Files
settled-reach/client/ui/implant/apps/atlas/atlas_window_request.gd
T
jpmschweitzerandClaude Fable 5 9e1e6db614 feat(ui): T-1138 regional map screen — click-through descent, fixed planetary view, windowed composite (D-226 T-1124 SS5)
Entry per Jeroen's 2026-07-21 revision: planetary heightmap is now
FIXED — all drag-pan/wheel-zoom input removed (set_view/get_view_*
capture API survives for the golden harness); hover shows a
not-to-scale bracket reticle with the real extent labeled (a true
n=32 rectangle is sub-pixel on the planetary canvas — the honest
representation given the morph transition is deferred), and a click
that misses every city marker descends (city-click wins — one
gesture, two contextual reads, no modifier). Descent pushes a new
'district' nav screen centered on the click point's DistrictPos via
atlas_descend_geometry.district_pos_at (the pixel-to-district inverse
of the server mapping, verified against scale.rs).

Regional mode: atlas_window_viewer draws the composite (morphology x
elev_q lightness base; temp/moisture/veg toggles — temp reuses the
region-ramp colorizer exactly; Marine=6 transparent; glaciation
always-on tint matching apply_ice_tint's REAL gate, None|Light no-op,
over the amendment's looser prose — documented); pan-on-held-composite
with edge-crossing refetch + border-fade during the queue-based
derive wait; zoom never refetches. atlas_window_cache: LRU keyed
(body_id, center, n), touch-on-read, evict-only, no freshness (D-227).
atlas_window_request mirrors the generation-proxy pending-retry shape
for None-until-derived. Codec: window params omitted from the wire
when absent — byte-identical for every existing caller.

Live-verified against the T-1137 server in-worktree: real round-trip
on a GJ380c coastal district (6 fields x 1024 cells), echo staleness
guard, genuine ~1.4s background-derive wait, pan-edge refetch to an
adjacent window, cache-hit on re-descent with zero network. Full
client suite 3194/3194; gdlint clean on all 18 files.

Open follow-ups flagged in-code: header location label always falls
back to coordinates (nearest-settlement needs a join the district
window does not carry); atlas_standalone.gd's 'atlas_app.gd is never
modified' doc line is now imprecise.

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

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