Godot 4 third-person controller with AnimationTree
A tested Godot 4 third-person controller: camera-relative movement, an AnimationTree for walk, run, jump and attacks, stairs, and a camera that won't clip.
A third-person controller looks like a weekend job: move, turn, jump, a camera on a stick. Then the camera goes through a wall, the walk stops after one step, the jump floats, the attack freezes the legs, and the character runs on the spot against a wall. We went through about 900 threads on the Godot forum, godotforums.org and Game Development Stack Exchange, plus the open issues in Godot's tracker, and sorted the problems by how many people looked at them. This guide builds a controller that solves each one, and explains why each fix works.
Everything here runs in Godot 4.7.2 with a character from Hatchery3D. An automated test walks the player through 25 checks, from "the walk keeps looping after four seconds" to "the camera stays on the player's side of the wall". The two scripts are complete at the end of the post. They work with any humanoid character that has idle, walk, run, jump, slash, punch, hit and death clips.

What you'll build
- Movement relative to the camera, with acceleration, on keyboard and gamepad.
- A character that turns at a steady speed to face where it goes.
- A camera on a
SpringArm3Dthat orbits with the mouse or the right stick, pulls in before walls, and doesn't jitter. - An
AnimationTreebuilt in code: idle, walk and run blended by real speed; jump, fall and land; attacks that keep the legs walking; a hit reaction; death. - A jump that leaves the ground the moment you press it, with coyote time, a jump buffer and a shorter hop when you tap.
- Stairs, slopes and ledges.
If you haven't imported a character yet, start with how to import a GLB character with animations into Godot 4. That guide also shows how to read each clip's measured speed from the file, which this one uses.
The scene
Player (CharacterBody3D) player.gd
├── CollisionShape3D CapsuleShape3D, height 1.8, radius 0.3, y = 0.9
├── character your .glb, dragged in and renamed
├── CharacterAnimator (AnimationTree) character_animator.gd
└── CameraPivot (Node3D)
└── SpringArm3D Spring Length 4, Shape: SphereShape3D r 0.25, Margin 0.1
└── Camera3D
Four settings in the Inspector decide whether it works at all:
- CharacterAnimator → Anim Player:
../character/AnimationPlayer. - CharacterAnimator → Root Node:
../character. The tree resolves every track path from this node. Left at its default (..), it looks forPlayer/Rig/Skeleton3Dinstead ofcharacter/Rig/Skeleton3D, finds nothing, and the character stands still in the game. This is behind most "my animation works in the editor but not in game" threads. - Player → Collision → Layer 2 and Mask 1. Put the level on layer 1.
- SpringArm3D → Collision Mask: 1 only, so the arm ignores the player.
Turn on Physics → Common → Physics Interpolation in the Project Settings (switch on Advanced Settings to see it). The camera section explains why.
You don't need to set up the Input Map. The script adds move_forward, move_back, move_left, move_right, look_*, jump, sprint and attack with WASD, the arrow keys, Space, Shift, the left mouse button and a gamepad, unless you already have actions with those names. Your own bindings win.
How do I move relative to the camera?
This is the most-viewed controller question on the Godot forum. The default CharacterBody3D template moves along the world axes, so "forward" stays north however you turn the camera. Turn the input by the camera's yaw, and only the yaw:
func _move_direction() -> Vector3:
var input := Input.get_vector(&"move_left", &"move_right", &"move_forward", &"move_back")
# Forward is where the camera looks, flattened onto the floor.
return Basis(Vector3.UP, _yaw) * Vector3(input.x, 0.0, input.y)
Use the yaw alone, not the camera's full basis. With the camera's basis, looking down tilts "forward" into the floor and the character slows down. Input.get_vector also keeps diagonals from being faster than straight lines, which the forum reports as "my player runs faster in some directions".
The player then eases its velocity toward the target with move_toward, so it speeds up and slows down at a fixed rate, with less control in the air:
var rate := acceleration if is_on_floor() else acceleration * air_control
var horizontal := Vector3(velocity.x, 0.0, velocity.z).move_toward(target, rate * delta)
How do I make the character face where it walks?
Godot's forward is −Z, but a character from a glTF file faces +Z. So look_at() turns its back to the target, and rotation.y = atan2(direction.x, direction.z) turns its face toward the direction.
To turn smoothly, don't snap the rotation and then lerp it. That's the usual mistake in the threads, and lerp_angle(a, a, t) from a value to itself does nothing. Also, a lerp with a fixed weight slows down as it gets close and never quite arrives. rotate_toward turns at a steady speed and stops exactly on the angle, the shorter way round:
if direction.length() > 0.1:
var facing := atan2(direction.x, direction.z)
model.rotation.y = rotate_toward(model.rotation.y, facing, turn_speed * delta)
Turn the character node, not the Player. The body's capsule is round, so it doesn't need to turn, and the camera rig stays independent.
The camera: orbit, walls and jitter
The pivot follows the player at head height and holds the yaw and pitch. The spring arm points backwards from it, and the camera sits at the end of the arm.
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
_yaw -= event.relative.x * mouse_sensitivity
_pitch = clampf(_pitch - event.relative.y * mouse_sensitivity, min_pitch, max_pitch)
func _process(delta: float) -> void:
var look := Input.get_vector(&"look_left", &"look_right", &"look_up", &"look_down")
_yaw -= look.x * stick_sensitivity * delta
_pitch = clampf(_pitch - look.y * stick_sensitivity * delta, min_pitch, max_pitch)
pivot.global_position = get_global_transform_interpolated().origin + Vector3.UP * camera_height
pivot.rotation = Vector3(_pitch, _yaw, 0.0)
The mouse and the stick need different code. A mouse sends an event for each move, so you add event.relative once per event. A stick held still sends no events at all, so read it every frame in _process and multiply by delta. That's why the gamepad threads say "the camera only moves while I wiggle the stick".
Esc frees the mouse, and a click takes it back. The click that recaptures the mouse is marked as handled, so it doesn't also swing the sword.
Why does my camera clip through walls?
The SpringArm3D issue on GitHub has been open since 2022, and the forum has a dozen versions of it. Three settings fix it:
- Give the arm a shape. With no shape, the arm casts a thin ray. The ray stops at the wall, but the camera's near plane, a few centimetres in front of the lens, is already inside it. A
SphereShape3Dof 0.25 m keeps the whole lens out. - Exclude the player. If the arm can hit the player's own capsule, it collapses into the character's head. Keep the player off the arm's collision mask, and to be safe, call
spring_arm.add_excluded_object(get_rid())in_ready. - Keep the pivot inside the body. The arm casts from the pivot. If the pivot is outside the capsule, for example far to the side for an over-the-shoulder view, the cast can start inside the wall. Keep the pivot over the body's centre, and move the camera sideways with an offset on the camera, not on the pivot.
In the test, the arm is 4 m long in the open. With the player 1.25 m from a wall and the camera behind, it shortens to 1.06 m and the camera stays in front of the wall.
Why does my camera jitter?
Physics runs 60 times a second. Your screen may draw 120 or 144 times. A camera glued to a body that only moves 60 times a second shows the steps. Godot 4.4 and later fix this in three parts:
- Turn on physics interpolation in the Project Settings. Godot then draws bodies between their physics positions.
- Move the pivot to
get_global_transform_interpolated(), the drawn position, in_process. Follow the plainglobal_position, and you follow the steps again. - Turn interpolation off on the pivot, because you move it every drawn frame yourself:
pivot.top_level = true
pivot.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
Without the third step, Godot warns about an "interpolated Camera3D triggered from outside physics process". top_level frees the pivot from the player's rotation, so turning the character doesn't swing the camera.
The AnimationTree, built in code
You can build the tree in the editor's graph. We build it in code: it's the same every time, it fits in a blog post, and you can still inspect it while the game runs (Remote tab, select the AnimationTree). This is its shape:
State machine
├── move Blend tree
│ ├── locomotion BlendSpace1D: idle at 0, walk at 1.3, run at 2.6 (m/s)
│ ├── upper_body OneShot, filtered to Spine1 and every bone above it
│ ├── full_body OneShot, no filter
│ └── hit OneShot over everything
├── jump take-off to the top of the jump
├── fall the top of the jump, held
├── land landing and recovery
└── death
The player's script never calls AnimationPlayer.play(). While a tree is active, it decides what plays, and play() calls fight it. The player only reports what happens: its speed, a jump, a landing, an attack.
Why does my AnimationTree play nothing?
The state machine starts in its Start node. A transition from Start with the default Advance Mode, Enabled, waits for you to travel. Nothing travels, so the tree plays no animation, and the character holds its rest pose. Set the first transition to Auto:
var start := _transition(0.0)
start.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO
machine.add_transition(&"Start", &"move", start)
Why does my walk stop after one step?
Godot imports every clip to play once. Inside a tree, walk reaches its last frame after 2.3 s and stays there, so the character glides along with its legs frozen. The tree has no loop setting of its own for these clips: set the loop on the clips.
for clip in [&"idle", &"walk", &"run"]:
player.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
You can also do it in the import settings, as the import guide shows. In code, it survives a new take of the file and works for characters you load at runtime. The test checks it: after four seconds of walking, the left foot still moves 0.35 m back and forth. Without the loop, it moves 0.000 m.
How do I blend idle, walk and run without sliding feet?
Put the blend points at the speeds the clips were made for, in metres per second, and feed the blend space the body's speed. For our character those are 1.318 m/s for the walk and 2.573 m/s for the run. A Hatchery3D file stores them on each clip as speed_mps. With other characters, measure them: walk the character in a straight line and change the speed until the feet stop sliding.
locomotion.add_blend_point(_clip(&"idle"), 0.0, -1, &"idle")
locomotion.add_blend_point(_clip(&"walk"), walk_speed, -1, &"walk")
locomotion.add_blend_point(_clip(&"run"), run_speed, -1, &"run")
This works between the points too. At half the walk speed, the blend is half idle and half walk: half a stride at the same rhythm, so the feet still roughly keep up with the floor.
Feed it the real speed, not the speed the player asked for:
var real := get_real_velocity()
animator.set_speed(Vector2(real.x, real.z).length())
get_real_velocity() is how far the body actually moved. Against a wall it's zero, so the character stands in idle instead of running on the spot. On a slope it's the horizontal part of the speed, so the legs slow a little uphill, as real legs do.
Godot 4.7 warns when a blend point has no name, so give each one a name, as above.
Why does my jump lag, float, or play only the first frame?
Three different problems hide in this question.
Only the first frame. The code calls play("jump") or travel("jump") on every physics frame while the character is in the air. Each call starts the clip again. Tell the tree once, when the jump starts, and let the state machine move on by itself.
It floats. Many jump clips, ours included, are a whole standing jump: wind-up, take-off, the flight with the height of the jump in the hips, the landing and the recovery. Play that while physics lifts the body, and the character goes up twice: once from the velocity, once from the clip. Here's our clip, measured:
| Time | What happens | Hips |
|---|---|---|
| 0.0–0.4 s | Standing | 0.96 m |
| 0.4–0.75 s | Wind-up crouch | Down to 0.81 m |
| 0.75 s | Feet leave the floor | Rising |
| 1.1 s | Top of the jump | 1.41 m |
| 1.4 s | Feet touch down | Down to 0.77 m |
| 1.4–2.0 s | Recovery | Back to 0.96 m |
| 2.0–3.0 s | Standing | 0.96 m |
It lags. The feet leave the floor 0.75 s after the clip starts. Wait for that before you jump, and the game feels slow. Don't wait, and the character flies while it still crouches.
The animator cuts the clip into three parts, and takes the height out of the hips:
var parts := {
&"jump_up": [0.70, 1.10, Animation.LOOP_NONE],
&"jump_fall": [1.05, 1.20, Animation.LOOP_PINGPONG],
&"jump_land": [1.35, 1.95, Animation.LOOP_NONE],
}
_flatten_hips caps the hips at their standing height. The crouch of the landing stays, because it's below standing height, but the rise is gone: the body's velocity does the rising. The jump starts at 0.70 s, at the push-off, so the velocity and the pose start together on the same frame. The test checks that the state is jump and the body is rising 4.4 m/s two frames after the press.
These times are for a Hatchery3D jump. For another clip, scrub it in the AnimationPlayer and note when the feet leave and touch the floor.

