@ludra/three 0.0.0-stage → 0.92.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/src/index.ts ADDED
@@ -0,0 +1,767 @@
1
+ import * as THREE from "three";
2
+ import { ConvexGeometry } from "three/addons/geometries/ConvexGeometry.js";
3
+ import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";
4
+ import { GLTFLoader, type GLTF } from "three/addons/loaders/GLTFLoader.js";
5
+ import { MeshoptDecoder } from "three/addons/libs/meshopt_decoder.module.js";
6
+ import { clone as cloneSkinned } from "three/addons/utils/SkeletonUtils.js";
7
+ import {
8
+ FIXED_DT,
9
+ type AnimationState,
10
+ type Body,
11
+ type Camera3D,
12
+ type DrawEvent,
13
+ type Entity,
14
+ type EnvironmentDef,
15
+ type GameEvent,
16
+ type Light as LightSpec,
17
+ type Mesh as MeshSpec,
18
+ type RenderContext,
19
+ type SceneDef,
20
+ } from "@ludra/core";
21
+ import type { Renderer3D, Renderer3DFactory } from "@ludra/core/browser";
22
+
23
+ /**
24
+ * The 3D renderer for Ludra: Three.js draws every entity with a `mesh` and lights the scene
25
+ * with its `light` entities, seen through a `camera3d`. It only reads the world, so it can
26
+ * never change what tests verified.
27
+ */
28
+
29
+ /** The World as the renderer needs it: what to draw, its events, and its render hooks. */
30
+ interface WorldView {
31
+ readonly entities: readonly Entity[];
32
+ readonly events?: readonly GameEvent[];
33
+ readonly time?: number;
34
+ callRenderHook?(entity: Entity, extras: Omit<RenderContext, "self" | "world" | "params">): void;
35
+ }
36
+
37
+ const DEG = Math.PI / 180;
38
+ const TONE_MAPPING = {
39
+ aces: THREE.ACESFilmicToneMapping,
40
+ neutral: THREE.NeutralToneMapping,
41
+ agx: THREE.AgXToneMapping,
42
+ none: THREE.NoToneMapping,
43
+ } as const;
44
+ /** Collider colors in the debug view, as in 2D: static, dynamic, kinematic, sensor, player. */
45
+ const DEBUG_COLORS = { static: 0x7fdbff, dynamic: 0xffdc00, kinematic: 0xff851b, sensor: 0x2ecc40, player: 0xff4136 } as const;
46
+
47
+ /** Creates the 3D renderer for a scene. Pass it to startGame as `loadRenderer3D`. */
48
+ export const createRenderer3D: Renderer3DFactory = async (scene, options) => {
49
+ const renderer = new ThreeRenderer(scene, options.width, options.height, options.assetBase);
50
+ await renderer.load();
51
+ return renderer;
52
+ };
53
+
54
+ /** A drawn glTF model and what poses it. */
55
+ interface Model {
56
+ root: THREE.Object3D;
57
+ clips: THREE.AnimationClip[];
58
+ mixer: THREE.AnimationMixer;
59
+ actions: Map<string, THREE.AnimationAction>;
60
+ }
61
+
62
+ /** A clip an entity started playing, at simulation time `since`. `once` holds the last pose (a jump). */
63
+ interface ClipStart {
64
+ clip: string;
65
+ since: number;
66
+ once: boolean;
67
+ }
68
+
69
+ interface Rig {
70
+ position: THREE.Vector3;
71
+ look: THREE.Vector3;
72
+ /** The point shadows are centred on. */
73
+ focus: THREE.Vector3;
74
+ /** Orbit cameras keep turning. */
75
+ yaw: number;
76
+ }
77
+
78
+ export class ThreeRenderer implements Renderer3D {
79
+ readonly canvas: HTMLCanvasElement;
80
+ debug = false;
81
+ /** Three.js itself, for render hooks and anything else (step 4 hands these to scripts). */
82
+ readonly renderer: THREE.WebGLRenderer;
83
+ readonly scene = new THREE.Scene();
84
+ readonly camera: THREE.PerspectiveCamera;
85
+ private readonly objects = new Map<Entity, THREE.Object3D>();
86
+ private readonly lights = new Map<Entity, THREE.Light>();
87
+ private readonly textures = new Map<string, THREE.Texture>();
88
+ /** Loaded glTF files by path; null when one failed to load (its entities fall back to a shape). */
89
+ private readonly gltfs = new Map<string, GLTF | null>();
90
+ private readonly gltfLoads = new Map<string, Promise<void>>();
91
+ private gltfLoader: GLTFLoader | undefined;
92
+ private readonly models = new Map<Entity, Model>();
93
+ /** Entities drawn with a placeholder while their model loads. */
94
+ private readonly waiting = new Set<Entity>();
95
+ /**
96
+ * The clips each modelled entity played, recorded once per simulation step (not per frame), so a
97
+ * pose depends only on the simulation: a screenshot shows what the live game shows at that moment.
98
+ */
99
+ private readonly clipHistory = new Map<Entity, ClipStart[]>();
100
+ private readonly debugGroup = new THREE.Group();
101
+ private readonly defaultLights = new THREE.Group();
102
+ private rig: Rig | undefined;
103
+ /** Each render-hook entity's own group, and its ctx.local. */
104
+ private readonly hookGroups = new Map<Entity, THREE.Group>();
105
+ private readonly hookLocals = new Map<Entity, Record<string, unknown>>();
106
+ /** A render hook's own way of drawing the frame (an EffectComposer), if one set it. */
107
+ private customRender: (() => void) | undefined;
108
+ /** Addons render hooks are still loading. */
109
+ private readonly pending = new Set<Promise<unknown>>();
110
+
111
+ private readonly def: SceneDef;
112
+ private readonly assetBase: string;
113
+
114
+ constructor(def: SceneDef, width: number, height: number, assetBase: string) {
115
+ this.def = def;
116
+ this.assetBase = assetBase;
117
+ this.canvas = document.createElement("canvas");
118
+ this.canvas.width = width;
119
+ this.canvas.height = height;
120
+ // preserveDrawingBuffer: the frame is copied into the game's 2D canvas after drawing.
121
+ this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true, preserveDrawingBuffer: true });
122
+ this.renderer.setPixelRatio(1);
123
+ this.renderer.setSize(width, height, false);
124
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace;
125
+ const env: EnvironmentDef = def.environment ?? {};
126
+ this.renderer.toneMapping = TONE_MAPPING[env.toneMapping ?? "aces"];
127
+ this.renderer.toneMappingExposure = env.exposure ?? 1;
128
+ this.renderer.shadowMap.enabled = env.shadows ?? true;
129
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
130
+ this.camera = new THREE.PerspectiveCamera(55, width / height, 0.05, 2000);
131
+ this.applyEnvironment(env);
132
+ this.scene.add(this.debugGroup, this.defaultLights);
133
+ }
134
+
135
+ /** Loads every texture and model the scene (and its prefabs) names, so the first frame (and screenshots) are complete. */
136
+ async load(): Promise<void> {
137
+ const loader = new THREE.TextureLoader();
138
+ const meshes = [...this.def.entities, ...Object.values(this.def.prefabs ?? {})].map((e) => e.components?.mesh as Record<string, unknown> | undefined);
139
+ const named = (field: string) => new Set(meshes.map((m) => m?.[field]).filter((p): p is string => typeof p === "string" && p !== ""));
140
+ const paths = named("texture");
141
+ const models = [...named("model")].map((path) => this.loadModel(path));
142
+ await Promise.all([
143
+ ...models,
144
+ ...[...paths].map(async (path) => {
145
+ try {
146
+ const texture = await loader.loadAsync(this.assetBase + path);
147
+ texture.colorSpace = THREE.SRGBColorSpace;
148
+ texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
149
+ texture.anisotropy = 4;
150
+ this.textures.set(path, texture);
151
+ } catch {
152
+ console.warn(`[ludra] could not load texture "${path}", using the mesh color instead.`);
153
+ }
154
+ }),
155
+ ]);
156
+ }
157
+
158
+ /** Loads a glTF file once. Draco and meshopt compressed files work too. */
159
+ private loadModel(path: string): Promise<void> {
160
+ let load = this.gltfLoads.get(path);
161
+ if (load) return load;
162
+ if (!this.gltfLoader) {
163
+ this.gltfLoader = new GLTFLoader();
164
+ const draco = new DRACOLoader();
165
+ // The decoder sits in three's addons, which the import map serves.
166
+ draco.setDecoderPath(new URL("../libs/draco/gltf/", import.meta.resolve("three/addons/loaders/DRACOLoader.js")).href);
167
+ this.gltfLoader.setDRACOLoader(draco);
168
+ this.gltfLoader.setMeshoptDecoder(MeshoptDecoder);
169
+ }
170
+ load = this.track(
171
+ this.gltfLoader.loadAsync(this.assetBase + path).then(
172
+ (gltf) => void this.gltfs.set(path, gltf),
173
+ (err: unknown) => {
174
+ console.warn(`[ludra] could not load model "${path}" (${(err as Error)?.message ?? err}), drawing its shape instead.`);
175
+ this.gltfs.set(path, null);
176
+ },
177
+ ),
178
+ );
179
+ this.gltfLoads.set(path, load);
180
+ return load;
181
+ }
182
+
183
+ private applyEnvironment(env: EnvironmentDef): void {
184
+ const sky = env.sky ?? ["#6fa8ff", "#dfeaff"];
185
+ if (typeof sky === "string") this.scene.background = new THREE.Color(sky);
186
+ else {
187
+ // A vertical gradient: top of the sky to the horizon.
188
+ const c = document.createElement("canvas");
189
+ c.width = 2;
190
+ c.height = 256;
191
+ const g = c.getContext("2d")!;
192
+ const gradient = g.createLinearGradient(0, 0, 0, 256);
193
+ gradient.addColorStop(0, sky[0]);
194
+ gradient.addColorStop(1, sky[1]);
195
+ g.fillStyle = gradient;
196
+ g.fillRect(0, 0, 2, 256);
197
+ const texture = new THREE.CanvasTexture(c);
198
+ texture.colorSpace = THREE.SRGBColorSpace;
199
+ this.scene.background = texture;
200
+ }
201
+ if (env.fog) {
202
+ const color = env.fog.color ?? (typeof sky === "string" ? sky : sky[1]);
203
+ this.scene.fog = new THREE.Fog(color, env.fog.near ?? 30, env.fog.far ?? 150);
204
+ }
205
+ }
206
+
207
+ step(world: WorldView): void {
208
+ this.updateRig(world, FIXED_DT);
209
+ this.recordClips(world);
210
+ }
211
+
212
+ reset(world: WorldView): void {
213
+ this.rig = undefined;
214
+ this.clipHistory.clear();
215
+ this.recordClips(world);
216
+ // Things render hooks built belong to the old run.
217
+ for (const group of this.hookGroups.values()) group.removeFromParent();
218
+ this.hookGroups.clear();
219
+ this.hookLocals.clear();
220
+ this.customRender = undefined;
221
+ this.updateRig(world, 0);
222
+ }
223
+
224
+ render(world: WorldView): void {
225
+ if (!this.rig) this.updateRig(world, 0);
226
+ this.sync(world);
227
+ const rig = this.rig!;
228
+ this.camera.position.copy(rig.position);
229
+ this.camera.lookAt(rig.look);
230
+ this.poseModels(world);
231
+ this.runRenderHooks(world);
232
+ if (this.customRender) this.customRender();
233
+ else this.renderer.render(this.scene, this.camera);
234
+ }
235
+
236
+ /** Waits for every addon render hooks asked for, and for what their callbacks start in turn. */
237
+ async settle(): Promise<void> {
238
+ for (let round = 0; round < 5 && this.pending.size > 0; round++) {
239
+ await Promise.allSettled([...this.pending]);
240
+ // Let .then callbacks run (they often load the next thing or set a custom render).
241
+ await new Promise((resolve) => setTimeout(resolve, 0));
242
+ }
243
+ }
244
+
245
+ private track<T>(promise: Promise<T>): Promise<T> {
246
+ this.pending.add(promise);
247
+ void promise.finally(() => this.pending.delete(promise)).catch(() => {});
248
+ return promise;
249
+ }
250
+
251
+ /** Scripts' render hooks: the real three.js, this entity's group, the scene, camera and renderer. */
252
+ private runRenderHooks(world: WorldView): void {
253
+ const live = new Set(world.entities);
254
+ for (const [entity, group] of this.hookGroups) {
255
+ if (live.has(entity)) continue;
256
+ group.removeFromParent();
257
+ this.hookGroups.delete(entity);
258
+ this.hookLocals.delete(entity);
259
+ }
260
+ if (!world.callRenderHook) return;
261
+ for (const e of world.entities) {
262
+ if (!e.script?.module.render) continue;
263
+ let group = this.hookGroups.get(e);
264
+ if (!group) {
265
+ group = new THREE.Group();
266
+ group.name = `${e.id}:render`;
267
+ this.hookGroups.set(e, group);
268
+ this.hookLocals.set(e, {});
269
+ this.scene.add(group);
270
+ }
271
+ placeObject(group, e);
272
+ world.callRenderHook(e, {
273
+ THREE,
274
+ scene: this.scene,
275
+ camera: this.camera,
276
+ renderer: this.renderer,
277
+ object: group,
278
+ model: this.modelHandle(e),
279
+ time: world.time ?? 0,
280
+ local: this.hookLocals.get(e)!,
281
+ view: { width: this.canvas.width, height: this.canvas.height },
282
+ recent: (name, seconds = 0.5) => recentEvents(world, name, seconds),
283
+ addon: (path) => this.track(import(`three/addons/${path}`)),
284
+ setRender: (fn) => {
285
+ this.customRender = fn;
286
+ },
287
+ random: Math.random,
288
+ });
289
+ }
290
+ }
291
+
292
+ dispose(): void {
293
+ this.scene.traverse((o) => {
294
+ const mesh = o as THREE.Mesh;
295
+ mesh.geometry?.dispose();
296
+ const material = mesh.material as THREE.Material | THREE.Material[] | undefined;
297
+ if (Array.isArray(material)) material.forEach((m) => m.dispose());
298
+ else material?.dispose();
299
+ });
300
+ for (const texture of this.textures.values()) texture.dispose();
301
+ this.renderer.dispose();
302
+ }
303
+
304
+ // ---------------------------------------------------------------- animation
305
+
306
+ /** Notes which clip every modelled entity should play now, and when that clip started. */
307
+ private recordClips(world: WorldView): void {
308
+ const time = world.time ?? 0;
309
+ const live = new Set(world.entities);
310
+ for (const entity of this.clipHistory.keys()) if (!live.has(entity)) this.clipHistory.delete(entity);
311
+ for (const e of world.entities) {
312
+ if (!e.mesh?.model) continue;
313
+ const { clip, once } = chooseClip(e);
314
+ const history = this.clipHistory.get(e) ?? [];
315
+ const last = history[history.length - 1];
316
+ if (last && last.clip === clip) continue;
317
+ history.push({ clip, since: time, once });
318
+ // Only the newest two matter: the clip playing and the one it blends out of.
319
+ if (history.length > 2) history.shift();
320
+ this.clipHistory.set(e, history);
321
+ }
322
+ }
323
+
324
+ /** Poses every model for this moment: a pure function of simulation time and the clip history. */
325
+ private poseModels(world: WorldView): void {
326
+ const time = world.time ?? 0;
327
+ for (const [e, model] of this.models) {
328
+ if (model.actions.size === 0) continue;
329
+ const spec = e.mesh!;
330
+ const history = this.clipHistory.get(e) ?? [];
331
+ const current = history[history.length - 1];
332
+ const previous = history[history.length - 2];
333
+ const blend = !current || !previous || spec.fade <= 0 ? 1 : Math.min(1, (time - current.since) / spec.fade);
334
+ for (const [name, action] of model.actions) {
335
+ const entry = name === current?.clip ? current : name === previous?.clip && blend < 1 ? previous : undefined;
336
+ if (!entry) {
337
+ action.setEffectiveWeight(0);
338
+ continue;
339
+ }
340
+ const duration = action.getClip().duration;
341
+ const elapsed = Math.max(0, time - entry.since) * spec.animationSpeed;
342
+ action.time = duration <= 0 ? 0 : entry.once ? Math.min(elapsed, duration - 1e-4) : elapsed % duration;
343
+ action.setEffectiveWeight(entry === current ? blend : 1 - blend);
344
+ }
345
+ if (current && current.clip !== "" && !model.actions.has(current.clip) && !warnedClips.has(`${e.id}:${current.clip}`)) {
346
+ warnedClips.add(`${e.id}:${current.clip}`);
347
+ console.warn(`[ludra] entity "${e.id}" asked for the clip "${current.clip}", which its model doesn't have. Clips: ${[...model.actions.keys()].join(", ") || "none"}.`);
348
+ }
349
+ model.mixer.update(0);
350
+ }
351
+ }
352
+
353
+ private modelHandle(e: Entity): RenderContext["model"] {
354
+ const model = this.models.get(e);
355
+ return model && { root: model.root, clips: model.clips, mixer: model.mixer };
356
+ }
357
+
358
+ // ---------------------------------------------------------------- objects
359
+
360
+ /** Brings Three's scene in line with the world: new entities, removed ones, every transform. */
361
+ private sync(world: WorldView): void {
362
+ const live = new Set(world.entities);
363
+ for (const [entity, object] of this.objects) {
364
+ // A model that finished loading replaces its placeholder.
365
+ const ready = this.waiting.has(entity) && this.gltfs.has(entity.mesh!.model);
366
+ if (live.has(entity) && !ready) continue;
367
+ object.removeFromParent();
368
+ this.objects.delete(entity);
369
+ this.models.get(entity)?.mixer.stopAllAction();
370
+ this.models.delete(entity);
371
+ this.waiting.delete(entity);
372
+ }
373
+ for (const [entity, light] of this.lights) {
374
+ if (live.has(entity)) continue;
375
+ light.removeFromParent();
376
+ this.lights.delete(entity);
377
+ }
378
+ let lit = false;
379
+ for (const e of world.entities) {
380
+ if (e.mesh) {
381
+ let object = this.objects.get(e);
382
+ if (!object) {
383
+ object = this.createMesh(e);
384
+ this.objects.set(e, object);
385
+ this.scene.add(object);
386
+ }
387
+ object.visible = e.mesh.visible;
388
+ placeObject(object, e);
389
+ }
390
+ if (e.light) {
391
+ lit = true;
392
+ let light = this.lights.get(e);
393
+ if (!light) {
394
+ light = createLight(e.light);
395
+ this.lights.set(e, light);
396
+ this.scene.add(light);
397
+ if (light instanceof THREE.DirectionalLight || light instanceof THREE.SpotLight) this.scene.add(light.target);
398
+ }
399
+ this.placeLight(light, e);
400
+ }
401
+ }
402
+ // A scene without lights would be black: a soft sky and a sun until it has its own.
403
+ if (!lit && this.defaultLights.children.length === 0) this.addDefaultLights();
404
+ this.defaultLights.visible = !lit;
405
+ if (!lit) {
406
+ const sun = this.defaultLights.children.find((c) => c instanceof THREE.DirectionalLight) as THREE.DirectionalLight | undefined;
407
+ if (sun) {
408
+ sun.position.copy(this.rig!.focus).add(new THREE.Vector3(8, 16, 6));
409
+ sun.target.position.copy(this.rig!.focus);
410
+ sun.target.updateMatrixWorld();
411
+ }
412
+ }
413
+ this.syncDebug(world);
414
+ }
415
+
416
+ private addDefaultLights(): void {
417
+ const sky = new THREE.HemisphereLight("#dfe9ff", "#4a4036", 1.1);
418
+ const sun = new THREE.DirectionalLight("#fff4e0", 2.4);
419
+ configureSun(sun, 40);
420
+ this.defaultLights.add(sky, sun, sun.target);
421
+ }
422
+
423
+ private createMesh(e: Entity): THREE.Object3D {
424
+ const spec = e.mesh!;
425
+ if (spec.model) {
426
+ const gltf = this.gltfs.get(spec.model);
427
+ if (gltf) return this.createModel(e, gltf);
428
+ if (gltf === undefined) {
429
+ // Not loaded yet (a spawned entity whose model the scene never named): an empty stand-in until it is.
430
+ void this.loadModel(spec.model);
431
+ this.waiting.add(e);
432
+ const placeholder = new THREE.Group();
433
+ placeholder.name = e.id;
434
+ return placeholder;
435
+ }
436
+ }
437
+ const geometry = meshGeometry(spec, e.body);
438
+ const texture = spec.texture ? this.textures.get(spec.texture)?.clone() : undefined;
439
+ if (texture && spec.textureScale > 0) {
440
+ // Tile the image every textureScale metres over the mesh's two largest sides.
441
+ geometry.computeBoundingBox();
442
+ const size = geometry.boundingBox!.getSize(new THREE.Vector3());
443
+ const [a, b] = [size.x, size.y, size.z].sort((p, q) => q - p);
444
+ texture.repeat.set(a! / spec.textureScale, b! / spec.textureScale);
445
+ texture.needsUpdate = true;
446
+ }
447
+ const material = new THREE.MeshStandardMaterial({
448
+ color: new THREE.Color(spec.color),
449
+ metalness: spec.metalness,
450
+ roughness: spec.roughness,
451
+ emissive: new THREE.Color(spec.emissive),
452
+ emissiveIntensity: spec.emissiveIntensity,
453
+ transparent: spec.opacity < 1,
454
+ opacity: spec.opacity,
455
+ map: texture ?? null,
456
+ flatShading: spec.flatShading,
457
+ wireframe: spec.wireframe,
458
+ });
459
+ const mesh = new THREE.Mesh(geometry, material);
460
+ mesh.castShadow = spec.castShadow;
461
+ mesh.receiveShadow = spec.receiveShadow;
462
+ mesh.name = e.id;
463
+ return mesh;
464
+ }
465
+
466
+ /**
467
+ * A copy of the model (bones included), turned, fitted to the entity and offset. The returned group
468
+ * is placed like any mesh; inside it a pivot holds the fit, so scripts can still move the root.
469
+ */
470
+ private createModel(e: Entity, gltf: GLTF): THREE.Object3D {
471
+ const spec = e.mesh!;
472
+ const root = cloneSkinned(gltf.scene);
473
+ root.traverse((o) => {
474
+ if ((o as THREE.Mesh).isMesh) {
475
+ o.castShadow = spec.castShadow;
476
+ o.receiveShadow = spec.receiveShadow;
477
+ }
478
+ });
479
+ root.rotation.set((spec.turn.x ?? 0) * DEG, (spec.turn.y ?? 0) * DEG, (spec.turn.z ?? 0) * DEG, "YXZ");
480
+ root.updateMatrixWorld(true);
481
+ const box = new THREE.Box3().setFromObject(root, true);
482
+ const size = box.isEmpty() ? new THREE.Vector3(1, 1, 1) : box.getSize(new THREE.Vector3());
483
+ const center = box.isEmpty() ? new THREE.Vector3() : box.getCenter(new THREE.Vector3());
484
+
485
+ const w = spec.width || e.body?.width || 0;
486
+ const h = spec.height || e.body?.height || 0;
487
+ const d = spec.depth || e.body?.depth || e.body?.width || 0;
488
+ const fitted = (target: number, own: number) => (target > 0 && own > 0 ? target / own : 1);
489
+ const scale = new THREE.Vector3(1, 1, 1);
490
+ if (spec.fit === "height") scale.setScalar(fitted(h, size.y));
491
+ else if (spec.fit === "box") scale.set(fitted(w, size.x), fitted(h, size.y), fitted(d, size.z));
492
+ scale.multiplyScalar(spec.scale);
493
+
494
+ const pivot = new THREE.Group();
495
+ pivot.scale.copy(scale);
496
+ pivot.add(root);
497
+ if (spec.fit !== "none") {
498
+ // Centred on the entity, standing on the bottom of its body (or on its origin without a height).
499
+ root.position.sub(new THREE.Vector3(center.x, box.isEmpty() ? 0 : box.min.y, center.z));
500
+ pivot.position.y = h > 0 ? -h / 2 : 0;
501
+ }
502
+ pivot.position.add(new THREE.Vector3(spec.offset.x ?? 0, spec.offset.y ?? 0, spec.offset.z ?? 0));
503
+
504
+ const mixer = new THREE.AnimationMixer(root);
505
+ const actions = new Map<string, THREE.AnimationAction>();
506
+ for (const clip of gltf.animations) {
507
+ const action = mixer.clipAction(clip);
508
+ action.setEffectiveWeight(0);
509
+ action.play();
510
+ actions.set(clip.name, action);
511
+ }
512
+ this.models.set(e, { root, clips: gltf.animations, mixer, actions });
513
+
514
+ const group = new THREE.Group();
515
+ group.name = e.id;
516
+ group.add(pivot);
517
+ return group;
518
+ }
519
+
520
+ private placeLight(light: THREE.Light, e: Entity): void {
521
+ const t = e.transform;
522
+ const spec = e.light!;
523
+ const target = new THREE.Vector3(spec.target.x ?? 0, spec.target.y ?? 0, spec.target.z ?? 0);
524
+ if (light instanceof THREE.DirectionalLight) {
525
+ // Sunlight: keep its direction, but centre the shadow area on what the camera watches.
526
+ const direction = new THREE.Vector3(t.x, t.y, t.z).sub(target);
527
+ const focus = this.rig?.focus ?? target;
528
+ light.position.copy(focus).add(direction);
529
+ light.target.position.copy(focus);
530
+ light.target.updateMatrixWorld();
531
+ } else {
532
+ light.position.set(t.x, t.y, t.z);
533
+ if (light instanceof THREE.SpotLight) {
534
+ light.target.position.copy(target);
535
+ light.target.updateMatrixWorld();
536
+ }
537
+ }
538
+ }
539
+
540
+ // ---------------------------------------------------------------- camera
541
+
542
+ /** Where the camera wants to be this step, eased by its smoothing. Deterministic per step. */
543
+ private updateRig(world: WorldView, dt: number): void {
544
+ const cameraEntity = world.entities.find((e) => e.camera3d);
545
+ const spec: Camera3D = cameraEntity?.camera3d ?? DEFAULT_CAMERA;
546
+ const target = (spec.target ? world.entities.find((e) => e.id === spec.target) : undefined) ?? world.entities.find((e) => e.character) ?? undefined;
547
+ const yaw = (this.rig?.yaw ?? spec.yaw) + (spec.mode === "orbit" ? spec.orbitSpeed * dt : 0);
548
+ this.camera.fov = spec.fov;
549
+ this.camera.updateProjectionMatrix();
550
+
551
+ let position: THREE.Vector3;
552
+ let look: THREE.Vector3;
553
+ const center = target ? new THREE.Vector3(target.transform.x, target.transform.y, target.transform.z) : new THREE.Vector3();
554
+ if (spec.mode === "fixed") {
555
+ const t = cameraEntity?.transform ?? { x: 0, y: 10, z: 18 };
556
+ position = new THREE.Vector3(t.x, t.y, t.z);
557
+ look = new THREE.Vector3(spec.lookAt.x ?? 0, spec.lookAt.y ?? 0, spec.lookAt.z ?? 0);
558
+ } else if (spec.mode === "firstPerson" && target) {
559
+ const eye = (target.body?.height ?? 1.8) * 0.4;
560
+ position = center.clone().add(new THREE.Vector3(0, eye, 0));
561
+ // Facing -z at a turn of 0; a turn of -90 faces +x.
562
+ const turn = target.transform.rotationY * DEG;
563
+ look = position.clone().add(new THREE.Vector3(-Math.sin(turn), 0, -Math.cos(turn)));
564
+ } else if (target || spec.mode === "orbit") {
565
+ position = center.clone().add(new THREE.Vector3(Math.sin(yaw * DEG) * spec.distance, spec.height, Math.cos(yaw * DEG) * spec.distance));
566
+ look = center.clone().add(new THREE.Vector3(0, (target?.body?.height ?? 0) * 0.25, 0));
567
+ } else {
568
+ // Nothing to follow: an overview of the origin.
569
+ position = new THREE.Vector3(0, 10, 18);
570
+ look = new THREE.Vector3();
571
+ }
572
+
573
+ if (!this.rig || dt === 0 || spec.smoothing === 0) {
574
+ this.rig = { position, look, focus: look.clone(), yaw };
575
+ return;
576
+ }
577
+ // Exponential easing toward the goal: frame-rate independent, the same every run.
578
+ const k = 1 - Math.exp(-dt / spec.smoothing);
579
+ this.rig.position.lerp(position, k);
580
+ this.rig.look.lerp(look, k);
581
+ this.rig.focus.copy(this.rig.look);
582
+ this.rig.yaw = yaw;
583
+ }
584
+
585
+ // ---------------------------------------------------------------- debug
586
+
587
+ /** Colliders as wireframes in their body's color, drawn over everything, and light positions. */
588
+ private syncDebug(world: WorldView): void {
589
+ this.debugGroup.visible = this.debug;
590
+ if (!this.debug) return;
591
+ for (const child of [...this.debugGroup.children]) {
592
+ child.removeFromParent();
593
+ (child as THREE.LineSegments).geometry?.dispose();
594
+ }
595
+ for (const e of world.entities) {
596
+ const b = e.body;
597
+ if (b) {
598
+ const color = b.sensor ? DEBUG_COLORS.sensor : e.character ? DEBUG_COLORS.player : DEBUG_COLORS[b.type];
599
+ const shape = colliderGeometry(b);
600
+ const lines = new THREE.LineSegments(new THREE.EdgesGeometry(shape, 1), new THREE.LineBasicMaterial({ color, depthTest: false, transparent: true, opacity: 0.9 }));
601
+ shape.dispose();
602
+ placeObject(lines, e);
603
+ lines.renderOrder = 999;
604
+ this.debugGroup.add(lines);
605
+ }
606
+ if (e.light) {
607
+ const marker = new THREE.Mesh(new THREE.SphereGeometry(0.15, 8, 6), new THREE.MeshBasicMaterial({ color: 0xffee88, depthTest: false }));
608
+ marker.position.set(e.transform.x, e.transform.y, e.transform.z);
609
+ marker.renderOrder = 999;
610
+ this.debugGroup.add(marker);
611
+ }
612
+ }
613
+ const axes = new THREE.AxesHelper(1.5);
614
+ axes.renderOrder = 999;
615
+ (axes.material as THREE.Material).depthTest = false;
616
+ this.debugGroup.add(axes);
617
+ }
618
+ }
619
+
620
+ /** Events in the last `seconds`, as hooks see them. */
621
+ function recentEvents(world: WorldView, name: string | undefined, seconds: number): DrawEvent[] {
622
+ const events = world.events ?? [];
623
+ const since = (world.time ?? 0) - seconds;
624
+ const result: DrawEvent[] = [];
625
+ for (let i = events.length - 1; i >= 0; i--) {
626
+ const event = events[i]!;
627
+ if (event.time < since) break;
628
+ const eventName = event.type === "custom" ? event.name : event.type;
629
+ if (name !== undefined && eventName !== name) continue;
630
+ const { type: _type, frame: _frame, time, x, y, entity, ...details } = event as GameEvent & { data?: unknown };
631
+ result.push({ name: eventName, time, x, y, entity, data: event.type === "custom" ? event.data : details });
632
+ }
633
+ return result.reverse();
634
+ }
635
+
636
+ /** Clips already reported missing, so the console says it once. */
637
+ const warnedClips = new Set<string>();
638
+
639
+ /**
640
+ * The clip an entity should play: a script's `state.animation`, then `mesh.animation`, then the
641
+ * clip its `animations` map gives the way it moves (in the air: jump while rising, fall after).
642
+ */
643
+ function chooseClip(e: Entity): { clip: string; once: boolean } {
644
+ const spec = e.mesh!;
645
+ const scripted = e.state.animation;
646
+ if (typeof scripted === "string" && scripted !== "") return { clip: scripted, once: false };
647
+ if (spec.animation) return { clip: spec.animation, once: false };
648
+ const map = spec.animations;
649
+ const pick = (...states: AnimationState[]) => states.map((s) => map[s]).find((c) => c) ?? "";
650
+ const b = e.body;
651
+ if (!b) return { clip: pick("idle"), once: false };
652
+ if (!b.grounded && b.type === "dynamic") {
653
+ const clip = b.vy > 0 ? pick("jump", "fall", "idle") : pick("fall", "jump", "idle");
654
+ // A jump plays once and holds its last pose; a fall loops.
655
+ return { clip, once: clip === map.jump && clip !== map.fall };
656
+ }
657
+ const speed = Math.hypot(b.vx, b.vz);
658
+ if (speed > 0.3) {
659
+ const runFrom = (e.character?.speed ?? 5) * 0.6;
660
+ return { clip: speed < runFrom ? pick("walk", "run", "idle") : pick("run", "walk", "idle"), once: false };
661
+ }
662
+ return { clip: pick("idle"), once: false };
663
+ }
664
+
665
+ const DEFAULT_CAMERA: Camera3D = { mode: "follow", target: "", fov: 55, distance: 9, height: 4, yaw: 0, orbitSpeed: 20, lookAt: {}, smoothing: 0.12 };
666
+
667
+ function placeObject(object: THREE.Object3D, e: Entity): void {
668
+ const t = e.transform;
669
+ object.position.set(t.x, t.y, t.z);
670
+ // Ludra's 3D rotations are applied y, then x, then z: Three's "YXZ" order.
671
+ object.rotation.set(t.rotationX * DEG, t.rotationY * DEG, t.rotation * DEG, "YXZ");
672
+ }
673
+
674
+ function configureSun(light: THREE.DirectionalLight, size: number): void {
675
+ light.castShadow = true;
676
+ light.shadow.mapSize.set(2048, 2048);
677
+ const half = size / 2;
678
+ Object.assign(light.shadow.camera, { left: -half, right: half, top: half, bottom: -half, near: 0.5, far: size * 4 });
679
+ light.shadow.camera.updateProjectionMatrix();
680
+ light.shadow.bias = -0.0005;
681
+ light.shadow.normalBias = 0.02;
682
+ }
683
+
684
+ function createLight(spec: LightSpec): THREE.Light {
685
+ const color = new THREE.Color(spec.color);
686
+ switch (spec.type) {
687
+ case "ambient":
688
+ return new THREE.AmbientLight(color, spec.intensity);
689
+ case "hemisphere":
690
+ return new THREE.HemisphereLight(color, new THREE.Color(spec.groundColor), spec.intensity);
691
+ case "point": {
692
+ const light = new THREE.PointLight(color, spec.intensity, spec.distance, 2);
693
+ light.castShadow = spec.castShadow;
694
+ light.shadow.mapSize.set(512, 512);
695
+ light.shadow.bias = -0.002;
696
+ return light;
697
+ }
698
+ case "spot": {
699
+ const light = new THREE.SpotLight(color, spec.intensity, spec.distance, spec.angle * DEG, spec.penumbra, 2);
700
+ light.castShadow = spec.castShadow;
701
+ light.shadow.mapSize.set(1024, 1024);
702
+ light.shadow.bias = -0.001;
703
+ return light;
704
+ }
705
+ default: {
706
+ const light = new THREE.DirectionalLight(color, spec.intensity);
707
+ if (spec.castShadow) configureSun(light, spec.shadowSize);
708
+ return light;
709
+ }
710
+ }
711
+ }
712
+
713
+ /** The mesh's geometry: its own shape and size, or its body's. */
714
+ function meshGeometry(spec: MeshSpec, body: Body | undefined): THREE.BufferGeometry {
715
+ const shape = spec.shape === "auto" ? (body ? bodyShape(body) : "box") : spec.shape;
716
+ if ((shape === "convex" || shape === "mesh") && body && spec.shape === "auto") return colliderGeometry(body);
717
+ const w = spec.width || body?.width || 1;
718
+ const h = spec.height || body?.height || 1;
719
+ const d = spec.depth || (body ? body.depth || body.width : w);
720
+ switch (shape) {
721
+ case "sphere":
722
+ return new THREE.SphereGeometry(w / 2, 40, 24);
723
+ case "capsule":
724
+ return new THREE.CapsuleGeometry(w / 2, Math.max(0, h - w), 10, 24);
725
+ case "cylinder":
726
+ return new THREE.CylinderGeometry(w / 2, w / 2, h, 40);
727
+ case "cone":
728
+ return new THREE.ConeGeometry(w / 2, h, 40);
729
+ case "plane":
730
+ return new THREE.PlaneGeometry(w, d).rotateX(-Math.PI / 2);
731
+ case "torus":
732
+ return new THREE.TorusGeometry((w - h) / 2, h / 2, 20, 56);
733
+ default:
734
+ return new THREE.BoxGeometry(w, h, d);
735
+ }
736
+ }
737
+
738
+ function bodyShape(body: Body): string {
739
+ return body.shape === "box" || body.shape === "sphere" || body.shape === "capsule" || body.shape === "cylinder" || body.shape === "convex" || body.shape === "mesh" ? body.shape : "box";
740
+ }
741
+
742
+ /** The exact collider shape, for the debug view and convex meshes. */
743
+ function colliderGeometry(b: Body): THREE.BufferGeometry {
744
+ const d = b.depth || b.width;
745
+ switch (b.shape) {
746
+ case "sphere":
747
+ return new THREE.SphereGeometry(b.width / 2, 16, 10);
748
+ case "capsule":
749
+ return new THREE.CapsuleGeometry(b.width / 2, Math.max(0, b.height - b.width), 4, 12);
750
+ case "cylinder":
751
+ return new THREE.CylinderGeometry(b.width / 2, b.width / 2, b.height, 16);
752
+ case "convex":
753
+ if (b.points.length >= 4) return new ConvexGeometry(b.points.map(([x, y, z]) => new THREE.Vector3(x, y, z ?? 0)));
754
+ return new THREE.BoxGeometry(b.width, b.height, d);
755
+ case "mesh": {
756
+ if (b.points.length < 3 || b.triangles.length === 0) return new THREE.BoxGeometry(b.width, b.height, d);
757
+ // Exactly the collider's triangles, so procedural terrain is drawn as it collides.
758
+ const geometry = new THREE.BufferGeometry();
759
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(b.points.flatMap(([x, y, z]) => [x!, y!, z ?? 0]), 3));
760
+ geometry.setIndex(b.triangles.flat());
761
+ geometry.computeVertexNormals();
762
+ return geometry;
763
+ }
764
+ default:
765
+ return new THREE.BoxGeometry(b.width, b.height, d);
766
+ }
767
+ }