diff --git a/audio/sfx/raid_warning.ogg b/audio/sfx/raid_warning.ogg new file mode 100644 index 0000000..b4afad0 Binary files /dev/null and b/audio/sfx/raid_warning.ogg differ diff --git a/audio/sfx/raid_warning.ogg.import b/audio/sfx/raid_warning.ogg.import new file mode 100644 index 0000000..03ec294 --- /dev/null +++ b/audio/sfx/raid_warning.ogg.import @@ -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 diff --git a/audio/sfx/rain_ambient.ogg b/audio/sfx/rain_ambient.ogg new file mode 100644 index 0000000..76e2142 Binary files /dev/null and b/audio/sfx/rain_ambient.ogg differ diff --git a/audio/sfx/rain_ambient.ogg.import b/audio/sfx/rain_ambient.ogg.import new file mode 100644 index 0000000..1a5f6f3 --- /dev/null +++ b/audio/sfx/rain_ambient.ogg.import @@ -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 diff --git a/audio/sfx/thunder_sting.ogg b/audio/sfx/thunder_sting.ogg new file mode 100644 index 0000000..0f8364d Binary files /dev/null and b/audio/sfx/thunder_sting.ogg differ diff --git a/audio/sfx/thunder_sting.ogg.import b/audio/sfx/thunder_sting.ogg.import new file mode 100644 index 0000000..d7bb62a --- /dev/null +++ b/audio/sfx/thunder_sting.ogg.import @@ -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 diff --git a/autoload/audio.gd b/autoload/audio.gd index d75946b..d6e1f61 100644 --- a/autoload/audio.gd +++ b/autoload/audio.gd @@ -1,5 +1,5 @@ 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 ## 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 / ## 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 ## level. App-pause mutes everything (NOTIFICATION_APPLICATION_PAUSED / focus ## 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", &"mine_tick": "res://audio/sfx/mine_tick.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 = { @@ -57,6 +72,14 @@ var master_linear: float = 1.0 var music_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 ─────────────────────────────────────────────────────────────── @@ -68,6 +91,15 @@ func _ready() -> void: _music_player.name = "MusicPlayer" 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. if EventBus.has_signal("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) if EventBus.has_signal("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. if Clock.has_signal("phase_changed"): @@ -86,7 +122,14 @@ func _ready() -> void: initial_phase = Clock.current_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 @@ -232,3 +275,54 @@ func _on_alert_added(_severity: StringName, _text: String, _focus_tile: Vector2i func _on_clock_phase_changed(new_phase: StringName) -> void: _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) diff --git a/autoload/event_bus.gd b/autoload/event_bus.gd index 81de2c8..a4d4e58 100644 --- a/autoload/event_bus.gd +++ b/autoload/event_bus.gd @@ -57,9 +57,12 @@ signal pawn_selected(pawn) ## Emitted when Selection pic signal pawn_deselected ## Emitted when Selection clears — closes PawnDetailPanel. signal workbench_selected(workbench) 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 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 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. # Phase 18 — Alert wiring (dangling signals surfaced to AlertsLog). diff --git a/autoload/game_state.gd b/autoload/game_state.gd index 6fbfacc..c5562a1 100644 --- a/autoload/game_state.gd +++ b/autoload/game_state.gd @@ -17,6 +17,7 @@ var settings: Dictionary = { "pause_on_wanderer": true, "pause_on_pawn_down": true, "pause_on_modal": true, + "show_day_summary": true, "audio_master": 1.0, "audio_music": 1.0, "audio_sfx": 1.0, diff --git a/autoload/strings.gd b/autoload/strings.gd index 4e2d599..fafca34 100644 --- a/autoload/strings.gd +++ b/autoload/strings.gd @@ -218,6 +218,49 @@ const TABLE: Dictionary = { &"ui.bill.no_bills_hint": "No bills. Add one to start crafting.", &"ui.workbench.current_bill": "Current", &"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.) + &"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", } diff --git a/scenes/ai/wolf_spawner.gd b/scenes/ai/wolf_spawner.gd index 1ff2a31..8d260ea 100644 --- a/scenes/ai/wolf_spawner.gd +++ b/scenes/ai/wolf_spawner.gd @@ -54,10 +54,13 @@ func _trigger_raid(current_tick: int) -> void: _last_raid_tick = current_tick var pack_size := randi_range(PACK_MIN, PACK_MAX) var spawn_tiles := _pick_spawn_tiles(pack_size) + var spawned: Array = [] for spawn_tile in spawn_tiles: var w: Wolf = WOLF_SCENE.instantiate() get_parent().add_child(w) 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]) @@ -68,10 +71,13 @@ func _trigger_raid(current_tick: int) -> void: ## the night-attack cooldown, so organic raids can still follow. func _on_request_wolf_spawn(count: int) -> void: var spawn_tiles := _pick_spawn_tiles(count) + var spawned: Array = [] for spawn_tile in spawn_tiles: var w: Wolf = WOLF_SCENE.instantiate() get_parent().add_child(w) 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]) diff --git a/scenes/main/main.gd b/scenes/main/main.gd index b3e6686..fa6d2d7 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -20,6 +20,7 @@ const RESUME_TOAST_SCRIPT: Script = preload("res://scenes/ui/resume_toa # Phase 17 — PawnDetailPanel (layer 18) and SettingsMenu (layer 26). 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 STOCKPILE_PANEL_SCRIPT: Script = preload("res://scenes/ui/stockpile_panel.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 @@ -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). 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") +# 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: @@ -99,6 +102,13 @@ func _ready() -> void: workbench_panel.name = "WorkbenchPanel" 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() settings_menu.set_script(SETTINGS_MENU_SCRIPT) settings_menu.name = "SettingsMenu" @@ -169,6 +179,14 @@ func _ready() -> void: 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. # 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 diff --git a/scenes/ui/day_summary_card.gd b/scenes/ui/day_summary_card.gd new file mode 100644 index 0000000..42efaf6 --- /dev/null +++ b/scenes/ui/day_summary_card.gd @@ -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 diff --git a/scenes/ui/day_summary_card.gd.uid b/scenes/ui/day_summary_card.gd.uid new file mode 100644 index 0000000..955c344 --- /dev/null +++ b/scenes/ui/day_summary_card.gd.uid @@ -0,0 +1 @@ +uid://7gvadq4jib4v diff --git a/scenes/ui/settings_menu.gd b/scenes/ui/settings_menu.gd index 318c501..6590006 100644 --- a/scenes/ui/settings_menu.gd +++ b/scenes/ui/settings_menu.gd @@ -13,10 +13,11 @@ var _dim: ColorRect = null var _panel: PanelContainer = null # Auto-pause checkboxes. -var _cb_threat: CheckBox = null -var _cb_wanderer: CheckBox = null -var _cb_pawn_down: CheckBox = null -var _cb_modal: CheckBox = null +var _cb_threat: CheckBox = null +var _cb_wanderer: CheckBox = null +var _cb_pawn_down: CheckBox = null +var _cb_modal: CheckBox = null +var _cb_day_summary: CheckBox = null # Audio sliders. var _sl_master: HSlider = null @@ -105,10 +106,11 @@ func _build_ui() -> void: ap_hdr.text = Strings.t(&"ui.settings.auto_pause") vbox.add_child(ap_hdr) - _cb_threat = _make_checkbox(Strings.t(&"ui.settings.pause_threat"), 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_modal = _make_checkbox(Strings.t(&"ui.settings.pause_modal"), vbox) + _cb_threat = _make_checkbox(Strings.t(&"ui.settings.pause_threat"), 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_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) @@ -196,10 +198,11 @@ func _add_separator(parent: VBoxContainer) -> void: func _load_from_game_state() -> void: var s: Dictionary = GameState.settings - _cb_threat.button_pressed = bool(s.get("pause_on_threat", 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_modal.button_pressed = bool(s.get("pause_on_modal", true)) + _cb_threat.button_pressed = bool(s.get("pause_on_threat", 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_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_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_pawn_down": _cb_pawn_down.button_pressed, "pause_on_modal": _cb_modal.button_pressed, + "show_day_summary": _cb_day_summary.button_pressed, "audio_master": _sl_master.value, "audio_music": _sl_music.value, "audio_sfx": _sl_sfx.value, diff --git a/scenes/ui/stockpile_panel.gd b/scenes/ui/stockpile_panel.gd new file mode 100644 index 0000000..54b13f2 --- /dev/null +++ b/scenes/ui/stockpile_panel.gd @@ -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." 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 diff --git a/scenes/ui/stockpile_panel.gd.uid b/scenes/ui/stockpile_panel.gd.uid new file mode 100644 index 0000000..3ba7b05 --- /dev/null +++ b/scenes/ui/stockpile_panel.gd.uid @@ -0,0 +1 @@ +uid://bqvdr1ww4bvpf diff --git a/scenes/world/selection.gd b/scenes/world/selection.gd index dbd0aca..41cf67f 100644 --- a/scenes/world/selection.gd +++ b/scenes/world/selection.gd @@ -17,6 +17,8 @@ var _selected_pawn: Pawn = null ## Currently selected workbench, or null. Mutually exclusive with _selected_pawn — ## selecting one clears the other (see _select / _select_workbench). 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 # 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() Audit.log("selection", "escape: deselected workbench") 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 ─────────────────────────────────── if not (event is InputEventMouseButton): 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: # Designation cancellation is handled by Designation._input; if we see # 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: _deselect_workbench() get_viewport().set_input_as_handled() + elif _selected_stockpile != null: + _deselect_stockpile() + get_viewport().set_input_as_handled() return if event.button_index != MOUSE_BUTTON_LEFT: @@ -139,10 +149,18 @@ func _handle_click(screen_pos: Vector2) -> void: _select_workbench(hit_workbench) 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_workbench != null: _deselect_workbench() + if _selected_stockpile != null: + _deselect_stockpile() return # 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: if _selected_pawn == pawn: 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: _deselect_workbench() + if _selected_stockpile != null: + _deselect_stockpile() if _selected_pawn != null: _selected_pawn.set_selected(false) EventBus.pawn_deselected.emit() @@ -184,12 +204,14 @@ func _deselect() -> void: ## 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: if _selected_workbench == wb: return if _selected_pawn != null: _deselect() + if _selected_stockpile != null: + _deselect_stockpile() if _selected_workbench != null: EventBus.workbench_deselected.emit() _selected_workbench = wb @@ -205,6 +227,30 @@ func _deselect_workbench() -> void: 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. ## Wraps around. If no pawn currently selected, picks World.pawns[0]. ## Pans the camera to the newly selected pawn's tile.