If you want the wind-up in your game, play the clip from 0.45 s instead and add the velocity 0.3 s after the press. It feels heavier, which suits some characters. Decide on purpose.
Jump, fall, land
The state machine does the rest:
move → jumpwhen the player jumps.jump → fallat the end of the take-off, automatically (Switch Mode At End, Advance Mode Auto).move → fallwhen the player walks off a ledge, after the coyote time.jumporfall→landwhen the body touches the floor.land → movewhen thelandedcondition is true. The player sets it after half a second standing still, or after 0.1 s if the player is already moving again, so a run doesn't stall on every landing.
A state machine can't have two transitions between the same two states. We first tried one immediate land → move and one at the end of the clip. Godot kept only one, and the character stayed in land. One transition with a condition does both jobs.
Coyote time, jump buffer and a short hop
Three small timers make a jump feel fair:
func _update_jump(delta: float) -> void:
_since_floor = 0.0 if is_on_floor() else _since_floor + delta
_since_jump_press += delta
if Input.is_action_just_pressed(&"jump"):
_since_jump_press = 0.0
if _since_jump_press <= jump_buffer and _since_floor <= coyote_time and not _jumping:
velocity.y = sqrt(2.0 * get_gravity().length() * jump_height)
_jumping = true
_since_jump_press = INF
_since_floor = INF
animator.jump()
# Let go early for a lower jump.
if Input.is_action_just_released(&"jump") and velocity.y > 0.0:
velocity.y *= 0.5
- Coyote time: the jump still works for 0.12 s after the feet leave a ledge. The test presses jump four frames after walking off a 1 m ledge, and the character jumps.
- Jump buffer: a press up to 0.12 s before landing counts when the feet touch down.
- Short hop: releasing jump while rising halves the upward speed. Held, the jump reaches 1.04 m for a
jump_heightof 1.0. Tapped, it reaches 0.43 m.
sqrt(2 * g * h) is the speed that reaches height h under gravity g. Use it, and jump_height is in metres instead of a magic number.
How do I attack while running?
With one play("slash"), the slash replaces the walk, and the character slides along on frozen legs. That's the "prevent my player from moving while attacking" thread, from the other side. Play the attack on the upper body only, with a filtered OneShot:
var upper := AnimationNodeOneShot.new()
upper.filter_enabled = true
for path in _upper_body_paths():
upper.set_filter_path(path, true)
_upper_body_paths() lists Spine1 and every bone above it, as track paths like Rig/Skeleton3D:Chest. Only those bones take the slash. The hips and legs keep the walk underneath.

