Per D-191, the atlas is the star map extended downward — not a separate app. AtlasPanel now owns the full Reach → system → planet → heightmap zoom hierarchy as a single HudGroups app (implant/map). - Add REACH_MAP as Level 0 of AtlasPanel's zoom hierarchy; renumber the enum so higher index = deeper zoom - Port hop-ring rendering (pan/zoom, system markers, hover/info, sector layout) from star_map.gd into AtlasPanel methods - Change AtlasPanel.APP_PATH from "implant/map/atlas" to "implant/map" - main.gd: KEY_M toggles unified atlas; KEY_A binding removed - Symmetric nav: ORBITAL_DIAGRAM back goes to REACH_MAP (not SYSTEM_PICKER), matching the forward skip - hud_groups.gd docstring documents the unified path and flags the legacy starchart path as kept-for-compat (retirement tracked in #852) star_map.gd's HudGroups registration stays live but inert — no key binding reaches it. Full retirement follows in #852 after a sprint of soak on the unified panel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1253 lines
42 KiB
GDScript
1253 lines
42 KiB
GDScript
class_name AtlasPanel
|
|
extends Control
|
|
|
|
## Atlas implant panel — unified 5-level nav chain (#844, D-191):
|
|
## reach map → system picker → orbital diagram → body entry → regional viewer.
|
|
## FULLSCREEN implant app (z=20) at implant/map per D-170.
|
|
## Uses ImplantPanel component library (D-169). Data from star_map_data.json.
|
|
##
|
|
## Navigation:
|
|
## Level 4 REACH_MAP — hop-ring view of the Reach gate network, click system → orbital
|
|
## Level 0 SYSTEM_PICKER — ◄ ► cycle systems alphabetically, Enter to open orbital view
|
|
## Level 1 ORBITAL_DIAGRAM — rendered via _draw(), click body → level 2, click station → mini panel
|
|
## Level 2 BODY_ENTRY — body info panel, Enter to open heightmap viewer, Esc back
|
|
## Level 3 HEIGHTMAP_VIEWER — AtlasViewer with pan/zoom + markers + city data (#835), Esc back
|
|
|
|
## Emitted when the viewer's city-data panel requests the economics monitor for
|
|
## the current system. main.gd bridges this to EconomicsPanel.select_system()
|
|
## + HudGroups.open_app("implant/economics") — D-191 cross-panel integration.
|
|
signal economics_link_requested(system_id: String)
|
|
|
|
enum Level { REACH_MAP = 0, SYSTEM_PICKER = 1, ORBITAL_DIAGRAM = 2, BODY_ENTRY = 3, HEIGHTMAP_VIEWER = 4 }
|
|
|
|
const APP_PATH := "implant/map"
|
|
const STAR_MAP_DATA := "res://data/star_map_data.json"
|
|
const PANEL_WIDTH: float = 320.0
|
|
const PANEL_MARGIN: float = 16.0
|
|
|
|
# ── Orbital diagram geometry ──────────────────────────────────────────────────
|
|
const ORBITAL_CENTER_FRACTION := Vector2(0.5, 0.55)
|
|
const STAR_RADIUS: float = 12.0
|
|
const ORBITAL_RING_BASE: float = 65.0 # innermost planet ring radius (px)
|
|
const ORBITAL_RING_STEP: float = 58.0 # radial gap between orbit rings
|
|
const MOON_ORBIT_RADIUS: float = 24.0 # sub-orbit radius for moons around parent
|
|
const STATION_SIZE: float = 6.0 # station marker half-size
|
|
const BODY_HIT_RADIUS: float = 16.0 # click/hover detection radius
|
|
const LABEL_OFFSET: float = 11.0 # px below body dot for label
|
|
|
|
# ── Colors ────────────────────────────────────────────────────────────────────
|
|
const COLOR_BG: Color = Color("#0d1117")
|
|
const COLOR_STAR: Color = Color("#f0d060")
|
|
const COLOR_PLANET_INHABITED: Color = Color("#44aa66")
|
|
const COLOR_PLANET_HABITABLE: Color = Color("#4488aa")
|
|
const COLOR_PLANET_BARE: Color = Color("#556677")
|
|
const COLOR_MOON: Color = Color("#3a4a55")
|
|
const COLOR_OORT: Color = Color("#253040")
|
|
const COLOR_STATION: Color = Color("#f0d060")
|
|
const COLOR_RING: Color = Color(1.0, 1.0, 1.0, 0.06)
|
|
const COLOR_TEXT: Color = Color("#c8d0e0")
|
|
const COLOR_TEXT_DIM: Color = Color("#667788")
|
|
|
|
# ── REACH_MAP (hop-ring) constants ────────────────────────────────────────────
|
|
const REACH_MAP_CENTER_FRACTION := Vector2(0.5, 0.5)
|
|
const REACH_MIN_RING_RADIUS: float = 30.0
|
|
const REACH_RING_SPACING: float = 22.0
|
|
const REACH_MAX_HOP_RINGS: int = 24
|
|
const REACH_DOT_RADIUS_HUB: float = 4.5
|
|
const REACH_DOT_RADIUS_JUNCTION: float = 3.5
|
|
const REACH_DOT_RADIUS_DEFAULT: float = 2.5
|
|
const REACH_DOT_RADIUS_DEAD_END: float = 2.0
|
|
const REACH_GATEWAY_RADIUS: float = 6.0
|
|
const REACH_SELECTION_RING_RADIUS: float = 8.0
|
|
const REACH_HIT_RADIUS: float = 10.0
|
|
const REACH_EDGE_WIDTH: float = 0.8
|
|
const REACH_EDGE_SELECTED_ALPHA: float = 0.55
|
|
const REACH_POPUP_WIDTH: float = 300.0
|
|
const REACH_POPUP_MARGIN: float = 16.0
|
|
const REACH_POPUP_GTTR_MAX_LINES: int = 7
|
|
const REACH_COLOR_RING: Color = Color("#1a2030")
|
|
const REACH_COLOR_RING_MAJOR: Color = Color("#222a3a")
|
|
const REACH_COLOR_GATEWAY: Color = Color("#f0d060")
|
|
const REACH_COLOR_SELECTION: Color = Color("#f0d060")
|
|
const REACH_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 REACH_SECTOR_LABELS: Dictionary = {
|
|
"north_reach": "NORTH REACH",
|
|
"south_reach": "SOUTH REACH",
|
|
"east_reach": "EAST REACH",
|
|
"west_reach": "WEST REACH",
|
|
}
|
|
const REACH_SECTOR_ANGLE_CENTER: Dictionary = {
|
|
"north_reach": -PI / 2.0,
|
|
"east_reach": 0.0,
|
|
"south_reach": PI / 2.0,
|
|
"west_reach": PI,
|
|
}
|
|
const REACH_SECTOR_ANGLE_SPREAD: float = PI / 2.5
|
|
const REACH_CORE_ANGLE_SPREAD: float = TAU
|
|
const REACH_DEEP_FRONTIER_ANGLE_SPREAD: float = TAU
|
|
const REACH_ZOOM_MIN: float = 0.3
|
|
const REACH_ZOOM_MAX: float = 3.0
|
|
const REACH_ZOOM_STEP: float = 0.15
|
|
|
|
# ── Navigation state ──────────────────────────────────────────────────────────
|
|
var _level: Level = Level.REACH_MAP
|
|
var _systems: Array = [] # Array[Dictionary] from star_map_data.json
|
|
var _selected_idx: int = 0 # index into _systems
|
|
var _selected_body: Dictionary = {} # body dict at Level.BODY_ENTRY
|
|
|
|
# ── Orbital diagram runtime state ─────────────────────────────────────────────
|
|
var _orbital_bodies: Array = [] # orbit_bodies for current system
|
|
var _orbital_stations: Array = [] # stations for current system
|
|
var _body_positions: Dictionary = {} # body_id -> Vector2
|
|
var _station_positions: Dictionary = {} # station_id -> Vector2
|
|
var _hovered_body: String = ""
|
|
var _hovered_station: String = ""
|
|
var _selected_station: Dictionary = {} # station clicked in orbital view
|
|
var _dirty: bool = true
|
|
|
|
# ── Visual components (D-169 ImplantPanel library) ────────────────────────────
|
|
var _implant_theme = null # ImplantTheme — loaded at runtime (autoload parse-order rule)
|
|
var _picker_panel = null # ImplantPanel — level 0 system selector
|
|
var _picker_nav_row = null # ImplantDataRow — nav hint text, updated on navigate
|
|
var _body_panel = null # ImplantPanel — level 2 body entry
|
|
var _station_panel = null # ImplantPanel — station mini, shown in level 1 on click
|
|
var _screen_header: ImplantHeader = null # D-169-composed title/hint row (top-left)
|
|
var _viewer = null # AtlasViewer — level 3 heightmap viewer (#835)
|
|
|
|
# ── REACH_MAP state ───────────────────────────────────────────────────────────
|
|
var _reach_positions: Dictionary = {} # system_id -> Vector2 (relative to map center)
|
|
var _reach_node_lookup: Dictionary = {} # system_id -> node dict
|
|
var _reach_zoom: float = 1.0
|
|
var _reach_pan: Vector2 = Vector2.ZERO
|
|
var _reach_is_panning: bool = false
|
|
var _reach_pan_start: Vector2 = Vector2.ZERO
|
|
var _reach_pan_start_offset: Vector2 = Vector2.ZERO
|
|
var _reach_selected: String = ""
|
|
var _reach_hovered: String = ""
|
|
var _reach_info_panel = null # ImplantPanel — selected system details
|
|
|
|
|
|
func _ready() -> void:
|
|
anchor_right = 1.0
|
|
anchor_bottom = 1.0
|
|
grow_horizontal = Control.GROW_DIRECTION_BOTH
|
|
grow_vertical = Control.GROW_DIRECTION_BOTH
|
|
visible = false
|
|
mouse_filter = Control.MOUSE_FILTER_STOP
|
|
|
|
# D-170: Register with HUD layer groups
|
|
HudGroups.register(self, APP_PATH)
|
|
HudGroups.app_changed.connect(_on_app_changed)
|
|
|
|
_implant_theme = load("res://ui/implant/default_implant.tres")
|
|
|
|
_load_system_list()
|
|
_build_screen_header()
|
|
_build_picker_panel()
|
|
_build_body_panel()
|
|
_build_station_panel()
|
|
_build_heightmap_viewer()
|
|
_build_reach_info_panel()
|
|
_compute_reach_layout()
|
|
_show_level(Level.REACH_MAP)
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
if not visible:
|
|
return
|
|
if _dirty:
|
|
queue_redraw()
|
|
_dirty = false
|
|
|
|
|
|
## Toggle atlas panel. Called from main.gd on KEY_M.
|
|
func toggle_visible() -> void:
|
|
HudGroups.toggle_app(APP_PATH, HudGroups.Mode.FULLSCREEN)
|
|
|
|
|
|
## Called from main.gd when insert state changes.
|
|
func set_insert_active(active: bool) -> void:
|
|
if not active and HudGroups.is_app_active(APP_PATH):
|
|
HudGroups.close_app()
|
|
|
|
|
|
func _on_app_changed(app_path: String, mode: int) -> void:
|
|
if app_path != APP_PATH:
|
|
return
|
|
if mode == HudGroups.Mode.FULLSCREEN:
|
|
visible = true
|
|
_dirty = true
|
|
else:
|
|
visible = false
|
|
|
|
|
|
# =============================================================================
|
|
# Data loading
|
|
# =============================================================================
|
|
|
|
|
|
func _load_system_list() -> void:
|
|
if not FileAccess.file_exists(STAR_MAP_DATA):
|
|
push_warning("AtlasPanel: %s not found" % STAR_MAP_DATA)
|
|
return
|
|
var file := FileAccess.open(STAR_MAP_DATA, FileAccess.READ)
|
|
if file == null:
|
|
return
|
|
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
|
file.close()
|
|
if not (parsed is Dictionary):
|
|
return
|
|
for node: Dictionary in parsed.get("nodes", []):
|
|
if not node.get("system_id", "").is_empty():
|
|
_systems.append(node)
|
|
_systems.sort_custom(
|
|
func(a: Dictionary, b: Dictionary) -> bool:
|
|
var na: String = a.get("proper_name", a.get("system_id", ""))
|
|
var nb: String = b.get("proper_name", b.get("system_id", ""))
|
|
return na < nb
|
|
)
|
|
for node: Dictionary in _systems:
|
|
_reach_node_lookup[node.get("system_id", "")] = node
|
|
|
|
|
|
func _current_system() -> Dictionary:
|
|
if _systems.is_empty():
|
|
return {}
|
|
_selected_idx = clampi(_selected_idx, 0, _systems.size() - 1)
|
|
return _systems[_selected_idx]
|
|
|
|
|
|
func _enter_orbital_diagram() -> void:
|
|
var sys: Dictionary = _current_system()
|
|
_orbital_bodies = sys.get("orbit_bodies", [])
|
|
_orbital_stations = sys.get("stations", [])
|
|
_hovered_body = ""
|
|
_hovered_station = ""
|
|
_selected_station = {}
|
|
_compute_body_positions()
|
|
if _station_panel:
|
|
_station_panel.visible = false
|
|
_show_level(Level.ORBITAL_DIAGRAM)
|
|
|
|
|
|
func _enter_body_entry(body: Dictionary) -> void:
|
|
_selected_body = body
|
|
_rebuild_body_panel()
|
|
_show_level(Level.BODY_ENTRY)
|
|
|
|
|
|
func _open_heightmap_viewer() -> void:
|
|
if _viewer == null:
|
|
return
|
|
_viewer.show_body(_selected_body, _current_system())
|
|
_show_level(Level.HEIGHTMAP_VIEWER)
|
|
|
|
|
|
# =============================================================================
|
|
# Orbital geometry
|
|
# =============================================================================
|
|
|
|
|
|
func _compute_body_positions() -> void:
|
|
_body_positions.clear()
|
|
_station_positions.clear()
|
|
|
|
var sz: Vector2 = get_rect().size
|
|
# Fall back to design-time size if rect not available yet
|
|
if sz == Vector2.ZERO:
|
|
sz = Vector2(1280.0, 720.0)
|
|
var center: Vector2 = sz * ORBITAL_CENTER_FRACTION
|
|
|
|
# Separate top-level bodies (no parent) from moons
|
|
var top_bodies: Array = []
|
|
for b: Dictionary in _orbital_bodies:
|
|
if b.get("parent_body_id") == null:
|
|
top_bodies.append(b)
|
|
top_bodies.sort_custom(
|
|
func(a: Dictionary, b: Dictionary) -> bool:
|
|
return int(a.get("orbit_index", 0)) < int(b.get("orbit_index", 0))
|
|
)
|
|
|
|
# Group by orbit_index and distribute evenly on each ring
|
|
var by_orbit: Dictionary = {}
|
|
for b: Dictionary in top_bodies:
|
|
var idx: int = int(b.get("orbit_index", 0))
|
|
if not by_orbit.has(idx):
|
|
by_orbit[idx] = []
|
|
by_orbit[idx].append(b)
|
|
|
|
for orbit_idx: int in by_orbit:
|
|
var ring_bodies: Array = by_orbit[orbit_idx]
|
|
var ring_r: float = ORBITAL_RING_BASE + (orbit_idx - 1) * ORBITAL_RING_STEP
|
|
var count: int = ring_bodies.size()
|
|
for i: int in range(count):
|
|
var b: Dictionary = ring_bodies[i]
|
|
var bid: String = str(b.get("body_id", ""))
|
|
if bid.is_empty():
|
|
continue
|
|
var angle: float
|
|
if count == 1:
|
|
angle = -PI / 2.0 # top position (12 o'clock)
|
|
else:
|
|
angle = -PI / 2.0 + TAU * float(i) / float(count)
|
|
_body_positions[bid] = center + Vector2(cos(angle), sin(angle)) * ring_r
|
|
|
|
# Place moons near their parent body. Moons per parent count drives the
|
|
# angular spacing — a hard-coded divisor made the 5th+ moon overlap moon 1
|
|
# and become unclickable on gas giants with many satellites (review #1).
|
|
var moons_by_parent: Dictionary = {}
|
|
for b: Dictionary in _orbital_bodies:
|
|
var parent_id: Variant = b.get("parent_body_id")
|
|
if parent_id == null:
|
|
continue
|
|
var key: String = str(parent_id)
|
|
if not moons_by_parent.has(key):
|
|
moons_by_parent[key] = []
|
|
moons_by_parent[key].append(b)
|
|
for parent_key: String in moons_by_parent:
|
|
if not _body_positions.has(parent_key):
|
|
continue
|
|
var siblings: Array = moons_by_parent[parent_key]
|
|
siblings.sort_custom(
|
|
func(a: Dictionary, b: Dictionary) -> bool:
|
|
return int(a.get("orbit_index", 0)) < int(b.get("orbit_index", 0))
|
|
)
|
|
var parent_pos: Vector2 = _body_positions[parent_key]
|
|
var count: int = siblings.size()
|
|
for i: int in range(count):
|
|
var moon: Dictionary = siblings[i]
|
|
var moon_id: String = str(moon.get("body_id", ""))
|
|
if moon_id.is_empty():
|
|
continue
|
|
var angle: float = -PI / 2.0 + TAU * float(i) / float(count)
|
|
_body_positions[moon_id] = (
|
|
parent_pos + Vector2(cos(angle), sin(angle)) * MOON_ORBIT_RADIUS
|
|
)
|
|
|
|
# Place stations near their parent body (offset right + slightly up)
|
|
for s: Dictionary in _orbital_stations:
|
|
var station_id: String = str(s.get("station_id", ""))
|
|
if station_id.is_empty():
|
|
continue
|
|
var parent_id: Variant = s.get("orbits_body_id")
|
|
var station_parent_pos: Vector2
|
|
if parent_id != null and _body_positions.has(str(parent_id)):
|
|
station_parent_pos = _body_positions[str(parent_id)]
|
|
else:
|
|
station_parent_pos = center # fallback to star position
|
|
_station_positions[station_id] = station_parent_pos + Vector2(20.0, -10.0)
|
|
|
|
|
|
# =============================================================================
|
|
# Drawing
|
|
# =============================================================================
|
|
|
|
|
|
func _draw() -> void:
|
|
match _level:
|
|
Level.REACH_MAP:
|
|
_draw_reach()
|
|
Level.SYSTEM_PICKER:
|
|
_draw_picker_bg()
|
|
Level.ORBITAL_DIAGRAM:
|
|
_draw_orbital()
|
|
Level.BODY_ENTRY:
|
|
_draw_body_bg()
|
|
Level.HEIGHTMAP_VIEWER:
|
|
pass # AtlasViewer draws its own background
|
|
|
|
|
|
func _draw_picker_bg() -> void:
|
|
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
|
|
|
|
|
func _draw_body_bg() -> void:
|
|
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
|
|
|
|
|
func _draw_orbital() -> void:
|
|
var sz: Vector2 = get_rect().size
|
|
var center: Vector2 = sz * ORBITAL_CENTER_FRACTION
|
|
|
|
draw_rect(Rect2(Vector2.ZERO, sz), COLOR_BG)
|
|
|
|
# Orbit rings for top-level bodies
|
|
_draw_orbit_rings(center)
|
|
|
|
# Star glow + body
|
|
draw_circle(center, STAR_RADIUS + 4.0, Color(COLOR_STAR.r, COLOR_STAR.g, COLOR_STAR.b, 0.18))
|
|
draw_circle(center, STAR_RADIUS, COLOR_STAR)
|
|
|
|
# Station markers (behind body dots)
|
|
_draw_stations()
|
|
|
|
# Body dots + labels
|
|
_draw_bodies()
|
|
|
|
|
|
func _build_screen_header() -> void:
|
|
# D-169: the top-of-screen title / hint composes from ImplantHeader so the
|
|
# implant theme drives its fonts and semantic colors. Review #4 flagged the
|
|
# original draw_string() approach as a theme-swap invariant violation.
|
|
_screen_header = ImplantHeader.new()
|
|
_screen_header.position = Vector2(PANEL_MARGIN, 16.0)
|
|
_screen_header.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
add_child(_screen_header)
|
|
if _implant_theme:
|
|
_screen_header.apply_implant_theme(_implant_theme)
|
|
|
|
|
|
func _refresh_screen_header() -> void:
|
|
if _screen_header == null:
|
|
return
|
|
var title: String = ""
|
|
var hint: String = ""
|
|
match _level:
|
|
Level.REACH_MAP:
|
|
title = "THE REACH — NAVIGATOR"
|
|
hint = "Concord Assembly Gate Network · click system · enter open orbital · esc close"
|
|
Level.SYSTEM_PICKER:
|
|
title = "ATLAS — SYSTEM SELECTION"
|
|
hint = "select a system · ◄ ► cycle · enter open orbital view · esc back"
|
|
Level.ORBITAL_DIAGRAM:
|
|
var sys: Dictionary = _current_system()
|
|
var sys_name: String = str(sys.get("proper_name", sys.get("system_id", "—")))
|
|
title = "ATLAS — ORBITAL VIEW · " + sys_name.to_upper()
|
|
var star_type: String = str(sys.get("star_type", ""))
|
|
var top_count: int = 0
|
|
for b: Dictionary in _orbital_bodies:
|
|
if b.get("parent_body_id") == null:
|
|
top_count += 1
|
|
var subtitle: String = ""
|
|
if not star_type.is_empty():
|
|
subtitle = star_type + " · "
|
|
subtitle += "%d orbital bodies · %d stations" % [top_count, _orbital_stations.size()]
|
|
hint = subtitle + " · click body → atlas entry · esc back"
|
|
Level.BODY_ENTRY:
|
|
var sys2: Dictionary = _current_system()
|
|
var sys2_name: String = str(sys2.get("proper_name", sys2.get("system_id", "—")))
|
|
title = "ATLAS — BODY ENTRY · " + sys2_name.to_upper()
|
|
hint = "enter view atlas · esc back to orbital"
|
|
Level.HEIGHTMAP_VIEWER:
|
|
# Viewer owns its own header while active.
|
|
title = ""
|
|
hint = ""
|
|
_screen_header.set_content(title, hint)
|
|
_screen_header.visible = (_level != Level.HEIGHTMAP_VIEWER)
|
|
|
|
|
|
func _draw_orbit_rings(center: Vector2) -> void:
|
|
# Draw one ring per unique orbit_index of top-level bodies
|
|
var seen_orbits: Dictionary = {}
|
|
for b: Dictionary in _orbital_bodies:
|
|
if b.get("parent_body_id") != null:
|
|
continue
|
|
var idx: int = int(b.get("orbit_index", 0))
|
|
if seen_orbits.has(idx):
|
|
continue
|
|
seen_orbits[idx] = true
|
|
var r: float = ORBITAL_RING_BASE + (idx - 1) * ORBITAL_RING_STEP
|
|
draw_arc(center, r, 0.0, TAU, 64, COLOR_RING, 0.5, true)
|
|
|
|
|
|
func _draw_bodies() -> void:
|
|
var font := get_theme_default_font()
|
|
for b: Dictionary in _orbital_bodies:
|
|
var bid: String = str(b.get("body_id", ""))
|
|
if bid.is_empty() or not _body_positions.has(bid):
|
|
continue
|
|
var pos: Vector2 = _body_positions[bid]
|
|
var body_type: String = b.get("body_type", "")
|
|
var atmo: String = b.get("atmosphere", "none")
|
|
var inhabited: bool = bool(b.get("inhabited", false))
|
|
|
|
var color: Color
|
|
var radius: float
|
|
match body_type:
|
|
"moon":
|
|
color = COLOR_MOON
|
|
radius = 3.5
|
|
"oort_cloud":
|
|
# Oort cloud shown as a faint dashed circle suggestion, not a dot
|
|
color = COLOR_OORT
|
|
radius = 2.0
|
|
_:
|
|
if inhabited:
|
|
color = COLOR_PLANET_INHABITED
|
|
radius = 6.0
|
|
elif atmo in ["breathable", "standard"]:
|
|
color = COLOR_PLANET_HABITABLE
|
|
radius = 5.5
|
|
else:
|
|
color = COLOR_PLANET_BARE
|
|
radius = 4.5
|
|
|
|
# Hover highlight ring
|
|
if bid == _hovered_body:
|
|
draw_arc(pos, radius + 5.0, 0.0, TAU, 20, Color(1.0, 1.0, 1.0, 0.25), 1.0, true)
|
|
|
|
draw_circle(pos, radius, color)
|
|
|
|
# Label: always for inhabited, on hover otherwise
|
|
var label: String = b.get("proper_name", bid) if b.get("proper_name") else bid
|
|
var show_label: bool = inhabited or bid == _hovered_body
|
|
if show_label:
|
|
var lcolor: Color = COLOR_TEXT if bid == _hovered_body else COLOR_TEXT_DIM
|
|
var lsz: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 9)
|
|
var lpos: Vector2 = pos + Vector2(-lsz.x / 2.0, radius + LABEL_OFFSET)
|
|
draw_string(font, lpos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 9, lcolor)
|
|
|
|
|
|
func _draw_stations() -> void:
|
|
var font := get_theme_default_font()
|
|
for s: Dictionary in _orbital_stations:
|
|
var sid: String = str(s.get("station_id", ""))
|
|
if sid.is_empty() or not _station_positions.has(sid):
|
|
continue
|
|
var pos: Vector2 = _station_positions[sid]
|
|
var half: float = STATION_SIZE / 2.0
|
|
var rect: Rect2 = Rect2(pos - Vector2(half, half), Vector2(STATION_SIZE, STATION_SIZE))
|
|
|
|
if sid == _hovered_station:
|
|
draw_rect(
|
|
Rect2(rect.position - Vector2(3, 3), rect.size + Vector2(6, 6)),
|
|
Color(1.0, 1.0, 1.0, 0.18)
|
|
)
|
|
|
|
draw_rect(rect, COLOR_STATION)
|
|
|
|
# Label on hover
|
|
if sid == _hovered_station:
|
|
var label: String = s.get("proper_name", sid) if s.get("proper_name") else sid
|
|
var lsz: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 9)
|
|
draw_string(
|
|
font,
|
|
pos + Vector2(-lsz.x / 2.0, half + 8.0),
|
|
label,
|
|
HORIZONTAL_ALIGNMENT_LEFT,
|
|
-1,
|
|
9,
|
|
COLOR_TEXT_DIM
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Input
|
|
# =============================================================================
|
|
|
|
|
|
func _gui_input(event: InputEvent) -> void:
|
|
if event is InputEventKey and (event as InputEventKey).pressed and not event.is_echo():
|
|
_handle_key(event as InputEventKey)
|
|
elif _level == Level.REACH_MAP:
|
|
_handle_reach_input(event)
|
|
elif _level == Level.ORBITAL_DIAGRAM:
|
|
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
|
|
_handle_orbital_click((event as InputEventMouseButton).position)
|
|
elif event is InputEventMouseMotion:
|
|
_handle_orbital_hover((event as InputEventMouseMotion).position)
|
|
|
|
|
|
func _handle_key(event: InputEventKey) -> void:
|
|
if _level == Level.HEIGHTMAP_VIEWER:
|
|
# Viewer handles its own input via _gui_input; don't double-process.
|
|
return
|
|
match event.keycode:
|
|
KEY_ESCAPE:
|
|
_navigate_back()
|
|
KEY_B:
|
|
HudGroups.close_app()
|
|
KEY_LEFT:
|
|
if _level == Level.SYSTEM_PICKER:
|
|
_navigate_system(-1)
|
|
KEY_RIGHT:
|
|
if _level == Level.SYSTEM_PICKER:
|
|
_navigate_system(1)
|
|
KEY_ENTER, KEY_KP_ENTER:
|
|
if _level == Level.REACH_MAP:
|
|
_reach_enter_selected()
|
|
elif _level == Level.SYSTEM_PICKER:
|
|
_enter_orbital_diagram()
|
|
elif _level == Level.BODY_ENTRY:
|
|
_open_heightmap_viewer()
|
|
|
|
|
|
func _navigate_back() -> void:
|
|
match _level:
|
|
Level.REACH_MAP:
|
|
HudGroups.close_app()
|
|
Level.SYSTEM_PICKER:
|
|
_show_level(Level.REACH_MAP)
|
|
Level.ORBITAL_DIAGRAM:
|
|
_show_level(Level.REACH_MAP)
|
|
Level.BODY_ENTRY:
|
|
_show_level(Level.ORBITAL_DIAGRAM)
|
|
Level.HEIGHTMAP_VIEWER:
|
|
_show_level(Level.BODY_ENTRY)
|
|
|
|
|
|
func _navigate_system(delta: int) -> void:
|
|
if _systems.is_empty():
|
|
return
|
|
_selected_idx = wrapi(_selected_idx + delta, 0, _systems.size())
|
|
_rebuild_picker_panel()
|
|
_refresh_screen_header()
|
|
|
|
|
|
func _handle_orbital_click(pos: Vector2) -> void:
|
|
# Bodies take priority over stations
|
|
var bid: String = _find_nearest_body(pos)
|
|
if not bid.is_empty():
|
|
for b: Dictionary in _orbital_bodies:
|
|
if str(b.get("body_id", "")) == bid:
|
|
_enter_body_entry(b)
|
|
return
|
|
|
|
# Station click → show mini panel (no drill-down per D-191)
|
|
var sid: String = _find_nearest_station(pos)
|
|
if not sid.is_empty():
|
|
for s: Dictionary in _orbital_stations:
|
|
if str(s.get("station_id", "")) == sid:
|
|
_selected_station = s
|
|
_rebuild_station_panel()
|
|
if _station_panel:
|
|
_station_panel.visible = true
|
|
return
|
|
|
|
# Click on empty space — dismiss station panel
|
|
_selected_station = {}
|
|
if _station_panel:
|
|
_station_panel.visible = false
|
|
_dirty = true
|
|
|
|
|
|
func _handle_orbital_hover(pos: Vector2) -> void:
|
|
var new_body: String = _find_nearest_body(pos)
|
|
var new_station: String = "" if not new_body.is_empty() else _find_nearest_station(pos)
|
|
if new_body != _hovered_body or new_station != _hovered_station:
|
|
_hovered_body = new_body
|
|
_hovered_station = new_station
|
|
_dirty = true
|
|
|
|
|
|
func _find_nearest_body(pos: Vector2) -> String:
|
|
var best_dist: float = BODY_HIT_RADIUS
|
|
var best_id: String = ""
|
|
for bid: String in _body_positions:
|
|
var d: float = pos.distance_to(_body_positions[bid])
|
|
if d < best_dist:
|
|
best_dist = d
|
|
best_id = bid
|
|
return best_id
|
|
|
|
|
|
func _find_nearest_station(pos: Vector2) -> String:
|
|
var best_dist: float = BODY_HIT_RADIUS
|
|
var best_id: String = ""
|
|
for sid: String in _station_positions:
|
|
var d: float = pos.distance_to(_station_positions[sid])
|
|
if d < best_dist:
|
|
best_dist = d
|
|
best_id = sid
|
|
return best_id
|
|
|
|
|
|
# =============================================================================
|
|
# Level switching — show/hide panels
|
|
# =============================================================================
|
|
|
|
|
|
func _show_level(level: Level) -> void:
|
|
_level = level
|
|
_dirty = true
|
|
|
|
if _picker_panel:
|
|
_picker_panel.visible = (level == Level.SYSTEM_PICKER)
|
|
if _body_panel:
|
|
_body_panel.visible = (level == Level.BODY_ENTRY)
|
|
# Station panel managed separately — remains hidden until a station click
|
|
if _station_panel and level != Level.ORBITAL_DIAGRAM:
|
|
_station_panel.visible = false
|
|
if _viewer:
|
|
_viewer.visible = (level == Level.HEIGHTMAP_VIEWER)
|
|
if _reach_info_panel:
|
|
_reach_info_panel.visible = (level == Level.REACH_MAP and _reach_selected != "")
|
|
|
|
_refresh_screen_header()
|
|
queue_redraw()
|
|
|
|
|
|
# =============================================================================
|
|
# Panel construction — D-169 ImplantPanel component library
|
|
# =============================================================================
|
|
|
|
|
|
func _build_picker_panel() -> void:
|
|
_picker_panel = ImplantPanel.new()
|
|
_picker_panel.name = "PickerPanel"
|
|
_picker_panel.theme_resource = _implant_theme
|
|
_picker_panel.custom_minimum_size.x = PANEL_WIDTH
|
|
_picker_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_picker_panel.position = Vector2(PANEL_MARGIN, 60.0)
|
|
add_child(_picker_panel)
|
|
_rebuild_picker_panel()
|
|
|
|
|
|
func _rebuild_picker_panel() -> void:
|
|
if not _picker_panel:
|
|
return
|
|
_picker_panel.clear()
|
|
|
|
var sys: Dictionary = _current_system()
|
|
var sys_name: String = sys.get("proper_name", sys.get("system_id", "—"))
|
|
var sys_id: String = sys.get("system_id", "")
|
|
var sector: String = sys.get("geographic_sector", "").replace("_", " ").to_upper()
|
|
var czone: String = sys.get("currency_zone", "").replace("_", " ")
|
|
var total: int = _systems.size()
|
|
|
|
_picker_panel.add_component(ImplantHeader.new("ATLAS", sys_name))
|
|
_picker_panel.add_component(ImplantSeparator.new())
|
|
|
|
_picker_nav_row = ImplantDataRow.new("◄ ► [%d / %d]" % [_selected_idx + 1, total])
|
|
_picker_panel.add_component(_picker_nav_row)
|
|
|
|
_picker_panel.add_component(ImplantSeparator.new())
|
|
_picker_panel.add_component(ImplantDataRow.new(sys_id))
|
|
if not sector.is_empty():
|
|
_picker_panel.add_component(ImplantDataRow.new(sector + " CORRIDOR"))
|
|
if not czone.is_empty():
|
|
_picker_panel.add_component(ImplantDataRow.new("currency " + czone))
|
|
_picker_panel.add_component(ImplantDataRow.new(sys.get("bodies", "—")))
|
|
_picker_panel.add_component(ImplantDataRow.new("pop " + sys.get("population", "—")))
|
|
|
|
_picker_panel.add_component(ImplantSeparator.new())
|
|
_picker_panel.add_component(ImplantTextBlock.new("enter open orbital map"))
|
|
_picker_panel.add_component(ImplantTextBlock.new("esc close atlas"))
|
|
|
|
_dirty = true
|
|
|
|
|
|
func _build_body_panel() -> void:
|
|
_body_panel = ImplantPanel.new()
|
|
_body_panel.name = "BodyPanel"
|
|
_body_panel.theme_resource = _implant_theme
|
|
_body_panel.custom_minimum_size.x = PANEL_WIDTH
|
|
_body_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_body_panel.position = Vector2(PANEL_MARGIN, 60.0)
|
|
_body_panel.visible = false
|
|
add_child(_body_panel)
|
|
|
|
|
|
func _rebuild_body_panel() -> void:
|
|
if not _body_panel:
|
|
return
|
|
_body_panel.clear()
|
|
|
|
var b: Dictionary = _selected_body
|
|
var bid: String = b.get("body_id", "")
|
|
var name_str: String = b.get("proper_name", "") if b.get("proper_name") else bid
|
|
var body_type: String = b.get("body_type", "").replace("_", " ").to_upper()
|
|
var mass_class: String = b.get("mass_class", "") if b.get("mass_class") else ""
|
|
var atmo: String = b.get("atmosphere", "none") if b.get("atmosphere") else "none"
|
|
var inhabited: bool = bool(b.get("inhabited", false))
|
|
var pop: int = int(b.get("population", 0))
|
|
var has_heightmap: bool = b.get("terrain_reference") != null
|
|
|
|
var sys: Dictionary = _current_system()
|
|
var sys_name: String = sys.get("proper_name", sys.get("system_id", "—"))
|
|
|
|
_body_panel.add_component(ImplantHeader.new(name_str, sys_name + " system"))
|
|
_body_panel.add_component(ImplantSeparator.new())
|
|
|
|
var type_line: String = body_type
|
|
if not mass_class.is_empty():
|
|
type_line += " · " + mass_class.replace("_", " ").to_upper()
|
|
_body_panel.add_component(ImplantDataRow.new(type_line))
|
|
_body_panel.add_component(ImplantDataRow.new("atmosphere " + atmo))
|
|
|
|
if inhabited:
|
|
_body_panel.add_component(ImplantSeparator.new())
|
|
_body_panel.add_component(ImplantDataRow.new("population " + _format_pop(pop)))
|
|
|
|
_body_panel.add_component(ImplantSeparator.new())
|
|
|
|
if has_heightmap:
|
|
_body_panel.add_component(ImplantTextBlock.new("enter view heightmap atlas"))
|
|
else:
|
|
_body_panel.add_component(ImplantTextBlock.new("atlas data pending (#839)"))
|
|
|
|
_body_panel.add_component(ImplantTextBlock.new("esc back to orbital view"))
|
|
|
|
|
|
func _build_station_panel() -> void:
|
|
_station_panel = ImplantPanel.new()
|
|
_station_panel.name = "StationPanel"
|
|
_station_panel.theme_resource = _implant_theme
|
|
_station_panel.custom_minimum_size.x = PANEL_WIDTH
|
|
_station_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_station_panel.position = Vector2(PANEL_MARGIN, 60.0)
|
|
_station_panel.visible = false
|
|
add_child(_station_panel)
|
|
|
|
|
|
func _build_heightmap_viewer() -> void:
|
|
_viewer = AtlasViewer.new()
|
|
_viewer.name = "AtlasViewer"
|
|
_viewer.visible = false
|
|
add_child(_viewer)
|
|
_viewer.back_pressed.connect(_on_viewer_back)
|
|
_viewer.economics_link_requested.connect(_on_viewer_economics_link)
|
|
|
|
|
|
func _on_viewer_back() -> void:
|
|
_show_level(Level.BODY_ENTRY)
|
|
|
|
|
|
func _on_viewer_economics_link(system_id: String) -> void:
|
|
economics_link_requested.emit(system_id)
|
|
# Close the atlas; main.gd will open economics monitor in its place.
|
|
HudGroups.close_app()
|
|
|
|
|
|
func _rebuild_station_panel() -> void:
|
|
if not _station_panel:
|
|
return
|
|
_station_panel.clear()
|
|
|
|
if _selected_station.is_empty():
|
|
return
|
|
|
|
var s: Dictionary = _selected_station
|
|
var s_name: String = (
|
|
s.get("proper_name", "") if s.get("proper_name") else s.get("station_id", "—")
|
|
)
|
|
var s_type: String = s.get("station_type", "").replace("_", " ").to_upper()
|
|
var gov: String = s.get("governance_type", "") if s.get("governance_type") else ""
|
|
var role: String = s.get("economic_role", "") if s.get("economic_role") else ""
|
|
var sys: Dictionary = _current_system()
|
|
var czone: String = sys.get("currency_zone", "") if sys.get("currency_zone") else "—"
|
|
czone = czone.replace("_", " ")
|
|
|
|
_station_panel.add_component(ImplantHeader.new(s_name, s_type + " STATION"))
|
|
_station_panel.add_component(ImplantSeparator.new())
|
|
if not gov.is_empty():
|
|
_station_panel.add_component(
|
|
ImplantDataRow.new("operator " + gov.replace("_", " ").to_upper())
|
|
)
|
|
if not role.is_empty():
|
|
_station_panel.add_component(ImplantDataRow.new("function " + role.replace("_", " ")))
|
|
_station_panel.add_component(ImplantDataRow.new("currency " + czone))
|
|
_station_panel.add_component(ImplantSeparator.new())
|
|
_station_panel.add_component(ImplantTextBlock.new("station atlas deferred"))
|
|
_station_panel.add_component(ImplantTextBlock.new("esc back"))
|
|
|
|
|
|
# =============================================================================
|
|
# Helpers
|
|
# =============================================================================
|
|
|
|
|
|
func _format_pop(pop: int) -> String:
|
|
if pop <= 0:
|
|
return "0"
|
|
var s: String = str(pop)
|
|
var result: String = ""
|
|
var count: int = 0
|
|
for i: int in range(s.length() - 1, -1, -1):
|
|
if count > 0 and count % 3 == 0:
|
|
result = "," + result
|
|
result = s[i] + result
|
|
count += 1
|
|
return result
|
|
|
|
|
|
# =============================================================================
|
|
# REACH_MAP — hop-ring view (D-191: Reach map is Level 0 of the atlas hierarchy)
|
|
# =============================================================================
|
|
|
|
|
|
func _build_reach_info_panel() -> void:
|
|
_reach_info_panel = ImplantPanel.new()
|
|
_reach_info_panel.name = "ReachInfoPanel"
|
|
_reach_info_panel.theme_resource = _implant_theme
|
|
_reach_info_panel.custom_minimum_size.x = REACH_POPUP_WIDTH
|
|
_reach_info_panel.size.x = REACH_POPUP_WIDTH
|
|
_reach_info_panel.visible = false
|
|
_reach_info_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
add_child(_reach_info_panel)
|
|
|
|
|
|
func _rebuild_reach_info_panel() -> void:
|
|
if not _reach_info_panel:
|
|
return
|
|
_reach_info_panel.clear()
|
|
|
|
var node: Dictionary = _reach_node_lookup.get(_reach_selected, {})
|
|
if node.is_empty():
|
|
_reach_info_panel.visible = false
|
|
return
|
|
|
|
var sys_name: String = node.get("proper_name", "")
|
|
if sys_name.is_empty():
|
|
sys_name = node.get("system_id", "Unknown")
|
|
_reach_info_panel.add_component(ImplantHeader.new(sys_name, node.get("system_id", "")))
|
|
_reach_info_panel.add_component(ImplantSeparator.new())
|
|
|
|
var star_type: String = node.get("star_type", "")
|
|
if not star_type.is_empty():
|
|
_reach_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 = REACH_SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM)
|
|
_reach_info_panel.add_component(
|
|
ImplantDataRow.new("%s corridor (hop %d)" % [sector_str, hop], sector_color)
|
|
)
|
|
|
|
var bodies: String = node.get("bodies", "")
|
|
if not bodies.is_empty():
|
|
_reach_info_panel.add_component(ImplantDataRow.new(bodies))
|
|
|
|
var population: String = node.get("population", "")
|
|
var gdp: String = node.get("gdp", "")
|
|
if not population.is_empty() or not gdp.is_empty():
|
|
_reach_info_panel.add_component(ImplantDataRow.new(""))
|
|
if not population.is_empty():
|
|
_reach_info_panel.add_component(ImplantDataRow.new("pop " + population))
|
|
var gdp_label: String = "gdp " + (gdp if not gdp.is_empty() else "—")
|
|
_reach_info_panel.add_component(ImplantDataRow.new(gdp_label))
|
|
|
|
var gttr: String = node.get("gttr_excerpt", "")
|
|
if not gttr.is_empty():
|
|
_reach_info_panel.add_component(ImplantSeparator.new())
|
|
_reach_info_panel.add_component(ImplantTextBlock.new(gttr, REACH_POPUP_GTTR_MAX_LINES))
|
|
|
|
_reach_info_panel.add_component(ImplantSeparator.new())
|
|
_reach_info_panel.add_component(ImplantTextBlock.new("enter open orbital map"))
|
|
|
|
_reach_info_panel.visible = true
|
|
|
|
|
|
func _compute_reach_layout() -> void:
|
|
_reach_positions.clear()
|
|
|
|
var rings: Dictionary = {}
|
|
for node: Dictionary in _systems:
|
|
var hop: int = int(node.get("hop_distance", 0))
|
|
if not rings.has(hop):
|
|
rings[hop] = []
|
|
rings[hop].append(node)
|
|
|
|
for hop: int in rings:
|
|
var ring_nodes: Array = rings[hop]
|
|
var radius: float = REACH_MIN_RING_RADIUS + hop * REACH_RING_SPACING
|
|
|
|
if hop == 0:
|
|
for node: Dictionary in ring_nodes:
|
|
_reach_positions[node["system_id"]] = Vector2.ZERO
|
|
continue
|
|
|
|
ring_nodes.sort_custom(_reach_sort_by_sector_angle)
|
|
|
|
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 = REACH_CORE_ANGLE_SPREAD
|
|
elif sector == "deep_frontier":
|
|
center_angle = 0.0
|
|
spread = REACH_DEEP_FRONTIER_ANGLE_SPREAD
|
|
elif REACH_SECTOR_ANGLE_CENTER.has(sector):
|
|
center_angle = REACH_SECTOR_ANGLE_CENTER[sector]
|
|
spread = REACH_SECTOR_ANGLE_SPREAD
|
|
else:
|
|
center_angle = 0.0
|
|
spread = TAU
|
|
|
|
for i: int in range(count):
|
|
var node: Dictionary = group[i]
|
|
var t: float = 0.0 if count == 1 else float(i) / float(count) - 0.5
|
|
var angle: float = center_angle + t * spread
|
|
var jitter: float = _reach_system_hash(node["system_id"]) * 0.08
|
|
angle += jitter
|
|
var r_var: float = (
|
|
radius + _reach_system_hash(node["system_id"] + "r") * REACH_RING_SPACING * 0.3
|
|
)
|
|
_reach_positions[node["system_id"]] = Vector2(cos(angle), sin(angle)) * r_var
|
|
|
|
|
|
func _reach_sort_by_sector_angle(a: Dictionary, b: Dictionary) -> bool:
|
|
var sa: float = _reach_sector_sort_key(a)
|
|
var sb: float = _reach_sector_sort_key(b)
|
|
if sa != sb:
|
|
return sa < sb
|
|
return a.get("system_id", "") < b.get("system_id", "")
|
|
|
|
|
|
func _reach_sector_sort_key(node: Dictionary) -> float:
|
|
match node.get("geographic_sector", "unknown"):
|
|
"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
|
|
|
|
|
|
func _reach_system_hash(key: String) -> float:
|
|
var h: int = key.hash() & 0x7FFFFFFF
|
|
return float(h) / 2147483647.0 * 2.0 - 1.0
|
|
|
|
|
|
func _reach_dot_radius(topology: String) -> float:
|
|
match topology:
|
|
"hub":
|
|
return REACH_DOT_RADIUS_HUB
|
|
"junction":
|
|
return REACH_DOT_RADIUS_JUNCTION
|
|
"dead_end":
|
|
return REACH_DOT_RADIUS_DEAD_END
|
|
_:
|
|
return REACH_DOT_RADIUS_DEFAULT # gdlint:ignore = max-returns
|
|
|
|
|
|
# =============================================================================
|
|
# REACH_MAP drawing
|
|
# =============================================================================
|
|
|
|
|
|
func _draw_reach() -> void:
|
|
var sz: Vector2 = get_rect().size
|
|
var center: Vector2 = sz * REACH_MAP_CENTER_FRACTION + _reach_pan
|
|
|
|
draw_rect(Rect2(Vector2.ZERO, sz), COLOR_BG)
|
|
_draw_reach_rings(center)
|
|
_draw_reach_sector_labels(center)
|
|
|
|
if _reach_selected != "":
|
|
_draw_reach_edges(center)
|
|
|
|
_draw_reach_systems(center)
|
|
|
|
if _reach_selected != "":
|
|
_draw_reach_selection(center)
|
|
|
|
# Position info panel in top-right corner
|
|
if _reach_info_panel and _reach_info_panel.visible:
|
|
_reach_info_panel.reset_size()
|
|
var px: float = sz.x - REACH_POPUP_WIDTH - REACH_POPUP_MARGIN
|
|
var py: float = REACH_POPUP_MARGIN
|
|
var panel_h: float = _reach_info_panel.size.y
|
|
if panel_h > 0.0 and py + panel_h > sz.y - REACH_POPUP_MARGIN:
|
|
py = sz.y - panel_h - REACH_POPUP_MARGIN
|
|
px = maxf(REACH_POPUP_MARGIN, px)
|
|
py = maxf(REACH_POPUP_MARGIN, py)
|
|
_reach_info_panel.position = Vector2(px, py)
|
|
|
|
|
|
func _draw_reach_rings(center: Vector2) -> void:
|
|
for hop: int in range(REACH_MAX_HOP_RINGS + 1):
|
|
var radius: float = (REACH_MIN_RING_RADIUS + hop * REACH_RING_SPACING) * _reach_zoom
|
|
if radius < 1.0 or radius > 2000.0:
|
|
continue
|
|
var color: Color = REACH_COLOR_RING_MAJOR if hop % 5 == 0 else REACH_COLOR_RING
|
|
draw_arc(center, radius, 0.0, TAU, 64, color, 0.5 if hop % 5 == 0 else 0.3, true)
|
|
|
|
|
|
func _draw_reach_sector_labels(center: Vector2) -> void:
|
|
var label_radius: float = (REACH_MIN_RING_RADIUS + 12 * REACH_RING_SPACING) * _reach_zoom
|
|
for sector: String in REACH_SECTOR_LABELS:
|
|
var angle: float = REACH_SECTOR_ANGLE_CENTER.get(sector, 0.0)
|
|
var pos: Vector2 = center + Vector2(cos(angle), sin(angle)) * label_radius
|
|
var label: String = REACH_SECTOR_LABELS[sector]
|
|
var color: Color = REACH_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_reach_systems(center: Vector2) -> void:
|
|
for node: Dictionary in _systems:
|
|
var sid: String = node.get("system_id", "")
|
|
if not _reach_positions.has(sid):
|
|
continue
|
|
var pos: Vector2 = center + _reach_positions[sid] * _reach_zoom
|
|
var sector: String = node.get("geographic_sector", "unknown")
|
|
var topology: String = node.get("gate_topology", "")
|
|
var color: Color = REACH_SECTOR_COLORS.get(sector, COLOR_TEXT_DIM)
|
|
var radius: float = _reach_dot_radius(topology)
|
|
|
|
if node.get("is_gateway", false):
|
|
color = REACH_COLOR_GATEWAY
|
|
radius = REACH_GATEWAY_RADIUS
|
|
|
|
if sector == "deep_frontier":
|
|
color.a = 0.7
|
|
|
|
if sid == _reach_hovered and sid != _reach_selected:
|
|
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)
|
|
|
|
var label: String = node.get("proper_name", "")
|
|
if not label.is_empty() and label != sid:
|
|
var show_label := false
|
|
if sid == _reach_selected or sid == _reach_hovered:
|
|
show_label = true
|
|
elif _reach_zoom >= 2.0:
|
|
show_label = true
|
|
elif _reach_zoom >= 1.2:
|
|
show_label = topology in ["hub", "junction", ""]
|
|
if show_label:
|
|
var label_color: Color = (
|
|
COLOR_TEXT if sid == _reach_selected or sid == _reach_hovered else 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_reach_edges(center: Vector2) -> void:
|
|
var node: Dictionary = _reach_node_lookup.get(_reach_selected, {})
|
|
var adj: Array = node.get("adjacent_systems", [])
|
|
if adj.is_empty():
|
|
return
|
|
var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, REACH_EDGE_SELECTED_ALPHA)
|
|
var sel_pos: Vector2 = (
|
|
center + _reach_positions.get(_reach_selected, Vector2.ZERO) * _reach_zoom
|
|
)
|
|
for neighbor_id: String in adj:
|
|
if not _reach_positions.has(neighbor_id):
|
|
continue
|
|
var neighbor_pos: Vector2 = center + _reach_positions[neighbor_id] * _reach_zoom
|
|
draw_line(sel_pos, neighbor_pos, color, REACH_EDGE_WIDTH, true)
|
|
|
|
|
|
func _draw_reach_selection(center: Vector2) -> void:
|
|
if not _reach_positions.has(_reach_selected):
|
|
return
|
|
var pos: Vector2 = center + _reach_positions[_reach_selected] * _reach_zoom
|
|
draw_arc(pos, REACH_SELECTION_RING_RADIUS, 0.0, TAU, 24, REACH_COLOR_SELECTION, 1.2, true)
|
|
|
|
|
|
# =============================================================================
|
|
# REACH_MAP input
|
|
# =============================================================================
|
|
|
|
|
|
func _handle_reach_input(event: InputEvent) -> void:
|
|
if event is InputEventMouseButton:
|
|
var mb := event as InputEventMouseButton
|
|
if mb.pressed:
|
|
match mb.button_index:
|
|
MOUSE_BUTTON_LEFT:
|
|
_handle_reach_click(mb.position)
|
|
MOUSE_BUTTON_MIDDLE:
|
|
_reach_is_panning = true
|
|
_reach_pan_start = mb.position
|
|
_reach_pan_start_offset = _reach_pan
|
|
MOUSE_BUTTON_WHEEL_UP:
|
|
var old_zoom := _reach_zoom
|
|
_reach_zoom = clampf(_reach_zoom + REACH_ZOOM_STEP, REACH_ZOOM_MIN, REACH_ZOOM_MAX)
|
|
if _reach_zoom != old_zoom:
|
|
_dirty = true
|
|
MOUSE_BUTTON_WHEEL_DOWN:
|
|
var old_zoom := _reach_zoom
|
|
_reach_zoom = clampf(_reach_zoom - REACH_ZOOM_STEP, REACH_ZOOM_MIN, REACH_ZOOM_MAX)
|
|
if _reach_zoom != old_zoom:
|
|
_dirty = true
|
|
else:
|
|
if mb.button_index == MOUSE_BUTTON_MIDDLE:
|
|
_reach_is_panning = false
|
|
elif event is InputEventMouseMotion:
|
|
var mm := event as InputEventMouseMotion
|
|
if _reach_is_panning:
|
|
_reach_pan = _reach_pan_start_offset + (mm.position - _reach_pan_start)
|
|
_dirty = true
|
|
else:
|
|
_update_reach_hover(mm.position)
|
|
|
|
|
|
func _handle_reach_click(pos: Vector2) -> void:
|
|
var sid := _find_nearest_reach_system(pos)
|
|
_reach_selected = sid
|
|
_rebuild_reach_info_panel()
|
|
_dirty = true
|
|
|
|
|
|
func _update_reach_hover(pos: Vector2) -> void:
|
|
var nearest := _find_nearest_reach_system(pos)
|
|
if nearest != _reach_hovered:
|
|
_reach_hovered = nearest
|
|
_dirty = true
|
|
|
|
|
|
func _find_nearest_reach_system(pos: Vector2) -> String:
|
|
var sz: Vector2 = get_rect().size
|
|
var center: Vector2 = sz * REACH_MAP_CENTER_FRACTION + _reach_pan
|
|
var best_dist: float = REACH_HIT_RADIUS
|
|
var best_sid: String = ""
|
|
for node: Dictionary in _systems:
|
|
var sid: String = node.get("system_id", "")
|
|
if not _reach_positions.has(sid):
|
|
continue
|
|
var node_pos: Vector2 = center + _reach_positions[sid] * _reach_zoom
|
|
var dist: float = pos.distance_to(node_pos)
|
|
if dist < best_dist:
|
|
best_dist = dist
|
|
best_sid = sid
|
|
return best_sid
|
|
|
|
|
|
func _reach_system_index(system_id: String) -> int:
|
|
for i: int in range(_systems.size()):
|
|
if _systems[i].get("system_id", "") == system_id:
|
|
return i
|
|
return -1
|
|
|
|
|
|
func _reach_enter_selected() -> void:
|
|
if _reach_selected.is_empty():
|
|
return
|
|
var idx := _reach_system_index(_reach_selected)
|
|
if idx >= 0:
|
|
_selected_idx = idx
|
|
_enter_orbital_diagram()
|