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