sindicate 0.16.0 → 0.17.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/CHANGELOG.md +80 -0
- package/package.json +1 -1
- package/src/audio/synth.js +31 -13
- package/src/core/anim.js +22 -0
- package/src/core/engine.js +50 -5
- package/src/core/overrides.js +84 -0
- package/src/core/quality.js +6 -2
- package/src/systems/climbing.js +18 -2
- package/src/systems/coach.js +3 -3
- package/src/systems/combat.js +17 -17
- package/src/systems/crime.js +5 -5
- package/src/systems/deadeye.js +4 -4
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +9 -9
- package/src/systems/footsteps.js +1 -1
- package/src/systems/loot.js +2 -2
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +32 -15
- package/src/systems/riding.js +26 -7
- package/src/systems/shop.js +2 -2
- package/src/systems/train.js +16 -3
- package/src/vehicle/car.js +4 -1
- package/src/world/cityPlan.js +500 -0
- package/src/world/mapWorld.js +120 -0
- package/src/world/roadAssert.js +28 -1
- package/src/world/roadLink.js +88 -15
- package/src/world/roadTerrain.js +36 -8
- package/src/world/roadWorld.js +5 -0
- package/src/world/settlement.js +254 -0
- package/src/world/walkHumps.js +22 -1
- package/tools/modelCatalogue.mjs +59 -0
- package/tools/unitySceneToBuildings.mjs +273 -0
- package/tools/wholeBuildings.mjs +233 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0 — towns, and a third game's report card
|
|
4
|
+
|
|
5
|
+
Two halves. Cities — the thing the road system was always for. And the first pass through
|
|
6
|
+
`ISSUES.md`, raised from building a **third** game (MilitaryGame/"Blacksand", a military game on
|
|
7
|
+
Synty + Kubold + Kevin Iglesias packs) against 0.16.0 as a published dependency rather than a
|
|
8
|
+
file link. Every fix in that half defaults to exactly the previous behaviour, so western and
|
|
9
|
+
fable do not move.
|
|
10
|
+
|
|
11
|
+
### Cities (new: `world/cityPlan.js`, `world/settlement.js`, `world/mapWorld.js`)
|
|
12
|
+
|
|
13
|
+
- `world/cityPlan.js` — a settlement as a street grid whose faces are BLOCKS, zoned. Terrain is a
|
|
14
|
+
veto: water and slope reject a cell outright, population decides how many of the survivors get
|
|
15
|
+
built on, and the town grows outward from its middle so it is one place rather than a rash of
|
|
16
|
+
hamlets. Junctions are classified by arm count — and two arms is **not one case**: two opposite
|
|
17
|
+
arms are a street running through, two perpendicular are a corner.
|
|
18
|
+
- `world/settlement.js` — `planBlockFrontage` fills the blocks, which is the step Kiwi's own
|
|
19
|
+
`BlockData` marks "for building placement later" and never took. Buildings stand on the block
|
|
20
|
+
boundary facing the street; a model's odds decay as it is used, so a pack of sixteen renders as
|
|
21
|
+
sixteen rather than five. `hasFrontage` refuses motorways and dual carriageways outright —
|
|
22
|
+
nothing in the world fronts one.
|
|
23
|
+
- `world/mapWorld.js` — several places and the roads between them. A city exposes `exits`, shaped
|
|
24
|
+
like a road-kit socket, so `buildRoadLink` joins town to motorway with no special case.
|
|
25
|
+
- `world/roadLink.js` — extrudes a **cross-section with height in it** when a socket carries one:
|
|
26
|
+
kerb, gutter and raised footway are in the profile, not laid separately. Four surfaces on their
|
|
27
|
+
own texels, and the past-the-kerb rule that decides which is which.
|
|
28
|
+
- `world/roadTerrain.js` — a corridor now declares whether it carries tarmac (`deck: false` for
|
|
29
|
+
pure earthwork), and coverage stops at the ends of a line instead of claiming a half-disc of
|
|
30
|
+
phantom road beyond them.
|
|
31
|
+
|
|
32
|
+
**The lesson that cost the most, recorded because it will recur:** the earthworks were tuned by
|
|
33
|
+
eye against a height FUNCTION, while every fault lived in the drawn LATTICE. Between two vertices
|
|
34
|
+
straddling a cut the mesh interpolates straight across, so 12 cm of clearance vanishes on a 3.8 m
|
|
35
|
+
cell. Kiwi's own figures — a 1.0 m road bed and a 5 m skirt — are an order of magnitude bigger
|
|
36
|
+
than the ones guessed here for exactly that reason.
|
|
37
|
+
|
|
38
|
+
### Reported by a third game (`ISSUES.md`, all 14 items)
|
|
39
|
+
|
|
40
|
+
- **A guarded object is not a guarded method.** `game.audio?.loadSamples(map)` guards `audio`
|
|
41
|
+
alone; 59 call sites across 13 files had the same shape. And frame hooks ran unguarded, so one
|
|
42
|
+
throw silently killed every system registered after it — reported as grenades hanging in
|
|
43
|
+
mid-air with frozen fuses, four systems from a footstep sound. Hooks are now isolated, and a
|
|
44
|
+
hook that throws eight times is retired rather than logged at 60 Hz.
|
|
45
|
+
- **Masks follow their source clip.** A game re-registering a logical clip (a weapon switch) kept
|
|
46
|
+
masks pointing at the boot weapon for ever. And a one-shot re-triggered faster than it plays is
|
|
47
|
+
no longer restarted — 200 restarts over 19 s becomes 34.
|
|
48
|
+
- **`climbing.js` referenced an undeclared `REACH`**, so its proximity-mount path threw on sight;
|
|
49
|
+
it also read only fable's `world.castle.ladders`. Both fixed, plus `setLadders()`.
|
|
50
|
+
- **`createCar()` spawned from the default ride height** while every later frame used the
|
|
51
|
+
configured one — a vehicle at 0.9 started 0.56 m low and popped up.
|
|
52
|
+
- Perf timers are both held and cleared; an unset `qualityName` no longer answers a BAD framerate
|
|
53
|
+
by stepping quality **up**.
|
|
54
|
+
- Dead `setTrainContent` keys say so; an unknown walk-hump is scenery rather than a crash, with
|
|
55
|
+
`setHumpDims()` to teach it.
|
|
56
|
+
|
|
57
|
+
### Declared overrides (new: `core/overrides.js`)
|
|
58
|
+
|
|
59
|
+
Named decisions a game may answer for itself, registered at construction and never patched at
|
|
60
|
+
runtime. It overrides **decisions, not methods** — `play`'s signature is implementation shape and
|
|
61
|
+
will move; "what should the body be playing?" is a question with a stable contract. Registering
|
|
62
|
+
nothing, or answering `null`, is byte-identical to before. Unknown names and non-functions throw
|
|
63
|
+
at construction; an override that throws at runtime is caught and the engine's own answer used.
|
|
64
|
+
|
|
65
|
+
First point: `locomotionClip`. The full-body clip per gun state is right for western's gunslinger
|
|
66
|
+
pack and wrong for Kubold's rifle set, which has one standing reload — so walking into a reload
|
|
67
|
+
stopped the legs dead.
|
|
68
|
+
|
|
69
|
+
### Also injected rather than assumed
|
|
70
|
+
|
|
71
|
+
- `AudioSystem` takes an `sfxManifest`; western's list is exported as `DUSTWATER_SFX(dirs)`.
|
|
72
|
+
- Seated-pose assets take `setSeatedContent({ rig, clips, audioDir })` — a soldier in a tank hatch
|
|
73
|
+
wants the same retarget as a rider on a horse.
|
|
74
|
+
- The weapon table derives from what the game registered. Only three lines ever named western's
|
|
75
|
+
weapons, and they were what made the constructor throw without both `revolver` and `rifle`.
|
|
76
|
+
|
|
77
|
+
### Notes
|
|
78
|
+
|
|
79
|
+
- True N-slot weapons, the seat/occupant abstraction, and `city` support in `roadAssert` are
|
|
80
|
+
deliberately **not** in this release. Each is scheduled rather than landed opportunistically.
|
|
81
|
+
- The city modules are new and may still move before 1.0.
|
|
82
|
+
|
|
3
83
|
## 0.16.0 — driving on it
|
|
4
84
|
|
|
5
85
|
### A car (new: `vehicle/`)
|
package/package.json
CHANGED
package/src/audio/synth.js
CHANGED
|
@@ -10,9 +10,24 @@
|
|
|
10
10
|
// new AudioSystem({ sfx, muted, sfxDirs, storageKey })
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
// The default manifest: western's recorded SFX, kept here rather than inline in loadSfx() so
|
|
14
|
+
// that a game replacing it can still see — and reuse — what the engine used to assume.
|
|
15
|
+
export function DUSTWATER_SFX({ combat: C, ui: U } = {}) {
|
|
16
|
+
const map = {};
|
|
17
|
+
for (let i = 1; i <= 6; i++) { map[`swing${i}`] = `${C}/swing${i}.wav`; map[`hit${i}`] = `${C}/hit${i}.wav`; }
|
|
18
|
+
for (let i = 1; i <= 3; i++) map[`block${i}`] = `${C}/block${i}.wav`;
|
|
19
|
+
for (let i = 1; i <= 4; i++) map[`boom${i}`] = `${C}/boom${i}.wav`;
|
|
20
|
+
map.uiclick1 = `${U}/uiclick1.wav`;
|
|
21
|
+
for (let i = 1; i <= 2; i++) map[`coin${i}`] = `${U}/coin${i}.wav`;
|
|
22
|
+
for (let i = 1; i <= 3; i++) map[`orb${i}`] = `${U}/orb${i}.wav`;
|
|
23
|
+
for (const e of ['heal', 'quest', 'questDone', 'renown', 'levelup']) map[`${e}1`] = `${U}/${e}1.wav`;
|
|
24
|
+
return map;
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
export class AudioSystem {
|
|
14
|
-
constructor({ sfx = {}, muted = [], sfxDirs = { combat: '/assets/audio/combat', ui: '/assets/audio/ui' }, storageKey = 'av_volSfx' } = {}) {
|
|
28
|
+
constructor({ sfx = {}, muted = [], sfxDirs = { combat: '/assets/audio/combat', ui: '/assets/audio/ui' }, storageKey = 'av_volSfx', sfxManifest = null } = {}) {
|
|
15
29
|
this._sfx = sfx;
|
|
30
|
+
this._sfxManifest = sfxManifest;
|
|
16
31
|
this._muted = new Set(muted);
|
|
17
32
|
this._sfxDirs = sfxDirs;
|
|
18
33
|
this._storageKey = storageKey;
|
|
@@ -127,19 +142,22 @@ export class AudioSystem {
|
|
|
127
142
|
src.start(t0); src.stop(t0 + dur);
|
|
128
143
|
}
|
|
129
144
|
|
|
130
|
-
// Lazily load all recorded SFX (
|
|
131
|
-
//
|
|
145
|
+
// Lazily load all recorded SFX. Called from ensure() once the context exists; sample()
|
|
146
|
+
// no-ops until they finish decoding.
|
|
147
|
+
//
|
|
148
|
+
// WHICH SAMPLES EXIST IS GAME DATA. This built a fixed list of western's names — swing1..6,
|
|
149
|
+
// hit1..6, block1..3, boom1..4, and the UI cues — and ensure() calls it unconditionally, so
|
|
150
|
+
// a game with different audio took about thirty 404s on its first sound and had no way to
|
|
151
|
+
// stop it but overriding the method. The class comment already said recorded SFX are "GAME
|
|
152
|
+
// data — pass at construction", and `sfx` and `sfxDirs` already arrive that way; the loader
|
|
153
|
+
// simply did not follow.
|
|
154
|
+
//
|
|
155
|
+
// `sfxManifest` is either a { name: url } map or a function of the sfx dirs returning one.
|
|
156
|
+
// Absent, it is western's list exactly as before — so western and fable do not move.
|
|
132
157
|
loadSfx() {
|
|
133
|
-
const
|
|
134
|
-
const map =
|
|
135
|
-
|
|
136
|
-
for (let i = 1; i <= 3; i++) map[`block${i}`] = `${C}/block${i}.wav`;
|
|
137
|
-
for (let i = 1; i <= 4; i++) map[`boom${i}`] = `${C}/boom${i}.wav`;
|
|
138
|
-
map.uiclick1 = `${U}/uiclick1.wav`;
|
|
139
|
-
for (let i = 1; i <= 2; i++) map[`coin${i}`] = `${U}/coin${i}.wav`;
|
|
140
|
-
for (let i = 1; i <= 3; i++) map[`orb${i}`] = `${U}/orb${i}.wav`;
|
|
141
|
-
for (const e of ['heal', 'quest', 'questDone', 'renown', 'levelup']) map[`${e}1`] = `${U}/${e}1.wav`;
|
|
142
|
-
this.loadSamples(map);
|
|
158
|
+
const m = this._sfxManifest;
|
|
159
|
+
const map = typeof m === 'function' ? m(this._sfxDirs) : (m ?? DUSTWATER_SFX(this._sfxDirs));
|
|
160
|
+
this.loadSamples(map ?? {});
|
|
143
161
|
}
|
|
144
162
|
|
|
145
163
|
// gainMul (default 1) scales the sound's volume — the hook for distance-attenuated one-shots: a
|
package/src/core/anim.js
CHANGED
|
@@ -57,12 +57,24 @@ export class Animator {
|
|
|
57
57
|
const ctx = this._ctxFor(this.clipRigs?.[name] ?? null);
|
|
58
58
|
const action = this.mixer.clipAction(retargetClip(clip, ctx));
|
|
59
59
|
this.actions[name] = action;
|
|
60
|
+
// A MASK IS DERIVED FROM A CLIP, so re-registering the clip must rebuild it. Masks were
|
|
61
|
+
// snapshotted at build time and never invalidated, so a game that re-registers a logical
|
|
62
|
+
// name later — one that swaps weapons by re-registering `gunFire`, say — kept masks
|
|
63
|
+
// pointing at whatever was loaded at boot. Every weapon then fired and reloaded like the
|
|
64
|
+
// one you spawned with, silently and for ever. The engine created the derivations, so it
|
|
65
|
+
// is the engine's job to know them.
|
|
66
|
+
for (const d of this._derived?.get(name) ?? []) this.registerMasked(d.name, name, d.keepFn);
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
// Build a bone-masked copy of an already-registered clip (keeps only tracks whose
|
|
63
70
|
// bone passes keepFn). Used to split locomotion across body halves — e.g. legs
|
|
64
71
|
// from `walk` + torso/arms from `blockLoop` so you can walk with the guard up.
|
|
65
72
|
registerMasked(name, srcName, keepFn) {
|
|
73
|
+
// remembered so register() can rebuild it — see the note there
|
|
74
|
+
const list = (this._derived ??= new Map()).get(srcName) ?? [];
|
|
75
|
+
if (!list.some((d) => d.name === name)) list.push({ name, keepFn });
|
|
76
|
+
this._derived.set(srcName, list);
|
|
77
|
+
|
|
66
78
|
const src = this.actions[srcName];
|
|
67
79
|
if (!src) return;
|
|
68
80
|
const clip = src.getClip().clone();
|
|
@@ -125,6 +137,16 @@ export class Animator {
|
|
|
125
137
|
const timeScale = opts.timeScale ?? tm.speed ?? 1;
|
|
126
138
|
if (this.currentName === name && !restart && !once) return action;
|
|
127
139
|
|
|
140
|
+
// A ONE-SHOT RE-TRIGGERED FASTER THAN IT PLAYS IS NOT A ONE-SHOT. One-shots deliberately
|
|
141
|
+
// skip the early return above so each call restarts them, which is right for a revolver
|
|
142
|
+
// and wrong for an M4: at a 0.095 s rate a 1 s full-body fire clip restarts ten times a
|
|
143
|
+
// second and the whole body jitters. Let it run unless it has advanced far enough to be
|
|
144
|
+
// worth restarting, or the caller insists with `restart`.
|
|
145
|
+
if (once && !restart && this.current === action && action.isRunning()) {
|
|
146
|
+
const dur = action.getClip().duration || 0;
|
|
147
|
+
if (dur > 0 && action.time < dur * (opts.retrigger ?? RETRIGGER_AT)) return action;
|
|
148
|
+
}
|
|
149
|
+
|
|
128
150
|
action.reset();
|
|
129
151
|
if (startFrac > 0) action.time = action.getClip().duration * startFrac;
|
|
130
152
|
action.timeScale = timeScale;
|
package/src/core/engine.js
CHANGED
|
@@ -16,9 +16,13 @@
|
|
|
16
16
|
// engine.start() // hands the loop to the renderer
|
|
17
17
|
import * as THREE from 'three/webgpu';
|
|
18
18
|
import { tune } from './tune.js';
|
|
19
|
+
import { Overrides } from './overrides.js';
|
|
20
|
+
|
|
21
|
+
// how many times a frame hook may throw before the engine stops calling it
|
|
22
|
+
const HOOK_THROW_LIMIT = 8;
|
|
19
23
|
|
|
20
24
|
export class Engine {
|
|
21
|
-
constructor({ renderer, scene, camera, input, post = null, isPaused = () => false, isUiModal = () => false }) {
|
|
25
|
+
constructor({ renderer, scene, camera, input, post = null, isPaused = () => false, isUiModal = () => false, overrides = null }) {
|
|
22
26
|
this.renderer = renderer;
|
|
23
27
|
this.scene = scene;
|
|
24
28
|
this.camera = camera;
|
|
@@ -26,6 +30,10 @@ export class Engine {
|
|
|
26
30
|
this.post = post;
|
|
27
31
|
this.isPaused = isPaused;
|
|
28
32
|
this.isUiModal = isUiModal;
|
|
33
|
+
// NAMED DECISIONS A GAME MAY ANSWER FOR ITSELF — see core/overrides.js. Passing nothing
|
|
34
|
+
// is byte-identical to before; an unknown name or a non-function throws here, at
|
|
35
|
+
// construction, rather than at frame 900.
|
|
36
|
+
this.overrides = new Overrides(overrides);
|
|
29
37
|
|
|
30
38
|
// time
|
|
31
39
|
this.rawDt = 0;
|
|
@@ -148,12 +156,26 @@ export class Engine {
|
|
|
148
156
|
start() {
|
|
149
157
|
this._clockLast = performance.now();
|
|
150
158
|
this.renderer.setAnimationLoop(this.tick);
|
|
151
|
-
// auto perf log — first at 6s (past load hitches), then periodic
|
|
152
|
-
|
|
159
|
+
// auto perf log — first at 6s (past load hitches), then periodic.
|
|
160
|
+
//
|
|
161
|
+
// BOTH TIMERS ARE HELD, and start() clears whatever is already running. stop() used to
|
|
162
|
+
// clear only the interval — so stopping inside the first six seconds left the timeout to
|
|
163
|
+
// fire afterwards and install an interval nothing would ever clear, and calling start()
|
|
164
|
+
// twice stacked a timeout and an interval each time.
|
|
165
|
+
this._stopPerfTimers();
|
|
166
|
+
this._perfDelay = setTimeout(() => {
|
|
167
|
+
this._perfDelay = null;
|
|
168
|
+
this.perfReport();
|
|
169
|
+
this._perfTimer = setInterval(() => this.perfReport(), 15000);
|
|
170
|
+
}, 6000);
|
|
153
171
|
}
|
|
154
172
|
stop() {
|
|
155
173
|
this.renderer.setAnimationLoop(null);
|
|
156
|
-
|
|
174
|
+
this._stopPerfTimers();
|
|
175
|
+
}
|
|
176
|
+
_stopPerfTimers() {
|
|
177
|
+
if (this._perfDelay) { clearTimeout(this._perfDelay); this._perfDelay = null; }
|
|
178
|
+
if (this._perfTimer) { clearInterval(this._perfTimer); this._perfTimer = null; }
|
|
157
179
|
}
|
|
158
180
|
|
|
159
181
|
_tick() {
|
|
@@ -196,7 +218,30 @@ export class Engine {
|
|
|
196
218
|
|
|
197
219
|
// MAIN PHASE — the game's ordered hooks. Pause-gating of individual blocks is the
|
|
198
220
|
// hook's business (ctx.paused); ordering between hooks is registration order.
|
|
199
|
-
|
|
221
|
+
//
|
|
222
|
+
// EACH HOOK IS ISOLATED. Unguarded, one hook that throws takes every hook registered
|
|
223
|
+
// AFTER it down with it, in silence — and the symptom surfaces wherever those systems
|
|
224
|
+
// were doing their work, which is nowhere near the throw. A game reported thrown grenades
|
|
225
|
+
// hanging in mid-air with frozen fuses; the cause was a footstep sound, four systems
|
|
226
|
+
// earlier. That is expensive to diagnose and cheap to prevent, and the timer loop just
|
|
227
|
+
// above has always done it this way.
|
|
228
|
+
//
|
|
229
|
+
// A hook that keeps throwing is retired rather than logged every frame at 60 Hz — the
|
|
230
|
+
// first stack trace is the useful one, and the thousandth buries it.
|
|
231
|
+
for (const fn of this._hooks.frame) {
|
|
232
|
+
if (this._hookFailed?.has(fn)) continue;
|
|
233
|
+
try {
|
|
234
|
+
fn(ctx);
|
|
235
|
+
} catch (e) {
|
|
236
|
+
(this._hookThrows ??= new Map()).set(fn, (this._hookThrows.get(fn) ?? 0) + 1);
|
|
237
|
+
const n = this._hookThrows.get(fn);
|
|
238
|
+
if (n === 1) console.error('[frame hook] threw — later hooks still ran:', e);
|
|
239
|
+
if (n >= HOOK_THROW_LIMIT) {
|
|
240
|
+
(this._hookFailed ??= new Set()).add(fn);
|
|
241
|
+
console.error(`[frame hook] threw ${n} times, retiring it. Fix the trace above.`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
200
245
|
|
|
201
246
|
this.renderFrame();
|
|
202
247
|
stamps.render = performance.now();
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// DECLARED OVERRIDES — named decisions a game may answer for itself.
|
|
2
|
+
//
|
|
3
|
+
// The engine makes decisions internally. Most are right for every game; some are right only for
|
|
4
|
+
// the game the engine grew out of. When a game's content differs, it has had one option: reach
|
|
5
|
+
// in and patch the method that makes the decision. That works, and it is coupled to signatures,
|
|
6
|
+
// call order and private fields the game does not own, so it breaks silently on any minor. It
|
|
7
|
+
// does not compose either — if two games patch the same method for different reasons, both
|
|
8
|
+
// break and neither loudly.
|
|
9
|
+
//
|
|
10
|
+
// const engine = new Engine({
|
|
11
|
+
// …,
|
|
12
|
+
// overrides: {
|
|
13
|
+
// locomotionClip: ({ moving, aiming, reloading, has }) =>
|
|
14
|
+
// reloading && !has('gunReloadWalk') ? 'gunReload' : null, // null = engine decides
|
|
15
|
+
// },
|
|
16
|
+
// });
|
|
17
|
+
//
|
|
18
|
+
// FOUR PROPERTIES MAKE THIS WORTH MORE THAN FORMALISED MONKEY-PATCHING:
|
|
19
|
+
//
|
|
20
|
+
// 1. IT OVERRIDES DECISIONS, NOT METHODS. `override('Animator.play', fn)` would only legalise
|
|
21
|
+
// the fragility — `play`'s signature is implementation shape and will move. A decision is a
|
|
22
|
+
// QUESTION, and questions have stable contracts. Every design gap this exists to close is a
|
|
23
|
+
// question the engine currently answers with a hardcoded string.
|
|
24
|
+
//
|
|
25
|
+
// 2. THE DEFAULT IS TODAY'S BEHAVIOUR. Registering nothing is byte-identical. That is what
|
|
26
|
+
// makes it safe to add decision points to code that two shipped games depend on: returning
|
|
27
|
+
// `null` or `undefined` from an override also means "engine decides", so a handler that
|
|
28
|
+
// only cares about one case does not have to reimplement the rest.
|
|
29
|
+
//
|
|
30
|
+
// 3. NARROW AND DECLARED. Every point is listed in POINTS below with its signature. A game
|
|
31
|
+
// needing something that is not there has found the next one to add — which is a design
|
|
32
|
+
// conversation, not a silent patch.
|
|
33
|
+
//
|
|
34
|
+
// 4. IT FAILS AT REGISTRATION, NOT AT FRAME 900. An unknown name or a non-function is thrown
|
|
35
|
+
// at construction, where the stack trace points at the mistake.
|
|
36
|
+
|
|
37
|
+
// Every decision point the engine will ask about. `args` documents what the handler receives;
|
|
38
|
+
// `returns` what it may answer, where null/undefined always means "engine decides".
|
|
39
|
+
export const POINTS = {
|
|
40
|
+
locomotionClip: {
|
|
41
|
+
args: '{ moving, sprinting, aiming, reloading, holstering, drawn, weapon, animator, has }',
|
|
42
|
+
returns: 'a clip name to play, or null to let the engine choose',
|
|
43
|
+
why: 'western\'s gunslinger pack has a full-body clip for every gun state. Kubold\'s rifle '
|
|
44
|
+
+ 'set has one standing reload and no walking reload, so a game on it needs the legs to '
|
|
45
|
+
+ 'keep moving through a reload the engine would otherwise stop dead.',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export class Overrides {
|
|
50
|
+
constructor(map = {}) {
|
|
51
|
+
this._fns = new Map();
|
|
52
|
+
for (const [name, fn] of Object.entries(map ?? {})) {
|
|
53
|
+
if (!POINTS[name]) {
|
|
54
|
+
throw new Error(`[overrides] unknown decision point "${name}". Known: `
|
|
55
|
+
+ `${Object.keys(POINTS).join(', ')}. A point that should exist and does not is a `
|
|
56
|
+
+ 'design change, not a config typo.');
|
|
57
|
+
}
|
|
58
|
+
if (typeof fn !== 'function') {
|
|
59
|
+
throw new TypeError(`[overrides] "${name}" must be a function, got ${typeof fn}. `
|
|
60
|
+
+ `It is asked: ${POINTS[name].args} -> ${POINTS[name].returns}`);
|
|
61
|
+
}
|
|
62
|
+
this._fns.set(name, fn);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
has(name) { return this._fns.has(name); }
|
|
67
|
+
|
|
68
|
+
// Ask the game. Returns undefined when nothing is registered, when the handler declines by
|
|
69
|
+
// returning null/undefined, or when it throws — a broken override must not take the frame
|
|
70
|
+
// with it, and one bad answer per name is worth exactly one trace.
|
|
71
|
+
ask(name, args) {
|
|
72
|
+
const fn = this._fns.get(name);
|
|
73
|
+
if (!fn) return undefined;
|
|
74
|
+
try {
|
|
75
|
+
return fn(args) ?? undefined;
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if (!(this._threw ??= new Set()).has(name)) {
|
|
78
|
+
this._threw.add(name);
|
|
79
|
+
console.error(`[overrides] "${name}" threw — the engine's own answer is being used:`, e);
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/core/quality.js
CHANGED
|
@@ -95,7 +95,11 @@ export class AutoTuner {
|
|
|
95
95
|
const fps = this.frames / this.acc;
|
|
96
96
|
this.acc = 0; this.frames = 0;
|
|
97
97
|
const order = ['high', 'medium', 'low'];
|
|
98
|
-
|
|
98
|
+
// WHAT ARE WE RUNNING? A game that never set `qualityName` gave indexOf(undefined) = -1,
|
|
99
|
+
// which passes the "can still step down" test and makes the next preset order[0] — so a
|
|
100
|
+
// bad framerate stepped quality UP, and paid applyQuality's setPixelRatio+setSize hitch to
|
|
101
|
+
// do it. Unknown means the top preset: that is what a game gets if it never chose.
|
|
102
|
+
const cur = Math.max(0, order.indexOf(this.game.qualityName ?? order[0]));
|
|
99
103
|
if (fps < 24 && cur < order.length - 1) {
|
|
100
104
|
// two consecutive bad windows before acting — applyQuality's setPixelRatio+setSize
|
|
101
105
|
// is itself a big hitch, so a single loading burst must not trigger it mid-play
|
|
@@ -104,7 +108,7 @@ export class AutoTuner {
|
|
|
104
108
|
this.badRun = 0;
|
|
105
109
|
const next = order[cur + 1];
|
|
106
110
|
applyQuality(this.game, next);
|
|
107
|
-
this.game.ui?.toast(`Performance: graphics set to ${PRESETS[next].label} (change in Esc menu)`, 4200);
|
|
111
|
+
this.game.ui?.toast?.(`Performance: graphics set to ${PRESETS[next].label} (change in Esc menu)`, 4200);
|
|
108
112
|
}
|
|
109
113
|
} else if (fps >= 24) {
|
|
110
114
|
this.done = true; // stable — stop fiddling
|
package/src/systems/climbing.js
CHANGED
|
@@ -18,6 +18,9 @@ export function setClimbContent({ faceSign = null } = {}) { if (faceSign != null
|
|
|
18
18
|
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
|
19
19
|
const _axis = new THREE.Vector3(), _face = new THREE.Vector3();
|
|
20
20
|
|
|
21
|
+
// how close the player must be to a ladder's base to mount it, in metres
|
|
22
|
+
const LADDER_REACH = 2.2;
|
|
23
|
+
|
|
21
24
|
export class Climbing {
|
|
22
25
|
constructor(player) {
|
|
23
26
|
this.player = player;
|
|
@@ -138,11 +141,24 @@ export class Climbing {
|
|
|
138
141
|
}
|
|
139
142
|
|
|
140
143
|
// ---- fable-lineage conveniences (unioned): ladder scan + input-driven mount ----
|
|
141
|
-
|
|
144
|
+
//
|
|
145
|
+
// WHEREVER THE GAME KEEPS THEM. This read `world.castle.ladders` alone — a fable dungeon
|
|
146
|
+
// path — while this file's own header says ladders come from `world.ladders`. Two of the
|
|
147
|
+
// three consumers therefore had no ladders at all, and nothing said so. Both are accepted;
|
|
148
|
+
// a game may also hand them over directly with `setLadders()`.
|
|
149
|
+
_ladders() {
|
|
150
|
+
return this._own ?? this.game.world?.ladders ?? this.game.world?.castle?.ladders ?? null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// a game that keeps its ladders somewhere else entirely can just pass them in
|
|
154
|
+
setLadders(list) { this._own = list?.length ? list : null; }
|
|
142
155
|
|
|
143
156
|
nearby() {
|
|
144
157
|
const ls = this._ladders(); if (!ls || !ls.length) return null;
|
|
145
|
-
|
|
158
|
+
// REACH was never declared — anywhere in the package — so this threw a ReferenceError the
|
|
159
|
+
// moment a non-empty ladder list existed, and tryMount() calls it unconditionally. The
|
|
160
|
+
// whole feature was unusable rather than merely misconfigured.
|
|
161
|
+
const p = this.player.position; let best = null, bd = LADDER_REACH * LADDER_REACH;
|
|
146
162
|
for (const l of ls) {
|
|
147
163
|
const dy = p.y - l.base.y;
|
|
148
164
|
if (dy < -1.0 || dy > l.height + 1.0) continue;
|
package/src/systems/coach.js
CHANGED
|
@@ -490,7 +490,7 @@ class Coach {
|
|
|
490
490
|
const people = []; // { x, z, ent } — ent null for the player (a coach does not shout the player aside)
|
|
491
491
|
const p = this.game.player;
|
|
492
492
|
const pp = p?.position ?? p?.root?.position;
|
|
493
|
-
const mine = this.rider?.player === p || this.game.platforms?.carrier(p) === this;
|
|
493
|
+
const mine = this.rider?.player === p || this.game.platforms?.carrier?.(p) === this;
|
|
494
494
|
if (pp && !mine) people.push({ x: pp.x, z: pp.z, ent: null });
|
|
495
495
|
for (const n of this.game.npcs ?? []) {
|
|
496
496
|
if (!n.alive) continue; // a coach drives over a body. It does not queue behind one
|
|
@@ -591,7 +591,7 @@ class Coach {
|
|
|
591
591
|
board(player, kind) {
|
|
592
592
|
// a man who sits down is no longer a man standing on the roof (and `aboard` writes his position
|
|
593
593
|
// from the seat, so the two states must never both be live)
|
|
594
|
-
this.game.platforms?.leave(player, 'aboard');
|
|
594
|
+
this.game.platforms?.leave?.(player, 'aboard');
|
|
595
595
|
this.rider = { player, kind };
|
|
596
596
|
// POINT THE CAMERA WHERE SHE IS GOING. It orbits the player at camYaw, and camYaw is whatever
|
|
597
597
|
// you happened to be looking at when you climbed up — so you took the reins and found yourself
|
|
@@ -954,7 +954,7 @@ export class CoachLine {
|
|
|
954
954
|
// A THING YOU CAN STAND ON. The registry measures her motion itself, so this is the whole
|
|
955
955
|
// of it — no per-frame hook, nothing for the coach to remember, and the same four lines
|
|
956
956
|
// will put a man on a boxcar. `cols` is what he stops being pushed by while he is up there.
|
|
957
|
-
c.plat = this.game.platforms?.register({
|
|
957
|
+
c.plat = this.game.platforms?.register?.({
|
|
958
958
|
root: c.root, owner: c, decks: [ROOF], hull: HULL,
|
|
959
959
|
cols: c.myCols, radius: 4,
|
|
960
960
|
});
|
package/src/systems/combat.js
CHANGED
|
@@ -130,7 +130,7 @@ export class Combat {
|
|
|
130
130
|
p.dodgeT = 1.6; // i-frames — nothing interrupts an execution
|
|
131
131
|
p.comboStep = 0; p.comboWindow = 0;
|
|
132
132
|
p.attackCooldown = 0.3;
|
|
133
|
-
this.game.audio?.play('swingHeavy');
|
|
133
|
+
this.game.audio?.play?.('swingHeavy');
|
|
134
134
|
p.animator.play('punchUpper', {
|
|
135
135
|
once: true, fade: 0.08, timeScale: 0.9, // slower = weightier than the combo upper
|
|
136
136
|
onDone: () => { p.busy = false; p.dodgeT = Math.min(p.dodgeT, 0.1); },
|
|
@@ -150,8 +150,8 @@ export class Combat {
|
|
|
150
150
|
this.game.vfx.blood(e.position.clone().setY(e.position.y + 1.2), bdir, 2);
|
|
151
151
|
this.game.hitstopT = Math.max(this.game.hitstopT ?? 0, 0.22); // the big crunch (kills are 0.13)
|
|
152
152
|
this.game.shake(0.75);
|
|
153
|
-
this.game.audio?.play('hit');
|
|
154
|
-
this.game.ui?.showDamage(e, shown);
|
|
153
|
+
this.game.audio?.play?.('hit');
|
|
154
|
+
this.game.ui?.showDamage?.(e, shown);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
// ONE fist-vs-body test. `e` is anything with the SHOOTABLE-PEOPLE contract — an outlaw, or a
|
|
@@ -177,7 +177,7 @@ export class Combat {
|
|
|
177
177
|
this.game.vfx.blood(e.position.clone().setY(e.position.y + 1.2), bdir, 1);
|
|
178
178
|
// hit-stop: a breath on connect, a longer one on the killing blow
|
|
179
179
|
this.game.hitstopT = Math.max(this.game.hitstopT ?? 0, !e.alive ? 0.13 : heavy ? 0.09 : 0.045);
|
|
180
|
-
this.game.ui?.showDamage(e, Math.round(dmg));
|
|
180
|
+
this.game.ui?.showDamage?.(e, Math.round(dmg));
|
|
181
181
|
return true;
|
|
182
182
|
}
|
|
183
183
|
|
|
@@ -209,7 +209,7 @@ export class Combat {
|
|
|
209
209
|
p.comboStep = (step + 1) % combo.length;
|
|
210
210
|
p.comboWindow = 1.1;
|
|
211
211
|
p.attackCooldown = heavy ? 0.25 : 0.12;
|
|
212
|
-
this.game.audio?.play(heavy ? 'swingHeavy' : 'swing');
|
|
212
|
+
this.game.audio?.play?.(heavy ? 'swingHeavy' : 'swing');
|
|
213
213
|
|
|
214
214
|
p.animator.play(anim, {
|
|
215
215
|
once: true, fade: 0.07, timeScale: heavy ? 1.2 : 1.5,
|
|
@@ -242,12 +242,12 @@ export class Combat {
|
|
|
242
242
|
if (this._meleeHit(n, dmg, heavy, range, arc)) hitAny = true;
|
|
243
243
|
}
|
|
244
244
|
// smashables share the swing arc — crates/barrels/bottles break on any hit
|
|
245
|
-
if (this.game.smashables?.tryMelee(p.position, p.heading, range, arc)) hitAny = true;
|
|
245
|
+
if (this.game.smashables?.tryMelee?.(p.position, p.heading, range, arc)) hitAny = true;
|
|
246
246
|
if (hitAny) {
|
|
247
|
-
this.game.audio?.play('hit');
|
|
247
|
+
this.game.audio?.play?.('hit');
|
|
248
248
|
this.game.shake(0.3);
|
|
249
249
|
} else {
|
|
250
|
-
this.game.audio?.play('whoosh');
|
|
250
|
+
this.game.audio?.play?.('whoosh');
|
|
251
251
|
}
|
|
252
252
|
});
|
|
253
253
|
}
|
|
@@ -295,7 +295,7 @@ export class Combat {
|
|
|
295
295
|
// bullet met the world: dirt/wood/stone all share the dust kick v1; the whine is rationed
|
|
296
296
|
_worldImpact(pr) {
|
|
297
297
|
this.game.vfx?.playFx?.('impact_dirt', { pos: pr.obj.position, scale: 1 });
|
|
298
|
-
if (Math.random() < 0.3) this.game.audio?.play('ricochet');
|
|
298
|
+
if (Math.random() < 0.3) this.game.audio?.play?.('ricochet');
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
// ONE BULLET-VS-BODY TEST, for every kind of body there is: the outlaws (combat.enemies), the
|
|
@@ -328,23 +328,23 @@ export class Combat {
|
|
|
328
328
|
if (hitY >= BODY.head) {
|
|
329
329
|
const at = e.position.clone().setY(e.position.y + 1.62);
|
|
330
330
|
e.takeDamage(e.maxHealth * 10, by.position, { knockback: 0.35, attacker: by });
|
|
331
|
-
this.game.ui?.showDamage(e, 'HEADSHOT');
|
|
331
|
+
this.game.ui?.showDamage?.(e, 'HEADSHOT');
|
|
332
332
|
this.game.vfx.hit(at);
|
|
333
333
|
this.game.vfx.blood(at, pr.dir.clone().setY(pr.dir.y + 0.5), 2.6); // it makes a mess
|
|
334
|
-
this.game.audio?.play('hit');
|
|
334
|
+
this.game.audio?.play?.('hit');
|
|
335
335
|
this.game.shakeT = Math.max(this.game.shakeT ?? 0, 0.12);
|
|
336
336
|
} else {
|
|
337
337
|
e.takeDamage(pr.damage, by.position, { knockback: 0.15, attacker: by });
|
|
338
|
-
this.game.ui?.showDamage(e, Math.round(pr.damage));
|
|
338
|
+
this.game.ui?.showDamage?.(e, Math.round(pr.damage));
|
|
339
339
|
this.game.vfx.hit(e.position.clone().setY(e.position.y + 1.1));
|
|
340
340
|
this._fleshImpact(pr, e);
|
|
341
|
-
this.game.audio?.play('hit');
|
|
341
|
+
this.game.audio?.play?.('hit');
|
|
342
342
|
}
|
|
343
343
|
// THE RETICLE GOES RED — but only for the man holding the gun. An outlaw's round finding a
|
|
344
344
|
// townsman is not YOUR hit and must not be reported as one; at 40m in bright desert the blood
|
|
345
345
|
// spray is four pixels and the mark is the only honest answer to "did I connect?".
|
|
346
346
|
if (by === this.game.player) {
|
|
347
|
-
this.game.ui?.hitMark(!e.alive);
|
|
347
|
+
this.game.ui?.hitMark?.(!e.alive);
|
|
348
348
|
// WHAT THE COUNTY KNOWS ABOUT YOU. Every man you put down is one the next one has heard about,
|
|
349
349
|
// and the men they send after that are better shots (enemy.js, AIM). It is the only progression
|
|
350
350
|
// this game keeps, and it is kept HERE because this is the one place a death by your hand is
|
|
@@ -470,7 +470,7 @@ export class Combat {
|
|
|
470
470
|
// must not: `noEvict` bodies lie in the dirt where they fell, resident for life, costing one
|
|
471
471
|
// distanceTo a frame past the cull. It is also better fiction — a cleared camp looks cleared.
|
|
472
472
|
if (e.dead && !e.noEvict) {
|
|
473
|
-
this.game.vatRoster?.release(e.vatProxy);
|
|
473
|
+
this.game.vatRoster?.release?.(e.vatProxy);
|
|
474
474
|
this.game.scene.remove(e.root);
|
|
475
475
|
e.spawner?.alive.splice(e.spawner.alive.indexOf(e), 1);
|
|
476
476
|
this.enemies.splice(i, 1);
|
|
@@ -555,10 +555,10 @@ export class Combat {
|
|
|
555
555
|
}
|
|
556
556
|
}
|
|
557
557
|
// bullets smash props they fly into (bottles on the saloon rail)
|
|
558
|
-
if (!dead && this.game.smashables?.tryPoint(pr.obj.position, 0.75, pr.dir)) dead = true;
|
|
558
|
+
if (!dead && this.game.smashables?.tryPoint?.(pr.obj.position, 0.75, pr.dir)) dead = true;
|
|
559
559
|
// THE COUNTY'S GAME. Deer and wolf are VAT herd instances, not entities — hunting.js owns
|
|
560
560
|
// the sphere test, the death take, and the carcass the round leaves behind.
|
|
561
|
-
if (!dead && this.game.hunting?.bulletHit(pr, _prevPos)) dead = true;
|
|
561
|
+
if (!dead && this.game.hunting?.bulletHit?.(pr, _prevPos)) dead = true;
|
|
562
562
|
} else if (!dead && p.alive && pr.obj.position.distanceTo(tmpV1.copy(p.position).setY(p.position.y + 1.2)) < 0.9) {
|
|
563
563
|
p.takeDamage(pr.damage, pr.shooter?.position ?? pr.obj.position);
|
|
564
564
|
this._fleshImpact(pr, p);
|
package/src/systems/crime.js
CHANGED
|
@@ -228,7 +228,7 @@ export class Crime {
|
|
|
228
228
|
if (lv === this.level) return;
|
|
229
229
|
this.level = lv;
|
|
230
230
|
if (lv > before) {
|
|
231
|
-
this.game.audio?.play('aggro');
|
|
231
|
+
this.game.audio?.play?.('aggro');
|
|
232
232
|
// the grace beat between "they know" and the first badge appearing — short at high stars
|
|
233
233
|
this._dispatchT = lv >= 3 ? 1.2 : 2.5;
|
|
234
234
|
const line = ['', 'A deputy is asking after you.', 'The sheriff has men on the street.',
|
|
@@ -567,13 +567,13 @@ export class Crime {
|
|
|
567
567
|
// 5. REPORTING (combat.js's job, not main.js's). combat.js must call, from the friendly-bullet /
|
|
568
568
|
// melee paths, when a CIVILIAN (townsfolk NPC or coach rider — anything with .civilian) is hit:
|
|
569
569
|
//
|
|
570
|
-
// this.game.crime?.report('assault', e.position); // hurt a civilian
|
|
571
|
-
// this.game.crime?.report('murder', e.position); // killed a civilian
|
|
572
|
-
// this.game.crime?.report('coachRobbery', e.position); // killed a coach driver/mate/fare
|
|
570
|
+
// this.game.crime?.report?.('assault', e.position); // hurt a civilian
|
|
571
|
+
// this.game.crime?.report?.('murder', e.position); // killed a civilian
|
|
572
|
+
// this.game.crime?.report?.('coachRobbery', e.position); // killed a coach driver/mate/fare
|
|
573
573
|
//
|
|
574
574
|
// and loot.js, when a corpse is robbed:
|
|
575
575
|
//
|
|
576
|
-
// game.crime?.report('robbery', corpse.position);
|
|
576
|
+
// game.crime?.report?.('robbery', corpse.position);
|
|
577
577
|
//
|
|
578
578
|
// Killing a LAWMAN needs NO call — Crime watches its own pool and reports it itself. (If
|
|
579
579
|
// combat.js reports it anyway, a 0.6s de-dup window in report() swallows the double.)
|
package/src/systems/deadeye.js
CHANGED
|
@@ -96,7 +96,7 @@ export class DeadEye {
|
|
|
96
96
|
if (!p.drawn && p._drawT <= 0) p._startDraw();
|
|
97
97
|
}
|
|
98
98
|
if (!this._canUse()) return false;
|
|
99
|
-
if ((p.deadEye ?? 0) < DEAD_EYE.minCharge) { this.game.audio?.play('dryfire'); return false; }
|
|
99
|
+
if ((p.deadEye ?? 0) < DEAD_EYE.minCharge) { this.game.audio?.play?.('dryfire'); return false; }
|
|
100
100
|
this.active = true;
|
|
101
101
|
this._tagT = 0;
|
|
102
102
|
this.tags.length = 0;
|
|
@@ -240,8 +240,8 @@ export class DeadEye {
|
|
|
240
240
|
const cap = Math.min(DEAD_EYE.maxTags, p.ammo ?? 0);
|
|
241
241
|
// A refused mark must SAY it was refused — a click that silently does nothing reads as a broken
|
|
242
242
|
// button, and you will keep clicking it. Out of chambers and out of meter are the two caps.
|
|
243
|
-
if (this.tags.length >= cap) { this.game.audio?.play('dryfire'); return; }
|
|
244
|
-
if ((p.deadEye ?? 0) < DEAD_EYE.tagCost) { this.game.audio?.play('dryfire'); return; }
|
|
243
|
+
if (this.tags.length >= cap) { this.game.audio?.play?.('dryfire'); return; }
|
|
244
|
+
if ((p.deadEye ?? 0) < DEAD_EYE.tagCost) { this.game.audio?.play?.('dryfire'); return; }
|
|
245
245
|
if (this._tagT > 0) return; // ...but a double-click inside 0.16s is just a hand
|
|
246
246
|
|
|
247
247
|
_o.copy(this.game.camera.position);
|
|
@@ -274,7 +274,7 @@ export class DeadEye {
|
|
|
274
274
|
});
|
|
275
275
|
p.deadEye = clamp(p.deadEye - DEAD_EYE.tagCost, 0, DEAD_EYE.meterMax);
|
|
276
276
|
this._tagT = DEAD_EYE.tagCooldown;
|
|
277
|
-
this.game.audio?.play('ui');
|
|
277
|
+
this.game.audio?.play?.('ui');
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
// Closest approach of ray (o,dir — dir normalized) to segment [a,b]. Returns the ray
|
|
@@ -118,6 +118,6 @@ export class Encounters {
|
|
|
118
118
|
gun.leashR = 40; // he will follow you a way for this — but not across the county
|
|
119
119
|
}
|
|
120
120
|
this.game.ui?.toast?.(`${grp.name} — they saw that.`, 3000);
|
|
121
|
-
this.game.audio?.play('aggro');
|
|
121
|
+
this.game.audio?.play?.('aggro');
|
|
122
122
|
}
|
|
123
123
|
}
|