fix(ui): Atlas fetch readout, and one settle timer for resize and pan
Two viewer fixes from the same pair session; they share step_canvas_viewer.gd so they land together. FETCH READOUT. A cold derive takes seconds and the map gave no honest sign of it. Root cause found while building the replacement: a Control paints its own _draw() BEFORE its children, so everything the viewer drew itself — the old DERIVING TERRAIN label AND the pending wash — was painted UNDER the terrain canvas, visible only when no texture existed at all, i.e. never in the slow-fetch case they existed for. That has been the state since the stepped viewer shipped. The readout now lives in its own overlay node added after the canvas: a centered implant-idiom panel, DOWNLOADING MAP DATA, indeterminate sweep, 250 ms grace so cache hits never flash it, held canvas still drawing beneath. Indeterminate by design — the server reports no derive sub-steps, and a progress fraction we cannot source would be invented. ONE SETTLE TIMER, THREE TRIGGERS. The viewer never re-requested a canvas on resize, so one derived for a smaller window letterboxed forever in a bigger one — the map not filling the frame. And the pan-edge refetch fired from inside the per-frame pan loop the instant its threshold was crossed, so a held edge-scroll issued a fresh request AND snapped the view on every frame past it. Both are the same event: the user is still moving. A shared one-shot timer now collapses them into a single request at the resting state, with a hard pan threshold that still fires immediately when the canvas edge is about to enter view (waiting there would show empty background), and drifting back inside the soft threshold cancels the pending request. Resize reaches this Control identically whether the OS window or a diegetic in-implant parent changed, so both sources are covered. Two new tests pin the soft path (schedules, does not refloat or snap) and its cancellation; the pre-existing threshold test was verified to still discriminate — its drift trips the new hard threshold — rather than passing vacuously. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -261,7 +261,7 @@ func test_maybe_refloat_does_not_refloat_on_a_small_pan() -> void:
|
||||
## A large pan (past half the canvas footprint) DOES re-float — new
|
||||
## world_center, view_offset reset to ZERO (the canvas re-centers under the
|
||||
## new request).
|
||||
func test_maybe_refloat_refloats_once_the_pan_crosses_the_edge_threshold() -> void:
|
||||
func test_maybe_refloat_refloats_once_the_pan_crosses_the_hard_threshold() -> void:
|
||||
var v: StepCanvasViewer = _make_viewer()
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
@@ -281,6 +281,68 @@ func test_maybe_refloat_refloats_once_the_pan_crosses_the_edge_threshold() -> vo
|
||||
).is_equal(Vector2.ZERO)
|
||||
|
||||
|
||||
## Pair session 2026-07-26 — the SOFT threshold: a pan that crosses it must
|
||||
## NOT re-float on the spot. It schedules the shared settle timer and leaves
|
||||
## the view alone, so a held edge-scroll keeps panning the current canvas
|
||||
## smoothly and issues ONE request when it stops, instead of a request plus a
|
||||
## view snap on every frame past the threshold (the old behavior).
|
||||
func test_soft_pan_drift_schedules_the_settle_instead_of_refloating_now() -> void:
|
||||
var v: StepCanvasViewer = _make_viewer()
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
v._scroll_rung(1, Vector2(400.0, 300.0)) # District
|
||||
v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(512, 384), v.get_held_rung(), "")
|
||||
v._view_offset = v._centered_view_offset()
|
||||
|
||||
# Drift to just past SOFT but well short of HARD.
|
||||
var half: Vector2 = v._terrain_layer.get_footprint_px() * 0.5
|
||||
var target: float = (
|
||||
StepCanvasViewer.PAN_REFLOAT_SOFT_FRACTION + StepCanvasViewer.PAN_REFLOAT_HARD_FRACTION
|
||||
) * 0.5
|
||||
var screen_center: Vector2 = v.get_rect().size * 0.5
|
||||
v._view_offset = screen_center - half + Vector2(half.x * target, 0.0)
|
||||
|
||||
var fraction: float = v._pan_drift_fraction()
|
||||
assert_float(fraction).override_failure_message(
|
||||
"test setup: drift must land between the soft and hard thresholds"
|
||||
).is_between(StepCanvasViewer.PAN_REFLOAT_SOFT_FRACTION, StepCanvasViewer.PAN_REFLOAT_HARD_FRACTION)
|
||||
|
||||
var center_before: Vector2 = v._world_center
|
||||
var offset_before: Vector2 = v._view_offset
|
||||
v._maybe_refloat()
|
||||
|
||||
assert_that(v._world_center).override_failure_message(
|
||||
"a soft-threshold pan must NOT re-float immediately"
|
||||
).is_equal(center_before)
|
||||
assert_that(v._view_offset).override_failure_message(
|
||||
"a soft-threshold pan must not snap the view — the held canvas keeps panning"
|
||||
).is_equal(offset_before)
|
||||
assert_bool(v._refetch_settle_timer.is_stopped()).override_failure_message(
|
||||
"a soft-threshold pan must schedule the shared settle timer"
|
||||
).is_false()
|
||||
|
||||
|
||||
## The inverse: panning back inside the soft threshold cancels the pending
|
||||
## settle — the view no longer wants a different canvas, so the request that
|
||||
## was about to go out must not.
|
||||
func test_panning_back_inside_the_soft_threshold_cancels_the_pending_settle() -> void:
|
||||
var v: StepCanvasViewer = _make_viewer()
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
v._scroll_rung(1, Vector2(400.0, 300.0))
|
||||
v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(512, 384), v.get_held_rung(), "")
|
||||
|
||||
v._refetch_settle_timer.start() # pretend a soft crossing already scheduled one
|
||||
v._view_offset = v._centered_view_offset() # centred == zero drift
|
||||
v._maybe_refloat()
|
||||
|
||||
assert_bool(v._refetch_settle_timer.is_stopped()).override_failure_message(
|
||||
"drifting back inside the soft threshold must cancel the pending refetch"
|
||||
).is_true()
|
||||
|
||||
|
||||
## Global never re-floats on pan (D-255(a): its canvas is the whole body,
|
||||
## no edge to cross) — even with a real texture held and a huge drift.
|
||||
func test_maybe_refloat_is_a_no_op_at_global_rung() -> void:
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
extends Control
|
||||
|
||||
## The Atlas's diegetic fetch readout — "DOWNLOADING MAP DATA" over the held
|
||||
## map while a step canvas is in flight (pair session 2026-07-26).
|
||||
##
|
||||
## WHY THIS IS ITS OWN NODE, not a `_draw()` in StepCanvasViewer: a Control
|
||||
## paints its own `_draw()` BEFORE its children, so anything the viewer drew
|
||||
## directly landed UNDERNEATH `_canvas` (the terrain texture) and was only
|
||||
## ever visible when no texture existed at all. That is exactly why the
|
||||
## previous treatment (a "DERIVING TERRAIN" label + a 12%-alpha wash, both
|
||||
## drawn by the viewer itself) was invisible during a real slow fetch — the
|
||||
## case it existed for. This node is added AFTER `_canvas` in the viewer's
|
||||
## `_ready()`, so it draws on top of the map by construction.
|
||||
##
|
||||
## Indeterminate, not a real percentage: the server reports no derive
|
||||
## sub-steps today, so a progress fraction would be invented. A sweeping
|
||||
## segment is the honest signal — "working, no ETA". A true progress bar is a
|
||||
## wire addition (StepCanvasResponse progress frames), deliberately deferred.
|
||||
##
|
||||
## The panel appears only after a grace delay so cache hits never flash it,
|
||||
## and it never blanks the screen: hold-and-swap keeps the previous rung's
|
||||
## canvas visible underneath (D-255's "no blank frames" rule).
|
||||
|
||||
const LABEL_TEXT: String = "DOWNLOADING MAP DATA"
|
||||
const GRACE_S: float = 0.25
|
||||
const PANEL_SIZE: Vector2 = Vector2(360.0, 92.0)
|
||||
const BAR_HEIGHT: float = 10.0
|
||||
const BAR_INSET: float = 24.0
|
||||
const SWEEP_FRACTION: float = 0.32
|
||||
const SWEEP_PERIOD_S: float = 1.1
|
||||
|
||||
const COLOR_PANEL_BG: Color = Color(0.05, 0.07, 0.09, 0.88)
|
||||
const COLOR_PANEL_BORDER: Color = Color(0.36, 0.62, 0.73, 0.85)
|
||||
const COLOR_LABEL: Color = Color("#c8d0e0")
|
||||
const COLOR_BAR_TRACK: Color = Color(0.16, 0.20, 0.24, 0.9)
|
||||
const COLOR_BAR_LIT: Color = Color("#4a9ebb")
|
||||
|
||||
## Seconds the current fetch has been pending, or -1.0 when idle. One clock
|
||||
## drives both the grace gate and the sweep phase, so the animation always
|
||||
## starts at the panel's first visible frame rather than mid-cycle.
|
||||
var _elapsed_s: float = -1.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||
grow_vertical = Control.GROW_DIRECTION_BOTH
|
||||
# Never intercept input: the viewer beneath owns pan/zoom/click entirely.
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
## Driven from the viewer's `_process()` (the viewer owns the request and
|
||||
## therefore the pending state; this node just renders it). Repaints per frame
|
||||
## only while the panel is up — the sweep is an animation and the viewer's
|
||||
## ordinary event-driven redraws (canvas arrival, pan, resize) do not cover it.
|
||||
func tick(delta: float, is_pending: bool) -> void:
|
||||
if not is_pending:
|
||||
var was_showing := is_showing()
|
||||
_elapsed_s = -1.0
|
||||
if was_showing:
|
||||
queue_redraw() # clear the panel the frame the fetch lands
|
||||
return
|
||||
if _elapsed_s < 0.0:
|
||||
_elapsed_s = 0.0
|
||||
_elapsed_s += delta
|
||||
if is_showing():
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func is_showing() -> bool:
|
||||
return _elapsed_s >= GRACE_S
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not is_showing():
|
||||
return
|
||||
var origin: Vector2 = ((size - PANEL_SIZE) * 0.5).floor()
|
||||
var panel := Rect2(origin, PANEL_SIZE)
|
||||
draw_rect(panel, COLOR_PANEL_BG)
|
||||
draw_rect(panel, COLOR_PANEL_BORDER, false, 1.0)
|
||||
|
||||
var font := ThemeDB.fallback_font
|
||||
var font_size := 16
|
||||
var text_size: Vector2 = font.get_string_size(
|
||||
LABEL_TEXT, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size
|
||||
)
|
||||
var text_pos := Vector2(origin.x + (PANEL_SIZE.x - text_size.x) * 0.5, origin.y + 34.0)
|
||||
draw_string(font, text_pos, LABEL_TEXT, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, COLOR_LABEL)
|
||||
|
||||
var track := Rect2(
|
||||
origin + Vector2(BAR_INSET, PANEL_SIZE.y - BAR_INSET - BAR_HEIGHT),
|
||||
Vector2(PANEL_SIZE.x - BAR_INSET * 2.0, BAR_HEIGHT)
|
||||
)
|
||||
draw_rect(track, COLOR_BAR_TRACK)
|
||||
draw_rect(sweep_rect(track, _elapsed_s), COLOR_BAR_LIT)
|
||||
|
||||
|
||||
## Pure geometry for the indeterminate sweep — static so it is unit-testable
|
||||
## without a live fetch. The lit segment is SWEEP_FRACTION of the track,
|
||||
## travelling left to right once per SWEEP_PERIOD_S and wrapping, clipped to
|
||||
## the track at both ends so it never paints outside the panel.
|
||||
static func sweep_rect(track: Rect2, elapsed_s: float) -> Rect2:
|
||||
var seg_w: float = track.size.x * SWEEP_FRACTION
|
||||
var travel: float = track.size.x + seg_w
|
||||
var phase: float = fposmod(elapsed_s, SWEEP_PERIOD_S) / SWEEP_PERIOD_S
|
||||
var lead: float = track.position.x - seg_w + travel * phase
|
||||
var lo: float = maxf(lead, track.position.x)
|
||||
var hi: float = minf(lead + seg_w, track.position.x + track.size.x)
|
||||
if hi <= lo:
|
||||
return Rect2(track.position, Vector2(0.0, track.size.y))
|
||||
return Rect2(Vector2(lo, track.position.y), Vector2(hi - lo, track.size.y))
|
||||
@@ -67,7 +67,37 @@ const EDGE_SCROLL_MARGIN_PX: float = 24.0
|
||||
const COLOR_BG: Color = Color("#0d1117")
|
||||
const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55)
|
||||
const COLOR_PENDING_WASH: Color = Color(0.20, 0.24, 0.30, 0.12)
|
||||
const COLOR_DERIVING_LABEL: Color = Color("#667788")
|
||||
|
||||
## The diegetic fetch readout lives in its own overlay node (added AFTER
|
||||
## `_canvas` so it paints above the map — see StepCanvasFetchOverlay's own
|
||||
## header for why a viewer-owned `_draw()` could never work).
|
||||
const StepCanvasFetchOverlay := preload(
|
||||
"res://ui/implant/apps/atlas/step_canvas/step_canvas_fetch_overlay.gd"
|
||||
)
|
||||
|
||||
## ONE settle delay for every "the view wants a different canvas than the one
|
||||
## we hold" trigger — window/implant resize AND pan-edge drift (pair session
|
||||
## 2026-07-26; Jeroen: "the debounce for the edge feels similar to the
|
||||
## mechanism we would need for edge scrolling. Roll it into the current
|
||||
## effort?"). Both are the same event: the user is still moving, so wait for
|
||||
## them to stop and issue ONE request for where they actually landed.
|
||||
## Long enough to collapse a drag, short enough to feel responsive.
|
||||
const REFETCH_SETTLE_S: float = 0.30
|
||||
|
||||
## Minimum gridunit difference between the held canvas and the viewport's own
|
||||
## fit before a resize earns a refetch. A canvas smaller than the viewport
|
||||
## letterboxes (visible empty margin); larger crops (harmless). Below this
|
||||
## threshold the mismatch is a couple of screen pixels — not worth a derive.
|
||||
const RESIZE_REFIT_MIN_CELL_DELTA: int = 4
|
||||
|
||||
## Pan drift thresholds, as a fraction of the held canvas's half-footprint.
|
||||
## SOFT: the canvas edge is nearing the viewport centre — schedule a settled
|
||||
## refetch, but keep panning the held canvas smoothly (no snap, no request
|
||||
## storm while the key/edge is held). HARD: the pan is about to run past the
|
||||
## canvas entirely, where waiting for a settle would show empty background —
|
||||
## re-float immediately and accept the recentre.
|
||||
const PAN_REFLOAT_SOFT_FRACTION: float = 0.5
|
||||
const PAN_REFLOAT_HARD_FRACTION: float = 0.9
|
||||
|
||||
const OVERLAY_DEFS: Array = [
|
||||
{
|
||||
@@ -151,6 +181,15 @@ var _legend_panel = null
|
||||
var _request = null # StepCanvasRequest
|
||||
var _disk_sweep_timer: Timer = null # T-1183 coarse background sweep trigger
|
||||
|
||||
var _fetch_overlay: Control = null # StepCanvasFetchOverlay — drawn above the map
|
||||
|
||||
## The shared settle timer (pair session 2026-07-26). One-shot, restarted by
|
||||
## every resize notification and every soft pan-edge crossing; on timeout
|
||||
## _on_refetch_settle() decides which refetch (if any) the view now needs.
|
||||
## Both triggers fire many times per second while the user is moving — this
|
||||
## collapses them into a single request at the resting state.
|
||||
var _refetch_settle_timer: Timer = null
|
||||
|
||||
## Test-injection-only (T-1193 first slice): threads into StepCanvasRequest's
|
||||
## disk_cache_root seam so tests can isolate the Tier-2/3 disk cache from the
|
||||
## machine-shared user://atlas_cache/ (one directory for every worktree gate,
|
||||
@@ -185,6 +224,24 @@ func _ready() -> void:
|
||||
_annotation_layer.name = "AnnotationLayer"
|
||||
_canvas.add_child(_annotation_layer)
|
||||
|
||||
# The fetch readout must paint ABOVE the terrain canvas. A Control's own
|
||||
# _draw() runs BEFORE its children, so anything this class drew directly
|
||||
# (the old DERIVING TERRAIN label, the pending wash) was painted underneath
|
||||
# `_canvas` and only ever visible when no texture existed at all — the
|
||||
# reason the old treatment read as "nothing is happening" during a slow
|
||||
# fetch (pair session 2026-07-26: Jeroen saw no panel). This dedicated
|
||||
# overlay is added AFTER _canvas, so it draws on top of the map.
|
||||
_fetch_overlay = StepCanvasFetchOverlay.new()
|
||||
_fetch_overlay.name = "FetchOverlay"
|
||||
add_child(_fetch_overlay)
|
||||
|
||||
_refetch_settle_timer = Timer.new()
|
||||
_refetch_settle_timer.name = "RefetchSettleTimer"
|
||||
_refetch_settle_timer.one_shot = true
|
||||
_refetch_settle_timer.wait_time = REFETCH_SETTLE_S
|
||||
_refetch_settle_timer.timeout.connect(_on_refetch_settle)
|
||||
add_child(_refetch_settle_timer)
|
||||
|
||||
_request = StepCanvasRequest.new(self, disk_cache_root_override)
|
||||
_request.name = "Request"
|
||||
add_child(_request)
|
||||
@@ -409,6 +466,75 @@ func _request_extent() -> Vector2i:
|
||||
return StepCanvasTransport.cap_extent_to_body(fit, _held_rung, _global_body_extent)
|
||||
|
||||
|
||||
## Resize refetch (pair session 2026-07-26, Jeroen: "the map dimension should
|
||||
## also respond to screen resizes, both of the game as well as diegetic
|
||||
## in-implant"). Debounce fired: if the viewport now wants a materially
|
||||
## different canvas than the one we hold, request it. The held canvas keeps
|
||||
## drawing until the new one lands (hold-and-swap), so a resize never blanks
|
||||
## the map — it just stops being letterboxed once the refit arrives.
|
||||
##
|
||||
## Global is deliberately EXEMPT: its extent is the body's own grid, chosen
|
||||
## server-side and ignored from the request, so a refetch would return the
|
||||
## identical canvas. Resize is already handled correctly for Global by
|
||||
## _recompute_canvas_transform()'s integer re-fit, which just ran.
|
||||
func _schedule_refetch_settle() -> void:
|
||||
if _refetch_settle_timer:
|
||||
_refetch_settle_timer.start() # restart: the user is still moving
|
||||
|
||||
|
||||
## Settle fired — the window stopped resizing and/or the pan stopped. Decide
|
||||
## what the view now needs, in priority order: a pan re-float (the canvas is
|
||||
## in the wrong PLACE) outranks a size refit (the canvas is the wrong SIZE),
|
||||
## because a re-float re-requests at the new centre anyway and that request
|
||||
## already carries the current viewport-fit extent.
|
||||
func _on_refetch_settle() -> void:
|
||||
if _held_rung == StepCanvasTransport.RUNG_GLOBAL:
|
||||
return
|
||||
if get_body_id().is_empty() or _held_extent == Vector2i.ZERO:
|
||||
return
|
||||
if _pan_drift_fraction() >= PAN_REFLOAT_SOFT_FRACTION:
|
||||
_refloat_now()
|
||||
return
|
||||
_maybe_refit_to_viewport()
|
||||
|
||||
|
||||
## How far the held canvas has been panned away from centre, as a fraction of
|
||||
## its own half-footprint (the larger axis). 0.0 = centred; 1.0 = the canvas
|
||||
## edge has reached the viewport centre. Shared by the soft/hard pan
|
||||
## thresholds so both read the same number.
|
||||
func _pan_drift_fraction() -> float:
|
||||
var footprint: Vector2 = _terrain_layer.get_footprint_px()
|
||||
if footprint == Vector2.ZERO:
|
||||
return 0.0
|
||||
var half: Vector2 = footprint * 0.5
|
||||
var screen_center: Vector2 = get_rect().size * 0.5
|
||||
var drift: Vector2 = (_view_offset + half) - screen_center
|
||||
var fx: float = absf(drift.x) / maxf(half.x, 0.0001)
|
||||
var fy: float = absf(drift.y) / maxf(half.y, 0.0001)
|
||||
return maxf(fx, fy)
|
||||
|
||||
|
||||
## Re-request this rung centred on whatever world point is under the viewport
|
||||
## centre right now. The held canvas keeps drawing until the new one lands;
|
||||
## _on_canvas_ready()'s own _recompute_canvas_transform() re-centres then.
|
||||
func _refloat_now() -> void:
|
||||
var screen_center: Vector2 = get_rect().size * 0.5
|
||||
_world_center = StepCanvasTransport.canvas_local_to_world_m(
|
||||
screen_center - _view_offset, _world_center, _held_rung, _held_extent
|
||||
)
|
||||
_view_offset = Vector2.ZERO
|
||||
_fire_request()
|
||||
|
||||
|
||||
func _maybe_refit_to_viewport() -> void:
|
||||
var wanted: Vector2i = _request_extent()
|
||||
var dx: int = absi(wanted.x - _held_extent.x)
|
||||
var dy: int = absi(wanted.y - _held_extent.y)
|
||||
if maxi(dx, dy) < RESIZE_REFIT_MIN_CELL_DELTA:
|
||||
return
|
||||
_fire_request()
|
||||
|
||||
|
||||
func _on_step_canvas_received(response: Dictionary) -> void:
|
||||
_request.on_response(response)
|
||||
|
||||
@@ -631,33 +757,35 @@ func _reset_to_global() -> void:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Pan-edge re-request: once the view has panned far enough that the held
|
||||
## canvas's own edge would show, re-request the SAME rung at a new center —
|
||||
## mirrors the retired viewer's own _maybe_refloat_window(), against the new
|
||||
## wire's request shape. Global never re-requests on pan (its canvas is the
|
||||
## whole body, D-255(a) — no edge to cross).
|
||||
## Pan-edge re-request, called every frame from _apply_pan_delta(). Two
|
||||
## thresholds (pair session 2026-07-26 — this used to fire the request the
|
||||
## instant the soft threshold was crossed, from inside the per-frame pan
|
||||
## loop, which issued a fresh request AND snapped the view on every crossing
|
||||
## of a sustained edge-scroll):
|
||||
##
|
||||
## - SOFT: schedule the shared settle timer and keep panning the held
|
||||
## canvas smoothly. Hold an edge-scroll for two seconds and exactly ONE
|
||||
## request goes out — for where the pan actually ended, not for each
|
||||
## waypoint it swept through.
|
||||
## - HARD: the canvas edge is about to enter view, where waiting for a
|
||||
## settle would show empty background — re-float immediately.
|
||||
##
|
||||
## Drifting back inside the soft threshold (panning back the way you came)
|
||||
## cancels the pending settle: the view no longer wants a different canvas.
|
||||
## Global never re-requests on pan (its canvas is the whole body, D-255(a) —
|
||||
## no edge to cross).
|
||||
func _maybe_refloat() -> void:
|
||||
if _held_rung == StepCanvasTransport.RUNG_GLOBAL:
|
||||
return
|
||||
var footprint: Vector2 = _terrain_layer.get_footprint_px()
|
||||
if footprint == Vector2.ZERO:
|
||||
return
|
||||
var half: Vector2 = footprint * 0.5
|
||||
# view_offset is the SCREEN position of canvas-local (0,0) — the canvas's
|
||||
# center in canvas-local space is `half`. Once panning has moved that
|
||||
# point more than half the footprint away from screen-center, the edge
|
||||
# is at or past the viewport's own center — time to re-float.
|
||||
var screen_center: Vector2 = get_rect().size * 0.5
|
||||
var canvas_center_screen: Vector2 = _view_offset + half
|
||||
var drift: Vector2 = canvas_center_screen - screen_center
|
||||
if absf(drift.x) < half.x * 0.5 and absf(drift.y) < half.y * 0.5:
|
||||
return
|
||||
var new_center_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m(
|
||||
screen_center - _view_offset, _world_center, _held_rung, _held_extent
|
||||
)
|
||||
_world_center = new_center_world
|
||||
_view_offset = Vector2.ZERO
|
||||
_fire_request()
|
||||
var fraction: float = _pan_drift_fraction()
|
||||
if fraction >= PAN_REFLOAT_HARD_FRACTION:
|
||||
if _refetch_settle_timer:
|
||||
_refetch_settle_timer.stop()
|
||||
_refloat_now()
|
||||
elif fraction >= PAN_REFLOAT_SOFT_FRACTION:
|
||||
_schedule_refetch_settle()
|
||||
elif _refetch_settle_timer and not _refetch_settle_timer.is_stopped():
|
||||
_refetch_settle_timer.stop()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -675,26 +803,12 @@ func _draw() -> void:
|
||||
|
||||
func _draw_border_fade() -> void:
|
||||
draw_rect(Rect2(_view_offset, get_rect().size), COLOR_BORDER_FADE)
|
||||
_draw_deriving_label()
|
||||
|
||||
|
||||
func _draw_pending_wash() -> void:
|
||||
draw_rect(Rect2(_view_offset, get_rect().size), COLOR_PENDING_WASH)
|
||||
|
||||
|
||||
func _draw_deriving_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 viewport: Vector2 = get_rect().size
|
||||
var center: Vector2 = viewport * 0.5
|
||||
var baseline: Vector2 = center - text_size * 0.5 + Vector2(0.0, text_size.y * 0.5)
|
||||
draw_string(
|
||||
font, baseline, label, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size, COLOR_DERIVING_LABEL
|
||||
)
|
||||
|
||||
|
||||
func _apply_transform() -> void:
|
||||
_canvas.position = _view_offset
|
||||
queue_redraw()
|
||||
@@ -837,6 +951,14 @@ func _notification(what: int) -> void:
|
||||
if _legend_panel:
|
||||
_legend_panel.reposition()
|
||||
_recompute_canvas_transform()
|
||||
# Re-center/re-fit the canvas we ALREADY hold (above) is only half the
|
||||
# job: a canvas derived for a smaller viewport stays that size forever
|
||||
# and letterboxes in the bigger window. Ask for one sized to the new
|
||||
# viewport, debounced so a drag issues a single request at the size the
|
||||
# window settles on. Fires for both resize sources — the OS window and
|
||||
# a diegetic in-implant resize — since either reaches this Control as
|
||||
# the same notification.
|
||||
_schedule_refetch_settle()
|
||||
elif what == NOTIFICATION_APPLICATION_FOCUS_OUT:
|
||||
_app_has_focus = false
|
||||
elif what == NOTIFICATION_APPLICATION_FOCUS_IN:
|
||||
@@ -881,6 +1003,7 @@ func _handle_key(event: InputEventKey) -> void:
|
||||
func _process(delta: float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
_tick_fetch_overlay(delta)
|
||||
var direction: Vector2 = StepCanvasTransport.held_pan_direction()
|
||||
if _is_cursor_edge_scrolling():
|
||||
direction += _edge_scroll_direction()
|
||||
@@ -889,6 +1012,18 @@ func _process(delta: float) -> void:
|
||||
_apply_pan_delta(direction, delta)
|
||||
|
||||
|
||||
## Feed the fetch overlay: the viewer owns the request (and therefore the
|
||||
## pending state), the overlay owns the rendering. Called every frame from
|
||||
## _process() BEFORE the pan early-return, so the readout keeps animating
|
||||
## while the player is not touching anything — which is exactly when a slow
|
||||
## fetch happens.
|
||||
func _tick_fetch_overlay(delta: float) -> void:
|
||||
if _fetch_overlay == null:
|
||||
return
|
||||
var pending: bool = _request != null and _request.is_pending()
|
||||
_fetch_overlay.tick(delta, pending)
|
||||
|
||||
|
||||
func _apply_pan_delta(direction: Vector2, delta: float) -> void:
|
||||
var normalized: Vector2 = direction.normalized()
|
||||
_view_offset -= normalized * PAN_SPEED_PX_S * delta
|
||||
|
||||
Reference in New Issue
Block a user