feat(ui): ✨ add lens switcher UI and event system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
52207e6ab9
commit
76be92394a
9 changed files with 544 additions and 11 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
74
src/game/engine/scenes/tests/lens_switcher_proof.gd
Normal file
74
src/game/engine/scenes/tests/lens_switcher_proof.gd
Normal file
|
|
@ -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)
|
||||
6
src/game/engine/scenes/tests/lens_switcher_proof.tscn
Normal file
6
src/game/engine/scenes/tests/lens_switcher_proof.tscn
Normal file
|
|
@ -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")
|
||||
306
src/game/engine/scenes/ui/lens_switcher.gd
Normal file
306
src/game/engine/scenes/ui/lens_switcher.gd
Normal file
|
|
@ -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)
|
||||
6
src/game/engine/scenes/ui/lens_switcher.tscn
Normal file
6
src/game/engine/scenes/ui/lens_switcher.tscn
Normal file
|
|
@ -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")
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
102
src/game/engine/tests/unit/test_lens_switcher.gd
Normal file
102
src/game/engine/tests/unit/test_lens_switcher.gd
Normal file
|
|
@ -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)
|
||||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue