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:
@@ -19,19 +19,22 @@ const CELL_PIXEL_SIZE: float = 16.0
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## n=32, cell_px=16 -> native composite is 512x512. A 1920x1080 viewport's
|
||||
## smaller dimension is 1080, so zoom = 0.9 * 1080 / 512 ~= 1.898 — well
|
||||
## inside [MIN_ZOOM, MAX_ZOOM], so the clamp is a no-op here.
|
||||
## n=32, cell_px=16 -> native composite is 512x512. T-1145 item 1: COVER
|
||||
## fit derives zoom from the LARGER viewport dimension (1920, not 1080) with
|
||||
## NO margin factor — zoom = 1920 / 512 = 3.75 — well inside [MIN_ZOOM,
|
||||
## MAX_ZOOM], so the clamp is a no-op here.
|
||||
func test_fit_window_view_computes_expected_zoom_for_a_wide_viewport() -> void:
|
||||
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
|
||||
Vector2(1920.0, 1080.0), 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM
|
||||
)
|
||||
var expected_zoom: float = 0.9 * 1080.0 / 512.0
|
||||
var expected_zoom: float = 1920.0 / 512.0
|
||||
assert_float(fit["zoom"]).is_equal_approx(expected_zoom, 0.001)
|
||||
|
||||
|
||||
## The composite must be CENTERED — offset.x/.y each leave an equal margin on
|
||||
## both sides of the (n*cell_px*zoom)-sized composite.
|
||||
## both sides of the (n*cell_px*zoom)-sized composite (a NEGATIVE "margin" is
|
||||
## fine and expected under cover — it just means the composite overhangs
|
||||
## that axis, checked separately by test_fit_window_view_covers_with_no_gap).
|
||||
func test_fit_window_view_centers_the_composite() -> void:
|
||||
var viewport := Vector2(1920.0, 1080.0)
|
||||
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
|
||||
@@ -47,10 +50,65 @@ func test_fit_window_view_centers_the_composite() -> void:
|
||||
assert_float(bottom_margin).is_equal_approx(offset.y, 0.01)
|
||||
|
||||
|
||||
## T-1145 item 1 (Jeroen's round-2 finding, KALLAST window): a wide viewport
|
||||
## must show NO side margins — the composite's LONG axis (the one the cover
|
||||
## zoom is derived from) must land EXACTLY at the viewport edges (offset ~=
|
||||
## 0 on that axis), and the SHORT axis must OVERHANG past both edges
|
||||
## (negative margin — the composite is bigger than the viewport there,
|
||||
## exactly what "cover" means). This is the literal assertion the coordinator
|
||||
## asked for: no side margins at 16:9.
|
||||
func test_fit_window_view_covers_with_no_gap_on_the_long_axis() -> void:
|
||||
var viewport := Vector2(1920.0, 1080.0)
|
||||
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
|
||||
viewport, 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM
|
||||
)
|
||||
var composite_scaled: float = 32.0 * CELL_PIXEL_SIZE * float(fit["zoom"])
|
||||
var offset: Vector2 = fit["offset"]
|
||||
# Long axis (X, 1920 > 1080): the composite must span EXACTLY the
|
||||
# viewport width — zero margin on both sides.
|
||||
assert_float(offset.x).override_failure_message(
|
||||
"the long (cover) axis must have NO side margin — offset.x should be ~0"
|
||||
).is_equal_approx(0.0, 0.5)
|
||||
var right_margin: float = viewport.x - (offset.x + composite_scaled)
|
||||
assert_float(right_margin).override_failure_message(
|
||||
"the long (cover) axis's far edge must have NO margin either"
|
||||
).is_equal_approx(0.0, 0.5)
|
||||
# Short axis (Y, 1080 < 1920): the composite must OVERHANG (negative
|
||||
# margin) past BOTH edges — this is the data that extends into pan-space.
|
||||
assert_float(offset.y).override_failure_message(
|
||||
"the short axis must OVERHANG past the top edge (negative offset)"
|
||||
).is_less(0.0)
|
||||
|
||||
|
||||
## A TALL viewport (portrait) must cover the same way, just with the axes
|
||||
## swapped — long axis (Y) gets zero margin, short axis (X) overhangs.
|
||||
func test_fit_window_view_covers_a_tall_viewport_too() -> void:
|
||||
var viewport := Vector2(1080.0, 1920.0)
|
||||
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
|
||||
viewport, 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM
|
||||
)
|
||||
var offset: Vector2 = fit["offset"]
|
||||
assert_float(offset.y).override_failure_message(
|
||||
"the long (cover) axis (Y, portrait) must have NO side margin"
|
||||
).is_equal_approx(0.0, 0.5)
|
||||
assert_float(offset.x).override_failure_message(
|
||||
"the short axis (X, portrait) must overhang past the left edge"
|
||||
).is_less(0.0)
|
||||
|
||||
|
||||
## A perfectly square viewport needs NO overhang on either axis — cover and
|
||||
## contain agree exactly at a 1:1 aspect ratio (the degenerate case where
|
||||
## "long" and "short" axis are the same).
|
||||
func test_fit_window_view_square_viewport_has_no_overhang_either_axis() -> void:
|
||||
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
|
||||
Vector2(1024.0, 1024.0), 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM
|
||||
)
|
||||
assert_vector(fit["offset"]).is_equal_approx(Vector2.ZERO, Vector2(0.5, 0.5))
|
||||
|
||||
|
||||
## Jeroen's exact bug: an n=32 composite (512px native) in a real ~1920px
|
||||
## viewport must NOT render at zoom=1.0 (the old, unfitted "postage stamp"
|
||||
## behavior) — the fit must scale it up to fill most of the smaller
|
||||
## viewport dimension.
|
||||
## behavior) — the fit must scale it up to fill (now: COVER) the viewport.
|
||||
func test_fit_window_view_scales_up_a_small_composite_to_fill_the_viewport() -> void:
|
||||
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
|
||||
Vector2(1920.0, 1080.0), 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
## T-1145 item 3 (interim presentation, pending the T-1143 design pass):
|
||||
## tests for AtlasWindowOverlay's smoothed-composite texture rebuild cache —
|
||||
## the "rebuild ONLY when window/overlay/tint inputs change, not per frame"
|
||||
## requirement. Does not test the actual PIXEL CONTENT of the built texture
|
||||
## (that content is exactly _cell_color()/_apply_glaciation(), already
|
||||
## covered by test_atlas_window_colors.gd's colorizer tests — this file is
|
||||
## about WHEN a rebuild happens, not what color a given cell produces).
|
||||
class_name TestAtlasWindowOverlay
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
static func _mock_window(n: int = 2) -> Dictionary:
|
||||
return {
|
||||
"center": [0, 0],
|
||||
"n": n,
|
||||
"morphology": PackedByteArray([8, 14, 0, 1]),
|
||||
"elev_q": PackedByteArray([40, 90, 5, 60]),
|
||||
"temp_dc": [120, 95, -32768, 60],
|
||||
"moisture_q": PackedByteArray([50, 30, 90, 20]),
|
||||
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
||||
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
||||
}
|
||||
|
||||
|
||||
## Minimal viewer stub — AtlasWindowOverlay only reaches the viewer through
|
||||
## get_district_window()/is_overlay_visible()/get_cell_pixel_size(), so a
|
||||
## bare stub with just those three methods is a legitimate "viewer" for
|
||||
## these tests, matching the duck-typed-viewer precedent this whole overlay
|
||||
## cluster already relies on (atlas_overlay_bar.gd/atlas_legend_panel.gd).
|
||||
class _ViewerStub:
|
||||
var window: Variant = null
|
||||
var active_overlay: String = ""
|
||||
|
||||
func get_district_window() -> Variant:
|
||||
return window
|
||||
|
||||
func is_overlay_visible(overlay_id: String) -> bool:
|
||||
return overlay_id == active_overlay
|
||||
|
||||
func get_cell_pixel_size() -> float:
|
||||
return 16.0
|
||||
|
||||
|
||||
func test_composite_smooth_defaults_true() -> void:
|
||||
assert_bool(AtlasWindowOverlay.COMPOSITE_SMOOTH).override_failure_message(
|
||||
"T-1145 item 3 ships the smoothed composite as the DEFAULT presentation"
|
||||
).is_true()
|
||||
|
||||
|
||||
## A fresh overlay with no draw yet has never built a texture — the cache
|
||||
## starts empty.
|
||||
func test_no_texture_before_first_draw() -> void:
|
||||
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
|
||||
assert_that(o._cached_texture).is_null()
|
||||
|
||||
|
||||
## First _rebuild_texture_if_needed() call for a real window builds a texture.
|
||||
func test_rebuild_builds_a_texture_on_first_call() -> void:
|
||||
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
|
||||
var window: Dictionary = _mock_window()
|
||||
o._rebuild_texture_if_needed(window, 2, "")
|
||||
assert_that(o._cached_texture).override_failure_message(
|
||||
"the first rebuild call for a real window must produce a texture"
|
||||
).is_not_null()
|
||||
|
||||
|
||||
## Calling _rebuild_texture_if_needed() AGAIN with the SAME window object
|
||||
## (same reference) and the same active toggle must NOT rebuild — the exact
|
||||
## same ImageTexture instance survives (reference equality, not just
|
||||
## "another texture that happens to look the same").
|
||||
func test_rebuild_is_a_noop_when_window_and_toggle_are_unchanged() -> void:
|
||||
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
|
||||
var window: Dictionary = _mock_window()
|
||||
o._rebuild_texture_if_needed(window, 2, "")
|
||||
var first_texture: ImageTexture = o._cached_texture
|
||||
|
||||
o._rebuild_texture_if_needed(window, 2, "")
|
||||
|
||||
assert_bool(is_same(o._cached_texture, first_texture)).override_failure_message(
|
||||
"an unchanged (window, active_toggle) pair must reuse the SAME texture"
|
||||
+ " object, not rebuild an equivalent-but-new one"
|
||||
).is_true()
|
||||
|
||||
|
||||
## A DIFFERENT window object (even with identical field VALUES) — matching
|
||||
## what a fresh server response always is, a new Dictionary — MUST trigger a
|
||||
## rebuild. This is the reference-vs-value distinction the class doc calls
|
||||
## out explicitly (is_same(), not a deep compare).
|
||||
func test_rebuild_fires_for_a_different_window_object_with_same_values() -> void:
|
||||
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
|
||||
var window_a: Dictionary = _mock_window()
|
||||
o._rebuild_texture_if_needed(window_a, 2, "")
|
||||
var first_texture: ImageTexture = o._cached_texture
|
||||
|
||||
# A structurally-IDENTICAL but DISTINCT Dictionary object — the exact
|
||||
# shape a second server response for the same window content would be.
|
||||
var window_b: Dictionary = _mock_window()
|
||||
o._rebuild_texture_if_needed(window_b, 2, "")
|
||||
|
||||
assert_bool(is_same(o._cached_texture, first_texture)).override_failure_message(
|
||||
"a new window object (even with identical field values) must trigger a"
|
||||
+ " fresh rebuild — the cache key is REFERENCE identity, not value equality"
|
||||
).is_false()
|
||||
|
||||
|
||||
## Changing the active toggle overlay (same window object) must ALSO trigger
|
||||
## a rebuild — temp/moisture/veg/base each read different colors per cell.
|
||||
func test_rebuild_fires_when_active_toggle_changes() -> void:
|
||||
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
|
||||
var window: Dictionary = _mock_window()
|
||||
o._rebuild_texture_if_needed(window, 2, "")
|
||||
var first_texture: ImageTexture = o._cached_texture
|
||||
|
||||
o._rebuild_texture_if_needed(window, 2, "gen_dw_temp")
|
||||
|
||||
assert_bool(is_same(o._cached_texture, first_texture)).override_failure_message(
|
||||
"switching the active toggle overlay must trigger a rebuild — the SAME"
|
||||
+ " window's cells read different colors under a different toggle"
|
||||
).is_false()
|
||||
|
||||
|
||||
## Panning/zooming (which redraw this node constantly via _apply_transform())
|
||||
## never touches window/overlay state — repeated rebuild CALLS with identical
|
||||
## inputs (simulating many redraws while nothing about the DATA changed) must
|
||||
## all be no-ops after the first, confirming the "not per frame" requirement
|
||||
## end to end, not just for a single repeat.
|
||||
func test_repeated_rebuild_calls_with_unchanged_inputs_all_reuse_the_same_texture() -> void:
|
||||
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
|
||||
var window: Dictionary = _mock_window()
|
||||
o._rebuild_texture_if_needed(window, 2, "")
|
||||
var first_texture: ImageTexture = o._cached_texture
|
||||
|
||||
for _i in range(20): # 20 simulated redraws (pan/zoom frames)
|
||||
o._rebuild_texture_if_needed(window, 2, "")
|
||||
|
||||
assert_bool(is_same(o._cached_texture, first_texture)).override_failure_message(
|
||||
"20 repeated rebuild calls with unchanged inputs must never touch the cache"
|
||||
).is_true()
|
||||
|
||||
|
||||
## The overlay's real _draw() entry point (via the smoothed path) produces a
|
||||
## texture through the SAME _ViewerStub duck-typed interface every other
|
||||
## caller in this cluster uses — an end-to-end sanity check that _draw()
|
||||
## actually reaches _rebuild_texture_if_needed() for a real window, not just
|
||||
## that the helper works in isolation.
|
||||
func test_draw_builds_a_texture_through_the_viewer_stub() -> void:
|
||||
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
|
||||
var stub := _ViewerStub.new()
|
||||
stub.window = _mock_window()
|
||||
o.viewer = stub
|
||||
o._draw()
|
||||
assert_that(o._cached_texture).is_not_null()
|
||||
@@ -271,116 +271,151 @@ func test_header_location_label_still_includes_the_coordinates() -> void:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1142 item 4: drag-pan through the real input chain (DistrictScreen ->
|
||||
# AtlasWindowViewer._gui_input) — verifies no ancestor eats the event.
|
||||
# T-1145 item 2: WASD/edge-scroll pan REPLACES drag-pan entirely (Jeroen's
|
||||
# input-model ruling — LMB-drag broke click semantics with future map
|
||||
# objects). Testable-shape choice (per the ticket's explicit either/or):
|
||||
# _apply_pan_delta(direction, delta) is the extracted, testable pan-tick —
|
||||
# calling it DIRECTLY with a synthetic direction/delta is preferred over
|
||||
# synthesizing InputEventKey events through _gui_input, because WASD panning
|
||||
# is NOT event-routed at all (it is Input.is_key_pressed() polling inside
|
||||
# _process(), see _held_pan_direction()'s own doc) — synthesizing a key EVENT
|
||||
# would exercise nothing (no _gui_input branch reads WASD), and driving it
|
||||
# through Godot's actual global Input singleton state (Input.action_press()
|
||||
# et al) would work but couples every test to mutating engine-global state
|
||||
# that must then be carefully reset, for zero additional coverage over
|
||||
# calling the already-extracted pure-ish tick function directly. This
|
||||
# confirms the HANDLER/tick logic itself (offset movement, pole wall, wrap,
|
||||
# _user_adjusted, refetch) exactly as the old drag tests did; a live human
|
||||
# drive (WASD held down, edge-scroll near a real screen edge) is the
|
||||
## lead's own stated live-verification step for what a real key-repeat/mouse-
|
||||
## position sequence produces end to end.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Drives _gui_input DIRECTLY on the viewer (the same call gdUnit's own
|
||||
## headless-mode InputEvent limitation forces every other _gui_input test in
|
||||
## this cluster to use — see test_atlas_descend_entry.gd's own note on real
|
||||
## mouse events not being transported in headless mode). This confirms the
|
||||
## HANDLER logic itself moves _view_offset on a drag; the "does the Control
|
||||
## TREE deliver the event to this handler at all" question is the separate,
|
||||
## real concern item 4 raises (DistrictScreen's own mouse_filter=STOP could
|
||||
## theoretically intercept) — that is checked by the follow-up test below,
|
||||
## which drives the SAME sequence starting from DistrictScreen's root.
|
||||
func test_drag_pan_moves_view_offset() -> void:
|
||||
## _apply_pan_delta() must move _view_offset — the WASD-input-model
|
||||
## equivalent of the old test_drag_pan_moves_view_offset.
|
||||
func test_wasd_pan_moves_view_offset() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32)
|
||||
# body_radius_km absent -> no pole wall (identity clamp), isolating the
|
||||
# drag-delta math itself from item 5's clamp in this test.
|
||||
# pan-delta math itself from item 5's clamp in this test.
|
||||
var offset_before: Vector2 = v.get_view_offset()
|
||||
|
||||
var press := InputEventMouseButton.new()
|
||||
press.button_index = MOUSE_BUTTON_LEFT
|
||||
press.pressed = true
|
||||
press.position = Vector2(400.0, 300.0)
|
||||
v._gui_input(press)
|
||||
|
||||
var motion := InputEventMouseMotion.new()
|
||||
motion.position = Vector2(500.0, 350.0) # +100, +50 drag delta
|
||||
v._gui_input(motion)
|
||||
v._apply_pan_delta(Vector2(1.0, 0.0), 0.1) # "D"/east held for one tick
|
||||
|
||||
assert_that(v.get_view_offset()).override_failure_message(
|
||||
"a drag must move _view_offset away from its pre-drag value"
|
||||
"a pan tick must move _view_offset away from its pre-pan value"
|
||||
).is_not_equal(offset_before)
|
||||
assert_that(v.get_view_offset()).is_equal(offset_before + Vector2(100.0, 50.0))
|
||||
|
||||
|
||||
## The real scene-tree path: DistrictScreen (mouse_filter=STOP, no
|
||||
## _gui_input override) -> AtlasWindowViewer (mouse_filter=STOP, HAS
|
||||
## _gui_input). Godot delivers _gui_input to the DEEPEST/topmost Control
|
||||
## under the mouse first — DistrictScreen having no _gui_input override
|
||||
## means it never intercepts before AtlasWindowViewer gets the event; this
|
||||
## test confirms that structurally by driving the event through
|
||||
## DistrictScreen's own child and checking the SAME state change reaches
|
||||
## AtlasWindowViewer, exactly as if the event had arrived organically through
|
||||
## the real app -> nav-stack -> DistrictScreen chain.
|
||||
func test_drag_pan_reaches_viewer_through_district_screen_chain() -> void:
|
||||
## Frame-rate independence (T-1145's explicit requirement): the SAME held
|
||||
## direction over a LONGER delta must move the view FARTHER — proportionally,
|
||||
## not by some fixed per-tick step. Two short ticks must (within float
|
||||
## rounding) equal one long tick of the combined duration.
|
||||
func test_wasd_pan_is_frame_rate_independent() -> void:
|
||||
var v1: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v1)
|
||||
v1.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32)
|
||||
v1._apply_pan_delta(Vector2(1.0, 0.0), 0.02)
|
||||
v1._apply_pan_delta(Vector2(1.0, 0.0), 0.02)
|
||||
|
||||
var v2: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v2)
|
||||
v2.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32)
|
||||
v2._apply_pan_delta(Vector2(1.0, 0.0), 0.04)
|
||||
|
||||
assert_vector(v1.get_view_offset()).override_failure_message(
|
||||
"two 0.02s ticks must move the view the same distance as one 0.04s tick"
|
||||
).is_equal_approx(v2.get_view_offset(), Vector2(0.01, 0.01))
|
||||
|
||||
|
||||
## Diagonal input (e.g. W+D held together) must NOT pan faster than a single
|
||||
## axis — _apply_pan_delta() normalizes the direction before applying speed.
|
||||
func test_wasd_diagonal_pan_is_not_faster_than_single_axis() -> void:
|
||||
var v_diag: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v_diag)
|
||||
v_diag.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32)
|
||||
var before_diag: Vector2 = v_diag.get_view_offset()
|
||||
v_diag._apply_pan_delta(Vector2(1.0, -1.0), 0.1) # D+W (east+north) held together
|
||||
var diag_distance: float = before_diag.distance_to(v_diag.get_view_offset())
|
||||
|
||||
var v_axis: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v_axis)
|
||||
v_axis.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32)
|
||||
var before_axis: Vector2 = v_axis.get_view_offset()
|
||||
v_axis._apply_pan_delta(Vector2(1.0, 0.0), 0.1) # D (east) alone
|
||||
var axis_distance: float = before_axis.distance_to(v_axis.get_view_offset())
|
||||
|
||||
assert_float(diag_distance).override_failure_message(
|
||||
"diagonal WASD must travel the SAME distance per tick as a single axis, not faster"
|
||||
).is_equal_approx(axis_distance, 0.01)
|
||||
|
||||
|
||||
## The real scene-tree path: DistrictScreen -> AtlasWindowViewer. Unlike
|
||||
## drag (which needed _gui_input event delivery, hence the old
|
||||
## "does an ancestor eat the event" test), WASD pan lives in _process() —
|
||||
## Godot delivers _process() to every node in the tree regardless of Control
|
||||
## mouse_filter/ancestry (there is no "topmost control" routing for
|
||||
## per-frame process callbacks the way there is for _gui_input), so there is
|
||||
## no equivalent "does DistrictScreen eat it" question for _process() itself.
|
||||
## What DOES still matter through the real chain is _is_over_ui()'s edge-
|
||||
## scroll suppression and visibility gating — pinned directly below instead.
|
||||
func test_wasd_pan_reaches_viewer_through_district_screen_chain() -> void:
|
||||
var screen: DistrictScreen = auto_free(DistrictScreen.new())
|
||||
add_child(screen)
|
||||
screen.enter({"body": {"body_id": "GJ380c"}, "district_center": Vector2i(0, 0)})
|
||||
var offset_before: Vector2 = screen._viewer.get_view_offset()
|
||||
|
||||
var press := InputEventMouseButton.new()
|
||||
press.button_index = MOUSE_BUTTON_LEFT
|
||||
press.pressed = true
|
||||
press.position = Vector2(400.0, 300.0)
|
||||
screen._viewer._gui_input(press)
|
||||
|
||||
var motion := InputEventMouseMotion.new()
|
||||
motion.position = Vector2(460.0, 300.0)
|
||||
screen._viewer._gui_input(motion)
|
||||
screen._viewer._apply_pan_delta(Vector2(1.0, 0.0), 0.1)
|
||||
|
||||
assert_that(screen._viewer.get_view_offset()).override_failure_message(
|
||||
"a drag driven through DistrictScreen's child viewer must still move"
|
||||
+ " _view_offset — no ancestor in the real screen chain eats the event"
|
||||
"a pan tick driven through DistrictScreen's child viewer must still move"
|
||||
+ " _view_offset — no ancestor in the real screen chain blocks it"
|
||||
).is_not_equal(offset_before)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1142 item 5: pole hard wall wired into the real drag handler
|
||||
# T-1142 item 5: pole hard wall wired into the (now WASD) pan handler
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## A window already near the pole, dragged FAR toward it, must have its
|
||||
## offset clamped by the real _gui_input path (not just the pure function in
|
||||
## isolation — this confirms the wiring, not just the math).
|
||||
## A window already near the pole, panned FAR toward it, must have its
|
||||
## offset clamped by the real _apply_pan_delta() path (not just the pure
|
||||
## function in isolation — this confirms the wiring, not just the math).
|
||||
## A synthetic small body (NOT GJ380c's real ~6238km radius) is used
|
||||
## deliberately: with a real body's huge rows_half (~4785 for GJ380c), the
|
||||
## wall sits so many screen-pixels away that even an "absurd" mouse-motion
|
||||
## delta (bounded by a real screen's pixel dimensions) never reaches it —
|
||||
## the wall is real but the test would need a physically-impossible mouse
|
||||
## position to trigger it. A small synthetic radius (-> a small rows_half)
|
||||
## keeps the wall reachable by an ordinary drag delta while exercising the
|
||||
## exact same code path.
|
||||
func test_drag_pan_is_clamped_by_the_pole_wall_when_wired() -> void:
|
||||
## wall sits so far away that even a long held-key tick never reaches it —
|
||||
## the wall is real but the test would need an implausibly long hold to
|
||||
## trigger it. A small synthetic radius (-> a small rows_half) keeps the
|
||||
## wall reachable by an ordinary tick while exercising the exact same code
|
||||
## path.
|
||||
func test_wasd_pan_is_clamped_by_the_pole_wall_when_wired() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 800.0)
|
||||
# A tiny synthetic radius -> district_extent().rows_half is small (a few
|
||||
# hundred districts), so the pole wall is within reach of an ordinary
|
||||
# drag delta. Center 10 districts from the north pole.
|
||||
# pan tick. Center 10 districts from the north pole.
|
||||
var radius_km := 50.0
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var rows_half: int = int(extent["rows_half"])
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": radius_km}, {}, Vector2i(0, -rows_half + 10), 32)
|
||||
|
||||
var press := InputEventMouseButton.new()
|
||||
press.button_index = MOUSE_BUTTON_LEFT
|
||||
press.pressed = true
|
||||
press.position = Vector2(400.0, 400.0)
|
||||
v._gui_input(press)
|
||||
# An absurdly long single tick (500s — no real frame is ever this long,
|
||||
# deliberately so the UNCLAMPED delta is orders of magnitude larger than
|
||||
# any plausible wall position, making "was it actually clamped" an
|
||||
# unambiguous check rather than a fragile near-boundary comparison).
|
||||
var unclamped_magnitude: float = 500.0 * AtlasWindowViewer.PAN_SPEED_CANVAS_PX_S * v.get_view_zoom()
|
||||
v._apply_pan_delta(Vector2(0.0, -1.0), 500.0) # "W"/north held
|
||||
|
||||
var motion := InputEventMouseMotion.new()
|
||||
motion.position = Vector2(400.0, -50000.0) # an absurd upward drag
|
||||
v._gui_input(motion)
|
||||
|
||||
assert_float(v.get_view_offset().y).override_failure_message(
|
||||
"an absurd drag toward the pole must be clamped by the real input path"
|
||||
).is_greater(-50000.0)
|
||||
var message: String = (
|
||||
"a pan toward the pole with an unclamped magnitude of %.0f must land"
|
||||
+ " nowhere near that far — the wall must have clamped it"
|
||||
) % unclamped_magnitude
|
||||
assert_float(absf(v.get_view_offset().y)).override_failure_message(message).is_less(
|
||||
unclamped_magnitude * 0.5
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -435,37 +470,23 @@ func test_center_one_column_past_the_seam_shares_a_cache_key_with_its_twin() ->
|
||||
# =============================================================================
|
||||
# _user_adjusted guard (PR #188 review) — the flag exists so auto-fit NEVER
|
||||
# fights a manually-adjusted view. The one branch that makes that true
|
||||
# (resize while user-adjusted) had no coverage; both directions pinned here,
|
||||
# driving the REAL _gui_input path (synthetic events), not the flag directly.
|
||||
# (resize while user-adjusted) had no coverage; both directions pinned here.
|
||||
# T-1145: the ORIGINAL version drove this through a synthetic drag sequence
|
||||
# (_gui_input); drag is gone (item 2), so this now drives a WASD press
|
||||
# instead — via _apply_pan_delta() directly, same testable-shape choice
|
||||
# documented at the top of the WASD section above (a real key-repeat
|
||||
# sequence through _gui_input would exercise nothing, since WASD panning
|
||||
# never goes through _gui_input at all).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _drag_viewer(v: AtlasWindowViewer, from: Vector2, to: Vector2) -> void:
|
||||
var down := InputEventMouseButton.new()
|
||||
down.button_index = MOUSE_BUTTON_LEFT
|
||||
down.pressed = true
|
||||
down.position = from
|
||||
down.global_position = from
|
||||
v._gui_input(down)
|
||||
var move := InputEventMouseMotion.new()
|
||||
move.position = to
|
||||
move.global_position = to
|
||||
v._gui_input(move)
|
||||
var up := InputEventMouseButton.new()
|
||||
up.button_index = MOUSE_BUTTON_LEFT
|
||||
up.pressed = false
|
||||
up.position = to
|
||||
up.global_position = to
|
||||
v._gui_input(up)
|
||||
|
||||
|
||||
func test_resize_after_manual_drag_keeps_user_view() -> void:
|
||||
func test_resize_after_manual_wasd_press_keeps_user_view() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(1280.0, 720.0)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}, Vector2i(10, 20), 32)
|
||||
|
||||
_drag_viewer(v, Vector2(600.0, 400.0), Vector2(540.0, 380.0))
|
||||
v._apply_pan_delta(Vector2(1.0, -1.0), 0.1) # a single "D+W" tick — sets _user_adjusted
|
||||
var user_zoom: float = v.get_view_zoom()
|
||||
var user_offset: Vector2 = v.get_view_offset()
|
||||
|
||||
@@ -493,3 +514,155 @@ func test_resize_without_user_adjustment_refits() -> void:
|
||||
assert_float(v.get_view_zoom()).override_failure_message(
|
||||
"resize with no manual adjustment must re-fit to the new viewport"
|
||||
).is_not_equal(fitted_zoom)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1145 item 2: edge-scroll suppression — over UI (_is_over_ui reuse) and
|
||||
# unfocused-window (_app_has_focus, NOTIFICATION_APPLICATION_FOCUS_OUT/IN).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Cursor within EDGE_SCROLL_MARGIN_PX of the left edge -> edge-scrolling.
|
||||
func test_edge_scroll_detects_cursor_near_the_left_edge() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v._last_mouse_pos = Vector2(10.0, 300.0) # within 24px of x=0
|
||||
assert_bool(v._is_cursor_edge_scrolling()).is_true()
|
||||
|
||||
|
||||
## Cursor well inside the viewport (nowhere near any edge) -> NOT edge-scrolling.
|
||||
func test_edge_scroll_does_not_trigger_away_from_any_edge() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v._last_mouse_pos = Vector2(400.0, 300.0) # dead center
|
||||
assert_bool(v._is_cursor_edge_scrolling()).is_false()
|
||||
|
||||
|
||||
## Cursor near the RIGHT edge (not just left) also triggers — all four edges
|
||||
## are live, not just one.
|
||||
func test_edge_scroll_detects_cursor_near_the_right_edge() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v._last_mouse_pos = Vector2(795.0, 300.0) # within 24px of x=800
|
||||
assert_bool(v._is_cursor_edge_scrolling()).is_true()
|
||||
|
||||
|
||||
## The direction produced when edge-scrolling near the left edge must point
|
||||
## WEST (negative X) — toward the edge the cursor is near, matching WASD's
|
||||
## own "A pans toward more western content" semantics exactly (same sign
|
||||
## convention, same _apply_pan_delta() consumer).
|
||||
func test_edge_scroll_direction_points_toward_the_near_edge() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v._last_mouse_pos = Vector2(5.0, 300.0)
|
||||
var direction: Vector2 = v._edge_scroll_direction()
|
||||
assert_float(direction.x).override_failure_message(
|
||||
"edge-scroll near the LEFT edge must produce a WESTWARD (negative x) direction"
|
||||
).is_less(0.0)
|
||||
assert_float(direction.y).is_equal_approx(0.0, 0.001)
|
||||
|
||||
|
||||
## Reuses _is_over_ui() (the ticket's explicit instruction) — this screen's
|
||||
## own _is_over_ui() always returns false today (no city panel yet, see its
|
||||
## own doc), so edge-scroll near an edge must still trigger; the POINT of
|
||||
## this test is pinning that the suppression call-site exists and reads
|
||||
## _is_over_ui's real return value, not that it currently suppresses
|
||||
## anything (nothing to suppress against yet on this screen).
|
||||
func test_edge_scroll_over_ui_uses_is_over_ui() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v._last_mouse_pos = Vector2(5.0, 300.0)
|
||||
assert_bool(v._is_over_ui(v._last_mouse_pos)).override_failure_message(
|
||||
"AtlasWindowViewer._is_over_ui() has no UI surface yet (see its own doc) —"
|
||||
+ " this pins that baseline so a future sidebar addition's test failure here"
|
||||
+ " signals the edge-scroll suppression wiring needs a look, not a silent pass"
|
||||
).is_false()
|
||||
assert_bool(v._is_cursor_edge_scrolling()).is_true()
|
||||
|
||||
|
||||
## _app_has_focus defaults true (a freshly-entered screen assumes OS focus).
|
||||
func test_app_focus_defaults_true() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
assert_bool(v._app_has_focus).is_true()
|
||||
|
||||
|
||||
## NOTIFICATION_APPLICATION_FOCUS_OUT flips _app_has_focus false, and edge-
|
||||
## scroll must stop triggering even with the cursor still parked at an edge
|
||||
## — "if detectable" per the ticket; Godot's own focus notification IS
|
||||
## directly detectable, so this pins that it is actually wired.
|
||||
func test_app_focus_out_suppresses_edge_scroll() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v._last_mouse_pos = Vector2(5.0, 300.0)
|
||||
assert_bool(v._is_cursor_edge_scrolling()).override_failure_message(
|
||||
"sanity: edge-scroll must be live before focus-out"
|
||||
).is_true()
|
||||
|
||||
v.notification(Control.NOTIFICATION_APPLICATION_FOCUS_OUT)
|
||||
assert_bool(v._app_has_focus).is_false()
|
||||
assert_bool(v._is_cursor_edge_scrolling()).override_failure_message(
|
||||
"edge-scroll must be suppressed while the OS window lacks focus"
|
||||
).is_false()
|
||||
|
||||
|
||||
## NOTIFICATION_APPLICATION_FOCUS_IN restores edge-scroll after a focus-out.
|
||||
func test_app_focus_in_restores_edge_scroll() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v._last_mouse_pos = Vector2(5.0, 300.0)
|
||||
v.notification(Control.NOTIFICATION_APPLICATION_FOCUS_OUT)
|
||||
v.notification(Control.NOTIFICATION_APPLICATION_FOCUS_IN)
|
||||
assert_bool(v._app_has_focus).is_true()
|
||||
assert_bool(v._is_cursor_edge_scrolling()).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1145 item 2: WASD reads the PHYSICAL keycode, independent of the
|
||||
# gameplay move_north/move_south/move_east/move_west InputMap actions those
|
||||
# SAME keys are already bound to project-wide (D-054). This is a structural
|
||||
# check, not a live-input one (gdUnit's headless mode does not transport real
|
||||
# InputEvents, per this suite's own established note) — it pins that
|
||||
## _held_pan_direction() calls Input.is_key_pressed() (physical keycode), NOT
|
||||
## Input.is_action_pressed("move_north") or similar, by inspecting that no
|
||||
## project Input Map action name appears anywhere in this function's own
|
||||
## reachable behavior. The live independence claim itself (holding W pans
|
||||
## the map AND does not also queue a gameplay move) is the lead's own
|
||||
## live-verification step.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## project.godot's move_north/move_south/move_east/move_west actions are
|
||||
## ALREADY bound to W/S/A/D physical keys (confirmed by direct inspection of
|
||||
## project.godot's [input] section during T-1145 implementation) — this test
|
||||
## exists purely as a living pin of that fact, so the rationale in
|
||||
## _held_pan_direction()'s own doc comment (why raw keycodes, not the shared
|
||||
## action) stays true if the project's key bindings are ever edited.
|
||||
func test_wasd_keys_are_the_same_physical_keys_as_gameplay_movement_actions() -> void:
|
||||
var action_to_key: Dictionary = {
|
||||
"move_north": KEY_W, "move_west": KEY_A, "move_south": KEY_S, "move_east": KEY_D
|
||||
}
|
||||
for action: String in action_to_key.keys():
|
||||
assert_bool(InputMap.has_action(action)).override_failure_message(
|
||||
"expected gameplay action '%s' to exist in the project InputMap" % action
|
||||
).is_true()
|
||||
var bound_to_key: bool = false
|
||||
for input_event: InputEvent in InputMap.action_get_events(action):
|
||||
if input_event is InputEventKey and (input_event as InputEventKey).physical_keycode == action_to_key[action]:
|
||||
bound_to_key = true
|
||||
break
|
||||
assert_bool(bound_to_key).override_failure_message(
|
||||
(
|
||||
"expected '%s' to be bound to physical keycode %d — if this ever"
|
||||
+ " stops being true, _held_pan_direction()'s own doc comment"
|
||||
+ " (why it reads Input.is_key_pressed() instead of the shared"
|
||||
+ " action) should be re-checked, not silently left stale"
|
||||
) % [action, action_to_key[action]]
|
||||
).is_true()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1665,6 +1665,8 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- **Overlay/legend reuse against §2–§4.** The base layer is **morphology, lightness-modulated by `elev_q`** (one `0.7 + 0.3*(elev_q/100)` multiply per cell — relief read without a second draw call, the T-1112 "shape=identity, cheap second channel=magnitude" instinct as hue=type / lightness=elevation), reusing the T-1123 probe's 17-entry `MORPHOLOGY_RGB` hues verbatim; it is always-on once the LOD threshold is crossed (it *is* this screen's `terrain` layer), so it takes no toggle id. Three switchable overlays get new `gen_dw_temp` / `gen_dw_moisture` / `gen_dw_veg` `OVERLAY_DEFS` ids (`group: "toggle"`, the `gen_l1_*` multi-toggle-over-one-base precedent): **temperature** reuses T-1118's region-grid ramp *exactly* (same `i16` deci-°C domain + `REGION_TEMP_NONE_DC` sentinel disposition — one colorizer across both zoom levels, §2's consistency ruling); **moisture** reuses the existing `SUB_BIOME_COLORS` dry-sand→wet-teal endpoints; **vegetation** is a green-family ramp with **`Marine = 6` rendered transparent** (lets the morphology water-blue show through — `Marine` is `derive_vegetation`'s bookkeeping answer for already-`OpenOcean`/`Lake` districts, not new player information; a second blue would fight or duplicate the morphology read — this is the exhaustive disposition §3 mandates). **Glaciation is a modifier, not a toggle**: an ice-tint wash gated on `glaciation_grade >= Moderate` (alpha scaling with grade; `None`/`Light` draw no tint — `Light` is erosion signatures, not visible ice, per `apply_ice_tint`'s own gate, which this corrected prose now matches — PR #187 C4) composited over whichever layer shows — the `aliveness_probe::apply_ice_tint` approach ported to the player composite; it keeps sea-ice (tint over `OpenOcean` navy → whitened blue) visually distinct from open ocean and from ice-capped land (tint over alpine grey → near-white) by alpha-compositing over different bases rather than three drifting hard-coded colors. Legend: one `GENERATION_LEGEND` entry per new id (existing data-driven `atlas_legend_panel.gd` table, no new panel class); the morphology base folds its 17 zones into ~5 family rows (water / coastal-transition / plains-river / upland / volcanic) with the full mapping in the city-click sidebar, mirroring T-1112's "not everything earns permanent screen space" discipline. Implant chrome discipline (D-169/D-170): `ImplantHeader` carries a location label (the nearest settlement's name when the window is over/near one, else a coordinate/region label — the window is not settlement-anchored, per the entry clause above) + extent-in-real-units subtitle (e.g. "4.1 × 4.1 km · 2.0 km/cell") + one optional flavor line; the map-data palettes stay **out of** the theme's semantic accent roles (especially `ACCENT_ACTIVE` gold, which the settlement marker owns and must not compete with); no scanline/glitch dressing (the implant is confident working tech — a signal-quality state, if ever needed, rides `_gen_pending_indicator`, not cosmetic noise). Full color/ramp/compositing/legibility rationale and the n=32↔n=64 on-screen-scale math live in Araminta's companion T-1124 sections (visual encoding / implant aesthetic / legibility constraints), not re-derived here.
|
||||
|
||||
**Amended 2026-07-21 (T-1124 §5 entry revision — Jeroen, first companion hands-on):** the regional-map **entry mechanic changes from zoom-threshold LOD swap to explicit click-through**. Jeroen's ruling after using `make atlas`: the planetary pixel-scaling pan/zoom "is only messing with the pixels of the map and the interaction is weird" — (a) the **planetary heightmap view becomes FIXED** (no drag-pan / wheel-zoom of the planetary canvas; the current pan/zoom ships until T-1138 replaces it, then is removed *in the same change* as the replacement so close inspection is never stranded); (b) **entry is a click-through**: hovering the planetary heightmap shows a **rectangle cursor** representing the regional-mode bounds, and clicking descends into the regional map centered on the click point's derived `DistrictPos` — §5's float-on-center/first-window rules carry over with "pan center" read as "click point". `DISTRICT_WINDOW_MIN_ZOOM` is retired before ever being built (the T-1138 zoom-headroom note is moot); the §5 cross-reference reading of D-013 ("the zoom gesture owns spatial descent") is superseded **for this seam only** — click owns descent. Everything *inside* the regional mode stands unchanged (§4 pan-only refetch, debounce, float-on-center, D-227 cache, border-fade). A **morphing transition** between map modes is explicitly deferred (Jeroen: nice, too ambitious for now) — the descent may cut. **Open at T-1138:** the rectangle cursor is an affordance, not to scale — an n=64 window (~131 km) is a few pixels on a planetary canvas; the screen design must resolve the honest representation (rectangle at true extent with a zoom-in cut on click, or a not-to-scale reticle with the real extent labeled beside it) without implying the regional view covers more planet than it does.
|
||||
|
||||
**Amended 2026-07-21 (T-1145 — Jeroen, second companion hands-on, KALLAST window):** three regional-window presentation fixes, all client-only. **Cover-fit supersedes contain:** `fit_window_view()`'s zoom now derives from the LARGER viewport dimension with no margin factor (`max(viewport.x, viewport.y) / composite_native`, not the old `0.9 * min(...)`), so the square district-window composite fills a wide/tall viewport edge to edge instead of leaving side margins, with the shorter axis' data extending into pan-space (the same "cover" concept as CSS `object-fit: cover`) — the existing §4 pan-edge refetch is unaffected (it keys off the screen-center-to-DistrictPos mapping, which any fit already centers on `_held_center` by construction, so no refetch churn at rest). **WASD + edge-scroll supersedes drag-pan:** LMB-drag panning is removed entirely (Jeroen's ruling — drag conflicts with click semantics for the map objects, e.g. settlements, this window will host later); panning is now held WASD/arrow keys (continuous, frame-rate-independent, `_process`-polled, physical-keycode reads to stay independent of the project's existing `move_north`/etc. gameplay-movement InputMap actions bound to the same keys) plus edge-scrolling (cursor within ~24px of a viewport edge, suppressed over UI and while the OS window lacks focus); wheel zoom is unchanged; pole-wall (§5 amendment above) and east-west wrap (T-1142) semantics are preserved unchanged under the new input source. **Smoothing is an interim presentation, pending T-1143:** the composite renders as an `n`×`n` `Image`/`ImageTexture` (one pixel per district, the identical existing per-cell color pipeline) drawn scaled with linear filtering — the same treatment the planetary heightmap already gets — instead of `n`×`n` flat rects, so GPU bilinear sampling reads as a terrain gradient rather than hard blocks; the original crisp per-cell path survives behind a compile-time const specifically so T-1143's design pass can compare both directly, and this smoothing is **not** T-1143's answer to district-tier legibility, only a stopgap ahead of it.
|
||||
- **Rationale:** Reusing the real UI — rather than a parallel offline renderer or dumped files — means the debug/review surface never diverges from what ships, and a dropped artifact can't go stale. Agent-navigability converts qualitative "does the synthesis look natural?" review from a manual eyeball pass into an automatable sweep that flags the few outliers for a human. The harness rides seams that already exist (`TickRate::Paused`, the paused-allowlist, `gameplay_occluded`, the bridge framing, the `run-visual` capture primitive) — a naming-and-contract exercise, not a new subsystem.
|
||||
- **New surface:** server pause-gating (run-conditions on the world phases keyed to a pause command); client `AtlasAgentInterface` (`observe`/`act`, Control-tree walker) + its local transport; the generation overlay rendering + selector + legend; interactive capture wired to `run-visual`.
|
||||
- **Implementation:** Phase 4 (epic T-750), built bottom-up — auto-pause substrate, T-969 proxy (D-225), T-960 viewer, agent channel, agent capture. Geography is the first consumer.
|
||||
|
||||
Reference in New Issue
Block a user