Add StageBackgroundView and related tests for plain background functionality

- Implemented StageBackgroundView class for managing sky and plain backgrounds in the stage.
- Added functionality to set background type and color, bind camera, and apply themes.
- Created test_phase19_plain_background.gd to validate plain background behavior and migration.
- Developed test_phase19_starter_chooser_layout.gd to ensure proper layout and functionality of the starter chooser.
- Included visual capture tests for UI elements and interactions.
This commit is contained in:
2026-10-22 16:18:25 -04:00
parent db677dc194
commit 59f1817063
43 changed files with 2403 additions and 160 deletions
+52
View File
@@ -61,6 +61,7 @@ var _warned: Dictionary = {}
func _init() -> void:
_apply_confirmation_defaults()
_apply_phase19_defaults()
func load_from(path: String = DEFAULT_PATH) -> bool:
invalidate_caches()
@@ -80,8 +81,59 @@ func load_from(path: String = DEFAULT_PATH) -> bool:
return false
_apply_legacy_aliases(parsed as Dictionary)
_config = _deep_merge(_config, parsed as Dictionary)
_apply_phase19_defaults()
return true
func _apply_phase19_defaults() -> void:
var grid: Dictionary = _config.get("grid", {}).duplicate(true)
for key: String in ["plain_light_minor", "plain_light_major", "plain_dark_minor", "plain_dark_major"]:
if not grid.has(key):
grid[key] = {
"plain_light_minor": "#11182120",
"plain_light_major": "#11182148",
"plain_dark_minor": "#FFFFFF25",
"plain_dark_major": "#FFFFFF58",
}[key]
_config["grid"] = grid
var popups: Dictionary = _config.get("popups", {}).duplicate(true)
var asset_selector: Dictionary = popups.get("asset_selector", {}).duplicate(true)
asset_selector["compact_swatch_size"] = maxi(44, int(asset_selector.get("compact_swatch_size", 44)))
var starter_defaults: Dictionary = {
"starter_dialog_width": 1160.0,
"starter_viewport_height_ratio": 0.96,
"starter_card_min_width": 340.0,
"starter_preview_aspect": 2.0,
"starter_preview_corner_radius": 12,
"starter_preview_horizontal_inset": 10.0,
"starter_preview_top_inset": 6,
"starter_card_text_height": 108.0,
"starter_title_size": 30,
"starter_label_size": 22,
"starter_label_height": 34,
"starter_description_size": 20,
"starter_description_height": 48,
}
for key: String in starter_defaults:
if not asset_selector.has(key):
asset_selector[key] = starter_defaults[key]
popups["asset_selector"] = asset_selector
_config["popups"] = popups
var overlays: Dictionary = _config.get("overlays", {}).duplicate(true)
var gizmos: Dictionary = overlays.get("gizmos", {}).duplicate(true)
var defaults: Dictionary = {
"backing_color": "#111821E8",
"selection_color": "#F5F7FAE6",
"hover_color": "#FFE66DCC",
"rotate_color": "#4DB9FFE6",
"backing_extra_width": 3.0,
}
for key: String in defaults:
if not gizmos.has(key):
gizmos[key] = defaults[key]
overlays["gizmos"] = gizmos
_config["overlays"] = overlays
func _apply_confirmation_defaults() -> void:
var popups: Dictionary = _config.get("popups", {}).duplicate(true)
popups["confirmation"] = CONFIRMATION_DEFAULTS.duplicate(true)
@@ -3,23 +3,29 @@ 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 BACKGROUND_VIEW: Script = preload("res://scripts/stage/scenery/stage_background_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
var _background_view: StageBackgroundView
var _plain_color: Color = Color("#F5EFE0")
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", "")))
var raw_plain_color: Variant = entry.get("plain_color", Color("#F5EFE0"))
_plain_color = raw_plain_color if raw_plain_color is Color else Color.from_string(String(raw_plain_color), Color("#F5EFE0"))
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)
if _background_kind in ["sky", "plain"]:
_background_view = BACKGROUND_VIEW.new() as StageBackgroundView
_background_view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_background_view.set_background(StringName(_background_kind), _preview_id, _plain_color)
add_child(_background_view)
_sky_view = _background_view.get_sky_view()
var frame: Panel = Panel.new()
frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -35,6 +41,8 @@ func configure(entry: Dictionary) -> void:
func _draw() -> void:
if _background_kind == "plain":
return
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
# The preview rect is already small and clipped, so draw its full pattern directly.
@@ -0,0 +1,74 @@
class_name StageBackgroundView
extends Control
## Shared full-rect backdrop for the live Stage and still card previews.
const SKY_VIEW_SCRIPT := preload("res://scripts/stage/scenery/stage_sky_view.gd")
var _sky_view: StageSkyView
var _plain_fill: ColorRect
var _bound_camera: Camera2D
var _background_kind: StringName = &"sky"
var _sky_id: StringName = &"sky_neutral"
var _plain_color: Color = Color("#F5EFE0")
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
if _sky_view == null:
_sky_view = SKY_VIEW_SCRIPT.new() as StageSkyView
_sky_view.name = "StageSkyView"
_sky_view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_sky_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_sky_view)
if _plain_fill == null:
_plain_fill = ColorRect.new()
_plain_fill.name = "PlainFill"
_plain_fill.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_plain_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
_plain_fill.color = _opaque(_plain_color)
add_child(_plain_fill)
_refresh_children()
func set_background(kind: StringName, sky_id: StringName, plain_color: Color) -> void:
_background_kind = &"plain" if kind == &"plain" else &"sky"
_sky_id = sky_id
_plain_color = _opaque(plain_color)
_refresh_children()
func bind_camera(camera: Camera2D) -> void:
_bound_camera = camera
if _sky_view != null:
_sky_view.bind_camera(_bound_camera if _background_kind == &"sky" else null)
func apply_theme(stage_theme: SandboxTheme) -> void:
if _sky_view != null:
_sky_view.apply_theme(stage_theme)
func get_sky_view() -> StageSkyView:
return _sky_view
func get_background_kind() -> StringName:
return _background_kind
func get_plain_color() -> Color:
return _plain_color
func _refresh_children() -> void:
if _sky_view == null or _plain_fill == null:
return
_sky_view.set_sky(_sky_id)
_sky_view.visible = _background_kind == &"sky"
_sky_view.bind_camera(_bound_camera if _background_kind == &"sky" else null)
_plain_fill.color = _plain_color
_plain_fill.visible = _background_kind == &"plain"
func _opaque(color: Color) -> Color:
return Color(color.r, color.g, color.b, 1.0)
@@ -0,0 +1 @@
uid://dc72udajou30y
@@ -6,6 +6,7 @@ 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_SKY_VIEW := preload("res://scripts/stage/scenery/stage_sky_view.gd")
const STAGE_BACKGROUND_VIEW := preload("res://scripts/stage/scenery/stage_background_view.gd")
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
const PREVIEW_SIZE := Vector2i(256, 256)
@@ -14,14 +15,16 @@ const REFERENCE_VIEWPORT_SIZE := Vector2(1280.0, 720.0)
var _viewport: SubViewport
var _sky_view: StageSkyView
var _background_view: StageBackgroundView
var _world: Node2D
var _loaded_nodes: Array[Node2D] = []
var _scene_valid: bool = false
func configure(entry: Dictionary) -> void:
func configure(entry: Dictionary, requested_preview_size: Vector2 = Vector2.ZERO) -> void:
name = "StarterScenePreview"
custom_minimum_size = Vector2(128.0, 128.0)
var resolved_preview_size: Vector2 = requested_preview_size if requested_preview_size.x > 0.0 and requested_preview_size.y > 0.0 else Vector2(PREVIEW_SIZE)
custom_minimum_size = resolved_preview_size
mouse_filter = Control.MOUSE_FILTER_IGNORE
stretch = true
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
@@ -31,36 +34,40 @@ func configure(entry: Dictionary) -> void:
return
var data: Dictionary = result.get("data", {}) as Dictionary
_scene_valid = true
_build_preview_viewport(data)
_build_preview_viewport(data, resolved_preview_size)
func has_preview_scene() -> bool:
return _scene_valid and _viewport != null and _sky_view != null and _world != null
return _scene_valid and _viewport != null and _background_view != null and _world != null
func get_loaded_object_count() -> int:
return _loaded_nodes.size()
func _build_preview_viewport(data: Dictionary) -> void:
func _build_preview_viewport(data: Dictionary, requested_preview_size: Vector2) -> void:
_viewport = SubViewport.new()
_viewport.name = "PreviewViewport"
_viewport.size = PREVIEW_SIZE
_viewport.size = Vector2i(maxi(1, roundi(requested_preview_size.x)), maxi(1, roundi(requested_preview_size.y)))
_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)
var background_layer := CanvasLayer.new()
background_layer.name = "PreviewBackgroundLayer"
background_layer.layer = -1
_viewport.add_child(background_layer)
_background_view = STAGE_BACKGROUND_VIEW.new() as StageBackgroundView
_background_view.name = "StageBackgroundView"
_background_view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_background_view.set_background(
StringName(String(data.get("background_kind", "sky"))),
StringName(String(data.get("sky_id", "sky_neutral"))),
STAGE_SCENE_IO.color_from_data(data.get("plain_color", STAGE_SCENE_IO.DEFAULT_PLAIN_COLOR), Color("#F5EFE0"))
)
background_layer.add_child(_background_view)
_sky_view = _background_view.get_sky_view()
_world = Node2D.new()
_world.name = "PreviewWorld"
@@ -85,27 +92,30 @@ func _build_preview_viewport(data: Dictionary) -> void:
func _fit_scene_to_preview(camera_state: Dictionary) -> void:
if _world == null or not is_instance_valid(_world):
return
var preview_frame: Dictionary = _preview_transform_for_camera(camera_state)
var preview_frame: Dictionary = _preview_transform_for_camera(camera_state, Vector2(_viewport.size))
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:
static func _preview_transform_for_camera(camera_state: Dictionary, preview_size: Vector2 = Vector2(PREVIEW_SIZE)) -> 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 safe_preview_size: Vector2 = preview_size if preview_size.x > 0.0 and preview_size.y > 0.0 else Vector2(PREVIEW_SIZE)
var preview_aspect: float = safe_preview_size.x / safe_preview_size.y
var square_world_extent: float = REFERENCE_VIEWPORT_SIZE.y / camera_zoom
var focus_size := Vector2(square_world_extent * preview_aspect, square_world_extent)
var focus_rect := Rect2(
camera_position - Vector2.ONE * square_world_extent * 0.5,
Vector2.ONE * square_world_extent
camera_position - focus_size * 0.5,
focus_size
)
var usable_size: Vector2 = Vector2(PREVIEW_SIZE) - Vector2.ONE * PREVIEW_MARGIN * 2.0
var usable_size: Vector2 = safe_preview_size - Vector2(PREVIEW_MARGIN * 2.0, 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,
"position": safe_preview_size * 0.5 - camera_position * preview_scale,
}
@@ -29,14 +29,16 @@ func _draw() -> void:
var rect: Rect2 = Rect2(Vector2(3.0, 3.0), size - Vector2(6.0, 6.0))
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
return
if surface_kind == &"background_ground":
if surface_kind == &"plain_background":
draw_rect(rect, Color(tint.r, tint.g, tint.b, 1.0), true)
elif surface_kind == &"background_ground":
SURFACE_RENDERER.draw_background(self, rect, finish_id, tint, Vector2.ZERO, Vector2.ONE, false)
else:
SURFACE_RENDERER.draw_wall(self, rect, finish_id, tint, Vector2.ZERO, Vector2.ONE, false)
var radius: float = minf(corner_radius, minf(rect.size.x, rect.size.y) * 0.5)
if radius > 0.0:
var wall: bool = surface_kind != &"background_ground"
var base_color: Color = SURFACE_RENDERER.surface_base_color(finish_id, tint, wall)
var base_color: Color = Color(tint.r, tint.g, tint.b, 1.0) if surface_kind == &"plain_background" else SURFACE_RENDERER.surface_base_color(finish_id, tint, wall)
_draw_rounded_corner(rect, radius, base_color, outside_color, 0)
_draw_rounded_corner(rect, radius, base_color, outside_color, 1)
_draw_rounded_corner(rect, radius, base_color, outside_color, 2)
+31 -4
View File
@@ -54,6 +54,13 @@ const MAX_SCALE: float = 20.0
# ---------------------------------------------------------------------------
var camera: Camera2D = null
var _selection_backing_color: Color = Color("#111821E8")
var _selection_core_color: Color = Color(1.0, 1.0, 1.0, 0.9)
var _hover_backing_color: Color = Color("#111821E8")
var _hover_core_color: Color = Color(1.0, 0.9, 0.2, 0.5)
var _rotate_backing_color: Color = Color("#111821E8")
var _rotate_core_color: Color = Color(0.3, 0.6, 1.0, 0.9)
var _outline_extra_width: float = 3.0
## When > 0, translation drags snap to this grid size (0 = off).
var snap_size: float = 0.0
@@ -105,6 +112,19 @@ func set_snap_size(size: float) -> void:
snap_size = maxf(size, 0.0)
func apply_theme(stage_theme: SandboxTheme) -> void:
if stage_theme == null:
return
_selection_backing_color = stage_theme.color("overlays.gizmos.backing_color", Color("#111821E8"))
_selection_core_color = stage_theme.color("overlays.gizmos.selection_color", SELECTION_COLOR)
_hover_backing_color = stage_theme.color("overlays.gizmos.backing_color", Color("#111821E8"))
_hover_core_color = stage_theme.color("overlays.gizmos.hover_color", HOVER_COLOR)
_rotate_backing_color = stage_theme.color("overlays.gizmos.backing_color", Color("#111821E8"))
_rotate_core_color = stage_theme.color("overlays.gizmos.rotate_color", ROTATE_COLOR)
_outline_extra_width = stage_theme.number("overlays.gizmos.backing_extra_width", 3.0, 1.0, 8.0)
queue_redraw()
func set_box_rect(rect: Rect2) -> void:
_box_rect = rect
queue_redraw()
@@ -253,16 +273,18 @@ 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(_target_bounds(_hovered), HOVER_COLOR, false, HOVER_WIDTH / zoom)
_draw_halo_rect(_target_bounds(_hovered), _hover_core_color, HOVER_WIDTH / zoom, _hover_backing_color)
# A selection outline for every selected object.
for node: Node2D in _targets:
if node != null and is_instance_valid(node):
draw_rect(_target_bounds(node), SELECTION_COLOR, false, SELECTION_WIDTH / zoom)
_draw_halo_rect(_target_bounds(node), _selection_core_color, SELECTION_WIDTH / zoom, _selection_backing_color)
# Rotate ring only for a single selection.
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)
var ring_radius: float = _rotate_ring_radius(rect)
draw_arc(rect.get_center(), ring_radius, 0.0, TAU, 64, _rotate_backing_color, ROTATE_WIDTH / zoom + _outline_extra_width / zoom, true)
draw_arc(rect.get_center(), ring_radius, 0.0, TAU, 64, _rotate_core_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"))
@@ -275,7 +297,7 @@ func _draw() -> void:
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_COLOR, 1.5 / zoom)
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_COLOR, 1.5 / zoom)
if _box_rect.size != Vector2.ZERO:
draw_rect(_box_rect.abs(), HOVER_COLOR, false, HOVER_WIDTH / zoom)
_draw_halo_rect(_box_rect.abs(), _hover_core_color, HOVER_WIDTH / zoom, _hover_backing_color)
# ---------------------------------------------------------------------------
# Internal helpers
@@ -287,6 +309,11 @@ func _zoom() -> float:
return 1.0
func _draw_halo_rect(rect: Rect2, core_color: Color, core_width: float, backing_color: Color) -> void:
draw_rect(rect, backing_color, false, core_width + _outline_extra_width / _zoom())
draw_rect(rect, core_color, false, core_width)
func _rotate_ring_radius(rect: Rect2) -> float:
return rect.size.length() * 0.5 + ROTATE_HANDLE_MARGIN_PX / _zoom()
+34 -2
View File
@@ -9,6 +9,12 @@ extends Node2D
var camera: Camera2D = null
var grid_size: float = 15.0
var enabled: bool = true
var _plain_background_active: bool = false
var _plain_background_color: Color = Color("#F5EFE0")
var _plain_light_minor_color: Color = Color("#11182120")
var _plain_light_major_color: Color = Color("#11182148")
var _plain_dark_minor_color: Color = Color("#FFFFFF25")
var _plain_dark_major_color: Color = Color("#FFFFFF58")
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.08)
const MAJOR_COLOR := Color(1.0, 1.0, 1.0, 0.16)
@@ -19,6 +25,23 @@ func _process(_delta: float) -> void:
queue_redraw()
func set_background_treatment(
plain_active: bool,
plain_color: Color,
light_minor: Color,
light_major: Color,
dark_minor: Color,
dark_major: Color
) -> void:
_plain_background_active = plain_active
_plain_background_color = Color(plain_color.r, plain_color.g, plain_color.b, 1.0)
_plain_light_minor_color = light_minor
_plain_light_major_color = light_major
_plain_dark_minor_color = dark_minor
_plain_dark_major_color = dark_major
queue_redraw()
func _draw() -> void:
if not enabled or grid_size <= 0.0:
return
@@ -34,13 +57,22 @@ func _draw() -> void:
var x := floorf(rect.position.x / grid_size) * grid_size
while x <= rect.end.x:
var grid_index := int(round(x / grid_size))
var color := MAJOR_COLOR if grid_index % MAJOR_EVERY == 0 else GRID_COLOR
var color: Color = _grid_color(grid_index % MAJOR_EVERY == 0)
draw_line(Vector2(x, rect.position.y), Vector2(x, rect.end.y), color, line_width)
x += grid_size
var y := floorf(rect.position.y / grid_size) * grid_size
while y <= rect.end.y:
var grid_index := int(round(y / grid_size))
var color := MAJOR_COLOR if grid_index % MAJOR_EVERY == 0 else GRID_COLOR
var color: Color = _grid_color(grid_index % MAJOR_EVERY == 0)
draw_line(Vector2(rect.position.x, y), Vector2(rect.end.x, y), color, line_width)
y += grid_size
func _grid_color(is_major: bool) -> Color:
if not _plain_background_active:
return MAJOR_COLOR if is_major else GRID_COLOR
var light_fill: bool = _plain_background_color.get_luminance() >= 0.55
if light_fill:
return _plain_light_major_color if is_major else _plain_light_minor_color
return _plain_dark_major_color if is_major else _plain_dark_minor_color
+28 -6
View File
@@ -3,7 +3,8 @@ 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.6"
const FILE_VERSION: String = "1.7"
const DEFAULT_PLAIN_COLOR: String = "#F5EFE0"
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
@@ -185,11 +186,13 @@ 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", "1.4", "1.5"]:
if version not in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6"]:
return {}
var migrated: Dictionary = data.duplicate(true)
if version != "1.5":
if version not in ["1.5", "1.6"]:
migrated["sky_id"] = "sky_neutral"
migrated["background_kind"] = "sky"
migrated["plain_color"] = DEFAULT_PLAIN_COLOR
for raw: Variant in migrated.get("objects", []):
if not raw is Dictionary:
continue
@@ -203,9 +206,9 @@ 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 version != "1.5" and String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground":
if version not in ["1.5", "1.6"] and String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground":
object["finish_id"] = "finish_ground_legacy"
if String(object.get("type", "")) == "wall_panel":
if version != "1.6" and String(object.get("type", "")) == "wall_panel":
_migrate_legacy_wall_tint(object)
migrated["version"] = FILE_VERSION
_assign_legacy_depths(migrated.get("objects", []))
@@ -254,9 +257,15 @@ 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")
var background_kind: Variant = data.get("background_kind", null)
if not background_kind is String or not ["sky", "plain"].has(String(background_kind)):
return {"ok": false, "error": "Scene background kind is invalid."}
var sky_id: Variant = data.get("sky_id", null)
if not sky_id is String or not SKY_IDS.has(String(sky_id)):
return {"ok": false, "error": "Scene Sky is invalid."}
var plain_color: Variant = data.get("plain_color", null)
if not _valid_opaque_hex_color(plain_color):
return {"ok": false, "error": "Scene Plain Background color is invalid."}
var seen: Dictionary = {}
var seen_stage_ids: Dictionary = {}
for raw: Variant in data["objects"]:
@@ -346,6 +355,19 @@ static func _valid_color(value: Variant) -> bool:
return true
static func _valid_opaque_hex_color(value: Variant) -> bool:
if not value is String:
return false
var raw: String = String(value).strip_edges()
if raw.length() != 7 or not raw.begins_with("#"):
return false
const HEX_DIGITS: String = "0123456789abcdefABCDEF"
for index: int in range(1, 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