feat(hud): Implement HUD comms UI with capital blackout notifications, intelligence log panel, and overlay scene files

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-18 23:01:22 -07:00
parent 3189dc7b3f
commit 538395c749
10 changed files with 701 additions and 1 deletions

View file

@ -17,5 +17,16 @@
{ "tier": 8, "vision_share_latency": 0, "decay_short": 10, "decay_long": 28, "heartbeat_interval": 6 },
{ "tier": 9, "vision_share_latency": 0, "decay_short": 12, "decay_long": 32, "heartbeat_interval": 4 }
],
"intercept_chance_per_severable_tile": 0.18
"intercept_chance_per_severable_tile": 0.18,
"capital_blackout": {
"decay_multiplier": 0.5,
"comm_tier_penalty": 1,
"auto_promote_after_turns": 1,
"_comment_auto_promote": "player MUST assign a new capital on their next turn; auto_promote_after_turns=1 means auto-promote kicks in only if the player ends turn N+1 without naming one (AI stall guard)"
},
"beacon_tap": {
"base_chance": 0.4,
"per_tile_compound": true,
"adamantine_echo_multiplier": 0.5
}
}

View file

@ -0,0 +1,70 @@
{
"_schema": "comms_ui_v1",
"_doc": "UI presentation strings for the comms subsystem (Phases 1-3). Consumed by CommsEventDispatcher autoload + IntelligenceLogPanel. Format strings use %s positional substitution; the dispatcher resolves placeholders in code (no template engine).",
"player_discovered": {
"toast_title": "First Contact",
"toast_body_fmt": "First contact — Clan %s.",
"log_category": "diplomacy",
"log_fmt": "Met %s at (%d, %d).",
"icon": "portrait"
},
"city_spotted": {
"toast_title": "Settlement Sighted",
"toast_body_fmt": "Spotted %s's settlement at (%d, %d).",
"log_category": "diplomacy",
"log_fmt": "Settlement of %s sighted at (%d, %d).",
"icon": "city",
"map_flash_color": [0.95, 0.85, 0.35, 1.0]
},
"unit_spotted": {
"toast_title": "",
"toast_body_fmt": "",
"log_category": "diplomacy",
"log_fmt": "Sighted %s of %s at (%d, %d).",
"icon": "scout",
"no_toast": true
},
"capital_blackout_began": {
"overlay_title_fmt": "Your capital has fallen.",
"overlay_subtitle": "Comms degraded.",
"toast_title": "Capital Blackout",
"toast_body_fmt": "%s's capital has fallen — comms degraded.",
"log_category": "diplomacy",
"log_fmt": "Capital blackout: %s lost their seat of power at (%d, %d)."
},
"capital_blackout_ended": {
"toast_title": "New Seat of Power",
"toast_body_fmt": "New seat of power: %s.",
"log_category": "diplomacy",
"log_fmt": "%s restored capital comms via city %s."
},
"envelope_tapped": {
"toast_title": "Beacon Tap",
"toast_body_fmt": "Beacon at (%d, %d) intercepted enemy communications. Decoded payload: %s.",
"log_category": "diplomacy",
"log_fmt": "Tapped envelope #%d at (%d, %d): %s.",
"intel_log_fmt": "T%d envelope #%d %s -> %s payload: %s beacon (%d, %d)",
"icon": "beacon"
},
"heartbeat_sent": {
"no_toast": true,
"dev_log_fmt": "[hb] sent agreement=%d %d -> %d"
},
"heartbeat_missed": {
"no_toast": true,
"dev_log_fmt": "[hb] MISSED agreement=%d expected_by=%d count=%d"
},
"vision_share_collapsed": {
"toast_title": "Vision Share Severed",
"toast_body_fmt": "Shared vision with ally collapsed (%s).",
"log_category": "diplomacy",
"log_fmt": "Vision-share agreement #%d collapsed between players %d and %d (%s)."
},
"vision_share_restored": {
"toast_title": "Vision Share Restored",
"toast_body_fmt": "Shared vision with ally restored.",
"log_category": "diplomacy",
"log_fmt": "Vision-share agreement #%d restored between players %d and %d."
}
}

View file

