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>
183 lines
7.2 KiB
GDScript
183 lines
7.2 KiB
GDScript
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
|