feat(ui): introduce ImplantApp pattern — base class, registry, atlas + economics refactor (#844, #824, #836)

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 13:16:56 +02:00
co-authored by Claude Sonnet 4.6
parent 3613b22c8f
commit df29d26d1e
26 changed files with 730 additions and 559 deletions
+1
View File
@@ -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]
-1
View File
@@ -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)
+26 -30
View File
@@ -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)
+13 -16
View File
@@ -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
+8 -14
View File
@@ -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
+13
View File
@@ -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
+204
View File
@@ -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
)
@@ -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")
@@ -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
@@ -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)
@@ -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
# =============================================================================
@@ -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)
@@ -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)
@@ -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)
+13
View File
@@ -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
@@ -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 []
@@ -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")
@@ -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()
-302
View File
@@ -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)
-17
View File
@@ -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")
-18
View File
@@ -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")
+87
View File
@@ -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
+12
View File
@@ -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
+69
View File
@@ -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()
+43
View File
@@ -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()