feat: real per-weapon reload/recoil choreography + knife slash, fully matte hair

Weapons no longer reload by spinning the gun 360 (that was the upside-down
flip; nothing detached). New ViewmodelAnim module drives per-style manual-of-
arms sequences on the existing model_root + named arm pivots, with a temporary
magazine/shell/rocket/cell prop that visibly leaves and returns to the gun:

  - mag     (M4/AK/MP7/DMR): roll gun to present the well, hand rips the mag
            down and away, fresh mag up, seat, charge. Gun stays UPRIGHT.
  - boltmag (AWP): mag swap + a distinct bolt-cycle rock.
  - break   (double barrel): hinge open, flick both shells out, drop fresh
            shells in, snap shut.
  - tube    (rocket launcher): tip the tube in, shove a rocket home, shoulder.
  - cell    (plasma/nail gun): glowing energy cell swapped on the side.

Per-shot viewmodel recoil kick (ViewmodelAnim.apply_kick) scaled by each
weapon's recoil_amplitude, so an AWP slams and an MP7 chatters — replaces the
old flat model behaviour. Weapons expose reload_style; base classes call
play_reload / stop (on unequip) / apply_kick.

Knife: the old barely-visible flick is now a real diagonal slash that
alternates backhand/forehand, whips the blade through screen centre, and
fires the third-person melee arm-swing action (synced to other players).