@ -0,0 +1,299 @@
extends CanvasLayer
## HUD-side renderer for comms events. Subscribes to the comms signals on
## EventBus, instantiates toasts from comms_toast.tscn, drives the capital
## blackout overlay, and forwards heartbeat events to the dev-mode strip.
##
## Owned by the HUD (or by the proof scene). Holds no game state; reads
## strings from CommsEventDispatcher.strings_for(kind).
##
## Local player resolution: looks up the human player from GameState. In
## headless / proof scenes where GameState has no players, falls back to the
## value of `default_local_player` so the proof scene can drive every event
## branch deterministically without booting the full game.
const TOAST_SCENE: PackedScene = preload("res://engine/scenes/notifications/comms_toast.tscn")
const BLACKOUT_SCENE: PackedScene = preload(
"res://engine/scenes/notifications/capital_blackout_overlay.tscn"
)
const TOAST_STACK_ANCHOR_TOP: float = 0.08
const TOAST_STACK_RIGHT_MARGIN: float = 24.0
const TOAST_STACK_WIDTH: float = 380.0
const TOAST_STACK_SEPARATION: int = 8
const DEV_LOG_MAX_VISIBLE: int = 6
## Override for proof scenes that don't boot GameState.
@export var default_local_player: int = -1
## Show the dev-mode heartbeat strip (silent in UI per spec — but the dev
## overlay is opt-in for debugging).
@export var dev_overlay: bool = false
var _toast_stack: VBoxContainer = null
var _blackout: CanvasLayer = null
var _dev_panel: PanelContainer = null
var _dev_vbox: VBoxContainer = null
## Map agreement_id -> "collapsed" | "active". UI consumers can read this to
## flip ally vision-share indicators without re-walking history.
var _vision_share_state: Dictionary = {}
func _ready() -> void:
layer = 18
_build_ui()
_connect_signals()
func _build_ui() -> void:
# Toast stack — anchored top-right, grows downward.
_toast_stack = VBoxContainer.new()
_toast_stack.name = "ToastStack"
_toast_stack.anchor_left = 1.0
_toast_stack.anchor_right = 1.0
_toast_stack.anchor_top = TOAST_STACK_ANCHOR_TOP
_toast_stack.anchor_bottom = TOAST_STACK_ANCHOR_TOP
_toast_stack.offset_left = -(TOAST_STACK_WIDTH + TOAST_STACK_RIGHT_MARGIN)
_toast_stack.offset_right = -TOAST_STACK_RIGHT_MARGIN
_toast_stack.offset_bottom = 0
_toast_stack.add_theme_constant_override("separation", TOAST_STACK_SEPARATION)
_toast_stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_toast_stack)
# Blackout overlay — instantiated once, toggled on/off.
_blackout = BLACKOUT_SCENE.instantiate() as CanvasLayer
get_tree().root.call_deferred("add_child", _blackout)
# Dev heartbeat strip — only visible when dev_overlay is true.
_dev_panel = PanelContainer.new()
_dev_panel.name = "DevHeartbeatStrip"
_dev_panel.visible = dev_overlay
_dev_panel.anchor_left = 0.0
_dev_panel.anchor_right = 0.0
_dev_panel.anchor_top = 1.0
_dev_panel.anchor_bottom = 1.0
_dev_panel.offset_left = 16
_dev_panel.offset_top = -160
_dev_panel.offset_right = 480
_dev_panel.offset_bottom = -16
_dev_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
var dev_style: StyleBoxFlat = StyleBoxFlat.new()
dev_style.bg_color = Color(0.0, 0.0, 0.0, 0.65)
dev_style.border_color = Color(0.5, 0.5, 0.5, 0.8)
dev_style.set_border_width_all(1)
dev_style.content_margin_left = 8
dev_style.content_margin_right = 8
dev_style.content_margin_top = 6
dev_style.content_margin_bottom = 6
_dev_panel.add_theme_stylebox_override("panel", dev_style)
_dev_vbox = VBoxContainer.new()
_dev_vbox.add_theme_constant_override("separation", 2)
_dev_panel.add_child(_dev_vbox)
add_child(_dev_panel)
func _connect_signals() -> void:
EventBus.player_discovered.connect(_on_player_discovered)
EventBus.city_spotted.connect(_on_city_spotted)
EventBus.unit_spotted.connect(_on_unit_spotted)
EventBus.capital_blackout_began.connect(_on_capital_blackout_began)
EventBus.capital_blackout_ended.connect(_on_capital_blackout_ended)
EventBus.envelope_tapped.connect(_on_envelope_tapped)
EventBus.heartbeat_sent.connect(_on_heartbeat_sent)
EventBus.heartbeat_missed.connect(_on_heartbeat_missed)
EventBus.vision_share_collapsed.connect(_on_vision_share_collapsed)
EventBus.vision_share_restored.connect(_on_vision_share_restored)
# -- Local-player resolution --------------------------------------------------
func _local_player() -> int:
if _has_populated_game_state():
var players: Array = GameState.get("players")
for p in players:
if p != null and bool(p.get("is_human")):
return int(p.get("index"))
return default_local_player
## True when the GameState autoload exists AND has a non-empty `players` list.
## The plain `get_node_or_null` check passes during early boot / proof scenes
## where GameState exists but holds no players — calling `get_player(i)` there
## triggers a stderr warning. This guards the name-lookup branch.
func _has_populated_game_state() -> bool:
if get_tree() == null:
return false
if get_tree().root.get_node_or_null("GameState") == null:
return false
var players: Array = GameState.get("players")
return players != null and not players.is_empty()
# -- Signal handlers ---------------------------------------------------------
func _on_player_discovered(payload: Dictionary) -> void:
var strings: Dictionary = CommsEventDispatcher.strings_for("PlayerDiscovered")
var discovered_id: int = int(payload.get("discovered", -1))
var name_str: String = "Clan %d" % discovered_id
if _has_populated_game_state():
var p: RefCounted = GameState.get_player(discovered_id) as RefCounted
if p != null:
var pn: String = str(p.get("player_name"))
if not pn.is_empty():
name_str = pn
var title: String = str(strings.get("toast_title", "First Contact"))
var body: String = str(strings.get("toast_body_fmt", "First contact — %s.")) % name_str
_spawn_toast(title, body, Color(0.85, 0.78, 0.40, 1.0))
func _on_city_spotted(payload: Dictionary) -> void:
var strings: Dictionary = CommsEventDispatcher.strings_for("CitySpotted")
var owner_id: int = int(payload.get("city_owner", -1))
var col_v: int = int(payload.get("col", 0))
var row_v: int = int(payload.get("row", 0))
var title: String = str(strings.get("toast_title", "Settlement Sighted"))
var fmt: String = str(strings.get("toast_body_fmt", "Spotted %s settlement at (%d, %d)."))
var owner_str: String = "Clan %d" % owner_id
var body: String = fmt % [owner_str, col_v, row_v]
_spawn_toast(title, body, Color(0.95, 0.85, 0.35, 1.0))
func _on_unit_spotted(_payload: Dictionary) -> void:
# Silent toast per spec — chronicle/log entry only. Hook left as no-op
# here; the chronicle log subscribes to the EventBus signal directly.
pass
func _on_capital_blackout_began(payload: Dictionary) -> void:
var strings: Dictionary = CommsEventDispatcher.strings_for("CapitalBlackoutBegan")
var affected: int = int(payload.get("player", -1))
var local: int = _local_player()
if affected == local and _blackout != null and _blackout.has_method("show_overlay"):
var title: String = str(strings.get("overlay_title_fmt", "Your capital has fallen."))
var subtitle: String = str(strings.get("overlay_subtitle", "Comms degraded."))
_blackout.call("show_overlay", title, subtitle)
else:
var toast_title: String = str(strings.get("toast_title", "Capital Blackout"))
var fmt: String = str(strings.get("toast_body_fmt", "%s's capital has fallen."))
var pname: String = "Clan %d" % affected
_spawn_toast(toast_title, fmt % pname, Color(1.0, 0.45, 0.45, 1.0))
func _on_capital_blackout_ended(payload: Dictionary) -> void:
var strings: Dictionary = CommsEventDispatcher.strings_for("CapitalBlackoutEnded")
var affected: int = int(payload.get("player", -1))
var local: int = _local_player()
if affected == local and _blackout != null and _blackout.has_method("hide_overlay"):
_blackout.call("hide_overlay")
var title: String = str(strings.get("toast_title", "New Seat of Power"))
var fmt: String = str(strings.get("toast_body_fmt", "New seat of power: %s."))
var city_name: String = str(payload.get("new_capital_city_id", "?"))
_spawn_toast(title, fmt % city_name, Color(0.55, 0.95, 0.55, 1.0))
func _on_envelope_tapped(payload: Dictionary) -> void:
# Covert — only the intercepting player sees the toast.
var intercepting: int = int(payload.get("intercepting_player", -1))
if intercepting != _local_player():
return
var strings: Dictionary = CommsEventDispatcher.strings_for("EnvelopeTapped")
var col_v: int = int(payload.get("col", 0))
var row_v: int = int(payload.get("row", 0))
var pkind: String = str(payload.get("payload_kind", "?"))
var title: String = str(strings.get("toast_title", "Beacon Tap"))
var fmt: String = str(
strings.get("toast_body_fmt", "Beacon at (%d, %d) intercepted enemy comms (%s).")
)
_spawn_toast(title, fmt % [col_v, row_v, pkind], Color(0.55, 0.40, 0.85, 1.0))
func _on_heartbeat_sent(payload: Dictionary) -> void:
if not dev_overlay:
return
var strings: Dictionary = CommsEventDispatcher.strings_for("HeartbeatSent")
var fmt: String = str(strings.get("dev_log_fmt", "[hb] sent agreement=%d %d -> %d"))
var agid: int = int(payload.get("agreement_id", 0))
var sender: int = int(payload.get("sender", -1))
var recipient: int = int(payload.get("recipient", -1))
_push_dev_line(fmt % [agid, sender, recipient], Color(0.6, 0.85, 0.6, 1.0))
func _on_heartbeat_missed(payload: Dictionary) -> void:
if not dev_overlay:
return
var strings: Dictionary = CommsEventDispatcher.strings_for("HeartbeatMissed")
var fmt: String = str(
strings.get("dev_log_fmt", "[hb] MISSED agreement=%d expected_by=%d count=%d")
)
var agid: int = int(payload.get("agreement_id", 0))
var expected: int = int(payload.get("expected_by_turn", 0))
var count: int = int(payload.get("missed_count", 0))
_push_dev_line(fmt % [agid, expected, count], Color(1.0, 0.55, 0.40, 1.0))
func _on_vision_share_collapsed(payload: Dictionary) -> void:
var strings: Dictionary = CommsEventDispatcher.strings_for("VisionShareCollapsed")
var agid: int = int(payload.get("agreement_id", 0))
_vision_share_state[agid] = "collapsed"
var title: String = str(strings.get("toast_title", "Vision Share Severed"))
var fmt: String = str(strings.get("toast_body_fmt", "Shared vision with ally collapsed (%s)."))
var reason: String = str(payload.get("reason", "unknown"))
_spawn_toast(title, fmt % reason, Color(1.0, 0.55, 0.55, 1.0))
func _on_vision_share_restored(payload: Dictionary) -> void:
var strings: Dictionary = CommsEventDispatcher.strings_for("VisionShareRestored")
var agid: int = int(payload.get("agreement_id", 0))
_vision_share_state[agid] = "active"
var title: String = str(strings.get("toast_title", "Vision Share Restored"))
var body: String = str(strings.get("toast_body_fmt", "Shared vision with ally restored."))
_spawn_toast(title, body, Color(0.55, 0.95, 0.55, 1.0))
# -- Render helpers ----------------------------------------------------------
func _spawn_toast(title: String, body: String, accent: Color) -> void:
if title.is_empty() and body.is_empty():
return
var toast: Node = TOAST_SCENE.instantiate()
_toast_stack.add_child(toast)
if toast.has_method("configure"):
toast.call("configure", title, body, accent)
func _push_dev_line(text: String, color: Color) -> void:
var lbl: Label = Label.new()
lbl.text = text
lbl.add_theme_font_size_override("font_size", 11)
lbl.add_theme_color_override("font_color", color)
_dev_vbox.add_child(lbl)
while _dev_vbox.get_child_count() > DEV_LOG_MAX_VISIBLE:
var oldest: Node = _dev_vbox.get_child(0)
oldest.queue_free()
_dev_vbox.remove_child(oldest)
# -- Public helpers for testing / external query -----------------------------
## Read-only view of vision-share state. Allied portrait UI can poll this.
func vision_share_state() -> Dictionary:
return _vision_share_state.duplicate()
## Test/proof helper — force-show the blackout overlay regardless of local
## player gating. Used by comms_events_proof to render every visual branch.
func force_show_blackout(title: String, subtitle: String) -> void:
if _blackout != null and _blackout.has_method("show_overlay"):
_blackout.call("show_overlay", title, subtitle)
func force_hide_blackout() -> void:
if _blackout != null and _blackout.has_method("hide_overlay"):
_blackout.call("hide_overlay")

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://b8c0mmrend1r"]
[ext_resource type="Script" path="res://engine/scenes/hud/comms_renderer.gd" id="1_comms"]
[node name="CommsRenderer" type="CanvasLayer"]
script = ExtResource("1_comms")

