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
+60 -1
View File
@@ -11,6 +11,12 @@ var camera: Camera3D = null
var flashlight: SpotLight3D = null
var _damage_layer: CanvasLayer = null
# Third-person view toggle (local player only). The first-person camera keeps
# handling aim/firing; the toggle only changes which camera renders and whether
# the owner sees their own model.
var third_person: bool = false
var _tp_camera: Camera3D = null
# Health and Shield
var max_health: float = 100.0
var health: float = 100.0
@@ -132,10 +138,56 @@ func _ready() -> void:
if skin_mgr:
synced_skin_id = skin_mgr.active_skin_id
synced_position = position
_setup_third_person_camera()
# Build the visual model for whatever skin is selected (remote peers get
# the id via the synchronizer and rebuild in _process when it arrives).
_apply_skin_model(synced_skin_id)
## Over-the-shoulder camera for the local player, on a spring arm so it doesn't
## clip through walls. The first-person camera still exists and handles firing;
## this one only renders when third_person is on.
func _setup_third_person_camera() -> void:
if not head_pivot or not is_instance_valid(camera):
return
var boom := SpringArm3D.new()
boom.name = "ThirdPersonBoom"
boom.spring_length = 3.2
boom.margin = 0.3
boom.collision_mask = 1 # environment only
boom.add_excluded_object(get_rid())
# Aim the arm up-and-back from the head so the camera sits behind/above.
boom.rotation_degrees = Vector3(20, 0, 0)
head_pivot.add_child(boom)
_tp_camera = Camera3D.new()
_tp_camera.name = "ThirdPersonCamera"
_tp_camera.fov = camera.fov
_tp_camera.current = false
boom.add_child(_tp_camera)
## Toggle between first- and third-person. Firing keeps using the first-person
## camera, so aim is unchanged; only rendering and self-visibility change.
func set_third_person(on: bool) -> void:
if not is_multiplayer_authority():
return
third_person = on
if is_instance_valid(_tp_camera):
_tp_camera.current = on
if is_instance_valid(camera):
camera.current = not on
# Reveal / hide the owner's own model.
var visual = get_visual_model()
if visual and visual.has_method("set_owner_visible"):
visual.set_owner_visible(on)
# Hide the first-person weapon viewmodel in third person (it would float in
# the middle of the screen); the third-person weapon on the model shows.
if is_instance_valid(camera):
var wman = camera.get_node_or_null("WeaponManager")
if wman and "canvas_layer" in wman and is_instance_valid(wman.canvas_layer):
wman.canvas_layer.visible = not on
## Returns the node that visually represents this player (skinned GLB model
## if the active skin has one, otherwise the procedural HumanoidModel).
func get_visual_model() -> Node3D:
@@ -760,6 +812,9 @@ func _physics_process(_delta: float) -> void:
if Input.is_action_just_pressed("toggle_flashlight") and is_instance_valid(flashlight):
flashlight.visible = !flashlight.visible
if Input.is_action_just_pressed("toggle_camera_view"):
set_third_person(not third_person)
var machine := _ensure_machine()
if machine:
machine.input_dir = raw_input
@@ -1026,7 +1081,11 @@ func _setup_hud() -> void:
func die(impulse: Vector3 = Vector3.ZERO) -> void:
if is_dead: return
is_dead = true
# Return to first person so the toggle state doesn't fight the death cam.
if is_multiplayer_authority() and third_person:
set_third_person(false)
# Disable movement state machine inputs
if _machine:
_machine.process_mode = Node.PROCESS_MODE_DISABLED