feat: add Phase 20a stage identities and overlap selection
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,720 @@
|
||||
extends SceneTree
|
||||
|
||||
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
|
||||
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
||||
const STAGE_GIZMOS := preload("res://scripts/stage/stage_gizmos.gd")
|
||||
const CAPTURE_DIR: String = "res://tests/phase20a_captures"
|
||||
|
||||
var _failures: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run_capture_scenarios")
|
||||
|
||||
|
||||
func _run_capture_scenarios() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
root.position = Vector2i(40, 40)
|
||||
root.gui_embed_subwindows = true
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(CAPTURE_DIR))
|
||||
var stage: SandboxStage = SANDBOX_STAGE.instantiate() as SandboxStage
|
||||
root.add_child(stage)
|
||||
await _wait_frames(8)
|
||||
stage.call("_on_grid_toggled", false)
|
||||
var spawner: StageSpawner = stage.get("_spawner") as StageSpawner
|
||||
spawner.selected_prop_id = "crate"
|
||||
stage.set_placement_mode("prop")
|
||||
var center_screen: Vector2 = _screen_at(stage, Vector2.ZERO)
|
||||
await _send_mouse_motion(center_screen)
|
||||
await _wait_frames(3)
|
||||
_check(_placement_prompt(stage).visible, "placement prompt is visible")
|
||||
_check(_placement_prompt(stage).text.contains("Click to place Crate"), "placement prompt shows the next automatic name")
|
||||
await _save_capture("placement_name_prompt")
|
||||
|
||||
await _send_click(center_screen)
|
||||
await _wait_frames(4)
|
||||
var placed_props: Array[PropBlock] = _placed_props(stage)
|
||||
var chooser: PopupPanel = null
|
||||
_check(placed_props.size() == 1, "first prop is placed through the viewport input path")
|
||||
if placed_props.size() >= 1:
|
||||
_check(placed_props[0].stage_name == "Crate", "first prop receives its asset-based stage name")
|
||||
await _send_click(center_screen)
|
||||
await _wait_frames(4)
|
||||
placed_props = _placed_props(stage)
|
||||
_check(placed_props.size() == 2, "second prop is placed through the viewport input path")
|
||||
if placed_props.size() < 2:
|
||||
stage.queue_free()
|
||||
await _wait_frames(2)
|
||||
quit(1)
|
||||
return
|
||||
_check(placed_props[0].stage_name == "Crate", "first prop receives its asset-based stage name")
|
||||
_check(placed_props[1].stage_name == "Crate 2", "repeat placement gets the next unique name")
|
||||
await _make_prop_rectangle(placed_props[0], Color("#C46B48"))
|
||||
await _make_prop_rectangle(placed_props[1], Color("#4D86C6"))
|
||||
stage.call("_show_placement_feedback", placed_props[1], placed_props[1].stage_name)
|
||||
stage.set_placement_mode("")
|
||||
await _wait_frames(3)
|
||||
await _save_capture("placement_feedback")
|
||||
await create_timer(1.8).timeout
|
||||
|
||||
# A third prop shares the broad-phase bounds, but its visible outline leaves
|
||||
# the click point empty. The production chooser should exclude it.
|
||||
var outline_decoy: PropBlock = spawner.spawn_prop_asset("crate", Vector2.ZERO)
|
||||
if outline_decoy != null:
|
||||
stage.call("_finalize_placed_node", outline_decoy)
|
||||
await _make_prop_outline(outline_decoy)
|
||||
var identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
|
||||
var rename_result: Dictionary = identity.rename(outline_decoy, "Outline Decoy", stage.get_node("World") as Node2D)
|
||||
_check(bool(rename_result.get("ok", false)), "outline-only decoy gets a unique label")
|
||||
else:
|
||||
_check(false, "outline-only decoy was spawned")
|
||||
await _wait_frames(4)
|
||||
|
||||
var world: Node2D = stage.get_node("World") as Node2D
|
||||
var character: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(-560.0, 0.0))
|
||||
if character != null:
|
||||
stage.call("_finalize_placed_node", character)
|
||||
var selection: StageSelection = stage.get("_selection") as StageSelection
|
||||
selection.select_only(character)
|
||||
await _wait_frames(4)
|
||||
await _save_capture("character_inspector")
|
||||
var character_view: StageCharacterSettingsView = stage.get("_character_settings_view") as StageCharacterSettingsView
|
||||
var rename_button: Button = character_view.find_child("RenameStageObject", true, false) as Button
|
||||
var character_card: StageInspectorPanel = character_view.get_card() as StageInspectorPanel
|
||||
var character_title: Label = character_card.find_child("InspectorTitle", true, false) as Label
|
||||
var character_body: ScrollContainer = character_card.find_child("InspectorBodyScroll", true, false) as ScrollContainer
|
||||
_check(rename_button != null and rename_button.visible and rename_button.text == "Rename", "character inspector has a visible Rename action in its header")
|
||||
_check(character_title != null and character_title.text == character.stage_name, "character name appears once as the inspector heading")
|
||||
_check(character_body != null and not character_body.visible, "character inspector stays a compact name row until editing")
|
||||
if rename_button != null:
|
||||
stage.call("_set_scene_dirty", false)
|
||||
_check(not bool(stage.get("_dirty")), "name editor starts from a clean document")
|
||||
await _click_control(rename_button)
|
||||
await _wait_frames(3)
|
||||
var character_edit: LineEdit = character_view.find_child("StageNameEdit", true, false) as LineEdit
|
||||
_check(character_edit != null and character_edit.visible, "Rename opens the inline editor")
|
||||
await _save_capture("character_name_edit")
|
||||
if character_edit != null:
|
||||
_check(character_edit.has_focus(), "inline name editor receives keyboard focus")
|
||||
await _send_key(KEY_A, false, true)
|
||||
await _type_text("Milo")
|
||||
_check(character_edit.text == "Milo", "injected keyboard input replaces the character name")
|
||||
var done_button: Button = character_view.find_child("DoneStageName", true, false) as Button
|
||||
if done_button != null:
|
||||
await _click_control(done_button)
|
||||
await _wait_frames(3)
|
||||
_check(character.stage_name == "Milo", "valid name commits from the inline editor")
|
||||
_check(character_title != null and character_title.text == "Milo", "renamed character heading updates immediately")
|
||||
_check(bool(stage.get("_dirty")), "valid rename marks the document dirty")
|
||||
await _save_capture("character_renamed")
|
||||
if rename_button != null:
|
||||
stage.call("_set_scene_dirty", false)
|
||||
_check(not bool(stage.get("_dirty")), "duplicate-name check starts from a clean document")
|
||||
await _click_control(rename_button)
|
||||
await _wait_frames(3)
|
||||
character_edit = character_view.find_child("StageNameEdit", true, false) as LineEdit
|
||||
if character_edit != null:
|
||||
_check(character_edit.has_focus(), "duplicate-name editor receives keyboard focus")
|
||||
await _send_key(KEY_A, false, true)
|
||||
await _type_text(placed_props[0].stage_name)
|
||||
_check(character_edit.text == placed_props[0].stage_name, "injected keyboard input enters the duplicate name")
|
||||
await _send_key(KEY_ENTER)
|
||||
await _wait_frames(3)
|
||||
var feedback: Label = character_view.find_child("StageNameFeedback", true, false) as Label
|
||||
_check(feedback != null and feedback.visible and feedback.text.contains("Crate"), "character duplicate name remains in the editor with feedback")
|
||||
_check(not bool(stage.get("_dirty")) and character.stage_name == "Milo", "invalid duplicate rename leaves the name and dirty state unchanged")
|
||||
await _save_capture("character_duplicate_name_feedback")
|
||||
var cancel_name_button: Button = character_view.find_child("CancelStageName", true, false) as Button
|
||||
if cancel_name_button != null:
|
||||
await _click_control(cancel_name_button)
|
||||
await _wait_frames(3)
|
||||
_check(not bool(stage.get("_dirty")) and character.stage_name == "Milo", "Cancel discards the draft without marking the scene dirty")
|
||||
else:
|
||||
_check(false, "character asset is spawned for inspector capture")
|
||||
|
||||
placed_props = _placed_props(stage)
|
||||
var first_prop: PropBlock = placed_props[0] if not placed_props.is_empty() else null
|
||||
var second_prop: PropBlock = placed_props[1] if placed_props.size() > 1 else null
|
||||
var selection_after_setup: StageSelection = stage.get("_selection") as StageSelection
|
||||
if first_prop != null:
|
||||
selection_after_setup.select_only(first_prop)
|
||||
await _wait_frames(3)
|
||||
await _save_capture("prop_inspector")
|
||||
var prop_view: StagePropSettingsView = stage.get("_prop_settings_view") as StagePropSettingsView
|
||||
_check(prop_view != null and prop_view.visible, "selected prop inspector is visible")
|
||||
var prop_card: StageInspectorPanel = prop_view.get_card() as StageInspectorPanel
|
||||
var prop_title: Label = prop_card.find_child("InspectorTitle", true, false) as Label
|
||||
var prop_rename_button: Button = prop_card.find_child("RenameStageObject", true, false) as Button
|
||||
var collapse_button: Button = prop_card.find_child("CollapseStageInspector", true, false) as Button
|
||||
_check(prop_title != null and prop_title.text == first_prop.stage_name, "prop name appears once in the inspector heading")
|
||||
_check(_find_label_with_text(prop_view, "Name on stage") == null, "prop inspector does not repeat its name in the body")
|
||||
_check(prop_rename_button != null and prop_rename_button.visible and prop_rename_button.text == "Rename", "prop inspector shares the header Rename action")
|
||||
_check(collapse_button != null and collapse_button.visible and not prop_card.is_collapsed(), "expanded prop inspector has a disclosure control")
|
||||
if collapse_button != null:
|
||||
await _click_control(collapse_button)
|
||||
await _wait_frames(3)
|
||||
_check(prop_card.visible and prop_card.is_collapsed() and collapse_button.text == "▾", "Close collapses to the named prop header")
|
||||
_check(_find_button_with_text(prop_view, "Settings") == null, "collapsed inspector does not replace the name with a Settings button")
|
||||
await _save_capture("prop_inspector_collapsed")
|
||||
if prop_rename_button != null:
|
||||
await _click_control(prop_rename_button)
|
||||
await _wait_frames(3)
|
||||
var prop_name_edit: LineEdit = prop_view.find_child("StageNameEdit", true, false) as LineEdit
|
||||
_check(not prop_card.is_collapsed() and prop_name_edit != null and prop_name_edit.visible, "Rename expands the inspector and opens its name editor")
|
||||
var prop_cancel_name: Button = prop_view.find_child("CancelStageName", true, false) as Button
|
||||
if prop_cancel_name != null:
|
||||
await _click_control(prop_cancel_name)
|
||||
await _wait_frames(3)
|
||||
_check(prop_card.is_collapsed(), "Cancel returns to the prior collapsed state")
|
||||
await _click_control(collapse_button)
|
||||
await _wait_frames(3)
|
||||
_check(not prop_card.is_collapsed() and prop_view.get_fields().visible, "chevron expands the full prop settings body")
|
||||
|
||||
# Click at a true overlap through the stage's normal unhandled input route.
|
||||
# Existing selection remains unchanged while the chooser is open.
|
||||
if first_prop != null and second_prop != null:
|
||||
selection_after_setup.select_only(first_prop)
|
||||
await _wait_frames(2)
|
||||
await _send_click(center_screen)
|
||||
await _wait_frames(5)
|
||||
chooser = stage.get("_objects_here_popup") as PopupPanel
|
||||
_check(chooser != null and chooser.visible, "overlap click opens Objects here")
|
||||
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "opening chooser leaves the previous selection unchanged")
|
||||
var candidates: Array[Node2D] = selection_after_setup.hit_test_all(Vector2.ZERO)
|
||||
_check(candidates.size() == 2 and candidates.has(first_prop) and candidates.has(second_prop), "chooser hit test excludes the empty center of the outline-only decoy")
|
||||
if chooser != null:
|
||||
var chooser_shell: StyleBoxFlat = chooser.get_theme_stylebox("panel") as StyleBoxFlat
|
||||
_check(chooser_shell != null and chooser_shell.corner_radius_top_left >= 12, "overlap chooser shell has the rounded popup treatment")
|
||||
_check(chooser_shell != null and chooser_shell.border_width_left >= 2 and chooser_shell.shadow_size > 0, "overlap chooser shell has a clear border and local shadow")
|
||||
var selected_candidate: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button
|
||||
var selected_candidate_style: StyleBoxFlat = selected_candidate.get_theme_stylebox("normal") as StyleBoxFlat if selected_candidate != null else null
|
||||
_check(selected_candidate_style != null and selected_candidate_style.corner_radius_top_left >= 10, "overlap candidate rows use rounded corners")
|
||||
var cancel_button: Button = chooser.find_child("CancelObjectsHere", true, false) as Button
|
||||
var cancel_style: StyleBoxFlat = cancel_button.get_theme_stylebox("normal") as StyleBoxFlat if cancel_button != null else null
|
||||
_check(cancel_button != null and cancel_button.custom_minimum_size.y >= 48, "overlap chooser Cancel has a large target")
|
||||
_check(cancel_style != null and cancel_style.corner_radius_top_left >= 8, "overlap chooser Cancel uses the rounded secondary treatment")
|
||||
await _save_capture("overlap_chooser")
|
||||
if chooser != null:
|
||||
var candidate_button: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button
|
||||
if candidate_button != null:
|
||||
var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
|
||||
await _send_mouse_motion(candidate_button.get_screen_position() + candidate_button.size * 0.5, candidate_button.get_window().get_window_id())
|
||||
await _wait_frames(3)
|
||||
_check(gizmos != null and gizmos.get("_candidate_preview") == second_prop, "pointer hover previews the exact overlap candidate")
|
||||
await _save_capture("overlap_hover_preview")
|
||||
await _send_mouse_motion(Vector2(860.0, 360.0), chooser.get_window().get_window_id())
|
||||
await _wait_frames(3)
|
||||
_check(gizmos != null and gizmos.get("_candidate_preview") == null, "leaving a candidate row clears hover preview")
|
||||
await _send_key(KEY_TAB, true)
|
||||
await _send_key(KEY_TAB, true)
|
||||
await _wait_frames(3)
|
||||
_check(candidate_button.has_focus(), "Shift+Tab keyboard navigation focuses the first overlap candidate")
|
||||
_check(gizmos != null and gizmos.get("_candidate_preview") == second_prop, "keyboard focus previews the exact candidate")
|
||||
await _save_capture("overlap_candidate_preview")
|
||||
var cancel_button: Button = chooser.find_child("CancelObjectsHere", true, false) as Button
|
||||
if cancel_button != null:
|
||||
await _click_control(cancel_button)
|
||||
await _wait_frames(4)
|
||||
_check(stage.get("_objects_here_popup") == null, "Cancel closes the chooser")
|
||||
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "Cancel preserves the previous selection")
|
||||
await _save_capture("overlap_cancelled")
|
||||
|
||||
await _send_click(center_screen)
|
||||
await _wait_frames(4)
|
||||
await _send_key(KEY_ESCAPE)
|
||||
await _wait_frames(4)
|
||||
_check(stage.get("_objects_here_popup") == null, "Escape closes the chooser")
|
||||
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "Escape preserves the previous selection")
|
||||
var escape_gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
|
||||
_check(escape_gizmos == null or escape_gizmos.get("_candidate_preview") == null, "Escape clears the transient candidate preview")
|
||||
await _save_capture("overlap_escape_dismissal")
|
||||
|
||||
await _send_click(center_screen)
|
||||
await _wait_frames(4)
|
||||
await _send_click(_screen_at(stage, Vector2(-60.0, -40.0)))
|
||||
await _wait_frames(4)
|
||||
_check(stage.get("_objects_here_popup") == null, "outside dismissal click closes the chooser")
|
||||
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "outside dismissal consumes the click and preserves selection")
|
||||
await _save_capture("overlap_outside_dismissal")
|
||||
|
||||
await _send_click(center_screen)
|
||||
await _wait_frames(4)
|
||||
chooser = stage.get("_objects_here_popup") as PopupPanel
|
||||
if chooser != null:
|
||||
var choose_button: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button
|
||||
if choose_button != null:
|
||||
await _click_control(choose_button)
|
||||
await _wait_frames(5)
|
||||
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == second_prop, "choosing a row commits that object once")
|
||||
await _save_capture("overlap_committed")
|
||||
|
||||
# DIRECT uses the same geometry-filtered chooser with a prop-only list and
|
||||
# target selection does not take over EDIT selection.
|
||||
var direct_use_spot_id: String = second_prop.add_use_spot(Vector2.ZERO) if second_prop != null else ""
|
||||
_check(not direct_use_spot_id.is_empty(), "DIRECT target fixture has a valid Use spot")
|
||||
stage.set_mode(SandboxStage.StageMode.DIRECT)
|
||||
selection_after_setup.select_only(first_prop)
|
||||
stage.call("_begin_use_target_capture", "direct")
|
||||
await _wait_frames(4)
|
||||
await _send_click(center_screen)
|
||||
await _wait_frames(4)
|
||||
chooser = stage.get("_objects_here_popup") as PopupPanel
|
||||
_check(chooser != null and chooser.visible, "DIRECT Use opens the shared prop chooser")
|
||||
if chooser != null:
|
||||
var direct_rows: VBoxContainer = chooser.find_child("ObjectsHereRows", true, false) as VBoxContainer
|
||||
_check(direct_rows != null and direct_rows.get_child_count() == 2, "DIRECT Use chooser contains only the two props that actually cover the point")
|
||||
await _save_capture("direct_use_chooser")
|
||||
var direct_candidate: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button
|
||||
if direct_candidate != null:
|
||||
await _send_key(KEY_TAB, true)
|
||||
await _send_key(KEY_TAB, true)
|
||||
await _wait_frames(3)
|
||||
_check(direct_candidate.has_focus(), "DIRECT picker candidate can receive keyboard focus")
|
||||
await _save_capture("direct_use_preview")
|
||||
await _click_control(direct_candidate)
|
||||
await _wait_frames(4)
|
||||
var target_capture: StageTargetCaptureSession = stage.get("_target_capture") as StageTargetCaptureSession
|
||||
_check(target_capture != null and target_capture.use_target_prop == second_prop and target_capture.use_target_step == 1, "DIRECT chooser advances to Use spot selection for the chosen prop")
|
||||
var direct_capture_prompt: Label = stage.find_child("Prompt", true, false) as Label
|
||||
_check(direct_capture_prompt != null and direct_capture_prompt.text == "Choose a spot on the prop", "valid DIRECT prop selection shows the next-step prompt")
|
||||
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "DIRECT target selection leaves object selection untouched")
|
||||
await _save_capture("direct_use_target_selected")
|
||||
else:
|
||||
_check(false, "DIRECT target chooser is available for interaction")
|
||||
|
||||
# A director selects between overlapping named characters. A nearby Blue Door
|
||||
# remains visible in the scene but its actual art does not cover the click.
|
||||
stage.set_mode(SandboxStage.StageMode.EDIT)
|
||||
var props_clear_of_character_point: Array[PropBlock] = _placed_props(stage)
|
||||
for prop_index: int in props_clear_of_character_point.size():
|
||||
props_clear_of_character_point[prop_index].position = Vector2(1000.0 + float(prop_index) * 220.0, 700.0)
|
||||
if character != null:
|
||||
character.position = Vector2.ZERO
|
||||
var ava: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", character.position)
|
||||
var character_identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
|
||||
if ava != null:
|
||||
stage.call("_finalize_placed_node", ava)
|
||||
var ava_result: Dictionary = character_identity.rename(ava, "Ava", stage.get_node("World") as Node2D)
|
||||
_check(bool(ava_result.get("ok", false)), "overlapping second character receives the distinct name Ava")
|
||||
else:
|
||||
_check(false, "overlapping second character is spawned")
|
||||
var blue_door: PropBlock = spawner.spawn_prop_asset("crate", character.position + Vector2(220.0, 0.0))
|
||||
if blue_door != null:
|
||||
stage.call("_finalize_placed_node", blue_door)
|
||||
await _make_blue_door(blue_door)
|
||||
var door_result: Dictionary = character_identity.rename(blue_door, "Blue Door", stage.get_node("World") as Node2D)
|
||||
_check(bool(door_result.get("ok", false)), "nearby prop receives the descriptive name Blue Door")
|
||||
else:
|
||||
_check(false, "nearby Blue Door prop is spawned")
|
||||
if ava != null and blue_door != null:
|
||||
var shared_point: Vector2 = _find_shared_hit_point(stage.get("_selection") as StageSelection, [character, ava])
|
||||
_check(shared_point.is_finite(), "overlapping Milo and Ava share a visible hit point")
|
||||
if shared_point.is_finite():
|
||||
var shared_hits: Array[Node2D] = (stage.get("_selection") as StageSelection).hit_test_all(shared_point)
|
||||
_check(shared_hits.size() == 2 and shared_hits.has(character) and shared_hits.has(ava) and not shared_hits.has(blue_door), "character overlap hit test omits the nearby Blue Door outside visible geometry")
|
||||
selection_after_setup.select_only(first_prop)
|
||||
await _send_click(_screen_at(stage, shared_point))
|
||||
await _wait_frames(5)
|
||||
chooser = stage.get("_objects_here_popup") as PopupPanel
|
||||
_check(chooser != null and chooser.visible, "overlapping Milo and Ava open the production Objects here chooser")
|
||||
if chooser != null:
|
||||
var character_rows: VBoxContainer = chooser.find_child("ObjectsHereRows", true, false) as VBoxContainer
|
||||
_check(character_rows != null and character_rows.get_child_count() == 2, "character overlap chooser contains Milo and Ava only")
|
||||
var row_names: Array[String] = []
|
||||
if character_rows != null:
|
||||
for row_index: int in character_rows.get_child_count():
|
||||
var row_button: Button = character_rows.get_child(row_index) as Button
|
||||
if row_button == null:
|
||||
continue
|
||||
for label_node: Node in row_button.find_children("*", "Label", true, false):
|
||||
var row_label: Label = label_node as Label
|
||||
if row_label != null:
|
||||
for expected_name: String in ["Milo", "Ava"]:
|
||||
if row_label.text.contains(expected_name) and not row_names.has(expected_name):
|
||||
row_names.append(expected_name)
|
||||
_check(row_names.size() == 2 and row_names.has("Milo") and row_names.has("Ava"), "chooser rows identify the two characters by their stage names")
|
||||
await _save_capture("character_overlap_chooser")
|
||||
var ava_button: Button = _find_candidate_button_for_name(chooser, "Ava")
|
||||
if ava_button != null:
|
||||
await _send_mouse_motion(ava_button.get_screen_position() + ava_button.size * 0.5, ava_button.get_window().get_window_id())
|
||||
await _wait_frames(3)
|
||||
await _save_capture("character_overlap_preview")
|
||||
await _click_control(ava_button)
|
||||
await _wait_frames(4)
|
||||
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == ava, "choosing Ava commits the character selection")
|
||||
else:
|
||||
_check(false, "Ava row is available for mouse selection")
|
||||
await _save_capture("character_overlap_committed")
|
||||
else:
|
||||
_check(false, "character overlap chooser is available for candidate inspection")
|
||||
for prop_index: int in props_clear_of_character_point.size():
|
||||
props_clear_of_character_point[prop_index].position = Vector2.ZERO
|
||||
else:
|
||||
_check(false, "Milo is available for the character overlap scenario")
|
||||
|
||||
# The larger overlap exercises the chooser's bounded scroll area at a small
|
||||
# viewport while retaining the normal stage click and popup input routes.
|
||||
stage.set_mode(SandboxStage.StageMode.EDIT)
|
||||
for extra_index: int in range(5):
|
||||
var extra_prop: PropBlock = spawner.spawn_prop_asset("crate", Vector2.ZERO)
|
||||
if extra_prop == null:
|
||||
_check(false, "crowded chooser prop %d is spawned" % extra_index)
|
||||
continue
|
||||
stage.call("_finalize_placed_node", extra_prop)
|
||||
await _make_prop_rectangle(extra_prop, Color.from_hsv(float(extra_index) / 5.0, 0.55, 0.82))
|
||||
root.size = Vector2i(920, 560)
|
||||
await _wait_frames(8)
|
||||
var stage_characters: Array[StickmanRig] = []
|
||||
for child: Node in (stage.get_node("World") as Node2D).get_children():
|
||||
if child is StickmanRig:
|
||||
stage_characters.append(child as StickmanRig)
|
||||
(child as StickmanRig).visible = false
|
||||
if first_prop != null:
|
||||
selection_after_setup.select_only(first_prop)
|
||||
stage.call("_refresh_prop_settings_card")
|
||||
await _wait_frames(4)
|
||||
var prop_identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
|
||||
var original_prop_name: String = first_prop.stage_name
|
||||
var long_prop_name: String = "Crate with a Very Long Descriptive Stage Name"
|
||||
var long_prop_result: Dictionary = prop_identity.rename(first_prop, long_prop_name, stage.get_node("World") as Node2D)
|
||||
_check(bool(long_prop_result.get("ok", false)), "long prop name is valid for compact-header capture")
|
||||
stage.call("_refresh_prop_settings_card")
|
||||
await _wait_frames(5)
|
||||
var long_prop_view: StagePropSettingsView = stage.get("_prop_settings_view") as StagePropSettingsView
|
||||
var long_prop_card: StageInspectorPanel = long_prop_view.get_card() as StageInspectorPanel
|
||||
var long_prop_title: Label = long_prop_card.find_child("InspectorTitle", true, false) as Label
|
||||
long_prop_card.set_collapsed(true)
|
||||
await _wait_frames(4)
|
||||
_check(long_prop_title != null and long_prop_title.max_lines_visible == 1 and long_prop_title.autowrap_mode == TextServer.AUTOWRAP_OFF, "collapsed prop title stays on one line")
|
||||
_check(long_prop_title != null and long_prop_title.tooltip_text == long_prop_name and long_prop_title.text_overrun_behavior == TextServer.OVERRUN_TRIM_ELLIPSIS, "collapsed prop keeps its full-name tooltip and ellipsis")
|
||||
await _save_capture("prop_inspector_collapsed_long_name_small_viewport")
|
||||
prop_identity.rename(first_prop, original_prop_name, stage.get_node("World") as Node2D)
|
||||
stage.call("_refresh_prop_settings_card")
|
||||
long_prop_card.set_collapsed(false)
|
||||
if character != null:
|
||||
var character_identity_small: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
|
||||
var original_position: Vector2 = character.global_position
|
||||
var original_name: String = character.stage_name
|
||||
var original_scale: Vector2 = character.scale
|
||||
character.scale = Vector2(0.6, 0.6)
|
||||
var canvas_to_world: Transform2D = stage.get_viewport().get_canvas_transform().affine_inverse()
|
||||
character.visible = true
|
||||
character.global_position = canvas_to_world * Vector2(float(root.size.x) * 0.52, 260.0)
|
||||
var long_character_name: String = "Milo with a Very Long Character Name"
|
||||
character_identity_small.rename(character, long_character_name, stage.get_node("World") as Node2D)
|
||||
selection_after_setup.select_only(character)
|
||||
stage.call("_refresh_character_settings_card")
|
||||
await _wait_frames(7)
|
||||
var compact_character_view: StageCharacterSettingsView = stage.get("_character_settings_view") as StageCharacterSettingsView
|
||||
var compact_character_card: StageInspectorPanel = compact_character_view.get_card() as StageInspectorPanel
|
||||
var compact_character_title: Label = compact_character_card.find_child("InspectorTitle", true, false) as Label
|
||||
var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
|
||||
var character_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(character)
|
||||
var rotation_handle: Vector2 = gizmos._rotate_handle_position(character_bounds)
|
||||
_check(character.source_stk_path.ends_with("break.stk"), "colorful Break character is present in the visual review")
|
||||
_check(compact_character_title != null and compact_character_title.max_lines_visible == 1 and compact_character_title.tooltip_text == long_character_name, "compact character header stays one line and retains the full-name tooltip")
|
||||
_check(rotation_handle.y > character_bounds.get_center().y, "rotate handle moves below a character near the top chrome")
|
||||
_check(gizmos.hit_test(rotation_handle) == STAGE_GIZMOS.Handle.ROTATE, "lower rotate handle remains inside its hit target")
|
||||
await _save_capture("break_character_long_name_top_handle_small_viewport")
|
||||
var original_rotation: float = character.global_rotation
|
||||
var rotate_press: Vector2 = _screen_at(stage, rotation_handle)
|
||||
await _send_mouse_motion(rotate_press)
|
||||
await _send_mouse_button(rotate_press, true)
|
||||
await _wait_frames(2)
|
||||
_check(gizmos.is_dragging(), "clicking the lower handle starts rotation through the stage input path")
|
||||
var rotation_target: Vector2 = character_bounds.get_center() + (rotation_handle - character_bounds.get_center()).rotated(deg_to_rad(20.0))
|
||||
await _send_mouse_motion(_screen_at(stage, rotation_target))
|
||||
await _send_mouse_button(_screen_at(stage, rotation_target), false)
|
||||
await _wait_frames(3)
|
||||
_check(absf(angle_difference(character.global_rotation, original_rotation + deg_to_rad(20.0))) < 0.03, "dragging the lower handle rotates the selected character")
|
||||
character.global_rotation = original_rotation
|
||||
character.global_position = original_position
|
||||
character.scale = original_scale
|
||||
character_identity_small.rename(character, original_name, stage.get_node("World") as Node2D)
|
||||
stage.call("_refresh_character_settings_card")
|
||||
for stage_character: StickmanRig in stage_characters:
|
||||
stage_character.visible = true
|
||||
selection_after_setup.select_only(first_prop)
|
||||
stage.call("_refresh_prop_settings_card")
|
||||
center_screen = _screen_at(stage, Vector2.ZERO)
|
||||
selection_after_setup.select_only(first_prop)
|
||||
await _send_click(center_screen)
|
||||
await _wait_frames(5)
|
||||
chooser = stage.get("_objects_here_popup") as PopupPanel
|
||||
_check(chooser != null and chooser.visible, "crowded overlap opens the chooser at a small viewport")
|
||||
if chooser != null:
|
||||
var crowded_rows: VBoxContainer = chooser.find_child("ObjectsHereRows", true, false) as VBoxContainer
|
||||
_check(crowded_rows != null and crowded_rows.get_child_count() == 9, "crowded chooser lists seven props and both visible characters")
|
||||
var popup_rect: Rect2 = Rect2(Vector2(chooser.position), Vector2(chooser.size))
|
||||
_check(popup_rect.position.x >= 0.0 and popup_rect.position.y >= 64.0 and popup_rect.end.x <= float(root.size.x) and popup_rect.end.y <= float(root.size.y) - 38.0, "small viewport popup remains inside safe screen bounds")
|
||||
var crowded_scroll: ScrollContainer = chooser.find_child("ObjectsHereScroll", true, false) as ScrollContainer
|
||||
_check(crowded_scroll != null and crowded_scroll.size.y <= 320.0, "crowded chooser bounds its scroll viewport")
|
||||
await _save_capture("crowded_chooser_small_viewport")
|
||||
if crowded_scroll != null:
|
||||
var scroll_pointer: Vector2 = crowded_scroll.get_screen_position() + crowded_scroll.size * 0.5
|
||||
await _send_mouse_wheel_down(scroll_pointer, chooser.get_window().get_window_id())
|
||||
await _wait_frames(4)
|
||||
_check(crowded_scroll.scroll_vertical > 0, "crowded chooser scrolls through production mouse-wheel input")
|
||||
await _save_capture("crowded_chooser_scrolled_small_viewport")
|
||||
await _send_key(KEY_ESCAPE)
|
||||
await _wait_frames(4)
|
||||
_check(stage.get("_objects_here_popup") == null, "Escape closes the crowded small-viewport chooser")
|
||||
|
||||
if _failures == 0:
|
||||
print("PHASE20A_GUI_CAPTURE PASS: production input/render scenarios; captures saved under %s" % CAPTURE_DIR)
|
||||
else:
|
||||
push_error("PHASE20A_GUI_CAPTURE FAIL: %d assertion(s) failed" % _failures)
|
||||
stage.queue_free()
|
||||
await _wait_frames(2)
|
||||
quit(1 if _failures > 0 else 0)
|
||||
|
||||
|
||||
func _placed_props(stage: SandboxStage) -> Array[PropBlock]:
|
||||
var result: Array[PropBlock] = []
|
||||
var world: Node2D = stage.get_node("World") as Node2D
|
||||
for child: Node in world.get_children():
|
||||
if child is PropBlock:
|
||||
result.append(child as PropBlock)
|
||||
return result
|
||||
|
||||
|
||||
func _placement_prompt(stage: SandboxStage) -> Label:
|
||||
return stage.find_child("PlacementNamePrompt", true, false) as Label
|
||||
|
||||
|
||||
func _make_prop_rectangle(prop: PropBlock, color: Color) -> void:
|
||||
var points: PackedVector2Array = PackedVector2Array([
|
||||
Vector2(-80.0, -60.0),
|
||||
Vector2(80.0, -60.0),
|
||||
Vector2(80.0, 60.0),
|
||||
Vector2(-80.0, 60.0),
|
||||
])
|
||||
var shapes: Array[Dictionary] = [{
|
||||
"shape_type": "polygon",
|
||||
"points": points,
|
||||
"closed": true,
|
||||
"fill_color": color.to_html(false),
|
||||
"outline_color": "#2D2532",
|
||||
"outline_width": 3.0,
|
||||
}]
|
||||
prop.set_custom_shapes(shapes, points)
|
||||
|
||||
|
||||
func _make_prop_outline(prop: PropBlock) -> void:
|
||||
var bars: Array[PackedVector2Array] = [
|
||||
PackedVector2Array([Vector2(-80.0, -60.0), Vector2(80.0, -60.0), Vector2(80.0, -50.0), Vector2(-80.0, -50.0)]),
|
||||
PackedVector2Array([Vector2(70.0, -60.0), Vector2(80.0, -60.0), Vector2(80.0, 60.0), Vector2(70.0, 60.0)]),
|
||||
PackedVector2Array([Vector2(-80.0, 50.0), Vector2(80.0, 50.0), Vector2(80.0, 60.0), Vector2(-80.0, 60.0)]),
|
||||
PackedVector2Array([Vector2(-80.0, -60.0), Vector2(-70.0, -60.0), Vector2(-70.0, 60.0), Vector2(-80.0, 60.0)]),
|
||||
]
|
||||
var shapes: Array[Dictionary] = []
|
||||
for points: PackedVector2Array in bars:
|
||||
shapes.append({
|
||||
"shape_type": "polygon",
|
||||
"points": points,
|
||||
"closed": true,
|
||||
"fill_color": "#9B83C5",
|
||||
"outline_color": "#392C4C",
|
||||
"outline_width": 2.0,
|
||||
})
|
||||
var hull_points: PackedVector2Array = PackedVector2Array([
|
||||
Vector2(-80.0, -60.0), Vector2(80.0, -60.0), Vector2(80.0, 60.0), Vector2(-80.0, 60.0),
|
||||
])
|
||||
prop.set_custom_shapes(shapes, Geometry2D.convex_hull(hull_points))
|
||||
|
||||
|
||||
func _make_blue_door(prop: PropBlock) -> void:
|
||||
var door_points: PackedVector2Array = PackedVector2Array([
|
||||
Vector2(-45.0, -85.0),
|
||||
Vector2(45.0, -85.0),
|
||||
Vector2(45.0, 85.0),
|
||||
Vector2(-45.0, 85.0),
|
||||
])
|
||||
var shapes: Array[Dictionary] = [{
|
||||
"shape_type": "polygon",
|
||||
"points": door_points,
|
||||
"closed": true,
|
||||
"fill_color": "#2878D5",
|
||||
"outline_color": "#173C6B",
|
||||
"outline_width": 5.0,
|
||||
}]
|
||||
var knob_points: PackedVector2Array = PackedVector2Array([
|
||||
Vector2(28.0, -8.0), Vector2(34.0, -14.0), Vector2(40.0, -8.0), Vector2(34.0, -2.0),
|
||||
])
|
||||
shapes.append({
|
||||
"shape_type": "polygon",
|
||||
"points": knob_points,
|
||||
"closed": true,
|
||||
"fill_color": "#F7D45C",
|
||||
"outline_color": "#684F1A",
|
||||
"outline_width": 2.0,
|
||||
})
|
||||
prop.set_custom_shapes(shapes, door_points)
|
||||
|
||||
|
||||
func _find_shared_hit_point(selection: StageSelection, nodes: Array[Node2D]) -> Vector2:
|
||||
if selection == null or nodes.size() < 2:
|
||||
return Vector2.INF
|
||||
var shared_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(nodes[0])
|
||||
for node_index: int in range(1, nodes.size()):
|
||||
shared_bounds = shared_bounds.intersection(STAGE_SELECTION.get_world_aabb(nodes[node_index]))
|
||||
if shared_bounds.size.x <= 0.0 or shared_bounds.size.y <= 0.0:
|
||||
return Vector2.INF
|
||||
for y_index: int in range(1, 10):
|
||||
for x_index: int in range(1, 10):
|
||||
var point: Vector2 = shared_bounds.position + Vector2(shared_bounds.size.x * float(x_index) / 10.0, shared_bounds.size.y * float(y_index) / 10.0)
|
||||
var hits: Array[Node2D] = selection.hit_test_all(point)
|
||||
var all_nodes_hit: bool = true
|
||||
for node: Node2D in nodes:
|
||||
if not hits.has(node):
|
||||
all_nodes_hit = false
|
||||
break
|
||||
if all_nodes_hit:
|
||||
return point
|
||||
return Vector2.INF
|
||||
|
||||
|
||||
func _find_candidate_button_for_name(popup: PopupPanel, candidate_name: String) -> Button:
|
||||
var rows: VBoxContainer = popup.find_child("ObjectsHereRows", true, false) as VBoxContainer
|
||||
if rows == null:
|
||||
return null
|
||||
for row_index: int in rows.get_child_count():
|
||||
var button: Button = rows.get_child(row_index) as Button
|
||||
if button == null:
|
||||
continue
|
||||
for label_node: Node in button.find_children("*", "Label", true, false):
|
||||
var label: Label = label_node as Label
|
||||
if label != null and label.text.contains(candidate_name):
|
||||
return button
|
||||
return null
|
||||
|
||||
|
||||
func _find_button_with_text(parent: Node, text_value: String) -> Button:
|
||||
for child: Node in parent.find_children("*", "Button", true, false):
|
||||
var button: Button = child as Button
|
||||
if button != null and button.text == text_value:
|
||||
return button
|
||||
return null
|
||||
|
||||
|
||||
func _find_label_with_text(parent: Node, text_value: String) -> Label:
|
||||
for child: Node in parent.find_children("*", "Label", true, false):
|
||||
var label: Label = child as Label
|
||||
if label != null and label.text == text_value:
|
||||
return label
|
||||
return null
|
||||
|
||||
|
||||
func _screen_at(stage: SandboxStage, world_position: Vector2) -> Vector2:
|
||||
return stage.get_viewport().get_canvas_transform() * world_position
|
||||
|
||||
|
||||
func _click_control(control: Control) -> void:
|
||||
var click_position: Vector2 = control.get_screen_position() + control.size * 0.5
|
||||
var owner_window: Window = control.get_window()
|
||||
print("PHASE20A_CONTROL_CLICK name=%s rect=%s screen=%s window_id=%d window_pos=%s root_window_id=%d" % [
|
||||
control.name,
|
||||
control.get_global_rect(),
|
||||
control.get_screen_position(),
|
||||
owner_window.get_window_id(),
|
||||
owner_window.position,
|
||||
root.get_window_id(),
|
||||
])
|
||||
await _send_click(click_position, owner_window.get_window_id())
|
||||
|
||||
|
||||
func _send_click(position: Vector2, window_id: int = 0) -> void:
|
||||
await _send_mouse_button(position, true, window_id)
|
||||
await _wait_frames(1)
|
||||
await _send_mouse_button(position, false, window_id)
|
||||
await _wait_frames(2)
|
||||
|
||||
|
||||
func _send_mouse_button(position: Vector2, pressed: bool, window_id: int = 0) -> void:
|
||||
var event: InputEventMouseButton = InputEventMouseButton.new()
|
||||
event.button_index = MOUSE_BUTTON_LEFT
|
||||
event.position = position
|
||||
event.global_position = position
|
||||
event.window_id = window_id
|
||||
event.pressed = pressed
|
||||
Input.parse_input_event(event)
|
||||
await process_frame
|
||||
|
||||
|
||||
func _send_mouse_wheel_down(position: Vector2, window_id: int = 0) -> void:
|
||||
var event: InputEventMouseButton = InputEventMouseButton.new()
|
||||
event.button_index = MOUSE_BUTTON_WHEEL_DOWN
|
||||
event.position = position
|
||||
event.global_position = position
|
||||
event.window_id = window_id
|
||||
event.pressed = true
|
||||
Input.parse_input_event(event)
|
||||
await process_frame
|
||||
|
||||
|
||||
func _send_mouse_motion(position: Vector2, window_id: int = 0) -> void:
|
||||
var event: InputEventMouseMotion = InputEventMouseMotion.new()
|
||||
event.position = position
|
||||
event.global_position = position
|
||||
event.window_id = window_id
|
||||
Input.parse_input_event(event)
|
||||
await process_frame
|
||||
|
||||
|
||||
func _send_key(keycode: Key, with_shift: bool = false, with_ctrl: bool = false) -> void:
|
||||
var event: InputEventKey = InputEventKey.new()
|
||||
event.keycode = keycode
|
||||
event.shift_pressed = with_shift
|
||||
event.ctrl_pressed = with_ctrl
|
||||
event.pressed = true
|
||||
Input.parse_input_event(event)
|
||||
await process_frame
|
||||
event = InputEventKey.new()
|
||||
event.keycode = keycode
|
||||
event.shift_pressed = with_shift
|
||||
event.ctrl_pressed = with_ctrl
|
||||
event.pressed = false
|
||||
Input.parse_input_event(event)
|
||||
await process_frame
|
||||
|
||||
|
||||
func _type_text(text_value: String) -> void:
|
||||
for character_index: int in range(text_value.length()):
|
||||
var character: String = text_value.substr(character_index, 1)
|
||||
var event: InputEventKey = InputEventKey.new()
|
||||
event.keycode = OS.find_keycode_from_string(character.to_upper())
|
||||
event.unicode = text_value.unicode_at(character_index)
|
||||
event.shift_pressed = character != character.to_lower()
|
||||
event.pressed = true
|
||||
Input.parse_input_event(event)
|
||||
await process_frame
|
||||
event = InputEventKey.new()
|
||||
event.keycode = OS.find_keycode_from_string(character.to_upper())
|
||||
event.unicode = text_value.unicode_at(character_index)
|
||||
event.shift_pressed = character != character.to_lower()
|
||||
event.pressed = false
|
||||
Input.parse_input_event(event)
|
||||
await process_frame
|
||||
|
||||
|
||||
func _wait_frames(count: int) -> void:
|
||||
for _index: int in count:
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
|
||||
|
||||
func _save_capture(name: String) -> void:
|
||||
await RenderingServer.frame_post_draw
|
||||
var image: Image = root.get_texture().get_image()
|
||||
var path: String = CAPTURE_DIR.path_join("%s.png" % name)
|
||||
var error: Error = image.save_png(ProjectSettings.globalize_path(path))
|
||||
print("PHASE20A_CAPTURE path=%s size=%s error=%d" % [path, image.get_size(), error])
|
||||
_check(error == OK and image.get_size() == root.size, "capture %s is saved at the expected viewport size" % name)
|
||||
|
||||
|
||||
func _check(condition: bool, label: String) -> void:
|
||||
if condition:
|
||||
print("PHASE20A_CHECK PASS: %s" % label)
|
||||
else:
|
||||
_failures += 1
|
||||
push_error("PHASE20A_CHECK FAIL: %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bpyp47ni2qyf5
|
||||
@@ -0,0 +1,218 @@
|
||||
extends SceneTree
|
||||
|
||||
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
|
||||
const STAGE_DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
|
||||
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
||||
|
||||
var _failures: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run_preview")
|
||||
|
||||
|
||||
func _run_preview() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
root.gui_embed_subwindows = true
|
||||
var stage: SandboxStage = SANDBOX_STAGE.instantiate() as SandboxStage
|
||||
root.add_child(stage)
|
||||
await _wait_frames(10)
|
||||
|
||||
# Keep any existing user recovery copy intact while removing its modal from
|
||||
# this visual-only preview. No recovery action or document edit is performed.
|
||||
var recovery_dialog: ConfirmationDialog = stage.find_child("AutosaveRecoveryDialog", true, false) as ConfirmationDialog
|
||||
if recovery_dialog != null:
|
||||
STAGE_DIALOG_STYLER.hide(recovery_dialog)
|
||||
await _wait_frames(2)
|
||||
|
||||
var spawner: StageSpawner = stage.get("_spawner") as StageSpawner
|
||||
var first: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(-18.0, 0.0))
|
||||
var second: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(18.0, 0.0))
|
||||
_check(first != null and second != null, "overlapping character fixtures are available")
|
||||
if first == null or second == null:
|
||||
quit(1)
|
||||
return
|
||||
first.stage_name = "Jimmy"
|
||||
second.stage_name = "Timmy"
|
||||
var crate: PropBlock = spawner.spawn_prop_asset("crate", Vector2(170.0, 190.0))
|
||||
_check(crate != null, "prop inspector fixture is available")
|
||||
if crate == null:
|
||||
quit(1)
|
||||
return
|
||||
crate.stage_name = "Blue Door"
|
||||
var selection: StageSelection = stage.get("_selection") as StageSelection
|
||||
selection.select_only(crate)
|
||||
await _wait_frames(4)
|
||||
var views: StageModalViews = stage.get("_modal_views") as StageModalViews
|
||||
var ui_layer: CanvasLayer = stage.get_node("UI") as CanvasLayer
|
||||
_check(views != null and ui_layer != null, "stage popup UI is available")
|
||||
if views == null or ui_layer == null:
|
||||
quit(1)
|
||||
return
|
||||
var avoid_rects: Array[Rect2] = [
|
||||
Rect2(Vector2.ZERO, Vector2(1280.0, 72.0)),
|
||||
Rect2(Vector2(0.0, 682.0), Vector2(1280.0, 38.0)),
|
||||
Rect2(Vector2.ZERO, Vector2(120.0, 720.0)),
|
||||
]
|
||||
var prop_view: StagePropSettingsView = stage.get("_prop_settings_view") as StagePropSettingsView
|
||||
if prop_view != null and prop_view.visible:
|
||||
var prop_card: Control = prop_view.get_card()
|
||||
if prop_card != null:
|
||||
avoid_rects.append(prop_card.get_global_rect().grow(8.0))
|
||||
for character: StickmanRig in [first, second]:
|
||||
var world_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(character)
|
||||
if world_bounds.has_area():
|
||||
avoid_rects.append(stage.call("_world_rect_to_viewport_rect", world_bounds).grow(8.0))
|
||||
var click_position: Vector2 = stage.get_viewport().get_canvas_transform() * Vector2.ZERO
|
||||
views.show_objects_here(ui_layer, [first, second], selection.get_selected().duplicate(), Vector2(1280.0, 720.0), click_position, avoid_rects)
|
||||
await _wait_frames(8)
|
||||
var popup: PopupPanel = views.get_objects_here_popup()
|
||||
_check(popup != null and popup.visible, "anchored chooser is visible")
|
||||
if popup != null:
|
||||
var candidate_button: Button = popup.find_child("ObjectCandidate_0", true, false) as Button
|
||||
if candidate_button != null:
|
||||
candidate_button.grab_focus()
|
||||
var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
|
||||
avoid_rects.append(views.get_objects_here_screen_rect().grow(8.0))
|
||||
if gizmos != null:
|
||||
gizmos.set_candidate_preview_obstacles(avoid_rects)
|
||||
gizmos.set_enabled(true)
|
||||
gizmos.set_candidate_preview(first)
|
||||
await _wait_frames(4)
|
||||
if gizmos != null:
|
||||
var name_pill_style: StyleBoxFlat = gizmos.get("_candidate_preview_name_style") as StyleBoxFlat
|
||||
_check(name_pill_style != null and name_pill_style.corner_radius_top_left >= 8, "candidate name label uses rounded pill corners")
|
||||
_check(name_pill_style != null and name_pill_style.border_width_left >= 3, "candidate name pill keeps a clear cyan outline")
|
||||
var shell: StyleBoxFlat = popup.get_theme_stylebox("panel") as StyleBoxFlat
|
||||
_check(shell != null and shell.corner_radius_top_left >= 12, "chooser shell has rounded corners")
|
||||
_check(shell != null and shell.border_width_left >= 2 and shell.shadow_size > 0, "chooser shell has a border and local shadow")
|
||||
var cancel: Button = popup.find_child("CancelObjectsHere", true, false) as Button
|
||||
var cancel_style: StyleBoxFlat = cancel.get_theme_stylebox("normal") as StyleBoxFlat if cancel != null else null
|
||||
_check(cancel != null and cancel.custom_minimum_size.y >= 48, "Cancel has a large target")
|
||||
_check(cancel_style != null and cancel_style.corner_radius_top_left >= 8, "Cancel has rounded secondary styling")
|
||||
|
||||
await RenderingServer.frame_post_draw
|
||||
var capture: Image = root.get_texture().get_image()
|
||||
var capture_names: Array[String] = [
|
||||
"overlap_chooser_popup_style",
|
||||
"character_overlap_preview",
|
||||
"overlap_hover_preview",
|
||||
"overlap_candidate_preview",
|
||||
]
|
||||
for capture_name: String in capture_names:
|
||||
var capture_path: String = "res://tests/phase20a_captures/%s.png" % capture_name
|
||||
var capture_error: Error = capture.save_png(ProjectSettings.globalize_path(capture_path))
|
||||
print("PHASE20A_CAPTURE path=%s size=%s error=%d" % [capture_path, capture.get_size(), capture_error])
|
||||
_check(capture_error == OK, "capture %s is saved" % capture_name)
|
||||
await _verify_overlap_gestures(stage, crate, views, selection)
|
||||
await _wait_frames(2)
|
||||
stage.queue_free()
|
||||
await _wait_frames(2)
|
||||
quit(1 if _failures > 0 else 0)
|
||||
|
||||
|
||||
func _verify_overlap_gestures(stage: SandboxStage, selected_prop: PropBlock, views: StageModalViews, selection: StageSelection) -> void:
|
||||
var spawner: StageSpawner = stage.get("_spawner") as StageSpawner
|
||||
var overlapping_prop: PropBlock = spawner.spawn_prop_asset("crate", selected_prop.global_position) as PropBlock
|
||||
_check(overlapping_prop != null, "overlap gesture fixture is available")
|
||||
if overlapping_prop == null:
|
||||
return
|
||||
stage.call("_finalize_placed_node", overlapping_prop)
|
||||
await _wait_frames(4)
|
||||
var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
|
||||
var canvas: Transform2D = stage.get_viewport().get_canvas_transform()
|
||||
var click_world: Vector2 = selected_prop.global_position
|
||||
var click_screen: Vector2 = canvas * click_world
|
||||
var candidates: Array[Node2D] = selection.hit_test_all(click_world)
|
||||
_check(candidates.has(selected_prop) and candidates.has(overlapping_prop), "gesture fixture has two visible objects at the click point")
|
||||
selection.select_only(selected_prop)
|
||||
await _wait_frames(2)
|
||||
if gizmos != null:
|
||||
gizmos.set_snap_size(0.0)
|
||||
|
||||
await _send_mouse_motion(click_screen)
|
||||
await _send_mouse_button(click_screen, true)
|
||||
_check(stage.get("_objects_here_popup") == null and not gizmos.is_dragging(), "overlap press waits without opening chooser or moving")
|
||||
await _send_mouse_motion(click_screen + Vector2(4.0, 0.0), MOUSE_BUTTON_MASK_LEFT)
|
||||
_check(stage.get("_objects_here_popup") == null and not gizmos.is_dragging(), "sub-threshold movement remains a click candidate")
|
||||
await _send_mouse_button(click_screen + Vector2(4.0, 0.0), false)
|
||||
var click_popup: PopupPanel = stage.get("_objects_here_popup") as PopupPanel
|
||||
_check(click_popup != null and click_popup.visible, "release before the threshold opens the overlap chooser")
|
||||
_check(selection.is_selected(selected_prop), "opening the chooser preserves the prior selection")
|
||||
views.dismiss_objects_here()
|
||||
await _wait_frames(2)
|
||||
|
||||
var start_position: Vector2 = selected_prop.global_position
|
||||
var start_screen: Vector2 = canvas * start_position
|
||||
var end_screen: Vector2 = start_screen + Vector2(24.0, 12.0)
|
||||
var expected_position: Vector2 = canvas.affine_inverse() * end_screen
|
||||
await _send_mouse_motion(start_screen)
|
||||
await _send_mouse_button(start_screen, true)
|
||||
await _send_mouse_motion(end_screen, MOUSE_BUTTON_MASK_LEFT)
|
||||
_check(stage.get("_objects_here_popup") == null, "drag past the threshold never opens the overlap chooser")
|
||||
_check(gizmos.is_dragging(), "drag past the threshold begins moving the selected overlap")
|
||||
_check(selected_prop.global_position.is_equal_approx(expected_position), "selected object follows the overlap drag")
|
||||
_check(overlapping_prop.global_position.is_equal_approx(start_position), "unselected overlapping object stays in place")
|
||||
await _send_mouse_button(end_screen, false)
|
||||
_check(stage.get("_objects_here_popup") == null, "drag release does not reopen the chooser")
|
||||
_check(not gizmos.is_dragging(), "drag release finishes the selected-object move")
|
||||
|
||||
# Verify rotate and resize handles remain ahead of overlap selection.
|
||||
selected_prop.global_position = start_position
|
||||
overlapping_prop.global_position = start_position
|
||||
selection.select_only(selected_prop)
|
||||
await _wait_frames(2)
|
||||
var prop_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(selected_prop)
|
||||
var scale_handle: Vector2 = Vector2(prop_bounds.end.x, prop_bounds.position.y)
|
||||
var scale_screen: Vector2 = canvas * scale_handle
|
||||
await _send_mouse_motion(scale_screen)
|
||||
await _send_mouse_button(scale_screen, true)
|
||||
_check(gizmos.is_dragging() and gizmos._dragging == StageGizmos.Handle.SCALE, "overlapped resize handle starts scaling immediately")
|
||||
_check(stage.get("_objects_here_popup") == null, "resize handle does not open the overlap chooser")
|
||||
await _send_mouse_button(scale_screen, false)
|
||||
_check(not gizmos.is_dragging(), "resize handle release completes without opening chooser")
|
||||
|
||||
selected_prop.global_position = start_position
|
||||
await _wait_frames(2)
|
||||
prop_bounds = STAGE_SELECTION.get_world_aabb(selected_prop)
|
||||
var rotate_handle: Vector2 = gizmos._rotate_handle_position(prop_bounds)
|
||||
var rotate_screen: Vector2 = canvas * rotate_handle
|
||||
await _send_mouse_motion(rotate_screen)
|
||||
await _send_mouse_button(rotate_screen, true)
|
||||
_check(gizmos.is_dragging() and gizmos._dragging == StageGizmos.Handle.ROTATE, "overlapped rotate handle starts rotation immediately")
|
||||
_check(stage.get("_objects_here_popup") == null, "rotate handle does not open the overlap chooser")
|
||||
await _send_mouse_button(rotate_screen, false)
|
||||
_check(not gizmos.is_dragging(), "rotate handle release completes without opening chooser")
|
||||
|
||||
|
||||
func _send_mouse_button(position: Vector2, pressed: bool) -> void:
|
||||
var event: InputEventMouseButton = InputEventMouseButton.new()
|
||||
event.button_index = MOUSE_BUTTON_LEFT
|
||||
event.position = position
|
||||
event.global_position = position
|
||||
event.pressed = pressed
|
||||
Input.parse_input_event(event)
|
||||
await process_frame
|
||||
|
||||
|
||||
func _send_mouse_motion(position: Vector2, button_mask: int = 0) -> void:
|
||||
var event: InputEventMouseMotion = InputEventMouseMotion.new()
|
||||
event.position = position
|
||||
event.global_position = position
|
||||
event.button_mask = button_mask
|
||||
Input.parse_input_event(event)
|
||||
await process_frame
|
||||
|
||||
|
||||
func _wait_frames(count: int) -> void:
|
||||
for _index: int in count:
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
|
||||
|
||||
func _check(condition: bool, label: String) -> void:
|
||||
if condition:
|
||||
print("PHASE20A_CHECK PASS: %s" % label)
|
||||
else:
|
||||
_failures += 1
|
||||
push_error("PHASE20A_CHECK FAIL: %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhrp36vg8or4m
|
||||
@@ -57,8 +57,13 @@ func _test_moved_stage_file_paths() -> void:
|
||||
func _test_legacy_stage_round_trip() -> void:
|
||||
_cleanup()
|
||||
var legacy_data: Dictionary = {
|
||||
"version": STAGE_SCENE_IO.FILE_VERSION,
|
||||
# Keep this fixture at the pre-identity schema so the path test also
|
||||
# exercises the existing legacy-stage migration into the current format.
|
||||
"version": "1.7",
|
||||
"scene_name": "Legacy Asset Paths",
|
||||
"sky_id": "sky_neutral",
|
||||
"background_kind": "sky",
|
||||
"plain_color": "#F5EFE0",
|
||||
"camera": {"position": {"x": 0.0, "y": -400.0}, "zoom": 0.5},
|
||||
"objects": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
extends SceneTree
|
||||
|
||||
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
||||
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
|
||||
|
||||
var _checks: int = 0
|
||||
var _failures: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var world := Node2D.new()
|
||||
root.add_child(world)
|
||||
var camera := Camera2D.new()
|
||||
root.add_child(camera)
|
||||
var selection: StageSelection = STAGE_SELECTION.new(world, camera) as StageSelection
|
||||
var triangle: PropBlock = _make_prop("Triangle")
|
||||
world.add_child(triangle)
|
||||
var triangle_points := PackedVector2Array([Vector2(-50.0, -50.0), Vector2(50.0, -50.0), Vector2(0.0, 50.0)])
|
||||
triangle.set_custom_shapes([{
|
||||
"shape_type": "polygon",
|
||||
"points": triangle_points,
|
||||
"closed": true,
|
||||
"fill_color": "#55AAFFFF",
|
||||
"outline_color": "#111111FF",
|
||||
"outline_width": 2.0,
|
||||
}], triangle_points)
|
||||
await process_frame
|
||||
_check(selection.hit_test_all(Vector2.ZERO).has(triangle), "visible polygon interior is selectable")
|
||||
_check(not selection.hit_test_all(Vector2(40.0, 40.0)).has(triangle), "empty point inside polygon AABB is rejected")
|
||||
|
||||
var front: PropBlock = _make_prop("Front")
|
||||
front.position = Vector2.ZERO
|
||||
front.z_index = 2
|
||||
world.add_child(front)
|
||||
front.set_custom_shapes([{
|
||||
"shape_type": "polygon",
|
||||
"points": triangle_points,
|
||||
"closed": true,
|
||||
"fill_color": "#FFAA55FF",
|
||||
"outline_color": "#111111FF",
|
||||
"outline_width": 2.0,
|
||||
}], triangle_points)
|
||||
await process_frame
|
||||
var overlap_hits: Array[Node2D] = selection.hit_test_all(Vector2.ZERO)
|
||||
_check(overlap_hits.size() == 2 and overlap_hits[0] == front and overlap_hits[1] == triangle, "overlap candidates preserve front-to-back draw order")
|
||||
_check(not selection.hit_test_all(Vector2(40.0, 40.0)).has(front), "nearby overlapping prop is excluded outside its visible shape")
|
||||
|
||||
triangle.position = Vector2(200.0, 0.0)
|
||||
triangle.rotation = PI * 0.5
|
||||
triangle.scale = Vector2(2.0, 1.0)
|
||||
await process_frame
|
||||
var transformed_center: Vector2 = triangle.to_global(Vector2.ZERO)
|
||||
var transformed_empty: Vector2 = triangle.to_global(Vector2(40.0, 40.0))
|
||||
_check(selection.hit_test_all(transformed_center).has(triangle), "polygon hit testing follows object translation, rotation, and scale")
|
||||
_check(not selection.hit_test_all(transformed_empty).has(triangle), "transformed empty polygon area remains unselectable")
|
||||
|
||||
var line_prop: PropBlock = PROP_BLOCK.new() as PropBlock
|
||||
line_prop.name = "Thin Line"
|
||||
line_prop.position = Vector2(400.0, 100.0)
|
||||
world.add_child(line_prop)
|
||||
var line_points := PackedVector2Array([Vector2(-20.0, 0.0), Vector2(20.0, 0.0)])
|
||||
var line_shape: Array[Dictionary] = [{
|
||||
"shape_type": "line",
|
||||
"points": line_points,
|
||||
"closed": false,
|
||||
"fill_color": "#00000000",
|
||||
"outline_color": "#FFFFFFFF",
|
||||
"outline_width": 2.0,
|
||||
}]
|
||||
line_prop.set_custom_shapes(line_shape, PackedVector2Array())
|
||||
await process_frame
|
||||
_check(selection.hit_test_all(Vector2(400.0, 105.0)).has(line_prop), "thin visible line remains selectable within the screen-space touch tolerance")
|
||||
_check(not selection.hit_test_all(Vector2(400.0, 109.0)).has(line_prop), "thin line rejects points beyond the touch tolerance")
|
||||
camera.zoom = Vector2(2.0, 2.0)
|
||||
_check(selection.hit_test_all(Vector2(400.0, 104.0)).has(line_prop), "line touch tolerance scales with camera zoom")
|
||||
_check(not selection.hit_test_all(Vector2(400.0, 105.0)).has(line_prop), "zoomed line does not retain an oversized world-space tolerance")
|
||||
camera.zoom = Vector2.ONE
|
||||
line_prop.modulate.a = 0.0
|
||||
_check(not selection.hit_test_all(Vector2(400.0, 100.0)).has(line_prop), "fully transparent line is not selectable")
|
||||
line_prop.modulate.a = 1.0
|
||||
line_prop.visible = false
|
||||
_check(not selection.hit_test_all(Vector2(400.0, 100.0)).has(line_prop), "hidden line is not selectable")
|
||||
|
||||
print("PHASE 20A HIT TESTING: %d checks, %d failures" % [_checks, _failures])
|
||||
world.queue_free()
|
||||
camera.queue_free()
|
||||
await process_frame
|
||||
quit(1 if _failures > 0 else 0)
|
||||
|
||||
|
||||
func _make_prop(prop_name: String) -> PropBlock:
|
||||
var prop := PROP_BLOCK.new() as PropBlock
|
||||
prop.name = prop_name
|
||||
return prop
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print("PASS: " + message)
|
||||
else:
|
||||
_failures += 1
|
||||
push_error("FAIL: " + message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpyppduges4mi
|
||||
@@ -0,0 +1,318 @@
|
||||
extends SceneTree
|
||||
|
||||
const STAGE_OBJECT_IDENTITY := preload("res://scripts/stage/stage_object_identity.gd")
|
||||
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
|
||||
const STAGE_SCENE_ADAPTER := preload("res://scripts/editors/stage/stage_scene_adapter.gd")
|
||||
const STAGE_SPAWNER := preload("res://scripts/stage/stage_spawner.gd")
|
||||
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
|
||||
const MISSING_STICKMAN := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
|
||||
const MISSING_PROP := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||
|
||||
var _checks: int = 0
|
||||
var _failures: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_test_scene_identity_and_rename()
|
||||
_test_current_format_validation()
|
||||
_test_legacy_migrations()
|
||||
await _test_adapter_identity_round_trip()
|
||||
print("PHASE 20A IDENTITY: %d checks, %d failures" % [_checks, _failures])
|
||||
quit(1 if _failures > 0 else 0)
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print("PASS: " + message)
|
||||
else:
|
||||
_failures += 1
|
||||
push_error("FAIL: " + message)
|
||||
|
||||
|
||||
func _test_scene_identity_and_rename() -> void:
|
||||
print("\n--- Scene identity and naming ---")
|
||||
var world := Node2D.new()
|
||||
var identity: StageObjectIdentity = STAGE_OBJECT_IDENTITY.new() as StageObjectIdentity
|
||||
var milo: StickmanRig = STICKMAN_RIG.new() as StickmanRig
|
||||
var milo_two: StickmanRig = STICKMAN_RIG.new() as StickmanRig
|
||||
var prop: PropBlock = PROP_BLOCK.new() as PropBlock
|
||||
world.add_child(milo)
|
||||
world.add_child(milo_two)
|
||||
world.add_child(prop)
|
||||
|
||||
identity.assign_new(milo, "Milo", world)
|
||||
identity.assign_new(milo_two, "Milo", world)
|
||||
identity.assign_new(prop, "milo", world)
|
||||
_check(not identity.get_id(milo).is_empty(), "first placed character gets a persistent ID")
|
||||
_check(identity.get_id(milo_two) != identity.get_id(milo), "duplicate character gets a fresh persistent ID")
|
||||
_check(identity.get_id(prop) != identity.get_id(milo) and identity.get_id(prop) != identity.get_id(milo_two), "prop ID is unique across both object types")
|
||||
_check(identity.get_name(milo) == "Milo", "first stage name keeps its familiar base")
|
||||
_check(identity.get_name(milo_two) == "Milo 2", "duplicate stage name receives the smallest free suffix")
|
||||
_check(identity.get_name(prop) == "milo 3", "character and prop names share case-insensitive uniqueness")
|
||||
|
||||
var capitalization: Dictionary = identity.validate_name(" MILO ", world, milo)
|
||||
_check(bool(capitalization.get("ok", false)) and String(capitalization.get("name", "")) == "MILO", "capitalization-only rename trims outer spaces and is valid")
|
||||
var original_id: String = identity.get_id(milo)
|
||||
var duplicate: Dictionary = identity.rename(milo, "mIlO 2", world)
|
||||
_check(not bool(duplicate.get("ok", true)) and duplicate.get("reason", &"") == &"duplicate", "duplicate name is rejected case-insensitively")
|
||||
var suggestion: String = String(duplicate.get("suggestion", ""))
|
||||
var suggestion_check: Dictionary = identity.validate_name(suggestion, world, milo)
|
||||
_check(not suggestion.is_empty() and bool(suggestion_check.get("ok", false)), "duplicate name supplies an available suggestion")
|
||||
var blank: Dictionary = identity.rename(milo, " ", world)
|
||||
_check(not bool(blank.get("ok", true)) and blank.get("reason", &"") == &"blank", "blank name is rejected")
|
||||
_check(identity.get_name(milo) == "Milo", "invalid rename leaves the current name unchanged")
|
||||
var renamed: Dictionary = identity.rename(milo, " MAX ", world)
|
||||
_check(bool(renamed.get("ok", false)) and identity.get_name(milo) == "MAX", "valid rename stores the trimmed scene name")
|
||||
_check(identity.get_id(milo) == original_id, "renaming preserves the persistent object ID")
|
||||
|
||||
world.remove_child(milo_two)
|
||||
milo_two.free()
|
||||
identity.rename(milo, "Milo", world)
|
||||
var next_milo: StickmanRig = STICKMAN_RIG.new() as StickmanRig
|
||||
world.add_child(next_milo)
|
||||
identity.assign_new(next_milo, "Milo", world)
|
||||
_check(identity.get_name(next_milo) == "Milo 2", "deleting an instance releases its stage name")
|
||||
world.free()
|
||||
|
||||
|
||||
func _test_current_format_validation() -> void:
|
||||
print("\n--- Current scene identity validation ---")
|
||||
var valid: Dictionary = _current_document([
|
||||
_object("stickman_1", "stickman", "char-1", "Milo"),
|
||||
_object("prop_1", "prop", "prop-1", "Blue Door"),
|
||||
])
|
||||
_check(bool(STAGE_SCENE_IO._validate_root(valid).get("ok", false)), "unique current-format character and prop identities validate")
|
||||
|
||||
var duplicate_ids: Dictionary = valid.duplicate(true)
|
||||
(duplicate_ids["objects"][1] as Dictionary)["stage_object_id"] = "char-1"
|
||||
_check(not bool(STAGE_SCENE_IO._validate_root(duplicate_ids).get("ok", true)), "current-format duplicate IDs across types are rejected")
|
||||
var empty_id: Dictionary = valid.duplicate(true)
|
||||
(empty_id["objects"][0] as Dictionary)["stage_object_id"] = " "
|
||||
_check(not bool(STAGE_SCENE_IO._validate_root(empty_id).get("ok", true)), "current-format empty IDs are rejected")
|
||||
var duplicate_names: Dictionary = valid.duplicate(true)
|
||||
(_other_named_object(duplicate_names))["stage_name"] = "mIlO"
|
||||
_check(not bool(STAGE_SCENE_IO._validate_root(duplicate_names).get("ok", true)), "current-format duplicate names ignoring capitalization are rejected")
|
||||
var whitespace_name: Dictionary = valid.duplicate(true)
|
||||
(_empty_names_object(whitespace_name))["stage_name"] = " Milo "
|
||||
_check(not bool(STAGE_SCENE_IO._validate_root(whitespace_name).get("ok", true)), "current-format names with outer whitespace are rejected")
|
||||
var missing_name: Dictionary = valid.duplicate(true)
|
||||
(_empty_names_object(missing_name)).erase("stage_name")
|
||||
_check(not bool(STAGE_SCENE_IO._validate_root(missing_name).get("ok", true)), "current-format objects missing a name are rejected")
|
||||
|
||||
|
||||
func _test_legacy_migrations() -> void:
|
||||
print("\n--- Legacy scene migrations ---")
|
||||
for version: String in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7"]:
|
||||
var source: Dictionary = _legacy_document(version)
|
||||
var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(source)
|
||||
var migrated_again: Dictionary = STAGE_SCENE_IO._migrate_to_current(source.duplicate(true))
|
||||
_check(String(migrated.get("version", "")) == STAGE_SCENE_IO.FILE_VERSION, "%s migrates to current version" % version)
|
||||
var objects: Array = migrated.get("objects", [])
|
||||
var repeat_objects: Array = migrated_again.get("objects", [])
|
||||
_check(objects.size() == 3 and repeat_objects.size() == 3, "%s preserves all named object entries" % version)
|
||||
var stage_ids: Dictionary = {}
|
||||
var stage_names: Dictionary = {}
|
||||
for object_index: int in objects.size():
|
||||
var entry: Dictionary = objects[object_index] as Dictionary
|
||||
var repeat_entry: Dictionary = repeat_objects[object_index] as Dictionary
|
||||
var stage_id: String = String(entry.get("stage_object_id", ""))
|
||||
var stage_name: String = String(entry.get("stage_name", ""))
|
||||
_check(not stage_id.is_empty(), "%s object %d receives a persistent ID" % [version, object_index + 1])
|
||||
_check(not stage_name.strip_edges().is_empty(), "%s object %d receives a nonempty name" % [version, object_index + 1])
|
||||
_check(not stage_ids.has(stage_id), "%s object %d receives a unique ID" % [version, object_index + 1])
|
||||
_check(not stage_names.has(stage_name.to_lower()), "%s object %d receives a case-insensitively unique name" % [version, object_index + 1])
|
||||
stage_ids[stage_id] = true
|
||||
stage_names[stage_name.to_lower()] = true
|
||||
_check(stage_id == String(repeat_entry.get("stage_object_id", "")) and stage_name == String(repeat_entry.get("stage_name", "")), "%s migration is deterministic for object %d" % [version, object_index + 1])
|
||||
var prop_entry: Dictionary = objects[2] as Dictionary
|
||||
var expected_prop_id: String = "prop_1" if version == "1.0" else "legacy-prop-id"
|
||||
_check(String(prop_entry.get("stage_object_id", "")) == expected_prop_id, "%s preserves the existing prop ID" % version)
|
||||
var validation: Dictionary = STAGE_SCENE_IO._validate_root(migrated)
|
||||
_check(bool(validation.get("ok", false)), "%s migrated document passes current-format validation" % version)
|
||||
|
||||
var collision_source: Dictionary = _legacy_document("1.7")
|
||||
var collision_objects: Array = collision_source["objects"]
|
||||
(collision_objects[2] as Dictionary)["stage_object_id"] = "stickman:stickman_1"
|
||||
var collision_migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(collision_source)
|
||||
var collision_validation: Dictionary = STAGE_SCENE_IO._validate_root(collision_migrated)
|
||||
_check(String(((collision_migrated["objects"] as Array)[2] as Dictionary).get("stage_object_id", "")) == "stickman:stickman_1", "a saved prop ID is retained when a legacy character ID would collide")
|
||||
_check(bool(collision_validation.get("ok", false)), "legacy ID collision is resolved without producing invalid current-format data")
|
||||
|
||||
|
||||
func _test_adapter_identity_round_trip() -> void:
|
||||
print("\n--- Production adapter identity round-trip ---")
|
||||
var source_world := Node2D.new()
|
||||
root.add_child(source_world)
|
||||
var source_spawner: StageSpawner = STAGE_SPAWNER.new(source_world) as StageSpawner
|
||||
var adapter: StageSceneAdapter = STAGE_SCENE_ADAPTER.new() as StageSceneAdapter
|
||||
var real_character: StickmanRig = source_spawner.spawn_stickman_asset("res://content/stickmen/basic.stk", Vector2(-180.0, 0.0))
|
||||
var real_prop: PropBlock = source_spawner.spawn_prop_asset("crate", Vector2(180.0, 0.0))
|
||||
_check(real_character != null and real_prop != null, "production spawner creates real character and prop fixtures")
|
||||
if real_character == null or real_prop == null:
|
||||
source_world.queue_free()
|
||||
await process_frame
|
||||
return
|
||||
real_character.stage_object_id = "stage-char-real"
|
||||
real_character.stage_name = "Milo"
|
||||
real_prop.stage_object_id = "stage-prop-real"
|
||||
real_prop.stage_name = "Blue Crate"
|
||||
var missing_character_entry: Dictionary = {
|
||||
"file_id": "legacy-missing-character",
|
||||
"type": "stickman",
|
||||
"stage_object_id": "stage-char-missing",
|
||||
"stage_name": "Ava",
|
||||
"stk_path": "res://content/stickmen/phase20a_missing_character.stk",
|
||||
"position": {"x": -360.0, "y": 0.0},
|
||||
"rotation": 0.0,
|
||||
"action_queue": [],
|
||||
}
|
||||
var missing_character: Node2D = MISSING_STICKMAN.new() as Node2D
|
||||
missing_character.configure(missing_character_entry)
|
||||
source_world.add_child(missing_character)
|
||||
var missing_prop_entry: Dictionary = {
|
||||
"file_id": "legacy-missing-prop",
|
||||
"type": "prop",
|
||||
"stage_object_id": "stage-prop-missing",
|
||||
"stage_name": "Secret Door",
|
||||
"asset_id": "res://content/props/phase20a_missing_prop.prop",
|
||||
"position": {"x": 360.0, "y": 0.0},
|
||||
"rotation": 0.0,
|
||||
"scale": {"x": 1.0, "y": 1.0},
|
||||
"use_spots": [],
|
||||
"next_use_spot_number": 1,
|
||||
}
|
||||
var missing_prop: Node2D = MISSING_PROP.new() as Node2D
|
||||
missing_prop.configure(missing_prop_entry)
|
||||
source_world.add_child(missing_prop)
|
||||
await process_frame
|
||||
var document: Dictionary = adapter.collect(
|
||||
source_world,
|
||||
{},
|
||||
[],
|
||||
{"position": Vector2(32.0, -64.0), "zoom": 0.75},
|
||||
{"scene_name": "Identity Round Trip"}
|
||||
)
|
||||
var serialized: Dictionary = {}
|
||||
for entry_value: Variant in document.get("objects", []):
|
||||
if entry_value is Dictionary:
|
||||
var entry: Dictionary = entry_value
|
||||
serialized[String(entry.get("stage_object_id", ""))] = entry
|
||||
for identity_pair in [
|
||||
["stage-char-real", "Milo"],
|
||||
["stage-prop-real", "Blue Crate"],
|
||||
["stage-char-missing", "Ava"],
|
||||
["stage-prop-missing", "Secret Door"],
|
||||
]:
|
||||
var entry: Dictionary = serialized.get(identity_pair[0], {}) as Dictionary
|
||||
_check(String(entry.get("stage_name", "")) == identity_pair[1], "collect preserves name '%s' for %s" % [identity_pair[1], identity_pair[0]])
|
||||
_check(not entry.is_empty(), "collect preserves identity %s" % identity_pair[0])
|
||||
# write_file_safe uses absolute-path rename operations after writing its temporary file.
|
||||
var save_path: String = ProjectSettings.globalize_path("res://tests/.phase20a_identity_round_trip.scene")
|
||||
var write_result: Dictionary = STAGE_SCENE_IO.write_file_safe(save_path, document)
|
||||
_check(bool(write_result.get("ok", false)), "scene writer saves the collected identity document: %s" % String(write_result.get("error", "")))
|
||||
var read_result: Dictionary = STAGE_SCENE_IO.read_file(save_path)
|
||||
_check(bool(read_result.get("ok", false)), "scene reader loads the saved identity document")
|
||||
var disk_document: Dictionary = read_result.get("data", {}) as Dictionary
|
||||
var apply_document: Dictionary = disk_document if not disk_document.is_empty() else document
|
||||
var disk_objects_by_id: Dictionary = {}
|
||||
for disk_entry_value: Variant in apply_document.get("objects", []):
|
||||
if disk_entry_value is Dictionary:
|
||||
var disk_entry: Dictionary = disk_entry_value as Dictionary
|
||||
disk_objects_by_id[String(disk_entry.get("stage_object_id", ""))] = disk_entry
|
||||
for identity_pair in [
|
||||
["stage-char-real", "Milo"],
|
||||
["stage-prop-real", "Blue Crate"],
|
||||
["stage-char-missing", "Ava"],
|
||||
["stage-prop-missing", "Secret Door"],
|
||||
]:
|
||||
var disk_entry: Dictionary = disk_objects_by_id.get(identity_pair[0], {}) as Dictionary
|
||||
_check(String(disk_entry.get("stage_name", "")) == identity_pair[1], "disk round-trip retains name '%s'" % identity_pair[1])
|
||||
_check(not disk_entry.is_empty(), "disk round-trip retains ID %s" % identity_pair[0])
|
||||
|
||||
var target_world := Node2D.new()
|
||||
root.add_child(target_world)
|
||||
var target_spawner: StageSpawner = STAGE_SPAWNER.new(target_world) as StageSpawner
|
||||
var applied: Dictionary = adapter.apply(apply_document, target_world, target_spawner)
|
||||
var restored_by_id: Dictionary = {}
|
||||
for node_value: Variant in applied.get("loaded_nodes", []):
|
||||
var node: Node2D = node_value as Node2D
|
||||
if node != null:
|
||||
restored_by_id[String(node.get("stage_object_id"))] = node
|
||||
for identity_pair in [
|
||||
["stage-char-real", "Milo"],
|
||||
["stage-prop-real", "Blue Crate"],
|
||||
["stage-char-missing", "Ava"],
|
||||
["stage-prop-missing", "Secret Door"],
|
||||
]:
|
||||
var restored: Node2D = restored_by_id.get(identity_pair[0]) as Node2D
|
||||
_check(restored != null and String(restored.get("stage_name")) == identity_pair[1], "apply restores ID and name for %s" % identity_pair[0])
|
||||
_check(restored_by_id["stage-char-missing"].get_script() == MISSING_STICKMAN, "missing character asset restores as its editable placeholder")
|
||||
_check(restored_by_id["stage-prop-missing"].get_script() == MISSING_PROP, "missing custom prop restores as its editable placeholder")
|
||||
var adapter_warnings: Array = applied.get("warnings", []) as Array
|
||||
_check(adapter_warnings.is_empty(), "adapter reports no malformed-object warnings during identity round-trip")
|
||||
target_world.queue_free()
|
||||
source_world.queue_free()
|
||||
await process_frame
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(save_path))
|
||||
|
||||
|
||||
func _empty_names_object(document: Dictionary) -> Dictionary:
|
||||
return (document["objects"] as Array)[0] as Dictionary
|
||||
|
||||
|
||||
func _other_named_object(document: Dictionary) -> Dictionary:
|
||||
return (document["objects"] as Array)[1] as Dictionary
|
||||
|
||||
|
||||
func _object(file_id: String, object_type: String, stage_id: String, stage_name: String) -> Dictionary:
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"type": object_type,
|
||||
"stage_object_id": stage_id,
|
||||
"stage_name": stage_name,
|
||||
"position": {"x": 0.0, "y": 0.0},
|
||||
"rotation": 0.0,
|
||||
}
|
||||
|
||||
|
||||
func _current_document(objects: Array) -> Dictionary:
|
||||
return {
|
||||
"version": STAGE_SCENE_IO.FILE_VERSION,
|
||||
"scene_name": "Identity Test",
|
||||
"sky_id": "sky_neutral",
|
||||
"background_kind": "sky",
|
||||
"plain_color": "#F5EFE0",
|
||||
"camera": {"position": {"x": 0.0, "y": 0.0}, "zoom": 0.5},
|
||||
"objects": objects,
|
||||
"event_rules": [],
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
|
||||
func _legacy_document(version: String) -> Dictionary:
|
||||
var prop: Dictionary = _object("prop_1", "prop", "legacy-prop-id", "Ignored")
|
||||
prop["asset_id"] = "res://content/props/briefcase.prop"
|
||||
prop["scale"] = {"x": 1.0, "y": 1.0}
|
||||
prop["material_preset"] = 0
|
||||
if version == "1.0":
|
||||
prop.erase("stage_object_id")
|
||||
prop.erase("stage_name")
|
||||
var first_character: Dictionary = _object("stickman_1", "stickman", "", "")
|
||||
first_character["stk_path"] = "res://content/stickmen/test.stk"
|
||||
first_character["action_queue"] = []
|
||||
first_character.erase("stage_object_id")
|
||||
first_character.erase("stage_name")
|
||||
var second_character: Dictionary = _object("stickman_2", "stickman", "", "")
|
||||
second_character["stk_path"] = "res://content/stickmen/test.stk"
|
||||
second_character["action_queue"] = []
|
||||
second_character.erase("stage_object_id")
|
||||
second_character.erase("stage_name")
|
||||
var document: Dictionary = _current_document([first_character, second_character, prop])
|
||||
document["version"] = version
|
||||
return document
|
||||
@@ -0,0 +1 @@
|
||||
uid://cphn6t44v67pk
|
||||
@@ -116,11 +116,12 @@ func _test_stage_prop_drag_and_rotation() -> void:
|
||||
|
||||
var rect: Rect2 = STAGE_SELECTION.get_world_aabb(prop)
|
||||
var center: Vector2 = rect.get_center()
|
||||
var radius: float = stage._gizmos._rotate_ring_radius(rect)
|
||||
press.position = canvas * (center + Vector2(radius, 0.0))
|
||||
var rotate_handle: Vector2 = stage._gizmos._rotate_handle_position(rect)
|
||||
var starting_vector: Vector2 = rotate_handle - center
|
||||
press.position = canvas * rotate_handle
|
||||
stage._handle_world_click(press)
|
||||
_check(stage._gizmos.is_dragging(), "prop rotation ring begins a rotate drag")
|
||||
motion.position = canvas * (center + Vector2(0.0, radius))
|
||||
_check(stage._gizmos.is_dragging(), "prop rotation handle begins a rotate drag")
|
||||
motion.position = canvas * (center + starting_vector.rotated(PI * 0.5))
|
||||
motion.relative = motion.position - press.position
|
||||
stage._input(motion)
|
||||
release.position = motion.position
|
||||
|
||||