Show system profile popup on select: name, star type, hop distance, corridor, GTTR excerpt, body count, population, adjacent systems. Edge rendering changed to show no edges by default, only selected system's gate lines on click. Star map data extended with wiki fields for all 301 systems. Ticket: #780 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
599 lines
20 KiB
GDScript
599 lines
20 KiB
GDScript
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 — expanded with wiki/GTTR content (#780)
|
|
const POPUP_WIDTH: float = 300.0
|
|
const POPUP_MARGIN: float = 16.0
|
|
const POPUP_PADDING: float = 12.0
|
|
const POPUP_LINE_H: float = 17.0
|
|
const POPUP_GTTR_FONT_SIZE: int = 10
|
|
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 COLOR_INFO_BG: Color = Color(0.05, 0.08, 0.14, 0.92)
|
|
|
|
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
|
|
|
|
|
|
func _ready() -> void:
|
|
mouse_filter = Control.MOUSE_FILTER_STOP
|
|
_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.
|
|
## Only force-hides when insert is inactive. Does NOT auto-show — star map is
|
|
## modal (player opens via toggle_visible()), not always-on like the minimap.
|
|
func set_insert_active(active: bool) -> void:
|
|
_insert_active = active
|
|
if not active:
|
|
visible = false
|
|
|
|
|
|
## Toggle visibility (e.g., from a keybind or button).
|
|
func toggle_visible() -> void:
|
|
visible = not visible
|
|
if visible:
|
|
_dirty = true
|
|
|
|
|
|
## 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 for selected system
|
|
if _selected_system != "":
|
|
_draw_info_panel(sz)
|
|
|
|
# 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)
|
|
|
|
|
|
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
|
|
draw_arc(pos, SELECTION_RING_RADIUS, 0.0, TAU, 24, COLOR_SELECTION, 1.2, true)
|
|
|
|
# Draw label next to selection
|
|
var node: Dictionary = _node_lookup.get(_selected_system, {})
|
|
var label: String = node.get("proper_name", _selected_system)
|
|
if label.is_empty():
|
|
label = _selected_system
|
|
var font := get_theme_default_font()
|
|
draw_string(font, pos + Vector2(SELECTION_RING_RADIUS + 4, 4), label,
|
|
HORIZONTAL_ALIGNMENT_LEFT, -1, 12, COLOR_SELECTION)
|
|
|
|
|
|
func _draw_info_panel(sz: Vector2) -> void:
|
|
var node: Dictionary = _node_lookup.get(_selected_system, {})
|
|
if node.is_empty():
|
|
return
|
|
|
|
var font := get_theme_default_font()
|
|
var panel_w: float = POPUP_WIDTH
|
|
var pad: float = POPUP_PADDING
|
|
var inner_w: float = panel_w - pad * 2.0
|
|
|
|
# Measure GTTR text height first so we can size the panel
|
|
var gttr: String = node.get("gttr_excerpt", "")
|
|
# Strip markdown bold markers for display
|
|
gttr = gttr.replace("**", "")
|
|
var gttr_block_h: float = 0.0
|
|
if not gttr.is_empty():
|
|
var gttr_size: Vector2 = font.get_multiline_string_size(
|
|
gttr, HORIZONTAL_ALIGNMENT_LEFT, inner_w, POPUP_GTTR_FONT_SIZE,
|
|
POPUP_GTTR_MAX_LINES)
|
|
gttr_block_h = gttr_size.y + 6.0
|
|
|
|
# Fixed rows: name, id, star+hop, corridor, bodies+pop, adjacent, separator lines
|
|
var fixed_h: float = (
|
|
POPUP_LINE_H * 2.0 # name + id
|
|
+ 4.0 # separator
|
|
+ POPUP_LINE_H * 3.0 # star+hop, corridor, bodies+pop
|
|
+ 4.0 # separator
|
|
+ gttr_block_h
|
|
+ 4.0 # separator (before adjacent)
|
|
+ POPUP_LINE_H # adjacent systems label
|
|
+ pad * 2.0
|
|
)
|
|
var panel_h: float = fixed_h
|
|
|
|
var panel_pos := Vector2(sz.x - panel_w - POPUP_MARGIN, POPUP_MARGIN)
|
|
var x: float = panel_pos.x + pad
|
|
var y: float = panel_pos.y + pad + POPUP_LINE_H # baseline offset
|
|
|
|
# Background + border
|
|
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), COLOR_INFO_BG)
|
|
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)),
|
|
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.15), false, 1.0)
|
|
|
|
# ── System name ──────────────────────────────────────────────────────────
|
|
var sys_name: String = node.get("proper_name", "")
|
|
if sys_name.is_empty():
|
|
sys_name = node.get("system_id", "Unknown")
|
|
draw_string(font, Vector2(x, y), sys_name, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 15, COLOR_TEXT)
|
|
y += POPUP_LINE_H
|
|
|
|
draw_string(font, Vector2(x, y), node.get("system_id", ""), HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
|
|
y += POPUP_LINE_H
|
|
|
|
# Separator
|
|
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
|
|
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
|
|
y += 6.0
|
|
|
|
# ── Stats block ──────────────────────────────────────────────────────────
|
|
var star_type: String = node.get("star_type", "")
|
|
var hop: int = int(node.get("hop_distance", 0))
|
|
var stat_line_1: String
|
|
if star_type.is_empty():
|
|
stat_line_1 = "Hop %d from Gateway" % hop
|
|
else:
|
|
stat_line_1 = "%s star · hop %d" % [star_type, hop]
|
|
draw_string(font, Vector2(x, y), stat_line_1, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
|
|
y += POPUP_LINE_H
|
|
|
|
var sector_str: String = node.get("geographic_sector", "unknown").replace("_", " ").to_upper()
|
|
var sector_color: Color = SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM)
|
|
draw_string(font, Vector2(x, y), sector_str + " corridor", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, sector_color)
|
|
y += POPUP_LINE_H
|
|
|
|
var bodies: String = node.get("bodies", "")
|
|
var population: String = node.get("population", "")
|
|
var stat_line_3: String
|
|
if not bodies.is_empty() and not population.is_empty():
|
|
stat_line_3 = "%s · pop %s" % [bodies, population]
|
|
elif not bodies.is_empty():
|
|
stat_line_3 = bodies
|
|
elif not population.is_empty():
|
|
stat_line_3 = "Population: " + population
|
|
else:
|
|
stat_line_3 = "%d aperture%s · %s" % [
|
|
int(node.get("aperture_count", 0)),
|
|
"s" if int(node.get("aperture_count", 0)) != 1 else "",
|
|
node.get("gate_topology", "")]
|
|
draw_string(font, Vector2(x, y), stat_line_3, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
|
|
y += POPUP_LINE_H
|
|
|
|
# ── GTTR excerpt ─────────────────────────────────────────────────────────
|
|
if not gttr.is_empty():
|
|
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
|
|
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
|
|
y += 6.0
|
|
draw_multiline_string(font, Vector2(x, y), gttr, HORIZONTAL_ALIGNMENT_LEFT,
|
|
inner_w, POPUP_GTTR_FONT_SIZE, POPUP_GTTR_MAX_LINES,
|
|
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.75))
|
|
y += gttr_block_h
|
|
|
|
# ── Adjacent systems ──────────────────────────────────────────────────────
|
|
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
|
|
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
|
|
y += 6.0
|
|
var adj: Array = node.get("adjacent_systems", [])
|
|
if adj.is_empty():
|
|
draw_string(font, Vector2(x, y), "No gate connections", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
|
|
else:
|
|
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)
|
|
var adj_line: String = " · ".join(adj_names)
|
|
draw_multiline_string(font, Vector2(x, y), adj_line, HORIZONTAL_ALIGNMENT_LEFT,
|
|
inner_w, 10, 2, COLOR_TEXT_DIM)
|
|
|
|
|
|
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
|
|
_dirty = true
|
|
|
|
|
|
func _update_hover(pos: Vector2) -> void:
|
|
var nearest := _find_nearest_system(pos)
|
|
if nearest != _hovered_system:
|
|
_hovered_system = nearest
|
|
_dirty = true
|