Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c7b5a1aca | ||
|
|
198345177a | ||
|
|
7da7f9f3f2 | ||
|
|
0ba14a9469 | ||
|
|
c4a538a469 | ||
|
|
29fdca3565 | ||
|
|
9939e7e524 | ||
|
|
22cb0a58b7 | ||
|
|
e2fbc424a7 | ||
|
|
64bbbf93c6 |
@@ -64,3 +64,6 @@ tags
|
||||
Papaya-Shooter.pck
|
||||
Papaya-Shooter.exe
|
||||
Papaya-Shooter.console.exe
|
||||
|
||||
# Raw downloaded asset packs (not game content; some paths exceed Windows limits)
|
||||
addons/lowpoly_map_gen/downloaded_assets/
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-------------------------------------------------------
|
||||
License:
|
||||
CC0 1.0 Universal (CC0 1.0)
|
||||
Public Domain Dedication
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
------------------------------------------------------
|
||||
Models by @Quaternius
|
||||
Consider supporting me on Patreon!
|
||||
|
||||
https://www.patreon.com/quaternius
|
||||
|
||||
-------------------------------------------------------
|
||||
Join the Discord Server:
|
||||
https://discord.gg/vJqnRUYRfT
|
||||
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 767 KiB |
|
Before Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 572 KiB |
|
Before Width: | Height: | Size: 978 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Hatsune Miku",
|
||||
"uid": "34f3e7daa4c64c8a8000ae7f90b01ceb",
|
||||
"author": "Tigerar1",
|
||||
"author_url": "https://sketchfab.com/allanromanreyes",
|
||||
"license": "Free Standard",
|
||||
"license_slug": "free-st",
|
||||
"source_url": "https://sketchfab.com/3d-models/hatsune-miku-34f3e7daa4c64c8a8000ae7f90b01ceb"
|
||||
}
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 767 KiB |
|
Before Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 572 KiB |
|
Before Width: | Height: | Size: 978 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 767 KiB |
|
Before Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 572 KiB |
|
Before Width: | Height: | Size: 978 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 767 KiB |
|
Before Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 572 KiB |
|
Before Width: | Height: | Size: 978 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -3,8 +3,8 @@
|
||||
{
|
||||
"id": "miku",
|
||||
"name": "Miku",
|
||||
"description": "Hatsune Miku — Virtual Idol",
|
||||
"model": "res://assets/characters/skins/miku_proper_anim.glb",
|
||||
"description": "Hatsune Miku \u2014 Virtual Idol",
|
||||
"model": "res://assets/characters/skins/miku.glb",
|
||||
"unlocked": true
|
||||
}
|
||||
]
|
||||
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
@@ -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
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
extends Node3D
|
||||
class_name FPSCameraRig
|
||||
|
||||
## FPS camera rig — handles mouse look, head bob, FOV kick, wall-run tilt.
|
||||
## FPS camera rig — mouse look, head bob, FOV kick, wall-run tilt, plus
|
||||
## movement-feel feedback driven by MovementStateMachine events:
|
||||
## - landing dip scaled by impact speed
|
||||
## - pitch impulse (vault kick) with spring decay
|
||||
## - smooth crouch/slide eye height (follows the capsule's smoothed height)
|
||||
## - slide roll tilt and dash FOV punch
|
||||
## Attach as child of the CharacterBody3D player. Camera3D is a child of this node.
|
||||
|
||||
@export var sensitivity: float = 0.002
|
||||
@@ -16,16 +21,50 @@ var _target_fov: float = 90.0
|
||||
var _weapon_fov_override: float = -1.0
|
||||
var _step_offset_y: float = 0.0
|
||||
|
||||
# Movement-feel feedback state
|
||||
var _base_eye_y: float = 0.7
|
||||
var _land_dip: float = 0.0 # current downward dip (springs back to 0)
|
||||
var _pitch_impulse: float = 0.0 # extra camera pitch in radians, decays
|
||||
var _dash_fov_kick: float = 0.0 # extra FOV from dashing, decays
|
||||
var _machine: MovementStateMachine = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
camera = get_node_or_null("Camera3D")
|
||||
_base_eye_y = position.y
|
||||
if camera and params:
|
||||
camera.fov = SettingsManager.world_fov
|
||||
_target_fov = SettingsManager.world_fov
|
||||
# Explicitly enable callbacks — set_script() at runtime doesn't auto-register them
|
||||
set_process_input(true)
|
||||
set_process(true)
|
||||
# The state machine may be added after us; hook events once the tree settles.
|
||||
call_deferred("_connect_machine")
|
||||
|
||||
|
||||
func _connect_machine() -> void:
|
||||
var player := get_parent()
|
||||
if not player:
|
||||
return
|
||||
var sm = player.get_node_or_null("MovementStateMachine")
|
||||
if sm and sm is MovementStateMachine:
|
||||
_machine = sm
|
||||
if not sm.movement_event.is_connected(_on_movement_event):
|
||||
sm.movement_event.connect(_on_movement_event)
|
||||
|
||||
|
||||
func _on_movement_event(ev: String, data: Dictionary) -> void:
|
||||
match ev:
|
||||
"land":
|
||||
var fall_speed: float = data.get("fall_speed", 0.0)
|
||||
var scale_p: float = params.land_dip_scale if params else 0.012
|
||||
var max_p: float = params.land_dip_max if params else 0.25
|
||||
_land_dip = clampf(fall_speed * scale_p, 0.0, max_p)
|
||||
"dash":
|
||||
_dash_fov_kick = 15.0
|
||||
"vault":
|
||||
pass # pitch impulse is applied via add_pitch_impulse by the machine
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
@@ -53,6 +92,14 @@ func _process(delta: float) -> void:
|
||||
if not player:
|
||||
return
|
||||
|
||||
if not _machine:
|
||||
_connect_machine()
|
||||
|
||||
var state := ""
|
||||
if _machine:
|
||||
state = _machine.current_state
|
||||
var is_sliding := state == "slide"
|
||||
|
||||
# ── FOV kick ──────────────────────────────────────────────────────────
|
||||
_target_fov = SettingsManager.world_fov
|
||||
var hspeed := Vector2(player.velocity.x, player.velocity.z).length()
|
||||
@@ -61,15 +108,24 @@ func _process(delta: float) -> void:
|
||||
var speed_factor = clampf((hspeed - params.walk_speed) / (params.walk_speed * 0.5), 0.0, 1.0)
|
||||
_target_fov = lerpf(SettingsManager.world_fov, SettingsManager.world_fov + 20.0, speed_factor)
|
||||
|
||||
# Dash punch decays on top
|
||||
_dash_fov_kick = lerpf(_dash_fov_kick, 0.0, 1.0 - exp(-6.0 * delta))
|
||||
_target_fov += _dash_fov_kick
|
||||
|
||||
if _weapon_fov_override > 0.0:
|
||||
_target_fov = _weapon_fov_override
|
||||
|
||||
camera.fov = lerpf(camera.fov, _target_fov, 1.0 - exp(-params.fov_lerp_speed * delta))
|
||||
|
||||
# ── Head bob ──────────────────────────────────────────────────────────
|
||||
var sm = player.get_node_or_null("MovementStateMachine")
|
||||
var is_sliding = sm and sm.current_state == "slide"
|
||||
# ── Crouch / slide eye height (follows the smoothed capsule) ──────────
|
||||
var crouch_factor := 0.0
|
||||
if _machine:
|
||||
crouch_factor = _machine.get_crouch_factor()
|
||||
var eye_drop: float = _machine.original_capsule_height * 0.5 if _machine else 0.9
|
||||
var target_eye_y: float = _base_eye_y - crouch_factor * eye_drop
|
||||
position.y = lerpf(position.y, target_eye_y, 1.0 - exp(-12.0 * delta))
|
||||
|
||||
# ── Head bob ──────────────────────────────────────────────────────────
|
||||
if player.is_on_floor() and hspeed > 1.0 and not is_sliding:
|
||||
_bob_timer += delta * params.head_bob_frequency * (hspeed / params.walk_speed)
|
||||
var bob_y := sin(_bob_timer) * params.head_bob_amplitude
|
||||
@@ -81,12 +137,25 @@ func _process(delta: float) -> void:
|
||||
camera.position.y = lerp(camera.position.y, 0.0, 0.15)
|
||||
camera.position.x = lerp(camera.position.x, 0.0, 0.15)
|
||||
|
||||
# ── Landing dip (springs back up) ─────────────────────────────────────
|
||||
_land_dip = lerpf(_land_dip, 0.0, 1.0 - exp(-8.0 * delta))
|
||||
camera.position.y -= _land_dip
|
||||
|
||||
# ── Step Smoothing ─────────────────────────────────────────────────────
|
||||
_step_offset_y = lerpf(_step_offset_y, 0.0, 15.0 * delta)
|
||||
camera.position.y += _step_offset_y
|
||||
|
||||
# ── Wall-run tilt ─────────────────────────────────────────────────────
|
||||
_current_tilt = lerpf(_current_tilt, _target_tilt, 1.0 - exp(-params.wall_run_tilt_speed * delta))
|
||||
# ── Pitch impulse (vault kick etc.), spring-decayed ───────────────────
|
||||
_pitch_impulse = lerpf(_pitch_impulse, 0.0, 1.0 - exp(-7.0 * delta))
|
||||
camera.rotation.x = _pitch_impulse
|
||||
|
||||
# ── Tilt: wall-run lean + slide roll ──────────────────────────────────
|
||||
var tilt_target := _target_tilt
|
||||
if is_sliding:
|
||||
# Subtle roll in the slide's steering direction
|
||||
var slide_roll: float = params.slide_tilt_angle
|
||||
tilt_target += -_machine.input_dir.x * slide_roll if _machine else 0.0
|
||||
_current_tilt = lerpf(_current_tilt, tilt_target, 1.0 - exp(-params.wall_run_tilt_speed * delta))
|
||||
camera.rotation.z = deg_to_rad(_current_tilt)
|
||||
|
||||
## Called by the movement system to set wall-run tilt direction.
|
||||
@@ -105,8 +174,11 @@ func set_weapon_fov(fov: float) -> void:
|
||||
func clear_wall_tilt() -> void:
|
||||
_target_tilt = 0.0
|
||||
|
||||
## Kick the camera pitch (degrees); decays back smoothly. Used for vaults.
|
||||
func add_pitch_impulse(degrees: float) -> void:
|
||||
_pitch_impulse += deg_to_rad(degrees)
|
||||
|
||||
## Adds an offset to the camera so that when the player physics body snaps up a step,
|
||||
## the camera interpolates smoothly instead of snapping.
|
||||
func add_step_offset(offset_y: float) -> void:
|
||||
_step_offset_y += offset_y
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
@@ -32,6 +35,7 @@ const CLIP_FALLBACKS := {
|
||||
"Sprint": ["Sprint", "Run", "Walk", "Idle"],
|
||||
"Jump": ["Jump", "Fall", "Idle"],
|
||||
"Fall": ["Fall", "Jump", "Idle"],
|
||||
"Land": ["Land", "Idle"],
|
||||
"Crouch": ["CrouchIdle", "Crouch", "Idle"],
|
||||
"CrouchWalk": ["CrouchWalk", "Crouch", "CrouchIdle", "Walk"],
|
||||
"Slide": ["Slide", "CrouchIdle", "Crouch", "Idle"],
|
||||
@@ -54,9 +58,22 @@ 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
|
||||
|
||||
# Procedural shooter pose layer (lean / slide / weapon hold), applied on top of
|
||||
# the base clip by a SkeletonModifier3D so it composes with the animation.
|
||||
var _pose_mod: ShooterPoseModifier
|
||||
var _target_strafe: float = 0.0
|
||||
var _target_fwd: float = 0.0
|
||||
var _target_ads: float = 0.0
|
||||
var _target_wall: float = 0.0
|
||||
var _cur_strafe: float = 0.0
|
||||
var _cur_fwd: float = 0.0
|
||||
var _cur_ads: float = 0.0
|
||||
var _cur_slide: float = 0.0
|
||||
var _cur_wall: float = 0.0
|
||||
const POSE_SMOOTH := 10.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if model_path != "":
|
||||
@@ -86,20 +103,39 @@ func load_model(path: String) -> void:
|
||||
|
||||
if not skeleton:
|
||||
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
|
||||
else:
|
||||
_ensure_meshes_bound(scene)
|
||||
_pose_mod = ShooterPoseModifier.new()
|
||||
_pose_mod.name = "ShooterPose"
|
||||
skeleton.add_child(_pose_mod)
|
||||
if animation_player:
|
||||
_index_animations()
|
||||
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")
|
||||
|
||||
|
||||
## Make sure every skinned MeshInstance3D is actually driven by the skeleton.
|
||||
## A correctly-exported GLB binds automatically, but if one imports with a skin
|
||||
## resource whose `skeleton` NodePath doesn't resolve, the mesh renders its bind
|
||||
## pose (a permanent T-pose) while the skeleton animates invisibly. This repairs
|
||||
## that at load time so a bad export degrades gracefully instead of T-posing.
|
||||
func _ensure_meshes_bound(scene: Node) -> void:
|
||||
for mi in scene.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.skin == null:
|
||||
continue # not a skinned mesh
|
||||
if mi.skeleton.is_empty() or mi.get_node_or_null(mi.skeleton) != skeleton:
|
||||
mi.skeleton = mi.get_path_to(skeleton)
|
||||
|
||||
|
||||
## Map canonical clip names to whatever actually shipped in the GLB and set
|
||||
## loop modes (glTF has no loop flag, so we set it here).
|
||||
func _index_animations() -> void:
|
||||
@@ -132,50 +168,49 @@ 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 ───────────────────────────────────────────────────────────
|
||||
|
||||
var _prev_state: String = ""
|
||||
var _land_lock: float = 0.0 # seconds left where the Land one-shot owns playback
|
||||
|
||||
|
||||
## Same contract as HumanoidModel.update_state(). Called by the movement
|
||||
## controller each frame with either local or network-synced state.
|
||||
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
|
||||
if not loaded or not animation_player:
|
||||
return
|
||||
|
||||
# A heavy landing plays the Land one-shot before locomotion resumes.
|
||||
if _land_lock > 0.0:
|
||||
_land_lock -= get_process_delta_time()
|
||||
if _land_lock > 0.0 and state in ["ground", "idle"]:
|
||||
_prev_state = state
|
||||
return
|
||||
if state in ["ground", "idle"] and _prev_state == "air" \
|
||||
and _vertical_speed() < -12.0 and _resolved_clips.has("Land"):
|
||||
_land_lock = 0.25
|
||||
_play_clip("Land")
|
||||
_prev_state = state
|
||||
return
|
||||
_prev_state = state
|
||||
|
||||
var clip := "Idle"
|
||||
match state:
|
||||
"ground", "idle":
|
||||
@@ -188,7 +223,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
|
||||
elif speed > 0.5:
|
||||
clip = "Walk"
|
||||
"air":
|
||||
clip = "Fall"
|
||||
# Rising = jump, falling = the fall loop.
|
||||
clip = "Jump" if _vertical_speed() > 0.5 else "Fall"
|
||||
"slide":
|
||||
clip = "Slide"
|
||||
"wall_run":
|
||||
@@ -204,6 +240,10 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
|
||||
|
||||
_play_clip(clip)
|
||||
|
||||
if _pose_mod:
|
||||
_pose_mod.state = state
|
||||
_pose_mod.weapon_held = is_holding_weapon
|
||||
|
||||
# Scale locomotion playback so feet keep up with actual movement speed.
|
||||
match clip:
|
||||
"Walk":
|
||||
@@ -214,6 +254,40 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
|
||||
animation_player.speed_scale = 1.0
|
||||
|
||||
|
||||
## Drives the procedural pose layer. Called by the controller each frame.
|
||||
## strafe: -1 (moving left) .. +1 (moving right), relative to facing
|
||||
## fwd: -1 (moving back) .. +1 (moving forward), relative to facing
|
||||
## ads: 0 (hip) .. 1 (aiming down sights)
|
||||
func set_locomotion(strafe: float, fwd: float, ads: float) -> void:
|
||||
_target_strafe = clampf(strafe, -1.0, 1.0)
|
||||
_target_fwd = clampf(fwd, -1.0, 1.0)
|
||||
_target_ads = clampf(ads, 0.0, 1.0)
|
||||
|
||||
|
||||
## Wall side during a wall run: -1 wall on left, +1 wall on right, 0 none.
|
||||
## Drives a whole-body lean into the wall.
|
||||
func set_wall_side(side: float) -> void:
|
||||
_target_wall = clampf(side, -1.0, 1.0)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _pose_mod:
|
||||
return
|
||||
var t := 1.0 - exp(-POSE_SMOOTH * delta)
|
||||
_cur_strafe = lerpf(_cur_strafe, _target_strafe, t)
|
||||
_cur_fwd = lerpf(_cur_fwd, _target_fwd, t)
|
||||
_cur_ads = lerpf(_cur_ads, _target_ads, t)
|
||||
var slide_target := 1.0 if _pose_mod.state == "slide" else 0.0
|
||||
_cur_slide = lerpf(_cur_slide, slide_target, t)
|
||||
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
|
||||
_cur_wall = lerpf(_cur_wall, wall_target, t)
|
||||
_pose_mod.strafe = _cur_strafe
|
||||
_pose_mod.fwd = _cur_fwd
|
||||
_pose_mod.ads = _cur_ads
|
||||
_pose_mod.slide = _cur_slide
|
||||
_pose_mod.wall = _cur_wall
|
||||
|
||||
|
||||
func _play_clip(canonical: String) -> void:
|
||||
if not animation_player or not _resolved_clips.has(canonical):
|
||||
return
|
||||
@@ -224,6 +298,14 @@ func _play_clip(canonical: String) -> void:
|
||||
_current_clip = clip_name
|
||||
|
||||
|
||||
## Vertical velocity of the body this model is attached to (0 if detached).
|
||||
func _vertical_speed() -> float:
|
||||
var p := get_parent()
|
||||
if p is CharacterBody3D:
|
||||
return p.velocity.y
|
||||
return 0.0
|
||||
|
||||
|
||||
# ── Third-person weapon ───────────────────────────────────────────────────────
|
||||
|
||||
## Attach a weapon (by weapon script path) to the right hand bone so other
|
||||
@@ -297,3 +379,114 @@ func _set_shadows_recursive(node: Node) -> void:
|
||||
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
|
||||
for child in node.get_children():
|
||||
_set_shadows_recursive(child)
|
||||
|
||||
|
||||
# ── Procedural shooter pose layer ─────────────────────────────────────────────
|
||||
#
|
||||
# Runs after the AnimationPlayer each frame and layers shooter-feel poses on top
|
||||
# of the base clip: lean into the movement direction, a slide that leans back and
|
||||
# looks forward, and an always-held weapon that raises to ADS. All rotations are
|
||||
# authored in the skeleton's own space (forward = +Z, up = +Y, character-right =
|
||||
# -X) and converted into each bone's local pose, so they read intuitively.
|
||||
class ShooterPoseModifier extends SkeletonModifier3D:
|
||||
# Inputs, written by the owning SkinnedPlayerModel each frame.
|
||||
var strafe: float = 0.0 # -1 left .. +1 right
|
||||
var fwd: float = 0.0 # -1 back .. +1 forward
|
||||
var ads: float = 0.0 # 0 hip .. 1 aiming
|
||||
var slide: float = 0.0 # 0 .. 1 slide blend
|
||||
var wall: float = 0.0 # -1 wall left .. +1 wall right (wall-run lean)
|
||||
var state: String = "idle"
|
||||
var weapon_held: bool = false
|
||||
|
||||
# Tuning (radians). Positive pitch leans forward; positive roll leans right.
|
||||
const LEAN_ROLL := 0.30
|
||||
const LEAN_PITCH := 0.18
|
||||
const SLIDE_BACK := 0.75 # torso lean-back during slide
|
||||
const SLIDE_HEAD_UP := 0.7 # head pitch to keep looking forward
|
||||
const ADS_LIFT := 1.05 # upper-arm raise toward aim at full ADS
|
||||
const ADS_SWING := 0.6 # swing arms in toward centre-front on ADS
|
||||
const ADS_FOREARM := 0.55 # forearm bend to bring the weapon up on ADS
|
||||
|
||||
const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
|
||||
|
||||
var _idx: Dictionary = {}
|
||||
var _resolved := false
|
||||
|
||||
func _resolve() -> void:
|
||||
var skel := get_skeleton()
|
||||
var names := SPINE + ["DEF-neck", "DEF-head",
|
||||
"DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
|
||||
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L"]
|
||||
for n in names:
|
||||
_idx[n] = skel.find_bone(n)
|
||||
_resolved = true
|
||||
|
||||
func _process_modification() -> void:
|
||||
var skel := get_skeleton()
|
||||
if not skel:
|
||||
return
|
||||
if not _resolved:
|
||||
_resolve()
|
||||
|
||||
_apply_lean(skel)
|
||||
if slide > 0.01:
|
||||
_apply_slide(skel)
|
||||
if absf(wall) > 0.01:
|
||||
_apply_wall_lean(skel)
|
||||
if weapon_held:
|
||||
_apply_weapon(skel)
|
||||
|
||||
# Distribute a skeleton-space lean across the spine bones.
|
||||
func _apply_lean(skel: Skeleton3D) -> void:
|
||||
var pitch := fwd * LEAN_PITCH * (1.0 - slide)
|
||||
var roll := strafe * LEAN_ROLL * (1.0 - slide)
|
||||
if absf(pitch) < 0.001 and absf(roll) < 0.001:
|
||||
return
|
||||
var q := Quaternion(Vector3(1, 0, 0), pitch) * Quaternion(Vector3(0, 0, 1), roll)
|
||||
var per := Quaternion.IDENTITY.slerp(q, 1.0 / SPINE.size())
|
||||
for n in SPINE:
|
||||
_add_space(skel, _idx.get(n, -1), per)
|
||||
|
||||
# Wall run: roll the torso into the wall (+wall = wall on the right).
|
||||
func _apply_wall_lean(skel: Skeleton3D) -> void:
|
||||
var roll := Quaternion(Vector3(0, 0, 1), wall * 0.35)
|
||||
var per := Quaternion.IDENTITY.slerp(roll, 1.0 / SPINE.size())
|
||||
for n in SPINE:
|
||||
_add_space(skel, _idx.get(n, -1), per)
|
||||
|
||||
# Slide: lean the whole torso back, then pitch the head up to look forward.
|
||||
func _apply_slide(skel: Skeleton3D) -> void:
|
||||
var back := Quaternion(Vector3(1, 0, 0), -SLIDE_BACK * slide)
|
||||
var per := Quaternion.IDENTITY.slerp(back, 1.0 / SPINE.size())
|
||||
for n in SPINE:
|
||||
_add_space(skel, _idx.get(n, -1), per)
|
||||
var up := Quaternion(Vector3(1, 0, 0), SLIDE_HEAD_UP * slide)
|
||||
_add_space(skel, _idx.get("DEF-neck", -1), Quaternion.IDENTITY.slerp(up, 0.5))
|
||||
_add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(up, 0.5))
|
||||
|
||||
# Weapon hold. At the hip the base clip already keeps the arms down with the
|
||||
# weapon (attached to the hand) at the side, so we leave it alone. On ADS we
|
||||
# additively lift both arms forward-up toward an aiming pose. Rotations are
|
||||
# about the skeleton's X axis (the shoulder line), so the down arms swing
|
||||
# forward to eye level.
|
||||
func _apply_weapon(skel: Skeleton3D) -> void:
|
||||
if ads < 0.01:
|
||||
return
|
||||
# Raise about X (down arm -> forward) and swing about Y so each arm comes
|
||||
# IN toward centre-front instead of splaying out to the side. Right arm
|
||||
# is on -X so it swings +Y; the left mirrors it.
|
||||
var lift := Quaternion(Vector3(1, 0, 0), -ADS_LIFT * ads)
|
||||
var swing := ADS_SWING * ads
|
||||
_add_space(skel, _idx.get("DEF-upper_arm.R", -1), Quaternion(Vector3(0, 1, 0), swing) * lift)
|
||||
_add_space(skel, _idx.get("DEF-upper_arm.L", -1), Quaternion(Vector3(0, 1, 0), -swing) * lift)
|
||||
var bend := Quaternion(Vector3(1, 0, 0), -ADS_FOREARM * ads)
|
||||
_add_space(skel, _idx.get("DEF-forearm.R", -1), bend)
|
||||
_add_space(skel, _idx.get("DEF-forearm.L", -1), bend)
|
||||
|
||||
# Compose a skeleton-space rotation onto a bone's animated local pose.
|
||||
func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void:
|
||||
if idx < 0:
|
||||
return
|
||||
var b := skel.get_bone_global_rest(idx).basis.get_rotation_quaternion()
|
||||
var local := b.inverse() * q_space * b
|
||||
skel.set_bone_pose_rotation(idx, skel.get_bone_pose_rotation(idx) * local)
|
||||
|
||||
@@ -38,14 +38,9 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
|
||||
func _build_dust2_layout() -> void:
|
||||
# Materials
|
||||
var wall_mat = StandardMaterial3D.new()
|
||||
wall_mat.albedo_color = Color(0.85, 0.75, 0.6) # Sandstone
|
||||
|
||||
var floor_mat = StandardMaterial3D.new()
|
||||
floor_mat.albedo_color = Color(0.7, 0.65, 0.55) # Dusty ground
|
||||
|
||||
var box_mat = StandardMaterial3D.new()
|
||||
box_mat.albedo_color = Color(0.4, 0.3, 0.2) # Wood crates
|
||||
var wall_mat = LevelMaterials.tinted(Color(0.85, 0.75, 0.6)) # Sandstone
|
||||
var floor_mat = LevelMaterials.tinted(Color(0.7, 0.65, 0.55)) # Dusty ground
|
||||
var box_mat = LevelMaterials.tinted(Color(0.4, 0.3, 0.2), true) # Wood crates
|
||||
|
||||
# Root CSG
|
||||
var root_csg = CSGCombiner3D.new()
|
||||
|
||||
@@ -79,6 +79,14 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
|
||||
if _check(skinned != null, "SkinnedModel created for GLB skin"):
|
||||
_check(skinned.loaded, "GLB model loaded")
|
||||
_check(skinned.skeleton != null, "skeleton found in GLB")
|
||||
# The mesh must be bound to the skeleton, else it renders its bind
|
||||
# pose (T-pose) while the skeleton animates invisibly.
|
||||
var bound := false
|
||||
if skinned.skeleton:
|
||||
for mi in skinned.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.skin != null and mi.get_node_or_null(mi.skeleton) == skinned.skeleton:
|
||||
bound = true
|
||||
_check(bound, "skinned mesh is bound to the skeleton (won't T-pose)")
|
||||
if skinned.animation_player:
|
||||
_check(skinned.animation_player.is_playing(), "animation playing")
|
||||
print(" clips resolved: ", skinned._resolved_clips)
|
||||
@@ -87,6 +95,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")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqc7midjff148
|
||||
@@ -82,10 +82,7 @@ func _box_static(pos: Vector3, size: Vector3, color: Color, node_name: String =
|
||||
var mesh := MeshInstance3D.new()
|
||||
mesh.mesh = BoxMesh.new()
|
||||
mesh.mesh.size = size
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.roughness = 0.8
|
||||
mesh.mesh.surface_set_material(0, mat)
|
||||
mesh.mesh.surface_set_material(0, LevelMaterials.tinted(color))
|
||||
body.add_child(mesh)
|
||||
return body
|
||||
|
||||
@@ -317,6 +314,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
client_rep_config.add_property(":synced_movement_state")
|
||||
client_rep_config.add_property(":synced_movement_speed")
|
||||
client_rep_config.add_property(":synced_is_crouching")
|
||||
client_rep_config.add_property(":synced_is_ads")
|
||||
client_rep_config.add_property(":synced_wall_side")
|
||||
client_rep_config.add_property(":synced_grapple_point")
|
||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
|
||||
@@ -17,9 +17,12 @@ menu dropdown, fully animated in first AND third person, synced in multiplayer.
|
||||
Put it in the env var `SKETCHFAB_API_TOKEN`, or in a file named
|
||||
`.sketchfab_token` in the project root (gitignored).
|
||||
2. **Blender 3.6+** — on PATH, or set `BLENDER_PATH` to `blender.exe`.
|
||||
3. **Animation library** — fill `assets/characters/animations/` with one clip
|
||||
per file on the Mixamo skeleton. This is done ONCE and reused for every
|
||||
character forever. See "Building the animation library" below.
|
||||
3. **Animation library** — already bundled. `assets/characters/animations/_library.glb`
|
||||
is the CC0 Quaternius Universal Animation Library (13 game-relevant clips
|
||||
mapped in `LIBRARY_CLIP_MAP`). `tools/autorig.py` rigs every character to
|
||||
this library's skeleton (fitting the arm bones to the model's actual pose),
|
||||
and `merge_animations.py` retargets each clip onto that fitted rest. To use a
|
||||
different/larger set instead, see "Swapping the animation library" below.
|
||||
|
||||
## The pipeline, step by step
|
||||
|
||||
@@ -29,8 +32,8 @@ Each step is also runnable on its own:
|
||||
|---|---|---|
|
||||
| 1. Find | `python tools/sketchfab_import.py search "anime robot" --rigged` | Search downloadable models (license shown per result) |
|
||||
| 2. Download | `python tools/sketchfab_import.py download <uid>` | GLB + license/attribution JSON into `assets/characters/incoming/` |
|
||||
| 3. Auto-rig | `blender --background --python tools/autorig.py -- in.glb out.glb` | Fits a Mixamo-named skeleton, binds automatic weights |
|
||||
| 4. Animate | `blender --background --python tools/merge_animations.py -- rigged.glb assets/characters/animations out.glb` | Merges the shared clip library, strips root motion, canonical names |
|
||||
| 3. Auto-rig | `blender --background --python tools/autorig.py -- in.glb out.glb` | Fits the skeleton (incl. arm bones) to the mesh, binds smooth nearest-bone weights |
|
||||
| 4. Animate | `blender --background --python tools/merge_animations.py -- rigged.glb assets/characters/animations out.glb` | Retargets the clip library onto the fitted rest, strips root motion, canonical names |
|
||||
| 5. Register | (automatic in pipeline.py) | Copies to `skins/`, adds entry to `skins.json` |
|
||||
|
||||
`tools/pipeline.py` chains all of it. Useful flags:
|
||||
@@ -63,38 +66,32 @@ automation needs an alternative. In order of preference:
|
||||
All roads lead to the same place: a GLB with Mixamo bone names, which is what
|
||||
`merge_animations.py` and the game expect.
|
||||
|
||||
## Building the animation library (once)
|
||||
## Swapping the animation library
|
||||
|
||||
The library lives in `assets/characters/animations/` — one clip per file,
|
||||
filename = clip name (`idle.fbx` → `Idle`, `crouch_walk.fbx` → `CrouchWalk`).
|
||||
`merge_animations.py` supports two library layouts in
|
||||
`assets/characters/animations/`:
|
||||
|
||||
**Recommended: Mixamo clips.** Go to mixamo.com, pick the X Bot character,
|
||||
download each clip as *FBX Binary, Without Skin, 30 fps* (use "In Place"
|
||||
variants where offered):
|
||||
**A. Multi-clip library GLB (the bundled default).** If `_library.glb` exists,
|
||||
its clips are merged and mapped through `LIBRARY_CLIP_MAP` in
|
||||
`tools/merge_animations.py`. The bundled file is the CC0 Quaternius Universal
|
||||
Animation Library (13 clips: Idle, Walk, Run, Sprint, Jump, Fall, Land,
|
||||
CrouchIdle, CrouchWalk, Dash, Death, Hit, Dance). **Important:** `autorig.py`
|
||||
rigs characters to *this GLB's own skeleton*, so if you replace `_library.glb`
|
||||
you must re-rig characters against the new one (the pipeline does this
|
||||
automatically). To pull in more of Quaternius's 120-clip set, drop in the Pro
|
||||
`_library.glb` and add rows to `LIBRARY_CLIP_MAP`.
|
||||
|
||||
| File | Mixamo search |
|
||||
|---|---|
|
||||
| `idle.fbx` | Rifle Idle (or Idle) |
|
||||
| `walk.fbx` | Walking (In Place) |
|
||||
| `run.fbx` | Rifle Run / Fast Run (In Place) |
|
||||
| `sprint.fbx` | Sprint (In Place) |
|
||||
| `jump.fbx` | Jump |
|
||||
| `fall.fbx` | Falling Idle |
|
||||
| `crouch_idle.fbx` | Crouch Idle |
|
||||
| `crouch_walk.fbx` | Crouch Walk (In Place) |
|
||||
| `slide.fbx` | Running Slide |
|
||||
| `wall_run.fbx` | (see note) Run variant works fine |
|
||||
| `dash.fbx` | Sprint or Dive |
|
||||
| `death.fbx` | Dying |
|
||||
**B. Loose one-clip-per-file (Mixamo).** Delete `_library.glb` and instead add
|
||||
FBX/GLB files named by clip (`idle.fbx` → `Idle`, `crouch_walk.fbx` →
|
||||
`CrouchWalk`). Download from mixamo.com as *FBX Binary, Without Skin, 30 fps*
|
||||
("In Place" variants). In this mode characters must be on the Mixamo skeleton
|
||||
(rig via Mixamo web / AccuRig and pass `--rigged`, or let the heuristic
|
||||
`build_mixamo_armature` fit one).
|
||||
|
||||
CC0 alternative: **Quaternius Universal Animation Library** (quaternius.com,
|
||||
500+ clips, Mixamo-compatible skeleton) or **Kenney character assets** —
|
||||
both free for commercial use, no attribution required.
|
||||
|
||||
Missing clips are fine: the game falls back along sensible chains
|
||||
(`Slide → CrouchIdle → Idle`, `WallRun → Run`, etc. — see
|
||||
`CLIP_FALLBACKS` in `characters/skinned_player_model.gd`). A model with just
|
||||
Idle/Walk/Run still animates in every movement state.
|
||||
Missing clips are fine either way: the game falls back along sensible chains
|
||||
(`Slide → CrouchIdle → Idle`, `WallRun → Run`, etc. — see `CLIP_FALLBACKS` in
|
||||
`characters/skinned_player_model.gd`). A model with just Idle/Walk/Run still
|
||||
animates in every movement state.
|
||||
|
||||
## How it works in-game
|
||||
|
||||
@@ -104,11 +101,16 @@ Idle/Walk/Run still 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.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
@tool
|
||||
extends EditorScript
|
||||
|
||||
func _run():
|
||||
var source = "res://assets/characters/skins/miku_rigged_final.glb"
|
||||
var dest = "res://assets/characters/skins/miku_rigged_imported.tscn"
|
||||
|
||||
print("Loading %s..." % source)
|
||||
var glb = load(source)
|
||||
if not glb:
|
||||
print("ERROR: Failed to load GLB")
|
||||
return
|
||||
|
||||
print("Instantiating...")
|
||||
var scene = glb.instantiate()
|
||||
if not scene:
|
||||
print("ERROR: Failed to instantiate")
|
||||
return
|
||||
|
||||
print("Scene: %s" % scene.name)
|
||||
print("Children:")
|
||||
for child in scene.get_children():
|
||||
print(" %s (%s)" % [child.name, child.get_class()])
|
||||
if child is Skeleton3D:
|
||||
print(" Bones: %d" % child.get_bone_count())
|
||||
if child is AnimationPlayer:
|
||||
print(" Animations: %s" % child.get_animation_list())
|
||||
|
||||
# Save as scene
|
||||
var packed = PackedScene.new()
|
||||
packed.pack(scene)
|
||||
ResourceSaver.save(packed, dest)
|
||||
print("Saved to %s" % dest)
|
||||
@@ -1 +0,0 @@
|
||||
uid://cr1w53xtjptct
|
||||
@@ -1,37 +0,0 @@
|
||||
@tool
|
||||
extends EditorScript
|
||||
|
||||
func _run():
|
||||
var source = "res://assets/characters/skins/miku_rigged_final.glb"
|
||||
var dest = "res://assets/characters/skins/miku_rigged.tscn"
|
||||
|
||||
print("Loading %s..." % source)
|
||||
var glb = load(source)
|
||||
if not glb:
|
||||
print("ERROR: Failed to load GLB")
|
||||
return
|
||||
|
||||
print("Instantiating...")
|
||||
var scene = glb.instantiate()
|
||||
if not scene:
|
||||
print("ERROR: Failed to instantiate")
|
||||
return
|
||||
|
||||
print("Scene: %s" % scene.name)
|
||||
print("Children:")
|
||||
for child in scene.get_children():
|
||||
print(" %s (%s)" % [child.name, child.get_class()])
|
||||
if child is Skeleton3D:
|
||||
print(" Bones: %d" % child.get_bone_count())
|
||||
for i in range(child.get_bone_count()):
|
||||
var bone_name = child.get_bone_name(i)
|
||||
var rest = child.get_bone_global_rest(i)
|
||||
print(" %s: pos=%s" % [bone_name, rest.origin])
|
||||
if child is AnimationPlayer:
|
||||
print(" Animations: %s" % child.get_animation_list())
|
||||
|
||||
# Save as scene
|
||||
var packed = PackedScene.new()
|
||||
packed.pack(scene)
|
||||
ResourceSaver.save(packed, dest)
|
||||
print("Saved to %s" % dest)
|
||||
@@ -1 +0,0 @@
|
||||
uid://bdes0ywhauamj
|
||||
@@ -0,0 +1 @@
|
||||
uid://c87jctwfqhyui
|
||||
@@ -3,10 +3,10 @@ class_name MovementParams
|
||||
|
||||
# ── Ground ────────────────────────────────────────────────────────────────────
|
||||
@export var walk_speed: float = 11.0
|
||||
@export var crouch_speed: float = 4.0
|
||||
@export var crouch_speed: float = 4.5
|
||||
@export var ground_friction: float = 10.0
|
||||
@export var ground_acceleration: float = 50.0
|
||||
@export var ground_deceleration: float = 40.0
|
||||
@export var ground_acceleration: float = 60.0
|
||||
@export var ground_deceleration: float = 45.0
|
||||
|
||||
# ── Jump / Air ────────────────────────────────────────────────────────────────
|
||||
@export var jump_velocity: float = 8.5
|
||||
@@ -14,70 +14,88 @@ class_name MovementParams
|
||||
@export var jump_buffer: float = 0.12
|
||||
@export var air_control: float = 0.35
|
||||
@export var air_acceleration: float = 30.0
|
||||
@export var max_air_speed: float = 16.0
|
||||
@export var max_air_speed: float = 18.0
|
||||
@export var gravity: float = 22.0
|
||||
@export var fall_multiplier: float = 2.2
|
||||
@export var fall_multiplier: float = 2.0
|
||||
@export var low_jump_multiplier: float = 1.6
|
||||
|
||||
# ── Air Strafe (Quake-style) ──────────────────────────────────────────────────
|
||||
@export var air_strafe_accel: float = 80.0
|
||||
@export var air_strafe_max_gain: float = 1.0
|
||||
# Classic accelerate: only the velocity component along wish_dir is capped, so
|
||||
# turning while strafing genuinely gains speed (up to max_air_speed overall).
|
||||
@export var air_strafe_accel: float = 90.0
|
||||
@export var air_wish_speed_cap: float = 2.5 # per-tick projection cap (m/s), quake-style
|
||||
# Direct steering: bends existing horizontal velocity toward the input without
|
||||
# changing its magnitude, so air direction changes feel light and responsive.
|
||||
@export var air_steer_rate: float = 5.0
|
||||
|
||||
# ── Bunny Hop ─────────────────────────────────────────────────────────────────
|
||||
@export var bunny_hop_impulse: float = 1.15
|
||||
@export var bunny_hop_speed_gain: float = 0.5
|
||||
@export var bunny_hop_speed_cap: float = 16.0
|
||||
@export var bunny_hop_impulse: float = 1.1
|
||||
@export var bunny_hop_speed_gain: float = 0.7
|
||||
@export var bunny_hop_speed_cap: float = 20.0
|
||||
|
||||
# ── Slide ─────────────────────────────────────────────────────────────────────
|
||||
@export var slide_speed: float = 14.0
|
||||
@export var slide_friction: float = 0.96
|
||||
@export var slide_min_speed: float = 5.0
|
||||
@export var slide_duration: float = 0.8
|
||||
@export var slide_cooldown: float = 0.4 # Prevents rapid slide re-entry
|
||||
@export var slide_jump_speed_boost: float = 1.5 # Extra m/s added when jumping out of slide
|
||||
@export var slide_boost: float = 3.0 # flat entry boost when grounded and fast
|
||||
@export var slide_friction_flat: float = 2.2 # m/s^2 decel on flat ground (low = long slides)
|
||||
@export var slide_slope_accel: float = 16.0 # downhill acceleration from gravity projection
|
||||
@export var slide_min_speed: float = 4.0
|
||||
@export var slide_cooldown: float = 0.35 # prevents rapid slide re-entry
|
||||
@export var slide_steer_rate: float = 2.5 # how fast slide direction bends toward input
|
||||
@export var slide_jump_speed_boost: float = 1.5 # extra m/s added when jumping out of slide
|
||||
|
||||
# ── Wall Run ──────────────────────────────────────────────────────────────────
|
||||
@export var wall_run_speed: float = 12.0
|
||||
@export var wall_run_vertical_speed: float = 4.0
|
||||
@export var wall_run_duration: float = 1.2
|
||||
@export var wall_run_gravity: float = 6.0
|
||||
@export var wall_run_speed: float = 13.0
|
||||
@export var wall_run_accel: float = 18.0 # accelerate toward run speed (no hard set)
|
||||
@export var wall_run_duration: float = 1.8
|
||||
@export var wall_run_gravity: float = 5.0
|
||||
@export var wall_run_entry_max_up: float = 3.5 # keep this much upward momentum on attach
|
||||
@export var wall_run_auto_jump_speed: float = 10.0
|
||||
@export var wall_run_jump_horizontal: float = 8.0
|
||||
@export var wall_run_jump_off_normal: float = 5.0
|
||||
@export var wall_run_jump_off_normal: float = 6.0
|
||||
@export var wall_angle_threshold: float = 70.0
|
||||
@export var wall_detect_distance: float = 0.7
|
||||
@export var wall_detect_distance: float = 0.8
|
||||
@export var wall_ray_up_height: float = 0.6
|
||||
@export var wall_ray_down_height: float = 0.0
|
||||
|
||||
# ── Wall Climb ────────────────────────────────────────────────────────────────
|
||||
@export var wall_climb_speed: float = 6.0
|
||||
@export var wall_climb_duration: float = 3.0
|
||||
@export var wall_climb_speed: float = 6.5
|
||||
@export var wall_climb_duration: float = 2.2
|
||||
@export var wall_climb_max_angle: float = 75.0
|
||||
@export var wall_climb_vault_forward: float = 16.0
|
||||
@export var wall_climb_vault_up: float = 14.0
|
||||
@export var wall_climb_vault_forward: float = 14.0
|
||||
@export var wall_climb_vault_up: float = 12.0
|
||||
@export var wall_climb_vault_height_check: float = 1.0
|
||||
|
||||
# ── Jumping ───────────────────────────────────────────────────────────────────
|
||||
@export var jump_cooldown: float = 0.27
|
||||
@export var jump_cooldown: float = 0.12
|
||||
|
||||
# ── Wall Cling ────────────────────────────────────────────────────────────────
|
||||
@export var wall_cling_slide_speed: float = -1.5
|
||||
@export var wall_cling_stamina_drain: float = 2.0
|
||||
@export var wall_cling_max_stamina: float = 2.0
|
||||
@export var wall_cling_stamina_drain: float = 1.0
|
||||
@export var wall_cling_max_stamina: float = 2.5
|
||||
|
||||
# ── Grapple ───────────────────────────────────────────────────────────────────
|
||||
@export var grapple_range: float = 30.0
|
||||
@export var grapple_pull_force: float = 25.0
|
||||
@export var grapple_range: float = 35.0
|
||||
@export var grapple_pull_force: float = 18.0 # constant reel toward the hook
|
||||
@export var grapple_reel_force: float = 22.0 # extra reel while holding forward
|
||||
@export var grapple_jump_boost: float = 12.0
|
||||
@export var grapple_spring_strength: float = 10.0
|
||||
@export var grapple_air_control: float = 20.0
|
||||
@export var grapple_spring_strength: float = 14.0
|
||||
@export var grapple_air_control: float = 24.0
|
||||
@export var grapple_shoot_speed: float = 70.0 # hook travel speed (m/s)
|
||||
|
||||
# ── Dash ──────────────────────────────────────────────────────────────────────
|
||||
@export var dash_speed: float = 10.0
|
||||
@export var dash_duration: float = 0.25
|
||||
@export var dash_cooldown: float = 10.0
|
||||
@export var dash_speed: float = 13.0
|
||||
@export var dash_duration: float = 0.18
|
||||
@export var dash_charges: int = 2 # dashes available before recharging
|
||||
@export var dash_cooldown: float = 2.0 # seconds to regain ONE charge
|
||||
@export var dash_invulnerability_time: float = 0.1
|
||||
|
||||
# ── Landing feel ──────────────────────────────────────────────────────────────
|
||||
@export var land_soft_speed: float = 8.0 # fall speed where landing feedback starts
|
||||
@export var land_heavy_speed: float = 18.0 # fall speed considered a heavy landing
|
||||
|
||||
# ── Crouch ────────────────────────────────────────────────────────────────────
|
||||
@export var crouch_transition_speed: float = 12.0 # capsule/camera height lerp rate
|
||||
|
||||
# ── Rocket Jump ───────────────────────────────────────────────────────────────
|
||||
@export var rocket_jump_self_damage: float = 15.0
|
||||
@export var rocket_jump_up_impulse: float = 20.0
|
||||
@@ -106,3 +124,6 @@ class_name MovementParams
|
||||
@export var head_bob_amplitude: float = 0.04
|
||||
@export var wall_run_tilt_angle: float = 12.0
|
||||
@export var wall_run_tilt_speed: float = 8.0
|
||||
@export var slide_tilt_angle: float = 4.0
|
||||
@export var land_dip_scale: float = 0.012 # camera dip per m/s of landing speed
|
||||
@export var land_dip_max: float = 0.25 # max camera dip on heavy landings
|
||||
|
||||
@@ -3,6 +3,15 @@ class_name MovementStateMachine
|
||||
|
||||
## Generic state machine for player movement.
|
||||
## Each state is a Node child; the machine switches between them.
|
||||
##
|
||||
## The machine owns cross-state concerns so individual states stay small:
|
||||
## - input snapshot (written by the controller each tick)
|
||||
## - jump buffering / coyote time / jump cooldown
|
||||
## - crouch capsule resizing (smoothly lerped, single owner)
|
||||
## - dash cooldown (per-instance, not shared between players)
|
||||
## - grapple hook raycast + travel
|
||||
## - wall detection helpers
|
||||
## - chain-bonus bookkeeping
|
||||
|
||||
signal state_changed(from_state: String, to_state: String)
|
||||
signal movement_event(event_name: String, data: Dictionary)
|
||||
@@ -19,6 +28,7 @@ var wish_dir_world: Vector3 = Vector3.ZERO
|
||||
var input_jump_pressed: bool = false
|
||||
var input_jump_just_pressed: bool = false
|
||||
var input_crouch: bool = false
|
||||
var input_sprint: bool = false
|
||||
var input_dash: bool = false
|
||||
var input_grapple: bool = false
|
||||
var input_grapple_just_pressed: bool = false
|
||||
@@ -38,6 +48,8 @@ var last_ground_time: float = 0.0
|
||||
var jump_buffer_time: float = 0.0
|
||||
var coyote_timer: float = 0.0
|
||||
var jump_cooldown_timer: float = 0.0
|
||||
var dash_charges: int = -1 # -1 = initialize from params on first tick
|
||||
var dash_recharge_timer: float = 0.0
|
||||
var current_jump_count: int = 0
|
||||
var chain_timer: float = 0.0
|
||||
var chain_count: int = 0
|
||||
@@ -48,6 +60,9 @@ var wall_cooldown_timer: float = 0.0 # Prevents instant re-attachment after wa
|
||||
var is_crouched: bool = false
|
||||
var can_wall_climb: bool = true
|
||||
|
||||
# Smooth capsule crouch: the machine is the single owner of capsule height.
|
||||
var _capsule_current_height: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Ensure grapple state is injected
|
||||
@@ -87,9 +102,19 @@ func _physics_process(delta: float) -> void:
|
||||
else:
|
||||
jump_buffer_time = maxf(jump_buffer_time - delta, 0.0)
|
||||
|
||||
# Update jump cooldown
|
||||
if jump_cooldown_timer > 0.0:
|
||||
# Update timers
|
||||
jump_cooldown_timer = maxf(jump_cooldown_timer - delta, 0.0)
|
||||
slide_cooldown_timer = maxf(slide_cooldown_timer - delta, 0.0)
|
||||
wall_cooldown_timer = maxf(wall_cooldown_timer - delta, 0.0)
|
||||
|
||||
# Dash charges: regain one per dash_cooldown while below max
|
||||
if dash_charges < 0:
|
||||
dash_charges = params.dash_charges
|
||||
if dash_charges < params.dash_charges:
|
||||
dash_recharge_timer -= delta
|
||||
if dash_recharge_timer <= 0.0:
|
||||
dash_charges += 1
|
||||
dash_recharge_timer = params.dash_cooldown if dash_charges < params.dash_charges else 0.0
|
||||
|
||||
# Update chain timer
|
||||
if chain_timer > 0.0 and on_ground:
|
||||
@@ -112,21 +137,9 @@ func _physics_process(delta: float) -> void:
|
||||
movement_event.emit("grapple_latch", {})
|
||||
switch_to("grapple")
|
||||
|
||||
# Manage global crouch state
|
||||
var want_crouch = input_crouch
|
||||
if current_state == "slide":
|
||||
want_crouch = true
|
||||
if want_crouch != is_crouched:
|
||||
is_crouched = want_crouch
|
||||
_apply_crouch(is_crouched)
|
||||
|
||||
# Update slide cooldown
|
||||
if slide_cooldown_timer > 0.0:
|
||||
slide_cooldown_timer = maxf(slide_cooldown_timer - delta, 0.0)
|
||||
|
||||
# Update wall cooldown
|
||||
if wall_cooldown_timer > 0.0:
|
||||
wall_cooldown_timer = maxf(wall_cooldown_timer - delta, 0.0)
|
||||
# Manage global crouch state (slide keeps the capsule low)
|
||||
is_crouched = input_crouch or current_state == "slide"
|
||||
_update_crouch_capsule(delta)
|
||||
|
||||
if current_state.is_empty():
|
||||
return
|
||||
@@ -157,6 +170,33 @@ func switch_to(new_state_name: String, data: Dictionary = {}) -> void:
|
||||
state_changed.emit(prev, new_state_name)
|
||||
|
||||
|
||||
## Called by states when the player touches down. Emits the landing event so
|
||||
## camera/audio/animation can react proportionally to impact speed.
|
||||
func notify_landed(fall_speed: float) -> void:
|
||||
on_ground = true
|
||||
current_jump_count = 0
|
||||
can_wall_climb = true
|
||||
if fall_speed > params.land_soft_speed:
|
||||
movement_event.emit("land", {
|
||||
"fall_speed": fall_speed,
|
||||
"heavy": fall_speed >= params.land_heavy_speed,
|
||||
})
|
||||
|
||||
|
||||
## Shared jump executed from ground-like states. Keeps horizontal momentum.
|
||||
func do_jump(extra_boost: float = 0.0) -> void:
|
||||
player.velocity.y = params.jump_velocity + extra_boost
|
||||
current_jump_count = 1
|
||||
on_ground = false
|
||||
coyote_timer = 0.0
|
||||
jump_buffer_time = 0.0
|
||||
jump_cooldown_timer = params.jump_cooldown
|
||||
register_chain_mechanic("jump")
|
||||
movement_event.emit("jump", {})
|
||||
if player.jump_player:
|
||||
player.jump_player.play()
|
||||
|
||||
|
||||
func _try_start_grapple() -> void:
|
||||
if not player or not player.camera:
|
||||
return
|
||||
@@ -170,9 +210,7 @@ func _try_start_grapple() -> void:
|
||||
if not hit.is_empty():
|
||||
grapple_point = hit.position
|
||||
grapple_length = origin.distance_to(grapple_point)
|
||||
|
||||
# Travel at 45 m/s
|
||||
grapple_travel_time = grapple_length / 45
|
||||
grapple_travel_time = grapple_length / params.grapple_shoot_speed
|
||||
grapple_shoot_time = 0.0
|
||||
is_grapple_shooting = true
|
||||
movement_event.emit("grapple_shoot", {})
|
||||
@@ -308,8 +346,30 @@ func detect_wall_forward() -> Dictionary:
|
||||
return {"hit": true, "normal": normal.normalized(), "is_short": hit_high.is_empty()}
|
||||
|
||||
|
||||
func _apply_crouch(crouched: bool) -> void:
|
||||
var shape_node = null
|
||||
## Perform an instant vault over a short wall: forward+up impulse, camera kick
|
||||
## via the rig, cooldown so we don't immediately re-detect the same wall.
|
||||
func do_vault() -> void:
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var h_look := Vector3(look_dir.x, 0.0, look_dir.z)
|
||||
if h_look.length_squared() > 0.01:
|
||||
h_look = h_look.normalized()
|
||||
player.velocity = h_look * params.wall_climb_vault_forward
|
||||
player.velocity.y = params.wall_climb_vault_up
|
||||
if player.vault_player:
|
||||
player.vault_player.play()
|
||||
movement_event.emit("vault", {})
|
||||
var rig = player.get_node_or_null("HeadPivot")
|
||||
if rig and rig.has_method("add_pitch_impulse"):
|
||||
rig.add_pitch_impulse(6.0)
|
||||
wall_cooldown_timer = 0.3
|
||||
register_chain_mechanic("vault")
|
||||
|
||||
|
||||
## Smoothly lerp the collision capsule toward the crouch/stand height and keep
|
||||
## the capsule's bottom anchored so shrinking doesn't lift the player off the
|
||||
## floor. Standing back up is blocked while there's no headroom.
|
||||
func _update_crouch_capsule(delta: float) -> void:
|
||||
var shape_node: CollisionShape3D = null
|
||||
for child in player.get_children():
|
||||
if child is CollisionShape3D and child.shape is CapsuleShape3D:
|
||||
shape_node = child
|
||||
@@ -317,19 +377,59 @@ func _apply_crouch(crouched: bool) -> void:
|
||||
if not shape_node:
|
||||
return
|
||||
var shape = shape_node.shape as CapsuleShape3D
|
||||
var head = player.get_node_or_null("HeadPivot")
|
||||
if _capsule_current_height <= 0.0:
|
||||
_capsule_current_height = shape.height
|
||||
|
||||
if crouched:
|
||||
shape.height = original_capsule_height * 0.5
|
||||
shape_node.position.y = -original_capsule_height * 0.25
|
||||
if head:
|
||||
head.position.y = 0.7 - (original_capsule_height * 0.5)
|
||||
else:
|
||||
shape.height = original_capsule_height
|
||||
shape_node.position.y = 0.0
|
||||
if head:
|
||||
head.position.y = 0.7
|
||||
var target_height := original_capsule_height * (0.5 if is_crouched else 1.0)
|
||||
|
||||
# Don't stand up into a ceiling
|
||||
if not is_crouched and target_height > _capsule_current_height + 0.01:
|
||||
var space_state := player.get_world_3d().direct_space_state
|
||||
var from := player.global_position + Vector3.UP * (_capsule_current_height * 0.5)
|
||||
var to := player.global_position + Vector3.UP * (original_capsule_height * 0.55)
|
||||
var ray := PhysicsRayQueryParameters3D.create(from, to)
|
||||
ray.exclude = [player.get_rid()]
|
||||
if not space_state.intersect_ray(ray).is_empty():
|
||||
target_height = _capsule_current_height # hold until clear
|
||||
|
||||
var t := 1.0 - exp(-params.crouch_transition_speed * delta)
|
||||
_capsule_current_height = lerpf(_capsule_current_height, target_height, t)
|
||||
if absf(_capsule_current_height - target_height) < 0.005:
|
||||
_capsule_current_height = target_height
|
||||
|
||||
shape.height = _capsule_current_height
|
||||
# Keep feet planted: offset the shape down by half the height loss.
|
||||
shape_node.position.y = -(original_capsule_height - _capsule_current_height) * 0.5
|
||||
|
||||
|
||||
## 0 (standing) → 1 (fully crouched); used by the camera rig for eye height.
|
||||
func get_crouch_factor() -> float:
|
||||
if original_capsule_height <= 0.0 or _capsule_current_height <= 0.0:
|
||||
return 0.0
|
||||
return clampf((original_capsule_height - _capsule_current_height)
|
||||
/ (original_capsule_height * 0.5), 0.0, 1.0)
|
||||
|
||||
|
||||
func can_dash() -> bool:
|
||||
return dash_charges != 0 # -1 (uninitialized) counts as ready
|
||||
|
||||
|
||||
## Spend one dash charge and start the recharge clock if it isn't running.
|
||||
func consume_dash_charge() -> void:
|
||||
if dash_charges < 0:
|
||||
dash_charges = params.dash_charges
|
||||
dash_charges = maxi(dash_charges - 1, 0)
|
||||
if dash_recharge_timer <= 0.0:
|
||||
dash_recharge_timer = params.dash_cooldown
|
||||
|
||||
|
||||
## Seconds until the NEXT dash is available (0 when a charge is banked).
|
||||
## HUDs also read get_dash_charges() to draw pips.
|
||||
func get_dash_cooldown_remaining() -> float:
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
return maxf(0.0, params.dash_cooldown - (now - StateDash._last_dash_time))
|
||||
if dash_charges != 0:
|
||||
return 0.0
|
||||
return maxf(dash_recharge_timer, 0.0)
|
||||
|
||||
|
||||
func get_dash_charges() -> int:
|
||||
return dash_charges if dash_charges >= 0 else params.dash_charges
|
||||
|
||||
@@ -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
|
||||
@@ -67,6 +73,8 @@ var synced_movement_speed: float = 0.0
|
||||
var synced_is_crouching: bool = false
|
||||
var synced_position: Vector3 = Vector3.ZERO
|
||||
var synced_velocity: Vector3 = Vector3.ZERO
|
||||
var synced_is_ads: bool = false
|
||||
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean)
|
||||
@export var synced_skin_id: String = ""
|
||||
@export var synced_weapon_path: String = ""
|
||||
|
||||
@@ -132,10 +140,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 +814,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
|
||||
@@ -809,20 +866,51 @@ func _physics_process(_delta: float) -> void:
|
||||
else:
|
||||
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0)
|
||||
|
||||
# Local player's aim-down-sights state (drives the model's weapon raise).
|
||||
synced_is_ads = _read_ads()
|
||||
|
||||
# Drive the visual model (skinned GLB or procedural) from local state
|
||||
var visual = get_visual_model()
|
||||
if visual:
|
||||
var h_speed = Vector2(velocity.x, velocity.z).length()
|
||||
visual.update_state(sm.current_state, h_speed, sm.input_crouch)
|
||||
if visual.has_method("set_locomotion"):
|
||||
var d := _local_move_dir()
|
||||
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
|
||||
if visual.has_method("set_wall_side"):
|
||||
visual.set_wall_side(sm.wall_side)
|
||||
|
||||
# Publish state for remote peers
|
||||
synced_movement_state = sm.current_state
|
||||
synced_movement_speed = Vector2(velocity.x, velocity.z).length()
|
||||
synced_is_crouching = sm.input_crouch
|
||||
synced_wall_side = sm.wall_side
|
||||
synced_position = position
|
||||
synced_velocity = velocity
|
||||
|
||||
|
||||
## Movement direction relative to facing: x = strafe (+right), y = forward
|
||||
## (+forward). Derived from velocity so it works for local and remote players.
|
||||
func _local_move_dir() -> Vector2:
|
||||
var hspeed := Vector2(velocity.x, velocity.z).length()
|
||||
if hspeed < 0.5:
|
||||
return Vector2.ZERO
|
||||
var local_vel := global_transform.basis.inverse() * velocity
|
||||
return Vector2(local_vel.x / hspeed, -local_vel.z / hspeed)
|
||||
|
||||
|
||||
## Whether the local player's active weapon is aiming down sights.
|
||||
func _read_ads() -> bool:
|
||||
if not is_instance_valid(camera):
|
||||
return false
|
||||
var wman = camera.get_node_or_null("WeaponManager")
|
||||
if wman and "active_slot" in wman and wman.weapons.has(wman.active_slot):
|
||||
var w = wman.weapons[wman.active_slot]
|
||||
if w and "is_ads" in w:
|
||||
return w.is_ads
|
||||
return false
|
||||
|
||||
|
||||
func _on_movement_event(ev: String, data: Dictionary) -> void:
|
||||
if ev == "chain_updated":
|
||||
chain_updated.emit(data.count, data.bonus)
|
||||
@@ -830,6 +918,14 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
|
||||
grapple_shoot_player.play()
|
||||
elif ev == "grapple_latch":
|
||||
grapple_latch_player.play()
|
||||
elif ev == "land":
|
||||
# Landing thud: reuse the footstep sample, pitched down and louder
|
||||
# with impact. Ground state resets pitch/volume before each step.
|
||||
if footstep_player:
|
||||
var heavy: bool = data.get("heavy", false)
|
||||
footstep_player.pitch_scale = 0.55 if heavy else 0.7
|
||||
footstep_player.volume_db = 2.0 if heavy else -2.0
|
||||
footstep_player.play()
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Remote players: interpolate toward the owner's synced transform and
|
||||
@@ -859,6 +955,11 @@ func _process(delta: float) -> void:
|
||||
var visual = get_visual_model()
|
||||
if visual:
|
||||
visual.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching)
|
||||
if visual.has_method("set_locomotion"):
|
||||
var d := _local_move_dir()
|
||||
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
|
||||
if visual.has_method("set_wall_side"):
|
||||
visual.set_wall_side(synced_wall_side)
|
||||
# Check for weapon changes
|
||||
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
|
||||
visual.set_weapon(synced_weapon_path)
|
||||
@@ -1027,6 +1128,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
|
||||
|
||||
@@ -28,30 +28,38 @@ func update(delta: float) -> void:
|
||||
grav *= params.low_jump_multiplier
|
||||
vel.y -= grav * delta
|
||||
|
||||
# ── Air control ───────────────────────────────────────────────────────
|
||||
# ── Air control: Quake-style accelerate ───────────────────────────────
|
||||
# Only the velocity component ALONG wish_dir is capped, so turning while
|
||||
# strafing genuinely gains speed. Total input-driven speed is bounded by
|
||||
# max_air_speed; externally-gained speed (dash, rockets) is never clamped.
|
||||
var wish_dir: Vector3 = machine.wish_dir_world
|
||||
var hvel := Vector3(vel.x, 0.0, vel.z)
|
||||
|
||||
if wish_dir.length_squared() > 0.01:
|
||||
wish_dir = wish_dir.normalized()
|
||||
var current_speed = hvel.length()
|
||||
var wish_speed: float = minf(machine.get_effective_speed(params.walk_speed), params.max_air_speed)
|
||||
var current_along := hvel.dot(wish_dir)
|
||||
var add_speed := wish_speed - current_along
|
||||
if add_speed > 0.0:
|
||||
var accel_speed: float = minf(params.air_strafe_accel * delta, add_speed)
|
||||
# Quake's trick: cap per-tick projection so sharp turns give the gain
|
||||
accel_speed = minf(accel_speed, params.air_wish_speed_cap)
|
||||
hvel += wish_dir * accel_speed
|
||||
|
||||
# Add a strong force in the wish direction
|
||||
var air_accel = params.air_strafe_accel * delta
|
||||
var new_hvel = hvel + wish_dir * air_accel
|
||||
|
||||
# Limit the speed so we don't gain speed purely from air control.
|
||||
# We only allow air control to change our direction (strafing)
|
||||
# or to accelerate us up to our walk speed if we started slow.
|
||||
var max_allowed_speed = maxf(current_speed, params.walk_speed)
|
||||
|
||||
if new_hvel.length() > max_allowed_speed:
|
||||
new_hvel = new_hvel.normalized() * max_allowed_speed
|
||||
|
||||
hvel = new_hvel
|
||||
# Direct steering: bend existing velocity toward the input WITHOUT
|
||||
# changing its magnitude, so airborne direction changes feel light.
|
||||
# Skipped when input opposes travel (braking is the accel path's job,
|
||||
# and lerping through zero would flip the heading unstably).
|
||||
var speed_now := hvel.length()
|
||||
if speed_now > 1.0:
|
||||
var travel_dir := hvel / speed_now
|
||||
if travel_dir.dot(wish_dir) > -0.7:
|
||||
var steered := hvel.lerp(wish_dir * speed_now, params.air_steer_rate * delta)
|
||||
if steered.length_squared() > 0.01:
|
||||
hvel = steered.normalized() * speed_now
|
||||
else:
|
||||
# No input: slight air drag (very subtle)
|
||||
hvel *= (1.0 - 0.5 * delta)
|
||||
# No input: barely any drag — held momentum should carry
|
||||
hvel *= (1.0 - 0.15 * delta)
|
||||
|
||||
vel.x = hvel.x
|
||||
vel.z = hvel.z
|
||||
@@ -61,8 +69,8 @@ func update(delta: float) -> void:
|
||||
|
||||
# ── Landing ───────────────────────────────────────────────────────────
|
||||
if player.is_on_floor():
|
||||
machine.on_ground = true
|
||||
machine.current_jump_count = 0
|
||||
var fall_speed: float = maxf(-vel.y, 0.0)
|
||||
machine.notify_landed(fall_speed)
|
||||
|
||||
# Bunny hop: if jump was buffered or pressed on landing frame
|
||||
if (machine.input_jump_pressed or machine.jump_buffer_time > 0.0) and machine.jump_cooldown_timer <= 0.0:
|
||||
@@ -105,26 +113,7 @@ func update(delta: float) -> void:
|
||||
var fwd_wall = machine.detect_wall_forward()
|
||||
if fwd_wall.hit:
|
||||
if fwd_wall.is_short:
|
||||
# Instant Vault
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var h_look := Vector3(look_dir.x, 0.0, look_dir.z)
|
||||
if h_look.length_squared() > 0.01:
|
||||
h_look = h_look.normalized()
|
||||
player.velocity = h_look * params.wall_climb_vault_forward
|
||||
player.velocity.y = params.wall_climb_vault_up
|
||||
if player.vault_player:
|
||||
player.vault_player.play()
|
||||
|
||||
# Quick camera animation (tilt up and forward bob)
|
||||
var camera = player.camera
|
||||
if camera:
|
||||
var tween = player.create_tween()
|
||||
tween.tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x + 10.0, 0.15).set_trans(Tween.TRANS_SINE)
|
||||
tween.parallel().tween_property(camera, "v_offset", -0.2, 0.15).set_trans(Tween.TRANS_SINE)
|
||||
tween.chain().tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x, 0.2).set_trans(Tween.TRANS_SINE)
|
||||
tween.parallel().tween_property(camera, "v_offset", 0.0, 0.2).set_trans(Tween.TRANS_SINE)
|
||||
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.do_vault()
|
||||
return
|
||||
else:
|
||||
# Check if moving directly into wall and looking generally towards it
|
||||
@@ -150,6 +139,6 @@ func update(delta: float) -> void:
|
||||
return
|
||||
|
||||
# ── Dash ──────────────────────────────────────────────────────────────
|
||||
if machine.input_dash:
|
||||
if machine.input_dash and machine.can_dash():
|
||||
machine.switch_to("dash")
|
||||
return
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
extends Node
|
||||
class_name StateDash
|
||||
|
||||
## Short burst dash. Cooldown lives on the machine (per-player instance) and is
|
||||
## checked by the states that enter dash, so entering this state always dashes.
|
||||
## Momentum is fully preserved on exit — the dash ADDS speed to your run.
|
||||
|
||||
var machine: MovementStateMachine
|
||||
var params: MovementParams:
|
||||
get: return machine.params
|
||||
|
||||
var elapsed: float = 0.0
|
||||
var direction: Vector3 = Vector3.ZERO
|
||||
var _exit_speed: float = 0.0
|
||||
var _last_vel: Vector3 = Vector3.ZERO
|
||||
|
||||
# Cooldown tracked across dash instances
|
||||
static var _last_dash_time: float = -999.0
|
||||
var _dash_speed: float = 0.0
|
||||
|
||||
|
||||
func enter(_data: Dictionary = {}) -> void:
|
||||
# Check cooldown
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
if now - _last_dash_time < params.dash_cooldown:
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
elapsed = 0.0
|
||||
_last_dash_time = now
|
||||
machine.consume_dash_charge()
|
||||
|
||||
var player := machine.player
|
||||
if player.dash_player:
|
||||
@@ -38,6 +32,7 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
direction = direction.normalized()
|
||||
|
||||
machine.register_chain_mechanic("dash")
|
||||
machine.movement_event.emit("dash", {})
|
||||
|
||||
var current_hvel := Vector3(machine.player.velocity.x, 0.0, machine.player.velocity.z)
|
||||
var current_speed := current_hvel.length()
|
||||
@@ -47,46 +42,39 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
|
||||
if proj >= 0.0:
|
||||
# Dashing forward or diagonally forward: add base dash speed to the projected speed
|
||||
_exit_speed = proj + base_dash_speed
|
||||
_dash_speed = proj + base_dash_speed
|
||||
else:
|
||||
# Dashing backward or diagonally backward:
|
||||
# Reflect current momentum into the new direction, smoothly scaling from base_dash_speed (at sideways) to current_speed (at exactly backward).
|
||||
# Reflect current momentum into the new direction, smoothly scaling from
|
||||
# base_dash_speed (at sideways) to current_speed (at exactly backward).
|
||||
var backward_factor = -proj / current_speed if current_speed > 0.001 else 0.0
|
||||
_exit_speed = lerpf(base_dash_speed, maxf(current_speed, base_dash_speed), backward_factor)
|
||||
_dash_speed = lerpf(base_dash_speed, maxf(current_speed, base_dash_speed), backward_factor)
|
||||
|
||||
machine.player.velocity = direction * _exit_speed
|
||||
machine.player.velocity = direction * _dash_speed
|
||||
machine.on_ground = false
|
||||
_last_vel = machine.player.velocity
|
||||
|
||||
|
||||
func exit() -> void:
|
||||
# Preserve dash velocity on exit (don't cut speed abruptly)
|
||||
pass
|
||||
|
||||
|
||||
func update(delta: float) -> void:
|
||||
elapsed += delta
|
||||
var player := machine.player
|
||||
|
||||
if elapsed > params.dash_duration:
|
||||
machine.player.velocity = direction * _exit_speed * 0.85
|
||||
if machine.player.is_on_floor():
|
||||
# Keep the full dash velocity — the dash is a speed investment
|
||||
player.velocity = direction * _dash_speed
|
||||
if player.is_on_floor():
|
||||
machine.on_ground = true
|
||||
machine.switch_to("ground")
|
||||
else:
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# Maintain dash velocity
|
||||
var player := machine.player
|
||||
var vel: Vector3 = player.velocity
|
||||
|
||||
if vel.distance_squared_to(_last_vel) > 100.0:
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
vel = direction * _exit_speed
|
||||
# Maintain dash velocity, flat trajectory
|
||||
var vel := direction * _dash_speed
|
||||
if player.is_on_floor():
|
||||
vel.y = -0.5 # Snap to floor to handle ramps
|
||||
|
||||
player.velocity = vel
|
||||
player.move_and_slide()
|
||||
_last_vel = player.velocity
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
extends Node
|
||||
class_name StateGrapple
|
||||
|
||||
## Swing grapple with active rope control:
|
||||
## - the rope acts as a hard pendulum constraint at its current length
|
||||
## - holding forward reels in (rope shortens, converts to speed)
|
||||
## - holding back pays rope out (up to the original latch length)
|
||||
## - strafe input steers tangentially around the swing sphere
|
||||
## - jump detaches with a boost along your current motion
|
||||
|
||||
var machine: MovementStateMachine
|
||||
var params: MovementParams:
|
||||
get: return machine.params
|
||||
|
||||
var _rope_length: float = 0.0
|
||||
|
||||
|
||||
func enter(_data: Dictionary = {}) -> void:
|
||||
machine.on_ground = false
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.register_chain_mechanic("grapple")
|
||||
_rope_length = machine.grapple_length
|
||||
|
||||
|
||||
func exit() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func update(delta: float) -> void:
|
||||
var player := machine.player
|
||||
var vel: Vector3 = player.velocity
|
||||
@@ -24,16 +37,26 @@ func update(delta: float) -> void:
|
||||
return
|
||||
|
||||
var grapple_pos: Vector3 = machine.grapple_point
|
||||
var current_dist: float = player.global_position.distance_to(grapple_pos)
|
||||
var dir_to_point: Vector3 = (grapple_pos - player.global_position).normalized()
|
||||
var to_point: Vector3 = grapple_pos - player.global_position
|
||||
var current_dist: float = to_point.length()
|
||||
if current_dist < 0.01:
|
||||
machine.switch_to("air")
|
||||
return
|
||||
var dir_to_point: Vector3 = to_point / current_dist
|
||||
|
||||
# Jump to detach and get a boost
|
||||
if machine.input_jump_just_pressed and machine.jump_cooldown_timer <= 0.0:
|
||||
vel.y = params.jump_velocity
|
||||
# Add a directional boost if jumping while swinging
|
||||
vel.y = maxf(vel.y, params.jump_velocity)
|
||||
# Boost along current motion so the release keeps the swing's energy
|
||||
var h_vel := Vector3(vel.x, 0.0, vel.z)
|
||||
if h_vel.length_squared() > 0.1:
|
||||
var boost := h_vel.normalized() * params.grapple_jump_boost * 0.5
|
||||
vel.x += boost.x
|
||||
vel.z += boost.z
|
||||
# Plus a steer boost toward held direction
|
||||
var h_look = machine.wish_dir_world
|
||||
if h_look.length_squared() > 0.1:
|
||||
var h_boost = h_look.normalized() * params.grapple_jump_boost
|
||||
var h_boost = h_look.normalized() * params.grapple_jump_boost * 0.5
|
||||
vel.x += h_boost.x
|
||||
vel.z += h_boost.z
|
||||
|
||||
@@ -48,39 +71,40 @@ func update(delta: float) -> void:
|
||||
# Apply gravity
|
||||
vel.y -= params.gravity * delta
|
||||
|
||||
# Apply slight inward pull (reeling in)
|
||||
# Base reel: constant gentle pull toward the hook
|
||||
vel += dir_to_point * params.grapple_pull_force * delta
|
||||
|
||||
# Pendulum / Rope physics
|
||||
# If the player tries to go further than the original rope length, pull them back aggressively
|
||||
var max_len: float = machine.grapple_length
|
||||
if current_dist > max_len:
|
||||
# Project velocity onto the tangent of the sphere (prevent moving further away)
|
||||
# ── Active rope control ───────────────────────────────────────────────
|
||||
var fwd_input := -machine.input_dir.y # +1 holding forward, -1 back
|
||||
if fwd_input > 0.1:
|
||||
# Reel in: shorten rope and pull hard — converts to swing speed
|
||||
vel += dir_to_point * params.grapple_reel_force * fwd_input * delta
|
||||
_rope_length = maxf(_rope_length - 8.0 * fwd_input * delta, 2.0)
|
||||
elif fwd_input < -0.1:
|
||||
# Pay out rope back toward the original latch length
|
||||
_rope_length = minf(_rope_length + 8.0 * -fwd_input * delta, machine.grapple_length)
|
||||
|
||||
# Keep the working length taut to where we actually are (swinging inside
|
||||
# the sphere shortens the constraint, giving crisp Tarzan arcs)
|
||||
_rope_length = minf(_rope_length, maxf(current_dist, 2.0))
|
||||
|
||||
# ── Pendulum constraint at the current rope length ────────────────────
|
||||
if current_dist > _rope_length:
|
||||
# Kill outward radial velocity (the rope is taut and inextensible)
|
||||
var radial_vel = vel.project(dir_to_point)
|
||||
# If radial_vel is pointing AWAY from the grapple point (dot < 0), kill it
|
||||
if radial_vel.dot(dir_to_point) < 0:
|
||||
vel -= radial_vel
|
||||
# Spring correction toward the sphere surface
|
||||
var diff = current_dist - _rope_length
|
||||
vel += dir_to_point * diff * params.grapple_spring_strength * delta
|
||||
|
||||
# Add a spring force to pull them back to the sphere
|
||||
var diff = current_dist - max_len
|
||||
var spring_force = dir_to_point * diff * params.grapple_spring_strength
|
||||
vel += spring_force * delta
|
||||
|
||||
# Air control while swinging
|
||||
var wish_dir: Vector3 = machine.wish_dir_world
|
||||
if wish_dir.length_squared() > 0.01:
|
||||
wish_dir = wish_dir.normalized()
|
||||
# Add force tangentially to the rope
|
||||
# We want to steer, but not pull away from or directly into the rope.
|
||||
var tangent_wish = wish_dir - wish_dir.project(dir_to_point)
|
||||
if tangent_wish.length_squared() > 0.01:
|
||||
vel += tangent_wish.normalized() * params.grapple_air_control * delta
|
||||
# ── Tangential steering (strafe around the swing sphere) ──────────────
|
||||
var strafe_input := machine.input_dir.x
|
||||
if absf(strafe_input) > 0.1:
|
||||
var right := player.global_transform.basis.x
|
||||
var tangent_steer := right - right.project(dir_to_point)
|
||||
if tangent_steer.length_squared() > 0.01:
|
||||
vel += tangent_steer.normalized() * params.grapple_air_control * strafe_input * delta
|
||||
|
||||
player.velocity = vel
|
||||
player.move_and_slide()
|
||||
|
||||
# Remove ground detach so players can grapple along the floor
|
||||
# if player.is_on_floor():
|
||||
# machine.on_ground = true
|
||||
# machine.switch_to("ground")
|
||||
# return
|
||||
|
||||
@@ -7,6 +7,7 @@ var params: MovementParams:
|
||||
|
||||
var _footstep_timer: float = 0.0
|
||||
|
||||
|
||||
func enter(_data: Dictionary = {}) -> void:
|
||||
machine.on_ground = true
|
||||
machine.current_jump_count = 0
|
||||
@@ -16,9 +17,6 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
if rig:
|
||||
rig.clear_wall_tilt()
|
||||
|
||||
# Ensure capsule is correct when entering ground state
|
||||
_update_capsule_height()
|
||||
|
||||
|
||||
func exit() -> void:
|
||||
pass
|
||||
@@ -28,36 +26,19 @@ func update(delta: float) -> void:
|
||||
var player := machine.player
|
||||
var vel: Vector3 = player.velocity
|
||||
|
||||
# ── Jump (coyote time + jump buffer) ──────────────────────────────────
|
||||
if machine.input_jump_just_pressed and machine.coyote_timer > 0.0 and machine.jump_cooldown_timer <= 0.0:
|
||||
player.velocity.y = params.jump_velocity
|
||||
machine.current_jump_count = 1
|
||||
machine.on_ground = false
|
||||
machine.coyote_timer = 0.0
|
||||
machine.register_chain_mechanic("jump")
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
if player.jump_player:
|
||||
player.jump_player.play()
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# Jump buffer: player pressed jump just before landing
|
||||
if machine.on_ground and machine.jump_buffer_time > 0.0 and machine.jump_cooldown_timer <= 0.0:
|
||||
player.velocity.y = params.jump_velocity
|
||||
machine.current_jump_count = 1
|
||||
machine.on_ground = false
|
||||
machine.jump_buffer_time = 0.0
|
||||
machine.register_chain_mechanic("jump")
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
if player.jump_player:
|
||||
player.jump_player.play()
|
||||
# ── Jump (direct press w/ coyote, or buffered from before landing) ────
|
||||
if machine.jump_cooldown_timer <= 0.0:
|
||||
var pressed := machine.input_jump_just_pressed and machine.coyote_timer > 0.0
|
||||
var buffered := machine.on_ground and machine.jump_buffer_time > 0.0
|
||||
if pressed or buffered:
|
||||
machine.do_jump()
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# ── Slide (crouch while moving fast enough) ───────────────────────────
|
||||
if machine.input_crouch and machine.slide_cooldown_timer <= 0.0:
|
||||
var hspeed := Vector3(player.velocity.x, 0.0, player.velocity.z).length()
|
||||
if hspeed > params.slide_min_speed:
|
||||
if hspeed > params.slide_min_speed + 1.0:
|
||||
machine.switch_to("slide")
|
||||
return
|
||||
|
||||
@@ -80,7 +61,7 @@ func update(delta: float) -> void:
|
||||
vel.x = hvel.x
|
||||
vel.z = hvel.z
|
||||
|
||||
# ── Gravity (correct direction: downward) ─────────────────────────────
|
||||
# ── Gravity / floor snap ──────────────────────────────────────────────
|
||||
if not player.is_on_floor():
|
||||
vel.y -= params.gravity * delta
|
||||
else:
|
||||
@@ -94,6 +75,63 @@ func update(delta: float) -> void:
|
||||
|
||||
# ── Stair Stepping ────────────────────────────────────────────────────
|
||||
if wish_dir.length_squared() > 0.01 and not machine.input_crouch:
|
||||
_try_step_up(player, wish_dir, hvel)
|
||||
|
||||
var current_hspeed = Vector2(player.velocity.x, player.velocity.z).length()
|
||||
if current_hspeed > 1.0 and player.is_on_floor():
|
||||
_footstep_timer -= delta
|
||||
if _footstep_timer <= 0.0:
|
||||
if player.footstep_player:
|
||||
# Reset pitch/volume (the landing thud reuses this player) and
|
||||
# add slight variation so steps don't machine-gun.
|
||||
player.footstep_player.pitch_scale = randf_range(0.92, 1.08)
|
||||
player.footstep_player.volume_db = -6.0
|
||||
player.footstep_player.play()
|
||||
_footstep_timer = max(0.2, 3.0 / current_hspeed)
|
||||
else:
|
||||
_footstep_timer = 0.0
|
||||
|
||||
# ── Update ground status ──────────────────────────────────────────────
|
||||
if player.is_on_floor():
|
||||
machine.on_ground = true
|
||||
machine.current_jump_count = 0
|
||||
else:
|
||||
machine.on_ground = false
|
||||
|
||||
# ── Launch or Falling off ledge → air state ───────────────────────────
|
||||
if not machine.on_ground:
|
||||
if player.velocity.y > 0.0 or machine.coyote_timer <= 0.0:
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# ── Wall interaction (Vault, Climb) ───────────────────────────────────
|
||||
if machine.input_dir.length() > 0.1 and machine.input_dir.y <= 0.0 \
|
||||
and machine.wall_cooldown_timer <= 0.0 and not machine.input_crouch:
|
||||
var fwd_wall = machine.detect_wall_forward()
|
||||
if fwd_wall.hit:
|
||||
var hspeed := Vector3(player.velocity.x, 0.0, player.velocity.z).length()
|
||||
if fwd_wall.is_short and hspeed > 3.0:
|
||||
machine.do_vault()
|
||||
machine.switch_to("air")
|
||||
return
|
||||
elif not fwd_wall.is_short:
|
||||
# Check if moving directly into wall and looking generally towards it
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var looking_towards := look_dir.dot(-fwd_wall.normal) > 0.0
|
||||
var moving_towards := machine.wish_dir_world.dot(-fwd_wall.normal) > cos(deg_to_rad(params.wall_climb_max_angle))
|
||||
|
||||
if looking_towards and moving_towards and machine.can_wall_climb:
|
||||
machine.wall_normal = fwd_wall.normal
|
||||
machine.switch_to("wall_climb")
|
||||
return
|
||||
|
||||
# ── Dash ──────────────────────────────────────────────────────────────
|
||||
if machine.input_dash and machine.can_dash():
|
||||
machine.switch_to("dash")
|
||||
return
|
||||
|
||||
|
||||
func _try_step_up(player: CharacterBody3D, wish_dir: Vector3, hvel: Vector3) -> void:
|
||||
for i in range(player.get_slide_collision_count()):
|
||||
var col = player.get_slide_collision(i)
|
||||
if abs(col.get_normal().y) < 0.3: # Hit a wall
|
||||
@@ -137,107 +175,8 @@ func update(delta: float) -> void:
|
||||
player.velocity.z = hvel.z
|
||||
break
|
||||
|
||||
_update_capsule_height()
|
||||
|
||||
var current_hspeed = Vector2(player.velocity.x, player.velocity.z).length()
|
||||
if current_hspeed > 1.0:
|
||||
_footstep_timer -= delta
|
||||
if _footstep_timer <= 0.0:
|
||||
if player.footstep_player:
|
||||
player.footstep_player.play()
|
||||
_footstep_timer = max(0.2, 3.0 / current_hspeed)
|
||||
else:
|
||||
_footstep_timer = 0.0
|
||||
|
||||
# ── Update ground status ──────────────────────────────────────────────
|
||||
if player.is_on_floor():
|
||||
machine.on_ground = true
|
||||
machine.current_jump_count = 0
|
||||
else:
|
||||
machine.on_ground = false
|
||||
|
||||
# ── Launch or Falling off ledge → air state ───────────────────────────
|
||||
if not machine.on_ground:
|
||||
if player.velocity.y > 0.0 or machine.coyote_timer <= 0.0:
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# ── Wall interaction (Run, Climb, Vault) ──────────────────────────────
|
||||
if machine.input_dir.length() > 0.1 and machine.input_dir.y <= 0.0 and machine.wall_cooldown_timer <= 0.0 and not machine.input_crouch:
|
||||
var fwd_wall = machine.detect_wall_forward()
|
||||
if fwd_wall.hit:
|
||||
if fwd_wall.is_short:
|
||||
# Instant Vault
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var h_look := Vector3(look_dir.x, 0.0, look_dir.z)
|
||||
if h_look.length_squared() > 0.01:
|
||||
h_look = h_look.normalized()
|
||||
player.velocity = h_look * params.wall_climb_vault_forward
|
||||
player.velocity.y = params.wall_climb_vault_up
|
||||
if player.vault_player:
|
||||
player.vault_player.play()
|
||||
|
||||
# Quick camera animation (tilt up and forward bob)
|
||||
var camera = player.camera
|
||||
if camera:
|
||||
var tween = player.create_tween()
|
||||
tween.tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x + 10.0, 0.15).set_trans(Tween.TRANS_SINE)
|
||||
tween.parallel().tween_property(camera, "v_offset", -0.2, 0.15).set_trans(Tween.TRANS_SINE)
|
||||
tween.chain().tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x, 0.2).set_trans(Tween.TRANS_SINE)
|
||||
tween.parallel().tween_property(camera, "v_offset", 0.0, 0.2).set_trans(Tween.TRANS_SINE)
|
||||
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.switch_to("air")
|
||||
return
|
||||
else:
|
||||
# Check if moving directly into wall and looking generally towards it
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var looking_towards := look_dir.dot(-fwd_wall.normal) > 0.0
|
||||
var moving_towards := wish_dir.dot(-fwd_wall.normal) > cos(deg_to_rad(params.wall_climb_max_angle))
|
||||
|
||||
if looking_towards and moving_towards and machine.can_wall_climb:
|
||||
machine.wall_normal = fwd_wall.normal
|
||||
machine.switch_to("wall_climb")
|
||||
return
|
||||
|
||||
# Fallback to wall run
|
||||
var wall_n := machine.detect_wall_horizontal()
|
||||
if wall_n != Vector3.ZERO:
|
||||
if not player.is_on_floor():
|
||||
var w_look_dir := -player.global_transform.basis.z
|
||||
var h_look := Vector3(w_look_dir.x, 0.0, w_look_dir.z).normalized()
|
||||
|
||||
# Require looking mostly along the wall (angle > 41 degrees from normal)
|
||||
# and inputting mostly along the wall (angle > 31 degrees from normal, allows W+A/D diagonally into wall)
|
||||
if abs(h_look.dot(wall_n)) < 0.75 and abs(wish_dir.dot(wall_n)) < 0.85:
|
||||
machine.switch_to("wall_run")
|
||||
return
|
||||
|
||||
# ── Dash ──────────────────────────────────────────────────────────────
|
||||
if machine.input_dash:
|
||||
var hspeed := Vector3(player.velocity.x, 0.0, player.velocity.z).length()
|
||||
if hspeed > 0.1 or machine.input_dir.length() > 0.1:
|
||||
machine.switch_to("dash")
|
||||
return
|
||||
|
||||
|
||||
func _get_camera_rig():
|
||||
if machine.player and machine.player.has_node("HeadPivot"):
|
||||
return machine.player.get_node("HeadPivot")
|
||||
return null
|
||||
|
||||
|
||||
func _update_capsule_height() -> void:
|
||||
var shape = _get_capsule()
|
||||
if shape:
|
||||
if machine.input_crouch:
|
||||
shape.height = machine.original_capsule_height * 0.5
|
||||
else:
|
||||
shape.height = machine.original_capsule_height
|
||||
|
||||
|
||||
func _get_capsule() -> CapsuleShape3D:
|
||||
for child in machine.player.get_children():
|
||||
if child is CollisionShape3D and child.shape is CapsuleShape3D:
|
||||
return child.shape as CapsuleShape3D
|
||||
return null
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
extends Node
|
||||
class_name StateSlide
|
||||
|
||||
## Momentum slide. Friction is a flat deceleration on level ground but gravity
|
||||
## projected along the floor accelerates you downhill, so slopes are the fast
|
||||
## route. The capsule stays low via the machine's crouch handling (slide counts
|
||||
## as crouched); no capsule fiddling here.
|
||||
|
||||
var machine: MovementStateMachine
|
||||
var params: MovementParams:
|
||||
get: return machine.params
|
||||
|
||||
var elapsed: float = 0.0
|
||||
var _saved_capsule_height: float = 0.0
|
||||
var _slide_direction: Vector3 = Vector3.ZERO
|
||||
|
||||
|
||||
@@ -17,12 +21,6 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
if machine.player.slide_player and not machine.player.slide_player.playing:
|
||||
machine.player.slide_player.play()
|
||||
|
||||
# Save original capsule height and halve it
|
||||
var shape: CapsuleShape3D = _get_capsule()
|
||||
if shape:
|
||||
_saved_capsule_height = shape.height
|
||||
shape.height = _saved_capsule_height * 0.5
|
||||
|
||||
# Slide in the direction of current velocity (momentum-based)
|
||||
var hvel := Vector3(machine.player.velocity.x, 0.0, machine.player.velocity.z)
|
||||
if hvel.length_squared() > 0.01:
|
||||
@@ -33,23 +31,21 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
_slide_direction.y = 0.0
|
||||
_slide_direction = _slide_direction.normalized()
|
||||
|
||||
# Set initial slide velocity
|
||||
var slide_speed := maxf(hvel.length(), params.slide_speed)
|
||||
machine.player.velocity.x = _slide_direction.x * machine.get_effective_speed(slide_speed)
|
||||
machine.player.velocity.z = _slide_direction.z * machine.get_effective_speed(slide_speed)
|
||||
# Entry speed: keep momentum, add a flat boost when actually moving fast on
|
||||
# the ground (rewards slide-cancelling sprints without making crouch a brake)
|
||||
var entry_speed := hvel.length()
|
||||
if machine.player.is_on_floor() and entry_speed > params.walk_speed * 0.8:
|
||||
entry_speed += params.slide_boost
|
||||
entry_speed = maxf(entry_speed, params.slide_speed * 0.75)
|
||||
machine.player.velocity.x = _slide_direction.x * machine.get_effective_speed(entry_speed)
|
||||
machine.player.velocity.z = _slide_direction.z * machine.get_effective_speed(entry_speed)
|
||||
|
||||
|
||||
func exit() -> void:
|
||||
machine.slide_cooldown_timer = params.slide_cooldown
|
||||
|
||||
if machine.player.slide_player:
|
||||
machine.player.slide_player.stop()
|
||||
|
||||
# Restore original capsule height
|
||||
var shape: CapsuleShape3D = _get_capsule()
|
||||
if shape and _saved_capsule_height > 0.0:
|
||||
shape.height = _saved_capsule_height
|
||||
|
||||
|
||||
func update(delta: float) -> void:
|
||||
elapsed += delta
|
||||
@@ -59,28 +55,46 @@ func update(delta: float) -> void:
|
||||
player.slide_player.play()
|
||||
|
||||
var vel: Vector3 = player.velocity
|
||||
|
||||
# ── Apply friction to horizontal velocity ─────────────────────────────
|
||||
vel.x *= pow(params.slide_friction, delta * 10.0)
|
||||
vel.z *= pow(params.slide_friction, delta * 10.0)
|
||||
|
||||
# ── Allow slight steering ─────────────────────────────────────────────
|
||||
var wish_dir := machine.wish_dir_world
|
||||
if wish_dir.length_squared() > 0.01:
|
||||
wish_dir = wish_dir.normalized()
|
||||
var hvel := Vector3(vel.x, 0.0, vel.z)
|
||||
var current_speed := hvel.length()
|
||||
var hspeed := hvel.length()
|
||||
|
||||
# Slowly bend velocity towards wish_dir
|
||||
var steer_speed = 3.0 * delta
|
||||
var new_hvel = hvel.lerp(wish_dir * current_speed, steer_speed)
|
||||
vel.x = new_hvel.x
|
||||
vel.z = new_hvel.z
|
||||
# ── Slope physics: gravity projected along the floor plane ────────────
|
||||
var on_slope := false
|
||||
if player.is_on_floor():
|
||||
var floor_n := player.get_floor_normal()
|
||||
if floor_n.y < 0.999:
|
||||
# Downhill direction on this slope
|
||||
var downhill := (Vector3.DOWN - floor_n * Vector3.DOWN.dot(floor_n))
|
||||
if downhill.length_squared() > 0.0001:
|
||||
downhill = downhill.normalized()
|
||||
var steepness := 1.0 - floor_n.y # 0 flat .. ~0.3 steep
|
||||
var slope_pull := downhill * params.slide_slope_accel * steepness * 8.0
|
||||
hvel += Vector3(slope_pull.x, 0.0, slope_pull.z) * delta
|
||||
on_slope = downhill.dot(_slide_direction) > 0.1
|
||||
|
||||
if new_hvel.length_squared() > 0.01:
|
||||
_slide_direction = new_hvel.normalized()
|
||||
# ── Friction: flat decel on level ground, nearly free downhill ────────
|
||||
if hspeed > 0.01:
|
||||
var friction := params.slide_friction_flat
|
||||
if on_slope:
|
||||
friction *= 0.15
|
||||
var new_speed := maxf(hvel.length() - friction * delta, 0.0)
|
||||
if hvel.length() > 0.001:
|
||||
hvel = hvel.normalized() * new_speed
|
||||
|
||||
# ── Gravity (for slopes) ──────────────────────────────────────────────
|
||||
# ── Steering: bend velocity toward input without changing speed ───────
|
||||
var wish_dir := machine.wish_dir_world
|
||||
if wish_dir.length_squared() > 0.01 and hvel.length_squared() > 0.01:
|
||||
wish_dir = wish_dir.normalized()
|
||||
var speed_now := hvel.length()
|
||||
var steered := hvel.lerp(wish_dir * speed_now, params.slide_steer_rate * delta)
|
||||
if steered.length_squared() > 0.01:
|
||||
hvel = steered.normalized() * speed_now
|
||||
_slide_direction = hvel.normalized()
|
||||
|
||||
vel.x = hvel.x
|
||||
vel.z = hvel.z
|
||||
|
||||
# ── Gravity (for slopes / edges) ──────────────────────────────────────
|
||||
if not player.is_on_floor():
|
||||
vel.y -= params.gravity * delta
|
||||
else:
|
||||
@@ -97,10 +111,41 @@ func update(delta: float) -> void:
|
||||
machine.on_ground = false
|
||||
|
||||
# ── End conditions ────────────────────────────────────────────────────
|
||||
var hspeed := Vector3(player.velocity.x, 0.0, player.velocity.z).length()
|
||||
var out_speed := Vector3(player.velocity.x, 0.0, player.velocity.z).length()
|
||||
|
||||
# Jump out of slide: momentum-preserving hop
|
||||
if machine.input_jump_just_pressed and machine.jump_cooldown_timer <= 0.0:
|
||||
var hv := Vector3(player.velocity.x, 0.0, player.velocity.z)
|
||||
if hv.length_squared() > 0.01:
|
||||
var dir := hv.normalized()
|
||||
var jump_speed := maxf(out_speed, minf(out_speed + params.slide_jump_speed_boost, params.bunny_hop_speed_cap))
|
||||
player.velocity.x = dir.x * jump_speed
|
||||
player.velocity.z = dir.z * jump_speed
|
||||
player.velocity.y = params.jump_velocity
|
||||
machine.current_jump_count = 1
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
machine.on_ground = false
|
||||
machine.register_chain_mechanic("slide_jump")
|
||||
machine.movement_event.emit("jump", {})
|
||||
if player.jump_player:
|
||||
player.jump_player.play()
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# Slide straight onto a wall → wall run (keeps the flow going)
|
||||
if not player.is_on_floor() and machine.wall_cooldown_timer <= 0.0 and out_speed > params.slide_min_speed:
|
||||
var wall_n := machine.detect_wall_horizontal()
|
||||
if wall_n != Vector3.ZERO and abs(machine.wish_dir_world.dot(wall_n)) < 0.85:
|
||||
machine.switch_to("wall_run")
|
||||
return
|
||||
|
||||
# Dash out of slide
|
||||
if machine.input_dash and machine.can_dash():
|
||||
machine.switch_to("dash")
|
||||
return
|
||||
|
||||
# Slide ended: too slow
|
||||
if hspeed < params.slide_min_speed:
|
||||
if out_speed < params.slide_min_speed:
|
||||
machine.switch_to("ground")
|
||||
return
|
||||
|
||||
@@ -113,27 +158,3 @@ func update(delta: float) -> void:
|
||||
if not machine.on_ground and machine.coyote_timer <= 0.0:
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# Jump out of slide
|
||||
if machine.input_jump_just_pressed and machine.jump_cooldown_timer <= 0.0:
|
||||
player.velocity.y = params.jump_velocity
|
||||
var hvel := Vector3(player.velocity.x, 0.0, player.velocity.z)
|
||||
if hvel.length_squared() > 0.01:
|
||||
var dir := hvel.normalized()
|
||||
var current_speed := hvel.length()
|
||||
var jump_speed := maxf(current_speed, minf(current_speed + params.slide_jump_speed_boost, params.bunny_hop_speed_cap))
|
||||
player.velocity.x = dir.x * jump_speed
|
||||
player.velocity.z = dir.z * jump_speed
|
||||
machine.current_jump_count = 1
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
machine.on_ground = false
|
||||
machine.register_chain_mechanic("slide_jump")
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
|
||||
func _get_capsule() -> CapsuleShape3D:
|
||||
for child in machine.player.get_children():
|
||||
if child is CollisionShape3D and child.shape is CapsuleShape3D:
|
||||
return child.shape as CapsuleShape3D
|
||||
return null
|
||||
|
||||
@@ -15,9 +15,9 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
if machine.player.wallrun_player and not machine.player.wallrun_player.playing:
|
||||
machine.player.wallrun_player.play()
|
||||
|
||||
# Give an initial upward boost
|
||||
# Carry existing upward momentum into the climb so jump→climb chains flow
|
||||
var player := machine.player
|
||||
player.velocity.y = params.wall_climb_speed
|
||||
player.velocity.y = maxf(player.velocity.y, params.wall_climb_speed)
|
||||
|
||||
var hvel := Vector3(player.velocity.x, 0.0, player.velocity.z)
|
||||
player.velocity.x = hvel.x * 0.5
|
||||
@@ -39,7 +39,7 @@ func update(delta: float) -> void:
|
||||
_detach()
|
||||
return
|
||||
|
||||
# ── Look/Move away to transition to wall run ──────────────────────────────
|
||||
# ── Look/Move away to transition to wall run ──────────────────────────
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var wish_dir := machine.wish_dir_world
|
||||
|
||||
@@ -59,6 +59,18 @@ func update(delta: float) -> void:
|
||||
_detach()
|
||||
return
|
||||
|
||||
# ── Jump off the wall (backward kick) ─────────────────────────────────
|
||||
if machine.input_jump_just_pressed and machine.jump_cooldown_timer <= 0.0:
|
||||
player.velocity = machine.wall_normal * params.wall_run_jump_off_normal
|
||||
player.velocity.y = params.jump_velocity
|
||||
machine.register_chain_mechanic("wall_jump")
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
machine.movement_event.emit("jump", {})
|
||||
if player.jump_player:
|
||||
player.jump_player.play()
|
||||
_detach()
|
||||
return
|
||||
|
||||
# ── Ledge Vault Check ─────────────────────────────────────────────────
|
||||
var fwd_wall = machine.detect_wall_forward()
|
||||
if not fwd_wall.hit:
|
||||
@@ -68,12 +80,14 @@ func update(delta: float) -> void:
|
||||
|
||||
if fwd_wall.is_short:
|
||||
# The upper ray missed, meaning we reached the top of the wall!
|
||||
_vault()
|
||||
machine.do_vault()
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# ── Movement ──────────────────────────────────────────────────────────
|
||||
# Slow down over time
|
||||
var speed_factor = maxf(0.0, 1.0 - (elapsed / params.wall_climb_duration))
|
||||
# ── Movement: ease out over the climb ─────────────────────────────────
|
||||
var speed_factor: float = pow(maxf(0.0, 1.0 - (elapsed / params.wall_climb_duration)), 0.7)
|
||||
player.velocity.y = params.wall_climb_speed * speed_factor
|
||||
|
||||
# Push slightly into the wall to maintain contact
|
||||
@@ -95,31 +109,3 @@ func _detach() -> void:
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.can_wall_climb = false
|
||||
machine.switch_to("air")
|
||||
|
||||
func _vault() -> void:
|
||||
var player := machine.player
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var h_look := Vector3(look_dir.x, 0.0, look_dir.z)
|
||||
if h_look.length_squared() > 0.01:
|
||||
h_look = h_look.normalized()
|
||||
|
||||
# Impulse
|
||||
player.velocity = h_look * params.wall_climb_vault_forward
|
||||
player.velocity.y = params.wall_climb_vault_up
|
||||
|
||||
if player.vault_player:
|
||||
player.vault_player.play()
|
||||
|
||||
# Quick camera animation (tilt up and forward bob)
|
||||
var camera = player.camera
|
||||
if camera:
|
||||
var tween = player.create_tween()
|
||||
tween.tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x + 10.0, 0.15).set_trans(Tween.TRANS_SINE)
|
||||
tween.parallel().tween_property(camera, "v_offset", -0.2, 0.15).set_trans(Tween.TRANS_SINE)
|
||||
tween.chain().tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x, 0.2).set_trans(Tween.TRANS_SINE)
|
||||
tween.parallel().tween_property(camera, "v_offset", 0.0, 0.2).set_trans(Tween.TRANS_SINE)
|
||||
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.switch_to("air")
|
||||
|
||||
@@ -13,8 +13,8 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
elapsed = 0.0
|
||||
stamina = params.wall_cling_max_stamina
|
||||
machine.register_chain_mechanic("wall_cling")
|
||||
# Kill most velocity but keep slight downward
|
||||
machine.player.velocity = Vector3.ZERO
|
||||
# Keep a touch of momentum so the stop reads as a grab, not a freeze
|
||||
machine.player.velocity *= 0.15
|
||||
|
||||
|
||||
func exit() -> void:
|
||||
@@ -56,6 +56,9 @@ func update(delta: float) -> void:
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.register_chain_mechanic("wall_cling_jump")
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
machine.movement_event.emit("jump", {})
|
||||
if machine.player.jump_player:
|
||||
machine.player.jump_player.play()
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
extends Node
|
||||
class_name StateWallRun
|
||||
|
||||
## Wall run that preserves momentum: on attach we keep speed along the wall
|
||||
## (plus some upward carry) and accelerate toward wall_run_speed instead of
|
||||
## hard-setting velocity, so entering fast stays fast and entering slow ramps
|
||||
## up smoothly. Gravity fades in over the run so the start feels planted.
|
||||
|
||||
var machine: MovementStateMachine
|
||||
var params: MovementParams:
|
||||
get: return machine.params
|
||||
|
||||
var elapsed: float = 0.0
|
||||
var _run_speed: float = 0.0
|
||||
var _last_vel: Vector3 = Vector3.ZERO
|
||||
|
||||
|
||||
var _current_tangent: Vector3 = Vector3.ZERO
|
||||
|
||||
|
||||
func enter(_data: Dictionary = {}) -> void:
|
||||
elapsed = 0.0
|
||||
machine.register_chain_mechanic("wall_run")
|
||||
# Cap the upward momentum so they don't fly up the wall,
|
||||
# and prevent negative y momentum so the wall "catches" them.
|
||||
machine.player.velocity.y = clampf(machine.player.velocity.y, 0.0, 1.5)
|
||||
machine.on_ground = false
|
||||
_last_vel = machine.player.velocity
|
||||
|
||||
# Keep some upward carry so jumping into a wall run flows; the wall
|
||||
# still "catches" downward momentum.
|
||||
machine.player.velocity.y = clampf(machine.player.velocity.y, 0.0, params.wall_run_entry_max_up)
|
||||
|
||||
if machine.player.wallrun_player and not machine.player.wallrun_player.playing:
|
||||
machine.player.wallrun_player.play()
|
||||
@@ -29,11 +31,6 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
if hvel.dot(_current_tangent) < 0.0:
|
||||
_current_tangent = -_current_tangent
|
||||
|
||||
# Preserve speed along the wall if it's faster than base wall_run_speed
|
||||
var base_speed = machine.get_effective_speed(params.wall_run_speed)
|
||||
var projected_speed = hvel.dot(_current_tangent)
|
||||
_run_speed = maxf(base_speed, projected_speed)
|
||||
|
||||
# Camera tilt
|
||||
var rig = _get_camera_rig()
|
||||
if rig:
|
||||
@@ -53,10 +50,7 @@ func exit() -> void:
|
||||
func update(delta: float) -> void:
|
||||
elapsed += delta
|
||||
if elapsed > params.wall_run_duration:
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.switch_to("air")
|
||||
_detach(0.4)
|
||||
return
|
||||
|
||||
var player := machine.player
|
||||
@@ -65,42 +59,35 @@ func update(delta: float) -> void:
|
||||
if player.wallrun_player and not player.wallrun_player.playing:
|
||||
player.wallrun_player.play()
|
||||
|
||||
if vel.distance_squared_to(_last_vel) > 100.0:
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# ── Gradual gravity pull (starts light, increases over time) ──────────
|
||||
var gravity_factor := 0.1 + 0.9 * (elapsed / params.wall_run_duration)
|
||||
# ── Gradual gravity pull (starts weightless, ramps up) ────────────────
|
||||
var gravity_factor := 0.05 + 0.95 * pow(elapsed / params.wall_run_duration, 1.5)
|
||||
vel.y -= params.wall_run_gravity * gravity_factor * delta
|
||||
|
||||
# ── Move along wall tangent ───────────────────────────────────────────
|
||||
# ── Accelerate along wall tangent (keeps momentum, no hard set) ───────
|
||||
var wall_tangent := machine.wall_normal.cross(Vector3.UP).normalized()
|
||||
# Ensure the new tangent aligns with our locked forward direction
|
||||
if _current_tangent.dot(wall_tangent) < 0.0:
|
||||
wall_tangent = -wall_tangent
|
||||
_current_tangent = wall_tangent
|
||||
|
||||
vel.x = wall_tangent.x * _run_speed
|
||||
vel.z = wall_tangent.z * _run_speed
|
||||
var hvel := Vector3(vel.x, 0.0, vel.z)
|
||||
var along := hvel.dot(wall_tangent)
|
||||
var target_speed: float = maxf(machine.get_effective_speed(params.wall_run_speed), along)
|
||||
along = move_toward(along, target_speed, params.wall_run_accel * delta)
|
||||
# Redirect all horizontal velocity along the wall (kills the into-wall part)
|
||||
hvel = wall_tangent * along
|
||||
vel.x = hvel.x
|
||||
vel.z = hvel.z
|
||||
|
||||
# ── Look away to break wall run ───────────────────────────────────────
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
if look_dir.dot(machine.wall_normal) > 0.4:
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.switch_to("air")
|
||||
_detach(0.3)
|
||||
return
|
||||
|
||||
# ── Push slightly toward wall to maintain contact ─────────────────────
|
||||
vel -= machine.wall_normal * 2.0
|
||||
|
||||
# ── Wall jump ─────────────────────────────────────────────────────────
|
||||
if machine.input_jump_just_pressed and machine.jump_cooldown_timer <= 0.0:
|
||||
# Base jump off velocity on current preserved momentum
|
||||
var jump_vel: Vector3 = _current_tangent * _run_speed
|
||||
var jump_vel: Vector3 = _current_tangent * along
|
||||
|
||||
# Spring off the wall
|
||||
jump_vel += machine.wall_normal * params.wall_run_jump_off_normal
|
||||
@@ -115,6 +102,9 @@ func update(delta: float) -> void:
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.register_chain_mechanic("wall_jump")
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
machine.movement_event.emit("jump", {})
|
||||
if player.jump_player:
|
||||
player.jump_player.play()
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
@@ -123,16 +113,16 @@ func update(delta: float) -> void:
|
||||
machine.switch_to("wall_cling")
|
||||
return
|
||||
|
||||
# ── Push slightly toward wall to maintain contact ─────────────────────
|
||||
vel -= machine.wall_normal * 2.0
|
||||
|
||||
player.velocity = vel
|
||||
player.move_and_slide()
|
||||
_last_vel = player.velocity
|
||||
|
||||
# ── Check still on wall ───────────────────────────────────────────────
|
||||
var still_on_wall := machine.detect_wall_horizontal()
|
||||
if still_on_wall == Vector3.ZERO:
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.switch_to("air")
|
||||
_detach(0.0)
|
||||
return
|
||||
|
||||
# ── Hit floor during wall run ─────────────────────────────────────────
|
||||
@@ -144,6 +134,14 @@ func update(delta: float) -> void:
|
||||
machine.switch_to("ground")
|
||||
|
||||
|
||||
func _detach(cooldown: float) -> void:
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
if cooldown > 0.0:
|
||||
machine.wall_cooldown_timer = cooldown
|
||||
machine.switch_to("air")
|
||||
|
||||
|
||||
func _get_camera_rig():
|
||||
if machine.player and machine.player.has_node("HeadPivot"):
|
||||
return machine.player.get_node("HeadPivot")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
extends CharacterBody3D
|
||||
class_name FakeMovementPlayer
|
||||
|
||||
## Minimal stand-in for PlayerMovementController in movement unit tests.
|
||||
## Carries just the properties movement states touch, all inert.
|
||||
|
||||
var jump_player = null
|
||||
var double_jump_player = null
|
||||
var footstep_player = null
|
||||
var dash_player = null
|
||||
var slide_player = null
|
||||
var wallrun_player = null
|
||||
var vault_player = null
|
||||
var wind_player = null
|
||||
var camera = null
|
||||
var head_pivot = null
|
||||
@@ -0,0 +1 @@
|
||||
uid://d07u4xreb41rf
|
||||
@@ -0,0 +1,14 @@
|
||||
extends SceneTree
|
||||
|
||||
## Headless entrypoint for the MovementStateMachine unit tests.
|
||||
## Run: godot --headless --path . -s res://movement/tests/run_fsm_tests.gd
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
var tester = load("res://movement/tests/test_fsm_runner.gd").new()
|
||||
root.add_child(tester)
|
||||
tester.run_all() # quits with the right exit code
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhu2es4slyh2i
|
||||
@@ -1,6 +1,12 @@
|
||||
extends Node
|
||||
class_name MovementStateMachineTest
|
||||
|
||||
## Unit tests for MovementStateMachine + states. Runs headless via
|
||||
## movement/tests/run_fsm_tests.gd. Each test gets a fresh machine with the
|
||||
## real state scripts attached, added to the tree so _ready registers states.
|
||||
## Tests exercise transitions and bookkeeping — not physics (no move_and_slide
|
||||
## against a real world here; the spawn smoke test covers integration).
|
||||
|
||||
var sm: MovementStateMachine
|
||||
var fake_player: CharacterBody3D
|
||||
var params: MovementParams
|
||||
@@ -9,32 +15,38 @@ var fail_log := []
|
||||
var tests_passed: int = 0
|
||||
var tests_failed: int = 0
|
||||
|
||||
const STATE_SCRIPTS := {
|
||||
"state_ground": "res://movement/states/state_ground.gd",
|
||||
"state_air": "res://movement/states/state_air.gd",
|
||||
"state_wall_run": "res://movement/states/state_wall_run.gd",
|
||||
"state_wall_cling": "res://movement/states/state_wall_cling.gd",
|
||||
"state_slide": "res://movement/states/state_slide.gd",
|
||||
"state_dash": "res://movement/states/state_dash.gd",
|
||||
}
|
||||
|
||||
|
||||
func run_all() -> void:
|
||||
print("=== MovementStateMachine tests ===")
|
||||
tests_passed = 0
|
||||
tests_failed = 0
|
||||
params = MovementParams.new()
|
||||
fake_player = CharacterBody3D.new()
|
||||
fake_player.set_floor_max_angle(0.01)
|
||||
sm = MovementStateMachine.new()
|
||||
sm.player = fake_player
|
||||
sm.params = params
|
||||
|
||||
var tests := [
|
||||
test_states_initialize_with_ground,
|
||||
test_ground_transitions_to_air_on_jump,
|
||||
test_air_transitions_to_ground_on_land,
|
||||
test_double_jump_allowed_once,
|
||||
test_wall_run_started_when_near_wall,
|
||||
test_chain_bonus_caps_at_50pct,
|
||||
test_sliding_reduces_speed,
|
||||
test_dash_speed_under_effective_cap,
|
||||
"test_states_register_and_initialize",
|
||||
"test_do_jump_sets_velocity_and_counters",
|
||||
"test_wall_run_preserves_fast_entry_speed",
|
||||
"test_wall_run_entry_keeps_some_upward_momentum",
|
||||
"test_dash_cooldown_is_per_machine",
|
||||
"test_dash_has_two_charges_then_recharges",
|
||||
"test_dash_adds_speed_to_forward_momentum",
|
||||
"test_chain_bonus_soft_caps",
|
||||
"test_notify_landed_emits_scaled_event",
|
||||
"test_slide_entry_boosts_and_keeps_direction",
|
||||
"test_grapple_state_registered_by_machine",
|
||||
]
|
||||
for t in tests:
|
||||
_fresh()
|
||||
call(t)
|
||||
var ok := fail_log.is_empty()
|
||||
var ok: bool = fail_log.is_empty()
|
||||
print(" %s: %s" % ["PASS" if ok else "FAIL", t])
|
||||
if ok:
|
||||
tests_passed += 1
|
||||
@@ -43,6 +55,7 @@ func run_all() -> void:
|
||||
for msg in fail_log:
|
||||
print(" ", msg)
|
||||
fail_log.clear()
|
||||
_teardown()
|
||||
|
||||
print("=== Results: %d passed, %d failed ===" % [tests_passed, tests_failed])
|
||||
if tests_failed > 0:
|
||||
@@ -56,25 +69,27 @@ func run_all() -> void:
|
||||
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func _fresh() -> void:
|
||||
sm.current_state = ""
|
||||
sm.chain_count = 0
|
||||
sm.current_chain_bonus = 0.0
|
||||
sm.chain_timer = 0.0
|
||||
sm.on_ground = false
|
||||
sm.coyote_timer = 0.0
|
||||
sm.jump_buffer_time = 0.0
|
||||
sm.current_jump_count = 0
|
||||
sm.wall_normal = Vector3.ZERO
|
||||
sm.input_dir = Vector2.ZERO
|
||||
sm.input_jump_just_pressed = false
|
||||
sm.input_jump_pressed = false
|
||||
sm.input_sprint = false
|
||||
sm.input_crouch = false
|
||||
sm.input_dash = false
|
||||
fake_player.velocity = Vector3.ZERO
|
||||
for c in sm.get_children():
|
||||
sm.remove_child(c)
|
||||
c.queue_free()
|
||||
params = MovementParams.new()
|
||||
fake_player = load("res://movement/tests/fake_player.gd").new()
|
||||
add_child(fake_player)
|
||||
sm = MovementStateMachine.new()
|
||||
sm.player = fake_player
|
||||
sm.params = params
|
||||
for state_name in STATE_SCRIPTS:
|
||||
var st := Node.new()
|
||||
st.name = state_name
|
||||
st.set_script(load(STATE_SCRIPTS[state_name]))
|
||||
sm.add_child(st)
|
||||
fake_player.add_child(sm) # triggers _ready: registers states + injected ones
|
||||
sm.set_physics_process(false) # tests drive the machine manually
|
||||
|
||||
|
||||
func _teardown() -> void:
|
||||
if is_instance_valid(fake_player):
|
||||
remove_child(fake_player)
|
||||
fake_player.free()
|
||||
sm = null
|
||||
fake_player = null
|
||||
|
||||
|
||||
func _expect(cond: bool, msg: String) -> void:
|
||||
@@ -89,108 +104,141 @@ func _eq(a: Variant, b: Variant, msg: String = "") -> void:
|
||||
|
||||
# ── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func test_states_initialize_with_ground() -> void:
|
||||
var g = Node.new()
|
||||
g.name = "state_ground"
|
||||
g.machine = sm
|
||||
sm.add_child(g)
|
||||
var a = Node.new()
|
||||
a.name = "state_air"
|
||||
a.machine = sm
|
||||
sm.add_child(a)
|
||||
func test_states_register_and_initialize() -> void:
|
||||
# All six explicit states plus the two the machine injects itself.
|
||||
for expected in ["ground", "air", "wall_run", "wall_cling", "slide", "dash", "grapple", "wall_climb"]:
|
||||
_expect(sm.states.has(expected), "state '%s' should be registered" % expected)
|
||||
_expect(not sm.current_state.is_empty(), "machine should start in a state")
|
||||
sm.switch_to("ground")
|
||||
_eq(sm.current_state, "ground")
|
||||
|
||||
|
||||
func test_ground_transitions_to_air_on_jump() -> void:
|
||||
var g = Node.new()
|
||||
g.name = "state_ground"
|
||||
g.machine = sm
|
||||
sm.add_child(g)
|
||||
var a = Node.new()
|
||||
a.name = "state_air"
|
||||
a.machine = sm
|
||||
sm.add_child(a)
|
||||
sm.on_ground = true
|
||||
sm.switch_to("air")
|
||||
_eq(sm.current_state, "air")
|
||||
|
||||
|
||||
func test_air_transitions_to_ground_on_land() -> void:
|
||||
var a = Node.new()
|
||||
a.name = "state_air"
|
||||
a.machine = sm
|
||||
sm.add_child(a)
|
||||
func test_do_jump_sets_velocity_and_counters() -> void:
|
||||
sm.switch_to("ground")
|
||||
sm.on_ground = false
|
||||
fake_player.velocity = Vector3.ZERO
|
||||
sm.switch_to("air")
|
||||
fake_player.velocity = Vector3.ZERO
|
||||
sm.switch_to("ground")
|
||||
_eq(sm.current_state, "ground")
|
||||
sm.on_ground = true
|
||||
fake_player.velocity = Vector3(5, 0, 0)
|
||||
sm.do_jump()
|
||||
_expect(fake_player.velocity.y == params.jump_velocity, "jump sets vertical velocity")
|
||||
_expect(fake_player.velocity.x == 5.0, "jump keeps horizontal momentum")
|
||||
_eq(sm.current_jump_count, 1, "jump count set")
|
||||
_expect(sm.jump_cooldown_timer > 0.0, "jump cooldown armed")
|
||||
_expect(not sm.on_ground, "no longer grounded")
|
||||
|
||||
|
||||
func test_double_jump_allowed_once() -> void:
|
||||
params.double_jump_max_count = 1
|
||||
var a = Node.new()
|
||||
a.name = "state_air"
|
||||
a.machine = sm
|
||||
sm.add_child(a)
|
||||
sm.on_ground = false
|
||||
sm.current_jump_count = 1
|
||||
_eq(sm.current_jump_count, 1)
|
||||
_expect(
|
||||
sm.current_jump_count < params.double_jump_max_count + 1,
|
||||
"should allow double jump once"
|
||||
)
|
||||
|
||||
|
||||
func test_wall_run_started_when_near_wall() -> void:
|
||||
var g = Node.new()
|
||||
g.name = "state_ground"
|
||||
g.machine = sm
|
||||
sm.add_child(g)
|
||||
var a = Node.new()
|
||||
a.name = "state_air"
|
||||
a.machine = sm
|
||||
sm.add_child(a)
|
||||
sm.wall_normal = Vector3.RIGHT.normalized()
|
||||
func test_wall_run_preserves_fast_entry_speed() -> void:
|
||||
# Enter a wall run at 20 m/s along the wall; speed must not drop to base.
|
||||
sm.wall_normal = Vector3.RIGHT
|
||||
sm.wall_side = -1.0
|
||||
fake_player.velocity = Vector3(0, 0, -20) # along wall tangent
|
||||
sm.switch_to("wall_run")
|
||||
_eq(sm.current_state, "wall_run")
|
||||
var hspeed := Vector3(fake_player.velocity.x, 0, fake_player.velocity.z).length()
|
||||
_expect(hspeed >= 19.9, "wall run must keep entry speed (got %.1f)" % hspeed)
|
||||
|
||||
|
||||
func test_chain_bonus_caps_at_50pct() -> void:
|
||||
func test_wall_run_entry_keeps_some_upward_momentum() -> void:
|
||||
sm.wall_normal = Vector3.RIGHT
|
||||
fake_player.velocity = Vector3(0, 20.0, -10)
|
||||
sm.switch_to("wall_run")
|
||||
_expect(fake_player.velocity.y > 0.0, "some upward carry should remain")
|
||||
_expect(fake_player.velocity.y <= params.wall_run_entry_max_up + 0.001,
|
||||
"upward carry capped at wall_run_entry_max_up")
|
||||
|
||||
|
||||
func test_dash_cooldown_is_per_machine() -> void:
|
||||
fake_player.velocity = Vector3(10, 0, 0)
|
||||
sm.wish_dir_world = Vector3(1, 0, 0)
|
||||
sm.switch_to("dash")
|
||||
_expect(sm.get_dash_charges() == params.dash_charges - 1, "dash spends one charge")
|
||||
_expect(sm.dash_recharge_timer > 0.0, "dash arms this machine's recharge clock")
|
||||
|
||||
# A second, independent machine must NOT share that state (the old
|
||||
# implementation used a static var shared across all players).
|
||||
var player2 = load("res://movement/tests/fake_player.gd").new()
|
||||
add_child(player2)
|
||||
var sm2 := MovementStateMachine.new()
|
||||
sm2.player = player2
|
||||
sm2.params = MovementParams.new()
|
||||
player2.add_child(sm2)
|
||||
_expect(sm2.get_dash_charges() == sm2.params.dash_charges, "second player keeps full charges")
|
||||
remove_child(player2)
|
||||
player2.free()
|
||||
|
||||
|
||||
func test_dash_has_two_charges_then_recharges() -> void:
|
||||
params.dash_charges = 2
|
||||
sm.wish_dir_world = Vector3(1, 0, 0)
|
||||
fake_player.velocity = Vector3(10, 0, 0)
|
||||
|
||||
_expect(sm.can_dash(), "first dash available")
|
||||
sm.switch_to("dash")
|
||||
sm.switch_to("air")
|
||||
_expect(sm.can_dash(), "second charge still banked — no cooldown wait")
|
||||
_eq(sm.get_dash_cooldown_remaining(), 0.0, "cooldown reads 0 while a charge is banked")
|
||||
sm.switch_to("dash")
|
||||
sm.switch_to("air")
|
||||
_expect(not sm.can_dash(), "both charges spent → dash blocked")
|
||||
_expect(sm.get_dash_cooldown_remaining() > 0.0, "cooldown now counts to next charge")
|
||||
|
||||
# Simulate the recharge tick the machine runs each physics frame.
|
||||
# Blank the state so only machine bookkeeping runs (no state update /
|
||||
# move_and_slide outside a real physics frame).
|
||||
sm.current_state = ""
|
||||
sm.dash_recharge_timer = 0.0001
|
||||
sm._physics_process(0.016)
|
||||
_expect(sm.can_dash(), "one charge regained after recharge timer elapses")
|
||||
_expect(sm.dash_recharge_timer > 0.0, "recharge continues toward the second charge")
|
||||
|
||||
|
||||
func test_dash_adds_speed_to_forward_momentum() -> void:
|
||||
fake_player.velocity = Vector3(12, 0, 0)
|
||||
sm.wish_dir_world = Vector3(1, 0, 0)
|
||||
sm.switch_to("dash")
|
||||
var speed := Vector3(fake_player.velocity.x, 0, fake_player.velocity.z).length()
|
||||
_expect(speed >= 12.0 + params.dash_speed - 0.01,
|
||||
"forward dash should add dash_speed on top of momentum (got %.1f)" % speed)
|
||||
|
||||
|
||||
func test_chain_bonus_soft_caps() -> void:
|
||||
params.chain_bonus_per_success = 0.05
|
||||
params.chain_bonus_cap = 0.50
|
||||
params.chain_bonus_cap = 0.25
|
||||
for i in range(12):
|
||||
sm.register_chain_mechanic("jump")
|
||||
sm.register_chain_mechanic("slide")
|
||||
sm.register_chain_mechanic("wall_run")
|
||||
_expect(sm.chain_count == 3, "chain_count 3 after 3 mechanics")
|
||||
_expect(
|
||||
sm.current_chain_bonus <= params.chain_bonus_cap + 0.001,
|
||||
"bonus should be bounded by cap"
|
||||
)
|
||||
_eq(sm.chain_count, 12, "chain count accumulates")
|
||||
_expect(sm.current_chain_bonus <= params.chain_bonus_cap + 0.35,
|
||||
"bonus stays near cap (soft cap)")
|
||||
_expect(sm.current_chain_bonus >= params.chain_bonus_cap,
|
||||
"soft cap still rewards long chains slightly")
|
||||
|
||||
|
||||
func test_sliding_reduces_speed() -> void:
|
||||
params.slide_speed = 14.0
|
||||
params.slide_min_speed = 12.0
|
||||
_expect(params.slide_min_speed < params.slide_speed, "slide_min_speed must be below slide_speed")
|
||||
func test_notify_landed_emits_scaled_event() -> void:
|
||||
var events := []
|
||||
sm.movement_event.connect(func(ev, data): events.append([ev, data]))
|
||||
sm.notify_landed(2.0) # soft: below land_soft_speed → no event
|
||||
_expect(events.is_empty(), "gentle landings emit no event")
|
||||
sm.notify_landed(params.land_heavy_speed + 1.0)
|
||||
_expect(events.size() == 1 and events[0][0] == "land", "heavy landing emits land event")
|
||||
if events.size() == 1:
|
||||
_expect(events[0][1].get("heavy", false), "heavy flag set above land_heavy_speed")
|
||||
_expect(sm.current_jump_count == 0, "landing resets jump count")
|
||||
|
||||
|
||||
func test_dash_speed_under_effective_cap() -> void:
|
||||
var d = Node.new()
|
||||
d.name = "state_dash"
|
||||
d.machine = sm
|
||||
sm.add_child(d)
|
||||
sm.on_ground = false
|
||||
var eff := sm.get_effective_speed(params.dash_speed)
|
||||
_expect(
|
||||
eff <= params.dash_speed * (1.0 + params.chain_bonus_cap),
|
||||
"dash effective speed should respect chain cap"
|
||||
)
|
||||
func test_slide_entry_boosts_and_keeps_direction() -> void:
|
||||
sm.switch_to("ground")
|
||||
fake_player.velocity = Vector3(0, 0, -12) # faster than 0.8*walk_speed
|
||||
# Slide entry boost only applies when is_on_floor(); fake player isn't on a
|
||||
# real floor, so just verify direction is kept and speed never decreases.
|
||||
sm.switch_to("slide")
|
||||
_expect(fake_player.velocity.z < 0.0, "slide keeps travel direction")
|
||||
var speed := Vector3(fake_player.velocity.x, 0, fake_player.velocity.z).length()
|
||||
_expect(speed >= 12.0 - 0.01, "slide entry must not lose speed (got %.1f)" % speed)
|
||||
_expect(sm.states.has("slide"), "slide state present")
|
||||
|
||||
|
||||
func test_rocket_jump_impulse_sets_upward_velocity() -> void:
|
||||
params.rocket_jump_up_impulse = 20.0
|
||||
_expect(params.rocket_jump_up_impulse > 0.0, "rocket jump must have positive upward impulse")
|
||||
func test_grapple_state_registered_by_machine() -> void:
|
||||
_expect(sm.states.has("grapple"), "machine injects grapple state")
|
||||
_expect(sm.states.has("wall_climb"), "machine injects wall_climb state")
|
||||
sm.grapple_length = 10.0
|
||||
sm.switch_to("grapple")
|
||||
_eq(sm.current_state, "grapple")
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Python dependencies for tools/ (sketchfab_import.py, pipeline.py).
|
||||
#
|
||||
# Currently empty: these scripts only use the standard library
|
||||
# (urllib, json, zipfile, argparse, subprocess). No third-party
|
||||
# packages to install — this file exists so `pip install -r
|
||||
# requirements.txt` works out of the box in a fresh venv.
|
||||
#
|
||||
# NOT covered here: tools/autorig.py and tools/merge_animations.py run
|
||||
# inside Blender's own bundled Python via `blender --background
|
||||
# --python ...`, not this venv. Their `bpy` / `mathutils` imports come
|
||||
# from Blender itself and cannot be pip-installed.
|
||||
@@ -0,0 +1,41 @@
|
||||
extends Object
|
||||
class_name LevelMaterials
|
||||
|
||||
## Shared prototype-grid materials for code-built levels.
|
||||
##
|
||||
## Every level builder makes its geometry from flat-colored boxes; this gives
|
||||
## them all the same world-space grid surface (0.5 m cells via triplanar
|
||||
## mapping) so speed and distance stay readable at movement-shooter velocity,
|
||||
## while keeping each builder's color coding as a tint.
|
||||
|
||||
const GRID_GRAY := "res://assets/textures/prototype/grid_gray.png"
|
||||
const GRID_DARK := "res://assets/textures/prototype/grid_dark.png"
|
||||
|
||||
## One texture tile = 2 m of world, so one grid cell = 0.5 m.
|
||||
const WORLD_UNITS_PER_TILE := 2.0
|
||||
|
||||
static var _cache: Dictionary = {}
|
||||
|
||||
|
||||
## A tinted grid material. Cached per (tint, dark) so identical surfaces share
|
||||
## one material (fewer draw-state changes, and edits apply everywhere).
|
||||
static func tinted(tint: Color, dark: bool = false) -> StandardMaterial3D:
|
||||
var key := "%s|%s" % [tint.to_html(), dark]
|
||||
if _cache.has(key):
|
||||
return _cache[key]
|
||||
var mat := StandardMaterial3D.new()
|
||||
var tex_path := GRID_DARK if dark else GRID_GRAY
|
||||
var tex: Texture2D = load(tex_path) if ResourceLoader.exists(tex_path) else null
|
||||
if tex:
|
||||
mat.albedo_texture = tex
|
||||
# World triplanar: boxes need no UVs and the grid stays world-scaled.
|
||||
mat.uv1_triplanar = true
|
||||
mat.uv1_world_triplanar = true
|
||||
mat.uv1_scale = Vector3.ONE / WORLD_UNITS_PER_TILE
|
||||
# The texture is grayscale ~mid value; multiply by ~2x-brightened tint to
|
||||
# land near the original flat color while keeping the grid contrast.
|
||||
mat.albedo_color = Color(
|
||||
minf(tint.r * 1.9, 1.0), minf(tint.g * 1.9, 1.0), minf(tint.b * 1.9, 1.0))
|
||||
mat.roughness = 0.85
|
||||
_cache[key] = mat
|
||||
return mat
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1v4lq35py3gd
|
||||
@@ -117,6 +117,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
client_rep_config.add_property(":synced_movement_state")
|
||||
client_rep_config.add_property(":synced_movement_speed")
|
||||
client_rep_config.add_property(":synced_is_crouching")
|
||||
client_rep_config.add_property(":synced_is_ads")
|
||||
client_rep_config.add_property(":synced_wall_side")
|
||||
client_rep_config.add_property(":synced_grapple_point")
|
||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
|
||||
@@ -176,10 +176,7 @@ func _create_box(node_name: String, pos: Vector3, size: Vector3, color: Color) -
|
||||
mesh.name = "MeshInstance3D"
|
||||
mesh.mesh = BoxMesh.new()
|
||||
mesh.mesh.size = size
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.roughness = 0.8
|
||||
mesh.mesh.surface_set_material(0, mat)
|
||||
mesh.mesh.surface_set_material(0, LevelMaterials.tinted(color))
|
||||
body.add_child(mesh)
|
||||
|
||||
add_child(body)
|
||||
@@ -206,10 +203,7 @@ func _create_ramp(node_name: String, pos: Vector3, size: Vector3, rot_deg: Vecto
|
||||
mesh.name = "MeshInstance3D"
|
||||
mesh.mesh = BoxMesh.new()
|
||||
mesh.mesh.size = size
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.roughness = 0.8
|
||||
mesh.mesh.surface_set_material(0, mat)
|
||||
mesh.mesh.surface_set_material(0, LevelMaterials.tinted(color))
|
||||
body.add_child(mesh)
|
||||
|
||||
add_child(body)
|
||||
|
||||
@@ -81,6 +81,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
client_rep_config.add_property(":synced_movement_state")
|
||||
client_rep_config.add_property(":synced_movement_speed")
|
||||
client_rep_config.add_property(":synced_is_crouching")
|
||||
client_rep_config.add_property(":synced_is_ads")
|
||||
client_rep_config.add_property(":synced_wall_side")
|
||||
client_rep_config.add_property(":synced_grapple_point")
|
||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
|
||||
@@ -10,7 +10,15 @@ animation (or CC0 pack retargeted to Mixamo names) can be merged onto the
|
||||
result with tools/merge_animations.py — rig once, reuse every animation.
|
||||
|
||||
Usage:
|
||||
blender --background --python tools/autorig.py -- <input> <output.glb> [target_height]
|
||||
blender --background --python tools/autorig.py -- <input> <output.glb> [target_height] [library.glb]
|
||||
|
||||
Skeleton source, in order of preference:
|
||||
1. If a 4th arg (or assets/characters/animations/_library.glb) exists, the
|
||||
armature from that animation-library GLB is used as the template. This
|
||||
guarantees every library clip fits the character perfectly — same rig,
|
||||
no retargeting.
|
||||
2. Otherwise a Mixamo-named skeleton is built from heuristics (works with
|
||||
Mixamo-skeleton clip files instead).
|
||||
|
||||
If the input already contains an armature, it is kept as-is (bones are only
|
||||
renamed to Mixamo convention when obvious matches exist) and the mesh is
|
||||
@@ -35,6 +43,10 @@ if len(argv) < 2:
|
||||
INPUT = argv[0]
|
||||
OUTPUT = argv[1]
|
||||
TARGET_HEIGHT = float(argv[2]) if len(argv) > 2 else 1.75
|
||||
_default_library = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"assets", "characters", "animations", "_library.glb")
|
||||
LIBRARY = argv[3] if len(argv) > 3 else (_default_library if os.path.exists(_default_library) else "")
|
||||
|
||||
# Mixamo bone names Godot/our animation library expects.
|
||||
MIX = "mixamorig:"
|
||||
@@ -115,6 +127,100 @@ def mesh_slice_width(obj, y_frac, height, min_z):
|
||||
return max_x
|
||||
|
||||
|
||||
def fit_arms_to_mesh(arm, mesh):
|
||||
"""Rotate each arm bone chain to lie along the mesh's actual arm direction.
|
||||
|
||||
Sketchfab humanoids are usually modelled in an A-pose (arms angled down),
|
||||
but the library skeleton rests in a T-pose (arms horizontal). If we bind the
|
||||
A-pose mesh to T-pose arm bones, bone-heat weighting fails (bones sit outside
|
||||
the arms) and the animations shove the already-drooped arms across the body.
|
||||
|
||||
Reorienting the arm bones down the real arm axis makes the rest pose fit the
|
||||
mesh, so bone-heat succeeds. The animations are then retargeted from the
|
||||
library's T-pose rest to this fitted rest in merge_animations.py.
|
||||
"""
|
||||
mw = mesh.matrix_world
|
||||
verts = [mw @ v.co for v in mesh.data.vertices]
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
eb = arm.data.edit_bones
|
||||
for side, sx in (("L", 1.0), ("R", -1.0)):
|
||||
chain = [eb.get(f"DEF-upper_arm.{side}"), eb.get(f"DEF-forearm.{side}"),
|
||||
eb.get(f"DEF-hand.{side}")]
|
||||
chain = [b for b in chain if b]
|
||||
if not chain:
|
||||
continue
|
||||
shoulder = chain[0].head.copy()
|
||||
# Hand tip = farthest mesh vertex to this side, near arm height.
|
||||
arm_z = shoulder.z
|
||||
cand = [v for v in verts if (v.x * sx) > abs(shoulder.x) * 0.8
|
||||
and abs(v.z - arm_z) < 0.35]
|
||||
if not cand:
|
||||
continue
|
||||
hand_tip = max(cand, key=lambda v: v.x * sx)
|
||||
cur = chain[-1].tail - shoulder
|
||||
tgt = hand_tip - shoulder
|
||||
if cur.length < 1e-4 or tgt.length < 1e-4:
|
||||
continue
|
||||
rot = cur.normalized().rotation_difference(tgt.normalized())
|
||||
# Rotate the whole chain about the shoulder joint.
|
||||
for b in chain:
|
||||
b.head = shoulder + rot @ (b.head - shoulder)
|
||||
b.tail = shoulder + rot @ (b.tail - shoulder)
|
||||
sh = eb.get(f"DEF-shoulder.{side}")
|
||||
if sh:
|
||||
sh.tail = chain[0].head
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
print("Fitted arm bones to mesh A-pose")
|
||||
|
||||
|
||||
def import_library_armature(height):
|
||||
"""Import the animation library's armature as the rig template.
|
||||
|
||||
Rigging to the exact skeleton the clips were authored on means clips need
|
||||
only a rest-pose retarget (handled in merge_animations.py).
|
||||
"""
|
||||
before = set(bpy.data.objects)
|
||||
before_actions = set(bpy.data.actions)
|
||||
bpy.ops.import_scene.gltf(filepath=LIBRARY)
|
||||
new_objects = [o for o in bpy.data.objects if o not in before]
|
||||
|
||||
arm = None
|
||||
for o in new_objects:
|
||||
if o.type == "ARMATURE":
|
||||
arm = o
|
||||
if not arm:
|
||||
print("WARNING: no armature in library, falling back to heuristic skeleton")
|
||||
for o in new_objects:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
return None
|
||||
|
||||
# Drop the library's mannequin mesh and all bundled actions — we only
|
||||
# want the bare skeleton here; clips get merged in the next stage.
|
||||
for o in new_objects:
|
||||
if o is not arm:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
if arm.animation_data:
|
||||
arm.animation_data_clear()
|
||||
for a in [a for a in bpy.data.actions if a not in before_actions]:
|
||||
bpy.data.actions.remove(a)
|
||||
|
||||
# Scale the rig uniformly so its height matches the character's.
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
arm.select_set(True)
|
||||
arm.location = (0, 0, 0)
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
rig_top = max((arm.matrix_world @ b.head_local).z for b in arm.data.bones)
|
||||
rig_top = max(rig_top, max((arm.matrix_world @ b.tail_local).z for b in arm.data.bones))
|
||||
if rig_top > 0.01:
|
||||
s = height / rig_top
|
||||
arm.scale = (s, s, s)
|
||||
bpy.ops.object.transform_apply(scale=True)
|
||||
print(f"Library rig scaled by {s:.3f} to height {height:.2f}")
|
||||
return arm
|
||||
|
||||
|
||||
def build_mixamo_armature(height, min_z, shoulder_w, hip_w):
|
||||
"""Create a Mixamo-named humanoid armature fitted to the mesh bounds.
|
||||
|
||||
@@ -167,6 +273,101 @@ def build_mixamo_armature(height, min_z, shoulder_w, hip_w):
|
||||
return arm
|
||||
|
||||
|
||||
def _count_weighted_verts(mesh):
|
||||
return sum(1 for v in mesh.data.vertices if len(v.groups) > 0)
|
||||
|
||||
|
||||
# Bones that must never deform the mesh: the root/master and any rig control
|
||||
# or mechanism bones. A deform-flagged root runs up the body centre-line, so
|
||||
# without this it captures inner-leg/heel verts and stretches them to centre.
|
||||
_NON_DEFORM_HINTS = ("root", "master", "mch-", "mch_", "ctrl", "org-", "-ik",
|
||||
"_ik", "pole", "target", "properties")
|
||||
|
||||
|
||||
def _deform_segments(arm):
|
||||
segs = []
|
||||
for b in arm.data.bones:
|
||||
if not b.use_deform:
|
||||
continue
|
||||
if any(h in b.name.lower() for h in _NON_DEFORM_HINTS):
|
||||
continue
|
||||
head = arm.matrix_world @ b.head_local
|
||||
tail = arm.matrix_world @ b.tail_local
|
||||
segs.append((b.name, head, tail))
|
||||
return segs
|
||||
|
||||
|
||||
def rigid_nearest_bone_weights(mesh, arm):
|
||||
"""Weight each vertex to its nearest deform bones (distance-falloff blend).
|
||||
|
||||
A guaranteed-to-work substitute when bone-heat weighting fails — which it
|
||||
does on most imported characters, whose joined hair/clothing/body meshes
|
||||
have the interior and overlapping geometry the heat solver chokes on.
|
||||
|
||||
Each vertex is blended across its nearest few bone segments with an
|
||||
inverse-distance falloff, so joints deform smoothly instead of tearing.
|
||||
Control/root bones are excluded (see _NON_DEFORM_HINTS) so the mesh follows
|
||||
real limbs, not the centre-line master bone.
|
||||
"""
|
||||
segs = _deform_segments(arm)
|
||||
if not segs:
|
||||
return 0
|
||||
|
||||
for vg in list(mesh.vertex_groups):
|
||||
mesh.vertex_groups.remove(vg)
|
||||
groups = {name: mesh.vertex_groups.new(name=name) for name, _, _ in segs}
|
||||
|
||||
def dist_to_seg(p, a, b):
|
||||
ab = b - a
|
||||
denom = ab.dot(ab)
|
||||
t = 0.0 if denom == 0 else max(0.0, min(1.0, (p - a).dot(ab) / denom))
|
||||
return (p - (a + ab * t)).length
|
||||
|
||||
K = 4 # blend across up to this many nearest bones
|
||||
FALLOFF = 3.0 # higher = tighter to the single nearest bone
|
||||
mw = mesh.matrix_world
|
||||
for v in mesh.data.vertices:
|
||||
p = mw @ v.co
|
||||
dists = sorted(((dist_to_seg(p, s[1], s[2]), s[0]) for s in segs),
|
||||
key=lambda d: d[0])[:K]
|
||||
nearest = max(dists[0][0], 1e-5)
|
||||
# Inverse-distance weights, relative to the nearest bone.
|
||||
raw = [((nearest / max(d, 1e-5)) ** FALLOFF, name) for d, name in dists]
|
||||
total = sum(w for w, _ in raw)
|
||||
for w, name in raw:
|
||||
groups[name].add([v.index], w / total, "REPLACE")
|
||||
return _count_weighted_verts(mesh)
|
||||
|
||||
|
||||
def bind_mesh_to_armature(mesh, arm):
|
||||
"""Bind mesh to armature so the glTF exporter writes a COMPLETE skin
|
||||
(skin object + node.skin reference + per-vertex JOINTS/WEIGHTS).
|
||||
|
||||
1. Try Blender automatic (bone-heat) weights for smooth deformation.
|
||||
2. If that assigns (almost) nothing — common on layered hair/clothing
|
||||
meshes where bone-heat fails — fall back to rigid nearest-bone.
|
||||
|
||||
Keep the standard ARMATURE_AUTO result: an Armature modifier plus vertex
|
||||
groups (parent_type stays OBJECT). That is exactly what the exporter needs
|
||||
to write the vertex weights. (An earlier version stripped the modifier and
|
||||
used parent_type='ARMATURE'; that produced an ORPHAN skin with no weights,
|
||||
so the mesh rendered its bind pose — a permanent T-pose — in game.)
|
||||
"""
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
mesh.select_set(True)
|
||||
arm.select_set(True)
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.parent_set(type="ARMATURE_AUTO")
|
||||
|
||||
weighted = _count_weighted_verts(mesh)
|
||||
total = len(mesh.data.vertices)
|
||||
if weighted < total * 0.5:
|
||||
print(f"Automatic weights covered {weighted}/{total} verts — "
|
||||
"falling back to rigid nearest-bone weights")
|
||||
weighted = rigid_nearest_bone_weights(mesh, arm)
|
||||
print(f"Bound mesh: {weighted}/{total} verts weighted")
|
||||
|
||||
|
||||
def main():
|
||||
clear_scene()
|
||||
print(f"Importing {INPUT}...")
|
||||
@@ -186,7 +387,19 @@ def main():
|
||||
if len(meshes) > 1:
|
||||
bpy.ops.object.join()
|
||||
mesh = bpy.context.active_object
|
||||
|
||||
# Detach from the importer's hierarchy (Sketchfab wraps meshes in scaled/
|
||||
# rotated empties) so the mesh sits in clean world space, then bake its
|
||||
# own transform. Otherwise bind + normalization fight the parent transform.
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
mesh.select_set(True)
|
||||
bpy.context.view_layer.objects.active = mesh
|
||||
if mesh.parent:
|
||||
bpy.ops.object.parent_clear(type="CLEAR_KEEP_TRANSFORM")
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
for o in list(bpy.data.objects):
|
||||
if o.type == "EMPTY":
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
if armatures:
|
||||
print("Input already rigged — keeping existing skeleton, renaming bones")
|
||||
@@ -214,19 +427,26 @@ def main():
|
||||
min_z = 0.0
|
||||
height = TARGET_HEIGHT
|
||||
|
||||
arm = None
|
||||
from_library = False
|
||||
if LIBRARY:
|
||||
print(f"Using animation library skeleton: {LIBRARY}")
|
||||
arm = import_library_armature(height)
|
||||
from_library = arm is not None
|
||||
if not arm:
|
||||
shoulder_w = mesh_slice_width(mesh, 0.80, height, min_z)
|
||||
hip_w = mesh_slice_width(mesh, 0.53, height, min_z)
|
||||
print(f"Fitted: height={height:.2f} shoulder_w={shoulder_w:.2f} hip_w={hip_w:.2f}")
|
||||
|
||||
arm = build_mixamo_armature(height, min_z, shoulder_w, hip_w)
|
||||
|
||||
# Bind with automatic weights.
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
mesh.select_set(True)
|
||||
arm.select_set(True)
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.parent_set(type="ARMATURE_AUTO")
|
||||
print("Bound mesh with automatic weights")
|
||||
# Fit the arm bones to the mesh's real (usually A-pose) arm direction so
|
||||
# bone-heat weighting fits and the rest pose matches the geometry. The
|
||||
# library-clip retarget in merge_animations.py compensates for the
|
||||
# rest-pose change.
|
||||
if from_library:
|
||||
fit_arms_to_mesh(arm, mesh)
|
||||
|
||||
bind_mesh_to_armature(mesh, arm)
|
||||
|
||||
print(f"Exporting {OUTPUT}...")
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
|
||||
@@ -234,7 +454,11 @@ def main():
|
||||
filepath=OUTPUT,
|
||||
export_format="GLB",
|
||||
export_yup=True,
|
||||
export_apply=True,
|
||||
# NOTE: export_apply must stay False — applying modifiers on a skinned
|
||||
# mesh bakes away the Armature modifier and drops the skin binding
|
||||
# (glTF then exports bones as plain nodes with skins:0). Transforms are
|
||||
# already applied in code before binding, so nothing is lost here.
|
||||
export_apply=False,
|
||||
export_animations=True,
|
||||
export_skins=True,
|
||||
)
|
||||
|
||||
@@ -6,9 +6,16 @@ Merges every animation clip from the shared animation library onto a rigged
|
||||
character (Mixamo-compatible bone names) and exports one game-ready GLB whose
|
||||
animations use the game's canonical clip names (Idle, Walk, Run, Jump, ...).
|
||||
|
||||
Animation library: a directory of FBX/GLB files, ONE clip per file, all using
|
||||
the Mixamo skeleton. Download once from Mixamo ("without skin") or use a CC0
|
||||
pack — see docs/ASSET_SOURCES.md. Filenames map to canonical clip names:
|
||||
Animation library — two supported layouts in <animations_dir>:
|
||||
|
||||
A. `_library.glb` — a single multi-clip library (e.g. Quaternius Universal
|
||||
Animation Library, CC0, committed at assets/characters/animations/).
|
||||
Clip names map through LIBRARY_CLIP_MAP below. The character must be
|
||||
rigged on this library's own skeleton (tools/autorig.py does this
|
||||
automatically when _library.glb is present).
|
||||
|
||||
B. Loose FBX/GLB files, ONE clip per file, all on the Mixamo skeleton
|
||||
(manual Mixamo downloads). Filenames map to canonical clip names:
|
||||
idle.fbx -> Idle
|
||||
run.fbx -> Run
|
||||
crouch_walk.fbx -> CrouchWalk
|
||||
@@ -38,12 +45,55 @@ STRIP_ROOT_MOTION = "--keep-root-motion" not in argv
|
||||
|
||||
MIX = "mixamorig:"
|
||||
|
||||
# Quaternius Universal Animation Library clip -> game canonical clip name.
|
||||
# Unmapped clips are skipped to keep character GLBs small.
|
||||
LIBRARY_CLIP_MAP = {
|
||||
"Idle_Loop": "Idle",
|
||||
"Walk_Loop": "Walk",
|
||||
"Jog_Fwd_Loop": "Run",
|
||||
"Sprint_Loop": "Sprint",
|
||||
"Jump_Start": "Jump",
|
||||
"Jump_Loop": "Fall",
|
||||
"Jump_Land": "Land",
|
||||
"Crouch_Idle_Loop": "CrouchIdle",
|
||||
"Crouch_Fwd_Loop": "CrouchWalk",
|
||||
"Roll": "Dash",
|
||||
"Death01": "Death",
|
||||
"Hit_Chest": "Hit",
|
||||
"Dance_Loop": "Dance",
|
||||
}
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
|
||||
|
||||
def action_fcurves(action):
|
||||
"""Return an action's F-curves across Blender versions.
|
||||
|
||||
Blender 4.4+/5.x replaced `Action.fcurves` with the slotted-action system
|
||||
(layers -> strips -> channelbags -> fcurves). This yields the curves either
|
||||
way so the merge logic doesn't care which Blender it runs under.
|
||||
"""
|
||||
legacy = getattr(action, "fcurves", None)
|
||||
if legacy is not None:
|
||||
return list(legacy)
|
||||
out = []
|
||||
for layer in getattr(action, "layers", []):
|
||||
for strip in layer.strips:
|
||||
cbags = getattr(strip, "channelbags", None)
|
||||
if cbags is not None:
|
||||
for cbag in cbags:
|
||||
out.extend(cbag.fcurves)
|
||||
else:
|
||||
for slot in getattr(action, "slots", []):
|
||||
cbag = strip.channelbag(slot)
|
||||
if cbag:
|
||||
out.extend(cbag.fcurves)
|
||||
return out
|
||||
|
||||
|
||||
def to_pascal(stem):
|
||||
return "".join(part.capitalize() for part in stem.replace("-", "_").split("_"))
|
||||
|
||||
@@ -76,7 +126,7 @@ CORE_BONES = {
|
||||
|
||||
|
||||
def normalize_action_paths(action):
|
||||
for fc in action.fcurves:
|
||||
for fc in action_fcurves(action):
|
||||
if 'pose.bones["' in fc.data_path:
|
||||
start = fc.data_path.index('"') + 1
|
||||
end = fc.data_path.index('"', start)
|
||||
@@ -88,7 +138,7 @@ def normalize_action_paths(action):
|
||||
|
||||
def hips_height(arm):
|
||||
for b in arm.data.bones:
|
||||
if b.name.endswith("Hips"):
|
||||
if b.name.lower().endswith("hips"):
|
||||
return (arm.matrix_world @ b.head_local).z
|
||||
return 1.0
|
||||
|
||||
@@ -96,7 +146,7 @@ def hips_height(arm):
|
||||
def scale_location_curves(action, ratio):
|
||||
if abs(ratio - 1.0) < 0.01:
|
||||
return
|
||||
for fc in action.fcurves:
|
||||
for fc in action_fcurves(action):
|
||||
if fc.data_path.endswith(".location"):
|
||||
for kp in fc.keyframe_points:
|
||||
kp.co.y *= ratio
|
||||
@@ -105,13 +155,15 @@ def scale_location_curves(action, ratio):
|
||||
|
||||
|
||||
def strip_hips_horizontal(action):
|
||||
"""Zero hips X (side) and Y (forward) location so clips play in place.
|
||||
|
||||
Blender bone-local axes for a Y-up-exported Mixamo hips: X = side,
|
||||
Y = up (bone axis), Z = forward. We keep Y (vertical bob) and zero X/Z.
|
||||
"""Zero horizontal root motion so clips play in place (gameplay code
|
||||
moves the body). Applies to hips AND any dedicated root bone; keeps the
|
||||
vertical channel so bob/land weight survives.
|
||||
"""
|
||||
for fc in action.fcurves:
|
||||
if fc.data_path.endswith('.location') and "Hips" in fc.data_path and fc.array_index in (0, 2):
|
||||
for fc in action_fcurves(action):
|
||||
if not fc.data_path.endswith(".location"):
|
||||
continue
|
||||
path_lower = fc.data_path.lower()
|
||||
if ("hips" in path_lower or '"root"' in path_lower) and fc.array_index in (0, 2):
|
||||
for kp in fc.keyframe_points:
|
||||
kp.co.y = 0.0
|
||||
kp.handle_left.y = 0.0
|
||||
@@ -144,9 +196,151 @@ def main():
|
||||
target_arm.animation_data.nla_tracks.remove(track)
|
||||
target_arm.animation_data.action = None
|
||||
|
||||
library_path = os.path.join(ANIM_DIR, "_library.glb")
|
||||
if os.path.exists(library_path):
|
||||
merged = merge_from_library(target_arm, target_hips, library_path)
|
||||
else:
|
||||
merged = merge_from_files(target_arm, target_hips)
|
||||
|
||||
if merged == 0:
|
||||
print("ERROR: no animations merged")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Merged {merged} clips. Exporting {OUTPUT}...")
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=OUTPUT,
|
||||
export_format="GLB",
|
||||
export_yup=True,
|
||||
export_animations=True,
|
||||
export_animation_mode="NLA_TRACKS",
|
||||
export_skins=True,
|
||||
export_bake_animation=True,
|
||||
)
|
||||
print("Done.")
|
||||
|
||||
|
||||
def _add_clip_track(target_arm, action, clip_name):
|
||||
action.name = clip_name
|
||||
track = target_arm.animation_data.nla_tracks.new()
|
||||
track.name = clip_name
|
||||
strip = track.strips.new(clip_name, 0, action)
|
||||
strip.name = clip_name
|
||||
track.mute = True
|
||||
action.use_fake_user = True
|
||||
|
||||
|
||||
def _assign_action(obj, action):
|
||||
"""Assign an action to an object across Blender versions (slotted actions)."""
|
||||
if not obj.animation_data:
|
||||
obj.animation_data_create()
|
||||
obj.animation_data.action = action
|
||||
try:
|
||||
slots = action.slots
|
||||
if len(slots):
|
||||
obj.animation_data.action_slot = slots[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def retarget_action(target_arm, src_arm, src_action, clip_name):
|
||||
"""Retarget one source clip onto the character rig via world-space
|
||||
constraint baking.
|
||||
|
||||
The character rig's arms were fitted to the mesh's A-pose (autorig.py), so
|
||||
its rest differs from the library's T-pose. Copying each bone's WORLD
|
||||
orientation (not its local channel) reproduces the library's actual motion
|
||||
regardless of that rest difference, then baking captures it as a clean
|
||||
action on the character rig.
|
||||
"""
|
||||
_assign_action(src_arm, src_action)
|
||||
fr0 = int(src_action.frame_range[0])
|
||||
fr1 = int(src_action.frame_range[1])
|
||||
|
||||
for pb in target_arm.pose.bones:
|
||||
if pb.name not in src_arm.pose.bones:
|
||||
continue
|
||||
cr = pb.constraints.new("COPY_ROTATION")
|
||||
cr.target = src_arm
|
||||
cr.subtarget = pb.name
|
||||
cr.target_space = "WORLD"
|
||||
cr.owner_space = "WORLD"
|
||||
if pb.name.lower().endswith("hips"):
|
||||
cl = pb.constraints.new("COPY_LOCATION")
|
||||
cl.target = src_arm
|
||||
cl.subtarget = pb.name
|
||||
cl.target_space = "WORLD"
|
||||
cl.owner_space = "WORLD"
|
||||
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
target_arm.select_set(True)
|
||||
bpy.context.view_layer.objects.active = target_arm
|
||||
if target_arm.animation_data:
|
||||
target_arm.animation_data.action = None
|
||||
|
||||
bpy.ops.nla.bake(frame_start=fr0, frame_end=fr1, step=1,
|
||||
only_selected=False, visual_keying=True,
|
||||
clear_constraints=True, clear_parents=False,
|
||||
use_current_action=True, bake_types={"POSE"})
|
||||
|
||||
baked = target_arm.animation_data.action
|
||||
if STRIP_ROOT_MOTION:
|
||||
strip_hips_horizontal(baked)
|
||||
return baked
|
||||
|
||||
|
||||
def merge_from_library(target_arm, target_hips, library_path):
|
||||
"""Retarget every mapped clip from a single multi-clip library GLB onto the
|
||||
character rig (whose arm rest was fitted to the mesh)."""
|
||||
print(f"Using multi-clip library: {library_path}")
|
||||
before = set(bpy.data.objects)
|
||||
before_actions = set(bpy.data.actions)
|
||||
bpy.ops.import_scene.gltf(filepath=library_path)
|
||||
new_objects = [o for o in bpy.data.objects if o not in before]
|
||||
new_actions = [a for a in bpy.data.actions if a not in before_actions]
|
||||
src_arm = find_armature(new_objects)
|
||||
if not src_arm:
|
||||
print("ERROR: no armature in animation library")
|
||||
sys.exit(1)
|
||||
|
||||
# Match the library rig's height to the character so hips bob / foot planting
|
||||
# translate correctly under the world-space location copy.
|
||||
src_hips = hips_height(src_arm)
|
||||
if src_hips > 1e-4:
|
||||
s = target_hips / src_hips
|
||||
src_arm.scale = (s, s, s)
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
src_arm.select_set(True)
|
||||
bpy.context.view_layer.objects.active = src_arm
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
|
||||
merged = 0
|
||||
keep = []
|
||||
for action in new_actions:
|
||||
base = action.name.split(".")[0] # tolerate .001 suffixes
|
||||
clip_name = LIBRARY_CLIP_MAP.get(base)
|
||||
if not clip_name:
|
||||
continue
|
||||
print(f"--- {base} -> '{clip_name}' (retarget bake)")
|
||||
baked = retarget_action(target_arm, src_arm, action, clip_name)
|
||||
target_arm.animation_data.action = None
|
||||
_add_clip_track(target_arm, baked, clip_name)
|
||||
keep.append(baked)
|
||||
merged += 1
|
||||
|
||||
for o in new_objects:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
for a in list(bpy.data.actions):
|
||||
if a not in keep and a in new_actions:
|
||||
bpy.data.actions.remove(a)
|
||||
return merged
|
||||
|
||||
|
||||
def merge_from_files(target_arm, target_hips):
|
||||
"""Merge clips from loose one-clip-per-file FBX/GLB files."""
|
||||
anim_files = sorted(
|
||||
f for f in os.listdir(ANIM_DIR)
|
||||
if f.lower().endswith((".fbx", ".glb", ".gltf")) and not f.startswith(".")
|
||||
if f.lower().endswith((".fbx", ".glb", ".gltf")) and not f.startswith((".", "_"))
|
||||
)
|
||||
if not anim_files:
|
||||
print(f"ERROR: no animation files in {ANIM_DIR}")
|
||||
@@ -176,21 +370,14 @@ def main():
|
||||
if not new_actions:
|
||||
print(" SKIP: no action found in file")
|
||||
else:
|
||||
action = max(new_actions, key=lambda a: len(a.fcurves))
|
||||
action.name = clip_name
|
||||
action = max(new_actions, key=lambda a: len(action_fcurves(a)))
|
||||
normalize_action_paths(action)
|
||||
if src_arm:
|
||||
ratio = target_hips / max(hips_height(src_arm), 1e-6)
|
||||
scale_location_curves(action, ratio)
|
||||
if STRIP_ROOT_MOTION:
|
||||
strip_hips_horizontal(action)
|
||||
|
||||
track = target_arm.animation_data.nla_tracks.new()
|
||||
track.name = clip_name
|
||||
strip = track.strips.new(clip_name, 0, action)
|
||||
strip.name = clip_name
|
||||
track.mute = True
|
||||
action.use_fake_user = True
|
||||
_add_clip_track(target_arm, action, clip_name)
|
||||
merged += 1
|
||||
|
||||
# Remove the imported helper objects (keep the action).
|
||||
@@ -200,22 +387,7 @@ def main():
|
||||
if a.name != clip_name:
|
||||
bpy.data.actions.remove(a)
|
||||
|
||||
if merged == 0:
|
||||
print("ERROR: no animations merged")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Merged {merged} clips. Exporting {OUTPUT}...")
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=OUTPUT,
|
||||
export_format="GLB",
|
||||
export_yup=True,
|
||||
export_animations=True,
|
||||
export_animation_mode="NLA_TRACKS",
|
||||
export_skins=True,
|
||||
export_bake_animation=True,
|
||||
)
|
||||
print("Done.")
|
||||
return merged
|
||||
|
||||
|
||||
main()
|
||||
|
||||
@@ -43,8 +43,14 @@ def get_token(cli_token: str | None) -> str:
|
||||
return tok
|
||||
token_file = os.path.join(PROJECT_ROOT, ".sketchfab_token")
|
||||
if os.path.exists(token_file):
|
||||
with open(token_file, "r", encoding="utf-8") as f:
|
||||
tok = f.read().strip()
|
||||
with open(token_file, "rb") as f:
|
||||
raw = f.read()
|
||||
# PowerShell's `echo`/`>` write UTF-16 with a BOM by default on
|
||||
# Windows; tolerate that as well as plain UTF-8.
|
||||
if raw.startswith(b"\xff\xfe") or raw.startswith(b"\xfe\xff"):
|
||||
tok = raw.decode("utf-16").strip()
|
||||
else:
|
||||
tok = raw.decode("utf-8-sig").strip()
|
||||
if tok:
|
||||
return tok
|
||||
print("ERROR: no Sketchfab API token found.")
|
||||
|
||||