1
0

feat: add playable flight and office screen sharing

This commit is contained in:
2026-08-11 19:46:47 -07:00
parent c0c7fcf974
commit 16dc85a6f8
13 changed files with 1355 additions and 45 deletions
+68 -9
View File
@@ -2,9 +2,10 @@
* The office-facing half of walk mode: a numeric `WalkerController` wearing an
* original actor rig and publishing a third-person camera pose.
*
* There is deliberately no identity, webcam, keyboard or network code here.
* There is deliberately no identity, webcam capture, keyboard or network code here.
* Callers choose humanoid or anonymous dog, translate their own input device to
* a normalized planar action, and decide when walk mode is active.
* a normalized planar action, and decide when walk mode is active. A caller may
* attach an already-created face texture; ownership stays with that caller.
*/
import * as THREE from "three";
@@ -88,6 +89,12 @@ export interface OfficeWalker {
setAction(action: WalkerAction): WalkerAction;
tick(elapsedSeconds: number): OfficeWalkerState;
reset(spawn?: WalkerSpawn): OfficeWalkerState;
/** Rebuild only the procedural skin, retaining this root and all walker/camera state. */
setAppearance(appearance: OfficeActorAppearance): OfficeWalkerState;
/** Attach a caller-owned texture. False for an anonymous dog. */
attachFaceTexture(texture: THREE.Texture): boolean;
/** Detach the current face without disposing the caller's texture. */
clearFaceTexture(): void;
/** A defensive scene-space chase-camera pose for the current actor state. */
followPose(): Pose;
dispose(): void;
@@ -98,8 +105,7 @@ type Actor =
| { kind: "anonymous-dog"; rig: DogRig };
export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): OfficeWalker {
const appearance = options.actor ?? { kind: "humanoid" };
const actor = buildActor(appearance);
let actor = buildActor(options.actor ?? { kind: "humanoid" });
const controller: WalkerController = createWalker(plan, options);
const baseCamera = actor.kind === "humanoid" ? HUMANOID_CAMERA : DOG_CAMERA;
const camera = {
@@ -112,15 +118,22 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
let enabled = options.active ?? false;
let desired: WalkerAction = { x: 0, z: 0 };
let gait = 0;
let faceTexture: THREE.Texture | null = null;
let disposed = false;
const root = new THREE.Group();
root.name = "office-walker-actor";
root.userData.kind = "playable-actor";
root.userData.forwardAxis = "-Z";
root.userData.actorType = actor.rig.root.userData.actorType;
root.add(actor.rig.root);
const view = { position: new THREE.Vector3() };
function sync(state: WalkerState, elapsedSeconds = 0, travelled = 0): void {
const level = plan.level(state.levelId);
if (!level) throw new Error(`office walker lost level "${state.levelId}"`);
actor.rig.root.position.set(state.position.x, level.floorY, state.position.z);
view.position.copy(actor.rig.root.position);
actor.rig.root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z);
root.position.set(state.position.x, level.floorY, state.position.z);
view.position.copy(root.position);
root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z);
const speed = elapsedSeconds > 0 ? travelled / elapsedSeconds : 0;
const targetGait = enabled && speed > 1e-5 ? Math.min(1, speed / (options.speed ?? 1.6)) : 0;
@@ -146,10 +159,24 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
};
}
function replaceActor(appearance: OfficeActorAppearance): void {
const previous = actor;
actor = buildActor(appearance);
root.add(actor.rig.root);
if (actor.kind === "humanoid" && faceTexture) applyOfficeFaceTexture(actor.rig, faceTexture);
if (previous.kind === "humanoid") applyOfficeFaceTexture(previous.rig, null);
if (actor.kind !== "humanoid") faceTexture = null;
previous.rig.root.removeFromParent();
if (previous.kind === "humanoid") disposeHumanoid(previous.rig);
else disposeDog(previous.rig);
root.userData.actorType = actor.rig.root.userData.actorType;
sync(controller.state());
}
sync(controller.state());
return {
root: actor.rig.root,
root,
view,
state: snapshot,
active: () => enabled,
@@ -178,6 +205,24 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
sync(state);
return snapshot();
},
setAppearance(appearance) {
if (disposed) return snapshot();
replaceActor(appearance);
return snapshot();
},
attachFaceTexture(texture) {
if (disposed || actor.kind !== "humanoid") return false;
if (!texture || texture.isTexture !== true) {
throw new RangeError("office walker: face texture must be a Three.js Texture");
}
faceTexture = texture;
applyOfficeFaceTexture(actor.rig, texture);
return true;
},
clearFaceTexture() {
faceTexture = null;
if (!disposed && actor.kind === "humanoid") applyOfficeFaceTexture(actor.rig, null);
},
followPose() {
const state = controller.state();
const level = plan.level(state.levelId);
@@ -198,7 +243,9 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
dispose() {
if (disposed) return;
disposed = true;
actor.rig.root.removeFromParent();
root.removeFromParent();
if (actor.kind === "humanoid") applyOfficeFaceTexture(actor.rig, null);
faceTexture = null;
if (actor.kind === "humanoid") disposeHumanoid(actor.rig);
else disposeDog(actor.rig);
},
@@ -228,6 +275,18 @@ function buildActor(appearance: OfficeActorAppearance): Actor {
};
}
const DEFAULT_OFFICE_FACE_COLOR = 0x18242b;
function applyOfficeFaceTexture(rig: HumanoidRig, texture: THREE.Texture | null): void {
const material = rig.face.material;
if (!(material instanceof THREE.MeshBasicMaterial)) {
throw new Error("office walker: humanoid face must use one MeshBasicMaterial");
}
material.map = texture;
material.color.set(texture ? 0xffffff : DEFAULT_OFFICE_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;