GDScript 502 lines
class_name SimCore
extends RefCounted
## The server-authoritative simulation: a deterministic, side-effect-free,
## fixed-timestep step function.
##
## It owns its own clock (a constant tick delta) so the same core can be driven
## by the local game loop, a headless test, a bot, or — later — the network
## layer, and always advances identically for identical input. Keep it free of
## any rendering, engine-input, or global-state coupling.
const TICK_RATE := 60
const TICK_DELTA := 1.0 / TICK_RATE
## A hero's body radius for collision — how far its centre is kept from an obstacle's surface. One
## shared value for v1: the mobile non-creep units (heroes) collide as they move; lane creeps march
## uncollided so a wave is never jammed on its own forward tower, and structures never move. Read by
## `apply_movement` here and by the nav grid, so a routed path and the resolve agree.
const UNIT_RADIUS := 40.0
## 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
## 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 := 210.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
## 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
## How long a slain hero stays down before respawning at its spawn point, full health. A flat
## timer for now (8 s at the tick rate) — short enough that a death is a setback, not a sit-out;
## scaling it with match time is a later tuning pass. A dead hero is kept in the world (not
## erased like a creep) so its id, and this countdown, persist for the client's death screen.
const HERO_RESPAWN_TICKS := 8 * TICK_RATE
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
## 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 four towers (two ringing the
## nexus, two forward down the lanes) plus its destructible nexus. Both teams' structures mirror
## across the map's y = x axis, 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)
## 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.is_hero = true # a hero from birth, so death downs-and-respawns it even before a kit
entity.max_hp = HERO_HP
entity.hp = HERO_HP
entity.spawn_position = position # where it returns after a death
entity.attack_damage = HERO_DAMAGE
entity.attack_range = HERO_RANGE
entity.attack_cooldown_ticks = HERO_COOLDOWN_TICKS
return _register(entity)
## Turns an already-spawned hero into an ability caster by equipping a kit from the
## catalog. The hero starts in human form with that form's resource pool full; the
## animal pool waits for the first transform. Kept separate from `add_hero` so a
## bare walking-skeleton hero — and the netcode that spawns one — is unchanged until
## a kit is equipped. A no-op for an unknown hero id or kit.
func equip_kit(hero_id: int, kit_id: String) -> void:
var hero := state.get_entity(hero_id)
if hero == null:
return
var kit_def := AbilityData.kit(kit_id)
if kit_def.is_empty():
return
var res: Dictionary = kit_def["resource"]
hero.is_hero = true
hero.form = AbilitySpec.FORM_HUMAN
hero.stance = kit_def.get("stance", AbilityData.STANCE_BRAWL)
hero.kit_id = kit_id
hero.kit = (kit_def["abilities"] as Dictionary).duplicate(true)
hero.form_resource_max = PackedInt32Array(
[res[AbilitySpec.FORM_HUMAN]["max"], res[AbilitySpec.FORM_ANIMAL]["max"]]
)
hero.form_resource_regen = PackedInt32Array(
[res[AbilitySpec.FORM_HUMAN]["regen_ticks"], res[AbilitySpec.FORM_ANIMAL]["regen_ticks"]]
)
hero.resource_max = hero.form_resource_max[AbilitySpec.FORM_HUMAN]
hero.resource_regen_ticks = hero.form_resource_regen[AbilitySpec.FORM_HUMAN]
hero.resource = hero.resource_max
hero.resource_regen_counter = 0
hero.ability_cooldowns = {}
## 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, revive the dead, 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:
state.fx_events.clear() # this tick's cast FX only — cleared even on a no-op tick
state.hit_events.clear() # this tick's damage numbers
state.attack_events.clear() # this tick's auto-attack strikes
if state.is_match_over():
return
_step_spawning()
_step_respawns()
_step_movement(inputs)
_step_creeps()
_step_statuses()
_step_abilities(inputs)
_step_combat()
_resolve_deaths()
state.tick += 1
func _register(entity: SimEntity) -> int:
var id := _next_id
_next_id += 1
state.add_entity(entity)
return id
func _step_movement(inputs: Dictionary) -> void:
for id in state.entities:
var entity: SimEntity = state.entities[id]
apply_movement(entity, inputs.get(id, null))
## Advances one entity by a single tick of movement intent: the pure movement
## sub-step, with the diagonal-speed clamp and the bounds clamp. A `null` command
## holds the entity still. The authoritative `_step_movement` runs it over every
## entity; the client's prediction/replay runs it over its own hero alone — so the
## server and a predicting client move a unit by byte-identical math, which is what
## lets client-side reconciliation land exactly on the authoritative position.
static func apply_movement(entity: SimEntity, command: InputCommand) -> void:
if entity.is_dead():
return # a downed hero holds where it fell — server and the client's prediction alike
var move_dir := Vector2.ZERO
if command != null:
move_dir = command.move_dir
if move_dir.length() > 1.0:
move_dir = move_dir.normalized()
var from := entity.position
entity.position += move_dir * entity.current_move_speed() * TICK_DELTA
entity.position = MapData.clamp_to_bounds(entity.position)
# Resolve a moving unit out of the solid obstacles, keeping the tangential slide along them. The
# gate is the same "mobile, non-creep" predicate the client identifies its hero by (main.gd
# `_local_hero`), so the decoded snapshot the client predicts on — which carries no is_hero flag —
# runs byte-identical math to the server and reconciliation lands exactly. Lane creeps march
# uncollided (so a wave never jams on its own forward tower) and a still unit is never shoved off
# its spot — collision resolves movement, not placement.
if move_dir != Vector2.ZERO and not entity.is_structure and not entity.is_creep:
entity.position = MapData.slide(from, entity.position, UNIT_RADIUS)
## 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
## each lane is its own reflection across the y = x axis, the two teams' waves
## mirror across that axis.
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 creep.is_stunned():
continue # a stunned creep holds its ground — no march this tick
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)
## Ages every active status by one tick and applies a venom DOT's bite. For each
## entity carrying a status: a DOT advances its interval counter and, on each interval,
## subtracts its damage; every status counts its duration down and is dropped when it
## expires. A SLOW and a STUN do nothing here — the movement, cast, and combat steps read
## the live status off the entity each tick — they only age out. Runs before the cast step
## (upkeep first, like resource
## regen) so a status applied this tick begins aging next tick, and before
## `_resolve_deaths` so a lethal DOT, an auto-attack, and an ability all reconcile in
## the one death pass. Pure and insertion-ordered over entities and each entity's
## statuses, so it replays identically.
func _step_statuses() -> void:
for id in state.entities:
var entity: SimEntity = state.entities[id]
if entity.statuses.is_empty():
continue
var expired: Array[int] = []
for kind in entity.statuses:
var s: Dictionary = entity.statuses[kind]
if kind == AbilitySpec.STATUS_DOT:
s["counter"] += 1
if s["counter"] >= s["interval"]:
s["counter"] = 0
entity.hp -= s["power"]
_record_damage(entity, s["power"])
s["remaining"] -= 1
if s["remaining"] <= 0:
expired.append(kind)
for kind in expired:
entity.statuses.erase(kind)
## Advances the ability layer one tick. First every hero's passive upkeep —
## resource regen and cooldown decay — which runs regardless of input so pools refill
## and cooldowns elapse while idle. Then any casts requested this tick: a cast is
## gated through AbilityExecutor.can_cast (form, resource, cooldown) and, on success,
## applied and its cost booked. Runs before `_step_combat` so an ability and an
## auto-attack that both finish a unit this tick are reconciled in one death pass.
## Pure and insertion-ordered like the rest of the step.
func _step_abilities(inputs: Dictionary) -> void:
for id in state.entities:
var hero: SimEntity = state.entities[id]
if not hero.is_hero or hero.is_dead():
continue # a dead hero neither regens nor decays cooldowns until it respawns
_regen_resource(hero)
_tick_cooldowns(hero)
for id in inputs:
var command: InputCommand = inputs[id]
if command == null or command.ability_slot < 0:
continue
var hero: SimEntity = state.get_entity(id)
if hero != null and hero.is_hero and not hero.is_dead():
_try_cast(hero, command)
## Restores one resource point once `resource_regen_ticks` ticks have elapsed,
## capped at the form's max. Integer regen on a tick interval keeps the pool
## deterministic; a form with no regen (or a full pool) is left alone.
func _regen_resource(hero: SimEntity) -> void:
if hero.resource_regen_ticks <= 0 or hero.resource >= hero.resource_max:
return
hero.resource_regen_counter += 1
if hero.resource_regen_counter >= hero.resource_regen_ticks:
hero.resource_regen_counter = 0
hero.resource = mini(hero.resource + 1, hero.resource_max)
## Ticks every live ability cooldown down by one. Keyed by ability id, so a cooldown
## set in one form keeps elapsing while the hero is in the other.
func _tick_cooldowns(hero: SimEntity) -> void:
for ability_id in hero.ability_cooldowns:
var remaining: int = hero.ability_cooldowns[ability_id]
if remaining > 0:
hero.ability_cooldowns[ability_id] = remaining - 1
## Resolves the requested slot to an ability of the hero's active form and casts it
## if it is castable. An empty slot, an off-form slot, or a failed gate is a no-op.
func _try_cast(hero: SimEntity, command: InputCommand) -> void:
var slots: Dictionary = hero.kit.get(hero.form, {})
var ability_id: int = slots.get(command.ability_slot, 0)
if ability_id == 0 or not AbilityData.has_ability(ability_id):
return
var spec := AbilityData.spec(ability_id)
if AbilityExecutor.can_cast(hero, spec):
AbilityExecutor.execute(state, hero, spec, command)
## 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.is_dead():
continue # a downed hero stops fighting until it respawns
if attacker.is_stunned():
continue # a locked unit neither strikes nor ticks its cooldown down
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
_record_attack_fx(attacker, target)
## Records an auto-attack for the renderer: a strike from `attacker` to `target`, flagged
## ranged (the renderer flies a projectile) or melee (a close-in impact), plus the damage
## number over the target. A structure or a kiting hero fires; everything else — creeps and
## brawler heroes — hits melee.
func _record_attack_fx(attacker: SimEntity, target: SimEntity) -> void:
var ranged := (
attacker.is_structure
or (attacker.is_hero and attacker.stance == AbilityData.STANCE_KITE)
)
state.attack_events.append(
{"origin": attacker.position, "target": target.position, "ranged": ranged}
)
_record_damage(target, attacker.attack_damage)
## Notes `amount` of damage on a struck entity for the floating-number renderer. A pure
## presentation hint — like `fx_events`, it never feeds the sim or crosses the wire.
func _record_damage(entity: SimEntity, amount: int) -> void:
state.hit_events.append({"position": entity.position, "amount": amount})
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 or other.is_dead():
continue # non-combat entities and downed heroes are not valid targets
var dist := attacker.position.distance_to(other.position)
if dist <= attacker.attack_range and dist < nearest_dist:
nearest_dist = dist
nearest = other
return nearest
## Reconciles every unit brought to 0 hp this tick. A creep or a structure is erased (a
## felled nexus first deciding the match); a hero is kept in the world but downed —
## marked dead and put on the respawn clock — so its id, position, and countdown persist
## for the client's death screen and `_step_respawns` can revive it in place. A hero
## already counting down is skipped, so it is downed once, not re-killed every tick.
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 and not entity.is_dead():
dead.append(id)
for id in dead:
var entity: SimEntity = state.entities[id]
if entity.is_hero:
_down_hero(entity)
continue
if entity.is_nexus and not state.is_match_over():
state.winner = 1 - entity.team
state.entities.erase(id)
## Puts a slain hero on the respawn clock instead of erasing it: hp pinned to 0, the
## respawn timer started, and any lingering statuses and auto-attack cooldown cleared so
## nothing carries over the death. `is_dead` now reads true, which makes every acting and
## targeting step skip it until `_step_respawns` revives it.
func _down_hero(hero: SimEntity) -> void:
hero.hp = 0
hero.respawn_ticks = HERO_RESPAWN_TICKS
hero.statuses.clear()
hero.cooldown = 0
## Counts every downed hero's respawn timer down by one tick and revives the hero the tick
## it elapses. Runs near the top of the step so a hero that comes back this tick is alive for
## the rest of it. Pure and insertion-ordered like every other step.
func _step_respawns() -> void:
for id in state.entities:
var hero: SimEntity = state.entities[id]
if not hero.is_dead():
continue
hero.respawn_ticks -= 1
if hero.respawn_ticks <= 0:
_respawn_hero(hero)
## Revives a hero at its spawn point with a full health bar, back in human form with a full
## resource pool and every cooldown cleared — a clean slate, as if freshly seated. `respawn_ticks`
## lands at 0, so `is_dead` reads false and the hero acts again from this tick. A hero with no kit
## (the bare walking skeleton) has empty resource tuning, so the pool simply stays 0.
func _respawn_hero(hero: SimEntity) -> void:
hero.respawn_ticks = 0
hero.position = hero.spawn_position
hero.hp = hero.max_hp
hero.cooldown = 0
hero.statuses.clear()
hero.ability_cooldowns.clear()
hero.form = AbilitySpec.FORM_HUMAN
hero.resource_max = hero.form_resource_max[AbilitySpec.FORM_HUMAN]
hero.resource_regen_ticks = hero.form_resource_regen[AbilitySpec.FORM_HUMAN]
hero.resource = hero.resource_max
hero.resource_regen_counter = 0