feat(ui): star map insert module — concentric hop-ring view (#674)
New StarMapRenderer: 301 systems in concentric hop-rings from player location, sector-colored, click-to-select with info panel, pan/zoom. Integrated into HUD (hidden by default), insert state propagation wired in main.gd. Data enriched from systems.db + star-map.json via tooling/generate-star-map-data.py regeneration script. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ extends Node2D
|
||||
@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load
|
||||
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
|
||||
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
|
||||
@onready var star_map = $UILayer/HUD/StarMap # #674: star map insert module (hop-ring view)
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||
@@ -254,6 +255,8 @@ func _propagate_insert_state() -> void:
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
if minimap:
|
||||
minimap.set_insert_active(insert_state)
|
||||
if star_map:
|
||||
star_map.set_insert_active(insert_state)
|
||||
|
||||
|
||||
# D-057: Update interaction list from game state.
|
||||
|
||||
+6
-1
@@ -1,6 +1,7 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
[gd_scene load_steps=3 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/hud.gd" id="1_hud"]
|
||||
[ext_resource type="PackedScene" path="res://ui/star_map.tscn" id="2_starmap"]
|
||||
|
||||
[node name="HUD" type="Control"]
|
||||
layout_mode = 3
|
||||
@@ -36,3 +37,7 @@ text = "Mode: Baseline"
|
||||
[node name="TimeLabel" type="Label" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Time: 08:00"
|
||||
|
||||
; #674: Star map insert — concentric hop-ring view, hidden by default, toggled via keybind
|
||||
[node name="StarMap" parent="." instance=ExtResource("2_starmap")]
|
||||
visible = false
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
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).
|
||||
## 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.
|
||||
|
||||
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
|
||||
const EDGE_WIDTH: float = 0.4
|
||||
const EDGE_ALPHA: float = 0.12
|
||||
const EDGE_SELECTED_ALPHA: float = 0.5
|
||||
|
||||
# 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 _show_edges: bool = false # toggle edge display
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_load_data()
|
||||
if _data_loaded:
|
||||
_compute_layout()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _insert_active and _data_loaded:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
## Deterministic float in [-1, 1] from a string key.
|
||||
func _system_hash(key: String) -> float:
|
||||
var h: int = key.hash()
|
||||
return fmod(float(h) / 2147483647.0, 1.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 (gate connections)
|
||||
if _show_edges or _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:
|
||||
for edge: Array in _edges:
|
||||
if edge.size() < 2:
|
||||
continue
|
||||
var sid_a: String = edge[0]
|
||||
var sid_b: String = edge[1]
|
||||
if not _node_positions.has(sid_a) or not _node_positions.has(sid_b):
|
||||
continue
|
||||
var pos_a: Vector2 = center + _node_positions[sid_a] * _zoom
|
||||
var pos_b: Vector2 = center + _node_positions[sid_b] * _zoom
|
||||
|
||||
var alpha: float = EDGE_ALPHA
|
||||
# Highlight edges connected to selected system
|
||||
if _selected_system == sid_a or _selected_system == sid_b:
|
||||
alpha = EDGE_SELECTED_ALPHA
|
||||
|
||||
var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, alpha)
|
||||
draw_line(pos_a, pos_b, 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 panel_w: float = 220.0
|
||||
var panel_h: float = 130.0
|
||||
var margin: float = 16.0
|
||||
var panel_pos := Vector2(sz.x - panel_w - margin, margin)
|
||||
|
||||
# Background
|
||||
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)
|
||||
|
||||
var font := get_theme_default_font()
|
||||
var y: float = panel_pos.y + 20.0
|
||||
var x: float = panel_pos.x + 12.0
|
||||
var line_h: float = 18.0
|
||||
|
||||
# System name
|
||||
var name: String = node.get("proper_name", "")
|
||||
if name.is_empty():
|
||||
name = node.get("system_id", "Unknown")
|
||||
draw_string(font, Vector2(x, y), name, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, COLOR_TEXT)
|
||||
y += line_h
|
||||
|
||||
# System ID
|
||||
draw_string(font, Vector2(x, y), node.get("system_id", ""), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM)
|
||||
y += line_h
|
||||
|
||||
# Sector
|
||||
var sector: String = node.get("geographic_sector", "").replace("_", " ").capitalize()
|
||||
var sector_color: Color = SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM)
|
||||
draw_string(font, Vector2(x, y), "Sector: " + sector, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, sector_color)
|
||||
y += line_h
|
||||
|
||||
# Hop distance
|
||||
draw_string(font, Vector2(x, y), "Hop distance: " + str(node.get("hop_distance", "?")), HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM)
|
||||
y += line_h
|
||||
|
||||
# Topology
|
||||
var topo: String = node.get("gate_topology", "").replace("_", " ").capitalize()
|
||||
draw_string(font, Vector2(x, y), "Topology: " + topo, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM)
|
||||
y += line_h
|
||||
|
||||
# Gate connections
|
||||
draw_string(font, Vector2(x, y), "Gates: " + str(node.get("aperture_count", 0)) + " apertures", HORIZONTAL_ALIGNMENT_LEFT, -1, 11, 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:
|
||||
_zoom = clampf(_zoom + ZOOM_STEP, ZOOM_MIN, ZOOM_MAX)
|
||||
MOUSE_BUTTON_WHEEL_DOWN:
|
||||
_zoom = clampf(_zoom - ZOOM_STEP, ZOOM_MIN, ZOOM_MAX)
|
||||
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)
|
||||
else:
|
||||
_update_hover(mm.position)
|
||||
|
||||
|
||||
func _handle_click(pos: Vector2) -> void:
|
||||
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
|
||||
|
||||
if best_sid != "":
|
||||
_selected_system = best_sid
|
||||
_show_edges = true
|
||||
else:
|
||||
_selected_system = ""
|
||||
_show_edges = false
|
||||
|
||||
|
||||
func _update_hover(pos: Vector2) -> void:
|
||||
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
|
||||
|
||||
_hovered_system = best_sid
|
||||
@@ -0,0 +1,18 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/star_map.gd" id="1_starmap"]
|
||||
|
||||
; #674: Star map insert module — concentric hop-ring view of the Settled Reach gate network.
|
||||
; Sector-colored, interactive selection, pan/zoom. Accessible from the insert UI.
|
||||
; Data source: res://data/star_map_data.json (enriched from star-map.json + systems.db).
|
||||
; Positioned as full-size overlay. Toggle visibility via set_insert_active() or toggle_visible().
|
||||
|
||||
[node name="StarMapRenderer" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 1
|
||||
script = ExtResource("1_starmap")
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate client/data/star_map_data.json from star-map.json + systems.db.
|
||||
|
||||
Run from the project root (any worktree):
|
||||
python3 tooling/generate-star-map-data.py
|
||||
|
||||
Sources:
|
||||
docs/design/star-map.json — graph topology (nodes + edges)
|
||||
server/server/data/systems.db — proper names, geographic sectors
|
||||
|
||||
Output:
|
||||
client/data/star_map_data.json — self-contained client data for the star map UI
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
|
||||
def find_file(candidates: list[str]) -> str | None:
|
||||
for p in candidates:
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Find star-map.json
|
||||
star_map_path = find_file([
|
||||
"docs/design/star-map.json",
|
||||
"../docs/design/star-map.json",
|
||||
"../../docs/design/star-map.json",
|
||||
])
|
||||
if not star_map_path:
|
||||
print("ERROR: docs/design/star-map.json not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find systems.db
|
||||
db_path = find_file([
|
||||
"server/server/data/systems.db",
|
||||
"../server/server/data/systems.db",
|
||||
"../../server/server/data/systems.db",
|
||||
])
|
||||
if not db_path:
|
||||
print("ERROR: server/server/data/systems.db not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Load star map topology
|
||||
with open(star_map_path) as f:
|
||||
star_map = json.load(f)
|
||||
|
||||
# Load DB data
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT system_id, proper_name, geographic_sector, geographic_band "
|
||||
"FROM star_systems"
|
||||
)
|
||||
db_lookup = {row["system_id"]: dict(row) for row in cur.fetchall()}
|
||||
conn.close()
|
||||
|
||||
# Merge
|
||||
nodes = []
|
||||
for n in star_map["nodes"]:
|
||||
sid = n["system_id"]
|
||||
db = db_lookup.get(sid, {})
|
||||
entry = {
|
||||
"system_id": sid,
|
||||
"proper_name": db.get("proper_name", ""),
|
||||
"geographic_sector": db.get("geographic_sector", "unknown"),
|
||||
"geographic_band": db.get("geographic_band", ""),
|
||||
"gate_topology": n["gate_topology"],
|
||||
"aperture_count": n["aperture_count"],
|
||||
"gate_connections": n["gate_connections"],
|
||||
"hop_distance": n["hop_distance_from_gateway"],
|
||||
}
|
||||
if n.get("is_gateway"):
|
||||
entry["is_gateway"] = True
|
||||
nodes.append(entry)
|
||||
|
||||
nodes.sort(key=lambda x: (x["hop_distance"], x["system_id"]))
|
||||
|
||||
output = {
|
||||
"_meta": {
|
||||
"generated_from": f"{star_map_path} + {db_path}",
|
||||
"system_count": len(nodes),
|
||||
"edge_count": len(star_map["edges"]),
|
||||
"note": "Client-side star map data. Regenerate with: tooling/generate-star-map-data.py",
|
||||
},
|
||||
"nodes": nodes,
|
||||
"edges": star_map["edges"],
|
||||
}
|
||||
|
||||
# Write output
|
||||
out_path = find_file(["client/data"]) or "client/data"
|
||||
os.makedirs(out_path, exist_ok=True)
|
||||
out_file = os.path.join(out_path, "star_map_data.json")
|
||||
with open(out_file, "w") as f:
|
||||
json.dump(output, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"Generated {out_file}")
|
||||
print(f" Nodes: {len(nodes)}, Edges: {len(star_map['edges'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user