feat(client): atlas roads/settlements overlays + legend + server-API data delivery (T-960, T-949)
T-960: gen_l2_roads (MaintenanceAuthority-colored polylines, rail styling, junction markers) + gen_l3_settlements (size-scaled markers, capital shape, name labels) overlays — cities render on generated bodies for the first time; left-side generation legend panel (D-226 item 3, data-driven per-overlay spec, implant component library); protocol.gd decodes road_graph/settlements + the two new response types. T-949: system_index/atlas_app/overview_screen migrated off the direct star_map_data.json read to StarMapRequest over the bridge (loading state + replay-on-connect, no silent file fallback); atlas_viewer _load_markers requests CityNamesResponse for non-Sol bodies; Sol keeps the legacy authored markers.json geometry read (D-236/T-1073, load-bearing guard). Lead fix: _send_star_map_request now carries the same guard as request_star_map — the autoload's _star_map_wanted leaked across gdUnit suites and the unguarded replay-on-CONNECTED crashed 8 pre-existing flow tests on a Nil bridge; reset_test_state clears the flag. Fixtures regenerated via gen_fixtures (road/settlement samples). Full suite 2946/2946. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,10 @@ func _ready() -> void:
|
||||
|
||||
func on_install() -> void:
|
||||
_load_system_data()
|
||||
# T-949: the star map now arrives over the bridge, possibly after this app
|
||||
# is already installed (or before the handshake completes at all) — refresh
|
||||
# once it lands instead of assuming _load_system_data's first call has data.
|
||||
SimBridge.star_map_received.connect(_on_star_map_received)
|
||||
var implant_theme = load("res://ui/implant/default_implant.tres")
|
||||
|
||||
_reach_screen = ReachScreen.new()
|
||||
@@ -72,7 +76,10 @@ func _handle_key(event: InputEventKey) -> void:
|
||||
KEY_M:
|
||||
HudGroups.close_app()
|
||||
KEY_ESCAPE:
|
||||
if current_screen_id() == "system" and _system_screen and (_system_screen.has_body_panel_open() or _system_screen.has_station_panel_open()):
|
||||
var has_open_panel: bool = _system_screen and (
|
||||
_system_screen.has_body_panel_open() or _system_screen.has_station_panel_open()
|
||||
)
|
||||
if current_screen_id() == "system" and has_open_panel:
|
||||
_system_screen.close_panels()
|
||||
elif current_screen_id() == "reach":
|
||||
HudGroups.close_app()
|
||||
@@ -134,6 +141,19 @@ func _forward_economics_link(system_id: String) -> void:
|
||||
economics_link_requested.emit(system_id)
|
||||
|
||||
|
||||
## T-949: the star map arrived — cache it, rebuild the local system
|
||||
## list/lookup, and push it into whichever screens already exist. set_systems()
|
||||
## is safe to call again after initial setup (ReachScreen/SystemScreen both
|
||||
## just recompute their layout from the new data).
|
||||
func _on_star_map_received(response: Dictionary) -> void:
|
||||
SystemIndex.ingest(response)
|
||||
_load_system_data()
|
||||
if _reach_screen:
|
||||
_reach_screen.set_systems(_systems, _system_lookup)
|
||||
if _system_screen:
|
||||
_system_screen.set_systems(_systems)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
@@ -147,6 +167,8 @@ func _system_idx_by_id(system_id: String) -> int:
|
||||
|
||||
|
||||
func _load_system_data() -> void:
|
||||
SystemIndex.request_refresh()
|
||||
_systems = SystemIndex.get_sorted_systems()
|
||||
_system_lookup.clear()
|
||||
for node: Dictionary in _systems:
|
||||
_system_lookup[node.get("system_id", "")] = node
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
extends ImplantPanel
|
||||
|
||||
## Left-side generation-overlay legend (D-226 item 3, T-960) — the shape/color
|
||||
## key for the generation overlay group (attractor types, sub-biome colors,
|
||||
## district morphology, road/rail authority + style, settlement size/capital).
|
||||
##
|
||||
## This script has no `class_name` on purpose, mirroring atlas_overlay_bar.gd
|
||||
## (review #8 there): the owner (AtlasViewer) passes the viewer reference to
|
||||
## _init(), and a `class_name` + required-arg _init() combo is a Godot editor
|
||||
## footgun. Instance it via
|
||||
## load("res://ui/implant/apps/atlas/atlas_legend_panel.gd").new(self).
|
||||
##
|
||||
## Data-driven (GENERATION_LEGEND below): one spec entry per generation
|
||||
## overlay id — multiple entries may share an id (e.g. gen_l1_attractors has
|
||||
## both a shape key and a color key, D-226's shape-vs-color separation taught
|
||||
## the same way here as it is drawn on the map). Adding a future layer's
|
||||
## legend is a new table row, never a layout change. refresh() shows only the
|
||||
## sections whose overlay is currently toggled on, and hides the whole panel
|
||||
## when none are active (invisible when not needed).
|
||||
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
const LEGEND_PANEL_WIDTH: float = 260.0
|
||||
|
||||
# Generation overlay colors (T-960) — kept in sync with the actual render
|
||||
# values in atlas_marker_overlay.gd; duplicated rather than cross-referenced,
|
||||
# matching the existing COLOR_ROAD/COLOR_RAIL/COLOR_CITY precedent shared
|
||||
# between atlas_viewer.gd and atlas_marker_overlay.gd (each file owns its own
|
||||
# reading of the palette: this one for the legend, the marker overlay for the
|
||||
# draw calls).
|
||||
const COLOR_ROAD_ADMINISTRATIVE: Color = Color(0.45, 0.65, 0.90, 0.85)
|
||||
const COLOR_ROAD_CORPORATE: Color = Color(0.85, 0.65, 0.20, 0.85)
|
||||
const COLOR_ROAD_COMMUNAL: Color = Color(0.45, 0.75, 0.50, 0.85)
|
||||
const COLOR_ROAD_TRADE: Color = Color(0.85, 0.60, 0.30, 0.85)
|
||||
const COLOR_ROAD_ABANDONED: Color = Color(0.45, 0.42, 0.38, 0.65)
|
||||
const COLOR_SETTLEMENT_GEN: Color = Color(0.94, 0.82, 0.38, 1.0)
|
||||
const COLOR_SETTLEMENT_CAPITAL_GEN: Color = Color(1.0, 0.92, 0.55, 1.0)
|
||||
|
||||
## One entry per generation-overlay id. "color": Color.TRANSPARENT means
|
||||
## "shape/style carries the meaning here, let the theme's dim text color
|
||||
## apply" — used for every row where color is NOT the encoded axis.
|
||||
const GENERATION_LEGEND: Array = [
|
||||
{
|
||||
"overlay_id": "gen_l1_rivers",
|
||||
"title": "RIVERS — L1",
|
||||
"rows": [
|
||||
{"glyph": "━", "color": Color(0.353, 0.647, 0.776, 1.0), "label": "channel / confluence"},
|
||||
{"glyph": "◎", "color": Color(0.353, 0.647, 0.776, 1.0), "label": "sea mouth"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"overlay_id": "gen_l1_basins",
|
||||
"title": "DRAINAGE BASINS — L1",
|
||||
"rows": [
|
||||
{"glyph": "▭", "color": Color(0.45, 0.65, 0.85, 0.70), "label": "basin fill + boundary"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"overlay_id": "gen_l1_attractors",
|
||||
"title": "ATTRACTORS — L1 (shape = type)",
|
||||
"rows": [
|
||||
{"glyph": "●", "color": Color.TRANSPARENT, "label": "river mouth"},
|
||||
{"glyph": "◑", "color": Color.TRANSPARENT, "label": "coastal access"},
|
||||
{"glyph": "◆", "color": Color.TRANSPARENT, "label": "river crossing"},
|
||||
{"glyph": "▼", "color": Color.TRANSPARENT, "label": "valley floor"},
|
||||
{"glyph": "▲", "color": Color.TRANSPARENT, "label": "pass entrance"},
|
||||
{"glyph": "○", "color": Color.TRANSPARENT, "label": "lake shore"},
|
||||
{"glyph": "■", "color": Color.TRANSPARENT, "label": "plain center"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"overlay_id": "gen_l1_attractors",
|
||||
"title": "SUB-BIOME — L1 (color)",
|
||||
"rows": [
|
||||
{"glyph": "●", "color": Color(0.25, 0.72, 0.65, 0.90), "label": "tropical / coastal"},
|
||||
{"glyph": "●", "color": Color(0.45, 0.68, 0.45, 0.90), "label": "temperate"},
|
||||
{"glyph": "●", "color": Color(0.78, 0.62, 0.35, 0.90), "label": "arid"},
|
||||
{"glyph": "●", "color": Color(0.52, 0.58, 0.72, 0.90), "label": "alpine"},
|
||||
{"glyph": "●", "color": Color(0.40, 0.60, 0.52, 0.90), "label": "wetland"},
|
||||
{"glyph": "●", "color": Color(0.55, 0.68, 0.82, 0.90), "label": "cold"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"overlay_id": "gen_district",
|
||||
"title": "DISTRICT MORPHOLOGY — coarse",
|
||||
"rows": [
|
||||
{"glyph": "▦", "color": Color.TRANSPARENT, "label": "terrain-colored fill (17 zones)"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"overlay_id": "gen_l2_roads",
|
||||
"title": "ROADS/RAIL — L2 (color = authority)",
|
||||
"rows": [
|
||||
{"glyph": "━", "color": COLOR_ROAD_ADMINISTRATIVE, "label": "administrative"},
|
||||
{"glyph": "━", "color": COLOR_ROAD_CORPORATE, "label": "corporate"},
|
||||
{"glyph": "━", "color": COLOR_ROAD_COMMUNAL, "label": "communal"},
|
||||
{"glyph": "━", "color": COLOR_ROAD_TRADE, "label": "trade"},
|
||||
{"glyph": "━", "color": COLOR_ROAD_ABANDONED, "label": "abandoned"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"overlay_id": "gen_l2_roads",
|
||||
"title": "LINE STYLE (road vs rail)",
|
||||
"rows": [
|
||||
{"glyph": "━", "color": Color.TRANSPARENT, "label": "road (solid)"},
|
||||
{"glyph": "┄", "color": Color.TRANSPARENT, "label": "rail (dashed)"},
|
||||
{"glyph": "◇", "color": Color.TRANSPARENT, "label": "junction (3+ ways)"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"overlay_id": "gen_l3_settlements",
|
||||
"title": "SETTLEMENTS — L3 (size = population)",
|
||||
"rows": [
|
||||
{"glyph": "●", "color": COLOR_SETTLEMENT_GEN, "label": "settlement"},
|
||||
{"glyph": "★", "color": COLOR_SETTLEMENT_CAPITAL_GEN, "label": "capital / major hub"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
var _viewer = null # AtlasViewer (untyped to avoid cyclic ref)
|
||||
|
||||
|
||||
func _init(viewer_ref = null) -> void:
|
||||
_viewer = viewer_ref
|
||||
custom_minimum_size.x = LEGEND_PANEL_WIDTH
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
visible = false
|
||||
|
||||
|
||||
func reposition() -> void:
|
||||
position = Vector2(PANEL_MARGIN, 60.0)
|
||||
|
||||
|
||||
## Rebuilds from GENERATION_LEGEND, showing only the sections whose overlay is
|
||||
## currently toggled on — invisible when no generation overlay is active,
|
||||
## updates every time AtlasViewer.set_overlay_visible() runs.
|
||||
func refresh() -> void:
|
||||
if _viewer == null:
|
||||
return
|
||||
var active_specs: Array = []
|
||||
for spec: Dictionary in GENERATION_LEGEND:
|
||||
if _viewer.is_overlay_visible(str(spec.get("overlay_id", ""))):
|
||||
active_specs.append(spec)
|
||||
|
||||
clear()
|
||||
visible = not active_specs.is_empty()
|
||||
if active_specs.is_empty():
|
||||
return
|
||||
|
||||
add_component(
|
||||
ImplantHeader.new("GENERATION LEGEND", "%d layer(s) active" % active_specs.size())
|
||||
)
|
||||
add_component(ImplantSeparator.new())
|
||||
for i: int in range(active_specs.size()):
|
||||
var spec: Dictionary = active_specs[i]
|
||||
add_component(ImplantTextBlock.new(str(spec.get("title", ""))))
|
||||
for row: Dictionary in spec.get("rows", []):
|
||||
var text: String = "%s %s" % [str(row.get("glyph", "-")), str(row.get("label", ""))]
|
||||
add_component(ImplantDataRow.new(text, row.get("color", Color.TRANSPARENT)))
|
||||
if i < active_specs.size() - 1:
|
||||
add_component(ImplantSeparator.new())
|
||||
reposition()
|
||||
@@ -16,6 +16,8 @@ extends Node2D
|
||||
## corp_presence (toggleable) Tier 1 corp dots placeholder
|
||||
## stockpile_weeks (locked) gated by corporate contact
|
||||
## production_vs_baseline (locked) gated by insider access
|
||||
## gen_l2_roads (toggleable) T-960 — road/rail graph, color = authority
|
||||
## gen_l3_settlements (toggleable) T-960 — settlement placements, size = population
|
||||
|
||||
const COLOR_HEIGHTMAP_TINT: Color = Color(0.85, 0.88, 0.95, 1.0)
|
||||
const COLOR_POLITICAL: Color = Color(0.25, 0.50, 0.75, 0.14)
|
||||
@@ -41,6 +43,75 @@ const COLOR_GEN_BASIN_FILL: Color = Color(0.20, 0.35, 0.55, 0.06)
|
||||
const COLOR_GEN_BASIN_LINE: Color = Color(0.45, 0.65, 0.85, 0.45)
|
||||
const GEN_ATTRACTOR_MIN_STRENGTH: float = 0.15
|
||||
|
||||
## Sub-biome → marker color (Araminta's palette, D-226). Grouped pairs share a
|
||||
## color since they read the same on the map; see _sub_biome_color.
|
||||
const SUB_BIOME_COLORS: Dictionary = {
|
||||
"TropicalWet": Color(0.25, 0.72, 0.65, 0.90), # teal — coastal/tropical
|
||||
"CoastalLowland": Color(0.25, 0.72, 0.65, 0.90),
|
||||
"TemperateForest": Color(0.45, 0.68, 0.45, 0.90), # sage — temperate
|
||||
"TemperateGrassland": Color(0.45, 0.68, 0.45, 0.90),
|
||||
"Desert": Color(0.78, 0.62, 0.35, 0.90), # sand — arid
|
||||
"Savanna": Color(0.78, 0.62, 0.35, 0.90),
|
||||
"Alpine": Color(0.52, 0.58, 0.72, 0.90), # slate — alpine
|
||||
"Wetland": Color(0.40, 0.60, 0.52, 0.90), # muted teal — wetland
|
||||
"Tundra": Color(0.55, 0.68, 0.82, 0.90), # cool grey-blue — cold
|
||||
"BorealForest": Color(0.55, 0.68, 0.82, 0.90),
|
||||
}
|
||||
const COLOR_SUB_BIOME_DEFAULT: Color = Color(0.72, 0.72, 0.76, 0.90) # default grey
|
||||
|
||||
## MorphologyZone discriminant → overlay colour (D-239 §6 order, T-1046).
|
||||
## ~0.55 alpha so the heightmap shows through: water blues, plains greens,
|
||||
## uplands greys/browns, volcanic dark red.
|
||||
const MORPHOLOGY_COLORS: Array = [
|
||||
Color(0.10, 0.20, 0.45, 0.55), # 0 OpenOcean
|
||||
Color(0.20, 0.40, 0.65, 0.55), # 1 Lake
|
||||
Color(0.45, 0.55, 0.50, 0.55), # 2 TidalFlat
|
||||
Color(0.85, 0.78, 0.45, 0.55), # 3 DuneStrand
|
||||
Color(0.50, 0.50, 0.55, 0.55), # 4 CliffCoast
|
||||
Color(0.30, 0.40, 0.50, 0.55), # 5 Fjord
|
||||
Color(0.40, 0.65, 0.60, 0.55), # 6 Delta
|
||||
Color(0.30, 0.55, 0.55, 0.55), # 7 Estuarine
|
||||
Color(0.30, 0.60, 0.30, 0.55), # 8 AlluvialPlain
|
||||
Color(0.45, 0.70, 0.40, 0.55), # 9 RiverBank
|
||||
Color(0.35, 0.60, 0.50, 0.55), # 10 MeanderReach
|
||||
Color(0.55, 0.60, 0.45, 0.55), # 11 BraidedPlain
|
||||
Color(0.50, 0.55, 0.30, 0.55), # 12 ValleyFloor
|
||||
Color(0.55, 0.45, 0.30, 0.55), # 13 MountainPass
|
||||
Color(0.80, 0.82, 0.85, 0.55), # 14 Alpine
|
||||
Color(0.45, 0.15, 0.12, 0.55), # 15 Volcanic
|
||||
Color(0.25, 0.45, 0.40, 0.55), # 16 Wetland
|
||||
]
|
||||
|
||||
# Province boundaries (D-205, #927)
|
||||
const COLOR_PROVINCE_BORDER: Color = Color(0.45, 0.65, 0.85, 0.55)
|
||||
const COLOR_PROVINCE_FILL: Color = Color(0.25, 0.45, 0.65, 0.08)
|
||||
const PROVINCE_BORDER_WIDTH: float = 1.2
|
||||
|
||||
# T-960 L2 — road/rail graph (D-211, T-1038). MaintenanceAuthority colors —
|
||||
# Corporate reuses COLOR_CORP's amber and Trade reuses the base D-191
|
||||
# COLOR_ROAD tan (long-haul trade routes ARE the base "road" concept, now
|
||||
# split out by authority) so the new encoding stays visually consistent with
|
||||
# the existing overlay palette instead of introducing an unrelated hue set.
|
||||
const COLOR_ROAD_ADMINISTRATIVE: Color = Color(0.45, 0.65, 0.90, 0.85)
|
||||
const COLOR_ROAD_CORPORATE: Color = Color(0.85, 0.65, 0.20, 0.85)
|
||||
const COLOR_ROAD_COMMUNAL: Color = Color(0.45, 0.75, 0.50, 0.85)
|
||||
const COLOR_ROAD_TRADE: Color = Color(0.85, 0.60, 0.30, 0.85)
|
||||
const COLOR_ROAD_ABANDONED: Color = Color(0.45, 0.42, 0.38, 0.65)
|
||||
const COLOR_ROAD_JUNCTION: Color = Color(0.85, 0.85, 0.90, 0.80)
|
||||
const ROAD_JUNCTION_MIN_DEGREE: int = 3 # mirrors server's JUNCTION_DEGREE (road_graph.rs)
|
||||
|
||||
# T-960 L3 — settlement placements (D-211, T-955's CityPlacement). Regular
|
||||
# settlements reuse the legacy COLOR_CITY gold (same concept — "this is a
|
||||
# city" reads consistently everywhere on the Atlas); capitals get a brighter
|
||||
# variant AND a distinct star shape (D-226 shape-encodes-identity).
|
||||
const COLOR_SETTLEMENT: Color = Color(0.94, 0.82, 0.38, 1.0)
|
||||
const COLOR_SETTLEMENT_CAPITAL: Color = Color(1.0, 0.92, 0.55, 1.0)
|
||||
# SettlementEntry.size_class (dudley-atlas-server contract, 2026-07-14) — the
|
||||
# same D-211 Tier A/B/C cutoffs already used for placement, now a render size.
|
||||
const SETTLEMENT_RADII: Dictionary = {"Major": 6.0, "Standard": 4.0, "Minor": 2.5}
|
||||
const SETTLEMENT_RADIUS_DEFAULT: float = 2.5
|
||||
const SETTLEMENT_LABEL_MIN_ZOOM: float = 2.0
|
||||
|
||||
const RAIL_DASH_ON: float = 6.0
|
||||
const RAIL_DASH_OFF: float = 4.0
|
||||
|
||||
@@ -133,6 +204,17 @@ func _draw() -> void:
|
||||
_draw_gen_rivers(layer1)
|
||||
if viewer.is_overlay_visible("gen_l1_attractors"):
|
||||
_draw_gen_attractors(layer1)
|
||||
# L2 roads / L3 settlements share the same working grid (T-960) —
|
||||
# both are positioned from the same cascade run as Layer1, so they
|
||||
# reuse the _gen_grid_*/_gen_tex_* mapping set up above.
|
||||
if viewer.is_overlay_visible("gen_l2_roads"):
|
||||
var road_graph: Variant = viewer.get_generation_road_graph()
|
||||
if road_graph is Dictionary:
|
||||
_draw_gen_roads(road_graph)
|
||||
if viewer.is_overlay_visible("gen_l3_settlements"):
|
||||
var settlements: Variant = viewer.get_generation_settlements()
|
||||
if settlements != null:
|
||||
_draw_gen_settlements(settlements)
|
||||
|
||||
# POIs (non-gate first, then gates on top if enabled)
|
||||
_draw_pois(markers)
|
||||
@@ -284,10 +366,6 @@ static func _city_key(city: Dictionary) -> String:
|
||||
# Province boundaries (D-205, #927)
|
||||
# =============================================================================
|
||||
|
||||
const COLOR_PROVINCE_BORDER: Color = Color(0.45, 0.65, 0.85, 0.55)
|
||||
const COLOR_PROVINCE_FILL: Color = Color(0.25, 0.45, 0.65, 0.08)
|
||||
const PROVINCE_BORDER_WIDTH: float = 1.2
|
||||
|
||||
|
||||
func _draw_province_boundaries(markers: Dictionary) -> void:
|
||||
var provinces: Array = markers.get("provinces", [])
|
||||
@@ -416,30 +494,6 @@ func _draw_gen_district(grid: Dictionary, tex_w: float, tex_h: float) -> void:
|
||||
draw_rect(Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5), _morphology_color(int(morphology[i])))
|
||||
|
||||
|
||||
## MorphologyZone discriminant → overlay colour (D-239 §6 order). ~0.55 alpha so
|
||||
## the heightmap shows through: water blues, plains greens, uplands greys/browns,
|
||||
## volcanic dark red.
|
||||
const MORPHOLOGY_COLORS: Array = [
|
||||
Color(0.10, 0.20, 0.45, 0.55), # 0 OpenOcean
|
||||
Color(0.20, 0.40, 0.65, 0.55), # 1 Lake
|
||||
Color(0.45, 0.55, 0.50, 0.55), # 2 TidalFlat
|
||||
Color(0.85, 0.78, 0.45, 0.55), # 3 DuneStrand
|
||||
Color(0.50, 0.50, 0.55, 0.55), # 4 CliffCoast
|
||||
Color(0.30, 0.40, 0.50, 0.55), # 5 Fjord
|
||||
Color(0.40, 0.65, 0.60, 0.55), # 6 Delta
|
||||
Color(0.30, 0.55, 0.55, 0.55), # 7 Estuarine
|
||||
Color(0.30, 0.60, 0.30, 0.55), # 8 AlluvialPlain
|
||||
Color(0.45, 0.70, 0.40, 0.55), # 9 RiverBank
|
||||
Color(0.35, 0.60, 0.50, 0.55), # 10 MeanderReach
|
||||
Color(0.55, 0.60, 0.45, 0.55), # 11 BraidedPlain
|
||||
Color(0.50, 0.55, 0.30, 0.55), # 12 ValleyFloor
|
||||
Color(0.55, 0.45, 0.30, 0.55), # 13 MountainPass
|
||||
Color(0.80, 0.82, 0.85, 0.55), # 14 Alpine
|
||||
Color(0.45, 0.15, 0.12, 0.55), # 15 Volcanic
|
||||
Color(0.25, 0.45, 0.40, 0.55), # 16 Wetland
|
||||
]
|
||||
|
||||
|
||||
func _morphology_color(zone: int) -> Color:
|
||||
if zone >= 0 and zone < MORPHOLOGY_COLORS.size():
|
||||
return MORPHOLOGY_COLORS[zone]
|
||||
@@ -499,23 +553,11 @@ func _draw_gen_attractors(layer1: Dictionary) -> void:
|
||||
|
||||
|
||||
## Sub-biome → marker color (Araminta's palette). Color is additive info; shape
|
||||
## carries the attractor-type identity (survives monochrome capture).
|
||||
## carries the attractor-type identity (survives monochrome capture). Table
|
||||
## lookup (SUB_BIOME_COLORS) rather than a multi-return match — same pattern
|
||||
## as MORPHOLOGY_COLORS/_morphology_color below.
|
||||
func _sub_biome_color(sub_biome: String) -> Color:
|
||||
match sub_biome:
|
||||
"TropicalWet", "CoastalLowland":
|
||||
return Color(0.25, 0.72, 0.65, 0.90) # teal — coastal/tropical
|
||||
"TemperateForest", "TemperateGrassland":
|
||||
return Color(0.45, 0.68, 0.45, 0.90) # sage — temperate
|
||||
"Desert", "Savanna":
|
||||
return Color(0.78, 0.62, 0.35, 0.90) # sand — arid
|
||||
"Alpine":
|
||||
return Color(0.52, 0.58, 0.72, 0.90) # slate — alpine
|
||||
"Wetland":
|
||||
return Color(0.40, 0.60, 0.52, 0.90) # muted teal — wetland
|
||||
"Tundra", "BorealForest":
|
||||
return Color(0.55, 0.68, 0.82, 0.90) # cool grey-blue — cold
|
||||
_:
|
||||
return Color(0.72, 0.72, 0.76, 0.90) # default grey
|
||||
return SUB_BIOME_COLORS.get(sub_biome, COLOR_SUB_BIOME_DEFAULT)
|
||||
|
||||
|
||||
## Attractor type → marker shape (Araminta's vocabulary, 7 types).
|
||||
@@ -552,6 +594,154 @@ func _draw_triangle(pos: Vector2, size: float, color: Color, point_down: bool) -
|
||||
draw_colored_polygon(pts, color)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Generation-cascade overlays — L2 roads / L3 settlements (T-960, D-225)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## MaintenanceAuthority (D-211/D-212) → line color. The one color-coded axis
|
||||
## on this overlay — road vs rail is told apart by line style/width instead
|
||||
## (_draw_gen_roads), so authority stays legible on its own.
|
||||
func _road_authority_color(maintenance: String) -> Color:
|
||||
match maintenance:
|
||||
"Administrative":
|
||||
return COLOR_ROAD_ADMINISTRATIVE
|
||||
"Corporate":
|
||||
return COLOR_ROAD_CORPORATE
|
||||
"Communal":
|
||||
return COLOR_ROAD_COMMUNAL
|
||||
"Trade":
|
||||
return COLOR_ROAD_TRADE
|
||||
"Abandoned":
|
||||
return COLOR_ROAD_ABANDONED
|
||||
_:
|
||||
return COLOR_ROAD_TRADE
|
||||
|
||||
|
||||
## Inter-settlement road/rail graph (RoadGraphLayer — D-211, T-1038). Edges
|
||||
## colored by MaintenanceAuthority; rail vs road told apart by line style/
|
||||
## width (dashed + thin = rail, solid + wider = road) rather than a second
|
||||
## color axis. Junction markers sit at Settlement nodes with 3+ incident
|
||||
## edges — RoadGraphLayer trims `degree`/`length_cells` as server-internal
|
||||
## bookkeeping (dudley-atlas-server contract, 2026-07-14), so degree is
|
||||
## derived here from the edge endpoints instead of read off the node.
|
||||
func _draw_gen_roads(road_graph: Dictionary) -> void:
|
||||
var edges: Array = road_graph.get("edges", [])
|
||||
var degree_by_index: Dictionary = {}
|
||||
for e: Variant in edges:
|
||||
if not e is Dictionary:
|
||||
continue
|
||||
var from_i: int = int(e.get("from", -1))
|
||||
var to_i: int = int(e.get("to", -1))
|
||||
degree_by_index[from_i] = int(degree_by_index.get(from_i, 0)) + 1
|
||||
degree_by_index[to_i] = int(degree_by_index.get(to_i, 0)) + 1
|
||||
|
||||
var path: Array = e.get("path", [])
|
||||
if path.size() < 2:
|
||||
continue
|
||||
var points: PackedVector2Array = PackedVector2Array()
|
||||
for pt: Variant in path:
|
||||
if pt is Array and pt.size() >= 2:
|
||||
points.append(_gen_pos(pt))
|
||||
if points.size() < 2:
|
||||
continue
|
||||
var color: Color = _road_authority_color(str(e.get("maintenance", "")))
|
||||
if bool(e.get("is_rail", false)):
|
||||
_draw_dashed_polyline(points, color, 1.1)
|
||||
else:
|
||||
draw_polyline(points, color, 1.6, true)
|
||||
|
||||
var nodes: Array = road_graph.get("nodes", [])
|
||||
for i: int in range(nodes.size()):
|
||||
var n: Variant = nodes[i]
|
||||
if not n is Dictionary:
|
||||
continue
|
||||
# Mirrors the server's own RoadGraph::high_connectivity_junctions()
|
||||
# filter (Settlement kind only — a Waypoint sits mid-edge and
|
||||
# structurally can't exceed degree 2).
|
||||
if str(n.get("kind", "")) != "Settlement":
|
||||
continue
|
||||
if int(degree_by_index.get(i, 0)) < ROAD_JUNCTION_MIN_DEGREE:
|
||||
continue
|
||||
var pos_rc: Variant = n.get("position")
|
||||
if not pos_rc is Array or pos_rc.size() < 2:
|
||||
continue
|
||||
_draw_junction_marker(_gen_pos(pos_rc), 3.5)
|
||||
|
||||
|
||||
## Small hollow diamond — distinct from both the filled attractor diamond
|
||||
## (RiverCrossing, larger + filled) and the filled settlement dot.
|
||||
func _draw_junction_marker(pos: Vector2, size: float) -> void:
|
||||
var pts: PackedVector2Array = PackedVector2Array(
|
||||
[
|
||||
pos + Vector2(0, -size),
|
||||
pos + Vector2(size, 0),
|
||||
pos + Vector2(0, size),
|
||||
pos + Vector2(-size, 0),
|
||||
pos + Vector2(0, -size),
|
||||
]
|
||||
)
|
||||
draw_polyline(pts, COLOR_ROAD_JUNCTION, 1.0, true)
|
||||
|
||||
|
||||
## Settlement placements (SettlementLayer — D-211 Layer 3, T-955's
|
||||
## CityPlacement, wired through the proxy for the first time on generated
|
||||
## bodies). `is_capital` is authored (atlas_city_names.kind == 'capital'),
|
||||
## not population-derived — capitals get a distinct STAR shape (D-226
|
||||
## shape-encodes-identity); everything else is a circle. Size scales with
|
||||
## `size_class` (Major/Standard/Minor — the same D-211 Tier A/B cutoffs
|
||||
## already used for placement). `settlements` tolerates a bare Array too,
|
||||
## defensively, though the confirmed shape is always {"settlements": [...]}.
|
||||
func _draw_gen_settlements(settlements: Variant) -> void:
|
||||
var entries: Array = []
|
||||
if settlements is Array:
|
||||
entries = settlements
|
||||
elif settlements is Dictionary:
|
||||
entries = settlements.get("settlements", [])
|
||||
if entries.is_empty():
|
||||
return
|
||||
|
||||
var font := ThemeDB.fallback_font
|
||||
var show_all_labels: bool = viewer.get_view_zoom() >= SETTLEMENT_LABEL_MIN_ZOOM
|
||||
for s: Variant in entries:
|
||||
if not s is Dictionary:
|
||||
continue
|
||||
var pos_rc: Variant = s.get("position")
|
||||
if not pos_rc is Array or pos_rc.size() < 2:
|
||||
continue
|
||||
var pos: Vector2 = _gen_pos(pos_rc)
|
||||
var size_class: String = str(s.get("size_class", "Minor"))
|
||||
var is_capital: bool = bool(s.get("is_capital", false))
|
||||
var radius: float = float(SETTLEMENT_RADII.get(size_class, SETTLEMENT_RADIUS_DEFAULT))
|
||||
if is_capital:
|
||||
_draw_star(pos, radius + 2.0, COLOR_SETTLEMENT_CAPITAL)
|
||||
else:
|
||||
draw_circle(pos, radius + 1.0, Color(0.0, 0.0, 0.0, 0.55))
|
||||
draw_circle(pos, radius, COLOR_SETTLEMENT)
|
||||
var name_str: String = str(s.get("name", ""))
|
||||
if name_str.is_empty():
|
||||
continue
|
||||
if is_capital or size_class == "Major" or show_all_labels:
|
||||
draw_string(
|
||||
font,
|
||||
pos + Vector2(radius + 3.0, radius * 0.4),
|
||||
name_str,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
8,
|
||||
COLOR_FEATURE_LABEL
|
||||
)
|
||||
|
||||
|
||||
func _draw_star(pos: Vector2, size: float, color: Color) -> void:
|
||||
var pts: PackedVector2Array = PackedVector2Array()
|
||||
for i in range(10):
|
||||
var angle: float = -PI / 2.0 + i * PI / 5.0
|
||||
var r: float = size if i % 2 == 0 else size * 0.42
|
||||
pts.append(pos + Vector2(cos(angle), sin(angle)) * r)
|
||||
draw_colored_polygon(pts, color)
|
||||
|
||||
|
||||
func _path_to_canvas(path: Array) -> PackedVector2Array:
|
||||
var out: PackedVector2Array = PackedVector2Array()
|
||||
for pt: Variant in path:
|
||||
|
||||
@@ -55,6 +55,12 @@ const COLOR_TEXT: Color = Color("#c8d0e0")
|
||||
const COLOR_TEXT_DIM: Color = Color("#667788")
|
||||
const COLOR_EMPTY_NOTICE: Color = Color("#445566")
|
||||
|
||||
# D-236: Sol (system GJ-0) is permanently out of the deterministic generation
|
||||
# cascade. Its markers.json keeps the legacy full-geometry FileAccess read
|
||||
# (T-1073 exception) — guard _load_markers by this id, not by enumerating
|
||||
# Sol's four bodies (GJ0d, GJ0d-1, GJ0e, GJ0f-2).
|
||||
const SOL_SYSTEM_ID: String = "GJ-0"
|
||||
|
||||
# ── Overlay definitions (single source of truth, review #7) ─────────────────
|
||||
## Overlay catalogue consumed by both AtlasMarkerOverlay (renders) and
|
||||
## AtlasOverlayBar (exposes as toggle buttons). Groups map to the D-181 signal
|
||||
@@ -145,6 +151,19 @@ const OVERLAY_DEFS: Array = [
|
||||
"group": "toggle",
|
||||
"tooltip": "District morphology zones (generation overlay, T-1046/D-226)."
|
||||
},
|
||||
{
|
||||
"id": "gen_l2_roads",
|
||||
"label": "RDS",
|
||||
"group": "toggle",
|
||||
"tooltip":
|
||||
"Layer 2 — inter-settlement road/rail graph, colored by maintenance authority (generation overlay, T-960)."
|
||||
},
|
||||
{
|
||||
"id": "gen_l3_settlements",
|
||||
"label": "STL",
|
||||
"group": "toggle",
|
||||
"tooltip": "Layer 3 — settlement placements, sized by population (generation overlay, T-960)."
|
||||
},
|
||||
]
|
||||
|
||||
# ── Context (set by show_body) ─────────────────────────────────────────────────
|
||||
@@ -157,6 +176,8 @@ var _heightmap_texture: Texture2D = null
|
||||
var _markers: Dictionary = {}
|
||||
var _generation_layer1: Variant = null # #960: Layer1Output from the proxy (D-225)
|
||||
var _generation_district_grid: Variant = null # T-1046: coarse DistrictGridLayer (D-226)
|
||||
var _generation_road_graph: Variant = null # T-960: RoadGraph layer from the proxy (D-225)
|
||||
var _generation_settlements: Variant = null # T-960: settlement placements from the proxy (D-225)
|
||||
var _gen_pending: bool = false # #960: awaiting a Layer1 response (re-polls on Pending)
|
||||
var _gen_retries: int = 0
|
||||
var _grid_w: float = 512.0
|
||||
@@ -164,6 +185,13 @@ var _grid_h: float = 256.0
|
||||
var _tex_w: float = 1024.0
|
||||
var _tex_h: float = 512.0
|
||||
|
||||
# T-949: body_id of an in-flight CityNamesRequest, "" if none. Non-Sol only —
|
||||
# Sol keeps the synchronous legacy markers.json read (D-236). Used both to
|
||||
# gate _on_city_names_received against a stale response (the viewer moved to
|
||||
# a different body while a request was in flight) and to replay the request
|
||||
# if the bridge wasn't connected yet when _load_markers first asked.
|
||||
var _city_names_pending_body: String = ""
|
||||
|
||||
# ── Pan/zoom state ────────────────────────────────────────────────────────────
|
||||
var _view_offset: Vector2 = Vector2.ZERO
|
||||
var _view_zoom: float = 1.0
|
||||
@@ -190,6 +218,7 @@ var _empty_notice = null # ImplantPanel shown when heightmap missing
|
||||
var _overlay_bar = null # #836 overlay toggle bar (no class_name, review #8)
|
||||
var _screen_header: ImplantHeader = null # top-left title/hint (D-169 composition)
|
||||
var _gen_pending_indicator = null # ImplantPending — loaded by path, not a class_name dep
|
||||
var _legend_panel = null # ImplantPanel — D-226 item 3, left-side generation-overlay legend
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -222,10 +251,16 @@ func _ready() -> void:
|
||||
_build_city_panel()
|
||||
_build_empty_notice()
|
||||
_build_overlay_bar()
|
||||
_build_legend_panel()
|
||||
|
||||
# #960: receive proxied Layer-1 generation output (D-225).
|
||||
SimBridge.atlas_layers_received.connect(_on_atlas_layers_received)
|
||||
|
||||
# T-949: receive the per-body atlas city-name pool (non-Sol _load_markers
|
||||
# path) and know when a reconnect should replay a pending request.
|
||||
SimBridge.city_names_received.connect(_on_city_names_received)
|
||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
||||
|
||||
# #960: diegetic "generating" indicator, shown only while the proxy is Pending.
|
||||
# Loaded by path (not `ImplantPending.new()`) so a stale global-class cache —
|
||||
# e.g. a running session that hasn't re-imported after this class was added —
|
||||
@@ -241,6 +276,10 @@ func _ready() -> void:
|
||||
func _exit_tree() -> void:
|
||||
if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received):
|
||||
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
|
||||
if SimBridge.city_names_received.is_connected(_on_city_names_received):
|
||||
SimBridge.city_names_received.disconnect(_on_city_names_received)
|
||||
if SimBridge.connection_state_changed.is_connected(_on_connection_state_changed):
|
||||
SimBridge.connection_state_changed.disconnect(_on_connection_state_changed)
|
||||
|
||||
|
||||
## Called by RegionalScreen.enter() when entering the viewer for a specific body.
|
||||
@@ -275,6 +314,7 @@ func set_overlay_visible(overlay_id: String, visible_state: bool) -> void:
|
||||
return
|
||||
_overlay_visibility[overlay_id] = visible_state
|
||||
_overlay_node.queue_redraw()
|
||||
_legend_panel.refresh()
|
||||
|
||||
|
||||
func is_overlay_visible(overlay_id: String) -> bool:
|
||||
@@ -293,6 +333,13 @@ func get_markers() -> Dictionary:
|
||||
return _markers
|
||||
|
||||
|
||||
## Current pan/zoom scale — exposed so AtlasMarkerOverlay can gate zoom-
|
||||
## dependent behavior (e.g. T-960 settlement name labels "at sensible zoom")
|
||||
## without reaching into the viewer's private state.
|
||||
func get_view_zoom() -> float:
|
||||
return _view_zoom
|
||||
|
||||
|
||||
## #960: store the Layer-1 generation output (from atlas_layers_received) and
|
||||
## redraw the overlay. The marker overlay reads it via get_generation_layer1().
|
||||
func set_generation_layer1(layer1: Variant) -> void:
|
||||
@@ -317,6 +364,30 @@ func get_generation_district_grid() -> Variant:
|
||||
return _generation_district_grid
|
||||
|
||||
|
||||
## T-960: store the L2 road/rail graph (RoadGraphLayer) from the proxy and
|
||||
## redraw. The marker overlay reads it via get_generation_road_graph().
|
||||
func set_generation_road_graph(graph: Variant) -> void:
|
||||
_generation_road_graph = graph
|
||||
if _overlay_node:
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
func get_generation_road_graph() -> Variant:
|
||||
return _generation_road_graph
|
||||
|
||||
|
||||
## T-960: store the L3 settlement placements (SettlementLayer) from the proxy
|
||||
## and redraw. The marker overlay reads it via get_generation_settlements().
|
||||
func set_generation_settlements(settlements: Variant) -> void:
|
||||
_generation_settlements = settlements
|
||||
if _overlay_node:
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
func get_generation_settlements() -> Variant:
|
||||
return _generation_settlements
|
||||
|
||||
|
||||
## #960: request the body's Layer-1 cascade output from the server proxy.
|
||||
## No-op in test mode (SimBridge has no server connection) — the overlays
|
||||
## simply stay empty, which is the correct serverless behavior.
|
||||
@@ -342,6 +413,8 @@ func _on_atlas_layers_received(response: Dictionary) -> void:
|
||||
_set_gen_indicator(false)
|
||||
set_generation_layer1(response.get("layer1"))
|
||||
set_generation_district_grid(response.get("district_grid"))
|
||||
set_generation_road_graph(response.get("road_graph"))
|
||||
set_generation_settlements(response.get("settlements"))
|
||||
"Pending":
|
||||
if _gen_retries < GEN_MAX_RETRIES:
|
||||
_gen_retries += 1
|
||||
@@ -433,13 +506,36 @@ func _load_heightmap() -> void:
|
||||
_tex_h = float(_heightmap_texture.get_height())
|
||||
|
||||
|
||||
## T-949: for Sol (D-236) this is still the synchronous legacy file read; for
|
||||
## every other body it fires an async CityNamesRequest and _markers populates
|
||||
## later, in _on_city_names_received.
|
||||
func _load_markers() -> void:
|
||||
_markers = {}
|
||||
# Reset grid dims alongside _tex_* in _load_heightmap so we start from a
|
||||
# known baseline regardless of which body ran previously.
|
||||
_grid_w = _tex_w
|
||||
_grid_h = _tex_h
|
||||
_city_names_pending_body = ""
|
||||
|
||||
var system_id: String = _dict_str(_system, "system_id", "")
|
||||
if system_id == SOL_SYSTEM_ID:
|
||||
_load_sol_markers_legacy()
|
||||
return
|
||||
|
||||
var body_id: String = _dict_str(_body, "body_id", "")
|
||||
if body_id.is_empty():
|
||||
return
|
||||
_city_names_pending_body = body_id
|
||||
SimBridge.request_city_names(body_id)
|
||||
|
||||
|
||||
## D-236/T-1073 SOL EXCEPTION (load-bearing — do not "clean up"): Sol bodies
|
||||
## (GJ0d, GJ0d-1, GJ0e, GJ0f-2, system GJ-0) are permanently excluded from the
|
||||
## deterministic generation cascade (D-236), so there is no server-side
|
||||
## atlas_city_names/geometry source to request instead — Sol's markers.json
|
||||
## is the one client file read T-949 does NOT migrate, pending T-1073
|
||||
## (gated on Q-107). This is byte-for-byte the pre-T-949 _load_markers body.
|
||||
func _load_sol_markers_legacy() -> void:
|
||||
var ref: Variant = _body.get("terrain_reference")
|
||||
if ref == null or str(ref).is_empty():
|
||||
return
|
||||
@@ -467,6 +563,50 @@ func _load_markers() -> void:
|
||||
_grid_h = float(grid.get("h", _tex_h))
|
||||
|
||||
|
||||
## T-949: CityNamesResponse handler for non-Sol bodies (dudley-atlas-server
|
||||
## contract, 2026-07-14). Ignores responses for a body the viewer has since
|
||||
## navigated away from. `cities` is a flat [{city_id, name, is_capital}] array
|
||||
## — NOT the legacy markers.json shape (no rivers/oceans/mountain_ranges pool,
|
||||
## no position; positions arrive separately via the gen_l3_settlements
|
||||
## overlay/T-960's SettlementLayer) — stored under a dedicated "city_names"
|
||||
## key so it can never collide with the top-level "cities" key the legacy
|
||||
## Sol reader/_draw_cities()/_find_city_at() expect (those entries have no
|
||||
## pos/center/lat+lon, so they'd all plot at Vector2.ZERO if merged in).
|
||||
## SolExcluded is a defensive backstop (D-236): the server refuses Sol bodies
|
||||
## even though atlas_viewer.gd's own SOL_SYSTEM_ID guard should make this
|
||||
## path rare — falls back to the legacy synchronous read either way.
|
||||
func _on_city_names_received(response: Dictionary) -> void:
|
||||
var body_id: String = _dict_str(_body, "body_id", "")
|
||||
if str(response.get("body_id", "")) != body_id:
|
||||
return
|
||||
_city_names_pending_body = ""
|
||||
match str(response.get("status", "")):
|
||||
"Ready":
|
||||
_markers = {"city_names": response.get("cities", [])}
|
||||
"SolExcluded":
|
||||
_load_sol_markers_legacy()
|
||||
_:
|
||||
pass # Error — leave markers empty (#960/D-191's empty-markers case)
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## T-949: the Atlas can be opened before the bridge finishes its handshake —
|
||||
## replay the in-flight CityNamesRequest once we actually reach CONNECTED
|
||||
## instead of leaving it stranded (request_city_names() no-ops silently while
|
||||
## disconnected, and unlike the Layer1 proxy there is no Pending status to
|
||||
## trigger a retry timer).
|
||||
func _on_connection_state_changed(_old_state: int, new_state: int) -> void:
|
||||
if new_state == SimBridge.ConnectionState.CONNECTED and not _city_names_pending_body.is_empty():
|
||||
SimBridge.request_city_names(_city_names_pending_body)
|
||||
|
||||
|
||||
## True while a non-Sol body's CityNamesRequest is in flight (T-949). Sol
|
||||
## bodies never set this — they use the synchronous legacy read (D-236).
|
||||
func has_pending_city_names_request() -> bool:
|
||||
return not _city_names_pending_body.is_empty()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# View transform
|
||||
# =============================================================================
|
||||
@@ -825,6 +965,25 @@ func _position_overlay_bar() -> void:
|
||||
_overlay_bar.position = Vector2(sz.x - bar_w - PANEL_MARGIN, PANEL_MARGIN)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Generation legend panel (D-226 item 3)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Loaded by path (matching atlas_overlay_bar.gd, review #8): the panel needs
|
||||
## the viewer reference at construction time, and a class_name + required-arg
|
||||
## _init() combo is a Godot editor footgun. GENERATION_LEGEND (the spec table)
|
||||
## and the road/settlement legend colors live on this script, not here — see
|
||||
## atlas_legend_panel.gd.
|
||||
func _build_legend_panel() -> void:
|
||||
var LegendScript := load("res://ui/implant/apps/atlas/atlas_legend_panel.gd")
|
||||
_legend_panel = LegendScript.new(self)
|
||||
_legend_panel.name = "GenerationLegend"
|
||||
_legend_panel.theme_resource = _implant_theme
|
||||
add_child(_legend_panel)
|
||||
_legend_panel.refresh()
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_RESIZED:
|
||||
if _city_panel:
|
||||
@@ -833,3 +992,5 @@ func _notification(what: int) -> void:
|
||||
_position_empty_notice()
|
||||
if _overlay_bar:
|
||||
_position_overlay_bar()
|
||||
if _legend_panel:
|
||||
_legend_panel.reposition()
|
||||
|
||||
@@ -46,6 +46,9 @@ func _ready() -> void:
|
||||
|
||||
_implant_theme = load("res://ui/implant/default_implant.tres") as ImplantTheme
|
||||
_load_system_list()
|
||||
# T-949: the star map now arrives over the bridge, possibly after this
|
||||
# screen is already built (or before the handshake completes at all).
|
||||
SimBridge.star_map_received.connect(_on_star_map_received)
|
||||
_build_panel()
|
||||
economy_data_updated.connect(_on_economy_data_updated)
|
||||
|
||||
@@ -111,11 +114,23 @@ func navigate(delta: int) -> void:
|
||||
|
||||
|
||||
func _load_system_list() -> void:
|
||||
SystemIndex.request_refresh()
|
||||
_systems = SystemIndex.get_sorted_systems()
|
||||
if not _systems.is_empty():
|
||||
# Only auto-select on the FIRST populated load — a late-arriving refresh
|
||||
# (T-949: the star map is now fetched over the bridge) must not stomp a
|
||||
# selection the player already navigated to via navigate().
|
||||
if selected_system.is_empty() and not _systems.is_empty():
|
||||
selected_system = _systems[0].get("system_id", "")
|
||||
|
||||
|
||||
## T-949: the star map arrived — cache it and rebuild the panel with real data
|
||||
## (it may have been showing the "—" loading placeholders until now).
|
||||
func _on_star_map_received(response: Dictionary) -> void:
|
||||
SystemIndex.ingest(response)
|
||||
_load_system_list()
|
||||
_rebuild_panel()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Visual panel — D-169 ImplantPanel composition
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,31 +1,69 @@
|
||||
class_name SystemIndex
|
||||
## Shared system data loader for implant apps (#844).
|
||||
## Static helper — call as SystemIndex.get_sorted_systems().
|
||||
## Shared, cached system data loader for implant apps (#844, T-949).
|
||||
##
|
||||
## T-949: replaces the direct star_map_data.json FileAccess read with a
|
||||
## StarMapRequest over the bridge (D-010 — the client never reads game data
|
||||
## files directly). A static cache so every caller (AtlasApp's Reach screen,
|
||||
## the economics OverviewScreen) shares one fetch instead of each re-asking.
|
||||
##
|
||||
## Usage per caller:
|
||||
## 1. Call request_refresh() once (e.g. in _ready()/on_install()) — idempotent,
|
||||
## no-op once loaded or already in flight.
|
||||
## 2. Connect to SimBridge.star_map_received and, in the handler, re-pull
|
||||
## get_sorted_systems() to refresh with the now-populated list.
|
||||
## The Atlas can open before the bridge finishes its handshake —
|
||||
## SimBridge.request_star_map() remembers the request and fires it
|
||||
## automatically the instant the connection reaches CONNECTED, so callers
|
||||
## never need to poll or retry themselves.
|
||||
|
||||
const DATA_PATH := "res://data/star_map_data.json"
|
||||
static var _cache: Array = []
|
||||
static var _loaded: bool = false
|
||||
static var _requested: bool = false
|
||||
|
||||
|
||||
## Sorted node list (by proper_name, falling back to system_id), or [] if the
|
||||
## star map has not arrived yet. Callers should re-pull this after
|
||||
## SimBridge.star_map_received fires.
|
||||
static func get_sorted_systems() -> Array:
|
||||
if not FileAccess.file_exists(DATA_PATH):
|
||||
push_warning("SystemIndex: %s not found" % DATA_PATH)
|
||||
return []
|
||||
var file := FileAccess.open(DATA_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("SystemIndex: could not open %s" % DATA_PATH)
|
||||
return []
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return []
|
||||
var nodes: Array = []
|
||||
for node: Dictionary in parsed.get("nodes", []):
|
||||
return _cache
|
||||
|
||||
|
||||
static func is_loaded() -> bool:
|
||||
return _loaded
|
||||
|
||||
|
||||
## Ask the bridge for the star map if it hasn't been fetched yet. Safe to call
|
||||
## from every screen's _ready()/on_install() — idempotent once loaded or a
|
||||
## request is already in flight.
|
||||
static func request_refresh() -> void:
|
||||
if _loaded or _requested:
|
||||
return
|
||||
_requested = true
|
||||
SimBridge.request_star_map()
|
||||
|
||||
|
||||
## Feed a decoded StarMapResponse (from a SimBridge.star_map_received handler)
|
||||
## into the cache. Sorts once here so every caller gets the same order for free.
|
||||
## Only a "Ready" status is trusted — an Error response (protocol.gd's
|
||||
## star_map_response_from_raw already reduces it to status/error/nodes=[])
|
||||
## must NOT mark the cache _loaded, and must clear _requested so a later
|
||||
## request_refresh() retries instead of treating the failed fetch as
|
||||
## permanently done.
|
||||
static func ingest(response: Dictionary) -> void:
|
||||
if str(response.get("status", "")) != "Ready":
|
||||
_requested = false
|
||||
return
|
||||
var nodes: Array = response.get("nodes", [])
|
||||
var sorted: Array = []
|
||||
for node: Dictionary in nodes:
|
||||
var sid: String = node.get("system_id", "")
|
||||
if not sid.is_empty():
|
||||
nodes.append(node)
|
||||
nodes.sort_custom(
|
||||
sorted.append(node)
|
||||
sorted.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var na: String = a.get("proper_name", a.get("system_id", ""))
|
||||
var nb: String = b.get("proper_name", b.get("system_id", ""))
|
||||
return na < nb
|
||||
)
|
||||
return nodes
|
||||
_cache = sorted
|
||||
_loaded = true
|
||||
|
||||
Reference in New Issue
Block a user