@worldmesh/runtime 0.1.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/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/abilities/abilities.d.ts +9 -0
- package/dist/abilities/abilities.d.ts.map +1 -0
- package/dist/abilities/abilities.js +25 -0
- package/dist/abilities/abilities.js.map +1 -0
- package/dist/camera/cameraRig.d.ts +39 -0
- package/dist/camera/cameraRig.d.ts.map +1 -0
- package/dist/camera/cameraRig.js +88 -0
- package/dist/camera/cameraRig.js.map +1 -0
- package/dist/controls/input.d.ts +64 -0
- package/dist/controls/input.d.ts.map +1 -0
- package/dist/controls/input.js +236 -0
- package/dist/controls/input.js.map +1 -0
- package/dist/controls/keymap.d.ts +8 -0
- package/dist/controls/keymap.d.ts.map +1 -0
- package/dist/controls/keymap.js +21 -0
- package/dist/controls/keymap.js.map +1 -0
- package/dist/controls/touch.d.ts +43 -0
- package/dist/controls/touch.d.ts.map +1 -0
- package/dist/controls/touch.js +329 -0
- package/dist/controls/touch.js.map +1 -0
- package/dist/core/events.d.ts +9 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +27 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/worldmesh.d.ts +10 -0
- package/dist/core/worldmesh.d.ts.map +1 -0
- package/dist/core/worldmesh.js +283 -0
- package/dist/core/worldmesh.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/movement/collision.d.ts +39 -0
- package/dist/movement/collision.d.ts.map +1 -0
- package/dist/movement/collision.js +93 -0
- package/dist/movement/collision.js.map +1 -0
- package/dist/movement/controller.d.ts +53 -0
- package/dist/movement/controller.d.ts.map +1 -0
- package/dist/movement/controller.js +200 -0
- package/dist/movement/controller.js.map +1 -0
- package/dist/net/adapter.d.ts +22 -0
- package/dist/net/adapter.d.ts.map +1 -0
- package/dist/net/adapter.js +8 -0
- package/dist/net/adapter.js.map +1 -0
- package/dist/player/avatar.d.ts +29 -0
- package/dist/player/avatar.d.ts.map +1 -0
- package/dist/player/avatar.js +420 -0
- package/dist/player/avatar.js.map +1 -0
- package/dist/player/player.d.ts +30 -0
- package/dist/player/player.d.ts.map +1 -0
- package/dist/player/player.js +76 -0
- package/dist/player/player.js.map +1 -0
- package/dist/portals/portals.d.ts +34 -0
- package/dist/portals/portals.d.ts.map +1 -0
- package/dist/portals/portals.js +132 -0
- package/dist/portals/portals.js.map +1 -0
- package/dist/types.d.ts +228 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/overlay.d.ts +19 -0
- package/dist/ui/overlay.d.ts.map +1 -0
- package/dist/ui/overlay.js +370 -0
- package/dist/ui/overlay.js.map +1 -0
- package/package.json +63 -0
- package/src/abilities/abilities.ts +30 -0
- package/src/camera/cameraRig.ts +106 -0
- package/src/controls/input.ts +252 -0
- package/src/controls/keymap.ts +23 -0
- package/src/controls/touch.ts +378 -0
- package/src/core/events.ts +31 -0
- package/src/core/worldmesh.ts +313 -0
- package/src/index.ts +46 -0
- package/src/movement/collision.ts +109 -0
- package/src/movement/controller.ts +248 -0
- package/src/net/adapter.ts +28 -0
- package/src/player/avatar.ts +495 -0
- package/src/player/player.ts +91 -0
- package/src/portals/portals.ts +173 -0
- package/src/types.ts +230 -0
- package/src/ui/overlay.ts +398 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { Vector3 } from 'three';
|
|
2
|
+
import { resolveAbilities } from '../abilities/abilities.js';
|
|
3
|
+
import { CameraRig } from '../camera/cameraRig.js';
|
|
4
|
+
import { Input } from '../controls/input.js';
|
|
5
|
+
import { CollisionWorld } from '../movement/collision.js';
|
|
6
|
+
import { MovementController } from '../movement/controller.js';
|
|
7
|
+
import { expressionForDigit, isAvatarExpression } from '../player/avatar.js';
|
|
8
|
+
import { Player } from '../player/player.js';
|
|
9
|
+
import { PortalManager, buildTravelUrl, type ResolvedPortal } from '../portals/portals.js';
|
|
10
|
+
import type {
|
|
11
|
+
PlayerState,
|
|
12
|
+
Vec3Tuple,
|
|
13
|
+
ViewMode,
|
|
14
|
+
WorldMeshEvents,
|
|
15
|
+
WorldMeshHandle,
|
|
16
|
+
WorldMeshOptions,
|
|
17
|
+
} from '../types.js';
|
|
18
|
+
import { Overlay } from '../ui/overlay.js';
|
|
19
|
+
import { isTouchDevice } from '../controls/touch.js';
|
|
20
|
+
import { Emitter } from './events.js';
|
|
21
|
+
|
|
22
|
+
/** Never simulate more than this per substep, so a stall cannot tunnel the player. */
|
|
23
|
+
const MAX_STEP = 1 / 60;
|
|
24
|
+
/** Never simulate more than this per frame, so a backgrounded tab does not catch up violently. */
|
|
25
|
+
const MAX_FRAME = 0.1;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Turn an ordinary Three.js scene into a WorldMesh world.
|
|
29
|
+
*
|
|
30
|
+
* This is the whole compatibility surface: a world that can produce a scene,
|
|
31
|
+
* a camera and a renderer gets the shared controls, both camera modes, the
|
|
32
|
+
* shared overlay and portals from one call.
|
|
33
|
+
*/
|
|
34
|
+
export function createWorldMesh(options: WorldMeshOptions): WorldMeshHandle {
|
|
35
|
+
const { scene, camera, renderer } = options;
|
|
36
|
+
const spawn = new Vector3(...(options.spawn ?? [0, 2, 0]));
|
|
37
|
+
const abilities = resolveAbilities(options.abilities);
|
|
38
|
+
|
|
39
|
+
const height = options.player?.height ?? 1.8;
|
|
40
|
+
const radius = options.player?.radius ?? 0.35;
|
|
41
|
+
|
|
42
|
+
const collision = new CollisionWorld(options.colliders ?? [], options.groundLevel ?? 0);
|
|
43
|
+
const events = new Emitter<WorldMeshEvents>();
|
|
44
|
+
|
|
45
|
+
const overlay = new Overlay({
|
|
46
|
+
...options.ui,
|
|
47
|
+
onEnter: (touch) => input.requestPointerLock(touch),
|
|
48
|
+
onInteract: () => {
|
|
49
|
+
if (activePortal) {
|
|
50
|
+
activatePortal(activePortal);
|
|
51
|
+
} else {
|
|
52
|
+
input.triggerAction('interact');
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const input = new Input({
|
|
58
|
+
element: renderer.domElement,
|
|
59
|
+
keymap: options.keymap,
|
|
60
|
+
onPointerLockChange: (locked) => {
|
|
61
|
+
overlay.setLocked(locked);
|
|
62
|
+
events.emit('pointer:lock', { locked });
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const cameraRig = new CameraRig({ ...options.view, camera, collision });
|
|
67
|
+
const player = new Player({ ...options.player, height, radius });
|
|
68
|
+
if (player.root) scene.add(player.root);
|
|
69
|
+
|
|
70
|
+
const controller = new MovementController({
|
|
71
|
+
input,
|
|
72
|
+
collision,
|
|
73
|
+
abilities,
|
|
74
|
+
tuning: options.movement,
|
|
75
|
+
height,
|
|
76
|
+
radius,
|
|
77
|
+
spawn,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const portals = new PortalManager(scene, options.portals);
|
|
81
|
+
|
|
82
|
+
let running = false;
|
|
83
|
+
let frameHandle = 0;
|
|
84
|
+
let lastTime = 0;
|
|
85
|
+
let elapsed = 0;
|
|
86
|
+
let activePortal: ResolvedPortal | null = null;
|
|
87
|
+
let disposed = false;
|
|
88
|
+
|
|
89
|
+
const handle: WorldMeshHandle = {
|
|
90
|
+
scene,
|
|
91
|
+
camera,
|
|
92
|
+
renderer,
|
|
93
|
+
abilities,
|
|
94
|
+
avatar: player.root,
|
|
95
|
+
|
|
96
|
+
start,
|
|
97
|
+
stop,
|
|
98
|
+
update,
|
|
99
|
+
dispose,
|
|
100
|
+
|
|
101
|
+
getState,
|
|
102
|
+
setState,
|
|
103
|
+
teleport,
|
|
104
|
+
respawn: () => doRespawn('manual'),
|
|
105
|
+
|
|
106
|
+
setViewMode,
|
|
107
|
+
getViewMode: () => cameraRig.mode,
|
|
108
|
+
|
|
109
|
+
addPortal: (portal) => {
|
|
110
|
+
portals.add(portal);
|
|
111
|
+
},
|
|
112
|
+
travelTo,
|
|
113
|
+
|
|
114
|
+
on: (event, fn) => events.on(event, fn),
|
|
115
|
+
off: (event, fn) => events.off(event, fn),
|
|
116
|
+
|
|
117
|
+
refreshColliders: () => collision.refresh(),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
applyViewVisibility();
|
|
121
|
+
|
|
122
|
+
if (options.autoResize !== false) window.addEventListener('resize', handleResize);
|
|
123
|
+
handleResize();
|
|
124
|
+
|
|
125
|
+
if (options.autoStart !== false) start();
|
|
126
|
+
|
|
127
|
+
options.network?.attach(handle);
|
|
128
|
+
|
|
129
|
+
return handle;
|
|
130
|
+
|
|
131
|
+
function start(): void {
|
|
132
|
+
if (running || disposed) return;
|
|
133
|
+
running = true;
|
|
134
|
+
lastTime = performance.now();
|
|
135
|
+
frameHandle = requestAnimationFrame(frame);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function stop(): void {
|
|
139
|
+
running = false;
|
|
140
|
+
if (frameHandle) cancelAnimationFrame(frameHandle);
|
|
141
|
+
frameHandle = 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function frame(now: number): void {
|
|
145
|
+
if (!running) return;
|
|
146
|
+
frameHandle = requestAnimationFrame(frame);
|
|
147
|
+
const dt = Math.min((now - lastTime) / 1000, MAX_FRAME);
|
|
148
|
+
lastTime = now;
|
|
149
|
+
update(dt);
|
|
150
|
+
options.onUpdate?.(dt, handle);
|
|
151
|
+
renderer.render(scene, camera);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Advance the simulation. Split from rendering so a fixed tick can drive it. */
|
|
155
|
+
function update(dt: number): void {
|
|
156
|
+
if (dt <= 0) return;
|
|
157
|
+
elapsed += dt;
|
|
158
|
+
|
|
159
|
+
const look = input.readLook();
|
|
160
|
+
if (input.locked) {
|
|
161
|
+
cameraRig.look(look.dx, look.dy);
|
|
162
|
+
cameraRig.zoom(look.wheel);
|
|
163
|
+
if (input.consume('toggleView')) setViewMode(cameraRig.mode === 'first' ? 'third' : 'first');
|
|
164
|
+
const digit = input.consumeDigit();
|
|
165
|
+
const expression = digit === null ? null : expressionForDigit(digit);
|
|
166
|
+
if (expression) player.setExpression(expression);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Fixed substeps keep movement stable regardless of frame rate.
|
|
170
|
+
let remaining = dt;
|
|
171
|
+
while (remaining > 0) {
|
|
172
|
+
const step = Math.min(remaining, MAX_STEP);
|
|
173
|
+
controller.step(step, cameraRig.yaw, input.locked);
|
|
174
|
+
remaining -= step;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (controller.position.y < controller.tuning.fallLimit) doRespawn('fell');
|
|
178
|
+
|
|
179
|
+
cameraRig.update(dt, controller.position, player.eyeHeight * (controller.height / height));
|
|
180
|
+
player.sync(controller.position, cameraRig.yaw, controller.height, {
|
|
181
|
+
dt,
|
|
182
|
+
speed: Math.hypot(controller.velocity.x, controller.velocity.z),
|
|
183
|
+
grounded: controller.onGround || controller.flying,
|
|
184
|
+
heading: Math.atan2(-controller.velocity.x, -controller.velocity.z),
|
|
185
|
+
}, cameraRig.mode === 'third');
|
|
186
|
+
portals.animate(elapsed);
|
|
187
|
+
|
|
188
|
+
updatePortals();
|
|
189
|
+
input.endFrame();
|
|
190
|
+
|
|
191
|
+
events.emit('update', { dt, state: getState() });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function updatePortals(): void {
|
|
195
|
+
const nearest = portals.findActive(controller.position);
|
|
196
|
+
|
|
197
|
+
if (nearest !== activePortal) {
|
|
198
|
+
if (activePortal) events.emit('portal:exit', { portal: activePortal });
|
|
199
|
+
if (nearest) events.emit('portal:enter', { portal: nearest });
|
|
200
|
+
activePortal = nearest;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!nearest) {
|
|
204
|
+
overlay.setPrompt(null);
|
|
205
|
+
if (input.locked && input.consume('interact')) {
|
|
206
|
+
events.emit('interact', { position: controller.position.toArray() as Vec3Tuple });
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (nearest.mode === 'auto') {
|
|
212
|
+
overlay.setPrompt(`Entering ${nearest.label}…`);
|
|
213
|
+
activatePortal(nearest);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const isTouch = isTouchDevice();
|
|
218
|
+
overlay.setPrompt(isTouch ? `Tap to enter ${nearest.label}` : `Press E to enter ${nearest.label}`);
|
|
219
|
+
if (input.locked && input.consume('interact')) activatePortal(nearest);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function activatePortal(portal: ResolvedPortal): void {
|
|
223
|
+
const url = buildTravelUrl(portal.url);
|
|
224
|
+
let cancelled = false;
|
|
225
|
+
events.emit('portal:activate', {
|
|
226
|
+
portal,
|
|
227
|
+
url,
|
|
228
|
+
preventDefault: () => {
|
|
229
|
+
cancelled = true;
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
if (cancelled) return;
|
|
233
|
+
input.exitPointerLock();
|
|
234
|
+
window.location.href = url;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function setViewMode(mode: ViewMode): void {
|
|
238
|
+
if (cameraRig.mode === mode) return;
|
|
239
|
+
cameraRig.setMode(mode);
|
|
240
|
+
applyViewVisibility();
|
|
241
|
+
events.emit('view:change', { mode });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** In first person the avatar would fill the screen. */
|
|
245
|
+
function applyViewVisibility(): void {
|
|
246
|
+
player.setVisible(cameraRig.mode === 'third');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getState(): PlayerState {
|
|
250
|
+
return {
|
|
251
|
+
position: controller.position.toArray() as Vec3Tuple,
|
|
252
|
+
velocity: controller.velocity.toArray() as Vec3Tuple,
|
|
253
|
+
yaw: cameraRig.yaw,
|
|
254
|
+
pitch: cameraRig.pitch,
|
|
255
|
+
facing: player.facing,
|
|
256
|
+
onGround: controller.onGround,
|
|
257
|
+
crouching: controller.crouching,
|
|
258
|
+
flying: controller.flying,
|
|
259
|
+
expression: player.expression,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function setState(state: Partial<PlayerState>): void {
|
|
264
|
+
if (state.position) controller.position.set(...state.position);
|
|
265
|
+
if (state.velocity) controller.velocity.set(...state.velocity);
|
|
266
|
+
if (typeof state.yaw === 'number') cameraRig.yaw = state.yaw;
|
|
267
|
+
if (typeof state.facing === 'number') player.facing = state.facing;
|
|
268
|
+
if (typeof state.pitch === 'number') cameraRig.pitch = state.pitch;
|
|
269
|
+
if (typeof state.flying === 'boolean') controller.flying = state.flying;
|
|
270
|
+
if (isAvatarExpression(state.expression)) player.setExpression(state.expression);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function teleport(position: Vec3Tuple, yaw?: number): void {
|
|
274
|
+
controller.reset(new Vector3(...position));
|
|
275
|
+
if (typeof yaw === 'number') {
|
|
276
|
+
cameraRig.yaw = yaw;
|
|
277
|
+
player.facing = yaw;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function doRespawn(reason: 'fell' | 'manual'): void {
|
|
282
|
+
controller.reset(spawn);
|
|
283
|
+
events.emit('respawn', { reason });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function travelTo(url: string): void {
|
|
287
|
+
input.exitPointerLock();
|
|
288
|
+
window.location.href = buildTravelUrl(url);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function handleResize(): void {
|
|
292
|
+
const width = window.innerWidth;
|
|
293
|
+
const viewHeight = window.innerHeight;
|
|
294
|
+
// Pixel ratio first: setSize derives the drawing buffer from it, and it
|
|
295
|
+
// must also update the canvas CSS size or the canvas overflows the window.
|
|
296
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
297
|
+
renderer.setSize(width, viewHeight);
|
|
298
|
+
cameraRig.resize(width, viewHeight);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function dispose(): void {
|
|
302
|
+
if (disposed) return;
|
|
303
|
+
disposed = true;
|
|
304
|
+
stop();
|
|
305
|
+
window.removeEventListener('resize', handleResize);
|
|
306
|
+
options.network?.detach?.();
|
|
307
|
+
input.dispose();
|
|
308
|
+
overlay.dispose();
|
|
309
|
+
portals.dispose();
|
|
310
|
+
player.dispose();
|
|
311
|
+
events.clear();
|
|
312
|
+
}
|
|
313
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export { createWorldMesh } from './core/worldmesh.js';
|
|
2
|
+
export { Emitter } from './core/events.js';
|
|
3
|
+
export { Input } from './controls/input.js';
|
|
4
|
+
export { TouchControls, isTouchDevice } from './controls/touch.js';
|
|
5
|
+
export { DEFAULT_KEYMAP, resolveKeymap } from './controls/keymap.js';
|
|
6
|
+
export { DEFAULT_ABILITIES, resolveAbilities } from './abilities/abilities.js';
|
|
7
|
+
export { CollisionWorld } from './movement/collision.js';
|
|
8
|
+
export { DEFAULT_TUNING, MovementController } from './movement/controller.js';
|
|
9
|
+
export { CameraRig } from './camera/cameraRig.js';
|
|
10
|
+
export { Player } from './player/player.js';
|
|
11
|
+
export {
|
|
12
|
+
AVATAR_EXPRESSIONS,
|
|
13
|
+
animateDefaultAvatar,
|
|
14
|
+
createDefaultAvatar,
|
|
15
|
+
expressionForDigit,
|
|
16
|
+
getAvatarExpression,
|
|
17
|
+
isAvatarExpression,
|
|
18
|
+
setAvatarExpression,
|
|
19
|
+
} from './player/avatar.js';
|
|
20
|
+
export type { AvatarExpression, AvatarMotion } from './player/avatar.js';
|
|
21
|
+
export { PortalManager, buildTravelUrl, getReferringWorld } from './portals/portals.js';
|
|
22
|
+
export { Overlay } from './ui/overlay.js';
|
|
23
|
+
export { deserializePlayerState, serializePlayerState } from './net/adapter.js';
|
|
24
|
+
|
|
25
|
+
export const WORLDMESH_VERSION = '0.1.0';
|
|
26
|
+
/** Bumped when the world-facing contract changes in an incompatible way. */
|
|
27
|
+
export const WORLDMESH_PROTOCOL = 1;
|
|
28
|
+
|
|
29
|
+
export type {
|
|
30
|
+
Abilities,
|
|
31
|
+
InputAction,
|
|
32
|
+
Keymap,
|
|
33
|
+
MovementTuning,
|
|
34
|
+
NetworkAdapter,
|
|
35
|
+
PlayerOptions,
|
|
36
|
+
PlayerState,
|
|
37
|
+
PortalMode,
|
|
38
|
+
PortalOptions,
|
|
39
|
+
UiOptions,
|
|
40
|
+
Vec3Tuple,
|
|
41
|
+
ViewMode,
|
|
42
|
+
ViewOptions,
|
|
43
|
+
WorldMeshEvents,
|
|
44
|
+
WorldMeshHandle,
|
|
45
|
+
WorldMeshOptions,
|
|
46
|
+
} from './types.js';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Object3D, Raycaster, Vector3 } from 'three';
|
|
2
|
+
|
|
3
|
+
const DOWN = new Vector3(0, -1, 0);
|
|
4
|
+
|
|
5
|
+
export interface GroundSample {
|
|
6
|
+
/**
|
|
7
|
+
* Surface height under the player. `-Infinity` when the world has colliders
|
|
8
|
+
* but none of them are below the player — that is a hole, and the player
|
|
9
|
+
* should keep falling rather than snap to an imaginary floor.
|
|
10
|
+
*/
|
|
11
|
+
y: number;
|
|
12
|
+
hit: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Deliberately minimal collision: raycasts against a world-supplied list of
|
|
17
|
+
* meshes. No physics engine, no broadphase. It is enough to stand on things
|
|
18
|
+
* and to stop walking through walls, which is all the MVP needs to prove that
|
|
19
|
+
* one controller works across five very different worlds.
|
|
20
|
+
*/
|
|
21
|
+
export class CollisionWorld {
|
|
22
|
+
private raycaster = new Raycaster();
|
|
23
|
+
private colliders: Object3D[] = [];
|
|
24
|
+
private source: Object3D[] | (() => Object3D[]);
|
|
25
|
+
/** Y used when a world supplies no colliders at all. */
|
|
26
|
+
groundLevel: number;
|
|
27
|
+
|
|
28
|
+
constructor(source: Object3D[] | (() => Object3D[]) = [], groundLevel = 0) {
|
|
29
|
+
this.source = source;
|
|
30
|
+
this.groundLevel = groundLevel;
|
|
31
|
+
this.refresh();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
refresh(): void {
|
|
35
|
+
this.colliders = typeof this.source === 'function' ? this.source() : this.source;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get isEmpty(): boolean {
|
|
39
|
+
return this.colliders.length === 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Find the ground under `position`. `stepHeight` lets the player walk up
|
|
44
|
+
* small ledges without jumping; `maxDrop` limits how far down we look.
|
|
45
|
+
*/
|
|
46
|
+
sampleGround(position: Vector3, stepHeight = 0.6, maxDrop = 4): GroundSample {
|
|
47
|
+
if (this.isEmpty) return { y: this.groundLevel, hit: false };
|
|
48
|
+
|
|
49
|
+
this.raycaster.set(new Vector3(position.x, position.y + stepHeight, position.z), DOWN);
|
|
50
|
+
this.raycaster.far = stepHeight + maxDrop;
|
|
51
|
+
const hits = this.raycaster.intersectObjects(this.colliders, true);
|
|
52
|
+
const hit = hits.find((candidate) => candidate.face !== null) ?? hits[0];
|
|
53
|
+
if (!hit) return { y: -Infinity, hit: false };
|
|
54
|
+
return { y: hit.point.y, hit: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Slide a horizontal movement `delta` along whatever it runs into.
|
|
59
|
+
* Two passes so that sliding into a corner does not tunnel through it.
|
|
60
|
+
*/
|
|
61
|
+
resolveHorizontal(feet: Vector3, delta: Vector3, radius: number, height: number): Vector3 {
|
|
62
|
+
const result = delta.clone();
|
|
63
|
+
if (this.isEmpty || (result.x === 0 && result.z === 0)) return result;
|
|
64
|
+
|
|
65
|
+
// Probe up the body: a knee-only ray walks through railings, a chest-only
|
|
66
|
+
// ray walks through crates, and two rays miss anything between them.
|
|
67
|
+
const heights = [height * 0.15, height * 0.4, height * 0.65, height * 0.9];
|
|
68
|
+
const probe = new Vector3();
|
|
69
|
+
const direction = new Vector3();
|
|
70
|
+
|
|
71
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
72
|
+
const distance = Math.hypot(result.x, result.z);
|
|
73
|
+
if (distance < 1e-5) break;
|
|
74
|
+
direction.set(result.x, 0, result.z).divideScalar(distance);
|
|
75
|
+
|
|
76
|
+
let blocked = false;
|
|
77
|
+
for (const offset of heights) {
|
|
78
|
+
probe.set(feet.x, feet.y + offset, feet.z);
|
|
79
|
+
this.raycaster.set(probe, direction);
|
|
80
|
+
this.raycaster.far = distance + radius;
|
|
81
|
+
const hit = this.raycaster.intersectObjects(this.colliders, true)[0];
|
|
82
|
+
if (!hit || !hit.face) continue;
|
|
83
|
+
|
|
84
|
+
const normal = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
|
|
85
|
+
normal.y = 0;
|
|
86
|
+
if (normal.lengthSq() < 1e-6) continue;
|
|
87
|
+
normal.normalize();
|
|
88
|
+
|
|
89
|
+
const into = result.dot(normal);
|
|
90
|
+
if (into < 0) {
|
|
91
|
+
result.addScaledVector(normal, -into);
|
|
92
|
+
blocked = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!blocked) break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Distance from `origin` along `direction` before hitting something. */
|
|
102
|
+
castDistance(origin: Vector3, direction: Vector3, far: number): number {
|
|
103
|
+
if (this.isEmpty) return far;
|
|
104
|
+
this.raycaster.set(origin, direction);
|
|
105
|
+
this.raycaster.far = far;
|
|
106
|
+
const hit = this.raycaster.intersectObjects(this.colliders, true)[0];
|
|
107
|
+
return hit ? hit.distance : far;
|
|
108
|
+
}
|
|
109
|
+
}
|