Hands/POV - Cut a real first-person arms rig out of the GODVERSE modular character kit (tools/gen_fps_arms.py): ch01 hands + per-side sleeves on the full 65-bone mixamorig skeleton, so all 20 finger bones per hand are poseable at runtime. Textures shrunk to 1k; 4.2 MB. - ViewModel.gd instances that rig ONCE PER HAND and places each instance so its own hand bone lands on the grip — no IK. Grip orientation is measured off the rig at load (pinky->index knuckle = bore axis, elbow->hand = forearm dir), so it survives re-tuning grip_rest_rot instead of needing new euler angles. - Motion layers: look-sway with spring-back, walk bob scaled by speed and weapon heft, idle breathe, landing dip, weapon lower/raise on swap. - Sleeve material overridden (donor asset is a fantasy leather bracer); hand material forced non-metallic (its spec/gloss maps rendered skin as bronze). Weapons - Weapon.gd replaces MeleeAttack: 6 weapons, 4 swing archetypes, and the weapon-vs-material matrix from the founding chat. - Smashable moves from binary hits_to_break to hp/toughness, giving three outcomes: break, dent, or futile (dead clank, no score, HUD nudge). The box cutter genuinely shreds cardboard and genuinely cannot hurt a filing cabinet. - The hit lands at Weapon.contact THROUGH the swing, not on the click — that delay is most of why the sledge feels different from the cutter. - Slots on 1-6 / wheel / Q; game modes moved to M, HUD cycle to H. HUD - Hud.gd: Arcade, Minimal, Work Order (a corporate destruction docket that fills in line items) and Dev, over one shared data feed. Scoring + combo multipliers. Dev harness (not shipped) - macOS screen-recording perms aren't available to the CLI, so the game records itself: dev/demo.tscn + DemoDriver.gd drive a scripted tour for --write-movie, and dev/probe_*.gd print rig/scale/placement numbers. Fix: tools/gen_viewmodel.py box() scaled by size/2 on top of primitive_cube_add's already-unit side length, halving every box — which detached the bat's blade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
502 lines
20 KiB
GDScript
502 lines
20 KiB
GDScript
extends Node3D
|
|
class_name ViewModel
|
|
|
|
## The first-person viewmodel: real rigged hands holding a real weapon, and every bit
|
|
## of motion that sells a swing.
|
|
##
|
|
## HOW THE ARMS WORK (the bit worth knowing before editing)
|
|
## `fps_arms.glb` is a mixamorig-rigged pair in a T-pose. Rather than solve IK to put a
|
|
## hand somewhere, the rig is instanced ONCE PER HAND and each instance is *placed* by
|
|
## transform so its own hand bone lands exactly on the grip. The other side's meshes are
|
|
## hidden. That works because in first person you only ever see forearm and hand — a
|
|
## straight forearm coming in from off-screen is exactly what an FPS arm looks like — and
|
|
## it buys exact, tunable grip placement for two lines of matrix maths instead of an IK
|
|
## solver that would need tuning anyway. Fingers ARE posed for real, per weapon, because
|
|
## finger curl is a single rotation per joint and the rig has all 20 bones.
|
|
##
|
|
## MOTION LAYERS, all composed onto `_rig` each frame:
|
|
## sway ....... the rig lags behind mouse-look, then springs back (weight)
|
|
## bob ........ figure-8 while walking, scaled by speed and weapon heft
|
|
## breathe .... a small idle drift so a standing player is never perfectly still
|
|
## swing ...... keyframed windup -> strike -> recover arc, per weapon archetype
|
|
## dip ........ a landing compression when you hit the floor
|
|
##
|
|
## Godot 4.7 GDScript 2.0.
|
|
|
|
const ARMS_GLB := "res://assets/viewmodel/fps_arms.glb"
|
|
|
|
const BONE_HAND_R := "mixamorig_RightHand"
|
|
const BONE_HAND_L := "mixamorig_LeftHand"
|
|
const FINGERS_R := ["mixamorig_RightHandIndex", "mixamorig_RightHandMiddle",
|
|
"mixamorig_RightHandRing", "mixamorig_RightHandPinky"]
|
|
const FINGERS_L := ["mixamorig_LeftHandIndex", "mixamorig_LeftHandMiddle",
|
|
"mixamorig_LeftHandRing", "mixamorig_LeftHandPinky"]
|
|
const THUMB_R := "mixamorig_RightHandThumb"
|
|
const THUMB_L := "mixamorig_LeftHandThumb"
|
|
|
|
## Whole-viewmodel scale. A cricket bat really is 85 cm and really is held 45 cm from
|
|
## your eye, and at a 75-degree FOV that fills the screen — which is why every FPS shrinks
|
|
## its viewmodel rather than rendering it life-size. This is that shrink.
|
|
@export var vm_scale: float = 0.52
|
|
|
|
## Where the weapon grip sits relative to the camera, at rest. +X right, +Y up, -Z fwd.
|
|
@export var grip_rest := Vector3(0.30, -0.32, -0.56)
|
|
## Rest orientation of the grip frame (degrees). The weapon's shaft runs +Y out of the
|
|
## grip, so this tips it up-and-right and leans it away from the camera — far enough
|
|
## right that a 45 cm cricket bat blade isn't parked over the crosshair.
|
|
@export var grip_rest_rot := Vector3(-30.0, 16.0, -40.0)
|
|
|
|
# ---------------------------------------------------------------- nodes
|
|
var _rig: Node3D # everything animates on this
|
|
var _grip: Node3D # the weapon's grip frame; hands are placed off it
|
|
var _arm_r: Node3D
|
|
var _arm_l: Node3D
|
|
var _skel_r: Skeleton3D
|
|
var _skel_l: Skeleton3D
|
|
var _hand_in_arm_r := Transform3D.IDENTITY # hand bone rest, in its instance's space
|
|
var _hand_in_arm_l := Transform3D.IDENTITY
|
|
var _bore_local_r := Vector3.RIGHT # grip axis, in hand-bone space (measured)
|
|
var _bore_local_l := Vector3.RIGHT
|
|
var _forearm_local_r := Vector3.UP # elbow->hand, in hand-bone space (measured)
|
|
var _forearm_local_l := Vector3.UP
|
|
var _palm_local_r := Vector3.ZERO # wrist->middle knuckle, in hand-bone space
|
|
var _palm_local_l := Vector3.ZERO
|
|
var _weapon_node: Node3D = null
|
|
var _weapon: Weapon = null
|
|
|
|
# ---------------------------------------------------------------- motion state
|
|
var _sway := Vector2.ZERO # smoothed look-delta the rig lags by
|
|
var _sway_vel := Vector2.ZERO
|
|
var _bob_t := 0.0
|
|
var _breathe_t := 0.0
|
|
var _dip := 0.0 # landing compression, decays to 0
|
|
var _swing_t := -1.0 # -1 = idle, else seconds into the swing
|
|
var _swap_t := -1.0 # weapon-change lower/raise
|
|
var _pending: Weapon = null
|
|
|
|
signal swing_contact ## the frame the swing actually connects
|
|
|
|
# ---------------------------------------------------------------- swing archetypes
|
|
# Keyframes are (time_fraction, position_offset, rotation_euler_degrees) on `_rig`.
|
|
# Interpolated with smoothstep; the strike segment is deliberately short so the arc
|
|
# reads as fast even when the whole animation is slow (the sledge).
|
|
const SWINGS := {
|
|
"overhead": [
|
|
[0.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
|
|
[0.34, Vector3(0.02, 0.20, 0.14), Vector3(-62, -8, -6)],
|
|
[0.56, Vector3(-0.02, -0.16, -0.30), Vector3(72, 4, 4)],
|
|
[0.74, Vector3(-0.01, -0.06, -0.10), Vector3(30, 2, 2)],
|
|
[1.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
|
|
],
|
|
"horizontal": [
|
|
[0.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
|
|
[0.32, Vector3(0.22, 0.07, 0.16), Vector3(-12, -54, -18)],
|
|
[0.58, Vector3(-0.24, -0.05, -0.24), Vector3(6, 58, 24)],
|
|
[0.76, Vector3(-0.09, -0.02, -0.06), Vector3(2, 24, 10)],
|
|
[1.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
|
|
],
|
|
"jab": [
|
|
[0.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
|
|
[0.28, Vector3(0.03, -0.05, 0.10), Vector3(-14, 6, 0)],
|
|
[0.50, Vector3(-0.02, 0.02, -0.30), Vector3(10, -6, 0)],
|
|
[1.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
|
|
],
|
|
"thrust": [
|
|
[0.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
|
|
[0.34, Vector3(0.02, 0.02, 0.14), Vector3(-8, 10, 0)],
|
|
[0.56, Vector3(-0.01, -0.02, -0.34), Vector3(6, -8, 0)],
|
|
[1.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
|
|
],
|
|
}
|
|
|
|
## Per-weapon hand placement, in the grip frame. `up` slides a hand along the shaft
|
|
## (+Y is toward the head), `rot` orients the fist around it, `curl` closes the fingers.
|
|
const GRIPS := {
|
|
"fists": {"r_up": 0.00, "l_up": 0.00, "curl": 1.00, "thumb": 0.85, "one_hand": true},
|
|
"cutter": {"r_up": 0.02, "l_up": 0.00, "curl": 0.92, "thumb": 0.70, "one_hand": true},
|
|
"bat": {"r_up": 0.03, "l_up": 0.15, "curl": 0.95, "thumb": 0.80, "one_hand": true},
|
|
"crowbar": {"r_up": 0.02, "l_up": 0.18, "curl": 0.95, "thumb": 0.80, "one_hand": true},
|
|
"sledge": {"r_up": 0.00, "l_up": 0.26, "curl": 0.97, "thumb": 0.85, "one_hand": false},
|
|
"extinguisher": {"r_up": 0.02, "l_up": 0.20, "curl": 0.90, "thumb": 0.75, "one_hand": false},
|
|
}
|
|
|
|
## Where the elbow should sit relative to the hand, as a direction (elbow -> hand) in
|
|
## rig space. This is what stops the forearm lying across the screen: the arm has to
|
|
## arrive from below and outside, the way your own does. Mirrored in X for the left.
|
|
const FOREARM_DIR := Vector3(-0.30, 0.62, -0.72)
|
|
## The off hand when it isn't on the weapon: down, out, and mostly off the bottom edge.
|
|
const IDLE_OFF_POS := Vector3(-0.30, -0.44, -0.40)
|
|
const IDLE_OFF_SHAFT := Vector3(0.35, 0.55, -0.75) # a virtual "shaft" for the loose fist
|
|
|
|
# ---------------------------------------------------------------- setup
|
|
func setup(cam: Camera3D) -> void:
|
|
if cam == null:
|
|
return
|
|
cam.add_child(self)
|
|
scale = Vector3.ONE * vm_scale
|
|
_rig = Node3D.new()
|
|
_rig.name = "Rig"
|
|
add_child(_rig)
|
|
|
|
_grip = Node3D.new()
|
|
_grip.name = "Grip"
|
|
_rig.add_child(_grip)
|
|
_grip.transform = Transform3D(
|
|
Basis.from_euler(Vector3(deg_to_rad(grip_rest_rot.x), deg_to_rad(grip_rest_rot.y),
|
|
deg_to_rad(grip_rest_rot.z))), grip_rest)
|
|
|
|
_arm_r = _spawn_arm(true)
|
|
_arm_l = _spawn_arm(false)
|
|
|
|
## One instance of the arms rig, with the other side's meshes hidden. Returns null if
|
|
## the asset is missing so the game still runs (you just get no hands).
|
|
func _spawn_arm(right: bool) -> Node3D:
|
|
if not ResourceLoader.exists(ARMS_GLB):
|
|
push_warning("[viewmodel] %s missing — running without hands" % ARMS_GLB)
|
|
return null
|
|
var packed := load(ARMS_GLB) as PackedScene
|
|
if packed == null:
|
|
return null
|
|
var inst: Node3D = packed.instantiate()
|
|
inst.name = "ArmR" if right else "ArmL"
|
|
_rig.add_child(inst)
|
|
# NOTE: do not scale this instance. The GLB's own `kachujin_rig` node already carries
|
|
# the 0.01 cm->m conversion, so the meshes are life-size as imported; scaling here
|
|
# would apply it twice.
|
|
|
|
var skel := _find_skeleton(inst)
|
|
var drop := ["ch01_hand_L", "arms_sleeve_L"] if right else ["ch01_hand_R", "arms_sleeve_R"]
|
|
for mi in _mesh_nodes(inst):
|
|
if mi.name in drop:
|
|
mi.visible = false
|
|
else:
|
|
_restyle(mi)
|
|
# a viewmodel must never be clipped by world geometry or lit like world geometry
|
|
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
|
|
if right:
|
|
_skel_r = skel
|
|
_hand_in_arm_r = _bone_rest_in(inst, skel, BONE_HAND_R)
|
|
_measure(inst, skel, true)
|
|
else:
|
|
_skel_l = skel
|
|
_hand_in_arm_l = _bone_rest_in(inst, skel, BONE_HAND_L)
|
|
_measure(inst, skel, false)
|
|
return inst
|
|
|
|
## Work out, IN HAND-LOCAL SPACE, two directions we later want to aim:
|
|
## bore ..... the axis a handle runs along inside the fist. Taken as pinky-knuckle ->
|
|
## index-knuckle, i.e. the axis the fingers literally wrap around, so it
|
|
## comes from the rig instead of from a guessed euler triple.
|
|
## forearm .. elbow -> hand, so the arm can be made to arrive from the shoulder.
|
|
## Both are unit vectors in the hand bone's own frame, which makes them independent of
|
|
## how the rig happens to be oriented in the file.
|
|
func _measure(inst: Node3D, skel: Skeleton3D, right: bool) -> void:
|
|
if skel == null:
|
|
return
|
|
var hand := _bone_rest_in(inst, skel, BONE_HAND_R if right else BONE_HAND_L)
|
|
var fore := _bone_rest_in(inst, skel,
|
|
"mixamorig_RightForeArm" if right else "mixamorig_LeftForeArm")
|
|
var idx := _bone_rest_in(inst, skel,
|
|
"mixamorig_RightHandIndex1" if right else "mixamorig_LeftHandIndex1")
|
|
var pky := _bone_rest_in(inst, skel,
|
|
"mixamorig_RightHandPinky1" if right else "mixamorig_LeftHandPinky1")
|
|
var mid := _bone_rest_in(inst, skel,
|
|
"mixamorig_RightHandMiddle1" if right else "mixamorig_LeftHandMiddle1")
|
|
var hb := hand.basis.orthonormalized().inverse()
|
|
var bore := hb * (idx.origin - pky.origin).normalized()
|
|
var farm := hb * (hand.origin - fore.origin).normalized()
|
|
# The hand BONE sits at the wrist, but a handle is held at the knuckles — seat the
|
|
# wrist on the shaft and the shaft ends up running past the fist instead of through
|
|
# it. Middle-knuckle offset is the correction.
|
|
var palm := hb * (mid.origin - hand.origin)
|
|
if right:
|
|
_bore_local_r = bore
|
|
_forearm_local_r = farm
|
|
_palm_local_r = palm
|
|
else:
|
|
_bore_local_l = bore
|
|
_forearm_local_l = farm
|
|
_palm_local_l = palm
|
|
|
|
## Two fixes to the donor asset.
|
|
##
|
|
## Sleeve: the Kachujin source is a fantasy warrior — red leather bracer, cross-lacing.
|
|
## Wrong game. Replaced with a flat staff-tee colour.
|
|
##
|
|
## Hands: keep the skin texture (it's the good part of the donor) but kill the shine.
|
|
## These meshes ship spec/gloss maps that Godot reads as metallic, which renders a hand
|
|
## as polished bronze. Forcing metallic off and roughness up makes it skin again.
|
|
func _restyle(mi: MeshInstance3D) -> void:
|
|
if mi.mesh == null:
|
|
return
|
|
if mi.name.begins_with("arms_sleeve"):
|
|
for s in mi.mesh.get_surface_count():
|
|
var m := StandardMaterial3D.new()
|
|
m.albedo_color = Color(0.15, 0.15, 0.19)
|
|
m.roughness = 0.92
|
|
m.metallic = 0.0
|
|
mi.set_surface_override_material(s, m)
|
|
return
|
|
for s in mi.mesh.get_surface_count():
|
|
var src := mi.mesh.surface_get_material(s) as BaseMaterial3D
|
|
if src == null:
|
|
continue
|
|
var d := src.duplicate() as BaseMaterial3D
|
|
d.metallic = 0.0
|
|
d.metallic_texture = null
|
|
d.roughness = 0.82
|
|
d.roughness_texture = null
|
|
mi.set_surface_override_material(s, d)
|
|
|
|
func _find_skeleton(n: Node) -> Skeleton3D:
|
|
if n is Skeleton3D:
|
|
return n
|
|
for c in n.get_children():
|
|
var r := _find_skeleton(c)
|
|
if r != null:
|
|
return r
|
|
return null
|
|
|
|
func _mesh_nodes(n: Node, acc: Array = []) -> Array:
|
|
if n is MeshInstance3D:
|
|
acc.append(n)
|
|
for c in n.get_children():
|
|
_mesh_nodes(c, acc)
|
|
return acc
|
|
|
|
## A bone's rest transform expressed in `root`'s local space, so we can invert it to
|
|
## work out where `root` has to sit for that bone to land on a target.
|
|
func _bone_rest_in(root: Node3D, skel: Skeleton3D, bone: String) -> Transform3D:
|
|
if skel == null:
|
|
return Transform3D.IDENTITY
|
|
var idx := skel.find_bone(bone)
|
|
if idx < 0:
|
|
push_warning("[viewmodel] bone %s not found" % bone)
|
|
return Transform3D.IDENTITY
|
|
var skel_in_root := root.global_transform.affine_inverse() * skel.global_transform
|
|
return skel_in_root * skel.get_bone_global_rest(idx)
|
|
|
|
# ---------------------------------------------------------------- weapon
|
|
func equip(w: Weapon) -> void:
|
|
_weapon = w
|
|
if _weapon_node != null:
|
|
_weapon_node.queue_free()
|
|
_weapon_node = null
|
|
if w != null and w.mesh_path != "" and ResourceLoader.exists(w.mesh_path):
|
|
var packed := load(w.mesh_path) as PackedScene
|
|
if packed != null:
|
|
_weapon_node = packed.instantiate()
|
|
_grip.add_child(_weapon_node)
|
|
for mi in _mesh_nodes(_weapon_node):
|
|
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
_pose_hands()
|
|
|
|
## Start a swap: the current weapon lowers, then `w` is equipped and raised.
|
|
func request_swap(w: Weapon) -> void:
|
|
_pending = w
|
|
_swap_t = 0.0
|
|
|
|
func current() -> Weapon:
|
|
return _weapon
|
|
|
|
# ---------------------------------------------------------------- posing
|
|
func _pose_hands() -> void:
|
|
var g: Dictionary = GRIPS.get(_weapon.id if _weapon else "fists", GRIPS["fists"])
|
|
var curl: float = float(g["curl"])
|
|
var thumb: float = float(g["thumb"])
|
|
var one_hand: bool = bool(g["one_hand"])
|
|
|
|
_place_hand(_arm_r, _hand_in_arm_r, float(g["r_up"]), true)
|
|
_curl_fingers(_skel_r, FINGERS_R, THUMB_R, curl, thumb)
|
|
|
|
# a one-handed weapon still shows the off hand, just idling out of the way
|
|
var l_up: float = float(g["l_up"])
|
|
if _arm_l != null:
|
|
_arm_l.visible = true
|
|
if one_hand:
|
|
_place_off_hand(_arm_l, _hand_in_arm_l, false)
|
|
_curl_fingers(_skel_l, FINGERS_L, THUMB_L, 0.62, 0.55)
|
|
else:
|
|
_place_hand(_arm_l, _hand_in_arm_l, l_up, false)
|
|
_curl_fingers(_skel_l, FINGERS_L, THUMB_L, curl, thumb)
|
|
|
|
## Put `arm`'s fist on the weapon shaft, `up` metres along it from the grip.
|
|
##
|
|
## Two aims, solved together: the fist's bore lines up with the shaft, and the forearm
|
|
## runs back toward where that shoulder would be. Building a frame from each pair and
|
|
## mapping one onto the other satisfies both at once — no euler tuning, and it survives
|
|
## any change to `grip_rest_rot`.
|
|
func _place_hand(arm: Node3D, hand_rest: Transform3D, up: float, right: bool) -> void:
|
|
if arm == null:
|
|
return
|
|
var shaft := _grip.transform.basis.y.normalized()
|
|
var forearm := FOREARM_DIR
|
|
if not right:
|
|
forearm.x = -forearm.x
|
|
var b_local := _bore_local_r if right else _bore_local_l
|
|
var f_local := _forearm_local_r if right else _forearm_local_l
|
|
var basis := _frame(shaft, forearm.normalized()) * _frame(b_local, f_local).inverse()
|
|
var palm := _palm_local_r if right else _palm_local_l
|
|
# aim the KNUCKLES at the shaft, not the wrist
|
|
var pos := _grip.transform * Vector3(0.0, up, 0.0) - basis * palm
|
|
_seat(arm, hand_rest, Transform3D(basis, pos))
|
|
|
|
## The idle off hand: a loose fist down and out of the sight line.
|
|
func _place_off_hand(arm: Node3D, hand_rest: Transform3D, right: bool) -> void:
|
|
if arm == null:
|
|
return
|
|
var forearm := FOREARM_DIR
|
|
if not right:
|
|
forearm.x = -forearm.x
|
|
var shaft := IDLE_OFF_SHAFT
|
|
if not right:
|
|
shaft.x = -shaft.x
|
|
var b_local := _bore_local_r if right else _bore_local_l
|
|
var f_local := _forearm_local_r if right else _forearm_local_l
|
|
var basis := _frame(shaft.normalized(), forearm.normalized()) \
|
|
* _frame(b_local, f_local).inverse()
|
|
var pos := IDLE_OFF_POS
|
|
if right:
|
|
pos.x = -pos.x
|
|
pos -= basis * (_palm_local_r if right else _palm_local_l)
|
|
_seat(arm, hand_rest, Transform3D(basis, pos))
|
|
|
|
## An orthonormal basis whose X is `a` and whose Y is `b` made perpendicular to it.
|
|
## Used on both sides of the mapping, so only the RELATIVE geometry of the two
|
|
## directions matters — which is exactly the constraint a grip expresses.
|
|
func _frame(a: Vector3, b: Vector3) -> Basis:
|
|
var x := a.normalized()
|
|
var y := b - x * b.dot(x)
|
|
if y.length() < 1e-4: # degenerate: b parallel to a
|
|
y = x.cross(Vector3.UP)
|
|
if y.length() < 1e-4:
|
|
y = x.cross(Vector3.RIGHT)
|
|
y = y.normalized()
|
|
return Basis(x, y, x.cross(y))
|
|
|
|
## Move `arm` so its hand bone lands exactly on `target` (both in _rig space).
|
|
##
|
|
## `hand_rest` carries the GLB's internal 0.01 scale in its basis, so inverting it raw
|
|
## would scale the whole arm by 100. Orthonormalising first keeps the placement rigid:
|
|
## the arm stays life-size and only its position/orientation change.
|
|
func _seat(arm: Node3D, hand_rest: Transform3D, target: Transform3D) -> void:
|
|
var rigid := hand_rest
|
|
rigid.basis = rigid.basis.orthonormalized()
|
|
arm.transform = target * rigid.affine_inverse()
|
|
|
|
## Curl every finger joint by a fraction of its comfortable range. Mixamo finger bones
|
|
## run +Y down the bone and flex about local Z, so one rotation per joint is all it takes.
|
|
func _curl_fingers(skel: Skeleton3D, prefixes: Array, thumb_prefix: String,
|
|
curl: float, thumb: float) -> void:
|
|
if skel == null:
|
|
return
|
|
const JOINT_DEG := [58.0, 62.0, 48.0] # proximal, middle, distal
|
|
for pre in prefixes:
|
|
for j in range(3):
|
|
var idx := skel.find_bone("%s%d" % [pre, j + 1])
|
|
if idx < 0:
|
|
continue
|
|
skel.set_bone_pose_rotation(idx, Quaternion(Vector3(0, 0, 1),
|
|
deg_to_rad(JOINT_DEG[j] * curl)))
|
|
const THUMB_DEG := [42.0, 46.0, 34.0]
|
|
for j in range(3):
|
|
var idx := skel.find_bone("%s%d" % [thumb_prefix, j + 1])
|
|
if idx < 0:
|
|
continue
|
|
skel.set_bone_pose_rotation(idx, Quaternion(Vector3(0, 0, 1),
|
|
deg_to_rad(THUMB_DEG[j] * thumb)))
|
|
|
|
# ---------------------------------------------------------------- driving
|
|
## Player calls this every frame with its state.
|
|
func drive(look_delta: Vector2, planar_speed: float, on_floor: bool, dt: float) -> void:
|
|
var heft: float = _weapon.heft if _weapon != null else 1.0
|
|
|
|
# --- sway: the rig lags the camera, then springs back ---
|
|
var target := Vector2(clampf(-look_delta.x, -1.0, 1.0), clampf(-look_delta.y, -1.0, 1.0))
|
|
var stiffness := 42.0 / maxf(heft, 0.3)
|
|
_sway_vel += (target * 0.030 - _sway) * stiffness * dt
|
|
_sway_vel *= exp(-9.0 * dt)
|
|
_sway += _sway_vel
|
|
|
|
# --- bob: figure-8, amplitude from speed, slowed by a heavy weapon ---
|
|
var speed01 := clampf(planar_speed / 5.0, 0.0, 1.4)
|
|
_bob_t += dt * (7.4 + speed01 * 2.2) * (1.0 if on_floor else 0.0)
|
|
var bob_amp := speed01 * 0.022 * (1.0 if on_floor else 0.0)
|
|
var bob := Vector3(sin(_bob_t) * bob_amp,
|
|
-absf(cos(_bob_t)) * bob_amp * 1.15, 0.0)
|
|
|
|
_breathe_t += dt * 1.15
|
|
var breathe := Vector3(sin(_breathe_t * 0.7) * 0.0035,
|
|
sin(_breathe_t) * 0.0042, 0.0)
|
|
|
|
_dip = move_toward(_dip, 0.0, dt * 0.9)
|
|
|
|
# --- compose ---
|
|
var pos := Vector3(_sway.x, _sway.y - _dip, 0.0) + bob + breathe
|
|
var rot := Vector3(-_sway.y * 5.4, _sway.x * 6.2, -_sway.x * 8.0)
|
|
rot.x += sin(_bob_t) * speed01 * 1.1
|
|
|
|
if _swing_t >= 0.0:
|
|
var st: float = _weapon.swing_time if _weapon != null else 0.4
|
|
_swing_t += dt
|
|
var frac := clampf(_swing_t / maxf(st, 0.01), 0.0, 1.0)
|
|
var kind: String = _weapon.swing if _weapon != null else "jab"
|
|
var kf: Array = SWINGS.get(kind, SWINGS["jab"])
|
|
var s := _sample(kf, frac)
|
|
pos += s[0]
|
|
rot += s[1]
|
|
if _swing_t >= st:
|
|
_swing_t = -1.0
|
|
|
|
if _swap_t >= 0.0:
|
|
_swap_t += dt
|
|
const SWAP_DOWN := 0.14
|
|
const SWAP_UP := 0.20
|
|
if _swap_t < SWAP_DOWN:
|
|
var k := _swap_t / SWAP_DOWN
|
|
pos += Vector3(0, -0.34 * k, 0)
|
|
rot += Vector3(46.0 * k, 0, 0)
|
|
elif _pending != null:
|
|
equip(_pending) # swap at the bottom of the arc
|
|
_pending = null
|
|
pos += Vector3(0, -0.34, 0)
|
|
rot += Vector3(46.0, 0, 0)
|
|
else:
|
|
var k := clampf((_swap_t - SWAP_DOWN) / SWAP_UP, 0.0, 1.0)
|
|
var e := 1.0 - (1.0 - k) * (1.0 - k)
|
|
pos += Vector3(0, -0.34 * (1.0 - e), 0)
|
|
rot += Vector3(46.0 * (1.0 - e), 0, 0)
|
|
if _swap_t >= SWAP_DOWN + SWAP_UP:
|
|
_swap_t = -1.0
|
|
|
|
_rig.position = pos
|
|
_rig.rotation = Vector3(deg_to_rad(rot.x), deg_to_rad(rot.y), deg_to_rad(rot.z))
|
|
|
|
## Interpolate a keyframe list at 0..1. Returns [position, rotation_degrees].
|
|
func _sample(kf: Array, t: float) -> Array:
|
|
for i in range(kf.size() - 1):
|
|
var a: Array = kf[i]
|
|
var b: Array = kf[i + 1]
|
|
if t >= float(a[0]) and t <= float(b[0]):
|
|
var span: float = maxf(float(b[0]) - float(a[0]), 0.0001)
|
|
var k: float = (t - float(a[0])) / span
|
|
k = k * k * (3.0 - 2.0 * k) # smoothstep
|
|
return [(a[1] as Vector3).lerp(b[1] as Vector3, k),
|
|
(a[2] as Vector3).lerp(b[2] as Vector3, k)]
|
|
var last: Array = kf[kf.size() - 1]
|
|
return [last[1], last[2]]
|
|
|
|
# ---------------------------------------------------------------- events
|
|
func start_swing() -> void:
|
|
_swing_t = 0.0
|
|
|
|
func is_swinging() -> bool:
|
|
return _swing_t >= 0.0
|
|
|
|
func is_swapping() -> bool:
|
|
return _swap_t >= 0.0
|
|
|
|
func land(force: float) -> void:
|
|
_dip = clampf(_dip + force * 0.06, 0.0, 0.11)
|