feat(ui): atlas system orbital redesign — horizontal orrery with globe textures

Half-sun on left edge, planets left-to-right with radius-based sizing
(Earth = 24px reference, unclamped — gas giants fill the view). Globe
textures loaded from wiki at runtime. Double-click on reach map opens
system view. M closes atlas from any screen, ESC navigates back.

Fixes Godot 4.6 type inference parse errors in main.gd (var zoom/speed).
Uses physical_keycode throughout for layout-independent key handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 20:11:16 +02:00
co-authored by Claude Opus 4.6
parent e6a557e8e7
commit e8b4dc74b5
4 changed files with 142 additions and 84 deletions
+5 -5
View File
@@ -194,7 +194,7 @@ func _unhandled_input(event: InputEvent) -> void:
if GameState.free_camera_mode and event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.pressed:
var zoom := camera.zoom
var zoom: Vector2 = camera.zoom
if mb.button_index == MOUSE_BUTTON_WHEEL_UP:
zoom += Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP)
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
@@ -221,7 +221,7 @@ func _unhandled_key_input(event: InputEvent) -> void:
if manifest.app_path.is_empty():
push_warning("main.gd: manifest with empty app_path — skipping")
continue
if manifest.default_key == key_event.keycode:
if manifest.default_key == key_event.physical_keycode:
HudGroups.toggle_app(
manifest.app_path,
ImplantRegistry.get_resolved_mode(manifest.app_path)
@@ -230,10 +230,10 @@ func _unhandled_key_input(event: InputEvent) -> void:
# [ / ] — in-app navigation for the economics monitor. Not manifest-declared because
# these control intra-app navigation (prev/next system), not app launch. A planned
# handle_global_key lifecycle hook will absorb this (see arch doc Follow-up).
if key_event.keycode == KEY_BRACKETLEFT:
if key_event.physical_keycode == KEY_BRACKETLEFT:
if economics_app and HudGroups.is_app_active("implant/economics"):
economics_app.navigate(-1)
elif key_event.keycode == KEY_BRACKETRIGHT:
elif key_event.physical_keycode == KEY_BRACKETRIGHT:
if economics_app and HudGroups.is_app_active("implant/economics"):
economics_app.navigate(1)
@@ -279,7 +279,7 @@ func _process(delta: float) -> void:
if Input.is_action_pressed("move_west"):
pan.x -= 1.0
if pan != Vector2.ZERO:
var speed := FREE_CAMERA_PAN_SPEED / camera.zoom.x
var speed: float = FREE_CAMERA_PAN_SPEED / camera.zoom.x
camera.global_position += pan.normalized() * speed * delta
# Track camera to player (D-015: locked, fixed-north).
# #117: Manual exponential smoothing.
+4 -2
View File
@@ -62,13 +62,15 @@ func _unhandled_key_input(event: InputEvent) -> void:
if not event.is_pressed() or event.is_echo():
return
if current_screen_id() == "regional":
return # AtlasViewer handles its own keyboard input
return
_handle_key(event as InputEventKey)
get_viewport().set_input_as_handled()
func _handle_key(event: InputEventKey) -> void:
match event.keycode:
match event.physical_keycode:
KEY_M:
HudGroups.close_app()
KEY_ESCAPE:
if current_screen_id() == "reach":
HudGroups.close_app()
@@ -438,7 +438,10 @@ func _gui_input(event: InputEvent) -> void:
if mb.pressed:
match mb.button_index:
MOUSE_BUTTON_LEFT:
_handle_reach_click(mb.position)
if mb.double_click:
_handle_reach_double_click(mb.position)
else:
_handle_reach_click(mb.position)
MOUSE_BUTTON_MIDDLE:
_reach_is_panning = true
_reach_pan_start = mb.position
@@ -476,6 +479,13 @@ func _handle_reach_click(pos: Vector2) -> void:
_dirty = true
func _handle_reach_double_click(pos: Vector2) -> void:
var sid := _find_nearest_reach_system(pos)
if not sid.is_empty():
_reach_selected = sid
system_selected.emit(sid)
func _update_reach_hover(pos: Vector2) -> void:
var nearest := _find_nearest_reach_system(pos)
if nearest != _reach_hovered:
@@ -6,14 +6,15 @@ extends Control
signal body_selected(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 STAR_RADIUS: float = 140.0
const STAR_X_OFFSET: float = 0.0
const BODY_LEFT_MARGIN: float = 300.0
const BODY_RIGHT_MARGIN: float = 60.0
const MOON_CLEARANCE: float = 12.0
const STATION_SIZE: float = 8.0
const STATION_OFFSET_Y: float = -26.0
const BODY_HIT_RADIUS: float = 20.0
const LABEL_OFFSET: float = 14.0
const PANEL_WIDTH: float = 320.0
const PANEL_MARGIN: float = 16.0
@@ -35,6 +36,7 @@ var _orbital_bodies: Array = []
var _orbital_stations: Array = []
var _body_positions: Dictionary = {}
var _station_positions: Dictionary = {}
var _body_textures: Dictionary = {} # body_id -> ImageTexture
var _hovered_body: String = ""
var _hovered_station: String = ""
var _selected_station: Dictionary = {}
@@ -116,6 +118,7 @@ func load_and_show_orbital() -> void:
_hovered_body = ""
_hovered_station = ""
_selected_station = {}
_load_body_textures(sys.get("system_id", ""))
_compute_body_positions()
if _picker_panel:
_picker_panel.visible = false
@@ -157,27 +160,30 @@ func _draw() -> void:
func _draw_orbital() -> void:
var sz: Vector2 = get_rect().size
var center: Vector2 = sz * ORBITAL_CENTER_FRACTION
var mid_y: float = sz.y * 0.5
var star_center := Vector2(STAR_X_OFFSET, mid_y)
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_circle(star_center, STAR_RADIUS + 40.0, Color(COLOR_STAR.r, COLOR_STAR.g, COLOR_STAR.b, 0.04))
draw_circle(star_center, STAR_RADIUS + 20.0, Color(COLOR_STAR.r, COLOR_STAR.g, COLOR_STAR.b, 0.08))
draw_circle(star_center, STAR_RADIUS, COLOR_STAR)
_draw_orbital_arcs(star_center)
_draw_stations()
_draw_bodies()
func _draw_orbit_rings(center: Vector2) -> void:
var seen_orbits: Dictionary = {}
func _draw_orbital_arcs(star_center: Vector2) -> void:
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):
var bid: String = str(b.get("body_id", ""))
if not _body_positions.has(bid):
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)
var body_pos: Vector2 = _body_positions[bid]
var orbit_r: float = body_pos.x - star_center.x
if orbit_r <= 0.0:
continue
draw_arc(star_center, orbit_r, -PI * 0.7, PI * 0.7, 64, COLOR_RING, 0.5, true)
func _draw_bodies() -> void:
@@ -188,36 +194,31 @@ func _draw_bodies() -> void:
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":
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
var radius_km: float = float(b.get("body_radius_km", 0))
var radius: float = _km_to_draw_radius(radius_km, body_type)
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_arc(pos, radius + 5.0, 0.0, TAU, 20, Color(1.0, 1.0, 1.0, 0.25), 1.5, true)
draw_circle(pos, radius, color)
var tex: ImageTexture = _body_textures.get(bid)
if tex:
var draw_size := Vector2(radius * 2.0, radius * 2.0)
draw_texture_rect(tex, Rect2(pos - draw_size / 2.0, draw_size), false)
else:
var atmo: String = b.get("atmosphere", "none")
var inhabited: bool = bool(b.get("inhabited", false))
var color: Color
match body_type:
"moon": color = COLOR_MOON
"oort_cloud": color = COLOR_OORT
_:
if inhabited: color = COLOR_PLANET_INHABITED
elif atmo in ["breathable", "standard"]: color = COLOR_PLANET_HABITABLE
else: color = COLOR_PLANET_BARE
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
var show_label: bool = body_type != "oort_cloud" 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)
@@ -269,7 +270,8 @@ func _compute_body_positions() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var center: Vector2 = sz * ORBITAL_CENTER_FRACTION
var mid_y: float = sz.y * 0.5
var usable_width: float = sz.x - BODY_LEFT_MARGIN - BODY_RIGHT_MARGIN
var top_bodies: Array = []
for b: Dictionary in _orbital_bodies:
@@ -280,30 +282,16 @@ func _compute_body_positions() -> void:
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)
var count: int = top_bodies.size()
for i: int in range(count):
var b: Dictionary = top_bodies[i]
var bid: String = str(b.get("body_id", ""))
if bid.is_empty():
continue
var t: float = float(i) / float(maxi(count - 1, 1))
var x: float = BODY_LEFT_MARGIN + t * usable_width
_body_positions[bid] = Vector2(x, mid_y)
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")
@@ -313,6 +301,10 @@ func _compute_body_positions() -> void:
if not moons_by_parent.has(key):
moons_by_parent[key] = []
moons_by_parent[key].append(b)
var body_dict_by_id: Dictionary = {}
for b: Dictionary in _orbital_bodies:
body_dict_by_id[str(b.get("body_id", ""))] = b
for parent_key: String in moons_by_parent:
if not _body_positions.has(parent_key):
continue
@@ -322,16 +314,23 @@ func _compute_body_positions() -> void:
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 parent_data: Dictionary = body_dict_by_id.get(parent_key, {})
var parent_draw_r: float = _km_to_draw_radius(
float(parent_data.get("body_radius_km", 0)),
parent_data.get("body_type", "")
)
var moon_count: int = siblings.size()
for j: int in range(moon_count):
var moon: Dictionary = siblings[j]
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
var moon_draw_r: float = _km_to_draw_radius(
float(moon.get("body_radius_km", 0)), "moon"
)
var label_space: float = LABEL_OFFSET + 12.0
var y_off: float = parent_draw_r + label_space + MOON_CLEARANCE + (moon_draw_r * 2.0 + MOON_CLEARANCE) * float(j)
_body_positions[moon_id] = parent_pos + Vector2(0.0, y_off)
for s: Dictionary in _orbital_stations:
var station_id: String = str(s.get("station_id", ""))
@@ -342,8 +341,8 @@ func _compute_body_positions() -> void:
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)
station_parent_pos = Vector2(sz.x - BODY_RIGHT_MARGIN, mid_y)
_station_positions[station_id] = station_parent_pos + Vector2(0.0, STATION_OFFSET_Y)
# =============================================================================
@@ -415,6 +414,53 @@ func _find_nearest_station(pos: Vector2) -> String:
return best_id
# =============================================================================
# Globe textures
# =============================================================================
func _load_body_textures(system_id: String) -> void:
_body_textures.clear()
var sys_dir: String = system_id.replace(" ", "-")
var wiki_root: String = ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir() + "/wiki/star-systems"
for b: Dictionary in _orbital_bodies:
var bid: String = str(b.get("body_id", ""))
if bid.is_empty():
continue
var bt: String = b.get("body_type", "")
var globe_path: String
if bt in ["oort_cloud", "asteroid_belt"]:
globe_path = wiki_root + "/_generics/" + bt + "/globe.png"
else:
globe_path = wiki_root + "/" + sys_dir + "/bodies/" + bid + "/globe.png"
if not FileAccess.file_exists(globe_path):
continue
var img := Image.load_from_file(globe_path)
if img == null:
continue
var tex := ImageTexture.create_from_image(img)
_body_textures[bid] = tex
# =============================================================================
# Sizing
# =============================================================================
const DRAW_RADIUS_MIN: float = 4.0
const DRAW_RADIUS_MAX: float = 48.0
const DRAW_RADIUS_REF_KM: float = 6371.0
const DRAW_RADIUS_REF_PX: float = 24.0
static func _km_to_draw_radius(radius_km: float, _body_type: String) -> float:
if radius_km <= 0.0:
return DRAW_RADIUS_MIN
var ratio: float = radius_km / DRAW_RADIUS_REF_KM
var px: float = DRAW_RADIUS_REF_PX * pow(ratio, 0.5)
return maxf(px, DRAW_RADIUS_MIN)
# =============================================================================
# Panels
# =============================================================================