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:
parent
88e3fa9364
commit
bba1ce4334
18 changed files with 935 additions and 18 deletions
BIN
audio/sfx/raid_warning.ogg
Normal file
BIN
audio/sfx/raid_warning.ogg
Normal file
Binary file not shown.
19
audio/sfx/raid_warning.ogg.import
Normal file
19
audio/sfx/raid_warning.ogg.import
Normal 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
BIN
audio/sfx/rain_ambient.ogg
Normal file
Binary file not shown.
19
audio/sfx/rain_ambient.ogg.import
Normal file
19
audio/sfx/rain_ambient.ogg.import
Normal 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
BIN
audio/sfx/thunder_sting.ogg
Normal file
Binary file not shown.
19
audio/sfx/thunder_sting.ogg.import
Normal file
19
audio/sfx/thunder_sting.ogg.import
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.<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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
268
scenes/ui/day_summary_card.gd
Normal file
268
scenes/ui/day_summary_card.gd
Normal 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
|
||||
1
scenes/ui/day_summary_card.gd.uid
Normal file
1
scenes/ui/day_summary_card.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://7gvadq4jib4v
|
||||
|
|
@ -17,6 +17,7 @@ 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
|
||||
|
|
@ -109,6 +110,7 @@ func _build_ui() -> void:
|
|||
_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)
|
||||
|
||||
|
|
@ -200,6 +202,7 @@ func _load_from_game_state() -> void:
|
|||
_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,
|
||||
|
|
|
|||
375
scenes/ui/stockpile_panel.gd
Normal file
375
scenes/ui/stockpile_panel.gd
Normal 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
|
||||
1
scenes/ui/stockpile_panel.gd.uid
Normal file
1
scenes/ui/stockpile_panel.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bqvdr1ww4bvpf
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue