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 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 21:02:54 +02:00
co-authored by Claude Opus 4.6
parent b9fd75b840
commit 768f37fd83
2 changed files with 165 additions and 27 deletions
+14 -23
View File
@@ -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:
@@ -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