ajhahn.de
← Theria commits

Commit

Theria

feat: show ability casts on the field — beams, zones, pulses

Abilities resolved invisibly, so their feel could not be read on screen. The
simulation now records each resolved cast on a transient, off-wire log
(SimState.fx_events) — origin, landing point, area radius, effect, target kind,
status — and the presenter drains it every tick, drawing a brief flash: a beam
to a skillshot's or a unit cast's landing, a disc at a ground area's true radius
(so a zone like the Web Nest stun reads its real size), and a ring pulse on a
self-cast. Each flash is coloured by effect or status and fades out and frees
itself. The new MatchFx module keeps the drawing out of the presenter; the log
never crosses the wire (PROTOCOL_VERSION stays 3), so a snapshot-fed client
draws no cast FX, as it shows no statuses. Sim-side recording is covered by a
new test; the drawing is gated by the headless smoke.

ajhahnde · Jun 2026 · 06f21679e8f7f1ce28b74428309c057cd0b3afc6 · parent: a7487e4 · view on GitHub →

modified CHANGELOG.md
@@ -58,6 +58,15 @@ protocol version.
### Added
- Abilities now show on screen instead of resolving invisibly. Each cast flashes for a
beat: a skillshot or a unit-targeted ability draws a beam from the caster to where it
landed, a ground area draws a disc at its **true radius** — so a zone like the Spider's
stun nest reads its real footprint — and a self-cast (a heal, a shapeshift) pulses a ring
on the caster. The flash is coloured by what the cast does (warm for damage, green for a
heal, or the status's own colour for a stun, slow, or poison) and fades out and frees
itself, so the field stays clean. The simulation records each cast on a transient log the
renderer drains every tick; it never crosses the wire, so the netcode protocol is
unchanged (a snapshot-fed client draws no cast FX, as it shows no statuses).
- Stun joins the lingering-status roster as a hard crowd-control effect: a stunned unit
cannot move, cast, or auto-attack until it wears off. The Verdani Spider's **Web Nest**
now lays this brief lock over its zone instead of a slow — its instant hit is trimmed in
modified src/client/main.gd
@@ -657,6 +657,8 @@ func _sync_world() -> void:
if not state.entities.has(id):
(_views[id]["root"] as Node3D).queue_free()
_views.erase(id)
for event in state.fx_events:
MatchFx.play(self, event)
_follow_camera(state)
added src/client/match_fx.gd
@@ -0,0 +1,158 @@
class_name MatchFx
extends RefCounted
## Draws the brief visual for a cast the simulation resolved this tick, so an ability
## reads on screen instead of resolving invisibly. The sim records each cast on
## `SimState.fx_events` (origin, landing point, area radius, effect, target kind, status);
## the presenter drains that list every tick and hands each entry here. A skillshot or a
## unit cast flashes a beam from the caster to where it landed, a ground area flashes a
## disc at its true radius (so a zone like the Spider's stun nest reads its real size), and
## a self-cast pulses a ring on the caster. Every flash fades out and frees itself, so the
## field never accumulates nodes. Pure presentation — nothing here touches the simulation.
## How far above the ground the flashes sit, so they read over the lit plane rather than
## z-fighting it.
const FX_HEIGHT := 30.0
## Beam (skillshot / unit cast) — a thin bright bar from the caster to the landing point.
const BEAM_WIDTH := 16.0
const BEAM_ALPHA := 0.9
const BEAM_LIFETIME := 0.16
## Area (ground cast) — a translucent disc at the landing point, drawn at the ability's
## true radius so the zone's real footprint reads. Held a touch longer than a beam and
## kept see-through so what stands inside it stays visible.
const AREA_THICKNESS := 8.0
const AREA_ALPHA := 0.32
const AREA_LIFETIME := 0.45
## Pulse (self / heal / transform) — a ring on the caster that swells as it fades.
const PULSE_INNER := 60.0
const PULSE_OUTER := 78.0
const PULSE_ALPHA := 0.7
const PULSE_GROWTH := 2.1
const PULSE_LIFETIME := 0.3
## Flash colours. A status cast (stun / slow / poison) takes its status's colour — matching
## the floating status labels — so a control ability reads as control; otherwise the cast
## reads by effect: warm for damage, green for a heal, pale blue for a shapeshift.
const DAMAGE_COLOR := Color(1.0, 0.95, 0.82)
const HEAL_COLOR := Color(0.4, 1.0, 0.5)
const TRANSFORM_COLOR := Color(0.82, 0.85, 1.0)
const STUN_COLOR := Color(1.0, 0.9, 0.3)
const DOT_COLOR := Color(0.6, 1.0, 0.4)
const SLOW_COLOR := Color(0.55, 0.8, 1.0)
## Draws one cast's flash under `parent` (a node already in the tree, at the world origin),
## dispatched by its targeting kind. The event is one `SimState.fx_events` entry.
static func play(parent: Node3D, event: Dictionary) -> void:
var color := _color(event)
match event["kind"]:
AbilitySpec.TARGET_SKILLSHOT, AbilitySpec.TARGET_UNIT:
_beam(parent, event["origin"], event["point"], color)
AbilitySpec.TARGET_GROUND:
_disc(parent, event["point"], maxf(event["radius"], BEAM_WIDTH), color)
_:
_pulse(parent, event["origin"], color)
## A thin bar from `a` to `b` (sim positions) — the path of a skillshot or the line to a
## locked target. Centred on the midpoint and turned to face the landing point, so it spans
## exactly the cast's reach. A zero-length cast (caster on the point) draws nothing.
static func _beam(parent: Node3D, a: Vector2, b: Vector2, color: Color) -> void:
var from := _world(a)
var to := _world(b)
var length := from.distance_to(to)
if length <= 0.0:
return
var bar := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(BEAM_WIDTH, BEAM_WIDTH, length)
bar.mesh = box
var mat := _material(color, BEAM_ALPHA)
bar.material_override = mat
parent.add_child(bar)
bar.global_position = from.lerp(to, 0.5)
bar.look_at(to, Vector3.UP)
_fade(parent, bar, mat, BEAM_LIFETIME)
## A flat translucent disc at `center`, drawn at `radius` — a ground area's real footprint.
static func _disc(parent: Node3D, center: Vector2, radius: float, color: Color) -> void:
var disc := MeshInstance3D.new()
var cyl := CylinderMesh.new()
cyl.top_radius = radius
cyl.bottom_radius = radius
cyl.height = AREA_THICKNESS
disc.mesh = cyl
var mat := _material(color, AREA_ALPHA)
disc.material_override = mat
parent.add_child(disc)
disc.global_position = _world(center)
_fade(parent, disc, mat, AREA_LIFETIME)
## A ring on the caster that swells outward as it fades — a self-cast (heal, shapeshift)
## has no reach to draw, so it marks the caster itself.
static func _pulse(parent: Node3D, at: Vector2, color: Color) -> void:
var ring := MeshInstance3D.new()
var torus := TorusMesh.new()
torus.inner_radius = PULSE_INNER
torus.outer_radius = PULSE_OUTER
ring.mesh = torus
var mat := _material(color, PULSE_ALPHA)
ring.material_override = mat
parent.add_child(ring)
ring.global_position = _world(at)
var faded := mat.albedo_color
faded.a = 0.0
var tween := parent.create_tween()
tween.set_parallel(true)
tween.tween_property(mat, "albedo_color", faded, PULSE_LIFETIME)
tween.tween_property(ring, "scale", Vector3.ONE * PULSE_GROWTH, PULSE_LIFETIME)
tween.chain().tween_callback(ring.queue_free)
## Fades `node`'s flash to transparent over `lifetime`, then frees it — so a cast's mark
## lingers a beat and clears itself, and the field never piles up flash nodes.
static func _fade(parent: Node3D, node: Node3D, mat: StandardMaterial3D, lifetime: float) -> void:
var faded := mat.albedo_color
faded.a = 0.0
var tween := parent.create_tween()
tween.tween_property(mat, "albedo_color", faded, lifetime)
tween.tween_callback(node.queue_free)
## The flash colour for a cast: its status's colour when it carries one, else its effect's.
static func _color(event: Dictionary) -> Color:
match event["status"]:
AbilitySpec.STATUS_STUN:
return STUN_COLOR
AbilitySpec.STATUS_DOT:
return DOT_COLOR
AbilitySpec.STATUS_SLOW:
return SLOW_COLOR
match event["effect"]:
AbilitySpec.EFFECT_HEAL:
return HEAL_COLOR
AbilitySpec.EFFECT_TRANSFORM:
return TRANSFORM_COLOR
return DAMAGE_COLOR
## An unshaded, alpha-blended material in `color` at `alpha` — a flat flash that reads at a
## glance and tweens its own opacity down as it fades.
static func _material(color: Color, alpha: float) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
var c := color
c.a = alpha
mat.albedo_color = c
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
return mat
## A sim ground position to a world point at the flash height — the same x/z mapping the
## presenter uses for entities, lifted clear of the ground plane.
static func _world(p: Vector2) -> Vector3:
return Vector3(p.x, FX_HEIGHT, p.y)
added src/client/match_fx.gd.uid
@@ -0,0 +1 @@
uid://d12nmwbk6vl7i
modified src/sim/ability_executor.gd
@@ -47,6 +47,42 @@ static func execute(
_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
modified src/sim/sim_core.gd
@@ -164,6 +164,7 @@ func add_creep(team: int, lane: int, position: Vector2) -> int:
## 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
if state.is_match_over():
return
_step_spawning()
modified src/sim/sim_state.gd
@@ -15,6 +15,14 @@ var entities: Dictionary = {}
## nexus is destroyed; once set, the simulation freezes (`step` no-ops).
var winner: int = -1
## Presentation-only record of the casts resolved this tick — one entry per cast, each
## carrying its origin, landing point, area radius, effect, target kind, and status, for
## the renderer to flash a skillshot line or an area zone. Cleared at the top of every
## `step` (so it never carries a stale cast) and never serialized onto the wire, so it
## stays a pure LOCAL/HOST render hint: a snapshot-fed CLIENT simply draws no cast FX,
## exactly as it shows no statuses. Read by the presenter, ignored by the simulation.
var fx_events: Array = []
func add_entity(entity: SimEntity) -> void:
entities[entity.id] = entity
added test/unit/test_ability_fx.gd
@@ -0,0 +1,72 @@
extends GutTest
## The cast-FX log the simulation leaves for the renderer. A resolved cast appends one
## entry to `SimState.fx_events` carrying enough geometry to draw it — origin, landing
## point, area radius, and the cast's kind/effect/status — and `step` clears the log at the
## top of every tick, so a flash draws exactly once. The log is presentation-only: it
## never feeds back into the simulation and never crosses the wire. Headless, deterministic.
const SPIRIT_BOLT_ID := 1 # wildkin human SKILLSHOT, range 600 / radius 60
const MEND_ID := 2 # wildkin human SELF heal
const WEB_NEST_ID := 54 # spider animal GROUND stun, range 340 / radius 220
func _hero(sim: SimCore, kit_id: String, pos: Vector2) -> int:
var id := sim.add_hero(0, pos, 300.0)
sim.equip_kit(id, kit_id)
return id
func test_a_skillshot_cast_records_a_beam_to_its_landing() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "wildkin", Vector2.ZERO)
var spec := AbilityData.spec(SPIRIT_BOLT_ID)
var cmd := InputCommand.new()
cmd.target_point = Vector2(100.0, 0.0) # aim +x — the bolt flies the full range along it
AbilityExecutor.execute(sim.state, sim.state.get_entity(id), spec, cmd)
assert_eq(sim.state.fx_events.size(), 1, "the cast is recorded once")
var fx: Dictionary = sim.state.fx_events[0]
assert_eq(fx["kind"], AbilitySpec.TARGET_SKILLSHOT, "drawn as a skillshot beam")
assert_eq(fx["effect"], AbilitySpec.EFFECT_DAMAGE)
assert_eq(fx["origin"], Vector2.ZERO, "from the caster")
assert_eq(fx["point"], Vector2(600.0, 0.0), "to the skillshot's full-range landing")
func test_a_ground_stun_cast_records_its_zone_at_true_radius() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "spider", Vector2.ZERO)
var spec := AbilityData.spec(WEB_NEST_ID)
var cmd := InputCommand.new()
cmd.target_point = Vector2(300.0, 0.0) # inside the 340 range, so it lands on the point
AbilityExecutor.execute(sim.state, sim.state.get_entity(id), spec, cmd)
var fx: Dictionary = sim.state.fx_events[0]
assert_eq(fx["kind"], AbilitySpec.TARGET_GROUND, "drawn as a ground area")
assert_eq(fx["status"], AbilitySpec.STATUS_STUN, "carries its stun so it reads as control")
assert_eq(fx["radius"], spec.radius, "the zone is drawn at the ability's true radius")
assert_eq(fx["point"], Vector2(300.0, 0.0), "centred where the area landed")
func test_a_self_cast_records_a_pulse_on_the_caster() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "wildkin", Vector2(50.0, 0.0))
var spec := AbilityData.spec(MEND_ID)
AbilityExecutor.execute(sim.state, sim.state.get_entity(id), spec, InputCommand.new())
var fx: Dictionary = sim.state.fx_events[0]
assert_eq(fx["kind"], AbilitySpec.TARGET_SELF, "drawn as a pulse, not a beam")
assert_eq(fx["effect"], AbilitySpec.EFFECT_HEAL)
assert_eq(fx["point"], Vector2(50.0, 0.0), "on the caster itself")
func test_step_clears_the_previous_tick_fx_so_a_flash_draws_once() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var id := _hero(sim, "wildkin", Vector2.ZERO)
var cmd := InputCommand.new()
cmd.ability_slot = 0 # Spirit Bolt
cmd.target_point = Vector2(100.0, 0.0)
sim.step({id: cmd})
assert_eq(sim.state.fx_events.size(), 1, "the cast tick records its flash")
sim.step({}) # a tick with no cast
assert_eq(sim.state.fx_events.size(), 0, "the next tick clears it, so the flash draws once")
added test/unit/test_ability_fx.gd.uid
@@ -0,0 +1 @@
uid://k3jarv8hphaj