rimlike/scenes/ui/pawn_detail_panel.gd
megaproxy b9093dd24b 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>
2026-05-11 19:45:35 +01:00

418 lines
15 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name PawnDetailPanel extends CanvasLayer
## Phase 17 — Right-side bottom-sheet pawn inspector.
##
## Layer 18: above banner (15) and world, below modal (20) and load_menu (25).
## Opens when EventBus.pawn_selected fires; closes on pawn_deselected or pawn_died.
## Repopulates every 5 sim ticks while open so vitals stay live.
##
## Touch targets: all interactive controls are at least 48×48 px.
## Background elements use MOUSE_FILTER_IGNORE so world taps pass through,
## allowing the player to tap a different pawn to swap panels without closing first.
const PANEL_WIDTH: int = 360
const REFRESH_TICKS: int = 5 # repopulate every N sim ticks
# ── internal state ────────────────────────────────────────────────────────────
var _pawn = null # currently displayed Pawn (may become null on death)
var _tick_counter: int = 0 # counts sim ticks since last repopulate
# ── node refs (built in _build_ui) ───────────────────────────────────────────
var _panel: PanelContainer = null
var _pawn_name_label: Label = null
var _portrait_rect: ColorRect = null
var _close_btn: Button = null
var _hp_bar: ProgressBar = null
var _hunger_bar: ProgressBar = null
var _sleep_bar: ProgressBar = null
var _sulk_badge: Label = null
var _job_label: Label = null
var _mood_label: Label = null
var _thoughts_vbox: VBoxContainer = null
var _statuses_vbox: VBoxContainer = null
var _skills_vbox: VBoxContainer = null
var _priorities_label: Label = null
func _ready() -> void:
layer = 18
_build_ui()
_set_visible(false)
EventBus.pawn_selected.connect(_on_pawn_selected)
EventBus.pawn_deselected.connect(_on_pawn_deselected)
if EventBus.has_signal("pawn_died"):
EventBus.pawn_died.connect(_on_pawn_died)
EventBus.sim_tick.connect(_on_sim_tick)
Audit.log("pawn_detail_panel", "PawnDetailPanel ready (layer %d)" % layer)
func _exit_tree() -> void:
if EventBus.pawn_selected.is_connected(_on_pawn_selected):
EventBus.pawn_selected.disconnect(_on_pawn_selected)
if EventBus.pawn_deselected.is_connected(_on_pawn_deselected):
EventBus.pawn_deselected.disconnect(_on_pawn_deselected)
if EventBus.has_signal("pawn_died") and EventBus.pawn_died.is_connected(_on_pawn_died):
EventBus.pawn_died.disconnect(_on_pawn_died)
if EventBus.sim_tick.is_connected(_on_sim_tick):
EventBus.sim_tick.disconnect(_on_sim_tick)
# ── UI construction ───────────────────────────────────────────────────────────
func _build_ui() -> void:
# Right-side sheet — anchored to the right edge, full height.
_panel = PanelContainer.new()
_panel.name = "PawnDetailSheet"
# Anchor: right strip.
_panel.anchor_left = 1.0
_panel.anchor_right = 1.0
_panel.anchor_top = 0.0
_panel.anchor_bottom = 1.0
_panel.offset_left = -PANEL_WIDTH
_panel.offset_right = 0.0
_panel.offset_top = 0.0
_panel.offset_bottom = 0.0
# Pass-through for the background so world taps reach the Selection handler.
_panel.mouse_filter = Control.MOUSE_FILTER_PASS
add_child(_panel)
# Scrollable inner container so content survives small screens.
var scroll := ScrollContainer.new()
scroll.name = "Scroll"
scroll.set_anchors_preset(Control.PRESET_FULL_RECT)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_panel.add_child(scroll)
var vbox := VBoxContainer.new()
vbox.name = "Content"
vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vbox.add_theme_constant_override("separation", 6)
scroll.add_child(vbox)
# ── Header ────────────────────────────────────────────────────────────────
var header := HBoxContainer.new()
header.name = "Header"
header.add_theme_constant_override("separation", 8)
vbox.add_child(header)
_portrait_rect = ColorRect.new()
_portrait_rect.name = "Portrait"
_portrait_rect.custom_minimum_size = Vector2(32, 32)
_portrait_rect.color = Color.WHITE
_portrait_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
header.add_child(_portrait_rect)
_pawn_name_label = Label.new()
_pawn_name_label.name = "PawnName"
_pawn_name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_pawn_name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_pawn_name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
header.add_child(_pawn_name_label)
_close_btn = Button.new()
_close_btn.name = "CloseBtn"
_close_btn.text = Strings.t(&"ui.detail.close")
_close_btn.custom_minimum_size = Vector2(48, 48)
_close_btn.focus_mode = Control.FOCUS_NONE
_close_btn.pressed.connect(_on_close_pressed)
header.add_child(_close_btn)
_add_separator(vbox)
# ── Vitals ────────────────────────────────────────────────────────────────
var vitals_vbox := VBoxContainer.new()
vitals_vbox.name = "Vitals"
vitals_vbox.add_theme_constant_override("separation", 4)
vitals_vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(vitals_vbox)
_hp_bar = _make_stat_bar(Strings.t(&"ui.detail.hp"), vitals_vbox)
_hunger_bar = _make_stat_bar(Strings.t(&"ui.detail.hunger"), vitals_vbox)
_sleep_bar = _make_stat_bar(Strings.t(&"ui.detail.sleep"), vitals_vbox)
_add_separator(vbox)
# ── Status row (sulk badge + job) ─────────────────────────────────────────
var status_row := HBoxContainer.new()
status_row.name = "StatusRow"
status_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(status_row)
_sulk_badge = Label.new()
_sulk_badge.name = "SulkBadge"
_sulk_badge.text = Strings.t(&"ui.detail.sulking")
_sulk_badge.modulate = Color(1.0, 0.25, 0.25)
_sulk_badge.visible = false
_sulk_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
status_row.add_child(_sulk_badge)
_job_label = Label.new()
_job_label.name = "JobLabel"
_job_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_job_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_job_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
status_row.add_child(_job_label)
_add_separator(vbox)
# ── Mood + thoughts ───────────────────────────────────────────────────────
_mood_label = Label.new()
_mood_label.name = "MoodLabel"
_mood_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(_mood_label)
_thoughts_vbox = VBoxContainer.new()
_thoughts_vbox.name = "Thoughts"
_thoughts_vbox.add_theme_constant_override("separation", 2)
_thoughts_vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(_thoughts_vbox)
_add_separator(vbox)
# ── Statuses ──────────────────────────────────────────────────────────────
var stat_header := Label.new()
stat_header.text = Strings.t(&"ui.detail.statuses")
stat_header.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(stat_header)
_statuses_vbox = VBoxContainer.new()
_statuses_vbox.name = "Statuses"
_statuses_vbox.add_theme_constant_override("separation", 2)
_statuses_vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(_statuses_vbox)
_add_separator(vbox)
# ── Skills ────────────────────────────────────────────────────────────────
var skills_header := Label.new()
skills_header.text = Strings.t(&"ui.detail.skills")
skills_header.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(skills_header)
_skills_vbox = VBoxContainer.new()
_skills_vbox.name = "Skills"
_skills_vbox.add_theme_constant_override("separation", 2)
_skills_vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(_skills_vbox)
_add_separator(vbox)
# ── Work priorities (read-only; Agent C adds the tap-to-cycle matrix) ─────
var prio_header := Label.new()
prio_header.text = Strings.t(&"ui.detail.priorities")
prio_header.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(prio_header)
_priorities_label = Label.new()
_priorities_label.name = "Priorities"
_priorities_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_priorities_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(_priorities_label)
## Build a labelled ProgressBar row inside parent_vbox. Returns the ProgressBar.
func _make_stat_bar(label_text: String, parent: VBoxContainer) -> ProgressBar:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 6)
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(row)
var lbl := Label.new()
lbl.text = label_text
lbl.custom_minimum_size = Vector2(56, 0)
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(lbl)
var bar := ProgressBar.new()
bar.min_value = 0.0
bar.max_value = 100.0
bar.value = 100.0
bar.show_percentage = false
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bar.custom_minimum_size = Vector2(0, 20)
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(bar)
return bar
func _add_separator(parent: VBoxContainer) -> void:
var sep := HSeparator.new()
sep.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(sep)
# ── event handlers ────────────────────────────────────────────────────────────
func _on_pawn_selected(pawn) -> void:
_pawn = pawn
_tick_counter = 0
_populate()
_set_visible(true)
Audit.log("pawn_detail_panel", "opened for %s" % pawn.pawn_name)
func _on_pawn_deselected() -> void:
_pawn = null
_set_visible(false)
Audit.log("pawn_detail_panel", "closed (deselected)")
func _on_pawn_died(pawn, _cause: StringName) -> void:
if _pawn == pawn or _pawn == null:
_pawn = null
_set_visible(false)
Audit.log("pawn_detail_panel", "closed (pawn died)")
func _on_close_pressed() -> void:
# Mirror a deselect — clear selection state so the world is consistent.
_pawn = null
_set_visible(false)
EventBus.pawn_deselected.emit()
Audit.log("pawn_detail_panel", "closed (X button)")
func _on_sim_tick(_tick_number: int) -> void:
if _pawn == null or not _panel.visible:
return
# Guard against a pawn that was freed without emitting pawn_died.
if not is_instance_valid(_pawn):
_pawn = null
_set_visible(false)
return
_tick_counter += 1
if _tick_counter >= REFRESH_TICKS:
_tick_counter = 0
_populate()
# ── population ────────────────────────────────────────────────────────────────
func _populate() -> void:
if _pawn == null or not is_instance_valid(_pawn):
return
# Header.
_pawn_name_label.text = _pawn.pawn_name
_portrait_rect.color = _pawn.portrait_color
# Vitals.
_hp_bar.value = _pawn.hp
_color_threshold_bar(_hp_bar, _pawn.hp, 30.0, 60.0)
_hunger_bar.value = _pawn.hunger
_color_threshold_bar(_hunger_bar, _pawn.hunger, 30.0, 60.0)
_sleep_bar.value = _pawn.sleep
_color_threshold_bar(_sleep_bar, _pawn.sleep, 30.0, 60.0)
# Status row.
_sulk_badge.visible = _pawn.sulking
var job_text: String = Strings.t(&"ui.detail.idle")
if _pawn.job_runner != null and _pawn.job_runner.has_method("current_job_label"):
var lbl: String = _pawn.job_runner.current_job_label()
if lbl != "":
job_text = lbl
elif _pawn.job_runner != null and _pawn.job_runner.get("current_job") != null:
var cj = _pawn.job_runner.get("current_job")
if cj != null and cj.get("label") != null and cj.label != "":
job_text = cj.label
_job_label.text = job_text
# Mood.
var mood_val: float = _pawn.mood
_mood_label.text = "%s: %.0f" % [Strings.t(&"ui.detail.mood"), mood_val]
if mood_val < 30.0:
_mood_label.modulate = Color(1.0, 0.3, 0.3)
elif mood_val < 60.0:
_mood_label.modulate = Color(1.0, 0.85, 0.3)
else:
_mood_label.modulate = Color(0.4, 1.0, 0.4)
# Thoughts — top 5 by |modifier × stacks| desc.
_clear_children(_thoughts_vbox)
var sorted_thoughts: Array = _pawn.thoughts.duplicate()
sorted_thoughts.sort_custom(func(a, b) -> bool:
return abs(a.modifier * a.stacks) > abs(b.modifier * b.stacks)
)
var count: int = mini(5, sorted_thoughts.size())
for i in range(count):
var t = sorted_thoughts[i]
var delta: int = t.modifier * mini(t.stacks, t.max_stacks)
var sign_str: String = "+" if delta >= 0 else ""
var row := Label.new()
row.text = " %s %s%d" % [t.label, sign_str, delta]
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
_thoughts_vbox.add_child(row)
# Statuses.
_clear_children(_statuses_vbox)
for s in _pawn.statuses:
var row := Label.new()
row.text = " %s %s" % [s.label, Strings.t(&"ui.detail.sev").format({"s": s.severity, "m": s.max_severity})]
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
_statuses_vbox.add_child(row)
# Skills.
_clear_children(_skills_vbox)
var skill_key_map: Array = [
[&"manual_labor", &"ui.detail.skill.manual_labor"],
[&"crafting", &"ui.detail.skill.crafting"],
[&"cooking", &"ui.detail.skill.cooking"],
[&"medicine", &"ui.detail.skill.medicine"],
[&"combat", &"ui.detail.skill.combat"],
]
for pair in skill_key_map:
var skill_id: StringName = pair[0]
var skill_lbl: StringName = pair[1]
if not _pawn.skills.has(skill_id):
continue
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
_skills_vbox.add_child(row)
var name_lbl := Label.new()
name_lbl.text = Strings.t(skill_lbl)
name_lbl.custom_minimum_size = Vector2(72, 0)
name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(name_lbl)
var val_lbl := Label.new()
val_lbl.text = str(_pawn.skills[skill_id])
val_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(val_lbl)
# Work priorities — comma-separated single line (Agent C adds interactive matrix).
var prio_parts: Array[String] = []
for cat in _pawn.work_priorities:
prio_parts.append("%s: %d" % [String(cat), _pawn.work_priorities[cat]])
_priorities_label.text = ", ".join(prio_parts)
## Color a progress bar by value thresholds: red below low, yellow below high, green above.
func _color_threshold_bar(bar: ProgressBar, value: float, low: float, high: float) -> void:
if value < low:
bar.modulate = Color(1.0, 0.25, 0.25)
elif value < high:
bar.modulate = Color(1.0, 0.85, 0.25)
else:
bar.modulate = Color(0.3, 1.0, 0.3)
func _clear_children(node: Node) -> void:
for child in node.get_children():
child.queue_free()
# ── visibility ────────────────────────────────────────────────────────────────
func _set_visible(v: bool) -> void:
if _panel != null:
_panel.visible = v