Relocates main_menu, character_creation, settings_dialog, debug_console,
bug_report_dialog, loading_screen from flat client/ui/ into structured
client/ui/meta/screens/<name>/. All six now extend MetaScreen instead
of Control; the base handles open/close lifecycle, visibility,
captures_input, and — for overlays — the sim-pause contract.
Screen policies set per Tyre's proposal:
- settings_dialog: pauses_sim=false, PUSHES onto MetaStack
- debug_console: pauses_sim=true, PUSHES (D-088 routing via base)
- bug_report_dialog: pauses_sim=true, PUSHES
- loading_screen: closable_by_escape=false, PUSHES
- main_menu, character_creation: scene-roots, extend MetaScreen for
the lifecycle contract only, do NOT push onto the stack
character_creation stays at its current surface (tabs, descriptor,
creation_confirmed signal unchanged). Tab consolidation and
CharacterProfile migration happen in Workstreams 5 and 6.
Knock-on changes:
- main.tscn ModalLayer CanvasLayer renamed to MetaLayer; main.gd
@onready refs updated; constants.gd comment updated; test_client_p3
and test_ui_framework_sprint15 assertions updated; test_monologue_display
and .tscn header comments updated.
- OPEN_MENU handler now pushes settings_dialog onto MetaStack before
calling open(). Full ESC priority chain lands in Workstream 4.
- atlas_app.gd: _unhandled_key_input signature widened from
InputEventKey to InputEvent with an is-check, per Godot 4 API. Pre-
existing narrowing was silently tolerated until main.tscn started
fully instantiating under the new pattern.
- test_client_p3: entity_renderer type annotations corrected from
ColorRect to Sprite2D (stale since a prior refactor); facing
indicator rotation assertion switched to angle_difference() for
modular-safe comparison.
Verification:
- gdlint client/scripts/ client/ui/ — zero problems
- godot --headless --path client --quit — no SCRIPT ERROR
- test_client_p3: 24/24 pass
- test_ui_framework_sprint15: 54/54 pass
- test_implant_nav_stack: 52/52 pass
- test_implant_registry: 42/42 pass
- test_implant_app_lifecycle: 36/36 pass
Workstream 1 foundation (84105916) remains unchanged. Workstreams 3-8
follow: protocol layer, Option A sequencing, 3-tab restructure,
Bookmark tab, location picker, Skills stub.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
343 lines
14 KiB
GDScript
343 lines
14 KiB
GDScript
## P3 client tests: z-layer ordering (4), entity lerp (3), Tyre additions (5).
|
||
## Validates scene tree draw order, framerate-independent entity interpolation,
|
||
## and recognition transition timing.
|
||
## Spec ref: sprint-9/client.md #493.
|
||
class_name TestClientP3
|
||
extends GdUnitTestSuite
|
||
|
||
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||
var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd")
|
||
|
||
var _instance: Node = null
|
||
|
||
|
||
func before_test() -> void:
|
||
SimBridge.reset_test_state()
|
||
SimBridge._last_snapshot = null
|
||
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.game_time = {}
|
||
GameState.pending_recognitions = []
|
||
GameState.nearby_interactions = []
|
||
GameState.player_inventory = []
|
||
GameState.player_stance = "Walk"
|
||
GameState.player_facing = "North"
|
||
GameState.dialogue_active = false
|
||
|
||
|
||
func after_test() -> void:
|
||
if _instance and is_instance_valid(_instance):
|
||
_instance.queue_free()
|
||
_instance = null
|
||
|
||
|
||
# -- Helpers -------------------------------------------------------------------
|
||
|
||
func _make_scene() -> Node:
|
||
var scene := load("res://scenes/main.tscn")
|
||
_instance = scene.instantiate()
|
||
auto_free(_instance)
|
||
add_child(_instance)
|
||
return _instance
|
||
|
||
|
||
func _make_entity_renderer() -> Node2D:
|
||
var renderer = Node2D.new()
|
||
renderer.set_script(EntityRendererScript)
|
||
add_child(renderer)
|
||
return renderer
|
||
|
||
|
||
func _make_fog_entities() -> Node2D:
|
||
var node = Node2D.new()
|
||
node.set_script(FogEntitiesScript)
|
||
add_child(node)
|
||
return node
|
||
|
||
|
||
# -- Z-layer ordering (4) -----------------------------------------------------
|
||
|
||
func test_z_floor_below_ysort() -> void:
|
||
# P3-Z01: FloorTiles (z:0) renders below YSortGroup (z:100).
|
||
var inst := _make_scene()
|
||
var floor_tiles = inst.get_node("World/FogGroup/FloorTiles")
|
||
var ysort = inst.get_node("World/FogGroup/YSortGroup")
|
||
assert_that(floor_tiles.z_index).override_failure_message(
|
||
"FloorTiles z_index must be Z_FLOOR (%d)" % Constants.Z_FLOOR
|
||
).is_equal(Constants.Z_FLOOR)
|
||
assert_that(ysort.z_index).override_failure_message(
|
||
"YSortGroup z_index must be Z_YSORT (%d)" % Constants.Z_YSORT
|
||
).is_equal(Constants.Z_YSORT)
|
||
assert_that(floor_tiles.z_index < ysort.z_index).is_true()
|
||
|
||
|
||
func test_z_entities_inside_ysort_at_zero() -> void:
|
||
# P3-Z02: Entities node lives inside YSortGroup with z_index = 0.
|
||
# Y-sort contract: children of YSortGroup must use z_index = 0.
|
||
var inst := _make_scene()
|
||
var entities = inst.get_node("World/FogGroup/YSortGroup/Entities")
|
||
assert_that(entities.z_index).override_failure_message(
|
||
"Entities z_index must be 0 inside YSortGroup (y-sort contract)"
|
||
).is_equal(0)
|
||
assert_that(entities.get_parent().y_sort_enabled).override_failure_message(
|
||
"Entities parent must have y_sort_enabled"
|
||
).is_true()
|
||
|
||
|
||
func test_z_fog_above_world_content() -> void:
|
||
# P3-Z03: FogOverlay (z:900) renders above all world content including
|
||
# YSortGroup (z:100) and Overhead (z:300).
|
||
# Spec #34: FogOverlay z_index == Z_FOG (900).
|
||
# Spec #35: FogEntities z_index == Z_FOG_ENTITIES (950).
|
||
var inst := _make_scene()
|
||
var fog = inst.get_node("World/FogOverlay")
|
||
var fog_entities = inst.get_node("World/FogEntities")
|
||
var fog_group = inst.get_node("World/FogGroup")
|
||
var overhead = inst.get_node("World/FogGroup/Overhead")
|
||
# FogOverlay must be a sibling of FogGroup (both children of World),
|
||
# not a child of FogGroup — fog renders OVER the composited group.
|
||
assert_that(fog.get_parent()).override_failure_message(
|
||
"FogOverlay must be sibling of FogGroup (both under World)"
|
||
).is_equal(fog_group.get_parent())
|
||
assert_that(fog.z_index).override_failure_message(
|
||
"FogOverlay z_index must be Z_FOG (%d) per D-049" % Constants.Z_FOG
|
||
).is_equal(Constants.Z_FOG)
|
||
assert_that(fog_entities.z_index).override_failure_message(
|
||
"FogEntities z_index must be Z_FOG_ENTITIES (%d) per D-049/D-059" % Constants.Z_FOG_ENTITIES
|
||
).is_equal(Constants.Z_FOG_ENTITIES)
|
||
assert_that(fog.z_index > overhead.z_index).override_failure_message(
|
||
"FogOverlay (z:%d) must render above Overhead (z:%d)" % [fog.z_index, overhead.z_index]
|
||
).is_true()
|
||
assert_that(fog_entities.z_index > fog.z_index).override_failure_message(
|
||
"FogEntities (z:%d) must render above FogOverlay (z:%d)" % [fog_entities.z_index, fog.z_index]
|
||
).is_true()
|
||
|
||
|
||
func test_z_ui_layer_above_world() -> void:
|
||
# P3-Z04: UILayer (CanvasLayer 20) renders above InsertOverlay (CanvasLayer 10)
|
||
# and both render above world content.
|
||
var inst := _make_scene()
|
||
var ui_layer = inst.get_node("UILayer") as CanvasLayer
|
||
var insert_layer = inst.get_node("InsertOverlay") as CanvasLayer
|
||
var modal_layer = inst.get_node("MetaLayer") as CanvasLayer
|
||
assert_that(insert_layer.layer).override_failure_message(
|
||
"InsertOverlay must be CanvasLayer %d" % Constants.CANVAS_INSERT
|
||
).is_equal(Constants.CANVAS_INSERT)
|
||
assert_that(ui_layer.layer).override_failure_message(
|
||
"UILayer must be CanvasLayer %d" % Constants.CANVAS_UI
|
||
).is_equal(Constants.CANVAS_UI)
|
||
assert_that(modal_layer.layer).override_failure_message(
|
||
"MetaLayer must be CanvasLayer %d" % Constants.CANVAS_MODAL
|
||
).is_equal(Constants.CANVAS_MODAL)
|
||
assert_that(ui_layer.layer > insert_layer.layer).override_failure_message(
|
||
"UILayer must render above InsertOverlay"
|
||
).is_true()
|
||
assert_that(modal_layer.layer > ui_layer.layer).override_failure_message(
|
||
"MetaLayer must render above UILayer"
|
||
).is_true()
|
||
|
||
|
||
# -- Entity lerp (3) ----------------------------------------------------------
|
||
|
||
func test_entity_snap_on_first_appear() -> void:
|
||
# P3-L01: Entity spawns at its position immediately — no lerp on first appear.
|
||
var renderer := _make_entity_renderer()
|
||
var entity := [{"entity_id": 10, "x": 8.0, "y": 6.0, "z": 0,
|
||
"kind": {"variant": "Npc", "data": null}}]
|
||
renderer.update_entities(entity)
|
||
var node = renderer.entity_nodes[10]
|
||
var expected := Vector2(
|
||
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)"
|
||
).is_equal(expected)
|
||
renderer.queue_free()
|
||
|
||
|
||
func test_entity_lerp_moves_toward_target() -> void:
|
||
# P3-L02: After updating target position, entity moves toward it over time.
|
||
var renderer := _make_entity_renderer()
|
||
# Spawn at (5, 5)
|
||
var entity := [{"entity_id": 11, "x": 5.0, "y": 5.0, "z": 0,
|
||
"kind": {"variant": "Npc", "data": null}}]
|
||
renderer.update_entities(entity)
|
||
var node: Sprite2D = renderer.entity_nodes[11]
|
||
var start_pos: Vector2 = node.position
|
||
# Move target to (6, 5)
|
||
var entity_moved := [{"entity_id": 11, "x": 6.0, "y": 5.0, "z": 0,
|
||
"kind": {"variant": "Npc", "data": null}}]
|
||
renderer.update_entities(entity_moved)
|
||
# Process several frames — entity should move toward target
|
||
renderer._process(0.016)
|
||
renderer._process(0.016)
|
||
var after_pos: Vector2 = node.position
|
||
var target := Vector2(
|
||
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(
|
||
"Entity x should move toward target after _process"
|
||
).is_true()
|
||
# But should not have snapped — still in transit
|
||
assert_that(after_pos.x < target.x).override_failure_message(
|
||
"Entity should still be in transit after 2 frames"
|
||
).is_true()
|
||
renderer.queue_free()
|
||
|
||
|
||
func test_entity_lerp_converges_within_300ms() -> void:
|
||
# P3-L03: At LERP_SPEED=12.0, entity converges within ~0.3s.
|
||
# At 12.0: weight = 1.0 - exp(-12.0 * 0.3) ≈ 0.973 — 97% there.
|
||
var renderer := _make_entity_renderer()
|
||
# Spawn at (5, 5)
|
||
var entity := [{"entity_id": 12, "x": 5.0, "y": 5.0, "z": 0,
|
||
"kind": {"variant": "Npc", "data": null}}]
|
||
renderer.update_entities(entity)
|
||
# Move target to (7, 5) — 2 tiles
|
||
var entity_moved := [{"entity_id": 12, "x": 7.0, "y": 5.0, "z": 0,
|
||
"kind": {"variant": "Npc", "data": null}}]
|
||
renderer.update_entities(entity_moved)
|
||
var target := Vector2(
|
||
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:
|
||
renderer._process(0.016)
|
||
var final_node: Sprite2D = renderer.entity_nodes[12]
|
||
var final_pos: Vector2 = final_node.position
|
||
# Should be within 5% of target (97% convergence at 0.3s)
|
||
var dist: float = final_pos.distance_to(target)
|
||
var total_dist: float = 2.0 * Constants.TILE_SIZE
|
||
assert_that(dist / total_dist < 0.05).override_failure_message(
|
||
"Entity should be within 5%% of target after 0.3s (dist: %.1f / %.1f)" % [dist, total_dist]
|
||
).is_true()
|
||
renderer.queue_free()
|
||
|
||
|
||
# -- Tyre additions (5) -------------------------------------------------------
|
||
|
||
func test_recognition_transition_progress() -> void:
|
||
# P3-T01: Recognition progress calculated correctly from remaining/total ticks.
|
||
# Grey blob → colored entity over total_delay_ticks.
|
||
var fog_entities := _make_fog_entities()
|
||
var saved := GameState.pending_recognitions
|
||
# 3 remaining out of 10 total → progress 0.7
|
||
GameState.pending_recognitions = [{
|
||
"entity_id": 200, "x": 5.0, "y": 5.0, "z": 0,
|
||
"remaining_ticks": 3, "total_delay_ticks": 10,
|
||
}]
|
||
fog_entities.update_from_state()
|
||
var blob: Dictionary = fog_entities._entities[200]
|
||
assert_that(blob.progress).override_failure_message(
|
||
"Progress should be 0.7 (1.0 - 3/10)"
|
||
).is_equal_approx(0.7, 0.01)
|
||
# 0 remaining → fully recognized (progress 1.0)
|
||
GameState.pending_recognitions = [{
|
||
"entity_id": 200, "x": 5.0, "y": 5.0, "z": 0,
|
||
"remaining_ticks": 0, "total_delay_ticks": 10,
|
||
}]
|
||
fog_entities.update_from_state()
|
||
assert_that(fog_entities._entities[200].progress).override_failure_message(
|
||
"Zero remaining should be progress 1.0"
|
||
).is_equal_approx(1.0, 0.01)
|
||
GameState.pending_recognitions = saved
|
||
fog_entities.queue_free()
|
||
|
||
|
||
func test_facing_indicator_rotation_matches_input_mapper_angle() -> void:
|
||
# P3-T02: D-054 — Facing indicator uses InputMapper.facing_angle (client-side float).
|
||
# Indicator rotation = facing_angle + PI/2 (0=North basis).
|
||
GameState.player_entity_id = 1
|
||
var renderer := _make_entity_renderer()
|
||
var entity := [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0,
|
||
"kind": {"variant": "Player", "data": null}, "visibility": "Forward"}]
|
||
renderer.update_entities(entity)
|
||
var indicator = renderer.entity_nodes[1].get_node("FacingIndicator")
|
||
# {facing_angle → expected indicator rotation}
|
||
var angles := {
|
||
-PI / 2.0: 0.0, # North
|
||
0.0: PI / 2.0, # East
|
||
PI / 2.0: PI, # South
|
||
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2 by Godot)
|
||
}
|
||
for angle in angles:
|
||
InputMapper.facing_angle = angle
|
||
renderer.update_entities(entity)
|
||
var diff := absf(angle_difference(indicator.rotation, angles[angle]))
|
||
assert_that(diff).override_failure_message(
|
||
"angle %.3f: expected rotation %.3f, got %.3f (diff %.4f)" % [angle, angles[angle], indicator.rotation, diff]
|
||
).is_less_equal(0.001)
|
||
InputMapper.facing_angle = -PI / 2.0 # Reset to default
|
||
renderer.queue_free()
|
||
|
||
|
||
func test_lerp_weight_increases_with_delta() -> void:
|
||
# P3-T03: Sprint snappiness — larger delta → larger lerp weight → faster arrival.
|
||
# Exponential smoothing: weight = 1.0 - exp(-LERP_SPEED * delta).
|
||
# Higher delta (or higher lerp multiplier) means more progress per frame.
|
||
var renderer := _make_entity_renderer()
|
||
# Spawn entity, then move target
|
||
var entity := [{"entity_id": 20, "x": 5.0, "y": 5.0, "z": 0,
|
||
"kind": {"variant": "Npc", "data": null}}]
|
||
renderer.update_entities(entity)
|
||
var entity_moved := [{"entity_id": 20, "x": 8.0, "y": 5.0, "z": 0,
|
||
"kind": {"variant": "Npc", "data": null}}]
|
||
renderer.update_entities(entity_moved)
|
||
# Small delta step
|
||
var small_node: Sprite2D = renderer.entity_nodes[20]
|
||
var small_start: float = small_node.position.x
|
||
renderer._process(0.008)
|
||
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_X,
|
||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||
)
|
||
# Large delta step
|
||
var large_start: float = small_node.position.x
|
||
renderer._process(0.032)
|
||
var large_progress: float = small_node.position.x - large_start
|
||
assert_that(large_progress > small_progress).override_failure_message(
|
||
"Larger delta should produce more lerp progress (%.2f vs %.2f)" % [large_progress, small_progress]
|
||
).is_true()
|
||
renderer.queue_free()
|
||
|
||
|
||
func test_recognition_blob_removed_when_absent() -> void:
|
||
# P3-T04: When a pending recognition disappears from state, FogEntities
|
||
# removes the blob.
|
||
var fog_entities := _make_fog_entities()
|
||
var saved := GameState.pending_recognitions
|
||
# Add entity
|
||
GameState.pending_recognitions = [{
|
||
"entity_id": 300, "x": 10.0, "y": 10.0, "z": 0,
|
||
"remaining_ticks": 5, "total_delay_ticks": 10,
|
||
}]
|
||
fog_entities.update_from_state()
|
||
assert_that(fog_entities._entities.size()).is_equal(1)
|
||
# Remove from state
|
||
GameState.pending_recognitions = []
|
||
fog_entities.update_from_state()
|
||
assert_that(fog_entities._entities.size()).override_failure_message(
|
||
"Blob should be removed when absent from pending_recognitions"
|
||
).is_equal(0)
|
||
GameState.pending_recognitions = saved
|
||
fog_entities.queue_free()
|
||
|
||
|
||
func test_entity_renderer_lerp_speed_constant() -> void:
|
||
# P3-T05: LERP_SPEED is tuned at 12.0 — exponential smoothing constant
|
||
# for entity visual interpolation. Pin value to prevent accidental changes.
|
||
assert_that(EntityRenderer.LERP_SPEED).override_failure_message(
|
||
"LERP_SPEED must be 12.0 (entity movement feel constant)"
|
||
).is_equal(12.0)
|