feat(ui): T-1138 regional map screen — click-through descent, fixed planetary view, windowed composite (D-226 T-1124 SS5)
Entry per Jeroen's 2026-07-21 revision: planetary heightmap is now FIXED — all drag-pan/wheel-zoom input removed (set_view/get_view_* capture API survives for the golden harness); hover shows a not-to-scale bracket reticle with the real extent labeled (a true n=32 rectangle is sub-pixel on the planetary canvas — the honest representation given the morph transition is deferred), and a click that misses every city marker descends (city-click wins — one gesture, two contextual reads, no modifier). Descent pushes a new 'district' nav screen centered on the click point's DistrictPos via atlas_descend_geometry.district_pos_at (the pixel-to-district inverse of the server mapping, verified against scale.rs). Regional mode: atlas_window_viewer draws the composite (morphology x elev_q lightness base; temp/moisture/veg toggles — temp reuses the region-ramp colorizer exactly; Marine=6 transparent; glaciation always-on tint matching apply_ice_tint's REAL gate, None|Light no-op, over the amendment's looser prose — documented); pan-on-held-composite with edge-crossing refetch + border-fade during the queue-based derive wait; zoom never refetches. atlas_window_cache: LRU keyed (body_id, center, n), touch-on-read, evict-only, no freshness (D-227). atlas_window_request mirrors the generation-proxy pending-retry shape for None-until-derived. Codec: window params omitted from the wire when absent — byte-identical for every existing caller. Live-verified against the T-1137 server in-worktree: real round-trip on a GJ380c coastal district (6 fields x 1024 cells), echo staleness guard, genuine ~1.4s background-derive wait, pan-edge refetch to an adjacent window, cache-hit on re-descent with zero network. Full client suite 3194/3194; gdlint clean on all 18 files. Open follow-ups flagged in-code: header location label always falls back to coordinates (nearest-settlement needs a join the district window does not carry); atlas_standalone.gd's 'atlas_app.gd is never modified' doc line is now imprecise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -425,10 +425,19 @@ func send_named_action(action_name: String, action_data: Variant = null) -> void
|
||||
## Request a body's generation-cascade layers from the server (#960, D-225).
|
||||
## Live mode only — sends an AtlasLayerRequest frame; the response arrives via the
|
||||
## atlas_layers_received signal. No-op in test mode (no server connection).
|
||||
func request_atlas_layers(body_id: String, up_to: String = "Topography") -> void:
|
||||
##
|
||||
## window_center/window_n (T-1138, D-226 T-1124 amendment §1): optional
|
||||
## windowed district-resolution regional-map query, riding alongside any
|
||||
## up_to value (the window derivation only needs TerrainAnalysis/BodyParams,
|
||||
## not a specific whole-body layer to be cached first). Omitted by every
|
||||
## whole-body-layer caller (show_body()'s existing request), so their wire
|
||||
## traffic is byte-unchanged.
|
||||
func request_atlas_layers(
|
||||
body_id: String, up_to: String = "Topography", window_center: Variant = null, window_n: int = 0
|
||||
) -> void:
|
||||
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
|
||||
return
|
||||
var bytes := Protocol.encode_atlas_layer_request(body_id, up_to)
|
||||
var bytes := Protocol.encode_atlas_layer_request(body_id, up_to, window_center, window_n)
|
||||
if bytes.is_empty():
|
||||
return
|
||||
var err: int = _bridge.send_message(bytes)
|
||||
|
||||
@@ -16,10 +16,33 @@ class_name AtlasMapProtocol
|
||||
## A bare map {body_id, up_to} — NOT the Vec<PlayerInput> array — so the server's
|
||||
## frame demux routes it to the atlas proxy. up_to is a CascadeLayer unit variant
|
||||
## (bare string: "Heightmap" | "Topography").
|
||||
##
|
||||
## `window_center`/`window_n` (T-1138, D-226 T-1124 amendment §1): the windowed
|
||||
## district-resolution regional-map query. Both are OMITTED from the encoded
|
||||
## map (not sent as null) when window_center is null — this is what makes
|
||||
## `#[serde(default)]` on the Rust side decode absence as `window_center: None`
|
||||
## for every whole-body-only caller (request_atlas_layers()'s existing call
|
||||
## sites), byte-identical to pre-T-1138 wire traffic. window_center is a
|
||||
## DistrictPos, wire-encoded as the same [row, col] int-pair convention every
|
||||
## other position field on this channel already uses (road_graph node
|
||||
## positions, settlement positions, Layer-1 river-cell positions) — there is
|
||||
## no separate DistrictPos struct-map on the wire, just a 2-element array.
|
||||
## window_n is left unclamped here — §1 is explicit the server clamps to
|
||||
## [1, DISTRICT_WINDOW_MAX_N] itself and never trusts the wire value; the
|
||||
## client-side default/cap constants (DISTRICT_WINDOW_DEFAULT_N/MAX_N) live on
|
||||
## the regional-window viewer, not duplicated into the codec.
|
||||
static func encode_atlas_layer_request(
|
||||
mp, body_id: String, up_to: String = "Topography"
|
||||
mp,
|
||||
body_id: String,
|
||||
up_to: String = "Topography",
|
||||
window_center: Variant = null,
|
||||
window_n: int = 0
|
||||
) -> PackedByteArray:
|
||||
var msg := {"body_id": body_id, "up_to": up_to}
|
||||
if window_center != null:
|
||||
var center: Vector2i = window_center
|
||||
msg["window_center"] = [center.x, center.y]
|
||||
msg["window_n"] = window_n
|
||||
var result = mp.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status)
|
||||
@@ -37,12 +60,22 @@ static func encode_atlas_layer_request(
|
||||
## the L4 quarter-footprint aggregates, same passthrough pattern —
|
||||
## QuarterFootprintLayer.entries is a BTreeMap<u64, QuarterFootprintEntry> on
|
||||
## the wire, decoding to a Dictionary with int keys (city_id), no reshaping.
|
||||
## Key names "road_graph"/"settlements"/"region_grid"/"quarter_footprints" are
|
||||
## the CONFIRMED wire contract — identical to server/src/atlas/layer_proxy.rs
|
||||
## AtlasLayerResponse's field names (region_grid pinned 2026-07-14,
|
||||
## quarter_footprints pinned 2026-07-18; round-tripped by
|
||||
## test_atlas_overlays.gd and the server's msgpack round-trip tests). This
|
||||
## remains the one client-side spot to touch if the contract ever changes.
|
||||
## district_window (T-1138, D-226 T-1124 amendment §2): the windowed
|
||||
## DistrictWindowLayer — a DISTINCT payload by design (keyed on the request's
|
||||
## (body, center, n), not the body alone), but the wire passthrough is the
|
||||
## same shape as every sibling: raw.get() with no reshaping, `None` on the
|
||||
## wire decodes to GDScript `null` exactly like every other Option field here.
|
||||
## The response's `center`/`n` echo (inside the layer dict itself) is the
|
||||
## client's race-condition/staleness guard (§2) — read by the window cache,
|
||||
## not unwrapped here.
|
||||
## Key names "road_graph"/"settlements"/"region_grid"/"quarter_footprints"/
|
||||
## "district_window" are the CONFIRMED wire contract — identical to
|
||||
## server/src/atlas/layer_proxy.rs AtlasLayerResponse's field names
|
||||
## (region_grid pinned 2026-07-14, quarter_footprints pinned 2026-07-18,
|
||||
## district_window per the D-226 T-1124 amendment §2 struct; round-tripped by
|
||||
## test_atlas_overlays.gd/test_atlas_data_delivery.gd and the server's msgpack
|
||||
## round-trip tests). This remains the one client-side spot to touch if the
|
||||
## contract ever changes.
|
||||
static func atlas_response_from_raw(raw: Variant) -> Variant:
|
||||
if not raw is Dictionary or not raw.has("status"):
|
||||
return null
|
||||
@@ -64,6 +97,7 @@ static func atlas_response_from_raw(raw: Variant) -> Variant:
|
||||
"settlements": raw.get("settlements"),
|
||||
"region_grid": raw.get("region_grid"),
|
||||
"quarter_footprints": raw.get("quarter_footprints"),
|
||||
"district_window": raw.get("district_window"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -776,16 +776,24 @@ static func encode_request_bookmark_catalog() -> PackedByteArray:
|
||||
## above. Every function below is a thin delegate under its ORIGINAL public
|
||||
## name — external callers (sim_bridge.gd, test_atlas_overlays.gd,
|
||||
## test_atlas_data_delivery.gd) are unaffected by the move.
|
||||
## window_center/window_n (T-1138, D-226 T-1124 amendment §1): optional
|
||||
## windowed district-resolution regional-map query — see
|
||||
## atlas_map_protocol.gd's encode_atlas_layer_request doc for the wire shape.
|
||||
## Omitted callers (every whole-body-layer call site predating T-1138) are
|
||||
## byte-unchanged.
|
||||
static func encode_atlas_layer_request(
|
||||
body_id: String, up_to: String = "Topography"
|
||||
body_id: String,
|
||||
up_to: String = "Topography",
|
||||
window_center: Variant = null,
|
||||
window_n: int = 0
|
||||
) -> PackedByteArray:
|
||||
return _amp().encode_atlas_layer_request(_mp(), body_id, up_to)
|
||||
return _amp().encode_atlas_layer_request(_mp(), body_id, up_to, window_center, window_n)
|
||||
|
||||
|
||||
## Decode an AtlasLayerResponse (#969, D-225). Returns a Dictionary
|
||||
## {body_id, status, error, layer1, district_grid, road_graph, settlements,
|
||||
## region_grid, quarter_footprints}, or null if the bytes are not an atlas
|
||||
## response (no "status" key — e.g. an ObserverSnapshot).
|
||||
## region_grid, quarter_footprints, district_window}, or null if the bytes are
|
||||
## not an atlas response (no "status" key — e.g. an ObserverSnapshot).
|
||||
static func decode_atlas_layer_response(bytes: PackedByteArray) -> Variant:
|
||||
return atlas_response_from_raw(decode_raw(bytes))
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
class_name TestAtlasDataDelivery
|
||||
extends GdUnitTestSuite
|
||||
|
||||
# T-1138: REGION_TEMP_NONE_DC sentinel reused verbatim for district_window's
|
||||
# temp_dc field (D-226 T-1124 amendment §2 — one colorizer/sentinel scheme
|
||||
# across both zoom levels). No class_name on atlas_overlay_colors.gd (review
|
||||
# #8 precedent elsewhere in this suite) — preloaded by path.
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Encode/decode round-trips
|
||||
@@ -177,6 +183,70 @@ func test_atlas_response_road_graph_and_settlements_default_null() -> void:
|
||||
assert_that((decoded as Dictionary).get("settlements")).is_null()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1138 (D-226 T-1124 amendment): windowed district-resolution regional map
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## §1: window_center/window_n are OMITTED (not sent as null) when no window is
|
||||
## requested — this is what makes an old-shaped call (every whole-body-layer
|
||||
## call site) byte-identical to pre-T-1138 wire traffic.
|
||||
func test_encode_atlas_layer_request_omits_window_fields_by_default() -> void:
|
||||
var bytes := Protocol.encode_atlas_layer_request("GJ1c", "Topography")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status == null).is_true()
|
||||
assert_bool(decoded.value.has("window_center")).is_false()
|
||||
assert_bool(decoded.value.has("window_n")).is_false()
|
||||
|
||||
|
||||
## §1: a windowed request carries window_center as a [row, col] pair (the same
|
||||
## int-pair convention every other position field on this channel already
|
||||
## uses — road_graph nodes, settlements, Layer-1 river cells) and window_n
|
||||
## verbatim, unclamped (the server owns the [1, DISTRICT_WINDOW_MAX_N] clamp).
|
||||
func test_encode_atlas_layer_request_carries_window_params() -> void:
|
||||
var bytes := Protocol.encode_atlas_layer_request(
|
||||
"GJ1c", "Topography", Vector2i(140, 260), 32
|
||||
)
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status == null).is_true()
|
||||
assert_that(decoded.value.get("body_id")).is_equal("GJ1c")
|
||||
assert_that(decoded.value.get("window_center")).is_equal([140, 260])
|
||||
assert_that(decoded.value.get("window_n")).is_equal(32)
|
||||
|
||||
|
||||
## §2: district_window is a distinct payload (echoes center/n for the
|
||||
## client's staleness guard) but the codec passthrough is the same shape as
|
||||
## every sibling layer — raw.get(), no reshaping. Field types follow the
|
||||
## established district_grid/region_grid convention: u8 arrays decode as
|
||||
## PackedByteArray, i16 (temp_dc, REGION_TEMP_NONE_DC sentinel scheme) as a
|
||||
## plain Array (test_region_grid_round_trips' mean_temp_dc precedent).
|
||||
func test_atlas_response_district_window_passthrough() -> void:
|
||||
var window := {
|
||||
"center": [140, 260],
|
||||
"n": 32,
|
||||
"morphology": PackedByteArray([8, 14, 0, 5]),
|
||||
"elev_q": PackedByteArray([40, 62, 5, 88]),
|
||||
"temp_dc": [120, 95, AtlasOverlayColors.REGION_TEMP_NONE_DC, 60],
|
||||
"moisture_q": PackedByteArray([50, 30, 90, 20]),
|
||||
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
||||
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
||||
}
|
||||
var raw := {"body_id": "GJ1c", "status": "Ready", "district_window": window}
|
||||
var decoded: Variant = Protocol.atlas_response_from_raw(raw)
|
||||
assert_that(decoded).is_not_null()
|
||||
assert_that((decoded as Dictionary).get("district_window")).is_equal(window)
|
||||
|
||||
|
||||
## A body with no window requested (or not yet derived — §1's background-queue
|
||||
## serving model: the completion may not have landed yet) must decode with
|
||||
## district_window absent -> null, same "layer hasn't produced yet" contract
|
||||
## every other Option layer already has.
|
||||
func test_atlas_response_district_window_default_null() -> void:
|
||||
var decoded: Variant = Protocol.atlas_response_from_raw({"body_id": "GJ1c", "status": "Ready"})
|
||||
assert_that(decoded).is_not_null()
|
||||
assert_that((decoded as Dictionary).get("district_window")).is_null()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SimBridge routing — receive_bytes emits the right signal with the right value
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
## T-1138 (D-226 T-1124 amendment §5 entry revision): tests for the planetary
|
||||
## AtlasViewer's click-through descent — the fixed view (no drag-pan/wheel-
|
||||
## zoom), the pixel-to-DistrictPos inverse mapping (atlas_descend_geometry.gd),
|
||||
## and the city-click-wins disambiguation rule.
|
||||
class_name TestAtlasDescendEntry
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# atlas_descend_geometry.gd — pure geometry (no scene tree needed)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The default n=32 window's real footprint is 32 * 2.048 km = 65.536 km, so
|
||||
## the label reads "~66 x 66 km" (rounded) — pins the number the amendment's
|
||||
## "honest labeling" resolution depends on.
|
||||
func test_reticle_label_extent_matches_district_window_default_n() -> void:
|
||||
var label: Dictionary = AtlasDescendGeometry.reticle_label(Vector2(100.0, 100.0))
|
||||
var expected_km: float = 32.0 * 2048.0 / 1000.0
|
||||
assert_str(label["text"]).is_equal("~%.0f × %.0f km" % [expected_km, expected_km])
|
||||
|
||||
|
||||
func test_reticle_label_position_offsets_from_center() -> void:
|
||||
var center := Vector2(50.0, 60.0)
|
||||
var label: Dictionary = AtlasDescendGeometry.reticle_label(center)
|
||||
var pos: Vector2 = label["position"]
|
||||
assert_float(pos.x).is_greater(center.x) # offset to the right, per §5's resolution
|
||||
|
||||
|
||||
## Eight segments (four L-shaped bracket corners, two arms each) — every
|
||||
## segment's "from" endpoint is exactly one of the four corner anchors, and no
|
||||
## segment has zero length (a degenerate reticle would be invisible).
|
||||
func test_reticle_segments_has_eight_nonzero_segments() -> void:
|
||||
var segments: Array = AtlasDescendGeometry.reticle_segments(Vector2(200.0, 150.0))
|
||||
assert_int(segments.size()).is_equal(8)
|
||||
for seg: Array in segments:
|
||||
assert_that(seg[0]).override_failure_message(
|
||||
"a reticle segment must not be zero-length"
|
||||
).is_not_equal(seg[1])
|
||||
|
||||
|
||||
## The reticle's overall bounding box is centered on `center` and sized by
|
||||
## DESCEND_RETICLE_SIZE — a regression guard against an off-center or
|
||||
## mis-scaled bracket.
|
||||
func test_reticle_segments_centered_on_input_point() -> void:
|
||||
var center := Vector2(300.0, 300.0)
|
||||
var segments: Array = AtlasDescendGeometry.reticle_segments(center)
|
||||
var min_pt := Vector2(INF, INF)
|
||||
var max_pt := Vector2(-INF, -INF)
|
||||
for seg: Array in segments:
|
||||
for p: Vector2 in seg:
|
||||
min_pt.x = minf(min_pt.x, p.x)
|
||||
min_pt.y = minf(min_pt.y, p.y)
|
||||
max_pt.x = maxf(max_pt.x, p.x)
|
||||
max_pt.y = maxf(max_pt.y, p.y)
|
||||
var bbox_center: Vector2 = (min_pt + max_pt) * 0.5
|
||||
assert_float(bbox_center.x).is_equal_approx(center.x, 0.01)
|
||||
assert_float(bbox_center.y).is_equal_approx(center.y, 0.01)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# district_pos_at — pixel-to-DistrictPos inverse mapping
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## No radius (tiny test body fallback, matches the server's own derive_district
|
||||
## fallback): the district grid IS the heightmap grid 1:1, so a canvas point
|
||||
## maps to the nearest integer district coordinate directly.
|
||||
func test_district_pos_at_no_radius_is_1to1_pixel_mapping() -> void:
|
||||
var pos: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||||
Vector2(12.4, 7.6), 100.0, 100.0, 0.0
|
||||
)
|
||||
assert_that(pos).is_equal(Vector2i(12, 8))
|
||||
|
||||
|
||||
func test_district_pos_at_zero_texture_size_is_safe() -> void:
|
||||
var pos: Vector2i = AtlasDescendGeometry.district_pos_at(Vector2(10.0, 10.0), 0.0, 0.0, 6371.0)
|
||||
assert_that(pos).is_equal(Vector2i.ZERO)
|
||||
|
||||
|
||||
## The equatorial center of the texture (px = tex_w/2) is district column
|
||||
## district_cols/2 on a body with a radius (equator wraps at district (0,0)
|
||||
## per the server's own doc: "district (0,0) sits at lon 0 / the equator").
|
||||
## The vertical center (py = tex_h/2) is the equator row (row 0).
|
||||
func test_district_pos_at_center_of_texture_is_near_equator_row_zero() -> void:
|
||||
var radius_km := 6371.0
|
||||
var tex_w := 1024.0
|
||||
var tex_h := 512.0
|
||||
var pos: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||||
Vector2(0.0, tex_h * 0.5), tex_w, tex_h, radius_km
|
||||
)
|
||||
assert_int(pos.y).override_failure_message(
|
||||
"the vertical texture center must map to row 0 (the equator)"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
## Panning across the texture's full width sweeps through the FULL district
|
||||
## column range (not clamped to a tiny sub-range) — a coarse sanity check that
|
||||
## district_cols is actually being derived from the body's circumference, not
|
||||
## left at some degenerate default.
|
||||
func test_district_pos_at_sweeps_full_column_range_across_texture_width() -> void:
|
||||
var radius_km := 6371.0
|
||||
var tex_w := 1024.0
|
||||
var tex_h := 512.0
|
||||
var left: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||||
Vector2(0.0, tex_h * 0.5), tex_w, tex_h, radius_km
|
||||
)
|
||||
var right: Vector2i = AtlasDescendGeometry.district_pos_at(
|
||||
Vector2(tex_w - 1.0, tex_h * 0.5), tex_w, tex_h, radius_km
|
||||
)
|
||||
# An Earth-radius body has thousands of equatorial districts (circumference
|
||||
# ~40,075 km / 2.048 km per district ≈ 19,568) — near-full-width should
|
||||
# sweep a large fraction of that, not a handful of columns.
|
||||
assert_int(absi(right.x - left.x)).is_greater(1000)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AtlasViewer — fixed view (no drag-pan/wheel-zoom) + click-through descent
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## T-1120 capture API (set_view/get_view_offset/get_view_zoom) must survive
|
||||
## the removal of user pan/zoom — this is the ticket's own explicit note, and
|
||||
## the visual-golden harness depends on it.
|
||||
func test_set_view_still_works_after_pan_zoom_removal() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
v.set_view(3.0, Vector2(50.0, -20.0))
|
||||
assert_that(v.get_view_zoom()).is_equal_approx(3.0, 0.001)
|
||||
assert_that(v.get_view_offset()).is_equal(Vector2(50.0, -20.0))
|
||||
|
||||
|
||||
## Clicking (with no heightmap loaded — the guard AtlasViewer's _gui_input
|
||||
## checks first) must not emit a descend request — there is nothing to
|
||||
## descend into yet.
|
||||
func test_no_descend_signal_without_a_loaded_heightmap() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
add_child(v)
|
||||
var received: Array = []
|
||||
v.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c))
|
||||
# _heightmap_texture stays null (no show_body() call) — _gui_input's
|
||||
# early-return guard should prevent any click handling at all.
|
||||
var mb := InputEventMouseButton.new()
|
||||
mb.button_index = MOUSE_BUTTON_LEFT
|
||||
mb.pressed = true
|
||||
mb.position = Vector2(100.0, 100.0)
|
||||
v._gui_input(mb)
|
||||
assert_int(received.size()).is_equal(0)
|
||||
|
||||
|
||||
## RegionalScreen forwards AtlasViewer's district_descend_requested verbatim —
|
||||
## the nav-stack wiring atlas_app.gd depends on.
|
||||
func test_regional_screen_forwards_district_descend_requested() -> void:
|
||||
var screen: RegionalScreen = auto_free(RegionalScreen.new())
|
||||
add_child(screen)
|
||||
var received: Array = []
|
||||
screen.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c))
|
||||
screen._viewer.district_descend_requested.emit(Vector2i(5, 7))
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_that(received[0]).is_equal(Vector2i(5, 7))
|
||||
@@ -0,0 +1,121 @@
|
||||
## T-1138 (D-226 T-1124 amendment §4): tests for the client-side
|
||||
## DistrictWindowLayer LRU cache — keyed on (body_id, center, n), LRU-evict
|
||||
## only (D-227 determinism means no freshness check is ever needed).
|
||||
class_name TestAtlasWindowCache
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasWindowCache := preload("res://ui/implant/apps/atlas/atlas_window_cache.gd")
|
||||
|
||||
|
||||
func test_make_key_distinguishes_body_center_and_n() -> void:
|
||||
var k1 := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32)
|
||||
var k2 := AtlasWindowCache.make_key("GJ1d", Vector2i(10, 20), 32) # different body
|
||||
var k3 := AtlasWindowCache.make_key("GJ1c", Vector2i(11, 20), 32) # different center
|
||||
var k4 := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 64) # different n
|
||||
assert_str(k1).is_not_equal(k2)
|
||||
assert_str(k1).is_not_equal(k3)
|
||||
assert_str(k1).is_not_equal(k4)
|
||||
|
||||
|
||||
func test_miss_returns_null_and_has_reports_false() -> void:
|
||||
var cache := AtlasWindowCache.new()
|
||||
assert_that(cache.get_window("GJ1c", Vector2i(0, 0), 32)).is_null()
|
||||
assert_bool(cache.has("GJ1c", Vector2i(0, 0), 32)).is_false()
|
||||
|
||||
|
||||
func test_put_then_get_round_trips_exact_window() -> void:
|
||||
var cache := AtlasWindowCache.new()
|
||||
var window := {"center": [10, 20], "n": 32, "morphology": PackedByteArray([1, 2, 3])}
|
||||
cache.put("GJ1c", Vector2i(10, 20), 32, window)
|
||||
assert_bool(cache.has("GJ1c", Vector2i(10, 20), 32)).is_true()
|
||||
assert_that(cache.get_window("GJ1c", Vector2i(10, 20), 32)).is_equal(window)
|
||||
|
||||
|
||||
## D-227: a window fetched once is valid FOREVER for that (body, center, n) —
|
||||
## no expiry, no invalidation path. Repeated get_window() calls across a
|
||||
## simulated time gap (just repeated calls here — there is no clock in this
|
||||
## cache at all) must keep returning the same stored value.
|
||||
func test_cached_window_never_expires() -> void:
|
||||
var cache := AtlasWindowCache.new()
|
||||
var window := {"center": [0, 0], "n": 32}
|
||||
cache.put("GJ1c", Vector2i(0, 0), 32, window)
|
||||
for _i in range(50):
|
||||
assert_that(cache.get_window("GJ1c", Vector2i(0, 0), 32)).is_equal(window)
|
||||
|
||||
|
||||
## Different (body_id, center, n) tuples never collide — this is the whole
|
||||
## point of the composite key (§4: "cacheable client-side keyed on
|
||||
## (body_id, center, n)").
|
||||
func test_different_bodies_same_center_do_not_collide() -> void:
|
||||
var cache := AtlasWindowCache.new()
|
||||
var window_a := {"body": "GJ1c"}
|
||||
var window_b := {"body": "GJ1d"}
|
||||
cache.put("GJ1c", Vector2i(10, 20), 32, window_a)
|
||||
cache.put("GJ1d", Vector2i(10, 20), 32, window_b)
|
||||
assert_that(cache.get_window("GJ1c", Vector2i(10, 20), 32)).is_equal(window_a)
|
||||
assert_that(cache.get_window("GJ1d", Vector2i(10, 20), 32)).is_equal(window_b)
|
||||
|
||||
|
||||
## Overwriting the same key replaces the value (e.g. a re-fetch of an
|
||||
## already-cached window from a server that somehow returns a different
|
||||
## payload — should never happen under D-227, but the cache itself must not
|
||||
## silently keep the stale one on an explicit put()).
|
||||
func test_put_overwrites_existing_key() -> void:
|
||||
var cache := AtlasWindowCache.new()
|
||||
cache.put("GJ1c", Vector2i(0, 0), 32, {"v": 1})
|
||||
cache.put("GJ1c", Vector2i(0, 0), 32, {"v": 2})
|
||||
assert_int(cache.size()).is_equal(1)
|
||||
assert_that(cache.get_window("GJ1c", Vector2i(0, 0), 32)).is_equal({"v": 2})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LRU eviction
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_eviction_drops_least_recently_used_on_overflow() -> void:
|
||||
var cache := AtlasWindowCache.new(2) # max 2 entries
|
||||
cache.put("GJ1c", Vector2i(0, 0), 32, {"id": "a"})
|
||||
cache.put("GJ1c", Vector2i(1, 0), 32, {"id": "b"})
|
||||
cache.put("GJ1c", Vector2i(2, 0), 32, {"id": "c"}) # evicts (0,0) — oldest, untouched
|
||||
|
||||
assert_int(cache.size()).is_equal(2)
|
||||
assert_bool(cache.has("GJ1c", Vector2i(0, 0), 32)).override_failure_message(
|
||||
"oldest entry should have been evicted"
|
||||
).is_false()
|
||||
assert_bool(cache.has("GJ1c", Vector2i(1, 0), 32)).is_true()
|
||||
assert_bool(cache.has("GJ1c", Vector2i(2, 0), 32)).is_true()
|
||||
|
||||
|
||||
## get_window() touches an entry (moves it to most-recently-used) — reading an
|
||||
## entry must protect it from the NEXT eviction, otherwise "LRU" degrades to
|
||||
## FIFO the moment anything reads from the cache (the common case: Esc-then-
|
||||
## re-enter and pan-back are exactly "read a recently-cached window", §4).
|
||||
func test_get_touches_entry_and_protects_it_from_eviction() -> void:
|
||||
var cache := AtlasWindowCache.new(2)
|
||||
cache.put("GJ1c", Vector2i(0, 0), 32, {"id": "a"})
|
||||
cache.put("GJ1c", Vector2i(1, 0), 32, {"id": "b"})
|
||||
cache.get_window("GJ1c", Vector2i(0, 0), 32) # touch (0,0) — now most-recently-used
|
||||
cache.put("GJ1c", Vector2i(2, 0), 32, {"id": "c"}) # should evict (1,0), not (0,0)
|
||||
|
||||
assert_bool(cache.has("GJ1c", Vector2i(0, 0), 32)).override_failure_message(
|
||||
"touched entry should survive eviction"
|
||||
).is_true()
|
||||
assert_bool(cache.has("GJ1c", Vector2i(1, 0), 32)).override_failure_message(
|
||||
"untouched entry should be the one evicted"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_max_entries_clamped_to_at_least_one() -> void:
|
||||
var cache := AtlasWindowCache.new(0)
|
||||
cache.put("GJ1c", Vector2i(0, 0), 32, {"id": "a"})
|
||||
cache.put("GJ1c", Vector2i(1, 0), 32, {"id": "b"})
|
||||
assert_int(cache.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_clear_empties_the_cache() -> void:
|
||||
var cache := AtlasWindowCache.new()
|
||||
cache.put("GJ1c", Vector2i(0, 0), 32, {"id": "a"})
|
||||
cache.clear()
|
||||
assert_int(cache.size()).is_equal(0)
|
||||
assert_bool(cache.has("GJ1c", Vector2i(0, 0), 32)).is_false()
|
||||
@@ -0,0 +1,170 @@
|
||||
## T-1138 (D-226 T-1124 amendment §5): pure color-ramp tests for the
|
||||
## regional-window base layer + toggle overlays. Every function under test
|
||||
## lives on atlas_overlay_colors.gd (no class_name — preloaded by path,
|
||||
## matching test_atlas_overlays.gd's own AtlasOverlayColors const).
|
||||
class_name TestAtlasWindowColors
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Base layer: morphology hue + elev_q lightness modulation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## MORPHOLOGY_RGB_OPAQUE must be the SAME 17-entry hue table as the planetary
|
||||
## overlay's MORPHOLOGY_COLORS (D-226 T-1124 amendment §5: "reusing the T-1123
|
||||
## probe's 17-entry MORPHOLOGY_RGB hues verbatim") — only alpha differs (the
|
||||
## window base is opaque; the planetary overlay draws semi-transparent over a
|
||||
## heightmap texture).
|
||||
func test_district_window_morphology_hues_match_planetary_overlay() -> void:
|
||||
assert_int(AtlasOverlayColors.MORPHOLOGY_RGB_OPAQUE.size()).is_equal(
|
||||
AtlasOverlayColors.MORPHOLOGY_COLORS.size()
|
||||
)
|
||||
for zone in range(AtlasOverlayColors.MORPHOLOGY_RGB_OPAQUE.size()):
|
||||
var opaque: Color = AtlasOverlayColors.MORPHOLOGY_RGB_OPAQUE[zone]
|
||||
var overlay: Color = AtlasOverlayColors.MORPHOLOGY_COLORS[zone]
|
||||
assert_float(opaque.r).override_failure_message(
|
||||
"zone %d hue mismatch (r)" % zone
|
||||
).is_equal_approx(overlay.r, 0.005)
|
||||
assert_float(opaque.g).override_failure_message(
|
||||
"zone %d hue mismatch (g)" % zone
|
||||
).is_equal_approx(overlay.g, 0.005)
|
||||
assert_float(opaque.b).override_failure_message(
|
||||
"zone %d hue mismatch (b)" % zone
|
||||
).is_equal_approx(overlay.b, 0.005)
|
||||
assert_float(opaque.a).override_failure_message(
|
||||
"district-window base layer must be OPAQUE (a=1.0), zone %d" % zone
|
||||
).is_equal_approx(1.0, 0.001)
|
||||
|
||||
|
||||
func test_district_window_morphology_color_out_of_range_is_magenta_sentinel() -> void:
|
||||
var c: Color = AtlasOverlayColors.district_window_morphology_color(999)
|
||||
assert_float(c.r).is_equal_approx(1.0, 0.001)
|
||||
assert_float(c.g).is_equal_approx(0.0, 0.001)
|
||||
assert_float(c.b).is_equal_approx(1.0, 0.001)
|
||||
|
||||
|
||||
## elev_q=0 -> 0.7x lightness; elev_q=100 -> 1.0x lightness (full base color);
|
||||
## elev_q=50 -> 0.85x (the midpoint of the documented [0.7, 1.0] range).
|
||||
func test_district_window_elevation_lightness_modulation() -> void:
|
||||
var base := Color(0.4, 0.4, 0.4, 1.0)
|
||||
var low: Color = AtlasOverlayColors.district_window_elevation_lightness(base, 0)
|
||||
var mid: Color = AtlasOverlayColors.district_window_elevation_lightness(base, 50)
|
||||
var high: Color = AtlasOverlayColors.district_window_elevation_lightness(base, 100)
|
||||
|
||||
assert_float(low.r).is_equal_approx(0.4 * 0.7, 0.001)
|
||||
assert_float(mid.r).is_equal_approx(0.4 * 0.85, 0.001)
|
||||
assert_float(high.r).is_equal_approx(0.4 * 1.0, 0.001)
|
||||
# Alpha is untouched by the lightness multiply.
|
||||
assert_float(low.a).is_equal_approx(1.0, 0.001)
|
||||
|
||||
|
||||
func test_district_window_elevation_lightness_clamps_out_of_range_elev_q() -> void:
|
||||
var base := Color(0.4, 0.4, 0.4, 1.0)
|
||||
var below: Color = AtlasOverlayColors.district_window_elevation_lightness(base, -20)
|
||||
var above: Color = AtlasOverlayColors.district_window_elevation_lightness(base, 500)
|
||||
assert_float(below.r).is_equal_approx(0.4 * 0.7, 0.001)
|
||||
assert_float(above.r).is_equal_approx(0.4 * 1.0, 0.001)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Vegetation ramp (Marine transparent, D-226 T-1124 amendment §3/§5)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Marine (VegetationClass discriminant 6, T-1126) MUST render transparent —
|
||||
## "non-negotiable inclusion, not a nice-to-have" per the amendment §3. This is
|
||||
## the one assertion in this suite with the highest stakes: a regression here
|
||||
## re-introduces the exact ocean-blind-vegetation bug T-1126 was created to fix.
|
||||
func test_vegetation_marine_is_transparent() -> void:
|
||||
var c: Color = AtlasOverlayColors.vegetation_color(AtlasOverlayColors.VEGETATION_MARINE)
|
||||
assert_that(c).is_equal(Color.TRANSPARENT)
|
||||
assert_int(AtlasOverlayColors.VEGETATION_MARINE).is_equal(6)
|
||||
|
||||
|
||||
## Every VegetationClass discriminant (0 Absent .. 5 RiparianThicket, excluding
|
||||
## 6 Marine which is transparent) must resolve to a DISTINCT, non-transparent
|
||||
## color — the amendment's exhaustive-disposition mandate (§3) extended to the
|
||||
## full field-list, matching quarter_notch_kind's own "every variant reads as
|
||||
## something" test precedent in test_atlas_overlays.gd.
|
||||
func test_vegetation_ramp_is_exhaustive_and_distinct() -> void:
|
||||
var seen: Array = []
|
||||
for vc in range(0, 6): # 0..5, Marine (6) handled separately above
|
||||
var c: Color = AtlasOverlayColors.vegetation_color(vc)
|
||||
assert_float(c.a).override_failure_message(
|
||||
"VegetationClass %d must not be transparent" % vc
|
||||
).is_greater(0.0)
|
||||
assert_that(seen).override_failure_message(
|
||||
"VegetationClass %d collides with an earlier entry's color" % vc
|
||||
).not_contains([c])
|
||||
seen.append(c)
|
||||
|
||||
|
||||
func test_vegetation_color_unrecognized_falls_back_to_absent_reading() -> void:
|
||||
var unknown: Color = AtlasOverlayColors.vegetation_color(999)
|
||||
var absent: Color = AtlasOverlayColors.vegetation_color(0)
|
||||
assert_that(unknown).is_equal(absent)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Glaciation ice-tint modifier (D-226 T-1124 amendment §5, ported apply_ice_tint)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## None (0) and Light (1) must return the base color UNCHANGED — this is the
|
||||
## load-bearing divergence from the amendment's own prose summary ("gated on
|
||||
## glaciation_grade >= Light"): the REFERENCE implementation the amendment
|
||||
## names for porting (aliveness_probe::apply_ice_tint) excludes Light too,
|
||||
## because grade 1 is glacial-erosion signatures, not visible ice. This test
|
||||
## pins that the port, not the summary, is what shipped.
|
||||
func test_glaciation_tint_none_and_light_are_no_ops() -> void:
|
||||
var base := Color(0.3, 0.5, 0.3, 0.8)
|
||||
_assert_color_approx(AtlasOverlayColors.glaciation_tint(base, 0), base) # None
|
||||
_assert_color_approx(AtlasOverlayColors.glaciation_tint(base, 1), base) # Light
|
||||
|
||||
|
||||
## Moderate (2) / Heavy (3) / IceCap (4) blend increasingly toward
|
||||
## GLACIATION_ICE_WHITE — each grade strictly closer to white than the last
|
||||
## (monotonic tint strength), matching apply_ice_tint's 0.30/0.50/0.70 alphas.
|
||||
func test_glaciation_tint_increases_with_grade() -> void:
|
||||
var base := Color(0.1, 0.1, 0.1, 1.0) # far from ice-white, maximizes signal
|
||||
var white: Color = AtlasOverlayColors.GLACIATION_ICE_WHITE
|
||||
|
||||
var moderate: Color = AtlasOverlayColors.glaciation_tint(base, 2)
|
||||
var heavy: Color = AtlasOverlayColors.glaciation_tint(base, 3)
|
||||
var ice_cap: Color = AtlasOverlayColors.glaciation_tint(base, 4)
|
||||
|
||||
var dist_moderate: float = _color_distance(base, moderate)
|
||||
var dist_heavy: float = _color_distance(base, heavy)
|
||||
var dist_ice_cap: float = _color_distance(base, ice_cap)
|
||||
|
||||
assert_float(dist_heavy).override_failure_message(
|
||||
"Heavy should tint more than Moderate"
|
||||
).is_greater(dist_moderate)
|
||||
assert_float(dist_ice_cap).override_failure_message(
|
||||
"IceCap should tint more than Heavy"
|
||||
).is_greater(dist_heavy)
|
||||
|
||||
# IceCap (alpha 0.70) should land noticeably closer to white than to base.
|
||||
assert_float(_color_distance(ice_cap, white)).is_less(_color_distance(ice_cap, base))
|
||||
|
||||
|
||||
func test_glaciation_tint_out_of_range_grade_is_no_op() -> void:
|
||||
var base := Color(0.3, 0.5, 0.3, 0.8)
|
||||
_assert_color_approx(AtlasOverlayColors.glaciation_tint(base, 999), base)
|
||||
|
||||
|
||||
func _assert_color_approx(actual: Color, expected: Color) -> void:
|
||||
assert_float(actual.r).is_equal_approx(expected.r, 0.0001)
|
||||
assert_float(actual.g).is_equal_approx(expected.g, 0.0001)
|
||||
assert_float(actual.b).is_equal_approx(expected.b, 0.0001)
|
||||
assert_float(actual.a).is_equal_approx(expected.a, 0.0001)
|
||||
|
||||
|
||||
## Godot 4's Color has no distance_to() — plain Euclidean over RGB (alpha
|
||||
## deliberately excluded: the tint blend leaves alpha untouched, so including
|
||||
## it would just add a constant offset with no signal).
|
||||
static func _color_distance(a: Color, b: Color) -> float:
|
||||
return Vector3(a.r, a.g, a.b).distance_to(Vector3(b.r, b.g, b.b))
|
||||
@@ -0,0 +1,204 @@
|
||||
## T-1138 (D-226 T-1124 amendment §1-§5): tests for AtlasWindowViewer + its
|
||||
## companion request/cache orchestration (atlas_window_request.gd) — pure
|
||||
## logic against hand-built AtlasLayerResponse-shaped dicts, matching the
|
||||
## ticket's "unit tests against hand-built response dicts" instruction. Live
|
||||
## end-to-end verification against a real spawned server is separate
|
||||
## (companion-run evidence, not gdUnit — this file never touches SimBridge's
|
||||
## live-mode path, only the response-handling/cache/overlay logic that path
|
||||
## eventually feeds).
|
||||
class_name TestAtlasWindowViewer
|
||||
extends GdUnitTestSuite
|
||||
|
||||
# atlas_window_request.gd has no class_name (review #8 precedent throughout
|
||||
# this cluster) — preloaded once here, not re-load()ed per test (gdlint
|
||||
# duplicated-load).
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
|
||||
|
||||
## Build a hand-authored DistrictWindowLayer dict (n=2, matching the shape
|
||||
## district_grid/region_grid fixtures already use elsewhere in this suite).
|
||||
static func _mock_window(center: Vector2i, n: int = 2) -> Dictionary:
|
||||
return {
|
||||
"center": [center.x, center.y],
|
||||
"n": n,
|
||||
"morphology": PackedByteArray([8, 14, 0, 1]),
|
||||
"elev_q": PackedByteArray([40, 90, 5, 60]),
|
||||
"temp_dc": [120, 95, -32768, 60],
|
||||
"moisture_q": PackedByteArray([50, 30, 90, 20]),
|
||||
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
||||
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
||||
}
|
||||
|
||||
|
||||
static func _mock_response(body_id: String, window: Variant) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Ready", "district_window": window}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AtlasWindowViewer — entry + overlay defs
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_enter_with_no_response_leaves_window_null_and_pending() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20))
|
||||
assert_that(v.get_district_window()).is_null()
|
||||
|
||||
|
||||
## Feeding a matching Ready response (via the SAME SimBridge.atlas_layers_received
|
||||
## routing path the viewer subscribes to in _ready()) must populate the window.
|
||||
func test_enter_then_matching_response_populates_window() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
|
||||
var window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
|
||||
assert_that(v.get_district_window()).is_equal(window)
|
||||
|
||||
|
||||
## A response for a DIFFERENT body must not populate the window — the
|
||||
## body_id scoping AtlasWindowRequest.on_response() checks.
|
||||
func test_response_for_different_body_is_ignored() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
|
||||
var window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ_wrong_body", window))
|
||||
|
||||
assert_that(v.get_district_window()).is_null()
|
||||
|
||||
|
||||
## A response whose echoed (center, n) does NOT match what was last asked for
|
||||
## is stale — §2's race-condition guard. Simulates a superseded-by-a-later-pan
|
||||
## response arriving after the fact.
|
||||
func test_response_with_mismatched_echo_is_discarded_as_stale() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
|
||||
var stale_window: Dictionary = _mock_window(Vector2i(99, 99), 2) # wrong center
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", stale_window))
|
||||
|
||||
assert_that(v.get_district_window()).is_null()
|
||||
|
||||
|
||||
## §1: an as-yet-underived window rides as `district_window: None` inside a
|
||||
## Ready response — this is NOT an error, the viewer just keeps waiting
|
||||
## (get_district_window() stays null, no crash, no window content shown).
|
||||
func test_ready_response_with_null_district_window_keeps_waiting() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", null))
|
||||
assert_that(v.get_district_window()).is_null()
|
||||
|
||||
|
||||
func test_overlay_defs_include_the_three_toggle_ids() -> void:
|
||||
var ids: Array = []
|
||||
for d: Dictionary in AtlasWindowViewer.OVERLAY_DEFS:
|
||||
ids.append(d["id"])
|
||||
assert_that(ids).contains(["gen_dw_temp", "gen_dw_moisture", "gen_dw_veg"])
|
||||
|
||||
|
||||
## Glaciation is explicitly NOT a toggle id (§5: "an always-on modifier, not
|
||||
## a toggle") — a regression here would silently re-introduce it as a switch.
|
||||
func test_overlay_defs_do_not_include_glaciation() -> void:
|
||||
var ids: Array = []
|
||||
for d: Dictionary in AtlasWindowViewer.OVERLAY_DEFS:
|
||||
ids.append(d["id"])
|
||||
assert_that(ids).not_contains(["gen_dw_glaciation", "gen_dw_ice"])
|
||||
|
||||
|
||||
func test_set_overlay_visible_toggles_and_is_overlay_visible_reflects_it() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
assert_bool(v.is_overlay_visible("gen_dw_temp")).is_false()
|
||||
v.set_overlay_visible("gen_dw_temp", true)
|
||||
assert_bool(v.is_overlay_visible("gen_dw_temp")).is_true()
|
||||
|
||||
|
||||
func test_set_overlay_visible_unknown_id_is_a_noop() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.set_overlay_visible("not_a_real_overlay", true)
|
||||
assert_bool(v.is_overlay_visible("not_a_real_overlay")).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1120 capture-API parity (the ticket's explicit note: must survive here too)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_set_view_and_getters_round_trip() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.set_view(2.5, Vector2(30.0, -10.0))
|
||||
assert_that(v.get_view_zoom()).is_equal_approx(2.5, 0.001)
|
||||
assert_that(v.get_view_offset()).is_equal(Vector2(30.0, -10.0))
|
||||
|
||||
|
||||
func test_set_view_clamps_to_min_max_zoom() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.set_view(0.01, Vector2.ZERO)
|
||||
assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MIN_ZOOM, 0.001)
|
||||
v.set_view(1000.0, Vector2.ZERO)
|
||||
assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MAX_ZOOM, 0.001)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AtlasWindowRequest — cache reuse (§4's Esc-then-re-enter / pan-back hit)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_window_request_cache_hit_emits_synchronously_no_pending() -> void:
|
||||
var owner_stub := RefCounted.new()
|
||||
var req = auto_free(AtlasWindowRequest.new(owner_stub))
|
||||
add_child(req)
|
||||
|
||||
# Prime the cache directly (bypassing the network path) — the ticket's
|
||||
# own instruction: unit test against hand-built response dicts.
|
||||
req.get_cache().put("GJ380c", Vector2i(1, 1), 2, _mock_window(Vector2i(1, 1), 2))
|
||||
|
||||
var received: Array = []
|
||||
req.window_ready.connect(func(w: Dictionary) -> void: received.append(w))
|
||||
req.request_now("GJ380c", Vector2i(1, 1), 2)
|
||||
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"a cache hit must never leave the request pending"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_window_request_cache_miss_leaves_pending_true() -> void:
|
||||
var owner_stub := RefCounted.new()
|
||||
var req = auto_free(AtlasWindowRequest.new(owner_stub))
|
||||
add_child(req)
|
||||
req.request_now("GJ380c", Vector2i(5, 5), 2)
|
||||
assert_bool(req.is_pending()).is_true()
|
||||
|
||||
|
||||
## on_response() with a matching Ready+window response resolves the pending
|
||||
## request AND populates the cache — verified by a second request_now() call
|
||||
## for the same (center, n) becoming a cache hit with zero additional pending.
|
||||
func test_on_response_resolves_and_populates_cache_for_next_request() -> void:
|
||||
var owner_stub := RefCounted.new()
|
||||
var req = auto_free(AtlasWindowRequest.new(owner_stub))
|
||||
add_child(req)
|
||||
|
||||
req.request_now("GJ380c", Vector2i(2, 2), 2)
|
||||
assert_bool(req.is_pending()).is_true()
|
||||
|
||||
var window: Dictionary = _mock_window(Vector2i(2, 2), 2)
|
||||
req.on_response(_mock_response("GJ380c", window))
|
||||
assert_bool(req.is_pending()).is_false()
|
||||
|
||||
# Re-request the SAME (body, center, n) — must be a cache hit, no pending.
|
||||
req.request_now("GJ380c", Vector2i(2, 2), 2)
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"a second request for an already-resolved window must hit the cache"
|
||||
).is_false()
|
||||
@@ -13,6 +13,7 @@ var _reach_screen = null # ReachScreen
|
||||
var _system_screen = null # SystemScreen
|
||||
var _planet_screen = null # PlanetScreen
|
||||
var _regional_screen = null # RegionalScreen
|
||||
var _district_screen = null # DistrictScreen (T-1138)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -47,8 +48,13 @@ func on_install() -> void:
|
||||
_regional_screen = RegionalScreen.new()
|
||||
_regional_screen.back_requested.connect(_on_regional_back)
|
||||
_regional_screen.economics_link_requested.connect(_forward_economics_link)
|
||||
_regional_screen.district_descend_requested.connect(_on_district_descend_requested)
|
||||
register_screen("regional", _regional_screen)
|
||||
|
||||
_district_screen = DistrictScreen.new()
|
||||
_district_screen.back_requested.connect(_on_district_back)
|
||||
register_screen("district", _district_screen)
|
||||
|
||||
nav.set_default("reach")
|
||||
|
||||
|
||||
@@ -65,7 +71,12 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
return
|
||||
if not event.is_pressed() or event.is_echo():
|
||||
return
|
||||
if current_screen_id() == "regional":
|
||||
# "regional" (the planetary heightmap, AtlasViewer) and "district" (the
|
||||
# windowed regional-window screen, AtlasWindowViewer, T-1138) both handle
|
||||
# their own Esc via _gui_input — same delegation shape for both, so a
|
||||
# stray M/other unhandled key on either screen doesn't ALSO fire this
|
||||
# app's own _handle_key underneath the viewer's own handling.
|
||||
if current_screen_id() == "regional" or current_screen_id() == "district":
|
||||
return
|
||||
_handle_key(event as InputEventKey)
|
||||
get_viewport().set_input_as_handled()
|
||||
@@ -137,6 +148,25 @@ func _on_regional_back() -> void:
|
||||
nav.pop()
|
||||
|
||||
|
||||
## T-1138: the planetary click-through descent (§5 entry revision) — pushes
|
||||
## the "district" screen centered on the click point's derived DistrictPos.
|
||||
## A real nav.push() (not a swap-in-place, unlike the superseded zoom-
|
||||
## threshold design) so Esc's existing nav.pop() path (DistrictScreen's own
|
||||
## back_requested -> _on_district_back below) returns to exactly the
|
||||
## planetary body the player descended from, at whatever crumb depth got
|
||||
## them there (reach -> system -> regional -> district).
|
||||
func _on_district_descend_requested(district_center: Vector2i) -> void:
|
||||
nav.push("district", {
|
||||
"body": nav.current_payload().get("body", {}),
|
||||
"system": nav.current_payload().get("system", {}),
|
||||
"district_center": district_center,
|
||||
})
|
||||
|
||||
|
||||
func _on_district_back() -> void:
|
||||
nav.pop()
|
||||
|
||||
|
||||
func _forward_economics_link(system_id: String) -> void:
|
||||
economics_link_requested.emit(system_id)
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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],
|
||||
}
|
||||
|
||||
|
||||
## 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)) * tex_h
|
||||
## which is a linear scaling of the same world-metre fraction 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 circumference_m: float = TAU * body_radius_km * 1000.0
|
||||
var meridian_m: float = PI * body_radius_km * 1000.0
|
||||
var district_cols: float = roundf(circumference_m / DISTRICT_M)
|
||||
var district_rows_half: float = roundf((meridian_m / DISTRICT_M) * 0.5)
|
||||
var col: int = roundi((canvas_pt.x / tex_w) * district_cols)
|
||||
var row: int = roundi(((canvas_pt.y / tex_h) - 0.5) * district_rows_half * 2.0)
|
||||
return Vector2i(col, row)
|
||||
@@ -86,6 +86,82 @@ const QUARTER_GLYPH_DENSITY_SCALE: float = 6.0
|
||||
const COLOR_QUARTER_LOW_DENSITY: Color = Color(0.55, 0.48, 0.30, 0.6) # dim gold
|
||||
const COLOR_QUARTER_HIGH_DENSITY: Color = Color(0.94, 0.82, 0.38, 1.0) # COLOR_SETTLEMENT gold
|
||||
|
||||
## T-1138 (D-226 T-1124 amendment §5) — the regional-window base layer's
|
||||
## MORPHOLOGY_RGB hues, port of aliveness_probe.rs's 17-entry table (same
|
||||
## file/discriminant order as MORPHOLOGY_COLORS above, but FULL ALPHA — those
|
||||
## colors carry a 0.55 alpha for the "semi-transparent overlay ON the
|
||||
## heightmap" planetary-view use case; the window's morphology layer IS the
|
||||
## base terrain read (no heightmap texture underneath it to show through), so
|
||||
## the district-window base needs opaque versions of the identical hues, not
|
||||
## a second independently-chosen palette.
|
||||
const MORPHOLOGY_RGB_OPAQUE: Array = [
|
||||
Color(0.102, 0.200, 0.451, 1.0), # 0 OpenOcean
|
||||
Color(0.200, 0.400, 0.651, 1.0), # 1 Lake
|
||||
Color(0.451, 0.549, 0.502, 1.0), # 2 TidalFlat
|
||||
Color(0.851, 0.780, 0.451, 1.0), # 3 DuneStrand
|
||||
Color(0.502, 0.502, 0.549, 1.0), # 4 CliffCoast
|
||||
Color(0.302, 0.400, 0.502, 1.0), # 5 Fjord
|
||||
Color(0.400, 0.651, 0.600, 1.0), # 6 Delta
|
||||
Color(0.302, 0.549, 0.549, 1.0), # 7 Estuarine
|
||||
Color(0.302, 0.600, 0.302, 1.0), # 8 AlluvialPlain
|
||||
Color(0.451, 0.702, 0.400, 1.0), # 9 RiverBank
|
||||
Color(0.349, 0.600, 0.502, 1.0), # 10 MeanderReach
|
||||
Color(0.549, 0.600, 0.451, 1.0), # 11 BraidedPlain
|
||||
Color(0.502, 0.549, 0.302, 1.0), # 12 ValleyFloor
|
||||
Color(0.549, 0.451, 0.302, 1.0), # 13 MountainPass
|
||||
Color(0.800, 0.820, 0.851, 1.0), # 14 Alpine
|
||||
Color(0.451, 0.149, 0.122, 1.0), # 15 Volcanic
|
||||
Color(0.251, 0.451, 0.400, 1.0), # 16 Wetland
|
||||
]
|
||||
|
||||
## Elevation lightness modulation (D-226 T-1124 amendment §5): one
|
||||
## `0.7 + 0.3*(elev_q/100)` multiply per cell — relief read without a second
|
||||
## draw call, hue=type / lightness=elevation.
|
||||
const DISTRICT_WINDOW_ELEV_LIGHTNESS_BASE: float = 0.7
|
||||
const DISTRICT_WINDOW_ELEV_LIGHTNESS_RANGE: float = 0.3
|
||||
|
||||
## T-1138 — vegetation green-family ramp (D-226 T-1124 amendment §5).
|
||||
## VegetationClass discriminants (server/src/atlas/district_profile.rs,
|
||||
## T-1126): 0 Absent, 1 Barren, 2 Scrub, 3 Forest, 4 RiparianScrub,
|
||||
## 5 RiparianThicket, 6 Marine. Marine is handled by the caller (transparent —
|
||||
## see vegetation_color() below), not a table entry, since "transparent" is
|
||||
## not a color decision but a "don't draw" decision.
|
||||
const COLOR_VEGETATION_ABSENT: Color = Color(0.35, 0.32, 0.28, 0.55) # airless/no branch — dim neutral
|
||||
const COLOR_VEGETATION_BARREN: Color = Color(0.55, 0.50, 0.38, 0.55) # sparse — dry olive-tan
|
||||
const COLOR_VEGETATION_SCRUB: Color = Color(0.45, 0.58, 0.32, 0.65) # transitional — olive-green
|
||||
const COLOR_VEGETATION_FOREST: Color = Color(0.20, 0.50, 0.24, 0.75) # closed-canopy — deep green
|
||||
# Riparian bands read as a saturated variant of their base class (D-226 T-1124
|
||||
# amendment §3's exhaustive-disposition mandate — every VegetationClass
|
||||
# variant needs SOME reading, not just the three density-ladder rungs).
|
||||
const COLOR_VEGETATION_RIPARIAN_SCRUB: Color = Color(0.35, 0.65, 0.45, 0.80) # scrub + water = teal-green
|
||||
const COLOR_VEGETATION_RIPARIAN_THICKET: Color = Color(0.15, 0.55, 0.38, 0.85) # forest + water = teal-dark-green
|
||||
const VEGETATION_MARINE: int = 6 # T-1126 — Marine renders transparent, see vegetation_color()
|
||||
|
||||
## T-1138 — glaciation ice-tint MODIFIER endpoint + per-grade alpha (D-226
|
||||
## T-1124 amendment §5, porting aliveness_probe.rs's apply_ice_tint()
|
||||
## unchanged in mechanism). GlaciationGrade discriminants (T-1127):
|
||||
## 0 None, 1 Light, 2 Moderate, 3 Heavy, 4 IceCap.
|
||||
##
|
||||
## The amendment's own prose summarizes the gate as "glaciation_grade >=
|
||||
## Light"; the REFERENCE implementation it names or explicitly asks to be
|
||||
## ported (apply_ice_tint, aliveness_probe.rs:602) returns the base color
|
||||
## UNCHANGED for None *and* Light, with its own code comment explaining why:
|
||||
## grade 1 is glacial-erosion signatures (U-valleys, moraines — landform
|
||||
## history visible on coasts as warm as +5C mean), not ice cover; visible ice
|
||||
## starts at grade >= Moderate, which is what D-239 §5 itself gates glacial
|
||||
## forms on. This table follows the port (the load-bearing instruction),
|
||||
## not the summary. GRADE_LIGHT is kept as an explicit key mapped to 0.0 —
|
||||
## not "absent from the dict" — so a caller iterating grades sees the
|
||||
## "no tint, and here is why" decision instead of an implicit fallback.
|
||||
const GLACIATION_ICE_WHITE: Color = Color(0.894, 0.941, 0.980, 1.0) # 228,240,250 / 255
|
||||
const GLACIATION_TINT_ALPHA: Dictionary = {
|
||||
0: 0.0, # None
|
||||
1: 0.0, # Light — erosion signatures only, not visible ice (see doc above)
|
||||
2: 0.30, # Moderate
|
||||
3: 0.50, # Heavy
|
||||
4: 0.70, # IceCap
|
||||
}
|
||||
|
||||
|
||||
static func morphology_color(zone: int) -> Color:
|
||||
if zone >= 0 and zone < MORPHOLOGY_COLORS.size():
|
||||
@@ -162,3 +238,71 @@ static func quarter_notch_kind(district_type: String) -> String:
|
||||
return district_type.to_lower()
|
||||
_:
|
||||
return "plain"
|
||||
|
||||
|
||||
## T-1138 — the regional-window base layer's hue read: MORPHOLOGY_RGB_OPAQUE
|
||||
## looked up by MorphologyZone discriminant, out-of-palette falls back to
|
||||
## magenta (matches aliveness_probe.rs's own out-of-palette sentinel — a
|
||||
## palette/enum drift bug should be LOUD, not silently grey).
|
||||
static func district_window_morphology_color(zone: int) -> Color:
|
||||
if zone >= 0 and zone < MORPHOLOGY_RGB_OPAQUE.size():
|
||||
return MORPHOLOGY_RGB_OPAQUE[zone]
|
||||
return Color(1.0, 0.0, 1.0, 1.0)
|
||||
|
||||
|
||||
## Lightness modulation by elev_q (D-226 T-1124 amendment §5): multiply RGB by
|
||||
## `0.7 + 0.3*(elev_q/100)`, alpha untouched. elev_q is clamped to [0, 100]
|
||||
## before the multiply so an out-of-range value (shouldn't happen — server
|
||||
## already clamps per the district_grid precedent) can't push lightness
|
||||
## outside [0.7, 1.0].
|
||||
static func district_window_elevation_lightness(base: Color, elev_q: int) -> Color:
|
||||
var clamped: int = clampi(elev_q, 0, 100)
|
||||
var lightness: float = (
|
||||
DISTRICT_WINDOW_ELEV_LIGHTNESS_BASE
|
||||
+ DISTRICT_WINDOW_ELEV_LIGHTNESS_RANGE * (float(clamped) / 100.0)
|
||||
)
|
||||
return Color(base.r * lightness, base.g * lightness, base.b * lightness, base.a)
|
||||
|
||||
|
||||
## Vegetation green-family ramp (D-226 T-1124 amendment §5). Marine (6)
|
||||
## returns Color.TRANSPARENT — "lets the morphology water-blue show through"
|
||||
## per the amendment; the caller must skip the draw_rect entirely on a
|
||||
## transparent result rather than drawing a zero-alpha rect (cheaper, and
|
||||
## avoids relying on alpha blending to do the "don't draw" work). Every other
|
||||
## discriminant (including out-of-range, e.g. a future VegetationClass
|
||||
## variant this table hasn't caught up to yet) falls back to the Absent
|
||||
## reading rather than a magenta sentinel — vegetation is deliberately a
|
||||
## SOFTER failure mode than morphology (an unrecognized vegetation class is
|
||||
## "no distinguishable vegetation signal", not a palette/enum drift bug of
|
||||
## the same severity as an unrecognized terrain zone).
|
||||
static func vegetation_color(vegetation_class: int) -> Color:
|
||||
match vegetation_class:
|
||||
VEGETATION_MARINE:
|
||||
return Color.TRANSPARENT
|
||||
1:
|
||||
return COLOR_VEGETATION_BARREN
|
||||
2:
|
||||
return COLOR_VEGETATION_SCRUB
|
||||
3:
|
||||
return COLOR_VEGETATION_FOREST
|
||||
4:
|
||||
return COLOR_VEGETATION_RIPARIAN_SCRUB
|
||||
5:
|
||||
return COLOR_VEGETATION_RIPARIAN_THICKET
|
||||
_:
|
||||
return COLOR_VEGETATION_ABSENT
|
||||
|
||||
|
||||
## Glaciation ice-tint MODIFIER (D-226 T-1124 amendment §5, porting
|
||||
## aliveness_probe.rs's apply_ice_tint()) — blends `base` toward glacial
|
||||
## ice-white by the per-grade alpha in GLACIATION_TINT_ALPHA. Grades None/Light
|
||||
## return `base` unchanged (alpha 0.0 lerp is a no-op, but the explicit
|
||||
## lookup-then-lerp keeps this one code path for every grade rather than an
|
||||
## early-return special case, matching the port's single lerp_rgb call site).
|
||||
## Out-of-range grade values are treated as None (no tint) — the same "softer
|
||||
## failure than morphology" reasoning as vegetation_color() above.
|
||||
static func glaciation_tint(base: Color, glaciation_grade: int) -> Color:
|
||||
var alpha: float = float(GLACIATION_TINT_ALPHA.get(glaciation_grade, 0.0))
|
||||
if alpha <= 0.0:
|
||||
return base
|
||||
return base.lerp(GLACIATION_ICE_WHITE, alpha)
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
class_name AtlasViewer
|
||||
extends Control
|
||||
|
||||
## Atlas regional viewer — heightmap PNG with pan/zoom + marker overlay (#835, D-191).
|
||||
## Atlas regional viewer — heightmap PNG with marker overlay (#835, D-191).
|
||||
##
|
||||
## Lives as a child of RegionalScreen, shown when the atlas nav stack is at
|
||||
## "regional". Receives body/system context via show_body(). Emits back_pressed
|
||||
## and economics_link_requested so RegionalScreen can route them.
|
||||
## "regional". Receives body/system context via show_body(). Emits back_pressed,
|
||||
## economics_link_requested, and district_descend_requested so RegionalScreen
|
||||
## can route them.
|
||||
##
|
||||
## Design notes:
|
||||
## - Heightmap texture is drawn on a Node2D _canvas child. Pan = _canvas.position,
|
||||
## zoom = _canvas.scale. MarkerOverlay is a child of _canvas so markers auto-
|
||||
## follow the same transform.
|
||||
## - Heightmap texture is drawn on a Node2D _canvas child. MarkerOverlay is a
|
||||
## child of _canvas so markers auto-follow the same transform.
|
||||
## - markers.json schema (D-191 §8): cities, roads, railroads, pois, plus rivers,
|
||||
## oceans, mountain_ranges with `center: [row, col]` and optional names.
|
||||
## - Empty markers case (server #832/#833 not yet shipped): bare heightmap renders
|
||||
## fine, no sidebar opens, overlays draw nothing.
|
||||
## - Overlays (#836) plug into _overlay_visibility dict and _draw_overlays().
|
||||
##
|
||||
## Navigation:
|
||||
## Mouse drag pan the map
|
||||
## Mouse wheel zoom in / out (centered on cursor)
|
||||
## Click city open city data panel
|
||||
## R reset view
|
||||
## Esc back to body entry
|
||||
## - T-1138: view is FIXED (set_view() is capture-API-only); descent screen has the reticle math.
|
||||
## Navigation: Hover reticle · Click descend · Click city data (wins) · R reset(no-op) · 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 ZOOM_STEP: float = 1.15
|
||||
|
||||
const PANEL_WIDTH: float = 320.0
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
@@ -37,6 +32,8 @@ const PANEL_MARGIN: float = 16.0
|
||||
# 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)
|
||||
@@ -215,16 +212,15 @@ var _tex_h: float = 512.0
|
||||
# if the bridge wasn't connected yet when _load_markers first asked.
|
||||
var _city_names_pending_body: String = ""
|
||||
|
||||
# ── Pan/zoom state ────────────────────────────────────────────────────────────
|
||||
# ── 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
|
||||
var _dragging: bool = false
|
||||
var _drag_start_mouse: Vector2
|
||||
var _drag_start_offset: Vector2
|
||||
|
||||
# ── 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
|
||||
@@ -366,14 +362,9 @@ func get_view_offset() -> Vector2:
|
||||
return _view_offset
|
||||
|
||||
|
||||
## Programmatic view control (T-1120 — added for the Atlas screenshot capture
|
||||
## harness, which needs deterministic zoom/pan without simulating mouse wheel
|
||||
## events). Mirrors _zoom_at()/_fit_to_view()'s clamp-then-_apply_transform
|
||||
## shape: zoom is clamped to [MIN_ZOOM, MAX_ZOOM] exactly like every other
|
||||
## mutator (_zoom_at, _fit_to_view), so this can't push the view out of the
|
||||
## range the rest of the viewer assumes. Offset is caller-supplied verbatim —
|
||||
## same as _fit_to_view()'s computed centering offset, there's no meaningful
|
||||
## clamp for a pan translation.
|
||||
## 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
|
||||
@@ -630,17 +621,6 @@ func _apply_transform() -> void:
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
func _zoom_at(mouse_pos: Vector2, factor: float) -> void:
|
||||
var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM)
|
||||
if is_equal_approx(new_zoom, _view_zoom):
|
||||
return
|
||||
# Keep the texture point under cursor fixed while zooming
|
||||
var local_before: Vector2 = (mouse_pos - _view_offset) / _view_zoom
|
||||
_view_zoom = new_zoom
|
||||
_view_offset = mouse_pos - local_before * _view_zoom
|
||||
_apply_transform()
|
||||
|
||||
|
||||
## 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:
|
||||
@@ -665,6 +645,26 @@ func screen_to_canvas(screen_point: Vector2) -> Vector2:
|
||||
|
||||
func _draw() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
||||
if _hover_active and _heightmap_texture != null:
|
||||
_draw_descend_reticle()
|
||||
|
||||
|
||||
## T-1138 NOT-TO-SCALE reticle (§5's open design point). Draw calls only —
|
||||
## geometry/rationale in atlas_descend_geometry.gd (draw_line must run on `self`).
|
||||
func _draw_descend_reticle() -> void:
|
||||
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:
|
||||
@@ -686,7 +686,7 @@ func _refresh_screen_header() -> void:
|
||||
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 = "drag pan · wheel zoom · r reset · click city data · esc back"
|
||||
var hint: String = "click descend · click city data · esc back"
|
||||
_screen_header.set_content(title, hint)
|
||||
|
||||
|
||||
@@ -734,27 +734,13 @@ func _gui_input(event: InputEvent) -> void:
|
||||
|
||||
if event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
|
||||
_zoom_at(mb.position, ZOOM_STEP)
|
||||
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
|
||||
_zoom_at(mb.position, 1.0 / ZOOM_STEP)
|
||||
elif mb.button_index == MOUSE_BUTTON_LEFT:
|
||||
if mb.pressed:
|
||||
if not _try_click_city(mb.position):
|
||||
_dragging = true
|
||||
_drag_start_mouse = mb.position
|
||||
_drag_start_offset = _view_offset
|
||||
else:
|
||||
_dragging = false
|
||||
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
|
||||
var mm := event as InputEventMouseMotion
|
||||
if _dragging:
|
||||
_view_offset = _drag_start_offset + (mm.position - _drag_start_mouse)
|
||||
_apply_transform()
|
||||
else:
|
||||
_update_hover(mm.position)
|
||||
_update_hover((event as InputEventMouseMotion).position)
|
||||
|
||||
|
||||
func _handle_key(event: InputEventKey) -> void:
|
||||
@@ -770,7 +756,7 @@ func _handle_key(event: InputEventKey) -> void:
|
||||
_fit_to_view()
|
||||
|
||||
|
||||
func _try_click_city(screen_pos: Vector2) -> bool:
|
||||
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
|
||||
@@ -781,11 +767,17 @@ func _try_click_city(screen_pos: Vector2) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func _descend_at(screen_pos: Vector2) -> void: # T-1138: every point maps to a DistrictPos
|
||||
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
|
||||
_overlay_node.queue_redraw()
|
||||
_hover_screen_pos = screen_pos
|
||||
_hover_active = true
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
func _find_city_at(screen_pos: Vector2) -> Dictionary:
|
||||
@@ -817,6 +809,12 @@ func city_canvas_pos(city: Dictionary) -> Vector2:
|
||||
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)
|
||||
# =============================================================================
|
||||
@@ -996,3 +994,6 @@ func _notification(what: int) -> void:
|
||||
_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()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
extends RefCounted
|
||||
|
||||
## Client-side LRU cache for DistrictWindowLayer responses (T-1138, D-226
|
||||
## T-1124 amendment §4 "Client cache policy").
|
||||
##
|
||||
## Keyed on (body_id, center, n) — D-227's determinism guarantee (same seed +
|
||||
## body + position -> same derived output, always) means a previously-fetched
|
||||
## window is valid FOREVER for that body+seed. This is an LRU-evict-only
|
||||
## cache: no freshness check, no TTL, no invalidation path at all. The only
|
||||
## reason an entry ever leaves is capacity pressure.
|
||||
##
|
||||
## Godot's Dictionary preserves insertion order, so "move to the end on
|
||||
## touch, evict from the front on overflow" is the whole LRU implementation —
|
||||
## no separate linked-list/counter bookkeeping needed.
|
||||
##
|
||||
## Consumed via explicit load() by path (no class_name), matching
|
||||
## atlas_overlay_bar.gd/atlas_legend_panel.gd (review #8 precedent
|
||||
## elsewhere in this app): the owner constructs one instance and holds it,
|
||||
## same shape as those two.
|
||||
|
||||
const DEFAULT_MAX_ENTRIES: int = 24
|
||||
|
||||
var _max_entries: int = DEFAULT_MAX_ENTRIES
|
||||
var _entries: Dictionary = {} # key String -> DistrictWindowLayer Dictionary
|
||||
|
||||
|
||||
func _init(max_entries: int = DEFAULT_MAX_ENTRIES) -> void:
|
||||
_max_entries = maxi(1, max_entries)
|
||||
|
||||
|
||||
## Build the cache key from the three fields D-227 makes sufficient:
|
||||
## body_id (which world+body), center (a [row, col] pair or Vector2i), and n
|
||||
## (window side length). String-keyed rather than a nested Dictionary/Array
|
||||
## key — Godot Dictionary keys compare by value for primitives but a
|
||||
## consistent stringification sidesteps any Vector2i-vs-Array identity
|
||||
## mismatch between what a caller happens to hand in.
|
||||
static func make_key(body_id: String, center: Vector2i, n: int) -> String:
|
||||
return "%s:%d,%d:%d" % [body_id, center.x, center.y, n]
|
||||
|
||||
|
||||
## True if a window is already cached for this exact (body, center, n).
|
||||
func has(body_id: String, center: Vector2i, n: int) -> bool:
|
||||
return _entries.has(make_key(body_id, center, n))
|
||||
|
||||
|
||||
## Fetch a cached window, touching it (move-to-most-recently-used). Returns
|
||||
## null on a miss — callers must not confuse this with a real "None" server
|
||||
## response, which is a different concept (§1: an as-yet-underived window is
|
||||
## carried as `district_window: None` inside a `Ready` AtlasLayerResponse,
|
||||
## not a cache state).
|
||||
func get_window(body_id: String, center: Vector2i, n: int) -> Variant:
|
||||
var key := make_key(body_id, center, n)
|
||||
if not _entries.has(key):
|
||||
return null
|
||||
var value: Variant = _entries[key]
|
||||
# Touch: erase + re-insert moves the key to the end (most-recently-used).
|
||||
_entries.erase(key)
|
||||
_entries[key] = value
|
||||
return value
|
||||
|
||||
|
||||
## Store a window, evicting the least-recently-used entry(ies) if over
|
||||
## capacity. Overwriting an existing key also counts as a touch.
|
||||
func put(body_id: String, center: Vector2i, n: int, window: Dictionary) -> void:
|
||||
var key := make_key(body_id, center, n)
|
||||
if _entries.has(key):
|
||||
_entries.erase(key)
|
||||
_entries[key] = window
|
||||
while _entries.size() > _max_entries:
|
||||
var oldest_key: String = _entries.keys()[0]
|
||||
_entries.erase(oldest_key)
|
||||
|
||||
|
||||
func size() -> int:
|
||||
return _entries.size()
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
_entries.clear()
|
||||
@@ -0,0 +1,134 @@
|
||||
extends ImplantPanel
|
||||
|
||||
## Legend for AtlasWindowViewer's regional-window overlays (T-1138, D-226
|
||||
## T-1124 amendment §5). Mirrors atlas_legend_panel.gd's data-driven shape —
|
||||
## one spec entry per overlay id, refresh() shows only the active ones — but
|
||||
## scoped to the district-window screen's OWN toggle set (gen_dw_temp/
|
||||
## gen_dw_moisture/gen_dw_veg) plus the two always-on layers that need a key
|
||||
## even though they have no toggle id of their own: the morphology base
|
||||
## (folded to ~5 family rows, per §5's "not everything earns permanent screen
|
||||
## space" instinct) and the glaciation ice-tint modifier.
|
||||
##
|
||||
## No `class_name` on purpose, matching atlas_legend_panel.gd (review #8
|
||||
## precedent): the owner (AtlasWindowViewer) passes itself to _init().
|
||||
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
const LEGEND_PANEL_WIDTH: float = 260.0
|
||||
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
|
||||
## The morphology base layer folds its 17 zones into ~5 family rows (§5:
|
||||
## "mirroring T-1112's 'not everything earns permanent screen space'
|
||||
## discipline") — the full 17-zone mapping stays in the city-click sidebar's
|
||||
## reach, not duplicated here. Representative hue per family, picked from
|
||||
## MORPHOLOGY_RGB_OPAQUE's own entries rather than a fresh set of colors.
|
||||
const MORPHOLOGY_FAMILY_ROWS: Array = [
|
||||
{"label": "water", "zones": [0, 1]}, # OpenOcean, Lake
|
||||
{"label": "coastal / transition", "zones": [2, 3, 4, 5, 6, 7]}, # TidalFlat..Estuarine
|
||||
{"label": "plains / river", "zones": [8, 9, 10, 11, 12]}, # AlluvialPlain..ValleyFloor
|
||||
{"label": "upland", "zones": [13, 14]}, # MountainPass, Alpine
|
||||
{"label": "volcanic / wetland", "zones": [15, 16]}, # Volcanic, Wetland
|
||||
]
|
||||
|
||||
const GLACIATION_ROWS: Array = [
|
||||
{"grade": 0, "label": "none"},
|
||||
{"grade": 1, "label": "light (erosion signatures — no tint)"},
|
||||
{"grade": 2, "label": "moderate"},
|
||||
{"grade": 3, "label": "heavy"},
|
||||
{"grade": 4, "label": "ice cap"},
|
||||
]
|
||||
|
||||
var _viewer = null # AtlasWindowViewer (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)
|
||||
|
||||
|
||||
## Always shows the base-layer key (morphology + elevation reading, always
|
||||
## on) plus glaciation (always-on modifier), then whichever toggle overlay is
|
||||
## currently active, if any.
|
||||
func refresh() -> void:
|
||||
if _viewer == null:
|
||||
return
|
||||
clear()
|
||||
visible = true
|
||||
|
||||
add_component(ImplantHeader.new("REGIONAL LEGEND", "district window · 2.048 km/cell"))
|
||||
add_component(ImplantSeparator.new())
|
||||
|
||||
_add_morphology_section()
|
||||
add_component(ImplantSeparator.new())
|
||||
_add_glaciation_section()
|
||||
|
||||
var active_id: String = _active_toggle_id()
|
||||
if not active_id.is_empty():
|
||||
add_component(ImplantSeparator.new())
|
||||
_add_toggle_section(active_id)
|
||||
|
||||
reposition()
|
||||
|
||||
|
||||
func _add_morphology_section() -> void:
|
||||
add_component(
|
||||
ImplantTextBlock.new("TERRAIN — hue = type, lightness = elevation (always on)")
|
||||
)
|
||||
for row: Dictionary in MORPHOLOGY_FAMILY_ROWS:
|
||||
var zones: Array = row.get("zones", [])
|
||||
var swatch: Color = (
|
||||
AtlasOverlayColors.district_window_morphology_color(zones[0]) if not zones.is_empty() else Color.TRANSPARENT
|
||||
)
|
||||
var text: String = "▦ %s" % str(row.get("label", ""))
|
||||
add_component(ImplantDataRow.new(text, swatch))
|
||||
|
||||
|
||||
func _add_glaciation_section() -> void:
|
||||
add_component(ImplantTextBlock.new("ICE TINT — always-on modifier over any layer"))
|
||||
for row: Dictionary in GLACIATION_ROWS:
|
||||
var swatch: Color = AtlasOverlayColors.glaciation_tint(
|
||||
Color(0.3, 0.3, 0.3, 1.0), int(row.get("grade", 0))
|
||||
)
|
||||
var text: String = "▦ %s" % str(row.get("label", ""))
|
||||
add_component(ImplantDataRow.new(text, swatch))
|
||||
|
||||
|
||||
func _add_toggle_section(overlay_id: String) -> void:
|
||||
match overlay_id:
|
||||
"gen_dw_temp":
|
||||
add_component(ImplantTextBlock.new("TEMPERATURE — cold->hot ramp (region colorizer, reused)"))
|
||||
add_component(
|
||||
ImplantDataRow.new("▦ cold", AtlasOverlayColors.COLOR_REGION_TEMP_COLD)
|
||||
)
|
||||
add_component(ImplantDataRow.new("▦ hot", AtlasOverlayColors.COLOR_REGION_TEMP_HOT))
|
||||
add_component(ImplantDataRow.new("▦ airless — no reading (skipped)", Color.TRANSPARENT))
|
||||
"gen_dw_moisture":
|
||||
add_component(ImplantTextBlock.new("MOISTURE — dry->wet ramp"))
|
||||
add_component(ImplantDataRow.new("▦ dry", Color(0.78, 0.62, 0.35, 1.0)))
|
||||
add_component(ImplantDataRow.new("▦ wet", Color(0.25, 0.72, 0.65, 1.0)))
|
||||
"gen_dw_veg":
|
||||
add_component(ImplantTextBlock.new("VEGETATION — green-family ramp"))
|
||||
add_component(ImplantDataRow.new("▦ barren", AtlasOverlayColors.COLOR_VEGETATION_BARREN))
|
||||
add_component(ImplantDataRow.new("▦ scrub", AtlasOverlayColors.COLOR_VEGETATION_SCRUB))
|
||||
add_component(ImplantDataRow.new("▦ forest", AtlasOverlayColors.COLOR_VEGETATION_FOREST))
|
||||
add_component(
|
||||
ImplantDataRow.new(
|
||||
"▦ riparian band", AtlasOverlayColors.COLOR_VEGETATION_RIPARIAN_THICKET
|
||||
)
|
||||
)
|
||||
add_component(
|
||||
ImplantDataRow.new("▦ marine — transparent (shows water below)", Color.TRANSPARENT)
|
||||
)
|
||||
|
||||
|
||||
func _active_toggle_id() -> String:
|
||||
for overlay_id in ["gen_dw_temp", "gen_dw_moisture", "gen_dw_veg"]:
|
||||
if _viewer.is_overlay_visible(overlay_id):
|
||||
return overlay_id
|
||||
return ""
|
||||
@@ -0,0 +1,158 @@
|
||||
class_name AtlasWindowOverlay
|
||||
extends Node2D
|
||||
|
||||
## Draws the DistrictWindowLayer composite for AtlasWindowViewer (T-1138,
|
||||
## D-226 T-1124 amendment §5). Child of AtlasWindowViewer._canvas so it
|
||||
## inherits the pan transform (zoom is client-side texture zoom on the
|
||||
## already-held composite, §5 — never a re-fetch).
|
||||
##
|
||||
## Draw order (bottom to top), matching the amendment's compositing model:
|
||||
## 1. Base layer — morphology hue, lightness-modulated by elev_q. Always on,
|
||||
## no toggle id (§5: "it IS this screen's terrain layer").
|
||||
## 2. Toggle overlays (mutually independent, at most one drawn per cell —
|
||||
## each REPLACES the base read for that cell rather than blending, so
|
||||
## switching between temp/moisture/veg never fights the base hue):
|
||||
## gen_dw_temp / gen_dw_moisture / gen_dw_veg.
|
||||
## 3. Glaciation ice-tint MODIFIER — composited over whichever layer is
|
||||
## showing (base or a toggle), always-on, not a toggle id of its own.
|
||||
##
|
||||
## Reads window data via viewer.get_district_window() (a Dictionary or null) — this
|
||||
## overlay draws nothing until the viewer has a window (border-fade during
|
||||
## the wait is the VIEWER's job, drawn separately underneath this node, not
|
||||
## here — this node is purely "draw the composite when there is one").
|
||||
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
|
||||
|
||||
## Moisture ramp reuses SUB_BIOME_COLORS' dry-sand->wet-teal ENDPOINTS (§5) —
|
||||
## not the categorical lookup itself (that's keyed by sub-biome NAME, not a
|
||||
## 0-100 quantity). Endpoints pulled from the existing dry/wet entries in that
|
||||
## table: Desert (arid/dry) and TropicalWet (wet/coastal).
|
||||
const COLOR_MOISTURE_DRY: Color = Color(0.78, 0.62, 0.35, 1.0) # sand — matches SUB_BIOME_COLORS.Desert
|
||||
const COLOR_MOISTURE_WET: Color = Color(0.25, 0.72, 0.65, 1.0) # teal — matches SUB_BIOME_COLORS.TropicalWet
|
||||
|
||||
var viewer = null # AtlasWindowViewer (untyped to avoid cyclic ref)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if viewer == null:
|
||||
return
|
||||
var window: Variant = viewer.get_district_window()
|
||||
if not window is Dictionary:
|
||||
return
|
||||
var w: Dictionary = window
|
||||
var n: int = int(w.get("n", 0))
|
||||
if n <= 0:
|
||||
return
|
||||
|
||||
var morphology: Variant = w.get("morphology")
|
||||
var elev_q: Variant = w.get("elev_q")
|
||||
if not (morphology is PackedByteArray or morphology is Array):
|
||||
return
|
||||
|
||||
var cell_px: float = viewer.get_cell_pixel_size()
|
||||
var active_toggle: String = _active_toggle_overlay()
|
||||
var glaciation: Variant = w.get("glaciation")
|
||||
|
||||
for row in range(n):
|
||||
for col in range(n):
|
||||
var i: int = row * n + col
|
||||
if i >= morphology.size():
|
||||
continue
|
||||
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
|
||||
if cell_color.a <= 0.0:
|
||||
continue # Marine-transparent or otherwise "don't draw" (cheaper than a 0-alpha rect)
|
||||
cell_color = _apply_glaciation(cell_color, glaciation, i)
|
||||
# +0.5 overdraw avoids hairline seams between adjacent cells —
|
||||
# same idiom as _draw_gen_district/_draw_gen_region_grid.
|
||||
draw_rect(Rect2(col * cell_px, row * cell_px, cell_px + 0.5, cell_px + 0.5), cell_color)
|
||||
|
||||
|
||||
## Which of the three mutually-exclusive toggle overlays (if any) is active.
|
||||
## At most one draws — §5 does not describe blending two toggles together,
|
||||
## and doing so would fight the "one colorizer, one read" legibility goal the
|
||||
## whole layer design optimizes for. First-match-wins on ties (should never
|
||||
## happen — the overlay bar toggles independently, but this keeps the draw
|
||||
## deterministic instead of implicitly depending on dictionary iteration
|
||||
## order if more than one somehow ends up true).
|
||||
func _active_toggle_overlay() -> String:
|
||||
if viewer.is_overlay_visible("gen_dw_temp"):
|
||||
return "gen_dw_temp"
|
||||
if viewer.is_overlay_visible("gen_dw_moisture"):
|
||||
return "gen_dw_moisture"
|
||||
if viewer.is_overlay_visible("gen_dw_veg"):
|
||||
return "gen_dw_veg"
|
||||
return ""
|
||||
|
||||
|
||||
func _cell_color(
|
||||
w: Dictionary, i: int, morphology_zone: int, elev_q: Variant, active_toggle: String
|
||||
) -> Color:
|
||||
match active_toggle:
|
||||
"gen_dw_temp":
|
||||
return _temp_cell_color(w.get("temp_dc"), i)
|
||||
"gen_dw_moisture":
|
||||
return _moisture_cell_color(w.get("moisture_q"), i)
|
||||
"gen_dw_veg":
|
||||
return _veg_cell_color(w.get("vegetation"), i)
|
||||
_:
|
||||
return _base_cell_color(morphology_zone, elev_q, i)
|
||||
|
||||
|
||||
## Base layer: morphology hue, lightness-modulated by elev_q (§5's one
|
||||
## `0.7 + 0.3*(elev_q/100)` multiply per cell).
|
||||
func _base_cell_color(morphology_zone: int, elev_q: Variant, i: int) -> Color:
|
||||
var base: Color = AtlasOverlayColors.district_window_morphology_color(morphology_zone)
|
||||
var eq: int = _dense_int(elev_q, i, 50)
|
||||
return AtlasOverlayColors.district_window_elevation_lightness(base, eq)
|
||||
|
||||
|
||||
## gen_dw_temp: reuses T-1118's region_temp_color() EXACTLY — same i16
|
||||
## deci-°C domain, same REGION_TEMP_NONE_DC sentinel disposition (skip the
|
||||
## cell entirely, matching _draw_gen_region_grid's airless treatment) — one
|
||||
## colorizer across both zoom levels, per the amendment's consistency ruling.
|
||||
func _temp_cell_color(temp_dc: Variant, i: int) -> Color:
|
||||
if temp_dc == null:
|
||||
return Color.TRANSPARENT
|
||||
var t: int = _dense_int(temp_dc, i, REGION_TEMP_NONE_DC)
|
||||
if t == REGION_TEMP_NONE_DC:
|
||||
return Color.TRANSPARENT # airless — no reading, skip the cell (matches region-grid precedent)
|
||||
return AtlasOverlayColors.region_temp_color(t)
|
||||
|
||||
|
||||
## gen_dw_moisture: dry-sand -> wet-teal ramp over the existing SUB_BIOME_COLORS
|
||||
## endpoints (§5).
|
||||
func _moisture_cell_color(moisture_q: Variant, i: int) -> Color:
|
||||
if moisture_q == null:
|
||||
return Color.TRANSPARENT
|
||||
var m: int = clampi(_dense_int(moisture_q, i, 50), 0, 100)
|
||||
return COLOR_MOISTURE_DRY.lerp(COLOR_MOISTURE_WET, float(m) / 100.0)
|
||||
|
||||
|
||||
## gen_dw_veg: green-family ramp, Marine transparent (§3/§5 — non-negotiable
|
||||
## per the amendment; see atlas_overlay_colors.gd's vegetation_color() doc).
|
||||
func _veg_cell_color(vegetation: Variant, i: int) -> Color:
|
||||
if vegetation == null:
|
||||
return Color.TRANSPARENT
|
||||
return AtlasOverlayColors.vegetation_color(_dense_int(vegetation, i, 0))
|
||||
|
||||
|
||||
## Glaciation: an always-on MODIFIER (never a toggle id), composited over
|
||||
## whichever layer is currently showing — the base or one of the three
|
||||
## toggles (§5).
|
||||
func _apply_glaciation(cell_color: Color, glaciation: Variant, i: int) -> Color:
|
||||
if glaciation == null:
|
||||
return cell_color
|
||||
var grade: int = _dense_int(glaciation, i, 0)
|
||||
return AtlasOverlayColors.glaciation_tint(cell_color, grade)
|
||||
|
||||
|
||||
## Reads element `i` from a dense numeric array field regardless of whether
|
||||
## the messagepack decode produced a PackedByteArray (u8 fields) or a plain
|
||||
## Array (i16 temp_dc — rmp_serde without serde_bytes, matching the existing
|
||||
## region_grid _dense_int precedent in test_atlas_overlays.gd, now needed at
|
||||
## RUNTIME here too, not just in a test helper).
|
||||
static func _dense_int(arr: Variant, i: int, fallback: int) -> int:
|
||||
if (arr is Array or arr is PackedByteArray) and i < arr.size():
|
||||
return int(arr[i])
|
||||
return fallback
|
||||
@@ -0,0 +1,182 @@
|
||||
extends Node
|
||||
|
||||
## District-window request orchestration for AtlasWindowViewer (T-1138, D-226
|
||||
## T-1124 amendment §1/§4). Owns the cache, the pan-triggered re-request
|
||||
## policy, the post-drag-release debounce, and the retry loop for the
|
||||
## queue-based background derive (PR #185 finding: a window response arrives
|
||||
## on a LATER TICK, not synchronously — the exact same "None until derived,
|
||||
## re-poll" contract atlas_generation_proxy.gd's Layer1 path already handles,
|
||||
## reused here rather than re-invented).
|
||||
##
|
||||
## This script has no `class_name` on purpose, matching every other
|
||||
## viewer-owned helper in this cluster (atlas_overlay_bar.gd/
|
||||
## atlas_legend_panel.gd/atlas_generation_proxy.gd, review #8 precedent): the
|
||||
## owner (AtlasWindowViewer) passes itself to _init(), and a `class_name` +
|
||||
## required-arg _init() combo is a Godot editor footgun. `extends Node` (not
|
||||
## RefCounted) because it needs get_tree() for the debounce/retry timers —
|
||||
## added as a child via
|
||||
## load("res://ui/implant/apps/atlas/atlas_window_request.gd").new(self).
|
||||
##
|
||||
## §4 policy fixed here (the constants + the debounce, NOT the pan-edge
|
||||
## detection — that's the viewer's job, since it owns the screen-to-district
|
||||
## geometry):
|
||||
## - DISTRICT_WINDOW_DEFAULT_N = 32 (client's interactive default, half the
|
||||
## server's DISTRICT_WINDOW_MAX_N = 64 hard cap — §4 pins both numbers;
|
||||
## the cap itself is a server-side clamp this client never needs to
|
||||
## duplicate, only stay under so a request is never silently clamped in
|
||||
## a way the client didn't expect).
|
||||
## - 150ms post-drag-release debounce — long enough to collapse a
|
||||
## flick-and-resettle into one request, short enough that a deliberate
|
||||
## single pan-and-stop never feels delayed (§4/§5 wording, identical).
|
||||
## - Cache-hit is instant (no request at all) — §4's "D-227 makes exact-
|
||||
## repeat the common case for Esc-then-re-enter and pan-back" is what
|
||||
## makes this the common path, not the minority one.
|
||||
|
||||
signal window_ready(window: Dictionary) # emitted on a cache hit OR a fresh Ready response
|
||||
|
||||
const AtlasWindowCache := preload("res://ui/implant/apps/atlas/atlas_window_cache.gd")
|
||||
|
||||
const DISTRICT_WINDOW_DEFAULT_N: int = 32
|
||||
const DEBOUNCE_DELAY: float = 0.15 # 150ms, §4/§5
|
||||
const RETRY_DELAY: float = 0.5 # matches atlas_generation_proxy.gd's GEN_RETRY_DELAY
|
||||
const MAX_RETRIES: int = 20 # ~10s ceiling, matches atlas_generation_proxy.gd's GEN_MAX_RETRIES
|
||||
|
||||
var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref)
|
||||
var _cache = null # AtlasWindowCache
|
||||
var _body_id: String = ""
|
||||
var _center: Vector2i = Vector2i.ZERO
|
||||
var _n: int = DISTRICT_WINDOW_DEFAULT_N
|
||||
var _pending: bool = false
|
||||
var _retries: int = 0
|
||||
var _debounce_timer: Timer = null
|
||||
|
||||
|
||||
func _init(owner_ref = null) -> void:
|
||||
_owner = owner_ref
|
||||
_cache = AtlasWindowCache.new()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_debounce_timer = Timer.new()
|
||||
_debounce_timer.name = "DebounceTimer"
|
||||
_debounce_timer.one_shot = true
|
||||
_debounce_timer.wait_time = DEBOUNCE_DELAY
|
||||
_debounce_timer.timeout.connect(_on_debounce_timeout)
|
||||
add_child(_debounce_timer)
|
||||
|
||||
|
||||
## Reset for a fresh entry into the regional window mode (new body/center) —
|
||||
## clears in-flight retry bookkeeping but NOT the cache (D-227: a cached
|
||||
## window is valid forever regardless of which body/center the viewer is
|
||||
## currently showing; clearing on every entry would throw away exactly the
|
||||
## Esc-then-re-enter hit §4 promises).
|
||||
func reset() -> void:
|
||||
_pending = false
|
||||
_retries = 0
|
||||
if _debounce_timer:
|
||||
_debounce_timer.stop()
|
||||
|
||||
|
||||
## Entry point + pan re-request: request the window centered on `center`
|
||||
## (a DistrictPos-equivalent Vector2i) for `body_id`. Cache hit -> immediate
|
||||
## synchronous window_ready emit, no network traffic at all. Cache miss ->
|
||||
## fire the request now (the caller — either the initial entry or a
|
||||
## debounce-fired pan — has already decided this call SHOULD fire; the 150ms
|
||||
## debounce itself lives in request_debounced() below, not here, so this
|
||||
## function is also the one entry-mechanic click-through uses directly with
|
||||
## no debounce at all, matching §5's "first window" contract).
|
||||
func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEFAULT_N) -> void:
|
||||
_body_id = body_id
|
||||
_center = center
|
||||
_n = n
|
||||
_debounce_timer.stop() # a direct request supersedes any pending debounced one
|
||||
|
||||
var cached: Variant = _cache.get_window(body_id, center, n)
|
||||
if cached != null:
|
||||
_pending = false
|
||||
_retries = 0
|
||||
window_ready.emit(cached)
|
||||
return
|
||||
|
||||
_pending = true
|
||||
_retries = 0
|
||||
SimBridge.request_atlas_layers(body_id, "Topography", center, n)
|
||||
|
||||
|
||||
## Pan-triggered re-request (§4/§5: "150ms after the last drag-release, not
|
||||
## per-drag-frame"). The viewer calls this on every pan-edge-crossing
|
||||
## candidate; only the LAST call within the debounce window actually fires
|
||||
## (Timer.start() on an already-running one-shot timer restarts it — Godot's
|
||||
## documented behavior — so a flick-and-resettle collapses to one request).
|
||||
func request_debounced(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEFAULT_N) -> void:
|
||||
_body_id = body_id
|
||||
_center = center
|
||||
_n = n
|
||||
_debounce_timer.start()
|
||||
|
||||
|
||||
func _on_debounce_timeout() -> void:
|
||||
request_now(_body_id, _center, _n)
|
||||
|
||||
|
||||
## Handle an AtlasLayerResponse (routed by the owning viewer from its own
|
||||
## SimBridge.atlas_layers_received subscription — this object has no signal
|
||||
## connection of its own, matching atlas_generation_proxy.gd's on_response()
|
||||
## shape). Ignores responses for a stale body/center/n (the player panned or
|
||||
## navigated away while a request was in flight) — the echoed center/n IS the
|
||||
## staleness guard (§2), compared here against what THIS object most recently
|
||||
## asked for.
|
||||
func on_response(response: Dictionary) -> void:
|
||||
if str(response.get("body_id", "")) != _body_id:
|
||||
return
|
||||
if str(response.get("status", "")) != "Ready":
|
||||
return # Pending/NotFound/Error on the WHOLE response — not a window signal either way
|
||||
var window: Variant = response.get("district_window")
|
||||
if window == null:
|
||||
# §1: an as-yet-underived window rides as `district_window: None` inside
|
||||
# a Ready response — this is the "still generating" signal, not an
|
||||
# error. Re-poll until the background derive lands or the retry
|
||||
# ceiling is hit (queue-based serving, PR #185 — the response lands
|
||||
# on a LATER tick, never this same round-trip).
|
||||
if not _pending:
|
||||
return
|
||||
if _retries < MAX_RETRIES:
|
||||
_retries += 1
|
||||
_schedule_retry()
|
||||
else:
|
||||
_pending = false # gave up — caller's border-fade / empty state persists
|
||||
return
|
||||
|
||||
var w: Dictionary = window
|
||||
var echoed_center := _vec_from_center(w.get("center", [0, 0]))
|
||||
var echoed_n := int(w.get("n", 0))
|
||||
if echoed_center != _center or echoed_n != _n:
|
||||
return # stale — answers a window we've since panned away from (§2)
|
||||
|
||||
_pending = false
|
||||
_retries = 0
|
||||
_cache.put(_body_id, _center, _n, w)
|
||||
window_ready.emit(w)
|
||||
|
||||
|
||||
func _schedule_retry() -> void:
|
||||
var timer := get_tree().create_timer(RETRY_DELAY)
|
||||
timer.timeout.connect(
|
||||
func() -> void:
|
||||
if _pending:
|
||||
SimBridge.request_atlas_layers(_body_id, "Topography", _center, _n)
|
||||
)
|
||||
|
||||
|
||||
func is_pending() -> bool:
|
||||
return _pending
|
||||
|
||||
|
||||
func get_cache() -> Variant:
|
||||
return _cache
|
||||
|
||||
|
||||
static func _vec_from_center(center: Variant) -> Vector2i:
|
||||
if center is Array and center.size() >= 2:
|
||||
return Vector2i(int(center[0]), int(center[1]))
|
||||
return Vector2i.ZERO
|
||||
@@ -0,0 +1,497 @@
|
||||
class_name AtlasWindowViewer
|
||||
extends Control
|
||||
|
||||
## Regional district-resolution window viewer (T-1138, D-226 T-1124
|
||||
## amendment). Entered via a click-through from the planetary AtlasViewer
|
||||
## (the 2026-07-21 §5 entry revision — NOT a zoom-threshold LOD swap).
|
||||
## Renders a DistrictWindowLayer composite: morphology base layer lightness-
|
||||
## modulated by elev_q, three switchable climate/vegetation overlays, and an
|
||||
## always-on glaciation ice-tint modifier (drawing itself is
|
||||
## AtlasWindowOverlay's job — this Control owns input, request orchestration,
|
||||
## chrome, and the pan/zoom transform).
|
||||
##
|
||||
## Design notes (mirroring AtlasViewer's own split, D-226 §5):
|
||||
## - _canvas (Node2D) holds AtlasWindowOverlay; pan = _canvas.position, zoom
|
||||
## = _canvas.scale — the SAME transform idiom as the planetary viewer.
|
||||
## - Zoom is ALWAYS client-side on the already-held composite (§5: "the
|
||||
## composite is a texture the client zooms client-side... from already-
|
||||
## held data") — it NEVER triggers a re-request. Only a pan past the held
|
||||
## window's edge does (§4/§5).
|
||||
## - _window_request (atlas_window_request.gd) owns the cache/debounce/
|
||||
## retry — this Control decides WHEN to call it (pan-edge detection,
|
||||
## entry), never talks to SimBridge directly itself.
|
||||
##
|
||||
## Navigation:
|
||||
## Mouse drag pan within/across the window
|
||||
## Mouse wheel zoom the held composite (client-side only, never refetches)
|
||||
## Esc back to the planetary view
|
||||
|
||||
signal back_pressed
|
||||
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
const OVERLAY_BAR_HEADER_RESERVE: float = 360.0
|
||||
|
||||
const MIN_ZOOM: float = 0.5
|
||||
const MAX_ZOOM: float = 8.0
|
||||
const ZOOM_STEP: float = 1.15
|
||||
|
||||
## Pixel size of one district cell at zoom=1.0 — a fixed on-screen scale
|
||||
## (unlike AtlasViewer's heightmap, there is no source texture dictating a
|
||||
## native pixel size; this constant IS the native size). 16px/cell at n=64
|
||||
## gives a ~1024px-wide composite before zoom, comfortably inside a
|
||||
## 1280x720+ viewport at the DEFAULT_N=32 interactive default (512px) too.
|
||||
const CELL_PIXEL_SIZE: float = 16.0
|
||||
|
||||
const COLOR_BG: Color = Color("#0d1117")
|
||||
## Border-fade target (§5 "what renders during the wait"): the underlying
|
||||
## whole-body heightmap's own background tint, so the newly-exposed edge
|
||||
## reads as "real data seen through", not a placeholder block. Reuses
|
||||
## AtlasViewer's own COLOR_HEIGHTMAP_TINT-adjacent dim value rather than
|
||||
## inventing a new one — this IS a dimmer/less-certain read of the same
|
||||
## planetary data, not a different visual language.
|
||||
const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55)
|
||||
|
||||
## D-243: 2,048 m per district side.
|
||||
const DISTRICT_M: float = 2048.0
|
||||
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
|
||||
# ── Overlay definitions (T-1138 — reuses atlas_overlay_bar.gd/
|
||||
# atlas_legend_panel.gd's existing duck-typed viewer interface: both call
|
||||
# only get_overlay_defs()/is_overlay_visible()/set_overlay_visible(), so
|
||||
# this Control is a drop-in "viewer" for either component without
|
||||
# subclassing AtlasViewer). Glaciation is deliberately NOT here — it is an
|
||||
# always-on modifier per §5, not a toggle id.
|
||||
const OVERLAY_DEFS: Array = [
|
||||
{
|
||||
"id": "gen_dw_temp",
|
||||
"label": "TMP",
|
||||
"group": "toggle",
|
||||
"tooltip": "Temperature — region-ramp colorizer, reused from the planetary climate overlay."
|
||||
},
|
||||
{
|
||||
"id": "gen_dw_moisture",
|
||||
"label": "MST",
|
||||
"group": "toggle",
|
||||
"tooltip": "Moisture — dry-to-wet ramp."
|
||||
},
|
||||
{
|
||||
"id": "gen_dw_veg",
|
||||
"label": "VEG",
|
||||
"group": "toggle",
|
||||
"tooltip": "Vegetation — green-family ramp. Marine reads transparent (open water)."
|
||||
},
|
||||
]
|
||||
|
||||
# ── Context (set by enter()) ──────────────────────────────────────────────
|
||||
var _body: Dictionary = {}
|
||||
var _system: Dictionary = {}
|
||||
var _implant_theme = null
|
||||
|
||||
# ── Held window + geometry ────────────────────────────────────────────────
|
||||
var _window: Variant = null # current DistrictWindowLayer Dictionary, or null while waiting
|
||||
var _held_center: Vector2i = Vector2i.ZERO
|
||||
var _held_n: int = 32
|
||||
|
||||
# ── Pan/zoom state (mirrors AtlasViewer's own fields exactly) ────────────
|
||||
var _view_offset: Vector2 = Vector2.ZERO
|
||||
var _view_zoom: float = 1.0
|
||||
var _dragging: bool = false
|
||||
var _drag_start_mouse: Vector2
|
||||
var _drag_start_offset: Vector2
|
||||
|
||||
# ── Overlay visibility ─────────────────────────────────────────────────────
|
||||
var _overlay_visibility: Dictionary = {}
|
||||
|
||||
# ── Child nodes ────────────────────────────────────────────────────────────
|
||||
var _canvas: Node2D = null
|
||||
var _overlay_node: AtlasWindowOverlay = null
|
||||
var _screen_header: ImplantHeader = null
|
||||
var _overlay_bar = null
|
||||
var _legend_panel = null
|
||||
var _window_request = null # AtlasWindowRequest
|
||||
|
||||
|
||||
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:
|
||||
_overlay_visibility[def["id"]] = false
|
||||
|
||||
_canvas = Node2D.new()
|
||||
_canvas.name = "WindowCanvas"
|
||||
add_child(_canvas)
|
||||
|
||||
_overlay_node = AtlasWindowOverlay.new()
|
||||
_overlay_node.name = "WindowOverlay"
|
||||
_overlay_node.viewer = self
|
||||
_canvas.add_child(_overlay_node)
|
||||
|
||||
_window_request = AtlasWindowRequest.new(self)
|
||||
_window_request.name = "WindowRequest"
|
||||
add_child(_window_request)
|
||||
_window_request.window_ready.connect(_on_window_ready)
|
||||
|
||||
_build_screen_header()
|
||||
_build_overlay_bar()
|
||||
_build_legend_panel()
|
||||
|
||||
SimBridge.atlas_layers_received.connect(_on_atlas_layers_received)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received):
|
||||
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
|
||||
|
||||
|
||||
## Enter the window screen centered on `district_center` (a DistrictPos-
|
||||
## equivalent Vector2i, from the planetary click-through's derived position —
|
||||
## §5's "pan center read as click point"). n defaults to the client's
|
||||
## interactive default (32), half the server's hard cap.
|
||||
func enter(
|
||||
body: Dictionary,
|
||||
system: Dictionary,
|
||||
district_center: Vector2i,
|
||||
n: int = AtlasWindowRequest.DISTRICT_WINDOW_DEFAULT_N
|
||||
) -> void:
|
||||
_body = body
|
||||
_system = system
|
||||
_held_center = district_center
|
||||
_held_n = n
|
||||
_window = null
|
||||
_view_zoom = 1.0
|
||||
_view_offset = Vector2.ZERO
|
||||
_apply_transform()
|
||||
_window_request.reset()
|
||||
_window_request.request_now(_dict_str(_body, "body_id", ""), district_center, n)
|
||||
_refresh_screen_header()
|
||||
grab_focus()
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
pass
|
||||
|
||||
|
||||
## Named get_district_window(), NOT get_window() — Node already defines
|
||||
## get_window() -> Window (the containing OS window); shadowing it with an
|
||||
## incompatible return type is a Godot parse error (confirmed the hard way).
|
||||
func get_district_window() -> Variant:
|
||||
return _window
|
||||
|
||||
|
||||
## District-cell pixel size at zoom=1.0 — AtlasWindowOverlay reads this
|
||||
## rather than hardcoding CELL_PIXEL_SIZE itself, so the viewer stays the
|
||||
## single source of geometry truth (same "viewer owns the transform, overlay
|
||||
## only draws" split as AtlasViewer/AtlasMarkerOverlay).
|
||||
func get_cell_pixel_size() -> float:
|
||||
return CELL_PIXEL_SIZE
|
||||
|
||||
|
||||
func is_overlay_visible(overlay_id: String) -> bool:
|
||||
return bool(_overlay_visibility.get(overlay_id, false))
|
||||
|
||||
|
||||
func set_overlay_visible(overlay_id: String, visible_state: bool) -> void:
|
||||
if not _overlay_visibility.has(overlay_id):
|
||||
push_warning("AtlasWindowViewer: unknown overlay id '%s'" % overlay_id)
|
||||
return
|
||||
_overlay_visibility[overlay_id] = visible_state
|
||||
_overlay_node.queue_redraw()
|
||||
_legend_panel.refresh()
|
||||
|
||||
|
||||
func get_overlay_defs() -> Array:
|
||||
return OVERLAY_DEFS
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Window response routing
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _on_atlas_layers_received(response: Dictionary) -> void:
|
||||
_window_request.on_response(response)
|
||||
|
||||
|
||||
func _on_window_ready(window: Dictionary) -> void:
|
||||
# Only adopt the window if it still matches what THIS viewer is currently
|
||||
# showing — AtlasWindowRequest already filtered by its own last-asked
|
||||
# (center, n) via the echo (§2), but a cache-hit path can fire
|
||||
# synchronously from enter() before _held_center is what the signal
|
||||
# handler expects in a re-entrant call; comparing again here is cheap and
|
||||
# removes any ordering assumption between enter()'s two calls.
|
||||
var w_center := _vec_from_center(window.get("center", [0, 0]))
|
||||
var w_n := int(window.get("n", 0))
|
||||
if w_center != _held_center or w_n != _held_n:
|
||||
return
|
||||
_window = window
|
||||
_refresh_screen_header()
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# View transform (mirrors AtlasViewer's own — pan is real, zoom is client-side
|
||||
# only and NEVER triggers a re-request per §5)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _apply_transform() -> void:
|
||||
_canvas.position = _view_offset
|
||||
_canvas.scale = Vector2(_view_zoom, _view_zoom)
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
func _zoom_at(mouse_pos: Vector2, factor: float) -> void:
|
||||
var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM)
|
||||
if is_equal_approx(new_zoom, _view_zoom):
|
||||
return
|
||||
var local_before: Vector2 = (mouse_pos - _view_offset) / _view_zoom
|
||||
_view_zoom = new_zoom
|
||||
_view_offset = mouse_pos - local_before * _view_zoom
|
||||
_apply_transform()
|
||||
|
||||
|
||||
## Programmatic view control (T-1120 capture-API parity — must survive on
|
||||
## every viewer this app exposes, per the ticket's explicit note, even one
|
||||
## that never got user pan/zoom to begin with on the OTHER seam this ticket
|
||||
## removes it from).
|
||||
func get_view_zoom() -> float:
|
||||
return _view_zoom
|
||||
|
||||
|
||||
func get_view_offset() -> Vector2:
|
||||
return _view_offset
|
||||
|
||||
|
||||
func set_view(zoom: float, offset: Vector2) -> void:
|
||||
_view_zoom = clampf(zoom, MIN_ZOOM, MAX_ZOOM)
|
||||
_view_offset = offset
|
||||
_apply_transform()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pan-edge re-request (§4/§5): re-request ONLY when the view pans past the
|
||||
# held window's edge; zoom never refetches.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## After a drag delta, check whether the screen-center now maps to a
|
||||
## DistrictPos outside the held window's extent — if so, float a NEW window
|
||||
## centered on that point (§5 "windows float on the pan center... not
|
||||
## grid-snapped") via the debounced request path.
|
||||
func _maybe_refloat_window() -> void:
|
||||
if _held_n <= 0:
|
||||
return
|
||||
var screen_center: Vector2 = size * 0.5
|
||||
var canvas_pt: Vector2 = (screen_center - _view_offset) / _view_zoom
|
||||
var cell: Vector2 = canvas_pt / CELL_PIXEL_SIZE
|
||||
# cell is in [0, _held_n) local window space when centered — half-window
|
||||
# offset from _held_center converts back to absolute district space.
|
||||
var half: float = float(_held_n) / 2.0
|
||||
var abs_col: float = float(_held_center.x) - half + cell.x
|
||||
var abs_row: float = float(_held_center.y) - half + cell.y
|
||||
var new_center := Vector2i(roundi(abs_col), roundi(abs_row))
|
||||
if new_center == _held_center:
|
||||
return
|
||||
# Edge-crossing check: only re-request if the screen-center point has
|
||||
# actually left the CURRENTLY HELD window's extent — a pan that stays
|
||||
# inside the window (even if the nominal "nearest DistrictPos to center"
|
||||
# ticked over by one cell near a boundary) must not spam a request every
|
||||
# frame. §4: "re-requests only when a pan carries the view past the held
|
||||
# window's edge."
|
||||
var local_col: float = abs_col - float(_held_center.x) + half
|
||||
var local_row: float = abs_row - float(_held_center.y) + half
|
||||
var inside: bool = (
|
||||
local_col >= 0.0
|
||||
and local_col < float(_held_n)
|
||||
and local_row >= 0.0
|
||||
and local_row < float(_held_n)
|
||||
)
|
||||
if inside:
|
||||
return
|
||||
_held_center = new_center
|
||||
_window_request.request_debounced(_dict_str(_body, "body_id", ""), new_center, _held_n)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Drawing (background + border-fade + header)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
||||
if _window == null:
|
||||
# §5 "what renders during the wait": a border-fade to the underlying
|
||||
# whole-body context rather than black/a spinner. This viewer has no
|
||||
# resident whole-body texture of its own (that lives on AtlasViewer,
|
||||
# which this screen has navigated away from) — the honest available
|
||||
# substitute is a dim fade wash over the held composite's last-known
|
||||
# extent, reusing _gen_pending_indicator (via the request object's own
|
||||
# is_pending()) for the "still working" cue rather than new dressing.
|
||||
_draw_border_fade()
|
||||
|
||||
|
||||
func _draw_border_fade() -> void:
|
||||
if not _window_request or not _window_request.is_pending():
|
||||
return
|
||||
var extent: float = float(_held_n) * CELL_PIXEL_SIZE * _view_zoom
|
||||
var top_left: Vector2 = _view_offset
|
||||
draw_rect(Rect2(top_left, Vector2(extent, extent)), COLOR_BORDER_FADE)
|
||||
|
||||
|
||||
func _build_screen_header() -> void:
|
||||
_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)
|
||||
|
||||
|
||||
## D-169/D-170 implant chrome (§5): location label (nearest settlement when
|
||||
## the window is over/near one, else a coordinate/region label — the window
|
||||
## is NOT settlement-anchored) + extent-in-real-units subtitle, e.g.
|
||||
## "4.1 x 4.1 km . 2.0 km/cell".
|
||||
func _refresh_screen_header() -> void:
|
||||
if _screen_header == null:
|
||||
return
|
||||
var location_label: String = _location_label()
|
||||
var extent_km: float = float(_held_n) * DISTRICT_M / 1000.0
|
||||
var extent_line: String = "%.1f x %.1f km · %.1f km/cell" % [
|
||||
extent_km, extent_km, DISTRICT_M / 1000.0
|
||||
]
|
||||
var title: String = "REGIONAL — %s" % location_label.to_upper()
|
||||
_screen_header.set_content(title, extent_line)
|
||||
|
||||
|
||||
## Coordinate/region label — no settlement join exists at this layer yet
|
||||
## (the district window carries no settlement data of its own; that lives on
|
||||
## the planetary gen_l3_settlements overlay, a different screen). This is
|
||||
## deliberately the coordinate fallback branch always, until a future ticket
|
||||
## wires a settlement-proximity join — recorded as an open follow-up, not
|
||||
## silently guessed at.
|
||||
func _location_label() -> String:
|
||||
return "district (%d, %d)" % [_held_center.x, _held_center.y]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Input
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## No city panel / sidebar in this mode (yet) — the window carries no
|
||||
## settlement join of its own (see _location_label's doc), so there is
|
||||
## nothing to hit-test against and this always reads false. Wired into
|
||||
## _gui_input exactly where AtlasViewer's own _is_over_ui is (same guard
|
||||
## shape) so a future sidebar addition only needs to change THIS function's
|
||||
## body, not every call site.
|
||||
func _is_over_ui(_pos: Vector2) -> bool:
|
||||
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 (
|
||||
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_WHEEL_UP and mb.pressed:
|
||||
_zoom_at(mb.position, ZOOM_STEP)
|
||||
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
|
||||
_zoom_at(mb.position, 1.0 / ZOOM_STEP)
|
||||
elif mb.button_index == MOUSE_BUTTON_LEFT:
|
||||
if mb.pressed:
|
||||
_dragging = true
|
||||
_drag_start_mouse = mb.position
|
||||
_drag_start_offset = _view_offset
|
||||
else:
|
||||
_dragging = false
|
||||
elif event is InputEventMouseMotion:
|
||||
var mm := event as InputEventMouseMotion
|
||||
if _dragging:
|
||||
_view_offset = _drag_start_offset + (mm.position - _drag_start_mouse)
|
||||
_apply_transform()
|
||||
_maybe_refloat_window()
|
||||
|
||||
|
||||
func _handle_key(event: InputEventKey) -> void:
|
||||
if event.keycode == KEY_ESCAPE:
|
||||
back_pressed.emit()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Overlay bar / legend (reuses atlas_overlay_bar.gd/atlas_legend_panel.gd —
|
||||
# both call only get_overlay_defs()/is_overlay_visible()/set_overlay_visible(),
|
||||
# so this Control is a drop-in viewer for either component)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
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 = "WindowOverlayBar"
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
func _build_legend_panel() -> void:
|
||||
var LegendScript := load("res://ui/implant/apps/atlas/atlas_window_legend.gd")
|
||||
_legend_panel = LegendScript.new(self)
|
||||
_legend_panel.name = "WindowLegend"
|
||||
_legend_panel.theme_resource = _implant_theme
|
||||
add_child(_legend_panel)
|
||||
_legend_panel.refresh()
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_RESIZED:
|
||||
if _overlay_bar:
|
||||
_position_overlay_bar()
|
||||
if _legend_panel:
|
||||
_legend_panel.reposition()
|
||||
|
||||
|
||||
## Safely extract a string field from a dict, falling back when missing or
|
||||
## null (matches atlas_viewer.gd's own _dict_str verbatim).
|
||||
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
|
||||
|
||||
|
||||
static func _vec_from_center(center: Variant) -> Vector2i:
|
||||
if center is Array and center.size() >= 2:
|
||||
return Vector2i(int(center[0]), int(center[1]))
|
||||
return Vector2i.ZERO
|
||||
@@ -0,0 +1,40 @@
|
||||
class_name DistrictScreen
|
||||
extends Control
|
||||
## Regional district-window viewer screen for AtlasApp (T-1138, D-226 T-1124
|
||||
## amendment). Thin wrapper around AtlasWindowViewer, mirroring
|
||||
## RegionalScreen's own shape exactly — enter/leave are the nav interface.
|
||||
##
|
||||
## Entered via a click-through from AtlasViewer (the "regional" screen),
|
||||
## carrying the derived DistrictPos the player clicked (§5's entry-revision:
|
||||
## "pan center read as click point"). Esc goes back to "regional" (the
|
||||
## planetary heightmap for the same body) — a nav.pop(), not a fresh push, so
|
||||
## the planetary view's own pan/zoom-removed FIXED state is exactly where the
|
||||
## player left it.
|
||||
|
||||
signal back_requested
|
||||
|
||||
var _viewer: AtlasWindowViewer = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_viewer = AtlasWindowViewer.new()
|
||||
_viewer.name = "AtlasWindowViewer"
|
||||
add_child(_viewer)
|
||||
_viewer.back_pressed.connect(_on_viewer_back)
|
||||
|
||||
|
||||
func enter(payload: Dictionary) -> void:
|
||||
var body: Dictionary = payload.get("body", {})
|
||||
var system: Dictionary = payload.get("system", {})
|
||||
var center: Vector2i = payload.get("district_center", Vector2i.ZERO)
|
||||
_viewer.enter(body, system, center)
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _on_viewer_back() -> void:
|
||||
back_requested.emit()
|
||||
@@ -5,6 +5,7 @@ extends Control
|
||||
|
||||
signal back_requested
|
||||
signal economics_link_requested(system_id: String)
|
||||
signal district_descend_requested(district_center: Vector2i) # T-1138, forwarded from AtlasViewer
|
||||
|
||||
var _viewer: AtlasViewer = null
|
||||
|
||||
@@ -17,6 +18,7 @@ func _ready() -> void:
|
||||
add_child(_viewer)
|
||||
_viewer.back_pressed.connect(_on_viewer_back)
|
||||
_viewer.economics_link_requested.connect(_on_viewer_economics_link)
|
||||
_viewer.district_descend_requested.connect(_on_viewer_district_descend)
|
||||
|
||||
|
||||
func enter(payload: Dictionary) -> void:
|
||||
@@ -35,3 +37,7 @@ func _on_viewer_back() -> void:
|
||||
|
||||
func _on_viewer_economics_link(system_id: String) -> void:
|
||||
economics_link_requested.emit(system_id)
|
||||
|
||||
|
||||
func _on_viewer_district_descend(district_center: Vector2i) -> void:
|
||||
district_descend_requested.emit(district_center)
|
||||
|
||||
Reference in New Issue
Block a user