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:
2026-10-21 17:46:58 -04:00
parent c1758efd3b
commit 0b1dda2e2f
18 changed files with 1574 additions and 141 deletions
+131
View File
@@ -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
+195 -8
View File
@@ -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