save/load round-trip: workbench bills, crop static-method, bed owner, wolf target now all survive reload via Bill.from_dict reconstruction, _spawn_crop using setup(), and a new _post_load_resolve_references pass. PlantProvider: sow path added; consumes 1 grain on a TILLED crop tile. CraftingProvider: ingredient2 supported via new KIND_DEPOSIT_AT_WB toil and Workbench.deposited_inputs buffer. Cremation pyre now actually consumes wood. HaulingProvider: per-item haul_retry_count + haul_rejected after 3 orphan passes; new EventBus.stockpile_layout_changed resets rejects on any player stockpile edit. Storyteller: 14 stubbed event effects implemented. New buff registry (add_buff/get_buff_multiplier/has_buff, day-prune, save/load) drives seasonal/resource events. New request_pawn_spawn signal + WANDERER table for arrivals. New SICK status + 3 mood thoughts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
379 lines
14 KiB
GDScript
379 lines
14 KiB
GDScript
class_name StockpilePanel extends CanvasLayer
|
||
## Phase 17 — Right-side stockpile filter + priority editor.
|
||
##
|
||
## Layer 18: shares the slot with WorkbenchPanel and PawnDetailPanel — only one
|
||
## is visible at a time (mutual exclusion via Selection).
|
||
## Opens when EventBus.stockpile_selected fires; closes on stockpile_deselected.
|
||
##
|
||
## Filter model:
|
||
## accepted_types.is_empty() → wildcard "accept all" mode (default).
|
||
## As soon as the player checks any chip, the zone switches to explicit-list
|
||
## mode. "Select all" returns to wildcard by clearing the array.
|
||
##
|
||
## Rebuild policy:
|
||
## _populate_chips / _update_priority_row are called only on open or on an
|
||
## explicit player action. No per-tick polling needed.
|
||
##
|
||
## Touch targets: all interactive controls are at least 48×48 px.
|
||
|
||
const PANEL_WIDTH: int = 360
|
||
const LAYER: int = 18
|
||
|
||
# ── internal state ────────────────────────────────────────────────────────────
|
||
var current_zone = null # StockpileZone (StorageDestination subclass)
|
||
|
||
# ── node refs (built in _build_ui) ───────────────────────────────────────────
|
||
var _panel: PanelContainer = null
|
||
var _header_label: Label = null
|
||
var _close_btn: Button = null
|
||
var _priority_btns: Array[Button] = []
|
||
var _chips_grid: GridContainer = null
|
||
var _all_label: Label = null
|
||
|
||
|
||
func _ready() -> void:
|
||
layer = LAYER
|
||
|
||
_build_ui()
|
||
_set_visible(false)
|
||
|
||
EventBus.stockpile_selected.connect(_on_stockpile_selected)
|
||
EventBus.stockpile_deselected.connect(_on_stockpile_deselected)
|
||
EventBus.pawn_selected.connect(_on_other_selected)
|
||
EventBus.workbench_selected.connect(_on_other_selected)
|
||
|
||
Audit.log("stockpile_panel", "StockpilePanel ready (layer %d)" % layer)
|
||
|
||
|
||
func _exit_tree() -> void:
|
||
if EventBus.stockpile_selected.is_connected(_on_stockpile_selected):
|
||
EventBus.stockpile_selected.disconnect(_on_stockpile_selected)
|
||
if EventBus.stockpile_deselected.is_connected(_on_stockpile_deselected):
|
||
EventBus.stockpile_deselected.disconnect(_on_stockpile_deselected)
|
||
if EventBus.pawn_selected.is_connected(_on_other_selected):
|
||
EventBus.pawn_selected.disconnect(_on_other_selected)
|
||
if EventBus.workbench_selected.is_connected(_on_other_selected):
|
||
EventBus.workbench_selected.disconnect(_on_other_selected)
|
||
|
||
|
||
# ── UI construction ───────────────────────────────────────────────────────────
|
||
|
||
func _build_ui() -> void:
|
||
# Right-side sheet anchored to the right edge, full height.
|
||
_panel = PanelContainer.new()
|
||
_panel.name = "StockpileSheet"
|
||
_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)
|
||
|
||
_header_label = Label.new()
|
||
_header_label.name = "HeaderLabel"
|
||
_header_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_header_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
_header_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
header.add_child(_header_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)
|
||
|
||
# ── Priority row ──────────────────────────────────────────────────────────
|
||
var prio_lbl := Label.new()
|
||
prio_lbl.text = Strings.t(&"ui.stockpile.priority")
|
||
prio_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
vbox.add_child(prio_lbl)
|
||
|
||
var prio_row := HBoxContainer.new()
|
||
prio_row.name = "PriorityRow"
|
||
prio_row.add_theme_constant_override("separation", 4)
|
||
vbox.add_child(prio_row)
|
||
|
||
# Buttons ordered CRITICAL → HIGH → NORMAL → LOW → OFF for display.
|
||
# StorageDestination.Priority has CRITICAL=0, HIGH=1, NORMAL=2, LOW=3, OFF=4.
|
||
var prio_labels: Array[StringName] = [
|
||
&"ui.stockpile.prio.critical",
|
||
&"ui.stockpile.prio.high",
|
||
&"ui.stockpile.prio.normal",
|
||
&"ui.stockpile.prio.low",
|
||
&"ui.stockpile.prio.off",
|
||
]
|
||
_priority_btns.clear()
|
||
for i in prio_labels.size():
|
||
var btn := Button.new()
|
||
btn.text = Strings.t(prio_labels[i])
|
||
btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
btn.custom_minimum_size = Vector2(0, 40)
|
||
btn.focus_mode = Control.FOCUS_NONE
|
||
btn.toggle_mode = false
|
||
var captured_i: int = i
|
||
btn.pressed.connect(func() -> void: _on_priority_pressed(captured_i))
|
||
prio_row.add_child(btn)
|
||
_priority_btns.append(btn)
|
||
|
||
_add_separator(vbox)
|
||
|
||
# ── Filter chip section ────────────────────────────────────────────────────
|
||
var filter_hdr := HBoxContainer.new()
|
||
filter_hdr.name = "FilterHeader"
|
||
filter_hdr.add_theme_constant_override("separation", 8)
|
||
vbox.add_child(filter_hdr)
|
||
|
||
var accepts_lbl := Label.new()
|
||
accepts_lbl.text = Strings.t(&"ui.stockpile.accepts")
|
||
accepts_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
accepts_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
filter_hdr.add_child(accepts_lbl)
|
||
|
||
_all_label = Label.new()
|
||
_all_label.name = "AllLabel"
|
||
_all_label.text = Strings.t(&"ui.stockpile.accepts_all_hint")
|
||
_all_label.modulate = Color(0.7, 0.7, 0.7)
|
||
_all_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
filter_hdr.add_child(_all_label)
|
||
|
||
# "Select all" / "Clear all" quick-action row.
|
||
var quick_row := HBoxContainer.new()
|
||
quick_row.name = "QuickRow"
|
||
quick_row.add_theme_constant_override("separation", 8)
|
||
vbox.add_child(quick_row)
|
||
|
||
var select_all_btn := Button.new()
|
||
select_all_btn.name = "SelectAllBtn"
|
||
select_all_btn.text = Strings.t(&"ui.stockpile.select_all")
|
||
select_all_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
select_all_btn.custom_minimum_size = Vector2(0, 40)
|
||
select_all_btn.focus_mode = Control.FOCUS_NONE
|
||
select_all_btn.pressed.connect(_on_select_all_pressed)
|
||
quick_row.add_child(select_all_btn)
|
||
|
||
var clear_all_btn := Button.new()
|
||
clear_all_btn.name = "ClearAllBtn"
|
||
clear_all_btn.text = Strings.t(&"ui.stockpile.clear_all")
|
||
clear_all_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
clear_all_btn.custom_minimum_size = Vector2(0, 40)
|
||
clear_all_btn.focus_mode = Control.FOCUS_NONE
|
||
clear_all_btn.pressed.connect(_on_clear_all_pressed)
|
||
quick_row.add_child(clear_all_btn)
|
||
|
||
# 4-column chip grid.
|
||
_chips_grid = GridContainer.new()
|
||
_chips_grid.name = "ChipsGrid"
|
||
_chips_grid.columns = 4
|
||
_chips_grid.add_theme_constant_override("h_separation", 4)
|
||
_chips_grid.add_theme_constant_override("v_separation", 4)
|
||
_chips_grid.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
vbox.add_child(_chips_grid)
|
||
|
||
|
||
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_stockpile_selected(zone) -> void:
|
||
current_zone = zone
|
||
_refresh_header()
|
||
_update_priority_row()
|
||
_populate_chips()
|
||
_set_visible(true)
|
||
Audit.log("stockpile_panel", "opened for zone at %s" % str(zone.position))
|
||
|
||
|
||
func _on_stockpile_deselected() -> void:
|
||
current_zone = null
|
||
_set_visible(false)
|
||
Audit.log("stockpile_panel", "closed (deselected)")
|
||
|
||
|
||
func _on_other_selected(_ignored = null) -> void:
|
||
# Mutual exclusion: any other selection type hides this panel.
|
||
if current_zone == null:
|
||
return
|
||
current_zone = null
|
||
_set_visible(false)
|
||
Audit.log("stockpile_panel", "closed (other panel selected)")
|
||
|
||
|
||
func _on_close_pressed() -> void:
|
||
current_zone = null
|
||
_set_visible(false)
|
||
EventBus.stockpile_deselected.emit()
|
||
Audit.log("stockpile_panel", "closed (X button)")
|
||
|
||
|
||
func _on_priority_pressed(prio_index: int) -> void:
|
||
if current_zone == null:
|
||
return
|
||
current_zone.priority = prio_index as StorageDestination.Priority
|
||
_update_priority_row()
|
||
EventBus.stockpile_layout_changed.emit()
|
||
Audit.log("stockpile_panel", "priority → %d" % prio_index)
|
||
|
||
|
||
func _on_select_all_pressed() -> void:
|
||
if current_zone == null:
|
||
return
|
||
# Empty array = wildcard — accept all. call_deferred to avoid freeing chips
|
||
# while this button's pressed signal is still emitting.
|
||
current_zone.accepted_types.clear()
|
||
call_deferred("_populate_chips")
|
||
EventBus.stockpile_layout_changed.emit()
|
||
Audit.log("stockpile_panel", "select_all → wildcard mode")
|
||
|
||
|
||
func _on_clear_all_pressed() -> void:
|
||
if current_zone == null:
|
||
return
|
||
# Start explicit-list mode but with nothing checked.
|
||
# We set accepted_types to all types so *nothing* is excluded yet, then
|
||
# clear to empty which means… wildcard. To mean "accept nothing" we need
|
||
# Priority.OFF instead. So: clear all chips means set priority to OFF and
|
||
# leave accepted_types alone (wildcard doesn't matter when OFF).
|
||
# The idiomatic result the player expects from "Clear all" on a filter grid
|
||
# is that the zone accepts nothing — set priority to OFF.
|
||
current_zone.priority = StorageDestination.Priority.OFF
|
||
_update_priority_row()
|
||
call_deferred("_populate_chips")
|
||
EventBus.stockpile_layout_changed.emit()
|
||
Audit.log("stockpile_panel", "clear_all → priority OFF")
|
||
|
||
|
||
# ── refresh helpers ────────────────────────────────────────────────────────────
|
||
|
||
func _refresh_header() -> void:
|
||
if current_zone == null:
|
||
return
|
||
var tile_count: int = 0
|
||
if current_zone.has_method("tile_count"):
|
||
tile_count = current_zone.tile_count()
|
||
elif current_zone.get("tiles") != null:
|
||
tile_count = current_zone.tiles.size()
|
||
_header_label.text = "%s (%d)" % [Strings.t(&"ui.stockpile.title"), tile_count]
|
||
|
||
|
||
func _update_priority_row() -> void:
|
||
if current_zone == null:
|
||
return
|
||
var active: int = int(current_zone.priority)
|
||
# CRITICAL=0 HIGH=1 NORMAL=2 LOW=3 OFF=4 — matches button array order.
|
||
var active_tint := Color(0.90, 0.65, 0.20)
|
||
var inactive_tint := Color(1.0, 1.0, 1.0)
|
||
for i in _priority_btns.size():
|
||
_priority_btns[i].modulate = active_tint if i == active else inactive_tint
|
||
|
||
|
||
func _populate_chips() -> void:
|
||
if current_zone == null:
|
||
return
|
||
|
||
_clear_children(_chips_grid)
|
||
|
||
var is_wildcard: bool = current_zone.accepted_types.is_empty()
|
||
_all_label.visible = is_wildcard
|
||
|
||
for type in Item.ALL_TYPES:
|
||
var chip := _make_chip(type, is_wildcard or (type in current_zone.accepted_types))
|
||
_chips_grid.add_child(chip)
|
||
|
||
|
||
## Build a single filter chip Button for one item type.
|
||
## checked=true renders the chip as "included" (white/bright), false as dimmed.
|
||
func _make_chip(type: StringName, checked: bool) -> Button:
|
||
# Label: try "item.<type>" key; Strings.t() returns the key string itself
|
||
# when missing (and emits a push_warning), so we detect that and fall back
|
||
# to a capitalized form without the "item." prefix.
|
||
var label_key: StringName = StringName("item." + String(type))
|
||
var looked_up: String = Strings.t(label_key)
|
||
var label_text: String
|
||
if looked_up != String(label_key):
|
||
label_text = looked_up
|
||
else:
|
||
label_text = String(type).capitalize()
|
||
|
||
var btn := Button.new()
|
||
btn.text = label_text
|
||
btn.custom_minimum_size = Vector2(0, 40)
|
||
btn.focus_mode = Control.FOCUS_NONE
|
||
btn.toggle_mode = false
|
||
btn.modulate = Color(1.0, 1.0, 1.0) if checked else Color(0.5, 0.5, 0.5, 0.8)
|
||
|
||
btn.pressed.connect(func() -> void: _on_chip_pressed(type))
|
||
return btn
|
||
|
||
|
||
func _on_chip_pressed(type: StringName) -> void:
|
||
if current_zone == null:
|
||
return
|
||
|
||
var is_wildcard: bool = current_zone.accepted_types.is_empty()
|
||
|
||
if is_wildcard:
|
||
# Wildcard → explicit: start with all types checked, then remove this one.
|
||
var explicit: Array[StringName] = []
|
||
for t in Item.ALL_TYPES:
|
||
if t != type:
|
||
explicit.append(t)
|
||
current_zone.accepted_types = explicit
|
||
else:
|
||
# Toggle: add if absent, remove if present.
|
||
if type in current_zone.accepted_types:
|
||
current_zone.accepted_types.erase(type)
|
||
# If we just unchecked the last one, go back to wildcard.
|
||
if current_zone.accepted_types.is_empty():
|
||
pass # empty = wildcard already
|
||
else:
|
||
current_zone.accepted_types.append(type)
|
||
|
||
# Defer the rebuild — don't free this chip while its pressed signal emits.
|
||
call_deferred("_populate_chips")
|
||
EventBus.stockpile_layout_changed.emit()
|
||
Audit.log("stockpile_panel", "chip toggled: %s → accepted_types size=%d" % [
|
||
type, current_zone.accepted_types.size()
|
||
])
|
||
|
||
|
||
# ── visibility ────────────────────────────────────────────────────────────────
|
||
|
||
func _set_visible(v: bool) -> void:
|
||
if _panel != null:
|
||
_panel.visible = v
|