/** * 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 = 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; 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; /** 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; 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 { identity: ActorIdentity; } export interface TimedActorInputFrame { steps: number; actions?: Partial; } export interface ActorReplayResult { trajectory: readonly ActorControllerSnapshot[]; final: ActorControllerSnapshot; } interface ResolvedOptions { kind: ActorKind; identity: Readonly; 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 { 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 { 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 | 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 | 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 { 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 = 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 = 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() }; }