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)