From 03060ff2e121406f34f8030f8248784beefa5e41 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 19 Apr 2026 12:27:27 +0200 Subject: [PATCH] refactor(ui): split atlas_panel.gd into 4 sub-widgets under 500 lines each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit atlas_panel.gd exceeded gdlint's 1000-line limit after the REACH_MAP level was added in #844. Split into: - atlas_panel.gd (298 lines) — shell: HudGroups reg, level enum, nav, key handler, screen header - atlas_reach_map.gd (494) — Level 0 REACH_MAP hop-ring view; emits enter_system - atlas_system_map.gd (495) — Level 1/2 SYSTEM_PICKER + ORBITAL_DIAGRAM; emits enter_body - atlas_planet_map.gd (146) — Level 3/4 BODY_ENTRY + HEIGHTMAP_VIEWER; emits back_to_viewer_body Shell owns all level transitions. Sub-widgets emit signals, never call show_level(). All four pass gdlint with no warnings. Co-Authored-By: Claude Sonnet 4.6 --- client/ui/implant/atlas_panel.gd | 1256 +++---------------------- client/ui/implant/atlas_panel.tscn | 7 +- client/ui/implant/atlas_planet_map.gd | 146 +++ client/ui/implant/atlas_reach_map.gd | 494 ++++++++++ client/ui/implant/atlas_system_map.gd | 495 ++++++++++ 5 files changed, 1289 insertions(+), 1109 deletions(-) create mode 100644 client/ui/implant/atlas_planet_map.gd create mode 100644 client/ui/implant/atlas_reach_map.gd create mode 100644 client/ui/implant/atlas_system_map.gd diff --git a/client/ui/implant/atlas_panel.gd b/client/ui/implant/atlas_panel.gd index cb0988ce4..5e626fcec 100644 --- a/client/ui/implant/atlas_panel.gd +++ b/client/ui/implant/atlas_panel.gd @@ -1,17 +1,16 @@ class_name AtlasPanel extends Control - ## Atlas implant panel — unified 5-level nav chain (#844, D-191): ## reach map → system picker → orbital diagram → body entry → regional viewer. ## FULLSCREEN implant app (z=20) at implant/map per D-170. ## Uses ImplantPanel component library (D-169). Data from star_map_data.json. ## ## Navigation: -## Level 4 REACH_MAP — hop-ring view of the Reach gate network, click system → orbital -## Level 0 SYSTEM_PICKER — ◄ ► cycle systems alphabetically, 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 +## Level 0 REACH_MAP — hop-ring view of the Reach gate network (AtlasReachMap) +## Level 1 SYSTEM_PICKER — ◄ ► cycle systems alphabetically (AtlasSystemMap) +## Level 2 ORBITAL_DIAGRAM — rendered via AtlasSystemMap._draw() +## Level 3 BODY_ENTRY — body info panel (AtlasPlanetMap) +## Level 4 HEIGHTMAP_VIEWER — AtlasViewer with pan/zoom + markers (AtlasPlanetMap) ## Emitted when the viewer's city-data panel requests the economics monitor for ## the current system. main.gd bridges this to EconomicsPanel.select_system() @@ -22,117 +21,17 @@ enum Level { REACH_MAP = 0, SYSTEM_PICKER = 1, ORBITAL_DIAGRAM = 2, BODY_ENTRY = const APP_PATH := "implant/map" 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") - -# ── REACH_MAP (hop-ring) constants ──────────────────────────────────────────── -const REACH_MAP_CENTER_FRACTION := Vector2(0.5, 0.5) -const REACH_MIN_RING_RADIUS: float = 30.0 -const REACH_RING_SPACING: float = 22.0 -const REACH_MAX_HOP_RINGS: int = 24 -const REACH_DOT_RADIUS_HUB: float = 4.5 -const REACH_DOT_RADIUS_JUNCTION: float = 3.5 -const REACH_DOT_RADIUS_DEFAULT: float = 2.5 -const REACH_DOT_RADIUS_DEAD_END: float = 2.0 -const REACH_GATEWAY_RADIUS: float = 6.0 -const REACH_SELECTION_RING_RADIUS: float = 8.0 -const REACH_HIT_RADIUS: float = 10.0 -const REACH_EDGE_WIDTH: float = 0.8 -const REACH_EDGE_SELECTED_ALPHA: float = 0.55 -const REACH_POPUP_WIDTH: float = 300.0 -const REACH_POPUP_MARGIN: float = 16.0 -const REACH_POPUP_GTTR_MAX_LINES: int = 7 -const REACH_COLOR_RING: Color = Color("#1a2030") -const REACH_COLOR_RING_MAJOR: Color = Color("#222a3a") -const REACH_COLOR_GATEWAY: Color = Color("#f0d060") -const REACH_COLOR_SELECTION: Color = Color("#f0d060") -const REACH_SECTOR_COLORS: Dictionary = { - "core": Color("#c8d0e0"), - "north_reach": Color("#4488aa"), - "south_reach": Color("#aa6644"), - "east_reach": Color("#44aa66"), - "west_reach": Color("#aa8844"), - "deep_frontier": Color("#556677"), - "unknown": Color("#445566"), -} -const REACH_SECTOR_LABELS: Dictionary = { - "north_reach": "NORTH REACH", - "south_reach": "SOUTH REACH", - "east_reach": "EAST REACH", - "west_reach": "WEST REACH", -} -const REACH_SECTOR_ANGLE_CENTER: Dictionary = { - "north_reach": -PI / 2.0, - "east_reach": 0.0, - "south_reach": PI / 2.0, - "west_reach": PI, -} -const REACH_SECTOR_ANGLE_SPREAD: float = PI / 2.5 -const REACH_CORE_ANGLE_SPREAD: float = TAU -const REACH_DEEP_FRONTIER_ANGLE_SPREAD: float = TAU -const REACH_ZOOM_MIN: float = 0.3 -const REACH_ZOOM_MAX: float = 3.0 -const REACH_ZOOM_STEP: float = 0.15 - -# ── Navigation state ────────────────────────────────────────────────────────── var _level: Level = Level.REACH_MAP -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 +var _systems: Array = [] +var _reach_node_lookup: Dictionary = {} -# ── 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) - -# ── REACH_MAP state ─────────────────────────────────────────────────────────── -var _reach_positions: Dictionary = {} # system_id -> Vector2 (relative to map center) -var _reach_node_lookup: Dictionary = {} # system_id -> node dict -var _reach_zoom: float = 1.0 -var _reach_pan: Vector2 = Vector2.ZERO -var _reach_is_panning: bool = false -var _reach_pan_start: Vector2 = Vector2.ZERO -var _reach_pan_start_offset: Vector2 = Vector2.ZERO -var _reach_selected: String = "" -var _reach_hovered: String = "" -var _reach_info_panel = null # ImplantPanel — selected system details +var _implant_theme = null # loaded at runtime (autoload parse-order rule) +var _screen_header: ImplantHeader = null +var _reach_map: AtlasReachMap = null +var _system_map: AtlasSystemMap = null +var _planet_map: AtlasPlanetMap = null func _ready() -> void: @@ -143,31 +42,17 @@ func _ready() -> void: 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_widgets() _build_screen_header() - _build_picker_panel() - _build_body_panel() - _build_station_panel() - _build_heightmap_viewer() - _build_reach_info_panel() - _compute_reach_layout() _show_level(Level.REACH_MAP) -func _process(_delta: float) -> void: - if not visible: - return - if _dirty: - queue_redraw() - _dirty = false - - ## Toggle atlas panel. Called from main.gd on KEY_M. func toggle_visible() -> void: HudGroups.toggle_app(APP_PATH, HudGroups.Mode.FULLSCREEN) @@ -184,7 +69,7 @@ func _on_app_changed(app_path: String, mode: int) -> void: return if mode == HudGroups.Mode.FULLSCREEN: visible = true - _dirty = true + _active_widget().queue_redraw() else: visible = false @@ -218,185 +103,35 @@ func _load_system_list() -> void: _reach_node_lookup[node.get("system_id", "")] = node -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 +# Widget construction # ============================================================================= -func _compute_body_positions() -> void: - _body_positions.clear() - _station_positions.clear() +func _build_widgets() -> void: + _reach_map = AtlasReachMap.new() + _reach_map.name = "ReachMap" + add_child(_reach_map) + _reach_map.setup(_implant_theme) + _reach_map.set_systems(_systems, _reach_node_lookup) + _reach_map.enter_system.connect(_on_reach_enter_system) - 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 + _system_map = AtlasSystemMap.new() + _system_map.name = "SystemMap" + add_child(_system_map) + _system_map.setup(_implant_theme) + _system_map.set_systems(_systems) + _system_map.enter_body.connect(_on_system_enter_body) - # 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.REACH_MAP: - _draw_reach() - 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() + _planet_map = AtlasPlanetMap.new() + _planet_map.name = "PlanetMap" + add_child(_planet_map) + _planet_map.setup(_implant_theme) + _planet_map.back_to_viewer_body.connect(_on_planet_back_to_body) + _planet_map.economics_link_requested.connect(_on_planet_economics_link) func _build_screen_header() -> void: - # 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 @@ -405,179 +140,61 @@ func _build_screen_header() -> void: _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.REACH_MAP: - title = "THE REACH — NAVIGATOR" - hint = "Concord Assembly Gate Network · click system · enter open orbital · esc close" - Level.SYSTEM_PICKER: - title = "ATLAS — SYSTEM SELECTION" - hint = "select a system · ◄ ► cycle · enter open orbital view · esc back" - Level.ORBITAL_DIAGRAM: - var sys: Dictionary = _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 +# Widget signal handlers # ============================================================================= -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.REACH_MAP: - _handle_reach_input(event) - 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 _on_reach_enter_system(system_id: String) -> void: + var idx := _find_system_idx(system_id) + if idx >= 0: + _system_map.set_selected_idx(idx) + _system_map.load_and_show_orbital() + _show_level(Level.ORBITAL_DIAGRAM) -func _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.REACH_MAP: - _reach_enter_selected() - elif _level == Level.SYSTEM_PICKER: - _enter_orbital_diagram() - elif _level == Level.BODY_ENTRY: - _open_heightmap_viewer() +func _on_system_enter_body(body: Dictionary) -> void: + _planet_map.set_body(body, _system_map.current_system()) + _show_level(Level.BODY_ENTRY) + + +func _on_planet_back_to_body() -> void: + _show_level(Level.BODY_ENTRY) + + +func _on_planet_economics_link(system_id: String) -> void: + economics_link_requested.emit(system_id) + HudGroups.close_app() + + +func _find_system_idx(system_id: String) -> int: + for i: int in range(_systems.size()): + if _systems[i].get("system_id", "") == system_id: + return i + return -1 + + +# ============================================================================= +# Navigation +# ============================================================================= + + +func _show_level(level: Level) -> void: + _level = level + + _reach_map.visible = (level == Level.REACH_MAP) + if level == Level.REACH_MAP: + _reach_map.refresh_info_panel_visibility() + + _system_map.visible = (level == Level.SYSTEM_PICKER or level == Level.ORBITAL_DIAGRAM) + if level == Level.SYSTEM_PICKER: + _system_map.show_picker_mode() + + _planet_map.visible = (level == Level.BODY_ENTRY or level == Level.HEIGHTMAP_VIEWER) + if level == Level.BODY_ENTRY: + _planet_map.show_body_mode() + + _refresh_screen_header() func _navigate_back() -> void: @@ -594,659 +211,88 @@ func _navigate_back() -> void: _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) - if _reach_info_panel: - _reach_info_panel.visible = (level == Level.REACH_MAP and _reach_selected != "") - - _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 - - -# ============================================================================= -# REACH_MAP — hop-ring view (D-191: Reach map is Level 0 of the atlas hierarchy) -# ============================================================================= - - -func _build_reach_info_panel() -> void: - _reach_info_panel = ImplantPanel.new() - _reach_info_panel.name = "ReachInfoPanel" - _reach_info_panel.theme_resource = _implant_theme - _reach_info_panel.custom_minimum_size.x = REACH_POPUP_WIDTH - _reach_info_panel.size.x = REACH_POPUP_WIDTH - _reach_info_panel.visible = false - _reach_info_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(_reach_info_panel) - - -func _rebuild_reach_info_panel() -> void: - if not _reach_info_panel: - return - _reach_info_panel.clear() - - var node: Dictionary = _reach_node_lookup.get(_reach_selected, {}) - if node.is_empty(): - _reach_info_panel.visible = false - return - - var sys_name: String = node.get("proper_name", "") - if sys_name.is_empty(): - sys_name = node.get("system_id", "Unknown") - _reach_info_panel.add_component(ImplantHeader.new(sys_name, node.get("system_id", ""))) - _reach_info_panel.add_component(ImplantSeparator.new()) - - var star_type: String = node.get("star_type", "") - if not star_type.is_empty(): - _reach_info_panel.add_component(ImplantDataRow.new(star_type + " star")) - - var sector_str: String = node.get("geographic_sector", "unknown").replace("_", " ").to_upper() - var hop: int = int(node.get("hop_distance", 0)) - var sector_color: Color = REACH_SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM) - _reach_info_panel.add_component( - ImplantDataRow.new("%s corridor (hop %d)" % [sector_str, hop], sector_color) - ) - - var bodies: String = node.get("bodies", "") - if not bodies.is_empty(): - _reach_info_panel.add_component(ImplantDataRow.new(bodies)) - - var population: String = node.get("population", "") - var gdp: String = node.get("gdp", "") - if not population.is_empty() or not gdp.is_empty(): - _reach_info_panel.add_component(ImplantDataRow.new("")) - if not population.is_empty(): - _reach_info_panel.add_component(ImplantDataRow.new("pop " + population)) - var gdp_label: String = "gdp " + (gdp if not gdp.is_empty() else "—") - _reach_info_panel.add_component(ImplantDataRow.new(gdp_label)) - - var gttr: String = node.get("gttr_excerpt", "") - if not gttr.is_empty(): - _reach_info_panel.add_component(ImplantSeparator.new()) - _reach_info_panel.add_component(ImplantTextBlock.new(gttr, REACH_POPUP_GTTR_MAX_LINES)) - - _reach_info_panel.add_component(ImplantSeparator.new()) - _reach_info_panel.add_component(ImplantTextBlock.new("enter open orbital map")) - - _reach_info_panel.visible = true - - -func _compute_reach_layout() -> void: - _reach_positions.clear() - - var rings: Dictionary = {} - for node: Dictionary in _systems: - var hop: int = int(node.get("hop_distance", 0)) - if not rings.has(hop): - rings[hop] = [] - rings[hop].append(node) - - for hop: int in rings: - var ring_nodes: Array = rings[hop] - var radius: float = REACH_MIN_RING_RADIUS + hop * REACH_RING_SPACING - - if hop == 0: - for node: Dictionary in ring_nodes: - _reach_positions[node["system_id"]] = Vector2.ZERO - continue - - ring_nodes.sort_custom(_reach_sort_by_sector_angle) - - var sector_groups: Dictionary = {} - for node: Dictionary in ring_nodes: - var sector: String = node.get("geographic_sector", "unknown") - if not sector_groups.has(sector): - sector_groups[sector] = [] - sector_groups[sector].append(node) - - for sector: String in sector_groups: - var group: Array = sector_groups[sector] - var count: int = group.size() - var center_angle: float - var spread: float - if sector == "core": - center_angle = 0.0 - spread = REACH_CORE_ANGLE_SPREAD - elif sector == "deep_frontier": - center_angle = 0.0 - spread = REACH_DEEP_FRONTIER_ANGLE_SPREAD - elif REACH_SECTOR_ANGLE_CENTER.has(sector): - center_angle = REACH_SECTOR_ANGLE_CENTER[sector] - spread = REACH_SECTOR_ANGLE_SPREAD - else: - center_angle = 0.0 - spread = TAU - - for i: int in range(count): - var node: Dictionary = group[i] - var t: float = 0.0 if count == 1 else float(i) / float(count) - 0.5 - var angle: float = center_angle + t * spread - var jitter: float = _reach_system_hash(node["system_id"]) * 0.08 - angle += jitter - var r_var: float = ( - radius + _reach_system_hash(node["system_id"] + "r") * REACH_RING_SPACING * 0.3 - ) - _reach_positions[node["system_id"]] = Vector2(cos(angle), sin(angle)) * r_var - - -func _reach_sort_by_sector_angle(a: Dictionary, b: Dictionary) -> bool: - var sa: float = _reach_sector_sort_key(a) - var sb: float = _reach_sector_sort_key(b) - if sa != sb: - return sa < sb - return a.get("system_id", "") < b.get("system_id", "") - - -func _reach_sector_sort_key(node: Dictionary) -> float: - match node.get("geographic_sector", "unknown"): - "core": - return 0.0 - "north_reach": - return 1.0 - "east_reach": - return 2.0 - "south_reach": - return 3.0 - "west_reach": - return 4.0 - "deep_frontier": - return 5.0 +func _active_widget() -> Control: + match _level: + Level.REACH_MAP: + return _reach_map + Level.SYSTEM_PICKER, Level.ORBITAL_DIAGRAM: + return _system_map _: - return 6.0 # gdlint:ignore = max-returns - - -func _reach_system_hash(key: String) -> float: - var h: int = key.hash() & 0x7FFFFFFF - return float(h) / 2147483647.0 * 2.0 - 1.0 - - -func _reach_dot_radius(topology: String) -> float: - match topology: - "hub": - return REACH_DOT_RADIUS_HUB - "junction": - return REACH_DOT_RADIUS_JUNCTION - "dead_end": - return REACH_DOT_RADIUS_DEAD_END - _: - return REACH_DOT_RADIUS_DEFAULT # gdlint:ignore = max-returns + return _planet_map # ============================================================================= -# REACH_MAP drawing +# Input # ============================================================================= -func _draw_reach() -> void: - var sz: Vector2 = get_rect().size - var center: Vector2 = sz * REACH_MAP_CENTER_FRACTION + _reach_pan - - draw_rect(Rect2(Vector2.ZERO, sz), COLOR_BG) - _draw_reach_rings(center) - _draw_reach_sector_labels(center) - - if _reach_selected != "": - _draw_reach_edges(center) - - _draw_reach_systems(center) - - if _reach_selected != "": - _draw_reach_selection(center) - - # Position info panel in top-right corner - if _reach_info_panel and _reach_info_panel.visible: - _reach_info_panel.reset_size() - var px: float = sz.x - REACH_POPUP_WIDTH - REACH_POPUP_MARGIN - var py: float = REACH_POPUP_MARGIN - var panel_h: float = _reach_info_panel.size.y - if panel_h > 0.0 and py + panel_h > sz.y - REACH_POPUP_MARGIN: - py = sz.y - panel_h - REACH_POPUP_MARGIN - px = maxf(REACH_POPUP_MARGIN, px) - py = maxf(REACH_POPUP_MARGIN, py) - _reach_info_panel.position = Vector2(px, py) +func _gui_input(event: InputEvent) -> void: + if event is InputEventKey and (event as InputEventKey).pressed and not event.is_echo(): + _handle_key(event as InputEventKey) -func _draw_reach_rings(center: Vector2) -> void: - for hop: int in range(REACH_MAX_HOP_RINGS + 1): - var radius: float = (REACH_MIN_RING_RADIUS + hop * REACH_RING_SPACING) * _reach_zoom - if radius < 1.0 or radius > 2000.0: - continue - var color: Color = REACH_COLOR_RING_MAJOR if hop % 5 == 0 else REACH_COLOR_RING - draw_arc(center, radius, 0.0, TAU, 64, color, 0.5 if hop % 5 == 0 else 0.3, true) - - -func _draw_reach_sector_labels(center: Vector2) -> void: - var label_radius: float = (REACH_MIN_RING_RADIUS + 12 * REACH_RING_SPACING) * _reach_zoom - for sector: String in REACH_SECTOR_LABELS: - var angle: float = REACH_SECTOR_ANGLE_CENTER.get(sector, 0.0) - var pos: Vector2 = center + Vector2(cos(angle), sin(angle)) * label_radius - var label: String = REACH_SECTOR_LABELS[sector] - var color: Color = REACH_SECTOR_COLORS.get(sector, COLOR_TEXT_DIM) - var font := get_theme_default_font() - var font_size: int = 10 - var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) - draw_string(font, pos - text_size / 2.0, label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color) - - -func _draw_reach_systems(center: Vector2) -> void: - for node: Dictionary in _systems: - var sid: String = node.get("system_id", "") - if not _reach_positions.has(sid): - continue - var pos: Vector2 = center + _reach_positions[sid] * _reach_zoom - var sector: String = node.get("geographic_sector", "unknown") - var topology: String = node.get("gate_topology", "") - var color: Color = REACH_SECTOR_COLORS.get(sector, COLOR_TEXT_DIM) - var radius: float = _reach_dot_radius(topology) - - if node.get("is_gateway", false): - color = REACH_COLOR_GATEWAY - radius = REACH_GATEWAY_RADIUS - - if sector == "deep_frontier": - color.a = 0.7 - - if sid == _reach_hovered and sid != _reach_selected: - draw_arc( - pos, radius + 3.0, 0.0, TAU, 16, Color(color.r, color.g, color.b, 0.4), 1.0, true - ) - - draw_circle(pos, radius, color) - - var label: String = node.get("proper_name", "") - if not label.is_empty() and label != sid: - var show_label := false - if sid == _reach_selected or sid == _reach_hovered: - show_label = true - elif _reach_zoom >= 2.0: - show_label = true - elif _reach_zoom >= 1.2: - show_label = topology in ["hub", "junction", ""] - if show_label: - var label_color: Color = ( - COLOR_TEXT if sid == _reach_selected or sid == _reach_hovered else COLOR_TEXT_DIM - ) - var font := get_theme_default_font() - var label_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 9) - draw_string( - font, - pos + Vector2(-label_size.x / 2.0, radius + 10.0), - label, - HORIZONTAL_ALIGNMENT_LEFT, - -1, - 9, - label_color, - ) - - -func _draw_reach_edges(center: Vector2) -> void: - var node: Dictionary = _reach_node_lookup.get(_reach_selected, {}) - var adj: Array = node.get("adjacent_systems", []) - if adj.is_empty(): +func _handle_key(event: InputEventKey) -> void: + if _level == Level.HEIGHTMAP_VIEWER: return - var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, REACH_EDGE_SELECTED_ALPHA) - var sel_pos: Vector2 = ( - center + _reach_positions.get(_reach_selected, Vector2.ZERO) * _reach_zoom - ) - for neighbor_id: String in adj: - if not _reach_positions.has(neighbor_id): - continue - var neighbor_pos: Vector2 = center + _reach_positions[neighbor_id] * _reach_zoom - draw_line(sel_pos, neighbor_pos, color, REACH_EDGE_WIDTH, true) - - -func _draw_reach_selection(center: Vector2) -> void: - if not _reach_positions.has(_reach_selected): - return - var pos: Vector2 = center + _reach_positions[_reach_selected] * _reach_zoom - draw_arc(pos, REACH_SELECTION_RING_RADIUS, 0.0, TAU, 24, REACH_COLOR_SELECTION, 1.2, true) + match event.keycode: + KEY_ESCAPE: + _navigate_back() + KEY_B: + HudGroups.close_app() + KEY_LEFT: + if _level == Level.SYSTEM_PICKER: + _system_map.navigate_system(-1) + _refresh_screen_header() + KEY_RIGHT: + if _level == Level.SYSTEM_PICKER: + _system_map.navigate_system(1) + _refresh_screen_header() + KEY_ENTER, KEY_KP_ENTER: + if _level == Level.REACH_MAP: + _reach_map.trigger_enter() + elif _level == Level.SYSTEM_PICKER: + _system_map.load_and_show_orbital() + _show_level(Level.ORBITAL_DIAGRAM) + elif _level == Level.BODY_ENTRY: + _planet_map.show_viewer_mode() + _show_level(Level.HEIGHTMAP_VIEWER) # ============================================================================= -# REACH_MAP input +# Screen header # ============================================================================= -func _handle_reach_input(event: InputEvent) -> void: - if event is InputEventMouseButton: - var mb := event as InputEventMouseButton - if mb.pressed: - match mb.button_index: - MOUSE_BUTTON_LEFT: - _handle_reach_click(mb.position) - MOUSE_BUTTON_MIDDLE: - _reach_is_panning = true - _reach_pan_start = mb.position - _reach_pan_start_offset = _reach_pan - MOUSE_BUTTON_WHEEL_UP: - var old_zoom := _reach_zoom - _reach_zoom = clampf(_reach_zoom + REACH_ZOOM_STEP, REACH_ZOOM_MIN, REACH_ZOOM_MAX) - if _reach_zoom != old_zoom: - _dirty = true - MOUSE_BUTTON_WHEEL_DOWN: - var old_zoom := _reach_zoom - _reach_zoom = clampf(_reach_zoom - REACH_ZOOM_STEP, REACH_ZOOM_MIN, REACH_ZOOM_MAX) - if _reach_zoom != old_zoom: - _dirty = true - else: - if mb.button_index == MOUSE_BUTTON_MIDDLE: - _reach_is_panning = false - elif event is InputEventMouseMotion: - var mm := event as InputEventMouseMotion - if _reach_is_panning: - _reach_pan = _reach_pan_start_offset + (mm.position - _reach_pan_start) - _dirty = true - else: - _update_reach_hover(mm.position) - - -func _handle_reach_click(pos: Vector2) -> void: - var sid := _find_nearest_reach_system(pos) - _reach_selected = sid - _rebuild_reach_info_panel() - _dirty = true - - -func _update_reach_hover(pos: Vector2) -> void: - var nearest := _find_nearest_reach_system(pos) - if nearest != _reach_hovered: - _reach_hovered = nearest - _dirty = true - - -func _find_nearest_reach_system(pos: Vector2) -> String: - var sz: Vector2 = get_rect().size - var center: Vector2 = sz * REACH_MAP_CENTER_FRACTION + _reach_pan - var best_dist: float = REACH_HIT_RADIUS - var best_sid: String = "" - for node: Dictionary in _systems: - var sid: String = node.get("system_id", "") - if not _reach_positions.has(sid): - continue - var node_pos: Vector2 = center + _reach_positions[sid] * _reach_zoom - var dist: float = pos.distance_to(node_pos) - if dist < best_dist: - best_dist = dist - best_sid = sid - return best_sid - - -func _reach_system_index(system_id: String) -> int: - for i: int in range(_systems.size()): - if _systems[i].get("system_id", "") == system_id: - return i - return -1 - - -func _reach_enter_selected() -> void: - if _reach_selected.is_empty(): +func _refresh_screen_header() -> void: + if _screen_header == null: return - var idx := _reach_system_index(_reach_selected) - if idx >= 0: - _selected_idx = idx - _enter_orbital_diagram() + var title: String = "" + var hint: String = "" + match _level: + Level.REACH_MAP: + title = "THE REACH — NAVIGATOR" + hint = "Concord Assembly Gate Network · click system · enter open orbital · esc close" + Level.SYSTEM_PICKER: + title = "ATLAS — SYSTEM SELECTION" + hint = "select a system · ◄ ► cycle · enter open orbital view · esc back" + Level.ORBITAL_DIAGRAM: + var sys: Dictionary = _system_map.current_system() + var sys_name: String = str(sys.get("proper_name", sys.get("system_id", "—"))) + title = "ATLAS — ORBITAL VIEW · " + sys_name.to_upper() + var star_type: String = str(sys.get("star_type", "")) + var top_count: int = _system_map.get_orbital_body_count() + var subtitle: String = "" + if not star_type.is_empty(): + subtitle = star_type + " · " + subtitle += "%d orbital bodies · %d stations" % [top_count, _system_map.get_station_count()] + hint = subtitle + " · click body → atlas entry · esc back" + Level.BODY_ENTRY: + var sys2: Dictionary = _system_map.current_system() + var sys2_name: String = str(sys2.get("proper_name", sys2.get("system_id", "—"))) + title = "ATLAS — BODY ENTRY · " + sys2_name.to_upper() + hint = "enter view atlas · esc back to orbital" + Level.HEIGHTMAP_VIEWER: + title = "" + hint = "" + _screen_header.set_content(title, hint) + _screen_header.visible = (_level != Level.HEIGHTMAP_VIEWER) diff --git a/client/ui/implant/atlas_panel.tscn b/client/ui/implant/atlas_panel.tscn index 555a4d53c..b22f7f8f2 100644 --- a/client/ui/implant/atlas_panel.tscn +++ b/client/ui/implant/atlas_panel.tscn @@ -2,10 +2,9 @@ [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). +; #844: Atlas implant panel — 5-level navigation: reach map → system picker → orbital → body entry → heightmap. +; FULLSCREEN app (z=20) at implant/map per D-170. Shell delegates to AtlasReachMap / AtlasSystemMap / AtlasPlanetMap. +; Toggle with M key from main.gd. Data from star_map_data.json. [node name="AtlasPanel" type="Control"] layout_mode = 3 diff --git a/client/ui/implant/atlas_planet_map.gd b/client/ui/implant/atlas_planet_map.gd new file mode 100644 index 000000000..694aec504 --- /dev/null +++ b/client/ui/implant/atlas_planet_map.gd @@ -0,0 +1,146 @@ +class_name AtlasPlanetMap +extends Control +## Level 3 BODY_ENTRY and Level 4 HEIGHTMAP_VIEWER widget for AtlasPanel (#844, D-191). +## Shows body detail panel and delegates heightmap rendering to AtlasViewer. + +signal back_to_viewer_body() +signal economics_link_requested(system_id: String) + +const PANEL_WIDTH: float = 320.0 +const PANEL_MARGIN: float = 16.0 +const COLOR_BG: Color = Color("#0d1117") + +var _selected_body: Dictionary = {} +var _current_sys: Dictionary = {} +var _body_panel = null # ImplantPanel +var _viewer = null # AtlasViewer + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + + +func _draw() -> void: + if _viewer == null or not _viewer.visible: + draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) + + +func setup(implant_theme) -> void: + _build_body_panel(implant_theme) + _build_heightmap_viewer() + + +func set_body(body: Dictionary, system: Dictionary) -> void: + _selected_body = body + _current_sys = system + _rebuild_body_panel() + + +func show_body_mode() -> void: + if _body_panel: + _body_panel.visible = true + if _viewer: + _viewer.visible = false + queue_redraw() + + +func show_viewer_mode() -> void: + if _viewer == null: + return + _viewer.show_body(_selected_body, _current_sys) + if _body_panel: + _body_panel.visible = false + _viewer.visible = true + + +# ============================================================================= +# Panels +# ============================================================================= + + +func _build_body_panel(implant_theme) -> 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_name: String = _current_sys.get("proper_name", _current_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_heightmap_viewer() -> void: + _viewer = AtlasViewer.new() + _viewer.name = "AtlasViewer" + _viewer.visible = false + add_child(_viewer) + _viewer.back_pressed.connect(_on_viewer_back) + _viewer.economics_link_requested.connect(_on_viewer_economics_link) + + +func _on_viewer_back() -> void: + back_to_viewer_body.emit() + + +func _on_viewer_economics_link(system_id: String) -> void: + economics_link_requested.emit(system_id) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +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 diff --git a/client/ui/implant/atlas_reach_map.gd b/client/ui/implant/atlas_reach_map.gd new file mode 100644 index 000000000..d47c6453f --- /dev/null +++ b/client/ui/implant/atlas_reach_map.gd @@ -0,0 +1,494 @@ +class_name AtlasReachMap +extends Control +## Level 0 REACH_MAP widget for AtlasPanel (#844, D-191). +## Hop-ring view of the Settled Reach gate network. +## Emits enter_system(system_id) when the player commits to a system. + +signal enter_system(system_id: String) + +const REACH_MAP_CENTER_FRACTION := Vector2(0.5, 0.5) +const REACH_MIN_RING_RADIUS: float = 30.0 +const REACH_RING_SPACING: float = 22.0 +const REACH_MAX_HOP_RINGS: int = 24 +const REACH_DOT_RADIUS_HUB: float = 4.5 +const REACH_DOT_RADIUS_JUNCTION: float = 3.5 +const REACH_DOT_RADIUS_DEFAULT: float = 2.5 +const REACH_DOT_RADIUS_DEAD_END: float = 2.0 +const REACH_GATEWAY_RADIUS: float = 6.0 +const REACH_SELECTION_RING_RADIUS: float = 8.0 +const REACH_HIT_RADIUS: float = 10.0 +const REACH_EDGE_WIDTH: float = 0.8 +const REACH_EDGE_SELECTED_ALPHA: float = 0.55 +const REACH_POPUP_WIDTH: float = 300.0 +const REACH_POPUP_MARGIN: float = 16.0 +const REACH_POPUP_GTTR_MAX_LINES: int = 7 +const REACH_COLOR_RING: Color = Color("#1a2030") +const REACH_COLOR_RING_MAJOR: Color = Color("#222a3a") +const REACH_COLOR_GATEWAY: Color = Color("#f0d060") +const REACH_COLOR_SELECTION: Color = Color("#f0d060") +const REACH_SECTOR_COLORS: Dictionary = { + "core": Color("#c8d0e0"), + "north_reach": Color("#4488aa"), + "south_reach": Color("#aa6644"), + "east_reach": Color("#44aa66"), + "west_reach": Color("#aa8844"), + "deep_frontier": Color("#556677"), + "unknown": Color("#445566"), +} +const REACH_SECTOR_LABELS: Dictionary = { + "north_reach": "NORTH REACH", + "south_reach": "SOUTH REACH", + "east_reach": "EAST REACH", + "west_reach": "WEST REACH", +} +const REACH_SECTOR_ANGLE_CENTER: Dictionary = { + "north_reach": -PI / 2.0, + "east_reach": 0.0, + "south_reach": PI / 2.0, + "west_reach": PI, +} +const REACH_SECTOR_ANGLE_SPREAD: float = PI / 2.5 +const REACH_CORE_ANGLE_SPREAD: float = TAU +const REACH_DEEP_FRONTIER_ANGLE_SPREAD: float = TAU +const REACH_ZOOM_MIN: float = 0.3 +const REACH_ZOOM_MAX: float = 3.0 +const REACH_ZOOM_STEP: float = 0.15 + +const COLOR_BG: Color = Color("#0d1117") +const COLOR_TEXT: Color = Color("#c8d0e0") +const COLOR_TEXT_DIM: Color = Color("#667788") + +var _systems: Array = [] +var _reach_positions: Dictionary = {} +var _reach_node_lookup: Dictionary = {} +var _reach_zoom: float = 1.0 +var _reach_pan: Vector2 = Vector2.ZERO +var _reach_is_panning: bool = false +var _reach_pan_start: Vector2 = Vector2.ZERO +var _reach_pan_start_offset: Vector2 = Vector2.ZERO +var _reach_selected: String = "" +var _reach_hovered: String = "" +var _reach_info_panel = null # ImplantPanel +var _dirty: bool = true + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + + +func _process(_delta: float) -> void: + if not visible: + return + if _dirty: + queue_redraw() + _dirty = false + + +func setup(implant_theme) -> void: + _build_reach_info_panel(implant_theme) + + +func set_systems(systems: Array, node_lookup: Dictionary) -> void: + _systems = systems + _reach_node_lookup = node_lookup + _compute_reach_layout() + _dirty = true + + +func has_selection() -> bool: + return not _reach_selected.is_empty() + + +func refresh_info_panel_visibility() -> void: + if _reach_info_panel: + _reach_info_panel.visible = not _reach_selected.is_empty() + + +func trigger_enter() -> void: + _reach_enter_selected() + + +# ============================================================================= +# Info panel +# ============================================================================= + + +func _build_reach_info_panel(implant_theme) -> void: + _reach_info_panel = ImplantPanel.new() + _reach_info_panel.name = "ReachInfoPanel" + _reach_info_panel.theme_resource = implant_theme + _reach_info_panel.custom_minimum_size.x = REACH_POPUP_WIDTH + _reach_info_panel.size.x = REACH_POPUP_WIDTH + _reach_info_panel.visible = false + _reach_info_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_reach_info_panel) + + +func _rebuild_reach_info_panel() -> void: + if not _reach_info_panel: + return + _reach_info_panel.clear() + + var node: Dictionary = _reach_node_lookup.get(_reach_selected, {}) + if node.is_empty(): + _reach_info_panel.visible = false + return + + var sys_name: String = node.get("proper_name", "") + if sys_name.is_empty(): + sys_name = node.get("system_id", "Unknown") + _reach_info_panel.add_component(ImplantHeader.new(sys_name, node.get("system_id", ""))) + _reach_info_panel.add_component(ImplantSeparator.new()) + + var star_type: String = node.get("star_type", "") + if not star_type.is_empty(): + _reach_info_panel.add_component(ImplantDataRow.new(star_type + " star")) + + var sector_str: String = node.get("geographic_sector", "unknown").replace("_", " ").to_upper() + var hop: int = int(node.get("hop_distance", 0)) + var sector_color: Color = REACH_SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM) + _reach_info_panel.add_component( + ImplantDataRow.new("%s corridor (hop %d)" % [sector_str, hop], sector_color) + ) + + var bodies: String = node.get("bodies", "") + if not bodies.is_empty(): + _reach_info_panel.add_component(ImplantDataRow.new(bodies)) + + var population: String = node.get("population", "") + var gdp: String = node.get("gdp", "") + if not population.is_empty() or not gdp.is_empty(): + _reach_info_panel.add_component(ImplantDataRow.new("")) + if not population.is_empty(): + _reach_info_panel.add_component(ImplantDataRow.new("pop " + population)) + var gdp_label: String = "gdp " + (gdp if not gdp.is_empty() else "—") + _reach_info_panel.add_component(ImplantDataRow.new(gdp_label)) + + var gttr: String = node.get("gttr_excerpt", "") + if not gttr.is_empty(): + _reach_info_panel.add_component(ImplantSeparator.new()) + _reach_info_panel.add_component(ImplantTextBlock.new(gttr, REACH_POPUP_GTTR_MAX_LINES)) + + _reach_info_panel.add_component(ImplantSeparator.new()) + _reach_info_panel.add_component(ImplantTextBlock.new("enter open orbital map")) + + _reach_info_panel.visible = true + + +# ============================================================================= +# Layout computation +# ============================================================================= + + +func _compute_reach_layout() -> void: + _reach_positions.clear() + + var rings: Dictionary = {} + for node: Dictionary in _systems: + var hop: int = int(node.get("hop_distance", 0)) + if not rings.has(hop): + rings[hop] = [] + rings[hop].append(node) + + for hop: int in rings: + var ring_nodes: Array = rings[hop] + var radius: float = REACH_MIN_RING_RADIUS + hop * REACH_RING_SPACING + + if hop == 0: + for node: Dictionary in ring_nodes: + _reach_positions[node["system_id"]] = Vector2.ZERO + continue + + ring_nodes.sort_custom(_reach_sort_by_sector_angle) + + var sector_groups: Dictionary = {} + for node: Dictionary in ring_nodes: + var sector: String = node.get("geographic_sector", "unknown") + if not sector_groups.has(sector): + sector_groups[sector] = [] + sector_groups[sector].append(node) + + for sector: String in sector_groups: + var group: Array = sector_groups[sector] + var count: int = group.size() + var center_angle: float + var spread: float + if sector == "core": + center_angle = 0.0 + spread = REACH_CORE_ANGLE_SPREAD + elif sector == "deep_frontier": + center_angle = 0.0 + spread = REACH_DEEP_FRONTIER_ANGLE_SPREAD + elif REACH_SECTOR_ANGLE_CENTER.has(sector): + center_angle = REACH_SECTOR_ANGLE_CENTER[sector] + spread = REACH_SECTOR_ANGLE_SPREAD + else: + center_angle = 0.0 + spread = TAU + + for i: int in range(count): + var node: Dictionary = group[i] + var t: float = 0.0 if count == 1 else float(i) / float(count) - 0.5 + var angle: float = center_angle + t * spread + var jitter: float = _reach_system_hash(node["system_id"]) * 0.08 + angle += jitter + var r_var: float = ( + radius + _reach_system_hash(node["system_id"] + "r") * REACH_RING_SPACING * 0.3 + ) + _reach_positions[node["system_id"]] = Vector2(cos(angle), sin(angle)) * r_var + + +func _reach_sort_by_sector_angle(a: Dictionary, b: Dictionary) -> bool: + var sa: float = _reach_sector_sort_key(a) + var sb: float = _reach_sector_sort_key(b) + if sa != sb: + return sa < sb + return a.get("system_id", "") < b.get("system_id", "") + + +func _reach_sector_sort_key(node: Dictionary) -> float: + match node.get("geographic_sector", "unknown"): + "core": + return 0.0 + "north_reach": + return 1.0 + "east_reach": + return 2.0 + "south_reach": + return 3.0 + "west_reach": + return 4.0 + "deep_frontier": + return 5.0 + _: + return 6.0 # gdlint:ignore = max-returns + + +func _reach_system_hash(key: String) -> float: + var h: int = key.hash() & 0x7FFFFFFF + return float(h) / 2147483647.0 * 2.0 - 1.0 + + +func _reach_dot_radius(topology: String) -> float: + match topology: + "hub": + return REACH_DOT_RADIUS_HUB + "junction": + return REACH_DOT_RADIUS_JUNCTION + "dead_end": + return REACH_DOT_RADIUS_DEAD_END + _: + return REACH_DOT_RADIUS_DEFAULT # gdlint:ignore = max-returns + + +# ============================================================================= +# Drawing +# ============================================================================= + + +func _draw() -> void: + var sz: Vector2 = get_rect().size + var center: Vector2 = sz * REACH_MAP_CENTER_FRACTION + _reach_pan + + draw_rect(Rect2(Vector2.ZERO, sz), COLOR_BG) + _draw_reach_rings(center) + _draw_reach_sector_labels(center) + + if _reach_selected != "": + _draw_reach_edges(center) + + _draw_reach_systems(center) + + if _reach_selected != "": + _draw_reach_selection(center) + + if _reach_info_panel and _reach_info_panel.visible: + _reach_info_panel.reset_size() + var px: float = sz.x - REACH_POPUP_WIDTH - REACH_POPUP_MARGIN + var py: float = REACH_POPUP_MARGIN + var panel_h: float = _reach_info_panel.size.y + if panel_h > 0.0 and py + panel_h > sz.y - REACH_POPUP_MARGIN: + py = sz.y - panel_h - REACH_POPUP_MARGIN + px = maxf(REACH_POPUP_MARGIN, px) + py = maxf(REACH_POPUP_MARGIN, py) + _reach_info_panel.position = Vector2(px, py) + + +func _draw_reach_rings(center: Vector2) -> void: + for hop: int in range(REACH_MAX_HOP_RINGS + 1): + var radius: float = (REACH_MIN_RING_RADIUS + hop * REACH_RING_SPACING) * _reach_zoom + if radius < 1.0 or radius > 2000.0: + continue + var color: Color = REACH_COLOR_RING_MAJOR if hop % 5 == 0 else REACH_COLOR_RING + draw_arc(center, radius, 0.0, TAU, 64, color, 0.5 if hop % 5 == 0 else 0.3, true) + + +func _draw_reach_sector_labels(center: Vector2) -> void: + var label_radius: float = (REACH_MIN_RING_RADIUS + 12 * REACH_RING_SPACING) * _reach_zoom + for sector: String in REACH_SECTOR_LABELS: + var angle: float = REACH_SECTOR_ANGLE_CENTER.get(sector, 0.0) + var pos: Vector2 = center + Vector2(cos(angle), sin(angle)) * label_radius + var label: String = REACH_SECTOR_LABELS[sector] + var color: Color = REACH_SECTOR_COLORS.get(sector, COLOR_TEXT_DIM) + var font := get_theme_default_font() + var font_size: int = 10 + var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) + draw_string(font, pos - text_size / 2.0, label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color) + + +func _draw_reach_systems(center: Vector2) -> void: + for node: Dictionary in _systems: + var sid: String = node.get("system_id", "") + if not _reach_positions.has(sid): + continue + var pos: Vector2 = center + _reach_positions[sid] * _reach_zoom + var sector: String = node.get("geographic_sector", "unknown") + var topology: String = node.get("gate_topology", "") + var color: Color = REACH_SECTOR_COLORS.get(sector, COLOR_TEXT_DIM) + var radius: float = _reach_dot_radius(topology) + + if node.get("is_gateway", false): + color = REACH_COLOR_GATEWAY + radius = REACH_GATEWAY_RADIUS + + if sector == "deep_frontier": + color.a = 0.7 + + if sid == _reach_hovered and sid != _reach_selected: + draw_arc( + pos, radius + 3.0, 0.0, TAU, 16, Color(color.r, color.g, color.b, 0.4), 1.0, true + ) + + draw_circle(pos, radius, color) + + var label: String = node.get("proper_name", "") + if not label.is_empty() and label != sid: + var show_label := false + if sid == _reach_selected or sid == _reach_hovered: + show_label = true + elif _reach_zoom >= 2.0: + show_label = true + elif _reach_zoom >= 1.2: + show_label = topology in ["hub", "junction", ""] + if show_label: + var label_color: Color = ( + COLOR_TEXT if sid == _reach_selected or sid == _reach_hovered else COLOR_TEXT_DIM + ) + var font := get_theme_default_font() + var label_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 9) + draw_string( + font, + pos + Vector2(-label_size.x / 2.0, radius + 10.0), + label, + HORIZONTAL_ALIGNMENT_LEFT, + -1, + 9, + label_color, + ) + + +func _draw_reach_edges(center: Vector2) -> void: + var node: Dictionary = _reach_node_lookup.get(_reach_selected, {}) + var adj: Array = node.get("adjacent_systems", []) + if adj.is_empty(): + return + var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, REACH_EDGE_SELECTED_ALPHA) + var sel_pos: Vector2 = ( + center + _reach_positions.get(_reach_selected, Vector2.ZERO) * _reach_zoom + ) + for neighbor_id: String in adj: + if not _reach_positions.has(neighbor_id): + continue + var neighbor_pos: Vector2 = center + _reach_positions[neighbor_id] * _reach_zoom + draw_line(sel_pos, neighbor_pos, color, REACH_EDGE_WIDTH, true) + + +func _draw_reach_selection(center: Vector2) -> void: + if not _reach_positions.has(_reach_selected): + return + var pos: Vector2 = center + _reach_positions[_reach_selected] * _reach_zoom + draw_arc(pos, REACH_SELECTION_RING_RADIUS, 0.0, TAU, 24, REACH_COLOR_SELECTION, 1.2, true) + + +# ============================================================================= +# Input +# ============================================================================= + + +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.pressed: + match mb.button_index: + MOUSE_BUTTON_LEFT: + _handle_reach_click(mb.position) + MOUSE_BUTTON_MIDDLE: + _reach_is_panning = true + _reach_pan_start = mb.position + _reach_pan_start_offset = _reach_pan + MOUSE_BUTTON_WHEEL_UP: + var old_zoom := _reach_zoom + _reach_zoom = clampf(_reach_zoom + REACH_ZOOM_STEP, REACH_ZOOM_MIN, REACH_ZOOM_MAX) + if _reach_zoom != old_zoom: + _dirty = true + MOUSE_BUTTON_WHEEL_DOWN: + var old_zoom := _reach_zoom + _reach_zoom = clampf(_reach_zoom - REACH_ZOOM_STEP, REACH_ZOOM_MIN, REACH_ZOOM_MAX) + if _reach_zoom != old_zoom: + _dirty = true + else: + if mb.button_index == MOUSE_BUTTON_MIDDLE: + _reach_is_panning = false + elif event is InputEventMouseMotion: + var mm := event as InputEventMouseMotion + if _reach_is_panning: + _reach_pan = _reach_pan_start_offset + (mm.position - _reach_pan_start) + _dirty = true + else: + _update_reach_hover(mm.position) + + +func _handle_reach_click(pos: Vector2) -> void: + var sid := _find_nearest_reach_system(pos) + _reach_selected = sid + _rebuild_reach_info_panel() + _dirty = true + + +func _update_reach_hover(pos: Vector2) -> void: + var nearest := _find_nearest_reach_system(pos) + if nearest != _reach_hovered: + _reach_hovered = nearest + _dirty = true + + +func _find_nearest_reach_system(pos: Vector2) -> String: + var sz: Vector2 = get_rect().size + var center: Vector2 = sz * REACH_MAP_CENTER_FRACTION + _reach_pan + var best_dist: float = REACH_HIT_RADIUS + var best_sid: String = "" + for node: Dictionary in _systems: + var sid: String = node.get("system_id", "") + if not _reach_positions.has(sid): + continue + var node_pos: Vector2 = center + _reach_positions[sid] * _reach_zoom + var dist: float = pos.distance_to(node_pos) + if dist < best_dist: + best_dist = dist + best_sid = sid + return best_sid + + +func _reach_system_index(system_id: String) -> int: + for i: int in range(_systems.size()): + if _systems[i].get("system_id", "") == system_id: + return i + return -1 + + +func _reach_enter_selected() -> void: + if _reach_selected.is_empty(): + return + if _reach_system_index(_reach_selected) >= 0: + enter_system.emit(_reach_selected) diff --git a/client/ui/implant/atlas_system_map.gd b/client/ui/implant/atlas_system_map.gd new file mode 100644 index 000000000..f41c09a1d --- /dev/null +++ b/client/ui/implant/atlas_system_map.gd @@ -0,0 +1,495 @@ +class_name AtlasSystemMap +extends Control +## Level 1 SYSTEM_PICKER and Level 2 ORBITAL_DIAGRAM widget for AtlasPanel (#844, D-191). +## Manages alphabetic system picker and orbital diagram rendering for the current system. +## Emits enter_body(body) when the player clicks a body in orbital view. + +signal enter_body(body: Dictionary) + +const ORBITAL_CENTER_FRACTION := Vector2(0.5, 0.55) +const STAR_RADIUS: float = 12.0 +const ORBITAL_RING_BASE: float = 65.0 +const ORBITAL_RING_STEP: float = 58.0 +const MOON_ORBIT_RADIUS: float = 24.0 +const STATION_SIZE: float = 6.0 +const BODY_HIT_RADIUS: float = 16.0 +const LABEL_OFFSET: float = 11.0 +const PANEL_WIDTH: float = 320.0 +const PANEL_MARGIN: float = 16.0 + +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") + +var _systems: Array = [] +var _selected_idx: int = 0 +var _orbital_bodies: Array = [] +var _orbital_stations: Array = [] +var _body_positions: Dictionary = {} +var _station_positions: Dictionary = {} +var _hovered_body: String = "" +var _hovered_station: String = "" +var _selected_station: Dictionary = {} +var _dirty: bool = true +var _in_orbital: bool = false # false = picker view, true = orbital view + +var _picker_panel = null # ImplantPanel +var _picker_nav_row = null # ImplantDataRow +var _station_panel = null # ImplantPanel + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + + +func _process(_delta: float) -> void: + if not visible: + return + if _dirty: + queue_redraw() + _dirty = false + + +func setup(implant_theme) -> void: + _build_picker_panel(implant_theme) + _build_station_panel(implant_theme) + + +func set_systems(systems: Array) -> void: + _systems = systems + + +func set_selected_idx(idx: int) -> void: + _selected_idx = idx + + +func get_selected_idx() -> int: + return _selected_idx + + +func current_system() -> Dictionary: + if _systems.is_empty(): + return {} + _selected_idx = clampi(_selected_idx, 0, _systems.size() - 1) + return _systems[_selected_idx] + + +func get_orbital_body_count() -> int: + var count: int = 0 + for b: Dictionary in _orbital_bodies: + if b.get("parent_body_id") == null: + count += 1 + return count + + +func get_station_count() -> int: + return _orbital_stations.size() + + +func show_picker_mode() -> void: + _in_orbital = false + if _picker_panel: + _picker_panel.visible = true + _rebuild_picker_panel() + if _station_panel: + _station_panel.visible = false + _dirty = true + + +func load_and_show_orbital() -> 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 _picker_panel: + _picker_panel.visible = false + if _station_panel: + _station_panel.visible = false + _in_orbital = true + _dirty = true + + +func navigate_system(delta: int) -> void: + if _systems.is_empty(): + return + _selected_idx = wrapi(_selected_idx + delta, 0, _systems.size()) + _rebuild_picker_panel() + + +# ============================================================================= +# Drawing +# ============================================================================= + + +func _draw() -> void: + if _in_orbital: + _draw_orbital() + else: + 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) + _draw_orbit_rings(center) + 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) + _draw_stations() + _draw_bodies() + + +func _draw_orbit_rings(center: Vector2) -> void: + 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 faint suggestion, not a solid 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 + + 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) + + 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) + + 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 + ) + + +# ============================================================================= +# Orbital geometry +# ============================================================================= + + +func _compute_body_positions() -> void: + _body_positions.clear() + _station_positions.clear() + + var sz: Vector2 = get_rect().size + if sz == Vector2.ZERO: + sz = Vector2(1280.0, 720.0) + var center: Vector2 = sz * ORBITAL_CENTER_FRACTION + + 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)) + ) + + 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 + else: + angle = -PI / 2.0 + TAU * float(i) / float(count) + _body_positions[bid] = center + Vector2(cos(angle), sin(angle)) * ring_r + + # Moons per parent count drives spacing — hard-coded divisor caused overlap on gas giants + 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 + ) + + 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 + _station_positions[station_id] = station_parent_pos + Vector2(20.0, -10.0) + + +# ============================================================================= +# Input +# ============================================================================= + + +func _gui_input(event: InputEvent) -> void: + if not _in_orbital: + return + 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_orbital_click(pos: Vector2) -> void: + 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.emit(b) + return + + 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 + + _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 + + +# ============================================================================= +# Panels +# ============================================================================= + + +func _build_picker_panel(implant_theme) -> 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_station_panel(implant_theme) -> 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"))