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()