Commit
Theria
feat: add lane creeps that march, fight, and siege the nexus
modified CHANGELOG.md
@@ -26,6 +26,10 @@ protocol version.
### Added
- Lane creeps: each team spawns periodic creep waves that march their lane
toward the enemy base, stop to fight whatever they meet, siege towers, and can
drive an undefended nexus to destruction — making the win condition reachable
in live play.
- Towers and a destructible nexus: structures auto-attack the nearest enemy in
range, units and structures carry health and can be destroyed, and a match
ends when a team's nexus falls.
modified src/client/main.gd
@@ -19,6 +19,11 @@ const HERO_COLOR := Color(0.36, 0.66, 1.0)
const BOT_COLOR := Color(1.0, 0.42, 0.38)
const ENTITY_RADIUS := 44.0
## Creeps render as small, darkened team-coloured circles so a wave reads as a
## cluster distinct from the larger heroes.
const CREEP_RADIUS := 22.0
const CREEP_DARKEN := 0.3
## Map debug-draw styling. World-unit sizes, tuned to read at the camera's
## zoomed-out framing of the whole arena.
const FIELD_COLOR := Color(0.114, 0.125, 0.145)
@@ -31,9 +36,12 @@ const CAMP_RADIUS := 60.0
const TOWER_SIZE := Vector2(110.0, 110.0)
const NEXUS_SIZE := Vector2(200.0, 200.0)
## HP bar, drawn above any entity that carries health.
## HP bar, drawn above any entity that carries health. Creeps get a compact bar
## scaled to their smaller footprint.
const HP_BAR_SIZE := Vector2(160.0, 26.0)
const HP_BAR_OFFSET := Vector2(-80.0, -150.0)
const CREEP_HP_BAR_SIZE := Vector2(70.0, 12.0)
const CREEP_HP_BAR_OFFSET := Vector2(-35.0, -55.0)
const HP_BAR_BG := Color(0.0, 0.0, 0.0, 0.6)
const HP_BAR_FG := Color(0.4, 0.85, 0.4)
@@ -82,18 +90,22 @@ func _draw_entities() -> void:
if entity.is_structure:
var size := NEXUS_SIZE if entity.is_nexus else TOWER_SIZE
draw_rect(Rect2(entity.position - size * 0.5, size), _team_color(entity.team), true)
_draw_hp_bar(entity, HP_BAR_SIZE, HP_BAR_OFFSET)
elif entity.is_creep:
draw_circle(entity.position, CREEP_RADIUS, _team_color(entity.team).darkened(CREEP_DARKEN))
_draw_hp_bar(entity, CREEP_HP_BAR_SIZE, CREEP_HP_BAR_OFFSET)
else:
draw_circle(entity.position, ENTITY_RADIUS, _team_color(entity.team))
_draw_hp_bar(entity)
_draw_hp_bar(entity, HP_BAR_SIZE, HP_BAR_OFFSET)
func _draw_hp_bar(entity: SimEntity) -> void:
func _draw_hp_bar(entity: SimEntity, size: Vector2, offset: Vector2) -> void:
if entity.max_hp <= 0:
return
var frac := clampf(float(entity.hp) / float(entity.max_hp), 0.0, 1.0)
var top_left := entity.position + HP_BAR_OFFSET
draw_rect(Rect2(top_left, HP_BAR_SIZE), HP_BAR_BG, true)
draw_rect(Rect2(top_left, Vector2(HP_BAR_SIZE.x * frac, HP_BAR_SIZE.y)), HP_BAR_FG, true)
var top_left := entity.position + offset
draw_rect(Rect2(top_left, size), HP_BAR_BG, true)
draw_rect(Rect2(top_left, Vector2(size.x * frac, size.y)), HP_BAR_FG, true)
func _team_color(team: int) -> Color:
modified src/sim/sim_core.gd
@@ -21,7 +21,32 @@ const TOWER_COOLDOWN_TICKS := 60
## The nexus is a destructible structure with no attack of its own.
const NEXUS_HP := 2000
## Creep tuning. A creep is a fragile melee unit — it dies in a few tower shots,
## but a wave pushing together trades, sieges, and (unopposed) fells a nexus.
const CREEP_HP := 100
const CREEP_DAMAGE := 20
const CREEP_RANGE := 150.0
const CREEP_COOLDOWN_TICKS := 30
const CREEP_SPEED := 180.0
## Wave cadence. Both teams spawn a wave per lane every interval, the first at
## tick 0. Creeps within a wave are strung along the lane so they file out of the
## base rather than stacking on one point.
const CREEP_WAVE_INTERVAL_TICKS := 600
const CREEP_PER_WAVE := 3
const CREEP_SPAWN_SPACING := 80.0
## How close (world units) a creep must come to its target waypoint before it
## switches to the next one — large enough to round a corner without stalling.
const WAYPOINT_ARRIVE_RADIUS := 40.0
var state: SimState = SimState.new()
## Whether `step` spawns creep waves on its own clock. On for live play and the
## integration tests; focused unit tests switch it off to stay isolated from the
## wave schedule and creep tuning.
var spawn_creeps: bool = true
var _next_id: int = 1
@@ -66,14 +91,32 @@ func spawn_structures() -> void:
add_structure(team, MapData.nexus_for_team(team), NEXUS_HP, 0, 0.0, 0, true)
## Advances the world by exactly one tick: movement, then combat, then deaths.
## `inputs` maps an entity id to its InputCommand; an entity with no command
## holds still. Pure: the result is a function of the prior state and `inputs`
## only. Once a nexus has fallen the match is over and the step is a no-op.
## Creates a lane creep at `position` and returns its id. The creep marches
## `lane` toward the enemy nexus and fights with the shared combat primitive.
func add_creep(team: int, lane: int, position: Vector2) -> int:
var entity := SimEntity.new(_next_id, team, position, CREEP_SPEED)
entity.max_hp = CREEP_HP
entity.hp = CREEP_HP
entity.attack_damage = CREEP_DAMAGE
entity.attack_range = CREEP_RANGE
entity.attack_cooldown_ticks = CREEP_COOLDOWN_TICKS
entity.is_creep = true
entity.lane = lane
entity.waypoint_index = 1 # heading for the second waypoint; the first is the spawn nexus
return _register(entity)
## Advances the world by exactly one tick: spawn waves, move the input-driven
## units, march the creeps, resolve combat, then deaths. `inputs` maps an entity
## id to its InputCommand; an entity with no command holds still. Pure: the
## result is a function of the prior state and `inputs` only (creep waves spawn
## off `state.tick`). Once a nexus has fallen the match is over and step no-ops.
func step(inputs: Dictionary) -> void:
if state.is_match_over():
return
_step_spawning()
_step_movement(inputs)
_step_creeps()
_step_combat()
_resolve_deaths()
state.tick += 1
@@ -99,6 +142,58 @@ func _step_movement(inputs: Dictionary) -> void:
entity.position = MapData.clamp_to_bounds(entity.position)
## On a wave tick, spawns one creep wave per team per lane. Driven off
## `state.tick` so wave timing is part of the authoritative, replayable state.
func _step_spawning() -> void:
if not spawn_creeps:
return
if state.tick % CREEP_WAVE_INTERVAL_TICKS != 0:
return
for team in MapData.NEXUS_POSITIONS.size():
for lane in MapData.lane_count():
_spawn_wave(team, lane)
## Spawns `CREEP_PER_WAVE` creeps for `team` on `lane`, strung forward along the
## first lane segment so they file out of the base instead of stacking. Because
## the lanes are point-symmetric, the two teams' waves mirror through the origin.
func _spawn_wave(team: int, lane: int) -> void:
var path := MapData.lane_path(lane, team)
var origin := path[0]
var forward := Vector2.ZERO
if path.size() > 1:
forward = (path[1] - origin).normalized()
for i in CREEP_PER_WAVE:
add_creep(team, lane, origin + forward * (CREEP_SPAWN_SPACING * float(i + 1)))
## Marches every creep along its lane toward the enemy nexus. A creep holds
## position while any enemy is within its attack range (the combat step then
## strikes), otherwise it advances toward its current waypoint, switching to the
## next once it arrives. Movement is capped at the per-tick step so it never
## overshoots — deterministic and replayable like the rest of the core.
func _step_creeps() -> void:
for id in state.entities:
var creep: SimEntity = state.entities[id]
if not creep.is_creep:
continue
if _nearest_enemy_in_range(creep) != null:
continue
var path := MapData.lane_path(creep.lane, creep.team)
if creep.waypoint_index >= path.size():
creep.waypoint_index = path.size() - 1
var target := path[creep.waypoint_index]
var to_target := target - creep.position
var dist := to_target.length()
var step_dist := creep.move_speed * TICK_DELTA
if dist > 0.0:
creep.position += to_target / dist * minf(step_dist, dist)
if creep.position.distance_to(target) <= WAYPOINT_ARRIVE_RADIUS:
if creep.waypoint_index < path.size() - 1:
creep.waypoint_index += 1
creep.position = MapData.clamp_to_bounds(creep.position)
## Every attacker ticks its cooldown down; when it hits 0 and an enemy is in
## range, it strikes the nearest one and the cooldown resets. Damage is applied
## to the shared entity in deterministic insertion order, so two attackers can
modified src/sim/sim_entity.gd
@@ -31,6 +31,14 @@ var cooldown: int = 0
var is_structure: bool = false
var is_nexus: bool = false
## A creep is an AI-driven mobile unit that marches a lane and fights on contact.
## It takes no player input: `lane` selects which corridor it walks and
## `waypoint_index` is the index of the lane waypoint it is currently heading
## for, advancing as it arrives until it reaches the enemy nexus.
var is_creep: bool = false
var lane: int = 0
var waypoint_index: int = 0
func _init(
p_id: int = 0,
modified test/unit/test_sim_core.gd
@@ -6,6 +6,7 @@ extends GutTest
func test_constant_input_advances_position_deterministically() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false # isolate the movement assertion from the wave schedule
var id := sim.add_entity(0, Vector2.ZERO, 300.0)
var command := InputCommand.new()
command.move_dir = Vector2.RIGHT
@@ -27,6 +28,7 @@ func test_identical_input_replays_identically() -> void:
func test_diagonal_input_is_not_faster() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var speed := 300.0
var id := sim.add_entity(0, Vector2.ZERO, speed)
var command := InputCommand.new()
@@ -38,6 +40,7 @@ func test_diagonal_input_is_not_faster() -> void:
func test_entity_without_command_holds_still() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := sim.add_entity(0, Vector2(10.0, -5.0), 300.0)
sim.step({})
assert_eq(sim.state.get_entity(id).position, Vector2(10.0, -5.0))
@@ -45,6 +48,7 @@ func test_entity_without_command_holds_still() -> void:
func _run_scripted(ticks: int) -> Vector2:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := sim.add_entity(0, Vector2.ZERO, 250.0)
var command := InputCommand.new()
for i in ticks:
@@ -58,6 +62,7 @@ func _run_scripted(ticks: int) -> Vector2:
func test_structure_strikes_an_enemy_in_range() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
sim.add_structure(0, Vector2.ZERO, 1000, 50, 200.0, 60)
var enemy := sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 600)
sim.step({})
@@ -66,6 +71,7 @@ func test_structure_strikes_an_enemy_in_range() -> void:
func test_structure_ignores_an_enemy_out_of_range() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
sim.add_structure(0, Vector2.ZERO, 1000, 50, 200.0, 60)
var enemy := sim.add_entity(1, Vector2(300.0, 0.0), 0.0, 600)
sim.step({})
@@ -74,6 +80,7 @@ func test_structure_ignores_an_enemy_out_of_range() -> void:
func test_structure_does_not_strike_an_ally() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
sim.add_structure(0, Vector2.ZERO, 1000, 50, 200.0, 60)
var ally := sim.add_entity(0, Vector2(100.0, 0.0), 0.0, 600)
sim.step({})
@@ -82,6 +89,7 @@ func test_structure_does_not_strike_an_ally() -> void:
func test_attack_respects_its_cooldown() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
sim.add_structure(0, Vector2.ZERO, 1000, 50, 200.0, 60)
var enemy := sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 600)
for _i in 60:
@@ -93,6 +101,7 @@ func test_attack_respects_its_cooldown() -> void:
func test_an_entity_dies_when_its_hp_reaches_zero() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
sim.add_structure(0, Vector2.ZERO, 1000, 100, 200.0, 60)
var enemy := sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 100)
sim.step({})
@@ -101,6 +110,7 @@ func test_an_entity_dies_when_its_hp_reaches_zero() -> void:
func test_nexus_destruction_sets_the_winner_and_freezes_the_match() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
sim.add_structure(0, Vector2.ZERO, 100, 0, 0.0, 0, true) # team 0 nexus
# A team 1 attacker in range (a stand-in for the creeps that arrive next).
sim.add_structure(1, Vector2(100.0, 0.0), 1000, 100, 200.0, 60)
@@ -164,3 +174,106 @@ func _snapshot(state: SimState) -> Array:
var entity: SimEntity = state.entities[id]
rows.append([id, entity.hp, entity.position.round()])
return rows
# --- Creeps: lane marching, contact combat, and the wave schedule -----------
func test_a_creep_marches_its_lane_toward_the_enemy_nexus() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var path := MapData.lane_path(0, 0)
var creep := sim.add_creep(0, 0, path[0])
var start := sim.state.get_entity(creep).position
for _i in SimCore.TICK_RATE:
sim.step({})
var here := sim.state.get_entity(creep).position
assert_true(here.distance_to(start) > 0.0, "a creep with a clear lane keeps moving")
assert_true(
here.distance_to(path[1]) < start.distance_to(path[1]),
"it advances toward its next waypoint",
)
func test_a_creep_holds_position_to_fight_an_enemy_in_range() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var spawn := MapData.lane_path(0, 0)[0]
var creep := sim.add_creep(0, 0, spawn)
# An enemy parked just inside the creep's reach: the creep must stop to fight.
var enemy := sim.add_entity(1, spawn + Vector2(SimCore.CREEP_RANGE - 10.0, 0.0), 0.0, 600)
sim.step({})
assert_eq(
sim.state.get_entity(creep).position,
spawn,
"a creep with an enemy in range holds to fight",
)
assert_eq(
sim.state.get_entity(enemy).hp,
600 - SimCore.CREEP_DAMAGE,
"and strikes it through the shared combat primitive",
)
func test_an_unopposed_creep_destroys_the_enemy_nexus_and_wins() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
# A team-1 nexus weak enough to fall to two creep hits, and a lone team-0 creep
# already in range — the win condition driven entirely by a creep.
var nexus := sim.add_structure(1, Vector2.ZERO, SimCore.CREEP_DAMAGE * 2, 0, 0.0, 0, true)
sim.add_creep(0, 0, Vector2(SimCore.CREEP_RANGE - 10.0, 0.0))
for _i in SimCore.CREEP_COOLDOWN_TICKS + 2:
sim.step({})
assert_null(sim.state.get_entity(nexus), "the creep's strikes destroy the enemy nexus")
assert_true(sim.state.is_match_over(), "felling the nexus ends the match")
assert_eq(sim.state.winner, 0, "the creep's team wins")
func test_creep_waves_spawn_on_the_wave_schedule() -> void:
var sim := SimCore.new() # spawn_creeps defaults on
var per_wave := SimCore.CREEP_PER_WAVE * MapData.lane_count() * MapData.NEXUS_POSITIONS.size()
sim.step({}) # tick 0 -> the opening wave
assert_eq(
_count_creeps(sim.state),
per_wave,
"a full wave spawns for both teams on every lane at tick 0",
)
# Clear the wave so the two teams' creeps can't clash and confound the count,
# leaving the schedule the only thing that adds creeps.
for id in sim.state.entities.keys():
if sim.state.entities[id].is_creep:
sim.state.entities.erase(id)
for _i in SimCore.CREEP_WAVE_INTERVAL_TICKS - 1:
sim.step({})
assert_eq(_count_creeps(sim.state), 0, "no wave spawns between intervals")
sim.step({}) # the next interval boundary
assert_eq(_count_creeps(sim.state), per_wave, "the next wave spawns on the interval")
func test_creep_waves_are_mirror_fair() -> void:
var sim := SimCore.new()
sim.step({}) # spawn and advance the opening waves one tick
for id in sim.state.entities:
var creep: SimEntity = sim.state.entities[id]
if not creep.is_creep or creep.team != 0:
continue
assert_not_null(
_creep_at(sim.state, 1, -creep.position),
"every team-0 creep has a team-1 creep mirrored through the origin",
)
func _count_creeps(state: SimState) -> int:
var n := 0
for id in state.entities:
if state.entities[id].is_creep:
n += 1
return n
func _creep_at(state: SimState, team: int, position: Vector2) -> SimEntity:
for id in state.entities:
var creep: SimEntity = state.entities[id]
if creep.is_creep and creep.team == team and creep.position.is_equal_approx(position):
return creep
return null