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,