Hair/materials: specular fully to 0 (even a 2% stepped glint swept as shine
across Taila's hair when the camera moved); rim trimmed to a whisper. Fully
matte cloth/hair now.

Verify: debug/fp_weapon_capture.gd screenshots each weapon's reload phases,
the knife slash, and the fire kick.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-21 00:26:29 -04:00
co-authored by Claude Fable 5
parent d46530bd8c
commit df0bf18e0f
16 changed files with 590 additions and 76 deletions
+305
View File
@@ -0,0 +1,305 @@
extends Object
class_name ViewmodelAnim
## Procedural first-person weapon choreography.
##
## Replaces the old "spin the gun 360° to reload" placeholder with per-style
## reload sequences that read like the real manual of arms, plus a per-shot
## viewmodel recoil kick. Everything is tween-driven on nodes the weapon
## already has: `model_root` (the gun) and the named arm pivots ("ArmL")
## built by WeaponManager, plus temporary prop meshes (magazine, shells,
## rocket) spawned for the duration.
##
## Styles:
## "mag" box-mag rifles/SMGs: tilt gun, left hand pulls the mag,
## discards it, brings a fresh one, seats it, charges the bolt.
## "boltmag" same, then a distinct bolt-cycle rock (AWP/DMR).
## "break" break-action: gun hinges open, shells eject, reload, snap shut.
## "tube" launchers: gun lowers, a rocket/shell is shoved into the tube.
## "cell" energy weapons: glowing cell swapped on the side.
## "none" no animation (ammo just refills).
##
## All state (original transforms, spawned props, the tween) is stored on the
## weapon via metadata and torn down by `stop()` — called on finish, unequip,
## or a new reload starting.
const META_TWEEN := "vm_reload_tween"
const META_PROPS := "vm_reload_props"
const META_ARM_L_XFORM := "vm_arm_l_xform"
static func play_reload(w: Node3D, style: String, T: float) -> void:
if style == "none" or not is_instance_valid(w) or not ("model_root" in w):
return
stop(w) # clean slate; also restores transforms from any prior run
var root: Node3D = w.model_root
var arm_l: Node3D = w.get_node_or_null("ArmL")
if arm_l and not w.has_meta(META_ARM_L_XFORM):
w.set_meta(META_ARM_L_XFORM, arm_l.transform)
var tw := w.create_tween()
w.set_meta(META_TWEEN, tw)
match style:
"boltmag":
_mag_sequence(w, root, arm_l, tw, T, true)
"break":
_break_sequence(w, root, arm_l, tw, T)
"tube":
_tube_sequence(w, root, arm_l, tw, T)
"cell":
_cell_sequence(w, root, arm_l, tw, T)
_:
_mag_sequence(w, root, arm_l, tw, T, false)
tw.tween_callback(func(): stop(w))
## Kill the running choreography and restore the resting pose.
static func stop(w: Node3D) -> void:
if not is_instance_valid(w):
return
if w.has_meta(META_TWEEN):
var tw = w.get_meta(META_TWEEN)
if tw is Tween and tw.is_valid():
tw.kill()
w.remove_meta(META_TWEEN)
if w.has_meta(META_PROPS):
for p in w.get_meta(META_PROPS):
if is_instance_valid(p):
p.queue_free()
w.remove_meta(META_PROPS)
if "model_root" in w and is_instance_valid(w.model_root):
w.model_root.transform = Transform3D.IDENTITY
var arm_l: Node3D = w.get_node_or_null("ArmL")
if arm_l and w.has_meta(META_ARM_L_XFORM):
arm_l.transform = w.get_meta(META_ARM_L_XFORM)
## Per-shot viewmodel kick: the gun slides back into the grip and the muzzle
## flips up, scaled by the weapon's recoil so every gun has its own punch.
## Weapons call this from _process with their decaying kick value 0..1.
static func apply_kick(w: Node3D, kick: float, amplitude: float) -> void:
if not ("model_root" in w) or not is_instance_valid(w.model_root):
return
var root: Node3D = w.model_root
root.position.z = kick * amplitude * 2.2
root.rotation.x = kick * amplitude * 5.0
# ── Sequences ────────────────────────────────────────────────────────────────
# Times are fractions of the weapon's reload_time so a 1.6 s M4 mag swap and
# a 3 s AWP reload both fill their whole window.
static func _mag_sequence(w: Node3D, root: Node3D, arm_l: Node3D,
tw: Tween, T: float, bolt_cycle: bool) -> void:
# Mag prop rides the left hand. Sits at the gun's mag well when stowed.
var mag := _make_prop(w, arm_l, Vector3(0.07, 0.22, 0.11),
Color(0.42, 0.44, 0.50), Vector3(0.02, -0.30, -0.30))
var mag_t := 0.55 if bolt_cycle else 0.78 # leave room for the bolt rock
# 1. Bring the gun UP into view and roll it so the mag well faces the
# player — real reloads happen at chest height, gun clearly on screen.
tw.tween_property(root, "rotation", Vector3(0.05, 0.15, 0.45), 0.12 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3(-0.10, 0.16, 0.18), 0.12 * T)
# Left hand leaves the foregrip for the mag well.
if arm_l:
tw.parallel().tween_method(_aim_arm.bind(arm_l),
Vector3(-0.02, -0.02, -0.3), Vector3(0.02, -0.10, -0.34), 0.12 * T)
# 2. Mag out: hand rips it down and back (mag visibly leaves the gun).
tw.tween_callback(func(): mag.visible = true)
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.02, -0.10, -0.34), Vector3(-0.12, -0.40, -0.14), 0.18 * mag_t * T) \
.set_ease(Tween.EASE_IN_OUT)
# 3. Discard, grab fresh mag low off-screen.
tw.tween_callback(func(): mag.visible = false)
tw.tween_interval(0.14 * mag_t * T)
tw.tween_callback(func(): mag.visible = true)
# 4. Fresh mag back up to the well.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.12, -0.40, -0.14), Vector3(0.02, -0.10, -0.34), 0.22 * mag_t * T) \
.set_ease(Tween.EASE_IN_OUT)
# 5. Seat it (small upward jolt on the gun), hide the prop.
tw.tween_property(root, "position", Vector3(-0.10, 0.19, 0.18), 0.05 * T) \
.set_ease(Tween.EASE_OUT)
tw.tween_callback(func(): mag.visible = false)
tw.tween_property(root, "position", Vector3(-0.10, 0.16, 0.18), 0.06 * T)
# 6. Hand back to the foregrip while the gun levels out.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.0, -0.06, -0.34), Vector3(-0.02, -0.02, -0.3), 0.12 * T)
tw.parallel().tween_property(root, "rotation", Vector3.ZERO, 0.12 * T)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.12 * T)
# 7. Charge: sharp pull back, snap forward. Bolt guns rock the whole gun.
if bolt_cycle:
tw.tween_property(root, "rotation", Vector3(-0.14, 0.0, -0.08), 0.10 * T)
tw.parallel().tween_property(root, "position", Vector3(0, 0.01, 0.10), 0.10 * T)
tw.tween_interval(0.06 * T)
tw.tween_property(root, "rotation", Vector3.ZERO, 0.08 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.08 * T)
else:
tw.tween_property(root, "position", Vector3(0, 0, 0.06), 0.06 * T)
tw.tween_property(root, "position", Vector3.ZERO, 0.05 * T) \
.set_ease(Tween.EASE_OUT)
static func _break_sequence(w: Node3D, root: Node3D, arm_l: Node3D,
tw: Tween, T: float) -> void:
var shell_a := _make_prop(w, root, Vector3(0.028, 0.028, 0.07),
Color(0.75, 0.25, 0.15), Vector3(-0.01, 0.03, -0.25))
var shell_b := _make_prop(w, root, Vector3(0.028, 0.028, 0.07),
Color(0.75, 0.25, 0.15), Vector3(0.02, 0.03, -0.25))
# Hinge open — barrels drop forward-down, breech rises into view — and
# flick the spent shells up and back.
tw.tween_property(root, "rotation", Vector3(-0.42, 0.15, 0.06), 0.18 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3(-0.10, 0.10, 0.16), 0.18 * T)
tw.tween_callback(func():
shell_a.visible = true
shell_b.visible = true)
tw.tween_property(shell_a, "position", shell_a.position + Vector3(-0.06, 0.28, 0.25), 0.2 * T)
tw.parallel().tween_property(shell_b, "position", shell_b.position + Vector3(0.08, 0.24, 0.28), 0.2 * T)
tw.parallel().tween_property(shell_a, "rotation", Vector3(2.5, 0, 1.0), 0.2 * T)
tw.parallel().tween_property(shell_b, "rotation", Vector3(2.1, 0, -1.2), 0.2 * T)
tw.tween_callback(func():
shell_a.visible = false
shell_b.visible = false
shell_a.position = Vector3(-0.01, 0.10, -0.25)
shell_b.position = Vector3(0.02, 0.10, -0.25)
shell_a.rotation = Vector3.ZERO
shell_b.rotation = Vector3.ZERO)
# Left hand brings two fresh shells and drops them in.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.02, -0.02, -0.3), Vector3(-0.04, -0.5, -0.1), 0.14 * T)
tw.tween_callback(func():
shell_a.visible = true
shell_b.visible = true)
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.04, -0.5, -0.1), Vector3(0.0, -0.06, -0.3), 0.16 * T)
tw.tween_property(shell_a, "position", Vector3(-0.01, 0.03, -0.25), 0.12 * T)
tw.parallel().tween_property(shell_b, "position", Vector3(0.02, 0.03, -0.25), 0.12 * T)
tw.tween_callback(func():
shell_a.visible = false
shell_b.visible = false)
# Snap shut with a little overshoot.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.0, -0.06, -0.3), Vector3(-0.02, -0.02, -0.3), 0.1 * T)
tw.parallel().tween_property(root, "rotation", Vector3(0.06, 0.0, 0.0), 0.10 * T) \
.set_ease(Tween.EASE_IN)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.10 * T)
tw.tween_property(root, "rotation", Vector3.ZERO, 0.06 * T) \
.set_ease(Tween.EASE_OUT)
static func _tube_sequence(w: Node3D, root: Node3D, arm_l: Node3D,
tw: Tween, T: float) -> void:
var rocket := _make_prop(w, arm_l if arm_l else root,
Vector3(0.045, 0.045, 0.30), Color(0.35, 0.55, 0.35),
Vector3(0, -0.1, -0.58))
# Bring the tube up across the chest and tip it toward the player.
tw.tween_property(root, "rotation", Vector3(0.28, 0.35, 0.0), 0.18 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3(-0.10, 0.06, 0.16), 0.18 * T)
# Left hand fetches a fresh rocket from below…
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.02, -0.02, -0.3), Vector3(-0.08, -0.55, -0.05), 0.16 * T)
tw.tween_callback(func(): rocket.visible = true)
# …lines it up with the muzzle and shoves it home.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.08, -0.55, -0.05), Vector3(0.0, -0.12, -0.55), 0.24 * T) \
.set_ease(Tween.EASE_IN_OUT)
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.0, -0.12, -0.55), Vector3(0.0, -0.05, -0.30), 0.16 * T) \
.set_ease(Tween.EASE_IN)
tw.tween_callback(func(): rocket.visible = false)
# Shoulder it again.
tw.tween_property(root, "rotation", Vector3.ZERO, 0.16 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.16 * T)
if arm_l:
tw.parallel().tween_method(_aim_arm.bind(arm_l),
Vector3(0.0, -0.05, -0.30), Vector3(-0.02, -0.02, -0.3), 0.14 * T)
static func _cell_sequence(w: Node3D, root: Node3D, arm_l: Node3D,
tw: Tween, T: float) -> void:
var cell := _make_prop(w, arm_l if arm_l else root,
Vector3(0.05, 0.09, 0.05), Color(0.2, 0.95, 0.9),
Vector3(0, -0.1, -0.58), true)
# Raise across the chest and quarter-roll so the cell port faces up.
tw.tween_property(root, "rotation", Vector3(0.08, 0.2, -0.35), 0.14 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3(-0.10, 0.08, 0.14), 0.14 * T)
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.02, -0.02, -0.3), Vector3(0.03, -0.10, -0.30), 0.12 * T)
tw.tween_callback(func(): cell.visible = true)
# Pull the spent cell, flick it away, slot a fresh one.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.03, -0.10, -0.30), Vector3(-0.10, -0.45, -0.12), 0.2 * T)
tw.tween_callback(func(): cell.visible = false)
tw.tween_interval(0.12 * T)
tw.tween_callback(func(): cell.visible = true)
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.10, -0.45, -0.12), Vector3(0.03, -0.10, -0.30), 0.2 * T)
tw.tween_callback(func(): cell.visible = false)
tw.tween_property(root, "rotation", Vector3.ZERO, 0.14 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.14 * T)
if arm_l:
tw.parallel().tween_method(_aim_arm.bind(arm_l),
Vector3(0.03, -0.10, -0.30), Vector3(-0.02, -0.02, -0.3), 0.14 * T)
# ── Helpers ──────────────────────────────────────────────────────────────────
## Re-aims an arm pivot (shoulder stays put, hand tracks `target` in weapon
## space) — same math WeaponManager used to place the arm initially.
static func _aim_arm(target: Vector3, arm: Node3D) -> void:
if not is_instance_valid(arm):
return
var shoulder := arm.position
var dir := target - shoulder
if dir.length_squared() < 0.0001:
return
arm.transform = Transform3D(Basis.looking_at(dir.normalized(), Vector3.UP), shoulder)
## An invisible-until-needed prop (mag/shell/rocket/cell) parented so it moves
## with the left hand or the gun. Registered for cleanup on stop().
static func _make_prop(w: Node3D, parent: Node3D, size: Vector3, color: Color,
pos: Vector3, emissive: bool = false) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = size
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.roughness = 0.9
if emissive:
mat.emission_enabled = true
mat.emission = color
mat.emission_energy_multiplier = 1.6
mesh.material = mat
mi.mesh = mesh
mi.position = pos
mi.visible = false
mi.layers = 1 << 19 # viewmodel layer, same as the rest of the gun
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
(parent if parent else w).add_child(mi)
var props: Array = w.get_meta(META_PROPS) if w.has_meta(META_PROPS) else []
props.append(mi)
w.set_meta(META_PROPS, props)
return mi