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:
co-authored by
Claude Fable 5
parent
64bbbf93c6
commit
e2fbc424a7
@@ -197,6 +197,19 @@ func _set_procedural_visible(on: bool) -> void:
|
|||||||
if part:
|
if part:
|
||||||
part.visible = on
|
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:
|
func _apply_color(col: Color) -> void:
|
||||||
var mat = StandardMaterial3D.new()
|
var mat = StandardMaterial3D.new()
|
||||||
mat.albedo_color = col
|
mat.albedo_color = col
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ class_name SkinnedPlayerModel
|
|||||||
## - set_weapon(script_path) — third-person weapon in hand
|
## - set_weapon(script_path) — third-person weapon in hand
|
||||||
## - shadows_only — legacy local-player mode
|
## - shadows_only — legacy local-player mode
|
||||||
##
|
##
|
||||||
## View modes:
|
## View modes (for the LOCAL player only):
|
||||||
## - first_person_mode = true → full body visible to the OWNER, head hidden
|
## - first_person_mode = true → model renders shadows-only for the owner, so
|
||||||
## (shrunk via a SkeletonModifier3D so hair/face follow), fully animated.
|
## 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.
|
## - first_person_mode = false → full third-person model for other players.
|
||||||
|
|
||||||
@export var model_path: String = ""
|
@export var model_path: String = ""
|
||||||
@@ -54,7 +57,6 @@ var loaded: bool = false
|
|||||||
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
|
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
|
||||||
var _current_clip: String = ""
|
var _current_clip: String = ""
|
||||||
var _weapon_attachment: BoneAttachment3D
|
var _weapon_attachment: BoneAttachment3D
|
||||||
var _head_hider: SkeletonModifier3D
|
|
||||||
var is_holding_weapon: bool = false
|
var is_holding_weapon: bool = false
|
||||||
|
|
||||||
|
|
||||||
@@ -91,10 +93,11 @@ func load_model(path: String) -> void:
|
|||||||
else:
|
else:
|
||||||
push_warning("SkinnedPlayerModel: no animations in '%s' — model will T-pose" % path)
|
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)
|
_set_shadows_recursive(self)
|
||||||
if first_person_mode:
|
|
||||||
_setup_first_person()
|
|
||||||
|
|
||||||
loaded = true
|
loaded = true
|
||||||
_play_clip("Idle")
|
_play_clip("Idle")
|
||||||
@@ -132,40 +135,21 @@ func _find_clip(available: PackedStringArray, wanted: String) -> String:
|
|||||||
|
|
||||||
# ── View modes ────────────────────────────────────────────────────────────────
|
# ── View modes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _setup_first_person() -> void:
|
## Show or hide the model to its OWNER. In first person we render shadows-only
|
||||||
if not skeleton:
|
## (on=false) so the camera doesn't see the inside of the mesh; the third-person
|
||||||
return
|
## toggle calls this with on=true to reveal the full animated model. Either way
|
||||||
var head_idx := _find_bone(["Head"])
|
## the model keeps casting shadows and stays visible to other players.
|
||||||
if head_idx < 0:
|
func set_owner_visible(on: bool) -> void:
|
||||||
# No head bone — fall back to shadows-only so the local player at least
|
var mode := GeometryInstance3D.SHADOW_CASTING_SETTING_ON if on \
|
||||||
# isn't staring at the inside of a face.
|
else GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
|
||||||
_set_shadows_recursive(self)
|
_set_shadow_mode_recursive(self, mode)
|
||||||
return
|
|
||||||
_head_hider = HeadHider.new()
|
|
||||||
_head_hider.name = "HeadHider"
|
|
||||||
_head_hider.head_bone = head_idx
|
|
||||||
skeleton.add_child(_head_hider)
|
|
||||||
|
|
||||||
|
|
||||||
func set_first_person(enabled: bool) -> void:
|
func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
|
||||||
first_person_mode = enabled
|
if node is GeometryInstance3D:
|
||||||
if enabled and not _head_hider:
|
node.cast_shadow = mode
|
||||||
_setup_first_person()
|
for child in node.get_children():
|
||||||
elif not enabled and _head_hider:
|
_set_shadow_mode_recursive(child, mode)
|
||||||
_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))
|
|
||||||
|
|
||||||
|
|
||||||
# ── Animation state ───────────────────────────────────────────────────────────
|
# ── Animation state ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -87,6 +87,19 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
|
|||||||
skinned.update_state(state, 9.0, false)
|
skinned.update_state(state, 9.0, false)
|
||||||
await process_frame
|
await process_frame
|
||||||
_check(true, "state cycling did not crash")
|
_check(true, "state cycling did not crash")
|
||||||
|
|
||||||
|
# Third-person toggle: camera swap + owner model visibility.
|
||||||
|
if player.has_method("set_third_person"):
|
||||||
|
var tp_cam = player.get_node_or_null("HeadPivot/ThirdPersonBoom/ThirdPersonCamera")
|
||||||
|
_check(tp_cam != null, "third-person camera boom created")
|
||||||
|
player.set_third_person(true)
|
||||||
|
await process_frame
|
||||||
|
_check(player.third_person, "toggled into third person")
|
||||||
|
if tp_cam:
|
||||||
|
_check(tp_cam.current, "third-person camera is current when toggled on")
|
||||||
|
player.set_third_person(false)
|
||||||
|
await process_frame
|
||||||
|
_check(not player.third_person, "toggled back to first person")
|
||||||
else:
|
else:
|
||||||
var humanoid = player.get_node_or_null("HumanoidModel")
|
var humanoid = player.get_node_or_null("HumanoidModel")
|
||||||
_check(humanoid != null and humanoid.visible, "procedural model visible for color skin")
|
_check(humanoid != null and humanoid.visible, "procedural model visible for color skin")
|
||||||
|
|||||||
@@ -100,11 +100,16 @@ animates in every movement state.
|
|||||||
- **`SkinnedPlayerModel`** loads the GLB at runtime, maps canonical clip
|
- **`SkinnedPlayerModel`** loads the GLB at runtime, maps canonical clip
|
||||||
names, sets loop modes, blends between clips (0.15 s), and scales
|
names, sets loop modes, blends between clips (0.15 s), and scales
|
||||||
locomotion playback speed to actual movement speed.
|
locomotion playback speed to actual movement speed.
|
||||||
- **First person (owner):** full body visible and animated; the head bone is
|
- **First person (owner):** the model renders shadows-only for the owner (the
|
||||||
shrunk via a `SkeletonModifier3D` so you never see your own face — you DO
|
camera sits inside the head, so drawing the mesh would show its inside). It's
|
||||||
see your legs, arms and shadow.
|
still fully animated and casts a real shadow; other players see the full body.
|
||||||
- **Third person (everyone else):** the full model, driven by the synced
|
- **Third person:** press **V** (`toggle_camera_view`) to swap to an
|
||||||
movement state, with the current weapon attached to the right-hand bone.
|
over-the-shoulder `SpringArm3D` camera and reveal your own animated model —
|
||||||
|
the easiest way to eyeball that a new skin's animations look right. Firing
|
||||||
|
still uses the first-person camera, so aim is unchanged. Press V again to
|
||||||
|
return to first person.
|
||||||
|
- **Other players** always see the full third-person model, driven by the
|
||||||
|
synced movement state, with the current weapon attached to the hand bone.
|
||||||
- **Licensing:** every Sketchfab download writes `<name>.license.json`.
|
- **Licensing:** every Sketchfab download writes `<name>.license.json`.
|
||||||
CC-BY models require crediting the author — keep these files and surface
|
CC-BY models require crediting the author — keep these files and surface
|
||||||
them in your credits screen before shipping.
|
them in your credits screen before shipping.
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ var camera: Camera3D = null
|
|||||||
var flashlight: SpotLight3D = null
|
var flashlight: SpotLight3D = null
|
||||||
var _damage_layer: CanvasLayer = 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
|
# Health and Shield
|
||||||
var max_health: float = 100.0
|
var max_health: float = 100.0
|
||||||
var health: float = 100.0
|
var health: float = 100.0
|
||||||
@@ -132,10 +138,56 @@ func _ready() -> void:
|
|||||||
if skin_mgr:
|
if skin_mgr:
|
||||||
synced_skin_id = skin_mgr.active_skin_id
|
synced_skin_id = skin_mgr.active_skin_id
|
||||||
synced_position = position
|
synced_position = position
|
||||||
|
_setup_third_person_camera()
|
||||||
# Build the visual model for whatever skin is selected (remote peers get
|
# Build the visual model for whatever skin is selected (remote peers get
|
||||||
# the id via the synchronizer and rebuild in _process when it arrives).
|
# the id via the synchronizer and rebuild in _process when it arrives).
|
||||||
_apply_skin_model(synced_skin_id)
|
_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
|
## Returns the node that visually represents this player (skinned GLB model
|
||||||
## if the active skin has one, otherwise the procedural HumanoidModel).
|
## if the active skin has one, otherwise the procedural HumanoidModel).
|
||||||
func get_visual_model() -> Node3D:
|
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):
|
if Input.is_action_just_pressed("toggle_flashlight") and is_instance_valid(flashlight):
|
||||||
flashlight.visible = !flashlight.visible
|
flashlight.visible = !flashlight.visible
|
||||||
|
|
||||||
|
if Input.is_action_just_pressed("toggle_camera_view"):
|
||||||
|
set_third_person(not third_person)
|
||||||
|
|
||||||
var machine := _ensure_machine()
|
var machine := _ensure_machine()
|
||||||
if machine:
|
if machine:
|
||||||
machine.input_dir = raw_input
|
machine.input_dir = raw_input
|
||||||
@@ -1026,7 +1081,11 @@ func _setup_hud() -> void:
|
|||||||
func die(impulse: Vector3 = Vector3.ZERO) -> void:
|
func die(impulse: Vector3 = Vector3.ZERO) -> void:
|
||||||
if is_dead: return
|
if is_dead: return
|
||||||
is_dead = true
|
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
|
# Disable movement state machine inputs
|
||||||
if _machine:
|
if _machine:
|
||||||
_machine.process_mode = Node.PROCESS_MODE_DISABLED
|
_machine.process_mode = Node.PROCESS_MODE_DISABLED
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ scoreboard={
|
|||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
toggle_camera_view={
|
||||||
|
"deadzone": 0.0,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":86,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
[physics]
|
[physics]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user