Files
Papay-Shooter/characters/humanoid_model.gd
T
Nicholas ButzkeandClaude Opus 4.8 ec6b8228da fix: cut the welded ankle band, seat the gun in the hand, flash at the barrel
1. Ankle cuffs. The previous pass re-weighted the offending vertices to the
   nearer leg, which was the wrong call: the strip between the ankles is REAL
   geometry spanning the gap (~10 cm of ClothCAndW crossing x = -0.05 .. +0.05
   at ankle height), so re-weighting only tore it in half — a visible seam that
   still stretched. A triangle with one corner weighted to each leg has no
   correct pose; it must stretch the moment the legs separate. SkinMeshRepair
   now deletes those triangles instead (48 on Taila: the cloth band plus its
   outline shell). The cut is limited to BELOW THE KNEE, taken from the
   skeleton's own rest pose rather than a hardcoded height, because above the
   knee cross-leg geometry is legitimate — the skirt and shorts genuinely span
   left-thigh to right-thigh weights at the crotch.

2. The M4 floated because _measure_weapon derived the grip from mesh AABBs, and
   the FBX guns report bind-pose bounds tens of metres across — it measured the
   M4 as 24 m long and pushed the gun 7.5 m in front of the character. Bounding
   boxes are simply not trustworthy for these meshes. The grip was already
   authored elsewhere: WeaponManager places the first-person viewmodel's hands
   at fixed points in weapon space, and every weapon marks its barrel tip with
   muzzle_flash.position. Those move to WeaponGrips (dependency-free, so both
   the weapon system and the character models can use it without dragging each
   other's load order along) and third person now reaches for exactly the
   points the viewmodel uses. Every weapon's grip now lands 0.073 m from the
   hand — the M4 included, down from 2.32 m — with barrel lengths that match
   the models (0.30 m for the MP7, 0.68 m for the DMR).

3. Muzzle flash and tracers were spawned off the viewmodel's muzzle. The
   viewmodel is parented to the camera, so its muzzle sits inside the player's
   head — in third person the flash appeared by the character's shoulder.
   world_muzzle() returns the muzzle of the gun actually in the character's
   hands whenever the character is what the viewer sees, and the networked
   fire-effect RPC now sends that position too, so remote players stop seeing
   tracers leave the shooter's head. Measured: the third-person origin sits at
   the held gun, 0.54 m below the head, instead of on the camera.

FSM tests 11/11, spawn smoke test 0 failures.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 19:16:22 -04:00

532 lines
17 KiB
GDScript

extends Node3D
class_name HumanoidModel
@export var color: Color = Color.WHITE
@export var shadows_only: bool = false
# Internal Pivots
var root_pivot: Node3D
var torso: MeshInstance3D
var head_pivot: Node3D
var head: MeshInstance3D
var upper_arm_l_pivot: Node3D
var upper_arm_l: MeshInstance3D
var lower_arm_l_pivot: Node3D
var lower_arm_l: MeshInstance3D
var upper_arm_r_pivot: Node3D
var upper_arm_r: MeshInstance3D
var lower_arm_r_pivot: Node3D
var lower_arm_r: MeshInstance3D
var hand_r_pivot: Node3D
var thigh_l_pivot: Node3D
var thigh_l: MeshInstance3D
var calf_l_pivot: Node3D
var calf_l: MeshInstance3D
var thigh_r_pivot: Node3D
var thigh_r: MeshInstance3D
var calf_r_pivot: Node3D
var calf_r: MeshInstance3D
# Animation State Variables
var current_state: String = "idle"
var movement_speed: float = 0.0
var _anim_time: float = 0.0
func _ready() -> void:
rotation_degrees.y = 180
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.roughness = 0.8
var shadow_setting = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY if shadows_only else GeometryInstance3D.SHADOW_CASTING_SETTING_ON
root_pivot = Node3D.new()
add_child(root_pivot)
# Torso
torso = MeshInstance3D.new()
var t_mesh = BoxMesh.new()
t_mesh.size = Vector3(0.4, 0.7, 0.25)
t_mesh.material = mat
torso.mesh = t_mesh
torso.position = Vector3(0, 1.15, 0)
torso.cast_shadow = shadow_setting
root_pivot.add_child(torso)
# Head
head_pivot = Node3D.new()
head_pivot.position = Vector3(0, 1.5, 0)
root_pivot.add_child(head_pivot)
head = MeshInstance3D.new()
var h_mesh = BoxMesh.new()
h_mesh.size = Vector3(0.25, 0.25, 0.25)
h_mesh.material = mat
head.mesh = h_mesh
head.position = Vector3(0, 0.15, 0) # Offset from neck
head.cast_shadow = shadow_setting
head_pivot.add_child(head)
# Arms (Upper: 0.35 length, Lower: 0.35 length)
var ua_mesh = BoxMesh.new()
ua_mesh.size = Vector3(0.12, 0.35, 0.12)
ua_mesh.material = mat
var la_mesh = BoxMesh.new()
la_mesh.size = Vector3(0.1, 0.35, 0.1)
la_mesh.material = mat
# Left Arm
upper_arm_l_pivot = Node3D.new()
upper_arm_l_pivot.position = Vector3(-0.28, 1.45, 0) # Shoulder
root_pivot.add_child(upper_arm_l_pivot)
upper_arm_l = MeshInstance3D.new()
upper_arm_l.mesh = ua_mesh
upper_arm_l.position = Vector3(0, -0.175, 0)
upper_arm_l.cast_shadow = shadow_setting
upper_arm_l_pivot.add_child(upper_arm_l)
lower_arm_l_pivot = Node3D.new()
lower_arm_l_pivot.position = Vector3(0, -0.35, 0) # Elbow
upper_arm_l_pivot.add_child(lower_arm_l_pivot)
lower_arm_l = MeshInstance3D.new()
lower_arm_l.mesh = la_mesh
lower_arm_l.position = Vector3(0, -0.175, 0)
lower_arm_l.cast_shadow = shadow_setting
lower_arm_l_pivot.add_child(lower_arm_l)
# Right Arm
upper_arm_r_pivot = Node3D.new()
upper_arm_r_pivot.position = Vector3(0.28, 1.45, 0) # Shoulder
root_pivot.add_child(upper_arm_r_pivot)
upper_arm_r = MeshInstance3D.new()
upper_arm_r.mesh = ua_mesh
upper_arm_r.position = Vector3(0, -0.175, 0)
upper_arm_r.cast_shadow = shadow_setting
upper_arm_r_pivot.add_child(upper_arm_r)
lower_arm_r_pivot = Node3D.new()
lower_arm_r_pivot.position = Vector3(0, -0.35, 0) # Elbow
upper_arm_r_pivot.add_child(lower_arm_r_pivot)
lower_arm_r = MeshInstance3D.new()
lower_arm_r.mesh = la_mesh
lower_arm_r.position = Vector3(0, -0.175, 0)
lower_arm_r.cast_shadow = shadow_setting
lower_arm_r_pivot.add_child(lower_arm_r)
# Right hand: the wrist joint at the far end of the forearm. The
# third-person weapon hangs here so it travels with the arm instead of
# floating in front of the chest.
hand_r_pivot = Node3D.new()
hand_r_pivot.position = Vector3(0, -0.35, 0)
lower_arm_r_pivot.add_child(hand_r_pivot)
# Legs (Thigh: 0.45 length, Calf: 0.45 length)
var t_leg_mesh = BoxMesh.new()
t_leg_mesh.size = Vector3(0.15, 0.45, 0.15)
t_leg_mesh.material = mat
var c_leg_mesh = BoxMesh.new()
c_leg_mesh.size = Vector3(0.13, 0.45, 0.13)
c_leg_mesh.material = mat
# Left Leg
thigh_l_pivot = Node3D.new()
thigh_l_pivot.position = Vector3(-0.12, 0.85, 0) # Hip (raised slightly for 0.45+0.45=0.9 total)
root_pivot.add_child(thigh_l_pivot)
thigh_l = MeshInstance3D.new()
thigh_l.mesh = t_leg_mesh
thigh_l.position = Vector3(0, -0.225, 0)
thigh_l.cast_shadow = shadow_setting
thigh_l_pivot.add_child(thigh_l)
calf_l_pivot = Node3D.new()
calf_l_pivot.position = Vector3(0, -0.45, 0) # Knee
thigh_l_pivot.add_child(calf_l_pivot)
calf_l = MeshInstance3D.new()
calf_l.mesh = c_leg_mesh
calf_l.position = Vector3(0, -0.225, 0)
calf_l.cast_shadow = shadow_setting
calf_l_pivot.add_child(calf_l)
# Right Leg
thigh_r_pivot = Node3D.new()
thigh_r_pivot.position = Vector3(0.12, 0.85, 0) # Hip
root_pivot.add_child(thigh_r_pivot)
thigh_r = MeshInstance3D.new()
thigh_r.mesh = t_leg_mesh
thigh_r.position = Vector3(0, -0.225, 0)
thigh_r.cast_shadow = shadow_setting
thigh_r_pivot.add_child(thigh_r)
calf_r_pivot = Node3D.new()
calf_r_pivot.position = Vector3(0, -0.45, 0) # Knee
thigh_r_pivot.add_child(calf_r_pivot)
calf_r = MeshInstance3D.new()
calf_r.mesh = c_leg_mesh
calf_r.position = Vector3(0, -0.225, 0)
calf_r.cast_shadow = shadow_setting
calf_r_pivot.add_child(calf_r)
# Cel-shade + ink outline the blocky fallback body so it matches skinned
# player models (shadows-only bodies skip it: outlines would cast shadows)
if not shadows_only:
LevelMaterials.apply_toon_recursive(root_pivot, 0.004)
## Skin System
var current_skin: PlayerSkin
var skin_model: Node3D
func apply_skin(skin: PlayerSkin) -> void:
current_skin = skin
if skin.model_path != "" and skin.model_path != null:
var loaded = load(skin.model_path)
if loaded:
_set_procedural_visible(false)
if skin_model:
skin_model.queue_free()
skin_model = null
skin_model = loaded.instantiate()
if skin_model:
skin_model.name = "SkinModel"
# Fix orientation: Blender Z-up to Godot Y-up
skin_model.rotation_degrees = Vector3(0, 0, 0)
# Scale down if needed (Blender models can be large)
skin_model.scale = Vector3(1, 1, 1)
add_child(skin_model)
print("HumanoidModel: applied skin '%s'" % skin.skin_name)
return
_set_procedural_visible(true)
if skin_model:
skin_model.queue_free()
skin_model = null
_apply_color(skin.color_tint)
func _set_procedural_visible(on: bool) -> void:
for part in [torso, head, upper_arm_l, lower_arm_l, upper_arm_r, lower_arm_r, thigh_l, calf_l, thigh_r, calf_r]:
if part:
part.visible = on
## Show/hide the model to its OWNER (third-person toggle). off = shadows-only so
## the local first-person camera doesn't see the body; on = fully visible.
func set_owner_visible(on: bool) -> void:
var mode := GeometryInstance3D.SHADOW_CASTING_SETTING_ON if on \
else GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
_set_owner_shadow_recursive(self, mode)
func _set_owner_shadow_recursive(node: Node, mode: int) -> void:
if node is GeometryInstance3D:
node.cast_shadow = mode
for child in node.get_children():
_set_owner_shadow_recursive(child, mode)
func _apply_color(col: Color) -> void:
var mat = StandardMaterial3D.new()
mat.albedo_color = col
mat.roughness = 0.8
for part in [torso, upper_arm_l, lower_arm_l, upper_arm_r, lower_arm_r, thigh_l, calf_l, thigh_r, calf_r]:
if part and part.mesh:
part.mesh.material = mat
if head and head.mesh:
var hmat = StandardMaterial3D.new()
hmat.albedo_color = Color(0.95, 0.85, 0.78)
hmat.roughness = 0.8
head.mesh.material = hmat
# Re-toon after the material swap (fresh StandardMaterials above)
if not shadows_only:
LevelMaterials.apply_toon_recursive(root_pivot, 0.004)
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
current_state = state
movement_speed = speed
if is_crouching and current_state == "ground":
current_state = "crouch"
var is_holding_weapon: bool = false
func set_weapon(script_path: String) -> void:
# Clear existing weapons (older builds parented them to root_pivot)
for parent in [root_pivot, hand_r_pivot]:
if not parent:
continue
for child in parent.get_children():
if child.has_meta("is_third_person_weapon"):
child.queue_free()
if script_path == "":
is_holding_weapon = false
return
is_holding_weapon = true
var script = load(script_path)
if not script:
return
var w = script.new()
w.name = "ThirdPersonWeapon"
w.set_meta("is_third_person_weapon", true)
# Connect to ready so we can override the weapon's own _ready() calls
w.ready.connect(func():
w.set_process(false)
w.set_process_input(false)
if shadows_only:
_set_shadows_recursive(w)
else:
# Toon shading only — FBX weapon normals tear inverted-hull outlines
LevelMaterials.apply_toon_recursive(w, 0.0)
# Undo the first-person viewmodel placement from _build_model()
_seat_weapon_in_hand(w)
)
# Riding the hand keeps the gun with the arm; _process re-aims it forward.
(hand_r_pivot if hand_r_pivot else root_pivot).add_child(w)
## The muzzle of the gun actually in this character's hand — see
## SkinnedPlayerModel.get_muzzle_node() for why world effects must use it.
func get_muzzle_node() -> Node3D:
var holder := hand_r_pivot if hand_r_pivot else root_pivot
if not holder:
return null
for child in holder.get_children():
if child.has_meta("is_third_person_weapon"):
if "muzzle_flash" in child and child.muzzle_flash:
return child.muzzle_flash
return child as Node3D
return null
## Slide the weapon along its own barrel axis so a plausible grip point — not
## whatever origin the artist left the model at — ends up in the fist, and turn
## it to face the way the body faces (the model is yawed 180° in _ready, so the
## gun's -Z muzzle axis has to point along the model's local +Z).
func _seat_weapon_in_hand(w: Node3D) -> void:
w.rotation_degrees = Vector3(0, 180, 0)
w.position = Vector3.ZERO
var min_t := INF # most negative along the barrel = stock end
var max_t := -INF # most positive = muzzle end
for mi in w.find_children("*", "MeshInstance3D", true, false):
if not mi.mesh:
continue
# mi.get_aabb(), NOT mi.mesh.get_aabb(): the FBX gun parts are skinned and
# the Mesh resource still carries huge bind-pose bounds.
var xf: Transform3D = w.global_transform.affine_inverse() * mi.global_transform
var aabb: AABB = mi.get_aabb()
for i in 8:
var t: float = (xf * aabb.get_endpoint(i)).dot(Vector3(0, 0, -1))
min_t = minf(min_t, t)
max_t = maxf(max_t, t)
if min_t > max_t:
return
# ~a third back from the muzzle is where a pistol grip sits on every gun in
# the set, which leaves real length of weapon behind the hand.
var grip_at := min_t + (max_t - min_t) * 0.32
w.position = Vector3(0, 0, -grip_at)
func _set_shadows_recursive(node: Node) -> void:
if node is GeometryInstance3D:
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
for child in node.get_children():
_set_shadows_recursive(child)
func _process(delta: float) -> void:
var anim_speed = 1.0
if current_state == "ground" and movement_speed > 1.0:
anim_speed = movement_speed * 0.4
_anim_time += delta * anim_speed
# Target values
var t_root_pos := Vector3.ZERO
var t_root_rot := Vector3.ZERO
var t_upper_arm_l_rot := Vector3.ZERO
var t_upper_arm_r_rot := Vector3.ZERO
var t_lower_arm_l_rot := Vector3.ZERO
var t_lower_arm_r_rot := Vector3.ZERO
var t_thigh_l_rot := Vector3.ZERO
var t_thigh_r_rot := Vector3.ZERO
var t_calf_l_rot := Vector3.ZERO
var t_calf_r_rot := Vector3.ZERO
match current_state:
"ground", "idle":
if movement_speed > 1.0:
# Run cycle
var swing = sin(_anim_time * 5.0)
# Hips
t_thigh_l_rot.x = -swing * 1.6
t_thigh_r_rot.x = swing * 1.6
# Knees bend backwards (positive X rotation) when leg swings back
# We add a bit of bend even when moving forward so it's not stiff
t_calf_l_rot.x = max(0.1, swing * 1.6 + 0.3)
t_calf_r_rot.x = max(0.1, -swing * 1.6 + 0.3)
# Arms
t_upper_arm_l_rot.x = swing * 1.0
t_upper_arm_r_rot.x = -swing * 1.0
# Elbows bend forwards (negative X rotation)
t_lower_arm_l_rot.x = -1.2 + max(0.0, -swing * 0.5)
t_lower_arm_r_rot.x = -1.2 + max(0.0, swing * 0.5)
# Bounce slightly
t_root_pos.y = abs(cos(_anim_time * 5.0)) * 0.1
else:
# Idle
var breath = sin(_anim_time * 2.0)
t_root_pos.y = breath * 0.02
t_upper_arm_l_rot.z = 0.05 + breath * 0.01
t_upper_arm_r_rot.z = -0.05 - breath * 0.01
t_lower_arm_l_rot.x = -0.1
t_lower_arm_r_rot.x = -0.1
t_calf_l_rot.x = 0.05
t_calf_r_rot.x = 0.05
"crouch":
t_root_pos.y = -0.5
# Thighs forward, calves back
t_thigh_l_rot.x = -1.0
t_thigh_r_rot.x = -1.0
t_calf_l_rot.x = 1.0
t_calf_r_rot.x = 1.0
# Arms bent defensively
t_upper_arm_l_rot.x = -0.5
t_upper_arm_r_rot.x = -0.5
t_lower_arm_l_rot.x = -1.2
t_lower_arm_r_rot.x = -1.2
"slide":
t_root_pos.y = -0.6
t_root_rot.x = deg_to_rad(-45) # Lean back
# Legs forward
t_thigh_l_rot.x = deg_to_rad(-60)
t_thigh_r_rot.x = deg_to_rad(-30)
t_calf_l_rot.x = 0.1
t_calf_r_rot.x = 0.1
# Arms back
t_upper_arm_l_rot.x = deg_to_rad(60)
t_upper_arm_r_rot.x = deg_to_rad(60)
t_lower_arm_l_rot.x = -0.2
t_lower_arm_r_rot.x = -0.2
"air":
# Falling / Jumping
t_thigh_l_rot.x = -0.4
t_thigh_r_rot.x = 0.2
t_calf_l_rot.x = 0.5
t_calf_r_rot.x = 0.1
t_upper_arm_l_rot.x = -0.8
t_upper_arm_r_rot.x = -0.8
t_upper_arm_l_rot.z = 0.5
t_upper_arm_r_rot.z = -0.5
t_lower_arm_l_rot.x = -0.5
t_lower_arm_r_rot.x = -0.5
"wall_run":
t_root_rot.z = deg_to_rad(-15)
var cycle = sin(_anim_time * 25.0)
t_thigh_l_rot.x = cycle * 0.8
t_thigh_r_rot.x = -cycle * 0.8
t_calf_l_rot.x = max(0.1, -cycle * 1.0 + 0.4)
t_calf_r_rot.x = max(0.1, cycle * 1.0 + 0.4)
t_upper_arm_l_rot.x = -cycle * 0.8
t_upper_arm_r_rot.x = cycle * 0.8
t_lower_arm_l_rot.x = -1.0
t_lower_arm_r_rot.x = -1.0
"wall_cling":
# Spiderman hanging
t_thigh_l_rot.x = deg_to_rad(-80)
t_thigh_r_rot.x = deg_to_rad(-40)
t_calf_l_rot.x = deg_to_rad(80)
t_calf_r_rot.x = deg_to_rad(40)
t_upper_arm_l_rot.x = deg_to_rad(-140)
t_upper_arm_r_rot.x = deg_to_rad(-140)
t_lower_arm_l_rot.x = deg_to_rad(-40)
t_lower_arm_r_rot.x = deg_to_rad(-40)
"grapple":
t_upper_arm_l_rot.x = deg_to_rad(-160)
t_lower_arm_l_rot.x = 0.0
t_upper_arm_r_rot.x = deg_to_rad(20)
t_lower_arm_r_rot.x = deg_to_rad(-90)
t_thigh_l_rot.x = deg_to_rad(-30)
t_calf_l_rot.x = deg_to_rad(30)
t_thigh_r_rot.x = deg_to_rad(20)
t_calf_r_rot.x = deg_to_rad(10)
"dash":
t_root_rot.x = deg_to_rad(-70)
t_upper_arm_l_rot.x = deg_to_rad(-170)
t_lower_arm_l_rot.x = 0.0
t_upper_arm_r_rot.x = deg_to_rad(-170)
t_lower_arm_r_rot.x = 0.0
t_thigh_l_rot.x = deg_to_rad(20)
t_calf_l_rot.x = 0.0
t_thigh_r_rot.x = deg_to_rad(20)
t_calf_r_rot.x = 0.0
"death":
t_root_rot.x = deg_to_rad(-90)
t_root_pos.y = -0.5
t_upper_arm_l_rot.x = deg_to_rad(170)
t_lower_arm_l_rot.x = 0.0
t_upper_arm_r_rot.x = deg_to_rad(170)
t_lower_arm_r_rot.x = 0.0
t_thigh_l_rot.x = deg_to_rad(-10)
t_calf_l_rot.x = 0.0
t_thigh_r_rot.x = deg_to_rad(10)
t_calf_r_rot.x = 0.0
# Override right arm (and left) if holding a weapon
if is_holding_weapon and current_state != "death":
# Right arm holds the grip: elbow tucked at the ribs, forearm level so
# the fist (and the weapon parented to it) sits chest-high just in
# front of the torso rather than out at arm's length.
t_upper_arm_r_rot.x = -0.15
t_upper_arm_r_rot.z = -0.10
t_lower_arm_r_rot.x = -1.45
# Left arm crosses over to the handguard: up, in, and a little further
# forward than the grip hand.
t_upper_arm_l_rot.x = -0.55
t_upper_arm_l_rot.z = 0.55
t_lower_arm_l_rot.x = -1.0
var lerp_speed = 40.0 * delta
root_pivot.position = root_pivot.position.lerp(t_root_pos, lerp_speed)
root_pivot.rotation = _lerp_vec3(root_pivot.rotation, t_root_rot, lerp_speed)
upper_arm_l_pivot.rotation = _lerp_vec3(upper_arm_l_pivot.rotation, t_upper_arm_l_rot, lerp_speed)
upper_arm_r_pivot.rotation = _lerp_vec3(upper_arm_r_pivot.rotation, t_upper_arm_r_rot, lerp_speed)
lower_arm_l_pivot.rotation = _lerp_vec3(lower_arm_l_pivot.rotation, t_lower_arm_l_rot, lerp_speed)
lower_arm_r_pivot.rotation = _lerp_vec3(lower_arm_r_pivot.rotation, t_lower_arm_r_rot, lerp_speed)
thigh_l_pivot.rotation = _lerp_vec3(thigh_l_pivot.rotation, t_thigh_l_rot, lerp_speed)
thigh_r_pivot.rotation = _lerp_vec3(thigh_r_pivot.rotation, t_thigh_r_rot, lerp_speed)
calf_l_pivot.rotation = _lerp_vec3(calf_l_pivot.rotation, t_calf_l_rot, lerp_speed)
calf_r_pivot.rotation = _lerp_vec3(calf_r_pivot.rotation, t_calf_r_rot, lerp_speed)
# The weapon rides the hand for position, but its AIM belongs to the body:
# cancel the arm chain's rotation so the barrel stays pointed where the
# player faces instead of swinging around with the arm animation.
if hand_r_pivot:
hand_r_pivot.global_basis = root_pivot.global_basis
func _lerp_vec3(a: Vector3, b: Vector3, t: float) -> Vector3:
return Vector3(
lerp_angle(a.x, b.x, t),
lerp_angle(a.y, b.y, t),
lerp_angle(a.z, b.z, t)
)