fix(client): T-1153/T-1152 round 3 — unified rung coverage model, reselect clamp mirror, orbital tile-set mosaic
Bug A (zoom reselection dead): select_rung() redesigned from the two-gate split to a unified per-rung coverage-ceiling walk (MAX_COVERAGE_M, finest-first) — which also restores District as a reachable rung (33-131km extent band; the two-gate model had made it structurally unreachable). Plus the THIRD instance of the PR #191 C1 clamp-mirror bug class: _maybe_reselect_rung fed the old rung's _held_n raw into the new rung's request — the server clamped, the held value didn't, every cross-rung response stale-dropped. Re-clamped at the boundary; the leaving_tile_mode flag also fixes a stale request-granularity edge case. Bug B (orbital = whole body): genuine tile-set model. compute_tile_grid composes ceil-divided Region-tile centers (canonicalized column wrap, row clamp, Dictionary-set dedup; Lendel = 3x2 = 6 tiles); AtlasWindowTileSet owns one AtlasWindowRequest per tile (reusing all tested request/cache/debounce machinery), fans responses out, per-tile tile_ready — genuinely progressive; the overlay draws arrived tiles as a mosaic under the one view transform. enter_orbital branches to tile mode when >1 tile; single-window path unchanged below the coverage ceiling. E/W wrap at tile seams live-confirmed (col -6400 wraps to 12739). Pole- row dedup verified correct but flagged honestly as currently-unreachable defensive logic (no real radius triggers row collision). All fixes + fallout verified via revert/confirm-fails/restore. Targeted suites 256/256; gdlint clean; new 20-test tile-set suite.
This commit is contained in:
@@ -15,7 +15,14 @@ extends RefCounted
|
||||
## 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.
|
||||
## side effects live on the viewer" split as the rest of this file. Live
|
||||
## round 3 also adds the orbital-rest-state TILE GRID computation
|
||||
## (compute_tile_grid(), near the bottom) — reuses
|
||||
## AtlasDescendGeometry.district_extent()/canonicalize_district_center() for
|
||||
## the SAME wrap/clamp discipline every other piece of this cluster already
|
||||
## depends on, hence the preload below (no circular dependency:
|
||||
## atlas_descend_geometry.gd never references this file).
|
||||
const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
|
||||
## D-243 rung spacings, metres/cell — the SAME constants
|
||||
## server/src/atlas/scale.rs and layer_proxy.rs's WindowGranularity::spacing_m
|
||||
@@ -40,11 +47,66 @@ const RUNG_TABLE: Array = [
|
||||
## 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.
|
||||
## mirror of the same constant, kept in sync there).
|
||||
const DISTRICT_WINDOW_MAX_N: int = 64
|
||||
|
||||
## Wire-size ceiling (mirrors AtlasWindowRequest.SERVER_WIRE_CAP_CELLS /
|
||||
## server/src/atlas/layer_proxy.rs's WIRE_CAP_CELLS) — the cell-count cap
|
||||
## EVERY rung's single window is clamped against, per
|
||||
## `_clamp_window_n_mirror`/`_clamp_window_n_mirror_v2`'s own formulas.
|
||||
const WIRE_CAP_CELLS: int = 4_096
|
||||
|
||||
## Region's own per-axis ceiling (mirrors
|
||||
## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION /
|
||||
## server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N_REGION).
|
||||
const DISTRICT_WINDOW_MAX_N_REGION: int = 6_400
|
||||
|
||||
## Per-tile district extent for the orbital-rest-state tile grid
|
||||
## (compute_tile_grid(), near the bottom of this file) — the SAME `n` a
|
||||
## single Region request uses at its own per-axis ceiling. Each tile
|
||||
## requests exactly this many districts on a side — the largest single
|
||||
## window the wire budget allows, so tiling uses the FEWEST tiles that can
|
||||
## cover a given body.
|
||||
const TILE_N: int = DISTRICT_WINDOW_MAX_N_REGION
|
||||
|
||||
## **Live round 3 finding (the actual root cause of "zoom-driven rung
|
||||
## reselection never fires"):** each rung's SINGLE WINDOW has a hard MAXIMUM
|
||||
## real-world coverage, derived from the SAME wire-size clamp
|
||||
## (`_clamp_window_n_mirror_v2`) the request layer already enforces — District
|
||||
## and Quarter are NOT exempt from this the way the original (§5-literal)
|
||||
## design assumed. A rung whose own single-window coverage is smaller than
|
||||
## the CURRENTLY DISPLAYED world extent cannot legally be selected: the
|
||||
## server would clamp `n` down to fit its own wire budget, producing a
|
||||
## composite that covers only a FRACTION of the viewport — visually a tiny
|
||||
## box in the middle of the screen, and (the bug this constant's discovery
|
||||
## fixes) a composite whose CLAMPED `n` no longer matches whatever `_held_n`
|
||||
## the viewer was still carrying from the PREVIOUS rung, permanently failing
|
||||
## `_on_window_ready()`'s staleness check. Computed here ONCE, from the same
|
||||
## constants `_clamp_window_n_mirror_v2` uses, rather than re-derived per
|
||||
## rung inline — see MAX_COVERAGE_M below.
|
||||
##
|
||||
## - Quarter: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/4) = 16` districts ->
|
||||
## cell-grid side `16*4 = 64` cells -> `64 * QUARTER_SPACING_M = 32,768 m`.
|
||||
## - District: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/1) = 64` districts ->
|
||||
## `64 * DISTRICT_SPACING_M = 131,072 m` (unchanged from the original
|
||||
## coverage-ceiling constant this replaces/generalizes).
|
||||
## - Region: per-axis cap `DISTRICT_WINDOW_MAX_N_REGION = 6,400` districts ->
|
||||
## `6,400 * DISTRICT_SPACING_M = 13,107,200 m` — this is a SINGLE window's
|
||||
## ceiling; bug B's progressive tiling composes MULTIPLE Region windows to
|
||||
## cover extents beyond this (see the viewer's tile-set model), so this
|
||||
## constant alone does NOT bound what the ORBITAL REST STATE can show —
|
||||
## only what one Region REQUEST's response covers.
|
||||
const MAX_COVERAGE_M: Dictionary = {
|
||||
"Quarter": 64.0 * QUARTER_SPACING_M,
|
||||
"District": float(DISTRICT_WINDOW_MAX_N) * DISTRICT_SPACING_M,
|
||||
"Region": float(DISTRICT_WINDOW_MAX_N_REGION) * DISTRICT_SPACING_M,
|
||||
}
|
||||
|
||||
## Rungs ordered FINEST-first — select_rung() walks this to find the finest
|
||||
## rung whose own single-window coverage ceiling still covers the current
|
||||
## extent (never a rung that would silently under-cover the viewport).
|
||||
const RUNGS_FINEST_FIRST: Array = ["Quarter", "District", "Region"]
|
||||
|
||||
|
||||
## Fit-and-center: given the viewport size and the window's side length in
|
||||
## districts, compute the zoom/offset that COVERS the viewport (fills it edge
|
||||
@@ -160,90 +222,61 @@ static func clamp_pan_offset_to_pole_wall(
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## 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.
|
||||
## Rung-selection rule — REDESIGNED (live round 3 finding, superseding the
|
||||
## original §5-literal `2x`-visual-tolerance-only reading): select the
|
||||
## FINEST rung whose OWN single-window coverage ceiling (MAX_COVERAGE_M)
|
||||
## still covers the current world extent. Walks RUNGS_FINEST_FIRST
|
||||
## (Quarter, District, Region) and returns the first whose ceiling is `>=
|
||||
## world_extent_m` — the coarser rungs are tried only once the finer ones
|
||||
## genuinely cannot show the requested extent in a single window.
|
||||
##
|
||||
## **Why this replaces the original `2x`-visual-tolerance formula entirely**
|
||||
## (not just patches its Region case, as an earlier version of this function
|
||||
## did): the design doc §5 rule ("coarsest rung whose spacing <= 2*(E/C)")
|
||||
## implicitly assumes every rung's single window CAN cover any extent the
|
||||
## rule selects it for — true for an unbounded wire budget, false here.
|
||||
## `_clamp_window_n_mirror_v2` (AtlasWindowRequest) — the SAME clamp the
|
||||
## server itself enforces — caps every rung's single-window real-world
|
||||
## coverage at a fixed maximum (`MAX_COVERAGE_M`, this file): Quarter
|
||||
## 32,768 m, District 131,072 m, Region 13,107,200 m (per single Region
|
||||
## window — bug B's progressive TILING composes several to cover more, a
|
||||
## viewer-level concern this function doesn't need to know about). A rung
|
||||
## selected for an extent BEYOND its own ceiling would have its `n` silently
|
||||
## clamped server-side to something covering only a FRACTION of the
|
||||
## viewport — visually a tiny box, AND (the actual live-round bug this
|
||||
## redesign fixes) a clamped echo that no longer matches whatever `n` the
|
||||
## viewer was still carrying from the rung it's leaving, permanently failing
|
||||
## the staleness check in `_on_window_ready()`.
|
||||
##
|
||||
## **The `2x` visual-tolerance rule becomes REDUNDANT under this model, not
|
||||
## contradicted by it** — verified numerically: at the exact zoom where
|
||||
## Quarter's coverage ceiling (32,768 m) is reached, the `2x` threshold
|
||||
## (`2*E/C`) works out to ~41 m, far finer than even Quarter's own 512 m
|
||||
## spacing. This means by the time coverage RELEASES a rung, the visual
|
||||
## tolerance would ALREADY prefer something finer than that rung offers —
|
||||
## i.e. every rung this function selects is, by construction, at or past its
|
||||
## own "as fine as it can usefully be" point. The visual-tolerance rule's
|
||||
## fine-end guarantee (never show a coarser composite than the screen can
|
||||
## resolve) is automatically satisfied by "select the finest rung whose
|
||||
## coverage allows it" — there is no case where the coverage rule picks a
|
||||
## rung the visual rule would have rejected as too coarse, because Quarter
|
||||
## (the finest rung) is always the answer whenever ANY rung's visual
|
||||
## tolerance alone would have mattered.
|
||||
##
|
||||
## `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.
|
||||
## the same answer — the caller picks one, consistently). `canvas_px` is
|
||||
## kept as a parameter (unused by the coverage rule itself) for signature
|
||||
## stability with existing callers and because a future finer-than-Quarter
|
||||
## rung (block/tile, D-226(d)-gated, out of scope here) would plausibly need
|
||||
## it again.
|
||||
##
|
||||
## 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
|
||||
static func select_rung(world_extent_m: float, _canvas_px: float) -> String:
|
||||
for rung: String in RUNGS_FINEST_FIRST:
|
||||
if world_extent_m <= float(MAX_COVERAGE_M[rung]):
|
||||
return rung
|
||||
return "Region" # extent exceeds even Region's own single-window ceiling -> still Region (tiling's job)
|
||||
|
||||
|
||||
## The metre spacing a given granularity_v2 tag resolves to — the inverse
|
||||
@@ -438,3 +471,116 @@ static func edge_scroll_direction(
|
||||
elif mouse_pos.y > viewport_size.y - edge_margin_px:
|
||||
direction.y += 1.0
|
||||
return direction
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153, live round 3 (Jeroen's ruling, design doc §4): the orbital REST
|
||||
# STATE must TILE — a single wire-capped Region window (MAX_COVERAGE_M["Region"]
|
||||
# = 13,107,200 m) covers only a fraction of a real body's circumference
|
||||
# (Lendel: 39,197,023 m — a single window is ~a third of the body). The top
|
||||
# rest state composes MULTIPLE Region windows ("progressive capped-density
|
||||
# TILING", design doc §4) into a mosaic under ONE view transform.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Compute the tile-set grid for the orbital rest state: the minimal set of
|
||||
## Region-granularity window CENTERS (each `TILE_N` districts wide) whose
|
||||
## union covers the WHOLE body — columns wrap (canonicalize_district_center()'s
|
||||
## own east-west periodicity), rows clamp at the poles. Returns an Array of
|
||||
## Vector2i tile centers, ALREADY CANONICALIZED (duplicates from pole-row
|
||||
## clamping or (degenerately) column-wrap collisions are DEDUPED — a tiny
|
||||
## body where multiple nominal tile rows clamp to the identical pole-adjacent
|
||||
## row, or multiple nominal tile columns wrap to the identical column, must
|
||||
## not request/draw the same tile twice).
|
||||
##
|
||||
## Grid layout: `cols_tiles = ceil(cols / TILE_N)` tiles span the full
|
||||
## circumference (evenly spaced, centered on column 0 — the canonical
|
||||
## origin); `rows_tiles = ceil(2*rows_half / TILE_N)` tiles span pole to
|
||||
## pole (centered on row 0). Each tile's PRE-CANONICALIZATION center is
|
||||
## `(tile_index - (tile_count-1)/2) * TILE_N` along its axis — symmetric
|
||||
## around the canonical origin, matching enter_orbital()'s own "canonical
|
||||
## origin = (0,0)" convention (AtlasDescendGeometry's doc) so the tile set's
|
||||
## own center-of-mass lands exactly on the canonical frame, not offset from
|
||||
## it.
|
||||
##
|
||||
## No-radius bodies (tiny test bodies, `body_radius_km <= 0`) return a
|
||||
## single tile at (0,0) — matching enter_orbital()'s own no-radius fallback
|
||||
## disposition (no circumference/tiling concept for a body with no radius).
|
||||
static func compute_tile_grid(body_radius_km: float) -> Array:
|
||||
if body_radius_km <= 0.0:
|
||||
return [Vector2i.ZERO]
|
||||
|
||||
var extent: Dictionary = AtlasDescendGeometryRef.district_extent(body_radius_km)
|
||||
var cols: int = int(extent["cols"])
|
||||
var rows_half: int = int(extent["rows_half"])
|
||||
var rows_total: int = rows_half * 2
|
||||
|
||||
var cols_tiles: int = maxi(1, ceili(float(cols) / float(TILE_N)))
|
||||
var rows_tiles: int = maxi(1, ceili(float(rows_total) / float(TILE_N)))
|
||||
|
||||
var col_centers: Array = []
|
||||
for tx in range(cols_tiles):
|
||||
var raw_col: int = roundi((float(tx) - (float(cols_tiles - 1) * 0.5)) * float(TILE_N))
|
||||
col_centers.append(raw_col)
|
||||
|
||||
var row_centers: Array = []
|
||||
for ty in range(rows_tiles):
|
||||
var raw_row: int = roundi((float(ty) - (float(rows_tiles - 1) * 0.5)) * float(TILE_N))
|
||||
row_centers.append(raw_row)
|
||||
|
||||
# Dedup via a Dictionary keyed on the CANONICALIZED (col, row) pair —
|
||||
# Godot Dictionary keys compare Vector2i by value, so this is a proper
|
||||
# set. Insertion order is preserved (Godot Dictionaries are
|
||||
# order-preserving), giving a deterministic tile ORDER too — the same
|
||||
# grid always requests/draws in the same sequence, useful for progressive
|
||||
# arrival to read as a stable left-to-right, top-to-bottom fill rather
|
||||
# than an unpredictable one.
|
||||
var seen: Dictionary = {}
|
||||
var tiles: Array = []
|
||||
for raw_col: int in col_centers:
|
||||
for raw_row: int in row_centers:
|
||||
var canonical: Vector2i = AtlasDescendGeometryRef.canonicalize_district_center(
|
||||
Vector2i(raw_col, raw_row), body_radius_km
|
||||
)
|
||||
if not seen.has(canonical):
|
||||
seen[canonical] = true
|
||||
tiles.append(canonical)
|
||||
return tiles
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: screen header chrome (D-169/D-170) — pure string-building, moved
|
||||
# here from atlas_window_viewer.gd for file-length (the viewer's own
|
||||
# `_refresh_screen_header()`/`_location_label()` stay as thin wrappers, since
|
||||
# both are directly tested by name).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Body name + coordinate label — T-1142: shows the body's proper name
|
||||
## (falling back to body_id) alongside the held district center, so the
|
||||
## header never reads as bare "district (col, row)" with no indication of
|
||||
## WHICH body the player is looking at.
|
||||
static func location_label(body_display_name: String, held_center: Vector2i) -> String:
|
||||
return "%s — (%d, %d)" % [body_display_name, held_center.x, held_center.y]
|
||||
|
||||
|
||||
## D-169/D-170 implant chrome (§5): {title, subtitle} for the screen header.
|
||||
## The subtitle's extent (`held_n` districts) is rung-INVARIANT (n is always
|
||||
## district extent — see AtlasWindowOverlay.cell_grid_side_for_window()'s
|
||||
## doc), but the km/cell reading reflects the HELD rung's actual spacing
|
||||
## (2.048 km District, 0.512 km Quarter, 204.8 km Region) — the "continuous
|
||||
## metres-per-pixel/extent readout" 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.
|
||||
static func screen_header_content(
|
||||
body_display_name: String,
|
||||
held_center: Vector2i,
|
||||
held_n: int,
|
||||
held_granularity_v2: String,
|
||||
district_m: float
|
||||
) -> Dictionary:
|
||||
var label: String = location_label(body_display_name, held_center)
|
||||
var extent_km: float = float(held_n) * district_m / 1000.0
|
||||
var spacing_km: float = spacing_for_rung(held_granularity_v2) / 1000.0
|
||||
var subtitle: String = "%.1f x %.1f km · %.3f km/cell" % [extent_km, extent_km, spacing_km]
|
||||
return {"title": "REGIONAL — %s" % label.to_upper(), "subtitle": subtitle}
|
||||
|
||||
@@ -61,6 +61,8 @@ extends Node2D
|
||||
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-1153, live round 3: TILE_N (the per-tile district extent) for the mosaic draw path.
|
||||
const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
|
||||
## T-1145 item 3: interim presentation toggle — true renders the smoothed
|
||||
## Image/ImageTexture composite; false keeps the original crisp per-cell
|
||||
@@ -94,6 +96,9 @@ var _cache_active_toggle: String = ""
|
||||
func _draw() -> void:
|
||||
if viewer == null:
|
||||
return
|
||||
if viewer.is_tile_mode():
|
||||
_draw_tile_mosaic()
|
||||
return
|
||||
var window: Variant = viewer.get_district_window()
|
||||
if not window is Dictionary:
|
||||
return
|
||||
@@ -132,6 +137,125 @@ func _draw() -> void:
|
||||
_draw_crisp_composite(w, grid_side, n, cell_px, active_toggle)
|
||||
|
||||
|
||||
## T-1153, live round 3 (Jeroen's ruling, design doc §4): the orbital
|
||||
## rest-state MOSAIC draw path — one call to the EXISTING single-tile
|
||||
## composite-building logic (`_rebuild_texture_if_needed()`/
|
||||
## `_draw_smoothed_composite()`'s own per-tile equivalent below) PER TILE,
|
||||
## each positioned at its own LOCAL offset from the tile-set's reference
|
||||
## origin (district (0,0), matching `_enter_tile_mode()`'s own
|
||||
## `_held_center = Vector2i.ZERO`). A tile centered at absolute district
|
||||
## `tile.center` occupies local canvas space
|
||||
## `[(tile.center - TILE_N/2) * cell_px, (tile.center + TILE_N/2) * cell_px)`
|
||||
## — the SAME "window spans [center - n/2, center + n/2)" convention the
|
||||
## single-window draw path already uses, just evaluated per tile instead of
|
||||
## once for the whole composite. Tiles that haven't arrived yet
|
||||
## (`tile["window"] == null`) are simply SKIPPED — no per-tile placeholder
|
||||
## draw, letting COLOR_BG show through as the honest "nothing here yet" read
|
||||
## (the viewer's own `_draw()` already documents why no separate
|
||||
## whole-viewport fade is needed on top of this).
|
||||
func _draw_tile_mosaic() -> void:
|
||||
var tile_set = viewer.get_tile_set()
|
||||
if tile_set == null:
|
||||
return
|
||||
var cell_px: float = viewer.get_cell_pixel_size()
|
||||
var active_toggle: String = _active_toggle_overlay()
|
||||
var half_tile: float = float(AtlasWindowGeometryRef.TILE_N) * 0.5
|
||||
|
||||
for tile: Dictionary in tile_set.get_tiles():
|
||||
var window: Variant = tile["window"]
|
||||
if not window is Dictionary:
|
||||
continue
|
||||
var w: Dictionary = window
|
||||
var morphology: Variant = w.get("morphology")
|
||||
if not (morphology is PackedByteArray or morphology is Array):
|
||||
continue
|
||||
var grid_side: int = cell_grid_side_for_window(w)
|
||||
if grid_side <= 0:
|
||||
continue
|
||||
|
||||
var center: Vector2i = tile["center"]
|
||||
var local_origin: Vector2 = Vector2(
|
||||
(float(center.x) - half_tile) * cell_px, (float(center.y) - half_tile) * cell_px
|
||||
)
|
||||
var extent: float = float(AtlasWindowGeometryRef.TILE_N) * cell_px
|
||||
_draw_one_tile(w, grid_side, local_origin, extent, active_toggle)
|
||||
|
||||
|
||||
## One tile's own composite — the SAME crisp/smoothed per-cell pipeline the
|
||||
## single-window path uses (_cell_color()/_apply_glaciation(), UNCHANGED),
|
||||
## just drawn at `local_origin` instead of always at (0,0). Each tile gets
|
||||
## its OWN texture-rebuild cache slot (keyed by the tile's own window
|
||||
## reference, via `_tile_texture_cache` below) — sharing ONE
|
||||
## `_cached_texture` slot across all tiles (the single-window field) would
|
||||
## thrash on every draw call as different tiles' windows compete for it.
|
||||
func _draw_one_tile(
|
||||
w: Dictionary, grid_side: int, local_origin: Vector2, extent: float, active_toggle: String
|
||||
) -> void:
|
||||
if not COMPOSITE_SMOOTH:
|
||||
_draw_crisp_tile(w, grid_side, local_origin, extent, active_toggle)
|
||||
return
|
||||
var tile_texture: ImageTexture = _build_tile_texture(w, grid_side, active_toggle)
|
||||
if tile_texture == null:
|
||||
return
|
||||
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
draw_texture_rect(tile_texture, Rect2(local_origin, Vector2(extent, extent)), false)
|
||||
|
||||
|
||||
## Builds (uncached — see the class doc's rebuild-cost paragraph for why the
|
||||
## SINGLE-window path caches by reference; a per-tile cache keyed the same
|
||||
## way would need a Dictionary keyed on tile index, a reasonable follow-up
|
||||
## if mosaic redraw cost ever matters in practice, not attempted here since
|
||||
## the mosaic is drawn only at the orbital rest state, never mid-interaction
|
||||
## at a high redraw rate the way single-window zoom/pan is) a tile's own
|
||||
## Image/ImageTexture from its per-cell colors — identical pipeline to
|
||||
## `_rebuild_texture_if_needed()`, just returning the texture directly
|
||||
## instead of writing to the single-window cache fields.
|
||||
func _build_tile_texture(w: Dictionary, grid_side: int, active_toggle: String) -> ImageTexture:
|
||||
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 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
|
||||
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
|
||||
cell_color = _apply_glaciation(cell_color, glaciation, i)
|
||||
img.set_pixel(col, row, cell_color)
|
||||
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
## The crisp (non-smoothed) per-tile path — mirrors `_draw_crisp_composite()`
|
||||
## exactly, just positioned at `local_origin` instead of always at (0,0).
|
||||
func _draw_crisp_tile(
|
||||
w: Dictionary, grid_side: int, local_origin: Vector2, extent: 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 = extent / float(grid_side)
|
||||
|
||||
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)
|
||||
if cell_color.a <= 0.0:
|
||||
continue
|
||||
cell_color = _apply_glaciation(cell_color, glaciation, i)
|
||||
var cell_origin: Vector2 = local_origin + Vector2(col * screen_cell_px, row * screen_cell_px)
|
||||
draw_rect(
|
||||
Rect2(cell_origin, Vector2(screen_cell_px + 0.5, screen_cell_px + 0.5)), cell_color
|
||||
)
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
extends Node
|
||||
|
||||
## Orbital rest-state TILE-SET orchestration (T-1153, live round 3 — Jeroen's
|
||||
## ruling, design doc §4: "the top rest state is the WHOLE body, served as
|
||||
## progressive capped-density TILING"). A single wire-capped Region window
|
||||
## (AtlasWindowGeometry.MAX_COVERAGE_M["Region"] = 13,107,200 m) covers only a
|
||||
## fraction of a real body's circumference (Lendel: ~39,197,023 m — a single
|
||||
## window is ~a third of the body, the exact live-round finding: shot 01's
|
||||
## own header read "13107.2 x 13107.2 km" against a 39,198 km circumference).
|
||||
##
|
||||
## Owns N independent `AtlasWindowRequest` child instances — one per tile —
|
||||
## reusing 100% of the EXISTING, already-tested single-window request/cache/
|
||||
## debounce/retry machinery (atlas_window_request.gd) rather than
|
||||
## reinventing multi-window orchestration from scratch. Each tile is just a
|
||||
## Region-granularity window request at its own canonicalized center
|
||||
## (AtlasWindowGeometry.compute_tile_grid()); distinct centers are already
|
||||
## distinct cache/coalescing keys (T-1150/T-1152's own aliasing discipline),
|
||||
## so nothing about the request/cache LAYER needed to change for tiling to
|
||||
## work — only the ORCHESTRATION (issue N requests instead of one) and the
|
||||
## DRAWING (a mosaic instead of one composite) are new.
|
||||
##
|
||||
## No `class_name` on purpose, matching every other viewer-owned helper in
|
||||
## this cluster (atlas_window_request.gd/atlas_overlay_bar.gd/
|
||||
## atlas_legend_panel.gd, review #8 precedent): the owner (AtlasWindowViewer)
|
||||
## passes itself to `_init()`.
|
||||
##
|
||||
## Progressive arrival (design doc §4's own "with visible refinement as
|
||||
## tiles complete"): each tile's `AtlasWindowRequest.window_ready` connects
|
||||
## independently — a tile's own `_tiles[i]["window"]` updates the moment
|
||||
## THAT tile's response lands, with no dependency on any other tile's
|
||||
## arrival. The viewer/overlay reads `get_tiles()` every draw and renders
|
||||
## whichever tiles have arrived so far — an empty/border-fade gap for the
|
||||
## rest, exactly the same "hold what's there, sharpen in place" contract
|
||||
## single-window progressive refinement already has (§6 "no mode flip"),
|
||||
## just per-tile instead of per-composite.
|
||||
|
||||
signal tile_ready(index: int) # a single tile's window arrived/updated — the viewer redraws
|
||||
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
|
||||
var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref)
|
||||
var _body_id: String = ""
|
||||
var _tile_n: int = AtlasWindowGeometry.TILE_N
|
||||
|
||||
## Array[Dictionary]: {"center": Vector2i, "request": AtlasWindowRequest,
|
||||
## "window": Variant (null until arrived)} — one entry per tile, in the SAME
|
||||
## deterministic order compute_tile_grid() produces (stable fill order, see
|
||||
## that function's own doc).
|
||||
var _tiles: Array = []
|
||||
|
||||
|
||||
func _init(owner_ref = null) -> void:
|
||||
_owner = owner_ref
|
||||
|
||||
|
||||
## Unlike an individual AtlasWindowRequest (which has no signal connection of
|
||||
## its own — the OWNING viewer forwards responses to it, per that class'
|
||||
## own doc), the tile set DOES connect directly to
|
||||
## SimBridge.atlas_layers_received itself and fans a single response out to
|
||||
## EVERY tile's own `on_response()` — each tile's OWN staleness guard
|
||||
## (center/n/granularity_v2) decides whether that particular response is
|
||||
## the one IT was waiting for; only the matching tile ever adopts it. This
|
||||
## is the same "one shared inbound signal, N independent consumers filtering
|
||||
## by their own criteria" shape the design already uses elsewhere (every
|
||||
## AtlasWindowRequest instance filters on its own state from a common
|
||||
## broadcast — tiling just means N instances share the broadcast instead of
|
||||
## one).
|
||||
func _ready() -> void:
|
||||
SimBridge.atlas_layers_received.connect(_on_atlas_layers_received)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received):
|
||||
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
|
||||
|
||||
|
||||
func _on_atlas_layers_received(response: Dictionary) -> void:
|
||||
for tile: Dictionary in _tiles:
|
||||
var request = tile["request"]
|
||||
if is_instance_valid(request):
|
||||
request.on_response(response)
|
||||
|
||||
|
||||
## Enter tile mode for `body_id`/`body_radius_km` — computes the tile grid,
|
||||
## tears down any PREVIOUS tile set's child request nodes (a fresh
|
||||
## enter_orbital() on a DIFFERENT body must not leave stale tile requests
|
||||
## from the old body wired up), and issues one request per tile immediately
|
||||
## (no debounce — matching AtlasWindowRequest.request_now()'s own "first
|
||||
## window" contract, §5: entry is never debounced, only pan/rung-reselect
|
||||
## refetches are).
|
||||
func enter(body_id: String, body_radius_km: float) -> void:
|
||||
_teardown()
|
||||
_body_id = body_id
|
||||
var centers: Array = AtlasWindowGeometry.compute_tile_grid(body_radius_km)
|
||||
for i in range(centers.size()):
|
||||
var center: Vector2i = centers[i]
|
||||
var request = AtlasWindowRequest.new(self)
|
||||
request.name = "Tile%d" % i
|
||||
add_child(request)
|
||||
var tile_index := i # capture by value for the lambda below
|
||||
request.window_ready.connect(
|
||||
func(window: Dictionary) -> void: _on_tile_window_ready(tile_index, window)
|
||||
)
|
||||
_tiles.append({"center": center, "request": request, "window": null})
|
||||
request.request_now(body_id, center, _tile_n, AtlasWindowRequest.GRANULARITY_V2_REGION)
|
||||
|
||||
|
||||
func _on_tile_window_ready(index: int, window: Dictionary) -> void:
|
||||
if index < 0 or index >= _tiles.size():
|
||||
return # a stale signal from a torn-down tile set (shouldn't happen — disconnected on teardown)
|
||||
_tiles[index]["window"] = window
|
||||
tile_ready.emit(index)
|
||||
|
||||
|
||||
## Tear down every tile's request node — disconnects nothing explicitly
|
||||
## (queue_free() on a Node disconnects all its own signal connections
|
||||
## automatically, Godot's documented behavior) but DOES clear `_tiles` so a
|
||||
## stale index from an in-flight-but-now-orphaned request's eventual
|
||||
## response can never reach `_on_tile_window_ready()` with a now-meaningless
|
||||
## index (guarded there too, belt-and-suspenders).
|
||||
func _teardown() -> void:
|
||||
for tile: Dictionary in _tiles:
|
||||
var request = tile["request"]
|
||||
if is_instance_valid(request):
|
||||
request.queue_free()
|
||||
_tiles.clear()
|
||||
|
||||
|
||||
## The current tile set, for the viewer/overlay to draw — an Array of
|
||||
## {"center": Vector2i, "window": Variant} (the "request" key is internal,
|
||||
## not exposed here; callers only need center + arrived-or-null window).
|
||||
func get_tiles() -> Array:
|
||||
var result: Array = []
|
||||
for tile: Dictionary in _tiles:
|
||||
result.append({"center": tile["center"], "window": tile["window"]})
|
||||
return result
|
||||
|
||||
|
||||
## True once tiling is active for the current body — a body whose whole
|
||||
## circumference fits in ONE Region window's own coverage ceiling produces
|
||||
## exactly one tile (compute_tile_grid()'s own degenerate-case doc), so
|
||||
## `is_multi_tile()` distinguishes "tile set with 1 entry" (still tiling
|
||||
## machinery, technically) from "genuinely multiple tiles" — the viewer uses
|
||||
## this to decide whether the tile-set draw path or the ORIGINAL
|
||||
## single-window draw path is simpler/preferred for a small body (both are
|
||||
## correct; single-window avoids the extra Node/signal overhead when there's
|
||||
## only ever going to be one tile).
|
||||
func is_multi_tile() -> bool:
|
||||
return _tiles.size() > 1
|
||||
|
||||
|
||||
func get_tile_count() -> int:
|
||||
return _tiles.size()
|
||||
|
||||
|
||||
## True if every tile currently has an arrived window — the viewer/legend
|
||||
## chrome can use this to know when the mosaic is "complete" vs. still
|
||||
## progressively filling in.
|
||||
func is_fully_arrived() -> bool:
|
||||
if _tiles.is_empty():
|
||||
return false
|
||||
for tile: Dictionary in _tiles:
|
||||
if tile["window"] == null:
|
||||
return false
|
||||
return true
|
||||
@@ -3,74 +3,64 @@ extends Control
|
||||
|
||||
## 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
|
||||
## enter_orbital()'s own doc). This IS the "regional" nav entry (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()).
|
||||
## rung) down to District/Quarter lives in ONE screen/Control, not a separate
|
||||
## planetary viewer + windowed drill-down. Renders a DistrictWindowLayer
|
||||
## composite (or, at the orbital rest state on a large body, a MOSAIC of
|
||||
## several — see `_tile_mode`/AtlasWindowTileSet, live round 3) at whichever
|
||||
## rung is currently held: morphology base layer lightness-modulated by
|
||||
## elev_q, three switchable climate/vegetation overlays, an always-on
|
||||
## glaciation ice-tint modifier (drawing is AtlasWindowOverlay's job — this
|
||||
## Control owns input, request orchestration, chrome, pan/zoom). One
|
||||
## colorizer family renders every rung unchanged (design doc §6).
|
||||
##
|
||||
## 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 (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).
|
||||
## = _canvas.scale.
|
||||
## - Zoom is client-side on the ALREADY-HELD composite frame-to-frame, but
|
||||
## CONTINUOUS AND UNCLAMPED ACROSS RUNGS (D-013 restored for this seam):
|
||||
## crossing a rung's coverage ceiling (§5, redesigned per live round 3 —
|
||||
## see AtlasWindowGeometry.select_rung()) 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.
|
||||
## - 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,
|
||||
## rung-reselect, entry), never talks to SimBridge directly itself.
|
||||
## condition) — see _maybe_reset_to_canonical_frame() — which, on a body
|
||||
## needing tiling, re-enters `_tile_mode` (live round 3, design doc §4:
|
||||
## "the top rest state is the WHOLE body, served as progressive
|
||||
## capped-density TILING").
|
||||
## - _window_request (atlas_window_request.gd) owns the single-window
|
||||
## cache/debounce/retry; _tile_set (atlas_window_tile_set.gd) owns N of
|
||||
## those for the tiled rest state — this Control decides WHICH is active.
|
||||
##
|
||||
## 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
|
||||
## are reserved for map objects, which will land in this window later, e.g.
|
||||
## settlements):
|
||||
## Navigation (Jeroen's input-model ruling: LMB-drag panning BREAKS click
|
||||
## semantics with future map objects, so it's removed entirely):
|
||||
## 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 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)
|
||||
## Esc back (nav.pop())
|
||||
|
||||
signal back_pressed
|
||||
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
const OVERLAY_BAR_HEADER_RESERVE: float = 360.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.
|
||||
## T-1153: MIN_ZOOM/MAX_ZOOM are a wide safety clamp on the raw display
|
||||
## multiplier, NOT a rung boundary — wheel zoom is CONTINUOUS and UNCLAMPED
|
||||
## ACROSS RUNGS (D-013 restored for this seam): crossing a rung's coverage
|
||||
## ceiling (AtlasWindowGeometry.select_rung()) re-requests a DIFFERENT
|
||||
## granularity at the SAME apparent screen extent, never clamping
|
||||
## _view_zoom itself. set_view() (T-1120 capture API) clamps to this same
|
||||
## range independently.
|
||||
##
|
||||
## 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.
|
||||
## enter_orbital()'s largest legal `n` (up to hundreds of thousands of
|
||||
## districts on a gas-giant-scale body) is never itself clamped — that would
|
||||
## silently show LESS than the whole body, breaking Jeroen's HARD condition.
|
||||
## 0.0005 covers a ~120,000 km-radius body at a 3840px 4K viewport with
|
||||
## headroom.
|
||||
const MIN_ZOOM: float = 0.0005
|
||||
const MAX_ZOOM: float = 64.0
|
||||
const ZOOM_STEP: float = 1.15
|
||||
@@ -125,6 +115,8 @@ const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_re
|
||||
# atlas_descend_geometry.gd instead — it already owns district_extent()).
|
||||
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
# T-1153: orbital rest-state mosaic orchestration.
|
||||
const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd")
|
||||
|
||||
# ── Overlay definitions (T-1138 — reuses atlas_overlay_bar.gd/
|
||||
# atlas_legend_panel.gd's existing duck-typed viewer interface: both call
|
||||
@@ -213,6 +205,15 @@ var _screen_header: ImplantHeader = null
|
||||
var _overlay_bar = null
|
||||
var _legend_panel = null
|
||||
var _window_request = null # AtlasWindowRequest
|
||||
var _tile_set = null # AtlasWindowTileSet (T-1153, live round 3)
|
||||
|
||||
## T-1153 (Jeroen's ruling, design doc §4): true while showing the orbital
|
||||
## rest state as a MULTI-WINDOW MOSAIC (AtlasWindowTileSet) instead of the
|
||||
## single held composite (`_window`). Set by `_enter_tile_mode()` when
|
||||
## compute_tile_grid() produces more than one tile; cleared the moment
|
||||
## `_maybe_reselect_rung()` crosses OUT of Region — tiling is purely a
|
||||
## TOP-of-the-ladder concern, never active below Region.
|
||||
var _tile_mode: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -242,6 +243,11 @@ func _ready() -> void:
|
||||
add_child(_window_request)
|
||||
_window_request.window_ready.connect(_on_window_ready)
|
||||
|
||||
_tile_set = AtlasWindowTileSet.new(self)
|
||||
_tile_set.name = "TileSet"
|
||||
add_child(_tile_set)
|
||||
_tile_set.tile_ready.connect(_on_tile_ready)
|
||||
|
||||
_build_screen_header()
|
||||
_build_overlay_bar()
|
||||
_build_legend_panel()
|
||||
@@ -254,29 +260,20 @@ func _exit_tree() -> void:
|
||||
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
|
||||
|
||||
|
||||
## Enter the window screen centered on `district_center` (a DistrictPos-
|
||||
## 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.
|
||||
## Enter the window screen centered on `district_center` (a DistrictPos, from
|
||||
## a click-through's derived position) at District granularity. n defaults to
|
||||
## 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). 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 would wire into, and as a direct-call entry for tests.
|
||||
##
|
||||
## T-1142: `district_center` is canonicalized (wrap column / clamp row)
|
||||
## BEFORE it becomes `_held_center` or reaches the request — matching the
|
||||
## server's own normalize_window_center() exactly, so the request the client
|
||||
## sends and the echo the server sends back describe the SAME canonical
|
||||
## point from the first round-trip (never a raw-vs-normalized mismatch that
|
||||
## would fail the §2 staleness echo check). Also fits-and-centers the view
|
||||
## instead of the old zoom=1/offset=ZERO reset (Jeroen's second finding: an
|
||||
## n=32 composite is 512px native, a postage stamp unfitted in a real
|
||||
## viewport).
|
||||
## server's normalize_window_center() so the client's echo comparison never
|
||||
## mismatches. Also fits-and-centers the view instead of resetting to
|
||||
## zoom=1/offset=ZERO.
|
||||
func enter(
|
||||
body: Dictionary,
|
||||
system: Dictionary,
|
||||
@@ -292,25 +289,28 @@ func enter(
|
||||
|
||||
## 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).
|
||||
## centered at the body's canonical origin"). This is the "regional" nav
|
||||
## entry point (T-1152 client half): 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
|
||||
## (though enter() below 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).
|
||||
## equator" (AtlasDescendGeometry's own doc). Canonical extent = the WHOLE
|
||||
## equatorial circumference in districts, the same quantity
|
||||
## is_fully_zoomed_out()/_maybe_reset_to_canonical_frame() test against, so
|
||||
## entry and reset always agree on what "the top" means. No-radius bodies
|
||||
## fall back to the District-rung default window (no circumference concept).
|
||||
##
|
||||
## **Live round 3 (Jeroen's ruling, design doc §4): the rest state must
|
||||
## TILE.** A single wire-capped Region window covers at most
|
||||
## `AtlasWindowGeometry.MAX_COVERAGE_M["Region"]` (13,107,200 m) — a THIRD of
|
||||
## Lendel's ~39,197,023 m circumference (shot 01's own header: "13107.2 x
|
||||
## 13107.2 km"). Once `compute_tile_grid()` returns MORE than one tile,
|
||||
## entry goes through `_enter_tile_mode()` instead of `_enter_at_rung()`; a
|
||||
## body whose circumference fits one Region window's ceiling still gets
|
||||
## exactly one "tile" (the degenerate case) and stays single-window.
|
||||
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:
|
||||
@@ -320,31 +320,55 @@ func enter_orbital(body: Dictionary, system: Dictionary) -> void:
|
||||
AtlasWindowRequest.GRANULARITY_V2_DISTRICT
|
||||
)
|
||||
return
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(radius_km)
|
||||
if tiles.size() > 1:
|
||||
_enter_tile_mode(body, system, radius_km)
|
||||
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)
|
||||
|
||||
|
||||
## T-1153, live round 3: the TILE-MODE entry path — same reset discipline as
|
||||
## `_enter_at_rung()` but populates `_tile_set` instead of `_window_request`,
|
||||
## setting `_tile_mode = true` so drawing/reset/reselect read the mosaic.
|
||||
## `_held_n` carries the WHOLE body's extent unclamped (each TILE clamps its
|
||||
## own TILE_N-sized request independently inside AtlasWindowTileSet), so the
|
||||
## extent math elsewhere needs no tile-specific branch.
|
||||
func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) -> void:
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var n: int = int(extent["cols"])
|
||||
_body = body
|
||||
_system = system
|
||||
_held_center = Vector2i.ZERO
|
||||
_held_n = n
|
||||
_held_granularity_v2 = AtlasWindowRequest.GRANULARITY_V2_REGION
|
||||
_tile_mode = true
|
||||
_window = null
|
||||
_user_adjusted = false
|
||||
_awaiting_first_window = true
|
||||
_fit_and_center()
|
||||
_window_request.reset()
|
||||
_tile_set.enter(_dict_str(_body, "body_id", ""), radius_km)
|
||||
_refresh_screen_header()
|
||||
grab_focus()
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## 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.
|
||||
## must already be canonicalized by the caller. Resets every piece of
|
||||
## held/request state for a fresh descent, plus _held_granularity_v2.
|
||||
##
|
||||
## **The C1 clamp-mirror lesson, one layer up (live-round finding):** `n`
|
||||
## MUST be clamped via `_clamp_window_n_mirror_v2()` BEFORE it becomes
|
||||
## `_held_n` — mirroring exactly what AtlasWindowRequest.request_now()
|
||||
## already does to ITS OWN `_n` before storing/sending (see that function's
|
||||
## own doc for the original PR #191 Tyre C1 finding). Storing the RAW `n`
|
||||
## here (e.g. enter_orbital()'s full district_extent().cols, routinely tens
|
||||
## of thousands at Region granularity, versus the server's clamped echo of
|
||||
## at most DISTRICT_WINDOW_MAX_N_REGION=6,400) left `_held_n` permanently
|
||||
## disagreeing with what the server would ever actually echo — every
|
||||
## orbital-rung response was silently rejected as stale by
|
||||
## _on_window_ready()'s `w_n != _held_n` check, hanging the ladder on every
|
||||
## real-sized body. `_maybe_reselect_rung()`/`_maybe_refloat_window()` both
|
||||
## read `_held_n` (never re-derive it), so clamping here — the ONE write
|
||||
## site — fixes every downstream caller too, not just entry.
|
||||
## **C1 clamp-mirror, one layer up (live-round finding):** `n` MUST be
|
||||
## clamped via `_clamp_window_n_mirror_v2()` BEFORE it becomes `_held_n` —
|
||||
## mirroring what AtlasWindowRequest.request_now() already does to ITS OWN
|
||||
## `_n` (PR #191 Tyre C1). Storing RAW `n` (e.g. enter_orbital()'s full
|
||||
## district_extent().cols, tens of thousands at Region, vs. the server's
|
||||
## clamped echo of at most 6,400) left `_held_n` permanently disagreeing
|
||||
## with the server's echo — every orbital response silently rejected as
|
||||
## stale forever. `_maybe_reselect_rung()`/`_maybe_refloat_window()` both
|
||||
## read `_held_n` unchanged, so clamping here fixes every downstream caller.
|
||||
func _enter_at_rung(
|
||||
body: Dictionary,
|
||||
system: Dictionary,
|
||||
@@ -358,6 +382,7 @@ func _enter_at_rung(
|
||||
_held_center = district_center
|
||||
_held_n = clamped_n
|
||||
_held_granularity_v2 = granularity_v2
|
||||
_tile_mode = false # T-1153 live round 3: a single-window entry always leaves tile mode
|
||||
_window = null
|
||||
_user_adjusted = false
|
||||
_awaiting_first_window = true
|
||||
@@ -391,11 +416,9 @@ func _fit_and_center() -> void:
|
||||
_apply_transform()
|
||||
|
||||
|
||||
## T-1142: the pole-wall clamp needs the body's rows_half, in whole districts
|
||||
## — a no-radius body (tiny test body) has no periodicity/pole concept at the
|
||||
## DistrictPos level (matching canonicalize_district_center()'s own no-radius
|
||||
## identity disposition), so the wall is a no-op there (rows_half=0, and
|
||||
## clamp_pan_offset_to_pole_wall() treats <= 0 as "no wall").
|
||||
## T-1142: needs the body's rows_half, in whole districts — a no-radius body
|
||||
## has no pole concept (matching canonicalize_district_center()'s own
|
||||
## no-radius identity), so the wall is a no-op there (rows_half=0).
|
||||
func _clamp_offset_to_pole_wall(offset: Vector2) -> Vector2:
|
||||
var radius_km: float = float(_body.get("body_radius_km", 0.0))
|
||||
if radius_km <= 0.0:
|
||||
@@ -417,6 +440,19 @@ func get_district_window() -> Variant:
|
||||
return _window
|
||||
|
||||
|
||||
## T-1153: true while showing the orbital rest state as a multi-window
|
||||
## mosaic instead of the single held composite — AtlasWindowOverlay reads
|
||||
## this to pick a draw path.
|
||||
func is_tile_mode() -> bool:
|
||||
return _tile_mode
|
||||
|
||||
|
||||
## T-1153: the tile-set orchestrator, for AtlasWindowOverlay's mosaic draw
|
||||
## path — only meaningful while is_tile_mode() is true.
|
||||
func get_tile_set() -> Variant:
|
||||
return _tile_set
|
||||
|
||||
|
||||
## District-cell pixel size at zoom=1.0 — AtlasWindowOverlay reads this
|
||||
## rather than hardcoding CELL_PIXEL_SIZE itself, so the viewer stays the
|
||||
## single source of geometry truth (same "viewer owns the transform, overlay
|
||||
@@ -451,15 +487,11 @@ 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).
|
||||
## T-1153: progressive refinement — the ONE place a new rung's window gets
|
||||
## adopted, deliberately WITHOUT clearing `_window` first. The OLD composite
|
||||
## stays drawn until this call — no blank frame, no mode flip (§6) —
|
||||
## because `_window` only ever gets REPLACED, never nulled, once adopted
|
||||
## (enter()/_enter_at_rung() null it only at a fresh descent, not a 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
|
||||
@@ -500,6 +532,15 @@ func _on_window_ready(window: Dictionary) -> void:
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## T-1153 (design doc §4 "progressive... with visible refinement as tiles
|
||||
## complete"): a SINGLE tile's window arrived — redraw so the overlay's
|
||||
## mosaic loop picks it up. No acceptance/staleness logic needed here (each
|
||||
## tile's OWN AtlasWindowRequest already filtered before this signal fired).
|
||||
func _on_tile_ready(_index: int) -> void:
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# View transform (mirrors AtlasViewer's own — pan is real; zoom is CURSOR-
|
||||
# ANCHORED and CONTINUOUS ACROSS RUNGS (T-1153, D-226 T-1143-rulings
|
||||
@@ -517,14 +558,11 @@ 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()).
|
||||
## Cursor-anchored zoom (D-013 restored for this seam): the CANVAS POINT
|
||||
## under the cursor stays fixed on screen across the zoom step. Unclamped
|
||||
## ACROSS RUNGS (only the wide MIN_ZOOM/MAX_ZOOM safety clamp applies — see
|
||||
## that constant's own doc); after applying, checks whether the extent now
|
||||
## calls for a different rung or the top rest state.
|
||||
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):
|
||||
@@ -539,59 +577,71 @@ func _zoom_at(mouse_pos: Vector2, factor: float) -> void:
|
||||
|
||||
|
||||
## 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).
|
||||
## dimension — the `E` half of the §5 rung-selection rule. A pure function
|
||||
## of `_view_zoom` (see AtlasWindowGeometry.world_extent_m()'s own doc for
|
||||
## why the held rung is NOT an input). Thin wrapper over that pure function.
|
||||
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).
|
||||
## step, recompute the legal rung for the NOW-displayed world extent. If it
|
||||
## differs from what's HELD, request the new granularity centered on the
|
||||
## CURRENT screen-center (_screen_center_district(), the same formula
|
||||
## _maybe_refloat_window() uses).
|
||||
##
|
||||
## 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).
|
||||
## **C1 clamp-mirror, a THIRD layer up (live round 3):** `_held_n` MUST be
|
||||
## re-clamped via `_clamp_window_n_mirror_v2()` for the TARGET rung, not left
|
||||
## at the PREVIOUS rung's clamp — crossing rungs changes the clamp ceiling
|
||||
## (Region caps at 6,400; District/Quarter at 64), so a stale Region-sized
|
||||
## `_held_n` fed into a Quarter request gets server-clamped small while
|
||||
## `_held_n` stays large — `_on_window_ready()`'s `w_n != _held_n` then
|
||||
## drops every cross-rung refinement forever. Same bug as _enter_at_rung(),
|
||||
## recurring at the CROSSING boundary.
|
||||
##
|
||||
## Progressive refinement: does NOT touch `_window`/`_held_granularity_v2` —
|
||||
## the OLD composite keeps drawing until _on_window_ready() adopts the new
|
||||
## one (§6 "no mode flip": never a blank frame, never 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():
|
||||
|
||||
# T-1153, live round 3: tile mode is TOP-of-the-ladder only (coordinator's
|
||||
# own scoping — "inside-zoom can stay single-window as now"). Staying at
|
||||
# Region means staying tiled (zooming within a mosaic is a client-side
|
||||
# scale on the SAME held tiles, like single-window zoom on one
|
||||
# composite). Crossing OUT of Region falls through to the single-window
|
||||
# path below, flipping `_tile_mode` off.
|
||||
var leaving_tile_mode := false
|
||||
if _tile_mode:
|
||||
if target_rung == AtlasWindowRequest.GRANULARITY_V2_REGION:
|
||||
return
|
||||
_tile_mode = false
|
||||
leaving_tile_mode = true
|
||||
|
||||
# `leaving_tile_mode` FORCES the request through even if
|
||||
# `_window_request`'s own STALE granularity_v2 (never touched while tiled)
|
||||
# happens to already equal `target_rung` by coincidence — without this,
|
||||
# the early-return below would skip the request that's supposed to
|
||||
# POPULATE `_window` for the first time since tile mode replaced it.
|
||||
if not leaving_tile_mode and 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()
|
||||
var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(_held_n, target_rung)
|
||||
_held_center = new_center
|
||||
_held_n = clamped_n
|
||||
_window_request.request_debounced(
|
||||
_dict_str(_body, "body_id", ""), new_center, _held_n, target_rung
|
||||
_dict_str(_body, "body_id", ""), new_center, clamped_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).
|
||||
## district space (the caller canonicalizes the final stored/sent value).
|
||||
## Thin wrapper over AtlasWindowGeometry.screen_center_to_district() so both
|
||||
## the pan-edge refetch and the rung-reselect refetch share ONE formula.
|
||||
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
|
||||
@@ -600,19 +650,13 @@ func _screen_center_district() -> Vector2i:
|
||||
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).
|
||||
## Jeroen's HARD condition: "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
|
||||
## extent (at _held_granularity_v2, deliberately NOT the in-flight request's
|
||||
## rung) covers the whole body AND the player isn't ALREADY at the canonical
|
||||
## frame (re-snapping every tick would fight a zoom-in-from-the-top
|
||||
## gesture). Returns true if it fired (caller skips _maybe_reselect_rung()).
|
||||
func _maybe_reset_to_canonical_frame() -> bool:
|
||||
var radius_km: float = float(_body.get("body_radius_km", 0.0))
|
||||
if radius_km <= 0.0:
|
||||
@@ -650,29 +694,26 @@ func set_view(zoom: float, offset: Vector2) -> void:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## After a pan delta (T-1145: WASD/edge-scroll, called from _process()'s pan
|
||||
## tick every frame the player is actively panning), check whether the
|
||||
## After a pan delta (T-1145: WASD/edge-scroll), check whether the
|
||||
## screen-center now maps to a DistrictPos outside the held window's extent
|
||||
## — if so, float a NEW window centered on that point (§5 "windows float on
|
||||
## the pan center... not grid-snapped") via the debounced request path.
|
||||
## — if so, float a NEW window centered on that point via the debounced path.
|
||||
##
|
||||
## T-1142 (item 6a): the edge-crossing decision below is computed in RAW
|
||||
## absolute district space (un-wrapped, un-clamped) — that is the correct
|
||||
## space for "has the pan carried the view past the held window's edge",
|
||||
## since the held window's own local bounds are relative to _held_center as
|
||||
## it was BEFORE this pan. Only the FINAL new_center that becomes the next
|
||||
## _held_center / the next request is canonicalized (wrap column, clamp
|
||||
## row) — matching the server's own normalize_window_center() and keeping
|
||||
## the client's echo-comparison and cache key on the same canonical form the
|
||||
## server uses (see canonicalize_district_center()'s doc for why this must
|
||||
## match bit-for-bit). A pan that straddles the antimeridian therefore still
|
||||
## floats correctly: the pre-canonicalization abs_col can be e.g. -3 or
|
||||
## district_cols+5, the edge-crossing math treats that as a normal delta from
|
||||
## the old center, and only the resulting new_center gets wrapped into range
|
||||
## before it's requested/cached.
|
||||
## T-1142 (item 6a): the edge-crossing decision is computed in RAW absolute
|
||||
## district space (un-wrapped, un-clamped) — the held window's own local
|
||||
## bounds are relative to _held_center as it was BEFORE this pan. Only the
|
||||
## FINAL new_center is canonicalized (wrap column, clamp row), matching the
|
||||
## server's normalize_window_center() so the echo comparison/cache key stay
|
||||
## on the same canonical form. A pan straddling the antimeridian still
|
||||
## floats correctly: the pre-canonicalization abs_col can go negative or
|
||||
## past cols, and only the resulting new_center gets wrapped before use.
|
||||
func _maybe_refloat_window() -> void:
|
||||
if _held_n <= 0:
|
||||
return
|
||||
if _tile_mode:
|
||||
# T-1153: the tile set already covers the WHOLE body — no "edge" to
|
||||
# cross while tiled. _apply_pan_delta() still moves _view_offset;
|
||||
# this only skips the single-window re-float below.
|
||||
return
|
||||
var raw_new_center: Vector2i = AtlasWindowGeometry.screen_center_to_district(
|
||||
size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n
|
||||
)
|
||||
@@ -716,6 +757,11 @@ func _maybe_refloat_window() -> void:
|
||||
|
||||
func _draw() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
||||
if _tile_mode:
|
||||
# T-1153: single-window border-fade/pending-wash don't apply to a
|
||||
# mosaic — AtlasWindowOverlay's tile draw only paints arrived tiles;
|
||||
# an unarrived one is an honest gap over COLOR_BG, no separate fade.
|
||||
return
|
||||
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
|
||||
@@ -760,39 +806,26 @@ func _build_screen_header() -> void:
|
||||
_screen_header.apply_implant_theme(_implant_theme)
|
||||
|
||||
|
||||
## 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". 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.
|
||||
## D-169/D-170 implant chrome (§5) — title/subtitle text built by the pure
|
||||
## AtlasWindowGeometry.screen_header_content() (T-1153: moved there for
|
||||
## file-length; the "continuous metres-per-pixel readout, never a discrete
|
||||
## mode label" rationale lives on that function's own doc now).
|
||||
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 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)
|
||||
var content: Dictionary = AtlasWindowGeometry.screen_header_content(
|
||||
_dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")),
|
||||
_held_center, _held_n, _held_granularity_v2, DISTRICT_M
|
||||
)
|
||||
_screen_header.set_content(content["title"], content["subtitle"])
|
||||
|
||||
|
||||
## Body name + coordinate label (T-1142: pulls the CHEAP half of T-1141
|
||||
## forward — the body's proper name was already sitting unused on _body,
|
||||
## passed through the whole descend chain since T-1138, but this header never
|
||||
## read it, showing bare "district (col, row)" with no indication of WHICH
|
||||
## body the player is looking at. T-1141 keeps only the harder half: nearest-
|
||||
## settlement proximity join (the window carries no settlement data of its
|
||||
## own — that lives on the planetary gen_l3_settlements overlay, a different
|
||||
## screen/dataset — a real follow-up, not a silently-guessed one).
|
||||
## Thin wrapper over AtlasWindowGeometry.location_label() (T-1153: moved
|
||||
## there for file-length) — kept as a method since it's directly tested.
|
||||
func _location_label() -> String:
|
||||
var body_name: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—"))
|
||||
return "%s — (%d, %d)" % [body_name, _held_center.x, _held_center.y]
|
||||
return AtlasWindowGeometry.location_label(
|
||||
_dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")), _held_center
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -811,16 +844,10 @@ func _is_over_ui(_pos: Vector2) -> bool:
|
||||
|
||||
|
||||
## T-1145 item 2: LMB-drag panning is GONE (Jeroen's ruling — drag broke click
|
||||
## semantics with map objects; clicks are reserved for future map objects,
|
||||
## e.g. settlements). What remains: wheel zoom (unchanged) and tracking the
|
||||
## local mouse position for edge-scroll (_process() reads _last_mouse_pos —
|
||||
## it has no InputEvent of its own to read a live position from). WASD/arrow
|
||||
## panning does NOT go through _gui_input at all — it is a HELD-key,
|
||||
## continuous, frame-rate-independent pan polled every frame in _process()
|
||||
## via Input.is_action_pressed()-equivalent raw key checks (Input.is_key_pressed(),
|
||||
## since WASD has no project-level Input Map action of its own in this
|
||||
## screen's remit — see _process()'s own doc for why raw physical-keycode
|
||||
## polling is deliberate here, not a new InputMap action).
|
||||
## semantics with map objects). What remains: wheel zoom and tracking the
|
||||
## local mouse position for edge-scroll (_process() has no InputEvent of its
|
||||
## own). WASD/arrow panning does NOT go through _gui_input — it's a
|
||||
## HELD-key, frame-rate-independent pan polled every frame in _process().
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.is_echo():
|
||||
_handle_key(event as InputEventKey)
|
||||
@@ -850,18 +877,11 @@ func _handle_key(event: InputEventKey) -> void:
|
||||
|
||||
|
||||
## T-1145 item 2: continuous WASD/arrow-key pan + edge-scroll, both applied
|
||||
## here (not _gui_input) because both are HELD-state effects (keys held down,
|
||||
## cursor lingering near an edge), not discrete input events — _process()
|
||||
## polls held state every frame and hands the resulting direction + this
|
||||
## frame's delta to _apply_pan_delta() (split out for testability — a gdUnit
|
||||
## test drives _apply_pan_delta(direction, delta) directly with a
|
||||
## deterministic direction/delta instead of needing to fake Godot's global
|
||||
## Input singleton reporting a key held, which is what testing THIS
|
||||
## function's own Input.is_key_pressed() polling would require). Skips
|
||||
## entirely while this Control is hidden (the screen is not the active
|
||||
## nav-stack entry) — no wasted per-frame work for an invisible viewer, and
|
||||
## no phantom panning if some other code path leaves this node in the tree
|
||||
## but not shown.
|
||||
## here (not _gui_input) since both are HELD-state effects, not discrete
|
||||
## events — polls held state every frame and hands the direction + delta to
|
||||
## _apply_pan_delta() (split out for testability — a gdUnit test drives it
|
||||
## directly rather than faking Godot's global Input singleton). Skips while
|
||||
## hidden (screen not the active nav-stack entry).
|
||||
func _process(delta: float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
@@ -873,15 +893,11 @@ func _process(delta: float) -> void:
|
||||
_apply_pan_delta(direction, delta)
|
||||
|
||||
|
||||
## The actual pan-tick state mutation, given an ALREADY-DECIDED (but not yet
|
||||
## normalized) direction and this frame's delta — frame-rate independent
|
||||
## (motion scales by `delta`, so the same speed at 30fps or 144fps), zoom-
|
||||
## scaled (PAN_SPEED_CANVAS_PX_S * _view_zoom — see that constant's own doc
|
||||
## for why), and pole-wall clamped (T-1142, unchanged mechanism, just fed by
|
||||
## a different input source now). Sets _user_adjusted (T-1145: "WASD/edge/
|
||||
## zoom all set _user_adjusted") and triggers the SAME pan-edge refetch check
|
||||
## (§4) drag used to. Split from _process() specifically so a test can call
|
||||
## this directly with a synthetic direction/delta — see _process()'s own doc.
|
||||
## The actual pan-tick state mutation, given an ALREADY-DECIDED direction and
|
||||
## this frame's delta — frame-rate independent, zoom-scaled
|
||||
## (PAN_SPEED_CANVAS_PX_S * _view_zoom), pole-wall clamped (T-1142). Sets
|
||||
## _user_adjusted and triggers the pan-edge refetch (§4). Split from
|
||||
## _process() so a test can call it directly with a synthetic direction/delta.
|
||||
func _apply_pan_delta(direction: Vector2, delta: float) -> void:
|
||||
var normalized: Vector2 = direction.normalized() # diagonal isn't faster than a single axis
|
||||
_user_adjusted = true
|
||||
|
||||
Reference in New Issue
Block a user