From 538395c749e651687534922e980b6f073132cf74 Mon Sep 17 00:00:00 2001 From: autocommit Date: Mon, 18 May 2026 23:01:22 -0700 Subject: [PATCH] =?UTF-8?q?feat(hud):=20=E2=9C=A8=20Implement=20HUD=20comm?= =?UTF-8?q?s=20UI=20with=20capital=20blackout=20notifications,=20intellige?= =?UTF-8?q?nce=20log=20panel,=20and=20overlay=20scene=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- public/games/age-of-dwarves/data/comms.json | 13 +- .../games/age-of-dwarves/data/comms_ui.json | 70 ++++ src/game/engine/scenes/hud/comms_renderer.gd | 299 ++++++++++++++++++ .../engine/scenes/hud/comms_renderer.tscn | 6 + .../scenes/hud/intelligence_log_panel.gd | 136 ++++++++ .../scenes/hud/intelligence_log_panel.tscn | 6 + .../notifications/capital_blackout_overlay.gd | 71 +++++ .../capital_blackout_overlay.tscn | 6 + .../scenes/notifications/comms_toast.gd | 89 ++++++ .../scenes/notifications/comms_toast.tscn | 6 + 10 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 public/games/age-of-dwarves/data/comms_ui.json create mode 100644 src/game/engine/scenes/hud/comms_renderer.gd create mode 100644 src/game/engine/scenes/hud/comms_renderer.tscn create mode 100644 src/game/engine/scenes/hud/intelligence_log_panel.gd create mode 100644 src/game/engine/scenes/hud/intelligence_log_panel.tscn create mode 100644 src/game/engine/scenes/notifications/capital_blackout_overlay.gd create mode 100644 src/game/engine/scenes/notifications/capital_blackout_overlay.tscn create mode 100644 src/game/engine/scenes/notifications/comms_toast.gd create mode 100644 src/game/engine/scenes/notifications/comms_toast.tscn diff --git a/public/games/age-of-dwarves/data/comms.json b/public/games/age-of-dwarves/data/comms.json index 4a96a749..deaef03e 100644 --- a/public/games/age-of-dwarves/data/comms.json +++ b/public/games/age-of-dwarves/data/comms.json @@ -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 + } } diff --git a/public/games/age-of-dwarves/data/comms_ui.json b/public/games/age-of-dwarves/data/comms_ui.json new file mode 100644 index 00000000..0dba6335 --- /dev/null +++ b/public/games/age-of-dwarves/data/comms_ui.json @@ -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." + } +} diff --git a/src/game/engine/scenes/hud/comms_renderer.gd b/src/game/engine/scenes/hud/comms_renderer.gd new file mode 100644 index 00000000..abb9af3b --- /dev/null +++ b/src/game/engine/scenes/hud/comms_renderer.gd @@ -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") diff --git a/src/game/engine/scenes/hud/comms_renderer.tscn b/src/game/engine/scenes/hud/comms_renderer.tscn new file mode 100644 index 00000000..1995b4fd --- /dev/null +++ b/src/game/engine/scenes/hud/comms_renderer.tscn @@ -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") diff --git a/src/game/engine/scenes/hud/intelligence_log_panel.gd b/src/game/engine/scenes/hud/intelligence_log_panel.gd new file mode 100644 index 00000000..b94ed65c --- /dev/null +++ b/src/game/engine/scenes/hud/intelligence_log_panel.gd @@ -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 "+" diff --git a/src/game/engine/scenes/hud/intelligence_log_panel.tscn b/src/game/engine/scenes/hud/intelligence_log_panel.tscn new file mode 100644 index 00000000..255f78fa --- /dev/null +++ b/src/game/engine/scenes/hud/intelligence_log_panel.tscn @@ -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") diff --git a/src/game/engine/scenes/notifications/capital_blackout_overlay.gd b/src/game/engine/scenes/notifications/capital_blackout_overlay.gd new file mode 100644 index 00000000..c528f735 --- /dev/null +++ b/src/game/engine/scenes/notifications/capital_blackout_overlay.gd @@ -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 diff --git a/src/game/engine/scenes/notifications/capital_blackout_overlay.tscn b/src/game/engine/scenes/notifications/capital_blackout_overlay.tscn new file mode 100644 index 00000000..9f901db1 --- /dev/null +++ b/src/game/engine/scenes/notifications/capital_blackout_overlay.tscn @@ -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") diff --git a/src/game/engine/scenes/notifications/comms_toast.gd b/src/game/engine/scenes/notifications/comms_toast.gd new file mode 100644 index 00000000..909ca4c6 --- /dev/null +++ b/src/game/engine/scenes/notifications/comms_toast.gd @@ -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) diff --git a/src/game/engine/scenes/notifications/comms_toast.tscn b/src/game/engine/scenes/notifications/comms_toast.tscn new file mode 100644 index 00000000..235ec2ce --- /dev/null +++ b/src/game/engine/scenes/notifications/comms_toast.tscn @@ -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")