From e8b4dc74b58e1f596cba356c35d890fb5538a6ab Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 3 May 2026 20:11:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20atlas=20system=20orbital=20redesign?= =?UTF-8?q?=20=E2=80=94=20horizontal=20orrery=20with=20globe=20textures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/scripts/main.gd | 10 +- client/ui/implant/apps/atlas/atlas_app.gd | 6 +- .../apps/atlas/screens/reach_screen.gd | 12 +- .../apps/atlas/screens/system_screen.gd | 198 +++++++++++------- 4 files changed, 142 insertions(+), 84 deletions(-) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 46a20f5c6..6a1b0e9dd 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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. diff --git a/client/ui/implant/apps/atlas/atlas_app.gd b/client/ui/implant/apps/atlas/atlas_app.gd index ad40d4b10..11d7f9f79 100644 --- a/client/ui/implant/apps/atlas/atlas_app.gd +++ b/client/ui/implant/apps/atlas/atlas_app.gd @@ -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() diff --git a/client/ui/implant/apps/atlas/screens/reach_screen.gd b/client/ui/implant/apps/atlas/screens/reach_screen.gd index b258300b7..d8b5487cf 100644 --- a/client/ui/implant/apps/atlas/screens/reach_screen.gd +++ b/client/ui/implant/apps/atlas/screens/reach_screen.gd @@ -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: diff --git a/client/ui/implant/apps/atlas/screens/system_screen.gd b/client/ui/implant/apps/atlas/screens/system_screen.gd index d83c80845..20f2ae0e0 100644 --- a/client/ui/implant/apps/atlas/screens/system_screen.gd +++ b/client/ui/implant/apps/atlas/screens/system_screen.gd @@ -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 # =============================================================================