import { UI_THEME as T } from "../../shared/ui-theme.mjs"; import { facingViewer } from "../../shared/cast-layout.mjs"; import * as THREE from "three"; import { actorMotionId, createPerformance, parseDirectorCommand } from "../../shared/actors.mjs"; import { actorMotionPresets, nextActorMotionPreset, STYLE_LABELS, changeCastStyle } from "../../shared/actor-presets.mjs"; import { createActorLayer } from "./actor-view.mjs"; import { CAST_SIZE as MAX_ACTORS, nextCastSlot } from "../../shared/actor-slots.mjs"; function createDirector({ authoring = false, world, assets, getState, getPhase, spatialKey, viewPosition, pickGround, occlusionDistance = () => Infinity, api, acceptState, notify, canEdit, canPlay = () => true, deferNewMotion = () => false, onSelect = () => { }, recordEvent = () => { }, mapFrames = (f) => f, pathCompleted = () => null }) { const performance = createPerformance(), layer = createActorLayer(world, assets); const cursor = new THREE.Mesh(new THREE.RingGeometry(0.18, 0.25, 32), new THREE.MeshBasicMaterial({ toneMapped: false, color: T.accent, side: THREE.DoubleSide, depthTest: false })); cursor.rotation.x = -Math.PI / 2; cursor.visible = false; cursor.renderOrder = 15; world.add(cursor); const facingArrow = new THREE.ArrowHelper(new THREE.Vector3(0, 0, 1), new THREE.Vector3(), 0.65, T.accent, 0.18, 0.12); facingArrow.line.material.toneMapped = facingArrow.cone.material.toneMapped = false; facingArrow.visible = false; facingArrow.line.material.depthTest = facingArrow.cone.material.depthTest = false; world.add(facingArrow); let lockedTarget = null; let selected = null, placing = false, placementDelay = 0, target = null, pending = false, frames = [], message = "Enter the room, then point at the floor to place an actor."; const emit = (text, status = true) => { message = text; notify(text, { status }); }; const selectedActor = () => getState()?.scene.actors?.find((a) => a.id === selected); function requireEdit() { if (getPhase() !== "explore") throw new Error("Enter the room first"); if (pending || !canEdit()) throw new Error("Finish the current request or preview first"); } function requirePlacement() { requireEdit(); if ((getState().scene.actors || []).length >= MAX_ACTORS) throw new Error(`${MAX_ACTORS} actors already placed. Remove one before adding another.`); } function validTarget(anchor) { if (!anchor || anchor.spatialKey !== spatialKey()) throw new Error("Point at the floor. Select a new location after alignment changes."); return anchor; } async function persist(command2) { requireEdit(); pending = true; try { const next = await api("/api/actors", { revision: getState().revision, command: command2 }); acceptState(next); recordEvent("actor-edit", { command: command2, revision: next.revision }); return next; } finally { pending = false; } } async function create(anchor, delay = 0) { requirePlacement(); const point = validTarget(anchor), actors = getState().scene.actors || [], preset = nextActorMotionPreset(actors, assets, getState().scene.actorStyle || "zombie"), asset = authoring && [...assets.values()].find((a) => a.defaultActor) || preset || [...assets.values()].find((a) => a.preferred) || assets.values().next().value; if (!asset) throw new Error("The default character asset has not loaded yet"); const delta = new THREE.Vector3().fromArray(point.viewer).sub(new THREE.Vector3().fromArray(point.position)); const actor = { id: "actor-" + crypto.randomUUID(), castSlot: nextCastSlot(actors), assetId: asset.id, motionId: deferNewMotion() || asset.bodyOnly ? null : preset?.id || null, name: `Actor ${nextCastSlot(actors)}`, position: [...point.position], yaw: Math.atan2(delta.x, delta.z), color: "#e7e9e5", trigger: "start", delay }; await persist({ type: "create", actor }); selected = actor.id; onSelect(selected); placing = false; cursor.visible = facingArrow.visible = false; emit(authoring ? "Mannequin placed. Select it and describe a basic motion; no motion import is needed." : actor.motionId ? `T-Pose actor placed · ${preset.name} assigned. Use the left grip to play or select another motion.` : "Mannequin placed. Select it to request a generated motion."); return message; } function select(id) { selected = id; onSelect(id); const actor = selectedActor(); if (actor) emit(authoring ? `${actor.name} selected. Describe a basic motion to the Agent, or draw a curve to guide this actor.` : `${actor.name} selected: ${assets.get(actorMotionId(actor))?.name || "No motion assigned"}. Choose a motion, move the actor, ask it to face you, or adjust its entrance delay. A style request changes the whole cast.`); } function updateRay(raycaster) { const point = pickGround(raycaster); target = lockedTarget ? structuredClone(lockedTarget) : point ? { position: point.toArray(), viewer: viewPosition().toArray(), spatialKey: spatialKey() } : null; cursor.visible = !!target && (authoring ? placing : placing || !!lockedTarget || deferNewMotion() && !getState().scene.actors?.length && canEdit()); facingArrow.visible = cursor.visible; if (target) { cursor.position.fromArray(target.position).add(new THREE.Vector3(0, 0.015, 0)); try { const yaw = facingViewer(target.position, target.viewer); facingArrow.position.copy(cursor.position); facingArrow.setDirection(new THREE.Vector3(Math.sin(yaw), 0, Math.cos(yaw))); } catch { cursor.visible = facingArrow.visible = false; } } } function beginPlacement(delay = 0) { requirePlacement(); placing = true; placementDelay = delay; target = null; cursor.visible = facingArrow.visible = false; emit(authoring ? "Point at the ground to place the actor · A Create · B Cancel" : "Point at the floor and press the right trigger to place an actor."); return message; } async function confirmPlacement(raycaster) { if (!placing) return false; updateRay(raycaster); if (!target) throw Error("Point at visible ground before confirming placement"); return create(structuredClone(target), placementDelay); } function interceptRay(raycaster) { if (getPhase() !== "explore") return false; if (placing) { if (authoring) { updateRay(raycaster); emit("A Create actor · B Cancel", false); } else confirmPlacement(raycaster).catch((e) => emit(e.message, false)); return true; } const hit = layer.pick(raycaster); if (hit && hit.distance <= occlusionDistance(raycaster)) { select(hit.id); return true; } return false; } function playable(actorIds) { requireEdit(); if (!canPlay()) throw new Error("Wait until ready, then say Preview or select the Preview button."); const all = getState().scene.actors || [], actors = actorIds ? all.filter((a) => actorIds.includes(a.id)) : all; if (actorIds && actors.length !== new Set(actorIds).size) throw new Error("The door-triggered cast has changed"); if (!actors.length) throw new Error("Place actors first"); if (!actors.some((a) => a.motionPlan || actorMotionId(a))) throw new Error("Select an actor and assign a motion first"); if (actors.some((a) => !assets.has(a.assetId) || actorMotionId(a) && !assets.has(actorMotionId(a)))) throw new Error("Actor motions are not loaded. Playback is unavailable."); return actors; } function start({ actorIds } = {}) { playable(actorIds); placing = false; cursor.visible = false; performance.start(actorIds); recordEvent("performance-start", { ...performance.snapshot(), ...actorIds ? { actorIds: [...actorIds] } : {} }); emit("Playing · Left grip or Stop to pause · Unassigned actors remain in T-Pose"); } function arm(actorIds, { cue = "door" } = {}) { playable(actorIds); placing = false; cursor.visible = false; performance.arm(actorIds); recordEvent("performance-armed", { ...performance.snapshot(), actorIds: [...actorIds] }); emit(cue === "preview" ? "Actors are ready at the first frame. Press the left grip to preview." : "Actors are ready at the first frame. Open the door to start them together."); } function stop() { performance.stop(); recordEvent("performance-stop", performance.snapshot()); emit("Motion stopped. You can still move, speak, and edit."); } function resume() { requireEdit(); if (!canPlay()) throw new Error("Confirm Preview first."); performance.resume(); recordEvent("performance-resume", performance.snapshot()); emit("Playing · Left grip to pause"); } function toggleTransport() { const s = performance.snapshot(), done = pathCompleted(s) ?? performance.completed(assets); if (s.mode === "running" && !done) { stop(); return; } if (placing) throw new Error(authoring ? "Press A to place the actor or B to cancel placement" : "Place with the right trigger, or press A to cancel"); if (s.mode === "paused" && !done) resume(); else start(); } function edit() { performance.edit(); recordEvent("performance-edit", performance.snapshot()); emit("Back to T-Pose layout. Assigned motions are kept and restart on the next playback."); } async function change(changes, id = selected) { const actor = getState().scene.actors?.find((a) => a.id === id); if (!actor) throw new Error("Select an actor first"); await persist({ type: "update", id: actor.id, changes }); } async function assignMotion(motionId, id = selected) { const actor = getState().scene.actors?.find((a) => a.id === id); if (!actor) throw new Error("Select an actor first"); const asset = motionId && assets.get(motionId); if (motionId && !asset) throw new Error("This motion has not been imported"); if (asset?.bodyOnly) throw new Error("This is the default character asset. Describe a basic motion to the Agent."); await change({ motionId }, id); const nextRun = performance.snapshot().mode !== "editing"; emit((asset ? `Assigned “${asset.name}” to ${actor.name}.` : `Cleared the motion for ${actor.name}.`) + (nextRun ? "Takes effect on the next playback. Current timing is unchanged." : asset ? "Holding T-Pose. Use the left grip to play." : "Holding T-Pose.")); return message; } function assignPreferredMotion(id = selected) { const motions = [...assets.values()].filter((a) => !a.bodyOnly), preferred = motions.find((a) => a.preferred) || motions[0]; if (!preferred) throw new Error("No motion assets imported"); return assignMotion(preferred.id, id); } function nextMotion(id = selected) { const actor = getState().scene.actors?.find((a) => a.id === id); if (!actor) throw new Error("Select an actor first"); const style = actorMotionPresets(assets).find((p) => p.id === actorMotionId(actor))?.style || getState().scene.actorStyle || "zombie", presets = actorMotionPresets(assets, style); if (!presets.length) return assignPreferredMotion(id); const index = presets.findIndex((p) => p.id === actorMotionId(actor)); return assignMotion(presets[(index + 1) % presets.length].id, id); } async function setStyle(style, { playback = "play", context = {} } = {}) { requireEdit(); if (playback === "play" && !canPlay()) throw new Error("Confirm Preview or prepare the motions again."); const initial = getState(), space = spatialKey(); if (context.revision !== void 0 && context.revision !== initial.revision || context.spatialContext?.spatialKey && context.spatialContext.spatialKey !== space) throw new Error("Scene or alignment changed since you spoke. Submit the style request again."); changeCastStyle(initial.scene, style, [...assets.keys()]); const previous = performance.snapshot(), offsets = {}; for (const actor of initial.scene.actors) { const view = layer.views.get(actor.id); if (view?.visible && previous.mode !== "editing") { const root = view.worldToLocal(view.bones[0].getWorldPosition(new THREE.Vector3())); offsets[actor.id] = [root.x, 0, root.z]; } } performance.stop(); pending = true; try { const next = await api("/api/actor-style", { revision: initial.revision, style }); acceptState(next); performance.sync(next.scene.actors); placing = false; cursor.visible = false; if (spatialKey() !== space || getPhase() !== "explore") { performance.edit(); emit("Motion style saved. Alignment changed; return to creating before playing."); return message; } if (previous.mode === "running") performance.resume(); performance.switchMotions(offsets, playback); recordEvent("actor-style-switch", { style, playback, offsets, ...performance.snapshot(), actors: next.scene.actors.map((a) => ({ id: a.id, slot: a.castSlot, motionId: actorMotionId(a) })) }); emit(`Changed ${next.scene.actors.length} actors to ${STYLE_LABELS[style]} motions. ${playback === "assign" ? "Takes effect on the next playback." : playback === "preserve" && previous.mode !== "running" ? "Paused / layout state retained. Left grip to play." : "Playing from the start. Left grip to pause."}`); return message; } catch (error) { if (previous.mode === "running" && getPhase() === "explore" && spatialKey() === space) performance.resume(); throw error; } finally { pending = false; } } async function command(text, context = {}) { const intent = parseDirectorCommand(text); if (!intent) return null; if (intent.type === "style") return setStyle(intent.style, { context }); if (intent.type === "create") return authoring ? beginPlacement(intent.delay) : create(context.actorTarget, intent.delay); if (intent.type === "start") { start(); return message; } if (intent.type === "stop") { stop(); return message; } if (intent.type === "resume") { resume(); return message; } if (intent.type === "edit") { edit(); return message; } if (intent.type === "unavailable") return "Choose an available motion from the asset list. Additional imported motions are required for unsupported actions."; if (intent.type === "motion") { if (!context.actorId) throw new Error("Select an actor first"); if (intent.number) { const preset = actorMotionPresets(assets, getState().scene.actorStyle || "zombie").find((p) => p.number === intent.number); if (!preset) throw new Error(`Motion ${intent.number} has not loaded`); return assignMotion(preset.id, context.actorId); } return assignPreferredMotion(context.actorId); } if (intent.type === "clearMotion") return assignMotion(null, context.actorId); if (intent.type === "save") { await api("/api/save-scene", { revision: getState().revision }).then(acceptState); emit("Scene, actor positions, and entry timing saved."); return message; } if (intent.type === "delay") { await change({ delay: intent.delay }, context.actorId); emit(`Will appear ${intent.delay} seconds after the next playback starts.`); return message; } if (intent.type === "move") { await change({ position: [...validTarget(context.actorTarget).position] }, context.actorId); emit("Actor moved to the selected point. Motion timing is unchanged."); return message; } if (intent.type === "face") { const actor = getState().scene.actors?.find((a) => a.id === context.actorId); if (!actor) throw new Error("Select an actor first"); const p = context.actorViewer || viewPosition().toArray(); await change({ yaw: Math.atan2(p[0] - actor.position[0], p[2] - actor.position[2]) }, actor.id); emit("Actor now faces your current position."); return message; } if (intent.type === "remove") { if (!context.actorId) throw new Error("Select an actor first"); const name = getState().scene.actors?.find((a) => a.id === context.actorId)?.name || "Actor"; await persist({ type: "remove", id: context.actorId }); selected = null; emit(`${name} removed and saved · Left stick click to undo`); return message; } return null; } return { lockPlacement(anchor) { validTarget(anchor); lockedTarget = structuredClone(anchor); }, unlockPlacement() { lockedTarget = null; cursor.visible = facingArrow.visible = false; }, command, setStyle, start, arm, stop, resume, toggleTransport, edit, select, change, assignMotion, assignPreferredMotion, nextMotion, updateRay, interceptRay, beginPlacement, confirmPlacement, cancelPlacement() { placing = false; target = null; cursor.visible = facingArrow.visible = false; }, togglePlacement() { if (placing) { placing = false; target = null; cursor.visible = facingArrow.visible = false; emit("Placement closed. Click the right stick to toggle editing."); } else beginPlacement(); }, clearTarget() { target = null; cursor.visible = facingArrow.visible = false; }, captureContext() { return { actorTarget: target ? structuredClone(target) : null, actorViewer: viewPosition().toArray(), actorId: selected }; }, previewGenerated(id) { performance.start([id]); }, freeze(ids) { performance.arm(ids); }, sync(scene) { performance.sync(scene.actors || []); if (selected && !scene.actors?.some((a) => a.id === selected)) selected = null; }, frame(dt, opacity, { showSelection = true, selectedIds = null, overrides = [] } = {}) { performance.advance(dt); const changes = new Map(overrides.map((o) => [o.id, o])); frames = mapFrames(performance.frames(assets), performance.snapshot()).map((f) => changes.has(f.id) ? { ...f, ...changes.get(f.id) } : f); layer.apply(frames, { opacity, selected: showSelection ? selectedIds || selected : null }); return frames; }, snapshot() { return { placementPreview: { visible: cursor.visible, arrowVisible: facingArrow.visible, locked: !!lockedTarget, target: target ? structuredClone(target) : null }, style: getState()?.scene.actorStyle || "zombie", ...performance.snapshot(), completed: pathCompleted(performance.snapshot()) ?? performance.completed(assets), selected, placing, pending, message, actors: frames.map((f) => ({ id: f.id, assetId: f.assetId, motionId: f.motionId, pose: f.pose, castSlot: f.castSlot, motionOffset: f.motionOffset, position: f.position, yaw: f.yaw, delay: f.delay, clipTime: f.clipTime, visible: f.visible, preview: f.preview })), assetCount: assets.size }; }, frames: () => frames, layer }; } export { createDirector };