@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 +367 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +126 -0
- package/dist/index.d.mts +126 -0
- package/dist/index.mjs +363 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +42 -0
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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"}
|
package/dist/index.d.cts
ADDED
|
@@ -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
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|