@ludra/three 0.0.0-stage → 0.91.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ludra contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,63 @@
1
+ import * as THREE from "three";
2
+ import { type Entity, type GameEvent, type RenderContext, type SceneDef } from "@ludra/core";
3
+ import type { Renderer3D, Renderer3DFactory } from "@ludra/core/browser";
4
+ /**
5
+ * The 3D renderer for Ludra: Three.js draws every entity with a `mesh` and lights the scene
6
+ * with its `light` entities, seen through a `camera3d`. It only reads the world, so it can
7
+ * never change what tests verified.
8
+ */
9
+ /** The World as the renderer needs it: what to draw, its events, and its render hooks. */
10
+ interface WorldView {
11
+ readonly entities: readonly Entity[];
12
+ readonly events?: readonly GameEvent[];
13
+ readonly time?: number;
14
+ callRenderHook?(entity: Entity, extras: Omit<RenderContext, "self" | "world" | "params">): void;
15
+ }
16
+ /** Creates the 3D renderer for a scene. Pass it to startGame as `loadRenderer3D`. */
17
+ export declare const createRenderer3D: Renderer3DFactory;
18
+ export declare class ThreeRenderer implements Renderer3D {
19
+ readonly canvas: HTMLCanvasElement;
20
+ debug: boolean;
21
+ /** Three.js itself, for render hooks and anything else (step 4 hands these to scripts). */
22
+ readonly renderer: THREE.WebGLRenderer;
23
+ readonly scene: THREE.Scene<THREE.Object3DEventMap>;
24
+ readonly camera: THREE.PerspectiveCamera;
25
+ private readonly objects;
26
+ private readonly lights;
27
+ private readonly textures;
28
+ private readonly debugGroup;
29
+ private readonly defaultLights;
30
+ private rig;
31
+ /** Each render-hook entity's own group, and its ctx.local. */
32
+ private readonly hookGroups;
33
+ private readonly hookLocals;
34
+ /** A render hook's own way of drawing the frame (an EffectComposer), if one set it. */
35
+ private customRender;
36
+ /** Addons render hooks are still loading. */
37
+ private readonly pending;
38
+ private readonly def;
39
+ private readonly assetBase;
40
+ constructor(def: SceneDef, width: number, height: number, assetBase: string);
41
+ /** Loads every texture the scene names, so the first frame (and screenshots) are complete. */
42
+ load(): Promise<void>;
43
+ private applyEnvironment;
44
+ step(world: WorldView): void;
45
+ reset(world: WorldView): void;
46
+ render(world: WorldView): void;
47
+ /** Waits for every addon render hooks asked for, and for what their callbacks start in turn. */
48
+ settle(): Promise<void>;
49
+ private track;
50
+ /** Scripts' render hooks: the real three.js, this entity's group, the scene, camera and renderer. */
51
+ private runRenderHooks;
52
+ dispose(): void;
53
+ /** Brings Three's scene in line with the world: new entities, removed ones, every transform. */
54
+ private sync;
55
+ private addDefaultLights;
56
+ private createMesh;
57
+ private placeLight;
58
+ /** Where the camera wants to be this step, eased by its smoothing. Deterministic per step. */
59
+ private updateRig;
60
+ /** Colliders as wireframes in their body's color, drawn over everything, and light positions. */
61
+ private syncDebug;
62
+ }
63
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,501 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import * as THREE from "three";
10
+ import { ConvexGeometry } from "three/addons/geometries/ConvexGeometry.js";
11
+ import { FIXED_DT, } from "@ludra/core";
12
+ const DEG = Math.PI / 180;
13
+ const TONE_MAPPING = {
14
+ aces: THREE.ACESFilmicToneMapping,
15
+ neutral: THREE.NeutralToneMapping,
16
+ agx: THREE.AgXToneMapping,
17
+ none: THREE.NoToneMapping,
18
+ };
19
+ /** Collider colors in the debug view, as in 2D: static, dynamic, kinematic, sensor, player. */
20
+ const DEBUG_COLORS = { static: 0x7fdbff, dynamic: 0xffdc00, kinematic: 0xff851b, sensor: 0x2ecc40, player: 0xff4136 };
21
+ /** Creates the 3D renderer for a scene. Pass it to startGame as `loadRenderer3D`. */
22
+ export const createRenderer3D = async (scene, options) => {
23
+ const renderer = new ThreeRenderer(scene, options.width, options.height, options.assetBase);
24
+ await renderer.load();
25
+ return renderer;
26
+ };
27
+ export class ThreeRenderer {
28
+ canvas;
29
+ debug = false;
30
+ /** Three.js itself, for render hooks and anything else (step 4 hands these to scripts). */
31
+ renderer;
32
+ scene = new THREE.Scene();
33
+ camera;
34
+ objects = new Map();
35
+ lights = new Map();
36
+ textures = new Map();
37
+ debugGroup = new THREE.Group();
38
+ defaultLights = new THREE.Group();
39
+ rig;
40
+ /** Each render-hook entity's own group, and its ctx.local. */
41
+ hookGroups = new Map();
42
+ hookLocals = new Map();
43
+ /** A render hook's own way of drawing the frame (an EffectComposer), if one set it. */
44
+ customRender;
45
+ /** Addons render hooks are still loading. */
46
+ pending = new Set();
47
+ def;
48
+ assetBase;
49
+ constructor(def, width, height, assetBase) {
50
+ this.def = def;
51
+ this.assetBase = assetBase;
52
+ this.canvas = document.createElement("canvas");
53
+ this.canvas.width = width;
54
+ this.canvas.height = height;
55
+ // preserveDrawingBuffer: the frame is copied into the game's 2D canvas after drawing.
56
+ this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true, preserveDrawingBuffer: true });
57
+ this.renderer.setPixelRatio(1);
58
+ this.renderer.setSize(width, height, false);
59
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace;
60
+ const env = def.environment ?? {};
61
+ this.renderer.toneMapping = TONE_MAPPING[env.toneMapping ?? "aces"];
62
+ this.renderer.toneMappingExposure = env.exposure ?? 1;
63
+ this.renderer.shadowMap.enabled = env.shadows ?? true;
64
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
65
+ this.camera = new THREE.PerspectiveCamera(55, width / height, 0.05, 2000);
66
+ this.applyEnvironment(env);
67
+ this.scene.add(this.debugGroup, this.defaultLights);
68
+ }
69
+ /** Loads every texture the scene names, so the first frame (and screenshots) are complete. */
70
+ async load() {
71
+ const loader = new THREE.TextureLoader();
72
+ const paths = new Set(this.def.entities.map((e) => e.components.mesh?.texture).filter((p) => typeof p === "string" && p !== ""));
73
+ await Promise.all([...paths].map(async (path) => {
74
+ try {
75
+ const texture = await loader.loadAsync(this.assetBase + path);
76
+ texture.colorSpace = THREE.SRGBColorSpace;
77
+ texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
78
+ texture.anisotropy = 4;
79
+ this.textures.set(path, texture);
80
+ }
81
+ catch {
82
+ console.warn(`[ludra] could not load texture "${path}", using the mesh color instead.`);
83
+ }
84
+ }));
85
+ }
86
+ applyEnvironment(env) {
87
+ const sky = env.sky ?? ["#6fa8ff", "#dfeaff"];
88
+ if (typeof sky === "string")
89
+ this.scene.background = new THREE.Color(sky);
90
+ else {
91
+ // A vertical gradient: top of the sky to the horizon.
92
+ const c = document.createElement("canvas");
93
+ c.width = 2;
94
+ c.height = 256;
95
+ const g = c.getContext("2d");
96
+ const gradient = g.createLinearGradient(0, 0, 0, 256);
97
+ gradient.addColorStop(0, sky[0]);
98
+ gradient.addColorStop(1, sky[1]);
99
+ g.fillStyle = gradient;
100
+ g.fillRect(0, 0, 2, 256);
101
+ const texture = new THREE.CanvasTexture(c);
102
+ texture.colorSpace = THREE.SRGBColorSpace;
103
+ this.scene.background = texture;
104
+ }
105
+ if (env.fog) {
106
+ const color = env.fog.color ?? (typeof sky === "string" ? sky : sky[1]);
107
+ this.scene.fog = new THREE.Fog(color, env.fog.near ?? 30, env.fog.far ?? 150);
108
+ }
109
+ }
110
+ step(world) {
111
+ this.updateRig(world, FIXED_DT);
112
+ }
113
+ reset(world) {
114
+ this.rig = undefined;
115
+ // Things render hooks built belong to the old run.
116
+ for (const group of this.hookGroups.values())
117
+ group.removeFromParent();
118
+ this.hookGroups.clear();
119
+ this.hookLocals.clear();
120
+ this.customRender = undefined;
121
+ this.updateRig(world, 0);
122
+ }
123
+ render(world) {
124
+ if (!this.rig)
125
+ this.updateRig(world, 0);
126
+ this.sync(world);
127
+ const rig = this.rig;
128
+ this.camera.position.copy(rig.position);
129
+ this.camera.lookAt(rig.look);
130
+ this.runRenderHooks(world);
131
+ if (this.customRender)
132
+ this.customRender();
133
+ else
134
+ this.renderer.render(this.scene, this.camera);
135
+ }
136
+ /** Waits for every addon render hooks asked for, and for what their callbacks start in turn. */
137
+ async settle() {
138
+ for (let round = 0; round < 5 && this.pending.size > 0; round++) {
139
+ await Promise.allSettled([...this.pending]);
140
+ // Let .then callbacks run (they often load the next thing or set a custom render).
141
+ await new Promise((resolve) => setTimeout(resolve, 0));
142
+ }
143
+ }
144
+ track(promise) {
145
+ this.pending.add(promise);
146
+ void promise.finally(() => this.pending.delete(promise)).catch(() => { });
147
+ return promise;
148
+ }
149
+ /** Scripts' render hooks: the real three.js, this entity's group, the scene, camera and renderer. */
150
+ runRenderHooks(world) {
151
+ const live = new Set(world.entities);
152
+ for (const [entity, group] of this.hookGroups) {
153
+ if (live.has(entity))
154
+ continue;
155
+ group.removeFromParent();
156
+ this.hookGroups.delete(entity);
157
+ this.hookLocals.delete(entity);
158
+ }
159
+ if (!world.callRenderHook)
160
+ return;
161
+ for (const e of world.entities) {
162
+ if (!e.script?.module.render)
163
+ continue;
164
+ let group = this.hookGroups.get(e);
165
+ if (!group) {
166
+ group = new THREE.Group();
167
+ group.name = `${e.id}:render`;
168
+ this.hookGroups.set(e, group);
169
+ this.hookLocals.set(e, {});
170
+ this.scene.add(group);
171
+ }
172
+ placeObject(group, e);
173
+ world.callRenderHook(e, {
174
+ THREE,
175
+ scene: this.scene,
176
+ camera: this.camera,
177
+ renderer: this.renderer,
178
+ object: group,
179
+ time: world.time ?? 0,
180
+ local: this.hookLocals.get(e),
181
+ view: { width: this.canvas.width, height: this.canvas.height },
182
+ recent: (name, seconds = 0.5) => recentEvents(world, name, seconds),
183
+ addon: (path) => this.track(import(__rewriteRelativeImportExtension(`three/addons/${path}`))),
184
+ setRender: (fn) => {
185
+ this.customRender = fn;
186
+ },
187
+ random: Math.random,
188
+ });
189
+ }
190
+ }
191
+ dispose() {
192
+ this.scene.traverse((o) => {
193
+ const mesh = o;
194
+ mesh.geometry?.dispose();
195
+ const material = mesh.material;
196
+ if (Array.isArray(material))
197
+ material.forEach((m) => m.dispose());
198
+ else
199
+ material?.dispose();
200
+ });
201
+ for (const texture of this.textures.values())
202
+ texture.dispose();
203
+ this.renderer.dispose();
204
+ }
205
+ // ---------------------------------------------------------------- objects
206
+ /** Brings Three's scene in line with the world: new entities, removed ones, every transform. */
207
+ sync(world) {
208
+ const live = new Set(world.entities);
209
+ for (const [entity, object] of this.objects) {
210
+ if (live.has(entity))
211
+ continue;
212
+ object.removeFromParent();
213
+ this.objects.delete(entity);
214
+ }
215
+ for (const [entity, light] of this.lights) {
216
+ if (live.has(entity))
217
+ continue;
218
+ light.removeFromParent();
219
+ this.lights.delete(entity);
220
+ }
221
+ let lit = false;
222
+ for (const e of world.entities) {
223
+ if (e.mesh) {
224
+ let object = this.objects.get(e);
225
+ if (!object) {
226
+ object = this.createMesh(e);
227
+ this.objects.set(e, object);
228
+ this.scene.add(object);
229
+ }
230
+ object.visible = e.mesh.visible;
231
+ placeObject(object, e);
232
+ }
233
+ if (e.light) {
234
+ lit = true;
235
+ let light = this.lights.get(e);
236
+ if (!light) {
237
+ light = createLight(e.light);
238
+ this.lights.set(e, light);
239
+ this.scene.add(light);
240
+ if (light instanceof THREE.DirectionalLight || light instanceof THREE.SpotLight)
241
+ this.scene.add(light.target);
242
+ }
243
+ this.placeLight(light, e);
244
+ }
245
+ }
246
+ // A scene without lights would be black: a soft sky and a sun until it has its own.
247
+ if (!lit && this.defaultLights.children.length === 0)
248
+ this.addDefaultLights();
249
+ this.defaultLights.visible = !lit;
250
+ if (!lit) {
251
+ const sun = this.defaultLights.children.find((c) => c instanceof THREE.DirectionalLight);
252
+ if (sun) {
253
+ sun.position.copy(this.rig.focus).add(new THREE.Vector3(8, 16, 6));
254
+ sun.target.position.copy(this.rig.focus);
255
+ sun.target.updateMatrixWorld();
256
+ }
257
+ }
258
+ this.syncDebug(world);
259
+ }
260
+ addDefaultLights() {
261
+ const sky = new THREE.HemisphereLight("#dfe9ff", "#4a4036", 1.1);
262
+ const sun = new THREE.DirectionalLight("#fff4e0", 2.4);
263
+ configureSun(sun, 40);
264
+ this.defaultLights.add(sky, sun, sun.target);
265
+ }
266
+ createMesh(e) {
267
+ const spec = e.mesh;
268
+ const geometry = meshGeometry(spec, e.body);
269
+ const texture = spec.texture ? this.textures.get(spec.texture)?.clone() : undefined;
270
+ if (texture && spec.textureScale > 0) {
271
+ // Tile the image every textureScale metres over the mesh's two largest sides.
272
+ geometry.computeBoundingBox();
273
+ const size = geometry.boundingBox.getSize(new THREE.Vector3());
274
+ const [a, b] = [size.x, size.y, size.z].sort((p, q) => q - p);
275
+ texture.repeat.set(a / spec.textureScale, b / spec.textureScale);
276
+ texture.needsUpdate = true;
277
+ }
278
+ const material = new THREE.MeshStandardMaterial({
279
+ color: new THREE.Color(spec.color),
280
+ metalness: spec.metalness,
281
+ roughness: spec.roughness,
282
+ emissive: new THREE.Color(spec.emissive),
283
+ emissiveIntensity: spec.emissiveIntensity,
284
+ transparent: spec.opacity < 1,
285
+ opacity: spec.opacity,
286
+ map: texture ?? null,
287
+ flatShading: spec.flatShading,
288
+ wireframe: spec.wireframe,
289
+ });
290
+ const mesh = new THREE.Mesh(geometry, material);
291
+ mesh.castShadow = spec.castShadow;
292
+ mesh.receiveShadow = spec.receiveShadow;
293
+ mesh.name = e.id;
294
+ return mesh;
295
+ }
296
+ placeLight(light, e) {
297
+ const t = e.transform;
298
+ const spec = e.light;
299
+ const target = new THREE.Vector3(spec.target.x ?? 0, spec.target.y ?? 0, spec.target.z ?? 0);
300
+ if (light instanceof THREE.DirectionalLight) {
301
+ // Sunlight: keep its direction, but centre the shadow area on what the camera watches.
302
+ const direction = new THREE.Vector3(t.x, t.y, t.z).sub(target);
303
+ const focus = this.rig?.focus ?? target;
304
+ light.position.copy(focus).add(direction);
305
+ light.target.position.copy(focus);
306
+ light.target.updateMatrixWorld();
307
+ }
308
+ else {
309
+ light.position.set(t.x, t.y, t.z);
310
+ if (light instanceof THREE.SpotLight) {
311
+ light.target.position.copy(target);
312
+ light.target.updateMatrixWorld();
313
+ }
314
+ }
315
+ }
316
+ // ---------------------------------------------------------------- camera
317
+ /** Where the camera wants to be this step, eased by its smoothing. Deterministic per step. */
318
+ updateRig(world, dt) {
319
+ const cameraEntity = world.entities.find((e) => e.camera3d);
320
+ const spec = cameraEntity?.camera3d ?? DEFAULT_CAMERA;
321
+ const target = (spec.target ? world.entities.find((e) => e.id === spec.target) : undefined) ?? world.entities.find((e) => e.character) ?? undefined;
322
+ const yaw = (this.rig?.yaw ?? spec.yaw) + (spec.mode === "orbit" ? spec.orbitSpeed * dt : 0);
323
+ this.camera.fov = spec.fov;
324
+ this.camera.updateProjectionMatrix();
325
+ let position;
326
+ let look;
327
+ const center = target ? new THREE.Vector3(target.transform.x, target.transform.y, target.transform.z) : new THREE.Vector3();
328
+ if (spec.mode === "fixed") {
329
+ const t = cameraEntity?.transform ?? { x: 0, y: 10, z: 18 };
330
+ position = new THREE.Vector3(t.x, t.y, t.z);
331
+ look = new THREE.Vector3(spec.lookAt.x ?? 0, spec.lookAt.y ?? 0, spec.lookAt.z ?? 0);
332
+ }
333
+ else if (spec.mode === "firstPerson" && target) {
334
+ const eye = (target.body?.height ?? 1.8) * 0.4;
335
+ position = center.clone().add(new THREE.Vector3(0, eye, 0));
336
+ // Facing -z at a turn of 0; a turn of -90 faces +x.
337
+ const turn = target.transform.rotationY * DEG;
338
+ look = position.clone().add(new THREE.Vector3(-Math.sin(turn), 0, -Math.cos(turn)));
339
+ }
340
+ else if (target || spec.mode === "orbit") {
341
+ position = center.clone().add(new THREE.Vector3(Math.sin(yaw * DEG) * spec.distance, spec.height, Math.cos(yaw * DEG) * spec.distance));
342
+ look = center.clone().add(new THREE.Vector3(0, (target?.body?.height ?? 0) * 0.25, 0));
343
+ }
344
+ else {
345
+ // Nothing to follow: an overview of the origin.
346
+ position = new THREE.Vector3(0, 10, 18);
347
+ look = new THREE.Vector3();
348
+ }
349
+ if (!this.rig || dt === 0 || spec.smoothing === 0) {
350
+ this.rig = { position, look, focus: look.clone(), yaw };
351
+ return;
352
+ }
353
+ // Exponential easing toward the goal: frame-rate independent, the same every run.
354
+ const k = 1 - Math.exp(-dt / spec.smoothing);
355
+ this.rig.position.lerp(position, k);
356
+ this.rig.look.lerp(look, k);
357
+ this.rig.focus.copy(this.rig.look);
358
+ this.rig.yaw = yaw;
359
+ }
360
+ // ---------------------------------------------------------------- debug
361
+ /** Colliders as wireframes in their body's color, drawn over everything, and light positions. */
362
+ syncDebug(world) {
363
+ this.debugGroup.visible = this.debug;
364
+ if (!this.debug)
365
+ return;
366
+ for (const child of [...this.debugGroup.children]) {
367
+ child.removeFromParent();
368
+ child.geometry?.dispose();
369
+ }
370
+ for (const e of world.entities) {
371
+ const b = e.body;
372
+ if (b) {
373
+ const color = b.sensor ? DEBUG_COLORS.sensor : e.character ? DEBUG_COLORS.player : DEBUG_COLORS[b.type];
374
+ const shape = colliderGeometry(b);
375
+ const lines = new THREE.LineSegments(new THREE.EdgesGeometry(shape, 1), new THREE.LineBasicMaterial({ color, depthTest: false, transparent: true, opacity: 0.9 }));
376
+ shape.dispose();
377
+ placeObject(lines, e);
378
+ lines.renderOrder = 999;
379
+ this.debugGroup.add(lines);
380
+ }
381
+ if (e.light) {
382
+ const marker = new THREE.Mesh(new THREE.SphereGeometry(0.15, 8, 6), new THREE.MeshBasicMaterial({ color: 0xffee88, depthTest: false }));
383
+ marker.position.set(e.transform.x, e.transform.y, e.transform.z);
384
+ marker.renderOrder = 999;
385
+ this.debugGroup.add(marker);
386
+ }
387
+ }
388
+ const axes = new THREE.AxesHelper(1.5);
389
+ axes.renderOrder = 999;
390
+ axes.material.depthTest = false;
391
+ this.debugGroup.add(axes);
392
+ }
393
+ }
394
+ /** Events in the last `seconds`, as hooks see them. */
395
+ function recentEvents(world, name, seconds) {
396
+ const events = world.events ?? [];
397
+ const since = (world.time ?? 0) - seconds;
398
+ const result = [];
399
+ for (let i = events.length - 1; i >= 0; i--) {
400
+ const event = events[i];
401
+ if (event.time < since)
402
+ break;
403
+ const eventName = event.type === "custom" ? event.name : event.type;
404
+ if (name !== undefined && eventName !== name)
405
+ continue;
406
+ const { type: _type, frame: _frame, time, x, y, entity, ...details } = event;
407
+ result.push({ name: eventName, time, x, y, entity, data: event.type === "custom" ? event.data : details });
408
+ }
409
+ return result.reverse();
410
+ }
411
+ const DEFAULT_CAMERA = { mode: "follow", target: "", fov: 55, distance: 9, height: 4, yaw: 0, orbitSpeed: 20, lookAt: {}, smoothing: 0.12 };
412
+ function placeObject(object, e) {
413
+ const t = e.transform;
414
+ object.position.set(t.x, t.y, t.z);
415
+ // Ludra's 3D rotations are applied y, then x, then z: Three's "YXZ" order.
416
+ object.rotation.set(t.rotationX * DEG, t.rotationY * DEG, t.rotation * DEG, "YXZ");
417
+ }
418
+ function configureSun(light, size) {
419
+ light.castShadow = true;
420
+ light.shadow.mapSize.set(2048, 2048);
421
+ const half = size / 2;
422
+ Object.assign(light.shadow.camera, { left: -half, right: half, top: half, bottom: -half, near: 0.5, far: size * 4 });
423
+ light.shadow.camera.updateProjectionMatrix();
424
+ light.shadow.bias = -0.0005;
425
+ light.shadow.normalBias = 0.02;
426
+ }
427
+ function createLight(spec) {
428
+ const color = new THREE.Color(spec.color);
429
+ switch (spec.type) {
430
+ case "ambient":
431
+ return new THREE.AmbientLight(color, spec.intensity);
432
+ case "hemisphere":
433
+ return new THREE.HemisphereLight(color, new THREE.Color(spec.groundColor), spec.intensity);
434
+ case "point": {
435
+ const light = new THREE.PointLight(color, spec.intensity, spec.distance, 2);
436
+ light.castShadow = spec.castShadow;
437
+ light.shadow.mapSize.set(512, 512);
438
+ light.shadow.bias = -0.002;
439
+ return light;
440
+ }
441
+ case "spot": {
442
+ const light = new THREE.SpotLight(color, spec.intensity, spec.distance, spec.angle * DEG, spec.penumbra, 2);
443
+ light.castShadow = spec.castShadow;
444
+ light.shadow.mapSize.set(1024, 1024);
445
+ light.shadow.bias = -0.001;
446
+ return light;
447
+ }
448
+ default: {
449
+ const light = new THREE.DirectionalLight(color, spec.intensity);
450
+ if (spec.castShadow)
451
+ configureSun(light, spec.shadowSize);
452
+ return light;
453
+ }
454
+ }
455
+ }
456
+ /** The mesh's geometry: its own shape and size, or its body's. */
457
+ function meshGeometry(spec, body) {
458
+ const shape = spec.shape === "auto" ? (body ? bodyShape(body) : "box") : spec.shape;
459
+ if (shape === "convex" && body && spec.shape === "auto")
460
+ return colliderGeometry(body);
461
+ const w = spec.width || body?.width || 1;
462
+ const h = spec.height || body?.height || 1;
463
+ const d = spec.depth || (body ? body.depth || body.width : w);
464
+ switch (shape) {
465
+ case "sphere":
466
+ return new THREE.SphereGeometry(w / 2, 40, 24);
467
+ case "capsule":
468
+ return new THREE.CapsuleGeometry(w / 2, Math.max(0, h - w), 10, 24);
469
+ case "cylinder":
470
+ return new THREE.CylinderGeometry(w / 2, w / 2, h, 40);
471
+ case "cone":
472
+ return new THREE.ConeGeometry(w / 2, h, 40);
473
+ case "plane":
474
+ return new THREE.PlaneGeometry(w, d).rotateX(-Math.PI / 2);
475
+ case "torus":
476
+ return new THREE.TorusGeometry((w - h) / 2, h / 2, 20, 56);
477
+ default:
478
+ return new THREE.BoxGeometry(w, h, d);
479
+ }
480
+ }
481
+ function bodyShape(body) {
482
+ return body.shape === "box" || body.shape === "sphere" || body.shape === "capsule" || body.shape === "cylinder" || body.shape === "convex" ? body.shape : "box";
483
+ }
484
+ /** The exact collider shape, for the debug view and convex meshes. */
485
+ function colliderGeometry(b) {
486
+ const d = b.depth || b.width;
487
+ switch (b.shape) {
488
+ case "sphere":
489
+ return new THREE.SphereGeometry(b.width / 2, 16, 10);
490
+ case "capsule":
491
+ return new THREE.CapsuleGeometry(b.width / 2, Math.max(0, b.height - b.width), 4, 12);
492
+ case "cylinder":
493
+ return new THREE.CylinderGeometry(b.width / 2, b.width / 2, b.height, 16);
494
+ case "convex":
495
+ if (b.points.length >= 4)
496
+ return new ConvexGeometry(b.points.map(([x, y, z]) => new THREE.Vector3(x, y, z ?? 0)));
497
+ return new THREE.BoxGeometry(b.width, b.height, d);
498
+ default:
499
+ return new THREE.BoxGeometry(b.width, b.height, d);
500
+ }
501
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@ludra/three",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.91.0",
4
+ "description": "The 3D renderer for Ludra: Three.js meshes, PBR materials, lights, shadows, cameras and environments for 3D scenes.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ersingvn/ludra.git",
9
+ "directory": "packages/three"
10
+ },
11
+ "homepage": "https://github.com/ersingvn/ludra#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/ersingvn/ludra/issues"
14
+ },
15
+ "engines": {
16
+ "node": ">=22.18"
17
+ },
18
+ "scripts": {
19
+ "prepack": "tsc -b --clean && tsc -b"
20
+ },
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "ludra-dev": "./src/index.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src"
32
+ ],
33
+ "dependencies": {
34
+ "@ludra/core": "0.91.0",
35
+ "three": "0.186.1"
36
+ },
37
+ "devDependencies": {
38
+ "@types/three": "0.186.0"
39
+ },
40
+ "keywords": [
41
+ "game-engine",
42
+ "3d",
43
+ "three",
44
+ "webgl",
45
+ "ai",
46
+ "agents"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,538 @@
1
+ import * as THREE from "three";
2
+ import { ConvexGeometry } from "three/addons/geometries/ConvexGeometry.js";
3
+ import {
4
+ FIXED_DT,
5
+ type Body,
6
+ type Camera3D,
7
+ type DrawEvent,
8
+ type Entity,
9
+ type EnvironmentDef,
10
+ type GameEvent,
11
+ type Light as LightSpec,
12
+ type Mesh as MeshSpec,
13
+ type RenderContext,
14
+ type SceneDef,
15
+ } from "@ludra/core";
16
+ import type { Renderer3D, Renderer3DFactory } from "@ludra/core/browser";
17
+
18
+ /**
19
+ * The 3D renderer for Ludra: Three.js draws every entity with a `mesh` and lights the scene
20
+ * with its `light` entities, seen through a `camera3d`. It only reads the world, so it can
21
+ * never change what tests verified.
22
+ */
23
+
24
+ /** The World as the renderer needs it: what to draw, its events, and its render hooks. */
25
+ interface WorldView {
26
+ readonly entities: readonly Entity[];
27
+ readonly events?: readonly GameEvent[];
28
+ readonly time?: number;
29
+ callRenderHook?(entity: Entity, extras: Omit<RenderContext, "self" | "world" | "params">): void;
30
+ }
31
+
32
+ const DEG = Math.PI / 180;
33
+ const TONE_MAPPING = {
34
+ aces: THREE.ACESFilmicToneMapping,
35
+ neutral: THREE.NeutralToneMapping,
36
+ agx: THREE.AgXToneMapping,
37
+ none: THREE.NoToneMapping,
38
+ } as const;
39
+ /** Collider colors in the debug view, as in 2D: static, dynamic, kinematic, sensor, player. */
40
+ const DEBUG_COLORS = { static: 0x7fdbff, dynamic: 0xffdc00, kinematic: 0xff851b, sensor: 0x2ecc40, player: 0xff4136 } as const;
41
+
42
+ /** Creates the 3D renderer for a scene. Pass it to startGame as `loadRenderer3D`. */
43
+ export const createRenderer3D: Renderer3DFactory = async (scene, options) => {
44
+ const renderer = new ThreeRenderer(scene, options.width, options.height, options.assetBase);
45
+ await renderer.load();
46
+ return renderer;
47
+ };
48
+
49
+ interface Rig {
50
+ position: THREE.Vector3;
51
+ look: THREE.Vector3;
52
+ /** The point shadows are centred on. */
53
+ focus: THREE.Vector3;
54
+ /** Orbit cameras keep turning. */
55
+ yaw: number;
56
+ }
57
+
58
+ export class ThreeRenderer implements Renderer3D {
59
+ readonly canvas: HTMLCanvasElement;
60
+ debug = false;
61
+ /** Three.js itself, for render hooks and anything else (step 4 hands these to scripts). */
62
+ readonly renderer: THREE.WebGLRenderer;
63
+ readonly scene = new THREE.Scene();
64
+ readonly camera: THREE.PerspectiveCamera;
65
+ private readonly objects = new Map<Entity, THREE.Object3D>();
66
+ private readonly lights = new Map<Entity, THREE.Light>();
67
+ private readonly textures = new Map<string, THREE.Texture>();
68
+ private readonly debugGroup = new THREE.Group();
69
+ private readonly defaultLights = new THREE.Group();
70
+ private rig: Rig | undefined;
71
+ /** Each render-hook entity's own group, and its ctx.local. */
72
+ private readonly hookGroups = new Map<Entity, THREE.Group>();
73
+ private readonly hookLocals = new Map<Entity, Record<string, unknown>>();
74
+ /** A render hook's own way of drawing the frame (an EffectComposer), if one set it. */
75
+ private customRender: (() => void) | undefined;
76
+ /** Addons render hooks are still loading. */
77
+ private readonly pending = new Set<Promise<unknown>>();
78
+
79
+ private readonly def: SceneDef;
80
+ private readonly assetBase: string;
81
+
82
+ constructor(def: SceneDef, width: number, height: number, assetBase: string) {
83
+ this.def = def;
84
+ this.assetBase = assetBase;
85
+ this.canvas = document.createElement("canvas");
86
+ this.canvas.width = width;
87
+ this.canvas.height = height;
88
+ // preserveDrawingBuffer: the frame is copied into the game's 2D canvas after drawing.
89
+ this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true, preserveDrawingBuffer: true });
90
+ this.renderer.setPixelRatio(1);
91
+ this.renderer.setSize(width, height, false);
92
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace;
93
+ const env: EnvironmentDef = def.environment ?? {};
94
+ this.renderer.toneMapping = TONE_MAPPING[env.toneMapping ?? "aces"];
95
+ this.renderer.toneMappingExposure = env.exposure ?? 1;
96
+ this.renderer.shadowMap.enabled = env.shadows ?? true;
97
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
98
+ this.camera = new THREE.PerspectiveCamera(55, width / height, 0.05, 2000);
99
+ this.applyEnvironment(env);
100
+ this.scene.add(this.debugGroup, this.defaultLights);
101
+ }
102
+
103
+ /** Loads every texture the scene names, so the first frame (and screenshots) are complete. */
104
+ async load(): Promise<void> {
105
+ const loader = new THREE.TextureLoader();
106
+ const paths = new Set(this.def.entities.map((e) => e.components.mesh?.texture).filter((p): p is string => typeof p === "string" && p !== ""));
107
+ await Promise.all(
108
+ [...paths].map(async (path) => {
109
+ try {
110
+ const texture = await loader.loadAsync(this.assetBase + path);
111
+ texture.colorSpace = THREE.SRGBColorSpace;
112
+ texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
113
+ texture.anisotropy = 4;
114
+ this.textures.set(path, texture);
115
+ } catch {
116
+ console.warn(`[ludra] could not load texture "${path}", using the mesh color instead.`);
117
+ }
118
+ }),
119
+ );
120
+ }
121
+
122
+ private applyEnvironment(env: EnvironmentDef): void {
123
+ const sky = env.sky ?? ["#6fa8ff", "#dfeaff"];
124
+ if (typeof sky === "string") this.scene.background = new THREE.Color(sky);
125
+ else {
126
+ // A vertical gradient: top of the sky to the horizon.
127
+ const c = document.createElement("canvas");
128
+ c.width = 2;
129
+ c.height = 256;
130
+ const g = c.getContext("2d")!;
131
+ const gradient = g.createLinearGradient(0, 0, 0, 256);
132
+ gradient.addColorStop(0, sky[0]);
133
+ gradient.addColorStop(1, sky[1]);
134
+ g.fillStyle = gradient;
135
+ g.fillRect(0, 0, 2, 256);
136
+ const texture = new THREE.CanvasTexture(c);
137
+ texture.colorSpace = THREE.SRGBColorSpace;
138
+ this.scene.background = texture;
139
+ }
140
+ if (env.fog) {
141
+ const color = env.fog.color ?? (typeof sky === "string" ? sky : sky[1]);
142
+ this.scene.fog = new THREE.Fog(color, env.fog.near ?? 30, env.fog.far ?? 150);
143
+ }
144
+ }
145
+
146
+ step(world: WorldView): void {
147
+ this.updateRig(world, FIXED_DT);
148
+ }
149
+
150
+ reset(world: WorldView): void {
151
+ this.rig = undefined;
152
+ // Things render hooks built belong to the old run.
153
+ for (const group of this.hookGroups.values()) group.removeFromParent();
154
+ this.hookGroups.clear();
155
+ this.hookLocals.clear();
156
+ this.customRender = undefined;
157
+ this.updateRig(world, 0);
158
+ }
159
+
160
+ render(world: WorldView): void {
161
+ if (!this.rig) this.updateRig(world, 0);
162
+ this.sync(world);
163
+ const rig = this.rig!;
164
+ this.camera.position.copy(rig.position);
165
+ this.camera.lookAt(rig.look);
166
+ this.runRenderHooks(world);
167
+ if (this.customRender) this.customRender();
168
+ else this.renderer.render(this.scene, this.camera);
169
+ }
170
+
171
+ /** Waits for every addon render hooks asked for, and for what their callbacks start in turn. */
172
+ async settle(): Promise<void> {
173
+ for (let round = 0; round < 5 && this.pending.size > 0; round++) {
174
+ await Promise.allSettled([...this.pending]);
175
+ // Let .then callbacks run (they often load the next thing or set a custom render).
176
+ await new Promise((resolve) => setTimeout(resolve, 0));
177
+ }
178
+ }
179
+
180
+ private track<T>(promise: Promise<T>): Promise<T> {
181
+ this.pending.add(promise);
182
+ void promise.finally(() => this.pending.delete(promise)).catch(() => {});
183
+ return promise;
184
+ }
185
+
186
+ /** Scripts' render hooks: the real three.js, this entity's group, the scene, camera and renderer. */
187
+ private runRenderHooks(world: WorldView): void {
188
+ const live = new Set(world.entities);
189
+ for (const [entity, group] of this.hookGroups) {
190
+ if (live.has(entity)) continue;
191
+ group.removeFromParent();
192
+ this.hookGroups.delete(entity);
193
+ this.hookLocals.delete(entity);
194
+ }
195
+ if (!world.callRenderHook) return;
196
+ for (const e of world.entities) {
197
+ if (!e.script?.module.render) continue;
198
+ let group = this.hookGroups.get(e);
199
+ if (!group) {
200
+ group = new THREE.Group();
201
+ group.name = `${e.id}:render`;
202
+ this.hookGroups.set(e, group);
203
+ this.hookLocals.set(e, {});
204
+ this.scene.add(group);
205
+ }
206
+ placeObject(group, e);
207
+ world.callRenderHook(e, {
208
+ THREE,
209
+ scene: this.scene,
210
+ camera: this.camera,
211
+ renderer: this.renderer,
212
+ object: group,
213
+ time: world.time ?? 0,
214
+ local: this.hookLocals.get(e)!,
215
+ view: { width: this.canvas.width, height: this.canvas.height },
216
+ recent: (name, seconds = 0.5) => recentEvents(world, name, seconds),
217
+ addon: (path) => this.track(import(`three/addons/${path}`)),
218
+ setRender: (fn) => {
219
+ this.customRender = fn;
220
+ },
221
+ random: Math.random,
222
+ });
223
+ }
224
+ }
225
+
226
+ dispose(): void {
227
+ this.scene.traverse((o) => {
228
+ const mesh = o as THREE.Mesh;
229
+ mesh.geometry?.dispose();
230
+ const material = mesh.material as THREE.Material | THREE.Material[] | undefined;
231
+ if (Array.isArray(material)) material.forEach((m) => m.dispose());
232
+ else material?.dispose();
233
+ });
234
+ for (const texture of this.textures.values()) texture.dispose();
235
+ this.renderer.dispose();
236
+ }
237
+
238
+ // ---------------------------------------------------------------- objects
239
+
240
+ /** Brings Three's scene in line with the world: new entities, removed ones, every transform. */
241
+ private sync(world: WorldView): void {
242
+ const live = new Set(world.entities);
243
+ for (const [entity, object] of this.objects) {
244
+ if (live.has(entity)) continue;
245
+ object.removeFromParent();
246
+ this.objects.delete(entity);
247
+ }
248
+ for (const [entity, light] of this.lights) {
249
+ if (live.has(entity)) continue;
250
+ light.removeFromParent();
251
+ this.lights.delete(entity);
252
+ }
253
+ let lit = false;
254
+ for (const e of world.entities) {
255
+ if (e.mesh) {
256
+ let object = this.objects.get(e);
257
+ if (!object) {
258
+ object = this.createMesh(e);
259
+ this.objects.set(e, object);
260
+ this.scene.add(object);
261
+ }
262
+ object.visible = e.mesh.visible;
263
+ placeObject(object, e);
264
+ }
265
+ if (e.light) {
266
+ lit = true;
267
+ let light = this.lights.get(e);
268
+ if (!light) {
269
+ light = createLight(e.light);
270
+ this.lights.set(e, light);
271
+ this.scene.add(light);
272
+ if (light instanceof THREE.DirectionalLight || light instanceof THREE.SpotLight) this.scene.add(light.target);
273
+ }
274
+ this.placeLight(light, e);
275
+ }
276
+ }
277
+ // A scene without lights would be black: a soft sky and a sun until it has its own.
278
+ if (!lit && this.defaultLights.children.length === 0) this.addDefaultLights();
279
+ this.defaultLights.visible = !lit;
280
+ if (!lit) {
281
+ const sun = this.defaultLights.children.find((c) => c instanceof THREE.DirectionalLight) as THREE.DirectionalLight | undefined;
282
+ if (sun) {
283
+ sun.position.copy(this.rig!.focus).add(new THREE.Vector3(8, 16, 6));
284
+ sun.target.position.copy(this.rig!.focus);
285
+ sun.target.updateMatrixWorld();
286
+ }
287
+ }
288
+ this.syncDebug(world);
289
+ }
290
+
291
+ private addDefaultLights(): void {
292
+ const sky = new THREE.HemisphereLight("#dfe9ff", "#4a4036", 1.1);
293
+ const sun = new THREE.DirectionalLight("#fff4e0", 2.4);
294
+ configureSun(sun, 40);
295
+ this.defaultLights.add(sky, sun, sun.target);
296
+ }
297
+
298
+ private createMesh(e: Entity): THREE.Object3D {
299
+ const spec = e.mesh!;
300
+ const geometry = meshGeometry(spec, e.body);
301
+ const texture = spec.texture ? this.textures.get(spec.texture)?.clone() : undefined;
302
+ if (texture && spec.textureScale > 0) {
303
+ // Tile the image every textureScale metres over the mesh's two largest sides.
304
+ geometry.computeBoundingBox();
305
+ const size = geometry.boundingBox!.getSize(new THREE.Vector3());
306
+ const [a, b] = [size.x, size.y, size.z].sort((p, q) => q - p);
307
+ texture.repeat.set(a! / spec.textureScale, b! / spec.textureScale);
308
+ texture.needsUpdate = true;
309
+ }
310
+ const material = new THREE.MeshStandardMaterial({
311
+ color: new THREE.Color(spec.color),
312
+ metalness: spec.metalness,
313
+ roughness: spec.roughness,
314
+ emissive: new THREE.Color(spec.emissive),
315
+ emissiveIntensity: spec.emissiveIntensity,
316
+ transparent: spec.opacity < 1,
317
+ opacity: spec.opacity,
318
+ map: texture ?? null,
319
+ flatShading: spec.flatShading,
320
+ wireframe: spec.wireframe,
321
+ });
322
+ const mesh = new THREE.Mesh(geometry, material);
323
+ mesh.castShadow = spec.castShadow;
324
+ mesh.receiveShadow = spec.receiveShadow;
325
+ mesh.name = e.id;
326
+ return mesh;
327
+ }
328
+
329
+ private placeLight(light: THREE.Light, e: Entity): void {
330
+ const t = e.transform;
331
+ const spec = e.light!;
332
+ const target = new THREE.Vector3(spec.target.x ?? 0, spec.target.y ?? 0, spec.target.z ?? 0);
333
+ if (light instanceof THREE.DirectionalLight) {
334
+ // Sunlight: keep its direction, but centre the shadow area on what the camera watches.
335
+ const direction = new THREE.Vector3(t.x, t.y, t.z).sub(target);
336
+ const focus = this.rig?.focus ?? target;
337
+ light.position.copy(focus).add(direction);
338
+ light.target.position.copy(focus);
339
+ light.target.updateMatrixWorld();
340
+ } else {
341
+ light.position.set(t.x, t.y, t.z);
342
+ if (light instanceof THREE.SpotLight) {
343
+ light.target.position.copy(target);
344
+ light.target.updateMatrixWorld();
345
+ }
346
+ }
347
+ }
348
+
349
+ // ---------------------------------------------------------------- camera
350
+
351
+ /** Where the camera wants to be this step, eased by its smoothing. Deterministic per step. */
352
+ private updateRig(world: WorldView, dt: number): void {
353
+ const cameraEntity = world.entities.find((e) => e.camera3d);
354
+ const spec: Camera3D = cameraEntity?.camera3d ?? DEFAULT_CAMERA;
355
+ const target = (spec.target ? world.entities.find((e) => e.id === spec.target) : undefined) ?? world.entities.find((e) => e.character) ?? undefined;
356
+ const yaw = (this.rig?.yaw ?? spec.yaw) + (spec.mode === "orbit" ? spec.orbitSpeed * dt : 0);
357
+ this.camera.fov = spec.fov;
358
+ this.camera.updateProjectionMatrix();
359
+
360
+ let position: THREE.Vector3;
361
+ let look: THREE.Vector3;
362
+ const center = target ? new THREE.Vector3(target.transform.x, target.transform.y, target.transform.z) : new THREE.Vector3();
363
+ if (spec.mode === "fixed") {
364
+ const t = cameraEntity?.transform ?? { x: 0, y: 10, z: 18 };
365
+ position = new THREE.Vector3(t.x, t.y, t.z);
366
+ look = new THREE.Vector3(spec.lookAt.x ?? 0, spec.lookAt.y ?? 0, spec.lookAt.z ?? 0);
367
+ } else if (spec.mode === "firstPerson" && target) {
368
+ const eye = (target.body?.height ?? 1.8) * 0.4;
369
+ position = center.clone().add(new THREE.Vector3(0, eye, 0));
370
+ // Facing -z at a turn of 0; a turn of -90 faces +x.
371
+ const turn = target.transform.rotationY * DEG;
372
+ look = position.clone().add(new THREE.Vector3(-Math.sin(turn), 0, -Math.cos(turn)));
373
+ } else if (target || spec.mode === "orbit") {
374
+ position = center.clone().add(new THREE.Vector3(Math.sin(yaw * DEG) * spec.distance, spec.height, Math.cos(yaw * DEG) * spec.distance));
375
+ look = center.clone().add(new THREE.Vector3(0, (target?.body?.height ?? 0) * 0.25, 0));
376
+ } else {
377
+ // Nothing to follow: an overview of the origin.
378
+ position = new THREE.Vector3(0, 10, 18);
379
+ look = new THREE.Vector3();
380
+ }
381
+
382
+ if (!this.rig || dt === 0 || spec.smoothing === 0) {
383
+ this.rig = { position, look, focus: look.clone(), yaw };
384
+ return;
385
+ }
386
+ // Exponential easing toward the goal: frame-rate independent, the same every run.
387
+ const k = 1 - Math.exp(-dt / spec.smoothing);
388
+ this.rig.position.lerp(position, k);
389
+ this.rig.look.lerp(look, k);
390
+ this.rig.focus.copy(this.rig.look);
391
+ this.rig.yaw = yaw;
392
+ }
393
+
394
+ // ---------------------------------------------------------------- debug
395
+
396
+ /** Colliders as wireframes in their body's color, drawn over everything, and light positions. */
397
+ private syncDebug(world: WorldView): void {
398
+ this.debugGroup.visible = this.debug;
399
+ if (!this.debug) return;
400
+ for (const child of [...this.debugGroup.children]) {
401
+ child.removeFromParent();
402
+ (child as THREE.LineSegments).geometry?.dispose();
403
+ }
404
+ for (const e of world.entities) {
405
+ const b = e.body;
406
+ if (b) {
407
+ const color = b.sensor ? DEBUG_COLORS.sensor : e.character ? DEBUG_COLORS.player : DEBUG_COLORS[b.type];
408
+ const shape = colliderGeometry(b);
409
+ const lines = new THREE.LineSegments(new THREE.EdgesGeometry(shape, 1), new THREE.LineBasicMaterial({ color, depthTest: false, transparent: true, opacity: 0.9 }));
410
+ shape.dispose();
411
+ placeObject(lines, e);
412
+ lines.renderOrder = 999;
413
+ this.debugGroup.add(lines);
414
+ }
415
+ if (e.light) {
416
+ const marker = new THREE.Mesh(new THREE.SphereGeometry(0.15, 8, 6), new THREE.MeshBasicMaterial({ color: 0xffee88, depthTest: false }));
417
+ marker.position.set(e.transform.x, e.transform.y, e.transform.z);
418
+ marker.renderOrder = 999;
419
+ this.debugGroup.add(marker);
420
+ }
421
+ }
422
+ const axes = new THREE.AxesHelper(1.5);
423
+ axes.renderOrder = 999;
424
+ (axes.material as THREE.Material).depthTest = false;
425
+ this.debugGroup.add(axes);
426
+ }
427
+ }
428
+
429
+ /** Events in the last `seconds`, as hooks see them. */
430
+ function recentEvents(world: WorldView, name: string | undefined, seconds: number): DrawEvent[] {
431
+ const events = world.events ?? [];
432
+ const since = (world.time ?? 0) - seconds;
433
+ const result: DrawEvent[] = [];
434
+ for (let i = events.length - 1; i >= 0; i--) {
435
+ const event = events[i]!;
436
+ if (event.time < since) break;
437
+ const eventName = event.type === "custom" ? event.name : event.type;
438
+ if (name !== undefined && eventName !== name) continue;
439
+ const { type: _type, frame: _frame, time, x, y, entity, ...details } = event as GameEvent & { data?: unknown };
440
+ result.push({ name: eventName, time, x, y, entity, data: event.type === "custom" ? event.data : details });
441
+ }
442
+ return result.reverse();
443
+ }
444
+
445
+ const DEFAULT_CAMERA: Camera3D = { mode: "follow", target: "", fov: 55, distance: 9, height: 4, yaw: 0, orbitSpeed: 20, lookAt: {}, smoothing: 0.12 };
446
+
447
+ function placeObject(object: THREE.Object3D, e: Entity): void {
448
+ const t = e.transform;
449
+ object.position.set(t.x, t.y, t.z);
450
+ // Ludra's 3D rotations are applied y, then x, then z: Three's "YXZ" order.
451
+ object.rotation.set(t.rotationX * DEG, t.rotationY * DEG, t.rotation * DEG, "YXZ");
452
+ }
453
+
454
+ function configureSun(light: THREE.DirectionalLight, size: number): void {
455
+ light.castShadow = true;
456
+ light.shadow.mapSize.set(2048, 2048);
457
+ const half = size / 2;
458
+ Object.assign(light.shadow.camera, { left: -half, right: half, top: half, bottom: -half, near: 0.5, far: size * 4 });
459
+ light.shadow.camera.updateProjectionMatrix();
460
+ light.shadow.bias = -0.0005;
461
+ light.shadow.normalBias = 0.02;
462
+ }
463
+
464
+ function createLight(spec: LightSpec): THREE.Light {
465
+ const color = new THREE.Color(spec.color);
466
+ switch (spec.type) {
467
+ case "ambient":
468
+ return new THREE.AmbientLight(color, spec.intensity);
469
+ case "hemisphere":
470
+ return new THREE.HemisphereLight(color, new THREE.Color(spec.groundColor), spec.intensity);
471
+ case "point": {
472
+ const light = new THREE.PointLight(color, spec.intensity, spec.distance, 2);
473
+ light.castShadow = spec.castShadow;
474
+ light.shadow.mapSize.set(512, 512);
475
+ light.shadow.bias = -0.002;
476
+ return light;
477
+ }
478
+ case "spot": {
479
+ const light = new THREE.SpotLight(color, spec.intensity, spec.distance, spec.angle * DEG, spec.penumbra, 2);
480
+ light.castShadow = spec.castShadow;
481
+ light.shadow.mapSize.set(1024, 1024);
482
+ light.shadow.bias = -0.001;
483
+ return light;
484
+ }
485
+ default: {
486
+ const light = new THREE.DirectionalLight(color, spec.intensity);
487
+ if (spec.castShadow) configureSun(light, spec.shadowSize);
488
+ return light;
489
+ }
490
+ }
491
+ }
492
+
493
+ /** The mesh's geometry: its own shape and size, or its body's. */
494
+ function meshGeometry(spec: MeshSpec, body: Body | undefined): THREE.BufferGeometry {
495
+ const shape = spec.shape === "auto" ? (body ? bodyShape(body) : "box") : spec.shape;
496
+ if (shape === "convex" && body && spec.shape === "auto") return colliderGeometry(body);
497
+ const w = spec.width || body?.width || 1;
498
+ const h = spec.height || body?.height || 1;
499
+ const d = spec.depth || (body ? body.depth || body.width : w);
500
+ switch (shape) {
501
+ case "sphere":
502
+ return new THREE.SphereGeometry(w / 2, 40, 24);
503
+ case "capsule":
504
+ return new THREE.CapsuleGeometry(w / 2, Math.max(0, h - w), 10, 24);
505
+ case "cylinder":
506
+ return new THREE.CylinderGeometry(w / 2, w / 2, h, 40);
507
+ case "cone":
508
+ return new THREE.ConeGeometry(w / 2, h, 40);
509
+ case "plane":
510
+ return new THREE.PlaneGeometry(w, d).rotateX(-Math.PI / 2);
511
+ case "torus":
512
+ return new THREE.TorusGeometry((w - h) / 2, h / 2, 20, 56);
513
+ default:
514
+ return new THREE.BoxGeometry(w, h, d);
515
+ }
516
+ }
517
+
518
+ function bodyShape(body: Body): string {
519
+ return body.shape === "box" || body.shape === "sphere" || body.shape === "capsule" || body.shape === "cylinder" || body.shape === "convex" ? body.shape : "box";
520
+ }
521
+
522
+ /** The exact collider shape, for the debug view and convex meshes. */
523
+ function colliderGeometry(b: Body): THREE.BufferGeometry {
524
+ const d = b.depth || b.width;
525
+ switch (b.shape) {
526
+ case "sphere":
527
+ return new THREE.SphereGeometry(b.width / 2, 16, 10);
528
+ case "capsule":
529
+ return new THREE.CapsuleGeometry(b.width / 2, Math.max(0, b.height - b.width), 4, 12);
530
+ case "cylinder":
531
+ return new THREE.CylinderGeometry(b.width / 2, b.width / 2, b.height, 16);
532
+ case "convex":
533
+ if (b.points.length >= 4) return new ConvexGeometry(b.points.map(([x, y, z]) => new THREE.Vector3(x, y, z ?? 0)));
534
+ return new THREE.BoxGeometry(b.width, b.height, d);
535
+ default:
536
+ return new THREE.BoxGeometry(b.width, b.height, d);
537
+ }
538
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.