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,500 @@
|
|
|
1
|
+
// THE SATCHEL (I) — what a man carries, and the screen he rummages in.
|
|
2
|
+
//
|
|
3
|
+
// Two things live in this one file, on purpose:
|
|
4
|
+
//
|
|
5
|
+
// 1. THE STATE — player.satchel = [{ id, n }]. A flat list of STACKS: each entry holds up to
|
|
6
|
+
// SC.items[id].stack of one good (data/items.js: "how many ride in one satchel slot"), so 25 strips
|
|
7
|
+
// of jerky at a stack of 10 ride as three slots — 10, 10, 5 — and the grid has something to show.
|
|
8
|
+
// add/remove/has/count/weight are the only writers of player.satchel; loot.js drops THINGS through
|
|
9
|
+
// add(), the shop buys and sells through add()/remove(), and money stays where it already lives
|
|
10
|
+
// (loot.js — we read game.loot.money, we do not keep a second purse here).
|
|
11
|
+
//
|
|
12
|
+
// 2. THE PANEL — the self-contained module from the contract (docs/MODULE_CONTRACT.md): it injects its
|
|
13
|
+
// own <style> and its own hidden #satchel once on the loading screen, and only ever writes its own
|
|
14
|
+
// nodes. It PAUSES the world the way the map does (ui.satchelOpen joins main.js's pause OR) and, so
|
|
15
|
+
// the frozen world doesn't leave the player free to walk off while you read WASD as grid keys, it
|
|
16
|
+
// raises ui.cinematic — the flag player.js already reads to freeze look + move (saved and restored
|
|
17
|
+
// so it never steps on the dialogue camera's use of the same flag).
|
|
18
|
+
//
|
|
19
|
+
// CARRY WEIGHT is a GENEROUS SOFT CAP that GROWS WITH THE CHARACTER — the settled call. add() NEVER
|
|
20
|
+
// blocks a pickup on weight; the footer just goes amber and nags. The cap is not a constant: it is
|
|
21
|
+
// CAP_BASE + level·CAP_PER_LEVEL, read off player.level. There is no XP/level system in the game yet,
|
|
22
|
+
// so player.level is a stub pinned at 1 (player.js) — but the capacity already follows it, so when the
|
|
23
|
+
// level system lands it raises the bag by moving one number and nothing here changes. The heaviest
|
|
24
|
+
// single things a man lifts are the stock saddle and a hide at ~20 lb; the base of 150 lets a level-1
|
|
25
|
+
// man haul a full larder, a medicine kit and two or three hides before it complains.
|
|
26
|
+
const CAP_BASE = 150; // lb at level 1
|
|
27
|
+
const CAP_PER_LEVEL = 20; // ...and this much more for every level after
|
|
28
|
+
//
|
|
29
|
+
// Shop reuses this file's slot renderer (makeSlot) so the trade view is this grid with a second column,
|
|
30
|
+
// not a second grid — see the contract. Icons are ui.itemIcons (iconBaker.js): a picture of the real
|
|
31
|
+
// prop, baked to an atlas on the loading screen, painted as a CSS background. Free at runtime.
|
|
32
|
+
// the goods catalogue + panel art are GAME data:
|
|
33
|
+
// setSatchelContent({ items, getItem, itemsIn, categories, panelArt })
|
|
34
|
+
let SC = { items: {}, getItem: () => null, itemsIn: () => [], categories: ['misc'], panelArt: '/assets/ui/panels' };
|
|
35
|
+
export function setSatchelContent(c) { SC = { ...SC, ...c }; }
|
|
36
|
+
|
|
37
|
+
// ── geometry (all CSS px — resolution-independent; see the arithmetic block at the foot of this file) ──
|
|
38
|
+
const SLOT = 120; // slot plate, px — MATCHES THE SHOP (Nick): big, legible boxes and icons, not the
|
|
39
|
+
// old 72px postage stamps. makeSlot scales the icon to the plate, so bigger = bigger.
|
|
40
|
+
const GAP = 14; // grid + tab gutter
|
|
41
|
+
const MIN_ROWS = 4; // pad the grid to at least this many rows so it reads as a bag with room (fewer rows
|
|
42
|
+
// now the plates are bigger; the grid scrolls past this).
|
|
43
|
+
const COLS = 4; // 4 columns at 120px: 4*120 + 3*14 = 522px of grid, centred in the ~664px main
|
|
44
|
+
// column — roomy, never overflows sideways. (Was 6×72.)
|
|
45
|
+
const ICON_INSET = 9; // icon box = 120 − 2*9 = 102px — the 2D item art fills it crisply.
|
|
46
|
+
|
|
47
|
+
const BRASS = 'rgba(229,214,75,1)'; // the selected/hover hue the HUD survey pulled off the Apocalypse pack
|
|
48
|
+
|
|
49
|
+
// Tab captions — 'ammunition' is trimmed so six tabs sit comfortably across ~664px without wrapping.
|
|
50
|
+
const TAB_LABEL = {
|
|
51
|
+
provisions: 'Provisions', medicine: 'Medicine', ammunition: 'Ammo',
|
|
52
|
+
valuables: 'Valuables', materials: 'Materials', kit: 'Kit',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export class Satchel {
|
|
56
|
+
constructor(ui) {
|
|
57
|
+
this.ui = ui;
|
|
58
|
+
this.game = ui.game;
|
|
59
|
+
this._cat = SC.categories[0]; // active tab
|
|
60
|
+
this._sel = 0; // grid cursor within the active tab
|
|
61
|
+
this._stacks = []; // the active tab's stacks, cached each rebuild (nav reads it)
|
|
62
|
+
this._justOpened = false; // eat the frame the panel appears on (I opened it; don't act yet)
|
|
63
|
+
this._prevCinematic = false; // restore, never clobber, the dialogue camera's flag
|
|
64
|
+
this._injectCSS();
|
|
65
|
+
this._buildDOM();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── STATE API ────────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
// Every method below is the public contract the loot seam, the shop and the integrator call.
|
|
70
|
+
// player.satchel is created lazily so the module can be constructed before the player exists.
|
|
71
|
+
|
|
72
|
+
_bag() {
|
|
73
|
+
const p = this.game.player;
|
|
74
|
+
if (!p) return null;
|
|
75
|
+
return (p.satchel ??= []);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Add n of an id, splitting across slots at the item's stack limit. Returns the amount actually
|
|
79
|
+
// added (0 for an unknown id). NEVER blocks on carry weight — that is the settled soft-cap call;
|
|
80
|
+
// an overload only paints the footer amber. This is the loot seam's entry (loot.js adds THINGS here)
|
|
81
|
+
// and the shop's buy.
|
|
82
|
+
add(id, n = 1) {
|
|
83
|
+
const item = SC.getItem(id);
|
|
84
|
+
const bag = this._bag();
|
|
85
|
+
if (!item || !bag || n <= 0) return 0;
|
|
86
|
+
let left = Math.floor(n);
|
|
87
|
+
const cap = item.stack || 1;
|
|
88
|
+
// top up existing non-full stacks of this id first, then open new slots
|
|
89
|
+
for (const s of bag) {
|
|
90
|
+
if (left <= 0) break;
|
|
91
|
+
if (s.id !== id || s.n >= cap) continue;
|
|
92
|
+
const room = cap - s.n;
|
|
93
|
+
const take = Math.min(room, left);
|
|
94
|
+
s.n += take; left -= take;
|
|
95
|
+
}
|
|
96
|
+
while (left > 0) {
|
|
97
|
+
const take = Math.min(cap, left);
|
|
98
|
+
bag.push({ id, n: take }); left -= take;
|
|
99
|
+
}
|
|
100
|
+
if (this.isOpen()) this._rebuild(); // a pickup mid-open should show up live
|
|
101
|
+
return Math.floor(n) - left; // == n unless something went wrong (left is 0 here)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Remove n of an id across its stacks, dropping any emptied slot. Returns the amount removed
|
|
105
|
+
// (≤ what he holds). The shop's sell and the panel's Drop both go through here.
|
|
106
|
+
remove(id, n = 1) {
|
|
107
|
+
const bag = this._bag();
|
|
108
|
+
if (!bag || n <= 0) return 0;
|
|
109
|
+
let left = Math.floor(n);
|
|
110
|
+
for (let i = bag.length - 1; i >= 0 && left > 0; i--) {
|
|
111
|
+
if (bag[i].id !== id) continue;
|
|
112
|
+
const take = Math.min(bag[i].n, left);
|
|
113
|
+
bag[i].n -= take; left -= take;
|
|
114
|
+
if (bag[i].n <= 0) bag.splice(i, 1);
|
|
115
|
+
}
|
|
116
|
+
if (this.isOpen()) this._rebuild();
|
|
117
|
+
return Math.floor(n) - left;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
count(id) {
|
|
121
|
+
const bag = this._bag();
|
|
122
|
+
if (!bag) return 0;
|
|
123
|
+
let t = 0;
|
|
124
|
+
for (const s of bag) if (s.id === id) t += s.n;
|
|
125
|
+
return t;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
has(id, n = 1) { return this.count(id) >= n; }
|
|
129
|
+
|
|
130
|
+
// Total pounds carried — weight × count, summed. Read by the footer and overCap().
|
|
131
|
+
weight() {
|
|
132
|
+
const bag = this._bag();
|
|
133
|
+
if (!bag) return 0;
|
|
134
|
+
let w = 0;
|
|
135
|
+
for (const s of bag) w += (SC.items[s.id]?.weight || 0) * s.n;
|
|
136
|
+
return w;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get carryCap() { return CAP_BASE + ((this.game.player?.level ?? 1) - 1) * CAP_PER_LEVEL; }
|
|
140
|
+
overCap() { return this.weight() > this.carryCap; }
|
|
141
|
+
|
|
142
|
+
// Consume one of an id: run items.js's use(player, game). A STRING means it did something → spend
|
|
143
|
+
// one and hand back the flavour line. NULL means it was wasted (full health / full Dead Eye) → keep
|
|
144
|
+
// it, return null. Consumables only; a valuable/kit carries use:null and always returns null.
|
|
145
|
+
use(id) {
|
|
146
|
+
const item = SC.getItem(id);
|
|
147
|
+
if (!item || typeof item.use !== 'function') return null;
|
|
148
|
+
if (!this.has(id, 1)) return null;
|
|
149
|
+
const line = item.use(this.game.player, this.game);
|
|
150
|
+
if (line != null) this.remove(id, 1);
|
|
151
|
+
return line;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── PANEL: the contract API (open / close / isOpen / handleKey) ───────────────────────────────
|
|
155
|
+
|
|
156
|
+
isOpen() { return !!this.ui.satchelOpen; }
|
|
157
|
+
|
|
158
|
+
open() {
|
|
159
|
+
// save cinematic ONLY on a real open, never on an idempotent re-open — else a second open()
|
|
160
|
+
// would capture our own `true` and strand the flag on when we close.
|
|
161
|
+
if (!this.isOpen()) this._prevCinematic = this.ui.cinematic;
|
|
162
|
+
this.ui.satchelOpen = true; // joins main.js's pause OR — the world holds
|
|
163
|
+
this.ui.cinematic = true; // freeze the player: WASD is now grid nav, not walking
|
|
164
|
+
this.game.input?.releaseLock?.(); // free the cursor for the pointer-events:auto panel
|
|
165
|
+
this._justOpened = true;
|
|
166
|
+
if (!SC.categories.includes(this._cat)) this._cat = SC.categories[0];
|
|
167
|
+
this._rebuild();
|
|
168
|
+
this.root.style.display = 'flex';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
close() {
|
|
172
|
+
if (!this.isOpen()) { this.root.style.display = 'none'; return; }
|
|
173
|
+
this.ui.satchelOpen = false;
|
|
174
|
+
this.ui.cinematic = this._prevCinematic; // hand the flag back exactly as we found it
|
|
175
|
+
this.root.style.display = 'none';
|
|
176
|
+
// do NOT re-request the lock — the next canvas click re-acquires it and input.js drops that click
|
|
177
|
+
// so it never registers as a shot (same contract the map and dialogue follow).
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Called once per frame by the integrator while the satchel is the active panel. Mouse works without
|
|
181
|
+
// it (native :hover + onclick); this is the keyboard/gamepad layer, per the contract's five rules.
|
|
182
|
+
handleKey(input) {
|
|
183
|
+
if (!this.isOpen()) return;
|
|
184
|
+
// pollGamepad(anyPanelOpen) CLEARS the synthesized keys while a panel is open, so — like the shop — the
|
|
185
|
+
// satchel must read the pad RAW itself (the old "left stick → WASD / button → E" comments never fired).
|
|
186
|
+
// Read it every frame so edges are detected and _pd stays primed against the button that OPENED us.
|
|
187
|
+
const pad = this._padEdge();
|
|
188
|
+
// rule 4: never act on the frame the panel appears — that key edge belongs to whatever opened it.
|
|
189
|
+
if (this._justOpened) { this._justOpened = false; return; }
|
|
190
|
+
if (input.hit('Escape') || pad.circle) { input.consume('Escape'); this.close(); return; }
|
|
191
|
+
|
|
192
|
+
// category switch — Q/E on the keyboard, L1/R1 on the pad. CONSUME the keys: the satchel can be opened
|
|
193
|
+
// from horseback and main.js's mounted branch reads a bare hit('KeyE') to DISMOUNT (not gated by a panel),
|
|
194
|
+
// so tabbing a category with E would otherwise throw you off your horse behind the paused panel.
|
|
195
|
+
if (input.hit('KeyQ') || pad.l1) { input.consume('KeyQ'); this._cycleCat(-1); }
|
|
196
|
+
if (input.hit('KeyE') || pad.r1) { input.consume('KeyE'); this._cycleCat(1); }
|
|
197
|
+
|
|
198
|
+
// 2-D grid move — arrows / WASD / D-pad / left stick. Clamped, no wrap.
|
|
199
|
+
const len = this._stacks.length;
|
|
200
|
+
if (len) {
|
|
201
|
+
const before = this._sel;
|
|
202
|
+
const col = this._sel % COLS;
|
|
203
|
+
if ((input.hit('ArrowRight') || input.hit('KeyD') || pad.right) && col < COLS - 1 && this._sel + 1 < len) this._sel++;
|
|
204
|
+
if ((input.hit('ArrowLeft') || input.hit('KeyA') || pad.left) && col > 0) this._sel--;
|
|
205
|
+
if ((input.hit('ArrowDown') || input.hit('KeyS') || pad.down) && this._sel + COLS < len) this._sel += COLS;
|
|
206
|
+
if ((input.hit('ArrowUp') || input.hit('KeyW') || pad.up) && this._sel - COLS >= 0) this._sel -= COLS;
|
|
207
|
+
if (this._sel !== before) { this._syncSel(); this.game.audio?.play?.('ui'); }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// actions — Enter/Space/Cross use, X/Square drop
|
|
211
|
+
if (input.hit('Enter') || input.hit('Space') || pad.cross) { input.consume('Enter'); input.consume('Space'); this._doUse(); }
|
|
212
|
+
if (input.hit('KeyX') || pad.square) { input.consume('KeyX'); this._doDrop(); }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// one connected pad, edge-detected against the previous frame (this._pd) so each press acts ONCE.
|
|
216
|
+
// D-pad / left stick → up/down/left/right; Cross(0) use · Circle(1) close · L1(4)/R1(5) tab · Square(2)
|
|
217
|
+
// drop. Returns {} when no pad is present, so keyboard-only play is untouched. (Mirrors shop.js.)
|
|
218
|
+
_padEdge() {
|
|
219
|
+
const pads = typeof navigator !== 'undefined' && navigator.getGamepads ? navigator.getGamepads() : null;
|
|
220
|
+
let gp = null; if (pads) for (const q of pads) if (q && q.connected) { gp = q; break; }
|
|
221
|
+
if (!gp) { this._pd = null; return {}; }
|
|
222
|
+
const b = (i) => !!gp.buttons[i]?.pressed, ax = (i) => gp.axes[i] || 0;
|
|
223
|
+
const st = { cross: b(0), circle: b(1), square: b(2), l1: b(4), r1: b(5),
|
|
224
|
+
up: b(12) || ax(1) < -0.55, down: b(13) || ax(1) > 0.55, left: b(14) || ax(0) < -0.55, right: b(15) || ax(0) > 0.55 };
|
|
225
|
+
const pv = this._pd || {};
|
|
226
|
+
const edge = {}; for (const k in st) edge[k] = st[k] && !pv[k];
|
|
227
|
+
this._pd = st;
|
|
228
|
+
return edge;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
_cycleCat(dir) {
|
|
232
|
+
const i = SC.categories.indexOf(this._cat);
|
|
233
|
+
this._cat = SC.categories[(i + dir + SC.categories.length) % SC.categories.length];
|
|
234
|
+
this._sel = 0;
|
|
235
|
+
this.game.audio?.play?.('ui');
|
|
236
|
+
this._rebuild();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── rendering ─────────────────────────────────────────────────────────────────────────────────
|
|
240
|
+
// Event-driven, not per-frame: the world is paused while the satchel is open, so we rebuild only on
|
|
241
|
+
// open / tab / pickup / use / drop. A full rebuild of ≤ a dozen slots is nothing.
|
|
242
|
+
|
|
243
|
+
_rebuild() {
|
|
244
|
+
this._stacks = this._bag() ? this._bag().filter((s) => SC.items[s.id]?.category === this._cat) : [];
|
|
245
|
+
if (this._sel >= this._stacks.length) this._sel = Math.max(0, this._stacks.length - 1);
|
|
246
|
+
|
|
247
|
+
// tabs
|
|
248
|
+
for (const b of this._tabs) b.classList.toggle('active', b.dataset.cat === this._cat);
|
|
249
|
+
|
|
250
|
+
// THE GRID IS A LATTICE, NOT A HANDFUL OF FLOATING BOXES. Two items used to draw as two lone
|
|
251
|
+
// plates in a huge void; a satchel reads as a bag when you can SEE the empty slots waiting to be
|
|
252
|
+
// filled, the way the reference does. So the filled slots come first, then blank plates pad the
|
|
253
|
+
// grid out to a full rectangle — at least MIN_ROWS deep, and always a whole number of rows so the
|
|
254
|
+
// right edge lines up. (An empty category shows one row of blanks and the hint below, not a wall
|
|
255
|
+
// of nothing.)
|
|
256
|
+
this.grid.textContent = '';
|
|
257
|
+
this._stacks.forEach((s, i) => {
|
|
258
|
+
const slot = this.makeSlot(s.id, s.n);
|
|
259
|
+
slot.dataset.i = i;
|
|
260
|
+
slot.onclick = () => { this._sel = i; this._syncSel(); };
|
|
261
|
+
this.grid.appendChild(slot);
|
|
262
|
+
});
|
|
263
|
+
const rowsNeeded = Math.max(MIN_ROWS, Math.ceil(this._stacks.length / COLS));
|
|
264
|
+
for (let i = this._stacks.length; i < rowsNeeded * COLS; i++) {
|
|
265
|
+
const blank = document.createElement('div');
|
|
266
|
+
blank.className = 'satchel-slot satchel-slot-empty';
|
|
267
|
+
this.grid.appendChild(blank);
|
|
268
|
+
}
|
|
269
|
+
if (!this._stacks.length) {
|
|
270
|
+
const empty = document.createElement('div');
|
|
271
|
+
empty.className = 'satchel-empty';
|
|
272
|
+
empty.textContent = 'Nothing here.';
|
|
273
|
+
this.grid.appendChild(empty);
|
|
274
|
+
}
|
|
275
|
+
this._syncSel();
|
|
276
|
+
this._syncFooter();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Build one slot: the rust plate, the baked icon, and a count badge when the stack is > 1. PUBLIC so
|
|
280
|
+
// shop.js paints its stock and the player's side from the same maker — one grid, two callers.
|
|
281
|
+
makeSlot(id, n, size = SLOT) {
|
|
282
|
+
const slot = document.createElement('div');
|
|
283
|
+
slot.className = 'satchel-slot';
|
|
284
|
+
if (size !== SLOT) { slot.style.width = slot.style.height = `${size}px`; }
|
|
285
|
+
|
|
286
|
+
const ic = document.createElement('i');
|
|
287
|
+
ic.className = 'satchel-ic';
|
|
288
|
+
const css = this.ui.itemIcons?.iconCss?.(id, size - ICON_INSET * 2) || '';
|
|
289
|
+
if (css) ic.style.cssText = css;
|
|
290
|
+
slot.appendChild(ic);
|
|
291
|
+
|
|
292
|
+
if (n > 1) {
|
|
293
|
+
const badge = document.createElement('span');
|
|
294
|
+
badge.className = 'satchel-count';
|
|
295
|
+
badge.textContent = n;
|
|
296
|
+
slot.appendChild(badge);
|
|
297
|
+
}
|
|
298
|
+
return slot;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
_syncSel() {
|
|
302
|
+
const slots = [...this.grid.querySelectorAll('.satchel-slot')];
|
|
303
|
+
slots.forEach((el, i) => el.classList.toggle('sel', i === this._sel));
|
|
304
|
+
this._fillDetail(this._stacks[this._sel] || null);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
_fillDetail(stack) {
|
|
308
|
+
const d = this.detail;
|
|
309
|
+
if (!stack) {
|
|
310
|
+
d.innerHTML = '<div class="satchel-hint">Select an item.</div>';
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const item = SC.items[stack.id];
|
|
314
|
+
const have = this.count(stack.id);
|
|
315
|
+
const useLabel = this._useLabel(item);
|
|
316
|
+
const iconCss = this.ui.itemIcons?.iconCss?.(stack.id, 96) || '';
|
|
317
|
+
d.innerHTML = `
|
|
318
|
+
<i class="satchel-dicon" style="${iconCss}"></i>
|
|
319
|
+
<div class="satchel-dname">${item.name}</div>
|
|
320
|
+
<div class="satchel-dcat">${item.category}</div>
|
|
321
|
+
<div class="satchel-dblurb">${item.blurb}</div>
|
|
322
|
+
<div class="satchel-dstats">
|
|
323
|
+
<span>Worth <b>$${item.value}</b></span>
|
|
324
|
+
<span>${item.weight} lb ea</span>
|
|
325
|
+
<span>Carrying <b>${have}</b></span>
|
|
326
|
+
</div>
|
|
327
|
+
<div class="satchel-acts">
|
|
328
|
+
${useLabel ? `<button class="satchel-btn" data-act="use">${useLabel}</button>` : ''}
|
|
329
|
+
<button class="satchel-btn" data-act="drop">Drop</button>
|
|
330
|
+
</div>`;
|
|
331
|
+
const useB = d.querySelector('[data-act="use"]');
|
|
332
|
+
const dropB = d.querySelector('[data-act="drop"]');
|
|
333
|
+
if (useB) useB.onclick = () => this._doUse();
|
|
334
|
+
if (dropB) dropB.onclick = () => this._doDrop();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// What the primary button says. Provisions you drink or eat; medicine you use; anything with
|
|
338
|
+
// use:null (valuables / materials / kit) has no primary button — only Drop.
|
|
339
|
+
_useLabel(item) {
|
|
340
|
+
if (typeof item.use !== 'function') return null;
|
|
341
|
+
if (item.category === 'provisions') {
|
|
342
|
+
return (item.id === 'whiskey' || item.id === 'moonshine' || item.id === 'coffee') ? 'Drink' : 'Eat';
|
|
343
|
+
}
|
|
344
|
+
return 'Use';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
_doUse() {
|
|
348
|
+
const stack = this._stacks[this._sel];
|
|
349
|
+
if (!stack) return;
|
|
350
|
+
const item = SC.items[stack.id];
|
|
351
|
+
if (typeof item.use !== 'function') { this.game.audio?.play?.('ui'); return; }
|
|
352
|
+
const line = this.use(stack.id); // rebuilds if it spent one
|
|
353
|
+
if (line) { this.ui.toast?.(line); this.game.audio?.play?.('coin'); }
|
|
354
|
+
else this.ui.toast?.('No need for that just now.');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
_doDrop() {
|
|
358
|
+
const stack = this._stacks[this._sel];
|
|
359
|
+
if (!stack) return;
|
|
360
|
+
const name = SC.items[stack.id].name;
|
|
361
|
+
// Dropped goods are discarded, not re-spawned (scope: the world stays as it is). The loot pool
|
|
362
|
+
// is money-only; a thrown-away tin is just gone.
|
|
363
|
+
this.remove(stack.id, 1);
|
|
364
|
+
this.ui.toast?.(`Dropped ${name}.`);
|
|
365
|
+
this.game.audio?.play?.('ui');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
_syncFooter() {
|
|
369
|
+
const w = Math.round(this.weight());
|
|
370
|
+
const cap = this.carryCap;
|
|
371
|
+
const over = w > cap;
|
|
372
|
+
this._wEl.textContent = `${w} / ${cap} lb`;
|
|
373
|
+
this._wEl.classList.toggle('warn', over);
|
|
374
|
+
this._wWarn.style.display = over ? 'inline' : 'none';
|
|
375
|
+
this._mEl.textContent = `$${this.game.loot?.money ?? 0}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── DOM + CSS (built once, on the loading screen) ─────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
_buildDOM() {
|
|
381
|
+
const root = document.createElement('div');
|
|
382
|
+
root.id = 'satchel';
|
|
383
|
+
root.innerHTML = `
|
|
384
|
+
<div class="satchel-window">
|
|
385
|
+
<div class="satchel-title">SATCHEL</div>
|
|
386
|
+
<div class="satchel-body">
|
|
387
|
+
<div class="satchel-detail"></div>
|
|
388
|
+
<div class="satchel-main">
|
|
389
|
+
<div class="satchel-tabs"></div>
|
|
390
|
+
<div class="satchel-rule"></div>
|
|
391
|
+
<div class="satchel-grid"></div>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
<div class="satchel-rule"></div>
|
|
395
|
+
<div class="satchel-footer">
|
|
396
|
+
<div class="satchel-weight"><span class="satchel-wlbl">Weight</span> <span class="satchel-wval"></span><span class="satchel-wwarn"> — overloaded</span></div>
|
|
397
|
+
<div class="satchel-money"><span class="satchel-mlbl">Money</span> <span class="satchel-mval"></span></div>
|
|
398
|
+
</div>
|
|
399
|
+
</div>`;
|
|
400
|
+
document.body.appendChild(root);
|
|
401
|
+
this.root = root;
|
|
402
|
+
this.detail = root.querySelector('.satchel-detail');
|
|
403
|
+
this.grid = root.querySelector('.satchel-grid');
|
|
404
|
+
this._wEl = root.querySelector('.satchel-wval');
|
|
405
|
+
this._wWarn = root.querySelector('.satchel-wwarn');
|
|
406
|
+
this._mEl = root.querySelector('.satchel-mval');
|
|
407
|
+
|
|
408
|
+
// tabs, once
|
|
409
|
+
const tabsEl = root.querySelector('.satchel-tabs');
|
|
410
|
+
this._tabs = SC.categories.map((cat) => {
|
|
411
|
+
const b = document.createElement('button');
|
|
412
|
+
b.className = 'satchel-tab';
|
|
413
|
+
b.dataset.cat = cat;
|
|
414
|
+
b.textContent = TAB_LABEL[cat] || cat;
|
|
415
|
+
b.onclick = () => { if (this._cat !== cat) { this._cat = cat; this._sel = 0; this._rebuild(); this.game.audio?.play?.('ui'); } };
|
|
416
|
+
tabsEl.appendChild(b);
|
|
417
|
+
return b;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
_injectCSS() {
|
|
422
|
+
if (document.getElementById('satchel-css')) return;
|
|
423
|
+
const css = document.createElement('style');
|
|
424
|
+
css.id = 'satchel-css';
|
|
425
|
+
css.textContent = `
|
|
426
|
+
#satchel { position:fixed; inset:0; z-index:62; display:none; align-items:center; justify-content:center;
|
|
427
|
+
pointer-events:auto; user-select:none;
|
|
428
|
+
background:radial-gradient(circle at 50% 44%, rgba(24,17,10,0.86), rgba(6,4,3,0.96));
|
|
429
|
+
font-family:Copperplate,'Copperplate Gothic Bold',Palatino,'Book Antiqua',Georgia,serif; color:#e8d9b0; }
|
|
430
|
+
|
|
431
|
+
#satchel .satchel-window { display:flex; flex-direction:column; box-sizing:border-box;
|
|
432
|
+
width:min(1060px,94vw); height:min(680px,92vh);
|
|
433
|
+
padding:20px 26px 18px; border:22px solid transparent;
|
|
434
|
+
border-image:url(${SC.panelArt}/panel_frame.png) 40 stretch;
|
|
435
|
+
background:rgba(20,14,8,0.96) padding-box;
|
|
436
|
+
box-shadow:0 0 44px rgba(0,0,0,0.85), inset 0 0 70px rgba(0,0,0,0.5); }
|
|
437
|
+
|
|
438
|
+
#satchel .satchel-title { text-align:center; font-size:20px; letter-spacing:7px; font-weight:700;
|
|
439
|
+
color:#e8c98a; text-shadow:0 2px 6px #000; margin-bottom:12px; }
|
|
440
|
+
|
|
441
|
+
#satchel .satchel-body { flex:1; min-height:0; display:flex; gap:24px; }
|
|
442
|
+
|
|
443
|
+
/* LEFT: the selected item's detail */
|
|
444
|
+
#satchel .satchel-detail { width:320px; flex:none; min-height:0; overflow-y:auto; text-align:center;
|
|
445
|
+
padding:6px 10px; }
|
|
446
|
+
#satchel .satchel-dicon { display:block; width:96px; height:96px; margin:6px auto 12px;
|
|
447
|
+
filter:drop-shadow(0 3px 6px rgba(0,0,0,0.6)); }
|
|
448
|
+
#satchel .satchel-dname { font-size:19px; font-weight:700; color:#f0e0b0; letter-spacing:1px; text-shadow:0 1px 3px #000; }
|
|
449
|
+
#satchel .satchel-dcat { font-size:11px; letter-spacing:2px; text-transform:uppercase; color:#9c8858; margin-top:2px; }
|
|
450
|
+
#satchel .satchel-dblurb { font-size:13px; line-height:1.5; color:#c9b892; margin:14px 4px; font-style:italic;
|
|
451
|
+
font-family:Palatino,'Book Antiqua',Georgia,serif; }
|
|
452
|
+
#satchel .satchel-dstats { display:flex; flex-direction:column; gap:4px; font-size:13px; color:#cdbb8e; margin:10px 0 16px; }
|
|
453
|
+
#satchel .satchel-dstats b { color:#e8c87a; }
|
|
454
|
+
#satchel .satchel-hint, #satchel .satchel-empty { color:#8a7a54; font-size:14px; opacity:0.8; padding:24px 0; text-align:center; }
|
|
455
|
+
#satchel .satchel-empty { grid-column:1 / -1; }
|
|
456
|
+
|
|
457
|
+
#satchel .satchel-acts { display:flex; gap:10px; justify-content:center; margin-top:8px; }
|
|
458
|
+
#satchel .satchel-btn { cursor:pointer; padding:8px 16px; font:600 13px/1 inherit; letter-spacing:1px;
|
|
459
|
+
color:#f0e0b0; background:rgba(60,42,20,0.7); border:1px solid #7a5c30; border-radius:4px;
|
|
460
|
+
text-transform:uppercase; }
|
|
461
|
+
#satchel .satchel-btn:hover { background:rgba(96,68,34,0.85); border-color:#e8c87a; color:#fff4c8; }
|
|
462
|
+
|
|
463
|
+
/* RIGHT: tabs + grid */
|
|
464
|
+
#satchel .satchel-main { flex:1; min-width:0; min-height:0; display:flex; flex-direction:column; }
|
|
465
|
+
#satchel .satchel-tabs { display:flex; gap:2px; }
|
|
466
|
+
#satchel .satchel-tab { flex:1; cursor:pointer; height:40px; padding:0 4px; border:0; background:transparent;
|
|
467
|
+
color:#9c8858; font:600 11px/1 inherit; letter-spacing:1px; text-transform:uppercase; white-space:nowrap;
|
|
468
|
+
border-bottom:2px solid transparent; }
|
|
469
|
+
#satchel .satchel-tab:hover { color:#cdbb8e; }
|
|
470
|
+
#satchel .satchel-tab.active { color:#f0e0b0; border-bottom-color:${BRASS}; text-shadow:0 1px 3px #000; }
|
|
471
|
+
|
|
472
|
+
#satchel .satchel-rule { height:8px; flex:none; margin:8px 0;
|
|
473
|
+
background:url(${SC.panelArt}/rule.png) center/100% 100% no-repeat; opacity:0.85; }
|
|
474
|
+
|
|
475
|
+
#satchel .satchel-grid { flex:1; min-height:0; overflow-y:auto; overflow-x:hidden;
|
|
476
|
+
display:grid; grid-template-columns:repeat(${COLS}, minmax(0, 1fr)); gap:${GAP}px;
|
|
477
|
+
align-content:start; padding:4px 2px; } /* 1fr cols FILL the parent width — col 1 at the section's left edge, no centre gap (Nick) */
|
|
478
|
+
|
|
479
|
+
#satchel .satchel-slot { position:relative; width:100%; aspect-ratio:1 / 1; box-sizing:border-box;
|
|
480
|
+
cursor:pointer; border:12px solid transparent;
|
|
481
|
+
border-image:url(${SC.panelArt}/slot.png) 60 fill / 12px stretch; } /* fill the 1fr column, stay square */
|
|
482
|
+
#satchel .satchel-slot:hover { box-shadow:inset 0 0 0 999px rgba(229,214,75,0.10), 0 0 0 1px rgba(229,214,75,0.45); }
|
|
483
|
+
#satchel .satchel-slot-empty { opacity:0.32; pointer-events:none; }
|
|
484
|
+
#satchel .satchel-slot-empty:hover { box-shadow:none; }
|
|
485
|
+
#satchel .satchel-slot.sel { box-shadow:0 0 0 2px ${BRASS}, 0 0 14px rgba(229,214,75,0.55); filter:brightness(1.1); }
|
|
486
|
+
#satchel .satchel-ic { position:absolute; inset:${ICON_INSET}px; background-repeat:no-repeat; }
|
|
487
|
+
#satchel .satchel-count { position:absolute; right:3px; bottom:2px; font:700 12px/1 ui-monospace,monospace;
|
|
488
|
+
color:#f4ecc8; text-shadow:0 1px 2px #000, 0 0 3px #000; padding:1px 3px; }
|
|
489
|
+
|
|
490
|
+
/* FOOTER: weight (soft cap, warns only) + money */
|
|
491
|
+
#satchel .satchel-footer { flex:none; display:flex; justify-content:space-between; align-items:center;
|
|
492
|
+
font-size:13px; letter-spacing:1px; padding:2px 4px 0; }
|
|
493
|
+
#satchel .satchel-wlbl, #satchel .satchel-mlbl { color:#9c8858; text-transform:uppercase; font-size:11px; letter-spacing:2px; }
|
|
494
|
+
#satchel .satchel-wval, #satchel .satchel-mval { color:#f0e0b0; font-weight:700; margin-left:6px; }
|
|
495
|
+
#satchel .satchel-wval.warn { color:#e0a94b; }
|
|
496
|
+
#satchel .satchel-wwarn { color:#e0a94b; font-weight:600; }
|
|
497
|
+
`;
|
|
498
|
+
document.head.appendChild(css);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// scenes.js — the SCENE PLAYER (cutscenes v1). A scene is DATA: an anchor point, a handful of
|
|
2
|
+
// camera SHOTS (eased dolly from→to, aimed at a look point, each holding a caption), and optional
|
|
3
|
+
// ACTORS (registered NPCs pinned to marks for the duration, restored after). Played letterboxed
|
|
4
|
+
// over the live world with the player frozen the campfire way (ui.cinematic kills look+move,
|
|
5
|
+
// busy suppresses the locomotion FSM, the mixer keeps breathing). Any key skips.
|
|
6
|
+
//
|
|
7
|
+
// Positions are ANCHOR-RELATIVE ([dx, dy, dz] with dy off the terrain at the anchor), so a scene
|
|
8
|
+
// survives terrain changes and can be authored without knowing absolute ground heights.
|
|
9
|
+
//
|
|
10
|
+
// WIRING: a mission stage carries `scene: 'id'` — missions._enterStage calls game.scenes.play(id),
|
|
11
|
+
// which guards itself with a per-save flag (scene_<id>) so a scene plays exactly once per game.
|
|
12
|
+
// main.js ticks update(dt, camera) AFTER every other camera writer, so the dolly owns the lens.
|
|
13
|
+
import * as THREE from 'three/webgpu';
|
|
14
|
+
|
|
15
|
+
// Cutscene defs are GAME data: registerScenes(defs)
|
|
16
|
+
let SCENES = {};
|
|
17
|
+
export function registerScenes(defs) { SCENES = defs ?? {}; }
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
const ease = (t) => t * t * (3 - 2 * t); // smoothstep — a dolly, not an elevator
|
|
21
|
+
|
|
22
|
+
export class ScenePlayer {
|
|
23
|
+
constructor(game) {
|
|
24
|
+
this.game = game;
|
|
25
|
+
this.active = null;
|
|
26
|
+
this._skip = (e) => { if (this.active && (e.type === 'mousedown' || !e.repeat)) this.finish(); };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
play(id) {
|
|
30
|
+
const sc = SCENES[id], g = this.game, m = g.missions;
|
|
31
|
+
if (!sc || this.active || m?.flag?.(`scene_${id}`)) return;
|
|
32
|
+
m?.setFlag?.(`scene_${id}`); // once per save — even if skipped instantly
|
|
33
|
+
const gy = g.world.groundAt(sc.anchor.x, sc.anchor.z);
|
|
34
|
+
const abs = ([dx, dy, dz]) => new THREE.Vector3(sc.anchor.x + dx, gy + dy, sc.anchor.z + dz);
|
|
35
|
+
// pin the actors to their marks (def.post moved so postedUpdate HOLDS them there; restored after)
|
|
36
|
+
const actors = [];
|
|
37
|
+
for (const a of sc.actors ?? []) {
|
|
38
|
+
const n = m?.npcs?.[a.npc];
|
|
39
|
+
if (!n) continue;
|
|
40
|
+
actors.push({ n, post: n.def.post, x: n.position.x, z: n.position.z, ry: n.root?.rotation.y ?? 0 });
|
|
41
|
+
const wx = sc.anchor.x + a.at[0], wz = sc.anchor.z + a.at[1];
|
|
42
|
+
n.def.post = { x: wx, z: wz, face: a.face ?? 0 };
|
|
43
|
+
n.setPosition(wx, g.world.groundAt(wx, wz), wz);
|
|
44
|
+
if (n.root) n.root.rotation.y = a.face ?? 0;
|
|
45
|
+
}
|
|
46
|
+
this.active = { sc, id, shot: 0, t: 0, abs, actors, prevCin: g.ui.cinematic };
|
|
47
|
+
g.ui.cinematic = true;
|
|
48
|
+
if (g.player) g.player.busy = true;
|
|
49
|
+
this._dom(true);
|
|
50
|
+
this._caption(sc.shots[0].caption);
|
|
51
|
+
addEventListener('keydown', this._skip); addEventListener('mousedown', this._skip);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
update(dt, camera) {
|
|
55
|
+
const a = this.active;
|
|
56
|
+
if (!a) return;
|
|
57
|
+
const shot = a.sc.shots[a.shot];
|
|
58
|
+
if (!shot) return this.finish();
|
|
59
|
+
a.t += dt;
|
|
60
|
+
const k = ease(Math.min(1, a.t / shot.dur));
|
|
61
|
+
const from = a.abs(shot.pos), to = a.abs(shot.to);
|
|
62
|
+
camera.position.lerpVectors(from, to, k);
|
|
63
|
+
const look = a.abs(shot.look);
|
|
64
|
+
camera.lookAt(look.x, look.y, look.z);
|
|
65
|
+
if (a.t >= shot.dur) {
|
|
66
|
+
a.shot++; a.t = 0;
|
|
67
|
+
const next = a.sc.shots[a.shot];
|
|
68
|
+
if (!next) return this.finish();
|
|
69
|
+
this._caption(next.caption);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
finish() {
|
|
74
|
+
const a = this.active;
|
|
75
|
+
if (!a) return;
|
|
76
|
+
for (const st of a.actors) { // everyone back where their life left off
|
|
77
|
+
st.n.def.post = st.post;
|
|
78
|
+
st.n.setPosition(st.x, this.game.world.groundAt(st.x, st.z), st.z);
|
|
79
|
+
if (st.n.root) st.n.root.rotation.y = st.ry;
|
|
80
|
+
}
|
|
81
|
+
this.game.ui.cinematic = a.prevCin;
|
|
82
|
+
if (this.game.player) this.game.player.busy = false;
|
|
83
|
+
this._dom(false);
|
|
84
|
+
removeEventListener('keydown', this._skip); removeEventListener('mousedown', this._skip);
|
|
85
|
+
this.active = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// letterbox bars + the caption strip — same visual language as ui.stageCard, own lifetime
|
|
89
|
+
_dom(on) {
|
|
90
|
+
let host = this._host;
|
|
91
|
+
if (!host && !on) return;
|
|
92
|
+
if (!host) {
|
|
93
|
+
host = this._host = document.createElement('div');
|
|
94
|
+
host.style.cssText = 'position:fixed;inset:0;z-index:930;pointer-events:none;opacity:0;transition:opacity 0.7s;';
|
|
95
|
+
host.innerHTML = `
|
|
96
|
+
<div style="position:absolute;left:0;right:0;top:0;height:12vh;background:#000;"></div>
|
|
97
|
+
<div style="position:absolute;left:0;right:0;bottom:0;height:12vh;background:#000;"></div>
|
|
98
|
+
<div data-k="cap" style="position:absolute;left:10vw;right:10vw;bottom:3.2vh;text-align:center;color:#e8d9b0;font:italic 400 19px Georgia,'Palatino Linotype',serif;text-shadow:0 2px 10px rgba(0,0,0,0.95);"></div>
|
|
99
|
+
<div style="position:absolute;right:2.2vw;top:13.5vh;color:#9b8c68;font:600 12px Georgia,serif;letter-spacing:2px;">ANY KEY — SKIP</div>`;
|
|
100
|
+
document.body.appendChild(host);
|
|
101
|
+
}
|
|
102
|
+
host.style.opacity = on ? 1 : 0;
|
|
103
|
+
}
|
|
104
|
+
_caption(text) { const c = this._host?.querySelector('[data-k="cap"]'); if (c) c.textContent = text ?? ''; }
|
|
105
|
+
}
|