fix(client): cold-start round 2 — DERIVING TERRAIN state, legend re-fit, release server for make atlas

Item 1: while zero tiles have arrived, the viewer draws a centered
screen-space 'DERIVING TERRAIN…' label (text_dim role, no new hue),
dropping the instant the first tile lands — a cold wait now reads as
loading, not broken. New has_any_tile_arrived() predicate (distinct
from has_pending_tiles(): both true mid-arrival, tested exactly there).

Item 2: legend re-fit root-caused empirically, two plausible fixes
disproven by trace before the real one: a manually-positioned Control's
size NEVER tracks a shrinking minimum in this parenting shape, and
RichTextLabel.fit_content reports degenerate minimums until laid out at
real width once — so reset must be DEFERRED and run after refill, not
inside clear(). ImplantPanel.reset_to_content_size() (call_deferred),
wired into both legend refresh()es. The load-bearing test compares
size.y to get_minimum_size().y — a size-to-size comparison passed
trivially with both numbers equally stuck (caught on first draft).

Item 3: make atlas now builds the RELEASE server and passes
SR_SERVER_BIN (a cold DEBUG server delivers zero tiles for >10s on a
new body — live-measured — vs 210ms warm; release serves cold in well
under a second). atlas_standalone._server_binary_path() honors the env
override per the SR_PORT two-tier precedent, debug path unchanged
when unset.

