1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/actors/controller.ts
T

821 lines
31 KiB
TypeScript

/**
* Renderer-independent playable actor simulation.
*
* DOM, touch and gamepad adapters reduce their input to `ActorActionSnapshot`.
* This state machine then advances on a deterministic fixed clock and publishes
* metre-scale transforms that any Three.js rig can consume. At yaw zero actors
* face -Z, matching the procedural assets under `assets/actors`.
*/
const TWO_PI = Math.PI * 2;
export type ActorKind = "humanoid" | "dog" | "crow";
export type ActorMode = "ground" | "flight";
export type ActorModeRequest = "none" | ActorMode;
export type ActorKindRequest = "none" | ActorKind;
export type CrowFlightPoseState = "flap" | "glide" | "bank" | "tuck" | "perch";
export interface CrowWind {
/** Positive X blows east/right in the actor's metre-space adapter. */
xMps: number;
/** Positive Z blows toward the actor's rear at yaw zero. */
zMps: number;
}
export interface CrowThermal {
x: number;
z: number;
radiusM: number;
/** Maximum deterministic updraft at the core. */
liftMps: number;
}
/** JSON-safe appearance fields. URLs are references; render resources stay outside simulation. */
export interface ActorProfile {
handle?: string;
pronouns?: string;
faceImageUrl?: string;
appearance?: {
skinTone?: string;
primaryColor?: string;
accentColor?: string;
hairColor?: string;
bodyShape?: "slim" | "average" | "broad";
};
}
/** Identity follows the player when their visible actor kind changes. */
export interface ActorIdentity {
id: string;
displayName: string;
authenticated: boolean;
profile: ActorProfile;
}
/** Device-neutral held inputs and one-shot requests. All axes normalize to [-1, 1]. */
export interface ActorActionSnapshot {
/** Ground forward/back; flight airspeed demand. */
forward: number;
/** Ground strafe. Ignored in flight. */
right: number;
/** Yaw left/right. */
turn: number;
/** Crow nose-up/nose-down request. */
pitch: number;
/** Crow vertical thrust independent of its nose. */
climb: number;
sprint: boolean;
glide: boolean;
modeRequest: ActorModeRequest;
kindRequest: ActorKindRequest;
reset: boolean;
}
export const NEUTRAL_ACTOR_ACTIONS: Readonly<ActorActionSnapshot> = Object.freeze({
forward: 0,
right: 0,
turn: 0,
pitch: 0,
climb: 0,
sprint: false,
glide: false,
modeRequest: "none",
kindRequest: "none",
reset: false,
});
export interface ActorPosition {
x: number;
y: number;
z: number;
}
export interface ActorHorizontalBounds {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}
export interface ActorControllerOptions {
kind: ActorKind;
identity: ActorIdentity;
position?: Partial<ActorPosition>;
yaw?: number;
/** Used only when spawning a crow in flight. */
pitch?: number;
mode?: ActorMode;
groundY?: number;
minFlightAltitude?: number;
maxFlightAltitude?: number;
walkSpeedMps?: number;
runSpeedMps?: number;
dogSpeedScale?: number;
groundTurnRateRadPerSecond?: number;
flightTurnRateRadPerSecond?: number;
maximumFlightPitchRad?: number;
minimumFlightSpeedMps?: number;
maximumFlightSpeedMps?: number;
glideSpeedMps?: number;
maximumClimbSpeedMps?: number;
/** Constant air-mass velocity. Defaults to still air. */
crowWind?: Partial<CrowWind>;
/** Bounded deterministic radial updrafts in actor metre space. */
crowThermals?: readonly CrowThermal[];
/** Normalized reserve used by powered flapping. Defaults to one. */
crowInitialEnergy?: number;
/** Reserve spent per second at a representative powered cruise. */
crowEnergyDrainPerSecond?: number;
/** Reserve recovered per second while gliding or perched. */
crowEnergyRecoveryPerSecond?: number;
/** Optional rectangle for a board or office adapter without collision geometry. */
horizontalBounds?: ActorHorizontalBounds;
fixedStepSeconds?: number;
/** Caps catch-up after a sleeping tab. */
maxFrameDeltaSeconds?: number;
}
export interface ActorControllerState extends ActorPosition {
kind: ActorKind;
mode: ActorMode;
identity: Readonly<ActorIdentity>;
yaw: number;
pitch: number;
speedMps: number;
verticalSpeedMps: number;
/** Renderer request for a cyclic walk/flap pose, wrapped to [0, 2π). */
posePhase: number;
/** Renderer request for pose intensity, in [0, 1]. */
poseAmount: number;
gliding: boolean;
/** Signed visual/turn bank in radians. */
roll: number;
/** Normalized powered-flight reserve in [0, 1]. */
flightEnergy: number;
/** Aerodynamic and thermal vertical contribution before pilot climb. */
liftMps: number;
thermalLiftMps: number;
windXMps: number;
windZMps: number;
crowPose: CrowFlightPoseState;
perched: boolean;
altitudeBoundContact: "none" | "minimum" | "maximum";
distanceM: number;
elapsedSteps: number;
}
export interface ActorControllerSnapshot extends Omit<ActorControllerState, "identity"> {
identity: ActorIdentity;
}
export interface TimedActorInputFrame {
steps: number;
actions?: Partial<ActorActionSnapshot>;
}
export interface ActorReplayResult {
trajectory: readonly ActorControllerSnapshot[];
final: ActorControllerSnapshot;
}
interface ResolvedOptions {
kind: ActorKind;
identity: Readonly<ActorIdentity>;
position: ActorPosition;
yaw: number;
pitch: number;
mode: ActorMode;
groundY: number;
minFlightAltitude: number;
maxFlightAltitude: number;
walkSpeedMps: number;
runSpeedMps: number;
dogSpeedScale: number;
groundTurnRateRadPerSecond: number;
flightTurnRateRadPerSecond: number;
maximumFlightPitchRad: number;
minimumFlightSpeedMps: number;
maximumFlightSpeedMps: number;
glideSpeedMps: number;
maximumClimbSpeedMps: number;
crowWind: CrowWind;
crowThermals: readonly CrowThermal[];
crowInitialEnergy: number;
crowEnergyDrainPerSecond: number;
crowEnergyRecoveryPerSecond: number;
horizontalBounds?: ActorHorizontalBounds;
fixedStepSeconds: number;
maxFrameDeltaSeconds: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function finiteOr(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function positive(value: number | undefined, fallback: number, name: string): number {
const result = finiteOr(value, fallback);
if (!(result > 0)) throw new RangeError(`${name} must be finite and positive`);
return result;
}
function wrapAngle(value: number): number {
return ((value + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI;
}
function wrapPhase(value: number): number {
return ((value % TWO_PI) + TWO_PI) % TWO_PI;
}
function moveToward(value: number, target: number, maximumDelta: number): number {
if (value < target) return Math.min(value + maximumDelta, target);
if (value > target) return Math.max(value - maximumDelta, target);
return value;
}
function validKind(value: unknown): value is ActorKind {
return value === "humanoid" || value === "dog" || value === "crow";
}
function copyProfile(profile: ActorProfile): ActorProfile {
const appearance = profile.appearance
? {
...(profile.appearance.skinTone === undefined ? {} : { skinTone: profile.appearance.skinTone }),
...(profile.appearance.primaryColor === undefined ? {} : { primaryColor: profile.appearance.primaryColor }),
...(profile.appearance.accentColor === undefined ? {} : { accentColor: profile.appearance.accentColor }),
...(profile.appearance.hairColor === undefined ? {} : { hairColor: profile.appearance.hairColor }),
...(profile.appearance.bodyShape === undefined ? {} : { bodyShape: profile.appearance.bodyShape }),
}
: undefined;
return {
...(profile.handle === undefined ? {} : { handle: profile.handle }),
...(profile.pronouns === undefined ? {} : { pronouns: profile.pronouns }),
...(profile.faceImageUrl === undefined ? {} : { faceImageUrl: profile.faceImageUrl }),
...(appearance === undefined ? {} : { appearance }),
};
}
function checkedIdentity(identity: ActorIdentity): Readonly<ActorIdentity> {
if (!identity || typeof identity.id !== "string" || identity.id.length === 0) {
throw new RangeError("actor identity must have a non-empty string id");
}
if (typeof identity.displayName !== "string" || typeof identity.authenticated !== "boolean") {
throw new RangeError("actor identity must contain a display name and authentication flag");
}
const profile = identity.profile ?? {};
const strings = [
profile.handle,
profile.pronouns,
profile.faceImageUrl,
profile.appearance?.skinTone,
profile.appearance?.primaryColor,
profile.appearance?.accentColor,
profile.appearance?.hairColor,
];
if (strings.some((item) => item !== undefined && typeof item !== "string")) {
throw new RangeError("actor profile fields must be strings");
}
if (
profile.appearance?.bodyShape !== undefined &&
profile.appearance.bodyShape !== "slim" &&
profile.appearance.bodyShape !== "average" &&
profile.appearance.bodyShape !== "broad"
) throw new RangeError("actor body shape is invalid");
const copy: ActorIdentity = {
id: identity.id,
displayName: identity.displayName,
authenticated: identity.authenticated,
profile: copyProfile(profile),
};
if (copy.profile.appearance) Object.freeze(copy.profile.appearance);
Object.freeze(copy.profile);
return Object.freeze(copy);
}
function copyIdentity(identity: Readonly<ActorIdentity>): ActorIdentity {
return {
id: identity.id,
displayName: identity.displayName,
authenticated: identity.authenticated,
profile: copyProfile(identity.profile),
};
}
function checkedBounds(bounds: ActorHorizontalBounds | undefined): ActorHorizontalBounds | undefined {
if (!bounds) return undefined;
if (
!Number.isFinite(bounds.minX) ||
!Number.isFinite(bounds.maxX) ||
!Number.isFinite(bounds.minZ) ||
!Number.isFinite(bounds.maxZ) ||
bounds.minX >= bounds.maxX ||
bounds.minZ >= bounds.maxZ
) {
throw new RangeError("actor horizontal bounds must be finite and ordered");
}
return { ...bounds };
}
function checkedCrowWind(value: Partial<CrowWind> | undefined): CrowWind {
const xMps = finiteOr(value?.xMps, 0);
const zMps = finiteOr(value?.zMps, 0);
// A malformed adapter cannot inject hurricane-scale displacement into an
// authoritative actor step. The generous cap still covers severe weather.
return { xMps: clamp(xMps, -60, 60), zMps: clamp(zMps, -60, 60) };
}
function checkedCrowThermals(value: readonly CrowThermal[] | undefined): readonly CrowThermal[] {
if (!value) return Object.freeze([]);
if (value.length > 32) throw new RangeError("crowThermals supports at most 32 updrafts");
const checked = value.map((thermal) => {
if (
!Number.isFinite(thermal?.x) || !Number.isFinite(thermal?.z) ||
!Number.isFinite(thermal?.radiusM) || thermal.radiusM <= 0 ||
!Number.isFinite(thermal?.liftMps) || thermal.liftMps < 0
) throw new RangeError("crow thermal fields must be finite with positive radius and non-negative lift");
return Object.freeze({
x: thermal.x,
z: thermal.z,
radiusM: clamp(thermal.radiusM, 0.5, 10_000),
liftMps: clamp(thermal.liftMps, 0, 20),
});
});
return Object.freeze(checked);
}
/** Clamp malformed adapter input before it can poison authoritative state. */
export function normalizeActorActions(
actions: Partial<ActorActionSnapshot> | undefined,
): ActorActionSnapshot {
let forward = clamp(finiteOr(actions?.forward, 0), -1, 1);
let right = clamp(finiteOr(actions?.right, 0), -1, 1);
const groundLength = Math.hypot(forward, right);
if (groundLength > 1) {
forward /= groundLength;
right /= groundLength;
}
const mode = actions?.modeRequest;
const kind = actions?.kindRequest;
return {
forward,
right,
turn: clamp(finiteOr(actions?.turn, 0), -1, 1),
pitch: clamp(finiteOr(actions?.pitch, 0), -1, 1),
climb: clamp(finiteOr(actions?.climb, 0), -1, 1),
sprint: actions?.sprint === true,
glide: actions?.glide === true,
modeRequest: mode === "ground" || mode === "flight" ? mode : "none",
kindRequest: validKind(kind) ? kind : "none",
reset: actions?.reset === true,
};
}
function resolveOptions(options: ActorControllerOptions): ResolvedOptions {
if (!validKind(options.kind)) throw new RangeError("unknown actor kind");
const groundY = finiteOr(options.groundY, 0);
const minFlightAltitude = positive(options.minFlightAltitude, 0.75, "minFlightAltitude");
const maxFlightAltitude = positive(options.maxFlightAltitude, 120, "maxFlightAltitude");
if (maxFlightAltitude <= minFlightAltitude) {
throw new RangeError("maxFlightAltitude must exceed minFlightAltitude");
}
const mode: ActorMode = options.kind === "crow" && options.mode === "flight" ? "flight" : "ground";
const position = {
x: finiteOr(options.position?.x, 0),
y: mode === "flight"
? clamp(finiteOr(options.position?.y, groundY + minFlightAltitude), groundY + minFlightAltitude, groundY + maxFlightAltitude)
: groundY,
z: finiteOr(options.position?.z, 0),
};
const maximumFlightSpeedMps = positive(options.maximumFlightSpeedMps, 16, "maximumFlightSpeedMps");
const minimumFlightSpeedMps = positive(options.minimumFlightSpeedMps, 4, "minimumFlightSpeedMps");
if (maximumFlightSpeedMps < minimumFlightSpeedMps) {
throw new RangeError("maximumFlightSpeedMps must not be below minimumFlightSpeedMps");
}
const bounds = checkedBounds(options.horizontalBounds);
if (bounds) {
position.x = clamp(position.x, bounds.minX, bounds.maxX);
position.z = clamp(position.z, bounds.minZ, bounds.maxZ);
}
return {
kind: options.kind,
identity: checkedIdentity(options.identity),
position,
yaw: wrapAngle(finiteOr(options.yaw, 0)),
pitch: mode === "flight" ? finiteOr(options.pitch, 0) : 0,
mode,
groundY,
minFlightAltitude,
maxFlightAltitude,
walkSpeedMps: positive(options.walkSpeedMps, 1.7, "walkSpeedMps"),
runSpeedMps: positive(options.runSpeedMps, 4.5, "runSpeedMps"),
dogSpeedScale: positive(options.dogSpeedScale, 1.25, "dogSpeedScale"),
groundTurnRateRadPerSecond: positive(options.groundTurnRateRadPerSecond, 2.8, "groundTurnRateRadPerSecond"),
flightTurnRateRadPerSecond: positive(options.flightTurnRateRadPerSecond, 1.75, "flightTurnRateRadPerSecond"),
maximumFlightPitchRad: clamp(positive(options.maximumFlightPitchRad, 0.72, "maximumFlightPitchRad"), 0.1, Math.PI / 2 - 0.05),
minimumFlightSpeedMps,
maximumFlightSpeedMps,
glideSpeedMps: clamp(positive(options.glideSpeedMps, 7.5, "glideSpeedMps"), minimumFlightSpeedMps, maximumFlightSpeedMps),
maximumClimbSpeedMps: positive(options.maximumClimbSpeedMps, 5, "maximumClimbSpeedMps"),
crowWind: checkedCrowWind(options.crowWind),
crowThermals: checkedCrowThermals(options.crowThermals),
crowInitialEnergy: clamp(finiteOr(options.crowInitialEnergy, 1), 0, 1),
crowEnergyDrainPerSecond: clamp(
positive(options.crowEnergyDrainPerSecond, 0.006, "crowEnergyDrainPerSecond"),
0.0001,
1,
),
crowEnergyRecoveryPerSecond: clamp(
positive(options.crowEnergyRecoveryPerSecond, 0.012, "crowEnergyRecoveryPerSecond"),
0.0001,
1,
),
horizontalBounds: bounds,
fixedStepSeconds: clamp(positive(options.fixedStepSeconds, 1 / 60, "fixedStepSeconds"), 1 / 240, 0.1),
maxFrameDeltaSeconds: clamp(positive(options.maxFrameDeltaSeconds, 0.25, "maxFrameDeltaSeconds"), 0.05, 1),
};
}
export class ActorController {
private readonly options: ResolvedOptions;
private readonly current: ActorControllerState;
private accumulator = 0;
constructor(options: ActorControllerOptions) {
this.options = resolveOptions(options);
this.options.pitch = clamp(this.options.pitch, -this.options.maximumFlightPitchRad, this.options.maximumFlightPitchRad);
this.current = {
kind: this.options.kind,
mode: this.options.mode,
identity: this.options.identity,
...this.options.position,
yaw: this.options.yaw,
pitch: this.options.pitch,
speedMps: 0,
verticalSpeedMps: 0,
posePhase: 0,
poseAmount: 0,
gliding: false,
roll: 0,
flightEnergy: this.options.crowInitialEnergy,
liftMps: 0,
thermalLiftMps: 0,
windXMps: this.options.crowWind.xMps,
windZMps: this.options.crowWind.zMps,
crowPose: this.options.mode === "flight" && this.options.kind === "crow" ? "glide" : "perch",
perched: this.options.kind === "crow" && this.options.mode === "ground",
altitudeBoundContact: "none",
distanceM: 0,
elapsedSteps: 0,
};
}
fixedStepSeconds(): number {
return this.options.fixedStepSeconds;
}
/** Stable allocation-free view. Treat nested identity as read-only and frozen. */
state(): Readonly<ActorControllerState> {
return this.current;
}
/** Detached JSON-safe state for persistence, networking and tests. */
snapshot(): ActorControllerSnapshot {
return { ...this.current, identity: copyIdentity(this.current.identity) };
}
/** Restore a trusted JSON snapshot for deterministic environment checkpointing. */
restore(snapshot: ActorControllerSnapshot): void {
const numeric = Object.entries(snapshot)
.filter(([, value]) => typeof value === "number")
.every(([, value]) => Number.isFinite(value));
if (
!numeric || !validKind(snapshot.kind) ||
(snapshot.mode !== "ground" && snapshot.mode !== "flight") ||
(snapshot.altitudeBoundContact !== "none" &&
snapshot.altitudeBoundContact !== "minimum" && snapshot.altitudeBoundContact !== "maximum") ||
!Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0
) throw new RangeError("actor snapshot is incompatible or invalid");
Object.assign(this.current, snapshot, { identity: checkedIdentity(snapshot.identity) });
this.applyHorizontalBounds();
if (this.current.mode === "flight" && this.current.kind === "crow") this.applyAltitudeBounds();
this.accumulator = 0;
}
/** Replace profile/identity without changing kind, pose or position. */
setIdentity(identity: ActorIdentity): void {
this.current.identity = checkedIdentity(identity);
}
/** Change visible actor while preserving identity and finite world position. */
setActorKind(kind: ActorKind): void {
if (!validKind(kind)) throw new RangeError("unknown actor kind");
if (kind === this.current.kind) return;
this.current.kind = kind;
this.current.speedMps = 0;
this.current.verticalSpeedMps = 0;
this.current.pitch = 0;
this.current.poseAmount = 0;
this.current.gliding = false;
this.current.roll = 0;
this.current.liftMps = 0;
this.current.thermalLiftMps = 0;
this.current.crowPose = kind === "crow" && this.current.mode === "ground" ? "perch" : "flap";
this.current.perched = kind === "crow" && this.current.mode === "ground";
this.current.altitudeBoundContact = "none";
if (kind !== "crow") this.land();
}
/** Request a locomotion mode without consuming a simulation step. Non-crows cannot fly. */
setMode(mode: ActorMode): void {
if (mode === "flight" && this.current.kind === "crow") this.takeOff();
else this.land();
}
/** Restore the original spawn, identity and kind and clear fractional time. */
reset(): void {
this.accumulator = 0;
Object.assign(this.current, this.options.position, {
kind: this.options.kind,
mode: this.options.mode,
identity: this.options.identity,
yaw: this.options.yaw,
pitch: this.options.pitch,
speedMps: 0,
verticalSpeedMps: 0,
posePhase: 0,
poseAmount: 0,
gliding: false,
roll: 0,
flightEnergy: this.options.crowInitialEnergy,
liftMps: 0,
thermalLiftMps: 0,
windXMps: this.options.crowWind.xMps,
windZMps: this.options.crowWind.zMps,
crowPose: this.options.mode === "flight" && this.options.kind === "crow" ? "glide" : "perch",
perched: this.options.kind === "crow" && this.options.mode === "ground",
altitudeBoundContact: "none",
distanceM: 0,
elapsedSteps: 0,
});
}
tick(deltaSeconds: number, actions: Partial<ActorActionSnapshot> = NEUTRAL_ACTOR_ACTIONS): number {
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
const normalized = normalizeActorActions(actions);
if (normalized.reset) {
this.reset();
return 0;
}
this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds);
let steps = 0;
while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) {
this.stepNormalized(normalized);
this.accumulator -= this.options.fixedStepSeconds;
steps += 1;
}
return steps;
}
stepFixed(actions: Partial<ActorActionSnapshot> = NEUTRAL_ACTOR_ACTIONS): void {
const normalized = normalizeActorActions(actions);
if (normalized.reset) {
this.reset();
return;
}
this.stepNormalized(normalized);
}
private stepNormalized(actions: ActorActionSnapshot): void {
if (actions.kindRequest !== "none") this.setActorKind(actions.kindRequest);
if (actions.modeRequest === "ground") this.land();
else if (actions.modeRequest === "flight" && this.current.kind === "crow") this.takeOff();
if (this.current.mode === "flight" && this.current.kind === "crow") this.stepFlight(actions);
else this.stepGround(actions);
this.current.elapsedSteps += 1;
}
private stepGround(actions: ActorActionSnapshot): void {
const dt = this.options.fixedStepSeconds;
this.current.yaw = wrapAngle(
this.current.yaw + actions.turn * this.options.groundTurnRateRadPerSecond * dt,
);
const input = Math.hypot(actions.forward, actions.right);
const kindScale = this.current.kind === "dog" ? this.options.dogSpeedScale : 1;
const maximum = (actions.sprint ? this.options.runSpeedMps : this.options.walkSpeedMps) * kindScale;
const speed = maximum * input;
const dx = (actions.right * Math.cos(this.current.yaw) - actions.forward * Math.sin(this.current.yaw)) * maximum * dt;
const dz = (-actions.right * Math.sin(this.current.yaw) - actions.forward * Math.cos(this.current.yaw)) * maximum * dt;
const beforeX = this.current.x;
const beforeZ = this.current.z;
this.current.x += dx;
this.current.z += dz;
this.applyHorizontalBounds();
const moved = Math.hypot(this.current.x - beforeX, this.current.z - beforeZ);
this.current.distanceM += moved;
this.current.speedMps = speed;
this.current.verticalSpeedMps = 0;
this.current.y = this.options.groundY;
this.current.pitch = 0;
this.current.gliding = false;
this.current.roll = moveToward(this.current.roll, 0, 7 * dt);
this.current.liftMps = 0;
this.current.thermalLiftMps = 0;
this.current.crowPose = this.current.kind === "crow" ? "perch" : "flap";
this.current.perched = this.current.kind === "crow";
this.current.flightEnergy = clamp(
this.current.flightEnergy + this.options.crowEnergyRecoveryPerSecond * 2.5 * dt,
0,
1,
);
this.current.altitudeBoundContact = "none";
this.current.poseAmount = input;
const strideLength = this.current.kind === "dog" ? 0.54 : 0.78;
this.current.posePhase = wrapPhase(this.current.posePhase + (moved / strideLength) * TWO_PI);
}
private stepFlight(actions: ActorActionSnapshot): void {
const dt = this.options.fixedStepSeconds;
const idleSoaring =
!actions.glide && Math.abs(actions.forward) < 0.02 && Math.abs(actions.turn) < 0.02 &&
Math.abs(actions.pitch) < 0.02 && Math.abs(actions.climb) < 0.02;
const speedFraction = clamp(
this.current.speedMps / Math.max(this.options.glideSpeedMps, 0.001),
0,
2,
);
const targetRoll = actions.turn * 0.62;
this.current.roll = moveToward(this.current.roll, targetRoll, 2.7 * dt);
this.current.yaw = wrapAngle(
this.current.yaw +
(
actions.turn * this.options.flightTurnRateRadPerSecond * (0.68 + speedFraction * 0.22) +
Math.sin(this.current.roll) * 0.34
) * dt,
);
const targetPitch = actions.pitch * this.options.maximumFlightPitchRad;
this.current.pitch = moveToward(this.current.pitch, targetPitch, 1.9 * dt);
const throttle = (actions.forward + 1) / 2;
const tucking = actions.glide && actions.forward < -0.6 && actions.pitch < -0.1;
const recovering = actions.glide;
if (recovering) {
this.current.flightEnergy = clamp(
this.current.flightEnergy + this.options.crowEnergyRecoveryPerSecond * dt,
0,
1,
);
} else {
const effort = 0.35 + throttle * 0.65 + Math.max(0, actions.climb) * 0.55;
this.current.flightEnergy = clamp(
this.current.flightEnergy - this.options.crowEnergyDrainPerSecond * effort * dt,
0,
1,
);
}
const energyAuthority = 0.52 + this.current.flightEnergy * 0.48;
const poweredTarget = this.options.minimumFlightSpeedMps +
(this.options.maximumFlightSpeedMps - this.options.minimumFlightSpeedMps) * throttle * energyAuthority;
const targetSpeed = tucking
? Math.min(this.options.maximumFlightSpeedMps, this.options.glideSpeedMps * 1.45)
: actions.glide ? this.options.glideSpeedMps : poweredTarget;
const response = actions.glide ? (tucking ? 3.1 : 2.2) : 5.5 * energyAuthority;
this.current.speedMps = moveToward(this.current.speedMps, targetSpeed, response * dt);
// Parasitic drag rises with the square of airspeed. It is small enough that
// a healthy powered bird holds its requested target but makes a tucked dive
// finite instead of a perpetual acceleration source.
const dragMps2 = 0.0045 * this.current.speedMps * this.current.speedMps;
this.current.speedMps = clamp(
this.current.speedMps - dragMps2 * dt * (actions.glide ? 0.7 : 0.25),
0,
this.options.maximumFlightSpeedMps,
);
let thermalLiftMps = 0;
for (const thermal of this.options.crowThermals) {
const normalizedDistance = Math.hypot(
this.current.x - thermal.x,
this.current.z - thermal.z,
) / thermal.radiusM;
if (normalizedDistance >= 1) continue;
const falloff = 1 - normalizedDistance;
thermalLiftMps += thermal.liftMps * falloff * falloff;
}
this.current.thermalLiftMps = clamp(thermalLiftMps, 0, this.options.maximumClimbSpeedMps * 1.5);
const liftRatio = this.current.speedMps / Math.max(this.options.glideSpeedMps, 0.001);
const wingLiftMps = (liftRatio * liftRatio - 0.92) * (actions.glide ? 0.72 : 0.38);
const flapLiftMps = actions.glide ? 0 : (0.18 + throttle * 0.46) * energyAuthority;
const tuckPenaltyMps = tucking ? 1.35 : 0;
this.current.liftMps = wingLiftMps + flapLiftMps + this.current.thermalLiftMps - tuckPenaltyMps;
const pitchLift = Math.sin(this.current.pitch) * this.current.speedMps;
const baselineSink = actions.glide ? (tucking ? 0.95 : 0.62) : 0.18;
const targetVertical =
pitchLift +
actions.climb * this.options.maximumClimbSpeedMps * energyAuthority +
this.current.liftMps -
baselineSink;
this.current.verticalSpeedMps = moveToward(
this.current.verticalSpeedMps,
clamp(
targetVertical,
-this.options.maximumClimbSpeedMps * 1.35,
this.options.maximumClimbSpeedMps + this.current.thermalLiftMps,
),
8 * dt,
);
const horizontalSpeed = this.current.speedMps * Math.cos(this.current.pitch);
const beforeX = this.current.x;
const beforeY = this.current.y;
const beforeZ = this.current.z;
this.current.x += (-Math.sin(this.current.yaw) * horizontalSpeed + this.options.crowWind.xMps) * dt;
this.current.z += (-Math.cos(this.current.yaw) * horizontalSpeed + this.options.crowWind.zMps) * dt;
this.current.y += this.current.verticalSpeedMps * dt;
this.applyHorizontalBounds();
this.applyAltitudeBounds();
this.current.distanceM += Math.hypot(
this.current.x - beforeX,
this.current.y - beforeY,
this.current.z - beforeZ,
);
this.current.gliding = actions.glide;
this.current.perched = false;
this.current.crowPose = tucking
? "tuck"
: actions.glide
? Math.abs(this.current.roll) > 0.16 ? "bank" : "glide"
: idleSoaring ? "glide" : "flap";
this.current.poseAmount = actions.glide || idleSoaring ? (tucking ? 0.42 : 0.72) : 0.55 + throttle * 0.45;
if (!actions.glide) {
// A tired bird loses cadence before it loses basic control authority.
const flapRate = 3.5 + throttle * (2.2 + energyAuthority * 1.8);
this.current.posePhase = wrapPhase(this.current.posePhase + flapRate * TWO_PI * dt);
}
}
private land(): void {
this.current.mode = "ground";
this.current.y = this.options.groundY;
this.current.pitch = 0;
this.current.roll = 0;
this.current.verticalSpeedMps = 0;
this.current.liftMps = 0;
this.current.thermalLiftMps = 0;
this.current.gliding = false;
this.current.crowPose = this.current.kind === "crow" ? "perch" : "flap";
this.current.perched = this.current.kind === "crow";
this.current.altitudeBoundContact = "none";
}
private takeOff(): void {
this.current.mode = "flight";
this.current.y = Math.max(this.current.y, this.options.groundY + this.options.minFlightAltitude);
this.current.crowPose = "flap";
this.current.perched = false;
this.current.altitudeBoundContact = "none";
}
private applyHorizontalBounds(): void {
const bounds = this.options.horizontalBounds;
if (!bounds) return;
this.current.x = clamp(this.current.x, bounds.minX, bounds.maxX);
this.current.z = clamp(this.current.z, bounds.minZ, bounds.maxZ);
}
private applyAltitudeBounds(): void {
const minimum = this.options.groundY + this.options.minFlightAltitude;
const maximum = this.options.groundY + this.options.maxFlightAltitude;
if (this.current.y <= minimum) {
this.current.y = minimum;
this.current.verticalSpeedMps = Math.max(0, this.current.verticalSpeedMps);
this.current.altitudeBoundContact = "minimum";
} else if (this.current.y >= maximum) {
this.current.y = maximum;
this.current.verticalSpeedMps = Math.min(0, this.current.verticalSpeedMps);
this.current.altitudeBoundContact = "maximum";
} else {
this.current.altitudeBoundContact = "none";
}
}
}
/** Execute an exact, allocation-friendly input recording for tests or playback. */
export function replayActorInputs(
options: ActorControllerOptions,
frames: readonly TimedActorInputFrame[],
): ActorReplayResult {
const controller = new ActorController(options);
const trajectory: ActorControllerSnapshot[] = [controller.snapshot()];
for (const frame of frames) {
const steps = Number.isSafeInteger(frame.steps) && frame.steps > 0 ? frame.steps : 0;
for (let index = 0; index < steps; index += 1) {
controller.stepFixed(frame.actions);
trajectory.push(controller.snapshot());
}
}
return { trajectory, final: controller.snapshot() };
}