View file

@ -0,0 +1,136 @@
extends PanelContainer
## Collapsible Intelligence Log sibling of the diplomacy screen.
##
## Lists EnvelopeTapped events the viewing player has intercepted. Source of
## truth is `CommsEventDispatcher.intelligence_log()`; the panel re-renders on
## EventBus.envelope_tapped or on demand via `refresh()`.
##
## Visibility is policy: only the local human player ever sees populated rows
## because the dispatcher gates the underlying log by `intercepting_player`.
const MAX_ROWS_DISPLAYED: int = 64
const HEADER_TEXT: String = "Intelligence Log"
var _header: Label = null
var _vbox: VBoxContainer = null
var _scroll: ScrollContainer = null
var _empty_label: Label = null
var _toggle_btn: Button = null
var _body: VBoxContainer = null
var _expanded: bool = true
func _ready() -> void:
custom_minimum_size = Vector2(360, 220)
_build_ui()
if not EventBus.envelope_tapped.is_connected(_on_envelope_tapped):
EventBus.envelope_tapped.connect(_on_envelope_tapped)
refresh()
func _build_ui() -> void:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(0.04, 0.04, 0.07, 0.94)
style.border_color = Color(0.55, 0.40, 0.85, 0.95)
style.set_border_width_all(2)
style.set_corner_radius_all(6)
style.content_margin_left = 12
style.content_margin_right = 12
style.content_margin_top = 10
style.content_margin_bottom = 10
add_theme_stylebox_override("panel", style)
var outer: VBoxContainer = VBoxContainer.new()
outer.add_theme_constant_override("separation", 8)
add_child(outer)
var header_row: HBoxContainer = HBoxContainer.new()
outer.add_child(header_row)
_header = Label.new()
_header.text = HEADER_TEXT
_header.add_theme_font_size_override("font_size", 18)
_header.add_theme_color_override("font_color", Color(0.85, 0.78, 1.0, 1.0))
_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header_row.add_child(_header)
_toggle_btn = Button.new()
_toggle_btn.text = "-"
_toggle_btn.custom_minimum_size = Vector2(28, 28)
_toggle_btn.pressed.connect(_on_toggle_pressed)
header_row.add_child(_toggle_btn)
_body = VBoxContainer.new()
_body.add_theme_constant_override("separation", 6)
outer.add_child(_body)
_scroll = ScrollContainer.new()
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_scroll.custom_minimum_size = Vector2(0, 180)
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_body.add_child(_scroll)
_vbox = VBoxContainer.new()
_vbox.add_theme_constant_override("separation", 4)
_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_scroll.add_child(_vbox)
_empty_label = Label.new()
_empty_label.text = "No intercepted envelopes yet."
_empty_label.add_theme_font_size_override("font_size", 13)
_empty_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6, 0.85))
_body.add_child(_empty_label)
## Rebuild from CommsEventDispatcher.intelligence_log(). Bounded by
## MAX_ROWS_DISPLAYED; older rows scroll-clip.
func refresh() -> void:
for child in _vbox.get_children():
child.queue_free()
if get_tree() == null or get_tree().root.get_node_or_null("CommsEventDispatcher") == null:
_empty_label.visible = true
return
var rows: Array[Dictionary] = CommsEventDispatcher.intelligence_log()
if rows.is_empty():
_empty_label.visible = true
return
_empty_label.visible = false
var strings: Dictionary = CommsEventDispatcher.strings_for("EnvelopeTapped")
var fmt: String = str(
strings.get("intel_log_fmt", "T%d envelope #%d %d -> %d payload: %s beacon (%d, %d)")
)
var start: int = maxi(0, rows.size() - MAX_ROWS_DISPLAYED)
for i: int in range(start, rows.size()):
var row: Dictionary = rows[i]
var text: String = _format_row(row, fmt)
var lbl: Label = Label.new()
lbl.text = text
lbl.add_theme_font_size_override("font_size", 13)
lbl.add_theme_color_override("font_color", Color(0.85, 0.78, 1.0, 1.0))
lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_vbox.add_child(lbl)
func _format_row(row: Dictionary, fmt: String) -> String:
var turn: int = int(row.get("turn", 0))
var envelope_id: int = int(row.get("envelope_id", 0))
var sender: int = int(row.get("sender", -1))
var recipient: int = int(row.get("recipient", -1))
var payload_kind: String = str(row.get("payload_kind", "?"))
var col_v: int = int(row.get("col", 0))
var row_v: int = int(row.get("row", 0))
return fmt % [turn, envelope_id, sender, recipient, payload_kind, col_v, row_v]
func _on_envelope_tapped(_payload: Dictionary) -> void:
refresh()
func _on_toggle_pressed() -> void:
_expanded = not _expanded
_body.visible = _expanded
_toggle_btn.text = "-" if _expanded else "+"

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://b3h7intllog1"]
[ext_resource type="Script" path="res://engine/scenes/hud/intelligence_log_panel.gd" id="1_intel"]
[node name="IntelligenceLogPanel" type="PanelContainer"]
script = ExtResource("1_intel")

