From df29d26d1e69c0869af69e637f0b9e2a8a5ccef6 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 19 Apr 2026 13:16:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20introduce=20ImplantApp=20pattern=20?= =?UTF-8?q?=E2=80=94=20base=20class,=20registry,=20atlas=20+=20economics?= =?UTF-8?q?=20refactor=20(#844,=20#824,=20#836)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the ImplantApp faux-mobile-OS architecture (D-191): Foundation: - ImplantAppManifest (Resource): @export vars for app_path, scene_path, default_key, default_mode, preserves_state - ImplantNavStack (Node): push/pop/replace/reset_to_default, synchronous screen_changed signal - ImplantApp (Control base class): absorbs HudGroups boilerplate; on_install/on_open/on_close/on_insert_deactivated lifecycle hooks - ImplantRegistry (autoload): lazy-scans res://ui/implant/apps/*/app.tres; avoids autoload parse-order trap Atlas app (replaces atlas_panel + atlas_reach_map + atlas_system_map + atlas_planet_map + root viewer files): - apps/atlas/app.tres — manifest (implant/map, FULLSCREEN, key=M) - apps/atlas/atlas_app.gd — coordinator; 4-screen nav via ImplantNavStack - apps/atlas/screens/{reach,system,planet,regional}_screen.gd — enter/leave interface - apps/atlas/{atlas_viewer,atlas_marker_overlay,atlas_overlay_bar}.gd — moved from root Economics app (replaces economics_panel): - apps/economics/app.tres — manifest (implant/economics, INSERT, key=N) - apps/economics/economics_app.gd — thin shell delegating to OverviewScreen - apps/economics/screens/overview_screen.gd — full panel logic, enter/leave interface Wiring: - project.godot: add ImplantRegistry autoload after HudGroups - main.gd: registry-driven key toggle loop; rename atlas_panel→atlas_app, economics_panel→economics_app - hud.tscn: swap to new scene paths; remove legacy StarMap node - snapshot_consumers.gd: on_insert_deactivated() uniformly; rename vars - hud_groups.gd: remove stale starchart compat comment Co-Authored-By: Claude Sonnet 4.6 --- client/project.godot | 1 + client/scripts/autoloads/hud_groups.gd | 1 - client/scripts/main.gd | 56 ++-- client/scripts/snapshot_consumers.gd | 29 +- client/ui/hud.tscn | 22 +- client/ui/implant/apps/atlas/app.tres | 13 + client/ui/implant/apps/atlas/atlas_app.gd | 204 ++++++++++++ client/ui/implant/apps/atlas/atlas_app.tscn | 17 + .../{ => apps/atlas}/atlas_marker_overlay.gd | 0 .../{ => apps/atlas}/atlas_overlay_bar.gd | 3 +- .../implant/{ => apps/atlas}/atlas_viewer.gd | 12 +- .../atlas/screens/planet_screen.gd} | 66 ++-- .../atlas/screens/reach_screen.gd} | 18 +- .../apps/atlas/screens/regional_screen.gd | 37 +++ .../atlas/screens/system_screen.gd} | 30 +- client/ui/implant/apps/economics/app.tres | 13 + .../implant/apps/economics/economics_app.gd | 80 +++++ .../implant/apps/economics/economics_app.tscn | 17 + .../economics/screens/overview_screen.gd} | 122 ++----- client/ui/implant/atlas_panel.gd | 302 ------------------ client/ui/implant/atlas_panel.tscn | 17 - client/ui/implant/economics_panel.tscn | 18 -- client/ui/implant/implant_app.gd | 87 +++++ client/ui/implant/implant_app_manifest.gd | 12 + client/ui/implant/implant_nav_stack.gd | 69 ++++ client/ui/implant/implant_registry.gd | 43 +++ 26 files changed, 730 insertions(+), 559 deletions(-) create mode 100644 client/ui/implant/apps/atlas/app.tres create mode 100644 client/ui/implant/apps/atlas/atlas_app.gd create mode 100644 client/ui/implant/apps/atlas/atlas_app.tscn rename client/ui/implant/{ => apps/atlas}/atlas_marker_overlay.gd (100%) rename client/ui/implant/{ => apps/atlas}/atlas_overlay_bar.gd (95%) rename client/ui/implant/{ => apps/atlas}/atlas_viewer.gd (97%) rename client/ui/implant/{atlas_planet_map.gd => apps/atlas/screens/planet_screen.gd} (68%) rename client/ui/implant/{atlas_reach_map.gd => apps/atlas/screens/reach_screen.gd} (97%) create mode 100644 client/ui/implant/apps/atlas/screens/regional_screen.gd rename client/ui/implant/{atlas_system_map.gd => apps/atlas/screens/system_screen.gd} (96%) create mode 100644 client/ui/implant/apps/economics/app.tres create mode 100644 client/ui/implant/apps/economics/economics_app.gd create mode 100644 client/ui/implant/apps/economics/economics_app.tscn rename client/ui/implant/{economics_panel.gd => apps/economics/screens/overview_screen.gd} (60%) delete mode 100644 client/ui/implant/atlas_panel.gd delete mode 100644 client/ui/implant/atlas_panel.tscn delete mode 100644 client/ui/implant/economics_panel.tscn create mode 100644 client/ui/implant/implant_app.gd create mode 100644 client/ui/implant/implant_app_manifest.gd create mode 100644 client/ui/implant/implant_nav_stack.gd create mode 100644 client/ui/implant/implant_registry.gd diff --git a/client/project.godot b/client/project.godot index 95f636a33..cb5202bbf 100644 --- a/client/project.godot +++ b/client/project.godot @@ -29,6 +29,7 @@ FogState="*res://scripts/autoloads/fog_state.gd" AudioManager="*res://scripts/autoloads/audio_manager.gd" SessionManager="*res://scripts/autoloads/session_manager.gd" HudGroups="*res://scripts/autoloads/hud_groups.gd" +ImplantRegistry="*res://ui/implant/implant_registry.gd" HardwareDetector="*res://ui/hardware_detector.gd" [audio] diff --git a/client/scripts/autoloads/hud_groups.gd b/client/scripts/autoloads/hud_groups.gd index d980295d6..07e6ec62e 100644 --- a/client/scripts/autoloads/hud_groups.gd +++ b/client/scripts/autoloads/hud_groups.gd @@ -12,7 +12,6 @@ extends Node ## Groups use hierarchical paths: ## "gameplay" — HUD status, minimap, prompts, stance ## "implant/map" — unified atlas (reach map → system → planet → regional, D-191) -## "implant/map/starchart" — legacy hop-ring view (registered by StarMapRenderer, kept for compat) ## "implant/wiki/gttr" — Drifter's Guide reader ## "implant/journal" — knowledge journal ## "implant/economics" — economics monitor (D-181, #824) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 098c2e140..ec26cd941 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -32,9 +32,8 @@ var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers @onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load @onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console @onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7) -@onready var star_map = $InsertOverlay/HUD/StarMap # #674: star map insert module (hop-ring view) -@onready var economics_panel = $InsertOverlay/HUD/EconomicsPanel # #824: economics monitor (D-170) -@onready var atlas_panel = $InsertOverlay/HUD/AtlasPanel # #834: atlas implant — system → orbital → body (D-191) +@onready var economics_app = $InsertOverlay/HUD/EconomicsApp # #824: economics monitor (D-170) +@onready var atlas_app = $InsertOverlay/HUD/AtlasApp # #844: atlas implant — reach → system → planet → regional (D-191) func _ready() -> void: @@ -92,9 +91,8 @@ func _ready() -> void: "interaction_list": interaction_list, "interaction_prompt": interaction_prompt, "minimap": minimap, - "star_map": star_map, - "economics_panel": economics_panel, - "atlas_panel": atlas_panel, + "economics_app": economics_app, + "atlas_app": atlas_app, }, _screen_flash ) @@ -171,38 +169,36 @@ func _ready() -> void: # #835 D-191: Atlas → Economics Monitor cross-link. The atlas city data panel # emits economics_link_requested(system_id); we pre-filter the monitor and # open it as an insert panel on top. - if atlas_panel and economics_panel: - atlas_panel.economics_link_requested.connect(_on_atlas_economics_link) + if atlas_app and economics_app: + atlas_app.economics_link_requested.connect(_on_atlas_economics_link) func _unhandled_key_input(event: InputEvent) -> void: - if event.is_pressed() and not event.is_echo(): - if event is InputEventKey and event.keycode == KEY_M: - # #844: M — unified atlas (implant/map). Reach map → system → planet → regional. - if atlas_panel: - atlas_panel.toggle_visible() - elif event is InputEventKey and event.keycode == KEY_N: - # #824: N — toggle Economics Monitor implant panel (E is bound to interact). - # Gated on the atlas being inactive so the viewer's N → city-economics - # cross-link isn't shadowed by this global toggle (review #6). - if economics_panel and not HudGroups.is_app_active("implant/map"): - economics_panel.toggle_visible() - elif event is InputEventKey and event.keycode == KEY_BRACKETLEFT: - # #824: [ — cycle economics panel system selector backward - if economics_panel and HudGroups.is_app_active("implant/economics"): - economics_panel.navigate(-1) - elif event is InputEventKey and event.keycode == KEY_BRACKETRIGHT: - # #824: ] — cycle economics panel system selector forward - if economics_panel and HudGroups.is_app_active("implant/economics"): - economics_panel.navigate(1) + if not (event is InputEventKey) or not event.is_pressed() or event.is_echo(): + return + var key_event := event as InputEventKey + # Registry-driven toggle: each manifest declares its own default_key. + for manifest: Variant in ImplantRegistry.get_manifests(): + if manifest.get("default_key") == key_event.keycode: + HudGroups.toggle_app( + manifest.get("app_path"), manifest.get("default_mode", HudGroups.Mode.FULLSCREEN) + ) + return + # [ / ] — cycle economics system selector (app-specific, not generic enough for manifest). + if key_event.keycode == KEY_BRACKETLEFT: + if economics_app and HudGroups.is_app_active("implant/economics"): + economics_app.navigate(-1) + elif key_event.keycode == KEY_BRACKETRIGHT: + if economics_app and HudGroups.is_app_active("implant/economics"): + economics_app.navigate(1) func _on_atlas_economics_link(system_id: String) -> void: # #835 D-191: Pre-filter the economics monitor to the city's system and pop - # the panel open. AtlasPanel closes itself before emitting this signal. - if economics_panel == null: + # the panel open. AtlasApp closes itself before emitting this signal. + if economics_app == null: return - economics_panel.select_system(system_id) + economics_app.select_system(system_id) HudGroups.open_app("implant/economics", HudGroups.Mode.INSERT) diff --git a/client/scripts/snapshot_consumers.gd b/client/scripts/snapshot_consumers.gd index b8a11dcc3..c2e33d8b2 100644 --- a/client/scripts/snapshot_consumers.gd +++ b/client/scripts/snapshot_consumers.gd @@ -16,9 +16,8 @@ var cursor_renderer: Node = null var interaction_list: Node = null var interaction_prompt: Node = null var minimap: Node = null -var star_map: Node = null -var economics_panel: Node = null # #824: economics monitor (D-181) -var atlas_panel: Node = null # #834: atlas implant panel (D-191) +var economics_app: Node = null # #824: economics monitor (D-181) +var atlas_app: Node = null # #844: atlas implant app (D-191) var _screen_flash_fn: Callable # Callable(color: Color, duration: float) @@ -38,9 +37,8 @@ func init(refs: Dictionary, screen_flash: Callable) -> SnapshotConsumers: interaction_list = refs.get("interaction_list") interaction_prompt = refs.get("interaction_prompt") minimap = refs.get("minimap") - star_map = refs.get("star_map") - economics_panel = refs.get("economics_panel") - atlas_panel = refs.get("atlas_panel") + economics_app = refs.get("economics_app") + atlas_app = refs.get("atlas_app") _screen_flash_fn = screen_flash return self @@ -56,12 +54,11 @@ func propagate_insert_state() -> void: interaction_prompt.set_insert_active(insert_state) if minimap: minimap.set_insert_active(insert_state) - if star_map: - star_map.set_insert_active(insert_state) - if economics_panel: - economics_panel.set_insert_active(insert_state) - if atlas_panel: - atlas_panel.set_insert_active(insert_state) + if not insert_state: + if economics_app and economics_app.has_method("on_insert_deactivated"): + economics_app.on_insert_deactivated() + if atlas_app and atlas_app.has_method("on_insert_deactivated"): + atlas_app.on_insert_deactivated() # D-057: Update interaction list from game state. @@ -181,12 +178,12 @@ func consume_debug_response() -> void: GameState.debug_response = null -# #824: Forward economy_snapshot from server to the economics panel (D-181). +# #824: Forward economy_snapshot from server to the economics app (D-181). func consume_economy_snapshot() -> void: - if GameState.economy_snapshot == null or not economics_panel: + if GameState.economy_snapshot == null or not economics_app: return - if economics_panel.has_method("receive_economy_data"): - economics_panel.receive_economy_data(GameState.economy_snapshot) + if economics_app.has_method("receive_economy_data"): + economics_app.receive_economy_data(GameState.economy_snapshot) GameState.economy_snapshot = null diff --git a/client/ui/hud.tscn b/client/ui/hud.tscn index ddc304eda..01c648f7d 100644 --- a/client/ui/hud.tscn +++ b/client/ui/hud.tscn @@ -1,9 +1,8 @@ -[gd_scene load_steps=4 format=3 uid="uid://cq1y5w3hmxr8b"] +[gd_scene load_steps=3 format=3 uid="uid://cq1y5w3hmxr8b"] [ext_resource type="Script" path="res://ui/hud.gd" id="1_hud"] -[ext_resource type="PackedScene" path="res://ui/star_map.tscn" id="2_starmap"] -[ext_resource type="PackedScene" path="res://ui/implant/economics_panel.tscn" id="3_econ"] -[ext_resource type="PackedScene" path="res://ui/implant/atlas_panel.tscn" id="4_atlas"] +[ext_resource type="PackedScene" path="res://ui/implant/apps/economics/economics_app.tscn" id="2_econ"] +[ext_resource type="PackedScene" path="res://ui/implant/apps/atlas/atlas_app.tscn" id="3_atlas"] [node name="HUD" type="Control"] layout_mode = 3 @@ -15,17 +14,12 @@ grow_vertical = 2 mouse_filter = 2 script = ExtResource("1_hud") -; #674: Star map — legacy hop-ring node, kept for backward compat. Superseded by AtlasPanel REACH_MAP level (#844). -[node name="StarMap" parent="." instance=ExtResource("2_starmap")] -visible = false - -; #824: Economics Monitor — implant/economics INSERT panel. Toggled via E key from main.gd. +; #824: Economics Monitor — implant/economics INSERT panel. Toggled via N key (manifest.default_key). ; Composes ImplantPanel from the D-169 component library. Placeholder data until #822 ships. -[node name="EconomicsPanel" parent="." instance=ExtResource("3_econ")] +[node name="EconomicsApp" parent="." instance=ExtResource("2_econ")] visible = false -; #834: Atlas implant panel — FULLSCREEN app at implant/map/atlas per D-170. -; 3-level navigation: system picker → orbital diagram → body entry. Toggled via A key. -[node name="AtlasPanel" parent="." instance=ExtResource("4_atlas")] +; #844: Atlas implant app — FULLSCREEN app at implant/map per D-170. +; Reach map → system orbital → planet entry → regional heightmap viewer. +[node name="AtlasApp" parent="." instance=ExtResource("3_atlas")] visible = false - diff --git a/client/ui/implant/apps/atlas/app.tres b/client/ui/implant/apps/atlas/app.tres new file mode 100644 index 000000000..fd28cd764 --- /dev/null +++ b/client/ui/implant/apps/atlas/app.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="ImplantAppManifest" load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/implant/implant_app_manifest.gd" id="1"] + +[resource] +script = ExtResource("1") +app_path = "implant/map" +display_name = "ATLAS" +icon_path = "" +scene_path = "res://ui/implant/apps/atlas/atlas_app.tscn" +default_mode = 2 +default_key = 77 +preserves_state = true diff --git a/client/ui/implant/apps/atlas/atlas_app.gd b/client/ui/implant/apps/atlas/atlas_app.gd new file mode 100644 index 000000000..4840fdc99 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_app.gd @@ -0,0 +1,204 @@ +class_name AtlasApp +extends ImplantApp +## Atlas implant app (#844, #836, D-191). +## Reach map → system orbital → planet entry → regional heightmap viewer. +## Registered as "implant/map" in FULLSCREEN mode. + +signal economics_link_requested(system_id: String) + +const STAR_MAP_DATA := "res://data/star_map_data.json" + +var _systems: Array = [] +var _system_lookup: Dictionary = {} # system_id → system dict + +var _reach_screen = null # ReachScreen +var _system_screen = null # SystemScreen +var _planet_screen = null # PlanetScreen +var _regional_screen = null # RegionalScreen +var _current_screen_id: String = "" + + +func _ready() -> void: + manifest = load("res://ui/implant/apps/atlas/app.tres") + super._ready() + + +func on_install() -> void: + _load_system_data() + var implant_theme = load("res://ui/implant/default_implant.tres") + + _reach_screen = ReachScreen.new() + _reach_screen.name = "ReachScreen" + _reach_screen.visible = false + add_child(_reach_screen) + _reach_screen.setup(implant_theme) + _reach_screen.set_systems(_systems, _system_lookup) + _reach_screen.system_selected.connect(_on_system_selected) + + _system_screen = SystemScreen.new() + _system_screen.name = "SystemScreen" + _system_screen.visible = false + add_child(_system_screen) + _system_screen.setup(implant_theme) + _system_screen.set_systems(_systems) + _system_screen.body_selected.connect(_on_body_selected) + + _planet_screen = PlanetScreen.new() + _planet_screen.name = "PlanetScreen" + _planet_screen.visible = false + add_child(_planet_screen) + _planet_screen.setup(implant_theme) + + _regional_screen = RegionalScreen.new() + _regional_screen.name = "RegionalScreen" + _regional_screen.visible = false + add_child(_regional_screen) + _regional_screen.back_requested.connect(_on_regional_back) + _regional_screen.economics_link_requested.connect(_forward_economics_link) + + nav.set_default("reach") + + +func on_open(_mode: int) -> void: + # Base class handles nav.push_default() on first open. + if _current_screen_id == "reach" and _reach_screen: + _reach_screen.refresh_info_panel_visibility() + + +func _on_screen_changed(new_id: String) -> void: + # Skip leave/hide when replacing the same screen (e.g. picker→orbital transition). + if _current_screen_id != new_id: + var old := _get_screen(_current_screen_id) + if old: + old.leave() + old.visible = false + + _current_screen_id = new_id + + var s := _get_screen(new_id) + if s: + s.visible = true + s.enter(nav.current_payload()) + + +func _unhandled_key_input(event: InputEventKey) -> void: + if manifest == null or not HudGroups.is_app_active(manifest.app_path): + return + if not event.is_pressed() or event.is_echo(): + return + if _current_screen_id == "regional": + return # AtlasViewer handles its own keyboard input + _handle_key(event) + get_viewport().set_input_as_handled() + + +func _handle_key(event: InputEventKey) -> void: + match event.keycode: + KEY_ESCAPE: + if _current_screen_id == "reach": + HudGroups.close_app() + else: + nav.pop() + KEY_ENTER, KEY_KP_ENTER: + _handle_enter() + KEY_BRACKETLEFT: + if _current_screen_id == "system" and _system_screen: + _system_screen.navigate_system(-1) + KEY_BRACKETRIGHT: + if _current_screen_id == "system" and _system_screen: + _system_screen.navigate_system(1) + + +func _handle_enter() -> void: + match _current_screen_id: + "reach": + if _reach_screen and _reach_screen.has_selection(): + _reach_screen.trigger_enter() + "system": + if _system_screen and not _system_screen.is_in_orbital(): + var sys: Dictionary = _system_screen.current_system() + nav.replace("system", {"mode": "orbital", "system": sys}) + "planet": + if _planet_screen and _planet_screen.has_heightmap(): + nav.push("regional", { + "body": _planet_screen.current_body(), + "system": nav.current_payload().get("system", {}), + }) + + +# ============================================================================= +# Signal handlers +# ============================================================================= + + +func _on_system_selected(system_id: String) -> void: + var idx: int = _system_idx_by_id(system_id) + if _system_screen: + _system_screen.set_selected_idx(idx) + var system: Dictionary = _system_lookup.get(system_id, {"system_id": system_id}) + nav.push("system", {"mode": "orbital", "system": system}) + + +func _on_body_selected(body: Dictionary) -> void: + nav.push("planet", { + "body": body, + "system": nav.current_payload().get("system", {}), + }) + + +func _on_regional_back() -> void: + nav.pop() + + +func _forward_economics_link(system_id: String) -> void: + economics_link_requested.emit(system_id) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +func _get_screen(screen_id: String) -> Control: + match screen_id: + "reach": + return _reach_screen as Control + "system": + return _system_screen as Control + "planet": + return _planet_screen as Control + "regional": + return _regional_screen as Control + _: + return null + + +func _system_idx_by_id(system_id: String) -> int: + for i: int in range(_systems.size()): + if _systems[i].get("system_id", "") == system_id: + return i + return 0 + + +func _load_system_data() -> void: + if not FileAccess.file_exists(STAR_MAP_DATA): + push_warning("AtlasApp: %s not found" % STAR_MAP_DATA) + return + var file := FileAccess.open(STAR_MAP_DATA, FileAccess.READ) + if file == null: + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + return + for node: Dictionary in parsed.get("nodes", []): + var sid: String = node.get("system_id", "") + if not sid.is_empty(): + _systems.append(node) + _system_lookup[sid] = node + _systems.sort_custom( + func(a: Dictionary, b: Dictionary) -> bool: + var na: String = a.get("proper_name", a.get("system_id", "")) + var nb: String = b.get("proper_name", b.get("system_id", "")) + return na < nb + ) diff --git a/client/ui/implant/apps/atlas/atlas_app.tscn b/client/ui/implant/apps/atlas/atlas_app.tscn new file mode 100644 index 000000000..ec338b395 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_app.tscn @@ -0,0 +1,17 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/implant/apps/atlas/atlas_app.gd" id="1_atlas_app"] + +; #844: Atlas implant app — reach map → system orbital → planet entry → regional heightmap viewer. +; FULLSCREEN app (z=20) at implant/map per D-170. Managed via ImplantApp/ImplantNavStack pattern. +; Toggle with M key (manifest.default_key). Data from star_map_data.json. + +[node name="AtlasApp" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1_atlas_app") diff --git a/client/ui/implant/atlas_marker_overlay.gd b/client/ui/implant/apps/atlas/atlas_marker_overlay.gd similarity index 100% rename from client/ui/implant/atlas_marker_overlay.gd rename to client/ui/implant/apps/atlas/atlas_marker_overlay.gd diff --git a/client/ui/implant/atlas_overlay_bar.gd b/client/ui/implant/apps/atlas/atlas_overlay_bar.gd similarity index 95% rename from client/ui/implant/atlas_overlay_bar.gd rename to client/ui/implant/apps/atlas/atlas_overlay_bar.gd index 01f97534e..64e3b3028 100644 --- a/client/ui/implant/atlas_overlay_bar.gd +++ b/client/ui/implant/apps/atlas/atlas_overlay_bar.gd @@ -6,8 +6,7 @@ extends HBoxContainer ## This script has no `class_name` on purpose: the owner (AtlasViewer) needs to ## pass the viewer reference to _init() and a `class_name` + required-arg ## _init() combo is a Godot editor footgun (review #8). Instance it via -## load("res://ui/implant/atlas_overlay_bar.gd").new(self) like AtlasViewer -## itself is instanced from AtlasPanel. +## load("res://ui/implant/apps/atlas/atlas_overlay_bar.gd").new(self). ## ## Layout: [ALWAYS-ON] [TOGGLEABLE] [LOCKED]. Each row in viewer.get_overlay_defs() ## produces exactly one button, so adding or retiring an overlay is a one-file diff --git a/client/ui/implant/atlas_viewer.gd b/client/ui/implant/apps/atlas/atlas_viewer.gd similarity index 97% rename from client/ui/implant/atlas_viewer.gd rename to client/ui/implant/apps/atlas/atlas_viewer.gd index c8eb42df8..4654339cc 100644 --- a/client/ui/implant/atlas_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_viewer.gd @@ -3,9 +3,9 @@ extends Control ## Atlas regional viewer — heightmap PNG with pan/zoom + marker overlay (#835, D-191). ## -## Lives as a child of AtlasPanel, shown at Level.HEIGHTMAP_VIEWER. Receives -## body/system context from AtlasPanel via show_body(). Emits back_pressed and -## economics_link_requested signals so AtlasPanel can route them. +## Lives as a child of RegionalScreen, shown when the atlas nav stack is at +## "regional". Receives body/system context via show_body(). Emits back_pressed +## and economics_link_requested so RegionalScreen can route them. ## ## Design notes: ## - Heightmap texture is drawn on a Node2D _canvas child. Pan = _canvas.position, @@ -124,7 +124,7 @@ const OVERLAY_DEFS: Array = [ }, ] -# ── Context (set by AtlasPanel.show_body) ───────────────────────────────────── +# ── Context (set by show_body) ───────────────────────────────────────────────── var _body: Dictionary = {} var _system: Dictionary = {} var _implant_theme = null @@ -196,7 +196,7 @@ func _ready() -> void: _build_overlay_bar() -## Called by AtlasPanel when entering the viewer for a specific body. +## Called by RegionalScreen.enter() when entering the viewer for a specific body. func show_body(body: Dictionary, system: Dictionary) -> void: _body = body _system = system @@ -637,7 +637,7 @@ func _position_empty_notice() -> void: func _build_overlay_bar() -> void: - var BarScript := load("res://ui/implant/atlas_overlay_bar.gd") + var BarScript := load("res://ui/implant/apps/atlas/atlas_overlay_bar.gd") _overlay_bar = BarScript.new(self) _overlay_bar.name = "OverlayBar" add_child(_overlay_bar) diff --git a/client/ui/implant/atlas_planet_map.gd b/client/ui/implant/apps/atlas/screens/planet_screen.gd similarity index 68% rename from client/ui/implant/atlas_planet_map.gd rename to client/ui/implant/apps/atlas/screens/planet_screen.gd index ddde1d3d4..d8e50ac3a 100644 --- a/client/ui/implant/atlas_planet_map.gd +++ b/client/ui/implant/apps/atlas/screens/planet_screen.gd @@ -1,10 +1,7 @@ -class_name AtlasPlanetMap +class_name PlanetScreen extends Control -## Level 3 BODY_ENTRY and Level 4 HEIGHTMAP_VIEWER widget for AtlasPanel (#844, D-191). -## Shows body detail panel and delegates heightmap rendering to AtlasViewer. - -signal back_to_viewer_body -signal economics_link_requested(system_id: String) +## Body entry screen for AtlasApp (#844, D-191). +## Shows body detail panel. Heightmap viewer is in RegionalScreen. const PANEL_WIDTH: float = 320.0 const PANEL_MARGIN: float = 16.0 @@ -13,49 +10,40 @@ const COLOR_BG: Color = Color("#0d1117") var _selected_body: Dictionary = {} var _current_sys: Dictionary = {} var _body_panel = null # ImplantPanel -var _viewer = null # AtlasViewer - - -func _ready() -> void: - mouse_filter = Control.MOUSE_FILTER_STOP - set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) func _draw() -> void: - if _viewer == null or not _viewer.visible: - draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) + draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) func setup(implant_theme) -> void: _build_body_panel(implant_theme) - _build_heightmap_viewer() -func set_body(body: Dictionary, system: Dictionary) -> void: - _selected_body = body - _current_sys = system +func enter(payload: Dictionary) -> void: + _selected_body = payload.get("body", {}) + _current_sys = payload.get("system", {}) _rebuild_body_panel() - - -func show_body_mode() -> void: if _body_panel: _body_panel.visible = true - if _viewer: - _viewer.visible = false queue_redraw() -func show_viewer_mode() -> void: - if _viewer == null: - return - _viewer.show_body(_selected_body, _current_sys) +func leave() -> void: if _body_panel: _body_panel.visible = false - _viewer.visible = true + + +func has_heightmap() -> bool: + return _selected_body.get("terrain_reference") != null + + +func current_body() -> Dictionary: + return _selected_body # ============================================================================= -# Panels +# Panel # ============================================================================= @@ -83,7 +71,6 @@ func _rebuild_body_panel() -> void: var atmo: String = b.get("atmosphere", "none") if b.get("atmosphere") else "none" var inhabited: bool = bool(b.get("inhabited", false)) var pop: int = int(b.get("population", 0)) - var has_heightmap: bool = b.get("terrain_reference") != null var sys_name: String = _current_sys.get("proper_name", _current_sys.get("system_id", "—")) @@ -102,7 +89,7 @@ func _rebuild_body_panel() -> void: _body_panel.add_component(ImplantSeparator.new()) - if has_heightmap: + if has_heightmap(): _body_panel.add_component(ImplantTextBlock.new("enter view heightmap atlas")) else: _body_panel.add_component(ImplantTextBlock.new("atlas data pending (#839)")) @@ -110,23 +97,6 @@ func _rebuild_body_panel() -> void: _body_panel.add_component(ImplantTextBlock.new("esc back to orbital view")) -func _build_heightmap_viewer() -> void: - _viewer = AtlasViewer.new() - _viewer.name = "AtlasViewer" - _viewer.visible = false - add_child(_viewer) - _viewer.back_pressed.connect(_on_viewer_back) - _viewer.economics_link_requested.connect(_on_viewer_economics_link) - - -func _on_viewer_back() -> void: - back_to_viewer_body.emit() - - -func _on_viewer_economics_link(system_id: String) -> void: - economics_link_requested.emit(system_id) - - # ============================================================================= # Helpers # ============================================================================= diff --git a/client/ui/implant/atlas_reach_map.gd b/client/ui/implant/apps/atlas/screens/reach_screen.gd similarity index 97% rename from client/ui/implant/atlas_reach_map.gd rename to client/ui/implant/apps/atlas/screens/reach_screen.gd index e7a847650..b258300b7 100644 --- a/client/ui/implant/atlas_reach_map.gd +++ b/client/ui/implant/apps/atlas/screens/reach_screen.gd @@ -1,10 +1,10 @@ -class_name AtlasReachMap +class_name ReachScreen extends Control -## Level 0 REACH_MAP widget for AtlasPanel (#844, D-191). +## Level 0 REACH_MAP screen for AtlasApp (#844, D-191). ## Hop-ring view of the Settled Reach gate network. -## Emits enter_system(system_id) when the player commits to a system. +## Emits system_selected when the player commits to a system. -signal enter_system(system_id: String) +signal system_selected(system_id: String) const REACH_MAP_CENTER_FRACTION := Vector2(0.5, 0.5) const REACH_MIN_RING_RADIUS: float = 30.0 @@ -109,6 +109,14 @@ func trigger_enter() -> void: _reach_enter_selected() +func enter(_payload: Dictionary) -> void: + refresh_info_panel_visibility() + + +func leave() -> void: + pass + + # ============================================================================= # Info panel # ============================================================================= @@ -503,4 +511,4 @@ func _reach_enter_selected() -> void: if _reach_selected.is_empty(): return if _reach_system_index(_reach_selected) >= 0: - enter_system.emit(_reach_selected) + system_selected.emit(_reach_selected) diff --git a/client/ui/implant/apps/atlas/screens/regional_screen.gd b/client/ui/implant/apps/atlas/screens/regional_screen.gd new file mode 100644 index 000000000..b0d6bdd49 --- /dev/null +++ b/client/ui/implant/apps/atlas/screens/regional_screen.gd @@ -0,0 +1,37 @@ +class_name RegionalScreen +extends Control +## Regional heightmap viewer screen for AtlasApp (#844, D-191). +## Thin wrapper around AtlasViewer; enter/leave are the nav interface. + +signal back_requested +signal economics_link_requested(system_id: String) + +var _viewer: AtlasViewer = null + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _viewer = AtlasViewer.new() + _viewer.name = "AtlasViewer" + add_child(_viewer) + _viewer.back_pressed.connect(_on_viewer_back) + _viewer.economics_link_requested.connect(_on_viewer_economics_link) + + +func enter(payload: Dictionary) -> void: + var body: Dictionary = payload.get("body", {}) + var system: Dictionary = payload.get("system", {}) + _viewer.show_body(body, system) + + +func leave() -> void: + pass + + +func _on_viewer_back() -> void: + back_requested.emit() + + +func _on_viewer_economics_link(system_id: String) -> void: + economics_link_requested.emit(system_id) diff --git a/client/ui/implant/atlas_system_map.gd b/client/ui/implant/apps/atlas/screens/system_screen.gd similarity index 96% rename from client/ui/implant/atlas_system_map.gd rename to client/ui/implant/apps/atlas/screens/system_screen.gd index f41c09a1d..d83c80845 100644 --- a/client/ui/implant/atlas_system_map.gd +++ b/client/ui/implant/apps/atlas/screens/system_screen.gd @@ -1,10 +1,10 @@ -class_name AtlasSystemMap +class_name SystemScreen extends Control -## Level 1 SYSTEM_PICKER and Level 2 ORBITAL_DIAGRAM widget for AtlasPanel (#844, D-191). -## Manages alphabetic system picker and orbital diagram rendering for the current system. -## Emits enter_body(body) when the player clicks a body in orbital view. +## System picker and orbital diagram screen for AtlasApp (#844, D-191). +## Manages alphabetic system picker and orbital diagram for the current system. +## Emits body_selected when the player clicks a body in orbital view. -signal enter_body(body: Dictionary) +signal body_selected(body: Dictionary) const ORBITAL_CENTER_FRACTION := Vector2(0.5, 0.55) const STAR_RADIUS: float = 12.0 @@ -39,7 +39,7 @@ var _hovered_body: String = "" var _hovered_station: String = "" var _selected_station: Dictionary = {} var _dirty: bool = true -var _in_orbital: bool = false # false = picker view, true = orbital view +var _in_orbital: bool = false var _picker_panel = null # ImplantPanel var _picker_nav_row = null # ImplantDataRow @@ -83,6 +83,10 @@ func current_system() -> Dictionary: return _systems[_selected_idx] +func is_in_orbital() -> bool: + return _in_orbital + + func get_orbital_body_count() -> int: var count: int = 0 for b: Dictionary in _orbital_bodies: @@ -128,6 +132,17 @@ func navigate_system(delta: int) -> void: _rebuild_picker_panel() +func enter(payload: Dictionary) -> void: + if payload.get("mode") == "orbital": + load_and_show_orbital() + else: + show_picker_mode() + + +func leave() -> void: + pass + + # ============================================================================= # Drawing # ============================================================================= @@ -183,7 +198,6 @@ func _draw_bodies() -> void: color = COLOR_MOON radius = 3.5 "oort_cloud": - # Oort cloud shown as faint suggestion, not a solid dot color = COLOR_OORT radius = 2.0 _: @@ -351,7 +365,7 @@ func _handle_orbital_click(pos: Vector2) -> void: if not bid.is_empty(): for b: Dictionary in _orbital_bodies: if str(b.get("body_id", "")) == bid: - enter_body.emit(b) + body_selected.emit(b) return var sid: String = _find_nearest_station(pos) diff --git a/client/ui/implant/apps/economics/app.tres b/client/ui/implant/apps/economics/app.tres new file mode 100644 index 000000000..242ac6f8b --- /dev/null +++ b/client/ui/implant/apps/economics/app.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="ImplantAppManifest" load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/implant/implant_app_manifest.gd" id="1"] + +[resource] +script = ExtResource("1") +app_path = "implant/economics" +display_name = "ECONOMICS MONITOR" +icon_path = "" +scene_path = "res://ui/implant/apps/economics/economics_app.tscn" +default_mode = 1 +default_key = 78 +preserves_state = true diff --git a/client/ui/implant/apps/economics/economics_app.gd b/client/ui/implant/apps/economics/economics_app.gd new file mode 100644 index 000000000..c15921191 --- /dev/null +++ b/client/ui/implant/apps/economics/economics_app.gd @@ -0,0 +1,80 @@ +class_name EconomicsApp +extends ImplantApp +## Economics Monitor implant app (#824, D-170, D-181). +## Registered as "implant/economics" in INSERT mode. +## Delegates all data/rendering to OverviewScreen. + +var _overview_screen = null # OverviewScreen +var _current_screen_id: String = "" + + +func _ready() -> void: + manifest = load("res://ui/implant/apps/economics/app.tres") + super._ready() + + +func on_install() -> void: + _overview_screen = OverviewScreen.new() + _overview_screen.name = "OverviewScreen" + _overview_screen.visible = false + add_child(_overview_screen) + nav.set_default("overview") + + +func _on_screen_changed(new_id: String) -> void: + if _current_screen_id != new_id: + var old := _get_screen(_current_screen_id) + if old: + old.leave() + old.visible = false + + _current_screen_id = new_id + + var s := _get_screen(new_id) + if s: + s.visible = true + s.enter(nav.current_payload()) + + +func _get_screen(screen_id: String) -> Control: + if screen_id == "overview": + return _overview_screen as Control + return null + + +# ============================================================================= +# Public API — delegates to OverviewScreen +# ============================================================================= + + +func receive_economy_data(data: Dictionary) -> void: + if _overview_screen: + _overview_screen.receive_economy_data(data) + + +func select_system(system_id: String) -> void: + if _overview_screen: + _overview_screen.select_system(system_id) + + +func navigate(delta: int) -> void: + if _overview_screen: + _overview_screen.navigate(delta) + + +func get_history(system_id: String) -> Array: + if _overview_screen: + return _overview_screen.get_history(system_id) + return [] + + +func get_latest(system_id: String) -> Dictionary: + if _overview_screen: + return _overview_screen.get_latest(system_id) + return {} + + +func get_known_systems() -> Array: + if _overview_screen: + return _overview_screen.get_known_systems() + return [] diff --git a/client/ui/implant/apps/economics/economics_app.tscn b/client/ui/implant/apps/economics/economics_app.tscn new file mode 100644 index 000000000..30344a1cb --- /dev/null +++ b/client/ui/implant/apps/economics/economics_app.tscn @@ -0,0 +1,17 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/implant/apps/economics/economics_app.gd" id="1_econ_app"] + +; #824: Economics Monitor implant app — price data and GDP for selected system. +; INSERT mode (z=10) at implant/economics per D-170. Managed via ImplantApp/ImplantNavStack pattern. +; Toggle with N key (manifest.default_key). Data flows from EconomySnapshot via snapshot_consumers. + +[node name="EconomicsApp" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1_econ_app") diff --git a/client/ui/implant/economics_panel.gd b/client/ui/implant/apps/economics/screens/overview_screen.gd similarity index 60% rename from client/ui/implant/economics_panel.gd rename to client/ui/implant/apps/economics/screens/overview_screen.gd index d4df4843d..ed51abce1 100644 --- a/client/ui/implant/economics_panel.gd +++ b/client/ui/implant/apps/economics/screens/overview_screen.gd @@ -1,33 +1,19 @@ -class_name EconomicsPanel +class_name OverviewScreen extends Control +## Economics Monitor overview screen (#824, D-170, D-181). +## Ported from economics_panel.gd into the ImplantApp screen pattern. +## +## Data architecture: ring buffer (last 20 ticks per system), 7 D-181 signals. +## Signals 1-2 (price_current, price_trend) are Phase 2 deliverables; +## signals 3-7 are parsed and stored but not yet displayed (Phase 3). -## Economics Monitor — implant insert panel (#824, D-170, D-181). -## -## Displays price data and GDP for a selected system. Receives economy_snapshot -## from the server via snapshot_handler → GameState → snapshot_consumers pipeline. -## -## Data architecture: -## - Ring buffer: last 20 ticks of economy data per system (for trend display) -## - 7 D-181 signals per system: price_current, price_trend, trade_flow_volume, -## corporate_presence, stockpile_weeks, production_vs_baseline, official_coverage_ratio -## - Signals 1-2 (price_current, price_trend) are Phase 2 deliverables -## - Signals 3-7 are parsed and stored but not yet displayed (Phase 3) -## -## Visual layer: ImplantPanel composition built in _ready() from component library (D-169). -## System selector uses LEFT/RIGHT arrow keys to cycle through all 301 systems. -## Placeholder commodity prices shown until #822 ships. - -## Emitted when new economy data arrives for the selected system. signal economy_data_updated(system_id: String, data: Dictionary) -const APP_PATH := "implant/economics" const RING_BUFFER_SIZE: int = 20 const STAR_MAP_DATA := "res://data/star_map_data.json" const PANEL_WIDTH: float = 340.0 const PANEL_MARGIN: float = 16.0 -# Placeholder commodity rows shown until server ships EconomySnapshot (#822). -# Commodity IDs match D-184 catalog. const PLACEHOLDER_COMMODITIES: Array[Dictionary] = [ {"id": "fusion_fuel", "name": "FUSION FUEL", "price": 142, "trend": 1}, {"id": "basic_goods", "name": "BASIC GOODS", "price": 58, "trend": 0}, @@ -37,27 +23,19 @@ const PLACEHOLDER_COMMODITIES: Array[Dictionary] = [ {"id": "pharmaceuticals", "name": "PHARMA", "price": 312, "trend": -1}, ] -## Currently selected system for detailed display. Empty = no selection. var selected_system: String = "" - -## Ring buffer: system_id → Array[Dictionary] (most recent last, max RING_BUFFER_SIZE). -## Each entry is one tick's worth of D-181 signals for that system. var _history: Dictionary = {} -var _insert_active: bool = true - -# Visual panel state (D-169 component library) -var _panel: ImplantPanel = null # root container +var _panel: ImplantPanel = null var _implant_theme: ImplantTheme = null -var _header: ImplantHeader = null # kept for set_content() on system change -var _nav_row: ImplantDataRow = null # system selector nav hint -var _gdp_row: ImplantDataRow = null # GDP value row -var _commodity_rows: Array = [] # ImplantDataRow × 6, updated without full rebuild -var _placeholder_notice: ImplantTextBlock = null # hidden once live data arrives +var _header: ImplantHeader = null +var _nav_row: ImplantDataRow = null +var _gdp_row: ImplantDataRow = null +var _commodity_rows: Array = [] +var _placeholder_notice: ImplantTextBlock = null -# System list for the selector (populated from STAR_MAP_DATA) -var _systems: Array = [] # Array[Dictionary], sorted by proper_name -var _selected_idx: int = 0 # index into _systems +var _systems: Array = [] +var _selected_idx: int = 0 func _ready() -> void: @@ -65,21 +43,22 @@ func _ready() -> void: anchor_bottom = 1.0 grow_horizontal = Control.GROW_DIRECTION_BOTH grow_vertical = Control.GROW_DIRECTION_BOTH - visible = false mouse_filter = Control.MOUSE_FILTER_STOP - # D-170: Register with HUD layer groups - HudGroups.register(self, APP_PATH) - HudGroups.app_changed.connect(_on_app_changed) - _implant_theme = load("res://ui/implant/default_implant.tres") as ImplantTheme _load_system_list() _build_panel() economy_data_updated.connect(_on_economy_data_updated) -## Called from SnapshotConsumers when economy_snapshot arrives in GameState. -## data: Dictionary keyed by system_id → signal payload (D-181). +func enter(_payload: Dictionary) -> void: + pass + + +func leave() -> void: + pass + + func receive_economy_data(data: Dictionary) -> void: for system_id: String in data: var signals: Variant = data[system_id] @@ -92,12 +71,10 @@ func receive_economy_data(data: Dictionary) -> void: if buf.size() > RING_BUFFER_SIZE: _history[system_id] = buf.slice(buf.size() - RING_BUFFER_SIZE) - # Notify listeners if the selected system received new data if not selected_system.is_empty() and data.has(selected_system): economy_data_updated.emit(selected_system, data[selected_system]) -## Select a system for detailed display. Emits economy_data_updated if history exists. func select_system(system_id: String) -> void: selected_system = system_id if not selected_system.is_empty() and _history.has(selected_system): @@ -106,13 +83,10 @@ func select_system(system_id: String) -> void: economy_data_updated.emit(selected_system, buf[buf.size() - 1]) -## Get the full ring buffer for a system (for chart/sparkline rendering). -## Returns empty array if no history exists. func get_history(system_id: String) -> Array: return _history.get(system_id, []) -## Get the latest tick's signals for a system, or empty dict. func get_latest(system_id: String) -> Dictionary: var buf: Array = _history.get(system_id, []) if buf.size() > 0: @@ -120,41 +94,26 @@ func get_latest(system_id: String) -> Dictionary: return {} -## Get all system IDs that have received at least one tick of data. func get_known_systems() -> Array: return _history.keys() -## Toggle via HUD layer system (D-170). INSERT mode — shares screen with gameplay. -func toggle_visible() -> void: - HudGroups.toggle_app(APP_PATH, HudGroups.Mode.INSERT) - - -## Called from main.gd when insert state changes (D-170). -func set_insert_active(active: bool) -> void: - _insert_active = active - if not active and HudGroups.is_app_active(APP_PATH): - HudGroups.close_app() - - -## Respond to app layer changes (D-170). -func _on_app_changed(app_path: String, mode: int) -> void: - if app_path != APP_PATH: +func navigate(delta: int) -> void: + if _systems.is_empty(): return - if mode == HudGroups.Mode.FULLSCREEN or mode == HudGroups.Mode.INSERT: - visible = true - else: - visible = false + _selected_idx = wrapi(_selected_idx + delta, 0, _systems.size()) + selected_system = _systems[_selected_idx].get("system_id", "") + _rebuild_panel() # ============================================================================= -# System list — populated from star_map_data.json +# System list # ============================================================================= func _load_system_list() -> void: if not FileAccess.file_exists(STAR_MAP_DATA): - push_warning("EconomicsPanel: %s not found" % STAR_MAP_DATA) + push_warning("OverviewScreen: %s not found" % STAR_MAP_DATA) return var file := FileAccess.open(STAR_MAP_DATA, FileAccess.READ) if file == null: @@ -193,7 +152,6 @@ func _build_panel() -> void: _rebuild_panel() -## Full rebuild of panel components. Called on system change and initial build. func _rebuild_panel() -> void: if not _panel: return @@ -205,29 +163,22 @@ func _rebuild_panel() -> void: var sys_id: String = node.get("system_id", "") var total: int = _systems.size() - # ── Header ──────────────────────────────────────────────────────────────── _header = ImplantHeader.new("ECONOMICS MONITOR", sys_name) _panel.add_component(_header) - _panel.add_component(ImplantSeparator.new()) - # ── System selector nav ──────────────────────────────────────────────────── var nav_hint := "◄ ► · %s [%d / %d]" % [sys_id, _selected_idx + 1, total] _nav_row = ImplantDataRow.new(nav_hint) _panel.add_component(_nav_row) - _panel.add_component(ImplantSeparator.new()) - # ── Population + GDP strip ──────────────────────────────────────────────── var pop_str: String = node.get("population", "—") _panel.add_component(ImplantDataRow.new("pop " + pop_str)) var gdp_str: String = node.get("gdp", "—") _gdp_row = ImplantDataRow.new("gdp " + gdp_str) _panel.add_component(_gdp_row) - _panel.add_component(ImplantSeparator.new()) - # ── Price table ─────────────────────────────────────────────────────────── _panel.add_component(ImplantTextBlock.new("MARKET PRICES")) var latest: Dictionary = get_latest(sys_id) @@ -238,7 +189,6 @@ func _rebuild_panel() -> void: var price: int = c.get("price", 0) var trend: int = c.get("trend", 0) - # Overlay live data when available (D-181 signal 1-2) for sig: Dictionary in commodity_signals: if sig.get("commodity_id", "") == cid: price = int(sig.get("price_current", price)) @@ -250,7 +200,6 @@ func _rebuild_panel() -> void: _panel.add_component(row) _commodity_rows.append(row) - # ── Placeholder notice ──────────────────────────────────────────────────── _panel.add_component(ImplantSeparator.new()) var notice_text: String = ( "[LIVE MARKET — #822 PENDING]" if _history.is_empty() else "LIVE DATA ACTIVE" @@ -277,7 +226,6 @@ func _trend_glyph(trend: int) -> String: return "—" -## Respond to economy_data_updated signal — refresh the price table in-place. func _on_economy_data_updated(system_id: String, data: Dictionary) -> void: if not _panel or _commodity_rows.is_empty(): return @@ -305,13 +253,3 @@ func _on_economy_data_updated(system_id: String, data: Dictionary) -> void: if _placeholder_notice and not _history.is_empty(): _placeholder_notice.text = "LIVE DATA ACTIVE" - - -## Cycle the system selector by delta steps (+1 or -1). -## Called from main.gd _unhandled_key_input — [ and ] keys when panel is active. -func navigate(delta: int) -> void: - if _systems.is_empty(): - return - _selected_idx = wrapi(_selected_idx + delta, 0, _systems.size()) - selected_system = _systems[_selected_idx].get("system_id", "") - _rebuild_panel() diff --git a/client/ui/implant/atlas_panel.gd b/client/ui/implant/atlas_panel.gd deleted file mode 100644 index 43f3b0f61..000000000 --- a/client/ui/implant/atlas_panel.gd +++ /dev/null @@ -1,302 +0,0 @@ -class_name AtlasPanel -extends Control -## Atlas implant panel — unified 5-level nav chain (#844, D-191): -## reach map → system picker → orbital diagram → body entry → regional viewer. -## FULLSCREEN implant app (z=20) at implant/map per D-170. -## Uses ImplantPanel component library (D-169). Data from star_map_data.json. -## -## Navigation: -## Level 0 REACH_MAP — hop-ring view of the Reach gate network (AtlasReachMap) -## Level 1 SYSTEM_PICKER — ◄ ► cycle systems alphabetically (AtlasSystemMap) -## Level 2 ORBITAL_DIAGRAM — rendered via AtlasSystemMap._draw() -## Level 3 BODY_ENTRY — body info panel (AtlasPlanetMap) -## Level 4 HEIGHTMAP_VIEWER — AtlasViewer with pan/zoom + markers (AtlasPlanetMap) - -## Emitted when the viewer's city-data panel requests the economics monitor for -## the current system. main.gd bridges this to EconomicsPanel.select_system() -## + HudGroups.open_app("implant/economics") — D-191 cross-panel integration. -signal economics_link_requested(system_id: String) - -enum Level { - REACH_MAP = 0, SYSTEM_PICKER = 1, ORBITAL_DIAGRAM = 2, BODY_ENTRY = 3, HEIGHTMAP_VIEWER = 4 -} - -const APP_PATH := "implant/map" -const STAR_MAP_DATA := "res://data/star_map_data.json" -const PANEL_MARGIN: float = 16.0 - -var _level: Level = Level.REACH_MAP -var _systems: Array = [] -var _reach_node_lookup: Dictionary = {} - -var _implant_theme = null # loaded at runtime (autoload parse-order rule) -var _screen_header: ImplantHeader = null -var _reach_map: AtlasReachMap = null -var _system_map: AtlasSystemMap = null -var _planet_map: AtlasPlanetMap = null - - -func _ready() -> void: - anchor_right = 1.0 - anchor_bottom = 1.0 - grow_horizontal = Control.GROW_DIRECTION_BOTH - grow_vertical = Control.GROW_DIRECTION_BOTH - visible = false - mouse_filter = Control.MOUSE_FILTER_STOP - - HudGroups.register(self, APP_PATH) - HudGroups.app_changed.connect(_on_app_changed) - - _implant_theme = load("res://ui/implant/default_implant.tres") - - _load_system_list() - _build_widgets() - _build_screen_header() - _show_level(Level.REACH_MAP) - - -## Toggle atlas panel. Called from main.gd on KEY_M. -func toggle_visible() -> void: - HudGroups.toggle_app(APP_PATH, HudGroups.Mode.FULLSCREEN) - - -## Called from main.gd when insert state changes. -func set_insert_active(active: bool) -> void: - if not active and HudGroups.is_app_active(APP_PATH): - HudGroups.close_app() - - -func _on_app_changed(app_path: String, mode: int) -> void: - if app_path != APP_PATH: - return - if mode == HudGroups.Mode.FULLSCREEN: - visible = true - _active_widget().queue_redraw() - else: - visible = false - - -# ============================================================================= -# Data loading -# ============================================================================= - - -func _load_system_list() -> void: - if not FileAccess.file_exists(STAR_MAP_DATA): - push_warning("AtlasPanel: %s not found" % STAR_MAP_DATA) - return - var file := FileAccess.open(STAR_MAP_DATA, FileAccess.READ) - if file == null: - return - var parsed: Variant = JSON.parse_string(file.get_as_text()) - file.close() - if not (parsed is Dictionary): - return - for node: Dictionary in parsed.get("nodes", []): - if not node.get("system_id", "").is_empty(): - _systems.append(node) - _systems.sort_custom( - func(a: Dictionary, b: Dictionary) -> bool: - var na: String = a.get("proper_name", a.get("system_id", "")) - var nb: String = b.get("proper_name", b.get("system_id", "")) - return na < nb - ) - for node: Dictionary in _systems: - _reach_node_lookup[node.get("system_id", "")] = node - - -# ============================================================================= -# Widget construction -# ============================================================================= - - -func _build_widgets() -> void: - _reach_map = AtlasReachMap.new() - _reach_map.name = "ReachMap" - add_child(_reach_map) - _reach_map.setup(_implant_theme) - _reach_map.set_systems(_systems, _reach_node_lookup) - _reach_map.enter_system.connect(_on_reach_enter_system) - - _system_map = AtlasSystemMap.new() - _system_map.name = "SystemMap" - add_child(_system_map) - _system_map.setup(_implant_theme) - _system_map.set_systems(_systems) - _system_map.enter_body.connect(_on_system_enter_body) - - _planet_map = AtlasPlanetMap.new() - _planet_map.name = "PlanetMap" - add_child(_planet_map) - _planet_map.setup(_implant_theme) - _planet_map.back_to_viewer_body.connect(_on_planet_back_to_body) - _planet_map.economics_link_requested.connect(_on_planet_economics_link) - - -func _build_screen_header() -> void: - _screen_header = ImplantHeader.new() - _screen_header.position = Vector2(PANEL_MARGIN, 16.0) - _screen_header.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(_screen_header) - if _implant_theme: - _screen_header.apply_implant_theme(_implant_theme) - - -# ============================================================================= -# Widget signal handlers -# ============================================================================= - - -func _on_reach_enter_system(system_id: String) -> void: - var idx := _find_system_idx(system_id) - if idx >= 0: - _system_map.set_selected_idx(idx) - _system_map.load_and_show_orbital() - _show_level(Level.ORBITAL_DIAGRAM) - - -func _on_system_enter_body(body: Dictionary) -> void: - _planet_map.set_body(body, _system_map.current_system()) - _show_level(Level.BODY_ENTRY) - - -func _on_planet_back_to_body() -> void: - _show_level(Level.BODY_ENTRY) - - -func _on_planet_economics_link(system_id: String) -> void: - economics_link_requested.emit(system_id) - HudGroups.close_app() - - -func _find_system_idx(system_id: String) -> int: - for i: int in range(_systems.size()): - if _systems[i].get("system_id", "") == system_id: - return i - return -1 - - -# ============================================================================= -# Navigation -# ============================================================================= - - -func _show_level(level: Level) -> void: - _level = level - - _reach_map.visible = (level == Level.REACH_MAP) - if level == Level.REACH_MAP: - _reach_map.refresh_info_panel_visibility() - - _system_map.visible = (level == Level.SYSTEM_PICKER or level == Level.ORBITAL_DIAGRAM) - if level == Level.SYSTEM_PICKER: - _system_map.show_picker_mode() - - _planet_map.visible = (level == Level.BODY_ENTRY or level == Level.HEIGHTMAP_VIEWER) - if level == Level.BODY_ENTRY: - _planet_map.show_body_mode() - - _refresh_screen_header() - - -func _navigate_back() -> void: - match _level: - Level.REACH_MAP: - HudGroups.close_app() - Level.SYSTEM_PICKER: - _show_level(Level.REACH_MAP) - Level.ORBITAL_DIAGRAM: - _show_level(Level.REACH_MAP) - Level.BODY_ENTRY: - _show_level(Level.ORBITAL_DIAGRAM) - Level.HEIGHTMAP_VIEWER: - _show_level(Level.BODY_ENTRY) - - -func _active_widget() -> Control: - match _level: - Level.REACH_MAP: - return _reach_map - Level.SYSTEM_PICKER, Level.ORBITAL_DIAGRAM: - return _system_map - _: - return _planet_map - - -# ============================================================================= -# Input -# ============================================================================= - - -func _gui_input(event: InputEvent) -> void: - if event is InputEventKey and (event as InputEventKey).pressed and not event.is_echo(): - _handle_key(event as InputEventKey) - - -func _handle_key(event: InputEventKey) -> void: - if _level == Level.HEIGHTMAP_VIEWER: - return - match event.keycode: - KEY_ESCAPE: - _navigate_back() - KEY_B: - HudGroups.close_app() - KEY_LEFT: - if _level == Level.SYSTEM_PICKER: - _system_map.navigate_system(-1) - _refresh_screen_header() - KEY_RIGHT: - if _level == Level.SYSTEM_PICKER: - _system_map.navigate_system(1) - _refresh_screen_header() - KEY_ENTER, KEY_KP_ENTER: - if _level == Level.REACH_MAP: - _reach_map.trigger_enter() - elif _level == Level.SYSTEM_PICKER: - _system_map.load_and_show_orbital() - _show_level(Level.ORBITAL_DIAGRAM) - elif _level == Level.BODY_ENTRY: - _planet_map.show_viewer_mode() - _show_level(Level.HEIGHTMAP_VIEWER) - - -# ============================================================================= -# Screen header -# ============================================================================= - - -func _refresh_screen_header() -> void: - if _screen_header == null: - return - var title: String = "" - var hint: String = "" - match _level: - Level.REACH_MAP: - title = "THE REACH — NAVIGATOR" - hint = "Concord Assembly Gate Network · click system · enter open orbital · esc close" - Level.SYSTEM_PICKER: - title = "ATLAS — SYSTEM SELECTION" - hint = "select a system · ◄ ► cycle · enter open orbital view · esc back" - Level.ORBITAL_DIAGRAM: - var sys: Dictionary = _system_map.current_system() - var sys_name: String = str(sys.get("proper_name", sys.get("system_id", "—"))) - title = "ATLAS — ORBITAL VIEW · " + sys_name.to_upper() - var star_type: String = str(sys.get("star_type", "")) - var top_count: int = _system_map.get_orbital_body_count() - var subtitle: String = "" - if not star_type.is_empty(): - subtitle = star_type + " · " - subtitle += ( - "%d orbital bodies · %d stations" % [top_count, _system_map.get_station_count()] - ) - hint = subtitle + " · click body → atlas entry · esc back" - Level.BODY_ENTRY: - var sys2: Dictionary = _system_map.current_system() - var sys2_name: String = str(sys2.get("proper_name", sys2.get("system_id", "—"))) - title = "ATLAS — BODY ENTRY · " + sys2_name.to_upper() - hint = "enter view atlas · esc back to orbital" - Level.HEIGHTMAP_VIEWER: - title = "" - hint = "" - _screen_header.set_content(title, hint) - _screen_header.visible = (_level != Level.HEIGHTMAP_VIEWER) diff --git a/client/ui/implant/atlas_panel.tscn b/client/ui/implant/atlas_panel.tscn deleted file mode 100644 index b22f7f8f2..000000000 --- a/client/ui/implant/atlas_panel.tscn +++ /dev/null @@ -1,17 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://ui/implant/atlas_panel.gd" id="1_atlas"] - -; #844: Atlas implant panel — 5-level navigation: reach map → system picker → orbital → body entry → heightmap. -; FULLSCREEN app (z=20) at implant/map per D-170. Shell delegates to AtlasReachMap / AtlasSystemMap / AtlasPlanetMap. -; Toggle with M key from main.gd. Data from star_map_data.json. - -[node name="AtlasPanel" type="Control"] -layout_mode = 3 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 -mouse_filter = 1 -script = ExtResource("1_atlas") diff --git a/client/ui/implant/economics_panel.tscn b/client/ui/implant/economics_panel.tscn deleted file mode 100644 index 03ad3e3c0..000000000 --- a/client/ui/implant/economics_panel.tscn +++ /dev/null @@ -1,18 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://ui/implant/economics_panel.gd" id="1_econ"] - -; #824: Economics Monitor insert panel — price data and GDP for selected system. -; Composed from ImplantPanel component library (D-169). Registered under implant/economics (D-170). -; Toggle with E key in implant mode. Data flows from EconomySnapshot via snapshot_consumers. -; Placeholder commodity prices shown until server ticket #822 ships. - -[node name="EconomicsPanel" type="Control"] -layout_mode = 3 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 -mouse_filter = 1 -script = ExtResource("1_econ") diff --git a/client/ui/implant/implant_app.gd b/client/ui/implant/implant_app.gd new file mode 100644 index 000000000..75ef083c9 --- /dev/null +++ b/client/ui/implant/implant_app.gd @@ -0,0 +1,87 @@ +class_name ImplantApp +extends Control +## Base class for all implant apps. Absorbs HudGroups boilerplate; subclasses +## override lifecycle hooks only (#844, D-191). +## +## Subclass _ready() pattern: +## func _ready() -> void: +## manifest = load("res://ui/implant/apps/my_app/app.tres") +## super._ready() +## # additional init here if needed + +signal app_opened(mode: int) +signal app_closed +signal insert_deactivated + +var manifest: ImplantAppManifest = null +var nav: ImplantNavStack = null + + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_STOP + visible = false + + if manifest: + HudGroups.register(self, manifest.app_path) + HudGroups.app_changed.connect(_internal_app_changed) + + nav = ImplantNavStack.new() + nav.screen_changed.connect(_on_screen_changed) + add_child(nav) + + on_install() + + +func _internal_app_changed(app_path: String, mode: int) -> void: + if manifest == null: + return + if app_path != manifest.app_path: + if visible: + visible = false + on_close() + app_closed.emit() + return + + match mode: + HudGroups.Mode.FULLSCREEN, HudGroups.Mode.INSERT: + if not visible: + visible = true + if not manifest.preserves_state: + nav.reset_to_default() + elif nav.is_empty(): + nav.push_default() + on_open(mode) + app_opened.emit(mode) + HudGroups.Mode.GAMEPLAY: + if visible: + visible = false + on_close() + app_closed.emit() + + +# --- Lifecycle hooks — subclasses override --- + +func on_install() -> void: + pass + + +func on_open(_mode: int) -> void: + pass + + +func on_close() -> void: + pass + + +func on_insert_deactivated() -> void: + if manifest and HudGroups.is_app_active(manifest.app_path): + HudGroups.close_app() + + +func _on_screen_changed(_screen_id: String) -> void: + pass + + +func handle_intent(_action: String, _params: Dictionary) -> void: + pass diff --git a/client/ui/implant/implant_app_manifest.gd b/client/ui/implant/implant_app_manifest.gd new file mode 100644 index 000000000..4812e7e51 --- /dev/null +++ b/client/ui/implant/implant_app_manifest.gd @@ -0,0 +1,12 @@ +class_name ImplantAppManifest +extends Resource +## Manifest resource for an implant app. Each app directory ships one app.tres +## that declares identity and capabilities (#844, D-191). + +@export var app_path: String = "" +@export var display_name: String = "" +@export var icon_path: String = "" +@export var scene_path: String = "" +@export var default_mode: int = 2 # HudGroups.Mode.FULLSCREEN +@export var default_key: int = -1 # KEY_M = 77, KEY_N = 78; -1 = no binding +@export var preserves_state: bool = true diff --git a/client/ui/implant/implant_nav_stack.gd b/client/ui/implant/implant_nav_stack.gd new file mode 100644 index 000000000..1f051ad3c --- /dev/null +++ b/client/ui/implant/implant_nav_stack.gd @@ -0,0 +1,69 @@ +class_name ImplantNavStack +extends Node +## Intra-app navigation stack for ImplantApp subclasses (#844, D-191). +## Mutation is synchronous: screen_changed fires before push/pop/replace returns. + +signal screen_changed(current_screen_id: String) + +var _stack: Array[String] = [] +var _payloads: Array[Dictionary] = [] +var _default_screen_id: String = "" + + +func set_default(screen_id: String) -> void: + _default_screen_id = screen_id + + +func push(screen_id: String, payload: Dictionary = {}) -> void: + _stack.append(screen_id) + _payloads.append(payload) + screen_changed.emit(screen_id) + + +func pop() -> void: + if _stack.is_empty(): + push_warning("ImplantNavStack: pop() on empty stack") + return + _stack.pop_back() + _payloads.pop_back() + if not _stack.is_empty(): + screen_changed.emit(_stack.back()) + elif not _default_screen_id.is_empty(): + push(_default_screen_id) + + +func replace(screen_id: String, payload: Dictionary = {}) -> void: + if not _stack.is_empty(): + _stack.pop_back() + _payloads.pop_back() + _stack.append(screen_id) + _payloads.append(payload) + screen_changed.emit(screen_id) + + +func reset_to_default() -> void: + _stack.clear() + _payloads.clear() + if not _default_screen_id.is_empty(): + push(_default_screen_id) + + +func is_empty() -> bool: + return _stack.is_empty() + + +func push_default() -> void: + if not _default_screen_id.is_empty(): + push(_default_screen_id) + + +func current() -> String: + if _stack.is_empty(): + return "" + return _stack.back() + + +func current_payload() -> Dictionary: + if _payloads.is_empty(): + return {} + return _payloads.back() diff --git a/client/ui/implant/implant_registry.gd b/client/ui/implant/implant_registry.gd new file mode 100644 index 000000000..342df7620 --- /dev/null +++ b/client/ui/implant/implant_registry.gd @@ -0,0 +1,43 @@ +extends Node +## Lazy registry of installed implant apps (#844, D-191). +## Autoload — scans apps/*/app.tres on first get_manifests() call. +## Does NOT reference ImplantAppManifest class_name at load time (autoload +## parse-order rule; see CLAUDE.md). + +var _manifests: Array = [] # Array[ImplantAppManifest] +var _scanned: bool = false + + +func get_manifests() -> Array: + if not _scanned: + _scan() + return _manifests + + +func _scan() -> void: + _scanned = true + _manifests.clear() + var dir := DirAccess.open("res://ui/implant/apps") + if dir == null: + push_warning("ImplantRegistry: could not open res://ui/implant/apps") + return + dir.list_dir_begin() + var entry := dir.get_next() + while not entry.is_empty(): + if dir.current_is_dir() and not entry.begins_with("."): + var tres_path: String = "res://ui/implant/apps/%s/app.tres" % entry + if ResourceLoader.exists(tres_path): + var m = load(tres_path) + if _is_valid_manifest(m): + _manifests.append(m) + else: + push_warning("ImplantRegistry: invalid or missing app_path in %s" % tres_path) + entry = dir.get_next() + dir.list_dir_end() + + +func _is_valid_manifest(m: Variant) -> bool: + if m == null: + return false + var app_path = m.get("app_path") + return app_path != null and not (app_path as String).is_empty()