GDScript 121 lines
class_name CombatFx
extends RefCounted
## Draws the brief visuals for the combat the simulation resolved this tick: a floating damage
## number over any unit that lost hp, and the auto-attack that caused it — a bolt flying from a
## ranged attacker to its target, or a close-in impact flash for a melee one. The sim records
## each on `SimState.hit_events` / `attack_events`; the presenter drains them every tick and
## hands each entry here. Like MatchFx, every node fades and frees itself, so the field never
## piles up; nothing here touches the simulation, and a snapshot-fed CLIENT simply draws none.
const GROUND_FX_HEIGHT := 30.0
## Bolts and impacts read better lofted to roughly mid-body rather than on the floor.
const STRIKE_HEIGHT := 70.0
## Floating damage number — a billboarded label that drifts up as it fades out.
const NUMBER_FONT_SIZE := 96
const NUMBER_START_Y := 130.0
const NUMBER_RISE := 90.0
const NUMBER_LIFETIME := 0.7
const NUMBER_COLOR := Color(1.0, 0.86, 0.4)
## Ranged auto: a small bright bolt that flies from attacker to target. Flight time scales
## with the gap but is clamped so a point-blank shot still reads and a long one is not slow.
const BOLT_RADIUS := 13.0
const BOLT_SPEED := 1300.0
const BOLT_MIN_TIME := 0.05
const BOLT_MAX_TIME := 0.22
const BOLT_COLOR := Color(1.0, 0.9, 0.55)
## Melee auto: a quick ring of impact flashed on the struck target.
const IMPACT_RADIUS := 50.0
const IMPACT_THICKNESS := 12.0
const IMPACT_ALPHA := 0.85
const IMPACT_LIFETIME := 0.16
const IMPACT_COLOR := Color(1.0, 0.95, 0.85)
## Pops one floating damage number from a `SimState.hit_events` entry (`{position, amount}`).
static func number(parent: Node3D, hit: Dictionary) -> void:
var label := Label3D.new()
label.text = str(hit["amount"])
label.font_size = NUMBER_FONT_SIZE
label.outline_size = NUMBER_FONT_SIZE / 6
label.modulate = NUMBER_COLOR
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.no_depth_test = true
parent.add_child(label)
var base := _world(hit["position"], NUMBER_START_Y)
label.global_position = base
var faded := NUMBER_COLOR
faded.a = 0.0
var risen := base + Vector3(0.0, NUMBER_RISE, 0.0)
var tween := parent.create_tween()
tween.set_parallel(true)
tween.tween_property(label, "global_position", risen, NUMBER_LIFETIME)
tween.tween_property(label, "modulate", faded, NUMBER_LIFETIME)
tween.chain().tween_callback(label.queue_free)
## Draws one auto-attack from a `SimState.attack_events` entry (`{origin, target, ranged}`):
## a flying bolt for a ranged attacker, an impact flash for a melee one.
static func strike(parent: Node3D, attack: Dictionary) -> void:
if attack["ranged"]:
_bolt(parent, attack["origin"], attack["target"])
else:
_impact(parent, attack["target"])
## A bright bolt that flies from `a` to `b` (sim positions) at mid-body height, then frees
## itself on arrival — the visible shot of a ranged auto-attack.
static func _bolt(parent: Node3D, a: Vector2, b: Vector2) -> void:
var from := _world(a, STRIKE_HEIGHT)
var to := _world(b, STRIKE_HEIGHT)
var bolt := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = BOLT_RADIUS
sphere.height = BOLT_RADIUS * 2.0
bolt.mesh = sphere
bolt.material_override = _material(BOLT_COLOR, 1.0)
parent.add_child(bolt)
bolt.global_position = from
var flight := clampf(from.distance_to(to) / BOLT_SPEED, BOLT_MIN_TIME, BOLT_MAX_TIME)
var tween := parent.create_tween()
tween.tween_property(bolt, "global_position", to, flight)
tween.tween_callback(bolt.queue_free)
## A quick ring flashed on the target — the close-in hit of a melee auto-attack.
static func _impact(parent: Node3D, at: Vector2) -> void:
var ring := MeshInstance3D.new()
var torus := TorusMesh.new()
torus.inner_radius = IMPACT_RADIUS - IMPACT_THICKNESS
torus.outer_radius = IMPACT_RADIUS
ring.mesh = torus
var mat := _material(IMPACT_COLOR, IMPACT_ALPHA)
ring.material_override = mat
parent.add_child(ring)
ring.global_position = _world(at, GROUND_FX_HEIGHT)
var faded := mat.albedo_color
faded.a = 0.0
var tween := parent.create_tween()
tween.tween_property(mat, "albedo_color", faded, IMPACT_LIFETIME)
tween.tween_callback(ring.queue_free)
## An unshaded, alpha-blended material in `color` at `alpha` — mirrors MatchFx so combat and
## cast flashes read as one visual language.
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 height `y` — the same x/z mapping the presenter
## uses for entities.
static func _world(p: Vector2, y: float) -> Vector3:
return Vector3(p.x, y, p.y)