class_name WorkbenchPanel extends CanvasLayer ## Phase 17 — Right-side bottom-sheet workbench bill editor. ## ## Layer 18: same level as PawnDetailPanel (only one is visible at a time). ## Opens when EventBus.workbench_selected fires; closes on workbench_deselected ## or when a pawn is selected (mutual-exclusion with PawnDetailPanel). ## ## Refresh model (matches PawnDetailPanel): ## - Full UI rebuild: only on workbench_selected, add_bill, remove_bill. ## - Status-line refresh: every 5 sim ticks while open (_on_sim_tick). ## - Bill rows are NOT touched during refresh to preserve scroll + dropdown state. ## ## Touch targets: all interactive controls are at least 48×48 px. ## Background elements use MOUSE_FILTER_IGNORE so world taps pass through. const PANEL_WIDTH: int = 360 const REFRESH_TICKS: int = 5 # update status line every N sim ticks const LAYER: int = 18 # ── internal state ──────────────────────────────────────────────────────────── var current_workbench: Workbench = null var _tick_counter: int = 0 # ── node refs (built in _build_ui) ─────────────────────────────────────────── var _panel: PanelContainer = null var _wb_name_label: Label = null var _close_btn: Button = null var _status_label: Label = null var _bills_vbox: VBoxContainer = null var _add_btn: Button = null var _no_bills_label: Label = null var _recipe_popup: PopupMenu = null func _ready() -> void: layer = LAYER _build_ui() _set_visible(false) EventBus.workbench_selected.connect(_on_workbench_selected) EventBus.workbench_deselected.connect(_on_workbench_deselected) EventBus.pawn_selected.connect(_on_pawn_selected) EventBus.sim_tick.connect(_on_sim_tick) Audit.log("workbench_panel", "WorkbenchPanel ready (layer %d)" % layer) func _exit_tree() -> void: if EventBus.workbench_selected.is_connected(_on_workbench_selected): EventBus.workbench_selected.disconnect(_on_workbench_selected) if EventBus.workbench_deselected.is_connected(_on_workbench_deselected): EventBus.workbench_deselected.disconnect(_on_workbench_deselected) if EventBus.pawn_selected.is_connected(_on_pawn_selected): EventBus.pawn_selected.disconnect(_on_pawn_selected) if EventBus.sim_tick.is_connected(_on_sim_tick): EventBus.sim_tick.disconnect(_on_sim_tick) # ── UI construction ─────────────────────────────────────────────────────────── func _build_ui() -> void: # Right-side sheet anchored to the right edge, full height. _panel = PanelContainer.new() _panel.name = "WorkbenchSheet" _panel.anchor_left = 1.0 _panel.anchor_right = 1.0 _panel.anchor_top = 0.0 _panel.anchor_bottom = 1.0 _panel.offset_left = -PANEL_WIDTH _panel.offset_right = 0.0 _panel.offset_top = 0.0 _panel.offset_bottom = 0.0 _panel.mouse_filter = Control.MOUSE_FILTER_PASS add_child(_panel) # Scrollable inner container so content survives small screens. var scroll := ScrollContainer.new() scroll.name = "Scroll" scroll.set_anchors_preset(Control.PRESET_FULL_RECT) scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _panel.add_child(scroll) var vbox := VBoxContainer.new() vbox.name = "Content" vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL vbox.add_theme_constant_override("separation", 6) scroll.add_child(vbox) # ── Header ──────────────────────────────────────────────────────────────── var header := HBoxContainer.new() header.name = "Header" header.add_theme_constant_override("separation", 8) vbox.add_child(header) _wb_name_label = Label.new() _wb_name_label.name = "WorkbenchName" _wb_name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _wb_name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _wb_name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE header.add_child(_wb_name_label) _close_btn = Button.new() _close_btn.name = "CloseBtn" _close_btn.text = Strings.t(&"ui.detail.close") _close_btn.custom_minimum_size = Vector2(48, 48) _close_btn.focus_mode = Control.FOCUS_NONE _close_btn.pressed.connect(_on_close_pressed) header.add_child(_close_btn) _add_separator(vbox) # ── Status line (live-refreshed) ────────────────────────────────────────── _status_label = Label.new() _status_label.name = "StatusLabel" _status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _status_label.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(_status_label) _add_separator(vbox) # ── Bill list ───────────────────────────────────────────────────────────── var bills_header := Label.new() bills_header.text = Strings.t(&"ui.bill.no_bills_hint") bills_header.name = "BillsHeader" bills_header.mouse_filter = Control.MOUSE_FILTER_IGNORE # Header is just section spacing; actual content is in _bills_vbox below. # Repurpose this as the "no bills" hint — hidden when bills exist. _no_bills_label = bills_header vbox.add_child(_no_bills_label) _bills_vbox = VBoxContainer.new() _bills_vbox.name = "BillList" _bills_vbox.add_theme_constant_override("separation", 8) _bills_vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(_bills_vbox) _add_separator(vbox) # ── Add-bill footer ─────────────────────────────────────────────────────── var footer := HBoxContainer.new() footer.name = "Footer" footer.add_theme_constant_override("separation", 8) vbox.add_child(footer) _add_btn = Button.new() _add_btn.name = "AddBillBtn" _add_btn.text = Strings.t(&"ui.bill.add_button") _add_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL _add_btn.custom_minimum_size = Vector2(0, 48) _add_btn.focus_mode = Control.FOCUS_NONE _add_btn.pressed.connect(_on_add_bill_pressed) footer.add_child(_add_btn) # PopupMenu for recipe selection — populated lazily in _on_add_bill_pressed. _recipe_popup = PopupMenu.new() _recipe_popup.name = "RecipePopup" _recipe_popup.id_pressed.connect(_on_recipe_chosen) _panel.add_child(_recipe_popup) func _add_separator(parent: VBoxContainer) -> void: var sep := HSeparator.new() sep.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(sep) func _clear_children(node: Node) -> void: for child in node.get_children(): child.queue_free() # ── event handlers ──────────────────────────────────────────────────────────── func _on_workbench_selected(wb: Workbench) -> void: current_workbench = wb _tick_counter = 0 _wb_name_label.text = wb.label_text _refresh_status() _populate_bills() _set_visible(true) Audit.log("workbench_panel", "opened for %s" % wb.label_text) func _on_workbench_deselected() -> void: current_workbench = null _set_visible(false) Audit.log("workbench_panel", "closed (deselected)") func _on_pawn_selected(_pawn) -> void: # Mutual exclusion: pawn panel and workbench panel are never both open. if current_workbench == null: return current_workbench = null _set_visible(false) EventBus.workbench_deselected.emit() Audit.log("workbench_panel", "closed (pawn selected)") func _on_close_pressed() -> void: current_workbench = null _set_visible(false) EventBus.workbench_deselected.emit() Audit.log("workbench_panel", "closed (X button)") func _on_sim_tick(_tick_number: int) -> void: if current_workbench == null or not _panel.visible: return if not is_instance_valid(current_workbench): current_workbench = null _set_visible(false) return _tick_counter += 1 if _tick_counter >= REFRESH_TICKS: _tick_counter = 0 _refresh_status() # ── status-line refresh (called every REFRESH_TICKS, NOT rebuild) ───────────── func _refresh_status() -> void: if current_workbench == null: return var cb = current_workbench.current_bill if cb != null and cb.recipe != null: var work_ticks: int = cb.recipe.work_ticks _status_label.text = "%s: %s %d/%d" % [ Strings.t(&"ui.workbench.current_bill"), cb.recipe.display_name(), current_workbench.current_work_progress, work_ticks ] else: _status_label.text = Strings.t(&"ui.workbench.idle") # ── bill list population (called on open / add / remove only) ───────────────── func _populate_bills() -> void: if current_workbench == null: return _clear_children(_bills_vbox) var has_bills: bool = current_workbench.bills.size() > 0 _no_bills_label.visible = not has_bills for bill in current_workbench.bills: _bills_vbox.add_child(_make_bill_row(bill)) # Enable/disable the add button based on whether any filtered recipes exist. var filtered: Array[Recipe] = _filtered_recipes() _add_btn.disabled = filtered.is_empty() ## Build and return a VBoxContainer widget for a single bill. ## All controls mutate bill fields directly; remove triggers _populate_bills(). func _make_bill_row(bill: Bill) -> VBoxContainer: var row_vbox := VBoxContainer.new() row_vbox.add_theme_constant_override("separation", 4) row_vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE # Row 1: recipe name (bold via theme; we use a plain Label — theme handles weight). var name_lbl := Label.new() name_lbl.text = bill.recipe.display_name() if bill.recipe != null else "???" name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE row_vbox.add_child(name_lbl) # Row 2: mode OptionButton (Forever / Do X times / Do until X). var mode_row := HBoxContainer.new() mode_row.add_theme_constant_override("separation", 6) row_vbox.add_child(mode_row) var mode_lbl := Label.new() mode_lbl.text = "Mode:" mode_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE mode_lbl.custom_minimum_size = Vector2(40, 0) mode_row.add_child(mode_lbl) var mode_btn := OptionButton.new() mode_btn.add_item(Strings.t(&"ui.bill.mode_forever"), Bill.Mode.FOREVER) mode_btn.add_item(Strings.t(&"ui.bill.mode_count"), Bill.Mode.COUNT) mode_btn.add_item(Strings.t(&"ui.bill.mode_until_n"), Bill.Mode.UNTIL_N) mode_btn.selected = bill.mode as int mode_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL mode_btn.focus_mode = Control.FOCUS_NONE mode_btn.custom_minimum_size = Vector2(0, 48) # Capture bill reference in closure; repopulate on mode change so conditional # rows (count spinner, done label) appear/disappear correctly. mode_btn.item_selected.connect(func(idx: int) -> void: bill.mode = idx as Bill.Mode Audit.log("workbench_ui", "%s: bill mode → %d" % [current_workbench.label_text, idx]) # Defer the rebuild — we must NOT free mode_btn while its item_selected # signal is still emitting (instant crash). call_deferred runs the # repopulate after the signal frame completes. call_deferred("_populate_bills") ) mode_row.add_child(mode_btn) # Row 3 (conditional): SpinBox for target_count. Shown when mode != FOREVER. if bill.mode != Bill.Mode.FOREVER: var count_row := HBoxContainer.new() count_row.add_theme_constant_override("separation", 6) row_vbox.add_child(count_row) var count_lbl := Label.new() count_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE count_lbl.custom_minimum_size = Vector2(80, 0) if bill.mode == Bill.Mode.COUNT: count_lbl.text = Strings.t(&"ui.bill.target") else: count_lbl.text = Strings.t(&"ui.bill.until_count") count_row.add_child(count_lbl) var spin := SpinBox.new() spin.min_value = 1 spin.max_value = 999 spin.step = 1 spin.value = max(1, bill.target_count) spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL spin.focus_mode = Control.FOCUS_NONE spin.value_changed.connect(func(v: float) -> void: bill.target_count = int(v) Audit.log("workbench_ui", "%s: bill target_count → %d" % [current_workbench.label_text, bill.target_count]) ) count_row.add_child(spin) # Row 4 (COUNT only): "Done: X/Y" progress label. if bill.mode == Bill.Mode.COUNT: var done_lbl := Label.new() done_lbl.text = "%s: %d/%d" % [Strings.t(&"ui.bill.completed"), bill.completed_count, bill.target_count] done_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE row_vbox.add_child(done_lbl) # Row 5: pause CheckBox. var pause_check := CheckBox.new() pause_check.text = Strings.t(&"ui.bill.pause") pause_check.button_pressed = bill.paused pause_check.focus_mode = Control.FOCUS_NONE pause_check.custom_minimum_size = Vector2(0, 40) pause_check.toggled.connect(func(on: bool) -> void: bill.paused = on Audit.log("workbench_ui", "%s: bill paused → %s" % [current_workbench.label_text, str(on)]) ) row_vbox.add_child(pause_check) # Row 6: "Remove" button, right-aligned via HSpacer + HBox. var remove_row := HBoxContainer.new() row_vbox.add_child(remove_row) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE remove_row.add_child(spacer) var remove_btn := Button.new() remove_btn.text = Strings.t(&"ui.bill.remove") remove_btn.custom_minimum_size = Vector2(80, 40) remove_btn.focus_mode = Control.FOCUS_NONE remove_btn.pressed.connect(func() -> void: if current_workbench == null: return current_workbench.remove_bill(bill) Audit.log("workbench_ui", "%s: bill removed — recipe '%s'" % [ current_workbench.label_text, bill.recipe.id if bill.recipe != null else "null" ]) # Defer — same reason as mode_btn: don't free this button mid-emit. call_deferred("_populate_bills") ) remove_row.add_child(remove_btn) # Thin separator below each bill row for visual grouping. var sep := HSeparator.new() sep.mouse_filter = Control.MOUSE_FILTER_IGNORE row_vbox.add_child(sep) return row_vbox # ── add-bill popup ──────────────────────────────────────────────────────────── func _on_add_bill_pressed() -> void: if current_workbench == null: return var recipes: Array[Recipe] = _filtered_recipes() if recipes.is_empty(): return _recipe_popup.clear() for i in recipes.size(): _recipe_popup.add_item(recipes[i].display_name(), i) # Position the popup just above the add button. var btn_rect: Rect2 = _add_btn.get_global_rect() _recipe_popup.position = Vector2i(int(btn_rect.position.x), int(btn_rect.position.y) - _recipe_popup.size.y - 4) _recipe_popup.popup() func _on_recipe_chosen(id: int) -> void: if current_workbench == null: return var recipes: Array[Recipe] = _filtered_recipes() if id < 0 or id >= recipes.size(): return var picked: Recipe = recipes[id] var b := Bill.new() b.recipe = picked b.mode = Bill.Mode.FOREVER current_workbench.add_bill(b) Audit.log("workbench_ui", "%s: bill added — recipe '%s'" % [current_workbench.label_text, picked.id]) _populate_bills() ## Returns all catalog recipes visible at the current workbench. ## Two-pass filter: ## 1. required_skill must match workbench.accepted_skill. ## 2. If the recipe has a target_workbench set, it must match ## workbench.label_text.to_lower() — e.g. "Carpenter" → "carpenter". ## Recipes with an empty target_workbench pass to any matching-skill bench. ## Returns an empty array when no workbench is set. func _filtered_recipes() -> Array[Recipe]: if current_workbench == null: return [] var workbench_key: StringName = StringName(current_workbench.label_text.to_lower()) var result: Array[Recipe] = [] for r in RecipeCatalog.all(): if r.required_skill != current_workbench.accepted_skill: continue if r.target_workbench != &"" and r.target_workbench != workbench_key: continue result.append(r) return result # ── visibility ──────────────────────────────────────────────────────────────── func _set_visible(v: bool) -> void: if _panel != null: _panel.visible = v