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
@@ -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)