Files
Papay-Shooter/weapons/viewmodel_anim.gd
T
Nicholas ButzkeandClaude Fable 5 df0bf18e0f 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]>
2026-07-21 00:26:29 -04:00

306 lines
13 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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