rimlike/scenes/ui/workbench_panel.gd
megaproxy 6789ca739f add iron/gold smelting + disambiguate workbench-vs-recipe
Q: iron_smelt (iron_ore + wood → iron_ingot) and gold_smelt
(gold + wood → gold_ingot) recipes added at Smelter, using the
existing ingredient2 buffer mechanism. New TYPE_IRON_INGOT and
TYPE_GOLD_INGOT item types (procedural hue-hash draw for now).

R: new Recipe.target_workbench field (StringName, empty = any matching
skill) round-trips through to_dict/from_dict. Workbench bill picker
filters by both required_skill AND target_workbench vs lower-cased
label_text. plank → carpenter, stone_block/iron_smelt/gold_smelt →
smelter, flour → millstone. Cooking-only recipes (bread, meal) stay
unrestricted since Hearth is the only cooking workbench.

9 recipes total now, 4 distinct workbench routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:45:11 +01:00

443 lines
16 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name 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