Add air direction only setting for player movement

This commit is contained in:
2026-04-12 02:18:53 +02:00
parent 6b825a7f28
commit 42cee3d19a
13 changed files with 301 additions and 28 deletions

View File

@@ -10924,6 +10924,29 @@ export function App({ store, initialStatusMessage }: AppProps) {
}
/>
</label>
<label className="form-field form-field--toggle">
<span className="label">Air Direction Only</span>
<input
data-testid="player-start-movement-air-direction-only-enabled"
type="checkbox"
checked={
playerStartMovementTemplateDraft.jump.directionOnly
}
onChange={(event) =>
commitPlayerStartMovementTemplateDraft(
{
jump: {
directionOnly:
event.currentTarget.checked
}
},
{
schedule: true
}
)
}
/>
</label>
<label className="form-field form-field--toggle">
<span className="label">Bunny Hopping</span>
<input

View File

@@ -92,6 +92,7 @@ import {
MULTI_SCENE_FOUNDATION_SCENE_DOCUMENT_VERSION,
MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION,
PLAYER_START_MOVEMENT_TEMPLATE_SCENE_DOCUMENT_VERSION,
PLAYER_START_AIR_CONTROL_SCENE_DOCUMENT_VERSION,
PLAYER_START_GAMEPAD_CAMERA_LOOK_SCENE_DOCUMENT_VERSION,
PLAYER_START_INPUT_BINDINGS_SCENE_DOCUMENT_VERSION,
PLAYER_START_NAVIGATION_MODE_SCENE_DOCUMENT_VERSION,
@@ -1369,6 +1370,11 @@ function readPlayerStartMovementTemplate(value: unknown, label: string) {
`${label}.jump.moveWhileFalling`,
preset.jump.moveWhileFalling
),
directionOnly: readOptionalBoolean(
jump?.directionOnly,
`${label}.jump.directionOnly`,
preset.jump.directionOnly
),
maxHoldMs:
jump?.maxHoldMs === undefined
? preset.jump.maxHoldMs
@@ -2866,6 +2872,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument {
if (
source.version !== SCENE_DOCUMENT_VERSION &&
source.version !== 33 &&
source.version !== PLAYER_START_AIR_CONTROL_SCENE_DOCUMENT_VERSION &&
source.version !== PLAYER_START_MOVEMENT_TEMPLATE_SCENE_DOCUMENT_VERSION &&
source.version !== PROJECT_NAME_SCENE_DOCUMENT_VERSION &&
source.version !== STATIC_SIMPLE_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION &&

View File

@@ -1370,6 +1370,17 @@ function validatePlayerStartEntity(
);
}
if (!isBoolean(entity.movementTemplate?.jump?.directionOnly)) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-player-start-air-direction-only",
"Player Start air direction only setting must be a boolean.",
`${path}.movementTemplate.jump.directionOnly`
)
);
}
if (!isBoolean(entity.movementTemplate?.jump?.bunnyHop)) {
diagnostics.push(
createDiagnostic(

View File

@@ -15,7 +15,9 @@ import {
type WorldSettings
} from "./world-settings";
export const SCENE_DOCUMENT_VERSION = 34 as const;
export const SCENE_DOCUMENT_VERSION = 35 as const;
export const PLAYER_START_AIR_DIRECTION_CONTROL_SCENE_DOCUMENT_VERSION =
35 as const;
export const PLAYER_START_AIR_CONTROL_SCENE_DOCUMENT_VERSION = 34 as const;
export const WHITEBOX_BEVEL_SCENE_DOCUMENT_VERSION = 32 as const;
export const PLAYER_START_LOCOMOTION_CORE_SCENE_DOCUMENT_VERSION = 32 as const;

View File

@@ -149,6 +149,7 @@ export interface PlayerStartJumpSettings {
maxHoldMs: number;
moveWhileJumping: boolean;
moveWhileFalling: boolean;
directionOnly: boolean;
bunnyHop: boolean;
bunnyHopBoost: number;
}
@@ -305,6 +306,7 @@ export const DEFAULT_PLAYER_START_VARIABLE_JUMP_HEIGHT = false;
export const DEFAULT_PLAYER_START_VARIABLE_JUMP_MAX_HOLD_MS = 160;
export const DEFAULT_PLAYER_START_MOVE_WHILE_JUMPING = true;
export const DEFAULT_PLAYER_START_MOVE_WHILE_FALLING = true;
export const DEFAULT_PLAYER_START_AIR_DIRECTION_ONLY = false;
export const DEFAULT_PLAYER_START_BUNNY_HOP = false;
export const DEFAULT_PLAYER_START_BUNNY_HOP_BOOST = 0.05;
export const DEFAULT_PLAYER_START_SPRINT_SPEED_MULTIPLIER = 1.65;
@@ -323,6 +325,7 @@ export const DEFAULT_PLAYER_START_JUMP_SETTINGS: PlayerStartJumpSettings = {
maxHoldMs: DEFAULT_PLAYER_START_VARIABLE_JUMP_MAX_HOLD_MS,
moveWhileJumping: DEFAULT_PLAYER_START_MOVE_WHILE_JUMPING,
moveWhileFalling: DEFAULT_PLAYER_START_MOVE_WHILE_FALLING,
directionOnly: DEFAULT_PLAYER_START_AIR_DIRECTION_ONLY,
bunnyHop: DEFAULT_PLAYER_START_BUNNY_HOP,
bunnyHopBoost: DEFAULT_PLAYER_START_BUNNY_HOP_BOOST
};
@@ -343,6 +346,7 @@ export const RESPONSIVE_PLAYER_START_JUMP_SETTINGS: PlayerStartJumpSettings = {
maxHoldMs: RESPONSIVE_PLAYER_START_VARIABLE_JUMP_MAX_HOLD_MS,
moveWhileJumping: DEFAULT_PLAYER_START_MOVE_WHILE_JUMPING,
moveWhileFalling: DEFAULT_PLAYER_START_MOVE_WHILE_FALLING,
directionOnly: DEFAULT_PLAYER_START_AIR_DIRECTION_ONLY,
bunnyHop: DEFAULT_PLAYER_START_BUNNY_HOP,
bunnyHopBoost: DEFAULT_PLAYER_START_BUNNY_HOP_BOOST
};
@@ -523,6 +527,7 @@ function clonePlayerStartJumpSettings(
maxHoldMs: settings.maxHoldMs,
moveWhileJumping: settings.moveWhileJumping,
moveWhileFalling: settings.moveWhileFalling,
directionOnly: settings.directionOnly,
bunnyHop: settings.bunnyHop,
bunnyHopBoost: settings.bunnyHopBoost
};
@@ -765,6 +770,8 @@ export function createPlayerStartMovementTemplate(
overrides.jump?.moveWhileJumping ?? preset.jump.moveWhileJumping,
moveWhileFalling:
overrides.jump?.moveWhileFalling ?? preset.jump.moveWhileFalling,
directionOnly:
overrides.jump?.directionOnly ?? preset.jump.directionOnly,
bunnyHop: overrides.jump?.bunnyHop ?? preset.jump.bunnyHop,
bunnyHopBoost:
overrides.jump?.bunnyHopBoost ?? preset.jump.bunnyHopBoost
@@ -821,6 +828,10 @@ export function createPlayerStartMovementTemplate(
jump.moveWhileFalling,
"Player Start move while falling setting"
);
assertBoolean(
jump.directionOnly,
"Player Start air direction only setting"
);
assertBoolean(jump.bunnyHop, "Player Start bunny hop setting");
assertNonNegativeFiniteNumber(
jump.bunnyHopBoost,
@@ -893,6 +904,8 @@ export function inferPlayerStartMovementTemplateKind(
createPlayerStartMovementTemplate({ kind: presetKind }).jump.moveWhileJumping &&
candidate.jump.moveWhileFalling ===
createPlayerStartMovementTemplate({ kind: presetKind }).jump.moveWhileFalling &&
candidate.jump.directionOnly ===
createPlayerStartMovementTemplate({ kind: presetKind }).jump.directionOnly &&
candidate.jump.bunnyHop ===
createPlayerStartMovementTemplate({ kind: presetKind }).jump.bunnyHop &&
candidate.jump.bunnyHopBoost ===
@@ -951,6 +964,7 @@ export function arePlayerStartMovementTemplatesEqual(
left.jump.maxHoldMs === right.jump.maxHoldMs &&
left.jump.moveWhileJumping === right.jump.moveWhileJumping &&
left.jump.moveWhileFalling === right.jump.moveWhileFalling &&
left.jump.directionOnly === right.jump.directionOnly &&
left.jump.bunnyHop === right.jump.bunnyHop &&
left.jump.bunnyHopBoost === right.jump.bunnyHopBoost &&
left.sprint.speedMultiplier === right.sprint.speedMultiplier &&

View File

@@ -65,6 +65,7 @@ function cloneRuntimePlayerMovement(
maxHoldMs: movement.jump.maxHoldMs,
moveWhileJumping: movement.jump.moveWhileJumping,
moveWhileFalling: movement.jump.moveWhileFalling,
directionOnly: movement.jump.directionOnly,
bunnyHop: movement.jump.bunnyHop,
bunnyHopBoost: movement.jump.bunnyHopBoost
},
@@ -313,6 +314,7 @@ export class FirstPersonNavigationController implements NavigationController {
dt,
feetPosition: this.feetPosition,
movementYawRadians: this.yawRadians,
airDirectionYawRadians: this.yawRadians,
standingShape: this.standingPlayerShape,
verticalVelocity: this.verticalVelocity,
previousLocomotionState: this.locomotionState,

View File

@@ -74,6 +74,7 @@ export interface StepPlayerLocomotionOptions {
dt: number;
feetPosition: Vec3;
movementYawRadians: number;
airDirectionYawRadians?: number;
standingShape: FirstPersonPlayerShape;
verticalVelocity: number;
previousLocomotionState?: RuntimeLocomotionState;
@@ -190,18 +191,50 @@ function computePlanarMotion(
requestedPlanarSpeed: number,
dt: number
): { motion: Vec3; inputMagnitude: number } {
const inputX = input.moveRight - input.moveLeft;
const inputZ = input.moveForward - input.moveBackward;
const rawMagnitude = Math.hypot(inputX, inputZ);
const inputMagnitude = clampUnitInterval(rawMagnitude);
const directionResult = computePlanarInputDirection(
movementYawRadians,
input
);
if (rawMagnitude <= 0 || requestedPlanarSpeed <= 0 || dt <= 0) {
if (
directionResult.direction === null ||
requestedPlanarSpeed <= 0 ||
dt <= 0
) {
return {
motion: {
x: 0,
y: 0,
z: 0
},
inputMagnitude: directionResult.inputMagnitude
};
}
const planarDistance = requestedPlanarSpeed * dt;
return {
motion: {
x: directionResult.direction.x * planarDistance,
y: 0,
z: directionResult.direction.z * planarDistance
},
inputMagnitude: directionResult.inputMagnitude
};
}
function computePlanarInputDirection(
movementYawRadians: number,
input: PlayerStartActionInputState
): { direction: Vec3 | null; inputMagnitude: number } {
const inputX = input.moveRight - input.moveLeft;
const inputZ = input.moveForward - input.moveBackward;
const rawMagnitude = Math.hypot(inputX, inputZ);
const inputMagnitude = clampUnitInterval(rawMagnitude);
if (rawMagnitude <= 0) {
return {
direction: null,
inputMagnitude
};
}
@@ -212,17 +245,24 @@ function computePlanarMotion(
const forwardZ = Math.cos(movementYawRadians);
const rightX = -Math.cos(movementYawRadians);
const rightZ = Math.sin(movementYawRadians);
const planarDistance = requestedPlanarSpeed * dt;
const directionX =
forwardX * normalizedInputZ + rightX * normalizedInputX;
const directionZ =
forwardZ * normalizedInputZ + rightZ * normalizedInputX;
const directionMagnitude = Math.hypot(directionX, directionZ);
if (directionMagnitude <= 0) {
return {
direction: null,
inputMagnitude
};
}
return {
motion: {
x:
(forwardX * normalizedInputZ + rightX * normalizedInputX) *
planarDistance,
direction: {
x: directionX / directionMagnitude,
y: 0,
z:
(forwardZ * normalizedInputZ + rightZ * normalizedInputX) *
planarDistance
z: directionZ / directionMagnitude
},
inputMagnitude
};
@@ -251,6 +291,48 @@ function clearPlanarMovementInput(
};
}
function computeDirectionalAirMotion(options: {
directionYawRadians: number;
input: PlayerStartActionInputState;
previousPlanarDisplacement: Vec3;
dt: number;
}): { motion: Vec3; inputMagnitude: number } {
const directionResult = computePlanarInputDirection(
options.directionYawRadians,
options.input
);
const planarSpeed = computePlanarSpeedFromDisplacement(
options.previousPlanarDisplacement,
options.dt
);
if (
directionResult.direction === null ||
planarSpeed <= 0 ||
options.dt <= 0
) {
return {
motion: {
x: 0,
y: 0,
z: 0
},
inputMagnitude: directionResult.inputMagnitude
};
}
const planarDistance = planarSpeed * options.dt;
return {
motion: {
x: directionResult.direction.x * planarDistance,
y: 0,
z: directionResult.direction.z * planarDistance
},
inputMagnitude: directionResult.inputMagnitude
};
}
function isWaterLocomotionMode(
locomotionMode: RuntimeLocomotionMode | null | undefined
): boolean {
@@ -502,6 +584,16 @@ export function stepPlayerLocomotion(
const planarInput = airMovementAllowed
? options.input
: clearPlanarMovementInput(options.input);
const directionalAirControlActive =
!currentlyGrounded &&
!jumpTriggered &&
!currentSwimmableWater &&
airMovementAllowed &&
options.movement.jump.directionOnly;
const previousPlanarSpeed = computePlanarSpeedFromDisplacement(
options.previousPlanarDisplacement,
options.dt
);
const requestedPlanarSpeed =
activeShape.mode !== "none" && !currentSwimmableWater
@@ -511,12 +603,20 @@ export function stepPlayerLocomotion(
? groundedRequestedPlanarSpeed
: airborneRequestedPlanarSpeed
: groundedRequestedPlanarSpeed;
const planarMotionFromInput = computePlanarMotion(
options.movementYawRadians,
planarInput,
requestedPlanarSpeed,
options.dt
);
const planarMotionFromInput = directionalAirControlActive
? computeDirectionalAirMotion({
directionYawRadians:
options.airDirectionYawRadians ?? options.movementYawRadians,
input: planarInput,
previousPlanarDisplacement: options.previousPlanarDisplacement,
dt: options.dt
})
: computePlanarMotion(
options.movementYawRadians,
planarInput,
requestedPlanarSpeed,
options.dt
);
const preserveAirborneMomentum =
activeShape.mode !== "none" &&
!currentSwimmableWater &&
@@ -710,11 +810,10 @@ export function stepPlayerLocomotion(
sprinting,
inputMagnitude: planarMotion.inputMagnitude,
requestedPlanarSpeed: preserveAirborneMomentum
? computePlanarSpeedFromDisplacement(
options.previousPlanarDisplacement,
options.dt
)
: requestedPlanarSpeed * planarMotion.inputMagnitude,
? previousPlanarSpeed
: (directionalAirControlActive
? previousPlanarSpeed
: requestedPlanarSpeed) * planarMotion.inputMagnitude,
planarSpeed: actualPlanarSpeed,
verticalVelocity,
contact: resolveContactState(resolvedMotion, groundProbe, grounded)

View File

@@ -287,6 +287,7 @@ function clonePlayerStartJumpSettings(
maxHoldMs: jump.maxHoldMs,
moveWhileJumping: jump.moveWhileJumping,
moveWhileFalling: jump.moveWhileFalling,
directionOnly: jump.directionOnly,
bunnyHop: jump.bunnyHop,
bunnyHopBoost: jump.bunnyHopBoost
};

View File

@@ -79,6 +79,7 @@ function cloneRuntimePlayerMovement(
maxHoldMs: movement.jump.maxHoldMs,
moveWhileJumping: movement.jump.moveWhileJumping,
moveWhileFalling: movement.jump.moveWhileFalling,
directionOnly: movement.jump.directionOnly,
bunnyHop: movement.jump.bunnyHop,
bunnyHopBoost: movement.jump.bunnyHopBoost
},
@@ -285,6 +286,7 @@ export class ThirdPersonNavigationController implements NavigationController {
dt,
feetPosition: this.feetPosition,
movementYawRadians: this.cameraYawRadians,
airDirectionYawRadians: this.yawRadians,
standingShape: this.standingPlayerShape,
verticalVelocity: this.verticalVelocity,
previousLocomotionState: this.locomotionState,

View File

@@ -132,7 +132,8 @@ describe("validateSceneDocument", () => {
variableHeight: "yes",
maxHoldMs: 0,
moveWhileJumping: "yes",
moveWhileFalling: 1
moveWhileFalling: 1,
directionOnly: "left"
},
sprint: {
speedMultiplier: 0
@@ -217,6 +218,9 @@ describe("validateSceneDocument", () => {
expect.objectContaining({
code: "invalid-player-start-move-while-falling"
}),
expect.objectContaining({
code: "invalid-player-start-air-direction-only"
}),
expect.objectContaining({
code: "invalid-player-start-sprint-speed-multiplier"
}),

View File

@@ -9,6 +9,7 @@ import {
IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION,
LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION,
MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION,
PLAYER_START_AIR_CONTROL_SCENE_DOCUMENT_VERSION,
PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION,
PLAYER_START_INPUT_BINDINGS_SCENE_DOCUMENT_VERSION,
PLAYER_START_MOVEMENT_TEMPLATE_SCENE_DOCUMENT_VERSION,
@@ -467,7 +468,8 @@ describe("scene document JSON", () => {
variableHeight: true,
maxHoldMs: 220,
moveWhileJumping: false,
moveWhileFalling: false
moveWhileFalling: false,
directionOnly: true
},
sprint: {
speedMultiplier: 1.8
@@ -951,6 +953,7 @@ describe("scene document JSON", () => {
const {
moveWhileJumping: _moveWhileJumping,
moveWhileFalling: _moveWhileFalling,
directionOnly: _directionOnly,
...legacyJump
} = playerStart.movementTemplate.jump;
const legacyDocument = {
@@ -975,6 +978,39 @@ describe("scene document JSON", () => {
expect(migratedDocument.entities[playerStart.id]).toEqual(playerStart);
});
it("migrates version 34 Player Start jump settings to include default air direction mode", () => {
const playerStart = createPlayerStartEntity({
id: "entity-player-start-legacy-air-direction",
movementTemplate: {
kind: "responsive"
}
});
const {
directionOnly: _directionOnly,
...legacyJump
} = playerStart.movementTemplate.jump;
const legacyDocument = {
...createEmptySceneDocument({
name: "Legacy Player Air Direction Scene"
}),
version: PLAYER_START_AIR_CONTROL_SCENE_DOCUMENT_VERSION,
entities: {
[playerStart.id]: {
...playerStart,
movementTemplate: {
...playerStart.movementTemplate,
jump: legacyJump
}
}
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[playerStart.id]).toEqual(playerStart);
});
it("round-trips authored third-person Player Start navigation", () => {
const playerStart = createPlayerStartEntity({
id: "entity-player-start-third-person",

View File

@@ -574,6 +574,70 @@ describe("player-locomotion", () => {
expect(step?.locomotionState.planarSpeed).toBeCloseTo(0);
});
it("reorients airborne movement using existing speed without adding more", () => {
const step = stepPlayerLocomotion({
dt: 0.1,
feetPosition: {
x: 0,
y: 1,
z: 0
},
movementYawRadians: 0,
airDirectionYawRadians: Math.PI / 2,
standingShape: FIRST_PERSON_PLAYER_SHAPE,
verticalVelocity: -2,
previousLocomotionState: createIdleRuntimeLocomotionState("airborne"),
previousPlanarDisplacement: {
x: 0,
y: 0,
z: 0.45
},
jumpBufferRemainingMs: 0,
coyoteTimeRemainingMs: 0,
jumpHoldRemainingMs: 0,
crouched: false,
wasJumpPressed: false,
input: FORWARD_INPUT,
movement: {
...DEFAULT_MOVEMENT,
jump: {
...DEFAULT_MOVEMENT.jump,
directionOnly: true
}
},
resolveMotion: (feetPosition, motion): ResolvedPlayerMotion => ({
feetPosition: {
x: feetPosition.x + motion.x,
y: feetPosition.y + motion.y,
z: feetPosition.z + motion.z
},
grounded: false,
collisionCount: 0,
groundCollisionNormal: null,
collidedAxes: {
x: false,
y: false,
z: false
}
}),
resolveVolumeState: () => createVolumeState(),
probeGround: () => ({
grounded: false,
distance: null,
normal: null,
slopeDegrees: null
}),
canOccupyShape: () => true
});
expect(step).not.toBeNull();
expect(step?.locomotionState.locomotionMode).toBe("airborne");
expect(step?.planarDisplacement.x).toBeCloseTo(0.45);
expect(step?.planarDisplacement.z).toBeCloseTo(0);
expect(step?.locomotionState.requestedPlanarSpeed).toBeCloseTo(4.5);
expect(step?.locomotionState.planarSpeed).toBeCloseTo(4.5);
});
it("sinks toward the water surface while keeping the head above water", () => {
const step = stepPlayerLocomotion({
dt: 0.1,

View File

@@ -179,6 +179,9 @@ describe("Player Start inspector", () => {
const variableJumpCheckbox = screen.getByTestId(
"player-start-movement-variable-jump-enabled"
);
const airDirectionOnlyCheckbox = screen.getByTestId(
"player-start-movement-air-direction-only-enabled"
);
const jumpBufferInput = screen.getByTestId(
"player-start-movement-jump-buffer"
);
@@ -217,6 +220,10 @@ describe("Player Start inspector", () => {
fireEvent.click(variableJumpCheckbox);
});
act(() => {
fireEvent.click(airDirectionOnlyCheckbox);
});
act(() => {
fireEvent.change(jumpBufferInput, {
target: {
@@ -233,7 +240,8 @@ describe("Player Start inspector", () => {
moveSpeed: 5.7,
jump: {
bufferMs: 75,
variableHeight: false
variableHeight: false,
directionOnly: true
}
}
});