Implement Phase 17a Prop Roles UI/UX Specification and associated functionality
- Added UI/UX specification for prop roles and set down mechanics in phase 17a. - Created `PropStageRoles` script to manage normalized behavior for props, including depth, motion, and collision settings. - Introduced `StagePhysicsPolicy` for shared physics configurations related to props and actors. - Developed comprehensive tests for prop roles, including default settings, legacy migration, serialization, and runtime behavior. - Ensured proper handling of set down actions, including clearance and orientation preservation.
This commit is contained in:
@@ -119,7 +119,7 @@ func _test_custom_prop_lifecycle() -> void:
|
||||
var authored_scale: Vector2 = prop.scale
|
||||
_check(prop.freeze, "custom prop is frozen in EDIT")
|
||||
stage.set_mode(stage.StageMode.PLAY)
|
||||
_check(not prop.freeze, "custom prop unfreezes in PLAY")
|
||||
_check(prop.freeze and prop.runtime_role == PROP_BLOCK.RuntimeRole.START_STILL, "new custom prop starts still in PLAY")
|
||||
var play_scene: Dictionary = stage._collect_scene_data()
|
||||
var play_entry: Dictionary = (play_scene.get("objects", []) as Array)[0]
|
||||
var saved_scale: Vector2 = Vector2(float((play_entry.get("scale", {}) as Dictionary).get("x", 0.0)), float((play_entry.get("scale", {}) as Dictionary).get("y", 0.0)))
|
||||
|
||||
@@ -113,6 +113,7 @@ func _run() -> void:
|
||||
prop.polygon_points = PackedVector2Array([Vector2(-10, -10), Vector2(10, -10), Vector2(10, 10), Vector2(-10, 10)])
|
||||
prop.freeze = true
|
||||
root.add_child(prop)
|
||||
prop.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_STILL)
|
||||
await process_frame
|
||||
var gizmos: StageGizmos = STAGE_GIZMOS.new()
|
||||
root.add_child(gizmos)
|
||||
@@ -128,6 +129,7 @@ func _run() -> void:
|
||||
gizmos.drag_to(prop.global_position + (corner - prop.global_position) * 2.0)
|
||||
gizmos.end_drag()
|
||||
_check(is_equal_approx(prop.scale.x / initial_scale.x, 2.0) and is_equal_approx(prop.scale.y / initial_scale.y, 2.0), "corner drag applies uniform scale")
|
||||
prop.begin_play_instance_scale()
|
||||
await physics_frame
|
||||
var space: PhysicsDirectSpaceState2D = root.get_world_2d().direct_space_state
|
||||
var polygon_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
||||
@@ -138,8 +140,7 @@ func _run() -> void:
|
||||
var polygon_outside_hits: Array[Dictionary] = space.intersect_point(polygon_outside)
|
||||
_check(not polygon_inside_hits.is_empty() and polygon_outside_hits.is_empty(), "scaled polygon collision matches its visual extent")
|
||||
prop.global_rotation = deg_to_rad(20.0)
|
||||
prop.begin_play_instance_scale()
|
||||
prop.freeze = false
|
||||
prop.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_PHYSICS)
|
||||
for frame: int in 3:
|
||||
await physics_frame
|
||||
var rotated_local_inside: Vector2 = Vector2(19.0, 0.0).rotated(prop.global_rotation)
|
||||
@@ -154,6 +155,7 @@ func _run() -> void:
|
||||
circle.scale = Vector2(2.0, 2.0)
|
||||
circle.freeze = true
|
||||
root.add_child(circle)
|
||||
circle.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_STILL)
|
||||
await physics_frame
|
||||
var circle_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
||||
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
|
||||
@@ -161,7 +163,7 @@ func _run() -> void:
|
||||
circle_outside.position = circle.global_position + Vector2(21.0, 0.0)
|
||||
_check(not space.intersect_point(circle_inside).is_empty() and space.intersect_point(circle_outside).is_empty(), "scaled circle collision matches its visual extent")
|
||||
circle.begin_play_instance_scale()
|
||||
circle.freeze = false
|
||||
circle.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_PHYSICS)
|
||||
for frame: int in 3:
|
||||
await physics_frame
|
||||
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
|
||||
@@ -171,6 +173,7 @@ func _run() -> void:
|
||||
circle.end_play_instance_scale()
|
||||
circle.scale = Vector2(2.0, 1.0)
|
||||
circle.begin_play_instance_scale()
|
||||
circle.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_PHYSICS)
|
||||
await physics_frame
|
||||
var ellipse_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
|
||||
ellipse_inside.position = circle.global_position + Vector2(0.0, 9.0)
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
extends SceneTree
|
||||
|
||||
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
|
||||
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
|
||||
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||
const PROP_BLOCK_SCRIPT := preload("res://scripts/props/prop_block.gd")
|
||||
const PROP_INTERACTOR_SCRIPT := preload("res://scripts/characters/stickman/stickman_prop_interactor.gd")
|
||||
const STICKMAN_RIG_SCENE := preload("res://scenes/characters/stickman/stickman_rig.tscn")
|
||||
|
||||
var _checks: int = 0
|
||||
var _failures: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_test_role_defaults_and_matrix()
|
||||
_test_legacy_migration()
|
||||
_test_set_down_serialization()
|
||||
_test_unknown_preview()
|
||||
await _test_runtime_profiles()
|
||||
await _test_start_still_support_contact()
|
||||
await _test_set_down_clearance_and_release()
|
||||
await _test_set_down_preserves_pickup_orientation()
|
||||
await _test_set_down_low_reach_pose()
|
||||
await _test_set_down_prop_follows_approaching_actor()
|
||||
print("----------------------------------------")
|
||||
if _failures == 0:
|
||||
print("RESULT: ALL PASSED (%d checks)" % _checks)
|
||||
quit(0)
|
||||
else:
|
||||
print("RESULT: %d FAILURE(S) out of %d checks" % [_failures, _checks])
|
||||
quit(1)
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print("PASS: " + message)
|
||||
else:
|
||||
_failures += 1
|
||||
push_error("Phase 17a check failed: " + message)
|
||||
|
||||
|
||||
func _test_role_defaults_and_matrix() -> void:
|
||||
var roles: Dictionary = PROP_STAGE_ROLES.new_placement(false)
|
||||
_expect(roles["depth"] == PROP_STAGE_ROLES.DEPTH_BEHIND, "new props start behind characters")
|
||||
_expect(roles["start_motion"] == PROP_STAGE_ROLES.START_STILL, "new props start still")
|
||||
_expect(not bool(roles["can_pick_up"]), "props without a grip are not pickable")
|
||||
_expect(bool(roles["released_blocks_stickmen"]) and bool(roles["released_bumps_other_props"]), "released contacts default On")
|
||||
var table_roles: Dictionary = roles.duplicate(true)
|
||||
table_roles["holds_other_props"] = true
|
||||
var pot_roles: Dictionary = PROP_STAGE_ROLES.new_placement(true)
|
||||
_expect(PROP_STAGE_ROLES.props_should_collide(table_roles, 1, pot_roles, 2), "start still support catches a physics prop")
|
||||
table_roles["holds_other_props"] = false
|
||||
_expect(not PROP_STAGE_ROLES.props_should_collide(table_roles, 1, pot_roles, 2), "start still prop without support capability does not catch props")
|
||||
var released_roles: Dictionary = pot_roles.duplicate(true)
|
||||
released_roles["released_bumps_other_props"] = false
|
||||
_expect(not PROP_STAGE_ROLES.props_should_collide(released_roles, 4, table_roles, 2), "released other prop Off wins over dynamic contacts")
|
||||
_expect(PROP_STAGE_ROLES.props_should_collide(pot_roles, 2, table_roles, 2), "two physics props can collide")
|
||||
_expect(not PROP_STAGE_ROLES.props_should_collide(pot_roles, 3, table_roles, 2), "held props never collide")
|
||||
|
||||
|
||||
func _test_legacy_migration() -> void:
|
||||
var legacy := {
|
||||
"version": "1.0",
|
||||
"objects": [
|
||||
{"file_id": "character_a", "type": "stickman"},
|
||||
{"file_id": "prop_a", "type": "prop"},
|
||||
{"file_id": "character_b", "type": "stickman"},
|
||||
],
|
||||
}
|
||||
var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(legacy)
|
||||
var prop_entry: Dictionary = migrated["objects"][1]
|
||||
var roles: Dictionary = prop_entry["roles"]
|
||||
_expect(migrated["version"] == "1.2", "old stage version migrates to 1.2")
|
||||
_expect(prop_entry["stage_object_id"] == "prop_a", "1.0 prop IDs migrate from file IDs")
|
||||
_expect(String(roles["start_motion"]) == PROP_STAGE_ROLES.START_PHYSICS, "legacy prop keeps dynamic start behavior")
|
||||
_expect(bool(roles["blocks_stickmen"]) and bool(roles["legacy_draw_order"]), "legacy prop keeps collisions and draw order")
|
||||
var current_incomplete := {"version": "1.2", "objects": [{"file_id": "p", "type": "prop", "stage_object_id": "p"}]}
|
||||
var repaired: Dictionary = STAGE_SCENE_IO._migrate_to_current(current_incomplete)
|
||||
_expect(bool(repaired["objects"][0]["roles"]["legacy_draw_order"]), "1.2 prop missing roles receives legacy behavior")
|
||||
var invalid := {"objects": [{"file_id": "p", "type": "prop", "stage_object_id": "p", "roles": {"depth": 5}}]}
|
||||
_expect(not bool(STAGE_SCENE_IO._validate_root(invalid)["ok"]), "malformed present role data is rejected")
|
||||
|
||||
|
||||
func _test_set_down_serialization() -> void:
|
||||
var position := Vector2(420.0, -260.0)
|
||||
var encoded_queue: Dictionary = STAGE_SCENE_IO.encode_queue([{"type": "set_down_prop", "target_position": position}])
|
||||
var queue_entries: Array = encoded_queue["actions"]
|
||||
var decoded_queue: Dictionary = STAGE_SCENE_IO.decode_queue(queue_entries)
|
||||
var queue_action: Dictionary = decoded_queue["actions"][0]
|
||||
_expect(queue_action["type"] == "set_down_prop" and queue_action["target_position"] == position, "Set Down queue target round-trips")
|
||||
var flat_action := {"type": "set_down_prop", "target_position": position}
|
||||
var rule_action: Dictionary = ACTION_REGISTRY.to_rule_action(flat_action, 7)
|
||||
var rules: Array[Dictionary] = [{
|
||||
"id": 1,
|
||||
"trigger": {"type": "speech_finished", "source": 7, "target": -1, "params": {}},
|
||||
"actions": [rule_action],
|
||||
}]
|
||||
var runtime_to_file := {7: "actor_file"}
|
||||
var encoded_rules: Dictionary = STAGE_SCENE_IO.encode_rules(rules, runtime_to_file)
|
||||
var file_to_runtime := {"actor_file": 7}
|
||||
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(encoded_rules["rules"], file_to_runtime, {})
|
||||
var decoded_action: Dictionary = decoded_rules["rules"][0]["actions"][0]
|
||||
_expect(decoded_action["type"] == "set_down_prop" and decoded_action["params"]["target_position"] == position, "Set Down rule target round-trips")
|
||||
_expect(String(ACTION_REGISTRY.summarize(flat_action)).contains("Set Down Prop"), "Set Down queue summary is available")
|
||||
|
||||
|
||||
func _test_unknown_preview() -> void:
|
||||
var interactor: Node = PROP_INTERACTOR_SCRIPT.new()
|
||||
root.add_child(interactor)
|
||||
var empty_prefix: Array[Dictionary] = []
|
||||
var preview: Dictionary = interactor.call("preview_set_down", Vector2(30.0, 60.0), empty_prefix)
|
||||
_expect(not bool(preview.get("known", true)), "unknown held prop preview remains conditional")
|
||||
_expect(String(preview.get("reason", "")) == "Prop depends on earlier actions", "unknown preview shows the conditional cue")
|
||||
interactor.queue_free()
|
||||
|
||||
|
||||
func _test_runtime_profiles() -> void:
|
||||
var prop: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||
root.add_child(prop)
|
||||
await process_frame
|
||||
var roles: Dictionary = PROP_STAGE_ROLES.new_placement(true)
|
||||
roles["blocks_stickmen"] = true
|
||||
roles["released_blocks_stickmen"] = false
|
||||
prop.set_stage_roles(roles)
|
||||
prop.apply_runtime_role(PropBlock.RuntimeRole.START_STILL)
|
||||
_expect(prop.freeze and prop.runtime_role == PropBlock.RuntimeRole.START_STILL, "start still profile freezes the body")
|
||||
_expect((prop.collision_layer & STAGE_PHYSICS.PROP_LAYER) != 0, "start still prop keeps its physical shape active")
|
||||
prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
|
||||
_expect(prop.freeze and prop.collision_layer == 0 and prop.collision_mask == 0, "held profile removes physics contacts")
|
||||
prop.apply_runtime_role(PropBlock.RuntimeRole.RELEASED)
|
||||
_expect(not prop.freeze and prop.runtime_role == PropBlock.RuntimeRole.RELEASED, "released profile wakes a dynamic body")
|
||||
_expect((prop.collision_layer & STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER) == 0, "released actor collision setting is independent of initial blocking")
|
||||
prop.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _test_start_still_support_contact() -> void:
|
||||
var world := Node2D.new()
|
||||
root.add_child(world)
|
||||
var support: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||
support.position = Vector2(0.0, 100.0)
|
||||
support.polygon_points = PackedVector2Array([Vector2(-60.0, -10.0), Vector2(60.0, -10.0), Vector2(60.0, 10.0), Vector2(-60.0, 10.0)])
|
||||
world.add_child(support)
|
||||
await process_frame
|
||||
var support_roles: Dictionary = PROP_STAGE_ROLES.new_placement(false)
|
||||
support_roles["holds_other_props"] = true
|
||||
support.set_stage_roles(support_roles)
|
||||
support.apply_runtime_role(PropBlock.RuntimeRole.START_STILL)
|
||||
var falling: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||
falling.position = Vector2(0.0, 55.0)
|
||||
falling.polygon_points = PackedVector2Array([Vector2(-10.0, -10.0), Vector2(10.0, -10.0), Vector2(10.0, 10.0), Vector2(-10.0, 10.0)])
|
||||
world.add_child(falling)
|
||||
await process_frame
|
||||
var falling_roles: Dictionary = PROP_STAGE_ROLES.new_placement(false)
|
||||
falling_roles["start_motion"] = PROP_STAGE_ROLES.START_PHYSICS
|
||||
falling.set_stage_roles(falling_roles)
|
||||
falling.apply_runtime_role(PropBlock.RuntimeRole.START_PHYSICS)
|
||||
var contact: Dictionary = {"other": false}
|
||||
falling.collided.connect(func(other: Node, _normal: Vector2, _position: Vector2) -> void:
|
||||
if other == support:
|
||||
contact["other"] = true
|
||||
)
|
||||
for _frame: int in 45:
|
||||
await physics_frame
|
||||
_expect(support.freeze and support.freeze_mode == RigidBody2D.FREEZE_MODE_STATIC, "start still support remains a static frozen collider")
|
||||
_expect(bool(contact["other"]) and falling.get_colliding_bodies().has(support), "dynamic prop makes an ordinary body contact with the frozen support")
|
||||
_expect(falling.global_position.y >= 78.0 and falling.global_position.y <= 82.0, "dynamic prop rests on the frozen support geometry")
|
||||
world.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _test_set_down_clearance_and_release() -> void:
|
||||
var world := Node2D.new()
|
||||
root.add_child(world)
|
||||
var support: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||
support.position = Vector2(0.0, 100.0)
|
||||
support.polygon_points = PackedVector2Array([Vector2(-100.0, -50.0), Vector2(100.0, -50.0), Vector2(100.0, 50.0), Vector2(-100.0, 50.0)])
|
||||
world.add_child(support)
|
||||
await process_frame
|
||||
var support_roles: Dictionary = PROP_STAGE_ROLES.new_placement(false)
|
||||
support_roles["holds_other_props"] = true
|
||||
support.set_stage_roles(support_roles)
|
||||
support.apply_runtime_role(PropBlock.RuntimeRole.START_STILL)
|
||||
var held: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||
held.polygon_points = PackedVector2Array([Vector2(-10.0, -10.0), Vector2(10.0, -10.0), Vector2(10.0, 10.0), Vector2(-10.0, 10.0)])
|
||||
world.add_child(held)
|
||||
await process_frame
|
||||
var held_roles: Dictionary = PROP_STAGE_ROLES.new_placement(true)
|
||||
held.set_stage_roles(held_roles)
|
||||
held.apply_runtime_role(PropBlock.RuntimeRole.HELD)
|
||||
var interactor: Node = PROP_INTERACTOR_SCRIPT.new()
|
||||
world.add_child(interactor)
|
||||
await physics_frame
|
||||
var requested := Vector2(0.0, 120.0)
|
||||
var resolved: Dictionary = interactor.call("_resolve_set_down_release_transform", held, requested)
|
||||
_expect(bool(resolved.get("valid", false)), "Set Down raises a point embedded in a support prop")
|
||||
var release_transform: Transform2D = resolved.get("release_transform", Transform2D.IDENTITY)
|
||||
_expect(float(resolved.get("clearance_lift", 0.0)) > 50.0 and release_transform.origin.y < requested.y, "support clearance moves only the release origin upward")
|
||||
_expect(is_equal_approx(release_transform.origin.x, requested.x), "support clearance preserves the clicked horizontal point")
|
||||
var above_support: Vector2 = Vector2(0.0, 25.0)
|
||||
var unchanged: Dictionary = interactor.call("_resolve_set_down_release_transform", held, above_support)
|
||||
var unchanged_transform: Transform2D = unchanged.get("release_transform", Transform2D.IDENTITY)
|
||||
_expect(bool(unchanged.get("valid", false)) and unchanged_transform.origin.is_equal_approx(above_support), "a clear point above the support stays exactly where clicked")
|
||||
var open_air := Vector2(300.0, -200.0)
|
||||
var open_air_result: Dictionary = interactor.call("_resolve_set_down_release_transform", held, open_air)
|
||||
var open_air_transform: Transform2D = open_air_result.get("release_transform", Transform2D.IDENTITY)
|
||||
_expect(bool(open_air_result.get("valid", false)) and open_air_transform.origin.is_equal_approx(open_air), "an open-air release stays exactly where clicked")
|
||||
_expect(held.collision_layer == 0 and held.collision_mask == 0, "Set Down keeps the object non-collidable while held")
|
||||
if bool(resolved.get("valid", false)):
|
||||
held.global_transform = release_transform
|
||||
held.apply_runtime_role(PropBlock.RuntimeRole.RELEASED)
|
||||
held.sleeping = false
|
||||
for _frame: int in 45:
|
||||
await physics_frame
|
||||
_expect(held.runtime_role == PropBlock.RuntimeRole.RELEASED and (held.collision_layer & STAGE_PHYSICS.PROP_LAYER) != 0, "release enables the configured prop collisions")
|
||||
_expect(held.get_colliding_bodies().has(support), "released object falls onto the support")
|
||||
_expect(absf(held.global_position.y - 40.0) <= 3.0, "released object settles on top of the crate")
|
||||
interactor.queue_free()
|
||||
world.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _test_set_down_low_reach_pose() -> void:
|
||||
var rig := STICKMAN_RIG_SCENE.instantiate() as Node2D
|
||||
root.add_child(rig)
|
||||
await physics_frame
|
||||
var interactor := rig.get_node(NodePath("RigComponents/PropInteractor"))
|
||||
var torso_marker := rig.get_node(NodePath("IK_Targets/Torso")) as Marker2D
|
||||
var upper_leg := rig.get_node(NodePath("Skeleton2D/Torso/RightUpperLeg")) as Bone2D
|
||||
var start_position := torso_marker.position
|
||||
var start_rotation := torso_marker.rotation
|
||||
var start_leg_rotation := upper_leg.rotation
|
||||
interactor.set("_set_down_facing_left", false)
|
||||
interactor.call("_prepare_set_down_crouch", rig.to_global(Vector2(60.0, 430.0)))
|
||||
interactor.call("_update_pickup_crouch", 0.5)
|
||||
await physics_frame
|
||||
_expect(torso_marker.position.y > start_position.y and torso_marker.rotation != start_rotation, "low Set Down reach lowers and leans the torso smoothly")
|
||||
_expect(absf(wrapf(upper_leg.rotation - start_leg_rotation, -PI, PI)) > 0.01, "the lowered torso lets leg IK bend a knee")
|
||||
interactor.call("_update_pickup_crouch", 0.0)
|
||||
await physics_frame
|
||||
_expect(torso_marker.position.is_equal_approx(start_position), "Set Down body recovery returns the torso to its starting pose")
|
||||
rig.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _test_set_down_preserves_pickup_orientation() -> void:
|
||||
var world := Node2D.new()
|
||||
root.add_child(world)
|
||||
var prop: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||
world.add_child(prop)
|
||||
await process_frame
|
||||
prop.global_transform = Transform2D(PI * 0.5, Vector2(0.0, -100.0))
|
||||
prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(true))
|
||||
prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
|
||||
var pickup_transform := prop.global_transform
|
||||
var interactor: Node = PROP_INTERACTOR_SCRIPT.new()
|
||||
world.add_child(interactor)
|
||||
interactor.set("_held_prop", prop)
|
||||
interactor.set("_pickup_transform", pickup_transform)
|
||||
interactor.set("_pickup_transform_valid", true)
|
||||
prop.global_transform = Transform2D(0.0, Vector2(0.0, -100.0))
|
||||
var resolved: Dictionary = interactor.call("_resolve_set_down_release_transform", prop, Vector2(1000.0, -300.0))
|
||||
var resolved_transform: Transform2D = resolved.get("release_transform", Transform2D.IDENTITY)
|
||||
_expect(bool(resolved.get("valid", false)), "orientation-preserving Set Down target remains valid")
|
||||
_expect(absf(wrapf(resolved_transform.get_rotation() - pickup_transform.get_rotation(), -PI, PI)) < 0.001, "Set Down restores the prop's pickup orientation")
|
||||
interactor.set("_held_anchor", {"position": Vector2(20.0, 0.0)})
|
||||
interactor.set("_set_down_facing_left", false)
|
||||
interactor.set("_set_down_reach_start_transform", Transform2D(PI, Vector2.ZERO))
|
||||
var hand_position := Vector2(80.0, 120.0)
|
||||
var eased_transform: Transform2D = interactor.call("_set_down_reach_attachment_transform", hand_position, 1.0)
|
||||
_expect(absf(wrapf(eased_transform.get_rotation() - pickup_transform.get_rotation(), -PI, PI)) < 0.001, "Set Down reach smoothly returns the prop to its pickup rotation")
|
||||
_expect((eased_transform * Vector2(20.0, 0.0)).distance_to(hand_position) < 0.001, "restored prop orientation keeps its grip anchor at the hand")
|
||||
interactor.queue_free()
|
||||
world.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _test_set_down_prop_follows_approaching_actor() -> void:
|
||||
var rig := STICKMAN_RIG_SCENE.instantiate() as Node2D
|
||||
root.add_child(rig)
|
||||
await physics_frame
|
||||
var prop: PropBlock = PROP_BLOCK_SCRIPT.new()
|
||||
prop.polygon_points = PackedVector2Array([Vector2(-20.0, -20.0), Vector2(20.0, -20.0), Vector2(20.0, 20.0), Vector2(-20.0, 20.0)])
|
||||
prop.position = Vector2(1400.0, 900.0)
|
||||
root.add_child(prop)
|
||||
await process_frame
|
||||
prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(true))
|
||||
prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
|
||||
var interactor := rig.get_node(NodePath("RigComponents/PropInteractor"))
|
||||
interactor.set("_held_prop", prop)
|
||||
interactor.set("_held_anchor", {"position": Vector2.ZERO})
|
||||
interactor.set("_phase", PROP_INTERACTOR_SCRIPT.Phase.SET_DOWN_APPROACHING)
|
||||
var before := prop.global_position
|
||||
interactor.call("tick", 0.016)
|
||||
_expect(prop.global_position.distance_to(before) > 500.0, "a held prop follows the hand while the actor walks to Set Down")
|
||||
_expect(prop.runtime_role == PropBlock.RuntimeRole.HELD and prop.collision_layer == 0 and prop.collision_mask == 0, "the following prop remains collision-free during the approach")
|
||||
interactor.set("_held_prop", null)
|
||||
interactor.set("_phase", PROP_INTERACTOR_SCRIPT.Phase.IDLE)
|
||||
prop.queue_free()
|
||||
rig.queue_free()
|
||||
await process_frame
|
||||
@@ -0,0 +1 @@
|
||||
uid://ewdgod63htwq
|
||||
@@ -1,6 +1,7 @@
|
||||
extends SceneTree
|
||||
|
||||
const STAGE_SCENE := preload("res://scenes/stage/sandbox_stage.tscn")
|
||||
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
@@ -23,7 +24,7 @@ func _run() -> void:
|
||||
_test_collision_proxy_geometry(rig)
|
||||
await _test_collision_proxy_physics_alignment(rig)
|
||||
_test_reactions(rig)
|
||||
await _test_direct_restores_animated_pose(stage, rig)
|
||||
await _test_direct_pause_and_edit_restore(stage, rig)
|
||||
_test_ghost_collision(stage)
|
||||
await _test_stage_contact_routing(stage, rig)
|
||||
_test_ragdoll_transitions(rig)
|
||||
@@ -54,7 +55,7 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
|
||||
_check(body.name == "CollisionBody", "%s proxy uses the standard name" % part_name)
|
||||
_check(not body.sync_to_physics, "%s proxy uses explicit transform synchronization" % part_name)
|
||||
_check(body.top_level, "%s proxy is isolated from RemoteTransform2D parent updates" % part_name)
|
||||
_check(body.collision_layer == 1 and body.collision_mask == 1, "%s proxy starts enabled" % part_name)
|
||||
_check(body.collision_layer == STAGE_PHYSICS.ACTOR_LAYER and body.collision_mask == STAGE_PHYSICS.actor_mask(), "%s proxy starts with the actor policy" % part_name)
|
||||
_check(body.get_meta("stickman_rig", null) == rig, "%s proxy stores its rig" % part_name)
|
||||
_check(String(body.get_meta("part_name", "")) == part_name, "%s proxy stores its part name" % part_name)
|
||||
|
||||
@@ -90,9 +91,9 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
|
||||
empty_visual.free()
|
||||
|
||||
rig.set_collision_bodies_enabled(false)
|
||||
_check(_all_proxy_layers_equal(rig, 0), "public collision hook disables every proxy")
|
||||
_check(_all_proxy_layers_equal(rig, 0, 0), "public collision hook disables every proxy")
|
||||
rig.set_collision_bodies_enabled(true)
|
||||
_check(_all_proxy_layers_equal(rig, 1), "public collision hook re-enables every proxy")
|
||||
_check(_all_proxy_layers_equal(rig, STAGE_PHYSICS.ACTOR_LAYER, STAGE_PHYSICS.actor_mask()), "public collision hook re-enables every proxy with the actor policy")
|
||||
|
||||
var sizes_before := _proxy_shape_sizes(rig)
|
||||
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
|
||||
@@ -181,7 +182,7 @@ func _test_reactions(rig: StickmanRig) -> void:
|
||||
_check(is_zero_approx(angle_difference(base_rotation, lower_bone.rotation)), "expired reaction restores its captured base rotation")
|
||||
|
||||
|
||||
func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> void:
|
||||
func _test_direct_pause_and_edit_restore(stage: Node2D, rig: StickmanRig) -> void:
|
||||
var authored: Dictionary = stage._authored[rig.get_instance_id()]
|
||||
var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
|
||||
var authored_markers := ragdoll.get_spawn_pose()
|
||||
@@ -194,18 +195,37 @@ func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> voi
|
||||
torso_marker.rotation = PI
|
||||
var head_marker := rig.get_node("IK_Targets/Head") as Marker2D
|
||||
head_marker.position += Vector2(120.0, 80.0)
|
||||
var direct_position: Vector2 = rig.global_position
|
||||
var direct_rotation: float = rig.global_rotation
|
||||
var direct_facing: int = rig.get_facing_profile()
|
||||
var direct_markers: Dictionary = {}
|
||||
for marker_name: String in StickmanRagdoll.STAND_POSE:
|
||||
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
|
||||
direct_markers[marker_name] = {"pos": marker.position, "rot": marker.rotation}
|
||||
stage.set_mode(stage.StageMode.DIRECT)
|
||||
for _frame in 4:
|
||||
await physics_frame
|
||||
await process_frame
|
||||
_check(rig.global_position.distance_to(authored["position"] as Vector2) < 0.01, "PLAY to DIRECT restores the authored stickman position")
|
||||
_check(absf(angle_difference(float(authored["rotation"]), rig.global_rotation)) < 0.001, "PLAY to DIRECT restores the authored stickman rotation")
|
||||
_check(rig.get_facing_profile() == int(authored["facing_profile"]), "PLAY to DIRECT restores the authored facing profile")
|
||||
_check(rig.global_position.distance_to(direct_position) < 0.01, "PLAY to DIRECT preserves the live stickman position while paused")
|
||||
_check(absf(angle_difference(direct_rotation, rig.global_rotation)) < 0.001, "PLAY to DIRECT preserves the live stickman rotation while paused")
|
||||
_check(rig.get_facing_profile() == direct_facing, "PLAY to DIRECT preserves the live facing profile while paused")
|
||||
for marker_name: String in StickmanRagdoll.STAND_POSE:
|
||||
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
|
||||
var paused_pose: Dictionary = direct_markers[marker_name]
|
||||
_check(marker.position.distance_to(paused_pose["pos"] as Vector2) < 0.01, "%s marker stays in its live pose while DIRECT is paused" % marker_name)
|
||||
_check(absf(angle_difference(float(paused_pose["rot"]), marker.rotation)) < 0.001, "%s marker keeps its live rotation while DIRECT is paused" % marker_name)
|
||||
stage.set_mode(stage.StageMode.EDIT)
|
||||
for _frame in 4:
|
||||
await physics_frame
|
||||
await process_frame
|
||||
_check(rig.global_position.distance_to(authored["position"] as Vector2) < 0.01, "EDIT/STOP reset restores the authored stickman position")
|
||||
_check(absf(angle_difference(float(authored["rotation"]), rig.global_rotation)) < 0.001, "EDIT/STOP reset restores the authored stickman rotation")
|
||||
_check(rig.get_facing_profile() == int(authored["facing_profile"]), "EDIT/STOP reset restores the authored facing profile")
|
||||
for marker_name: String in StickmanRagdoll.STAND_POSE:
|
||||
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
|
||||
var expected: Dictionary = authored_markers[marker_name]
|
||||
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to its authored spawn pose after PLAY" % marker_name)
|
||||
_check(absf(angle_difference(torso_marker.rotation, float((authored_markers["Torso"] as Dictionary)["rot"]))) < 0.001, "torso marker restores its authored rotation after PLAY")
|
||||
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to its authored pose after EDIT/STOP reset" % marker_name)
|
||||
_check(absf(angle_difference(torso_marker.rotation, float((authored_markers["Torso"] as Dictionary)["rot"]))) < 0.001, "EDIT/STOP reset restores the authored torso marker rotation")
|
||||
|
||||
|
||||
func _test_ghost_collision(stage: Node2D) -> void:
|
||||
@@ -213,12 +233,14 @@ func _test_ghost_collision(stage: Node2D) -> void:
|
||||
if ghost == null:
|
||||
return
|
||||
stage._configure_ghost_collision(ghost)
|
||||
_check(_all_proxy_layers_equal(ghost, 0), "stickman placement ghosts have no collision")
|
||||
_check(_all_proxy_layers_equal(ghost, 0, 0), "stickman placement ghosts have no collision")
|
||||
ghost.queue_free()
|
||||
|
||||
|
||||
func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
|
||||
stage.set_mode(stage.StageMode.PLAY)
|
||||
for _frame: int in 3:
|
||||
await physics_frame
|
||||
var prop := PropBlock.new()
|
||||
prop.position = Vector2(50.0, 25.0)
|
||||
prop.linear_velocity = Vector2(400.0, 0.0)
|
||||
@@ -332,26 +354,26 @@ func _test_ragdoll_transitions(rig: StickmanRig) -> void:
|
||||
_check(not locomotion.impact_reactions.is_empty(), "animated rig accepts a reaction before ragdoll")
|
||||
rig.set_ragdoll(true)
|
||||
_check(rig.state == StickmanRig.RigState.RAGDOLL, "rig enters ragdoll")
|
||||
_check(_all_proxy_layers_equal(rig, 0), "ragdoll disables animated collision proxies")
|
||||
_check(_all_proxy_layers_equal(rig, 0, 0), "ragdoll disables animated collision proxies")
|
||||
_check(locomotion.impact_reactions.is_empty(), "ragdoll entry clears reactions")
|
||||
|
||||
rig.request_recovery()
|
||||
_check(rig.state == StickmanRig.RigState.RECOVERING, "recovery request starts the stand-up state")
|
||||
_check(_all_proxy_layers_equal(rig, 1), "collision proxies stay enabled throughout recovery")
|
||||
_check(_all_proxy_layers_equal(rig, STAGE_PHYSICS.ACTOR_LAYER, STAGE_PHYSICS.actor_mask()), "collision proxies stay enabled throughout recovery")
|
||||
rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0)
|
||||
_check(locomotion.impact_reactions.is_empty(), "recovering rig ignores visual reactions")
|
||||
|
||||
rig.set_ragdoll(true)
|
||||
_check(rig.state == StickmanRig.RigState.RAGDOLL, "recovery can be interrupted by ragdoll")
|
||||
_check(_all_proxy_layers_equal(rig, 0), "recovery interruption disables proxies again")
|
||||
_check(_all_proxy_layers_equal(rig, 0, 0), "recovery interruption disables proxies again")
|
||||
rig.snap_to_standing()
|
||||
_check(rig.state == StickmanRig.RigState.ANIMATED, "snap returns the rig to animated")
|
||||
_check(_all_proxy_layers_equal(rig, 1), "snap restores animated collision proxies")
|
||||
_check(_all_proxy_layers_equal(rig, STAGE_PHYSICS.ACTOR_LAYER, STAGE_PHYSICS.actor_mask()), "snap restores animated collision proxies")
|
||||
|
||||
|
||||
func _all_proxy_layers_equal(rig: StickmanRig, expected: int) -> bool:
|
||||
func _all_proxy_layers_equal(rig: StickmanRig, expected_layer: int, expected_mask: int) -> bool:
|
||||
for body: AnimatableBody2D in rig.get_locomotion().collision_bodies.values():
|
||||
if body.collision_layer != expected or body.collision_mask != expected:
|
||||
if body.collision_layer != expected_layer or body.collision_mask != expected_mask:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
Reference in New Issue
Block a user