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.
Files changed (70) hide show
  1. package/README.md +63 -2
  2. package/docs/api/README.md +28 -0
  3. package/docs/api/anim.md +125 -0
  4. package/docs/api/assets.md +208 -0
  5. package/docs/api/collision.md +101 -0
  6. package/docs/api/fbxloader.md +50 -0
  7. package/docs/api/grass.md +103 -0
  8. package/docs/api/input.md +164 -0
  9. package/docs/api/renderer.md +115 -0
  10. package/docs/api/retarget.md +149 -0
  11. package/docs/api/shatter.md +121 -0
  12. package/docs/api/utils-decals.md +188 -0
  13. package/docs/api/water.md +143 -0
  14. package/docs/api/wind-weather-uniforms.md +105 -0
  15. package/docs/contracts/render.md +32 -0
  16. package/docs/contracts/time-feel.md +52 -0
  17. package/package.json +1 -1
  18. package/src/core/engine.js +284 -0
  19. package/src/index.js +13 -1
  20. package/src/systems/aim.js +114 -0
  21. package/src/systems/campfire.js +150 -0
  22. package/src/systems/casino/blackjack.js +187 -0
  23. package/src/systems/casino/cards3d.js +55 -0
  24. package/src/systems/casino/coins3d.js +75 -0
  25. package/src/systems/casino/table3d.js +130 -0
  26. package/src/systems/climbing.js +136 -0
  27. package/src/systems/coach.js +1034 -0
  28. package/src/systems/combat.js +586 -0
  29. package/src/systems/crime.js +582 -0
  30. package/src/systems/deadeye.js +332 -0
  31. package/src/systems/encounters.js +123 -0
  32. package/src/systems/enemy.js +442 -0
  33. package/src/systems/entity.js +524 -0
  34. package/src/systems/fistCurl.js +38 -0
  35. package/src/systems/footsteps.js +161 -0
  36. package/src/systems/glass.js +67 -0
  37. package/src/systems/herd.js +277 -0
  38. package/src/systems/horse.js +361 -0
  39. package/src/systems/loot.js +303 -0
  40. package/src/systems/missions.js +418 -0
  41. package/src/systems/mountedTraveller.js +518 -0
  42. package/src/systems/nav.js +343 -0
  43. package/src/systems/npc.js +880 -0
  44. package/src/systems/pathFollow.js +107 -0
  45. package/src/systems/platform.js +484 -0
  46. package/src/systems/player.js +1619 -0
  47. package/src/systems/riding.js +580 -0
  48. package/src/systems/roadGraph.js +264 -0
  49. package/src/systems/satchel.js +500 -0
  50. package/src/systems/scenes.js +105 -0
  51. package/src/systems/seatedRider.js +396 -0
  52. package/src/systems/shop.js +515 -0
  53. package/src/systems/skybirds.js +129 -0
  54. package/src/systems/train.js +1957 -0
  55. package/src/systems/trainCamera.js +414 -0
  56. package/src/systems/traveller.js +254 -0
  57. package/src/systems/tumbleweeds.js +92 -0
  58. package/src/systems/vat.js +327 -0
  59. package/src/systems/vfx.js +472 -0
  60. package/src/world/blobShadows.js +109 -0
  61. package/src/world/clouds.js +61 -0
  62. package/src/world/flora.js +87 -0
  63. package/src/world/footprints.js +117 -0
  64. package/src/world/lampField.js +58 -0
  65. package/src/world/lampGlow.js +92 -0
  66. package/src/world/scatter.js +1077 -0
  67. package/src/world/sky.js +363 -0
  68. package/src/world/tileManager.js +197 -0
  69. package/src/world/walkHumps.js +74 -0
  70. package/src/world/weather.js +312 -0