Standing still, the whole body should swing, so the animator has a second OneShot with no filter, and the player picks one by speed. During a full-body attack, the player's target speed is zero, so the feet stay planted:
if animator.get("parameters/move/full_body/active"):
target = Vector3.ZERO
Two OneShot details from the threads:
- Don't wait for
animation_finished. It doesn't fire reliably for clips inside a tree (there's an open issue). Read the OneShot'sactiveparameter instead, asis_attacking()does. - Don't fire a OneShot that is already playing. A second request restarts it, and the first attack comes back after the second.
attack()returns early while either OneShot is active.
A Transition node in front of each OneShot picks the clip, so the same OneShot plays slash or punch.
Hits and death
The hit reaction is a third OneShot on top of everything, so a hit can interrupt an attack. Death is a state of its own with transitions from every other state. A clip that plays once stays on its last frame, so the character stays down. The player stops reading input when it dies.
How do I walk up stairs with CharacterBody3D?
CharacterBody3D slides along a step instead of climbing it. The usual answer is an invisible ramp over each staircase. That works, and it's still the cleanest fix for long flights of stairs. For steps scattered through a level, a step-up check before move_and_slide handles them all:
func _step_up(delta: float) -> void:
if not is_on_floor():
return
var motion := Vector3(velocity.x, 0.0, velocity.z) * delta
if motion.length() < 0.001:
return
var hit := KinematicCollision3D.new()
if not test_move(global_transform, motion, hit):
return
if hit.get_normal().angle_to(Vector3.UP) <= floor_max_angle:
return # A slope: move_and_slide walks up it.
var up := Vector3.UP * step_height
if test_move(global_transform, up):
return # No room above.
var raised := global_transform.translated(up)
if test_move(raised, motion):
return # Too high: still blocked one step up.
# Look for the top of the step one capsule radius ahead: the round bottom
# of the capsule meets the edge of the step at a steep angle.
var probe := motion.normalized() * (motion.length() + _radius())
if test_move(raised, probe):
return
var down := KinematicCollision3D.new()
if test_move(raised.translated(probe), -up, down):
var rise := step_height - down.get_travel().length()
if rise > 0.01 and down.get_normal().angle_to(Vector3.UP) <= floor_max_angle:
global_position.y += rise
# The body pops up in one frame. Keep the model where it was and
# let it catch up, so the step looks like a step.
model.position.y -= rise
The probe is the detail that took us a test run to find. Our first version looked down for the step just one frame's movement ahead. The capsule's round bottom met the edge of the step there, at 59°. That's steeper than the 45° floor limit, so the code refused the step, and the character stood stuck at the first stair. Looking one capsule radius ahead finds the flat top of the step.
Raising the body by a whole step in one physics frame looks like a teleport. So the code lowers the model by the same amount, and _physics_process lifts it back at 1.5 m/s:
model.position.y = move_toward(model.position.y, 0.0, step_catch_up * delta)

