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:
+151
-16
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user