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]>
306 lines
13 KiB
GDScript
306 lines
13 KiB
GDScript
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
|