rimlike/scenes/ui/alerts_log.gd
megaproxy 0b2e0fcd03 PC controls: keyboard pan/zoom, Tab cycle, Escape stack, right-click deselect
Adds full PC keyboard+mouse support on top of existing touch controls. Touch
paths untouched. All input goes through named actions in project.godot.

Bindings:
- WASD / arrows: camera pan (speed scales with zoom)
- = / -: keyboard zoom in/out
- C / Home: center on selected pawn
- Tab / Shift+Tab: cycle through pawns (pans camera to selection)
- B / L / P / ,: toggle BuildDrawer / AlertsLog / WorkPriorityMatrix / Settings
- Escape: cancel active designation tool > close topmost panel > deselect pawn
- Right-click: cancel active tool or deselect pawn (RTS convention)
- F: speed_cycle (action restored; handler still TODO)
- pawn_prev action removed; Shift+Tab read via event.shift_pressed inline

Escape priority enforced by Designation._input running before _unhandled_input
plus each panel consuming its own cancel action when visible.

Also fixes a pre-existing pre-Phase-17 bug: WorkPriorityMatrix, AlertsLog,
StorytellerModal, LoadMenu, and SettingsMenu had MOUSE_FILTER_STOP Controls
(Backdrop / Dim) that remained input-active when the panel was "closed" —
their open/close paths only toggled _root.visible / _panel.visible, never
CanvasLayer.visible. World mouse events (right-click deselect, left-click
pawn-select) were silently eaten. Now each _set_visible / open / close
toggles self.visible (the CanvasLayer) so input dispatch shuts off properly.

Verified end-to-end via MCP runtime: WASD pan, zoom keys, Tab+Shift+Tab
cycle, B-open + Escape-close, right-click deselect, left-click pawn-select
all working in sequence with no input bleed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:06:38 +01:00

306 lines
10 KiB
GDScript

