From 76be92394a49ec4f31fef7a9e63a257929747108 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 14 May 2026 11:25:14 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E2=9C=A8=20add=20lens=20switcher?= =?UTF-8?q?=20UI=20and=20event=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../objectives/p2-60-weather-lens-godot-ui.md | 23 +- src/game/engine/scenes/hud/world_map_hud.gd | 22 ++ .../scenes/tests/lens_switcher_proof.gd | 74 +++++ .../scenes/tests/lens_switcher_proof.tscn | 6 + src/game/engine/scenes/ui/lens_switcher.gd | 306 ++++++++++++++++++ src/game/engine/scenes/ui/lens_switcher.tscn | 6 + src/game/engine/src/autoloads/event_bus.gd | 6 + .../engine/tests/unit/test_lens_switcher.gd | 102 ++++++ src/game/project.godot | 10 + 9 files changed, 544 insertions(+), 11 deletions(-) create mode 100644 src/game/engine/scenes/tests/lens_switcher_proof.gd create mode 100644 src/game/engine/scenes/tests/lens_switcher_proof.tscn create mode 100644 src/game/engine/scenes/ui/lens_switcher.gd create mode 100644 src/game/engine/scenes/ui/lens_switcher.tscn create mode 100644 src/game/engine/tests/unit/test_lens_switcher.gd diff --git a/.project/objectives/p2-60-weather-lens-godot-ui.md b/.project/objectives/p2-60-weather-lens-godot-ui.md index 34561d0d..4ee22394 100644 --- a/.project/objectives/p2-60-weather-lens-godot-ui.md +++ b/.project/objectives/p2-60-weather-lens-godot-ui.md @@ -2,14 +2,15 @@ id: p2-60 title: "Weather / observation lens switcher in the Godot HUD" priority: p2 -status: stub +status: partial scope: game1 category: ui -owner: unassigned +owner: godot-ui created: 2026-05-03 -updated_at: 2026-05-03 +updated_at: 2026-05-14 blocked_by: [] -follow_ups: [] +follow_ups: + - "Capture one screenshot per lens via tools/screenshot.sh against res://engine/scenes/tests/lens_switcher_proof.tscn — phase-gate evidence for bullet 6." --- ## Context @@ -20,13 +21,13 @@ This objective ships the Godot UI side: a lens switcher widget + the renderer ho ## Acceptance -- ❌ HUD scene `src/game/engine/scenes/ui/lens_switcher.tscn` (or equivalent) exposes a horizontal lens picker with one button per supported lens. -- ❌ Lens enum sourced from JSON manifest `public/resources/ui/lenses.json` — no hardcoded GDScript list. -- ❌ Renderer reads the active lens via the existing tile-meta bridge and tints overlays per the lens's colormap entry. -- ❌ Hotkey cycling (e.g., `Tab`/`Shift+Tab`) reuses the `p2-03` hotkey-cheat-sheet registry. -- ❌ EventBus signal `lens_changed(lens_id: String)` emitted on switch; renderer listens. -- ❌ Proof scene under `src/game/engine/scenes/tests/` captures one screenshot per lens; phase-gate accepted via `tools/screenshot.sh`. -- ❌ `--headless` GUT test asserts the lens registry parses and the EventBus signal fires on simulated switch. +- ✓ HUD scene `src/game/engine/scenes/ui/lens_switcher.tscn` (+ `lens_switcher.gd`) exposes a horizontal lens picker with one button per supported lens, grouped by `lens_categories.json` order. Mounted by `world_map_hud.gd::_build_lens_switcher()`. +- ✓ Lens enum sourced from JSON manifests under `public/resources/lenses/*.json` (excluding `lenses.schema.json` and `lens_categories.json`). No hardcoded GDScript list — `lens_switcher.gd::_load_lenses()` walks the directory and parses each file. +- ✓ Renderer reads the active lens through the existing `EventBus.map_overlay_changed(mode)` bridge (compat-emitted alongside the new `lens_changed`). `hex_overlay_renderer.gd` already consumes the matching mode strings (`temperature`, `moisture`, `pressure`, `humidity`, …). Additive only — no renderer refactoring. +- ✓ Hotkey cycling: actions `ui_map_lens_next` (Tab) and `ui_map_lens_prev` (Shift+Tab) registered in `project.godot`. The `ui_map_` prefix is auto-picked up by the `p2-03` `hotkey_sheet.gd` bucket rule, so the cheat sheet surfaces the new keys without further wiring. `lens_switcher.gd::_unhandled_input()` handles them. +- ✓ EventBus signal `lens_changed(lens_id: String)` added in `event_bus.gd`; widget emits it (and the legacy `map_overlay_changed`) on every switch. +- ❌ Proof scene `src/game/engine/scenes/tests/lens_switcher_proof.tscn` (+ `.gd`) exists and is wired to capture one screenshot per loaded lens, but the screenshot run itself (via `tools/screenshot.sh` on the headless host) has not been performed in this session. Phase-gate evidence pending. +- ✓ `--headless` GUT test `src/game/engine/tests/unit/test_lens_switcher.gd` asserts the lens registry parses (at least one lens, `temperature` present) and that both `EventBus.lens_changed` and the compat `EventBus.map_overlay_changed` fire on `set_active_lens` (including the empty-string clear case). ## Source-of-truth rails diff --git a/src/game/engine/scenes/hud/world_map_hud.gd b/src/game/engine/scenes/hud/world_map_hud.gd index 98681513..2bd69821 100644 --- a/src/game/engine/scenes/hud/world_map_hud.gd +++ b/src/game/engine/scenes/hud/world_map_hud.gd @@ -55,14 +55,36 @@ const _RALLY_COMMANDS: Array[String] = [ ] +## p2-60: observation-lens switcher widget instance. +var _lens_switcher: PanelContainer = null + +const LensSwitcherScene: PackedScene = preload( + "res://engine/scenes/ui/lens_switcher.tscn" +) + + func _ready() -> void: _build_top_bar() + _build_lens_switcher() _build_prologue_banner() _build_notification_box() _build_patrol_banner() _build_rally_command_picker() +func _build_lens_switcher() -> void: + ## p2-60: instantiate the JSON-driven lens picker and mount it below the + ## top bar. The widget owns its own JSON loading, hotkey handling, and + ## EventBus emissions; the HUD only owns its position. + _lens_switcher = LensSwitcherScene.instantiate() as PanelContainer + _lens_switcher.name = "LensSwitcher" + _lens_switcher.anchor_left = 0.0 + _lens_switcher.anchor_top = 0.0 + _lens_switcher.offset_left = 8.0 + _lens_switcher.offset_top = 44.0 + add_child(_lens_switcher) + + func _build_top_bar() -> void: var top_bar: HBoxContainer = HBoxContainer.new() top_bar.name = "TopBar" diff --git a/src/game/engine/scenes/tests/lens_switcher_proof.gd b/src/game/engine/scenes/tests/lens_switcher_proof.gd new file mode 100644 index 00000000..9ebdc691 --- /dev/null +++ b/src/game/engine/scenes/tests/lens_switcher_proof.gd @@ -0,0 +1,74 @@ +extends Node +## p2-60 proof scene: instantiate the lens switcher widget, cycle through every +## loaded lens, and capture one screenshot per lens. Used by `tools/screenshot.sh` +## to produce phase-gate evidence — one PNG per observation lens proves the +## JSON-driven button list renders and the active-button highlight tracks +## the `set_active_lens` call. + +const LensSwitcherScene: PackedScene = preload( + "res://engine/scenes/ui/lens_switcher.tscn" +) + +var _screenshot_prefix: String = "lens" +var _switcher: PanelContainer = null + + +func _ready() -> void: + RenderingServer.set_default_clear_color(Color(0.08, 0.07, 0.12)) + get_viewport().size = Vector2i(1920, 360) + DisplayServer.window_set_size(Vector2i(1920, 360)) + + var env_name: String = OS.get_environment("SCREENSHOT_NAME") + if not env_name.is_empty(): + _screenshot_prefix = env_name + + ThemeVocabulary.load_vocabulary("age-of-dwarves") + await get_tree().process_frame + + var bg: ColorRect = ColorRect.new() + bg.color = Color(0.04, 0.03, 0.05, 1.0) + bg.anchor_right = 1.0 + bg.anchor_bottom = 1.0 + add_child(bg) + + _switcher = LensSwitcherScene.instantiate() as PanelContainer + _switcher.anchor_left = 0.0 + _switcher.anchor_top = 0.0 + _switcher.offset_left = 16.0 + _switcher.offset_top = 16.0 + add_child(_switcher) + + for _i: int in range(4): + await get_tree().process_frame + + # Default state — no active lens. + await _shoot("none") + + # One screenshot per lens, in the widget's display order. + var ids: Array[String] = _switcher.call("get_lens_ids") as Array[String] + print("Lens switcher loaded %d lenses" % ids.size()) + for id: String in ids: + _switcher.call("set_active_lens", id) + for _j: int in range(2): + await get_tree().process_frame + await _shoot(id) + + get_tree().quit() + + +func _shoot(label: String) -> void: + DirAccess.make_dir_recursive_absolute( + ProjectSettings.globalize_path("user://screenshots") + ) + var image: Image = get_viewport().get_texture().get_image() + if image == null: + return + var timestamp: String = ( + Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_") + ) + var rel_path: String = ( + "user://screenshots/%s_%s_%s.png" % [_screenshot_prefix, label, timestamp] + ) + var abs_path: String = ProjectSettings.globalize_path(rel_path) + if image.save_png(abs_path) == OK: + print("SCREENSHOT_PATH:%s" % abs_path) diff --git a/src/game/engine/scenes/tests/lens_switcher_proof.tscn b/src/game/engine/scenes/tests/lens_switcher_proof.tscn new file mode 100644 index 00000000..234b9f14 --- /dev/null +++ b/src/game/engine/scenes/tests/lens_switcher_proof.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://b7lns60proof1"] + +[ext_resource type="Script" path="res://engine/scenes/tests/lens_switcher_proof.gd" id="1"] + +[node name="LensSwitcherProof" type="Node"] +script = ExtResource("1") diff --git a/src/game/engine/scenes/ui/lens_switcher.gd b/src/game/engine/scenes/ui/lens_switcher.gd new file mode 100644 index 00000000..822773dd --- /dev/null +++ b/src/game/engine/scenes/ui/lens_switcher.gd @@ -0,0 +1,306 @@ +extends PanelContainer +## p2-60 — Observation-lens switcher widget. +## +## A JSON-driven horizontal lens picker. Reads lens manifests from +## `public/resources/lenses/*.json` (excluding the schema + categories index) +## and renders one button per lens, grouped by category in the order given by +## `lens_categories.json`. +## +## Emits `EventBus.lens_changed(lens_id)` when the player selects a lens, and +## also forwards as `EventBus.map_overlay_changed(mode)` so the existing +## `hex_overlay_renderer` keeps working without changes. Selecting the active +## lens toggles it off (empty `lens_id`). +## +## Hotkeys (registered in `project.godot` as `ui_map_lens_next` / +## `ui_map_lens_prev`) cycle forward / backward through the loaded lens list. +## +## Presentation only — no game logic. Reads lens metadata, fires signals. + +const LENS_DIR: String = "res://public/resources/lenses" +const NON_LENS_FILES: Array[String] = [ + "lenses.schema.json", + "lens_categories.json", + "registry.md", +] + +const ACTION_NEXT: String = "ui_map_lens_next" +const ACTION_PREV: String = "ui_map_lens_prev" + +## Loaded lens entries in display order. Each entry is a Dictionary with at +## least the keys: `id`, `name`, `category`. +var _lenses: Array[Dictionary] = [] + +## Ordered category ids from `lens_categories.json`. Lenses without a known +## category fall through to a synthetic "other" bucket at the tail. +var _category_order: Array[String] = [] + +## Currently active lens id. Empty string means "no lens / default view". +var _active_lens_id: String = "" + +## Map from lens id (String) to its Button — used to toggle the active visual +## state. Dictionary is the only structural option here; values are Buttons. +var _buttons_by_id: Dictionary = {} + +## Cached id order for hotkey cycling (matches `_lenses` order). +var _id_order: Array[String] = [] + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + _apply_panel_style() + _load_category_order() + _load_lenses() + _build_buttons() + + +# -- Public API -------------------------------------------------------------- + +## Returns the currently active lens id, or "" if none is active. +func get_active_lens_id() -> String: + return _active_lens_id + + +## Sets the active lens by id. Empty string clears the lens. Emits both +## `lens_changed` and `map_overlay_changed`. Public so proof scenes and tests +## can drive the widget without simulating button presses. +func set_active_lens(lens_id: String) -> void: + _active_lens_id = lens_id + EventBus.lens_changed.emit(_active_lens_id) + var overlay_mode: String = _active_lens_id if _active_lens_id != "" else "none" + EventBus.map_overlay_changed.emit(overlay_mode) + _update_button_states() + + +## Returns the list of loaded lens ids in display order. Exposed for tests. +func get_lens_ids() -> Array[String]: + return _id_order.duplicate() + + +# -- JSON loading ------------------------------------------------------------ + +func _load_category_order() -> void: + var path: String = LENS_DIR + "/lens_categories.json" + var data: Dictionary = _load_json_object(path) + if data.is_empty(): + return + var order: Array = data.get("category_order", []) as Array + for v: String in _to_string_array(order): + _category_order.append(v) + + +func _load_lenses() -> void: + var dir: DirAccess = DirAccess.open(LENS_DIR) + if dir == null: + push_error( + "LensSwitcher: cannot open %s — %s" + % [LENS_DIR, DirAccess.get_open_error()] + ) + return + + var file_names: Array[String] = [] + dir.list_dir_begin() + var name: String = dir.get_next() + while name != "": + if name.ends_with(".json") and name not in NON_LENS_FILES: + file_names.append(name) + name = dir.get_next() + dir.list_dir_end() + file_names.sort() + + for file_name: String in file_names: + var path: String = LENS_DIR + "/" + file_name + # Try object form first (one lens per file), fall back to array form. + var as_obj: Dictionary = _load_json_object(path) + if not as_obj.is_empty(): + _append_lens(as_obj) + continue + var as_arr: Array = _load_json_array(path) + for entry: Dictionary in _to_dict_array(as_arr): + _append_lens(entry) + + # Group by category in canonical order; preserve in-category insertion order. + _lenses = _sort_by_category(_lenses) + _id_order.clear() + for lens: Dictionary in _lenses: + _id_order.append(str(lens.get("id", ""))) + + +func _append_lens(lens: Dictionary) -> void: + var id: String = str(lens.get("id", "")) + if id == "": + return + _lenses.append(lens) + + +func _sort_by_category(lenses: Array[Dictionary]) -> Array[Dictionary]: + var by_cat: Dictionary = {} + for lens: Dictionary in lenses: + var cat: String = str(lens.get("category", "other")) + if not by_cat.has(cat): + by_cat[cat] = ([] as Array[Dictionary]) + (by_cat[cat] as Array[Dictionary]).append(lens) + + var out: Array[Dictionary] = [] + for cat: String in _category_order: + if by_cat.has(cat): + for lens: Dictionary in by_cat[cat] as Array[Dictionary]: + out.append(lens) + by_cat.erase(cat) + # Any unknown-category lenses go to the tail, sorted by category name for + # determinism. + var leftover: Array = by_cat.keys() + leftover.sort() + for cat_key: String in _to_string_array(leftover): + for lens: Dictionary in by_cat[cat_key] as Array[Dictionary]: + out.append(lens) + return out + + +func _load_json_object(path: String) -> Dictionary: + var file: FileAccess = FileAccess.open(path, FileAccess.READ) + if file == null: + return {} + var text: String = file.get_as_text() + file.close() + var json: JSON = JSON.new() + if json.parse(text) != OK: + push_error( + "LensSwitcher: JSON parse error in %s — %s" + % [path, json.get_error_message()] + ) + return {} + if json.data is Dictionary: + return json.data as Dictionary + return {} + + +func _load_json_array(path: String) -> Array: + var file: FileAccess = FileAccess.open(path, FileAccess.READ) + if file == null: + return [] + var text: String = file.get_as_text() + file.close() + var json: JSON = JSON.new() + if json.parse(text) != OK: + return [] + if json.data is Array: + return json.data as Array + return [] + + +func _to_string_array(values: Array) -> Array[String]: + var out: Array[String] = [] + for v: Variant in values: + out.append(str(v)) + return out + + +func _to_dict_array(values: Array) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + for v: Variant in values: + if v is Dictionary: + out.append(v as Dictionary) + return out + + +# -- Button construction ----------------------------------------------------- + +func _build_buttons() -> void: + var row: HBoxContainer = HBoxContainer.new() + row.name = "LensRow" + row.add_theme_constant_override("separation", 4) + add_child(row) + + # "None" button at the head — clears the active lens. + var btn_none: Button = Button.new() + btn_none.name = "BtnNone" + btn_none.text = "—" + btn_none.tooltip_text = "No lens (default biome view)" + btn_none.pressed.connect(_on_lens_pressed.bind("")) + row.add_child(btn_none) + _buttons_by_id[""] = btn_none + + var current_cat: String = "" + for lens: Dictionary in _lenses: + var cat: String = str(lens.get("category", "other")) + if cat != current_cat: + current_cat = cat + var sep: VSeparator = VSeparator.new() + row.add_child(sep) + var id: String = str(lens.get("id", "")) + var btn: Button = Button.new() + btn.name = "Btn_" + id + btn.text = str(lens.get("name", id)) + btn.tooltip_text = str(lens.get("description", id)) + btn.pressed.connect(_on_lens_pressed.bind(id)) + row.add_child(btn) + _buttons_by_id[id] = btn + + _update_button_states() + + +func _on_lens_pressed(id: String) -> void: + # Toggle: pressing the active lens turns it off. + if id != "" and id == _active_lens_id: + set_active_lens("") + else: + set_active_lens(id) + + +func _update_button_states() -> void: + for key: String in _buttons_by_id.keys(): + var btn: Button = _buttons_by_id[key] as Button + var active: bool = (key == _active_lens_id) + if active: + btn.add_theme_color_override("font_color", Color(0.15, 0.9, 0.4, 1.0)) + btn.add_theme_color_override( + "font_pressed_color", Color(0.15, 0.9, 0.4, 1.0) + ) + else: + btn.remove_theme_color_override("font_color") + btn.remove_theme_color_override("font_pressed_color") + + +# -- Hotkey cycling ---------------------------------------------------------- + +func _unhandled_input(event: InputEvent) -> void: + if _id_order.is_empty(): + return + if InputMap.has_action(ACTION_NEXT) and event.is_action_pressed(ACTION_NEXT): + _cycle(1) + get_viewport().set_input_as_handled() + elif InputMap.has_action(ACTION_PREV) and event.is_action_pressed(ACTION_PREV): + _cycle(-1) + get_viewport().set_input_as_handled() + + +func _cycle(direction: int) -> void: + if _active_lens_id == "": + var idx: int = 0 if direction > 0 else (_id_order.size() - 1) + set_active_lens(_id_order[idx]) + return + var cur: int = _id_order.find(_active_lens_id) + if cur < 0: + set_active_lens(_id_order[0]) + return + var next_idx: int = cur + direction + # Wrap with a "none" step at both ends so the player can clear by cycling. + if next_idx < 0 or next_idx >= _id_order.size(): + set_active_lens("") + else: + set_active_lens(_id_order[next_idx]) + + +# -- Style ------------------------------------------------------------------- + +func _apply_panel_style() -> void: + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = Color(0.05, 0.05, 0.08, 0.82) + style.border_color = Color(0.25, 0.22, 0.12, 0.85) + style.set_border_width_all(1) + style.set_corner_radius_all(3) + style.content_margin_left = 6 + style.content_margin_right = 6 + style.content_margin_top = 4 + style.content_margin_bottom = 4 + add_theme_stylebox_override("panel", style) diff --git a/src/game/engine/scenes/ui/lens_switcher.tscn b/src/game/engine/scenes/ui/lens_switcher.tscn new file mode 100644 index 00000000..fc82b7c9 --- /dev/null +++ b/src/game/engine/scenes/ui/lens_switcher.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://b7lns60sw1tch0"] + +[ext_resource type="Script" path="res://engine/scenes/ui/lens_switcher.gd" id="1"] + +[node name="LensSwitcher" type="PanelContainer"] +script = ExtResource("1") diff --git a/src/game/engine/src/autoloads/event_bus.gd b/src/game/engine/src/autoloads/event_bus.gd index bd1d7613..04437ada 100644 --- a/src/game/engine/src/autoloads/event_bus.gd +++ b/src/game/engine/src/autoloads/event_bus.gd @@ -209,6 +209,12 @@ signal climate_phase_changed(new_label: String) ## mode: "none" | "temperature" | "moisture" | "wind_heatmap" | "weather" ## | "land_value" | "water" | "elevation" signal map_overlay_changed(mode: String) +## p2-60: emitted by the lens-switcher widget when the player chooses a different +## observation lens. `lens_id` is the lens manifest id from +## `public/resources/lenses/*.json` (e.g. "temperature", "moisture"). The empty +## string `""` means "no lens / default biome view". Renderers and overlay +## listeners may subscribe to this signal in addition to `map_overlay_changed`. +signal lens_changed(lens_id: String) ## Refresh weather footprint highlights; effects from weather.get_active_effects(). signal weather_effects_updated(effects: Array) ## A coastal tile is at flood risk (global_avg_temp > 0.55). diff --git a/src/game/engine/tests/unit/test_lens_switcher.gd b/src/game/engine/tests/unit/test_lens_switcher.gd new file mode 100644 index 00000000..3c7892fe --- /dev/null +++ b/src/game/engine/tests/unit/test_lens_switcher.gd @@ -0,0 +1,102 @@ +extends GutTest +## p2-60 — Observation-lens switcher. +## Verifies: +## 1. The widget loads at least one lens from `public/resources/lenses/*.json`. +## 2. `EventBus.lens_changed(lens_id)` fires when `set_active_lens` is called. +## 3. Toggling the active lens off emits the empty-string id. +## 4. The compatibility `EventBus.map_overlay_changed(mode)` also fires so the +## existing `hex_overlay_renderer` keeps receiving updates. +## +## Headless-safe: instantiates the widget through `add_child` but never relies +## on rendering, fonts, or input events. JSON loading uses `res://` paths which +## resolve against the project filesystem, not the display server. + +const LensSwitcherScene: PackedScene = preload( + "res://engine/scenes/ui/lens_switcher.tscn" +) + +var _widget: PanelContainer = null + +# Captured signal payloads. Strings because the autoload signal types String. +var _lens_changed_payloads: Array[String] = [] +var _overlay_changed_payloads: Array[String] = [] + + +func before_each() -> void: + _lens_changed_payloads = [] + _overlay_changed_payloads = [] + EventBus.lens_changed.connect(_on_lens_changed) + EventBus.map_overlay_changed.connect(_on_overlay_changed) + + _widget = LensSwitcherScene.instantiate() as PanelContainer + add_child(_widget) + + +func after_each() -> void: + if EventBus.lens_changed.is_connected(_on_lens_changed): + EventBus.lens_changed.disconnect(_on_lens_changed) + if EventBus.map_overlay_changed.is_connected(_on_overlay_changed): + EventBus.map_overlay_changed.disconnect(_on_overlay_changed) + if _widget != null: + _widget.queue_free() + _widget = null + + +# -- Tests ------------------------------------------------------------------- + +func test_lenses_load_from_json() -> void: + var ids: Array[String] = _widget.call("get_lens_ids") as Array[String] + assert_gt( + ids.size(), 0, + "lens_switcher must load at least one lens from public/resources/lenses/" + ) + # Sanity: temperature ships in the manifest set and should always parse. + assert_true( + ids.has("temperature"), + "temperature lens (public/resources/lenses/temperature.json) must load" + ) + + +func test_set_active_lens_emits_lens_changed() -> void: + _widget.call("set_active_lens", "temperature") + assert_eq(_lens_changed_payloads.size(), 1, "lens_changed must fire once") + assert_eq( + _lens_changed_payloads[0], "temperature", + "lens_changed payload must echo the chosen lens id" + ) + + +func test_set_active_lens_emits_map_overlay_changed_compat() -> void: + _widget.call("set_active_lens", "moisture") + assert_eq( + _overlay_changed_payloads.size(), 1, + "compat map_overlay_changed must fire alongside lens_changed" + ) + assert_eq( + _overlay_changed_payloads[0], "moisture", + "non-empty lens id forwards as the overlay mode string" + ) + + +func test_clearing_lens_emits_empty_string() -> void: + _widget.call("set_active_lens", "temperature") + _widget.call("set_active_lens", "") + assert_eq(_lens_changed_payloads.size(), 2, "two switches → two signals") + assert_eq( + _lens_changed_payloads[1], "", + "clearing the lens must emit the empty-string id" + ) + assert_eq( + _overlay_changed_payloads[1], "none", + "clearing the lens forwards as the legacy 'none' overlay mode" + ) + + +# -- Signal sinks ------------------------------------------------------------ + +func _on_lens_changed(lens_id: String) -> void: + _lens_changed_payloads.append(lens_id) + + +func _on_overlay_changed(mode: String) -> void: + _overlay_changed_payloads.append(mode) diff --git a/src/game/project.godot b/src/game/project.godot index 217de07c..e97ceb4e 100644 --- a/src/game/project.godot +++ b/src/game/project.godot @@ -126,6 +126,16 @@ ui_map_patrol={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":80,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +ui_map_lens_next={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +ui_map_lens_prev={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [rendering]