|
|
|
@@ -1,637 +0,0 @@
|
|
|
|
|
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.
|
|
|
|
|
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", "")
|
|
|
|
|
var gdp: String = node.get("gdp", "")
|
|
|
|
|
if not population.is_empty() or not gdp.is_empty():
|
|
|
|
|
_info_panel.add_component(ImplantDataRow.new("")) # blank line spacer
|
|
|
|
|
if not population.is_empty():
|
|
|
|
|
_info_panel.add_component(ImplantDataRow.new("pop " + population))
|
|
|
|
|
var gdp_label: String = "gdp " + (gdp if not gdp.is_empty() else "—")
|
|
|
|
|
_info_panel.add_component(ImplantDataRow.new(gdp_label))
|
|
|
|
|
|
|
|
|
|
# ── 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
|