View file

@ -0,0 +1,71 @@
extends CanvasLayer
## Full-screen overlay shown when the local player's capital has fallen
## (TurnEvent::CapitalBlackoutBegan). Hides on CapitalBlackoutEnded.
##
## Owned by CommsRenderer — one instance per HUD. Does not subscribe to the
## EventBus itself; the renderer drives `show_overlay()` / `hide_overlay()`
## so we can guard on local-player and surface a deterministic test path.
const TITLE_DEFAULT: String = "Your capital has fallen."
const SUBTITLE_DEFAULT: String = "Comms degraded."
var _dim: ColorRect = null
var _title_label: Label = null
var _subtitle_label: Label = null
var _glitch_band: ColorRect = null
func _ready() -> void:
layer = 25
visible = false
_build_ui()
func _build_ui() -> void:
_dim = ColorRect.new()
_dim.set_anchors_preset(Control.PRESET_FULL_RECT)
_dim.color = Color(0.04, 0.0, 0.02, 0.88)
_dim.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_dim)
_glitch_band = ColorRect.new()
_glitch_band.anchor_left = 0.0
_glitch_band.anchor_right = 1.0
_glitch_band.anchor_top = 0.42
_glitch_band.anchor_bottom = 0.58
_glitch_band.color = Color(0.55, 0.05, 0.05, 0.35)
add_child(_glitch_band)
var center: CenterContainer = CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(center)
var vbox: VBoxContainer = VBoxContainer.new()
vbox.add_theme_constant_override("separation", 12)
center.add_child(vbox)
_title_label = Label.new()
_title_label.text = TITLE_DEFAULT
_title_label.add_theme_font_size_override("font_size", 42)
_title_label.add_theme_color_override("font_color", Color(1.0, 0.45, 0.45, 1.0))
_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(_title_label)
_subtitle_label = Label.new()
_subtitle_label.text = SUBTITLE_DEFAULT
_subtitle_label.add_theme_font_size_override("font_size", 22)
_subtitle_label.add_theme_color_override("font_color", Color(0.9, 0.85, 0.65, 0.95))
_subtitle_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(_subtitle_label)
## Show the overlay for the local player. Title / subtitle pulled from
## comms_ui.json via CommsRenderer; defaults applied if omitted.
func show_overlay(title: String = TITLE_DEFAULT, subtitle: String = SUBTITLE_DEFAULT) -> void:
_title_label.text = title if not title.is_empty() else TITLE_DEFAULT
_subtitle_label.text = subtitle if not subtitle.is_empty() else SUBTITLE_DEFAULT
visible = true
func hide_overlay() -> void:
visible = false

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://b9k5c4pblkout"]
[ext_resource type="Script" path="res://engine/scenes/notifications/capital_blackout_overlay.gd" id="1_blackout"]
[node name="CapitalBlackoutOverlay" type="CanvasLayer"]
script = ExtResource("1_blackout")

