diff --git a/client/ui/implant/atlas_marker_overlay.gd b/client/ui/implant/atlas_marker_overlay.gd index d5a8bbc4f..b3bfa2f5d 100644 --- a/client/ui/implant/atlas_marker_overlay.gd +++ b/client/ui/implant/atlas_marker_overlay.gd @@ -172,23 +172,55 @@ func _draw_cities(markers: Dictionary) -> void: if cities.is_empty(): return var font := ThemeDB.fallback_font - var hovered: Dictionary = viewer.get_hovered_city() - var selected: Dictionary = viewer.get_selected_city() + # Compare by stable key, not Dictionary.== — deep equality was O(fields) + # per city per redraw (review #13). `name` is unique per body in the + # D-191 §8 schema; fall back to `body_id` or the hash of the dict for + # un-named markers so the comparison still works in transitional data. + var hovered_key: String = _city_key(viewer.get_hovered_city()) + var selected_key: String = _city_key(viewer.get_selected_city()) for c: Dictionary in cities: var pos: Vector2 = viewer.city_canvas_pos(c) var tier: int = int(c.get("population_tier", 1)) var r: float = 2.5 + float(tier) * 0.8 + var key: String = _city_key(c) + var is_selected: bool = not selected_key.is_empty() and key == selected_key + var is_hovered: bool = not hovered_key.is_empty() and key == hovered_key var col: Color = COLOR_CITY - if c == selected: + if is_selected: col = COLOR_CITY_SELECTED - elif c == hovered: + elif is_hovered: col = COLOR_CITY_HOVER draw_circle(pos, r + 1.0, Color(0.0, 0.0, 0.0, 0.55)) draw_circle(pos, r, col) var name_str: String = c.get("name", "") if c.get("name") else "" - if not name_str.is_empty() and (tier >= 3 or c == hovered or c == selected): - var lcolor: Color = COLOR_CITY_HOVER if c == hovered or c == selected else Color(0.88, 0.90, 0.96, 0.85) - draw_string(font, pos + Vector2(r + 2.0, r * 0.4), name_str, HORIZONTAL_ALIGNMENT_LEFT, -1, 8, lcolor) + if not name_str.is_empty() and (tier >= 3 or is_hovered or is_selected): + var lcolor: Color = ( + COLOR_CITY_HOVER if is_hovered or is_selected + else Color(0.88, 0.90, 0.96, 0.85) + ) + draw_string( + font, pos + Vector2(r + 2.0, r * 0.4), name_str, + HORIZONTAL_ALIGNMENT_LEFT, -1, 8, lcolor + ) + + +static func _city_key(city: Dictionary) -> String: + if city.is_empty(): + return "" + var name_val: Variant = city.get("name") + if name_val != null and not str(name_val).is_empty(): + return "n:" + str(name_val) + var id_val: Variant = city.get("city_id") + if id_val != null and not str(id_val).is_empty(): + return "i:" + str(id_val) + # Last resort: positional key from pos/lat/lon so two unnamed cities at + # different coordinates still compare unequal. + var pos: Variant = city.get("pos") + if pos != null: + return "p:" + str(pos) + if city.has("lat") and city.has("lon"): + return "ll:%s:%s" % [city["lat"], city["lon"]] + return "h:%d" % city.hash() # ============================================================================= diff --git a/client/ui/implant/atlas_panel.gd b/client/ui/implant/atlas_panel.gd index 5dfa901bd..546ae5b7a 100644 --- a/client/ui/implant/atlas_panel.gd +++ b/client/ui/implant/atlas_panel.gd @@ -85,7 +85,6 @@ func _ready() -> void: HudGroups.register(self, APP_PATH) HudGroups.app_changed.connect(_on_app_changed) - # Autoload parse-order rule: load via load() in method body, not _ready() cache _implant_theme = load("res://ui/implant/default_implant.tres") _load_system_list() @@ -225,12 +224,15 @@ func _compute_body_positions() -> void: 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 # top position (12 o'clock) else: angle = -PI / 2.0 + TAU * float(i) / float(count) - _body_positions[b["body_id"]] = center + Vector2(cos(angle), sin(angle)) * ring_r + _body_positions[bid] = center + Vector2(cos(angle), sin(angle)) * ring_r # Place moons near their parent body. Moons per parent count drives the # angular spacing — a hard-coded divisor made the 5th+ moon overlap moon 1 @@ -256,20 +258,26 @@ func _compute_body_positions() -> void: 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["body_id"]] = ( + _body_positions[moon_id] = ( parent_pos + Vector2(cos(angle), sin(angle)) * MOON_ORBIT_RADIUS ) # Place stations near their parent body (offset right + slightly up) 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 parent_pos: Vector2 + var station_parent_pos: Vector2 if parent_id != null and _body_positions.has(str(parent_id)): - parent_pos = _body_positions[str(parent_id)] + station_parent_pos = _body_positions[str(parent_id)] else: - parent_pos = center # fallback to star position - _station_positions[s["station_id"]] = parent_pos + Vector2(20.0, -10.0) + station_parent_pos = center # fallback to star position + _station_positions[station_id] = station_parent_pos + Vector2(20.0, -10.0) # ============================================================================= @@ -382,8 +390,8 @@ func _draw_orbit_rings(center: Vector2) -> void: func _draw_bodies() -> void: var font := get_theme_default_font() for b: Dictionary in _orbital_bodies: - var bid: String = b["body_id"] - if not _body_positions.has(bid): + 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", "") @@ -430,8 +438,8 @@ func _draw_bodies() -> void: func _draw_stations() -> void: var font := get_theme_default_font() for s: Dictionary in _orbital_stations: - var sid: String = s["station_id"] - if not _station_positions.has(sid): + 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 @@ -511,7 +519,7 @@ 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 b["body_id"] == bid: + if str(b.get("body_id", "")) == bid: _enter_body_entry(b) return @@ -519,7 +527,7 @@ func _handle_orbital_click(pos: Vector2) -> void: var sid: String = _find_nearest_station(pos) if not sid.is_empty(): for s: Dictionary in _orbital_stations: - if s["station_id"] == sid: + if str(s.get("station_id", "")) == sid: _selected_station = s _rebuild_station_panel() if _station_panel: @@ -627,7 +635,7 @@ func _rebuild_picker_panel() -> void: _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", "no body data"))) + _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()) @@ -701,10 +709,7 @@ func _build_station_panel() -> void: func _build_heightmap_viewer() -> void: - # #835: Regional viewer instanced at runtime (parse-order rule — AtlasMarkerOverlay - # class_name is resolved by the time this _ready() runs since AtlasPanel is not an autoload). - var ViewerScript := load("res://ui/implant/atlas_viewer.gd") - _viewer = ViewerScript.new() + _viewer = AtlasViewer.new() _viewer.name = "AtlasViewer" _viewer.visible = false add_child(_viewer)