Add prop interaction system and throw mechanics specification

- Introduced `stickman_prop_interactor.gd` to manage prop interactions.
- Created `phase_11_prop_interactions.md` detailing the design and implementation of prop pickup, drop, and throw mechanics.
- Defined interaction states, action lifecycles, and event triggers for prop handling.
- Specified constraints for throw actions, including target positioning and velocity calculations.
- Established acceptance criteria for prop interactions and lifecycle management.
This commit is contained in:
2026-09-25 23:42:26 -04:00
parent 730a9a966b
commit 6a1359f244
22 changed files with 3433 additions and 34 deletions
+91
View File
@@ -22,10 +22,16 @@ var _text_edit: LineEdit = null
var _duration_spin: SpinBox = null
var _target_label: Label = null
var _set_target_btn: Button = null
var _prop_target_label: Label = null
var _set_prop_target_btn: Button = null
var _hand_option: OptionButton = null
var _stop_current_check: CheckBox = null
var _show_reactive_options: bool = false
var _has_target: bool = false
var _target_pos: Vector2 = Vector2.ZERO
var _prop_target_id: String = ""
var _has_throw_target: bool = false
var _throw_target_pos: Vector2 = Vector2.ZERO
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
## override (engine default); Font null = no override.
@@ -53,6 +59,9 @@ func open_new(show_reactive_options: bool = false) -> void:
_initial = {}
_has_target = false
_target_pos = Vector2.ZERO
_prop_target_id = ""
_has_throw_target = false
_throw_target_pos = Vector2.ZERO
_select_type("walk_to")
_rebuild_params("walk_to")
title = "Add Action"
@@ -67,6 +76,9 @@ func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
var type := String(action.get("type", "walk_to"))
_has_target = action.has("target") and action["target"] is Vector2
_target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO
_prop_target_id = String(action.get("target_prop_id", ""))
_has_throw_target = action.has("target_position") and action["target_position"] is Vector2
_throw_target_pos = action.get("target_position", Vector2.ZERO) if _has_throw_target else Vector2.ZERO
_select_type(type)
_rebuild_params(type)
title = "Edit Action"
@@ -81,6 +93,23 @@ func set_walk_target(pos: Vector2) -> void:
popup_centered()
func set_prop_target(prop_id: String) -> void:
_prop_target_id = prop_id
_update_prop_target_label()
popup_centered()
func set_throw_target(pos: Vector2) -> void:
_has_throw_target = true
_throw_target_pos = pos
_update_throw_target_label()
popup_centered()
func current_action_type() -> String:
return _current_type()
func _build_ui() -> void:
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 12)
@@ -184,6 +213,9 @@ func _rebuild_params(type: String) -> void:
_duration_spin = null
_target_label = null
_set_target_btn = null
_prop_target_label = null
_set_prop_target_btn = null
_hand_option = null
_stop_current_check = null
match type:
@@ -198,6 +230,39 @@ func _rebuild_params(type: String) -> void:
_set_target_btn.pressed.connect(func() -> void: target_requested.emit())
trow.add_child(_set_target_btn)
_update_walk_target_label()
"pick_up_prop":
var prop_row := HBoxContainer.new()
_params_box.add_child(prop_row)
_prop_target_label = Label.new()
_prop_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
prop_row.add_child(_prop_target_label)
_set_prop_target_btn = Button.new()
_set_prop_target_btn.text = "🎯 Click prop…"
_set_prop_target_btn.pressed.connect(func() -> void: target_requested.emit())
prop_row.add_child(_set_prop_target_btn)
_update_prop_target_label()
var hand_row := HBoxContainer.new()
_params_box.add_child(hand_row)
var hand_label := Label.new()
hand_label.text = "Hand:"
hand_row.add_child(hand_label)
_hand_option = OptionButton.new()
for label: String in ["Auto", "Left", "Right"]:
_hand_option.add_item(label)
var hand_idx := ["auto", "left", "right"].find(String(_initial.get("hand", "auto")))
_hand_option.select(maxi(hand_idx, 0))
hand_row.add_child(_hand_option)
"throw_prop":
var throw_row := HBoxContainer.new()
_params_box.add_child(throw_row)
_target_label = Label.new()
_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
throw_row.add_child(_target_label)
_set_target_btn = Button.new()
_set_target_btn.text = "🎯 Click target…"
_set_target_btn.pressed.connect(func() -> void: target_requested.emit())
throw_row.add_child(_set_target_btn)
_update_throw_target_label()
"speak":
var tlabel := Label.new()
tlabel.text = "Text:"
@@ -247,6 +312,20 @@ func _update_walk_target_label() -> void:
_target_label.text = "Target: not set"
func _update_prop_target_label() -> void:
if _prop_target_label != null:
_prop_target_label.text = "Prop: %s" % (_prop_target_id if not _prop_target_id.is_empty() else "not set")
func _update_throw_target_label() -> void:
if _target_label == null:
return
if _has_throw_target:
_target_label.text = "Target: (%d, %d)" % [int(roundf(_throw_target_pos.x)), int(roundf(_throw_target_pos.y))]
else:
_target_label.text = "Target: not set"
func _on_ok_pressed() -> void:
var type := _current_type()
var action: Dictionary = { "type": type }
@@ -257,6 +336,18 @@ func _on_ok_pressed() -> void:
target_requested.emit()
return
action["target"] = _target_pos
"pick_up_prop":
if _prop_target_id.is_empty():
target_requested.emit()
return
var hands: Array[String] = ["auto", "left", "right"]
action["target_prop_id"] = _prop_target_id
action["hand"] = hands[_hand_option.selected] if _hand_option != null and _hand_option.selected >= 0 else "auto"
"throw_prop":
if not _has_throw_target:
target_requested.emit()
return
action["target_position"] = _throw_target_pos
"speak":
action["text"] = _text_edit.text if _text_edit != null else ""
action["duration"] = _duration_spin.value if _duration_spin != null else 2.0
+42
View File
@@ -47,6 +47,29 @@ const ACTION_TEMPLATES := {
"params": [],
"reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false,
},
"pick_up_prop": {
"label": "Pick Up Prop",
"icon": "🫴",
"params": [
{ "key": "target_prop_id", "type": "prop", "required": true },
{ "key": "hand", "type": "choice", "default": "auto", "choices": ["auto", "left", "right"] },
],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
"drop_prop": {
"label": "Drop Prop",
"icon": "⬇️",
"params": [],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
"throw_prop": {
"label": "Throw Prop",
"icon": "🏹",
"params": [
{ "key": "target_position", "type": "position", "required": true },
],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
}
@@ -102,6 +125,11 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
params["stop_current"] = true
"wait":
params["duration"] = float(action.get("duration", 0.0))
"pick_up_prop":
params["target_prop_id"] = String(action.get("target_prop_id", ""))
params["hand"] = String(action.get("hand", "auto"))
"throw_prop":
params["target_position"] = action.get("target_position", Vector2.ZERO)
return {
"type": String(action.get("type", "")),
"target": target_id,
@@ -126,6 +154,12 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
return speak
"wait":
return { "type": "wait", "duration": float(params.get("duration", 0.0)) }
"pick_up_prop":
return { "type": "pick_up_prop", "target_prop_id": String(params.get("target_prop_id", "")), "hand": String(params.get("hand", "auto")) }
"drop_prop":
return { "type": "drop_prop" }
"throw_prop":
return { "type": "throw_prop", "target_position": params.get("target_position", Vector2.ZERO) }
_:
return { "type": String(rule_action.get("type", "")) }
@@ -155,6 +189,14 @@ static func summarize(action: Dictionary) -> String:
return "Ragdoll"
"recover":
return "Recover"
"pick_up_prop":
return "Pick Up Prop (%s)" % String(action.get("target_prop_id", params.get("target_prop_id", "")))
"drop_prop":
return "Drop Prop"
"throw_prop":
var raw_position: Variant = action.get("target_position", params.get("target_position", Vector2.ZERO))
var target_position: Vector2 = raw_position as Vector2 if raw_position is Vector2 else Vector2.ZERO
return "Throw Prop (%d, %d)" % [int(roundf(target_position.x)), int(roundf(target_position.y))]
_:
return type
+2
View File
@@ -3,11 +3,13 @@ extends Node2D
var original_entry: Dictionary = {}
var missing_prop_path: String = ""
var stage_object_id: String = ""
const MARKER_SIZE := Vector2(180.0, 54.0)
func configure(entry: Dictionary) -> void:
original_entry = entry.duplicate(true)
missing_prop_path = String(entry.get("asset_id", ""))
stage_object_id = String(entry.get("stage_object_id", ""))
queue_redraw()
func get_area_rect() -> Rect2:
+39
View File
@@ -11,6 +11,7 @@ extends RigidBody2D
## friction/bounce (PhysicsMaterial), and themed visuals.
const PropVisuals := preload("res://scripts/prop_visuals.gd")
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
# ---------------------------------------------------------------------------
# Enums
@@ -23,6 +24,13 @@ enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
## Stable library provenance used by Sandbox Stage scene files.
var spawn_id: String = ""
## Stable identity of this placed prop in one Sandbox Stage scene. Unlike
## spawn_id, this value is unique per instance and survives scene save/load.
var stage_object_id: String = ""
var interaction_anchors: Dictionary = {}
var interaction_reserved_by: int = 0
var interaction_owner_id: int = 0
# ---------------------------------------------------------------------------
# Child node names
# ---------------------------------------------------------------------------
@@ -126,6 +134,33 @@ func _ready() -> void:
body_exited.connect(_on_body_exited)
func set_interaction_anchors(value: Dictionary) -> void:
interaction_anchors = PropAnchorDataScript.normalize_anchors(value)
func get_interaction_anchors() -> Dictionary:
return PropAnchorDataScript.normalize_anchors(interaction_anchors, false)
func ensure_default_grip() -> void:
var normalized := get_interaction_anchors()
if not PropAnchorDataScript.ids_for_role(normalized, "grip").is_empty():
interaction_anchors = normalized
return
var bounds := _visual_local_bounds
if bounds.size == Vector2.ZERO:
if shape_type == ShapeType.CIRCLE:
bounds = Rect2(Vector2(-radius, -radius), Vector2(radius * 2.0, radius * 2.0))
elif polygon_points.size() >= 3:
var min_point := polygon_points[0]
var max_point := polygon_points[0]
for point: Vector2 in polygon_points:
min_point = min_point.min(point)
max_point = max_point.max(point)
bounds = Rect2(min_point, max_point - min_point)
interaction_anchors = PropAnchorDataScript.preset_layout("one_handed", bounds)
func _on_body_entered(body: Node) -> void:
var center: Vector2 = (body as Node2D).global_position if body is Node2D else global_position
var normal: Vector2 = (center - global_position).normalized()
@@ -277,6 +312,10 @@ func has_custom_shapes() -> bool:
func get_visual_local_bounds() -> Rect2:
return _visual_local_bounds
func get_interaction_scale() -> Vector2:
return _play_instance_scale if _play_scale_active else scale.abs()
## RigidBody2D transforms do not reliably retain scale during simulation. Move
## the authored uniform scale into child visuals and collision geometry for
## PLAY, then restore the root scale on return to EDIT for scene persistence.
+1 -1
View File
@@ -75,7 +75,7 @@ static func make_custom_entry(path: String) -> Dictionary:
var name := String(parsed.get("prop_name", "")).strip_edges()
if name.is_empty(): name = normalized_path.get_file().get_basename()
var preset: int = int(parsed.get("material_preset", PROP_BLOCK.MaterialPreset.NONE))
return {"id": normalized_path, "path": normalized_path, "name": name, "material_preset": preset, "material_label": PROP_FILE.preset_to_string(preset).capitalize(), "is_custom": true, "payload": PROP_FILE.flatten_to_payload(shapes), "shapes": shapes, "collision_hull": hull}
return {"id": normalized_path, "path": normalized_path, "name": name, "material_preset": preset, "material_label": PROP_FILE.preset_to_string(preset).capitalize(), "is_custom": true, "payload": PROP_FILE.flatten_to_payload(shapes), "shapes": shapes, "collision_hull": hull, "anchors": parsed.get("anchors", {})}
static func is_custom_path(path: String) -> bool:
+332 -9
View File
@@ -52,7 +52,7 @@ enum RunState { STOPPED, RUNNING, PAUSED }
enum RuleStep { IDLE, SELECT_TRIGGER, TRIGGER_TARGET, SELECT_ACTION, ACTION_TARGET, ACTION_POSITION, PARAMS }
## Phase 3c target-capture kinds (what the next stage click resolves to).
enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION }
enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION }
enum PendingFileAction { NONE, NEW_SCENE, OPEN_DIALOG, OPEN_PATH, QUIT }
# ---------------------------------------------------------------------------
@@ -106,6 +106,9 @@ const TRIG_SPEECH_FINISHED := 2
const TRIG_ENTERED_AREA := 3
const TRIG_COLLIDED := 4
const TRIG_BACK := 5
const TRIG_PROP_PICKED_UP := 6
const TRIG_PROP_DROPPED := 7
const TRIG_PROP_THROWN := 8
## Phase 4 "add another / done" popup item ids.
const RULE_MORE_ADD := 0
@@ -118,6 +121,9 @@ const RULE_ACTION_DONE := 6
## Phase 3c action-popup item ids ("Edit Queue…" / "Edit Rules…").
const ACT_EDIT_QUEUE := 7
const ACT_EDIT_RULES := 8
const ACT_PICK_UP_PROP := 9
const ACT_DROP_PROP := 10
const ACT_THROW_PROP := 11
## Phase 3c stickman right-click context-menu item ids.
const RIG_CTX_EDIT_QUEUE := 0
@@ -276,6 +282,7 @@ var _edit_physics_settle_frames: int = 0
var _action_popup: PopupMenu = null
var _context_rig: StickmanRig = null
var _pending_walk_target: bool = false
var _next_stage_prop_serial: int = 0
var _speak_dialog: AcceptDialog = null
var _speak_edit: LineEdit = null
var _speak_stop_current_check: CheckBox = null
@@ -399,6 +406,9 @@ var _capture_kind: CaptureKind = CaptureKind.NONE
var _capture_hint: String = ""
var _capture_callback: Callable = Callable()
var _capture_cancel: Callable = Callable()
var _capture_throw_preview_rig: StickmanRig = null
var _capture_throw_preview_prefix: Array[Dictionary] = []
var _throw_preview_cache: Dictionary = {}
## Walk-edit state (Phase 3c.3): re-placing an existing walk_to target.
var _walk_edit_rig: StickmanRig = null
@@ -492,6 +502,7 @@ func _process(delta: float) -> void:
func _physics_process(delta: float) -> void:
_update_throw_preview_cache()
if _run_state == RunState.RUNNING:
_elapsed_seconds += delta
_set_timestamp(_elapsed_seconds)
@@ -507,6 +518,19 @@ func _physics_process(delta: float) -> void:
if current_mode == StageMode.PLAY and _event_dispatch_ready and _run_state == RunState.RUNNING:
_update_area_entry()
func _update_throw_preview_cache() -> void:
if _capture_kind != CaptureKind.THROW_POSITION or _capture_throw_preview_rig == null or not is_instance_valid(_capture_throw_preview_rig):
_throw_preview_cache.clear()
return
if _camera == null:
_throw_preview_cache.clear()
return
var target := _camera.get_global_mouse_position()
if _snap_enabled:
target = _snap_to_grid(target)
_throw_preview_cache = _capture_throw_preview_rig.preview_prop_throw(target, _capture_throw_preview_prefix)
# ---------------------------------------------------------------------------
# Input
# ---------------------------------------------------------------------------
@@ -924,6 +948,7 @@ func _reset_run() -> void:
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as StickmanRig
rig.reset_prop_interactions_for_scene_exit()
rig.stop_queue()
rig.set_terrain_following_enabled(false)
rig.snap_to_standing()
@@ -1118,6 +1143,11 @@ func _place_at(world_pos: Vector2) -> void:
## Common post-spawn wiring: freeze kinematic props in EDIT, save authored state,
## and connect the Phase 3a/4 signals for stickmen/props.
func _finalize_placed_node(node: Node2D) -> void:
if node is PropBlock:
var placed_prop := node as PropBlock
if placed_prop.stage_object_id.is_empty():
placed_prop.stage_object_id = _new_stage_prop_id()
placed_prop.ensure_default_grip()
if current_mode == StageMode.EDIT and node is RigidBody2D:
var body := node as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
@@ -1125,11 +1155,13 @@ func _finalize_placed_node(node: Node2D) -> void:
_save_object_state(node)
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.set_prop_resolver(Callable(self, "_resolve_prop_by_stage_id"))
rig.queue_changed.connect(_on_rig_queue_changed)
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))
rig.prop_interaction_event.connect(_on_rig_prop_interaction_event.bind(rig))
_director_visuals.mark_dirty()
if node is PropBlock:
var prop := node as PropBlock
@@ -1137,6 +1169,39 @@ func _finalize_placed_node(node: Node2D) -> void:
prop.separated.connect(_on_prop_separated.bind(prop))
func _new_stage_prop_id() -> String:
while true:
_next_stage_prop_serial += 1
var candidate := "prop_%d_%d_%d" % [Time.get_ticks_usec(), randi(), _next_stage_prop_serial]
if not _stage_prop_id_in_use(candidate):
return candidate
return ""
func _stage_prop_id_in_use(stage_id: String) -> bool:
for child: Node in _world.get_children():
if child is PropBlock and (child as PropBlock).stage_object_id == stage_id:
return true
if child.get_script() == MISSING_PROP_PLACEHOLDER and String(child.get("stage_object_id")) == stage_id:
return true
return false
func _resolve_prop_by_stage_id(stage_id: String) -> PropBlock:
if stage_id.is_empty():
return null
for child: Node in _world.get_children():
if child is PropBlock and (child as PropBlock).stage_object_id == stage_id:
return child as PropBlock
return null
func _on_rig_prop_interaction_event(event_type: String, prop: PropBlock, reason: String, rig: StickmanRig) -> void:
if prop == null or not is_instance_valid(prop) or rig == null or not is_instance_valid(rig):
return
_handle_event({ "type": event_type, "source": rig, "target": prop, "position": prop.global_position, "reason": reason, "action": {} })
func _spawn_ghost() -> void:
_free_ghost()
if _placement_id == "":
@@ -1559,9 +1624,11 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
elif type == "prop" and n.get_script() == MISSING_PROP_PLACEHOLDER:
entry = (n.get("original_entry") as Dictionary).duplicate(true)
entry["file_id"] = file_id; entry["type"] = "prop"; entry["position"] = STAGE_SCENE_IO.vector_to_data(pos); entry["rotation"] = rot; entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale)
entry["stage_object_id"] = String(n.get("stage_object_id"))
elif type == "prop":
var prop := n as PropBlock
entry["asset_id"] = prop.spawn_id
entry["stage_object_id"] = prop.stage_object_id
var prop_scale: Vector2 = authored.get("scale", prop.scale)
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale)
entry["material_preset"] = prop.material_preset
@@ -1662,6 +1729,7 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool)
continue
(node as TriggerArea).size = area_size
if node is PropBlock:
(node as PropBlock).stage_object_id = String(entry.get("stage_object_id", ""))
var preset_names: Array = PropBlock.MaterialPreset.keys()
var raw_preset: Variant = entry.get("material_preset", PropBlock.MaterialPreset.NONE)
var index := -1
@@ -2333,6 +2401,9 @@ func _build_ui() -> void:
_action_popup.add_item("⏳ Wait", ACT_WAIT)
_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
_action_popup.add_item("🔄 Recover", ACT_RECOVER)
_action_popup.add_item("🫴 Pick Up Prop…", ACT_PICK_UP_PROP)
_action_popup.add_item("⬇️ Drop Prop", ACT_DROP_PROP)
_action_popup.add_item("🏹 Throw Prop…", ACT_THROW_PROP)
_action_popup.add_separator()
_action_popup.add_item("⚡ When...", ACT_WHEN)
_action_popup.add_separator()
@@ -2348,6 +2419,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)
@@ -2359,6 +2433,9 @@ func _build_ui() -> void:
_rule_action_popup.add_item("⏳ Wait", ACT_WAIT)
_rule_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
_rule_action_popup.add_item("🔄 Recover", ACT_RECOVER)
_rule_action_popup.add_item("🫴 Pick Up Prop…", ACT_PICK_UP_PROP)
_rule_action_popup.add_item("⬇️ Drop Prop", ACT_DROP_PROP)
_rule_action_popup.add_item("🏹 Throw Prop…", ACT_THROW_PROP)
_rule_action_popup.id_pressed.connect(_on_rule_action_popup_id_pressed)
ui.add_child(_rule_action_popup)
@@ -3110,6 +3187,18 @@ func _on_action_popup_id_pressed(id: int) -> void:
_context_rig.queue_action({ "type": "ragdoll" })
ACT_RECOVER:
_context_rig.queue_action({ "type": "recover" })
ACT_PICK_UP_PROP:
_begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_direct_pickup_prop, _cb_direct_capture_cancel)
ACT_DROP_PROP:
_context_rig.queue_action({ "type": "drop_prop" })
ACT_THROW_PROP:
_begin_throw_position_capture(
_context_rig,
"🏹 Click where to throw (Esc or RMB to cancel)",
_cb_direct_throw_target,
_cb_direct_capture_cancel,
_throw_preview_prefix_for(_context_rig)
)
ACT_WHEN:
_begin_rule_build(_context_rig.get_instance_id())
ACT_EDIT_QUEUE:
@@ -3118,6 +3207,26 @@ func _on_action_popup_id_pressed(id: int) -> void:
_open_rules_panel_for_rig(_context_rig)
func _cb_direct_pickup_prop(value: Node2D) -> void:
if _context_rig != null and is_instance_valid(_context_rig) and value is PropBlock:
var prop := value as PropBlock
if not prop.stage_object_id.is_empty():
_context_rig.queue_action({ "type": "pick_up_prop", "target_prop_id": prop.stage_object_id, "hand": "auto" })
else:
_show_toast("That prop is missing its scene ID")
_context_rig = null
func _cb_direct_throw_target(value: Vector2) -> void:
if _context_rig != null and is_instance_valid(_context_rig):
_context_rig.queue_action({ "type": "throw_prop", "target_position": value })
_context_rig = null
func _cb_direct_capture_cancel() -> void:
_context_rig = null
# ---------------------------------------------------------------------------
# Director UX (Phase 4b): cursor-attached tooltip, trajectory, ghost marker
# ---------------------------------------------------------------------------
@@ -3136,6 +3245,12 @@ func _action_hint_text() -> String:
if _pending_walk_target:
return "🚩 Click to set walk target"
if _capture_kind != CaptureKind.NONE:
if _capture_kind == CaptureKind.THROW_POSITION and not _throw_preview_cache.is_empty():
if not bool(_throw_preview_cache.get("preview_available", true)):
return "ℹ %s Click to set the throw target." % String(_throw_preview_cache.get("preview_reason", "No throw preview is available."))
var preview_hint := String(_throw_preview_cache.get("preview_hint", ""))
if not preview_hint.is_empty():
return "%s %s" % [_capture_hint, preview_hint]
return _capture_hint
match _rule_step:
RuleStep.TRIGGER_TARGET:
@@ -3157,6 +3272,10 @@ func _action_hint_text() -> String:
func _action_origin() -> Vector2:
if _capture_kind == CaptureKind.THROW_POSITION and _capture_throw_preview_rig != null and is_instance_valid(_capture_throw_preview_rig):
if not _throw_preview_cache.is_empty():
return _throw_preview_cache.get("start_position", _capture_throw_preview_rig.global_position)
return _capture_throw_preview_rig.global_position
if _pending_walk_target:
if _context_rig != null and is_instance_valid(_context_rig):
return _context_rig.global_position - StickmanLocomotion.FOOT_OFFSET
@@ -3191,9 +3310,37 @@ func _update_action_overlay() -> void:
if awaiting:
var origin := _action_origin()
var target := _camera.get_global_mouse_position()
if _snap_enabled and _rule_step == RuleStep.ACTION_POSITION:
if _snap_enabled and (_rule_step == RuleStep.ACTION_POSITION or _capture_kind == CaptureKind.THROW_POSITION):
target = _snap_to_grid(target)
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
if _capture_kind == CaptureKind.THROW_POSITION:
if _capture_throw_preview_rig == null or not is_instance_valid(_capture_throw_preview_rig):
_placement_overlay.set_throw_preview_unavailable(target)
else:
var throw_preview := _throw_preview_cache
if throw_preview.is_empty():
_placement_overlay.set_throw_preview_unavailable(target)
elif not bool(throw_preview.get("preview_available", true)):
_placement_overlay.set_throw_preview_unavailable(target)
else:
var trajectory_points: PackedVector2Array = throw_preview.get("trajectory_points", PackedVector2Array())
var effective_target: Vector2 = throw_preview.get("effective_target", target)
var collision_position: Vector2 = throw_preview.get("collision_position", Vector2.INF)
var target_reachable := bool(throw_preview.get("target_reachable", true))
var preview_planned := bool(throw_preview.get("preview_planned", false))
var preview_uncertain := bool(throw_preview.get("preview_uncertain", false))
_placement_overlay.set_throw_trajectory(
throw_preview["start_position"],
target,
throw_preview["landing_position"],
target_reachable,
trajectory_points,
effective_target,
collision_position,
preview_planned,
preview_uncertain
)
else:
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
else:
_placement_overlay.clear_action()
if _tooltip != null:
@@ -3317,7 +3464,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:
@@ -3326,6 +3473,8 @@ 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, TRIG_PROP_DROPPED, TRIG_PROP_THROWN:
_rule_hint = "Click the prop to watch (Esc or RMB to cancel)"
_refresh_status()
else:
_rule_step = RuleStep.SELECT_ACTION
@@ -3366,6 +3515,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 ""
@@ -3382,6 +3537,12 @@ func _action_type_string(id: int) -> String:
return "ragdoll"
ACT_RECOVER:
return "recover"
ACT_PICK_UP_PROP:
return "pick_up_prop"
ACT_DROP_PROP:
return "drop_prop"
ACT_THROW_PROP:
return "throw_prop"
_:
return ""
@@ -3398,6 +3559,12 @@ func _action_verb(id: int) -> String:
return "ragdoll"
ACT_RECOVER:
return "recover"
ACT_PICK_UP_PROP:
return "pick up a prop"
ACT_DROP_PROP:
return "drop a prop"
ACT_THROW_PROP:
return "throw a prop"
_:
return "act"
@@ -3436,7 +3603,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()
@@ -3476,8 +3643,12 @@ func _handle_action_target_click(world_pos: Vector2) -> void:
_rule_step = RuleStep.PARAMS
_wait_spin.value = 1.0
_wait_dialog.popup_centered()
"pick_up_prop":
_begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_rule_action_prop_target, _cb_rule_action_capture_cancel)
"throw_prop":
_begin_throw_position_capture(hit as StickmanRig, "🏹 Click where to throw (Esc or RMB to cancel)", _cb_rule_action_throw_target, _cb_rule_action_capture_cancel)
_:
# ragdoll / recover need no params.
# Ragdoll, recover, and drop need no extra parameters.
_open_rule_more_popup()
@@ -3496,6 +3667,45 @@ func _handle_action_position_click(world_pos: Vector2) -> void:
_open_rule_more_popup()
func _cb_rule_action_prop_target(value: Node2D) -> void:
if not value is PropBlock:
return
var prop := value as PropBlock
if prop.stage_object_id.is_empty():
_show_toast("That prop is missing its scene ID")
return
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions.back()
var params: Dictionary = action.get("params", {})
params["target_prop_id"] = prop.stage_object_id
params["hand"] = String(params.get("hand", "auto"))
action["params"] = params
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
_open_rule_more_popup()
func _cb_rule_action_throw_target(value: Vector2) -> void:
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions.back()
var params: Dictionary = action.get("params", {})
params["target_position"] = value
action["params"] = params
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
_open_rule_more_popup()
func _cb_rule_action_capture_cancel() -> void:
_rule_step = RuleStep.ACTION_TARGET
_rule_hint = "Click the stickman who will act (Esc or RMB to cancel)"
_refresh_status()
## Writes dialog-confirmed params onto the last action in the builder.
func _set_rule_action_params(params: Dictionary) -> void:
var actions: Array = _rule_builder.get("actions", [])
@@ -3737,6 +3947,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
@@ -3776,6 +3988,11 @@ func _action_for_rig(a: Dictionary) -> Dictionary:
out["stop_current"] = bool(params.get("stop_current", false))
"wait":
out["duration"] = float(params.get("duration", 0.0))
"pick_up_prop":
out["target_prop_id"] = String(params.get("target_prop_id", ""))
out["hand"] = String(params.get("hand", "auto"))
"throw_prop":
out["target_position"] = params.get("target_position", Vector2.ZERO)
return out
@@ -3952,19 +4169,58 @@ func _begin_capture(kind: CaptureKind, hint: String, on_resolve: Callable, on_ca
_capture_hint = hint
_capture_callback = on_resolve
_capture_cancel = on_cancel
_capture_throw_preview_rig = null
_capture_throw_preview_prefix.clear()
_throw_preview_cache.clear()
_apply_cursor()
_refresh_status()
func _begin_throw_position_capture(rig: StickmanRig, hint: String, on_resolve: Callable, on_cancel: Callable = Callable(), queue_prefix: Array[Dictionary] = []) -> void:
_begin_capture(CaptureKind.THROW_POSITION, hint, on_resolve, on_cancel)
_capture_throw_preview_rig = rig if rig != null and is_instance_valid(rig) else null
_capture_throw_preview_prefix = queue_prefix.duplicate(true)
_throw_preview_cache.clear()
func _end_capture() -> void:
_capture_kind = CaptureKind.NONE
_capture_hint = ""
_capture_callback = Callable()
_capture_cancel = Callable()
_capture_throw_preview_rig = null
_capture_throw_preview_prefix.clear()
_throw_preview_cache.clear()
_apply_cursor()
_refresh_status()
func _throw_preview_prefix_for(rig: StickmanRig, before_action_id: int = -1) -> Array[Dictionary]:
var result: Array[Dictionary] = []
if rig == null or not is_instance_valid(rig):
return result
var queue_view: Array[Dictionary] = rig.get_queue_view()
var limit := queue_view.size()
if before_action_id >= 0:
limit = rig.index_for_action_id(before_action_id)
if limit < 0:
return result
for index: int in mini(limit, queue_view.size()):
var entry: Dictionary = queue_view[index]
var status := int(entry.get("status", StickmanActionRunner.QueueStatus.NOT_STARTED))
if status == StickmanActionRunner.QueueStatus.COMPLETED or status == StickmanActionRunner.QueueStatus.INTERRUPTED:
continue
var action: Dictionary = entry.get("action", {})
if action.is_empty():
continue
result.append({
"action": action.duplicate(true),
"step": index + 1,
"active": status == StickmanActionRunner.QueueStatus.ACTIVE,
})
return result
func _cancel_capture() -> void:
var cb := _capture_cancel
_end_capture()
@@ -3993,6 +4249,8 @@ func _resolve_capture(world_pos: Vector2) -> void:
value = hit3
CaptureKind.POSITION:
value = _snap_to_grid(world_pos) if _snap_enabled else world_pos
CaptureKind.THROW_POSITION:
value = _snap_to_grid(world_pos) if _snap_enabled else world_pos
if value == null:
_refresh_status()
return
@@ -4412,8 +4670,12 @@ func _on_action_editor_committed(flat: Dictionary) -> void:
else:
_show_toast("That action can no longer be used as an insert point")
"rule_add":
_pending_rule_action_flat = flat
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_rule_actor, _cb_rule_actor_cancel)
if _action_editor_actor_id >= 0:
if _rule_editor != null:
_rule_editor.set_action(-1, ACTION_REGISTRY.to_rule_action(flat, _action_editor_actor_id))
else:
_pending_rule_action_flat = flat
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_rule_actor, _cb_rule_actor_cancel)
return
"rule_edit":
if _rule_editor != null:
@@ -4435,7 +4697,33 @@ 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 action_type := _action_editor.current_action_type()
if _action_editor_kind == "rule_add" and _action_editor_actor_id < 0 and (action_type == "pick_up_prop" or action_type == "throw_prop"):
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_editor_actor_for_target, _cb_editor_actor_capture_cancel)
return
_begin_editor_action_target_capture()
func _begin_editor_action_target_capture() -> void:
match _action_editor.current_action_type():
"pick_up_prop":
_begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_editor_prop_target, _cb_editor_walk_target_cancel)
"throw_prop":
var preview_rig := _action_editor_preview_rig()
var queue_prefix: Array[Dictionary] = []
if _action_editor_kind == "queue_edit":
queue_prefix = _throw_preview_prefix_for(preview_rig, _action_editor_action_id)
elif _action_editor_kind == "queue_add":
queue_prefix = _throw_preview_prefix_for(preview_rig)
_begin_throw_position_capture(
preview_rig,
"🏹 Click where to throw (Esc or RMB to cancel)",
_cb_editor_throw_target,
_cb_editor_walk_target_cancel,
queue_prefix
)
_:
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
func _cb_editor_walk_target(value: Vector2) -> void:
@@ -4443,6 +4731,41 @@ func _cb_editor_walk_target(value: Vector2) -> void:
_action_editor.set_walk_target(value)
func _cb_editor_prop_target(value: Node2D) -> void:
if _action_editor != null and value is PropBlock:
var prop := value as PropBlock
if not prop.stage_object_id.is_empty():
_action_editor.set_prop_target(prop.stage_object_id)
else:
_action_editor.popup_centered()
func _cb_editor_throw_target(value: Vector2) -> void:
if _action_editor != null:
_action_editor.set_throw_target(value)
func _cb_editor_actor_for_target(value: Node2D) -> void:
if not value is StickmanRig:
if _action_editor != null:
_action_editor.popup_centered()
return
_action_editor_actor_id = int(value.get_instance_id())
_begin_editor_action_target_capture()
func _cb_editor_actor_capture_cancel() -> void:
if _action_editor != null:
_action_editor.popup_centered()
func _action_editor_preview_rig() -> StickmanRig:
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
return _action_editor_rig
var actor := instance_from_id(_action_editor_actor_id) as StickmanRig
return actor if actor != null and is_instance_valid(actor) else null
func _cb_editor_walk_target_cancel() -> void:
if _action_editor != null:
_action_editor.popup_centered()
+83 -3
View File
@@ -19,7 +19,16 @@ var guide_line_color: Color = Color("#22c6ff")
var action_visible: bool = false
var action_origin: Vector2 = Vector2.ZERO
var action_target: Vector2 = Vector2.ZERO
var action_curve_points: PackedVector2Array = PackedVector2Array()
var action_valid: bool = true
var action_requested_target: Vector2 = Vector2.ZERO
var action_show_requested_target: bool = false
var action_effective_target: Vector2 = Vector2.ZERO
var action_collision_position: Vector2 = Vector2.INF
var action_show_collision: bool = false
var action_preview_unavailable: bool = false
var action_preview_planned: bool = false
var action_preview_uncertain: bool = false
var valid_color: Color = Color(0.3, 0.9, 0.4)
var invalid_color: Color = Color(1.0, 0.3, 0.3)
var line_width_px: float = 2.0
@@ -62,11 +71,53 @@ func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> voi
action_visible = true
action_origin = origin
action_target = target
action_curve_points.clear()
action_valid = valid
action_show_requested_target = false
action_show_collision = false
action_preview_unavailable = false
action_preview_planned = false
action_preview_uncertain = false
func set_throw_trajectory(origin: Vector2, requested_target: Vector2, landing_target: Vector2, valid: bool, trajectory_points: PackedVector2Array = PackedVector2Array(), effective_target: Vector2 = Vector2.INF, collision_position: Vector2 = Vector2.INF, planned: bool = false, uncertain: bool = false) -> void:
action_visible = true
action_origin = origin
action_target = landing_target
action_curve_points = trajectory_points.duplicate()
action_valid = valid
action_requested_target = requested_target
action_show_requested_target = true
action_effective_target = effective_target if effective_target.is_finite() else landing_target
action_collision_position = collision_position
action_show_collision = collision_position.is_finite()
action_preview_unavailable = false
action_preview_planned = planned
action_preview_uncertain = uncertain
func set_throw_preview_unavailable(requested_target: Vector2) -> void:
action_visible = true
action_origin = requested_target
action_target = requested_target
action_curve_points.clear()
action_valid = false
action_requested_target = requested_target
action_show_requested_target = false
action_show_collision = false
action_preview_unavailable = true
action_preview_planned = false
action_preview_uncertain = false
func clear_action() -> void:
action_visible = false
action_curve_points.clear()
action_show_requested_target = false
action_show_collision = false
action_preview_unavailable = false
action_preview_planned = false
action_preview_uncertain = false
queue_redraw()
@@ -76,9 +127,29 @@ func _draw() -> void:
_draw_cell_marker(terrain_anchor, guide_line_color)
_draw_cell_marker(terrain_target, guide_line_color)
if action_visible:
var color := valid_color if action_valid else invalid_color
_draw_dashed(action_origin, action_target, color)
_draw_ghost_marker(action_target, color)
if action_preview_unavailable:
var zoom := _zoom()
var unavailable_color := Color(0.74, 0.79, 0.85, 0.9)
draw_arc(action_target, MARKER_RADIUS_PX / zoom, 0.0, TAU, 24, unavailable_color, LINE_WIDTH_PX / zoom, true)
else:
var color := valid_color if action_valid else invalid_color
if action_valid and action_preview_planned:
color = Color(0.35, 0.8, 1.0, 0.95)
elif action_valid and action_preview_uncertain:
color = Color(0.8, 0.68, 1.0, 0.95)
if action_curve_points.size() >= 2:
draw_polyline(action_curve_points, color, line_width_px / _zoom(), true)
else:
_draw_dashed(action_origin, action_target, color)
if action_show_requested_target:
_draw_ghost_marker(action_effective_target, color)
var request_color := Color(1.0, 0.85, 0.25, 0.9)
var r := 7.0 / _zoom()
draw_arc(action_requested_target, r, 0.0, TAU, 20, request_color, LINE_WIDTH_PX / _zoom(), true)
if action_show_collision:
_draw_collision_marker(action_collision_position)
else:
_draw_ghost_marker(action_target, color)
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
@@ -113,6 +184,15 @@ func _draw_ghost_marker(pos: Vector2, color: Color) -> void:
draw_line(pos + Vector2(0.0, -r), pos + Vector2(0.0, r), color, LINE_WIDTH_PX / zoom, true)
func _draw_collision_marker(pos: Vector2) -> void:
var zoom := _zoom()
var r := 8.0 / zoom
var color := Color(1.0, 0.25, 0.15, 0.95)
draw_arc(pos, r, 0.0, TAU, 24, color, LINE_WIDTH_PX / zoom, true)
draw_line(pos + Vector2(-r * 0.55, -r * 0.55), pos + Vector2(r * 0.55, r * 0.55), color, LINE_WIDTH_PX / zoom, true)
draw_line(pos + Vector2(-r * 0.55, r * 0.55), pos + Vector2(r * 0.55, -r * 0.55), color, LINE_WIDTH_PX / zoom, true)
func _zoom() -> float:
if camera != null and is_instance_valid(camera):
return maxf(camera.zoom.x, 0.0001)
+44 -3
View File
@@ -3,7 +3,7 @@ extends RefCounted
## JSON boundary for Sandbox Stage documents. This class deliberately has no
## scene-tree dependencies, which makes rejected documents non-destructive.
const FILE_VERSION: String = "1.0"
const FILE_VERSION: String = "1.1"
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd")
@@ -149,17 +149,38 @@ static func write_file_safe(path: String, data: Dictionary) -> Dictionary:
return {"ok": true, "error": ""}
static func _migrate_to_current(data: Dictionary) -> Dictionary:
if String(data.get("version", "")) != FILE_VERSION: return {}
return data
var version := String(data.get("version", ""))
if version == FILE_VERSION:
return data
if version != "1.0":
return {}
var migrated: Dictionary = data.duplicate(true)
for raw: Variant in migrated.get("objects", []):
if not raw is Dictionary:
continue
var object: Dictionary = raw
if String(object.get("type", "")) == "prop":
object["stage_object_id"] = String(object.get("file_id", ""))
migrated["version"] = FILE_VERSION
return migrated
static func _validate_root(data: Dictionary) -> Dictionary:
var seen: Dictionary = {}
var seen_stage_ids: Dictionary = {}
for raw: Variant in data["objects"]:
if not raw is Dictionary: continue
var entry: Dictionary = raw
var file_id := String(entry.get("file_id", ""))
if file_id.is_empty() or seen.has(file_id): return {"ok": false, "error": "Object file IDs must be unique and non-empty."}
seen[file_id] = true
if String(entry.get("type", "")) == "prop":
var raw_stage_id: Variant = entry.get("stage_object_id", "")
if not raw_stage_id is String:
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty strings."}
var stage_id := String(raw_stage_id)
if stage_id.strip_edges().is_empty() or seen_stage_ids.has(stage_id):
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty."}
seen_stage_ids[stage_id] = true
return {"ok": true, "error": ""}
static func _recover_backup(path: String) -> void:
@@ -180,6 +201,17 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
return {"type": type, "target": vector_to_data(action["target"])}
"speak": return {"type": type, "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0)), "stop_current": bool(action.get("stop_current", false))} if _finite_number(action.get("duration", 2.0)) else {}
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
"pick_up_prop":
var prop_id := String(action.get("target_prop_id", ""))
var hand := String(action.get("hand", "auto"))
if prop_id.is_empty() or not ["auto", "left", "right"].has(hand): return {}
return {"type": type, "target_prop_id": prop_id, "hand": hand}
"drop_prop": return {"type": type}
"throw_prop":
if not action.get("target_position") is Vector2: return {}
var target_position: Vector2 = action["target_position"]
if not target_position.is_finite(): return {}
return {"type": type, "target_position": vector_to_data(target_position)}
_: return {"type": type}
static func _decode_flat_action(action: Dictionary) -> Dictionary:
@@ -195,6 +227,15 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary:
if bool(action.get("stop_current", false)): out["stop_current"] = true
return out
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
"pick_up_prop":
var prop_id := String(action.get("target_prop_id", ""))
var hand := String(action.get("hand", "auto"))
if prop_id.is_empty() or not ["auto", "left", "right"].has(hand): return {}
return {"type": type, "target_prop_id": prop_id, "hand": hand}
"drop_prop": return {"type": type}
"throw_prop":
if not is_vector_data(action.get("target_position")): return {}
return {"type": type, "target_position": data_to_vector(action["target_position"], Vector2.ZERO)}
_: return {"type": type}
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
+2
View File
@@ -176,6 +176,8 @@ func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock:
else:
prop = PROP_UTILS.spawn_prop(_world, object_position, template["payload"], int(template["material_preset"]), Vector2.ZERO)
prop.spawn_id = String(template.get("id", asset_id))
prop.set_interaction_anchors(template.get("anchors", {}) as Dictionary)
prop.ensure_default_grip()
return prop
+27 -3
View File
@@ -9,7 +9,7 @@ const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd")
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
enum RunnerState { IDLE, EXECUTING }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP_INTERACTION }
enum QueueStatus { NOT_STARTED, COMPLETED, INTERRUPTED, ACTIVE, PENDING }
signal action_started(action: Dictionary, index: int)
@@ -29,6 +29,7 @@ var action_queue: Array[Dictionary]:
set_authored_queue(value)
var _rig: StickmanRig = null
var _locomotion: StickmanLocomotion = null
var _prop_interactor: Node = null
var _runner_state: RunnerState = RunnerState.IDLE
var _action_phase: ActionPhase = ActionPhase.NONE
var _current_index: int = -1
@@ -46,11 +47,15 @@ var _completed_ids: Dictionary = {}
var _interrupted_ids: Dictionary = {}
var _paused: bool = false
var _session_started: bool = false
var _prop_action_finished: bool = false
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
func configure(rig: StickmanRig, locomotion: StickmanLocomotion, prop_interactor: Node = null) -> void:
_rig = rig
_locomotion = locomotion
_prop_interactor = prop_interactor
if _prop_interactor != null and not _prop_interactor.is_connected("action_completed", _on_prop_action_completed):
_prop_interactor.connect("action_completed", _on_prop_action_completed)
func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
@@ -78,6 +83,10 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
ActionPhase.RECOVERING:
if rig_state == StickmanRig.RigState.ANIMATED:
_finish_action()
ActionPhase.PROP_INTERACTION:
if _prop_action_finished:
_prop_action_finished = false
_finish_action()
func speak(text: String, duration: float) -> void:
@@ -358,7 +367,9 @@ func _advance_to_next_action() -> void:
_current_action_id = int(_current_run_item.get("action_id", -1))
_current_index = _index_for_action_id(_current_action_id) if _current_action_id >= 0 else -1
var action: Dictionary = _current_run_item.get("action", {})
if String(action.get("type", "")) == "walk_to" and _rig != null:
var action_type := String(action.get("type", ""))
var requires_animated := action_type == "walk_to" or action_type == "pick_up_prop" or action_type == "drop_prop" or action_type == "throw_prop"
if requires_animated and _rig != null:
if _rig.state == StickmanRig.RigState.RECOVERING:
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
_current_run_item.clear()
@@ -394,6 +405,12 @@ func _begin_action(action: Dictionary) -> void:
"recover":
_action_phase = ActionPhase.RECOVERING
recovery_requested.emit()
"pick_up_prop", "drop_prop", "throw_prop":
if _rig == null or not _rig.start_prop_action(action):
_finish_action()
else:
_prop_action_finished = false
_action_phase = ActionPhase.PROP_INTERACTION
_:
push_warning("StickmanActionRunner: unknown action type '%s'; skipped." % String(action.get("type", "")))
_finish_action()
@@ -420,9 +437,12 @@ func _cancel_current_primary(notify: bool = false) -> void:
active_action_cancelled.emit(action.duplicate(true))
if _locomotion != null:
_locomotion.cancel_walk(not _is_blocked())
if _rig != null:
_rig.cancel_prop_action()
_primary_speech.clear()
_phase_timer = 0.0
_action_phase = ActionPhase.NONE
_prop_action_finished = false
_current_run_item.clear()
_current_index = -1
_current_action_id = -1
@@ -430,6 +450,10 @@ func _cancel_current_primary(notify: bool = false) -> void:
_speech_bubble.hide_bubble()
func _on_prop_action_completed() -> void:
_prop_action_finished = true
func _allocate_action_id() -> int:
var result: int = _next_action_id
_next_action_id += 1
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://bsdnwby48vl5
+42 -1
View File
@@ -179,6 +179,7 @@ signal action_finished(action: Dictionary, index: int)
signal queue_finished # queue ran to completion (not on stop)
signal queue_changed # any queue mutation
signal speech_finished # bubble auto-hid after speak()
signal prop_interaction_event(event_type: String, prop: PropBlock, reason: String)
# ---------------------------------------------------------------------------
# Internal state
@@ -225,6 +226,7 @@ var source_stk_path: String = ""
var _locomotion_component: StickmanLocomotion = null
var _ragdoll_component: StickmanRagdoll = null
var _action_runner_component: StickmanActionRunner = null
var _prop_interactor_component: Node = null
var _simulation_paused: bool = false
var _phase12_paused_animation: bool = false
var _phase12_paused_animation_name: StringName = &""
@@ -238,12 +240,15 @@ func _ready() -> void:
_locomotion_component = get_node_or_null(NodePath("RigComponents/Locomotion")) as StickmanLocomotion
_ragdoll_component = get_node_or_null(NodePath("RigComponents/Ragdoll")) as StickmanRagdoll
_action_runner_component = get_node_or_null(NodePath("RigComponents/ActionRunner")) as StickmanActionRunner
_prop_interactor_component = get_node_or_null(NodePath("RigComponents/PropInteractor"))
if _locomotion_component == null:
push_warning("StickmanRig: missing RigComponents/Locomotion; locomotion boundary unavailable.")
if _ragdoll_component == null:
push_warning("StickmanRig: missing RigComponents/Ragdoll; ragdoll boundary unavailable.")
if _action_runner_component == null:
push_warning("StickmanRig: missing RigComponents/ActionRunner; action boundary unavailable.")
if _prop_interactor_component == null:
push_warning("StickmanRig: missing RigComponents/PropInteractor; prop interactions unavailable.")
if _locomotion_component != null:
_locomotion_component.configure(self)
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
@@ -251,8 +256,11 @@ func _ready() -> void:
_ragdoll_component.configure(self, _locomotion_component)
_ragdoll_component.recovery_requested.connect(func() -> void: request_recovery())
_ragdoll_component.state_transition_requested.connect(_set_state)
if _prop_interactor_component != null:
_prop_interactor_component.call("configure", self, _locomotion_component, Callable())
_prop_interactor_component.connect("prop_event", _on_prop_interaction_event)
if _action_runner_component != null:
_action_runner_component.configure(self, _locomotion_component)
_action_runner_component.configure(self, _locomotion_component, _prop_interactor_component)
_action_runner_component.action_queue = _pending_action_queue
_action_runner_component.action_started.connect(func(action: Dictionary, index: int) -> void: action_started.emit(action, index))
_action_runner_component.action_finished.connect(func(action: Dictionary, index: int) -> void: action_finished.emit(action, index))
@@ -315,6 +323,8 @@ func _physics_process(delta: float) -> void:
_ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover)
if _locomotion_component != null:
_locomotion_component.tick_movement(delta, int(state))
if _prop_interactor_component != null:
_prop_interactor_component.call("tick", delta)
if _action_runner_component != null:
_action_runner_component.tick(delta, int(state), is_ragdoll_at_rest())
_pin_mirrored_head_rotation()
@@ -457,6 +467,8 @@ func _set_state(next_state: int) -> void:
func set_ragdoll(enabled: bool) -> void:
if enabled:
if _prop_interactor_component != null:
_prop_interactor_component.call("disarm_for_ragdoll")
if _locomotion_component != null:
_locomotion_component.reset_for_ragdoll_handoff()
match state:
@@ -480,6 +492,35 @@ func toggle_ragdoll() -> void:
set_ragdoll(not is_in_ragdoll())
func set_prop_resolver(resolver: Callable) -> void:
if _prop_interactor_component != null:
_prop_interactor_component.call("configure", self, _locomotion_component, resolver)
func start_prop_action(action: Dictionary) -> bool:
return bool(_prop_interactor_component.call("start_action", action)) if _prop_interactor_component != null else false
func cancel_prop_action() -> void:
if _prop_interactor_component != null:
_prop_interactor_component.call("cancel_action")
func preview_prop_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary:
if _prop_interactor_component == null:
return {}
return _prop_interactor_component.call("preview_throw", target, queue_prefix)
func reset_prop_interactions_for_scene_exit() -> void:
if _prop_interactor_component != null:
_prop_interactor_component.call("reset_for_scene_exit")
func _on_prop_interaction_event(event_type: String, prop: PropBlock, reason: String) -> void:
prop_interaction_event.emit(event_type, prop, reason)
## Public stand-up request. No-op unless the rig is in RAGDOLL.
func request_recovery() -> void:
if state == RigState.RAGDOLL and _ragdoll_component != null:
+15
View File
@@ -32,6 +32,21 @@ const TRIGGER_TEMPLATES := {
"icon": "💥",
"target_type": "prop",
},
"prop_picked_up": {
"label": "Picks up a prop",
"icon": "🫴",
"target_type": "prop",
},
"prop_dropped": {
"label": "Drops a prop",
"icon": "⬇️",
"target_type": "prop",
},
"prop_thrown": {
"label": "Throws a prop",
"icon": "🏹",
"target_type": "prop",
},
}