Files
stickman/tests/test_phase19_starter_scene_composition.gd
T
ryan 0b1dda2e2f Add Phase 19 Starter Scene Composition UI/UX Specification and Tests
- Introduced a new UI/UX specification document for the Phase 19 Starter Scene Composition, detailing user paths, composition rules for Town Street and Living Room, camera framing, and observable acceptance outcomes.
- Implemented a visual capture test for different sky states in the sandbox stage, ensuring proper rendering and visual consistency.
- Created a comprehensive test suite for the Phase 19 starter scene composition, validating scene loading, geometry, navigation, and interaction behaviors for both Town Street and Living Room.
- Added checks for visual alignment, object placements, and scene transitions to ensure adherence to the new UI/UX specifications.
2026-10-21 17:46:58 -04:00

409 lines
25 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends SceneTree
const STAGE_SCENE_IO: Script = preload("res://scripts/stage/stage_scene_io.gd")
const STAGE_SCENE_ADAPTER: Script = preload("res://scripts/editors/stage/stage_scene_adapter.gd")
const STAGE_SPAWNER: Script = preload("res://scripts/stage/stage_spawner.gd")
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
const TERRAIN_BLOCK: Script = preload("res://scripts/terrain/terrain_block.gd")
const STAGE_SCENERY_ARTWORK: Script = preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
const STAGE_BACKGROUND_GROUND: Script = preload("res://scripts/stage/scenery/stage_background_ground.gd")
const STAGE_WALL_PANEL: Script = preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const STAGE_STARTER_CARD_PREVIEW: Script = preload("res://scripts/stage/scenery/stage_starter_card_preview.gd")
const STICKMAN_LOCOMOTION: Script = preload("res://scripts/characters/stickman/stickman_locomotion.gd")
const TOWN_PATH: String = "res://content/stages/starters/town_street.scene"
const LIVING_ROOM_PATH: String = "res://content/stages/starters/living_room.scene"
const REFERENCE_VIEWPORT: Vector2 = Vector2(1280.0, 720.0)
const SAVED_ZOOM: float = 0.5
const PAN_TRAVEL: float = 1000.0
const SURFACE_HALF_WIDTH: float = 2400.0
const ASPHALT_HALF_WIDTH: float = SURFACE_HALF_WIDTH + TILE_WIDTH
const TILE_WIDTH: float = 192.0
const TILE_HEIGHT: float = 32.0
const TILE_COUNT: int = 25
const TILE_SEAM_X: float = TILE_WIDTH * 0.5
const SURFACE_TOP_LINE: float = -16.0
const LIVING_ROOM_FLOOR_TOP: float = 184.0
const CONTACT_GAP_LOGICAL_PIXELS: float = 3.0
var _failures: Array[String] = []
var _assertion_count: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_preview_transform()
await _test_scene(TOWN_PATH, true)
await _test_scene(LIVING_ROOM_PATH, false)
for failure: String in _failures:
push_error("FAIL: " + failure)
if _failures.is_empty():
print("PASS: Phase 19 starter composition (%d assertions)" % _assertion_count)
else:
print("FAIL: Phase 19 starter composition (%d/%d failures)" % [_failures.size(), _assertion_count])
quit(1 if not _failures.is_empty() else 0)
func _test_preview_transform() -> void:
var preview_frame: Dictionary = STAGE_STARTER_CARD_PREVIEW._preview_transform_for_camera({
"position": {"x": 125.0, "y": -75.0},
"zoom": SAVED_ZOOM,
})
var focus_rect: Rect2 = preview_frame["focus_rect"] as Rect2
var expected_extent: float = REFERENCE_VIEWPORT.y / SAVED_ZOOM
_expect(focus_rect.size.is_equal_approx(Vector2.ONE * expected_extent), "starter preview uses a 1,440-unit square camera crop")
_expect(focus_rect.get_center().is_equal_approx(Vector2(125.0, -75.0)), "starter preview crop follows the saved camera center")
_expect(is_equal_approx(float(preview_frame["scale"]), 220.0 / expected_extent), "starter preview fits the camera crop into the 220-pixel usable area")
var centered_position: Vector2 = preview_frame["position"] as Vector2
_expect((centered_position + Vector2(125.0, -75.0) * float(preview_frame["scale"])).is_equal_approx(Vector2(128.0, 128.0)), "starter preview centers the saved camera without changing it")
var bad_zoom_frame: Dictionary = STAGE_STARTER_CARD_PREVIEW._preview_transform_for_camera({"zoom": 0.0})
_expect(is_equal_approx(float(bad_zoom_frame["focus_rect"].size.x), expected_extent), "starter preview safely falls back for an invalid saved zoom")
func _test_scene(path: String, is_town: bool) -> void:
var read_result: Dictionary = STAGE_SCENE_IO.read_file(path)
_expect(bool(read_result.get("ok", false)), "starter snapshot loads through StageSceneIO: %s" % path)
if not bool(read_result.get("ok", false)):
return
var data: Dictionary = read_result["data"] as Dictionary
_expect(String(data.get("version", "")) == STAGE_SCENE_IO.FILE_VERSION, "starter snapshot uses current Stage format: %s" % path)
var camera: Dictionary = data.get("camera", {}) as Dictionary
var camera_position: Vector2 = STAGE_SCENE_IO.data_to_vector(camera.get("position"), Vector2.ZERO)
_expect(is_equal_approx(float(camera.get("zoom", 0.0)), SAVED_ZOOM), "starter saved opening zoom is 0.5: %s" % path)
_expect(is_equal_approx(camera_position.x, 0.0), "starter saved opening camera remains centered horizontally: %s" % path)
_test_round_trip(path, data)
await _test_production_adapter(path, data, camera_position, is_town)
await _test_play_support(path, is_town)
func _test_round_trip(path: String, data: Dictionary) -> void:
var temp_path: String = "/private/tmp/phase19_composition_%s.scene" % path.get_file().get_basename()
var write_result: Dictionary = STAGE_SCENE_IO.write_file_safe(temp_path, data)
var read_result: Dictionary = STAGE_SCENE_IO.read_file(temp_path) if bool(write_result.get("ok", false)) else {}
_expect(bool(write_result.get("ok", false)), "starter snapshot writes through ordinary scene serializer: %s" % path)
_expect(bool(read_result.get("ok", false)), "saved starter snapshot reopens through ordinary scene loader: %s" % path)
if bool(read_result.get("ok", false)):
var reopened: Dictionary = read_result["data"] as Dictionary
_expect(reopened.get("objects", []) == data.get("objects", []), "starter placements and finishes round-trip unchanged: %s" % path)
_expect(reopened.get("camera", {}) == data.get("camera", {}), "starter opening camera round-trips unchanged: %s" % path)
var absolute_path: String = ProjectSettings.globalize_path(temp_path)
if FileAccess.file_exists(absolute_path):
DirAccess.remove_absolute(absolute_path)
func _test_production_adapter(path: String, data: Dictionary, camera_position: Vector2, is_town: bool) -> void:
var world: Node2D = Node2D.new()
world.name = "CompositionTestWorld"
root.add_child(world)
var spawner: StageSpawner = STAGE_SPAWNER.new(world) as StageSpawner
var adapter: StageSceneAdapter = STAGE_SCENE_ADAPTER.new() as StageSceneAdapter
var applied: Dictionary = adapter.apply(data, world, spawner)
await process_frame
var loaded_nodes: Array[Node2D] = applied.get("loaded_nodes", []) as Array[Node2D]
_expect(loaded_nodes.size() == (data.get("objects", []) as Array).size(), "production adapter creates all saved starter objects: %s" % path)
_expect((applied.get("warnings", []) as Array).is_empty(), "production adapter loads starter objects without warnings: %s" % path)
if is_town:
_test_town_geometry(data, loaded_nodes, camera_position)
else:
_test_living_room_geometry(data, loaded_nodes, camera_position)
world.queue_free()
await process_frame
func _test_play_support(path: String, is_town: bool) -> void:
var stage: SandboxStage = SANDBOX_STAGE.instantiate() as SandboxStage
root.add_child(stage)
await process_frame
await process_frame
stage.set("_cli_startup", true)
var loaded: bool = bool(stage.call("_load_scene_from_path", path, false))
_expect(loaded, "starter scene loads through the production SandboxStage path before PLAY: %s" % path)
if not loaded:
stage.queue_free()
await process_frame
return
var spawner: StageSpawner = stage.get("_spawner") as StageSpawner
var support_y: float = SURFACE_TOP_LINE if is_town else LIVING_ROOM_FLOOR_TOP
await physics_frame
await physics_frame
_test_navigation_seam_path(stage, support_y, path.get_file().get_basename())
if is_town:
_test_navigation_seam_path(stage, 16.0, "town_street_asphalt")
await _test_town_navigation_level_transition(stage)
var actor: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/test.stk", Vector2(TILE_SEAM_X, support_y - 385.0))
_expect(actor != null, "standard stickman spawns on a starter floor tile seam")
if actor != null:
_expect(is_equal_approx(actor.global_position.x, TILE_SEAM_X), "PLAY support fixture is centered on the shared tile seam")
stage.call("_save_object_state", actor)
stage.call("set_mode", SandboxStage.StageMode.PLAY)
for _frame: int in range(48):
await physics_frame
var locomotion: StickmanLocomotion = actor.get_locomotion()
var grounded: bool = locomotion != null and locomotion.terrain_motion == STICKMAN_LOCOMOTION.TerrainMotion.GROUNDED
print("METRIC PLAY support scene=", path.get_file().get_basename(), " actor_root_y=", actor.global_position.y, " target_floor_y=", support_y, " terrain_motion=", locomotion.terrain_motion if locomotion != null else -1)
_expect(grounded, "standard stickman reaches the starter walk line in PLAY: %s" % path)
actor.speak("Ready for a story!\nLet's begin.", 20.0)
await process_frame
var bubble: Node = actor.find_child("SpeechBubble", true, false)
_expect(bubble != null and bubble.is_visible_in_tree(), "two-line starter speech bubble appears in PLAY: %s" % path)
if is_town and grounded:
await _test_town_physical_step_down(actor)
stage.call("set_mode", SandboxStage.StageMode.EDIT)
stage.queue_free()
await process_frame
func _test_navigation_seam_path(stage: SandboxStage, walk_y: float, label: String) -> void:
var navigation_region: NavigationRegion2D = stage.get_node_or_null("NavigationRegion2D") as NavigationRegion2D
_expect(navigation_region != null, "%s production stage has its navigation region" % label)
if navigation_region == null:
return
var navigation_map: RID = navigation_region.get_navigation_map()
NavigationServer2D.map_force_update(navigation_map)
await physics_frame
NavigationServer2D.map_force_update(navigation_map)
var start: Vector2 = Vector2(-2208.0, walk_y)
var finish: Vector2 = Vector2(2208.0, walk_y)
var path: PackedVector2Array = NavigationServer2D.map_get_path(navigation_map, start, finish, true)
print("METRIC Navigation seam traversal scene=", label, " region_position=", navigation_region.global_position, " polygons=", navigation_region.navigation_polygon.get_polygon_count(), " vertices=", navigation_region.navigation_polygon.get_vertices(), " regions=", NavigationServer2D.map_get_regions(navigation_map).size(), " region_enabled=", navigation_region.enabled, " nearest_start=", NavigationServer2D.map_get_closest_point(navigation_map, start), " nearest_center=", NavigationServer2D.map_get_closest_point(navigation_map, Vector2(0.0, walk_y)), " nearest_end=", NavigationServer2D.map_get_closest_point(navigation_map, finish), " iteration=", NavigationServer2D.map_get_iteration_id(navigation_map), " path_points=", path.size(), " path=", path)
_expect(NavigationServer2D.map_get_iteration_id(navigation_map) > 0, "%s navigation map synchronizes" % label)
_expect(path.size() >= 2 and path[0].x <= 0.0 and path[path.size() - 1].x >= 0.0, "%s navigation path traverses multiple tile seams" % label)
func _test_town_navigation_level_transition(stage: SandboxStage) -> void:
var navigation_region: NavigationRegion2D = stage.get_node_or_null("NavigationRegion2D") as NavigationRegion2D
if navigation_region == null:
return
var navigation_map: RID = navigation_region.get_navigation_map()
NavigationServer2D.map_force_update(navigation_map)
await physics_frame
NavigationServer2D.map_force_update(navigation_map)
var transition_path: PackedVector2Array = NavigationServer2D.map_get_path(
navigation_map,
Vector2(0.0, SURFACE_TOP_LINE),
Vector2(0.0, 16.0),
true
)
print("METRIC Town Street sidewalk-to-asphalt transition path_points=", transition_path.size(), " path=", transition_path)
var uses_exposed_endpoint: bool = false
var reaches_asphalt_level: bool = false
for point: Vector2 in transition_path:
uses_exposed_endpoint = uses_exposed_endpoint or absf(point.x) >= SURFACE_HALF_WIDTH - 32.0
reaches_asphalt_level = reaches_asphalt_level or point.y >= 16.0 - 0.1
_expect(transition_path.size() >= 3, "Town Street navigation connects sidewalk and asphalt through an exposed endpoint")
_expect(uses_exposed_endpoint, "Town Street navigation transition uses a sidewalk endpoint connector")
_expect(reaches_asphalt_level, "Town Street navigation route reaches the lower Asphalt walk line")
func _test_town_physical_step_down(actor: StickmanRig) -> void:
var locomotion: StickmanLocomotion = actor.get_locomotion()
if locomotion == null:
_expect(false, "Town Street physical step-down fixture has stickman locomotion")
return
actor.global_position.x = 2280.0
for _frame: int in range(4):
await physics_frame
var starts_grounded: bool = locomotion.terrain_motion == STICKMAN_LOCOMOTION.TerrainMotion.GROUNDED
_expect(starts_grounded, "Town Street step-down actor starts grounded near the sidewalk endpoint")
var walk_started: bool = locomotion.walk_to(Vector2(2500.0, 16.0), 300.0)
_expect(walk_started, "Town Street actor can walk from sidewalk onto the extended Asphalt row")
for _frame: int in range(180):
if not locomotion.is_walking() and locomotion.terrain_motion == STICKMAN_LOCOMOTION.TerrainMotion.GROUNDED:
break
await physics_frame
var foot_target_y: float = actor.global_position.y - locomotion.get_foot_offset().y
var reaches_road: bool = absf(actor.global_position.x - 2500.0) <= 8.0 and absf(foot_target_y - 16.0) <= 8.0
var grounded_on_road: bool = locomotion.terrain_motion == STICKMAN_LOCOMOTION.TerrainMotion.GROUNDED
print("METRIC Town Street physical step-down x=", actor.global_position.x, " feet_y=", foot_target_y, " terrain_motion=", locomotion.terrain_motion, " walking=", locomotion.is_walking())
_expect(reaches_road and grounded_on_road, "Town Street actor physically reaches and stands on Asphalt below the sidewalk")
func _test_town_geometry(data: Dictionary, nodes: Array[Node2D], camera_position: Vector2) -> void:
var sidewalk_tiles: Array[TerrainBlock] = _find_terrain_tiles(nodes, &"finish_ground_sidewalk")
var asphalt_tiles: Array[TerrainBlock] = _find_terrain_tiles(nodes, &"finish_ground_asphalt")
_expect(sidewalk_tiles.size() == TILE_COUNT and asphalt_tiles.size() == TILE_COUNT + 2, "Town Street keeps 25 separate sidewalk tiles and extends Asphalt with two ordinary endpoint tiles")
var sidewalk_bounds: Rect2 = _test_tile_row(sidewalk_tiles, 0.0, "Town Street sidewalk")
var asphalt_bounds: Rect2 = _test_tile_row(asphalt_tiles, 32.0, "Town Street asphalt", TILE_COUNT + 2, ASPHALT_HALF_WIDTH)
print("METRIC Town Street sidewalk_bounds=", sidewalk_bounds, " asphalt_bounds=", asphalt_bounds, " endpoint_world_margin=", SURFACE_HALF_WIDTH - (PAN_TRAVEL + REFERENCE_VIEWPORT.x / (2.0 * SAVED_ZOOM)))
_expect(is_equal_approx(sidewalk_bounds.position.y, SURFACE_TOP_LINE), "Town Street sidewalk top remains the intended walk line")
_expect(is_equal_approx(asphalt_bounds.position.y, sidewalk_bounds.end.y), "Town Street asphalt row touches the sidewalk row with no vertical gap")
var background: StageBackgroundGround = _find_background_ground(nodes)
_expect(background != null, "Town Street includes a non-walkable Grass backdrop behind the set")
if background != null:
var backdrop_bounds: Rect2 = background.global_transform * background.get_local_bounds()
var skyline_bottom: float = -INF
var skyline_count: int = 0
for node: Node2D in nodes:
if node.get_script() != STAGE_SCENERY_ARTWORK:
continue
var skyline: StageSceneryArtwork = node as StageSceneryArtwork
if skyline.display_name != "City skyline":
continue
skyline_count += 1
skyline_bottom = maxf(skyline_bottom, skyline.get_visual_world_bounds().end.y)
_expect(background.z_index < skyline.z_index, "Town Street Grass draws behind the skyline")
_expect(skyline_count == 3, "Town Street keeps three skyline pieces across the opening")
_expect(background.finish_id == &"finish_background_grass", "Town Street backdrop uses the approved outdoor Grass finish")
_expect(backdrop_bounds.size.is_equal_approx(Vector2(SURFACE_HALF_WIDTH * 2.0, 2000.0)), "Town Street backdrop spans 4,800 world units at authored size")
_expect(is_equal_approx(backdrop_bounds.position.y, -340.0), "Town Street backdrop starts slightly above the skyline base")
_expect(skyline_bottom > -INF and backdrop_bounds.position.y <= skyline_bottom - 15.0, "Town Street backdrop overlaps the measured skyline base to prevent a Sky seam")
_expect(backdrop_bounds.position.x <= -SURFACE_HALF_WIDTH and backdrop_bounds.end.x >= SURFACE_HALF_WIDTH, "Town Street backdrop covers the full pan envelope")
_expect(backdrop_bounds.end.y > camera_position.y + REFERENCE_VIEWPORT.y / SAVED_ZOOM, "Town Street backdrop extends below the opening viewport")
_expect(background.z_index < (sidewalk_tiles[0] as TerrainBlock).z_index, "Town Street Grass draws behind walkable Ground")
var expected_assets: Array[String] = ["Town house", "Storefront", "Tree"]
var seen_assets: Dictionary = {}
var clear_lane: Rect2 = Rect2(Vector2(-REFERENCE_VIEWPORT.x / 6.0, -10000.0), Vector2(REFERENCE_VIEWPORT.x / 3.0, 20000.0))
for node: Node2D in nodes:
if node.get_script() != STAGE_SCENERY_ARTWORK:
continue
var artwork: StageSceneryArtwork = node as StageSceneryArtwork
if artwork.depth != &"near":
continue
var bounds: Rect2 = artwork.get_visual_world_bounds()
var logical_gap: float = absf(bounds.end.y - sidewalk_bounds.position.y) * SAVED_ZOOM
print("METRIC Town Street contact name=", artwork.display_name, " art_bottom_y=", bounds.end.y, " surface_top_y=", sidewalk_bounds.position.y, " logical_gap_px=", logical_gap)
_expect(logical_gap <= CONTACT_GAP_LOGICAL_PIXELS, "Town Street %s visually meets the sidewalk within 3 logical pixels (%.2f px)" % [artwork.display_name, logical_gap])
_expect(not bounds.intersects(clear_lane), "Town Street %s leaves the middle-third action lane open" % artwork.display_name)
seen_assets[artwork.display_name] = true
for artwork_name: String in expected_assets:
_expect(seen_assets.has(artwork_name), "Town Street keeps editable near artwork: %s" % artwork_name)
var preview_frame: Dictionary = STAGE_STARTER_CARD_PREVIEW._preview_transform_for_camera(data.get("camera", {}) as Dictionary)
_expect((preview_frame["focus_rect"] as Rect2).get_center().is_equal_approx(camera_position), "Town Street starter preview centers on its saved opening shot")
func _test_living_room_geometry(data: Dictionary, nodes: Array[Node2D], camera_position: Vector2) -> void:
var floor_tiles: Array[TerrainBlock] = _find_terrain_tiles(nodes, &"finish_ground_wood")
_expect(floor_tiles.size() == TILE_COUNT, "Living Room has a 25-tile Wood support row")
var floor_bounds: Rect2 = _test_tile_row(floor_tiles, 200.0, "Living Room Wood floor")
print("METRIC Living Room floor_bounds=", floor_bounds, " endpoint_world_margin=", SURFACE_HALF_WIDTH - (PAN_TRAVEL + REFERENCE_VIEWPORT.x / (2.0 * SAVED_ZOOM)))
_expect(is_equal_approx(floor_bounds.position.y, LIVING_ROOM_FLOOR_TOP), "Living Room Wood floor keeps one level collision top")
var wall_rects: Array[Rect2] = []
for node: Node2D in nodes:
if node.get_script() == STAGE_WALL_PANEL:
var wall: StageWallPanel = node as StageWallPanel
wall_rects.append(wall.global_transform * wall.get_local_bounds())
_expect(wall_rects.size() == 5, "Living Room uses editable wall panels around the window opening")
var aperture_points: Array[Vector2] = [Vector2(0.0, -550.0), Vector2(0.0, -600.0), Vector2(0.0, -410.0)]
print("METRIC Living Room window_gap_x=[-145,145] y=[-682,-402]")
for point: Vector2 in aperture_points:
var blocked: bool = false
for wall_rect: Rect2 in wall_rects:
blocked = blocked or wall_rect.has_point(point)
_expect(not blocked, "Living Room window remains a true wall-panel opening at %s" % str(point))
for camera_x: float in [-PAN_TRAVEL, 0.0, PAN_TRAVEL]:
var viewport_left: float = camera_x - REFERENCE_VIEWPORT.x / (2.0 * SAVED_ZOOM)
var viewport_right: float = camera_x + REFERENCE_VIEWPORT.x / (2.0 * SAVED_ZOOM)
for screen_step: int in range(17):
var x: float = lerpf(viewport_left, viewport_right, float(screen_step) / 16.0)
_expect(_rect_union_covers_point(wall_rects, Vector2(x, -800.0)), "Living Room top wall covers the visible width at pan %.0f" % camera_x)
_expect(_rect_union_covers_point(wall_rects, Vector2(x, 100.0)), "Living Room side wall covers the visible width at pan %.0f" % camera_x)
var expected_grounded_art: Array[String] = ["Bookshelf", "Fireplace", "Floor rug", "Potted plant"]
var seen_grounded_art: Dictionary = {}
for node: Node2D in nodes:
if node.get_script() != STAGE_SCENERY_ARTWORK:
continue
var artwork: StageSceneryArtwork = node as StageSceneryArtwork
if not expected_grounded_art.has(artwork.display_name):
continue
var bounds: Rect2 = artwork.get_visual_world_bounds()
var logical_gap: float = absf(bounds.end.y - floor_bounds.position.y) * SAVED_ZOOM
print("METRIC Living Room scenery_contact name=", artwork.display_name, " art_bottom_y=", bounds.end.y, " floor_top_y=", floor_bounds.position.y, " logical_gap_px=", logical_gap)
_expect(logical_gap <= CONTACT_GAP_LOGICAL_PIXELS, "Living Room %s visually meets the floor within 3 logical pixels (%.2f px)" % [artwork.display_name, logical_gap])
seen_grounded_art[artwork.display_name] = true
for artwork_name: String in expected_grounded_art:
_expect(seen_grounded_art.has(artwork_name), "Living Room retains editable set artwork: %s" % artwork_name)
var prop_count: int = 0
for node: Node2D in nodes:
if not node is PropBlock:
continue
prop_count += 1
var prop: PropBlock = node as PropBlock
var bounds: Rect2 = prop.global_transform * prop.get_visual_local_bounds()
var logical_gap: float = absf(bounds.end.y - floor_bounds.position.y) * SAVED_ZOOM
print("METRIC Living Room prop_contact name=", prop.stage_object_id, " prop_bottom_y=", bounds.end.y, " floor_top_y=", floor_bounds.position.y, " logical_gap_px=", logical_gap)
_expect(logical_gap <= CONTACT_GAP_LOGICAL_PIXELS, "Living Room prop %s rests on the floor within 3 logical pixels (%.2f px)" % [prop.stage_object_id, logical_gap])
_expect(bool(prop.stage_roles.get("blocks_stickmen", false)), "Living Room prop %s preserves its normal blocking role" % prop.stage_object_id)
_expect(prop_count == 3, "Living Room keeps couch, armchair, and coffee table as separate Props")
var preview_frame: Dictionary = STAGE_STARTER_CARD_PREVIEW._preview_transform_for_camera(data.get("camera", {}) as Dictionary)
_expect((preview_frame["focus_rect"] as Rect2).get_center().is_equal_approx(camera_position), "Living Room starter preview centers on its saved opening shot")
func _find_terrain_tiles(nodes: Array[Node2D], finish_id: StringName) -> Array[TerrainBlock]:
var result: Array[TerrainBlock] = []
for node: Node2D in nodes:
if node.get_script() == TERRAIN_BLOCK and StringName((node as TerrainBlock).finish_id) == finish_id:
result.append(node as TerrainBlock)
return result
func _find_background_ground(nodes: Array[Node2D]) -> StageBackgroundGround:
for node: Node2D in nodes:
if node.get_script() == STAGE_BACKGROUND_GROUND:
return node as StageBackgroundGround
return null
func _test_tile_row(
tiles: Array[TerrainBlock],
center_y: float,
label: String,
expected_tile_count: int = TILE_COUNT,
expected_half_width: float = SURFACE_HALF_WIDTH
) -> Rect2:
if tiles.is_empty():
return Rect2()
tiles.sort_custom(_terrain_precedes)
var row_bounds: Rect2 = _terrain_world_bounds(tiles[0])
_expect(tiles.size() == expected_tile_count, "%s contains exactly %d tiles" % [label, expected_tile_count])
for tile: TerrainBlock in tiles:
var bounds: Rect2 = _terrain_world_bounds(tile)
_expect(tile.scale.is_equal_approx(Vector2.ONE), "%s tile %s uses normal scale" % [label, tile.name])
_expect(bounds.size.is_equal_approx(Vector2(TILE_WIDTH, TILE_HEIGHT)), "%s tile %s keeps the ordinary 192×32 footprint" % [label, tile.name])
_expect(is_equal_approx(bounds.get_center().y, center_y), "%s tile %s stays on the shared row level" % [label, tile.name])
row_bounds = row_bounds.merge(bounds)
for index: int in range(tiles.size() - 1):
var left_bounds: Rect2 = _terrain_world_bounds(tiles[index])
var right_bounds: Rect2 = _terrain_world_bounds(tiles[index + 1])
var seam_gap: float = right_bounds.position.x - left_bounds.end.x
_expect(is_zero_approx(seam_gap), "%s tiles meet exactly at seam %d (gap %.3f)" % [label, index + 1, seam_gap])
_expect(_covers_pan_envelope(row_bounds), "%s covers ±1,000 camera pan and at least 100-unit endpoint margins" % label)
_expect(is_equal_approx(row_bounds.position.x, -expected_half_width) and is_equal_approx(row_bounds.end.x, expected_half_width), "%s spans %.0f world units" % [label, expected_half_width * 2.0])
return row_bounds
func _terrain_precedes(left: TerrainBlock, right: TerrainBlock) -> bool:
return left.global_position.x < right.global_position.x
func _terrain_world_bounds(terrain: TerrainBlock) -> Rect2:
if terrain.polygon_points.is_empty():
return Rect2()
var bounds: Rect2 = Rect2(terrain.to_global(terrain.polygon_points[0]), Vector2.ZERO)
for point: Vector2 in terrain.polygon_points:
bounds = bounds.expand(terrain.to_global(point))
return bounds
func _covers_pan_envelope(bounds: Rect2) -> bool:
var half_view_world_width: float = REFERENCE_VIEWPORT.x / (2.0 * SAVED_ZOOM)
for camera_x: float in [-PAN_TRAVEL, 0.0, PAN_TRAVEL]:
if bounds.position.x > camera_x - half_view_world_width or bounds.end.x < camera_x + half_view_world_width:
return false
return bounds.position.x <= -SURFACE_HALF_WIDTH and bounds.end.x >= SURFACE_HALF_WIDTH
func _rect_union_covers_point(rects: Array[Rect2], point: Vector2) -> bool:
for rect: Rect2 in rects:
if rect.has_point(point):
return true
return false
func _expect(condition: bool, message: String) -> void:
_assertion_count += 1
if not condition:
_failures.append(message)