In the test, the player walks up four 0.2 m steps onto a 0.8 m platform, and the model has caught up with the body when it arrives.
Slopes
Three properties of CharacterBody3D handle slopes:
floor_snap_lengthkeeps the body on the ground going down a slope or a step. Without it, the body leaves the ground on each step down and bounces, which is the "character bounces down slopes" thread. We set it to the step height.floor_constant_speedkeeps the speed the same uphill and downhill.floor_max_angle, 45° by default, is the steepest slope the body can walk on.
Add gravity only in the air, as the script does. On the ground it only pushes the body into the floor every frame, and forum threads tie it to sticky, scraping movement along walls. floor_stop_on_slope, on by default, keeps the body from sliding down a slope while it stands still. The test stands on a 25° slope for a second, and the body doesn't move.
Troubleshooting
| You see | The cause | The fix |
|---|---|---|
| The tree plays nothing, the character holds its rest pose | The transition out of Start isn't Auto | Set Advance Mode to Auto |
| It works in the editor but not in the game | The tree's Root Node is wrong, or the tree isn't active | Root Node ../character; set active = true |
travel() does nothing | A typo in the state name, no path to the state, or another travel() every frame | Print playback.get_current_node(); call travel() only when something changes |
| The walk stops after one step | The clips play once | Set loop_mode on idle, walk and run |
| The jump plays only its first frame | play() or travel() every frame restarts it | Tell the tree once, when the jump starts |
| The character goes up twice as high | The clip moves the hips up while physics moves the body | Cap the hips at standing height |
| The jump feels slow | The clip winds up before the feet leave | Start the clip at the push-off |
animation_finished never fires | The clip plays inside the tree | Read the OneShot's active parameter |
| An earlier attack plays again after a second one | A OneShot fired while it was still playing | Don't fire while active |
| The legs freeze during an attack | The attack replaces the whole body | A OneShot filtered to the upper body |
| It runs on the spot against a wall | The animation reads the input, not the movement | Use get_real_velocity() |
| The character walks backwards | It faces +Z, Godot's forward is −Z | atan2(direction.x, direction.z) |
| It snaps instead of turning | Snapped first, or a lerp from a value to itself | rotate_toward each frame |
| The camera goes through walls | The arm has no shape, or it hits the player | A sphere shape; exclude the player |
| The camera jitters | Physics steps at 60 Hz, the screen draws faster | Physics interpolation; follow the interpolated position |
| The gamepad camera moves only while the stick moves | The stick is read in _unhandled_input | Read it with Input.get_vector in _process |
| It bounces down slopes | No floor snap | floor_snap_length |
| It scrapes along walls | Gravity added on the ground | Add gravity only in the air |
| It stops at the first stair | CharacterBody3D doesn't climb steps | The step-up check, probing one radius ahead |
| It falls through the floor | The CollisionShape3D isn't a direct child of the body | Put it right under Player |
How we tested it
A headless script builds a level with a wall, four steps, a 25° slope and a 1 m ledge, puts the player in it, and presses the actions with Input.action_press. It checks:
- The character starts in idle on the floor.
- It walks at 1.32 m/s away from the camera, and runs at 2.57 m/s.
- Against the wall, the blend returns to idle.
- The walk keeps looping after four seconds.
- The jump starts on the second frame, reaches 1.04 m when held and 0.43 m when tapped, goes
jump → fall → land, and returns tomove. - An attack while walking plays on the upper body, and the body keeps moving at 1.32 m/s. A standing attack plays on the whole body, and the feet stay planted.
- It climbs four 0.2 m steps to 0.8 m, and the model catches up with the body.
- It walks up the 25° slope and stands on it without sliding.
- Walking off the ledge goes to
fall, thenland. A jump four frames after leaving the ledge still works. - The camera arm is 4 m long in the open, shortens to 1.06 m near the wall, and stays on the player's side.
- The hit reaction fires, and death holds.
The first runs caught three of the problems in this post: the tree waiting in Start, the two transitions between the same states, and the stair probe. One of our own checks was wrong too. The loop check first sampled the run too early, so it passed even with a frozen walk. Now it fails without the fix and passes with it.
The full scripts
character_animator.gd
Attach it to the AnimationTree. Set Walk Speed and Run Speed in the Inspector to your character's measured speeds.
class_name CharacterAnimator
extends AnimationTree
## Builds the whole animation graph from code: locomotion, jump, fall, land,
## attacks that keep the legs walking, a hit reaction and death.
## Put it under the player, point Anim Player at the character's
## AnimationPlayer and Root Node at the character.
## Measured speeds of the walk and run clips, in metres per second.
## A Hatchery3D character stores them as speed_mps in the file.
@export var walk_speed := 1.3
@export var run_speed := 2.6
## The first bone of the upper body. Attacks while moving play on this bone
## and everything below it, so the legs keep walking.
@export var upper_body_bone := &"Spine1"
const JUMP_CLIP := &"jump"
var _state: AnimationNodeStateMachinePlayback
func _ready() -> void:
var player := get_node(anim_player) as AnimationPlayer
# Godot imports every clip to play once. Loop the gaits, or the walk
# stops on its last frame after one cycle.
for clip in [&"idle", &"walk", &"run"]:
player.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
_make_jump_clips(player)
tree_root = _build_graph()
active = true
_state = get("parameters/playback")
## Horizontal speed in metres per second. Picks idle, walk, run or a blend.
func set_speed(speed: float) -> void:
set("parameters/move/locomotion/blend_position", speed)
## One-shot attack. Upper body only while moving, full body while standing.
func attack(clip: StringName, moving: bool) -> void:
if is_attacking():
return
set("parameters/move/attack_clip/transition_request", String(clip))
set("parameters/move/upper_body_clip/transition_request", String(clip))
var shot := "upper_body" if moving else "full_body"
set("parameters/move/%s/request" % shot, AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE)
func is_attacking() -> bool:
return get("parameters/move/upper_body/active") or get("parameters/move/full_body/active")
func hit() -> void:
set("parameters/move/hit/request", AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE)
func jump() -> void:
set("parameters/conditions/landed", false)
_state.travel(&"jump")
func fall() -> void:
if _state.get_current_node() == &"move":
_state.travel(&"fall")
func land() -> void:
if _state.get_current_node() in [&"jump", &"fall"]:
set("parameters/conditions/landed", false)
_state.travel(&"land")
## The landing is over, or the player moves on: back to locomotion.
func back_to_moving() -> void:
set("parameters/conditions/landed", true)
func die() -> void:
_state.travel(&"death")
func current_state() -> StringName:
return _state.get_current_node()
# The jump clip is a whole standing jump: wind-up, take-off, flight, landing
# and recovery, with the height of the jump in the hips. Physics moves the
# body, so cut the clip into its parts and take the height out of the hips.
func _make_jump_clips(player: AnimationPlayer) -> void:
var library := player.get_animation_library(&"")
var source := player.get_animation(JUMP_CLIP)
var parts := {
&"jump_up": [0.70, 1.10, Animation.LOOP_NONE],
&"jump_fall": [1.05, 1.20, Animation.LOOP_PINGPONG],
&"jump_land": [1.35, 1.95, Animation.LOOP_NONE],
}
for part: StringName in parts:
var span: Array = parts[part]
var clip := _slice(source, span[0], span[1])
clip.loop_mode = span[2]
_flatten_hips(clip, _standing_hips_height(source))
if library.has_animation(part):
library.remove_animation(part)
library.add_animation(part, clip)
# A copy of the clip between two times, starting at zero.
func _slice(source: Animation, from: float, to: float) -> Animation:
var clip := Animation.new()
clip.length = to - from
for track in source.get_track_count():
var type := source.track_get_type(track)
var copy := clip.add_track(type)
clip.track_set_path(copy, source.track_get_path(track))
clip.track_set_interpolation_type(copy, source.track_get_interpolation_type(track))
var time := from
while time < to + 0.0001:
var at := minf(time, to)
match type:
Animation.TYPE_POSITION_3D:
clip.position_track_insert_key(copy, at - from, source.position_track_interpolate(track, at))
Animation.TYPE_ROTATION_3D:
clip.rotation_track_insert_key(copy, at - from, source.rotation_track_interpolate(track, at))
Animation.TYPE_SCALE_3D:
clip.scale_track_insert_key(copy, at - from, source.scale_track_interpolate(track, at))
_:
clip.track_insert_key(copy, at - from, source.value_track_interpolate(track, at))
time += 1.0 / 30.0
return clip
func _standing_hips_height(source: Animation) -> float:
var track := _hips_track(source)
return source.position_track_interpolate(track, 0.0).y
# Keep the crouch of the wind-up and the landing, but never let the hips rise
# above standing height: the body's velocity does the rising.
func _flatten_hips(clip: Animation, standing: float) -> void:
var track := _hips_track(clip)
for key in clip.track_get_key_count(track):
var position: Vector3 = clip.track_get_key_value(track, key)
position.y = minf(position.y, standing)
clip.track_set_key_value(track, key, position)
func _hips_track(clip: Animation) -> int:
for track in clip.get_track_count():
var path := String(clip.track_get_path(track))
if path.ends_with(":Hips") and clip.track_get_type(track) == Animation.TYPE_POSITION_3D:
return track
push_error("No position track for the Hips bone.")
return -1
func _build_graph() -> AnimationNodeStateMachine:
var machine := AnimationNodeStateMachine.new()
machine.add_node(&"move", _build_move(), Vector2(0, 0))
machine.add_node(&"jump", _clip(&"jump_up"), Vector2(300, -100))
machine.add_node(&"fall", _clip(&"jump_fall"), Vector2(300, 100))
machine.add_node(&"land", _clip(&"jump_land"), Vector2(600, 0))
machine.add_node(&"death", _clip(&"death"), Vector2(0, 250))
# Without Auto, the machine waits in Start and plays nothing.
var start := _transition(0.0)
start.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO
machine.add_transition(&"Start", &"move", start)
machine.add_transition(&"move", &"jump", _transition(0.1))
machine.add_transition(&"move", &"fall", _transition(0.2))
# The take-off ends at the top of the jump: fall from there.
var to_fall := _transition(0.1)
to_fall.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END
to_fall.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO
machine.add_transition(&"jump", &"fall", to_fall)
machine.add_transition(&"jump", &"land", _transition(0.05))
machine.add_transition(&"fall", &"land", _transition(0.05))
machine.add_transition(&"land", &"jump", _transition(0.1))
# One transition per pair of states: the player's code sets the condition
# when the landing is over or the player moves on.
var after_land := _transition(0.2)
after_land.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO
after_land.advance_condition = &"landed"
machine.add_transition(&"land", &"move", after_land)
for from in [&"move", &"jump", &"fall", &"land"]:
machine.add_transition(from, &"death", _transition(0.2))
return machine
# Locomotion, then the attacks over it, then the hit reaction over everything.
func _build_move() -> AnimationNodeBlendTree:
var tree := AnimationNodeBlendTree.new()
var locomotion := AnimationNodeBlendSpace1D.new()
locomotion.min_space = 0.0
locomotion.max_space = run_speed * 1.5
locomotion.add_blend_point(_clip(&"idle"), 0.0, -1, &"idle")
locomotion.add_blend_point(_clip(&"walk"), walk_speed, -1, &"walk")
locomotion.add_blend_point(_clip(&"run"), run_speed, -1, &"run")
tree.add_node(&"locomotion", locomotion, Vector2(0, 0))
var attacks := [&"slash", &"punch"]
for choice: StringName in [&"attack_clip", &"upper_body_clip"]:
tree.add_node(choice, _choice(attacks), Vector2(0, 200))
for index in attacks.size():
var input := StringName("%s_%s" % [choice, attacks[index]])
tree.add_node(input, _clip(attacks[index]), Vector2(-250, 200 + index * 100))
tree.connect_node(choice, index, input)
var upper := AnimationNodeOneShot.new()
upper.fadein_time = 0.1
upper.fadeout_time = 0.2
upper.filter_enabled = true
for path in _upper_body_paths():
upper.set_filter_path(path, true)
tree.add_node(&"upper_body", upper, Vector2(300, 0))
var full := AnimationNodeOneShot.new()
full.fadein_time = 0.1
full.fadeout_time = 0.2
tree.add_node(&"full_body", full, Vector2(550, 0))
var hit := AnimationNodeOneShot.new()
hit.fadein_time = 0.05
hit.fadeout_time = 0.25
tree.add_node(&"hit", hit, Vector2(800, 0))
tree.add_node(&"hit_clip", _clip(&"hit"), Vector2(550, 200))
tree.connect_node(&"upper_body", 0, &"locomotion")
tree.connect_node(&"upper_body", 1, &"upper_body_clip")
tree.connect_node(&"full_body", 0, &"upper_body")
tree.connect_node(&"full_body", 1, &"attack_clip")
tree.connect_node(&"hit", 0, &"full_body")
tree.connect_node(&"hit", 1, &"hit_clip")
tree.connect_node(&"output", 0, &"hit")
return tree
# A Transition node with one input for each clip, so code can pick the clip.
func _choice(clips: Array) -> AnimationNodeTransition:
var choice := AnimationNodeTransition.new()
choice.xfade_time = 0.0
for clip: StringName in clips:
choice.add_input(String(clip))
return choice
func _clip(name: StringName) -> AnimationNodeAnimation:
var node := AnimationNodeAnimation.new()
node.animation = name
return node
func _transition(xfade: float) -> AnimationNodeStateMachineTransition:
var transition := AnimationNodeStateMachineTransition.new()
transition.xfade_time = xfade
return transition
# Track paths of the upper body bones, as the tree sees them.
func _upper_body_paths() -> Array[NodePath]:
var character := get_node(root_node)
var skeleton := character.find_child("Skeleton3D", true, false) as Skeleton3D
var prefix := String(character.get_path_to(skeleton))
var first := skeleton.find_bone(upper_body_bone)
var paths: Array[NodePath] = []
for bone in skeleton.get_bone_count():
var parent := bone
while parent != -1 and parent != first:
parent = skeleton.get_bone_parent(parent)
if parent == first:
paths.append(NodePath("%s:%s" % [prefix, skeleton.get_bone_name(bone)]))
return paths
player.gd
Attach it to the Player.
extends CharacterBody3D
## Third-person player: moves relative to the camera, turns to face where it
## goes, jumps with coyote time and a jump buffer, climbs low steps, and
## drives a CharacterAnimator.
@export_group("Movement")
@export var acceleration := 10.0 ## Metres per second, per second.
@export var air_control := 0.3 ## Share of the acceleration in the air.
@export var turn_speed := 10.0 ## Radians per second.
@export var jump_height := 1.0 ## Metres.
@export var coyote_time := 0.12 ## Seconds after a ledge when a jump still works.
@export var jump_buffer := 0.12 ## Seconds a jump press waits for the floor.
@export var step_height := 0.3 ## The highest step it walks up, in metres.
@export var landing_time := 0.5 ## Seconds the landing plays when standing still.
@export var step_catch_up := 1.5 ## How fast the model rises onto a step, in metres per second.
@export_group("Camera")
@export var mouse_sensitivity := 0.003 ## Radians per pixel.
@export var stick_sensitivity := 3.0 ## Radians per second at full tilt.
@export var camera_height := 1.5 ## The point the camera orbits, in metres.
@export var min_pitch := -1.2
@export var max_pitch := 0.4
@onready var model: Node3D = $character
@onready var animator: CharacterAnimator = $CharacterAnimator
@onready var pivot: Node3D = $CameraPivot
@onready var spring_arm: SpringArm3D = $CameraPivot/SpringArm3D
var _yaw := 0.0
var _pitch := -0.35
var _since_floor := 0.0
var _since_jump_press := INF
var _jumping := false
var _in_air := false
var _landed_for := 0.0
var _dead := false
func _ready() -> void:
_add_default_inputs()
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
spring_arm.add_excluded_object(get_rid())
pivot.top_level = true
# The camera moves every drawn frame in _process, already smooth: physics
# interpolation must leave it alone.
pivot.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
floor_snap_length = step_height
floor_constant_speed = true
_yaw = model.global_rotation.y + PI
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
_yaw -= event.relative.x * mouse_sensitivity
_pitch = clampf(_pitch - event.relative.y * mouse_sensitivity, min_pitch, max_pitch)
elif event.is_action_pressed(&"ui_cancel"):
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif event is InputEventMouseButton and event.pressed and Input.mouse_mode != Input.MOUSE_MODE_CAPTURED:
# The click that takes the mouse back doesn't also attack.
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
get_viewport().set_input_as_handled()
func _process(delta: float) -> void:
var look := Input.get_vector(&"look_left", &"look_right", &"look_up", &"look_down")
_yaw -= look.x * stick_sensitivity * delta
_pitch = clampf(_pitch - look.y * stick_sensitivity * delta, min_pitch, max_pitch)
# Follow the drawn position, not the physics position, or the camera jitters.
pivot.global_position = get_global_transform_interpolated().origin + Vector3.UP * camera_height
pivot.rotation = Vector3(_pitch, _yaw, 0.0)
func _physics_process(delta: float) -> void:
if not is_on_floor():
velocity += get_gravity() * delta
if _dead:
velocity.x = 0.0
velocity.z = 0.0
move_and_slide()
return
_update_jump(delta)
var direction := _move_direction()
_update_velocity(direction, delta)
if direction.length() > 0.1:
var facing := atan2(direction.x, direction.z)
model.rotation.y = rotate_toward(model.rotation.y, facing, turn_speed * delta)
_step_up(delta)
move_and_slide()
model.position.y = move_toward(model.position.y, 0.0, step_catch_up * delta)
_update_animation(direction, delta)
func take_hit() -> void:
if not _dead:
animator.hit()
func die() -> void:
_dead = true
animator.die()
func _move_direction() -> Vector3:
var input := Input.get_vector(&"move_left", &"move_right", &"move_forward", &"move_back")
# Forward is where the camera looks, flattened onto the floor.
return Basis(Vector3.UP, _yaw) * Vector3(input.x, 0.0, input.y)
func _update_velocity(direction: Vector3, delta: float) -> void:
var top_speed := animator.run_speed if Input.is_action_pressed(&"sprint") else animator.walk_speed
var target := direction * top_speed
# A full-body attack plants the feet.
if animator.get("parameters/move/full_body/active"):
target = Vector3.ZERO
var rate := acceleration if is_on_floor() else acceleration * air_control
var horizontal := Vector3(velocity.x, 0.0, velocity.z).move_toward(target, rate * delta)
velocity.x = horizontal.x
velocity.z = horizontal.z
func _update_jump(delta: float) -> void:
_since_floor = 0.0 if is_on_floor() else _since_floor + delta
_since_jump_press += delta
if Input.is_action_just_pressed(&"jump"):
_since_jump_press = 0.0
if _since_jump_press <= jump_buffer and _since_floor <= coyote_time and not _jumping:
velocity.y = sqrt(2.0 * get_gravity().length() * jump_height)
_jumping = true
_since_jump_press = INF
_since_floor = INF
animator.jump()
# Let go early for a lower jump.
if Input.is_action_just_released(&"jump") and velocity.y > 0.0:
velocity.y *= 0.5
func _update_animation(direction: Vector3, delta: float) -> void:
# The speed the body really moved: against a wall, that's zero, so idle.
var real := get_real_velocity()
animator.set_speed(Vector2(real.x, real.z).length())
if is_on_floor():
if _in_air:
_in_air = false
_jumping = false
_landed_for = 0.0
animator.land()
_landed_for += delta
# Moving again cuts the landing short.
if _landed_for > landing_time or (direction.length() > 0.1 and _landed_for > 0.1):
animator.back_to_moving()
else:
_in_air = true
if not _jumping and _since_floor > coyote_time:
animator.fall()
if Input.is_action_just_pressed(&"attack") and is_on_floor():
animator.attack(&"slash", direction.length() > 0.1)
# CharacterBody3D slides along steps instead of climbing them. When the way
# ahead is blocked by something steep, try the same move one step higher,
# then put the body down on top of the step.
func _step_up(delta: float) -> void:
if not is_on_floor():
return
var motion := Vector3(velocity.x, 0.0, velocity.z) * delta
if motion.length() < 0.001:
return
var hit := KinematicCollision3D.new()
if not test_move(global_transform, motion, hit):
return
if hit.get_normal().angle_to(Vector3.UP) <= floor_max_angle:
return # A slope: move_and_slide walks up it.
var up := Vector3.UP * step_height
if test_move(global_transform, up):
return # No room above.
var raised := global_transform.translated(up)
if test_move(raised, motion):
return # Too high: still blocked one step up.
# Look for the top of the step one capsule radius ahead: the round bottom
# of the capsule meets the edge of the step at a steep angle.
var probe := motion.normalized() * (motion.length() + _radius())
if test_move(raised, probe):
return
var down := KinematicCollision3D.new()
if test_move(raised.translated(probe), -up, down):
var rise := step_height - down.get_travel().length()
if rise > 0.01 and down.get_normal().angle_to(Vector3.UP) <= floor_max_angle:
global_position.y += rise
# The body pops up in one frame. Keep the model where it was and
# let it catch up, so the step looks like a step.
model.position.y -= rise
func _radius() -> float:
var capsule := $CollisionShape3D.shape as CapsuleShape3D
return capsule.radius if capsule else 0.3
func _add_default_inputs() -> void:
var keys := {
&"move_forward": [KEY_W, KEY_UP], &"move_back": [KEY_S, KEY_DOWN],
&"move_left": [KEY_A, KEY_LEFT], &"move_right": [KEY_D, KEY_RIGHT],
&"jump": [KEY_SPACE], &"sprint": [KEY_SHIFT],
}
var axes := {
&"move_left": [JOY_AXIS_LEFT_X, -1.0], &"move_right": [JOY_AXIS_LEFT_X, 1.0],
&"move_forward": [JOY_AXIS_LEFT_Y, -1.0], &"move_back": [JOY_AXIS_LEFT_Y, 1.0],
&"look_left": [JOY_AXIS_RIGHT_X, -1.0], &"look_right": [JOY_AXIS_RIGHT_X, 1.0],
&"look_up": [JOY_AXIS_RIGHT_Y, -1.0], &"look_down": [JOY_AXIS_RIGHT_Y, 1.0],
}
var buttons := {
&"jump": JOY_BUTTON_A, &"sprint": JOY_BUTTON_LEFT_STICK, &"attack": JOY_BUTTON_X,
}
for action: StringName in [&"move_forward", &"move_back", &"move_left", &"move_right",
&"look_left", &"look_right", &"look_up", &"look_down", &"jump", &"sprint", &"attack"]:
if InputMap.has_action(action):
continue # Your own bindings from the Input Map win.
InputMap.add_action(action, 0.2)
for key: Key in keys.get(action, []):
var event := InputEventKey.new()
event.physical_keycode = key
InputMap.action_add_event(action, event)
if axes.has(action):
var motion := InputEventJoypadMotion.new()
motion.axis = axes[action][0]
motion.axis_value = axes[action][1]
InputMap.action_add_event(action, motion)
if buttons.has(action):
var button := InputEventJoypadButton.new()
button.button_index = buttons[action]
InputMap.action_add_event(action, button)
if InputMap.action_get_events(&"attack").filter(func(e): return e is InputEventMouseButton).is_empty():
var click := InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_LEFT
InputMap.action_add_event(&"attack", click)
Where to go from here
- More moves. Add a clip as one more input on the attack Transition nodes, or one more blend point. A Hatchery3D character comes with all the clips this controller uses.
- Strafing and aiming. Replace the BlendSpace1D with a BlendSpace2D of forward, back and side clips, and face the camera instead of the movement.
- A weapon in the hand. The Godot import guide attaches a prop to the hand socket and closes the fingers around the grip.
- A different character. Describe the next one well, with what to put in a 3D character prompt.