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.
- package/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/engine.js +284 -0
- package/src/index.js +13 -1
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- 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
|
@@ -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 —
|
|
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
|
+
}
|