Implement collision bodies, physics events, and hit reactions for StickmanRig

- Added a new specification for collision bodies, detailing the implementation requirements and corrections needed in the StickmanRig, PropBlock, and SandboxStage scripts.
- Created a comprehensive test suite for collision bodies, including tests for proxy geometry, physics alignment, reaction handling, ghost collision, and stage contact routing.
- Ensured that the collision system correctly handles ragdoll transitions and maintains the integrity of existing event rules.
- Introduced new methods in StickmanRig for managing collision bodies and applying reactions based on impact direction and speed.
- Updated PropBlock to emit signals for collisions and separations, preserving the order of events for stickman and prop interactions.
This commit is contained in:
2026-09-13 10:31:41 -04:00
parent 04e0f1bea2
commit ce6bcecd00
12 changed files with 1698 additions and 58 deletions
+77 -29
View File
@@ -419,7 +419,6 @@ func _physics_process(_delta: float) -> void:
_restore_authored_state()
if current_mode == StageMode.PLAY:
_update_area_entry()
_update_stickman_prop_collision()
# ---------------------------------------------------------------------------
# Input
@@ -682,11 +681,14 @@ func _enter_play_mode() -> void:
func _save_object_state(node: Node2D) -> void:
_authored[node.get_instance_id()] = {
var entry := {
"node": node,
"position": node.global_position,
"rotation": node.global_rotation,
}
if node is STICKMAN_RIG:
entry["facing_profile"] = (node as STICKMAN_RIG).get_facing_profile()
_authored[node.get_instance_id()] = entry
func _clear_object_state(node: Node2D) -> void:
@@ -702,6 +704,8 @@ func _restore_authored_state() -> void:
continue
node.global_position = entry.get("position", node.global_position)
node.global_rotation = entry.get("rotation", node.global_rotation)
if node is STICKMAN_RIG and entry.has("facing_profile"):
(node as STICKMAN_RIG).set_facing_profile(int(entry["facing_profile"]))
if node is RigidBody2D:
var body := node as RigidBody2D
body.linear_velocity = Vector2.ZERO
@@ -753,10 +757,12 @@ func _finalize_placed_node(node: Node2D) -> void:
rig.arrived.connect(_on_rig_arrived.bind(rig))
rig.action_finished.connect(_on_rig_action_finished.bind(rig))
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
rig.state_changed.connect(_on_rig_state_changed.bind(rig))
_director_visuals.mark_dirty()
if node is PropBlock:
var prop := node as PropBlock
prop.collided.connect(_on_prop_collided.bind(prop))
prop.separated.connect(_on_prop_separated.bind(prop))
func _spawn_ghost() -> void:
@@ -797,6 +803,7 @@ func _configure_ghost_collision(ghost: Node2D) -> void:
var anim := rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
if anim != null:
anim.stop()
rig.set_collision_bodies_enabled(false)
func _free_ghost() -> void:
@@ -1099,6 +1106,8 @@ func delete_selected() -> void:
if node is TerrainBlock:
nav_changed = true
_clear_object_state(node)
var deleted_ids: Array[int] = [node.get_instance_id()]
_purge_collision_pairs_for_ids(deleted_ids)
node.queue_free()
_rebuild_grid_cells()
_cleanup_rules_for_nodes(selected)
@@ -2399,8 +2408,72 @@ func _on_rig_speech_finished(rig: StickmanRig) -> void:
_handle_event({ "type": "speech_finished", "source": rig, "target": null, "position": rig.global_position, "action": {} })
func _on_prop_collided(other: Node, prop: PropBlock) -> void:
_handle_event({ "type": "collided", "source": prop, "target": other, "position": prop.global_position, "action": {} })
func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: PropBlock) -> void:
if other == null or not is_instance_valid(other):
return
if bool(other.get_meta("stickman_ragdoll_body", false)):
return
var has_rig_metadata := other.has_meta("stickman_rig") or other.has_meta("part_name")
var rig := other.get_meta("stickman_rig") as StickmanRig if other.has_meta("stickman_rig") else null
var part := String(other.get_meta("part_name")) if other.has_meta("part_name") else ""
if rig == null or not is_instance_valid(rig) or part.is_empty():
if has_rig_metadata:
return
_handle_event({ "type": "collided", "source": prop, "target": other, "position": position, "action": {} })
return
if rig.state == StickmanRig.RigState.RAGDOLL:
return
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var pair: Dictionary = _collision_pairs.get(key, {
"rig_id": rig.get_instance_id(),
"prop_id": prop.get_instance_id(),
"bodies": {},
})
var bodies: Dictionary = pair.get("bodies", {})
var body_id := other.get_instance_id()
if bodies.has(body_id):
return
var first_contact := bodies.is_empty()
bodies[body_id] = true
pair["bodies"] = bodies
_collision_pairs[key] = pair
rig.apply_collision_reaction(part, normal, prop.linear_velocity.length(), position)
if first_contact:
_handle_event({ "type": "collided", "source": rig, "target": prop, "position": position, "action": {} })
func _on_prop_separated(other: Node, prop: PropBlock) -> void:
if other == null or not is_instance_valid(other):
return
if not other.has_meta("stickman_rig"):
return
var rig := other.get_meta("stickman_rig") as StickmanRig
if rig == null or not is_instance_valid(rig):
return
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var pair: Dictionary = _collision_pairs.get(key, {})
if pair.is_empty():
return
var bodies: Dictionary = pair.get("bodies", {})
bodies.erase(other.get_instance_id())
if bodies.is_empty():
_collision_pairs.erase(key)
else:
pair["bodies"] = bodies
_collision_pairs[key] = pair
func _on_rig_state_changed(new_state: int, rig: StickmanRig) -> void:
if new_state == StickmanRig.RigState.RAGDOLL:
var rig_ids: Array[int] = [rig.get_instance_id()]
_purge_collision_pairs_for_ids(rig_ids)
func _purge_collision_pairs_for_ids(ids: Array[int]) -> void:
for key: String in _collision_pairs.keys().duplicate():
var pair: Dictionary = _collision_pairs[key]
if ids.has(int(pair.get("rig_id", -1))) or ids.has(int(pair.get("prop_id", -1))):
_collision_pairs.erase(key)
## Iterates every rule in order; every matching rule executes (no short-circuit).
@@ -2501,31 +2574,6 @@ func _update_area_entry() -> void:
_area_overlap[key] = false
## Geometric stickman-vs-prop collision (ANIMATED rigs x unfrozen props),
## edge-triggered via the prop's world AABB containing the rig's feet/root point.
func _update_stickman_prop_collision() -> void:
var rigs: Array[StickmanRig] = []
var props: Array[PropBlock] = []
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
if rig.state == StickmanRig.RigState.ANIMATED:
rigs.append(rig)
elif node is PropBlock and not (node as PropBlock).freeze:
props.append(node as PropBlock)
for rig: StickmanRig in rigs:
for prop: PropBlock in props:
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var feet: Vector2 = rig.global_position - StickmanRig.FOOT_OFFSET
var overlap: bool = STAGE_SELECTION.get_world_aabb(prop).has_point(feet)
var was: bool = bool(_collision_pairs.get(key, false))
if overlap and not was:
_collision_pairs[key] = true
_handle_event({ "type": "collided", "source": rig, "target": prop, "position": rig.global_position, "action": {} })
elif not overlap and was:
_collision_pairs[key] = false
## Stickman point = feet (root - FOOT_OFFSET); prop point = global position.
func _movable_point(node: Node2D) -> Vector2:
if node is STICKMAN_RIG: