From 768f37fd83c6910a6b2fafc6a015409a2bcef1ba Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 3 May 2026 21:02:54 +0200 Subject: [PATCH] feat(ui): inline body detail panel on system orbital view Body info panel with globe artwork renders on the right side of the system map instead of navigating to a separate screen. Single-click shows/swaps panel, double-click opens regional heightmap view, ESC closes panel. Station panel also moved to right side for consistency. Moon and station positioning clears parent body radius + label space. Co-Authored-By: Claude Opus 4.6 --- client/ui/implant/apps/atlas/atlas_app.gd | 37 ++--- .../apps/atlas/screens/system_screen.gd | 155 +++++++++++++++++- 2 files changed, 165 insertions(+), 27 deletions(-) diff --git a/client/ui/implant/apps/atlas/atlas_app.gd b/client/ui/implant/apps/atlas/atlas_app.gd index 11d7f9f79..8b5bdbf83 100644 --- a/client/ui/implant/apps/atlas/atlas_app.gd +++ b/client/ui/implant/apps/atlas/atlas_app.gd @@ -72,7 +72,9 @@ func _handle_key(event: InputEventKey) -> void: KEY_M: HudGroups.close_app() KEY_ESCAPE: - if current_screen_id() == "reach": + if current_screen_id() == "system" and _system_screen and (_system_screen.has_body_panel_open() or _system_screen.has_station_panel_open()): + _system_screen.close_panels() + elif current_screen_id() == "reach": HudGroups.close_app() else: nav.pop() @@ -95,18 +97,13 @@ func _handle_enter() -> void: if _system_screen and not _system_screen.is_in_orbital(): var sys: Dictionary = _system_screen.current_system() nav.replace("system", {"mode": "orbital", "system": sys}) - "planet": - if _planet_screen and _planet_screen.has_heightmap(): - ( - nav - . push( - "regional", - { - "body": _planet_screen.current_body(), - "system": nav.current_payload().get("system", {}), - } - ) - ) + elif _system_screen and _system_screen.has_body_panel_open(): + var body: Dictionary = _system_screen.get_selected_body() + if body.get("terrain_reference") != null: + nav.push("regional", { + "body": body, + "system": nav.current_payload().get("system", {}), + }) # ============================================================================= @@ -123,16 +120,10 @@ func _on_system_selected(system_id: String) -> void: func _on_body_selected(body: Dictionary) -> void: - ( - nav - . push( - "planet", - { - "body": body, - "system": nav.current_payload().get("system", {}), - } - ) - ) + nav.push("regional", { + "body": body, + "system": nav.current_payload().get("system", {}), + }) func _on_regional_back() -> void: diff --git a/client/ui/implant/apps/atlas/screens/system_screen.gd b/client/ui/implant/apps/atlas/screens/system_screen.gd index 20f2ae0e0..5391a3deb 100644 --- a/client/ui/implant/apps/atlas/screens/system_screen.gd +++ b/client/ui/implant/apps/atlas/screens/system_screen.gd @@ -40,12 +40,14 @@ var _body_textures: Dictionary = {} # body_id -> ImageTexture var _hovered_body: String = "" var _hovered_station: String = "" var _selected_station: Dictionary = {} +var _selected_body: Dictionary = {} var _dirty: bool = true var _in_orbital: bool = false var _picker_panel = null # ImplantPanel var _picker_nav_row = null # ImplantDataRow var _station_panel = null # ImplantPanel +var _body_panel = null # ImplantPanel func _ready() -> void: @@ -64,6 +66,7 @@ func _process(_delta: float) -> void: func setup(implant_theme) -> void: _build_picker_panel(implant_theme) _build_station_panel(implant_theme) + _build_body_panel(implant_theme) func set_systems(systems: Array) -> void: @@ -118,12 +121,15 @@ func load_and_show_orbital() -> void: _hovered_body = "" _hovered_station = "" _selected_station = {} + _selected_body = {} _load_body_textures(sys.get("system_id", "")) _compute_body_positions() if _picker_panel: _picker_panel.visible = false if _station_panel: _station_panel.visible = false + if _body_panel: + _body_panel.visible = false _in_orbital = true _dirty = true @@ -338,11 +344,15 @@ func _compute_body_positions() -> void: continue var parent_id: Variant = s.get("orbits_body_id") var station_parent_pos: Vector2 + var parent_r: float = 0.0 if parent_id != null and _body_positions.has(str(parent_id)): station_parent_pos = _body_positions[str(parent_id)] + var pd: Dictionary = body_dict_by_id.get(str(parent_id), {}) + parent_r = _km_to_draw_radius(float(pd.get("body_radius_km", 0)), pd.get("body_type", "")) else: station_parent_pos = Vector2(sz.x - BODY_RIGHT_MARGIN, mid_y) - _station_positions[station_id] = station_parent_pos + Vector2(0.0, STATION_OFFSET_Y) + var station_y: float = -(parent_r + LABEL_OFFSET + 12.0 + STATION_SIZE + MOON_CLEARANCE) + _station_positions[station_id] = station_parent_pos + Vector2(0.0, station_y) # ============================================================================= @@ -354,12 +364,16 @@ 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) + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_LEFT and mb.double_click: + _handle_orbital_double_click(mb.position) + elif mb.button_index == MOUSE_BUTTON_LEFT: + _handle_orbital_click(mb.position) elif event is InputEventMouseMotion: _handle_orbital_hover((event as InputEventMouseMotion).position) -func _handle_orbital_click(pos: Vector2) -> void: +func _handle_orbital_double_click(pos: Vector2) -> void: var bid: String = _find_nearest_body(pos) if not bid.is_empty(): for b: Dictionary in _orbital_bodies: @@ -367,22 +381,65 @@ func _handle_orbital_click(pos: Vector2) -> void: body_selected.emit(b) return + +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: + _selected_body = b + _selected_station = {} + if _station_panel: + _station_panel.visible = false + _rebuild_body_panel() + if _body_panel: + _body_panel.visible = true + _dirty = true + 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 + _selected_body = {} + if _body_panel: + _body_panel.visible = false _rebuild_station_panel() if _station_panel: _station_panel.visible = true return + _close_detail_panels() + _dirty = true + + +func _close_detail_panels() -> void: + _selected_body = {} _selected_station = {} + if _body_panel: + _body_panel.visible = false if _station_panel: _station_panel.visible = false + + +func has_body_panel_open() -> bool: + return not _selected_body.is_empty() + + +func has_station_panel_open() -> bool: + return not _selected_station.is_empty() + + +func close_panels() -> void: + _close_detail_panels() _dirty = true +func get_selected_body() -> Dictionary: + return _selected_body + + 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) @@ -517,7 +574,6 @@ func _build_station_panel(implant_theme) -> void: _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) @@ -530,6 +586,9 @@ func _rebuild_station_panel() -> void: if _selected_station.is_empty(): return + var sz: Vector2 = get_rect().size + _station_panel.position = Vector2(sz.x - PANEL_WIDTH - 60.0, 60.0) + var s: Dictionary = _selected_station var s_name: String = ( s.get("proper_name", "") if s.get("proper_name") else s.get("station_id", "—") @@ -553,3 +612,91 @@ func _rebuild_station_panel() -> void: _station_panel.add_component(ImplantSeparator.new()) _station_panel.add_component(ImplantTextBlock.new("station atlas deferred")) _station_panel.add_component(ImplantTextBlock.new("esc back")) + + +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.visible = false + add_child(_body_panel) + + +func _rebuild_body_panel() -> void: + if not _body_panel: + return + _body_panel.clear() + + if _selected_body.is_empty(): + return + + var sz: Vector2 = get_rect().size + _body_panel.position = Vector2(sz.x - PANEL_WIDTH - 60.0, 60.0) + + 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 radius_km: float = float(b.get("body_radius_km", 0)) + + var sys: Dictionary = current_system() + var sys_name: String = sys.get("proper_name", sys.get("system_id", "—")) + + # Globe artwork at top + var tex: ImageTexture = _body_textures.get(bid) + if tex: + var globe_rect := TextureRect.new() + globe_rect.texture = tex + globe_rect.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL + globe_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + globe_rect.custom_minimum_size = Vector2(PANEL_WIDTH, PANEL_WIDTH) + _body_panel.add_component(globe_rect) + + _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 radius_km > 0: + _body_panel.add_component(ImplantDataRow.new("radius " + _format_radius(radius_km))) + + if inhabited: + _body_panel.add_component(ImplantSeparator.new()) + _body_panel.add_component(ImplantDataRow.new("population " + _format_pop(pop))) + + _body_panel.add_component(ImplantSeparator.new()) + var has_heightmap: bool = b.get("terrain_reference") != null + if has_heightmap: + _body_panel.add_component(ImplantTextBlock.new("enter view heightmap atlas")) + _body_panel.add_component(ImplantTextBlock.new("esc close")) + + _dirty = true + + +static 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 + + +static func _format_radius(km: float) -> String: + if km >= 10000.0: + return "%s km" % _format_pop(int(km)) + return "%.0f km" % km