@scenography/plugin-fpc 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -160,10 +160,15 @@ const movementFromTouch = (state, phi, delta) => {
160
160
  return movementFromAxis(state.moveForward, -state.moveStrafe, phi, delta);
161
161
  };
162
162
  /** Integrate the look joystick into the look angles, mutating `look` in place. */
163
- const updateLookFromTouch = (state, look, delta) => {
163
+ const updateLookFromTouch = (state, look, delta, invert = {
164
+ x: false,
165
+ y: false
166
+ }) => {
164
167
  if (state.lookX === 0 && state.lookY === 0) return;
165
- look.phi -= state.lookX * LOOK_SPEED * delta;
166
- look.theta = clamp(look.theta + state.lookY * LOOK_SPEED * delta, -MAX_PITCH, MAX_PITCH);
168
+ const sx = invert.x ? -1 : 1;
169
+ const sy = invert.y ? -1 : 1;
170
+ look.phi -= state.lookX * sx * LOOK_SPEED * delta;
171
+ look.theta = clamp(look.theta + state.lookY * sy * LOOK_SPEED * delta, -MAX_PITCH, MAX_PITCH);
167
172
  };
168
173
  //#endregion
169
174
  //#region src/FreeRoamControls.ts
@@ -193,6 +198,10 @@ var FreeRoamControls = class {
193
198
  rotation = new three.Quaternion();
194
199
  translation = new three.Vector3(0, 170, 0);
195
200
  enabled = true;
201
+ lookInvert = {
202
+ x: false,
203
+ y: false
204
+ };
196
205
  boundary;
197
206
  groundHeight;
198
207
  start;
@@ -270,6 +279,13 @@ var FreeRoamControls = class {
270
279
  setEnabled(enabled) {
271
280
  this.enabled = enabled;
272
281
  }
282
+ /** Invert the analog look per axis (the visitor's control preference). */
283
+ setLookInvert(x, y) {
284
+ this.lookInvert = {
285
+ x,
286
+ y
287
+ };
288
+ }
273
289
  /** Current pose: world position plus yaw (`phi`) and pitch (`theta`) in radians. */
274
290
  getPose() {
275
291
  return {
@@ -293,7 +309,7 @@ var FreeRoamControls = class {
293
309
  }
294
310
  if (this.mouse.dragging) updateLookFromMouse(this.mouse, this.look);
295
311
  this.look.phi += yawFromKeyboard(this.keyboard, delta);
296
- updateLookFromTouch(this.input, this.look, delta);
312
+ updateLookFromTouch(this.input, this.look, delta, this.lookInvert);
297
313
  this.rotation = quaternionFromLook(this.look);
298
314
  const step = movementFromKeyboard(this.keyboard, this.look.phi, delta).add(movementFromTouch(this.input, this.look.phi, delta));
299
315
  this.translation = resolveMove(this.translation, step, this.colliders, this.playerRadius, this.boundary);
@@ -343,6 +359,8 @@ const fpc = () => ({
343
359
  calibrateEyeHeight(scene);
344
360
  return {
345
361
  update: (deltaMs) => {
362
+ const flags = ctx.controlFlags();
363
+ controls.setLookInvert(flags.invertLookX, flags.invertLookY);
346
364
  controls.setColliders(capabilities.get(_scenography_engine.COLLIDERS) ?? []);
347
365
  controls.update(deltaMs / 1e3);
348
366
  },
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["Vector3","Quaternion","Vector3","Vector2","Quaternion","Vector3","Vector3","Quaternion","Vector3","TERRAIN","Vector3","ROAM_CONTROL","COLLIDERS"],"sources":["../src/collision.ts","../src/headBob.ts","../src/keyboard.ts","../src/mouse.ts","../src/touch.ts","../src/FreeRoamControls.ts","../src/plugin.ts"],"sourcesContent":["import { Vector3 } from \"three\";\nimport type { Vec2 } from \"@scenography/scene\";\nimport type { Collider } from \"@scenography/engine\";\n\n/** Whether `(x, z)` lies inside the XZ polygon (even-odd ray casting). */\nexport const insidePolygon = (x: number, z: number, polygon: Vec2[]): boolean => {\n let inside = false;\n for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {\n const a = polygon[i];\n const b = polygon[j];\n if (!a || !b) continue; // unreachable given the loop bounds; satisfies strict indexing\n if (a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]) {\n inside = !inside;\n }\n }\n return inside;\n};\n\n/** Whether a circle of `radius` centered at `(x, z)` overlaps any collider box. */\nconst hits = (x: number, z: number, radius: number, colliders: Collider[]): boolean => {\n const r2 = radius * radius;\n for (const c of colliders) {\n const px = x - c.cx;\n const pz = z - c.cz;\n // Project onto the box axes: along its length and along its thickness normal.\n const along = px * c.dx + pz * c.dz;\n const perp = px * -c.dz + pz * c.dx;\n // Distance from the point to the closest point on the box.\n const ddAlong = along - Math.max(-c.halfLength, Math.min(c.halfLength, along));\n const ddPerp = perp - Math.max(-c.halfThickness, Math.min(c.halfThickness, perp));\n if (ddAlong * ddAlong + ddPerp * ddPerp < r2) return true;\n }\n return false;\n};\n\n/**\n * Resolve a desired XZ step against wall colliders and an optional walkable-area\n * boundary, one axis at a time so the camera slides along an obstacle instead of\n * sticking when it can't move straight.\n */\nexport const resolveMove = (\n current: Vector3,\n step: Vector3,\n colliders: Collider[],\n radius: number,\n boundary?: Vec2[],\n): Vector3 => {\n let { x, z } = current;\n const free = (nx: number, nz: number): boolean =>\n !hits(nx, nz, radius, colliders) && (!boundary || insidePolygon(nx, nz, boundary));\n if (step.x !== 0 && free(x + step.x, z)) x += step.x;\n if (step.z !== 0 && free(x, z + step.z)) z += step.z;\n return new Vector3(x, current.y, z);\n};\n","/** State for the subtle vertical bob applied while walking. */\nexport interface HeadBobState {\n active: boolean;\n timer: number;\n}\n\nexport const createHeadBobState = (): HeadBobState => ({ active: false, timer: 0 });\n\n/**\n * Advance the head-bob timer by `elapsed` seconds, snapping to half-wavelength\n * steps so the bob always settles back to neutral when movement stops.\n */\nexport const updateHeadBob = (state: HeadBobState, elapsed: number): void => {\n if (!state.active) return;\n\n const wavelength = Math.PI;\n const nextStep = 1 + Math.floor(((state.timer + 1e-6) * 10) / wavelength);\n const nextStepTime = (nextStep * wavelength) / 10;\n state.timer = Math.min(state.timer + elapsed, nextStepTime);\n\n if (state.timer === nextStepTime) state.active = false;\n};\n\n/** Vertical offset to add to the camera, in world units. */\nexport const headBobOffset = (state: HeadBobState): number => Math.sin(state.timer * 10) * 1.5;\n","import { Quaternion, Vector3 } from \"three\";\n\n/** Set of currently-pressed key codes. */\nexport type KeyboardState = Record<string, boolean>;\n\nexport const createKeyboardState = (): KeyboardState => ({});\n\n/** Codes the controls consume; preventDefault'd so the arrows don't scroll. */\nconst MOVEMENT_KEYS = new Set([\n \"KeyW\",\n \"KeyA\",\n \"KeyS\",\n \"KeyD\",\n \"ArrowUp\",\n \"ArrowDown\",\n \"ArrowLeft\",\n \"ArrowRight\",\n]);\n\nexport const onKeyDown = (state: KeyboardState, event: KeyboardEvent): void => {\n state[event.code] = true;\n if (MOVEMENT_KEYS.has(event.code)) event.preventDefault();\n};\n\nexport const onKeyUp = (state: KeyboardState, event: KeyboardEvent): void => {\n state[event.code] = false;\n};\n\n/** Yaw turn rate for the Q/E keys, in radians per second. */\nconst KEY_TURN_SPEED = 1.8;\n\n/**\n * Turn a forward/strafe pair (each in `-1..1`) into a per-frame translation\n * oriented by the current yaw (`phi`). Shared by keyboard and touch input.\n */\nexport const movementFromAxis = (\n forwardVelocity: number,\n strafeVelocity: number,\n phi: number,\n delta: number,\n boosted = false,\n): Vector3 => {\n const factor = boosted ? 60 : 20;\n const yaw = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), phi);\n\n const forward = new Vector3(0, 0, -10)\n .applyQuaternion(yaw)\n .multiplyScalar(forwardVelocity * delta * factor);\n const left = new Vector3(-10, 0, 0)\n .applyQuaternion(yaw)\n .multiplyScalar(strafeVelocity * delta * factor);\n\n return forward.add(left);\n};\n\n/**\n * Compute the per-frame translation from WASD or arrow-key input, oriented by\n * the current yaw (`phi`). Holding Shift acts as a speed booster.\n */\nexport const movementFromKeyboard = (state: KeyboardState, phi: number, delta: number): Vector3 => {\n const has = (...codes: string[]): number => (codes.some((c) => state[c]) ? 1 : 0);\n const forwardVelocity = has(\"KeyW\", \"ArrowUp\") - has(\"KeyS\", \"ArrowDown\");\n const strafeVelocity = has(\"KeyA\", \"ArrowLeft\") - has(\"KeyD\", \"ArrowRight\");\n const boosted = Boolean(state[\"ShiftLeft\"] || state[\"ShiftRight\"]);\n\n return movementFromAxis(forwardVelocity, strafeVelocity, phi, delta, boosted);\n};\n\n/**\n * Per-frame yaw delta (radians) from the Q/E turn keys. Q turns left\n * (increasing yaw), E turns right.\n */\nexport const yawFromKeyboard = (state: KeyboardState, delta: number): number => {\n const turn = (state[\"KeyQ\"] ? 1 : 0) - (state[\"KeyE\"] ? 1 : 0);\n return turn * KEY_TURN_SPEED * delta;\n};\n","import { Quaternion, Vector2, Vector3 } from \"three\";\n\n/** Mutable pointer state accumulated between frames. */\nexport interface MouseState {\n /** Whether the primary button is held (drag-to-look). */\n dragging: boolean;\n pos: Vector2;\n prevPos: Vector2;\n delta: Vector2;\n}\n\n/** Look angles driven by the pointer. */\nexport interface LookState {\n phi: number;\n theta: number;\n}\n\nconst PHI_SPEED = 8;\nconst THETA_SPEED = 5;\n\nexport const createMouseState = (): MouseState => ({\n dragging: false,\n pos: new Vector2(),\n prevPos: new Vector2(),\n delta: new Vector2(),\n});\n\nexport const clamp = (x: number, a: number, b: number): number => Math.min(Math.max(x, a), b);\n\n/** Maximum up/down look angle (radians) — keeps the camera from flipping over. */\nexport const MAX_PITCH = Math.PI / 3;\n\n/** Build the look quaternion from the current yaw/pitch angles. */\nexport const quaternionFromLook = (look: LookState): Quaternion => {\n const qy = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), look.phi);\n const qx = new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), look.theta);\n return new Quaternion().multiply(qy).multiply(qx);\n};\n\nexport const onMouseDown = (state: MouseState, event: MouseEvent): void => {\n state.dragging = event.button === 0;\n};\n\nexport const onMouseUp = (state: MouseState): void => {\n state.dragging = false;\n};\n\nexport const onMouseMove = (state: MouseState, event: MouseEvent): void => {\n state.pos.set(event.pageX - window.innerWidth / 2, event.pageY - window.innerHeight / 2);\n state.delta.subVectors(state.pos, state.prevPos);\n};\n\nexport const commitMouseFrame = (state: MouseState): void => {\n state.prevPos.copy(state.pos);\n state.delta.set(0, 0);\n};\n\n/**\n * Integrate the pointer delta into the look angles. Mutates `look` in place.\n * Yaw is unbounded; pitch is clamped to avoid flipping over.\n */\nexport const updateLookFromMouse = (mouse: MouseState, look: LookState): void => {\n const xh = mouse.delta.x / window.innerWidth;\n const yh = mouse.delta.y / window.innerHeight;\n\n look.phi += -xh * PHI_SPEED;\n look.theta = clamp(look.theta + -yh * THETA_SPEED, -MAX_PITCH, MAX_PITCH);\n};\n","import { Vector3 } from \"three\";\n\nimport { movementFromAxis } from \"./keyboard\";\nimport { clamp, MAX_PITCH, type LookState } from \"./mouse\";\n\n/**\n * The shape of the shared analog input fpc reads (the kernel's `InputBus`): the\n * move/look axes a joystick feeds. Structural, so any producer with these fields\n * works. fpc interprets them as walking + turning; another camera might not.\n */\nexport interface AnalogInput {\n /** Forward (`+1`) / backward (`-1`) movement. */\n moveForward: number;\n /** Strafe right (`+1`) / left (`-1`). */\n moveStrafe: number;\n /** Look right (`+1`) / left (`-1`). */\n lookX: number;\n /** Look up (`+1`) / down (`-1`). */\n lookY: number;\n}\n\n/** Look turn rate driven by the joystick, in radians per second at full deflection. */\nconst LOOK_SPEED = 2.2;\n\n/** Per-frame translation from the analog move axes, oriented by yaw (`phi`). */\nexport const movementFromTouch = (state: AnalogInput, phi: number, delta: number): Vector3 => {\n if (state.moveForward === 0 && state.moveStrafe === 0) return new Vector3();\n // Strafe sign matches the keyboard helper (left strafe is +1 there).\n return movementFromAxis(state.moveForward, -state.moveStrafe, phi, delta);\n};\n\n/** Integrate the look joystick into the look angles, mutating `look` in place. */\nexport const updateLookFromTouch = (state: AnalogInput, look: LookState, delta: number): void => {\n if (state.lookX === 0 && state.lookY === 0) return;\n look.phi -= state.lookX * LOOK_SPEED * delta;\n look.theta = clamp(look.theta + state.lookY * LOOK_SPEED * delta, -MAX_PITCH, MAX_PITCH);\n};\n","import { Quaternion, Vector3, type PerspectiveCamera } from \"three\";\nimport type { Vec2 } from \"@scenography/scene\";\nimport type { Collider } from \"@scenography/engine\";\n\nimport { resolveMove } from \"./collision\";\nimport { createHeadBobState, headBobOffset, updateHeadBob } from \"./headBob\";\nimport {\n createKeyboardState,\n movementFromKeyboard,\n onKeyDown,\n onKeyUp,\n yawFromKeyboard,\n} from \"./keyboard\";\nimport {\n commitMouseFrame,\n createMouseState,\n onMouseDown,\n onMouseMove,\n onMouseUp,\n quaternionFromLook,\n updateLookFromMouse,\n type LookState,\n} from \"./mouse\";\nimport { movementFromTouch, updateLookFromTouch, type AnalogInput } from \"./touch\";\n\nexport interface FreeRoamOptions {\n /** Wall collision boxes the camera can't pass through. */\n colliders?: Collider[];\n /** Camera collision radius in world units. Defaults to `25`. */\n playerRadius?: number;\n /** Starting position. */\n position?: Vector3;\n /** Starting yaw in radians. */\n yaw?: number;\n /** Walkable-area polygon (XZ ring); the camera can't step outside it. */\n boundary?: Vec2[];\n /** Ground height at `(x, z)`; when set the camera follows the terrain. */\n groundHeight?: (x: number, z: number) => number;\n /** Eye height above the ground. Defaults to the start Y over the start ground. */\n eyeHeight?: number;\n}\n\n/**\n * First-person, free-roaming camera controls: drag-to-look with the mouse, WASD\n * to walk, a subtle head-bob while moving, wall collision and terrain following.\n * The shared analog input (`AnalogInput`, the kernel's InputBus) is folded in on\n * top of keyboard/mouse, so on-screen joysticks drive it too. It keeps the\n * camera upright (no roll) and clamps pitch — the first-person contract.\n *\n * Framework-agnostic: construct it with a camera, the canvas and the input,\n * call `attach()`, then `update(delta)` once per frame.\n */\nexport class FreeRoamControls {\n private colliders: Collider[];\n private readonly playerRadius: number;\n\n private readonly camera: PerspectiveCamera;\n private readonly domElement: HTMLElement;\n private readonly input: AnalogInput;\n private readonly mouse = createMouseState();\n private readonly keyboard = createKeyboardState();\n private readonly headBob = createHeadBobState();\n private readonly look: LookState = { phi: 0, theta: 0 };\n private rotation = new Quaternion();\n private translation = new Vector3(0, 170, 0);\n private enabled = true;\n private boundary: Vec2[] | undefined;\n private groundHeight: ((x: number, z: number) => number) | null;\n private readonly start: Vector3;\n private eyeHeight: number;\n\n private readonly handlers: Array<[EventTarget, string, EventListener]> = [];\n\n constructor(\n camera: PerspectiveCamera,\n domElement: HTMLElement,\n input: AnalogInput,\n options: FreeRoamOptions = {},\n ) {\n this.camera = camera;\n this.domElement = domElement;\n this.input = input;\n this.colliders = options.colliders ?? [];\n this.playerRadius = options.playerRadius ?? 25;\n if (options.position) this.translation.copy(options.position);\n if (options.yaw !== undefined) this.look.phi = options.yaw;\n this.rotation.setFromAxisAngle(new Vector3(0, 1, 0), this.look.phi);\n this.boundary = options.boundary;\n this.groundHeight = options.groundHeight ?? null;\n this.start = this.translation.clone();\n // Eye height = start Y above the ground under the start position.\n const groundAtStart = this.groundHeight?.(this.start.x, this.start.z) ?? 0;\n this.eyeHeight = options.eyeHeight ?? this.start.y - groundAtStart;\n }\n\n /** Current eye height above the ground (world units). */\n getEyeHeight(): number {\n return this.eyeHeight;\n }\n\n /** Fix the eye height above the ground (world units). */\n setEyeHeight(eyeHeight: number): void {\n this.eyeHeight = eyeHeight;\n }\n\n /**\n * Re-derive the default eye height (start Y over the ground under the start\n * position). Call once asynchronous ground data (a floor heightmap) has\n * loaded: the value captured at construction was measured against the\n * not-yet-displaced floor.\n */\n recalibrateEyeHeight(): void {\n const ground = this.groundHeight?.(this.start.x, this.start.z) ?? 0;\n this.eyeHeight = this.start.y - ground;\n }\n\n attach(): void {\n const bind = (target: EventTarget, type: string, listener: EventListener): void => {\n target.addEventListener(type, listener);\n this.handlers.push([target, type, listener]);\n };\n\n bind(this.domElement, \"mousedown\", (e) => onMouseDown(this.mouse, e as MouseEvent));\n bind(window, \"mousemove\", (e) => onMouseMove(this.mouse, e as MouseEvent));\n bind(window, \"mouseup\", () => onMouseUp(this.mouse));\n bind(window, \"keydown\", (e) => onKeyDown(this.keyboard, e as KeyboardEvent));\n bind(window, \"keyup\", (e) => onKeyUp(this.keyboard, e as KeyboardEvent));\n }\n\n detach(): void {\n for (const [target, type, listener] of this.handlers)\n target.removeEventListener(type, listener);\n this.handlers.length = 0;\n }\n\n /** Replace the wall collision set (e.g. after the scene's walls change). */\n setColliders(colliders: Collider[]): void {\n this.colliders = colliders;\n }\n\n /** Replace the walkable-area boundary polygon (or clear it with `undefined`). */\n setBoundary(boundary: Vec2[] | undefined): void {\n this.boundary = boundary;\n }\n\n /** Replace the ground-height function (or clear it to stop following terrain). */\n setGroundHeight(groundHeight: ((x: number, z: number) => number) | null): void {\n this.groundHeight = groundHeight;\n }\n\n /**\n * Enable/disable user input. While disabled, `update()` leaves the camera\n * untouched so another driver (e.g. a camera tween) can own it; re-enable after\n * syncing the pose with `setPose` so free-roam continues from there.\n */\n setEnabled(enabled: boolean): void {\n this.enabled = enabled;\n }\n\n /** Current pose: world position plus yaw (`phi`) and pitch (`theta`) in radians. */\n getPose(): { position: Vector3; phi: number; theta: number } {\n return { position: this.translation.clone(), phi: this.look.phi, theta: this.look.theta };\n }\n\n /** Force the pose (e.g. at the end of a camera tween) so input resumes from it. */\n setPose(position: Vector3, phi: number, theta: number): void {\n this.translation.copy(position);\n this.look.phi = phi;\n this.look.theta = theta;\n this.rotation = quaternionFromLook(this.look);\n }\n\n /** Advance the camera by `delta` seconds of input. */\n update(delta: number): void {\n // Disabled: another driver owns the camera this frame. Drop any accumulated\n // pointer delta so look doesn't jump when control is handed back.\n if (!this.enabled) {\n commitMouseFrame(this.mouse);\n return;\n }\n // Accumulate look from every source into the angles, then derive rotation.\n if (this.mouse.dragging) updateLookFromMouse(this.mouse, this.look);\n this.look.phi += yawFromKeyboard(this.keyboard, delta);\n updateLookFromTouch(this.input, this.look, delta);\n this.rotation = quaternionFromLook(this.look);\n\n const step = movementFromKeyboard(this.keyboard, this.look.phi, delta).add(\n movementFromTouch(this.input, this.look.phi, delta),\n );\n this.translation = resolveMove(\n this.translation,\n step,\n this.colliders,\n this.playerRadius,\n this.boundary,\n );\n // Follow the terrain: keep the eye a fixed height above the ground below.\n if (this.groundHeight) {\n this.translation.y = this.groundHeight(this.translation.x, this.translation.z) + this.eyeHeight;\n }\n if (step.x !== 0 || step.z !== 0) this.headBob.active = true;\n updateHeadBob(this.headBob, delta);\n\n this.camera.quaternion.copy(this.rotation);\n this.camera.position.copy(this.translation);\n this.camera.position.y += headBobOffset(this.headBob);\n\n const target = new Vector3(0, 0, -1)\n .applyQuaternion(this.rotation)\n .multiplyScalar(100)\n .add(this.translation);\n this.camera.lookAt(target);\n\n commitMouseFrame(this.mouse);\n }\n}\n","import {\n COLLIDERS,\n ROAM_CONTROL,\n TERRAIN,\n type Collider,\n type RoamControl,\n type SystemPlugin,\n type TerrainCapability,\n} from \"@scenography/engine\";\nimport type { Scene } from \"@scenography/scene\";\nimport { Vector3 } from \"three\";\n\nimport { FreeRoamControls } from \"./FreeRoamControls\";\n\n/**\n * First-person camera — a *system* plugin. It drives the kernel camera from\n * WASD/drag plus the shared analog input (joysticks), keeps it at `eyeHeight`\n * over the terrain, and blocks it against wall colliders. Consumes the `terrain`\n * and `colliders` capabilities (live, so it tracks async heightmaps and hot wall\n * edits) and publishes `roamControl` so a framing/tour system can suspend it.\n */\nexport const fpc = (): SystemPlugin => ({\n name: \"fpc\",\n setup: (ctx) => {\n const { camera, canvas, input, capabilities, scene } = ctx;\n const terrainHeight = (x: number, z: number): number =>\n capabilities.get<TerrainCapability>(TERRAIN)?.heightAt(x, z) ?? 0;\n // Follow terrain only when some plugin provides it; otherwise keep a fixed Y.\n const groundHeight = (): ((x: number, z: number) => number) | undefined =>\n capabilities.get<TerrainCapability>(TERRAIN) ? terrainHeight : undefined;\n\n const start = scene.camera.start;\n const controls = new FreeRoamControls(camera, canvas, input, {\n position: new Vector3(start.position[0], start.position[1], start.position[2]),\n yaw: start.yaw,\n boundary: scene.boundary,\n eyeHeight: scene.camera.eyeHeight,\n groundHeight: groundHeight(),\n });\n controls.attach();\n capabilities.provide(ROAM_CONTROL, controls satisfies RoamControl);\n\n // Eye height: an explicit `camera.eyeHeight` wins. Otherwise it was derived\n // from the start Y over the ground — but a floor heightmap loads async, so\n // recalibrate once the terrain is real, or the visitor walks in the air.\n const calibrateEyeHeight = (s: Scene): void => {\n if (s.camera.eyeHeight !== undefined) controls.setEyeHeight(s.camera.eyeHeight);\n else {\n const terrain = capabilities.get<TerrainCapability>(TERRAIN);\n if (terrain) void terrain.ready.then(() => controls.recalibrateEyeHeight());\n }\n };\n calibrateEyeHeight(scene);\n\n return {\n update: (deltaMs) => {\n controls.setColliders(capabilities.get<Collider[]>(COLLIDERS) ?? []);\n controls.update(deltaMs / 1000);\n },\n setScene: (s) => {\n controls.setBoundary(s.boundary);\n controls.setGroundHeight(groundHeight() ?? null);\n calibrateEyeHeight(s);\n },\n dispose: () => {\n controls.detach();\n capabilities.revoke(ROAM_CONTROL);\n },\n };\n },\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["Vector3","Quaternion","Vector3","Vector2","Quaternion","Vector3","Vector3","Quaternion","Vector3","TERRAIN","Vector3","ROAM_CONTROL","COLLIDERS"],"sources":["../src/collision.ts","../src/headBob.ts","../src/keyboard.ts","../src/mouse.ts","../src/touch.ts","../src/FreeRoamControls.ts","../src/plugin.ts"],"sourcesContent":["import { Vector3 } from \"three\";\nimport type { Vec2 } from \"@scenography/scene\";\nimport type { Collider } from \"@scenography/engine\";\n\n/** Whether `(x, z)` lies inside the XZ polygon (even-odd ray casting). */\nexport const insidePolygon = (x: number, z: number, polygon: Vec2[]): boolean => {\n let inside = false;\n for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {\n const a = polygon[i];\n const b = polygon[j];\n if (!a || !b) continue; // unreachable given the loop bounds; satisfies strict indexing\n if (a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]) {\n inside = !inside;\n }\n }\n return inside;\n};\n\n/** Whether a circle of `radius` centered at `(x, z)` overlaps any collider box. */\nconst hits = (x: number, z: number, radius: number, colliders: Collider[]): boolean => {\n const r2 = radius * radius;\n for (const c of colliders) {\n const px = x - c.cx;\n const pz = z - c.cz;\n // Project onto the box axes: along its length and along its thickness normal.\n const along = px * c.dx + pz * c.dz;\n const perp = px * -c.dz + pz * c.dx;\n // Distance from the point to the closest point on the box.\n const ddAlong = along - Math.max(-c.halfLength, Math.min(c.halfLength, along));\n const ddPerp = perp - Math.max(-c.halfThickness, Math.min(c.halfThickness, perp));\n if (ddAlong * ddAlong + ddPerp * ddPerp < r2) return true;\n }\n return false;\n};\n\n/**\n * Resolve a desired XZ step against wall colliders and an optional walkable-area\n * boundary, one axis at a time so the camera slides along an obstacle instead of\n * sticking when it can't move straight.\n */\nexport const resolveMove = (\n current: Vector3,\n step: Vector3,\n colliders: Collider[],\n radius: number,\n boundary?: Vec2[],\n): Vector3 => {\n let { x, z } = current;\n const free = (nx: number, nz: number): boolean =>\n !hits(nx, nz, radius, colliders) && (!boundary || insidePolygon(nx, nz, boundary));\n if (step.x !== 0 && free(x + step.x, z)) x += step.x;\n if (step.z !== 0 && free(x, z + step.z)) z += step.z;\n return new Vector3(x, current.y, z);\n};\n","/** State for the subtle vertical bob applied while walking. */\nexport interface HeadBobState {\n active: boolean;\n timer: number;\n}\n\nexport const createHeadBobState = (): HeadBobState => ({ active: false, timer: 0 });\n\n/**\n * Advance the head-bob timer by `elapsed` seconds, snapping to half-wavelength\n * steps so the bob always settles back to neutral when movement stops.\n */\nexport const updateHeadBob = (state: HeadBobState, elapsed: number): void => {\n if (!state.active) return;\n\n const wavelength = Math.PI;\n const nextStep = 1 + Math.floor(((state.timer + 1e-6) * 10) / wavelength);\n const nextStepTime = (nextStep * wavelength) / 10;\n state.timer = Math.min(state.timer + elapsed, nextStepTime);\n\n if (state.timer === nextStepTime) state.active = false;\n};\n\n/** Vertical offset to add to the camera, in world units. */\nexport const headBobOffset = (state: HeadBobState): number => Math.sin(state.timer * 10) * 1.5;\n","import { Quaternion, Vector3 } from \"three\";\n\n/** Set of currently-pressed key codes. */\nexport type KeyboardState = Record<string, boolean>;\n\nexport const createKeyboardState = (): KeyboardState => ({});\n\n/** Codes the controls consume; preventDefault'd so the arrows don't scroll. */\nconst MOVEMENT_KEYS = new Set([\n \"KeyW\",\n \"KeyA\",\n \"KeyS\",\n \"KeyD\",\n \"ArrowUp\",\n \"ArrowDown\",\n \"ArrowLeft\",\n \"ArrowRight\",\n]);\n\nexport const onKeyDown = (state: KeyboardState, event: KeyboardEvent): void => {\n state[event.code] = true;\n if (MOVEMENT_KEYS.has(event.code)) event.preventDefault();\n};\n\nexport const onKeyUp = (state: KeyboardState, event: KeyboardEvent): void => {\n state[event.code] = false;\n};\n\n/** Yaw turn rate for the Q/E keys, in radians per second. */\nconst KEY_TURN_SPEED = 1.8;\n\n/**\n * Turn a forward/strafe pair (each in `-1..1`) into a per-frame translation\n * oriented by the current yaw (`phi`). Shared by keyboard and touch input.\n */\nexport const movementFromAxis = (\n forwardVelocity: number,\n strafeVelocity: number,\n phi: number,\n delta: number,\n boosted = false,\n): Vector3 => {\n const factor = boosted ? 60 : 20;\n const yaw = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), phi);\n\n const forward = new Vector3(0, 0, -10)\n .applyQuaternion(yaw)\n .multiplyScalar(forwardVelocity * delta * factor);\n const left = new Vector3(-10, 0, 0)\n .applyQuaternion(yaw)\n .multiplyScalar(strafeVelocity * delta * factor);\n\n return forward.add(left);\n};\n\n/**\n * Compute the per-frame translation from WASD or arrow-key input, oriented by\n * the current yaw (`phi`). Holding Shift acts as a speed booster.\n */\nexport const movementFromKeyboard = (state: KeyboardState, phi: number, delta: number): Vector3 => {\n const has = (...codes: string[]): number => (codes.some((c) => state[c]) ? 1 : 0);\n const forwardVelocity = has(\"KeyW\", \"ArrowUp\") - has(\"KeyS\", \"ArrowDown\");\n const strafeVelocity = has(\"KeyA\", \"ArrowLeft\") - has(\"KeyD\", \"ArrowRight\");\n const boosted = Boolean(state[\"ShiftLeft\"] || state[\"ShiftRight\"]);\n\n return movementFromAxis(forwardVelocity, strafeVelocity, phi, delta, boosted);\n};\n\n/**\n * Per-frame yaw delta (radians) from the Q/E turn keys. Q turns left\n * (increasing yaw), E turns right.\n */\nexport const yawFromKeyboard = (state: KeyboardState, delta: number): number => {\n const turn = (state[\"KeyQ\"] ? 1 : 0) - (state[\"KeyE\"] ? 1 : 0);\n return turn * KEY_TURN_SPEED * delta;\n};\n","import { Quaternion, Vector2, Vector3 } from \"three\";\n\n/** Mutable pointer state accumulated between frames. */\nexport interface MouseState {\n /** Whether the primary button is held (drag-to-look). */\n dragging: boolean;\n pos: Vector2;\n prevPos: Vector2;\n delta: Vector2;\n}\n\n/** Look angles driven by the pointer. */\nexport interface LookState {\n phi: number;\n theta: number;\n}\n\nconst PHI_SPEED = 8;\nconst THETA_SPEED = 5;\n\nexport const createMouseState = (): MouseState => ({\n dragging: false,\n pos: new Vector2(),\n prevPos: new Vector2(),\n delta: new Vector2(),\n});\n\nexport const clamp = (x: number, a: number, b: number): number => Math.min(Math.max(x, a), b);\n\n/** Maximum up/down look angle (radians) — keeps the camera from flipping over. */\nexport const MAX_PITCH = Math.PI / 3;\n\n/** Build the look quaternion from the current yaw/pitch angles. */\nexport const quaternionFromLook = (look: LookState): Quaternion => {\n const qy = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), look.phi);\n const qx = new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), look.theta);\n return new Quaternion().multiply(qy).multiply(qx);\n};\n\nexport const onMouseDown = (state: MouseState, event: MouseEvent): void => {\n state.dragging = event.button === 0;\n};\n\nexport const onMouseUp = (state: MouseState): void => {\n state.dragging = false;\n};\n\nexport const onMouseMove = (state: MouseState, event: MouseEvent): void => {\n state.pos.set(event.pageX - window.innerWidth / 2, event.pageY - window.innerHeight / 2);\n state.delta.subVectors(state.pos, state.prevPos);\n};\n\nexport const commitMouseFrame = (state: MouseState): void => {\n state.prevPos.copy(state.pos);\n state.delta.set(0, 0);\n};\n\n/**\n * Integrate the pointer delta into the look angles. Mutates `look` in place.\n * Yaw is unbounded; pitch is clamped to avoid flipping over.\n */\nexport const updateLookFromMouse = (mouse: MouseState, look: LookState): void => {\n const xh = mouse.delta.x / window.innerWidth;\n const yh = mouse.delta.y / window.innerHeight;\n\n look.phi += -xh * PHI_SPEED;\n look.theta = clamp(look.theta + -yh * THETA_SPEED, -MAX_PITCH, MAX_PITCH);\n};\n","import { Vector3 } from \"three\";\n\nimport { movementFromAxis } from \"./keyboard\";\nimport { clamp, MAX_PITCH, type LookState } from \"./mouse\";\n\n/**\n * The shape of the shared analog input fpc reads (the kernel's `InputBus`): the\n * move/look axes a joystick feeds. Structural, so any producer with these fields\n * works. fpc interprets them as walking + turning; another camera might not.\n */\nexport interface AnalogInput {\n /** Forward (`+1`) / backward (`-1`) movement. */\n moveForward: number;\n /** Strafe right (`+1`) / left (`-1`). */\n moveStrafe: number;\n /** Look right (`+1`) / left (`-1`). */\n lookX: number;\n /** Look up (`+1`) / down (`-1`). */\n lookY: number;\n}\n\n/** Look turn rate driven by the joystick, in radians per second at full deflection. */\nconst LOOK_SPEED = 2.2;\n\n/** Per-frame translation from the analog move axes, oriented by yaw (`phi`). */\nexport const movementFromTouch = (state: AnalogInput, phi: number, delta: number): Vector3 => {\n if (state.moveForward === 0 && state.moveStrafe === 0) return new Vector3();\n // Strafe sign matches the keyboard helper (left strafe is +1 there).\n return movementFromAxis(state.moveForward, -state.moveStrafe, phi, delta);\n};\n\n/** Per-axis inversion of the analog look, for visitors who feel it backwards. */\nexport interface LookInvert {\n /** Invert the horizontal (turn) axis. */\n x: boolean;\n /** Invert the vertical (pitch) axis. */\n y: boolean;\n}\n\n/** Integrate the look joystick into the look angles, mutating `look` in place. */\nexport const updateLookFromTouch = (\n state: AnalogInput,\n look: LookState,\n delta: number,\n invert: LookInvert = { x: false, y: false },\n): void => {\n if (state.lookX === 0 && state.lookY === 0) return;\n const sx = invert.x ? -1 : 1;\n const sy = invert.y ? -1 : 1;\n look.phi -= state.lookX * sx * LOOK_SPEED * delta;\n look.theta = clamp(look.theta + state.lookY * sy * LOOK_SPEED * delta, -MAX_PITCH, MAX_PITCH);\n};\n","import { Quaternion, Vector3, type PerspectiveCamera } from \"three\";\nimport type { Vec2 } from \"@scenography/scene\";\nimport type { Collider } from \"@scenography/engine\";\n\nimport { resolveMove } from \"./collision\";\nimport { createHeadBobState, headBobOffset, updateHeadBob } from \"./headBob\";\nimport {\n createKeyboardState,\n movementFromKeyboard,\n onKeyDown,\n onKeyUp,\n yawFromKeyboard,\n} from \"./keyboard\";\nimport {\n commitMouseFrame,\n createMouseState,\n onMouseDown,\n onMouseMove,\n onMouseUp,\n quaternionFromLook,\n updateLookFromMouse,\n type LookState,\n} from \"./mouse\";\nimport { movementFromTouch, updateLookFromTouch, type AnalogInput, type LookInvert } from \"./touch\";\n\nexport interface FreeRoamOptions {\n /** Wall collision boxes the camera can't pass through. */\n colliders?: Collider[];\n /** Camera collision radius in world units. Defaults to `25`. */\n playerRadius?: number;\n /** Starting position. */\n position?: Vector3;\n /** Starting yaw in radians. */\n yaw?: number;\n /** Walkable-area polygon (XZ ring); the camera can't step outside it. */\n boundary?: Vec2[];\n /** Ground height at `(x, z)`; when set the camera follows the terrain. */\n groundHeight?: (x: number, z: number) => number;\n /** Eye height above the ground. Defaults to the start Y over the start ground. */\n eyeHeight?: number;\n}\n\n/**\n * First-person, free-roaming camera controls: drag-to-look with the mouse, WASD\n * to walk, a subtle head-bob while moving, wall collision and terrain following.\n * The shared analog input (`AnalogInput`, the kernel's InputBus) is folded in on\n * top of keyboard/mouse, so on-screen joysticks drive it too. It keeps the\n * camera upright (no roll) and clamps pitch — the first-person contract.\n *\n * Framework-agnostic: construct it with a camera, the canvas and the input,\n * call `attach()`, then `update(delta)` once per frame.\n */\nexport class FreeRoamControls {\n private colliders: Collider[];\n private readonly playerRadius: number;\n\n private readonly camera: PerspectiveCamera;\n private readonly domElement: HTMLElement;\n private readonly input: AnalogInput;\n private readonly mouse = createMouseState();\n private readonly keyboard = createKeyboardState();\n private readonly headBob = createHeadBobState();\n private readonly look: LookState = { phi: 0, theta: 0 };\n private rotation = new Quaternion();\n private translation = new Vector3(0, 170, 0);\n private enabled = true;\n private lookInvert: LookInvert = { x: false, y: false };\n private boundary: Vec2[] | undefined;\n private groundHeight: ((x: number, z: number) => number) | null;\n private readonly start: Vector3;\n private eyeHeight: number;\n\n private readonly handlers: Array<[EventTarget, string, EventListener]> = [];\n\n constructor(\n camera: PerspectiveCamera,\n domElement: HTMLElement,\n input: AnalogInput,\n options: FreeRoamOptions = {},\n ) {\n this.camera = camera;\n this.domElement = domElement;\n this.input = input;\n this.colliders = options.colliders ?? [];\n this.playerRadius = options.playerRadius ?? 25;\n if (options.position) this.translation.copy(options.position);\n if (options.yaw !== undefined) this.look.phi = options.yaw;\n this.rotation.setFromAxisAngle(new Vector3(0, 1, 0), this.look.phi);\n this.boundary = options.boundary;\n this.groundHeight = options.groundHeight ?? null;\n this.start = this.translation.clone();\n // Eye height = start Y above the ground under the start position.\n const groundAtStart = this.groundHeight?.(this.start.x, this.start.z) ?? 0;\n this.eyeHeight = options.eyeHeight ?? this.start.y - groundAtStart;\n }\n\n /** Current eye height above the ground (world units). */\n getEyeHeight(): number {\n return this.eyeHeight;\n }\n\n /** Fix the eye height above the ground (world units). */\n setEyeHeight(eyeHeight: number): void {\n this.eyeHeight = eyeHeight;\n }\n\n /**\n * Re-derive the default eye height (start Y over the ground under the start\n * position). Call once asynchronous ground data (a floor heightmap) has\n * loaded: the value captured at construction was measured against the\n * not-yet-displaced floor.\n */\n recalibrateEyeHeight(): void {\n const ground = this.groundHeight?.(this.start.x, this.start.z) ?? 0;\n this.eyeHeight = this.start.y - ground;\n }\n\n attach(): void {\n const bind = (target: EventTarget, type: string, listener: EventListener): void => {\n target.addEventListener(type, listener);\n this.handlers.push([target, type, listener]);\n };\n\n bind(this.domElement, \"mousedown\", (e) => onMouseDown(this.mouse, e as MouseEvent));\n bind(window, \"mousemove\", (e) => onMouseMove(this.mouse, e as MouseEvent));\n bind(window, \"mouseup\", () => onMouseUp(this.mouse));\n bind(window, \"keydown\", (e) => onKeyDown(this.keyboard, e as KeyboardEvent));\n bind(window, \"keyup\", (e) => onKeyUp(this.keyboard, e as KeyboardEvent));\n }\n\n detach(): void {\n for (const [target, type, listener] of this.handlers)\n target.removeEventListener(type, listener);\n this.handlers.length = 0;\n }\n\n /** Replace the wall collision set (e.g. after the scene's walls change). */\n setColliders(colliders: Collider[]): void {\n this.colliders = colliders;\n }\n\n /** Replace the walkable-area boundary polygon (or clear it with `undefined`). */\n setBoundary(boundary: Vec2[] | undefined): void {\n this.boundary = boundary;\n }\n\n /** Replace the ground-height function (or clear it to stop following terrain). */\n setGroundHeight(groundHeight: ((x: number, z: number) => number) | null): void {\n this.groundHeight = groundHeight;\n }\n\n /**\n * Enable/disable user input. While disabled, `update()` leaves the camera\n * untouched so another driver (e.g. a camera tween) can own it; re-enable after\n * syncing the pose with `setPose` so free-roam continues from there.\n */\n setEnabled(enabled: boolean): void {\n this.enabled = enabled;\n }\n\n /** Invert the analog look per axis (the visitor's control preference). */\n setLookInvert(x: boolean, y: boolean): void {\n this.lookInvert = { x, y };\n }\n\n /** Current pose: world position plus yaw (`phi`) and pitch (`theta`) in radians. */\n getPose(): { position: Vector3; phi: number; theta: number } {\n return { position: this.translation.clone(), phi: this.look.phi, theta: this.look.theta };\n }\n\n /** Force the pose (e.g. at the end of a camera tween) so input resumes from it. */\n setPose(position: Vector3, phi: number, theta: number): void {\n this.translation.copy(position);\n this.look.phi = phi;\n this.look.theta = theta;\n this.rotation = quaternionFromLook(this.look);\n }\n\n /** Advance the camera by `delta` seconds of input. */\n update(delta: number): void {\n // Disabled: another driver owns the camera this frame. Drop any accumulated\n // pointer delta so look doesn't jump when control is handed back.\n if (!this.enabled) {\n commitMouseFrame(this.mouse);\n return;\n }\n // Accumulate look from every source into the angles, then derive rotation.\n if (this.mouse.dragging) updateLookFromMouse(this.mouse, this.look);\n this.look.phi += yawFromKeyboard(this.keyboard, delta);\n updateLookFromTouch(this.input, this.look, delta, this.lookInvert);\n this.rotation = quaternionFromLook(this.look);\n\n const step = movementFromKeyboard(this.keyboard, this.look.phi, delta).add(\n movementFromTouch(this.input, this.look.phi, delta),\n );\n this.translation = resolveMove(\n this.translation,\n step,\n this.colliders,\n this.playerRadius,\n this.boundary,\n );\n // Follow the terrain: keep the eye a fixed height above the ground below.\n if (this.groundHeight) {\n this.translation.y = this.groundHeight(this.translation.x, this.translation.z) + this.eyeHeight;\n }\n if (step.x !== 0 || step.z !== 0) this.headBob.active = true;\n updateHeadBob(this.headBob, delta);\n\n this.camera.quaternion.copy(this.rotation);\n this.camera.position.copy(this.translation);\n this.camera.position.y += headBobOffset(this.headBob);\n\n const target = new Vector3(0, 0, -1)\n .applyQuaternion(this.rotation)\n .multiplyScalar(100)\n .add(this.translation);\n this.camera.lookAt(target);\n\n commitMouseFrame(this.mouse);\n }\n}\n","import {\n COLLIDERS,\n ROAM_CONTROL,\n TERRAIN,\n type Collider,\n type RoamControl,\n type SystemPlugin,\n type TerrainCapability,\n} from \"@scenography/engine\";\nimport type { Scene } from \"@scenography/scene\";\nimport { Vector3 } from \"three\";\n\nimport { FreeRoamControls } from \"./FreeRoamControls\";\n\n/**\n * First-person camera — a *system* plugin. It drives the kernel camera from\n * WASD/drag plus the shared analog input (joysticks), keeps it at `eyeHeight`\n * over the terrain, and blocks it against wall colliders. Consumes the `terrain`\n * and `colliders` capabilities (live, so it tracks async heightmaps and hot wall\n * edits) and publishes `roamControl` so a framing/tour system can suspend it.\n */\nexport const fpc = (): SystemPlugin => ({\n name: \"fpc\",\n setup: (ctx) => {\n const { camera, canvas, input, capabilities, scene } = ctx;\n const terrainHeight = (x: number, z: number): number =>\n capabilities.get<TerrainCapability>(TERRAIN)?.heightAt(x, z) ?? 0;\n // Follow terrain only when some plugin provides it; otherwise keep a fixed Y.\n const groundHeight = (): ((x: number, z: number) => number) | undefined =>\n capabilities.get<TerrainCapability>(TERRAIN) ? terrainHeight : undefined;\n\n const start = scene.camera.start;\n const controls = new FreeRoamControls(camera, canvas, input, {\n position: new Vector3(start.position[0], start.position[1], start.position[2]),\n yaw: start.yaw,\n boundary: scene.boundary,\n eyeHeight: scene.camera.eyeHeight,\n groundHeight: groundHeight(),\n });\n controls.attach();\n capabilities.provide(ROAM_CONTROL, controls satisfies RoamControl);\n\n // Eye height: an explicit `camera.eyeHeight` wins. Otherwise it was derived\n // from the start Y over the ground — but a floor heightmap loads async, so\n // recalibrate once the terrain is real, or the visitor walks in the air.\n const calibrateEyeHeight = (s: Scene): void => {\n if (s.camera.eyeHeight !== undefined) controls.setEyeHeight(s.camera.eyeHeight);\n else {\n const terrain = capabilities.get<TerrainCapability>(TERRAIN);\n if (terrain) void terrain.ready.then(() => controls.recalibrateEyeHeight());\n }\n };\n calibrateEyeHeight(scene);\n\n return {\n update: (deltaMs) => {\n const flags = ctx.controlFlags();\n controls.setLookInvert(flags.invertLookX, flags.invertLookY);\n controls.setColliders(capabilities.get<Collider[]>(COLLIDERS) ?? []);\n controls.update(deltaMs / 1000);\n },\n setScene: (s) => {\n controls.setBoundary(s.boundary);\n controls.setGroundHeight(groundHeight() ?? null);\n calibrateEyeHeight(s);\n },\n dispose: () => {\n controls.detach();\n capabilities.revoke(ROAM_CONTROL);\n },\n };\n },\n});\n"],"mappings":"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"}
package/dist/index.d.cts CHANGED
@@ -69,6 +69,7 @@ declare class FreeRoamControls {
69
69
  private rotation;
70
70
  private translation;
71
71
  private enabled;
72
+ private lookInvert;
72
73
  private boundary;
73
74
  private groundHeight;
74
75
  private readonly start;
@@ -100,6 +101,8 @@ declare class FreeRoamControls {
100
101
  * syncing the pose with `setPose` so free-roam continues from there.
101
102
  */
102
103
  setEnabled(enabled: boolean): void;
104
+ /** Invert the analog look per axis (the visitor's control preference). */
105
+ setLookInvert(x: boolean, y: boolean): void;
103
106
  /** Current pose: world position plus yaw (`phi`) and pitch (`theta`) in radians. */
104
107
  getPose(): {
105
108
  position: Vector3;
package/dist/index.d.mts CHANGED
@@ -69,6 +69,7 @@ declare class FreeRoamControls {
69
69
  private rotation;
70
70
  private translation;
71
71
  private enabled;
72
+ private lookInvert;
72
73
  private boundary;
73
74
  private groundHeight;
74
75
  private readonly start;
@@ -100,6 +101,8 @@ declare class FreeRoamControls {
100
101
  * syncing the pose with `setPose` so free-roam continues from there.
101
102
  */
102
103
  setEnabled(enabled: boolean): void;
104
+ /** Invert the analog look per axis (the visitor's control preference). */
105
+ setLookInvert(x: boolean, y: boolean): void;
103
106
  /** Current pose: world position plus yaw (`phi`) and pitch (`theta`) in radians. */
104
107
  getPose(): {
105
108
  position: Vector3;
package/dist/index.mjs CHANGED
@@ -159,10 +159,15 @@ const movementFromTouch = (state, phi, delta) => {
159
159
  return movementFromAxis(state.moveForward, -state.moveStrafe, phi, delta);
160
160
  };
161
161
  /** Integrate the look joystick into the look angles, mutating `look` in place. */
162
- const updateLookFromTouch = (state, look, delta) => {
162
+ const updateLookFromTouch = (state, look, delta, invert = {
163
+ x: false,
164
+ y: false
165
+ }) => {
163
166
  if (state.lookX === 0 && state.lookY === 0) return;
164
- look.phi -= state.lookX * LOOK_SPEED * delta;
165
- look.theta = clamp(look.theta + state.lookY * LOOK_SPEED * delta, -MAX_PITCH, MAX_PITCH);
167
+ const sx = invert.x ? -1 : 1;
168
+ const sy = invert.y ? -1 : 1;
169
+ look.phi -= state.lookX * sx * LOOK_SPEED * delta;
170
+ look.theta = clamp(look.theta + state.lookY * sy * LOOK_SPEED * delta, -MAX_PITCH, MAX_PITCH);
166
171
  };
167
172
  //#endregion
168
173
  //#region src/FreeRoamControls.ts
@@ -192,6 +197,10 @@ var FreeRoamControls = class {
192
197
  rotation = new Quaternion();
193
198
  translation = new Vector3(0, 170, 0);
194
199
  enabled = true;
200
+ lookInvert = {
201
+ x: false,
202
+ y: false
203
+ };
195
204
  boundary;
196
205
  groundHeight;
197
206
  start;
@@ -269,6 +278,13 @@ var FreeRoamControls = class {
269
278
  setEnabled(enabled) {
270
279
  this.enabled = enabled;
271
280
  }
281
+ /** Invert the analog look per axis (the visitor's control preference). */
282
+ setLookInvert(x, y) {
283
+ this.lookInvert = {
284
+ x,
285
+ y
286
+ };
287
+ }
272
288
  /** Current pose: world position plus yaw (`phi`) and pitch (`theta`) in radians. */
273
289
  getPose() {
274
290
  return {
@@ -292,7 +308,7 @@ var FreeRoamControls = class {
292
308
  }
293
309
  if (this.mouse.dragging) updateLookFromMouse(this.mouse, this.look);
294
310
  this.look.phi += yawFromKeyboard(this.keyboard, delta);
295
- updateLookFromTouch(this.input, this.look, delta);
311
+ updateLookFromTouch(this.input, this.look, delta, this.lookInvert);
296
312
  this.rotation = quaternionFromLook(this.look);
297
313
  const step = movementFromKeyboard(this.keyboard, this.look.phi, delta).add(movementFromTouch(this.input, this.look.phi, delta));
298
314
  this.translation = resolveMove(this.translation, step, this.colliders, this.playerRadius, this.boundary);
@@ -342,6 +358,8 @@ const fpc = () => ({
342
358
  calibrateEyeHeight(scene);
343
359
  return {
344
360
  update: (deltaMs) => {
361
+ const flags = ctx.controlFlags();
362
+ controls.setLookInvert(flags.invertLookX, flags.invertLookY);
345
363
  controls.setColliders(capabilities.get(COLLIDERS) ?? []);
346
364
  controls.update(deltaMs / 1e3);
347
365
  },
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/collision.ts","../src/headBob.ts","../src/keyboard.ts","../src/mouse.ts","../src/touch.ts","../src/FreeRoamControls.ts","../src/plugin.ts"],"sourcesContent":["import { Vector3 } from \"three\";\nimport type { Vec2 } from \"@scenography/scene\";\nimport type { Collider } from \"@scenography/engine\";\n\n/** Whether `(x, z)` lies inside the XZ polygon (even-odd ray casting). */\nexport const insidePolygon = (x: number, z: number, polygon: Vec2[]): boolean => {\n let inside = false;\n for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {\n const a = polygon[i];\n const b = polygon[j];\n if (!a || !b) continue; // unreachable given the loop bounds; satisfies strict indexing\n if (a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]) {\n inside = !inside;\n }\n }\n return inside;\n};\n\n/** Whether a circle of `radius` centered at `(x, z)` overlaps any collider box. */\nconst hits = (x: number, z: number, radius: number, colliders: Collider[]): boolean => {\n const r2 = radius * radius;\n for (const c of colliders) {\n const px = x - c.cx;\n const pz = z - c.cz;\n // Project onto the box axes: along its length and along its thickness normal.\n const along = px * c.dx + pz * c.dz;\n const perp = px * -c.dz + pz * c.dx;\n // Distance from the point to the closest point on the box.\n const ddAlong = along - Math.max(-c.halfLength, Math.min(c.halfLength, along));\n const ddPerp = perp - Math.max(-c.halfThickness, Math.min(c.halfThickness, perp));\n if (ddAlong * ddAlong + ddPerp * ddPerp < r2) return true;\n }\n return false;\n};\n\n/**\n * Resolve a desired XZ step against wall colliders and an optional walkable-area\n * boundary, one axis at a time so the camera slides along an obstacle instead of\n * sticking when it can't move straight.\n */\nexport const resolveMove = (\n current: Vector3,\n step: Vector3,\n colliders: Collider[],\n radius: number,\n boundary?: Vec2[],\n): Vector3 => {\n let { x, z } = current;\n const free = (nx: number, nz: number): boolean =>\n !hits(nx, nz, radius, colliders) && (!boundary || insidePolygon(nx, nz, boundary));\n if (step.x !== 0 && free(x + step.x, z)) x += step.x;\n if (step.z !== 0 && free(x, z + step.z)) z += step.z;\n return new Vector3(x, current.y, z);\n};\n","/** State for the subtle vertical bob applied while walking. */\nexport interface HeadBobState {\n active: boolean;\n timer: number;\n}\n\nexport const createHeadBobState = (): HeadBobState => ({ active: false, timer: 0 });\n\n/**\n * Advance the head-bob timer by `elapsed` seconds, snapping to half-wavelength\n * steps so the bob always settles back to neutral when movement stops.\n */\nexport const updateHeadBob = (state: HeadBobState, elapsed: number): void => {\n if (!state.active) return;\n\n const wavelength = Math.PI;\n const nextStep = 1 + Math.floor(((state.timer + 1e-6) * 10) / wavelength);\n const nextStepTime = (nextStep * wavelength) / 10;\n state.timer = Math.min(state.timer + elapsed, nextStepTime);\n\n if (state.timer === nextStepTime) state.active = false;\n};\n\n/** Vertical offset to add to the camera, in world units. */\nexport const headBobOffset = (state: HeadBobState): number => Math.sin(state.timer * 10) * 1.5;\n","import { Quaternion, Vector3 } from \"three\";\n\n/** Set of currently-pressed key codes. */\nexport type KeyboardState = Record<string, boolean>;\n\nexport const createKeyboardState = (): KeyboardState => ({});\n\n/** Codes the controls consume; preventDefault'd so the arrows don't scroll. */\nconst MOVEMENT_KEYS = new Set([\n \"KeyW\",\n \"KeyA\",\n \"KeyS\",\n \"KeyD\",\n \"ArrowUp\",\n \"ArrowDown\",\n \"ArrowLeft\",\n \"ArrowRight\",\n]);\n\nexport const onKeyDown = (state: KeyboardState, event: KeyboardEvent): void => {\n state[event.code] = true;\n if (MOVEMENT_KEYS.has(event.code)) event.preventDefault();\n};\n\nexport const onKeyUp = (state: KeyboardState, event: KeyboardEvent): void => {\n state[event.code] = false;\n};\n\n/** Yaw turn rate for the Q/E keys, in radians per second. */\nconst KEY_TURN_SPEED = 1.8;\n\n/**\n * Turn a forward/strafe pair (each in `-1..1`) into a per-frame translation\n * oriented by the current yaw (`phi`). Shared by keyboard and touch input.\n */\nexport const movementFromAxis = (\n forwardVelocity: number,\n strafeVelocity: number,\n phi: number,\n delta: number,\n boosted = false,\n): Vector3 => {\n const factor = boosted ? 60 : 20;\n const yaw = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), phi);\n\n const forward = new Vector3(0, 0, -10)\n .applyQuaternion(yaw)\n .multiplyScalar(forwardVelocity * delta * factor);\n const left = new Vector3(-10, 0, 0)\n .applyQuaternion(yaw)\n .multiplyScalar(strafeVelocity * delta * factor);\n\n return forward.add(left);\n};\n\n/**\n * Compute the per-frame translation from WASD or arrow-key input, oriented by\n * the current yaw (`phi`). Holding Shift acts as a speed booster.\n */\nexport const movementFromKeyboard = (state: KeyboardState, phi: number, delta: number): Vector3 => {\n const has = (...codes: string[]): number => (codes.some((c) => state[c]) ? 1 : 0);\n const forwardVelocity = has(\"KeyW\", \"ArrowUp\") - has(\"KeyS\", \"ArrowDown\");\n const strafeVelocity = has(\"KeyA\", \"ArrowLeft\") - has(\"KeyD\", \"ArrowRight\");\n const boosted = Boolean(state[\"ShiftLeft\"] || state[\"ShiftRight\"]);\n\n return movementFromAxis(forwardVelocity, strafeVelocity, phi, delta, boosted);\n};\n\n/**\n * Per-frame yaw delta (radians) from the Q/E turn keys. Q turns left\n * (increasing yaw), E turns right.\n */\nexport const yawFromKeyboard = (state: KeyboardState, delta: number): number => {\n const turn = (state[\"KeyQ\"] ? 1 : 0) - (state[\"KeyE\"] ? 1 : 0);\n return turn * KEY_TURN_SPEED * delta;\n};\n","import { Quaternion, Vector2, Vector3 } from \"three\";\n\n/** Mutable pointer state accumulated between frames. */\nexport interface MouseState {\n /** Whether the primary button is held (drag-to-look). */\n dragging: boolean;\n pos: Vector2;\n prevPos: Vector2;\n delta: Vector2;\n}\n\n/** Look angles driven by the pointer. */\nexport interface LookState {\n phi: number;\n theta: number;\n}\n\nconst PHI_SPEED = 8;\nconst THETA_SPEED = 5;\n\nexport const createMouseState = (): MouseState => ({\n dragging: false,\n pos: new Vector2(),\n prevPos: new Vector2(),\n delta: new Vector2(),\n});\n\nexport const clamp = (x: number, a: number, b: number): number => Math.min(Math.max(x, a), b);\n\n/** Maximum up/down look angle (radians) — keeps the camera from flipping over. */\nexport const MAX_PITCH = Math.PI / 3;\n\n/** Build the look quaternion from the current yaw/pitch angles. */\nexport const quaternionFromLook = (look: LookState): Quaternion => {\n const qy = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), look.phi);\n const qx = new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), look.theta);\n return new Quaternion().multiply(qy).multiply(qx);\n};\n\nexport const onMouseDown = (state: MouseState, event: MouseEvent): void => {\n state.dragging = event.button === 0;\n};\n\nexport const onMouseUp = (state: MouseState): void => {\n state.dragging = false;\n};\n\nexport const onMouseMove = (state: MouseState, event: MouseEvent): void => {\n state.pos.set(event.pageX - window.innerWidth / 2, event.pageY - window.innerHeight / 2);\n state.delta.subVectors(state.pos, state.prevPos);\n};\n\nexport const commitMouseFrame = (state: MouseState): void => {\n state.prevPos.copy(state.pos);\n state.delta.set(0, 0);\n};\n\n/**\n * Integrate the pointer delta into the look angles. Mutates `look` in place.\n * Yaw is unbounded; pitch is clamped to avoid flipping over.\n */\nexport const updateLookFromMouse = (mouse: MouseState, look: LookState): void => {\n const xh = mouse.delta.x / window.innerWidth;\n const yh = mouse.delta.y / window.innerHeight;\n\n look.phi += -xh * PHI_SPEED;\n look.theta = clamp(look.theta + -yh * THETA_SPEED, -MAX_PITCH, MAX_PITCH);\n};\n","import { Vector3 } from \"three\";\n\nimport { movementFromAxis } from \"./keyboard\";\nimport { clamp, MAX_PITCH, type LookState } from \"./mouse\";\n\n/**\n * The shape of the shared analog input fpc reads (the kernel's `InputBus`): the\n * move/look axes a joystick feeds. Structural, so any producer with these fields\n * works. fpc interprets them as walking + turning; another camera might not.\n */\nexport interface AnalogInput {\n /** Forward (`+1`) / backward (`-1`) movement. */\n moveForward: number;\n /** Strafe right (`+1`) / left (`-1`). */\n moveStrafe: number;\n /** Look right (`+1`) / left (`-1`). */\n lookX: number;\n /** Look up (`+1`) / down (`-1`). */\n lookY: number;\n}\n\n/** Look turn rate driven by the joystick, in radians per second at full deflection. */\nconst LOOK_SPEED = 2.2;\n\n/** Per-frame translation from the analog move axes, oriented by yaw (`phi`). */\nexport const movementFromTouch = (state: AnalogInput, phi: number, delta: number): Vector3 => {\n if (state.moveForward === 0 && state.moveStrafe === 0) return new Vector3();\n // Strafe sign matches the keyboard helper (left strafe is +1 there).\n return movementFromAxis(state.moveForward, -state.moveStrafe, phi, delta);\n};\n\n/** Integrate the look joystick into the look angles, mutating `look` in place. */\nexport const updateLookFromTouch = (state: AnalogInput, look: LookState, delta: number): void => {\n if (state.lookX === 0 && state.lookY === 0) return;\n look.phi -= state.lookX * LOOK_SPEED * delta;\n look.theta = clamp(look.theta + state.lookY * LOOK_SPEED * delta, -MAX_PITCH, MAX_PITCH);\n};\n","import { Quaternion, Vector3, type PerspectiveCamera } from \"three\";\nimport type { Vec2 } from \"@scenography/scene\";\nimport type { Collider } from \"@scenography/engine\";\n\nimport { resolveMove } from \"./collision\";\nimport { createHeadBobState, headBobOffset, updateHeadBob } from \"./headBob\";\nimport {\n createKeyboardState,\n movementFromKeyboard,\n onKeyDown,\n onKeyUp,\n yawFromKeyboard,\n} from \"./keyboard\";\nimport {\n commitMouseFrame,\n createMouseState,\n onMouseDown,\n onMouseMove,\n onMouseUp,\n quaternionFromLook,\n updateLookFromMouse,\n type LookState,\n} from \"./mouse\";\nimport { movementFromTouch, updateLookFromTouch, type AnalogInput } from \"./touch\";\n\nexport interface FreeRoamOptions {\n /** Wall collision boxes the camera can't pass through. */\n colliders?: Collider[];\n /** Camera collision radius in world units. Defaults to `25`. */\n playerRadius?: number;\n /** Starting position. */\n position?: Vector3;\n /** Starting yaw in radians. */\n yaw?: number;\n /** Walkable-area polygon (XZ ring); the camera can't step outside it. */\n boundary?: Vec2[];\n /** Ground height at `(x, z)`; when set the camera follows the terrain. */\n groundHeight?: (x: number, z: number) => number;\n /** Eye height above the ground. Defaults to the start Y over the start ground. */\n eyeHeight?: number;\n}\n\n/**\n * First-person, free-roaming camera controls: drag-to-look with the mouse, WASD\n * to walk, a subtle head-bob while moving, wall collision and terrain following.\n * The shared analog input (`AnalogInput`, the kernel's InputBus) is folded in on\n * top of keyboard/mouse, so on-screen joysticks drive it too. It keeps the\n * camera upright (no roll) and clamps pitch — the first-person contract.\n *\n * Framework-agnostic: construct it with a camera, the canvas and the input,\n * call `attach()`, then `update(delta)` once per frame.\n */\nexport class FreeRoamControls {\n private colliders: Collider[];\n private readonly playerRadius: number;\n\n private readonly camera: PerspectiveCamera;\n private readonly domElement: HTMLElement;\n private readonly input: AnalogInput;\n private readonly mouse = createMouseState();\n private readonly keyboard = createKeyboardState();\n private readonly headBob = createHeadBobState();\n private readonly look: LookState = { phi: 0, theta: 0 };\n private rotation = new Quaternion();\n private translation = new Vector3(0, 170, 0);\n private enabled = true;\n private boundary: Vec2[] | undefined;\n private groundHeight: ((x: number, z: number) => number) | null;\n private readonly start: Vector3;\n private eyeHeight: number;\n\n private readonly handlers: Array<[EventTarget, string, EventListener]> = [];\n\n constructor(\n camera: PerspectiveCamera,\n domElement: HTMLElement,\n input: AnalogInput,\n options: FreeRoamOptions = {},\n ) {\n this.camera = camera;\n this.domElement = domElement;\n this.input = input;\n this.colliders = options.colliders ?? [];\n this.playerRadius = options.playerRadius ?? 25;\n if (options.position) this.translation.copy(options.position);\n if (options.yaw !== undefined) this.look.phi = options.yaw;\n this.rotation.setFromAxisAngle(new Vector3(0, 1, 0), this.look.phi);\n this.boundary = options.boundary;\n this.groundHeight = options.groundHeight ?? null;\n this.start = this.translation.clone();\n // Eye height = start Y above the ground under the start position.\n const groundAtStart = this.groundHeight?.(this.start.x, this.start.z) ?? 0;\n this.eyeHeight = options.eyeHeight ?? this.start.y - groundAtStart;\n }\n\n /** Current eye height above the ground (world units). */\n getEyeHeight(): number {\n return this.eyeHeight;\n }\n\n /** Fix the eye height above the ground (world units). */\n setEyeHeight(eyeHeight: number): void {\n this.eyeHeight = eyeHeight;\n }\n\n /**\n * Re-derive the default eye height (start Y over the ground under the start\n * position). Call once asynchronous ground data (a floor heightmap) has\n * loaded: the value captured at construction was measured against the\n * not-yet-displaced floor.\n */\n recalibrateEyeHeight(): void {\n const ground = this.groundHeight?.(this.start.x, this.start.z) ?? 0;\n this.eyeHeight = this.start.y - ground;\n }\n\n attach(): void {\n const bind = (target: EventTarget, type: string, listener: EventListener): void => {\n target.addEventListener(type, listener);\n this.handlers.push([target, type, listener]);\n };\n\n bind(this.domElement, \"mousedown\", (e) => onMouseDown(this.mouse, e as MouseEvent));\n bind(window, \"mousemove\", (e) => onMouseMove(this.mouse, e as MouseEvent));\n bind(window, \"mouseup\", () => onMouseUp(this.mouse));\n bind(window, \"keydown\", (e) => onKeyDown(this.keyboard, e as KeyboardEvent));\n bind(window, \"keyup\", (e) => onKeyUp(this.keyboard, e as KeyboardEvent));\n }\n\n detach(): void {\n for (const [target, type, listener] of this.handlers)\n target.removeEventListener(type, listener);\n this.handlers.length = 0;\n }\n\n /** Replace the wall collision set (e.g. after the scene's walls change). */\n setColliders(colliders: Collider[]): void {\n this.colliders = colliders;\n }\n\n /** Replace the walkable-area boundary polygon (or clear it with `undefined`). */\n setBoundary(boundary: Vec2[] | undefined): void {\n this.boundary = boundary;\n }\n\n /** Replace the ground-height function (or clear it to stop following terrain). */\n setGroundHeight(groundHeight: ((x: number, z: number) => number) | null): void {\n this.groundHeight = groundHeight;\n }\n\n /**\n * Enable/disable user input. While disabled, `update()` leaves the camera\n * untouched so another driver (e.g. a camera tween) can own it; re-enable after\n * syncing the pose with `setPose` so free-roam continues from there.\n */\n setEnabled(enabled: boolean): void {\n this.enabled = enabled;\n }\n\n /** Current pose: world position plus yaw (`phi`) and pitch (`theta`) in radians. */\n getPose(): { position: Vector3; phi: number; theta: number } {\n return { position: this.translation.clone(), phi: this.look.phi, theta: this.look.theta };\n }\n\n /** Force the pose (e.g. at the end of a camera tween) so input resumes from it. */\n setPose(position: Vector3, phi: number, theta: number): void {\n this.translation.copy(position);\n this.look.phi = phi;\n this.look.theta = theta;\n this.rotation = quaternionFromLook(this.look);\n }\n\n /** Advance the camera by `delta` seconds of input. */\n update(delta: number): void {\n // Disabled: another driver owns the camera this frame. Drop any accumulated\n // pointer delta so look doesn't jump when control is handed back.\n if (!this.enabled) {\n commitMouseFrame(this.mouse);\n return;\n }\n // Accumulate look from every source into the angles, then derive rotation.\n if (this.mouse.dragging) updateLookFromMouse(this.mouse, this.look);\n this.look.phi += yawFromKeyboard(this.keyboard, delta);\n updateLookFromTouch(this.input, this.look, delta);\n this.rotation = quaternionFromLook(this.look);\n\n const step = movementFromKeyboard(this.keyboard, this.look.phi, delta).add(\n movementFromTouch(this.input, this.look.phi, delta),\n );\n this.translation = resolveMove(\n this.translation,\n step,\n this.colliders,\n this.playerRadius,\n this.boundary,\n );\n // Follow the terrain: keep the eye a fixed height above the ground below.\n if (this.groundHeight) {\n this.translation.y = this.groundHeight(this.translation.x, this.translation.z) + this.eyeHeight;\n }\n if (step.x !== 0 || step.z !== 0) this.headBob.active = true;\n updateHeadBob(this.headBob, delta);\n\n this.camera.quaternion.copy(this.rotation);\n this.camera.position.copy(this.translation);\n this.camera.position.y += headBobOffset(this.headBob);\n\n const target = new Vector3(0, 0, -1)\n .applyQuaternion(this.rotation)\n .multiplyScalar(100)\n .add(this.translation);\n this.camera.lookAt(target);\n\n commitMouseFrame(this.mouse);\n }\n}\n","import {\n COLLIDERS,\n ROAM_CONTROL,\n TERRAIN,\n type Collider,\n type RoamControl,\n type SystemPlugin,\n type TerrainCapability,\n} from \"@scenography/engine\";\nimport type { Scene } from \"@scenography/scene\";\nimport { Vector3 } from \"three\";\n\nimport { FreeRoamControls } from \"./FreeRoamControls\";\n\n/**\n * First-person camera — a *system* plugin. It drives the kernel camera from\n * WASD/drag plus the shared analog input (joysticks), keeps it at `eyeHeight`\n * over the terrain, and blocks it against wall colliders. Consumes the `terrain`\n * and `colliders` capabilities (live, so it tracks async heightmaps and hot wall\n * edits) and publishes `roamControl` so a framing/tour system can suspend it.\n */\nexport const fpc = (): SystemPlugin => ({\n name: \"fpc\",\n setup: (ctx) => {\n const { camera, canvas, input, capabilities, scene } = ctx;\n const terrainHeight = (x: number, z: number): number =>\n capabilities.get<TerrainCapability>(TERRAIN)?.heightAt(x, z) ?? 0;\n // Follow terrain only when some plugin provides it; otherwise keep a fixed Y.\n const groundHeight = (): ((x: number, z: number) => number) | undefined =>\n capabilities.get<TerrainCapability>(TERRAIN) ? terrainHeight : undefined;\n\n const start = scene.camera.start;\n const controls = new FreeRoamControls(camera, canvas, input, {\n position: new Vector3(start.position[0], start.position[1], start.position[2]),\n yaw: start.yaw,\n boundary: scene.boundary,\n eyeHeight: scene.camera.eyeHeight,\n groundHeight: groundHeight(),\n });\n controls.attach();\n capabilities.provide(ROAM_CONTROL, controls satisfies RoamControl);\n\n // Eye height: an explicit `camera.eyeHeight` wins. Otherwise it was derived\n // from the start Y over the ground — but a floor heightmap loads async, so\n // recalibrate once the terrain is real, or the visitor walks in the air.\n const calibrateEyeHeight = (s: Scene): void => {\n if (s.camera.eyeHeight !== undefined) controls.setEyeHeight(s.camera.eyeHeight);\n else {\n const terrain = capabilities.get<TerrainCapability>(TERRAIN);\n if (terrain) void terrain.ready.then(() => controls.recalibrateEyeHeight());\n }\n };\n calibrateEyeHeight(scene);\n\n return {\n update: (deltaMs) => {\n controls.setColliders(capabilities.get<Collider[]>(COLLIDERS) ?? []);\n controls.update(deltaMs / 1000);\n },\n setScene: (s) => {\n controls.setBoundary(s.boundary);\n controls.setGroundHeight(groundHeight() ?? null);\n calibrateEyeHeight(s);\n },\n dispose: () => {\n controls.detach();\n capabilities.revoke(ROAM_CONTROL);\n },\n };\n },\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/collision.ts","../src/headBob.ts","../src/keyboard.ts","../src/mouse.ts","../src/touch.ts","../src/FreeRoamControls.ts","../src/plugin.ts"],"sourcesContent":["import { Vector3 } from \"three\";\nimport type { Vec2 } from \"@scenography/scene\";\nimport type { Collider } from \"@scenography/engine\";\n\n/** Whether `(x, z)` lies inside the XZ polygon (even-odd ray casting). */\nexport const insidePolygon = (x: number, z: number, polygon: Vec2[]): boolean => {\n let inside = false;\n for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {\n const a = polygon[i];\n const b = polygon[j];\n if (!a || !b) continue; // unreachable given the loop bounds; satisfies strict indexing\n if (a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]) {\n inside = !inside;\n }\n }\n return inside;\n};\n\n/** Whether a circle of `radius` centered at `(x, z)` overlaps any collider box. */\nconst hits = (x: number, z: number, radius: number, colliders: Collider[]): boolean => {\n const r2 = radius * radius;\n for (const c of colliders) {\n const px = x - c.cx;\n const pz = z - c.cz;\n // Project onto the box axes: along its length and along its thickness normal.\n const along = px * c.dx + pz * c.dz;\n const perp = px * -c.dz + pz * c.dx;\n // Distance from the point to the closest point on the box.\n const ddAlong = along - Math.max(-c.halfLength, Math.min(c.halfLength, along));\n const ddPerp = perp - Math.max(-c.halfThickness, Math.min(c.halfThickness, perp));\n if (ddAlong * ddAlong + ddPerp * ddPerp < r2) return true;\n }\n return false;\n};\n\n/**\n * Resolve a desired XZ step against wall colliders and an optional walkable-area\n * boundary, one axis at a time so the camera slides along an obstacle instead of\n * sticking when it can't move straight.\n */\nexport const resolveMove = (\n current: Vector3,\n step: Vector3,\n colliders: Collider[],\n radius: number,\n boundary?: Vec2[],\n): Vector3 => {\n let { x, z } = current;\n const free = (nx: number, nz: number): boolean =>\n !hits(nx, nz, radius, colliders) && (!boundary || insidePolygon(nx, nz, boundary));\n if (step.x !== 0 && free(x + step.x, z)) x += step.x;\n if (step.z !== 0 && free(x, z + step.z)) z += step.z;\n return new Vector3(x, current.y, z);\n};\n","/** State for the subtle vertical bob applied while walking. */\nexport interface HeadBobState {\n active: boolean;\n timer: number;\n}\n\nexport const createHeadBobState = (): HeadBobState => ({ active: false, timer: 0 });\n\n/**\n * Advance the head-bob timer by `elapsed` seconds, snapping to half-wavelength\n * steps so the bob always settles back to neutral when movement stops.\n */\nexport const updateHeadBob = (state: HeadBobState, elapsed: number): void => {\n if (!state.active) return;\n\n const wavelength = Math.PI;\n const nextStep = 1 + Math.floor(((state.timer + 1e-6) * 10) / wavelength);\n const nextStepTime = (nextStep * wavelength) / 10;\n state.timer = Math.min(state.timer + elapsed, nextStepTime);\n\n if (state.timer === nextStepTime) state.active = false;\n};\n\n/** Vertical offset to add to the camera, in world units. */\nexport const headBobOffset = (state: HeadBobState): number => Math.sin(state.timer * 10) * 1.5;\n","import { Quaternion, Vector3 } from \"three\";\n\n/** Set of currently-pressed key codes. */\nexport type KeyboardState = Record<string, boolean>;\n\nexport const createKeyboardState = (): KeyboardState => ({});\n\n/** Codes the controls consume; preventDefault'd so the arrows don't scroll. */\nconst MOVEMENT_KEYS = new Set([\n \"KeyW\",\n \"KeyA\",\n \"KeyS\",\n \"KeyD\",\n \"ArrowUp\",\n \"ArrowDown\",\n \"ArrowLeft\",\n \"ArrowRight\",\n]);\n\nexport const onKeyDown = (state: KeyboardState, event: KeyboardEvent): void => {\n state[event.code] = true;\n if (MOVEMENT_KEYS.has(event.code)) event.preventDefault();\n};\n\nexport const onKeyUp = (state: KeyboardState, event: KeyboardEvent): void => {\n state[event.code] = false;\n};\n\n/** Yaw turn rate for the Q/E keys, in radians per second. */\nconst KEY_TURN_SPEED = 1.8;\n\n/**\n * Turn a forward/strafe pair (each in `-1..1`) into a per-frame translation\n * oriented by the current yaw (`phi`). Shared by keyboard and touch input.\n */\nexport const movementFromAxis = (\n forwardVelocity: number,\n strafeVelocity: number,\n phi: number,\n delta: number,\n boosted = false,\n): Vector3 => {\n const factor = boosted ? 60 : 20;\n const yaw = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), phi);\n\n const forward = new Vector3(0, 0, -10)\n .applyQuaternion(yaw)\n .multiplyScalar(forwardVelocity * delta * factor);\n const left = new Vector3(-10, 0, 0)\n .applyQuaternion(yaw)\n .multiplyScalar(strafeVelocity * delta * factor);\n\n return forward.add(left);\n};\n\n/**\n * Compute the per-frame translation from WASD or arrow-key input, oriented by\n * the current yaw (`phi`). Holding Shift acts as a speed booster.\n */\nexport const movementFromKeyboard = (state: KeyboardState, phi: number, delta: number): Vector3 => {\n const has = (...codes: string[]): number => (codes.some((c) => state[c]) ? 1 : 0);\n const forwardVelocity = has(\"KeyW\", \"ArrowUp\") - has(\"KeyS\", \"ArrowDown\");\n const strafeVelocity = has(\"KeyA\", \"ArrowLeft\") - has(\"KeyD\", \"ArrowRight\");\n const boosted = Boolean(state[\"ShiftLeft\"] || state[\"ShiftRight\"]);\n\n return movementFromAxis(forwardVelocity, strafeVelocity, phi, delta, boosted);\n};\n\n/**\n * Per-frame yaw delta (radians) from the Q/E turn keys. Q turns left\n * (increasing yaw), E turns right.\n */\nexport const yawFromKeyboard = (state: KeyboardState, delta: number): number => {\n const turn = (state[\"KeyQ\"] ? 1 : 0) - (state[\"KeyE\"] ? 1 : 0);\n return turn * KEY_TURN_SPEED * delta;\n};\n","import { Quaternion, Vector2, Vector3 } from \"three\";\n\n/** Mutable pointer state accumulated between frames. */\nexport interface MouseState {\n /** Whether the primary button is held (drag-to-look). */\n dragging: boolean;\n pos: Vector2;\n prevPos: Vector2;\n delta: Vector2;\n}\n\n/** Look angles driven by the pointer. */\nexport interface LookState {\n phi: number;\n theta: number;\n}\n\nconst PHI_SPEED = 8;\nconst THETA_SPEED = 5;\n\nexport const createMouseState = (): MouseState => ({\n dragging: false,\n pos: new Vector2(),\n prevPos: new Vector2(),\n delta: new Vector2(),\n});\n\nexport const clamp = (x: number, a: number, b: number): number => Math.min(Math.max(x, a), b);\n\n/** Maximum up/down look angle (radians) — keeps the camera from flipping over. */\nexport const MAX_PITCH = Math.PI / 3;\n\n/** Build the look quaternion from the current yaw/pitch angles. */\nexport const quaternionFromLook = (look: LookState): Quaternion => {\n const qy = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), look.phi);\n const qx = new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), look.theta);\n return new Quaternion().multiply(qy).multiply(qx);\n};\n\nexport const onMouseDown = (state: MouseState, event: MouseEvent): void => {\n state.dragging = event.button === 0;\n};\n\nexport const onMouseUp = (state: MouseState): void => {\n state.dragging = false;\n};\n\nexport const onMouseMove = (state: MouseState, event: MouseEvent): void => {\n state.pos.set(event.pageX - window.innerWidth / 2, event.pageY - window.innerHeight / 2);\n state.delta.subVectors(state.pos, state.prevPos);\n};\n\nexport const commitMouseFrame = (state: MouseState): void => {\n state.prevPos.copy(state.pos);\n state.delta.set(0, 0);\n};\n\n/**\n * Integrate the pointer delta into the look angles. Mutates `look` in place.\n * Yaw is unbounded; pitch is clamped to avoid flipping over.\n */\nexport const updateLookFromMouse = (mouse: MouseState, look: LookState): void => {\n const xh = mouse.delta.x / window.innerWidth;\n const yh = mouse.delta.y / window.innerHeight;\n\n look.phi += -xh * PHI_SPEED;\n look.theta = clamp(look.theta + -yh * THETA_SPEED, -MAX_PITCH, MAX_PITCH);\n};\n","import { Vector3 } from \"three\";\n\nimport { movementFromAxis } from \"./keyboard\";\nimport { clamp, MAX_PITCH, type LookState } from \"./mouse\";\n\n/**\n * The shape of the shared analog input fpc reads (the kernel's `InputBus`): the\n * move/look axes a joystick feeds. Structural, so any producer with these fields\n * works. fpc interprets them as walking + turning; another camera might not.\n */\nexport interface AnalogInput {\n /** Forward (`+1`) / backward (`-1`) movement. */\n moveForward: number;\n /** Strafe right (`+1`) / left (`-1`). */\n moveStrafe: number;\n /** Look right (`+1`) / left (`-1`). */\n lookX: number;\n /** Look up (`+1`) / down (`-1`). */\n lookY: number;\n}\n\n/** Look turn rate driven by the joystick, in radians per second at full deflection. */\nconst LOOK_SPEED = 2.2;\n\n/** Per-frame translation from the analog move axes, oriented by yaw (`phi`). */\nexport const movementFromTouch = (state: AnalogInput, phi: number, delta: number): Vector3 => {\n if (state.moveForward === 0 && state.moveStrafe === 0) return new Vector3();\n // Strafe sign matches the keyboard helper (left strafe is +1 there).\n return movementFromAxis(state.moveForward, -state.moveStrafe, phi, delta);\n};\n\n/** Per-axis inversion of the analog look, for visitors who feel it backwards. */\nexport interface LookInvert {\n /** Invert the horizontal (turn) axis. */\n x: boolean;\n /** Invert the vertical (pitch) axis. */\n y: boolean;\n}\n\n/** Integrate the look joystick into the look angles, mutating `look` in place. */\nexport const updateLookFromTouch = (\n state: AnalogInput,\n look: LookState,\n delta: number,\n invert: LookInvert = { x: false, y: false },\n): void => {\n if (state.lookX === 0 && state.lookY === 0) return;\n const sx = invert.x ? -1 : 1;\n const sy = invert.y ? -1 : 1;\n look.phi -= state.lookX * sx * LOOK_SPEED * delta;\n look.theta = clamp(look.theta + state.lookY * sy * LOOK_SPEED * delta, -MAX_PITCH, MAX_PITCH);\n};\n","import { Quaternion, Vector3, type PerspectiveCamera } from \"three\";\nimport type { Vec2 } from \"@scenography/scene\";\nimport type { Collider } from \"@scenography/engine\";\n\nimport { resolveMove } from \"./collision\";\nimport { createHeadBobState, headBobOffset, updateHeadBob } from \"./headBob\";\nimport {\n createKeyboardState,\n movementFromKeyboard,\n onKeyDown,\n onKeyUp,\n yawFromKeyboard,\n} from \"./keyboard\";\nimport {\n commitMouseFrame,\n createMouseState,\n onMouseDown,\n onMouseMove,\n onMouseUp,\n quaternionFromLook,\n updateLookFromMouse,\n type LookState,\n} from \"./mouse\";\nimport { movementFromTouch, updateLookFromTouch, type AnalogInput, type LookInvert } from \"./touch\";\n\nexport interface FreeRoamOptions {\n /** Wall collision boxes the camera can't pass through. */\n colliders?: Collider[];\n /** Camera collision radius in world units. Defaults to `25`. */\n playerRadius?: number;\n /** Starting position. */\n position?: Vector3;\n /** Starting yaw in radians. */\n yaw?: number;\n /** Walkable-area polygon (XZ ring); the camera can't step outside it. */\n boundary?: Vec2[];\n /** Ground height at `(x, z)`; when set the camera follows the terrain. */\n groundHeight?: (x: number, z: number) => number;\n /** Eye height above the ground. Defaults to the start Y over the start ground. */\n eyeHeight?: number;\n}\n\n/**\n * First-person, free-roaming camera controls: drag-to-look with the mouse, WASD\n * to walk, a subtle head-bob while moving, wall collision and terrain following.\n * The shared analog input (`AnalogInput`, the kernel's InputBus) is folded in on\n * top of keyboard/mouse, so on-screen joysticks drive it too. It keeps the\n * camera upright (no roll) and clamps pitch — the first-person contract.\n *\n * Framework-agnostic: construct it with a camera, the canvas and the input,\n * call `attach()`, then `update(delta)` once per frame.\n */\nexport class FreeRoamControls {\n private colliders: Collider[];\n private readonly playerRadius: number;\n\n private readonly camera: PerspectiveCamera;\n private readonly domElement: HTMLElement;\n private readonly input: AnalogInput;\n private readonly mouse = createMouseState();\n private readonly keyboard = createKeyboardState();\n private readonly headBob = createHeadBobState();\n private readonly look: LookState = { phi: 0, theta: 0 };\n private rotation = new Quaternion();\n private translation = new Vector3(0, 170, 0);\n private enabled = true;\n private lookInvert: LookInvert = { x: false, y: false };\n private boundary: Vec2[] | undefined;\n private groundHeight: ((x: number, z: number) => number) | null;\n private readonly start: Vector3;\n private eyeHeight: number;\n\n private readonly handlers: Array<[EventTarget, string, EventListener]> = [];\n\n constructor(\n camera: PerspectiveCamera,\n domElement: HTMLElement,\n input: AnalogInput,\n options: FreeRoamOptions = {},\n ) {\n this.camera = camera;\n this.domElement = domElement;\n this.input = input;\n this.colliders = options.colliders ?? [];\n this.playerRadius = options.playerRadius ?? 25;\n if (options.position) this.translation.copy(options.position);\n if (options.yaw !== undefined) this.look.phi = options.yaw;\n this.rotation.setFromAxisAngle(new Vector3(0, 1, 0), this.look.phi);\n this.boundary = options.boundary;\n this.groundHeight = options.groundHeight ?? null;\n this.start = this.translation.clone();\n // Eye height = start Y above the ground under the start position.\n const groundAtStart = this.groundHeight?.(this.start.x, this.start.z) ?? 0;\n this.eyeHeight = options.eyeHeight ?? this.start.y - groundAtStart;\n }\n\n /** Current eye height above the ground (world units). */\n getEyeHeight(): number {\n return this.eyeHeight;\n }\n\n /** Fix the eye height above the ground (world units). */\n setEyeHeight(eyeHeight: number): void {\n this.eyeHeight = eyeHeight;\n }\n\n /**\n * Re-derive the default eye height (start Y over the ground under the start\n * position). Call once asynchronous ground data (a floor heightmap) has\n * loaded: the value captured at construction was measured against the\n * not-yet-displaced floor.\n */\n recalibrateEyeHeight(): void {\n const ground = this.groundHeight?.(this.start.x, this.start.z) ?? 0;\n this.eyeHeight = this.start.y - ground;\n }\n\n attach(): void {\n const bind = (target: EventTarget, type: string, listener: EventListener): void => {\n target.addEventListener(type, listener);\n this.handlers.push([target, type, listener]);\n };\n\n bind(this.domElement, \"mousedown\", (e) => onMouseDown(this.mouse, e as MouseEvent));\n bind(window, \"mousemove\", (e) => onMouseMove(this.mouse, e as MouseEvent));\n bind(window, \"mouseup\", () => onMouseUp(this.mouse));\n bind(window, \"keydown\", (e) => onKeyDown(this.keyboard, e as KeyboardEvent));\n bind(window, \"keyup\", (e) => onKeyUp(this.keyboard, e as KeyboardEvent));\n }\n\n detach(): void {\n for (const [target, type, listener] of this.handlers)\n target.removeEventListener(type, listener);\n this.handlers.length = 0;\n }\n\n /** Replace the wall collision set (e.g. after the scene's walls change). */\n setColliders(colliders: Collider[]): void {\n this.colliders = colliders;\n }\n\n /** Replace the walkable-area boundary polygon (or clear it with `undefined`). */\n setBoundary(boundary: Vec2[] | undefined): void {\n this.boundary = boundary;\n }\n\n /** Replace the ground-height function (or clear it to stop following terrain). */\n setGroundHeight(groundHeight: ((x: number, z: number) => number) | null): void {\n this.groundHeight = groundHeight;\n }\n\n /**\n * Enable/disable user input. While disabled, `update()` leaves the camera\n * untouched so another driver (e.g. a camera tween) can own it; re-enable after\n * syncing the pose with `setPose` so free-roam continues from there.\n */\n setEnabled(enabled: boolean): void {\n this.enabled = enabled;\n }\n\n /** Invert the analog look per axis (the visitor's control preference). */\n setLookInvert(x: boolean, y: boolean): void {\n this.lookInvert = { x, y };\n }\n\n /** Current pose: world position plus yaw (`phi`) and pitch (`theta`) in radians. */\n getPose(): { position: Vector3; phi: number; theta: number } {\n return { position: this.translation.clone(), phi: this.look.phi, theta: this.look.theta };\n }\n\n /** Force the pose (e.g. at the end of a camera tween) so input resumes from it. */\n setPose(position: Vector3, phi: number, theta: number): void {\n this.translation.copy(position);\n this.look.phi = phi;\n this.look.theta = theta;\n this.rotation = quaternionFromLook(this.look);\n }\n\n /** Advance the camera by `delta` seconds of input. */\n update(delta: number): void {\n // Disabled: another driver owns the camera this frame. Drop any accumulated\n // pointer delta so look doesn't jump when control is handed back.\n if (!this.enabled) {\n commitMouseFrame(this.mouse);\n return;\n }\n // Accumulate look from every source into the angles, then derive rotation.\n if (this.mouse.dragging) updateLookFromMouse(this.mouse, this.look);\n this.look.phi += yawFromKeyboard(this.keyboard, delta);\n updateLookFromTouch(this.input, this.look, delta, this.lookInvert);\n this.rotation = quaternionFromLook(this.look);\n\n const step = movementFromKeyboard(this.keyboard, this.look.phi, delta).add(\n movementFromTouch(this.input, this.look.phi, delta),\n );\n this.translation = resolveMove(\n this.translation,\n step,\n this.colliders,\n this.playerRadius,\n this.boundary,\n );\n // Follow the terrain: keep the eye a fixed height above the ground below.\n if (this.groundHeight) {\n this.translation.y = this.groundHeight(this.translation.x, this.translation.z) + this.eyeHeight;\n }\n if (step.x !== 0 || step.z !== 0) this.headBob.active = true;\n updateHeadBob(this.headBob, delta);\n\n this.camera.quaternion.copy(this.rotation);\n this.camera.position.copy(this.translation);\n this.camera.position.y += headBobOffset(this.headBob);\n\n const target = new Vector3(0, 0, -1)\n .applyQuaternion(this.rotation)\n .multiplyScalar(100)\n .add(this.translation);\n this.camera.lookAt(target);\n\n commitMouseFrame(this.mouse);\n }\n}\n","import {\n COLLIDERS,\n ROAM_CONTROL,\n TERRAIN,\n type Collider,\n type RoamControl,\n type SystemPlugin,\n type TerrainCapability,\n} from \"@scenography/engine\";\nimport type { Scene } from \"@scenography/scene\";\nimport { Vector3 } from \"three\";\n\nimport { FreeRoamControls } from \"./FreeRoamControls\";\n\n/**\n * First-person camera — a *system* plugin. It drives the kernel camera from\n * WASD/drag plus the shared analog input (joysticks), keeps it at `eyeHeight`\n * over the terrain, and blocks it against wall colliders. Consumes the `terrain`\n * and `colliders` capabilities (live, so it tracks async heightmaps and hot wall\n * edits) and publishes `roamControl` so a framing/tour system can suspend it.\n */\nexport const fpc = (): SystemPlugin => ({\n name: \"fpc\",\n setup: (ctx) => {\n const { camera, canvas, input, capabilities, scene } = ctx;\n const terrainHeight = (x: number, z: number): number =>\n capabilities.get<TerrainCapability>(TERRAIN)?.heightAt(x, z) ?? 0;\n // Follow terrain only when some plugin provides it; otherwise keep a fixed Y.\n const groundHeight = (): ((x: number, z: number) => number) | undefined =>\n capabilities.get<TerrainCapability>(TERRAIN) ? terrainHeight : undefined;\n\n const start = scene.camera.start;\n const controls = new FreeRoamControls(camera, canvas, input, {\n position: new Vector3(start.position[0], start.position[1], start.position[2]),\n yaw: start.yaw,\n boundary: scene.boundary,\n eyeHeight: scene.camera.eyeHeight,\n groundHeight: groundHeight(),\n });\n controls.attach();\n capabilities.provide(ROAM_CONTROL, controls satisfies RoamControl);\n\n // Eye height: an explicit `camera.eyeHeight` wins. Otherwise it was derived\n // from the start Y over the ground — but a floor heightmap loads async, so\n // recalibrate once the terrain is real, or the visitor walks in the air.\n const calibrateEyeHeight = (s: Scene): void => {\n if (s.camera.eyeHeight !== undefined) controls.setEyeHeight(s.camera.eyeHeight);\n else {\n const terrain = capabilities.get<TerrainCapability>(TERRAIN);\n if (terrain) void terrain.ready.then(() => controls.recalibrateEyeHeight());\n }\n };\n calibrateEyeHeight(scene);\n\n return {\n update: (deltaMs) => {\n const flags = ctx.controlFlags();\n controls.setLookInvert(flags.invertLookX, flags.invertLookY);\n controls.setColliders(capabilities.get<Collider[]>(COLLIDERS) ?? []);\n controls.update(deltaMs / 1000);\n },\n setScene: (s) => {\n controls.setBoundary(s.boundary);\n controls.setGroundHeight(groundHeight() ?? null);\n calibrateEyeHeight(s);\n },\n dispose: () => {\n controls.detach();\n capabilities.revoke(ROAM_CONTROL);\n },\n };\n },\n});\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scenography/plugin-fpc",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "First-person camera plugin for scenography: WASD + drag/touch free-roam with head-bob, wall collision and terrain following. A system plugin that drives the kernel camera.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -23,16 +23,16 @@
23
23
  }
24
24
  },
25
25
  "dependencies": {
26
- "@scenography/scene": "0.2.0"
26
+ "@scenography/scene": "0.3.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/three": "^0.160.0",
30
30
  "three": "^0.160.0",
31
- "@scenography/engine": "0.2.0"
31
+ "@scenography/engine": "0.3.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "three": ">=0.160.0",
35
- "@scenography/engine": "0.2.0"
35
+ "@scenography/engine": "0.3.0"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsdown",