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
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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue