Phase 14 - Organized directories.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
class_name MissingStickmanPlaceholder
|
||||
extends Node2D
|
||||
|
||||
var original_entry: Dictionary = {}
|
||||
var missing_stk_path: String = ""
|
||||
|
||||
const MARKER_SIZE := Vector2(260.0, 52.0)
|
||||
|
||||
func configure(entry: Dictionary) -> void:
|
||||
original_entry = entry.duplicate(true)
|
||||
missing_stk_path = String(entry.get("stk_path", ""))
|
||||
queue_redraw()
|
||||
|
||||
func get_area_rect() -> Rect2:
|
||||
return Rect2(-MARKER_SIZE * 0.5, MARKER_SIZE)
|
||||
|
||||
func _draw() -> void:
|
||||
var rect := get_area_rect()
|
||||
draw_rect(rect, Color(0.76, 0.10, 0.10, 0.95), true)
|
||||
draw_rect(rect, Color(1.0, 0.78, 0.78, 1.0), false, 2.0)
|
||||
var asset_name := missing_stk_path.get_file()
|
||||
if asset_name.is_empty():
|
||||
asset_name = "Unnamed .stk asset"
|
||||
draw_string(ThemeDB.fallback_font, rect.position + Vector2(10.0, 32.0), "Missing asset - %s" % asset_name, HORIZONTAL_ALIGNMENT_CENTER, rect.size.x - 20.0, 16, Color.WHITE)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cd0b16vgbqyag
|
||||
@@ -0,0 +1,578 @@
|
||||
class_name StickmanActionRunner
|
||||
extends Node
|
||||
## Owns authored and reactive action execution plus the shared speech bubble.
|
||||
## StickmanRig remains the public facade; this node only calls down into
|
||||
## locomotion and signals requests back to the facade.
|
||||
|
||||
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||
const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/speech/stickman_speech_bubble.gd")
|
||||
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
|
||||
|
||||
enum RunnerState { IDLE, EXECUTING }
|
||||
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)
|
||||
signal action_finished(action: Dictionary, index: int)
|
||||
signal queue_finished
|
||||
signal queue_changed
|
||||
signal speech_finished
|
||||
signal ragdoll_requested(enabled: bool)
|
||||
signal recovery_requested
|
||||
signal active_action_cancelled(action: Dictionary)
|
||||
|
||||
var _authored_queue: Array[Dictionary] = []
|
||||
var action_queue: Array[Dictionary]:
|
||||
get:
|
||||
return _authored_queue.duplicate(true)
|
||||
set(value):
|
||||
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
|
||||
var _run_queue: Array[Dictionary] = []
|
||||
var _current_run_item: Dictionary = {}
|
||||
var _run_cursor: int = 0
|
||||
var _phase_timer: float = 0.0
|
||||
var _primary_speech: Dictionary = {}
|
||||
var _overlay_speech: Dictionary = {}
|
||||
var _speech_bubble: Node2D = null
|
||||
var _speech_preview_suppressed: bool = false
|
||||
var _action_ids: Array[int] = []
|
||||
var _next_action_id: int = 1
|
||||
var _current_action_id: int = -1
|
||||
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, 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:
|
||||
if _paused:
|
||||
return
|
||||
_update_speech(delta)
|
||||
if _runner_state != RunnerState.EXECUTING or _is_blocked():
|
||||
return
|
||||
match _action_phase:
|
||||
ActionPhase.NONE:
|
||||
_advance_to_next_action()
|
||||
ActionPhase.WALKING:
|
||||
if _locomotion != null and _locomotion.consume_walk_completed():
|
||||
_finish_action()
|
||||
ActionPhase.SPEAKING:
|
||||
if _primary_speech.is_empty():
|
||||
_finish_action()
|
||||
ActionPhase.WAITING:
|
||||
_phase_timer -= delta
|
||||
if _phase_timer <= 0.0:
|
||||
_finish_action()
|
||||
ActionPhase.RAGDOLLING:
|
||||
if ragdoll_at_rest:
|
||||
_finish_action()
|
||||
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:
|
||||
speak_action({"type": "speak", "text": text, "duration": maxf(duration, 0.0)})
|
||||
|
||||
|
||||
func speak_action(action: Dictionary) -> void:
|
||||
var normalized: Dictionary = SPEECH_PAYLOAD.normalize(action)
|
||||
var duration: float = SPEECH_PAYLOAD.duration_for(normalized)
|
||||
_primary_speech = {"action": normalized, "elapsed": 0.0, "duration": duration}
|
||||
_show_visible_speech()
|
||||
|
||||
|
||||
func speak_overlay(text: String, duration: float) -> void:
|
||||
speak_overlay_action({"type": "speak", "text": text, "duration": maxf(duration, 0.0)})
|
||||
|
||||
|
||||
func speak_overlay_action(action: Dictionary) -> void:
|
||||
var normalized: Dictionary = SPEECH_PAYLOAD.normalize(action)
|
||||
var duration: float = SPEECH_PAYLOAD.duration_for(normalized)
|
||||
_overlay_speech = {"action": normalized, "elapsed": 0.0, "duration": duration}
|
||||
_show_visible_speech()
|
||||
|
||||
|
||||
func set_speech_preview_suppressed(value: bool) -> void:
|
||||
_speech_preview_suppressed = value
|
||||
if _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
(_speech_bubble as SpeechBubble).set_suppressed(value)
|
||||
|
||||
|
||||
func hide_speech() -> void:
|
||||
_primary_speech.clear()
|
||||
_overlay_speech.clear()
|
||||
if _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_speech_bubble.hide_bubble()
|
||||
|
||||
|
||||
func sync_facing(profile: int) -> void:
|
||||
if _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
(_speech_bubble as SpeechBubble).set_mirrored(profile == StickmanRig.FacingProfile.LEFT)
|
||||
|
||||
|
||||
func set_authored_queue(actions: Array[Dictionary]) -> bool:
|
||||
if _session_started:
|
||||
return false
|
||||
_authored_queue = actions.duplicate(true)
|
||||
_action_ids.clear()
|
||||
for _action: Dictionary in _authored_queue:
|
||||
_action_ids.append(_allocate_action_id())
|
||||
queue_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func queue_action(action: Dictionary) -> void:
|
||||
insert_action(action_queue.size(), action)
|
||||
|
||||
|
||||
func clear_queue() -> void:
|
||||
_authored_queue.clear()
|
||||
_action_ids.clear()
|
||||
if not _current_run_item.is_empty():
|
||||
_cancel_current_primary(true)
|
||||
_current_action_id = -1
|
||||
_run_queue.clear()
|
||||
_run_cursor = 0
|
||||
if _session_started:
|
||||
_runner_state = RunnerState.IDLE
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
func get_queue() -> Array[Dictionary]:
|
||||
return action_queue.duplicate(true)
|
||||
|
||||
|
||||
func remove_action(index: int) -> void:
|
||||
if index < 0 or index >= action_queue.size():
|
||||
push_warning("StickmanActionRunner: remove_action index %d out of range." % index)
|
||||
return
|
||||
var action_id: int = _action_ids[index] if index < _action_ids.size() else -1
|
||||
var status: int = _status_for_action_id(action_id)
|
||||
if _session_started and _paused and status == QueueStatus.ACTIVE:
|
||||
_cancel_current_primary(true)
|
||||
_current_action_id = -1
|
||||
_runner_state = RunnerState.IDLE
|
||||
_authored_queue.remove_at(index)
|
||||
if index < _action_ids.size():
|
||||
_action_ids.remove_at(index)
|
||||
_rebuild_pending_runtime_queue()
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
func insert_action(index: int, action: Dictionary) -> void:
|
||||
var target: int = clampi(index, 0, _authored_queue.size())
|
||||
if _session_started and _paused:
|
||||
if target <= _paused_history_floor():
|
||||
return
|
||||
var action_id: int = _allocate_action_id()
|
||||
_authored_queue.insert(target, action.duplicate(true))
|
||||
_action_ids.insert(target, action_id)
|
||||
if _session_started and _paused:
|
||||
_rebuild_pending_runtime_queue()
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
func replace_action(index: int, action: Dictionary) -> bool:
|
||||
if index < 0 or index >= _authored_queue.size():
|
||||
return false
|
||||
var action_id: int = _action_ids[index]
|
||||
var status: int = _status_for_action_id(action_id)
|
||||
if _session_started and _paused and (status == QueueStatus.COMPLETED or status == QueueStatus.INTERRUPTED):
|
||||
return false
|
||||
if _session_started and _paused and status == QueueStatus.ACTIVE:
|
||||
_cancel_current_primary(true)
|
||||
_current_action_id = -1
|
||||
_runner_state = RunnerState.IDLE
|
||||
_authored_queue[index] = action.duplicate(true)
|
||||
if _session_started and _paused:
|
||||
_completed_ids.erase(action_id)
|
||||
_interrupted_ids.erase(action_id)
|
||||
_rebuild_pending_runtime_queue()
|
||||
queue_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func move_action(from_index: int, to_index: int) -> bool:
|
||||
if from_index < 0 or from_index >= _authored_queue.size() or to_index < 0 or to_index >= _authored_queue.size():
|
||||
return false
|
||||
if _session_started and _paused:
|
||||
var id: int = _action_ids[from_index]
|
||||
if _status_for_action_id(id) != QueueStatus.PENDING:
|
||||
return false
|
||||
if to_index <= _paused_history_floor():
|
||||
return false
|
||||
if from_index == to_index:
|
||||
return true
|
||||
var action: Dictionary = _authored_queue.pop_at(from_index)
|
||||
var action_id: int = _action_ids.pop_at(from_index)
|
||||
_authored_queue.insert(to_index, action)
|
||||
_action_ids.insert(to_index, action_id)
|
||||
if _session_started and _paused:
|
||||
_rebuild_pending_runtime_queue()
|
||||
queue_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func get_queue_view() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for index: int in _authored_queue.size():
|
||||
var action_id: int = _action_ids[index] if index < _action_ids.size() else -1
|
||||
result.append({"action": _authored_queue[index].duplicate(true), "authored_index": index, "status": _status_for_action_id(action_id), "action_id": action_id})
|
||||
return result
|
||||
|
||||
|
||||
func action_id_at(index: int) -> int:
|
||||
return _action_ids[index] if index >= 0 and index < _action_ids.size() else -1
|
||||
|
||||
|
||||
func index_for_action_id(action_id: int) -> int:
|
||||
return _index_for_action_id(action_id)
|
||||
|
||||
|
||||
func get_action_status(action_id: int) -> int:
|
||||
return _status_for_action_id(action_id)
|
||||
|
||||
|
||||
func pause_simulation() -> void:
|
||||
_paused = true
|
||||
|
||||
|
||||
func resume_simulation() -> void:
|
||||
_paused = false
|
||||
|
||||
|
||||
func clear_pause_state() -> void:
|
||||
_paused = false
|
||||
_completed_ids.clear()
|
||||
_interrupted_ids.clear()
|
||||
_current_action_id = -1
|
||||
_session_started = false
|
||||
|
||||
|
||||
func queue_size() -> int:
|
||||
return action_queue.size()
|
||||
|
||||
|
||||
func enqueue_reactive(actions: Array[Dictionary]) -> void:
|
||||
if actions.is_empty():
|
||||
return
|
||||
if not _session_started:
|
||||
_start_session_runtime()
|
||||
if _run_queue.is_empty() and _runner_state == RunnerState.IDLE:
|
||||
_rebuild_pending_runtime_queue()
|
||||
var primary: Array[Dictionary] = []
|
||||
var takeover_seen: bool = false
|
||||
for action: Dictionary in actions:
|
||||
var role: int = ACTION_REGISTRY.reactive_role(action)
|
||||
if role == ACTION_REGISTRY.ReactiveRole.OVERLAY:
|
||||
speak_overlay_action(action)
|
||||
continue
|
||||
if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER:
|
||||
if not takeover_seen:
|
||||
takeover_seen = true
|
||||
if _current_action_id >= 0:
|
||||
_interrupted_ids[_current_action_id] = true
|
||||
_cancel_current_primary(true)
|
||||
_current_action_id = -1
|
||||
_overlay_speech.clear()
|
||||
hide_speech()
|
||||
var kept: Array[Dictionary] = []
|
||||
for item: Dictionary in _run_queue.slice(_run_cursor):
|
||||
if not bool(item.get("reactive", false)):
|
||||
kept.append(item)
|
||||
_run_queue = kept
|
||||
_run_cursor = 0
|
||||
primary.clear()
|
||||
primary.append(action.duplicate(true))
|
||||
if primary.is_empty():
|
||||
return
|
||||
var injected: Array[Dictionary] = []
|
||||
for action: Dictionary in primary:
|
||||
injected.append({"action": action, "queue_index": -1, "reactive": true})
|
||||
_run_queue = injected + _run_queue.slice(_run_cursor)
|
||||
_run_cursor = 0
|
||||
_runner_state = RunnerState.EXECUTING
|
||||
_action_phase = ActionPhase.NONE
|
||||
|
||||
|
||||
func clear_reactive_actions() -> void:
|
||||
var runtime_kept: Array[Dictionary] = []
|
||||
for item: Dictionary in _run_queue:
|
||||
if not bool(item.get("reactive", false)):
|
||||
runtime_kept.append(item)
|
||||
_run_queue = runtime_kept
|
||||
_run_cursor = mini(_run_cursor, _run_queue.size())
|
||||
var kept: Array[Dictionary] = []
|
||||
for action: Dictionary in _authored_queue:
|
||||
if not bool(action.get("reactive", false)):
|
||||
kept.append(action)
|
||||
if kept.size() != _authored_queue.size():
|
||||
_authored_queue = kept
|
||||
_action_ids.clear()
|
||||
for _action: Dictionary in _authored_queue:
|
||||
_action_ids.append(_allocate_action_id())
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
func start_queue() -> void:
|
||||
if _runner_state == RunnerState.EXECUTING and _session_started:
|
||||
return
|
||||
if not _session_started:
|
||||
_start_session_runtime()
|
||||
_paused = false
|
||||
if _authored_queue.is_empty():
|
||||
queue_finished.emit()
|
||||
return
|
||||
_runner_state = RunnerState.EXECUTING
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_index = -1
|
||||
_rebuild_pending_runtime_queue()
|
||||
_current_run_item.clear()
|
||||
|
||||
|
||||
func stop_queue() -> void:
|
||||
_cancel_current_primary()
|
||||
hide_speech()
|
||||
_run_queue.clear()
|
||||
_current_run_item.clear()
|
||||
_runner_state = RunnerState.IDLE
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_index = -1
|
||||
_current_action_id = -1
|
||||
_paused = false
|
||||
_session_started = false
|
||||
_completed_ids.clear()
|
||||
_interrupted_ids.clear()
|
||||
|
||||
|
||||
func is_queue_running() -> bool:
|
||||
return _runner_state == RunnerState.EXECUTING
|
||||
|
||||
|
||||
func get_current_index() -> int:
|
||||
return _current_index
|
||||
|
||||
|
||||
func _advance_to_next_action() -> void:
|
||||
if _run_cursor >= _run_queue.size():
|
||||
_runner_state = RunnerState.IDLE
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_index = -1
|
||||
queue_finished.emit()
|
||||
return
|
||||
_current_run_item = _run_queue[_run_cursor]
|
||||
_run_cursor += 1
|
||||
_current_index = int(_current_run_item.get("queue_index", -1))
|
||||
_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", {})
|
||||
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()
|
||||
_current_action_id = -1
|
||||
_current_index = -1
|
||||
return
|
||||
if _rig.is_ragdolled():
|
||||
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
|
||||
_run_queue.insert(_run_cursor, {"action": {"type": "recover"}, "queue_index": -1, "action_id": -1, "reactive": true})
|
||||
_current_run_item.clear()
|
||||
_current_action_id = -1
|
||||
_current_index = -1
|
||||
return
|
||||
action_started.emit(action, _current_index)
|
||||
_begin_action(action)
|
||||
|
||||
|
||||
func _begin_action(action: Dictionary) -> void:
|
||||
match String(action.get("type", "")):
|
||||
"walk_to":
|
||||
_action_phase = ActionPhase.WALKING
|
||||
if _locomotion == null or not _locomotion.walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0))):
|
||||
_finish_action()
|
||||
"speak":
|
||||
if bool(action.get("keep_acting", false)):
|
||||
speak_overlay_action(action)
|
||||
_finish_action()
|
||||
else:
|
||||
_action_phase = ActionPhase.SPEAKING
|
||||
speak_action(action)
|
||||
"wait":
|
||||
_phase_timer = float(action.get("duration", 0.0))
|
||||
_action_phase = ActionPhase.WAITING
|
||||
"ragdoll":
|
||||
_action_phase = ActionPhase.RAGDOLLING
|
||||
ragdoll_requested.emit(true)
|
||||
"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()
|
||||
|
||||
|
||||
func _finish_action() -> void:
|
||||
var finished: Dictionary = _current_run_item.get("action", {})
|
||||
var index: int = int(_current_run_item.get("queue_index", -1))
|
||||
var action_id: int = int(_current_run_item.get("action_id", -1))
|
||||
if action_id >= 0:
|
||||
_completed_ids[action_id] = true
|
||||
index = _index_for_action_id(action_id) if action_id >= 0 else -1
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_run_item.clear()
|
||||
_current_index = -1
|
||||
_current_action_id = -1
|
||||
if not finished.is_empty():
|
||||
action_finished.emit(finished, index)
|
||||
|
||||
|
||||
func _cancel_current_primary(notify: bool = false) -> void:
|
||||
if notify and not _current_run_item.is_empty():
|
||||
var action: Dictionary = _current_run_item.get("action", {})
|
||||
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
|
||||
if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_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
|
||||
return result
|
||||
|
||||
|
||||
func _start_session_runtime() -> void:
|
||||
_session_started = true
|
||||
_completed_ids.clear()
|
||||
_interrupted_ids.clear()
|
||||
_action_ids.clear()
|
||||
for _action: Dictionary in _authored_queue:
|
||||
_action_ids.append(_allocate_action_id())
|
||||
|
||||
|
||||
func _runtime_item(action_id: int, action: Dictionary, index: int) -> Dictionary:
|
||||
return {"action": action.duplicate(true), "queue_index": index, "action_id": action_id, "reactive": false}
|
||||
|
||||
|
||||
func _rebuild_pending_runtime_queue() -> void:
|
||||
var rebuilt: Array[Dictionary] = []
|
||||
var retained_reactive: Array[Dictionary] = []
|
||||
for runtime_index: int in range(_run_cursor, _run_queue.size()):
|
||||
var runtime_item: Dictionary = _run_queue[runtime_index]
|
||||
if bool(runtime_item.get("reactive", false)):
|
||||
retained_reactive.append(runtime_item.duplicate(true))
|
||||
for index: int in _authored_queue.size():
|
||||
var action_id: int = _action_ids[index]
|
||||
var status: int = _status_for_action_id(action_id)
|
||||
if status == QueueStatus.PENDING or status == QueueStatus.NOT_STARTED:
|
||||
rebuilt.append(_runtime_item(action_id, _authored_queue[index], index))
|
||||
_run_queue = retained_reactive + rebuilt
|
||||
_run_cursor = 0
|
||||
if not _run_queue.is_empty() and _session_started:
|
||||
_runner_state = RunnerState.EXECUTING
|
||||
|
||||
|
||||
func _paused_history_floor() -> int:
|
||||
var floor_index: int = -1
|
||||
for index: int in _authored_queue.size():
|
||||
var status: int = _status_for_action_id(_action_ids[index])
|
||||
if status != QueueStatus.PENDING and status != QueueStatus.NOT_STARTED:
|
||||
floor_index = index
|
||||
return floor_index
|
||||
|
||||
|
||||
func _index_for_action_id(action_id: int) -> int:
|
||||
return _action_ids.find(action_id)
|
||||
|
||||
|
||||
func _status_for_action_id(action_id: int) -> int:
|
||||
if action_id < 0:
|
||||
return QueueStatus.NOT_STARTED
|
||||
if _completed_ids.has(action_id):
|
||||
return QueueStatus.COMPLETED
|
||||
if _interrupted_ids.has(action_id):
|
||||
return QueueStatus.INTERRUPTED
|
||||
if action_id == _current_action_id:
|
||||
return QueueStatus.ACTIVE
|
||||
if _session_started:
|
||||
return QueueStatus.PENDING
|
||||
return QueueStatus.NOT_STARTED
|
||||
|
||||
|
||||
func _is_blocked() -> bool:
|
||||
return _locomotion != null and _locomotion.is_action_blocked()
|
||||
|
||||
|
||||
func _update_speech(delta: float) -> void:
|
||||
if not _overlay_speech.is_empty():
|
||||
_overlay_speech["elapsed"] = float(_overlay_speech.get("elapsed", 0.0)) + delta
|
||||
if float(_overlay_speech.get("elapsed", 0.0)) >= float(_overlay_speech.get("duration", 0.0)):
|
||||
_overlay_speech.clear()
|
||||
speech_finished.emit()
|
||||
elif not _primary_speech.is_empty() and not _is_blocked():
|
||||
_primary_speech["elapsed"] = float(_primary_speech.get("elapsed", 0.0)) + delta
|
||||
if float(_primary_speech.get("elapsed", 0.0)) >= float(_primary_speech.get("duration", 0.0)):
|
||||
_primary_speech.clear()
|
||||
speech_finished.emit()
|
||||
if _overlay_speech.is_empty() and _primary_speech.is_empty():
|
||||
if _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_speech_bubble.hide_bubble()
|
||||
else:
|
||||
_show_visible_speech()
|
||||
|
||||
|
||||
func _show_visible_speech() -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
if _speech_bubble == null or not is_instance_valid(_speech_bubble):
|
||||
_speech_bubble = SPEECH_BUBBLE_SCRIPT.new() as SpeechBubble
|
||||
_speech_bubble.name = "SpeechBubble"
|
||||
_rig.add_child(_speech_bubble)
|
||||
sync_facing(_rig.facing_profile)
|
||||
(_speech_bubble as SpeechBubble).set_suppressed(_speech_preview_suppressed)
|
||||
var visible_speech: Dictionary = _overlay_speech if not _overlay_speech.is_empty() else _primary_speech
|
||||
if not visible_speech.is_empty():
|
||||
var action: Dictionary = visible_speech.get("action", {})
|
||||
(_speech_bubble as SpeechBubble).render_action(action, float(visible_speech.get("elapsed", 0.0)), false)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bd0gqgr5oyu3g
|
||||
@@ -0,0 +1,50 @@
|
||||
class_name StickmanFactory
|
||||
extends RefCounted
|
||||
## StickmanFactory - Runtime factory for spawning rig instances from .stk files
|
||||
## (Phase 9).
|
||||
##
|
||||
## Loads a versioned .stk JSON dictionary and instantiates scenes/characters/stickman/stickman_rig.tscn,
|
||||
## adapting it via StkRigAdapter. Consumed by the standalone test harness, not
|
||||
## referenced by the editor.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const RIG_SCENE := preload("res://scenes/characters/stickman/stickman_rig.tscn")
|
||||
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func load_stk(path: String) -> Dictionary:
|
||||
path = CONTENT_PATHS.canonicalize(path)
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("StickmanFactory: failed to open '%s' (error %d)." % [path, FileAccess.get_open_error()])
|
||||
return {}
|
||||
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if parsed == null or not parsed is Dictionary:
|
||||
push_warning("StickmanFactory: failed to parse '%s' as JSON." % path)
|
||||
return {}
|
||||
|
||||
return parsed as Dictionary
|
||||
|
||||
|
||||
static func spawn_from_data(stk_data: Dictionary) -> StickmanRig:
|
||||
var rig := RIG_SCENE.instantiate() as StickmanRig
|
||||
StkRigAdapter.apply(stk_data, rig)
|
||||
rig.initialize_spawn_pose_from_skeleton()
|
||||
return rig
|
||||
|
||||
|
||||
static func spawn(path: String) -> StickmanRig:
|
||||
var data := load_stk(path)
|
||||
if data.is_empty():
|
||||
return null
|
||||
return spawn_from_data(data)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdrs7705oidyc
|
||||
@@ -0,0 +1,67 @@
|
||||
class_name StickmanLibrary
|
||||
extends RefCounted
|
||||
|
||||
const STICKMAN_FACTORY := preload("res://scripts/characters/stickman/stickman_factory.gd")
|
||||
|
||||
const STICKMEN_DIR := "res://content/stickmen"
|
||||
|
||||
var entries: Array[Dictionary] = []
|
||||
|
||||
|
||||
func scan(dir_path: String = STICKMEN_DIR) -> Array[Dictionary]:
|
||||
entries = _scan_dir(dir_path)
|
||||
return entries
|
||||
|
||||
|
||||
func get_entries() -> Array[Dictionary]:
|
||||
return entries
|
||||
|
||||
|
||||
func find_by_path(path: String) -> Dictionary:
|
||||
for e: Dictionary in entries:
|
||||
if String(e.get("path", "")) == path:
|
||||
return e
|
||||
return {}
|
||||
|
||||
|
||||
func make_entry(path: String) -> Dictionary:
|
||||
var data: Dictionary = STICKMAN_FACTORY.load_stk(path)
|
||||
if data.is_empty() or not data.has("body_parts"):
|
||||
if not data.is_empty():
|
||||
push_warning("StickmanLibrary: skipped '%s' (missing body_parts)." % path)
|
||||
return {}
|
||||
var name := String(data.get("stickman_name", "")).strip_edges()
|
||||
if name.is_empty():
|
||||
name = path.get_file().get_basename()
|
||||
return { "path": path, "name": name, "data": data }
|
||||
|
||||
|
||||
func _scan_dir(dir_path: String) -> Array[Dictionary]:
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
push_warning("StickmanLibrary: stickmen dir '%s' not found." % dir_path)
|
||||
return []
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
push_warning("StickmanLibrary: failed to open '%s'." % dir_path)
|
||||
return []
|
||||
var files: Array[String] = []
|
||||
dir.list_dir_begin()
|
||||
var fname := dir.get_next()
|
||||
while fname != "":
|
||||
if not dir.current_is_dir() and fname.get_extension().to_lower() == "stk":
|
||||
files.append(dir_path + "/" + fname)
|
||||
fname = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
var built: Array[Dictionary] = []
|
||||
for path: String in files:
|
||||
var entry := make_entry(path)
|
||||
if not entry.is_empty():
|
||||
built.append(entry)
|
||||
built.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var na := String(a["name"])
|
||||
var nb := String(b["name"])
|
||||
if na != nb:
|
||||
return na < nb
|
||||
return String(a["path"]) < String(b["path"])
|
||||
)
|
||||
return built
|
||||
@@ -0,0 +1 @@
|
||||
uid://d1s0xetl4uics
|
||||
@@ -0,0 +1,780 @@
|
||||
class_name StickmanLocomotion
|
||||
extends Node
|
||||
## Owns navigation, walking/gait, terrain following, animated falls, and landings.
|
||||
## StickmanRig schedules this component and exposes its public facade.
|
||||
|
||||
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
|
||||
|
||||
const TERRAIN_PROBE_UP: float = 96.0
|
||||
const TERRAIN_PROBE_DOWN: float = 700.0
|
||||
const TERRAIN_NORMAL_MIN_Y: float = -0.15
|
||||
const TERRAIN_SUPPORT_RANGE: float = 160.0
|
||||
const TERRAIN_MAX_FOOT_SURFACE_DROP: float = 72.0
|
||||
const TERRAIN_FOOT_GUARD_HEIGHT: float = 24.0
|
||||
const TERRAIN_CENTER_PROBE_OFFSETS: Array[float] = [-24.0, 0.0, 24.0]
|
||||
const TERRAIN_FOOT_PROBE_OFFSETS: Array[float] = [-4.0, 0.0, 4.0]
|
||||
const TERRAIN_GRAVITY: float = 1400.0
|
||||
const TERRAIN_MAX_FALL_SPEED: float = 1800.0
|
||||
const RECOVERY_PROBE_START_OFFSET: float = 50.0
|
||||
const RECOVERY_PROBE_END_OFFSET: float = 1500.0
|
||||
const RECOVERY_PROBE_TOLERANCE: float = 200.0
|
||||
const RECOVERY_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
|
||||
const AIR_DRAG_X: float = 0.75
|
||||
const AIR_POSE_START_SPEED: float = 240.0
|
||||
const AIR_POSE_FULL_SPEED: float = 1200.0
|
||||
const AIR_POSE_RESPONSE: float = 10.0
|
||||
const AIR_HAND_FORWARD: float = 220.0
|
||||
const AIR_HAND_FORWARD_START_SPEED: float = 80.0
|
||||
const AIR_HAND_FORWARD_FULL_SPEED: float = 520.0
|
||||
const AIR_HAND_LIFT: float = 680.0
|
||||
const AIR_HAND_FLAIL_AMPLITUDE: Vector2 = Vector2(20.0, 16.0)
|
||||
const AIR_FOOT_FLAIL_AMPLITUDE: Vector2 = Vector2(14.0, 10.0)
|
||||
const AIR_FLAIL_HZ: float = 12.0
|
||||
const HARD_LANDING_MIN_FALL_HEIGHT: float = 80.0
|
||||
const HARD_LANDING_MAX_FALL_HEIGHT: float = 900.0
|
||||
const LANDING_COMPRESSION_MIN: float = 32.0
|
||||
const LANDING_COMPRESSION_MAX: float = 160.0
|
||||
const LANDING_HAND_DROP_MIN: float = 270.0
|
||||
const LANDING_HAND_DROP_MAX: float = 520.0
|
||||
const LANDING_COMPRESS_DURATION: float = 0.08
|
||||
const LANDING_RECOVER_DURATION: float = 0.22
|
||||
const PELVIS_MAX_OFFSET: float = 240.0
|
||||
const PELVIS_RESPONSE: float = 10.0
|
||||
const FOOT_OFFSET: Vector2 = Vector2(0.0, -385.0)
|
||||
const NAV_AGENT_LOCAL_POS: Vector2 = Vector2(0.0, 385.0)
|
||||
const ARRIVE_DISTANCE: float = 8.0
|
||||
const NAV_PATH_DESIRED_DISTANCE: float = 8.0
|
||||
const NAV_TARGET_DESIRED_DISTANCE: float = 12.0
|
||||
const LATCH_PROBE_MAX_FRAMES: int = 6
|
||||
const WALK_CYCLE_DURATION: float = 0.8
|
||||
|
||||
const HEAD_COLLISION_RADIUS: float = 90.0
|
||||
const TORSO_COLLISION_RADIUS: float = 20.0
|
||||
const ARM_COLLISION_RADIUS: float = 12.0
|
||||
const LEG_COLLISION_RADIUS: float = 14.0
|
||||
const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0)
|
||||
const COLLISION_LAYER: int = 1
|
||||
const COLLISION_PARTS: Dictionary = {
|
||||
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
|
||||
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
|
||||
"left_upper_arm": {"visual": "LeftUpperArm", "bone": "Torso/LeftUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"left_lower_arm": {"visual": "LeftLowerArm", "bone": "Torso/LeftUpperArm/LeftLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"right_upper_arm": {"visual": "RightUpperArm", "bone": "Torso/RightUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"right_lower_arm": {"visual": "RightLowerArm", "bone": "Torso/RightUpperArm/RightLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||||
"left_upper_leg": {"visual": "LeftUpperLeg", "bone": "Torso/LeftUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
"left_lower_leg": {"visual": "LeftLowerLeg", "bone": "Torso/LeftUpperLeg/LeftLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
"right_upper_leg": {"visual": "RightUpperLeg", "bone": "Torso/RightUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
"right_lower_leg": {"visual": "RightLowerLeg", "bone": "Torso/RightUpperLeg/RightLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||||
}
|
||||
const REACTION_BASE_ANGLE: float = 0.35
|
||||
const REACTION_REFERENCE_SPEED: float = 400.0
|
||||
const REACTION_MAX_ANGLE: float = 0.6
|
||||
const REACTION_LOCAL_X_EPSILON: float = 0.05
|
||||
const REACTION_TARGETS: Dictionary = {
|
||||
"head": [{"joint": "Head", "weight": 1.0, "decay": 0.50}],
|
||||
"torso": [{"joint": "Torso", "weight": 0.5, "decay": 0.40}],
|
||||
"left_upper_arm": [{"joint": "LeftUpperArm", "weight": 1.0, "decay": 0.40}],
|
||||
"left_lower_arm": [{"joint": "LeftLowerArm", "weight": 1.0, "decay": 0.35}, {"joint": "LeftUpperArm", "weight": 0.4, "decay": 0.25}],
|
||||
"right_upper_arm": [{"joint": "RightUpperArm", "weight": 1.0, "decay": 0.40}],
|
||||
"right_lower_arm": [{"joint": "RightLowerArm", "weight": 1.0, "decay": 0.35}, {"joint": "RightUpperArm", "weight": 0.4, "decay": 0.25}],
|
||||
"left_upper_leg": [{"joint": "LeftUpperLeg", "weight": 1.0, "decay": 0.40}],
|
||||
"left_lower_leg": [{"joint": "LeftLowerLeg", "weight": 1.0, "decay": 0.35}, {"joint": "LeftUpperLeg", "weight": 0.4, "decay": 0.25}],
|
||||
"right_upper_leg": [{"joint": "RightUpperLeg", "weight": 1.0, "decay": 0.40}],
|
||||
"right_lower_leg": [{"joint": "RightLowerLeg", "weight": 1.0, "decay": 0.35}, {"joint": "RightUpperLeg", "weight": 0.4, "decay": 0.25}],
|
||||
}
|
||||
|
||||
signal arrived(target: Vector2)
|
||||
|
||||
var _rig: StickmanRig = null
|
||||
var foot_contacts: Dictionary = {"Left_Leg": {"valid": false, "locked": false}, "Right_Leg": {"valid": false, "locked": false}}
|
||||
var terrain_pelvis_offset: float = 0.0
|
||||
var terrain_air_velocity: Vector2 = Vector2.ZERO
|
||||
var terrain_motion: TerrainMotion = TerrainMotion.DISABLED
|
||||
var terrain_following_enabled: bool = false
|
||||
var _air_time: float = 0.0
|
||||
var air_pose_weight: float = 0.0
|
||||
var air_pose_base: Dictionary = {}
|
||||
var _air_entry_root_y: float = 0.0
|
||||
var air_fall_distance: float = 0.0
|
||||
var _landing_elapsed: float = 0.0
|
||||
var landing_compression: float = 0.0
|
||||
var _landing_hand_drop: float = 0.0
|
||||
var _landing_torso_base: Vector2 = Vector2.ZERO
|
||||
var _landing_hand_start: Dictionary = {}
|
||||
var _landing_contacts: Dictionary = {}
|
||||
var _terrain_paused_walk_animation: bool = false
|
||||
var _nav_agent: NavigationAgent2D = null
|
||||
var _nav_anchor: Node2D = null
|
||||
var _walking: bool = false
|
||||
var _walk_target_feet: Vector2 = Vector2.ZERO
|
||||
var _walk_speed_current: float = 300.0
|
||||
var _walk_mode: String = "nav"
|
||||
var _walk_mode_latched: bool = false
|
||||
var _walk_latch_probe_frames: int = 0
|
||||
var _walk_settle_frames: int = 0
|
||||
var _walk_done: bool = false
|
||||
var _leg_base_markers: Dictionary = {}
|
||||
var _last_grounded_walk_velocity_x: float = 0.0
|
||||
var collision_bodies: Dictionary = {}
|
||||
var collision_lengths: Dictionary = {}
|
||||
var _collision_visuals: Dictionary = {}
|
||||
var reaction_bones: Dictionary = {}
|
||||
var impact_reactions: Dictionary = {}
|
||||
|
||||
func configure(rig: StickmanRig) -> void:
|
||||
_rig = rig
|
||||
|
||||
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
||||
var base: Marker2D = _rig.get_node_or_null(NodePath("AnimationTargets/%s_Base" % leg_name)) as Marker2D
|
||||
if base == null:
|
||||
push_warning("StickmanLocomotion: missing AnimationTargets/%s_Base." % leg_name)
|
||||
else:
|
||||
_leg_base_markers[leg_name] = base
|
||||
_nav_anchor = Node2D.new()
|
||||
_nav_anchor.name = "NavigationAgentAnchor"
|
||||
_nav_anchor.position = NAV_AGENT_LOCAL_POS
|
||||
_rig.add_child(_nav_anchor)
|
||||
_nav_agent = NavigationAgent2D.new()
|
||||
_nav_agent.name = "NavigationAgent2D"
|
||||
_nav_agent.path_desired_distance = NAV_PATH_DESIRED_DISTANCE
|
||||
_nav_agent.target_desired_distance = NAV_TARGET_DESIRED_DISTANCE
|
||||
_nav_agent.path_max_distance = 100.0
|
||||
_nav_agent.max_speed = _rig.walk_speed
|
||||
_nav_agent.avoidance_enabled = false
|
||||
_nav_anchor.add_child(_nav_agent)
|
||||
|
||||
|
||||
func setup_collision_bodies() -> void:
|
||||
collision_bodies.clear()
|
||||
collision_lengths.clear()
|
||||
_collision_visuals.clear()
|
||||
reaction_bones.clear()
|
||||
if _rig == null or _rig._body_container == null or _rig._skeleton == null:
|
||||
return
|
||||
for key: String in COLLISION_PARTS:
|
||||
var definition: Dictionary = COLLISION_PARTS[key]
|
||||
var bone: Bone2D = _rig._skeleton.get_node_or_null(NodePath(str(definition["bone"]))) as Bone2D
|
||||
if bone == null or not is_instance_valid(bone):
|
||||
push_warning("StickmanLocomotion: missing reaction bone '%s'." % str(definition["bone"]))
|
||||
continue
|
||||
var joint_name: String = "Torso" if str(definition["visual"]) == "Body" else str(definition["visual"])
|
||||
reaction_bones[joint_name] = bone
|
||||
var visual: Node2D = _rig._body_container.get_node_or_null(NodePath(str(definition["visual"]))) as Node2D
|
||||
if visual == null:
|
||||
push_warning("StickmanLocomotion: missing collision visual for '%s'." % key)
|
||||
continue
|
||||
var proxy := AnimatableBody2D.new()
|
||||
proxy.name = "CollisionBody"
|
||||
proxy.collision_layer = COLLISION_LAYER
|
||||
proxy.collision_mask = COLLISION_LAYER
|
||||
proxy.sync_to_physics = false
|
||||
proxy.set_meta("stickman_rig", _rig)
|
||||
proxy.set_meta("part_name", key)
|
||||
var shape_node := CollisionShape2D.new()
|
||||
shape_node.name = "CollisionShape2D"
|
||||
if str(definition["shape"]) == "circle":
|
||||
var circle := CircleShape2D.new()
|
||||
var bounds: Dictionary = mounted_visual_bounds_local(visual)
|
||||
if bool(bounds["valid"]):
|
||||
var rect: Rect2 = bounds["rect"] as Rect2
|
||||
shape_node.position = rect.get_center()
|
||||
circle.radius = maxf(float(definition["radius"]), maxf(rect.size.x, rect.size.y) * 0.5)
|
||||
else:
|
||||
shape_node.position = HEAD_COLLISION_FALLBACK_CENTER
|
||||
circle.radius = float(definition["radius"])
|
||||
shape_node.shape = circle
|
||||
else:
|
||||
var length: float = absf(_rig._head_bone.position.y) if key == "torso" and _rig._head_bone != null else bone.length
|
||||
collision_lengths[key] = length
|
||||
var capsule := CapsuleShape2D.new()
|
||||
capsule.height = length
|
||||
capsule.radius = float(definition["radius"])
|
||||
shape_node.shape = capsule
|
||||
shape_node.position = Vector2(0.0, length * 0.5)
|
||||
proxy.add_child(shape_node)
|
||||
visual.add_child(proxy)
|
||||
proxy.top_level = true
|
||||
proxy.global_transform = visual.global_transform
|
||||
collision_bodies[key] = proxy
|
||||
_collision_visuals[key] = visual
|
||||
|
||||
|
||||
func sync_collision_bodies() -> void:
|
||||
for key: String in collision_bodies:
|
||||
var proxy: AnimatableBody2D = collision_bodies.get(key) as AnimatableBody2D
|
||||
var visual: Node2D = _collision_visuals.get(key) as Node2D
|
||||
if proxy != null and visual != null and is_instance_valid(proxy) and is_instance_valid(visual):
|
||||
proxy.global_transform = visual.global_transform
|
||||
|
||||
|
||||
func mounted_visual_bounds_local(part: Node2D) -> Dictionary:
|
||||
var points: Array[Vector2] = []
|
||||
var nodes: Array[Node] = [part]
|
||||
while not nodes.is_empty():
|
||||
var child: Node = nodes.pop_back()
|
||||
if child is Line2D:
|
||||
for point: Vector2 in (child as Line2D).points:
|
||||
points.append(part.to_local((child as Node2D).to_global(point)))
|
||||
elif child is Polygon2D:
|
||||
for point: Vector2 in (child as Polygon2D).polygon:
|
||||
points.append(part.to_local((child as Node2D).to_global(point)))
|
||||
for grandchild: Node in child.get_children():
|
||||
nodes.append(grandchild)
|
||||
if points.is_empty():
|
||||
return {"valid": false, "rect": Rect2()}
|
||||
var rect := Rect2(points[0], Vector2.ZERO)
|
||||
for point: Vector2 in points:
|
||||
rect = rect.expand(point)
|
||||
return {"valid": true, "rect": rect}
|
||||
|
||||
|
||||
func set_collision_bodies_enabled(enabled: bool) -> void:
|
||||
var layer: int = COLLISION_LAYER if enabled else 0
|
||||
for proxy: AnimatableBody2D in collision_bodies.values():
|
||||
if proxy != null and is_instance_valid(proxy):
|
||||
proxy.collision_layer = layer
|
||||
proxy.collision_mask = layer
|
||||
|
||||
|
||||
func apply_collision_reaction(part_name: String, impact_direction: Vector2, impact_speed: float, impact_position: Vector2 = Vector2.INF) -> void:
|
||||
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED or impact_direction.is_zero_approx() or impact_speed <= 0.0:
|
||||
return
|
||||
for target: Dictionary in REACTION_TARGETS.get(part_name, []):
|
||||
var joint: String = str(target["joint"])
|
||||
var bone: Bone2D = reaction_bones.get(joint) as Bone2D
|
||||
if bone == null or not is_instance_valid(bone):
|
||||
continue
|
||||
var direction: float = collision_contact_direction(part_name, impact_position, impact_direction)
|
||||
if is_zero_approx(direction):
|
||||
var local_dir: Vector2 = bone.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
|
||||
direction = signf(local_dir.x) if absf(local_dir.x) > REACTION_LOCAL_X_EPSILON else 0.0
|
||||
if is_zero_approx(direction): direction = -1.0 if joint.begins_with("Left") else 1.0
|
||||
var angle: float = direction * minf(REACTION_MAX_ANGLE, REACTION_BASE_ANGLE * impact_speed / REACTION_REFERENCE_SPEED) * float(target["weight"])
|
||||
var old: Dictionary = impact_reactions.get(joint, {})
|
||||
if old.is_empty():
|
||||
impact_reactions[joint] = {"angle": angle, "strength": 1.0, "decay": float(target["decay"]), "base_rotation": bone.rotation}
|
||||
else:
|
||||
if absf(angle) >= absf(float(old.get("angle", 0.0))):
|
||||
old["angle"] = angle
|
||||
old["decay"] = float(target["decay"])
|
||||
old["strength"] = 1.0
|
||||
impact_reactions[joint] = old
|
||||
|
||||
|
||||
func collision_contact_direction(part_name: String, impact_position: Vector2, impact_direction: Vector2) -> float:
|
||||
if not impact_position.is_finite(): return 0.0
|
||||
var proxy: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D
|
||||
if proxy == null or not is_instance_valid(proxy): return 0.0
|
||||
var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||
if shape_node == null: return 0.0
|
||||
var side: float = proxy.to_local(impact_position).x - shape_node.position.x
|
||||
if absf(side) <= REACTION_LOCAL_X_EPSILON: return 0.0
|
||||
var local_force: Vector2 = proxy.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
|
||||
return signf(side * local_force.y) if absf(local_force.y) > REACTION_LOCAL_X_EPSILON else signf(side)
|
||||
|
||||
|
||||
func clear_impact_reactions(restore_bones: bool = false) -> void:
|
||||
if restore_bones:
|
||||
for joint: String in impact_reactions:
|
||||
var reaction: Dictionary = impact_reactions[joint]
|
||||
var bone: Bone2D = reaction_bones.get(joint) as Bone2D
|
||||
if bone != null and is_instance_valid(bone) and reaction.has("base_rotation"):
|
||||
bone.rotation = float(reaction["base_rotation"])
|
||||
impact_reactions.clear()
|
||||
|
||||
|
||||
func apply_impact_reactions(delta: float) -> void:
|
||||
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED:
|
||||
clear_impact_reactions()
|
||||
return
|
||||
for joint: String in impact_reactions.keys().duplicate():
|
||||
var reaction: Dictionary = impact_reactions[joint]
|
||||
var bone: Bone2D = reaction_bones.get(joint) as Bone2D
|
||||
if bone == null or not is_instance_valid(bone):
|
||||
impact_reactions.erase(joint)
|
||||
continue
|
||||
var base_rotation: float = float(reaction.get("base_rotation", bone.rotation))
|
||||
bone.rotation = base_rotation + float(reaction["angle"]) * float(reaction["strength"])
|
||||
reaction["strength"] = float(reaction["strength"]) - delta / maxf(float(reaction["decay"]), 0.001)
|
||||
if float(reaction["strength"]) <= 0.0:
|
||||
bone.rotation = base_rotation
|
||||
impact_reactions.erase(joint)
|
||||
else:
|
||||
impact_reactions[joint] = reaction
|
||||
|
||||
func tick_movement(delta: float, _rig_state: int) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
terrain_pelvis_offset = 0.0
|
||||
if terrain_following_enabled and terrain_motion == TerrainMotion.GROUNDED:
|
||||
_last_grounded_walk_velocity_x = 0.0
|
||||
_update_walking(delta)
|
||||
_update_terrain_following(delta)
|
||||
_settle_walk_markers()
|
||||
|
||||
func tick_post_skeleton(delta: float, _rig_state: int) -> void:
|
||||
if _rig != null:
|
||||
apply_impact_reactions(delta)
|
||||
sync_collision_bodies()
|
||||
|
||||
func walk_to(target: Vector2, speed: float = -1.0) -> bool:
|
||||
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED:
|
||||
push_warning("StickmanRig: walk_to ignored; not ANIMATED.")
|
||||
return false
|
||||
if _rig._is_action_queue_blocked():
|
||||
push_warning("StickmanRig: walk_to ignored during landing impact.")
|
||||
return false
|
||||
if _nav_agent == null or _nav_anchor == null:
|
||||
push_warning("StickmanLocomotion: walk_to ignored; navigation is unavailable.")
|
||||
return false
|
||||
_walk_target_feet = target
|
||||
_walk_speed_current = speed if speed > 0.0 else _rig.walk_speed
|
||||
_nav_agent.max_speed = _walk_speed_current
|
||||
_nav_agent.target_position = target
|
||||
var dx: float = target.x - _rig.global_position.x
|
||||
if dx < -0.5:
|
||||
_rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
|
||||
elif dx > 0.5:
|
||||
_rig.set_facing_profile(StickmanRig.FacingProfile.RIGHT)
|
||||
_rig._set_limb_mods_enabled(true)
|
||||
if _rig._anim_player != null and is_instance_valid(_rig._anim_player) and _rig._anim_player.has_animation("walk_right"):
|
||||
_rig._anim_player.play("walk_right")
|
||||
_walking = true
|
||||
_walk_done = false
|
||||
_walk_mode = "nav"
|
||||
_walk_mode_latched = false
|
||||
_walk_latch_probe_frames = 0
|
||||
_walk_settle_frames = 0
|
||||
return true
|
||||
|
||||
func cancel_walk(restore_idle_pose: bool = true) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
var current_limb_rotations: Dictionary = _rig._capture_limb_rotations()
|
||||
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
|
||||
_rig._anim_player.stop(true)
|
||||
if _nav_agent != null and _nav_anchor != null:
|
||||
_nav_agent.target_position = _nav_anchor.global_position
|
||||
_walking = false
|
||||
_walk_done = false
|
||||
_walk_mode_latched = false
|
||||
_walk_latch_probe_frames = 0
|
||||
_walk_settle_frames = 0
|
||||
if restore_idle_pose:
|
||||
_rig._apply_idle_limb_solver_mode(current_limb_rotations)
|
||||
|
||||
func is_walking() -> bool:
|
||||
return _walking
|
||||
|
||||
|
||||
func ensure_grounded_walk_animation() -> void:
|
||||
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED or not _walking:
|
||||
return
|
||||
if terrain_motion != TerrainMotion.GROUNDED or blocks_walking():
|
||||
return
|
||||
if _rig._anim_player == null or not is_instance_valid(_rig._anim_player) or not _rig._anim_player.has_animation("walk_right"):
|
||||
return
|
||||
if _terrain_paused_walk_animation:
|
||||
if _rig._anim_player.is_playing():
|
||||
_terrain_paused_walk_animation = false
|
||||
else:
|
||||
_resume_walk_animation()
|
||||
elif not _rig._anim_player.is_playing():
|
||||
_rig._anim_player.play("walk_right")
|
||||
|
||||
func get_foot_offset() -> Vector2:
|
||||
return FOOT_OFFSET
|
||||
|
||||
func consume_walk_completed() -> bool:
|
||||
if not _walk_done:
|
||||
return false
|
||||
_walk_done = false
|
||||
return true
|
||||
|
||||
func _update_walking(delta: float) -> void:
|
||||
if not _walking:
|
||||
return
|
||||
if _rig.state != StickmanRig.RigState.ANIMATED:
|
||||
cancel_walk(false)
|
||||
return
|
||||
if blocks_walking() or _nav_agent == null:
|
||||
return
|
||||
if NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0:
|
||||
return
|
||||
if not _walk_mode_latched:
|
||||
if _walk_latch_probe_frames < LATCH_PROBE_MAX_FRAMES:
|
||||
_walk_latch_probe_frames += 1
|
||||
_nav_agent.get_next_path_position()
|
||||
if _nav_agent.is_target_reachable():
|
||||
_walk_mode_latched = true
|
||||
_walk_mode = "nav"
|
||||
else:
|
||||
return
|
||||
else:
|
||||
_walk_mode_latched = true
|
||||
_walk_mode = "direct"
|
||||
var next_feet: Vector2 = _nav_agent.get_next_path_position()
|
||||
var final_root: Vector2 = _walk_target_feet + FOOT_OFFSET
|
||||
var dist_to_final: float = absf(_rig.global_position.x - final_root.x)
|
||||
var root_target: Vector2
|
||||
if _walk_mode == "nav":
|
||||
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
|
||||
_rig.global_position.x = final_root.x
|
||||
_finish_walk()
|
||||
return
|
||||
# A freshly synchronized agent may yield its own feet as the first
|
||||
# waypoint. Treat that zero-progress waypoint as the final segment.
|
||||
var next_progress: float = next_feet.x - _rig.global_position.x
|
||||
var remaining_progress: float = final_root.x - _rig.global_position.x
|
||||
if dist_to_final <= 2.0 * ARRIVE_DISTANCE or remaining_progress * next_progress <= 0.0:
|
||||
root_target = Vector2(final_root.x, _rig.global_position.y)
|
||||
else:
|
||||
root_target = Vector2(next_feet.x, _rig.global_position.y)
|
||||
else:
|
||||
root_target = Vector2(final_root.x, _rig.global_position.y)
|
||||
var before_x: float = _rig.global_position.x
|
||||
_rig.global_position = _rig.global_position.move_toward(root_target, _walk_speed_current * delta)
|
||||
_last_grounded_walk_velocity_x = (_rig.global_position.x - before_x) / delta if delta > 0.0 else 0.0
|
||||
if dist_to_final <= ARRIVE_DISTANCE or absf(_rig.global_position.x - final_root.x) <= ARRIVE_DISTANCE:
|
||||
_rig.global_position.x = final_root.x
|
||||
_finish_walk()
|
||||
|
||||
func _settle_walk_markers() -> void:
|
||||
if _walk_settle_frames > 0:
|
||||
_walk_settle_frames -= 1
|
||||
_rig._restore_standing_markers()
|
||||
|
||||
func _finish_walk() -> void:
|
||||
var final_limb_rotations: Dictionary = _rig._capture_limb_rotations()
|
||||
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
|
||||
_rig._anim_player.stop(true)
|
||||
_walking = false
|
||||
_rig._apply_idle_limb_solver_mode(final_limb_rotations)
|
||||
_walk_settle_frames = 0
|
||||
_walk_done = true
|
||||
arrived.emit(_walk_target_feet)
|
||||
|
||||
func begin_terrain_fall() -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
terrain_following_enabled = true
|
||||
terrain_pelvis_offset = 0.0
|
||||
_terrain_paused_walk_animation = false
|
||||
_enter_airborne(0.0)
|
||||
_reset_foot_contacts()
|
||||
|
||||
func set_terrain_following_enabled(enabled: bool) -> void:
|
||||
if enabled:
|
||||
if terrain_motion == TerrainMotion.DISABLED:
|
||||
begin_terrain_fall()
|
||||
return
|
||||
terrain_following_enabled = false
|
||||
_cancel_landing_impact(true)
|
||||
_restore_air_pose()
|
||||
terrain_pelvis_offset = 0.0
|
||||
terrain_air_velocity = Vector2.ZERO
|
||||
terrain_motion = TerrainMotion.DISABLED
|
||||
_terrain_paused_walk_animation = false
|
||||
_reset_foot_contacts()
|
||||
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
||||
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
|
||||
var target: Marker2D = _rig._get_ik_marker(leg_name)
|
||||
if base != null and target != null:
|
||||
target.position = base.position
|
||||
|
||||
func prepare_for_ragdoll() -> void:
|
||||
if terrain_motion != TerrainMotion.DISABLED:
|
||||
set_terrain_following_enabled(false)
|
||||
|
||||
func reset_for_ragdoll_handoff() -> void:
|
||||
_cancel_landing_impact(false)
|
||||
air_pose_base.clear()
|
||||
air_pose_weight = 0.0
|
||||
_air_time = 0.0
|
||||
_air_entry_root_y = 0.0
|
||||
air_fall_distance = 0.0
|
||||
terrain_motion = TerrainMotion.DISABLED
|
||||
terrain_air_velocity = Vector2.ZERO
|
||||
_reset_foot_contacts()
|
||||
|
||||
func is_action_blocked() -> bool:
|
||||
return terrain_motion == TerrainMotion.LANDING_IMPACT
|
||||
|
||||
func blocks_walking() -> bool:
|
||||
return terrain_following_enabled and terrain_motion != TerrainMotion.GROUNDED
|
||||
|
||||
func _reset_foot_contacts() -> void:
|
||||
for leg_name: String in foot_contacts:
|
||||
foot_contacts[leg_name] = {"valid": false, "locked": false}
|
||||
|
||||
func _probe_terrain_below(world_point: Vector2, probe_down: float = TERRAIN_PROBE_DOWN) -> Dictionary:
|
||||
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
|
||||
if space_state == null:
|
||||
return {"valid": false}
|
||||
var query := PhysicsRayQueryParameters2D.create(world_point + Vector2.UP * TERRAIN_PROBE_UP, world_point + Vector2.DOWN * probe_down)
|
||||
query.collision_mask = 1
|
||||
query.collide_with_areas = false
|
||||
query.exclude = _rig.get_collision_body_rids()
|
||||
var hit: Dictionary = space_state.intersect_ray(query)
|
||||
if hit.is_empty() or not (hit.get("collider") is TerrainBlock):
|
||||
return {"valid": false}
|
||||
var normal: Vector2 = hit.get("normal", Vector2.ZERO)
|
||||
if normal.y > TERRAIN_NORMAL_MIN_Y:
|
||||
return {"valid": false}
|
||||
return {"valid": true, "world_pos": hit.get("position", world_point), "normal": normal}
|
||||
|
||||
func _probe_terrain_fan(world_point: Vector2, offsets: Array[float]) -> Dictionary:
|
||||
var best: Dictionary = {"valid": false}
|
||||
for x_offset: float in offsets:
|
||||
var hit: Dictionary = _probe_terrain_below(world_point + Vector2(x_offset, 0.0))
|
||||
if bool(hit.get("valid", false)) and (not bool(best.get("valid", false)) or (hit["world_pos"] as Vector2).y < (best["world_pos"] as Vector2).y):
|
||||
best = hit
|
||||
return best
|
||||
|
||||
func probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float:
|
||||
var best_y := bottom_y
|
||||
var best_distance := INF
|
||||
if _rig == null:
|
||||
return best_y
|
||||
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
|
||||
if space_state == null:
|
||||
return best_y
|
||||
for offset: float in RECOVERY_PROBE_X_OFFSETS:
|
||||
var query := PhysicsRayQueryParameters2D.create(
|
||||
Vector2(world_x + offset, top_y - RECOVERY_PROBE_START_OFFSET),
|
||||
Vector2(world_x + offset, bottom_y + RECOVERY_PROBE_END_OFFSET))
|
||||
query.collision_mask = 1
|
||||
query.collide_with_areas = false
|
||||
query.collide_with_bodies = true
|
||||
query.exclude = exclude
|
||||
var hit := space_state.intersect_ray(query)
|
||||
if hit.is_empty():
|
||||
continue
|
||||
var hit_position: Vector2 = hit.get("position", Vector2.ZERO)
|
||||
if hit_position.y < top_y - RECOVERY_PROBE_TOLERANCE or hit_position.y > bottom_y + RECOVERY_PROBE_TOLERANCE:
|
||||
continue
|
||||
var distance := absf(hit_position.y - bottom_y)
|
||||
if distance < best_distance:
|
||||
best_distance = distance
|
||||
best_y = hit_position.y
|
||||
return best_y
|
||||
|
||||
func _stance_weight(leg_name: String) -> float:
|
||||
if not _walking or _rig._anim_player == null:
|
||||
return 1.0
|
||||
var phase: float = fmod(_rig._anim_player.get_current_animation_position(), WALK_CYCLE_DURATION)
|
||||
var right_weight: float = 0.5 + 0.5 * cos(TAU * phase / WALK_CYCLE_DURATION)
|
||||
return right_weight if leg_name == "Right_Leg" else 1.0 - right_weight
|
||||
|
||||
func _update_terrain_following(delta: float) -> void:
|
||||
if not terrain_following_enabled or _rig.state != StickmanRig.RigState.ANIMATED or _leg_base_markers.size() != 2:
|
||||
terrain_pelvis_offset = 0.0
|
||||
return
|
||||
_rig._set_limb_mods_enabled(true)
|
||||
var center_world: Vector2 = _rig.to_global(NAV_AGENT_LOCAL_POS)
|
||||
var center_probe: Dictionary = _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS)
|
||||
var center_delta: float = (center_probe["world_pos"] as Vector2).y - center_world.y if bool(center_probe.get("valid", false)) else INF
|
||||
var center_supported: bool = bool(center_probe.get("valid", false)) and absf(center_delta) <= TERRAIN_SUPPORT_RANGE
|
||||
var center_surface_y: float = (center_probe["world_pos"] as Vector2).y if bool(center_probe.get("valid", false)) else INF
|
||||
var solved: Dictionary = {}
|
||||
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
||||
var base: Marker2D = _leg_base_markers[leg_name] as Marker2D
|
||||
var target: Marker2D = _rig._get_ik_marker(leg_name)
|
||||
if base == null or target == null:
|
||||
continue
|
||||
var base_world: Vector2 = base.global_position
|
||||
var probe: Dictionary = _probe_terrain_fan(base_world, TERRAIN_FOOT_PROBE_OFFSETS)
|
||||
var probe_delta: float = (probe["world_pos"] as Vector2).y - base_world.y if bool(probe.get("valid", false)) else INF
|
||||
var within_reach: bool = absf(probe_delta) <= PELVIS_MAX_OFFSET
|
||||
if within_reach and center_supported:
|
||||
within_reach = (probe["world_pos"] as Vector2).y <= center_surface_y + TERRAIN_MAX_FOOT_SURFACE_DROP
|
||||
var stance: float = _stance_weight(leg_name)
|
||||
var contact: Dictionary = foot_contacts[leg_name]
|
||||
if stance >= 0.5 and bool(probe.get("valid", false)) and within_reach:
|
||||
if not bool(contact.get("locked", false)):
|
||||
contact = {"valid": true, "locked": true, "world_pos": probe["world_pos"], "normal": probe["normal"]}
|
||||
elif (contact.get("world_pos", base_world) as Vector2).distance_to(probe["world_pos"]) > 2.0:
|
||||
contact["world_pos"] = probe["world_pos"]
|
||||
elif stance < 0.5 or not within_reach:
|
||||
contact = {"valid": bool(probe.get("valid", false)), "locked": false}
|
||||
foot_contacts[leg_name] = contact
|
||||
var floor_world: Vector2 = contact.get("world_pos", base_world) as Vector2 if bool(contact.get("locked", false)) else probe.get("world_pos", base_world) as Vector2
|
||||
solved[leg_name] = {"base_marker": base, "floor": floor_world, "surface": probe.get("world_pos", base_world) if within_reach else base_world, "surface_valid": bool(probe.get("valid", false)) and within_reach, "weight": stance, "target": target}
|
||||
if terrain_motion == TerrainMotion.LANDING_IMPACT:
|
||||
_update_landing_impact(delta)
|
||||
return
|
||||
if terrain_motion == TerrainMotion.AIRBORNE:
|
||||
_update_airborne(delta, solved)
|
||||
return
|
||||
if not center_supported:
|
||||
_enter_airborne(_last_grounded_walk_velocity_x)
|
||||
_update_airborne(delta, solved)
|
||||
return
|
||||
terrain_pelvis_offset = lerpf(0.0, clampf(center_delta, -PELVIS_MAX_OFFSET, PELVIS_MAX_OFFSET), 1.0 - exp(-PELVIS_RESPONSE * delta))
|
||||
terrain_motion = TerrainMotion.GROUNDED
|
||||
terrain_air_velocity = Vector2.ZERO
|
||||
_resume_walk_animation()
|
||||
_rig.global_position.y += terrain_pelvis_offset
|
||||
for leg_name: String in solved:
|
||||
var item: Dictionary = solved[leg_name]
|
||||
var base_after: Vector2 = (item["base_marker"] as Marker2D).global_position
|
||||
var weight: float = float(item["weight"])
|
||||
var surface: Vector2 = item["surface"] as Vector2
|
||||
if bool(item["surface_valid"]):
|
||||
weight = maxf(weight, smoothstep(0.0, 1.0, 1.0 - clampf((surface.y - base_after.y) / TERRAIN_FOOT_GUARD_HEIGHT, 0.0, 1.0)))
|
||||
var wanted: Vector2 = base_after
|
||||
wanted.y = lerpf(base_after.y, (item["floor"] as Vector2).y, weight)
|
||||
if bool(item["surface_valid"]) and wanted.y > surface.y:
|
||||
wanted.y = surface.y
|
||||
(item["target"] as Marker2D).global_position = wanted
|
||||
|
||||
func _enter_airborne(horizontal_velocity: float) -> void:
|
||||
if terrain_motion != TerrainMotion.AIRBORNE:
|
||||
air_pose_base.clear()
|
||||
for marker_name: String in ["Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]:
|
||||
var marker: Marker2D = _rig._get_ik_marker(marker_name)
|
||||
if marker != null:
|
||||
air_pose_base[marker_name] = marker.position
|
||||
_air_time = 0.0
|
||||
air_pose_weight = 0.0
|
||||
_air_entry_root_y = _rig.global_position.y
|
||||
air_fall_distance = 0.0
|
||||
terrain_motion = TerrainMotion.AIRBORNE
|
||||
terrain_air_velocity = Vector2(horizontal_velocity, 0.0)
|
||||
if _walking and _rig._anim_player != null and _rig._anim_player.is_playing():
|
||||
_rig._anim_player.pause()
|
||||
_terrain_paused_walk_animation = true
|
||||
_reset_foot_contacts()
|
||||
|
||||
func _update_airborne(delta: float, solved: Dictionary) -> void:
|
||||
terrain_air_velocity.x *= exp(-AIR_DRAG_X * delta)
|
||||
terrain_air_velocity.y = minf(terrain_air_velocity.y + TERRAIN_GRAVITY * delta, TERRAIN_MAX_FALL_SPEED)
|
||||
_rig.global_position += _sweep_airborne_horizontal_motion(Vector2(terrain_air_velocity.x * delta, 0.0))
|
||||
_rig.global_position.y += terrain_air_velocity.y * delta
|
||||
air_fall_distance = maxf(air_fall_distance, _rig.global_position.y - _air_entry_root_y)
|
||||
_air_time += delta
|
||||
_apply_air_pose(delta)
|
||||
var center_world: Vector2 = _rig.to_global(NAV_AGENT_LOCAL_POS)
|
||||
var center_probe: Dictionary = _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS)
|
||||
var landing_delta: float = (center_probe["world_pos"] as Vector2).y - center_world.y if bool(center_probe.get("valid", false)) else INF
|
||||
if bool(center_probe.get("valid", false)) and terrain_air_velocity.y >= 0.0 and landing_delta >= -TERRAIN_SUPPORT_RANGE and landing_delta <= TERRAIN_SUPPORT_RANGE + terrain_air_velocity.y * delta:
|
||||
_rig.global_position.y += landing_delta
|
||||
var speed: float = maxf(terrain_air_velocity.y, 0.0)
|
||||
terrain_air_velocity = Vector2.ZERO
|
||||
if air_fall_distance >= HARD_LANDING_MIN_FALL_HEIGHT:
|
||||
_begin_landing(speed, center_probe, solved)
|
||||
else:
|
||||
_restore_air_pose()
|
||||
terrain_motion = TerrainMotion.GROUNDED
|
||||
_resume_walk_animation()
|
||||
|
||||
func _apply_air_pose(delta: float) -> void:
|
||||
if air_pose_base.is_empty():
|
||||
return
|
||||
var speed: float = maxf(terrain_air_velocity.y, 0.0)
|
||||
air_pose_weight = lerpf(air_pose_weight, smoothstep(AIR_POSE_START_SPEED, AIR_POSE_FULL_SPEED, speed), 1.0 - exp(-AIR_POSE_RESPONSE * delta))
|
||||
var forward_weight: float = smoothstep(AIR_HAND_FORWARD_START_SPEED, AIR_HAND_FORWARD_FULL_SPEED, speed)
|
||||
var phase: float = TAU * AIR_FLAIL_HZ * _air_time
|
||||
for marker_name: String in air_pose_base:
|
||||
var marker: Marker2D = _rig._get_ik_marker(marker_name)
|
||||
if marker == null:
|
||||
continue
|
||||
var base: Vector2 = air_pose_base[marker_name]
|
||||
var local_phase: float = phase + (PI if marker_name.begins_with("Right") else 0.0)
|
||||
var offset: Vector2
|
||||
if marker_name.ends_with("Hand"):
|
||||
offset = Vector2(AIR_HAND_FORWARD * forward_weight, -AIR_HAND_LIFT * air_pose_weight) + Vector2(sin(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.y) * air_pose_weight
|
||||
marker.position = base + offset
|
||||
else:
|
||||
offset = Vector2(sin(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.y)
|
||||
marker.position = base + offset * air_pose_weight
|
||||
|
||||
func _sweep_airborne_horizontal_motion(motion: Vector2) -> Vector2:
|
||||
if is_zero_approx(motion.x):
|
||||
return motion
|
||||
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
|
||||
if space_state == null:
|
||||
return motion
|
||||
var safe_fraction: float = 1.0
|
||||
var excluded: Array[RID] = []
|
||||
for own_proxy: AnimatableBody2D in collision_bodies.values():
|
||||
if own_proxy != null and is_instance_valid(own_proxy):
|
||||
var own_rid: RID = own_proxy.get_rid()
|
||||
if own_rid.is_valid():
|
||||
excluded.append(own_rid)
|
||||
for proxy: AnimatableBody2D in collision_bodies.values():
|
||||
if proxy == null or not is_instance_valid(proxy):
|
||||
continue
|
||||
var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||
if shape_node == null or shape_node.shape == null:
|
||||
continue
|
||||
var query := PhysicsShapeQueryParameters2D.new()
|
||||
query.shape = shape_node.shape
|
||||
query.transform = shape_node.global_transform
|
||||
query.motion = motion
|
||||
query.collision_mask = COLLISION_LAYER
|
||||
query.exclude = excluded
|
||||
var cast: PackedFloat32Array = space_state.cast_motion(query)
|
||||
if cast.size() >= 1:
|
||||
safe_fraction = minf(safe_fraction, cast[0])
|
||||
if safe_fraction < 0.9999:
|
||||
terrain_air_velocity.x = 0.0
|
||||
return motion * safe_fraction
|
||||
|
||||
func _begin_landing(_impact_speed: float, center_probe: Dictionary, _solved: Dictionary) -> void:
|
||||
terrain_motion = TerrainMotion.LANDING_IMPACT
|
||||
_landing_elapsed = 0.0
|
||||
var weight: float = clampf(inverse_lerp(HARD_LANDING_MIN_FALL_HEIGHT, HARD_LANDING_MAX_FALL_HEIGHT, air_fall_distance), 0.0, 1.0)
|
||||
landing_compression = lerpf(LANDING_COMPRESSION_MIN, LANDING_COMPRESSION_MAX, weight)
|
||||
_landing_hand_drop = lerpf(LANDING_HAND_DROP_MIN, LANDING_HAND_DROP_MAX, weight)
|
||||
var torso: Marker2D = _rig._get_ik_marker("Torso")
|
||||
if torso != null: _landing_torso_base = torso.position
|
||||
_landing_hand_start.clear(); _landing_contacts.clear()
|
||||
for hand_name: String in ["Left_Hand", "Right_Hand"]:
|
||||
var hand: Marker2D = _rig._get_ik_marker(hand_name)
|
||||
if hand != null: _landing_hand_start[hand_name] = hand.position
|
||||
var center_y: float = (center_probe.get("world_pos", _rig.global_position) as Vector2).y
|
||||
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
||||
var target: Marker2D = _rig._get_ik_marker(leg_name)
|
||||
if target == null: continue
|
||||
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
|
||||
var probe: Dictionary = _probe_terrain_fan(base.global_position if base != null else target.global_position, TERRAIN_FOOT_PROBE_OFFSETS)
|
||||
var contact: Vector2 = probe["world_pos"] as Vector2 if bool(probe.get("valid", false)) else Vector2(target.global_position.x, center_y)
|
||||
_landing_contacts[leg_name] = contact; target.global_position = contact
|
||||
|
||||
func _update_landing_impact(delta: float) -> void:
|
||||
_landing_elapsed += delta
|
||||
var total: float = LANDING_COMPRESS_DURATION + LANDING_RECOVER_DURATION
|
||||
var offset: float = landing_compression * (1.0 - pow(1.0 - clampf(_landing_elapsed / LANDING_COMPRESS_DURATION, 0.0, 1.0), 2.0)) if _landing_elapsed <= LANDING_COMPRESS_DURATION else landing_compression * cos(clampf((_landing_elapsed - LANDING_COMPRESS_DURATION) / LANDING_RECOVER_DURATION, 0.0, 1.0) * PI * 0.5)
|
||||
var torso: Marker2D = _rig._get_ik_marker("Torso")
|
||||
if torso != null: torso.position = _landing_torso_base + Vector2.DOWN * offset
|
||||
for leg_name: String in _landing_contacts:
|
||||
var target: Marker2D = _rig._get_ik_marker(leg_name)
|
||||
if target != null: target.global_position = _landing_contacts[leg_name]
|
||||
for hand_name: String in _landing_hand_start:
|
||||
var hand: Marker2D = _rig._get_ik_marker(hand_name)
|
||||
if hand != null: hand.position = (_landing_hand_start[hand_name] as Vector2).lerp(air_pose_base.get(hand_name, _landing_hand_start[hand_name]), clampf(_landing_elapsed / total, 0.0, 1.0)) + Vector2.DOWN * _landing_hand_drop * offset / maxf(landing_compression, 0.0001)
|
||||
if _landing_elapsed >= total: _finish_landing_impact()
|
||||
|
||||
func _finish_landing_impact() -> void:
|
||||
var torso: Marker2D = _rig._get_ik_marker("Torso")
|
||||
if torso != null: torso.position = _landing_torso_base
|
||||
_restore_air_pose(); _landing_contacts.clear(); _landing_hand_start.clear(); _landing_elapsed = 0.0; landing_compression = 0.0; _landing_hand_drop = 0.0; terrain_motion = TerrainMotion.GROUNDED
|
||||
_resume_walk_animation()
|
||||
|
||||
func _cancel_landing_impact(restore_pose: bool) -> void:
|
||||
if terrain_motion == TerrainMotion.LANDING_IMPACT and restore_pose:
|
||||
var torso: Marker2D = _rig._get_ik_marker("Torso")
|
||||
if torso != null: torso.position = _landing_torso_base
|
||||
_landing_contacts.clear(); _landing_hand_start.clear(); _landing_elapsed = 0.0; landing_compression = 0.0; _landing_hand_drop = 0.0
|
||||
|
||||
func _restore_air_pose() -> void:
|
||||
for marker_name: String in air_pose_base:
|
||||
var marker: Marker2D = _rig._get_ik_marker(marker_name)
|
||||
if marker != null: marker.position = air_pose_base[marker_name]
|
||||
air_pose_base.clear(); air_pose_weight = 0.0; _air_time = 0.0; _air_entry_root_y = 0.0; air_fall_distance = 0.0
|
||||
|
||||
func _resume_walk_animation() -> void:
|
||||
if _terrain_paused_walk_animation and _walking and _rig._anim_player != null:
|
||||
_rig._anim_player.play()
|
||||
_terrain_paused_walk_animation = false
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxyogpn8rv8xy
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://bsdnwby48vl5
|
||||
@@ -0,0 +1,878 @@
|
||||
class_name StickmanRagdoll
|
||||
extends Node
|
||||
## Owns procedural ragdoll physics. StickmanRig remains the public state facade.
|
||||
|
||||
signal recovery_requested()
|
||||
signal rest_state_changed(at_rest: bool)
|
||||
signal state_transition_requested(next_state: int)
|
||||
|
||||
const CONTAINER_NAME: StringName = &"RagdollBodyContainer"
|
||||
const LIMB_RADIUS: float = 8.0
|
||||
const TORSO_RADIUS: float = 12.0
|
||||
const HEAD_RADIUS: float = 100.0
|
||||
const VISUAL_COLOR: Color = Color(0.445488, 0.445488, 0.445488)
|
||||
const CIRCLE_SEGMENTS: int = 32
|
||||
const REST_LINEAR_THRESHOLD: float = 5.0
|
||||
const REST_ANGULAR_THRESHOLD: float = 0.1
|
||||
const STABILIZATION_DELAY: float = 0.1
|
||||
const TARGET_SOFTNESS: float = 0.2
|
||||
const SIDE_PROFILE_STRAIGHT_THRESHOLD := deg_to_rad(5.0)
|
||||
const SIDE_PROFILE_KNEE_BIAS := deg_to_rad(3.0)
|
||||
const GROUND_PROBE_START_OFFSET := 50.0
|
||||
const GROUND_PROBE_END_OFFSET := 1500.0
|
||||
const GROUND_PROBE_TOLERANCE := 200.0
|
||||
const GROUND_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
|
||||
const STAND_UP_DURATION := 2.0
|
||||
const STAND_POSE: Dictionary = {
|
||||
"Torso": {"pos": Vector2(0, 10), "rot": 0.0},
|
||||
"Head": {"pos": Vector2(100, -614), "rot": 0.0},
|
||||
"Left_Hand": {"pos": Vector2(90, 110), "rot": 0.0},
|
||||
"Right_Hand": {"pos": Vector2(-90, 110), "rot": 0.0},
|
||||
"Left_Leg": {"pos": Vector2(-110, 380), "rot": 0.0},
|
||||
"Right_Leg": {"pos": Vector2(110, 390), "rot": 0.0},
|
||||
}
|
||||
const IK_TARGET_PATHS: Dictionary = {
|
||||
"Torso": "IK_Targets/Torso", "Head": "IK_Targets/Head",
|
||||
"Left_Hand": "IK_Targets/Left_Hand", "Right_Hand": "IK_Targets/Right_Hand",
|
||||
"Left_Leg": "IK_Targets/Left_Leg", "Right_Leg": "IK_Targets/Right_Leg",
|
||||
}
|
||||
const LIMB_BONE_PATHS: Dictionary = {
|
||||
"LeftArm": ["Torso/LeftUpperArm", "Torso/LeftUpperArm/LeftLowerArm"],
|
||||
"RightArm": ["Torso/RightUpperArm", "Torso/RightUpperArm/RightLowerArm"],
|
||||
"LeftLeg": ["Torso/LeftUpperLeg", "Torso/LeftUpperLeg/LeftLowerLeg"],
|
||||
"RightLeg": ["Torso/RightUpperLeg", "Torso/RightUpperLeg/RightLowerLeg"],
|
||||
}
|
||||
const LIMB_BODY_KEYS: Dictionary = {
|
||||
"LeftArm": ["left_upper_arm", "left_lower_arm"],
|
||||
"RightArm": ["right_upper_arm", "right_lower_arm"],
|
||||
"LeftLeg": ["left_upper_leg", "left_lower_leg"],
|
||||
"RightLeg": ["right_upper_leg", "right_lower_leg"],
|
||||
}
|
||||
const RECOVERY_LEG_SIDE_FORCE: Dictionary = {
|
||||
StickmanRig.FacingProfile.FORWARD: {}, StickmanRig.FacingProfile.LEFT: {}, StickmanRig.FacingProfile.RIGHT: {},
|
||||
}
|
||||
const RECOVERY_JOINT_PATH: Dictionary = {
|
||||
StickmanRig.FacingProfile.FORWARD: {}, StickmanRig.FacingProfile.LEFT: {}, StickmanRig.FacingProfile.RIGHT: {},
|
||||
}
|
||||
|
||||
const BODIES: Array[Dictionary] = [
|
||||
{ "key": "torso", "kind": "bone", "node_path": "Torso", "visual_path": "Body/Body", "radius": TORSO_RADIUS, "mass": 8.0, "linear_damp": 1.0, "angular_damp": 4.0 },
|
||||
{ "key": "head", "kind": "visual", "node_path": "Body/Head", "visual_path": "Body/Head", "radius": HEAD_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 2.0 },
|
||||
{ "key": "left_upper_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm", "visual_path": "Body/LeftUpperArm", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "left_lower_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm/LeftLowerArm", "visual_path": "Body/LeftLowerArm", "radius": LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "right_upper_arm", "kind": "bone", "node_path": "Torso/RightUpperArm", "visual_path": "Body/RightUpperArm", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_lower_arm", "kind": "bone", "node_path": "Torso/RightUpperArm/RightLowerArm", "visual_path": "Body/RightLowerArm", "radius": LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "left_upper_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg", "visual_path": "Body/LeftUpperLeg", "radius": LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "left_lower_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "visual_path": "Body/LeftLowerLeg", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
{ "key": "right_upper_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg", "visual_path": "Body/RightUpperLeg", "radius": LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_lower_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg/RightLowerLeg", "visual_path": "Body/RightLowerLeg", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||||
]
|
||||
const JOINTS: Array[Dictionary] = [
|
||||
{ "child": "head", "parent": "torso", "pin": "Torso/Head", "limit": "neck" }, { "child": "left_upper_arm", "parent": "torso", "pin": "Torso/LeftUpperArm", "limit": "shoulder" }, { "child": "left_lower_arm", "parent": "left_upper_arm", "pin": "Torso/LeftUpperArm/LeftLowerArm", "limit": "cw" }, { "child": "right_upper_arm", "parent": "torso", "pin": "Torso/RightUpperArm", "limit": "shoulder" }, { "child": "right_lower_arm", "parent": "right_upper_arm", "pin": "Torso/RightUpperArm/RightLowerArm", "limit": "ccw" }, { "child": "left_upper_leg", "parent": "torso", "pin": "Torso/LeftUpperLeg", "limit": "shoulder" }, { "child": "left_lower_leg", "parent": "left_upper_leg", "pin": "Torso/LeftUpperLeg/LeftLowerLeg", "limit": "ccw" }, { "child": "right_upper_leg", "parent": "torso", "pin": "Torso/RightUpperLeg", "limit": "shoulder" }, { "child": "right_lower_leg", "parent": "right_upper_leg", "pin": "Torso/RightUpperLeg/RightLowerLeg", "limit": "cw" },
|
||||
]
|
||||
|
||||
## Per-profile elbow/knee limits. Side-profile leg hinges include the straight
|
||||
## pose (0 degrees) and allow both knees to fold toward the same profile-relative
|
||||
## side, avoiding an instant forced +/-90-degree fold at ragdoll handoff.
|
||||
## The component owns these settings because it creates the PinJoint2D network.
|
||||
const JOINT_BAND_LIMITS: Dictionary = {
|
||||
StickmanRig.FacingProfile.FORWARD: {
|
||||
"left_lower_arm": { "lower": -150.0, "upper": 5.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 },
|
||||
"left_lower_leg": { "lower": -5.0, "upper": 150.0 }, "right_lower_leg": { "lower": -5.0, "upper": 150.0 },
|
||||
},
|
||||
StickmanRig.FacingProfile.LEFT: {
|
||||
"left_lower_arm": { "lower": 60.0, "upper": 100.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 },
|
||||
"left_lower_leg": { "lower": -150.0, "upper": 0.0 }, "right_lower_leg": { "lower": -150.0, "upper": 0.0 },
|
||||
},
|
||||
StickmanRig.FacingProfile.RIGHT: {
|
||||
"left_lower_arm": { "lower": -100.0, "upper": -60.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 },
|
||||
"left_lower_leg": { "lower": 0.0, "upper": 150.0 }, "right_lower_leg": { "lower": 0.0, "upper": 150.0 },
|
||||
},
|
||||
}
|
||||
|
||||
var _rig: StickmanRig = null
|
||||
var _locomotion: StickmanLocomotion = null
|
||||
var _skeleton: Skeleton2D = null
|
||||
var _body_container: Node2D = null
|
||||
var _anim_player: AnimationPlayer = null
|
||||
var _torso_bone: Bone2D = null
|
||||
var _head_bone: Bone2D = null
|
||||
var _head_look_at: SkeletonModification2DLookAt = null
|
||||
var _limb_bones: Dictionary = {}
|
||||
var _bend_modifications: Dictionary = {}
|
||||
var _root: Node2D = null
|
||||
var _bodies: Dictionary = {}
|
||||
var _previous_position: Vector2 = Vector2.ZERO
|
||||
var _previous_rotation: float = 0.0
|
||||
var _linear_velocity: Vector2 = Vector2.ZERO
|
||||
var _angular_velocity: float = 0.0
|
||||
var _rest_timer: float = 0.0
|
||||
var _stabilize_timer: float = 0.0
|
||||
var _at_rest: bool = false
|
||||
var _captured_pose: Dictionary = {}
|
||||
var _captured_landing_center := Vector2.ZERO
|
||||
var _captured_ground_y := 0.0
|
||||
var _stand_up_tween: Tween = null
|
||||
var _stand_up_generation := 0
|
||||
var _recovery_bones: Array[Dictionary] = []
|
||||
var _pre_ragdoll_state: Dictionary = {}
|
||||
var _probe_top_y := 0.0
|
||||
var _probe_bottom_y := 0.0
|
||||
var _probe_exclude: Array[RID] = []
|
||||
var _spawn_pose: Dictionary = {}
|
||||
var _spawn_limb_rotations: Dictionary = {}
|
||||
var _simulation_paused: bool = false
|
||||
var _paused_body_states: Dictionary = {}
|
||||
var _was_recovery_tween_running: bool = false
|
||||
|
||||
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
|
||||
_rig = rig
|
||||
_locomotion = locomotion
|
||||
|
||||
func initialize(skeleton: Skeleton2D) -> void:
|
||||
_skeleton = skeleton
|
||||
_body_container = _rig.get_node_or_null(NodePath("Body")) as Node2D
|
||||
_anim_player = _rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
|
||||
if _skeleton != null:
|
||||
_torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
|
||||
_head_bone = _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
||||
_resolve_limb_bones()
|
||||
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
||||
if stack != null:
|
||||
for index: int in stack.modification_count:
|
||||
var modification: SkeletonModification2D = stack.get_modification(index)
|
||||
if modification is SkeletonModification2DLookAt:
|
||||
_head_look_at = modification as SkeletonModification2DLookAt
|
||||
elif modification is SkeletonModification2DTwoBoneIK:
|
||||
var ik := modification as SkeletonModification2DTwoBoneIK
|
||||
for joint: String in LIMB_BONE_PATHS:
|
||||
if ik.joint_two_bone2d_node == NodePath(String((LIMB_BONE_PATHS[joint] as Array)[1])):
|
||||
_bend_modifications[joint] = ik
|
||||
break
|
||||
_previous_position = _rig.global_position
|
||||
_previous_rotation = _rig.global_rotation
|
||||
|
||||
|
||||
## Runs after the physics step and before drawing. Godot's PinJoint2D can let a
|
||||
## straight knee cross its angular limit during the step; correcting here keeps
|
||||
## that invalid intermediate pose from ever reaching the rendered frame.
|
||||
func _process(_delta: float) -> void:
|
||||
if not _simulation_paused and _rig != null and _rig.state == StickmanRig.RigState.RAGDOLL:
|
||||
_enforce_side_profile_leg_bends()
|
||||
|
||||
|
||||
func pause_simulation() -> void:
|
||||
if _simulation_paused:
|
||||
return
|
||||
_simulation_paused = true
|
||||
_paused_body_states.clear()
|
||||
_was_recovery_tween_running = _stand_up_tween != null and _stand_up_tween.is_valid() and _stand_up_tween.is_running()
|
||||
if _was_recovery_tween_running:
|
||||
_stand_up_tween.pause()
|
||||
for body: RigidBody2D in _bodies.values():
|
||||
if body == null or not is_instance_valid(body):
|
||||
continue
|
||||
var body_id: int = body.get_instance_id()
|
||||
_paused_body_states[body_id] = {"body": body, "transform": body.global_transform, "linear_velocity": body.linear_velocity, "angular_velocity": body.angular_velocity, "sleeping": body.sleeping, "freeze": body.freeze, "freeze_mode": body.freeze_mode}
|
||||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
body.freeze = true
|
||||
|
||||
|
||||
func resume_simulation() -> void:
|
||||
if not _simulation_paused:
|
||||
return
|
||||
for body_id: int in _paused_body_states.keys():
|
||||
var snapshot: Dictionary = _paused_body_states[body_id]
|
||||
var body: RigidBody2D = snapshot.get("body") as RigidBody2D
|
||||
if body == null or not is_instance_valid(body) or body.is_queued_for_deletion():
|
||||
continue
|
||||
body.freeze_mode = int(snapshot.get("freeze_mode", RigidBody2D.FREEZE_MODE_STATIC))
|
||||
body.freeze = bool(snapshot.get("freeze", false))
|
||||
body.linear_velocity = snapshot.get("linear_velocity", Vector2.ZERO)
|
||||
body.angular_velocity = float(snapshot.get("angular_velocity", 0.0))
|
||||
body.sleeping = bool(snapshot.get("sleeping", false))
|
||||
_paused_body_states.clear()
|
||||
_simulation_paused = false
|
||||
if _was_recovery_tween_running and _stand_up_tween != null and _stand_up_tween.is_valid():
|
||||
_stand_up_tween.play()
|
||||
_was_recovery_tween_running = false
|
||||
|
||||
|
||||
func clear_pause_snapshot() -> void:
|
||||
_paused_body_states.clear()
|
||||
_simulation_paused = false
|
||||
_was_recovery_tween_running = false
|
||||
|
||||
|
||||
func get_body_nodes() -> Array[RigidBody2D]:
|
||||
var result: Array[RigidBody2D] = []
|
||||
for body: RigidBody2D in _bodies.values():
|
||||
if body != null and is_instance_valid(body):
|
||||
result.append(body)
|
||||
return result
|
||||
|
||||
|
||||
func get_body_rids() -> Array[RID]:
|
||||
var result: Array[RID] = []
|
||||
for body: RigidBody2D in get_body_nodes():
|
||||
var rid: RID = body.get_rid()
|
||||
if rid.is_valid():
|
||||
result.append(rid)
|
||||
return result
|
||||
|
||||
|
||||
func get_world_body_bounds() -> Rect2:
|
||||
var bounds := Rect2()
|
||||
var has_bounds: bool = false
|
||||
for body: RigidBody2D in get_body_nodes():
|
||||
var extent: float = float(body.get_meta("radius", LIMB_RADIUS)) + float(body.get_meta("half_height", 0.0))
|
||||
var body_rect := Rect2(body.global_position - Vector2(extent, extent), Vector2(extent * 2.0, extent * 2.0))
|
||||
if not has_bounds:
|
||||
bounds = body_rect
|
||||
has_bounds = true
|
||||
else:
|
||||
bounds = bounds.merge(body_rect)
|
||||
return bounds if has_bounds else Rect2()
|
||||
|
||||
|
||||
func interrupt_recovery_to_ragdoll() -> void:
|
||||
if _rig == null or _rig.state != StickmanRig.RigState.RECOVERING:
|
||||
return
|
||||
if _stand_up_tween != null and _stand_up_tween.is_valid():
|
||||
_stand_up_tween.kill()
|
||||
_stand_up_tween = null
|
||||
_stand_up_generation += 1
|
||||
if enter_ragdoll():
|
||||
state_transition_requested.emit(StickmanRig.RigState.RAGDOLL)
|
||||
if _simulation_paused:
|
||||
_simulation_paused = false
|
||||
pause_simulation()
|
||||
|
||||
func _resolve_limb_bones() -> void:
|
||||
_limb_bones.clear()
|
||||
for joint: String in LIMB_BONE_PATHS:
|
||||
var paths: Array = LIMB_BONE_PATHS[joint]
|
||||
var upper := _skeleton.get_node_or_null(NodePath(String(paths[0]))) as Bone2D
|
||||
var lower := _skeleton.get_node_or_null(NodePath(String(paths[1]))) as Bone2D
|
||||
if upper != null and lower != null:
|
||||
_limb_bones[joint] = {"upper": upper, "lower": lower}
|
||||
|
||||
func tick_pre_movement(delta: float, rig_state: int, rest_timeout: float, auto_recover: bool) -> void:
|
||||
if _rig == null: return
|
||||
if delta > 0.0:
|
||||
_linear_velocity = (_rig.global_position - _previous_position) / delta
|
||||
_angular_velocity = wrapf(_rig.global_rotation - _previous_rotation, -PI, PI) / delta
|
||||
_previous_position = _rig.global_position
|
||||
_previous_rotation = _rig.global_rotation
|
||||
if rig_state != StickmanRig.RigState.RAGDOLL: return
|
||||
var torso: RigidBody2D = _bodies.get("torso") as RigidBody2D
|
||||
if torso == null or not is_instance_valid(torso): return
|
||||
var resting: bool = torso.sleeping or (torso.linear_velocity.length() <= REST_LINEAR_THRESHOLD and absf(torso.angular_velocity) <= REST_ANGULAR_THRESHOLD)
|
||||
if resting != _at_rest: _at_rest = resting; rest_state_changed.emit(resting)
|
||||
if not resting: _rest_timer = 0.0; _stabilize_timer = 0.0; return
|
||||
if not auto_recover: _rest_timer = 0.0; return
|
||||
_rest_timer += delta
|
||||
if _rest_timer >= rest_timeout:
|
||||
_stabilize_timer += delta
|
||||
if _stabilize_timer >= STABILIZATION_DELAY: recovery_requested.emit()
|
||||
|
||||
func build() -> bool:
|
||||
if _rig == null or _skeleton == null: return false
|
||||
var parent: Node = _rig.get_parent()
|
||||
if parent == null: parent = _rig.get_tree().current_scene
|
||||
if parent == null: return false
|
||||
_root = Node2D.new(); _root.name = CONTAINER_NAME; _root.set_meta("stickman_ragdoll_container", true); _root.add_to_group("stickman_ragdoll_container"); _root.set_meta("stickman_rig", _rig); parent.add_child(_root); _bodies.clear()
|
||||
for entry: Dictionary in BODIES: _build_body(entry)
|
||||
_stabilize_side_profile_leg_bends()
|
||||
for entry: Dictionary in JOINTS: _build_joint(entry)
|
||||
var torso: RigidBody2D = _bodies.get("torso") as RigidBody2D
|
||||
if torso != null: torso.linear_velocity = _linear_velocity; torso.angular_velocity = _angular_velocity
|
||||
reset_rest_state()
|
||||
return torso != null
|
||||
|
||||
|
||||
## A walk keyframe can put a leg exactly straight. At that singularity the
|
||||
## physics solver has no geometric knee side to preserve, so floating-point
|
||||
## noise can make either knee fold as if the opposite facing profile were in
|
||||
## use. Give only near-straight side-profile legs a small world-space bend
|
||||
## toward the profile's established rear side before the joints are created.
|
||||
func _stabilize_side_profile_leg_bends() -> void:
|
||||
if _rig == null or (_rig.facing_profile != StickmanRig.FacingProfile.LEFT \
|
||||
and _rig.facing_profile != StickmanRig.FacingProfile.RIGHT):
|
||||
return
|
||||
var desired_delta := SIDE_PROFILE_KNEE_BIAS \
|
||||
if _rig.facing_profile == StickmanRig.FacingProfile.RIGHT \
|
||||
else -SIDE_PROFILE_KNEE_BIAS
|
||||
for prefix: String in ["left", "right"]:
|
||||
var upper := _bodies.get(prefix + "_upper_leg") as RigidBody2D
|
||||
var lower := _bodies.get(prefix + "_lower_leg") as RigidBody2D
|
||||
if upper == null or lower == null:
|
||||
continue
|
||||
var current_delta := wrapf(lower.global_rotation - upper.global_rotation, -PI, PI)
|
||||
if absf(current_delta) > SIDE_PROFILE_STRAIGHT_THRESHOLD:
|
||||
continue
|
||||
var upper_half := float(upper.get_meta("half_height", 0.0))
|
||||
var lower_half := float(lower.get_meta("half_height", 0.0))
|
||||
var upper_dir := Vector2.from_angle(upper.global_rotation)
|
||||
var knee := upper.global_position + upper_dir * upper_half
|
||||
var lower_dir := upper_dir.rotated(desired_delta)
|
||||
lower.global_rotation = lower_dir.angle()
|
||||
lower.global_position = knee + lower_dir * lower_half
|
||||
|
||||
|
||||
## PinJoint2D can cross its angular limit near a perfectly straight chain in
|
||||
## Godot 4.7.1. Correct only an actual side crossing so a knee cannot select
|
||||
## the opposite solution. Normal motion on the intended side remains physical.
|
||||
func _enforce_side_profile_leg_bends() -> void:
|
||||
if _rig == null or (_rig.facing_profile != StickmanRig.FacingProfile.LEFT \
|
||||
and _rig.facing_profile != StickmanRig.FacingProfile.RIGHT):
|
||||
return
|
||||
var desired_side := -1.0 if _rig.facing_profile == StickmanRig.FacingProfile.RIGHT else 1.0
|
||||
var desired_delta_sign := -desired_side
|
||||
for prefix: String in ["left", "right"]:
|
||||
var upper := _bodies.get(prefix + "_upper_leg") as RigidBody2D
|
||||
var lower := _bodies.get(prefix + "_lower_leg") as RigidBody2D
|
||||
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
|
||||
continue
|
||||
var upper_half := float(upper.get_meta("half_height", 0.0))
|
||||
var lower_half := float(lower.get_meta("half_height", 0.0))
|
||||
var upper_dir := Vector2.from_angle(upper.global_rotation)
|
||||
var knee := upper.global_position + upper_dir * upper_half
|
||||
var hip := upper.global_position - upper_dir * upper_half
|
||||
var lower_dir_current := Vector2.from_angle(lower.global_rotation)
|
||||
var ankle := lower.global_position + lower_dir_current * lower_half
|
||||
var current_side := (ankle - hip).cross(knee - hip)
|
||||
if current_side * desired_side > 0.0:
|
||||
continue
|
||||
var current_delta := wrapf(lower.global_rotation - upper.global_rotation, -PI, PI)
|
||||
var corrected_delta := desired_delta_sign * minf(
|
||||
maxf(absf(current_delta), SIDE_PROFILE_KNEE_BIAS), deg_to_rad(150.0))
|
||||
var lower_dir := upper_dir.rotated(corrected_delta)
|
||||
lower.global_rotation = lower_dir.angle()
|
||||
lower.global_position = knee + lower_dir * lower_half
|
||||
lower.angular_velocity = upper.angular_velocity
|
||||
|
||||
func destroy() -> void:
|
||||
if _root != null and is_instance_valid(_root): _root.name = StringName(str(CONTAINER_NAME) + "_retired"); _root.queue_free()
|
||||
_root = null; _bodies.clear(); reset_rest_state()
|
||||
|
||||
func get_bodies() -> Dictionary: return _bodies
|
||||
func is_at_rest() -> bool: return _at_rest
|
||||
func reset_rest_state() -> void: _at_rest = false; _rest_timer = 0.0; _stabilize_timer = 0.0
|
||||
func apply_velocity_boost(velocity: Vector2) -> void:
|
||||
for body: RigidBody2D in _bodies.values():
|
||||
if body != null and is_instance_valid(body): body.apply_central_impulse(velocity * body.mass)
|
||||
|
||||
func enter_ragdoll() -> bool:
|
||||
if _rig == null or _skeleton == null or _body_container == null:
|
||||
push_warning("StickmanRagdoll: cannot enter ragdoll; missing rig nodes.")
|
||||
return false
|
||||
_capture_pre_ragdoll_state()
|
||||
if _locomotion != null:
|
||||
_locomotion.clear_impact_reactions()
|
||||
_locomotion.set_collision_bodies_enabled(false)
|
||||
_locomotion.cancel_walk(false)
|
||||
if _anim_player != null and is_instance_valid(_anim_player):
|
||||
_anim_player.stop(true)
|
||||
if not build():
|
||||
return false
|
||||
_body_container.visible = false
|
||||
_body_container.modulate.a = 1.0
|
||||
if _skeleton.modification_stack != null:
|
||||
_skeleton.modification_stack.enabled = false
|
||||
reset_rest_state()
|
||||
return true
|
||||
|
||||
func request_recovery() -> bool:
|
||||
if _rig == null or _rig.state != StickmanRig.RigState.RAGDOLL:
|
||||
return false
|
||||
_capture_ragdoll_pose()
|
||||
destroy()
|
||||
_reanchor_root_to_landing()
|
||||
state_transition_requested.emit(StickmanRig.RigState.RECOVERING)
|
||||
_snap_skeleton_to_pose()
|
||||
if _locomotion != null:
|
||||
_locomotion.set_collision_bodies_enabled(true)
|
||||
_play_stand_up()
|
||||
return true
|
||||
|
||||
func cancel_recovery() -> void:
|
||||
_stand_up_generation += 1
|
||||
if _stand_up_tween != null and _stand_up_tween.is_valid():
|
||||
_stand_up_tween.kill()
|
||||
_stand_up_tween = null
|
||||
|
||||
func snap_to_standing(previous_state: int) -> void:
|
||||
if _locomotion != null:
|
||||
_locomotion.set_terrain_following_enabled(false)
|
||||
if previous_state == StickmanRig.RigState.RAGDOLL:
|
||||
destroy()
|
||||
elif previous_state == StickmanRig.RigState.RECOVERING:
|
||||
cancel_recovery()
|
||||
if _locomotion != null:
|
||||
_locomotion.clear_impact_reactions(true)
|
||||
_rig._hide_speech()
|
||||
# The factory captures the adapted skeleton's real authored spawn pose. That
|
||||
# is also the pose shown by the placement ghost, so every forced reset must
|
||||
# restore it instead of the legacy, one-size-fits-all STAND_POSE fallback.
|
||||
_restore_standing_markers(true)
|
||||
_rearm_ik_stack()
|
||||
_set_limb_mods_enabled(true)
|
||||
if previous_state == StickmanRig.RigState.ANIMATED:
|
||||
_rig._apply_head_lookat_mirror_mode()
|
||||
else:
|
||||
_restore_head_lookat_state()
|
||||
_recovery_bones.clear()
|
||||
reset_rest_state()
|
||||
_clear_recovery_capture()
|
||||
if _locomotion != null:
|
||||
_locomotion.set_collision_bodies_enabled(true)
|
||||
if _body_container != null:
|
||||
_body_container.visible = true
|
||||
_body_container.modulate.a = 1.0
|
||||
_rig._apply_idle_limb_solver_mode(_spawn_limb_rotations)
|
||||
if previous_state != StickmanRig.RigState.ANIMATED:
|
||||
state_transition_requested.emit(StickmanRig.RigState.ANIMATED)
|
||||
|
||||
func initialize_spawn_pose_from_skeleton() -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
var skeleton := _rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
|
||||
if skeleton == null:
|
||||
push_warning("StickmanRagdoll: cannot initialize spawn pose; missing Skeleton2D.")
|
||||
return
|
||||
var pose: Dictionary = {}
|
||||
var torso := skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
|
||||
if torso != null:
|
||||
pose["Torso"] = {"pos": _rig.to_local(torso.global_position), "rot": torso.rotation}
|
||||
var head := skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
||||
if head != null:
|
||||
var head_tip := head.to_global(Vector2(maxf(head.length, 1.0), 0.0).rotated(deg_to_rad(head.bone_angle)))
|
||||
pose["Head"] = {"pos": _rig.to_local(head_tip), "rot": 0.0}
|
||||
var endpoints: Dictionary = {
|
||||
"Left_Hand": "Torso/LeftUpperArm/LeftLowerArm", "Right_Hand": "Torso/RightUpperArm/RightLowerArm",
|
||||
"Left_Leg": "Torso/LeftUpperLeg/LeftLowerLeg", "Right_Leg": "Torso/RightUpperLeg/RightLowerLeg",
|
||||
}
|
||||
for marker_name: String in endpoints:
|
||||
var bone := skeleton.get_node_or_null(NodePath(String(endpoints[marker_name]))) as Bone2D
|
||||
if bone == null:
|
||||
return
|
||||
var endpoint := bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
|
||||
pose[marker_name] = {"pos": _rig.to_local(endpoint), "rot": 0.0}
|
||||
if pose.size() != IK_TARGET_PATHS.size():
|
||||
return
|
||||
for marker_name: String in pose:
|
||||
var marker := _rig.get_node_or_null(NodePath(String(IK_TARGET_PATHS[marker_name]))) as Marker2D
|
||||
if marker == null:
|
||||
return
|
||||
var target: Dictionary = pose[marker_name]
|
||||
marker.position = target.get("pos", marker.position)
|
||||
marker.rotation = target.get("rot", marker.rotation)
|
||||
if marker_name == "Left_Leg" or marker_name == "Right_Leg":
|
||||
var base_marker := _rig.get_node_or_null(NodePath("AnimationTargets/%s_Base" % marker_name)) as Marker2D
|
||||
if base_marker != null:
|
||||
base_marker.position = marker.position
|
||||
_spawn_pose = pose.duplicate(true)
|
||||
_spawn_limb_rotations.clear()
|
||||
for joint: String in LIMB_BONE_PATHS:
|
||||
var pair: Array = LIMB_BONE_PATHS[joint]
|
||||
var upper := skeleton.get_node_or_null(NodePath(String(pair[0]))) as Bone2D
|
||||
var lower := skeleton.get_node_or_null(NodePath(String(pair[1]))) as Bone2D
|
||||
if upper != null and lower != null:
|
||||
_spawn_limb_rotations[joint] = {"upper": upper.rotation, "lower": lower.rotation}
|
||||
|
||||
func get_spawn_limb_rotations() -> Dictionary:
|
||||
return _spawn_limb_rotations
|
||||
|
||||
func get_spawn_pose() -> Dictionary:
|
||||
return _spawn_pose.duplicate(true)
|
||||
|
||||
func get_captured_pose() -> Dictionary:
|
||||
return _captured_pose
|
||||
|
||||
func _capture_pre_ragdoll_state() -> void:
|
||||
_pre_ragdoll_state.clear()
|
||||
var markers: Dictionary = {}
|
||||
for marker_name: String in STAND_POSE:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
if marker != null:
|
||||
markers[marker_name] = {"pos": marker.position, "rot": marker.rotation}
|
||||
_pre_ragdoll_state["markers"] = markers
|
||||
var rotations: Dictionary = {}
|
||||
for joint: String in _limb_bones:
|
||||
var pair: Dictionary = _limb_bones[joint]
|
||||
var upper := pair.get("upper") as Bone2D
|
||||
var lower := pair.get("lower") as Bone2D
|
||||
rotations[joint] = {"upper": upper.rotation if upper != null else 0.0, "lower": lower.rotation if lower != null else 0.0}
|
||||
_pre_ragdoll_state["limb_rotations"] = rotations
|
||||
_pre_ragdoll_state["head_rotation"] = _head_bone.rotation if _head_bone != null else 0.0
|
||||
_pre_ragdoll_state["facing_profile"] = int(_rig.facing_profile)
|
||||
var bends: Dictionary = {}
|
||||
for joint: String in StickmanRig.BEND_JOINTS:
|
||||
bends[joint] = _rig.get_joint_bend_flipped(joint)
|
||||
_pre_ragdoll_state["bends"] = bends
|
||||
_pre_ragdoll_state["head_lookat_enabled"] = _head_look_at.enabled if _head_look_at != null else false
|
||||
var leg_sides: Dictionary = {}
|
||||
for leg_joint: String in ["LeftLeg", "RightLeg"]:
|
||||
var pair: Dictionary = _limb_bones.get(leg_joint, {})
|
||||
var upper := pair.get("upper") as Bone2D
|
||||
var lower := pair.get("lower") as Bone2D
|
||||
var marker := _get_ik_marker("Left_Leg" if leg_joint == "LeftLeg" else "Right_Leg")
|
||||
if upper == null or lower == null or marker == null:
|
||||
continue
|
||||
var side := (marker.global_position - upper.global_position).cross(lower.global_position - upper.global_position)
|
||||
leg_sides[leg_joint] = {"side": 1.0 if side >= 0.0 else -1.0, "flag": _rig.get_joint_bend_flipped(leg_joint)}
|
||||
_pre_ragdoll_state["leg_sides"] = leg_sides
|
||||
var torso_entry_rot := 0.0
|
||||
var torso_marker := _get_ik_marker("Torso")
|
||||
if torso_marker != null:
|
||||
torso_entry_rot = torso_marker.rotation
|
||||
var spine_entry_rel := 0.0
|
||||
if _head_bone != null and _torso_bone != null:
|
||||
spine_entry_rel = (_head_bone.global_position - _torso_bone.global_position).angle() - _rig.global_rotation
|
||||
_pre_ragdoll_state["torso_chain_offset"] = _chain_angle_from_captured(spine_entry_rel) - torso_entry_rot
|
||||
var head_entry_chain := torso_entry_rot + float(_pre_ragdoll_state.get("head_rotation", 0.0))
|
||||
_pre_ragdoll_state["head_chain_offset"] = _chain_angle_from_captured(-_rig.global_rotation) - head_entry_chain
|
||||
|
||||
func _capture_ragdoll_pose() -> void:
|
||||
_captured_pose.clear()
|
||||
_captured_landing_center = Vector2.ZERO
|
||||
_captured_ground_y = 0.0
|
||||
var top_y := INF
|
||||
var bottom_y := -INF
|
||||
var exclude_rids: Array[RID] = []
|
||||
for key: String in _bodies:
|
||||
var body := _bodies[key] as RigidBody2D
|
||||
if body == null or not is_instance_valid(body):
|
||||
continue
|
||||
var half := float(body.get_meta("half_height", 0.0))
|
||||
var radius := float(body.get_meta("radius", 0.0))
|
||||
var extent := half + radius
|
||||
top_y = minf(top_y, body.global_position.y - extent)
|
||||
bottom_y = maxf(bottom_y, body.global_position.y + extent)
|
||||
exclude_rids.append(body.get_rid())
|
||||
_captured_pose[key] = {"pos": _rig.to_local(body.global_position), "rot": body.global_rotation - _rig.global_rotation, "half": half}
|
||||
var torso := _bodies.get("torso") as RigidBody2D
|
||||
if torso != null and is_instance_valid(torso):
|
||||
_captured_landing_center = torso.global_position
|
||||
_captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids)
|
||||
if is_equal_approx(_captured_ground_y, bottom_y):
|
||||
_captured_ground_y = torso.global_position.y + float(torso.get_meta("radius", 0.0))
|
||||
_probe_top_y = top_y
|
||||
_probe_bottom_y = bottom_y
|
||||
_probe_exclude = exclude_rids
|
||||
|
||||
func _probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float:
|
||||
if _locomotion != null:
|
||||
return _locomotion.probe_ground_y(world_x, top_y, bottom_y, exclude)
|
||||
var best_y := bottom_y
|
||||
var space_state := _rig.get_world_2d().direct_space_state
|
||||
if space_state == null:
|
||||
return best_y
|
||||
var best_dist := INF
|
||||
for offset: float in GROUND_PROBE_X_OFFSETS:
|
||||
var query := PhysicsRayQueryParameters2D.create(Vector2(world_x + offset, top_y - GROUND_PROBE_START_OFFSET), Vector2(world_x + offset, bottom_y + GROUND_PROBE_END_OFFSET))
|
||||
query.collision_mask = 1
|
||||
query.exclude = exclude
|
||||
var hit := space_state.intersect_ray(query)
|
||||
if hit.is_empty():
|
||||
continue
|
||||
var y := (hit.get("position", Vector2.ZERO) as Vector2).y
|
||||
if y < top_y - GROUND_PROBE_TOLERANCE or y > bottom_y + GROUND_PROBE_TOLERANCE:
|
||||
continue
|
||||
var distance := absf(y - bottom_y)
|
||||
if distance < best_dist:
|
||||
best_dist = distance
|
||||
best_y = y
|
||||
return best_y
|
||||
|
||||
func _reanchor_root_to_landing() -> void:
|
||||
if not _captured_pose.has("torso"):
|
||||
return
|
||||
var feet_world := Vector2(_captured_landing_center.x, _captured_ground_y)
|
||||
var old_root := _rig.global_position
|
||||
var foot_offset := _locomotion.get_foot_offset() if _locomotion != null else Vector2(0.0, -385.0)
|
||||
_rig.global_position = feet_world + foot_offset
|
||||
var shift := old_root - _rig.global_position
|
||||
var local_shift := _rig.to_local(shift) - _rig.to_local(Vector2.ZERO)
|
||||
for key: String in _captured_pose:
|
||||
var entry: Dictionary = _captured_pose[key]
|
||||
entry["pos"] = entry.get("pos", Vector2.ZERO) + local_shift
|
||||
|
||||
func _clear_recovery_capture() -> void:
|
||||
_captured_pose.clear()
|
||||
_captured_landing_center = Vector2.ZERO
|
||||
_captured_ground_y = 0.0
|
||||
_pre_ragdoll_state.clear()
|
||||
_probe_top_y = 0.0
|
||||
_probe_bottom_y = 0.0
|
||||
_probe_exclude.clear()
|
||||
|
||||
func _chain_angle_from_captured(captured_rel: float) -> float:
|
||||
return -captured_rel if _rig.facing_profile == StickmanRig.FacingProfile.LEFT else captured_rel
|
||||
|
||||
func _wrap_toward(value: float, target: float) -> float:
|
||||
return target + wrapf(value - target, -PI, PI)
|
||||
|
||||
func _natural_joint_target(current: float, target: float, offset: float) -> float:
|
||||
var a := wrapf(current + offset, -PI, PI)
|
||||
var b := wrapf(target + offset, -PI, PI)
|
||||
var delta := wrapf(b - a, -PI, PI)
|
||||
if a * b < 0.0 and absf(a) + absf(b) > PI:
|
||||
delta += TAU if delta < 0.0 else -TAU
|
||||
return current + delta
|
||||
|
||||
func _set_limb_mods_enabled(enabled: bool) -> void:
|
||||
for joint: String in StickmanRig.BEND_JOINTS:
|
||||
var modification := _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
|
||||
if modification != null and is_instance_valid(modification):
|
||||
modification.enabled = enabled
|
||||
|
||||
func _restore_head_lookat_state() -> void:
|
||||
if _head_look_at != null and is_instance_valid(_head_look_at):
|
||||
_head_look_at.enabled = bool(_pre_ragdoll_state.get("head_lookat_enabled", false))
|
||||
|
||||
func _rearm_ik_stack() -> void:
|
||||
if _skeleton == null or not is_instance_valid(_skeleton):
|
||||
return
|
||||
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
||||
if stack != null:
|
||||
if not stack.get_is_setup():
|
||||
stack.setup()
|
||||
stack.enabled = true
|
||||
_skeleton.set_process_internal(true)
|
||||
var torso_marker := _get_ik_marker("Torso")
|
||||
if torso_marker != null:
|
||||
var driver := torso_marker.get_node_or_null("RemoteTransform2D") as RemoteTransform2D
|
||||
if driver != null:
|
||||
driver.update_position = true
|
||||
driver.update_rotation = true
|
||||
driver.update_scale = true
|
||||
|
||||
func _restore_standing_markers(use_spawn_pose: bool = true) -> void:
|
||||
var pose := _spawn_pose if use_spawn_pose and not _spawn_pose.is_empty() else STAND_POSE
|
||||
for marker_name: String in pose:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
if marker == null:
|
||||
continue
|
||||
var target: Dictionary = pose[marker_name]
|
||||
marker.position = target.get("pos", marker.position)
|
||||
if marker_name == "Torso":
|
||||
marker.rotation = target.get("rot", marker.rotation)
|
||||
|
||||
func _get_ik_marker(marker_name: String) -> Marker2D:
|
||||
if _rig == null:
|
||||
return null
|
||||
var path := String(IK_TARGET_PATHS.get(marker_name, ""))
|
||||
return _rig.get_node_or_null(NodePath(path)) as Marker2D if not path.is_empty() else null
|
||||
|
||||
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
var pose: Dictionary = _captured_pose.get(body_key, {})
|
||||
if marker == null or pose.is_empty():
|
||||
return
|
||||
var direction := Vector2.from_angle(float(pose.get("rot", 0.0)) + _rig.global_rotation)
|
||||
var center_world := _rig.to_global(pose.get("pos", Vector2.ZERO))
|
||||
marker.position = _rig.to_local(center_world + direction * float(pose.get("half", 0.0)))
|
||||
|
||||
func _snap_skeleton_to_pose() -> void:
|
||||
_recovery_bones.clear()
|
||||
var pre_markers: Dictionary = _pre_ragdoll_state.get("markers", {})
|
||||
var pre_limb_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
|
||||
var pre_head_rotation := float(_pre_ragdoll_state.get("head_rotation", 0.0))
|
||||
var torso_chain_offset := float(_pre_ragdoll_state.get("torso_chain_offset", 0.0))
|
||||
var head_chain_offset := float(_pre_ragdoll_state.get("head_chain_offset", 0.0))
|
||||
var theta_t := 0.0
|
||||
var torso_marker := _get_ik_marker("Torso")
|
||||
if torso_marker != null:
|
||||
var torso_pose: Dictionary = _captured_pose.get("torso", {})
|
||||
if not torso_pose.is_empty():
|
||||
var torso_rel := float(torso_pose.get("rot", 0.0))
|
||||
var spine_dir := Vector2.from_angle(torso_rel + _rig.global_rotation)
|
||||
var center_world := _rig.to_global(torso_pose.get("pos", Vector2.ZERO))
|
||||
torso_marker.position = _rig.to_local(center_world - spine_dir * float(torso_pose.get("half", 0.0)))
|
||||
var raw_rotation := _chain_angle_from_captured(torso_rel) - torso_chain_offset
|
||||
var pre_torso_rotation := float((pre_markers.get("Torso", {}) as Dictionary).get("rot", raw_rotation))
|
||||
theta_t = wrapf(_wrap_toward(raw_rotation, pre_torso_rotation), -PI, PI)
|
||||
torso_marker.rotation = theta_t
|
||||
var head_marker := _get_ik_marker("Head")
|
||||
var head_pose: Dictionary = _captured_pose.get("head", {})
|
||||
if head_marker != null and not head_pose.is_empty():
|
||||
head_marker.position = head_pose.get("pos", head_marker.position)
|
||||
_set_marker_from_body("Left_Hand", "left_lower_arm")
|
||||
_set_marker_from_body("Right_Hand", "right_lower_arm")
|
||||
_set_marker_from_body("Left_Leg", "left_lower_leg")
|
||||
_set_marker_from_body("Right_Leg", "right_lower_leg")
|
||||
for marker_name: String in ["Left_Leg", "Right_Leg"]:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
var body_key := "left_lower_leg" if marker_name == "Left_Leg" else "right_lower_leg"
|
||||
if marker == null or (_captured_pose.get(body_key, {}) as Dictionary).is_empty():
|
||||
continue
|
||||
var ankle_world := _rig.to_global(marker.position)
|
||||
var ground_y := _probe_ground_y(ankle_world.x, _probe_top_y, _probe_bottom_y, _probe_exclude)
|
||||
if ankle_world.y < ground_y:
|
||||
ankle_world.y = ground_y
|
||||
marker.position = _rig.to_local(ankle_world)
|
||||
if not (_captured_pose.get("torso", {}) as Dictionary).is_empty():
|
||||
for joint: String in LIMB_BONE_PATHS:
|
||||
var pair: Dictionary = _limb_bones.get(joint, {})
|
||||
var upper := pair.get("upper") as Bone2D
|
||||
var lower := pair.get("lower") as Bone2D
|
||||
if upper == null or lower == null:
|
||||
continue
|
||||
var keys: Array = LIMB_BODY_KEYS[joint]
|
||||
var upper_pose: Dictionary = _captured_pose.get(keys[0], {})
|
||||
var lower_pose: Dictionary = _captured_pose.get(keys[1], {})
|
||||
if upper_pose.is_empty() or lower_pose.is_empty():
|
||||
continue
|
||||
var upper_chain := _chain_angle_from_captured(float(upper_pose.get("rot", 0.0)))
|
||||
var lower_chain := _chain_angle_from_captured(float(lower_pose.get("rot", 0.0)))
|
||||
var pre_pair: Dictionary = pre_limb_rotations.get(joint, {})
|
||||
upper.rotation = _wrap_toward(upper_chain - theta_t - deg_to_rad(upper.bone_angle), float(pre_pair.get("upper", upper.rotation)))
|
||||
lower.rotation = _wrap_toward(lower_chain - upper_chain + deg_to_rad(upper.bone_angle) - deg_to_rad(lower.bone_angle), float(pre_pair.get("lower", lower.rotation)))
|
||||
upper.rotation = wrapf(upper.rotation, -PI, PI)
|
||||
lower.rotation = wrapf(lower.rotation, -PI, PI)
|
||||
if joint == "LeftLeg" or joint == "RightLeg":
|
||||
continue
|
||||
var target_upper := upper.rotation + wrapf(float(pre_pair.get("upper", upper.rotation)) - upper.rotation, -PI, PI)
|
||||
var pre_lower := float(pre_pair.get("lower", lower.rotation))
|
||||
var path_override := String((RECOVERY_JOINT_PATH.get(_rig.facing_profile, {}) as Dictionary).get(joint, ""))
|
||||
var target_lower := lower.rotation + wrapf(pre_lower - lower.rotation, -PI, PI) if path_override == "shortest" else _natural_joint_target(lower.rotation, pre_lower, deg_to_rad(lower.bone_angle - upper.bone_angle))
|
||||
_recovery_bones.append({"bone": upper, "target": target_upper})
|
||||
_recovery_bones.append({"bone": lower, "target": target_lower})
|
||||
if not head_pose.is_empty() and _head_bone != null:
|
||||
var raw_head := _chain_angle_from_captured(float(head_pose.get("rot", 0.0))) - head_chain_offset - theta_t
|
||||
_head_bone.rotation = wrapf(_wrap_toward(raw_head, pre_head_rotation), -PI, PI)
|
||||
_recovery_bones.append({"bone": _head_bone, "target": _head_bone.rotation + wrapf(pre_head_rotation - _head_bone.rotation, -PI, PI)})
|
||||
_set_limb_mods_enabled(false)
|
||||
if _head_look_at != null:
|
||||
_head_look_at.enabled = false
|
||||
_rearm_ik_stack()
|
||||
for leg_joint: String in ["LeftLeg", "RightLeg"]:
|
||||
var modification := _bend_modifications.get(leg_joint) as SkeletonModification2DTwoBoneIK
|
||||
if modification == null:
|
||||
continue
|
||||
var entry: Dictionary = (_pre_ragdoll_state.get("leg_sides", {}) as Dictionary).get(leg_joint, {})
|
||||
var forced: Dictionary = RECOVERY_LEG_SIDE_FORCE.get(_rig.facing_profile, {})
|
||||
if forced.has(leg_joint):
|
||||
modification.flip_bend_direction = bool(forced[leg_joint])
|
||||
elif not entry.is_empty():
|
||||
var upper_key := "left_upper_leg" if leg_joint == "LeftLeg" else "right_upper_leg"
|
||||
var lower_key := "left_lower_leg" if leg_joint == "LeftLeg" else "right_lower_leg"
|
||||
var up_pose: Dictionary = _captured_pose.get(upper_key, {})
|
||||
var low_pose: Dictionary = _captured_pose.get(lower_key, {})
|
||||
if not up_pose.is_empty() and not low_pose.is_empty():
|
||||
var dir_u := Vector2.from_angle(float(up_pose.get("rot", 0.0)) + _rig.global_rotation)
|
||||
var dir_l := Vector2.from_angle(float(low_pose.get("rot", 0.0)) + _rig.global_rotation)
|
||||
var center_u := _rig.to_global(up_pose.get("pos", Vector2.ZERO))
|
||||
var hip := center_u - dir_u * float(up_pose.get("half", 0.0))
|
||||
var knee := center_u + dir_u * float(up_pose.get("half", 0.0))
|
||||
var ankle := _rig.to_global(low_pose.get("pos", Vector2.ZERO)) + dir_l * float(low_pose.get("half", 0.0))
|
||||
var captured_side := 1.0 if (ankle - hip).cross(knee - hip) >= 0.0 else -1.0
|
||||
modification.flip_bend_direction = bool(entry.get("flag", false)) if captured_side == float(entry.get("side", captured_side)) else not bool(entry.get("flag", false))
|
||||
modification.enabled = true
|
||||
if _body_container != null:
|
||||
_body_container.visible = true
|
||||
_body_container.modulate.a = 1.0
|
||||
|
||||
func _play_stand_up() -> void:
|
||||
_stand_up_generation += 1
|
||||
var generation := _stand_up_generation
|
||||
var target_markers: Dictionary = _pre_ragdoll_state.get("markers", STAND_POSE)
|
||||
_stand_up_tween = _tween_markers_to(target_markers, STAND_UP_DURATION)
|
||||
for entry: Dictionary in _recovery_bones:
|
||||
var bone := entry.get("bone") as Bone2D
|
||||
if bone != null:
|
||||
_stand_up_tween.tween_property(bone, "rotation", float(entry.get("target", bone.rotation)), STAND_UP_DURATION)
|
||||
_stand_up_tween.finished.connect(_on_stand_up_finished.bind(generation))
|
||||
|
||||
func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
|
||||
var tween := create_tween()
|
||||
tween.set_parallel(true).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||
for marker_name: String in target_pose:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
if marker == null:
|
||||
continue
|
||||
var target: Dictionary = target_pose[marker_name]
|
||||
tween.tween_property(marker, "position", target.get("pos", marker.position), duration)
|
||||
if marker_name == "Torso":
|
||||
tween.tween_property(marker, "rotation", target.get("rot", marker.rotation), duration)
|
||||
return tween
|
||||
|
||||
func _on_stand_up_finished(generation: int) -> void:
|
||||
if generation != _stand_up_generation or _rig.state != StickmanRig.RigState.RECOVERING:
|
||||
return
|
||||
var recovered_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
|
||||
_rig.set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(_rig.facing_profile))))
|
||||
var bends: Dictionary = _pre_ragdoll_state.get("bends", {})
|
||||
for joint: String in StickmanRig.BEND_JOINTS:
|
||||
if bends.has(joint):
|
||||
_rig.set_joint_bend_flipped(joint, bool(bends[joint]))
|
||||
_set_limb_mods_enabled(true)
|
||||
_restore_head_lookat_state()
|
||||
_recovery_bones.clear()
|
||||
_rearm_ik_stack()
|
||||
if _body_container != null:
|
||||
_body_container.visible = true
|
||||
_body_container.modulate.a = 1.0
|
||||
if _locomotion != null:
|
||||
_locomotion.set_collision_bodies_enabled(true)
|
||||
state_transition_requested.emit(StickmanRig.RigState.ANIMATED)
|
||||
_rig._apply_idle_limb_solver_mode(recovered_rotations)
|
||||
|
||||
func _build_body(entry: Dictionary) -> void:
|
||||
var key: String = str(entry["key"]); var body := RigidBody2D.new(); body.name = "Ragdoll_" + key
|
||||
body.mass = float(entry["mass"]); body.linear_damp = float(entry["linear_damp"]); body.angular_damp = float(entry["angular_damp"]); body.collision_layer = 1; body.collision_mask = 1
|
||||
body.set_meta("stickman_rig", _rig); body.set_meta("part_name", key); body.set_meta("stickman_ragdoll_body", true)
|
||||
var shape := CollisionShape2D.new()
|
||||
if str(entry["kind"]) == "visual":
|
||||
var visual := _rig.get_node_or_null(NodePath(str(entry["node_path"]))) as Node2D
|
||||
if visual == null: return
|
||||
var circle := CircleShape2D.new(); circle.radius = float(entry["radius"]); shape.shape = circle; body.add_child(shape); body.position = visual.global_position; body.set_meta("half_height", 0.0); body.set_meta("radius", float(entry["radius"]))
|
||||
if not _clone_visuals(body, str(entry["visual_path"])): _add_circle(body, float(entry["radius"]))
|
||||
else:
|
||||
var bone := _skeleton.get_node_or_null(NodePath(str(entry["node_path"]))) as Bone2D
|
||||
if bone == null: return
|
||||
var origin: Vector2 = bone.global_position; var tip: Vector2
|
||||
if key == "torso":
|
||||
var head := _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
||||
if head == null: return
|
||||
tip = head.global_position
|
||||
else: tip = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
|
||||
var length: float = origin.distance_to(tip); var capsule := CapsuleShape2D.new(); capsule.height = length; capsule.radius = float(entry["radius"]); shape.shape = capsule; shape.rotation = -PI * 0.5; body.add_child(shape); body.position = (origin + tip) * 0.5; body.rotation = (tip - origin).angle(); body.set_meta("half_height", length * 0.5); body.set_meta("radius", float(entry["radius"]))
|
||||
if not _clone_visuals(body, str(entry["visual_path"])): _add_capsule(body, length, float(entry["radius"]))
|
||||
_root.add_child(body); _bodies[key] = body
|
||||
|
||||
func _build_joint(entry: Dictionary) -> void:
|
||||
var child := _bodies.get(str(entry["child"])) as RigidBody2D; var parent := _bodies.get(str(entry["parent"])) as RigidBody2D; var bone := _skeleton.get_node_or_null(NodePath(str(entry["pin"]))) as Bone2D
|
||||
if child == null or parent == null or bone == null: return
|
||||
var pin := PinJoint2D.new(); pin.name = "RagdollPin_" + str(entry["child"]); pin.position = bone.global_position; _root.add_child(pin); pin.node_a = pin.get_path_to(parent); pin.node_b = pin.get_path_to(child); pin.softness = TARGET_SOFTNESS
|
||||
_apply_joint_limits(pin, str(entry["limit"]), str(entry["child"]))
|
||||
|
||||
func _apply_joint_limits(pin: PinJoint2D, limit: String, child_key: String) -> void:
|
||||
if limit == "shoulder":
|
||||
pin.angular_limit_enabled = true; pin.angular_limit_lower = -deg_to_rad(160.0); pin.angular_limit_upper = deg_to_rad(160.0)
|
||||
elif limit != "neck":
|
||||
var profile: int = int(_rig.facing_profile) if _rig != null else StickmanRig.FacingProfile.FORWARD
|
||||
var band: Dictionary = JOINT_BAND_LIMITS.get(profile, {}).get(child_key, {})
|
||||
if band.is_empty(): band = { "lower": -5.0 if limit == "cw" else -150.0, "upper": 150.0 if limit == "cw" else 5.0 }
|
||||
pin.angular_limit_enabled = true; pin.angular_limit_lower = deg_to_rad(float(band["lower"])); pin.angular_limit_upper = deg_to_rad(float(band["upper"]))
|
||||
else: pin.angular_limit_enabled = false
|
||||
|
||||
func _clone_visuals(body: RigidBody2D, path: String) -> bool:
|
||||
var source := _rig.get_node_or_null(NodePath(path)) as Node2D
|
||||
if source == null: return false
|
||||
var count: int = 0
|
||||
for child: Node in source.get_children():
|
||||
if child is Line2D:
|
||||
var src := child as Line2D; var copy := Line2D.new(); var points := PackedVector2Array()
|
||||
for point: Vector2 in src.points: points.append(body.to_local(src.to_global(point)))
|
||||
copy.points = points; copy.width = src.width; copy.default_color = src.default_color; copy.begin_cap_mode = src.begin_cap_mode; copy.end_cap_mode = src.end_cap_mode; copy.joint_mode = src.joint_mode; copy.closed = src.closed; body.add_child(copy); count += 1
|
||||
elif child is Polygon2D:
|
||||
var src := child as Polygon2D; var copy := Polygon2D.new(); var points := PackedVector2Array()
|
||||
for point: Vector2 in src.polygon: points.append(body.to_local(src.to_global(point)))
|
||||
copy.polygon = points; copy.color = src.color; body.add_child(copy); count += 1
|
||||
return count > 0
|
||||
|
||||
func _add_capsule(body: RigidBody2D, length: float, radius: float) -> void:
|
||||
var line := Line2D.new(); line.points = PackedVector2Array([Vector2(-length * 0.5, 0.0), Vector2(length * 0.5, 0.0)]); line.width = radius * 2.0; line.default_color = VISUAL_COLOR; line.begin_cap_mode = Line2D.LINE_CAP_ROUND; line.end_cap_mode = Line2D.LINE_CAP_ROUND; body.add_child(line)
|
||||
func _add_circle(body: RigidBody2D, radius: float) -> void:
|
||||
var polygon := Polygon2D.new(); var points := PackedVector2Array()
|
||||
for index: int in CIRCLE_SEGMENTS: points.append(Vector2.from_angle(TAU * float(index) / float(CIRCLE_SEGMENTS)) * radius)
|
||||
polygon.polygon = points; polygon.color = Color.WHITE; body.add_child(polygon)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bihf4kx2qgr1o
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://b4rsae8suaxj7
|
||||
@@ -0,0 +1,557 @@
|
||||
class_name StkRigAdapter
|
||||
extends RefCounted
|
||||
## StkRigAdapter - Standalone runtime adapter (Phase 8).
|
||||
##
|
||||
## Fits an instantiated scenes/characters/stickman/stickman_rig.tscn to a loaded .stk dictionary: re-fits
|
||||
## the skeleton bone lengths, recalibrates the IK targets, and mounts the
|
||||
## .stk vector shapes onto the rig's Body/ visual nodes in the rig's "hanging"
|
||||
## convention (joint end at the local origin, far end along +Y). The
|
||||
## RemoteTransform2D drivers keep `update_rotation = true`, so each mounted
|
||||
## part rotates to follow its bone in every pose (IK flexing included).
|
||||
## Consumed by a future runtime pipeline, never referenced by the editor.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_LINE_WIDTH := 2.0
|
||||
const ELBOW_REST_Y := -256.0
|
||||
|
||||
## Head chin drop (px): the editor's pose guide draws the head as a circle of
|
||||
## radius 100 centered at the Head joint (0, -463.5), so its bottom sits at
|
||||
## -363.5; the neck (Head bone origin) is at -391.5. The mounted head's chin
|
||||
## (its local origin) therefore drops 28 px below the neck so the head overlaps
|
||||
## the torso the same way the guide circle does.
|
||||
const HEAD_CHIN_DROP := 28.0
|
||||
|
||||
const DEFAULT_PROPORTIONS: Dictionary = {
|
||||
"upper_arm_length": 168.0,
|
||||
"lower_arm_length": 200.0,
|
||||
"upper_leg_length": 200.0,
|
||||
"lower_leg_length": 200.0,
|
||||
"torso_length": 391.5,
|
||||
}
|
||||
|
||||
const PART_KEYS: PackedStringArray = [
|
||||
"head", "torso",
|
||||
"left_upper_arm", "left_lower_arm",
|
||||
"right_upper_arm", "right_lower_arm",
|
||||
"left_upper_leg", "left_lower_leg",
|
||||
"right_upper_leg", "right_lower_leg",
|
||||
]
|
||||
|
||||
## Bone node paths (relative to rig root), keyed by part name.
|
||||
const BONE_PATHS: Dictionary = {
|
||||
"left_upper_arm": "Skeleton2D/Torso/LeftUpperArm",
|
||||
"left_lower_arm": "Skeleton2D/Torso/LeftUpperArm/LeftLowerArm",
|
||||
"right_upper_arm": "Skeleton2D/Torso/RightUpperArm",
|
||||
"right_lower_arm": "Skeleton2D/Torso/RightUpperArm/RightLowerArm",
|
||||
"left_upper_leg": "Skeleton2D/Torso/LeftUpperLeg",
|
||||
"left_lower_leg": "Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg",
|
||||
"right_upper_leg": "Skeleton2D/Torso/RightUpperLeg",
|
||||
"right_lower_leg": "Skeleton2D/Torso/RightUpperLeg/RightLowerLeg",
|
||||
}
|
||||
|
||||
## Body visual node paths (relative to rig root), keyed by part name.
|
||||
const BODY_PATHS: Dictionary = {
|
||||
"head": "Body/Head",
|
||||
"torso": "Body/Body",
|
||||
"left_upper_arm": "Body/LeftUpperArm",
|
||||
"left_lower_arm": "Body/LeftLowerArm",
|
||||
"right_upper_arm": "Body/RightUpperArm",
|
||||
"right_lower_arm": "Body/RightLowerArm",
|
||||
"left_upper_leg": "Body/LeftUpperLeg",
|
||||
"left_lower_leg": "Body/LeftLowerLeg",
|
||||
"right_upper_leg": "Body/RightUpperLeg",
|
||||
"right_lower_leg": "Body/RightLowerLeg",
|
||||
}
|
||||
|
||||
## RemoteTransform2D driver node paths (relative to rig root), keyed by part
|
||||
## name. Each driver pushes the matching Body/* node's global transform from
|
||||
## its bone; its `global_rotation` is the bone frame used (Phase 9 Round 5) to
|
||||
## convert the master-space guide_offset into a bone-relative placement.
|
||||
const DRIVER_PATHS: Dictionary = {
|
||||
"head": "Skeleton2D/Torso/Head/Pivot/RemoteTransform2D",
|
||||
"torso": "Skeleton2D/Torso/RemoteTransform2D",
|
||||
"left_upper_arm": "Skeleton2D/Torso/LeftUpperArm/RemoteTransform2D",
|
||||
"left_lower_arm": "Skeleton2D/Torso/LeftUpperArm/LeftLowerArm/RemoteTransform2D",
|
||||
"right_upper_arm": "Skeleton2D/Torso/RightUpperArm/RemoteTransform2D",
|
||||
"right_lower_arm": "Skeleton2D/Torso/RightUpperArm/RightLowerArm/RemoteTransform2D",
|
||||
"left_upper_leg": "Skeleton2D/Torso/LeftUpperLeg/RemoteTransform2D",
|
||||
"left_lower_leg": "Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg/RemoteTransform2D",
|
||||
"right_upper_leg": "Skeleton2D/Torso/RightUpperLeg/RemoteTransform2D",
|
||||
"right_lower_leg": "Skeleton2D/Torso/RightUpperLeg/RightLowerLeg/RemoteTransform2D",
|
||||
}
|
||||
|
||||
const IK_LEFT_HAND := "IK_Targets/Left_Hand"
|
||||
const IK_RIGHT_HAND := "IK_Targets/Right_Hand"
|
||||
const IK_LEFT_LEG := "IK_Targets/Left_Leg"
|
||||
const IK_RIGHT_LEG := "IK_Targets/Right_Leg"
|
||||
|
||||
const HEAD_BONE_PATH := "Skeleton2D/Torso/Head"
|
||||
const HEAD_DRIVER_PATH := "Skeleton2D/Torso/Head/Pivot/RemoteTransform2D"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func apply(stk_data: Dictionary, rig: Node2D) -> void:
|
||||
_fit_bones(stk_data, rig)
|
||||
_recalibrate_ik(stk_data, rig)
|
||||
_mount_shapes(stk_data, rig)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proportions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func _get_proportions(stk_data: Dictionary) -> Dictionary:
|
||||
var raw: Variant = stk_data.get("proportions", {})
|
||||
if raw is Dictionary:
|
||||
return raw as Dictionary
|
||||
return {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bone fitting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void:
|
||||
var proportions := _get_proportions(stk_data)
|
||||
var ua := float(proportions.get("upper_arm_length", DEFAULT_PROPORTIONS["upper_arm_length"]))
|
||||
var la := float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"]))
|
||||
var ul := float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"]))
|
||||
var ll := float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"]))
|
||||
var torso := float(proportions.get("torso_length", DEFAULT_PROPORTIONS["torso_length"]))
|
||||
|
||||
# Arms — upper length + lower-bone origin on X.
|
||||
_set_prop(rig, BONE_PATHS["left_upper_arm"], "length", ua)
|
||||
_set_prop(rig, BONE_PATHS["left_lower_arm"], "position", Vector2(-ua, 0.0))
|
||||
_set_prop(rig, BONE_PATHS["left_lower_arm"], "length", la)
|
||||
_set_prop(rig, BONE_PATHS["right_upper_arm"], "length", ua)
|
||||
_set_prop(rig, BONE_PATHS["right_lower_arm"], "position", Vector2(ua, 0.0))
|
||||
_set_prop(rig, BONE_PATHS["right_lower_arm"], "length", la)
|
||||
|
||||
# Legs — upper length + lower-bone origin on Y.
|
||||
_set_prop(rig, BONE_PATHS["left_upper_leg"], "length", ul)
|
||||
_set_prop(rig, BONE_PATHS["left_lower_leg"], "position", Vector2(0.0, ul))
|
||||
_set_prop(rig, BONE_PATHS["left_lower_leg"], "length", ll)
|
||||
_set_prop(rig, BONE_PATHS["right_upper_leg"], "length", ul)
|
||||
_set_prop(rig, BONE_PATHS["right_lower_leg"], "position", Vector2(0.0, ul))
|
||||
_set_prop(rig, BONE_PATHS["right_lower_leg"], "length", ll)
|
||||
|
||||
# Head — position the head bone at the top of the torso (preserve the
|
||||
# existing x so the rig's ~ -0.1288 x-offset is retained).
|
||||
var head_bone := rig.get_node_or_null(NodePath(HEAD_BONE_PATH))
|
||||
if head_bone is Node2D:
|
||||
_set_prop(rig, HEAD_BONE_PATH, "position", Vector2((head_bone as Node2D).position.x, -torso))
|
||||
|
||||
# Head driver — zero the RemoteTransform2D local position so Body/Head sits
|
||||
# on the neck joint (the authored offset centered the old 100-px circle
|
||||
# 72 px above the neck; the mounted head's chin is its local origin).
|
||||
_set_prop(rig, HEAD_DRIVER_PATH, "position", Vector2.ZERO)
|
||||
|
||||
|
||||
static func _set_prop(rig: Node2D, path: String, prop: String, value: Variant) -> void:
|
||||
var node := rig.get_node_or_null(NodePath(path))
|
||||
if node == null:
|
||||
push_warning("StkRigAdapter: missing node '%s'; skipped '%s'." % [path, prop])
|
||||
return
|
||||
node.set(prop, value)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IK target recalibration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func _recalibrate_ik(stk_data: Dictionary, rig: Node2D) -> void:
|
||||
var proportions := _get_proportions(stk_data)
|
||||
var ua := float(proportions.get("upper_arm_length", DEFAULT_PROPORTIONS["upper_arm_length"]))
|
||||
var la := float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"]))
|
||||
var ul := float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"]))
|
||||
var ll := float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"]))
|
||||
|
||||
var left_leg := rig.get_node_or_null(NodePath(IK_LEFT_LEG)) as Node2D
|
||||
if left_leg != null:
|
||||
left_leg.position = Vector2(left_leg.position.x, ul + ll)
|
||||
else:
|
||||
push_warning("StkRigAdapter: missing IK target '%s'." % IK_LEFT_LEG)
|
||||
|
||||
var right_leg := rig.get_node_or_null(NodePath(IK_RIGHT_LEG)) as Node2D
|
||||
if right_leg != null:
|
||||
right_leg.position = Vector2(right_leg.position.x, ul + ll)
|
||||
else:
|
||||
push_warning("StkRigAdapter: missing IK target '%s'." % IK_RIGHT_LEG)
|
||||
|
||||
var left_hand := rig.get_node_or_null(NodePath(IK_LEFT_HAND)) as Node2D
|
||||
if left_hand != null:
|
||||
left_hand.position = Vector2(-ua, ELBOW_REST_Y - la)
|
||||
else:
|
||||
push_warning("StkRigAdapter: missing IK target '%s'." % IK_LEFT_HAND)
|
||||
|
||||
var right_hand := rig.get_node_or_null(NodePath(IK_RIGHT_HAND)) as Node2D
|
||||
if right_hand != null:
|
||||
right_hand.position = Vector2(ua, ELBOW_REST_Y - la)
|
||||
else:
|
||||
push_warning("StkRigAdapter: missing IK target '%s'." % IK_RIGHT_HAND)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Visual shape mount
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void:
|
||||
var body_parts_var: Variant = stk_data.get("body_parts", {})
|
||||
if not body_parts_var is Dictionary:
|
||||
push_warning("StkRigAdapter: 'body_parts' missing or invalid; no shapes mounted.")
|
||||
return
|
||||
var body_parts: Dictionary = body_parts_var as Dictionary
|
||||
var proportions := _get_proportions(stk_data)
|
||||
|
||||
for part_name: String in PART_KEYS:
|
||||
var visual := rig.get_node_or_null(NodePath(BODY_PATHS[part_name]))
|
||||
if visual == null:
|
||||
push_warning("StkRigAdapter: missing Body node for part '%s'; skipped." % part_name)
|
||||
continue
|
||||
|
||||
# Reset the node's authored scale/rotation so the mount math starts from
|
||||
# a unit-scaled, unrotated frame (position is owned by the
|
||||
# RemoteTransform2D driver and is left untouched; the driver overwrites
|
||||
# scale/rotation each frame anyway).
|
||||
_reset_node_transform(visual)
|
||||
|
||||
# Phase 9: the Body/Head node is a plain Node2D carrying an inline
|
||||
# @tool circle-drawing script; clear it so the head is mounted with the
|
||||
# same full-geometry path as every other part.
|
||||
if part_name == "head":
|
||||
visual.set_script(null)
|
||||
|
||||
_reset_own_geometry(visual)
|
||||
_clear_visual_children(visual)
|
||||
|
||||
var shapes: Array = []
|
||||
var rotation_deg := 0.0
|
||||
var part_scale := Vector2.ONE
|
||||
var guide_offset := Vector2.ZERO
|
||||
var has_guide_offset := false
|
||||
var part_data: Variant = body_parts.get(part_name, {})
|
||||
if part_data is Dictionary:
|
||||
var pd := part_data as Dictionary
|
||||
if pd.has("shapes"):
|
||||
var shapes_var: Variant = pd.get("shapes")
|
||||
if shapes_var is Array:
|
||||
shapes = shapes_var as Array
|
||||
elif pd.has("points"):
|
||||
# v1.0/v1.1 single-shape format: the part dict is itself one
|
||||
# shape (wrapped like the editor's load path).
|
||||
shapes = [pd]
|
||||
# Phase 9 Round 3: the preview's per-part rotation (degrees) and
|
||||
# scale about the bbox center are applied to the mounted geometry.
|
||||
rotation_deg = float(pd.get("rotation", 0.0))
|
||||
var scale_var: Variant = pd.get("scale", {})
|
||||
if scale_var is Dictionary:
|
||||
var sd := scale_var as Dictionary
|
||||
part_scale = Vector2(float(sd.get("x", 1.0)), float(sd.get("y", 1.0)))
|
||||
# Phase 9 Round 5: guide-relative placement (write-only metadata;
|
||||
# absent on old files → offset 0).
|
||||
var go_var: Variant = pd.get("guide_offset")
|
||||
if go_var is Dictionary:
|
||||
var god := go_var as Dictionary
|
||||
guide_offset = Vector2(float(god.get("x", 0.0)), float(god.get("y", 0.0)))
|
||||
has_guide_offset = true
|
||||
|
||||
# Phase 9 Round 5: the part's driver rotation (bone frame) used to
|
||||
# convert the master-space guide offset into a bone-relative
|
||||
# translation. Null-guarded; fallback 0.0.
|
||||
var c_node := 0.0
|
||||
var driver := rig.get_node_or_null(NodePath(DRIVER_PATHS[part_name]))
|
||||
if driver is Node2D:
|
||||
c_node = (driver as Node2D).global_rotation
|
||||
|
||||
# Recompute the mount transform (part rotation/scale applied first, then
|
||||
# the fitted anchor, alignment rotation and bone-fit scale) from the
|
||||
# shape bbox at mount time. The file's pivot/length fields are
|
||||
# write-only metadata and are never trusted for mounting.
|
||||
var bbox := _compute_part_bbox(shapes)
|
||||
if float(bbox["min_x"]) > float(bbox["max_x"]) or float(bbox["min_y"]) > float(bbox["max_y"]):
|
||||
continue
|
||||
var mt := _compute_mount_transform(bbox, part_name, proportions, rotation_deg, part_scale, guide_offset, has_guide_offset, c_node)
|
||||
|
||||
for shape in shapes:
|
||||
if shape is Dictionary:
|
||||
_mount_shape(visual, shape as Dictionary, mt)
|
||||
|
||||
|
||||
static func _bone_length_for(part_name: String, proportions: Dictionary) -> float:
|
||||
match part_name:
|
||||
"left_upper_arm", "right_upper_arm":
|
||||
return float(proportions.get("upper_arm_length", DEFAULT_PROPORTIONS["upper_arm_length"]))
|
||||
"left_lower_arm", "right_lower_arm":
|
||||
return float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"]))
|
||||
"left_upper_leg", "right_upper_leg":
|
||||
return float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"]))
|
||||
"left_lower_leg", "right_lower_leg":
|
||||
return float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"]))
|
||||
"torso":
|
||||
return float(proportions.get("torso_length", DEFAULT_PROPORTIONS["torso_length"]))
|
||||
_:
|
||||
return 1.0
|
||||
|
||||
|
||||
static func _compute_part_bbox(shapes: Array) -> Dictionary:
|
||||
var min_x := INF
|
||||
var min_y := INF
|
||||
var max_x := -INF
|
||||
var max_y := -INF
|
||||
for shape in shapes:
|
||||
if not shape is Dictionary:
|
||||
continue
|
||||
var pts_var: Variant = (shape as Dictionary).get("points", [])
|
||||
if pts_var is Array:
|
||||
for p in pts_var as Array:
|
||||
if p is Dictionary:
|
||||
var d := p as Dictionary
|
||||
var pt := Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0)))
|
||||
min_x = minf(min_x, pt.x)
|
||||
min_y = minf(min_y, pt.y)
|
||||
max_x = maxf(max_x, pt.x)
|
||||
max_y = maxf(max_y, pt.y)
|
||||
elif p is Vector2:
|
||||
var pt := p as Vector2
|
||||
min_x = minf(min_x, pt.x)
|
||||
min_y = minf(min_y, pt.y)
|
||||
max_x = maxf(max_x, pt.x)
|
||||
max_y = maxf(max_y, pt.y)
|
||||
elif pts_var is PackedVector2Array:
|
||||
for pt in pts_var as PackedVector2Array:
|
||||
min_x = minf(min_x, pt.x)
|
||||
min_y = minf(min_y, pt.y)
|
||||
max_x = maxf(max_x, pt.x)
|
||||
max_y = maxf(max_y, pt.y)
|
||||
return {
|
||||
"min_x": min_x,
|
||||
"min_y": min_y,
|
||||
"max_x": max_x,
|
||||
"max_y": max_y,
|
||||
}
|
||||
|
||||
|
||||
## Computes the mount transform for a part: `{ "anchor": Vector2, "theta":
|
||||
## float, "s": float, "center": Vector2, "part_rotation": float,
|
||||
## "part_scale": Vector2, "offset": Vector2 }`.
|
||||
##
|
||||
## Pipeline (spec §2c):
|
||||
## 1. The raw bbox center C, raw joint end J_raw, and raw far point F_pt_raw
|
||||
## come from the Round 2 family rules (head/torso bottom-center →
|
||||
## top-center; horizontally-drawn limbs end-to-end; vertical limbs
|
||||
## top-center → bottom-center).
|
||||
## 2. The preview's part transform E(P) = C + R(rot)·S·(P − C) is applied to
|
||||
## the anchor and far point: J' = E(J_raw), F_pt' = E(F_pt_raw),
|
||||
## F' = F_pt' − J'.
|
||||
## 3. Phase 9 Round 6: when `guide_offset` is present, the anchor end is
|
||||
## whichever transformed end (J' or F_pt') is nearest the guide joint
|
||||
## (C − guide_offset): A = F_pt', V = −F' if the far end is nearer, else
|
||||
## A = J', V = F'. Old files (no guide_offset) fall back to the 180° flip
|
||||
## heuristic (|wrapf(rot)| > 0.75·π attaches the drawn far end at the
|
||||
## joint; otherwise A = J', V = F').
|
||||
## 4. Alignment θ = V.normalized().angle_to(Vector2.DOWN) rotates the fitted
|
||||
## long axis onto the hanging frame; the bone-fit scale s = bone_length/|V|
|
||||
## is measured on the transformed extent. The head mounts upright,
|
||||
## unscaled (θ = 0, s = 1) but still applies E, plus a rig-space
|
||||
## translation offset (head only) that drops the chin below the neck.
|
||||
## 5. Phase 9 Round 5: when `guide_offset` is present, the offset becomes
|
||||
## t = (guide_offset + (A − C)).rotated(−c_node) — the anchor's placement
|
||||
## relative to the guide joint, converted to the driver's bone frame —
|
||||
## which reproduces the editor's guide-relative placement (and, for the
|
||||
## head, subsumes the HEAD_CHIN_DROP fallback).
|
||||
static func _compute_mount_transform(bbox: Dictionary, part_name: String, proportions: Dictionary, rotation_deg: float, part_scale: Vector2, guide_offset: Vector2, has_guide_offset: bool, c_node: float) -> Dictionary:
|
||||
var min_x := float(bbox["min_x"])
|
||||
var min_y := float(bbox["min_y"])
|
||||
var max_x := float(bbox["max_x"])
|
||||
var max_y := float(bbox["max_y"])
|
||||
var cx := (min_x + max_x) * 0.5
|
||||
var cy := (min_y + max_y) * 0.5
|
||||
var width := max_x - min_x
|
||||
var height := max_y - min_y
|
||||
var center := Vector2(cx, cy)
|
||||
|
||||
# Raw joint end (J_raw) and far point (F_pt_raw), per the Round 2 family
|
||||
# rules. Head and torso mount bottom-center (chin / hip end) with the far
|
||||
# point at top-center (cap / neck end); limbs auto-detect the drawn long
|
||||
# axis (horizontal: end-to-end; vertical: top-center → bottom-center).
|
||||
var j_raw := Vector2.ZERO
|
||||
var f_pt_raw := Vector2.ZERO
|
||||
if part_name == "head" or part_name == "torso":
|
||||
j_raw = Vector2(cx, max_y)
|
||||
f_pt_raw = Vector2(cx, min_y)
|
||||
else:
|
||||
var long_axis_is_x := width >= height
|
||||
if part_name.begins_with("left_"):
|
||||
if long_axis_is_x:
|
||||
j_raw = Vector2(max_x, cy)
|
||||
f_pt_raw = Vector2(min_x, cy)
|
||||
else:
|
||||
j_raw = Vector2(cx, min_y)
|
||||
f_pt_raw = Vector2(cx, max_y)
|
||||
else:
|
||||
if long_axis_is_x:
|
||||
j_raw = Vector2(min_x, cy)
|
||||
f_pt_raw = Vector2(max_x, cy)
|
||||
else:
|
||||
j_raw = Vector2(cx, min_y)
|
||||
f_pt_raw = Vector2(cx, max_y)
|
||||
|
||||
# Apply the preview's part transform to the anchor and far point.
|
||||
var rot_rad := deg_to_rad(rotation_deg)
|
||||
var j_prime := _apply_part_transform(j_raw, center, part_scale, rot_rad)
|
||||
var f_pt_prime := _apply_part_transform(f_pt_raw, center, part_scale, rot_rad)
|
||||
var f_prime := f_pt_prime - j_prime
|
||||
|
||||
# Phase 9 Round 6: when guide_offset is present, the stored guide placement is
|
||||
# the ground truth for which drawn end is the joint — pick whichever
|
||||
# transformed end (j_prime or f_pt_prime) is nearest the guide joint
|
||||
# (center - guide_offset). This replaces the family-side + 180° flip heuristic
|
||||
# for that case and naturally reproduces the flip (a flipped part's far end
|
||||
# lands near the joint). Old files (no guide_offset) keep the flip heuristic.
|
||||
var anchor: Vector2
|
||||
var v: Vector2
|
||||
if has_guide_offset:
|
||||
var joint_pos := center - guide_offset
|
||||
var d_joint := j_prime.distance_to(joint_pos)
|
||||
var d_far := f_pt_prime.distance_to(joint_pos)
|
||||
if d_far < d_joint:
|
||||
anchor = f_pt_prime
|
||||
v = -f_prime
|
||||
else:
|
||||
anchor = j_prime
|
||||
v = f_prime
|
||||
else:
|
||||
# 180° flips attach the drawn far end at the joint (the end the user rotated
|
||||
# into the joint position), making the rotation visibly applied.
|
||||
var flipped := absf(wrapf(rot_rad, -PI, PI)) > PI * 0.75
|
||||
if flipped:
|
||||
anchor = f_pt_prime
|
||||
v = -f_prime
|
||||
else:
|
||||
anchor = j_prime
|
||||
v = f_prime
|
||||
|
||||
var v_len := v.length()
|
||||
var theta := 0.0
|
||||
if v_len > 0.0001:
|
||||
theta = v.normalized().angle_to(Vector2.DOWN)
|
||||
|
||||
var s := 1.0
|
||||
var offset := Vector2.ZERO
|
||||
|
||||
# Phase 9 Round 5: guide-relative placement. delta = guide_offset + (A − C)
|
||||
# is the anchor's offset from its guide joint in master space; t rotates it
|
||||
# into the driver's (bone) frame so the placement stays bone-relative as the
|
||||
# rig flexes. Applied only when the key is present (old files keep the
|
||||
# offset-0 / chin-drop behavior).
|
||||
if has_guide_offset:
|
||||
offset = (guide_offset + (anchor - center)).rotated(-c_node)
|
||||
|
||||
if part_name == "head":
|
||||
# Head mounts upright, unscaled — a bone-fit scale would double-scale the
|
||||
# face; E already applied the user's part scale. The chin (local origin)
|
||||
# is dropped below the neck by HEAD_CHIN_DROP so the head overlaps the
|
||||
# torso like the editor's pose guide. That drop is the old-file fallback;
|
||||
# when guide_offset is present it is subsumed by the computed offset.
|
||||
theta = 0.0
|
||||
s = 1.0
|
||||
if not has_guide_offset:
|
||||
offset = Vector2(0.0, HEAD_CHIN_DROP)
|
||||
elif v_len > 0.0001:
|
||||
s = _bone_length_for(part_name, proportions) / v_len
|
||||
|
||||
return {
|
||||
"anchor": anchor,
|
||||
"theta": theta,
|
||||
"s": s,
|
||||
"center": center,
|
||||
"part_rotation": rot_rad,
|
||||
"part_scale": part_scale,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
## Applies the preview's part transform E(P) = C + R(rot)·S·(P − C): scale
|
||||
## about the bbox center, then rotate about the bbox center.
|
||||
static func _apply_part_transform(pt: Vector2, center: Vector2, part_scale: Vector2, rot_rad: float) -> Vector2:
|
||||
return center + Vector2((pt.x - center.x) * part_scale.x, (pt.y - center.y) * part_scale.y).rotated(rot_rad)
|
||||
|
||||
|
||||
static func _reset_node_transform(visual: Node) -> void:
|
||||
if visual is Node2D:
|
||||
(visual as Node2D).scale = Vector2.ONE
|
||||
(visual as Node2D).rotation = 0.0
|
||||
|
||||
|
||||
static func _mount_shape(visual: Node, shape: Dictionary, mt: Dictionary) -> void:
|
||||
var anchor: Vector2 = mt["anchor"]
|
||||
var theta: float = mt["theta"]
|
||||
var s: float = mt["s"]
|
||||
var center: Vector2 = mt["center"]
|
||||
var part_rotation: float = mt["part_rotation"]
|
||||
var part_scale: Vector2 = mt["part_scale"]
|
||||
var offset: Vector2 = mt["offset"]
|
||||
|
||||
var pts := _transform_points(shape.get("points", []), anchor, theta, s, center, part_rotation, part_scale, offset)
|
||||
if pts.size() < 2:
|
||||
return
|
||||
|
||||
var color := Color.from_string(str(shape.get("color", "#ffffff")), Color.WHITE)
|
||||
var closed := bool(shape.get("closed", false))
|
||||
|
||||
# Phase 9 Round 3: one node per shape — closed shapes mount as a single
|
||||
# Polygon2D (fill only, no outline Line2D); open shapes mount as a single
|
||||
# Line2D.
|
||||
if closed:
|
||||
var poly := Polygon2D.new()
|
||||
poly.polygon = pts
|
||||
poly.color = color
|
||||
visual.add_child(poly)
|
||||
else:
|
||||
var line := Line2D.new()
|
||||
line.points = pts
|
||||
line.width = DEFAULT_LINE_WIDTH
|
||||
line.default_color = color
|
||||
visual.add_child(line)
|
||||
|
||||
|
||||
static func _transform_points(pts_var: Variant, anchor: Vector2, theta: float, s: float, center: Vector2, part_rotation: float, part_scale: Vector2, offset: Vector2) -> PackedVector2Array:
|
||||
var out := PackedVector2Array()
|
||||
if pts_var is Array:
|
||||
for p in pts_var as Array:
|
||||
if p is Dictionary:
|
||||
var d := p as Dictionary
|
||||
var pt := Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0)))
|
||||
out.append(_map_point(pt, anchor, theta, s, center, part_rotation, part_scale, offset))
|
||||
elif p is Vector2:
|
||||
out.append(_map_point(p as Vector2, anchor, theta, s, center, part_rotation, part_scale, offset))
|
||||
elif pts_var is PackedVector2Array:
|
||||
for pt in pts_var as PackedVector2Array:
|
||||
out.append(_map_point(pt, anchor, theta, s, center, part_rotation, part_scale, offset))
|
||||
return out
|
||||
|
||||
|
||||
## Maps one drawn point into the rig's hanging frame:
|
||||
## Q = E(P) = C + R(rot)·S·(P − C) (the preview's part transform)
|
||||
## v = R(θ)·(Q − A); v.y *= s (align + bone-fit scale along the axis)
|
||||
## v += offset (rig-space translation, head chin drop)
|
||||
static func _map_point(pt: Vector2, anchor: Vector2, theta: float, s: float, center: Vector2, part_rotation: float, part_scale: Vector2, offset: Vector2) -> Vector2:
|
||||
var q := _apply_part_transform(pt, center, part_scale, part_rotation)
|
||||
var v := (q - anchor).rotated(theta)
|
||||
v.y *= s
|
||||
return v + offset
|
||||
|
||||
|
||||
static func _reset_own_geometry(visual: Node) -> void:
|
||||
if visual is Line2D:
|
||||
(visual as Line2D).points = PackedVector2Array()
|
||||
elif visual is Polygon2D:
|
||||
(visual as Polygon2D).polygon = PackedVector2Array()
|
||||
|
||||
|
||||
static func _clear_visual_children(visual: Node) -> void:
|
||||
for child in visual.get_children():
|
||||
if child is Line2D or child is Polygon2D:
|
||||
visual.remove_child(child)
|
||||
child.queue_free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxdghe86wml5e
|
||||
Reference in New Issue
Block a user