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