361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
/**
|
|
* 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,
|
|
dogPoseStateForSpeed,
|
|
disposeCrow,
|
|
disposeDog,
|
|
disposeHumanoid,
|
|
poseCrow,
|
|
poseDog,
|
|
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;
|
|
/** Attach a caller-owned live/static face texture. False when the current actor is not humanoid. */
|
|
attachFaceTexture(texture: THREE.Texture): boolean;
|
|
/** Detach and release the adapter's reference without disposing the caller texture. */
|
|
clearFaceTexture(): void;
|
|
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) }),
|
|
}),
|
|
};
|
|
}
|
|
// `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) }),
|
|
...(color(appearance?.hairColor) === undefined ? {} : { hairColor: color(appearance?.hairColor) }),
|
|
...(appearance?.bodyShape === undefined ? {} : { bodyShape: appearance.bodyShape }),
|
|
}),
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
const DEFAULT_FACE_COLOR = 0x18242b;
|
|
|
|
function faceMaterial(rig: HumanoidRig): THREE.MeshBasicMaterial {
|
|
const material = rig.face.material;
|
|
if (!(material instanceof THREE.MeshBasicMaterial)) {
|
|
throw new Error("scene actor: humanoid face must use one MeshBasicMaterial");
|
|
}
|
|
return material;
|
|
}
|
|
|
|
function applyFaceTexture(rig: HumanoidRig, texture: THREE.Texture | null): void {
|
|
const material = faceMaterial(rig);
|
|
material.map = texture;
|
|
material.color.set(texture ? 0xffffff : DEFAULT_FACE_COLOR);
|
|
material.needsUpdate = true;
|
|
}
|
|
|
|
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 faceTexture: THREE.Texture | null = null;
|
|
let disposed = false;
|
|
|
|
function sync(elapsedSeconds = 0, previousSpeedMps?: number): 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,
|
|
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 });
|
|
} else if (actor.kind === "dog") {
|
|
const dogState = dogPoseStateForSpeed(state.speedMps);
|
|
const acceleration = elapsedSeconds > 0 && previousSpeedMps !== undefined
|
|
? THREE.MathUtils.clamp((state.speedMps - previousSpeedMps) / elapsedSeconds / 5, -1, 1)
|
|
: 0;
|
|
poseDog(actor.rig, {
|
|
state: dogState,
|
|
phase: state.posePhase,
|
|
amount: dogState === "idle" ? 1 : THREE.MathUtils.clamp(0.38 + state.poseAmount * 0.62, 0, 1),
|
|
turn: desired.turn,
|
|
acceleration,
|
|
attentionYaw: -desired.turn * 0.08,
|
|
});
|
|
} else if (state.mode === "flight") {
|
|
// 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 });
|
|
}
|
|
}
|
|
|
|
function replaceActor(kind: ActorKind): void {
|
|
const previous = actor;
|
|
actor = buildActor(kind, controller.state().identity);
|
|
root.add(actor.rig.root);
|
|
if (actor.kind === "humanoid" && faceTexture) applyFaceTexture(actor.rig, faceTexture);
|
|
if (previous.kind === "humanoid") applyFaceTexture(previous.rig, null);
|
|
// A face is a humanoid-only, potentially sensitive live reference. Do not
|
|
// retain it invisibly while the player is represented as an animal.
|
|
if (actor.kind !== "humanoid") faceTexture = null;
|
|
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;
|
|
const previousSpeedMps = controller.state().speedMps;
|
|
controller.tick(elapsedSeconds, desired);
|
|
if (controller.state().kind !== beforeKind) replaceActor(controller.state().kind);
|
|
else sync(Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0, previousSpeedMps);
|
|
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();
|
|
},
|
|
attachFaceTexture(texture) {
|
|
if (disposed || actor.kind !== "humanoid") return false;
|
|
if (!texture || texture.isTexture !== true) {
|
|
throw new RangeError("scene actor: face texture must be a Three.js Texture");
|
|
}
|
|
faceTexture = texture;
|
|
applyFaceTexture(actor.rig, texture);
|
|
return true;
|
|
},
|
|
clearFaceTexture() {
|
|
faceTexture = null;
|
|
if (!disposed && actor.kind === "humanoid") applyFaceTexture(actor.rig, null);
|
|
},
|
|
dispose() {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
root.removeFromParent();
|
|
if (actor.kind === "humanoid") applyFaceTexture(actor.rig, null);
|
|
faceTexture = null;
|
|
disposeRig(actor);
|
|
},
|
|
};
|
|
}
|