ajhahn.de
← Theria
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)