Feat/14 movement overhaul #20
@@ -197,6 +197,19 @@ func _set_procedural_visible(on: bool) -> void:
|
||||
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
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -87,6 +87,19 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
|
||||
skinned.update_state(state, 9.0, false)
|
||||
await process_frame
|
||||
_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:
|
||||
var humanoid = player.get_node_or_null("HumanoidModel")
|
||||
_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
|
||||
names, sets loop modes, blends between clips (0.15 s), and scales
|
||||
locomotion playback speed to actual movement speed.
|
||||
- **First person (owner):** full body visible and animated; the head bone is
|
||||
shrunk via a `SkeletonModifier3D` so you never see your own face — you DO
|
||||
see your legs, arms and shadow.
|
||||
- **Third person (everyone else):** the full model, driven by the synced
|
||||
movement state, with the current weapon attached to the right-hand bone.
|
||||
- **First person (owner):** the model renders shadows-only for the owner (the
|
||||
camera sits inside the head, so drawing the mesh would show its inside). It's
|
||||
still fully animated and casts a real shadow; other players see the full body.
|
||||
- **Third person:** press **V** (`toggle_camera_view`) to swap to an
|
||||
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`.
|
||||
CC-BY models require crediting the author — keep these files and surface
|
||||
them in your credits screen before shipping.
|
||||
|
||||
@@ -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
|
||||
@@ -1027,6 +1082,10 @@ 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
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user