Merge remote-tracking branch 'origin/sprint-35/client'

This commit is contained in:
2026-04-15 09:16:44 +02:00
11 changed files with 46883 additions and 310 deletions
+13 -3
View File
@@ -7,7 +7,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client fixtures-gauntlet golden-diff golden-update \
checklist-validate checklist-generate check-star-map \
checklist-validate checklist-generate check-star-map star-map-data \
build-sr-voice run-sr-voice test-voice-mock test-voice-real \
perf-baseline debug-schedule \
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \
@@ -53,6 +53,8 @@ help:
@echo " make validate-content Validate content YAML against schemas"
@echo " make check-fact-ids Check fact_id references against knowledge catalogs"
@echo " make atlas-verify Verify atlas proposal JSONs (all in docs/atlas/proposals/)"
@echo " make star-map-data Regenerate client/data/star_map_data.json from systems.db + wiki"
@echo " make check-star-map Assert star_map_data.json is up to date (part of pre-pr-client)"
@echo " make economy-db Import economics data into systems.db (TOML/JSON → SQLite)"
@echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)"
@echo " make golden-diff Show diff if golden file output has changed"
@@ -260,7 +262,7 @@ pre-pr-build: build-server build-client
pre-pr-test: test-server test-client
@echo "--- Tests: PASS ---"
pre-pr-validate: validate-content check-fact-ids
pre-pr-validate: validate-content check-fact-ids check-star-map
@echo "--- Content validation: PASS ---"
pre-pr-fixtures:
@@ -302,7 +304,7 @@ pre-pr-fixtures:
pre-pr-server: lint-server build-server test-server pre-pr-fixtures audit
@echo "=== Server pre-PR: PASSED ==="
pre-pr-client: lint-client build-client test-client
pre-pr-client: lint-client build-client test-client check-star-map
@echo "=== Client pre-PR: PASSED ==="
pre-pr-content: validate-content check-fact-ids checklist-validate atlas-verify
@@ -375,6 +377,14 @@ checklist-generate:
check-star-map:
@python3 tooling/generate-star-map-data.py --check
# Regenerate client/data/star_map_data.json from systems.db + wiki. Depends on
# nothing — call this after any systems.db change (e.g. the server atlas
# pipeline populating terrain_reference in #839) so the atlas viewer picks up
# the new fields. pre-pr-client / pre-pr-validate assert staleness via
# check-star-map and will fail if this step is skipped.
star-map-data:
@python3 tooling/generate-star-map-data.py
perf-baseline:
@tooling/perf-baseline
File diff suppressed because it is too large Load Diff
+25 -2
View File
@@ -34,6 +34,7 @@ var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
@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)
func _ready() -> void:
@@ -93,6 +94,7 @@ func _ready() -> void:
"minimap": minimap,
"star_map": star_map,
"economics_panel": economics_panel,
"atlas_panel": atlas_panel,
},
_screen_flash
)
@@ -166,15 +168,27 @@ func _ready() -> void:
debug_console.pause_requested.connect(_dialogue.on_dialogue_pause_requested)
debug_console.unpause_requested.connect(_dialogue.on_dialogue_unpause_requested)
# #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)
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:
if star_map:
star_map.toggle_visible()
elif event is InputEventKey and event.keycode == KEY_A:
# #834: A — toggle Atlas implant panel (FULLSCREEN, implant/map/atlas)
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)
if economics_panel:
# #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/atlas"):
economics_panel.toggle_visible()
elif event is InputEventKey and event.keycode == KEY_BRACKETLEFT:
# #824: [ — cycle economics panel system selector backward
@@ -186,6 +200,15 @@ func _unhandled_key_input(event: InputEvent) -> void:
economics_panel.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:
return
economics_panel.select_system(system_id)
HudGroups.open_app("implant/economics", HudGroups.Mode.INSERT)
func _process(delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
var snapshot: Variant = SimBridge.poll_snapshot()
+4
View File
@@ -18,6 +18,7 @@ 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 _screen_flash_fn: Callable # Callable(color: Color, duration: float)
@@ -39,6 +40,7 @@ func init(refs: Dictionary, screen_flash: Callable) -> SnapshotConsumers:
minimap = refs.get("minimap")
star_map = refs.get("star_map")
economics_panel = refs.get("economics_panel")
atlas_panel = refs.get("atlas_panel")
_screen_flash_fn = screen_flash
return self
@@ -58,6 +60,8 @@ func propagate_insert_state() -> void:
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)
# D-057: Update interaction list from game state.
+7 -1
View File
@@ -1,8 +1,9 @@
[gd_scene load_steps=3 format=3 uid="uid://cq1y5w3hmxr8b"]
[gd_scene load_steps=4 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"]
[node name="HUD" type="Control"]
layout_mode = 3
@@ -25,3 +26,8 @@ visible = false
[node name="EconomicsPanel" parent="." instance=ExtResource("3_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")]
visible = false
+349
View File
@@ -0,0 +1,349 @@
class_name AtlasMarkerOverlay
extends Node2D
## Draws heightmap + markers for #835 AtlasViewer. Child of AtlasViewer._canvas
## so it inherits the pan/zoom transform. Draws in texture-native coordinates.
##
## Overlay layers (#836 toggles via viewer._overlay_visibility):
## terrain heightmap texture itself
## political_zones currency-zone color bands (coarse)
## infrastructure roads + railroads
## named_features labels for rivers, oceans, mountain ranges
## gate_markers gate-terminal POIs
## population_density (toggleable) density heatmap placeholder
## production_zones (toggleable) production zone outlines placeholder
## shadow_economy (toggleable) shadow-zone broad bands placeholder
## corp_presence (toggleable) Tier 1 corp dots placeholder
## stockpile_weeks (locked) gated by corporate contact
## production_vs_baseline (locked) gated by insider access
const COLOR_HEIGHTMAP_TINT: Color = Color(0.85, 0.88, 0.95, 1.0)
const COLOR_POLITICAL: Color = Color(0.25, 0.50, 0.75, 0.14)
const COLOR_ROAD: Color = Color(0.85, 0.60, 0.30, 0.85)
const COLOR_RAIL: Color = Color(0.45, 0.55, 0.70, 0.85)
const COLOR_CITY: Color = Color(0.94, 0.82, 0.38, 1.0)
const COLOR_CITY_HOVER: Color = Color(1.0, 1.0, 1.0, 1.0)
const COLOR_CITY_SELECTED: Color = Color(1.0, 0.95, 0.60, 1.0)
const COLOR_GATE: Color = Color(0.70, 0.88, 1.0, 1.0)
const COLOR_POI: Color = Color(0.45, 0.75, 0.85, 0.90)
const COLOR_FEATURE_LABEL: Color = Color(0.78, 0.82, 0.92, 0.65)
const COLOR_POP_HEAT: Color = Color(0.95, 0.35, 0.25, 0.22)
const COLOR_PRODUCTION: Color = Color(0.40, 0.80, 0.55, 0.18)
const COLOR_SHADOW: Color = Color(0.35, 0.20, 0.50, 0.22)
const COLOR_CORP: Color = Color(0.85, 0.65, 0.20, 0.75)
const RAIL_DASH_ON: float = 6.0
const RAIL_DASH_OFF: float = 4.0
const PRODUCTION_FUNCTIONS: Array = [
"industrial",
"industry",
"manufacturing",
"extraction",
"mining",
"refinery",
"foundry",
"shipyard",
"production",
]
var viewer = null # AtlasViewer (untyped to avoid cyclic ref)
func _draw() -> void:
if viewer == null:
return
var tex: Texture2D = viewer.get_heightmap_texture()
if tex == null:
return
var tex_w: float = float(tex.get_width())
var tex_h: float = float(tex.get_height())
var markers: Dictionary = viewer.get_markers()
# Terrain (heightmap) — always first
if viewer.is_overlay_visible("terrain"):
draw_texture_rect(
tex, Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), false, COLOR_HEIGHTMAP_TINT
)
else:
draw_rect(Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), Color(0.05, 0.07, 0.10, 1.0))
# Political zone tint (currency zone band — single tint over whole body for MVP)
if viewer.is_overlay_visible("political_zones"):
draw_rect(Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), COLOR_POLITICAL)
# Infrastructure (roads + rail)
if viewer.is_overlay_visible("infrastructure"):
_draw_roads(markers)
_draw_rails(markers)
# Named features (rivers, oceans, mountain ranges)
if viewer.is_overlay_visible("named_features"):
_draw_named_features(markers)
# Toggleable placeholders — drawn only when populated
if viewer.is_overlay_visible("population_density"):
_draw_population_density(markers)
if viewer.is_overlay_visible("production_zones"):
_draw_production_zones(markers)
if viewer.is_overlay_visible("shadow_economy"):
_draw_shadow_economy(markers)
if viewer.is_overlay_visible("corp_presence"):
_draw_corp_presence(markers)
# POIs (non-gate first, then gates on top if enabled)
_draw_pois(markers)
# Gate markers
if viewer.is_overlay_visible("gate_markers"):
_draw_gate_markers(markers)
# Cities last so labels sit on top
_draw_cities(markers)
# =============================================================================
# Feature drawing
# =============================================================================
func _draw_roads(markers: Dictionary) -> void:
var roads: Array = markers.get("roads", [])
for r: Dictionary in roads:
var path: Array = r.get("path", [])
if path.size() < 2:
continue
var points: PackedVector2Array = _path_to_canvas(path)
draw_polyline(points, COLOR_ROAD, 1.5, true)
func _draw_rails(markers: Dictionary) -> void:
var rails: Array = markers.get("railroads", [])
for r: Dictionary in rails:
var path: Array = r.get("path", [])
if path.size() < 2:
continue
var points: PackedVector2Array = _path_to_canvas(path)
_draw_dashed_polyline(points, COLOR_RAIL, 1.0)
func _draw_named_features(markers: Dictionary) -> void:
var font := ThemeDB.fallback_font
var fs: int = 8
for ocean: Dictionary in markers.get("oceans", []):
var label: String = ocean.get("name", "") if ocean.get("name") else ""
if label.is_empty():
continue
var p: Vector2 = _center_to_canvas(ocean.get("center"))
draw_string(
font, p, label.to_upper(), HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL
)
for mtn: Dictionary in markers.get("mountain_ranges", []):
var label: String = mtn.get("name", "") if mtn.get("name") else ""
if label.is_empty():
continue
var p: Vector2 = _center_to_canvas(mtn.get("center"))
draw_string(font, p, label, HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL)
for river: Dictionary in markers.get("rivers", []):
var label: String = river.get("name", "") if river.get("name") else ""
if label.is_empty():
continue
var p: Vector2 = _center_to_canvas(river.get("center"))
draw_string(font, p, label, HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL)
func _draw_pois(markers: Dictionary) -> void:
var pois: Array = markers.get("pois", [])
for p: Dictionary in pois:
if bool(p.get("gate_terminal", false)):
continue # drawn by gate_markers layer
var pos: Vector2 = _poi_pos(p)
_draw_diamond(pos, 4.0, COLOR_POI)
func _draw_gate_markers(markers: Dictionary) -> void:
# Gates show up in both pois[] (kind="gate") and cities[].gate_terminal
for p: Dictionary in markers.get("pois", []):
if not bool(p.get("gate_terminal", false)):
continue
_draw_diamond(_poi_pos(p), 6.0, COLOR_GATE)
for c: Dictionary in markers.get("cities", []):
if not bool(c.get("gate_terminal", false)):
continue
var pos: Vector2 = viewer.city_canvas_pos(c)
draw_arc(pos, 9.0, 0.0, TAU, 18, COLOR_GATE, 1.2, true)
func _draw_cities(markers: Dictionary) -> void:
var cities: Array = markers.get("cities", [])
if cities.is_empty():
return
var font := ThemeDB.fallback_font
# Compare by stable key, not Dictionary.== — deep equality was O(fields)
# per city per redraw (review #13). `name` is unique per body in the
# D-191 §8 schema; fall back to `body_id` or the hash of the dict for
# un-named markers so the comparison still works in transitional data.
var hovered_key: String = _city_key(viewer.get_hovered_city())
var selected_key: String = _city_key(viewer.get_selected_city())
for c: Dictionary in cities:
var pos: Vector2 = viewer.city_canvas_pos(c)
var tier: int = int(c.get("population_tier", 1))
var r: float = 2.5 + float(tier) * 0.8
var key: String = _city_key(c)
var is_selected: bool = not selected_key.is_empty() and key == selected_key
var is_hovered: bool = not hovered_key.is_empty() and key == hovered_key
var col: Color = COLOR_CITY
if is_selected:
col = COLOR_CITY_SELECTED
elif is_hovered:
col = COLOR_CITY_HOVER
draw_circle(pos, r + 1.0, Color(0.0, 0.0, 0.0, 0.55))
draw_circle(pos, r, col)
var name_str: String = c.get("name", "") if c.get("name") else ""
if not name_str.is_empty() and (tier >= 3 or is_hovered or is_selected):
var lcolor: Color = (
COLOR_CITY_HOVER if is_hovered or is_selected else Color(0.88, 0.90, 0.96, 0.85)
)
draw_string(
font,
pos + Vector2(r + 2.0, r * 0.4),
name_str,
HORIZONTAL_ALIGNMENT_LEFT,
-1,
8,
lcolor
)
static func _city_key(city: Dictionary) -> String:
if city.is_empty():
return ""
var name_val: Variant = city.get("name")
if name_val != null and not str(name_val).is_empty():
return "n:" + str(name_val)
var id_val: Variant = city.get("city_id")
if id_val != null and not str(id_val).is_empty():
return "i:" + str(id_val)
# Last resort: positional key from pos/lat/lon so two unnamed cities at
# different coordinates still compare unequal.
var pos: Variant = city.get("pos")
if pos != null:
return "p:" + str(pos)
if city.has("lat") and city.has("lon"):
return "ll:%s:%s" % [city["lat"], city["lon"]]
return "h:%d" % city.hash()
# =============================================================================
# Overlay placeholders (populated by server side signals eventually)
# =============================================================================
func _draw_population_density(markers: Dictionary) -> void:
# Placeholder: soft blobs at city positions scaled by population_tier
for c: Dictionary in markers.get("cities", []):
var pos: Vector2 = viewer.city_canvas_pos(c)
var tier: int = int(c.get("population_tier", 1))
var r: float = 12.0 + float(tier) * 6.0
draw_circle(pos, r, COLOR_POP_HEAT)
# Review #3: derive overlays from existing cities[] fields. The original
# code read production_zones / shadow_zones / corp_presence from keys that
# are not in the D-191 §8 markers schema, so toggling was a silent no-op.
# When the server schema lands with explicit zone polygons we can promote
# these back to dedicated arrays; until then the MVP reads what's there.
func _draw_production_zones(markers: Dictionary) -> void:
for city: Dictionary in markers.get("cities", []):
var func_id: String = str(city.get("primary_function", "")).to_lower()
if not PRODUCTION_FUNCTIONS.has(func_id):
continue
var pos: Vector2 = viewer.city_canvas_pos(city)
var tier: int = int(city.get("population_tier", 1))
var radius: float = 10.0 + float(tier) * 4.0
draw_circle(pos, radius, COLOR_PRODUCTION)
func _draw_shadow_economy(markers: Dictionary) -> void:
# Broad bands around cities outside the Commission's reach. Intentionally
# soft + overlapping — D-181 treats shadow zones as "broad bands", not
# precise polygons.
for city: Dictionary in markers.get("cities", []):
var commission: bool = bool(city.get("commission_presence", false))
if commission:
continue
var pos: Vector2 = viewer.city_canvas_pos(city)
var tier: int = int(city.get("population_tier", 1))
var radius: float = 18.0 + float(tier) * 6.0
draw_circle(pos, radius, COLOR_SHADOW)
func _draw_corp_presence(markers: Dictionary) -> void:
# Tier 1 corp presence — keyed off the Commission-presence flag on cities
# until D-191 §8 schema explicitly lists per-corp dots.
for city: Dictionary in markers.get("cities", []):
if not bool(city.get("commission_presence", false)):
continue
var pos: Vector2 = viewer.city_canvas_pos(city)
draw_rect(Rect2(pos - Vector2(3, 3), Vector2(6, 6)), COLOR_CORP)
# =============================================================================
# Helpers
# =============================================================================
func _path_to_canvas(path: Array) -> PackedVector2Array:
var out: PackedVector2Array = PackedVector2Array()
for pt: Variant in path:
if pt is Array and pt.size() >= 2:
out.append(viewer.grid_to_canvas(Vector2(float(pt[0]), float(pt[1]))))
return out
func _center_to_canvas(center: Variant) -> Vector2:
# Legacy markers.json encodes center as [row, col]
if center is Array and center.size() >= 2:
return viewer.grid_to_canvas(Vector2(float(center[1]), float(center[0])))
return Vector2.ZERO
func _poi_pos(poi: Dictionary) -> Vector2:
if poi.has("pos") and poi["pos"] is Array and poi["pos"].size() >= 2:
return viewer.grid_to_canvas(Vector2(float(poi["pos"][0]), float(poi["pos"][1])))
return _center_to_canvas(poi.get("center"))
func _draw_diamond(pos: Vector2, size: float, color: Color) -> void:
var pts: PackedVector2Array = PackedVector2Array(
[
pos + Vector2(0, -size),
pos + Vector2(size, 0),
pos + Vector2(0, size),
pos + Vector2(-size, 0),
]
)
draw_colored_polygon(pts, color)
func _draw_dashed_polyline(points: PackedVector2Array, color: Color, width: float) -> void:
if points.size() < 2:
return
# Simple per-segment dashing — good enough for the MVP.
for i: int in range(points.size() - 1):
var a: Vector2 = points[i]
var b: Vector2 = points[i + 1]
var seg: Vector2 = b - a
var seg_len: float = seg.length()
if seg_len <= 0.001:
continue
var dir: Vector2 = seg / seg_len
var t: float = 0.0
while t < seg_len:
var t_end: float = minf(t + RAIL_DASH_ON, seg_len)
draw_line(a + dir * t, a + dir * t_end, color, width, true)
t = t_end + RAIL_DASH_OFF
+77
View File
@@ -0,0 +1,77 @@
extends HBoxContainer
## Overlay toggle bar for #835 AtlasViewer — nine MVP overlays plus two locked
## layers (#836, D-191 §7, D-181 signal visibility ladder).
##
## 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.
##
## 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
## change — the bar and marker renderer stay in lockstep (review #7).
const COLOR_PINNED: Color = Color("#c8d0e0")
const COLOR_ACTIVE: Color = Color("#f0d060")
const COLOR_INACTIVE: Color = Color("#556677")
const COLOR_LOCKED: Color = Color("#3a4a55")
var _viewer = null
var _buttons: Dictionary = {} # overlay_id -> Button
func _init(viewer_ref = null) -> void:
_viewer = viewer_ref
add_theme_constant_override("separation", 4)
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
if _viewer == null:
return
for def: Dictionary in _viewer.get_overlay_defs():
var b: Button = Button.new()
b.text = def["label"]
b.tooltip_text = def["tooltip"]
b.toggle_mode = (def["group"] != "always")
b.focus_mode = Control.FOCUS_NONE
b.custom_minimum_size = Vector2(42.0, 24.0)
b.add_theme_font_size_override("font_size", 10)
match def["group"]:
"always":
# Disabled + pressed = visually pinned, no click handler churn
# (review #16). Theme font_disabled_color is overridden so the
# button still reads as "on" rather than greyed-out.
b.disabled = true
b.button_pressed = true
b.add_theme_color_override("font_disabled_color", COLOR_PINNED)
"toggle":
b.button_pressed = _viewer.is_overlay_visible(def["id"])
b.add_theme_color_override("font_color", COLOR_INACTIVE)
b.add_theme_color_override("font_pressed_color", COLOR_ACTIVE)
b.toggled.connect(_on_toggle_changed.bind(def["id"]))
"locked":
b.disabled = true
b.button_pressed = false
b.add_theme_color_override("font_disabled_color", COLOR_LOCKED)
add_child(b)
_buttons[def["id"]] = b
func _on_toggle_changed(pressed: bool, overlay_id: String) -> void:
if _viewer:
_viewer.set_overlay_visible(overlay_id, pressed)
func sync_from_viewer() -> void:
if _viewer == null:
return
for def: Dictionary in _viewer.get_overlay_defs():
if def["group"] != "toggle":
continue
var b: Button = _buttons.get(def["id"])
if b:
b.set_pressed_no_signal(_viewer.is_overlay_visible(def["id"]))
+790
View File
@@ -0,0 +1,790 @@
class_name AtlasPanel
extends Control
## Atlas implant panel — 4-level navigation (#834, #835):
## system picker → orbital diagram → body entry → regional viewer.
## FULLSCREEN implant app (z=20) at implant/map/atlas per D-170.
## Uses ImplantPanel component library (D-169). Data from star_map_data.json.
##
## Navigation:
## Level 0 SYSTEM_PICKER — ◄ ► cycle systems, Enter to open orbital view
## Level 1 ORBITAL_DIAGRAM — rendered via _draw(), click body → level 2, click station → mini panel
## Level 2 BODY_ENTRY — body info panel, Enter to open heightmap viewer, Esc back
## Level 3 HEIGHTMAP_VIEWER — AtlasViewer with pan/zoom + markers + city data (#835), Esc back
## 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 { SYSTEM_PICKER = 0, ORBITAL_DIAGRAM = 1, BODY_ENTRY = 2, HEIGHTMAP_VIEWER = 3 }
const APP_PATH := "implant/map/atlas"
const STAR_MAP_DATA := "res://data/star_map_data.json"
const PANEL_WIDTH: float = 320.0
const PANEL_MARGIN: float = 16.0
# ── Orbital diagram geometry ──────────────────────────────────────────────────
const ORBITAL_CENTER_FRACTION := Vector2(0.5, 0.55)
const STAR_RADIUS: float = 12.0
const ORBITAL_RING_BASE: float = 65.0 # innermost planet ring radius (px)
const ORBITAL_RING_STEP: float = 58.0 # radial gap between orbit rings
const MOON_ORBIT_RADIUS: float = 24.0 # sub-orbit radius for moons around parent
const STATION_SIZE: float = 6.0 # station marker half-size
const BODY_HIT_RADIUS: float = 16.0 # click/hover detection radius
const LABEL_OFFSET: float = 11.0 # px below body dot for label
# ── Colors ────────────────────────────────────────────────────────────────────
const COLOR_BG: Color = Color("#0d1117")
const COLOR_STAR: Color = Color("#f0d060")
const COLOR_PLANET_INHABITED: Color = Color("#44aa66")
const COLOR_PLANET_HABITABLE: Color = Color("#4488aa")
const COLOR_PLANET_BARE: Color = Color("#556677")
const COLOR_MOON: Color = Color("#3a4a55")
const COLOR_OORT: Color = Color("#253040")
const COLOR_STATION: Color = Color("#f0d060")
const COLOR_RING: Color = Color(1.0, 1.0, 1.0, 0.06)
const COLOR_TEXT: Color = Color("#c8d0e0")
const COLOR_TEXT_DIM: Color = Color("#667788")
# ── Navigation state ──────────────────────────────────────────────────────────
var _level: Level = Level.SYSTEM_PICKER
var _systems: Array = [] # Array[Dictionary] from star_map_data.json
var _selected_idx: int = 0 # index into _systems
var _selected_body: Dictionary = {} # body dict at Level.BODY_ENTRY
# ── Orbital diagram runtime state ─────────────────────────────────────────────
var _orbital_bodies: Array = [] # orbit_bodies for current system
var _orbital_stations: Array = [] # stations for current system
var _body_positions: Dictionary = {} # body_id -> Vector2
var _station_positions: Dictionary = {} # station_id -> Vector2
var _hovered_body: String = ""
var _hovered_station: String = ""
var _selected_station: Dictionary = {} # station clicked in orbital view
var _dirty: bool = true
# ── Visual components (D-169 ImplantPanel library) ────────────────────────────
var _implant_theme = null # ImplantTheme — loaded at runtime (autoload parse-order rule)
var _picker_panel = null # ImplantPanel — level 0 system selector
var _picker_nav_row = null # ImplantDataRow — nav hint text, updated on navigate
var _body_panel = null # ImplantPanel — level 2 body entry
var _station_panel = null # ImplantPanel — station mini, shown in level 1 on click
var _screen_header: ImplantHeader = null # D-169-composed title/hint row (top-left)
var _viewer = null # AtlasViewer — level 3 heightmap viewer (#835)
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
# 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")
_load_system_list()
_build_screen_header()
_build_picker_panel()
_build_body_panel()
_build_station_panel()
_build_heightmap_viewer()
_show_level(Level.SYSTEM_PICKER)
func _process(_delta: float) -> void:
if not visible:
return
if _dirty:
queue_redraw()
_dirty = false
## Toggle atlas panel. Called from main.gd on KEY_A.
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
_dirty = true
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
)
func _current_system() -> Dictionary:
if _systems.is_empty():
return {}
_selected_idx = clampi(_selected_idx, 0, _systems.size() - 1)
return _systems[_selected_idx]
func _enter_orbital_diagram() -> void:
var sys: Dictionary = _current_system()
_orbital_bodies = sys.get("orbit_bodies", [])
_orbital_stations = sys.get("stations", [])
_hovered_body = ""
_hovered_station = ""
_selected_station = {}
_compute_body_positions()
if _station_panel:
_station_panel.visible = false
_show_level(Level.ORBITAL_DIAGRAM)
func _enter_body_entry(body: Dictionary) -> void:
_selected_body = body
_rebuild_body_panel()
_show_level(Level.BODY_ENTRY)
func _open_heightmap_viewer() -> void:
if _viewer == null:
return
_viewer.show_body(_selected_body, _current_system())
_show_level(Level.HEIGHTMAP_VIEWER)
# =============================================================================
# Orbital geometry
# =============================================================================
func _compute_body_positions() -> void:
_body_positions.clear()
_station_positions.clear()
var sz: Vector2 = get_rect().size
# Fall back to design-time size if rect not available yet
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var center: Vector2 = sz * ORBITAL_CENTER_FRACTION
# Separate top-level bodies (no parent) from moons
var top_bodies: Array = []
for b: Dictionary in _orbital_bodies:
if b.get("parent_body_id") == null:
top_bodies.append(b)
top_bodies.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool:
return int(a.get("orbit_index", 0)) < int(b.get("orbit_index", 0))
)
# Group by orbit_index and distribute evenly on each ring
var by_orbit: Dictionary = {}
for b: Dictionary in top_bodies:
var idx: int = int(b.get("orbit_index", 0))
if not by_orbit.has(idx):
by_orbit[idx] = []
by_orbit[idx].append(b)
for orbit_idx: int in by_orbit:
var ring_bodies: Array = by_orbit[orbit_idx]
var ring_r: float = ORBITAL_RING_BASE + (orbit_idx - 1) * ORBITAL_RING_STEP
var count: int = ring_bodies.size()
for i: int in range(count):
var b: Dictionary = ring_bodies[i]
var bid: String = str(b.get("body_id", ""))
if bid.is_empty():
continue
var angle: float
if count == 1:
angle = -PI / 2.0 # top position (12 o'clock)
else:
angle = -PI / 2.0 + TAU * float(i) / float(count)
_body_positions[bid] = center + Vector2(cos(angle), sin(angle)) * ring_r
# Place moons near their parent body. Moons per parent count drives the
# angular spacing — a hard-coded divisor made the 5th+ moon overlap moon 1
# and become unclickable on gas giants with many satellites (review #1).
var moons_by_parent: Dictionary = {}
for b: Dictionary in _orbital_bodies:
var parent_id: Variant = b.get("parent_body_id")
if parent_id == null:
continue
var key: String = str(parent_id)
if not moons_by_parent.has(key):
moons_by_parent[key] = []
moons_by_parent[key].append(b)
for parent_key: String in moons_by_parent:
if not _body_positions.has(parent_key):
continue
var siblings: Array = moons_by_parent[parent_key]
siblings.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool:
return int(a.get("orbit_index", 0)) < int(b.get("orbit_index", 0))
)
var parent_pos: Vector2 = _body_positions[parent_key]
var count: int = siblings.size()
for i: int in range(count):
var moon: Dictionary = siblings[i]
var moon_id: String = str(moon.get("body_id", ""))
if moon_id.is_empty():
continue
var angle: float = -PI / 2.0 + TAU * float(i) / float(count)
_body_positions[moon_id] = (
parent_pos + Vector2(cos(angle), sin(angle)) * MOON_ORBIT_RADIUS
)
# Place stations near their parent body (offset right + slightly up)
for s: Dictionary in _orbital_stations:
var station_id: String = str(s.get("station_id", ""))
if station_id.is_empty():
continue
var parent_id: Variant = s.get("orbits_body_id")
var station_parent_pos: Vector2
if parent_id != null and _body_positions.has(str(parent_id)):
station_parent_pos = _body_positions[str(parent_id)]
else:
station_parent_pos = center # fallback to star position
_station_positions[station_id] = station_parent_pos + Vector2(20.0, -10.0)
# =============================================================================
# Drawing
# =============================================================================
func _draw() -> void:
match _level:
Level.SYSTEM_PICKER:
_draw_picker_bg()
Level.ORBITAL_DIAGRAM:
_draw_orbital()
Level.BODY_ENTRY:
_draw_body_bg()
Level.HEIGHTMAP_VIEWER:
pass # AtlasViewer draws its own background
func _draw_picker_bg() -> void:
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
func _draw_body_bg() -> void:
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
func _draw_orbital() -> void:
var sz: Vector2 = get_rect().size
var center: Vector2 = sz * ORBITAL_CENTER_FRACTION
draw_rect(Rect2(Vector2.ZERO, sz), COLOR_BG)
# Orbit rings for top-level bodies
_draw_orbit_rings(center)
# Star glow + body
draw_circle(center, STAR_RADIUS + 4.0, Color(COLOR_STAR.r, COLOR_STAR.g, COLOR_STAR.b, 0.18))
draw_circle(center, STAR_RADIUS, COLOR_STAR)
# Station markers (behind body dots)
_draw_stations()
# Body dots + labels
_draw_bodies()
func _build_screen_header() -> void:
# D-169: the top-of-screen title / hint composes from ImplantHeader so the
# implant theme drives its fonts and semantic colors. Review #4 flagged the
# original draw_string() approach as a theme-swap invariant violation.
_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)
func _refresh_screen_header() -> void:
if _screen_header == null:
return
var title: String = ""
var hint: String = ""
match _level:
Level.SYSTEM_PICKER:
title = "ATLAS — SYSTEM SELECTION"
hint = "select a system · ◄ ► cycle · enter open orbital view · esc close"
Level.ORBITAL_DIAGRAM:
var sys: Dictionary = _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 = 0
for b: Dictionary in _orbital_bodies:
if b.get("parent_body_id") == null:
top_count += 1
var subtitle: String = ""
if not star_type.is_empty():
subtitle = star_type + " · "
subtitle += "%d orbital bodies · %d stations" % [top_count, _orbital_stations.size()]
hint = subtitle + " · click body → atlas entry · esc back"
Level.BODY_ENTRY:
var sys2: Dictionary = _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:
# Viewer owns its own header while active.
title = ""
hint = ""
_screen_header.set_content(title, hint)
_screen_header.visible = (_level != Level.HEIGHTMAP_VIEWER)
func _draw_orbit_rings(center: Vector2) -> void:
# Draw one ring per unique orbit_index of top-level bodies
var seen_orbits: Dictionary = {}
for b: Dictionary in _orbital_bodies:
if b.get("parent_body_id") != null:
continue
var idx: int = int(b.get("orbit_index", 0))
if seen_orbits.has(idx):
continue
seen_orbits[idx] = true
var r: float = ORBITAL_RING_BASE + (idx - 1) * ORBITAL_RING_STEP
draw_arc(center, r, 0.0, TAU, 64, COLOR_RING, 0.5, true)
func _draw_bodies() -> void:
var font := get_theme_default_font()
for b: Dictionary in _orbital_bodies:
var bid: String = str(b.get("body_id", ""))
if bid.is_empty() or not _body_positions.has(bid):
continue
var pos: Vector2 = _body_positions[bid]
var body_type: String = b.get("body_type", "")
var atmo: String = b.get("atmosphere", "none")
var inhabited: bool = bool(b.get("inhabited", false))
var color: Color
var radius: float
match body_type:
"moon":
color = COLOR_MOON
radius = 3.5
"oort_cloud":
# Oort cloud shown as a faint dashed circle suggestion, not a dot
color = COLOR_OORT
radius = 2.0
_:
if inhabited:
color = COLOR_PLANET_INHABITED
radius = 6.0
elif atmo in ["breathable", "standard"]:
color = COLOR_PLANET_HABITABLE
radius = 5.5
else:
color = COLOR_PLANET_BARE
radius = 4.5
# Hover highlight ring
if bid == _hovered_body:
draw_arc(pos, radius + 5.0, 0.0, TAU, 20, Color(1.0, 1.0, 1.0, 0.25), 1.0, true)
draw_circle(pos, radius, color)
# Label: always for inhabited, on hover otherwise
var label: String = b.get("proper_name", bid) if b.get("proper_name") else bid
var show_label: bool = inhabited or bid == _hovered_body
if show_label:
var lcolor: Color = COLOR_TEXT if bid == _hovered_body else COLOR_TEXT_DIM
var lsz: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 9)
var lpos: Vector2 = pos + Vector2(-lsz.x / 2.0, radius + LABEL_OFFSET)
draw_string(font, lpos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 9, lcolor)
func _draw_stations() -> void:
var font := get_theme_default_font()
for s: Dictionary in _orbital_stations:
var sid: String = str(s.get("station_id", ""))
if sid.is_empty() or not _station_positions.has(sid):
continue
var pos: Vector2 = _station_positions[sid]
var half: float = STATION_SIZE / 2.0
var rect: Rect2 = Rect2(pos - Vector2(half, half), Vector2(STATION_SIZE, STATION_SIZE))
if sid == _hovered_station:
draw_rect(
Rect2(rect.position - Vector2(3, 3), rect.size + Vector2(6, 6)),
Color(1.0, 1.0, 1.0, 0.18)
)
draw_rect(rect, COLOR_STATION)
# Label on hover
if sid == _hovered_station:
var label: String = s.get("proper_name", sid) if s.get("proper_name") else sid
var lsz: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 9)
draw_string(
font,
pos + Vector2(-lsz.x / 2.0, half + 8.0),
label,
HORIZONTAL_ALIGNMENT_LEFT,
-1,
9,
COLOR_TEXT_DIM
)
# =============================================================================
# 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)
elif _level == Level.ORBITAL_DIAGRAM:
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
_handle_orbital_click((event as InputEventMouseButton).position)
elif event is InputEventMouseMotion:
_handle_orbital_hover((event as InputEventMouseMotion).position)
func _handle_key(event: InputEventKey) -> void:
if _level == Level.HEIGHTMAP_VIEWER:
# Viewer handles its own input via _gui_input; don't double-process.
return
match event.keycode:
KEY_ESCAPE:
_navigate_back()
KEY_B:
HudGroups.close_app()
KEY_LEFT:
if _level == Level.SYSTEM_PICKER:
_navigate_system(-1)
KEY_RIGHT:
if _level == Level.SYSTEM_PICKER:
_navigate_system(1)
KEY_ENTER, KEY_KP_ENTER:
if _level == Level.SYSTEM_PICKER:
_enter_orbital_diagram()
elif _level == Level.BODY_ENTRY:
_open_heightmap_viewer()
func _navigate_back() -> void:
match _level:
Level.SYSTEM_PICKER:
HudGroups.close_app()
Level.ORBITAL_DIAGRAM:
_show_level(Level.SYSTEM_PICKER)
Level.BODY_ENTRY:
_show_level(Level.ORBITAL_DIAGRAM)
Level.HEIGHTMAP_VIEWER:
_show_level(Level.BODY_ENTRY)
func _navigate_system(delta: int) -> void:
if _systems.is_empty():
return
_selected_idx = wrapi(_selected_idx + delta, 0, _systems.size())
_rebuild_picker_panel()
_refresh_screen_header()
func _handle_orbital_click(pos: Vector2) -> void:
# Bodies take priority over stations
var bid: String = _find_nearest_body(pos)
if not bid.is_empty():
for b: Dictionary in _orbital_bodies:
if str(b.get("body_id", "")) == bid:
_enter_body_entry(b)
return
# Station click → show mini panel (no drill-down per D-191)
var sid: String = _find_nearest_station(pos)
if not sid.is_empty():
for s: Dictionary in _orbital_stations:
if str(s.get("station_id", "")) == sid:
_selected_station = s
_rebuild_station_panel()
if _station_panel:
_station_panel.visible = true
return
# Click on empty space — dismiss station panel
_selected_station = {}
if _station_panel:
_station_panel.visible = false
_dirty = true
func _handle_orbital_hover(pos: Vector2) -> void:
var new_body: String = _find_nearest_body(pos)
var new_station: String = "" if not new_body.is_empty() else _find_nearest_station(pos)
if new_body != _hovered_body or new_station != _hovered_station:
_hovered_body = new_body
_hovered_station = new_station
_dirty = true
func _find_nearest_body(pos: Vector2) -> String:
var best_dist: float = BODY_HIT_RADIUS
var best_id: String = ""
for bid: String in _body_positions:
var d: float = pos.distance_to(_body_positions[bid])
if d < best_dist:
best_dist = d
best_id = bid
return best_id
func _find_nearest_station(pos: Vector2) -> String:
var best_dist: float = BODY_HIT_RADIUS
var best_id: String = ""
for sid: String in _station_positions:
var d: float = pos.distance_to(_station_positions[sid])
if d < best_dist:
best_dist = d
best_id = sid
return best_id
# =============================================================================
# Level switching — show/hide panels
# =============================================================================
func _show_level(level: Level) -> void:
_level = level
_dirty = true
if _picker_panel:
_picker_panel.visible = (level == Level.SYSTEM_PICKER)
if _body_panel:
_body_panel.visible = (level == Level.BODY_ENTRY)
# Station panel managed separately — remains hidden until a station click
if _station_panel and level != Level.ORBITAL_DIAGRAM:
_station_panel.visible = false
if _viewer:
_viewer.visible = (level == Level.HEIGHTMAP_VIEWER)
_refresh_screen_header()
queue_redraw()
# =============================================================================
# Panel construction — D-169 ImplantPanel component library
# =============================================================================
func _build_picker_panel() -> void:
_picker_panel = ImplantPanel.new()
_picker_panel.name = "PickerPanel"
_picker_panel.theme_resource = _implant_theme
_picker_panel.custom_minimum_size.x = PANEL_WIDTH
_picker_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_picker_panel.position = Vector2(PANEL_MARGIN, 60.0)
add_child(_picker_panel)
_rebuild_picker_panel()
func _rebuild_picker_panel() -> void:
if not _picker_panel:
return
_picker_panel.clear()
var sys: Dictionary = _current_system()
var sys_name: String = sys.get("proper_name", sys.get("system_id", ""))
var sys_id: String = sys.get("system_id", "")
var sector: String = sys.get("geographic_sector", "").replace("_", " ").to_upper()
var czone: String = sys.get("currency_zone", "").replace("_", " ")
var total: int = _systems.size()
_picker_panel.add_component(ImplantHeader.new("ATLAS", sys_name))
_picker_panel.add_component(ImplantSeparator.new())
_picker_nav_row = ImplantDataRow.new("◄ ► [%d / %d]" % [_selected_idx + 1, total])
_picker_panel.add_component(_picker_nav_row)
_picker_panel.add_component(ImplantSeparator.new())
_picker_panel.add_component(ImplantDataRow.new(sys_id))
if not sector.is_empty():
_picker_panel.add_component(ImplantDataRow.new(sector + " CORRIDOR"))
if not czone.is_empty():
_picker_panel.add_component(ImplantDataRow.new("currency " + czone))
_picker_panel.add_component(ImplantDataRow.new(sys.get("bodies", "")))
_picker_panel.add_component(ImplantDataRow.new("pop " + sys.get("population", "")))
_picker_panel.add_component(ImplantSeparator.new())
_picker_panel.add_component(ImplantTextBlock.new("enter open orbital map"))
_picker_panel.add_component(ImplantTextBlock.new("esc close atlas"))
_dirty = true
func _build_body_panel() -> void:
_body_panel = ImplantPanel.new()
_body_panel.name = "BodyPanel"
_body_panel.theme_resource = _implant_theme
_body_panel.custom_minimum_size.x = PANEL_WIDTH
_body_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_body_panel.position = Vector2(PANEL_MARGIN, 60.0)
_body_panel.visible = false
add_child(_body_panel)
func _rebuild_body_panel() -> void:
if not _body_panel:
return
_body_panel.clear()
var b: Dictionary = _selected_body
var bid: String = b.get("body_id", "")
var name_str: String = b.get("proper_name", "") if b.get("proper_name") else bid
var body_type: String = b.get("body_type", "").replace("_", " ").to_upper()
var mass_class: String = b.get("mass_class", "") if b.get("mass_class") else ""
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: Dictionary = _current_system()
var sys_name: String = sys.get("proper_name", sys.get("system_id", ""))
_body_panel.add_component(ImplantHeader.new(name_str, sys_name + " system"))
_body_panel.add_component(ImplantSeparator.new())
var type_line: String = body_type
if not mass_class.is_empty():
type_line += " · " + mass_class.replace("_", " ").to_upper()
_body_panel.add_component(ImplantDataRow.new(type_line))
_body_panel.add_component(ImplantDataRow.new("atmosphere " + atmo))
if inhabited:
_body_panel.add_component(ImplantSeparator.new())
_body_panel.add_component(ImplantDataRow.new("population " + _format_pop(pop)))
_body_panel.add_component(ImplantSeparator.new())
if has_heightmap:
_body_panel.add_component(ImplantTextBlock.new("enter view heightmap atlas"))
else:
_body_panel.add_component(ImplantTextBlock.new("atlas data pending (#839)"))
_body_panel.add_component(ImplantTextBlock.new("esc back to orbital view"))
func _build_station_panel() -> void:
_station_panel = ImplantPanel.new()
_station_panel.name = "StationPanel"
_station_panel.theme_resource = _implant_theme
_station_panel.custom_minimum_size.x = PANEL_WIDTH
_station_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_station_panel.position = Vector2(PANEL_MARGIN, 60.0)
_station_panel.visible = false
add_child(_station_panel)
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:
_show_level(Level.BODY_ENTRY)
func _on_viewer_economics_link(system_id: String) -> void:
economics_link_requested.emit(system_id)
# Close the atlas; main.gd will open economics monitor in its place.
HudGroups.close_app()
func _rebuild_station_panel() -> void:
if not _station_panel:
return
_station_panel.clear()
if _selected_station.is_empty():
return
var s: Dictionary = _selected_station
var s_name: String = (
s.get("proper_name", "") if s.get("proper_name") else s.get("station_id", "")
)
var s_type: String = s.get("station_type", "").replace("_", " ").to_upper()
var gov: String = s.get("governance_type", "") if s.get("governance_type") else ""
var role: String = s.get("economic_role", "") if s.get("economic_role") else ""
var sys: Dictionary = _current_system()
var czone: String = sys.get("currency_zone", "") if sys.get("currency_zone") else ""
czone = czone.replace("_", " ")
_station_panel.add_component(ImplantHeader.new(s_name, s_type + " STATION"))
_station_panel.add_component(ImplantSeparator.new())
if not gov.is_empty():
_station_panel.add_component(
ImplantDataRow.new("operator " + gov.replace("_", " ").to_upper())
)
if not role.is_empty():
_station_panel.add_component(ImplantDataRow.new("function " + role.replace("_", " ")))
_station_panel.add_component(ImplantDataRow.new("currency " + czone))
_station_panel.add_component(ImplantSeparator.new())
_station_panel.add_component(ImplantTextBlock.new("station atlas deferred"))
_station_panel.add_component(ImplantTextBlock.new("esc back"))
# =============================================================================
# Helpers
# =============================================================================
func _format_pop(pop: int) -> String:
if pop <= 0:
return "0"
var s: String = str(pop)
var result: String = ""
var count: int = 0
for i: int in range(s.length() - 1, -1, -1):
if count > 0 and count % 3 == 0:
result = "," + result
result = s[i] + result
count += 1
return result
+18
View File
@@ -0,0 +1,18 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/implant/atlas_panel.gd" id="1_atlas"]
; #834: Atlas implant panel — 3-level navigation: system picker → orbital diagram → body entry.
; FULLSCREEN app (z=20) at implant/map/atlas per D-170.
; Composed from ImplantPanel component library (D-169). Toggle with A key from main.gd.
; Data from star_map_data.json (orbit_bodies + stations arrays added by generate-star-map-data.py).
[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")
+663
View File
@@ -0,0 +1,663 @@
class_name AtlasViewer
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.
##
## Design notes:
## - Heightmap texture is drawn on a Node2D _canvas child. Pan = _canvas.position,
## zoom = _canvas.scale. MarkerOverlay is a child of _canvas so markers auto-
## follow the same transform.
## - markers.json schema (D-191 §8): cities, roads, railroads, pois, plus rivers,
## oceans, mountain_ranges with `center: [row, col]` and optional names.
## - Empty markers case (server #832/#833 not yet shipped): bare heightmap renders
## fine, no sidebar opens, overlays draw nothing.
## - Overlays (#836) plug into _overlay_visibility dict and _draw_overlays().
##
## Navigation:
## Mouse drag pan the map
## Mouse wheel zoom in / out (centered on cursor)
## Click city open city data panel
## R reset view
## Esc back to body entry
signal back_pressed
signal economics_link_requested(system_id: String)
const MIN_ZOOM: float = 0.5
const MAX_ZOOM: float = 8.0
const ZOOM_STEP: float = 1.15
const PANEL_WIDTH: float = 320.0
const PANEL_MARGIN: float = 16.0
# ── Colors ────────────────────────────────────────────────────────────────────
const COLOR_BG: Color = Color("#0d1117")
const COLOR_HEIGHTMAP_TINT: Color = Color(0.85, 0.88, 0.95, 1.0)
const COLOR_CITY: Color = Color("#f0d060")
const COLOR_CITY_HOVER: Color = Color("#ffffff")
const COLOR_CITY_LABEL: Color = Color("#c8d0e0")
const COLOR_ROAD: Color = Color("#d89040")
const COLOR_RAIL: Color = Color("#7888a0")
const COLOR_POI: Color = Color("#70c0d8")
const COLOR_FEATURE_LABEL: Color = Color(0.78, 0.82, 0.92, 0.65)
const COLOR_GATE_MARKER: Color = Color("#b0e0ff")
const COLOR_POLITICAL_ZONE: Color = Color(0.25, 0.45, 0.65, 0.12)
const COLOR_TEXT: Color = Color("#c8d0e0")
const COLOR_TEXT_DIM: Color = Color("#667788")
const COLOR_EMPTY_NOTICE: Color = Color("#445566")
# ── Overlay definitions (single source of truth, review #7) ─────────────────
## Overlay catalogue consumed by both AtlasMarkerOverlay (renders) and
## AtlasOverlayBar (exposes as toggle buttons). Groups map to the D-181 signal
## visibility ladder: always = public, toggle = observable, locked = semi-
## private / private. Keep this table in sync with D-191 §7.
const OVERLAY_DEFS: Array = [
{
"id": "terrain",
"label": "TER",
"group": "always",
"tooltip": "Terrain — base heightmap. Always visible."
},
{
"id": "infrastructure",
"label": "INF",
"group": "always",
"tooltip": "Infrastructure — roads + rail. Always visible."
},
{
"id": "named_features",
"label": "NAM",
"group": "always",
"tooltip": "Named features — rivers, oceans, ranges. Always visible."
},
{
"id": "gate_markers",
"label": "GAT",
"group": "always",
"tooltip": "Gate + spaceport markers. Always visible."
},
{
"id": "political_zones",
"label": "POL",
"group": "always",
"tooltip": "Political zones — currency-zone bands. Always visible."
},
{
"id": "population_density",
"label": "POP",
"group": "toggle",
"tooltip": "Population density (D-181 public)."
},
{
"id": "production_zones",
"label": "PRD",
"group": "toggle",
"tooltip": "Production zones (D-181 observable — requires presence)."
},
{
"id": "shadow_economy",
"label": "SHD",
"group": "toggle",
"tooltip": "Shadow economy zones (D-181 observable)."
},
{
"id": "corp_presence",
"label": "CRP",
"group": "toggle",
"tooltip": "Corporate presence — Tier 1 only (D-181 observable)."
},
{
"id": "stockpile_weeks",
"label": "STK",
"group": "locked",
"tooltip": "Stockpile weeks — LOCKED. Needs corporate contact (D-181 semi-private)."
},
{
"id": "production_vs_baseline",
"label": "BSL",
"group": "locked",
"tooltip": "Production vs baseline — LOCKED. Needs insider access (D-181 private)."
},
]
# ── Context (set by AtlasPanel.show_body) ─────────────────────────────────────
var _body: Dictionary = {}
var _system: Dictionary = {}
var _implant_theme = null
# ── Heightmap + markers ───────────────────────────────────────────────────────
var _heightmap_texture: Texture2D = null
var _markers: Dictionary = {}
var _grid_w: float = 512.0
var _grid_h: float = 256.0
var _tex_w: float = 1024.0
var _tex_h: float = 512.0
# ── Pan/zoom state ────────────────────────────────────────────────────────────
var _view_offset: Vector2 = Vector2.ZERO
var _view_zoom: float = 1.0
var _dragging: bool = false
var _drag_start_mouse: Vector2
var _drag_start_offset: Vector2
# ── Selection ─────────────────────────────────────────────────────────────────
var _selected_city: Dictionary = {}
var _hovered_city: Dictionary = {}
# ── Overlay visibility (#836 plugs in here) ───────────────────────────────────
## Runtime state derived from OVERLAY_DEFS in _ready() — always-on overlays
## start true, toggleables false, locked entries are tracked separately and
## can't be flipped by set_overlay_visible().
var _overlay_visibility: Dictionary = {}
var _overlay_locked: Dictionary = {}
# ── Child nodes ───────────────────────────────────────────────────────────────
var _canvas: Node2D = null # transformed node holding heightmap + markers
var _overlay_node: AtlasMarkerOverlay = null # draws on top of heightmap
var _city_panel = null # ImplantPanel sidebar (city data)
var _empty_notice = null # ImplantPanel shown when heightmap missing
var _overlay_bar = null # #836 overlay toggle bar (no class_name, review #8)
var _screen_header: ImplantHeader = null # top-left title/hint (D-169 composition)
func _ready() -> void:
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = Control.GROW_DIRECTION_BOTH
grow_vertical = Control.GROW_DIRECTION_BOTH
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_ALL
_implant_theme = load("res://ui/implant/default_implant.tres")
for def: Dictionary in OVERLAY_DEFS:
var group: String = def["group"]
_overlay_visibility[def["id"]] = (group == "always")
if group == "locked":
_overlay_locked[def["id"]] = true
# Build transformed canvas that holds heightmap + marker overlay
_canvas = Node2D.new()
_canvas.name = "AtlasCanvas"
add_child(_canvas)
_overlay_node = AtlasMarkerOverlay.new()
_overlay_node.name = "MarkerOverlay"
_overlay_node.viewer = self
_canvas.add_child(_overlay_node)
_build_screen_header()
_build_city_panel()
_build_empty_notice()
_build_overlay_bar()
## Called by AtlasPanel when entering the viewer for a specific body.
func show_body(body: Dictionary, system: Dictionary) -> void:
_body = body
_system = system
_selected_city = {}
_hovered_city = {}
_load_heightmap()
_load_markers()
_fit_to_view()
_refresh_screen_header()
_city_panel.visible = false
_empty_notice.visible = (_heightmap_texture == null)
grab_focus()
queue_redraw()
_overlay_node.queue_redraw()
## #836 hook — toggle an overlay. Locked overlays are no-ops.
func set_overlay_visible(overlay_id: String, visible_state: bool) -> void:
if _overlay_locked.get(overlay_id, false):
return
if not _overlay_visibility.has(overlay_id):
push_warning("AtlasViewer: unknown overlay id '%s'" % overlay_id)
return
_overlay_visibility[overlay_id] = visible_state
_overlay_node.queue_redraw()
func is_overlay_visible(overlay_id: String) -> bool:
return bool(_overlay_visibility.get(overlay_id, false))
func is_overlay_locked(overlay_id: String) -> bool:
return bool(_overlay_locked.get(overlay_id, false))
func get_heightmap_texture() -> Texture2D:
return _heightmap_texture
func get_markers() -> Dictionary:
return _markers
func get_hovered_city() -> Dictionary:
return _hovered_city
func get_selected_city() -> Dictionary:
return _selected_city
func get_overlay_defs() -> Array:
return OVERLAY_DEFS
# =============================================================================
# Data loading
# =============================================================================
func _load_heightmap() -> void:
# Reset to sentinel defaults so a body with no heightmap (or that fails to
# load) doesn't inherit the previous body's dimensions — otherwise
# grid_to_canvas would project markers using the prior texture size
# (review #2).
_heightmap_texture = null
_tex_w = 1024.0
_tex_h = 512.0
var ref: Variant = _body.get("terrain_reference")
if ref == null or str(ref).is_empty():
return
var path: String = str(ref)
if not path.begins_with("res://"):
path = "res://" + path.lstrip("/")
if not ResourceLoader.exists(path):
push_warning("AtlasViewer: terrain_reference not found at %s" % path)
return
var tex: Variant = load(path)
if tex is Texture2D:
_heightmap_texture = tex
_tex_w = float(_heightmap_texture.get_width())
_tex_h = float(_heightmap_texture.get_height())
func _load_markers() -> void:
_markers = {}
# Reset grid dims alongside _tex_* in _load_heightmap so we start from a
# known baseline regardless of which body ran previously.
_grid_w = _tex_w
_grid_h = _tex_h
var ref: Variant = _body.get("terrain_reference")
if ref == null or str(ref).is_empty():
return
var hm_path: String = str(ref)
if not hm_path.begins_with("res://"):
hm_path = "res://" + hm_path.lstrip("/")
var dir: String = hm_path.get_base_dir()
var markers_path: String = dir + "/markers.json"
if not FileAccess.file_exists(markers_path):
return
var f := FileAccess.open(markers_path, FileAccess.READ)
if f == null:
return
var parsed: Variant = JSON.parse_string(f.get_as_text())
f.close()
if not (parsed is Dictionary):
return
_markers = parsed
var grid: Dictionary = _markers.get("grid", {})
_grid_w = float(grid.get("w", _tex_w))
_grid_h = float(grid.get("h", _tex_h))
# =============================================================================
# View transform
# =============================================================================
func _fit_to_view() -> void:
if _heightmap_texture == null:
_view_zoom = 1.0
_view_offset = Vector2.ZERO
_canvas.position = _view_offset
_canvas.scale = Vector2(_view_zoom, _view_zoom)
return
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var avail: Vector2 = sz - Vector2(0, 60) # leave space for header
var fit_x: float = avail.x / _tex_w
var fit_y: float = avail.y / _tex_h
_view_zoom = clampf(minf(fit_x, fit_y) * 0.92, MIN_ZOOM, MAX_ZOOM)
var scaled: Vector2 = Vector2(_tex_w, _tex_h) * _view_zoom
_view_offset = (sz - scaled) * 0.5 + Vector2(0, 20)
_apply_transform()
func _apply_transform() -> void:
_canvas.position = _view_offset
_canvas.scale = Vector2(_view_zoom, _view_zoom)
queue_redraw()
_overlay_node.queue_redraw()
func _zoom_at(mouse_pos: Vector2, factor: float) -> void:
var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM)
if is_equal_approx(new_zoom, _view_zoom):
return
# Keep the texture point under cursor fixed while zooming
var local_before: Vector2 = (mouse_pos - _view_offset) / _view_zoom
_view_zoom = new_zoom
_view_offset = mouse_pos - local_before * _view_zoom
_apply_transform()
## Convert grid coordinates (from markers.json) to canvas-space (texture pixels).
func grid_to_canvas(grid_point: Vector2) -> Vector2:
if _grid_w <= 0.0 or _grid_h <= 0.0:
return Vector2.ZERO
return Vector2(grid_point.x / _grid_w * _tex_w, grid_point.y / _grid_h * _tex_h)
func canvas_to_screen(canvas_point: Vector2) -> Vector2:
return canvas_point * _view_zoom + _view_offset
func screen_to_canvas(screen_point: Vector2) -> Vector2:
if _view_zoom == 0.0:
return Vector2.ZERO
return (screen_point - _view_offset) / _view_zoom
# =============================================================================
# Drawing (background + header)
# =============================================================================
func _draw() -> void:
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
func _build_screen_header() -> void:
# D-169: compose the title + hint from ImplantHeader so the implant theme
# drives fonts/colors. Review #4 flagged the draw_string() approach as a
# theme-swap invariant violation.
_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)
func _refresh_screen_header() -> void:
if _screen_header == null:
return
var body_name: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", ""))
var sys_name: String = _dict_str(_system, "proper_name", _dict_str(_system, "system_id", ""))
var title: String = "ATLAS — %s · %s" % [body_name.to_upper(), sys_name.to_upper()]
var hint: String = "drag pan · wheel zoom · r reset · click city data · esc back"
_screen_header.set_content(title, hint)
## Safely extract a string field from a dict, falling back when missing or null.
static func _dict_str(d: Dictionary, key: String, fallback: String) -> String:
var v: Variant = d.get(key)
if v == null:
return fallback
var s: String = str(v)
if s.is_empty():
return fallback
return s
# =============================================================================
# Input
# =============================================================================
func _gui_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.is_echo():
_handle_key(event as InputEventKey)
return
if _heightmap_texture == null:
return
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
_zoom_at(mb.position, ZOOM_STEP)
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
_zoom_at(mb.position, 1.0 / ZOOM_STEP)
elif mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
if not _try_click_city(mb.position):
_dragging = true
_drag_start_mouse = mb.position
_drag_start_offset = _view_offset
else:
_dragging = false
elif event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion
if _dragging:
_view_offset = _drag_start_offset + (mm.position - _drag_start_mouse)
_apply_transform()
else:
_update_hover(mm.position)
func _handle_key(event: InputEventKey) -> void:
match event.keycode:
KEY_ESCAPE:
if _selected_city.size() > 0:
_selected_city = {}
_city_panel.visible = false
queue_redraw()
else:
back_pressed.emit()
KEY_R:
_fit_to_view()
func _try_click_city(screen_pos: Vector2) -> bool:
var city: Dictionary = _find_city_at(screen_pos)
if city.is_empty():
return false
_selected_city = city
_rebuild_city_panel()
_city_panel.visible = true
queue_redraw()
return true
func _update_hover(screen_pos: Vector2) -> void:
var new_hover: Dictionary = _find_city_at(screen_pos)
if new_hover != _hovered_city:
_hovered_city = new_hover
_overlay_node.queue_redraw()
func _find_city_at(screen_pos: Vector2) -> Dictionary:
var cities: Array = _markers.get("cities", [])
if cities.is_empty():
return {}
var hit_radius: float = 12.0
for c: Dictionary in cities:
var canvas_pt: Vector2 = city_canvas_pos(c)
var screen_pt: Vector2 = canvas_to_screen(canvas_pt)
if screen_pos.distance_to(screen_pt) <= hit_radius:
return c
return {}
## Extract canvas-space position for a city marker. Supports `pos: [x,y]`,
## `lat`/`lon` (equirectangular), and legacy `center: [row,col]`. Public so
## AtlasMarkerOverlay doesn't need to reach into private state (review #5).
func city_canvas_pos(city: Dictionary) -> Vector2:
if city.has("pos") and city["pos"] is Array and city["pos"].size() >= 2:
return grid_to_canvas(Vector2(float(city["pos"][0]), float(city["pos"][1])))
if city.has("lat") and city.has("lon"):
var lon: float = float(city["lon"])
var lat: float = float(city["lat"])
return Vector2((lon + 180.0) / 360.0 * _tex_w, (90.0 - lat) / 180.0 * _tex_h)
if city.has("center") and city["center"] is Array and city["center"].size() >= 2:
# center is [row, col] in grid space
return grid_to_canvas(Vector2(float(city["center"][1]), float(city["center"][0])))
return Vector2.ZERO
# =============================================================================
# City data panel (sidebar)
# =============================================================================
func _build_city_panel() -> void:
_city_panel = ImplantPanel.new()
_city_panel.name = "CityPanel"
_city_panel.theme_resource = _implant_theme
_city_panel.custom_minimum_size.x = PANEL_WIDTH
_city_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_city_panel.visible = false
add_child(_city_panel)
_position_city_panel()
func _position_city_panel() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
_city_panel.position = Vector2(sz.x - PANEL_WIDTH - PANEL_MARGIN, 60.0)
func _rebuild_city_panel() -> void:
if not _city_panel:
return
_city_panel.clear()
_position_city_panel()
if _selected_city.is_empty():
return
var c: Dictionary = _selected_city
var name_str: String = _dict_str(c, "name", "(unnamed)")
var tier: int = int(c.get("population_tier", 0))
var func_label: String = _dict_str(c, "primary_function", "").replace("_", " ")
var is_gate: bool = bool(c.get("gate_terminal", false))
var sys_label: String = _dict_str(_system, "proper_name", _dict_str(_system, "system_id", ""))
var body_label: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", ""))
_city_panel.add_component(ImplantHeader.new(name_str, sys_label + " · " + body_label))
_city_panel.add_component(ImplantSeparator.new())
if tier > 0:
_city_panel.add_component(ImplantDataRow.new("pop tier %d" % tier))
if not func_label.is_empty():
_city_panel.add_component(ImplantDataRow.new("function " + func_label))
if is_gate:
_city_panel.add_component(ImplantDataRow.new("gate terminal"))
# Cross-system facts
var czone: String = str(_system.get("currency_zone", "")).replace("_", " ")
if not czone.is_empty():
_city_panel.add_component(ImplantDataRow.new("currency " + czone))
var commission: bool = bool(c.get("commission_presence", false))
_city_panel.add_component(
ImplantDataRow.new("commission " + ("yes" if commission else "no"))
)
var shadow: Variant = c.get("shadow_economy_zone")
if shadow != null and not str(shadow).is_empty():
_city_panel.add_component(ImplantDataRow.new("shadow zone " + str(shadow)))
var gate_dist: Variant = c.get("gate_distance_hops")
if gate_dist != null:
_city_panel.add_component(ImplantDataRow.new("gate distance %d hops" % int(gate_dist)))
_city_panel.add_component(ImplantSeparator.new())
_city_panel.add_component(ImplantTextBlock.new("n open economics monitor for this system"))
_city_panel.add_component(ImplantTextBlock.new("esc close"))
func _unhandled_key_input(event: InputEvent) -> void:
if not visible:
return
if not (event is InputEventKey) or not event.pressed or event.is_echo():
return
var ek := event as InputEventKey
if ek.keycode != KEY_N:
return
# Always consume KEY_N while the viewer is visible — otherwise it would
# fall through to main.gd's global economics monitor toggle and close the
# fullscreen atlas as a side effect (review #6).
get_viewport().set_input_as_handled()
if _selected_city.size() > 0:
var sys_id: String = _system.get("system_id", "")
if not sys_id.is_empty():
economics_link_requested.emit(sys_id)
# =============================================================================
# Empty-state notice
# =============================================================================
func _build_empty_notice() -> void:
_empty_notice = ImplantPanel.new()
_empty_notice.name = "EmptyNotice"
_empty_notice.theme_resource = _implant_theme
_empty_notice.custom_minimum_size.x = 400.0
_empty_notice.mouse_filter = Control.MOUSE_FILTER_IGNORE
_empty_notice.visible = false
add_child(_empty_notice)
_empty_notice.add_component(
ImplantHeader.new("TERRAIN DATA PENDING", "regional atlas unavailable")
)
_empty_notice.add_component(ImplantSeparator.new())
var notice_text: String = (
"The server atlas pipeline has not yet populated terrain_reference for this body (#839). "
+ "Once regenerated, the heightmap and markers will render here."
)
_empty_notice.add_component(ImplantTextBlock.new(notice_text))
_empty_notice.add_component(ImplantSeparator.new())
_empty_notice.add_component(ImplantTextBlock.new("esc back to orbital view"))
_position_empty_notice()
func _position_empty_notice() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
_empty_notice.position = Vector2((sz.x - 400.0) * 0.5, sz.y * 0.35)
# =============================================================================
# Overlay toggle bar (#836)
# =============================================================================
func _build_overlay_bar() -> void:
var BarScript := load("res://ui/implant/atlas_overlay_bar.gd")
_overlay_bar = BarScript.new(self)
_overlay_bar.name = "OverlayBar"
add_child(_overlay_bar)
_position_overlay_bar()
func _position_overlay_bar() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
# Top-right, leaving room for the header text
var bar_w: float = _overlay_bar.size.x if _overlay_bar.size.x > 0.0 else 520.0
_overlay_bar.position = Vector2(sz.x - bar_w - PANEL_MARGIN, PANEL_MARGIN)
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED:
if _city_panel:
_position_city_panel()
if _empty_notice:
_position_empty_notice()
if _overlay_bar:
_position_overlay_bar()
+56 -2
View File
@@ -164,7 +164,7 @@ def generate() -> dict:
cur = conn.cursor()
cur.execute(
"SELECT system_id, proper_name, geographic_sector, geographic_band "
"SELECT system_id, proper_name, geographic_sector, geographic_band, currency_zone "
"FROM star_systems"
)
db_lookup = {row["system_id"]: dict(row) for row in cur.fetchall()}
@@ -192,6 +192,50 @@ def generate() -> dict:
# Economic tier for GDP calculation
cur.execute("SELECT system_id, economic_tier FROM system_economy")
econ_tiers = {row["system_id"]: row["economic_tier"] for row in cur.fetchall()}
# Per-system body list for atlas orbital diagram (D-191 §6)
cur.execute("""
SELECT system_id, body_id, parent_body_id, orbit_index, body_type,
proper_name, atmosphere, inhabited, population,
terrain_reference, mass_class
FROM bodies
ORDER BY system_id, orbit_index
""")
orbit_bodies_by_system: dict = {}
for row in cur.fetchall():
sid_ = row["system_id"]
orbit_bodies_by_system.setdefault(sid_, []).append({
"body_id": row["body_id"],
"parent_body_id": row["parent_body_id"],
"orbit_index": row["orbit_index"],
"body_type": row["body_type"],
"proper_name": row["proper_name"],
"atmosphere": row["atmosphere"],
"inhabited": bool(row["inhabited"]),
"population": int(row["population"] or 0),
"terrain_reference": row["terrain_reference"],
"mass_class": row["mass_class"],
})
# Per-system station list for atlas orbital diagram (D-191 §6)
cur.execute("""
SELECT system_id, station_id, orbits_body_id, station_type,
proper_name, population, governance_type, economic_role
FROM stations
ORDER BY system_id
""")
stations_by_system: dict = {}
for row in cur.fetchall():
sid_ = row["system_id"]
stations_by_system.setdefault(sid_, []).append({
"station_id": row["station_id"],
"orbits_body_id": row["orbits_body_id"],
"station_type": row["station_type"],
"proper_name": row["proper_name"],
"population": int(row["population"] or 0),
"governance_type": row["governance_type"],
"economic_role": row["economic_role"],
})
except sqlite3.Error as e:
print(f"ERROR: systems.db query failed: {e}", file=sys.stderr)
sys.exit(1)
@@ -227,6 +271,11 @@ def generate() -> dict:
if wiki["star_type"]:
entry["star_type"] = wiki["star_type"]
# Currency zone from star_systems
cz = db.get("currency_zone", "")
if cz:
entry["currency_zone"] = cz
# Bodies + population from systems.db (authoritative, not wiki)
bs = body_stats.get(sid, {})
ss = station_stats.get(sid, {})
@@ -245,6 +294,11 @@ def generate() -> dict:
entry["gttr_excerpt"] = gttr
if n.get("is_gateway"):
entry["is_gateway"] = True
# Atlas orbital diagram data (D-191 §6) — per-body and per-station arrays
entry["orbit_bodies"] = orbit_bodies_by_system.get(sid, [])
entry["stations"] = stations_by_system.get(sid, [])
nodes.append(entry)
nodes.sort(key=lambda x: (x["hop_distance"], x["system_id"]))
@@ -257,7 +311,7 @@ def generate() -> dict:
"generated_from": "star-map.json + systems.db + wiki/star-systems",
"system_count": len(nodes),
"edge_count": len(star_map["edges"]),
"note": "Client-side star map data. Regenerate with: tooling/generate-star-map-data.py",
"note": "Client-side star map + atlas orbital data. Regenerate with: tooling/generate-star-map-data.py",
},
"nodes": nodes,
"edges": star_map["edges"],