fix(ui): PR #203 review round — total retirement + bin decode + wired reset
Tyre finding: the orphaned AtlasViewer cluster is now actually deleted (atlas_viewer, atlas_marker_overlay, atlas_descend_geometry, atlas_legend_panel — a sixth orphan found beyond the review list — atlas_generation_proxy, atlas_generation_state; ~2,497 lines), with reachability re-verified across preload/class_name/res:// strings, every .tscn, and the standalone companion app. Test suites triaged, not blanket-deleted: 5 pure AtlasOverlayColors tests relocated into test_atlas_window_colors, the live type-identity regression guard relocated into test_step_canvas_viewer, dead coverage deleted. A real harness gap surfaced during diligence and RULED, not patched: visual_scenarios/visual_capture golden shots call retired continuous-zoom API — no shim (would resurrect what D-255 kills); inventory recorded on re-scoped T-1157 (gate-invisible, manual targets only). Hoshe finding 1: decode_png_field now detects the [Error, PackedByteArray] bin-shape from messagepack.gd explicitly — a genuine msgpack bin payload decodes correctly instead of silently collapsing to [0,0]; test built from a real round-tripped bin decode. Hoshe finding 2: the hard zoom-out reset is wired — ascend at rung 0 with a drifted view triggers _reset_to_global (the restored HARD condition), behavioral tests through the real input path. Notes folded: refloat + edge-scroll test coverage, legend smoke suite, Vector2i narrowing-safety comment with computed headroom. gdlint clean on touched files; full client suite 3364/3364. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,201 +0,0 @@
|
||||
extends RefCounted
|
||||
|
||||
## Pure geometry helpers for AtlasViewer's T-1138 descent affordance (D-226
|
||||
## T-1124 amendment §5 entry revision) — factored out to keep atlas_viewer.gd
|
||||
## under gdlint's max-file-lines cap, same rationale/shape as
|
||||
## atlas_overlay_colors.gd's split from atlas_marker_overlay.gd (draw_line()/
|
||||
## draw_string() are CanvasItem instance methods called implicitly on `self`,
|
||||
## so the actual draw calls stay on AtlasViewer — only the pure lookups/math
|
||||
## that decide WHERE/WHAT to draw move here):
|
||||
## const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
##
|
||||
## D-243: 2,048 m per district side — the source-canonical unit the server's
|
||||
## derive_district()/DISTRICT_WINDOW_DEFAULT_N (32) both key off of.
|
||||
const DISTRICT_M: float = 2048.0
|
||||
const DISTRICT_WINDOW_DEFAULT_N: int = 32
|
||||
|
||||
## Fixed on-screen reticle size (px) — deliberately NOT scaled to the
|
||||
## window's true planetary footprint.
|
||||
##
|
||||
## The true footprint of a DISTRICT_WINDOW_DEFAULT_N=32 window is
|
||||
## 32 * 2.048 km = ~65.5 km per side (~131 km at the n=64 cap) — at every
|
||||
## zoom level AtlasViewer's _fit_to_view() ever produces for a whole-planet
|
||||
## heightmap (MAX_ZOOM=8.0 on a texture that already spans the full body),
|
||||
## that distance is on the order of a handful of PIXELS. A true-extent
|
||||
## rectangle would therefore be visually indistinguishable from a dot
|
||||
## regardless of zoom — not an honest representation, just an illegible one;
|
||||
## the amendment explicitly rejects "implying more coverage than real" but a
|
||||
## sub-pixel rectangle fails the OPPOSITE way (implying almost no coverage,
|
||||
## which is equally dishonest about what a click actually captures).
|
||||
##
|
||||
## The resolution (D-226 T-1124 amendment §5's open design point, resolved
|
||||
## here): a small FIXED-SIZE bracket reticle (reads clearly at any zoom, same
|
||||
## idiom as the marker overlay's fixed-size POI glyphs elsewhere on this map)
|
||||
## plus a text label giving the REAL extent in km — "honest" comes from the
|
||||
## label's number, not from the reticle's pixel size pretending to be to
|
||||
## scale. This is a reticle, explicitly not scaled to true size, with the
|
||||
## real extent stated next to it — the amendment's second named option
|
||||
## (chosen over a true-extent rectangle + zoom-in cut).
|
||||
const DESCEND_RETICLE_SIZE: float = 28.0
|
||||
const COLOR_DESCEND_RETICLE: Color = Color(0.70, 0.88, 1.0, 0.85) # matches COLOR_GATE_MARKER family
|
||||
|
||||
|
||||
## The eight line segments (as [from, to] pairs) for the reticle's four
|
||||
## L-shaped bracket corners — reads as "this is a bounded region", distinct
|
||||
## from the circular city-marker glyphs and diamond gate markers already on
|
||||
## this map (D-226's hue=type/shape=identity instinct applied to interaction
|
||||
## affordances). Flat segment-pair array so the caller's draw_line() loop is
|
||||
## a one-liner, not a struct AtlasViewer needs to know the shape of.
|
||||
static func reticle_segments(center: Vector2) -> Array:
|
||||
var half: float = DESCEND_RETICLE_SIZE * 0.5
|
||||
var arm: float = half * 0.5
|
||||
var corners: Array = [
|
||||
center + Vector2(-half, -half),
|
||||
center + Vector2(half, -half),
|
||||
center + Vector2(half, half),
|
||||
center + Vector2(-half, half),
|
||||
]
|
||||
var h_dirs: Array = [Vector2(1, 0), Vector2(-1, 0), Vector2(-1, 0), Vector2(1, 0)]
|
||||
var v_dirs: Array = [Vector2(0, 1), Vector2(0, 1), Vector2(0, -1), Vector2(0, -1)]
|
||||
var segments: Array = []
|
||||
for i in range(4):
|
||||
segments.append([corners[i], corners[i] + h_dirs[i] * arm])
|
||||
segments.append([corners[i], corners[i] + v_dirs[i] * arm])
|
||||
return segments
|
||||
|
||||
|
||||
## Label position (offset from the reticle center, to the right of it) + the
|
||||
## real-extent text — "~65 x 65 km" for the default n=32 window.
|
||||
static func reticle_label(center: Vector2) -> Dictionary:
|
||||
var half: float = DESCEND_RETICLE_SIZE * 0.5
|
||||
var extent_km: float = float(DISTRICT_WINDOW_DEFAULT_N) * DISTRICT_M / 1000.0
|
||||
return {
|
||||
"position": center + Vector2(half + 6.0, 4.0),
|
||||
"text": "~%.0f × %.0f km" % [extent_km, extent_km],
|
||||
}
|
||||
|
||||
|
||||
## Whole-body district extent (columns spanning the full equatorial
|
||||
## circumference; the half-meridian row range, i.e. equator to either pole)
|
||||
## for a body of `body_radius_km`. Shared by district_pos_at(),
|
||||
## canonicalize_district_center(), and AtlasWindowViewer's pole-wall pan
|
||||
## clamp — ONE formula, matching server/src/atlas/layer_proxy.rs's
|
||||
## normalize_window_center() EXACTLY (T-1142 canonicalization, dudley's
|
||||
## in-flight server counterpart): `districts_per_circumference =
|
||||
## round(circumference_m / DISTRICT_M).max(1)`, `half_meridian_districts =
|
||||
## round(meridian_m / DISTRICT_M / 2.0)`. The `.max(1)` floor on cols matters
|
||||
## for canonicalization's rem_euclid (a zero modulus panics/undefined-behaves
|
||||
## on the server; GDScript's `%` on 0 is likewise not safe to rely on) even
|
||||
## though no real systems.db body is small enough to hit it.
|
||||
static func district_extent(body_radius_km: float) -> Dictionary:
|
||||
var circumference_m: float = TAU * body_radius_km * 1000.0
|
||||
var meridian_m: float = PI * body_radius_km * 1000.0
|
||||
return {
|
||||
"cols": maxf(roundf(circumference_m / DISTRICT_M), 1.0),
|
||||
"rows_half": roundf((meridian_m / DISTRICT_M) * 0.5),
|
||||
}
|
||||
|
||||
|
||||
## Inverse of the server's derive_district() pixel mapping
|
||||
## (server/src/atlas/district_profile.rs) — a `true_district_of_pixel`-style
|
||||
## function, per the amendment's §5 carry-over wording. The server's forward
|
||||
## mapping (body has a radius) is:
|
||||
## px = ((dx * DISTRICT_M) / circumference_m mod 1.0) * tex_w
|
||||
## py = (0.5 + clamp(dy * DISTRICT_M / meridian_m, -0.5, 0.5)) * ta.h.saturating_sub(1)
|
||||
## (district_profile.rs:1436 — note `.saturating_sub(1)`, NOT a bare `ta.h`;
|
||||
## confirmed against the ground-truth inverse aliveness_probe.rs:511 too:
|
||||
## `row / (ta_h - 1) - 0.5`). Columns and rows are DELIBERATELY asymmetric:
|
||||
## longitude WRAPS (rem_euclid), so a column has no "last pixel" edge case and
|
||||
## divides by the plain width; latitude CLAMPS at the poles, so row 0 and row
|
||||
## (h-1) are real, distinct endpoints (the north/south pole pixels) and the
|
||||
## division must land exactly on them — dividing by `tex_h` instead of
|
||||
## `tex_h - 1` introduces a systematic drift that grows with |lat_frac|
|
||||
## (worst at the poles, invisible only at the exact equator row, `tex_h*0.5`,
|
||||
## where the two formulas round identically). This is why inverting is "pixel
|
||||
## fraction * district count" for columns but NOT a symmetric operation for
|
||||
## rows — the row inverse must undo the SAME -1 the forward map applied.
|
||||
##
|
||||
## The (wx/circumference_m, wy/meridian_m) fractions are linear scalings of
|
||||
## the same world-metre quantity the equatorial/meridian district COUNT
|
||||
## already IS (district_cols = round(circumference_m / DISTRICT_M), the same
|
||||
## value build_district_grid()'s `cols` converges to for a body tiled
|
||||
## edge-to-edge) — so inverting is "pixel fraction * district count", not a
|
||||
## re-derivation of the server's geodesy. Self-contained: does NOT depend on
|
||||
## district_grid (the whole-body layer) having arrived yet, so descent works
|
||||
## immediately on entry even before that async layer resolves. The "no
|
||||
## radius" branch (tiny test bodies, body_radius_km absent/<=0) mirrors the
|
||||
## server's own fallback: the district grid IS the heightmap grid 1:1.
|
||||
static func district_pos_at(
|
||||
canvas_pt: Vector2, tex_w: float, tex_h: float, body_radius_km: float
|
||||
) -> Vector2i:
|
||||
if tex_w <= 0.0 or tex_h <= 0.0:
|
||||
return Vector2i.ZERO
|
||||
if body_radius_km <= 0.0:
|
||||
return Vector2i(roundi(canvas_pt.x), roundi(canvas_pt.y))
|
||||
var extent: Dictionary = district_extent(body_radius_km)
|
||||
var col: int = roundi((canvas_pt.x / tex_w) * float(extent["cols"]))
|
||||
# tex_h - 1.0, matching the forward map's ta.h.saturating_sub(1) — NOT a
|
||||
# bare tex_h (see the docstring above; this was a live bug, T-1138 PR #187
|
||||
# review, Hoshe: every off-equator click descended into the wrong district).
|
||||
# Guarded the same way the server's own inverse (aliveness_probe.rs:510,
|
||||
# `if ta_h > 1 { ... } else { 0.0 }`) guards the same division — a
|
||||
# degenerate 1px-tall texture would otherwise divide by zero.
|
||||
var lat_frac: float = ((canvas_pt.y / (tex_h - 1.0)) - 0.5) if tex_h > 1.0 else 0.0
|
||||
var row: int = roundi(lat_frac * float(extent["rows_half"]) * 2.0)
|
||||
return Vector2i(col, row)
|
||||
|
||||
|
||||
## T-1142 addendum (Jeroen — pole-wall/east-west-wrap ruling): canonicalize a
|
||||
## district-window center to the SAME range the server's
|
||||
## normalize_window_center() (server/src/atlas/layer_proxy.rs) produces —
|
||||
## column WRAPS (longitude is periodic; rem_euclid into [0, cols)), row
|
||||
## CLAMPS (latitude terminates at the poles; clamp into [-rows_half,
|
||||
## rows_half]). Load-bearing that this matches the server bit-for-bit: the
|
||||
## server echoes back the NORMALIZED center in DistrictWindowLayer.center, so
|
||||
## a client that requests a raw (un-normalized) center but compares against
|
||||
## its own raw value in the §2 staleness guard would reject every legitimate
|
||||
## response for an out-of-range request as "stale". Canonicalizing HERE,
|
||||
## before the request is even sent, means the client's held `_center` already
|
||||
## equals what the server will echo — no drift between the two sides'
|
||||
## "canonical" concepts, and the cache key (built from the same canonicalized
|
||||
## Vector2i) naturally de-dupes a full-circumnavigation pan back to a
|
||||
## previously-fetched column.
|
||||
##
|
||||
## No-radius bodies (tiny test bodies, BodyParams' own doc) are identity —
|
||||
## same fallback disposition as normalize_window_center()'s own no-radius
|
||||
## branch (the forward map's no-radius path has no periodicity concept).
|
||||
static func canonicalize_district_center(center: Vector2i, body_radius_km: float) -> Vector2i:
|
||||
if body_radius_km <= 0.0:
|
||||
return center
|
||||
var extent: Dictionary = district_extent(body_radius_km)
|
||||
var cols: int = int(extent["cols"])
|
||||
var rows_half: int = int(extent["rows_half"])
|
||||
# GDScript's % on negative operands follows sign-of-dividend (like Rust's
|
||||
# %, NOT rem_euclid) — posmod() is Godot's rem_euclid equivalent, exactly
|
||||
# what the server's DistrictPos.rem_euclid(districts_per_circumference) does.
|
||||
var wrapped_col: int = posmod(center.x, cols)
|
||||
var clamped_row: int = clampi(center.y, -rows_half, rows_half)
|
||||
return Vector2i(wrapped_col, clamped_row)
|
||||
|
||||
|
||||
## T-1142 (Jeroen's first hands-on click, PR #187 follow-up): true unless the
|
||||
## canvas point lies ON the heightmap texture, [0, tex_w) x [0, tex_h). The
|
||||
## fixed planetary view (T-1138) can letterbox a non-2:1-aspect viewport
|
||||
## around the 2:1 heightmap — AtlasViewer's mouse-motion/click handlers see
|
||||
## every screen point in the FULL Control rect, including the letterbox dead
|
||||
## zone beside/above/below the actual map, and screen_to_canvas() has no
|
||||
## opinion about whether the resulting canvas point is still ON the texture
|
||||
## (it is a pure affine inverse — it happily returns x=1400 for a click at
|
||||
## screen-x 1900 on a 1024px-wide fitted texture). Left unchecked, a letterbox
|
||||
## click both (a) shows the descend reticle (a promise) and (b) derives a
|
||||
## DistrictPos from an out-of-range canvas point — Jeroen's exact repro
|
||||
## (clicked the letterbox, landed at column 12276 on a body whose max valid
|
||||
## column is ~11236, and the server's clamped-sampling derive at that
|
||||
## beyond-the-planet position produced uniform green).
|
||||
##
|
||||
## ONE named helper, used by BOTH the reticle-show guard and the descend
|
||||
## click fall-through (never two independent bounds checks that could drift
|
||||
## — the same "one truth" lesson T-1140's hover/reticle mismatch already
|
||||
## taught: the visible affordance must always match what the click does).
|
||||
static func is_on_texture(canvas_pt: Vector2, tex_w: float, tex_h: float) -> bool:
|
||||
return canvas_pt.x >= 0.0 and canvas_pt.x < tex_w and canvas_pt.y >= 0.0 and canvas_pt.y < tex_h
|
||||
@@ -1,126 +0,0 @@
|
||||
extends Node
|
||||
|
||||
## Generation-cascade layer-stream proxy client for AtlasViewer (#960, D-225).
|
||||
## Extracted from atlas_viewer.gd (T-1118) to keep that file under the gdlint
|
||||
## max-file-lines cap — this is the whole "poll the proxy, retry on Pending,
|
||||
## show the diegetic GENERATING indicator, dispatch each layer field to
|
||||
## AtlasGenerationState on Ready" responsibility, self-contained apart from
|
||||
## reading the owning viewer's `_body`/`size` and calling its
|
||||
## set_generation_*() accessors.
|
||||
##
|
||||
## This script has no `class_name` on purpose, matching atlas_overlay_bar.gd/
|
||||
## atlas_legend_panel.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. `extends Node` (not RefCounted) because
|
||||
## this owns a child Control (the pending indicator) and needs get_tree() for
|
||||
## the retry timer — added as a child via
|
||||
## load("res://ui/implant/apps/atlas/atlas_generation_proxy.gd").new(self).
|
||||
|
||||
# #960: Layer-1 proxy re-poll. The proxy returns Pending on a cache miss and
|
||||
# generates in the background (D-225); the client re-requests until Ready.
|
||||
const GEN_RETRY_DELAY: float = 0.5
|
||||
const GEN_MAX_RETRIES: int = 20 # ~10s ceiling before giving up
|
||||
|
||||
var _viewer = null # AtlasViewer (untyped to avoid cyclic ref)
|
||||
var _pending: bool = false # awaiting a Layer1 response (re-polls on Pending)
|
||||
var _retries: int = 0
|
||||
var _indicator = null # ImplantPending — loaded by path, not a class_name dep
|
||||
|
||||
|
||||
func _init(viewer_ref = null) -> void:
|
||||
_viewer = viewer_ref
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if _viewer == null:
|
||||
return
|
||||
# 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 — can't fail to parse AtlasViewer and break the atlas from opening
|
||||
# (#960).
|
||||
_indicator = load("res://ui/implant/implant_pending.gd").new()
|
||||
_indicator.name = "GenPending"
|
||||
_viewer.add_child(_indicator)
|
||||
_indicator.apply_implant_theme(_viewer.get_implant_theme())
|
||||
_viewer.resized.connect(reposition_indicator)
|
||||
reposition_indicator()
|
||||
|
||||
|
||||
## Reset polling state for a fresh show_body() call — the caller is
|
||||
## responsible for also resetting the layer data itself
|
||||
## (AtlasGenerationState.reset_layer1()).
|
||||
func reset() -> void:
|
||||
_pending = false
|
||||
_retries = 0
|
||||
if _indicator:
|
||||
_indicator.stop()
|
||||
|
||||
|
||||
## 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.
|
||||
func request(body_id: String) -> void:
|
||||
if body_id.is_empty():
|
||||
return
|
||||
_pending = true
|
||||
SimBridge.request_atlas_layers(body_id)
|
||||
|
||||
|
||||
## Handle a Layer-1 response. Ignores responses for a stale body (the user
|
||||
## navigated away). On Pending the proxy is still generating, so we re-request
|
||||
## after a short delay until Ready or the retry ceiling.
|
||||
func on_response(response: Dictionary, current_body_id: String) -> void:
|
||||
if str(response.get("body_id", "")) != current_body_id:
|
||||
return
|
||||
match str(response.get("status", "")):
|
||||
"Ready":
|
||||
_pending = false
|
||||
_retries = 0
|
||||
_set_indicator(false)
|
||||
_viewer.set_generation_layer1(response.get("layer1"))
|
||||
_viewer.set_generation_district_grid(response.get("district_grid"))
|
||||
_viewer.set_generation_road_graph(response.get("road_graph"))
|
||||
_viewer.set_generation_settlements(response.get("settlements"))
|
||||
_viewer.set_generation_region_grid(response.get("region_grid"))
|
||||
_viewer.set_generation_quarter_footprints(response.get("quarter_footprints"))
|
||||
"Pending":
|
||||
if _retries < GEN_MAX_RETRIES:
|
||||
_retries += 1
|
||||
_set_indicator(true)
|
||||
_schedule_retry(current_body_id)
|
||||
else:
|
||||
_pending = false # gave up — overlays stay empty
|
||||
_set_indicator(false)
|
||||
_:
|
||||
_pending = false # NotFound / Error — nothing to draw
|
||||
_set_indicator(false)
|
||||
|
||||
|
||||
func _schedule_retry(body_id: String) -> void:
|
||||
var timer := get_tree().create_timer(GEN_RETRY_DELAY)
|
||||
timer.timeout.connect(
|
||||
func() -> void:
|
||||
# Re-request only if still on the same body and still waiting.
|
||||
if _pending and _viewer.get_body_id() == body_id:
|
||||
SimBridge.request_atlas_layers(body_id)
|
||||
)
|
||||
|
||||
|
||||
## Show/hide the diegetic pending indicator. Starting only when not already
|
||||
## visible avoids resetting the sweep on every re-poll.
|
||||
func _set_indicator(on: bool) -> void:
|
||||
if _indicator == null:
|
||||
return
|
||||
if on:
|
||||
if not _indicator.visible:
|
||||
reposition_indicator()
|
||||
_indicator.start("GENERATING LAYER 1")
|
||||
else:
|
||||
_indicator.stop()
|
||||
|
||||
|
||||
func reposition_indicator() -> void:
|
||||
if _indicator == null or _viewer == null:
|
||||
return
|
||||
var sz: Vector2 = _viewer.size
|
||||
_indicator.position = Vector2((sz.x - _indicator.DEFAULT_WIDTH) * 0.5, sz.y * 0.45)
|
||||
@@ -1,105 +0,0 @@
|
||||
extends RefCounted
|
||||
## Generation-cascade layer state for AtlasViewer (#960, D-225).
|
||||
##
|
||||
## Extracted from atlas_viewer.gd (T-1118/T-1119) to keep that file under the
|
||||
## gdlint max-file-lines cap while it keeps growing a new field+accessor pair
|
||||
## per generation layer (layer1, district_grid, road_graph, settlements,
|
||||
## region_grid, quarter_footprints — the AtlasLayerResponse growth-ceiling
|
||||
## note in the D-226 T-1112 amendment names region_grid/quarter_footprints as
|
||||
## the last two candidate sibling Option fields). One `_generation_*` field +
|
||||
## one get/set pair per layer, all following the same "store + redraw the
|
||||
## overlay" shape — collecting them here means that shape is written once
|
||||
## (in _set) instead of once per accessor pair.
|
||||
##
|
||||
## AtlasViewer holds one instance (`_gen_state`) and exposes the SAME public
|
||||
## get_generation_*()/set_generation_*() method names it always has —
|
||||
## external callers (atlas_marker_overlay.gd, test_atlas_overlays.gd) are
|
||||
## unaffected; only the storage moved. Consumed via explicit load() by path
|
||||
## (no class_name), matching atlas_legend_panel.gd/atlas_overlay_bar.gd
|
||||
## (review #8 there): AtlasViewer is itself referenced by class_name in
|
||||
## other scripts, and a second global class_name in this cluster is an
|
||||
## unnecessary addition to the global class cache.
|
||||
|
||||
var _overlay_node: Node2D = null # set once by AtlasViewer._ready() (the redraw target)
|
||||
|
||||
var _layer1: Variant = null # #960: Layer1Output from the proxy (D-225)
|
||||
var _district_grid: Variant = null # T-1046: coarse DistrictGridLayer (D-226)
|
||||
var _road_graph: Variant = null # T-960: RoadGraph layer from the proxy (D-225)
|
||||
var _settlements: Variant = null # T-960: settlement placements from the proxy (D-225)
|
||||
var _region_grid: Variant = null # T-1118: RegionGridLayer climate grid (D-226/T-1113)
|
||||
var _quarter_footprints: Variant = null # T-1119: QuarterFootprintLayer (D-226 T-1112 amendment)
|
||||
|
||||
|
||||
func bind_overlay_node(overlay_node: Node2D) -> void:
|
||||
_overlay_node = overlay_node
|
||||
|
||||
|
||||
func _redraw() -> void:
|
||||
if _overlay_node:
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## Reset every layer to its pre-body-load default. Called from
|
||||
## AtlasViewer.show_body() so a body switch doesn't inherit the previous
|
||||
## body's generation data.
|
||||
func reset_layer1() -> void:
|
||||
_layer1 = null
|
||||
|
||||
|
||||
func set_layer1(layer1: Variant) -> void:
|
||||
_layer1 = layer1
|
||||
_redraw()
|
||||
|
||||
|
||||
func get_layer1() -> Variant:
|
||||
return _layer1
|
||||
|
||||
|
||||
func set_district_grid(grid: Variant) -> void:
|
||||
_district_grid = grid
|
||||
_redraw()
|
||||
|
||||
|
||||
func get_district_grid() -> Variant:
|
||||
return _district_grid
|
||||
|
||||
|
||||
func set_road_graph(graph: Variant) -> void:
|
||||
_road_graph = graph
|
||||
_redraw()
|
||||
|
||||
|
||||
func get_road_graph() -> Variant:
|
||||
return _road_graph
|
||||
|
||||
|
||||
func set_settlements(settlements: Variant) -> void:
|
||||
_settlements = settlements
|
||||
_redraw()
|
||||
|
||||
|
||||
func get_settlements() -> Variant:
|
||||
return _settlements
|
||||
|
||||
|
||||
## T-1118: store the region climate grid (RegionGridLayer) from the proxy and
|
||||
## redraw. The marker overlay reads it via AtlasViewer.get_generation_region_grid().
|
||||
func set_region_grid(grid: Variant) -> void:
|
||||
_region_grid = grid
|
||||
_redraw()
|
||||
|
||||
|
||||
func get_region_grid() -> Variant:
|
||||
return _region_grid
|
||||
|
||||
|
||||
## T-1119: store the L4 quarter-footprint aggregates (QuarterFootprintLayer)
|
||||
## from the proxy and redraw. The marker overlay reads it via
|
||||
## AtlasViewer.get_generation_quarter_footprints().
|
||||
func set_quarter_footprints(footprints: Variant) -> void:
|
||||
_quarter_footprints = footprints
|
||||
_redraw()
|
||||
|
||||
|
||||
func get_quarter_footprints() -> Variant:
|
||||
return _quarter_footprints
|
||||
@@ -1,192 +0,0 @@
|
||||
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)
|
||||
# T-1118 — region climate grid cold/hot ramp endpoints (matches
|
||||
# atlas_overlay_colors.gd's COLOR_REGION_TEMP_COLD/COLOR_REGION_TEMP_HOT).
|
||||
const COLOR_REGION_TEMP_COLD_GEN: Color = Color(0.25, 0.45, 0.85, 0.60)
|
||||
const COLOR_REGION_TEMP_HOT_GEN: Color = Color(0.90, 0.25, 0.20, 0.60)
|
||||
# T-1119 — quarter-footprint density ramp endpoints, D-226 T-1112 amendment
|
||||
# SS3's literal legend spec (matches atlas_overlay_colors.gd's
|
||||
# COLOR_QUARTER_LOW_DENSITY/COLOR_QUARTER_HIGH_DENSITY).
|
||||
const COLOR_QUARTER_LOW_DENSITY_GEN: Color = Color(0.55, 0.48, 0.30, 0.6)
|
||||
const COLOR_QUARTER_HIGH_DENSITY_GEN: Color = Color(0.94, 0.82, 0.38, 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_region_grid",
|
||||
"title": "REGION CLIMATE — mean temperature (~205 km cells)",
|
||||
"rows": [
|
||||
{"glyph": "▦", "color": COLOR_REGION_TEMP_COLD_GEN, "label": "cold"},
|
||||
{"glyph": "▦", "color": COLOR_REGION_TEMP_HOT_GEN, "label": "hot"},
|
||||
{"glyph": "▦", "color": Color.TRANSPARENT, "label": "airless — no reading (skipped)"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"overlay_id": "gen_l4_quarters",
|
||||
"title": "QUARTER FOOTPRINT — L4 (color = density, shape = dominant type)",
|
||||
"rows": [
|
||||
{"glyph": "▪", "color": COLOR_QUARTER_LOW_DENSITY_GEN, "label": "low density"},
|
||||
{"glyph": "▪", "color": COLOR_QUARTER_HIGH_DENSITY_GEN, "label": "high density"},
|
||||
{"glyph": "◪", "color": Color.TRANSPARENT, "label": "commercial (corner tab, top-right)"},
|
||||
{"glyph": "◩", "color": Color.TRANSPARENT, "label": "industrial (corner tab, bottom-right)"},
|
||||
{"glyph": "◈", "color": Color.TRANSPARENT, "label": "administrative (diamond cutout)"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
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():
|
||||
reset_to_content_size()
|
||||
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()
|
||||
reset_to_content_size()
|
||||
@@ -1,873 +0,0 @@
|
||||
class_name AtlasMarkerOverlay
|
||||
extends Node2D
|
||||
|
||||
## Draws heightmap + markers for #835 AtlasViewer. Child of AtlasViewer._canvas
|
||||
## so it inherits the pan/zoom transform. Draws in texture-native coordinates.
|
||||
##
|
||||
## Overlay layers (#836 toggles via viewer._overlay_visibility):
|
||||
## terrain heightmap texture itself
|
||||
## political_zones currency-zone color bands (coarse)
|
||||
## infrastructure roads + railroads
|
||||
## named_features labels for rivers, oceans, mountain ranges
|
||||
## gate_markers gate-terminal POIs
|
||||
## population_density (toggleable) density heatmap placeholder
|
||||
## production_zones (toggleable) production zone outlines placeholder
|
||||
## shadow_economy (toggleable) shadow-zone broad bands placeholder
|
||||
## 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)
|
||||
const COLOR_ROAD: Color = Color(0.85, 0.60, 0.30, 0.85)
|
||||
const COLOR_RAIL: Color = Color(0.45, 0.55, 0.70, 0.85)
|
||||
const COLOR_CITY: Color = Color(0.94, 0.82, 0.38, 1.0)
|
||||
const COLOR_CITY_HOVER: Color = Color(1.0, 1.0, 1.0, 1.0)
|
||||
const COLOR_CITY_SELECTED: Color = Color(1.0, 0.95, 0.60, 1.0)
|
||||
const COLOR_GATE: Color = Color(0.70, 0.88, 1.0, 1.0)
|
||||
const COLOR_POI: Color = Color(0.45, 0.75, 0.85, 0.90)
|
||||
const COLOR_FEATURE_LABEL: Color = Color(0.78, 0.82, 0.92, 0.65)
|
||||
const COLOR_POP_HEAT: Color = Color(0.95, 0.35, 0.25, 0.22)
|
||||
const COLOR_PRODUCTION: Color = Color(0.40, 0.80, 0.55, 0.18)
|
||||
const COLOR_SHADOW: Color = Color(0.35, 0.20, 0.50, 0.22)
|
||||
const COLOR_CORP: Color = Color(0.85, 0.65, 0.20, 0.75)
|
||||
# Generation overlays (#960, D-225, Araminta's encoding).
|
||||
# Rivers/mouths use the reliefmap's own water colour (sampled median ocean blue)
|
||||
# so river lines blend seamlessly into the surface water bodies instead of
|
||||
# reading as a distinct-coloured line flowing onto the sea.
|
||||
const COLOR_GEN_RIVER: Color = Color(0.353, 0.647, 0.776, 1.0)
|
||||
const COLOR_GEN_MOUTH: Color = Color(0.353, 0.647, 0.776, 1.0)
|
||||
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
|
||||
|
||||
## Pure color-ramp/shape-selection helpers (morphology, sub-biome, road
|
||||
## authority, region temp, quarter density/notch) — factored into
|
||||
## atlas_overlay_colors.gd (T-1118) to stay under gdlint's max-file-lines,
|
||||
## same rationale as atlas_format.gd's static-function + preload pattern.
|
||||
## That file owns the actual constant tables; this file only keeps constants
|
||||
## with no decision function wrapping them (e.g. COLOR_SETTLEMENT — used
|
||||
## directly, not looked up).
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
|
||||
|
||||
# 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_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
|
||||
|
||||
const PRODUCTION_FUNCTIONS: Array = [
|
||||
"industrial",
|
||||
"industry",
|
||||
"manufacturing",
|
||||
"extraction",
|
||||
"mining",
|
||||
"refinery",
|
||||
"foundry",
|
||||
"shipyard",
|
||||
"production",
|
||||
]
|
||||
|
||||
var viewer = null # AtlasViewer (untyped to avoid cyclic ref)
|
||||
|
||||
# Generation-overlay coordinate mapping (#960): set in _draw before the gen
|
||||
# layers are drawn. Positions are [row, col] in the Layer-1 working grid
|
||||
# (_gen_grid_*), mapped onto the displayed heightmap texture (_gen_tex_*).
|
||||
var _gen_grid_w: float = 0.0
|
||||
var _gen_grid_h: float = 0.0
|
||||
var _gen_tex_w: float = 0.0
|
||||
var _gen_tex_h: float = 0.0
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if viewer == null:
|
||||
return
|
||||
var tex: Texture2D = viewer.get_heightmap_texture()
|
||||
if tex == null:
|
||||
return
|
||||
|
||||
var tex_w: float = float(tex.get_width())
|
||||
var tex_h: float = float(tex.get_height())
|
||||
var markers: Dictionary = viewer.get_markers()
|
||||
|
||||
# Terrain (heightmap) — always first
|
||||
if viewer.is_overlay_visible("terrain"):
|
||||
draw_texture_rect(
|
||||
tex, Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), false, COLOR_HEIGHTMAP_TINT
|
||||
)
|
||||
else:
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), Color(0.05, 0.07, 0.10, 1.0))
|
||||
|
||||
# Political zones — province boundaries from drainage analysis
|
||||
if viewer.is_overlay_visible("political_zones"):
|
||||
_draw_province_boundaries(markers)
|
||||
|
||||
# Infrastructure (roads + rail)
|
||||
if viewer.is_overlay_visible("infrastructure"):
|
||||
_draw_roads(markers)
|
||||
_draw_rails(markers)
|
||||
|
||||
# Named features (rivers, oceans, mountain ranges)
|
||||
if viewer.is_overlay_visible("named_features"):
|
||||
_draw_named_features(markers)
|
||||
|
||||
# Toggleable placeholders — drawn only when populated
|
||||
if viewer.is_overlay_visible("population_density"):
|
||||
_draw_population_density(markers)
|
||||
if viewer.is_overlay_visible("production_zones"):
|
||||
_draw_production_zones(markers)
|
||||
if viewer.is_overlay_visible("shadow_economy"):
|
||||
_draw_shadow_economy(markers)
|
||||
if viewer.is_overlay_visible("corp_presence"):
|
||||
_draw_corp_presence(markers)
|
||||
|
||||
# Generation: region climate grid (T-1118, D-243) — the coarsest area fill
|
||||
# (~205 km cells), drawn first among generation overlays so the district
|
||||
# morphology grid and every finer layer below reads on top of it as
|
||||
# background climate context.
|
||||
if viewer.is_overlay_visible("gen_region_grid"):
|
||||
var region_grid: Variant = viewer.get_generation_region_grid()
|
||||
if region_grid is Dictionary:
|
||||
_draw_gen_region_grid(region_grid, tex_w, tex_h)
|
||||
|
||||
# Generation: district morphology grid (T-1046, D-226) — coarse area fill,
|
||||
# drawn under the Layer-1 line/point overlays.
|
||||
if viewer.is_overlay_visible("gen_district"):
|
||||
var grid: Variant = viewer.get_generation_district_grid()
|
||||
if grid is Dictionary:
|
||||
_draw_gen_district(grid, tex_w, tex_h)
|
||||
|
||||
# Generation-cascade layers (#960, D-225) — from the proxied Layer1Output.
|
||||
# Draw order: basins (area) under rivers under attractors (point anchors).
|
||||
var layer1: Variant = viewer.get_generation_layer1()
|
||||
if layer1 is Dictionary:
|
||||
# The Layer1Output carries the working-grid dims its positions live in
|
||||
# (#960). Map from those, NOT the markers.json grid (_grid_w/_grid_h).
|
||||
_gen_grid_w = float(layer1.get("grid_w", 0))
|
||||
_gen_grid_h = float(layer1.get("grid_h", 0))
|
||||
_gen_tex_w = tex_w
|
||||
_gen_tex_h = tex_h
|
||||
if _gen_grid_w > 0.0 and _gen_grid_h > 0.0:
|
||||
if viewer.is_overlay_visible("gen_l1_basins"):
|
||||
_draw_gen_basins(layer1)
|
||||
if viewer.is_overlay_visible("gen_l1_rivers"):
|
||||
_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)
|
||||
# T-1119: drawn immediately after gen_l3_settlements so it reads
|
||||
# as "on top of" the city dot it annotates (D-226 T-1112 SS3),
|
||||
# and needs the SAME settlements payload for the city_id->
|
||||
# position join (quarter footprints carry no position of
|
||||
# their own).
|
||||
if viewer.is_overlay_visible("gen_l4_quarters"):
|
||||
var quarters: Variant = viewer.get_generation_quarter_footprints()
|
||||
if quarters is Dictionary:
|
||||
_draw_gen_quarter_footprints(quarters, settlements)
|
||||
|
||||
# POIs (non-gate first, then gates on top if enabled)
|
||||
_draw_pois(markers)
|
||||
|
||||
# Gate markers
|
||||
if viewer.is_overlay_visible("gate_markers"):
|
||||
_draw_gate_markers(markers)
|
||||
|
||||
# Cities last so labels sit on top
|
||||
_draw_cities(markers)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Feature drawing
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _draw_roads(markers: Dictionary) -> void:
|
||||
var roads: Array = markers.get("roads", [])
|
||||
for r: Dictionary in roads:
|
||||
var path: Array = r.get("path", [])
|
||||
if path.size() < 2:
|
||||
continue
|
||||
var points: PackedVector2Array = _path_to_canvas(path)
|
||||
draw_polyline(points, COLOR_ROAD, 1.5, true)
|
||||
|
||||
|
||||
func _draw_rails(markers: Dictionary) -> void:
|
||||
var rails: Array = markers.get("railroads", [])
|
||||
for r: Dictionary in rails:
|
||||
var path: Array = r.get("path", [])
|
||||
if path.size() < 2:
|
||||
continue
|
||||
var points: PackedVector2Array = _path_to_canvas(path)
|
||||
_draw_dashed_polyline(points, COLOR_RAIL, 1.0)
|
||||
|
||||
|
||||
func _draw_named_features(markers: Dictionary) -> void:
|
||||
var font := ThemeDB.fallback_font
|
||||
var fs: int = 8
|
||||
|
||||
for ocean: Dictionary in markers.get("oceans", []):
|
||||
var label: String = ocean.get("name", "") if ocean.get("name") else ""
|
||||
if label.is_empty():
|
||||
continue
|
||||
var p: Vector2 = _center_to_canvas(ocean.get("center"))
|
||||
draw_string(
|
||||
font, p, label.to_upper(), HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL
|
||||
)
|
||||
|
||||
for mtn: Dictionary in markers.get("mountain_ranges", []):
|
||||
var label: String = mtn.get("name", "") if mtn.get("name") else ""
|
||||
if label.is_empty():
|
||||
continue
|
||||
var p: Vector2 = _center_to_canvas(mtn.get("center"))
|
||||
draw_string(font, p, label, HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL)
|
||||
|
||||
for river: Dictionary in markers.get("rivers", []):
|
||||
var label: String = river.get("name", "") if river.get("name") else ""
|
||||
if label.is_empty():
|
||||
continue
|
||||
var p: Vector2 = _center_to_canvas(river.get("center"))
|
||||
draw_string(font, p, label, HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL)
|
||||
|
||||
|
||||
func _draw_pois(markers: Dictionary) -> void:
|
||||
var pois: Array = markers.get("pois", [])
|
||||
for p: Dictionary in pois:
|
||||
if bool(p.get("gate_terminal", false)):
|
||||
continue # drawn by gate_markers layer
|
||||
var pos: Vector2 = _poi_pos(p)
|
||||
_draw_diamond(pos, 4.0, COLOR_POI)
|
||||
|
||||
|
||||
func _draw_gate_markers(markers: Dictionary) -> void:
|
||||
# Gates show up in both pois[] (kind="gate") and cities[].gate_terminal
|
||||
for p: Dictionary in markers.get("pois", []):
|
||||
if not bool(p.get("gate_terminal", false)):
|
||||
continue
|
||||
_draw_diamond(_poi_pos(p), 6.0, COLOR_GATE)
|
||||
for c: Dictionary in markers.get("cities", []):
|
||||
if not bool(c.get("gate_terminal", false)):
|
||||
continue
|
||||
var pos: Vector2 = viewer.city_canvas_pos(c)
|
||||
draw_arc(pos, 9.0, 0.0, TAU, 18, COLOR_GATE, 1.2, true)
|
||||
|
||||
|
||||
func _draw_cities(markers: Dictionary) -> void:
|
||||
var cities: Array = markers.get("cities", [])
|
||||
if cities.is_empty():
|
||||
return
|
||||
var font := ThemeDB.fallback_font
|
||||
# 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 is_selected:
|
||||
col = COLOR_CITY_SELECTED
|
||||
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 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()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Province boundaries (D-205, #927)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _draw_province_boundaries(markers: Dictionary) -> void:
|
||||
var provinces: Array = markers.get("provinces", [])
|
||||
if provinces.is_empty():
|
||||
draw_rect(
|
||||
Rect2(
|
||||
Vector2.ZERO,
|
||||
Vector2(
|
||||
viewer.get_heightmap_texture().get_width(),
|
||||
viewer.get_heightmap_texture().get_height()
|
||||
)
|
||||
),
|
||||
COLOR_POLITICAL
|
||||
)
|
||||
return
|
||||
for prov: Dictionary in provinces:
|
||||
var path: Array = prov.get("path", [])
|
||||
if path.size() < 3:
|
||||
continue
|
||||
var points: PackedVector2Array = _province_path_to_canvas(path)
|
||||
if points.size() >= 3:
|
||||
draw_colored_polygon(points, COLOR_PROVINCE_FILL)
|
||||
draw_polyline(points, COLOR_PROVINCE_BORDER, PROVINCE_BORDER_WIDTH, true)
|
||||
|
||||
|
||||
func _province_path_to_canvas(path: Array) -> PackedVector2Array:
|
||||
var out: PackedVector2Array = PackedVector2Array()
|
||||
for pt: Variant in path:
|
||||
if pt is Array and pt.size() >= 2:
|
||||
out.append(viewer.grid_to_canvas(Vector2(float(pt[1]), float(pt[0]))))
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Overlay placeholders (populated by server side signals eventually)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _draw_population_density(markers: Dictionary) -> void:
|
||||
# Placeholder: soft blobs at city positions scaled by population_tier
|
||||
for c: Dictionary in markers.get("cities", []):
|
||||
var pos: Vector2 = viewer.city_canvas_pos(c)
|
||||
var tier: int = int(c.get("population_tier", 1))
|
||||
var r: float = 12.0 + float(tier) * 6.0
|
||||
draw_circle(pos, r, COLOR_POP_HEAT)
|
||||
|
||||
|
||||
# Review #3: derive overlays from existing cities[] fields. The original
|
||||
# code read production_zones / shadow_zones / corp_presence from keys that
|
||||
# are not in the D-191 §8 markers schema, so toggling was a silent no-op.
|
||||
# When the server schema lands with explicit zone polygons we can promote
|
||||
# these back to dedicated arrays; until then the MVP reads what's there.
|
||||
func _draw_production_zones(markers: Dictionary) -> void:
|
||||
for city: Dictionary in markers.get("cities", []):
|
||||
var func_id: String = str(city.get("primary_function", "")).to_lower()
|
||||
if not PRODUCTION_FUNCTIONS.has(func_id):
|
||||
continue
|
||||
var pos: Vector2 = viewer.city_canvas_pos(city)
|
||||
var tier: int = int(city.get("population_tier", 1))
|
||||
var radius: float = 10.0 + float(tier) * 4.0
|
||||
draw_circle(pos, radius, COLOR_PRODUCTION)
|
||||
|
||||
|
||||
func _draw_shadow_economy(markers: Dictionary) -> void:
|
||||
# Broad bands around cities outside the Commission's reach. Intentionally
|
||||
# soft + overlapping — D-181 treats shadow zones as "broad bands", not
|
||||
# precise polygons.
|
||||
for city: Dictionary in markers.get("cities", []):
|
||||
var commission: bool = bool(city.get("commission_presence", false))
|
||||
if commission:
|
||||
continue
|
||||
var pos: Vector2 = viewer.city_canvas_pos(city)
|
||||
var tier: int = int(city.get("population_tier", 1))
|
||||
var radius: float = 18.0 + float(tier) * 6.0
|
||||
draw_circle(pos, radius, COLOR_SHADOW)
|
||||
|
||||
|
||||
func _draw_corp_presence(markers: Dictionary) -> void:
|
||||
# Tier 1 corp presence — keyed off the Commission-presence flag on cities
|
||||
# until D-191 §8 schema explicitly lists per-corp dots.
|
||||
for city: Dictionary in markers.get("cities", []):
|
||||
if not bool(city.get("commission_presence", false)):
|
||||
continue
|
||||
var pos: Vector2 = viewer.city_canvas_pos(city)
|
||||
draw_rect(Rect2(pos - Vector2(3, 3), Vector2(6, 6)), COLOR_CORP)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# Generation-cascade overlays (#960, D-225)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Map a Layer-1 [row, col] position (in the Layer1Output working grid) onto the
|
||||
## displayed heightmap texture. Uses the grid dims FROM THE DATA — NOT
|
||||
## viewer.grid_to_canvas, whose _grid_w/_grid_h is the markers.json coordinate
|
||||
## space. Conflating the two scaled the overlays off by the downsample ratio (#960).
|
||||
func _gen_pos(rc: Variant) -> Vector2:
|
||||
return Vector2(float(rc[1]) / _gen_grid_w * _gen_tex_w, float(rc[0]) / _gen_grid_h * _gen_tex_h)
|
||||
|
||||
|
||||
## District morphology grid (T-1046, D-226). A coarse `cols × rows` area fill —
|
||||
## each cell coloured by its MorphologyZone discriminant (D-239 §6), semi-
|
||||
## transparent so the heightmap reads through. Planetary-scale map view (NOT the
|
||||
## 2 km on-demand districts, which are Phase 5 in-world).
|
||||
func _draw_gen_district(grid: Dictionary, tex_w: float, tex_h: float) -> void:
|
||||
var cols: int = int(grid.get("cols", 0))
|
||||
var rows: int = int(grid.get("rows", 0))
|
||||
if cols <= 0 or rows <= 0:
|
||||
return
|
||||
var morphology: Variant = grid.get("morphology")
|
||||
if not morphology is PackedByteArray:
|
||||
return
|
||||
var cw: float = tex_w / float(cols)
|
||||
var ch: float = tex_h / float(rows)
|
||||
var n: int = morphology.size()
|
||||
for ry in range(rows):
|
||||
for rx in range(cols):
|
||||
var i: int = ry * cols + rx
|
||||
if i >= n:
|
||||
continue
|
||||
# +0.5 overdraw avoids hairline seams between adjacent cells.
|
||||
draw_rect(
|
||||
Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5),
|
||||
AtlasOverlayColors.morphology_color(int(morphology[i]))
|
||||
)
|
||||
|
||||
|
||||
## Region climate grid (T-1118, D-243 SS"region"). A coarse `cols x rows` area
|
||||
## fill mirroring _draw_gen_district's SELF-CONTAINED coordinate mapping above
|
||||
## (dims come from the layer dict itself, cw/ch = tex/cols|rows) — this is a
|
||||
## dense row-major grid keyed by its OWN cols/rows, NOT a Layer-1 point
|
||||
## position, so it does NOT use _gen_pos()/_gen_grid_w (that path is for
|
||||
## Layer-1 river/basin/attractor points and the L2/L3 graphs, which share one
|
||||
## working-grid coordinate space; the region grid is its own dense array with
|
||||
## its own extent, exactly like district_grid).
|
||||
##
|
||||
## Channel: mean_temp_dc only (season/weather/moisture deferred to a future
|
||||
## tooltip/variant overlay per the ticket scope — D-226 "one visual channel
|
||||
## first"). Airless-body cells (REGION_TEMP_NONE_DC sentinel) are skipped
|
||||
## entirely, not colored — see the REGION_TEMP_NONE_DC comment above.
|
||||
func _draw_gen_region_grid(grid: Dictionary, tex_w: float, tex_h: float) -> void:
|
||||
var cols: int = int(grid.get("cols", 0))
|
||||
var rows: int = int(grid.get("rows", 0))
|
||||
if cols <= 0 or rows <= 0:
|
||||
return
|
||||
var mean_temp_dc: Variant = grid.get("mean_temp_dc")
|
||||
if not (mean_temp_dc is Array or mean_temp_dc is PackedByteArray):
|
||||
return
|
||||
var cw: float = tex_w / float(cols)
|
||||
var ch: float = tex_h / float(rows)
|
||||
var n: int = mean_temp_dc.size()
|
||||
for ry in range(rows):
|
||||
for rx in range(cols):
|
||||
var i: int = ry * cols + rx
|
||||
if i >= n:
|
||||
continue
|
||||
var temp_dc: int = int(mean_temp_dc[i])
|
||||
if temp_dc == REGION_TEMP_NONE_DC:
|
||||
continue # airless — no temperature to show
|
||||
# +0.5 overdraw avoids hairline seams between adjacent cells.
|
||||
draw_rect(
|
||||
Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5),
|
||||
AtlasOverlayColors.region_temp_color(temp_dc)
|
||||
)
|
||||
|
||||
|
||||
func _draw_gen_rivers(layer1: Dictionary) -> void:
|
||||
var rn: Variant = layer1.get("river_network")
|
||||
if not rn is Dictionary:
|
||||
return
|
||||
for c: Variant in rn.get("river_cells", []):
|
||||
if c is Array and c.size() >= 2:
|
||||
draw_circle(_gen_pos(c), 1.2, COLOR_GEN_RIVER)
|
||||
for cf: Variant in rn.get("confluences", []):
|
||||
if cf is Array and cf.size() >= 2:
|
||||
draw_circle(_gen_pos(cf), 3.0, COLOR_GEN_RIVER)
|
||||
for m: Variant in rn.get("mouths", []):
|
||||
if m is Array and m.size() >= 2:
|
||||
var p: Vector2 = _gen_pos(m)
|
||||
# Double ring = sea terminus (high-value anchor).
|
||||
draw_arc(p, 5.0, 0.0, TAU, 18, COLOR_GEN_MOUTH, 1.5)
|
||||
var halo := Color(COLOR_GEN_MOUTH.r, COLOR_GEN_MOUTH.g, COLOR_GEN_MOUTH.b, 0.30)
|
||||
draw_arc(p, 8.0, 0.0, TAU, 22, halo, 1.0)
|
||||
|
||||
|
||||
func _draw_gen_basins(layer1: Dictionary) -> void:
|
||||
for b: Variant in layer1.get("drainage_basins", []):
|
||||
if not b is Dictionary:
|
||||
continue
|
||||
var boundary: Array = b.get("boundary", [])
|
||||
var pts: PackedVector2Array = PackedVector2Array()
|
||||
for pt: Variant in boundary:
|
||||
if pt is Array and pt.size() >= 2:
|
||||
pts.append(_gen_pos(pt))
|
||||
if pts.size() < 2:
|
||||
continue
|
||||
if pts.size() >= 3:
|
||||
draw_colored_polygon(pts, COLOR_GEN_BASIN_FILL)
|
||||
var loop: PackedVector2Array = pts.duplicate()
|
||||
loop.append(pts[0])
|
||||
draw_polyline(loop, COLOR_GEN_BASIN_LINE, 0.8, true)
|
||||
|
||||
|
||||
func _draw_gen_attractors(layer1: Dictionary) -> void:
|
||||
for a: Variant in layer1.get("attractors", []):
|
||||
if not a is Dictionary:
|
||||
continue
|
||||
var strength: float = float(a.get("strength", 0.0))
|
||||
if strength < GEN_ATTRACTOR_MIN_STRENGTH:
|
||||
continue
|
||||
var pos_rc: Variant = a.get("position")
|
||||
if not pos_rc is Array or pos_rc.size() < 2:
|
||||
continue
|
||||
var size: float = 5.0 + strength * 4.0
|
||||
var color: Color = AtlasOverlayColors.sub_biome_color(str(a.get("sub_biome", "")))
|
||||
_draw_attractor_shape(str(a.get("attractor_type", "")), _gen_pos(pos_rc), size, color)
|
||||
|
||||
|
||||
## Attractor type → marker shape (Araminta's vocabulary, 7 types).
|
||||
func _draw_attractor_shape(atype: String, pos: Vector2, size: float, color: Color) -> void:
|
||||
match atype:
|
||||
"RiverMouth":
|
||||
draw_circle(pos, size, color)
|
||||
"CoastalAccess":
|
||||
draw_arc(pos, size, 0.0, TAU, 18, color, 1.5)
|
||||
draw_circle(pos, size * 0.5, color)
|
||||
"RiverCrossing":
|
||||
_draw_diamond(pos, size, color)
|
||||
"ValleyFloor":
|
||||
_draw_triangle(pos, size, color, true)
|
||||
"PassEntrance":
|
||||
_draw_triangle(pos, size, color, false)
|
||||
"LakeShore":
|
||||
draw_arc(pos, size, 0.0, TAU, 18, color, 1.5)
|
||||
"PlainCenter":
|
||||
draw_rect(Rect2(pos - Vector2(size, size) * 0.7, Vector2(size, size) * 1.4), color)
|
||||
_:
|
||||
draw_circle(pos, size, color)
|
||||
|
||||
|
||||
func _draw_triangle(pos: Vector2, size: float, color: Color, point_down: bool) -> void:
|
||||
var dir: float = 1.0 if point_down else -1.0
|
||||
var pts: PackedVector2Array = PackedVector2Array(
|
||||
[
|
||||
pos + Vector2(0.0, size * dir),
|
||||
pos + Vector2(-size, -size * dir),
|
||||
pos + Vector2(size, -size * dir),
|
||||
]
|
||||
)
|
||||
draw_colored_polygon(pts, color)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Generation-cascade overlays — L2 roads / L3 settlements (T-960, D-225)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## 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 = AtlasOverlayColors.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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Generation-cascade overlays — L4 quarter footprints (T-1119, D-226 T-1112 amendment)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Quarter-footprint aggregates (QuarterFootprintLayer — D-226 T-1112 amendment
|
||||
## SS1/SS4), one density-scaled glyph per city_id anchored at the SAME position
|
||||
## the L3 settlement dot already drew (`_gen_pos` on the settlement's
|
||||
## `position` — quarter footprints carry no independent spatial position per
|
||||
## the amendment, only a `city_id` join key). `entries` is keyed by city_id
|
||||
## (BTreeMap<u64,_> on the wire, decodes to a Dictionary with int keys) and is
|
||||
## a SUBSET of settlements — a placement can exist with no quarter entry yet
|
||||
## (async per-city generation), so a missing city_id is silently skipped, not
|
||||
## an error. `landmark_count`/`corridor_count` are tooltip-only per the D-226
|
||||
## SS2 hard ceiling — never drawn here (city-click sidebar addition, out of
|
||||
## this overlay's scope). Draw call MUST come from the same `settlements`
|
||||
## payload gen_l3_settlements just drew, so this needs the raw settlements
|
||||
## data threaded in from _draw() rather than re-fetching it here.
|
||||
func _draw_gen_quarter_footprints(footprints: Variant, settlements: Variant) -> void:
|
||||
var entries: Dictionary = {}
|
||||
if footprints is Dictionary:
|
||||
entries = footprints.get("entries", {})
|
||||
if entries.is_empty():
|
||||
return
|
||||
|
||||
var settlement_entries: Array = []
|
||||
if settlements is Array:
|
||||
settlement_entries = settlements
|
||||
elif settlements is Dictionary:
|
||||
settlement_entries = settlements.get("settlements", [])
|
||||
if settlement_entries.is_empty():
|
||||
return
|
||||
|
||||
var show_notch: bool = viewer.get_view_zoom() >= SETTLEMENT_LABEL_MIN_ZOOM
|
||||
for s: Variant in settlement_entries:
|
||||
if not s is Dictionary:
|
||||
continue
|
||||
var city_id: int = int(s.get("city_id", -1))
|
||||
if city_id < 0 or not entries.has(city_id):
|
||||
continue
|
||||
var entry: Dictionary = entries[city_id]
|
||||
var pos_rc: Variant = s.get("position")
|
||||
if not pos_rc is Array or pos_rc.size() < 2:
|
||||
continue
|
||||
_draw_quarter_glyph(_gen_pos(pos_rc), entry, show_notch)
|
||||
|
||||
|
||||
## One density-scaled square glyph. Below SETTLEMENT_LABEL_MIN_ZOOM, draws at
|
||||
## minimum size with color only (the notch is illegible at a few px anyway,
|
||||
## per the amendment); at/above it, full size with the dominant-type notch.
|
||||
## Size/color/shape decisions live in AtlasOverlayColors (unit tested there
|
||||
## directly via test_atlas_overlays.gd) — this function is just the
|
||||
## draw_rect/notch calls.
|
||||
func _draw_quarter_glyph(pos: Vector2, entry: Dictionary, show_notch: bool) -> void:
|
||||
var density_pct: int = int(entry.get("density_avg_pct", 0))
|
||||
var side: float = AtlasOverlayColors.quarter_glyph_size(density_pct, show_notch)
|
||||
var color: Color = AtlasOverlayColors.quarter_glyph_color(density_pct)
|
||||
var half: Vector2 = Vector2(side, side) * 0.5
|
||||
draw_rect(Rect2(pos - half, Vector2(side, side)), color)
|
||||
if not show_notch:
|
||||
return
|
||||
var district_type: String = str(entry.get("dominant_district_type", ""))
|
||||
_draw_quarter_notch(pos, half, AtlasOverlayColors.quarter_notch_kind(district_type))
|
||||
|
||||
|
||||
## Draws the notch glyph for a resolved AtlasOverlayColors.quarter_notch_kind() result.
|
||||
func _draw_quarter_notch(pos: Vector2, half: Vector2, kind: String) -> void:
|
||||
var notch_color := Color(0.08, 0.08, 0.08, 0.55)
|
||||
match kind:
|
||||
"commercial":
|
||||
# Corner tab, top-right.
|
||||
var tab: float = half.x * 0.7
|
||||
draw_rect(Rect2(pos + Vector2(half.x - tab, -half.y), Vector2(tab, tab)), notch_color)
|
||||
"industrial":
|
||||
# Corner tab, bottom-right.
|
||||
var tab: float = half.x * 0.7
|
||||
draw_rect(
|
||||
Rect2(pos + Vector2(half.x - tab, half.y - tab), Vector2(tab, tab)), notch_color
|
||||
)
|
||||
"administrative":
|
||||
# Small diamond cutout, center (civic/landmark read).
|
||||
_draw_diamond(pos, half.x * 0.45, notch_color)
|
||||
_:
|
||||
pass # plain square — mixed / no clear dominant
|
||||
|
||||
|
||||
func _path_to_canvas(path: Array) -> PackedVector2Array:
|
||||
var out: PackedVector2Array = PackedVector2Array()
|
||||
for pt: Variant in path:
|
||||
if pt is Array and pt.size() >= 2:
|
||||
out.append(viewer.grid_to_canvas(Vector2(float(pt[0]), float(pt[1]))))
|
||||
return out
|
||||
|
||||
|
||||
func _center_to_canvas(center: Variant) -> Vector2:
|
||||
# Legacy markers.json encodes center as [row, col]
|
||||
if center is Array and center.size() >= 2:
|
||||
return viewer.grid_to_canvas(Vector2(float(center[1]), float(center[0])))
|
||||
return Vector2.ZERO
|
||||
|
||||
|
||||
func _poi_pos(poi: Dictionary) -> Vector2:
|
||||
if poi.has("pos") and poi["pos"] is Array and poi["pos"].size() >= 2:
|
||||
return viewer.grid_to_canvas(Vector2(float(poi["pos"][0]), float(poi["pos"][1])))
|
||||
return _center_to_canvas(poi.get("center"))
|
||||
|
||||
|
||||
func _draw_diamond(pos: Vector2, size: float, color: Color) -> void:
|
||||
var pts: PackedVector2Array = PackedVector2Array(
|
||||
[
|
||||
pos + Vector2(0, -size),
|
||||
pos + Vector2(size, 0),
|
||||
pos + Vector2(0, size),
|
||||
pos + Vector2(-size, 0),
|
||||
]
|
||||
)
|
||||
draw_colored_polygon(pts, color)
|
||||
|
||||
|
||||
func _draw_dashed_polyline(points: PackedVector2Array, color: Color, width: float) -> void:
|
||||
if points.size() < 2:
|
||||
return
|
||||
# Simple per-segment dashing — good enough for the MVP.
|
||||
for i: int in range(points.size() - 1):
|
||||
var a: Vector2 = points[i]
|
||||
var b: Vector2 = points[i + 1]
|
||||
var seg: Vector2 = b - a
|
||||
var seg_len: float = seg.length()
|
||||
if seg_len <= 0.001:
|
||||
continue
|
||||
var dir: Vector2 = seg / seg_len
|
||||
var t: float = 0.0
|
||||
while t < seg_len:
|
||||
var t_end: float = minf(t + RAIL_DASH_ON, seg_len)
|
||||
draw_line(a + dir * t, a + dir * t_end, color, width, true)
|
||||
t = t_end + RAIL_DASH_OFF
|
||||
File diff suppressed because it is too large
Load Diff
@@ -264,13 +264,29 @@ func _active_toggle_overlay() -> String:
|
||||
## < 0 ascends. Cursor-anchored (D-255(a)/Stig round-1 §2): the world point
|
||||
## under the cursor becomes the new step's request center, so the player's
|
||||
## point of interest stays put across the crossing.
|
||||
##
|
||||
## **Hard full-zoom-out reset (D-255(a), PR #203 review — Hoshe finding 2):**
|
||||
## scrolling OUT while ALREADY at rung 0 (Global) is the "past the top of the
|
||||
## ladder" gesture — `scroll_step()` clamps the rung INDEX at 0 (there is no
|
||||
## rung -1 to request), but the CANONICAL Global frame is more than just "rung
|
||||
## 0" — it is rung 0 AT THE UN-PANNED CENTER (world_center == ZERO, view_offset
|
||||
## == ZERO). A player who has panned around the Global canvas and then scrolls
|
||||
## out again expects the HARD reset the retired viewer's own
|
||||
## _maybe_reset_to_canonical_frame() provided (Jeroen's explicit HARD
|
||||
## condition), not a silent no-op. Detected here (not inside
|
||||
## StepCanvasTransport.scroll_step(), which is deliberately a pure
|
||||
## index-clamp with no view-state concept) by checking whether ANY view
|
||||
## drift exists at rung 0 when an ascend gesture arrives with nowhere further
|
||||
## to ascend.
|
||||
func _scroll_rung(direction: int, cursor_local: Vector2) -> void:
|
||||
var new_index: int = StepCanvasTransport.scroll_step(_rung_index, direction)
|
||||
if new_index == _rung_index:
|
||||
if direction < 0 and _rung_index == 0 and _is_global_view_drifted():
|
||||
_reset_to_global()
|
||||
return
|
||||
var cursor_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m(
|
||||
cursor_local - _view_offset, _world_center, _held_rung, _held_extent
|
||||
)
|
||||
var new_index: int = StepCanvasTransport.scroll_step(_rung_index, direction)
|
||||
if new_index == _rung_index:
|
||||
return
|
||||
_rung_index = new_index
|
||||
_held_rung = StepCanvasTransport.rung_at_index(_rung_index)
|
||||
_world_center = cursor_world if _held_rung != StepCanvasTransport.RUNG_GLOBAL else Vector2.ZERO
|
||||
@@ -280,10 +296,17 @@ func _scroll_rung(direction: int, cursor_local: Vector2) -> void:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## True while at rung 0 but the view has drifted from the canonical
|
||||
## un-panned Global frame (world_center/view_offset both ZERO) — the
|
||||
## condition _scroll_rung()'s hard-reset gesture fires on.
|
||||
func _is_global_view_drifted() -> bool:
|
||||
return _world_center != Vector2.ZERO or _view_offset != Vector2.ZERO
|
||||
|
||||
|
||||
## Hard reset to the Global opener (D-255(a): "a hard full-zoom-out reset to
|
||||
## the canonical Global body-surface frame").
|
||||
func _reset_to_global() -> void:
|
||||
if _rung_index == 0:
|
||||
if _rung_index == 0 and not _is_global_view_drifted():
|
||||
return
|
||||
_rung_index = 0
|
||||
_held_rung = StepCanvasTransport.RUNG_GLOBAL
|
||||
|
||||
Reference in New Issue
Block a user