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