ajhahn.de
← Theria
GDScript 183 lines
class_name SimEntity
extends RefCounted
## 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. 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

## 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

## A hero is the player/bot unit that, on top of the shared auto-attack, casts
## abilities. The ability layer is inert until `is_hero` is set and a kit equipped
## (see SimCore.equip_kit); a hero with no kit just auto-attacks like before.
var is_hero: bool = false

## Where this hero respawns after dying — its spawn point, set once at creation. Sim-only
## (never serialized): respawn is resolved server-side, so a client never needs it.
var spawn_position: Vector2 = Vector2.ZERO

## Ticks until this hero respawns, counted down each tick. 0 for a living unit; set to
## SimCore.HERO_RESPAWN_TICKS the tick the hero dies, the hero blinking back at full HP when it
## reaches 0. Only a hero ever carries it — creeps and structures are erased on death, a hero is
## kept and revived — so `is_dead` reads it as the dead/alive flag. Serialized so a client can
## raise its own death screen and tick down the respawn countdown from its hero's snapshot.
var respawn_ticks: int = 0

## The active shapeshifter form (AbilitySpec.FORM_HUMAN / FORM_ANIMAL). Only the
## abilities of the active form are castable; a TRANSFORM ability flips it.
var form: int = 0

## How a bot positions this hero (AbilityData.STANCE_*), set from the kit at equip.
## BRAWL (the default) closes to land a hit and shifts toward whichever form reaches;
## KITE holds the kit's ranged poke and keeps an enemy at arm's length. Ignored for a
## player-driven hero, which positions itself — a player hero just leaves it at BRAWL.
var stance: int = 0

## The current form's resource pool: `resource` spent to cast (gated against
## `resource_max`), refilled by one point every `resource_regen_ticks` ticks
## (0 = no regen), counted by `resource_regen_counter`. The two forms keep separate
## pools — `form_resource_max[form]` / `form_resource_regen[form]` hold each form's
## tuning, and a transform swaps the active values to the destination form's.
var resource: int = 0
var resource_max: int = 0
var resource_regen_ticks: int = 0
var resource_regen_counter: int = 0
var form_resource_max: PackedInt32Array = PackedInt32Array([0, 0])
var form_resource_regen: PackedInt32Array = PackedInt32Array([0, 0])

## Remaining cooldown in ticks per ability id (absent/0 = ready). Keyed by ability
## id rather than slot, so a cooldown set in one form is still ticking when the hero
## transforms back to it.
var ability_cooldowns: Dictionary = {}

## The kit this hero was equipped with (an AbilityData kit id), or "" for a non-hero
## or an unequipped hero. The hero's identity: the renderer tints each kit distinctly,
## so squadmates sharing a team read apart while keeping the team hue. Set at
## SimCore.equip_kit; not carried on the wire — the networked duel is one hero per
## team, already distinguished by team colour.
var kit_id: String = ""

## The hero's bar, by form: `kit[form][slot]` is the ability id in that slot, or
## absent for an empty slot. Set once when the kit is equipped; the catalog holds
## the immutable specs the ids resolve to.
var kit: Dictionary = {}

## Active status effects (venom DOT, web SLOW, a hard STUN) left on this entity by
## abilities that struck it, keyed by status kind (AbilitySpec.STATUS_*) so there is one
## instance per kind — a re-application refreshes it. Each value holds `power`,
## `remaining` ticks, the DOT `interval`, and its `counter` (a SLOW reads only `power`, a
## STUN only `remaining`). Empty for every entity carrying no status (towers, creeps, an
## unharmed hero), so the status layer is inert until something is laid on.
## SimCore.`_step_statuses` ages and ticks these; insertion order keeps the pass
## deterministic.
var statuses: Dictionary = {}


func _init(
	p_id: int = 0,
	p_team: int = 0,
	p_pos: Vector2 = Vector2.ZERO,
	p_speed: float = 0.0,
) -> void:
	id = p_id
	team = p_team
	position = p_pos
	move_speed = p_speed


## Returns a field-for-field copy of this entity. The client's snapshot
## interpolation uses it to build a render entity at an in-between position without
## mutating the buffered authoritative snapshots it derives from.
func clone() -> SimEntity:
	var copy := SimEntity.new(id, team, position, move_speed)
	copy.hp = hp
	copy.max_hp = max_hp
	copy.attack_damage = attack_damage
	copy.attack_range = attack_range
	copy.attack_cooldown_ticks = attack_cooldown_ticks
	copy.cooldown = cooldown
	copy.is_structure = is_structure
	copy.is_nexus = is_nexus
	copy.is_creep = is_creep
	copy.lane = lane
	copy.waypoint_index = waypoint_index
	copy.is_hero = is_hero
	copy.spawn_position = spawn_position
	copy.respawn_ticks = respawn_ticks
	copy.form = form
	copy.stance = stance
	copy.kit_id = kit_id
	copy.resource = resource
	copy.resource_max = resource_max
	copy.resource_regen_ticks = resource_regen_ticks
	copy.resource_regen_counter = resource_regen_counter
	copy.form_resource_max = form_resource_max.duplicate()
	copy.form_resource_regen = form_resource_regen.duplicate()
	copy.ability_cooldowns = ability_cooldowns.duplicate()
	copy.kit = kit.duplicate(true)
	copy.statuses = statuses.duplicate(true)
	return copy


## Whether this hero is dead and counting down to respawn. A dead hero is inert — it cannot
## move, attack, cast, or be targeted (every acting and targeting step skips it) — and the
## client hides its body behind the death screen until the timer elapses. Reads off
## `respawn_ticks` so death and its countdown are one piece of state, alive again at 0.
func is_dead() -> bool:
	return respawn_ticks > 0


## This entity's move speed after any active lock or slow. A STUN freezes it outright
## (speed 0); otherwise a SLOW status scales the base speed by (100 - its percent), and
## with neither the base speed is returned unchanged — so a status-free entity (every
## entity on the wire, every Solane unit) moves by exactly the same math as before. The
## authoritative movement step and the client's local prediction both read it, so a
## stunned or slowed hero is predicted identically.
func current_move_speed() -> float:
	if is_stunned():
		return 0.0
	var slow: Dictionary = statuses.get(AbilitySpec.STATUS_SLOW, {})
	if slow.is_empty():
		return move_speed
	var pct := clampi(slow["power"], 0, 100)
	return move_speed * float(100 - pct) / 100.0


## Whether a STUN status is currently locking this entity: while one is active it cannot
## move, cast, or auto-attack. The movement, ability, and combat steps each read this to
## freeze a locked unit; the stun ages out in `_step_statuses` like any other status.
func is_stunned() -> bool:
	return statuses.has(AbilitySpec.STATUS_STUN)