Commit
Theria
feat: show damage numbers and ranged/melee auto-attacks, add a move-target marker
modified CHANGELOG.md
@@ -64,6 +64,15 @@ protocol version.
### Added
- Combat now reads on screen. Every hit — an auto-attack, an ability, or a venom tick — pops
a floating damage number over the struck unit, so damage is legible instead of only a bar
ticking down. Auto-attacks themselves now show: a ranged attacker (a tower, a skirmisher
hero) flies a bolt at its target, while a melee one (a creep, a brawler hero) flashes a
close-in impact. The sim records each hit and strike on a per-tick presentation log it
already keeps for casts; like that log it never crosses the wire, so this is a LOCAL/HOST
render change only — the simulation and the netcode protocol are unchanged.
- A click-to-move destination marker: right-clicking lays a pulsing ring on the ground where
the hero is headed, so the move target reads at a glance. Presentation only.
- 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
added src/client/combat_fx.gd
@@ -0,0 +1,120 @@
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 := 2000.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)
added src/client/combat_fx.gd.uid
@@ -0,0 +1 @@
uid://cww56aeqtxvsn
modified src/client/main.gd
@@ -146,6 +146,8 @@ var _bot_id: int = 0
## `move_dir` on the client before it reaches the sim or the wire, so neither is changed.
var _move_target: Vector2 = Vector2.ZERO
var _has_move_target: bool = false
## The on-ground marker drawn at the active move target while the hero walks to it.
var _move_marker: MoveMarker = null
## LOCAL: the hero the player drives, from `--hero` — any hero of either tribe. Its
## tribe fills the player's team and the opposing tribe the bot team, so the choice also
@@ -622,6 +624,8 @@ func _build_world() -> void:
_camera.current = true
add_child(_camera)
_point_camera(MapData.BOUNDS.get_center())
_move_marker = MoveMarker.new()
add_child(_move_marker)
## Reconciles the view pool against the live state, then trails the camera. Called each
@@ -642,6 +646,14 @@ func _sync_world() -> void:
_views.erase(id)
for event in state.fx_events:
MatchFx.play(self, event)
for attack in state.attack_events:
CombatFx.strike(self, attack)
for hit in state.hit_events:
CombatFx.number(self, hit)
if _has_move_target:
_move_marker.point_at(_move_target)
else:
_move_marker.clear()
_follow_camera(state)
@@ -752,7 +764,7 @@ func _update_view(view: Dictionary, entity: SimEntity) -> void:
(view["res_node"] as Node3D).visible = entity.resource_max > 0
_set_bar(view["res_fg"], _fraction(entity.resource, entity.resource_max))
if view.has("status"):
_update_status(view["status"], entity)
StatusLabel.refresh(view["status"], entity)
## Left-anchors a bar's fill to `frac` of its full width by scaling the foreground quad
@@ -763,46 +775,6 @@ func _set_bar(fg: MeshInstance3D, frac: float) -> void:
fg.position.x = -BAR_WIDTH * 0.5 * (1.0 - frac)
## Writes the active statuses onto a hero's floating label — `STUNNED` / `POISONED` /
## `SLOWED`, coloured by the highest-priority one — and hides it when there are none.
## Statuses live only in the authoritative sim (LOCAL/HOST), so a pure CLIENT shows
## none until they are carried over the wire; the label simply stays hidden there.
func _update_status(label: Label3D, entity: SimEntity) -> void:
if entity.statuses.is_empty():
label.visible = false
return
var names: Array[String] = []
for kind in [AbilitySpec.STATUS_STUN, AbilitySpec.STATUS_DOT, AbilitySpec.STATUS_SLOW]:
if entity.statuses.has(kind):
names.append(_status_name(kind))
if names.size() == 1:
label.modulate = _status_color(kind)
label.visible = true
label.text = "\n".join(names)
func _status_name(kind: int) -> String:
match kind:
AbilitySpec.STATUS_STUN:
return "STUNNED"
AbilitySpec.STATUS_DOT:
return "POISONED"
AbilitySpec.STATUS_SLOW:
return "SLOWED"
return ""
func _status_color(kind: int) -> Color:
match kind:
AbilitySpec.STATUS_STUN:
return Color(1.0, 0.9, 0.3)
AbilitySpec.STATUS_DOT:
return Color(0.6, 1.0, 0.4)
AbilitySpec.STATUS_SLOW:
return Color(0.55, 0.8, 1.0)
return Color.WHITE
## A billboarded HP/resource bar: a dark background quad with a coloured foreground quad
## over it, both returned with the foreground so `_set_bar` can scale the fill.
func _make_bar(fg_color: Color, y: float) -> Dictionary:
added src/client/move_marker.gd
@@ -0,0 +1,52 @@
class_name MoveMarker
extends Node3D
## The click-to-move destination marker (LoL-style): a flat ring laid on the ground at the
## point the player last right-clicked, shown while the hero walks toward it and hidden once
## it arrives. It pulses so the destination reads at a glance. Pure presentation — driven by
## the client's move target each tick, never by the simulation or the wire.
## Ring footprint and how far it floats above the ground (a hair, to dodge z-fighting with
## the ground plane), then the pulse rate and how far the radius breathes.
const RADIUS := 48.0
const THICKNESS := 8.0
const LIFT := 4.0
const COLOR := Color(0.45, 1.0, 0.65)
const PULSE_HZ := 2.0
const PULSE_AMOUNT := 0.16
var _ring: MeshInstance3D = null
var _phase := 0.0
func _ready() -> void:
var torus := TorusMesh.new()
torus.inner_radius = RADIUS - THICKNESS
torus.outer_radius = RADIUS
_ring = MeshInstance3D.new()
_ring.mesh = torus
var mat := StandardMaterial3D.new()
mat.albedo_color = COLOR
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
_ring.material_override = mat
add_child(_ring)
visible = false
## Shows the marker at a field point (a sim `Vector2`, placed on the ground). Idempotent —
## the pulse runs on its own clock, so re-pointing each tick just tracks the live target.
func point_at(field_point: Vector2) -> void:
position = Vector3(field_point.x, LIFT, field_point.y)
visible = true
## Hides the marker — the hero has arrived (or has no destination).
func clear() -> void:
visible = false
func _process(delta: float) -> void:
if not visible:
return
_phase += delta * PULSE_HZ * TAU
var s := 1.0 + sin(_phase) * PULSE_AMOUNT
_ring.scale = Vector3(s, 1.0, s)
added src/client/move_marker.gd.uid
@@ -0,0 +1 @@
uid://3ol6aaj0vteo
added src/client/status_label.gd
@@ -0,0 +1,44 @@
class_name StatusLabel
extends RefCounted
## Writes a hero's active crowd-control statuses onto its floating `Label3D` — `STUNNED` /
## `POISONED` / `SLOWED`, coloured by the highest-priority one, and hidden when there are
## none. Statuses live only in the authoritative sim (LOCAL/HOST), so a pure CLIENT shows
## none until they cross the wire; the label simply stays hidden there. Pure presentation,
## lifted out of `main.gd` so that file stays under the line cap.
## Refreshes `label` from `entity`'s live statuses (see the class doc for the contract).
static func refresh(label: Label3D, entity: SimEntity) -> void:
if entity.statuses.is_empty():
label.visible = false
return
var names: Array[String] = []
for kind in [AbilitySpec.STATUS_STUN, AbilitySpec.STATUS_DOT, AbilitySpec.STATUS_SLOW]:
if entity.statuses.has(kind):
names.append(_name_of(kind))
if names.size() == 1:
label.modulate = _color_of(kind)
label.visible = true
label.text = "\n".join(names)
static func _name_of(kind: int) -> String:
match kind:
AbilitySpec.STATUS_STUN:
return "STUNNED"
AbilitySpec.STATUS_DOT:
return "POISONED"
AbilitySpec.STATUS_SLOW:
return "SLOWED"
return ""
static func _color_of(kind: int) -> Color:
match kind:
AbilitySpec.STATUS_STUN:
return Color(1.0, 0.9, 0.3)
AbilitySpec.STATUS_DOT:
return Color(0.6, 1.0, 0.4)
AbilitySpec.STATUS_SLOW:
return Color(0.55, 0.8, 1.0)
return Color.WHITE
added src/client/status_label.gd.uid
@@ -0,0 +1 @@
uid://cl2qe7j2avn36
modified src/sim/ability_executor.gd
@@ -43,6 +43,7 @@ static func execute(
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
modified src/sim/sim_core.gd
@@ -165,6 +165,8 @@ func add_creep(team: int, lane: int, position: Vector2) -> int:
## 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
state.hit_events.clear() # this tick's damage numbers
state.attack_events.clear() # this tick's auto-attack strikes
if state.is_match_over():
return
_step_spawning()
@@ -283,6 +285,7 @@ func _step_statuses() -> void:
if s["counter"] >= s["interval"]:
s["counter"] = 0
entity.hp -= s["power"]
_record_damage(entity, s["power"])
s["remaining"] -= 1
if s["remaining"] <= 0:
expired.append(kind)
@@ -366,6 +369,28 @@ func _step_combat() -> void:
continue
target.hp -= attacker.attack_damage
attacker.cooldown = attacker.attack_cooldown_ticks
_record_attack_fx(attacker, target)
## Records an auto-attack for the renderer: a strike from `attacker` to `target`, flagged
## ranged (the renderer flies a projectile) or melee (a close-in impact), plus the damage
## number over the target. A structure or a kiting hero fires; everything else — creeps and
## brawler heroes — hits melee.
func _record_attack_fx(attacker: SimEntity, target: SimEntity) -> void:
var ranged := (
attacker.is_structure
or (attacker.is_hero and attacker.stance == AbilityData.STANCE_KITE)
)
state.attack_events.append(
{"origin": attacker.position, "target": target.position, "ranged": ranged}
)
_record_damage(target, attacker.attack_damage)
## Notes `amount` of damage on a struck entity for the floating-number renderer. A pure
## presentation hint — like `fx_events`, it never feeds the sim or crosses the wire.
func _record_damage(entity: SimEntity, amount: int) -> void:
state.hit_events.append({"position": entity.position, "amount": amount})
func _nearest_enemy_in_range(attacker: SimEntity) -> SimEntity:
modified src/sim/sim_state.gd
@@ -23,6 +23,17 @@ var winner: int = -1
## exactly as it shows no statuses. Read by the presenter, ignored by the simulation.
var fx_events: Array = []
## Presentation-only record of the damage dealt this tick — one entry per hp loss (an
## auto-attack, an ability hit, a venom tick), each `{position, amount}`, for the renderer to
## pop a floating damage number over the struck unit. Same lifecycle as `fx_events`: cleared
## at the top of every `step` and never serialized, so it stays a LOCAL/HOST render hint.
var hit_events: Array = []
## Presentation-only record of the auto-attacks that landed this tick — one entry per strike,
## each `{origin, target, ranged}`, so the renderer flies a projectile from a ranged attacker
## or flashes a close-in impact for a melee one. Same lifecycle as `fx_events`.
var attack_events: Array = []
func add_entity(entity: SimEntity) -> void:
entities[entity.id] = entity