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],
+138 -46
View File
@@ -5,27 +5,54 @@ extends Control
const SKY_IDS: Array[StringName] = [&"sky_clear_day", &"sky_cloudy_day", &"sky_sunset", &"sky_starry_night", &"sky_neutral"]
const GRADIENT_TEXTURE_HEIGHT: int = 2048
const CLOUD_VIEWBOX: Vector2 = Vector2(210.0, 110.0)
const BASE_VIEWPORT_SIZE: Vector2 = Vector2(800.0, 480.0)
const REF_POSITION: Vector2 = Vector2(0.0, -400.0)
const REF_ZOOM: float = 0.5
const NIGHT_STAR_COLOR: Color = Color("#FFF4CE")
const CAMERA_RESPONSE_PROFILES: Dictionary = {
&"sky_clear_day": {"pan_limits": Vector2(0.025, 0.015), "pan_spans": Vector2(0.8, 0.8), "zoom_gain": 0.10, "zoom_span": 1.2},
&"sky_cloudy_day": {"pan_limits": Vector2(0.020, 0.012), "pan_spans": Vector2(0.85, 0.85), "zoom_gain": 0.075, "zoom_span": 1.2},
&"sky_sunset": {"pan_limits": Vector2(0.030, 0.018), "pan_spans": Vector2(0.9, 0.9), "zoom_gain": 0.11, "zoom_span": 1.2},
&"sky_starry_night": {"pan_limits": Vector2(0.035, 0.025), "pan_spans": Vector2(1.0, 1.0), "zoom_gain": 0.12, "zoom_span": 1.2},
&"sky_neutral": {"pan_limits": Vector2.ZERO, "pan_spans": Vector2.ONE, "zoom_gain": 0.0, "zoom_span": 1.0},
}
var _sky_id: StringName = &"sky_neutral"
var _stage_theme: SandboxTheme
var _gradient_texture: GradientTexture2D
var _bound_camera: Camera2D
var _motif_shift: Vector2 = Vector2.ZERO
var _motif_scale: float = 1.0
var _cached_cloud_outline: PackedVector2Array = PackedVector2Array()
var _cached_cloud_underside: PackedVector2Array = PackedVector2Array()
var _cached_cloud_accents: Array[PackedVector2Array] = []
var _cached_moon_outline: PackedVector2Array = PackedVector2Array()
var _cached_moon_outer_arc: PackedVector2Array = PackedVector2Array()
var _cached_moon_warm_rim: PackedVector2Array = PackedVector2Array()
var _cached_moon_highlight: PackedVector2Array = PackedVector2Array()
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
_cache_motif_geometry()
_update_gradient_texture()
queue_redraw()
func bind_camera(camera: Camera2D) -> void:
_bound_camera = camera
set_process(camera != null)
_refresh_camera_response(true)
func set_sky(id: StringName) -> void:
var next_sky_id: StringName = id if SKY_IDS.has(id) else &"sky_neutral"
if _sky_id == next_sky_id:
return
var changed: bool = _sky_id != next_sky_id
_sky_id = next_sky_id
_update_gradient_texture()
queue_redraw()
if changed:
_update_gradient_texture()
_refresh_camera_response(true)
func get_sky() -> StringName:
@@ -39,9 +66,73 @@ func apply_theme(theme: SandboxTheme) -> void:
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED:
_refresh_camera_response(true)
queue_redraw()
func _process(_delta: float) -> void:
_refresh_camera_response(false)
static func compute_camera_response(sky_id: StringName, camera_position: Vector2, camera_zoom: float, viewport_size: Vector2) -> Dictionary:
var fallback: Dictionary = {"shift": Vector2.ZERO, "scale": 1.0}
if not camera_position.is_finite() or not viewport_size.is_finite() or viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
return fallback
if not is_finite(camera_zoom) or camera_zoom <= 0.0:
return fallback
var profile_value: Variant = CAMERA_RESPONSE_PROFILES.get(sky_id, CAMERA_RESPONSE_PROFILES[&"sky_neutral"])
var profile: Dictionary = profile_value as Dictionary
var pan_limits: Vector2 = profile.get("pan_limits", Vector2.ZERO)
var pan_spans: Vector2 = profile.get("pan_spans", Vector2.ONE)
var zoom_gain: float = float(profile.get("zoom_gain", 0.0))
var zoom_span: float = maxf(float(profile.get("zoom_span", 1.0)), 0.001)
var reference_frame_delta: Vector2 = (camera_position - REF_POSITION) * REF_ZOOM
var normalized_pan: Vector2 = reference_frame_delta / viewport_size
var shift: Vector2 = Vector2(
-viewport_size.x * pan_limits.x * tanh(normalized_pan.x / maxf(pan_spans.x, 0.001)),
-viewport_size.y * pan_limits.y * tanh(normalized_pan.y / maxf(pan_spans.y, 0.001))
)
var log_zoom: float = log(camera_zoom / REF_ZOOM)
var motif_scale: float = exp(zoom_gain * tanh(log_zoom / zoom_span))
return {"shift": shift, "scale": motif_scale}
static func star_anchor_for_index(index: int) -> Vector2:
return Vector2(
fposmod(float(index) * 0.61803398875 + 0.13, 1.0),
fposmod(float(index) * 0.41421356237 + 0.08, 0.62)
)
func _refresh_camera_response(force_redraw: bool) -> void:
var next_shift: Vector2 = Vector2.ZERO
var next_scale: float = 1.0
if _bound_camera != null and is_instance_valid(_bound_camera):
var camera_position: Vector2 = _bound_camera.global_position
var camera_zoom: Vector2 = _bound_camera.zoom
if camera_position.is_finite() and camera_zoom.is_finite() and camera_zoom.x > 0.0 and camera_zoom.y > 0.0 and is_equal_approx(camera_zoom.x, camera_zoom.y):
var response: Dictionary = compute_camera_response(_sky_id, camera_position, camera_zoom.x, size)
next_shift = response.get("shift", Vector2.ZERO)
next_scale = float(response.get("scale", 1.0))
var changed: bool = not _motif_shift.is_equal_approx(next_shift) or not is_equal_approx(_motif_scale, next_scale)
_motif_shift = next_shift
_motif_scale = next_scale
if force_redraw or changed:
queue_redraw()
func _cache_motif_geometry() -> void:
_cached_cloud_outline = _build_cloud_outline()
_cached_cloud_underside = _build_cloud_underside()
_cached_cloud_accents.clear()
for accent_curve: PackedVector2Array in _build_cloud_accents():
_cached_cloud_accents.append(_sample_curve_chain([accent_curve], 12))
_cached_moon_outline = _sample_curve_chain(_build_moon_curves(), 16)
_cached_moon_outer_arc = _sample_curve_chain(_build_moon_outer_arc_curves(), 16)
_cached_moon_warm_rim = _build_moon_warm_rim(_cached_moon_outer_arc, 7.5)
_cached_moon_highlight = _sample_curve_chain(_build_moon_highlight(), 18)
func _draw() -> void:
if size.x <= 0.0 or size.y <= 0.0:
return
@@ -90,8 +181,9 @@ func _update_gradient_texture() -> void:
func _draw_sun() -> void:
var center: Vector2 = Vector2(size.x * 0.78125, size.y * (0.241 if _sky_id == &"sky_sunset" else 0.242))
var radius: float = size.y * (0.0979 if _sky_id == &"sky_sunset" else 0.0792)
var center_anchor: Vector2 = Vector2(0.72, 0.241 if _sky_id == &"sky_sunset" else 0.242)
var center: Vector2 = _motif_anchor(center_anchor)
var radius: float = _base_motif_scale() * _motif_scale * (47.0 if _sky_id == &"sky_sunset" else 38.0)
var halo_color: Color = Color("#FFE1A2") if _sky_id == &"sky_sunset" else Color("#FFF1B3")
var sun_color: Color = Color("#FFE296") if _sky_id == &"sky_sunset" else Color("#FFE990")
draw_circle(center, radius * 1.5, Color(halo_color.r, halo_color.g, halo_color.b, 0.14))
@@ -101,87 +193,87 @@ func _draw_sun() -> void:
func _draw_stars() -> void:
for index: int in 48:
var x: float = fposmod(float(index * 137 + 39), size.x)
var y: float = fposmod(float(index * 83 + 21), size.y * 0.62)
draw_circle(Vector2(x, y), 1.3 if index % 4 != 0 else 2.0, NIGHT_STAR_COLOR)
var anchor: Vector2 = star_anchor_for_index(index)
var center: Vector2 = _motif_anchor(anchor)
var radius: float = _base_motif_scale() * _motif_scale * (1.3 if index % 4 != 0 else 2.0)
draw_circle(center, radius, NIGHT_STAR_COLOR)
for star_index: int in 3:
var center: Vector2 = Vector2(size.x * [0.3675, 0.9425, 0.22125][star_index], size.y * [0.085, 0.229, 0.348][star_index])
var radius: float = maxf(1.3, size.y / 480.0 * 7.0)
var anchors: Array[Vector2] = [Vector2(0.3675, 0.085), Vector2(0.9425, 0.229), Vector2(0.22125, 0.348)]
var center: Vector2 = _motif_anchor(anchors[star_index])
var radius: float = _base_motif_scale() * _motif_scale * 7.0
draw_line(center - Vector2(0.0, radius), center + Vector2(0.0, radius), Color("#FFEABB"), maxf(1.0, radius * 0.22), true)
draw_line(center - Vector2(radius, 0.0), center + Vector2(radius, 0.0), Color("#FFEABB"), maxf(1.0, radius * 0.22), true)
func _draw_clouds() -> void:
var origins: Array[Vector4] = []
var origins: Array[Vector3] = []
var opacities: Array[float] = []
var outer_color: Color
var underside_color: Color
var accent_color: Color
match _sky_id:
&"sky_clear_day":
origins = [Vector4(42.0, 66.0, 205.0, 108.0), Vector4(282.0, 167.0, 244.0, 127.0), Vector4(612.0, 288.0, 187.0, 98.0)]
origins = [Vector3(0.18, 0.17, 200.0), Vector3(0.51, 0.33, 220.0), Vector3(0.88, 0.48, 174.0)]
opacities = [0.93, 0.88, 0.8]
outer_color = Color("#FFFFFF")
underside_color = Color("#CBE6FF", 0.72)
accent_color = Color("#D9EEFF", 0.86)
&"sky_cloudy_day":
origins = [Vector4(-16.0, 47.0, 246.0, 129.0), Vector4(188.0, 145.0, 242.0, 127.0), Vector4(508.0, 64.0, 281.0, 147.0), Vector4(74.0, 302.0, 190.0, 100.0), Vector4(535.0, 324.0, 231.0, 121.0)]
origins = [Vector3(0.14, 0.17, 205.0), Vector3(0.48, 0.28, 208.0), Vector3(0.83, 0.17, 226.0), Vector3(0.28, 0.45, 170.0), Vector3(0.78, 0.47, 194.0)]
opacities = [1.0, 0.93, 0.95, 0.7, 0.69]
outer_color = Color("#F8FCFF")
underside_color = Color("#BED7EF", 0.9)
accent_color = Color("#D0E5F7", 1.0)
&"sky_sunset":
origins = [Vector4(24.0, 82.0, 235.0, 123.0), Vector4(294.0, 244.0, 258.0, 135.0), Vector4(585.0, 310.0, 218.0, 114.0)]
origins = [Vector3(0.18, 0.20, 210.0), Vector3(0.55, 0.37, 226.0), Vector3(0.88, 0.50, 192.0)]
opacities = [0.85, 0.9, 0.81]
outer_color = Color("#FFE3D1")
underside_color = Color("#E9A8BA", 0.8)
accent_color = Color("#FFF2D7", 0.8)
_:
origins = [Vector4(-4.0, 331.0, 231.0, 121.0), Vector4(314.0, 380.0, 188.0, 98.0)]
origins = [Vector3(0.16, 0.55, 220.0), Vector3(0.68, 0.62, 178.0)]
opacities = [0.28, 0.23]
outer_color = Color("#A9BCE1")
underside_color = Color("#7898C5", 0.8)
accent_color = Color("#D7E3F7", 0.45)
var viewport_scale: Vector2 = Vector2(size.x / 800.0, size.y / 480.0)
for cloud_index: int in origins.size():
var placement: Vector4 = origins[cloud_index]
var cloud_rect: Rect2 = Rect2(
Vector2(placement.x, placement.y) * viewport_scale,
Vector2(placement.z, placement.w) * viewport_scale
)
_draw_cloud(cloud_rect, outer_color, underside_color, accent_color, opacities[cloud_index])
var placement: Vector3 = origins[cloud_index]
_draw_cloud(Vector2(placement.x, placement.y), placement.z, outer_color, underside_color, accent_color, opacities[cloud_index])
func _draw_cloud(rect: Rect2, outer_color: Color, underside_color: Color, accent_color: Color, opacity: float) -> void:
var outline: PackedVector2Array = _map_cloud_points(_build_cloud_outline(), rect)
func _draw_cloud(anchor: Vector2, authored_width: float, outer_color: Color, underside_color: Color, accent_color: Color, opacity: float) -> void:
var cloud_scale: float = _base_motif_scale() * _motif_scale * authored_width / CLOUD_VIEWBOX.x
var center: Vector2 = _motif_anchor(anchor)
var outline: PackedVector2Array = _map_cloud_points(_cached_cloud_outline, center, cloud_scale)
var cloud_color: Color = _with_opacity(outer_color, opacity)
draw_colored_polygon(outline, cloud_color)
draw_polyline(outline, cloud_color, maxf(1.0, rect.size.y / CLOUD_VIEWBOX.y * 1.5), true)
var underside: PackedVector2Array = _map_cloud_points(_build_cloud_underside(), rect)
draw_polyline(outline, cloud_color, maxf(1.0, cloud_scale * 1.5), true)
var underside: PackedVector2Array = _map_cloud_points(_cached_cloud_underside, center, cloud_scale)
draw_colored_polygon(underside, _with_opacity(underside_color, opacity))
for accent_curve: PackedVector2Array in _build_cloud_accents():
var accent_points: PackedVector2Array = _map_cloud_points(_sample_curve_chain([accent_curve], 12), rect)
draw_polyline(accent_points, _with_opacity(accent_color, opacity), maxf(1.0, rect.size.y / CLOUD_VIEWBOX.y * 3.0), true)
for accent_points_local: PackedVector2Array in _cached_cloud_accents:
var accent_points: PackedVector2Array = _map_cloud_points(accent_points_local, center, cloud_scale)
draw_polyline(accent_points, _with_opacity(accent_color, opacity), maxf(1.0, cloud_scale * 3.0), true)
func _draw_moon() -> void:
var moon_curves: Array[PackedVector2Array] = _build_moon_curves()
var moon_local: PackedVector2Array = _sample_curve_chain(moon_curves, 16)
var outer_arc: PackedVector2Array = _sample_curve_chain(_build_moon_outer_arc_curves(), 16)
var moon_scale: float = size.y / 480.0 * 0.63
var moon_origin: Vector2 = Vector2(size.x * 0.7625, size.y * 0.09583)
var moon_size: Vector2 = Vector2(150.0, 240.0) * moon_scale
var moon_rect: Rect2 = Rect2(moon_origin, moon_size)
var moon_outline: PackedVector2Array = _map_moon_points(moon_local, moon_rect)
var moon_scale: float = _base_motif_scale() * _motif_scale * 0.63
var moon_center: Vector2 = _motif_anchor(Vector2(0.75, 0.25))
var moon_outline: PackedVector2Array = _map_moon_points(_cached_moon_outline, moon_center, moon_scale)
draw_colored_polygon(moon_outline, Color("#17213A"))
draw_colored_polygon(moon_outline, Color("#FFEA31"))
var warm_rim: PackedVector2Array = _build_moon_warm_rim(outer_arc, 7.5)
draw_colored_polygon(_map_moon_points(warm_rim, moon_rect), Color("#F4A629"))
var highlight: PackedVector2Array = _sample_curve_chain(_build_moon_highlight(), 18)
draw_polyline(_map_moon_points(highlight, moon_rect), Color(1.0, 0.98, 0.63, 0.62), maxf(1.0, moon_scale * 8.0), true)
draw_colored_polygon(_map_moon_points(_cached_moon_warm_rim, moon_center, moon_scale), Color("#F4A629"))
draw_polyline(_map_moon_points(_cached_moon_highlight, moon_center, moon_scale), Color(1.0, 0.98, 0.63, 0.62), maxf(1.0, moon_scale * 8.0), true)
draw_polyline(moon_outline, Color("#17213A"), maxf(1.0, moon_scale * 5.5), true)
func _base_motif_scale() -> float:
return maxf(0.001, minf(size.x / BASE_VIEWPORT_SIZE.x, size.y / BASE_VIEWPORT_SIZE.y))
func _motif_anchor(normalized_anchor: Vector2) -> Vector2:
return normalized_anchor * size + _motif_shift
func _build_cloud_outline() -> PackedVector2Array:
return _sample_curve_chain(_build_cloud_outer_curves(), 10)
@@ -284,17 +376,17 @@ func _cubic_point(curve: PackedVector2Array, t: float) -> Vector2:
)
func _map_cloud_points(points: PackedVector2Array, rect: Rect2) -> PackedVector2Array:
func _map_cloud_points(points: PackedVector2Array, center: Vector2, uniform_scale: float) -> PackedVector2Array:
var mapped: PackedVector2Array = PackedVector2Array()
for point: Vector2 in points:
mapped.append(rect.position + Vector2(point.x / CLOUD_VIEWBOX.x * rect.size.x, point.y / CLOUD_VIEWBOX.y * rect.size.y))
mapped.append(center + (point - CLOUD_VIEWBOX * 0.5) * uniform_scale)
return mapped
func _map_moon_points(points: PackedVector2Array, rect: Rect2) -> PackedVector2Array:
func _map_moon_points(points: PackedVector2Array, center: Vector2, uniform_scale: float) -> PackedVector2Array:
var mapped: PackedVector2Array = PackedVector2Array()
for point: Vector2 in points:
mapped.append(rect.position + point * (rect.size.y / 240.0))
mapped.append(center + (point - Vector2(75.0, 120.0)) * uniform_scale)
return mapped
@@ -5,15 +5,12 @@ extends SubViewportContainer
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
const STAGE_SCENE_ADAPTER := preload("res://scripts/editors/stage/stage_scene_adapter.gd")
const STAGE_SPAWNER := preload("res://scripts/stage/stage_spawner.gd")
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
const STAGE_SKY_VIEW := preload("res://scripts/stage/scenery/stage_sky_view.gd")
const STAGE_WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
const PREVIEW_SIZE := Vector2i(256, 256)
const PREVIEW_MARGIN := 18.0
const MIN_PREVIEW_ZOOM := 0.08
const MAX_PREVIEW_ZOOM := 0.42
const REFERENCE_VIEWPORT_SIZE := Vector2(1280.0, 720.0)
var _viewport: SubViewport
var _sky_view: StageSkyView
@@ -82,35 +79,33 @@ func _build_preview_viewport(data: Dictionary) -> void:
prop.gravity_scale = 0.0
prop.collision_layer = 0
prop.collision_mask = 0
call_deferred("_fit_scene_to_preview")
call_deferred("_fit_scene_to_preview", data.get("camera", {}) as Dictionary)
func _fit_scene_to_preview() -> void:
func _fit_scene_to_preview(camera_state: Dictionary) -> void:
if _world == null or not is_instance_valid(_world):
return
var content_bounds := Rect2()
var has_bounds: bool = false
for node: Node2D in _loaded_nodes:
if node == null or not is_instance_valid(node):
continue
var bounds: Rect2
if node.get_script() == STAGE_WALL_PANEL:
bounds = node.global_transform * (node as StageWallPanel).get_local_bounds()
else:
bounds = STAGE_SELECTION.get_world_aabb(node)
bounds = bounds.abs()
if bounds.size.x <= 0.0 or bounds.size.y <= 0.0:
continue
content_bounds = bounds if not has_bounds else content_bounds.merge(bounds)
has_bounds = true
if not has_bounds:
_world.scale = Vector2.ONE * 0.2
_world.position = Vector2(PREVIEW_SIZE) * 0.5
_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
return
var usable_size: Vector2 = Vector2(PREVIEW_SIZE) - Vector2.ONE * PREVIEW_MARGIN * 2.0
var fit_zoom: float = minf(usable_size.x / content_bounds.size.x, usable_size.y / content_bounds.size.y)
fit_zoom = clampf(fit_zoom, MIN_PREVIEW_ZOOM, MAX_PREVIEW_ZOOM)
_world.scale = Vector2.ONE * fit_zoom
_world.position = Vector2(PREVIEW_SIZE) * 0.5 - content_bounds.get_center() * fit_zoom
var preview_frame: Dictionary = _preview_transform_for_camera(camera_state)
var preview_scale: float = float(preview_frame["scale"])
_world.scale = Vector2.ONE * preview_scale
_world.position = preview_frame["position"] as Vector2
_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
static func _preview_transform_for_camera(camera_state: Dictionary) -> Dictionary:
var camera_position: Vector2 = STAGE_SCENE_IO.data_to_vector(camera_state.get("position"), Vector2.ZERO)
var camera_zoom: float = float(camera_state.get("zoom", 0.5))
if not is_finite(camera_zoom) or camera_zoom <= 0.0:
camera_zoom = 0.5
var square_world_extent: float = REFERENCE_VIEWPORT_SIZE.y / camera_zoom
var focus_rect := Rect2(
camera_position - Vector2.ONE * square_world_extent * 0.5,
Vector2.ONE * square_world_extent
)
var usable_size: Vector2 = Vector2(PREVIEW_SIZE) - Vector2.ONE * PREVIEW_MARGIN * 2.0
var preview_scale: float = minf(usable_size.x / focus_rect.size.x, usable_size.y / focus_rect.size.y)
return {
"focus_rect": focus_rect,
"scale": preview_scale,
"position": Vector2(PREVIEW_SIZE) * 0.5 - camera_position * preview_scale,
}