feat: add playable actors and seamless journey state
This commit is contained in:
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/** 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;
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** 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;
|
||||
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;
|
||||
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 }),
|
||||
}
|
||||
: 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,
|
||||
];
|
||||
if (strings.some((item) => item !== undefined && typeof item !== "string")) {
|
||||
throw new RangeError("actor profile fields must be strings");
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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"),
|
||||
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,
|
||||
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) };
|
||||
}
|
||||
|
||||
/** 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.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,
|
||||
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.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;
|
||||
this.current.yaw = wrapAngle(
|
||||
this.current.yaw + actions.turn * this.options.flightTurnRateRadPerSecond * 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 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);
|
||||
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;
|
||||
this.current.verticalSpeedMps = moveToward(
|
||||
this.current.verticalSpeedMps,
|
||||
clamp(targetVertical, -this.options.maximumClimbSpeedMps, this.options.maximumClimbSpeedMps),
|
||||
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 * dt;
|
||||
this.current.z -= Math.cos(this.current.yaw) * horizontalSpeed * 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.poseAmount = actions.glide ? 0.22 : 0.55 + throttle * 0.45;
|
||||
if (!actions.glide) {
|
||||
const flapRate = 3.5 + throttle * 4;
|
||||
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.verticalSpeedMps = 0;
|
||||
this.current.gliding = false;
|
||||
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.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() };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export {
|
||||
ActorController,
|
||||
NEUTRAL_ACTOR_ACTIONS,
|
||||
normalizeActorActions,
|
||||
replayActorInputs,
|
||||
type ActorActionSnapshot,
|
||||
type ActorControllerOptions,
|
||||
type ActorControllerSnapshot,
|
||||
type ActorControllerState,
|
||||
type ActorHorizontalBounds,
|
||||
type ActorIdentity,
|
||||
type ActorKind,
|
||||
type ActorKindRequest,
|
||||
type ActorMode,
|
||||
type ActorModeRequest,
|
||||
type ActorPosition,
|
||||
type ActorProfile,
|
||||
type ActorReplayResult,
|
||||
type TimedActorInputFrame,
|
||||
} from "./controller.ts";
|
||||
|
||||
export {
|
||||
createSceneActor,
|
||||
type SceneActor,
|
||||
type SceneActorCameraOptions,
|
||||
type SceneActorOptions,
|
||||
type SceneActorView,
|
||||
} from "./sceneActor.ts";
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Three.js presentation adapter for a playable city/corridor actor.
|
||||
*
|
||||
* `ActorController` remains the sole owner of motion and identity. This module
|
||||
* only turns its metre-space state into a procedural rig, pose joints and a
|
||||
* third-person scene camera contract. A state-scale California board can set a
|
||||
* small `sceneUnitsPerMetre`; a metre-scale plaza leaves it at one.
|
||||
*/
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
buildCrow,
|
||||
buildDog,
|
||||
buildHumanoid,
|
||||
disposeCrow,
|
||||
disposeDog,
|
||||
disposeHumanoid,
|
||||
poseCrowFlight,
|
||||
poseDogAttention,
|
||||
poseDogWalk,
|
||||
poseHumanoid,
|
||||
type CrowRig,
|
||||
type DogRig,
|
||||
type HumanoidRig,
|
||||
} from "../assets/actors/index.ts";
|
||||
import type { Pose } from "../engine/scenekit.ts";
|
||||
import {
|
||||
ActorController,
|
||||
NEUTRAL_ACTOR_ACTIONS,
|
||||
normalizeActorActions,
|
||||
type ActorActionSnapshot,
|
||||
type ActorControllerOptions,
|
||||
type ActorControllerSnapshot,
|
||||
type ActorIdentity,
|
||||
type ActorKind,
|
||||
type ActorMode,
|
||||
} from "./controller.ts";
|
||||
|
||||
export interface SceneActorCameraOptions {
|
||||
/** All camera dimensions are actor-space metres and share the render scale. */
|
||||
distance?: number;
|
||||
height?: number;
|
||||
targetHeight?: number;
|
||||
lookAhead?: number;
|
||||
}
|
||||
|
||||
export interface SceneActorOptions extends ActorControllerOptions {
|
||||
/** Actor metres to scene units. Defaults to one. */
|
||||
sceneUnitsPerMetre?: number;
|
||||
/** Optional representational scale for the rig/camera on very coarse boards. */
|
||||
visualSceneUnitsPerMetre?: number;
|
||||
/** Scene-space anchor for the controller's local metre origin. */
|
||||
sceneOrigin?: { x: number; y: number; z: number };
|
||||
camera?: SceneActorCameraOptions;
|
||||
/** False by default so constructing a layer never steals orbit navigation. */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface SceneActorView {
|
||||
/** Stable live vector in scene coordinates for camera/light consumers. */
|
||||
position: THREE.Vector3;
|
||||
}
|
||||
|
||||
export interface SceneActor {
|
||||
/** Stable outer group; changing actor kind only replaces its rig child. */
|
||||
root: THREE.Group;
|
||||
view: SceneActorView;
|
||||
state(): ActorControllerSnapshot;
|
||||
actions(): ActorActionSnapshot;
|
||||
setActions(actions: Partial<ActorActionSnapshot>): ActorActionSnapshot;
|
||||
tick(elapsedSeconds: number): ActorControllerSnapshot;
|
||||
/** Defensive scene-space third-person pose. */
|
||||
followPose(): Pose;
|
||||
active(): boolean;
|
||||
setActive(active: boolean): void;
|
||||
/** Change rig and optional identity/mode without replacing the stable root. */
|
||||
switchActor(kind: ActorKind, identity?: ActorIdentity, mode?: ActorMode): ActorControllerSnapshot;
|
||||
/** Rebuild the visible skin for a new serializable identity, preserving motion and kind. */
|
||||
setIdentity(identity: ActorIdentity): ActorControllerSnapshot;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
type RiggedActor =
|
||||
| { kind: "humanoid"; rig: HumanoidRig }
|
||||
| { kind: "dog"; rig: DogRig }
|
||||
| { kind: "crow"; rig: CrowRig };
|
||||
|
||||
const CAMERA_BY_KIND: Record<ActorKind, Required<SceneActorCameraOptions>> = {
|
||||
humanoid: { distance: 3.5, height: 2.25, targetHeight: 1.25, lookAhead: 0.75 },
|
||||
dog: { distance: 2.8, height: 1.35, targetHeight: 0.48, lookAhead: 0.65 },
|
||||
crow: { distance: 4.2, height: 1.15, targetHeight: 0.18, lookAhead: 2.2 },
|
||||
};
|
||||
|
||||
function color(value: string | undefined): THREE.ColorRepresentation | undefined {
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildActor(kind: ActorKind, identity: Readonly<ActorIdentity>): RiggedActor {
|
||||
const appearance = identity.profile.appearance;
|
||||
if (kind === "dog") {
|
||||
return {
|
||||
kind,
|
||||
rig: buildDog({
|
||||
...(color(appearance?.primaryColor) === undefined ? {} : { coatColor: color(appearance?.primaryColor) }),
|
||||
...(color(appearance?.skinTone) === undefined ? {} : { markingsColor: color(appearance?.skinTone) }),
|
||||
...(color(appearance?.accentColor) === undefined ? {} : { collarColor: color(appearance?.accentColor) }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (kind === "crow") {
|
||||
return {
|
||||
kind,
|
||||
rig: buildCrow({
|
||||
...(color(appearance?.primaryColor) === undefined ? {} : { featherColor: color(appearance?.primaryColor) }),
|
||||
...(color(appearance?.accentColor) === undefined ? {} : { sheenColor: color(appearance?.accentColor) }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
// `faceImageUrl` intentionally remains data, not an implicit network fetch.
|
||||
// A realtime/video integration may attach its caller-owned texture to the
|
||||
// named `humanoid.face` mesh without changing simulation or this lifecycle.
|
||||
return {
|
||||
kind,
|
||||
rig: buildHumanoid({
|
||||
...(color(appearance?.skinTone) === undefined ? {} : { skinTone: color(appearance?.skinTone) }),
|
||||
...(color(appearance?.primaryColor) === undefined ? {} : { outfitColor: color(appearance?.primaryColor) }),
|
||||
...(color(appearance?.accentColor) === undefined ? {} : { accentColor: color(appearance?.accentColor) }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function disposeRig(actor: RiggedActor): void {
|
||||
actor.rig.root.removeFromParent();
|
||||
if (actor.kind === "humanoid") disposeHumanoid(actor.rig);
|
||||
else if (actor.kind === "dog") disposeDog(actor.rig);
|
||||
else disposeCrow(actor.rig);
|
||||
}
|
||||
|
||||
function positive(value: number, name: string): number {
|
||||
if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegative(value: number, name: string): number {
|
||||
if (value < 0 || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and non-negative`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function finiteOrigin(origin: SceneActorOptions["sceneOrigin"]): THREE.Vector3 {
|
||||
if (!origin) return new THREE.Vector3();
|
||||
if (![origin.x, origin.y, origin.z].every(Number.isFinite)) {
|
||||
throw new RangeError("sceneOrigin must contain finite coordinates");
|
||||
}
|
||||
return new THREE.Vector3(origin.x, origin.y, origin.z);
|
||||
}
|
||||
|
||||
export function createSceneActor(options: SceneActorOptions): SceneActor {
|
||||
const scale = positive(options.sceneUnitsPerMetre ?? 1, "sceneUnitsPerMetre");
|
||||
const visualScale = positive(options.visualSceneUnitsPerMetre ?? scale, "visualSceneUnitsPerMetre");
|
||||
const origin = finiteOrigin(options.sceneOrigin);
|
||||
const cameraOverrides = options.camera ?? {};
|
||||
for (const [name, value] of Object.entries(cameraOverrides)) {
|
||||
if (value === undefined) continue;
|
||||
if (name === "distance" || name === "height") positive(value, `camera.${name}`);
|
||||
else nonNegative(value, `camera.${name}`);
|
||||
}
|
||||
|
||||
const controller = new ActorController(options);
|
||||
const root = new THREE.Group();
|
||||
root.name = "playable-scene-actor";
|
||||
root.userData.kind = "playable-actor";
|
||||
root.userData.forwardAxis = "-Z";
|
||||
root.scale.setScalar(visualScale);
|
||||
const view = { position: new THREE.Vector3() };
|
||||
let actor = buildActor(controller.state().kind, controller.state().identity);
|
||||
root.add(actor.rig.root);
|
||||
let enabled = options.active ?? false;
|
||||
let desired: ActorActionSnapshot = { ...NEUTRAL_ACTOR_ACTIONS };
|
||||
let disposed = false;
|
||||
|
||||
function sync(): void {
|
||||
const state = controller.state();
|
||||
root.position.set(
|
||||
origin.x + state.x * scale,
|
||||
origin.y + state.y * scale,
|
||||
origin.z + state.z * scale,
|
||||
);
|
||||
root.rotation.order = "YXZ";
|
||||
root.rotation.set(state.mode === "flight" ? state.pitch : 0, state.yaw, 0);
|
||||
view.position.copy(root.position);
|
||||
if (actor.kind === "humanoid") {
|
||||
poseHumanoid(actor.rig, { walkPhase: state.posePhase, stride: state.poseAmount * 0.68 });
|
||||
} else if (actor.kind === "dog") {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function replaceActor(kind: ActorKind): void {
|
||||
const previous = actor;
|
||||
actor = buildActor(kind, controller.state().identity);
|
||||
root.add(actor.rig.root);
|
||||
disposeRig(previous);
|
||||
sync();
|
||||
}
|
||||
|
||||
function clearEdges(): void {
|
||||
desired.modeRequest = "none";
|
||||
desired.kindRequest = "none";
|
||||
desired.reset = false;
|
||||
}
|
||||
|
||||
sync();
|
||||
|
||||
return {
|
||||
root,
|
||||
view,
|
||||
state: () => controller.snapshot(),
|
||||
actions: () => ({ ...desired }),
|
||||
setActions(actions) {
|
||||
desired = normalizeActorActions(actions);
|
||||
return { ...desired };
|
||||
},
|
||||
tick(elapsedSeconds) {
|
||||
if (!disposed && enabled) {
|
||||
const beforeKind = controller.state().kind;
|
||||
controller.tick(elapsedSeconds, desired);
|
||||
if (controller.state().kind !== beforeKind) replaceActor(controller.state().kind);
|
||||
else sync();
|
||||
clearEdges();
|
||||
}
|
||||
return controller.snapshot();
|
||||
},
|
||||
followPose() {
|
||||
const state = controller.state();
|
||||
const defaults = CAMERA_BY_KIND[state.kind];
|
||||
const camera = {
|
||||
distance: cameraOverrides.distance ?? defaults.distance,
|
||||
height: cameraOverrides.height ?? defaults.height,
|
||||
targetHeight: cameraOverrides.targetHeight ?? defaults.targetHeight,
|
||||
lookAhead: cameraOverrides.lookAhead ?? defaults.lookAhead,
|
||||
};
|
||||
const forwardX = -Math.sin(state.yaw);
|
||||
const forwardZ = -Math.cos(state.yaw);
|
||||
const forwardY = state.mode === "flight" ? Math.sin(state.pitch) : 0;
|
||||
const atX = origin.x + state.x * scale;
|
||||
const atY = origin.y + state.y * scale;
|
||||
const atZ = origin.z + state.z * scale;
|
||||
return {
|
||||
position: new THREE.Vector3(
|
||||
atX - forwardX * camera.distance * visualScale,
|
||||
atY + camera.height * visualScale,
|
||||
atZ - forwardZ * camera.distance * visualScale,
|
||||
),
|
||||
target: new THREE.Vector3(
|
||||
atX + forwardX * camera.lookAhead * visualScale,
|
||||
atY + (camera.targetHeight + forwardY * camera.lookAhead) * visualScale,
|
||||
atZ + forwardZ * camera.lookAhead * visualScale,
|
||||
),
|
||||
};
|
||||
},
|
||||
active: () => enabled,
|
||||
setActive(active) {
|
||||
if (active !== enabled) desired = { ...NEUTRAL_ACTOR_ACTIONS };
|
||||
enabled = active;
|
||||
},
|
||||
switchActor(kind, identity, mode) {
|
||||
if (disposed) return controller.snapshot();
|
||||
if (identity) controller.setIdentity(identity);
|
||||
const changed = kind !== controller.state().kind || identity !== undefined;
|
||||
controller.setActorKind(kind);
|
||||
if (mode) controller.setMode(mode);
|
||||
desired = { ...NEUTRAL_ACTOR_ACTIONS };
|
||||
if (changed) replaceActor(kind);
|
||||
else sync();
|
||||
return controller.snapshot();
|
||||
},
|
||||
setIdentity(identity) {
|
||||
if (disposed) return controller.snapshot();
|
||||
controller.setIdentity(identity);
|
||||
replaceActor(controller.state().kind);
|
||||
return controller.snapshot();
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
root.removeFromParent();
|
||||
disposeRig(actor);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user