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
+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)