GDScript 188 lines
class_name AbilityExecutor
extends RefCounted
## Resolves and applies one ability cast against the world. Pure and engine-free,
## exactly like the simulation core it runs inside: given the world, a caster, the
## ability's spec, and the cast intent, it picks the targets and applies the effect
## deterministically — in insertion order, with integer damage — so the result is a
## function of state and input alone and replays identically.
##
## `can_cast` is the gate (form, resource, cooldown); `execute` performs the cast
## and books the cost. SimCore's ability step calls them in that order. Effects
## that reduce hp leave the kill to the core's death-resolution pass, so an ability
## and an auto-attack that both finish a unit this tick kill it once.
## Whether `caster` may cast `spec` this tick: the caster must not be stunned, the spec
## must belong to the caster's active form, the caster must hold enough resource, and the
## ability must be off cooldown. Reads only — the decision never mutates the world. Both
## the player's casts and the bot's gate through here, so a stunned hero of either kind is
## silenced by the same check.
static func can_cast(caster: SimEntity, spec: AbilitySpec) -> bool:
if caster.is_stunned():
return false
if spec.form != caster.form:
return false
if caster.resource < spec.cost:
return false
if caster.ability_cooldowns.get(spec.id, 0) > 0:
return false
return true
## Performs the cast: applies the effect, puts the ability on cooldown, and spends
## its resource. Assumes `can_cast` already passed (SimCore gates on it), so the
## resource never goes negative. `command` carries the aim — the target point for an
## aimed ability, the target id for a unit-locked one.
static func execute(
state: SimState, caster: SimEntity, spec: AbilitySpec, command: InputCommand
) -> void:
match spec.effect:
AbilitySpec.EFFECT_TRANSFORM:
_transform(caster)
AbilitySpec.EFFECT_HEAL:
caster.hp = mini(caster.max_hp, caster.hp + spec.power)
AbilitySpec.EFFECT_DAMAGE:
for target in _targets(state, caster, spec, command):
target.hp -= spec.power
state.hit_events.append({"position": target.position, "amount": spec.power})
if spec.status != AbilitySpec.STATUS_NONE:
_apply_status(target, spec)
caster.ability_cooldowns[spec.id] = spec.cooldown_ticks
caster.resource -= spec.cost
_record_fx(state, caster, spec, command)
## Notes the cast on the state's transient FX log for the renderer — origin, landing
## point, area radius, and the cast's kind/effect/status, enough to draw a skillshot
## line or an area flash. A pure presentation side effect: the log never feeds back into
## the simulation and never crosses the wire, so recording it keeps the cast deterministic.
static func _record_fx(
state: SimState, caster: SimEntity, spec: AbilitySpec, command: InputCommand
) -> void:
state.fx_events.append(
{
"kind": spec.target_kind,
"effect": spec.effect,
"status": spec.status,
"origin": caster.position,
"point": _fx_point(state, caster, spec, command),
"radius": spec.radius,
}
)
## Where a cast's FX is centred: the landing point for an aimed ability, the locked
## enemy's position for a unit-targeted one (the caster's own spot if that enemy is gone),
## and the caster for a self-cast. Mirrors `_targets`/`_landing_point` so the flash sits
## where the ability actually resolved.
static func _fx_point(
state: SimState, caster: SimEntity, spec: AbilitySpec, command: InputCommand
) -> Vector2:
match spec.target_kind:
AbilitySpec.TARGET_SKILLSHOT, AbilitySpec.TARGET_GROUND:
return _landing_point(caster, spec, command)
AbilitySpec.TARGET_UNIT:
var t: SimEntity = state.get_entity(command.target_id)
return t.position if t != null else caster.position
return caster.position
## Lays the spec's lingering status on one struck target. One instance per kind: a
## re-application overwrites any active status of the same kind, so it refreshes (the
## latest cast wins) rather than stacking — bounded and deterministic. A DOT clamps its
## interval to at least one tick and starts its damage counter fresh; a SLOW carries
## only its percent; a STUN carries only its duration (its power and interval go unused).
## The duration always starts over.
static func _apply_status(target: SimEntity, spec: AbilitySpec) -> void:
target.statuses[spec.status] = {
"power": spec.status_power,
"remaining": spec.status_duration,
"interval": maxi(1, spec.status_interval),
"counter": 0,
}
## Swaps the caster to its other form and to that form's resource pool: max and
## regen switch, the current pool carries over clamped to the new max, and the regen
## counter restarts. Ability cooldowns are keyed by ability id, so they persist
## untouched across the swap.
static func _transform(caster: SimEntity) -> void:
var to_form := 1 - caster.form
caster.form = to_form
caster.resource_max = caster.form_resource_max[to_form]
caster.resource_regen_ticks = caster.form_resource_regen[to_form]
caster.resource = mini(caster.resource, caster.resource_max)
caster.resource_regen_counter = 0
## The enemies a damaging ability strikes, by its targeting mode. SELF deals no
## outward damage (returns nothing); UNIT returns its one locked enemy when valid
## and in range; an aimed ability returns every enemy inside the area at its landing
## point. Allies and non-combat entities (max_hp 0) are never struck.
static func _targets(
state: SimState, caster: SimEntity, spec: AbilitySpec, command: InputCommand
) -> Array[SimEntity]:
var hits: Array[SimEntity] = []
match spec.target_kind:
AbilitySpec.TARGET_UNIT:
var t: SimEntity = state.get_entity(command.target_id)
if (
t != null
and t.max_hp > 0
and not t.is_dead()
and t.team != caster.team
and caster.position.distance_to(t.position) <= spec.range
):
hits.append(t)
AbilitySpec.TARGET_SKILLSHOT, AbilitySpec.TARGET_GROUND:
hits = _enemies_in_area(state, caster, _landing_point(caster, spec, command), spec.radius)
return hits
## Where an aimed ability lands. A skillshot flies the full range along the aim
## direction (it travels through the cursor — dodgeable); a ground-target lands on
## the chosen point, pulled in to the maximum range. A zero-length aim lands on the
## caster.
static func _landing_point(caster: SimEntity, spec: AbilitySpec, command: InputCommand) -> Vector2:
var to_aim := command.target_point - caster.position
var dist := to_aim.length()
if dist <= 0.0:
return caster.position
var dir := to_aim / dist
if spec.target_kind == AbilitySpec.TARGET_SKILLSHOT:
return caster.position + dir * spec.range
return caster.position + dir * minf(spec.range, dist)
## The id of the living enemy nearest `point` — a unit-targeted ability's target
## acquisition for a cursor or click, picked by the caster's driver and validated by
## `execute` against the ability's range. 0 when the caster's enemies hold no living
## unit. Pure: a function of the world, the caster's team, and the point, so a bot
## and the client pick the same lock.
static func pick_unit_target(state: SimState, caster_team: int, point: Vector2) -> int:
var best_id := 0
var best_dist := INF
for id in state.entities:
var e: SimEntity = state.entities[id]
if e.team == caster_team or e.max_hp <= 0 or e.is_dead():
continue
var d := point.distance_to(e.position)
if d < best_dist:
best_dist = d
best_id = id
return best_id
## Every living enemy of `caster` within `radius` of `center`, in deterministic
## insertion order.
static func _enemies_in_area(
state: SimState, caster: SimEntity, center: Vector2, radius: float
) -> Array[SimEntity]:
var hits: Array[SimEntity] = []
for id in state.entities:
var e: SimEntity = state.entities[id]
if e.team == caster.team or e.max_hp <= 0 or e.is_dead():
continue
if center.distance_to(e.position) <= radius:
hits.append(e)
return hits