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.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
extends SceneTree
|
||||
|
||||
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
|
||||
const TOWN_STREET_PATH: String = "res://content/stages/starters/town_street.scene"
|
||||
const SKY_IDS: Array[StringName] = [&"sky_clear_day", &"sky_cloudy_day", &"sky_sunset", &"sky_starry_night"]
|
||||
const CAPTURE_DIR: String = "/private/tmp"
|
||||
const TEMP_SCENE_PATH: String = "res://tests/phase19_sky_camera_tmp.scene"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_capture_sky_states")
|
||||
|
||||
|
||||
func _capture_sky_states() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var stage: SandboxStage = SANDBOX_STAGE.instantiate() as SandboxStage
|
||||
root.add_child(stage)
|
||||
await _wait_for_render_frames(4)
|
||||
var town_loaded: bool = bool(stage.call("_load_scene_from_path", TOWN_STREET_PATH, false))
|
||||
print("VISUAL town scene loaded=", town_loaded)
|
||||
stage.call("_on_grid_toggled", false)
|
||||
var camera: Camera2D = stage.get_node("Camera2D") as Camera2D
|
||||
var sky_view: StageSkyView = stage.get("_sky_view") as StageSkyView
|
||||
var spawner: StageSpawner = stage.get("_spawner") as StageSpawner
|
||||
var stickman: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(0.0, -400.0))
|
||||
if stickman != null:
|
||||
stage.call("_save_object_state", stickman)
|
||||
var scenery_controller: StageSceneryController = stage.get("_scenery_controller") as StageSceneryController
|
||||
scenery_controller.begin_background_ground(&"finish_background_grass", Vector2(-900.0, 0.0), Color.WHITE)
|
||||
var ground_press: InputEventMouseButton = InputEventMouseButton.new()
|
||||
ground_press.button_index = MOUSE_BUTTON_LEFT
|
||||
ground_press.pressed = true
|
||||
scenery_controller.handle_mouse_button(ground_press, Vector2(-900.0, 0.0), false)
|
||||
var ground_release: InputEventMouseButton = InputEventMouseButton.new()
|
||||
ground_release.button_index = MOUSE_BUTTON_LEFT
|
||||
ground_release.pressed = false
|
||||
scenery_controller.handle_mouse_button(ground_release, Vector2(900.0, 900.0), false)
|
||||
scenery_controller.exit_tool()
|
||||
print("VISUAL action context stickman=", stickman != null, " background_ground_count=", (stage.get_node("World") as Node2D).get_children().filter(func(child: Node) -> bool: return child.get_script() == preload("res://scripts/stage/scenery/stage_background_ground.gd")).size())
|
||||
await _wait_for_render_frames(4)
|
||||
for sky_id: StringName in SKY_IDS:
|
||||
camera.global_position = Vector2(0.0, -400.0)
|
||||
camera.zoom = Vector2.ONE * 0.5
|
||||
stage.set("_sky_id", sky_id)
|
||||
sky_view.set_sky(sky_id)
|
||||
await _wait_for_render_frames(2)
|
||||
await _save_capture("sky_%s_reference.png" % String(sky_id))
|
||||
|
||||
stage.set("_sky_id", &"sky_sunset")
|
||||
sky_view.set_sky(&"sky_sunset")
|
||||
camera.global_position = Vector2(420.0, -210.0)
|
||||
camera.zoom = Vector2.ONE * 1.5
|
||||
await _wait_for_render_frames(3)
|
||||
await _save_capture("sky_sunset_close_pan.png")
|
||||
|
||||
camera.global_position = Vector2(-620.0, -740.0)
|
||||
camera.zoom = Vector2.ONE * 0.12
|
||||
await _wait_for_render_frames(3)
|
||||
await _save_capture("sky_sunset_wide_pan.png")
|
||||
|
||||
camera.global_position = Vector2(0.0, -400.0)
|
||||
camera.zoom = Vector2.ONE * 0.5
|
||||
await _wait_for_render_frames(3)
|
||||
root.size = Vector2i(900, 700)
|
||||
await _wait_for_render_frames(4)
|
||||
await _save_capture("sky_sunset_resize_900x700.png")
|
||||
stage.set("_sky_id", &"sky_starry_night")
|
||||
sky_view.set_sky(&"sky_starry_night")
|
||||
await _wait_for_render_frames(3)
|
||||
await _save_capture("sky_starry_night_resize_900x700.png")
|
||||
root.size = Vector2i(1440, 720)
|
||||
await _wait_for_render_frames(4)
|
||||
stage.set("_sky_id", &"sky_sunset")
|
||||
sky_view.set_sky(&"sky_sunset")
|
||||
await _wait_for_render_frames(3)
|
||||
await _save_capture("sky_sunset_resize_1440x720.png")
|
||||
stage.set("_sky_id", &"sky_starry_night")
|
||||
sky_view.set_sky(&"sky_starry_night")
|
||||
await _wait_for_render_frames(3)
|
||||
await _save_capture("sky_starry_night_resize_1440x720.png")
|
||||
root.size = Vector2i(1280, 720)
|
||||
stage.set("_sky_id", &"sky_sunset")
|
||||
sky_view.set_sky(&"sky_sunset")
|
||||
|
||||
stage.set("_cli_startup", true)
|
||||
stage.call("set_mode", 1)
|
||||
await _wait_for_render_frames(3)
|
||||
await _save_capture("sky_sunset_direct.png")
|
||||
stage.call("set_mode", 2)
|
||||
await _wait_for_render_frames(3)
|
||||
await _save_capture("sky_sunset_play.png")
|
||||
stage.call("set_mode", 0)
|
||||
await _wait_for_render_frames(3)
|
||||
stage.set("_cli_startup", false)
|
||||
await _save_capture("sky_sunset_play_stopped_edit.png")
|
||||
|
||||
camera.global_position = Vector2(420.0, -210.0)
|
||||
camera.zoom = Vector2.ONE * 1.5
|
||||
await _wait_for_render_frames(3)
|
||||
var saved_shift: Vector2 = sky_view.get("_motif_shift")
|
||||
var saved_scale: float = float(sky_view.get("_motif_scale"))
|
||||
var saved_camera_position: Vector2 = camera.global_position
|
||||
var saved_camera_zoom: Vector2 = camera.zoom
|
||||
var saved: bool = stage.call("_save_scene_to_path", TEMP_SCENE_PATH, false)
|
||||
var reopened: bool = bool(stage.call("_load_scene_from_path", TEMP_SCENE_PATH, false)) if saved else false
|
||||
await _wait_for_render_frames(4)
|
||||
var reopened_shift: Vector2 = sky_view.get("_motif_shift")
|
||||
var reopened_scale: float = float(sky_view.get("_motif_scale"))
|
||||
print("VISUAL sky save/reopen saved=", saved, " reopened=", reopened, " camera=", saved_camera_position, "/", saved_camera_zoom, " reopened_camera=", camera.global_position, "/", camera.zoom, " shift=", saved_shift, "/", reopened_shift, " scale=", saved_scale, "/", reopened_scale, " framing_matches=", saved_shift.is_equal_approx(reopened_shift) and is_equal_approx(saved_scale, reopened_scale))
|
||||
await _save_capture("sky_sunset_save_reopen.png")
|
||||
if FileAccess.file_exists(TEMP_SCENE_PATH):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(TEMP_SCENE_PATH))
|
||||
stage.call("_load_scene_from_path", "res://content/stages/starters/blank.scene", false)
|
||||
await _wait_for_render_frames(3)
|
||||
stage.queue_free()
|
||||
await process_frame
|
||||
quit(0)
|
||||
|
||||
|
||||
func _wait_for_render_frames(count: int) -> void:
|
||||
for _frame: int in count:
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
|
||||
|
||||
func _save_capture(file_name: String) -> void:
|
||||
await RenderingServer.frame_post_draw
|
||||
var image: Image = root.get_texture().get_image()
|
||||
var path: String = CAPTURE_DIR.path_join("phase19_" + file_name)
|
||||
var error: Error = image.save_png(path)
|
||||
print("VISUAL capture=", path, " size=", image.get_size(), " error=", error)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dg0vu601trn7c
|
||||
@@ -34,6 +34,18 @@ func _run() -> void:
|
||||
print("PASS: Phase 19 Sky render capture")
|
||||
quit(0)
|
||||
return
|
||||
if OS.get_cmdline_user_args().has("--sky-camera-only"):
|
||||
_stage = SANDBOX_STAGE.instantiate() as SandboxStage
|
||||
root.add_child(_stage)
|
||||
await process_frame
|
||||
await process_frame
|
||||
await _test_live_sky_camera_binding()
|
||||
for failure: String in _failures:
|
||||
push_error("FAIL: " + failure)
|
||||
_stage.queue_free()
|
||||
await process_frame
|
||||
quit(1 if not _failures.is_empty() else 0)
|
||||
return
|
||||
for starter_path: String in STARTER_PATHS:
|
||||
var result: Dictionary = STAGE_SCENE_IO.read_file(starter_path)
|
||||
_expect(bool(result.get("ok", false)), "starter scene validates: %s" % starter_path)
|
||||
@@ -44,6 +56,7 @@ func _run() -> void:
|
||||
root.add_child(_stage)
|
||||
await process_frame
|
||||
await process_frame
|
||||
await _test_live_sky_camera_binding()
|
||||
await _test_grid_controls_layout()
|
||||
var baseline_spawner: StageSpawner = _stage.get("_spawner") as StageSpawner
|
||||
var baseline_ground: TerrainBlock = baseline_spawner.spawn_terrain_asset("ground", Vector2.ZERO)
|
||||
@@ -102,14 +115,10 @@ func _run() -> void:
|
||||
_expect(String(_stage.get("_scene_name")) == "Town Street", "choosing Town Street loads it as a named, unsaved scene")
|
||||
_expect(String(_stage.get("_sky_id")) == "sky_cloudy_day", "Town Street starter selects the approved cloudy sky")
|
||||
var world: Node2D = _stage.get_node("World") as Node2D
|
||||
var terrain_finish_count: int = 0
|
||||
var scaled_terrain_count: int = 0
|
||||
for child: Node in world.get_children():
|
||||
if child is TerrainBlock and String((child as TerrainBlock).finish_id) in ["finish_ground_sidewalk", "finish_ground_asphalt"]:
|
||||
terrain_finish_count += 1
|
||||
if is_equal_approx((child as TerrainBlock).scale.x, 7.0):
|
||||
scaled_terrain_count += 1
|
||||
_expect(terrain_finish_count == 2 and scaled_terrain_count == 2, "Town Street uses full-width sidewalk and asphalt strips")
|
||||
var sidewalk_bounds: Rect2 = _test_starter_surface_row(world, &"finish_ground_sidewalk", -16.0, "Town Street sidewalk")
|
||||
var road_bounds: Rect2 = _test_starter_surface_row(world, &"finish_ground_asphalt", 16.0, "Town Street asphalt", 27, 2592.0)
|
||||
_expect(is_equal_approx(road_bounds.position.y, sidewalk_bounds.end.y), "Town Street asphalt row meets the sidewalk row with no vertical gap")
|
||||
_test_saved_starter_camera("res://content/stages/starters/town_street.scene", "Town Street")
|
||||
await physics_frame
|
||||
await physics_frame
|
||||
var navigation_region: NavigationRegion2D = _stage.get_node("NavigationRegion2D") as NavigationRegion2D
|
||||
@@ -273,6 +282,7 @@ func _run() -> void:
|
||||
await _test_single_scenery_transforms(world, String(artwork_entries[0].get("path", "")))
|
||||
await _test_selected_scenery_depth(world, String(artwork_entries[0].get("path", "")))
|
||||
await _test_prop_size_for_stage()
|
||||
await _test_living_room_surface(world)
|
||||
if _stage != null and is_instance_valid(_stage):
|
||||
_stage.queue_free()
|
||||
await process_frame
|
||||
@@ -286,6 +296,70 @@ func _run() -> void:
|
||||
quit(0 if _failures.is_empty() else 1)
|
||||
|
||||
|
||||
func _test_living_room_surface(world: Node2D) -> void:
|
||||
var living_path: String = "res://content/stages/starters/living_room.scene"
|
||||
var loaded: bool = bool(_stage.call("_load_scene_from_path", living_path, false))
|
||||
_expect(loaded, "Living Room loads through the ordinary Stage scene path")
|
||||
if not loaded:
|
||||
return
|
||||
_test_starter_surface_row(world, &"finish_ground_wood", 184.0, "Living Room Wood floor")
|
||||
_test_saved_starter_camera(living_path, "Living Room")
|
||||
|
||||
|
||||
func _test_starter_surface_row(
|
||||
world: Node2D,
|
||||
finish_id: StringName,
|
||||
expected_top: float,
|
||||
label: String,
|
||||
expected_count: int = 25,
|
||||
expected_half_width: float = 2400.0
|
||||
) -> Rect2:
|
||||
var tiles: Array[TerrainBlock] = []
|
||||
for child: Node in world.get_children():
|
||||
if child is TerrainBlock and StringName((child as TerrainBlock).finish_id) == finish_id:
|
||||
tiles.append(child as TerrainBlock)
|
||||
_expect(tiles.size() == expected_count, "%s uses %d ordinary Ground tiles" % [label, expected_count])
|
||||
if tiles.is_empty():
|
||||
return Rect2()
|
||||
tiles.sort_custom(func(left: TerrainBlock, right: TerrainBlock) -> bool: return left.global_position.x < right.global_position.x)
|
||||
var row_bounds: Rect2 = _terrain_world_bounds(tiles[0])
|
||||
for tile: TerrainBlock in tiles:
|
||||
var bounds: Rect2 = _terrain_world_bounds(tile)
|
||||
_expect(tile.scale.is_equal_approx(Vector2.ONE), "%s tile stays at normal scale" % label)
|
||||
_expect(bounds.size.is_equal_approx(Vector2(192.0, 32.0)), "%s tile keeps its 192×32 footprint" % label)
|
||||
_expect(is_equal_approx(bounds.position.y, expected_top), "%s tile keeps the authored surface top" % label)
|
||||
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])
|
||||
_expect(is_zero_approx(right_bounds.position.x - left_bounds.end.x), "%s adjacent tiles meet without gaps or overlap" % 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])
|
||||
_expect(expected_half_width - (1000.0 + 1280.0) >= 100.0, "%s retains 100-unit camera-pan endpoint margins" % label)
|
||||
return row_bounds
|
||||
|
||||
|
||||
func _test_saved_starter_camera(path: String, label: String) -> void:
|
||||
var result: Dictionary = STAGE_SCENE_IO.read_file(path)
|
||||
_expect(bool(result.get("ok", false)), "%s saved camera fixture loads" % label)
|
||||
if not bool(result.get("ok", false)):
|
||||
return
|
||||
var data: Dictionary = result.get("data", {}) as Dictionary
|
||||
var camera_data: Dictionary = data.get("camera", {}) as Dictionary
|
||||
var expected_position: Vector2 = STAGE_SCENE_IO.data_to_vector(camera_data.get("position"), Vector2.ZERO)
|
||||
var expected_zoom: float = float(camera_data.get("zoom", 0.0))
|
||||
var camera: Camera2D = _stage.get_node("Camera2D") as Camera2D
|
||||
_expect(camera.global_position.is_equal_approx(expected_position) and camera.zoom.is_equal_approx(Vector2.ONE * expected_zoom), "%s runtime camera matches its single saved opening camera" % label)
|
||||
|
||||
|
||||
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 _test_large_brick_pattern_budget() -> void:
|
||||
var view_size: Vector2 = Vector2(12000.0, 8000.0)
|
||||
var detail_scale: float = STAGE_SURFACE_RENDERER.brick_detail_scale_for_view(view_size)
|
||||
@@ -323,8 +397,40 @@ func _test_stage_sky_visual_model() -> void:
|
||||
sky_view.size = Vector2(root.size)
|
||||
root.add_child(sky_view)
|
||||
await process_frame
|
||||
var viewport_size: Vector2 = Vector2(root.size)
|
||||
var reference_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(0.0, -400.0), 0.5, viewport_size)
|
||||
_expect((reference_response["shift"] as Vector2).is_zero_approx() and is_equal_approx(float(reference_response["scale"]), 1.0), "camera-aware Sky uses the fixed Stage reference framing")
|
||||
var close_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(420.0, -210.0), 1.5, viewport_size)
|
||||
var close_shift: Vector2 = close_response["shift"]
|
||||
var close_scale: float = float(close_response["scale"])
|
||||
_expect(close_shift.length() > 0.0 and absf(close_shift.x) <= viewport_size.x * 0.05 and absf(close_shift.y) <= viewport_size.y * 0.03, "camera-aware Sky pan stays perceptible and inside the viewport-fraction limits")
|
||||
_expect(close_scale > 1.0 and close_scale <= 1.15, "camera-aware Sky details grow gently during a close zoom")
|
||||
var wide_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(0.0, -400.0), 0.1, viewport_size)
|
||||
var wide_scale: float = float(wide_response["scale"])
|
||||
_expect(wide_scale < 1.0 and wide_scale >= 0.85, "camera-aware Sky details shrink gently during a wide zoom")
|
||||
var repeated_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(420.0, -210.0), 1.5, viewport_size)
|
||||
_expect((repeated_response["shift"] as Vector2).is_equal_approx(close_shift) and is_equal_approx(float(repeated_response["scale"]), close_scale), "absolute camera framing produces a repeatable Sky composition")
|
||||
var invalid_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(INF, 0.0), 1.0, viewport_size)
|
||||
_expect((invalid_response["shift"] as Vector2).is_zero_approx() and is_equal_approx(float(invalid_response["scale"]), 1.0), "invalid camera framing falls back to the fixed default composition")
|
||||
var resized_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(420.0, -210.0), 1.5, Vector2(900.0, 700.0))
|
||||
var resized_shift: Vector2 = resized_response["shift"]
|
||||
_expect(absf(resized_shift.x) <= 900.0 * 0.05 and absf(resized_shift.y) <= 700.0 * 0.03, "resized viewport keeps Sky pan inside the same viewport-fraction bounds")
|
||||
var stars_stay_normalized: bool = true
|
||||
for star_index: int in 48:
|
||||
var star_anchor: Vector2 = StageSkyView.star_anchor_for_index(star_index)
|
||||
if not star_anchor.is_finite() or star_anchor.x < 0.0 or star_anchor.x >= 1.0 or star_anchor.y < 0.0 or star_anchor.y >= 0.62:
|
||||
stars_stay_normalized = false
|
||||
break
|
||||
_expect(stars_stay_normalized, "star anchors remain stable normalized coordinates across viewport resizes")
|
||||
var capture_sky_views: bool = OS.get_cmdline_user_args().has("--capture-sky-v2")
|
||||
for sky_id: StringName in StageSkyView.SKY_IDS:
|
||||
var profile_reference: Dictionary = StageSkyView.compute_camera_response(sky_id, Vector2(0.0, -400.0), 0.5, viewport_size)
|
||||
_expect((profile_reference["shift"] as Vector2).is_zero_approx() and is_equal_approx(float(profile_reference["scale"]), 1.0), "%s has a stable reference composition" % String(sky_id))
|
||||
var close_profile: Dictionary = StageSkyView.compute_camera_response(sky_id, Vector2(0.0, -400.0), 6.0, viewport_size)
|
||||
var wide_profile: Dictionary = StageSkyView.compute_camera_response(sky_id, Vector2(0.0, -400.0), 0.1, viewport_size)
|
||||
var close_profile_scale: float = float(close_profile["scale"])
|
||||
var wide_profile_scale: float = float(wide_profile["scale"])
|
||||
_expect(close_profile_scale >= 0.85 and close_profile_scale <= 1.15 and wide_profile_scale >= 0.85 and wide_profile_scale <= 1.15, "%s motif size stays bounded across the supported zoom range" % String(sky_id))
|
||||
sky_view.set_sky(sky_id)
|
||||
await process_frame
|
||||
var texture_value: Variant = sky_view.get("_gradient_texture")
|
||||
@@ -339,6 +445,9 @@ func _test_stage_sky_visual_model() -> void:
|
||||
var capture_path: String = ProjectSettings.globalize_path("user://phase19_sky_%s.png" % String(sky_id))
|
||||
var capture_error: Error = capture.save_png(capture_path)
|
||||
_expect(capture_error == OK, "%s visual capture saved for inspection" % String(sky_id))
|
||||
var unbound_shift: Vector2 = sky_view.get("_motif_shift")
|
||||
var unbound_scale: float = float(sky_view.get("_motif_scale"))
|
||||
_expect(unbound_shift.is_zero_approx() and is_equal_approx(unbound_scale, 1.0), "unbound chooser-style Sky previews keep their default still composition")
|
||||
var outline_value: Variant = sky_view.call("_build_cloud_outline")
|
||||
var cloud_outline: PackedVector2Array = outline_value
|
||||
_expect(cloud_outline.size() >= 100, "cloud silhouette is a detailed tessellated Bézier contour")
|
||||
@@ -359,6 +468,84 @@ func _test_stage_sky_visual_model() -> void:
|
||||
await process_frame
|
||||
|
||||
|
||||
func _test_live_sky_camera_binding() -> void:
|
||||
var sky_view: StageSkyView = _stage.get("_sky_view") as StageSkyView
|
||||
var camera: Camera2D = _stage.get_node("Camera2D") as Camera2D
|
||||
_expect(sky_view != null and camera != null and sky_view.get("_bound_camera") == camera, "live Stage Sky binds once to the Stage camera")
|
||||
if sky_view == null or camera == null:
|
||||
return
|
||||
var previous_position: Vector2 = camera.global_position
|
||||
var previous_zoom: Vector2 = camera.zoom
|
||||
var previous_sky: StringName = sky_view.get_sky()
|
||||
sky_view.set_sky(&"sky_sunset")
|
||||
camera.global_position = Vector2(0.0, -400.0)
|
||||
camera.zoom = Vector2.ONE * 0.5
|
||||
await process_frame
|
||||
var reference_shift: Vector2 = sky_view.get("_motif_shift")
|
||||
var reference_scale: float = float(sky_view.get("_motif_scale"))
|
||||
camera.global_position = Vector2(420.0, -210.0)
|
||||
camera.zoom = Vector2.ONE * 1.5
|
||||
await process_frame
|
||||
var moved_shift: Vector2 = sky_view.get("_motif_shift")
|
||||
var moved_scale: float = float(sky_view.get("_motif_scale"))
|
||||
_expect(reference_shift.is_zero_approx() and is_equal_approx(reference_scale, 1.0), "live Stage Sky starts at the fixed reference frame")
|
||||
_expect(not moved_shift.is_zero_approx() and moved_scale > 1.0, "live Stage Sky samples Stage pan and zoom while the camera moves")
|
||||
var saved_shift: Vector2 = moved_shift
|
||||
_stage.call("set_mode", 1)
|
||||
_stage.call("set_mode", 0)
|
||||
await process_frame
|
||||
_expect((sky_view.get("_motif_shift") as Vector2).is_equal_approx(saved_shift), "EDIT and DIRECT transitions preserve the same camera-derived Sky framing")
|
||||
await _test_camera_input_route(sky_view, camera)
|
||||
camera.global_position = previous_position
|
||||
camera.zoom = previous_zoom
|
||||
sky_view.set_sky(previous_sky)
|
||||
await process_frame
|
||||
|
||||
|
||||
func _test_camera_input_route(sky_view: StageSkyView, camera: Camera2D) -> void:
|
||||
var input_route_passes: bool = true
|
||||
_stage.set("_cli_startup", true)
|
||||
var hovered_control: Control = root.gui_get_hovered_control()
|
||||
var saved_mouse_filter: Control.MouseFilter = hovered_control.mouse_filter if hovered_control != null else Control.MOUSE_FILTER_STOP
|
||||
if hovered_control != null:
|
||||
hovered_control.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
await process_frame
|
||||
for mode: int in [0, 1, 2]:
|
||||
_stage.call("set_mode", mode)
|
||||
camera.global_position = Vector2(0.0, -400.0)
|
||||
camera.zoom = Vector2.ONE * 0.5
|
||||
var wheel_event: InputEventMouseButton = InputEventMouseButton.new()
|
||||
wheel_event.button_index = MOUSE_BUTTON_WHEEL_UP
|
||||
wheel_event.pressed = true
|
||||
wheel_event.position = Vector2(root.size) * 0.5
|
||||
_stage.call("_input", wheel_event)
|
||||
await process_frame
|
||||
var zoomed: bool = camera.zoom.x > 0.5 and float(sky_view.get("_motif_scale")) > 1.0
|
||||
var pan_press: InputEventMouseButton = InputEventMouseButton.new()
|
||||
pan_press.button_index = MOUSE_BUTTON_MIDDLE
|
||||
pan_press.pressed = true
|
||||
pan_press.position = Vector2(root.size) * 0.5
|
||||
_stage.call("_input", pan_press)
|
||||
var pan_motion: InputEventMouseMotion = InputEventMouseMotion.new()
|
||||
pan_motion.position = pan_press.position + Vector2(80.0, -30.0)
|
||||
pan_motion.relative = Vector2(80.0, -30.0)
|
||||
_stage.call("_input", pan_motion)
|
||||
var pan_release: InputEventMouseButton = InputEventMouseButton.new()
|
||||
pan_release.button_index = MOUSE_BUTTON_MIDDLE
|
||||
pan_release.pressed = false
|
||||
pan_release.position = pan_motion.position
|
||||
_stage.call("_input", pan_release)
|
||||
await process_frame
|
||||
var panned: bool = not camera.global_position.is_equal_approx(Vector2(0.0, -400.0)) and not (sky_view.get("_motif_shift") as Vector2).is_zero_approx()
|
||||
input_route_passes = input_route_passes and zoomed and panned
|
||||
_expect(zoomed and panned, "SandboxStage wheel zoom and middle-drag pan update the camera-aware Sky in mode %d" % mode)
|
||||
_stage.call("set_mode", 0)
|
||||
_stage.set("_cli_startup", false)
|
||||
if hovered_control != null and is_instance_valid(hovered_control):
|
||||
hovered_control.mouse_filter = saved_mouse_filter
|
||||
_expect(input_route_passes, "SandboxStage camera input route remains active in EDIT, DIRECT, and PLAY")
|
||||
|
||||
|
||||
func _expect(condition: bool, description: String) -> void:
|
||||
if condition:
|
||||
print("PASS: " + description)
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://paxevrnrl10b
|
||||
Reference in New Issue
Block a user