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:
@@ -126,6 +126,9 @@ build: build-server build-client
|
||||
build-server:
|
||||
cd server && cargo build
|
||||
|
||||
build-server-release:
|
||||
cd server && cargo build --release
|
||||
|
||||
build-client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
@# First import may error on theme/font loading before the import scan completes.
|
||||
@@ -161,9 +164,14 @@ game: stop build
|
||||
# without it SimBridge boots in test mode (instant fake CONNECTED, no network
|
||||
# at all), which would make the companion open the Atlas against a dynamic
|
||||
# test-harness snapshot instead of the real systems.db world via the wire.
|
||||
atlas: build-server build-client
|
||||
# Release server for the atlas: a cold DEBUG server takes >10s before the
|
||||
# first tile of a new body can exist (first-body terrain analysis), which
|
||||
# reads as a black/broken map on entry (live-verified 2026-07-22). The
|
||||
# release binary serves a cold body's first tiles in well under a second.
|
||||
# SR_SERVER_BIN tells atlas_standalone.gd's spawn path which binary to use.
|
||||
atlas: build-server-release build-client
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
@SR_LIVE=1 $(GODOT) --path client scenes/atlas_standalone.tscn
|
||||
@SR_LIVE=1 SR_SERVER_BIN=server/target/release/settled-reach-server $(GODOT) --path client scenes/atlas_standalone.tscn
|
||||
|
||||
stop:
|
||||
@lsof -ti :9876 | xargs -r kill 2>/dev/null || true
|
||||
|
||||
@@ -263,8 +263,26 @@ func _wait_for_connected(timeout_s: float) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func _server_binary_path() -> String:
|
||||
var project_dir := ProjectSettings.globalize_path("res://")
|
||||
## Pure function: SR_SERVER_BIN env override, else the debug build path —
|
||||
## same two-tier shape as _attach_port()'s SR_PORT (D-254 §1). `make atlas`
|
||||
## now builds the RELEASE server and passes SR_SERVER_BIN so a `_spawn_server()`
|
||||
## cold-start (this file's own live-repro path — the coordinator's cold-start
|
||||
## dossier) gets sub-second first-tile derivation instead of a debug build's
|
||||
## multi-second AnalyzeBody. `override_env`/`override_project_dir` exist ONLY
|
||||
## for direct-call unit tests (mirrors _attach_port(port_env)'s own param
|
||||
## shape) — every real caller uses the zero-arg form. Accepts either an
|
||||
## ABSOLUTE path or one relative to the project root (`res://../`), so
|
||||
## SR_SERVER_BIN can be given as `server/target/release/settled-reach-server`
|
||||
## (the Makefile's own shape) without the caller having to know this script's
|
||||
## `res://` layout.
|
||||
static func _server_binary_path(override_env: String = "", override_project_dir: String = "") -> String:
|
||||
var project_dir := (
|
||||
override_project_dir if not override_project_dir.is_empty()
|
||||
else ProjectSettings.globalize_path("res://")
|
||||
)
|
||||
var env := override_env if not override_env.is_empty() else OS.get_environment("SR_SERVER_BIN")
|
||||
if not env.is_empty():
|
||||
return env if env.is_absolute_path() else project_dir.path_join("../" + env)
|
||||
return project_dir.path_join("../server/target/debug/settled-reach-server")
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
## PR #192 cold-start dossier — coordinator's live repro against a freshly-
|
||||
## spawned (cold) server (`make atlas` shape, first AnalyzeBody taking
|
||||
## seconds): BUG 1 (tile-mosaic paint never resolving) and the legend-
|
||||
## stacking half of BUG 2. Split out of test_atlas_zoom_ladder.gd purely for
|
||||
## file-length reasons (gdlint max-file-lines) — same instantiation/mock-
|
||||
## response conventions as that file, not a different testing philosophy.
|
||||
## seconds): BUG 1 (tile-mosaic paint never resolving), the legend-stacking
|
||||
## half of BUG 2, and BUG 3 (the "DERIVING TERRAIN…" pending-state label,
|
||||
## round 2). Split out of test_atlas_zoom_ladder.gd purely for file-length
|
||||
## reasons (gdlint max-file-lines) — same instantiation/mock-response
|
||||
## conventions as that file, not a different testing philosophy.
|
||||
## RegionalScreen's own re-entry-guard half of BUG 2 is covered separately
|
||||
## in test_regional_screen.gd (a different layer — nav, not the viewer).
|
||||
class_name TestAtlasColdStart
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
|
||||
## Dudley's WINDOW_GRANULARITY_REGION_KEY sentinel — mirrors
|
||||
## test_atlas_zoom_ladder.gd's own constant (see that file's doc for why the
|
||||
@@ -159,3 +161,151 @@ func test_legend_refresh_is_idempotent_against_same_frame_re_entry() -> void:
|
||||
+ " free immediately, not defer via queue_free()"
|
||||
) % [baseline_count, after_count]
|
||||
).is_equal(baseline_count)
|
||||
|
||||
|
||||
## PR #192 cold-start round 2: the children-count fix above is necessary but
|
||||
## not sufficient — the coordinator's live scene dump showed the CONTAINER
|
||||
## itself measured 260x2343px even after that fix landed. Root cause turned
|
||||
## out to be BROADER than "only after stacking": this panel is manually
|
||||
## positioned under AtlasWindowViewer (not inside a parent Container), so
|
||||
## `size` NEVER tracks a shrinking `get_minimum_size()` on its own at
|
||||
## all — confirmed directly (instrumented and reverted) that even a
|
||||
## completely FRESH, never-refreshed-twice legend shows `size` frozen at
|
||||
## whatever it happened to be on its very first measurement, while
|
||||
## get_minimum_size() reports the correct value the whole time. The
|
||||
## regression here therefore compares `size.y` against the RELIABLE ground
|
||||
## truth (`get_minimum_size().y`, confirmed correct in every trace) rather
|
||||
## than an earlier `size.y` snapshot — comparing size-to-size would pass
|
||||
## trivially if BOTH numbers were equally stuck at the same stale value,
|
||||
## which is exactly what silently happened during earlier drafts of this
|
||||
## test. Reproduces the stacking shape (N same-frame refresh() calls) for
|
||||
## realism, matching the coordinator's own trigger — the coordinator's
|
||||
## acceptance bar: "after N refreshes the panel rect height must be within
|
||||
## one legend's height" (of the CORRECT single-legend height, i.e. the
|
||||
## settled minimum size).
|
||||
func test_legend_panel_shrinks_back_after_a_stacking_window() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
|
||||
# Reproduce the stacking window directly (same-frame repeat refresh()
|
||||
# calls, matching the pre-fix trigger shape) — this drives the panel's
|
||||
# minimum size up the same way N repeat enter_orbital() calls did live.
|
||||
for _i in range(10):
|
||||
v._legend_panel.refresh()
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
|
||||
# A further, ordinary refresh() (the kind every real rung change already
|
||||
# triggers) must leave the panel within one legend's height.
|
||||
v._legend_panel.refresh()
|
||||
# reset_to_content_size() is deferred (see its own doc — get_minimum_size()
|
||||
# is momentarily wrong for RichTextLabel.fit_content children until the
|
||||
# panel's real width has been laid out once) — a real frame must elapse
|
||||
# for the deferred reset_size() call to actually run.
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
|
||||
var one_legend_height: float = v._legend_panel.get_minimum_size().y
|
||||
assert_float(one_legend_height).override_failure_message(
|
||||
"sanity: a single settled legend must have a real, non-zero measured minimum height"
|
||||
).is_greater(0.0)
|
||||
assert_float(v._legend_panel.size.y).override_failure_message(
|
||||
(
|
||||
"after a stacking window, the legend panel's rect height (%.1f) must"
|
||||
+ " shrink back to within one legend's height (%.1f, the panel's own"
|
||||
+ " correctly-settled get_minimum_size()) — reset_to_content_size()"
|
||||
+ " must actually collapse the Control back down, not just hold onto"
|
||||
+ " its previously-grown size"
|
||||
) % [v._legend_panel.size.y, one_legend_height]
|
||||
).is_less_equal(one_legend_height + 1.0) # +1.0: float rounding slack
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BUG 3 (round 2) — "DERIVING TERRAIN…" label: the subtle per-tile
|
||||
# COLOR_BORDER_FADE wash alone was invisible in a live cold capture. The
|
||||
# viewer draws an unmistakable centered label while ZERO tiles have arrived,
|
||||
# dropping it the instant even one lands.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The viewer must show the label exactly while is_tile_mode() is true AND
|
||||
## has_any_tile_arrived() is false — the coordinator's "ZERO tiles have
|
||||
## arrived" trigger condition, pinned directly against real tile-set state
|
||||
## (not a mock) via a real enter_orbital() on a tiling body. Also exercises
|
||||
## _draw()'s ACTUAL dispatch to _draw_deriving_terrain_label() through a
|
||||
## real frame (queue_redraw() + await process_frame, matching the
|
||||
## _CountingOverlay spy pattern from the BUG 1 self-heal tests above) —
|
||||
## proving the draw call itself is reachable and doesn't error, not just
|
||||
## that the underlying predicate is correct.
|
||||
func test_deriving_terrain_label_condition_true_before_any_tile_arrives() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_bool(v.is_tile_mode()).is_true()
|
||||
assert_bool(v._tile_set.has_any_tile_arrived()).override_failure_message(
|
||||
"sanity: entry must leave every tile unarrived before any response arrives"
|
||||
).is_false()
|
||||
v.queue_redraw()
|
||||
await get_tree().process_frame
|
||||
|
||||
|
||||
## The instant even ONE tile lands, the label's own gate condition must flip
|
||||
## off — per-tile washes alone are the right treatment once real content is
|
||||
## visibly filling in (coordinator: "dropping to per-tile washes once the
|
||||
## first tile lands").
|
||||
func test_deriving_terrain_label_condition_false_after_one_tile_arrives() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel)
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
var tile_set = v.get_tile_set()
|
||||
var first_tile: Dictionary = tile_set.get_tiles()[0]
|
||||
var window: Dictionary = _mock_window(first_tile["center"])
|
||||
window["granularity_v2"] = "Region"
|
||||
window["granularity"] = SERVER_LEGACY_GRANULARITY_REGION_SENTINEL
|
||||
window["n"] = AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
|
||||
assert_bool(tile_set.has_any_tile_arrived()).override_failure_message(
|
||||
"the label's own gate condition (NOT has_any_tile_arrived()) must flip"
|
||||
+ " false the instant a single tile lands, dropping the label"
|
||||
).is_true()
|
||||
|
||||
|
||||
## Single-window mode (District/Quarter/small-body Region, not tile mode)
|
||||
## never shows this label at all — it's a mosaic-specific cue for the
|
||||
## "whole orbital rest state is still deriving" case, not every wait state
|
||||
## (the single-window path already has its own COLOR_BORDER_FADE treatment,
|
||||
## unchanged by this round).
|
||||
func test_deriving_terrain_label_never_applies_outside_tile_mode() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"sanity: a District-rung enter() must never be tile mode"
|
||||
).is_false()
|
||||
|
||||
|
||||
## centered_label_baseline() pure geometry: the X component is the
|
||||
## VIEWPORT-CENTERED text block's left-anchor-adjusted X (viewport center
|
||||
## minus half the text width — draw_string() itself does the final
|
||||
## horizontal centering from there via HORIZONTAL_ALIGNMENT_CENTER, this
|
||||
## only sets up where that alignment measures from); the Y component sits
|
||||
## at viewport-center (a draw_string() baseline is the text's OWN vertical
|
||||
## center here, by construction: center.y - text.y/2 + text.y/2 == center.y).
|
||||
func test_centered_label_baseline_centers_a_symmetric_case() -> void:
|
||||
var viewport_size := Vector2(1000.0, 800.0)
|
||||
var text_size := Vector2(200.0, 40.0)
|
||||
var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline(viewport_size, text_size)
|
||||
assert_that(baseline).is_equal(Vector2(400.0, 400.0))
|
||||
|
||||
|
||||
## A zero-size viewport (never laid out yet) must not crash — degenerate
|
||||
## input, not a real scenario, but the function must stay total.
|
||||
func test_centered_label_baseline_zero_viewport_does_not_crash() -> void:
|
||||
var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline(
|
||||
Vector2.ZERO, Vector2(100.0, 20.0)
|
||||
)
|
||||
assert_that(baseline).is_equal(Vector2(-50.0, 0.0))
|
||||
|
||||
@@ -89,6 +89,60 @@ func test_attach_port_whitespace_env_falls_back_to_default() -> void:
|
||||
assert_that(s._attach_port(" ")).is_equal(9876)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _server_binary_path — SR_SERVER_BIN env override, else the debug build path
|
||||
# (same two-tier shape as _attach_port's SR_PORT). PR #192 cold-start round 2:
|
||||
# `make atlas` now builds RELEASE and passes SR_SERVER_BIN so a spawned cold
|
||||
# server's first AnalyzeBody is sub-second instead of a debug build's
|
||||
# multi-second derivation.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_server_binary_path_unset_env_falls_back_to_debug_path() -> void:
|
||||
var s = _script()
|
||||
assert_that(s._server_binary_path("", "/project")).is_equal(
|
||||
"/project/../server/target/debug/settled-reach-server"
|
||||
)
|
||||
|
||||
|
||||
func test_server_binary_path_relative_env_is_joined_to_project_root() -> void:
|
||||
# The exact shape make atlas's own SR_SERVER_BIN value takes: relative to
|
||||
# the repo root, not to client/'s res:// tree — matching the Makefile's
|
||||
# own "server/target/release/settled-reach-server" string.
|
||||
var s = _script()
|
||||
assert_that(
|
||||
s._server_binary_path("server/target/release/settled-reach-server", "/project")
|
||||
).is_equal("/project/../server/target/release/settled-reach-server")
|
||||
|
||||
|
||||
func test_server_binary_path_absolute_env_is_used_verbatim() -> void:
|
||||
var s = _script()
|
||||
assert_that(s._server_binary_path("/opt/custom/settled-reach-server", "/project")).is_equal(
|
||||
"/opt/custom/settled-reach-server"
|
||||
)
|
||||
|
||||
|
||||
## The acceptance shape the coordinator asked for verbatim: "env set -> that
|
||||
## path used; unset -> debug path unchanged" — via the REAL OS.get_environment
|
||||
## read (zero override_env arg), not the injectable param the tests above use
|
||||
## for isolation. OS.set_environment() is the standard gdUnit4-safe way to
|
||||
## drive a real env var for the duration of one test without touching the
|
||||
## actual process environment permanently.
|
||||
func test_server_binary_path_real_env_set_overrides_debug_path() -> void:
|
||||
var s = _script()
|
||||
OS.set_environment("SR_SERVER_BIN", "server/target/release/settled-reach-server")
|
||||
var result: String = s._server_binary_path("", "/project")
|
||||
OS.set_environment("SR_SERVER_BIN", "")
|
||||
assert_that(result).is_equal("/project/../server/target/release/settled-reach-server")
|
||||
|
||||
|
||||
func test_server_binary_path_real_env_unset_leaves_debug_path_unchanged() -> void:
|
||||
var s = _script()
|
||||
OS.set_environment("SR_SERVER_BIN", "")
|
||||
var result: String = s._server_binary_path("", "/project")
|
||||
assert_that(result).is_equal("/project/../server/target/debug/settled-reach-server")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _parse_listening_line — main.rs's "LISTENING:{port}" stdout signal
|
||||
# (server/src/main.rs) parsed to an int, or -1 if the line doesn't match.
|
||||
|
||||
@@ -142,6 +142,79 @@ func test_all_tiles_arriving_flips_fully_arrived() -> void:
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# has_pending_tiles() / has_any_tile_arrived() — PR #192 cold-start dossier.
|
||||
# Distinct predicates (both can be true at once, mid-arrival): the viewer's
|
||||
# self-healing redraw (BUG 1) polls has_pending_tiles(); the "DERIVING
|
||||
# TERRAIN…" label (BUG 3) polls has_any_tile_arrived() to know when to drop.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_has_pending_tiles_true_immediately_after_enter() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
assert_bool(ts.has_pending_tiles()).override_failure_message(
|
||||
"every tile is unarrived right after enter() — has_pending_tiles() must be true"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_has_pending_tiles_false_once_every_tile_has_arrived() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
for tile: Dictionary in ts.get_tiles():
|
||||
var window: Dictionary = _mock_window(
|
||||
tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
assert_bool(ts.has_pending_tiles()).is_false()
|
||||
|
||||
|
||||
func test_has_pending_tiles_true_while_only_some_tiles_have_arrived() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var first_tile: Dictionary = ts.get_tiles()[0]
|
||||
var window: Dictionary = _mock_window(
|
||||
first_tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
assert_bool(ts.has_pending_tiles()).override_failure_message(
|
||||
"5 of 6 tiles still unarrived — has_pending_tiles() must stay true"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_has_any_tile_arrived_false_immediately_after_enter() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
assert_bool(ts.has_any_tile_arrived()).override_failure_message(
|
||||
"nothing has arrived right after enter() — has_any_tile_arrived() must be false"
|
||||
).is_false()
|
||||
|
||||
|
||||
## The exact mid-arrival case both predicates must agree can coexist: one
|
||||
## tile in, five still pending — the point the "DERIVING TERRAIN…" label
|
||||
## must drop (has_any_tile_arrived() flips true) while the self-heal must
|
||||
## keep redrawing (has_pending_tiles() stays true).
|
||||
func test_has_any_tile_arrived_true_after_a_single_tile_lands() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var first_tile: Dictionary = ts.get_tiles()[0]
|
||||
var window: Dictionary = _mock_window(
|
||||
first_tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
assert_bool(ts.has_any_tile_arrived()).is_true()
|
||||
assert_bool(ts.has_pending_tiles()).override_failure_message(
|
||||
"sanity: the other 5 tiles are still pending at the same moment"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_has_any_tile_arrived_false_for_an_empty_tile_set() -> void:
|
||||
var ts = _make_tile_set()
|
||||
assert_bool(ts.has_any_tile_arrived()).override_failure_message(
|
||||
"an empty tile set (never entered) must not vacuously report arrival"
|
||||
).is_false()
|
||||
|
||||
|
||||
## A response for a body the tile set is NOT currently showing (a stale
|
||||
## response from a body the player has since navigated away from) must not
|
||||
## be adopted by any tile — the SAME body_id staleness guard every other
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user