class_name AlertsLog extends CanvasLayer
## Phase 17 — Scrollable alerts / event log bottom-sheet.
##
## Opened via TopBar "Log" button (injected by main.gd).
## Subscribes to:
## EventBus.alert_added(severity, text, focus_tile)
## EventBus.storyteller_event_fired(event) — translated to an alert entry
## EventBus.day_ended(summary) — produces a one-line day-summary entry
##
## Internal ring buffer capped at RING_CAP (50) entries; oldest dropped.
## Session-scoped — no save/load round-trip required for MVP.
##
## Severity icons (inline text prefix):
## info → "[I]" blue
## warn → "[!]" yellow
## danger → "[!!]" red
##
## Timestamp format: "Day D, HH:MM"
const RING_CAP: int = 50
const PANEL_HEIGHT: float = 500.0
const SEVERITY_COLORS: Dictionary = {
&"info": Color(0.30, 0.55, 0.95, 1.0),
&"warn": Color(0.95, 0.80, 0.10, 1.0),
&"danger": Color(0.90, 0.15, 0.10, 1.0),
}
const SEVERITY_PREFIX: Dictionary = {
&"info": "[I]",
&"warn": "[!]",
&"danger": "[!!]",
}
## Maps EventDef.Category → default alert severity for storyteller events.
const STORYTELLER_SEVERITY: Dictionary = {
0: &"info", # NUDGE
1: &"info", # SEASONAL
2: &"info", # WANDERER
3: &"danger", # THREAT
4: &"warn", # DISEASE
5: &"info", # RESOURCE
6: &"info", # LORE
7: &"info", # MILESTONE
}
## Unread badge count — increments on add, resets on open.
var _unread_count: int = 0
## Ring buffer of alert entry dicts:
## { "severity": StringName, "timestamp": String, "text": String, "focus_tile": Vector2i }
var _entries: Array = []
var _root: Control = null
var _scroll: ScrollContainer = null
var _vbox: VBoxContainer = null
var _badge_btn: Button = null
## The button node injected from TopBar (the "Log" button).
## AlertsLog updates its text with the unread count badge.
var log_button: Button = null
func _ready() -> void:
layer = 19
_build_ui()
# Hide the whole CanvasLayer when closed so the Backdrop ColorRect (which
# has MOUSE_FILTER_STOP for click-outside-to-close) does not eat mouse
# events in `_unhandled_input` for the world below.
visible = false
_root.visible = false
EventBus.alert_added.connect(_on_alert_added)
if EventBus.has_signal("storyteller_event_fired"):
EventBus.storyteller_event_fired.connect(_on_storyteller_event)
if EventBus.has_signal("day_ended"):
EventBus.day_ended.connect(_on_day_ended)
Audit.log("alerts_log", "AlertsLog ready")
func _exit_tree() -> void:
if EventBus.alert_added.is_connected(_on_alert_added):
EventBus.alert_added.disconnect(_on_alert_added)
if EventBus.has_signal("storyteller_event_fired") and EventBus.storyteller_event_fired.is_connected(_on_storyteller_event):
EventBus.storyteller_event_fired.disconnect(_on_storyteller_event)
if EventBus.has_signal("day_ended") and EventBus.day_ended.is_connected(_on_day_ended):
EventBus.day_ended.disconnect(_on_day_ended)
# ── public API ────────────────────────────────────────────────────────────────
func open() -> void:
_rebuild_list()
visible = true
_root.visible = true
_unread_count = 0
_update_badge()
Audit.log("alerts_log", "opened (entries=%d)" % _entries.size())
func close() -> void:
visible = false
_root.visible = false
Audit.log("alerts_log", "closed")
# ── UI construction ───────────────────────────────────────────────────────────
func _build_ui() -> void:
var backdrop := ColorRect.new()
backdrop.name = "Backdrop"
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
backdrop.color = Color(0.0, 0.0, 0.0, 0.45)
backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
backdrop.gui_input.connect(_on_backdrop_input)
add_child(backdrop)
_root = Control.new()
_root.name = "LogPanel"
_root.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
_root.custom_minimum_size = Vector2(0.0, PANEL_HEIGHT)
_root.offset_top = -PANEL_HEIGHT
_root.offset_bottom = 0.0
_root.mouse_filter = Control.MOUSE_FILTER_STOP
backdrop.add_child(_root)
var bg := PanelContainer.new()
bg.name = "BG"
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(bg)
var outer_vbox := VBoxContainer.new()
outer_vbox.add_theme_constant_override("separation", 4)
bg.add_child(outer_vbox)
# Header.
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 8)
outer_vbox.add_child(header)
var title := Label.new()
title.text = "Alerts"
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(title)
var close_btn := Button.new()
close_btn.text = "Close"
close_btn.focus_mode = Control.FOCUS_NONE
close_btn.custom_minimum_size = Vector2(72.0, 36.0)
close_btn.pressed.connect(close)
header.add_child(close_btn)
# Scrollable entry list.
_scroll = ScrollContainer.new()
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
outer_vbox.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)
# ── entry management ──────────────────────────────────────────────────────────
func _push_entry(severity: StringName, text: String, focus_tile: Vector2i) -> void:
var entry: Dictionary = {
"severity": severity,
"timestamp": _format_timestamp(),
"text": text,
"focus_tile": focus_tile,
}
_entries.push_front(entry) # newest first
if _entries.size() > RING_CAP:
_entries.resize(RING_CAP)
_unread_count += 1
_update_badge()
func _rebuild_list() -> void:
for child in _vbox.get_children():
child.queue_free()
for entry in _entries:
_add_entry_row(entry)
func _add_entry_row(entry: Dictionary) -> void:
var sev: StringName = entry.get("severity", &"info")
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 6)
_vbox.add_child(row)
# Severity color icon label.
var icon_lbl := Label.new()
icon_lbl.text = SEVERITY_PREFIX.get(sev, "[i]")
icon_lbl.modulate = SEVERITY_COLORS.get(sev, Color.WHITE)
icon_lbl.custom_minimum_size = Vector2(32.0, 0.0)
row.add_child(icon_lbl)
# Timestamp.
var ts_lbl := Label.new()
ts_lbl.text = entry.get("timestamp", "")
ts_lbl.custom_minimum_size = Vector2(90.0, 0.0)
ts_lbl.add_theme_font_size_override("font_size", 11)
row.add_child(ts_lbl)
# Text — expands to fill.
var text_lbl := Label.new()
text_lbl.text = entry.get("text", "")
text_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
text_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
row.add_child(text_lbl)
# "Go there" button if focus tile is valid.
var ft: Vector2i = entry.get("focus_tile", Vector2i(-1, -1))
if ft != Vector2i(-1, -1):
var go_btn := Button.new()
go_btn.text = "Go"
go_btn.focus_mode = Control.FOCUS_NONE
go_btn.custom_minimum_size = Vector2(48.0, 32.0)
var tile := ft
go_btn.pressed.connect(func() -> void: _pan_to(tile))
row.add_child(go_btn)
# ── signal handlers ───────────────────────────────────────────────────────────
func _on_alert_added(severity: StringName, text: String, focus_tile: Vector2i) -> void:
_push_entry(severity, text, focus_tile)
Audit.log("alerts_log", "[%s] %s" % [severity, text])
func _on_storyteller_event(event) -> void:
# Translate EventDef → alert entry. severity derived from category enum value.
var cat_int: int = int(event.get("category") if event.get("category") != null else 0)
var sev: StringName = STORYTELLER_SEVERITY.get(cat_int, &"info")
var title_text: String = event.get("title") if event.get("title") != null else "Event"
var body_text: String = event.get("body") if event.get("body") != null else ""
var ft: Vector2i = Vector2i(-1, -1)
if event.get("focus_tile") != null:
ft = event.focus_tile
_push_entry(sev, "%s%s" % [title_text, body_text], ft)
func _on_day_ended(summary: Dictionary) -> void:
var day: int = int(summary.get("day", 0))
var season: StringName = StringName(summary.get("season", &""))
var pawns: int = int(summary.get("pawns_alive", 0))
var tension: float = float(summary.get("tension", 0.0))
var wolves: int = int(summary.get("wolves_alive", 0))
var text: String = "Day %d ended — %s — pawns: %d, wolves: %d, tension: %.0f" % [
day, String(season), pawns, wolves, tension
]
_push_entry(&"info", text, Vector2i(-1, -1))
Audit.log("alerts_log", "day_ended logged: %s" % text)
# ── helpers ───────────────────────────────────────────────────────────────────
func _format_timestamp() -> String:
if Clock == null:
return ""
return "Day %d, %s" % [Clock.current_day(), Clock.time_string()]
func _update_badge() -> void:
if log_button == null:
return
if _unread_count > 0:
log_button.text = "Log [%d]" % _unread_count
else:
log_button.text = "Log"
func _pan_to(tile: Vector2i) -> void:
var cam = get_node_or_null("/root/Main/World/CameraRig")
if cam == null:
Audit.log("alerts_log", "pan_to: CameraRig not found")
return
if cam.has_method("pan_to_tile"):
cam.pan_to_tile(tile)
else:
cam.position = Vector2(tile.x * 16 + 8, tile.y * 16 + 8)
close()
func _unhandled_input(event: InputEvent) -> void:
# L — toggle the alerts log.
if event.is_action_pressed("open_log"):
if _root != null and _root.visible:
close()
else:
open()
get_viewport().set_input_as_handled()
return
# Escape — close if open.
if event.is_action_pressed("cancel") and _root != null and _root.visible:
close()
get_viewport().set_input_as_handled()
return
func _on_backdrop_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
close()
elif event is InputEventScreenTouch and event.pressed:
close()