@scenography/plugin-fpc 0.2.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 ADDED
@@ -0,0 +1,367 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _scenography_engine = require("@scenography/engine");
3
+ let three = require("three");
4
+ //#region src/collision.ts
5
+ /** Whether `(x, z)` lies inside the XZ polygon (even-odd ray casting). */
6
+ const insidePolygon = (x, z, polygon) => {
7
+ let inside = false;
8
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
9
+ const a = polygon[i];
10
+ const b = polygon[j];
11
+ if (!a || !b) continue;
12
+ if (a[1] > z !== b[1] > z && x < (b[0] - a[0]) * (z - a[1]) / (b[1] - a[1]) + a[0]) inside = !inside;
13
+ }
14
+ return inside;
15
+ };
16
+ /** Whether a circle of `radius` centered at `(x, z)` overlaps any collider box. */
17
+ const hits = (x, z, radius, colliders) => {
18
+ const r2 = radius * radius;
19
+ for (const c of colliders) {
20
+ const px = x - c.cx;
21
+ const pz = z - c.cz;
22
+ const along = px * c.dx + pz * c.dz;
23
+ const perp = px * -c.dz + pz * c.dx;
24
+ const ddAlong = along - Math.max(-c.halfLength, Math.min(c.halfLength, along));
25
+ const ddPerp = perp - Math.max(-c.halfThickness, Math.min(c.halfThickness, perp));
26
+ if (ddAlong * ddAlong + ddPerp * ddPerp < r2) return true;
27
+ }
28
+ return false;
29
+ };
30
+ /**
31
+ * Resolve a desired XZ step against wall colliders and an optional walkable-area
32
+ * boundary, one axis at a time so the camera slides along an obstacle instead of
33
+ * sticking when it can't move straight.
34
+ */
35
+ const resolveMove = (current, step, colliders, radius, boundary) => {
36
+ let { x, z } = current;
37
+ const free = (nx, nz) => !hits(nx, nz, radius, colliders) && (!boundary || insidePolygon(nx, nz, boundary));
38
+ if (step.x !== 0 && free(x + step.x, z)) x += step.x;
39
+ if (step.z !== 0 && free(x, z + step.z)) z += step.z;
40
+ return new three.Vector3(x, current.y, z);
41
+ };
42
+ //#endregion
43
+ //#region src/headBob.ts
44
+ const createHeadBobState = () => ({
45
+ active: false,
46
+ timer: 0
47
+ });
48
+ /**
49
+ * Advance the head-bob timer by `elapsed` seconds, snapping to half-wavelength
50
+ * steps so the bob always settles back to neutral when movement stops.
51
+ */
52
+ const updateHeadBob = (state, elapsed) => {
53
+ if (!state.active) return;
54
+ const wavelength = Math.PI;
55
+ const nextStepTime = (1 + Math.floor((state.timer + 1e-6) * 10 / wavelength)) * wavelength / 10;
56
+ state.timer = Math.min(state.timer + elapsed, nextStepTime);
57
+ if (state.timer === nextStepTime) state.active = false;
58
+ };
59
+ /** Vertical offset to add to the camera, in world units. */
60
+ const headBobOffset = (state) => Math.sin(state.timer * 10) * 1.5;
61
+ //#endregion
62
+ //#region src/keyboard.ts
63
+ const createKeyboardState = () => ({});
64
+ /** Codes the controls consume; preventDefault'd so the arrows don't scroll. */
65
+ const MOVEMENT_KEYS = new Set([
66
+ "KeyW",
67
+ "KeyA",
68
+ "KeyS",
69
+ "KeyD",
70
+ "ArrowUp",
71
+ "ArrowDown",
72
+ "ArrowLeft",
73
+ "ArrowRight"
74
+ ]);
75
+ const onKeyDown = (state, event) => {
76
+ state[event.code] = true;
77
+ if (MOVEMENT_KEYS.has(event.code)) event.preventDefault();
78
+ };
79
+ const onKeyUp = (state, event) => {
80
+ state[event.code] = false;
81
+ };
82
+ /** Yaw turn rate for the Q/E keys, in radians per second. */
83
+ const KEY_TURN_SPEED = 1.8;
84
+ /**
85
+ * Turn a forward/strafe pair (each in `-1..1`) into a per-frame translation
86
+ * oriented by the current yaw (`phi`). Shared by keyboard and touch input.
87
+ */
88
+ const movementFromAxis = (forwardVelocity, strafeVelocity, phi, delta, boosted = false) => {
89
+ const factor = boosted ? 60 : 20;
90
+ const yaw = new three.Quaternion().setFromAxisAngle(new three.Vector3(0, 1, 0), phi);
91
+ const forward = new three.Vector3(0, 0, -10).applyQuaternion(yaw).multiplyScalar(forwardVelocity * delta * factor);
92
+ const left = new three.Vector3(-10, 0, 0).applyQuaternion(yaw).multiplyScalar(strafeVelocity * delta * factor);
93
+ return forward.add(left);
94
+ };
95
+ /**
96
+ * Compute the per-frame translation from WASD or arrow-key input, oriented by
97
+ * the current yaw (`phi`). Holding Shift acts as a speed booster.
98
+ */
99
+ const movementFromKeyboard = (state, phi, delta) => {
100
+ const has = (...codes) => codes.some((c) => state[c]) ? 1 : 0;
101
+ return movementFromAxis(has("KeyW", "ArrowUp") - has("KeyS", "ArrowDown"), has("KeyA", "ArrowLeft") - has("KeyD", "ArrowRight"), phi, delta, Boolean(state["ShiftLeft"] || state["ShiftRight"]));
102
+ };
103
+ /**
104
+ * Per-frame yaw delta (radians) from the Q/E turn keys. Q turns left
105
+ * (increasing yaw), E turns right.
106
+ */
107
+ const yawFromKeyboard = (state, delta) => {
108
+ return ((state["KeyQ"] ? 1 : 0) - (state["KeyE"] ? 1 : 0)) * KEY_TURN_SPEED * delta;
109
+ };
110
+ //#endregion
111
+ //#region src/mouse.ts
112
+ const PHI_SPEED = 8;
113
+ const THETA_SPEED = 5;
114
+ const createMouseState = () => ({
115
+ dragging: false,
116
+ pos: new three.Vector2(),
117
+ prevPos: new three.Vector2(),
118
+ delta: new three.Vector2()
119
+ });
120
+ const clamp = (x, a, b) => Math.min(Math.max(x, a), b);
121
+ /** Maximum up/down look angle (radians) — keeps the camera from flipping over. */
122
+ const MAX_PITCH = Math.PI / 3;
123
+ /** Build the look quaternion from the current yaw/pitch angles. */
124
+ const quaternionFromLook = (look) => {
125
+ const qy = new three.Quaternion().setFromAxisAngle(new three.Vector3(0, 1, 0), look.phi);
126
+ const qx = new three.Quaternion().setFromAxisAngle(new three.Vector3(1, 0, 0), look.theta);
127
+ return new three.Quaternion().multiply(qy).multiply(qx);
128
+ };
129
+ const onMouseDown = (state, event) => {
130
+ state.dragging = event.button === 0;
131
+ };
132
+ const onMouseUp = (state) => {
133
+ state.dragging = false;
134
+ };
135
+ const onMouseMove = (state, event) => {
136
+ state.pos.set(event.pageX - window.innerWidth / 2, event.pageY - window.innerHeight / 2);
137
+ state.delta.subVectors(state.pos, state.prevPos);
138
+ };
139
+ const commitMouseFrame = (state) => {
140
+ state.prevPos.copy(state.pos);
141
+ state.delta.set(0, 0);
142
+ };
143
+ /**
144
+ * Integrate the pointer delta into the look angles. Mutates `look` in place.
145
+ * Yaw is unbounded; pitch is clamped to avoid flipping over.
146
+ */
147
+ const updateLookFromMouse = (mouse, look) => {
148
+ const xh = mouse.delta.x / window.innerWidth;
149
+ const yh = mouse.delta.y / window.innerHeight;
150
+ look.phi += -xh * PHI_SPEED;
151
+ look.theta = clamp(look.theta + -yh * THETA_SPEED, -MAX_PITCH, MAX_PITCH);
152
+ };
153
+ //#endregion
154
+ //#region src/touch.ts
155
+ /** Look turn rate driven by the joystick, in radians per second at full deflection. */
156
+ const LOOK_SPEED = 2.2;
157
+ /** Per-frame translation from the analog move axes, oriented by yaw (`phi`). */
158
+ const movementFromTouch = (state, phi, delta) => {
159
+ if (state.moveForward === 0 && state.moveStrafe === 0) return new three.Vector3();
160
+ return movementFromAxis(state.moveForward, -state.moveStrafe, phi, delta);
161
+ };
162
+ /** Integrate the look joystick into the look angles, mutating `look` in place. */
163
+ const updateLookFromTouch = (state, look, delta) => {
164
+ 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);
167
+ };
168
+ //#endregion
169
+ //#region src/FreeRoamControls.ts
170
+ /**
171
+ * First-person, free-roaming camera controls: drag-to-look with the mouse, WASD
172
+ * to walk, a subtle head-bob while moving, wall collision and terrain following.
173
+ * The shared analog input (`AnalogInput`, the kernel's InputBus) is folded in on
174
+ * top of keyboard/mouse, so on-screen joysticks drive it too. It keeps the
175
+ * camera upright (no roll) and clamps pitch — the first-person contract.
176
+ *
177
+ * Framework-agnostic: construct it with a camera, the canvas and the input,
178
+ * call `attach()`, then `update(delta)` once per frame.
179
+ */
180
+ var FreeRoamControls = class {
181
+ colliders;
182
+ playerRadius;
183
+ camera;
184
+ domElement;
185
+ input;
186
+ mouse = createMouseState();
187
+ keyboard = createKeyboardState();
188
+ headBob = createHeadBobState();
189
+ look = {
190
+ phi: 0,
191
+ theta: 0
192
+ };
193
+ rotation = new three.Quaternion();
194
+ translation = new three.Vector3(0, 170, 0);
195
+ enabled = true;
196
+ boundary;
197
+ groundHeight;
198
+ start;
199
+ eyeHeight;
200
+ handlers = [];
201
+ constructor(camera, domElement, input, options = {}) {
202
+ this.camera = camera;
203
+ this.domElement = domElement;
204
+ this.input = input;
205
+ this.colliders = options.colliders ?? [];
206
+ this.playerRadius = options.playerRadius ?? 25;
207
+ if (options.position) this.translation.copy(options.position);
208
+ if (options.yaw !== void 0) this.look.phi = options.yaw;
209
+ this.rotation.setFromAxisAngle(new three.Vector3(0, 1, 0), this.look.phi);
210
+ this.boundary = options.boundary;
211
+ this.groundHeight = options.groundHeight ?? null;
212
+ this.start = this.translation.clone();
213
+ const groundAtStart = this.groundHeight?.(this.start.x, this.start.z) ?? 0;
214
+ this.eyeHeight = options.eyeHeight ?? this.start.y - groundAtStart;
215
+ }
216
+ /** Current eye height above the ground (world units). */
217
+ getEyeHeight() {
218
+ return this.eyeHeight;
219
+ }
220
+ /** Fix the eye height above the ground (world units). */
221
+ setEyeHeight(eyeHeight) {
222
+ this.eyeHeight = eyeHeight;
223
+ }
224
+ /**
225
+ * Re-derive the default eye height (start Y over the ground under the start
226
+ * position). Call once asynchronous ground data (a floor heightmap) has
227
+ * loaded: the value captured at construction was measured against the
228
+ * not-yet-displaced floor.
229
+ */
230
+ recalibrateEyeHeight() {
231
+ const ground = this.groundHeight?.(this.start.x, this.start.z) ?? 0;
232
+ this.eyeHeight = this.start.y - ground;
233
+ }
234
+ attach() {
235
+ const bind = (target, type, listener) => {
236
+ target.addEventListener(type, listener);
237
+ this.handlers.push([
238
+ target,
239
+ type,
240
+ listener
241
+ ]);
242
+ };
243
+ bind(this.domElement, "mousedown", (e) => onMouseDown(this.mouse, e));
244
+ bind(window, "mousemove", (e) => onMouseMove(this.mouse, e));
245
+ bind(window, "mouseup", () => onMouseUp(this.mouse));
246
+ bind(window, "keydown", (e) => onKeyDown(this.keyboard, e));
247
+ bind(window, "keyup", (e) => onKeyUp(this.keyboard, e));
248
+ }
249
+ detach() {
250
+ for (const [target, type, listener] of this.handlers) target.removeEventListener(type, listener);
251
+ this.handlers.length = 0;
252
+ }
253
+ /** Replace the wall collision set (e.g. after the scene's walls change). */
254
+ setColliders(colliders) {
255
+ this.colliders = colliders;
256
+ }
257
+ /** Replace the walkable-area boundary polygon (or clear it with `undefined`). */
258
+ setBoundary(boundary) {
259
+ this.boundary = boundary;
260
+ }
261
+ /** Replace the ground-height function (or clear it to stop following terrain). */
262
+ setGroundHeight(groundHeight) {
263
+ this.groundHeight = groundHeight;
264
+ }
265
+ /**
266
+ * Enable/disable user input. While disabled, `update()` leaves the camera
267
+ * untouched so another driver (e.g. a camera tween) can own it; re-enable after
268
+ * syncing the pose with `setPose` so free-roam continues from there.
269
+ */
270
+ setEnabled(enabled) {
271
+ this.enabled = enabled;
272
+ }
273
+ /** Current pose: world position plus yaw (`phi`) and pitch (`theta`) in radians. */
274
+ getPose() {
275
+ return {
276
+ position: this.translation.clone(),
277
+ phi: this.look.phi,
278
+ theta: this.look.theta
279
+ };
280
+ }
281
+ /** Force the pose (e.g. at the end of a camera tween) so input resumes from it. */
282
+ setPose(position, phi, theta) {
283
+ this.translation.copy(position);
284
+ this.look.phi = phi;
285
+ this.look.theta = theta;
286
+ this.rotation = quaternionFromLook(this.look);
287
+ }
288
+ /** Advance the camera by `delta` seconds of input. */
289
+ update(delta) {
290
+ if (!this.enabled) {
291
+ commitMouseFrame(this.mouse);
292
+ return;
293
+ }
294
+ if (this.mouse.dragging) updateLookFromMouse(this.mouse, this.look);
295
+ this.look.phi += yawFromKeyboard(this.keyboard, delta);
296
+ updateLookFromTouch(this.input, this.look, delta);
297
+ this.rotation = quaternionFromLook(this.look);
298
+ const step = movementFromKeyboard(this.keyboard, this.look.phi, delta).add(movementFromTouch(this.input, this.look.phi, delta));
299
+ this.translation = resolveMove(this.translation, step, this.colliders, this.playerRadius, this.boundary);
300
+ if (this.groundHeight) this.translation.y = this.groundHeight(this.translation.x, this.translation.z) + this.eyeHeight;
301
+ if (step.x !== 0 || step.z !== 0) this.headBob.active = true;
302
+ updateHeadBob(this.headBob, delta);
303
+ this.camera.quaternion.copy(this.rotation);
304
+ this.camera.position.copy(this.translation);
305
+ this.camera.position.y += headBobOffset(this.headBob);
306
+ const target = new three.Vector3(0, 0, -1).applyQuaternion(this.rotation).multiplyScalar(100).add(this.translation);
307
+ this.camera.lookAt(target);
308
+ commitMouseFrame(this.mouse);
309
+ }
310
+ };
311
+ //#endregion
312
+ //#region src/plugin.ts
313
+ /**
314
+ * First-person camera — a *system* plugin. It drives the kernel camera from
315
+ * WASD/drag plus the shared analog input (joysticks), keeps it at `eyeHeight`
316
+ * over the terrain, and blocks it against wall colliders. Consumes the `terrain`
317
+ * and `colliders` capabilities (live, so it tracks async heightmaps and hot wall
318
+ * edits) and publishes `roamControl` so a framing/tour system can suspend it.
319
+ */
320
+ const fpc = () => ({
321
+ name: "fpc",
322
+ setup: (ctx) => {
323
+ const { camera, canvas, input, capabilities, scene } = ctx;
324
+ const terrainHeight = (x, z) => capabilities.get(_scenography_engine.TERRAIN)?.heightAt(x, z) ?? 0;
325
+ const groundHeight = () => capabilities.get(_scenography_engine.TERRAIN) ? terrainHeight : void 0;
326
+ const start = scene.camera.start;
327
+ const controls = new FreeRoamControls(camera, canvas, input, {
328
+ position: new three.Vector3(start.position[0], start.position[1], start.position[2]),
329
+ yaw: start.yaw,
330
+ boundary: scene.boundary,
331
+ eyeHeight: scene.camera.eyeHeight,
332
+ groundHeight: groundHeight()
333
+ });
334
+ controls.attach();
335
+ capabilities.provide(_scenography_engine.ROAM_CONTROL, controls);
336
+ const calibrateEyeHeight = (s) => {
337
+ if (s.camera.eyeHeight !== void 0) controls.setEyeHeight(s.camera.eyeHeight);
338
+ else {
339
+ const terrain = capabilities.get(_scenography_engine.TERRAIN);
340
+ if (terrain) terrain.ready.then(() => controls.recalibrateEyeHeight());
341
+ }
342
+ };
343
+ calibrateEyeHeight(scene);
344
+ return {
345
+ update: (deltaMs) => {
346
+ controls.setColliders(capabilities.get(_scenography_engine.COLLIDERS) ?? []);
347
+ controls.update(deltaMs / 1e3);
348
+ },
349
+ setScene: (s) => {
350
+ controls.setBoundary(s.boundary);
351
+ controls.setGroundHeight(groundHeight() ?? null);
352
+ calibrateEyeHeight(s);
353
+ },
354
+ dispose: () => {
355
+ controls.detach();
356
+ capabilities.revoke(_scenography_engine.ROAM_CONTROL);
357
+ }
358
+ };
359
+ }
360
+ });
361
+ //#endregion
362
+ exports.FreeRoamControls = FreeRoamControls;
363
+ exports.fpc = fpc;
364
+ exports.insidePolygon = insidePolygon;
365
+ exports.resolveMove = resolveMove;
366
+
367
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +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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"}
@@ -0,0 +1,126 @@
1
+ import { Collider, SystemPlugin } from "@scenography/engine";
2
+ import { PerspectiveCamera, Vector3 } from "three";
3
+ import { Vec2 } from "@scenography/scene";
4
+
5
+ //#region src/plugin.d.ts
6
+ /**
7
+ * First-person camera — a *system* plugin. It drives the kernel camera from
8
+ * WASD/drag plus the shared analog input (joysticks), keeps it at `eyeHeight`
9
+ * over the terrain, and blocks it against wall colliders. Consumes the `terrain`
10
+ * and `colliders` capabilities (live, so it tracks async heightmaps and hot wall
11
+ * edits) and publishes `roamControl` so a framing/tour system can suspend it.
12
+ */
13
+ declare const fpc: () => SystemPlugin;
14
+ //#endregion
15
+ //#region src/touch.d.ts
16
+ /**
17
+ * The shape of the shared analog input fpc reads (the kernel's `InputBus`): the
18
+ * move/look axes a joystick feeds. Structural, so any producer with these fields
19
+ * works. fpc interprets them as walking + turning; another camera might not.
20
+ */
21
+ interface AnalogInput {
22
+ /** Forward (`+1`) / backward (`-1`) movement. */
23
+ moveForward: number;
24
+ /** Strafe right (`+1`) / left (`-1`). */
25
+ moveStrafe: number;
26
+ /** Look right (`+1`) / left (`-1`). */
27
+ lookX: number;
28
+ /** Look up (`+1`) / down (`-1`). */
29
+ lookY: number;
30
+ }
31
+ //#endregion
32
+ //#region src/FreeRoamControls.d.ts
33
+ interface FreeRoamOptions {
34
+ /** Wall collision boxes the camera can't pass through. */
35
+ colliders?: Collider[];
36
+ /** Camera collision radius in world units. Defaults to `25`. */
37
+ playerRadius?: number;
38
+ /** Starting position. */
39
+ position?: Vector3;
40
+ /** Starting yaw in radians. */
41
+ yaw?: number;
42
+ /** Walkable-area polygon (XZ ring); the camera can't step outside it. */
43
+ boundary?: Vec2[];
44
+ /** Ground height at `(x, z)`; when set the camera follows the terrain. */
45
+ groundHeight?: (x: number, z: number) => number;
46
+ /** Eye height above the ground. Defaults to the start Y over the start ground. */
47
+ eyeHeight?: number;
48
+ }
49
+ /**
50
+ * First-person, free-roaming camera controls: drag-to-look with the mouse, WASD
51
+ * to walk, a subtle head-bob while moving, wall collision and terrain following.
52
+ * The shared analog input (`AnalogInput`, the kernel's InputBus) is folded in on
53
+ * top of keyboard/mouse, so on-screen joysticks drive it too. It keeps the
54
+ * camera upright (no roll) and clamps pitch — the first-person contract.
55
+ *
56
+ * Framework-agnostic: construct it with a camera, the canvas and the input,
57
+ * call `attach()`, then `update(delta)` once per frame.
58
+ */
59
+ declare class FreeRoamControls {
60
+ private colliders;
61
+ private readonly playerRadius;
62
+ private readonly camera;
63
+ private readonly domElement;
64
+ private readonly input;
65
+ private readonly mouse;
66
+ private readonly keyboard;
67
+ private readonly headBob;
68
+ private readonly look;
69
+ private rotation;
70
+ private translation;
71
+ private enabled;
72
+ private boundary;
73
+ private groundHeight;
74
+ private readonly start;
75
+ private eyeHeight;
76
+ private readonly handlers;
77
+ constructor(camera: PerspectiveCamera, domElement: HTMLElement, input: AnalogInput, options?: FreeRoamOptions);
78
+ /** Current eye height above the ground (world units). */
79
+ getEyeHeight(): number;
80
+ /** Fix the eye height above the ground (world units). */
81
+ setEyeHeight(eyeHeight: number): void;
82
+ /**
83
+ * Re-derive the default eye height (start Y over the ground under the start
84
+ * position). Call once asynchronous ground data (a floor heightmap) has
85
+ * loaded: the value captured at construction was measured against the
86
+ * not-yet-displaced floor.
87
+ */
88
+ recalibrateEyeHeight(): void;
89
+ attach(): void;
90
+ detach(): void;
91
+ /** Replace the wall collision set (e.g. after the scene's walls change). */
92
+ setColliders(colliders: Collider[]): void;
93
+ /** Replace the walkable-area boundary polygon (or clear it with `undefined`). */
94
+ setBoundary(boundary: Vec2[] | undefined): void;
95
+ /** Replace the ground-height function (or clear it to stop following terrain). */
96
+ setGroundHeight(groundHeight: ((x: number, z: number) => number) | null): void;
97
+ /**
98
+ * Enable/disable user input. While disabled, `update()` leaves the camera
99
+ * untouched so another driver (e.g. a camera tween) can own it; re-enable after
100
+ * syncing the pose with `setPose` so free-roam continues from there.
101
+ */
102
+ setEnabled(enabled: boolean): void;
103
+ /** Current pose: world position plus yaw (`phi`) and pitch (`theta`) in radians. */
104
+ getPose(): {
105
+ position: Vector3;
106
+ phi: number;
107
+ theta: number;
108
+ };
109
+ /** Force the pose (e.g. at the end of a camera tween) so input resumes from it. */
110
+ setPose(position: Vector3, phi: number, theta: number): void;
111
+ /** Advance the camera by `delta` seconds of input. */
112
+ update(delta: number): void;
113
+ }
114
+ //#endregion
115
+ //#region src/collision.d.ts
116
+ /** Whether `(x, z)` lies inside the XZ polygon (even-odd ray casting). */
117
+ declare const insidePolygon: (x: number, z: number, polygon: Vec2[]) => boolean;
118
+ /**
119
+ * Resolve a desired XZ step against wall colliders and an optional walkable-area
120
+ * boundary, one axis at a time so the camera slides along an obstacle instead of
121
+ * sticking when it can't move straight.
122
+ */
123
+ declare const resolveMove: (current: Vector3, step: Vector3, colliders: Collider[], radius: number, boundary?: Vec2[]) => Vector3;
124
+ //#endregion
125
+ export { FreeRoamControls, type FreeRoamOptions, fpc, insidePolygon, resolveMove };
126
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1,126 @@
1
+ import { Collider, SystemPlugin } from "@scenography/engine";
2
+ import { PerspectiveCamera, Quaternion, Vector2, Vector3 } from "three";
3
+ import { Vec2 } from "@scenography/scene";
4
+
5
+ //#region src/plugin.d.ts
6
+ /**
7
+ * First-person camera — a *system* plugin. It drives the kernel camera from
8
+ * WASD/drag plus the shared analog input (joysticks), keeps it at `eyeHeight`
9
+ * over the terrain, and blocks it against wall colliders. Consumes the `terrain`
10
+ * and `colliders` capabilities (live, so it tracks async heightmaps and hot wall
11
+ * edits) and publishes `roamControl` so a framing/tour system can suspend it.
12
+ */
13
+ declare const fpc: () => SystemPlugin;
14
+ //#endregion
15
+ //#region src/touch.d.ts
16
+ /**
17
+ * The shape of the shared analog input fpc reads (the kernel's `InputBus`): the
18
+ * move/look axes a joystick feeds. Structural, so any producer with these fields
19
+ * works. fpc interprets them as walking + turning; another camera might not.
20
+ */
21
+ interface AnalogInput {
22
+ /** Forward (`+1`) / backward (`-1`) movement. */
23
+ moveForward: number;
24
+ /** Strafe right (`+1`) / left (`-1`). */
25
+ moveStrafe: number;
26
+ /** Look right (`+1`) / left (`-1`). */
27
+ lookX: number;
28
+ /** Look up (`+1`) / down (`-1`). */
29
+ lookY: number;
30
+ }
31
+ //#endregion
32
+ //#region src/FreeRoamControls.d.ts
33
+ interface FreeRoamOptions {
34
+ /** Wall collision boxes the camera can't pass through. */
35
+ colliders?: Collider[];
36
+ /** Camera collision radius in world units. Defaults to `25`. */
37
+ playerRadius?: number;
38
+ /** Starting position. */
39
+ position?: Vector3;
40
+ /** Starting yaw in radians. */
41
+ yaw?: number;
42
+ /** Walkable-area polygon (XZ ring); the camera can't step outside it. */
43
+ boundary?: Vec2[];
44
+ /** Ground height at `(x, z)`; when set the camera follows the terrain. */
45
+ groundHeight?: (x: number, z: number) => number;
46
+ /** Eye height above the ground. Defaults to the start Y over the start ground. */
47
+ eyeHeight?: number;
48
+ }
49
+ /**
50
+ * First-person, free-roaming camera controls: drag-to-look with the mouse, WASD
51
+ * to walk, a subtle head-bob while moving, wall collision and terrain following.
52
+ * The shared analog input (`AnalogInput`, the kernel's InputBus) is folded in on
53
+ * top of keyboard/mouse, so on-screen joysticks drive it too. It keeps the
54
+ * camera upright (no roll) and clamps pitch — the first-person contract.
55
+ *
56
+ * Framework-agnostic: construct it with a camera, the canvas and the input,
57
+ * call `attach()`, then `update(delta)` once per frame.
58
+ */
59
+ declare class FreeRoamControls {
60
+ private colliders;
61
+ private readonly playerRadius;
62
+ private readonly camera;
63
+ private readonly domElement;
64
+ private readonly input;
65
+ private readonly mouse;
66
+ private readonly keyboard;
67
+ private readonly headBob;
68
+ private readonly look;
69
+ private rotation;
70
+ private translation;
71
+ private enabled;
72
+ private boundary;
73
+ private groundHeight;
74
+ private readonly start;
75
+ private eyeHeight;
76
+ private readonly handlers;
77
+ constructor(camera: PerspectiveCamera, domElement: HTMLElement, input: AnalogInput, options?: FreeRoamOptions);
78
+ /** Current eye height above the ground (world units). */
79
+ getEyeHeight(): number;
80
+ /** Fix the eye height above the ground (world units). */
81
+ setEyeHeight(eyeHeight: number): void;
82
+ /**
83
+ * Re-derive the default eye height (start Y over the ground under the start
84
+ * position). Call once asynchronous ground data (a floor heightmap) has
85
+ * loaded: the value captured at construction was measured against the
86
+ * not-yet-displaced floor.
87
+ */
88
+ recalibrateEyeHeight(): void;
89
+ attach(): void;
90
+ detach(): void;
91
+ /** Replace the wall collision set (e.g. after the scene's walls change). */
92
+ setColliders(colliders: Collider[]): void;
93
+ /** Replace the walkable-area boundary polygon (or clear it with `undefined`). */
94
+ setBoundary(boundary: Vec2[] | undefined): void;
95
+ /** Replace the ground-height function (or clear it to stop following terrain). */
96
+ setGroundHeight(groundHeight: ((x: number, z: number) => number) | null): void;
97
+ /**
98
+ * Enable/disable user input. While disabled, `update()` leaves the camera
99
+ * untouched so another driver (e.g. a camera tween) can own it; re-enable after
100
+ * syncing the pose with `setPose` so free-roam continues from there.
101
+ */
102
+ setEnabled(enabled: boolean): void;
103
+ /** Current pose: world position plus yaw (`phi`) and pitch (`theta`) in radians. */
104
+ getPose(): {
105
+ position: Vector3;
106
+ phi: number;
107
+ theta: number;
108
+ };
109
+ /** Force the pose (e.g. at the end of a camera tween) so input resumes from it. */
110
+ setPose(position: Vector3, phi: number, theta: number): void;
111
+ /** Advance the camera by `delta` seconds of input. */
112
+ update(delta: number): void;
113
+ }
114
+ //#endregion
115
+ //#region src/collision.d.ts
116
+ /** Whether `(x, z)` lies inside the XZ polygon (even-odd ray casting). */
117
+ declare const insidePolygon: (x: number, z: number, polygon: Vec2[]) => boolean;
118
+ /**
119
+ * Resolve a desired XZ step against wall colliders and an optional walkable-area
120
+ * boundary, one axis at a time so the camera slides along an obstacle instead of
121
+ * sticking when it can't move straight.
122
+ */
123
+ declare const resolveMove: (current: Vector3, step: Vector3, colliders: Collider[], radius: number, boundary?: Vec2[]) => Vector3;
124
+ //#endregion
125
+ export { FreeRoamControls, type FreeRoamOptions, fpc, insidePolygon, resolveMove };
126
+ //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs ADDED
@@ -0,0 +1,363 @@
1
+ import { COLLIDERS, ROAM_CONTROL, TERRAIN } from "@scenography/engine";
2
+ import { Quaternion, Vector2, Vector3 } from "three";
3
+ //#region src/collision.ts
4
+ /** Whether `(x, z)` lies inside the XZ polygon (even-odd ray casting). */
5
+ const insidePolygon = (x, z, polygon) => {
6
+ let inside = false;
7
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
8
+ const a = polygon[i];
9
+ const b = polygon[j];
10
+ if (!a || !b) continue;
11
+ if (a[1] > z !== b[1] > z && x < (b[0] - a[0]) * (z - a[1]) / (b[1] - a[1]) + a[0]) inside = !inside;
12
+ }
13
+ return inside;
14
+ };
15
+ /** Whether a circle of `radius` centered at `(x, z)` overlaps any collider box. */
16
+ const hits = (x, z, radius, colliders) => {
17
+ const r2 = radius * radius;
18
+ for (const c of colliders) {
19
+ const px = x - c.cx;
20
+ const pz = z - c.cz;
21
+ const along = px * c.dx + pz * c.dz;
22
+ const perp = px * -c.dz + pz * c.dx;
23
+ const ddAlong = along - Math.max(-c.halfLength, Math.min(c.halfLength, along));
24
+ const ddPerp = perp - Math.max(-c.halfThickness, Math.min(c.halfThickness, perp));
25
+ if (ddAlong * ddAlong + ddPerp * ddPerp < r2) return true;
26
+ }
27
+ return false;
28
+ };
29
+ /**
30
+ * Resolve a desired XZ step against wall colliders and an optional walkable-area
31
+ * boundary, one axis at a time so the camera slides along an obstacle instead of
32
+ * sticking when it can't move straight.
33
+ */
34
+ const resolveMove = (current, step, colliders, radius, boundary) => {
35
+ let { x, z } = current;
36
+ const free = (nx, nz) => !hits(nx, nz, radius, colliders) && (!boundary || insidePolygon(nx, nz, boundary));
37
+ if (step.x !== 0 && free(x + step.x, z)) x += step.x;
38
+ if (step.z !== 0 && free(x, z + step.z)) z += step.z;
39
+ return new Vector3(x, current.y, z);
40
+ };
41
+ //#endregion
42
+ //#region src/headBob.ts
43
+ const createHeadBobState = () => ({
44
+ active: false,
45
+ timer: 0
46
+ });
47
+ /**
48
+ * Advance the head-bob timer by `elapsed` seconds, snapping to half-wavelength
49
+ * steps so the bob always settles back to neutral when movement stops.
50
+ */
51
+ const updateHeadBob = (state, elapsed) => {
52
+ if (!state.active) return;
53
+ const wavelength = Math.PI;
54
+ const nextStepTime = (1 + Math.floor((state.timer + 1e-6) * 10 / wavelength)) * wavelength / 10;
55
+ state.timer = Math.min(state.timer + elapsed, nextStepTime);
56
+ if (state.timer === nextStepTime) state.active = false;
57
+ };
58
+ /** Vertical offset to add to the camera, in world units. */
59
+ const headBobOffset = (state) => Math.sin(state.timer * 10) * 1.5;
60
+ //#endregion
61
+ //#region src/keyboard.ts
62
+ const createKeyboardState = () => ({});
63
+ /** Codes the controls consume; preventDefault'd so the arrows don't scroll. */
64
+ const MOVEMENT_KEYS = new Set([
65
+ "KeyW",
66
+ "KeyA",
67
+ "KeyS",
68
+ "KeyD",
69
+ "ArrowUp",
70
+ "ArrowDown",
71
+ "ArrowLeft",
72
+ "ArrowRight"
73
+ ]);
74
+ const onKeyDown = (state, event) => {
75
+ state[event.code] = true;
76
+ if (MOVEMENT_KEYS.has(event.code)) event.preventDefault();
77
+ };
78
+ const onKeyUp = (state, event) => {
79
+ state[event.code] = false;
80
+ };
81
+ /** Yaw turn rate for the Q/E keys, in radians per second. */
82
+ const KEY_TURN_SPEED = 1.8;
83
+ /**
84
+ * Turn a forward/strafe pair (each in `-1..1`) into a per-frame translation
85
+ * oriented by the current yaw (`phi`). Shared by keyboard and touch input.
86
+ */
87
+ const movementFromAxis = (forwardVelocity, strafeVelocity, phi, delta, boosted = false) => {
88
+ const factor = boosted ? 60 : 20;
89
+ const yaw = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), phi);
90
+ const forward = new Vector3(0, 0, -10).applyQuaternion(yaw).multiplyScalar(forwardVelocity * delta * factor);
91
+ const left = new Vector3(-10, 0, 0).applyQuaternion(yaw).multiplyScalar(strafeVelocity * delta * factor);
92
+ return forward.add(left);
93
+ };
94
+ /**
95
+ * Compute the per-frame translation from WASD or arrow-key input, oriented by
96
+ * the current yaw (`phi`). Holding Shift acts as a speed booster.
97
+ */
98
+ const movementFromKeyboard = (state, phi, delta) => {
99
+ const has = (...codes) => codes.some((c) => state[c]) ? 1 : 0;
100
+ return movementFromAxis(has("KeyW", "ArrowUp") - has("KeyS", "ArrowDown"), has("KeyA", "ArrowLeft") - has("KeyD", "ArrowRight"), phi, delta, Boolean(state["ShiftLeft"] || state["ShiftRight"]));
101
+ };
102
+ /**
103
+ * Per-frame yaw delta (radians) from the Q/E turn keys. Q turns left
104
+ * (increasing yaw), E turns right.
105
+ */
106
+ const yawFromKeyboard = (state, delta) => {
107
+ return ((state["KeyQ"] ? 1 : 0) - (state["KeyE"] ? 1 : 0)) * KEY_TURN_SPEED * delta;
108
+ };
109
+ //#endregion
110
+ //#region src/mouse.ts
111
+ const PHI_SPEED = 8;
112
+ const THETA_SPEED = 5;
113
+ const createMouseState = () => ({
114
+ dragging: false,
115
+ pos: new Vector2(),
116
+ prevPos: new Vector2(),
117
+ delta: new Vector2()
118
+ });
119
+ const clamp = (x, a, b) => Math.min(Math.max(x, a), b);
120
+ /** Maximum up/down look angle (radians) — keeps the camera from flipping over. */
121
+ const MAX_PITCH = Math.PI / 3;
122
+ /** Build the look quaternion from the current yaw/pitch angles. */
123
+ const quaternionFromLook = (look) => {
124
+ const qy = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), look.phi);
125
+ const qx = new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), look.theta);
126
+ return new Quaternion().multiply(qy).multiply(qx);
127
+ };
128
+ const onMouseDown = (state, event) => {
129
+ state.dragging = event.button === 0;
130
+ };
131
+ const onMouseUp = (state) => {
132
+ state.dragging = false;
133
+ };
134
+ const onMouseMove = (state, event) => {
135
+ state.pos.set(event.pageX - window.innerWidth / 2, event.pageY - window.innerHeight / 2);
136
+ state.delta.subVectors(state.pos, state.prevPos);
137
+ };
138
+ const commitMouseFrame = (state) => {
139
+ state.prevPos.copy(state.pos);
140
+ state.delta.set(0, 0);
141
+ };
142
+ /**
143
+ * Integrate the pointer delta into the look angles. Mutates `look` in place.
144
+ * Yaw is unbounded; pitch is clamped to avoid flipping over.
145
+ */
146
+ const updateLookFromMouse = (mouse, look) => {
147
+ const xh = mouse.delta.x / window.innerWidth;
148
+ const yh = mouse.delta.y / window.innerHeight;
149
+ look.phi += -xh * PHI_SPEED;
150
+ look.theta = clamp(look.theta + -yh * THETA_SPEED, -MAX_PITCH, MAX_PITCH);
151
+ };
152
+ //#endregion
153
+ //#region src/touch.ts
154
+ /** Look turn rate driven by the joystick, in radians per second at full deflection. */
155
+ const LOOK_SPEED = 2.2;
156
+ /** Per-frame translation from the analog move axes, oriented by yaw (`phi`). */
157
+ const movementFromTouch = (state, phi, delta) => {
158
+ if (state.moveForward === 0 && state.moveStrafe === 0) return new Vector3();
159
+ return movementFromAxis(state.moveForward, -state.moveStrafe, phi, delta);
160
+ };
161
+ /** Integrate the look joystick into the look angles, mutating `look` in place. */
162
+ const updateLookFromTouch = (state, look, delta) => {
163
+ 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);
166
+ };
167
+ //#endregion
168
+ //#region src/FreeRoamControls.ts
169
+ /**
170
+ * First-person, free-roaming camera controls: drag-to-look with the mouse, WASD
171
+ * to walk, a subtle head-bob while moving, wall collision and terrain following.
172
+ * The shared analog input (`AnalogInput`, the kernel's InputBus) is folded in on
173
+ * top of keyboard/mouse, so on-screen joysticks drive it too. It keeps the
174
+ * camera upright (no roll) and clamps pitch — the first-person contract.
175
+ *
176
+ * Framework-agnostic: construct it with a camera, the canvas and the input,
177
+ * call `attach()`, then `update(delta)` once per frame.
178
+ */
179
+ var FreeRoamControls = class {
180
+ colliders;
181
+ playerRadius;
182
+ camera;
183
+ domElement;
184
+ input;
185
+ mouse = createMouseState();
186
+ keyboard = createKeyboardState();
187
+ headBob = createHeadBobState();
188
+ look = {
189
+ phi: 0,
190
+ theta: 0
191
+ };
192
+ rotation = new Quaternion();
193
+ translation = new Vector3(0, 170, 0);
194
+ enabled = true;
195
+ boundary;
196
+ groundHeight;
197
+ start;
198
+ eyeHeight;
199
+ handlers = [];
200
+ constructor(camera, domElement, input, options = {}) {
201
+ this.camera = camera;
202
+ this.domElement = domElement;
203
+ this.input = input;
204
+ this.colliders = options.colliders ?? [];
205
+ this.playerRadius = options.playerRadius ?? 25;
206
+ if (options.position) this.translation.copy(options.position);
207
+ if (options.yaw !== void 0) this.look.phi = options.yaw;
208
+ this.rotation.setFromAxisAngle(new Vector3(0, 1, 0), this.look.phi);
209
+ this.boundary = options.boundary;
210
+ this.groundHeight = options.groundHeight ?? null;
211
+ this.start = this.translation.clone();
212
+ const groundAtStart = this.groundHeight?.(this.start.x, this.start.z) ?? 0;
213
+ this.eyeHeight = options.eyeHeight ?? this.start.y - groundAtStart;
214
+ }
215
+ /** Current eye height above the ground (world units). */
216
+ getEyeHeight() {
217
+ return this.eyeHeight;
218
+ }
219
+ /** Fix the eye height above the ground (world units). */
220
+ setEyeHeight(eyeHeight) {
221
+ this.eyeHeight = eyeHeight;
222
+ }
223
+ /**
224
+ * Re-derive the default eye height (start Y over the ground under the start
225
+ * position). Call once asynchronous ground data (a floor heightmap) has
226
+ * loaded: the value captured at construction was measured against the
227
+ * not-yet-displaced floor.
228
+ */
229
+ recalibrateEyeHeight() {
230
+ const ground = this.groundHeight?.(this.start.x, this.start.z) ?? 0;
231
+ this.eyeHeight = this.start.y - ground;
232
+ }
233
+ attach() {
234
+ const bind = (target, type, listener) => {
235
+ target.addEventListener(type, listener);
236
+ this.handlers.push([
237
+ target,
238
+ type,
239
+ listener
240
+ ]);
241
+ };
242
+ bind(this.domElement, "mousedown", (e) => onMouseDown(this.mouse, e));
243
+ bind(window, "mousemove", (e) => onMouseMove(this.mouse, e));
244
+ bind(window, "mouseup", () => onMouseUp(this.mouse));
245
+ bind(window, "keydown", (e) => onKeyDown(this.keyboard, e));
246
+ bind(window, "keyup", (e) => onKeyUp(this.keyboard, e));
247
+ }
248
+ detach() {
249
+ for (const [target, type, listener] of this.handlers) target.removeEventListener(type, listener);
250
+ this.handlers.length = 0;
251
+ }
252
+ /** Replace the wall collision set (e.g. after the scene's walls change). */
253
+ setColliders(colliders) {
254
+ this.colliders = colliders;
255
+ }
256
+ /** Replace the walkable-area boundary polygon (or clear it with `undefined`). */
257
+ setBoundary(boundary) {
258
+ this.boundary = boundary;
259
+ }
260
+ /** Replace the ground-height function (or clear it to stop following terrain). */
261
+ setGroundHeight(groundHeight) {
262
+ this.groundHeight = groundHeight;
263
+ }
264
+ /**
265
+ * Enable/disable user input. While disabled, `update()` leaves the camera
266
+ * untouched so another driver (e.g. a camera tween) can own it; re-enable after
267
+ * syncing the pose with `setPose` so free-roam continues from there.
268
+ */
269
+ setEnabled(enabled) {
270
+ this.enabled = enabled;
271
+ }
272
+ /** Current pose: world position plus yaw (`phi`) and pitch (`theta`) in radians. */
273
+ getPose() {
274
+ return {
275
+ position: this.translation.clone(),
276
+ phi: this.look.phi,
277
+ theta: this.look.theta
278
+ };
279
+ }
280
+ /** Force the pose (e.g. at the end of a camera tween) so input resumes from it. */
281
+ setPose(position, phi, theta) {
282
+ this.translation.copy(position);
283
+ this.look.phi = phi;
284
+ this.look.theta = theta;
285
+ this.rotation = quaternionFromLook(this.look);
286
+ }
287
+ /** Advance the camera by `delta` seconds of input. */
288
+ update(delta) {
289
+ if (!this.enabled) {
290
+ commitMouseFrame(this.mouse);
291
+ return;
292
+ }
293
+ if (this.mouse.dragging) updateLookFromMouse(this.mouse, this.look);
294
+ this.look.phi += yawFromKeyboard(this.keyboard, delta);
295
+ updateLookFromTouch(this.input, this.look, delta);
296
+ this.rotation = quaternionFromLook(this.look);
297
+ const step = movementFromKeyboard(this.keyboard, this.look.phi, delta).add(movementFromTouch(this.input, this.look.phi, delta));
298
+ this.translation = resolveMove(this.translation, step, this.colliders, this.playerRadius, this.boundary);
299
+ if (this.groundHeight) this.translation.y = this.groundHeight(this.translation.x, this.translation.z) + this.eyeHeight;
300
+ if (step.x !== 0 || step.z !== 0) this.headBob.active = true;
301
+ updateHeadBob(this.headBob, delta);
302
+ this.camera.quaternion.copy(this.rotation);
303
+ this.camera.position.copy(this.translation);
304
+ this.camera.position.y += headBobOffset(this.headBob);
305
+ const target = new Vector3(0, 0, -1).applyQuaternion(this.rotation).multiplyScalar(100).add(this.translation);
306
+ this.camera.lookAt(target);
307
+ commitMouseFrame(this.mouse);
308
+ }
309
+ };
310
+ //#endregion
311
+ //#region src/plugin.ts
312
+ /**
313
+ * First-person camera — a *system* plugin. It drives the kernel camera from
314
+ * WASD/drag plus the shared analog input (joysticks), keeps it at `eyeHeight`
315
+ * over the terrain, and blocks it against wall colliders. Consumes the `terrain`
316
+ * and `colliders` capabilities (live, so it tracks async heightmaps and hot wall
317
+ * edits) and publishes `roamControl` so a framing/tour system can suspend it.
318
+ */
319
+ const fpc = () => ({
320
+ name: "fpc",
321
+ setup: (ctx) => {
322
+ const { camera, canvas, input, capabilities, scene } = ctx;
323
+ const terrainHeight = (x, z) => capabilities.get(TERRAIN)?.heightAt(x, z) ?? 0;
324
+ const groundHeight = () => capabilities.get(TERRAIN) ? terrainHeight : void 0;
325
+ const start = scene.camera.start;
326
+ const controls = new FreeRoamControls(camera, canvas, input, {
327
+ position: new Vector3(start.position[0], start.position[1], start.position[2]),
328
+ yaw: start.yaw,
329
+ boundary: scene.boundary,
330
+ eyeHeight: scene.camera.eyeHeight,
331
+ groundHeight: groundHeight()
332
+ });
333
+ controls.attach();
334
+ capabilities.provide(ROAM_CONTROL, controls);
335
+ const calibrateEyeHeight = (s) => {
336
+ if (s.camera.eyeHeight !== void 0) controls.setEyeHeight(s.camera.eyeHeight);
337
+ else {
338
+ const terrain = capabilities.get(TERRAIN);
339
+ if (terrain) terrain.ready.then(() => controls.recalibrateEyeHeight());
340
+ }
341
+ };
342
+ calibrateEyeHeight(scene);
343
+ return {
344
+ update: (deltaMs) => {
345
+ controls.setColliders(capabilities.get(COLLIDERS) ?? []);
346
+ controls.update(deltaMs / 1e3);
347
+ },
348
+ setScene: (s) => {
349
+ controls.setBoundary(s.boundary);
350
+ controls.setGroundHeight(groundHeight() ?? null);
351
+ calibrateEyeHeight(s);
352
+ },
353
+ dispose: () => {
354
+ controls.detach();
355
+ capabilities.revoke(ROAM_CONTROL);
356
+ }
357
+ };
358
+ }
359
+ });
360
+ //#endregion
361
+ export { FreeRoamControls, fpc, insidePolygon, resolveMove };
362
+
363
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +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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"}
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@scenography/plugin-fpc",
3
+ "version": "0.2.0",
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
+ "license": "MIT",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "type": "module",
10
+ "main": "./dist/index.cjs",
11
+ "module": "./dist/index.mjs",
12
+ "types": "./dist/index.d.cts",
13
+ "exports": {
14
+ ".": {
15
+ "import": {
16
+ "types": "./dist/index.d.mts",
17
+ "default": "./dist/index.mjs"
18
+ },
19
+ "require": {
20
+ "types": "./dist/index.d.cts",
21
+ "default": "./dist/index.cjs"
22
+ }
23
+ }
24
+ },
25
+ "dependencies": {
26
+ "@scenography/scene": "0.2.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/three": "^0.160.0",
30
+ "three": "^0.160.0",
31
+ "@scenography/engine": "0.2.0"
32
+ },
33
+ "peerDependencies": {
34
+ "three": ">=0.160.0",
35
+ "@scenography/engine": "0.2.0"
36
+ },
37
+ "scripts": {
38
+ "build": "tsdown",
39
+ "dev": "tsdown --watch",
40
+ "typecheck": "tsc --noEmit"
41
+ }
42
+ }