Implement interactive prop handling for StickmanRig

- Added PropTrajectory class for deterministic ballistic calculations.
- Introduced StickmanPropInteraction component to manage prop interactions (pickup, drop, throw).
- Implemented grip resolution and attachment logic for props.
- Added signals for interaction events (prop_picked_up, prop_dropped, prop_thrown).
- Defined specifications for prop interaction lifecycle and event rules.
- Ensured compatibility with existing prop systems and added new capabilities for prop pickup.
This commit is contained in:
2026-09-23 21:26:13 -04:00
parent 0a7f97cec7
commit 043f114083
22 changed files with 1444 additions and 37 deletions
+151 -16
View File
@@ -40,6 +40,7 @@ const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd")
const STAGE_SCENE_IO := preload("res://scripts/stage_scene_io.gd")
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/missing_stickman_placeholder.gd")
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholder.gd")
const PROP_TRAJECTORY := preload("res://scripts/prop_trajectory.gd")
# ---------------------------------------------------------------------------
# Enums
@@ -101,7 +102,10 @@ const TRIG_ACTION_FINISHED := 1
const TRIG_SPEECH_FINISHED := 2
const TRIG_ENTERED_AREA := 3
const TRIG_COLLIDED := 4
const TRIG_BACK := 5
const TRIG_PROP_PICKED_UP := 5
const TRIG_PROP_DROPPED := 6
const TRIG_PROP_THROWN := 7
const TRIG_BACK := 8
## Phase 4 "add another / done" popup item ids.
const RULE_MORE_ADD := 0
@@ -392,6 +396,11 @@ var _action_editor_rig: StickmanRig = null
var _action_editor_actor_id: int = -1
var _action_editor_restore_queue: bool = false
var _pending_rule_action_flat: Dictionary = {}
## Active only while the Action Editor is capturing a throw landing point.
## Kept separately from generic position capture so walk/rule capture remains
## unchanged and preview/commit share one explicit contract.
var _throw_capture_prop_id: int = -1
var _throw_capture_flight_time: float = 0.9
## Rule editor came from the rule panel (restore it on close).
var _rule_editor_from_panel: bool = false
@@ -674,6 +683,7 @@ func _enter_edit_shared() -> void:
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.release_held_prop_for_lifecycle(&"play_exit")
rig.stop_queue()
rig.set_terrain_following_enabled(false)
rig.snap_to_standing()
@@ -871,6 +881,9 @@ func _finalize_placed_node(node: Node2D) -> void:
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))
var interaction: StickmanPropInteraction = rig.get_prop_interaction()
if interaction != null:
interaction.prop_transition.connect(_on_rig_prop_transition)
_director_visuals.mark_dirty()
if node is PropBlock:
var prop := node as PropBlock
@@ -1234,6 +1247,16 @@ func delete_selected() -> void:
var nav_changed := false
for node: Node2D in selected:
if is_instance_valid(node):
# Disarm before either endpoint leaves World so a held body is never
# left frozen or referenced by a surviving IK marker.
if node is StickmanRig:
(node as StickmanRig).release_held_prop_for_lifecycle(&"holder_deleted")
elif node is PropBlock:
for child: Node in _world.get_children():
if child is StickmanRig:
var interaction: StickmanPropInteraction = (child as StickmanRig).get_prop_interaction()
if interaction != null and interaction.get_held_prop() == node:
interaction.release_for_lifecycle(&"prop_deleted")
if node is TerrainBlock:
nav_changed = true
_clear_object_state(node)
@@ -1274,6 +1297,20 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
var objects: Array[Dictionary] = []
var runtime_to_file: Dictionary = {}
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
# Queue actions may point at a prop that appears later in World. Build the
# complete runtime→file map before encoding any individual rig queue.
for node: Node in _world.get_children():
if not node is Node2D: continue
var map_node: Node2D = node as Node2D
var map_type: String = ""
if map_node is TerrainBlock: map_type = "terrain"
elif map_node is PropBlock or map_node.get_script() == MISSING_PROP_PLACEHOLDER: map_type = "prop"
elif map_node is STICKMAN_RIG or map_node.get_script() == MISSING_STICKMAN_PLACEHOLDER: map_type = "stickman"
elif map_node is TriggerArea: map_type = "area"
if map_type.is_empty(): continue
counters[map_type] = int(counters[map_type]) + 1
runtime_to_file[map_node.get_instance_id()] = "%s_%d" % [map_type, int(counters[map_type])]
counters = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
for node: Node in _world.get_children():
if not node is Node2D: continue
var n := node as Node2D
@@ -1285,7 +1322,6 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
else: continue
counters[type] = int(counters[type]) + 1
var file_id := "%s_%d" % [type, int(counters[type])]
runtime_to_file[n.get_instance_id()] = file_id
var authored: Dictionary = _authored.get(n.get_instance_id(), {})
var pos: Vector2 = authored.get("position", n.global_position)
var rot: float = rad_to_deg(float(authored.get("rotation", n.global_rotation)))
@@ -1313,7 +1349,7 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
var rig := n as StickmanRig
entry["stk_path"] = rig.source_stk_path
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[int(authored.get("facing_profile", rig.get_facing_profile()))]
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue())
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue(), runtime_to_file)
entry["action_queue"] = queue_result["actions"]
objects.append(entry)
var rules_result: Dictionary = STAGE_SCENE_IO.encode_rules(_event_rules, runtime_to_file)
@@ -1355,7 +1391,7 @@ func _load_scene_from_path(path: String, is_recovery: bool = false) -> bool:
func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) -> bool:
var was_suppressed := _suppress_dirty; _suppress_dirty = true
_reset_scene_content()
var file_to_runtime: Dictionary = {}; var unavailable: Dictionary = {}; var warnings: Array[String] = []; var missing_stickmen := 0; var missing_paths: Array[String] = []
var file_to_runtime: Dictionary = {}; var unavailable: Dictionary = {}; var warnings: Array[String] = []; var missing_stickmen := 0; var missing_paths: Array[String] = []; var pending_queues: Array[Dictionary] = []
for raw: Variant in data.get("objects", []):
if not raw is Dictionary: warnings.append("Malformed object"); continue
var entry: Dictionary = raw; var file_id := String(entry.get("file_id", "")); var type := String(entry.get("type", ""))
@@ -1416,14 +1452,16 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool)
if facing >= 0: (node as StickmanRig).set_facing_profile(facing)
var raw_queue: Variant = entry.get("action_queue", [])
if raw_queue is Array:
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(raw_queue as Array)
for warning: String in queue.get("warnings", []):
warnings.append(warning)
for action: Dictionary in queue["actions"]:
(node as StickmanRig).queue_action(action)
pending_queues.append({"rig": node, "entries": raw_queue})
else:
warnings.append("Invalid stickman action queue")
_finalize_placed_node(node); _save_object_state(node); file_to_runtime[file_id] = node.get_instance_id()
for pending: Dictionary in pending_queues:
var queued_rig: StickmanRig = pending.get("rig") as StickmanRig
if queued_rig == null or not is_instance_valid(queued_rig): continue
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(pending.get("entries", []) as Array, file_to_runtime, unavailable)
for warning: String in queue.get("warnings", []): warnings.append(warning)
for action: Dictionary in queue.get("actions", []): queued_rig.queue_action(action)
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
_event_rules.assign(decoded_rules["rules"]); _next_rule_id = 0
for rule: Dictionary in _event_rules: _next_rule_id = maxi(_next_rule_id, int(rule["id"]) + 1)
@@ -2030,6 +2068,9 @@ func _build_ui() -> void:
_trigger_popup.add_item("💬 Finishes speaking", TRIG_SPEECH_FINISHED)
_trigger_popup.add_item("🎯 Enters trigger area", TRIG_ENTERED_AREA)
_trigger_popup.add_item("💥 Collides with something", TRIG_COLLIDED)
_trigger_popup.add_item("🖐️ Picks up a prop", TRIG_PROP_PICKED_UP)
_trigger_popup.add_item("⬇️ Drops a prop", TRIG_PROP_DROPPED)
_trigger_popup.add_item("🎯 Throws a prop", TRIG_PROP_THROWN)
_trigger_popup.add_separator()
_trigger_popup.add_item("⬅ Back to actions", TRIG_BACK)
_trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed)
@@ -2209,6 +2250,7 @@ func _build_ui() -> void:
_action_editor.committed.connect(_on_action_editor_committed)
_action_editor.cancelled.connect(_on_action_editor_cancelled)
_action_editor.target_requested.connect(_on_action_editor_target_requested)
_action_editor.prop_requested.connect(_on_action_editor_prop_requested)
ui.add_child(_action_editor)
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
_action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390))
@@ -2871,11 +2913,17 @@ func _update_action_overlay() -> void:
var awaiting := _is_awaiting_click()
if _placement_overlay != null:
if awaiting:
var origin := _action_origin()
var throw_capture: bool = _is_throw_capture_active()
var origin: Vector2 = _throw_release_point() if throw_capture else _action_origin()
var target := _camera.get_global_mouse_position()
if _snap_enabled and _rule_step == RuleStep.ACTION_POSITION:
if _snap_enabled and (_capture_kind == CaptureKind.POSITION or _rule_step == RuleStep.ACTION_POSITION):
target = _snap_to_grid(target)
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
if throw_capture:
var solution: Dictionary = _solve_throw_capture(target)
var arc: PackedVector2Array = _sample_throw_arc(origin, solution) if bool(solution.get("ok", false)) else PackedVector2Array([origin, target])
_placement_overlay.set_throw_trajectory(origin, target, arc, bool(solution.get("ok", false)))
else:
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
else:
_placement_overlay.clear_action()
if _tooltip != null:
@@ -2999,7 +3047,7 @@ func _on_trigger_popup_id_pressed(id: int) -> void:
var trigger: Dictionary = _rule_builder.get("trigger", {})
trigger["type"] = _trigger_type_string(id)
_rule_builder["trigger"] = trigger
if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED:
if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED or id == TRIG_PROP_PICKED_UP or id == TRIG_PROP_DROPPED or id == TRIG_PROP_THROWN:
_rule_step = RuleStep.TRIGGER_TARGET
match id:
TRIG_ARRIVED:
@@ -3008,6 +3056,12 @@ func _on_trigger_popup_id_pressed(id: int) -> void:
_rule_hint = "Click the trigger area to watch (Esc or RMB to cancel)"
TRIG_COLLIDED:
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
TRIG_PROP_PICKED_UP:
_rule_hint = "Click the prop to watch being picked up (Esc or RMB to cancel)"
TRIG_PROP_DROPPED:
_rule_hint = "Click the prop to watch being dropped (Esc or RMB to cancel)"
TRIG_PROP_THROWN:
_rule_hint = "Click the prop to watch being thrown (Esc or RMB to cancel)"
_refresh_status()
else:
_rule_step = RuleStep.SELECT_ACTION
@@ -3048,6 +3102,12 @@ func _trigger_type_string(id: int) -> String:
return "entered_area"
TRIG_COLLIDED:
return "collided"
TRIG_PROP_PICKED_UP:
return "prop_picked_up"
TRIG_PROP_DROPPED:
return "prop_dropped"
TRIG_PROP_THROWN:
return "prop_thrown"
_:
return ""
@@ -3118,7 +3178,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
else:
_rule_hint = "Click a trigger area (Esc or RMB to cancel)"
_refresh_status()
"collided":
"collided", "prop_picked_up", "prop_dropped", "prop_thrown":
var hit2 := _selection.hit_test(world_pos)
if hit2 is PropBlock:
trigger["target"] = hit2.get_instance_id()
@@ -3126,7 +3186,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
_rule_hint = "Click the prop to use for this trigger (Esc or RMB to cancel)"
_refresh_status()
@@ -3296,6 +3356,11 @@ func _on_rig_arrived(target: Vector2, rig: StickmanRig) -> void:
func _on_rig_action_finished(action: Dictionary, _index: int, rig: StickmanRig) -> void:
_handle_event({ "type": "action_finished", "source": rig, "target": null, "position": rig.global_position, "action": action })
func _on_rig_prop_transition(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName) -> void:
if rig == null or prop == null or not is_instance_valid(rig) or not is_instance_valid(prop):
return
_handle_event({"type": String(action_type), "source": rig, "target": prop, "position": prop.global_position, "action": {"type": String(action_type), "reason": String(reason)}})
func _on_rig_speech_finished(rig: StickmanRig) -> void:
_handle_event({ "type": "speech_finished", "source": rig, "target": null, "position": rig.global_position, "action": {} })
@@ -3410,6 +3475,8 @@ func _rule_matches(rule: Dictionary, event: Dictionary) -> bool:
return _ids_match(source_id, source) and _ids_match(target_id, target)
"collided":
return _ids_match(source_id, source) and _ids_match(target_id, target)
"prop_picked_up", "prop_dropped", "prop_thrown":
return _ids_match(source_id, source) and _ids_match(target_id, target)
_:
return false
@@ -3624,6 +3691,8 @@ func _end_capture() -> void:
_capture_hint = ""
_capture_callback = Callable()
_capture_cancel = Callable()
_throw_capture_prop_id = -1
_throw_capture_flight_time = 0.9
_apply_cursor()
_refresh_status()
@@ -3659,6 +3728,10 @@ func _resolve_capture(world_pos: Vector2) -> void:
if value == null:
_refresh_status()
return
if _capture_kind == CaptureKind.POSITION and _is_throw_capture_active() and not bool(_solve_throw_capture(value as Vector2).get("ok", false)):
_show_toast("That throw is too far for this flight time.")
_refresh_status()
return
var cb := _capture_callback
_end_capture()
if cb.is_valid():
@@ -4061,18 +4134,80 @@ func _on_action_editor_cancelled() -> void:
func _on_action_editor_target_requested() -> void:
_action_editor.hide()
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
var is_throw: bool = _action_editor != null and _action_editor.get_action_type() == "throw_prop"
if is_throw:
_throw_capture_prop_id = _action_editor.get_target_prop_id()
_throw_capture_flight_time = _action_editor.get_flight_time()
_begin_capture(CaptureKind.POSITION, "🎯 Click where the prop should land (Esc or RMB to cancel)" if is_throw else "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_throw_target if is_throw else _cb_editor_walk_target, _cb_editor_walk_target_cancel)
func _cb_editor_walk_target(value: Vector2) -> void:
if _action_editor != null:
_action_editor.set_walk_target(value)
func _cb_editor_throw_target(value: Vector2) -> void:
if _action_editor != null:
_action_editor.set_throw_target(value)
func _on_action_editor_prop_requested() -> void:
_action_editor.hide()
_begin_capture(CaptureKind.PROP, "🧱 Click the prop to use (Esc or RMB to cancel)", _cb_editor_prop_target, _cb_editor_walk_target_cancel)
func _cb_editor_prop_target(value: PropBlock) -> void:
if _action_editor != null:
_action_editor.set_prop_target(value, _prop_action_target_label(value))
func _prop_action_target_label(prop: PropBlock) -> String:
if prop == null or not is_instance_valid(prop):
return ""
var entry: Dictionary = PROP_LIBRARY.get_entry(prop.spawn_id)
var label: String = String(entry.get("name", ""))
if label.is_empty():
label = prop.spawn_id.get_file().get_basename() if prop.spawn_id.contains(".prop") else prop.spawn_id.capitalize()
if label.is_empty():
label = "Prop"
var ordinal: int = 0
for node: Node in _world.get_children():
if node is PropBlock and (node as PropBlock).spawn_id == prop.spawn_id:
ordinal += 1
if node == prop:
break
return "%s #%d" % [label, maxi(1, ordinal)]
func _cb_editor_walk_target_cancel() -> void:
if _action_editor != null:
_action_editor.popup_centered()
func _is_throw_capture_active() -> bool:
return _capture_kind == CaptureKind.POSITION and _throw_capture_prop_id >= 0
func _throw_release_point() -> Vector2:
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
var marker: Marker2D = _action_editor_rig._get_ik_marker("Right_Hand")
if marker != null and is_instance_valid(marker): return marker.global_position
return _action_editor_rig.global_position
return Vector2.ZERO
func _solve_throw_capture(target: Vector2) -> Dictionary:
var prop: PropBlock = instance_from_id(_throw_capture_prop_id) as PropBlock
if prop == null or not is_instance_valid(prop): return {"ok": false, "reason": "missing_prop"}
return PROP_TRAJECTORY.solve_throw_velocity(_throw_release_point(), target, _throw_capture_flight_time, StickmanPropInteraction.gravity_for(prop))
func _sample_throw_arc(origin: Vector2, solution: Dictionary) -> PackedVector2Array:
var points := PackedVector2Array()
if not bool(solution.get("ok", false)): return points
var prop: PropBlock = instance_from_id(_throw_capture_prop_id) as PropBlock
if prop == null or not is_instance_valid(prop): return points
var velocity: Vector2 = solution.get("velocity", Vector2.ZERO)
var duration: float = float(solution.get("flight_time", 0.0))
var gravity: Vector2 = StickmanPropInteraction.gravity_for(prop)
const SEGMENTS: int = 24
for index: int in range(SEGMENTS + 1):
var time: float = duration * float(index) / float(SEGMENTS)
points.append(origin + velocity * time + gravity * 0.5 * time * time)
return points
func _restore_queue_panel() -> void:
if _action_editor_restore_queue and _queue_panel != null: