sindicate 0.2.0 → 0.4.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.
Files changed (54) hide show
  1. package/README.md +63 -2
  2. package/docs/api/README.md +28 -0
  3. package/docs/api/anim.md +125 -0
  4. package/docs/api/assets.md +208 -0
  5. package/docs/api/collision.md +101 -0
  6. package/docs/api/fbxloader.md +50 -0
  7. package/docs/api/grass.md +103 -0
  8. package/docs/api/input.md +164 -0
  9. package/docs/api/renderer.md +115 -0
  10. package/docs/api/retarget.md +149 -0
  11. package/docs/api/shatter.md +121 -0
  12. package/docs/api/utils-decals.md +188 -0
  13. package/docs/api/water.md +143 -0
  14. package/docs/api/wind-weather-uniforms.md +105 -0
  15. package/docs/contracts/render.md +32 -0
  16. package/docs/contracts/time-feel.md +52 -0
  17. package/package.json +1 -1
  18. package/src/core/engine.js +284 -0
  19. package/src/index.js +13 -1
  20. package/src/systems/aim.js +114 -0
  21. package/src/systems/campfire.js +150 -0
  22. package/src/systems/climbing.js +136 -0
  23. package/src/systems/deadeye.js +332 -0
  24. package/src/systems/encounters.js +123 -0
  25. package/src/systems/entity.js +524 -0
  26. package/src/systems/fistCurl.js +38 -0
  27. package/src/systems/footsteps.js +161 -0
  28. package/src/systems/glass.js +67 -0
  29. package/src/systems/herd.js +277 -0
  30. package/src/systems/horse.js +361 -0
  31. package/src/systems/mountedTraveller.js +518 -0
  32. package/src/systems/nav.js +343 -0
  33. package/src/systems/npc.js +880 -0
  34. package/src/systems/pathFollow.js +107 -0
  35. package/src/systems/platform.js +484 -0
  36. package/src/systems/riding.js +580 -0
  37. package/src/systems/seatedRider.js +396 -0
  38. package/src/systems/skybirds.js +129 -0
  39. package/src/systems/trainCamera.js +414 -0
  40. package/src/systems/traveller.js +254 -0
  41. package/src/systems/tumbleweeds.js +92 -0
  42. package/src/systems/vat.js +327 -0
  43. package/src/systems/vfx.js +472 -0
  44. package/src/world/blobShadows.js +109 -0
  45. package/src/world/clouds.js +61 -0
  46. package/src/world/flora.js +87 -0
  47. package/src/world/footprints.js +117 -0
  48. package/src/world/lampField.js +58 -0
  49. package/src/world/lampGlow.js +92 -0
  50. package/src/world/scatter.js +1077 -0
  51. package/src/world/sky.js +363 -0
  52. package/src/world/tileManager.js +197 -0
  53. package/src/world/walkHumps.js +74 -0
  54. package/src/world/weather.js +312 -0
@@ -0,0 +1,52 @@
1
+ # Contract: time & feel
2
+
3
+ **Status:** draft (step 3 — extraction from western main.js in progress). Field names final, wiring lands with the Engine base.
4
+
5
+ ## The two clocks
6
+
7
+ Every frame has TWO dt values, and using the wrong one is a shipped-bug class:
8
+
9
+ - **`rawDt`** — wall-clock frame time, never scaled. Consumers: camera look, aim IK (aiming stays crisp inside slow-mo), the fps meter (scaled dt inflates it during hit-stop), and any *meter that must drain in real time* (a slow-mo meter timed in slow-mo drains 3× slower the instant it activates — the engine's slow-mo system runs on rawDt for exactly this reason).
10
+ - **`dt`** — the world dt: `rawDt × Π(timeScale contributions)`, clamped at 0.1s. Drives mixers, physics, AI, projectiles, VFX, game-time timers.
11
+
12
+ ## timeScale contributions (the multiplier chain)
13
+
14
+ Systems never write loop fields. They register contributors; the engine multiplies them in registration order each frame *after* the pre-scale phase:
15
+
16
+ ```js
17
+ engine.time.addTimeScale(() => (hitstopActive ? 0.12 : 1)) // combat's hit-stop
18
+ engine.time.addTimeScale(() => slowMo.scale) // Dead-Eye-style slow-mo
19
+ ```
20
+
21
+ - Contributors run every frame; return 1 when inactive.
22
+ - Systems that *decide* the frame's scale (e.g. slow-mo reading its meter) update in the **pre-scale phase** on rawDt, so the scale they return lands on THIS frame, not the next.
23
+ - `engine.time.worldDtScale` exposes the product (read-only; the dev drive-bus asserts on it).
24
+
25
+ ## Game-time timers
26
+
27
+ `engine.time.after(seconds, fn)` — one-shots that tick on **world dt inside the unpaused guard**: they freeze with the pause menu (a pause can never eat a death-beat landing) and stretch under slow-mo. Not setTimeout. Errors in `fn` are caught and logged, never thrown into the loop.
28
+
29
+ `engine.time.playSeconds` — accumulated unpaused world time (save-slot playtime readout).
30
+
31
+ ## Feel write APIs
32
+
33
+ - `engine.feel.hitstop(seconds)` — refreshes the hit-stop window (max with current, never additive stacking).
34
+ - `engine.feel.shake(strength)` — camera shake impulse, consumed by the camera phase.
35
+
36
+ ## Frame phases (execution order is the contract)
37
+
38
+ 1. **frame-start** — clock advance, input `pollGamepad(ui.anyPanelOpen)`.
39
+ 2. **pre-scale** *(rawDt)* — systems that produce this frame's timeScale.
40
+ 3. **scale** — multiplier chain → world dt.
41
+ 4. **ui-modal** — global keys, panel nav, modal key routing (see ui contract). May consume input.
42
+ 5. **sim** *(world dt, skipped when paused)* — timers, world update, then game-registered sim hooks in registration order.
43
+ 6. **actors** — player update (writes the follow camera), then game actor hooks. Runs even paused (movement is pause-gated inside the player; a hand-ticked NPC keeps breathing during dialogue).
44
+ 7. **camera-overrides** — ordered, **last writer wins**, after everything that can move a platform or a vehicle seat: dialogue two-shot → scripted scenes → cinematics (each registered with an order key). The engine builds the culling frustum AFTER this phase, once, when unpaused (`engine.render.frustum`; null while a stale frustum would mis-cull — see render contract).
45
+ 8. **late-sim** *(unpaused)* — combat resolution, missions, crowd systems, ambient ticks.
46
+ 9. **render** — exposure write-gating, `renderFrame()` (the ONE render path shared by boot warm-up and the loop), freeze-profiler phase stamps.
47
+
48
+ The pause predicate is supplied by the ui facet (`ui.anyPanelOpen`) and holds phases 5 and 8; 6, 7 and 9 always run.
49
+
50
+ ## Why this shape
51
+
52
+ Western's main.js encoded all of the above as a single 400-line closure with ordering comments ("It runs HERE — not one line earlier"). The phases make the ordering *structural*: a system registered in the wrong phase is a visible design error, not a subtle frame-lag bug discovered in play.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sindicate",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Sindicate — open-world game engine for three.js WebGPU. Streaming worlds, characters, mounts & vehicles, missions, crowds.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,284 @@
1
+ // The Engine base: frame loop, the two clocks (rawDt vs scaled dt), the timeScale
2
+ // contributor chain, game-time timers, feel (hitstop/shake), the culling frustum,
3
+ // the single render path, warm-up, and the freeze profiler / perf census.
4
+ // Contracts: docs/contracts/time-feel.md and docs/contracts/render.md.
5
+ //
6
+ // const engine = new Engine({ renderer, scene, camera, input,
7
+ // post, // PostProcessing from createPostProcessing (or null)
8
+ // isPaused: () => ui.anyPanelOpen, // holds sim: engine timers + game's gated blocks
9
+ // isUiModal: () => ui.anyPanelOpen, // pad drives the UI, suppress game mappings
10
+ // })
11
+ // engine.addTimeScale(() => slowMo.scale) // multiplicative, 1 = inactive
12
+ // engine.on('preScale', (ctx) => …) // rawDt phase — decide THIS frame's scale
13
+ // engine.on('frame', (ctx) => …) // ordered main-phase hooks: ctx = { dt,
14
+ // // rawDt, paused, stamp, engine }
15
+ // engine.onFps((fps) => …) // 1s-window fps readout
16
+ // engine.start() // hands the loop to the renderer
17
+ import * as THREE from 'three/webgpu';
18
+
19
+ export class Engine {
20
+ constructor({ renderer, scene, camera, input, post = null, isPaused = () => false, isUiModal = () => false }) {
21
+ this.renderer = renderer;
22
+ this.scene = scene;
23
+ this.camera = camera;
24
+ this.input = input;
25
+ this.post = post;
26
+ this.isPaused = isPaused;
27
+ this.isUiModal = isUiModal;
28
+
29
+ // time
30
+ this.rawDt = 0;
31
+ this.worldDtScale = 1; // exposed: the dev drive-bus asserts on it
32
+ this.playSeconds = 0; // unpaused world time (save-slot playtime readout)
33
+ this._timers = [];
34
+ this._timeScales = [];
35
+ this._clockLast = performance.now();
36
+
37
+ // feel — hit-stop is a built-in contributor: combat refreshes the window, the
38
+ // world crawls at 0.12× while it runs (cheap, famous crunch)
39
+ this.hitstopT = 0;
40
+ this.addTimeScale(() => {
41
+ if (this.hitstopT > 0) { this.hitstopT -= this._frameDt; return 0.12; }
42
+ return 1;
43
+ });
44
+ this.shakeT = 0;
45
+
46
+ // culling frustum — built by buildCullFrustum() once per unpaused frame; MAY BE
47
+ // NULL and null means "don't cull" (a stale frustum mis-culls; see render contract)
48
+ this.cullFrustum = null;
49
+
50
+ this._hooks = { preScale: [], frame: [] };
51
+ this._fpsCb = null;
52
+ this._fps = { acc: 0, n: 0, t: 0 };
53
+ this._perf = null;
54
+ this._stampKeys = [];
55
+ this.__post = undefined; // set false for a plain-render A/B
56
+
57
+ this.tick = this._tick.bind(this);
58
+ }
59
+
60
+ // ---- registration -------------------------------------------------------------------
61
+ on(phase, fn) {
62
+ if (!this._hooks[phase]) throw new Error(`Engine.on: unknown phase '${phase}'`);
63
+ this._hooks[phase].push(fn);
64
+ return fn;
65
+ }
66
+ addTimeScale(fn) { this._timeScales.push(fn); return fn; }
67
+ onFps(cb) { this._fpsCb = cb; }
68
+
69
+ // ---- time & feel --------------------------------------------------------------------
70
+ // schedule fn after `sec` seconds of GAME time — freezes with the pause menu (a pause
71
+ // can never eat a landing), stretches under slow-mo. Errors are caught, never thrown
72
+ // into the loop.
73
+ after(sec, fn) { this._timers.push({ t: sec, fn }); }
74
+ wait(sec) { return new Promise((r) => this.after(sec, r)); }
75
+ hitstop(sec) { this.hitstopT = Math.max(this.hitstopT, sec); }
76
+ shake(amount) { this.shakeT = Math.max(this.shakeT, amount); }
77
+ // camera shake — call at the point in the frame where the camera is FINAL (after every
78
+ // camera writer), not earlier: shake applied before an override is shake thrown away.
79
+ applyShake(dt) {
80
+ if (this.shakeT > 0) {
81
+ this.shakeT = Math.max(0, this.shakeT - dt * 1.8);
82
+ this.camera.position.x += (Math.random() - 0.5) * this.shakeT * 0.75;
83
+ this.camera.position.y += (Math.random() - 0.5) * this.shakeT * 0.75;
84
+ }
85
+ }
86
+
87
+ // ---- render path --------------------------------------------------------------------
88
+ // THE render path — one function so boot warm-up and the tick go through the SAME
89
+ // pipelines (a settle that warmed the plain path would leave the post path to compile
90
+ // its first frame in gameplay). engine.__post = false drops to plain render for an A/B.
91
+ renderFrame() {
92
+ if (this.post && this.__post !== false) this.post.render();
93
+ else this.renderer.render(this.scene, this.camera);
94
+ }
95
+
96
+ // Night dimming etc. via tone-map exposure. CRITICAL: only WRITE when it actually
97
+ // changes — writing renderer.toneMappingExposure every frame forces a WebGPU
98
+ // output-pipeline rebuild on some GPUs → freezes. The gate lives here so games can
99
+ // "set" it every frame safely.
100
+ setExposure(v) {
101
+ if (Math.abs(v - (this._lastExposure ?? -1)) > 0.008) { this.renderer.toneMappingExposure = v; this._lastExposure = v; }
102
+ }
103
+
104
+ // Build the per-frame culling frustum. Call ONCE per unpaused frame, after every
105
+ // camera writer that matters to culling. Consumers read engine.cullFrustum and MUST
106
+ // treat null as visible; a game may null it deliberately for a frame (paused dialogue
107
+ // ticking one NPC against a stale camera) — the next build repairs it.
108
+ buildCullFrustum() {
109
+ this.camera.updateMatrixWorld();
110
+ (this._camMWI ??= new THREE.Matrix4()).copy(this.camera.matrixWorld).invert();
111
+ (this._camProj ??= new THREE.Matrix4()).multiplyMatrices(this.camera.projectionMatrix, this._camMWI);
112
+ (this.cullFrustum ??= new THREE.Frustum()).setFromProjectionMatrix(this._camProj);
113
+ return this.cullFrustum;
114
+ }
115
+
116
+ // ---- warm-up (PERF LAW) -------------------------------------------------------------
117
+ // Force every hidden / frustum-culled / count-0-instanced mesh to compile its GPU
118
+ // pipeline NOW (at load) instead of on first sight — first-sight compile is the
119
+ // multi-frame freeze you get spinning the camera onto something you hadn't looked at.
120
+ // compileAsync, NOT render(): compiles off the main thread (render() blocked ~20s and
121
+ // tripped Chrome's 'page unresponsive'). Still awaited at boot — no travel freezes.
122
+ async warmRenderHidden(tag, root = this.scene) {
123
+ const warm = [];
124
+ root.traverse((o) => {
125
+ const mesh = o.isMesh || o.isInstancedMesh;
126
+ const hidI = mesh && o.isInstancedMesh && o.count === 0 && (o.instanceMatrix?.array?.length ?? 0) >= 16;
127
+ if (!(o.visible === false || hidI || (mesh && o.frustumCulled))) return;
128
+ warm.push({ o, vis: o.visible, cnt: o.isInstancedMesh ? o.count : null, fc: mesh ? o.frustumCulled : null });
129
+ o.visible = true; if (mesh) o.frustumCulled = false; if (hidI) o.count = 1;
130
+ });
131
+ const t0 = performance.now();
132
+ if (warm.length) { try { await this.renderer.compileAsync(this.scene, this.camera); } catch (e) { /* warm */ } }
133
+ console.log(`[warm:${tag}] ${Math.round(performance.now() - t0)}ms over ${warm.length} hidden/culled objs (off-thread compile)`);
134
+ for (const w of warm) { w.o.visible = w.vis; if (w.cnt !== null) w.o.count = w.cnt; if (w.fc !== null) w.o.frustumCulled = w.fc; }
135
+ return warm.length;
136
+ }
137
+
138
+ // ---- the loop -----------------------------------------------------------------------
139
+ start() {
140
+ this._clockLast = performance.now();
141
+ this.renderer.setAnimationLoop(this.tick);
142
+ // auto perf log — first at 6s (past load hitches), then periodic
143
+ setTimeout(() => { this.perfReport(); this._perfTimer = setInterval(() => this.perfReport(), 15000); }, 6000);
144
+ }
145
+ stop() {
146
+ this.renderer.setAnimationLoop(null);
147
+ if (this._perfTimer) clearInterval(this._perfTimer);
148
+ }
149
+
150
+ _tick() {
151
+ const now = performance.now();
152
+ let dt = Math.min((now - this._clockLast) / 1000, 0.1);
153
+ this._clockLast = now;
154
+ const rawDt = dt; // wall-clock — the fps meter reads THIS (scaled dt inflates it during hit-stop)
155
+ this.rawDt = rawDt; // published for systems that must NOT slow down (camera look, aim IK)
156
+
157
+ this.input?.pollGamepad(this.isUiModal()); // panels drive the pad themselves; suppress game mappings
158
+
159
+ const paused = this.isPaused();
160
+ const stamps = (this._stamps = { t0: performance.now() });
161
+ const stamp = (key) => { stamps[key] = performance.now(); if (!(key in (this._perf ?? {}))) this._stampKeys.includes(key) || this._stampKeys.push(key); };
162
+ const ctx = { dt: 0, rawDt, paused, stamp, engine: this };
163
+
164
+ // PRE-SCALE (rawDt): systems that produce this frame's timeScale run first, so the
165
+ // scale they return lands on THIS frame (a slow-mo meter timed in slow-mo drains 3×
166
+ // slower the instant it activates).
167
+ for (const fn of this._hooks.preScale) fn(ctx);
168
+
169
+ // world-dt multiplier chain — every contribution lands on ONE dt, which then drives
170
+ // mixers, physics, AI, projectiles, VFX.
171
+ this._frameDt = dt;
172
+ let scale = 1;
173
+ for (const fn of this._timeScales) scale *= fn();
174
+ this.worldDtScale = scale;
175
+ dt *= scale;
176
+ ctx.dt = dt;
177
+
178
+ // game-time bookkeeping — first thing inside the pause gate, before any sim hook
179
+ if (!paused) {
180
+ this.playSeconds += dt;
181
+ for (let i = this._timers.length - 1; i >= 0; i--) {
182
+ const tm = this._timers[i];
183
+ tm.t -= dt;
184
+ if (tm.t <= 0) { this._timers.splice(i, 1); try { tm.fn(); } catch (e) { console.warn('[after]', e); } }
185
+ }
186
+ }
187
+
188
+ // MAIN PHASE — the game's ordered hooks. Pause-gating of individual blocks is the
189
+ // hook's business (ctx.paused); ordering between hooks is registration order.
190
+ for (const fn of this._hooks.frame) fn(ctx);
191
+
192
+ this.renderFrame();
193
+ stamps.render = performance.now();
194
+
195
+ this._accumulatePerf(stamps);
196
+ this._freezeProbe(stamps);
197
+
198
+ // fps meter — 1s window (a calmer readout; real spikes still show in the freeze log)
199
+ const f = this._fps;
200
+ f.acc += rawDt; f.n++; f.t += rawDt;
201
+ if (f.t > 1) {
202
+ this._fpsCb?.(Math.round(f.n / f.acc));
203
+ f.acc = 0; f.n = 0; f.t = 0;
204
+ }
205
+
206
+ this.input?.endFrame();
207
+ }
208
+
209
+ // ---- profiling ----------------------------------------------------------------------
210
+ _accumulatePerf(stamps) {
211
+ const P = (this._perfAcc ??= { n: 0, frame: 0, phases: {} });
212
+ P.n++; P.frame += stamps.render - stamps.t0;
213
+ let prev = stamps.t0;
214
+ for (const k of this._stampKeys) {
215
+ if (!(k in stamps)) continue;
216
+ P.phases[k] = (P.phases[k] ?? 0) + (stamps[k] - prev);
217
+ prev = stamps[k];
218
+ }
219
+ P.phases.render = (P.phases.render ?? 0) + (stamps.render - prev);
220
+ }
221
+
222
+ // FREEZE PROFILER: a SILENT ring buffer of slow frames + their phase breakdown, with
223
+ // frame-level cause attribution sampled EVERY frame so a freeze's deltas reveal WHAT
224
+ // it did: pipe = compiled-pipeline count (grows = a shader compiled this frame = a
225
+ // warm-coverage gap); geo/tex = uploads (a streamed tile). __dumpFreezeLog() downloads.
226
+ _freezeProbe(stamps) {
227
+ const frame = stamps.render - stamps.t0;
228
+ const _pipe = this.renderer._pipelines?.caches?.size ?? -1;
229
+ const _geo = this.renderer.info?.memory?.geometries ?? -1;
230
+ const _tex = this.renderer.info?.memory?.textures ?? -1;
231
+ const last = (this._fzLast ??= { pipe: _pipe, geo: _geo, tex: _tex });
232
+ if (frame > 55) {
233
+ const b = { ms: Math.round(frame), v: 5 };
234
+ let prev = stamps.t0;
235
+ for (const k of this._stampKeys) { if (k in stamps) { b[k] = +(stamps[k] - prev).toFixed(1); prev = stamps[k]; } }
236
+ b.render = +(stamps.render - prev).toFixed(1);
237
+ b.calls = this.renderer.info?.render?.drawCalls ?? -1;
238
+ b.tris = this.renderer.info?.render?.triangles ?? -1;
239
+ b.pipe = _pipe - last.pipe; b.geo = _geo - last.geo; b.tex = _tex - last.tex; b.pipeTotal = _pipe;
240
+ b.t = Math.round(stamps.render);
241
+ Object.assign(b, this._freezeExtra?.() ?? {});
242
+ const log = (window.__freezeLog ??= []); log.push(b); if (log.length > 300) log.shift();
243
+ if (import.meta.env?.DEV) { try { fetch('/__perf', { method: 'POST', body: JSON.stringify({ FREEZE: b }) }); } catch (e) { /* sink offline */ } }
244
+ window.__dumpFreezeLog ??= () => {
245
+ const a = document.createElement('a');
246
+ a.href = URL.createObjectURL(new Blob([JSON.stringify(window.__freezeLog, null, 2)], { type: 'application/json' }));
247
+ a.download = 'freeze-log.json'; a.click();
248
+ };
249
+ }
250
+ last.pipe = _pipe; last.geo = _geo; last.tex = _tex;
251
+ }
252
+ // game hook: extra fields for freeze records / perf reports (e.g. sky hour, player pos)
253
+ setPerfExtras({ freeze = null, report = null } = {}) { this._freezeExtra = freeze; this._reportExtra = report; }
254
+
255
+ // One-shot perf census: frame-time by phase, GPU load, what's resident/visible, exact
256
+ // render state. Prints to console when window.__perflog is set; always returns it.
257
+ perfReport() {
258
+ if (document.hidden) return null; // backgrounded tab: rAF paused, the sample is noise
259
+ const r = this.renderer, info = r.info;
260
+ let meshes = 0, skinned = 0, skinnedVis = 0, instanced = 0, instCount = 0, lights = 0, litOn = 0, sprites = 0;
261
+ this.scene.traverse((o) => {
262
+ if (o.isInstancedMesh) { instanced++; instCount += o.count; }
263
+ else if (o.isSkinnedMesh) { skinned++; if (o.visible && o.parent?.visible !== false) skinnedVis++; }
264
+ else if (o.isMesh) meshes++;
265
+ if (o.isSprite) sprites++;
266
+ if (o.isLight) { lights++; if (o.intensity > 0) litOn++; }
267
+ });
268
+ const P = this._perfAcc ?? { n: 1, frame: 0, phases: {} };
269
+ const n = Math.max(1, P.n), fps = Math.round(1000 / (P.frame / n || 16));
270
+ const phases = {};
271
+ for (const k of Object.keys(P.phases)) phases[k] = +(P.phases[k] / n).toFixed(2);
272
+ const rep = {
273
+ fps, frameMs: +(P.frame / n).toFixed(2), phases,
274
+ gpu: { calls: info.render?.drawCalls, ktris: +((info.render?.triangles ?? 0) / 1000).toFixed(0), geometries: info.memory?.geometries, textures: info.memory?.textures },
275
+ scene: { meshes, skinned, skinnedVisible: skinnedVis, instancedMeshes: instanced, instances: instCount, lights, litOn, sprites },
276
+ render: { postProcessing: !!this.post, shadows: r.shadowMap?.enabled, pixelRatio: +r.getPixelRatio().toFixed(2), cameraFar: this.camera.far },
277
+ ...(this._reportExtra?.() ?? {}),
278
+ };
279
+ if (window.__perflog) console.log(`%c[PERF] ${fps}fps · frame ${rep.frameMs}ms`, 'color:#4dd; font-weight:700', rep);
280
+ this._perfAcc = { n: 0, frame: 0, phases: {} };
281
+ if (import.meta.env?.DEV) { try { fetch('/__perf', { method: 'POST', body: JSON.stringify(rep) }); } catch (e) { /* sink offline */ } }
282
+ return rep;
283
+ }
284
+ }
package/src/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  // their proven originals on purpose.
11
11
 
12
12
  // core — rendering, assets, animation, retargeting, input, quality, decals, utils
13
+ export { Engine } from './core/engine.js';
13
14
  export { createRenderer, createPostProcessing, wantsTRAA } from './core/renderer.js';
14
15
  export * from './core/assets.js';
15
16
  export * from './core/anim.js';
@@ -19,13 +20,24 @@ export * from './core/utils.js';
19
20
  export { PRESETS, getQuality, getQualityName, setQualityName, applyQuality, AutoTuner } from './core/quality.js';
20
21
  export { DecalField } from './core/decalField.js';
21
22
 
22
- // world — collision (sync + worker BVH), water materials, grass, wind, shared weather uniforms
23
+ // world — streaming tiles, scatter/LOD, collision, sky/weather stack, lighting, ground dressing
23
24
  export * from './world/collision.js';
25
+ export * from './world/tileManager.js';
26
+ export * from './world/scatter.js';
27
+ export * from './world/sky.js';
28
+ export * from './world/weather.js';
29
+ export * from './world/clouds.js';
24
30
  export * from './world/water.js';
25
31
  export * from './world/grass.js';
26
32
  export * from './world/wind.js';
27
33
  export * from './world/weatherUniforms.js';
28
34
  export * from './world/waterNoise.js';
35
+ export * from './world/flora.js';
36
+ export * from './world/lampField.js';
37
+ export * from './world/lampGlow.js';
38
+ export * from './world/blobShadows.js';
39
+ export * from './world/walkHumps.js';
40
+ export * from './world/footprints.js';
29
41
 
30
42
  // systems — pooled debris shatter
31
43
  export * from './systems/shatter.js';
@@ -0,0 +1,114 @@
1
+ // WHERE THE ROUND ACTUALLY GOES — the one copy of the maths.
2
+ //
3
+ // This module exists because the same geometry was being written twice, in two files, by two hands,
4
+ // and the two copies disagreed. The reticle said one thing (player.js), the bullet did another
5
+ // (combat.js), and the argument between them was invisible: the green light came on, the gun went
6
+ // off, and the man did not fall. Now there is one body model and one intersection test, and the
7
+ // aim, the reticle and the round are all reading from it. /shoottest.html fires the same functions
8
+ // at a measured range, so what you tune there is what the game does.
9
+ //
10
+ // TWO FACTS THIS ENCODES, both learned the hard way:
11
+ //
12
+ // 1. A MAN IS A CAPSULE, NOT A BALL AT HIS STERNUM. The first version tested a sphere at chest
13
+ // height. A crosshair resting on his HEAD is 0.68m from that point — outside the tolerance — so
14
+ // the aim found nobody, converged on a default distance far behind him, and the round went under
15
+ // his chin. Headshots weren't failing to register; they were never headshots.
16
+ //
17
+ // 2. THE CROSSHAIR IS THE CAMERA'S RAY, AND THE BULLET LEAVES THE GUN. Those are two different
18
+ // lines. The camera sits over the player's shoulder — measured 4.45m behind and 2m above the
19
+ // muzzle — so firing "along the camera direction" from the muzzle produces a shot PARALLEL to
20
+ // the crosshair that never meets it, landing a fixed step low at every range. The round must be
21
+ // aimed AT the point the crosshair is over, not merely in the same direction.
22
+ import * as THREE from 'three/webgpu';
23
+
24
+ // The body, as the guns see it. Feet-relative: `position` is on the ground, by the convention every
25
+ // entity in this game already follows (combat.js's HEAD_Y test depends on it, and the coach
26
+ // publishes the datum a seated man's feet WOULD be on so the same numbers work on him).
27
+ export const BODY = {
28
+ lo: 0.25, // ankles
29
+ hi: 1.80, // crown
30
+ r: 0.42, // half a man's shoulders, and generous by a hair — a western is about hitting people
31
+ head: 1.50, // above this and it went in over the collar
32
+ };
33
+ // The AIM is a shade more forgiving than the bullet: the crosshair should never light green on a
34
+ // shot that misses, but it may stay dark on one that grazes. Err that way round.
35
+ export const AIM_R = 0.50;
36
+
37
+ const _v = new THREE.Vector3();
38
+
39
+ // Where a ray crosses a standing body. Returns the distance along the ray, or null.
40
+ // The body's axis is VERTICAL, so this is a 2D problem in x/z with the height riding along the ray
41
+ // — no general 3D segment-segment solve, and no allocation.
42
+ export function rayCapsule(ox, oy, oz, dx, dy, dz, feet, radius = BODY.r) {
43
+ if (!feet || !Number.isFinite(feet.x) || !Number.isFinite(feet.y) || !Number.isFinite(feet.z)) return null;
44
+ const ax = ox - feet.x, az = oz - feet.z;
45
+ const h = dx * dx + dz * dz;
46
+ const t = h > 1e-9 ? -(ax * dx + az * dz) / h : 0;
47
+ if (!(t > 0)) return null; // behind us (NaN-safe: !(NaN > 0) is true)
48
+ const hx = ax + dx * t, hz = az + dz * t;
49
+ if (!(hx * hx + hz * hz <= radius * radius)) return null; // the ray goes past him
50
+ const y = oy + dy * t - feet.y; // where it crosses, above his feet
51
+ if (!(y >= BODY.lo && y <= BODY.hi)) return null; // over his head, or into the dirt
52
+ return { t, hitY: y };
53
+ }
54
+
55
+ // The nearest body a ray crosses. `bodies` is any iterable of things with .position (at the feet)
56
+ // and .alive. Used by the reticle AND by the aim — so the light and the lead agree by construction.
57
+ //
58
+ // `e.noTarget` (a child, npc.js) is invisible to this function, and BECAUSE this one function
59
+ // serves both the light and the lead, that single word is what makes the reticle refuse to sit on
60
+ // a child AND the round refuse to converge on one. There is no second copy of this to forget.
61
+ export function nearestBody(origin, dir, bodies, { skip = null, maxT = 600, radius = AIM_R } = {}) {
62
+ let best = null, bestT = Infinity;
63
+ for (const e of bodies) {
64
+ if (!e || e === skip || e.alive === false || e.noTarget) continue;
65
+ const hit = rayCapsule(origin.x, origin.y, origin.z, dir.x, dir.y, dir.z, e.position, radius);
66
+ if (!hit || hit.t >= bestT || hit.t > maxT) continue;
67
+ best = e; bestT = hit.t;
68
+ }
69
+ return best ? { e: best, t: bestT, } : null;
70
+ }
71
+
72
+ // THE POINT THE CROSSHAIR IS OVER. A body if there is one, otherwise a long way down the ray.
73
+ // `fallback` matters more than it looks: it is the distance the shot converges at when you are
74
+ // aiming at nothing, and a shot fired at a target 25m away while converging on 140m still carries
75
+ // most of its parallax error. So: converge on the man when there is a man.
76
+ export function aimPoint(out, camPos, dir, bodies, { skip = null, fallback = 140 } = {}) {
77
+ const hit = nearestBody(camPos, dir, bodies, { skip });
78
+ const t = hit && Number.isFinite(hit.t) ? hit.t : fallback;
79
+ return out.copy(camPos).addScaledVector(dir, t);
80
+ }
81
+
82
+ // The direction a round leaves the gun on: from the MUZZLE, AT the aim point. Optionally scattered.
83
+ // The jitter goes in the plane ACROSS the shot, so `spread` radians means that much off the line
84
+ // you actually took, whichever way you happen to be facing.
85
+ export function shotDir(out, muzzle, aim, spread = 0, rnd = Math.random) {
86
+ out.copy(aim).sub(muzzle).normalize();
87
+ if (spread > 0) {
88
+ _v.set(0, 1, 0).cross(out).normalize(); // right, across the shot
89
+ const up = _v.clone().cross(out).normalize(); // and up, across it the other way
90
+ out.addScaledVector(_v, (rnd() - 0.5) * 2 * spread)
91
+ .addScaledVector(up, (rnd() - 0.5) * 2 * spread)
92
+ .normalize();
93
+ }
94
+ return out;
95
+ }
96
+
97
+ // Where a bullet's FLIGHT SEGMENT (p1→p2, one frame of travel) crosses a body. Returns the height
98
+ // above his feet, or null. Segment-based on purpose: a round does 90m/s and we step at 1/60s, so it
99
+ // jumps 1.5 METRES between frames — a point test against a 1m ball can step clean over a man at
100
+ // range (that is the "I shot him and nothing happened" bug), and lands at the ball's near edge when
101
+ // it doesn't (that is the "my headshot did 25 damage" bug).
102
+ export function segmentCapsule(p1, p2, feet, radius = BODY.r) {
103
+ if (!feet || !Number.isFinite(feet.x)) return null;
104
+ const dx = p2.x - p1.x, dy = p2.y - p1.y, dz = p2.z - p1.z;
105
+ const ax = p1.x - feet.x, az = p1.z - feet.z;
106
+ const a = dx * dx + dz * dz;
107
+ let t = a > 1e-9 ? -(ax * dx + az * dz) / a : 0;
108
+ t = t < 0 ? 0 : t > 1 ? 1 : t; // stay on THIS frame's step
109
+ const hx = ax + dx * t, hz = az + dz * t;
110
+ if (!(hx * hx + hz * hz <= radius * radius)) return null;
111
+ const y = p1.y + dy * t - feet.y;
112
+ if (!(y >= BODY.lo - 0.15 && y <= BODY.hi + 0.15)) return null;
113
+ return { hitY: y, at: t };
114
+ }
@@ -0,0 +1,150 @@
1
+ // A lit campsite fire the player can REST at (walk up → E → sit → sleep → skip 5h + heal → wake).
2
+ // The prop is the real Synty campfire (SM_Prop_Campfire_01, frontier pack); the flame + smoke are
3
+ // the Synty-style emissive-orb VFX ported from Fable's torchfire.js. Everything is created ONCE at
4
+ // boot and resident for life (PERF LAW: no scene churn mid-play) — the light is toggled by INTENSITY
5
+ // only, never .visible (toggling a light's presence recompiles every lit material on WebGPU).
6
+ //
7
+ // The sit/sleep sequence itself lives in Game.startRest() (main.js) so it can reach the sky clock +
8
+ // player health + the fade overlay; this file owns the fire object and its look. Prototyped whole in
9
+ // /campfire.html (src/tools/campfire.js) — see [[survival-animset-retarget]].
10
+ import * as THREE from 'three/webgpu';
11
+ import { attribute } from 'three/tsl';
12
+ import { loadGLB, loadTexture } from '../core/assets.js';
13
+
14
+ const FIRE_STOPS = [
15
+ { t: 0.00, c: new THREE.Color(1.00, 0.97, 0.80) },
16
+ { t: 0.30, c: new THREE.Color(1.00, 0.68, 0.20) },
17
+ { t: 0.70, c: new THREE.Color(0.96, 0.30, 0.06) },
18
+ { t: 1.00, c: new THREE.Color(0.72, 0.12, 0.02) },
19
+ ];
20
+ const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _p = new THREE.Vector3(), _s = new THREE.Vector3(), _c = new THREE.Color();
21
+ function fireColor(t, out) {
22
+ for (let i = 1; i < FIRE_STOPS.length; i++) {
23
+ if (t <= FIRE_STOPS[i].t) { const a = FIRE_STOPS[i - 1], b = FIRE_STOPS[i]; return out.lerpColors(a.c, b.c, (t - a.t) / (b.t - a.t)); }
24
+ }
25
+ return out.copy(FIRE_STOPS[FIRE_STOPS.length - 1].c);
26
+ }
27
+
28
+ class CampFlame {
29
+ constructor(count = 46, size = 0.16) {
30
+ this.size = size; this._pool = [];
31
+ const geo = new THREE.IcosahedronGeometry(1, 0);
32
+ const mat = new THREE.MeshBasicNodeMaterial({ toneMapped: false });
33
+ this.mesh = new THREE.InstancedMesh(geo, mat, count);
34
+ this.mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(count * 3), 3);
35
+ this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
36
+ this.mesh.frustumCulled = false;
37
+ for (let i = 0; i < count; i++) { const f = this._spawn({}); f.age = Math.random() * f.life; this._pool.push(f); }
38
+ this.update(0);
39
+ }
40
+ _spawn(f) {
41
+ const z = this.size;
42
+ f.age = 0; f.life = 0.5 + Math.random() * 0.4;
43
+ f.x = (Math.random() - 0.5) * z * 2.4; f.z = (Math.random() - 0.5) * z * 2.4;
44
+ f.vy = z * (2.0 + Math.random() * 1.4); f.sz = z * (0.5 + Math.random() * 0.45);
45
+ return f;
46
+ }
47
+ update(dt, bright = 1) {
48
+ for (let i = 0; i < this._pool.length; i++) {
49
+ const f = this._pool[i]; f.age += dt;
50
+ if (f.age >= f.life) this._spawn(f);
51
+ const t = f.age / f.life;
52
+ _p.set(f.x * (1 - t * 0.6), t * f.vy, f.z * (1 - t * 0.6));
53
+ _s.setScalar(Math.max(0.001, f.sz * Math.sin(Math.min(1, t * 1.3) * Math.PI)));
54
+ this.mesh.setMatrixAt(i, _m.compose(_p, _q, _s));
55
+ this.mesh.setColorAt(i, fireColor(t, _c).multiplyScalar(bright));
56
+ }
57
+ this.mesh.instanceMatrix.needsUpdate = true;
58
+ if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true;
59
+ }
60
+ }
61
+
62
+ class CampSmoke {
63
+ constructor(count = 120, size = 0.16) {
64
+ this.size = size; this._pool = [];
65
+ const geo = new THREE.IcosahedronGeometry(1, 0);
66
+ const mat = new THREE.MeshBasicNodeMaterial({ transparent: true, depthWrite: false, toneMapped: false });
67
+ this.alpha = new THREE.InstancedBufferAttribute(new Float32Array(count), 1);
68
+ geo.setAttribute('iAlpha', this.alpha);
69
+ mat.opacityNode = attribute('iAlpha', 'float');
70
+ this.mesh = new THREE.InstancedMesh(geo, mat, count);
71
+ this.mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(count * 3), 3);
72
+ this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
73
+ this.mesh.frustumCulled = false; this.mesh.renderOrder = 3;
74
+ for (let i = 0; i < count; i++) { const s = this._spawn({}); s.age = s.life; this._pool.push(s); this.alpha.array[i] = 0; } // start empty
75
+ this.update(0, 0);
76
+ }
77
+ _spawn(s) {
78
+ const z = this.size;
79
+ s.age = 0; s.life = 2.2 + Math.random() * 1.4;
80
+ s.x = (Math.random() - 0.5) * z * 0.5; s.z = (Math.random() - 0.5) * z * 0.5;
81
+ s.vy = z * (5.5 + Math.random() * 3.5); s.drift = (Math.random() - 0.5) * z * 1.2;
82
+ s.sz0 = z * 0.5; s.sz1 = z * (1.6 + Math.random() * 0.5); s.base = z * 4.0; // start above the flame tip
83
+ return s;
84
+ }
85
+ // emit = fire level (respawn rate); doused -> the column clears upward, no downward shrink.
86
+ update(dt, emit = 1, bright = 1) {
87
+ for (let i = 0; i < this._pool.length; i++) {
88
+ const s = this._pool[i]; s.age += dt;
89
+ if (s.age >= s.life) {
90
+ if (Math.random() < emit) this._spawn(s);
91
+ else { this.alpha.array[i] = 0; continue; }
92
+ }
93
+ const t = s.age / s.life;
94
+ _p.set(s.x + s.drift * t, s.base + t * s.vy, s.z + s.drift * t * 0.6);
95
+ _s.setScalar(Math.max(0.001, s.sz0 + (s.sz1 - s.sz0) * t));
96
+ this.mesh.setMatrixAt(i, _m.compose(_p, _q, _s));
97
+ this.alpha.array[i] = Math.max(0, Math.min(t * 6, (1 - t) * 10, 1)) * 0.34;
98
+ const g = (0.4 + t * 0.06) * bright;
99
+ this.mesh.setColorAt(i, _c.setRGB(g * 1.05, g, g * 0.97));
100
+ }
101
+ this.mesh.instanceMatrix.needsUpdate = true;
102
+ this.alpha.needsUpdate = true;
103
+ if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true;
104
+ }
105
+ }
106
+
107
+ export class Campfire {
108
+ // starts UNLIT — just the wood pile. The player's rest lights it (Game.startRest) and douses it on
109
+ // leaving; `level` eases toward `lit` so it catches + dies out over a few seconds.
110
+ constructor(game, x, z) { this.game = game; this.x = x; this.z = z; this.group = new THREE.Group(); this.t = 0; this.lit = false; this.level = 0; }
111
+ light() { this.lit = true; }
112
+ douse() { this.lit = false; }
113
+
114
+ async build() {
115
+ const g = this.game;
116
+ const y = g.world.groundAt?.(this.x, this.z) ?? 0;
117
+ this.group.position.set(this.x, y, this.z);
118
+ // real Synty prop (GLB is in METRES ~3.3m -> 0.3 ≈ 1m; its shipped map is a 1x1 stub, so apply the atlas)
119
+ try {
120
+ const prop = (await loadGLB('/assets/frontier/SM_Prop_Campfire_01.glb')).clone();
121
+ prop.scale.setScalar(0.3);
122
+ const atlas = await loadTexture('/assets/textures/atlas_westernfrontier.png', { flipY: false });
123
+ prop.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; if (o.material) { o.material.map = atlas; o.material.color?.setScalar(1); o.material.needsUpdate = true; } } });
124
+ this.group.add(prop);
125
+ } catch (e) { console.warn('[campfire] prop failed', e); }
126
+ this.flame = new CampFlame(); this.flame.mesh.position.y = 0.04; this.group.add(this.flame.mesh);
127
+ this.smoke = new CampSmoke(); this.group.add(this.smoke.mesh);
128
+ // warm light added once, toggled by intensity only (never .visible — that recompiles lit materials).
129
+ // NOTE: named warmLight, NOT `light` — that would clobber the light() method above.
130
+ this.warmLight = new THREE.PointLight(0xff7a2a, 0, 16, 2); this.warmLight.position.set(0, 0.35, 0); this.group.add(this.warmLight);
131
+ g.scene.add(this.group);
132
+ // walk-up-and-press-E REST prompt (same registry as doors/coach/NPCs)
133
+ g.interactables.push({ x: this.x, z: this.z, r: 2.6, label: 'E — Rest at the campfire', action: () => g.startRest(this) });
134
+ }
135
+
136
+ update(dt) {
137
+ if (!this.flame) return;
138
+ this.t += dt;
139
+ this.level += ((this.lit ? 1 : 0) - this.level) * Math.min(1, dt * (this.lit ? 0.5 : 2.2)); // catch slow, die quicker
140
+ const lvl = this.level;
141
+ const flick = 0.75 + 0.25 * Math.sin(this.t * 24) * Math.sin(this.t * 13.3 + 1.7);
142
+ this.warmLight.intensity = lvl * (7 + flick * 4);
143
+ this.flame.mesh.scale.setScalar(lvl < 0.02 ? 0 : 0.12 + 0.88 * lvl); // fully gone when unlit (else a tiny orb lingers)
144
+ // keep the unlit-tone-mapped flame a constant brightness through the night-exposure dimmer (torch trick)
145
+ const exp = this.game.renderer?.toneMappingExposure ?? 1;
146
+ const bright = Math.min(10, Math.max(1, 1 / exp));
147
+ this.flame.update(dt, bright);
148
+ this.smoke.update(dt, lvl, bright);
149
+ }
150
+ }