Files
settled-reach/client/ui/star_map.gd
T
jpmschweitzerandClaude Opus 4.6 8d02b110ae fix(ui): star map info panel spacing — split separator padding
Separators now have explicit above (6px) and below (12px) spacing
relative to text baselines. Previously a single 8px block didn't
account for baseline positioning, causing text to merge with
separator lines. Line height bumped to 18px.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:35:05 +02:00

624 lines
21 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 line_h: float = 18.0 # line height for single-line text (baseline to baseline)
var sep_above: float = 6.0 # space from last text baseline to separator line
var sep_below: float = 12.0 # space from separator line to next text baseline
var inner_w: float = panel_w - pad * 2.0
var sep_color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12)
# ── Prepare all text content before measuring ────────────────────────────
var sys_name: String = node.get("proper_name", "")
if sys_name.is_empty():
sys_name = node.get("system_id", "Unknown")
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]
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)
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" % [
int(node.get("aperture_count", 0)),
"s" if int(node.get("aperture_count", 0)) != 1 else ""]
var gttr: String = node.get("gttr_excerpt", "").replace("**", "")
var adj: Array = node.get("adjacent_systems", [])
var adj_line: String = ""
if not adj.is_empty():
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)
adj_line = " · ".join(adj_names)
# ── Measure variable-height blocks ───────────────────────────────────────
var gttr_h: float = 0.0
if not gttr.is_empty():
gttr_h = font.get_multiline_string_size(
gttr, HORIZONTAL_ALIGNMENT_LEFT, inner_w,
POPUP_GTTR_FONT_SIZE, POPUP_GTTR_MAX_LINES).y
var adj_h: float = POPUP_LINE_H # minimum one line
if not adj_line.is_empty():
adj_h = font.get_multiline_string_size(
adj_line, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, 3).y
# ── Calculate total panel height ─────────────────────────────────────────
var sep_total: float = sep_above + sep_below # full separator block height
var panel_h: float = pad # top padding
panel_h += line_h # system name (15px font)
panel_h += line_h # system id (10px font)
panel_h += sep_total # separator
panel_h += line_h # star type + hop
panel_h += line_h # corridor
panel_h += line_h # bodies + pop
if not gttr.is_empty():
panel_h += sep_total + gttr_h
panel_h += sep_total + adj_h # adjacent systems
panel_h += pad # bottom padding
# ── Draw background ──────────────────────────────────────────────────────
var panel_pos := Vector2(sz.x - panel_w - POPUP_MARGIN, POPUP_MARGIN)
var x: float = panel_pos.x + pad
var x_right: float = panel_pos.x + panel_w - pad
var y: float = panel_pos.y + pad + line_h # first baseline
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), COLOR_INFO_BG)
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), sep_color, false, 1.0)
# ── Draw content ─────────────────────────────────────────────────────────
# System name
draw_string(font, Vector2(x, y), sys_name,
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 15, COLOR_TEXT)
y += line_h
# System ID
draw_string(font, Vector2(x, y), node.get("system_id", ""),
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
# Separator — space above from baseline, line, space below to next baseline
y += sep_above
draw_line(Vector2(x, y), Vector2(x_right, y), sep_color, 1.0)
y += sep_below
# Star type + hop
draw_string(font, Vector2(x, y), stat_line_1,
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
y += line_h
# Corridor
draw_string(font, Vector2(x, y), sector_str + " corridor",
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, sector_color)
y += line_h
# Bodies + population
draw_string(font, Vector2(x, y), stat_line_3,
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
# GTTR excerpt
if not gttr.is_empty():
y += sep_above
draw_line(Vector2(x, y), Vector2(x_right, y), sep_color, 1.0)
y += sep_below
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_h - line_h # multiline size includes first baseline
# Adjacent systems
y += sep_above
draw_line(Vector2(x, y), Vector2(x_right, y), sep_color, 1.0)
y += sep_below
if adj_line.is_empty():
draw_string(font, Vector2(x, y), "No gate connections",
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
else:
draw_multiline_string(font, Vector2(x, y), adj_line,
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, 3, 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