ajhahn.de
← Theria
GDScript 100 lines
class_name KillFeed
extends VBoxContainer
## The top-right kill feed: a short stack of recent takedown lines, newest on top, each
## fading out after a few seconds so the feed stays a glance, not a wall. Pure presentation —
## the driver decides a kill happened and calls `push`; this owns only the on-screen list and
## its expiry, like the other code-built client overlays, on the shared `UiTheme` palette.
##
## First pass shows the victim alone ("X was slain"), because the simulation records no killer
## today — `_resolve_deaths` only zeroes hp and starts the respawn timer. Attributing the kill
## ("X slew Y") is a later sim slice (record the dealer of the lethal blow, and carry it on the
## wire for a networked feed); the `push` signature already takes the full line so that slice is
## a driver change, not a rework here.

## How many lines stay on screen at once; older lines drop off the bottom as new ones arrive.
const MAX_ENTRIES := 5
## How long (seconds) a line lingers before it removes itself.
const LIFETIME := 6.0
const FONT_SIZE := 16
const MARGIN := 16.0
## Where the feed sits below the top-right settings button, so the two do not overlap.
const TOP_OFFSET := 70.0

## Which heroes were down last tick (id -> true), so `observe` fires one line on the
## alive -> down edge rather than every tick a hero stays dead.
var _down_last_tick: Dictionary = {}


func _ready() -> void:
	set_anchors_preset(Control.PRESET_TOP_RIGHT)
	grow_horizontal = Control.GROW_DIRECTION_BEGIN
	offset_top = TOP_OFFSET
	offset_right = -MARGIN
	alignment = BoxContainer.ALIGNMENT_BEGIN
	add_theme_constant_override("separation", 4)
	mouse_filter = Control.MOUSE_FILTER_IGNORE


## Adds a takedown line, newest at the top, trimming the oldest past the cap and scheduling
## the line to fade itself out after LIFETIME. `color` tints the line (the victim's team) so a
## glance reads which side fell. Safe before the node is in a tree — the timer is only armed
## once it is, so a headless or pre-ready caller just gets the label without an expiry.
func push(text: String, color: Color) -> void:
	var label := Label.new()
	label.text = text
	label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
	label.add_theme_font_size_override("font_size", FONT_SIZE)
	label.add_theme_color_override("font_color", color)
	label.mouse_filter = Control.MOUSE_FILTER_IGNORE
	add_child(label)
	move_child(label, 0)
	while get_child_count() > MAX_ENTRIES:
		# remove_child drops the count synchronously (queue_free alone does not), so detach the
		# oldest first to bound the loop, then free the now-orphaned line.
		var oldest := get_child(get_child_count() - 1)
		remove_child(oldest)
		oldest.queue_free()
	_expire(label)


## Scans the state for heroes that went down this tick — the alive -> down edge against last
## tick's record — and posts one feed line each, tinted by the team's colour (`team_colors`
## indexed by team id). `ally_team` only picks the Ally/Enemy fallback name when a hero's kit is
## unknown (a pure CLIENT hero). First pass names the victim alone; the killer is unknown until
## the sim attributes the lethal blow. Owns the death-edge tracking so the driver just hands it
## the state each tick.
func observe(state: SimState, ally_team: int, team_colors: Array) -> void:
	var down_now: Dictionary = {}
	for id in state.entities:
		var entity: SimEntity = state.entities[id]
		if not entity.is_hero:
			continue
		var down := entity.is_dead()
		down_now[id] = down
		if down and not _down_last_tick.get(id, false):
			push("%s was slain" % _victim_name(entity, ally_team), team_colors[entity.team])
	_down_last_tick = down_now


## A downed hero's name for the feed: its kit, capitalised, or an Ally/Enemy fallback when the
## kit is unknown (not carried on the wire for a pure CLIENT hero).
func _victim_name(hero: SimEntity, ally_team: int) -> String:
	if hero.kit_id != "":
		return hero.kit_id.capitalize()
	return "Ally" if hero.team == ally_team else "Enemy"


## Arms a line to remove itself after LIFETIME. A scene-tree timer needs the node in a tree;
## outside one (a unit test that never adds the feed) the line simply persists, which is all a
## push assertion needs.
func _expire(label: Label) -> void:
	if not is_inside_tree():
		return
	var timer := get_tree().create_timer(LIFETIME)
	timer.timeout.connect(func() -> void: _remove(label))


func _remove(label: Label) -> void:
	if is_instance_valid(label):
		label.queue_free()