Files
settled-reach/client/ui/implant/apps/atlas/atlas_viewer.gd
T
jpmschweitzerandClaude Fable 5 831625019a fix(ui): T-1142 client — descent bounds gate, fit-and-center, pole wall, east-west wrap, body-name header
Bounds gate: AtlasDescendGeometry.is_on_texture() — ONE helper feeding
both the reticle guard and the click fall-through (the T-1140 lesson:
the visible affordance always matches the click); half-open
[0,tex_w)x[0,tex_h) boundary pinned at the exact edge. Letterbox
clicks no longer show a reticle or descend.

Fit-and-center: pure fit_window_view() (new atlas_window_geometry.gd)
wired into enter(), the FIRST window arrival, and NOTIFICATION_RESIZED
— gated by a _user_adjusted flag so the fit never fights manual
zoom/pan (flag clears only on a fresh enter). Found-own-bug: RESIZED
can fire mid-_ready() before _canvas exists — null-guarded like the
sibling panels.

Pole wall (Jeroen's ruling): clamp_pan_offset_to_pole_wall() clamps
the WINDOW EDGE, not the center, in screen space from the fitted
transform — Y only; wired into the drag handler and every fit (a
fresh fit can itself need the wall on a tiny body — the
window-taller-than-planet case is handled and tested). Three numeric
hand-traces preceded the code; a first-draft test using GJ380c's huge
radius silently never exercised the clamp — replaced with a synthetic
small radius.

East-west wrap (Jeroen's ruling): canonicalize_district_center() —
posmod column wrap (verified against a live Godot process to match
Rust rem_euclid bit-for-bit), clamped row; district_extent() shares
the exact formula (incl. .max(1)) with the server's
normalize_window_center so echoes and cache keys agree on canonical
form. Canonicalization applies only to the FINAL refetch center — the
edge-crossing decision stays in absolute district space (first-pass
math error caught by hand-trace). Seam-adjacent cache-key sharing
tested. Pan offset itself has no x wall — circumnavigation is
seamless.

Header: body proper_name/body_id ahead of the coordinates (the cheap
half of T-1141, noted in code). Drag-pan verified through the REAL
DistrictScreen-to-viewer chain and pinned by test (no fix needed).

140 tests across three suites, 0 failures; 94 sibling tests no
ripple; gdlint clean (atlas_viewer.gd at the 1000-line cap a second
round — structural extraction flagged for maintenance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:53:32 +02:00

1001 lines
36 KiB
GDScript

class_name AtlasViewer
extends Control
## Atlas regional viewer — heightmap PNG with marker overlay (#835, D-191).
##
## Lives as a child of RegionalScreen (via show_body()). Emits back_pressed,
## economics_link_requested, district_descend_requested for RegionalScreen to route.
## Design notes:
## - Heightmap on a Node2D _canvas child; MarkerOverlay is a child of _canvas too.
## - markers.json schema (D-191 §8): cities, roads, rail, pois, rivers, oceans, ranges.
## - Empty markers: bare heightmap, no sidebar. Overlays (#836): _overlay_visibility dict.
## Navigation (FIXED, T-1142 bounds-gated): hover reticle · click descend/city · esc back
signal back_pressed
signal economics_link_requested(system_id: String)
signal district_descend_requested(district_center: Vector2i) # T-1138
const MIN_ZOOM: float = 0.5
const MAX_ZOOM: float = 8.0
const PANEL_WIDTH: float = 320.0
const PANEL_MARGIN: float = 16.0
# Width reserved for the top-left header block (title + hint lines) that the
# wrapping overlay bar must not overlap.
const OVERLAY_BAR_HEADER_RESERVE: float = 360.0
const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") # T-1138
# ── Colors ────────────────────────────────────────────────────────────────────
const COLOR_BG: Color = Color("#0d1117")
const COLOR_HEIGHTMAP_TINT: Color = Color(0.85, 0.88, 0.95, 1.0)
const COLOR_CITY: Color = Color("#f0d060")
const COLOR_CITY_HOVER: Color = Color("#ffffff")
const COLOR_CITY_LABEL: Color = Color("#c8d0e0")
const COLOR_ROAD: Color = Color("#d89040")
const COLOR_RAIL: Color = Color("#7888a0")
const COLOR_POI: Color = Color("#70c0d8")
const COLOR_FEATURE_LABEL: Color = Color(0.78, 0.82, 0.92, 0.65)
const COLOR_GATE_MARKER: Color = Color("#b0e0ff")
const COLOR_POLITICAL_ZONE: Color = Color(0.25, 0.45, 0.65, 0.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
## visibility ladder: always = public, toggle = observable, locked = semi-
## private / private. Keep this table in sync with D-191 §7.
const OVERLAY_DEFS: Array = [
{
"id": "terrain",
"label": "TER",
"group": "always",
"tooltip": "Terrain — base heightmap. Always visible."
},
{
"id": "infrastructure",
"label": "INF",
"group": "always",
"tooltip": "Infrastructure — roads + rail. Always visible."
},
{
"id": "named_features",
"label": "NAM",
"group": "always",
"tooltip": "Named features — rivers, oceans, ranges. Always visible."
},
{
"id": "gate_markers",
"label": "GAT",
"group": "always",
"tooltip": "Gate + spaceport markers. Always visible."
},
{
"id": "political_zones",
"label": "POL",
"group": "always",
"tooltip": "Political zones — currency-zone bands. Always visible."
},
{
"id": "population_density",
"label": "POP",
"group": "toggle",
"tooltip": "Population density (public signal)."
},
{
"id": "production_zones",
"label": "PRD",
"group": "toggle",
"tooltip": "Production zones (observable — requires presence)."
},
{
"id": "corp_presence",
"label": "CRP",
"group": "toggle",
"tooltip": "Corporate presence — Tier 1 only (observable)."
},
{
"id": "stockpile_weeks",
"label": "STK",
"group": "locked",
"tooltip": "Stockpile weeks — LOCKED. Needs corporate contact."
},
{
"id": "production_vs_baseline",
"label": "BSL",
"group": "locked",
"tooltip": "Production vs baseline — LOCKED. Needs insider access."
},
{
"id": "gen_l1_rivers",
"label": "RVR",
"group": "toggle",
"tooltip": "River network (generation overlay)."
},
{
"id": "gen_l1_basins",
"label": "BAS",
"group": "toggle",
"tooltip": "Drainage basins (generation overlay)."
},
{
"id": "gen_l1_attractors",
"label": "ATR",
"group": "toggle",
"tooltip": "Geographic attractors (generation overlay)."
},
{
"id": "gen_district",
"label": "MRPH",
"group": "toggle",
"tooltip": "District morphology zones (generation overlay)."
},
{
"id": "gen_l2_roads",
"label": "RDS",
"group": "toggle",
"tooltip":
"Inter-settlement road/rail graph, colored by maintenance authority (generation overlay)."
},
{
"id": "gen_l3_settlements",
"label": "STL",
"group": "toggle",
"tooltip": "Settlement placements, sized by population (generation overlay)."
},
{
"id": "gen_region_grid",
"label": "TMP",
"group": "toggle",
"tooltip": "Region climate — mean temperature (generation overlay)."
},
{
"id": "gen_l4_quarters",
"label": "QTR",
"group": "toggle",
"tooltip": "Quarter footprints — color is density, shape is district type (generation overlay)."
},
]
# ── Context (set by show_body) ─────────────────────────────────────────────────
var _body: Dictionary = {}
var _system: Dictionary = {}
var _implant_theme = null
# ── Heightmap + markers ───────────────────────────────────────────────────────
var _heightmap_texture: Texture2D = null
var _markers: Dictionary = {}
# Generation-cascade state (#960, D-225) is split into two companion objects,
# both extracted from this file (T-1118) to keep it under the gdlint
# max-file-lines cap: AtlasGenerationState owns the per-layer data
# (get/set_generation_*() accessors below delegate to it) and AtlasGenerationProxy
# owns the proxy-polling/retry/pending-indicator machinery.
#
# _gen_state is built as a FIELD DEFAULT (not in _ready()) — it is a pure
# RefCounted with no tree dependency, and get/set_generation_*() must work on
# a bare `AtlasViewer.new()` that never enters the scene tree (the
# established test pattern throughout this suite — auto_free(AtlasViewer.new())
# with no add_child(), so _ready() never fires). _overlay_node isn't bound
# until _ready() (bind_overlay_node() below), so a redraw is a safe no-op
# until then — see atlas_generation_state.gd's _redraw() null-guard.
#
# _gen_proxy is a Node (needs get_tree() for its retry timer, owns the
# pending-indicator child Control) and is built in _ready() like every other
# child node on this class — nothing calls into it before the tree exists.
var _gen_state = load("res://ui/implant/apps/atlas/atlas_generation_state.gd").new()
var _gen_proxy = null
var _grid_w: float = 512.0
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 = ""
# ── View state (T-1138: FIXED — set only by _fit_to_view()/set_view()) ─────
var _view_offset: Vector2 = Vector2.ZERO
var _view_zoom: float = 1.0
# ── Selection ─────────────────────────────────────────────────────────────────
var _selected_city: Dictionary = {}
var _hovered_city: Dictionary = {}
var _hover_screen_pos: Vector2 = Vector2.ZERO # T-1138 descent reticle cursor tracking
var _hover_active: bool = false
# ── Overlay visibility (#836 plugs in here) ───────────────────────────────────
## Runtime state derived from OVERLAY_DEFS in _ready() — always-on overlays
## start true, toggleables false, locked entries are tracked separately and
## can't be flipped by set_overlay_visible().
var _overlay_visibility: Dictionary = {}
var _overlay_locked: Dictionary = {}
# ── Child nodes ───────────────────────────────────────────────────────────────
var _canvas: Node2D = null # transformed node holding heightmap + markers
var _overlay_node: AtlasMarkerOverlay = null # draws on top of heightmap
var _city_panel = null # ImplantPanel sidebar (city data)
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 _legend_panel = null # ImplantPanel — D-226 item 3, left-side generation-overlay legend
func _ready() -> void:
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = Control.GROW_DIRECTION_BOTH
grow_vertical = Control.GROW_DIRECTION_BOTH
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_ALL
_implant_theme = load("res://ui/implant/default_implant.tres")
for def: Dictionary in OVERLAY_DEFS:
var group: String = def["group"]
_overlay_visibility[def["id"]] = (group == "always")
if group == "locked":
_overlay_locked[def["id"]] = true
# Build transformed canvas that holds heightmap + marker overlay
_canvas = Node2D.new()
_canvas.name = "AtlasCanvas"
add_child(_canvas)
_overlay_node = AtlasMarkerOverlay.new()
_overlay_node.name = "MarkerOverlay"
_overlay_node.viewer = self
_canvas.add_child(_overlay_node)
# _gen_state itself was already constructed as a field default (see the
# comment there) — bind its redraw target now that _overlay_node exists.
_gen_state.bind_overlay_node(_overlay_node)
# #960: proxy-polling + diegetic "generating" indicator (D-225). Added as a
# child Node (not RefCounted) so it can own the indicator Control and use
# get_tree() for the retry timer; see atlas_generation_proxy.gd.
_gen_proxy = load("res://ui/implant/apps/atlas/atlas_generation_proxy.gd").new(self)
_gen_proxy.name = "GenerationProxy"
add_child(_gen_proxy)
_build_screen_header()
_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)
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.
func show_body(body: Dictionary, system: Dictionary) -> void:
_body = body
_system = system
_selected_city = {}
_hovered_city = {}
_load_heightmap()
_load_markers()
_fit_to_view()
_refresh_screen_header()
_city_panel.visible = false
_empty_notice.visible = (_heightmap_texture == null)
_gen_state.reset_layer1()
_gen_proxy.reset()
_gen_proxy.request(_dict_str(_body, "body_id", ""))
grab_focus()
queue_redraw()
_overlay_node.queue_redraw()
## #836 hook — toggle an overlay. Locked overlays are no-ops.
func set_overlay_visible(overlay_id: String, visible_state: bool) -> void:
if _overlay_locked.get(overlay_id, false):
return
if not _overlay_visibility.has(overlay_id):
push_warning("AtlasViewer: unknown overlay id '%s'" % overlay_id)
return
_overlay_visibility[overlay_id] = visible_state
_overlay_node.queue_redraw()
_legend_panel.refresh()
func is_overlay_visible(overlay_id: String) -> bool:
return bool(_overlay_visibility.get(overlay_id, false))
func is_overlay_locked(overlay_id: String) -> bool:
return bool(_overlay_locked.get(overlay_id, false))
func get_heightmap_texture() -> Texture2D:
return _heightmap_texture
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
## Current pan offset (canvas-space translation) — paired with get_view_zoom()
## so callers can read back the full view transform without reaching into
## private state (T-1120, same rationale as get_view_zoom()).
func get_view_offset() -> Vector2:
return _view_offset
## Programmatic view control (T-1120 capture harness) — survives T-1138's
## removal of user drag-pan/wheel-zoom; the harness still overrides the FIXED
## view deterministically. Clamps zoom to [MIN_ZOOM, MAX_ZOOM] like _fit_to_view().
func set_view(zoom: float, offset: Vector2) -> void:
_view_zoom = clampf(zoom, MIN_ZOOM, MAX_ZOOM)
_view_offset = offset
_apply_transform()
## Per-layer generation accessors (#960, D-225; region_grid T-1118, quarter_footprints
## T-1119) — thin delegates onto AtlasGenerationState (see atlas_generation_state.gd
## for what each layer is and its store-then-redraw contract). Same public method
## names as before the T-1118 extraction; callers (atlas_marker_overlay.gd,
## test_atlas_overlays.gd) are unaffected.
func set_generation_layer1(layer1: Variant) -> void:
_gen_state.set_layer1(layer1)
func get_generation_layer1() -> Variant:
return _gen_state.get_layer1()
func set_generation_district_grid(grid: Variant) -> void:
_gen_state.set_district_grid(grid)
func get_generation_district_grid() -> Variant:
return _gen_state.get_district_grid()
func set_generation_road_graph(graph: Variant) -> void:
_gen_state.set_road_graph(graph)
func get_generation_road_graph() -> Variant:
return _gen_state.get_road_graph()
func set_generation_settlements(settlements: Variant) -> void:
_gen_state.set_settlements(settlements)
func get_generation_settlements() -> Variant:
return _gen_state.get_settlements()
func set_generation_region_grid(grid: Variant) -> void:
_gen_state.set_region_grid(grid)
func get_generation_region_grid() -> Variant:
return _gen_state.get_region_grid()
func set_generation_quarter_footprints(footprints: Variant) -> void:
_gen_state.set_quarter_footprints(footprints)
func get_generation_quarter_footprints() -> Variant:
return _gen_state.get_quarter_footprints()
## #960: forward a Layer-1 response to AtlasGenerationProxy, which handles
## staleness, retry, indicator, and dispatch to AtlasGenerationState (T-1118
## extraction — see atlas_generation_proxy.gd).
func _on_atlas_layers_received(response: Dictionary) -> void:
_gen_proxy.on_response(response, _dict_str(_body, "body_id", ""))
## Read-only accessors AtlasGenerationProxy needs (it is added as a child, not
## a subclass, so it reaches viewer state through the same public surface any
## other caller would use).
func get_implant_theme() -> Variant:
return _implant_theme
func get_body_id() -> String:
return _dict_str(_body, "body_id", "")
func get_hovered_city() -> Dictionary:
return _hovered_city
func get_selected_city() -> Dictionary:
return _selected_city
func get_overlay_defs() -> Array:
return OVERLAY_DEFS
# =============================================================================
# Data loading
# =============================================================================
func _load_heightmap() -> void:
# Reset to sentinel defaults so a body with no heightmap (or that fails to
# load) doesn't inherit the previous body's dimensions — otherwise
# grid_to_canvas would project markers using the prior texture size
# (review #2).
_heightmap_texture = null
_tex_w = 1024.0
_tex_h = 512.0
var ref: Variant = _body.get("terrain_reference")
if ref == null or str(ref).is_empty():
return
# terrain_reference points at <body>/heightmap.png — now the canonical
# 16-bit GRAYSCALE elevation (D-202 amended, #963). For display we load the
# color reliefmap.png that sits beside it.
var path: String = str(ref).get_base_dir() + "/reliefmap.png"
if not path.begins_with("/"):
var project_root: String = (
ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
)
path = project_root + "/" + path
if not FileAccess.file_exists(path):
push_warning("AtlasViewer: reliefmap not found at %s" % path)
return
var img := Image.load_from_file(path)
if img != null:
_heightmap_texture = ImageTexture.create_from_image(img)
_tex_w = float(_heightmap_texture.get_width())
_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
var hm_path: String = str(ref)
if not hm_path.begins_with("/"):
var project_root: String = (
ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
)
hm_path = project_root + "/" + hm_path
var dir: String = hm_path.get_base_dir()
var markers_path: String = dir + "/markers.json"
if not FileAccess.file_exists(markers_path):
return
var f := FileAccess.open(markers_path, FileAccess.READ)
if f == null:
return
var parsed: Variant = JSON.parse_string(f.get_as_text())
f.close()
if not (parsed is Dictionary):
return
_markers = parsed
var grid: Dictionary = _markers.get("grid", {})
_grid_w = float(grid.get("w", _tex_w))
_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
# =============================================================================
func _fit_to_view() -> void:
if _heightmap_texture == null:
_view_zoom = 1.0
_view_offset = Vector2.ZERO
_canvas.position = _view_offset
_canvas.scale = Vector2(_view_zoom, _view_zoom)
return
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var avail: Vector2 = sz - Vector2(0, 60) # leave space for header
var fit_x: float = avail.x / _tex_w
var fit_y: float = avail.y / _tex_h
_view_zoom = clampf(minf(fit_x, fit_y) * 0.92, MIN_ZOOM, MAX_ZOOM)
var scaled: Vector2 = Vector2(_tex_w, _tex_h) * _view_zoom
_view_offset = (sz - scaled) * 0.5 + Vector2(0, 20)
_apply_transform()
func _apply_transform() -> void:
_canvas.position = _view_offset
_canvas.scale = Vector2(_view_zoom, _view_zoom)
queue_redraw()
_overlay_node.queue_redraw()
## Convert grid coordinates (from markers.json) to canvas-space (texture pixels).
func grid_to_canvas(grid_point: Vector2) -> Vector2:
if _grid_w <= 0.0 or _grid_h <= 0.0:
return Vector2.ZERO
return Vector2(grid_point.x / _grid_w * _tex_w, grid_point.y / _grid_h * _tex_h)
func canvas_to_screen(canvas_point: Vector2) -> Vector2:
return canvas_point * _view_zoom + _view_offset
func screen_to_canvas(screen_point: Vector2) -> Vector2:
if _view_zoom == 0.0:
return Vector2.ZERO
return (screen_point - _view_offset) / _view_zoom
# =============================================================================
# Drawing (background + header)
# =============================================================================
func _draw() -> void:
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
if _should_draw_descend_reticle():
_draw_descend_reticle()
func _should_draw_descend_reticle() -> bool: # hidden over a city — _try_click_city wins the click
var base_ok: bool = _hover_active and _heightmap_texture != null and _hovered_city.is_empty()
return base_ok and _is_screen_pos_on_texture(_hover_screen_pos)
func _is_screen_pos_on_texture(screen_pos: Vector2) -> bool: # T-1142: ONE bounds-gate helper
return AtlasDescendGeometry.is_on_texture(screen_to_canvas(screen_pos), _tex_w, _tex_h)
func _draw_descend_reticle() -> void: # draw calls only — geometry in atlas_descend_geometry.gd
var center: Vector2 = _hover_screen_pos
for seg: Array in AtlasDescendGeometry.reticle_segments(center):
draw_line(seg[0], seg[1], AtlasDescendGeometry.COLOR_DESCEND_RETICLE, 1.5)
var label: Dictionary = AtlasDescendGeometry.reticle_label(center)
draw_string(
ThemeDB.fallback_font,
label["position"],
label["text"],
HORIZONTAL_ALIGNMENT_LEFT,
-1,
10,
AtlasDescendGeometry.COLOR_DESCEND_RETICLE
)
func _build_screen_header() -> void:
# D-169: compose the title + hint from ImplantHeader so the implant theme
# drives fonts/colors. Review #4 flagged the draw_string() approach as a
# theme-swap invariant violation.
_screen_header = ImplantHeader.new()
_screen_header.position = Vector2(PANEL_MARGIN, 16.0)
_screen_header.custom_minimum_size.x = 320.0
_screen_header.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_screen_header)
if _implant_theme:
_screen_header.apply_implant_theme(_implant_theme)
func _refresh_screen_header() -> void:
if _screen_header == null:
return
var body_name: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—"))
var sys_name: String = _dict_str(_system, "proper_name", _dict_str(_system, "system_id", "—"))
var title: String = "ATLAS — %s · %s" % [body_name.to_upper(), sys_name.to_upper()]
var hint: String = "click descend · click city data · esc back"
_screen_header.set_content(title, hint)
## Safely extract a string field from a dict, falling back when missing or null.
static func _dict_str(d: Dictionary, key: String, fallback: String) -> String:
var v: Variant = d.get(key)
if v == null:
return fallback
var s: String = str(v)
if s.is_empty():
return fallback
return s
# =============================================================================
# Input
# =============================================================================
func _is_over_ui(pos: Vector2) -> bool:
# The overlay bar is deliberately NOT checked here (PR #186 review): its
# container spans the full header-adjacent width for row wrapping but is
# MOUSE_FILTER_IGNORE — chip Buttons STOP their own events (they never
# reach this viewer), and the empty flow area left of the right-aligned
# chips must stay interactive map surface. Checking the wide bar rect
# would re-create the ~700px dead strip the review caught.
if _city_panel and _city_panel.visible and _city_panel.get_global_rect().has_point(pos):
return true
return false
func _gui_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.is_echo():
_handle_key(event as InputEventKey)
return
if _heightmap_texture == null:
return
if (
event is InputEventMouseButton
and _is_over_ui((event as InputEventMouseButton).global_position)
):
return
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed:
if not _try_click_city(mb.position):
_descend_at(mb.position)
elif event is InputEventMouseMotion:
if _is_over_ui((event as InputEventMouseMotion).global_position):
return
_update_hover((event as InputEventMouseMotion).position)
func _handle_key(event: InputEventKey) -> void:
match event.keycode:
KEY_ESCAPE:
if _selected_city.size() > 0:
_selected_city = {}
_city_panel.visible = false
queue_redraw()
else:
back_pressed.emit()
KEY_R:
_fit_to_view()
func _try_click_city(screen_pos: Vector2) -> bool: # T-1138: a city's hit-radius wins over descent
var city: Dictionary = _find_city_at(screen_pos)
if city.is_empty():
return false
_selected_city = city
_rebuild_city_panel()
_city_panel.visible = true
queue_redraw()
return true
func _descend_at(screen_pos: Vector2) -> void: # T-1142: inert off-texture (letterbox)
if not _is_screen_pos_on_texture(screen_pos):
return
district_descend_requested.emit(_district_pos_at(screen_pos))
func _update_hover(screen_pos: Vector2) -> void:
var new_hover: Dictionary = _find_city_at(screen_pos)
if new_hover != _hovered_city:
_hovered_city = new_hover
_hover_screen_pos = screen_pos
_hover_active = true
_overlay_node.queue_redraw()
func _find_city_at(screen_pos: Vector2) -> Dictionary:
var cities: Array = _markers.get("cities", [])
if cities.is_empty():
return {}
var hit_radius: float = 12.0
for c: Dictionary in cities:
var canvas_pt: Vector2 = city_canvas_pos(c)
var screen_pt: Vector2 = canvas_to_screen(canvas_pt)
if screen_pos.distance_to(screen_pt) <= hit_radius:
return c
return {}
## Extract canvas-space position for a city marker. Supports `pos: [x,y]`,
## `lat`/`lon` (equirectangular), and legacy `center: [row,col]`. Public so
## AtlasMarkerOverlay doesn't need to reach into private state (review #5).
func city_canvas_pos(city: Dictionary) -> Vector2:
if city.has("pos") and city["pos"] is Array and city["pos"].size() >= 2:
return grid_to_canvas(Vector2(float(city["pos"][0]), float(city["pos"][1])))
if city.has("lat") and city.has("lon"):
var lon: float = float(city["lon"])
var lat: float = float(city["lat"])
return Vector2((lon + 180.0) / 360.0 * _tex_w, (90.0 - lat) / 180.0 * _tex_h)
if city.has("center") and city["center"] is Array and city["center"].size() >= 2:
# center is [row, col] in grid space
return grid_to_canvas(Vector2(float(city["center"][1]), float(city["center"][0])))
return Vector2.ZERO
func _district_pos_at(screen_pos: Vector2) -> Vector2i: # T-1138, geometry: atlas_descend_geometry.gd
var canvas_pt: Vector2 = screen_to_canvas(screen_pos)
var radius_km: float = float(_body.get("body_radius_km", 0.0))
return AtlasDescendGeometry.district_pos_at(canvas_pt, _tex_w, _tex_h, radius_km)
# =============================================================================
# City data panel (sidebar)
# =============================================================================
func _build_city_panel() -> void:
_city_panel = ImplantPanel.new()
_city_panel.name = "CityPanel"
_city_panel.theme_resource = _implant_theme
_city_panel.custom_minimum_size.x = PANEL_WIDTH
_city_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_city_panel.visible = false
add_child(_city_panel)
_position_city_panel()
func _position_city_panel() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
_city_panel.position = Vector2(sz.x - PANEL_WIDTH - PANEL_MARGIN, 60.0)
func _rebuild_city_panel() -> void:
if not _city_panel:
return
_city_panel.clear()
_position_city_panel()
if _selected_city.is_empty():
return
var c: Dictionary = _selected_city
var name_str: String = _dict_str(c, "name", "(unnamed)")
var tier: int = int(c.get("population_tier", 0))
var func_label: String = _dict_str(c, "primary_function", "").replace("_", " ")
var is_gate: bool = bool(c.get("gate_terminal", false))
var sys_label: String = _dict_str(_system, "proper_name", _dict_str(_system, "system_id", ""))
var body_label: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", ""))
_city_panel.add_component(ImplantHeader.new(name_str, sys_label + " · " + body_label))
_city_panel.add_component(ImplantSeparator.new())
if tier > 0:
_city_panel.add_component(ImplantDataRow.new("pop tier %d" % tier))
if not func_label.is_empty():
_city_panel.add_component(ImplantDataRow.new("function " + func_label))
if is_gate:
_city_panel.add_component(ImplantDataRow.new("gate terminal"))
# Cross-system facts
var czone: String = str(_system.get("currency_zone", "")).replace("_", " ")
if not czone.is_empty():
_city_panel.add_component(ImplantDataRow.new("currency " + czone))
var commission: bool = bool(c.get("commission_presence", false))
_city_panel.add_component(
ImplantDataRow.new("commission " + ("yes" if commission else "no"))
)
var shadow: Variant = c.get("shadow_economy_zone")
if shadow != null and not str(shadow).is_empty():
_city_panel.add_component(ImplantDataRow.new("shadow zone " + str(shadow)))
var gate_dist: Variant = c.get("gate_distance_hops")
if gate_dist != null:
_city_panel.add_component(ImplantDataRow.new("gate distance %d hops" % int(gate_dist)))
_city_panel.add_component(ImplantSeparator.new())
_city_panel.add_component(ImplantTextBlock.new("n open economics monitor for this system"))
_city_panel.add_component(ImplantTextBlock.new("esc close"))
func _unhandled_key_input(event: InputEvent) -> void:
if not visible:
return
if not (event is InputEventKey) or not event.pressed or event.is_echo():
return
var ek := event as InputEventKey
if ek.keycode != KEY_N:
return
# Always consume KEY_N while the viewer is visible — otherwise it would
# fall through to main.gd's global economics monitor toggle and close the
# fullscreen atlas as a side effect (review #6).
get_viewport().set_input_as_handled()
if _selected_city.size() > 0:
var sys_id: String = _system.get("system_id", "")
if not sys_id.is_empty():
economics_link_requested.emit(sys_id)
# =============================================================================
# Empty-state notice
# =============================================================================
func _build_empty_notice() -> void:
_empty_notice = ImplantPanel.new()
_empty_notice.name = "EmptyNotice"
_empty_notice.theme_resource = _implant_theme
_empty_notice.custom_minimum_size.x = 400.0
_empty_notice.mouse_filter = Control.MOUSE_FILTER_IGNORE
_empty_notice.visible = false
add_child(_empty_notice)
_empty_notice.add_component(
ImplantHeader.new("TERRAIN DATA PENDING", "regional atlas unavailable")
)
_empty_notice.add_component(ImplantSeparator.new())
var notice_text: String = (
"The server atlas pipeline has not yet populated terrain_reference for this body (#839). "
+ "Once regenerated, the heightmap and markers will render here."
)
_empty_notice.add_component(ImplantTextBlock.new(notice_text))
_empty_notice.add_component(ImplantSeparator.new())
_empty_notice.add_component(ImplantTextBlock.new("esc back to orbital view"))
_position_empty_notice()
func _position_empty_notice() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
_empty_notice.position = Vector2((sz.x - 400.0) * 0.5, sz.y * 0.35)
# =============================================================================
# Overlay toggle bar (#836)
# =============================================================================
func _build_overlay_bar() -> void:
var BarScript := load("res://ui/implant/apps/atlas/atlas_overlay_bar.gd")
_overlay_bar = BarScript.new(self)
_overlay_bar.name = "OverlayBar"
add_child(_overlay_bar)
_position_overlay_bar()
func _position_overlay_bar() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
# Top-right, constrained to everything right of the header. The bar is an
# HFlowContainer with ALIGNMENT_END: give it the available width and it
# wraps into as many right-aligned rows as the chip count needs (17 chips
# overflowed the screen edge as a single HBox row — companion feedback,
# 2026-07-21). Height is Control-clamped to the flow's minimum, so it
# grows per row on its own.
var avail_w: float = maxf(sz.x - OVERLAY_BAR_HEADER_RESERVE - PANEL_MARGIN * 2.0, 200.0)
_overlay_bar.position = Vector2(sz.x - avail_w - PANEL_MARGIN, PANEL_MARGIN)
_overlay_bar.size = Vector2(avail_w, 0.0)
# =============================================================================
# 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:
_position_city_panel()
if _empty_notice:
_position_empty_notice()
if _overlay_bar:
_position_overlay_bar()
if _legend_panel:
_legend_panel.reposition()
elif what == NOTIFICATION_MOUSE_EXIT and _hover_active:
_hover_active = false # T-1138: hide the reticle when the cursor leaves
_overlay_node.queue_redraw()