rimlike/scenes/ui/day_summary_card.gd
megaproxy bba1ce4334 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>
2026-05-16 17:20:40 +01:00

268 lines
10 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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