- 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.
409 lines
25 KiB
GDScript
409 lines
25 KiB
GDScript
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)
|