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:
parent
3189dc7b3f
commit
538395c749
10 changed files with 701 additions and 1 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
70
public/games/age-of-dwarves/data/comms_ui.json
Normal file
70
public/games/age-of-dwarves/data/comms_ui.json
Normal 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."
|
||||
}
|
||||
}
|
||||
299
src/game/engine/scenes/hud/comms_renderer.gd
Normal file
299
src/game/engine/scenes/hud/comms_renderer.gd
Normal 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")
|
||||
6
src/game/engine/scenes/hud/comms_renderer.tscn
Normal file
6
src/game/engine/scenes/hud/comms_renderer.tscn
Normal 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")
|
||||
136
src/game/engine/scenes/hud/intelligence_log_panel.gd
Normal file
136
src/game/engine/scenes/hud/intelligence_log_panel.gd
Normal 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 "+"
|
||||
6
src/game/engine/scenes/hud/intelligence_log_panel.tscn
Normal file
6
src/game/engine/scenes/hud/intelligence_log_panel.tscn
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
89
src/game/engine/scenes/notifications/comms_toast.gd
Normal file
89
src/game/engine/scenes/notifications/comms_toast.gd
Normal 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)
|
||||
6
src/game/engine/scenes/notifications/comms_toast.tscn
Normal file
6
src/game/engine/scenes/notifications/comms_toast.tscn
Normal 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")
|
||||
Loading…
Add table
Reference in a new issue