From a06646d8105213d20ee382c68fd91c7284b23e2d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 19 Apr 2026 11:52:15 +0200 Subject: [PATCH] feat(ui): unify star map and atlas into single implant/map chain (#844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per D-191, the atlas is the star map extended downward — not a separate app. AtlasPanel now owns the full Reach → system → planet → heightmap zoom hierarchy as a single HudGroups app (implant/map). - Add REACH_MAP as Level 0 of AtlasPanel's zoom hierarchy; renumber the enum so higher index = deeper zoom - Port hop-ring rendering (pan/zoom, system markers, hover/info, sector layout) from star_map.gd into AtlasPanel methods - Change AtlasPanel.APP_PATH from "implant/map/atlas" to "implant/map" - main.gd: KEY_M toggles unified atlas; KEY_A binding removed - Symmetric nav: ORBITAL_DIAGRAM back goes to REACH_MAP (not SYSTEM_PICKER), matching the forward skip - hud_groups.gd docstring documents the unified path and flags the legacy starchart path as kept-for-compat (retirement tracked in #852) star_map.gd's HudGroups registration stays live but inert — no key binding reaches it. Full retirement follows in #852 after a sprint of soak on the unified panel. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/hud_groups.gd | 11 +- client/scripts/main.gd | 7 +- client/ui/hud.tscn | 4 +- client/ui/implant/atlas_panel.gd | 488 ++++++++++++++++++++++++- 4 files changed, 484 insertions(+), 26 deletions(-) diff --git a/client/scripts/autoloads/hud_groups.gd b/client/scripts/autoloads/hud_groups.gd index d199dd74a..d980295d6 100644 --- a/client/scripts/autoloads/hud_groups.gd +++ b/client/scripts/autoloads/hud_groups.gd @@ -11,17 +11,18 @@ extends Node ## ## Groups use hierarchical paths: ## "gameplay" — HUD status, minimap, prompts, stance -## "implant/map/starchart" — star map navigator +## "implant/map" — unified atlas (reach map → system → planet → regional, D-191) +## "implant/map/starchart" — legacy hop-ring view (registered by StarMapRenderer, kept for compat) ## "implant/wiki/gttr" — Drifter's Guide reader ## "implant/journal" — knowledge journal ## "implant/economics" — economics monitor (D-181, #824) ## ## Usage: -## HudGroups.register(self, "implant/map/starchart") -## HudGroups.open_app("implant/map/starchart") # fullscreen by default -## HudGroups.open_app("implant/map/starchart", HudGroups.MODE_INSERT) +## HudGroups.register(self, "implant/map") +## HudGroups.open_app("implant/map") # fullscreen by default +## HudGroups.open_app("implant/map", HudGroups.MODE_INSERT) ## HudGroups.close_app() -## HudGroups.toggle_app("implant/map/starchart") +## HudGroups.toggle_app("implant/map") ## Emitted when an app opens, closes, or changes mode. ## mode is a HudGroups.Mode enum value. diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 0db82afab..098c2e140 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -178,17 +178,14 @@ func _ready() -> void: func _unhandled_key_input(event: InputEvent) -> void: if event.is_pressed() and not event.is_echo(): if event is InputEventKey and event.keycode == KEY_M: - if star_map: - star_map.toggle_visible() - elif event is InputEventKey and event.keycode == KEY_A: - # #834: A — toggle Atlas implant panel (FULLSCREEN, implant/map/atlas) + # #844: M — unified atlas (implant/map). Reach map → system → planet → regional. if atlas_panel: atlas_panel.toggle_visible() elif event is InputEventKey and event.keycode == KEY_N: # #824: N — toggle Economics Monitor implant panel (E is bound to interact). # Gated on the atlas being inactive so the viewer's N → city-economics # cross-link isn't shadowed by this global toggle (review #6). - if economics_panel and not HudGroups.is_app_active("implant/map/atlas"): + if economics_panel and not HudGroups.is_app_active("implant/map"): economics_panel.toggle_visible() elif event is InputEventKey and event.keycode == KEY_BRACKETLEFT: # #824: [ — cycle economics panel system selector backward diff --git a/client/ui/hud.tscn b/client/ui/hud.tscn index 2deb0c8da..ddc304eda 100644 --- a/client/ui/hud.tscn +++ b/client/ui/hud.tscn @@ -15,9 +15,7 @@ grow_vertical = 2 mouse_filter = 2 script = ExtResource("1_hud") -; #674: Star map — full-screen insert overlay, hidden until player activates (M key / insert UI). -; Toggled via star_map.toggle_visible() from main.gd. -; TODO: migrate to HudGroups.open_app("implant/map/starchart") per D-170. +; #674: Star map — legacy hop-ring node, kept for backward compat. Superseded by AtlasPanel REACH_MAP level (#844). [node name="StarMap" parent="." instance=ExtResource("2_starmap")] visible = false diff --git a/client/ui/implant/atlas_panel.gd b/client/ui/implant/atlas_panel.gd index 44a148e3d..cb0988ce4 100644 --- a/client/ui/implant/atlas_panel.gd +++ b/client/ui/implant/atlas_panel.gd @@ -1,13 +1,14 @@ class_name AtlasPanel extends Control -## Atlas implant panel — 4-level navigation (#834, #835): -## system picker → orbital diagram → body entry → regional viewer. -## FULLSCREEN implant app (z=20) at implant/map/atlas per D-170. +## Atlas implant panel — unified 5-level nav chain (#844, D-191): +## reach map → system picker → orbital diagram → body entry → regional viewer. +## FULLSCREEN implant app (z=20) at implant/map per D-170. ## Uses ImplantPanel component library (D-169). Data from star_map_data.json. ## ## Navigation: -## Level 0 SYSTEM_PICKER — ◄ ► cycle systems, Enter to open orbital view +## 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 @@ -17,9 +18,9 @@ extends Control ## + HudGroups.open_app("implant/economics") — D-191 cross-panel integration. signal economics_link_requested(system_id: String) -enum Level { SYSTEM_PICKER = 0, ORBITAL_DIAGRAM = 1, BODY_ENTRY = 2, HEIGHTMAP_VIEWER = 3 } +enum Level { REACH_MAP = 0, SYSTEM_PICKER = 1, ORBITAL_DIAGRAM = 2, BODY_ENTRY = 3, HEIGHTMAP_VIEWER = 4 } -const APP_PATH := "implant/map/atlas" +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 @@ -47,8 +48,57 @@ 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.SYSTEM_PICKER +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 @@ -72,6 +122,18 @@ var _station_panel = null # ImplantPanel — station mini, shown in level 1 on 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 + func _ready() -> void: anchor_right = 1.0 @@ -93,7 +155,9 @@ func _ready() -> void: _build_body_panel() _build_station_panel() _build_heightmap_viewer() - _show_level(Level.SYSTEM_PICKER) + _build_reach_info_panel() + _compute_reach_layout() + _show_level(Level.REACH_MAP) func _process(_delta: float) -> void: @@ -104,7 +168,7 @@ func _process(_delta: float) -> void: _dirty = false -## Toggle atlas panel. Called from main.gd on KEY_A. +## Toggle atlas panel. Called from main.gd on KEY_M. func toggle_visible() -> void: HudGroups.toggle_app(APP_PATH, HudGroups.Mode.FULLSCREEN) @@ -150,6 +214,8 @@ func _load_system_list() -> void: var nb: String = b.get("proper_name", b.get("system_id", "")) return na < nb ) + for node: Dictionary in _systems: + _reach_node_lookup[node.get("system_id", "")] = node func _current_system() -> Dictionary: @@ -287,6 +353,8 @@ func _compute_body_positions() -> void: func _draw() -> void: match _level: + Level.REACH_MAP: + _draw_reach() Level.SYSTEM_PICKER: _draw_picker_bg() Level.ORBITAL_DIAGRAM: @@ -343,9 +411,12 @@ func _refresh_screen_header() -> void: 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 close" + 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", "—"))) @@ -476,6 +547,8 @@ func _draw_stations() -> void: 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) @@ -499,7 +572,9 @@ func _handle_key(event: InputEventKey) -> void: if _level == Level.SYSTEM_PICKER: _navigate_system(1) KEY_ENTER, KEY_KP_ENTER: - if _level == Level.SYSTEM_PICKER: + if _level == Level.REACH_MAP: + _reach_enter_selected() + elif _level == Level.SYSTEM_PICKER: _enter_orbital_diagram() elif _level == Level.BODY_ENTRY: _open_heightmap_viewer() @@ -507,10 +582,12 @@ func _handle_key(event: InputEventKey) -> void: func _navigate_back() -> void: match _level: - Level.SYSTEM_PICKER: + Level.REACH_MAP: HudGroups.close_app() + Level.SYSTEM_PICKER: + _show_level(Level.REACH_MAP) Level.ORBITAL_DIAGRAM: - _show_level(Level.SYSTEM_PICKER) + _show_level(Level.REACH_MAP) Level.BODY_ENTRY: _show_level(Level.ORBITAL_DIAGRAM) Level.HEIGHTMAP_VIEWER: @@ -601,6 +678,8 @@ func _show_level(level: Level) -> void: _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() @@ -788,3 +867,386 @@ func _format_pop(pop: int) -> String: 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 + _: + 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 + + +# ============================================================================= +# REACH_MAP drawing +# ============================================================================= + + +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 _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) + + +# ============================================================================= +# REACH_MAP input +# ============================================================================= + + +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(): + return + var idx := _reach_system_index(_reach_selected) + if idx >= 0: + _selected_idx = idx + _enter_orbital_diagram()