Workbench bill editor — tap a workbench, see/edit bills
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.
This commit is contained in:
parent
e5f3693ad9
commit
bdd435202d
9 changed files with 551 additions and 10 deletions
|
|
@ -55,6 +55,8 @@ signal load_finished(slot: StringName, ok: bool, real_seconds_away: int) ## Emit
|
||||||
# Phase 17 — Touch UX completion.
|
# Phase 17 — Touch UX completion.
|
||||||
signal pawn_selected(pawn) ## Emitted when Selection picks a pawn — opens PawnDetailPanel.
|
signal pawn_selected(pawn) ## Emitted when Selection picks a pawn — opens PawnDetailPanel.
|
||||||
signal pawn_deselected ## Emitted when Selection clears — closes 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 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 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.
|
signal request_wolf_spawn(count: int) ## Phase 15 EventCatalog → WolfSpawner. Decouples threat-event effects from spawner.
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,18 @@ const TABLE: Dictionary = {
|
||||||
&"tool.workbench_cremation_pyre": "Cremation Pyre",
|
&"tool.workbench_cremation_pyre": "Cremation Pyre",
|
||||||
&"tool.stockpile_general": "Stockpile",
|
&"tool.stockpile_general": "Stockpile",
|
||||||
&"tool.graveyard": "Graveyard",
|
&"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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,15 @@ func pawn_at_tile(tile: Vector2i) -> Pawn:
|
||||||
return null
|
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:
|
func clear_pawns() -> void:
|
||||||
# For save-load / new-game flow in Phase 16.
|
# For save-load / new-game flow in Phase 16.
|
||||||
pawns.clear()
|
pawns.clear()
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,15 @@ var label: String = ""
|
||||||
|
|
||||||
# ── save / load ───────────────────────────────────────────────────────────────
|
# ── 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:
|
func to_dict() -> Dictionary:
|
||||||
return {
|
return {
|
||||||
"id": String(id),
|
"id": String(id),
|
||||||
|
|
|
||||||
|
|
@ -109,3 +109,17 @@ static func cremate_corpse() -> Recipe:
|
||||||
r.required_skill = &"manual_labor"
|
r.required_skill = &"manual_labor"
|
||||||
r.skill_threshold = 0
|
r.skill_threshold = 0
|
||||||
return r
|
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(),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,15 @@ func add_bill(b) -> void:
|
||||||
Audit.log("workbench", "%s: bill added — recipe '%s'" % [label_text, b.recipe.id])
|
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
|
## Return the first bill that is active and whose required_skill matches
|
||||||
## this bench's accepted_skill. Returns null when none qualify.
|
## this bench's accepted_skill. Returns null when none qualify.
|
||||||
## CraftingProvider calls this; JobRunner also calls it when the current_bill
|
## CraftingProvider calls this; JobRunner also calls it when the current_bill
|
||||||
|
|
|
||||||
|
|
@ -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")
|
const RESUME_TOAST_SCRIPT: Script = preload("res://scenes/ui/resume_toast.gd")
|
||||||
# Phase 17 — PawnDetailPanel (layer 18) and SettingsMenu (layer 26).
|
# Phase 17 — PawnDetailPanel (layer 18) and SettingsMenu (layer 26).
|
||||||
const PAWN_DETAIL_PANEL_SCRIPT: Script = preload("res://scenes/ui/pawn_detail_panel.gd")
|
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")
|
const SETTINGS_MENU_SCRIPT: Script = preload("res://scenes/ui/settings_menu.gd")
|
||||||
# Phase 17 (Agent B) — BuildDrawer bottom-sheet (layer 16).
|
# Phase 17 (Agent B) — BuildDrawer bottom-sheet (layer 16).
|
||||||
const BUILD_DRAWER_SCRIPT: Script = preload("res://scenes/ui/build_drawer.gd")
|
const BUILD_DRAWER_SCRIPT: Script = preload("res://scenes/ui/build_drawer.gd")
|
||||||
|
|
@ -79,6 +80,13 @@ func _ready() -> void:
|
||||||
pawn_detail_panel.name = "PawnDetailPanel"
|
pawn_detail_panel.name = "PawnDetailPanel"
|
||||||
add_child(pawn_detail_panel)
|
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()
|
var settings_menu := CanvasLayer.new()
|
||||||
settings_menu.set_script(SETTINGS_MENU_SCRIPT)
|
settings_menu.set_script(SETTINGS_MENU_SCRIPT)
|
||||||
settings_menu.name = "SettingsMenu"
|
settings_menu.name = "SettingsMenu"
|
||||||
|
|
@ -91,7 +99,7 @@ func _ready() -> void:
|
||||||
if top_bar.has_method("_add_settings_btn"):
|
if top_bar.has_method("_add_settings_btn"):
|
||||||
top_bar._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).
|
# Phase 17 (Agent B) — BuildDrawer bottom-sheet (layer 16).
|
||||||
# Must mount AFTER the World node is ready (World._ready seeds designation_ctl).
|
# Must mount AFTER the World node is ready (World._ready seeds designation_ctl).
|
||||||
|
|
|
||||||
432
scenes/ui/workbench_panel.gd
Normal file
432
scenes/ui/workbench_panel.gd
Normal file
|
|
@ -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
|
||||||
|
|
@ -14,6 +14,9 @@ const CLICK_MAX_DURATION_MS: int = 300
|
||||||
|
|
||||||
var _pathfinder: Pathfinder = null
|
var _pathfinder: Pathfinder = null
|
||||||
var _selected_pawn: Pawn = 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
|
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
|
# 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) ─────────────
|
# ── Keyboard: Escape → deselect (lowest-priority; consumed last) ─────────────
|
||||||
# Designation._input handles Escape first; panels handle it in _unhandled_input
|
# 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.
|
# before reaching here. If we still see it and have a selection, consume it.
|
||||||
if event.is_action_pressed("cancel") and _selected_pawn != null:
|
if event.is_action_pressed("cancel"):
|
||||||
|
if _selected_pawn != null:
|
||||||
_deselect()
|
_deselect()
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
Audit.log("selection", "escape: deselected")
|
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
|
return
|
||||||
|
|
||||||
# ── Mouse: only handle button events below ───────────────────────────────────
|
# ── Mouse: only handle button events below ───────────────────────────────────
|
||||||
if not (event is InputEventMouseButton):
|
if not (event is InputEventMouseButton):
|
||||||
return
|
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:
|
if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
|
||||||
# Designation cancellation is handled by Designation._input; if we see
|
# 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:
|
if _selected_pawn != null:
|
||||||
_deselect()
|
_deselect()
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
|
elif _selected_workbench != null:
|
||||||
|
_deselect_workbench()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
return
|
return
|
||||||
|
|
||||||
if event.button_index != MOUSE_BUTTON_LEFT:
|
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)),
|
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)
|
var hit_pawn: Pawn = World.pawn_at_tile(tile)
|
||||||
if hit_pawn != null:
|
if hit_pawn != null:
|
||||||
_select(hit_pawn)
|
_select(hit_pawn)
|
||||||
return
|
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_pawn == null:
|
||||||
|
if _selected_workbench != null:
|
||||||
|
_deselect_workbench()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Empty walkable tile with a selection → queue a forced job. Decision picks
|
# 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:
|
func _select(pawn: Pawn) -> void:
|
||||||
if _selected_pawn == pawn:
|
if _selected_pawn == pawn:
|
||||||
return
|
return
|
||||||
|
# Mutual exclusion with workbench selection: clear it before promoting pawn.
|
||||||
|
if _selected_workbench != null:
|
||||||
|
_deselect_workbench()
|
||||||
if _selected_pawn != null:
|
if _selected_pawn != null:
|
||||||
_selected_pawn.set_selected(false)
|
_selected_pawn.set_selected(false)
|
||||||
EventBus.pawn_deselected.emit()
|
EventBus.pawn_deselected.emit()
|
||||||
|
|
@ -159,6 +183,28 @@ func _deselect() -> void:
|
||||||
_selected_pawn = null
|
_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.
|
## Cycle the selection forward (dir=1) or backward (dir=-1) through World.pawns.
|
||||||
## Wraps around. If no pawn currently selected, picks World.pawns[0].
|
## Wraps around. If no pawn currently selected, picks World.pawns[0].
|
||||||
## Pans the camera to the newly selected pawn's tile.
|
## Pans the camera to the newly selected pawn's tile.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue