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
+180 -6
View File
@@ -2660,8 +2660,6 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool)
_reset_scene_content()
var adapter_result: Dictionary = _scene_adapter.apply(data, _world, _spawner)
_sky_id = StringName(String(adapter_result.get("sky_id", "sky_neutral")))
if _sky_view != null:
_sky_view.set_sky(_sky_id)
var loaded_nodes: Array[Node2D] = adapter_result.get("loaded_nodes", [])
for node: Node2D in loaded_nodes:
_finalize_placed_node(node)
@@ -2685,6 +2683,8 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool)
var camera_data: Dictionary = raw_camera as Dictionary if raw_camera is Dictionary else {}
_camera.global_position = STAGE_SCENE_IO.data_to_vector(camera_data.get("position"), DEFAULT_CAMERA_POSITION)
_camera.zoom = Vector2.ONE * clampf(float(camera_data.get("zoom", DEFAULT_CAMERA_ZOOM)), min_zoom, max_zoom)
if _sky_view != null:
_sky_view.set_sky(_sky_id)
var raw_metadata: Variant = data.get("metadata", {})
var metadata: Dictionary = raw_metadata as Dictionary if raw_metadata is Dictionary else {}
if _document != null:
@@ -4550,6 +4550,7 @@ func _build_sky_layer() -> void:
_sky_view.set_sky(_sky_id)
_sky_layer.add_child(_sky_view)
add_child(_sky_layer)
_sky_view.bind_camera(_camera)
func _build_placement_overlay() -> void:
@@ -4591,29 +4592,47 @@ func _rebake_navigation() -> void:
if block == null:
continue
var pts := PackedVector2Array()
var block_top_y: float = INF
for p: Vector2 in block.polygon_points:
pts.append(block.transform * p)
var world_point: Vector2 = block.transform * p
pts.append(world_point)
block_top_y = minf(block_top_y, world_point.y)
for i: int in pts.size():
var a := pts[i]
var b := pts[(i + 1) % pts.size()]
var edge := b - a
if edge.length_squared() <= 0.001:
continue
if absf(a.y - b.y) <= 0.001:
if is_equal_approx(a.y, block_top_y):
surfaces.append({"a": a, "b": b, "normal": Vector2.UP})
continue
# TerrainUtils stores clockwise polygons; this right normal is
# outward, so only its upward-facing edges are walkable.
# outward on sloped edges, so only upward-facing slopes are walkable.
var outward := Vector2(edge.y, -edge.x).normalized()
if outward.y > -0.15:
continue
surfaces.append({"a": a, "b": b, "normal": outward})
for surface: Dictionary in surfaces:
surfaces = _merge_contiguous_horizontal_navigation_surfaces(surfaces)
var endpoint_steps: Array[Dictionary] = _find_navigation_endpoint_steps(surfaces)
var ribbon_surfaces: Array[Dictionary] = _split_navigation_surfaces_at_step_edges(surfaces, endpoint_steps)
for surface: Dictionary in ribbon_surfaces:
_append_navigation_ribbon(verts, triangles, surface["a"], surface["b"], surface["normal"])
for endpoint_step: Dictionary in endpoint_steps:
_append_navigation_endpoint_step_ribbon(
verts,
triangles,
endpoint_step["upper_point"],
endpoint_step["lower_y"],
endpoint_step["outward_normal"]
)
# Join only short, climbable gaps between exposed surface ends. This connects
# authored stair treads without making cliffs or terrain interiors walkable.
for i: int in surfaces.size():
for j: int in range(i + 1, surfaces.size()):
for a: Vector2 in [surfaces[i]["a"], surfaces[i]["b"]]:
for b: Vector2 in [surfaces[j]["a"], surfaces[j]["b"]]:
if absf(a.x - b.x) <= 64.0 and absf(a.y - b.y) <= 96.0:
if absf(a.x - b.x) > 0.001 and absf(a.x - b.x) <= 64.0 and absf(a.y - b.y) <= 96.0:
_append_navigation_ribbon(verts, triangles, a, b, Vector2.UP)
var poly := NavigationPolygon.new()
poly.set_vertices(verts)
@@ -4624,6 +4643,161 @@ func _rebake_navigation() -> void:
print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()])
func _find_navigation_endpoint_steps(surfaces: Array[Dictionary]) -> Array[Dictionary]:
var endpoint_steps: Array[Dictionary] = []
var horizontal: Array[Dictionary] = []
for surface: Dictionary in surfaces:
var a: Vector2 = surface.get("a", Vector2.ZERO)
var b: Vector2 = surface.get("b", Vector2.ZERO)
var normal: Vector2 = surface.get("normal", Vector2.ZERO)
if absf(a.y - b.y) <= 0.001 and normal.is_equal_approx(Vector2.UP):
horizontal.append(surface)
for upper: Dictionary in horizontal:
var upper_a: Vector2 = upper["a"]
var upper_b: Vector2 = upper["b"]
for lower: Dictionary in horizontal:
var lower_a: Vector2 = lower["a"]
var lower_b: Vector2 = lower["b"]
var vertical_step: float = lower_a.y - upper_a.y
if vertical_step <= 0.001 or vertical_step > 96.0:
continue
# A lower walk surface can join an upper row only where it extends
# past that row's end. The vertical nav strip then represents the
# physically exposed step/drop beside the upper terrain collider.
if lower_a.x < upper_a.x - 0.01 and lower_b.x >= upper_a.x - 0.01:
endpoint_steps.append({
"upper_point": upper_a,
"lower_y": lower_a.y,
"outward_normal": Vector2.LEFT,
})
if lower_b.x > upper_b.x + 0.01 and lower_a.x <= upper_b.x + 0.01:
endpoint_steps.append({
"upper_point": upper_b,
"lower_y": lower_a.y,
"outward_normal": Vector2.RIGHT,
})
return endpoint_steps
func _split_navigation_surfaces_at_step_edges(
surfaces: Array[Dictionary],
endpoint_steps: Array[Dictionary]
) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for surface: Dictionary in surfaces:
var a: Vector2 = surface.get("a", Vector2.ZERO)
var b: Vector2 = surface.get("b", Vector2.ZERO)
var normal: Vector2 = surface.get("normal", Vector2.ZERO)
if absf(a.y - b.y) > 0.001 or not normal.is_equal_approx(Vector2.UP):
result.append(surface)
continue
var split_x: Array[float] = [a.x, b.x]
for endpoint_step: Dictionary in endpoint_steps:
if not is_equal_approx(float(endpoint_step["lower_y"]), a.y):
continue
var upper_point: Vector2 = endpoint_step["upper_point"]
var outward_normal: Vector2 = endpoint_step["outward_normal"]
var outer_x: float = upper_point.x + outward_normal.x * 16.0
if upper_point.x > a.x and upper_point.x < b.x:
split_x.append(upper_point.x)
if outer_x > a.x and outer_x < b.x:
split_x.append(outer_x)
split_x.sort()
var unique_split_x: Array[float] = []
for split: float in split_x:
if unique_split_x.is_empty() or not is_equal_approx(split, unique_split_x[unique_split_x.size() - 1]):
unique_split_x.append(split)
for split_index: int in range(unique_split_x.size() - 1):
var segment_start: float = unique_split_x[split_index]
var segment_end: float = unique_split_x[split_index + 1]
if segment_end - segment_start <= 0.001:
continue
result.append({
"a": Vector2(segment_start, a.y),
"b": Vector2(segment_end, a.y),
"normal": Vector2.UP,
})
return result
func _append_navigation_endpoint_step_ribbon(
verts: PackedVector2Array,
triangles: Array[PackedInt32Array],
upper_point: Vector2,
lower_y: float,
outward_normal: Vector2
) -> void:
var inner_x: float = upper_point.x
var outer_x: float = inner_x + outward_normal.x * 16.0
var upper_nav_top_y: float = upper_point.y - 4.0
var upper_nav_bottom_y: float = upper_point.y + 12.0
var lower_nav_top_y: float = lower_y - 4.0
var base: int = verts.size()
if outward_normal.x < 0.0:
verts.append_array(PackedVector2Array([
Vector2(outer_x, upper_nav_top_y),
Vector2(inner_x, upper_nav_top_y),
Vector2(inner_x, upper_nav_bottom_y),
Vector2(inner_x, lower_nav_top_y),
Vector2(outer_x, lower_nav_top_y),
]))
triangles.append(PackedInt32Array([base, base + 1, base + 2]))
triangles.append(PackedInt32Array([base, base + 2, base + 3]))
triangles.append(PackedInt32Array([base, base + 3, base + 4]))
else:
verts.append_array(PackedVector2Array([
Vector2(inner_x, upper_nav_top_y),
Vector2(outer_x, upper_nav_top_y),
Vector2(outer_x, lower_nav_top_y),
Vector2(inner_x, lower_nav_top_y),
Vector2(inner_x, upper_nav_bottom_y),
]))
triangles.append(PackedInt32Array([base, base + 1, base + 2]))
triangles.append(PackedInt32Array([base, base + 2, base + 4]))
triangles.append(PackedInt32Array([base + 2, base + 3, base + 4]))
func _merge_contiguous_horizontal_navigation_surfaces(surfaces: Array[Dictionary]) -> Array[Dictionary]:
var horizontal: Array[Dictionary] = []
var other: Array[Dictionary] = []
for surface: Dictionary in surfaces:
var a: Vector2 = surface.get("a", Vector2.ZERO)
var b: Vector2 = surface.get("b", Vector2.ZERO)
var normal: Vector2 = surface.get("normal", Vector2.ZERO)
if absf(a.y - b.y) > 0.001 or not normal.is_equal_approx(Vector2.UP):
other.append(surface)
continue
horizontal.append({
"a": Vector2(minf(a.x, b.x), a.y),
"b": Vector2(maxf(a.x, b.x), a.y),
"normal": Vector2.UP,
})
horizontal.sort_custom(_navigation_surface_precedes)
var merged: Array[Dictionary] = []
for surface: Dictionary in horizontal:
if not merged.is_empty():
var previous_index: int = merged.size() - 1
var previous: Dictionary = merged[previous_index]
var previous_a: Vector2 = previous["a"]
var previous_b: Vector2 = previous["b"]
var current_a: Vector2 = surface["a"]
if is_equal_approx(previous_a.y, current_a.y) and absf(previous_b.x - current_a.x) <= 0.01:
previous["b"] = surface["b"]
merged[previous_index] = previous
continue
merged.append(surface)
merged.append_array(other)
return merged
func _navigation_surface_precedes(left: Dictionary, right: Dictionary) -> bool:
var left_a: Vector2 = left.get("a", Vector2.ZERO)
var right_a: Vector2 = right.get("a", Vector2.ZERO)
if not is_equal_approx(left_a.y, right_a.y):
return left_a.y < right_a.y
return left_a.x < right_a.x
func _append_navigation_ribbon(
verts: PackedVector2Array,
triangles: Array[PackedInt32Array],