ajhahn.de
← Theria commits

Commit

Theria

feat: give heroes auto-attack and spawn them at base

ajhahnde · Jun 2026 · b270072edbd1a63abf80936e984fe5a79feb92c5 · parent: b44237d · view on GitHub →

modified CHANGELOG.md
@@ -26,6 +26,9 @@ protocol version.
### Added
- Combat heroes: the player's hero and the bot now auto-attack the nearest enemy
in range, so a hero can clear creep waves, pressure towers, duel the enemy hero,
and push a lane toward the nexus. Heroes spawn at their base fountain.
- 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
modified src/client/main.gd
@@ -10,8 +10,6 @@ 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
@@ -53,8 +51,8 @@ var _bot_id: int = 0
func _ready() -> void:
_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)
_hero_id = _sim.add_hero(HERO_TEAM, MapData.spawn_for_team(HERO_TEAM), HERO_SPEED)
_bot_id = _sim.add_hero(BOT_TEAM, MapData.spawn_for_team(BOT_TEAM), BOT_SPEED)
queue_redraw()
modified src/sim/map_data.gd
@@ -20,16 +20,9 @@ const NEXUS_POSITIONS: Array[Vector2] = [
Vector2(1600.0, -1600.0),
]
## Hero spawn position for each team, indexed by team id.
##
## These are the v0.1 walking-skeleton spawns near the centre, kept stable so the
## skeleton demo is undisturbed by this layer. Aligning spawns to a fountain at
## each base is a later slice (it lands with the client camera that frames the
## full map); the base anchor for that work is `NEXUS_POSITIONS`.
const TEAM_SPAWNS: Array[Vector2] = [
Vector2(-360.0, -200.0),
Vector2(360.0, 200.0),
]
## How far in front of the nexus a team's heroes spawn — a fountain pulled toward
## the map centre so a hero starts at its base without sitting on the nexus.
const FOUNTAIN_PULLBACK := 300.0
## Top corridor: out of team 0's base, up the left edge, across the top.
const LANE_TOP: Array[Vector2] = [
@@ -76,8 +69,12 @@ const TOWER_SLOTS_TEAM0: Array[Vector2] = [
]
## A team's hero spawn: its base fountain, set just in front of the nexus toward
## the map centre. Derived from `NEXUS_POSITIONS`, so the two teams' spawns mirror
## through the origin like the rest of the map.
static func spawn_for_team(team: int) -> Vector2:
return TEAM_SPAWNS[team % TEAM_SPAWNS.size()]
var nexus := nexus_for_team(team)
return nexus - nexus.normalized() * FOUNTAIN_PULLBACK
static func nexus_for_team(team: int) -> Vector2:
modified src/sim/sim_core.gd
@@ -40,6 +40,14 @@ const CREEP_SPAWN_SPACING := 80.0
## switches to the next one — large enough to round a corner without stalling.
const WAYPOINT_ARRIVE_RADIUS := 40.0
## Hero tuning. A hero out-hits a creep and out-ranges one, so a player can clear
## a wave, pressure a tower, and duel the enemy hero — but a tower still out-ranges
## and out-hits a lone hero, so diving one undefended is punished.
const HERO_HP := 600
const HERO_DAMAGE := 60
const HERO_RANGE := 250.0
const HERO_COOLDOWN_TICKS := 36
var state: SimState = SimState.new()
## Whether `step` spawns creep waves on its own clock. On for live play and the
@@ -91,6 +99,19 @@ func spawn_structures() -> void:
add_structure(team, MapData.nexus_for_team(team), NEXUS_HP, 0, 0.0, 0, true)
## Creates a hero — a player- or bot-driven mobile unit that fights with the
## shared combat primitive (it auto-strikes the nearest enemy in range) — and
## returns its id. `move_speed` is set by the driver; combat is fixed tuning.
func add_hero(team: int, position: Vector2, move_speed: float) -> int:
var entity := SimEntity.new(_next_id, team, position, move_speed)
entity.max_hp = HERO_HP
entity.hp = HERO_HP
entity.attack_damage = HERO_DAMAGE
entity.attack_range = HERO_RANGE
entity.attack_cooldown_ticks = HERO_COOLDOWN_TICKS
return _register(entity)
## 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:
modified test/unit/test_map_data.gd
@@ -12,6 +12,19 @@ func test_nexuses_are_point_symmetric_and_in_bounds() -> void:
assert_eq(MapData.clamp_to_bounds(n1), n1, "nexus 1 must sit inside the bounds")
func test_team_fountains_sit_at_base_and_mirror_through_the_origin() -> void:
var f0 := MapData.spawn_for_team(0)
var f1 := MapData.spawn_for_team(1)
assert_eq(f1, -f0, "team 1's fountain must be team 0's fountain negated")
assert_eq(MapData.clamp_to_bounds(f0), f0, "the fountain must sit inside the bounds")
# It is at base: closer to its own nexus than to the map centre.
var nexus := MapData.nexus_for_team(0)
assert_true(
f0.distance_to(nexus) < f0.length(),
"a fountain spawns at its base, not out near the centre",
)
func test_lane_path_orients_from_each_team_nexus_to_the_enemy_nexus() -> void:
for lane in MapData.lane_count():
var team0 := MapData.lane_path(lane, 0)
modified test/unit/test_sim_core.gd
@@ -155,8 +155,8 @@ func test_a_combat_run_replays_identically() -> void:
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 hero := sim.add_hero(0, MapData.spawn_for_team(0), 320.0)
var bot := sim.add_hero(1, MapData.spawn_for_team(1), 300.0)
var march := InputCommand.new()
march.move_dir = Vector2(1.0, -1.0) # walk both units toward the enemy base
for _i in 600:
@@ -263,6 +263,24 @@ func test_creep_waves_are_mirror_fair() -> void:
)
# --- Heroes: the player/bot combat unit -------------------------------------
func test_a_hero_strikes_an_enemy_in_range() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var hero := sim.add_hero(0, Vector2.ZERO, 320.0)
var enemy := sim.add_entity(1, Vector2(SimCore.HERO_RANGE - 10.0, 0.0), 0.0, 600)
sim.step({})
assert_eq(
sim.state.get_entity(enemy).hp,
600 - SimCore.HERO_DAMAGE,
"a hero auto-attacks an enemy in range through the shared combat primitive",
)
# A hero out-hits a creep: its damage exceeds a creep's, so it clears waves.
assert_true(SimCore.HERO_DAMAGE > SimCore.CREEP_DAMAGE, "a hero out-damages a creep")
func _count_creeps(state: SimState) -> int:
var n := 0
for id in state.entities: