minimojs 1.0.0-alpha.21 → 1.0.0-alpha.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,355 @@
1
+ import { Game } from "./minimo.js";
2
+ /**
3
+ * One card on a {@link CardTable}.
4
+ *
5
+ * A card is plain, serializable data. `id` must be unique on its table and
6
+ * `faceUp` says which side is showing. Every other field belongs to the game:
7
+ * a suit and rank, a value, a name, a cost, or anything else its rules need.
8
+ *
9
+ * Change where a card is with {@link CardTable.move} and which side shows with
10
+ * {@link CardTable.flip}. The table animates every change on its own.
11
+ */
12
+ export interface Card {
13
+ /** Unique id on its table. Used by snapshots and by face textures. */
14
+ id: string;
15
+ /** `true` when the face is showing, `false` when the back is showing. */
16
+ faceUp: boolean;
17
+ /** Game-defined fields such as `suit`, `rank`, or `value`. */
18
+ [field: string]: any;
19
+ }
20
+ /** Suit of a card in {@link standardDeck}. */
21
+ export type StandardSuit = "spades" | "hearts" | "diamonds" | "clubs";
22
+ /** A card from the standard 52-card French deck built by {@link standardDeck}. */
23
+ export interface StandardCard extends Card {
24
+ suit: StandardSuit;
25
+ /** `1` is the ace, `11` the jack, `12` the queen, and `13` the king. */
26
+ rank: number;
27
+ color: "red" | "black";
28
+ }
29
+ /** The four standard suits, in {@link standardDeck} order. */
30
+ export declare const STANDARD_SUITS: readonly StandardSuit[];
31
+ /**
32
+ * Builds the standard 52-card French deck, face down, ordered by suit and then
33
+ * by rank from ace to king.
34
+ *
35
+ * Ids are `"<suit>-<rank>"`, for example `"hearts-12"` for the queen of hearts.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * const deck = shuffle(standardDeck());
40
+ * ```
41
+ */
42
+ export declare function standardDeck(): StandardCard[];
43
+ /**
44
+ * Returns the printed rank of a standard card: `"A"`, `"2"` to `"10"`, `"J"`,
45
+ * `"Q"`, or `"K"`.
46
+ */
47
+ export declare function rankLabel(rank: number): string;
48
+ /**
49
+ * Shuffles an array in place and returns it.
50
+ *
51
+ * Without a seed the order is random. With a seed the order is always the
52
+ * same for that seed, which makes a deal reproducible: a numbered deal, a
53
+ * daily challenge, or a bug report that can be replayed.
54
+ *
55
+ * @param items - The array to shuffle. It is modified.
56
+ * @param seed - Optional integer seed for a reproducible order.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * shuffle(deck); // a different deal every time
61
+ * shuffle(deck, 1234); // deal number 1234, the same every time
62
+ * ```
63
+ */
64
+ export declare function shuffle<T>(items: T[], seed?: number): T[];
65
+ /**
66
+ * How a {@link CardTable} composes the fronts of {@link StandardCard}s from a
67
+ * game's own images. Every visual part is an image the game loaded; only the
68
+ * rank is text, in a web font the game declared, so a card looks the same on
69
+ * every device.
70
+ *
71
+ * Layout: the blank front fills the card, the rank sits in the top-left
72
+ * corner, the suit symbol in the top-right corner, and the middle shows the
73
+ * suit symbol again or, for a jack, queen, or king, its court art.
74
+ */
75
+ export interface StandardFace {
76
+ /**
77
+ * Image key of an empty card front: its paper, border, and shadow. It is
78
+ * stretched to the card size, so draw it at the card's proportions.
79
+ */
80
+ blank: string;
81
+ /** Image key of each suit symbol. */
82
+ suits: Record<StandardSuit, string>;
83
+ /** Image keys of the court art. A court without one shows its suit symbol. */
84
+ courts?: {
85
+ jack?: string;
86
+ queen?: string;
87
+ king?: string;
88
+ };
89
+ /**
90
+ * CSS font family of the rank, such as `'"Cinzel"'`. It must be a web font
91
+ * the page declares and the game requires with `game.requireFont`, never a
92
+ * system font, whose look changes from one device to the next.
93
+ */
94
+ font: string;
95
+ /** Font weight of the rank. Default `"700"`. */
96
+ fontWeight?: string;
97
+ /** Rank color on hearts and diamonds. Default `"#9c1a1a"`. */
98
+ red?: string;
99
+ /** Rank color on spades and clubs. Default `"#1b1f1d"`. */
100
+ black?: string;
101
+ }
102
+ /**
103
+ * Which cards a drag takes from a pile when the player grabs one of them.
104
+ *
105
+ * - `"none"`: cards cannot be dragged out of this pile. Taps still arrive.
106
+ * - `"top"`: only the top card can be dragged.
107
+ * - `"tail"`: the grabbed card and every card above it move together, as in
108
+ * a solitaire column.
109
+ * - `"any"`: any card can be dragged, alone, as from a hand.
110
+ *
111
+ * Face-down cards are never dragged.
112
+ */
113
+ export type CardDragRule = "none" | "top" | "tail" | "any";
114
+ /**
115
+ * Layout and interaction of one {@link CardPile}.
116
+ *
117
+ * Offsets describe how far each card sits from the card below it. With both
118
+ * offsets at `0`, the default, the pile is a stack where only the top card
119
+ * shows. A positive `offsetY` fans the pile downward like a solitaire column.
120
+ */
121
+ export interface CardPileOptions {
122
+ /** Horizontal distance after a face-up card. Default `0`. */
123
+ offsetX?: number;
124
+ /** Vertical distance after a face-up card. Default `0`. */
125
+ offsetY?: number;
126
+ /** Horizontal distance after a face-down card. Defaults to `offsetX`. */
127
+ faceDownOffsetX?: number;
128
+ /** Vertical distance after a face-down card. Defaults to `offsetY`. */
129
+ faceDownOffsetY?: number;
130
+ /**
131
+ * Longest distance, in pixels, from the first card to the last. When a fan
132
+ * would grow past it, the offsets shrink so the pile still fits. Default: no
133
+ * limit.
134
+ */
135
+ maxSpread?: number;
136
+ /** Which cards a drag takes. Default `"top"`. See {@link CardDragRule}. */
137
+ drag?: CardDragRule;
138
+ }
139
+ /** Timing of one {@link CardTable.move} or {@link CardTable.flip}. */
140
+ export interface CardMoveOptions {
141
+ /** Wait this long before the cards start moving. Default `0`. */
142
+ delayMs?: number;
143
+ /** Travel time. `0` places the cards instantly. Default: the table's `moveMs`. */
144
+ durationMs?: number;
145
+ /** Turn every moved card to this side as it travels. Default: unchanged. */
146
+ faceUp?: boolean;
147
+ }
148
+ /** Construction options of a {@link CardTable}. */
149
+ export interface CardTableOptions {
150
+ /** Card width in game pixels. */
151
+ cardWidth: number;
152
+ /** Card height in game pixels. */
153
+ cardHeight: number;
154
+ /**
155
+ * The card fronts, always made of the game's own images:
156
+ *
157
+ * - a {@link StandardFace} composes every card of {@link standardDeck} from
158
+ * a blank front, four suit symbols, optional court art, and a web font for
159
+ * the rank;
160
+ * - a function returning an image key gives each card its own complete
161
+ * front, for any other deck: numbered colors, tarot, or invented cards.
162
+ */
163
+ face: StandardFace | ((card: Card) => string);
164
+ /** Image key of the card back, scaled to fit the card. */
165
+ back: string;
166
+ /**
167
+ * Lowest sprite layer the table uses. Resting cards sit just above it,
168
+ * travelling cards at `layer + 500`, and dragged cards at `layer + 1000`.
169
+ * Default `10`.
170
+ */
171
+ layer?: number;
172
+ /** Default travel time of a card, in milliseconds. Default `260`. */
173
+ moveMs?: number;
174
+ /** Time of a full flip, in milliseconds. Default `220`. */
175
+ flipMs?: number;
176
+ /** Pointer travel, in pixels, before a press becomes a drag. Default `8`. */
177
+ dragThreshold?: number;
178
+ }
179
+ /**
180
+ * A snapshot of every pile on a {@link CardTable}, from {@link CardTable.snapshot}.
181
+ * It is plain JSON: keep a list of them for undo, or save one to resume later.
182
+ */
183
+ export interface CardTableSnapshot {
184
+ /** Copies of the cards of each pile, bottom first, in pile creation order. */
185
+ piles: Card[][];
186
+ }
187
+ /**
188
+ * A place on a {@link CardTable} that holds an ordered list of cards: a deck,
189
+ * a discard pile, a solitaire column, a hand, or a slot on the table.
190
+ *
191
+ * Create piles with {@link CardTable.addPile}. `cards` is bottom first, so the
192
+ * last card is the top one.
193
+ */
194
+ export declare class CardPile {
195
+ /** The table this pile belongs to. */
196
+ readonly table: CardTable;
197
+ /** Position of this pile in {@link CardTable.piles}, and in snapshots. */
198
+ readonly index: number;
199
+ /** Center of the bottom card. */
200
+ x: number;
201
+ /** Center of the bottom card. */
202
+ y: number;
203
+ /** See {@link CardPileOptions.offsetX}. */
204
+ offsetX: number;
205
+ /** See {@link CardPileOptions.offsetY}. */
206
+ offsetY: number;
207
+ /** See {@link CardPileOptions.faceDownOffsetX}. */
208
+ faceDownOffsetX: number;
209
+ /** See {@link CardPileOptions.faceDownOffsetY}. */
210
+ faceDownOffsetY: number;
211
+ /** See {@link CardPileOptions.maxSpread}. `Infinity` means no limit. */
212
+ maxSpread: number;
213
+ /** See {@link CardDragRule}. */
214
+ drag: CardDragRule;
215
+ /** The cards of this pile, bottom first. Read it; change it through the table. */
216
+ get cards(): readonly Card[];
217
+ /** Number of cards in this pile. */
218
+ get length(): number;
219
+ /** The top card, or `undefined` when the pile is empty. */
220
+ get top(): Card | undefined;
221
+ /** Position of a card in this pile, `0` for the bottom, or `-1`. */
222
+ indexOf(card: Card): number;
223
+ /**
224
+ * Where the card at `index` rests, as the center of the card. Use it to put
225
+ * a marker or a label next to a card. An index past the top gives the spot
226
+ * the next card would take.
227
+ */
228
+ cardPosition(index: number): {
229
+ x: number;
230
+ y: number;
231
+ };
232
+ }
233
+ /**
234
+ * A card table: piles of cards, their animation, and drag-and-drop between
235
+ * them. It is the whole card-games module.
236
+ *
237
+ * The table keeps the authoritative state, which is simply which cards are in
238
+ * which pile, in which order, and which side is up. Every change is applied at
239
+ * once, and the sprites then travel, flip, and settle to show it. Rules stay
240
+ * in the game: the table only asks, through `canDrag`, `canDrop`, `onDrop`,
241
+ * and `onTap`, and never decides what a legal move is.
242
+ *
243
+ * Call {@link update} once per frame.
244
+ *
245
+ * @example
246
+ * ```ts
247
+ * const table = new CardTable(game, {
248
+ * cardWidth: 100,
249
+ * cardHeight: 140,
250
+ * back: "card-back",
251
+ * face: {
252
+ * blank: "card-front",
253
+ * suits: { spades: "spade", hearts: "heart", diamonds: "diamond", clubs: "club" },
254
+ * font: '"Cinzel"',
255
+ * },
256
+ * });
257
+ * const deck = table.addPile(120, 200);
258
+ * const discard = table.addPile(260, 200);
259
+ * table.move(shuffle(standardDeck()), deck, { durationMs: 0 });
260
+ *
261
+ * table.onTap = (card, pile) => {
262
+ * if (pile === deck && card) table.move(card, discard, { faceUp: true });
263
+ * };
264
+ * table.canDrop = (cards, from, to) => to === discard;
265
+ *
266
+ * game.onUpdate = (dt) => table.update(dt);
267
+ * ```
268
+ */
269
+ export declare class CardTable {
270
+ /** The game this table draws into. */
271
+ readonly game: Game;
272
+ /** Card width in game pixels. */
273
+ readonly cardWidth: number;
274
+ /** Card height in game pixels. */
275
+ readonly cardHeight: number;
276
+ /** Lowest sprite layer the table uses. See {@link CardTableOptions.layer}. */
277
+ readonly layer: number;
278
+ /** Default travel time of a card, in milliseconds. */
279
+ moveMs: number;
280
+ /** Time of a full flip, in milliseconds. */
281
+ flipMs: number;
282
+ /** Pointer travel, in pixels, before a press becomes a drag. */
283
+ dragThreshold: number;
284
+ /** While `false`, the table ignores the pointer. Animations continue. */
285
+ enabled: boolean;
286
+ /**
287
+ * Asked before a drag starts, with the cards the drag would take, bottom
288
+ * first. Return `false` to keep them in place. Default: every face-up drag
289
+ * the pile's `drag` rule allows.
290
+ */
291
+ canDrag: ((cards: Card[], pile: CardPile) => boolean) | null;
292
+ /**
293
+ * Asked for each pile the dragged cards are over, to find where they may
294
+ * land. Return `true` if the rules allow the move. Default: any pile.
295
+ */
296
+ canDrop: ((cards: Card[], from: CardPile, to: CardPile) => boolean) | null;
297
+ /**
298
+ * Called when dragged cards are released over a pile that accepts them.
299
+ * Default: `table.move(cards, to)`. Replace it to also score, reveal a card,
300
+ * or check for a win. Cards released anywhere else return on their own.
301
+ */
302
+ onDrop: ((cards: Card[], from: CardPile, to: CardPile) => void) | null;
303
+ /**
304
+ * Called when a card, or an empty pile, is tapped without being dragged.
305
+ * `card` is `null` for an empty pile.
306
+ */
307
+ onTap: ((card: Card | null, pile: CardPile) => void) | null;
308
+ constructor(game: Game, options: CardTableOptions);
309
+ /** Every pile, in creation order. */
310
+ get piles(): readonly CardPile[];
311
+ /** `true` while any card is travelling, flipping, waiting to move, or being dragged. */
312
+ get isBusy(): boolean;
313
+ /** `true` while the player is dragging cards. */
314
+ get isDragging(): boolean;
315
+ /**
316
+ * Adds a pile whose bottom card is centered at `x`, `y`.
317
+ *
318
+ * @example
319
+ * ```ts
320
+ * const stock = table.addPile(120, 200, { drag: "none" });
321
+ * const column = table.addPile(300, 400, { offsetY: 40, faceDownOffsetY: 20, drag: "tail" });
322
+ * ```
323
+ */
324
+ addPile(x: number, y: number, options?: CardPileOptions): CardPile;
325
+ /** The pile that holds `card`, or `null` if the card is not on the table. */
326
+ pileOf(card: Card): CardPile | null;
327
+ /**
328
+ * Moves cards onto the top of a pile, in the order given, from wherever they
329
+ * are. A card not yet on the table appears there. The state changes now; the
330
+ * returned promise resolves when the cards have arrived.
331
+ *
332
+ * @example
333
+ * ```ts
334
+ * table.move(deck, stock, { durationMs: 0 }); // set up instantly
335
+ * await table.move(stock.top, waste, { faceUp: true }); // draw one
336
+ * cards.forEach((card, i) => table.move(card, hand, { delayMs: i * 80 })); // deal
337
+ * ```
338
+ */
339
+ move(cards: Card | Card[] | readonly Card[] | undefined, to: CardPile, options?: CardMoveOptions): Promise<void>;
340
+ /**
341
+ * Turns a card to `faceUp`, or over when `faceUp` is omitted. The promise
342
+ * resolves when the flip has finished.
343
+ */
344
+ flip(card: Card | undefined, faceUp?: boolean, options?: Pick<CardMoveOptions, "delayMs">): Promise<void>;
345
+ /** Copies the state of every pile. See {@link CardTableSnapshot}. */
346
+ snapshot(): CardTableSnapshot;
347
+ /**
348
+ * Puts every pile back as it was in a snapshot, moving and flipping the
349
+ * cards there. Cards are matched by id. Cards missing from the snapshot
350
+ * leave the table.
351
+ */
352
+ restore(snapshot: CardTableSnapshot, options?: Pick<CardMoveOptions, "durationMs">): Promise<void>;
353
+ /** Advances input and animation. Call it once per frame from `onUpdate`. */
354
+ update(dt: number): void;
355
+ }
@@ -0,0 +1,809 @@
1
+ import { Game, ImageSprite } from "./minimo.js";
2
+ /** The four standard suits, in {@link standardDeck} order. */
3
+ export const STANDARD_SUITS = ["spades", "hearts", "diamonds", "clubs"];
4
+ /**
5
+ * Builds the standard 52-card French deck, face down, ordered by suit and then
6
+ * by rank from ace to king.
7
+ *
8
+ * Ids are `"<suit>-<rank>"`, for example `"hearts-12"` for the queen of hearts.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * const deck = shuffle(standardDeck());
13
+ * ```
14
+ */
15
+ export function standardDeck() {
16
+ const deck = [];
17
+ for (const suit of STANDARD_SUITS) {
18
+ const color = suit === "hearts" || suit === "diamonds" ? "red" : "black";
19
+ for (let rank = 1; rank <= 13; rank++) {
20
+ deck.push({ id: `${suit}-${rank}`, suit, rank, color, faceUp: false });
21
+ }
22
+ }
23
+ return deck;
24
+ }
25
+ /**
26
+ * Returns the printed rank of a standard card: `"A"`, `"2"` to `"10"`, `"J"`,
27
+ * `"Q"`, or `"K"`.
28
+ */
29
+ export function rankLabel(rank) {
30
+ if (rank === 1)
31
+ return "A";
32
+ if (rank === 11)
33
+ return "J";
34
+ if (rank === 12)
35
+ return "Q";
36
+ if (rank === 13)
37
+ return "K";
38
+ return String(rank);
39
+ }
40
+ /**
41
+ * Shuffles an array in place and returns it.
42
+ *
43
+ * Without a seed the order is random. With a seed the order is always the
44
+ * same for that seed, which makes a deal reproducible: a numbered deal, a
45
+ * daily challenge, or a bug report that can be replayed.
46
+ *
47
+ * @param items - The array to shuffle. It is modified.
48
+ * @param seed - Optional integer seed for a reproducible order.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * shuffle(deck); // a different deal every time
53
+ * shuffle(deck, 1234); // deal number 1234, the same every time
54
+ * ```
55
+ */
56
+ export function shuffle(items, seed) {
57
+ const random = typeof seed === "number" && Number.isFinite(seed) ? seededRandom(seed) : Math.random;
58
+ for (let i = items.length - 1; i > 0; i--) {
59
+ const j = Math.floor(random() * (i + 1));
60
+ const swap = items[i];
61
+ items[i] = items[j];
62
+ items[j] = swap;
63
+ }
64
+ return items;
65
+ }
66
+ // mulberry32: small, fast, and good enough to shuffle cards reproducibly.
67
+ function seededRandom(seed) {
68
+ let state = Math.floor(seed) >>> 0;
69
+ return () => {
70
+ state = (state + 0x6d2b79f5) >>> 0;
71
+ let t = state;
72
+ t = Math.imul(t ^ (t >>> 15), t | 1);
73
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
74
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
75
+ };
76
+ }
77
+ function isStandardCard(card) {
78
+ return STANDARD_SUITS.includes(card.suit) && Number.isInteger(card.rank) && card.rank >= 1 && card.rank <= 13;
79
+ }
80
+ function drawImageFit(ctx, image, cx, cy, maxW, maxH) {
81
+ const scale = Math.min(maxW / image.width, maxH / image.height);
82
+ const w = image.width * scale;
83
+ const h = image.height * scale;
84
+ ctx.drawImage(image, cx - w / 2, cy - h / 2, w, h);
85
+ }
86
+ function paintStandardFace(game, ctx, card, face, width, height) {
87
+ if (!isStandardCard(card)) {
88
+ throw new Error(`MinimoJS cards: card '${card.id}' has no standard suit and rank, so a StandardFace cannot draw it. ` +
89
+ "Use cards from standardDeck(), or give the table face: (card) => imageKey for any other deck.");
90
+ }
91
+ const image = (key) => game.getImage(key);
92
+ ctx.drawImage(image(face.blank), 0, 0, width, height);
93
+ const label = rankLabel(card.rank);
94
+ ctx.fillStyle = card.color === "red" ? face.red ?? "#9c1a1a" : face.black ?? "#1b1f1d";
95
+ ctx.font = `${face.fontWeight ?? "700"} ${Math.round(width * (label.length > 1 ? 0.23 : 0.295))}px ${face.font}`;
96
+ ctx.textAlign = "center";
97
+ ctx.textBaseline = "middle";
98
+ ctx.fillText(label, width * 0.218, height * 0.167);
99
+ const suit = image(face.suits[card.suit]);
100
+ drawImageFit(ctx, suit, width * 0.766, height * 0.192, width * 0.25, height * 0.198);
101
+ const courtKey = card.rank === 11 ? face.courts?.jack : card.rank === 12 ? face.courts?.queen : card.rank === 13 ? face.courts?.king : undefined;
102
+ if (courtKey)
103
+ drawImageFit(ctx, image(courtKey), width * 0.5, height * 0.616, width * 0.694, height * 0.64);
104
+ else
105
+ drawImageFit(ctx, suit, width * 0.5, height * 0.605, width * 0.613, height * 0.477);
106
+ }
107
+ /**
108
+ * A place on a {@link CardTable} that holds an ordered list of cards: a deck,
109
+ * a discard pile, a solitaire column, a hand, or a slot on the table.
110
+ *
111
+ * Create piles with {@link CardTable.addPile}. `cards` is bottom first, so the
112
+ * last card is the top one.
113
+ */
114
+ export class CardPile {
115
+ /** @internal */
116
+ constructor(table, index, x, y, options = {}) {
117
+ /** @internal */
118
+ this._cards = [];
119
+ this.table = table;
120
+ this.index = index;
121
+ this.x = x;
122
+ this.y = y;
123
+ this.offsetX = finiteOr(options.offsetX, 0);
124
+ this.offsetY = finiteOr(options.offsetY, 0);
125
+ this.faceDownOffsetX = finiteOr(options.faceDownOffsetX, this.offsetX);
126
+ this.faceDownOffsetY = finiteOr(options.faceDownOffsetY, this.offsetY);
127
+ this.maxSpread = finiteOr(options.maxSpread, Infinity);
128
+ this.drag = options.drag ?? "top";
129
+ }
130
+ /** The cards of this pile, bottom first. Read it; change it through the table. */
131
+ get cards() {
132
+ return this._cards;
133
+ }
134
+ /** Number of cards in this pile. */
135
+ get length() {
136
+ return this._cards.length;
137
+ }
138
+ /** The top card, or `undefined` when the pile is empty. */
139
+ get top() {
140
+ return this._cards[this._cards.length - 1];
141
+ }
142
+ /** Position of a card in this pile, `0` for the bottom, or `-1`. */
143
+ indexOf(card) {
144
+ return this._cards.indexOf(card);
145
+ }
146
+ /**
147
+ * Where the card at `index` rests, as the center of the card. Use it to put
148
+ * a marker or a label next to a card. An index past the top gives the spot
149
+ * the next card would take.
150
+ */
151
+ cardPosition(index) {
152
+ const positions = this._layout(Math.max(index + 1, this._cards.length));
153
+ return positions[Math.max(0, Math.min(index, positions.length - 1))];
154
+ }
155
+ /** @internal */
156
+ _layout(count = this._cards.length) {
157
+ const steps = [];
158
+ let spread = 0;
159
+ for (let i = 0; i < count - 1; i++) {
160
+ const card = this._cards[i];
161
+ const faceUp = card ? card.faceUp : true;
162
+ const step = faceUp
163
+ ? { x: this.offsetX, y: this.offsetY }
164
+ : { x: this.faceDownOffsetX, y: this.faceDownOffsetY };
165
+ steps.push(step);
166
+ spread += Math.hypot(step.x, step.y);
167
+ }
168
+ const shrink = spread > this.maxSpread && spread > 0 ? this.maxSpread / spread : 1;
169
+ const positions = [{ x: this.x, y: this.y }];
170
+ for (const step of steps) {
171
+ const last = positions[positions.length - 1];
172
+ positions.push({ x: last.x + step.x * shrink, y: last.y + step.y * shrink });
173
+ }
174
+ return positions.slice(0, Math.max(0, count));
175
+ }
176
+ }
177
+ let nextTableId = 1;
178
+ /**
179
+ * A card table: piles of cards, their animation, and drag-and-drop between
180
+ * them. It is the whole card-games module.
181
+ *
182
+ * The table keeps the authoritative state, which is simply which cards are in
183
+ * which pile, in which order, and which side is up. Every change is applied at
184
+ * once, and the sprites then travel, flip, and settle to show it. Rules stay
185
+ * in the game: the table only asks, through `canDrag`, `canDrop`, `onDrop`,
186
+ * and `onTap`, and never decides what a legal move is.
187
+ *
188
+ * Call {@link update} once per frame.
189
+ *
190
+ * @example
191
+ * ```ts
192
+ * const table = new CardTable(game, {
193
+ * cardWidth: 100,
194
+ * cardHeight: 140,
195
+ * back: "card-back",
196
+ * face: {
197
+ * blank: "card-front",
198
+ * suits: { spades: "spade", hearts: "heart", diamonds: "diamond", clubs: "club" },
199
+ * font: '"Cinzel"',
200
+ * },
201
+ * });
202
+ * const deck = table.addPile(120, 200);
203
+ * const discard = table.addPile(260, 200);
204
+ * table.move(shuffle(standardDeck()), deck, { durationMs: 0 });
205
+ *
206
+ * table.onTap = (card, pile) => {
207
+ * if (pile === deck && card) table.move(card, discard, { faceUp: true });
208
+ * };
209
+ * table.canDrop = (cards, from, to) => to === discard;
210
+ *
211
+ * game.onUpdate = (dt) => table.update(dt);
212
+ * ```
213
+ */
214
+ export class CardTable {
215
+ constructor(game, options) {
216
+ /** While `false`, the table ignores the pointer. Animations continue. */
217
+ this.enabled = true;
218
+ /**
219
+ * Asked before a drag starts, with the cards the drag would take, bottom
220
+ * first. Return `false` to keep them in place. Default: every face-up drag
221
+ * the pile's `drag` rule allows.
222
+ */
223
+ this.canDrag = null;
224
+ /**
225
+ * Asked for each pile the dragged cards are over, to find where they may
226
+ * land. Return `true` if the rules allow the move. Default: any pile.
227
+ */
228
+ this.canDrop = null;
229
+ /**
230
+ * Called when dragged cards are released over a pile that accepts them.
231
+ * Default: `table.move(cards, to)`. Replace it to also score, reveal a card,
232
+ * or check for a win. Cards released anywhere else return on their own.
233
+ */
234
+ this.onDrop = null;
235
+ /**
236
+ * Called when a card, or an empty pile, is tapped without being dragged.
237
+ * `card` is `null` for an empty pile.
238
+ */
239
+ this.onTap = null;
240
+ /** @internal */
241
+ this._piles = [];
242
+ /** @internal */
243
+ this._views = new Map();
244
+ /** @internal Standard-face textures painted so far, by texture key. */
245
+ this._paintedFaces = new Map();
246
+ /** @internal */
247
+ this._press = null;
248
+ /** @internal */
249
+ this._drag = null;
250
+ /** @internal */
251
+ this._flightCount = 0;
252
+ /** @internal Waiters for cards whose sprite is created on the next update. */
253
+ this._pendingWaiters = new Map();
254
+ if (!(game instanceof Game)) {
255
+ throw new Error("MinimoJS cards: new CardTable(game, options) needs the Game as its first argument.");
256
+ }
257
+ this.game = game;
258
+ this._id = nextTableId++;
259
+ this.cardWidth = Math.max(1, finiteOr(options.cardWidth, 100));
260
+ this.cardHeight = Math.max(1, finiteOr(options.cardHeight, 140));
261
+ this.layer = finiteOr(options.layer, 10);
262
+ this.moveMs = Math.max(0, finiteOr(options.moveMs, 260));
263
+ this.flipMs = Math.max(0, finiteOr(options.flipMs, 220));
264
+ this.dragThreshold = Math.max(0, finiteOr(options.dragThreshold, 8));
265
+ requireImage(game, options.back, "back");
266
+ this._backKey = options.back;
267
+ const face = options.face;
268
+ if (typeof face === "function") {
269
+ this._face = face;
270
+ }
271
+ else if (face && typeof face === "object") {
272
+ requireImage(game, face.blank, "face.blank");
273
+ for (const suit of STANDARD_SUITS)
274
+ requireImage(game, face.suits?.[suit], `face.suits.${suit}`);
275
+ for (const court of ["jack", "queen", "king"]) {
276
+ if (face.courts?.[court] !== undefined)
277
+ requireImage(game, face.courts[court], `face.courts.${court}`);
278
+ }
279
+ if (typeof face.font !== "string" || face.font.trim().length === 0) {
280
+ throw new Error("MinimoJS cards: face.font must name the web font of the rank, for example '\"Cinzel\"', " +
281
+ "declared by the page and required with game.requireFont.");
282
+ }
283
+ this._face = face;
284
+ this.whenFontReady(face);
285
+ }
286
+ else {
287
+ throw new Error("MinimoJS cards: new CardTable needs a face: a StandardFace built from the game's images, " +
288
+ "or a function that returns the image key of each card's front.");
289
+ }
290
+ }
291
+ /** Every pile, in creation order. */
292
+ get piles() {
293
+ return this._piles;
294
+ }
295
+ /** `true` while any card is travelling, flipping, waiting to move, or being dragged. */
296
+ get isBusy() {
297
+ if (this._drag)
298
+ return true;
299
+ for (const view of this._views.values()) {
300
+ if (!this.isIdle(view))
301
+ return true;
302
+ }
303
+ return false;
304
+ }
305
+ /** `true` while the player is dragging cards. */
306
+ get isDragging() {
307
+ return this._drag !== null;
308
+ }
309
+ /**
310
+ * Adds a pile whose bottom card is centered at `x`, `y`.
311
+ *
312
+ * @example
313
+ * ```ts
314
+ * const stock = table.addPile(120, 200, { drag: "none" });
315
+ * const column = table.addPile(300, 400, { offsetY: 40, faceDownOffsetY: 20, drag: "tail" });
316
+ * ```
317
+ */
318
+ addPile(x, y, options = {}) {
319
+ const pile = new CardPile(this, this._piles.length, x, y, options);
320
+ this._piles.push(pile);
321
+ return pile;
322
+ }
323
+ /** The pile that holds `card`, or `null` if the card is not on the table. */
324
+ pileOf(card) {
325
+ for (const pile of this._piles) {
326
+ if (pile._cards.includes(card))
327
+ return pile;
328
+ }
329
+ return null;
330
+ }
331
+ /**
332
+ * Moves cards onto the top of a pile, in the order given, from wherever they
333
+ * are. A card not yet on the table appears there. The state changes now; the
334
+ * returned promise resolves when the cards have arrived.
335
+ *
336
+ * @example
337
+ * ```ts
338
+ * table.move(deck, stock, { durationMs: 0 }); // set up instantly
339
+ * await table.move(stock.top, waste, { faceUp: true }); // draw one
340
+ * cards.forEach((card, i) => table.move(card, hand, { delayMs: i * 80 })); // deal
341
+ * ```
342
+ */
343
+ move(cards, to, options = {}) {
344
+ const list = toCardList(cards);
345
+ if (to.table !== this) {
346
+ throw new Error("MinimoJS cards: move() target pile belongs to another table.");
347
+ }
348
+ for (const card of list) {
349
+ const from = this.pileOf(card);
350
+ if (from)
351
+ from._cards.splice(from._cards.indexOf(card), 1);
352
+ }
353
+ for (const card of list) {
354
+ if (typeof options.faceUp === "boolean")
355
+ card.faceUp = options.faceUp;
356
+ to._cards.push(card);
357
+ this.schedule(card, options);
358
+ }
359
+ return this.whenIdle(list);
360
+ }
361
+ /**
362
+ * Turns a card to `faceUp`, or over when `faceUp` is omitted. The promise
363
+ * resolves when the flip has finished.
364
+ */
365
+ flip(card, faceUp, options = {}) {
366
+ if (!card)
367
+ return Promise.resolve();
368
+ card.faceUp = typeof faceUp === "boolean" ? faceUp : !card.faceUp;
369
+ this.schedule(card, options);
370
+ return this.whenIdle([card]);
371
+ }
372
+ /** Copies the state of every pile. See {@link CardTableSnapshot}. */
373
+ snapshot() {
374
+ return { piles: this._piles.map((pile) => pile._cards.map((card) => ({ ...card }))) };
375
+ }
376
+ /**
377
+ * Puts every pile back as it was in a snapshot, moving and flipping the
378
+ * cards there. Cards are matched by id. Cards missing from the snapshot
379
+ * leave the table.
380
+ */
381
+ restore(snapshot, options = {}) {
382
+ this.cancelDrag();
383
+ const byId = new Map();
384
+ for (const pile of this._piles)
385
+ for (const card of pile._cards)
386
+ byId.set(card.id, card);
387
+ for (const card of this._views.keys())
388
+ byId.set(card.id, card);
389
+ const kept = new Set();
390
+ this._piles.forEach((pile, i) => {
391
+ const saved = snapshot.piles[i] ?? [];
392
+ pile._cards = saved.map((data) => {
393
+ const card = byId.get(data.id) ?? { ...data };
394
+ Object.assign(card, data);
395
+ kept.add(card);
396
+ return card;
397
+ });
398
+ });
399
+ for (const [card, view] of this._views) {
400
+ if (!kept.has(card))
401
+ this.removeView(card, view);
402
+ }
403
+ const all = [...kept];
404
+ for (const card of all)
405
+ this.schedule(card, options);
406
+ return this.whenIdle(all);
407
+ }
408
+ /** Advances input and animation. Call it once per frame from `onUpdate`. */
409
+ update(dt) {
410
+ const dtMs = Math.max(0, finiteOr(dt, 0)) * 1000;
411
+ this.updateInput();
412
+ this.updateViews(dtMs);
413
+ }
414
+ // -------------------------------------------------------------------------
415
+ // Presentation
416
+ // -------------------------------------------------------------------------
417
+ /** @internal */
418
+ schedule(card, options) {
419
+ const view = this._views.get(card);
420
+ if (!view)
421
+ return;
422
+ if (typeof options.delayMs === "number" && options.delayMs > 0)
423
+ view.waitMs = options.delayMs;
424
+ if (typeof options.durationMs === "number")
425
+ view.nextDurationMs = Math.max(0, options.durationMs);
426
+ }
427
+ /** @internal */
428
+ whenIdle(cards) {
429
+ if (cards.length === 0)
430
+ return Promise.resolve();
431
+ return new Promise((resolve) => {
432
+ let pending = cards.length;
433
+ const done = () => {
434
+ pending -= 1;
435
+ if (pending === 0)
436
+ resolve();
437
+ };
438
+ for (const card of cards) {
439
+ const view = this._views.get(card);
440
+ if (view)
441
+ view.waiters.push(done);
442
+ else
443
+ this.pendingWaiters(card).push(done);
444
+ }
445
+ });
446
+ }
447
+ /** @internal */
448
+ pendingWaiters(card) {
449
+ let list = this._pendingWaiters.get(card);
450
+ if (!list) {
451
+ list = [];
452
+ this._pendingWaiters.set(card, list);
453
+ }
454
+ return list;
455
+ }
456
+ /** @internal */
457
+ faceKey(card) {
458
+ const face = this._face;
459
+ if (typeof face === "function") {
460
+ const key = face(card);
461
+ if (typeof key !== "string" || !this.game.hasImage(key)) {
462
+ throw new Error(`MinimoJS cards: front image '${key}' of card '${card.id}' is not loaded. Load it in game.onPreload.`);
463
+ }
464
+ return key;
465
+ }
466
+ const key = `cards:${this._id}:face:${card.id}`;
467
+ if (!this._paintedFaces.has(key)) {
468
+ this.paintFace(key, card, face);
469
+ this._paintedFaces.set(key, card);
470
+ }
471
+ return key;
472
+ }
473
+ /** @internal */
474
+ paintFace(key, card, face) {
475
+ this.game.createTexture(key, this.cardWidth, this.cardHeight, (ctx) => paintStandardFace(this.game, ctx, card, face, this.cardWidth, this.cardHeight));
476
+ }
477
+ /**
478
+ * @internal A face painted before its font arrived would keep the fallback
479
+ * font forever, so faces are repainted once the font is ready.
480
+ */
481
+ whenFontReady(face) {
482
+ const fonts = typeof document !== "undefined" ? document.fonts : undefined;
483
+ if (!fonts)
484
+ return;
485
+ const spec = `${face.fontWeight ?? "700"} 32px ${face.font}`;
486
+ if (fonts.check(spec))
487
+ return;
488
+ fonts.load(spec).then(() => {
489
+ for (const [key, card] of this._paintedFaces)
490
+ this.paintFace(key, card, face);
491
+ }, () => { });
492
+ }
493
+ /** @internal */
494
+ showSide(view, faceUp) {
495
+ const key = faceUp ? this.faceKey(view.card) : this._backKey;
496
+ view.sprite.setTexture(key);
497
+ view.shownFaceUp = faceUp;
498
+ this.applyScale(view);
499
+ }
500
+ /** @internal */
501
+ applyScale(view) {
502
+ const sprite = view.sprite;
503
+ const fit = Math.min(this.cardWidth / sprite.width, this.cardHeight / sprite.height);
504
+ sprite.scale = fit * (view.dragging ? 1.06 : 1);
505
+ }
506
+ /** @internal */
507
+ createView(card, x, y, layer) {
508
+ const sprite = this.game.add(new ImageSprite(this.game, this._backKey, x, y));
509
+ sprite.layer = layer;
510
+ const view = {
511
+ card,
512
+ sprite,
513
+ shownFaceUp: false,
514
+ toX: x,
515
+ toY: y,
516
+ fromX: x,
517
+ fromY: y,
518
+ elapsedMs: 0,
519
+ durationMs: 0,
520
+ moving: false,
521
+ flipping: false,
522
+ waitMs: 0,
523
+ nextDurationMs: null,
524
+ dragging: false,
525
+ grabX: 0,
526
+ grabY: 0,
527
+ waiters: this._pendingWaiters.get(card) ?? [],
528
+ };
529
+ this._pendingWaiters.delete(card);
530
+ this.showSide(view, card.faceUp);
531
+ this._views.set(card, view);
532
+ return view;
533
+ }
534
+ /** @internal */
535
+ removeView(card, view) {
536
+ this.game.destroySprite(view.sprite);
537
+ this._views.delete(card);
538
+ for (const waiter of view.waiters.splice(0))
539
+ waiter();
540
+ }
541
+ /** @internal */
542
+ isIdle(view) {
543
+ return !view.moving && !view.flipping && !view.dragging && view.waitMs <= 0;
544
+ }
545
+ /** @internal */
546
+ updateViews(dtMs) {
547
+ for (const pile of this._piles) {
548
+ const positions = pile._layout();
549
+ pile._cards.forEach((card, i) => {
550
+ const target = positions[i];
551
+ const restLayer = this.layer + (i + 1) * 0.01;
552
+ const view = this._views.get(card) ?? this.createView(card, target.x, target.y, restLayer);
553
+ this.updateView(view, target.x, target.y, restLayer, dtMs);
554
+ });
555
+ }
556
+ for (const view of this._views.values()) {
557
+ if (this.isIdle(view) && view.waiters.length > 0) {
558
+ for (const waiter of view.waiters.splice(0))
559
+ waiter();
560
+ }
561
+ }
562
+ }
563
+ /** @internal */
564
+ updateView(view, x, y, restLayer, dtMs) {
565
+ if (view.dragging)
566
+ return;
567
+ if (view.waitMs > 0) {
568
+ view.waitMs -= dtMs;
569
+ if (view.waitMs > 0)
570
+ return;
571
+ }
572
+ const sprite = view.sprite;
573
+ if (x !== view.toX || y !== view.toY) {
574
+ const duration = view.nextDurationMs ?? this.moveMs;
575
+ view.nextDurationMs = null;
576
+ view.toX = x;
577
+ view.toY = y;
578
+ if (duration <= 0) {
579
+ sprite.x = x;
580
+ sprite.y = y;
581
+ view.moving = false;
582
+ if (view.shownFaceUp !== view.card.faceUp && !view.flipping)
583
+ this.showSide(view, view.card.faceUp);
584
+ }
585
+ else {
586
+ view.fromX = sprite.x;
587
+ view.fromY = sprite.y;
588
+ view.elapsedMs = 0;
589
+ view.durationMs = duration;
590
+ view.moving = true;
591
+ this._flightCount += 1;
592
+ sprite.layer = this.layer + 500 + (this._flightCount % 1000) * 0.001;
593
+ }
594
+ }
595
+ else if (!view.moving) {
596
+ view.nextDurationMs = null;
597
+ }
598
+ if (view.shownFaceUp !== view.card.faceUp && !view.flipping)
599
+ this.startFlip(view);
600
+ if (view.moving) {
601
+ view.elapsedMs += dtMs;
602
+ const t = Math.min(1, view.elapsedMs / view.durationMs);
603
+ const eased = 1 - Math.pow(1 - t, 3);
604
+ sprite.x = view.fromX + (view.toX - view.fromX) * eased;
605
+ sprite.y = view.fromY + (view.toY - view.fromY) * eased;
606
+ if (t >= 1)
607
+ view.moving = false;
608
+ }
609
+ if (!view.moving)
610
+ sprite.layer = restLayer;
611
+ }
612
+ /** @internal */
613
+ startFlip(view) {
614
+ const half = this.flipMs / 2;
615
+ if (half <= 0) {
616
+ this.showSide(view, view.card.faceUp);
617
+ return;
618
+ }
619
+ view.flipping = true;
620
+ const sprite = view.sprite;
621
+ this.game.animateDeform(sprite, 0, 1.06, 0.5, 0.5, half, () => {
622
+ if (!this._views.has(view.card))
623
+ return;
624
+ this.showSide(view, view.card.faceUp);
625
+ this.game.animateDeform(sprite, 1, 1, 0.5, 0.5, half, () => {
626
+ view.flipping = false;
627
+ });
628
+ });
629
+ }
630
+ // -------------------------------------------------------------------------
631
+ // Input
632
+ // -------------------------------------------------------------------------
633
+ /** @internal */
634
+ updateInput() {
635
+ const game = this.game;
636
+ if (!this.enabled) {
637
+ this._press = null;
638
+ this.cancelDrag();
639
+ return;
640
+ }
641
+ const x = game.pointerX;
642
+ const y = game.pointerY;
643
+ if (game.isPointerPressed()) {
644
+ this.cancelDrag();
645
+ const hit = this.hitTest(x, y);
646
+ this._press = hit ? { x, y, card: hit.card, pile: hit.pile, dragTried: false } : null;
647
+ return;
648
+ }
649
+ const press = this._press;
650
+ if (!press)
651
+ return;
652
+ if (game.isPointerDown()) {
653
+ if (!this._drag && !press.dragTried && Math.hypot(x - press.x, y - press.y) >= this.dragThreshold) {
654
+ this.startDrag(press);
655
+ }
656
+ if (this._drag)
657
+ this.followPointer(x, y);
658
+ return;
659
+ }
660
+ this._press = null;
661
+ if (this._drag)
662
+ this.finishDrag();
663
+ else if (Math.hypot(x - press.x, y - press.y) < this.dragThreshold)
664
+ this.onTap?.(press.card, press.pile);
665
+ }
666
+ /** @internal */
667
+ hitTest(x, y) {
668
+ const halfW = this.cardWidth / 2;
669
+ const halfH = this.cardHeight / 2;
670
+ for (let p = this._piles.length - 1; p >= 0; p--) {
671
+ const pile = this._piles[p];
672
+ const positions = pile._layout();
673
+ for (let i = pile._cards.length - 1; i >= 0; i--) {
674
+ const at = positions[i];
675
+ if (Math.abs(x - at.x) <= halfW && Math.abs(y - at.y) <= halfH) {
676
+ return { card: pile._cards[i], pile };
677
+ }
678
+ }
679
+ }
680
+ for (let p = this._piles.length - 1; p >= 0; p--) {
681
+ const pile = this._piles[p];
682
+ if (pile._cards.length === 0 && Math.abs(x - pile.x) <= halfW && Math.abs(y - pile.y) <= halfH) {
683
+ return { card: null, pile };
684
+ }
685
+ }
686
+ return null;
687
+ }
688
+ /** @internal */
689
+ startDrag(press) {
690
+ const { card, pile } = press;
691
+ press.dragTried = true;
692
+ if (!card || pile.drag === "none")
693
+ return;
694
+ const index = pile._cards.indexOf(card);
695
+ if (index < 0)
696
+ return;
697
+ let cards;
698
+ if (pile.drag === "tail")
699
+ cards = pile._cards.slice(index);
700
+ else if (pile.drag === "any")
701
+ cards = [card];
702
+ else if (index === pile._cards.length - 1)
703
+ cards = [card];
704
+ else
705
+ return;
706
+ if (cards.some((c) => !c.faceUp))
707
+ return;
708
+ if (this.canDrag && this.canDrag(cards, pile) === false)
709
+ return;
710
+ this._drag = { cards, from: pile };
711
+ cards.forEach((c, i) => {
712
+ const view = this._views.get(c);
713
+ if (!view)
714
+ return;
715
+ view.moving = false;
716
+ view.waitMs = 0;
717
+ view.dragging = true;
718
+ view.grabX = press.x - view.sprite.x;
719
+ view.grabY = press.y - view.sprite.y;
720
+ view.sprite.layer = this.layer + 1000 + i * 0.01;
721
+ this.applyScale(view);
722
+ });
723
+ }
724
+ /** @internal */
725
+ followPointer(x, y) {
726
+ for (const card of this._drag?.cards ?? []) {
727
+ const view = this._views.get(card);
728
+ if (!view)
729
+ continue;
730
+ view.sprite.x = x - view.grabX;
731
+ view.sprite.y = y - view.grabY;
732
+ }
733
+ }
734
+ /** @internal */
735
+ finishDrag() {
736
+ const drag = this._drag;
737
+ if (!drag)
738
+ return;
739
+ const lead = this._views.get(drag.cards[0]);
740
+ let target = null;
741
+ if (lead) {
742
+ let best = 0;
743
+ for (const pile of this._piles) {
744
+ if (pile === drag.from)
745
+ continue;
746
+ const area = this.dropOverlap(pile, lead.sprite.x, lead.sprite.y);
747
+ if (area <= best)
748
+ continue;
749
+ if (this.canDrop && this.canDrop(drag.cards, drag.from, pile) !== true)
750
+ continue;
751
+ best = area;
752
+ target = pile;
753
+ }
754
+ }
755
+ this.releaseDrag();
756
+ if (!target)
757
+ return;
758
+ if (this.onDrop)
759
+ this.onDrop(drag.cards, drag.from, target);
760
+ else
761
+ this.move(drag.cards, target);
762
+ }
763
+ /** @internal Overlap between the dragged lead card and the spot a pile would take it. */
764
+ dropOverlap(pile, x, y) {
765
+ const at = pile._cards.length > 0 ? pile.cardPosition(pile._cards.length - 1) : { x: pile.x, y: pile.y };
766
+ const overlapX = Math.max(0, this.cardWidth - Math.abs(x - at.x));
767
+ const overlapY = Math.max(0, this.cardHeight - Math.abs(y - at.y));
768
+ return overlapX * overlapY;
769
+ }
770
+ /** @internal */
771
+ cancelDrag() {
772
+ if (this._drag)
773
+ this.releaseDrag();
774
+ }
775
+ /** @internal */
776
+ releaseDrag() {
777
+ const drag = this._drag;
778
+ if (!drag)
779
+ return;
780
+ this._drag = null;
781
+ for (const card of drag.cards) {
782
+ const view = this._views.get(card);
783
+ if (!view)
784
+ continue;
785
+ view.dragging = false;
786
+ // Forces a motion from where the card was dropped back to its resting place.
787
+ view.toX = Number.NaN;
788
+ view.toY = Number.NaN;
789
+ view.nextDurationMs = Math.round(this.moveMs * 0.8);
790
+ this.applyScale(view);
791
+ }
792
+ }
793
+ }
794
+ function requireImage(game, key, option) {
795
+ if (typeof key !== "string" || !game.hasImage(key)) {
796
+ throw new Error(`MinimoJS cards: ${option} image '${key}' is not loaded. Card art always comes from the game's images; ` +
797
+ "load it with game.loadImage in game.onPreload.");
798
+ }
799
+ }
800
+ function finiteOr(value, fallback) {
801
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
802
+ }
803
+ function toCardList(cards) {
804
+ if (!cards)
805
+ return [];
806
+ if (Array.isArray(cards))
807
+ return [...cards];
808
+ return [cards];
809
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.21",
3
+ "version": "1.0.0-alpha.22",
4
4
  "description": "MinimoJS v1 — ultra-minimal, flat, deterministic 2D web game engine. Image-first sprites with emoji as a zero-asset alternative, rAF loop, TypeScript-first, LLM-friendly.",
5
5
  "type": "module",
6
6
  "main": "dist/minimo.js",