feat(client): T-1153 + T-1152 client half — continuous cursor-anchored zoom ladder, Region-rung orbital entry, click-through retired
The atlas 'regional' screen now opens the LADDER at the canonical orbital frame (Region granularity, whole body fitted and centered) and wheel zoom descends continuously — cursor-anchored, unclamped across rungs, with progressive refinement (held composite keeps drawing, finer rung swaps in place on arrival; no blank frame, no mode flip). Full-zoom-out resets to the canonical planetary frame per Jeroen's HARD condition (is_fully_zoomed_out = extent >= body circumference, not a zoom-value heuristic). The district_screen nav hop is deleted — D-013 restored: descent is a zoom gesture, not a nav push. AtlasViewer's heightmap-texture path is unreachable from nav (code intact; overlay surface deferred, see report/tickets). Rung selection: design doc §5's literal formula has NO legal District band at any real viewport (visual-tolerance band and n=64 coverage ceiling never overlap — pinned by executable boundary tests at 1600x900); select_rung() splits it into a coverage ceiling (decides Region) then the 2x visual tolerance (District vs Quarter), documented at the function. In practice the ladder steps Region -> Quarter directly. Wire: window_granularity_v2 encoded (omitted at District for byte-compat), granularity_v2 echoed value keyed + staleness-guarded end to end; Region clamp mirror replicates the server's bounded halving loop (no closed form). MIN/MAX_ZOOM widened to [0.0005, 64] — the old 0.5 floor would have clamped a real body's canonical fit zoom, violating the reset condition. Real pre-existing bug fixed in atlas_window_overlay.gd: the draw path used echoed n as both cell-grid dimension and district extent — only coincidentally correct at District granularity; Quarter/Region would have read wrong array offsets. cell_grid_side_for_window() now mirrors the server's WindowGranularity::cell_grid_side. Tests: +26 pure-function geometry tests, new 30-test zoom-ladder suite, extensions across the window cache/request/overlay/delivery suites. Full suite 3518 green; cold-parse clean.
This commit is contained in:
@@ -1,8 +1,20 @@
|
||||
class_name AtlasApp
|
||||
extends ImplantApp
|
||||
## Atlas implant app (#844, #836, D-191).
|
||||
## Reach map → system orbital → planet entry → regional heightmap viewer.
|
||||
## Reach map → system orbital → planet entry → regional zoom ladder.
|
||||
## Registered as "implant/map" in FULLSCREEN mode.
|
||||
##
|
||||
## T-1153 (D-226 T-1143-rulings amendment): the "district" nav-stack screen
|
||||
## (T-1138's windowed drill-down, a separate nav.push() hop from "regional")
|
||||
## RETIRES as a nav-stack level — the continuous cursor-anchored zoom ladder
|
||||
## means descent/ascent through every rung (Region -> District -> Quarter)
|
||||
## happens INSIDE the "regional" screen via zoom, not by pushing a new
|
||||
## screen. D-013 "the zoom gesture owns spatial descent" is restored for
|
||||
## this seam (Jeroen's ruling) — descent is a continuous gesture, not a
|
||||
## discrete nav hop. Esc from anywhere in the ladder is therefore a single
|
||||
## nav.pop() back to "system", exactly the same _handle_key() KEY_ESCAPE
|
||||
## branch every other non-"reach" screen already uses; there is no longer a
|
||||
## "district" screen entry in the match/registration table.
|
||||
|
||||
signal economics_link_requested(system_id: String)
|
||||
|
||||
@@ -12,8 +24,7 @@ var _system_lookup: Dictionary = {} # system_id → system dict
|
||||
var _reach_screen = null # ReachScreen
|
||||
var _system_screen = null # SystemScreen
|
||||
var _planet_screen = null # PlanetScreen
|
||||
var _regional_screen = null # RegionalScreen
|
||||
var _district_screen = null # DistrictScreen (T-1138)
|
||||
var _regional_screen = null # RegionalScreen — now the whole zoom ladder (T-1153)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -47,14 +58,8 @@ func on_install() -> void:
|
||||
|
||||
_regional_screen = RegionalScreen.new()
|
||||
_regional_screen.back_requested.connect(_on_regional_back)
|
||||
_regional_screen.economics_link_requested.connect(_forward_economics_link)
|
||||
_regional_screen.district_descend_requested.connect(_on_district_descend_requested)
|
||||
register_screen("regional", _regional_screen)
|
||||
|
||||
_district_screen = DistrictScreen.new()
|
||||
_district_screen.back_requested.connect(_on_district_back)
|
||||
register_screen("district", _district_screen)
|
||||
|
||||
nav.set_default("reach")
|
||||
|
||||
|
||||
@@ -71,12 +76,11 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
return
|
||||
if not event.is_pressed() or event.is_echo():
|
||||
return
|
||||
# "regional" (the planetary heightmap, AtlasViewer) and "district" (the
|
||||
# windowed regional-window screen, AtlasWindowViewer, T-1138) both handle
|
||||
# their own Esc via _gui_input — same delegation shape for both, so a
|
||||
# stray M/other unhandled key on either screen doesn't ALSO fire this
|
||||
# app's own _handle_key underneath the viewer's own handling.
|
||||
if current_screen_id() == "regional" or current_screen_id() == "district":
|
||||
# "regional" (the whole zoom ladder, AtlasWindowViewer, T-1153) handles its
|
||||
# own Esc via _gui_input — a stray M/other unhandled key on that screen
|
||||
# must not ALSO fire this app's own _handle_key underneath the viewer's
|
||||
# own handling.
|
||||
if current_screen_id() == "regional":
|
||||
return
|
||||
_handle_key(event as InputEventKey)
|
||||
get_viewport().set_input_as_handled()
|
||||
@@ -148,29 +152,6 @@ func _on_regional_back() -> void:
|
||||
nav.pop()
|
||||
|
||||
|
||||
## T-1138: the planetary click-through descent (§5 entry revision) — pushes
|
||||
## the "district" screen centered on the click point's derived DistrictPos.
|
||||
## A real nav.push() (not a swap-in-place, unlike the superseded zoom-
|
||||
## threshold design) so Esc's existing nav.pop() path (DistrictScreen's own
|
||||
## back_requested -> _on_district_back below) returns to exactly the
|
||||
## planetary body the player descended from, at whatever crumb depth got
|
||||
## them there (reach -> system -> regional -> district).
|
||||
func _on_district_descend_requested(district_center: Vector2i) -> void:
|
||||
nav.push("district", {
|
||||
"body": nav.current_payload().get("body", {}),
|
||||
"system": nav.current_payload().get("system", {}),
|
||||
"district_center": district_center,
|
||||
})
|
||||
|
||||
|
||||
func _on_district_back() -> void:
|
||||
nav.pop()
|
||||
|
||||
|
||||
func _forward_economics_link(system_id: String) -> void:
|
||||
economics_link_requested.emit(system_id)
|
||||
|
||||
|
||||
## T-949: the star map arrived — cache it, rebuild the local system
|
||||
## list/lookup, and push it into whichever screens already exist. set_systems()
|
||||
## is safe to call again after initial setup (ReachScreen/SystemScreen both
|
||||
|
||||
@@ -2,21 +2,27 @@ extends RefCounted
|
||||
|
||||
## Client-side LRU cache for DistrictWindowLayer responses (T-1138, D-226
|
||||
## T-1124 amendment §4 "Client cache policy"; extended T-1150 for the
|
||||
## granularity/min_wl axes).
|
||||
## granularity/min_wl axes; extended T-1152/T-1153 for the granularity_v2
|
||||
## string-tag axis that makes Region representable at all).
|
||||
##
|
||||
## Keyed on (body_id, center, n, granularity, min_wl_m) — D-227's determinism
|
||||
## guarantee (same seed + body + position + derivation params -> same derived
|
||||
## output, always) means a previously-fetched window is valid FOREVER for
|
||||
## that body+seed. This is an LRU-evict-only cache: no freshness check, no
|
||||
## TTL, no invalidation path at all. The only reason an entry ever leaves is
|
||||
## capacity pressure.
|
||||
## Keyed on (body_id, center, n, granularity, min_wl_m, granularity_v2) —
|
||||
## D-227's determinism guarantee (same seed + body + position + derivation
|
||||
## params -> same derived output, always) means a previously-fetched window is
|
||||
## valid FOREVER for that body+seed. This is an LRU-evict-only cache: no
|
||||
## freshness check, no TTL, no invalidation path at all. The only reason an
|
||||
## entry ever leaves is capacity pressure.
|
||||
##
|
||||
## granularity/min_wl_m default to DISTRICT_GRANULARITY/0 (district spacing,
|
||||
## no octave cutoff) — every pre-T-1150 caller that doesn't pass them keeps
|
||||
## its existing key shape and cache behavior unchanged. This is the client
|
||||
## half of the mandatory aliasing fix (T-1150 design doc §3): a
|
||||
## quarter-granularity request and a district-granularity request at the
|
||||
## identical (body, center, n) MUST NOT collide on the same cache slot.
|
||||
## granularity/min_wl_m/granularity_v2 default to
|
||||
## DISTRICT_GRANULARITY/0/DEFAULT_GRANULARITY_V2 ("District", district
|
||||
## spacing, no octave cutoff) — every pre-T-1150 caller that doesn't pass them
|
||||
## keeps its existing key shape and cache behavior unchanged. This is the
|
||||
## client half of the mandatory aliasing fix (T-1150 design doc §3, extended
|
||||
## T-1152): a quarter-granularity request, a district-granularity request,
|
||||
## and a REGION-granularity request all at the identical (body, center, n)
|
||||
## MUST NOT collide on the same cache slot — the legacy int alone cannot
|
||||
## distinguish Region (it has no legal legacy-int value, see
|
||||
## GRANULARITY_V2_REGION's doc), which is exactly why granularity_v2 is a
|
||||
## SEPARATE key component rather than a replacement for the legacy one.
|
||||
##
|
||||
## Godot's Dictionary preserves insertion order, so "move to the end on
|
||||
## touch, evict from the front on overflow" is the whole LRU implementation —
|
||||
@@ -33,6 +39,24 @@ const DEFAULT_MAX_ENTRIES: int = 24
|
||||
## default granularity every pre-T-1150 caller implicitly requests.
|
||||
const DISTRICT_GRANULARITY: int = 1
|
||||
|
||||
## Mirrors the server's WindowGranularity enum (T-1152, R5 redesign,
|
||||
## layer_proxy.rs) — the string-tag vocabulary rmp_serde encodes a bare
|
||||
## `#[derive(Serialize, Deserialize)]` enum's variant name as, verbatim (same
|
||||
## wire convention `RoadNodeKind` already established on this carrier). This
|
||||
## is the KEY-SPACE axis (T-1153): the legacy int `granularity` param below
|
||||
## stays wired for every existing District/Quarter caller (byte/behavior
|
||||
## compatible), but a cache slot is now ALSO qualified by this string so a
|
||||
## Region-rung window can never alias onto a District/Quarter slot at the
|
||||
## identical (body, center, n, legacy_granularity, min_wl_m) — the exact
|
||||
## aliasing risk the T-1150 design doc §3 flagged, extended to the new axis.
|
||||
const GRANULARITY_V2_QUARTER: String = "Quarter"
|
||||
const GRANULARITY_V2_DISTRICT: String = "District"
|
||||
const GRANULARITY_V2_REGION: String = "Region"
|
||||
## Default v2 tag for every caller that doesn't pass one — matches
|
||||
## DISTRICT_GRANULARITY's own "district is the implicit default" contract, so
|
||||
## an omitted v2 tag and an explicit "District" tag key identically.
|
||||
const DEFAULT_GRANULARITY_V2: String = GRANULARITY_V2_DISTRICT
|
||||
|
||||
var _max_entries: int = DEFAULT_MAX_ENTRIES
|
||||
var _entries: Dictionary = {} # key String -> DistrictWindowLayer Dictionary
|
||||
|
||||
@@ -41,34 +65,42 @@ func _init(max_entries: int = DEFAULT_MAX_ENTRIES) -> void:
|
||||
_max_entries = maxi(1, max_entries)
|
||||
|
||||
|
||||
## Build the cache key from the five fields D-227 + T-1150 make sufficient:
|
||||
## body_id (which world+body), center (a [row, col] pair or Vector2i), n
|
||||
## (window extent in districts), granularity (district=1 / quarter=4), and
|
||||
## min_wl_m (the octave cutoff, 0 = none). String-keyed rather than a nested
|
||||
## Dictionary/Array key — Godot Dictionary keys compare by value for
|
||||
## primitives but a consistent stringification sidesteps any
|
||||
## Vector2i-vs-Array identity mismatch between what a caller happens to hand
|
||||
## in.
|
||||
## Build the cache key from the six fields D-227 + T-1150/T-1152 make
|
||||
## sufficient: body_id (which world+body), center (a [row, col] pair or
|
||||
## Vector2i), n (window extent in districts), granularity (the legacy int:
|
||||
## district=1 / quarter=4), min_wl_m (the octave cutoff, 0 = none), and
|
||||
## granularity_v2 (the T-1152 string tag: "Quarter"/"District"/"Region" — the
|
||||
## axis that actually distinguishes Region from every finer rung, since
|
||||
## Region has no legal legacy-int representation and the legacy slot alone
|
||||
## cannot tell a Region window's cache entry apart from a District one at the
|
||||
## same (center, n)). String-keyed rather than a nested Dictionary/Array key
|
||||
## — Godot Dictionary keys compare by value for primitives but a consistent
|
||||
## stringification sidesteps any Vector2i-vs-Array identity mismatch between
|
||||
## what a caller happens to hand in.
|
||||
static func make_key(
|
||||
body_id: String,
|
||||
center: Vector2i,
|
||||
n: int,
|
||||
granularity: int = DISTRICT_GRANULARITY,
|
||||
min_wl_m: int = 0
|
||||
min_wl_m: int = 0,
|
||||
granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
) -> String:
|
||||
return "%s:%d,%d:%d:%d:%d" % [body_id, center.x, center.y, n, granularity, min_wl_m]
|
||||
return "%s:%d,%d:%d:%d:%d:%s" % [
|
||||
body_id, center.x, center.y, n, granularity, min_wl_m, granularity_v2
|
||||
]
|
||||
|
||||
|
||||
## True if a window is already cached for this exact (body, center, n,
|
||||
## granularity, min_wl_m).
|
||||
## granularity, min_wl_m, granularity_v2).
|
||||
func has(
|
||||
body_id: String,
|
||||
center: Vector2i,
|
||||
n: int,
|
||||
granularity: int = DISTRICT_GRANULARITY,
|
||||
min_wl_m: int = 0
|
||||
min_wl_m: int = 0,
|
||||
granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
) -> bool:
|
||||
return _entries.has(make_key(body_id, center, n, granularity, min_wl_m))
|
||||
return _entries.has(make_key(body_id, center, n, granularity, min_wl_m, granularity_v2))
|
||||
|
||||
|
||||
## Fetch a cached window, touching it (move-to-most-recently-used). Returns
|
||||
@@ -81,9 +113,10 @@ func get_window(
|
||||
center: Vector2i,
|
||||
n: int,
|
||||
granularity: int = DISTRICT_GRANULARITY,
|
||||
min_wl_m: int = 0
|
||||
min_wl_m: int = 0,
|
||||
granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
) -> Variant:
|
||||
var key := make_key(body_id, center, n, granularity, min_wl_m)
|
||||
var key := make_key(body_id, center, n, granularity, min_wl_m, granularity_v2)
|
||||
if not _entries.has(key):
|
||||
return null
|
||||
var value: Variant = _entries[key]
|
||||
@@ -101,9 +134,10 @@ func put(
|
||||
n: int,
|
||||
window: Dictionary,
|
||||
granularity: int = DISTRICT_GRANULARITY,
|
||||
min_wl_m: int = 0
|
||||
min_wl_m: int = 0,
|
||||
granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
) -> void:
|
||||
var key := make_key(body_id, center, n, granularity, min_wl_m)
|
||||
var key := make_key(body_id, center, n, granularity, min_wl_m, granularity_v2)
|
||||
if _entries.has(key):
|
||||
_entries.erase(key)
|
||||
_entries[key] = window
|
||||
|
||||
@@ -10,6 +10,40 @@ extends RefCounted
|
||||
## window size, what zoom/offset centers it" math is unit-testable in
|
||||
## isolation here — a caller does:
|
||||
## const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
##
|
||||
## T-1153: also carries the rung-selection rule (design doc
|
||||
## docs/architecture/atlas-zoom-ladder-t1143.md §5) and the "fully zoomed
|
||||
## out" reset predicate (Jeroen's D-226 T-1143-rulings HARD condition) — both
|
||||
## pure functions of (viewport, held state, body), same "geometry lives here,
|
||||
## side effects live on the viewer" split as the rest of this file.
|
||||
|
||||
## D-243 rung spacings, metres/cell — the SAME constants
|
||||
## server/src/atlas/scale.rs and layer_proxy.rs's WindowGranularity::spacing_m
|
||||
## source from, mirrored here rather than re-derived so the client's rung
|
||||
## table can never silently drift from the wire contract it's choosing
|
||||
## between.
|
||||
const QUARTER_SPACING_M: float = 512.0
|
||||
const DISTRICT_SPACING_M: float = 2048.0
|
||||
const REGION_SPACING_M: float = 204_800.0
|
||||
|
||||
## Table form, coarsest-first — spacing_for_rung()'s inverse lookup walks
|
||||
## this. select_rung() (below) does NOT walk this table directly — see that
|
||||
## function's own doc for why the coarse (Region) and fine (District/
|
||||
## Quarter) ends are decided by two DIFFERENT tests, not a single ordered
|
||||
## table scan.
|
||||
const RUNG_TABLE: Array = [
|
||||
{"granularity_v2": "Region", "spacing_m": REGION_SPACING_M},
|
||||
{"granularity_v2": "District", "spacing_m": DISTRICT_SPACING_M},
|
||||
{"granularity_v2": "Quarter", "spacing_m": QUARTER_SPACING_M},
|
||||
]
|
||||
|
||||
## Server per-axis cap on a District/Quarter-granularity window's `n`
|
||||
## (mirrors server/src/atlas/layer_proxy.rs's `DISTRICT_WINDOW_MAX_N` — see
|
||||
## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N, the existing client-side
|
||||
## mirror of the same constant, kept in sync there). select_rung() uses this
|
||||
## to answer "CAN a District-granularity window even cover this much world at
|
||||
## all" — the coarse-end ceiling, distinct from the fine-end `2x` tolerance.
|
||||
const DISTRICT_WINDOW_MAX_N: int = 64
|
||||
|
||||
|
||||
## Fit-and-center: given the viewport size and the window's side length in
|
||||
@@ -116,3 +150,291 @@ static func clamp_pan_offset_to_pole_wall(
|
||||
var min_y: float = minf(north_wall_screen_y, south_wall_screen_y)
|
||||
var max_y: float = maxf(north_wall_screen_y, south_wall_screen_y)
|
||||
return Vector2(offset.x, clampf(offset.y, min_y, max_y))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: rung selection (design doc §5) — the continuous zoom ladder's join
|
||||
# point between "what granularity is legal to request" (a rung, a discrete
|
||||
# set) and "what density the client actually wants" (world extent per canvas
|
||||
# px, a continuous quantity that tracks the live zoom level).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Rung-selection rule (design doc §5, restated): for a world extent `E`
|
||||
## metres shown across canvas `C` px, sample spacing is `E/C`. Select the
|
||||
## COARSEST rung whose cell spacing is `<= 2*(E/C)` — one tier finer than a
|
||||
## screen pixel, never coarser (never magnified-interpolation of a coarser
|
||||
## composite, the literal thing the D-166 corollary forbids). This `2x`
|
||||
## tolerance gates the FINE end only (District vs. Quarter) — see the
|
||||
## coarse-end paragraph below for why Region is decided by a DIFFERENT test.
|
||||
##
|
||||
## `world_extent_m`/`canvas_px` are both callers'-choice-of-axis (the held
|
||||
## window is always square, so either axis of the viewport/extent pair gives
|
||||
## the same answer — the caller picks one, consistently).
|
||||
##
|
||||
## **Region is selected by a COVERAGE test, not the `2x` visual tolerance**
|
||||
## (found live-testing the orbital-entry fit zoom, where E covers a whole
|
||||
## planetary circumference). The `2x` formula is calibrated to catch the
|
||||
## ZOOM-IN failure mode the corollary names explicitly — never request
|
||||
## coarser derivation than the screen can currently resolve — and has no
|
||||
## meaningful symmetric zoom-OUT reading: testing Region's own 204.8 km
|
||||
## spacing against the SAME threshold that gates District/Quarter would
|
||||
## reject Region at essentially every normal screen resolution (a whole-body
|
||||
## view's sample spacing is tens of km/px, and 2x that is still far under
|
||||
## 204.8 km — even though visually a ~5-10-screen-px-per-cell Region view
|
||||
## reads perfectly fine, nowhere near "magnified interpolation"). The
|
||||
## GENUINELY load-bearing question at the coarse end is different: can a
|
||||
## District-granularity window (capped server-side at
|
||||
## DISTRICT_WINDOW_MAX_N=64 districts, ~131 km per side) physically COVER
|
||||
## the extent being displayed at all? Once it can't, Region is the only rung
|
||||
## that CAN — this is a coverage/capacity fact, not a resolution-legibility
|
||||
## judgment, and it's what actually decides "zoom out past the district rung
|
||||
## transitions to Region" per the ticket's own framing.
|
||||
##
|
||||
## **A third finding, resolving the above two against each other:** at
|
||||
## CELL_PIXEL_SIZE=16 (the shipped display scale), the fine-end `2x` band
|
||||
## that would select District and the coarse-end coverage ceiling that
|
||||
## selects Region do not meet — District's OWN native resolution already
|
||||
## reads as "too fine" (wants Quarter) well before its 64-district coverage
|
||||
## cap becomes binding (wants Region), leaving NO zoom range where the `2x`
|
||||
## formula alone would ever pick District. Since the coverage ceiling is a
|
||||
## hard CAPABILITY limit (a District request literally cannot serve more
|
||||
## world than its per-axis cap covers) while the `2x` band is a QUALITY
|
||||
## preference (finer than strictly needed is wasteful, not wrong), the
|
||||
## coverage ceiling wins whenever the two disagree: check it FIRST, and only
|
||||
## consult the `2x` band to choose between District and Quarter for whatever
|
||||
## extent remains under that ceiling. This is a genuine engineering call this
|
||||
## implementation makes (flagged to the team, not a design-doc-literal
|
||||
## derivation) — see docs/architecture/atlas-zoom-ladder-t1143.md §5 Risk R4
|
||||
## ("Region rung is named but unscoped") for the open design question this
|
||||
## resolves pragmatically rather than by further design-pass iteration.
|
||||
##
|
||||
## **Whether a District band exists at all is independent of CELL_PIXEL_SIZE**
|
||||
## — it cancels out of the "does District's `2x` band overlap the coverage
|
||||
## ceiling" condition entirely. The condition reduces to `canvas_px <=
|
||||
## DISTRICT_WINDOW_MAX_N * DISTRICT_SPACING_M / 1024` — i.e. `canvas_px <=
|
||||
## 128px` at the shipped constants. At every real viewport (800px+), this is
|
||||
## never satisfied: District's band is empty by construction, and the ladder
|
||||
## in practice steps Region -> Quarter directly at any normal screen size.
|
||||
## Verified numerically at 1600x900 (canvas_px=1600): the Region/District
|
||||
## crossing (world_extent_m == the coverage ceiling) sits at `_view_zoom ≈
|
||||
## 1.5625`, and the District/Quarter crossing (the `2x` band's own edge)
|
||||
## sits at `_view_zoom ≈ 0.125` — i.e. the `2x` band's own boundary is
|
||||
## already PAST (a smaller zoom than) where the coverage ceiling releases
|
||||
## District, so the two never overlap in the zoomed-in direction either.
|
||||
## **Tuning knobs, if a real District band is wanted:** the ONLY lever that
|
||||
## opens the gap is `DISTRICT_WINDOW_MAX_N` (currently 64, mirrored from the
|
||||
## server's own per-axis cap) — it would need to reach `1024 * canvas_px /
|
||||
## DISTRICT_SPACING_M` (≈800 at a 1600px canvas) to open a band there, a
|
||||
## substantial server-side wire-size change (T-1150's `WIRE_CAP_CELLS`
|
||||
## budget), not a client-only tuning knob. `CELL_PIXEL_SIZE` does NOT affect
|
||||
## whether a band exists — it only shifts WHERE both crossing zooms sit on
|
||||
## the wheel gesture (scaling both proportionally, preserving their ~12.5x
|
||||
## gap), i.e. it is the felt-pacing knob for how much wheel travel separates
|
||||
## Region from Quarter, not a way to reintroduce District.
|
||||
##
|
||||
## Returns the granularity_v2 string tag ("Quarter" | "District" | "Region").
|
||||
static func select_rung(world_extent_m: float, canvas_px: float) -> String:
|
||||
if world_extent_m > float(DISTRICT_WINDOW_MAX_N) * DISTRICT_SPACING_M:
|
||||
return "Region" # coverage ceiling — District physically cannot span this much world
|
||||
if canvas_px <= 0.0:
|
||||
return "Quarter" # finest — an unlaid-out viewport must under-resolve, not over-resolve
|
||||
var sample_spacing_m: float = world_extent_m / canvas_px
|
||||
var threshold_m: float = 2.0 * sample_spacing_m
|
||||
if DISTRICT_SPACING_M <= threshold_m:
|
||||
return "District"
|
||||
return "Quarter" # threshold too small for even District's own spacing -> finest legal rung
|
||||
|
||||
|
||||
## The metre spacing a given granularity_v2 tag resolves to — the inverse
|
||||
## lookup select_rung() itself doesn't need but callers computing "what world
|
||||
## extent does holding N districts at this rung actually cover" do (the
|
||||
## viewer's own extent-in-real-units header line, and the full-zoom-out
|
||||
## predicate below).
|
||||
static func spacing_for_rung(granularity_v2: String) -> float:
|
||||
for rung: Dictionary in RUNG_TABLE:
|
||||
if rung["granularity_v2"] == granularity_v2:
|
||||
return float(rung["spacing_m"])
|
||||
return DISTRICT_SPACING_M # unknown tag -> district, matching the server's "unknown -> District" posture
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: full-zoom-out reset (Jeroen's D-226 T-1143-rulings HARD condition —
|
||||
# "a full zoom-out resets to the original canonical planetary frame and
|
||||
# location", the ladder's top rest state, not a drifted pan state).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## True once the requested world extent (at the CURRENT zoom, before any
|
||||
## further zoom-out) covers the full body — i.e. the player has zoomed out as
|
||||
## far as the ladder goes and is looking at (at least) the whole equatorial
|
||||
## circumference. This is the crisp "fully zoomed out" definition the ticket
|
||||
## asks for: `world_extent_m >= circumference_m` at the fit-zoom floor, rather
|
||||
## than a fuzzy "close to MIN_ZOOM" heuristic (MIN_ZOOM is a UI clamp
|
||||
## constant, not a planetary-coverage fact — a small body could hit MIN_ZOOM
|
||||
## while still showing less than the whole circumference, and a huge body
|
||||
## could show full coverage before MIN_ZOOM is reached, depending on
|
||||
## CELL_PIXEL_SIZE/held_n; extent-vs-circumference is the honest test either
|
||||
## way).
|
||||
##
|
||||
## **Distinct from select_rung()'s own coverage ceiling** (District's
|
||||
## `DISTRICT_WINDOW_MAX_N * DISTRICT_SPACING_M = 131,072 m`, a fixed,
|
||||
## body-independent number) — this predicate's threshold is the actual
|
||||
## body's full circumference, always far larger than 131,072 m for any real
|
||||
## planet. The two compose in the expected order: extent crosses 131,072 m
|
||||
## first (select_rung() already reports Region well before this predicate
|
||||
## fires), and only once extent reaches the WHOLE circumference does the
|
||||
## top-rest-state reset itself trigger. There is no conflict between the two
|
||||
## thresholds, just two different questions ("which rung" vs. "are we at the
|
||||
## very top").
|
||||
static func is_fully_zoomed_out(world_extent_m: float, body_radius_km: float) -> bool:
|
||||
if body_radius_km <= 0.0:
|
||||
return false # no-radius (tiny test body) has no circumference concept — never auto-resets
|
||||
var circumference_m: float = TAU * body_radius_km * 1000.0
|
||||
return world_extent_m >= circumference_m
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: pure screen<->world math extracted from AtlasWindowViewer for
|
||||
# testability (the project's stated preference — static funcs over Control
|
||||
# instance methods wherever the math doesn't need the scene tree).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The world extent (metres) currently displayed across the LARGER viewport
|
||||
## dimension — the `E` half of the §5 rung-selection rule's `E/C`.
|
||||
## `cell_pixel_size` is the caller's district-at-zoom-1.0 constant
|
||||
## (AtlasWindowViewer.CELL_PIXEL_SIZE) — the composite's ON-SCREEN FOOTPRINT
|
||||
## is ALWAYS `held_n * cell_pixel_size * view_zoom` px for `held_n * DISTRICT_M`
|
||||
## metres of world, REGARDLESS of which rung is currently held (this is
|
||||
## exactly the invariant AtlasWindowOverlay.cell_grid_side_for_window()'s doc
|
||||
## establishes on the render side: `n` districts occupy a FIXED screen
|
||||
## footprint; only the DERIVED CELL RESOLUTION packed into that footprint
|
||||
## varies by rung). So the metres-per-screen-px sample density is a pure
|
||||
## function of `view_zoom` — DISTRICT_SPACING_M / (cell_pixel_size *
|
||||
## view_zoom) — with NO granularity_v2 parameter needed at all: the rung
|
||||
## itself is the OUTPUT of this calculation (via select_rung()), not an
|
||||
## input to it.
|
||||
static func world_extent_m(cell_pixel_size: float, view_zoom: float, viewport: Vector2) -> float:
|
||||
var canvas_px: float = cell_pixel_size * view_zoom
|
||||
if canvas_px <= 0.0:
|
||||
return 0.0
|
||||
var screen_px: float = maxf(viewport.x, viewport.y)
|
||||
return DISTRICT_SPACING_M / canvas_px * screen_px
|
||||
|
||||
|
||||
## The DistrictPos the viewport's screen center currently maps to, in RAW
|
||||
## absolute district space (un-wrapped, un-clamped — the caller canonicalizes
|
||||
## the final value it actually stores/sends, matching
|
||||
## canonicalize_district_center()'s own "canonicalize once, at the boundary"
|
||||
## discipline). Shared by AtlasWindowViewer's pan-edge refetch
|
||||
## (_maybe_refloat_window()) and rung-reselect refetch
|
||||
## (_maybe_reselect_rung()) so both read the SAME screen-to-district formula
|
||||
## rather than two copies that could drift.
|
||||
static func screen_center_to_district(
|
||||
viewport_size: Vector2,
|
||||
view_offset: Vector2,
|
||||
view_zoom: float,
|
||||
cell_pixel_size: float,
|
||||
held_center: Vector2i,
|
||||
held_n: int
|
||||
) -> Vector2i:
|
||||
var screen_center: Vector2 = viewport_size * 0.5
|
||||
var canvas_pt: Vector2 = (screen_center - view_offset) / view_zoom
|
||||
var cell: Vector2 = canvas_pt / cell_pixel_size
|
||||
var half: float = float(held_n) / 2.0
|
||||
var abs_col: float = float(held_center.x) - half + cell.x
|
||||
var abs_row: float = float(held_center.y) - half + cell.y
|
||||
return Vector2i(roundi(abs_col), roundi(abs_row))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1145 item 2 (moved here T-1153 for file-length/testability): WASD/
|
||||
# arrow-key held-pan direction + edge-scroll suppression/direction — pure
|
||||
# functions of explicit inputs, no Control/scene-tree dependency.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode
|
||||
## (not an InputMap action): W/S/A/D on this project's global InputMap are
|
||||
## already bound to move_north/move_south/move_east/move_west (gameplay
|
||||
## movement, D-054 mouse-relative facing) — reusing those actions here would
|
||||
## make holding W simultaneously pan this map AND queue a gameplay move
|
||||
## command server-side the moment this implant screen closes back to
|
||||
## gameplay (InputMapper polls Input.is_action_pressed() unconditionally,
|
||||
## with no implant-occlusion guard — a genuine pre-existing gap outside this
|
||||
## ticket's scope, not introduced here). Reading the raw physical keycode
|
||||
## instead of the shared action name means this screen's WASD use is fully
|
||||
## independent of whatever the gameplay action happens to be bound to — same
|
||||
## key, two UNRELATED consumers, neither needs to know about the other.
|
||||
## Arrow keys have no InputMap action bound at all, so they're conflict-free
|
||||
## either way. Returns a raw (non-normalized) direction — the caller
|
||||
## normalizes once after adding the edge-scroll contribution, so N+E doesn't
|
||||
## move faster than N alone. Reads the global `Input` singleton directly (not
|
||||
## injected) — this is the one function in this file that isn't a pure
|
||||
## function of its arguments, kept here anyway to sit beside its two siblings
|
||||
## below rather than splitting the WASD/edge-scroll trio across two files.
|
||||
static func held_pan_direction() -> Vector2:
|
||||
var direction := Vector2.ZERO
|
||||
if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP):
|
||||
direction.y -= 1.0
|
||||
if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN):
|
||||
direction.y += 1.0
|
||||
if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT):
|
||||
direction.x -= 1.0
|
||||
if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT):
|
||||
direction.x += 1.0
|
||||
return direction
|
||||
|
||||
|
||||
## T-1145 item 2: edge-scroll is suppressed (a) while the cursor is over UI
|
||||
## (`is_over_ui` — the caller's own _is_over_ui() result, passed in rather
|
||||
## than called from here since "what counts as UI" is viewer-specific) and
|
||||
## (b) while the application window itself lacks OS focus (`app_has_focus` —
|
||||
## otherwise a background window with the cursor left resting near its edge
|
||||
## from a previous session would silently pan while the player is doing
|
||||
## something else entirely; Godot's NOTIFICATION_APPLICATION_FOCUS_OUT/IN
|
||||
## make this directly detectable, the caller's own _notification() wires it).
|
||||
static func is_cursor_edge_scrolling(
|
||||
app_has_focus: bool,
|
||||
is_over_ui: bool,
|
||||
viewport_size: Vector2,
|
||||
mouse_pos: Vector2,
|
||||
edge_margin_px: float
|
||||
) -> bool:
|
||||
if not app_has_focus:
|
||||
return false
|
||||
if is_over_ui:
|
||||
return false
|
||||
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
|
||||
return false
|
||||
return (
|
||||
mouse_pos.x >= 0.0
|
||||
and mouse_pos.y >= 0.0
|
||||
and mouse_pos.x <= viewport_size.x
|
||||
and mouse_pos.y <= viewport_size.y
|
||||
and (
|
||||
mouse_pos.x < edge_margin_px
|
||||
or mouse_pos.y < edge_margin_px
|
||||
or mouse_pos.x > viewport_size.x - edge_margin_px
|
||||
or mouse_pos.y > viewport_size.y - edge_margin_px
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
## Direction toward whichever edge(s) the cursor is near — same shape as
|
||||
## held_pan_direction() (a raw, un-normalized Vector2 the caller combines and
|
||||
## normalizes once).
|
||||
static func edge_scroll_direction(
|
||||
viewport_size: Vector2, mouse_pos: Vector2, edge_margin_px: float
|
||||
) -> Vector2:
|
||||
var direction := Vector2.ZERO
|
||||
if mouse_pos.x < edge_margin_px:
|
||||
direction.x -= 1.0
|
||||
elif mouse_pos.x > viewport_size.x - edge_margin_px:
|
||||
direction.x += 1.0
|
||||
if mouse_pos.y < edge_margin_px:
|
||||
direction.y -= 1.0
|
||||
elif mouse_pos.y > viewport_size.y - edge_margin_px:
|
||||
direction.y += 1.0
|
||||
return direction
|
||||
|
||||
@@ -46,9 +46,21 @@ extends Node2D
|
||||
## window/overlay state, so the common case (panning within an already-held
|
||||
## window) is zero rebuild cost — draw_texture_rect() on an already-built
|
||||
## ImageTexture, same as any other texture draw.
|
||||
##
|
||||
## T-1152/T-1153: `n` (the response's echoed district extent) and the DERIVED
|
||||
## cell-grid side length are now DIFFERENT quantities at every rung except
|
||||
## District — cell_grid_side_for_window() computes the latter from `n` and
|
||||
## the response's own `granularity_v2` echo (mirroring the server's
|
||||
## `WindowGranularity::cell_grid_side` exactly), so a Region-rung response (a
|
||||
## FAR SPARSER cell grid than its district extent — see that Rust doc's
|
||||
## "inversion" note) renders through the exact same colorizer pipeline as
|
||||
## District/Quarter, satisfying the design doc §6 "one colorizer family, no
|
||||
## per-rung palettes" encoding-continuity requirement — no branch in
|
||||
## _cell_color()/_temp_cell_color()/etc. below needed any change at all.
|
||||
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
|
||||
## T-1145 item 3: interim presentation toggle — true renders the smoothed
|
||||
## Image/ImageTexture composite; false keeps the original crisp per-cell
|
||||
@@ -94,25 +106,67 @@ func _draw() -> void:
|
||||
if not (morphology is PackedByteArray or morphology is Array):
|
||||
return
|
||||
|
||||
# T-1152/T-1153: `n` (the response's echoed field) is ALWAYS window extent
|
||||
# in DISTRICTS at every rung (Dudley's wire contract, DistrictWindowLayer.n's
|
||||
# own doc) — the per-cell arrays (morphology/elev_q/etc.) are sized by the
|
||||
# DERIVED cell-grid side, `cell_grid_side_for_window()` below, which equals
|
||||
# `n` only at District granularity. Quarter packs MORE cells into the same
|
||||
# n-district extent (`n*4`); Region packs FEWER, since one region cell
|
||||
# spans 100 districts (`round(n/100).max(1)`). The on-screen EXTENT stays
|
||||
# `n * cell_px` regardless of rung (CELL_PIXEL_SIZE is defined as "one
|
||||
# DISTRICT at zoom=1.0" — see the viewer's own doc on that constant) so a
|
||||
# rung swap at a fixed pan/zoom never jumps the composite's screen footprint
|
||||
# (§6 "no layout jump") — only the TEXTURE RESOLUTION packed into that
|
||||
# footprint changes, exactly the "same colorizer family, different LoD"
|
||||
# picture the design doc describes.
|
||||
var grid_side: int = cell_grid_side_for_window(w)
|
||||
if grid_side <= 0:
|
||||
return
|
||||
|
||||
var cell_px: float = viewer.get_cell_pixel_size()
|
||||
var active_toggle: String = _active_toggle_overlay()
|
||||
|
||||
if COMPOSITE_SMOOTH:
|
||||
_draw_smoothed_composite(w, n, cell_px, active_toggle)
|
||||
_draw_smoothed_composite(w, n, grid_side, cell_px, active_toggle)
|
||||
else:
|
||||
_draw_crisp_composite(w, n, cell_px, active_toggle)
|
||||
_draw_crisp_composite(w, grid_side, n, cell_px, active_toggle)
|
||||
|
||||
|
||||
## T-1145 item 3: the smoothed path — build/reuse an n x n ImageTexture (one
|
||||
## pixel per district) and draw it scaled to (n*cell_px) with LINEAR
|
||||
## filtering. texture_filter is set on `self` (a CanvasItem property) once
|
||||
## per draw — cheap (a property write, not a texture rebuild) and correct
|
||||
## even the first time this runs (Godot's engine default already IS linear,
|
||||
## but this makes the choice explicit rather than relying on an implicit
|
||||
## project-wide default that could change).
|
||||
func _draw_smoothed_composite(w: Dictionary, n: int, cell_px: float, active_toggle: String) -> void:
|
||||
## The derived cell-grid side length (in CELLS) for a window dict `w` —
|
||||
## mirrors server/src/atlas/layer_proxy.rs's `WindowGranularity::cell_grid_side`
|
||||
## exactly, reading `w`'s OWN echoed `n`/`granularity_v2` fields rather than
|
||||
## trusting a caller's separately-tracked rung (the response is the source of
|
||||
## truth for what it actually contains). Falls back to `n` unchanged
|
||||
## (District's own identity mapping) for an old-shape response with no
|
||||
## `granularity_v2` key — matches the server's own "unknown -> District"
|
||||
## posture and AtlasWindowRequest.on_response()'s own default-to-District
|
||||
## disposition for the same field.
|
||||
static func cell_grid_side_for_window(w: Dictionary) -> int:
|
||||
var n: int = int(w.get("n", 0))
|
||||
var granularity_v2 := str(w.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT))
|
||||
match granularity_v2:
|
||||
AtlasWindowRequest.GRANULARITY_V2_QUARTER:
|
||||
return n * 4 # WINDOW_GRANULARITY_QUARTER multiplier — D-243 QUARTER_M
|
||||
AtlasWindowRequest.GRANULARITY_V2_REGION:
|
||||
return maxi(roundi(float(n) / 100.0), 1) # D-243 DISTRICTS_PER_REGION
|
||||
_:
|
||||
return n # District — 1:1
|
||||
|
||||
|
||||
## T-1145 item 3: the smoothed path — build/reuse a `grid_side` x `grid_side`
|
||||
## ImageTexture (one pixel per DERIVED CELL, T-1152 — not per district, see
|
||||
## cell_grid_side_for_window()'s doc) and draw it scaled to (n*cell_px), n
|
||||
## being the window's DISTRICT extent, with LINEAR filtering. texture_filter
|
||||
## is set on `self` (a CanvasItem property) once per draw — cheap (a property
|
||||
## write, not a texture rebuild) and correct even the first time this runs
|
||||
## (Godot's engine default already IS linear, but this makes the choice
|
||||
## explicit rather than relying on an implicit project-wide default that
|
||||
## could change).
|
||||
func _draw_smoothed_composite(
|
||||
w: Dictionary, n: int, grid_side: int, cell_px: float, active_toggle: String
|
||||
) -> void:
|
||||
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
_rebuild_texture_if_needed(w, n, active_toggle)
|
||||
_rebuild_texture_if_needed(w, grid_side, active_toggle)
|
||||
if _cached_texture == null:
|
||||
return
|
||||
var extent: float = float(n) * cell_px
|
||||
@@ -125,8 +179,9 @@ func _draw_smoothed_composite(w: Dictionary, n: int, cell_px: float, active_togg
|
||||
## read directly from `w` here (rather than threaded through as params, the
|
||||
## way the crisp path's _cell_color()/_apply_glaciation() calls already
|
||||
## receive them) since this function owns the whole per-cell loop, not just
|
||||
## one cell.
|
||||
func _rebuild_texture_if_needed(w: Dictionary, n: int, active_toggle: String) -> void:
|
||||
## one cell. `grid_side` (T-1152) is the DERIVED cell-grid side (see
|
||||
## cell_grid_side_for_window()), not the window's district extent `n`.
|
||||
func _rebuild_texture_if_needed(w: Dictionary, grid_side: int, active_toggle: String) -> void:
|
||||
if (
|
||||
is_same(_cache_window_ref, w)
|
||||
and _cache_active_toggle == active_toggle
|
||||
@@ -139,10 +194,10 @@ func _rebuild_texture_if_needed(w: Dictionary, n: int, active_toggle: String) ->
|
||||
var morphology: Variant = w.get("morphology")
|
||||
var n_cells: int = morphology.size()
|
||||
|
||||
var img := Image.create(n, n, false, Image.FORMAT_RGBA8)
|
||||
for row in range(n):
|
||||
for col in range(n):
|
||||
var i: int = row * n + col
|
||||
var img := Image.create(grid_side, grid_side, false, Image.FORMAT_RGBA8)
|
||||
for row in range(grid_side):
|
||||
for col in range(grid_side):
|
||||
var i: int = row * grid_side + col
|
||||
if i >= n_cells:
|
||||
img.set_pixel(col, row, Color.TRANSPARENT)
|
||||
continue
|
||||
@@ -157,16 +212,22 @@ func _rebuild_texture_if_needed(w: Dictionary, n: int, active_toggle: String) ->
|
||||
|
||||
## The ORIGINAL crisp per-cell path — kept byte-for-byte behind
|
||||
## COMPOSITE_SMOOTH := false so T-1143's design pass can compare both
|
||||
## renderings directly (see the class doc).
|
||||
func _draw_crisp_composite(w: Dictionary, n: int, cell_px: float, active_toggle: String) -> void:
|
||||
## renderings directly (see the class doc). `grid_side` (T-1152) is the
|
||||
## DERIVED cell-grid side (see cell_grid_side_for_window()); `n` (the
|
||||
## window's district extent) sizes the on-screen cell pitch so the total
|
||||
## drawn footprint stays `n * cell_px` regardless of rung.
|
||||
func _draw_crisp_composite(
|
||||
w: Dictionary, grid_side: int, n: int, cell_px: float, active_toggle: String
|
||||
) -> void:
|
||||
var elev_q: Variant = w.get("elev_q")
|
||||
var glaciation: Variant = w.get("glaciation")
|
||||
var morphology: Variant = w.get("morphology")
|
||||
var n_cells: int = morphology.size()
|
||||
var screen_cell_px: float = float(n) * cell_px / float(grid_side)
|
||||
|
||||
for row in range(n):
|
||||
for col in range(n):
|
||||
var i: int = row * n + col
|
||||
for row in range(grid_side):
|
||||
for col in range(grid_side):
|
||||
var i: int = row * grid_side + col
|
||||
if i >= n_cells:
|
||||
continue
|
||||
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
|
||||
@@ -175,7 +236,10 @@ func _draw_crisp_composite(w: Dictionary, n: int, cell_px: float, active_toggle:
|
||||
cell_color = _apply_glaciation(cell_color, glaciation, i)
|
||||
# +0.5 overdraw avoids hairline seams between adjacent cells —
|
||||
# same idiom as _draw_gen_district/_draw_gen_region_grid.
|
||||
draw_rect(Rect2(col * cell_px, row * cell_px, cell_px + 0.5, cell_px + 0.5), cell_color)
|
||||
draw_rect(
|
||||
Rect2(col * screen_cell_px, row * screen_cell_px, screen_cell_px + 0.5, screen_cell_px + 0.5),
|
||||
cell_color
|
||||
)
|
||||
|
||||
|
||||
## Which of the three mutually-exclusive toggle overlays (if any) is active.
|
||||
|
||||
@@ -41,13 +41,23 @@ 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
|
||||
|
||||
## T-1150 struct/key plumbing: this viewer only ever REQUESTS district
|
||||
## granularity today (requesting quarter is T-1153's job) — these constants
|
||||
## exist so the cache key / staleness guard below are granularity-aware from
|
||||
## day one, not bolted on later.
|
||||
## T-1150 struct/key plumbing: legacy int granularity — district is the
|
||||
## default for every caller that doesn't request quarter/Region explicitly.
|
||||
const DEFAULT_GRANULARITY: int = AtlasWindowCache.DISTRICT_GRANULARITY
|
||||
const DEFAULT_MIN_WL_M: int = 0
|
||||
|
||||
## T-1152/T-1153: the R5-redesigned string-tag granularity — "Quarter" |
|
||||
## "District" | "Region". This is the axis request_now()/request_debounced()'s
|
||||
## `granularity_v2` parameter actually varies; the legacy int
|
||||
## (DEFAULT_GRANULARITY) stays pinned at district for every call this object
|
||||
## makes, since v2 always wins server-side once present
|
||||
## (resolve_window_granularity_v2()'s documented precedence) and the legacy
|
||||
## int cannot express Region at all.
|
||||
const GRANULARITY_V2_QUARTER: String = AtlasWindowCache.GRANULARITY_V2_QUARTER
|
||||
const GRANULARITY_V2_DISTRICT: String = AtlasWindowCache.GRANULARITY_V2_DISTRICT
|
||||
const GRANULARITY_V2_REGION: String = AtlasWindowCache.GRANULARITY_V2_REGION
|
||||
const DEFAULT_GRANULARITY_V2: String = AtlasWindowCache.DEFAULT_GRANULARITY_V2
|
||||
|
||||
## Mirrors server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N /
|
||||
## WIRE_CAP_CELLS exactly (PR #191 review, Tyre C1). `_clamp_window_n_mirror()`
|
||||
## below reproduces `clamp_window_n()` bit-for-bit — the load-bearing-mirror
|
||||
@@ -57,12 +67,24 @@ const DEFAULT_MIN_WL_M: int = 0
|
||||
const SERVER_DISTRICT_WINDOW_MAX_N: int = 64
|
||||
const SERVER_WIRE_CAP_CELLS: int = 4_096
|
||||
|
||||
## T-1152/T-1153: mirrors server/src/atlas/layer_proxy.rs's
|
||||
## `DISTRICT_WINDOW_MAX_N_REGION` — the Region-only per-axis ceiling on `n`
|
||||
## (still window extent in DISTRICTS, per WindowGranularity::cell_grid_side's
|
||||
## doc: `sqrt(WIRE_CAP_CELLS) * DISTRICTS_PER_REGION = 64 * 100`). Keep in
|
||||
## sync with the server constant of the same name.
|
||||
const SERVER_DISTRICT_WINDOW_MAX_N_REGION: int = 6_400
|
||||
## Mirrors server/src/atlas/scale.rs's DISTRICTS_PER_REGION (D-243: one region
|
||||
## = 100 districts/side) — the divisor `_cell_grid_side_region_mirror()` needs
|
||||
## to reproduce `WindowGranularity::cell_grid_side`'s Region branch.
|
||||
const SERVER_DISTRICTS_PER_REGION: int = 100
|
||||
|
||||
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 _granularity: int = DEFAULT_GRANULARITY
|
||||
var _granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
var _min_wl_m: int = DEFAULT_MIN_WL_M
|
||||
var _pending: bool = false
|
||||
var _retries: int = 0
|
||||
@@ -127,23 +149,76 @@ static func _clamp_window_n_mirror(raw_n: int, granularity: int) -> int:
|
||||
return mini(n, maxi(cap_n, 1))
|
||||
|
||||
|
||||
## [`WindowGranularity`]-aware twin of `_clamp_window_n_mirror()` (T-1152/
|
||||
## T-1153) — mirrors server/src/atlas/layer_proxy.rs's `clamp_window_n_v2`
|
||||
## EXACTLY, including its Region branch, per the ticket's explicit
|
||||
## instruction ("replicate the loop exactly, there is NO closed form"). For
|
||||
## District/Quarter this delegates straight to `_clamp_window_n_mirror()`
|
||||
## (byte-identical clamped `n`, matching the server's own
|
||||
## `clamp_window_n_v2_matches_legacy_for_finer_than_district_rungs`
|
||||
## guarantee). For Region: per-axis clamp to
|
||||
## `SERVER_DISTRICT_WINDOW_MAX_N_REGION` (6,400), then halve `n` in a bounded
|
||||
## loop while `_cell_grid_side_region_mirror(n)^2 > SERVER_WIRE_CAP_CELLS` and
|
||||
## `n > 1` — there is no closed-form inverse of the rounding division
|
||||
## `cell_grid_side` uses at Region granularity, so this loop is the correct
|
||||
## (and only) mirror, not an approximation of one.
|
||||
static func _clamp_window_n_mirror_v2(raw_n: int, granularity_v2: String) -> int:
|
||||
if granularity_v2 != AtlasWindowCache.GRANULARITY_V2_REGION:
|
||||
var legacy_granularity: int = (
|
||||
DEFAULT_GRANULARITY
|
||||
if granularity_v2 == AtlasWindowCache.GRANULARITY_V2_DISTRICT
|
||||
else AtlasWindowCache.DISTRICT_GRANULARITY * 4 # "Quarter" — WINDOW_GRANULARITY_QUARTER
|
||||
)
|
||||
return _clamp_window_n_mirror(raw_n, legacy_granularity)
|
||||
|
||||
var n: int = clampi(raw_n, 1, SERVER_DISTRICT_WINDOW_MAX_N_REGION)
|
||||
while (
|
||||
_cell_grid_side_region_mirror(n) * _cell_grid_side_region_mirror(n) > SERVER_WIRE_CAP_CELLS
|
||||
and n > 1
|
||||
):
|
||||
n = int(n / 2.0)
|
||||
return maxi(n, 1)
|
||||
|
||||
|
||||
## Mirrors `WindowGranularity::cell_grid_side`'s Region branch EXACTLY:
|
||||
## `round(n / DISTRICTS_PER_REGION).max(1)` — the derived region-cell-grid
|
||||
## side length (in CELLS) for a window whose extent is `n` DISTRICTS. Rust's
|
||||
## `f64::round()` is round-half-away-from-zero; GDScript's `roundi()` matches
|
||||
## that for non-negative inputs (the only domain `n` — always >= 1 here —
|
||||
## can produce), so this is a faithful mirror, not an approximation.
|
||||
static func _cell_grid_side_region_mirror(n: int) -> int:
|
||||
var side: int = roundi(float(n) / float(SERVER_DISTRICTS_PER_REGION))
|
||||
return maxi(side, 1)
|
||||
|
||||
|
||||
## 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:
|
||||
## (a DistrictPos-equivalent Vector2i) for `body_id`, at `granularity_v2`
|
||||
## ("Quarter" | "District" | "Region", T-1152/T-1153 — District is the
|
||||
## default for every caller that doesn't ask for a different rung explicitly,
|
||||
## matching the legacy behavior byte-for-byte when omitted). 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/zoom — 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,
|
||||
granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
) -> void:
|
||||
_body_id = body_id
|
||||
_center = center
|
||||
_granularity = DEFAULT_GRANULARITY
|
||||
_granularity = DEFAULT_GRANULARITY # legacy int stays pinned at district — v2 always wins server-side
|
||||
_granularity_v2 = granularity_v2
|
||||
_min_wl_m = DEFAULT_MIN_WL_M
|
||||
_n = _clamp_window_n_mirror(n, _granularity) # Tyre C1 — mirror BEFORE storing/requesting
|
||||
_n = _clamp_window_n_mirror_v2(n, _granularity_v2) # Tyre C1, extended T-1152 — mirror BEFORE storing
|
||||
_debounce_timer.stop() # a direct request supersedes any pending debounced one
|
||||
|
||||
var cached: Variant = _cache.get_window(body_id, center, _n, _granularity, _min_wl_m)
|
||||
var cached: Variant = _cache.get_window(
|
||||
body_id, center, _n, _granularity, _min_wl_m, _granularity_v2
|
||||
)
|
||||
if cached != null:
|
||||
_pending = false
|
||||
_retries = 0
|
||||
@@ -152,7 +227,9 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF
|
||||
|
||||
_pending = true
|
||||
_retries = 0
|
||||
SimBridge.request_atlas_layers(body_id, "Topography", center, _n, _granularity, _min_wl_m)
|
||||
SimBridge.request_atlas_layers(
|
||||
body_id, "Topography", center, _n, _granularity, _min_wl_m, _granularity_v2
|
||||
)
|
||||
|
||||
|
||||
## Pan-triggered re-request (§4/§5: "150ms after the last drag-release, not
|
||||
@@ -160,27 +237,34 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF
|
||||
## 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:
|
||||
func request_debounced(
|
||||
body_id: String,
|
||||
center: Vector2i,
|
||||
n: int = DISTRICT_WINDOW_DEFAULT_N,
|
||||
granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
) -> void:
|
||||
_body_id = body_id
|
||||
_center = center
|
||||
_granularity = DEFAULT_GRANULARITY
|
||||
_granularity_v2 = granularity_v2
|
||||
_min_wl_m = DEFAULT_MIN_WL_M
|
||||
_n = _clamp_window_n_mirror(n, _granularity) # Tyre C1 — mirror BEFORE storing/requesting
|
||||
_n = _clamp_window_n_mirror_v2(n, _granularity_v2) # Tyre C1, extended T-1152 — mirror BEFORE storing
|
||||
_debounce_timer.start()
|
||||
|
||||
|
||||
func _on_debounce_timeout() -> void:
|
||||
request_now(_body_id, _center, _n)
|
||||
request_now(_body_id, _center, _n, _granularity_v2)
|
||||
|
||||
|
||||
## 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/granularity/min_wl_m
|
||||
## (the player panned or navigated away while a request was in flight, or a
|
||||
## different rung's derive answers a request for a different rung, T-1150) —
|
||||
## the echoed fields ARE the staleness guard (§2, extended T-1150), compared
|
||||
## here against what THIS object most recently asked for.
|
||||
## shape). Ignores responses for a stale body/center/n/granularity/min_wl_m/
|
||||
## granularity_v2 (the player panned, zoomed across a rung boundary, or
|
||||
## navigated away while a request was in flight, or a different rung's derive
|
||||
## answers a request for a different rung, T-1150/T-1152) — the echoed fields
|
||||
## ARE the staleness guard (§2, extended T-1150/T-1152), 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
|
||||
@@ -207,17 +291,25 @@ func on_response(response: Dictionary) -> void:
|
||||
var echoed_n := int(w.get("n", 0))
|
||||
var echoed_granularity := int(w.get("granularity", AtlasWindowCache.DISTRICT_GRANULARITY))
|
||||
var echoed_min_wl_m := int(w.get("min_wl_m", 0))
|
||||
# T-1152: granularity_v2 is ALWAYS populated on a real server response
|
||||
# (resolve_window_granularity_v2() always resolves to a concrete rung —
|
||||
# see DistrictWindowLayer.granularity_v2's own doc), but the mock/old-shape
|
||||
# response fixtures this suite's own tests build predate the field —
|
||||
# default to "District" so an old-shape mock keeps matching a
|
||||
# district-granularity request exactly as it did before this field existed.
|
||||
var echoed_granularity_v2 := str(w.get("granularity_v2", AtlasWindowCache.DEFAULT_GRANULARITY_V2))
|
||||
if (
|
||||
echoed_center != _center
|
||||
or echoed_n != _n
|
||||
or echoed_granularity != _granularity
|
||||
or echoed_min_wl_m != _min_wl_m
|
||||
or echoed_granularity_v2 != _granularity_v2
|
||||
):
|
||||
return # stale — answers a window we've since panned away from, or a different rung (§2/T-1150)
|
||||
return # stale — answers a window we've since panned/zoomed away from, or a different rung
|
||||
|
||||
_pending = false
|
||||
_retries = 0
|
||||
_cache.put(_body_id, _center, _n, w, _granularity, _min_wl_m)
|
||||
_cache.put(_body_id, _center, _n, w, _granularity, _min_wl_m, _granularity_v2)
|
||||
window_ready.emit(w)
|
||||
|
||||
|
||||
@@ -227,7 +319,7 @@ func _schedule_retry() -> void:
|
||||
func() -> void:
|
||||
if _pending:
|
||||
SimBridge.request_atlas_layers(
|
||||
_body_id, "Topography", _center, _n, _granularity, _min_wl_m
|
||||
_body_id, "Topography", _center, _n, _granularity, _min_wl_m, _granularity_v2
|
||||
)
|
||||
)
|
||||
|
||||
@@ -236,6 +328,21 @@ func is_pending() -> bool:
|
||||
return _pending
|
||||
|
||||
|
||||
## The v2 granularity ("Quarter" | "District" | "Region") this object most
|
||||
## recently asked for — the viewer reads this to know which rung the HELD
|
||||
## window (once it arrives) actually is, without threading a second copy of
|
||||
## the state through window_ready's payload.
|
||||
func get_granularity_v2() -> String:
|
||||
return _granularity_v2
|
||||
|
||||
|
||||
## Current window extent in districts, as CLAMPED — the viewer's rung-
|
||||
## selection math needs this to compute the held composite's real-world
|
||||
## extent regardless of which rung last resolved it.
|
||||
func get_n() -> int:
|
||||
return _n
|
||||
|
||||
|
||||
func get_cache() -> Variant:
|
||||
return _cache
|
||||
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
class_name AtlasWindowViewer
|
||||
extends Control
|
||||
|
||||
## Regional district-resolution window viewer (T-1138, D-226 T-1124
|
||||
## amendment). Entered via a click-through from the planetary AtlasViewer
|
||||
## (the 2026-07-21 §5 entry revision — NOT a zoom-threshold LOD swap).
|
||||
## Renders a DistrictWindowLayer composite: morphology base layer lightness-
|
||||
## modulated by elev_q, three switchable climate/vegetation overlays, and an
|
||||
## always-on glaciation ice-tint modifier (drawing itself is
|
||||
## AtlasWindowOverlay's job — this Control owns input, request orchestration,
|
||||
## chrome, and the pan/zoom transform).
|
||||
## Continuous cursor-anchored zoom ladder viewer (T-1153, superseding T-1138's
|
||||
## click-through-only entry per the D-226 T-1143-rulings amendment — see
|
||||
## enter_orbital()'s own doc). This IS the "regional" nav entry now (T-1152
|
||||
## client half): the whole ladder from the canonical orbital frame (Region
|
||||
## rung) down to District/Quarter granularity lives in ONE screen/Control,
|
||||
## not a separate planetary heightmap viewer + a windowed drill-down. Renders
|
||||
## a DistrictWindowLayer composite at whichever rung is currently held:
|
||||
## morphology base layer lightness-modulated by elev_q, three switchable
|
||||
## climate/vegetation overlays, and an always-on glaciation ice-tint modifier
|
||||
## (drawing itself is AtlasWindowOverlay's job — this Control owns input,
|
||||
## request orchestration, chrome, and the pan/zoom transform). One colorizer
|
||||
## family renders every rung unchanged (design doc §6) — AtlasWindowOverlay
|
||||
## never branches on granularity_v2 for COLOR, only for the derived
|
||||
## cell-grid's RESOLUTION (cell_grid_side_for_window()).
|
||||
##
|
||||
## Design notes (mirroring AtlasViewer's own split, D-226 §5):
|
||||
## Design notes (mirroring AtlasViewer's own split, D-226 §5, extended T-1153):
|
||||
## - _canvas (Node2D) holds AtlasWindowOverlay; pan = _canvas.position, zoom
|
||||
## = _canvas.scale — the SAME transform idiom as the planetary viewer.
|
||||
## - Zoom is ALWAYS client-side on the already-held composite (§5: "the
|
||||
## composite is a texture the client zooms client-side... from already-
|
||||
## held data") — it NEVER triggers a re-request. Only a pan past the held
|
||||
## window's edge does (§4/§5).
|
||||
## = _canvas.scale — the SAME transform idiom as the (retired) planetary
|
||||
## viewer.
|
||||
## - Zoom is client-side on the ALREADY-HELD composite frame-to-frame (never
|
||||
## blocks on a re-derive), but is CONTINUOUS AND UNCLAMPED ACROSS RUNGS
|
||||
## (T-1153, D-013 restored for this seam): crossing a rung's spacing
|
||||
## threshold (§5 rung-selection rule) fires a background request for the
|
||||
## new granularity while the OLD composite keeps drawing — progressive
|
||||
## refinement, no blank frame, no mode flip (§6). A pan past the held
|
||||
## window's edge re-requests the SAME rung at a new center (§4/§5,
|
||||
## unchanged from T-1138).
|
||||
## - Zooming fully out snaps to the CANONICAL planetary frame (Jeroen's HARD
|
||||
## condition) — see _maybe_reset_to_canonical_frame().
|
||||
## - _window_request (atlas_window_request.gd) owns the cache/debounce/
|
||||
## retry — this Control decides WHEN to call it (pan-edge detection,
|
||||
## entry), never talks to SimBridge directly itself.
|
||||
## rung-reselect, entry), never talks to SimBridge directly itself.
|
||||
##
|
||||
## Navigation (T-1145 item 2 — Jeroen's input-model ruling: LMB-drag panning
|
||||
## BREAKS click semantics with map objects, so it is removed entirely; clicks
|
||||
@@ -28,16 +41,38 @@ extends Control
|
||||
## WASD / arrow keys continuous pan, held (frame-rate independent, _process)
|
||||
## Edge scrolling cursor within EDGE_SCROLL_MARGIN_PX of a viewport
|
||||
## edge pans toward it (suppressed over UI / unfocused)
|
||||
## Mouse wheel zoom the held composite (client-side only, never refetches)
|
||||
## Esc back to the planetary view
|
||||
## Mouse wheel cursor-anchored zoom; crosses rungs continuously (T-1153)
|
||||
## Esc back (nav.pop() — the "district" nav-stack entry is
|
||||
## gone as a separate hop, see atlas_app.gd's own doc)
|
||||
|
||||
signal back_pressed
|
||||
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
const OVERLAY_BAR_HEADER_RESERVE: float = 360.0
|
||||
|
||||
const MIN_ZOOM: float = 0.5
|
||||
const MAX_ZOOM: float = 8.0
|
||||
## T-1153: MIN_ZOOM/MAX_ZOOM stay a wide safety clamp on the raw display
|
||||
## multiplier (never letting _view_zoom collapse to zero or run away toward
|
||||
## infinity) — they are NOT a rung boundary any more. Wheel zoom is now
|
||||
## CONTINUOUS and UNCLAMPED ACROSS RUNGS (D-226 T-1143-rulings amendment,
|
||||
## Jeroen's seam ruling: "D-013's zoom gesture owns spatial descent restored
|
||||
## for this seam"): crossing a rung's spacing threshold (§5's rung-selection
|
||||
## rule, AtlasWindowGeometry.select_rung()) re-requests a DIFFERENT
|
||||
## granularity window at the SAME apparent screen extent, it does not clamp
|
||||
## _view_zoom itself. The programmatic capture API (set_view(), T-1120) still
|
||||
## clamps to this same wide range — a capture harness driving a specific
|
||||
## zoom/offset pair has no rung-crossing concept of its own to trigger.
|
||||
##
|
||||
## MIN_ZOOM must stay low enough that fit_window_view()'s COVER fit for
|
||||
## enter_orbital()'s largest legal `n` (a whole equatorial circumference, up
|
||||
## to hundreds of thousands of districts on a gas-giant-scale body) is never
|
||||
## itself clamped — a clamped fit zoom would silently show LESS than the
|
||||
## whole body, breaking Jeroen's HARD condition ("the whole body fitted to
|
||||
## the canvas") at exactly the moment it matters most. 0.0005 covers a
|
||||
## ~120,000 km-radius body (n≈368,000 districts) at a 3840px 4K viewport with
|
||||
## headroom; a real fit_zoom this low is expected and correct at the
|
||||
## canonical orbital frame, not a bug.
|
||||
const MIN_ZOOM: float = 0.0005
|
||||
const MAX_ZOOM: float = 64.0
|
||||
const ZOOM_STEP: float = 1.15
|
||||
|
||||
## T-1145 item 2: WASD/arrow-key continuous pan speed, in CANVAS px/s at
|
||||
@@ -74,6 +109,14 @@ const COLOR_BG: Color = Color("#0d1117")
|
||||
## planetary data, not a different visual language.
|
||||
const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55)
|
||||
|
||||
## T-1153/R6: pending-refinement wash — the border-fade's referent repointed
|
||||
## to "the previous derived composite at this position" for a rung-crossing
|
||||
## zoom (progressive refinement leaves real data on screen, unlike the
|
||||
## no-composite-yet case COLOR_BORDER_FADE covers). Same hue family, much
|
||||
## lighter alpha — a hint that something sharper is arriving, not a claim
|
||||
## that the current view is empty or wrong.
|
||||
const COLOR_PENDING_REFINEMENT_WASH: Color = Color(0.20, 0.24, 0.30, 0.12)
|
||||
|
||||
## D-243: 2,048 m per district side.
|
||||
const DISTRICT_M: float = 2048.0
|
||||
|
||||
@@ -119,6 +162,16 @@ var _implant_theme = null
|
||||
var _window: Variant = null # current DistrictWindowLayer Dictionary, or null while waiting
|
||||
var _held_center: Vector2i = Vector2i.ZERO
|
||||
var _held_n: int = 32
|
||||
## T-1153: the granularity_v2 tag ("Quarter"/"District"/"Region") this viewer
|
||||
## is currently HOLDING (the last-adopted _window's own rung) — distinct from
|
||||
## _window_request.get_granularity_v2(), which is what's most recently been
|
||||
## REQUESTED (may be a finer/coarser rung already in flight while the held
|
||||
## composite is still the previous rung's, per the progressive-refinement
|
||||
## contract: hold the old composite, swap only when the new one arrives).
|
||||
## Defaults to District — the ladder's historical entry rung, and the correct
|
||||
## disposition for AtlasDescendGeometry click-through descent (still District,
|
||||
## see enter()'s own doc).
|
||||
var _held_granularity_v2: String = "District"
|
||||
|
||||
# ── Pan/zoom state ─────────────────────────────────────────────────────────
|
||||
var _view_offset: Vector2 = Vector2.ZERO
|
||||
@@ -202,9 +255,18 @@ func _exit_tree() -> void:
|
||||
|
||||
|
||||
## Enter the window screen centered on `district_center` (a DistrictPos-
|
||||
## equivalent Vector2i, from the planetary click-through's derived position —
|
||||
## §5's "pan center read as click point"). n defaults to the client's
|
||||
## interactive default (32), half the server's hard cap.
|
||||
## equivalent Vector2i, from a click-through's derived position — §5's "pan
|
||||
## center read as click point") at District granularity. n defaults to the
|
||||
## client's interactive default (32), half the server's hard cap. Kept as a
|
||||
## thin District-rung wrapper over _enter_at_rung() (T-1153) — a click-to-
|
||||
## descend-to-point shortcut on top of the continuous ladder (Jeroen's
|
||||
## ruling: "if a click-to-descend-to-point remains cheap to keep... wired to
|
||||
## the same descent path"). No screen currently calls this directly (the
|
||||
## retired planetary click-through it served no longer exists — see
|
||||
## atlas_app.gd's own doc); it survives as the landing point a future
|
||||
## map-object click (e.g. a settlement marker on the Region-rung view) would
|
||||
## wire into, and as a direct-call entry for tests/tools that want a
|
||||
## District-rung window without going through enter_orbital() first.
|
||||
##
|
||||
## T-1142: `district_center` is canonicalized (wrap column / clamp row)
|
||||
## BEFORE it becomes `_held_center` or reaches the request — matching the
|
||||
@@ -220,18 +282,72 @@ func enter(
|
||||
system: Dictionary,
|
||||
district_center: Vector2i,
|
||||
n: int = AtlasWindowRequest.DISTRICT_WINDOW_DEFAULT_N
|
||||
) -> void:
|
||||
var radius_km: float = float(body.get("body_radius_km", 0.0))
|
||||
var canonical_center: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||||
district_center, radius_km
|
||||
)
|
||||
_enter_at_rung(body, system, canonical_center, n, AtlasWindowRequest.GRANULARITY_V2_DISTRICT)
|
||||
|
||||
|
||||
## T-1153: enter the ladder at its TOP REST STATE — the canonical orbital
|
||||
## frame (Jeroen's HARD condition: "the whole body fitted to the canvas,
|
||||
## centered at the body's canonical origin"). This is the new "regional" nav
|
||||
## entry point (T-1152 client half — supersedes AtlasViewer's heightmap
|
||||
## texture as the sole entry): the player lands on a fully-derived Region-rung
|
||||
## view of the whole body, then wheel-zoom descends CONTINUOUSLY from there —
|
||||
## no separate planetary screen, no click-through required to reach the
|
||||
## windowed view at all (though enter() above stays wired for a
|
||||
## click-to-descend shortcut, per Jeroen's ruling).
|
||||
##
|
||||
## Canonical origin = district (0,0) — "district (0,0) sits at lon 0 / the
|
||||
## equator" (AtlasDescendGeometry's own doc, mirroring
|
||||
## district_profile.rs). Canonical extent = the WHOLE equatorial
|
||||
## circumference in districts (district_extent().cols), i.e. one full
|
||||
## circumnavigation — the same quantity is_fully_zoomed_out()/the
|
||||
## full-zoom-out reset (see _maybe_reset_to_canonical_frame()) test against,
|
||||
## so entry and reset always agree on what "the top" means. No-radius bodies
|
||||
## (tiny test bodies) fall back to the District-rung default window — there
|
||||
## is no planetary circumference concept to derive a Region-rung n from (same
|
||||
## fallback disposition AtlasDescendGeometry's own no-radius branches use
|
||||
## throughout).
|
||||
func enter_orbital(body: Dictionary, system: Dictionary) -> void:
|
||||
var radius_km: float = float(body.get("body_radius_km", 0.0))
|
||||
if radius_km <= 0.0:
|
||||
_enter_at_rung(
|
||||
body, system, Vector2i.ZERO,
|
||||
AtlasWindowRequest.DISTRICT_WINDOW_DEFAULT_N,
|
||||
AtlasWindowRequest.GRANULARITY_V2_DISTRICT
|
||||
)
|
||||
return
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var n: int = int(extent["cols"])
|
||||
_enter_at_rung(body, system, Vector2i.ZERO, n, AtlasWindowRequest.GRANULARITY_V2_REGION)
|
||||
|
||||
|
||||
## Shared entry path for enter()/enter_orbital() (T-1153) — `district_center`
|
||||
## must already be canonicalized by the caller (enter_orbital()'s (0,0) needs
|
||||
## no canonicalization; enter()'s does its own before calling in). Resets
|
||||
## every piece of held/request state for a fresh descent, exactly as the
|
||||
## pre-T-1153 enter() always did, plus the new _held_granularity_v2 tracking.
|
||||
func _enter_at_rung(
|
||||
body: Dictionary,
|
||||
system: Dictionary,
|
||||
district_center: Vector2i,
|
||||
n: int,
|
||||
granularity_v2: String
|
||||
) -> void:
|
||||
_body = body
|
||||
_system = system
|
||||
var radius_km: float = float(_body.get("body_radius_km", 0.0))
|
||||
_held_center = AtlasDescendGeometry.canonicalize_district_center(district_center, radius_km)
|
||||
_held_center = district_center
|
||||
_held_n = n
|
||||
_held_granularity_v2 = granularity_v2
|
||||
_window = null
|
||||
_user_adjusted = false
|
||||
_awaiting_first_window = true
|
||||
_fit_and_center()
|
||||
_window_request.reset()
|
||||
_window_request.request_now(_dict_str(_body, "body_id", ""), _held_center, n)
|
||||
_window_request.request_now(_dict_str(_body, "body_id", ""), _held_center, n, granularity_v2)
|
||||
_refresh_screen_header()
|
||||
grab_focus()
|
||||
queue_redraw()
|
||||
@@ -317,23 +433,47 @@ func _on_atlas_layers_received(response: Dictionary) -> void:
|
||||
_window_request.on_response(response)
|
||||
|
||||
|
||||
## T-1153: progressive refinement — this is the ONE place a new rung's
|
||||
## window gets adopted (swapped in), and it deliberately does NOT clear
|
||||
## `_window` first. The OLD composite (whatever rung it was) stays drawn
|
||||
## every frame up to and including the one before this call — no blank
|
||||
## frame, no mode flip (§6 acceptance criterion) — because `_window` is a
|
||||
## single-slot "the composite currently drawn" reference that only ever gets
|
||||
## REPLACED, never nulled, once a window has been adopted at least once
|
||||
## (enter()/_enter_at_rung() nulls it only at a fresh descent, a real
|
||||
## navigation event, not a rung swap).
|
||||
func _on_window_ready(window: Dictionary) -> void:
|
||||
# Only adopt the window if it still matches what THIS viewer is currently
|
||||
# showing — AtlasWindowRequest already filtered by its own last-asked
|
||||
# (center, n) via the echo (§2), but a cache-hit path can fire
|
||||
# synchronously from enter() before _held_center is what the signal
|
||||
# handler expects in a re-entrant call; comparing again here is cheap and
|
||||
# removes any ordering assumption between enter()'s two calls.
|
||||
# (center, n, granularity_v2) via the echo (§2/T-1150/T-1152), but a
|
||||
# cache-hit path can fire synchronously from enter() before _held_center
|
||||
# is what the signal handler expects in a re-entrant call; comparing
|
||||
# again here is cheap and removes any ordering assumption between
|
||||
# enter()'s two calls. granularity_v2 (T-1153) is compared too — a
|
||||
# district-rung response answering a request that's SINCE moved on to a
|
||||
# region-rung request (rapid wheel-zoom) must not be adopted just because
|
||||
# center/n happen to still match.
|
||||
var w_center := _vec_from_center(window.get("center", [0, 0]))
|
||||
var w_n := int(window.get("n", 0))
|
||||
if w_center != _held_center or w_n != _held_n:
|
||||
var w_granularity_v2 := str(
|
||||
window.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT)
|
||||
)
|
||||
if (
|
||||
w_center != _held_center
|
||||
or w_n != _held_n
|
||||
or w_granularity_v2 != _window_request.get_granularity_v2()
|
||||
):
|
||||
return
|
||||
_window = window
|
||||
_held_granularity_v2 = w_granularity_v2
|
||||
# T-1142: re-fit on the FIRST composite arrival only (the entry-time fit
|
||||
# may have used a viewport size the layout hadn't settled into yet — this
|
||||
# corrects it once) — never on a later pan-triggered arrival, and never
|
||||
# once the user has manually zoomed/panned (same _user_adjusted guard
|
||||
# enter()/NOTIFICATION_RESIZED use).
|
||||
# enter()/NOTIFICATION_RESIZED use). A later rung-swap arrival is
|
||||
# EXACTLY a "later pan/zoom-triggered arrival" in this sense — it must
|
||||
# never re-fit either, or a wheel-zoom-triggered rung swap would yank the
|
||||
# player's view back to a fitted framing mid-gesture.
|
||||
if _awaiting_first_window and not _user_adjusted:
|
||||
_fit_and_center()
|
||||
_awaiting_first_window = false
|
||||
@@ -343,8 +483,12 @@ func _on_window_ready(window: Dictionary) -> void:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# View transform (mirrors AtlasViewer's own — pan is real, zoom is client-side
|
||||
# only and NEVER triggers a re-request per §5)
|
||||
# View transform (mirrors AtlasViewer's own — pan is real; zoom is CURSOR-
|
||||
# ANCHORED and CONTINUOUS ACROSS RUNGS (T-1153, D-226 T-1143-rulings
|
||||
# amendment): the held composite is always drawn client-side-zoomed with NO
|
||||
# re-request, but crossing a rung's spacing threshold fires a NEW request at
|
||||
# the new granularity in the background (progressive refinement — see
|
||||
# _maybe_reselect_rung()'s own doc) while the OLD composite stays on screen.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@@ -355,6 +499,14 @@ func _apply_transform() -> void:
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## Cursor-anchored zoom (D-013 restored for this seam, Jeroen's ruling): the
|
||||
## CANVAS POINT under the cursor stays fixed on screen across the zoom step —
|
||||
## zooming toward the cursor, not the view center. Unclamped ACROSS RUNGS
|
||||
## (only the wide MIN_ZOOM/MAX_ZOOM safety clamp applies to the raw
|
||||
## multiplier itself — see that constant's own doc); after applying the new
|
||||
## zoom, checks whether the currently-displayed world extent now calls for a
|
||||
## different rung (_maybe_reselect_rung()) and whether the view has reached
|
||||
## the ladder's top rest state (_maybe_reset_to_canonical_frame()).
|
||||
func _zoom_at(mouse_pos: Vector2, factor: float) -> void:
|
||||
var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM)
|
||||
if is_equal_approx(new_zoom, _view_zoom):
|
||||
@@ -363,6 +515,97 @@ func _zoom_at(mouse_pos: Vector2, factor: float) -> void:
|
||||
_view_zoom = new_zoom
|
||||
_view_offset = mouse_pos - local_before * _view_zoom
|
||||
_apply_transform()
|
||||
if _maybe_reset_to_canonical_frame():
|
||||
return # the reset already re-fit + re-requested at the top rung
|
||||
_maybe_reselect_rung()
|
||||
|
||||
|
||||
## The world extent (metres) currently displayed across the LARGER viewport
|
||||
## dimension — the `E` half of the §5 rung-selection rule's `E/C`. A pure
|
||||
## function of `_view_zoom` (see AtlasWindowGeometry.world_extent_m()'s own
|
||||
## doc for why the currently-held rung is NOT an input: the composite's
|
||||
## on-screen footprint is rung-invariant by construction, so sample density
|
||||
## depends only on zoom). Thin wrapper kept here so callers don't need to
|
||||
## know the pure function lives on AtlasWindowGeometry (T-1153 — extracted
|
||||
## there, alongside select_rung(), to keep the §5 math unit-testable without
|
||||
## a Control in the tree).
|
||||
func _current_world_extent_m() -> float:
|
||||
return AtlasWindowGeometry.world_extent_m(CELL_PIXEL_SIZE, _view_zoom, get_rect().size)
|
||||
|
||||
|
||||
## §5 rung-selection rule + progressive refinement (T-1153): after a zoom
|
||||
## step, recompute the coarsest legal rung for the NOW-displayed world extent
|
||||
## (_current_world_extent_m() over the viewport's larger dimension). If that
|
||||
## differs from what's currently HELD on screen, request the new granularity
|
||||
## centered on the CURRENT screen-center's district position (reusing
|
||||
## _screen_center_district() — the same screen-to-district math
|
||||
## _maybe_refloat_window() already established, which is rung-agnostic since
|
||||
## CELL_PIXEL_SIZE is always district-based regardless of the held rung's
|
||||
## true cell spacing — see atlas_window_overlay.gd's cell_grid_side_for_window()
|
||||
## doc for why that's true).
|
||||
##
|
||||
## Progressive refinement, not block-on-derive: this does NOT touch `_window`
|
||||
## or `_held_granularity_v2` — the OLD composite keeps drawing every frame
|
||||
## (border-fade/pending-indicator per R6 shows the request is in flight, see
|
||||
## _draw_border_fade()) until _on_window_ready() adopts the NEW rung's window
|
||||
## once it actually arrives (§6 "no mode flip": never a blank frame, never a
|
||||
## clear-then-redraw).
|
||||
func _maybe_reselect_rung() -> void:
|
||||
if _held_n <= 0:
|
||||
return
|
||||
var world_extent_m: float = _current_world_extent_m()
|
||||
var canvas_px: float = maxf(get_rect().size.x, get_rect().size.y)
|
||||
var target_rung: String = AtlasWindowGeometry.select_rung(world_extent_m, canvas_px)
|
||||
if target_rung == _window_request.get_granularity_v2():
|
||||
return # already requesting (or holding) the rung this extent calls for
|
||||
var new_center: Vector2i = _screen_center_district()
|
||||
_held_center = new_center
|
||||
_window_request.request_debounced(
|
||||
_dict_str(_body, "body_id", ""), new_center, _held_n, target_rung
|
||||
)
|
||||
|
||||
|
||||
## The DistrictPos the current screen center maps to, in RAW absolute
|
||||
## district space (matching _maybe_refloat_window()'s own convention — only
|
||||
## the caller canonicalizes the final value it actually stores/sends). Thin
|
||||
## wrapper over AtlasWindowGeometry.screen_center_to_district() (T-1153 —
|
||||
## extracted alongside the rung-selection math for the same testability
|
||||
## reason) so both the pan-edge refetch and the rung-reselect refetch share
|
||||
## ONE screen-to-district formula rather than two copies that could drift
|
||||
## (the exact lesson _maybe_refloat_window()'s own doc already establishes
|
||||
## for the pan case).
|
||||
func _screen_center_district() -> Vector2i:
|
||||
var raw: Vector2i = AtlasWindowGeometry.screen_center_to_district(
|
||||
size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n
|
||||
)
|
||||
var radius_km: float = float(_body.get("body_radius_km", 0.0))
|
||||
return AtlasDescendGeometry.canonicalize_district_center(raw, radius_km)
|
||||
|
||||
|
||||
## Jeroen's HARD condition (D-226 T-1143-rulings amendment): "a full
|
||||
## zoom-out resets to the original canonical planetary frame and location" —
|
||||
## the ladder's TOP REST STATE, never a drifted pan/zoom-out state. Fires
|
||||
## when the CURRENTLY DISPLAYED world extent (at _held_granularity_v2, the
|
||||
## rung actually on screen — deliberately NOT the in-flight request's rung,
|
||||
## so this can't fire prematurely off a request that hasn't landed yet)
|
||||
## covers the whole body (AtlasWindowGeometry.is_fully_zoomed_out()) AND the
|
||||
## player isn't ALREADY sitting at the canonical frame (center == (0,0) —
|
||||
## re-entering the SAME enter_orbital() state on every zoom tick past the
|
||||
## threshold would fight a player trying to zoom back IN from the top, since
|
||||
## every zoom-out tick would keep re-snapping to the identical framing).
|
||||
## Returns true if it fired (the caller should skip _maybe_reselect_rung() —
|
||||
## the reset already re-requested at the canonical Region-rung window).
|
||||
func _maybe_reset_to_canonical_frame() -> bool:
|
||||
var radius_km: float = float(_body.get("body_radius_km", 0.0))
|
||||
if radius_km <= 0.0:
|
||||
return false # no-radius body — no canonical frame concept (matches enter_orbital()'s own guard)
|
||||
var world_extent_m: float = _current_world_extent_m()
|
||||
if not AtlasWindowGeometry.is_fully_zoomed_out(world_extent_m, radius_km):
|
||||
return false
|
||||
if _held_center == Vector2i.ZERO and _held_granularity_v2 == AtlasWindowRequest.GRANULARITY_V2_REGION:
|
||||
return false # already at the canonical frame — don't fight a zoom-in-from-the-top gesture
|
||||
enter_orbital(_body, _system)
|
||||
return true
|
||||
|
||||
|
||||
## Programmatic view control (T-1120 capture-API parity — must survive on
|
||||
@@ -412,15 +655,9 @@ func set_view(zoom: float, offset: Vector2) -> void:
|
||||
func _maybe_refloat_window() -> void:
|
||||
if _held_n <= 0:
|
||||
return
|
||||
var screen_center: Vector2 = size * 0.5
|
||||
var canvas_pt: Vector2 = (screen_center - _view_offset) / _view_zoom
|
||||
var cell: Vector2 = canvas_pt / CELL_PIXEL_SIZE
|
||||
# cell is in [0, _held_n) local window space when centered — half-window
|
||||
# offset from _held_center converts back to absolute district space.
|
||||
var half: float = float(_held_n) / 2.0
|
||||
var abs_col: float = float(_held_center.x) - half + cell.x
|
||||
var abs_row: float = float(_held_center.y) - half + cell.y
|
||||
var raw_new_center := Vector2i(roundi(abs_col), roundi(abs_row))
|
||||
var raw_new_center: Vector2i = AtlasWindowGeometry.screen_center_to_district(
|
||||
size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n
|
||||
)
|
||||
if raw_new_center == _held_center:
|
||||
return
|
||||
# Edge-crossing check: only re-request if the screen-center point has
|
||||
@@ -429,8 +666,9 @@ func _maybe_refloat_window() -> void:
|
||||
# ticked over by one cell near a boundary) must not spam a request every
|
||||
# frame. §4: "re-requests only when a pan carries the view past the held
|
||||
# window's edge."
|
||||
var local_col: float = abs_col - float(_held_center.x) + half
|
||||
var local_row: float = abs_row - float(_held_center.y) + half
|
||||
var half: float = float(_held_n) / 2.0
|
||||
var local_col: float = float(raw_new_center.x - _held_center.x) + half
|
||||
var local_row: float = float(raw_new_center.y - _held_center.y) + half
|
||||
var inside: bool = (
|
||||
local_col >= 0.0
|
||||
and local_col < float(_held_n)
|
||||
@@ -444,7 +682,13 @@ func _maybe_refloat_window() -> void:
|
||||
raw_new_center, radius_km
|
||||
)
|
||||
_held_center = new_center
|
||||
_window_request.request_debounced(_dict_str(_body, "body_id", ""), new_center, _held_n)
|
||||
# T-1153: pass the CURRENTLY HELD rung — panning must re-request at the
|
||||
# SAME granularity it's already showing, never silently reset to the
|
||||
# request object's District default (that default exists for callers with
|
||||
# no rung concept of their own; this viewer always has one).
|
||||
_window_request.request_debounced(
|
||||
_dict_str(_body, "body_id", ""), new_center, _held_n, _held_granularity_v2
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -457,22 +701,37 @@ func _draw() -> void:
|
||||
if _window == null:
|
||||
# §5 "what renders during the wait": a border-fade to the underlying
|
||||
# whole-body context rather than black/a spinner. This viewer has no
|
||||
# resident whole-body texture of its own (that lives on AtlasViewer,
|
||||
# which this screen has navigated away from) — the honest available
|
||||
# resident whole-body texture of its own — the honest available
|
||||
# substitute is a dim fade wash over the held composite's last-known
|
||||
# extent, reusing _gen_pending_indicator (via the request object's own
|
||||
# is_pending()) for the "still working" cue rather than new dressing.
|
||||
# extent, reusing the request object's own is_pending() for the
|
||||
# "still working" cue rather than new dressing.
|
||||
_draw_border_fade()
|
||||
elif _window_request and _window_request.is_pending():
|
||||
# T-1153/R6: the border-fade's REFERENT repointed — a rung-crossing
|
||||
# zoom (progressive refinement) leaves `_window` non-null (the OLD
|
||||
# composite is still the thing on screen, drawn by AtlasWindowOverlay
|
||||
# as always) while a NEW rung's request is in flight underneath it.
|
||||
# R6's ruling: "the mechanism survives; its target must be repointed
|
||||
# to 'the previous derived composite at this position'" — exactly
|
||||
# this case. A lighter pending wash (not the full opaque fade the
|
||||
# no-composite-at-all case uses, since there IS real data showing
|
||||
# through here, not emptiness) signals "sharper detail incoming"
|
||||
# without implying the current view is stale or wrong.
|
||||
_draw_pending_refinement_wash()
|
||||
|
||||
|
||||
func _draw_border_fade() -> void:
|
||||
if not _window_request or not _window_request.is_pending():
|
||||
return
|
||||
var extent: float = float(_held_n) * CELL_PIXEL_SIZE * _view_zoom
|
||||
var top_left: Vector2 = _view_offset
|
||||
draw_rect(Rect2(top_left, Vector2(extent, extent)), COLOR_BORDER_FADE)
|
||||
|
||||
|
||||
func _draw_pending_refinement_wash() -> void:
|
||||
var extent: float = float(_held_n) * CELL_PIXEL_SIZE * _view_zoom
|
||||
var top_left: Vector2 = _view_offset
|
||||
draw_rect(Rect2(top_left, Vector2(extent, extent)), COLOR_PENDING_REFINEMENT_WASH)
|
||||
|
||||
|
||||
func _build_screen_header() -> void:
|
||||
_screen_header = ImplantHeader.new()
|
||||
_screen_header.position = Vector2(PANEL_MARGIN, 16.0)
|
||||
@@ -485,15 +744,22 @@ func _build_screen_header() -> void:
|
||||
|
||||
## D-169/D-170 implant chrome (§5): location label (body name + coordinate,
|
||||
## T-1142 — see _location_label()) + extent-in-real-units subtitle, e.g.
|
||||
## "4.1 x 4.1 km . 2.0 km/cell".
|
||||
## "4.1 x 4.1 km . 2.0 km/cell". T-1153: the extent (`n` districts) is
|
||||
## rung-INVARIANT (n is always district extent — see
|
||||
## AtlasWindowOverlay.cell_grid_side_for_window()'s doc), but the km/cell
|
||||
## reading must reflect the HELD rung's actual spacing (2.048 km at District,
|
||||
## 0.512 km at Quarter, 204.8 km at Region) — this is the "continuous
|
||||
## metres-per-pixel/extent readout" the design doc §6 calls for in place of a
|
||||
## discrete "you are now in Quarter Mode" label (Jeroen's "no mode
|
||||
## transition" ruling): the number itself communicates the rung, no named
|
||||
## mode chrome does.
|
||||
func _refresh_screen_header() -> void:
|
||||
if _screen_header == null:
|
||||
return
|
||||
var location_label: String = _location_label()
|
||||
var extent_km: float = float(_held_n) * DISTRICT_M / 1000.0
|
||||
var extent_line: String = "%.1f x %.1f km · %.1f km/cell" % [
|
||||
extent_km, extent_km, DISTRICT_M / 1000.0
|
||||
]
|
||||
var spacing_km: float = AtlasWindowGeometry.spacing_for_rung(_held_granularity_v2) / 1000.0
|
||||
var extent_line: String = "%.1f x %.1f km · %.3f km/cell" % [extent_km, extent_km, spacing_km]
|
||||
var title: String = "REGIONAL — %s" % location_label.to_upper()
|
||||
_screen_header.set_content(title, extent_line)
|
||||
|
||||
@@ -608,84 +874,25 @@ func _apply_pan_delta(direction: Vector2, delta: float) -> void:
|
||||
|
||||
|
||||
## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode
|
||||
## (not an InputMap action): W/S/A/D on this project's global InputMap are
|
||||
## already bound to move_north/move_south/move_east/move_west (gameplay
|
||||
## movement, D-054 mouse-relative facing) — reusing those actions here would
|
||||
## make holding W simultaneously pan this map AND queue a gameplay move
|
||||
## command server-side the moment this implant screen closes back to
|
||||
## gameplay (InputMapper polls Input.is_action_pressed() unconditionally,
|
||||
## with no implant-occlusion guard — confirmed by reading input_mapper.gd
|
||||
## directly, a genuine pre-existing gap outside this ticket's scope, not
|
||||
## introduced here). Reading the raw physical keycode instead of the shared
|
||||
## action name means this screen's WASD use is fully independent of
|
||||
## whatever the gameplay action happens to be bound to — same key, two
|
||||
## UNRELATED consumers, neither needs to know about the other. Arrow keys
|
||||
## have no InputMap action bound at all (confirmed by grep across
|
||||
## project.godot's [input] section), so they're conflict-free either way.
|
||||
## Returns a raw (non-normalized) direction — the caller normalizes once
|
||||
## after adding the edge-scroll contribution, so N+E doesn't move faster
|
||||
## than N alone.
|
||||
## (not an InputMap action) — see AtlasWindowGeometry.held_pan_direction()'s
|
||||
## doc for the full W/S/A/D-vs-gameplay-movement rationale (moved there
|
||||
## T-1153 for file-length/testability, unchanged behavior).
|
||||
func _held_pan_direction() -> Vector2:
|
||||
var direction := Vector2.ZERO
|
||||
if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP):
|
||||
direction.y -= 1.0
|
||||
if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN):
|
||||
direction.y += 1.0
|
||||
if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT):
|
||||
direction.x -= 1.0
|
||||
if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT):
|
||||
direction.x += 1.0
|
||||
return direction
|
||||
return AtlasWindowGeometry.held_pan_direction()
|
||||
|
||||
|
||||
## T-1145 item 2: edge-scroll is suppressed (a) while the cursor is over UI
|
||||
## (_is_over_ui() — the SAME helper the click-era _gui_input guard used, per
|
||||
## the ticket's explicit "reuse _is_over_ui" instruction) and (b) while the
|
||||
## application window itself lacks OS focus (_app_has_focus — otherwise a
|
||||
## background window with the cursor left resting near its edge from a
|
||||
## previous session would silently pan while the player is doing something
|
||||
## else entirely; "if detectable" per the ticket, and Godot's
|
||||
## NOTIFICATION_APPLICATION_FOCUS_OUT/IN make it directly detectable, see
|
||||
## _notification()).
|
||||
## T-1145 item 2: edge-scroll suppression — see
|
||||
## AtlasWindowGeometry.is_cursor_edge_scrolling()'s doc (moved there T-1153).
|
||||
func _is_cursor_edge_scrolling() -> bool:
|
||||
if not _app_has_focus:
|
||||
return false
|
||||
if _is_over_ui(_last_mouse_pos):
|
||||
return false
|
||||
var sz: Vector2 = size
|
||||
if sz.x <= 0.0 or sz.y <= 0.0:
|
||||
return false
|
||||
var pos: Vector2 = _last_mouse_pos
|
||||
return (
|
||||
pos.x >= 0.0
|
||||
and pos.y >= 0.0
|
||||
and pos.x <= sz.x
|
||||
and pos.y <= sz.y
|
||||
and (
|
||||
pos.x < EDGE_SCROLL_MARGIN_PX
|
||||
or pos.y < EDGE_SCROLL_MARGIN_PX
|
||||
or pos.x > sz.x - EDGE_SCROLL_MARGIN_PX
|
||||
or pos.y > sz.y - EDGE_SCROLL_MARGIN_PX
|
||||
)
|
||||
return AtlasWindowGeometry.is_cursor_edge_scrolling(
|
||||
_app_has_focus, _is_over_ui(_last_mouse_pos), size, _last_mouse_pos, EDGE_SCROLL_MARGIN_PX
|
||||
)
|
||||
|
||||
|
||||
## Direction toward whichever edge(s) the cursor is near — same shape as
|
||||
## _held_pan_direction() (a raw, un-normalized Vector2 the caller combines
|
||||
## and normalizes once).
|
||||
## Direction toward whichever edge(s) the cursor is near — see
|
||||
## AtlasWindowGeometry.edge_scroll_direction()'s doc (moved there T-1153).
|
||||
func _edge_scroll_direction() -> Vector2:
|
||||
var sz: Vector2 = size
|
||||
var pos: Vector2 = _last_mouse_pos
|
||||
var direction := Vector2.ZERO
|
||||
if pos.x < EDGE_SCROLL_MARGIN_PX:
|
||||
direction.x -= 1.0
|
||||
elif pos.x > sz.x - EDGE_SCROLL_MARGIN_PX:
|
||||
direction.x += 1.0
|
||||
if pos.y < EDGE_SCROLL_MARGIN_PX:
|
||||
direction.y -= 1.0
|
||||
elif pos.y > sz.y - EDGE_SCROLL_MARGIN_PX:
|
||||
direction.y += 1.0
|
||||
return direction
|
||||
return AtlasWindowGeometry.edge_scroll_direction(size, _last_mouse_pos, EDGE_SCROLL_MARGIN_PX)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
class_name DistrictScreen
|
||||
extends Control
|
||||
## Regional district-window viewer screen for AtlasApp (T-1138, D-226 T-1124
|
||||
## amendment). Thin wrapper around AtlasWindowViewer, mirroring
|
||||
## RegionalScreen's own shape exactly — enter/leave are the nav interface.
|
||||
##
|
||||
## Entered via a click-through from AtlasViewer (the "regional" screen),
|
||||
## carrying the derived DistrictPos the player clicked (§5's entry-revision:
|
||||
## "pan center read as click point"). Esc goes back to "regional" (the
|
||||
## planetary heightmap for the same body) — a nav.pop(), not a fresh push, so
|
||||
## the planetary view's own pan/zoom-removed FIXED state is exactly where the
|
||||
## player left it.
|
||||
|
||||
signal back_requested
|
||||
|
||||
var _viewer: AtlasWindowViewer = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_viewer = AtlasWindowViewer.new()
|
||||
_viewer.name = "AtlasWindowViewer"
|
||||
add_child(_viewer)
|
||||
_viewer.back_pressed.connect(_on_viewer_back)
|
||||
|
||||
|
||||
func enter(payload: Dictionary) -> void:
|
||||
var body: Dictionary = payload.get("body", {})
|
||||
var system: Dictionary = payload.get("system", {})
|
||||
var center: Vector2i = payload.get("district_center", Vector2i.ZERO)
|
||||
_viewer.enter(body, system, center)
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _on_viewer_back() -> void:
|
||||
back_requested.emit()
|
||||
@@ -1,30 +1,48 @@
|
||||
class_name RegionalScreen
|
||||
extends Control
|
||||
## Regional heightmap viewer screen for AtlasApp (#844, D-191).
|
||||
## Thin wrapper around AtlasViewer; enter/leave are the nav interface.
|
||||
## Regional zoom-ladder screen for AtlasApp (#844, D-191; superseded T-1153 —
|
||||
## D-226 T-1143-rulings amendment). Thin wrapper around AtlasWindowViewer,
|
||||
## entering at the CANONICAL ORBITAL FRAME (Region rung) via enter_orbital()
|
||||
## instead of AtlasViewer's retired heightmap-texture show_body() path —
|
||||
## enter/leave are still the nav interface, unchanged shape.
|
||||
##
|
||||
## T-1152 client half: this is the ONE screen for the whole ladder now —
|
||||
## there is no separate "district" nav hop for the windowed drill-down
|
||||
## (D-013 "the zoom gesture owns spatial descent" restored for this seam
|
||||
## means descent is a CONTINUOUS in-screen zoom, not a nav-stack push). Esc
|
||||
## from anywhere in the ladder is a single nav.pop() back to whatever pushed
|
||||
## "regional" (system screen) — see atlas_app.gd's _handle_key(), unchanged
|
||||
## from before this ticket (it already routed Esc through nav.pop() for any
|
||||
## screen that isn't "reach"/"system"-with-a-panel-open).
|
||||
##
|
||||
## `district_descend_requested`/`economics_link_requested` signals retire
|
||||
## with AtlasViewer's click-through (the reticle/hover-to-descend affordance
|
||||
## — Jeroen's ruling: retired as the SOLE entry, and no cheap click-target
|
||||
## exists on the orbital Region-rung view to wire a shortcut onto yet, unlike
|
||||
## a future settlement-marker click which WOULD have a natural landing
|
||||
## point — see AtlasWindowViewer's own doc on why enter() still exists as a
|
||||
## District-rung entry point for exactly that future wiring).
|
||||
## economics_link_requested is deferred with AtlasViewer's city-click sidebar
|
||||
## (see the batch report for the full list of what's deferred vs. carried).
|
||||
|
||||
signal back_requested
|
||||
signal economics_link_requested(system_id: String)
|
||||
signal district_descend_requested(district_center: Vector2i) # T-1138, forwarded from AtlasViewer
|
||||
|
||||
var _viewer: AtlasViewer = null
|
||||
var _viewer: AtlasWindowViewer = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_viewer = AtlasViewer.new()
|
||||
_viewer.name = "AtlasViewer"
|
||||
_viewer = AtlasWindowViewer.new()
|
||||
_viewer.name = "AtlasWindowViewer"
|
||||
add_child(_viewer)
|
||||
_viewer.back_pressed.connect(_on_viewer_back)
|
||||
_viewer.economics_link_requested.connect(_on_viewer_economics_link)
|
||||
_viewer.district_descend_requested.connect(_on_viewer_district_descend)
|
||||
|
||||
|
||||
func enter(payload: Dictionary) -> void:
|
||||
var body: Dictionary = payload.get("body", {})
|
||||
var system: Dictionary = payload.get("system", {})
|
||||
_viewer.show_body(body, system)
|
||||
_viewer.enter_orbital(body, system)
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
@@ -33,11 +51,3 @@ func leave() -> void:
|
||||
|
||||
func _on_viewer_back() -> void:
|
||||
back_requested.emit()
|
||||
|
||||
|
||||
func _on_viewer_economics_link(system_id: String) -> void:
|
||||
economics_link_requested.emit(system_id)
|
||||
|
||||
|
||||
func _on_viewer_district_descend(district_center: Vector2i) -> void:
|
||||
district_descend_requested.emit(district_center)
|
||||
|
||||
Reference in New Issue
Block a user