- Updated architectural specifications for Phase 19 scenery, backgrounds, and surface finishes to reflect implementation status and user verification results. - Revised UI/UX specifications for background ground and wall color, plain background, and starter scene composition to indicate successful implementation and verification. - Enhanced scenery controller and sandbox stage scripts to improve eraser functionality and user interactions. - Adjusted test cases to align with the latest changes in scenery and background implementations, ensuring all checks pass successfully. - Added new buttons for wall area erasure in the scenery capture bar and updated layout handling for better user experience.
1450 lines
88 KiB
GDScript
1450 lines
88 KiB
GDScript
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", 41, 3936.0)
|
||
var road_bounds: Rect2 = _test_starter_surface_row(world, &"finish_ground_asphalt", 16.0, "Town Street asphalt", 41, 3936.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 surface_cards_row: HBoxContainer = selector.find_child("SurfaceCardsRow", true, false) as HBoxContainer
|
||
var background_preview_count: int = 0
|
||
var cloudy_card: AssetCard = _find_card_by_id(background_grid, "sky_cloudy_day")
|
||
for card_container: Control in [background_grid, surface_cards_row]:
|
||
if card_container == null:
|
||
continue
|
||
for card_node: Node in card_container.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() == 5 and surface_cards_row != null and surface_cards_row.get_child_count() == 2 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,
|
||
"background_kind": "sky",
|
||
"sky_id": "sky_clear_day",
|
||
"plain_color": STAGE_SCENE_IO.DEFAULT_PLAIN_COLOR,
|
||
"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_tool()
|
||
_expect(not controller.is_active() and _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:
|
||
_stage.set_mode(SandboxStage.StageMode.PLAY)
|
||
await physics_frame
|
||
_stage.set_mode(SandboxStage.StageMode.EDIT)
|
||
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 erase_wall_buttons: Array[Node] = capture_bar.find_children("Action_erase_wall_area", "Button", true, false)
|
||
_expect(erase_wall_buttons.size() == 1, "repeated selection refresh keeps one wall-area erase action control")
|
||
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 (bar=%s inspector=%s viewport=%s)" % [capture_rect, repair_rect, get_root().size])
|
||
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(12000.0, 8000.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 (bar=%s inspector=%s viewport=%s)" % [capture_bar.get_global_rect(), scenery_inspector.get_global_rect(), get_root().size])
|
||
_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
|