sindicate 0.4.0 → 0.6.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/package.json +1 -1
- package/src/audio/ambience.js +65 -0
- package/src/audio/music.js +234 -0
- package/src/audio/synth.js +228 -0
- package/src/systems/casino/blackjack.js +187 -0
- package/src/systems/casino/cards3d.js +55 -0
- package/src/systems/casino/coins3d.js +75 -0
- package/src/systems/casino/table3d.js +130 -0
- package/src/systems/coach.js +1034 -0
- package/src/systems/combat.js +586 -0
- package/src/systems/crime.js +582 -0
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +442 -0
- package/src/systems/loot.js +303 -0
- package/src/systems/missions.js +418 -0
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +1619 -0
- package/src/systems/roadGraph.js +264 -0
- package/src/systems/satchel.js +500 -0
- package/src/systems/scenes.js +105 -0
- package/src/systems/shop.js +515 -0
- package/src/systems/train.js +1957 -0
- package/src/ui/cartograph.js +456 -0
- package/src/ui/deathScreen.js +130 -0
- package/src/ui/dialogue.js +434 -0
- package/src/ui/iconBaker.js +180 -0
- package/src/ui/intro.js +137 -0
- package/src/ui/menuHints.js +79 -0
- package/src/ui/pauseMenu.js +194 -0
- package/src/ui/saveLoadScreen.js +156 -0
- package/src/ui/titleScreen.js +359 -0
- package/src/ui/worldmap.js +837 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// missions.js — the mission ENGINE (v2). A mission is an ordered list of STAGES, each a first-class
|
|
2
|
+
// objective (talk · kill · goto · deliver · defend · escort) with a target, a description, a live
|
|
3
|
+
// counter, and optional onEnter/onComplete hooks. The engine runs ONE story spine at a time, advances
|
|
4
|
+
// stage by stage, drives the compass/map objective marker, and only pays out at the end. Definitions
|
|
5
|
+
// (data/missions.js) are immutable; PROGRESS is a small JSON blob persisted to localStorage (auto-save
|
|
6
|
+
// on every stage change) — the basis for save/load. See docs/PLAN_missions_v2.md.
|
|
7
|
+
//
|
|
8
|
+
// Wiring (main.js): registerNpc(npc) for every giver/target NPC, registerHook(id, fn) for the spawn
|
|
9
|
+
// hooks a mission's onEnter needs, then boot() once the world exists. The game feeds events in:
|
|
10
|
+
// enemyKilled(e) from enemy.die, talked(npcId) from a dialogue topic, and update(dt) each frame for
|
|
11
|
+
// the position-based types.
|
|
12
|
+
import * as THREE from 'three/webgpu';
|
|
13
|
+
// Campaign defs, save-key namespace and act title-cards are GAME data:
|
|
14
|
+
// configureMissions({ defs, saveKey, stageCards })
|
|
15
|
+
let MISSION_DEFS = [];
|
|
16
|
+
let KEY = 'sindicate_save_';
|
|
17
|
+
let STAGE_CARDS = {};
|
|
18
|
+
export function configureMissions({ defs = null, saveKey = null, stageCards = null } = {}) {
|
|
19
|
+
if (defs) MISSION_DEFS = defs;
|
|
20
|
+
if (saveKey) KEY = saveKey;
|
|
21
|
+
if (stageCards) STAGE_CARDS = stageCards;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// MULTI-SLOT SAVES. localStorage `dustwater_save_<slot>`: slot 'auto' is the running autosave (written
|
|
25
|
+
// silently on every stage change); numbered slots are the player's manual saves from the Save screen.
|
|
26
|
+
|
|
27
|
+
const AUTO = 'auto';
|
|
28
|
+
|
|
29
|
+
// Act-break cards (ui.stageCard) — shown when the spine crosses into a new stage-pack.
|
|
30
|
+
const keyOf = (slot) => KEY + slot;
|
|
31
|
+
|
|
32
|
+
// A small JPEG of the scene for the Load screen. WebGPU won't let drawImage read the canvas, so render
|
|
33
|
+
// the scene to a tiny offscreen target and read it back — the exact pattern tools/map.js uses for its
|
|
34
|
+
// thumbnails (sync render, setRenderTarget(null), buffer RETURNED, top-left origin so no flip). Reuses
|
|
35
|
+
// one target + canvas. Null (→ "no preview") if it can't. Called after a save, patching the slot.
|
|
36
|
+
async function captureThumb(game) {
|
|
37
|
+
try {
|
|
38
|
+
const { renderer, scene, camera } = game;
|
|
39
|
+
if (!renderer?.readRenderTargetPixelsAsync || !scene || !camera) return null;
|
|
40
|
+
const w = 256, h = 144;
|
|
41
|
+
const rt = (captureThumb._rt ||= new THREE.RenderTarget(w, h));
|
|
42
|
+
renderer.setRenderTarget(rt);
|
|
43
|
+
renderer.render(scene, camera);
|
|
44
|
+
renderer.setRenderTarget(null);
|
|
45
|
+
const buf = await renderer.readRenderTargetPixelsAsync(rt, 0, 0, w, h);
|
|
46
|
+
// the RT is LINEAR (no post grade), so it reads dark — lift it to sRGB (gamma 1/2.2) via a LUT so
|
|
47
|
+
// the thumbnail looks like the graded game, not a murky raw pass.
|
|
48
|
+
const lut = (captureThumb._lut ||= (() => { const t = new Uint8Array(256); for (let i = 0; i < 256; i++) t[i] = Math.round(255 * Math.pow(i / 255, 1 / 2.2)); return t; })());
|
|
49
|
+
for (let i = 0; i < buf.length; i += 4) { buf[i] = lut[buf[i]]; buf[i + 1] = lut[buf[i + 1]]; buf[i + 2] = lut[buf[i + 2]]; }
|
|
50
|
+
const c = (captureThumb._c ||= Object.assign(document.createElement('canvas'), { width: w, height: h }));
|
|
51
|
+
const ctx = c.getContext('2d'), img = ctx.createImageData(w, h);
|
|
52
|
+
img.data.set(buf); // top-left origin — no flip (per map.js)
|
|
53
|
+
ctx.putImageData(img, 0, 0);
|
|
54
|
+
return c.toDataURL('image/jpeg', 0.62);
|
|
55
|
+
} catch (e) { console.warn('[save] thumbnail failed', e?.message ?? e); return null; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class Missions {
|
|
59
|
+
constructor(game) {
|
|
60
|
+
this.game = game;
|
|
61
|
+
this.defs = MISSION_DEFS;
|
|
62
|
+
this.hooks = {}; // hook id -> fn (onEnter spawners, etc.)
|
|
63
|
+
this.npcs = {}; // def.id -> NPC (givers + talk targets)
|
|
64
|
+
this._stageEnemies = []; // the live hostiles a kill stage is tracking
|
|
65
|
+
this.progress = this._loadKey(keyOf(AUTO)) || this._loadKey(keyOf('1')) || this._fresh(); // resume the autosave (legacy slot 1 as fallback)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_fresh() { return { version: 1, money: null, active: MISSION_DEFS[0]?.id ?? null, missions: {}, flags: {}, stats: {} }; } // read at CALL time — a module-level capture predates registration
|
|
69
|
+
// `id` override: county NPCs carry GENERATED ids (`sheriff7`), so campaign givers found by NAME
|
|
70
|
+
// register under a stable alias the mission defs can target ('fisk', 'august', 'rand'…).
|
|
71
|
+
registerNpc(npc, id = null) { const k = id ?? npc?.def?.id; if (k && npc) this.npcs[k] = npc; }
|
|
72
|
+
registerHook(id, fn) { this.hooks[id] = fn; }
|
|
73
|
+
|
|
74
|
+
// STORY FLAGS — the campaign's persistent keys (badge worn, dynamite proven, house for sale…).
|
|
75
|
+
// Written by mission completion (def.flags) or by anything else (the house purchase), saved with
|
|
76
|
+
// the rest of progress.
|
|
77
|
+
flag(name) { return !!(this.progress.flags ??= {})[name]; }
|
|
78
|
+
setFlag(name, v = true) { (this.progress.flags ??= {})[name] = v; this.game._applyCrewState?.(); this._save(); }
|
|
79
|
+
|
|
80
|
+
// STORY STATS — the campaign's sliding scales (trust, reputation). Numbers, not booleans: every
|
|
81
|
+
// hard choice nudges them (topic `stat: { trust: -1 }`), and later stages read them through
|
|
82
|
+
// onlyIf/skipIf-style gates in dialogue (`statMin: { trust: 2 }` / `statMax`) or plain stat().
|
|
83
|
+
stat(name) { return (this.progress.stats ??= {})[name] ?? 0; }
|
|
84
|
+
addStat(name, d) { const s = (this.progress.stats ??= {}); s[name] = (s[name] ?? 0) + d; this.game._applyCrewState?.(); this._save(); }
|
|
85
|
+
|
|
86
|
+
// ── lookups into the active mission ──────────────────────────────────────────────────
|
|
87
|
+
get def() { return this.defs.find((m) => m.id === this.progress.active) || null; }
|
|
88
|
+
_rec() { const a = this.progress.active; return (this.progress.missions[a] ??= { stage: 0, done: false, counters: {} }); }
|
|
89
|
+
get stageIndex() { return this._rec().stage; }
|
|
90
|
+
get stage() { return this.def?.stages[this.stageIndex] || null; }
|
|
91
|
+
|
|
92
|
+
// Called once after the NPCs + world exist. Resumes the saved mission at its stage (silent) and
|
|
93
|
+
// restores the purse. (Position is NOT restored on boot — only via an explicit Load — so a dev
|
|
94
|
+
// reload doesn't yank the player around; a Continue from the title will call loadGame instead.)
|
|
95
|
+
boot() {
|
|
96
|
+
// MIGRATION: a save from before the campaign shipped finished m1 and persisted active: null —
|
|
97
|
+
// under the old defs there was nothing to unlock. Re-open the spine: first mission not yet done
|
|
98
|
+
// whose prerequisites all are (for that save, c1_dust).
|
|
99
|
+
if (!this.progress.active) {
|
|
100
|
+
const next = this.defs.find((m) => !this.progress.missions[m.id]?.done
|
|
101
|
+
&& (m.requires || []).every((r) => this.progress.missions[r]?.done));
|
|
102
|
+
if (next) this.progress.active = next.id;
|
|
103
|
+
}
|
|
104
|
+
if (!this.progress.active) return;
|
|
105
|
+
if (this.progress.money != null && this.game.loot) this.game.loot.money = this.progress.money;
|
|
106
|
+
this._enterStage(true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// In-session load (the SYSTEM tab's Load Game): restore progress + purse + position, then rebuild
|
|
110
|
+
// the current stage's world. NOTE: kill counters reset on load — the benched hostiles all respawn
|
|
111
|
+
// (per-enemy kill persistence is a later refinement).
|
|
112
|
+
loadGame(slot) {
|
|
113
|
+
const s = slot != null ? this._loadKey(keyOf(slot)) : this._mostRecent();
|
|
114
|
+
if (!s || !s.active) { this.game.ui?.toast?.('No saved game found.', 3000); return false; }
|
|
115
|
+
this.progress = { version: s.version, money: s.money, active: s.active, missions: s.missions || {}, flags: s.flags || {}, stats: s.stats || {}, player: s.player };
|
|
116
|
+
if (s.money != null && this.game.loot) this.game.loot.money = s.money;
|
|
117
|
+
if (s.playtime != null) this.game._playSeconds = s.playtime;
|
|
118
|
+
if (s.player && this.game.player) {
|
|
119
|
+
this.game.player.position.set(s.player.x, this.game.world.groundAt(s.player.x, s.player.z) + 0.5, s.player.z);
|
|
120
|
+
if (s.player.heading != null) this.game.player.heading = s.player.heading;
|
|
121
|
+
}
|
|
122
|
+
this._enterStage(true);
|
|
123
|
+
this.game.ui?.toast?.('Game loaded.', 2500);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
hasSave() { return this.listSaves().length > 0; } // any save FILE exists (auto or manual)
|
|
128
|
+
|
|
129
|
+
// ── stage lifecycle ──────────────────────────────────────────────────────────────────
|
|
130
|
+
_enterStage(resuming) {
|
|
131
|
+
const s = this.stage;
|
|
132
|
+
if (!s) return;
|
|
133
|
+
this.game._ensurePackBench?.(this.progress.active); // lazy-bench this pack's hostiles (+ the next's)
|
|
134
|
+
this._stageEnemies = [];
|
|
135
|
+
// a talk stage hands its own dialogue to the giver, so his lines match the stage. The NPC's
|
|
136
|
+
// ORIGINAL tree (usually undefined — treeFor falls back to NAMED/ROLES) is stashed on first
|
|
137
|
+
// wrap and restored when the mission completes, so a barkeep goes back to being a MERCHANT
|
|
138
|
+
// and not a man stuck forever on one campaign scene.
|
|
139
|
+
if (s.type === 'talk' && s.tree) {
|
|
140
|
+
const npc = this.npcs[s.target];
|
|
141
|
+
if (npc) {
|
|
142
|
+
if (!('_preTree' in npc.def)) npc.def._preTree = npc.def.tree;
|
|
143
|
+
npc.def.tree = this._wrapTree(s.tree, s.target);
|
|
144
|
+
((this._wrapped ??= {})[this.progress.active] ??= new Set()).add(s.target);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// FOLLOW / UNFOLLOW — a stage can put a registered NPC on the player's heel (escorts, walking a
|
|
148
|
+
// recruit home) and take him off it. Tracked per mission so complete() can never leave a man
|
|
149
|
+
// trailing the player for the rest of his life.
|
|
150
|
+
for (const key of [].concat(s.follow ?? [])) {
|
|
151
|
+
const n = this.npcs[key];
|
|
152
|
+
if (n) { n._follow = true; ((this._followers ??= {})[this.progress.active] ??= new Set()).add(key); }
|
|
153
|
+
}
|
|
154
|
+
for (const key of [].concat(s.unfollow ?? [])) { const n = this.npcs[key]; if (n) n._follow = false; }
|
|
155
|
+
// onEnter hook (spawn/teleport a set, open a door…). Runs on resume too — the hooks teleport
|
|
156
|
+
// already-benched entities, so they're idempotent, and a saved kill stage needs its world back.
|
|
157
|
+
if (s.onEnter) this.hooks[s.onEnter]?.(this);
|
|
158
|
+
this._setMarkers();
|
|
159
|
+
// A KILL STAGE WITH NOBODY LEFT TO KILL completes itself. Reachable via an in-session Load of a
|
|
160
|
+
// stale slot (the bench died later in this same session — the hook skips the dead), where the
|
|
161
|
+
// old behavior was worse than a stall: with zero matched hostiles, the NEXT kill of ANY stranger
|
|
162
|
+
// in the county advanced the stage. Skip it cleanly instead — UNLESS the tag's bench is simply
|
|
163
|
+
// still LOADING (pack-gated lazy spawn): then hold; _spawnPackBench re-enters when the men land.
|
|
164
|
+
if (s.type === 'kill' && this._stageEnemies.length === 0) {
|
|
165
|
+
if (this.game._benchPending?.(s.tag)) return;
|
|
166
|
+
this._save(); return this.advance();
|
|
167
|
+
}
|
|
168
|
+
// a stage can open on a SCENE (scenes.js — letterboxed camera beats; plays once per save)
|
|
169
|
+
if (s.scene) this.game.scenes?.play?.(s.scene);
|
|
170
|
+
if (!resuming) {
|
|
171
|
+
if (this.stageIndex === 0 && !this._quietTitle) this.game.ui?.questBanner?.(this.def.title?.toUpperCase?.() || 'NEW JOB');
|
|
172
|
+
if (s.desc) this.game.ui?.toast?.(s.desc, 6500);
|
|
173
|
+
}
|
|
174
|
+
this._save();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Transform a data tree's `complete: true` topic into one that advances this talk stage when picked.
|
|
178
|
+
// `targetId` is the stage's own target key, captured at wrap time — NOT npc.def.id: county NPCs are
|
|
179
|
+
// registered under a stable ALIAS ('fisk', 'august', 'rand') while their def.id is generated, so
|
|
180
|
+
// completing via def.id would never match the stage and the talk would never advance.
|
|
181
|
+
//
|
|
182
|
+
// BRANCHING lives in the topics (the campaign's choices are things you SAY):
|
|
183
|
+
// flag: 'name'|[...] picking this topic sets these story flags (spare/kill, bribe/refuse…)
|
|
184
|
+
// goto: 'stageId' a completing topic can route to a NAMED stage instead of the next one
|
|
185
|
+
// onlyIf / skipIf the topic only appears when the flags allow — earlier choices shape
|
|
186
|
+
// later conversations (evaluated at stage entry, when the tree is wrapped)
|
|
187
|
+
_wrapTree(tree, targetId) {
|
|
188
|
+
const topics = (tree.topics || [])
|
|
189
|
+
.filter((t) => this._condOk(t))
|
|
190
|
+
.map((t) => (t.complete || t.flag || t.stat)
|
|
191
|
+
? { ...t, action: (npc, dlg) => {
|
|
192
|
+
for (const f of [].concat(t.flag ?? [])) (this.progress.flags ??= {})[f] = true;
|
|
193
|
+
for (const [k, d] of Object.entries(t.stat ?? {})) { const s = (this.progress.stats ??= {}); s[k] = (s[k] ?? 0) + d; }
|
|
194
|
+
this.game._applyCrewState?.();
|
|
195
|
+
if (t.complete) dlg?.ui?.game?.missions?.talked(targetId ?? npc?.def?.id, t.goto);
|
|
196
|
+
} }
|
|
197
|
+
: t);
|
|
198
|
+
return { ...tree, topics };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Gate checks, shared by stages AND topics:
|
|
202
|
+
// onlyIf / skipIf — a flag name or list (onlyIf: all set; skipIf: none set)
|
|
203
|
+
// statMin / statMax — { statName: bound } thresholds (statMin: at least; statMax: at most)
|
|
204
|
+
_condOk(o) {
|
|
205
|
+
const f = this.progress.flags ?? {};
|
|
206
|
+
if (o.onlyIf && ![].concat(o.onlyIf).every((k) => f[k])) return false;
|
|
207
|
+
if (o.skipIf && [].concat(o.skipIf).some((k) => f[k])) return false;
|
|
208
|
+
for (const [k, v] of Object.entries(o.statMin ?? {})) if (this.stat(k) < v) return false;
|
|
209
|
+
for (const [k, v] of Object.entries(o.statMax ?? {})) if (this.stat(k) > v) return false;
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The active stage's target lights the gold objective marker (compass + map).
|
|
214
|
+
_setMarkers() {
|
|
215
|
+
const s = this.stage;
|
|
216
|
+
// clear old
|
|
217
|
+
for (const id in this.npcs) this.npcs[id].objective = false;
|
|
218
|
+
for (const e of this._stageEnemies) if (e) e.objective = false;
|
|
219
|
+
let marks = [];
|
|
220
|
+
if (!s) { this.game.questMarkers = []; return; }
|
|
221
|
+
if (s.type === 'talk' || s.type === 'deliver') {
|
|
222
|
+
const npc = this.npcs[s.target || s.to];
|
|
223
|
+
if (npc) { npc.objective = true; marks = [npc]; }
|
|
224
|
+
} else if (s.type === 'kill' || s.type === 'defend' || s.type === 'escort') {
|
|
225
|
+
this._stageEnemies = this._matchEnemies(s);
|
|
226
|
+
for (const e of this._stageEnemies) e.objective = true;
|
|
227
|
+
marks = this._stageEnemies.slice();
|
|
228
|
+
if (s.type === 'escort' && this.npcs[s.who]) marks.push(this.npcs[s.who]);
|
|
229
|
+
} else if (s.type === 'goto') {
|
|
230
|
+
marks = [{ position: { x: s.pos.x, y: 0, z: s.pos.z }, objective: true, alive: true, type: 'objective' }];
|
|
231
|
+
}
|
|
232
|
+
this.game.questMarkers = marks;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// the live hostiles this stage cares about, by explicit ids or a mission tag stamped at spawn
|
|
236
|
+
_matchEnemies(s) {
|
|
237
|
+
const en = this.game.combat?.enemies ?? [];
|
|
238
|
+
return en.filter((e) => e.alive && (
|
|
239
|
+
(s.targets && s.targets.includes(e.missionId)) || (s.tag && e.missionTag === s.tag)));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── the event bus the game feeds ─────────────────────────────────────────────────────
|
|
243
|
+
// a hostile died — advance a kill stage when its quota is met
|
|
244
|
+
enemyKilled(e) {
|
|
245
|
+
const s = this.stage;
|
|
246
|
+
if (!s || s.type !== 'kill') return;
|
|
247
|
+
const rec = this._rec();
|
|
248
|
+
const alive = this._matchEnemies(s).length;
|
|
249
|
+
const spawned = s.count ?? this._stageEnemies.length;
|
|
250
|
+
rec.counters[s.id] = Math.min(spawned, spawned - alive);
|
|
251
|
+
this._setMarkers();
|
|
252
|
+
this._save();
|
|
253
|
+
if (alive === 0 && (rec.counters[s.id] >= spawned || this._stageEnemies.length === 0)) this.advance();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// the player finished a giver's dialogue that completes the current talk stage. `gotoId` is the
|
|
257
|
+
// branching topic's routing — advance to that named stage instead of the next in order.
|
|
258
|
+
talked(npcId, gotoId = null) {
|
|
259
|
+
const s = this.stage;
|
|
260
|
+
if (s && s.type === 'talk' && s.target === npcId) this.advance(gotoId);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// per-frame — position-based completion (goto, escort arrival, defend timer), plus the two gates:
|
|
264
|
+
// timeLimit + onExpire — a stage that can be LOST forward: run out the clock and the mission
|
|
265
|
+
// routes to the named stage instead (the fuse burns down, the wagon rolls)
|
|
266
|
+
// hours: [from, to] — a goto that only completes inside the window (night jobs). Wrapping
|
|
267
|
+
// windows (21→5) welcome.
|
|
268
|
+
update(dt) {
|
|
269
|
+
const s = this.stage;
|
|
270
|
+
if (!s) return;
|
|
271
|
+
if (s.timeLimit && s.onExpire) {
|
|
272
|
+
const rec = this._rec();
|
|
273
|
+
rec.counters[`t_${s.id}`] = (rec.counters[`t_${s.id}`] ?? 0) + dt;
|
|
274
|
+
if (rec.counters[`t_${s.id}`] >= s.timeLimit) { this.game.ui?.toast?.(s.expireDesc ?? 'Too late.', 5000); return this.advance(s.onExpire); }
|
|
275
|
+
}
|
|
276
|
+
const p = this.game.player?.position;
|
|
277
|
+
if (s.type === 'goto' && p) {
|
|
278
|
+
if (s.hours) {
|
|
279
|
+
const h = this.game.world?.sky?.hour ?? 12, [a, b] = s.hours;
|
|
280
|
+
const inWin = a <= b ? (h >= a && h <= b) : (h >= a || h <= b);
|
|
281
|
+
if (!inWin) {
|
|
282
|
+
// standing at the mark at the wrong hour: say so, once — the man at the door at noon
|
|
283
|
+
// shouldn't have to guess that the job wants dark (rest at a fire passes the hours)
|
|
284
|
+
const r = s.radius ?? 6;
|
|
285
|
+
if ((p.x - s.pos.x) ** 2 + (p.z - s.pos.z) ** 2 <= (r + 8) ** 2)
|
|
286
|
+
this.game.hint?.(`hours_${s.id}`, `Not at this hour. This work keeps different ones — rest at a fire to pass the time.`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const r = s.radius ?? 6;
|
|
291
|
+
if ((p.x - s.pos.x) ** 2 + (p.z - s.pos.z) ** 2 <= r * r) this.advance();
|
|
292
|
+
} else if (s.type === 'defend' && s.seconds) {
|
|
293
|
+
const rec = this._rec();
|
|
294
|
+
rec.counters[s.id] = (rec.counters[s.id] ?? 0) + dt;
|
|
295
|
+
if (rec.counters[s.id] >= s.seconds) this.advance();
|
|
296
|
+
} else if (s.type === 'escort') {
|
|
297
|
+
const who = this.npcs[s.who];
|
|
298
|
+
if (who && ((who.position.x - s.to.x) ** 2 + (who.position.z - s.to.z) ** 2) <= (s.radius ?? 6) ** 2) this.advance();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// an item reached its destination (called from the delivery interaction when it lands)
|
|
303
|
+
delivered(item, toId) {
|
|
304
|
+
const s = this.stage;
|
|
305
|
+
if (s && s.type === 'deliver' && s.item === item && (s.to === toId || !s.to)) this.advance();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── advance / complete ───────────────────────────────────────────────────────────────
|
|
309
|
+
// `toId` (branching): jump to the named stage instead of the next in order. Either way, stages
|
|
310
|
+
// whose onlyIf/skipIf don't match this playthrough's flags are stepped over — that's how one
|
|
311
|
+
// mission carries both halves of a fork and each run only walks its own.
|
|
312
|
+
advance(toId = null) {
|
|
313
|
+
const rec = this._rec(), def = this.def, s = this.stage;
|
|
314
|
+
if (s?.onComplete) this.hooks[s.onComplete]?.(this);
|
|
315
|
+
let i = toId != null ? def.stages.findIndex((st) => st.id === toId) : rec.stage + 1;
|
|
316
|
+
if (i < 0) { console.warn('[missions] goto to unknown stage', toId); i = rec.stage + 1; }
|
|
317
|
+
while (i < def.stages.length && !this._condOk(def.stages[i])) i++;
|
|
318
|
+
if (i >= def.stages.length) { this.complete(); return; }
|
|
319
|
+
rec.stage = i;
|
|
320
|
+
this._enterStage(false);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
complete() {
|
|
324
|
+
const rec = this._rec(), def = this.def;
|
|
325
|
+
rec.done = true;
|
|
326
|
+
if (def.reward?.money && this.game.loot) this.game.loot.money = (this.game.loot.money ?? 0) + def.reward.money;
|
|
327
|
+
if (def.reward?.items?.length) this.game.loot?._giveItems?.(def.reward.items); // [{id,n}] into the satchel, toasted
|
|
328
|
+
for (const f of def.flags ?? []) (this.progress.flags ??= {})[f] = true; // story keys this mission proves
|
|
329
|
+
this.game.ui?.questBanner?.(def.doneBanner ?? 'THE JOB IS DONE');
|
|
330
|
+
if (def.reward?.money) this.game.ui?.toast?.(`$${def.reward.money} — the county pays its debts.`, 8000);
|
|
331
|
+
// Every NPC this mission wrapped goes back to who he was: the stashed original tree (usually
|
|
332
|
+
// undefined → treeFor's NAMED/ROLES fallback, which is how the barkeep gets his BAR back after
|
|
333
|
+
// c1 and August his ammunition counter after c4 — a wrapped tree left in place buried their
|
|
334
|
+
// merchant lines for the rest of the game). Toomey alone keeps a plain post-mission line: his
|
|
335
|
+
// role fallback is the anonymous drifter tree, which reads worse than one tired sentence.
|
|
336
|
+
for (const key of this._wrapped?.[def.id] ?? []) {
|
|
337
|
+
const n = this.npcs[key];
|
|
338
|
+
if (n && '_preTree' in n.def) { n.def.tree = n.def._preTree; delete n.def._preTree; }
|
|
339
|
+
}
|
|
340
|
+
delete this._wrapped?.[def.id];
|
|
341
|
+
// …and nobody keeps following the player out of a finished mission
|
|
342
|
+
for (const key of this._followers?.[def.id] ?? []) { const n = this.npcs[key]; if (n) n._follow = false; }
|
|
343
|
+
delete this._followers?.[def.id];
|
|
344
|
+
this.game._applyCrewState?.(); // completion flags may have recruited someone — seat them at the house
|
|
345
|
+
if (def.giver === 'deputy_toomey' && this.npcs.deputy_toomey) {
|
|
346
|
+
this.npcs.deputy_toomey.def.tree = { openers: [`Town's quieter than it was. There'll be more paper before long.`], topics: [] };
|
|
347
|
+
}
|
|
348
|
+
// unlock the next spine mission (one at a time): first unlock whose prereqs are all done
|
|
349
|
+
const next = (def.unlocks || []).map((id) => this.defs.find((m) => m.id === id))
|
|
350
|
+
.find((m) => m && (m.requires || []).every((r) => this.progress.missions[r]?.done));
|
|
351
|
+
this.progress.active = next ? next.id : null;
|
|
352
|
+
if (next) {
|
|
353
|
+
// crossing into a new STAGE-PACK gets the act-break card (letterbox + act title) on top of
|
|
354
|
+
// everything else — the campaign's between-stages cinematic beat.
|
|
355
|
+
const actBreak = next.id[0] !== def.id[0] && STAGE_CARDS[next.id[0]];
|
|
356
|
+
if (actBreak) setTimeout(() => this.game.ui?.stageCard?.(actBreak[0], actBreak[1]), 2600);
|
|
357
|
+
// enter the next mission with its TITLE BANNER held back — fired synchronously it lands in the
|
|
358
|
+
// same frame as the doneBanner above and stomps it (questBanner replaces immediately). The
|
|
359
|
+
// banner is wall-clock UI chrome, so a wall-clock delay is the right tool; the guard drops it
|
|
360
|
+
// if the player has already moved the story on by then.
|
|
361
|
+
this._rec(); this._quietTitle = true; this._enterStage(false); this._quietTitle = false;
|
|
362
|
+
const nd = this.def;
|
|
363
|
+
setTimeout(() => { if (this.def === nd) this.game.ui?.questBanner?.(nd.title?.toUpperCase?.() || 'NEW JOB'); }, actBreak ? 10200 : 4800);
|
|
364
|
+
} else this._setMarkers(); // no next — clear every objective marker
|
|
365
|
+
this._save();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── persistence (multi-slot) ──────────────────────────────────────────────────────────
|
|
369
|
+
snapshot() {
|
|
370
|
+
const p = this.game.player?.position;
|
|
371
|
+
return { version: this.progress.version, money: this.game.loot?.money ?? this.progress.money,
|
|
372
|
+
active: this.progress.active, missions: this.progress.missions, flags: this.progress.flags ?? {}, stats: this.progress.stats ?? {},
|
|
373
|
+
player: p ? { x: p.x, z: p.z, heading: this.game.player.heading } : this.progress.player,
|
|
374
|
+
// metadata for the Load screen: date, current job, playtime. (thumb is filled in async below.)
|
|
375
|
+
savedAt: Date.now(), title: this.def?.title ?? 'Dustwater', objective: this.stage?.desc ?? '',
|
|
376
|
+
playtime: this.game._playSeconds ?? 0 };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Write a save. slot 'auto' = the running autosave (silent); a number = a manual save (toasts). The
|
|
380
|
+
// thumbnail is a WebGPU readback (async), so the slot is written now with the PREVIOUS thumb (no
|
|
381
|
+
// flicker) and patched with the fresh one when the pixels land.
|
|
382
|
+
saveGame(slot = AUTO, name = null) {
|
|
383
|
+
const snap = this.snapshot();
|
|
384
|
+
snap.slot = slot; snap.auto = (slot === AUTO);
|
|
385
|
+
snap.name = name != null ? name : (slot === AUTO ? 'Autosave' : snap.title);
|
|
386
|
+
const prev = this._loadKey(keyOf(slot)); if (prev?.thumb) snap.thumb = prev.thumb;
|
|
387
|
+
try { localStorage.setItem(keyOf(slot), JSON.stringify(snap)); } catch (e) { /* private mode / quota */ }
|
|
388
|
+
this.game.ui?.saving?.(); // the SAVING… spinner (auto + manual)
|
|
389
|
+
if (slot !== AUTO) this.game.ui?.toast?.('Game saved.', 2500);
|
|
390
|
+
this._patchThumb(slot);
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
async _patchThumb(slot) {
|
|
394
|
+
const thumb = await captureThumb(this.game);
|
|
395
|
+
if (!thumb) return;
|
|
396
|
+
try { const raw = localStorage.getItem(keyOf(slot)); if (!raw) return; const s = JSON.parse(raw); s.thumb = thumb; localStorage.setItem(keyOf(slot), JSON.stringify(s)); } catch (e) {}
|
|
397
|
+
}
|
|
398
|
+
_save() { this.saveGame(AUTO); } // auto-persist on every stage change (silent)
|
|
399
|
+
|
|
400
|
+
// every save on disk, newest first, with its slot id — for the Save/Load screen.
|
|
401
|
+
listSaves() {
|
|
402
|
+
const out = [];
|
|
403
|
+
try {
|
|
404
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
405
|
+
const k = localStorage.key(i);
|
|
406
|
+
if (!k || !k.startsWith(KEY)) continue;
|
|
407
|
+
const s = JSON.parse(localStorage.getItem(k));
|
|
408
|
+
if (s && s.active !== undefined) out.push({ ...s, slot: s.slot ?? k.slice(KEY.length) });
|
|
409
|
+
}
|
|
410
|
+
} catch (e) { /* private mode / bad json */ }
|
|
411
|
+
return out.sort((a, b) => (b.savedAt ?? 0) - (a.savedAt ?? 0));
|
|
412
|
+
}
|
|
413
|
+
_mostRecent() { return this.listSaves()[0] || null; }
|
|
414
|
+
deleteSave(slot) { try { localStorage.removeItem(keyOf(slot)); } catch (e) {} }
|
|
415
|
+
_loadKey(k) { try { const s = localStorage.getItem(k); return s ? JSON.parse(s) : null; } catch (e) { return null; } }
|
|
416
|
+
|
|
417
|
+
reset() { this.deleteSave(AUTO); this.progress = this._fresh(); } // New Game: fresh autosave; manual saves are kept
|
|
418
|
+
}
|
package/src/systems/npc.js
CHANGED
|
@@ -69,7 +69,7 @@ export class NPC extends Character {
|
|
|
69
69
|
const talks = !!(def.wander || def.talk);
|
|
70
70
|
const extra = [def.idle, ...(def.idles ?? [])].filter(Boolean);
|
|
71
71
|
const names = [...new Set([
|
|
72
|
-
...
|
|
72
|
+
...NC.villagerClips,
|
|
73
73
|
...extra,
|
|
74
74
|
...(talks ? NC.convClips : []),
|
|
75
75
|
// a GUNHAND (the ride-along crew) carries the fighting clips: the aim/fire pair for the
|