Phase 17: Touch UX (PawnDetail+BuildDrawer+WorkMatrix+AlertsLog+Settings)
Three-agent fan-out shipping the major touch UI surfaces. Opus pre-wrote 6 EventBus signals (pawn_selected/deselected, pawn_priority_changed, alert_added, request_wolf_spawn, day_ended) + Pawn.work_priorities Dictionary stub before dispatch. Pattern proven across Phases 12-17. Pawn detail + Settings (Agent A): - scenes/ui/pawn_detail_panel.gd — right-side CanvasLayer (layer 18), ~360px wide, opens on EventBus.pawn_selected. Renders portrait, HP/Hunger/Sleep bars with threshold colors, current job, mood + sulking, statuses, top 5 mood thoughts, full skill table, read-only work-priorities row. Live-refreshes each sim tick. - scenes/ui/settings_menu.gd — modal CanvasLayer (layer 26), opened via Settings button. Auto-pause toggles (Threat/Wanderer/Pawn-Down/ Modal), audio sliders (stubs for Phase 18), accessibility checkboxes. Persists via GameState.apply_settings. - scenes/world/selection.gd — extended to emit pawn_selected/deselected through EventBus on tap. Build drawer + 12 new Designation tools (Agent B): - scenes/ui/build_drawer.gd — bottom-sheet CanvasLayer (layer 16) with 4 tabs (Designate/Build/Stockpile/Cancel) + FAB ⊕ open button. Each tab has HFlowContainer of 80×80 buttons with procedural colored icons + label. Tap → Designation.set_active_tool + alert + auto-close. - Designation: added TOOL_CHOP, TOOL_MINE, TOOL_BUILD_CRATE, TOOL_BUILD_BED, TOOL_BUILD_TORCH, 5× TOOL_BUILD_WORKBENCH_* variants, TOOL_PAINT_STOCKPILE. Plus tool_material override for wall/floor. - World._on_designation_added: extended dispatch for all 12 new tools; added _spawn_workbench() helper for the 5 bench kinds. Work matrix + Alerts log + Decision refactor + Wolf signal (Agent C): - scenes/ai/decision.gd: Layer 4 now filters by pawn.work_priorities (0=OFF skip, sort by level ascending with provider.priority tiebreak). NEEDS_CATEGORIES (rest/eat/sleep) bypass the filter — a pawn can never starve from misconfiguration. Audit log prefixes work decisions with (pri=N). - scenes/ui/work_priority_matrix.gd — CanvasLayer (layer 17) bottom-sheet grid: rows=pawns × cols=8 work categories. Each cell tap-cycles 1→2→3→4→0→1, color-coded (red/orange/yellow/blue/gray). Writes back to pawn.work_priorities + emits pawn_priority_changed. - scenes/ui/alerts_log.gd — CanvasLayer (layer 19) ring buffer 50 entries. Newest first, severity icon (info/warn/danger), Day HH:MM timestamp, Go-there camera pan. Listens to alert_added + storyteller_event_fired + day_ended. - EventBus.request_wolf_spawn wired end-to-end: EventCatalog _spawn_wolves emits; WolfSpawner._on_request_wolf_spawn force-spawns bypassing the darkness/cooldown gates. - Clock emits EventBus.day_ended(summary) at dusk→night transition. Top bar buttons added in order: ‖ / 1× / 5× / 12× / Save / Load / Settings / Build / Work / Log[N]. Plus the ⊕ FAB at bottom-right. MCP runtime verified all 4 surfaces via screenshot: - PawnDetailPanel: Bram shows Crafting=8 / Cooking=2 / Manual=0 matching seed; bars green; Mood: 50; work-priorities readout - BuildDrawer: 4 tabs visible, Designate tab shows Chop/Mine/Dig grave/ No roof buttons with procedural icons - WorkPriorityMatrix: 3 pawns × 8 categories, all '3' (NORMAL default) cells in yellow, tap-to-cycle ready - AlertsLog: 4 entries — red 'Wolf pack approaching!' danger, blue 'Bram is at the cabin' info, yellow 'Test alert' warn, blue 'Spring Awakens' from boot storyteller roll. Go-there button per entry. Mouse drag-paint works as-is (user noted). Existing Selection/Designation _unhandled_input handles drag. Deferred to Phase 17.5 polish: - Per-pawn/per-job view layers on the matrix - Stockpile 4×4 chip filter UI (paint creates 1×1 zones today) - Bill UI for workbenches (programmatic only today) - 'No stockpile accepts X' / 'Bill blocked' alert emit wiring - DaySummaryCard visual (signal emits today, no card UI) - Wanderer recruit UI, resource buff system Delegation: 3× gdscript-refactor (Sonnet) agents in parallel; integration + MCP verify on Opus. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
19d28ca9f8
commit
b9093dd24b
25 changed files with 2138 additions and 44 deletions
284
scenes/ui/alerts_log.gd
Normal file
284
scenes/ui/alerts_log.gd
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
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()
|
||||
_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()
|
||||
_root.visible = true
|
||||
_unread_count = 0
|
||||
_update_badge()
|
||||
Audit.log("alerts_log", "opened (entries=%d)" % _entries.size())
|
||||
|
||||
|
||||
func close() -> void:
|
||||
_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 _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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue