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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user