rimlike/scenes/ui/stockpile_panel.gd
megaproxy d9638a4ea4 fix six critical bugs from audit sprint
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>
2026-05-16 18:06:55 +01:00

379 lines
14 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 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