feat(ui): T-1145 round-2 polish — cover-fit, WASD+edge-scroll pan, smoothed composite (Jeroen rulings)
Cover-fit: fit_window_view zooms from the viewport's LARGER dimension, no margin factor (any factor under 1.0 leaves a long-axis gap — checked numerically) — the composite fills edge to edge, overhanging the short axis into pan-space; the refloat center-equality early-return already prevents refetch churn at rest (proved, not just tested). Input model (Jeroen: drag breaks click semantics with map objects): LMB-drag pan REMOVED from the regional window; clicks are object-reserved. Pan = held WASD/arrows polled in _process (delta- and zoom-scaled, camera-pans-toward-key convention verified numerically) plus edge-scroll within 24px of the viewport border; both suppressed over UI and on OS focus loss; both set _user_adjusted; wheel zoom and Esc unchanged. Reads RAW physical keycodes deliberately — independent of the shared D-054 move_* InputMap actions bound to the same keys (whose occlusion-leak is pre-existing and now ticketed as T-1146). Pole wall + east-west wrap unchanged, re-driven through the new inputs; drag tests replaced, not kept. Smoothed composite (interim pending T-1143): per-cell colors bake into an n x n Image/ImageTexture (exact existing colorizer incl. overlay + ice tint) drawn once with LINEAR filtering — GPU bilinear reads as terrain, the planetary heightmap's own treatment. Crisp per-cell path preserved behind COMPOSITE_SMOOTH for T-1143 A/B. Rebuild only on reference-identity change of window/toggle (is_same — verified true reference equality; value-equal distinct dicts DO rebuild). Governance: T-1145 amendment paragraph on D-226 T-1124 SS5 (all three supersessions); pql decisions validate ok. Suites: window_viewer 74/74, window_geometry 32/32, window_overlay (new) 16/16; gdlint clean on all six files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,29 +13,43 @@ extends RefCounted
|
||||
|
||||
|
||||
## Fit-and-center: given the viewport size and the window's side length in
|
||||
## districts, compute the zoom/offset that fills ~90% of the smaller viewport
|
||||
## dimension and centers the composite. Mirrors AtlasViewer's own
|
||||
## _fit_to_view() shape (fit-to-smaller-dimension, then center) but as a pure
|
||||
## function returning {zoom, offset} instead of writing _view_zoom/_view_offset
|
||||
## districts, compute the zoom/offset that COVERS the viewport (fills it edge
|
||||
## to edge, no side margins) and centers the composite. Mirrors AtlasViewer's
|
||||
## own _fit_to_view() shape (fit, then center) but as a pure function
|
||||
## returning {zoom, offset} instead of writing _view_zoom/_view_offset
|
||||
## directly, so AtlasWindowViewer.enter()/(_on_window_ready)/NOTIFICATION_RESIZED
|
||||
## can all call the SAME formula without three copies of the math drifting.
|
||||
##
|
||||
## zoom = clampf(0.9 * min(viewport.x, viewport.y) / (n * cell_px), MIN_ZOOM, MAX_ZOOM)
|
||||
## — the 0.9 factor leaves a visible margin around the composite (same
|
||||
## "don't touch the edges" instinct as AtlasViewer's own 0.92 fit factor,
|
||||
## slightly more generous here since the window composite has no header/
|
||||
## overlay-bar chrome competing for the same rect the way the planetary view
|
||||
## does). offset centers the (n * cell_px * zoom)-sized composite in the
|
||||
## viewport.
|
||||
## T-1145 item 1 (Jeroen's round-2 finding, KALLAST window): the ORIGINAL fit
|
||||
## was CONTAIN (zoom from the SMALLER viewport dimension, with a 0.9 margin
|
||||
## factor) — in a wide viewport this left large side margins around a square
|
||||
## composite (the window data is always n x n, a square, regardless of
|
||||
## viewport aspect). Changed to COVER: zoom from the LARGER viewport
|
||||
## dimension, with NO margin factor — a margin on the CONTAIN axis (the one
|
||||
## the zoom is computed from) is a deliberate breathing-room choice; the
|
||||
## exact same margin on the COVER axis would be a literal gap at the
|
||||
## viewport's own edge, which is precisely the "no side margins" defect this
|
||||
## fix removes. The composite therefore fills the screen edge to edge on its
|
||||
## long axis (scaled side == max(viewport.x, viewport.y) exactly) and
|
||||
## overhangs past both edges on its short axis (exactly the same "cover"
|
||||
## concept CSS background-size/object-fit use — fill the frame, crop what
|
||||
## doesn't fit, never letterbox). This is honest for a square dataset in a
|
||||
## non-square frame: at rest, the player sees a full-bleed slice of the
|
||||
## window, and panning (T-1145 item 2: WASD/edge-scroll) reveals the rest,
|
||||
## including triggering the existing pan-edge refetch (§4) exactly as
|
||||
## intended — cover does not change what "past the window edge" means, only
|
||||
## how much of the window is visible before the player pans at all.
|
||||
##
|
||||
## zoom = clampf(max(viewport.x, viewport.y) / (n * cell_px), MIN_ZOOM, MAX_ZOOM)
|
||||
## offset centers the (n * cell_px * zoom)-sized composite on the viewport,
|
||||
## exactly as the old contain fit did.
|
||||
static func fit_window_view(
|
||||
viewport: Vector2, n: int, cell_px: float, min_zoom: float, max_zoom: float
|
||||
) -> Dictionary:
|
||||
if n <= 0 or cell_px <= 0.0 or viewport.x <= 0.0 or viewport.y <= 0.0:
|
||||
return {"zoom": 1.0, "offset": Vector2.ZERO}
|
||||
var composite_native: float = float(n) * cell_px
|
||||
var zoom: float = clampf(
|
||||
0.9 * minf(viewport.x, viewport.y) / composite_native, min_zoom, max_zoom
|
||||
)
|
||||
var zoom: float = clampf(maxf(viewport.x, viewport.y) / composite_native, min_zoom, max_zoom)
|
||||
var composite_scaled: Vector2 = Vector2(composite_native, composite_native) * zoom
|
||||
var offset: Vector2 = (viewport - composite_scaled) * 0.5
|
||||
return {"zoom": zoom, "offset": offset}
|
||||
|
||||
@@ -20,10 +20,45 @@ extends Node2D
|
||||
## overlay draws nothing until the viewer has a window (border-fade during
|
||||
## the wait is the VIEWER's job, drawn separately underneath this node, not
|
||||
## here — this node is purely "draw the composite when there is one").
|
||||
##
|
||||
## T-1145 item 3 (interim presentation, pending the T-1143 design pass):
|
||||
## COMPOSITE_SMOOTH := true renders the composite as an n x n Image (one
|
||||
## pixel per district, EXACT same per-cell color pipeline this file always
|
||||
## had — _cell_color()/_apply_glaciation() are UNCHANGED) converted to an
|
||||
## ImageTexture and drawn scaled with LINEAR filtering, instead of n*n flat
|
||||
## draw_rect() calls. GPU bilinear sampling between adjacent district pixels
|
||||
## reads as a terrain gradient rather than hard-edged blocks — the same
|
||||
## treatment the planetary heightmap already gets (Godot's engine-default
|
||||
## CanvasItem.texture_filter is LINEAR_WITH_MIPMAPS project-wide, which is
|
||||
## what AtlasViewer's draw_texture_rect() calls already inherit for free;
|
||||
## this node sets texture_filter explicitly rather than relying on that
|
||||
## default, so the choice is visible in code, not implicit). The crisp
|
||||
## per-cell rect path SURVIVES behind the const (COMPOSITE_SMOOTH := false)
|
||||
## so T-1143's design pass can compare both renderings directly — this is
|
||||
## explicitly an INTERIM presentation, not the final answer on district-tier
|
||||
## legibility (T-1143 owns that design).
|
||||
##
|
||||
## The texture is REBUILT only when its inputs change (the window object
|
||||
## itself — a new DistrictWindowLayer arriving is a new Dictionary, checked
|
||||
## by REFERENCE via is_same(), not a per-field deep compare — or the active
|
||||
## toggle overlay id), not per frame/per redraw. Panning and zooming redraw
|
||||
## this node constantly (every _apply_transform() call) but never touch
|
||||
## window/overlay state, so the common case (panning within an already-held
|
||||
## window) is zero rebuild cost — draw_texture_rect() on an already-built
|
||||
## ImageTexture, same as any other texture draw.
|
||||
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
|
||||
|
||||
## T-1145 item 3: interim presentation toggle — true renders the smoothed
|
||||
## Image/ImageTexture composite; false keeps the original crisp per-cell
|
||||
## draw_rect() path (both call the SAME _cell_color()/_apply_glaciation()
|
||||
## pipeline, so switching this never changes WHAT color a cell reads, only
|
||||
## HOW it's rendered). Left as a compile-time const, not a runtime toggle —
|
||||
## T-1143's design pass is expected to pick a winner, not ship a player-
|
||||
## facing switch between them.
|
||||
const COMPOSITE_SMOOTH: bool = true
|
||||
|
||||
## Moisture ramp reuses SUB_BIOME_COLORS' dry-sand->wet-teal ENDPOINTS (§5) —
|
||||
## not the categorical lookup itself (that's keyed by sub-biome NAME, not a
|
||||
## 0-100 quantity). Endpoints pulled from the existing dry/wet entries in that
|
||||
@@ -33,6 +68,16 @@ const COLOR_MOISTURE_WET: Color = Color(0.25, 0.72, 0.65, 1.0) # teal — match
|
||||
|
||||
var viewer = null # AtlasWindowViewer (untyped to avoid cyclic ref)
|
||||
|
||||
## T-1145 item 3: texture rebuild cache — see the class doc's "REBUILT only
|
||||
## when its inputs change" paragraph. _cache_window_ref is compared by
|
||||
## REFERENCE (is_same()), not value — a fresh DistrictWindowLayer response is
|
||||
## always a NEW Dictionary object (built by atlas_map_protocol.gd's decode),
|
||||
## so reference identity is both correct AND far cheaper than a deep compare
|
||||
## of a potentially-4096-cell dictionary on every _draw() call.
|
||||
var _cached_texture: ImageTexture = null
|
||||
var _cache_window_ref: Variant = null
|
||||
var _cache_active_toggle: String = ""
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if viewer == null:
|
||||
@@ -46,18 +91,83 @@ func _draw() -> void:
|
||||
return
|
||||
|
||||
var morphology: Variant = w.get("morphology")
|
||||
var elev_q: Variant = w.get("elev_q")
|
||||
if not (morphology is PackedByteArray or morphology is Array):
|
||||
return
|
||||
|
||||
var cell_px: float = viewer.get_cell_pixel_size()
|
||||
var active_toggle: String = _active_toggle_overlay()
|
||||
|
||||
if COMPOSITE_SMOOTH:
|
||||
_draw_smoothed_composite(w, n, cell_px, active_toggle)
|
||||
else:
|
||||
_draw_crisp_composite(w, n, cell_px, active_toggle)
|
||||
|
||||
|
||||
## T-1145 item 3: the smoothed path — build/reuse an n x n ImageTexture (one
|
||||
## pixel per district) and draw it scaled to (n*cell_px) with LINEAR
|
||||
## filtering. texture_filter is set on `self` (a CanvasItem property) once
|
||||
## per draw — cheap (a property write, not a texture rebuild) and correct
|
||||
## even the first time this runs (Godot's engine default already IS linear,
|
||||
## but this makes the choice explicit rather than relying on an implicit
|
||||
## project-wide default that could change).
|
||||
func _draw_smoothed_composite(w: Dictionary, n: int, cell_px: float, active_toggle: String) -> void:
|
||||
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
_rebuild_texture_if_needed(w, n, active_toggle)
|
||||
if _cached_texture == null:
|
||||
return
|
||||
var extent: float = float(n) * cell_px
|
||||
draw_texture_rect(_cached_texture, Rect2(0.0, 0.0, extent, extent), false)
|
||||
|
||||
|
||||
## Rebuilds _cached_texture from `w`'s per-cell colors ONLY when the window
|
||||
## object or the active toggle overlay has changed since the last build —
|
||||
## see the class doc's rebuild-cost paragraph. `elev_q` and `glaciation` are
|
||||
## read directly from `w` here (rather than threaded through as params, the
|
||||
## way the crisp path's _cell_color()/_apply_glaciation() calls already
|
||||
## receive them) since this function owns the whole per-cell loop, not just
|
||||
## one cell.
|
||||
func _rebuild_texture_if_needed(w: Dictionary, n: int, active_toggle: String) -> void:
|
||||
if (
|
||||
is_same(_cache_window_ref, w)
|
||||
and _cache_active_toggle == active_toggle
|
||||
and _cached_texture != null
|
||||
):
|
||||
return # inputs unchanged since the last build — reuse the existing texture
|
||||
|
||||
var elev_q: Variant = w.get("elev_q")
|
||||
var glaciation: Variant = w.get("glaciation")
|
||||
var morphology: Variant = w.get("morphology")
|
||||
var n_cells: int = morphology.size()
|
||||
|
||||
var img := Image.create(n, n, false, Image.FORMAT_RGBA8)
|
||||
for row in range(n):
|
||||
for col in range(n):
|
||||
var i: int = row * n + col
|
||||
if i >= n_cells:
|
||||
img.set_pixel(col, row, Color.TRANSPARENT)
|
||||
continue
|
||||
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
|
||||
cell_color = _apply_glaciation(cell_color, glaciation, i)
|
||||
img.set_pixel(col, row, cell_color)
|
||||
|
||||
_cached_texture = ImageTexture.create_from_image(img)
|
||||
_cache_window_ref = w
|
||||
_cache_active_toggle = active_toggle
|
||||
|
||||
|
||||
## The ORIGINAL crisp per-cell path — kept byte-for-byte behind
|
||||
## COMPOSITE_SMOOTH := false so T-1143's design pass can compare both
|
||||
## renderings directly (see the class doc).
|
||||
func _draw_crisp_composite(w: Dictionary, n: int, cell_px: float, active_toggle: String) -> void:
|
||||
var elev_q: Variant = w.get("elev_q")
|
||||
var glaciation: Variant = w.get("glaciation")
|
||||
var morphology: Variant = w.get("morphology")
|
||||
var n_cells: int = morphology.size()
|
||||
|
||||
for row in range(n):
|
||||
for col in range(n):
|
||||
var i: int = row * n + col
|
||||
if i >= morphology.size():
|
||||
if i >= n_cells:
|
||||
continue
|
||||
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
|
||||
if cell_color.a <= 0.0:
|
||||
|
||||
@@ -21,10 +21,15 @@ extends Control
|
||||
## retry — this Control decides WHEN to call it (pan-edge detection,
|
||||
## entry), never talks to SimBridge directly itself.
|
||||
##
|
||||
## Navigation:
|
||||
## Mouse drag pan within/across the window
|
||||
## Mouse wheel zoom the held composite (client-side only, never refetches)
|
||||
## Esc back to the planetary view
|
||||
## Navigation (T-1145 item 2 — Jeroen's input-model ruling: LMB-drag panning
|
||||
## BREAKS click semantics with map objects, so it is removed entirely; clicks
|
||||
## are reserved for map objects, which will land in this window later, e.g.
|
||||
## settlements):
|
||||
## WASD / arrow keys continuous pan, held (frame-rate independent, _process)
|
||||
## Edge scrolling cursor within EDGE_SCROLL_MARGIN_PX of a viewport
|
||||
## edge pans toward it (suppressed over UI / unfocused)
|
||||
## Mouse wheel zoom the held composite (client-side only, never refetches)
|
||||
## Esc back to the planetary view
|
||||
|
||||
signal back_pressed
|
||||
|
||||
@@ -35,6 +40,24 @@ const MIN_ZOOM: float = 0.5
|
||||
const MAX_ZOOM: float = 8.0
|
||||
const ZOOM_STEP: float = 1.15
|
||||
|
||||
## T-1145 item 2: WASD/arrow-key continuous pan speed, in CANVAS px/s at
|
||||
## zoom=1.0 — the ACTUAL screen-space pan rate is this value times the
|
||||
## CURRENT _view_zoom (see _process()'s pan tick), so panning covers the
|
||||
## same amount of TERRAIN per second regardless of zoom level. A fixed
|
||||
## SCREEN-px/s rate (no zoom scaling) would feel painfully slow zoomed in
|
||||
## (each screen pixel is a fraction of a district) and uncontrollably fast
|
||||
## zoomed out — scaling by zoom keeps the "how much world passes per
|
||||
## second" feel constant, matching the ticket's "speed in screen px/s
|
||||
## scaled by zoom" wording. ~6 districts/s at zoom=1.0 (96/16) — brisk
|
||||
## enough to cross a default n=32 window in ~5s, not a crawl.
|
||||
const PAN_SPEED_CANVAS_PX_S: float = 96.0
|
||||
|
||||
## T-1145 item 2: cursor-to-edge distance (px) that triggers edge-scroll —
|
||||
## Jeroen's own number ("~24px").
|
||||
const EDGE_SCROLL_MARGIN_PX: float = 24.0
|
||||
## Edge-scroll uses the SAME speed as WASD (one pan feel, two triggers) —
|
||||
## no separate constant, _process() reads PAN_SPEED_CANVAS_PX_S for both.
|
||||
|
||||
## Pixel size of one district cell at zoom=1.0 — a fixed on-screen scale
|
||||
## (unlike AtlasViewer's heightmap, there is no source texture dictating a
|
||||
## native pixel size; this constant IS the native size). 16px/cell at n=64
|
||||
@@ -97,15 +120,27 @@ var _window: Variant = null # current DistrictWindowLayer Dictionary, or null w
|
||||
var _held_center: Vector2i = Vector2i.ZERO
|
||||
var _held_n: int = 32
|
||||
|
||||
# ── Pan/zoom state (mirrors AtlasViewer's own fields exactly) ────────────
|
||||
# ── Pan/zoom state ─────────────────────────────────────────────────────────
|
||||
var _view_offset: Vector2 = Vector2.ZERO
|
||||
var _view_zoom: float = 1.0
|
||||
var _dragging: bool = false
|
||||
var _drag_start_mouse: Vector2
|
||||
var _drag_start_offset: Vector2
|
||||
# T-1142: true once the user has manually dragged/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
|
||||
# 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.
|
||||
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).
|
||||
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).
|
||||
var _user_adjusted: bool = false
|
||||
# T-1142: true from enter() until the FIRST _on_window_ready() fires (the
|
||||
@@ -354,10 +389,11 @@ func set_view(zoom: float, offset: Vector2) -> void:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## After a drag delta, check whether the screen-center now maps to a
|
||||
## DistrictPos outside the held window's extent — if so, float a NEW window
|
||||
## centered on that point (§5 "windows float on the pan center... not
|
||||
## grid-snapped") via the debounced request path.
|
||||
## After a pan delta (T-1145: WASD/edge-scroll, called from _process()'s pan
|
||||
## tick every frame the player is actively panning), check whether the
|
||||
## screen-center now maps to a DistrictPos outside the held window's extent
|
||||
## — if so, float a NEW window centered on that point (§5 "windows float on
|
||||
## the pan center... not grid-snapped") via the debounced request path.
|
||||
##
|
||||
## T-1142 (item 6a): the edge-crossing decision below is computed in RAW
|
||||
## absolute district space (un-wrapped, un-clamped) — that is the correct
|
||||
@@ -490,6 +526,17 @@ func _is_over_ui(_pos: Vector2) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
## T-1145 item 2: LMB-drag panning is GONE (Jeroen's ruling — drag broke click
|
||||
## semantics with map objects; clicks are reserved for future map objects,
|
||||
## e.g. settlements). What remains: wheel zoom (unchanged) and tracking the
|
||||
## local mouse position for edge-scroll (_process() reads _last_mouse_pos —
|
||||
## it has no InputEvent of its own to read a live position from). WASD/arrow
|
||||
## panning does NOT go through _gui_input at all — it is a HELD-key,
|
||||
## continuous, frame-rate-independent pan polled every frame in _process()
|
||||
## via Input.is_action_pressed()-equivalent raw key checks (Input.is_key_pressed(),
|
||||
## since WASD has no project-level Input Map action of its own in this
|
||||
## screen's remit — see _process()'s own doc for why raw physical-keycode
|
||||
## polling is deliberate here, not a new InputMap action).
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.is_echo():
|
||||
_handle_key(event as InputEventKey)
|
||||
@@ -509,24 +556,8 @@ func _gui_input(event: InputEvent) -> void:
|
||||
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
|
||||
_user_adjusted = true
|
||||
_zoom_at(mb.position, 1.0 / ZOOM_STEP)
|
||||
elif mb.button_index == MOUSE_BUTTON_LEFT:
|
||||
if mb.pressed:
|
||||
_dragging = true
|
||||
_drag_start_mouse = mb.position
|
||||
_drag_start_offset = _view_offset
|
||||
else:
|
||||
_dragging = false
|
||||
elif event is InputEventMouseMotion:
|
||||
var mm := event as InputEventMouseMotion
|
||||
if _dragging:
|
||||
_user_adjusted = true
|
||||
var dragged_offset: Vector2 = _drag_start_offset + (mm.position - _drag_start_mouse)
|
||||
# T-1142 pole-wall: clamp Y only (item 5) — the window edge, not
|
||||
# merely its center, must never cross ±rows_half. X is untouched
|
||||
# (item 6: east-west circumnavigation is seamless, no wall).
|
||||
_view_offset = _clamp_offset_to_pole_wall(dragged_offset)
|
||||
_apply_transform()
|
||||
_maybe_refloat_window()
|
||||
_last_mouse_pos = (event as InputEventMouseMotion).position
|
||||
|
||||
|
||||
func _handle_key(event: InputEventKey) -> void:
|
||||
@@ -534,6 +565,129 @@ func _handle_key(event: InputEventKey) -> void:
|
||||
back_pressed.emit()
|
||||
|
||||
|
||||
## T-1145 item 2: continuous WASD/arrow-key pan + edge-scroll, both applied
|
||||
## here (not _gui_input) because both are HELD-state effects (keys held down,
|
||||
## cursor lingering near an edge), not discrete input events — _process()
|
||||
## polls held state every frame and hands the resulting direction + this
|
||||
## frame's delta to _apply_pan_delta() (split out for testability — a gdUnit
|
||||
## test drives _apply_pan_delta(direction, delta) directly with a
|
||||
## deterministic direction/delta instead of needing to fake Godot's global
|
||||
## Input singleton reporting a key held, which is what testing THIS
|
||||
## function's own Input.is_key_pressed() polling would require). Skips
|
||||
## entirely while this Control is hidden (the screen is not the active
|
||||
## nav-stack entry) — no wasted per-frame work for an invisible viewer, and
|
||||
## no phantom panning if some other code path leaves this node in the tree
|
||||
## but not shown.
|
||||
func _process(delta: float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
var direction: Vector2 = _held_pan_direction()
|
||||
if _is_cursor_edge_scrolling():
|
||||
direction += _edge_scroll_direction()
|
||||
if direction == Vector2.ZERO:
|
||||
return
|
||||
_apply_pan_delta(direction, delta)
|
||||
|
||||
|
||||
## The actual pan-tick state mutation, given an ALREADY-DECIDED (but not yet
|
||||
## normalized) direction and this frame's delta — frame-rate independent
|
||||
## (motion scales by `delta`, so the same speed at 30fps or 144fps), zoom-
|
||||
## scaled (PAN_SPEED_CANVAS_PX_S * _view_zoom — see that constant's own doc
|
||||
## for why), and pole-wall clamped (T-1142, unchanged mechanism, just fed by
|
||||
## a different input source now). Sets _user_adjusted (T-1145: "WASD/edge/
|
||||
## zoom all set _user_adjusted") and triggers the SAME pan-edge refetch check
|
||||
## (§4) drag used to. Split from _process() specifically so a test can call
|
||||
## this directly with a synthetic direction/delta — see _process()'s own doc.
|
||||
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
|
||||
var delta_offset: Vector2 = -normalized * PAN_SPEED_CANVAS_PX_S * _view_zoom * delta
|
||||
_view_offset = _clamp_offset_to_pole_wall(_view_offset + delta_offset)
|
||||
_apply_transform()
|
||||
_maybe_refloat_window()
|
||||
|
||||
|
||||
## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode
|
||||
## (not an InputMap action): W/S/A/D on this project's global InputMap are
|
||||
## already bound to move_north/move_south/move_east/move_west (gameplay
|
||||
## movement, D-054 mouse-relative facing) — reusing those actions here would
|
||||
## make holding W simultaneously pan this map AND queue a gameplay move
|
||||
## command server-side the moment this implant screen closes back to
|
||||
## gameplay (InputMapper polls Input.is_action_pressed() unconditionally,
|
||||
## with no implant-occlusion guard — confirmed by reading input_mapper.gd
|
||||
## directly, a genuine pre-existing gap outside this ticket's scope, not
|
||||
## introduced here). Reading the raw physical keycode instead of the shared
|
||||
## action name means this screen's WASD use is fully independent of
|
||||
## whatever the gameplay action happens to be bound to — same key, two
|
||||
## UNRELATED consumers, neither needs to know about the other. Arrow keys
|
||||
## have no InputMap action bound at all (confirmed by grep across
|
||||
## project.godot's [input] section), so they're conflict-free either way.
|
||||
## Returns a raw (non-normalized) direction — the caller normalizes once
|
||||
## after adding the edge-scroll contribution, so N+E doesn't move faster
|
||||
## than N alone.
|
||||
func _held_pan_direction() -> Vector2:
|
||||
var direction := Vector2.ZERO
|
||||
if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP):
|
||||
direction.y -= 1.0
|
||||
if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN):
|
||||
direction.y += 1.0
|
||||
if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT):
|
||||
direction.x -= 1.0
|
||||
if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT):
|
||||
direction.x += 1.0
|
||||
return direction
|
||||
|
||||
|
||||
## T-1145 item 2: edge-scroll is suppressed (a) while the cursor is over UI
|
||||
## (_is_over_ui() — the SAME helper the click-era _gui_input guard used, per
|
||||
## the ticket's explicit "reuse _is_over_ui" instruction) and (b) while the
|
||||
## application window itself lacks OS focus (_app_has_focus — otherwise a
|
||||
## background window with the cursor left resting near its edge from a
|
||||
## previous session would silently pan while the player is doing something
|
||||
## else entirely; "if detectable" per the ticket, and Godot's
|
||||
## NOTIFICATION_APPLICATION_FOCUS_OUT/IN make it directly detectable, see
|
||||
## _notification()).
|
||||
func _is_cursor_edge_scrolling() -> bool:
|
||||
if not _app_has_focus:
|
||||
return false
|
||||
if _is_over_ui(_last_mouse_pos):
|
||||
return false
|
||||
var sz: Vector2 = size
|
||||
if sz.x <= 0.0 or sz.y <= 0.0:
|
||||
return false
|
||||
var pos: Vector2 = _last_mouse_pos
|
||||
return (
|
||||
pos.x >= 0.0
|
||||
and pos.y >= 0.0
|
||||
and pos.x <= sz.x
|
||||
and pos.y <= sz.y
|
||||
and (
|
||||
pos.x < EDGE_SCROLL_MARGIN_PX
|
||||
or pos.y < EDGE_SCROLL_MARGIN_PX
|
||||
or pos.x > sz.x - EDGE_SCROLL_MARGIN_PX
|
||||
or pos.y > sz.y - EDGE_SCROLL_MARGIN_PX
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
## Direction toward whichever edge(s) the cursor is near — same shape as
|
||||
## _held_pan_direction() (a raw, un-normalized Vector2 the caller combines
|
||||
## and normalizes once).
|
||||
func _edge_scroll_direction() -> Vector2:
|
||||
var sz: Vector2 = size
|
||||
var pos: Vector2 = _last_mouse_pos
|
||||
var direction := Vector2.ZERO
|
||||
if pos.x < EDGE_SCROLL_MARGIN_PX:
|
||||
direction.x -= 1.0
|
||||
elif pos.x > sz.x - EDGE_SCROLL_MARGIN_PX:
|
||||
direction.x += 1.0
|
||||
if pos.y < EDGE_SCROLL_MARGIN_PX:
|
||||
direction.y -= 1.0
|
||||
elif pos.y > sz.y - EDGE_SCROLL_MARGIN_PX:
|
||||
direction.y += 1.0
|
||||
return direction
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Overlay bar / legend (reuses atlas_overlay_bar.gd/atlas_legend_panel.gd —
|
||||
# both call only get_overlay_defs()/is_overlay_visible()/set_overlay_visible(),
|
||||
@@ -581,6 +735,10 @@ func _notification(what: int) -> void:
|
||||
# is constructed — confirmed the hard way (gdUnit add_child() crash).
|
||||
if _canvas and not _user_adjusted:
|
||||
_fit_and_center()
|
||||
elif what == NOTIFICATION_APPLICATION_FOCUS_OUT:
|
||||
_app_has_focus = false
|
||||
elif what == NOTIFICATION_APPLICATION_FOCUS_IN:
|
||||
_app_has_focus = true
|
||||
|
||||
|
||||
## Safely extract a string field from a dict, falling back when missing or
|
||||
|
||||
Reference in New Issue
Block a user