sindicate 0.1.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/quality.md +159 -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/anim.js +184 -0
- package/src/core/assets.js +596 -0
- package/src/core/engine.js +284 -0
- package/src/core/input/bindings.js +30 -0
- package/src/core/input/gamepad.js +92 -0
- package/src/core/input/index.js +62 -0
- package/src/core/input/keyboard.js +16 -0
- package/src/core/input/mouse.js +48 -0
- package/src/core/quality.js +5 -1
- package/src/core/renderer.js +104 -0
- package/src/core/retarget.js +282 -0
- package/src/index.js +19 -2
- 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,282 @@
|
|
|
1
|
+
// Retargeting from the ANIMATION-pack rig (PolygonSyntyCharacter:
|
|
2
|
+
// Root/Hips/Spine_01/Shoulder_L…) onto Synty POLYGON character rigs.
|
|
3
|
+
//
|
|
4
|
+
// The rigs share structure and proportions, but bone names differ
|
|
5
|
+
// (Pelvis/spine_01/UpperArm_L…) and the bone-local frames differ
|
|
6
|
+
// (Unreal-style axis convention). We retarget with bind-pose deltas:
|
|
7
|
+
//
|
|
8
|
+
// Δb = Wb_animBind⁻¹ · Wb_targetBind (world-rotation delta)
|
|
9
|
+
// q'b(t) = Δparent(b)⁻¹ · qb(t) · Δb (per quaternion key)
|
|
10
|
+
//
|
|
11
|
+
// which makes the target's world deformation match the source clip
|
|
12
|
+
// exactly, regardless of local frame conventions.
|
|
13
|
+
import * as THREE from 'three/webgpu';
|
|
14
|
+
|
|
15
|
+
// ANIMATION rig bone -> POLYGON rig bone. Identity entries are implied
|
|
16
|
+
// for rigs (e.g. Knights pack) that reuse the source names.
|
|
17
|
+
const ANIM_TO_POLYGON = {
|
|
18
|
+
Hips: 'Pelvis',
|
|
19
|
+
Spine_01: 'spine_01', Spine_02: 'spine_02', Spine_03: 'spine_03',
|
|
20
|
+
Neck: 'neck_01', Head: 'head', Eyes: 'eyes', Eyebrows: 'eyebrows',
|
|
21
|
+
Clavicle_L: 'clavicle_l', Shoulder_L: 'UpperArm_L', Elbow_L: 'lowerarm_l', Hand_L: 'Hand_L',
|
|
22
|
+
Clavicle_R: 'clavicle_r', Shoulder_R: 'UpperArm_R', Elbow_R: 'lowerarm_r', Hand_R: 'Hand_R',
|
|
23
|
+
Thumb_01: 'thumb_01_l', Thumb_02: 'thumb_02_l', Thumb_03: 'thumb_03_l',
|
|
24
|
+
IndexFinger_01: 'indexFinger_01_l', IndexFinger_02: 'indexFinger_02_l',
|
|
25
|
+
IndexFinger_03: 'indexFinger_03_l', IndexFinger_04: 'indexFinger_04_l',
|
|
26
|
+
Finger_01: 'finger_01_l', Finger_02: 'finger_02_l', Finger_03: 'finger_03_l', Finger_04: 'finger_04_l',
|
|
27
|
+
Thumb_01_1: 'thumb_01_r', Thumb_02_1: 'thumb_02_r', Thumb_03_1: 'thumb_03_r',
|
|
28
|
+
IndexFinger_01_1: 'indexFinger_01_r', IndexFinger_02_1: 'indexFinger_02_r',
|
|
29
|
+
IndexFinger_03_1: 'indexFinger_03_r', IndexFinger_04_1: 'indexFinger_04_r',
|
|
30
|
+
Finger_01_1: 'finger_01_r', Finger_02_1: 'finger_02_r', Finger_03_1: 'finger_03_r', Finger_04_1: 'finger_04_r',
|
|
31
|
+
UpperLeg_L: 'Thigh_L', LowerLeg_L: 'calf_l', Ankle_L: 'Foot_L', Ball_L: 'ball_l', Toes_L: 'toes_l',
|
|
32
|
+
UpperLeg_R: 'Thigh_R', LowerLeg_R: 'calf_r', Ankle_R: 'Foot_R', Ball_R: 'ball_r', Toes_R: 'toes_r',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Named SOURCE RIGS. Clips are authored against a specific skeleton's BIND POSE, and a clip
|
|
36
|
+
// only plays correctly on a rig with that same bind. Unity hides this — its Humanoid/Mecanim
|
|
37
|
+
// avatar retargets muscle-space, so any humanoid clip plays on any humanoid rig. three.js has
|
|
38
|
+
// no such layer: it applies raw bone quaternions. So a pack whose bind differs (the Wild West
|
|
39
|
+
// gunslinger pack's PreviewRig is ~90-107 deg off on Hips/Spine vs the Synty anim rig, despite
|
|
40
|
+
// IDENTICAL bone names) must declare its own source bind, or it plays as a folded-in-half mess.
|
|
41
|
+
// One global source bind was the bug: every clip was assumed to be authored on the Synty rig.
|
|
42
|
+
const sourceRigs = new Map(); // key -> { bind, map }
|
|
43
|
+
let defaultRigKey = null;
|
|
44
|
+
|
|
45
|
+
// Malbers "Human" rig -> the ANIMATION rig. Used for the climb/ladder clips, which are authored on the
|
|
46
|
+
// Malbers skeleton, not the Synty animation rig. We map onto ANIM-rig names (Hips/Spine_01/Shoulder_L…);
|
|
47
|
+
// the existing ANIM_TO_POLYGON step in mapName() then carries it onto polygon characters automatically, so
|
|
48
|
+
// ONE map serves both anim- and polygon-rigged targets. NOTE: FBXLoader sanitizes spaces->underscores, so
|
|
49
|
+
// the bones load as R_L_Clavicle (not "R_L Clavicle").
|
|
50
|
+
export const MALBERS_TO_ANIM = {
|
|
51
|
+
R_CG: 'Root', // Malbers root (above pelvis) -> anim Root; without it the pelvis loses parent compensation and the body pitches 90°
|
|
52
|
+
R_Pelvis: 'Hips', R_Spine: 'Spine_01', R_Spine1: 'Spine_02', R_Spine2: 'Spine_03',
|
|
53
|
+
R_Neck: 'Neck', R_Head: 'Head',
|
|
54
|
+
R_L_Clavicle: 'Clavicle_L', R_L_UpperArm: 'Shoulder_L', R_L_Forearm: 'Elbow_L', R_L_Hand: 'Hand_L',
|
|
55
|
+
R_R_Clavicle: 'Clavicle_R', R_R_UpperArm: 'Shoulder_R', R_R_Forearm: 'Elbow_R', R_R_Hand: 'Hand_R',
|
|
56
|
+
R_L_Thigh: 'UpperLeg_L', R_L_Calf: 'LowerLeg_L', R_L_Foot: 'Ankle_L',
|
|
57
|
+
R_R_Thigh: 'UpperLeg_R', R_R_Calf: 'LowerLeg_R', R_R_Foot: 'Ankle_R',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const _bindP = new THREE.Vector3(), _bindS = new THREE.Vector3();
|
|
61
|
+
const _posV = new THREE.Vector3();
|
|
62
|
+
|
|
63
|
+
function captureBind(root) {
|
|
64
|
+
root.updateMatrixWorld(true);
|
|
65
|
+
// TRUE bind-pose world rotations from the SkinnedMesh inverse-bind matrices (boneInverses[i]⁻¹
|
|
66
|
+
// IS the bone's world matrix at bind), which are pose-independent ground truth. Reading the
|
|
67
|
+
// live getWorldQuaternion() instead silently poisons every delta if the model is captured
|
|
68
|
+
// mid-animation — e.g. a shared master that got posed before this clone (which is exactly why
|
|
69
|
+
// the SK_Chr_Soldier_* guards held their arms out: their arm deltas were ~30° off true bind).
|
|
70
|
+
const bindQ = new Map(); // bone name -> world quaternion at bind
|
|
71
|
+
root.traverse((o) => {
|
|
72
|
+
if (!o.isSkinnedMesh || !o.skeleton) return;
|
|
73
|
+
const { bones, boneInverses } = o.skeleton;
|
|
74
|
+
for (let i = 0; i < bones.length; i++) {
|
|
75
|
+
const b = bones[i];
|
|
76
|
+
if (!b || !boneInverses[i] || bindQ.has(b.name)) continue;
|
|
77
|
+
const q = new THREE.Quaternion();
|
|
78
|
+
boneInverses[i].clone().invert().decompose(_bindP, q, _bindS);
|
|
79
|
+
bindQ.set(b.name, q);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
// OBJECT-CHAIN ROTATION. boneInverses are expressed in the SKELETON's container space —
|
|
83
|
+
// NOT world. FBXLoader parks the Z-up->Y-up conversion on the container Object3Ds for some
|
|
84
|
+
// packs (the Wild West gunslinger rig: root Group -90deg X, PreviewRig Group -180deg Z) and
|
|
85
|
+
// on a Root BONE for others (Synty: containers identity). Comparing a bind captured in one
|
|
86
|
+
// frame against a bind captured in the other tilts every delta by that rotation — the
|
|
87
|
+
// character animates lying on his back. Fold the container's world rotation in so both rigs
|
|
88
|
+
// are captured in the MODEL-ROOT frame. Identity for Synty-family rigs (no behaviour change);
|
|
89
|
+
// it is a static transform, so this stays safe for posed shared masters (unlike reading
|
|
90
|
+
// getWorldQuaternion off a live bone).
|
|
91
|
+
root.updateMatrixWorld(true);
|
|
92
|
+
let firstBone = null;
|
|
93
|
+
root.traverse((o) => { if (!firstBone && o.isBone) firstBone = o; });
|
|
94
|
+
let rootBone = firstBone;
|
|
95
|
+
while (rootBone?.parent?.isBone) rootBone = rootBone.parent;
|
|
96
|
+
const container = rootBone?.parent ?? null;
|
|
97
|
+
// ...MEASURED RELATIVE TO `root`, NOT TO THE WORLD. getWorldQuaternion() also picks up
|
|
98
|
+
// wherever the character happens to be STANDING — and a Character's model hangs under a root
|
|
99
|
+
// Group that is rotated to its HEADING. So the bind captured for a townsman facing south had
|
|
100
|
+
// his heading folded into it, and every delta computed from it was off by exactly that angle:
|
|
101
|
+
// measured across the town, chainQ came out at precisely |heading| for every NPC (Silas at
|
|
102
|
+
// heading π captured a 180° "bind" and stood with his arms straight up; the player and the
|
|
103
|
+
// barkeep face heading 0, which is the only reason they ever looked right). The container's
|
|
104
|
+
// rotation is only meaningful in the MODEL-ROOT frame — which is what this always meant to be.
|
|
105
|
+
const rootQinv = root.getWorldQuaternion(new THREE.Quaternion()).invert();
|
|
106
|
+
const chainQ = container
|
|
107
|
+
? rootQinv.multiply(container.getWorldQuaternion(new THREE.Quaternion()))
|
|
108
|
+
: new THREE.Quaternion();
|
|
109
|
+
|
|
110
|
+
const map = new Map();
|
|
111
|
+
map.chainQ = chainQ; // container rotation — position tracks live in this frame too
|
|
112
|
+
root.traverse((o) => {
|
|
113
|
+
if (!o.isBone) return;
|
|
114
|
+
const bq = bindQ.get(o.name);
|
|
115
|
+
const worldQ = bq
|
|
116
|
+
? chainQ.clone().multiply(bq) // container space -> model-root frame
|
|
117
|
+
: o.getWorldQuaternion(new THREE.Quaternion()); // fallback: bones with no skin weight
|
|
118
|
+
map.set(o.name, {
|
|
119
|
+
worldQ,
|
|
120
|
+
localP: o.position.clone(),
|
|
121
|
+
parentName: o.parent?.isBone ? o.parent.name : null,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
return map;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Register a source rig by key (its loaded pose = its bind pose). The FIRST one registered
|
|
128
|
+
// becomes the default for clips that don't name a rig.
|
|
129
|
+
export function addSourceRig(key, root, map = null) {
|
|
130
|
+
sourceRigs.set(key, { bind: captureBind(root), map });
|
|
131
|
+
if (!defaultRigKey) defaultRigKey = key;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Back-compat: the original single-source API — registers the 'anim' (Synty) rig.
|
|
135
|
+
export function setSourceRig(root, map = null) {
|
|
136
|
+
addSourceRig('anim', root, map);
|
|
137
|
+
defaultRigKey = 'anim';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function detectRig(root) {
|
|
141
|
+
let rig = 'anim';
|
|
142
|
+
root.traverse((o) => { if (o.isBone && o.name === 'Pelvis') rig = 'polygon'; });
|
|
143
|
+
return rig;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Per-character retarget context: bone deltas keyed by SOURCE bone name.
|
|
147
|
+
// Characters sharing a rig signature can share a context, but contexts are
|
|
148
|
+
// cheap; we just build one per loaded character master.
|
|
149
|
+
export function buildRetargetContext(targetRoot, rigKey = null) {
|
|
150
|
+
const key = rigKey ?? defaultRigKey;
|
|
151
|
+
const srcRig = sourceRigs.get(key);
|
|
152
|
+
if (!srcRig) throw new Error(`source rig '${key}' not registered — call setSourceRig()/addSourceRig() first`);
|
|
153
|
+
const sourceBind = srcRig.bind, sourceMap = srcRig.map;
|
|
154
|
+
const rig = detectRig(targetRoot);
|
|
155
|
+
const targetBind = captureBind(targetRoot);
|
|
156
|
+
const deltas = new Map(); // source bone name -> THREE.Quaternion
|
|
157
|
+
const mapName = (src) => {
|
|
158
|
+
let n = sourceMap ? (sourceMap[src] || src) : src; // foreign source -> anim-rig name
|
|
159
|
+
if (rig === 'polygon' && ANIM_TO_POLYGON[n]) return ANIM_TO_POLYGON[n]; // ...then anim -> polygon for polygon targets
|
|
160
|
+
return n; // identity mapping (Knights-style rigs, or shared names)
|
|
161
|
+
};
|
|
162
|
+
for (const [srcName, src] of sourceBind) {
|
|
163
|
+
const tgtName = mapName(srcName);
|
|
164
|
+
const tgt = targetBind.get(tgtName);
|
|
165
|
+
if (!tgt) continue;
|
|
166
|
+
const delta = src.worldQ.clone().invert().multiply(tgt.worldQ);
|
|
167
|
+
// ROOT FIX. Each bone is compensated through its PARENT's delta. A source bone with no
|
|
168
|
+
// parent (packs whose root bone is Hips — no Root — e.g. the Wild West gunslinger rig)
|
|
169
|
+
// has no parent delta, and the old code fell back to IDENTITY. But the TARGET's Hips
|
|
170
|
+
// hangs under a Root bone carrying its own bind rotation, which then never cancels:
|
|
171
|
+
// want W_tgt(t) = W_src(t) · Δb , and W_tgt = W_parent · q_local
|
|
172
|
+
// so q_local = W_parent⁻¹ · q_src(t) · Δb
|
|
173
|
+
// i.e. the fallback must be the TARGET PARENT's inverse bind world rotation, not identity.
|
|
174
|
+
// With identity the whole character renders upside down / rolled by the root's bind.
|
|
175
|
+
let parentFix = null;
|
|
176
|
+
if (!src.parentName) {
|
|
177
|
+
const tp = tgt.parentName ? targetBind.get(tgt.parentName) : null;
|
|
178
|
+
if (tp) parentFix = tp.worldQ.clone().invert();
|
|
179
|
+
}
|
|
180
|
+
deltas.set(srcName, { delta, targetName: tgtName, parentFix });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Root translation tracks are authored in the source rig's units (cm).
|
|
184
|
+
// Packs authored in metres need them rescaled or the pelvis flies 87m up.
|
|
185
|
+
const srcHips = sourceBind.get('Hips');
|
|
186
|
+
const tgtRootName = rig === 'polygon' ? 'Pelvis' : 'Hips';
|
|
187
|
+
const tgtRoot = targetBind.get(tgtRootName);
|
|
188
|
+
let posScale = 1;
|
|
189
|
+
if (srcHips && tgtRoot && Math.abs(srcHips.localP.y) > 0.001 && Math.abs(tgtRoot.localP.y) > 0.001) {
|
|
190
|
+
posScale = tgtRoot.localP.y / srcHips.localP.y;
|
|
191
|
+
}
|
|
192
|
+
// A TARGET whose Hips sits at its parent's origin (localP.y === 0 — true of the western
|
|
193
|
+
// Synty rigs, where Root and Hips are coincident) used to yield posScale = 0, which
|
|
194
|
+
// multiplied every Hips.position key by ZERO: the pelvis got pinned to the root and the
|
|
195
|
+
// character sank into the ground. Only rescale when BOTH hip heights are real; else 1:1.
|
|
196
|
+
if (!(posScale > 0.01 && posScale < 100)) posScale = 1;
|
|
197
|
+
|
|
198
|
+
// Same bone NAMES don't guarantee the same bind POSE: the Fantasy Rivals bosses each carry a
|
|
199
|
+
// custom rest stance (bent legs, etc.) on anim-rig bone names. The clip can only be used as-is
|
|
200
|
+
// when the bind pose ALSO matches — otherwise their legs break. bindMatches = every bone's
|
|
201
|
+
// bind-delta is ~identity (|w|≈1).
|
|
202
|
+
// Also fold a hash of the bind-deltas into the cache key: every custom-posed boss otherwise
|
|
203
|
+
// produces the SAME key (same rig/boneCount), so they'd share ONE retargeted clip — the first
|
|
204
|
+
// one computed — and wear each other's skeleton. The hash makes each distinct bind pose unique
|
|
205
|
+
// while still letting identical rigs (e.g. many goblins) share the cache.
|
|
206
|
+
let bindMatches = true, h = 0;
|
|
207
|
+
for (const e of deltas.values()) {
|
|
208
|
+
const d = e.delta;
|
|
209
|
+
if (Math.abs(Math.abs(d.w) - 1) > 0.002) bindMatches = false;
|
|
210
|
+
h = (Math.imul(h, 31) + ((d.x * 1e3) | 0) + Math.imul((d.y * 1e3) | 0, 7) + Math.imul((d.z * 1e3) | 0, 13)) | 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const needsRootFix = [...deltas.values()].some((e) => e.parentFix);
|
|
214
|
+
// position tracks are VECTORS in the source container's frame — convert them the same way
|
|
215
|
+
const posQ = (targetBind.chainQ ?? new THREE.Quaternion()).clone().invert()
|
|
216
|
+
.multiply(sourceBind.chainQ ?? new THREE.Quaternion());
|
|
217
|
+
return { rig, deltas, posScale, bindMatches, needsRootFix, posQ, sourceBind, rigKey: key, key: `${key}:${rig}:${posScale.toFixed(3)}:${deltas.size}:${bindMatches ? 1 : 0}:${h}` };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const clipCache = new Map(); // `${clip.uuid}:${ctx.key}` -> retargeted clip
|
|
221
|
+
|
|
222
|
+
// Retarget a clip recorded on the ANIMATION rig to the target context.
|
|
223
|
+
export function retargetClip(clip, ctx) {
|
|
224
|
+
// Identity rig with full bone coverage AND matching units (cm): use clip as-is.
|
|
225
|
+
// A metre-scale rig (e.g. the Modular Fantasy Hero) needs the root-position rescale below,
|
|
226
|
+
// so don't short-circuit it even though its bone names already match the source.
|
|
227
|
+
if (ctx.rig === 'anim' && ctx.bindMatches && !ctx.needsRootFix && Math.abs(ctx.posScale - 1) < 0.05) return clip;
|
|
228
|
+
const cacheKey = `${clip.uuid}:${ctx.key}`;
|
|
229
|
+
if (clipCache.has(cacheKey)) return clipCache.get(cacheKey);
|
|
230
|
+
|
|
231
|
+
const qa = new THREE.Quaternion();
|
|
232
|
+
const tracks = [];
|
|
233
|
+
for (const track of clip.tracks) {
|
|
234
|
+
const dot = track.name.lastIndexOf('.');
|
|
235
|
+
const node = track.name.slice(0, dot);
|
|
236
|
+
const prop = track.name.slice(dot + 1);
|
|
237
|
+
const entry = ctx.deltas.get(node);
|
|
238
|
+
if (!entry) continue; // not a character bone (e.g. bow-prop joints) -> drop
|
|
239
|
+
|
|
240
|
+
if (prop === 'quaternion') {
|
|
241
|
+
const src = ctx.sourceBind.get(node);
|
|
242
|
+
const parentEntry = src.parentName ? ctx.deltas.get(src.parentName) : null;
|
|
243
|
+
// parented bone → cancel the parent's delta; source-root bone → cancel the TARGET
|
|
244
|
+
// parent's bind world rotation (see ROOT FIX in buildRetargetContext)
|
|
245
|
+
const dParentInv = parentEntry ? parentEntry.delta.clone().invert()
|
|
246
|
+
: (entry.parentFix ? entry.parentFix.clone() : null);
|
|
247
|
+
const dBone = entry.delta;
|
|
248
|
+
const values = new Float32Array(track.values.length);
|
|
249
|
+
for (let i = 0; i < track.values.length; i += 4) {
|
|
250
|
+
qa.fromArray(track.values, i);
|
|
251
|
+
if (dParentInv) qa.premultiply(dParentInv);
|
|
252
|
+
qa.multiply(dBone);
|
|
253
|
+
qa.toArray(values, i);
|
|
254
|
+
}
|
|
255
|
+
const t = new THREE.QuaternionKeyframeTrack(`${entry.targetName}.${prop}`, Array.from(track.times), Array.from(values));
|
|
256
|
+
tracks.push(t);
|
|
257
|
+
} else if (prop === 'position') {
|
|
258
|
+
// Keep only the root (Hips) translation — drives bob/crouch —
|
|
259
|
+
// rescaled into the target rig's units.
|
|
260
|
+
if (node !== 'Hips') continue;
|
|
261
|
+
const t = track.clone();
|
|
262
|
+
t.name = `${entry.targetName}.${prop}`;
|
|
263
|
+
// The hips translation is a VECTOR in the source container's frame. Rotating only the
|
|
264
|
+
// quaternion tracks left it in the pack's Z-up space: the pelvis got driven to the
|
|
265
|
+
// floor (hipsY 0) while the body above it posed correctly. Rotate, then rescale.
|
|
266
|
+
const v = t.values;
|
|
267
|
+
const identityQ = Math.abs(ctx.posQ.w) > 0.999999;
|
|
268
|
+
if (!identityQ) {
|
|
269
|
+
for (let i = 0; i < v.length; i += 3) {
|
|
270
|
+
_posV.set(v[i], v[i + 1], v[i + 2]).applyQuaternion(ctx.posQ);
|
|
271
|
+
v[i] = _posV.x; v[i + 1] = _posV.y; v[i + 2] = _posV.z;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (ctx.posScale !== 1) for (let i = 0; i < v.length; i++) v[i] *= ctx.posScale;
|
|
275
|
+
tracks.push(t);
|
|
276
|
+
}
|
|
277
|
+
// scale tracks dropped
|
|
278
|
+
}
|
|
279
|
+
const out = new THREE.AnimationClip(clip.name, clip.duration, tracks);
|
|
280
|
+
clipCache.set(cacheKey, out);
|
|
281
|
+
return out;
|
|
282
|
+
}
|
package/src/index.js
CHANGED
|
@@ -9,18 +9,35 @@
|
|
|
9
9
|
// facet contracts) land in later steps; step 1 files are byte-identical to
|
|
10
10
|
// their proven originals on purpose.
|
|
11
11
|
|
|
12
|
-
// core —
|
|
12
|
+
// core — rendering, assets, animation, retargeting, input, quality, decals, utils
|
|
13
|
+
export { Engine } from './core/engine.js';
|
|
14
|
+
export { createRenderer, createPostProcessing, wantsTRAA } from './core/renderer.js';
|
|
15
|
+
export * from './core/assets.js';
|
|
16
|
+
export * from './core/anim.js';
|
|
17
|
+
export * from './core/retarget.js';
|
|
18
|
+
export { Input, DEFAULT_PAD_BINDINGS } from './core/input/index.js';
|
|
13
19
|
export * from './core/utils.js';
|
|
14
20
|
export { PRESETS, getQuality, getQualityName, setQualityName, applyQuality, AutoTuner } from './core/quality.js';
|
|
15
21
|
export { DecalField } from './core/decalField.js';
|
|
16
22
|
|
|
17
|
-
// world —
|
|
23
|
+
// world — streaming tiles, scatter/LOD, collision, sky/weather stack, lighting, ground dressing
|
|
18
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';
|
|
19
30
|
export * from './world/water.js';
|
|
20
31
|
export * from './world/grass.js';
|
|
21
32
|
export * from './world/wind.js';
|
|
22
33
|
export * from './world/weatherUniforms.js';
|
|
23
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';
|
|
24
41
|
|
|
25
42
|
// systems — pooled debris shatter
|
|
26
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
|
+
}
|