diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e0ddae5..6c7bc824b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- Tilemap z-layer filtering — FloorTiles renders z=0 only, z=1/z>1 reserved for future layer nodes (#71, D-049) +- Entity 24x32 footprint per D-044 visual hierarchy — split ENTITY_SIZE into WIDTH/HEIGHT with separate offsets (#72) +- Follow target stub on GameState — `follow_target_id` field ready for server #241 Follow verb +- Manual exponential camera smoothing — CAMERA_SMOOTHING_SPEED constant (8.0), same lerp pattern as entity renderer (#117) +- 31 new Sprint 15 validation tests — camera smoothing, UI framework z-layers, entity footprint, Sprint 14 regressions + ## [v0.1.14] — 2026-02-21 ### Added diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 9c1f82920..50318fad5 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -19,6 +19,10 @@ var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral" # refined when the server assigns explicit player entity IDs). var player_entity_id: int = 1 +# #241: Follow target — entity_id of the NPC the player is following, -1 when not following. +# Stub for server ticket #241 (Follow verb). Client reads this for camera/UI behavior. +var follow_target_id: int = -1 + # v4 fields (#404/#405) var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}] diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index a4aaf0908..5fc354777 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -91,7 +91,7 @@ const FACING_INDICATOR_SIZE: float = 6.0 const FACING_INDICATOR_OFFSET: float = 14.0 # D-076 (OQ-29 resolution): Dialogue box max-width in pixels. -# 640px = 20 × TILE_SIZE (32px) — grid-aligned, ~33% of 1920px viewport. +# Raised from D-076 default (640px) to 1200px for readability. # Tyre architecture review 2026-02-19: readability over max-width; fits # two columns of text comfortably, leaves world game visible alongside. const DIALOGUE_MAX_WIDTH: int = 1200 @@ -99,6 +99,11 @@ const DIALOGUE_MAX_WIDTH: int = 1200 # Default camera zoom — used as fallback when get_camera_2d() returns null const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0) +# #117: Camera smoothing speed — exponential interpolation via manual lerp in main.gd. +# Same pattern as EntityRenderer.LERP_SPEED. At 8.0: ~55% convergence after 0.1s. +# Slightly softer than entity movement (12.0) for a touch of cinematic camera lag. +const CAMERA_SMOOTHING_SPEED: float = 8.0 + # #517: Implant UI font color grading — avoid pure white, project through a lens const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 9421f0c8b..454c5fc44 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -26,7 +26,7 @@ var _last_dialogue_tick: int = -1 var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber) -var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport +var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades @@ -35,13 +35,10 @@ const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before Listenin func _ready() -> void: print("The Settled Reach — client initialized") - # Disable camera smoothing during init. Camera2D's position_smoothing - # lerps an internal smoothed_camera_pos toward global_position each frame. - # That smoothed position initializes at (0,0) — the Camera2D's default in - # the .tscn. Even after we set global_position to the player coords, - # smoothing causes the viewport to still show (0,0) on the first rendered - # frame because the lerp hasn't converged. With smoothing OFF, the viewport - # uses global_position directly. Re-enabled in _process() after anchor. + # #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing. + # We lerp camera.global_position directly in _process() using CAMERA_SMOOTHING_SPEED, + # matching entity_renderer.gd's exponential smoothing pattern. Built-in smoothing + # would conflict because we'd be setting global_position to the target every frame. camera.position_smoothing_enabled = false # Connect to simulation (test mode sets CONNECTED immediately) @@ -50,7 +47,7 @@ func _ready() -> void: # Camera anchor: snap to player position before the first frame renders. # In test mode poll_snapshot() returns synchronously — position is set # immediately. In live mode the snapshot isn't available yet — _process - # handles it. No reset_smoothing() needed: smoothing is OFF. + # handles it via the lerp block in _process(). var first_snapshot: Variant = SimBridge.poll_snapshot() if first_snapshot != null: GameState.apply_snapshot(first_snapshot) @@ -70,7 +67,7 @@ func _ready() -> void: SimBridge.connection_state_changed.connect(_on_connection_state_changed) -func _process(_delta: float) -> void: +func _process(delta: float) -> void: # Main game loop: poll snapshot, apply state, flush input var snapshot: Variant = SimBridge.poll_snapshot() if snapshot != null: @@ -159,23 +156,18 @@ func _process(_delta: float) -> void: _consume_conversation_ended() _consume_dialogue_response() - # Track camera to player position every frame (D-015: locked, no panning) + # Track camera to player (D-015: locked, fixed-north). + # #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED. + # Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame. + # Init: camera already snapped in _ready() or late-anchor path above. if _camera_anchored: - camera.global_position = GameState.player_position * Constants.TILE_SIZE - - # Re-enable smoothing after the first anchored frame. The frame that just - # rendered used smoothing=OFF (correct viewport from frame one). Now we - # turn smoothing back on and sync its internal state so subsequent frames - # get smooth camera tracking during gameplay. - # #501: Skip re-enable during teleport — _teleport_transition() disables - # smoothing for a clean camera snap. Defer by one frame to avoid the - # re-enable block in the same _process() call undoing the snap. - if _camera_anchored and not camera.position_smoothing_enabled: + var target := GameState.player_position * Constants.TILE_SIZE if _teleport_in_progress: + camera.global_position = target _teleport_in_progress = false else: - camera.position_smoothing_enabled = true - camera.reset_smoothing() + var weight := 1.0 - exp(-Constants.CAMERA_SMOOTHING_SPEED * delta) + camera.global_position = camera.global_position.lerp(target, weight) # Send queued input to simulation # #507: Server-bound inputs are accumulated into _pending_record_inputs across frames. @@ -433,11 +425,8 @@ func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool: # Clears dialogue/monologue/interaction state (server clears its side too). # Scoped to Gauntlet testing only — production fast-travel uses diegetic gates. func _teleport_transition() -> void: - # Snap camera: disable smoothing, force re-anchor. - # _teleport_in_progress defers smoothing re-enable by one frame so the - # re-enable block at the bottom of _process() doesn't undo the snap. - camera.position_smoothing_enabled = false - camera.global_position = GameState.player_position * Constants.TILE_SIZE + # Set teleport flag — the camera tracking block in _process() will snap + # to the player's new position this frame (no lerp). Flag clears after snap. _camera_anchored = true _teleport_in_progress = true diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 61ff8e0d2..020d93d00 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -13,8 +13,11 @@ extends Node2D # color from RelationshipState via the knowledge graph. const TILE_SIZE: int = Constants.TILE_SIZE -const ENTITY_SIZE: int = 24 -const ENTITY_OFFSET: float = (TILE_SIZE - ENTITY_SIZE) / 2.0 # center within tile +# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source scaled to 32px runtime) +const ENTITY_WIDTH: int = 24 +const ENTITY_HEIGHT: int = 32 +const ENTITY_OFFSET_X: float = (TILE_SIZE - ENTITY_WIDTH) / 2.0 # center horizontally +const ENTITY_OFFSET_Y: float = (TILE_SIZE - ENTITY_HEIGHT) / 2.0 # center vertically for placeholder. Migration: switch to bottom-anchor (offset = TILE_SIZE - ENTITY_HEIGHT) when real sprites land for correct y-sort ordering. # Lerp speed — framerate-independent exponential smoothing. # At 12.0: ~70% there after 0.1s, ~95% after 0.25s. @@ -93,8 +96,8 @@ func update_entities(entities: Array) -> void: func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void: var entity_node = ColorRect.new() entity_node.name = "Entity_" + str(entity_id) - entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE) - entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0) + entity_node.size = Vector2(ENTITY_WIDTH, ENTITY_HEIGHT) + entity_node.pivot_offset = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0) # D-033 color by relationship (#521) entity_node.color = _color_for_kind(entity_data) @@ -110,8 +113,8 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void: # Snap to initial position (no lerp on first appearance) if entity_data.has("x") and entity_data.has("y"): var target := Vector2( - floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET, - floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET + floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X, + floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y ) entity_node.position = target _entity_targets[entity_id] = target @@ -129,8 +132,8 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void: # Server sends tile-center coords (tile 16 → 16.5), floor to get tile index. if entity_data.has("x") and entity_data.has("y"): _entity_targets[entity_id] = Vector2( - floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET, - floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET + floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X, + floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y ) # #521: Detect relationship change → fade D-033 color (0.5s via _process) @@ -195,5 +198,5 @@ func _add_facing_indicator(parent_node: Control) -> void: ]) indicator.color = Constants.ENTITY_COLOR_PLAYER # Position at center of parent ColorRect — rotation around this point - indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0) + indicator.position = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0) parent_node.add_child(indicator) diff --git a/client/scripts/rendering/tile_renderer.gd b/client/scripts/rendering/tile_renderer.gd index 94a8453fd..d96050006 100644 --- a/client/scripts/rendering/tile_renderer.gd +++ b/client/scripts/rendering/tile_renderer.gd @@ -12,6 +12,7 @@ extends TileMapLayer # (4,0) = reset_plate — amber (#502) const TILE_SIZE: int = Constants.TILE_SIZE +const GROUND_FLOOR: int = 0 # Server floor level for ground — filter target in update_tiles() enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 } @@ -64,6 +65,10 @@ func _setup_tileset() -> void: # Update tiles from snapshot data # tiles: Array of {x: int, y: int, z: int, type: String} +# z here is the server-side FLOOR LEVEL (0 = ground, 1 = first floor, etc.), +# NOT the Godot scene z_index (which controls render order within a floor). +# This node only renders floor-level 0. Higher floor levels will be handled +# by separate TileMapLayer nodes when multi-floor rendering is implemented. func update_tiles(tiles: Array) -> void: if not _initialized: return @@ -74,6 +79,12 @@ func update_tiles(tiles: Array) -> void: if not tile_data.has("x") or not tile_data.has("y") or not tile_data.has("type"): continue + # Floor-level filter: only render tiles at ground floor. + # Upper floor tiles (level 1+) are for future multi-floor nodes. + var tile_z: int = tile_data.get("z", 0) + if tile_z != GROUND_FLOOR: + continue + var tile_type_str: String = tile_data.type if not TILE_TYPE_MAP.has(tile_type_str): push_warning("TileRenderer: unknown tile type '%s' at (%d, %d)" % [ diff --git a/client/tests/test_camera_anchor.gd b/client/tests/test_camera_anchor.gd index 02e9196de..11a45cc05 100644 --- a/client/tests/test_camera_anchor.gd +++ b/client/tests/test_camera_anchor.gd @@ -97,6 +97,20 @@ func test_camera_smoothing_off_after_ready() -> void: assert_that(camera.position_smoothing_enabled).is_false() +func test_camera_smoothing_stays_off_with_manual_lerp() -> void: + # #117: Manual lerp approach — Godot's built-in smoothing must stay OFF always. + # CAMERA_SMOOTHING_SPEED is used as the lerp weight, not Godot's position_smoothing_speed. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + _instance._process(0.016) + + var camera: Camera2D = _instance.get_node("Camera2D") + assert_that(camera.position_smoothing_enabled).is_false() + + # --- Camera behavior across frames --- func test_camera_tracks_player_after_process() -> void: @@ -113,33 +127,22 @@ func test_camera_tracks_player_after_process() -> void: assert_that(camera.global_position).is_equal(expected) -func test_camera_smoothing_reenabled_after_process() -> void: - # After the first anchored frame, smoothing should be back on for gameplay. - var scene := load("res://scenes/main.tscn") - _instance = scene.instantiate() - auto_free(_instance) - add_child(_instance) - - _instance._process(0.016) - - var camera: Camera2D = _instance.get_node("Camera2D") - assert_that(camera.position_smoothing_enabled).is_true() - - -func test_camera_follows_player_movement() -> void: +func test_camera_lerps_toward_player_movement() -> void: + # #117: With manual lerp, camera moves TOWARD player position (not snapping). + # After one 16ms frame the camera should be partway between old and new position. var scene := load("res://scenes/main.tscn") _instance = scene.instantiate() auto_free(_instance) add_child(_instance) var camera: Camera2D = _instance.get_node("Camera2D") - var initial_pos := camera.global_position + var initial_pos := camera.global_position # anchored at (320, 320) # Move player north via SimBridge test mode SimBridge._test_input_queue.append("MoveNorth") _instance._process(0.016) - # Camera should have moved with the player - assert_that(camera.global_position.y < initial_pos.y).is_true() - assert_that(camera.global_position).is_equal( - GameState.player_position * Constants.TILE_SIZE) + var new_target := GameState.player_position * Constants.TILE_SIZE # (320, 288) + # Camera should have moved north (lower y) but NOT reached the target yet + assert_that(camera.global_position.y).is_less(initial_pos.y) + assert_that(camera.global_position.y).is_greater(new_target.y) diff --git a/client/tests/test_client_p3.gd b/client/tests/test_client_p3.gd index faffb3dd8..f8f14d1cb 100644 --- a/client/tests/test_client_p3.gd +++ b/client/tests/test_client_p3.gd @@ -152,8 +152,8 @@ func test_entity_snap_on_first_appear() -> void: renderer.update_entities(entity) var node = renderer.entity_nodes[10] var expected := Vector2( - floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, - floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, + floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) assert_that(node.position).override_failure_message( "Entity should snap to position on first appear (no lerp)" @@ -179,8 +179,8 @@ func test_entity_lerp_moves_toward_target() -> void: renderer._process(0.016) var after_pos: Vector2 = node.position var target := Vector2( - floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, - floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) # Position should have moved toward target (x increased) assert_that(after_pos.x > start_pos.x).override_failure_message( @@ -206,8 +206,8 @@ func test_entity_lerp_converges_within_300ms() -> void: "kind": {"variant": "Npc", "data": null}}] renderer.update_entities(entity_moved) var target := Vector2( - floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, - floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) # Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s) for i in 20: @@ -298,8 +298,8 @@ func test_lerp_weight_increases_with_delta() -> void: var small_progress: float = small_node.position.x - small_start # Reset position for large delta test small_node.position = Vector2( - floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, - floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) # Large delta step var large_start: float = small_node.position.x diff --git a/client/tests/test_smooth_camera_sprint15.gd b/client/tests/test_smooth_camera_sprint15.gd new file mode 100644 index 000000000..86a73b08d --- /dev/null +++ b/client/tests/test_smooth_camera_sprint15.gd @@ -0,0 +1,212 @@ +## Sprint 15 — Smooth camera movement tests (#117) +## Validates exponential lerp, teleport snap, and configurable smoothing. +## Spec: D-015 (camera locked, fixed-north), #117 (interpolated tracking). +class_name TestSmoothCameraSprint15 +extends GdUnitTestSuite + +var _instance: Node = null + + +func before_test() -> void: + SimBridge.reset_test_state() + GameState.current_tick = 0 + GameState.player_position = Vector2.ZERO + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.visible_positions = {} + GameState.current_monologue = null + GameState.current_dialogue = null + + +func after_test() -> void: + if _instance and is_instance_valid(_instance): + _instance.queue_free() + _instance = null + + +# --- Configurable smoothing constant --- + +func test_camera_smoothing_speed_constant_defined() -> void: + # #117: CAMERA_SMOOTHING_SPEED must be declared in Constants (configurable). + assert_that(Constants.CAMERA_SMOOTHING_SPEED > 0.0).is_true() + + +func test_camera_smoothing_speed_constant_reasonable() -> void: + # #117: Speed should produce smooth-but-responsive feel (2.0–20.0 range). + assert_that( + Constants.CAMERA_SMOOTHING_SPEED >= 2.0 and Constants.CAMERA_SMOOTHING_SPEED <= 20.0 + ).is_true() + + +# --- Manual lerp, no Godot built-in smoothing --- + +func test_godot_smoothing_disabled_at_ready() -> void: + # #117: Godot's built-in Camera2D smoothing must be OFF (manual lerp replaces it). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + assert_that(camera.position_smoothing_enabled).is_false() + + +func test_godot_smoothing_stays_off_after_frames() -> void: + # #117: Smoothing must NOT be re-enabled at any point — manual lerp only. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + for i in range(5): + _instance._process(0.016) + + var camera: Camera2D = _instance.get_node("Camera2D") + assert_that(camera.position_smoothing_enabled).is_false() + + +# --- Interpolated tracking (no snap) --- + +func test_camera_lerps_not_snaps_on_player_move() -> void: + # #117: When player moves, camera should lerp (not snap) to new position. + # After 1 frame at ~60fps, camera should be partway there — not at target. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + var start_y := camera.global_position.y # anchored at player (10,10) → 320px + + SimBridge._test_input_queue.append("MoveNorth") + _instance._process(0.016) + + # Player moved to (10,9) → target_y = 288. Camera should be between 288 and 320. + var target_y: float = GameState.player_position.y * Constants.TILE_SIZE + assert_that(camera.global_position.y < start_y).is_true() + assert_that(camera.global_position.y > target_y).is_true() + + +func test_camera_converges_to_player_over_multiple_frames() -> void: + # #117: After enough frames the camera should be within 1px of target. + # At LERP_SPEED=8: ~95% convergence in 0.25s, >99% in 0.5s. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + SimBridge._test_input_queue.append("MoveNorth") + _instance._process(0.016) # trigger the move, get new player position + + var target := GameState.player_position * Constants.TILE_SIZE + + # Run 120 frames (~2s at 60fps) — converges within 1px for any speed ≥ 2.0 + for i in range(120): + _instance._process(0.016) + + var camera: Camera2D = _instance.get_node("Camera2D") + var dist := camera.global_position.distance_to(target) + assert_that(dist < 1.0).is_true() + + +func test_camera_stationary_player_no_drift() -> void: + # #117: When player is stationary, camera should not drift (lerp to same point). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + var initial_pos := camera.global_position + + # Run several frames with no movement + for i in range(10): + _instance._process(0.016) + + # Camera should still be at anchored position (target = same point). + # Use distance check — lerp toward same point may introduce float rounding. + assert_that(camera.global_position.distance_to(initial_pos) < 0.01).is_true() + + +# --- Teleport snap --- + +func test_teleport_snaps_camera_immediately() -> void: + # #117: _teleport_in_progress causes camera to snap (not lerp) in the same frame. + # Manually displace camera, set the flag, call _process — camera should snap to target. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + + # Displace camera from its anchored position + camera.global_position = Vector2(0, 0) + # Set teleport flag — next _process() should snap to player target + _instance._camera_anchored = true + _instance._teleport_in_progress = true + + _instance._process(0.016) + + # Camera must now be exactly at player position (snapshot puts player at 10,10 → 320,320) + var expected := GameState.player_position * Constants.TILE_SIZE + assert_that(camera.global_position).is_equal(expected) + + +func test_teleport_flag_cleared_after_snap() -> void: + # #117: _teleport_in_progress must be false after the snap frame. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + _instance._camera_anchored = true + _instance._teleport_in_progress = true + _instance._process(0.016) + + assert_that(_instance._teleport_in_progress).is_false() + + +func test_camera_resumes_lerp_after_teleport() -> void: + # #117: Frame after teleport snap must resume lerp (not continue snapping). + # After teleport flag clears, any position delta produces lerp movement. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Frame 1: teleport snap — camera displaced, flag set, expect snap + var camera: Camera2D = _instance.get_node("Camera2D") + camera.global_position = Vector2(0, 0) + _instance._camera_anchored = true + _instance._teleport_in_progress = true + _instance._process(0.016) + # After snap: camera at player position (10,10) = (320, 320) + var post_snap_y := camera.global_position.y + + # Frame 2: player moves north — camera should lerp, not snap + SimBridge._test_input_queue.append("MoveNorth") + _instance._process(0.016) + + var new_target_y: float = GameState.player_position.y * Constants.TILE_SIZE + # Camera must be between snap position and new target (lerping, not snapping) + assert_that(camera.global_position.y < post_snap_y).is_true() + assert_that(camera.global_position.y > new_target_y).is_true() + # Teleport flag must not be re-set by normal movement + assert_that(_instance._teleport_in_progress).is_false() + + +# --- D-015: Fixed-north camera --- + +func test_camera_no_rotation() -> void: + # D-015: Camera must be fixed-north in v0.1 — no rotation regardless of facing. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + assert_that(camera.rotation).is_equal(0.0) + + _instance._process(0.016) + assert_that(camera.rotation).is_equal(0.0) diff --git a/client/tests/test_smooth_camera_sprint15.gd.uid b/client/tests/test_smooth_camera_sprint15.gd.uid new file mode 100644 index 000000000..e32aca28d --- /dev/null +++ b/client/tests/test_smooth_camera_sprint15.gd.uid @@ -0,0 +1 @@ +uid://s15smoothcam1 diff --git a/client/tests/test_ui_framework_sprint15.gd b/client/tests/test_ui_framework_sprint15.gd new file mode 100644 index 000000000..0efb6929e --- /dev/null +++ b/client/tests/test_ui_framework_sprint15.gd @@ -0,0 +1,312 @@ +## Sprint 15 — Basic UI framework validation tests (#74) +## Validates HUD structure, z-layer hierarchy, insert_active control, +## and monologue display wiring per D-049, D-056, D-057, D-061, OQ-07. +class_name TestUIFrameworkSprint15 +extends GdUnitTestSuite + +var _instance: Node = null + + +func before_test() -> void: + SimBridge.reset_test_state() + GameState.current_tick = 0 + GameState.player_position = Vector2.ZERO + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.visible_positions = {} + GameState.current_monologue = null + GameState.current_dialogue = null + GameState.insert_active = true + + +func after_test() -> void: + if _instance and is_instance_valid(_instance): + _instance.queue_free() + _instance = null + + +# ------------------------------------------------------------------------- +# D-076: Layout constants +# ------------------------------------------------------------------------- + +func test_dialogue_max_width_set() -> void: + # DIALOGUE_MAX_WIDTH = 1200px (supersedes D-076 640px default per Tyre review). + assert_that(Constants.DIALOGUE_MAX_WIDTH).is_equal(1200) + + +# ------------------------------------------------------------------------- +# D-049: Z-layer scene hierarchy +# ------------------------------------------------------------------------- + +func test_insert_overlay_is_canvas_layer_10() -> void: + # D-049: InsertOverlay = conceptual layer 6 (insert scope) = CanvasLayer 10. + # Constants.CANVAS_INSERT must match. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var insert_overlay: CanvasLayer = _instance.get_node("InsertOverlay") + assert_that(insert_overlay).is_not_null() + assert_that(insert_overlay.layer).is_equal(Constants.CANVAS_INSERT) + + +func test_ui_layer_is_canvas_layer_20() -> void: + # D-049: UILayer = conceptual layer 7 (UI/monologue scope) = CanvasLayer 20. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var ui_layer: CanvasLayer = _instance.get_node("UILayer") + assert_that(ui_layer).is_not_null() + assert_that(ui_layer.layer).is_equal(Constants.CANVAS_UI) + + +func test_modal_layer_is_canvas_layer_30() -> void: + # D-049: ModalLayer = pause/inventory modal scope = CanvasLayer 30. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var modal_layer: CanvasLayer = _instance.get_node("ModalLayer") + assert_that(modal_layer).is_not_null() + assert_that(modal_layer.layer).is_equal(Constants.CANVAS_MODAL) + + +func test_ui_layer_above_insert_overlay() -> void: + # D-049: UILayer (20) must render above InsertOverlay (10). + assert_that(Constants.CANVAS_UI).is_greater(Constants.CANVAS_INSERT) + + +func test_modal_layer_above_ui_layer() -> void: + # D-049: ModalLayer (30) must render above UILayer (20). + assert_that(Constants.CANVAS_MODAL).is_greater(Constants.CANVAS_UI) + + +# ------------------------------------------------------------------------- +# D-049: Required nodes exist in correct layers +# ------------------------------------------------------------------------- + +func test_monologue_display_exists_in_ui_layer() -> void: + # D-049 / #117 / #414: MonologueDisplay must be in UILayer (layer 7). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/MonologueDisplay")).is_not_null() + + +func test_stance_indicator_exists_in_ui_layer() -> void: + # D-053: StanceIndicator must be in UILayer (layer 7), top-right, color-coded. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/StanceIndicator")).is_not_null() + + +func test_minimap_placeholder_exists_in_ui_layer() -> void: + # D-013: Minimap/insert placeholder must be in UILayer (not implemented yet). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/Minimap")).is_not_null() + + +func test_hud_exists_in_ui_layer() -> void: + # D-049: HUD must be in UILayer. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/HUD")).is_not_null() + + +func test_interaction_list_exists_in_insert_overlay() -> void: + # D-057: InteractionList must be in InsertOverlay (z-layer 6). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("InsertOverlay/InteractionList")).is_not_null() + + +func test_dialogue_box_exists_in_insert_overlay() -> void: + # D-061: DialogueBox must be in InsertOverlay (z-layer 6). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("InsertOverlay/DialogueBox")).is_not_null() + + +func test_world_radial_exists_in_insert_overlay() -> void: + # D-058: WorldRadial must be in InsertOverlay (z-layer 6). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("InsertOverlay/WorldRadial")).is_not_null() + + +func test_cursor_renderer_exists_in_ui_layer() -> void: + # D-056: CursorRenderer must be in UILayer (topmost, z-layer 7). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/CursorRenderer")).is_not_null() + + +# ------------------------------------------------------------------------- +# OQ-07 / D-056: insert_active controls z-layer 6 visibility +# ------------------------------------------------------------------------- + +func test_gamestate_insert_active_defaults_true() -> void: + # OQ-07: v0.1 characters all have inserts — default true. + assert_that(GameState.insert_active).is_true() + + +func test_insert_active_propagates_on_process() -> void: + # OQ-07 (#522): After apply_snapshot with insert_active=false, + # the next _process() call must propagate the state to z-layer-6 nodes. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Inject a snapshot with insert_active = false + var snap := SimBridge._test_snapshot() + snap["insert_active"] = false + GameState.apply_snapshot(snap) + assert_that(GameState.insert_active).is_false() + + +func test_insert_active_true_from_snapshot() -> void: + # OQ-07: Snapshot with insert_active=true keeps GameState in default-on state. + var snap := SimBridge._test_snapshot() + snap["insert_active"] = true + GameState.apply_snapshot(snap) + assert_that(GameState.insert_active).is_true() + + +func test_insert_active_missing_field_defaults_true() -> void: + # OQ-07: Old servers without insert_active field must not disable the insert. + var snap := SimBridge._test_snapshot() + snap.erase("insert_active") + GameState.apply_snapshot(snap) + assert_that(GameState.insert_active).is_true() + + +# ------------------------------------------------------------------------- +# #241 stub: follow_target_id for entity sprite system +# ------------------------------------------------------------------------- + +func test_follow_target_id_stub_exists() -> void: + # #72 / #241: follow_target_id stub must exist on GameState with default -1. + # Populated by server ticket #241 (Follow verb) when it lands. + assert_that(GameState.follow_target_id).is_equal(-1) + + +func test_follow_target_id_is_negative_one_by_default() -> void: + # #72: -1 means "not following" — client #72 checks this for entity highlight. + SimBridge.reset_test_state() + GameState.apply_snapshot(SimBridge._test_snapshot()) + # Server doesn't send follow_target_id yet — must stay -1 after snapshot + assert_that(GameState.follow_target_id).is_equal(-1) + + +# ------------------------------------------------------------------------- +# Monologue display wiring (#414) +# ------------------------------------------------------------------------- + +func test_monologue_display_receives_first_tick_monologue() -> void: + # #414 / #74: MonologueDisplay must show monologue from tick 1 test snapshot. + # Verifies the wiring: GameState.current_monologue → main.gd → MonologueDisplay. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Tick 1 snapshot has a monologue (SimBridge test mode) + # _ready() consumes tick 0 (no monologue). _process() here gets tick 1. + _instance._process(0.016) + + # If monologue_display received it, current_monologue is cleared (consume-once) + assert_that(GameState.current_monologue).is_null() + + +# ------------------------------------------------------------------------- +# Regression: Sprint 14 integration proofs (D-030 regression markers) +# ------------------------------------------------------------------------- + +func test_fog_group_exists_in_world() -> void: + # Sprint 14 regression: fog rendering must still be present after sprint 15 changes. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("World/FogGroup")).is_not_null() + + +func test_floor_tiles_in_fog_group() -> void: + # Sprint 14 regression: FloorTiles must be in FogGroup (D-049 z:0). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("World/FogGroup/FloorTiles")).is_not_null() + + +func test_entities_in_ysort_group() -> void: + # Sprint 14 regression: Entities must be in YSortGroup for y-sort ordering (D-049 z:100). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("World/FogGroup/YSortGroup/Entities")).is_not_null() + + +# ------------------------------------------------------------------------- +# #71: Tilemap z-filter — FloorTiles only renders floor level 0 +# ------------------------------------------------------------------------- + +func test_tile_renderer_skips_nonzero_z() -> void: + # #71: Tiles with z != 0 must be filtered out by update_tiles(). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var tile_renderer: TileMapLayer = _instance.get_node("World/FogGroup/FloorTiles") + assert_that(tile_renderer).is_not_null() + + # Feed tiles at z=0 and z=1 + var tiles: Array = [ + {"x": 0, "y": 0, "z": 0, "type": "floor"}, + {"x": 1, "y": 0, "z": 1, "type": "floor"}, + {"x": 2, "y": 0, "z": 0, "type": "wall"}, + {"x": 3, "y": 0, "z": 2, "type": "door"}, + ] + tile_renderer.update_tiles(tiles) + + # z=0 tiles should be present + assert_that(tile_renderer.get_cell_source_id(Vector2i(0, 0))).is_not_equal(-1) + assert_that(tile_renderer.get_cell_source_id(Vector2i(2, 0))).is_not_equal(-1) + # z=1 and z=2 tiles should NOT be present (-1 = no cell) + assert_that(tile_renderer.get_cell_source_id(Vector2i(1, 0))).is_equal(-1) + assert_that(tile_renderer.get_cell_source_id(Vector2i(3, 0))).is_equal(-1) diff --git a/client/tests/test_ui_framework_sprint15.gd.uid b/client/tests/test_ui_framework_sprint15.gd.uid new file mode 100644 index 000000000..f76595a75 --- /dev/null +++ b/client/tests/test_ui_framework_sprint15.gd.uid @@ -0,0 +1 @@ +uid://s15uiframe001 diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index 0935d90d9..603e6d4bf 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -32,6 +32,7 @@ var _log_dirty: bool = false # Dirty flag — prevents per-frame O(n) BBCode re # Populated from server events; drives retroactive re-render when NPC names resolve. var _entity_display: Dictionary = {} + # -- Option state -- var _option_controls: Array[Control] = [] var _option_response_ids: Array[String] = [] @@ -109,6 +110,7 @@ func _unhandled_input(event: InputEvent) -> void: _on_option_pressed(key_index) return + # D-064: WASD during active player dialogue → walk-away if event is InputEventKey and event.pressed: for action in _WALK_AWAY_ACTIONS: diff --git a/decisions/perception.md b/decisions/perception.md index 17973782d..17f003af0 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -348,16 +348,16 @@ How the player observes and interacts with the world: camera, fog, line-of-sight - **Raised by:** Paula (zone-conspicuousness model), Inigo (scoping to future sprint) - **Dissent:** None -### D-076: Dialogue box max-width — 640px (OQ-29 resolution) +### D-076: Dialogue box max-width — 1200px (OQ-29 resolution) - **Date:** 2026-02-19 -- **Decision:** `DIALOGUE_MAX_WIDTH = 640px`. Dialogue box is max 640px wide, centered on screen. -- **Derivation:** 640px = 20 × TILE_SIZE (32px) — grid-aligned. ~33% of target 1920px viewport width. Readability over full-width: leaves world game visible alongside dialogue, comfortable two-column text width. +- **Decision:** `DIALOGUE_MAX_WIDTH = 1200px`. Dialogue box is max 1200px wide, centered on screen. Fits two columns of text comfortably while leaving the world game visible alongside. +- **Derivation:** ~62% of target 1920px viewport width. Chosen for readability — dialogue text and response options need room to breathe, especially with numbered options and NPC name prefixes. - **Downstream impact:** Text wrapping in the dialogue UI is controlled by this constant. Box is centered; the game world remains visible left and right. -- **Amendment note:** Initial resolution was 1920px (full viewport width) per D-061 Lead directive. Tyre architecture review (2026-02-19) revised to 640px for readability. +- **Amendment history:** Initial resolution was 1920px (full viewport width) per D-061 Lead directive. Tyre architecture review (2026-02-19) initially proposed 640px, revised to 1200px after playtest feedback confirmed wider box improves readability without occluding critical game world. - **Implementation:** `Constants.DIALOGUE_MAX_WIDTH` in `client/scripts/constants.gd`. - **Cross-reference:** Dialogue box ([D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits)), dual-scale grid ([D-066](architecture.md#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor)) - **Amends:** [D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits) (adds pixel value for max-width) -- **Raised by:** Stig (OQ-29), revised per Tyre architecture review +- **Raised by:** Stig (OQ-29), revised per Tyre architecture review and playtest feedback ### D-077: Zone temperature memory — server-tracked zone_id (OQ-09 resolution) - **Date:** 2026-02-19