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:
@@ -13,6 +13,7 @@ const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
||||
signal committed(action: Dictionary)
|
||||
signal cancelled()
|
||||
signal target_requested()
|
||||
signal prop_requested()
|
||||
|
||||
var _mode: String = "new"
|
||||
var _initial: Dictionary = {}
|
||||
@@ -21,11 +22,17 @@ var _params_box: VBoxContainer = null
|
||||
var _text_edit: LineEdit = null
|
||||
var _duration_spin: SpinBox = null
|
||||
var _target_label: Label = null
|
||||
var _prop_target_label: Label = null
|
||||
var _target_prop_display_name: String = ""
|
||||
var _set_target_btn: Button = null
|
||||
var _stop_current_check: CheckBox = null
|
||||
var _show_reactive_options: bool = false
|
||||
var _allow_prop_actions: bool = true
|
||||
var _has_target: bool = false
|
||||
var _target_pos: Vector2 = Vector2.ZERO
|
||||
var _target_prop_id: int = -1
|
||||
var _flight_time_spin: SpinBox = null
|
||||
var _hand_option: OptionButton = null
|
||||
|
||||
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
|
||||
## override (engine default); Font null = no override.
|
||||
@@ -49,10 +56,14 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
## Opens the editor in "add" mode (empty, default type walk_to).
|
||||
func open_new(show_reactive_options: bool = false) -> void:
|
||||
_show_reactive_options = show_reactive_options
|
||||
_allow_prop_actions = not show_reactive_options
|
||||
_rebuild_type_options()
|
||||
_mode = "new"
|
||||
_initial = {}
|
||||
_has_target = false
|
||||
_target_pos = Vector2.ZERO
|
||||
_target_prop_id = -1
|
||||
_target_prop_display_name = ""
|
||||
_select_type("walk_to")
|
||||
_rebuild_params("walk_to")
|
||||
title = "Add Action"
|
||||
@@ -62,11 +73,15 @@ func open_new(show_reactive_options: bool = false) -> void:
|
||||
## Opens the editor pre-filled from a flat queue action.
|
||||
func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
|
||||
_show_reactive_options = show_reactive_options
|
||||
_allow_prop_actions = not show_reactive_options
|
||||
_rebuild_type_options()
|
||||
_mode = "edit"
|
||||
_initial = action.duplicate(true)
|
||||
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
|
||||
_has_target = (action.has("target") and action["target"] is Vector2) or (action.has("target_position") and action["target_position"] is Vector2)
|
||||
_target_pos = action.get("target_position", action.get("target", Vector2.ZERO)) if _has_target else Vector2.ZERO
|
||||
_target_prop_id = int(action.get("target_prop", -1))
|
||||
_target_prop_display_name = ""
|
||||
_select_type(type)
|
||||
_rebuild_params(type)
|
||||
title = "Edit Action"
|
||||
@@ -80,6 +95,18 @@ func set_walk_target(pos: Vector2) -> void:
|
||||
_update_walk_target_label()
|
||||
popup_centered()
|
||||
|
||||
func set_prop_target(prop: PropBlock, display_name: String = "") -> void:
|
||||
_target_prop_id = prop.get_instance_id() if prop != null and is_instance_valid(prop) else -1
|
||||
_target_prop_display_name = display_name
|
||||
_update_prop_target_label()
|
||||
popup_centered()
|
||||
|
||||
func set_throw_target(pos: Vector2) -> void:
|
||||
_has_target = true
|
||||
_target_pos = pos
|
||||
_update_walk_target_label()
|
||||
popup_centered()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var margin := MarginContainer.new()
|
||||
@@ -100,7 +127,7 @@ func _build_ui() -> void:
|
||||
type_row.add_child(type_label)
|
||||
_type_option = OptionButton.new()
|
||||
_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
for type: String in ACTION_REGISTRY.types():
|
||||
for type: String in _available_types():
|
||||
_type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)])
|
||||
_type_option.item_selected.connect(_on_type_selected)
|
||||
type_row.add_child(_type_option)
|
||||
@@ -158,19 +185,42 @@ func _apply_font_to(c: Control, size: int) -> void:
|
||||
|
||||
|
||||
func _select_type(type: String) -> void:
|
||||
var idx := ACTION_REGISTRY.types().find(type)
|
||||
var idx := _available_types().find(type)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
_type_option.select(idx)
|
||||
|
||||
|
||||
func _current_type() -> String:
|
||||
var types := ACTION_REGISTRY.types()
|
||||
var types := _available_types()
|
||||
var idx := _type_option.selected
|
||||
if idx < 0 or idx >= types.size():
|
||||
return "walk_to"
|
||||
return types[idx]
|
||||
|
||||
func get_action_type() -> String:
|
||||
return _current_type()
|
||||
|
||||
func _available_types() -> Array[String]:
|
||||
var types: Array[String] = ACTION_REGISTRY.types()
|
||||
if not _allow_prop_actions:
|
||||
types.erase("pick_up_prop")
|
||||
types.erase("drop_prop")
|
||||
types.erase("throw_prop")
|
||||
return types
|
||||
|
||||
func _rebuild_type_options() -> void:
|
||||
if _type_option == null: return
|
||||
_type_option.clear()
|
||||
for type: String in _available_types():
|
||||
_type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)])
|
||||
|
||||
func get_target_prop_id() -> int:
|
||||
return _target_prop_id
|
||||
|
||||
func get_flight_time() -> float:
|
||||
return _flight_time_spin.value if _flight_time_spin != null else 0.9
|
||||
|
||||
|
||||
func _on_type_selected(_index: int) -> void:
|
||||
_rebuild_params(_current_type())
|
||||
@@ -183,8 +233,11 @@ func _rebuild_params(type: String) -> void:
|
||||
_text_edit = null
|
||||
_duration_spin = null
|
||||
_target_label = null
|
||||
_prop_target_label = null
|
||||
_set_target_btn = null
|
||||
_stop_current_check = null
|
||||
_flight_time_spin = null
|
||||
_hand_option = null
|
||||
|
||||
match type:
|
||||
"walk_to":
|
||||
@@ -222,6 +275,24 @@ func _rebuild_params(type: String) -> void:
|
||||
_params_box.add_child(wlabel)
|
||||
_duration_spin = _make_duration_spin(float(_initial.get("duration", 1.0)))
|
||||
_params_box.add_child(_duration_spin)
|
||||
"pick_up_prop", "throw_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)
|
||||
var prop_button := Button.new(); prop_button.text = "🧱 Click prop…"; prop_button.pressed.connect(func() -> void: prop_requested.emit()); prop_row.add_child(prop_button)
|
||||
_update_prop_target_label()
|
||||
if type == "pick_up_prop":
|
||||
var hand_label := Label.new(); hand_label.text = "Hand:"; _params_box.add_child(hand_label)
|
||||
_hand_option = OptionButton.new()
|
||||
for hand: String in ["auto", "left", "right"]: _hand_option.add_item(hand.capitalize())
|
||||
_hand_option.select(maxi(0, ["auto", "left", "right"].find(String(_initial.get("hand", "auto")))))
|
||||
_params_box.add_child(_hand_option)
|
||||
if type == "throw_prop":
|
||||
var target_row := HBoxContainer.new(); _params_box.add_child(target_row)
|
||||
_target_label = Label.new(); _target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL; target_row.add_child(_target_label)
|
||||
var target_button := Button.new(); target_button.text = "🎯 Click landing point…"; target_button.pressed.connect(func() -> void: target_requested.emit()); target_row.add_child(target_button)
|
||||
_update_walk_target_label()
|
||||
var flight_label := Label.new(); flight_label.text = "Flight (s):"; _params_box.add_child(flight_label)
|
||||
_flight_time_spin = _make_duration_spin(float(_initial.get("flight_time", 0.9))); _flight_time_spin.min_value = 0.35; _flight_time_spin.max_value = 1.5; _params_box.add_child(_flight_time_spin)
|
||||
_:
|
||||
# ragdoll / recover (and any future no-param action) need no fields.
|
||||
pass
|
||||
@@ -246,6 +317,15 @@ func _update_walk_target_label() -> void:
|
||||
else:
|
||||
_target_label.text = "Target: not set"
|
||||
|
||||
func _update_prop_target_label() -> void:
|
||||
if _prop_target_label != null:
|
||||
if _target_prop_id < 0:
|
||||
_prop_target_label.text = "Prop: not set"
|
||||
elif not _target_prop_display_name.is_empty():
|
||||
_prop_target_label.text = "Prop: %s" % _target_prop_display_name
|
||||
else:
|
||||
_prop_target_label.text = "Prop: selected"
|
||||
|
||||
|
||||
func _on_ok_pressed() -> void:
|
||||
var type := _current_type()
|
||||
@@ -264,4 +344,11 @@ func _on_ok_pressed() -> void:
|
||||
action["stop_current"] = _stop_current_check.button_pressed
|
||||
"wait":
|
||||
action["duration"] = _duration_spin.value if _duration_spin != null else 1.0
|
||||
"pick_up_prop":
|
||||
if _target_prop_id < 0: prop_requested.emit(); return
|
||||
action["target_prop"] = _target_prop_id; action["hand"] = ["auto", "left", "right"][_hand_option.selected] if _hand_option != null else "auto"
|
||||
"throw_prop":
|
||||
if _target_prop_id < 0: prop_requested.emit(); return
|
||||
if not _has_target: target_requested.emit(); return
|
||||
action["target_prop"] = _target_prop_id; action["target_position"] = _target_pos; action["flight_time"] = _flight_time_spin.value if _flight_time_spin != null else 0.9
|
||||
committed.emit(action)
|
||||
|
||||
@@ -47,6 +47,9 @@ const ACTION_TEMPLATES := {
|
||||
"params": [],
|
||||
"reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false,
|
||||
},
|
||||
"pick_up_prop": { "label": "Pick Up Prop", "icon": "🖐️", "params": [{"key": "target_prop", "type": "prop", "required": true}, {"key": "hand", "type": "hand", "default": "auto"}], "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_prop", "type": "prop", "required": true}, {"key": "target_position", "type": "position", "required": true}, {"key": "flight_time", "type": "float", "default": 0.9}], "reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false },
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +105,10 @@ 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"] = int(action.get("target_prop", -1)); params["hand"] = String(action.get("hand", "auto"))
|
||||
"throw_prop":
|
||||
params["target_prop"] = int(action.get("target_prop", -1)); params["target_position"] = action.get("target_position", Vector2.ZERO); params["flight_time"] = float(action.get("flight_time", 0.9))
|
||||
return {
|
||||
"type": String(action.get("type", "")),
|
||||
"target": target_id,
|
||||
@@ -126,6 +133,9 @@ 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": int(params.get("target_prop", -1)), "hand": String(params.get("hand", "auto"))}
|
||||
"drop_prop": return {"type": "drop_prop"}
|
||||
"throw_prop": return {"type": "throw_prop", "target_prop": int(params.get("target_prop", -1)), "target_position": params.get("target_position", Vector2.ZERO), "flight_time": float(params.get("flight_time", 0.9))}
|
||||
_:
|
||||
return { "type": String(rule_action.get("type", "")) }
|
||||
|
||||
@@ -155,6 +165,9 @@ static func summarize(action: Dictionary) -> String:
|
||||
return "Ragdoll"
|
||||
"recover":
|
||||
return "Recover"
|
||||
"pick_up_prop": return "Pick Up Prop"
|
||||
"drop_prop": return "Drop Prop"
|
||||
"throw_prop": return "Throw Prop"
|
||||
_:
|
||||
return type
|
||||
|
||||
|
||||
+16
-1
@@ -11,6 +11,7 @@ extends RigidBody2D
|
||||
## friction/bounce (PhysicsMaterial), and themed visuals.
|
||||
|
||||
const PropVisuals := preload("res://scripts/prop_visuals.gd")
|
||||
const PropAnchorData := preload("res://scripts/prop_anchor_data.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
@@ -22,6 +23,11 @@ enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
|
||||
|
||||
## Stable library provenance used by Sandbox Stage scene files.
|
||||
var spawn_id: String = ""
|
||||
## Runtime interaction metadata. Both values are additive so existing scenes
|
||||
## and built-in templates remain pickup-enabled.
|
||||
var can_be_picked_up: bool = true
|
||||
var anchors: Dictionary = PropAnchorData.default_anchors()
|
||||
var held_by_instance_id: int = 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Child node names
|
||||
@@ -275,7 +281,16 @@ func has_custom_shapes() -> bool:
|
||||
|
||||
|
||||
func get_visual_local_bounds() -> Rect2:
|
||||
return _visual_local_bounds
|
||||
if _custom_shapes:
|
||||
return _visual_local_bounds
|
||||
if shape_type == ShapeType.CIRCLE:
|
||||
return Rect2(Vector2.ONE * -radius, Vector2.ONE * radius * 2.0)
|
||||
if not polygon_points.is_empty():
|
||||
var bounds: Rect2 = Rect2(polygon_points[0], Vector2.ZERO)
|
||||
for point: Vector2 in polygon_points:
|
||||
bounds = bounds.expand(point)
|
||||
return bounds
|
||||
return Rect2()
|
||||
|
||||
## RigidBody2D transforms do not reliably retain scale during simulation. Move
|
||||
## the authored uniform scale into child visuals and collision geometry for
|
||||
|
||||
+14
-1
@@ -110,6 +110,7 @@ var _current_path: String = ""
|
||||
var _dirty: bool = false
|
||||
var _clipboard: Dictionary = {}
|
||||
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
|
||||
var _can_be_picked_up: bool = true
|
||||
var _selected_anchor_id: String = ""
|
||||
var _thumb: PropThumbnail
|
||||
var _thumb_cache: ThumbnailCache
|
||||
@@ -121,6 +122,7 @@ var _material_section: VBoxContainer
|
||||
var _mass_label: Label
|
||||
var _friction_label: Label
|
||||
var _bounce_label: Label
|
||||
var _can_be_picked_up_check: CheckBox
|
||||
var _fill_button: ColorPickerButton
|
||||
var _outline_button: ColorPickerButton
|
||||
var _width_spin: SpinBox
|
||||
@@ -790,6 +792,8 @@ func _do_new() -> void:
|
||||
_set_whole_prop_resize_enabled(false)
|
||||
_prop_name_edit.text = ""
|
||||
_material_preset = PropBlockScript.MaterialPreset.NONE
|
||||
_can_be_picked_up = true
|
||||
if _can_be_picked_up_check != null: _can_be_picked_up_check.button_pressed = true
|
||||
_preset_option.selected = _material_preset
|
||||
_update_physics_labels()
|
||||
_canvas.clear()
|
||||
@@ -855,6 +859,13 @@ func _build_inspector() -> void:
|
||||
_material_section.add_child(_mass_label)
|
||||
_material_section.add_child(_friction_label)
|
||||
_material_section.add_child(_bounce_label)
|
||||
_can_be_picked_up_check = CheckBox.new()
|
||||
_can_be_picked_up_check.text = "Can be picked up"
|
||||
_can_be_picked_up_check.button_pressed = _can_be_picked_up
|
||||
_can_be_picked_up_check.toggled.connect(func(enabled: bool) -> void:
|
||||
_can_be_picked_up = enabled
|
||||
_mark_dirty())
|
||||
_material_section.add_child(_can_be_picked_up_check)
|
||||
_update_physics_labels()
|
||||
|
||||
# Selected-shape section
|
||||
@@ -1454,7 +1465,7 @@ func _check_autosave_recovery() -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _build_json_data() -> Dictionary:
|
||||
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data(), _anchors)
|
||||
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data(), _anchors, _can_be_picked_up)
|
||||
|
||||
|
||||
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
|
||||
@@ -1465,6 +1476,8 @@ func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
|
||||
return "The file could not be read: unsupported version or malformed data."
|
||||
_prop_name_edit.text = str(model.get("prop_name", ""))
|
||||
_material_preset = int(model.get("material_preset", PropBlockScript.MaterialPreset.NONE))
|
||||
_can_be_picked_up = bool(model.get("can_be_picked_up", true))
|
||||
if _can_be_picked_up_check != null: _can_be_picked_up_check.button_pressed = _can_be_picked_up
|
||||
if _preset_option != null:
|
||||
_preset_option.selected = _material_preset
|
||||
_update_physics_labels()
|
||||
|
||||
@@ -62,7 +62,7 @@ static func sanitize_shape(shape: Dictionary, grid_size: float = TerrainUtilsScr
|
||||
return result
|
||||
|
||||
|
||||
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary], anchors: Dictionary = {}) -> Dictionary:
|
||||
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary], anchors: Dictionary = {}, can_be_picked_up: bool = true) -> Dictionary:
|
||||
var serialized: Array = []
|
||||
for shape: Dictionary in shapes:
|
||||
serialized.append(_serialize_shape(shape))
|
||||
@@ -73,6 +73,7 @@ static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[
|
||||
"material_preset": preset_to_string(material_preset),
|
||||
"shapes": serialized,
|
||||
"anchors": PropAnchorDataScript.serialize_anchors(anchors),
|
||||
"can_be_picked_up": can_be_picked_up,
|
||||
"metadata": {"created_at": now, "modified_at": now},
|
||||
}
|
||||
|
||||
@@ -108,6 +109,7 @@ static func from_file_dict(data: Variant) -> Dictionary:
|
||||
"material_preset": preset,
|
||||
"shapes": shapes,
|
||||
"anchors": PropAnchorDataScript.normalize_anchors(dict.get("anchors", {})),
|
||||
"can_be_picked_up": dict.get("can_be_picked_up") if dict.get("can_be_picked_up") is bool else true,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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", {}), "can_be_picked_up": bool(parsed.get("can_be_picked_up", true))}
|
||||
|
||||
|
||||
static func is_custom_path(path: String) -> bool:
|
||||
@@ -110,4 +110,6 @@ static func _make(id: String, name: String, preset: int, label: String, payload:
|
||||
"path": "",
|
||||
"shapes": [],
|
||||
"collision_hull": PackedVector2Array(),
|
||||
"anchors": {},
|
||||
"can_be_picked_up": true,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
class_name PropTrajectory
|
||||
extends RefCounted
|
||||
## Shared deterministic ballistic solver for director feedback and playback.
|
||||
|
||||
const MIN_FLIGHT_TIME: float = 0.35
|
||||
const MAX_FLIGHT_TIME: float = 1.5
|
||||
const MAX_LAUNCH_SPEED: float = 900.0
|
||||
|
||||
static func solve_throw_velocity(release_point: Vector2, target_point: Vector2, flight_time: float, gravity_vector: Vector2) -> Dictionary:
|
||||
if not release_point.is_finite() or not target_point.is_finite() or not gravity_vector.is_finite():
|
||||
return {"ok": false, "reason": "non_finite"}
|
||||
if not is_finite(flight_time) or flight_time < MIN_FLIGHT_TIME or flight_time > MAX_FLIGHT_TIME:
|
||||
return {"ok": false, "reason": "flight_time"}
|
||||
var velocity: Vector2 = (target_point - release_point - gravity_vector * 0.5 * flight_time * flight_time) / flight_time
|
||||
if not velocity.is_finite() or velocity.length() > MAX_LAUNCH_SPEED:
|
||||
return {"ok": false, "reason": "speed"}
|
||||
return {"ok": true, "velocity": velocity, "flight_time": flight_time, "reason": ""}
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddw3n0jjlduk
|
||||
+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:
|
||||
|
||||
@@ -20,6 +20,8 @@ var action_visible: bool = false
|
||||
var action_origin: Vector2 = Vector2.ZERO
|
||||
var action_target: Vector2 = Vector2.ZERO
|
||||
var action_valid: bool = true
|
||||
var action_arc: PackedVector2Array = PackedVector2Array()
|
||||
var action_is_throw: 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
|
||||
@@ -63,10 +65,24 @@ func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> voi
|
||||
action_origin = origin
|
||||
action_target = target
|
||||
action_valid = valid
|
||||
action_arc = PackedVector2Array()
|
||||
action_is_throw = false
|
||||
|
||||
## Displays a sampled ballistic arc. `points` must come from the same solver
|
||||
## that validates the eventual physics release.
|
||||
func set_throw_trajectory(origin: Vector2, target: Vector2, points: PackedVector2Array, valid: bool) -> void:
|
||||
action_visible = true
|
||||
action_origin = origin
|
||||
action_target = target
|
||||
action_valid = valid
|
||||
action_arc = points.duplicate()
|
||||
action_is_throw = true
|
||||
|
||||
|
||||
func clear_action() -> void:
|
||||
action_visible = false
|
||||
action_arc = PackedVector2Array()
|
||||
action_is_throw = false
|
||||
queue_redraw()
|
||||
|
||||
|
||||
@@ -77,8 +93,24 @@ func _draw() -> void:
|
||||
_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)
|
||||
if action_is_throw and action_arc.size() >= 2:
|
||||
_draw_arc_path(action_arc, color)
|
||||
else:
|
||||
_draw_dashed(action_origin, action_target, color)
|
||||
_draw_ghost_marker(action_target, color)
|
||||
if not action_valid:
|
||||
_draw_invalid_marker(action_target, color)
|
||||
|
||||
func _draw_arc_path(points: PackedVector2Array, color: Color) -> void:
|
||||
var zoom: float = _zoom()
|
||||
for index: int in range(points.size() - 1):
|
||||
draw_line(points[index], points[index + 1], color, line_width_px / zoom, true)
|
||||
|
||||
func _draw_invalid_marker(pos: Vector2, color: Color) -> void:
|
||||
var zoom: float = _zoom()
|
||||
var radius: float = MARKER_RADIUS_PX * 1.35 / zoom
|
||||
draw_line(pos - Vector2(radius, radius), pos + Vector2(radius, radius), color, LINE_WIDTH_PX * 1.5 / zoom, true)
|
||||
draw_line(pos + Vector2(radius, -radius), pos + Vector2(-radius, radius), color, LINE_WIDTH_PX * 1.5 / zoom, true)
|
||||
|
||||
|
||||
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
|
||||
|
||||
@@ -22,25 +22,25 @@ static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2:
|
||||
var data: Dictionary = value
|
||||
return Vector2(float(data["x"]), float(data["y"]))
|
||||
|
||||
static func encode_queue(actions: Array[Dictionary]) -> Dictionary:
|
||||
static func encode_queue(actions: Array[Dictionary], runtime_to_file: Dictionary = {}) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var warnings: Array[String] = []
|
||||
for action: Dictionary in actions:
|
||||
var encoded := _encode_flat_action(action)
|
||||
var encoded := _encode_flat_action(action, runtime_to_file)
|
||||
if encoded.is_empty():
|
||||
warnings.append("Skipped invalid queue action.")
|
||||
else:
|
||||
out.append(encoded)
|
||||
return {"actions": out, "warnings": warnings}
|
||||
|
||||
static func decode_queue(entries: Array) -> Dictionary:
|
||||
static func decode_queue(entries: Array, file_to_runtime: Dictionary = {}, unavailable_ids: Dictionary = {}) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var warnings: Array[String] = []
|
||||
for entry: Variant in entries:
|
||||
if not entry is Dictionary:
|
||||
warnings.append("Skipped malformed queue action.")
|
||||
continue
|
||||
var decoded := _decode_flat_action(entry as Dictionary)
|
||||
var decoded := _decode_flat_action(entry as Dictionary, file_to_runtime, unavailable_ids)
|
||||
if decoded.is_empty():
|
||||
warnings.append("Skipped invalid queue action.")
|
||||
else:
|
||||
@@ -171,7 +171,7 @@ static func _recover_backup(path: String) -> void:
|
||||
static func _finite_number(value: Variant) -> bool:
|
||||
return (value is float or value is int) and is_finite(float(value))
|
||||
|
||||
static func _encode_flat_action(action: Dictionary) -> Dictionary:
|
||||
static func _encode_flat_action(action: Dictionary, refs: Dictionary = {}) -> Dictionary:
|
||||
var type := String(action.get("type", ""))
|
||||
if not ACTION_REGISTRY.has_type(type): return {}
|
||||
match type:
|
||||
@@ -180,9 +180,20 @@ 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: Variant = _map_ref(action.get("target_prop", -1), refs)
|
||||
var hand: String = String(action.get("hand", "auto"))
|
||||
return {"type": type, "target_prop_id": prop_id, "hand": hand} if prop_id != null and ["auto", "left", "right"].has(hand) else {}
|
||||
"drop_prop": return {"type": type}
|
||||
"throw_prop":
|
||||
var throw_id: Variant = _map_ref(action.get("target_prop", -1), refs)
|
||||
var target: Variant = action.get("target_position")
|
||||
var time: float = float(action.get("flight_time", 0.0))
|
||||
if throw_id == null or not target is Vector2 or not target.is_finite() or not _finite_number(time): return {}
|
||||
return {"type": type, "target_prop_id": throw_id, "target_position": vector_to_data(target as Vector2), "flight_time": time}
|
||||
_: return {"type": type}
|
||||
|
||||
static func _decode_flat_action(action: Dictionary) -> Dictionary:
|
||||
static func _decode_flat_action(action: Dictionary, refs: Dictionary = {}, unavailable: Dictionary = {}) -> Dictionary:
|
||||
var type := String(action.get("type", ""))
|
||||
if not ACTION_REGISTRY.has_type(type): return {}
|
||||
match type:
|
||||
@@ -195,6 +206,17 @@ 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 pickup_id: int = _unmap_ref(action.get("target_prop_id"), refs, unavailable)
|
||||
var hand: String = String(action.get("hand", "auto"))
|
||||
return {"type": type, "target_prop": pickup_id, "hand": hand} if pickup_id >= 0 and ["auto", "left", "right"].has(hand) else {}
|
||||
"drop_prop": return {"type": type}
|
||||
"throw_prop":
|
||||
var throw_id: int = _unmap_ref(action.get("target_prop_id"), refs, unavailable)
|
||||
var time: float = float(action.get("flight_time", 0.0))
|
||||
if throw_id < 0 or not is_vector_data(action.get("target_position")) or not _finite_number(time): return {}
|
||||
if time < 0.35 or time > 1.5: return {}
|
||||
return {"type": type, "target_prop": throw_id, "target_position": data_to_vector(action["target_position"], Vector2.ZERO), "flight_time": time}
|
||||
_: return {"type": type}
|
||||
|
||||
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
|
||||
@@ -229,12 +251,12 @@ static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Di
|
||||
|
||||
static func _encode_rule_action(action: Dictionary, refs: Dictionary) -> Dictionary:
|
||||
var id: Variant = _map_ref(action.get("target", -1), refs); if id == null and int(action.get("target", -1)) != -1: return {}
|
||||
var flat := _encode_flat_action(ACTION_REGISTRY.from_rule_action(action)); if flat.is_empty(): return {}
|
||||
var flat := _encode_flat_action(ACTION_REGISTRY.from_rule_action(action), refs); if flat.is_empty(): return {}
|
||||
var params := flat.duplicate(); params.erase("type")
|
||||
return {"type": String(flat["type"]), "target_id": id, "params": params}
|
||||
|
||||
static func _decode_rule_action(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
|
||||
var target := _unmap_ref(value.get("target_id"), refs, unavailable); if target == -2: return {}
|
||||
var flat: Dictionary = value.get("params", {}).duplicate() if value.get("params") is Dictionary else {}
|
||||
flat["type"] = String(value.get("type", "")); var decoded := _decode_flat_action(flat); if decoded.is_empty(): return {}
|
||||
flat["type"] = String(value.get("type", "")); var decoded := _decode_flat_action(flat, refs, unavailable); if decoded.is_empty(): return {}
|
||||
return ACTION_REGISTRY.to_rule_action(decoded, target)
|
||||
|
||||
@@ -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.anchors = template.get("anchors", {}).duplicate(true)
|
||||
prop.can_be_picked_up = bool(template.get("can_be_picked_up", true))
|
||||
return prop
|
||||
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
signal action_started(action: Dictionary, index: int)
|
||||
signal action_finished(action: Dictionary, index: int)
|
||||
@@ -22,6 +22,7 @@ signal recovery_requested
|
||||
var action_queue: Array[Dictionary] = []
|
||||
var _rig: StickmanRig = null
|
||||
var _locomotion: StickmanLocomotion = null
|
||||
var _prop_interaction: StickmanPropInteraction = null
|
||||
var _runner_state: RunnerState = RunnerState.IDLE
|
||||
var _action_phase: ActionPhase = ActionPhase.NONE
|
||||
var _current_index: int = -1
|
||||
@@ -34,9 +35,12 @@ var _overlay_speech: Dictionary = {}
|
||||
var _speech_bubble: Node2D = null
|
||||
|
||||
|
||||
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
|
||||
func configure(rig: StickmanRig, locomotion: StickmanLocomotion, prop_interaction: StickmanPropInteraction = null) -> void:
|
||||
_rig = rig
|
||||
_locomotion = locomotion
|
||||
_prop_interaction = prop_interaction
|
||||
if _prop_interaction != null and not _prop_interaction.interaction_finished.is_connected(_on_interaction_finished):
|
||||
_prop_interaction.interaction_finished.connect(_on_interaction_finished)
|
||||
|
||||
|
||||
func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
|
||||
@@ -62,6 +66,9 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
|
||||
ActionPhase.RECOVERING:
|
||||
if rig_state == StickmanRig.RigState.ANIMATED:
|
||||
_finish_action()
|
||||
ActionPhase.PROP:
|
||||
if _prop_interaction != null:
|
||||
_prop_interaction.tick(delta)
|
||||
|
||||
|
||||
func speak(text: String, duration: float) -> void:
|
||||
@@ -241,6 +248,10 @@ func _begin_action(action: Dictionary) -> void:
|
||||
"recover":
|
||||
_action_phase = ActionPhase.RECOVERING
|
||||
recovery_requested.emit()
|
||||
"pick_up_prop", "drop_prop", "throw_prop":
|
||||
_action_phase = ActionPhase.PROP
|
||||
if _prop_interaction == null or not _prop_interaction.begin_action(action):
|
||||
_finish_action()
|
||||
_:
|
||||
push_warning("StickmanActionRunner: unknown action type '%s'; skipped." % String(action.get("type", "")))
|
||||
_finish_action()
|
||||
@@ -257,6 +268,8 @@ func _finish_action() -> void:
|
||||
|
||||
|
||||
func _cancel_current_primary() -> void:
|
||||
if _prop_interaction != null:
|
||||
_prop_interaction.cancel()
|
||||
if _locomotion != null:
|
||||
_locomotion.cancel_walk(not _is_blocked())
|
||||
_primary_speech.clear()
|
||||
@@ -267,6 +280,10 @@ func _cancel_current_primary() -> void:
|
||||
if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_speech_bubble.hide_bubble()
|
||||
|
||||
func _on_interaction_finished(_succeeded: bool) -> void:
|
||||
if _action_phase == ActionPhase.PROP:
|
||||
_finish_action()
|
||||
|
||||
|
||||
func _is_blocked() -> bool:
|
||||
return _locomotion != null and _locomotion.is_action_blocked()
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
class_name StickmanPropInteraction
|
||||
extends Node
|
||||
## Owns the one-prop hand interaction lifecycle for a StickmanRig.
|
||||
|
||||
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
||||
const PROP_ANCHOR_DATA := preload("res://scripts/prop_anchor_data.gd")
|
||||
const PROP_TRAJECTORY := preload("res://scripts/prop_trajectory.gd")
|
||||
|
||||
const REACH_DURATION: float = 0.18
|
||||
const ARM_REACH: float = 360.0
|
||||
const RELEASE_SPIN: float = 1.5
|
||||
enum State { IDLE, APPROACHING, REACHING, HOLDING, WINDUP }
|
||||
|
||||
signal interaction_finished(succeeded: bool)
|
||||
signal prop_transition(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName)
|
||||
|
||||
var _rig: StickmanRig = null
|
||||
var _locomotion: StickmanLocomotion = null
|
||||
var _state: State = State.IDLE
|
||||
var _held_prop: PropBlock = null
|
||||
var _reserved_prop: PropBlock = null
|
||||
var _hand: StringName = &"right"
|
||||
var _grip: Dictionary = {}
|
||||
var _saved_hand_target: Vector2 = Vector2.ZERO
|
||||
var _timer: float = 0.0
|
||||
var _pending_action: Dictionary = {}
|
||||
var _saved_physics: Dictionary = {}
|
||||
var _last_rig_rotation: float = 0.0
|
||||
var _angular_snapshot: float = 0.0
|
||||
|
||||
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
|
||||
_rig = rig
|
||||
_locomotion = locomotion
|
||||
|
||||
func has_held_prop() -> bool:
|
||||
return _held_prop != null and is_instance_valid(_held_prop)
|
||||
|
||||
func get_held_prop() -> PropBlock:
|
||||
return _held_prop if has_held_prop() else null
|
||||
|
||||
func begin_action(action: Dictionary) -> bool:
|
||||
var type: String = String(action.get("type", ""))
|
||||
match type:
|
||||
"pick_up_prop":
|
||||
return _begin_pickup(action)
|
||||
"drop_prop":
|
||||
if not has_held_prop():
|
||||
return false
|
||||
release_held(&"drop", _rig_velocity(), 0.0, &"prop_dropped")
|
||||
interaction_finished.emit(true)
|
||||
return true
|
||||
"throw_prop":
|
||||
return _begin_throw(action)
|
||||
return false
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
if has_held_prop():
|
||||
_update_attachment()
|
||||
if _state == State.APPROACHING:
|
||||
if _locomotion != null and _locomotion.consume_walk_completed():
|
||||
_state = State.REACHING
|
||||
_timer = REACH_DURATION
|
||||
return
|
||||
if _state != State.REACHING and _state != State.WINDUP:
|
||||
return
|
||||
_timer -= delta
|
||||
if _state == State.REACHING:
|
||||
_update_reach_target()
|
||||
if _timer <= 0.0:
|
||||
_complete_pickup()
|
||||
elif _timer <= 0.0:
|
||||
_complete_throw()
|
||||
|
||||
func update_held_attachment(delta: float) -> void:
|
||||
if _rig != null and is_instance_valid(_rig) and delta > 0.0:
|
||||
_angular_snapshot = wrapf(_rig.global_rotation - _last_rig_rotation, -PI, PI) / delta
|
||||
_last_rig_rotation = _rig.global_rotation
|
||||
if has_held_prop(): _update_attachment()
|
||||
|
||||
func cancel() -> void:
|
||||
if _state == State.APPROACHING or _state == State.REACHING or _state == State.WINDUP:
|
||||
_restore_hand_target()
|
||||
if _reserved_prop != null and is_instance_valid(_reserved_prop):
|
||||
_reserved_prop.held_by_instance_id = 0
|
||||
_reserved_prop = null
|
||||
_pending_action.clear()
|
||||
_state = State.IDLE
|
||||
|
||||
func release_for_ragdoll() -> void:
|
||||
if has_held_prop():
|
||||
release_held(&"ragdoll", _rig_velocity(), _angular_snapshot if is_finite(_angular_snapshot) else 0.0, &"prop_dropped")
|
||||
|
||||
func release_for_lifecycle(reason: StringName = &"lifecycle") -> void:
|
||||
if has_held_prop():
|
||||
release_held(reason, _rig_velocity(), 0.0, &"prop_dropped")
|
||||
else:
|
||||
cancel()
|
||||
|
||||
func release_held(reason: StringName, linear_velocity: Vector2, angular_velocity: float, event_type: StringName) -> bool:
|
||||
if not has_held_prop():
|
||||
return false
|
||||
var prop: PropBlock = _held_prop
|
||||
_update_attachment()
|
||||
prop.freeze = bool(_saved_physics.get("freeze", false))
|
||||
prop.freeze_mode = int(_saved_physics.get("freeze_mode", RigidBody2D.FREEZE_MODE_STATIC))
|
||||
prop.collision_layer = int(_saved_physics.get("collision_layer", prop.collision_layer))
|
||||
prop.collision_mask = int(_saved_physics.get("collision_mask", prop.collision_mask))
|
||||
prop.z_index = int(_saved_physics.get("z_index", prop.z_index))
|
||||
prop.gravity_scale = float(_saved_physics.get("gravity_scale", prop.gravity_scale))
|
||||
prop.linear_damp = float(_saved_physics.get("linear_damp", prop.linear_damp))
|
||||
prop.angular_damp = float(_saved_physics.get("angular_damp", prop.angular_damp))
|
||||
prop.linear_velocity = linear_velocity if linear_velocity.is_finite() else Vector2.ZERO
|
||||
prop.angular_velocity = angular_velocity if is_finite(angular_velocity) else 0.0
|
||||
prop.held_by_instance_id = 0
|
||||
_held_prop = null
|
||||
_reserved_prop = null
|
||||
_saved_physics.clear()
|
||||
_restore_hand_target()
|
||||
_state = State.IDLE
|
||||
prop_transition.emit(event_type, _rig, prop, reason)
|
||||
return true
|
||||
|
||||
static func gravity_for(prop: PropBlock) -> Vector2:
|
||||
var direction: Vector2 = ProjectSettings.get_setting("physics/2d/default_gravity_vector", Vector2.DOWN)
|
||||
var magnitude: float = float(ProjectSettings.get_setting("physics/2d/default_gravity", 980.0))
|
||||
return direction * magnitude * prop.gravity_scale
|
||||
|
||||
func _begin_pickup(action: Dictionary) -> bool:
|
||||
if has_held_prop() or _state != State.IDLE:
|
||||
return false
|
||||
var prop: PropBlock = instance_from_id(int(action.get("target_prop", 0))) as PropBlock
|
||||
if prop == null or not is_instance_valid(prop) or not prop.can_be_picked_up or prop.held_by_instance_id != 0:
|
||||
return false
|
||||
var choice: Dictionary = _choose_grip(prop, String(action.get("hand", "auto")))
|
||||
if choice.is_empty():
|
||||
return false
|
||||
_reserved_prop = prop
|
||||
_reserved_prop.held_by_instance_id = _rig.get_instance_id()
|
||||
_hand = StringName(choice["hand"])
|
||||
_grip = choice["grip"]
|
||||
_saved_hand_target = _hand_marker().global_position
|
||||
_last_rig_rotation = _rig.global_rotation
|
||||
_pending_action = action.duplicate(true)
|
||||
var grip_world: Vector2 = _grip_world_point(prop, _grip)
|
||||
if _can_reach(grip_world):
|
||||
_state = State.REACHING
|
||||
_timer = REACH_DURATION
|
||||
else:
|
||||
var bounds: Rect2 = prop.get_visual_local_bounds()
|
||||
var left: float = prop.to_global(Vector2(bounds.position.x, bounds.get_center().y)).x
|
||||
var right: float = prop.to_global(Vector2(bounds.end.x, bounds.get_center().y)).x
|
||||
var side: float = -1.0 if _rig.global_position.x <= grip_world.x else 1.0
|
||||
var feet: Vector2 = _rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
var stand_point: Vector2 = Vector2(left - ARM_REACH * 0.65 if side < 0.0 else right + ARM_REACH * 0.65, feet.y)
|
||||
if _locomotion == null or not _locomotion.walk_to(stand_point):
|
||||
cancel()
|
||||
return false
|
||||
_state = State.APPROACHING
|
||||
return true
|
||||
|
||||
func _complete_pickup() -> void:
|
||||
if _reserved_prop == null or not is_instance_valid(_reserved_prop) or not _can_reach(_grip_world_point(_reserved_prop, _grip)):
|
||||
cancel()
|
||||
interaction_finished.emit(false)
|
||||
return
|
||||
_held_prop = _reserved_prop
|
||||
_saved_physics = {"freeze": _held_prop.freeze, "freeze_mode": _held_prop.freeze_mode, "collision_layer": _held_prop.collision_layer, "collision_mask": _held_prop.collision_mask, "z_index": _held_prop.z_index, "gravity_scale": _held_prop.gravity_scale, "linear_damp": _held_prop.linear_damp, "angular_damp": _held_prop.angular_damp}
|
||||
_held_prop.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
_held_prop.freeze = true
|
||||
_held_prop.collision_layer = 0
|
||||
_held_prop.collision_mask = 0
|
||||
_held_prop.linear_velocity = Vector2.ZERO
|
||||
_held_prop.angular_velocity = 0.0
|
||||
_held_prop.z_index += int(_grip.get("z_index_offset", 0))
|
||||
_state = State.HOLDING
|
||||
_update_attachment()
|
||||
prop_transition.emit(&"prop_picked_up", _rig, _held_prop, &"pickup")
|
||||
interaction_finished.emit(true)
|
||||
|
||||
func _begin_throw(action: Dictionary) -> bool:
|
||||
if not has_held_prop() or _state != State.HOLDING:
|
||||
return false
|
||||
var requested: int = int(action.get("target_prop", _held_prop.get_instance_id()))
|
||||
if requested != _held_prop.get_instance_id() or not action.get("target_position") is Vector2:
|
||||
return false
|
||||
var time: float = float(action.get("flight_time", 0.0))
|
||||
var solved: Dictionary = PROP_TRAJECTORY.solve_throw_velocity(_hand_marker().global_position, action["target_position"] as Vector2, time, gravity_for(_held_prop))
|
||||
if not bool(solved.get("ok", false)):
|
||||
return false
|
||||
_pending_action = action.duplicate(true)
|
||||
_timer = 0.10
|
||||
_state = State.WINDUP
|
||||
return true
|
||||
|
||||
func _complete_throw() -> void:
|
||||
if not has_held_prop() or not _pending_action.get("target_position") is Vector2:
|
||||
cancel()
|
||||
interaction_finished.emit(false)
|
||||
return
|
||||
var solved: Dictionary = PROP_TRAJECTORY.solve_throw_velocity(_hand_marker().global_position, _pending_action["target_position"] as Vector2, float(_pending_action.get("flight_time", 0.0)), gravity_for(_held_prop))
|
||||
if not bool(solved.get("ok", false)):
|
||||
_restore_hand_target(); _state = State.HOLDING; interaction_finished.emit(false); return
|
||||
release_held(&"throw", solved["velocity"] as Vector2, RELEASE_SPIN, &"prop_thrown")
|
||||
interaction_finished.emit(true)
|
||||
|
||||
func _choose_grip(prop: PropBlock, requested_hand: String) -> Dictionary:
|
||||
var options: Array[Dictionary] = []
|
||||
for hand_id: String in ["right", "left"]:
|
||||
if requested_hand != "auto" and requested_hand != hand_id:
|
||||
continue
|
||||
var enum_hand: int = PROP_ANCHOR_DATA.Hand.RIGHT if hand_id == "right" else PROP_ANCHOR_DATA.Hand.LEFT
|
||||
for id: String in PROP_ANCHOR_DATA.compatible_grip_ids(prop.anchors, enum_hand):
|
||||
options.append({"hand": hand_id, "grip": prop.anchors[id], "id": id})
|
||||
if options.is_empty():
|
||||
var bounds: Rect2 = prop.get_visual_local_bounds()
|
||||
var fallback: Dictionary = {"position": bounds.get_center(), "rotation_deg": 0.0, "z_index_offset": 0, "allowed_hands": "both"}
|
||||
for fallback_hand: String in ["right", "left"]:
|
||||
if requested_hand == "auto" or requested_hand == fallback_hand:
|
||||
options.append({"hand": fallback_hand, "grip": fallback, "id": "fallback"})
|
||||
options.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var ad: float = _marker_for(String(a["hand"])).global_position.distance_squared_to(_grip_world_point(prop, a["grip"] as Dictionary))
|
||||
var bd: float = _marker_for(String(b["hand"])).global_position.distance_squared_to(_grip_world_point(prop, b["grip"] as Dictionary))
|
||||
if not is_equal_approx(ad, bd): return ad < bd
|
||||
if String(a["hand"]) != String(b["hand"]): return String(a["hand"]) == "right"
|
||||
return String(a["id"]) < String(b["id"])
|
||||
)
|
||||
return options[0]
|
||||
|
||||
func _update_reach_target() -> void:
|
||||
if _reserved_prop == null or not is_instance_valid(_reserved_prop):
|
||||
return
|
||||
var goal: Vector2 = _reserved_prop.global_transform * (_grip.get("position", Vector2.ZERO) as Vector2)
|
||||
_hand_marker().global_position = _saved_hand_target.lerp(goal, clampf(1.0 - _timer / REACH_DURATION, 0.0, 1.0))
|
||||
|
||||
func _update_attachment() -> void:
|
||||
if not has_held_prop(): return
|
||||
var wrist: Transform2D = _hand_marker().global_transform
|
||||
_held_prop.global_transform = PROP_ANCHOR_DATA.solve_attachment_transform(wrist, _grip, _held_prop.scale, _rig.facing_profile == StickmanRig.FacingProfile.LEFT)
|
||||
|
||||
func _can_reach(point: Vector2) -> bool:
|
||||
return _hand_marker().global_position.distance_to(point) <= ARM_REACH
|
||||
|
||||
func _grip_world_point(prop: PropBlock, grip: Dictionary) -> Vector2:
|
||||
return prop.global_transform * (grip.get("position", Vector2.ZERO) as Vector2)
|
||||
|
||||
func _hand_marker() -> Marker2D:
|
||||
return _marker_for(String(_hand))
|
||||
|
||||
func _marker_for(hand: String) -> Marker2D:
|
||||
return _rig._get_ik_marker("Right_Hand" if hand == "right" else "Left_Hand")
|
||||
|
||||
func _restore_hand_target() -> void:
|
||||
var marker: Marker2D = _hand_marker()
|
||||
if marker != null and is_instance_valid(marker): marker.global_position = _saved_hand_target
|
||||
|
||||
func _rig_velocity() -> Vector2:
|
||||
if _locomotion != null and _locomotion.terrain_air_velocity.is_finite(): return _locomotion.terrain_air_velocity
|
||||
return Vector2.ZERO
|
||||
@@ -0,0 +1 @@
|
||||
uid://p3owck52oug2
|
||||
+18
-1
@@ -225,6 +225,7 @@ var source_stk_path: String = ""
|
||||
var _locomotion_component: StickmanLocomotion = null
|
||||
var _ragdoll_component: StickmanRagdoll = null
|
||||
var _action_runner_component: StickmanActionRunner = null
|
||||
var _prop_interaction_component: StickmanPropInteraction = null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
@@ -234,12 +235,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_interaction_component = get_node_or_null(NodePath("RigComponents/PropInteraction")) as StickmanPropInteraction
|
||||
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_interaction_component == null:
|
||||
push_warning("StickmanRig: missing RigComponents/PropInteraction; prop interaction boundary unavailable.")
|
||||
if _locomotion_component != null:
|
||||
_locomotion_component.configure(self)
|
||||
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
|
||||
@@ -248,7 +252,7 @@ func _ready() -> void:
|
||||
_ragdoll_component.recovery_requested.connect(func() -> void: request_recovery())
|
||||
_ragdoll_component.state_transition_requested.connect(_set_state)
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.configure(self, _locomotion_component)
|
||||
_action_runner_component.configure(self, _locomotion_component, _prop_interaction_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))
|
||||
@@ -257,6 +261,8 @@ func _ready() -> void:
|
||||
_action_runner_component.speech_finished.connect(func() -> void: speech_finished.emit())
|
||||
_action_runner_component.ragdoll_requested.connect(func(enabled: bool) -> void: set_ragdoll(enabled))
|
||||
_action_runner_component.recovery_requested.connect(func() -> void: request_recovery())
|
||||
if _prop_interaction_component != null:
|
||||
_prop_interaction_component.configure(self, _locomotion_component)
|
||||
# Resolve all runtime node references.
|
||||
_skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
|
||||
if _skeleton == null:
|
||||
@@ -313,6 +319,8 @@ func _physics_process(delta: float) -> void:
|
||||
_pin_mirrored_head_rotation()
|
||||
if _locomotion_component != null:
|
||||
_locomotion_component.tick_post_skeleton(delta, int(state))
|
||||
if _prop_interaction_component != null:
|
||||
_prop_interaction_component.update_held_attachment(delta)
|
||||
|
||||
|
||||
func get_locomotion() -> StickmanLocomotion:
|
||||
@@ -450,6 +458,8 @@ func _set_state(next_state: int) -> void:
|
||||
|
||||
func set_ragdoll(enabled: bool) -> void:
|
||||
if enabled:
|
||||
if _prop_interaction_component != null:
|
||||
_prop_interaction_component.release_for_ragdoll()
|
||||
if _locomotion_component != null:
|
||||
_locomotion_component.reset_for_ragdoll_handoff()
|
||||
match state:
|
||||
@@ -805,6 +815,13 @@ func stop_queue() -> void:
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.stop_queue()
|
||||
|
||||
func get_prop_interaction() -> StickmanPropInteraction:
|
||||
return _prop_interaction_component
|
||||
|
||||
func release_held_prop_for_lifecycle(reason: StringName = &"lifecycle") -> void:
|
||||
if _prop_interaction_component != null:
|
||||
_prop_interaction_component.release_for_lifecycle(reason)
|
||||
|
||||
|
||||
func is_queue_running() -> bool:
|
||||
return _action_runner_component != null and _action_runner_component.is_queue_running()
|
||||
|
||||
@@ -32,6 +32,9 @@ 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" },
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user