GDScript 159 lines
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)