@@ -0,0 +1,187 @@
1
+ // BLACKJACK ENGINE — pure rules + state machine, no three.js and no DOM. MULTI-SEAT: one human seat +
2
+ // any number of AI seats, all versus the house. 6-deck shoe, dealer stands on 17 (incl. soft 17), 3:2
3
+ // on a natural, Hit / Stand / Double (no split/insurance). AI seats hit under 17. The 3D table and the
4
+ // HUD subscribe to the events and read the public state — they never touch the rules.
5
+ //
6
+ // TICKABLE: call update(dt) each frame. The engine paces the AI seats, the dealer's draws and the
7
+ // end-of-round hold itself. Each seat's stake is reserved by the caller before start(); the seat's
8
+ // result reports the signed delta to credit back (see onResult).
9
+
10
+ export const SUITS = ['spades', 'hearts', 'diamonds', 'clubs']; // suit index 0..3
11
+ export const RANK_LABEL = ['', 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']; // rank 1..13
12
+
13
+ export const PHASE = { IDLE: 'idle', DEALING: 'dealing', PLAYER: 'player', DEALER: 'dealer', PAYOUT: 'payout' };
14
+ const AI_DELAY = 0.9; // seconds an AI seat waits before each hit/stand
15
+ const DEALER_STEP = 0.6; // seconds between the dealer's own draws
16
+ const PAYOUT_HOLD = 2.6; // seconds the result sits before the round clears
17
+
18
+ export function cardValue(rank) { return rank >= 10 ? 10 : rank; }
19
+ export function handValue(cards) {
20
+ let v = 0, aces = 0;
21
+ for (const c of cards) { v += cardValue(c.rank); if (c.rank === 1) aces++; }
22
+ while (aces > 0 && v + 10 <= 21) { v += 10; aces--; }
23
+ return v;
24
+ }
25
+ export function isSoft(cards) {
26
+ let v = 0, aces = 0;
27
+ for (const c of cards) { v += cardValue(c.rank); if (c.rank === 1) aces++; }
28
+ return aces > 0 && v + 10 <= 21;
29
+ }
30
+ export function isBlackjack(cards) { return cards.length === 2 && handValue(cards) === 21; }
31
+
32
+ class Shoe {
33
+ constructor(decks = 6, rng = Math.random) { this.decks = decks; this.rng = rng; this.build(); }
34
+ build() {
35
+ this.cards = [];
36
+ for (let d = 0; d < this.decks; d++)
37
+ for (let s = 0; s < 4; s++)
38
+ for (let r = 1; r <= 13; r++) this.cards.push({ rank: r, suit: s });
39
+ this.shuffle();
40
+ }
41
+ shuffle() {
42
+ for (let i = this.cards.length - 1; i > 0; i--) {
43
+ const j = (this.rng() * (i + 1)) | 0;
44
+ const t = this.cards[i]; this.cards[i] = this.cards[j]; this.cards[j] = t;
45
+ }
46
+ }
47
+ get needsReshuffle() { return this.cards.length < 52; }
48
+ draw() { if (this.cards.length === 0) this.build(); return this.cards.pop(); }
49
+ }
50
+
51
+ export class Blackjack {
52
+ constructor(opts = {}) {
53
+ this.minBet = opts.minBet ?? 5;
54
+ this.maxBet = opts.maxBet ?? 500;
55
+ this.shoe = new Shoe(opts.decks ?? 6, opts.rng);
56
+ this.phase = PHASE.IDLE;
57
+ this.seats = []; // [{ id, ai, cards, bet, standing, busted, result }] — turn order = insertion order
58
+ this.dealer = [];
59
+ this.holeRevealed = false;
60
+ this.turn = -1; // index into seats of the seat whose turn it is (-1 = none)
61
+ this._t = 0;
62
+ this._pendingSettle = false;
63
+ // callbacks the presentation layer wires up (all optional)
64
+ this.onDeal = null; // (targetId:'dealer'|seatId, card, faceUp, index)
65
+ this.onReveal = null; // (holeCard)
66
+ this.onPhase = null; // (phase)
67
+ this.onTurn = null; // (seatId | null) — whose turn it is now
68
+ this.onResult = null; // (seatId, { outcome, delta })
69
+ this.onClear = null; // () — round fully over, wipe the felt
70
+ }
71
+
72
+ addSeat(id, ai = false) { const s = { id, ai, cards: [], bet: 0, standing: false, busted: false, result: null }; this.seats.push(s); return s; }
73
+ seat(id) { return this.seats.find((s) => s.id === id); }
74
+ get current() { return this.turn >= 0 ? this.seats[this.turn] : null; }
75
+ handValue(id) { const s = this.seat(id); return s ? handValue(s.cards) : 0; }
76
+ get dealerValue() { return handValue(this.dealer); }
77
+ get dealerUpValue() { return this.holeRevealed ? handValue(this.dealer) : cardValue(this.dealer[0]?.rank ?? 0); }
78
+ canDouble(id, chips) { const s = this.seat(id); return this.phase === PHASE.PLAYER && this.current === s && !s.ai && s.cards.length === 2 && chips >= s.bet; }
79
+
80
+ _setPhase(p) { this.phase = p; this.onPhase?.(p); }
81
+ _give(target, faceUp) {
82
+ const arr = target === 'dealer' ? this.dealer : target.cards;
83
+ const c = this.shoe.draw();
84
+ arr.push(c);
85
+ this.onDeal?.(target === 'dealer' ? 'dealer' : target.id, c, faceUp, arr.length - 1);
86
+ return c;
87
+ }
88
+
89
+ // clear the table back to IDLE WITHOUT dealing — used when the player sits into an ambient hand or
90
+ // stands up. Fires onClear so the presentation wipes the felt.
91
+ reset() {
92
+ for (const s of this.seats) { s.cards = []; s.bet = 0; s.standing = false; s.busted = false; s.result = null; }
93
+ this.dealer = []; this.holeRevealed = false; this._t = 0; this.turn = -1; this._pendingSettle = false;
94
+ this._setPhase(PHASE.IDLE);
95
+ this.onClear?.();
96
+ }
97
+
98
+ // begin a round. `bets` is a map seatId -> stake (already reserved by the caller)
99
+ start(bets = {}) {
100
+ if (this.shoe.needsReshuffle) this.shoe.build();
101
+ for (const s of this.seats) { s.cards = []; s.bet = bets[s.id] ?? this.minBet; s.standing = false; s.busted = false; s.result = null; }
102
+ this.dealer = []; this.holeRevealed = false; this._t = 0; this.turn = -1;
103
+ this._setPhase(PHASE.DEALING);
104
+ for (const s of this.seats) this._give(s, true); // round 1: each seat up
105
+ this._give('dealer', true); // dealer up
106
+ for (const s of this.seats) this._give(s, true); // round 2: each seat up
107
+ this._give('dealer', false); // dealer hole (down)
108
+ for (const s of this.seats) if (isBlackjack(s.cards)) s.standing = true; // naturals auto-stand
109
+ if (isBlackjack(this.dealer)) { this._enterDealer(); return this.phase; }
110
+ this._advanceTurn(-1);
111
+ return this.phase;
112
+ }
113
+
114
+ _advanceTurn(from) {
115
+ for (let i = from + 1; i < this.seats.length; i++) {
116
+ const s = this.seats[i];
117
+ if (!s.standing && !s.busted) { this.turn = i; this._t = 0; this._setPhase(PHASE.PLAYER); this.onTurn?.(s.id); return; }
118
+ }
119
+ this.turn = -1; this.onTurn?.(null); this._enterDealer();
120
+ }
121
+
122
+ // human input, only honoured for the current non-AI seat
123
+ hit(id) { const s = this.current; if (s && s.id === id && !s.ai && this.phase === PHASE.PLAYER) this._hit(s); }
124
+ stand(id) { const s = this.current; if (s && s.id === id && !s.ai && this.phase === PHASE.PLAYER) { s.standing = true; this._advanceTurn(this.turn); } }
125
+ double(id) {
126
+ const s = this.current;
127
+ if (!s || s.id !== id || s.ai || this.phase !== PHASE.PLAYER || s.cards.length !== 2) return;
128
+ s.bet *= 2; this._give(s, true); s.standing = true;
129
+ if (handValue(s.cards) > 21) s.busted = true;
130
+ this._advanceTurn(this.turn);
131
+ }
132
+ _hit(s) {
133
+ this._give(s, true);
134
+ if (handValue(s.cards) > 21) { s.busted = true; s.standing = true; this._advanceTurn(this.turn); }
135
+ }
136
+
137
+ update(dt) {
138
+ if (this.phase === PHASE.PLAYER) {
139
+ const s = this.current;
140
+ if (s && s.ai) { // AI seat plays itself, paced
141
+ this._t += dt;
142
+ if (this._t >= AI_DELAY) {
143
+ this._t = 0;
144
+ if (handValue(s.cards) < 17) this._hit(s);
145
+ else { s.standing = true; this._advanceTurn(this.turn); }
146
+ }
147
+ }
148
+ } else if (this.phase === PHASE.DEALER) {
149
+ this._t += dt;
150
+ if (this._t < DEALER_STEP) return;
151
+ this._t = 0;
152
+ if (!this._pendingSettle && this.dealerValue < 17) { this._give('dealer', true); return; }
153
+ this._settle();
154
+ } else if (this.phase === PHASE.PAYOUT) {
155
+ this._t += dt;
156
+ if (this._t >= PAYOUT_HOLD) { this._t = 0; this._setPhase(PHASE.IDLE); this.onClear?.(); }
157
+ }
158
+ }
159
+
160
+ _enterDealer() {
161
+ this._setPhase(PHASE.DEALER);
162
+ this.holeRevealed = true; this.onReveal?.(this.dealer[1]);
163
+ this._t = 0;
164
+ // if every seat busted (or a natural is out), the dealer needn't draw
165
+ const anyAlive = this.seats.some((s) => !s.busted);
166
+ this._pendingSettle = !anyAlive || isBlackjack(this.dealer) || this.seats.every((s) => isBlackjack(s.cards));
167
+ }
168
+
169
+ // resolve every seat vs the dealer; `delta` is the amount to CREDIT back (stake was reserved on start)
170
+ _settle() {
171
+ const dv = this.dealerValue, dbj = isBlackjack(this.dealer);
172
+ for (const s of this.seats) {
173
+ const pv = handValue(s.cards), pbj = isBlackjack(s.cards);
174
+ let outcome, credit;
175
+ if (s.busted) { outcome = 'bust'; credit = 0; }
176
+ else if (pbj && !dbj) { outcome = 'blackjack'; credit = Math.floor(s.bet * 2.5); }
177
+ else if (dbj && !pbj) { outcome = 'lose'; credit = 0; }
178
+ else if (pbj && dbj) { outcome = 'push'; credit = s.bet; }
179
+ else if (dv > 21 || pv > dv) { outcome = 'win'; credit = s.bet * 2; }
180
+ else if (pv < dv) { outcome = 'lose'; credit = 0; }
181
+ else { outcome = 'push'; credit = s.bet; }
182
+ s.result = { outcome, delta: credit };
183
+ this.onResult?.(s.id, s.result);
184
+ }
185
+ this._setPhase(PHASE.PAYOUT);
186
+ }
187
+ }
@@ -0,0 +1,55 @@
1
+ // 3D PLAYING CARDS — a clean public-domain vector deck, one PNG per card in public/assets/casino/cards/
2
+ // (<rank><suit>.png, e.g. AS.png, 10H.png, KC.png, plus back.png). A card is a thin quad: top face = the
3
+ // card's own texture, bottom face = the shared back. Sized to the Kiwi card (~0.06 × 0.09 m). All 52 faces
4
+ // + the back preload in initCards() so makeCard() stays synchronous for the dealer.
5
+ import * as THREE from 'three/webgpu';
6
+ import { loadTexture } from '../../core/assets.js';
7
+
8
+ export const CARD_W = 0.062;
9
+ export const CARD_L = 0.090; // deck aspect ≈ 0.69
10
+ const RANK = ['', 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']; // rank 1..13
11
+ const SUITL = ['S', 'H', 'D', 'C']; // matches engine SUITS: spades, hearts, diamonds, clubs
12
+
13
+ const _tex = {};
14
+ let _ready = null;
15
+
16
+ async function load(name) {
17
+ if (_tex[name]) return _tex[name];
18
+ const t = await loadTexture(`/assets/casino/cards/${name}.png`);
19
+ t.colorSpace = THREE.SRGBColorSpace;
20
+ t.anisotropy = 8;
21
+ _tex[name] = t;
22
+ return t;
23
+ }
24
+
25
+ export async function initCards() {
26
+ if (_ready) return _ready;
27
+ _ready = (async () => {
28
+ const jobs = [load('back')];
29
+ for (let s = 0; s < 4; s++) for (let r = 1; r <= 13; r++) jobs.push(load(`${RANK[r]}${SUITL[s]}`));
30
+ await Promise.all(jobs);
31
+ })();
32
+ return _ready;
33
+ }
34
+
35
+ // A card lying flat: face (rank+suit) on +Y, back on −Y. suit is an index 0..3; rank is 1..13. Flip
36
+ // face-down by rotating the group 180° about its long (Z) axis — then the back reads up.
37
+ export function makeCard(rank, suit) {
38
+ const g = new THREE.Group();
39
+ const faceTex = _tex[`${RANK[rank]}${SUITL[suit]}`] || _tex['back'];
40
+
41
+ const face = new THREE.Mesh(new THREE.PlaneGeometry(CARD_W, CARD_L),
42
+ new THREE.MeshBasicNodeMaterial({ map: faceTex, toneMapped: true })); // opaque white card art
43
+ face.rotation.x = -Math.PI / 2; // lie flat, facing +Y
44
+ face.position.y = 0.0016;
45
+ g.add(face);
46
+
47
+ const back = new THREE.Mesh(new THREE.PlaneGeometry(CARD_W, CARD_L),
48
+ new THREE.MeshBasicNodeMaterial({ map: _tex['back'], toneMapped: true }));
49
+ back.rotation.x = Math.PI / 2; // facing −Y
50
+ back.position.y = -0.0016;
51
+ g.add(back);
52
+
53
+ g.userData = { rank, suit };
54
+ return g;
55
+ }
@@ -0,0 +1,75 @@
1
+ // GOLD COINS — the live wager on the felt, from Synty's PolygonCasino coin (SM_Prop_Coin_01, converted
2
+ // to public/assets/casino/coin_01.glb). The GLB renders ~0.11 m across at native scale, so a stack is
3
+ // built native-size and the whole group is shrunk to a believable coin and given a plain gold PBR
4
+ // material — no atlas needed. A CoinStack tracks the current bet: raise it and a coin DROPS in from
5
+ // just above with a little weight + bounce; lower it and a coin comes off the top.
6
+ import * as THREE from 'three/webgpu';
7
+ import { loadGLB } from '../../core/assets.js';
8
+
9
+ const STACK_SCALE = 0.4; // native 0.11 m coin → ~0.045 m
10
+ const NATIVE_THICK = 0.016; // native coin thickness (m), before STACK_SCALE
11
+ const DROP = 0.09; // native units a coin falls from (≈ 3.6 mm above rest after scale)
12
+ const GRAV = -3.2; // native units/s² — falls the drop in ~0.25 s
13
+ const REST = 0.3; // bounce restitution
14
+ const STOP = 0.12; // |v| below which a coin is done bouncing
15
+ const TAU = Math.PI * 2;
16
+
17
+ let _proto = null, _mat = null, _ready = null;
18
+
19
+ export async function initCoins() {
20
+ if (_ready) return _ready;
21
+ _ready = (async () => {
22
+ _proto = await loadGLB('/assets/casino/coin_01.glb'); // keep the whole node (its baked scale)
23
+ _mat = new THREE.MeshStandardNodeMaterial({ color: 0xd8a52a, metalness: 0.85, roughness: 0.34 });
24
+ })();
25
+ return _ready;
26
+ }
27
+
28
+ // how many coins to show for a bet — a readable stack, not a literal count (min a few, capped)
29
+ export function coinsForBet(bet) { return Math.max(3, Math.min(16, Math.round(bet / 5) + 2)); }
30
+
31
+ // A growable/shrinkable stack of gold coins. `group` sits at the bet spot (base on y=0, stacks +Y).
32
+ export class CoinStack {
33
+ constructor() {
34
+ this.group = new THREE.Group();
35
+ this.group.scale.setScalar(STACK_SCALE);
36
+ this.coins = [];
37
+ }
38
+ count() { return this.coins.length; }
39
+
40
+ // match the stack to `n` coins — new ones drop in, extras come off the top
41
+ setCount(n) {
42
+ n = Math.max(0, n);
43
+ while (this.coins.length < n) this._add();
44
+ while (this.coins.length > n) this._remove();
45
+ }
46
+ _add() {
47
+ const i = this.coins.length;
48
+ const c = _proto.clone(true);
49
+ c.traverse((o) => { if (o.isMesh) o.material = _mat; });
50
+ c.rotation.y = Math.random() * TAU; // vary edges so it's not a clean cylinder
51
+ const restY = NATIVE_THICK * (i + 0.5);
52
+ c.position.y = restY + DROP; // start just above its resting height
53
+ this.group.add(c);
54
+ this.coins.push({ c, restY, v: 0, delay: Math.random() * 0.03, done: false });
55
+ }
56
+ _remove() {
57
+ const o = this.coins.pop();
58
+ if (o) this.group.remove(o.c);
59
+ }
60
+
61
+ // per-frame: let any un-settled coins fall + jingle to rest
62
+ settle(dt) {
63
+ for (const o of this.coins) {
64
+ if (o.done) continue;
65
+ if (o.delay > 0) { o.delay -= dt; continue; }
66
+ o.v += GRAV * dt;
67
+ o.c.position.y += o.v * dt;
68
+ if (o.c.position.y <= o.restY) { // hit the coin below (or the felt)
69
+ o.c.position.y = o.restY;
70
+ if (Math.abs(o.v) < STOP) { o.v = 0; o.done = true; }
71
+ else o.v = -o.v * REST; // little jingle
72
+ }
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,130 @@
1
+ // BLACKJACK TABLE — the 3D presentation on top of the pure engine (blackjack.js). MULTI-SEAT: every
2
+ // seat (you + AI) and the dealer has an anchor on the felt where its cards land; the table subscribes
3
+ // to the engine's events so a dealt card becomes a card flying from the deck with the arc + flip.
4
+ // No DOM here — the HUD is separate; this owns the felt (cards, deck, the human's coin wager).
5
+ //
6
+ // Anchors are LOCAL to the root Group placed at the table: { id, x, z, yaw } — yaw is the way that
7
+ // seat faces (cards read toward it), cards spread along the seat's "right".
8
+ import * as THREE from 'three/webgpu';
9
+ import { Blackjack, PHASE, handValue } from './blackjack.js';
10
+ import { makeCard, initCards } from './cards3d.js';
11
+ import { initCoins, CoinStack, coinsForBet } from './coins3d.js';
12
+
13
+ const DEAL_TRAVEL = 0.32; // s, card flight
14
+ const DEAL_ARC = 0.14; // m, hop peak
15
+ const DEAL_GAP = 0.15; // s, between successive deals in a burst
16
+ const SPREAD = 0.052; // m, gap between cards in a hand
17
+ const CARD_LIFT = 0.006; // m above the felt
18
+
19
+ const _pp = new THREE.Vector3(), _q0 = new THREE.Quaternion(), _q1 = new THREE.Quaternion();
20
+ const ease = (t) => t * t * (3 - 2 * t);
21
+
22
+ export class BlackjackTable {
23
+ // opts: { tableY, seats:[{id,x,z,yaw,ai}], dealer:{x,z,yaw}, deckPos:{x,z}, humanId, minBet, maxBet }
24
+ constructor(opts = {}) {
25
+ this.root = new THREE.Group();
26
+ this.tableY = opts.tableY ?? 0;
27
+ this.humanId = opts.humanId ?? 'you';
28
+ this.anchors = {};
29
+ for (const s of opts.seats) this.anchors[s.id] = s;
30
+ this.anchors['dealer'] = opts.dealer;
31
+ this.deck = { x: opts.deckPos?.x ?? 0, z: opts.deckPos?.z ?? 0.14 };
32
+ this.game = new Blackjack({ minBet: opts.minBet ?? 5, maxBet: opts.maxBet ?? 500 });
33
+ for (const s of opts.seats) this.game.addSeat(s.id, !!s.ai);
34
+ this.meshes = {}; // id -> [card meshes]
35
+ for (const id of Object.keys(this.anchors)) this.meshes[id] = [];
36
+ this._queue = []; this._gap = 0; this._anims = [];
37
+ this._wire();
38
+ }
39
+
40
+ async load() {
41
+ await Promise.all([initCards(), initCoins()]);
42
+ // the DRAW PILE (shoe)
43
+ this.deckPile = new THREE.Group();
44
+ for (let i = 0; i < 16; i++) {
45
+ const c = makeCard(1, 0);
46
+ c.rotation.set(Math.PI, (Math.random() - 0.5) * 0.12, 0);
47
+ c.position.set(this.deck.x, this.tableY + CARD_LIFT + i * 0.0016, this.deck.z);
48
+ this.deckPile.add(c);
49
+ }
50
+ this.root.add(this.deckPile);
51
+ // the human's coin wager sits just inboard of their card row
52
+ const h = this.anchors[this.humanId];
53
+ this.betCoins = new CoinStack();
54
+ this.betCoins.group.position.set(h.x * 0.72, this.tableY, h.z * 0.72);
55
+ this.root.add(this.betCoins.group);
56
+ return this;
57
+ }
58
+
59
+ _wire() {
60
+ const g = this.game;
61
+ g.onDeal = (id, card, faceUp) => this._queue.push({ id, card, faceUp });
62
+ g.onReveal = () => { const m = this.meshes['dealer'][1]; if (m) this._flip(m, true); };
63
+ g.onClear = () => this._clearFelt();
64
+ }
65
+
66
+ // where card `i` of a hand sits at seat `id` (centred, spread along the seat's right)
67
+ _slot(id, i, total) {
68
+ const a = this.anchors[id];
69
+ const rx = Math.cos(a.yaw), rz = -Math.sin(a.yaw); // right ⟂ forward=(sin,cos)
70
+ const off = (i - (total - 1) / 2) * SPREAD;
71
+ return _pp.set(a.x + rx * off, this.tableY + CARD_LIFT + i * 0.001, a.z + rz * off);
72
+ }
73
+ _relayout(id) {
74
+ const arr = this.meshes[id];
75
+ for (let i = 0; i < arr.length; i++) {
76
+ const m = arr[i]; if (m.userData._flying) continue;
77
+ const s = this._slot(id, i, arr.length); m.position.set(s.x, s.y, s.z);
78
+ }
79
+ }
80
+
81
+ _startDeal(job) {
82
+ const { id, card, faceUp } = job;
83
+ const a = this.anchors[id], arr = this.meshes[id];
84
+ const mesh = makeCard(card.rank, card.suit);
85
+ mesh.rotation.set(Math.PI, a.yaw, 0); // start face-down, facing the seat
86
+ mesh.position.set(this.deck.x, this.tableY + 0.02, this.deck.z);
87
+ this.root.add(mesh); arr.push(mesh); this._relayout(id);
88
+ const dest = this._slot(id, arr.length - 1, arr.length);
89
+ mesh.userData._flying = true;
90
+ _q0.setFromEuler(new THREE.Euler(Math.PI, a.yaw, 0));
91
+ _q1.setFromEuler(new THREE.Euler(faceUp ? 0 : Math.PI, a.yaw, 0));
92
+ this._anims.push({
93
+ mesh, t: 0, dur: DEAL_TRAVEL, arc: DEAL_ARC,
94
+ p0: new THREE.Vector3(this.deck.x, this.tableY + 0.02, this.deck.z), p1: dest.clone(),
95
+ q0: _q0.clone(), q1: _q1.clone(),
96
+ onDone: () => { mesh.userData._flying = false; this._relayout(id); },
97
+ });
98
+ }
99
+ _flip(mesh, faceUp) {
100
+ _q1.setFromEuler(new THREE.Euler(faceUp ? 0 : Math.PI, mesh.rotation.y, 0));
101
+ this._anims.push({ mesh, t: 0, dur: 0.22, arc: 0, p0: mesh.position.clone(), p1: mesh.position.clone(), q0: mesh.quaternion.clone(), q1: _q1.clone() });
102
+ }
103
+ _clearFelt() {
104
+ for (const id of Object.keys(this.meshes)) { for (const m of this.meshes[id]) this.root.remove(m); this.meshes[id] = []; }
105
+ this._queue.length = 0; this._anims.length = 0; // drop in-flight/pending cards too, or a reset (e.g. sitting into an ambient hand) redeals them onto the just-cleared felt as ghosts
106
+ }
107
+
108
+ setBet(amount) { this.betCoins?.setCount(coinsForBet(amount)); }
109
+ deal(bets) { this._clearFelt(); this._queue.length = 0; this._anims.length = 0; this.game.start(bets); }
110
+ hit(id) { this.game.hit(id); }
111
+ stand(id) { this.game.stand(id); }
112
+ double(id) { this.game.double(id); }
113
+ // true while cards are still flying/pacing — the HUD uses it to know the felt is settling
114
+ get busy() { return this._queue.length > 0 || this._anims.length > 0; }
115
+
116
+ update(dt) {
117
+ this.betCoins?.settle(dt);
118
+ this._gap -= dt;
119
+ if (this._queue.length && this._gap <= 0) { this._startDeal(this._queue.shift()); this._gap = DEAL_GAP; }
120
+ for (let i = this._anims.length - 1; i >= 0; i--) {
121
+ const a = this._anims[i]; a.t += dt;
122
+ const k = Math.min(1, a.t / a.dur), e = ease(k);
123
+ _pp.lerpVectors(a.p0, a.p1, e); _pp.y += Math.sin(k * Math.PI) * a.arc;
124
+ a.mesh.position.copy(_pp);
125
+ a.mesh.quaternion.slerpQuaternions(a.q0, a.q1, e);
126
+ if (k >= 1) { a.onDone?.(); this._anims.splice(i, 1); }
127
+ }
128
+ if (!this._queue.length && !this._anims.length) this.game.update(dt); // engine paces AI/dealer only once the felt has caught up
129
+ }
130
+ }
@@ -0,0 +1,136 @@
1
+ // Climbing: the player's ladder controller — ported from fable/src/game/climbing.js and adapted to
2
+ // western. The climb clips (climbMount/Up/Down/Top/Idle) are already retargeted onto the player rig
3
+ // through the normal clip pipeline (clips.js → the default Synty rig), so this just snaps the player to
4
+ // a registered ladder and runs mount → up/down loop (code-driven vertical) → climb-off.
5
+ //
6
+ // Ladders come from world.ladders (town.js buildLadders): [{ base:Vector3(world), quat:Quaternion(world),
7
+ // height }]. main.js's E-contest resolves the nearest one and calls mount(l). Mirrors Riding's
8
+ // "takes over the player's update + camera" shape; player.update early-returns while active.
9
+ import * as THREE from 'three/webgpu';
10
+
11
+ const CLIMB_SPEED = 1.7; // m/s along the rungs (match the up/down loop cadence)
12
+ const ANIM_SPEED = 2; // clip playback ×; the mocap loops are slow, so 2× keeps the hands with the movement
13
+ const BODY_OFF = 0.30; // player root sits this far back from the ladder line (gripping side)
14
+ const FACE_SIGN = 1; // +1 for western's ladder props (fable used −1); flips which side the climber grips + faces
15
+ const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
16
+ const _axis = new THREE.Vector3(), _face = new THREE.Vector3();
17
+
18
+ export class Climbing {
19
+ constructor(player) {
20
+ this.player = player;
21
+ this.game = player.game;
22
+ this.mode = 'off'; // off | mounting | climbing | dismounting
23
+ this.ladder = null; this.h = 0; this.faceYaw = 0; this.height = 0;
24
+ }
25
+
26
+ get active() { return this.mode !== 'off'; } // getter — never assign it; forceOff() resets the raw fields
27
+
28
+ // up-the-rungs axis (from the prop quat) + the horizontal yaw to face INTO the ladder
29
+ _solve(l) {
30
+ _axis.set(0, 1, 0).applyQuaternion(l.quat).normalize(); // climb direction
31
+ _face.set(_axis.x, 0, _axis.z); // a leaning ladder tilts away from the wall
32
+ if (_face.lengthSq() < 1e-3) _face.set(0, 0, 1).applyQuaternion(l.quat).setY(0); // vertical ladder → prop +Z
33
+ _face.normalize();
34
+ this.faceYaw = Math.atan2(FACE_SIGN * _face.x, FACE_SIGN * _face.z);
35
+ }
36
+
37
+ // Cap the climb at the SURFACE you step onto. The ladder MESH runs from its base to its own top, which
38
+ // sits above the roofline (a real ladder pokes past the eaves). Raycast down over the ladder top, nudged
39
+ // toward the building, for the highest roof/floor at/below the mesh top, and top the climb out there so
40
+ // you don't climb off into thin air. Falls back to the mesh height if no surface is found.
41
+ _resolveHeight(l) {
42
+ const W = this.game.world;
43
+ const bvhs = [W.buildingBVH, ...(W.sideBVHs || [])].filter((b) => b && b.raycast);
44
+ if (!bvhs.length || !W._snapRay) return l.height;
45
+ _axis.set(0, 1, 0).applyQuaternion(l.quat).normalize();
46
+ const sx = l.base.x + _axis.x * l.height + Math.sin(this.faceYaw) * 0.7; // over the top, onto the roof
47
+ const sz = l.base.z + _axis.z * l.height + Math.cos(this.faceYaw) * 0.7;
48
+ const ray = W._snapRay; ray.origin.set(sx, l.base.y + l.height + 4, sz); ray.direction.set(0, -1, 0);
49
+ let roof = -Infinity;
50
+ for (const b of bvhs) {
51
+ const hs = b.raycast(ray, 2) || [];
52
+ for (const h of hs) {
53
+ const n = h.face?.normal;
54
+ if (n && Math.abs(n.y) > 0.4 && h.point.y > roof && h.point.y <= l.base.y + l.height + 0.3) roof = h.point.y;
55
+ }
56
+ }
57
+ return roof > l.base.y + 1 ? Math.min(l.height, roof - l.base.y + 0.15) : l.height;
58
+ }
59
+
60
+ // ENTRY — main.js's E-contest resolves the nearest ladder and calls this. Enters at the bottom, or the
61
+ // top if you walked onto it from the roof platform.
62
+ mount(l) {
63
+ if (this.active || !l) return;
64
+ this._solve(l);
65
+ this.height = this._resolveHeight(l); // cap the climb at the surface you step ONTO (the mesh pokes above)
66
+ // Enter at YOUR height on the rungs — project (you − base) onto the climb axis — so grabbing from the
67
+ // street enters at the bottom, from the roof at the top, and RE-GRABBING MID-AIR (after a hop-off)
68
+ // continues from where you are instead of snapping to an end.
69
+ _axis.set(0, 1, 0).applyQuaternion(l.quat).normalize();
70
+ const p = this.player.position;
71
+ const hAlong = (p.x - l.base.x) * _axis.x + (p.y - l.base.y) * _axis.y + (p.z - l.base.z) * _axis.z;
72
+ this.ladder = l; this.mode = 'mounting'; this.h = clamp(hAlong, 0, this.height);
73
+ this._place(); this.player.busy = true;
74
+ this.player.animator.play('climbMount', { once: true, fade: 0.12, timeScale: ANIM_SPEED, onDone: () => { if (this.mode === 'mounting') this.mode = 'climbing'; } });
75
+ }
76
+
77
+ // place the player at height h along the rungs, set back from the ladder by BODY_OFF, facing it
78
+ _place() {
79
+ const l = this.ladder, p = this.player;
80
+ _axis.set(0, 1, 0).applyQuaternion(l.quat).normalize();
81
+ p.position.x = l.base.x + _axis.x * this.h - Math.sin(this.faceYaw) * BODY_OFF;
82
+ p.position.z = l.base.z + _axis.z * this.h - Math.cos(this.faceYaw) * BODY_OFF;
83
+ p.position.y = l.base.y + _axis.y * this.h;
84
+ p._visualY = p.position.y; // keep the model lift glued to the climb height
85
+ p.heading = this.faceYaw; p.root.rotation.y = this.faceYaw;
86
+ }
87
+
88
+ update(dt, input, camera) {
89
+ const p = this.player, l = this.ladder;
90
+ const ui = this.game.ui;
91
+ const inMenu = ui?.anyPanelOpen || ui?.dialogueOpen || ui?.cinematic;
92
+ if (this.mode === 'climbing') {
93
+ let v = 0;
94
+ if (!inMenu) { if (input.down('KeyW')) v += 1; if (input.down('KeyS')) v -= 1; } // held W/S
95
+ p.animator.play(v > 0 ? 'climbUp' : v < 0 ? 'climbDown' : 'climbIdle', { fade: 0.12, timeScale: ANIM_SPEED });
96
+ this.h = clamp(this.h + v * CLIMB_SPEED * dt, 0, this.height);
97
+ this._place();
98
+ if (this.h >= this.height && v > 0) { // reached the top → climb off
99
+ this.mode = 'dismounting';
100
+ p.animator.play('climbTop', { once: true, fade: 0.12, timeScale: ANIM_SPEED, onDone: () => this._dismount(true) });
101
+ } else if (this.h <= 0 && v < 0) this._dismount(false); // stepped off the bottom
102
+ else if (!inMenu && input.hit('Space')) this._dismount(false); // hop off anywhere
103
+ } else {
104
+ this._place(); // mounting | dismounting — hold while the clip plays
105
+ }
106
+ // Reuse the player's OWN look + follow camera (not a bespoke one) so the on-foot ↔ climb transition is
107
+ // seamless — _camera reads position + _visualY, which _place() keeps synced to the climb height.
108
+ if (!inMenu) p._look?.(input, this.game.rawDt ?? dt);
109
+ p._camera(dt, input, camera);
110
+ }
111
+
112
+ _dismount(top) {
113
+ const l = this.ladder, p = this.player;
114
+ if (top) { // step forward off the top onto the surface
115
+ _axis.set(0, 1, 0).applyQuaternion(l.quat).normalize();
116
+ p.position.x = l.base.x + _axis.x * this.height + Math.sin(this.faceYaw) * 0.7;
117
+ p.position.z = l.base.z + _axis.z * this.height + Math.cos(this.faceYaw) * 0.7;
118
+ p.position.y = l.base.y + _axis.y * this.height + 0.1;
119
+ }
120
+ this.mode = 'off'; this.ladder = null;
121
+ // PIN _visualY to the current feet height, NOT undefined: this same frame the climb update still calls
122
+ // p._camera(), which reads `_visualY + 1.55` — undefined there = NaN = a grey (void) screen. position.y
123
+ // is valid, and the on-foot smoother continues cleanly from it next frame.
124
+ p.busy = false; p.vy = 0; p.airborne = false; p._visualY = p.position.y;
125
+ p.animator.play('idle', { fade: 0.15 });
126
+ }
127
+
128
+ // Hard exit for death / fast-travel / load — a LIVE controller re-glues the player to the ladder every
129
+ // frame via _place(), silently undoing any teleport. Reset the raw fields (never assign .active).
130
+ forceOff() {
131
+ if (!this.active) return;
132
+ const p = this.player;
133
+ this.mode = 'off'; this.ladder = null;
134
+ p.busy = false; p.vy = 0; p.airborne = false; p._visualY = p.position.y;
135
+ }
136
+ }