Add new test file for phase 19 scenery with unique identifier

This commit is contained in:
2026-10-17 12:53:38 -04:00
parent 36032ebb1e
commit a0510e6405
74 changed files with 40948 additions and 149 deletions
@@ -0,0 +1,45 @@
class_name StageBackgroundCardPreview
extends Control
## Reuses the Stage's Sky and wall-finish drawing rules in gallery cards.
const SKY_VIEW: Script = preload("res://scripts/stage/scenery/stage_sky_view.gd")
const SURFACE_RENDERER: Script = preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
var _background_kind: String = ""
var _preview_id: StringName = &""
var _sky_view: StageSkyView
func configure(entry: Dictionary) -> void:
_background_kind = String(entry.get("background_kind", ""))
_preview_id = StringName(String(entry.get("id", ""))) if _background_kind == "sky" else StringName(String(entry.get("finish_id", "")))
clip_contents = true
mouse_filter = Control.MOUSE_FILTER_IGNORE
if _background_kind == "sky":
_sky_view = SKY_VIEW.new() as StageSkyView
_sky_view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_sky_view.set_sky(_preview_id)
add_child(_sky_view)
var frame: Panel = Panel.new()
frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
var frame_style: StyleBoxFlat = StyleBoxFlat.new()
frame_style.bg_color = Color.TRANSPARENT
frame_style.draw_center = false
frame_style.set_border_width_all(2)
frame_style.border_color = Color("#D7DEE7")
frame_style.set_corner_radius_all(4)
frame.add_theme_stylebox_override("panel", frame_style)
add_child(frame)
queue_redraw()
func _draw() -> void:
if _background_kind == "wall" and SURFACE_RENDERER.is_wall_finish(_preview_id):
var wall_tint: Color = Color("#7FA36C") if _preview_id == &"finish_wall_paint" else Color.WHITE
SURFACE_RENDERER.draw_wall(self, Rect2(Vector2.ZERO, size), _preview_id, wall_tint, Vector2.ZERO)
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED:
queue_redraw()
@@ -0,0 +1 @@
uid://csk4xjt1unoh
@@ -0,0 +1,134 @@
class_name StageSceneryArtwork
extends Node2D
## Visual-only live-reference Scenery placement. Never participates in physics.
const PROP_FILE := preload("res://scripts/editors/prop/prop_file.gd")
const PROP_VISUALS := preload("res://scripts/props/prop_visuals.gd")
const DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
var asset_path: String = ""
var display_name: String = "Scenery"
var depth: StringName = &"near"
var last_known_local_bounds: Rect2 = Rect2(Vector2(-32.0, -32.0), Vector2(64.0, 64.0))
var has_known_bounds: bool = false
var missing: bool = false
var _shapes: Array[Dictionary] = []
var _visuals: Node2D
func load_visual(path: String) -> bool:
var normalized_path: String = CONTENT_PATHS.canonicalize(path)
if not FileAccess.file_exists(normalized_path):
_set_missing()
return false
var raw: Dictionary = PROP_FILE.load(normalized_path)
var parsed: Dictionary = PROP_FILE.from_file_dict(raw)
var parsed_shapes: Array[Dictionary] = parsed.get("shapes", []) as Array[Dictionary]
if parsed_shapes.is_empty():
_set_missing()
return false
if _visuals != null and is_instance_valid(_visuals):
_visuals.queue_free()
_visuals = Node2D.new()
_visuals.name = "Visuals"
add_child(_visuals)
asset_path = normalized_path
_shapes = parsed_shapes
var bounds: Rect2 = PROP_VISUALS.build(_visuals, _shapes)
var stroke: float = _max_stroke_width(_shapes) * 0.5
last_known_local_bounds = bounds.grow(stroke) if bounds.size.x > 0.0 or bounds.size.y > 0.0 else Rect2(Vector2(-32.0, -32.0), Vector2(64.0, 64.0))
has_known_bounds = true
missing = false
set_process(false)
_apply_depth()
queue_redraw()
return true
func set_depth(value: StringName) -> void:
depth = value if value in [&"far", &"near", &"floor"] else &"near"
_apply_depth()
func _process(_delta: float) -> void:
if missing:
queue_redraw()
func get_visual_world_bounds() -> Rect2:
return global_transform * last_known_local_bounds
func get_saved_footprint() -> Rect2:
return last_known_local_bounds
func configure_missing(path: String, name: String, saved_bounds: Rect2, saved_depth: StringName) -> void:
asset_path = CONTENT_PATHS.canonicalize(path)
display_name = name.strip_edges() if not name.strip_edges().is_empty() else asset_path.get_file().get_basename()
if saved_bounds.size.x > 0.0 and saved_bounds.size.y > 0.0:
last_known_local_bounds = saved_bounds
has_known_bounds = true
else:
last_known_local_bounds = Rect2(Vector2(-32.0, -32.0), Vector2(64.0, 64.0))
has_known_bounds = false
depth = saved_depth
load_visual(asset_path)
func _set_missing() -> void:
missing = true
set_process(true)
if _visuals != null and is_instance_valid(_visuals):
_visuals.queue_free()
_visuals = null
_apply_depth()
queue_redraw()
func _apply_depth() -> void:
z_index = DRAW_ORDER.scenery_z(depth)
func _draw() -> void:
if not missing:
return
var rect: Rect2 = last_known_local_bounds
draw_rect(rect, Color("#87909D30"), true)
_draw_dashed_line(rect.position, Vector2(rect.end.x, rect.position.y))
_draw_dashed_line(Vector2(rect.end.x, rect.position.y), rect.end)
_draw_dashed_line(rect.end, Vector2(rect.position.x, rect.end.y))
_draw_dashed_line(Vector2(rect.position.x, rect.end.y), rect.position)
var camera: Camera2D = get_viewport().get_camera_2d() if is_inside_tree() else null
var zoom: float = maxf(0.05, camera.zoom.x) if camera != null else 1.0
draw_set_transform(rect.position + Vector2(0.0, -18.0), 0.0, Vector2.ONE / zoom)
draw_rect(Rect2(Vector2.ZERO, Vector2(188.0, 32.0)), Color("#242B37E8"), true)
draw_circle(Vector2(16.0, 16.0), 10.0, Color("#FFD166"))
var font: Font = ThemeDB.fallback_font
if font != null:
draw_string(font, Vector2(13.0, 22.0), "?", HORIZONTAL_ALIGNMENT_LEFT, -1.0, 16, Color("#242B37"))
draw_string(font, Vector2(32.0, 22.0), "Artwork not found", HORIZONTAL_ALIGNMENT_LEFT, -1.0, 18, Color("#F5F7FA"))
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
func _draw_dashed_line(start: Vector2, finish: Vector2) -> void:
var length: float = start.distance_to(finish)
if length <= 0.0:
return
var direction: Vector2 = (finish - start) / length
var offset: float = 0.0
var segment_index: int = 0
while offset < length:
var segment_end: float = minf(length, offset + 9.0)
var color: Color = Color("#E2E6EC") if segment_index % 2 == 0 else Color("#687281")
draw_line(start + direction * offset, start + direction * segment_end, color, 2.0)
offset += 15.0
segment_index += 1
func _max_stroke_width(shapes: Array[Dictionary]) -> float:
var width: float = 0.0
for shape: Dictionary in shapes:
width = maxf(width, float(shape.get("outline_width", 0.0)))
return width
@@ -0,0 +1 @@
uid://ysop8p4ekoc4
+323
View File
@@ -0,0 +1,323 @@
class_name StageSkyView
extends Control
## Adaptive screen-space Sky behind the world.
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 NIGHT_STAR_COLOR: Color = Color("#FFF4CE")
var _sky_id: StringName = &"sky_neutral"
var _stage_theme: SandboxTheme
var _gradient_texture: GradientTexture2D
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
_update_gradient_texture()
queue_redraw()
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
_sky_id = next_sky_id
_update_gradient_texture()
queue_redraw()
func get_sky() -> StringName:
return _sky_id
func apply_theme(theme: SandboxTheme) -> void:
_stage_theme = theme
queue_redraw()
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED:
queue_redraw()
func _draw() -> void:
if size.x <= 0.0 or size.y <= 0.0:
return
if _gradient_texture == null:
_update_gradient_texture()
if _gradient_texture != null:
draw_texture_rect(_gradient_texture, Rect2(Vector2.ZERO, size), false)
if _sky_id == &"sky_starry_night":
_draw_stars()
_draw_moon()
_draw_clouds()
else:
if _sky_id != &"sky_cloudy_day":
_draw_sun()
_draw_clouds()
func _update_gradient_texture() -> void:
var gradient: Gradient = Gradient.new()
var offsets: PackedFloat32Array = PackedFloat32Array()
var colors: PackedColorArray = PackedColorArray()
match _sky_id:
&"sky_clear_day":
offsets = PackedFloat32Array([0.0, 0.48, 1.0])
colors = PackedColorArray([Color("#54ADEA"), Color("#95D4F4"), Color("#E0F5FF")])
&"sky_cloudy_day":
offsets = PackedFloat32Array([0.0, 0.52, 1.0])
colors = PackedColorArray([Color("#687D98"), Color("#A2B7C6"), Color("#D9E2E4")])
&"sky_sunset":
offsets = PackedFloat32Array([0.0, 0.38, 0.71, 1.0])
colors = PackedColorArray([Color("#644B86"), Color("#A96B99"), Color("#E89491"), Color("#FFC57F")])
&"sky_starry_night":
offsets = PackedFloat32Array([0.0, 0.55, 1.0])
colors = PackedColorArray([Color("#111A3C"), Color("#253963"), Color("#526E95")])
_:
offsets = PackedFloat32Array([0.0, 0.5, 1.0])
colors = PackedColorArray([Color("#323944"), Color("#6B737F"), Color("#AEB6C1")])
gradient.offsets = offsets
gradient.colors = colors
_gradient_texture = GradientTexture2D.new()
_gradient_texture.gradient = gradient
_gradient_texture.width = 2
_gradient_texture.height = GRADIENT_TEXTURE_HEIGHT
_gradient_texture.fill_from = Vector2.ZERO
_gradient_texture.fill_to = Vector2(0.0, 1.0)
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 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))
draw_circle(center, radius, sun_color)
draw_circle(center + Vector2(-radius * 0.19, -radius * 0.2), radius * 0.54, Color(1.0, 0.97, 0.82, 0.32))
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)
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)
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 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)]
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)]
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)]
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)]
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])
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)
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_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)
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)
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_polyline(moon_outline, Color("#17213A"), maxf(1.0, moon_scale * 5.5), true)
func _build_cloud_outline() -> PackedVector2Array:
return _sample_curve_chain(_build_cloud_outer_curves(), 10)
func _build_cloud_underside() -> PackedVector2Array:
return _sample_curve_chain(_build_cloud_underside_curves(), 10)
func _build_cloud_outer_curves() -> Array[PackedVector2Array]:
var curves: Array[PackedVector2Array] = []
curves.append(PackedVector2Array([Vector2(18, 74), Vector2(7, 71), Vector2(2, 62), Vector2(5, 52)]))
curves.append(PackedVector2Array([Vector2(5, 52), Vector2(8, 42), Vector2(19, 36), Vector2(32, 39)]))
curves.append(PackedVector2Array([Vector2(32, 39), Vector2(29, 25), Vector2(40, 13), Vector2(54, 13)]))
curves.append(PackedVector2Array([Vector2(54, 13), Vector2(64, 13), Vector2(72, 18), Vector2(77, 25)]))
curves.append(PackedVector2Array([Vector2(77, 25), Vector2(84, 9), Vector2(101, 2), Vector2(116, 7)]))
curves.append(PackedVector2Array([Vector2(116, 7), Vector2(131, 11), Vector2(141, 24), Vector2(142, 38)]))
curves.append(PackedVector2Array([Vector2(142, 38), Vector2(153, 30), Vector2(167, 32), Vector2(176, 40)]))
curves.append(PackedVector2Array([Vector2(176, 40), Vector2(182, 45), Vector2(185, 51), Vector2(185, 57)]))
curves.append(PackedVector2Array([Vector2(185, 57), Vector2(197, 58), Vector2(205, 67), Vector2(204, 78)]))
curves.append(PackedVector2Array([Vector2(204, 78), Vector2(203, 89), Vector2(193, 97), Vector2(180, 97)]))
curves.append(PackedVector2Array([Vector2(180, 97), Vector2(171, 97), Vector2(164, 94), Vector2(159, 90)]))
curves.append(PackedVector2Array([Vector2(159, 90), Vector2(148, 100), Vector2(131, 101), Vector2(119, 93)]))
curves.append(PackedVector2Array([Vector2(119, 93), Vector2(107, 102), Vector2(88, 103), Vector2(75, 94)]))
curves.append(PackedVector2Array([Vector2(75, 94), Vector2(63, 101), Vector2(47, 98), Vector2(39, 89)]))
curves.append(PackedVector2Array([Vector2(39, 89), Vector2(30, 89), Vector2(23, 83), Vector2(18, 74)]))
return curves
func _build_cloud_underside_curves() -> Array[PackedVector2Array]:
var curves: Array[PackedVector2Array] = []
curves.append(PackedVector2Array([Vector2(9, 69), Vector2(20, 79), Vector2(31, 79), Vector2(42, 75)]))
curves.append(PackedVector2Array([Vector2(42, 75), Vector2(57, 89), Vector2(71, 86), Vector2(82, 81)]))
curves.append(PackedVector2Array([Vector2(82, 81), Vector2(96, 92), Vector2(111, 89), Vector2(122, 82)]))
curves.append(PackedVector2Array([Vector2(122, 82), Vector2(139, 93), Vector2(152, 89), Vector2(163, 80)]))
curves.append(PackedVector2Array([Vector2(163, 80), Vector2(179, 89), Vector2(195, 83), Vector2(202, 74)]))
curves.append(PackedVector2Array([Vector2(202, 74), Vector2(205, 87), Vector2(195, 97), Vector2(180, 97)]))
curves.append(PackedVector2Array([Vector2(180, 97), Vector2(171, 97), Vector2(164, 94), Vector2(159, 90)]))
curves.append(PackedVector2Array([Vector2(159, 90), Vector2(148, 100), Vector2(131, 101), Vector2(119, 93)]))
curves.append(PackedVector2Array([Vector2(119, 93), Vector2(107, 102), Vector2(88, 103), Vector2(75, 94)]))
curves.append(PackedVector2Array([Vector2(75, 94), Vector2(63, 101), Vector2(47, 98), Vector2(39, 89)]))
curves.append(PackedVector2Array([Vector2(39, 89), Vector2(25, 91), Vector2(13, 82), Vector2(9, 69)]))
return curves
func _build_cloud_accents() -> Array[PackedVector2Array]:
var curves: Array[PackedVector2Array] = []
curves.append(PackedVector2Array([Vector2(42, 43), Vector2(49, 54), Vector2(58, 59), Vector2(69, 58)]))
curves.append(PackedVector2Array([Vector2(85, 33), Vector2(96, 21), Vector2(114, 21), Vector2(126, 32)]))
curves.append(PackedVector2Array([Vector2(142, 58), Vector2(151, 48), Vector2(164, 47), Vector2(173, 53)]))
return curves
func _build_moon_curves() -> Array[PackedVector2Array]:
var curves: Array[PackedVector2Array] = []
curves.append(PackedVector2Array([Vector2(15, 13), Vector2(69, 20), Vector2(113, 54), Vector2(129, 104)]))
curves.append(PackedVector2Array([Vector2(129, 104), Vector2(149, 166), Vector2(119, 224), Vector2(61, 235)]))
curves.append(PackedVector2Array([Vector2(61, 235), Vector2(36, 240), Vector2(13, 231), Vector2(2, 216)]))
curves.append(PackedVector2Array([Vector2(2, 216), Vector2(40, 218), Vector2(60, 193), Vector2(68, 158)]))
curves.append(PackedVector2Array([Vector2(68, 158), Vector2(85, 103), Vector2(63, 47), Vector2(15, 13)]))
return curves
func _build_moon_outer_arc_curves() -> Array[PackedVector2Array]:
var curves: Array[PackedVector2Array] = []
curves.append(PackedVector2Array([Vector2(15, 13), Vector2(69, 20), Vector2(113, 54), Vector2(129, 104)]))
curves.append(PackedVector2Array([Vector2(129, 104), Vector2(149, 166), Vector2(119, 224), Vector2(61, 235)]))
curves.append(PackedVector2Array([Vector2(61, 235), Vector2(36, 240), Vector2(13, 231), Vector2(2, 216)]))
return curves
func _build_moon_highlight() -> Array[PackedVector2Array]:
var curves: Array[PackedVector2Array] = []
curves.append(PackedVector2Array([Vector2(29, 37), Vector2(63, 58), Vector2(85, 98), Vector2(87, 128)]))
return curves
func _sample_curve_chain(curves: Array[PackedVector2Array], steps_per_curve: int) -> PackedVector2Array:
var sampled: PackedVector2Array = PackedVector2Array()
if steps_per_curve <= 0:
return sampled
for curve_index: int in curves.size():
var curve: PackedVector2Array = curves[curve_index]
if curve.size() != 4:
continue
for step_index: int in range(steps_per_curve + 1):
if curve_index > 0 and step_index == 0:
continue
var t: float = float(step_index) / float(steps_per_curve)
sampled.append(_cubic_point(curve, t))
return sampled
func _cubic_point(curve: PackedVector2Array, t: float) -> Vector2:
var inverse_t: float = 1.0 - t
return (
curve[0] * inverse_t * inverse_t * inverse_t
+ curve[1] * 3.0 * inverse_t * inverse_t * t
+ curve[2] * 3.0 * inverse_t * t * t
+ curve[3] * t * t * t
)
func _map_cloud_points(points: PackedVector2Array, rect: Rect2) -> 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))
return mapped
func _map_moon_points(points: PackedVector2Array, rect: Rect2) -> PackedVector2Array:
var mapped: PackedVector2Array = PackedVector2Array()
for point: Vector2 in points:
mapped.append(rect.position + point * (rect.size.y / 240.0))
return mapped
func _build_moon_warm_rim(outer_arc: PackedVector2Array, width: float) -> PackedVector2Array:
var rim: PackedVector2Array = PackedVector2Array()
if outer_arc.size() < 3:
return rim
for point: Vector2 in outer_arc:
rim.append(point)
for point_index: int in range(outer_arc.size() - 1, -1, -1):
var tangent: Vector2
if point_index == 0:
tangent = outer_arc[1] - outer_arc[0]
elif point_index == outer_arc.size() - 1:
tangent = outer_arc[point_index] - outer_arc[point_index - 1]
else:
tangent = outer_arc[point_index + 1] - outer_arc[point_index - 1]
var inward: Vector2 = Vector2(-tangent.y, tangent.x).normalized()
var progress: float = float(point_index) / float(outer_arc.size() - 1)
var local_width: float = width * sin(PI * progress)
rim.append(outer_arc[point_index] + inward * local_width)
return rim
func _with_opacity(color: Color, opacity: float) -> Color:
return Color(color.r, color.g, color.b, color.a * opacity)
@@ -0,0 +1 @@
uid://dxoqflho7kyik
@@ -0,0 +1,116 @@
class_name StageStarterCardPreview
extends SubViewportContainer
## Lightweight live preview of a saved starter Stage scene.
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
var _viewport: SubViewport
var _sky_view: StageSkyView
var _world: Node2D
var _loaded_nodes: Array[Node2D] = []
var _scene_valid: bool = false
func configure(entry: Dictionary) -> void:
name = "StarterScenePreview"
custom_minimum_size = Vector2(128.0, 128.0)
mouse_filter = Control.MOUSE_FILTER_IGNORE
stretch = true
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
var result: Dictionary = STAGE_SCENE_IO.read_file(String(entry.get("path", "")))
if not bool(result.get("ok", false)):
return
var data: Dictionary = result.get("data", {}) as Dictionary
_scene_valid = true
_build_preview_viewport(data)
func has_preview_scene() -> bool:
return _scene_valid and _viewport != null and _sky_view != null and _world != null
func get_loaded_object_count() -> int:
return _loaded_nodes.size()
func _build_preview_viewport(data: Dictionary) -> void:
_viewport = SubViewport.new()
_viewport.name = "PreviewViewport"
_viewport.size = PREVIEW_SIZE
_viewport.world_2d = World2D.new()
_viewport.transparent_bg = false
_viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
add_child(_viewport)
var sky_layer := CanvasLayer.new()
sky_layer.name = "PreviewSky"
sky_layer.layer = -1
_viewport.add_child(sky_layer)
_sky_view = STAGE_SKY_VIEW.new() as StageSkyView
_sky_view.name = "SkyView"
_sky_view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_sky_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
_sky_view.set_sky(StringName(String(data.get("sky_id", "sky_neutral"))))
sky_layer.add_child(_sky_view)
_world = Node2D.new()
_world.name = "PreviewWorld"
_viewport.add_child(_world)
var spawner: StageSpawner = STAGE_SPAWNER.new(_world) as StageSpawner
var adapter: StageSceneAdapter = STAGE_SCENE_ADAPTER.new() as StageSceneAdapter
var applied: Dictionary = adapter.apply(data, _world, spawner)
for raw_node: Variant in applied.get("loaded_nodes", []):
var stage_node: Node2D = raw_node as Node2D
if stage_node == null:
continue
_loaded_nodes.append(stage_node)
if stage_node is PropBlock:
var prop: PropBlock = stage_node as PropBlock
prop.freeze = true
prop.gravity_scale = 0.0
prop.collision_layer = 0
prop.collision_mask = 0
call_deferred("_fit_scene_to_preview")
func _fit_scene_to_preview() -> 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
_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
@@ -0,0 +1 @@
uid://lw57y3sl5wfk
@@ -0,0 +1,267 @@
class_name StageSurfaceRenderer
extends RefCounted
## World-aligned, bounded vector motifs for finite walls and Ground.
const WALL_FINISHES: Array[StringName] = [&"finish_wall_paint", &"finish_wall_brick", &"finish_wall_wood"]
const GROUND_FINISHES: Array[StringName] = [&"finish_ground_paint", &"finish_ground_brick", &"finish_ground_wood", &"finish_ground_grass", &"finish_ground_dirt", &"finish_ground_sidewalk", &"finish_ground_asphalt"]
const MAX_CELLS: int = 1200
const BRICK_WIDTH: float = 82.0
const BRICK_HEIGHT: float = 34.0
const MAX_VISIBLE_BRICK_CELLS: int = 12000
static func is_wall_finish(id: StringName) -> bool:
return WALL_FINISHES.has(id)
static func is_ground_finish(id: StringName) -> bool:
return GROUND_FINISHES.has(id) or id == &"finish_ground_legacy"
static func draw_wall(
canvas: CanvasItem,
rect: Rect2,
finish: StringName,
tint: Color,
world_origin: Vector2,
drawing_scale: Vector2 = Vector2.ONE
) -> void:
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, true)
static func draw_ground(
canvas: CanvasItem,
rect: Rect2,
finish: StringName,
tint: Color,
world_origin: Vector2,
drawing_scale: Vector2 = Vector2.ONE
) -> void:
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false)
static func _draw_surface(
canvas: CanvasItem,
rect: Rect2,
finish: StringName,
tint: Color,
world_origin: Vector2,
drawing_scale: Vector2,
wall: bool
) -> void:
var normalized := rect.abs()
if normalized.size.x <= 0.0 or normalized.size.y <= 0.0:
return
var motif_scale: Vector2 = _normalized_scale(drawing_scale)
var base: Color = tint
if finish == &"finish_ground_legacy":
base = Color("#468B42")
elif finish.ends_with("brick"):
base = Color("#B95F3D") if wall else Color("#994D35")
elif finish.ends_with("wood"):
base = Color("#9A673C") if wall else Color("#B88951")
elif finish == &"finish_ground_grass":
base = Color("#72A84C")
elif finish == &"finish_ground_dirt":
base = Color("#8E633E")
elif finish == &"finish_ground_sidewalk":
base = Color("#B7B9B2")
elif finish == &"finish_ground_asphalt":
base = Color("#505A62")
canvas.draw_rect(normalized, base, true)
if finish.ends_with("brick"):
var view_rect: Rect2 = _visible_local_rect(canvas)
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
var detail_scale: float = brick_detail_scale_for_view(view_rect.size if not view_rect.size.is_zero_approx() else normalized.size, motif_scale)
_draw_bricks(canvas, pattern_rect, world_origin, base, motif_scale, detail_scale)
elif finish.ends_with("wood"):
_draw_wood(canvas, normalized, world_origin, base, motif_scale)
elif finish == &"finish_ground_grass":
_draw_grass(canvas, normalized, world_origin, motif_scale)
elif finish == &"finish_ground_dirt":
_draw_dirt(canvas, normalized, world_origin, motif_scale)
elif finish == &"finish_ground_sidewalk":
_draw_sidewalk(canvas, normalized, world_origin, motif_scale)
elif finish == &"finish_ground_asphalt":
_draw_asphalt(canvas, normalized, world_origin, motif_scale)
elif finish != &"finish_wall_paint" and finish != &"finish_ground_paint" and finish != &"finish_ground_legacy":
push_warning("StageSurfaceRenderer: unknown finish '%s'; drawing paint." % String(finish))
static func brick_detail_scale_for_view(view_size: Vector2, motif_scale: Vector2 = Vector2.ONE) -> float:
var scale: float = 1.0
var safe_size: Vector2 = Vector2(maxf(1.0, view_size.x), maxf(1.0, view_size.y))
var safe_motif_scale: Vector2 = _normalized_scale(motif_scale)
for _iteration: int in 8:
var columns: int = int(ceil(safe_size.x * safe_motif_scale.x / (BRICK_WIDTH * scale))) + 2
var rows: int = int(ceil(safe_size.y * safe_motif_scale.y / (BRICK_HEIGHT * scale))) + 2
var cell_count: int = rows * columns
if cell_count <= MAX_VISIBLE_BRICK_CELLS:
return scale
scale *= sqrt(float(cell_count) / float(MAX_VISIBLE_BRICK_CELLS)) * 1.01
return scale
static func _draw_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, base: Color, motif_scale: Vector2, detail_scale: float = 1.0) -> void:
var brick_world_w: float = BRICK_WIDTH * detail_scale
var brick_world_h: float = BRICK_HEIGHT * detail_scale
var brick_w: float = brick_world_w / motif_scale.x
var brick_h: float = brick_world_h / motif_scale.y
var mortar := base.lightened(0.46)
var x_start: float = (floorf((rect.position.x * motif_scale.x + origin.x) / brick_world_w) * brick_world_w - origin.x) / motif_scale.x
var y_start: float = (floorf((rect.position.y * motif_scale.y + origin.y) / brick_world_h) * brick_world_h - origin.y) / motif_scale.y
var rows: int = int(ceil(rect.size.y / brick_h)) + 2
var columns: int = int(ceil(rect.size.x / brick_w)) + 2
for row: int in rows:
var y: float = y_start + float(row) * brick_h
var offset: float = brick_w * 0.5 if posmod(row + int(floori((rect.position.y * motif_scale.y + origin.y) / brick_world_h)), 2) == 1 else 0.0
_draw_clipped_line(canvas, rect, Vector2(rect.position.x, y), Vector2(rect.end.x, y), mortar, 3.0 / motif_scale.y)
for column: int in columns:
var x: float = x_start + float(column) * brick_w + offset
if x < rect.position.x or x > rect.end.x:
continue
var next_row_y: float = y + brick_h
_draw_clipped_line(canvas, rect, Vector2(x, y), Vector2(x, next_row_y), mortar, 3.0 / motif_scale.x)
static func _visible_local_rect(canvas: CanvasItem) -> Rect2:
if canvas == null or not canvas.is_inside_tree():
return Rect2()
var viewport: Viewport = canvas.get_viewport()
if viewport == null:
return Rect2()
var local_from_viewport: Transform2D = canvas.get_global_transform_with_canvas().affine_inverse()
var viewport_rect: Rect2 = viewport.get_visible_rect()
var corners: Array[Vector2] = [
viewport_rect.position,
Vector2(viewport_rect.end.x, viewport_rect.position.y),
viewport_rect.end,
Vector2(viewport_rect.position.x, viewport_rect.end.y),
]
var local_rect: Rect2 = Rect2(local_from_viewport * corners[0], Vector2.ZERO)
for corner_index: int in range(1, corners.size()):
local_rect = local_rect.expand(local_from_viewport * corners[corner_index])
return local_rect
static func _draw_wood(canvas: CanvasItem, rect: Rect2, origin: Vector2, base: Color, motif_scale: Vector2) -> void:
const BOARD: float = 64.0
var line_color := base.darkened(0.28)
var board_h: float = BOARD / motif_scale.y
var start_y: float = (floorf((rect.position.y * motif_scale.y + origin.y) / BOARD) * BOARD - origin.y) / motif_scale.y - board_h
var count: int = mini(int(ceil(rect.size.y / board_h)) + 3, MAX_CELLS)
for index: int in count:
var y: float = start_y + float(index) * board_h
_draw_clipped_line(canvas, rect, Vector2(rect.position.x, y), Vector2(rect.end.x, y), line_color, 2.0 / motif_scale.y)
var seed_x: float = fposmod(origin.x * 0.37 + float(index * 41), 90.0) / motif_scale.x
_draw_clipped_line(
canvas,
rect,
Vector2(rect.position.x + seed_x, y + board_h * 0.5),
Vector2(rect.position.x + seed_x + 28.0 / motif_scale.x, y + board_h * 0.5),
line_color,
1.0 / motif_scale.y
)
static func _draw_clipped_line(canvas: CanvasItem, rect: Rect2, from: Vector2, to: Vector2, color: Color, width: float) -> void:
var clipped: PackedVector2Array = _clip_line_to_rect(rect, from, to, width)
if clipped.size() == 2:
canvas.draw_line(clipped[0], clipped[1], color, width)
static func _clip_line_to_rect(rect: Rect2, from: Vector2, to: Vector2, width: float) -> PackedVector2Array:
var inset: float = maxf(0.0, width) * 0.5
var safe_rect: Rect2 = Rect2(rect.position + Vector2.ONE * inset, rect.size - Vector2.ONE * inset * 2.0)
if safe_rect.size.x <= 0.0 or safe_rect.size.y <= 0.0:
return PackedVector2Array()
var delta: Vector2 = to - from
var p_values: Array[float] = [-delta.x, delta.x, -delta.y, delta.y]
var q_values: Array[float] = [
from.x - safe_rect.position.x,
safe_rect.end.x - from.x,
from.y - safe_rect.position.y,
safe_rect.end.y - from.y,
]
var t_min: float = 0.0
var t_max: float = 1.0
for edge_index: int in p_values.size():
var p_value: float = p_values[edge_index]
var q_value: float = q_values[edge_index]
if is_zero_approx(p_value):
if q_value < 0.0:
return PackedVector2Array()
continue
var ratio: float = q_value / p_value
if p_value < 0.0:
if ratio > t_max:
return PackedVector2Array()
t_min = maxf(t_min, ratio)
else:
if ratio < t_min:
return PackedVector2Array()
t_max = minf(t_max, ratio)
if t_min > t_max:
return PackedVector2Array()
return PackedVector2Array([from + delta * t_min, from + delta * t_max])
static func _draw_grass(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
var pitch: float = 18.0 / motif_scale.x
var phase: float = fposmod(origin.x, 18.0) / motif_scale.x
var count: int = mini(int(ceil(rect.size.x / pitch)) + 2, MAX_CELLS)
var baseline: float = rect.position.y
for index: int in count:
var x: float = rect.position.x + float(index) * pitch - phase
var seed: int = absi(int(floori((x * motif_scale.x + origin.x) * 0.23)))
var height: float = 5.0 + float(seed % 8)
var green := Color("#3D7E39") if seed % 3 == 0 else Color("#4D913F")
canvas.draw_line(Vector2(x, baseline + 4.0 / motif_scale.y), Vector2(x - 3.0 / motif_scale.x, baseline - height / motif_scale.y), green, 2.0 / motif_scale.x)
canvas.draw_line(Vector2(x + 4.0 / motif_scale.x, baseline + 4.0 / motif_scale.y), Vector2(x + 7.0 / motif_scale.x, baseline - height * 0.72 / motif_scale.y), green, 1.5 / motif_scale.x)
if seed % 5 == 0:
_draw_world_circle(canvas, Vector2(x + 2.0 / motif_scale.x, baseline - (height + 2.0) / motif_scale.y), 2.0, Color("#C6D96B"), motif_scale)
static func _draw_dirt(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
var pitch: float = 26.0 / motif_scale.x
var phase: float = fposmod(origin.x, 26.0) / motif_scale.x
for index: int in mini(int(ceil(rect.size.x / pitch)) + 1, MAX_CELLS):
var x: float = rect.position.x + float(index) * pitch - phase
var y: float = rect.position.y + fposmod(float(index * 17) + origin.y, 18.0) / motif_scale.y
_draw_world_circle(canvas, Vector2(x, y), 2.0, Color("#67492F"), motif_scale)
static func _draw_sidewalk(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
const SIZE: float = 72.0
var local_size: float = SIZE / motif_scale.x
var color := Color("#92968F")
var x: float = rect.position.x - fposmod(origin.x, SIZE) / motif_scale.x
var count: int = mini(int(ceil(rect.size.x / local_size)) + 2, MAX_CELLS)
for index: int in count:
var px: float = x + float(index) * local_size
canvas.draw_line(Vector2(px, rect.position.y), Vector2(px, rect.end.y), color, 2.0 / motif_scale.x)
canvas.draw_line(Vector2(rect.position.x, rect.position.y), Vector2(rect.end.x, rect.position.y), color, 2.0 / motif_scale.y)
static func _draw_asphalt(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
var local_pitch: float = 80.0 / motif_scale.x
var phase: float = fposmod(origin.x, 80.0) / motif_scale.x
var count: int = mini(int(ceil(rect.size.x / local_pitch)) + 2, MAX_CELLS)
var world_height: float = maxf(rect.size.y * motif_scale.y, 1.0)
for index: int in count:
var x: float = rect.position.x + float(index) * local_pitch - phase
var y: float = rect.position.y + fposmod(float(index * 31) + origin.y, world_height) / motif_scale.y
_draw_world_circle(canvas, Vector2(x, y), 1.5, Color("#727A7D"), motif_scale)
static func _draw_world_circle(canvas: CanvasItem, center: Vector2, radius: float, color: Color, motif_scale: Vector2) -> void:
var points := PackedVector2Array()
for point_index: int in 12:
var angle: float = TAU * float(point_index) / 12.0
points.append(center + Vector2(cos(angle) * radius / motif_scale.x, sin(angle) * radius / motif_scale.y))
canvas.draw_colored_polygon(points, color)
static func _normalized_scale(scale: Vector2) -> Vector2:
return Vector2(maxf(absf(scale.x), 0.001), maxf(absf(scale.y), 0.001))
@@ -0,0 +1 @@
uid://cefj7bf1p3bq2
+36
View File
@@ -0,0 +1,36 @@
class_name StageWallPanel
extends Node2D
## Finite decorative wall panel with world-scale finish motifs.
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
var finish_id: StringName = &"finish_wall_paint"
var tint: Color = Color("#5A9D70")
var panel_size: Vector2 = Vector2(320.0, 400.0)
## Saved source-wall anchor offset, retained to preserve erased-fragment metadata.
## Motif drawing uses the fragment's current world origin so its brick courses stay aligned.
var motif_origin_offset: Vector2 = Vector2.ZERO
func configure(new_size: Vector2, finish: StringName, color: Color) -> void:
panel_size = Vector2(maxf(1.0, new_size.x), maxf(1.0, new_size.y))
finish_id = finish if SURFACE_RENDERER.is_wall_finish(finish) else &"finish_wall_paint"
tint = color
z_index = DRAW_ORDER.WALL
queue_redraw()
func get_local_bounds() -> Rect2:
return Rect2(-panel_size * 0.5, panel_size)
func get_effective_motif_origin_world() -> Vector2:
return global_position + motif_origin_offset
func get_surface_draw_origin_world() -> Vector2:
return global_position
func _draw() -> void:
SURFACE_RENDERER.draw_wall(self, get_local_bounds(), finish_id, tint, get_surface_draw_origin_world())
@@ -0,0 +1 @@
uid://d1ttvnqumh2hd
+21
View File
@@ -0,0 +1,21 @@
class_name StageDrawOrder
extends RefCounted
## Semantic draw bands shared by scenery, terrain, Props, and characters.
const WALL: int = -40
const FAR: int = -30
const NEAR: int = -20
const TERRAIN: int = -10
const FLOOR: int = -5
static func scenery_z(depth: StringName) -> int:
match depth:
&"far": return FAR
&"floor": return FLOOR
_: return NEAR
static func apply_terrain(node: TerrainBlock) -> void:
if node != null:
node.z_index = TERRAIN
+1
View File
@@ -0,0 +1 @@
uid://dsyoepjah36yk
+58 -9
View File
@@ -11,6 +11,8 @@ extends Node2D
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
const StageWallPanelScript := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const StageSceneryArtworkScript := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
# ---------------------------------------------------------------------------
# Enums
@@ -42,6 +44,7 @@ const ROTATE_HANDLE_MARGIN_PX := 48.0
const ROTATE_RING_TOLERANCE_PX := 12.0
const SCALE_CROSS_SIZE_PX := 5.0
const SCALE_HIT_RADIUS_PX := 12.0
const SCENERY_SCALE_HIT_HALF_SIZE_PX := 22.0
const MIN_SCALE: float = 0.05
const MAX_SCALE: float = 20.0
@@ -55,6 +58,7 @@ var camera: Camera2D = null
var snap_size: float = 0.0
var _enabled: bool = true
var _transform_handles_visible: bool = true
var _targets: Array[Node2D] = []
var _hovered: Node2D = null
var _dragging: Handle = Handle.NONE
@@ -91,6 +95,11 @@ func set_enabled(enabled: bool) -> void:
queue_redraw()
func set_transform_handles_visible(visible: bool) -> void:
_transform_handles_visible = visible
queue_redraw()
func set_snap_size(size: float) -> void:
snap_size = maxf(size, 0.0)
@@ -102,12 +111,18 @@ func set_box_rect(rect: Rect2) -> void:
## Returns ROTATE if the cursor is on the rotate ring (single selection only).
func hit_test(world_pos: Vector2) -> Handle:
if not _enabled or _targets.size() != 1:
if not _enabled or not _transform_handles_visible or _targets.size() != 1:
return Handle.NONE
var target := _targets[0]
if target == null or not is_instance_valid(target):
return Handle.NONE
var rect := STAGE_SELECTION.get_world_aabb(target)
var rect: Rect2 = _target_bounds(target)
if _is_scenery_target(target):
for corner: Vector2 in _corners(rect):
var offset: Vector2 = (world_pos - corner) * _zoom()
if absf(offset.x) <= SCENERY_SCALE_HIT_HALF_SIZE_PX and absf(offset.y) <= SCENERY_SCALE_HIT_HALF_SIZE_PX:
return Handle.SCALE
return Handle.NONE
if target is PropBlockScript:
for corner: Vector2 in _corners(rect):
if world_pos.distance_to(corner) <= SCALE_HIT_RADIUS_PX / _zoom():
@@ -118,12 +133,29 @@ func hit_test(world_pos: Vector2) -> Handle:
return Handle.NONE
func get_scenery_resize_corner(world_pos: Vector2) -> Vector2:
if not _enabled or not _transform_handles_visible or _targets.size() != 1 or not _is_scenery_target(_targets[0]):
return Vector2.INF
var bounds: Rect2 = _target_bounds(_targets[0])
var closest: Vector2 = Vector2.INF
var closest_distance: float = INF
for corner: Vector2 in _corners(bounds):
var offset: Vector2 = (world_pos - corner) * _zoom()
if absf(offset.x) > SCENERY_SCALE_HIT_HALF_SIZE_PX or absf(offset.y) > SCENERY_SCALE_HIT_HALF_SIZE_PX:
continue
var distance: float = offset.length_squared()
if distance < closest_distance:
closest = corner
closest_distance = distance
return closest
## Begins a rotate drag (used when the ring is clicked).
func begin_drag(world_pos: Vector2) -> void:
_dragging = hit_test(world_pos)
_drag_last = world_pos
if _dragging == Handle.ROTATE and _targets.size() == 1 and is_instance_valid(_targets[0]):
_rotate_center = STAGE_SELECTION.get_world_aabb(_targets[0]).get_center()
_rotate_center = _target_bounds(_targets[0]).get_center()
_rotate_start_rotation = _targets[0].global_rotation
_rotate_start_angle = (world_pos - _rotate_center).angle()
transform_started.emit(_targets.duplicate())
@@ -220,16 +252,21 @@ func _draw() -> void:
return
var zoom := _zoom()
if _hovered != null and is_instance_valid(_hovered) and not _hovered.is_queued_for_deletion() and not _targets.has(_hovered):
draw_rect(STAGE_SELECTION.get_world_aabb(_hovered), HOVER_COLOR, false, HOVER_WIDTH / zoom)
draw_rect(_target_bounds(_hovered), HOVER_COLOR, false, HOVER_WIDTH / zoom)
# A selection outline for every selected object.
for node: Node2D in _targets:
if node != null and is_instance_valid(node):
draw_rect(STAGE_SELECTION.get_world_aabb(node), SELECTION_COLOR, false, SELECTION_WIDTH / zoom)
draw_rect(_target_bounds(node), SELECTION_COLOR, false, SELECTION_WIDTH / zoom)
# Rotate ring only for a single selection.
if _targets.size() == 1 and is_instance_valid(_targets[0]):
var rect := STAGE_SELECTION.get_world_aabb(_targets[0])
draw_arc(rect.get_center(), _rotate_ring_radius(rect), 0.0, TAU, 64, ROTATE_COLOR, ROTATE_WIDTH / zoom, true)
if _targets[0] is PropBlockScript:
if _transform_handles_visible and _targets.size() == 1 and is_instance_valid(_targets[0]):
var rect: Rect2 = _target_bounds(_targets[0])
if not _is_scenery_target(_targets[0]):
draw_arc(rect.get_center(), _rotate_ring_radius(rect), 0.0, TAU, 64, ROTATE_COLOR, ROTATE_WIDTH / zoom, true)
if _is_scenery_target(_targets[0]):
for corner: Vector2 in _corners(rect):
draw_circle(corner, 11.0 / zoom, Color("#111821E8"))
draw_circle(corner, 7.0 / zoom, Color("#F5F7FA"))
elif _targets[0] is PropBlockScript:
var half: float = SCALE_CROSS_SIZE_PX / zoom
for corner: Vector2 in _corners(rect):
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
@@ -261,6 +298,18 @@ func _snap(v: Vector2) -> Vector2:
func _corners(rect: Rect2) -> Array[Vector2]:
return [rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)]
func _is_scenery_target(node: Node2D) -> bool:
return node != null and (node.get_script() == StageWallPanelScript or node.get_script() == StageSceneryArtworkScript)
func _target_bounds(node: Node2D) -> Rect2:
if node != null and node.get_script() == StageWallPanelScript:
return node.global_transform * (node as StageWallPanel).get_local_bounds()
if node != null and node.get_script() == StageSceneryArtworkScript:
return (node as StageSceneryArtwork).get_visual_world_bounds()
return STAGE_SELECTION.get_world_aabb(node)
func _nearest_corner(rect: Rect2, world_pos: Vector2) -> Vector2:
var nearest: Vector2 = rect.position
var distance: float = INF
+74 -2
View File
@@ -3,13 +3,17 @@ extends RefCounted
## JSON boundary for Sandbox Stage documents. This class deliberately has no
## scene-tree dependencies, which makes rejected documents non-destructive.
const FILE_VERSION: String = "1.4"
const FILE_VERSION: String = "1.5"
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
const WALL_FINISHES: Array[String] = ["finish_wall_paint", "finish_wall_brick", "finish_wall_wood"]
const GROUND_FINISHES: Array[String] = ["finish_ground_paint", "finish_ground_brick", "finish_ground_wood", "finish_ground_grass", "finish_ground_dirt", "finish_ground_sidewalk", "finish_ground_asphalt", "finish_ground_legacy"]
const SKY_IDS: Array[String] = ["sky_clear_day", "sky_cloudy_day", "sky_sunset", "sky_starry_night", "sky_neutral"]
const SCENERY_DEPTHS: Array[String] = ["far", "near", "floor"]
static func vector_to_data(value: Vector2) -> Dictionary:
return {"x": value.x, "y": value.y}
@@ -26,6 +30,14 @@ static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2:
var data: Dictionary = value
return Vector2(float(data["x"]), float(data["y"]))
static func color_from_data(value: Variant, fallback: Color) -> Color:
if not _valid_color(value):
return fallback
var html_color: String = String(value).strip_edges()
if not html_color.begins_with("#"):
html_color = "#" + html_color
return Color.from_string(html_color, fallback)
static func encode_queue(actions: Array[Dictionary]) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
@@ -171,9 +183,10 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
prop_entry["wobble_on_impact"] = false
_assign_legacy_depths(current.get("objects", []))
return current
if version not in ["1.0", "1.1", "1.2", "1.3"]:
if version not in ["1.0", "1.1", "1.2", "1.3", "1.4"]:
return {}
var migrated: Dictionary = data.duplicate(true)
migrated["sky_id"] = "sky_neutral"
for raw: Variant in migrated.get("objects", []):
if not raw is Dictionary:
continue
@@ -187,6 +200,8 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
object["next_use_spot_number"] = object.get("next_use_spot_number", 1)
if not object.has("wobble_on_impact"):
object["wobble_on_impact"] = false
if String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground":
object["finish_id"] = "finish_ground_legacy"
migrated["version"] = FILE_VERSION
_assign_legacy_depths(migrated.get("objects", []))
return migrated
@@ -217,6 +232,9 @@ static func _assign_legacy_depths(raw_objects: Variant) -> void:
roles["depth"] = PROP_STAGE_ROLES.DEPTH_FRONT if characters_before > characters_after else PROP_STAGE_ROLES.DEPTH_BEHIND
static func _validate_root(data: Dictionary) -> Dictionary:
var sky_id: Variant = data.get("sky_id", "sky_neutral")
if not sky_id is String or not SKY_IDS.has(String(sky_id)):
return {"ok": false, "error": "Scene Sky is invalid."}
var seen: Dictionary = {}
var seen_stage_ids: Dictionary = {}
for raw: Variant in data["objects"]:
@@ -249,8 +267,62 @@ static func _validate_root(data: Dictionary) -> Dictionary:
for key: String in ["holds_other_props", "blocks_stickmen", "can_pick_up", "released_blocks_stickmen", "released_bumps_other_props", "legacy_draw_order"]:
if roles.has(key) and not roles[key] is bool:
return {"ok": false, "error": "Prop role '%s' must be true or false." % key}
elif String(entry.get("type", "")) == "wall_panel":
if not is_vector_data(entry.get("size")):
return {"ok": false, "error": "Wall panel size is invalid."}
var wall_size: Vector2 = data_to_vector(entry.get("size"), Vector2.ZERO)
if wall_size.x <= 0.0 or wall_size.y <= 0.0 or not WALL_FINISHES.has(String(entry.get("finish_id", ""))):
return {"ok": false, "error": "Wall panel size or finish is invalid."}
if not _valid_color(entry.get("color", "#5A9D70")):
return {"ok": false, "error": "Wall panel color is invalid."}
if entry.has("motif_origin_offset") and not is_vector_data(entry.get("motif_origin_offset")):
return {"ok": false, "error": "Wall panel motif origin is invalid."}
elif String(entry.get("type", "")) == "scenery_artwork":
var asset_id: Variant = entry.get("asset_id", "")
if not asset_id is String or String(asset_id).strip_edges().is_empty():
return {"ok": false, "error": "Scenery artwork reference is invalid."}
entry["asset_id"] = CONTENT_PATHS.canonicalize(String(asset_id))
if not entry.get("display_name", "Scenery") is String or not SCENERY_DEPTHS.has(String(entry.get("depth", "near"))):
return {"ok": false, "error": "Scenery artwork label or depth is invalid."}
var art_scale: Vector2 = data_to_vector(entry.get("scale"), Vector2.ZERO)
if not is_vector_data(entry.get("scale")) or art_scale.x <= 0.0 or art_scale.y <= 0.0:
return {"ok": false, "error": "Scenery artwork scale is invalid."}
if not _finite_number(entry.get("rotation", 0.0)):
return {"ok": false, "error": "Scenery artwork rotation is invalid."}
var bounds_value: Variant = entry.get("last_known_local_bounds", null)
if bounds_value != null and not _valid_rect_data(bounds_value):
entry.erase("last_known_local_bounds")
elif String(entry.get("type", "")) == "terrain" and String(entry.get("asset_id", "")) == "ground":
var finish: String = String(entry.get("finish_id", "finish_ground_legacy"))
if not GROUND_FINISHES.has(finish):
return {"ok": false, "error": "Ground finish is invalid."}
return {"ok": true, "error": ""}
static func _valid_color(value: Variant) -> bool:
if not value is String:
return false
var raw: String = String(value).strip_edges()
if raw.begins_with("#"):
raw = raw.substr(1)
if raw.length() not in [6, 8]:
return false
const HEX_DIGITS: String = "0123456789abcdefABCDEF"
for index: int in range(raw.length()):
if not HEX_DIGITS.contains(raw.substr(index, 1)):
return false
return true
static func _valid_rect_data(value: Variant) -> bool:
if not value is Dictionary:
return false
var rect_data: Dictionary = value
if not is_vector_data(rect_data.get("position")) or not is_vector_data(rect_data.get("size")):
return false
var size: Vector2 = data_to_vector(rect_data.get("size"), Vector2.ZERO)
return size.x > 0.0 and size.y > 0.0
static func _recover_backup(path: String) -> void:
var bak := path + ".bak"
if not FileAccess.file_exists(path) and FileAccess.file_exists(bak): DirAccess.rename_absolute(bak, path)
+17 -1
View File
@@ -19,6 +19,10 @@ signal selection_changed(nodes: Array[Node2D])
# ---------------------------------------------------------------------------
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
const STAGE_WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const STAGE_SCENERY_ARTWORK := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
enum SelectionScope { ACTION, SCENERY }
## Rig-local bounds for stickman hit-testing (mirrors the rig's standing bounds).
const RIG_LOCAL_RECT := Rect2(Vector2(-120.0, -1000.0), Vector2(240.0, 1000.0))
@@ -32,6 +36,7 @@ var _camera: Camera2D
var _selected: Array[Node2D] = []
var _hovered: Node2D = null
var _primary: Node2D = null
var _scope: SelectionScope = SelectionScope.ACTION
# ---------------------------------------------------------------------------
# Lifecycle
@@ -53,6 +58,12 @@ func get_primary() -> Node2D:
return _primary
func set_scope(scope: SelectionScope) -> void:
_scope = scope
clear_selection()
clear_hover()
func clear_selection() -> void:
_selected.clear()
_primary = null
@@ -110,7 +121,7 @@ func hit_test_all(world_pos: Vector2) -> Array[Node2D]:
continue
if get_world_aabb(node).has_point(world_pos):
hits.append(node)
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos)
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos) if _scope == SelectionScope.ACTION else null
if ragdoll_hit != null and not hits.has(ragdoll_hit):
hits.append(ragdoll_hit)
hits.sort_custom(func(a: Node2D, b: Node2D) -> bool:
@@ -219,6 +230,11 @@ func _is_selectable(node: Node) -> bool:
return false
if node.is_in_group("stickman_ragdoll_container") or bool(node.get_meta("stickman_ragdoll_container", false)):
return false
var is_scenery: bool = node.get_script() == STAGE_WALL_PANEL or node.get_script() == STAGE_SCENERY_ARTWORK
if _scope == SelectionScope.ACTION and is_scenery:
return false
if _scope == SelectionScope.SCENERY and not is_scenery:
return false
return true
+8 -2
View File
@@ -19,6 +19,7 @@ const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
const STICKMAN_FACTORY := preload("res://scripts/characters/stickman/stickman_factory.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const TRIGGER_AREA := preload("res://scripts/stage/trigger_area.gd")
const STAGE_DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
# ---------------------------------------------------------------------------
# Constants
@@ -67,13 +68,14 @@ func get_spawnable_ids() -> Array[String]:
## UI-safe registry view. Callers receive no templates or mutable registry state.
func get_palette_entries() -> Array[Dictionary]:
var icons: Dictionary = {"ground":"🧱", "ramp":"📐", "step":"🪜", "prop":"📦", "stickman":"🧍", "area":"🎯"}
var categories: Dictionary = {"terrain":"Terrain", "prop":"Props", "stickman":"Cast", "area":"Events"}
var icons: Dictionary = {"ground":"🧱", "ramp":"📐", "step":"🪜", "prop":"📦", "stickman":"🧍", "area":"🎯", "scenery":"🌄"}
var categories: Dictionary = {"terrain":"Terrain", "prop":"Props", "stickman":"Cast", "area":"Events", "scenery":"Backgrounds"}
var result: Array[Dictionary] = []
for entry: Dictionary in _registry:
var id: String = String(entry.get("id", ""))
var kind: String = String(entry.get("kind", ""))
result.append({"placement_id":id, "kind":kind, "label":String(entry.get("label", id)), "icon":String(icons.get(id, "")), "category":String(categories.get(kind, "")), "opens_selector":kind == "prop" or kind == "stickman"})
result.append({"placement_id":"scenery", "kind":"scenery", "label":"Scenery", "icon":"🌄", "category":"Backgrounds", "opens_selector":true})
return result
@@ -328,6 +330,10 @@ func _spawn_terrain(entry: Dictionary, world_position: Vector2) -> TerrainBlock:
)
block.position = world_position
block.spawn_id = String(entry.get("id", ""))
if block.spawn_id == "ground":
block.finish_id = &"finish_ground_legacy"
block.set_finish(&"finish_ground_legacy")
STAGE_DRAW_ORDER.apply_terrain(block)
return block