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

449 lines
19 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 = 280 # fixed tray height — same for every tab
const BTN_SIZE: int = 72 # preferred hit area for build buttons
const FAB_SIZE: int = 48 # floating action button (open trigger)
const TAB_HEIGHT: int = 36
const LABEL_HEIGHT: int = 18
const SECTION_HEADER_HEIGHT: int = 20
const SECTION_COLS: int = 3 # buttons per row inside one Build-tab section
const FLOW_COLS: int = 5 # buttons per row in plain (non-sectioned) tabs
# ── 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)
EventBus.ui_panel_opened.emit(&"build_drawer")
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.tooltip_text = Strings.t(&"tooltip.fab_build")
_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 tray, anchored to the bottom of the screen. Fixed
# height so switching tabs doesn't change the panel's footprint.
_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", 4)
_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)
# Tab content area — one HBox holding all tabs; only the active one is
# visible. Hidden BoxContainer children don't contribute to layout, so
# the parent auto-shrinks to the active tab.
var content_stack := HBoxContainer.new()
content_stack.name = "ContentStack"
vbox.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"), &"chop", func() -> void: _activate(&"chop", &"", Strings.t(&"tool.chop")), Strings.t(&"tooltip.tool.chop"))
_add_tool_btn(flow, Strings.t(&"tool.mine"), &"mine", func() -> void: _activate(&"mine", &"", Strings.t(&"tool.mine")), Strings.t(&"tooltip.tool.mine"))
_add_tool_btn(flow, Strings.t(&"tool.dig_grave"), &"dig_grave", func() -> void: _activate(&"dig_grave", &"", Strings.t(&"tool.dig_grave")), Strings.t(&"tooltip.tool.dig_grave"))
_add_tool_btn(flow, Strings.t(&"tool.no_roof"), &"no_roof", func() -> void: _activate(&"no_roof", &"", Strings.t(&"tool.no_roof")), Strings.t(&"tooltip.tool.no_roof"))
_add_tool_btn(flow, Strings.t(&"tool.plant_tree"), &"plant_tree", func() -> void: _activate(&"plant_tree", &"", Strings.t(&"tool.plant_tree")), Strings.t(&"tooltip.tool.plant_tree"))
return box
func _build_build_tab() -> Control:
# Build tab is a row of three section columns (Structures | Furniture |
# Production), each its own header + grid. Side-by-side layout fills the
# wide tray horizontally instead of pushing it taller.
var row := HBoxContainer.new()
row.name = "BuildTab"
row.add_theme_constant_override("separation", 14)
# Structures — walls, floors, door.
var st := _make_section_column(Strings.t(&"ui.build_drawer.section.structures"))
var st_grid := st.get_child(1) as GridContainer
_add_tool_btn(st_grid, Strings.t(&"tool.build_wall_stone"), &"build_wall_stone",
func() -> void: _activate_wall(&"stone"), Strings.t(&"tooltip.tool.build_wall_stone"))
_add_tool_btn(st_grid, Strings.t(&"tool.build_wall_wood"), &"build_wall_wood",
func() -> void: _activate_wall(&"wood"), Strings.t(&"tooltip.tool.build_wall_wood"))
_add_tool_btn(st_grid, Strings.t(&"tool.build_door"), &"build_door",
func() -> void: _activate(&"build_door", &"", Strings.t(&"tool.build_door")), Strings.t(&"tooltip.tool.build_door"))
_add_tool_btn(st_grid, Strings.t(&"tool.build_floor_wood"), &"build_floor_wood",
func() -> void: _activate_floor(&"wood"), Strings.t(&"tooltip.tool.build_floor_wood"))
_add_tool_btn(st_grid, Strings.t(&"tool.build_floor_stone"), &"build_floor_stone",
func() -> void: _activate_floor(&"stone"), Strings.t(&"tooltip.tool.build_floor_stone"))
row.add_child(st)
row.add_child(VSeparator.new())
# Furniture — crate, bed, torch.
var fu := _make_section_column(Strings.t(&"ui.build_drawer.section.furniture"))
var fu_grid := fu.get_child(1) as GridContainer
_add_tool_btn(fu_grid, Strings.t(&"tool.build_crate"), &"build_crate",
func() -> void: _activate(&"build_crate", &"", Strings.t(&"tool.build_crate")), Strings.t(&"tooltip.tool.build_crate"))
_add_tool_btn(fu_grid, Strings.t(&"tool.build_bed"), &"build_bed",
func() -> void: _activate(&"build_bed", &"", Strings.t(&"tool.build_bed")), Strings.t(&"tooltip.tool.build_bed"))
_add_tool_btn(fu_grid, Strings.t(&"tool.build_torch"), &"build_torch",
func() -> void: _activate(&"build_torch", &"", Strings.t(&"tool.build_torch")), Strings.t(&"tooltip.tool.build_torch"))
row.add_child(fu)
row.add_child(VSeparator.new())
# Production — workbenches + quarry.
var pr := _make_section_column(Strings.t(&"ui.build_drawer.section.production"))
var pr_grid := pr.get_child(1) as GridContainer
_add_tool_btn(pr_grid, Strings.t(&"tool.workbench_carpenter"),
&"build_workbench_carpenter",
func() -> void: _activate(&"build_workbench_carpenter", &"", Strings.t(&"tool.workbench_carpenter")),
Strings.t(&"tooltip.tool.build_workbench_carpenter"))
_add_tool_btn(pr_grid, Strings.t(&"tool.workbench_smelter"),
&"build_workbench_smelter",
func() -> void: _activate(&"build_workbench_smelter", &"", Strings.t(&"tool.workbench_smelter")),
Strings.t(&"tooltip.tool.build_workbench_smelter"))
_add_tool_btn(pr_grid, Strings.t(&"tool.workbench_millstone"),
&"build_workbench_millstone",
func() -> void: _activate(&"build_workbench_millstone", &"", Strings.t(&"tool.workbench_millstone")),
Strings.t(&"tooltip.tool.build_workbench_millstone"))
_add_tool_btn(pr_grid, Strings.t(&"tool.workbench_hearth"),
&"build_workbench_hearth",
func() -> void: _activate(&"build_workbench_hearth", &"", Strings.t(&"tool.workbench_hearth")),
Strings.t(&"tooltip.tool.build_workbench_hearth"))
_add_tool_btn(pr_grid, Strings.t(&"tool.workbench_cremation_pyre"),
&"build_workbench_cremation_pyre",
func() -> void: _activate(&"build_workbench_cremation_pyre", &"", Strings.t(&"tool.workbench_cremation_pyre")),
Strings.t(&"tooltip.tool.build_workbench_cremation_pyre"))
# Quarry — must be painted on a stone outcrop (BigRockNode); world.gd
# rejects placements on plain ground.
_add_tool_btn(pr_grid, Strings.t(&"tool.paint_quarry"),
&"paint_quarry",
func() -> void: _activate(&"paint_quarry", &"", Strings.t(&"tool.paint_quarry")),
Strings.t(&"tooltip.tool.paint_quarry"))
row.add_child(pr)
return row
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"), &"paint_stockpile",
func() -> void: _activate(&"paint_stockpile", &"", Strings.t(&"tool.stockpile_general")),
Strings.t(&"tooltip.tool.paint_stockpile"))
_add_tool_btn(flow, Strings.t(&"tool.graveyard"), &"graveyard",
func() -> void: _activate(&"graveyard", &"", Strings.t(&"tool.graveyard")),
Strings.t(&"tooltip.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", 6)
g.add_theme_constant_override("v_separation", 6)
return g
## One vertical section of the Build tab: header label on top, fixed-column
## button grid below. Returns the column (child 0 = label, child 1 = grid)
## so callers can populate the grid with `_add_tool_btn`.
func _make_section_column(title: String) -> VBoxContainer:
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 4)
var lbl := Label.new()
lbl.text = title
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
lbl.custom_minimum_size = Vector2(0, SECTION_HEADER_HEIGHT)
lbl.add_theme_color_override("font_color", MedievalTheme.C_PANEL_DARK)
lbl.add_theme_font_size_override("font_size", 13)
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
col.add_child(lbl)
var grid := GridContainer.new()
grid.columns = SECTION_COLS
grid.add_theme_constant_override("h_separation", 6)
grid.add_theme_constant_override("v_separation", 6)
col.add_child(grid)
return col
const _THUMB_SCRIPT: Script = preload("res://scenes/ui/build_drawer_thumb.gd")
## Add a single tool button to `container`. The button is a VBoxContainer of
## [thumb preview + Label] wrapped in a Button so the whole cell is one touch
## target. `tool_id` drives the procedural preview shape (BuildDrawerThumb).
## `tooltip` is optional — set it for desktop discoverability; ignored on touch.
func _add_tool_btn(container: Control, label_text: String, tool_id: StringName, callback: Callable, tooltip: String = "") -> void:
var btn := Button.new()
btn.custom_minimum_size = Vector2(BTN_SIZE, BTN_SIZE + LABEL_HEIGHT)
btn.focus_mode = Control.FOCUS_NONE
if tooltip != "":
btn.tooltip_text = tooltip
var vb := VBoxContainer.new()
vb.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_theme_constant_override("separation", 2)
# Procedural preview of the entity this tool builds.
var thumb := Control.new()
thumb.set_script(_THUMB_SCRIPT)
# Use .set() — the static type is Control (set_script doesn't refine it),
# but the runtime instance has the tool_id property from the script.
thumb.set("tool_id", tool_id)
thumb.custom_minimum_size = Vector2(BTN_SIZE - 8, BTN_SIZE - LABEL_HEIGHT - 8)
thumb.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
thumb.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(thumb)
# 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")
# ── keyboard input ───────────────────────────────────────────────────────────
func _unhandled_input(event: InputEvent) -> void:
# B — toggle the build drawer.
if event.is_action_pressed("open_build"):
toggle()
get_viewport().set_input_as_handled()
return
# Escape — close if open (panel Escape runs before Selection deselect).
if event.is_action_pressed("cancel") and _open:
close()
get_viewport().set_input_as_handled()
return
# ── helpers — visibility ─────────────────────────────────────────────────────
func _set_panel_visible(v: bool) -> void:
_open = v
if _panel != null:
_panel.visible = v
# Keep the FAB visible at all times.