Compare commits

..
10 Commits
Author SHA1 Message Date
Nicholas ButzkeandClaude Fable 5 2c7b5a1aca feat: lighter air control (steer + stronger strafe) and 2-charge dash
- Air: direct steering bends existing velocity toward input without changing
  its magnitude (air_steer_rate), on top of Quake accelerate with raised
  gains (air_strafe_accel 55->90, per-tick cap 1.2->2.5). No-input air drag
  nearly removed so held momentum carries. Steering skips opposing input to
  avoid flipping through zero.
- Dash: charge system on the machine — dash_charges (2) spend instantly,
  each recharges in dash_cooldown (2 s). get_dash_cooldown_remaining() now
  reports time to the NEXT charge (0 while one is banked) so existing HUDs
  keep working; get_dash_charges() added for pips.
- Tests: dash tests updated to the charge API + new coverage for
  spend-2-then-recharge; 11/11 pass, spawn smoke clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 13:32:01 -04:00
Nicholas ButzkeandClaude Fable 5 198345177a feat: world-grid level materials + real movement test suite
- Generated CC0-style prototype grid textures (0.5 m cells) and a shared
  LevelMaterials factory: world-space triplanar mapping over every code-built
  level (test level, dust2, procedural arena) with the existing color coding
  kept as tints. Readable surfaces at speed instead of flat color boxes;
  materials are cached per tint so identical surfaces batch.
- .gdignore + .gitignore the raw downloaded asset packs in addons/ whose
  overlong paths broke Godot import scans and spammed git warnings.
