sindicate 0.4.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/package.json +1 -1
- package/src/systems/casino/blackjack.js +187 -0
- package/src/systems/casino/cards3d.js +55 -0
- package/src/systems/casino/coins3d.js +75 -0
- package/src/systems/casino/table3d.js +130 -0
- package/src/systems/coach.js +1034 -0
- package/src/systems/combat.js +586 -0
- package/src/systems/crime.js +582 -0
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +442 -0
- package/src/systems/loot.js +303 -0
- package/src/systems/missions.js +418 -0
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +1619 -0
- package/src/systems/roadGraph.js +264 -0
- package/src/systems/satchel.js +500 -0
- package/src/systems/scenes.js +105 -0
- package/src/systems/shop.js +515 -0
- package/src/systems/train.js +1957 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|