feat(ui): atlas implant panel — system picker, orbital diagram, body nav (#834)
3-level atlas navigation as an implant/map/atlas FULLSCREEN app (D-170 z=20): system picker (◄ ► cycle, Enter opens orbital) → orbital diagram (star centre, bodies grouped by orbit_index with moons sub-orbiting parents, stations as markers, click-to-open body or mini station panel) → body entry (data sheet, Enter stub for #835 heightmap viewer, Esc back). Composed from the ImplantPanel component library (D-169). Wires the panel into hud.tscn, adds an A-key toggle in main.gd, propagates insert_state via snapshot_consumers. Extends generate-star-map-data.py to emit per-system orbit_bodies + stations arrays (with currency_zone, atmosphere, population, terrain_reference, etc.) from systems.db; star_map_data.json regenerated deterministically. Per D-191 §6, Phase 3.
This commit is contained in:
+44881
-302
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
@@ -172,6 +174,10 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
class_name AtlasPanel
|
||||
extends Control
|
||||
|
||||
## Atlas implant panel — 3-level navigation: system picker → orbital diagram → body entry (#834).
|
||||
## 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 (#835), Esc back
|
||||
|
||||
enum Level { SYSTEM_PICKER = 0, ORBITAL_DIAGRAM = 1, BODY_ENTRY = 2 }
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Autoload parse-order rule: load via load() in method body, not _ready() cache
|
||||
_implant_theme = load("res://ui/implant/default_implant.tres")
|
||||
|
||||
_load_system_list()
|
||||
_build_picker_panel()
|
||||
_build_body_panel()
|
||||
_build_station_panel()
|
||||
_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:
|
||||
# Placeholder — #835 implements the heightmap viewer scene.
|
||||
# This will open it via HudGroups or a signal when #835 lands.
|
||||
push_warning("AtlasPanel: heightmap viewer not yet implemented (#835)")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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 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[b["body_id"]] = center + Vector2(cos(angle), sin(angle)) * ring_r
|
||||
|
||||
# Place moons near their parent body
|
||||
for b: Dictionary in _orbital_bodies:
|
||||
var parent_id: Variant = b.get("parent_body_id")
|
||||
if parent_id == null:
|
||||
continue
|
||||
if not _body_positions.has(str(parent_id)):
|
||||
continue
|
||||
var parent_pos: Vector2 = _body_positions[str(parent_id)]
|
||||
var moon_idx: int = int(b.get("orbit_index", 1))
|
||||
# Distribute moons around parent at evenly spaced angles
|
||||
var angle: float = -PI / 2.0 + TAU * float(moon_idx - 1) / 4.0
|
||||
_body_positions[b["body_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 parent_id: Variant = s.get("orbits_body_id")
|
||||
var parent_pos: Vector2
|
||||
if parent_id != null and _body_positions.has(str(parent_id)):
|
||||
parent_pos = _body_positions[str(parent_id)]
|
||||
else:
|
||||
parent_pos = center # fallback to star position
|
||||
_station_positions[s["station_id"]] = 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()
|
||||
|
||||
|
||||
func _draw_picker_bg() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
||||
_draw_header("ATLAS — SYSTEM SELECTION", "select a system · ◄ ► cycle · enter open orbital view · esc close")
|
||||
|
||||
|
||||
func _draw_body_bg() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
||||
var sys: Dictionary = _current_system()
|
||||
_draw_header(
|
||||
"ATLAS — BODY ENTRY · " + sys.get("proper_name", sys.get("system_id", "—")).to_upper(),
|
||||
"enter view atlas · esc back to orbital"
|
||||
)
|
||||
|
||||
|
||||
func _draw_header(title: String, hint: String) -> void:
|
||||
var font := get_theme_default_font()
|
||||
draw_string(font, Vector2(16, 28), title, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, COLOR_TEXT)
|
||||
draw_string(font, Vector2(16, 44), hint, HORIZONTAL_ALIGNMENT_LEFT, -1, 9, COLOR_TEXT_DIM)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
# Header
|
||||
var sys: Dictionary = _current_system()
|
||||
var sys_name: String = sys.get("proper_name", sys.get("system_id", "—"))
|
||||
var star_type: String = 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()]
|
||||
_draw_header(
|
||||
"ATLAS — ORBITAL VIEW · " + sys_name.to_upper(),
|
||||
subtitle + " · click body → atlas entry · esc back"
|
||||
)
|
||||
|
||||
|
||||
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 = b["body_id"]
|
||||
if 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 = s["station_id"]
|
||||
if 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:
|
||||
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)
|
||||
|
||||
|
||||
func _navigate_system(delta: int) -> void:
|
||||
if _systems.is_empty():
|
||||
return
|
||||
_selected_idx = wrapi(_selected_idx + delta, 0, _systems.size())
|
||||
_rebuild_picker_panel()
|
||||
|
||||
|
||||
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 b["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 s["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
|
||||
|
||||
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", "no body data")))
|
||||
_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 _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
|
||||
@@ -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")
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user