rimlike/scenes/ui/work_priority_matrix.gd
megaproxy 59ca6ba9c5 Phase 19 — onboarding: hint tour + Help modal + tooltip pass
Three-agent fan-out (gdscript-refactor x3) ships the chosen Phase 19
approach: contextual hints during first session + a Help reference,
plus a sweep of hover tooltips for desktop discoverability.

- HintSystem (autoload) + HintOverlay (layer 22 top-center banner):
  7-step tour gated on player events — welcome (boot+2s), pawn select,
  build drawer open, stockpile painted, work matrix open, day_ended,
  tour_complete. Per-hint dismissals persist as Array[String] in
  GameState.settings['dismissed_hints']. Max-3 FIFO queue if hints
  chain. Reduce-motion path snaps in/out instead of tweening.
  Reset_tour() public API for the Help modal.

- HelpModal (layer 20): 5-tab static reference (Controls / Verbs /
  Priorities / Storyteller / Tips). Opens via EventBus.help_requested,
  dimmed backdrop, X/Esc/backdrop-tap dismiss. SettingsMenu gains an
  'Onboarding' section: Show-hints checkbox, Help button (emits
  help_requested), Reset hints button (calls HintSystem.reset_tour with
  has_method guard). Pre-existing 'W' keybind reference fixed to 'P'.

- Tooltip pass: tooltip_text via Strings.t on every TopBar button
  (10 buttons incl. speed shortcuts), BuildDrawer FAB, and every tool
  button in BuildDrawer (21 tools). _add_tool_btn extended with optional
  tooltip param. ~34 new tooltip.* string keys.

Contracts pre-written (Opus): EventBus.help_requested, hint_dismissed,
ui_panel_opened signals; GameState show_hints + dismissed_hints
defaults; BuildDrawer.open + WorkPriorityMatrix.open emit
ui_panel_opened so HintSystem can subscribe via one signal.

Also recorded [MED] known bug in memory.md: drag-paint with active
paint tool is eaten by camera drag-pan.

MCP runtime verified: welcome banner fires 2s after boot, dismiss
queues build_drawer hint on next ui_panel_opened, dismissed_hints
persisted as ['welcome'], HelpModal opens via help_requested with
tab switching working (Controls → Tips verified visually).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:36:18 +01:00

