Files
settled-reach/client/ui/star_map.gd
T
jpmschweitzerandClaude Opus 4.6 d2ea687ec3 fix(ui): star map info panel — dynamic height measurement
Panel now measures all variable content (GTTR text, adjacent systems)
before calculating height. Fixes clipping on hub systems with many
neighbors and long GTTR excerpts. Unified separator spacing (8px).

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

620 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 sep_h: float = 8.0 # separator line + spacing
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 panel_h: float = pad # top padding
panel_h += POPUP_LINE_H # system name (15px font)
panel_h += POPUP_LINE_H # system id (10px font)
panel_h += sep_h # separator
panel_h += POPUP_LINE_H # star type + hop
panel_h += POPUP_LINE_H # corridor
panel_h += POPUP_LINE_H # bodies + pop
if not gttr.is_empty():
panel_h += sep_h + gttr_h
panel_h += sep_h + 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 + POPUP_LINE_H # baseline offset
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 += POPUP_LINE_H
# System ID
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 + 2.0), Vector2(x_right, y + 2.0), sep_color, 1.0)
y += sep_h
# 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
# Corridor
draw_string(font, Vector2(x, y), sector_str + " corridor",
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, sector_color)
y += POPUP_LINE_H
# Bodies + population
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 + 2.0), Vector2(x_right, y + 2.0), sep_color, 1.0)
y += sep_h
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
# Adjacent systems
draw_line(Vector2(x, y + 2.0), Vector2(x_right, y + 2.0), sep_color, 1.0)
y += sep_h
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