feat: add playable actors and seamless journey state
This commit is contained in:
@@ -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