View file

@ -0,0 +1,89 @@
extends PanelContainer
## Stackable comms toast. Single visual component reused for every comms event
## variant that opts into toast rendering. Parameterised at spawn time —
## bespoke per-event toast scripts are forbidden.
##
## Lifecycle: spawned by CommsRenderer, auto-fades after `lifetime` seconds,
## then queue_free()s itself. Parent (CommsRenderer) owns ordering.
const DEFAULT_LIFETIME: float = 4.5
const FADE_DURATION: float = 0.45
@export var lifetime: float = DEFAULT_LIFETIME
var _title: Label = null
var _body: Label = null
var _icon_rect: ColorRect = null
var _timer: Timer = null
func _ready() -> void:
custom_minimum_size = Vector2(360, 0)
mouse_filter = Control.MOUSE_FILTER_IGNORE
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(0.06, 0.06, 0.10, 0.92)
style.border_color = Color(0.85, 0.78, 0.40, 0.95)
style.set_border_width_all(2)
style.set_corner_radius_all(6)
style.content_margin_left = 12
style.content_margin_right = 12
style.content_margin_top = 10
style.content_margin_bottom = 10
add_theme_stylebox_override("panel", style)
var row: HBoxContainer = HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
add_child(row)
_icon_rect = ColorRect.new()
_icon_rect.custom_minimum_size = Vector2(8, 0)
_icon_rect.size_flags_vertical = Control.SIZE_EXPAND_FILL
_icon_rect.color = Color(0.85, 0.78, 0.40, 1.0)
row.add_child(_icon_rect)
var col: VBoxContainer = VBoxContainer.new()
col.size_flags_horizontal = Control.SIZE_EXPAND_FILL
col.add_theme_constant_override("separation", 4)
row.add_child(col)
_title = Label.new()
_title.add_theme_font_size_override("font_size", 16)
_title.add_theme_color_override("font_color", Color(1.0, 0.94, 0.75, 1.0))
col.add_child(_title)
_body = Label.new()
_body.add_theme_font_size_override("font_size", 13)
_body.add_theme_color_override("font_color", Color(0.88, 0.88, 0.84, 1.0))
_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
col.add_child(_body)
_timer = Timer.new()
_timer.one_shot = true
_timer.wait_time = lifetime
_timer.timeout.connect(_fade_out)
add_child(_timer)
## Parameterise the toast at spawn time. Call after add_child / before showing.
func configure(title: String, body: String, accent: Color = Color(0.85, 0.78, 0.40, 1.0)) -> void:
_title.text = title
_body.text = body
if _title.text.is_empty():
_title.visible = false
_icon_rect.color = accent
var style: StyleBoxFlat = get_theme_stylebox("panel") as StyleBoxFlat
if style != null:
style.border_color = accent
modulate.a = 0.0
var tween: Tween = create_tween()
tween.tween_property(self, "modulate:a", 1.0, FADE_DURATION)
_timer.start(lifetime)
func _fade_out() -> void:
var tween: Tween = create_tween()
tween.tween_property(self, "modulate:a", 0.0, FADE_DURATION)
tween.tween_callback(queue_free)

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://b1k5c0mmst0ast"]
[ext_resource type="Script" path="res://engine/scenes/notifications/comms_toast.gd" id="1_toast"]
[node name="CommsToast" type="PanelContainer"]
script = ExtResource("1_toast")