feat: third-person camera toggle (V) + fix first-person showing inside the model

The first-person head-shrink (SkeletonModifier3D) left the camera looking at
the inside of the neck/torso/hair. Replaced it: the owner's model now renders
shadows-only in first person (clean FPS view, still fully animated and visible
to others and in shadows).

Added a third-person toggle (V / toggle_camera_view) so you can actually see
your own animated model — the easiest way to verify a new skin's animations:
- over-the-shoulder SpringArm3D camera (wall-collision aware) on the local
  player; press V to swap, press again to return
- firing still uses the first-person camera, so aim is unchanged
- reveals the owner's model via set_owner_visible() on both SkinnedPlayerModel
  and HumanoidModel; hides the first-person weapon viewmodel while in TP
- death forces first person so the toggle can't fight the death cam

Smoke test extended to cover the toggle (camera boom created, becomes current,
round-trips) — 28/28 checks pass for both color and GLB skins.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-06 17:18:16 -04:00
co-authored by Claude Fable 5
parent 64bbbf93c6
commit e2fbc424a7
6 changed files with 124 additions and 45 deletions
+23 -39
View File
@@ -9,9 +9,12 @@ class_name SkinnedPlayerModel
## - set_weapon(script_path) — third-person weapon in hand
## - shadows_only — legacy local-player mode
##
## View modes:
## - first_person_mode = true → full body visible to the OWNER, head hidden
## (shrunk via a SkeletonModifier3D so hair/face follow), fully animated.
## View modes (for the LOCAL player only):
## - first_person_mode = true → model renders shadows-only for the owner, so
## the camera (which sits inside the head) never shows the inside of the
## mesh. Still fully animated; still visible to other players and in shadows.
## - Press the third-person toggle → set_owner_visible(true) makes the full
## animated model visible to the owner too (over-the-shoulder camera).
## - first_person_mode = false → full third-person model for other players.
@export var model_path: String = ""
@@ -54,7 +57,6 @@ var loaded: bool = false
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
var _current_clip: String = ""
var _weapon_attachment: BoneAttachment3D
var _head_hider: SkeletonModifier3D
var is_holding_weapon: bool = false
@@ -91,10 +93,11 @@ func load_model(path: String) -> void:
else:
push_warning("SkinnedPlayerModel: no animations in '%s' — model will T-pose" % path)
if shadows_only:
# The local owner renders shadows-only (the camera is inside the head, so
# showing the mesh would show the inside of it). Other players see it fully.
# The third-person toggle calls set_owner_visible(true) to reveal it.
if shadows_only or first_person_mode:
_set_shadows_recursive(self)
if first_person_mode:
_setup_first_person()
loaded = true
_play_clip("Idle")
@@ -132,40 +135,21 @@ func _find_clip(available: PackedStringArray, wanted: String) -> String:
# ── View modes ────────────────────────────────────────────────────────────────
func _setup_first_person() -> void:
if not skeleton:
return
var head_idx := _find_bone(["Head"])
if head_idx < 0:
# No head bone — fall back to shadows-only so the local player at least
# isn't staring at the inside of a face.
_set_shadows_recursive(self)
return
_head_hider = HeadHider.new()
_head_hider.name = "HeadHider"
_head_hider.head_bone = head_idx
skeleton.add_child(_head_hider)
## Show or hide the model to its OWNER. In first person we render shadows-only
## (on=false) so the camera doesn't see the inside of the mesh; the third-person
## toggle calls this with on=true to reveal the full animated model. Either way
## the model keeps casting shadows and stays visible to other players.
func set_owner_visible(on: bool) -> void:
var mode := GeometryInstance3D.SHADOW_CASTING_SETTING_ON if on \
else GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
_set_shadow_mode_recursive(self, mode)
func set_first_person(enabled: bool) -> void:
first_person_mode = enabled
if enabled and not _head_hider:
_setup_first_person()
elif not enabled and _head_hider:
_head_hider.queue_free()
_head_hider = null
## Shrinks the head bone after each animation update so the owner's camera
## never sees their own face/hair, while shadows and other players see the
## full head (the modifier only runs on this local instance).
class HeadHider extends SkeletonModifier3D:
var head_bone: int = -1
func _process_modification() -> void:
var skel := get_skeleton()
if skel and head_bone >= 0:
skel.set_bone_pose_scale(head_bone, Vector3(0.001, 0.001, 0.001))
func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
if node is GeometryInstance3D:
node.cast_shadow = mode
for child in node.get_children():
_set_shadow_mode_recursive(child, mode)
# ── Animation state ───────────────────────────────────────────────────────────