sindicate 0.5.0 → 0.7.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.
@@ -0,0 +1,434 @@
1
+ // dialogue.js — TALKING TO THE COUNTY. A self-contained panel, built once on the loading screen,
2
+ // DOM-only (PERF LAW: nothing touches the scene during play). It owns three things:
3
+ //
4
+ // 1. the Fantasy-Warrior panel — a name banner, the line the NPC speaks, and numbered option
5
+ // plates with a gold flourish down the side, laid out as Synty's own demo scene lays it: the
6
+ // speaker to the LEFT of frame, the panel filling the RIGHT.
7
+ // 2. keyboard + gamepad + mouse selection of those options (the same five rules the pause menu
8
+ // uses — number keys, up/down, Enter/E, Esc/B — copied here so ui.js's _navTarget stays the
9
+ // pause menu's alone; three panels must not fight over it).
10
+ // 3. THE TALK CAMERA — it swings the lens round to the NPC's front, eases in, and hands back with
11
+ // a cut the instant he reaches for the gun. Same shape as trainCamera.js (seize the lens, kill
12
+ // mouse-look via ui.cinematic, snap on handback), realised for a two-shot instead of a train.
13
+ //
14
+ // The WORDS live in data/dialogue.js, per role, with a few named people. This file is the machine
15
+ // that says them; that file is the feature.
16
+ //
17
+ // WIRING (the integrator, main.js/ui.js — NOT this file):
18
+ // · construct once beside loot/deadEye: game.ui.dialogue = new Dialogue(game.ui)
19
+ // · open on the nearest talkable NPC at the E-scan: ui.dialogue.open(npc) (game.npcs only — see
20
+ // WESTERN SEAMS §5: outlaws and the law are Enemies in other arrays, never talk candidates)
21
+ // · once per frame while open: ui.dialogue.handleKey(input)
22
+ // · from the on-foot camera tail (outside the pause guard, like player.aboardPost):
23
+ // ui.dialogue.cameraTakeover(dt, camera)
24
+ // · point the trade option at the shop: ui.dialogue.onTrade = (npc) => ui.shop.open(npc)
25
+ // Escape and getting-shot already route through ui.closeDialogue() (main.js:748, player.js:1366),
26
+ // which this module adopts as its own close — so those paths tear the panel down and hand the camera
27
+ // back for free, without an integrator edit.
28
+ // dialogue trees are GAME data: setDialogueTrees(treeForFn) — (npc) => tree
29
+ let treeFor = () => null;
30
+ export function setDialogueTrees(fn) { treeFor = fn ?? (() => null); }
31
+ import { padMode, buttonCanvas } from './menuHints.js';
32
+
33
+ // ── the two-shot, in numbers ────────────────────────────────────────────────────────────────────
34
+ // The lens stands in FRONT of the NPC (he has already turned to face the player, so his front faces
35
+ // roughly where the player — and thus the lens — is), swung a touch to one side for a 3/4 read, then
36
+ // PANNED screen-right so he slides into the LEFT of frame and the panel has the right to itself.
37
+ const CAM_D = 2.6; // metres the lens stands off the NPC — close enough he fills frame,
38
+ // far enough the 40°-ish FOV doesn't fish-eye his hat brim
39
+ const CAM_THETA = 0.454; // 26°: swing off dead-centre for a 3/4 face instead of a passport photo
40
+ const CAM_PAN = 0.9; // metres of screen-right pan → NPC to the left, panel fills the right
41
+ const HEAD = 1.5; // look height above the NPC's feet (his eyeline; matches the follow
42
+ // cam's head target at _visualY+1.55)
43
+ const CAM_H = 1.7; // lens height above his feet — a touch above the eyeline, looking down
44
+ // ~4°, which clears hats and hitching rails between the two of them
45
+ const GROUND_CLEAR = 0.5; // never let the eased lens drop into the dirt
46
+ const CAM_RATE = 3.2; // ease-in speed (per second). trainCamera plants exactly; a two-shot
47
+ // wants to GLIDE in over ~0.4 s so the swing reads as a move.
48
+
49
+ // segment-test scratch (world.segmentBlocked wants {x,y,z}) — module scope, no per-frame alloc.
50
+ const _segA = { x: 0, y: 0, z: 0 };
51
+ const _segB = { x: 0, y: 0, z: 0 };
52
+
53
+ // Pick a line from a pool without repeating the last one drawn from THAT pool — so two miners in a
54
+ // row don't greet you identically, and asking a question twice gets a different answer. The last
55
+ // index is stashed on the array itself (WeakMap), shared across every NPC of the role.
56
+ const _lastPick = new WeakMap();
57
+ function pick(a) {
58
+ if (!Array.isArray(a)) return a;
59
+ if (a.length <= 1) return a[0] ?? '';
60
+ let i = (Math.random() * a.length) | 0;
61
+ if (i === _lastPick.get(a)) i = (i + 1) % a.length;
62
+ _lastPick.set(a, i);
63
+ return a[i];
64
+ }
65
+
66
+ export class Dialogue {
67
+ constructor(ui) {
68
+ this.ui = ui;
69
+ this.game = ui.game;
70
+ this.npc = null;
71
+ this.tree = null;
72
+ this.onTrade = null; // the shop hook the integrator points at ui.shop.open
73
+
74
+ // nav state (mirrors ui.js navUpdate, kept local so we never touch _navTarget)
75
+ this._idx = 0;
76
+ this._engaged = false; // a key/pad has been used since the last mouse move
77
+ this._navFresh = true; // options just (re)rendered — swallow this frame's confirm edge
78
+ this._mx = 0; this._my = 0;
79
+
80
+ // talk-camera state — plain scalars eased toward the target; camera.position is reset every
81
+ // frame by the follow cam before we run, so we can't lerp IT, we lerp our own kept pose.
82
+ this._camActive = false;
83
+ this._cp = { x: 0, y: 0, z: 0 }; // current lens position
84
+ this._ct = { x: 0, y: 0, z: 0 }; // current look-at
85
+ this._tp = { x: 0, y: 0, z: 0 }; // target lens position (the planted two-shot)
86
+ this._tt = { x: 0, y: 0, z: 0 }; // target look-at
87
+
88
+ this._injectCSS();
89
+ this._buildDOM();
90
+
91
+ // ADOPT ui.closeDialogue. It exists as a stub (ui.js:913) that ten systems and two hot paths
92
+ // call — Escape (main.js:748) and taking a bullet (player.js:1366). Routing it through close()
93
+ // makes those paths tear the panel down and hand the camera back with no edit to ui.js or
94
+ // main.js. openDialogue is aliased too, for any caller that reaches for the Fable-era name.
95
+ ui.closeDialogue = () => this.close();
96
+ ui.openDialogue = (npc) => this.open(npc);
97
+ if (ui.currentNPC === undefined) ui.currentNPC = null; // the flag npc.js reads to hold + face
98
+ }
99
+
100
+ // ── the contract API ──────────────────────────────────────────────────────────────────────────
101
+ isOpen() { return !!this.ui.dialogueOpen; }
102
+
103
+ open(npc) {
104
+ if (!npc) return;
105
+ if (this.ui.dialogueOpen && this.npc === npc) { this._render(pick(this.tree.openers)); return; }
106
+ this.npc = npc;
107
+ this.tree = treeFor(npc);
108
+ // A HOUSED GUNHAND (COPPERHEAD crew at the Halloway place) always offers the ride-along toggle,
109
+ // appended non-destructively over WHATEVER tree he's using — the merchant counter, a mission
110
+ // stage, his own lines — so taking August along never costs you his shop.
111
+ if (npc._atHouse && npc.def?.gunhand) {
112
+ const g = this.ui.game;
113
+ this.tree = { ...this.tree, topics: [...(this.tree.topics ?? []), npc._follow
114
+ ? { q: `Head on home.`, a: [`Holler when it turns interesting again.`], action: (n) => g._toggleRideAlong?.(n) }
115
+ : { q: `Ride with me.`, a: [`(A check of the load, a tug of the hat, and he falls in.)`], action: (n) => g._toggleRideAlong?.(n) },
116
+ ] };
117
+ }
118
+ this._asked = new Set(); // topics already put to him THIS conversation — they drop off the
119
+ // list once answered, so a chat PROGRESSES toward "So long"
120
+ // instead of handing back the same three buttons forever.
121
+ this.ui.dialogueOpen = true; // freezes the player (look/move/gun/mount) via ten readers;
122
+ this.ui.cinematic = true; // and freezes look/move a second way — the camera-takeover flag
123
+ this.ui.currentNPC = npc; // npc.js: face the player, hold idle, drop the wander goal
124
+ const input = this.game.input;
125
+ input?.releaseLock?.(); // free the cursor for the option buttons (map does the same)
126
+ input?.consume?.('KeyE'); // the E that opened us must NOT also confirm the top option
127
+ this._camActive = false; // re-seed the ease from wherever the follow cam is this frame
128
+ this._plantShot(npc);
129
+ this._mx = input?.mouse?.x ?? 0; this._my = input?.mouse?.y ?? 0;
130
+ this._engaged = false;
131
+ this.el.root.style.display = 'block';
132
+ this._render(pick(this.tree.openers));
133
+ this.game.audio?.play?.('ui');
134
+ }
135
+
136
+ close(opts = {}) {
137
+ if (!this.ui.dialogueOpen) return; // idempotent — safe to call already-closed
138
+ this.el.root.style.display = 'none';
139
+ this.ui.dialogueOpen = false;
140
+ this.ui.cinematic = false;
141
+ this.ui.currentNPC = null;
142
+ this.npc = null; this.tree = null;
143
+ this._camActive = false;
144
+ this._navFresh = false;
145
+ // HAND THE LENS BACK. If he needs the gun this instant — shot, aiming, a fight, dead — CUT, the
146
+ // way trainCamera does (player._camSnap, consumed next frame in _camera). Otherwise leave the
147
+ // lens where the two-shot ended and let the follow cam EASE it home (a soft ~0.2 s settle), which
148
+ // is the ease-OUT: a calm goodbye shouldn't snap. (Do NOT re-request the pointer lock; the next
149
+ // canvas click re-acquires it and input.js drops that click so it never lands as a shot.)
150
+ const p = this.game.player;
151
+ const danger = opts.snap || !p?.alive || p?.aiming || p?.drawn || p?.reloading
152
+ || (p?._combatT > 0) || this.game._inCombat;
153
+ if (danger && p) p._camSnap = true;
154
+ }
155
+
156
+ // Called once per frame by the integrator while this panel is the active one. Mouse works without
157
+ // it (native :hover + onclick); this drives keys/pad and consumes Escape.
158
+ handleKey(input) {
159
+ if (!this.ui.dialogueOpen || !input) return;
160
+ const fresh = this._navFresh; this._navFresh = false;
161
+ this._paintByeKey(); // swap the way-out keycap live if a pad was (un)plugged
162
+
163
+ // RAW pad. The synthesized pad keys are suppressed while a panel is open (input.pollGamepad(uiOpen)),
164
+ // so read the stick / D-pad / face buttons DIRECTLY, edge-detected, like the pause menu & world map.
165
+ let padUp = false, padDown = false, padConfirm = false, padClose = false;
166
+ {
167
+ const pads = navigator.getGamepads ? navigator.getGamepads() : null;
168
+ let gp = null; if (pads) for (const p of pads) if (p && p.connected) { gp = p; break; }
169
+ if (gp) {
170
+ const b = (i) => !!gp.buttons[i]?.pressed, ax = (i) => gp.axes[i] || 0;
171
+ const st = { u: b(12) || ax(1) < -0.55, d: b(13) || ax(1) > 0.55, a: b(0), c: b(1) };
172
+ const pv = this._dpad || {};
173
+ padUp = st.u && !pv.u; padDown = st.d && !pv.d; padConfirm = st.a && !pv.a; padClose = st.c && !pv.c;
174
+ this._dpad = st;
175
+ } else this._dpad = null;
176
+ }
177
+
178
+ // leave the conversation — Circle (pad), KeyB / Esc, or reaching for the gun
179
+ if (padClose && !fresh) return this.close();
180
+ if (input.hit('Escape')) { input.consume('Escape'); return this.close(); }
181
+ if (input.hit('KeyB')) { input.consume('KeyB'); return this.close(); }
182
+ // reaching for the gun ends the talk and cuts the lens straight back
183
+ if (input.mouseHit?.(2)) { input.consumeMouse?.(2); return this.close({ snap: true }); }
184
+
185
+ const opts = this._opts();
186
+ if (!opts.length) return;
187
+
188
+ // number keys jump straight to a numbered option (the topics + the trade line; NOT the B row)
189
+ const numbered = opts.filter((b) => b.dataset && b.dataset.num);
190
+ for (let i = 0; i < numbered.length && i < 9; i++) {
191
+ if (input.hit('Digit' + (i + 1)) || input.hit('Numpad' + (i + 1))) {
192
+ input.consume('Digit' + (i + 1)); input.consume('Numpad' + (i + 1));
193
+ numbered[i].click(); return;
194
+ }
195
+ }
196
+
197
+ const down = input.hit('ArrowDown') || input.hit('KeyS') || padDown;
198
+ const up = input.hit('ArrowUp') || input.hit('KeyW') || padUp;
199
+ const confirm = input.hit('Enter') || input.hit('Space') || input.hit('KeyE') || padConfirm;
200
+
201
+ // last-device-wins: a mouse move drops the key marker so it can't sit lit beside a :hover'd row
202
+ const mx = input.mouse?.x ?? 0, my = input.mouse?.y ?? 0;
203
+ if ((mx !== this._mx || my !== this._my) && !input.gpActive) this._engaged = false;
204
+ this._mx = mx; this._my = my;
205
+ if (down || up || confirm || padUp || padDown || input.gpActive) this._engaged = true;
206
+
207
+ if (this._idx >= opts.length) this._idx = opts.length - 1;
208
+ if (this._idx < 0) this._idx = 0;
209
+ if (!this._engaged) { opts.forEach((b) => b.classList.remove('navsel')); return; }
210
+
211
+ if (down) this._idx = (this._idx + 1) % opts.length;
212
+ if (up) this._idx = (this._idx - 1 + opts.length) % opts.length;
213
+ if (down || up) this.game.audio?.play?.('ui');
214
+ opts.forEach((b, i) => b.classList.toggle('navsel', i === this._idx));
215
+
216
+ // never confirm on the frame the options first appear — that E/Enter edge belongs to whatever
217
+ // opened the panel (KeyE both OPENS dialogue and CONFIRMS options), so the top option can't
218
+ // self-select the instant you walk up.
219
+ if (confirm && !fresh) {
220
+ ['Enter', 'Space', 'KeyE'].forEach((c) => input.consume(c));
221
+ opts[this._idx]?.click();
222
+ }
223
+ }
224
+
225
+ // ── THE TALK CAMERA ─────────────────────────────────────────────────────────────────────────
226
+ // Returns true while the rig owns the lens (mouse-look already dead via ui.cinematic), false the
227
+ // moment the panel closes — then the follow cam takes over and eases home. Same seam as the train:
228
+ // player.aboardPost calls the train rig and falls through when it answers false; the integrator
229
+ // calls THIS from the on-foot camera tail and overrides the follow cam when it answers true.
230
+ cameraTakeover(dt, camera) {
231
+ if (!this.ui.dialogueOpen || !this.npc) return false;
232
+ if (!this._camActive) {
233
+ // seed the ease from where the follow cam left the lens this frame, so the swing reads as a
234
+ // move and not a cut — and seed the look-at at the player's head, which is exactly what the
235
+ // follow cam was looking at, so the aim glides FROM the player round TO the man he's talking to.
236
+ this._camActive = true;
237
+ this._cp.x = camera.position.x; this._cp.y = camera.position.y; this._cp.z = camera.position.z;
238
+ const P = this.game.player.position;
239
+ this._ct.x = P.x; this._ct.y = P.y + HEAD; this._ct.z = P.z;
240
+ }
241
+ const k = Math.min(1, dt * CAM_RATE);
242
+ this._cp.x += (this._tp.x - this._cp.x) * k;
243
+ this._cp.y += (this._tp.y - this._cp.y) * k;
244
+ this._cp.z += (this._tp.z - this._cp.z) * k;
245
+ this._ct.x += (this._tt.x - this._ct.x) * k;
246
+ this._ct.y += (this._tt.y - this._ct.y) * k;
247
+ this._ct.z += (this._tt.z - this._ct.z) * k;
248
+ camera.position.set(this._cp.x, this._cp.y, this._cp.z);
249
+ camera.lookAt(this._ct.x, this._ct.y, this._ct.z);
250
+ return true;
251
+ }
252
+
253
+ // Compute the planted two-shot ONCE per conversation (the player is frozen and the NPC is held on
254
+ // his mark facing us, so the target never moves — no need to re-solve the ray every frame). Writes
255
+ // _tp (lens) and _tt (look-at).
256
+ _plantShot(npc) {
257
+ const N = npc.position, P = this.game.player.position;
258
+ let fx = P.x - N.x, fz = P.z - N.z; // the way he faces: toward the player, toward us
259
+ let fl = Math.hypot(fx, fz);
260
+ if (fl < 0.2) { fx = 0; fz = 1; fl = 1; } // stacked on the same spot → pick an arbitrary front
261
+ fx /= fl; fz /= fl;
262
+ const world = this.game.world;
263
+ let chosen = null;
264
+ // try a 3/4 swing to either side; keep the first whose sightline to his head is CLEAR of walls,
265
+ // so the lens never frames him through the back of the saloon. (Prefer the +26° side.)
266
+ for (const s of [1, -1]) {
267
+ const th = CAM_THETA * s, c = Math.cos(th), sn = Math.sin(th);
268
+ const cx = fx * c + fz * sn, cz = -fx * sn + fz * c; // rotate the facing vector about +Y
269
+ let px = N.x + cx * CAM_D, pz = N.z + cz * CAM_D, py = N.y + CAM_H;
270
+ let tx = N.x, tz = N.z; const ty = N.y + HEAD;
271
+ // screen-right = cross(up, view); view = look − lens. For up=(0,1,0): right = (view.z, 0, −view.x)
272
+ const vx = tx - px, vz = tz - pz;
273
+ // SCREEN-RIGHT is cross(view, up), not cross(up, view) — the sign was flipped, so the pan went
274
+ // screen-LEFT and slid the man behind the panel on the right instead of into the clear on the
275
+ // left. three's look-at builds right = normalize(cross(forward, up)); for up=(0,1,0) and a
276
+ // horizontal view that is (−view.z, 0, view.x).
277
+ let rx = -vz, rz = vx; const rl = Math.hypot(rx, rz) || 1; rx /= rl; rz /= rl;
278
+ px += rx * CAM_PAN; pz += rz * CAM_PAN; // pan the lens right → he slides left, panel gets the right
279
+ tx += rx * CAM_PAN; tz += rz * CAM_PAN;
280
+ const g = world.groundAt(px, pz) + GROUND_CLEAR;
281
+ if (py < g) py = g; // never through the floor
282
+ const shot = { px, py, pz, tx, ty, tz };
283
+ if (!chosen) chosen = shot;
284
+ _segA.x = px; _segA.y = py; _segA.z = pz;
285
+ _segB.x = N.x; _segB.y = N.y + HEAD; _segB.z = N.z;
286
+ if (!world.segmentBlocked(_segA, _segB)) { chosen = shot; break; }
287
+ }
288
+ this._tp.x = chosen.px; this._tp.y = chosen.py; this._tp.z = chosen.pz;
289
+ this._tt.x = chosen.tx; this._tt.y = chosen.ty; this._tt.z = chosen.tz;
290
+ }
291
+
292
+ // ── rendering ─────────────────────────────────────────────────────────────────────────────────
293
+ _opts() { return [...this.el.opts.querySelectorAll('button')].filter((b) => !b.disabled); }
294
+
295
+ // the "way out" keycap: the pad's Circle icon when a controller is connected, else a boxed "B".
296
+ _paintByeKey() {
297
+ const mode = padMode();
298
+ if (mode === this._byeMode || !this._byeKey) return;
299
+ this._byeMode = mode;
300
+ this._byeKey.textContent = '';
301
+ if (mode) { this._byeKey.className = 'dlg-byekey pad'; const c = buttonCanvas('circle', mode, 19); c.style.display = 'inline-block'; c.style.verticalAlign = 'middle'; this._byeKey.appendChild(c); }
302
+ else { this._byeKey.className = 'dlg-byekey kbd'; this._byeKey.textContent = 'B'; }
303
+ }
304
+
305
+ _render(line) {
306
+ const npc = this.npc, tree = this.tree;
307
+ this.el.name.textContent = npc.name || 'Stranger';
308
+ this.el.say.textContent = line || '';
309
+ const opts = this.el.opts;
310
+ opts.innerHTML = '';
311
+ let k = 0;
312
+ const add = (label, fn, cls) => {
313
+ const b = document.createElement('button');
314
+ b.className = 'dlg-opt' + (cls ? ' ' + cls : '');
315
+ b.textContent = label;
316
+ b.onmouseenter = () => { this._engaged = false; }; // mouse takes the highlight from the keys
317
+ b.onclick = () => { this.game.audio?.play?.('ui'); fn(); };
318
+ opts.appendChild(b);
319
+ return b;
320
+ };
321
+ // THE TOPICS. Asking one MARKS IT ASKED and re-renders, so it is gone from the list next time —
322
+ // a conversation that moves forward, not a menu you click a billion times. The number keys map to
323
+ // what is on screen, so they re-number as topics fall away; when the last is asked only the
324
+ // merchant's counter (if any) and "So long" remain, which is the chat ending on its own.
325
+ (tree.topics || []).forEach((t, ti) => {
326
+ if (this._asked && this._asked.has(ti)) return;
327
+ k++;
328
+ const b = add(`${k}. ${t.q}`, () => { this._asked.add(ti); t.action?.(this.npc, this); this._render(pick(t.a)); });
329
+ b.dataset.num = String(k);
330
+ });
331
+ // ...and once he has nothing left to be asked, put a sign-off under his name so the panel is not a
332
+ // banner over a lone "So long" with no line beneath it.
333
+ const topicsN = (tree.topics || []).length;
334
+ if (topicsN && this._asked && this._asked.size >= topicsN && !tree.merchant) {
335
+ this.el.say.textContent = line || `That's about the size of it. Mind how you go.`;
336
+ }
337
+ // the merchant's counter — reached, per the plan, through a line of dialogue
338
+ if (tree.merchant) {
339
+ k++;
340
+ const label = `${k}. ${tree.tradeLabel || `Let me see what you've got.`}`;
341
+ const b = this.onTrade
342
+ ? add(label, () => { const n = this.npc; this.close(); this.onTrade(n); }, 'dlg-trade')
343
+ // shop not wired yet (it ships after dialogue): keep the option visible but honest
344
+ : add(label, () => this._render(`Counter's shut just now. Come back when I've cracked the crates.`), 'dlg-trade');
345
+ b.dataset.num = String(k);
346
+ }
347
+ // the "way out" row — a controller-aware keycap: Circle (○) on a pad, B on a keyboard. Closes on
348
+ // Circle / KeyB / Esc. No data-num: number keys skip it.
349
+ const bye = add('So long.', () => this.close(), 'dlg-bye');
350
+ this._byeKey = document.createElement('span'); this._byeKey.className = 'dlg-byekey';
351
+ bye.insertBefore(this._byeKey, bye.firstChild);
352
+ this._byeMode = undefined; this._paintByeKey();
353
+ this._navFresh = true;
354
+ this._idx = 0;
355
+ [...opts.children].forEach((b) => b.classList.remove('navsel'));
356
+ }
357
+
358
+ // ── DOM + CSS (built once) ────────────────────────────────────────────────────────────────────
359
+ _buildDOM() {
360
+ const root = document.createElement('div');
361
+ root.id = 'dlg';
362
+ root.innerHTML = '<div class="dlg-panel">'
363
+ + '<div class="dlg-name"></div>'
364
+ + '<div class="dlg-say"></div>'
365
+ + '<div class="dlg-opts"></div>'
366
+ + '</div>';
367
+ document.body.appendChild(root);
368
+ this.el = {
369
+ root,
370
+ name: root.querySelector('.dlg-name'),
371
+ say: root.querySelector('.dlg-say'),
372
+ opts: root.querySelector('.dlg-opts'),
373
+ };
374
+ }
375
+
376
+ _injectCSS() {
377
+ const P = '/assets/ui/panels';
378
+ const css = document.createElement('style');
379
+ // All under #dlg / .dlg-* — no selector reaches another panel or #hud. The Fantasy-Warrior
380
+ // sprites sit on top of solid, legible CSS plates (western palette: gold #e8c87a, brown #7a5c30,
381
+ // near-black panel), so a sprite that reads oddly or fails to load still leaves a readable panel.
382
+ css.textContent = `
383
+ #dlg { position:fixed; inset:0; z-index:60; display:none; pointer-events:none;
384
+ font-family:Copperplate,'Copperplate Gothic Bold','Palatino','Book Antiqua',Georgia,serif; }
385
+ #dlg .dlg-panel { position:absolute; right:3.2vw; bottom:8vh; width:min(560px,46vw);
386
+ display:flex; flex-direction:column; align-items:stretch; gap:10px; }
387
+
388
+ /* NAME BANNER — the ornate FW banner as a horizontal 9-slice (330 0 330 0): the two end
389
+ flourishes hold their size and the middle stretches, so a long name never tears it. The
390
+ gradient + border below is the fallback shape if the sprite is missing. */
391
+ #dlg .dlg-name { align-self:center; min-width:200px; max-width:100%; height:56px; padding:0 66px;
392
+ display:flex; align-items:center; justify-content:center; white-space:nowrap; overflow:hidden;
393
+ color:#f4e3b0; font-size:19px; letter-spacing:2px; text-transform:uppercase;
394
+ text-shadow:0 2px 4px #000, 0 0 10px rgba(0,0,0,0.85);
395
+ background:linear-gradient(180deg,rgba(60,44,22,0.96),rgba(28,18,9,0.96));
396
+ border:2px solid #6a5630; border-radius:8px;
397
+ border-image:url(${P}/dlg_banner.png) 330 0 330 0 fill; border-image-width:0 72px;
398
+ border-image-repeat:stretch; }
399
+
400
+ /* THE LINE HE SPEAKS — dark plate for legibility, dlg_frame's ornate gold border on top. */
401
+ #dlg .dlg-say { position:relative; padding:18px 22px; min-height:72px; display:flex; align-items:center;
402
+ color:#f1e6c6; font-family:'Palatino','Book Antiqua',Georgia,serif; font-size:19px; font-style:italic;
403
+ line-height:1.42; text-shadow:0 1px 3px #000;
404
+ background:rgba(18,12,7,0.94); border:2px solid #7a5c30; border-radius:8px;
405
+ box-shadow:0 10px 34px rgba(0,0,0,0.72), inset 0 0 40px rgba(122,92,48,0.10);
406
+ border-image:url(${P}/dlg_frame.png) 200 fill; border-image-width:20px; border-image-repeat:stretch; }
407
+
408
+ /* OPTIONS — a column of plates with the gold flourish down the left gutter. */
409
+ #dlg .dlg-opts { position:relative; display:flex; flex-direction:column; gap:7px; padding-left:26px; }
410
+ #dlg .dlg-opts::before { content:''; position:absolute; left:0; top:3px; bottom:3px; width:16px;
411
+ background:url(${P}/dlg_flourish.png) center/100% 100% no-repeat; opacity:0.9; pointer-events:none; }
412
+ #dlg .dlg-opt { pointer-events:auto; position:relative; text-align:left; cursor:pointer; width:100%;
413
+ padding:11px 16px; color:#d8c79a; font-size:16px; letter-spacing:0.4px; line-height:1.3;
414
+ font-family:inherit; text-shadow:0 1px 2px #000; white-space:normal;
415
+ background:rgba(28,20,10,0.90); border:1px solid #4a3a20; border-radius:6px;
416
+ transition:color .12s, background .12s, transform .08s, box-shadow .12s; }
417
+ #dlg .dlg-opt.dlg-trade { color:#e8c87a; }
418
+ /* highlight — mouse :hover and keyboard .navsel read identically (last-device-wins in JS). The
419
+ FW tracery frame, tinted by the sprite, rides on top of the solid gold border fallback. */
420
+ #dlg .dlg-opt:hover, #dlg .dlg-opt.navsel { color:#fff4d2; background:rgba(58,42,20,0.96);
421
+ border-color:#e8c87a; transform:translateX(3px); box-shadow:0 0 15px rgba(232,200,122,0.28);
422
+ border-image:url(${P}/opt_frame.png) 120 240 120 240 fill; border-image-width:11px 18px;
423
+ border-image-repeat:stretch; }
424
+ /* the "So long" row: a keycap (pad Circle icon or boxed B), tucked right, quieter than a topic */
425
+ #dlg .dlg-bye { align-self:flex-end; width:auto; margin-top:2px; padding:7px 15px 7px 12px;
426
+ color:#b9a878; font-size:14px; letter-spacing:1px; background:rgba(10,7,4,0.92); border-color:#3a2c18; }
427
+ #dlg .dlg-bye:hover, #dlg .dlg-bye.navsel { transform:translateX(0); }
428
+ #dlg .dlg-byekey { display:inline-flex; align-items:center; vertical-align:middle; margin-right:9px; }
429
+ #dlg .dlg-byekey.kbd { width:18px; height:18px; justify-content:center; line-height:17px; font-size:12px;
430
+ font-weight:700; color:#f4e3b0; border:1px solid #8a6a3a; border-radius:4px; background:rgba(40,28,14,0.92); }
431
+ `;
432
+ document.head.appendChild(css);
433
+ }
434
+ }
@@ -0,0 +1,180 @@
1
+ // iconBaker.js — the item satchel's icons are PICTURES OF THE GAME'S OWN PROPS.
2
+ //
3
+ // Instead of hunting item icons in four fantasy/military/sci-fi HUD packs (wrong period, wrong art),
4
+ // we render each item's real 3D prop to one cell of a texture atlas ONCE, on the loading screen. The
5
+ // icon of a whiskey bottle is the whiskey bottle that stands on the Emporium shelf, in the game's own
6
+ // low-poly art, lit the same way. That is exactly why RDR2's satchel reads as a satchel.
7
+ //
8
+ // PERF LAW HOLDS: the rig below is a THROWAWAY scene (never game.scene). Each prop is added to it,
9
+ // shot, and removed inside the same loop turn — nothing is ever added to or removed from the live
10
+ // scene, and the whole bake runs beside the other loading-screen work (warmTextures, the 1.7s chart).
11
+ //
12
+ // THE MECHANISM is tools/map.js:genThumb, which already renders editor-palette props to 128² render
13
+ // targets and reads them back — the same lighting rig, ported to an ortho camera for a distortion-free
14
+ // icon. readRenderTargetPixelsAsync FENCES THE GPU (editorBus.js's item-card lesson: an unfenced
15
+ // drawImage copies a stale/blank frame), so the readback is the fence and no separate wait is needed.
16
+ //
17
+ // COST (loading screen, ~30 items, estimated from the map.js precedent):
18
+ // · model parse — props already resident in the world = cached ~0 ms; a fresh prop parses its fast
19
+ // GLB twin ≈ 5–15 ms (instantiate redirects .fbx→.glb when a texture is given). ~15 fresh ≈ 150 ms.
20
+ // · render — one 128² draw of a low-poly Synty prop ≪ 1 ms each.
21
+ // · readback fence — ~1–3 ms each; ~30 items ≈ 30–90 ms.
22
+ // · toDataURL once ≈ a few ms.
23
+ // TOTAL ≈ 150–300 ms, dominated by any fresh parses and shared with the world when the prop is used.
24
+ // Free at runtime: the atlas is a data URL painted as a CSS background; nothing renders per frame.
25
+ //
26
+ // ATLAS: ~30 icons at 128 px on a ceil(√n)-wide grid ≈ 768×640 → a ~100–300 KB PNG data URL. Bump
27
+ // CELL to 192 for retina crisp (still trivial). A single-RT / setScissor variant could collapse the 30
28
+ // fences to one; the per-item path below is the proven map.js one and its cost is already negligible.
29
+ import * as THREE from 'three/webgpu';
30
+ import { instantiate } from '../core/assets.js';
31
+
32
+ // `dir` picks the atlas the FBX/GLB twin is textured with (assets.js won't auto-link Synty atlases).
33
+ // western props live on the main western atlas; frontier props and the weapons share the frontier one
34
+ // (player.js:63 WEAPON_TEX). Wrong atlas = wrong texels, so this map is load-bearing.
35
+ // prop-dir -> texture atlas routing is GAME data: setIconAtlases({ dir: url, ... })
36
+ let ATLAS_FOR = {};
37
+ export function setIconAtlases(map) { ATLAS_FOR = map ?? {}; }
38
+
39
+ const CELL = 128; // px per icon. 128*4 = 512 is a multiple of 256 → a legal WebGPU readback stride
40
+ // (map.js:207: 160/224 are illegal — row-pad skew). 192 and 256 are also legal.
41
+
42
+ // A fixed 3/4 hero angle: front-on, a touch above and to the side, so a bottle reads as a bottle and a
43
+ // revolver shows its profile. One direction for every prop keeps the satchel looking like a set.
44
+ const VIEW_DIR = new THREE.Vector3(1, 0.72, 1.35).normalize();
45
+
46
+ export class IconBaker {
47
+ constructor(renderer) {
48
+ this.renderer = renderer;
49
+ this.url = null; // the finished atlas, a PNG data URL (null until bake() resolves)
50
+ this.cell = CELL;
51
+ this.cols = 0;
52
+ this.rows = 0;
53
+ this._uv = new Map(); // id -> { col, row }
54
+
55
+ // Throwaway rig. Lighting copied from tools/map.js:208-210 so icons match the editor thumbs that
56
+ // already look right — a bright hemisphere fill plus one key from front-upper-left.
57
+ this.scene = new THREE.Scene();
58
+ this.scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 3));
59
+ const key = new THREE.DirectionalLight(0xffffff, 2.6);
60
+ key.position.set(2, 4, 3);
61
+ this.scene.add(key);
62
+
63
+ // Ortho, not perspective: an icon wants no lens distortion. Frustum is set per-prop in bake().
64
+ this.cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.01, 100);
65
+ this.rt = new THREE.RenderTarget(CELL, CELL); // one small target, read back per item
66
+ }
67
+
68
+ // Render every item's prop into the atlas. `items` is an array of item defs carrying
69
+ // `{ id, icon: { dir, prop } }` (i.e. Object.values(ITEMS) from data/items.js). Idempotent-ish:
70
+ // call once on the loading screen. Resolves to `this` so callers can chain.
71
+ async bake(items) {
72
+ const list = (items || []).filter((it) => it && it.icon && it.icon.prop);
73
+ const n = list.length;
74
+ if (!n) { this.url = null; return this; }
75
+
76
+ this.cols = Math.ceil(Math.sqrt(n));
77
+ this.rows = Math.ceil(n / this.cols);
78
+ const atlas = Object.assign(document.createElement('canvas'), {
79
+ width: this.cols * CELL, height: this.rows * CELL,
80
+ });
81
+ const actx = atlas.getContext('2d');
82
+ const r = this.renderer;
83
+
84
+ // Save every bit of renderer state we touch — this is the LIVE game renderer, and leaving the
85
+ // clear colour transparent or the render target pointed at our RT would wreck the first world frame.
86
+ const prevRT = r.getRenderTarget();
87
+ const prevColor = r.getClearColor(new THREE.Color());
88
+ const prevAlpha = r.getClearAlpha();
89
+ r.setClearColor(0x000000, 0); // transparent: the icon is a cutout over the satchel chrome, not a box
90
+
91
+ const box = new THREE.Box3();
92
+ const center = new THREE.Vector3();
93
+ const sphere = new THREE.Sphere();
94
+
95
+ for (let i = 0; i < n; i++) {
96
+ const it = list[i];
97
+ const col = i % this.cols;
98
+ const row = (i / this.cols) | 0;
99
+ this._uv.set(it.id, { col, row });
100
+
101
+ let model = null;
102
+ try {
103
+ const tex = ATLAS_FOR[it.icon.dir] ?? Object.values(ATLAS_FOR)[0];
104
+ // No `scale`: instantiate hands back the metre GLB twin or the cm FBX (assets.js decides).
105
+ // We never care which — the camera frames the MEASURED bbox, so the scale trap can't bite.
106
+ model = await instantiate(`/assets/${it.icon.dir}/${it.icon.prop}.fbx`, { texture: tex });
107
+ box.setFromObject(model);
108
+ if (box.isEmpty()) { console.warn('[iconBaker] empty bbox for', it.id); continue; }
109
+ box.getBoundingSphere(sphere);
110
+ box.getCenter(center);
111
+ const rad = Math.max(1e-3, sphere.radius);
112
+
113
+ // Ortho frustum snug to the bounding sphere with ~12% air so nothing kisses the cell edge.
114
+ const half = rad * 1.12;
115
+ this.cam.left = -half; this.cam.right = half;
116
+ this.cam.top = half; this.cam.bottom = -half;
117
+ this.cam.near = 0.01; this.cam.far = rad * 12;
118
+ this.cam.position.copy(center).addScaledVector(VIEW_DIR, rad * 4);
119
+ this.cam.up.set(0, 1, 0);
120
+ this.cam.lookAt(center);
121
+ this.cam.updateProjectionMatrix();
122
+
123
+ this.scene.add(model);
124
+ r.setRenderTarget(this.rt);
125
+ r.render(this.scene, this.cam); // autoClear wipes the RT transparent first
126
+ r.setRenderTarget(null);
127
+ const buf = await r.readRenderTargetPixelsAsync(this.rt, 0, 0, CELL, CELL); // fences the GPU
128
+ this.scene.remove(model);
129
+
130
+ const img = actx.createImageData(CELL, CELL);
131
+ img.data.set(buf); // readback is top-left origin — no flip (map.js:234)
132
+ actx.putImageData(img, col * CELL, row * CELL);
133
+ } catch (e) {
134
+ console.warn('[iconBaker] failed for', it.id, e);
135
+ if (model) this.scene.remove(model);
136
+ }
137
+ }
138
+
139
+ r.setRenderTarget(prevRT);
140
+ r.setClearColor(prevColor, prevAlpha);
141
+
142
+ this.url = atlas.toDataURL('image/png');
143
+ return this;
144
+ }
145
+
146
+ // Where an id landed in the atlas grid: { col, row } or null if it wasn't baked.
147
+ uv(id) { return this._uv.get(id) ?? null; }
148
+
149
+ // CSS to paint an id's icon into a size×size DOM box (a satchel slot, a shop row, a dialogue line).
150
+ // Returns the background-* declarations as one string; drop it onto the slot's style. Empty string if
151
+ // the id has no icon, so a missing item degrades to a blank slot rather than a broken-image glyph.
152
+ // PAINT ONE ATLAS CELL, in a way that CANNOT be wrong at the caller's chosen size. The px form
153
+ // (cols*size / -col*size) is only right when the element is EXACTLY `size` px — and the satchel slot
154
+ // told it 96 while the box is really 54 (72 − 2×9 inset), so the atlas blew up to 576 px across a
155
+ // 54 px window and you saw one cropped corner of a cell. Percentages fix it for every caller: a
156
+ // background-size of (cols·100% × rows·100%) makes each cell exactly the element's own size, and a
157
+ // background-position of (col/(cols−1) × row/(rows−1)) in % lands cell (col,row) dead centre of the
158
+ // box whatever its pixels. `size` is now ignored (kept so callers don't break).
159
+ iconCss(id) {
160
+ // The 2D hand-illustrated icon (scripts/gen_item_icons.mjs → /assets/items/<id>.png) sits ON TOP of
161
+ // the baked-3D-prop atlas cell. Where the PNG exists it's opaque (white engraving on black, square) so
162
+ // it fully covers the cell beneath; where it doesn't (a 404), that layer is transparent and the old
163
+ // baked icon shows through — a per-item, no-manifest fallback while the set is filled in.
164
+ const png = `url(/assets/items/${id}.png)`;
165
+ const u = this._uv.get(id);
166
+ if (!u || !this.url) {
167
+ return `background-image:${png};background-size:contain;background-position:center;background-repeat:no-repeat;`;
168
+ }
169
+ const px = this.cols > 1 ? (u.col / (this.cols - 1)) * 100 : 0;
170
+ const py = this.rows > 1 ? (u.row / (this.rows - 1)) * 100 : 0;
171
+ return `background-image:${png},url(${this.url});`
172
+ + `background-size:contain,${this.cols * 100}% ${this.rows * 100}%;`
173
+ + `background-position:center,${px}% ${py}%;`
174
+ + `background-repeat:no-repeat,no-repeat;`;
175
+ }
176
+
177
+ // Optional: free the GPU render target once every panel has read `url`. The atlas data URL and the
178
+ // uv map survive this, so uv()/iconCss() keep working — only the (tiny) 128² RT is released.
179
+ dispose() { this.rt?.dispose?.(); }
180
+ }