Phase 17/18 closure: stockpile filter UI + day summary + atmospheric audio

Three-agent fan-out (gdscript-refactor x3) closing deferred polish:

- Stockpile chip filter UI: new StockpilePanel (layer 18, right-anchored,
  mirrors WorkbenchPanel). 5-priority segmented control + 21-chip 4-col
  filter grid using Item.ALL_TYPES; wildcard (empty accepted_types) shows
  all chips checked with 'All' hint, first explicit pick switches to
  explicit-list mode. Selection chain extended to pawn → workbench →
  stockpile with mutual exclusion. 12 ui.stockpile.* + 13 item.* keys.

- DaySummaryCard: layer-19 modal auto-opens at dusk→night via day_ended,
  auto-pauses sim, shows day+season header, weather row, stats grid with
  green/yellow/red tension bar, Continue dismiss + backdrop tap.
  Settings 'Show end-of-day summary' toggle persists via GameState.

- Atmospheric audio: rain ambient loop (Cozy Melodies Pack 6) on
  weather_changed rain/storm with 0.5s fade-out on clear; thunder sting
  (Magic and Spells 6) on rain→storm transition; raid warning sting
  (Sword Pack 1, 'blades drawn') on EventBus.wolf_spawned. All on SFX
  bus — inherits existing slider + suspend mute.

Contracts pre-written before fan-out: EventBus.stockpile_selected /
stockpile_deselected / wolf_spawned signals; WolfSpawner._trigger_raid
+ _on_request_wolf_spawn now emit wolf_spawned with the spawned array.

MCP runtime verified: StockpilePanel opens with 21 chips, DaySummaryCard
renders weather row + tension bar + auto-pause, rain_player.playing=true
on weather_changed(rain), all three new SFX keys in Audio.SFX_FILES.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-16 17:20:40 +01:00
parent 88e3fa9364
commit bba1ce4334
18 changed files with 935 additions and 18 deletions

View file

@ -0,0 +1,375 @@
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()
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")
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")
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")
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