All fixes revert-verified; nine suites green collateral-checked;
gdlint clean.
This commit is contained in:
2026-07-22 19:51:12 +02:00
parent d4526e51ff
commit dd13760d62
11 changed files with 489 additions and 120 deletions
@@ -173,6 +173,7 @@ func refresh() -> void:
clear()
visible = not active_specs.is_empty()
if active_specs.is_empty():
reset_to_content_size()
return
add_component(
@@ -188,3 +189,4 @@ func refresh() -> void:
if i < active_specs.size() - 1:
add_component(ImplantSeparator.new())
reposition()
reset_to_content_size()
@@ -673,3 +673,16 @@ static func screen_header_content(
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}
## Cold-start dossier: the baseline position draw_string() needs to render
## `text` horizontally AND vertically centered inside `viewport_size` — pure
## geometry, split out of AtlasWindowViewer._draw_deriving_terrain_label()
## for file-length (gdlint max-file-lines), not a different concern. Callers
## pass HORIZONTAL_ALIGNMENT_CENTER to draw_string() themselves (that part
## isn't pure — it needs the real Font instance) — this only computes the Y
## baseline offset and the X center point draw_string()'s own centering
## then works from.
static func centered_label_baseline(viewport_size: Vector2, text_size: Vector2) -> Vector2:
var center: Vector2 = viewport_size * 0.5
return center - text_size * 0.5 + Vector2(0.0, text_size.y * 0.5)
@@ -90,6 +90,7 @@ func refresh() -> void:
_add_toggle_section(active_id)
reposition()
reset_to_content_size()
func _add_morphology_section() -> void:
@@ -150,6 +150,24 @@ func has_pending_tiles() -> bool:
return false
## True once at least ONE tile has a real window — distinct from
## has_pending_tiles()'s "at least one MISSING" (both can be true at once,
## mid-arrival). PR #192 cold-start round 2: a cold server's first
## AnalyzeBody can take >10s with ZERO tiles landed the whole time — the
## per-tile border-fade wash alone (subtle, same color as every OTHER
## no-data-yet state) read as broken darkness in a live cold capture, not
## loading. The viewer uses this to gate an unmistakable "DERIVING
## TERRAIN…" label: shown while this is false (nothing has arrived at all —
## the reassurance is needed most), dropped the moment even one tile lands
## (per-tile washes alone read fine once real content is visibly filling in
## around the gaps).
func has_any_tile_arrived() -> bool:
for tile: Dictionary in _tiles:
if tile["window"] != null:
return true
return false
## 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
@@ -8,25 +8,23 @@ extends Control
## 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).
## several — see `_tile_mode`/AtlasWindowTileSet) at whichever rung is
## currently held: morphology base 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. Zoom is client-side on the ALREADY-HELD composite
## frame-to-frame, but CONTINUOUS AND UNCLAMPED ACROSS RUNGS (D-013):
## crossing a rung's coverage ceiling (§5, AtlasWindowGeometry.
## select_rung()) fires a background request for the new granularity
## while the OLD composite keeps drawing — progressive refinement, no
## blank frame (§6). A pan past the held window's edge re-requests the
## SAME rung at a new center.
## crossing a rung's coverage ceiling fires a background request for the
## new granularity while the OLD composite keeps drawing — progressive
## refinement, no blank frame (§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()) — on a body needing
## tiling, re-enters `_tile_mode` (live round 3, design doc §4).
## condition) — on a body needing tiling, re-enters `_tile_mode` (§4).
## - _window_request owns the single-window cache/debounce/retry; _tile_set
## owns N of those for the tiled rest state — this Control decides WHICH.
##
@@ -44,13 +42,12 @@ const OVERLAY_BAR_HEADER_RESERVE: float = 360.0
## 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): 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 enter_orbital()'s largest legal `n`
## COVER-fits without clamping (would silently show less than the whole
## body). 0.0005 covers a ~120,000 km-radius body at a 3840px 4K viewport.
## ACROSS RUNGS (D-013): crossing a rung's coverage ceiling 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 enter_orbital()'s
## largest legal `n` COVER-fits without clamping — 0.0005 covers a
## ~120,000 km-radius body at a 3840px 4K viewport.
const MIN_ZOOM: float = 0.0005
const MAX_ZOOM: float = 64.0
const ZOOM_STEP: float = 1.15
@@ -61,23 +58,20 @@ const ZOOM_STEP: float = 1.15
const PAN_SPEED_CANVAS_PX_S: float = 96.0
## T-1145 item 2: cursor-to-edge distance (px) that triggers edge-scroll
## (Jeroen's "~24px"). Same speed as WASD — _process() reads
## PAN_SPEED_CANVAS_PX_S for both, no separate constant.
## (Jeroen's "~24px"). Same speed as WASD, no separate constant.
const EDGE_SCROLL_MARGIN_PX: float = 24.0
## Pixel size of one district cell at zoom=1.0 — a fixed on-screen scale
## (unlike AtlasViewer's heightmap, no source texture dictates a native pixel
## size; this constant IS the native size). 16px/cell at n=64 gives a
## ~1024px-wide composite before zoom, comfortable in a 1280x720+ viewport.
## (no source texture dictates a native size; this constant IS it). 16px/cell
## at n=64 gives a ~1024px composite before zoom, fits a 1280x720+ viewport.
const CELL_PIXEL_SIZE: float = 16.0
const COLOR_BG: Color = Color("#0d1117")
## Border-fade target (§5 "what renders during the wait"): the underlying
## whole-body heightmap's own background tint, so the newly-exposed edge
## reads as "real data seen through", not a placeholder block — a dimmer/
## less-certain read of the same planetary data, not a different visual
## language. Also read by AtlasWindowOverlay for a per-tile pending wash in
## the mosaic (cold-start dossier) — same "still working" cue, one color.
## reads as "real data seen through", not a placeholder block. Also read by
## AtlasWindowOverlay for a per-tile pending wash in the mosaic — same
## "still working" cue, one color.
const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55)
## T-1153/R6: pending-refinement wash — border-fade's referent repointed to
@@ -86,6 +80,11 @@ const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55)
## hue, lighter alpha — hints something sharper is arriving, not that the view is wrong.
const COLOR_PENDING_REFINEMENT_WASH: Color = Color(0.20, 0.24, 0.30, 0.12)
## Cold-start dossier: the "DERIVING TERRAIN…" label's own text color —
## default_implant.tres's text_dim (#667788), matching the implant chrome's
## existing dim/secondary-text role rather than inventing a new hue.
const COLOR_DERIVING_LABEL: Color = Color("#667788")
## D-243: 2,048 m per district side.
const DISTRICT_M: float = 2048.0
@@ -135,38 +134,33 @@ var _held_center: Vector2i = Vector2i.ZERO
var _held_n: int = 32
## T-1153: the granularity_v2 tag this viewer is currently HOLDING (the
## last-adopted _window's own rung) — distinct from
## _window_request.get_granularity_v2() (most recently REQUESTED, may be a
## different rung already in flight). Defaults to District (see enter()'s doc).
## _window_request.get_granularity_v2() (most recently REQUESTED, may
## already be in flight to a different rung). Defaults to District.
var _held_granularity_v2: String = "District"
# ── Pan/zoom state ─────────────────────────────────────────────────────────
var _view_offset: Vector2 = Vector2.ZERO
var _view_zoom: float = 1.0
# T-1145 item 2: last known LOCAL mouse position (this Control's coordinate
# space), tracked from _gui_input's motion events for the edge-scroll check
# in _process() — _process() has no InputEvent of its own to read a position
# from, so the position has to be cached from the last motion event we DID
# see. Starts at -ONE (an impossible in-bounds position) so edge-scroll never
# fires before the mouse has ever moved over this Control at least once.
# T-1145 item 2: last known LOCAL mouse position, tracked from _gui_input's
# motion events for the edge-scroll check in _process() (which has no
# InputEvent of its own to read a position from). Starts at -ONE (an
# impossible in-bounds position) so edge-scroll never fires before the mouse
# has ever moved over this Control at least once.
var _last_mouse_pos: Vector2 = Vector2(-1.0, -1.0)
# T-1145 item 2: whether the OS application window currently has focus —
# edge-scroll is suppressed while false (see _is_cursor_edge_scrolling()'s
# doc). Defaults true: a freshly-entered screen assumes focus until told
# otherwise by NOTIFICATION_APPLICATION_FOCUS_OUT (matches the game's own
# window normally having focus when the player is actively navigating the
# implant in the first place).
# otherwise by NOTIFICATION_APPLICATION_FOCUS_OUT.
var _app_has_focus: bool = true
# T-1142/T-1145: true once the user has manually panned (WASD/edge-scroll,
# T-1145) or zoomed since the last enter()/fit — auto-fit (enter, first
# window arrival, resize) only re-fits BEFORE this flips, so it never fights
# a player mid-interaction. Reset to
# false on every enter() (a fresh descent always starts fitted).
# T-1142/T-1145: true once the user has manually panned or zoomed since the
# last enter()/fit — auto-fit (enter, first window arrival, resize) only
# re-fits BEFORE this flips, so it never fights a player mid-interaction.
# Reset to false on every enter() (a fresh descent always starts fitted).
var _user_adjusted: bool = false
# T-1142: true from enter() until the FIRST _on_window_ready() fires (the
# actual composite's arrival re-fits once, in case the entry-time fit used a
# not-yet-final viewport size) — false after that first arrival, so LATER
# pan-triggered window arrivals never re-fit on their own (only entry/first-
# arrival/resize do, per the ticket's three named events).
# composite's arrival re-fits once, in case entry used a not-yet-final
# viewport size) — false after, so LATER pan-triggered arrivals never re-fit
# on their own (only entry/first-arrival/resize do).
var _awaiting_first_window: bool = true
# ── Overlay visibility ─────────────────────────────────────────────────────
@@ -182,9 +176,8 @@ var _window_request = null # AtlasWindowRequest
var _tile_set = null # AtlasWindowTileSet (T-1153, live round 3)
## T-1153 (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()`; cleared the moment
## `_maybe_reselect_rung()` crosses OUT of Region.
## MULTI-WINDOW MOSAIC instead of the single held composite (`_window`). Set
## by `_enter_tile_mode()`; cleared when `_maybe_reselect_rung()` leaves Region.
var _tile_mode: bool = false
@@ -257,12 +250,10 @@ func enter(
## body, then wheel-zoom descends CONTINUOUSLY from there. Canonical origin
## = district (0,0), same quantity is_fully_zoomed_out()/
## _maybe_reset_to_canonical_frame() test against. No-radius bodies fall
## back to the District-rung default window.
##
## **Live round 3 (design doc §4): the rest state must TILE.** A single
## wire-capped Region window covers only a fraction of a real body's
## circumference. Once `compute_tile_grid()` returns MORE than one tile,
## entry goes through `_enter_tile_mode()` instead of `_enter_at_rung()`.
## back to the District-rung default window. Live round 3 (design doc §4):
## the rest state must TILE — a single wire-capped Region window covers only
## a fraction of a real body's circumference, so once compute_tile_grid()
## returns MORE than one tile, entry goes through _enter_tile_mode() instead.
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:
@@ -342,10 +333,10 @@ func _enter_at_rung(
_overlay_node.queue_redraw()
## T-1142: fit-and-center — applies AtlasWindowGeometry.fit_window_view()'s
## zoom/offset, then re-clamps the offset to the pole wall. Called from
## enter(), the FIRST _on_window_ready() after entry, and
## NOTIFICATION_RESIZED — never mid-interaction (guarded by _user_adjusted).
## T-1142: fit-and-center — applies fit_window_view()'s zoom/offset, then
## re-clamps to the pole wall. Called from enter(), the FIRST
## _on_window_ready() after entry, and NOTIFICATION_RESIZED — never
## mid-interaction (guarded by _user_adjusted).
func _fit_and_center() -> void:
var viewport: Vector2 = get_rect().size
if viewport == Vector2.ZERO:
@@ -425,9 +416,8 @@ func get_body_id() -> String:
## 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
## only draws" split as AtlasViewer/AtlasMarkerOverlay).
## rather than hardcoding CELL_PIXEL_SIZE, so the viewer stays the single
## source of geometry truth ("viewer owns the transform, overlay only draws").
func get_cell_pixel_size() -> float:
return CELL_PIXEL_SIZE
@@ -464,14 +454,13 @@ func _on_atlas_layers_received(response: Dictionary) -> void:
## 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
# (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
# reflects a re-entrant call, so comparing again here removes any
# ordering assumption. granularity_v2 (T-1153) is compared too — a
# district response answering a request since moved on to Region (rapid
# wheel-zoom) must not be adopted just because center/n still match.
# Only adopt if it still matches what THIS viewer is currently showing —
# AtlasWindowRequest already filtered by its own last-asked echo (§2/
# T-1150/T-1152), but a cache-hit path can fire synchronously from
# enter() before _held_center reflects a re-entrant call, so comparing
# again removes any ordering assumption. granularity_v2 is compared too
# — a district response answering a request since moved on to Region
# (rapid wheel-zoom) must not be adopted just because center/n match.
var w_center := _vec_from_center(window.get("center", [0, 0]))
var w_n := int(window.get("n", 0))
var w_granularity_v2 := str(
@@ -514,10 +503,10 @@ func _on_tile_ready(_index: int) -> void:
# =============================================================================
# View transform (mirrors AtlasViewer's own — pan is real; zoom is CURSOR-
# ANCHORED and CONTINUOUS ACROSS RUNGS, D-226 T-1143-rulings: the held
# composite is always drawn client-side-zoomed with NO re-request, but
# crossing a rung's spacing threshold fires a background request at the new
# granularity (progressive refinement, see _maybe_reselect_rung()) while the
# OLD composite stays on screen.
# composite draws client-side-zoomed with NO re-request, but crossing a
# rung's spacing threshold fires a background request at the new granularity
# (progressive refinement, _maybe_reselect_rung()) while the OLD composite
# stays on screen.
# =============================================================================
@@ -533,10 +522,9 @@ func _apply_transform() -> void:
## safety clamp applies); after applying, checks 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)
# Live round 6: once settled at the canonical frame, a continued zoom-OUT
# tick must not drift `_view_zoom` below fit (see the reset's own doc for
# why that caused a request storm). Clamping the ZOOM here — not the
# reset guard — keeps the reset edge-triggered. Zoom-IN is never clamped.
# Live round 6: once at the canonical frame, zoom-OUT must not drift
# below fit (see the reset's own doc — that caused a request storm).
# Clamping here, not the reset guard, keeps the reset edge-triggered.
if new_zoom < _view_zoom and _is_at_canonical_frame():
var fit_zoom: float = _canonical_fit_zoom()
new_zoom = maxf(new_zoom, fit_zoom)
@@ -562,12 +550,12 @@ func _current_world_extent_m() -> float:
## 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 (same formula as _maybe_refloat_window()). `_held_n`
## MUST be re-clamped via `_clamp_window_n_mirror_v2()` for the TARGET rung
## (C1 clamp-mirror, a third layer up) — a stale large `_held_n` desyncs
## `_on_window_ready()`'s staleness check. Does NOT touch
## `_window`/`_held_granularity_v2` — the OLD composite keeps drawing until
## _on_window_ready() adopts the new one (§6 "no mode flip"; live round 5:
## this lag is what made `_maybe_reset_to_canonical_frame()`'s OLD guard misfire).
## MUST be re-clamped for the TARGET rung (C1 clamp-mirror, a third layer
## up) — a stale large `_held_n` desyncs `_on_window_ready()`'s staleness
## check. Does NOT touch `_window`/`_held_granularity_v2` — the OLD
## composite keeps drawing until _on_window_ready() adopts the new one (§6
## "no mode flip"; this lag is what made the canonical-frame reset's OLD
## guard misfire, live round 5).
func _maybe_reselect_rung() -> void:
if _held_n <= 0:
return
@@ -655,20 +643,16 @@ func _is_at_canonical_frame() -> bool:
## Jeroen's HARD condition: "a full zoom-out resets to the original
## canonical planetary frame and location." EDGE-triggered (live round 6):
## fires only on the transition INTO fully-zoomed-out from non-canonical.
##
## **Live round 5 fix:** the old guard checked only
## `_held_center`/`_held_granularity_v2` (LAGGING, updated only on response
## adoption) — a TILING body's granularity stayed stale "Region" after
## zooming IN left tile mode, misreading "already canonical." Fixed by also
## requiring `is_tile_mode()` to match.
##
## **Live round 6 fix (round 5's fix overshot into a storm):** a per-tick
## zoom-equality check made this guard LEVEL-triggered — continued zoom-out
## kept nudging `_view_zoom` below fit, so `enter_orbital()` fired
## repeatedly: tile set torn down/recreated each time, orphaning in-flight
## responses (nothing held -> black), flooding the server (889/897 wire
## responses in one phase). Fixed by moving the zoom-drift concern to
## `_zoom_at()`'s own floor — this guard stays edge-triggered.
## Live round 5 fix: also requires is_tile_mode() to match (a TILING body's
## granularity stayed stale "Region" after zooming IN left tile mode,
## misreading "already canonical" from `_held_center`/`_held_granularity_v2`
## alone — LAGGING fields, updated only on response adoption). Live round 6
## fix: a per-tick zoom-equality check had made this LEVEL-triggered —
## continued zoom-out flooded the server (889/897 wire responses in one
## phase) via repeated enter_orbital() tearing down/rebuilding the tile set,
## orphaning in-flight responses (nothing held -> black). Fixed by moving
## the zoom-drift concern to `_zoom_at()`'s own floor instead — this guard
## stays edge-triggered.
func _maybe_reset_to_canonical_frame() -> bool:
var radius_km: float = float(_body.get("body_radius_km", 0.0))
if radius_km <= 0.0:
@@ -764,6 +748,8 @@ func _draw() -> void:
# T-1153: this viewer's own border-fade/pending-wash are single-window
# concepts (one extent, one state) — a mosaic's per-tile pending wash
# is drawn by AtlasWindowOverlay itself (see _draw_tile_mosaic()).
if _tile_set and not _tile_set.has_any_tile_arrived():
_draw_deriving_terrain_label()
return
if _window == null:
# §5 "what renders during the wait": a border-fade to the underlying
@@ -793,6 +779,26 @@ func _draw_pending_refinement_wash() -> void:
draw_rect(Rect2(top_left, Vector2(extent, extent)), COLOR_PENDING_REFINEMENT_WASH)
## Cold-start dossier: the subtle per-tile COLOR_BORDER_FADE wash alone
## (AtlasWindowOverlay._draw_tile_mosaic()) read as broken darkness in a
## live cold capture (>10s, zero tiles landed), not loading. Drawn in SCREEN
## space (unlike the per-tile washes, which live in the panned/zoomed
## _canvas) so it always reads centered/legible regardless of zoom. Dropped
## the instant even one tile lands (has_any_tile_arrived()) — per-tile
## washes alone read fine once real content fills in around the gaps.
## Positioning math lives in AtlasWindowGeometry.centered_label_baseline()
## (file-length; a pure function either way).
func _draw_deriving_terrain_label() -> void:
var label := "DERIVING TERRAIN…"
var font := ThemeDB.fallback_font
var font_size := 20
var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline(get_rect().size, text_size)
draw_string(
font, baseline, label, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size, COLOR_DERIVING_LABEL
)
func _build_screen_header() -> void:
_screen_header = ImplantHeader.new()
_screen_header.position = Vector2(PANEL_MARGIN, 16.0)
@@ -830,10 +836,9 @@ func _location_label() -> String:
# =============================================================================
## No city panel / sidebar in this mode (yet) — nothing to hit-test against,
## so this always reads false. Wired into _gui_input exactly where
## AtlasViewer's own _is_over_ui is, so a future sidebar addition only
## needs to change THIS function's body.
## No city panel / sidebar in this mode (yet) — always false. Wired into
## _gui_input exactly where AtlasViewer's own _is_over_ui is, so a future
## sidebar addition only needs to change THIS function's body.
func _is_over_ui(_pos: Vector2) -> bool:
return false
@@ -894,10 +899,9 @@ func _process(delta: float) -> void:
## 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.
## this frame's delta — frame-rate independent, zoom-scaled, pole-wall
## clamped (T-1142). Sets _user_adjusted and triggers the pan-edge refetch
## (§4). Split from _process() so a test can call it with a synthetic input.
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
@@ -908,9 +912,8 @@ 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) — 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).
## — see AtlasWindowGeometry.held_pan_direction()'s own doc for the full
## rationale (moved there for file-length/testability).
func _held_pan_direction() -> Vector2:
return AtlasWindowGeometry.held_pan_direction()
+29
View File
@@ -86,3 +86,32 @@ func clear() -> void:
for child in _vbox.get_children():
_vbox.remove_child(child)
child.free()
## PR #192 cold-start round 2: call once at the END of a refresh() — AFTER
## clear() + every add_component() for the new content. A Control's `size`
## GROWS to fit a rising minimum size automatically (normal Container
## behavior) but never shrinks back down on its own once a taller stack of
## content has pushed it up (confirmed live: the same stacking window that
## caused the duplicate-children bug above ALSO left the panel's rect
## oversized — 260x2343px, ~10 legends tall — even after the children-count
## fix landed). This panel is manually positioned (not itself inside a
## parent Container), so nothing else forces that re-measure.
##
## DEFERRED, not a direct reset_size() call — confirmed directly
## (instrumented and reverted through several shapes before landing here):
## get_minimum_size() is momentarily WRONG for a frame or more after
## add_component() runs (RichTextLabel.fit_content's minimum-height
## computation is width-dependent and the control hasn't been laid out with
## its real width yet — the classic Godot "fit_content measures huge before
## its first real layout pass" gap), and — critically — a DIRECT reset_size()
## call that captures that transitional wrong value does NOT self-correct
## later even once get_minimum_size() itself settles down on its own:
## `size` stays stuck at whatever the premature reset_size() captured,
## because nothing re-triggers a growth pass for an already-`_ready()`'d
## panel afterward. call_deferred() runs at the END of the current frame's
## idle processing — after layout has had its chance to update
## get_minimum_size() — so by the time this actually executes, it reads the
## real, settled value.
func reset_to_content_size() -> void:
call_deferred("reset_size")