1249 lines
76 KiB
GDScript
1249 lines
76 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/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
|
|
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
|
|
_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() == 3, "New Scene gallery lists Blank, 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 = starter_grid.get_child(1) as AssetCard if starter_grid != null and starter_grid.get_child_count() > 1 else null
|
|
_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 terrain_finish_count: int = 0
|
|
var scaled_terrain_count: int = 0
|
|
for child: Node in world.get_children():
|
|
if child is TerrainBlock and String((child as TerrainBlock).finish_id) in ["finish_ground_sidewalk", "finish_ground_asphalt"]:
|
|
terrain_finish_count += 1
|
|
if is_equal_approx((child as TerrainBlock).scale.x, 7.0):
|
|
scaled_terrain_count += 1
|
|
_expect(terrain_finish_count == 2 and scaled_terrain_count == 2, "Town Street uses full-width sidewalk and asphalt strips")
|
|
await physics_frame
|
|
await physics_frame
|
|
var navigation_region: NavigationRegion2D = _stage.get_node("NavigationRegion2D") as NavigationRegion2D
|
|
var navigation_map: RID = navigation_region.get_navigation_map()
|
|
NavigationServer2D.map_force_update(navigation_map)
|
|
var street_terrain_summary: Array[String] = []
|
|
for terrain_node: Node in world.get_children():
|
|
if terrain_node is TerrainBlock:
|
|
var street_terrain: TerrainBlock = terrain_node as TerrainBlock
|
|
var transformed_points: Array[Vector2] = []
|
|
for point: Vector2 in street_terrain.polygon_points:
|
|
transformed_points.append(street_terrain.transform * point)
|
|
var visible_top_y: float = INF
|
|
for point: Vector2 in transformed_points:
|
|
visible_top_y = minf(visible_top_y, point.y)
|
|
var visible_top_edges: Array[String] = []
|
|
var navigation_up_edges: Array[String] = []
|
|
for point_index: int in street_terrain.polygon_points.size():
|
|
var a: Vector2 = transformed_points[point_index]
|
|
var b: Vector2 = transformed_points[(point_index + 1) % transformed_points.size()]
|
|
var edge: Vector2 = b - a
|
|
var outward: Vector2 = Vector2(edge.y, -edge.x).normalized()
|
|
if is_equal_approx(a.y, visible_top_y) and is_equal_approx(b.y, visible_top_y):
|
|
visible_top_edges.append("%s -> %s" % [str(a), str(b)])
|
|
if outward.y < -0.15:
|
|
navigation_up_edges.append("%s -> %s" % [str(a), str(b)])
|
|
street_terrain_summary.append("%s scale=%s transform=%s visible_top=%s nav_up=%s" % [String(street_terrain.finish_id), str(street_terrain.scale), str(street_terrain.transform), str(visible_top_edges), str(navigation_up_edges)])
|
|
var sidewalk_start: Vector2 = Vector2(-600.0, -16.0)
|
|
var sidewalk_goal: Vector2 = Vector2(600.0, -16.0)
|
|
var road_start: Vector2 = Vector2(-600.0, 16.0)
|
|
var road_goal: Vector2 = Vector2(600.0, 16.0)
|
|
var sidewalk_path: PackedVector2Array = NavigationServer2D.map_get_path(
|
|
navigation_map,
|
|
sidewalk_start,
|
|
sidewalk_goal,
|
|
true
|
|
)
|
|
var road_path: PackedVector2Array = NavigationServer2D.map_get_path(
|
|
navigation_map,
|
|
road_start,
|
|
road_goal,
|
|
true
|
|
)
|
|
var sidewalk_to_road_path: PackedVector2Array = NavigationServer2D.map_get_path(
|
|
navigation_map,
|
|
Vector2(600.0, -16.0),
|
|
Vector2(600.0, 16.0),
|
|
true
|
|
)
|
|
print(
|
|
"[TownStreetNavigationCheck] nav_dirty=%s polygons=%d agent_radius=%.1f vertices=%s iteration=%d terrain=%s sidewalk=(%s -> %s; closest %s -> %s; path=%s) road=(%s -> %s; closest %s -> %s; path=%s) curb_path=%s"
|
|
% [
|
|
str(bool(_stage.get("_nav_dirty"))),
|
|
navigation_region.navigation_polygon.get_polygon_count(),
|
|
navigation_region.navigation_polygon.agent_radius,
|
|
str(navigation_region.navigation_polygon.get_vertices()),
|
|
NavigationServer2D.map_get_iteration_id(navigation_map),
|
|
str(street_terrain_summary),
|
|
str(sidewalk_start),
|
|
str(sidewalk_goal),
|
|
str(NavigationServer2D.map_get_closest_point(navigation_map, sidewalk_start)),
|
|
str(NavigationServer2D.map_get_closest_point(navigation_map, sidewalk_goal)),
|
|
str(sidewalk_path),
|
|
str(road_start),
|
|
str(road_goal),
|
|
str(NavigationServer2D.map_get_closest_point(navigation_map, road_start)),
|
|
str(NavigationServer2D.map_get_closest_point(navigation_map, road_goal)),
|
|
str(road_path),
|
|
str(sidewalk_to_road_path),
|
|
]
|
|
)
|
|
_expect(
|
|
NavigationServer2D.map_get_iteration_id(navigation_map) > 0 and navigation_region.navigation_polygon.get_polygon_count() > 0,
|
|
"Town Street navigation region synchronizes its starter terrain polygons"
|
|
)
|
|
_expect(not bool(_stage.get("_nav_dirty")), "Town Street deferred navigation rebake completed after starter application")
|
|
|
|
# Drive the Stage's existing palette/gallery callbacks and its real
|
|
# _unhandled_input(InputEventMouseButton) production entry point.
|
|
_stage.call("_on_palette_card_activated", {"placement_id": "scenery", "opens_selector": true})
|
|
selector = browser.get_selector()
|
|
_expect(selector.visible and selector.kind == "scenery", "Scenery palette opens the shared gallery")
|
|
selector.call("_set_gallery_tab", "backgrounds")
|
|
await process_frame
|
|
var background_grid: GridContainer = selector.find_child("GridContainer", true, false) as GridContainer
|
|
var background_preview_count: int = 0
|
|
var cloudy_card: AssetCard = _find_card_by_id(background_grid, "sky_cloudy_day")
|
|
if background_grid != null:
|
|
for card_node: Node in background_grid.get_children():
|
|
for card_content: Node in card_node.get_children():
|
|
for preview_node: Node in card_content.get_children():
|
|
if preview_node.get_script() == BACKGROUND_CARD_PREVIEW:
|
|
background_preview_count += 1
|
|
_expect(background_grid != null and background_grid.get_child_count() == 7 and background_preview_count == 7, "Sky and wall-finish cards render a distinct production-style preview")
|
|
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 Sky", "gallery marks the loaded Town Street Sky as Current Sky")
|
|
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 Sky", "Current Sky 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()
|
|
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_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 capture_sky_views: bool = OS.get_cmdline_user_args().has("--capture-sky-v2")
|
|
for sky_id: StringName in StageSkyView.SKY_IDS:
|
|
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 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 _expect(condition: bool, description: String) -> void:
|
|
if condition:
|
|
print("PASS: " + description)
|
|
else:
|
|
_failures.append(description)
|
|
|
|
|
|
func _test_scene_color_compatibility() -> void:
|
|
var wall_document: Dictionary = {
|
|
"version": STAGE_SCENE_IO.FILE_VERSION,
|
|
"sky_id": "sky_clear_day",
|
|
"objects": [{
|
|
"file_id": "wall_panel_1",
|
|
"type": "wall_panel",
|
|
"position": {"x": 0.0, "y": 0.0},
|
|
"rotation": 0.0,
|
|
"size": {"x": 320.0, "y": 400.0},
|
|
"finish_id": "finish_wall_paint",
|
|
"color": "5a9d70ff",
|
|
}],
|
|
"event_rules": [],
|
|
}
|
|
var validation: Dictionary = STAGE_SCENE_IO._validate_root(wall_document)
|
|
_expect(bool(validation.get("ok", false)), "wall tint written without a # prefix is accepted")
|
|
var decoded: Color = STAGE_SCENE_IO.color_from_data("5a9d70ff", Color.MAGENTA)
|
|
_expect(decoded.is_equal_approx(Color("#5a9d70ff")), "wall tint without a # prefix decodes to its original color")
|
|
_expect(not STAGE_SCENE_IO._valid_color("5a9d70gg"), "malformed wall tint remains rejected")
|
|
|
|
|
|
func _test_grid_controls_layout() -> void:
|
|
var chrome: CanvasLayer = _stage.get("_chrome") as CanvasLayer
|
|
if chrome == null:
|
|
_expect(false, "Stage Chrome exists for grid controls")
|
|
return
|
|
var snap_check: CheckBox = chrome.call("get_snap_check") as CheckBox
|
|
var grid_check: CheckBox = chrome.call("get_grid_check") as CheckBox
|
|
var size_spin: SpinBox = chrome.call("get_grid_size_spin") as SpinBox
|
|
for control: Control in [grid_check, snap_check, size_spin]:
|
|
if control == null:
|
|
_expect(false, "Grid and Snap controls are present in the Stage toolbar")
|
|
continue
|
|
var control_rect: Rect2 = control.get_global_rect()
|
|
_expect(control.is_visible_in_tree(), "%s is visible in Edit mode" % control.name)
|
|
_expect(control_rect.size.x > 0.0 and control_rect.size.y > 0.0, "%s has a non-zero toolbar layout" % control.name)
|
|
_expect(control_rect.position.x >= 0.0 and control_rect.end.x <= root.size.x, "%s fits within the Stage window width" % control.name)
|
|
_expect(control_rect.position.y >= 0.0 and control_rect.end.y <= 64.0, "%s fits within the Stage top bar" % control.name)
|
|
var file_menu: MenuButton = chrome.call("get_file_menu") as MenuButton
|
|
if file_menu != null:
|
|
await _send_stage_click(file_menu.get_global_rect().get_center(), MOUSE_BUTTON_LEFT)
|
|
_expect(file_menu.get_popup().visible, "File menu opens when clicked beside the right-aligned Grid and Snap controls")
|
|
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 == 3, "starter chooser has three 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("SkyView", true, false) != null, "starter preview has an isolated viewport and sky renderer")
|
|
_expect(preview_count == 3, "all three starter cards use scene previews instead of placeholder tiles")
|
|
_expect(populated_scene_count == 2, "Town Street and Living Room previews load their scene objects")
|
|
|
|
|
|
func _contains_collision_object(node: Node) -> bool:
|
|
if node is CollisionObject2D:
|
|
return true
|
|
for child: Node in node.get_children():
|
|
if _contains_collision_object(child):
|
|
return true
|
|
return false
|
|
|
|
|
|
func _count_artwork(world: Node2D) -> int:
|
|
var count: int = 0
|
|
for child: Node in world.get_children():
|
|
if child.get_script() == STAGE_SCENERY_ARTWORK:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
func _test_scenery_placement_snap(world: Node2D, controller: StageSceneryController, artwork_path: String) -> void:
|
|
var previous_snap: bool = bool(_stage.get("_snap_enabled"))
|
|
var previous_grid_size: float = float(_stage.get("_grid_size"))
|
|
_stage.call("_on_grid_size_changed", 20.0)
|
|
_stage.call("_on_snap_toggled", true)
|
|
var raw_art_position: Vector2 = Vector2(47.0, 58.0)
|
|
var click := InputEventMouseButton.new()
|
|
click.button_index = MOUSE_BUTTON_LEFT
|
|
click.pressed = true
|
|
_expect(controller.begin_artwork(artwork_path), "snap test starts an artwork placement tool")
|
|
controller.update_pointer(raw_art_position)
|
|
var artwork_ghost: StageSceneryArtwork = controller.get("_ghost") as StageSceneryArtwork
|
|
var ghost_bounds: Rect2 = artwork_ghost.get_visual_world_bounds() if artwork_ghost != null else Rect2()
|
|
_expect(artwork_ghost != null and ghost_bounds.get_center().is_equal_approx(Vector2(40.0, 60.0)), "artwork preview centers its visible bounds under the snapped cursor")
|
|
_expect(artwork_ghost != null and _is_grid_aligned(ghost_bounds.get_center(), 20.0), "artwork preview center aligns to a grid intersection when snapping is enabled")
|
|
controller.handle_mouse_button(click, raw_art_position, false)
|
|
var snapped_artwork: Node2D = _last_node_with_script(world, STAGE_SCENERY_ARTWORK)
|
|
var snapped_art_bounds: Rect2 = (snapped_artwork as StageSceneryArtwork).get_visual_world_bounds() if snapped_artwork != null else Rect2()
|
|
_expect(snapped_artwork != null and snapped_art_bounds.get_center().is_equal_approx(Vector2(40.0, 60.0)), "placed artwork center matches the preview and snapped cursor")
|
|
_expect(controller.get("_ghost") == artwork_ghost and controller.get_active_tool() == &"artwork", "placing artwork keeps its cursor ghost ready for another copy")
|
|
if snapped_artwork != null:
|
|
snapped_artwork.free()
|
|
|
|
_stage.call("_on_snap_toggled", false)
|
|
_expect(controller.begin_artwork(artwork_path), "unsnapped test restarts artwork placement")
|
|
controller.handle_mouse_button(click, raw_art_position, false)
|
|
var unsnapped_artwork: Node2D = _last_node_with_script(world, STAGE_SCENERY_ARTWORK)
|
|
var unsnapped_art_bounds: Rect2 = (unsnapped_artwork as StageSceneryArtwork).get_visual_world_bounds() if unsnapped_artwork != null else Rect2()
|
|
_expect(unsnapped_artwork != null and unsnapped_art_bounds.get_center().is_equal_approx(raw_art_position), "artwork placement centers under the exact cursor when grid snapping is off")
|
|
if unsnapped_artwork != null:
|
|
unsnapped_artwork.free()
|
|
|
|
_stage.call("_on_snap_toggled", true)
|
|
controller.begin_wall(&"finish_wall_brick", raw_art_position)
|
|
var wall_ghost: StageWallPanel = controller.get("_ghost") as StageWallPanel
|
|
var wall_ghost_bounds: Rect2 = wall_ghost.global_transform * wall_ghost.get_local_bounds() if wall_ghost != null else Rect2()
|
|
print("[WallPanelGhostStart] raw=%s global_position=%s bounds=%s" % [str(raw_art_position), str(wall_ghost.global_position if wall_ghost != null else Vector2.INF), str(wall_ghost_bounds)])
|
|
_expect(wall_ghost != null and wall_ghost_bounds.position.is_equal_approx(Vector2(40.0, 60.0)), "wall-panel preview immediately uses the current cursor as its visible top-left")
|
|
_expect(_is_grid_aligned(wall_ghost_bounds.position, 20.0), "wall-panel preview aligns its visible top-left to a grid intersection")
|
|
var wall_start := InputEventMouseButton.new()
|
|
wall_start.button_index = MOUSE_BUTTON_LEFT
|
|
wall_start.pressed = true
|
|
var expected_wall_start: Vector2 = wall_ghost_bounds.position
|
|
controller.handle_mouse_button(wall_start, raw_art_position, false)
|
|
var actual_wall_start: Vector2 = controller.get("_wall_drag_start")
|
|
_expect(actual_wall_start.is_equal_approx(expected_wall_start), "wall-panel drawing starts at the preview ghost corner")
|
|
wall_ghost_bounds = wall_ghost.global_transform * wall_ghost.get_local_bounds()
|
|
_expect(_is_grid_aligned(wall_ghost_bounds.position, 20.0), "wall-panel preview stays top-left aligned when a drag begins")
|
|
var wall_end := InputEventMouseButton.new()
|
|
wall_end.button_index = MOUSE_BUTTON_LEFT
|
|
wall_end.pressed = false
|
|
controller.handle_mouse_button(wall_end, Vector2(113.0, 138.0), false)
|
|
var snapped_wall: Node2D = _last_node_with_script(world, STAGE_WALL_PANEL)
|
|
var snapped_wall_bounds: Rect2 = (snapped_wall.global_transform * (snapped_wall as StageWallPanel).get_local_bounds()) if snapped_wall != null else Rect2()
|
|
_expect(snapped_wall != null and snapped_wall_bounds.position.is_equal_approx(Vector2(40.0, 60.0)), "wall-panel placement aligns its top-left after snapping both drag endpoints")
|
|
_expect(wall_ghost != null and wall_ghost.panel_size.is_equal_approx(Vector2(240.0, 280.0)), "wall-panel placement restores the original preview size after drawing")
|
|
var restored_ghost_bounds: Rect2 = wall_ghost.global_transform * wall_ghost.get_local_bounds() if wall_ghost != null else Rect2()
|
|
_expect(restored_ghost_bounds.position.is_equal_approx(Vector2(120.0, 140.0)), "wall-panel preview returns to the cursor at its original size after drawing")
|
|
if snapped_wall != null:
|
|
_expect(Vector2(snapped_wall.get("panel_size")).is_equal_approx(Vector2(80.0, 80.0)), "wall-panel dimensions follow the snapped drag endpoints")
|
|
snapped_wall.free()
|
|
controller.exit_tool()
|
|
_stage.call("_on_snap_toggled", false)
|
|
controller.begin_wall(&"finish_wall_brick", raw_art_position)
|
|
wall_ghost = controller.get("_ghost") as StageWallPanel
|
|
wall_ghost_bounds = wall_ghost.global_transform * wall_ghost.get_local_bounds() if wall_ghost != null else Rect2()
|
|
_expect(wall_ghost != null and wall_ghost_bounds.position.is_equal_approx(raw_art_position), "unsnapped wall preview uses the raw cursor as its visible top-left")
|
|
controller.handle_mouse_button(wall_start, raw_art_position, false)
|
|
actual_wall_start = controller.get("_wall_drag_start")
|
|
_expect(actual_wall_start.is_equal_approx(raw_art_position), "unsnapped wall drawing starts at the preview ghost corner")
|
|
controller.cancel_active_gesture()
|
|
controller.exit_tool()
|
|
_stage.call("_on_grid_size_changed", previous_grid_size)
|
|
_stage.call("_on_snap_toggled", previous_snap)
|
|
|
|
|
|
func _test_wall_eraser(world: Node2D, controller: StageSceneryController) -> void:
|
|
var wall := STAGE_WALL_PANEL.new() as StageWallPanel
|
|
wall.position = Vector2(20000.0, 20000.0)
|
|
wall.configure(Vector2(400.0, 300.0), &"finish_wall_brick", Color("#B95F3D"))
|
|
world.add_child(wall)
|
|
var start := Vector2(19900.0, 19900.0)
|
|
var finish := Vector2(20100.0, 20100.0)
|
|
var snapped_start: Vector2 = controller.call("_snap", start)
|
|
var snapped_finish: Vector2 = controller.call("_snap", finish)
|
|
var expected_erase_rect: Rect2 = Rect2(snapped_start, snapped_finish - snapped_start).abs()
|
|
var local_cut_size: Vector2 = (wall.to_local(snapped_finish) - wall.to_local(snapped_start)).abs()
|
|
var expected_remaining_area: float = wall.panel_size.x * wall.panel_size.y - local_cut_size.x * local_cut_size.y
|
|
var document_changes: Array[int] = [0]
|
|
controller.document_change_requested.connect(func() -> void: document_changes[0] += 1)
|
|
_stage.call("_enter_scenery_edit_mode")
|
|
var selection: StageSelection = _stage.get("_selection") as StageSelection
|
|
selection.select_only(wall)
|
|
var erase_button: Button = _stage.get("_scenery_erase_wall_area_button") as Button
|
|
_expect(erase_button != null and erase_button.visible and erase_button.text == "Erase wall area" and erase_button.custom_minimum_size.y >= 44.0, "selected wall inspector exposes the touch-sized Erase wall area tool")
|
|
if erase_button != null:
|
|
erase_button.pressed.emit()
|
|
_expect(controller.get_active_tool() == &"wall_eraser", "Erase wall area starts the dedicated eraser from Edit Scenery")
|
|
var preview: Node2D = controller.get("_ghost") as Node2D
|
|
var capture_bar: StageUseTargetCaptureBar = _stage.get("_use_target_capture_bar") as StageUseTargetCaptureBar
|
|
_expect(controller.get_active_tool() == &"wall_eraser" and preview != null and preview.name == "WallEraserPreview", "wall eraser shows its distinct preview instead of a wall panel")
|
|
var prompt: Label = capture_bar.find_child("Prompt", true, false) as Label
|
|
var done_button: Button = capture_bar.find_child("Action_done_tool", true, false) as Button
|
|
var cancel_button: Button = capture_bar.find_child("Action_cancel_tool", true, false) as Button
|
|
_expect(capture_bar.visible and prompt != null and prompt.text == "Drag over the wall · release to erase" and done_button != null and cancel_button != null, "wall eraser capture bar explains release and keeps Done and Cancel visible")
|
|
var press := InputEventMouseButton.new()
|
|
press.button_index = MOUSE_BUTTON_LEFT
|
|
press.pressed = true
|
|
controller.handle_mouse_button(press, start, false)
|
|
controller.handle_mouse_motion(finish)
|
|
var release := InputEventMouseButton.new()
|
|
release.button_index = MOUSE_BUTTON_LEFT
|
|
release.pressed = false
|
|
controller.handle_mouse_button(release, finish, false)
|
|
var fragments: Array[StageWallPanel] = _wall_fragments_with_origin(world, Vector2(20000.0, 20000.0))
|
|
_expect(fragments.size() == 4, "dragging the eraser through a wall makes one rectangular opening")
|
|
var remaining_area: float = 0.0
|
|
var no_fragment_in_cut: bool = true
|
|
for fragment: StageWallPanel in fragments:
|
|
remaining_area += fragment.panel_size.x * fragment.panel_size.y
|
|
var fragment_bounds: Rect2 = fragment.global_transform * fragment.get_local_bounds()
|
|
var local_top_left: Vector2 = fragment.get_local_bounds().position
|
|
var rendered_top_left: Vector2 = local_top_left + fragment.get_surface_draw_origin_world()
|
|
_expect(rendered_top_left.is_equal_approx(fragment.to_global(local_top_left)), "brick pattern on an erased fragment keeps its world-space mortar phase")
|
|
if fragment_bounds.intersects(expected_erase_rect):
|
|
no_fragment_in_cut = false
|
|
_expect(fragment.finish_id == &"finish_wall_brick" and fragment.tint.is_equal_approx(Color("#B95F3D")), "wall fragments preserve their finish and color")
|
|
_expect(is_equal_approx(remaining_area, expected_remaining_area), "wall fragments preserve the area outside the opening")
|
|
_expect(no_fragment_in_cut, "no wall panel remains behind the window-sized opening")
|
|
_expect(document_changes[0] == 1, "one released wall opening emits one Stage document change")
|
|
controller.begin_wall_eraser(Vector2(19850.0, 19950.0))
|
|
var cancel_press := InputEventMouseButton.new()
|
|
cancel_press.button_index = MOUSE_BUTTON_LEFT
|
|
cancel_press.pressed = true
|
|
controller.handle_mouse_button(cancel_press, Vector2(19850.0, 19950.0), false)
|
|
controller.handle_mouse_motion(Vector2(19950.0, 20050.0))
|
|
controller.cancel_active_gesture()
|
|
_expect(_count_walls_with_origin(world, Vector2(20000.0, 20000.0)) == 4 and document_changes[0] == 1, "Cancel discards the unfinished eraser rectangle without changing earlier openings")
|
|
controller.exit_tool()
|
|
controller.begin_wall_eraser(Vector2(19850.0, 19950.0))
|
|
controller.handle_mouse_button(cancel_press, Vector2(19850.0, 19950.0), false)
|
|
controller.handle_mouse_motion(Vector2(19950.0, 20050.0))
|
|
var right_click := InputEventMouseButton.new()
|
|
right_click.button_index = MOUSE_BUTTON_RIGHT
|
|
right_click.pressed = true
|
|
controller.handle_mouse_button(right_click, Vector2(19950.0, 20050.0), false)
|
|
_expect(not controller.is_active() and _count_walls_with_origin(world, Vector2(20000.0, 20000.0)) == 4 and document_changes[0] == 1, "right-click cancels the eraser draft and exits without undoing earlier openings")
|
|
var serialized: Dictionary = (STAGE_SCENE_ADAPTER.new() as StageSceneAdapter).collect(world, {}, [], {"position": Vector2.ZERO, "zoom": 1.0}, {})
|
|
var serialized_anchors: int = 0
|
|
for entry_value: Variant in serialized.get("objects", []):
|
|
if entry_value is Dictionary and String((entry_value as Dictionary).get("type", "")) == "wall_panel" and (entry_value as Dictionary).has("motif_origin_offset"):
|
|
serialized_anchors += 1
|
|
_expect(serialized_anchors == 4, "the current scene format records shared motif anchors for erased-wall fragments")
|
|
selection.clear_selection()
|
|
_stage.call("_refresh_scenery_capture_bar")
|
|
capture_bar = _stage.get("_use_target_capture_bar") as StageUseTargetCaptureBar
|
|
var unselected_erase_button: Button = capture_bar.find_child("Action_erase_wall_area", true, false) as Button
|
|
_expect(unselected_erase_button != null and unselected_erase_button.visible, "Edit Scenery exposes Erase wall area in the capture bar when nothing is selected")
|
|
if unselected_erase_button != null:
|
|
unselected_erase_button.pressed.emit()
|
|
_expect(controller.get_active_tool() == &"wall_eraser", "the always-visible Edit Scenery eraser action starts without requiring a wall selection")
|
|
controller.exit_tool()
|
|
_stage.call("_exit_scenery_edit_mode")
|
|
|
|
|
|
func _test_wall_eraser_full_cut(world: Node2D, controller: StageSceneryController) -> void:
|
|
if controller == null:
|
|
_expect(false, "wall eraser is available after scene reopen")
|
|
return
|
|
var start := Vector2(19790.0, 19840.0)
|
|
var finish := Vector2(20210.0, 20160.0)
|
|
controller.begin_wall_eraser(start)
|
|
var press := InputEventMouseButton.new()
|
|
press.button_index = MOUSE_BUTTON_LEFT
|
|
press.pressed = true
|
|
controller.handle_mouse_button(press, start, false)
|
|
var release := InputEventMouseButton.new()
|
|
release.button_index = MOUSE_BUTTON_LEFT
|
|
release.pressed = false
|
|
controller.handle_mouse_button(release, finish, false)
|
|
_expect(_count_walls_with_origin(world, Vector2(20000.0, 20000.0)) == 0, "erasing across the full wall removes all remaining fragments")
|
|
controller.exit_tool()
|
|
_stage.call("_exit_scenery_edit_mode")
|
|
|
|
|
|
func _wall_fragments_with_origin(world: Node2D, origin: Vector2) -> Array[StageWallPanel]:
|
|
var fragments: Array[StageWallPanel] = []
|
|
for child: Node in world.get_children():
|
|
if child.get_script() != STAGE_WALL_PANEL:
|
|
continue
|
|
var wall: StageWallPanel = child as StageWallPanel
|
|
if wall.get_effective_motif_origin_world().is_equal_approx(origin):
|
|
fragments.append(wall)
|
|
return fragments
|
|
|
|
|
|
func _count_walls_with_origin(world: Node2D, origin: Vector2) -> int:
|
|
return _wall_fragments_with_origin(world, origin).size()
|
|
|
|
|
|
func _last_node_with_script(parent: Node, script: Script) -> Node2D:
|
|
for index: int in range(parent.get_child_count() - 1, -1, -1):
|
|
var candidate: Node = parent.get_child(index)
|
|
if candidate.get_script() == script:
|
|
return candidate as Node2D
|
|
return null
|
|
|
|
|
|
func _is_grid_aligned(position: Vector2, grid_size: float) -> bool:
|
|
var snapped: Vector2 = Vector2(roundf(position.x / grid_size) * grid_size, roundf(position.y / grid_size) * grid_size)
|
|
return position.is_equal_approx(snapped)
|
|
|
|
|
|
func _test_group_preview_save_gate(world: Node2D) -> void:
|
|
var members: Array[Node2D] = []
|
|
for child: Node in world.get_children():
|
|
if child.get_script() == STAGE_SCENERY_ARTWORK:
|
|
members.append(child as Node2D)
|
|
if members.size() == 2:
|
|
break
|
|
_expect(members.size() == 2, "group-save fixture has two independent Scenery pieces")
|
|
if members.size() < 2:
|
|
return
|
|
var stage_selection: StageSelection = _stage.get("_selection") as StageSelection
|
|
var controller: StageSceneryController = _stage.get("_scenery_controller") as StageSceneryController
|
|
var pending_restore_frames: int = int(_stage.get("_restore_frames_left"))
|
|
_expect(pending_restore_frames > 0, "post-PLAY authored restore is pending before the group edit")
|
|
_stage.call("_enter_scenery_edit_mode")
|
|
stage_selection.select_only(members[0])
|
|
stage_selection.add_to_selection(members[1])
|
|
_stage.call("_begin_scenery_group_transform", &"move")
|
|
_expect(controller.is_group_preview_pending(), "multi-selection starts a live group preview")
|
|
_expect(int(_stage.get("_restore_frames_left")) == 0, "starting the group edit protects it from deferred post-PLAY restoration")
|
|
var preview_delta: Vector2 = Vector2(36.0, 21.0)
|
|
_expect(controller.preview_group(preview_delta, 1.0), "group preview accepts a valid move")
|
|
var expected_positions: Array[Vector2] = [members[0].global_position, members[1].global_position]
|
|
var path: String = "res://tests/phase19_pending_group_tmp.scene"
|
|
var auto_path: String = "res://tests/phase19_pending_group_autosave_tmp.scene"
|
|
var manual_saved: bool = _stage.call("_save_scene_to_path", path, false)
|
|
_expect(not manual_saved, "manual save is blocked while a group preview is pending")
|
|
var save_gate: ConfirmationDialog = _stage.get("_group_preview_save_dialog") as ConfirmationDialog
|
|
_expect(save_gate.visible, "manual save asks whether to keep or cancel the scenery preview")
|
|
_expect(not FileAccess.file_exists(path), "pending preview is not written before the user chooses")
|
|
var autosave_result: bool = _stage.call("_save_scene_to_path", auto_path, true)
|
|
_expect(not autosave_result and not FileAccess.file_exists(auto_path), "autosave also skips uncommitted preview values")
|
|
if save_gate.visible:
|
|
await _activate_button(save_gate.get_cancel_button())
|
|
_expect(controller.is_group_preview_pending() and not FileAccess.file_exists(path), "Cancel save leaves the preview pending and writes no scene")
|
|
_expect(members[0].global_position.is_equal_approx(expected_positions[0]) and members[1].global_position.is_equal_approx(expected_positions[1]), "Cancel save keeps the visible group preview unchanged")
|
|
manual_saved = _stage.call("_save_scene_to_path", path, false)
|
|
_expect(not manual_saved and save_gate.visible, "a later save request asks again after Cancel")
|
|
if save_gate.visible:
|
|
await _activate_button(save_gate.get_ok_button())
|
|
await process_frame
|
|
_expect(not controller.is_group_preview_pending(), "Keep changes commits the group before saving")
|
|
_expect(FileAccess.file_exists(path), "confirmed group preview is saved")
|
|
if FileAccess.file_exists(path):
|
|
var saved_read: Dictionary = STAGE_SCENE_IO.read_file(path)
|
|
var saved_data: Dictionary = saved_read.get("data", {}) as Dictionary
|
|
var saved_objects: Array = saved_data.get("objects", []) as Array
|
|
var serialized_positions: Array[Vector2] = []
|
|
for raw_object: Variant in saved_objects:
|
|
if not raw_object is Dictionary:
|
|
continue
|
|
var object_data: Dictionary = raw_object as Dictionary
|
|
if String(object_data.get("type", "")) == "scenery_artwork":
|
|
serialized_positions.append(STAGE_SCENE_IO.data_to_vector(object_data.get("position"), Vector2.ZERO))
|
|
_expect(_contains_positions(expected_positions, serialized_positions), "saved document contains both committed group preview positions")
|
|
var loaded: bool = _stage.call("_load_scene_from_path", path, false)
|
|
_expect(loaded, "group-transformed scene reopens")
|
|
var reopened_positions: Array[Vector2] = []
|
|
for child: Node in world.get_children():
|
|
if child.get_script() == STAGE_SCENERY_ARTWORK:
|
|
reopened_positions.append((child as Node2D).global_position)
|
|
var positions_restored: bool = _contains_positions(expected_positions, reopened_positions)
|
|
_expect(positions_restored, "saved group member transforms survive scene reopen")
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
|
if FileAccess.file_exists(auto_path):
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(auto_path))
|
|
_stage.call("_exit_scenery_edit_mode")
|
|
|
|
|
|
func _test_missing_artwork_repair(world: Node2D, browser: StageAssetBrowser) -> void:
|
|
var path: String = "res://tests/phase19_missing_artwork_tmp.scene"
|
|
var missing_path: String = "res://content/props/phase19_picture_not_here.prop"
|
|
var marker := STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
|
|
marker.name = "MissingTree"
|
|
world.add_child(marker)
|
|
marker.global_position = Vector2(142.0, 213.0)
|
|
marker.rotation = 0.24
|
|
marker.scale = Vector2(1.25, 0.82)
|
|
marker.configure_missing(missing_path, "My missing tree", Rect2(Vector2(-44.0, -51.0), Vector2(88.0, 102.0)), &"far")
|
|
_stage.call("_mark_scene_dirty")
|
|
var saved_missing: bool = _stage.call("_save_scene_to_path", path, false)
|
|
_expect(saved_missing, "scene with a missing artwork reference can be saved")
|
|
var loaded_missing: bool = _stage.call("_load_scene_from_path", path, false)
|
|
_expect(loaded_missing, "scene with a missing artwork reference reopens")
|
|
var summary: ConfirmationDialog = _stage.get("_missing_scenery_dialog") as ConfirmationDialog
|
|
_expect(summary.visible and summary.title == "Artwork not found", "missing-art load summary offers kid-readable recovery")
|
|
if summary.visible:
|
|
_expect(summary.ok_button_text == "Show Scenery" and summary.cancel_button_text == "Later", "missing-art summary exposes Show Scenery and Later")
|
|
await _activate_button(summary.get_cancel_button())
|
|
var missing_target: StageSceneryArtwork = _find_missing_artwork(world)
|
|
_expect(missing_target != null, "missing art remains as a selectable placement marker")
|
|
if missing_target == null:
|
|
return
|
|
var saved_position: Vector2 = missing_target.global_position
|
|
var saved_rotation: float = missing_target.global_rotation
|
|
var saved_scale: Vector2 = missing_target.scale
|
|
var saved_depth: StringName = missing_target.depth
|
|
var dirty_before_repair: bool = bool(_stage.get("_dirty"))
|
|
_stage.call("_enter_scenery_edit_mode")
|
|
(_stage.get("_selection") as StageSelection).select_only(missing_target)
|
|
var repair_card: StageInspectorPanel = _stage.get("_scenery_inspector") as StageInspectorPanel
|
|
_expect(repair_card.visible, "selecting missing art in Edit Scenery opens the shared repair card")
|
|
await process_frame
|
|
await process_frame
|
|
var capture_bar: StageUseTargetCaptureBar = _stage.get("_use_target_capture_bar") as StageUseTargetCaptureBar
|
|
for _refresh_index: int in 3:
|
|
_stage.call("_refresh_scenery_capture_bar")
|
|
await process_frame
|
|
var action_buttons: Array[Node] = capture_bar.find_children("Action_*", "Button", true, false)
|
|
_expect(action_buttons.size() == 4, "repeated selection refresh keeps one capture button per action")
|
|
var capture_rect: Rect2 = capture_bar.get_global_rect()
|
|
var repair_rect: Rect2 = repair_card.get_global_rect()
|
|
_expect(capture_bar.visible and capture_rect.end.x <= repair_rect.position.x, "1280x720 scenery capture bar reserves a clear lane beside the repair inspector (bar=%s inspector=%s viewport=%s)" % [capture_rect, repair_rect, get_root().size])
|
|
root.size = Vector2i(800, 600)
|
|
_stage.call("_refresh_scenery_inspector_layout")
|
|
await process_frame
|
|
await process_frame
|
|
capture_rect = capture_bar.get_global_rect()
|
|
repair_rect = repair_card.get_global_rect()
|
|
_expect(capture_bar.visible and repair_card.visible and capture_rect.end.x <= repair_rect.position.x, "narrow viewport keeps the stacked capture actions separate from the repair inspector")
|
|
root.size = Vector2i(1280, 720)
|
|
_stage.call("_refresh_scenery_inspector_layout")
|
|
await process_frame
|
|
await process_frame
|
|
var find_button: Button = _stage.get("_scenery_find_button") as Button
|
|
await _activate_button(find_button)
|
|
var browse_dialog: FileDialog = browser.get_browse_dialog()
|
|
_expect(browse_dialog.visible and browser.selector_kind == "scenery" and browse_dialog.filters.has("*.prop ; Prop Files"), "Find artwork opens the established .prop chooser")
|
|
var choose_button: Button = _stage.get("_scenery_choose_button") as Button
|
|
var entries: Array[Dictionary] = browser.scan_entries("scenery")
|
|
_expect(not entries.is_empty(), "repair chooser has valid artwork choices")
|
|
if entries.is_empty():
|
|
return
|
|
if browse_dialog.visible:
|
|
browse_dialog.file_selected.emit(String(entries[0].get("path", "")))
|
|
if browse_dialog.visible:
|
|
browse_dialog.hide()
|
|
await process_frame
|
|
await process_frame
|
|
var confirm_dialog: ConfirmationDialog = _stage.get("_scenery_repair_confirm_dialog") as ConfirmationDialog
|
|
_expect(confirm_dialog.visible and confirm_dialog.title == "Use this artwork here?", "candidate previews at the saved spot and requires explicit confirmation")
|
|
var candidate: StageSceneryArtwork = _stage.get("_scenery_repair_candidate") as StageSceneryArtwork
|
|
_expect(candidate != null and candidate.global_position.is_equal_approx(saved_position) and candidate.global_rotation == saved_rotation and candidate.scale.is_equal_approx(saved_scale), "repair candidate ghost matches the saved transform")
|
|
_expect(missing_target.missing and missing_target.asset_path == missing_path and bool(_stage.get("_dirty")) == dirty_before_repair, "preview has not changed the marker or dirty state")
|
|
if confirm_dialog.visible:
|
|
await _activate_button(confirm_dialog.get_cancel_button())
|
|
_expect(missing_target.missing and missing_target.asset_path == missing_path and bool(_stage.get("_dirty")) == dirty_before_repair, "Keep for now cancels repair without altering the scene")
|
|
await _activate_button(choose_button)
|
|
var selector: AssetSelector = browser.get_selector()
|
|
_expect(selector.visible and selector.kind == "scenery", "Choose another artwork opens the shared Artwork chooser")
|
|
var gallery_grid: GridContainer = selector.find_child("GridContainer", true, false) as GridContainer
|
|
var first_card: AssetCard = gallery_grid.get_child(0) as AssetCard if gallery_grid != null and gallery_grid.get_child_count() > 0 else null
|
|
_expect(first_card != null, "shared Artwork chooser displays a selectable drawing card")
|
|
if first_card != null:
|
|
await _activate_card(first_card)
|
|
await process_frame
|
|
await process_frame
|
|
_expect(confirm_dialog.visible, "gallery artwork selection opens the same explicit repair confirmation")
|
|
if confirm_dialog.visible:
|
|
await _activate_button(confirm_dialog.get_ok_button())
|
|
_expect(not missing_target.missing and missing_target.asset_path == String(entries[0].get("path", "")), "Use artwork replaces only the selected missing reference")
|
|
_expect(missing_target.global_position.is_equal_approx(saved_position) and is_equal_approx(missing_target.global_rotation, saved_rotation) and missing_target.scale.is_equal_approx(saved_scale) and missing_target.depth == saved_depth, "repair preserves saved placement, size, orientation, and depth")
|
|
_expect(bool(_stage.get("_dirty")), "confirmed artwork repair marks the scene dirty")
|
|
var saved_repair: bool = _stage.call("_save_scene_to_path", path, false)
|
|
var reopened_repair: bool = _stage.call("_load_scene_from_path", path, false) if saved_repair else false
|
|
var repaired_after_reopen: StageSceneryArtwork = _find_artwork(world, String(entries[0].get("path", "")))
|
|
_expect(reopened_repair and repaired_after_reopen != null and not repaired_after_reopen.missing, "repaired live artwork path persists after save and reopen")
|
|
_stage.call("_exit_scenery_edit_mode")
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
|
|
|
|
|
func _test_single_scenery_transforms(world: Node2D, artwork_path: String) -> void:
|
|
var artwork: StageSceneryArtwork = STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
|
|
_expect(artwork.load_visual(artwork_path), "single-transform fixture loads production scenery artwork")
|
|
if artwork.missing:
|
|
artwork.free()
|
|
return
|
|
artwork.display_name = "Phase 19 Transform Fixture"
|
|
artwork.set_depth(&"near")
|
|
world.add_child(artwork)
|
|
artwork.global_position = Vector2(2400.0, 1600.0)
|
|
artwork.global_rotation = 0.19
|
|
artwork.scale = Vector2(1.25, 0.82)
|
|
var selection: StageSelection = _stage.get("_selection") as StageSelection
|
|
var controller: StageSceneryController = _stage.get("_scenery_controller") as StageSceneryController
|
|
var document_changes: Array[int] = [0]
|
|
controller.document_change_requested.connect(func() -> void: document_changes[0] += 1)
|
|
_stage.call("_enter_scenery_edit_mode")
|
|
selection.select_only(artwork)
|
|
_stage.call("_on_grid_size_changed", 20.0)
|
|
_stage.call("_on_snap_toggled", true)
|
|
var original_scale: Vector2 = artwork.scale
|
|
var original_bounds: Rect2 = artwork.get_visual_world_bounds()
|
|
_stage.set("_dirty", false)
|
|
var press: InputEventMouseButton = InputEventMouseButton.new()
|
|
press.button_index = MOUSE_BUTTON_LEFT
|
|
press.pressed = true
|
|
_stage.call("_handle_scenery_edit_click", press, original_bounds.get_center())
|
|
await process_frame
|
|
_expect(controller.is_single_transform_active() and controller.get_single_transform_kind() == &"move", "single scenery body drag starts the production move transaction")
|
|
var capture_bar: StageUseTargetCaptureBar = _stage.get("_use_target_capture_bar") as StageUseTargetCaptureBar
|
|
var cancel_button: Button = capture_bar.find_child("Action_cancel_single", true, false) as Button
|
|
_expect(capture_bar.visible and cancel_button != null and cancel_button.text.contains("Cancel"), "active scenery drag exposes a visible Cancel action")
|
|
var scenery_inspector: StageInspectorPanel = _stage.get("_scenery_inspector") as StageInspectorPanel
|
|
_expect(capture_bar.get_global_rect().end.x <= scenery_inspector.get_global_rect().position.x, "active scenery Cancel bar stays in the clear lane beside the inspector")
|
|
_stage.call("_handle_scenery_edit_motion", original_bounds.get_center() + Vector2(37.0, 43.0))
|
|
var moved_bounds: Rect2 = artwork.get_visual_world_bounds()
|
|
_expect(_is_grid_aligned(moved_bounds.position, 20.0), "snapped scenery move aligns the visible AABB top-left to the grid")
|
|
var release: InputEventMouseButton = InputEventMouseButton.new()
|
|
release.button_index = MOUSE_BUTTON_LEFT
|
|
release.pressed = false
|
|
_stage.call("_handle_scenery_edit_click", release, moved_bounds.get_center())
|
|
_expect(not controller.is_single_transform_active() and bool(_stage.get("_dirty")), "mouse release commits one move and marks the Stage document dirty")
|
|
var committed_position: Vector2 = artwork.global_position
|
|
_stage.set("_dirty", false)
|
|
_stage.call("_handle_scenery_edit_click", press, moved_bounds.get_center())
|
|
_stage.call("_handle_scenery_edit_motion", moved_bounds.get_center() + Vector2(63.0, 35.0))
|
|
_expect(not artwork.global_position.is_equal_approx(committed_position), "second drag shows a live move preview before cancellation")
|
|
if cancel_button != null:
|
|
cancel_button.pressed.emit()
|
|
_expect(not controller.is_single_transform_active() and artwork.global_position.is_equal_approx(committed_position), "visible Cancel restores the exact committed transform")
|
|
_stage.call("_handle_scenery_edit_click", press, committed_position)
|
|
_expect(controller.is_single_transform_active(), "next canvas press after clicking Cancel is not swallowed by a stale release guard")
|
|
_stage.call("_handle_scenery_edit_motion", committed_position + Vector2(63.0, 35.0))
|
|
var document_changes_after_cancel: int = document_changes[0]
|
|
var escape: InputEventKey = InputEventKey.new()
|
|
escape.keycode = KEY_ESCAPE
|
|
escape.pressed = true
|
|
_stage.call("_unhandled_key_input", escape)
|
|
_expect(not controller.is_single_transform_active() and artwork.global_position.is_equal_approx(committed_position), "Escape cancels the active single-piece transform")
|
|
_expect(document_changes[0] == document_changes_after_cancel, "Cancel and Escape leave the Stage document unchanged")
|
|
_stage.set("_dirty", false)
|
|
var resize_bounds: Rect2 = artwork.get_visual_world_bounds()
|
|
var resize_corner: Vector2 = resize_bounds.position
|
|
var opposite_corner: Vector2 = resize_bounds.end
|
|
_stage.call("_handle_scenery_edit_click", press, resize_corner)
|
|
_expect(controller.is_single_transform_active() and controller.get_single_transform_kind() == &"resize", "44-pixel scenery corner handle starts resize")
|
|
var resize_delta: Vector2 = (resize_corner - opposite_corner) * 0.25
|
|
_stage.call("_handle_scenery_edit_motion", resize_corner + resize_delta)
|
|
_expect(artwork.scale.is_equal_approx(original_scale * 1.25), "corner resize applies a uniform factor while preserving non-uniform starting scale (actual %s expected %s)" % [str(artwork.scale), str(original_scale * 1.25)])
|
|
var resized_bounds: Rect2 = artwork.get_visual_world_bounds()
|
|
_expect(resized_bounds.end.is_equal_approx(opposite_corner), "corner resize keeps the opposite visible AABB corner fixed (actual %s expected %s)" % [str(resized_bounds.end), str(opposite_corner)])
|
|
document_changes[0] = 0
|
|
release.position = resize_corner + resize_delta
|
|
_stage.call("_handle_scenery_edit_click", release, release.position)
|
|
_expect(not controller.is_single_transform_active() and bool(_stage.get("_dirty")), "resize commits on release without a separate Keep step")
|
|
_expect(document_changes[0] == 1, "resize release emits exactly one document change")
|
|
_stage.call("_handle_scenery_edit_click", release, release.position)
|
|
_expect(document_changes[0] == 1, "repeated resize release does not commit the same gesture twice")
|
|
var saved_scale: Vector2 = artwork.scale
|
|
var saved_position: Vector2 = artwork.global_position
|
|
var roundtrip_path: String = "res://tests/phase19_single_scenery_transform_tmp.scene"
|
|
var saved: bool = _stage.call("_save_scene_to_path", roundtrip_path, false)
|
|
var roundtrip_data: Dictionary = STAGE_SCENE_IO.read_file(roundtrip_path).get("data", {}) as Dictionary if saved else {}
|
|
_expect(saved and String(roundtrip_data.get("version", "")) == STAGE_SCENE_IO.FILE_VERSION, "single-piece transforms serialize in the current .scene version")
|
|
var loaded: bool = _stage.call("_load_scene_from_path", roundtrip_path, false) if saved else false
|
|
var restored: StageSceneryArtwork = _find_artwork_by_display_name(world, "Phase 19 Transform Fixture")
|
|
_expect(loaded and restored != null and restored.scale.is_equal_approx(saved_scale) and restored.global_position.is_equal_approx(saved_position), "single-piece size and placement survive save/reopen")
|
|
if FileAccess.file_exists(roundtrip_path):
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(roundtrip_path))
|
|
_stage.call("_enter_scenery_edit_mode")
|
|
var wall: StageWallPanel = STAGE_WALL_PANEL.new() as StageWallPanel
|
|
wall.configure(Vector2(160.0, 220.0), &"finish_wall_brick", Color.WHITE)
|
|
world.add_child(wall)
|
|
wall.global_position = Vector2(2800.0, 1600.0)
|
|
wall.scale = Vector2(1.4, 0.9)
|
|
selection.select_only(wall)
|
|
_stage.call("_refresh_scenery_inspector")
|
|
var size_row: HBoxContainer = _stage.get("_scenery_size_row") as HBoxContainer
|
|
var smaller_button: Button = _stage.get("_scenery_smaller_button") as Button
|
|
var bigger_button: Button = _stage.get("_scenery_bigger_button") as Button
|
|
_expect(size_row.visible and smaller_button.custom_minimum_size.y >= 44.0 and bigger_button.custom_minimum_size.y >= 44.0, "selected wall inspector uses shared, touch-sized Smaller and Bigger actions")
|
|
var wall_bounds: Rect2 = wall.global_transform * wall.get_local_bounds()
|
|
_stage.call("_step_selected_scenery_size", 1.1)
|
|
_expect(wall.panel_size.is_equal_approx(Vector2(176.0, 242.0)), "wall size step updates panel dimensions")
|
|
_expect(wall.scale.is_equal_approx(Vector2(1.4, 0.9)), "wall size step preserves stage-space motif pitch by leaving node scale unchanged")
|
|
_expect((wall.global_transform * wall.get_local_bounds()).position.is_equal_approx(wall_bounds.position), "wall size step pins visible top-left while resizing")
|
|
var unknown_marker: StageSceneryArtwork = STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
|
|
unknown_marker.configure_missing("res://content/props/phase19_unknown_footprint.prop", "Unknown footprint", Rect2(), &"near")
|
|
world.add_child(unknown_marker)
|
|
unknown_marker.global_position = Vector2(2900.0, 1600.0)
|
|
selection.select_only(unknown_marker)
|
|
_stage.call("_refresh_scenery_inspector")
|
|
_expect(not size_row.visible and smaller_button.disabled and bigger_button.disabled, "unknown-footprint missing artwork hides and disables misleading size controls")
|
|
_expect(not controller.begin_single_transform(unknown_marker, &"resize", unknown_marker.global_position), "unknown-footprint missing artwork cannot start a corner resize")
|
|
_expect(not controller.step_single_size(unknown_marker, 1.1), "unknown-footprint missing artwork cannot use Smaller or Bigger size steps")
|
|
_stage.call("_exit_scenery_edit_mode")
|
|
_stage.call("_on_snap_toggled", false)
|
|
unknown_marker.free()
|
|
wall.free()
|
|
|
|
|
|
func _test_selected_scenery_depth(world: Node2D, artwork_path: String) -> void:
|
|
var artwork: StageSceneryArtwork = STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
|
|
_expect(artwork.load_visual(artwork_path), "depth-inspector fixture loads production scenery artwork")
|
|
if artwork.missing:
|
|
artwork.free()
|
|
return
|
|
artwork.display_name = "Phase 19 Depth Inspector Fixture"
|
|
artwork.set_depth(&"near")
|
|
world.add_child(artwork)
|
|
artwork.global_position = Vector2(4600.0, 1800.0)
|
|
var selection: StageSelection = _stage.get("_selection") as StageSelection
|
|
_stage.call("_enter_scenery_edit_mode")
|
|
selection.select_only(artwork)
|
|
await process_frame
|
|
await process_frame
|
|
var inspector: StageInspectorPanel = _stage.get("_scenery_inspector") as StageInspectorPanel
|
|
var section: VBoxContainer = _stage.get("_scenery_depth_section") as VBoxContainer
|
|
var choices: StagePanelChoiceGroup = _stage.get("_scenery_depth_group") as StagePanelChoiceGroup
|
|
var far_button: Button = choices.find_child("Choice_far", true, false) as Button if choices != null else null
|
|
var near_button: Button = choices.find_child("Choice_near", true, false) as Button if choices != null else null
|
|
var floor_button: Button = choices.find_child("Choice_floor", true, false) as Button if choices != null else null
|
|
_expect(inspector.visible and section != null and section.visible, "one selected artwork shows the Where it appears section")
|
|
_expect(far_button != null and near_button != null and floor_button != null, "depth inspector offers Far, Near, and On the floor choices")
|
|
if far_button == null or near_button == null or floor_button == null:
|
|
_stage.call("_exit_scenery_edit_mode")
|
|
return
|
|
_expect(far_button.custom_minimum_size.y >= 44.0 and near_button.custom_minimum_size.y >= 44.0 and floor_button.custom_minimum_size.y >= 44.0, "depth choices meet the shared 44-pixel touch target minimum")
|
|
_expect(near_button.button_pressed and not far_button.button_pressed and not floor_button.button_pressed, "inspector silently highlights the selected artwork's saved depth")
|
|
var original_position: Vector2 = artwork.global_position
|
|
var original_scale: Vector2 = artwork.scale
|
|
_stage.set("_dirty", false)
|
|
await _send_button_key(near_button, KEY_SPACE)
|
|
_expect(artwork.depth == &"near" and not bool(_stage.get("_dirty")), "choosing the current depth is a real UI no-op and leaves the Stage clean")
|
|
var scroll_fixture: Control = Control.new()
|
|
scroll_fixture.name = "DepthInspectorScrollFixture"
|
|
scroll_fixture.custom_minimum_size = Vector2(1.0, 360.0)
|
|
inspector.body().add_child(scroll_fixture)
|
|
await process_frame
|
|
await process_frame
|
|
inspector.request_fit()
|
|
await process_frame
|
|
await process_frame
|
|
var scroll: ScrollContainer = inspector.get("_scroll") as ScrollContainer
|
|
var scrollbar: VScrollBar = scroll.get_v_scroll_bar() as VScrollBar if scroll != null else null
|
|
var scroll_limit: int = maxi(0, int(floor(scrollbar.max_value - scrollbar.page))) if scrollbar != null else 0
|
|
_expect(scroll_limit > 0, "overflowing artwork inspector body scrolls independently of its fixed header")
|
|
if scroll != null and scroll_limit > 0:
|
|
scroll.scroll_vertical = mini(40, scroll_limit)
|
|
await process_frame
|
|
var scroll_before_change: int = scroll.scroll_vertical if scroll != null else 0
|
|
var header: Control = inspector.get("_header") as Control
|
|
var header_position_before_change: Vector2 = header.global_position if header != null else Vector2.ZERO
|
|
_stage.set("_dirty", false)
|
|
await _send_button_key(far_button, KEY_SPACE)
|
|
_expect(artwork.depth == &"far" and artwork.z_index == -30, "focused keyboard input immediately changes the selected artwork's live Far draw layer")
|
|
_expect(bool(_stage.get("_dirty")), "changing artwork depth marks the Stage dirty")
|
|
_expect(artwork.global_position.is_equal_approx(original_position) and artwork.scale.is_equal_approx(original_scale), "changing depth leaves artwork position and size unchanged")
|
|
_expect(inspector.visible and section.visible and far_button.button_pressed, "depth change keeps the inspector open and highlights the new choice")
|
|
_expect(scroll == null or scroll.scroll_vertical == scroll_before_change, "depth refresh preserves the inspector body's scroll position")
|
|
_expect(header == null or header.global_position.is_equal_approx(header_position_before_change), "depth refresh keeps the inspector header fixed while its body is scrolled")
|
|
_stage.set("_dirty", false)
|
|
await _send_button_key(far_button, KEY_SPACE)
|
|
_expect(artwork.depth == &"far" and not bool(_stage.get("_dirty")), "repeating the selected depth through real keyboard input does not dirty the Stage")
|
|
if scroll_fixture.is_inside_tree():
|
|
scroll_fixture.queue_free()
|
|
_stage.call("_refresh_scenery_inspector_layout")
|
|
await process_frame
|
|
var roundtrip_path: String = "res://tests/phase19_selected_depth_tmp.scene"
|
|
var saved: bool = _stage.call("_save_scene_to_path", roundtrip_path, false)
|
|
var loaded: bool = _stage.call("_load_scene_from_path", roundtrip_path, false) if saved else false
|
|
var restored: StageSceneryArtwork = _find_artwork_by_display_name(world, "Phase 19 Depth Inspector Fixture")
|
|
_expect(saved and loaded and restored != null and restored.depth == &"far", "selected artwork depth persists through production scene save and reopen")
|
|
if restored != null:
|
|
_stage.call("_enter_scenery_edit_mode")
|
|
selection.select_only(restored)
|
|
await process_frame
|
|
choices = _stage.get("_scenery_depth_group") as StagePanelChoiceGroup
|
|
far_button = choices.find_child("Choice_far", true, false) as Button if choices != null else null
|
|
_expect(far_button != null and far_button.button_pressed, "reopened artwork inspector highlights the saved depth")
|
|
_stage.set("_dirty", false)
|
|
var missing_marker: StageSceneryArtwork = STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
|
|
missing_marker.configure_missing("res://content/props/phase19_depth_marker_missing.prop", "Phase 19 Depth Marker", Rect2(Vector2(-44.0, -51.0), Vector2(88.0, 102.0)), &"near")
|
|
world.add_child(missing_marker)
|
|
missing_marker.global_position = Vector2(5000.0, 1800.0)
|
|
selection.select_only(missing_marker)
|
|
await process_frame
|
|
choices = _stage.get("_scenery_depth_group") as StagePanelChoiceGroup
|
|
near_button = choices.find_child("Choice_near", true, false) as Button if choices != null else null
|
|
floor_button = choices.find_child("Choice_floor", true, false) as Button if choices != null else null
|
|
_expect(missing_marker.missing and section.visible and near_button != null and near_button.button_pressed, "missing-art marker exposes and highlights its saved depth before repair")
|
|
_stage.set("_dirty", false)
|
|
if floor_button != null:
|
|
await _send_button_key(floor_button, KEY_SPACE)
|
|
_expect(missing_marker.depth == &"floor" and missing_marker.z_index == -5, "keyboard input changes a missing-art marker's live draw layer")
|
|
_expect(bool(_stage.get("_dirty")), "changing missing-art depth marks the Stage dirty")
|
|
saved = _stage.call("_save_scene_to_path", roundtrip_path, false)
|
|
loaded = _stage.call("_load_scene_from_path", roundtrip_path, false) if saved else false
|
|
var restored_marker: StageSceneryArtwork = _find_artwork_by_display_name(world, "Phase 19 Depth Marker")
|
|
_expect(saved and loaded and restored_marker != null and restored_marker.missing and restored_marker.depth == &"floor", "missing-art marker keeps its edited depth after scene save and reopen")
|
|
_stage.call("_enter_scenery_edit_mode")
|
|
var wall: StageWallPanel = STAGE_WALL_PANEL.new() as StageWallPanel
|
|
wall.configure(Vector2(180.0, 220.0), &"finish_wall_brick", Color.WHITE)
|
|
world.add_child(wall)
|
|
selection.select_only(wall)
|
|
await process_frame
|
|
section = _stage.get("_scenery_depth_section") as VBoxContainer
|
|
_expect(not section.visible, "wall panels do not show artwork depth choices")
|
|
var other_artwork: StageSceneryArtwork = STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
|
|
_expect(other_artwork.load_visual(artwork_path), "multi-selection guard fixture loads production scenery artwork")
|
|
world.add_child(other_artwork)
|
|
if restored_marker != null:
|
|
selection.select_only(restored_marker)
|
|
selection.add_to_selection(other_artwork)
|
|
await process_frame
|
|
_expect(not section.visible and not inspector.visible, "multiple selected scenery pieces do not offer a single-artwork depth choice")
|
|
selection.clear_selection()
|
|
await process_frame
|
|
_expect(not section.visible and not inspector.visible, "no selection hides the artwork depth section")
|
|
wall.free()
|
|
other_artwork.free()
|
|
_stage.call("_exit_scenery_edit_mode")
|
|
if FileAccess.file_exists(roundtrip_path):
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(roundtrip_path))
|
|
|
|
|
|
func _test_prop_size_for_stage() -> void:
|
|
var editor: PropEditor = PROP_EDITOR_SCENE.instantiate() as PropEditor
|
|
root.add_child(editor)
|
|
await process_frame
|
|
await process_frame
|
|
var canvas: PropCanvas = editor.get("_canvas") as PropCanvas
|
|
var original_shape: Dictionary = {
|
|
"shape_type": "polygon",
|
|
"points": PackedVector2Array([Vector2(-50.0, -200.0), Vector2(50.0, -200.0), Vector2(50.0, 0.0), Vector2(-50.0, 0.0)]),
|
|
"fill_color": "#9D6A4A",
|
|
"outline_color": "#2D2532",
|
|
"outline_width": 4.0,
|
|
"closed": true,
|
|
}
|
|
canvas.set_shape_data([original_shape])
|
|
var original_shapes: Array[Dictionary] = canvas.get_shape_data()
|
|
var original_anchors: Dictionary = (editor.get("_anchors") as Dictionary).duplicate(true)
|
|
editor.set("_dirty", false)
|
|
editor.call("_on_size_for_stage_pressed")
|
|
var size_controls: FlowContainer = editor.get("_stage_size_controls") as FlowContainer
|
|
_expect(canvas.is_stage_size_preview_active() and size_controls.visible, "Size for Stage starts a separate preview transaction and reveals its controls")
|
|
var required_labels: Array[String] = ["Small", "Room", "Big", "Make smaller", "Make bigger", "Keep size", "✕ Cancel"]
|
|
for required_label: String in required_labels:
|
|
var button: Button = _find_button_by_text(size_controls, required_label)
|
|
_expect(button != null and button.custom_minimum_size.y >= 44.0, "Size for Stage exposes touch-sized %s control" % required_label)
|
|
for _frame: int in 4:
|
|
await process_frame
|
|
var entry_bounds: Rect2 = canvas.get_stage_size_comparison_bounds()
|
|
var entry_zoom: float = float(canvas.get("_zoom"))
|
|
var entry_pan: Vector2 = canvas.get("_pan_offset")
|
|
var entry_screen_bounds: Rect2 = Rect2(entry_bounds.position * entry_zoom + entry_pan, entry_bounds.size * entry_zoom)
|
|
var entry_viewport_size: Vector2 = Vector2(maxf(0.0, canvas.size.x - 96.0), maxf(0.0, canvas.size.y - 96.0))
|
|
var entry_viewport: Rect2 = Rect2(Vector2(48.0, 48.0), entry_viewport_size)
|
|
var size_tools: FlowContainer = editor.get("_size_tools") as FlowContainer
|
|
_expect(size_tools.size.y < 160.0 and canvas.size.y > 96.0, "Size for Stage controls preserve a visible canvas instead of expanding the toolbar vertically")
|
|
_expect(entry_viewport.encloses(entry_screen_bounds), "Size for Stage entry fits artwork and the stickman guide after the editor layout settles")
|
|
editor.call("_choose_stage_size", 1.0)
|
|
var room_extent: float = maxf(canvas.get_visible_shapes_bounds().size.x, canvas.get_visible_shapes_bounds().size.y)
|
|
_expect(is_equal_approx(room_extent, 939.5), "Room preset targets the 939.5-unit standing-stickman height")
|
|
_expect(not bool(editor.get("_dirty")), "size preview leaves the prop draft and autosave state untouched")
|
|
editor.call("_adjust_stage_size", 1.1)
|
|
var bigger_extent: float = maxf(canvas.get_visible_shapes_bounds().size.x, canvas.get_visible_shapes_bounds().size.y)
|
|
_expect(is_equal_approx(bigger_extent, room_extent * 1.1), "Make bigger applies one 1.1 step")
|
|
editor.call("_adjust_stage_size", 1.0 / 1.1)
|
|
_expect(is_equal_approx(maxf(canvas.get_visible_shapes_bounds().size.x, canvas.get_visible_shapes_bounds().size.y), room_extent), "Make smaller applies the reciprocal 1.1 step")
|
|
var comparison_bounds: Rect2 = canvas.get_stage_size_comparison_bounds()
|
|
_expect(comparison_bounds.position.y < canvas.get_visible_shapes_bounds().position.y and comparison_bounds.end.y >= canvas.get_visible_shapes_bounds().end.y, "preview fit bounds include the foreground stickman guide and shared floor line")
|
|
editor.call("_finish_stage_size_preview", false)
|
|
_expect(not canvas.is_stage_size_preview_active() and canvas.get_shape_data() == original_shapes, "Cancel closes the preview and restores the original drawing exactly")
|
|
_expect((editor.get("_anchors") as Dictionary) == original_anchors and not bool(editor.get("_dirty")), "Cancel restores anchors and leaves the document clean")
|
|
editor.call("_on_size_for_stage_pressed")
|
|
editor.call("_choose_stage_size", 0.35)
|
|
var small_extent: float = maxf(canvas.get_visible_shapes_bounds().size.x, canvas.get_visible_shapes_bounds().size.y)
|
|
_expect(is_equal_approx(small_extent, 328.825), "Small preset targets 0.35 of the standing-stickman height")
|
|
editor.call("_finish_stage_size_preview", true)
|
|
var committed_extent: float = maxf(canvas.get_visible_shapes_bounds().size.x, canvas.get_visible_shapes_bounds().size.y)
|
|
_expect(not canvas.is_stage_size_preview_active() and is_equal_approx(committed_extent, small_extent), "Keep commits the chosen overall document scale")
|
|
_expect(bool(editor.get("_dirty")), "keeping a size change enters the existing dirty/save lifecycle")
|
|
editor.queue_free()
|
|
await process_frame
|
|
|
|
|
|
func _find_button_by_text(parent: Node, text: String) -> Button:
|
|
if parent == null:
|
|
return null
|
|
for candidate: Node in parent.find_children("*", "Button", true, false):
|
|
var button: Button = candidate as Button
|
|
if button != null and button.text == text:
|
|
return button
|
|
return null
|
|
|
|
|
|
func _find_missing_artwork(world: Node2D) -> StageSceneryArtwork:
|
|
for child: Node in world.get_children():
|
|
if child.get_script() == STAGE_SCENERY_ARTWORK and (child as StageSceneryArtwork).missing:
|
|
return child as StageSceneryArtwork
|
|
return null
|
|
|
|
|
|
func _contains_positions(expected: Array[Vector2], actual: Array[Vector2]) -> bool:
|
|
var unmatched: Array[Vector2] = actual.duplicate()
|
|
for expected_position: Vector2 in expected:
|
|
var match_index: int = -1
|
|
for index: int in range(unmatched.size()):
|
|
if unmatched[index].is_equal_approx(expected_position):
|
|
match_index = index
|
|
break
|
|
if match_index < 0:
|
|
return false
|
|
unmatched.remove_at(match_index)
|
|
return true
|
|
|
|
|
|
func _find_artwork(world: Node2D, asset_path: String) -> StageSceneryArtwork:
|
|
for child: Node in world.get_children():
|
|
if child.get_script() == STAGE_SCENERY_ARTWORK and (child as StageSceneryArtwork).asset_path == asset_path:
|
|
return child as StageSceneryArtwork
|
|
return null
|
|
|
|
|
|
func _find_artwork_by_display_name(world: Node2D, display_name: String) -> StageSceneryArtwork:
|
|
for child: Node in world.get_children():
|
|
if child.get_script() == STAGE_SCENERY_ARTWORK and (child as StageSceneryArtwork).display_name == display_name:
|
|
return child as StageSceneryArtwork
|
|
return null
|
|
|
|
|
|
func _find_card_by_id(grid: GridContainer, card_id: String) -> AssetCard:
|
|
if grid == null:
|
|
return null
|
|
for child: Node in grid.get_children():
|
|
var card: AssetCard = child as AssetCard
|
|
if card != null and String(card.payload().get("placement_id", "")) == card_id:
|
|
return card
|
|
return null
|
|
|
|
|
|
func _send_stage_click(position: Vector2, button: int) -> void:
|
|
var press := InputEventMouseButton.new()
|
|
press.button_index = button
|
|
press.position = position
|
|
press.global_position = position
|
|
press.pressed = true
|
|
_stage.get_viewport().push_input(press)
|
|
await process_frame
|
|
var release := InputEventMouseButton.new()
|
|
release.button_index = button
|
|
release.position = position
|
|
release.global_position = position
|
|
release.pressed = false
|
|
_stage.get_viewport().push_input(release)
|
|
await process_frame
|
|
|
|
|
|
func _send_button_key(button: Button, keycode: Key) -> void:
|
|
button.grab_focus()
|
|
await process_frame
|
|
var viewport: Viewport = button.get_viewport()
|
|
var press: InputEventKey = InputEventKey.new()
|
|
press.keycode = keycode
|
|
press.pressed = true
|
|
viewport.push_input(press)
|
|
await process_frame
|
|
var release: InputEventKey = InputEventKey.new()
|
|
release.keycode = keycode
|
|
release.pressed = false
|
|
viewport.push_input(release)
|
|
await process_frame
|
|
|
|
|
|
func _activate_button(button: Button) -> void:
|
|
button.pressed.emit()
|
|
await process_frame
|
|
|
|
|
|
func _activate_card(card: AssetCard) -> void:
|
|
card.activated.emit(card.payload())
|
|
await process_frame
|