Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/idle.glb
Binary file not shown.
Binary file added assets/jump.glb
Binary file not shown.
Binary file added assets/run.glb
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_jump01.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_jump02.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_jump03.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_land01.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_land02.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_run01.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_run02.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_run03.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_run04.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_run05.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_run06.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_run07.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_run08.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_walk01.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_walk02.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_walk03.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_walk04.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_walk05.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_walk06.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_walk07.wav
Binary file not shown.
Binary file added assets/sounds/avatar/avatar_footstep_walk08.wav
Binary file not shown.
Binary file added assets/walk.glb
Binary file not shown.
218 changes: 102 additions & 116 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@
"description": "ECS7 ",
"version": "1.0.0",
"dependencies": {
"@dcl/ecs": "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/1349/merge/dcl-sdk-7.20.5-22901328357.commit-f984718.tgz",
"@dcl/js-runtime": "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/walk-player-to/@dcl/js-runtime/dcl-js-runtime-7.20.5-22901328357.commit-f984718.tgz",
"@dcl/sdk": "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/1349/merge/dcl-sdk-7.20.5-22901328357.commit-f984718.tgz",
"@dcl/sdk-commands": "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/1349/merge/dcl-sdk-commands-7.20.5-22901328357.commit-f984718.tgz"
"@dcl/sdk": "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/1377/merge/dcl-sdk-7.22.5-24715381227.commit-c17cf4f.tgz"
},
"engines": {
"node": ">=16.0.0",
Expand Down
2 changes: 1 addition & 1 deletion src/ground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function initGroundRaycast() {

function recordGroundState() {
prevGrounded = grounded;
grounded = ((groundHitTick == tick) || (playerPosition.y < 0.01)) && prevActualVelocity.y <= 0.01;
grounded = ((groundHitTick == tick) || (playerPosition.y < 0.01));
if (playerPosition.y < 0.01) {
Vector3.copyFrom(VEC3_UP, groundNormal);
}
Expand Down
222 changes: 218 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,57 @@
import { AvatarMovement, AvatarMovementInfo, engine, Transform } from '@dcl/sdk/ecs'
import { AvatarAnimationState, AvatarMovement, AvatarMovementInfo, engine, MovementAnimation, Transform } from '@dcl/sdk/ecs'
import { Quaternion, Vector3 } from '@dcl/sdk/math';
import { getExplorerConfiguration } from '~system/EnvironmentApi';
import { initGroundRaycast, updateGroundAdjust } from './ground';
import { grounded, initGroundRaycast, updateGroundAdjust } from './ground';
import { dampVelocity, orientation, updateHorizontalVelocity } from './horizontal';
import { initStepCasts, updateVerticalVelocity } from './vertical';
import { currentJumpHeight, initStepCasts, jumpStartHeight, updateVerticalVelocity } from './vertical';
import { initParamters as initParameters } from './parameters';
import { initWalkSystem, updateEngineWalk, consumeWalkResult } from './walk';
import { MAX_SPEED } from './constants';
import { GRAVITY, MAX_SPEED } from './constants';

// Avatar-bus audio clip pools published via MovementAnimation.sounds. Engine
// plays each listed clip once per frame on the avatar's local audio bus — the
// `sounds` field is single-frame fire-and-forget, so we only include it on the
// tick the event actually fires. Timings and variant pools mirror the native
// built-ins in `crates/collectibles/src/emotes.rs` so this scene-driven path
// behaves the same as the velocity-based fallback.
const WALK_STEP_SOUNDS = [
'assets/sounds/avatar/avatar_footstep_walk01.wav',
'assets/sounds/avatar/avatar_footstep_walk02.wav',
'assets/sounds/avatar/avatar_footstep_walk03.wav',
'assets/sounds/avatar/avatar_footstep_walk04.wav',
'assets/sounds/avatar/avatar_footstep_walk05.wav',
'assets/sounds/avatar/avatar_footstep_walk06.wav',
'assets/sounds/avatar/avatar_footstep_walk07.wav',
'assets/sounds/avatar/avatar_footstep_walk08.wav',
];
const RUN_STEP_SOUNDS = [
'assets/sounds/avatar/avatar_footstep_run01.wav',
'assets/sounds/avatar/avatar_footstep_run02.wav',
'assets/sounds/avatar/avatar_footstep_run03.wav',
'assets/sounds/avatar/avatar_footstep_run04.wav',
'assets/sounds/avatar/avatar_footstep_run05.wav',
'assets/sounds/avatar/avatar_footstep_run06.wav',
'assets/sounds/avatar/avatar_footstep_run07.wav',
'assets/sounds/avatar/avatar_footstep_run08.wav',
];
const JUMP_SOUNDS = [
'assets/sounds/avatar/avatar_footstep_jump01.wav',
'assets/sounds/avatar/avatar_footstep_jump02.wav',
'assets/sounds/avatar/avatar_footstep_jump03.wav',
];
const LAND_SOUNDS = [
'assets/sounds/avatar/avatar_footstep_land01.wav',
'assets/sounds/avatar/avatar_footstep_land02.wav',
];

function pickRandom(pool: string[]): string {
return pool[Math.floor(Math.random() * pool.length)];
}

// Clip-relative playback times (seconds) at which the walk/run loops trigger
// a footstep. Native config: walk.(0.41, 0.91), run.(0.21, 0.54).
const WALK_STEP_TIMES = [0.41, 0.91];
const RUN_STEP_TIMES = [0.21, 0.54];

// export all the functions required to make the scene work
export * from '@dcl/sdk'
Expand Down Expand Up @@ -62,6 +107,7 @@ function initFrame() {
playerRotation = playerTransform.rotation;

const movementInfo = AvatarMovementInfo.getOrNull(engine.PlayerEntity);
activeAnimationState = movementInfo?.activeAnimationState;
if (movementInfo !== null) {
Vector3.copyFrom(movementInfo.requestedVelocity ?? Vector3.Zero(), prevRequestedVelocity);
Vector3.copyFrom(movementInfo.actualVelocity ?? Vector3.Zero(), prevActualVelocity);
Expand All @@ -83,12 +129,180 @@ function initFrame() {
}
}

// Tracks the last jumpStartHeight we observed so we can detect a new jump
// (undefined -> defined) and seek the jump clip back to 0 on its first frame.
var prevJumpStartHeight: number | undefined = undefined;
// True from touchdown until the engine reports the non-looped landing clip has
// played through. While true, keep re-requesting the landing so the engine
// holds it; once activeAnimationState shows it has completed, fall through.
var requestingLanding = false;
// Max Y reached while airborne; reset on landing. Used to gate the landing
// sound to drops of >0.5m so stepping off a small curb stays silent.
var maxUngroundedY = -Infinity;
var wasJumpingOrFalling = false;
// Mirror of the engine's currently-active scene animation state. Read in
// initFrame; consulted in selectAnimation to decide when to stop the landing.
var activeAnimationState: AvatarAnimationState | undefined = undefined;
// Last-observed playback phase of the currently-active scene animation, used to
// detect when the clip wrapped past a footstep trigger time since the previous
// frame. Keyed per src so switching clip resets tracking.
var prevSoundTrackedSrc: string | undefined = undefined;
var prevSoundTrackedTime: number = 0;

// Detects whether the clip's playback time crossed any of the given trigger
// timestamps (in seconds) while playing forward between `prev` and `cur`.
// Backward motion (negative animation speed, e.g. while turning in place
// with the walk clip's speed driven by signed directional velocity) is
// ignored so we don't fire a storm of sounds as the clip scrubs back and
// forth. A genuine loop wrap is distinguished from backward play by
// requiring the apparent reverse jump to span more than half the clip.
function stepTriggered(prev: number, cur: number, duration: number, triggers: number[]): boolean {
if (cur === prev) return false;
const isLoopWrap = cur < prev && duration > 0 && prev - cur > duration * 0.5;
if (cur < prev && !isLoopWrap) return false;
for (const t of triggers) {
if (isLoopWrap) {
if (t > prev || t <= cur) return true;
} else if (t > prev && t <= cur) {
return true;
}
}
return false;
}

function selectAnimation(): MovementAnimation {
const jumpingOrFalling = jumpStartHeight !== undefined || !grounded;
const newJump = jumpStartHeight !== undefined && prevJumpStartHeight === undefined;
prevJumpStartHeight = jumpStartHeight;

if (jumpingOrFalling) {
maxUngroundedY = Math.max(maxUngroundedY, playerPosition.y);
}
// Ungrounded -> grounded transition: the frame the landing sound may fire.
const justLanded = wasJumpingOrFalling && !jumpingOrFalling;
wasJumpingOrFalling = jumpingOrFalling;

if (jumpingOrFalling) {
requestingLanding = true;
// Match the jump clip's ascent timing to the physical ascent: time-to-peak
// under gravity = sqrt(2h/g). Speed 0.5/ttp means the clip hits its apex
// (midpoint, t=0.5) at roughly the same moment the avatar does.
const gravityMag = Math.abs(GRAVITY.y);
const timeToPeak = Math.sqrt(2 * Math.max(currentJumpHeight, 0.01) / gravityMag);
const ascentSpeed = 0.5 / timeToPeak;
// Once the clip has played past its apex pose (midpoint), freeze it via
// speed 0 so long falls from high places don't loop the clip back to the
// takeoff pose. We deliberately don't re-seek every frame — the engine
// preserves the current playback position, and an explicit seek would
// add a visible hiccup to the pose.
const s = activeAnimationState;
const atApex = s !== undefined
&& s.src === 'assets/jump.glb'
&& s.playbackTime >= 0.5;
// Jump takeoff sound fires for exactly one frame on the newJump tick.
return {
src: 'assets/jump.glb',
speed: atApex ? 0 : ascentSpeed,
loop: true,
idle: false,
transitionSeconds: 0.1,
playbackTime: newJump ? 0 : undefined,
sounds: newJump ? [pickRandom(JUMP_SOUNDS)] : [],
};
}

// Landing: play the non-looped jump clip until the engine reports it has
// finished. `loopCount >= 1` means a non-looping clip ran past its end;
// `playbackTime >= duration` covers the tick where it first hits the end.
if (requestingLanding) {
const s = activeAnimationState;
const landingClipFinished = s !== undefined
&& s.src === 'assets/jump.glb'
&& !s.loop
&& (s.loopCount >= 1 || s.playbackTime >= s.duration);
if (landingClipFinished) {
requestingLanding = false;
} else {
// Fire the landing sound on the touchdown frame only, and only if the
// fall was substantial. Covers both jumps and uncommanded walk-offs.
var landSounds: string[] = [];
if (justLanded) {
if (maxUngroundedY - playerPosition.y > 0.5) {
landSounds = [pickRandom(LAND_SOUNDS)];
}
maxUngroundedY = -Infinity;
}
return {
src: 'assets/jump.glb',
speed: 1.5,
loop: false,
idle: false,
transitionSeconds: 0.1,
sounds: landSounds,
};
}
}

// Directional (signed) forward speed — matches engine's damped_velocity projected
// onto gt.forward(); lets the walk/run anim play reversed when moving backward.
const forward = Vector3.rotate(Vector3.Forward(), playerRotation);
const directionalVelLen = velocity.x * forward.x + velocity.z * forward.z;

if (velocityLength > 0.1) {
if (velocityLength <= 2.6) {
return {
src: 'assets/walk.glb',
speed: directionalVelLen / 1.5,
loop: true,
idle: false,
transitionSeconds: 0.4,
sounds: footstepsFor('assets/walk.glb', WALK_STEP_TIMES, WALK_STEP_SOUNDS),
};
}
return {
src: 'assets/run.glb',
speed: directionalVelLen / 4.5,
loop: true,
idle: false,
transitionSeconds: 0.4,
sounds: footstepsFor('assets/run.glb', RUN_STEP_TIMES, RUN_STEP_SOUNDS),
};
}

return {
src: 'assets/idle.glb',
speed: 1.0,
loop: true,
idle: true,
transitionSeconds: 0.4,
sounds: [],
};
}

// Detects a footstep trigger crossing for the currently-playing clip. Returns a
// single-frame sounds list (or empty) so remote clients see each step as an
// independent transition.
function footstepsFor(src: string, triggers: number[], pool: string[]): string[] {
const s = activeAnimationState;
if (s === undefined || s.src !== src) {
prevSoundTrackedSrc = src;
prevSoundTrackedTime = 0;
return [];
}
const cur = s.playbackTime;
const prev = prevSoundTrackedSrc === src ? prevSoundTrackedTime : cur;
prevSoundTrackedSrc = src;
prevSoundTrackedTime = cur;
return stepTriggered(prev, cur, s.duration, triggers) ? [pickRandom(pool)] : [];
}

function writeMovement() {
AvatarMovement.createOrReplace(engine.PlayerEntity, {
velocity,
orientation: -orientation,
groundDirection: Vector3.Down(),
walkSuccess: consumeWalkResult(),
animation: selectAnimation(),
})

Vector3.copyFrom(velocity, lastPublished);
Expand Down
20 changes: 17 additions & 3 deletions src/vertical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,24 @@ function applyGravity() {
}

export var jumpStartHeight: number | undefined = undefined;
// The jump height applicable to the current (or next) jump — blended between
// walk-jump and sprint-jump based on horizontal speed. Exported so the animation
// selector can size the jump clip's playback speed to match the ascent duration.
export var currentJumpHeight = 0;
var jumpWasPressed = false;
var jumpReleased = false;
function applyJump() {
const jumpIsPressed = inputSystem.isPressed(InputAction.IA_JUMP);

// Track release so a re-press mid-jump doesn't re-engage the continuing-jump
// branch with stale state — that branch sets velocity.y = min(requiredSpeed,
// jumpSpeedCap), and jumpSpeedCap derives from the decayed prev velocity so
// it collapses to a negative, driving velocity.y deeply negative while
// jumpStartHeight stays defined (keeping the avatar in jump animation).
if (!jumpIsPressed && jumpStartHeight !== undefined) {
jumpReleased = true;
}

// Use the more conservative of prev requested vs prev actual so external
// forces (impulses, moving platforms) don't inflate sprint jump height/speed.
const prevReqHorizLen = Math.sqrt(prevRequestedVelocity.x * prevRequestedVelocity.x + prevRequestedVelocity.z * prevRequestedVelocity.z);
Expand All @@ -43,7 +57,7 @@ function applyJump() {
(naturalHorizSpeed - jogSpeed)
/ (sprintSpeed - jogSpeed)
));
const currentJumpHeight = jumpHeight + (sprintJumpHeight - jumpHeight) * sprintRatio;
currentJumpHeight = jumpHeight + (sprintJumpHeight - jumpHeight) * sprintRatio;
const currentJumpSpeed = JUMP_SPEED + (JUMP_SPEED_SPRINT - JUMP_SPEED) * sprintRatio;

// Same rationale for the vertical cap: don't let external vertical forces
Expand All @@ -58,10 +72,11 @@ function applyJump() {
// new jump
jumpStartHeight = playerPosition.y;
jumpSpeedCap = currentJumpSpeed;
jumpReleased = false;
}

if (jumpStartHeight !== undefined) {
if (jumpIsPressed && playerPosition.y + 1e-3 < jumpStartHeight + currentJumpHeight) {
if (jumpIsPressed && !jumpReleased && playerPosition.y + 1e-3 < jumpStartHeight + currentJumpHeight) {
// continuing jump
const jumpHeightRemaining = (jumpStartHeight + currentJumpHeight - playerPosition.y);
const requiredJumpTime = Math.sqrt(jumpHeightRemaining * 2 / JUMP_DECEL);
Expand All @@ -88,7 +103,6 @@ function snapToGround() {
if (
jumpStartHeight === undefined // not jumping
&& !stepping // not stepping
&& prevRequestedVelocity.y < 0 // not trying to move up (strictly < 0 so snap is skipped when requestedVelocity is zero, e.g. during engine-controlled movement)
&& prevGrounded // was grounded last frame
&& groundDistance < GROUND_SNAP_HEIGHT // close enough
) {
Expand Down