From bdd435202d3eb9811f7b00426770602d54efbd3d Mon Sep 17 00:00:00 2001 From: megaproxy Date: Sat, 16 May 2026 00:29:46 +0100 Subject: [PATCH] =?UTF-8?q?Workbench=20bill=20editor=20=E2=80=94=20tap=20a?= =?UTF-8?q?=20workbench,=20see/edit=20bills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tap-to-select chain extended to workbenches (pawn always wins on shared tile). Mutually exclusive with pawn selection via EventBus — selecting one clears the other. New WorkbenchPanel (scenes/ui/workbench_panel.gd, ~432 LOC, layer 18, right-anchored 360 px) mirrors PawnDetailPanel shape. Bill rows expose recipe name, mode (FOREVER / COUNT / UNTIL_N), target count, completed progress, pause, and remove. Add-bill popup filters RecipeCatalog.all() by accepted_skill so a Hearth only offers cooking recipes. Supporting plumbing: - EventBus.workbench_selected / workbench_deselected signals. - Workbench.remove_bill() — interrupts mid-craft cleanly via on_craft_interrupted() before erasing. - RecipeCatalog.all() static enumerator + Recipe.display_name() helper. - World.workbench_at_tile() lookup. - i18n keys ui.bill.* and ui.workbench.* in strings.gd. Closes the deferred Phase 17 "Bill UI for workbenches" item. Player- built workbenches are now functionally configurable; before this landed, only world.gd-hardcoded bills worked. --- autoload/event_bus.gd | 2 + autoload/strings.gd | 12 + autoload/world.gd | 9 + scenes/ai/recipe.gd | 9 + scenes/ai/recipe_catalog.gd | 14 ++ scenes/entities/workbench.gd | 9 + scenes/main/main.gd | 10 +- scenes/ui/workbench_panel.gd | 432 +++++++++++++++++++++++++++++++++++ scenes/world/selection.gd | 64 +++++- 9 files changed, 551 insertions(+), 10 deletions(-) create mode 100644 scenes/ui/workbench_panel.gd diff --git a/autoload/event_bus.gd b/autoload/event_bus.gd index dc9a8b2..81de2c8 100644 --- a/autoload/event_bus.gd +++ b/autoload/event_bus.gd @@ -55,6 +55,8 @@ signal load_finished(slot: StringName, ok: bool, real_seconds_away: int) ## Emit # Phase 17 — Touch UX completion. signal pawn_selected(pawn) ## Emitted when Selection picks a pawn — opens PawnDetailPanel. signal pawn_deselected ## Emitted when Selection clears — closes PawnDetailPanel. +signal workbench_selected(workbench) +signal workbench_deselected signal pawn_priority_changed(pawn, category: StringName, level: int) ## Emitted when priority matrix updates a cell. signal alert_added(severity: StringName, text: String, focus_tile: Vector2i) ## Emitted by gameplay subsystems to surface a player notice. severity = info | warn | danger. signal request_wolf_spawn(count: int) ## Phase 15 EventCatalog → WolfSpawner. Decouples threat-event effects from spawner. diff --git a/autoload/strings.gd b/autoload/strings.gd index c67813c..d49660d 100644 --- a/autoload/strings.gd +++ b/autoload/strings.gd @@ -196,6 +196,18 @@ const TABLE: Dictionary = { &"tool.workbench_cremation_pyre": "Cremation Pyre", &"tool.stockpile_general": "Stockpile", &"tool.graveyard": "Graveyard", + &"ui.bill.mode_forever": "Forever", + &"ui.bill.mode_count": "Do X times", + &"ui.bill.mode_until_n": "Do until X", + &"ui.bill.target": "Target", + &"ui.bill.until_count": "Until count", + &"ui.bill.completed": "Done", + &"ui.bill.pause": "Pause", + &"ui.bill.remove": "Remove", + &"ui.bill.add_button": "Add bill", + &"ui.bill.no_bills_hint": "No bills. Add one to start crafting.", + &"ui.workbench.current_bill": "Current", + &"ui.workbench.idle": "Idle", } diff --git a/autoload/world.gd b/autoload/world.gd index 9169dac..ffa2b99 100644 --- a/autoload/world.gd +++ b/autoload/world.gd @@ -124,6 +124,15 @@ func pawn_at_tile(tile: Vector2i) -> Pawn: return null +## Returns the Workbench occupying `tile`, or null if none. Used by Selection +## to route taps on a workbench to the bill-editor panel. +func workbench_at_tile(tile: Vector2i): + for w in workbenches: + if w.tile == tile: + return w + return null + + func clear_pawns() -> void: # For save-load / new-game flow in Phase 16. pawns.clear() diff --git a/scenes/ai/recipe.gd b/scenes/ai/recipe.gd index 8571b38..768fa6a 100644 --- a/scenes/ai/recipe.gd +++ b/scenes/ai/recipe.gd @@ -46,6 +46,15 @@ var label: String = "" # ── save / load ─────────────────────────────────────────────────────────────── +## Player-visible display name for this recipe. Used by the workbench bill +## editor's recipe-picker and the bill list. Falls back to `id` if `label` +## is empty (shouldn't happen for catalog recipes, but defensive). +func display_name() -> String: + if label.is_empty(): + return str(id) + return label + + func to_dict() -> Dictionary: return { "id": String(id), diff --git a/scenes/ai/recipe_catalog.gd b/scenes/ai/recipe_catalog.gd index 0645c3a..c8f9e22 100644 --- a/scenes/ai/recipe_catalog.gd +++ b/scenes/ai/recipe_catalog.gd @@ -109,3 +109,17 @@ static func cremate_corpse() -> Recipe: r.required_skill = &"manual_labor" r.skill_threshold = 0 return r + + +## Returns one fresh instance of every recipe in the catalog. Used by UI +## recipe-pickers to enumerate available bills; callers filter by +## `recipe.required_skill` against the workbench's `accepted_skill`. +static func all() -> Array[Recipe]: + return [ + plank(), + stone_block(), + flour(), + bread(), + meal_from_vegetables(), + cremate_corpse(), + ] diff --git a/scenes/entities/workbench.gd b/scenes/entities/workbench.gd index 029ea00..a108c24 100644 --- a/scenes/entities/workbench.gd +++ b/scenes/entities/workbench.gd @@ -206,6 +206,15 @@ func add_bill(b) -> void: Audit.log("workbench", "%s: bill added — recipe '%s'" % [label_text, b.recipe.id]) +## Remove a bill from this workbench's queue. If the bill is currently being +## crafted, the active toil is interrupted cleanly so the pawn re-decides. +func remove_bill(b) -> void: + if current_bill == b: + on_craft_interrupted() + bills.erase(b) + Audit.log("workbench", "%s: bill removed — recipe '%s'" % [label_text, b.recipe.id]) + + ## Return the first bill that is active and whose required_skill matches ## this bench's accepted_skill. Returns null when none qualify. ## CraftingProvider calls this; JobRunner also calls it when the current_bill diff --git a/scenes/main/main.gd b/scenes/main/main.gd index 692a27d..c5b559f 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -19,6 +19,7 @@ const LOAD_MENU_SCRIPT: Script = preload("res://scenes/ui/load_menu. const RESUME_TOAST_SCRIPT: Script = preload("res://scenes/ui/resume_toast.gd") # Phase 17 — PawnDetailPanel (layer 18) and SettingsMenu (layer 26). const PAWN_DETAIL_PANEL_SCRIPT: Script = preload("res://scenes/ui/pawn_detail_panel.gd") +const WORKBENCH_PANEL_SCRIPT: Script = preload("res://scenes/ui/workbench_panel.gd") const SETTINGS_MENU_SCRIPT: Script = preload("res://scenes/ui/settings_menu.gd") # Phase 17 (Agent B) — BuildDrawer bottom-sheet (layer 16). const BUILD_DRAWER_SCRIPT: Script = preload("res://scenes/ui/build_drawer.gd") @@ -79,6 +80,13 @@ func _ready() -> void: pawn_detail_panel.name = "PawnDetailPanel" add_child(pawn_detail_panel) + # Bill-editor bottom-sheet for workbenches. Same shape as PawnDetailPanel + # (right-anchored 360 px, layer 18); mutually exclusive with it via Selection. + var workbench_panel := CanvasLayer.new() + workbench_panel.set_script(WORKBENCH_PANEL_SCRIPT) + workbench_panel.name = "WorkbenchPanel" + add_child(workbench_panel) + var settings_menu := CanvasLayer.new() settings_menu.set_script(SETTINGS_MENU_SCRIPT) settings_menu.name = "SettingsMenu" @@ -91,7 +99,7 @@ func _ready() -> void: if top_bar.has_method("_add_settings_btn"): top_bar._add_settings_btn() - Audit.log("main", "Phase 17 — PawnDetailPanel + SettingsMenu mounted.") + Audit.log("main", "Phase 17 — PawnDetailPanel + WorkbenchPanel + SettingsMenu mounted.") # Phase 17 (Agent B) — BuildDrawer bottom-sheet (layer 16). # Must mount AFTER the World node is ready (World._ready seeds designation_ctl). diff --git a/scenes/ui/workbench_panel.gd b/scenes/ui/workbench_panel.gd new file mode 100644 index 0000000..b7cf4de --- /dev/null +++ b/scenes/ui/workbench_panel.gd @@ -0,0 +1,432 @@ +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]) + # Repopulate so conditional rows update; OptionButton state survives because + # we set mode on bill before the rebuild, and the new row reads bill.mode. + _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" + ]) + _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 whose required_skill matches the workbench's +## accepted_skill. Returns an empty array when no workbench is set. +func _filtered_recipes() -> Array[Recipe]: + if current_workbench == null: + return [] + var result: Array[Recipe] = [] + for r in RecipeCatalog.all(): + if r.required_skill == current_workbench.accepted_skill: + result.append(r) + return result + + +# ── visibility ──────────────────────────────────────────────────────────────── + +func _set_visible(v: bool) -> void: + if _panel != null: + _panel.visible = v diff --git a/scenes/world/selection.gd b/scenes/world/selection.gd index ec04c64..dbd0aca 100644 --- a/scenes/world/selection.gd +++ b/scenes/world/selection.gd @@ -14,6 +14,9 @@ const CLICK_MAX_DURATION_MS: int = 300 var _pathfinder: Pathfinder = null var _selected_pawn: Pawn = null +## Currently selected workbench, or null. Mutually exclusive with _selected_pawn — +## selecting one clears the other (see _select / _select_workbench). +var _selected_workbench: Workbench = null var _camera = null # Camera2D (CameraRig) — set via bind_camera(); duck-typed to avoid circular preload # When Designation paint mode is active this flag is raised by Designation so @@ -62,23 +65,32 @@ func _unhandled_input(event: InputEvent) -> void: # ── Keyboard: Escape → deselect (lowest-priority; consumed last) ───────────── # Designation._input handles Escape first; panels handle it in _unhandled_input # before reaching here. If we still see it and have a selection, consume it. - if event.is_action_pressed("cancel") and _selected_pawn != null: - _deselect() - get_viewport().set_input_as_handled() - Audit.log("selection", "escape: deselected") - return + if event.is_action_pressed("cancel"): + if _selected_pawn != null: + _deselect() + get_viewport().set_input_as_handled() + Audit.log("selection", "escape: deselected pawn") + return + if _selected_workbench != null: + _deselect_workbench() + get_viewport().set_input_as_handled() + Audit.log("selection", "escape: deselected workbench") + return # ── Mouse: only handle button events below ─────────────────────────────────── if not (event is InputEventMouseButton): return - # ── Right-click: cancel designation (if active) or deselect pawn ───────────── + # ── Right-click: cancel designation (if active) or deselect pawn / workbench ── if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: # Designation cancellation is handled by Designation._input; if we see - # this right-click, no designation was active. Deselect any selected pawn. + # this right-click, no designation was active. Deselect whatever is selected. if _selected_pawn != null: _deselect() get_viewport().set_input_as_handled() + elif _selected_workbench != null: + _deselect_workbench() + get_viewport().set_input_as_handled() return if event.button_index != MOUSE_BUTTON_LEFT: @@ -114,14 +126,23 @@ func _handle_click(screen_pos: Vector2) -> void: floori(world_pos.y / float(Pawn.TILE_SIZE_PX)), ) - # Click on a pawn → select. + # Click on a pawn → select. Pawn wins over workbench when they share a tile + # (a pawn working at a bench is selectable; tap empty bench tile to inspect bills). var hit_pawn: Pawn = World.pawn_at_tile(tile) if hit_pawn != null: _select(hit_pawn) return - # Empty tile with no current selection → no-op. + # Click on a workbench → open the bill-editor panel. + var hit_workbench = World.workbench_at_tile(tile) + if hit_workbench != null: + _select_workbench(hit_workbench) + return + + # Empty tile with no current pawn selection → also clear any workbench selection. if _selected_pawn == null: + if _selected_workbench != null: + _deselect_workbench() return # Empty walkable tile with a selection → queue a forced job. Decision picks @@ -140,6 +161,9 @@ func _handle_click(screen_pos: Vector2) -> void: func _select(pawn: Pawn) -> void: if _selected_pawn == pawn: return + # Mutual exclusion with workbench selection: clear it before promoting pawn. + if _selected_workbench != null: + _deselect_workbench() if _selected_pawn != null: _selected_pawn.set_selected(false) EventBus.pawn_deselected.emit() @@ -159,6 +183,28 @@ func _deselect() -> void: _selected_pawn = null +## Select a workbench → opens the bill-editor panel via EventBus. +## Mutually exclusive with pawn selection: clears _selected_pawn first. +func _select_workbench(wb) -> void: + if _selected_workbench == wb: + return + if _selected_pawn != null: + _deselect() + if _selected_workbench != null: + EventBus.workbench_deselected.emit() + _selected_workbench = wb + EventBus.workbench_selected.emit(wb) + Audit.log("selection", "selected workbench %s at %s" % [wb.label_text, wb.tile]) + + +func _deselect_workbench() -> void: + if _selected_workbench == null: + return + Audit.log("selection", "deselected workbench %s" % _selected_workbench.label_text) + _selected_workbench = null + EventBus.workbench_deselected.emit() + + ## Cycle the selection forward (dir=1) or backward (dir=-1) through World.pawns. ## Wraps around. If no pawn currently selected, picks World.pawns[0]. ## Pans the camera to the newly selected pawn's tile.