Feat/14 movement overhaul #20

Merged
Dotts merged 86 commits from feat/14-movement-overhaul into main 2026-07-17 10:44:23 -07:00
6 changed files with 124 additions and 45 deletions
Showing only changes of commit e2fbc424a7 - Show all commits
+13
View File
@@ -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
+23 -39
View File
@@ -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 ───────────────────────────────────────────────────────────
+13
View File
@@ -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")
+10 -5
View File
@@ -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.
+59
View File
@@ -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
@@ -1027,6 +1082,10 @@ 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
+5
View File
@@ -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]