feat(ui): T-1118 climate overlay + T-1119 quarter glyphs — client halves, with file-size extractions
T-1118: gen_region_grid overlay (label TMP) — _draw_gen_region_grid copies _draw_gen_district's self-contained mapping (dims from the layer dict); mean-temp cold-to-hot ramp over -50..+50 C; REGION_TEMP_NONE_DC airless sentinel = skip-cell (undrawn, never an invented color); legend entry. T-1119 (touch points 3-6): quarter_footprints protocol passthrough; gen_l4_quarters overlay (label QTR) — density-scaled glyph anchored on the L3 settlement dot joined by city_id, shape = dominant district type (corner-tab/diamond marks for Commercial/Industrial/ Administrative), color = density ramp on the settlement-gold family, zoom-gated at SETTLEMENT_LABEL_MIN_ZOOM; landmark/corridor counts never drawn (D-226(d) tooltip-only ceiling); legend entry. Structure: atlas_viewer.gd and protocol.gd were over gdlint's 1000-line cap before this batch; cleanly-separable responsibilities extracted on existing precedent — atlas_generation_state.gd (per-layer data + accessors), atlas_generation_proxy.gd (polling/retry/pending machinery), atlas_overlay_colors.gd (pure ramp/shape lookups), atlas_map_protocol.gd (atlas/starmap/citynames codec, the browse_protocol.gd delegate pattern). Public APIs preserved exactly; _gen_state stays a field default (RefCounted, pre-_ready safe) because _ready()-construction breaks every bare AtlasViewer.new() test — documented inline. Tests: registration + round-trip for both overlays; pure-function suites for the temp ramp (endpoints/midpoint/clamp/sentinel) and quarter glyph (scaling, zoom gate, ramp, notch across all 9 DistrictTypes); Tier-2 replay asserts exact literals from the real server-generated fixture incl. the airless sentinel. Color.lerp(a,b, 1.0) is not bit-exact to b — endpoint assertions use per-component is_equal_approx. Full suite 3094/3094; gdlint zero warnings incl. the two previously-over-cap files. Live capture: legend grows to 7 sections, TMP/QTR toggles clean against a live server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
class_name AtlasMapProtocol
|
||||
## AtlasLayerRequest/Response, StarMapRequest/Response, CityNamesRequest/Response
|
||||
## codec — factored out of protocol.gd (T-1118) to stay under gdlint's
|
||||
## max-file-lines cap, same rationale + shape as browse_protocol.gd
|
||||
## (T-1131/T-1133): `mp` (the loaded messagepack.gd module) is passed in
|
||||
## rather than reloaded here — protocol.gd's _mp() already owns that load().
|
||||
##
|
||||
## Protocol delegates every one of these under the SAME public name
|
||||
## (Protocol.atlas_response_from_raw(), Protocol.encode_star_map_request(),
|
||||
## etc.) via its _amp() accessor — external callers (sim_bridge.gd,
|
||||
## test_atlas_overlays.gd, test_atlas_data_delivery.gd) are unaffected by the
|
||||
## move; only where the body lives changed.
|
||||
|
||||
|
||||
## Encode an AtlasLayerRequest (#969, D-225) for the layer-stream proxy.
|
||||
## 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").
|
||||
static func encode_atlas_layer_request(
|
||||
mp, body_id: String, up_to: String = "Topography"
|
||||
) -> PackedByteArray:
|
||||
var msg := {"body_id": body_id, "up_to": up_to}
|
||||
var result = mp.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
## Build an AtlasLayerResponse from an already-decoded raw value. Returns null
|
||||
## if it is not an atlas response (no "status" key).
|
||||
## road_graph/settlements (T-960): passthrough fields for the L2 road/rail
|
||||
## graph and L3 settlement placements, mirroring the district_grid precedent
|
||||
## (T-1046) — raw decoded maps/arrays, no further client-side reshaping.
|
||||
## region_grid (T-1113/T-1118): the region climate grid, same passthrough
|
||||
## pattern. quarter_footprints (T-1119, D-226 T-1112 amendment touch point 3):
|
||||
## 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.
|
||||
static func atlas_response_from_raw(raw: Variant) -> Variant:
|
||||
if not raw is Dictionary or not raw.has("status"):
|
||||
return null
|
||||
var status_raw = raw["status"]
|
||||
var status := ""
|
||||
var error := ""
|
||||
if status_raw is String:
|
||||
status = status_raw
|
||||
elif status_raw is Dictionary and status_raw.has("Error"):
|
||||
status = "Error"
|
||||
error = str(status_raw["Error"])
|
||||
return {
|
||||
"body_id": raw.get("body_id", ""),
|
||||
"status": status,
|
||||
"error": error,
|
||||
"layer1": raw.get("layer1"),
|
||||
"district_grid": raw.get("district_grid"),
|
||||
"road_graph": raw.get("road_graph"),
|
||||
"settlements": raw.get("settlements"),
|
||||
"region_grid": raw.get("region_grid"),
|
||||
"quarter_footprints": raw.get("quarter_footprints"),
|
||||
}
|
||||
|
||||
|
||||
## Decode a status enum shared by StarMapStatus/CityNamesStatus/AtlasLayerStatus
|
||||
## shape: a unit variant is a bare string ("Ready", "SolExcluded", …); the one
|
||||
## data variant (Error(String)) is a single-key map {"Error": "message"}.
|
||||
## Returns {"status": String, "error": String} (error empty unless Error).
|
||||
static func _decode_status_field(status_raw: Variant) -> Dictionary:
|
||||
if status_raw is String:
|
||||
return {"status": status_raw, "error": ""}
|
||||
if status_raw is Dictionary and status_raw.has("Error"):
|
||||
return {"status": "Error", "error": str(status_raw["Error"])}
|
||||
return {"status": "", "error": ""}
|
||||
|
||||
|
||||
## Encode a StarMapRequest (T-949, D-010) for the Reach-level star-map proxy.
|
||||
## `star_map: true` is the mandatory discriminator field the server's demux
|
||||
## matches on (dudley-atlas-server contract, 2026-07-14) — always send it,
|
||||
## never omit it, or the frame can't be routed.
|
||||
static func encode_star_map_request(mp) -> PackedByteArray:
|
||||
var msg := {"star_map": true}
|
||||
var result = mp.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_star_map_request failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
## Build a StarMapResponse from an already-decoded raw value. Returns null if
|
||||
## it is not a star-map response (no "status" key). `data` is a verbatim
|
||||
## MessagePack re-encoding of star_map_data.json's own top-level shape
|
||||
## (`_meta`/`nodes`/`edges`) — unwrapped here so callers (SystemIndex) see the
|
||||
## same {"nodes": [...]} shape they'd have gotten from the raw file, and never
|
||||
## need to know about the status/data envelope.
|
||||
static func star_map_response_from_raw(raw: Variant) -> Variant:
|
||||
if not raw is Dictionary or not raw.has("status"):
|
||||
return null
|
||||
var decoded_status := _decode_status_field(raw.get("status"))
|
||||
var nodes: Array = []
|
||||
if decoded_status["status"] == "Ready":
|
||||
var data: Variant = raw.get("data")
|
||||
if data is Dictionary:
|
||||
nodes = data.get("nodes", [])
|
||||
return {"status": decoded_status["status"], "error": decoded_status["error"], "nodes": nodes}
|
||||
|
||||
|
||||
## Encode a CityNamesRequest (T-949, D-223/D-236) for one body's atlas
|
||||
## city-name pool. `city_names: true` is the mandatory discriminator field
|
||||
## (same contract as StarMapRequest) — without it the request is structurally
|
||||
## ambiguous with a malformed AtlasLayerRequest (missing `up_to`). Sent for
|
||||
## every body INCLUDING Sol — the server itself reports SolExcluded for those
|
||||
## (D-236) as a defensive backstop; atlas_viewer.gd's own guard is expected to
|
||||
## make that path rare, not load-bearing on its own.
|
||||
static func encode_city_names_request(mp, body_id: String) -> PackedByteArray:
|
||||
var msg := {"city_names": true, "body_id": body_id}
|
||||
var result = mp.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_city_names_request failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
## Build a CityNamesResponse from an already-decoded raw value. Returns null
|
||||
## unless it carries both "body_id" and "status". `cities` is a flat array of
|
||||
## {city_id, name, is_capital} — no position (that comes from SettlementLayer,
|
||||
## T-960's gen_l3_settlements). status is one of "Ready" | "SolExcluded" |
|
||||
## "Error" (see _decode_status_field) — SolExcluded means the caller must fall
|
||||
## back to the legacy markers.json read for that body (D-236).
|
||||
static func city_names_response_from_raw(raw: Variant) -> Variant:
|
||||
if not raw is Dictionary or not raw.has("body_id") or not raw.has("status"):
|
||||
return null
|
||||
var decoded_status := _decode_status_field(raw.get("status"))
|
||||
return {
|
||||
"body_id": str(raw.get("body_id", "")),
|
||||
"status": decoded_status["status"],
|
||||
"error": decoded_status["error"],
|
||||
"cities": raw.get("cities", []),
|
||||
}
|
||||
@@ -25,6 +25,12 @@ static func _bp():
|
||||
return load("res://scripts/protocol/browse_protocol.gd")
|
||||
|
||||
|
||||
## AtlasLayerRequest/Response + StarMapRequest/Response + CityNamesRequest/Response
|
||||
## codec (T-1118) — same load()-by-path rationale as _bp() above.
|
||||
static func _amp():
|
||||
return load("res://scripts/protocol/atlas_map_protocol.gd")
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
|
||||
|
||||
@@ -764,105 +770,41 @@ static func encode_request_bookmark_catalog() -> PackedByteArray:
|
||||
return result.value
|
||||
|
||||
|
||||
## Encode an AtlasLayerRequest (#969, D-225) for the layer-stream proxy.
|
||||
## 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").
|
||||
## AtlasLayerRequest/Response + StarMapRequest/Response + CityNamesRequest/
|
||||
## Response codec — factored into atlas_map_protocol.gd (T-1118) to stay
|
||||
## under gdlint's max-file-lines, same rationale as _bp()/browse_protocol.gd
|
||||
## 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.
|
||||
static func encode_atlas_layer_request(
|
||||
body_id: String, up_to: String = "Topography"
|
||||
) -> PackedByteArray:
|
||||
var msg := {"body_id": body_id, "up_to": up_to}
|
||||
var result = _mp().encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
return _amp().encode_atlas_layer_request(_mp(), body_id, up_to)
|
||||
|
||||
|
||||
## Decode an AtlasLayerResponse (#969, D-225). Returns a Dictionary
|
||||
## {body_id, status, error, layer1}, or null if the bytes are not an atlas
|
||||
## response (no "status" key — e.g. an ObserverSnapshot). status is the variant
|
||||
## name ("Ready"|"Pending"|"NotFound"|"Error"); error holds the message for the
|
||||
## Error variant. layer1 is the raw decoded Layer1Output map, or null.
|
||||
## {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).
|
||||
static func decode_atlas_layer_response(bytes: PackedByteArray) -> Variant:
|
||||
return atlas_response_from_raw(decode_raw(bytes))
|
||||
|
||||
|
||||
## Build an AtlasLayerResponse from an already-decoded raw value. Returns null
|
||||
## if it is not an atlas response (no "status" key).
|
||||
## road_graph/settlements (T-960): passthrough fields for the L2 road/rail
|
||||
## graph and L3 settlement placements, mirroring the district_grid precedent
|
||||
## (T-1046) — raw decoded maps/arrays, no further client-side reshaping.
|
||||
## region_grid (T-1113): the region climate grid, same passthrough pattern.
|
||||
## Key names "road_graph"/"settlements"/"region_grid" are the CONFIRMED wire
|
||||
## contract — identical to server/src/atlas/layer_proxy.rs AtlasLayerResponse's
|
||||
## field names (pinned 2026-07-14; 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.
|
||||
## Build an AtlasLayerResponse from an already-decoded raw value. See
|
||||
## atlas_map_protocol.gd for the full field-by-field wire-shape rationale.
|
||||
static func atlas_response_from_raw(raw: Variant) -> Variant:
|
||||
if not raw is Dictionary or not raw.has("status"):
|
||||
return null
|
||||
var status_raw = raw["status"]
|
||||
var status := ""
|
||||
var error := ""
|
||||
if status_raw is String:
|
||||
status = status_raw
|
||||
elif status_raw is Dictionary and status_raw.has("Error"):
|
||||
status = "Error"
|
||||
error = str(status_raw["Error"])
|
||||
return {
|
||||
"body_id": raw.get("body_id", ""),
|
||||
"status": status,
|
||||
"error": error,
|
||||
"layer1": raw.get("layer1"),
|
||||
"district_grid": raw.get("district_grid"),
|
||||
"road_graph": raw.get("road_graph"),
|
||||
"settlements": raw.get("settlements"),
|
||||
"region_grid": raw.get("region_grid"),
|
||||
}
|
||||
|
||||
|
||||
## Decode a status enum shared by StarMapStatus/CityNamesStatus/AtlasLayerStatus
|
||||
## shape: a unit variant is a bare string ("Ready", "SolExcluded", …); the one
|
||||
## data variant (Error(String)) is a single-key map {"Error": "message"}.
|
||||
## Returns {"status": String, "error": String} (error empty unless Error).
|
||||
static func _decode_status_field(status_raw: Variant) -> Dictionary:
|
||||
if status_raw is String:
|
||||
return {"status": status_raw, "error": ""}
|
||||
if status_raw is Dictionary and status_raw.has("Error"):
|
||||
return {"status": "Error", "error": str(status_raw["Error"])}
|
||||
return {"status": "", "error": ""}
|
||||
return _amp().atlas_response_from_raw(raw)
|
||||
|
||||
|
||||
## Encode a StarMapRequest (T-949, D-010) for the Reach-level star-map proxy.
|
||||
## `star_map: true` is the mandatory discriminator field the server's demux
|
||||
## matches on (dudley-atlas-server contract, 2026-07-14) — always send it,
|
||||
## never omit it, or the frame can't be routed.
|
||||
static func encode_star_map_request() -> PackedByteArray:
|
||||
var msg := {"star_map": true}
|
||||
var result = _mp().encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_star_map_request failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
return _amp().encode_star_map_request(_mp())
|
||||
|
||||
|
||||
## Build a StarMapResponse from an already-decoded raw value. Returns null if
|
||||
## it is not a star-map response (no "status" key). `data` is a verbatim
|
||||
## MessagePack re-encoding of star_map_data.json's own top-level shape
|
||||
## (`_meta`/`nodes`/`edges`) — unwrapped here so callers (SystemIndex) see the
|
||||
## same {"nodes": [...]} shape they'd have gotten from the raw file, and never
|
||||
## need to know about the status/data envelope.
|
||||
## Build a StarMapResponse from an already-decoded raw value. See
|
||||
## atlas_map_protocol.gd for the data-unwrapping rationale.
|
||||
static func star_map_response_from_raw(raw: Variant) -> Variant:
|
||||
if not raw is Dictionary or not raw.has("status"):
|
||||
return null
|
||||
var decoded_status := _decode_status_field(raw.get("status"))
|
||||
var nodes: Array = []
|
||||
if decoded_status["status"] == "Ready":
|
||||
var data: Variant = raw.get("data")
|
||||
if data is Dictionary:
|
||||
nodes = data.get("nodes", [])
|
||||
return {"status": decoded_status["status"], "error": decoded_status["error"], "nodes": nodes}
|
||||
return _amp().star_map_response_from_raw(raw)
|
||||
|
||||
|
||||
## Decode a StarMapResponse from MessagePack bytes. See star_map_response_from_raw.
|
||||
@@ -871,37 +813,15 @@ static func decode_star_map_response(bytes: PackedByteArray) -> Variant:
|
||||
|
||||
|
||||
## Encode a CityNamesRequest (T-949, D-223/D-236) for one body's atlas
|
||||
## city-name pool. `city_names: true` is the mandatory discriminator field
|
||||
## (same contract as StarMapRequest) — without it the request is structurally
|
||||
## ambiguous with a malformed AtlasLayerRequest (missing `up_to`). Sent for
|
||||
## every body INCLUDING Sol — the server itself reports SolExcluded for those
|
||||
## (D-236) as a defensive backstop; atlas_viewer.gd's own guard is expected to
|
||||
## make that path rare, not load-bearing on its own.
|
||||
## city-name pool.
|
||||
static func encode_city_names_request(body_id: String) -> PackedByteArray:
|
||||
var msg := {"city_names": true, "body_id": body_id}
|
||||
var result = _mp().encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_city_names_request failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
return _amp().encode_city_names_request(_mp(), body_id)
|
||||
|
||||
|
||||
## Build a CityNamesResponse from an already-decoded raw value. Returns null
|
||||
## unless it carries both "body_id" and "status". `cities` is a flat array of
|
||||
## {city_id, name, is_capital} — no position (that comes from SettlementLayer,
|
||||
## T-960's gen_l3_settlements). status is one of "Ready" | "SolExcluded" |
|
||||
## "Error" (see _decode_status_field) — SolExcluded means the caller must fall
|
||||
## back to the legacy markers.json read for that body (D-236).
|
||||
## Build a CityNamesResponse from an already-decoded raw value. See
|
||||
## atlas_map_protocol.gd for the SolExcluded/cities-shape rationale.
|
||||
static func city_names_response_from_raw(raw: Variant) -> Variant:
|
||||
if not raw is Dictionary or not raw.has("body_id") or not raw.has("status"):
|
||||
return null
|
||||
var decoded_status := _decode_status_field(raw.get("status"))
|
||||
return {
|
||||
"body_id": str(raw.get("body_id", "")),
|
||||
"status": decoded_status["status"],
|
||||
"error": decoded_status["error"],
|
||||
"cities": raw.get("cities", []),
|
||||
}
|
||||
return _amp().city_names_response_from_raw(raw)
|
||||
|
||||
|
||||
## Decode a CityNamesResponse from MessagePack bytes. See city_names_response_from_raw.
|
||||
|
||||
@@ -9,6 +9,10 @@ extends GdUnitTestSuite
|
||||
# resource rather than a global identifier.
|
||||
const LegendPanelScript := preload("res://ui/implant/apps/atlas/atlas_legend_panel.gd")
|
||||
|
||||
# T-1118/T-1119 pure color-ramp/shape-selection helpers (no class_name, same
|
||||
# rationale as LegendPanelScript above) — see atlas_overlay_colors.gd.
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
|
||||
|
||||
func test_generation_overlays_registered() -> void:
|
||||
var ids: Array = []
|
||||
@@ -141,9 +145,193 @@ func test_settlements_round_trip() -> void:
|
||||
assert_that((decoded as Dictionary).get("settlements")).is_equal(settlements)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1118: gen_region_grid overlay (region climate grid, mean-temp channel)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_region_grid_overlay_registered() -> void:
|
||||
var ids: Array = []
|
||||
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
|
||||
ids.append(d["id"])
|
||||
assert_that(ids).contains(["gen_region_grid"])
|
||||
|
||||
|
||||
## RegionGridLayer shape pinned to server/src/atlas/layer_proxy.rs (T-1113):
|
||||
## dense row-major, mean_temp_dc is deci-degC with REGION_TEMP_NONE_DC
|
||||
## (i16::MIN) the airless sentinel.
|
||||
func test_region_grid_round_trips() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
assert_that(v.get_generation_region_grid()).is_null()
|
||||
var grid := {
|
||||
"cols": 2,
|
||||
"rows": 1,
|
||||
"season": [0, 3],
|
||||
"weather": [0, 2],
|
||||
"mean_temp_dc": [123, AtlasOverlayColors.REGION_TEMP_NONE_DC],
|
||||
"moisture_q": [80, 5],
|
||||
}
|
||||
v.set_generation_region_grid(grid)
|
||||
assert_that(v.get_generation_region_grid()).is_equal(grid)
|
||||
var decoded: Variant = Protocol.atlas_response_from_raw(
|
||||
{"body_id": "GJ1c", "status": "Ready", "region_grid": grid}
|
||||
)
|
||||
assert_that((decoded as Dictionary).get("region_grid")).is_equal(grid)
|
||||
|
||||
|
||||
## AtlasOverlayColors.region_temp_color() pure ramp — the one visual channel
|
||||
## this ticket ships (mean temp only; season/weather/moisture deferred).
|
||||
## Compares components with is_equal_approx() rather than whole-Color
|
||||
## is_equal(): Godot's Color.lerp(a, b, 1.0) is NOT bit-exact to b (confirmed
|
||||
## empirically — the two print identically but == is false at the ULP
|
||||
## level), so an exact Color equality check is the wrong tool at a lerp
|
||||
## boundary regardless of whether the ramp math itself is correct.
|
||||
func test_region_temp_color_ramp() -> void:
|
||||
# Cold end clamps to pure cold color.
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(AtlasOverlayColors.REGION_TEMP_MIN_DC),
|
||||
AtlasOverlayColors.COLOR_REGION_TEMP_COLD
|
||||
)
|
||||
# Hot end clamps to pure hot color.
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(AtlasOverlayColors.REGION_TEMP_MAX_DC),
|
||||
AtlasOverlayColors.COLOR_REGION_TEMP_HOT
|
||||
)
|
||||
# Midpoint (0.0 C) lands on the mid color.
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(0), AtlasOverlayColors.COLOR_REGION_TEMP_MID
|
||||
)
|
||||
# Out-of-band readings clamp rather than extrapolate past the endpoints.
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(-9999), AtlasOverlayColors.COLOR_REGION_TEMP_COLD
|
||||
)
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.region_temp_color(9999), AtlasOverlayColors.COLOR_REGION_TEMP_HOT
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
## The airless sentinel is a SKIP-CELL disposition (documented on
|
||||
## REGION_TEMP_NONE_DC and _draw_gen_region_grid) — the renderer never calls
|
||||
## region_temp_color() for it at all, so there is no "sentinel color" to
|
||||
## assert on. This test instead pins the sentinel's numeric identity, which
|
||||
## is what _draw_gen_region_grid's equality check depends on.
|
||||
func test_region_temp_none_sentinel_is_i16_min() -> void:
|
||||
assert_int(AtlasOverlayColors.REGION_TEMP_NONE_DC).is_equal(-32768)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1119: gen_l4_quarters overlay (quarter-footprint glyph, D-226 T-1112 amendment)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_quarter_footprints_overlay_registered() -> void:
|
||||
var ids: Array = []
|
||||
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
|
||||
ids.append(d["id"])
|
||||
assert_that(ids).contains(["gen_l4_quarters"])
|
||||
|
||||
|
||||
## QuarterFootprintLayer shape pinned to server/src/atlas/layer_proxy.rs
|
||||
## (D-226 T-1112 amendment SS1): entries keyed by city_id (BTreeMap<u64,_> on
|
||||
## the wire, decodes to a Dictionary with int keys), five scalar u8/enum
|
||||
## fields per entry, no per-block detail (SS2 hard ceiling).
|
||||
func test_quarter_footprints_round_trips() -> void:
|
||||
var v: AtlasViewer = auto_free(AtlasViewer.new())
|
||||
assert_that(v.get_generation_quarter_footprints()).is_null()
|
||||
var footprints := {
|
||||
"entries":
|
||||
{
|
||||
1:
|
||||
{
|
||||
"city_id": 1,
|
||||
"density_avg_pct": 62,
|
||||
"dominant_district_type": "Commercial",
|
||||
"dominant_zoning": "Commercial",
|
||||
"landmark_count": 3,
|
||||
"corridor_count": 4,
|
||||
},
|
||||
2:
|
||||
{
|
||||
"city_id": 2,
|
||||
"density_avg_pct": 18,
|
||||
"dominant_district_type": "Residential",
|
||||
"dominant_zoning": "Residential",
|
||||
"landmark_count": 0,
|
||||
"corridor_count": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
v.set_generation_quarter_footprints(footprints)
|
||||
assert_that(v.get_generation_quarter_footprints()).is_equal(footprints)
|
||||
var decoded: Variant = Protocol.atlas_response_from_raw(
|
||||
{"body_id": "GJ1c", "status": "Ready", "quarter_footprints": footprints}
|
||||
)
|
||||
assert_that((decoded as Dictionary).get("quarter_footprints")).is_equal(footprints)
|
||||
|
||||
|
||||
## AtlasOverlayColors.quarter_glyph_size() — density-scaled, clamped, and
|
||||
## forced to minimum when the zoom gate (show_notch) is off regardless of
|
||||
## density (D-226 T-1112 amendment SS3: "below [the threshold], draws at
|
||||
## minimum size with color only").
|
||||
func test_quarter_glyph_size() -> void:
|
||||
# 0% density, notch shown -> base size.
|
||||
assert_float(AtlasOverlayColors.quarter_glyph_size(0, true)).is_equal_approx(
|
||||
AtlasOverlayColors.QUARTER_GLYPH_MIN_SIZE, 0.001
|
||||
)
|
||||
# 100% density, notch shown -> max size (4.0 + 1.0*6.0 = 10.0, under the 12.0 cap).
|
||||
assert_float(AtlasOverlayColors.quarter_glyph_size(100, true)).is_equal_approx(10.0, 0.001)
|
||||
# Below the zoom gate: always minimum, regardless of density.
|
||||
assert_float(AtlasOverlayColors.quarter_glyph_size(100, false)).is_equal_approx(
|
||||
AtlasOverlayColors.QUARTER_GLYPH_MIN_SIZE, 0.001
|
||||
)
|
||||
# Fixture literals: city 1 (62%) and city 2 (18%), both notch-shown.
|
||||
assert_float(AtlasOverlayColors.quarter_glyph_size(62, true)).is_greater(
|
||||
AtlasOverlayColors.quarter_glyph_size(18, true)
|
||||
)
|
||||
|
||||
|
||||
## AtlasOverlayColors.quarter_glyph_color() — single-hue ramp within the
|
||||
## settlement-gold family, endpoints pinned to the D-226 T-1112 amendment
|
||||
## SS3's literal legend colors. Approx compare (see test_region_temp_color_ramp's
|
||||
## comment) — a lerp(a, b, 1.0) isn't guaranteed bit-exact to b for every
|
||||
## color pair, and exact equality shouldn't depend on which pair happens to
|
||||
## round losslessly.
|
||||
func test_quarter_glyph_color_ramp() -> void:
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.quarter_glyph_color(0), AtlasOverlayColors.COLOR_QUARTER_LOW_DENSITY
|
||||
)
|
||||
_assert_color_approx(
|
||||
AtlasOverlayColors.quarter_glyph_color(100), AtlasOverlayColors.COLOR_QUARTER_HIGH_DENSITY
|
||||
)
|
||||
|
||||
|
||||
## AtlasOverlayColors.quarter_notch_kind() — the 3-variant-plus-plain cap
|
||||
## (D-226 T-1112 amendment SS3: "a coarse skeleton read, not a legend of
|
||||
## every DistrictType"). Every other DistrictType — including an unknown/
|
||||
## empty string — falls through to "plain".
|
||||
func test_quarter_notch_kind_selection() -> void:
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("Commercial")).is_equal("commercial")
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("Industrial")).is_equal("industrial")
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("Administrative")).is_equal("administrative")
|
||||
for other in ["LogisticsHub", "Residential", "Entertainment", "MixedUse", "Transit", "Specialized"]:
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind(other)).override_failure_message(
|
||||
"DistrictType '%s' should read as plain (not one of the 3 marked variants)" % other
|
||||
).is_equal("plain")
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("")).is_equal("plain")
|
||||
assert_str(AtlasOverlayColors.quarter_notch_kind("SomeUnrecognizedFutureVariant")).is_equal("plain")
|
||||
|
||||
|
||||
## Tier-2 replay: a REAL server-generated msgpack blob (server/tests/gen_fixtures.rs
|
||||
## generate_atlas_layer_response_fixtures, regenerated 2026-07-14 with T-960's
|
||||
## road_graph/settlements populated) decoded through the actual client path —
|
||||
## generate_atlas_layer_response_fixtures, regenerated 2026-07-18 with T-960's
|
||||
## road_graph/settlements, T-1118's region_grid, and T-1119's
|
||||
## quarter_footprints all populated) decoded through the actual client path —
|
||||
## the strongest check that protocol.gd's decode matches the server's wire
|
||||
## encoding, not just a hand-authored Dictionary the client wrote itself.
|
||||
func test_atlas_response_ready_fixture_decodes_road_graph_and_settlements() -> void:
|
||||
@@ -180,6 +368,83 @@ func test_atlas_response_ready_fixture_decodes_road_graph_and_settlements() -> v
|
||||
assert_str(minor.get("size_class")).is_equal("Minor")
|
||||
assert_bool(minor.get("is_capital")).is_false()
|
||||
|
||||
# T-1118: region_grid, a 2x1 grid (dudley-depth's fixture literal,
|
||||
# 2026-07-18) — col0 Summer/Clear/12.3C(123 deci-C)/moisture 80, col1
|
||||
# Winter/Snow/airless-sentinel/moisture 5. season/weather/mean_temp_dc/
|
||||
# moisture_q are all "dense array of ints" fields (rmp_serde Vec<u8>/
|
||||
# Vec<i16> with no serde_bytes, so NOT bin_8/16/32 on the wire) — read
|
||||
# element-wise via int() rather than asserting a specific Godot container
|
||||
# type, since the addon decodes a plain msgpack array as Array, not
|
||||
# PackedByteArray (see _dense_int helper below).
|
||||
var region_grid: Dictionary = response.get("region_grid")
|
||||
assert_that(region_grid).is_not_null()
|
||||
assert_int(int(region_grid.get("cols", 0))).is_equal(2)
|
||||
assert_int(int(region_grid.get("rows", 0))).is_equal(1)
|
||||
assert_int(_dense_int(region_grid.get("mean_temp_dc"), 0)).is_equal(123)
|
||||
assert_int(_dense_int(region_grid.get("mean_temp_dc"), 1)).is_equal(
|
||||
AtlasOverlayColors.REGION_TEMP_NONE_DC
|
||||
)
|
||||
assert_int(_dense_int(region_grid.get("moisture_q"), 0)).is_equal(80)
|
||||
assert_int(_dense_int(region_grid.get("moisture_q"), 1)).is_equal(5)
|
||||
|
||||
# T-1119: quarter_footprints, keyed by city_id (BTreeMap<u64,_> on the
|
||||
# wire -> Dictionary with int keys). city_id 1 (Port Aldren, capital) gets
|
||||
# a "rich" entry; city_id 2 (Farmstead Rell, minor) gets a deliberately
|
||||
# sparse one (zero landmarks, one corridor) — both present, per
|
||||
# dudley-depth's fixture literal, exercising the subset-safety contract
|
||||
# (entries can legitimately be a SUBSET of settlements — not tested here
|
||||
# since both happen to be present in this fixture, but the accessor path
|
||||
# must not assume 1:1).
|
||||
var quarters: Dictionary = response.get("quarter_footprints")
|
||||
assert_that(quarters).is_not_null()
|
||||
var qf_entries: Dictionary = quarters.get("entries", {})
|
||||
assert_int(qf_entries.size()).is_equal(2)
|
||||
var rich: Dictionary = qf_entries[1]
|
||||
assert_int(int(rich.get("density_avg_pct", -1))).is_equal(62)
|
||||
assert_str(str(rich.get("dominant_district_type", ""))).is_equal("Commercial")
|
||||
assert_str(str(rich.get("dominant_zoning", ""))).is_equal("Commercial")
|
||||
assert_int(int(rich.get("landmark_count", -1))).is_equal(3)
|
||||
assert_int(int(rich.get("corridor_count", -1))).is_equal(4)
|
||||
var sparse: Dictionary = qf_entries[2]
|
||||
assert_int(int(sparse.get("density_avg_pct", -1))).is_equal(18)
|
||||
assert_str(str(sparse.get("dominant_district_type", ""))).is_equal("Residential")
|
||||
assert_str(str(sparse.get("dominant_zoning", ""))).is_equal("Residential")
|
||||
assert_int(int(sparse.get("landmark_count", -1))).is_equal(0)
|
||||
assert_int(int(sparse.get("corridor_count", -1))).is_equal(1)
|
||||
|
||||
|
||||
## pending/not_found fixtures carry region_grid/quarter_footprints as None too
|
||||
## (mirrors every other Option field's "unrun layer" treatment) — a quick
|
||||
## sanity check that the new fields don't silently break the OTHER two
|
||||
## fixtures' decode (they were regenerated in the same batch).
|
||||
func test_atlas_response_pending_and_not_found_fixtures_have_no_new_layers() -> void:
|
||||
for fixture_name in ["atlas_response_pending", "atlas_response_not_found"]:
|
||||
var path := "res://tests/fixtures/msgpack/%s.msgpack" % fixture_name
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
assert_that(f).override_failure_message("missing fixture %s" % fixture_name).is_not_null()
|
||||
var bytes := f.get_buffer(f.get_length())
|
||||
f.close()
|
||||
var decoded: Variant = Protocol.decode_atlas_layer_response(bytes)
|
||||
assert_that(decoded).is_not_null()
|
||||
var response: Dictionary = decoded
|
||||
assert_that(response.get("region_grid")).override_failure_message(
|
||||
"%s should carry no region_grid" % fixture_name
|
||||
).is_null()
|
||||
assert_that(response.get("quarter_footprints")).override_failure_message(
|
||||
"%s should carry no quarter_footprints" % fixture_name
|
||||
).is_null()
|
||||
|
||||
|
||||
## Reads element `i` from a decoded "dense numeric array" field regardless of
|
||||
## whether the messagepack addon produced a PackedByteArray (bin_8/16/32) or a
|
||||
## plain Array (fixarray/array_16/array_32) — rmp_serde without serde_bytes
|
||||
## encodes Vec<u8>/Vec<i16> as the latter, so this is the defensively-correct
|
||||
## read for region_grid's season/weather/mean_temp_dc/moisture_q.
|
||||
static func _dense_int(arr: Variant, i: int) -> int:
|
||||
if arr is Array or arr is PackedByteArray:
|
||||
return int(arr[i])
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# D-226 item 3: generation legend panel
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
extends Node
|
||||
|
||||
## Generation-cascade layer-stream proxy client for AtlasViewer (#960, D-225).
|
||||
## Extracted from atlas_viewer.gd (T-1118) to keep that file under the gdlint
|
||||
## max-file-lines cap — this is the whole "poll the proxy, retry on Pending,
|
||||
## show the diegetic GENERATING indicator, dispatch each layer field to
|
||||
## AtlasGenerationState on Ready" responsibility, self-contained apart from
|
||||
## reading the owning viewer's `_body`/`size` and calling its
|
||||
## set_generation_*() accessors.
|
||||
##
|
||||
## This script has no `class_name` on purpose, matching atlas_overlay_bar.gd/
|
||||
## atlas_legend_panel.gd (review #8 there): the owner (AtlasViewer) passes the
|
||||
## viewer reference to _init(), and a `class_name` + required-arg _init()
|
||||
## combo is a Godot editor footgun. `extends Node` (not RefCounted) because
|
||||
## this owns a child Control (the pending indicator) and needs get_tree() for
|
||||
## the retry timer — added as a child via
|
||||
## load("res://ui/implant/apps/atlas/atlas_generation_proxy.gd").new(self).
|
||||
|
||||
# #960: Layer-1 proxy re-poll. The proxy returns Pending on a cache miss and
|
||||
# generates in the background (D-225); the client re-requests until Ready.
|
||||
const GEN_RETRY_DELAY: float = 0.5
|
||||
const GEN_MAX_RETRIES: int = 20 # ~10s ceiling before giving up
|
||||
|
||||
var _viewer = null # AtlasViewer (untyped to avoid cyclic ref)
|
||||
var _pending: bool = false # awaiting a Layer1 response (re-polls on Pending)
|
||||
var _retries: int = 0
|
||||
var _indicator = null # ImplantPending — loaded by path, not a class_name dep
|
||||
|
||||
|
||||
func _init(viewer_ref = null) -> void:
|
||||
_viewer = viewer_ref
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if _viewer == null:
|
||||
return
|
||||
# Loaded by path (not `ImplantPending.new()`) so a stale global-class cache
|
||||
# — e.g. a running session that hasn't re-imported after this class was
|
||||
# added — can't fail to parse AtlasViewer and break the atlas from opening
|
||||
# (#960).
|
||||
_indicator = load("res://ui/implant/implant_pending.gd").new()
|
||||
_indicator.name = "GenPending"
|
||||
_viewer.add_child(_indicator)
|
||||
_indicator.apply_implant_theme(_viewer.get_implant_theme())
|
||||
_viewer.resized.connect(reposition_indicator)
|
||||
reposition_indicator()
|
||||
|
||||
|
||||
## Reset polling state for a fresh show_body() call — the caller is
|
||||
## responsible for also resetting the layer data itself
|
||||
## (AtlasGenerationState.reset_layer1()).
|
||||
func reset() -> void:
|
||||
_pending = false
|
||||
_retries = 0
|
||||
if _indicator:
|
||||
_indicator.stop()
|
||||
|
||||
|
||||
## Request the body's Layer-1 cascade output from the server proxy. No-op in
|
||||
## test mode (SimBridge has no server connection) — the overlays simply stay
|
||||
## empty, which is the correct serverless behavior.
|
||||
func request(body_id: String) -> void:
|
||||
if body_id.is_empty():
|
||||
return
|
||||
_pending = true
|
||||
SimBridge.request_atlas_layers(body_id)
|
||||
|
||||
|
||||
## Handle a Layer-1 response. Ignores responses for a stale body (the user
|
||||
## navigated away). On Pending the proxy is still generating, so we re-request
|
||||
## after a short delay until Ready or the retry ceiling.
|
||||
func on_response(response: Dictionary, current_body_id: String) -> void:
|
||||
if str(response.get("body_id", "")) != current_body_id:
|
||||
return
|
||||
match str(response.get("status", "")):
|
||||
"Ready":
|
||||
_pending = false
|
||||
_retries = 0
|
||||
_set_indicator(false)
|
||||
_viewer.set_generation_layer1(response.get("layer1"))
|
||||
_viewer.set_generation_district_grid(response.get("district_grid"))
|
||||
_viewer.set_generation_road_graph(response.get("road_graph"))
|
||||
_viewer.set_generation_settlements(response.get("settlements"))
|
||||
_viewer.set_generation_region_grid(response.get("region_grid"))
|
||||
_viewer.set_generation_quarter_footprints(response.get("quarter_footprints"))
|
||||
"Pending":
|
||||
if _retries < GEN_MAX_RETRIES:
|
||||
_retries += 1
|
||||
_set_indicator(true)
|
||||
_schedule_retry(current_body_id)
|
||||
else:
|
||||
_pending = false # gave up — overlays stay empty
|
||||
_set_indicator(false)
|
||||
_:
|
||||
_pending = false # NotFound / Error — nothing to draw
|
||||
_set_indicator(false)
|
||||
|
||||
|
||||
func _schedule_retry(body_id: String) -> void:
|
||||
var timer := get_tree().create_timer(GEN_RETRY_DELAY)
|
||||
timer.timeout.connect(
|
||||
func() -> void:
|
||||
# Re-request only if still on the same body and still waiting.
|
||||
if _pending and _viewer.get_body_id() == body_id:
|
||||
SimBridge.request_atlas_layers(body_id)
|
||||
)
|
||||
|
||||
|
||||
## Show/hide the diegetic pending indicator. Starting only when not already
|
||||
## visible avoids resetting the sweep on every re-poll.
|
||||
func _set_indicator(on: bool) -> void:
|
||||
if _indicator == null:
|
||||
return
|
||||
if on:
|
||||
if not _indicator.visible:
|
||||
reposition_indicator()
|
||||
_indicator.start("GENERATING LAYER 1")
|
||||
else:
|
||||
_indicator.stop()
|
||||
|
||||
|
||||
func reposition_indicator() -> void:
|
||||
if _indicator == null or _viewer == null:
|
||||
return
|
||||
var sz: Vector2 = _viewer.size
|
||||
_indicator.position = Vector2((sz.x - _indicator.DEFAULT_WIDTH) * 0.5, sz.y * 0.45)
|
||||
@@ -0,0 +1,105 @@
|
||||
extends RefCounted
|
||||
## Generation-cascade layer state for AtlasViewer (#960, D-225).
|
||||
##
|
||||
## Extracted from atlas_viewer.gd (T-1118/T-1119) to keep that file under the
|
||||
## gdlint max-file-lines cap while it keeps growing a new field+accessor pair
|
||||
## per generation layer (layer1, district_grid, road_graph, settlements,
|
||||
## region_grid, quarter_footprints — the AtlasLayerResponse growth-ceiling
|
||||
## note in the D-226 T-1112 amendment names region_grid/quarter_footprints as
|
||||
## the last two candidate sibling Option fields). One `_generation_*` field +
|
||||
## one get/set pair per layer, all following the same "store + redraw the
|
||||
## overlay" shape — collecting them here means that shape is written once
|
||||
## (in _set) instead of once per accessor pair.
|
||||
##
|
||||
## AtlasViewer holds one instance (`_gen_state`) and exposes the SAME public
|
||||
## get_generation_*()/set_generation_*() method names it always has —
|
||||
## external callers (atlas_marker_overlay.gd, test_atlas_overlays.gd) are
|
||||
## unaffected; only the storage moved. Consumed via explicit load() by path
|
||||
## (no class_name), matching atlas_legend_panel.gd/atlas_overlay_bar.gd
|
||||
## (review #8 there): AtlasViewer is itself referenced by class_name in
|
||||
## other scripts, and a second global class_name in this cluster is an
|
||||
## unnecessary addition to the global class cache.
|
||||
|
||||
var _overlay_node: Node2D = null # set once by AtlasViewer._ready() (the redraw target)
|
||||
|
||||
var _layer1: Variant = null # #960: Layer1Output from the proxy (D-225)
|
||||
var _district_grid: Variant = null # T-1046: coarse DistrictGridLayer (D-226)
|
||||
var _road_graph: Variant = null # T-960: RoadGraph layer from the proxy (D-225)
|
||||
var _settlements: Variant = null # T-960: settlement placements from the proxy (D-225)
|
||||
var _region_grid: Variant = null # T-1118: RegionGridLayer climate grid (D-226/T-1113)
|
||||
var _quarter_footprints: Variant = null # T-1119: QuarterFootprintLayer (D-226 T-1112 amendment)
|
||||
|
||||
|
||||
func bind_overlay_node(overlay_node: Node2D) -> void:
|
||||
_overlay_node = overlay_node
|
||||
|
||||
|
||||
func _redraw() -> void:
|
||||
if _overlay_node:
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## Reset every layer to its pre-body-load default. Called from
|
||||
## AtlasViewer.show_body() so a body switch doesn't inherit the previous
|
||||
## body's generation data.
|
||||
func reset_layer1() -> void:
|
||||
_layer1 = null
|
||||
|
||||
|
||||
func set_layer1(layer1: Variant) -> void:
|
||||
_layer1 = layer1
|
||||
_redraw()
|
||||
|
||||
|
||||
func get_layer1() -> Variant:
|
||||
return _layer1
|
||||
|
||||
|
||||
func set_district_grid(grid: Variant) -> void:
|
||||
_district_grid = grid
|
||||
_redraw()
|
||||
|
||||
|
||||
func get_district_grid() -> Variant:
|
||||
return _district_grid
|
||||
|
||||
|
||||
func set_road_graph(graph: Variant) -> void:
|
||||
_road_graph = graph
|
||||
_redraw()
|
||||
|
||||
|
||||
func get_road_graph() -> Variant:
|
||||
return _road_graph
|
||||
|
||||
|
||||
func set_settlements(settlements: Variant) -> void:
|
||||
_settlements = settlements
|
||||
_redraw()
|
||||
|
||||
|
||||
func get_settlements() -> Variant:
|
||||
return _settlements
|
||||
|
||||
|
||||
## T-1118: store the region climate grid (RegionGridLayer) from the proxy and
|
||||
## redraw. The marker overlay reads it via AtlasViewer.get_generation_region_grid().
|
||||
func set_region_grid(grid: Variant) -> void:
|
||||
_region_grid = grid
|
||||
_redraw()
|
||||
|
||||
|
||||
func get_region_grid() -> Variant:
|
||||
return _region_grid
|
||||
|
||||
|
||||
## T-1119: store the L4 quarter-footprint aggregates (QuarterFootprintLayer)
|
||||
## from the proxy and redraw. The marker overlay reads it via
|
||||
## AtlasViewer.get_generation_quarter_footprints().
|
||||
func set_quarter_footprints(footprints: Variant) -> void:
|
||||
_quarter_footprints = footprints
|
||||
_redraw()
|
||||
|
||||
|
||||
func get_quarter_footprints() -> Variant:
|
||||
return _quarter_footprints
|
||||
@@ -34,6 +34,15 @@ const COLOR_ROAD_TRADE: Color = Color(0.85, 0.60, 0.30, 0.85)
|
||||
const COLOR_ROAD_ABANDONED: Color = Color(0.45, 0.42, 0.38, 0.65)
|
||||
const COLOR_SETTLEMENT_GEN: Color = Color(0.94, 0.82, 0.38, 1.0)
|
||||
const COLOR_SETTLEMENT_CAPITAL_GEN: Color = Color(1.0, 0.92, 0.55, 1.0)
|
||||
# T-1118 — region climate grid cold/hot ramp endpoints (matches
|
||||
# atlas_overlay_colors.gd's COLOR_REGION_TEMP_COLD/COLOR_REGION_TEMP_HOT).
|
||||
const COLOR_REGION_TEMP_COLD_GEN: Color = Color(0.25, 0.45, 0.85, 0.60)
|
||||
const COLOR_REGION_TEMP_HOT_GEN: Color = Color(0.90, 0.25, 0.20, 0.60)
|
||||
# T-1119 — quarter-footprint density ramp endpoints, D-226 T-1112 amendment
|
||||
# SS3's literal legend spec (matches atlas_overlay_colors.gd's
|
||||
# COLOR_QUARTER_LOW_DENSITY/COLOR_QUARTER_HIGH_DENSITY).
|
||||
const COLOR_QUARTER_LOW_DENSITY_GEN: Color = Color(0.55, 0.48, 0.30, 0.6)
|
||||
const COLOR_QUARTER_HIGH_DENSITY_GEN: Color = Color(0.94, 0.82, 0.38, 1.0)
|
||||
|
||||
## One entry per generation-overlay id. "color": Color.TRANSPARENT means
|
||||
## "shape/style carries the meaning here, let the theme's dim text color
|
||||
@@ -79,6 +88,15 @@ const GENERATION_LEGEND: Array = [
|
||||
{"glyph": "●", "color": Color(0.55, 0.68, 0.82, 0.90), "label": "cold"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"overlay_id": "gen_region_grid",
|
||||
"title": "REGION CLIMATE — mean temperature (~205 km cells)",
|
||||
"rows": [
|
||||
{"glyph": "▦", "color": COLOR_REGION_TEMP_COLD_GEN, "label": "cold"},
|
||||
{"glyph": "▦", "color": COLOR_REGION_TEMP_HOT_GEN, "label": "hot"},
|
||||
{"glyph": "▦", "color": Color.TRANSPARENT, "label": "airless — no reading (skipped)"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"overlay_id": "gen_district",
|
||||
"title": "DISTRICT MORPHOLOGY — coarse",
|
||||
@@ -114,6 +132,17 @@ const GENERATION_LEGEND: Array = [
|
||||
{"glyph": "★", "color": COLOR_SETTLEMENT_CAPITAL_GEN, "label": "capital / major hub"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"overlay_id": "gen_l4_quarters",
|
||||
"title": "QUARTER FOOTPRINT — L4 (color = density, shape = dominant type)",
|
||||
"rows": [
|
||||
{"glyph": "▪", "color": COLOR_QUARTER_LOW_DENSITY_GEN, "label": "low density"},
|
||||
{"glyph": "▪", "color": COLOR_QUARTER_HIGH_DENSITY_GEN, "label": "high density"},
|
||||
{"glyph": "◪", "color": Color.TRANSPARENT, "label": "commercial (corner tab, top-right)"},
|
||||
{"glyph": "◩", "color": Color.TRANSPARENT, "label": "industrial (corner tab, bottom-right)"},
|
||||
{"glyph": "◈", "color": Color.TRANSPARENT, "label": "administrative (diamond cutout)"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
var _viewer = null # AtlasViewer (untyped to avoid cyclic ref)
|
||||
|
||||
@@ -43,44 +43,15 @@ const COLOR_GEN_BASIN_FILL: Color = Color(0.20, 0.35, 0.55, 0.06)
|
||||
const COLOR_GEN_BASIN_LINE: Color = Color(0.45, 0.65, 0.85, 0.45)
|
||||
const GEN_ATTRACTOR_MIN_STRENGTH: float = 0.15
|
||||
|
||||
## Sub-biome → marker color (Araminta's palette, D-226). Grouped pairs share a
|
||||
## color since they read the same on the map; see _sub_biome_color.
|
||||
const SUB_BIOME_COLORS: Dictionary = {
|
||||
"TropicalWet": Color(0.25, 0.72, 0.65, 0.90), # teal — coastal/tropical
|
||||
"CoastalLowland": Color(0.25, 0.72, 0.65, 0.90),
|
||||
"TemperateForest": Color(0.45, 0.68, 0.45, 0.90), # sage — temperate
|
||||
"TemperateGrassland": Color(0.45, 0.68, 0.45, 0.90),
|
||||
"Desert": Color(0.78, 0.62, 0.35, 0.90), # sand — arid
|
||||
"Savanna": Color(0.78, 0.62, 0.35, 0.90),
|
||||
"Alpine": Color(0.52, 0.58, 0.72, 0.90), # slate — alpine
|
||||
"Wetland": Color(0.40, 0.60, 0.52, 0.90), # muted teal — wetland
|
||||
"Tundra": Color(0.55, 0.68, 0.82, 0.90), # cool grey-blue — cold
|
||||
"BorealForest": Color(0.55, 0.68, 0.82, 0.90),
|
||||
}
|
||||
const COLOR_SUB_BIOME_DEFAULT: Color = Color(0.72, 0.72, 0.76, 0.90) # default grey
|
||||
|
||||
## MorphologyZone discriminant → overlay colour (D-239 §6 order, T-1046).
|
||||
## ~0.55 alpha so the heightmap shows through: water blues, plains greens,
|
||||
## uplands greys/browns, volcanic dark red.
|
||||
const MORPHOLOGY_COLORS: Array = [
|
||||
Color(0.10, 0.20, 0.45, 0.55), # 0 OpenOcean
|
||||
Color(0.20, 0.40, 0.65, 0.55), # 1 Lake
|
||||
Color(0.45, 0.55, 0.50, 0.55), # 2 TidalFlat
|
||||
Color(0.85, 0.78, 0.45, 0.55), # 3 DuneStrand
|
||||
Color(0.50, 0.50, 0.55, 0.55), # 4 CliffCoast
|
||||
Color(0.30, 0.40, 0.50, 0.55), # 5 Fjord
|
||||
Color(0.40, 0.65, 0.60, 0.55), # 6 Delta
|
||||
Color(0.30, 0.55, 0.55, 0.55), # 7 Estuarine
|
||||
Color(0.30, 0.60, 0.30, 0.55), # 8 AlluvialPlain
|
||||
Color(0.45, 0.70, 0.40, 0.55), # 9 RiverBank
|
||||
Color(0.35, 0.60, 0.50, 0.55), # 10 MeanderReach
|
||||
Color(0.55, 0.60, 0.45, 0.55), # 11 BraidedPlain
|
||||
Color(0.50, 0.55, 0.30, 0.55), # 12 ValleyFloor
|
||||
Color(0.55, 0.45, 0.30, 0.55), # 13 MountainPass
|
||||
Color(0.80, 0.82, 0.85, 0.55), # 14 Alpine
|
||||
Color(0.45, 0.15, 0.12, 0.55), # 15 Volcanic
|
||||
Color(0.25, 0.45, 0.40, 0.55), # 16 Wetland
|
||||
]
|
||||
## Pure color-ramp/shape-selection helpers (morphology, sub-biome, road
|
||||
## authority, region temp, quarter density/notch) — factored into
|
||||
## atlas_overlay_colors.gd (T-1118) to stay under gdlint's max-file-lines,
|
||||
## same rationale as atlas_format.gd's static-function + preload pattern.
|
||||
## That file owns the actual constant tables; this file only keeps constants
|
||||
## with no decision function wrapping them (e.g. COLOR_SETTLEMENT — used
|
||||
## directly, not looked up).
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
|
||||
|
||||
# Province boundaries (D-205, #927)
|
||||
const COLOR_PROVINCE_BORDER: Color = Color(0.45, 0.65, 0.85, 0.55)
|
||||
@@ -92,11 +63,6 @@ const PROVINCE_BORDER_WIDTH: float = 1.2
|
||||
# COLOR_ROAD tan (long-haul trade routes ARE the base "road" concept, now
|
||||
# split out by authority) so the new encoding stays visually consistent with
|
||||
# the existing overlay palette instead of introducing an unrelated hue set.
|
||||
const COLOR_ROAD_ADMINISTRATIVE: Color = Color(0.45, 0.65, 0.90, 0.85)
|
||||
const COLOR_ROAD_CORPORATE: Color = Color(0.85, 0.65, 0.20, 0.85)
|
||||
const COLOR_ROAD_COMMUNAL: Color = Color(0.45, 0.75, 0.50, 0.85)
|
||||
const COLOR_ROAD_TRADE: Color = Color(0.85, 0.60, 0.30, 0.85)
|
||||
const COLOR_ROAD_ABANDONED: Color = Color(0.45, 0.42, 0.38, 0.65)
|
||||
const COLOR_ROAD_JUNCTION: Color = Color(0.85, 0.85, 0.90, 0.80)
|
||||
const ROAD_JUNCTION_MIN_DEGREE: int = 3 # mirrors server's JUNCTION_DEGREE (road_graph.rs)
|
||||
|
||||
@@ -180,6 +146,15 @@ func _draw() -> void:
|
||||
if viewer.is_overlay_visible("corp_presence"):
|
||||
_draw_corp_presence(markers)
|
||||
|
||||
# Generation: region climate grid (T-1118, D-243) — the coarsest area fill
|
||||
# (~205 km cells), drawn first among generation overlays so the district
|
||||
# morphology grid and every finer layer below reads on top of it as
|
||||
# background climate context.
|
||||
if viewer.is_overlay_visible("gen_region_grid"):
|
||||
var region_grid: Variant = viewer.get_generation_region_grid()
|
||||
if region_grid is Dictionary:
|
||||
_draw_gen_region_grid(region_grid, tex_w, tex_h)
|
||||
|
||||
# Generation: district morphology grid (T-1046, D-226) — coarse area fill,
|
||||
# drawn under the Layer-1 line/point overlays.
|
||||
if viewer.is_overlay_visible("gen_district"):
|
||||
@@ -215,6 +190,15 @@ func _draw() -> void:
|
||||
var settlements: Variant = viewer.get_generation_settlements()
|
||||
if settlements != null:
|
||||
_draw_gen_settlements(settlements)
|
||||
# T-1119: drawn immediately after gen_l3_settlements so it reads
|
||||
# as "on top of" the city dot it annotates (D-226 T-1112 SS3),
|
||||
# and needs the SAME settlements payload for the city_id->
|
||||
# position join (quarter footprints carry no position of
|
||||
# their own).
|
||||
if viewer.is_overlay_visible("gen_l4_quarters"):
|
||||
var quarters: Variant = viewer.get_generation_quarter_footprints()
|
||||
if quarters is Dictionary:
|
||||
_draw_gen_quarter_footprints(quarters, settlements)
|
||||
|
||||
# POIs (non-gate first, then gates on top if enabled)
|
||||
_draw_pois(markers)
|
||||
@@ -491,13 +475,49 @@ func _draw_gen_district(grid: Dictionary, tex_w: float, tex_h: float) -> void:
|
||||
if i >= n:
|
||||
continue
|
||||
# +0.5 overdraw avoids hairline seams between adjacent cells.
|
||||
draw_rect(Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5), _morphology_color(int(morphology[i])))
|
||||
draw_rect(
|
||||
Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5),
|
||||
AtlasOverlayColors.morphology_color(int(morphology[i]))
|
||||
)
|
||||
|
||||
|
||||
func _morphology_color(zone: int) -> Color:
|
||||
if zone >= 0 and zone < MORPHOLOGY_COLORS.size():
|
||||
return MORPHOLOGY_COLORS[zone]
|
||||
return Color(0.5, 0.5, 0.5, 0.4)
|
||||
## Region climate grid (T-1118, D-243 SS"region"). A coarse `cols x rows` area
|
||||
## fill mirroring _draw_gen_district's SELF-CONTAINED coordinate mapping above
|
||||
## (dims come from the layer dict itself, cw/ch = tex/cols|rows) — this is a
|
||||
## dense row-major grid keyed by its OWN cols/rows, NOT a Layer-1 point
|
||||
## position, so it does NOT use _gen_pos()/_gen_grid_w (that path is for
|
||||
## Layer-1 river/basin/attractor points and the L2/L3 graphs, which share one
|
||||
## working-grid coordinate space; the region grid is its own dense array with
|
||||
## its own extent, exactly like district_grid).
|
||||
##
|
||||
## Channel: mean_temp_dc only (season/weather/moisture deferred to a future
|
||||
## tooltip/variant overlay per the ticket scope — D-226 "one visual channel
|
||||
## first"). Airless-body cells (REGION_TEMP_NONE_DC sentinel) are skipped
|
||||
## entirely, not colored — see the REGION_TEMP_NONE_DC comment above.
|
||||
func _draw_gen_region_grid(grid: Dictionary, tex_w: float, tex_h: float) -> void:
|
||||
var cols: int = int(grid.get("cols", 0))
|
||||
var rows: int = int(grid.get("rows", 0))
|
||||
if cols <= 0 or rows <= 0:
|
||||
return
|
||||
var mean_temp_dc: Variant = grid.get("mean_temp_dc")
|
||||
if not (mean_temp_dc is Array or mean_temp_dc is PackedByteArray):
|
||||
return
|
||||
var cw: float = tex_w / float(cols)
|
||||
var ch: float = tex_h / float(rows)
|
||||
var n: int = mean_temp_dc.size()
|
||||
for ry in range(rows):
|
||||
for rx in range(cols):
|
||||
var i: int = ry * cols + rx
|
||||
if i >= n:
|
||||
continue
|
||||
var temp_dc: int = int(mean_temp_dc[i])
|
||||
if temp_dc == REGION_TEMP_NONE_DC:
|
||||
continue # airless — no temperature to show
|
||||
# +0.5 overdraw avoids hairline seams between adjacent cells.
|
||||
draw_rect(
|
||||
Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5),
|
||||
AtlasOverlayColors.region_temp_color(temp_dc)
|
||||
)
|
||||
|
||||
|
||||
func _draw_gen_rivers(layer1: Dictionary) -> void:
|
||||
@@ -548,18 +568,10 @@ func _draw_gen_attractors(layer1: Dictionary) -> void:
|
||||
if not pos_rc is Array or pos_rc.size() < 2:
|
||||
continue
|
||||
var size: float = 5.0 + strength * 4.0
|
||||
var color: Color = _sub_biome_color(str(a.get("sub_biome", "")))
|
||||
var color: Color = AtlasOverlayColors.sub_biome_color(str(a.get("sub_biome", "")))
|
||||
_draw_attractor_shape(str(a.get("attractor_type", "")), _gen_pos(pos_rc), size, color)
|
||||
|
||||
|
||||
## Sub-biome → marker color (Araminta's palette). Color is additive info; shape
|
||||
## carries the attractor-type identity (survives monochrome capture). Table
|
||||
## lookup (SUB_BIOME_COLORS) rather than a multi-return match — same pattern
|
||||
## as MORPHOLOGY_COLORS/_morphology_color below.
|
||||
func _sub_biome_color(sub_biome: String) -> Color:
|
||||
return SUB_BIOME_COLORS.get(sub_biome, COLOR_SUB_BIOME_DEFAULT)
|
||||
|
||||
|
||||
## Attractor type → marker shape (Araminta's vocabulary, 7 types).
|
||||
func _draw_attractor_shape(atype: String, pos: Vector2, size: float, color: Color) -> void:
|
||||
match atype:
|
||||
@@ -599,25 +611,6 @@ func _draw_triangle(pos: Vector2, size: float, color: Color, point_down: bool) -
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## MaintenanceAuthority (D-211/D-212) → line color. The one color-coded axis
|
||||
## on this overlay — road vs rail is told apart by line style/width instead
|
||||
## (_draw_gen_roads), so authority stays legible on its own.
|
||||
func _road_authority_color(maintenance: String) -> Color:
|
||||
match maintenance:
|
||||
"Administrative":
|
||||
return COLOR_ROAD_ADMINISTRATIVE
|
||||
"Corporate":
|
||||
return COLOR_ROAD_CORPORATE
|
||||
"Communal":
|
||||
return COLOR_ROAD_COMMUNAL
|
||||
"Trade":
|
||||
return COLOR_ROAD_TRADE
|
||||
"Abandoned":
|
||||
return COLOR_ROAD_ABANDONED
|
||||
_:
|
||||
return COLOR_ROAD_TRADE
|
||||
|
||||
|
||||
## Inter-settlement road/rail graph (RoadGraphLayer — D-211, T-1038). Edges
|
||||
## colored by MaintenanceAuthority; rail vs road told apart by line style/
|
||||
## width (dashed + thin = rail, solid + wider = road) rather than a second
|
||||
@@ -645,7 +638,7 @@ func _draw_gen_roads(road_graph: Dictionary) -> void:
|
||||
points.append(_gen_pos(pt))
|
||||
if points.size() < 2:
|
||||
continue
|
||||
var color: Color = _road_authority_color(str(e.get("maintenance", "")))
|
||||
var color: Color = AtlasOverlayColors.road_authority_color(str(e.get("maintenance", "")))
|
||||
if bool(e.get("is_rail", false)):
|
||||
_draw_dashed_polyline(points, color, 1.1)
|
||||
else:
|
||||
@@ -742,6 +735,92 @@ func _draw_star(pos: Vector2, size: float, color: Color) -> void:
|
||||
draw_colored_polygon(pts, color)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Generation-cascade overlays — L4 quarter footprints (T-1119, D-226 T-1112 amendment)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Quarter-footprint aggregates (QuarterFootprintLayer — D-226 T-1112 amendment
|
||||
## SS1/SS4), one density-scaled glyph per city_id anchored at the SAME position
|
||||
## the L3 settlement dot already drew (`_gen_pos` on the settlement's
|
||||
## `position` — quarter footprints carry no independent spatial position per
|
||||
## the amendment, only a `city_id` join key). `entries` is keyed by city_id
|
||||
## (BTreeMap<u64,_> on the wire, decodes to a Dictionary with int keys) and is
|
||||
## a SUBSET of settlements — a placement can exist with no quarter entry yet
|
||||
## (async per-city generation), so a missing city_id is silently skipped, not
|
||||
## an error. `landmark_count`/`corridor_count` are tooltip-only per the D-226
|
||||
## SS2 hard ceiling — never drawn here (city-click sidebar addition, out of
|
||||
## this overlay's scope). Draw call MUST come from the same `settlements`
|
||||
## payload gen_l3_settlements just drew, so this needs the raw settlements
|
||||
## data threaded in from _draw() rather than re-fetching it here.
|
||||
func _draw_gen_quarter_footprints(footprints: Variant, settlements: Variant) -> void:
|
||||
var entries: Dictionary = {}
|
||||
if footprints is Dictionary:
|
||||
entries = footprints.get("entries", {})
|
||||
if entries.is_empty():
|
||||
return
|
||||
|
||||
var settlement_entries: Array = []
|
||||
if settlements is Array:
|
||||
settlement_entries = settlements
|
||||
elif settlements is Dictionary:
|
||||
settlement_entries = settlements.get("settlements", [])
|
||||
if settlement_entries.is_empty():
|
||||
return
|
||||
|
||||
var show_notch: bool = viewer.get_view_zoom() >= SETTLEMENT_LABEL_MIN_ZOOM
|
||||
for s: Variant in settlement_entries:
|
||||
if not s is Dictionary:
|
||||
continue
|
||||
var city_id: int = int(s.get("city_id", -1))
|
||||
if city_id < 0 or not entries.has(city_id):
|
||||
continue
|
||||
var entry: Dictionary = entries[city_id]
|
||||
var pos_rc: Variant = s.get("position")
|
||||
if not pos_rc is Array or pos_rc.size() < 2:
|
||||
continue
|
||||
_draw_quarter_glyph(_gen_pos(pos_rc), entry, show_notch)
|
||||
|
||||
|
||||
## One density-scaled square glyph. Below SETTLEMENT_LABEL_MIN_ZOOM, draws at
|
||||
## minimum size with color only (the notch is illegible at a few px anyway,
|
||||
## per the amendment); at/above it, full size with the dominant-type notch.
|
||||
## Size/color/shape decisions live in AtlasOverlayColors (unit tested there
|
||||
## directly via test_atlas_overlays.gd) — this function is just the
|
||||
## draw_rect/notch calls.
|
||||
func _draw_quarter_glyph(pos: Vector2, entry: Dictionary, show_notch: bool) -> void:
|
||||
var density_pct: int = int(entry.get("density_avg_pct", 0))
|
||||
var side: float = AtlasOverlayColors.quarter_glyph_size(density_pct, show_notch)
|
||||
var color: Color = AtlasOverlayColors.quarter_glyph_color(density_pct)
|
||||
var half: Vector2 = Vector2(side, side) * 0.5
|
||||
draw_rect(Rect2(pos - half, Vector2(side, side)), color)
|
||||
if not show_notch:
|
||||
return
|
||||
var district_type: String = str(entry.get("dominant_district_type", ""))
|
||||
_draw_quarter_notch(pos, half, AtlasOverlayColors.quarter_notch_kind(district_type))
|
||||
|
||||
|
||||
## Draws the notch glyph for a resolved AtlasOverlayColors.quarter_notch_kind() result.
|
||||
func _draw_quarter_notch(pos: Vector2, half: Vector2, kind: String) -> void:
|
||||
var notch_color := Color(0.08, 0.08, 0.08, 0.55)
|
||||
match kind:
|
||||
"commercial":
|
||||
# Corner tab, top-right.
|
||||
var tab: float = half.x * 0.7
|
||||
draw_rect(Rect2(pos + Vector2(half.x - tab, -half.y), Vector2(tab, tab)), notch_color)
|
||||
"industrial":
|
||||
# Corner tab, bottom-right.
|
||||
var tab: float = half.x * 0.7
|
||||
draw_rect(
|
||||
Rect2(pos + Vector2(half.x - tab, half.y - tab), Vector2(tab, tab)), notch_color
|
||||
)
|
||||
"administrative":
|
||||
# Small diamond cutout, center (civic/landmark read).
|
||||
_draw_diamond(pos, half.x * 0.45, notch_color)
|
||||
_:
|
||||
pass # plain square — mixed / no clear dominant
|
||||
|
||||
|
||||
func _path_to_canvas(path: Array) -> PackedVector2Array:
|
||||
var out: PackedVector2Array = PackedVector2Array()
|
||||
for pt: Variant in path:
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
extends RefCounted
|
||||
## Pure color-ramp / shape-selection helpers for the Atlas generation overlays
|
||||
## (T-1118) — factored out of atlas_marker_overlay.gd to keep that file under
|
||||
## gdlint's max-file-lines cap. draw_rect()/draw_circle()/etc are CanvasItem
|
||||
## instance methods called implicitly on `self`, so the actual draw calls
|
||||
## can't move here — only the pure lookups that decide WHAT color/shape to
|
||||
## draw, matching atlas_format.gd's static-function + preload pattern:
|
||||
## const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
##
|
||||
## Every constant/function here is duplicated FROM atlas_marker_overlay.gd
|
||||
## (not the other way — that file still owns the same constants for its own
|
||||
## draw calls and the legend's own copies, matching the existing
|
||||
## COLOR_ROAD_*/COLOR_SETTLEMENT_* "each file owns its own reading of the
|
||||
## palette" precedent, atlas_legend_panel.gd's own header comment). Consumers
|
||||
## needing the DECISION (not just the constant) call the static functions
|
||||
## below instead of re-deriving the same match/lerp logic a third time.
|
||||
##
|
||||
## gdlint's class-definitions-order requires all consts before all funcs at
|
||||
## file scope (no interleaving) — hence every table up top, decisions below.
|
||||
|
||||
## MorphologyZone discriminant -> overlay colour (D-239 SS6 order, T-1046).
|
||||
const MORPHOLOGY_COLORS: Array = [
|
||||
Color(0.10, 0.20, 0.45, 0.55), # 0 OpenOcean
|
||||
Color(0.20, 0.40, 0.65, 0.55), # 1 Lake
|
||||
Color(0.45, 0.55, 0.50, 0.55), # 2 TidalFlat
|
||||
Color(0.85, 0.78, 0.45, 0.55), # 3 DuneStrand
|
||||
Color(0.50, 0.50, 0.55, 0.55), # 4 CliffCoast
|
||||
Color(0.30, 0.40, 0.50, 0.55), # 5 Fjord
|
||||
Color(0.40, 0.65, 0.60, 0.55), # 6 Delta
|
||||
Color(0.30, 0.55, 0.55, 0.55), # 7 Estuarine
|
||||
Color(0.30, 0.60, 0.30, 0.55), # 8 AlluvialPlain
|
||||
Color(0.45, 0.70, 0.40, 0.55), # 9 RiverBank
|
||||
Color(0.35, 0.60, 0.50, 0.55), # 10 MeanderReach
|
||||
Color(0.55, 0.60, 0.45, 0.55), # 11 BraidedPlain
|
||||
Color(0.50, 0.55, 0.30, 0.55), # 12 ValleyFloor
|
||||
Color(0.55, 0.45, 0.30, 0.55), # 13 MountainPass
|
||||
Color(0.80, 0.82, 0.85, 0.55), # 14 Alpine
|
||||
Color(0.45, 0.15, 0.12, 0.55), # 15 Volcanic
|
||||
Color(0.25, 0.45, 0.40, 0.55), # 16 Wetland
|
||||
]
|
||||
|
||||
## Sub-biome -> marker color (Araminta's palette, D-226). Grouped pairs share
|
||||
## a color since they read the same on the map.
|
||||
const SUB_BIOME_COLORS: Dictionary = {
|
||||
"TropicalWet": Color(0.25, 0.72, 0.65, 0.90), # teal — coastal/tropical
|
||||
"CoastalLowland": Color(0.25, 0.72, 0.65, 0.90),
|
||||
"TemperateForest": Color(0.45, 0.68, 0.45, 0.90), # sage — temperate
|
||||
"TemperateGrassland": Color(0.45, 0.68, 0.45, 0.90),
|
||||
"Desert": Color(0.78, 0.62, 0.35, 0.90), # sand — arid
|
||||
"Savanna": Color(0.78, 0.62, 0.35, 0.90),
|
||||
"Alpine": Color(0.52, 0.58, 0.72, 0.90), # slate — alpine
|
||||
"Wetland": Color(0.40, 0.60, 0.52, 0.90), # muted teal — wetland
|
||||
"Tundra": Color(0.55, 0.68, 0.82, 0.90), # cool grey-blue — cold
|
||||
"BorealForest": Color(0.55, 0.68, 0.82, 0.90),
|
||||
}
|
||||
const COLOR_SUB_BIOME_DEFAULT: Color = Color(0.72, 0.72, 0.76, 0.90) # default grey
|
||||
|
||||
## T-960 L2 — MaintenanceAuthority (D-211/D-212) -> line color.
|
||||
const COLOR_ROAD_ADMINISTRATIVE: Color = Color(0.45, 0.65, 0.90, 0.85)
|
||||
const COLOR_ROAD_CORPORATE: Color = Color(0.85, 0.65, 0.20, 0.85)
|
||||
const COLOR_ROAD_COMMUNAL: Color = Color(0.45, 0.75, 0.50, 0.85)
|
||||
const COLOR_ROAD_TRADE: Color = Color(0.85, 0.60, 0.30, 0.85)
|
||||
const COLOR_ROAD_ABANDONED: Color = Color(0.45, 0.42, 0.38, 0.65)
|
||||
|
||||
## T-1118 — region climate grid (D-243 SS"region", D-226). Cold->hot ramp over
|
||||
## the realistic surface mean-annual band (~ -50C to +50C); REGION_TEMP_NONE_DC
|
||||
## (i16::MIN, server/src/atlas/layer_proxy.rs) is the airless-body sentinel —
|
||||
## the caller (atlas_marker_overlay.gd's _draw_gen_region_grid) skips those
|
||||
## cells entirely rather than calling this function for them.
|
||||
const REGION_TEMP_MIN_DC: int = -500 # -50.0 C, deci-degC
|
||||
const REGION_TEMP_MAX_DC: int = 500 # +50.0 C, deci-degC
|
||||
const REGION_TEMP_NONE_DC: int = -32768 # i16::MIN sentinel (airless — skip cell)
|
||||
const COLOR_REGION_TEMP_COLD: Color = Color(0.25, 0.45, 0.85, 0.60) # blue
|
||||
const COLOR_REGION_TEMP_MID: Color = Color(0.85, 0.85, 0.55, 0.55) # pale warm
|
||||
const COLOR_REGION_TEMP_HOT: Color = Color(0.90, 0.25, 0.20, 0.60) # red
|
||||
|
||||
## T-1119 — L4 quarter-footprint glyph (D-226 T-1112 amendment SS3). Square
|
||||
## side [QUARTER_GLYPH_MIN_SIZE, QUARTER_GLYPH_MAX_SIZE] scales off
|
||||
## density_avg_pct; color is a single-hue intensity ramp WITHIN the
|
||||
## settlement-gold family so the layer reads as part of the settlement-marker
|
||||
## family, not a competing hue.
|
||||
const QUARTER_GLYPH_MIN_SIZE: float = 4.0
|
||||
const QUARTER_GLYPH_MAX_SIZE: float = 12.0
|
||||
const QUARTER_GLYPH_BASE_SIZE: float = 4.0
|
||||
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
|
||||
|
||||
|
||||
static func morphology_color(zone: int) -> Color:
|
||||
if zone >= 0 and zone < MORPHOLOGY_COLORS.size():
|
||||
return MORPHOLOGY_COLORS[zone]
|
||||
return Color(0.5, 0.5, 0.5, 0.4)
|
||||
|
||||
|
||||
static func sub_biome_color(sub_biome: String) -> Color:
|
||||
return SUB_BIOME_COLORS.get(sub_biome, COLOR_SUB_BIOME_DEFAULT)
|
||||
|
||||
|
||||
static func road_authority_color(maintenance: String) -> Color:
|
||||
match maintenance:
|
||||
"Administrative":
|
||||
return COLOR_ROAD_ADMINISTRATIVE
|
||||
"Corporate":
|
||||
return COLOR_ROAD_CORPORATE
|
||||
"Communal":
|
||||
return COLOR_ROAD_COMMUNAL
|
||||
"Trade":
|
||||
return COLOR_ROAD_TRADE
|
||||
"Abandoned":
|
||||
return COLOR_ROAD_ABANDONED
|
||||
_:
|
||||
return COLOR_ROAD_TRADE
|
||||
|
||||
|
||||
## Cold->hot ramp over [REGION_TEMP_MIN_DC, REGION_TEMP_MAX_DC], clamped at
|
||||
## the ends (a real reading outside the band still renders, just capped to
|
||||
## the extreme color rather than clipping to nothing).
|
||||
static func region_temp_color(temp_dc: int) -> Color:
|
||||
var clamped: int = clampi(temp_dc, REGION_TEMP_MIN_DC, REGION_TEMP_MAX_DC)
|
||||
var t: float = (
|
||||
float(clamped - REGION_TEMP_MIN_DC) / float(REGION_TEMP_MAX_DC - REGION_TEMP_MIN_DC)
|
||||
)
|
||||
if t <= 0.5:
|
||||
return COLOR_REGION_TEMP_COLD.lerp(COLOR_REGION_TEMP_MID, t / 0.5)
|
||||
return COLOR_REGION_TEMP_MID.lerp(COLOR_REGION_TEMP_HOT, (t - 0.5) / 0.5)
|
||||
|
||||
|
||||
## Density-scaled side length, clamped to [QUARTER_GLYPH_MIN_SIZE,
|
||||
## QUARTER_GLYPH_MAX_SIZE]. Below SETTLEMENT_LABEL_MIN_ZOOM the glyph always
|
||||
## draws at minimum size (color-only read, per the amendment) regardless of
|
||||
## density — `show_notch` is the zoom gate, not a density input.
|
||||
static func quarter_glyph_size(density_avg_pct: int, show_notch: bool) -> float:
|
||||
if not show_notch:
|
||||
return QUARTER_GLYPH_MIN_SIZE
|
||||
var density_frac: float = clampf(float(density_avg_pct) / 100.0, 0.0, 1.0)
|
||||
return clampf(
|
||||
QUARTER_GLYPH_BASE_SIZE + density_frac * QUARTER_GLYPH_DENSITY_SCALE,
|
||||
QUARTER_GLYPH_MIN_SIZE,
|
||||
QUARTER_GLYPH_MAX_SIZE
|
||||
)
|
||||
|
||||
|
||||
## Single-hue intensity ramp within the settlement-gold family (D-226 T-1112
|
||||
## amendment SS3's literal legend colors).
|
||||
static func quarter_glyph_color(density_avg_pct: int) -> Color:
|
||||
var density_frac: float = clampf(float(density_avg_pct) / 100.0, 0.0, 1.0)
|
||||
return COLOR_QUARTER_LOW_DENSITY.lerp(COLOR_QUARTER_HIGH_DENSITY, density_frac)
|
||||
|
||||
|
||||
## Shape = dominant_district_type, capped at 3 marked variants + plain (D-226
|
||||
## SS3: "a coarse skeleton read, not a legend of every DistrictType"). Only
|
||||
## Commercial/Industrial/Administrative get a distinct mark; every other
|
||||
## DistrictType (LogisticsHub, Residential, Entertainment, MixedUse, Transit,
|
||||
## Specialized) — including an unrecognized/empty string — reads as "plain"
|
||||
## (mixed / no clear dominant), matching the legend. Returns a lowercase kind
|
||||
## string ("commercial"|"industrial"|"administrative"|"plain") the caller
|
||||
## switches on to pick the actual draw call.
|
||||
static func quarter_notch_kind(district_type: String) -> String:
|
||||
match district_type:
|
||||
"Commercial", "Industrial", "Administrative":
|
||||
return district_type.to_lower()
|
||||
_:
|
||||
return "plain"
|
||||
@@ -31,11 +31,6 @@ const MIN_ZOOM: float = 0.5
|
||||
const MAX_ZOOM: float = 8.0
|
||||
const ZOOM_STEP: float = 1.15
|
||||
|
||||
# #960: Layer-1 proxy re-poll. The proxy returns Pending on a cache miss and
|
||||
# generates in the background (D-225); the client re-requests until Ready.
|
||||
const GEN_RETRY_DELAY: float = 0.5
|
||||
const GEN_MAX_RETRIES: int = 20 # ~10s ceiling before giving up
|
||||
|
||||
const PANEL_WIDTH: float = 320.0
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
|
||||
@@ -164,6 +159,18 @@ const OVERLAY_DEFS: Array = [
|
||||
"group": "toggle",
|
||||
"tooltip": "Layer 3 — settlement placements, sized by population (generation overlay, T-960)."
|
||||
},
|
||||
{
|
||||
"id": "gen_region_grid",
|
||||
"label": "TMP",
|
||||
"group": "toggle",
|
||||
"tooltip": "Region climate grid — mean temperature (generation overlay, T-1118/D-243)."
|
||||
},
|
||||
{
|
||||
"id": "gen_l4_quarters",
|
||||
"label": "QTR",
|
||||
"group": "toggle",
|
||||
"tooltip": "Layer 4 — quarter footprint, color=density shape=type (generation overlay, T-1119)."
|
||||
},
|
||||
]
|
||||
|
||||
# ── Context (set by show_body) ─────────────────────────────────────────────────
|
||||
@@ -174,12 +181,25 @@ var _implant_theme = null
|
||||
# ── Heightmap + markers ───────────────────────────────────────────────────────
|
||||
var _heightmap_texture: Texture2D = null
|
||||
var _markers: Dictionary = {}
|
||||
var _generation_layer1: Variant = null # #960: Layer1Output from the proxy (D-225)
|
||||
var _generation_district_grid: Variant = null # T-1046: coarse DistrictGridLayer (D-226)
|
||||
var _generation_road_graph: Variant = null # T-960: RoadGraph layer from the proxy (D-225)
|
||||
var _generation_settlements: Variant = null # T-960: settlement placements from the proxy (D-225)
|
||||
var _gen_pending: bool = false # #960: awaiting a Layer1 response (re-polls on Pending)
|
||||
var _gen_retries: int = 0
|
||||
# Generation-cascade state (#960, D-225) is split into two companion objects,
|
||||
# both extracted from this file (T-1118) to keep it under the gdlint
|
||||
# max-file-lines cap: AtlasGenerationState owns the per-layer data
|
||||
# (get/set_generation_*() accessors below delegate to it) and AtlasGenerationProxy
|
||||
# owns the proxy-polling/retry/pending-indicator machinery.
|
||||
#
|
||||
# _gen_state is built as a FIELD DEFAULT (not in _ready()) — it is a pure
|
||||
# RefCounted with no tree dependency, and get/set_generation_*() must work on
|
||||
# a bare `AtlasViewer.new()` that never enters the scene tree (the
|
||||
# established test pattern throughout this suite — auto_free(AtlasViewer.new())
|
||||
# with no add_child(), so _ready() never fires). _overlay_node isn't bound
|
||||
# until _ready() (bind_overlay_node() below), so a redraw is a safe no-op
|
||||
# until then — see atlas_generation_state.gd's _redraw() null-guard.
|
||||
#
|
||||
# _gen_proxy is a Node (needs get_tree() for its retry timer, owns the
|
||||
# pending-indicator child Control) and is built in _ready() like every other
|
||||
# child node on this class — nothing calls into it before the tree exists.
|
||||
var _gen_state = load("res://ui/implant/apps/atlas/atlas_generation_state.gd").new()
|
||||
var _gen_proxy = null
|
||||
var _grid_w: float = 512.0
|
||||
var _grid_h: float = 256.0
|
||||
var _tex_w: float = 1024.0
|
||||
@@ -217,7 +237,6 @@ var _city_panel = null # ImplantPanel sidebar (city data)
|
||||
var _empty_notice = null # ImplantPanel shown when heightmap missing
|
||||
var _overlay_bar = null # #836 overlay toggle bar (no class_name, review #8)
|
||||
var _screen_header: ImplantHeader = null # top-left title/hint (D-169 composition)
|
||||
var _gen_pending_indicator = null # ImplantPending — loaded by path, not a class_name dep
|
||||
var _legend_panel = null # ImplantPanel — D-226 item 3, left-side generation-overlay legend
|
||||
|
||||
|
||||
@@ -247,6 +266,17 @@ func _ready() -> void:
|
||||
_overlay_node.viewer = self
|
||||
_canvas.add_child(_overlay_node)
|
||||
|
||||
# _gen_state itself was already constructed as a field default (see the
|
||||
# comment there) — bind its redraw target now that _overlay_node exists.
|
||||
_gen_state.bind_overlay_node(_overlay_node)
|
||||
|
||||
# #960: proxy-polling + diegetic "generating" indicator (D-225). Added as a
|
||||
# child Node (not RefCounted) so it can own the indicator Control and use
|
||||
# get_tree() for the retry timer; see atlas_generation_proxy.gd.
|
||||
_gen_proxy = load("res://ui/implant/apps/atlas/atlas_generation_proxy.gd").new(self)
|
||||
_gen_proxy.name = "GenerationProxy"
|
||||
add_child(_gen_proxy)
|
||||
|
||||
_build_screen_header()
|
||||
_build_city_panel()
|
||||
_build_empty_notice()
|
||||
@@ -261,17 +291,6 @@ func _ready() -> void:
|
||||
SimBridge.city_names_received.connect(_on_city_names_received)
|
||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
||||
|
||||
# #960: diegetic "generating" indicator, shown only while the proxy is Pending.
|
||||
# Loaded by path (not `ImplantPending.new()`) so a stale global-class cache —
|
||||
# e.g. a running session that hasn't re-imported after this class was added —
|
||||
# can't fail to parse AtlasViewer and break the atlas from opening (#960).
|
||||
_gen_pending_indicator = load("res://ui/implant/implant_pending.gd").new()
|
||||
_gen_pending_indicator.name = "GenPending"
|
||||
add_child(_gen_pending_indicator)
|
||||
_gen_pending_indicator.apply_implant_theme(_implant_theme)
|
||||
resized.connect(_position_pending_indicator)
|
||||
_position_pending_indicator()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received):
|
||||
@@ -294,12 +313,9 @@ func show_body(body: Dictionary, system: Dictionary) -> void:
|
||||
_refresh_screen_header()
|
||||
_city_panel.visible = false
|
||||
_empty_notice.visible = (_heightmap_texture == null)
|
||||
_generation_layer1 = null
|
||||
_gen_pending = false
|
||||
_gen_retries = 0
|
||||
if _gen_pending_indicator:
|
||||
_gen_pending_indicator.stop()
|
||||
_request_generation_layers()
|
||||
_gen_state.reset_layer1()
|
||||
_gen_proxy.reset()
|
||||
_gen_proxy.request(_dict_str(_body, "body_id", ""))
|
||||
grab_focus()
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
@@ -361,123 +377,75 @@ func set_view(zoom: float, offset: Vector2) -> void:
|
||||
_apply_transform()
|
||||
|
||||
|
||||
## #960: store the Layer-1 generation output (from atlas_layers_received) and
|
||||
## redraw the overlay. The marker overlay reads it via get_generation_layer1().
|
||||
## Per-layer generation accessors (#960, D-225; region_grid T-1118, quarter_footprints
|
||||
## T-1119) — thin delegates onto AtlasGenerationState (see atlas_generation_state.gd
|
||||
## for what each layer is and its store-then-redraw contract). Same public method
|
||||
## names as before the T-1118 extraction; callers (atlas_marker_overlay.gd,
|
||||
## test_atlas_overlays.gd) are unaffected.
|
||||
func set_generation_layer1(layer1: Variant) -> void:
|
||||
_generation_layer1 = layer1
|
||||
if _overlay_node:
|
||||
_overlay_node.queue_redraw()
|
||||
_gen_state.set_layer1(layer1)
|
||||
|
||||
|
||||
func get_generation_layer1() -> Variant:
|
||||
return _generation_layer1
|
||||
return _gen_state.get_layer1()
|
||||
|
||||
|
||||
## T-1046: store the coarse district/morphology grid (DistrictGridLayer) from the
|
||||
## proxy and redraw. The marker overlay reads it via get_generation_district_grid().
|
||||
func set_generation_district_grid(grid: Variant) -> void:
|
||||
_generation_district_grid = grid
|
||||
if _overlay_node:
|
||||
_overlay_node.queue_redraw()
|
||||
_gen_state.set_district_grid(grid)
|
||||
|
||||
|
||||
func get_generation_district_grid() -> Variant:
|
||||
return _generation_district_grid
|
||||
return _gen_state.get_district_grid()
|
||||
|
||||
|
||||
## T-960: store the L2 road/rail graph (RoadGraphLayer) from the proxy and
|
||||
## redraw. The marker overlay reads it via get_generation_road_graph().
|
||||
func set_generation_road_graph(graph: Variant) -> void:
|
||||
_generation_road_graph = graph
|
||||
if _overlay_node:
|
||||
_overlay_node.queue_redraw()
|
||||
_gen_state.set_road_graph(graph)
|
||||
|
||||
|
||||
func get_generation_road_graph() -> Variant:
|
||||
return _generation_road_graph
|
||||
return _gen_state.get_road_graph()
|
||||
|
||||
|
||||
## T-960: store the L3 settlement placements (SettlementLayer) from the proxy
|
||||
## and redraw. The marker overlay reads it via get_generation_settlements().
|
||||
func set_generation_settlements(settlements: Variant) -> void:
|
||||
_generation_settlements = settlements
|
||||
if _overlay_node:
|
||||
_overlay_node.queue_redraw()
|
||||
_gen_state.set_settlements(settlements)
|
||||
|
||||
|
||||
func get_generation_settlements() -> Variant:
|
||||
return _generation_settlements
|
||||
return _gen_state.get_settlements()
|
||||
|
||||
|
||||
## #960: request the body's Layer-1 cascade output from the server proxy.
|
||||
## No-op in test mode (SimBridge has no server connection) — the overlays
|
||||
## simply stay empty, which is the correct serverless behavior.
|
||||
func _request_generation_layers() -> void:
|
||||
var body_id: String = _dict_str(_body, "body_id", "")
|
||||
if body_id.is_empty():
|
||||
return
|
||||
_gen_pending = true
|
||||
SimBridge.request_atlas_layers(body_id)
|
||||
func set_generation_region_grid(grid: Variant) -> void:
|
||||
_gen_state.set_region_grid(grid)
|
||||
|
||||
|
||||
## #960: handle a Layer-1 response. Ignores responses for a stale body (the
|
||||
## user navigated away). On Pending the proxy is still generating, so we
|
||||
## re-request after a short delay until Ready or the retry ceiling.
|
||||
func get_generation_region_grid() -> Variant:
|
||||
return _gen_state.get_region_grid()
|
||||
|
||||
|
||||
func set_generation_quarter_footprints(footprints: Variant) -> void:
|
||||
_gen_state.set_quarter_footprints(footprints)
|
||||
|
||||
|
||||
func get_generation_quarter_footprints() -> Variant:
|
||||
return _gen_state.get_quarter_footprints()
|
||||
|
||||
|
||||
## #960: forward a Layer-1 response to AtlasGenerationProxy, which handles
|
||||
## staleness, retry, indicator, and dispatch to AtlasGenerationState (T-1118
|
||||
## extraction — see atlas_generation_proxy.gd).
|
||||
func _on_atlas_layers_received(response: Dictionary) -> void:
|
||||
var body_id: String = _dict_str(_body, "body_id", "")
|
||||
if str(response.get("body_id", "")) != body_id:
|
||||
return
|
||||
match str(response.get("status", "")):
|
||||
"Ready":
|
||||
_gen_pending = false
|
||||
_gen_retries = 0
|
||||
_set_gen_indicator(false)
|
||||
set_generation_layer1(response.get("layer1"))
|
||||
set_generation_district_grid(response.get("district_grid"))
|
||||
set_generation_road_graph(response.get("road_graph"))
|
||||
set_generation_settlements(response.get("settlements"))
|
||||
"Pending":
|
||||
if _gen_retries < GEN_MAX_RETRIES:
|
||||
_gen_retries += 1
|
||||
_set_gen_indicator(true)
|
||||
_schedule_gen_retry(body_id)
|
||||
else:
|
||||
_gen_pending = false # gave up — overlays stay empty
|
||||
_set_gen_indicator(false)
|
||||
_:
|
||||
_gen_pending = false # NotFound / Error — nothing to draw
|
||||
_set_gen_indicator(false)
|
||||
_gen_proxy.on_response(response, _dict_str(_body, "body_id", ""))
|
||||
|
||||
|
||||
func _schedule_gen_retry(body_id: String) -> void:
|
||||
var timer := get_tree().create_timer(GEN_RETRY_DELAY)
|
||||
timer.timeout.connect(
|
||||
func() -> void:
|
||||
# Re-request only if still on the same body and still waiting.
|
||||
if _gen_pending and _dict_str(_body, "body_id", "") == body_id:
|
||||
SimBridge.request_atlas_layers(body_id)
|
||||
)
|
||||
## Read-only accessors AtlasGenerationProxy needs (it is added as a child, not
|
||||
## a subclass, so it reaches viewer state through the same public surface any
|
||||
## other caller would use).
|
||||
func get_implant_theme() -> Variant:
|
||||
return _implant_theme
|
||||
|
||||
|
||||
## Show/hide the diegetic pending indicator. Starting only when not already
|
||||
## visible avoids resetting the sweep on every re-poll.
|
||||
func _set_gen_indicator(on: bool) -> void:
|
||||
if _gen_pending_indicator == null:
|
||||
return
|
||||
if on:
|
||||
if not _gen_pending_indicator.visible:
|
||||
_position_pending_indicator()
|
||||
_gen_pending_indicator.start("GENERATING LAYER 1")
|
||||
else:
|
||||
_gen_pending_indicator.stop()
|
||||
|
||||
|
||||
func _position_pending_indicator() -> void:
|
||||
if _gen_pending_indicator == null:
|
||||
return
|
||||
_gen_pending_indicator.position = Vector2(
|
||||
(size.x - _gen_pending_indicator.DEFAULT_WIDTH) * 0.5, size.y * 0.45
|
||||
)
|
||||
func get_body_id() -> String:
|
||||
return _dict_str(_body, "body_id", "")
|
||||
|
||||
|
||||
func get_hovered_city() -> Dictionary:
|
||||
|
||||
Reference in New Issue
Block a user