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()