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 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 FLOW_COLS: int = 5 # 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 — auto-sized, anchored to the bottom-center of the screen. # Width hugs the grid contents; height hugs the active tab's contents. _panel = PanelContainer.new() _panel.name = "BuildPanel" _panel.anchor_left = 0.5 _panel.anchor_right = 0.5 _panel.anchor_top = 1.0 _panel.anchor_bottom = 1.0 _panel.offset_left = 0 _panel.offset_right = 0 _panel.offset_top = 0 _panel.offset_bottom = -8 # small gap from bottom edge _panel.grow_horizontal = Control.GROW_DIRECTION_BOTH _panel.grow_vertical = Control.GROW_DIRECTION_BEGIN 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"))) _add_tool_btn(flow, Strings.t(&"tool.mine"), &"mine", func() -> void: _activate(&"mine", &"", Strings.t(&"tool.mine"))) _add_tool_btn(flow, Strings.t(&"tool.dig_grave"), &"dig_grave", func() -> void: _activate(&"dig_grave", &"", Strings.t(&"tool.dig_grave"))) _add_tool_btn(flow, Strings.t(&"tool.no_roof"), &"no_roof", func() -> void: _activate(&"no_roof", &"", Strings.t(&"tool.no_roof"))) _add_tool_btn(flow, Strings.t(&"tool.plant_tree"), &"plant_tree", func() -> void: _activate(&"plant_tree", &"", Strings.t(&"tool.plant_tree"))) return box func _build_build_tab() -> Control: var box := VBoxContainer.new() box.name = "BuildTab" box.add_theme_constant_override("separation", 4) # Structures — walls, floors, doors. _add_section_header(box, Strings.t(&"ui.build_drawer.section.structures")) var structures := _make_flow_grid() box.add_child(structures) _add_tool_btn(structures, Strings.t(&"tool.build_wall_stone"), &"build_wall_stone", func() -> void: _activate_wall(&"stone")) _add_tool_btn(structures, Strings.t(&"tool.build_wall_wood"), &"build_wall_wood", func() -> void: _activate_wall(&"wood")) _add_tool_btn(structures, Strings.t(&"tool.build_floor_wood"), &"build_floor_wood", func() -> void: _activate_floor(&"wood")) _add_tool_btn(structures, Strings.t(&"tool.build_floor_stone"), &"build_floor_stone", func() -> void: _activate_floor(&"stone")) _add_tool_btn(structures, Strings.t(&"tool.build_door"), &"build_door", func() -> void: _activate(&"build_door", &"", Strings.t(&"tool.build_door"))) # Furniture — crate, bed, torch. _add_section_header(box, Strings.t(&"ui.build_drawer.section.furniture")) var furniture := _make_flow_grid() box.add_child(furniture) _add_tool_btn(furniture, Strings.t(&"tool.build_crate"), &"build_crate", func() -> void: _activate(&"build_crate", &"", Strings.t(&"tool.build_crate"))) _add_tool_btn(furniture, Strings.t(&"tool.build_bed"), &"build_bed", func() -> void: _activate(&"build_bed", &"", Strings.t(&"tool.build_bed"))) _add_tool_btn(furniture, Strings.t(&"tool.build_torch"), &"build_torch", func() -> void: _activate(&"build_torch", &"", Strings.t(&"tool.build_torch"))) # Production — workbenches + quarry. _add_section_header(box, Strings.t(&"ui.build_drawer.section.production")) var production := _make_flow_grid() box.add_child(production) _add_tool_btn(production, Strings.t(&"tool.workbench_carpenter"), &"build_workbench_carpenter", func() -> void: _activate(&"build_workbench_carpenter", &"", Strings.t(&"tool.workbench_carpenter"))) _add_tool_btn(production, Strings.t(&"tool.workbench_smelter"), &"build_workbench_smelter", func() -> void: _activate(&"build_workbench_smelter", &"", Strings.t(&"tool.workbench_smelter"))) _add_tool_btn(production, Strings.t(&"tool.workbench_millstone"), &"build_workbench_millstone", func() -> void: _activate(&"build_workbench_millstone", &"", Strings.t(&"tool.workbench_millstone"))) _add_tool_btn(production, Strings.t(&"tool.workbench_hearth"), &"build_workbench_hearth", func() -> void: _activate(&"build_workbench_hearth", &"", Strings.t(&"tool.workbench_hearth"))) _add_tool_btn(production, Strings.t(&"tool.workbench_cremation_pyre"), &"build_workbench_cremation_pyre", func() -> void: _activate(&"build_workbench_cremation_pyre", &"", Strings.t(&"tool.workbench_cremation_pyre"))) # Quarry — must be painted on a stone outcrop (BigRockNode); world.gd # rejects placements on plain ground. _add_tool_btn(production, Strings.t(&"tool.paint_quarry"), &"paint_quarry", func() -> void: _activate(&"paint_quarry", &"", Strings.t(&"tool.paint_quarry"))) 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"), &"paint_stockpile", func() -> void: _activate(&"paint_stockpile", &"", Strings.t(&"tool.stockpile_general"))) _add_tool_btn(flow, Strings.t(&"tool.graveyard"), &"graveyard", 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", 6) g.add_theme_constant_override("v_separation", 6) return g ## Small section-header label between groups in the Build tab. Drawn in the ## panel's dark border colour so it reads as a divider rather than a button. func _add_section_header(box: Control, title: String) -> void: 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 box.add_child(lbl) 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). func _add_tool_btn(container: Control, label_text: String, tool_id: StringName, 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) # 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.