/** * 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; 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> = { 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): 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); }, }; }