Files
stickman/tests/test_phase19_scenery.gd
T
ryan 59f1817063 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.
2026-10-22 16:18:25 -04:00

1442 lines
88 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends SceneTree
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
const STAGE_SCENE_IO: Script = preload("res://scripts/stage/stage_scene_io.gd")
const STAGE_SCENE_ADAPTER: Script = preload("res://scripts/editors/stage/stage_scene_adapter.gd")
const PROP_EDITOR_SCENE: PackedScene = preload("res://scenes/editors/prop/prop_editor.tscn")
const STAGE_SCENERY_ARTWORK: Script = preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
const STAGE_WALL_PANEL: Script = preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const STAGE_SURFACE_RENDERER: Script = preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const BACKGROUND_CARD_PREVIEW: Script = preload("res://scripts/stage/scenery/stage_background_card_preview.gd")
const STARTER_CARD_PREVIEW: Script = preload("res://scripts/stage/scenery/stage_starter_card_preview.gd")
const STAGE_SKY_VIEW_SCRIPT: Script = preload("res://scripts/stage/scenery/stage_sky_view.gd")
const STARTER_PATHS: Array[String] = [
"res://content/stages/starters/blank.scene",
"res://content/stages/starters/plain_background.scene",
"res://content/stages/starters/town_street.scene",
"res://content/stages/starters/living_room.scene",
]
var _failures: Array[String] = []
var _stage: SandboxStage
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 720)
_test_large_brick_pattern_budget()
_test_surface_stroke_clipping()
_test_scene_color_compatibility()
await _test_stage_sky_visual_model()
if OS.get_cmdline_user_args().has("--capture-sky-v2-only"):
print("PASS: Phase 19 Sky render capture")
quit(0)
return
if OS.get_cmdline_user_args().has("--sky-camera-only"):
_stage = SANDBOX_STAGE.instantiate() as SandboxStage
root.add_child(_stage)
await process_frame
await process_frame
await _test_live_sky_camera_binding()
for failure: String in _failures:
push_error("FAIL: " + failure)
_stage.queue_free()
await process_frame
quit(1 if not _failures.is_empty() else 0)
return
for starter_path: String in STARTER_PATHS:
var result: Dictionary = STAGE_SCENE_IO.read_file(starter_path)
_expect(bool(result.get("ok", false)), "starter scene validates: %s" % starter_path)
if bool(result.get("ok", false)):
_expect(String((result["data"] as Dictionary).get("version", "")) == STAGE_SCENE_IO.FILE_VERSION, "starter uses current Stage schema: %s" % starter_path)
_stage = SANDBOX_STAGE.instantiate() as SandboxStage
root.add_child(_stage)
await process_frame
await process_frame
await _test_live_sky_camera_binding()
await _test_grid_controls_layout()
var baseline_spawner: StageSpawner = _stage.get("_spawner") as StageSpawner
var baseline_ground: TerrainBlock = baseline_spawner.spawn_terrain_asset("ground", Vector2.ZERO)
_expect(baseline_ground != null, "baseline navigation probe places one ordinary Ground block")
_stage.call("_rebake_navigation")
await physics_frame
await physics_frame
var baseline_nav_region: NavigationRegion2D = _stage.get_node("NavigationRegion2D") as NavigationRegion2D
var baseline_nav_map: RID = baseline_nav_region.get_navigation_map()
NavigationServer2D.map_force_update(baseline_nav_map)
var baseline_nav_start: Vector2 = Vector2(-50.0, -16.0)
var baseline_nav_goal: Vector2 = Vector2(50.0, -16.0)
var baseline_nav_path: PackedVector2Array = NavigationServer2D.map_get_path(
baseline_nav_map,
baseline_nav_start,
baseline_nav_goal,
true
)
print(
"[BaselineGroundNavigationProbe] polygons=%d vertices=%s iteration=%d top_surface=%s -> %s closest=%s -> %s path_points=%d"
% [
baseline_nav_region.navigation_polygon.get_polygon_count(),
str(baseline_nav_region.navigation_polygon.get_vertices()),
NavigationServer2D.map_get_iteration_id(baseline_nav_map),
str(baseline_nav_start),
str(baseline_nav_goal),
str(NavigationServer2D.map_get_closest_point(baseline_nav_map, baseline_nav_start)),
str(NavigationServer2D.map_get_closest_point(baseline_nav_map, baseline_nav_goal)),
baseline_nav_path.size(),
]
)
var browser: StageAssetBrowser = _stage.get("_asset_browser") as StageAssetBrowser
var starter_entries: Array[Dictionary] = browser.scan_entries("starters")
_expect(starter_entries.size() == 4, "New Scene gallery lists Plain Background, Open Sky, Town Street, and Living Room")
var original_name: String = String(_stage.get("_scene_name"))
var original_world_count: int = (_stage.get_node("World") as Node2D).get_child_count()
var original_sky: String = String(_stage.get("_sky_id"))
_stage.call("_on_file_menu_id_pressed", 0)
var selector: AssetSelector = browser.get_selector()
_expect(selector.visible and selector.kind == "starters", "New Scene opens the starter chooser without clearing the current document")
var starter_close_button: Button = selector.find_child("CloseButton", true, false) as Button
_expect(starter_close_button != null, "starter chooser exposes its real Close control")
if starter_close_button != null:
await _activate_button(starter_close_button)
_expect(not selector.visible, "Cancel closes the starter chooser")
_expect(String(_stage.get("_scene_name")) == original_name and String(_stage.get("_sky_id")) == original_sky and (_stage.get_node("World") as Node2D).get_child_count() == original_world_count, "Cancel leaves the existing Stage document intact")
_stage.call("_on_file_menu_id_pressed", 0)
selector = browser.get_selector()
var starter_grid: GridContainer = selector.find_child("GridContainer", true, false) as GridContainer
await _test_starter_card_previews(starter_grid)
var town_card: AssetCard = _find_card_by_id(starter_grid, "starter_town_street")
_expect(town_card != null, "Town Street is selectable as a real starter card")
if town_card != null:
await _activate_card(town_card)
await process_frame
_expect(String(_stage.get("_scene_name")) == "Town Street", "choosing Town Street loads it as a named, unsaved scene")
_expect(String(_stage.get("_sky_id")) == "sky_cloudy_day", "Town Street starter selects the approved cloudy sky")
var world: Node2D = _stage.get_node("World") as Node2D
var sidewalk_bounds: Rect2 = _test_starter_surface_row(world, &"finish_ground_sidewalk", -16.0, "Town Street sidewalk")
var road_bounds: Rect2 = _test_starter_surface_row(world, &"finish_ground_asphalt", 16.0, "Town Street asphalt", 27, 2592.0)
_expect(is_equal_approx(road_bounds.position.y, sidewalk_bounds.end.y), "Town Street asphalt row meets the sidewalk row with no vertical gap")
_test_saved_starter_camera("res://content/stages/starters/town_street.scene", "Town Street")
await physics_frame
await physics_frame
var navigation_region: NavigationRegion2D = _stage.get_node("NavigationRegion2D") as NavigationRegion2D
var navigation_map: RID = navigation_region.get_navigation_map()
NavigationServer2D.map_force_update(navigation_map)
var street_terrain_summary: Array[String] = []
for terrain_node: Node in world.get_children():
if terrain_node is TerrainBlock:
var street_terrain: TerrainBlock = terrain_node as TerrainBlock
var transformed_points: Array[Vector2] = []
for point: Vector2 in street_terrain.polygon_points:
transformed_points.append(street_terrain.transform * point)
var visible_top_y: float = INF
for point: Vector2 in transformed_points:
visible_top_y = minf(visible_top_y, point.y)
var visible_top_edges: Array[String] = []
var navigation_up_edges: Array[String] = []
for point_index: int in street_terrain.polygon_points.size():
var a: Vector2 = transformed_points[point_index]
var b: Vector2 = transformed_points[(point_index + 1) % transformed_points.size()]
var edge: Vector2 = b - a
var outward: Vector2 = Vector2(edge.y, -edge.x).normalized()
if is_equal_approx(a.y, visible_top_y) and is_equal_approx(b.y, visible_top_y):
visible_top_edges.append("%s -> %s" % [str(a), str(b)])
if outward.y < -0.15:
navigation_up_edges.append("%s -> %s" % [str(a), str(b)])
street_terrain_summary.append("%s scale=%s transform=%s visible_top=%s nav_up=%s" % [String(street_terrain.finish_id), str(street_terrain.scale), str(street_terrain.transform), str(visible_top_edges), str(navigation_up_edges)])
var sidewalk_start: Vector2 = Vector2(-600.0, -16.0)
var sidewalk_goal: Vector2 = Vector2(600.0, -16.0)
var road_start: Vector2 = Vector2(-600.0, 16.0)
var road_goal: Vector2 = Vector2(600.0, 16.0)
var sidewalk_path: PackedVector2Array = NavigationServer2D.map_get_path(
navigation_map,
sidewalk_start,
sidewalk_goal,
true
)
var road_path: PackedVector2Array = NavigationServer2D.map_get_path(
navigation_map,
road_start,
road_goal,
true
)
var sidewalk_to_road_path: PackedVector2Array = NavigationServer2D.map_get_path(
navigation_map,
Vector2(600.0, -16.0),
Vector2(600.0, 16.0),
true
)
print(
"[TownStreetNavigationCheck] nav_dirty=%s polygons=%d agent_radius=%.1f vertices=%s iteration=%d terrain=%s sidewalk=(%s -> %s; closest %s -> %s; path=%s) road=(%s -> %s; closest %s -> %s; path=%s) curb_path=%s"
% [
str(bool(_stage.get("_nav_dirty"))),
navigation_region.navigation_polygon.get_polygon_count(),
navigation_region.navigation_polygon.agent_radius,
str(navigation_region.navigation_polygon.get_vertices()),
NavigationServer2D.map_get_iteration_id(navigation_map),
str(street_terrain_summary),
str(sidewalk_start),
str(sidewalk_goal),
str(NavigationServer2D.map_get_closest_point(navigation_map, sidewalk_start)),
str(NavigationServer2D.map_get_closest_point(navigation_map, sidewalk_goal)),
str(sidewalk_path),
str(road_start),
str(road_goal),
str(NavigationServer2D.map_get_closest_point(navigation_map, road_start)),
str(NavigationServer2D.map_get_closest_point(navigation_map, road_goal)),
str(road_path),
str(sidewalk_to_road_path),
]
)
_expect(
NavigationServer2D.map_get_iteration_id(navigation_map) > 0 and navigation_region.navigation_polygon.get_polygon_count() > 0,
"Town Street navigation region synchronizes its starter terrain polygons"
)
_expect(not bool(_stage.get("_nav_dirty")), "Town Street deferred navigation rebake completed after starter application")
# Drive the Stage's existing palette/gallery callbacks and its real
# _unhandled_input(InputEventMouseButton) production entry point.
_stage.call("_on_palette_card_activated", {"placement_id": "scenery", "opens_selector": true})
selector = browser.get_selector()
_expect(selector.visible and selector.kind == "scenery", "Scenery palette opens the shared gallery")
selector.call("_set_gallery_tab", "backgrounds")
await process_frame
var background_grid: GridContainer = selector.find_child("GridContainer", true, false) as GridContainer
var background_preview_count: int = 0
var cloudy_card: AssetCard = _find_card_by_id(background_grid, "sky_cloudy_day")
if background_grid != null:
for card_node: Node in background_grid.get_children():
for card_content: Node in card_node.get_children():
for preview_node: Node in card_content.get_children():
if preview_node.get_script() == BACKGROUND_CARD_PREVIEW:
background_preview_count += 1
_expect(background_grid != null and background_grid.get_child_count() == 7 and background_preview_count == 7, "Plain Background, four Sky looks, and both surface setup cards render production-style previews")
var cloudy_status: Label = cloudy_card.find_child("StatusLabel", true, false) as Label if cloudy_card != null else null
_expect(cloudy_card != null and cloudy_card.button_pressed and cloudy_status != null and cloudy_status.text == "Current background", "gallery marks the loaded Town Street Sky as the current background")
var clear_sky_card: AssetCard = _find_card_by_id(background_grid, "sky_clear_day")
if clear_sky_card != null:
await _activate_card(clear_sky_card)
var selected_clear_card: AssetCard = _find_card_by_id(selector.find_child("GridContainer", true, false) as GridContainer, "sky_clear_day")
var clear_status: Label = selected_clear_card.find_child("StatusLabel", true, false) as Label if selected_clear_card != null else null
_expect(selector.visible and String(_stage.get("_sky_id")) == "sky_clear_day", "choosing a Sky updates the Stage and keeps the chooser open")
_expect(selected_clear_card != null and selected_clear_card.button_pressed and clear_status != null and clear_status.text == "Current background", "current background marker follows the newly chosen card")
selector.call("_set_gallery_tab", "artwork")
await process_frame
var artwork_entries: Array[Dictionary] = browser.scan_entries("scenery")
_expect(not artwork_entries.is_empty(), "Artwork tab has approved cutouts")
var prior_art_count: int = _count_artwork(world)
var artwork_grid: GridContainer = selector.find_child("GridContainer", true, false) as GridContainer
var artwork_card: AssetCard = artwork_grid.get_child(0) as AssetCard if artwork_grid != null and artwork_grid.get_child_count() > 0 else null
_expect(artwork_card != null, "Artwork gallery provides a real artwork card")
if artwork_card != null:
await _activate_card(artwork_card)
await process_frame
var scenery_controller: StageSceneryController = _stage.get("_scenery_controller") as StageSceneryController
_expect(scenery_controller.is_active(), "choosing artwork starts the Scenery placement tool")
var world_click: Vector2 = Vector2(760.0, 390.0)
var art_count_before_placement: int = _count_artwork(world)
var committed_nodes: Array[Node2D] = []
scenery_controller.placement_committed.connect(func(node: Node2D) -> void: committed_nodes.append(node))
await _send_stage_click(world_click, MOUSE_BUTTON_LEFT)
var placed_artwork: StageSceneryArtwork = committed_nodes[0] as StageSceneryArtwork if not committed_nodes.is_empty() else null
_expect(placed_artwork != null, "left click places an independent Scenery artwork node")
_expect(_count_artwork(world) == art_count_before_placement + 1, "left click adds exactly one Scenery artwork copy")
if placed_artwork != null:
_expect(not _contains_collision_object(placed_artwork), "Scenery artwork is not a physics object")
_expect(placed_artwork.get_node_or_null("CollisionPolygon2D") == null, "Scenery artwork contains no collision polygon")
var original_position: Vector2 = placed_artwork.global_position
_stage.set_mode(SandboxStage.StageMode.PLAY)
await physics_frame
await physics_frame
_expect(placed_artwork.global_position.is_equal_approx(original_position), "Scenery remains stationary during PLAY physics")
_stage.set_mode(SandboxStage.StageMode.EDIT)
await _send_stage_click(world_click, MOUSE_BUTTON_RIGHT)
_expect(not scenery_controller.is_active(), "RMB finishes Scenery placement")
_expect(world.get_children().any(func(node: Node) -> bool: return node.get_script() == STAGE_SCENERY_ARTWORK), "RMB keeps pieces already placed")
_test_scenery_placement_snap(world, scenery_controller, String(artwork_entries[0].get("path", "")))
await _test_wall_eraser(world, scenery_controller)
var roundtrip_path: String = "res://tests/phase19_scenery_roundtrip_tmp.scene"
var saved: bool = _stage.call("_save_scene_to_path", roundtrip_path, false)
_expect(saved, "Stage save succeeds with Scenery in the document")
if saved:
var reopen_ok: bool = _stage.call("_load_scene_from_path", roundtrip_path, false)
_expect(reopen_ok, "Stage reopens the saved Scenery document")
var reopened_art_count: int = 0
for child: Node in world.get_children():
if child.get_script() == STAGE_SCENERY_ARTWORK:
reopened_art_count += 1
_expect(reopened_art_count == prior_art_count + 1, "save/reopen preserves the placed artwork exactly once")
_expect(_count_walls_with_origin(world, Vector2(20000.0, 20000.0)) == 4, "save/reopen preserves the four wall pieces around an erased opening")
for child: Node in world.get_children():
if child.get_script() == STAGE_WALL_PANEL and not (child as StageWallPanel).motif_origin_offset.is_zero_approx():
_expect((child as StageWallPanel).get_effective_motif_origin_world().is_equal_approx(Vector2(20000.0, 20000.0)), "saved wall fragments keep their shared brick pattern anchor")
break
_test_wall_eraser_full_cut(world, _stage.get("_scenery_controller") as StageSceneryController)
DirAccess.remove_absolute(ProjectSettings.globalize_path(roundtrip_path))
await _test_group_preview_save_gate(world)
await _test_missing_artwork_repair(world, browser)
await _test_single_scenery_transforms(world, String(artwork_entries[0].get("path", "")))
await _test_selected_scenery_depth(world, String(artwork_entries[0].get("path", "")))
await _test_prop_size_for_stage()
await _test_living_room_surface(world)
if _stage != null and is_instance_valid(_stage):
_stage.queue_free()
await process_frame
await process_frame
if _failures.is_empty():
print("PASS: Phase 19 starter/gallery/input/physics/save roundtrip")
else:
for failure: String in _failures:
push_error("FAIL: " + failure)
quit(0 if _failures.is_empty() else 1)
func _test_living_room_surface(world: Node2D) -> void:
var living_path: String = "res://content/stages/starters/living_room.scene"
var loaded: bool = bool(_stage.call("_load_scene_from_path", living_path, false))
_expect(loaded, "Living Room loads through the ordinary Stage scene path")
if not loaded:
return
_test_starter_surface_row(world, &"finish_ground_wood", 184.0, "Living Room Wood floor")
_test_saved_starter_camera(living_path, "Living Room")
func _test_starter_surface_row(
world: Node2D,
finish_id: StringName,
expected_top: float,
label: String,
expected_count: int = 25,
expected_half_width: float = 2400.0
) -> Rect2:
var tiles: Array[TerrainBlock] = []
for child: Node in world.get_children():
if child is TerrainBlock and StringName((child as TerrainBlock).finish_id) == finish_id:
tiles.append(child as TerrainBlock)
_expect(tiles.size() == expected_count, "%s uses %d ordinary Ground tiles" % [label, expected_count])
if tiles.is_empty():
return Rect2()
tiles.sort_custom(func(left: TerrainBlock, right: TerrainBlock) -> bool: return left.global_position.x < right.global_position.x)
var row_bounds: Rect2 = _terrain_world_bounds(tiles[0])
for tile: TerrainBlock in tiles:
var bounds: Rect2 = _terrain_world_bounds(tile)
_expect(tile.scale.is_equal_approx(Vector2.ONE), "%s tile stays at normal scale" % label)
_expect(bounds.size.is_equal_approx(Vector2(192.0, 32.0)), "%s tile keeps its 192×32 footprint" % label)
_expect(is_equal_approx(bounds.position.y, expected_top), "%s tile keeps the authored surface top" % label)
row_bounds = row_bounds.merge(bounds)
for index: int in range(tiles.size() - 1):
var left_bounds: Rect2 = _terrain_world_bounds(tiles[index])
var right_bounds: Rect2 = _terrain_world_bounds(tiles[index + 1])
_expect(is_zero_approx(right_bounds.position.x - left_bounds.end.x), "%s adjacent tiles meet without gaps or overlap" % label)
_expect(is_equal_approx(row_bounds.position.x, -expected_half_width) and is_equal_approx(row_bounds.end.x, expected_half_width), "%s spans %.0f world units" % [label, expected_half_width * 2.0])
_expect(expected_half_width - (1000.0 + 1280.0) >= 100.0, "%s retains 100-unit camera-pan endpoint margins" % label)
return row_bounds
func _test_saved_starter_camera(path: String, label: String) -> void:
var result: Dictionary = STAGE_SCENE_IO.read_file(path)
_expect(bool(result.get("ok", false)), "%s saved camera fixture loads" % label)
if not bool(result.get("ok", false)):
return
var data: Dictionary = result.get("data", {}) as Dictionary
var camera_data: Dictionary = data.get("camera", {}) as Dictionary
var expected_position: Vector2 = STAGE_SCENE_IO.data_to_vector(camera_data.get("position"), Vector2.ZERO)
var expected_zoom: float = float(camera_data.get("zoom", 0.0))
var camera: Camera2D = _stage.get_node("Camera2D") as Camera2D
_expect(camera.global_position.is_equal_approx(expected_position) and camera.zoom.is_equal_approx(Vector2.ONE * expected_zoom), "%s runtime camera matches its single saved opening camera" % label)
func _terrain_world_bounds(terrain: TerrainBlock) -> Rect2:
if terrain.polygon_points.is_empty():
return Rect2()
var bounds: Rect2 = Rect2(terrain.to_global(terrain.polygon_points[0]), Vector2.ZERO)
for point: Vector2 in terrain.polygon_points:
bounds = bounds.expand(terrain.to_global(point))
return bounds
func _test_large_brick_pattern_budget() -> void:
var view_size: Vector2 = Vector2(12000.0, 8000.0)
var detail_scale: float = STAGE_SURFACE_RENDERER.brick_detail_scale_for_view(view_size)
var columns: int = int(ceil(view_size.x / (82.0 * detail_scale))) + 2
var rows: int = int(ceil(view_size.y / (34.0 * detail_scale))) + 2
var covered_width: float = float(columns - 2) * 82.0 * detail_scale
var covered_height: float = float(rows - 2) * 34.0 * detail_scale
_expect(detail_scale > 1.0, "very large visible brick fields use one uniform detail scale instead of silently dropping rows and columns")
_expect(rows * columns <= STAGE_SURFACE_RENDERER.MAX_VISIBLE_BRICK_CELLS, "large brick fields stay within the visible-cell budget")
_expect(covered_width >= view_size.x and covered_height >= view_size.y, "large brick fields keep the full visible area covered after detail scaling")
func _test_surface_stroke_clipping() -> void:
var rect: Rect2 = Rect2(Vector2(10.0, 10.0), Vector2(100.0, 60.0))
var horizontal: PackedVector2Array = STAGE_SURFACE_RENDERER._clip_line_to_rect(
rect, Vector2(-30.0, 20.0), Vector2(200.0, 20.0), 4.0
)
_expect(horizontal.size() == 2, "surface horizontal strokes crossing the panel are retained")
if horizontal.size() == 2:
_expect(horizontal[0].is_equal_approx(Vector2(12.0, 20.0)) and horizontal[1].is_equal_approx(Vector2(108.0, 20.0)), "surface horizontal strokes are clipped inside panel edges with stroke width accounted for")
var vertical: PackedVector2Array = STAGE_SURFACE_RENDERER._clip_line_to_rect(
rect, Vector2(20.0, -30.0), Vector2(20.0, 100.0), 4.0
)
_expect(vertical.size() == 2, "surface vertical strokes crossing the panel are retained")
if vertical.size() == 2:
_expect(vertical[0].is_equal_approx(Vector2(20.0, 12.0)) and vertical[1].is_equal_approx(Vector2(20.0, 68.0)), "surface vertical strokes are clipped inside panel edges with stroke width accounted for")
var outside: PackedVector2Array = STAGE_SURFACE_RENDERER._clip_line_to_rect(
rect, Vector2(-30.0, 9.0), Vector2(200.0, 9.0), 4.0
)
_expect(outside.is_empty(), "surface strokes outside the panel bounds are discarded")
func _test_stage_sky_visual_model() -> void:
var sky_view: StageSkyView = STAGE_SKY_VIEW_SCRIPT.new() as StageSkyView
sky_view.size = Vector2(root.size)
root.add_child(sky_view)
await process_frame
var viewport_size: Vector2 = Vector2(root.size)
var reference_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(0.0, -400.0), 0.5, viewport_size)
_expect((reference_response["shift"] as Vector2).is_zero_approx() and is_equal_approx(float(reference_response["scale"]), 1.0), "camera-aware Sky uses the fixed Stage reference framing")
var close_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(420.0, -210.0), 1.5, viewport_size)
var close_shift: Vector2 = close_response["shift"]
var close_scale: float = float(close_response["scale"])
_expect(close_shift.length() > 0.0 and absf(close_shift.x) <= viewport_size.x * 0.05 and absf(close_shift.y) <= viewport_size.y * 0.03, "camera-aware Sky pan stays perceptible and inside the viewport-fraction limits")
_expect(close_scale > 1.0 and close_scale <= 1.15, "camera-aware Sky details grow gently during a close zoom")
var wide_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(0.0, -400.0), 0.1, viewport_size)
var wide_scale: float = float(wide_response["scale"])
_expect(wide_scale < 1.0 and wide_scale >= 0.85, "camera-aware Sky details shrink gently during a wide zoom")
var repeated_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(420.0, -210.0), 1.5, viewport_size)
_expect((repeated_response["shift"] as Vector2).is_equal_approx(close_shift) and is_equal_approx(float(repeated_response["scale"]), close_scale), "absolute camera framing produces a repeatable Sky composition")
var invalid_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(INF, 0.0), 1.0, viewport_size)
_expect((invalid_response["shift"] as Vector2).is_zero_approx() and is_equal_approx(float(invalid_response["scale"]), 1.0), "invalid camera framing falls back to the fixed default composition")
var resized_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(420.0, -210.0), 1.5, Vector2(900.0, 700.0))
var resized_shift: Vector2 = resized_response["shift"]
_expect(absf(resized_shift.x) <= 900.0 * 0.05 and absf(resized_shift.y) <= 700.0 * 0.03, "resized viewport keeps Sky pan inside the same viewport-fraction bounds")
var stars_stay_normalized: bool = true
for star_index: int in 48:
var star_anchor: Vector2 = StageSkyView.star_anchor_for_index(star_index)
if not star_anchor.is_finite() or star_anchor.x < 0.0 or star_anchor.x >= 1.0 or star_anchor.y < 0.0 or star_anchor.y >= 0.62:
stars_stay_normalized = false
break
_expect(stars_stay_normalized, "star anchors remain stable normalized coordinates across viewport resizes")
var capture_sky_views: bool = OS.get_cmdline_user_args().has("--capture-sky-v2")
for sky_id: StringName in StageSkyView.SKY_IDS:
var profile_reference: Dictionary = StageSkyView.compute_camera_response(sky_id, Vector2(0.0, -400.0), 0.5, viewport_size)
_expect((profile_reference["shift"] as Vector2).is_zero_approx() and is_equal_approx(float(profile_reference["scale"]), 1.0), "%s has a stable reference composition" % String(sky_id))
var close_profile: Dictionary = StageSkyView.compute_camera_response(sky_id, Vector2(0.0, -400.0), 6.0, viewport_size)
var wide_profile: Dictionary = StageSkyView.compute_camera_response(sky_id, Vector2(0.0, -400.0), 0.1, viewport_size)
var close_profile_scale: float = float(close_profile["scale"])
var wide_profile_scale: float = float(wide_profile["scale"])
_expect(close_profile_scale >= 0.85 and close_profile_scale <= 1.15 and wide_profile_scale >= 0.85 and wide_profile_scale <= 1.15, "%s motif size stays bounded across the supported zoom range" % String(sky_id))
sky_view.set_sky(sky_id)
await process_frame
var texture_value: Variant = sky_view.get("_gradient_texture")
var gradient_texture: GradientTexture2D = texture_value as GradientTexture2D
_expect(gradient_texture != null and gradient_texture.height >= 2048, "%s uses a high-resolution continuous gradient texture" % String(sky_id))
if gradient_texture != null and gradient_texture.gradient != null:
var gradient: Gradient = gradient_texture.gradient
_expect(gradient.colors.size() >= 3 and gradient.offsets.size() == gradient.colors.size(), "%s retains its approved multi-stop palette" % String(sky_id))
if capture_sky_views:
await RenderingServer.frame_post_draw
var capture: Image = root.get_texture().get_image()
var capture_path: String = ProjectSettings.globalize_path("user://phase19_sky_%s.png" % String(sky_id))
var capture_error: Error = capture.save_png(capture_path)
_expect(capture_error == OK, "%s visual capture saved for inspection" % String(sky_id))
var unbound_shift: Vector2 = sky_view.get("_motif_shift")
var unbound_scale: float = float(sky_view.get("_motif_scale"))
_expect(unbound_shift.is_zero_approx() and is_equal_approx(unbound_scale, 1.0), "unbound chooser-style Sky previews keep their default still composition")
var outline_value: Variant = sky_view.call("_build_cloud_outline")
var cloud_outline: PackedVector2Array = outline_value
_expect(cloud_outline.size() >= 100, "cloud silhouette is a detailed tessellated Bézier contour")
if cloud_outline.size() > 1:
_expect(cloud_outline[0].is_equal_approx(cloud_outline[cloud_outline.size() - 1]), "cloud contour is continuous and closed")
var underside_value: Variant = sky_view.call("_build_cloud_underside")
var cloud_underside: PackedVector2Array = underside_value
_expect(cloud_underside.size() >= 80, "cloud underside uses a connected curved silhouette")
var moon_outline_value: Variant = sky_view.call("_sample_curve_chain", sky_view.call("_build_moon_curves"), 16)
var moon_outline: PackedVector2Array = moon_outline_value
_expect(moon_outline.size() >= 70 and moon_outline[0].is_equal_approx(moon_outline[moon_outline.size() - 1]), "crescent outline is one continuous closed curve")
var moon_arc_value: Variant = sky_view.call("_sample_curve_chain", sky_view.call("_build_moon_outer_arc_curves"), 16)
var moon_outer_arc: PackedVector2Array = moon_arc_value
var warm_rim_value: Variant = sky_view.call("_build_moon_warm_rim", moon_outer_arc, 7.5)
var warm_rim: PackedVector2Array = warm_rim_value
_expect(warm_rim.size() == moon_outer_arc.size() * 2, "crescent warm shading follows the full outer arc as an inset band")
sky_view.queue_free()
await process_frame
func _test_live_sky_camera_binding() -> void:
var sky_view: StageSkyView = _stage.get("_sky_view") as StageSkyView
var camera: Camera2D = _stage.get_node("Camera2D") as Camera2D
_expect(sky_view != null and camera != null and sky_view.get("_bound_camera") == camera, "live Stage Sky binds once to the Stage camera")
if sky_view == null or camera == null:
return
var previous_position: Vector2 = camera.global_position
var previous_zoom: Vector2 = camera.zoom
var previous_sky: StringName = sky_view.get_sky()
sky_view.set_sky(&"sky_sunset")
camera.global_position = Vector2(0.0, -400.0)
camera.zoom = Vector2.ONE * 0.5
await process_frame
var reference_shift: Vector2 = sky_view.get("_motif_shift")
var reference_scale: float = float(sky_view.get("_motif_scale"))
camera.global_position = Vector2(420.0, -210.0)
camera.zoom = Vector2.ONE * 1.5
await process_frame
var moved_shift: Vector2 = sky_view.get("_motif_shift")
var moved_scale: float = float(sky_view.get("_motif_scale"))
_expect(reference_shift.is_zero_approx() and is_equal_approx(reference_scale, 1.0), "live Stage Sky starts at the fixed reference frame")
_expect(not moved_shift.is_zero_approx() and moved_scale > 1.0, "live Stage Sky samples Stage pan and zoom while the camera moves")
var saved_shift: Vector2 = moved_shift
_stage.call("set_mode", 1)
_stage.call("set_mode", 0)
await process_frame
_expect((sky_view.get("_motif_shift") as Vector2).is_equal_approx(saved_shift), "EDIT and DIRECT transitions preserve the same camera-derived Sky framing")
await _test_camera_input_route(sky_view, camera)
camera.global_position = previous_position
camera.zoom = previous_zoom
sky_view.set_sky(previous_sky)
await process_frame
func _test_camera_input_route(sky_view: StageSkyView, camera: Camera2D) -> void:
var input_route_passes: bool = true
_stage.set("_cli_startup", true)
var hovered_control: Control = root.gui_get_hovered_control()
var saved_mouse_filter: Control.MouseFilter = hovered_control.mouse_filter if hovered_control != null else Control.MOUSE_FILTER_STOP
if hovered_control != null:
hovered_control.mouse_filter = Control.MOUSE_FILTER_IGNORE
await process_frame
for mode: int in [0, 1, 2]:
_stage.call("set_mode", mode)
camera.global_position = Vector2(0.0, -400.0)
camera.zoom = Vector2.ONE * 0.5
var wheel_event: InputEventMouseButton = InputEventMouseButton.new()
wheel_event.button_index = MOUSE_BUTTON_WHEEL_UP
wheel_event.pressed = true
wheel_event.position = Vector2(root.size) * 0.5
_stage.call("_input", wheel_event)
await process_frame
var zoomed: bool = camera.zoom.x > 0.5 and float(sky_view.get("_motif_scale")) > 1.0
var pan_press: InputEventMouseButton = InputEventMouseButton.new()
pan_press.button_index = MOUSE_BUTTON_MIDDLE
pan_press.pressed = true
pan_press.position = Vector2(root.size) * 0.5
_stage.call("_input", pan_press)
var pan_motion: InputEventMouseMotion = InputEventMouseMotion.new()
pan_motion.position = pan_press.position + Vector2(80.0, -30.0)
pan_motion.relative = Vector2(80.0, -30.0)
_stage.call("_input", pan_motion)
var pan_release: InputEventMouseButton = InputEventMouseButton.new()
pan_release.button_index = MOUSE_BUTTON_MIDDLE
pan_release.pressed = false
pan_release.position = pan_motion.position
_stage.call("_input", pan_release)
await process_frame
var panned: bool = not camera.global_position.is_equal_approx(Vector2(0.0, -400.0)) and not (sky_view.get("_motif_shift") as Vector2).is_zero_approx()
input_route_passes = input_route_passes and zoomed and panned
_expect(zoomed and panned, "SandboxStage wheel zoom and middle-drag pan update the camera-aware Sky in mode %d" % mode)
_stage.call("set_mode", 0)
_stage.set("_cli_startup", false)
if hovered_control != null and is_instance_valid(hovered_control):
hovered_control.mouse_filter = saved_mouse_filter
_expect(input_route_passes, "SandboxStage camera input route remains active in EDIT, DIRECT, and PLAY")
func _expect(condition: bool, description: String) -> void:
if condition:
print("PASS: " + description)
else:
_failures.append(description)
func _test_scene_color_compatibility() -> void:
var wall_document: Dictionary = {
"version": STAGE_SCENE_IO.FILE_VERSION,
"sky_id": "sky_clear_day",
"objects": [{
"file_id": "wall_panel_1",
"type": "wall_panel",
"position": {"x": 0.0, "y": 0.0},
"rotation": 0.0,
"size": {"x": 320.0, "y": 400.0},
"finish_id": "finish_wall_paint",
"color": "5a9d70ff",
}],
"event_rules": [],
}
var validation: Dictionary = STAGE_SCENE_IO._validate_root(wall_document)
_expect(bool(validation.get("ok", false)), "wall tint written without a # prefix is accepted")
var decoded: Color = STAGE_SCENE_IO.color_from_data("5a9d70ff", Color.MAGENTA)
_expect(decoded.is_equal_approx(Color("#5a9d70ff")), "wall tint without a # prefix decodes to its original color")
_expect(not STAGE_SCENE_IO._valid_color("5a9d70gg"), "malformed wall tint remains rejected")
func _test_grid_controls_layout() -> void:
var chrome: CanvasLayer = _stage.get("_chrome") as CanvasLayer
if chrome == null:
_expect(false, "Stage Chrome exists for grid controls")
return
var snap_check: CheckBox = chrome.call("get_snap_check") as CheckBox
var grid_check: CheckBox = chrome.call("get_grid_check") as CheckBox
var size_spin: SpinBox = chrome.call("get_grid_size_spin") as SpinBox
for control: Control in [grid_check, snap_check, size_spin]:
if control == null:
_expect(false, "Grid and Snap controls are present in the Stage toolbar")
continue
var control_rect: Rect2 = control.get_global_rect()
_expect(control.is_visible_in_tree(), "%s is visible in Edit mode" % control.name)
_expect(control_rect.size.x > 0.0 and control_rect.size.y > 0.0, "%s has a non-zero toolbar layout" % control.name)
_expect(control_rect.position.x >= 0.0 and control_rect.end.x <= root.size.x, "%s fits within the Stage window width" % control.name)
_expect(control_rect.position.y >= 0.0 and control_rect.end.y <= 64.0, "%s fits within the Stage top bar" % control.name)
var file_menu: MenuButton = chrome.call("get_file_menu") as MenuButton
if file_menu != null:
var file_menu_rect: Rect2 = file_menu.get_global_rect()
_expect(file_menu.is_visible_in_tree() and file_menu_rect.position.x >= 0.0 and file_menu_rect.end.x <= root.size.x, "File menu remains visible beside the right-aligned Grid and Snap controls")
# Headless SceneTree mouse injection reaches Stage canvas input but does not
# synthesize a Control pointer position, so verify the real MenuButton popup.
file_menu.show_popup()
await process_frame
_expect(file_menu.get_popup().visible, "File menu popup opens through its production MenuButton control")
file_menu.get_popup().hide()
func _test_starter_card_previews(starter_grid: GridContainer) -> void:
if starter_grid == null:
_expect(false, "starter chooser has a grid for scene previews")
return
await process_frame
await process_frame
var live_card_count: int = 0
for card_node: Node in starter_grid.get_children():
if not card_node.is_queued_for_deletion():
live_card_count += 1
_expect(live_card_count == 4, "starter chooser has four scene cards to preview")
var preview_count: int = 0
var populated_scene_count: int = 0
for card_node: Node in starter_grid.get_children():
if card_node.is_queued_for_deletion():
continue
var preview: Control = card_node.find_child("StarterScenePreview", true, false) as Control
if preview == null:
continue
preview_count += 1
_expect(bool(preview.call("has_preview_scene")), "starter card has a loaded scene preview: %s" % card_node.name)
if int(preview.call("get_loaded_object_count")) > 0:
populated_scene_count += 1
var viewport: SubViewport = preview.get_child(0) as SubViewport if preview.get_child_count() > 0 else null
_expect(viewport != null and preview.find_child("StageSkyView", true, false) != null, "starter preview has an isolated viewport and shared backdrop renderer")
_expect(preview_count == 4, "all four starter cards use scene previews instead of placeholder tiles")
_expect(populated_scene_count == 2, "Town Street and Living Room previews load their scene objects")
func _contains_collision_object(node: Node) -> bool:
if node is CollisionObject2D:
return true
for child: Node in node.get_children():
if _contains_collision_object(child):
return true
return false
func _count_artwork(world: Node2D) -> int:
var count: int = 0
for child: Node in world.get_children():
if child.get_script() == STAGE_SCENERY_ARTWORK:
count += 1
return count
func _test_scenery_placement_snap(world: Node2D, controller: StageSceneryController, artwork_path: String) -> void:
var previous_snap: bool = bool(_stage.get("_snap_enabled"))
var previous_grid_size: float = float(_stage.get("_grid_size"))
_stage.call("_on_grid_size_changed", 20.0)
_stage.call("_on_snap_toggled", true)
var raw_art_position: Vector2 = Vector2(47.0, 58.0)
var click := InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_LEFT
click.pressed = true
_expect(controller.begin_artwork(artwork_path), "snap test starts an artwork placement tool")
controller.update_pointer(raw_art_position)
var artwork_ghost: StageSceneryArtwork = controller.get("_ghost") as StageSceneryArtwork
var ghost_bounds: Rect2 = artwork_ghost.get_visual_world_bounds() if artwork_ghost != null else Rect2()
_expect(artwork_ghost != null and ghost_bounds.get_center().is_equal_approx(Vector2(40.0, 60.0)), "artwork preview centers its visible bounds under the snapped cursor")
_expect(artwork_ghost != null and _is_grid_aligned(ghost_bounds.get_center(), 20.0), "artwork preview center aligns to a grid intersection when snapping is enabled")
controller.handle_mouse_button(click, raw_art_position, false)
var snapped_artwork: Node2D = _last_node_with_script(world, STAGE_SCENERY_ARTWORK)
var snapped_art_bounds: Rect2 = (snapped_artwork as StageSceneryArtwork).get_visual_world_bounds() if snapped_artwork != null else Rect2()
_expect(snapped_artwork != null and snapped_art_bounds.get_center().is_equal_approx(Vector2(40.0, 60.0)), "placed artwork center matches the preview and snapped cursor")
_expect(controller.get("_ghost") == artwork_ghost and controller.get_active_tool() == &"artwork", "placing artwork keeps its cursor ghost ready for another copy")
if snapped_artwork != null:
snapped_artwork.free()
_stage.call("_on_snap_toggled", false)
_expect(controller.begin_artwork(artwork_path), "unsnapped test restarts artwork placement")
controller.handle_mouse_button(click, raw_art_position, false)
var unsnapped_artwork: Node2D = _last_node_with_script(world, STAGE_SCENERY_ARTWORK)
var unsnapped_art_bounds: Rect2 = (unsnapped_artwork as StageSceneryArtwork).get_visual_world_bounds() if unsnapped_artwork != null else Rect2()
_expect(unsnapped_artwork != null and unsnapped_art_bounds.get_center().is_equal_approx(raw_art_position), "artwork placement centers under the exact cursor when grid snapping is off")
if unsnapped_artwork != null:
unsnapped_artwork.free()
_stage.call("_on_snap_toggled", true)
controller.begin_wall(&"finish_wall_brick", raw_art_position)
var wall_ghost: StageWallPanel = controller.get("_ghost") as StageWallPanel
var wall_ghost_bounds: Rect2 = wall_ghost.global_transform * wall_ghost.get_local_bounds() if wall_ghost != null else Rect2()
print("[WallPanelGhostStart] raw=%s global_position=%s bounds=%s" % [str(raw_art_position), str(wall_ghost.global_position if wall_ghost != null else Vector2.INF), str(wall_ghost_bounds)])
_expect(wall_ghost != null and wall_ghost_bounds.position.is_equal_approx(Vector2(40.0, 60.0)), "wall-panel preview immediately uses the current cursor as its visible top-left")
_expect(_is_grid_aligned(wall_ghost_bounds.position, 20.0), "wall-panel preview aligns its visible top-left to a grid intersection")
var wall_start := InputEventMouseButton.new()
wall_start.button_index = MOUSE_BUTTON_LEFT
wall_start.pressed = true
var expected_wall_start: Vector2 = wall_ghost_bounds.position
controller.handle_mouse_button(wall_start, raw_art_position, false)
var actual_wall_start: Vector2 = controller.get("_wall_drag_start")
_expect(actual_wall_start.is_equal_approx(expected_wall_start), "wall-panel drawing starts at the preview ghost corner")
wall_ghost_bounds = wall_ghost.global_transform * wall_ghost.get_local_bounds()
_expect(_is_grid_aligned(wall_ghost_bounds.position, 20.0), "wall-panel preview stays top-left aligned when a drag begins")
var wall_end := InputEventMouseButton.new()
wall_end.button_index = MOUSE_BUTTON_LEFT
wall_end.pressed = false
controller.handle_mouse_button(wall_end, Vector2(113.0, 138.0), false)
var snapped_wall: Node2D = _last_node_with_script(world, STAGE_WALL_PANEL)
var snapped_wall_bounds: Rect2 = (snapped_wall.global_transform * (snapped_wall as StageWallPanel).get_local_bounds()) if snapped_wall != null else Rect2()
_expect(snapped_wall != null and snapped_wall_bounds.position.is_equal_approx(Vector2(40.0, 60.0)), "wall-panel placement aligns its top-left after snapping both drag endpoints")
_expect(wall_ghost != null and wall_ghost.panel_size.is_equal_approx(Vector2(240.0, 280.0)), "wall-panel placement restores the original preview size after drawing")
var restored_ghost_bounds: Rect2 = wall_ghost.global_transform * wall_ghost.get_local_bounds() if wall_ghost != null else Rect2()
_expect(restored_ghost_bounds.position.is_equal_approx(Vector2(120.0, 140.0)), "wall-panel preview returns to the cursor at its original size after drawing")
if snapped_wall != null:
_expect(Vector2(snapped_wall.get("panel_size")).is_equal_approx(Vector2(80.0, 80.0)), "wall-panel dimensions follow the snapped drag endpoints")
snapped_wall.free()
controller.exit_tool()
_stage.call("_on_snap_toggled", false)
controller.begin_wall(&"finish_wall_brick", raw_art_position)
wall_ghost = controller.get("_ghost") as StageWallPanel
wall_ghost_bounds = wall_ghost.global_transform * wall_ghost.get_local_bounds() if wall_ghost != null else Rect2()
_expect(wall_ghost != null and wall_ghost_bounds.position.is_equal_approx(raw_art_position), "unsnapped wall preview uses the raw cursor as its visible top-left")
controller.handle_mouse_button(wall_start, raw_art_position, false)
actual_wall_start = controller.get("_wall_drag_start")
_expect(actual_wall_start.is_equal_approx(raw_art_position), "unsnapped wall drawing starts at the preview ghost corner")
controller.cancel_active_gesture()
controller.exit_tool()
_stage.call("_on_grid_size_changed", previous_grid_size)
_stage.call("_on_snap_toggled", previous_snap)
func _test_wall_eraser(world: Node2D, controller: StageSceneryController) -> void:
var wall := STAGE_WALL_PANEL.new() as StageWallPanel
wall.position = Vector2(20000.0, 20000.0)
wall.configure(Vector2(400.0, 300.0), &"finish_wall_brick", Color("#B95F3D"))
world.add_child(wall)
var start := Vector2(19900.0, 19900.0)
var finish := Vector2(20100.0, 20100.0)
var snapped_start: Vector2 = controller.call("_snap", start)
var snapped_finish: Vector2 = controller.call("_snap", finish)
var expected_erase_rect: Rect2 = Rect2(snapped_start, snapped_finish - snapped_start).abs()
var local_cut_size: Vector2 = (wall.to_local(snapped_finish) - wall.to_local(snapped_start)).abs()
var expected_remaining_area: float = wall.panel_size.x * wall.panel_size.y - local_cut_size.x * local_cut_size.y
var document_changes: Array[int] = [0]
controller.document_change_requested.connect(func() -> void: document_changes[0] += 1)
_stage.call("_enter_scenery_edit_mode")
var selection: StageSelection = _stage.get("_selection") as StageSelection
selection.select_only(wall)
var erase_button: Button = _stage.get("_scenery_erase_wall_area_button") as Button
_expect(erase_button != null and erase_button.visible and erase_button.text == "Erase wall area" and erase_button.custom_minimum_size.y >= 44.0, "selected wall inspector exposes the touch-sized Erase wall area tool")
if erase_button != null:
erase_button.pressed.emit()
_expect(controller.get_active_tool() == &"wall_eraser", "Erase wall area starts the dedicated eraser from Edit Scenery")
var preview: Node2D = controller.get("_ghost") as Node2D
var capture_bar: StageUseTargetCaptureBar = _stage.get("_use_target_capture_bar") as StageUseTargetCaptureBar
_expect(controller.get_active_tool() == &"wall_eraser" and preview != null and preview.name == "WallEraserPreview", "wall eraser shows its distinct preview instead of a wall panel")
var prompt: Label = capture_bar.find_child("Prompt", true, false) as Label
var done_button: Button = capture_bar.find_child("Action_done_tool", true, false) as Button
var cancel_button: Button = capture_bar.find_child("Action_cancel_tool", true, false) as Button
_expect(capture_bar.visible and prompt != null and prompt.text == "Drag over the wall · release to erase" and done_button != null and cancel_button != null, "wall eraser capture bar explains release and keeps Done and Cancel visible")
var press := InputEventMouseButton.new()
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
controller.handle_mouse_button(press, start, false)
controller.handle_mouse_motion(finish)
var release := InputEventMouseButton.new()
release.button_index = MOUSE_BUTTON_LEFT
release.pressed = false
controller.handle_mouse_button(release, finish, false)
var fragments: Array[StageWallPanel] = _wall_fragments_with_origin(world, Vector2(20000.0, 20000.0))
_expect(fragments.size() == 4, "dragging the eraser through a wall makes one rectangular opening")
var remaining_area: float = 0.0
var no_fragment_in_cut: bool = true
for fragment: StageWallPanel in fragments:
remaining_area += fragment.panel_size.x * fragment.panel_size.y
var fragment_bounds: Rect2 = fragment.global_transform * fragment.get_local_bounds()
var local_top_left: Vector2 = fragment.get_local_bounds().position
var rendered_top_left: Vector2 = local_top_left + fragment.get_surface_draw_origin_world()
_expect(rendered_top_left.is_equal_approx(fragment.to_global(local_top_left)), "brick pattern on an erased fragment keeps its world-space mortar phase")
if fragment_bounds.intersects(expected_erase_rect):
no_fragment_in_cut = false
_expect(fragment.finish_id == &"finish_wall_brick" and fragment.tint.is_equal_approx(Color("#B95F3D")), "wall fragments preserve their finish and color")
_expect(is_equal_approx(remaining_area, expected_remaining_area), "wall fragments preserve the area outside the opening")
_expect(no_fragment_in_cut, "no wall panel remains behind the window-sized opening")
_expect(document_changes[0] == 1, "one released wall opening emits one Stage document change")
controller.begin_wall_eraser(Vector2(19850.0, 19950.0))
var cancel_press := InputEventMouseButton.new()
cancel_press.button_index = MOUSE_BUTTON_LEFT
cancel_press.pressed = true
controller.handle_mouse_button(cancel_press, Vector2(19850.0, 19950.0), false)
controller.handle_mouse_motion(Vector2(19950.0, 20050.0))
controller.cancel_active_gesture()
_expect(_count_walls_with_origin(world, Vector2(20000.0, 20000.0)) == 4 and document_changes[0] == 1, "Cancel discards the unfinished eraser rectangle without changing earlier openings")
controller.exit_tool()
controller.begin_wall_eraser(Vector2(19850.0, 19950.0))
controller.handle_mouse_button(cancel_press, Vector2(19850.0, 19950.0), false)
controller.handle_mouse_motion(Vector2(19950.0, 20050.0))
var right_click := InputEventMouseButton.new()
right_click.button_index = MOUSE_BUTTON_RIGHT
right_click.pressed = true
controller.handle_mouse_button(right_click, Vector2(19950.0, 20050.0), false)
_expect(not controller.is_active() and _count_walls_with_origin(world, Vector2(20000.0, 20000.0)) == 4 and document_changes[0] == 1, "right-click cancels the eraser draft and exits without undoing earlier openings")
var serialized: Dictionary = (STAGE_SCENE_ADAPTER.new() as StageSceneAdapter).collect(world, {}, [], {"position": Vector2.ZERO, "zoom": 1.0}, {})
var serialized_anchors: int = 0
for entry_value: Variant in serialized.get("objects", []):
if entry_value is Dictionary and String((entry_value as Dictionary).get("type", "")) == "wall_panel" and (entry_value as Dictionary).has("motif_origin_offset"):
serialized_anchors += 1
_expect(serialized_anchors == 4, "the current scene format records shared motif anchors for erased-wall fragments")
selection.clear_selection()
_stage.call("_refresh_scenery_capture_bar")
capture_bar = _stage.get("_use_target_capture_bar") as StageUseTargetCaptureBar
var unselected_erase_button: Button = capture_bar.find_child("Action_erase_wall_area", true, false) as Button
_expect(unselected_erase_button != null and unselected_erase_button.visible, "Edit Scenery exposes Erase wall area in the capture bar when nothing is selected")
if unselected_erase_button != null:
unselected_erase_button.pressed.emit()
_expect(controller.get_active_tool() == &"wall_eraser", "the always-visible Edit Scenery eraser action starts without requiring a wall selection")
controller.exit_tool()
_stage.call("_exit_scenery_edit_mode")
func _test_wall_eraser_full_cut(world: Node2D, controller: StageSceneryController) -> void:
if controller == null:
_expect(false, "wall eraser is available after scene reopen")
return
var start := Vector2(19790.0, 19840.0)
var finish := Vector2(20210.0, 20160.0)
controller.begin_wall_eraser(start)
var press := InputEventMouseButton.new()
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
controller.handle_mouse_button(press, start, false)
var release := InputEventMouseButton.new()
release.button_index = MOUSE_BUTTON_LEFT
release.pressed = false
controller.handle_mouse_button(release, finish, false)
_expect(_count_walls_with_origin(world, Vector2(20000.0, 20000.0)) == 0, "erasing across the full wall removes all remaining fragments")
controller.exit_tool()
_stage.call("_exit_scenery_edit_mode")
func _wall_fragments_with_origin(world: Node2D, origin: Vector2) -> Array[StageWallPanel]:
var fragments: Array[StageWallPanel] = []
for child: Node in world.get_children():
if child.get_script() != STAGE_WALL_PANEL:
continue
var wall: StageWallPanel = child as StageWallPanel
if wall.get_effective_motif_origin_world().is_equal_approx(origin):
fragments.append(wall)
return fragments
func _count_walls_with_origin(world: Node2D, origin: Vector2) -> int:
return _wall_fragments_with_origin(world, origin).size()
func _last_node_with_script(parent: Node, script: Script) -> Node2D:
for index: int in range(parent.get_child_count() - 1, -1, -1):
var candidate: Node = parent.get_child(index)
if candidate.get_script() == script:
return candidate as Node2D
return null
func _is_grid_aligned(position: Vector2, grid_size: float) -> bool:
var snapped: Vector2 = Vector2(roundf(position.x / grid_size) * grid_size, roundf(position.y / grid_size) * grid_size)
return position.is_equal_approx(snapped)
func _test_group_preview_save_gate(world: Node2D) -> void:
var members: Array[Node2D] = []
for child: Node in world.get_children():
if child.get_script() == STAGE_SCENERY_ARTWORK:
members.append(child as Node2D)
if members.size() == 2:
break
_expect(members.size() == 2, "group-save fixture has two independent Scenery pieces")
if members.size() < 2:
return
var stage_selection: StageSelection = _stage.get("_selection") as StageSelection
var controller: StageSceneryController = _stage.get("_scenery_controller") as StageSceneryController
var pending_restore_frames: int = int(_stage.get("_restore_frames_left"))
_expect(pending_restore_frames > 0, "post-PLAY authored restore is pending before the group edit")
_stage.call("_enter_scenery_edit_mode")
stage_selection.select_only(members[0])
stage_selection.add_to_selection(members[1])
_stage.call("_begin_scenery_group_transform", &"move")
_expect(controller.is_group_preview_pending(), "multi-selection starts a live group preview")
_expect(int(_stage.get("_restore_frames_left")) == 0, "starting the group edit protects it from deferred post-PLAY restoration")
var preview_delta: Vector2 = Vector2(36.0, 21.0)
_expect(controller.preview_group(preview_delta, 1.0), "group preview accepts a valid move")
var expected_positions: Array[Vector2] = [members[0].global_position, members[1].global_position]
var path: String = "res://tests/phase19_pending_group_tmp.scene"
var auto_path: String = "res://tests/phase19_pending_group_autosave_tmp.scene"
var manual_saved: bool = _stage.call("_save_scene_to_path", path, false)
_expect(not manual_saved, "manual save is blocked while a group preview is pending")
var save_gate: ConfirmationDialog = _stage.get("_group_preview_save_dialog") as ConfirmationDialog
_expect(save_gate.visible, "manual save asks whether to keep or cancel the scenery preview")
_expect(not FileAccess.file_exists(path), "pending preview is not written before the user chooses")
var autosave_result: bool = _stage.call("_save_scene_to_path", auto_path, true)
_expect(not autosave_result and not FileAccess.file_exists(auto_path), "autosave also skips uncommitted preview values")
if save_gate.visible:
await _activate_button(save_gate.get_cancel_button())
_expect(controller.is_group_preview_pending() and not FileAccess.file_exists(path), "Cancel save leaves the preview pending and writes no scene")
_expect(members[0].global_position.is_equal_approx(expected_positions[0]) and members[1].global_position.is_equal_approx(expected_positions[1]), "Cancel save keeps the visible group preview unchanged")
manual_saved = _stage.call("_save_scene_to_path", path, false)
_expect(not manual_saved and save_gate.visible, "a later save request asks again after Cancel")
if save_gate.visible:
await _activate_button(save_gate.get_ok_button())
await process_frame
_expect(not controller.is_group_preview_pending(), "Keep changes commits the group before saving")
_expect(FileAccess.file_exists(path), "confirmed group preview is saved")
if FileAccess.file_exists(path):
var saved_read: Dictionary = STAGE_SCENE_IO.read_file(path)
var saved_data: Dictionary = saved_read.get("data", {}) as Dictionary
var saved_objects: Array = saved_data.get("objects", []) as Array
var serialized_positions: Array[Vector2] = []
for raw_object: Variant in saved_objects:
if not raw_object is Dictionary:
continue
var object_data: Dictionary = raw_object as Dictionary
if String(object_data.get("type", "")) == "scenery_artwork":
serialized_positions.append(STAGE_SCENE_IO.data_to_vector(object_data.get("position"), Vector2.ZERO))
_expect(_contains_positions(expected_positions, serialized_positions), "saved document contains both committed group preview positions")
var loaded: bool = _stage.call("_load_scene_from_path", path, false)
_expect(loaded, "group-transformed scene reopens")
var reopened_positions: Array[Vector2] = []
for child: Node in world.get_children():
if child.get_script() == STAGE_SCENERY_ARTWORK:
reopened_positions.append((child as Node2D).global_position)
var positions_restored: bool = _contains_positions(expected_positions, reopened_positions)
_expect(positions_restored, "saved group member transforms survive scene reopen")
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
if FileAccess.file_exists(auto_path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(auto_path))
_stage.call("_exit_scenery_edit_mode")
func _test_missing_artwork_repair(world: Node2D, browser: StageAssetBrowser) -> void:
var path: String = "res://tests/phase19_missing_artwork_tmp.scene"
var missing_path: String = "res://content/props/phase19_picture_not_here.prop"
var marker := STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
marker.name = "MissingTree"
world.add_child(marker)
marker.global_position = Vector2(142.0, 213.0)
marker.rotation = 0.24
marker.scale = Vector2(1.25, 0.82)
marker.configure_missing(missing_path, "My missing tree", Rect2(Vector2(-44.0, -51.0), Vector2(88.0, 102.0)), &"far")
_stage.call("_mark_scene_dirty")
var saved_missing: bool = _stage.call("_save_scene_to_path", path, false)
_expect(saved_missing, "scene with a missing artwork reference can be saved")
var loaded_missing: bool = _stage.call("_load_scene_from_path", path, false)
_expect(loaded_missing, "scene with a missing artwork reference reopens")
var summary: ConfirmationDialog = _stage.get("_missing_scenery_dialog") as ConfirmationDialog
_expect(summary.visible and summary.title == "Artwork not found", "missing-art load summary offers kid-readable recovery")
if summary.visible:
_expect(summary.ok_button_text == "Show Scenery" and summary.cancel_button_text == "Later", "missing-art summary exposes Show Scenery and Later")
await _activate_button(summary.get_cancel_button())
var missing_target: StageSceneryArtwork = _find_missing_artwork(world)
_expect(missing_target != null, "missing art remains as a selectable placement marker")
if missing_target == null:
return
var saved_position: Vector2 = missing_target.global_position
var saved_rotation: float = missing_target.global_rotation
var saved_scale: Vector2 = missing_target.scale
var saved_depth: StringName = missing_target.depth
var dirty_before_repair: bool = bool(_stage.get("_dirty"))
_stage.call("_enter_scenery_edit_mode")
(_stage.get("_selection") as StageSelection).select_only(missing_target)
var repair_card: StageInspectorPanel = _stage.get("_scenery_inspector") as StageInspectorPanel
_expect(repair_card.visible, "selecting missing art in Edit Scenery opens the shared repair card")
await process_frame
await process_frame
var capture_bar: StageUseTargetCaptureBar = _stage.get("_use_target_capture_bar") as StageUseTargetCaptureBar
for _refresh_index: int in 3:
_stage.call("_refresh_scenery_capture_bar")
await process_frame
var action_buttons: Array[Node] = capture_bar.find_children("Action_*", "Button", true, false)
_expect(action_buttons.size() == 4, "repeated selection refresh keeps one capture button per action")
var capture_rect: Rect2 = capture_bar.get_global_rect()
var repair_rect: Rect2 = repair_card.get_global_rect()
_expect(capture_bar.visible and capture_rect.end.x <= repair_rect.position.x, "1280x720 scenery capture bar reserves a clear lane beside the repair inspector (bar=%s inspector=%s viewport=%s)" % [capture_rect, repair_rect, get_root().size])
root.size = Vector2i(800, 600)
_stage.call("_refresh_scenery_inspector_layout")
await process_frame
await process_frame
capture_rect = capture_bar.get_global_rect()
repair_rect = repair_card.get_global_rect()
_expect(capture_bar.visible and repair_card.visible and capture_rect.end.x <= repair_rect.position.x, "narrow viewport keeps the stacked capture actions separate from the repair inspector")
root.size = Vector2i(1280, 720)
_stage.call("_refresh_scenery_inspector_layout")
await process_frame
await process_frame
var find_button: Button = _stage.get("_scenery_find_button") as Button
await _activate_button(find_button)
var browse_dialog: FileDialog = browser.get_browse_dialog()
_expect(browse_dialog.visible and browser.selector_kind == "scenery" and browse_dialog.filters.has("*.prop ; Prop Files"), "Find artwork opens the established .prop chooser")
var choose_button: Button = _stage.get("_scenery_choose_button") as Button
var entries: Array[Dictionary] = browser.scan_entries("scenery")
_expect(not entries.is_empty(), "repair chooser has valid artwork choices")
if entries.is_empty():
return
if browse_dialog.visible:
browse_dialog.file_selected.emit(String(entries[0].get("path", "")))
if browse_dialog.visible:
browse_dialog.hide()
await process_frame
await process_frame
var confirm_dialog: ConfirmationDialog = _stage.get("_scenery_repair_confirm_dialog") as ConfirmationDialog
_expect(confirm_dialog.visible and confirm_dialog.title == "Use this artwork here?", "candidate previews at the saved spot and requires explicit confirmation")
var candidate: StageSceneryArtwork = _stage.get("_scenery_repair_candidate") as StageSceneryArtwork
_expect(candidate != null and candidate.global_position.is_equal_approx(saved_position) and candidate.global_rotation == saved_rotation and candidate.scale.is_equal_approx(saved_scale), "repair candidate ghost matches the saved transform")
_expect(missing_target.missing and missing_target.asset_path == missing_path and bool(_stage.get("_dirty")) == dirty_before_repair, "preview has not changed the marker or dirty state")
if confirm_dialog.visible:
await _activate_button(confirm_dialog.get_cancel_button())
_expect(missing_target.missing and missing_target.asset_path == missing_path and bool(_stage.get("_dirty")) == dirty_before_repair, "Keep for now cancels repair without altering the scene")
await _activate_button(choose_button)
var selector: AssetSelector = browser.get_selector()
_expect(selector.visible and selector.kind == "scenery", "Choose another artwork opens the shared Artwork chooser")
var gallery_grid: GridContainer = selector.find_child("GridContainer", true, false) as GridContainer
var first_card: AssetCard = gallery_grid.get_child(0) as AssetCard if gallery_grid != null and gallery_grid.get_child_count() > 0 else null
_expect(first_card != null, "shared Artwork chooser displays a selectable drawing card")
if first_card != null:
await _activate_card(first_card)
await process_frame
await process_frame
_expect(confirm_dialog.visible, "gallery artwork selection opens the same explicit repair confirmation")
if confirm_dialog.visible:
await _activate_button(confirm_dialog.get_ok_button())
_expect(not missing_target.missing and missing_target.asset_path == String(entries[0].get("path", "")), "Use artwork replaces only the selected missing reference")
_expect(missing_target.global_position.is_equal_approx(saved_position) and is_equal_approx(missing_target.global_rotation, saved_rotation) and missing_target.scale.is_equal_approx(saved_scale) and missing_target.depth == saved_depth, "repair preserves saved placement, size, orientation, and depth")
_expect(bool(_stage.get("_dirty")), "confirmed artwork repair marks the scene dirty")
var saved_repair: bool = _stage.call("_save_scene_to_path", path, false)
var reopened_repair: bool = _stage.call("_load_scene_from_path", path, false) if saved_repair else false
var repaired_after_reopen: StageSceneryArtwork = _find_artwork(world, String(entries[0].get("path", "")))
_expect(reopened_repair and repaired_after_reopen != null and not repaired_after_reopen.missing, "repaired live artwork path persists after save and reopen")
_stage.call("_exit_scenery_edit_mode")
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func _test_single_scenery_transforms(world: Node2D, artwork_path: String) -> void:
var artwork: StageSceneryArtwork = STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
_expect(artwork.load_visual(artwork_path), "single-transform fixture loads production scenery artwork")
if artwork.missing:
artwork.free()
return
artwork.display_name = "Phase 19 Transform Fixture"
artwork.set_depth(&"near")
world.add_child(artwork)
artwork.global_position = Vector2(2400.0, 1600.0)
artwork.global_rotation = 0.19
artwork.scale = Vector2(1.25, 0.82)
var selection: StageSelection = _stage.get("_selection") as StageSelection
var controller: StageSceneryController = _stage.get("_scenery_controller") as StageSceneryController
var document_changes: Array[int] = [0]
controller.document_change_requested.connect(func() -> void: document_changes[0] += 1)
_stage.call("_enter_scenery_edit_mode")
selection.select_only(artwork)
_stage.call("_on_grid_size_changed", 20.0)
_stage.call("_on_snap_toggled", true)
var original_scale: Vector2 = artwork.scale
var original_bounds: Rect2 = artwork.get_visual_world_bounds()
_stage.set("_dirty", false)
var press: InputEventMouseButton = InputEventMouseButton.new()
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
_stage.call("_handle_scenery_edit_click", press, original_bounds.get_center())
await process_frame
_expect(controller.is_single_transform_active() and controller.get_single_transform_kind() == &"move", "single scenery body drag starts the production move transaction")
var capture_bar: StageUseTargetCaptureBar = _stage.get("_use_target_capture_bar") as StageUseTargetCaptureBar
var cancel_button: Button = capture_bar.find_child("Action_cancel_single", true, false) as Button
_expect(capture_bar.visible and cancel_button != null and cancel_button.text.contains("Cancel"), "active scenery drag exposes a visible Cancel action")
var scenery_inspector: StageInspectorPanel = _stage.get("_scenery_inspector") as StageInspectorPanel
_expect(capture_bar.get_global_rect().end.x <= scenery_inspector.get_global_rect().position.x, "active scenery Cancel bar stays in the clear lane beside the inspector")
_stage.call("_handle_scenery_edit_motion", original_bounds.get_center() + Vector2(37.0, 43.0))
var moved_bounds: Rect2 = artwork.get_visual_world_bounds()
_expect(_is_grid_aligned(moved_bounds.position, 20.0), "snapped scenery move aligns the visible AABB top-left to the grid")
var release: InputEventMouseButton = InputEventMouseButton.new()
release.button_index = MOUSE_BUTTON_LEFT
release.pressed = false
_stage.call("_handle_scenery_edit_click", release, moved_bounds.get_center())
_expect(not controller.is_single_transform_active() and bool(_stage.get("_dirty")), "mouse release commits one move and marks the Stage document dirty")
var committed_position: Vector2 = artwork.global_position
_stage.set("_dirty", false)
_stage.call("_handle_scenery_edit_click", press, moved_bounds.get_center())
_stage.call("_handle_scenery_edit_motion", moved_bounds.get_center() + Vector2(63.0, 35.0))
_expect(not artwork.global_position.is_equal_approx(committed_position), "second drag shows a live move preview before cancellation")
if cancel_button != null:
cancel_button.pressed.emit()
_expect(not controller.is_single_transform_active() and artwork.global_position.is_equal_approx(committed_position), "visible Cancel restores the exact committed transform")
_stage.call("_handle_scenery_edit_click", press, committed_position)
_expect(controller.is_single_transform_active(), "next canvas press after clicking Cancel is not swallowed by a stale release guard")
_stage.call("_handle_scenery_edit_motion", committed_position + Vector2(63.0, 35.0))
var document_changes_after_cancel: int = document_changes[0]
var escape: InputEventKey = InputEventKey.new()
escape.keycode = KEY_ESCAPE
escape.pressed = true
_stage.call("_unhandled_key_input", escape)
_expect(not controller.is_single_transform_active() and artwork.global_position.is_equal_approx(committed_position), "Escape cancels the active single-piece transform")
_expect(document_changes[0] == document_changes_after_cancel, "Cancel and Escape leave the Stage document unchanged")
_stage.set("_dirty", false)
var resize_bounds: Rect2 = artwork.get_visual_world_bounds()
var resize_corner: Vector2 = resize_bounds.position
var opposite_corner: Vector2 = resize_bounds.end
_stage.call("_handle_scenery_edit_click", press, resize_corner)
_expect(controller.is_single_transform_active() and controller.get_single_transform_kind() == &"resize", "44-pixel scenery corner handle starts resize")
var resize_delta: Vector2 = (resize_corner - opposite_corner) * 0.25
_stage.call("_handle_scenery_edit_motion", resize_corner + resize_delta)
_expect(artwork.scale.is_equal_approx(original_scale * 1.25), "corner resize applies a uniform factor while preserving non-uniform starting scale (actual %s expected %s)" % [str(artwork.scale), str(original_scale * 1.25)])
var resized_bounds: Rect2 = artwork.get_visual_world_bounds()
_expect(resized_bounds.end.is_equal_approx(opposite_corner), "corner resize keeps the opposite visible AABB corner fixed (actual %s expected %s)" % [str(resized_bounds.end), str(opposite_corner)])
document_changes[0] = 0
release.position = resize_corner + resize_delta
_stage.call("_handle_scenery_edit_click", release, release.position)
_expect(not controller.is_single_transform_active() and bool(_stage.get("_dirty")), "resize commits on release without a separate Keep step")
_expect(document_changes[0] == 1, "resize release emits exactly one document change")
_stage.call("_handle_scenery_edit_click", release, release.position)
_expect(document_changes[0] == 1, "repeated resize release does not commit the same gesture twice")
var saved_scale: Vector2 = artwork.scale
var saved_position: Vector2 = artwork.global_position
var roundtrip_path: String = "res://tests/phase19_single_scenery_transform_tmp.scene"
var saved: bool = _stage.call("_save_scene_to_path", roundtrip_path, false)
var roundtrip_data: Dictionary = STAGE_SCENE_IO.read_file(roundtrip_path).get("data", {}) as Dictionary if saved else {}
_expect(saved and String(roundtrip_data.get("version", "")) == STAGE_SCENE_IO.FILE_VERSION, "single-piece transforms serialize in the current .scene version")
var loaded: bool = _stage.call("_load_scene_from_path", roundtrip_path, false) if saved else false
var restored: StageSceneryArtwork = _find_artwork_by_display_name(world, "Phase 19 Transform Fixture")
_expect(loaded and restored != null and restored.scale.is_equal_approx(saved_scale) and restored.global_position.is_equal_approx(saved_position), "single-piece size and placement survive save/reopen")
if FileAccess.file_exists(roundtrip_path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(roundtrip_path))
_stage.call("_enter_scenery_edit_mode")
var wall: StageWallPanel = STAGE_WALL_PANEL.new() as StageWallPanel
wall.configure(Vector2(160.0, 220.0), &"finish_wall_brick", Color.WHITE)
world.add_child(wall)
wall.global_position = Vector2(2800.0, 1600.0)
wall.scale = Vector2(1.4, 0.9)
selection.select_only(wall)
_stage.call("_refresh_scenery_inspector")
var size_row: HBoxContainer = _stage.get("_scenery_size_row") as HBoxContainer
var smaller_button: Button = _stage.get("_scenery_smaller_button") as Button
var bigger_button: Button = _stage.get("_scenery_bigger_button") as Button
_expect(size_row.visible and smaller_button.custom_minimum_size.y >= 44.0 and bigger_button.custom_minimum_size.y >= 44.0, "selected wall inspector uses shared, touch-sized Smaller and Bigger actions")
var wall_bounds: Rect2 = wall.global_transform * wall.get_local_bounds()
_stage.call("_step_selected_scenery_size", 1.1)
_expect(wall.panel_size.is_equal_approx(Vector2(176.0, 242.0)), "wall size step updates panel dimensions")
_expect(wall.scale.is_equal_approx(Vector2(1.4, 0.9)), "wall size step preserves stage-space motif pitch by leaving node scale unchanged")
_expect((wall.global_transform * wall.get_local_bounds()).position.is_equal_approx(wall_bounds.position), "wall size step pins visible top-left while resizing")
var unknown_marker: StageSceneryArtwork = STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
unknown_marker.configure_missing("res://content/props/phase19_unknown_footprint.prop", "Unknown footprint", Rect2(), &"near")
world.add_child(unknown_marker)
unknown_marker.global_position = Vector2(2900.0, 1600.0)
selection.select_only(unknown_marker)
_stage.call("_refresh_scenery_inspector")
_expect(not size_row.visible and smaller_button.disabled and bigger_button.disabled, "unknown-footprint missing artwork hides and disables misleading size controls")
_expect(not controller.begin_single_transform(unknown_marker, &"resize", unknown_marker.global_position), "unknown-footprint missing artwork cannot start a corner resize")
_expect(not controller.step_single_size(unknown_marker, 1.1), "unknown-footprint missing artwork cannot use Smaller or Bigger size steps")
_stage.call("_exit_scenery_edit_mode")
_stage.call("_on_snap_toggled", false)
unknown_marker.free()
wall.free()
func _test_selected_scenery_depth(world: Node2D, artwork_path: String) -> void:
var artwork: StageSceneryArtwork = STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
_expect(artwork.load_visual(artwork_path), "depth-inspector fixture loads production scenery artwork")
if artwork.missing:
artwork.free()
return
artwork.display_name = "Phase 19 Depth Inspector Fixture"
artwork.set_depth(&"near")
world.add_child(artwork)
artwork.global_position = Vector2(4600.0, 1800.0)
var selection: StageSelection = _stage.get("_selection") as StageSelection
_stage.call("_enter_scenery_edit_mode")
selection.select_only(artwork)
await process_frame
await process_frame
var inspector: StageInspectorPanel = _stage.get("_scenery_inspector") as StageInspectorPanel
var section: VBoxContainer = _stage.get("_scenery_depth_section") as VBoxContainer
var choices: StagePanelChoiceGroup = _stage.get("_scenery_depth_group") as StagePanelChoiceGroup
var far_button: Button = choices.find_child("Choice_far", true, false) as Button if choices != null else null
var near_button: Button = choices.find_child("Choice_near", true, false) as Button if choices != null else null
var floor_button: Button = choices.find_child("Choice_floor", true, false) as Button if choices != null else null
_expect(inspector.visible and section != null and section.visible, "one selected artwork shows the Where it appears section")
_expect(far_button != null and near_button != null and floor_button != null, "depth inspector offers Far, Near, and On the floor choices")
if far_button == null or near_button == null or floor_button == null:
_stage.call("_exit_scenery_edit_mode")
return
_expect(far_button.custom_minimum_size.y >= 44.0 and near_button.custom_minimum_size.y >= 44.0 and floor_button.custom_minimum_size.y >= 44.0, "depth choices meet the shared 44-pixel touch target minimum")
_expect(near_button.button_pressed and not far_button.button_pressed and not floor_button.button_pressed, "inspector silently highlights the selected artwork's saved depth")
var original_position: Vector2 = artwork.global_position
var original_scale: Vector2 = artwork.scale
_stage.set("_dirty", false)
await _send_button_key(near_button, KEY_SPACE)
_expect(artwork.depth == &"near" and not bool(_stage.get("_dirty")), "choosing the current depth is a real UI no-op and leaves the Stage clean")
var scroll_fixture: Control = Control.new()
scroll_fixture.name = "DepthInspectorScrollFixture"
scroll_fixture.custom_minimum_size = Vector2(1.0, 360.0)
inspector.body().add_child(scroll_fixture)
await process_frame
await process_frame
inspector.request_fit()
await process_frame
await process_frame
var scroll: ScrollContainer = inspector.get("_scroll") as ScrollContainer
var scrollbar: VScrollBar = scroll.get_v_scroll_bar() as VScrollBar if scroll != null else null
var scroll_limit: int = maxi(0, int(floor(scrollbar.max_value - scrollbar.page))) if scrollbar != null else 0
_expect(scroll_limit > 0, "overflowing artwork inspector body scrolls independently of its fixed header")
if scroll != null and scroll_limit > 0:
scroll.scroll_vertical = mini(40, scroll_limit)
await process_frame
var scroll_before_change: int = scroll.scroll_vertical if scroll != null else 0
var header: Control = inspector.get("_header") as Control
var header_position_before_change: Vector2 = header.global_position if header != null else Vector2.ZERO
_stage.set("_dirty", false)
await _send_button_key(far_button, KEY_SPACE)
_expect(artwork.depth == &"far" and artwork.z_index == -30, "focused keyboard input immediately changes the selected artwork's live Far draw layer")
_expect(bool(_stage.get("_dirty")), "changing artwork depth marks the Stage dirty")
_expect(artwork.global_position.is_equal_approx(original_position) and artwork.scale.is_equal_approx(original_scale), "changing depth leaves artwork position and size unchanged")
_expect(inspector.visible and section.visible and far_button.button_pressed, "depth change keeps the inspector open and highlights the new choice")
_expect(scroll == null or scroll.scroll_vertical == scroll_before_change, "depth refresh preserves the inspector body's scroll position")
_expect(header == null or header.global_position.is_equal_approx(header_position_before_change), "depth refresh keeps the inspector header fixed while its body is scrolled")
_stage.set("_dirty", false)
await _send_button_key(far_button, KEY_SPACE)
_expect(artwork.depth == &"far" and not bool(_stage.get("_dirty")), "repeating the selected depth through real keyboard input does not dirty the Stage")
if scroll_fixture.is_inside_tree():
scroll_fixture.queue_free()
_stage.call("_refresh_scenery_inspector_layout")
await process_frame
var roundtrip_path: String = "res://tests/phase19_selected_depth_tmp.scene"
var saved: bool = _stage.call("_save_scene_to_path", roundtrip_path, false)
var loaded: bool = _stage.call("_load_scene_from_path", roundtrip_path, false) if saved else false
var restored: StageSceneryArtwork = _find_artwork_by_display_name(world, "Phase 19 Depth Inspector Fixture")
_expect(saved and loaded and restored != null and restored.depth == &"far", "selected artwork depth persists through production scene save and reopen")
if restored != null:
_stage.call("_enter_scenery_edit_mode")
selection.select_only(restored)
await process_frame
choices = _stage.get("_scenery_depth_group") as StagePanelChoiceGroup
far_button = choices.find_child("Choice_far", true, false) as Button if choices != null else null
_expect(far_button != null and far_button.button_pressed, "reopened artwork inspector highlights the saved depth")
_stage.set("_dirty", false)
var missing_marker: StageSceneryArtwork = STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
missing_marker.configure_missing("res://content/props/phase19_depth_marker_missing.prop", "Phase 19 Depth Marker", Rect2(Vector2(-44.0, -51.0), Vector2(88.0, 102.0)), &"near")
world.add_child(missing_marker)
missing_marker.global_position = Vector2(5000.0, 1800.0)
selection.select_only(missing_marker)
await process_frame
choices = _stage.get("_scenery_depth_group") as StagePanelChoiceGroup
near_button = choices.find_child("Choice_near", true, false) as Button if choices != null else null
floor_button = choices.find_child("Choice_floor", true, false) as Button if choices != null else null
_expect(missing_marker.missing and section.visible and near_button != null and near_button.button_pressed, "missing-art marker exposes and highlights its saved depth before repair")
_stage.set("_dirty", false)
if floor_button != null:
await _send_button_key(floor_button, KEY_SPACE)
_expect(missing_marker.depth == &"floor" and missing_marker.z_index == -5, "keyboard input changes a missing-art marker's live draw layer")
_expect(bool(_stage.get("_dirty")), "changing missing-art depth marks the Stage dirty")
saved = _stage.call("_save_scene_to_path", roundtrip_path, false)
loaded = _stage.call("_load_scene_from_path", roundtrip_path, false) if saved else false
var restored_marker: StageSceneryArtwork = _find_artwork_by_display_name(world, "Phase 19 Depth Marker")
_expect(saved and loaded and restored_marker != null and restored_marker.missing and restored_marker.depth == &"floor", "missing-art marker keeps its edited depth after scene save and reopen")
_stage.call("_enter_scenery_edit_mode")
var wall: StageWallPanel = STAGE_WALL_PANEL.new() as StageWallPanel
wall.configure(Vector2(180.0, 220.0), &"finish_wall_brick", Color.WHITE)
world.add_child(wall)
selection.select_only(wall)
await process_frame
section = _stage.get("_scenery_depth_section") as VBoxContainer
_expect(not section.visible, "wall panels do not show artwork depth choices")
var other_artwork: StageSceneryArtwork = STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
_expect(other_artwork.load_visual(artwork_path), "multi-selection guard fixture loads production scenery artwork")
world.add_child(other_artwork)
if restored_marker != null:
selection.select_only(restored_marker)
selection.add_to_selection(other_artwork)
await process_frame
_expect(not section.visible and not inspector.visible, "multiple selected scenery pieces do not offer a single-artwork depth choice")
selection.clear_selection()
await process_frame
_expect(not section.visible and not inspector.visible, "no selection hides the artwork depth section")
wall.free()
other_artwork.free()
_stage.call("_exit_scenery_edit_mode")
if FileAccess.file_exists(roundtrip_path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(roundtrip_path))
func _test_prop_size_for_stage() -> void:
var editor: PropEditor = PROP_EDITOR_SCENE.instantiate() as PropEditor
root.add_child(editor)
await process_frame
await process_frame
var canvas: PropCanvas = editor.get("_canvas") as PropCanvas
var original_shape: Dictionary = {
"shape_type": "polygon",
"points": PackedVector2Array([Vector2(-50.0, -200.0), Vector2(50.0, -200.0), Vector2(50.0, 0.0), Vector2(-50.0, 0.0)]),
"fill_color": "#9D6A4A",
"outline_color": "#2D2532",
"outline_width": 4.0,
"closed": true,
}
canvas.set_shape_data([original_shape])
var original_shapes: Array[Dictionary] = canvas.get_shape_data()
var original_anchors: Dictionary = (editor.get("_anchors") as Dictionary).duplicate(true)
editor.set("_dirty", false)
editor.call("_on_size_for_stage_pressed")
var size_controls: FlowContainer = editor.get("_stage_size_controls") as FlowContainer
_expect(canvas.is_stage_size_preview_active() and size_controls.visible, "Size for Stage starts a separate preview transaction and reveals its controls")
var required_labels: Array[String] = ["Small", "Room", "Big", "Make smaller", "Make bigger", "Keep size", "✕ Cancel"]
for required_label: String in required_labels:
var button: Button = _find_button_by_text(size_controls, required_label)
_expect(button != null and button.custom_minimum_size.y >= 44.0, "Size for Stage exposes touch-sized %s control" % required_label)
for _frame: int in 4:
await process_frame
var entry_bounds: Rect2 = canvas.get_stage_size_comparison_bounds()
var entry_zoom: float = float(canvas.get("_zoom"))
var entry_pan: Vector2 = canvas.get("_pan_offset")
var entry_screen_bounds: Rect2 = Rect2(entry_bounds.position * entry_zoom + entry_pan, entry_bounds.size * entry_zoom)
var entry_viewport_size: Vector2 = Vector2(maxf(0.0, canvas.size.x - 96.0), maxf(0.0, canvas.size.y - 96.0))
var entry_viewport: Rect2 = Rect2(Vector2(48.0, 48.0), entry_viewport_size)
var size_tools: FlowContainer = editor.get("_size_tools") as FlowContainer
_expect(size_tools.size.y < 160.0 and canvas.size.y > 96.0, "Size for Stage controls preserve a visible canvas instead of expanding the toolbar vertically")
_expect(entry_viewport.encloses(entry_screen_bounds), "Size for Stage entry fits artwork and the stickman guide after the editor layout settles")
editor.call("_choose_stage_size", 1.0)
var room_extent: float = maxf(canvas.get_visible_shapes_bounds().size.x, canvas.get_visible_shapes_bounds().size.y)
_expect(is_equal_approx(room_extent, 939.5), "Room preset targets the 939.5-unit standing-stickman height")
_expect(not bool(editor.get("_dirty")), "size preview leaves the prop draft and autosave state untouched")
editor.call("_adjust_stage_size", 1.1)
var bigger_extent: float = maxf(canvas.get_visible_shapes_bounds().size.x, canvas.get_visible_shapes_bounds().size.y)
_expect(is_equal_approx(bigger_extent, room_extent * 1.1), "Make bigger applies one 1.1 step")
editor.call("_adjust_stage_size", 1.0 / 1.1)
_expect(is_equal_approx(maxf(canvas.get_visible_shapes_bounds().size.x, canvas.get_visible_shapes_bounds().size.y), room_extent), "Make smaller applies the reciprocal 1.1 step")
var comparison_bounds: Rect2 = canvas.get_stage_size_comparison_bounds()
_expect(comparison_bounds.position.y < canvas.get_visible_shapes_bounds().position.y and comparison_bounds.end.y >= canvas.get_visible_shapes_bounds().end.y, "preview fit bounds include the foreground stickman guide and shared floor line")
editor.call("_finish_stage_size_preview", false)
_expect(not canvas.is_stage_size_preview_active() and canvas.get_shape_data() == original_shapes, "Cancel closes the preview and restores the original drawing exactly")
_expect((editor.get("_anchors") as Dictionary) == original_anchors and not bool(editor.get("_dirty")), "Cancel restores anchors and leaves the document clean")
editor.call("_on_size_for_stage_pressed")
editor.call("_choose_stage_size", 0.35)
var small_extent: float = maxf(canvas.get_visible_shapes_bounds().size.x, canvas.get_visible_shapes_bounds().size.y)
_expect(is_equal_approx(small_extent, 328.825), "Small preset targets 0.35 of the standing-stickman height")
editor.call("_finish_stage_size_preview", true)
var committed_extent: float = maxf(canvas.get_visible_shapes_bounds().size.x, canvas.get_visible_shapes_bounds().size.y)
_expect(not canvas.is_stage_size_preview_active() and is_equal_approx(committed_extent, small_extent), "Keep commits the chosen overall document scale")
_expect(bool(editor.get("_dirty")), "keeping a size change enters the existing dirty/save lifecycle")
editor.queue_free()
await process_frame
func _find_button_by_text(parent: Node, text: String) -> Button:
if parent == null:
return null
for candidate: Node in parent.find_children("*", "Button", true, false):
var button: Button = candidate as Button
if button != null and button.text == text:
return button
return null
func _find_missing_artwork(world: Node2D) -> StageSceneryArtwork:
for child: Node in world.get_children():
if child.get_script() == STAGE_SCENERY_ARTWORK and (child as StageSceneryArtwork).missing:
return child as StageSceneryArtwork
return null
func _contains_positions(expected: Array[Vector2], actual: Array[Vector2]) -> bool:
var unmatched: Array[Vector2] = actual.duplicate()
for expected_position: Vector2 in expected:
var match_index: int = -1
for index: int in range(unmatched.size()):
if unmatched[index].is_equal_approx(expected_position):
match_index = index
break
if match_index < 0:
return false
unmatched.remove_at(match_index)
return true
func _find_artwork(world: Node2D, asset_path: String) -> StageSceneryArtwork:
for child: Node in world.get_children():
if child.get_script() == STAGE_SCENERY_ARTWORK and (child as StageSceneryArtwork).asset_path == asset_path:
return child as StageSceneryArtwork
return null
func _find_artwork_by_display_name(world: Node2D, display_name: String) -> StageSceneryArtwork:
for child: Node in world.get_children():
if child.get_script() == STAGE_SCENERY_ARTWORK and (child as StageSceneryArtwork).display_name == display_name:
return child as StageSceneryArtwork
return null
func _find_card_by_id(grid: GridContainer, card_id: String) -> AssetCard:
if grid == null:
return null
for child: Node in grid.get_children():
var card: AssetCard = child as AssetCard
if card != null and String(card.payload().get("placement_id", "")) == card_id:
return card
return null
func _send_stage_click(position: Vector2, button: int) -> void:
var press := InputEventMouseButton.new()
press.button_index = button
press.position = position
press.global_position = position
press.pressed = true
_stage.get_viewport().push_input(press)
await process_frame
var release := InputEventMouseButton.new()
release.button_index = button
release.position = position
release.global_position = position
release.pressed = false
_stage.get_viewport().push_input(release)
await process_frame
func _send_button_key(button: Button, keycode: Key) -> void:
button.grab_focus()
await process_frame
var viewport: Viewport = button.get_viewport()
var press: InputEventKey = InputEventKey.new()
press.keycode = keycode
press.pressed = true
viewport.push_input(press)
await process_frame
var release: InputEventKey = InputEventKey.new()
release.keycode = keycode
release.pressed = false
viewport.push_input(release)
await process_frame
func _activate_button(button: Button) -> void:
button.pressed.emit()
await process_frame
func _activate_card(card: AssetCard) -> void:
card.activated.emit(card.payload())
await process_frame