254 lines
8.4 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 WorkPriorityMatrix extends CanvasLayer
## Phase 17 — Per-pawn work-priority matrix bottom-sheet.
##
## Opened via TopBar "Work" button (injected by main.gd).
## Displays one row per pawn × one column per player-configurable work category.
## Tapping a cell cycles the priority: 1 (Critical) → 2 → 3 → 4 (Low) → 0 (OFF) → 1.
##
## Priority semantics (mirrors Pawn.work_priorities and Decision layer 4):
## 0 = OFF (gray) — provider never picks this pawn for this category.
## 1 = Critical (red)
## 2 = High (orange)
## 3 = Normal (yellow) — default
## 4 = Low (blue)
##
## Category order matches the locked list in pawn.gd / Decision layer 4.
## &"doctor" is included — players can opt a pawn out of doctor duty even
## though the needs-driven healing path bypasses the filter at Layer 3.
##
## Save / load: NOT session-persistent for MVP — the matrix re-reads
## pawn.work_priorities live from the Pawn nodes every open, so any save that
## round-trips pawn.work_priorities (via pawn.to_dict) will reflect correctly.
const CATEGORIES: Array[StringName] = [
&"construction", &"chop", &"plant", &"mine", &"crafting", &"haul", &"clean", &"doctor"
]
const CATEGORY_LABELS: Dictionary = {
&"construction": "Build",
&"chop": "Chop",
&"plant": "Plant",
&"mine": "Mine",
&"crafting": "Craft",
&"haul": "Haul",
&"clean": "Clean",
&"doctor": "Doctor",
}
## Color per priority level for the cell buttons.
const PRIORITY_COLORS: Dictionary = {
0: Color(0.45, 0.45, 0.45, 1.0), # OFF — gray
1: Color(0.85, 0.18, 0.18, 1.0), # Critical — red
2: Color(0.90, 0.55, 0.10, 1.0), # High — orange
3: Color(0.85, 0.80, 0.10, 1.0), # Normal — yellow
4: Color(0.20, 0.40, 0.85, 1.0), # Low — blue
}
const PRIORITY_DISPLAY: Dictionary = {
0: "X",
1: "1",
2: "2",
3: "3",
4: "4",
}
## Panel height in pixels.
const PANEL_HEIGHT: float = 600.0
## Row height for each pawn row.
const ROW_H: float = 48.0
## Column width for category cells.
const COL_W: float = 64.0
## Width of the pawn-name column.
const NAME_COL_W: float = 100.0
var _root: Control = null
var _scroll: ScrollContainer = null
var _grid: GridContainer = null
## Flat list of [pawn, category, button] triples so we can refresh on open.
var _cells: Array = []
func _ready() -> void:
layer = 17
_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. Toggling _root.visible
# alone leaves the Backdrop alive and intercepting clicks.
visible = false
_root.visible = false
Audit.log("work_priority_ui", "WorkPriorityMatrix ready")
# ── public API ────────────────────────────────────────────────────────────────
## Open the matrix panel and rebuild the grid from current pawn state.
func open() -> void:
_rebuild_grid()
visible = true
_root.visible = true
EventBus.ui_panel_opened.emit(&"work_matrix")
Audit.log("work_priority_ui", "opened (pawns=%d)" % World.pawns.size())
## Close the panel.
func close() -> void:
visible = false
_root.visible = false
Audit.log("work_priority_ui", "closed")
# ── UI construction ───────────────────────────────────────────────────────────
func _build_ui() -> void:
# Full-screen semi-transparent backdrop.
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)
# Bottom-sheet panel anchored to the bottom of the screen.
_root = Control.new()
_root.name = "MatrixPanel"
_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 vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 4)
bg.add_child(vbox)
# Header row.
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 8)
vbox.add_child(header)
var title := Label.new()
title.text = "Work Priorities"
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)
# Scroll area for the grid (handles many pawns).
_scroll = ScrollContainer.new()
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
vbox.add_child(_scroll)
# GridContainer: 1 + CATEGORIES.size() columns.
_grid = GridContainer.new()
_grid.columns = 1 + CATEGORIES.size()
_grid.add_theme_constant_override("h_separation", 4)
_grid.add_theme_constant_override("v_separation", 4)
_scroll.add_child(_grid)
# ── grid rebuild ──────────────────────────────────────────────────────────────
func _rebuild_grid() -> void:
# Clear existing children and cell tracking.
for child in _grid.get_children():
child.queue_free()
_cells.clear()
# Header row: "Pawn" + one label per category.
_add_header_cell("Pawn")
for cat in CATEGORIES:
_add_header_cell(CATEGORY_LABELS.get(cat, String(cat)))
# One data row per pawn in World.pawns.
for pawn in World.pawns:
# Pawn name cell.
var name_lbl := Label.new()
name_lbl.text = pawn.pawn_name
name_lbl.custom_minimum_size = Vector2(NAME_COL_W, ROW_H)
name_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
name_lbl.clip_text = true
_grid.add_child(name_lbl)
# One button per category.
for cat in CATEGORIES:
var lvl: int = int(pawn.work_priorities.get(cat, 3))
var btn := Button.new()
btn.focus_mode = Control.FOCUS_NONE
btn.custom_minimum_size = Vector2(COL_W, ROW_H)
_apply_cell_style(btn, lvl)
# Capture pawn + category in the closure.
var p = pawn
var c: StringName = cat
btn.pressed.connect(func() -> void: _on_cell_pressed(p, c, btn))
_grid.add_child(btn)
_cells.append([pawn, cat, btn])
func _add_header_cell(text: String) -> void:
var lbl := Label.new()
lbl.text = text
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.custom_minimum_size = Vector2(COL_W, 32.0)
lbl.add_theme_font_size_override("font_size", 11)
_grid.add_child(lbl)
func _apply_cell_style(btn: Button, lvl: int) -> void:
btn.text = PRIORITY_DISPLAY.get(lvl, "?")
btn.modulate = PRIORITY_COLORS.get(lvl, Color.WHITE)
# ── cell interaction ──────────────────────────────────────────────────────────
func _on_cell_pressed(pawn, category: StringName, btn: Button) -> void:
# Cycle: 1 → 2 → 3 → 4 → 0 → 1
var current: int = int(pawn.work_priorities.get(category, 3))
var next: int
match current:
0: next = 1
1: next = 2
2: next = 3
3: next = 4
4: next = 0
_: next = 3
pawn.work_priorities[category] = next
_apply_cell_style(btn, next)
EventBus.pawn_priority_changed.emit(pawn, category, next)
Audit.log("work_priority_ui", "%s: %s%d" % [pawn.pawn_name, String(category), next])
func _unhandled_input(event: InputEvent) -> void:
# P — toggle the work priority matrix.
if event.is_action_pressed("open_priorities"):
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:
# Tap on the backdrop (outside the panel) closes the matrix.
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
close()
elif event is InputEventScreenTouch and event.pressed:
close()