sindicate 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/engine.js +284 -0
- package/src/index.js +13 -1
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -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/climbing.js +136 -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/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/enemy.js +442 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/loot.js +303 -0
- package/src/systems/missions.js +418 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/player.js +1619 -0
- package/src/systems/riding.js +580 -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/seatedRider.js +396 -0
- package/src/systems/shop.js +515 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/train.js +1957 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- package/src/world/weather.js +312 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
// THE SHOP — a man behind a counter, and the satchel wearing a second column.
|
|
2
|
+
//
|
|
3
|
+
// The plan is explicit (PLAN §3): the trade view IS the inventory screen with his stock added on the
|
|
4
|
+
// right and Buy/Sell where Use/Drop was. So this module does NOT author a second grid — it borrows the
|
|
5
|
+
// Satchel's slot renderer (ui.satchel.makeSlot) so a can of peaches on his shelf is drawn by the same
|
|
6
|
+
// code, from the same baked icon, as the can in your satchel. Two callers, one grid.
|
|
7
|
+
//
|
|
8
|
+
// THE VIEW (RDR2's store): YOUR satchel on the LEFT (sell at 40%), HIS STOCK on the RIGHT (buy at
|
|
9
|
+
// list), your money top-right. A cursor lives on one column at a time; the arrow keys cross between
|
|
10
|
+
// them at the edges, Enter trades, and the detail strip under the two columns names the selected
|
|
11
|
+
// good and carries the Buy/Sell button.
|
|
12
|
+
//
|
|
13
|
+
// PRICES come from items.js and nowhere else — buyPrice = list value, sellPrice = value*SELL_MULT
|
|
14
|
+
// floored at $1 — so the shop and the satchel can never disagree on the spread. The spread is the
|
|
15
|
+
// economy: a stolen watch buys at $45 and sells at $18, which is the whole point of the valuables tab.
|
|
16
|
+
//
|
|
17
|
+
// MONEY lives where it always has, in loot.js — loot.spend(n) (false + takes nothing if he's short)
|
|
18
|
+
// is the buy, loot.add(n) is the sell. We keep no second purse.
|
|
19
|
+
//
|
|
20
|
+
// STOCK is data/shops.js, keyed by shop id. Infinity = a bottomless staple; a finite count is a
|
|
21
|
+
// one-off that leaves the shelf when bought (and does not come back this pass). Live counts persist
|
|
22
|
+
// on the instance for the session, so a rifle you buy stays bought.
|
|
23
|
+
//
|
|
24
|
+
// PAUSE + FREEZE, exactly like the satchel: ui.shopOpen joins main.js's pause OR so the world holds,
|
|
25
|
+
// and ui.cinematic goes up so the frozen world doesn't read your WASD as walking — it's grid nav now.
|
|
26
|
+
// Both flags saved/restored so the trade view never strands the dialogue camera's use of cinematic.
|
|
27
|
+
//
|
|
28
|
+
// WIRING (the integrator, not this file):
|
|
29
|
+
// · construct once on the loading screen, after the satchel: game.ui.shop = new Shop(game.ui)
|
|
30
|
+
// · point the dialogue's trade hook at it: ui.dialogue.onTrade = (npc) => ui.shop.open(npc)
|
|
31
|
+
// · add its flag to the pause OR (main.js:763): ... || this.ui.shopOpen
|
|
32
|
+
// · per frame while open: if (ui.shop.isOpen()) ui.shop.handleKey(input)
|
|
33
|
+
// · peel it on Escape first (ui.toggleMenu): if (this.shopOpen) return this.shop.close()
|
|
34
|
+
// the goods catalogue + shop tables are GAME data:
|
|
35
|
+
// setShopContent({ items, buyPrice, sellPrice, shops, getShop, shopIdFor, defaultShop, shopOpen, shopHours })
|
|
36
|
+
let SH = { items: {}, buyPrice: () => 0, sellPrice: () => 0, shops: {}, getShop: () => null,
|
|
37
|
+
shopIdFor: () => null, defaultShop: null, shopOpen: () => true, shopHours: [8, 20] };
|
|
38
|
+
export function setShopContent(c) { SH = { ...SH, ...c }; }
|
|
39
|
+
|
|
40
|
+
// 24h → "8am"/"9pm" for the "we're closed" line.
|
|
41
|
+
const fmtHour = (h) => { const ap = (h % 24) < 12 ? 'am' : 'pm'; let hh = (h % 12); if (hh === 0) hh = 12; return `${hh}${ap}`; };
|
|
42
|
+
|
|
43
|
+
// Geometry — CSS px, resolution-independent. Bigger, fewer slots than the standalone satchel: the shop
|
|
44
|
+
// window shows FEWER, LARGER goods per side so the illustrated icons and text read big (Nick's call).
|
|
45
|
+
const SLOT = 120; // was 72. makeSlot scales its icon to the slot, so a bigger plate = a bigger picture.
|
|
46
|
+
const GAP = 14;
|
|
47
|
+
const COLS = 3; // 3 per side (was 5). 3*120 + 2*14 = 388px of grid, centred in each ~490px column —
|
|
48
|
+
// both the satchel (left) and the shop stock (right) get roomy, legible boxes.
|
|
49
|
+
const BRASS = 'rgba(229,214,75,1)';
|
|
50
|
+
const PANELS = '/assets/ui/panels';
|
|
51
|
+
|
|
52
|
+
export class Shop {
|
|
53
|
+
constructor(ui) {
|
|
54
|
+
this.ui = ui;
|
|
55
|
+
this.game = ui.game;
|
|
56
|
+
|
|
57
|
+
this._shopId = SH.defaultShop;
|
|
58
|
+
this._side = 'buy'; // which column the cursor is on: 'buy' (his) | 'sell' (yours)
|
|
59
|
+
this._i = { buy: 0, sell: 0 }; // cursor index within each column
|
|
60
|
+
this._buyList = []; // [{ id, stock }] — his shelf, in shops.js order (sold-out kept, greyed)
|
|
61
|
+
this._sellList = []; // [{ id, n }] — your satchel, one row per item type (aggregated)
|
|
62
|
+
this._stock = new Map(); // shopId → Map(id → live count). Persists for the session.
|
|
63
|
+
this._justOpened = false; // eat the frame the panel appears on (the confirm that opened it)
|
|
64
|
+
this._prevCinematic = false; // restore, never clobber, the dialogue camera's flag
|
|
65
|
+
|
|
66
|
+
this._injectCSS();
|
|
67
|
+
this._buildDOM();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── the contract API ────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
isOpen() { return !!this.ui.shopOpen; }
|
|
72
|
+
|
|
73
|
+
// open(shopId, game): shopId is a shops.js key ('general'/'gunsmith'/'doctor'/'saloon'/'stable') OR
|
|
74
|
+
// the NPC straight off the dialogue trade hook, which we resolve to his counter by trade. `game` is
|
|
75
|
+
// accepted for the integrator's convenience; we already hold it.
|
|
76
|
+
open(shop, game) {
|
|
77
|
+
if (game) this.game = game;
|
|
78
|
+
const id = (typeof shop === 'string') ? shop : SH.shopIdFor(shop);
|
|
79
|
+
// OPENING HOURS: a shut counter won't trade. (The saloon barkeep is already gone at these times, so
|
|
80
|
+
// this bites the general store / gunsmith / doctor / stable — whose keepers stay put — come nightfall.)
|
|
81
|
+
const hour = this.game.world?.sky?.hour ?? 12;
|
|
82
|
+
if (!SH.shopOpen(id, hour)) {
|
|
83
|
+
const win = SH.shopHours[id];
|
|
84
|
+
this.ui.toast?.(win ? `${SH.shops[id]?.name ?? 'Closed'} — open ${fmtHour(win[0])} to ${fmtHour(win[1])}.` : `Closed just now.`);
|
|
85
|
+
this.game.audio?.play?.('ui');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this._shopId = SH.shops[id] ? id : SH.defaultShop;
|
|
89
|
+
|
|
90
|
+
if (!this.isOpen()) this._prevCinematic = this.ui.cinematic;
|
|
91
|
+
this.ui.shopOpen = true; // joins main.js's pause OR — the world holds
|
|
92
|
+
this.ui.cinematic = true; // freeze the player: WASD is grid nav now, not walking
|
|
93
|
+
this.game.input?.releaseLock?.(); // free the cursor for the pointer-events:auto panel
|
|
94
|
+
this._justOpened = true;
|
|
95
|
+
this._side = 'buy'; // you came to buy — land on his goods
|
|
96
|
+
this._i = { buy: 0, sell: 0 };
|
|
97
|
+
|
|
98
|
+
const s = SH.getShop(this._shopId);
|
|
99
|
+
this._nameEl.textContent = s.name;
|
|
100
|
+
this._buyHEl.textContent = s.name;
|
|
101
|
+
this._rebuild();
|
|
102
|
+
this.root.style.display = 'flex';
|
|
103
|
+
this.game.audio?.play?.('ui');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
close() {
|
|
107
|
+
if (!this.isOpen()) { this.root.style.display = 'none'; return; }
|
|
108
|
+
this.ui.shopOpen = false;
|
|
109
|
+
this.ui.cinematic = this._prevCinematic; // hand the flag back exactly as we found it
|
|
110
|
+
this.root.style.display = 'none';
|
|
111
|
+
// do NOT re-request the lock — the next canvas click re-acquires it and input.js drops that click
|
|
112
|
+
// so it never lands as a shot (same contract the map, satchel and dialogue all follow).
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Once per frame while open. Mouse works without it (native :hover + onclick); this is the
|
|
116
|
+
// keyboard/gamepad layer. combatInputs is frozen by cinematic (player.js:753), so Q/arrows can't
|
|
117
|
+
// leak into weapon-cycle or lock-target; we still consume every key we act on.
|
|
118
|
+
handleKey(input) {
|
|
119
|
+
if (!this.isOpen() || !input) return;
|
|
120
|
+
// pollGamepad(anyPanelOpen) CLEARS the synthesized keys while a panel is open (input.js), so a paused
|
|
121
|
+
// counter must read the pad RAW itself, like blackjack does. Read it every frame so edges are detected
|
|
122
|
+
// and _pd stays primed (so the button still held from OPENING us can't fire an action next frame).
|
|
123
|
+
const pad = this._padEdge();
|
|
124
|
+
if (this._justOpened) { this._justOpened = false; return; } // the confirm that opened us isn't ours
|
|
125
|
+
if (input.hit('Escape') || input.hit('KeyB') || pad.circle) { input.consume('Escape'); input.consume('KeyB'); return this.close(); }
|
|
126
|
+
|
|
127
|
+
// switch column — Q / a shoulder button toggles his shelf ↔ your satchel (arrows/stick also cross at the edges)
|
|
128
|
+
if (input.hit('KeyQ') || pad.l1 || pad.r1) { input.consume('KeyQ'); this._switchSide(this._side === 'buy' ? 'sell' : 'buy'); }
|
|
129
|
+
|
|
130
|
+
// move the cursor — arrows / WASD / D-pad / left stick. Left/Right cross between the columns at the boundary.
|
|
131
|
+
const right = input.hit('ArrowRight') || input.hit('KeyD') || pad.right;
|
|
132
|
+
const left = input.hit('ArrowLeft') || input.hit('KeyA') || pad.left;
|
|
133
|
+
const down = input.hit('ArrowDown') || input.hit('KeyS') || pad.down;
|
|
134
|
+
const up = input.hit('ArrowUp') || input.hit('KeyW') || pad.up;
|
|
135
|
+
if (right || left || down || up) this._move({ right, left, down, up });
|
|
136
|
+
|
|
137
|
+
// trade the selected — Enter / Space / Cross. (KeyE is deliberately NOT a confirm here: it is the world
|
|
138
|
+
// interact key, and the shop must never risk tripping a door behind a paused counter.)
|
|
139
|
+
if (input.hit('Enter') || input.hit('Space') || pad.cross) {
|
|
140
|
+
input.consume('Enter'); input.consume('Space');
|
|
141
|
+
this._transact();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// one connected pad, edge-detected against the previous frame (this._pd) so each press acts ONCE.
|
|
146
|
+
// D-pad / left stick → up/down/left/right (dead-zoned); Cross(0) trades · Circle(1) leaves · L1(4)/R1(5)
|
|
147
|
+
// switch side. Returns {} when no pad is present, so keyboard-only play is untouched.
|
|
148
|
+
_padEdge() {
|
|
149
|
+
const pads = typeof navigator !== 'undefined' && navigator.getGamepads ? navigator.getGamepads() : null;
|
|
150
|
+
let gp = null; if (pads) for (const q of pads) if (q && q.connected) { gp = q; break; }
|
|
151
|
+
if (!gp) { this._pd = null; return {}; }
|
|
152
|
+
const b = (i) => !!gp.buttons[i]?.pressed, ax = (i) => gp.axes[i] || 0;
|
|
153
|
+
const st = { cross: b(0), circle: b(1), l1: b(4), r1: b(5),
|
|
154
|
+
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 };
|
|
155
|
+
const pv = this._pd || {};
|
|
156
|
+
const edge = {}; for (const k in st) edge[k] = st[k] && !pv[k];
|
|
157
|
+
this._pd = st;
|
|
158
|
+
return edge;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── navigation ────────────────────────────────────────────────────────────────────────────────
|
|
162
|
+
_list(side) { return side === 'buy' ? this._buyList : this._sellList; }
|
|
163
|
+
|
|
164
|
+
_move({ right, left, down, up }) {
|
|
165
|
+
const side = this._side;
|
|
166
|
+
const len = this._list(side).length;
|
|
167
|
+
let i = this._i[side];
|
|
168
|
+
const col = len ? i % COLS : 0;
|
|
169
|
+
let moved = false;
|
|
170
|
+
|
|
171
|
+
if (right) {
|
|
172
|
+
if (col < COLS - 1 && i + 1 < len) { i++; moved = true; }
|
|
173
|
+
else if (side === 'sell') { this._cross('buy', Math.floor(i / COLS), 0); return; } // off the right of your satchel → his shelf
|
|
174
|
+
}
|
|
175
|
+
if (left) {
|
|
176
|
+
if (col > 0) { i--; moved = true; }
|
|
177
|
+
else if (side === 'buy') { this._cross('sell', Math.floor(i / COLS), COLS - 1); return; } // off his left → your satchel
|
|
178
|
+
}
|
|
179
|
+
if (down && i + COLS < len) { i += COLS; moved = true; }
|
|
180
|
+
if (up && i - COLS >= 0) { i -= COLS; moved = true; }
|
|
181
|
+
|
|
182
|
+
if (moved) { this._i[side] = i; this._syncSel(); this.game.audio?.play?.('ui'); }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Jump to the other column, keeping the row and entering at a chosen column, clamped to that list.
|
|
186
|
+
_cross(toSide, row, enterCol) {
|
|
187
|
+
const list = this._list(toSide);
|
|
188
|
+
if (!list.length) return;
|
|
189
|
+
this._side = toSide;
|
|
190
|
+
let idx = row * COLS + enterCol;
|
|
191
|
+
if (idx >= list.length) idx = list.length - 1;
|
|
192
|
+
if (idx < 0) idx = 0;
|
|
193
|
+
this._i[toSide] = idx;
|
|
194
|
+
this._syncSel();
|
|
195
|
+
this.game.audio?.play?.('ui');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_switchSide(side) {
|
|
199
|
+
if (!this._list(side).length) return;
|
|
200
|
+
this._side = side;
|
|
201
|
+
this._syncSel();
|
|
202
|
+
this.game.audio?.play?.('ui');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── trade ─────────────────────────────────────────────────────────────────────────────────────
|
|
206
|
+
_transact() {
|
|
207
|
+
const row = this._list(this._side)[this._i[this._side]];
|
|
208
|
+
if (!row) return;
|
|
209
|
+
if (this._side === 'buy') this._buy(row.id);
|
|
210
|
+
else this._sell(row.id);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
_buy(id) {
|
|
214
|
+
if (this._curStock(id) <= 0) { this.ui.toast?.(`He's clean out of that.`); this.game.audio?.play?.('ui'); return; }
|
|
215
|
+
const price = SH.buyPrice(id);
|
|
216
|
+
if (!this.game.loot?.spend(price)) { // spend() returns false and takes nothing if he's short
|
|
217
|
+
this.ui.toast?.(`You're short the coin for that.`);
|
|
218
|
+
this.game.audio?.play?.('ui');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.ui.satchel.add(id, 1); // the same add() the loot seam uses
|
|
222
|
+
this._decStock(id);
|
|
223
|
+
this.game.audio?.play?.('coin');
|
|
224
|
+
this.ui.toast?.(`Bought ${SH.items[id].name} — $${price}.`);
|
|
225
|
+
this._rebuild();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
_sell(id) {
|
|
229
|
+
if (!this.ui.satchel.has(id, 1)) return;
|
|
230
|
+
const price = SH.sellPrice(id);
|
|
231
|
+
this.ui.satchel.remove(id, 1);
|
|
232
|
+
this.game.loot?.add(price); // add() plays the coin chime
|
|
233
|
+
this.ui.toast?.(`Sold ${SH.items[id].name} — $${price}.`);
|
|
234
|
+
this._rebuild();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── live stock (persists for the session) ───────────────────────────────────────────────────────
|
|
238
|
+
_liveStock(shopId) {
|
|
239
|
+
let m = this._stock.get(shopId);
|
|
240
|
+
if (!m) {
|
|
241
|
+
m = new Map();
|
|
242
|
+
for (const [id, n] of (SH.getShop(shopId)?.stock || [])) m.set(id, n);
|
|
243
|
+
this._stock.set(shopId, m);
|
|
244
|
+
}
|
|
245
|
+
return m;
|
|
246
|
+
}
|
|
247
|
+
_curStock(id) { return this._liveStock(this._shopId).get(id) ?? 0; }
|
|
248
|
+
_decStock(id) {
|
|
249
|
+
const m = this._liveStock(this._shopId);
|
|
250
|
+
const c = m.get(id);
|
|
251
|
+
if (c !== undefined && c !== Infinity) m.set(id, Math.max(0, c - 1));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── rendering (event-driven: the world is paused, so we rebuild on open / trade only) ──────────
|
|
255
|
+
_rebuild() {
|
|
256
|
+
// his shelf — shops.js order, live counts, sold-out rows kept so the grid doesn't reflow under you
|
|
257
|
+
const live = this._liveStock(this._shopId);
|
|
258
|
+
this._buyList = (SH.getShop(this._shopId)?.stock || [])
|
|
259
|
+
.map(([id]) => ({ id, stock: live.get(id) ?? 0 }))
|
|
260
|
+
.filter((r) => SH.items[r.id]);
|
|
261
|
+
|
|
262
|
+
// your satchel — aggregate the stacks to one row per item type
|
|
263
|
+
const bag = this.game.player?.satchel || [];
|
|
264
|
+
const agg = new Map();
|
|
265
|
+
for (const s of bag) if (SH.items[s.id]) agg.set(s.id, (agg.get(s.id) || 0) + s.n);
|
|
266
|
+
this._sellList = [...agg].map(([id, n]) => ({ id, n }));
|
|
267
|
+
|
|
268
|
+
// clamp cursors; if you've nothing to sell, don't strand the cursor on an empty column
|
|
269
|
+
if (this._i.buy >= this._buyList.length) this._i.buy = Math.max(0, this._buyList.length - 1);
|
|
270
|
+
if (this._i.sell >= this._sellList.length) this._i.sell = Math.max(0, this._sellList.length - 1);
|
|
271
|
+
if (this._side === 'sell' && !this._sellList.length) this._side = 'buy';
|
|
272
|
+
|
|
273
|
+
this._paintGrid(this._sellEl, this._sellList, 'sell', `Nothing to sell.`);
|
|
274
|
+
this._paintGrid(this._buyEl, this._buyList, 'buy', `Counter's bare.`);
|
|
275
|
+
this._syncSel();
|
|
276
|
+
this._syncMoney();
|
|
277
|
+
this._syncFooter();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
_paintGrid(gridEl, list, side, emptyMsg) {
|
|
281
|
+
gridEl.textContent = '';
|
|
282
|
+
if (!list.length) {
|
|
283
|
+
const e = document.createElement('div');
|
|
284
|
+
e.className = 'shop-empty';
|
|
285
|
+
e.textContent = emptyMsg;
|
|
286
|
+
gridEl.appendChild(e);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const money = this.game.loot?.money ?? 0;
|
|
290
|
+
list.forEach((row, i) => {
|
|
291
|
+
const id = row.id;
|
|
292
|
+
const slot = this._makeSlot(id);
|
|
293
|
+
slot.classList.add('shop-slot');
|
|
294
|
+
slot.dataset.side = side;
|
|
295
|
+
slot.dataset.i = String(i);
|
|
296
|
+
|
|
297
|
+
// badge — his shelf shows what's left (∞ for a staple); your satchel shows a count when >1
|
|
298
|
+
const txt = side === 'buy'
|
|
299
|
+
? (row.stock === Infinity ? '∞' : String(row.stock))
|
|
300
|
+
: (row.n > 1 ? String(row.n) : '');
|
|
301
|
+
if (txt) {
|
|
302
|
+
const b = document.createElement('span');
|
|
303
|
+
b.className = 'shop-badge';
|
|
304
|
+
b.textContent = txt;
|
|
305
|
+
slot.appendChild(b);
|
|
306
|
+
}
|
|
307
|
+
if (side === 'buy' && row.stock <= 0) slot.classList.add('sold-out');
|
|
308
|
+
else if (side === 'buy' && money < SH.buyPrice(id)) slot.classList.add('cant-afford');
|
|
309
|
+
|
|
310
|
+
slot.onclick = () => { this._side = side; this._i[side] = i; this._syncSel(); this.game.audio?.play?.('ui'); };
|
|
311
|
+
slot.ondblclick = () => { this._side = side; this._i[side] = i; this._transact(); };
|
|
312
|
+
gridEl.appendChild(slot);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Borrow the Satchel's slot builder (plate + baked icon, no count badge — we add our own). Falls
|
|
317
|
+
// back to a bare plate only if the satchel module isn't up yet, so the shop is never blank.
|
|
318
|
+
_makeSlot(id) {
|
|
319
|
+
if (this.ui.satchel?.makeSlot) return this.ui.satchel.makeSlot(id, 1, SLOT);
|
|
320
|
+
const slot = document.createElement('div');
|
|
321
|
+
slot.className = 'satchel-slot';
|
|
322
|
+
const ic = document.createElement('i');
|
|
323
|
+
ic.className = 'satchel-ic';
|
|
324
|
+
const css = this.ui.itemIcons?.iconCss?.(id, SLOT - 18) || '';
|
|
325
|
+
if (css) ic.style.cssText = css;
|
|
326
|
+
slot.appendChild(ic);
|
|
327
|
+
return slot;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
_syncSel() {
|
|
331
|
+
for (const el of this.root.querySelectorAll('.shop-slot.sel')) el.classList.remove('sel');
|
|
332
|
+
const gridEl = this._side === 'buy' ? this._buyEl : this._sellEl;
|
|
333
|
+
const slots = gridEl.querySelectorAll('.shop-slot');
|
|
334
|
+
slots[this._i[this._side]]?.classList.add('sel');
|
|
335
|
+
this._sellCol.classList.toggle('active', this._side === 'sell');
|
|
336
|
+
this._buyCol.classList.toggle('active', this._side === 'buy');
|
|
337
|
+
this._fillDetail();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
_fillDetail() {
|
|
341
|
+
const d = this.detail;
|
|
342
|
+
const side = this._side;
|
|
343
|
+
const row = this._list(side)[this._i[side]];
|
|
344
|
+
if (!row) { d.innerHTML = `<div class="shop-hint">Nothing to trade at this counter.</div>`; return; }
|
|
345
|
+
|
|
346
|
+
const item = SH.items[row.id];
|
|
347
|
+
const iconCss = this.ui.itemIcons?.iconCss?.(row.id, 84) || '';
|
|
348
|
+
const money = this.game.loot?.money ?? 0;
|
|
349
|
+
|
|
350
|
+
let meta, label, disabled;
|
|
351
|
+
if (side === 'buy') {
|
|
352
|
+
const price = SH.buyPrice(row.id);
|
|
353
|
+
const sold = row.stock <= 0;
|
|
354
|
+
const afford = money >= price;
|
|
355
|
+
const have = this.ui.satchel?.count?.(row.id) ?? 0;
|
|
356
|
+
const stockTxt = row.stock === Infinity ? 'plenty' : (sold ? 'none left' : `${row.stock} left`);
|
|
357
|
+
meta = `<span>His price <b>$${price}</b></span><span>In stock: ${stockTxt}</span><span>You carry ${have}</span>`;
|
|
358
|
+
label = sold ? 'Sold out' : (afford ? `Buy — $${price}` : `Need $${price}`);
|
|
359
|
+
disabled = sold || !afford;
|
|
360
|
+
} else {
|
|
361
|
+
const price = SH.sellPrice(row.id);
|
|
362
|
+
meta = `<span>He pays <b>$${price}</b></span><span>List $${item.value}</span><span>You carry ${row.n}</span>`;
|
|
363
|
+
label = `Sell — $${price}`;
|
|
364
|
+
disabled = row.n <= 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
d.innerHTML = `
|
|
368
|
+
<i class="shop-dicon" style="${iconCss}"></i>
|
|
369
|
+
<div class="shop-dtext">
|
|
370
|
+
<div class="shop-dname">${item.name}</div>
|
|
371
|
+
<div class="shop-dcat">${item.category}</div>
|
|
372
|
+
<div class="shop-dblurb">${item.blurb}</div>
|
|
373
|
+
<div class="shop-dmeta">${meta}</div>
|
|
374
|
+
</div>
|
|
375
|
+
<div class="shop-act">
|
|
376
|
+
<button class="shop-btn ${side}" ${disabled ? 'disabled' : ''}>${label}</button>
|
|
377
|
+
</div>`;
|
|
378
|
+
const btn = d.querySelector('.shop-btn');
|
|
379
|
+
if (btn && !disabled) btn.onclick = () => this._transact();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
_syncMoney() { this._moneyEl.textContent = `$${this.game.loot?.money ?? 0}`; }
|
|
383
|
+
|
|
384
|
+
_syncFooter() {
|
|
385
|
+
const w = Math.round(this.ui.satchel?.weight?.() ?? 0);
|
|
386
|
+
const cap = this.ui.satchel?.carryCap ?? 150;
|
|
387
|
+
this._wEl.textContent = `${w} / ${cap} lb`;
|
|
388
|
+
this._wEl.classList.toggle('warn', w > cap);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── DOM + CSS (built once, on the loading screen) ──────────────────────────────────────────────
|
|
392
|
+
_buildDOM() {
|
|
393
|
+
const root = document.createElement('div');
|
|
394
|
+
root.id = 'shop';
|
|
395
|
+
root.innerHTML = `
|
|
396
|
+
<div class="shop-window">
|
|
397
|
+
<div class="shop-head">
|
|
398
|
+
<div class="shop-name">Store</div>
|
|
399
|
+
<div class="shop-money-wrap"><span class="shop-money-lbl">Money</span> <span class="shop-money"></span></div>
|
|
400
|
+
</div>
|
|
401
|
+
<div class="shop-body">
|
|
402
|
+
<div class="shop-col shop-sell">
|
|
403
|
+
<div class="shop-col-h">Your Satchel</div>
|
|
404
|
+
<div class="shop-grid shop-grid-sell"></div>
|
|
405
|
+
</div>
|
|
406
|
+
<div class="shop-div"></div>
|
|
407
|
+
<div class="shop-col shop-buy active">
|
|
408
|
+
<div class="shop-col-h shop-buy-h">Store</div>
|
|
409
|
+
<div class="shop-grid shop-grid-buy"></div>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
<div class="shop-rule"></div>
|
|
413
|
+
<div class="shop-detail"></div>
|
|
414
|
+
<div class="shop-foot">
|
|
415
|
+
<div class="shop-weight"><span class="shop-flbl">Weight</span> <span class="shop-wval"></span></div>
|
|
416
|
+
<div class="shop-hintbar">Q — switch side · Enter — trade · Esc — leave</div>
|
|
417
|
+
</div>
|
|
418
|
+
</div>`;
|
|
419
|
+
document.body.appendChild(root);
|
|
420
|
+
this.root = root;
|
|
421
|
+
this._nameEl = root.querySelector('.shop-name');
|
|
422
|
+
this._moneyEl = root.querySelector('.shop-money');
|
|
423
|
+
this._sellCol = root.querySelector('.shop-sell');
|
|
424
|
+
this._buyCol = root.querySelector('.shop-buy');
|
|
425
|
+
this._sellEl = root.querySelector('.shop-grid-sell');
|
|
426
|
+
this._buyEl = root.querySelector('.shop-grid-buy');
|
|
427
|
+
this._buyHEl = root.querySelector('.shop-buy-h');
|
|
428
|
+
this.detail = root.querySelector('.shop-detail');
|
|
429
|
+
this._wEl = root.querySelector('.shop-wval');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
_injectCSS() {
|
|
433
|
+
if (document.getElementById('shop-css')) return;
|
|
434
|
+
const css = document.createElement('style');
|
|
435
|
+
css.id = 'shop-css';
|
|
436
|
+
css.textContent = `
|
|
437
|
+
#shop { position:fixed; inset:0; z-index:63; display:none; align-items:center; justify-content:center;
|
|
438
|
+
pointer-events:auto; user-select:none;
|
|
439
|
+
background:radial-gradient(circle at 50% 44%, rgba(24,17,10,0.86), rgba(6,4,3,0.96));
|
|
440
|
+
font-family:Copperplate,'Copperplate Gothic Bold',Palatino,'Book Antiqua',Georgia,serif; color:#e8d9b0; }
|
|
441
|
+
|
|
442
|
+
#shop .shop-window { display:flex; flex-direction:column; box-sizing:border-box;
|
|
443
|
+
width:min(1060px,94vw); height:min(680px,92vh);
|
|
444
|
+
padding:20px 26px 18px; border:22px solid transparent;
|
|
445
|
+
border-image:url(${PANELS}/panel_frame.png) 40 stretch;
|
|
446
|
+
background:rgba(20,14,8,0.96) padding-box;
|
|
447
|
+
box-shadow:0 0 44px rgba(0,0,0,0.85), inset 0 0 70px rgba(0,0,0,0.5); }
|
|
448
|
+
|
|
449
|
+
/* header: shop name (left), money (right) */
|
|
450
|
+
#shop .shop-head { flex:none; display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; }
|
|
451
|
+
#shop .shop-name { font-size:20px; letter-spacing:6px; font-weight:700; color:#e8c98a; text-transform:uppercase; text-shadow:0 2px 6px #000; }
|
|
452
|
+
#shop .shop-money-lbl { color:#9c8858; text-transform:uppercase; font-size:11px; letter-spacing:2px; }
|
|
453
|
+
#shop .shop-money { color:#f0e0b0; font-weight:700; font-size:18px; letter-spacing:1px; margin-left:6px; }
|
|
454
|
+
|
|
455
|
+
/* two columns */
|
|
456
|
+
#shop .shop-body { flex:1; min-height:0; display:flex; gap:22px; }
|
|
457
|
+
#shop .shop-col { flex:1; min-width:0; min-height:0; display:flex; flex-direction:column; }
|
|
458
|
+
#shop .shop-col-h { flex:none; text-align:center; font-size:12px; letter-spacing:2px; text-transform:uppercase;
|
|
459
|
+
color:#9c8858; padding:2px 0 8px; border-bottom:2px solid transparent; margin-bottom:6px; }
|
|
460
|
+
#shop .shop-col.active .shop-col-h { color:#f0e0b0; border-bottom-color:${BRASS}; text-shadow:0 1px 3px #000; }
|
|
461
|
+
#shop .shop-div { flex:none; width:2px; align-self:stretch;
|
|
462
|
+
background:linear-gradient(180deg, transparent, #6a5630 12%, #6a5630 88%, transparent); opacity:0.7; }
|
|
463
|
+
|
|
464
|
+
#shop .shop-grid { flex:1; min-height:0; overflow-y:auto; overflow-x:hidden;
|
|
465
|
+
display:grid; grid-template-columns:repeat(${COLS}, ${SLOT}px); gap:${GAP}px;
|
|
466
|
+
justify-content:center; align-content:start; padding:6px 2px; }
|
|
467
|
+
#shop .shop-empty { grid-column:1 / -1; color:#8a7a54; font-size:13px; opacity:0.8; padding:22px 6px; text-align:center; }
|
|
468
|
+
|
|
469
|
+
/* slots — the plate + icon come from the satchel's makeSlot (.satchel-slot/.satchel-ic); these
|
|
470
|
+
rules re-scope that plate under #shop, and .shop-* carries the trade-view states on top. */
|
|
471
|
+
#shop .satchel-slot { position:relative; width:${SLOT}px; height:${SLOT}px; box-sizing:border-box;
|
|
472
|
+
cursor:pointer; border:12px solid transparent;
|
|
473
|
+
border-image:url(${PANELS}/slot.png) 60 fill / 12px stretch; }
|
|
474
|
+
#shop .satchel-ic { position:absolute; inset:9px; background-repeat:no-repeat; }
|
|
475
|
+
#shop .shop-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); }
|
|
476
|
+
#shop .shop-slot.sel { box-shadow:0 0 0 2px ${BRASS}, 0 0 14px rgba(229,214,75,0.55); filter:brightness(1.1); }
|
|
477
|
+
#shop .shop-slot.cant-afford { opacity:0.6; }
|
|
478
|
+
#shop .shop-slot.sold-out { filter:grayscale(1) brightness(0.45); cursor:default; }
|
|
479
|
+
#shop .shop-badge { position:absolute; right:6px; bottom:4px; font:700 16px/1 ui-monospace,monospace;
|
|
480
|
+
color:#f4ecc8; text-shadow:0 1px 2px #000, 0 0 3px #000; padding:1px 3px; }
|
|
481
|
+
|
|
482
|
+
/* the rule + detail strip under the two columns */
|
|
483
|
+
#shop .shop-rule { flex:none; height:8px; margin:10px 0 8px;
|
|
484
|
+
background:url(${PANELS}/rule.png) center/100% 100% no-repeat; opacity:0.85; }
|
|
485
|
+
#shop .shop-detail { flex:none; display:flex; align-items:center; gap:18px; min-height:104px; padding:2px 6px; }
|
|
486
|
+
#shop .shop-dicon { flex:none; width:84px; height:84px; background-repeat:no-repeat;
|
|
487
|
+
filter:drop-shadow(0 3px 6px rgba(0,0,0,0.6)); }
|
|
488
|
+
#shop .shop-dtext { flex:1; min-width:0; }
|
|
489
|
+
#shop .shop-dname { font-size:18px; font-weight:700; color:#f0e0b0; letter-spacing:1px; text-shadow:0 1px 3px #000; }
|
|
490
|
+
#shop .shop-dcat { font-size:11px; letter-spacing:2px; text-transform:uppercase; color:#9c8858; margin-top:1px; }
|
|
491
|
+
#shop .shop-dblurb { font-size:13px; line-height:1.45; color:#c9b892; font-style:italic; margin:6px 0;
|
|
492
|
+
font-family:Palatino,'Book Antiqua',Georgia,serif; max-width:64ch; }
|
|
493
|
+
#shop .shop-dmeta { display:flex; gap:20px; flex-wrap:wrap; font-size:12px; color:#cdbb8e; letter-spacing:0.5px; }
|
|
494
|
+
#shop .shop-dmeta b { color:#e8c87a; }
|
|
495
|
+
#shop .shop-hint { color:#8a7a54; font-size:14px; opacity:0.85; padding:22px 0; text-align:center; width:100%; }
|
|
496
|
+
|
|
497
|
+
#shop .shop-act { flex:none; }
|
|
498
|
+
#shop .shop-btn { cursor:pointer; padding:12px 22px; font:700 15px/1 inherit; letter-spacing:1px;
|
|
499
|
+
text-transform:uppercase; color:#f0e0b0; background:rgba(60,42,20,0.75); border:1px solid #7a5c30;
|
|
500
|
+
border-radius:5px; min-width:158px; text-align:center; }
|
|
501
|
+
#shop .shop-btn.sell { color:#e6d29a; }
|
|
502
|
+
#shop .shop-btn:hover:not([disabled]) { background:rgba(96,68,34,0.9); border-color:#e8c87a; color:#fff4c8; }
|
|
503
|
+
#shop .shop-btn[disabled] { opacity:0.42; cursor:default; }
|
|
504
|
+
|
|
505
|
+
/* footer: carry weight (soft cap, warn only) + the controls hint */
|
|
506
|
+
#shop .shop-foot { flex:none; display:flex; justify-content:space-between; align-items:center;
|
|
507
|
+
font-size:12px; letter-spacing:1px; padding:6px 4px 0; margin-top:6px; color:#9c8858; }
|
|
508
|
+
#shop .shop-flbl { text-transform:uppercase; font-size:11px; letter-spacing:2px; }
|
|
509
|
+
#shop .shop-wval { color:#f0e0b0; font-weight:700; margin-left:6px; }
|
|
510
|
+
#shop .shop-wval.warn { color:#e0a94b; }
|
|
511
|
+
#shop .shop-hintbar { color:#8a7a54; }
|
|
512
|
+
`;
|
|
513
|
+
document.head.appendChild(css);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Sky birds: one flock = ONE VAT InstancedMesh (like herd.js, but airborne). Each bird flies its own
|
|
2
|
+
// slow orbit around the flock's centre, banking into the turn and mixing flap and glide. Resident for
|
|
3
|
+
// life, one draw call per flock.
|
|
4
|
+
//
|
|
5
|
+
// Nick asked for eagles by name, so these are eagles — the pack ships Eagle_Flying and a perched
|
|
6
|
+
// Eagle_Idle, and the badlands are the right country for them. NO SEAGULLS: this county is eight
|
|
7
|
+
// hundred miles from the sea, and fable's flock (which is where this is ported from) is a gull flock.
|
|
8
|
+
//
|
|
9
|
+
// TWO DIFFERENCES FROM FABLE'S:
|
|
10
|
+
// • THE FLOCK CAN BE ANCHORED. Fable's flock centre drifts after the player forever, so there are
|
|
11
|
+
// always birds overhead. That is right for a coastal town and wrong for a county: an eagle that
|
|
12
|
+
// follows you into the saloon is not an eagle. An `over: {x,z}` flock circles ITS OWN ground —
|
|
13
|
+
// the badlands, the quarry — and you ride to it. The drifting kind is kept for the one flock that
|
|
14
|
+
// should be everywhere (a pair of high birds, the kind you always see in an empty sky).
|
|
15
|
+
// • The gate is the flock's own radius, not a hardcoded 400 m.
|
|
16
|
+
import * as THREE from 'three/webgpu';
|
|
17
|
+
import { vatMaterial } from './vat.js';
|
|
18
|
+
|
|
19
|
+
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _p = new THREE.Vector3(), _e = new THREE.Euler();
|
|
20
|
+
|
|
21
|
+
export class Flock {
|
|
22
|
+
// `over: {x,z}` = circle THIS ground and stay there. Omit it and the flock trails the player.
|
|
23
|
+
constructor(game, species, count = 6, { alt = 40, spread = 60, over = null } = {}) {
|
|
24
|
+
this.game = game;
|
|
25
|
+
this.over = over;
|
|
26
|
+
this.scale = species.scale;
|
|
27
|
+
this.ci = species.clipIndex;
|
|
28
|
+
this._scl = new THREE.Vector3(this.scale, this.scale, this.scale);
|
|
29
|
+
|
|
30
|
+
const geo = species.geometry.clone();
|
|
31
|
+
const aClip = new Float32Array(count), aPhase = new Float32Array(count), aTint = new Float32Array(count * 3);
|
|
32
|
+
const aClipAttr = new THREE.InstancedBufferAttribute(aClip, 1);
|
|
33
|
+
aClipAttr.setUsage(THREE.DynamicDrawUsage);
|
|
34
|
+
geo.setAttribute('aClip', aClipAttr);
|
|
35
|
+
geo.setAttribute('aPhase', new THREE.InstancedBufferAttribute(aPhase, 1));
|
|
36
|
+
geo.setAttribute('aTint', new THREE.InstancedBufferAttribute(aTint, 3));
|
|
37
|
+
if (species.layers > 1) geo.setAttribute('aLayer', new THREE.InstancedBufferAttribute(new Float32Array(count), 1));
|
|
38
|
+
this.aClip = aClip; this._aClipAttr = aClipAttr;
|
|
39
|
+
this.mesh = new THREE.InstancedMesh(geo, vatMaterial(species.vat, species.albedo, species.layers), count);
|
|
40
|
+
this.mesh.name = 'flock:eagle';
|
|
41
|
+
|
|
42
|
+
this.centre = new THREE.Vector3();
|
|
43
|
+
this.alt = alt;
|
|
44
|
+
this.birds = [];
|
|
45
|
+
for (let i = 0; i < count; i++) {
|
|
46
|
+
this.birds.push({
|
|
47
|
+
r: spread * (0.35 + Math.random() * 0.65), // orbit radius
|
|
48
|
+
a: Math.random() * Math.PI * 2, // orbit angle
|
|
49
|
+
w: (0.05 + Math.random() * 0.06) * (Math.random() < 0.5 ? 1 : -1), // rad/s, either way — a raptor turns SLOWLY
|
|
50
|
+
h: alt * (0.7 + Math.random() * 0.6), // altitude above the ground under it
|
|
51
|
+
bob: Math.random() * Math.PI * 2,
|
|
52
|
+
});
|
|
53
|
+
aPhase[i] = Math.random() * 6;
|
|
54
|
+
// an eagle GLIDES; it does not flap its way across a county. Two in three are on the glide slot.
|
|
55
|
+
aClip[i] = Math.random() < 0.34 ? (this.ci.walk ?? 0) : (this.ci.idle ?? 0);
|
|
56
|
+
const b = 0.9 + Math.random() * 0.2;
|
|
57
|
+
aTint[i * 3] = b; aTint[i * 3 + 1] = b; aTint[i * 3 + 2] = b;
|
|
58
|
+
}
|
|
59
|
+
this._clipT = 1.5;
|
|
60
|
+
|
|
61
|
+
if (over) {
|
|
62
|
+
// an anchored flock never leaves its own sky — so it gets a real bounding sphere and is culled
|
|
63
|
+
// like anything else. A drifting one is always near the player by construction.
|
|
64
|
+
const gy = game.world?.groundAt?.(over.x, over.z) ?? 0;
|
|
65
|
+
this.mesh.boundingSphere = new THREE.Sphere(new THREE.Vector3(over.x, gy + alt, over.z), spread + alt + 30);
|
|
66
|
+
this.mesh.frustumCulled = true;
|
|
67
|
+
this.gate2 = (spread + 700) ** 2; // eagles are the one thing you are MEANT to see from far off
|
|
68
|
+
} else {
|
|
69
|
+
this.mesh.frustumCulled = false;
|
|
70
|
+
this.gate2 = Infinity;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Place every instance in its sky NOW. An anchored flock's update() early-returns while the
|
|
74
|
+
// player is far (the gate) BEFORE it ever runs the placement loop — so without this its instances
|
|
75
|
+
// sit at the identity matrix, i.e. at (0,0,0), flapping in the ground, until you finally ride up
|
|
76
|
+
// to it (Nick: "birds at 0,0,0 in the floor"). Culling then hides them by their bounding sphere.
|
|
77
|
+
const cx = over?.x ?? 0, cz = over?.z ?? 0;
|
|
78
|
+
this.centre.set(cx, 0, cz);
|
|
79
|
+
for (let i = 0; i < count; i++) {
|
|
80
|
+
const b = this.birds[i];
|
|
81
|
+
const x = cx + Math.cos(b.a) * b.r, z = cz + Math.sin(b.a) * b.r;
|
|
82
|
+
const y = (game.world?.groundAt?.(x, z) ?? 0) + b.h;
|
|
83
|
+
_m.compose(_p.set(x, y, z), _q.identity(), this._scl);
|
|
84
|
+
this.mesh.setMatrixAt(i, _m);
|
|
85
|
+
}
|
|
86
|
+
this.mesh.instanceMatrix.needsUpdate = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
update(dt, playerPos) {
|
|
90
|
+
this.mesh.material.userData.uTime.value += dt; // the VAT clock — without it the bake is frozen on frame 0
|
|
91
|
+
const c = this.centre;
|
|
92
|
+
if (this.over) {
|
|
93
|
+
c.set(this.over.x, 0, this.over.z);
|
|
94
|
+
const dx = playerPos.x - c.x, dz = playerPos.z - c.z;
|
|
95
|
+
if (dx * dx + dz * dz > this.gate2) return; // nobody within sight of that sky
|
|
96
|
+
} else {
|
|
97
|
+
if (!this._init) { this._init = true; c.set(playerPos.x + 40, 0, playerPos.z - 60); }
|
|
98
|
+
// drift the flock centre toward the player's country — never snap
|
|
99
|
+
const dx = playerPos.x - c.x, dz = playerPos.z - c.z, d = Math.hypot(dx, dz);
|
|
100
|
+
if (d > 90) { const k = Math.min(3, d / 120) * 4 * dt; c.x += (dx / d) * k; c.z += (dz / d) * k; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// occasionally a bird switches glide ↔ flap. That is all "soaring" is.
|
|
104
|
+
this._clipT -= dt;
|
|
105
|
+
if (this._clipT <= 0) {
|
|
106
|
+
this._clipT = 1.4 + Math.random() * 2.6;
|
|
107
|
+
const i = (Math.random() * this.birds.length) | 0;
|
|
108
|
+
this.aClip[i] = this.aClip[i] === (this.ci.idle ?? 0) ? (this.ci.walk ?? 0) : (this.ci.idle ?? 0);
|
|
109
|
+
this._aClipAttr.needsUpdate = true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const g = this.game.world;
|
|
113
|
+
for (let i = 0; i < this.birds.length; i++) {
|
|
114
|
+
const b = this.birds[i];
|
|
115
|
+
b.a += b.w * dt;
|
|
116
|
+
b.bob += dt;
|
|
117
|
+
const x = c.x + Math.cos(b.a) * b.r;
|
|
118
|
+
const z = c.z + Math.sin(b.a) * b.r;
|
|
119
|
+
const y = g.groundAt(x, z) + b.h + Math.sin(b.bob * 0.35) * 4.0; // the long slow rise and fall of a thermal
|
|
120
|
+
// heading = the orbit's tangent; the bank rolls INTO the turn
|
|
121
|
+
const vx = -Math.sin(b.a) * b.w, vz = Math.cos(b.a) * b.w;
|
|
122
|
+
const heading = Math.atan2(vx, vz) + Math.PI; // +PI: the rig faces −Z, so without this it flies tail-first
|
|
123
|
+
const bank = Math.max(-0.5, Math.min(0.5, b.w * b.r * 0.09));
|
|
124
|
+
_m.compose(_p.set(x, y, z), _q.setFromEuler(_e.set(0, heading, bank)), this._scl);
|
|
125
|
+
this.mesh.setMatrixAt(i, _m);
|
|
126
|
+
}
|
|
127
|
+
this.mesh.instanceMatrix.needsUpdate = true;
|
|
128
|
+
}
|
|
129
|
+
}
|