feat: build articulated crow flight v2
This commit is contained in:
+206
-11
@@ -13,6 +13,22 @@ 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 {
|
||||
@@ -102,6 +118,16 @@ export interface ActorControllerOptions {
|
||||
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;
|
||||
@@ -122,6 +148,17 @@ export interface ActorControllerState extends ActorPosition {
|
||||
/** 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;
|
||||
@@ -161,6 +198,11 @@ interface ResolvedOptions {
|
||||
maximumFlightSpeedMps: number;
|
||||
glideSpeedMps: number;
|
||||
maximumClimbSpeedMps: number;
|
||||
crowWind: CrowWind;
|
||||
crowThermals: readonly CrowThermal[];
|
||||
crowInitialEnergy: number;
|
||||
crowEnergyDrainPerSecond: number;
|
||||
crowEnergyRecoveryPerSecond: number;
|
||||
horizontalBounds?: ActorHorizontalBounds;
|
||||
fixedStepSeconds: number;
|
||||
maxFrameDeltaSeconds: number;
|
||||
@@ -277,6 +319,33 @@ function checkedBounds(bounds: ActorHorizontalBounds | undefined): ActorHorizont
|
||||
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,
|
||||
@@ -350,6 +419,19 @@ function resolveOptions(options: ActorControllerOptions): ResolvedOptions {
|
||||
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),
|
||||
@@ -376,6 +458,14 @@ export class ActorController {
|
||||
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,
|
||||
@@ -429,6 +519,11 @@ export class ActorController {
|
||||
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();
|
||||
}
|
||||
@@ -453,6 +548,14 @@ export class ActorController {
|
||||
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,
|
||||
@@ -518,6 +621,16 @@ export class ActorController {
|
||||
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;
|
||||
@@ -526,23 +639,91 @@ export class ActorController {
|
||||
|
||||
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 * dt,
|
||||
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;
|
||||
const targetSpeed = actions.glide ? this.options.glideSpeedMps : poweredTarget;
|
||||
this.current.speedMps = moveToward(this.current.speedMps, targetSpeed, (actions.glide ? 2.2 : 5.5) * dt);
|
||||
(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 glideSink = actions.glide ? 0.7 : 0.15;
|
||||
const targetVertical = pitchLift + actions.climb * this.options.maximumClimbSpeedMps - glideSink;
|
||||
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, this.options.maximumClimbSpeedMps),
|
||||
clamp(
|
||||
targetVertical,
|
||||
-this.options.maximumClimbSpeedMps * 1.35,
|
||||
this.options.maximumClimbSpeedMps + this.current.thermalLiftMps,
|
||||
),
|
||||
8 * dt,
|
||||
);
|
||||
|
||||
@@ -550,8 +731,8 @@ export class ActorController {
|
||||
const beforeX = this.current.x;
|
||||
const beforeY = this.current.y;
|
||||
const beforeZ = this.current.z;
|
||||
this.current.x -= Math.sin(this.current.yaw) * horizontalSpeed * dt;
|
||||
this.current.z -= Math.cos(this.current.yaw) * horizontalSpeed * dt;
|
||||
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();
|
||||
@@ -561,9 +742,16 @@ export class ActorController {
|
||||
this.current.z - beforeZ,
|
||||
);
|
||||
this.current.gliding = actions.glide;
|
||||
this.current.poseAmount = actions.glide ? 0.22 : 0.55 + throttle * 0.45;
|
||||
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) {
|
||||
const flapRate = 3.5 + throttle * 4;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -572,14 +760,21 @@ export class ActorController {
|
||||
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";
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ export {
|
||||
type ActorPosition,
|
||||
type ActorProfile,
|
||||
type ActorReplayResult,
|
||||
type CrowFlightPoseState,
|
||||
type CrowThermal,
|
||||
type CrowWind,
|
||||
type TimedActorInputFrame,
|
||||
} from "./controller.ts";
|
||||
|
||||
|
||||
+17
-19
@@ -14,7 +14,7 @@ import {
|
||||
disposeCrow,
|
||||
disposeDog,
|
||||
disposeHumanoid,
|
||||
poseCrowFlight,
|
||||
poseCrow,
|
||||
poseDogAttention,
|
||||
poseDogWalk,
|
||||
poseHumanoid,
|
||||
@@ -115,7 +115,6 @@ function buildActor(kind: ActorKind, identity: Readonly<ActorIdentity>): RiggedA
|
||||
kind,
|
||||
rig: buildCrow({
|
||||
...(color(appearance?.primaryColor) === undefined ? {} : { featherColor: color(appearance?.primaryColor) }),
|
||||
...(color(appearance?.accentColor) === undefined ? {} : { sheenColor: color(appearance?.accentColor) }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -209,7 +208,11 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
|
||||
origin.z + state.z * scale,
|
||||
);
|
||||
root.rotation.order = "YXZ";
|
||||
root.rotation.set(state.mode === "flight" ? state.pitch : 0, state.yaw, 0);
|
||||
root.rotation.set(
|
||||
state.mode === "flight" ? state.pitch : 0,
|
||||
state.yaw,
|
||||
state.mode === "flight" && state.kind === "crow" ? -state.roll * 0.42 : 0,
|
||||
);
|
||||
view.position.copy(root.position);
|
||||
if (actor.kind === "humanoid") {
|
||||
poseHumanoid(actor.rig, { walkPhase: state.posePhase, stride: state.poseAmount * 0.68 });
|
||||
@@ -217,22 +220,17 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
|
||||
poseDogWalk(actor.rig, state.posePhase, state.poseAmount * 0.68);
|
||||
poseDogAttention(actor.rig, 0, state.posePhase * 0.7);
|
||||
} else if (state.mode === "flight") {
|
||||
// A possessed crow is airborne even before the first key arrives. Keep a
|
||||
// readable soaring silhouette at neutral input; the controller still
|
||||
// owns phase/intensity as soon as movement begins.
|
||||
const idleSoar = enabled && state.poseAmount < 1e-4;
|
||||
poseCrowFlight(
|
||||
actor.rig,
|
||||
idleSoar ? -Math.PI / 2 : state.posePhase,
|
||||
Math.max(state.poseAmount, idleSoar ? 0.58 : 0),
|
||||
);
|
||||
if (idleSoar) {
|
||||
// The authored wing sheet already extends along local X. A neutral
|
||||
// chase view needs that broad plan silhouette, not the edge-on middle
|
||||
// of a flap cycle.
|
||||
actor.rig.joints.wingLeft.rotation.set(-0.08, 0, 0.08);
|
||||
actor.rig.joints.wingRight.rotation.set(-0.08, 0, -0.08);
|
||||
}
|
||||
// The controller names the aerodynamic state; the rig owns how that
|
||||
// state bends shoulder, elbow, wrist, tail and feet.
|
||||
poseCrow(actor.rig, {
|
||||
state: state.crowPose,
|
||||
phase: state.posePhase,
|
||||
amount: Math.max(state.poseAmount, enabled ? 0.68 : 0.5),
|
||||
bank: THREE.MathUtils.clamp(state.roll / 0.62, -1, 1),
|
||||
flight: 1,
|
||||
});
|
||||
} else {
|
||||
poseCrow(actor.rig, { state: "perch", amount: 1, flight: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user