- Replaced the rotted FSM test file (invalid call(t) on Callables, stateless
  stub nodes, hung forever without quitting) with a real suite: 10 tests
  covering state registration, jump bookkeeping, wall-run momentum
  preservation and upward-carry cap, per-player dash cooldown (regression for
  the old shared static), dash speed stacking, chain soft cap, landing
  events, and slide entry momentum. New headless entrypoint:
  godot --headless --path . -s res://movement/tests/run_fsm_tests.gd

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 12:14:30 -04:00
Nicholas ButzkeandClaude Fable 5 7da7f9f3f2 feat: rebuild character skin with full 13-clip CC0 animation set
The shipped skin GLB only had Idle/Walk/Run/Jump, so Slide, WallRun, Crouch,
Dash, Fall and Land all fell back to the wrong clips. Rebuilt via the Blender
pipeline from the library-skeleton rigged model (the old miku_proper_anim was
on a different skeleton, so retargeting silently baked flat clips — and the
shooter pose layer's DEF-* bone names never matched it either).

miku.glb now ships Idle, Walk, Run, Sprint, Jump, Fall, Land, CrouchIdle,
CrouchWalk, Dash (roll), Death, Hit, Dance from the Quaternius Universal
Animation Library (CC0). Verified per-bone keyframe motion in the export and
rendered pose stills for deformation QA (no crossed arms / stretched feet).

Also removed five stale experimental skin GLBs + extracted textures and the
one-off editor import scripts; skins.json now lists only the working skin.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 12:04:52 -04:00
Nicholas ButzkeandClaude Fable 5 0ba14a9469 feat: overhaul movement for flow — momentum-preserving states, smooth crouch, landing feel
- Quake-style air-strafe accelerate (real speed gain from strafing; external
  speed from dash/rockets never clamped)
- Wall run: keeps entry momentum (incl. upward carry), accelerates along the
  wall instead of hard-setting velocity, gravity fades in over the run
- Slide: slope physics (gravity projected along floor accelerates downhill),
  flat-ground decel instead of multiplicative friction, entry boost, direct
  slide->wallrun and slide->dash transitions
- Dash: cooldown 10s -> 2s, per-player (was a static shared across instances),
  momentum fully kept on exit, FOV punch event
- Grapple: taut pendulum with active rope control (W reels in, S pays out),
  release keeps swing energy
- Wall climb: carries upward momentum in, jump-off kick, shared vault helper
- Crouch capsule: single owner in the machine, smoothly lerped, ceiling check;
  camera eye height follows via crouch factor (no more head snapping)
- Landing: machine emits 'land' events scaled by impact; camera dip + pitched
  down thud; footsteps get pitch variation
- Camera rig: event-driven (land dip, dash FOV kick, vault pitch impulse),
  slide roll tilt
- Model: Jump vs Fall split by vertical velocity, Land one-shot on heavy
  landings, wall-run body lean (synced to remotes via synced_wall_side)

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 11:58:14 -04:00
Nicholas ButzkeandClaude Opus 4.8 c4a538a469 chore: add miku_test skin textures and script .uid files
Godot import artifacts that pair with already-committed assets:
- miku_test_Image_0..3.png: textures extracted from miku_test.glb on import
- .uid files for spawn_smoke_test.gd and audio_manager.gd

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 01:31:52 -04:00
Nicholas ButzkeandClaude Fable 5 29fdca3565 feat: shooter animation feel — directional lean, slide, composed airborne, weapon hold
Layer a procedural SkeletonModifier3D (ShooterPoseModifier) on top of the base
clip so the character reads like a movement-shooter avatar:

- Directional lean: banks into the movement direction (right/left/back) and
  blends smoothly for diagonals, driven by velocity relative to facing.
- Slide: leans the torso back and pitches the head up to look forward, instead
  of the base clip's forward-torso/legs-out "spine break".
- Airborne: plays a composed Jump pose (weapon ready) rather than a flailing
  fall.
- Weapon hold: the base clip already keeps the arms down (weapon at the hip);
  on ADS both arms lift and swing in toward centre-front to aim. ADS is read
  from the active weapon and synced (synced_is_ads) so remote players raise
  their weapons too.

The controller feeds movement direction + ADS to SkinnedPlayerModel.set_locomotion()
each frame (works for local and remote via synced velocity/rotation). All
rotations are authored in skeleton space (fwd=+Z, up=+Y, right=-X) and converted
per-bone; tuning constants are at the top of ShooterPoseModifier.

Verified by rendering the poses (idle/strafe/back/slide/ads) in a real Godot
viewport. Smoke test 30/30.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 23:23:51 -04:00
Nicholas ButzkeandClaude Fable 5 9939e7e524 fix: correct rig deformation — arms no longer cross body, feet no longer stretch to center
The model animated but deformed wrong: arms crossed the torso and foot heels
stretched to the model's center-bottom. Two root causes, both fixed:

1. Pose mismatch. Sketchfab models are modelled in an A-pose (arms angled
   down) but the Quaternius library rests in a T-pose. Binding the A-pose mesh
   to T-pose arm bones misweighted the arms, and the library clips (which
   rotate arms ~70° down from the T rest) then shoved the already-drooped arms
   across the body.
   Fix: autorig.fit_arms_to_mesh() reorients each arm bone chain along the
   model's real arm direction; merge_animations retargets every clip from the
   library's T-pose rest onto the fitted rest via world-space constraint bake
   (Copy Rotation/Location + nla.bake), so world motion is reproduced
   regardless of the rest difference.

2. Feet-to-center stretch. The library's deform-flagged `root` bone runs up
   the body centre-line; nearest-bone weighting assigned inner-foot/heel verts
   to it, stretching them to center when animated.
   Fix: exclude root/control/mechanism bones from weighting (_NON_DEFORM_HINTS).

Also upgraded nearest-bone weighting from rigid (1 bone/vert) to a smooth
inverse-distance blend across the nearest 4 bones, so joints deform cleanly.
Bone-heat still fails on Miku's joined hair/clothing mesh, so this is the
primary path.

Verified by rendering Idle/Walk/Run poses (Blender): natural stance, arms at
sides swinging correctly, feet planted, proper running stride. Smoke test
still 30/30.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 19:38:59 -04:00
Nicholas ButzkeandClaude Fable 5 22cb0a58b7 fix: T-posing skinned models — autorig produced an orphan skin with no weights
The model animated its skeleton but rendered a permanent T-pose: the mesh
wasn't skin-bound. Root cause was in autorig's binding, introduced while
chasing an earlier "skins:0" export:

- The earlier "skins:0" was actually caused by bone-heat weighting failing
  (0 weighted verts → empty skin gets dropped), NOT by the Armature modifier.
- The "fix" then stripped the Armature modifier and used parent_type=ARMATURE.
  That makes the glTF exporter emit a skin OBJECT but with no node.skin
  reference and no per-vertex JOINTS/WEIGHTS — an orphan skin. The mesh then
  renders its bind pose (T-pose) forever while the skeleton animates unseen.

Fix: keep the standard ARMATURE_AUTO result (Armature modifier + vertex
groups). With the nearest-bone fallback ensuring real weights, the exporter
now writes a COMPLETE skin (verified: meshnode.skin=0, JOINTS/WEIGHTS present).

Also:
- skinned_player_model: _ensure_meshes_bound() re-binds any skinned mesh whose
  skeleton NodePath doesn't resolve at load — graceful degradation instead of
  a silent T-pose for imperfect GLBs.
- smoke test now asserts the spawned player's mesh is bound to its skeleton,
  so this class of bug fails the test instead of shipping. 30/30 pass.
- regenerated miku_test.glb with the corrected pipeline.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 18:11:26 -04:00
Nicholas ButzkeandClaude Fable 5 e2fbc424a7 feat: third-person camera toggle (V) + fix first-person showing inside the model
The first-person head-shrink (SkeletonModifier3D) left the camera looking at
the inside of the neck/torso/hair. Replaced it: the owner's model now renders
shadows-only in first person (clean FPS view, still fully animated and visible
to others and in shadows).

Added a third-person toggle (V / toggle_camera_view) so you can actually see
your own animated model — the easiest way to verify a new skin's animations:
- over-the-shoulder SpringArm3D camera (wall-collision aware) on the local
  player; press V to swap, press again to return
- firing still uses the first-person camera, so aim is unchanged
- reveals the owner's model via set_owner_visible() on both SkinnedPlayerModel
  and HumanoidModel; hides the first-person weapon viewmodel while in TP
- death forces first person so the toggle can't fight the death cam

Smoke test extended to cover the toggle (camera boom created, becomes current,
round-trips) — 28/28 checks pass for both color and GLB skins.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:18:16 -04:00
Nicholas ButzkeandClaude Fable 5 64bbbf93c6 fix: make the character pipeline work end-to-end in Blender 5.1 + bundle CC0 animation library
Verified the full chain on a real Sketchfab model (Miku UID -> rigged ->
13 clips -> loads and animates in-engine, 24/24 smoke checks pass).

Fixes found while running it for real:
- sketchfab_import: read .sketchfab_token as UTF-16/BOM too (PowerShell's
  `echo >` writes UTF-16LE, which crashed the UTF-8 reader)
- autorig: rig to the animation library's OWN skeleton when _library.glb
  is present (zero retargeting); robust binding — fall back to rigid
  nearest-bone weights when Blender bone-heat fails on complex meshes
  (Miku's joined hair/clothing gave 0 weighted verts); export as
  parent_type=ARMATURE with no modifier (Blender 5.x only emits a glTF
  skin in that exact form — a lingering Armature modifier gave skins:0);
  export_apply=False (applying modifiers baked away the skin); flatten
  the Sketchfab empty hierarchy before binding
- merge_animations: support a single multi-clip library GLB via
  LIBRARY_CLIP_MAP; version-safe fcurve access for Blender 4.4+/5.x
  slotted actions (Action.fcurves was removed)

Assets/tooling:
- bundle assets/characters/animations/_library.glb — CC0 Quaternius
  Universal Animation Library (13 game clips), + .gdignore so Godot
  skips the pipeline-input folder
- requirements.txt (stdlib-only; documents Blender-python separation)
- pipeline docs updated for the bundled library + swap instructions

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 12:42:31 -04:00
84 changed files with 1807 additions and 843 deletions
+3
View File
@@ -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/
Binary file not shown.
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 978 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
@@ -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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 978 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 978 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 978 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

+2 -2
View File
@@ -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
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+13
View File
@@ -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
+79 -7
View File
@@ -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
+233 -40
View File
@@ -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)
+3 -8
View File
@@ -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()
+21
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://dqc7midjff148
+3 -4
View File
@@ -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")
+40 -38
View File
@@ -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.
-33
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://cr1w53xtjptct
-37
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://bdes0ywhauamj
+1
View File
@@ -0,0 +1 @@
uid://c87jctwfqhyui
+56 -35
View File
@@ -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
+135 -35
View File
@@ -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
+105
View File
@@ -11,6 +11,12 @@ var camera: Camera3D = null
var flashlight: SpotLight3D = null
var _damage_layer: CanvasLayer = null
# Third-person view toggle (local player only). The first-person camera keeps
# handling aim/firing; the toggle only changes which camera renders and whether
# the owner sees their own model.
var third_person: bool = false
var _tp_camera: Camera3D = null
# Health and Shield
var max_health: float = 100.0
var health: float = 100.0
@@ -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
+29 -40
View File
@@ -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
+19 -31
View File
@@ -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
+56 -32
View File
@@ -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
+66 -127
View File
@@ -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
+82 -61
View File
@@ -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
+21 -35
View File
@@ -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")
+5 -2
View File
@@ -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
+39 -41
View File
@@ -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")
+16
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://d07u4xreb41rf
+14
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dhu2es4slyh2i
+167 -119
View File
@@ -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")
+5
View File
@@ -108,6 +108,11 @@ scoreboard={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
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]
+11
View File
@@ -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.
+41
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://b1v4lq35py3gd
+2
View File
@@ -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")
+234 -10
View File
@@ -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,
)
+210 -38
View File
@@ -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()
+8 -2
View File
@@ -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.")