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

BIN
audio/sfx/raid_warning.ogg Normal file

Binary file not shown.

View file

@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://v4x2amlkbvx8"
path="res://.godot/imported/raid_warning.ogg-89250df9a7224461cf6aacd16fa7bbe9.oggvorbisstr"
[deps]
source_file="res://audio/sfx/raid_warning.ogg"
dest_files=["res://.godot/imported/raid_warning.ogg-89250df9a7224461cf6aacd16fa7bbe9.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

BIN
audio/sfx/rain_ambient.ogg Normal file

Binary file not shown.

View file

@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dqnag3fkym4du"
path="res://.godot/imported/rain_ambient.ogg-6ff82d75655695d38fb7a3cec3a90a39.oggvorbisstr"
[deps]
source_file="res://audio/sfx/rain_ambient.ogg"
dest_files=["res://.godot/imported/rain_ambient.ogg-6ff82d75655695d38fb7a3cec3a90a39.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

BIN
audio/sfx/thunder_sting.ogg Normal file

Binary file not shown.

View file

@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://cicdya1141lqu"
path="res://.godot/imported/thunder_sting.ogg-99961441947f162c6b1f91d5eab1b9b5.oggvorbisstr"
[deps]
source_file="res://audio/sfx/thunder_sting.ogg"
dest_files=["res://.godot/imported/thunder_sting.ogg-99961441947f162c6b1f91d5eab1b9b5.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

View file

@ -1,5 +1,5 @@
extends Node extends Node
## AudioManager — Phase 18. ## AudioManager — Phase 18 + atmospheric audio (Phase 18 follow-up).
## ##
## Owns three AudioServer buses (Master, Music, SFX), a small catalog of music ## Owns three AudioServer buses (Master, Music, SFX), a small catalog of music
## loops + one-shot SFX, and a single AudioStreamPlayer for the music director. ## loops + one-shot SFX, and a single AudioStreamPlayer for the music director.
@ -9,6 +9,13 @@ extends Node
## EventBus signals (combat, storyteller stings) and direct calls from Tree / ## EventBus signals (combat, storyteller stings) and direct calls from Tree /
## Rock entities for chop / mine completion sounds. ## Rock entities for chop / mine completion sounds.
## ##
## Atmospheric audio (Phase 18 follow-up):
## - Rain ambient loop: persistent AudioStreamPlayer on SFX bus, fades in/out
## on weather_changed. Routed to SFX bus (no new bus) so it respects the
## existing SFX slider and application-pause muting via Master bus mute.
## - Storm thunder sting: one-shot on WEATHER_RAIN → WEATHER_STORM transition.
## - Raid warning sting: one-shot on wolf_spawned.
##
## Volume sliders in SettingsMenu drive set_*_db() at the Master/Music/SFX bus ## Volume sliders in SettingsMenu drive set_*_db() at the Master/Music/SFX bus
## level. App-pause mutes everything (NOTIFICATION_APPLICATION_PAUSED / focus ## level. App-pause mutes everything (NOTIFICATION_APPLICATION_PAUSED / focus
## loss) to match the existing "no background simulation" rule. ## loss) to match the existing "no background simulation" rule.
@ -33,6 +40,14 @@ const SFX_FILES: Dictionary = {
&"tree_fell": "res://audio/sfx/tree_fell.ogg", &"tree_fell": "res://audio/sfx/tree_fell.ogg",
&"mine_tick": "res://audio/sfx/mine_tick.ogg", &"mine_tick": "res://audio/sfx/mine_tick.ogg",
&"combat_hit": "res://audio/sfx/combat_hit.ogg", &"combat_hit": "res://audio/sfx/combat_hit.ogg",
# Atmospheric — Phase 18 follow-up.
# rain_ambient: Cozy Melodies Pack 6 (ElvGames Ultimate Farming RPG Tier 3)
&"rain_ambient": "res://audio/sfx/rain_ambient.ogg",
# thunder_sting: Magic and Spells 6 (ElvGames Ultimate Farming RPG Tier 3)
&"thunder_sting": "res://audio/sfx/thunder_sting.ogg",
# raid_warning: Sword Sound Effects Pack 1 (ElvGames Ultimate Farming RPG Tier 3)
# Sword unsheathe sting used as danger signal; no dedicated alarm/horn in bundle.
&"raid_warning": "res://audio/sfx/raid_warning.ogg",
} }
const MUSIC_FILES: Dictionary = { const MUSIC_FILES: Dictionary = {
@ -57,6 +72,14 @@ var master_linear: float = 1.0
var music_linear: float = 1.0 var music_linear: float = 1.0
var sfx_linear: float = 1.0 var sfx_linear: float = 1.0
# Atmospheric — rain ambient loop player. Persistent; stream stays loaded.
# Routed to SFX bus so it inherits the SFX slider and app-pause muting.
var _rain_player: AudioStreamPlayer = null
# Tween used for the rain fade-out (clear/cold_snap). Cancelled on re-rain.
var _rain_tween: Tween = null
# Track previous weather to detect RAIN → STORM transition for thunder sting.
var _prev_weather: StringName = &""
# ── lifecycle ─────────────────────────────────────────────────────────────── # ── lifecycle ───────────────────────────────────────────────────────────────
@ -68,6 +91,15 @@ func _ready() -> void:
_music_player.name = "MusicPlayer" _music_player.name = "MusicPlayer"
add_child(_music_player) add_child(_music_player)
# Rain ambient: persistent looping player on SFX bus. Starts silent; weather
# signal activates it. Routing to SFX bus means app-pause muting (Master bus)
# covers it automatically — no extra NOTIFICATION handling needed.
_rain_player = AudioStreamPlayer.new()
_rain_player.bus = BUS_SFX
_rain_player.name = "RainAmbientPlayer"
_rain_player.volume_db = 0.0
add_child(_rain_player)
# SFX → world event wiring. Keep this list short and obvious. # SFX → world event wiring. Keep this list short and obvious.
if EventBus.has_signal("pawn_took_damage"): if EventBus.has_signal("pawn_took_damage"):
EventBus.pawn_took_damage.connect(_on_pawn_took_damage) EventBus.pawn_took_damage.connect(_on_pawn_took_damage)
@ -75,6 +107,10 @@ func _ready() -> void:
EventBus.storyteller_event_fired.connect(_on_storyteller_event_fired) EventBus.storyteller_event_fired.connect(_on_storyteller_event_fired)
if EventBus.has_signal("alert_added"): if EventBus.has_signal("alert_added"):
EventBus.alert_added.connect(_on_alert_added) EventBus.alert_added.connect(_on_alert_added)
if EventBus.has_signal("weather_changed"):
EventBus.weather_changed.connect(_on_weather_changed)
if EventBus.has_signal("wolf_spawned"):
EventBus.wolf_spawned.connect(_on_wolf_spawned)
# Music director: swap day/night loops as Clock.phase_changed fires. # Music director: swap day/night loops as Clock.phase_changed fires.
if Clock.has_signal("phase_changed"): if Clock.has_signal("phase_changed"):
@ -86,7 +122,14 @@ func _ready() -> void:
initial_phase = Clock.current_phase() initial_phase = Clock.current_phase()
_apply_phase_to_music(initial_phase) _apply_phase_to_music(initial_phase)
Audit.log("audio", "AudioManager ready (master/music/sfx buses online)") # Sync rain loop to current weather in case we loaded mid-rain (e.g. after
# save/load). Weather autoload is ready before Audio in the autoload list;
# safe to query here.
if Weather.has_method("is_raining") and Weather.is_raining():
_prev_weather = Weather.current_weather
_start_rain_loop()
Audit.log("audio", "AudioManager ready (master/music/sfx buses online + atmospheric)")
## NOTIFICATION_APPLICATION_PAUSED fires when the app is suspended (mobile ## NOTIFICATION_APPLICATION_PAUSED fires when the app is suspended (mobile
@ -232,3 +275,54 @@ func _on_alert_added(_severity: StringName, _text: String, _focus_tile: Vector2i
func _on_clock_phase_changed(new_phase: StringName) -> void: func _on_clock_phase_changed(new_phase: StringName) -> void:
_apply_phase_to_music(new_phase) _apply_phase_to_music(new_phase)
func _on_weather_changed(weather: StringName) -> void:
var prev: StringName = _prev_weather
_prev_weather = weather
match weather:
&"rain", &"storm":
# Cancel any pending fade-out tween.
if _rain_tween != null and _rain_tween.is_valid():
_rain_tween.kill()
_rain_tween = null
_start_rain_loop()
# Thunder sting only on a direct transition into storm.
if weather == &"storm" and prev != &"storm":
play_sfx(&"thunder_sting")
_:
# WEATHER_CLEAR or WEATHER_COLD_SNAP — fade out rain loop.
_stop_rain_loop_fade()
func _on_wolf_spawned(wolves: Array) -> void:
if wolves.size() > 0:
play_sfx(&"raid_warning")
# ── rain ambient helpers ─────────────────────────────────────────────────────
func _start_rain_loop() -> void:
# Load the stream once and reuse across transitions.
var stream: AudioStream = _load_sfx(&"rain_ambient")
if stream == null:
return
if _rain_player.stream != stream:
_rain_player.stream = stream
if stream is AudioStreamOggVorbis:
stream.loop = true
_rain_player.volume_db = 0.0
if not _rain_player.playing:
_rain_player.play()
func _stop_rain_loop_fade() -> void:
if not _rain_player.playing:
return
# 0.5 s linear fade from current volume to SILENT_DB, then stop.
if _rain_tween != null and _rain_tween.is_valid():
_rain_tween.kill()
_rain_tween = create_tween()
_rain_tween.tween_property(_rain_player, "volume_db", SILENT_DB, 0.5)
_rain_tween.tween_callback(_rain_player.stop)

View file

@ -57,9 +57,12 @@ signal pawn_selected(pawn) ## Emitted when Selection pic
signal pawn_deselected ## Emitted when Selection clears — closes PawnDetailPanel. signal pawn_deselected ## Emitted when Selection clears — closes PawnDetailPanel.
signal workbench_selected(workbench) signal workbench_selected(workbench)
signal workbench_deselected signal workbench_deselected
signal stockpile_selected(zone) ## Emitted when Selection picks a StockpileZone — opens StockpilePanel for filter editing.
signal stockpile_deselected ## Emitted when Selection clears the active stockpile.
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.
signal wolf_spawned(wolves: Array) ## Emitted by WolfSpawner AFTER a raid wave has been instantiated; carries the spawned Wolf nodes. Audio uses this for raid-warning sting.
signal day_ended(summary: Dictionary) ## Emitted by Clock at dusk→night boundary; carries the end-of-day recap dict. signal day_ended(summary: Dictionary) ## Emitted by Clock at dusk→night boundary; carries the end-of-day recap dict.
# Phase 18 — Alert wiring (dangling signals surfaced to AlertsLog). # Phase 18 — Alert wiring (dangling signals surfaced to AlertsLog).

View file

@ -17,6 +17,7 @@ var settings: Dictionary = {
"pause_on_wanderer": true, "pause_on_wanderer": true,
"pause_on_pawn_down": true, "pause_on_pawn_down": true,
"pause_on_modal": true, "pause_on_modal": true,
"show_day_summary": true,
"audio_master": 1.0, "audio_master": 1.0,
"audio_music": 1.0, "audio_music": 1.0,
"audio_sfx": 1.0, "audio_sfx": 1.0,

View file

@ -218,6 +218,49 @@ const TABLE: Dictionary = {
&"ui.bill.no_bills_hint": "No bills. Add one to start crafting.", &"ui.bill.no_bills_hint": "No bills. Add one to start crafting.",
&"ui.workbench.current_bill": "Current", &"ui.workbench.current_bill": "Current",
&"ui.workbench.idle": "Idle", &"ui.workbench.idle": "Idle",
# Phase 17 — DaySummaryCard end-of-day modal.
&"ui.day_summary.title": "Day {day} — {season}",
&"ui.day_summary.continue": "Continue",
&"ui.day_summary.pawns_alive": "Pawns alive",
&"ui.day_summary.wolves_on_map": "Wolves on map",
&"ui.day_summary.tension": "Tension",
&"ui.day_summary.tension_fmt": "{t} / 100",
# Weather labels (weather.<name>)
&"weather.clear": "Clear",
&"weather.rain": "Rain",
&"weather.storm": "Storm",
&"weather.cold_snap": "Cold Snap",
&"weather.unknown": "Unknown",
# Settings checkbox label
&"ui.settings.show_day_summary": "Show end-of-day summary",
# Phase 17 — Stockpile filter chips (StockpilePanel).
&"ui.stockpile.title": "Stockpile",
&"ui.stockpile.priority": "Priority:",
&"ui.stockpile.prio.critical": "Crit",
&"ui.stockpile.prio.high": "High",
&"ui.stockpile.prio.normal": "Norm",
&"ui.stockpile.prio.low": "Low",
&"ui.stockpile.prio.off": "Off",
&"ui.stockpile.accepts": "Accepts:",
&"ui.stockpile.accepts_all_hint": "All",
&"ui.stockpile.select_all": "All",
&"ui.stockpile.clear_all": "None",
# Item type chip labels missing from existing table (copper_ore through ash).
# (item.wood, .stone, .iron_ore, .plank, .stone_block, .flour, .bread, .meal
# are already defined above — only the ones below are new.)
&"item.copper_ore": "Copper",
&"item.silver": "Silver",
&"item.gold": "Gold",
&"item.cloth": "Cloth",
&"item.vegetable": "Vegetable",
&"item.meat": "Meat",
&"item.grain": "Grain",
&"item.medicine": "Medicine",
&"item.tool": "Tool",
&"item.weapon": "Weapon",
&"item.armor": "Armor",
&"item.corpse": "Corpse",
&"item.ash": "Ash",
} }

View file

@ -54,10 +54,13 @@ func _trigger_raid(current_tick: int) -> void:
_last_raid_tick = current_tick _last_raid_tick = current_tick
var pack_size := randi_range(PACK_MIN, PACK_MAX) var pack_size := randi_range(PACK_MIN, PACK_MAX)
var spawn_tiles := _pick_spawn_tiles(pack_size) var spawn_tiles := _pick_spawn_tiles(pack_size)
var spawned: Array = []
for spawn_tile in spawn_tiles: for spawn_tile in spawn_tiles:
var w: Wolf = WOLF_SCENE.instantiate() var w: Wolf = WOLF_SCENE.instantiate()
get_parent().add_child(w) get_parent().add_child(w)
w.setup(spawn_tile) w.setup(spawn_tile)
spawned.append(w)
EventBus.wolf_spawned.emit(spawned)
Audit.log("wolf", "RAID: %d wolf(ves) spawned at %s" % [pack_size, spawn_tiles]) Audit.log("wolf", "RAID: %d wolf(ves) spawned at %s" % [pack_size, spawn_tiles])
@ -68,10 +71,13 @@ func _trigger_raid(current_tick: int) -> void:
## the night-attack cooldown, so organic raids can still follow. ## the night-attack cooldown, so organic raids can still follow.
func _on_request_wolf_spawn(count: int) -> void: func _on_request_wolf_spawn(count: int) -> void:
var spawn_tiles := _pick_spawn_tiles(count) var spawn_tiles := _pick_spawn_tiles(count)
var spawned: Array = []
for spawn_tile in spawn_tiles: for spawn_tile in spawn_tiles:
var w: Wolf = WOLF_SCENE.instantiate() var w: Wolf = WOLF_SCENE.instantiate()
get_parent().add_child(w) get_parent().add_child(w)
w.setup(spawn_tile) w.setup(spawn_tile)
spawned.append(w)
EventBus.wolf_spawned.emit(spawned)
Audit.log("wolf", "FORCED RAID (event): %d wolf(ves) spawned at %s" % [count, spawn_tiles]) Audit.log("wolf", "FORCED RAID (event): %d wolf(ves) spawned at %s" % [count, spawn_tiles])

View file

@ -20,6 +20,7 @@ const RESUME_TOAST_SCRIPT: Script = preload("res://scenes/ui/resume_toa
# 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 WORKBENCH_PANEL_SCRIPT: Script = preload("res://scenes/ui/workbench_panel.gd")
const STOCKPILE_PANEL_SCRIPT: Script = preload("res://scenes/ui/stockpile_panel.gd")
const MEDIEVAL_THEME_SCRIPT: Script = preload("res://scenes/ui/medieval_theme.gd") const MEDIEVAL_THEME_SCRIPT: Script = preload("res://scenes/ui/medieval_theme.gd")
# Built once in _ready and re-applied to any CanvasLayer-rooted Control because # Built once in _ready and re-applied to any CanvasLayer-rooted Control because
@ -31,6 +32,8 @@ const BUILD_DRAWER_SCRIPT: Script = preload("res://scenes/ui/build_draw
# Phase 17 (Agent C) — WorkPriorityMatrix (layer 17) and AlertsLog (layer 19). # Phase 17 (Agent C) — WorkPriorityMatrix (layer 17) and AlertsLog (layer 19).
const WORK_PRIORITY_MATRIX_SCRIPT: Script = preload("res://scenes/ui/work_priority_matrix.gd") const WORK_PRIORITY_MATRIX_SCRIPT: Script = preload("res://scenes/ui/work_priority_matrix.gd")
const ALERTS_LOG_SCRIPT: Script = preload("res://scenes/ui/alerts_log.gd") const ALERTS_LOG_SCRIPT: Script = preload("res://scenes/ui/alerts_log.gd")
# Phase 17 — DaySummaryCard end-of-day recap modal (layer 19).
const DAY_SUMMARY_CARD_SCRIPT: Script = preload("res://scenes/ui/day_summary_card.gd")
func _ready() -> void: func _ready() -> void:
@ -99,6 +102,13 @@ func _ready() -> void:
workbench_panel.name = "WorkbenchPanel" workbench_panel.name = "WorkbenchPanel"
add_child(workbench_panel) add_child(workbench_panel)
# Stockpile filter + priority editor. Right-anchored 360 px, layer 18;
# mutually exclusive with PawnDetailPanel and WorkbenchPanel via Selection.
var stockpile_panel := CanvasLayer.new()
stockpile_panel.set_script(STOCKPILE_PANEL_SCRIPT)
stockpile_panel.name = "StockpilePanel"
add_child(stockpile_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"
@ -169,6 +179,14 @@ func _ready() -> void:
Audit.log("main", "Phase 17 (Agent C) — WorkPriorityMatrix + AlertsLog mounted.") Audit.log("main", "Phase 17 (Agent C) — WorkPriorityMatrix + AlertsLog mounted.")
# Phase 17 — DaySummaryCard (layer 19) — auto-opens on day_ended signal.
var day_summary_card := CanvasLayer.new()
day_summary_card.set_script(DAY_SUMMARY_CARD_SCRIPT)
day_summary_card.name = "DaySummaryCard"
add_child(day_summary_card)
Audit.log("main", "Phase 17 — DaySummaryCard mounted.")
# Apply the medieval theme to every Control under each CanvasLayer. # Apply the medieval theme to every Control under each CanvasLayer.
# CanvasLayers interrupt the root-Window theme cascade so we have to seed # CanvasLayers interrupt the root-Window theme cascade so we have to seed
# each one explicitly. Defer one frame so panels that build their UI in # each one explicitly. Defer one frame so panels that build their UI in

View file

@ -0,0 +1,268 @@
class_name DaySummaryCard extends CanvasLayer
## Phase 17 — End-of-day recap modal shown at every dusk→night boundary.
##
## Layer 19: above PawnDetailPanel (18), below StorytellerModal (20).
## Auto-opens on EventBus.day_ended; auto-pauses the sim.
## Dismiss via the Continue button OR tapping anywhere on the dim backdrop.
## Opt-out via Settings → "Show end-of-day summary" (GameState.settings["show_day_summary"]).
##
## Layout: centered 480×400 card with header, weather row, stats grid, tension bar.
## Sim pause/resume contract: Sim.set_speed(Speed.PAUSE) on open, restore prior
## speed on dismiss.
const CARD_W: int = 480
const CARD_H: int = 400
# Tension bar thresholds.
const TENSION_LOW: float = 40.0
const TENSION_MID: float = 70.0
# Weather emoji map — procedural, no asset dependency.
const WEATHER_ICONS: Dictionary = {
&"clear": "",
&"rain": "",
&"storm": "",
&"cold_snap": "",
}
# ── node refs ─────────────────────────────────────────────────────────────────
var _dim: ColorRect = null
var _panel: PanelContainer = null
var _header: Label = null
var _weather_row: HBoxContainer = null
var _weather_icon: Label = null
var _weather_label: Label = null
var _stats_grid: GridContainer = null
var _tension_bar: ColorRect = null
var _tension_track: ColorRect = null
var _continue_btn: Button = null
# Speed saved at open so we restore it correctly (player may have been on Fast).
var _speed_before_open: int = Sim.Speed.FAST
func _ready() -> void:
layer = 19
_build_ui()
_set_visible(false)
if EventBus.has_signal("day_ended"):
EventBus.day_ended.connect(_on_day_ended)
Audit.log("day_summary_card", "DaySummaryCard ready (layer %d)" % layer)
func _exit_tree() -> void:
if EventBus.has_signal("day_ended") and EventBus.day_ended.is_connected(_on_day_ended):
EventBus.day_ended.disconnect(_on_day_ended)
# ── UI construction ───────────────────────────────────────────────────────────
func _build_ui() -> void:
# Full-screen dim — captures backdrop taps for dismiss.
_dim = ColorRect.new()
_dim.name = "Dim"
_dim.set_anchors_preset(Control.PRESET_FULL_RECT)
_dim.color = Color(0.0, 0.0, 0.0, 0.50)
_dim.mouse_filter = Control.MOUSE_FILTER_STOP
_dim.gui_input.connect(_on_dim_input)
add_child(_dim)
# Centered card panel — fixed explicit size to avoid auto-resize jumping.
_panel = PanelContainer.new()
_panel.name = "Card"
_panel.set_anchors_preset(Control.PRESET_CENTER)
_panel.custom_minimum_size = Vector2(CARD_W, CARD_H)
_panel.offset_left = -CARD_W / 2
_panel.offset_right = CARD_W / 2
_panel.offset_top = -CARD_H / 2
_panel.offset_bottom = CARD_H / 2
add_child(_panel)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 14)
_panel.add_child(vbox)
# ── Header: "Day N — Season" ──────────────────────────────────────────────
_header = Label.new()
_header.name = "Header"
_header.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_header.add_theme_font_size_override("font_size", 22)
vbox.add_child(_header)
vbox.add_child(HSeparator.new())
# ── Weather row ───────────────────────────────────────────────────────────
_weather_row = HBoxContainer.new()
_weather_row.alignment = BoxContainer.ALIGNMENT_CENTER
_weather_row.add_theme_constant_override("separation", 8)
vbox.add_child(_weather_row)
_weather_icon = Label.new()
_weather_icon.name = "WeatherIcon"
_weather_icon.add_theme_font_size_override("font_size", 20)
_weather_row.add_child(_weather_icon)
_weather_label = Label.new()
_weather_label.name = "WeatherLabel"
_weather_row.add_child(_weather_label)
vbox.add_child(HSeparator.new())
# ── Stats grid: 2 columns label | value ──────────────────────────────────
_stats_grid = GridContainer.new()
_stats_grid.columns = 2
_stats_grid.add_theme_constant_override("h_separation", 24)
_stats_grid.add_theme_constant_override("v_separation", 8)
vbox.add_child(_stats_grid)
# Pawns alive row — values populated in _populate().
_add_stat_row(Strings.t(&"ui.day_summary.pawns_alive"), "PawnsValue")
# Wolves row.
_add_stat_row(Strings.t(&"ui.day_summary.wolves_on_map"), "WolvesValue")
# Tension row — label only here; bar added below.
_add_stat_row(Strings.t(&"ui.day_summary.tension"), "TensionValue")
# Tension bar — track (grey bg) + fill (color).
var bar_container := VBoxContainer.new()
bar_container.add_theme_constant_override("separation", 4)
# Span both grid columns via a separate vbox below the grid.
vbox.add_child(bar_container)
_tension_track = ColorRect.new()
_tension_track.name = "TensionTrack"
_tension_track.custom_minimum_size = Vector2(CARD_W - 48, 10)
_tension_track.color = Color(0.25, 0.22, 0.18, 1.0)
bar_container.add_child(_tension_track)
_tension_bar = ColorRect.new()
_tension_bar.name = "TensionBar"
# Width set dynamically in _populate(); height matches track.
_tension_bar.custom_minimum_size = Vector2(0, 10)
_tension_track.add_child(_tension_bar)
# Anchor the bar to the left edge of the track.
_tension_bar.set_anchors_preset(Control.PRESET_LEFT_WIDE)
vbox.add_child(HSeparator.new())
# ── Continue button ───────────────────────────────────────────────────────
var btn_row := HBoxContainer.new()
btn_row.alignment = BoxContainer.ALIGNMENT_CENTER
vbox.add_child(btn_row)
_continue_btn = Button.new()
_continue_btn.name = "ContinueBtn"
_continue_btn.text = Strings.t(&"ui.day_summary.continue")
_continue_btn.custom_minimum_size = Vector2(140, 48)
_continue_btn.focus_mode = Control.FOCUS_NONE
_continue_btn.pressed.connect(_on_continue_pressed)
btn_row.add_child(_continue_btn)
## Add a label+value pair row to the stats grid.
## Returns the value Label so _populate() can update its text.
func _add_stat_row(label_text: String, value_name: String) -> Label:
var lbl := Label.new()
lbl.text = label_text
_stats_grid.add_child(lbl)
var val := Label.new()
val.name = value_name
val.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
val.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_stats_grid.add_child(val)
return val
# ── event handling ────────────────────────────────────────────────────────────
func _on_day_ended(summary: Dictionary) -> void:
if not bool(GameState.settings.get("show_day_summary", true)):
return
call_deferred("_open_with_summary", summary)
func _open_with_summary(summary: Dictionary) -> void:
_populate(summary)
_speed_before_open = int(Sim.current_speed)
Sim.set_speed(Sim.Speed.PAUSE)
_set_visible(true)
Audit.log("day_summary_card", "opened: day=%d season=%s tension=%.1f" % [
int(summary.get("day", 0)),
String(summary.get("season", &"")),
float(summary.get("tension", 0.0)),
])
func _populate(summary: Dictionary) -> void:
var day: int = int(summary.get("day", 0))
var season: StringName = summary.get("season", &"spring")
var weather: StringName = summary.get("weather", &"clear")
var pawns_alive: int = int(summary.get("pawns_alive", 0))
var wolves: int = int(summary.get("wolves_alive", 0))
var tension: float = float(summary.get("tension", 0.0))
# Header.
var season_label: String = Strings.t(StringName("season." + String(season)))
_header.text = Strings.t(&"ui.day_summary.title").format({
"day": str(day),
"season": season_label,
})
# Weather.
_weather_icon.text = WEATHER_ICONS.get(weather, "?")
_weather_label.text = Strings.t(StringName("weather." + String(weather)))
# Stat values — find nodes by name inside the grid.
var pawns_val: Label = _stats_grid.find_child("PawnsValue", false, false)
if pawns_val != null:
pawns_val.text = str(pawns_alive)
var wolves_val: Label = _stats_grid.find_child("WolvesValue", false, false)
if wolves_val != null:
wolves_val.text = str(wolves)
var tension_val: Label = _stats_grid.find_child("TensionValue", false, false)
if tension_val != null:
tension_val.text = Strings.t(&"ui.day_summary.tension_fmt").format({"t": "%.0f" % tension})
# Tension color bar — width is a fraction of the track.
if _tension_track != null and _tension_bar != null:
var track_w: float = float(CARD_W - 48)
var fill_w: float = clampf(tension / 100.0, 0.0, 1.0) * track_w
_tension_bar.size = Vector2(fill_w, _tension_track.custom_minimum_size.y)
if tension < TENSION_LOW:
_tension_bar.color = Color(0.25, 0.75, 0.25, 1.0) # green
elif tension < TENSION_MID:
_tension_bar.color = Color(0.85, 0.75, 0.15, 1.0) # yellow
else:
_tension_bar.color = Color(0.85, 0.25, 0.20, 1.0) # red
# ── dismiss ───────────────────────────────────────────────────────────────────
func _on_continue_pressed() -> void:
_dismiss()
func _on_dim_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
_dismiss()
func _dismiss() -> void:
_set_visible(false)
Sim.set_speed(Sim.Speed.values()[_speed_before_open])
Audit.log("day_summary_card", "dismissed, speed restored to %d" % _speed_before_open)
# ── visibility ────────────────────────────────────────────────────────────────
func _set_visible(v: bool) -> void:
# Toggle CanvasLayer.visible so the dim Control (MOUSE_FILTER_STOP) does not
# eat _unhandled_input mouse events for the world below when the card is hidden.
visible = v
if _dim != null:
_dim.visible = v
if _panel != null:
_panel.visible = v

View file

@ -0,0 +1 @@
uid://7gvadq4jib4v

View file

@ -13,10 +13,11 @@ var _dim: ColorRect = null
var _panel: PanelContainer = null var _panel: PanelContainer = null
# Auto-pause checkboxes. # Auto-pause checkboxes.
var _cb_threat: CheckBox = null var _cb_threat: CheckBox = null
var _cb_wanderer: CheckBox = null var _cb_wanderer: CheckBox = null
var _cb_pawn_down: CheckBox = null var _cb_pawn_down: CheckBox = null
var _cb_modal: CheckBox = null var _cb_modal: CheckBox = null
var _cb_day_summary: CheckBox = null
# Audio sliders. # Audio sliders.
var _sl_master: HSlider = null var _sl_master: HSlider = null
@ -105,10 +106,11 @@ func _build_ui() -> void:
ap_hdr.text = Strings.t(&"ui.settings.auto_pause") ap_hdr.text = Strings.t(&"ui.settings.auto_pause")
vbox.add_child(ap_hdr) vbox.add_child(ap_hdr)
_cb_threat = _make_checkbox(Strings.t(&"ui.settings.pause_threat"), vbox) _cb_threat = _make_checkbox(Strings.t(&"ui.settings.pause_threat"), vbox)
_cb_wanderer = _make_checkbox(Strings.t(&"ui.settings.pause_wanderer"), vbox) _cb_wanderer = _make_checkbox(Strings.t(&"ui.settings.pause_wanderer"), vbox)
_cb_pawn_down = _make_checkbox(Strings.t(&"ui.settings.pause_pawn_down"), vbox) _cb_pawn_down = _make_checkbox(Strings.t(&"ui.settings.pause_pawn_down"), vbox)
_cb_modal = _make_checkbox(Strings.t(&"ui.settings.pause_modal"), vbox) _cb_modal = _make_checkbox(Strings.t(&"ui.settings.pause_modal"), vbox)
_cb_day_summary = _make_checkbox(Strings.t(&"ui.settings.show_day_summary"), vbox)
_add_separator(vbox) _add_separator(vbox)
@ -196,10 +198,11 @@ func _add_separator(parent: VBoxContainer) -> void:
func _load_from_game_state() -> void: func _load_from_game_state() -> void:
var s: Dictionary = GameState.settings var s: Dictionary = GameState.settings
_cb_threat.button_pressed = bool(s.get("pause_on_threat", true)) _cb_threat.button_pressed = bool(s.get("pause_on_threat", true))
_cb_wanderer.button_pressed = bool(s.get("pause_on_wanderer", true)) _cb_wanderer.button_pressed = bool(s.get("pause_on_wanderer", true))
_cb_pawn_down.button_pressed = bool(s.get("pause_on_pawn_down", true)) _cb_pawn_down.button_pressed = bool(s.get("pause_on_pawn_down", true))
_cb_modal.button_pressed = bool(s.get("pause_on_modal", true)) _cb_modal.button_pressed = bool(s.get("pause_on_modal", true))
_cb_day_summary.button_pressed = bool(s.get("show_day_summary", true))
_sl_master.value = float(s.get("audio_master", 1.0)) _sl_master.value = float(s.get("audio_master", 1.0))
_sl_music.value = float(s.get("audio_music", 1.0)) _sl_music.value = float(s.get("audio_music", 1.0))
@ -230,6 +233,7 @@ func _collect_to_dict() -> Dictionary:
"pause_on_wanderer": _cb_wanderer.button_pressed, "pause_on_wanderer": _cb_wanderer.button_pressed,
"pause_on_pawn_down": _cb_pawn_down.button_pressed, "pause_on_pawn_down": _cb_pawn_down.button_pressed,
"pause_on_modal": _cb_modal.button_pressed, "pause_on_modal": _cb_modal.button_pressed,
"show_day_summary": _cb_day_summary.button_pressed,
"audio_master": _sl_master.value, "audio_master": _sl_master.value,
"audio_music": _sl_music.value, "audio_music": _sl_music.value,
"audio_sfx": _sl_sfx.value, "audio_sfx": _sl_sfx.value,

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

View file

@ -0,0 +1 @@
uid://bqvdr1ww4bvpf

View file

@ -17,6 +17,8 @@ var _selected_pawn: Pawn = null
## Currently selected workbench, or null. Mutually exclusive with _selected_pawn — ## Currently selected workbench, or null. Mutually exclusive with _selected_pawn —
## selecting one clears the other (see _select / _select_workbench). ## selecting one clears the other (see _select / _select_workbench).
var _selected_workbench: Workbench = null var _selected_workbench: Workbench = null
## Currently selected stockpile zone, or null. Mutually exclusive with the above.
var _selected_stockpile = null # StockpileZone (duck-typed to avoid circular preload)
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
@ -76,12 +78,17 @@ func _unhandled_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
Audit.log("selection", "escape: deselected workbench") Audit.log("selection", "escape: deselected workbench")
return return
if _selected_stockpile != null:
_deselect_stockpile()
get_viewport().set_input_as_handled()
Audit.log("selection", "escape: deselected stockpile")
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 / workbench ── # ── Right-click: cancel designation (if active) or deselect pawn / workbench / stockpile ──
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 whatever is selected. # this right-click, no designation was active. Deselect whatever is selected.
@ -91,6 +98,9 @@ func _unhandled_input(event: InputEvent) -> void:
elif _selected_workbench != null: elif _selected_workbench != null:
_deselect_workbench() _deselect_workbench()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif _selected_stockpile != null:
_deselect_stockpile()
get_viewport().set_input_as_handled()
return return
if event.button_index != MOUSE_BUTTON_LEFT: if event.button_index != MOUSE_BUTTON_LEFT:
@ -139,10 +149,18 @@ func _handle_click(screen_pos: Vector2) -> void:
_select_workbench(hit_workbench) _select_workbench(hit_workbench)
return return
# Empty tile with no current pawn selection → also clear any workbench selection. # Click on a stockpile zone → open the filter editor panel.
var hit_stockpile = World.stockpile_at_tile(tile)
if hit_stockpile != null:
_select_stockpile(hit_stockpile)
return
# Empty tile with no current pawn selection → also clear any workbench / stockpile selection.
if _selected_pawn == null: if _selected_pawn == null:
if _selected_workbench != null: if _selected_workbench != null:
_deselect_workbench() _deselect_workbench()
if _selected_stockpile != null:
_deselect_stockpile()
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
@ -161,9 +179,11 @@ 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. # Mutual exclusion with workbench and stockpile: clear them before promoting pawn.
if _selected_workbench != null: if _selected_workbench != null:
_deselect_workbench() _deselect_workbench()
if _selected_stockpile != null:
_deselect_stockpile()
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()
@ -184,12 +204,14 @@ func _deselect() -> void:
## Select a workbench → opens the bill-editor panel via EventBus. ## Select a workbench → opens the bill-editor panel via EventBus.
## Mutually exclusive with pawn selection: clears _selected_pawn first. ## Mutually exclusive with pawn and stockpile selections.
func _select_workbench(wb) -> void: func _select_workbench(wb) -> void:
if _selected_workbench == wb: if _selected_workbench == wb:
return return
if _selected_pawn != null: if _selected_pawn != null:
_deselect() _deselect()
if _selected_stockpile != null:
_deselect_stockpile()
if _selected_workbench != null: if _selected_workbench != null:
EventBus.workbench_deselected.emit() EventBus.workbench_deselected.emit()
_selected_workbench = wb _selected_workbench = wb
@ -205,6 +227,30 @@ func _deselect_workbench() -> void:
EventBus.workbench_deselected.emit() EventBus.workbench_deselected.emit()
## Select a stockpile zone → opens the filter editor panel via EventBus.
## Mutually exclusive with pawn and workbench selections.
func _select_stockpile(zone) -> void:
if _selected_stockpile == zone:
return
if _selected_pawn != null:
_deselect()
if _selected_workbench != null:
_deselect_workbench()
if _selected_stockpile != null:
EventBus.stockpile_deselected.emit()
_selected_stockpile = zone
EventBus.stockpile_selected.emit(zone)
Audit.log("selection", "selected stockpile at %s" % str(zone.position))
func _deselect_stockpile() -> void:
if _selected_stockpile == null:
return
Audit.log("selection", "deselected stockpile")
_selected_stockpile = null
EventBus.stockpile_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.