chobit/shared/godot/avatar/idle_animator.gd
Claude Code 4496e5af32 feat(avatar): Update idle animations and add gesture/bone definitions for enhanced realism
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-28 21:13:48 -07:00

345 lines
10 KiB
GDScript

extends Node
## Procedural idle animation for VRM avatars.
## Breathing, blinking, sway, head micro-movements, and data-driven gestures.
## Layers additively on top of AnimationStateMachine postures.
## Delegates gesture logic to GestureRegistry and bone control to BoneRegistry.
const NodeUtilsScript = preload("res://src/core/node_utils.gd")
const BodyConstraints = preload("res://src/data/body_constraints.gd")
const BoneRegistryScript = preload("res://src/avatar/bone_registry.gd")
const GestureRegistryScript = preload("res://src/avatar/gesture_registry.gd")
# ── Tuning ──
const BREATH_CYCLE := 3.0
const BREATH_AMP := 0.004
const BLINK_MIN := 2.0
const BLINK_MAX := 6.0
const BLINK_DUR := 0.15
const SWAY_SPEED := 0.3
const SWAY_AMP := 0.015
const HEAD_SPEED := Vector3(0.12, 0.09, 0.07)
const HEAD_AMP := Vector3(0.02, 0.015, 0.012)
const HEAD_MOVE_INTERVAL := Vector2(4.0, 10.0)
const HEAD_MOVE_DUR := 1.5
# ── Registries (public — accessible by tray_listener, test_pose, etc.) ──
var bone_reg: RefCounted # BoneRegistry
var gesture_reg: RefCounted # GestureRegistry
# ── Skeleton refs ──
var _skeleton: Skeleton3D
var _mesh: MeshInstance3D
var _time: float = 0.0
# Bone indices (idle animation bones — NOT managed by bone_reg)
var _chest_idx: int = -1
var _upper_chest_idx: int = -1
var _hips_idx: int = -1
var _head_idx: int = -1
var _shoulder_l_idx: int = -1
var _shoulder_r_idx: int = -1
var _blink_idx: int = -1
# Rest poses
var _chest_rest_pos: Vector3
var _upper_chest_rest_pos: Vector3
var _hips_rest_rot: Quaternion
var _head_rest_rot: Quaternion
var _shoulder_l_rest_rot: Quaternion
var _shoulder_r_rest_rot: Quaternion
# ── Blink state ──
var _blink_timer: float = 0.0
var _next_blink: float = 3.0
var _blink_progress: float = -1.0
var _blink_weight: float = 0.0
# ── Head micro state ──
var _head_move_timer: float = 0.0
var _next_head_move: float = 8.0
var _head_target := Vector3.ZERO
var _head_move_progress: float = 1.0
var _head_pitch: float = 0.0
var _head_yaw: float = 0.0
var _head_roll: float = 0.0
# ── Breathing / sway state ──
var _breath_offset: float = 0.0
var _sway_x: float = 0.0
var _sway_z: float = 0.0
func setup(model: Node3D) -> void:
_skeleton = NodeUtilsScript.find_child_of_type(model, "Skeleton3D") as Skeleton3D
_mesh = NodeUtilsScript.find_first_mesh_with_blendshapes(model)
if _skeleton == null:
push_warning("IdleAnimator: No Skeleton3D found")
return
_chest_idx = NodeUtilsScript.find_bone_case_insensitive(_skeleton, "Chest")
if _chest_idx == -1:
_chest_idx = NodeUtilsScript.find_bone_case_insensitive(_skeleton, "Spine")
_upper_chest_idx = NodeUtilsScript.find_bone_case_insensitive(_skeleton, "UpperChest")
_hips_idx = NodeUtilsScript.find_bone_case_insensitive(_skeleton, "Hips")
_head_idx = NodeUtilsScript.find_bone_case_insensitive(_skeleton, "Head")
_shoulder_l_idx = NodeUtilsScript.find_bone_case_insensitive(_skeleton, "LeftShoulder")
_shoulder_r_idx = NodeUtilsScript.find_bone_case_insensitive(_skeleton, "RightShoulder")
if _chest_idx != -1:
_chest_rest_pos = _skeleton.get_bone_rest(_chest_idx).origin
if _upper_chest_idx != -1:
_upper_chest_rest_pos = _skeleton.get_bone_rest(_upper_chest_idx).origin
if _hips_idx != -1:
_hips_rest_rot = _skeleton.get_bone_rest(_hips_idx).basis.get_rotation_quaternion()
if _head_idx != -1:
_head_rest_rot = _skeleton.get_bone_rest(_head_idx).basis.get_rotation_quaternion()
if _shoulder_l_idx != -1:
_shoulder_l_rest_rot = (
_skeleton.get_bone_rest(_shoulder_l_idx).basis.get_rotation_quaternion()
)
if _shoulder_r_idx != -1:
_shoulder_r_rest_rot = (
_skeleton.get_bone_rest(_shoulder_r_idx).basis.get_rotation_quaternion()
)
# Initialize registries
bone_reg = BoneRegistryScript.new()
bone_reg.setup(_skeleton)
gesture_reg = GestureRegistryScript.new()
gesture_reg.setup(bone_reg)
if _mesh != null:
_blink_idx = NodeUtilsScript.find_blend_shape_index(_mesh, "Blink")
if _blink_idx == -1:
_blink_idx = NodeUtilsScript.find_blend_shape_index(_mesh, "blink")
_next_blink = randf_range(BLINK_MIN, BLINK_MAX)
_next_head_move = randf_range(HEAD_MOVE_INTERVAL.x, HEAD_MOVE_INTERVAL.y)
EventBus.gesture_requested.connect(_on_gesture_requested)
func _process(delta: float) -> void:
if _skeleton == null:
return
_time += delta
_do_breathing()
_do_blink(delta)
_do_sway()
_do_head_micro(delta)
_do_gestures(delta)
_apply_bones()
func _do_breathing() -> void:
_breath_offset = sin(_time * TAU / BREATH_CYCLE) * BREATH_AMP
func _do_blink(delta: float) -> void:
if _blink_progress >= 0.0:
_blink_progress += delta
var t := _blink_progress / BLINK_DUR
if t < 0.5:
_blink_weight = t * 2.0
elif t < 1.0:
_blink_weight = (1.0 - t) * 2.0
else:
_blink_weight = 0.0
_blink_progress = -1.0
_next_blink = randf_range(BLINK_MIN, BLINK_MAX)
else:
_blink_timer += delta
if _blink_timer >= _next_blink:
_blink_timer = 0.0
_blink_progress = 0.0
func _do_sway() -> void:
_sway_x = sin(_time * SWAY_SPEED) * SWAY_AMP
_sway_z = sin(_time * SWAY_SPEED * 0.7 + 1.0) * SWAY_AMP
func _do_head_micro(delta: float) -> void:
var mp := sin(_time * HEAD_SPEED.x) * HEAD_AMP.x
var my := sin(_time * HEAD_SPEED.y + 2.0) * HEAD_AMP.y
var mr := sin(_time * HEAD_SPEED.z + 4.0) * HEAD_AMP.z
if _head_move_progress >= 1.0:
_head_move_timer += delta
if _head_move_timer >= _next_head_move:
_head_target = _pick_head_move()
_head_move_progress = 0.0
_head_move_timer = 0.0
_next_head_move = randf_range(HEAD_MOVE_INTERVAL.x, HEAD_MOVE_INTERVAL.y)
else:
_head_move_progress = minf(_head_move_progress + delta / HEAD_MOVE_DUR, 1.0)
var env := _envelope_bell(_head_move_progress) if _head_move_progress < 1.0 else 0.0
_head_pitch = mp + _head_target.x * env
_head_yaw = my + _head_target.y * env
_head_roll = mr + _head_target.z * env
func _pick_head_move() -> Vector3:
var s := 1.0 if randf() > 0.5 else -1.0
var r := randf()
if r < 0.60:
return Vector3(0.0, 0.0, 0.06 * s)
if r < 0.85:
return Vector3(-0.04, 0.0, 0.0)
return Vector3(0.0, 0.05 * s, -0.025 * s)
func _do_gestures(delta: float) -> void:
if gesture_reg.is_playing():
var active: String = gesture_reg.get_active()
gesture_reg.update(delta, _time)
if not gesture_reg.is_playing():
# Gesture just finished
(
FlightRecorder
. record(
"gesture.finished",
"Gesture '%s' finished" % active,
{"gesture": active},
)
)
return
# Auto-trigger from cooldowns
var ready: String = gesture_reg.tick_cooldowns(delta)
if not ready.is_empty():
_play_gesture(ready)
func _play_gesture(gname: String, params: Dictionary = {}) -> void:
var def: Dictionary = gesture_reg.get_def(gname)
if def.is_empty():
return
if gesture_reg.is_playing():
(
FlightRecorder
. record(
"gesture.interrupted",
"Gesture '%s' interrupted by '%s'" % [gesture_reg.get_active(), gname],
{"interrupted": gesture_reg.get_active(), "by": gname},
)
)
gesture_reg.stop()
gesture_reg.play(gname, params)
(
FlightRecorder
. record(
"gesture.started",
"Gesture '%s' started" % gname,
{"gesture": gname, "duration": def.get("duration", 0.0), "params": str(params)},
)
)
var triggers: Array = def.get("triggers", [])
for trigger: String in triggers:
match trigger:
"deep_breath":
pass
"slow_blink":
_blink_timer = _next_blink
if def.get("disengage_gaze", false):
EventBus.gaze_disengage.emit(def.get("duration", 2.0) * 0.8)
func _apply_bones() -> void:
var g: Dictionary = gesture_reg.get_flat_outputs()
if _chest_idx != -1:
var pos := _chest_rest_pos
pos.y += _breath_offset + g.get("chest_y", 0.0)
_skeleton.set_bone_pose_position(_chest_idx, pos)
if _upper_chest_idx != -1:
var pos := _upper_chest_rest_pos
pos.y += _breath_offset * 0.4
_skeleton.set_bone_pose_position(_upper_chest_idx, pos)
if _hips_idx != -1:
var rx: float = _sway_x + g.get("hips_x", 0.0)
var rz: float = _sway_z + g.get("hips_z", 0.0)
var hips_lim: Dictionary = BodyConstraints.ROTATION_LIMITS["hips"]
rx = clampf(rx, deg_to_rad(hips_lim["pitch"][0]), deg_to_rad(hips_lim["pitch"][1]))
rz = clampf(rz, deg_to_rad(hips_lim["roll"][0]), deg_to_rad(hips_lim["roll"][1]))
(
_skeleton
. set_bone_pose_rotation(
_hips_idx,
_hips_rest_rot * Quaternion(Vector3.RIGHT, rx) * Quaternion(Vector3.FORWARD, rz),
)
)
if _head_idx != -1:
var p: float = _head_pitch + g.get("head_pitch", 0.0)
var y: float = _head_yaw + g.get("head_yaw", 0.0)
var r: float = _head_roll + g.get("head_roll", 0.0)
var head_lim: Dictionary = BodyConstraints.ROTATION_LIMITS["head"]
p = clampf(p, deg_to_rad(head_lim["pitch"][0]), deg_to_rad(head_lim["pitch"][1]))
y = clampf(y, deg_to_rad(head_lim["yaw"][0]), deg_to_rad(head_lim["yaw"][1]))
r = clampf(r, deg_to_rad(head_lim["roll"][0]), deg_to_rad(head_lim["roll"][1]))
(
_skeleton
. set_bone_pose_rotation(
_head_idx,
(
_head_rest_rot
* Quaternion(Vector3.RIGHT, p)
* Quaternion(Vector3.UP, y)
* Quaternion(Vector3.FORWARD, r)
),
)
)
if _shoulder_l_idx != -1:
var a: float = g.get("shoulder_l", 0.0)
if absf(a) > 0.0001:
(
_skeleton
. set_bone_pose_rotation(
_shoulder_l_idx,
_shoulder_l_rest_rot * Quaternion(Vector3.FORWARD, a),
)
)
if _shoulder_r_idx != -1:
var a: float = g.get("shoulder_r", 0.0)
if absf(a) > 0.0001:
(
_skeleton
. set_bone_pose_rotation(
_shoulder_r_idx,
_shoulder_r_rest_rot * Quaternion(Vector3.FORWARD, a),
)
)
# Apply gesture bone targets via bone_reg (handles twist propagation)
for bone_name: String in gesture_reg.get_bone_targets():
if bone_reg.has_bone(bone_name):
bone_reg.set_rotation(bone_name, gesture_reg.get_bone_targets()[bone_name])
if _blink_idx != -1 and _mesh != null:
_mesh.set_blend_shape_value(_blink_idx, _blink_weight)
static func _envelope_bell(t: float) -> float:
if t < 0.3:
return smoothstep(0.0, 1.0, t / 0.3)
if t < 0.6:
return 1.0
return smoothstep(0.0, 1.0, (1.0 - t) / 0.4)
func _on_gesture_requested(gname: String) -> void:
if gname == "slow_blink":
_blink_timer = _next_blink
return
if not gesture_reg.has_gesture(gname):
return
_play_gesture(gname)