refactor(ui): split atlas_panel.gd into 4 sub-widgets under 500 lines each

atlas_panel.gd exceeded gdlint's 1000-line limit after the REACH_MAP level
was added in #844. Split into:
  - atlas_panel.gd (298 lines)   — shell: HudGroups reg, level enum, nav, key handler, screen header
  - atlas_reach_map.gd (494)     — Level 0 REACH_MAP hop-ring view; emits enter_system
  - atlas_system_map.gd (495)    — Level 1/2 SYSTEM_PICKER + ORBITAL_DIAGRAM; emits enter_body
  - atlas_planet_map.gd (146)    — Level 3/4 BODY_ENTRY + HEIGHTMAP_VIEWER; emits back_to_viewer_body

Shell owns all level transitions. Sub-widgets emit signals, never call show_level().
All four pass gdlint with no warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 12:27:27 +02:00
co-authored by Claude Sonnet 4.6
parent be9cfa2fca
commit 03060ff2e1
5 changed files with 1289 additions and 1109 deletions
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -2,10 +2,9 @@
[ext_resource type="Script" path="res://ui/implant/atlas_panel.gd" id="1_atlas"]
; #834: Atlas implant panel — 3-level navigation: system picker → orbital diagram → body entry.
; FULLSCREEN app (z=20) at implant/map/atlas per D-170.
; Composed from ImplantPanel component library (D-169). Toggle with A key from main.gd.
; Data from star_map_data.json (orbit_bodies + stations arrays added by generate-star-map-data.py).
; #844: Atlas implant panel — 5-level navigation: reach map → system picker → orbital → body entry → heightmap.
; FULLSCREEN app (z=20) at implant/map per D-170. Shell delegates to AtlasReachMap / AtlasSystemMap / AtlasPlanetMap.
; Toggle with M key from main.gd. Data from star_map_data.json.
[node name="AtlasPanel" type="Control"]
layout_mode = 3
+146
View File
@@ -0,0 +1,146 @@
class_name AtlasPlanetMap
extends Control
## Level 3 BODY_ENTRY and Level 4 HEIGHTMAP_VIEWER widget for AtlasPanel (#844, D-191).
## Shows body detail panel and delegates heightmap rendering to AtlasViewer.
signal back_to_viewer_body()
signal economics_link_requested(system_id: String)
const PANEL_WIDTH: float = 320.0
const PANEL_MARGIN: float = 16.0
const COLOR_BG: Color = Color("#0d1117")
var _selected_body: Dictionary = {}
var _current_sys: Dictionary = {}
var _body_panel = null # ImplantPanel
var _viewer = null # AtlasViewer
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
func _draw() -> void:
if _viewer == null or not _viewer.visible:
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
func setup(implant_theme) -> void:
_build_body_panel(implant_theme)
_build_heightmap_viewer()
func set_body(body: Dictionary, system: Dictionary) -> void:
_selected_body = body
_current_sys = system
_rebuild_body_panel()
func show_body_mode() -> void:
if _body_panel:
_body_panel.visible = true
if _viewer:
_viewer.visible = false
queue_redraw()
func show_viewer_mode() -> void:
if _viewer == null:
return
_viewer.show_body(_selected_body, _current_sys)
if _body_panel:
_body_panel.visible = false
_viewer.visible = true
# =============================================================================
# Panels
# =============================================================================
func _build_body_panel(implant_theme) -> 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_name: String = _current_sys.get("proper_name", _current_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_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:
back_to_viewer_body.emit()
func _on_viewer_economics_link(system_id: String) -> void:
economics_link_requested.emit(system_id)
# =============================================================================
# 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
+494
View File
@@ -0,0 +1,494 @@
class_name AtlasReachMap
extends Control
## Level 0 REACH_MAP widget for AtlasPanel (#844, D-191).
## Hop-ring view of the Settled Reach gate network.
## Emits enter_system(system_id) when the player commits to a system.
signal enter_system(system_id: String)
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
const COLOR_BG: Color = Color("#0d1117")
const COLOR_TEXT: Color = Color("#c8d0e0")
const COLOR_TEXT_DIM: Color = Color("#667788")
var _systems: Array = []
var _reach_positions: Dictionary = {}
var _reach_node_lookup: Dictionary = {}
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
var _dirty: bool = true
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
func _process(_delta: float) -> void:
if not visible:
return
if _dirty:
queue_redraw()
_dirty = false
func setup(implant_theme) -> void:
_build_reach_info_panel(implant_theme)
func set_systems(systems: Array, node_lookup: Dictionary) -> void:
_systems = systems
_reach_node_lookup = node_lookup
_compute_reach_layout()
_dirty = true
func has_selection() -> bool:
return not _reach_selected.is_empty()
func refresh_info_panel_visibility() -> void:
if _reach_info_panel:
_reach_info_panel.visible = not _reach_selected.is_empty()
func trigger_enter() -> void:
_reach_enter_selected()
# =============================================================================
# Info panel
# =============================================================================
func _build_reach_info_panel(implant_theme) -> 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
# =============================================================================
# Layout computation
# =============================================================================
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
# =============================================================================
# Drawing
# =============================================================================
func _draw() -> 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)
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)
# =============================================================================
# Input
# =============================================================================
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_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
if _reach_system_index(_reach_selected) >= 0:
enter_system.emit(_reach_selected)
+495
View File
@@ -0,0 +1,495 @@
class_name AtlasSystemMap
extends Control
## Level 1 SYSTEM_PICKER and Level 2 ORBITAL_DIAGRAM widget for AtlasPanel (#844, D-191).
## Manages alphabetic system picker and orbital diagram rendering for the current system.
## Emits enter_body(body) when the player clicks a body in orbital view.
signal enter_body(body: Dictionary)
const ORBITAL_CENTER_FRACTION := Vector2(0.5, 0.55)
const STAR_RADIUS: float = 12.0
const ORBITAL_RING_BASE: float = 65.0
const ORBITAL_RING_STEP: float = 58.0
const MOON_ORBIT_RADIUS: float = 24.0
const STATION_SIZE: float = 6.0
const BODY_HIT_RADIUS: float = 16.0
const LABEL_OFFSET: float = 11.0
const PANEL_WIDTH: float = 320.0
const PANEL_MARGIN: float = 16.0
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")
var _systems: Array = []
var _selected_idx: int = 0
var _orbital_bodies: Array = []
var _orbital_stations: Array = []
var _body_positions: Dictionary = {}
var _station_positions: Dictionary = {}
var _hovered_body: String = ""
var _hovered_station: String = ""
var _selected_station: Dictionary = {}
var _dirty: bool = true
var _in_orbital: bool = false # false = picker view, true = orbital view
var _picker_panel = null # ImplantPanel
var _picker_nav_row = null # ImplantDataRow
var _station_panel = null # ImplantPanel
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
func _process(_delta: float) -> void:
if not visible:
return
if _dirty:
queue_redraw()
_dirty = false
func setup(implant_theme) -> void:
_build_picker_panel(implant_theme)
_build_station_panel(implant_theme)
func set_systems(systems: Array) -> void:
_systems = systems
func set_selected_idx(idx: int) -> void:
_selected_idx = idx
func get_selected_idx() -> int:
return _selected_idx
func current_system() -> Dictionary:
if _systems.is_empty():
return {}
_selected_idx = clampi(_selected_idx, 0, _systems.size() - 1)
return _systems[_selected_idx]
func get_orbital_body_count() -> int:
var count: int = 0
for b: Dictionary in _orbital_bodies:
if b.get("parent_body_id") == null:
count += 1
return count
func get_station_count() -> int:
return _orbital_stations.size()
func show_picker_mode() -> void:
_in_orbital = false
if _picker_panel:
_picker_panel.visible = true
_rebuild_picker_panel()
if _station_panel:
_station_panel.visible = false
_dirty = true
func load_and_show_orbital() -> 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 _picker_panel:
_picker_panel.visible = false
if _station_panel:
_station_panel.visible = false
_in_orbital = true
_dirty = true
func navigate_system(delta: int) -> void:
if _systems.is_empty():
return
_selected_idx = wrapi(_selected_idx + delta, 0, _systems.size())
_rebuild_picker_panel()
# =============================================================================
# Drawing
# =============================================================================
func _draw() -> void:
if _in_orbital:
_draw_orbital()
else:
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)
_draw_orbit_rings(center)
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)
_draw_stations()
_draw_bodies()
func _draw_orbit_rings(center: Vector2) -> void:
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 faint suggestion, not a solid 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
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)
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)
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
)
# =============================================================================
# Orbital geometry
# =============================================================================
func _compute_body_positions() -> void:
_body_positions.clear()
_station_positions.clear()
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var center: Vector2 = sz * ORBITAL_CENTER_FRACTION
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))
)
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
else:
angle = -PI / 2.0 + TAU * float(i) / float(count)
_body_positions[bid] = center + Vector2(cos(angle), sin(angle)) * ring_r
# Moons per parent count drives spacing — hard-coded divisor caused overlap on gas giants
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
)
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
_station_positions[station_id] = station_parent_pos + Vector2(20.0, -10.0)
# =============================================================================
# Input
# =============================================================================
func _gui_input(event: InputEvent) -> void:
if not _in_orbital:
return
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_orbital_click(pos: Vector2) -> void:
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.emit(b)
return
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
_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
# =============================================================================
# Panels
# =============================================================================
func _build_picker_panel(implant_theme) -> 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_station_panel(implant_theme) -> 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 _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"))