rimlike/scenes/ui/build_drawer.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

380 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 BuildDrawer extends CanvasLayer
## Phase 17 — Build drawer bottom-sheet.
##
## A mobile-first panel for issuing Designation orders and queuing build jobs.
## Closed state: a 40×40 "+" button at bottom-right (always visible).
## Open state: a full-width panel ~600 px tall with four tabs:
## Designate | Build | Stockpile | Cancel
##
## Tapping any tool button calls Designation.set_active_tool(), emits
## EventBus.alert_added(&"info", …) and auto-closes the drawer so the player
## can begin painting tiles immediately.
##
## Layer 16 — above storyteller banner (15), below modal (20).
# ── layout constants ─────────────────────────────────────────────────────────
const LAYER_ORDER: int = 16
const PANEL_HEIGHT: int = 600
const BTN_SIZE: int = 80 # preferred hit area for build buttons
const FAB_SIZE: int = 48 # floating action button (open trigger)
const TAB_HEIGHT: int = 48
const LABEL_HEIGHT: int = 20
const FLOW_COLS: int = 4 # buttons per row in the flow grid
# ── tab indices ──────────────────────────────────────────────────────────────
const TAB_DESIGNATE: int = 0
const TAB_BUILD: int = 1
const TAB_STOCKPILE: int = 2
const TAB_CANCEL: int = 3
# ── state ────────────────────────────────────────────────────────────────────
var _open: bool = false
var _active_tab: int = TAB_DESIGNATE
# ── node refs (built at runtime) ─────────────────────────────────────────────
var _fab: Button = null # floating ⊕ button (always visible)
var _panel: PanelContainer = null
var _close_btn: Button = null
var _tab_btns: Array[Button] = []
var _tab_containers: Array[Control] = []
# ── build-wall material chooser state ────────────────────────────────────────
# When the player first taps the Stone/Wood Wall button we show an inline
# material row; a second tap on the same button commits the choice.
var _wall_pending_mat: StringName = &""
var _floor_pending_mat: StringName = &""
var _wall_mat_row: HBoxContainer = null
var _floor_mat_row: HBoxContainer = null
## Injected by main.gd; the shared Designation controller on the World node.
var designation: Designation = null
func _ready() -> void:
layer = LAYER_ORDER
_build_ui()
_set_panel_visible(false)
Audit.log("build_drawer", "BuildDrawer ready (layer %d)" % layer)
# ── public API ───────────────────────────────────────────────────────────────
func open() -> void:
_set_panel_visible(true)
Audit.log("build_drawer", "opened (tab=%d)" % _active_tab)
func close() -> void:
_set_panel_visible(false)
Audit.log("build_drawer", "closed")
func toggle() -> void:
if _open:
close()
else:
open()
# ── UI construction ──────────────────────────────────────────────────────────
func _build_ui() -> void:
# Root control — full viewport anchor so anchors on children work.
var root := Control.new()
root.name = "Root"
root.set_anchors_preset(Control.PRESET_FULL_RECT)
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(root)
# Floating action button — bottom-right, always visible.
_fab = Button.new()
_fab.name = "FAB"
_fab.text = "+"
_fab.custom_minimum_size = Vector2(FAB_SIZE, FAB_SIZE)
_fab.focus_mode = Control.FOCUS_NONE
_fab.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
_fab.offset_left = -FAB_SIZE - 8
_fab.offset_right = -8
_fab.offset_top = -FAB_SIZE - 8
_fab.offset_bottom = -8
_fab.pressed.connect(toggle)
root.add_child(_fab)
# Panel — full-width, anchored to the bottom of the screen.
_panel = PanelContainer.new()
_panel.name = "BuildPanel"
_panel.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
_panel.offset_top = -PANEL_HEIGHT
_panel.offset_bottom = 0
root.add_child(_panel)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 0)
_panel.add_child(vbox)
# ── header row (tabs + close button) ────────────────────────────────────
var header := HBoxContainer.new()
header.name = "Header"
header.custom_minimum_size = Vector2(0, TAB_HEIGHT)
header.add_theme_constant_override("separation", 2)
vbox.add_child(header)
var tab_names: Array[StringName] = [
&"ui.build_drawer.designate",
&"ui.build_drawer.build",
&"ui.build_drawer.stockpile",
&"ui.build_drawer.cancel",
]
_tab_btns.clear()
for i in tab_names.size():
var tb := Button.new()
tb.text = Strings.t(tab_names[i])
tb.custom_minimum_size = Vector2(0, TAB_HEIGHT)
tb.focus_mode = Control.FOCUS_NONE
tb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var idx := i # capture for closure
tb.pressed.connect(func() -> void: _select_tab(idx))
header.add_child(tb)
_tab_btns.append(tb)
_close_btn = Button.new()
_close_btn.name = "CloseBtn"
_close_btn.text = "X"
_close_btn.custom_minimum_size = Vector2(TAB_HEIGHT, TAB_HEIGHT)
_close_btn.focus_mode = Control.FOCUS_NONE
_close_btn.pressed.connect(close)
header.add_child(_close_btn)
# ── scroll area for tab content ──────────────────────────────────────────
var scroll := ScrollContainer.new()
scroll.name = "Scroll"
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
vbox.add_child(scroll)
var content_stack := HBoxContainer.new()
content_stack.name = "ContentStack"
content_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
# We only show one tab at a time by hiding the others.
scroll.add_child(content_stack)
# Build each tab panel.
_tab_containers.clear()
_tab_containers.append(_build_designate_tab())
_tab_containers.append(_build_build_tab())
_tab_containers.append(_build_stockpile_tab())
_tab_containers.append(_build_cancel_tab())
for tc in _tab_containers:
tc.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content_stack.add_child(tc)
_select_tab(TAB_DESIGNATE)
func _build_designate_tab() -> Control:
var box := VBoxContainer.new()
box.name = "DesignateTab"
box.add_theme_constant_override("separation", 8)
var flow := _make_flow_grid()
box.add_child(flow)
_add_tool_btn(flow, Strings.t(&"tool.chop"), Color(0.3, 0.7, 0.2), func() -> void: _activate(&"chop", &"", Strings.t(&"tool.chop")))
_add_tool_btn(flow, Strings.t(&"tool.mine"), Color(0.6, 0.6, 0.6), func() -> void: _activate(&"mine", &"", Strings.t(&"tool.mine")))
_add_tool_btn(flow, Strings.t(&"tool.dig_grave"),Color(0.4, 0.3, 0.2), func() -> void: _activate(&"dig_grave",&"", Strings.t(&"tool.dig_grave")))
_add_tool_btn(flow, Strings.t(&"tool.no_roof"), Color(0.7, 0.7, 0.9), func() -> void: _activate(&"no_roof", &"", Strings.t(&"tool.no_roof")))
return box
func _build_build_tab() -> Control:
var box := VBoxContainer.new()
box.name = "BuildTab"
box.add_theme_constant_override("separation", 8)
var flow := _make_flow_grid()
box.add_child(flow)
# Wall — show material chooser on first tap.
_add_tool_btn(flow, Strings.t(&"tool.build_wall_stone"), Color(0.55, 0.55, 0.55),
func() -> void: _activate_wall(&"stone"))
_add_tool_btn(flow, Strings.t(&"tool.build_wall_wood"), Color(0.65, 0.45, 0.25),
func() -> void: _activate_wall(&"wood"))
# Floor.
_add_tool_btn(flow, Strings.t(&"tool.build_floor_wood"), Color(0.60, 0.40, 0.20),
func() -> void: _activate_floor(&"wood"))
_add_tool_btn(flow, Strings.t(&"tool.build_floor_stone"), Color(0.60, 0.60, 0.55),
func() -> void: _activate_floor(&"stone"))
# Door + Crate.
_add_tool_btn(flow, Strings.t(&"tool.build_door"), Color(0.55, 0.35, 0.15),
func() -> void: _activate(&"build_door", &"", Strings.t(&"tool.build_door")))
_add_tool_btn(flow, Strings.t(&"tool.build_crate"), Color(0.65, 0.45, 0.10),
func() -> void: _activate(&"build_crate", &"", Strings.t(&"tool.build_crate")))
# Bed + Torch.
_add_tool_btn(flow, Strings.t(&"tool.build_bed"), Color(0.40, 0.40, 0.80),
func() -> void: _activate(&"build_bed", &"", Strings.t(&"tool.build_bed")))
_add_tool_btn(flow, Strings.t(&"tool.build_torch"), Color(0.90, 0.70, 0.20),
func() -> void: _activate(&"build_torch", &"", Strings.t(&"tool.build_torch")))
# Workbenches.
_add_tool_btn(flow, Strings.t(&"tool.workbench_carpenter"),
Color(0.50, 0.35, 0.15),
func() -> void: _activate(&"build_workbench_carpenter", &"", Strings.t(&"tool.workbench_carpenter")))
_add_tool_btn(flow, Strings.t(&"tool.workbench_smelter"),
Color(0.60, 0.55, 0.45),
func() -> void: _activate(&"build_workbench_smelter", &"", Strings.t(&"tool.workbench_smelter")))
_add_tool_btn(flow, Strings.t(&"tool.workbench_millstone"),
Color(0.55, 0.55, 0.55),
func() -> void: _activate(&"build_workbench_millstone", &"", Strings.t(&"tool.workbench_millstone")))
_add_tool_btn(flow, Strings.t(&"tool.workbench_hearth"),
Color(0.80, 0.35, 0.15),
func() -> void: _activate(&"build_workbench_hearth", &"", Strings.t(&"tool.workbench_hearth")))
_add_tool_btn(flow, Strings.t(&"tool.workbench_cremation_pyre"),
Color(0.30, 0.25, 0.20),
func() -> void: _activate(&"build_workbench_cremation_pyre", &"", Strings.t(&"tool.workbench_cremation_pyre")))
return box
func _build_stockpile_tab() -> Control:
var box := VBoxContainer.new()
box.name = "StockpileTab"
box.add_theme_constant_override("separation", 8)
var flow := _make_flow_grid()
box.add_child(flow)
_add_tool_btn(flow, Strings.t(&"tool.stockpile_general"), Color(0.30, 0.60, 0.30),
func() -> void: _activate(&"paint_stockpile", &"", Strings.t(&"tool.stockpile_general")))
_add_tool_btn(flow, Strings.t(&"tool.graveyard"), Color(0.25, 0.20, 0.15),
func() -> void: _activate(&"graveyard", &"", Strings.t(&"tool.graveyard")))
return box
func _build_cancel_tab() -> Control:
var box := VBoxContainer.new()
box.name = "CancelTab"
box.add_theme_constant_override("separation", 8)
var lbl := Label.new()
lbl.text = Strings.t(&"ui.build_drawer.cancel")
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.add_theme_constant_override("margin_top", 16)
box.add_child(lbl)
var btn := Button.new()
btn.text = Strings.t(&"ui.cancel")
btn.custom_minimum_size = Vector2(200, BTN_SIZE)
btn.focus_mode = Control.FOCUS_NONE
var cancel_hbox := HBoxContainer.new()
cancel_hbox.alignment = BoxContainer.ALIGNMENT_CENTER
cancel_hbox.add_child(btn)
box.add_child(cancel_hbox)
btn.pressed.connect(_on_cancel_pressed)
return box
# ── helpers — UI factories ───────────────────────────────────────────────────
func _make_flow_grid() -> GridContainer:
var g := GridContainer.new()
g.columns = FLOW_COLS
g.add_theme_constant_override("h_separation", 8)
g.add_theme_constant_override("v_separation", 8)
return g
## Add a single tool button to `container`. Each button is a VBoxContainer of
## [ColorRect icon area + Label] wrapped in a Button so the whole cell is one
## touch target.
func _add_tool_btn(container: Control, label_text: String, icon_color: Color, callback: Callable) -> void:
var btn := Button.new()
btn.custom_minimum_size = Vector2(BTN_SIZE, BTN_SIZE + LABEL_HEIGHT)
btn.focus_mode = Control.FOCUS_NONE
var vb := VBoxContainer.new()
vb.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_theme_constant_override("separation", 2)
# Icon area — procedural colored rect (real sprites land with Phase 17 art pass).
var icon := ColorRect.new()
icon.color = icon_color
icon.custom_minimum_size = Vector2(BTN_SIZE - 8, BTN_SIZE - LABEL_HEIGHT - 8)
icon.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(icon)
# Label.
var lbl := Label.new()
lbl.text = label_text
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
lbl.custom_minimum_size = Vector2(BTN_SIZE, LABEL_HEIGHT)
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(lbl)
btn.add_child(vb)
btn.pressed.connect(callback)
container.add_child(btn)
# ── helpers — tab switching ──────────────────────────────────────────────────
func _select_tab(idx: int) -> void:
_active_tab = idx
for i in _tab_containers.size():
_tab_containers[i].visible = (i == idx)
for i in _tab_btns.size():
_tab_btns[i].modulate = Color(1.2, 1.2, 0.8) if i == idx else Color.WHITE
# ── helpers — tool activation ────────────────────────────────────────────────
## Activate `tool_id`, optionally set `mat` on the Designation controller, emit
## the alert feedback, and auto-close the drawer.
func _activate(tool_id: StringName, mat: StringName, display_name: String) -> void:
if designation == null:
Audit.log("build_drawer", "no Designation ref — cannot activate tool '%s'" % tool_id)
return
designation.tool_material = mat
designation.set_active_tool(tool_id)
EventBus.alert_added.emit(&"info", "Tool: %s" % display_name, Vector2i(-1, -1))
close()
Audit.log("build_drawer", "activated tool '%s' (mat='%s')" % [tool_id, mat])
func _activate_wall(mat: StringName) -> void:
var display: String = Strings.t(&"tool.build_wall_stone") if mat == &"stone" \
else Strings.t(&"tool.build_wall_wood")
_activate(&"build_wall", mat, display)
func _activate_floor(mat: StringName) -> void:
var display: String = Strings.t(&"tool.build_floor_wood") if mat == &"wood" \
else Strings.t(&"tool.build_floor_stone")
_activate(&"build_floor", mat, display)
func _on_cancel_pressed() -> void:
if designation != null:
designation.tool_material = &""
designation.set_active_tool(Designation.TOOL_NONE)
EventBus.alert_added.emit(&"info", "Tool: None", Vector2i(-1, -1))
close()
Audit.log("build_drawer", "tool cancelled")
# ── helpers — visibility ─────────────────────────────────────────────────────
func _set_panel_visible(v: bool) -> void:
_open = v
if _panel != null:
_panel.visible = v
# Keep the FAB visible at all times.