class_name StarMapRenderer extends Control ## Star map — concentric hop-ring view of the Settled Reach gate network (#674). ## Renders 301 systems as dots on concentric rings (hop distance from Gateway). ## Sector-colored: core (white-gold), north (blue), south (orange), east (green), west (tan). ## ## Data source: res://data/star_map_data.json (generated from star-map.json + systems.db + wiki). ## Regenerate with: tooling/generate-star-map-data.py ## ## D-013: Diegetic neural insert overlay. Accessible from the insert UI. ## Parent epic: #51 (Diegetic Insert/Minimap), ticket #674. ## Ticket #780: Click-through popup — wiki/GTTR content on system select. const DATA_PATH := "res://data/star_map_data.json" # Layout const MAP_CENTER_FRACTION := Vector2(0.5, 0.5) # center of control const MIN_RING_RADIUS: float = 30.0 # innermost ring (hop 0 = gateway dot only) const RING_SPACING: float = 22.0 # pixels between hop rings const MAX_HOP_RINGS: int = 24 # max hop distance we render rings for # Dot sizing const DOT_RADIUS_HUB: float = 4.5 const DOT_RADIUS_JUNCTION: float = 3.5 const DOT_RADIUS_DEFAULT: float = 2.5 const DOT_RADIUS_DEAD_END: float = 2.0 const GATEWAY_RADIUS: float = 6.0 # Selection const SELECTION_RING_RADIUS: float = 8.0 const HIT_RADIUS: float = 10.0 # click tolerance # Edge rendering — only shown for selected system (ticket #780 UX rule) const EDGE_WIDTH: float = 0.8 const EDGE_SELECTED_ALPHA: float = 0.55 # Info popup — uses ImplantPanel component library (D-169) const POPUP_WIDTH: float = 300.0 const POPUP_MARGIN: float = 16.0 const POPUP_GTTR_MAX_LINES: int = 7 # Colors — sector palette from wireframe const COLOR_BG: Color = Color("#0d1117") const COLOR_RING: Color = Color("#1a2030") const COLOR_RING_MAJOR: Color = Color("#222a3a") const COLOR_GATEWAY: Color = Color("#f0d060") const COLOR_SELECTION: Color = Color("#f0d060") const COLOR_TEXT: Color = Color("#c8d0e0") const COLOR_TEXT_DIM: Color = Color("#667788") const 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 SECTOR_LABELS: Dictionary = { "north_reach": "NORTH REACH", "south_reach": "SOUTH REACH", "east_reach": "EAST REACH", "west_reach": "WEST REACH", } # Quadrant angles for sector placement (radians, 0 = right, counterclockwise) # North = top (-PI/2), East = right (0), South = bottom (PI/2), West = left (PI) const SECTOR_ANGLE_CENTER: Dictionary = { "north_reach": -PI / 2.0, "east_reach": 0.0, "south_reach": PI / 2.0, "west_reach": PI, } const SECTOR_ANGLE_SPREAD: float = PI / 2.5 # each sector occupies ~72° of arc const CORE_ANGLE_SPREAD: float = TAU # core systems spread full circle const DEEP_FRONTIER_ANGLE_SPREAD: float = TAU # deep frontier wraps entire outer edge # Pan/zoom const ZOOM_MIN: float = 0.3 const ZOOM_MAX: float = 3.0 const ZOOM_STEP: float = 0.15 # Internal state var _nodes: Array = [] var _edges: Array = [] var _node_positions: Dictionary = {} # system_id -> Vector2 (screen coords relative to map center) var _node_lookup: Dictionary = {} # system_id -> node dict var _selected_system: String = "" var _hovered_system: String = "" var _zoom: float = 1.0 var _pan_offset: Vector2 = Vector2.ZERO var _is_panning: bool = false var _pan_start: Vector2 = Vector2.ZERO var _pan_start_offset: Vector2 = Vector2.ZERO var _data_loaded: bool = false var _insert_active: bool = true var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw var _info_panel: ImplantPanel # D-169: component-based info panel var _implant_theme: ImplantTheme func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_STOP # D-169: Load implant theme and create info panel _implant_theme = load("res://ui/implant/default_implant.tres") as ImplantTheme _info_panel = ImplantPanel.new() _info_panel.name = "InfoPanel" _info_panel.theme_resource = _implant_theme _info_panel.custom_minimum_size.x = POPUP_WIDTH _info_panel.size.x = POPUP_WIDTH _info_panel.visible = false _info_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_info_panel) # D-170: Register with HUD layer groups HudGroups.register(self, "implant/map/starchart") HudGroups.app_changed.connect(_on_app_changed) _load_data() if _data_loaded: _compute_layout() func _process(_delta: float) -> void: if not visible: return if _dirty and _data_loaded: queue_redraw() _dirty = false ## Called from main.gd when insert state changes. ## Called from main.gd when insert state changes. func set_insert_active(active: bool) -> void: _insert_active = active if not active and HudGroups.is_app_active("implant/map/starchart"): HudGroups.close_app() ## Toggle via HUD layer system (D-170). func toggle_visible() -> void: HudGroups.toggle_app("implant/map/starchart") ## Respond to app layer changes (D-170). func _on_app_changed(app_path: String, mode: int) -> void: if app_path != "implant/map/starchart": return if mode == HudGroups.Mode.FULLSCREEN or mode == HudGroups.Mode.INSERT: visible = true _dirty = true else: visible = false ## Return the currently selected system data, or empty dict. func get_selected_system() -> Dictionary: return _node_lookup.get(_selected_system, {}) ## Return total system count. func get_system_count() -> int: return _nodes.size() ## Return total edge count. func get_edge_count() -> int: return _edges.size() # ============================================================================= # Data loading # ============================================================================= func _load_data() -> void: if not FileAccess.file_exists(DATA_PATH): push_warning("StarMapRenderer: data file not found at %s" % DATA_PATH) return var file := FileAccess.open(DATA_PATH, FileAccess.READ) if file == null: push_warning("StarMapRenderer: could not open %s" % DATA_PATH) return var parsed: Variant = JSON.parse_string(file.get_as_text()) file.close() if not (parsed is Dictionary): push_warning("StarMapRenderer: invalid JSON in %s" % DATA_PATH) return var data := parsed as Dictionary _nodes = data.get("nodes", []) _edges = data.get("edges", []) for node: Dictionary in _nodes: _node_lookup[node.get("system_id", "")] = node _data_loaded = true # ============================================================================= # Layout — place systems on concentric rings by hop distance # ============================================================================= func _compute_layout() -> void: _node_positions.clear() # Group nodes by hop distance var rings: Dictionary = {} # hop -> Array of nodes for node: Dictionary in _nodes: var hop: int = int(node.get("hop_distance", 0)) if not rings.has(hop): rings[hop] = [] rings[hop].append(node) # Place each ring for hop: int in rings: var ring_nodes: Array = rings[hop] var radius: float = MIN_RING_RADIUS + hop * RING_SPACING if hop == 0: # Gateway at center for node: Dictionary in ring_nodes: _node_positions[node["system_id"]] = Vector2.ZERO continue # Sort nodes within ring by sector for angular grouping ring_nodes.sort_custom(_sort_by_sector_angle) # Distribute nodes within their sector's angular range 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 = CORE_ANGLE_SPREAD elif sector == "deep_frontier": center_angle = 0.0 spread = DEEP_FRONTIER_ANGLE_SPREAD elif SECTOR_ANGLE_CENTER.has(sector): center_angle = SECTOR_ANGLE_CENTER[sector] spread = SECTOR_ANGLE_SPREAD else: center_angle = 0.0 spread = TAU # Distribute evenly within sector arc, with deterministic offset per system for i: int in range(count): var node: Dictionary = group[i] var t: float if count == 1: t = 0.0 else: t = float(i) / float(count) - 0.5 # -0.5 to +0.5 var angle: float = center_angle + t * spread # Add small per-node jitter based on system_id hash for visual variety var jitter: float = _system_hash(node["system_id"]) * 0.08 angle += jitter # Slight radial variation to avoid perfect circles var r_var: float = ( radius + _system_hash(node["system_id"] + "r") * RING_SPACING * 0.3 ) _node_positions[node["system_id"]] = Vector2(cos(angle), sin(angle)) * r_var func _sort_by_sector_angle(a: Dictionary, b: Dictionary) -> bool: var sa: float = _sector_sort_key(a) var sb: float = _sector_sort_key(b) if sa != sb: return sa < sb return a.get("system_id", "") < b.get("system_id", "") func _sector_sort_key(node: Dictionary) -> float: var sector: String = node.get("geographic_sector", "unknown") match sector: "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 ## Deterministic float in [-1, 1] from a string key. func _system_hash(key: String) -> float: var h: int = key.hash() & 0x7FFFFFFF # mask to 31-bit positive range return float(h) / 2147483647.0 * 2.0 - 1.0 # ============================================================================= # Drawing # ============================================================================= func _draw() -> void: if not _data_loaded: return var sz: Vector2 = get_rect().size var center: Vector2 = sz * MAP_CENTER_FRACTION + _pan_offset # Background draw_rect(Rect2(Vector2.ZERO, sz), COLOR_BG) # Hop rings (concentric circles) _draw_rings(center) # Sector labels _draw_sector_labels(center) # Edges — only draw from selected system (UX rule: full edge web is too dense) if _selected_system != "": _draw_edges(center) # System dots _draw_systems(center) # Selection highlight if _selected_system != "": _draw_selection(center) # Info panel positioned in top-right, clamped to screen (D-169) if _info_panel: _info_panel.visible = _selected_system != "" if _info_panel.visible: # Force layout so size.y is accurate for clamping _info_panel.reset_size() var px: float = sz.x - POPUP_WIDTH - POPUP_MARGIN var py: float = POPUP_MARGIN # Clamp to keep panel fully on screen var panel_h: float = _info_panel.size.y if panel_h > 0.0 and py + panel_h > sz.y - POPUP_MARGIN: py = sz.y - panel_h - POPUP_MARGIN px = maxf(POPUP_MARGIN, px) py = maxf(POPUP_MARGIN, py) _info_panel.position = Vector2(px, py) # Title _draw_title() func _draw_rings(center: Vector2) -> void: for hop: int in range(MAX_HOP_RINGS + 1): var radius: float = (MIN_RING_RADIUS + hop * RING_SPACING) * _zoom if radius < 1.0 or radius > 2000.0: continue var color: Color = COLOR_RING_MAJOR if hop % 5 == 0 else COLOR_RING draw_arc(center, radius, 0.0, TAU, 64, color, 0.5 if hop % 5 == 0 else 0.3, true) func _draw_sector_labels(center: Vector2) -> void: var label_radius: float = (MIN_RING_RADIUS + 12 * RING_SPACING) * _zoom for sector: String in SECTOR_LABELS: var angle: float = SECTOR_ANGLE_CENTER.get(sector, 0.0) var pos: Vector2 = center + Vector2(cos(angle), sin(angle)) * label_radius var label: String = SECTOR_LABELS[sector] var color: Color = 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_systems(center: Vector2) -> void: for node: Dictionary in _nodes: var sid: String = node.get("system_id", "") if not _node_positions.has(sid): continue var pos: Vector2 = center + _node_positions[sid] * _zoom var sector: String = node.get("geographic_sector", "unknown") var topology: String = node.get("gate_topology", "") var color: Color = SECTOR_COLORS.get(sector, COLOR_TEXT_DIM) var radius: float = _dot_radius(topology) # Gateway gets special treatment if node.get("is_gateway", false): color = COLOR_GATEWAY radius = GATEWAY_RADIUS # Dim deep frontier slightly if sector == "deep_frontier": color.a = 0.7 # Hover highlight if sid == _hovered_system and sid != _selected_system: 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) # System name label — centered below dot, zoom-based LoD var label: String = node.get("proper_name", "") if not label.is_empty() and label != sid: var show_label := false if sid == _selected_system or sid == _hovered_system: show_label = true # always show selected/hovered elif _zoom >= 2.0: show_label = true # zoomed in: show all elif _zoom >= 1.2: show_label = topology in ["hub", "junction", ""] # medium: hubs + junctions + gateway # else: zoomed out, only selected/hovered if show_label: var label_color: Color if sid == _selected_system or sid == _hovered_system: label_color = COLOR_TEXT else: label_color = 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_edges(center: Vector2) -> void: # Only draw edges connected to the selected system (full web is unreadable at 301 systems) var node: Dictionary = _node_lookup.get(_selected_system, {}) var adj: Array = node.get("adjacent_systems", []) if adj.is_empty(): return var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, EDGE_SELECTED_ALPHA) var sel_pos: Vector2 = center + _node_positions.get(_selected_system, Vector2.ZERO) * _zoom for neighbor_id: String in adj: if not _node_positions.has(neighbor_id): continue var neighbor_pos: Vector2 = center + _node_positions[neighbor_id] * _zoom draw_line(sel_pos, neighbor_pos, color, EDGE_WIDTH, true) func _draw_selection(center: Vector2) -> void: if not _node_positions.has(_selected_system): return var pos: Vector2 = center + _node_positions[_selected_system] * _zoom # Selection ring only — label is drawn by _draw_systems() draw_arc(pos, SELECTION_RING_RADIUS, 0.0, TAU, 24, COLOR_SELECTION, 1.2, true) ## Rebuild the info panel with components for the selected system (D-169). func _rebuild_info_panel() -> void: if not _info_panel: return _info_panel.clear() var node: Dictionary = _node_lookup.get(_selected_system, {}) if node.is_empty(): _info_panel.visible = false return # ── Header ─────────────────────────────────────────────────────────────── var sys_name: String = node.get("proper_name", "") if sys_name.is_empty(): sys_name = node.get("system_id", "Unknown") _info_panel.add_component(ImplantHeader.new(sys_name, node.get("system_id", ""))) _info_panel.add_component(ImplantSeparator.new()) # ── Stats ──────────────────────────────────────────────────────────────── var star_type: String = node.get("star_type", "") if not star_type.is_empty(): _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 = SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM) _info_panel.add_component( ImplantDataRow.new("%s corridor (hop %d)" % [sector_str, hop], sector_color) ) # Bodies — single line var bodies: String = node.get("bodies", "") if not bodies.is_empty(): _info_panel.add_component(ImplantDataRow.new(bodies)) # Population + GDP var population: String = node.get("population", "") if not population.is_empty(): _info_panel.add_component(ImplantDataRow.new("")) # blank line spacer _info_panel.add_component(ImplantDataRow.new("pop " + population)) _info_panel.add_component(ImplantDataRow.new("GDP —")) # ── GTTR excerpt ───────────────────────────────────────────────────────── var gttr: String = node.get("gttr_excerpt", "") if not gttr.is_empty(): _info_panel.add_component(ImplantSeparator.new()) _info_panel.add_component(ImplantTextBlock.new(gttr, POPUP_GTTR_MAX_LINES)) # ── Adjacent systems ───────────────────────────────────────────────────── var adj: Array = node.get("adjacent_systems", []) if not adj.is_empty(): _info_panel.add_component(ImplantSeparator.new()) var adj_names: Array = [] for neighbor_id: String in adj: var neighbor: Dictionary = _node_lookup.get(neighbor_id, {}) var n_name: String = neighbor.get("proper_name", "") adj_names.append(n_name if not n_name.is_empty() else neighbor_id) _info_panel.add_component(ImplantTextBlock.new(" · ".join(adj_names))) func _draw_title() -> void: var font := get_theme_default_font() draw_string( font, Vector2(16, 28), "THE REACH — NAVIGATOR", HORIZONTAL_ALIGNMENT_LEFT, -1, 16, COLOR_TEXT ) draw_string( font, Vector2(16, 44), "Concord Assembly Gate Network — %d Systems" % _nodes.size(), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM ) func _dot_radius(topology: String) -> float: match topology: "hub": return DOT_RADIUS_HUB "junction": return DOT_RADIUS_JUNCTION "dead_end": return DOT_RADIUS_DEAD_END _: return DOT_RADIUS_DEFAULT # ============================================================================= # Input — selection, pan, zoom # ============================================================================= 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_click(mb.position) MOUSE_BUTTON_MIDDLE: _is_panning = true _pan_start = mb.position _pan_start_offset = _pan_offset MOUSE_BUTTON_WHEEL_UP: var old_zoom := _zoom _zoom = clampf(_zoom + ZOOM_STEP, ZOOM_MIN, ZOOM_MAX) if _zoom != old_zoom: _dirty = true MOUSE_BUTTON_WHEEL_DOWN: var old_zoom := _zoom _zoom = clampf(_zoom - ZOOM_STEP, ZOOM_MIN, ZOOM_MAX) if _zoom != old_zoom: _dirty = true else: if mb.button_index == MOUSE_BUTTON_MIDDLE: _is_panning = false elif event is InputEventMouseMotion: var mm := event as InputEventMouseMotion if _is_panning: _pan_offset = _pan_start_offset + (mm.position - _pan_start) _dirty = true else: _update_hover(mm.position) ## Find the system_id of the nearest node to screen position, or "" if none within HIT_RADIUS. func _find_nearest_system(pos: Vector2) -> String: var sz: Vector2 = get_rect().size var center: Vector2 = sz * MAP_CENTER_FRACTION + _pan_offset var best_dist: float = HIT_RADIUS var best_sid: String = "" for node: Dictionary in _nodes: var sid: String = node.get("system_id", "") if not _node_positions.has(sid): continue var node_pos: Vector2 = center + _node_positions[sid] * _zoom var dist: float = pos.distance_to(node_pos) if dist < best_dist: best_dist = dist best_sid = sid return best_sid func _handle_click(pos: Vector2) -> void: var nearest := _find_nearest_system(pos) _selected_system = nearest _rebuild_info_panel() _dirty = true func _update_hover(pos: Vector2) -> void: var nearest := _find_nearest_system(pos) if nearest != _hovered_system: _hovered_system = nearest _dirty = true