ajhahn.de
← Theria commits

Commit

Theria

feat: add towers, a destructible nexus, and the win condition

ajhahnde · Jun 2026 · c624ab15ca866711def1204907e4eb0ce1b078eb · parent: e58ce53 · view on GitHub →

modified src/client/main.gd
@@ -10,6 +10,8 @@ extends Node2D
const HERO_SPEED := 320.0
const BOT_SPEED := 300.0
const HERO_HP := 600
const BOT_HP := 600
const HERO_TEAM := 0
const BOT_TEAM := 1
@@ -26,7 +28,14 @@ const LANE_COLOR := Color(0.5, 0.5, 0.55, 0.7)
const LANE_WIDTH := 28.0
const CAMP_COLOR := Color(0.45, 0.7, 0.45)
const CAMP_RADIUS := 60.0
const NEXUS_SIZE := Vector2(140.0, 140.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.
const HP_BAR_SIZE := Vector2(160.0, 26.0)
const HP_BAR_OFFSET := Vector2(-80.0, -150.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)
var _sim := SimCore.new()
var _bot := BotController.new()
@@ -35,8 +44,9 @@ var _bot_id: int = 0
func _ready() -> void:
_hero_id = _sim.add_entity(HERO_TEAM, MapData.spawn_for_team(HERO_TEAM), HERO_SPEED)
_bot_id = _sim.add_entity(BOT_TEAM, MapData.spawn_for_team(BOT_TEAM), BOT_SPEED)
_sim.spawn_structures()
_hero_id = _sim.add_entity(HERO_TEAM, MapData.spawn_for_team(HERO_TEAM), HERO_SPEED, HERO_HP)
_bot_id = _sim.add_entity(BOT_TEAM, MapData.spawn_for_team(BOT_TEAM), BOT_SPEED, BOT_HP)
queue_redraw()
@@ -61,16 +71,29 @@ func _draw_map() -> void:
draw_polyline(MapData.lane_path(lane, HERO_TEAM), LANE_COLOR, LANE_WIDTH)
for camp in MapData.JUNGLE_CAMPS:
draw_circle(camp, CAMP_RADIUS, CAMP_COLOR)
for team in MapData.NEXUS_POSITIONS.size():
var centre := MapData.nexus_for_team(team)
var rect := Rect2(centre - NEXUS_SIZE * 0.5, NEXUS_SIZE)
draw_rect(rect, _team_color(team), true)
## Draws the live world: towers and nexuses as squares, mobile units as circles,
## each with an HP bar. Structures and units share one entity list, so they all
## come straight from the authoritative state.
func _draw_entities() -> void:
for id in _sim.state.entities:
var entity: SimEntity = _sim.state.entities[id]
draw_circle(entity.position, ENTITY_RADIUS, _team_color(entity.team))
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)
else:
draw_circle(entity.position, ENTITY_RADIUS, _team_color(entity.team))
_draw_hp_bar(entity)
func _draw_hp_bar(entity: SimEntity) -> 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)
func _team_color(team: int) -> Color:
modified src/sim/map_data.gd
@@ -63,6 +63,18 @@ const JUNGLE_CAMPS: Array[Vector2] = [
Vector2(-500.0, 500.0),
]
## Defensive tower slots for team 0: two per lane, sitting on the lane segment
## that leaves team 0's base, so each corridor is guarded on the way in. Team 1's
## slots are these negated (see `tower_positions`), which — because the lane set
## is closed under point reflection — also land on the lanes, by team 1's base.
## Every slot lies exactly on a lane polyline segment and inside the bounds.
const TOWER_SLOTS_TEAM0: Array[Vector2] = [
Vector2(-1600.0, 800.0),
Vector2(-1600.0, -800.0),
Vector2(-800.0, 1600.0),
Vector2(800.0, 1600.0),
]
static func spawn_for_team(team: int) -> Vector2:
return TEAM_SPAWNS[team % TEAM_SPAWNS.size()]
@@ -72,6 +84,17 @@ static func nexus_for_team(team: int) -> Vector2:
return NEXUS_POSITIONS[team % NEXUS_POSITIONS.size()]
## The tower slots for `team`: team 0's stored slots, negated for team 1 so the
## two teams' defences are point reflections of each other. Returns a fresh copy
## so callers cannot mutate the stored geometry.
static func tower_positions(team: int) -> PackedVector2Array:
var slots := PackedVector2Array(TOWER_SLOTS_TEAM0)
if team % 2 == 1:
for i in slots.size():
slots[i] = -slots[i]
return slots
static func lane_count() -> int:
return LANES.size()
modified src/sim/sim_core.gd
@@ -11,22 +11,82 @@ extends RefCounted
const TICK_RATE := 60
const TICK_DELTA := 1.0 / TICK_RATE
## Tower combat tuning. A tower out-ranges and chips a unit that wanders into it,
## but takes many shots to kill — pressure, not an instant wall.
const TOWER_HP := 1000
const TOWER_DAMAGE := 50
const TOWER_RANGE := 500.0
const TOWER_COOLDOWN_TICKS := 60
## The nexus is a destructible structure with no attack of its own.
const NEXUS_HP := 2000
var state: SimState = SimState.new()
var _next_id: int = 1
## Creates an entity, registers it in the world, and returns its id.
func add_entity(team: int, position: Vector2, move_speed: float) -> int:
## Creates a mobile entity, registers it in the world, and returns its id.
## `hp` of 0 (the default) leaves the entity outside the combat system — it
## cannot be targeted, damaged, or killed.
func add_entity(team: int, position: Vector2, move_speed: float, hp: int = 0) -> int:
var entity := SimEntity.new(_next_id, team, position, move_speed)
entity.max_hp = hp
entity.hp = hp
return _register(entity)
## Creates a static structure (a tower or nexus) and returns its id.
func add_structure(
team: int,
position: Vector2,
hp: int,
attack_damage: int,
attack_range: float,
attack_cooldown_ticks: int,
is_nexus: bool = false,
) -> int:
var entity := SimEntity.new(_next_id, team, position, 0.0)
entity.max_hp = hp
entity.hp = hp
entity.attack_damage = attack_damage
entity.attack_range = attack_range
entity.attack_cooldown_ticks = attack_cooldown_ticks
entity.is_structure = true
entity.is_nexus = is_nexus
return _register(entity)
## Populates the arena's structures from the map: each team's lane towers plus
## its destructible nexus. Both teams' structures mirror through the origin, so
## the match starts mirror-fair.
func spawn_structures() -> void:
for team in MapData.NEXUS_POSITIONS.size():
for slot in MapData.tower_positions(team):
add_structure(team, slot, TOWER_HP, TOWER_DAMAGE, TOWER_RANGE, TOWER_COOLDOWN_TICKS)
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.
func step(inputs: Dictionary) -> void:
if state.is_match_over():
return
_step_movement(inputs)
_step_combat()
_resolve_deaths()
state.tick += 1
func _register(entity: SimEntity) -> int:
var id := _next_id
_next_id += 1
state.add_entity(SimEntity.new(id, team, position, move_speed))
state.add_entity(entity)
return id
## Advances the world by exactly one tick. `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.
func step(inputs: Dictionary) -> void:
func _step_movement(inputs: Dictionary) -> void:
for id in state.entities:
var entity: SimEntity = state.entities[id]
var command: InputCommand = inputs.get(id, null)
@@ -37,4 +97,52 @@ func step(inputs: Dictionary) -> void:
move_dir = move_dir.normalized()
entity.position += move_dir * entity.move_speed * TICK_DELTA
entity.position = MapData.clamp_to_bounds(entity.position)
state.tick += 1
## 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
## both land on a target this tick and it dies once, in `_resolve_deaths`.
func _step_combat() -> void:
for id in state.entities:
var attacker: SimEntity = state.entities[id]
if attacker.attack_damage <= 0:
continue
if attacker.cooldown > 0:
attacker.cooldown -= 1
if attacker.cooldown > 0:
continue
var target := _nearest_enemy_in_range(attacker)
if target == null:
continue
target.hp -= attacker.attack_damage
attacker.cooldown = attacker.attack_cooldown_ticks
func _nearest_enemy_in_range(attacker: SimEntity) -> SimEntity:
var nearest: SimEntity = null
var nearest_dist := INF
for id in state.entities:
var other: SimEntity = state.entities[id]
if other.team == attacker.team:
continue
if other.max_hp <= 0:
continue
var dist := attacker.position.distance_to(other.position)
if dist <= attacker.attack_range and dist < nearest_dist:
nearest_dist = dist
nearest = other
return nearest
func _resolve_deaths() -> void:
var dead: Array[int] = []
for id in state.entities:
var entity: SimEntity = state.entities[id]
if entity.max_hp > 0 and entity.hp <= 0:
dead.append(id)
for id in dead:
var entity: SimEntity = state.entities[id]
if entity.is_nexus and not state.is_match_over():
state.winner = 1 - entity.team
state.entities.erase(id)
modified src/sim/sim_entity.gd
@@ -1,14 +1,36 @@
class_name SimEntity
extends RefCounted
## A simulated actor (hero or bot) inside the authoritative world state.
## A simulated actor — a mobile unit (hero, bot) or a static structure (tower,
## nexus) — inside the authoritative world state.
##
## Plain data plus its tuning — no engine, render, or input coupling.
## Plain data plus its tuning — no engine, render, or input coupling. The combat
## fields are the shared primitive: a tower attacks with them now, and creeps and
## heroes reuse the same fields when those layers land.
var id: int = 0
var team: int = 0
var position: Vector2 = Vector2.ZERO
var move_speed: float = 0.0
## Health. An entity is damageable and killable only when `max_hp > 0`; pure
## movers (the v0.1 walking-skeleton entities) leave it at 0 and ignore combat.
var hp: int = 0
var max_hp: int = 0
## Attack tuning. An entity attacks only when `attack_damage > 0`: each time its
## `cooldown` reaches 0 it deals `attack_damage` to the nearest enemy within
## `attack_range`, then resets `cooldown` to `attack_cooldown_ticks`. Integer
## damage and a tick-counted cooldown keep combat deterministic.
var attack_damage: int = 0
var attack_range: float = 0.0
var attack_cooldown_ticks: int = 0
var cooldown: int = 0
## A structure is static (takes no movement input) and renders as a building.
## The nexus is the win anchor: destroying it ends the match for the other team.
var is_structure: bool = false
var is_nexus: bool = false
func _init(
p_id: int = 0,
modified src/sim/sim_state.gd
@@ -11,6 +11,10 @@ var tick: int = 0
## iteration over the world deterministic.
var entities: Dictionary = {}
## The team that has won, or -1 while the match is ongoing. Set when a team's
## nexus is destroyed; once set, the simulation freezes (`step` no-ops).
var winner: int = -1
func add_entity(entity: SimEntity) -> void:
entities[entity.id] = entity
@@ -18,3 +22,7 @@ func add_entity(entity: SimEntity) -> void:
func get_entity(id: int) -> SimEntity:
return entities.get(id, null)
func is_match_over() -> bool:
return winner != -1
modified test/unit/test_map_data.gd
@@ -60,3 +60,46 @@ func test_jungle_camps_are_closed_under_negation_and_in_bounds() -> void:
for camp in camps:
assert_eq(MapData.clamp_to_bounds(camp), camp, "every camp must sit inside the bounds")
assert_true(camps.has(-camp), "every camp must have a mirrored partner (negated)")
func test_tower_positions_are_point_symmetric_between_teams() -> void:
var team0 := MapData.tower_positions(0)
var team1 := MapData.tower_positions(1)
assert_eq(team0.size(), team1.size(), "both teams field the same number of towers")
for i in team0.size():
assert_eq(team1[i], -team0[i], "team 1's towers must be team 0's towers negated")
func test_tower_positions_returns_a_fresh_copy() -> void:
var first := MapData.tower_positions(0)
first[0] = Vector2.ZERO
var second := MapData.tower_positions(0)
assert_ne(first[0], second[0], "mutating a returned tower list must not alter the stored slots")
func test_every_tower_sits_on_a_lane_and_inside_the_bounds() -> void:
for team in MapData.NEXUS_POSITIONS.size():
for tower in MapData.tower_positions(team):
assert_eq(MapData.clamp_to_bounds(tower), tower, "every tower must sit inside the bounds")
var on_a_lane := false
for lane in MapData.lane_count():
if _point_on_polyline(tower, MapData.lane_path(lane, 0)):
on_a_lane = true
break
assert_true(on_a_lane, "every tower must sit on a lane corridor")
## True when `point` lies on one of the polyline's segments (within a small
## tolerance): the segment endpoints span it and it is collinear with them.
func _point_on_polyline(point: Vector2, path: PackedVector2Array) -> bool:
for i in path.size() - 1:
var a := path[i]
var b := path[i + 1]
var ab := b - a
var ap := point - a
if absf(ab.cross(ap)) > 0.01:
continue
var t := ap.dot(ab) / ab.length_squared()
if t >= 0.0 and t <= 1.0:
return true
return false
modified test/unit/test_sim_core.gd
@@ -51,3 +51,116 @@ func _run_scripted(ticks: int) -> Vector2:
command.move_dir = Vector2(sin(float(i)), cos(float(i)))
sim.step({id: command})
return sim.state.get_entity(id).position
# --- Combat: towers, structures, and the win condition ----------------------
func test_structure_strikes_an_enemy_in_range() -> void:
var sim := SimCore.new()
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({})
assert_eq(sim.state.get_entity(enemy).hp, 550, "an in-range enemy takes attack_damage")
func test_structure_ignores_an_enemy_out_of_range() -> void:
var sim := SimCore.new()
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({})
assert_eq(sim.state.get_entity(enemy).hp, 600, "an out-of-range enemy is untouched")
func test_structure_does_not_strike_an_ally() -> void:
var sim := SimCore.new()
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({})
assert_eq(sim.state.get_entity(ally).hp, 600, "an attacker never hits its own team")
func test_attack_respects_its_cooldown() -> void:
var sim := SimCore.new()
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:
sim.step({})
assert_eq(sim.state.get_entity(enemy).hp, 550, "one hit lands across a full cooldown window")
sim.step({})
assert_eq(sim.state.get_entity(enemy).hp, 500, "the cooldown elapses next tick, second hit lands")
func test_an_entity_dies_when_its_hp_reaches_zero() -> void:
var sim := SimCore.new()
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({})
assert_null(sim.state.get_entity(enemy), "an entity at 0 hp is removed from the world")
func test_nexus_destruction_sets_the_winner_and_freezes_the_match() -> void:
var sim := SimCore.new()
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)
sim.step({})
assert_true(sim.state.is_match_over(), "a destroyed nexus ends the match")
assert_eq(sim.state.winner, 1, "the other team wins")
var frozen_tick := sim.state.tick
sim.step({})
assert_eq(sim.state.tick, frozen_tick, "the simulation no-ops once the match is over")
func test_spawn_structures_is_mirror_fair() -> void:
var sim := SimCore.new()
sim.spawn_structures()
# Every team 0 structure must have a team 1 structure at the negated position
# with the same role and health, so neither side starts ahead.
for id in sim.state.entities:
var s: SimEntity = sim.state.entities[id]
if s.team != 0:
continue
var mirror := _structure_at(sim.state, 1, -s.position)
assert_not_null(mirror, "team 0's structure must have a mirrored team 1 counterpart")
if mirror != null:
assert_eq(mirror.is_nexus, s.is_nexus, "the mirrored structure must share its role")
assert_eq(mirror.max_hp, s.max_hp, "the mirrored structure must share its health")
func _structure_at(state: SimState, team: int, position: Vector2) -> SimEntity:
for id in state.entities:
var s: SimEntity = state.entities[id]
if s.team == team and s.is_structure and s.position.is_equal_approx(position):
return s
return null
func test_a_combat_run_replays_identically() -> void:
var a := _run_combat()
var b := _run_combat()
assert_eq(a, b, "combat must be a pure function of state + input")
func _run_combat() -> Array:
var sim := SimCore.new()
sim.spawn_structures()
var hero := sim.add_entity(0, MapData.spawn_for_team(0), 320.0, 600)
var bot := sim.add_entity(1, MapData.spawn_for_team(1), 300.0, 600)
var march := InputCommand.new()
march.move_dir = Vector2(1.0, -1.0) # walk both units toward the enemy base
for _i in 600:
sim.step({hero: march, bot: march})
return _snapshot(sim.state)
## A deterministic, comparable digest of the world: every surviving entity's id,
## hp, and rounded position, ordered by id.
func _snapshot(state: SimState) -> Array:
var ids := state.entities.keys()
ids.sort()
var rows: Array = []
for id in ids:
var entity: SimEntity = state.entities[id]
rows.append([id, entity.hp, entity.position.round()])
return rows