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,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
|
+
}
|