minimojs 1.0.0-alpha.3 → 1.0.0-alpha.30
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 +85 -285
- package/dist/internal/AnimationSystem.js +345 -0
- package/dist/internal/AssetSystem.d.ts +1 -0
- package/dist/internal/AssetSystem.js +186 -0
- package/dist/internal/BackgroundSystem.d.ts +1 -0
- package/dist/internal/BackgroundSystem.js +26 -0
- package/dist/internal/CanvasSystem.d.ts +1 -0
- package/dist/internal/CanvasSystem.js +51 -0
- package/dist/internal/DesignNamingCheck.d.ts +1 -0
- package/dist/internal/DesignNamingCheck.js +7 -0
- package/dist/internal/ExplosionSystem.d.ts +1 -0
- package/dist/internal/ExplosionSystem.js +540 -0
- package/dist/internal/InputSystem.d.ts +1 -0
- package/dist/internal/InputSystem.js +292 -0
- package/dist/internal/LoopSystem.d.ts +1 -0
- package/dist/internal/LoopSystem.js +61 -0
- package/dist/internal/PhysicsSystem.d.ts +1 -0
- package/dist/internal/PhysicsSystem.js +174 -0
- package/dist/internal/RenderSystem.d.ts +1 -0
- package/dist/internal/RenderSystem.js +1021 -0
- package/dist/internal/SoundSystem.d.ts +1 -0
- package/dist/internal/SoundSystem.js +55 -0
- package/dist/internal/SpriteSystem.d.ts +1 -0
- package/dist/internal/SpriteSystem.js +32 -0
- package/dist/internal/TextSystem.d.ts +1 -0
- package/dist/internal/TextSystem.js +16 -0
- package/dist/internal/TimerSystem.d.ts +1 -0
- package/dist/internal/TimerSystem.js +43 -0
- package/dist/internal/TrailSystem.d.ts +1 -0
- package/dist/internal/TrailSystem.js +116 -0
- package/dist/internal/TransitionSystem.d.ts +1 -0
- package/dist/internal/TransitionSystem.js +74 -0
- package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.js +198 -0
- package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.js +120 -0
- package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.js +599 -0
- package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.js +13 -0
- package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.js +447 -0
- package/dist/minimo-ambient.d.ts +299 -0
- package/dist/minimo-ambient.js +1445 -0
- package/dist/minimo-arcaderacer.d.ts +1431 -0
- package/dist/minimo-arcaderacer.js +2060 -0
- package/dist/minimo-cards.d.ts +359 -0
- package/dist/minimo-cards.js +819 -0
- package/dist/minimo-format.d.ts +407 -0
- package/dist/minimo-format.js +446 -0
- package/dist/minimo.d.ts +1754 -225
- package/dist/minimo.js +2551 -761
- package/package.json +10 -3
- package/dist/animations.js +0 -30
- package/dist/audio.js +0 -17
- package/dist/game.js +0 -1105
- package/dist/input.js +0 -185
- package/dist/internal-types.js +0 -4
- package/dist/physics.js +0 -10
- package/dist/render.js +0 -75
- package/dist/sprite.js +0 -149
- package/dist/timers.js +0 -23
- /package/dist/{pointer-info.js → internal/AnimationSystem.d.ts} +0 -0
|
@@ -0,0 +1,819 @@
|
|
|
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
|
+
* Piles and cards live in world space and scroll with the game. The table
|
|
191
|
+
* reads the pointer itself and adds `game.scrollX` and `game.scrollY`, so a
|
|
192
|
+
* scrolled board needs no conversion from the game.
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* ```ts
|
|
196
|
+
* const table = new CardTable(game, {
|
|
197
|
+
* cardWidth: 100,
|
|
198
|
+
* cardHeight: 140,
|
|
199
|
+
* back: "card-back",
|
|
200
|
+
* face: {
|
|
201
|
+
* blank: "card-front",
|
|
202
|
+
* suits: { spades: "spade", hearts: "heart", diamonds: "diamond", clubs: "club" },
|
|
203
|
+
* font: '"Cinzel"',
|
|
204
|
+
* },
|
|
205
|
+
* });
|
|
206
|
+
* const deck = table.addPile(120, 200);
|
|
207
|
+
* const discard = table.addPile(260, 200);
|
|
208
|
+
* table.move(shuffle(standardDeck()), deck, { durationMs: 0 });
|
|
209
|
+
*
|
|
210
|
+
* table.onTap = (card, pile) => {
|
|
211
|
+
* if (pile === deck && card) table.move(card, discard, { faceUp: true });
|
|
212
|
+
* };
|
|
213
|
+
* table.canDrop = (cards, from, to) => to === discard;
|
|
214
|
+
*
|
|
215
|
+
* game.onUpdate = (dt) => table.update(dt);
|
|
216
|
+
* ```
|
|
217
|
+
*/
|
|
218
|
+
export class CardTable {
|
|
219
|
+
constructor(game, options) {
|
|
220
|
+
/** While `false`, the table ignores the pointer. Animations continue. */
|
|
221
|
+
this.enabled = true;
|
|
222
|
+
/**
|
|
223
|
+
* Asked before a drag starts, with the cards the drag would take, bottom
|
|
224
|
+
* first. Return `false` to keep them in place. Default: every face-up drag
|
|
225
|
+
* the pile's `drag` rule allows.
|
|
226
|
+
*/
|
|
227
|
+
this.canDrag = null;
|
|
228
|
+
/**
|
|
229
|
+
* Asked for each pile the dragged cards are over, to find where they may
|
|
230
|
+
* land. Return `true` if the rules allow the move. Default: any pile.
|
|
231
|
+
*/
|
|
232
|
+
this.canDrop = null;
|
|
233
|
+
/**
|
|
234
|
+
* Called when dragged cards are released over a pile that accepts them.
|
|
235
|
+
* Default: `table.move(cards, to)`. Replace it to also score, reveal a card,
|
|
236
|
+
* or check for a win. Cards released anywhere else return on their own.
|
|
237
|
+
*/
|
|
238
|
+
this.onDrop = null;
|
|
239
|
+
/**
|
|
240
|
+
* Called when a card, or an empty pile, is tapped without being dragged.
|
|
241
|
+
* `card` is `null` for an empty pile.
|
|
242
|
+
*/
|
|
243
|
+
this.onTap = null;
|
|
244
|
+
/** @internal */
|
|
245
|
+
this._piles = [];
|
|
246
|
+
/** @internal */
|
|
247
|
+
this._views = new Map();
|
|
248
|
+
/** @internal Standard-face textures painted so far, by texture key. */
|
|
249
|
+
this._paintedFaces = new Map();
|
|
250
|
+
/** @internal */
|
|
251
|
+
this._press = null;
|
|
252
|
+
/** @internal */
|
|
253
|
+
this._drag = null;
|
|
254
|
+
/** @internal */
|
|
255
|
+
this._flightCount = 0;
|
|
256
|
+
/** @internal Waiters for cards whose sprite is created on the next update. */
|
|
257
|
+
this._pendingWaiters = new Map();
|
|
258
|
+
if (!(game instanceof Game)) {
|
|
259
|
+
throw new Error("MinimoJS cards: new CardTable(game, options) needs the Game as its first argument.");
|
|
260
|
+
}
|
|
261
|
+
this.game = game;
|
|
262
|
+
this._id = nextTableId++;
|
|
263
|
+
this.cardWidth = Math.max(1, finiteOr(options.cardWidth, 100));
|
|
264
|
+
this.cardHeight = Math.max(1, finiteOr(options.cardHeight, 140));
|
|
265
|
+
this.layer = finiteOr(options.layer, 10);
|
|
266
|
+
this.moveMs = Math.max(0, finiteOr(options.moveMs, 260));
|
|
267
|
+
this.flipMs = Math.max(0, finiteOr(options.flipMs, 220));
|
|
268
|
+
this.dragThreshold = Math.max(0, finiteOr(options.dragThreshold, 8));
|
|
269
|
+
requireImage(game, options.back, "back");
|
|
270
|
+
this._backKey = options.back;
|
|
271
|
+
const face = options.face;
|
|
272
|
+
if (typeof face === "function") {
|
|
273
|
+
this._face = face;
|
|
274
|
+
}
|
|
275
|
+
else if (face && typeof face === "object") {
|
|
276
|
+
requireImage(game, face.blank, "face.blank");
|
|
277
|
+
for (const suit of STANDARD_SUITS)
|
|
278
|
+
requireImage(game, face.suits?.[suit], `face.suits.${suit}`);
|
|
279
|
+
for (const court of ["jack", "queen", "king"]) {
|
|
280
|
+
if (face.courts?.[court] !== undefined)
|
|
281
|
+
requireImage(game, face.courts[court], `face.courts.${court}`);
|
|
282
|
+
}
|
|
283
|
+
if (typeof face.font !== "string" || face.font.trim().length === 0) {
|
|
284
|
+
throw new Error("MinimoJS cards: face.font must name the web font of the rank, for example '\"Cinzel\"', " +
|
|
285
|
+
"declared by the page and required with game.requireFont.");
|
|
286
|
+
}
|
|
287
|
+
this._face = face;
|
|
288
|
+
this.whenFontReady(face);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
throw new Error("MinimoJS cards: new CardTable needs a face: a StandardFace built from the game's images, " +
|
|
292
|
+
"or a function that returns the image key of each card's front.");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** Every pile, in creation order. */
|
|
296
|
+
get piles() {
|
|
297
|
+
return this._piles;
|
|
298
|
+
}
|
|
299
|
+
/** `true` while any card is travelling, flipping, waiting to move, or being dragged. */
|
|
300
|
+
get isBusy() {
|
|
301
|
+
if (this._drag)
|
|
302
|
+
return true;
|
|
303
|
+
for (const view of this._views.values()) {
|
|
304
|
+
if (!this.isIdle(view))
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
/** `true` while the player is dragging cards. */
|
|
310
|
+
get isDragging() {
|
|
311
|
+
return this._drag !== null;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Adds a pile whose bottom card is centered at `x`, `y`.
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* ```ts
|
|
318
|
+
* const stock = table.addPile(120, 200, { drag: "none" });
|
|
319
|
+
* const column = table.addPile(300, 400, { offsetY: 40, faceDownOffsetY: 20, drag: "tail" });
|
|
320
|
+
* ```
|
|
321
|
+
*/
|
|
322
|
+
addPile(x, y, options = {}) {
|
|
323
|
+
const pile = new CardPile(this, this._piles.length, x, y, options);
|
|
324
|
+
this._piles.push(pile);
|
|
325
|
+
return pile;
|
|
326
|
+
}
|
|
327
|
+
/** The pile that holds `card`, or `null` if the card is not on the table. */
|
|
328
|
+
pileOf(card) {
|
|
329
|
+
for (const pile of this._piles) {
|
|
330
|
+
if (pile._cards.includes(card))
|
|
331
|
+
return pile;
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Moves cards onto the top of a pile, in the order given, from wherever they
|
|
337
|
+
* are. A card not yet on the table appears there. The state changes now; the
|
|
338
|
+
* returned promise resolves when the cards have arrived.
|
|
339
|
+
*
|
|
340
|
+
* @example
|
|
341
|
+
* ```ts
|
|
342
|
+
* table.move(deck, stock, { durationMs: 0 }); // set up instantly
|
|
343
|
+
* await table.move(stock.top, waste, { faceUp: true }); // draw one
|
|
344
|
+
* cards.forEach((card, i) => table.move(card, hand, { delayMs: i * 80 })); // deal
|
|
345
|
+
* ```
|
|
346
|
+
*/
|
|
347
|
+
move(cards, to, options = {}) {
|
|
348
|
+
const list = toCardList(cards);
|
|
349
|
+
if (to.table !== this) {
|
|
350
|
+
throw new Error("MinimoJS cards: move() target pile belongs to another table.");
|
|
351
|
+
}
|
|
352
|
+
for (const card of list) {
|
|
353
|
+
const from = this.pileOf(card);
|
|
354
|
+
if (from)
|
|
355
|
+
from._cards.splice(from._cards.indexOf(card), 1);
|
|
356
|
+
}
|
|
357
|
+
for (const card of list) {
|
|
358
|
+
if (typeof options.faceUp === "boolean")
|
|
359
|
+
card.faceUp = options.faceUp;
|
|
360
|
+
to._cards.push(card);
|
|
361
|
+
this.schedule(card, options);
|
|
362
|
+
}
|
|
363
|
+
return this.whenIdle(list);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Turns a card to `faceUp`, or over when `faceUp` is omitted. The promise
|
|
367
|
+
* resolves when the flip has finished.
|
|
368
|
+
*/
|
|
369
|
+
flip(card, faceUp, options = {}) {
|
|
370
|
+
if (!card)
|
|
371
|
+
return Promise.resolve();
|
|
372
|
+
card.faceUp = typeof faceUp === "boolean" ? faceUp : !card.faceUp;
|
|
373
|
+
this.schedule(card, options);
|
|
374
|
+
return this.whenIdle([card]);
|
|
375
|
+
}
|
|
376
|
+
/** Copies the state of every pile. See {@link CardTableSnapshot}. */
|
|
377
|
+
snapshot() {
|
|
378
|
+
return { piles: this._piles.map((pile) => pile._cards.map((card) => ({ ...card }))) };
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Puts every pile back as it was in a snapshot, moving and flipping the
|
|
382
|
+
* cards there. Cards are matched by id. Cards missing from the snapshot
|
|
383
|
+
* leave the table.
|
|
384
|
+
*/
|
|
385
|
+
restore(snapshot, options = {}) {
|
|
386
|
+
this.cancelDrag();
|
|
387
|
+
const byId = new Map();
|
|
388
|
+
for (const pile of this._piles)
|
|
389
|
+
for (const card of pile._cards)
|
|
390
|
+
byId.set(card.id, card);
|
|
391
|
+
for (const card of this._views.keys())
|
|
392
|
+
byId.set(card.id, card);
|
|
393
|
+
const kept = new Set();
|
|
394
|
+
this._piles.forEach((pile, i) => {
|
|
395
|
+
const saved = snapshot.piles[i] ?? [];
|
|
396
|
+
pile._cards = saved.map((data) => {
|
|
397
|
+
const card = byId.get(data.id) ?? { ...data };
|
|
398
|
+
Object.assign(card, data);
|
|
399
|
+
kept.add(card);
|
|
400
|
+
return card;
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
for (const [card, view] of this._views) {
|
|
404
|
+
if (!kept.has(card))
|
|
405
|
+
this.removeView(card, view);
|
|
406
|
+
}
|
|
407
|
+
const all = [...kept];
|
|
408
|
+
for (const card of all)
|
|
409
|
+
this.schedule(card, options);
|
|
410
|
+
return this.whenIdle(all);
|
|
411
|
+
}
|
|
412
|
+
/** Advances input and animation. Call it once per frame from `onUpdate`. */
|
|
413
|
+
update(dt) {
|
|
414
|
+
const dtMs = Math.max(0, finiteOr(dt, 0)) * 1000;
|
|
415
|
+
this.updateInput();
|
|
416
|
+
this.updateViews(dtMs);
|
|
417
|
+
}
|
|
418
|
+
// -------------------------------------------------------------------------
|
|
419
|
+
// Presentation
|
|
420
|
+
// -------------------------------------------------------------------------
|
|
421
|
+
/** @internal */
|
|
422
|
+
schedule(card, options) {
|
|
423
|
+
const view = this._views.get(card);
|
|
424
|
+
if (!view)
|
|
425
|
+
return;
|
|
426
|
+
if (typeof options.delayMs === "number" && options.delayMs > 0)
|
|
427
|
+
view.waitMs = options.delayMs;
|
|
428
|
+
if (typeof options.durationMs === "number")
|
|
429
|
+
view.nextDurationMs = Math.max(0, options.durationMs);
|
|
430
|
+
}
|
|
431
|
+
/** @internal */
|
|
432
|
+
whenIdle(cards) {
|
|
433
|
+
if (cards.length === 0)
|
|
434
|
+
return Promise.resolve();
|
|
435
|
+
return new Promise((resolve) => {
|
|
436
|
+
let pending = cards.length;
|
|
437
|
+
const done = () => {
|
|
438
|
+
pending -= 1;
|
|
439
|
+
if (pending === 0)
|
|
440
|
+
resolve();
|
|
441
|
+
};
|
|
442
|
+
for (const card of cards) {
|
|
443
|
+
const view = this._views.get(card);
|
|
444
|
+
if (view)
|
|
445
|
+
view.waiters.push(done);
|
|
446
|
+
else
|
|
447
|
+
this.pendingWaiters(card).push(done);
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
/** @internal */
|
|
452
|
+
pendingWaiters(card) {
|
|
453
|
+
let list = this._pendingWaiters.get(card);
|
|
454
|
+
if (!list) {
|
|
455
|
+
list = [];
|
|
456
|
+
this._pendingWaiters.set(card, list);
|
|
457
|
+
}
|
|
458
|
+
return list;
|
|
459
|
+
}
|
|
460
|
+
/** @internal */
|
|
461
|
+
faceKey(card) {
|
|
462
|
+
const face = this._face;
|
|
463
|
+
if (typeof face === "function") {
|
|
464
|
+
const key = face(card);
|
|
465
|
+
if (typeof key !== "string" || !this.game.hasImage(key)) {
|
|
466
|
+
throw new Error(`MinimoJS cards: front image '${key}' of card '${card.id}' is not loaded. Load it in game.onPreload.`);
|
|
467
|
+
}
|
|
468
|
+
return key;
|
|
469
|
+
}
|
|
470
|
+
const key = `cards:${this._id}:face:${card.id}`;
|
|
471
|
+
if (!this._paintedFaces.has(key)) {
|
|
472
|
+
this.paintFace(key, card, face);
|
|
473
|
+
this._paintedFaces.set(key, card);
|
|
474
|
+
}
|
|
475
|
+
return key;
|
|
476
|
+
}
|
|
477
|
+
/** @internal */
|
|
478
|
+
paintFace(key, card, face) {
|
|
479
|
+
this.game.createTexture(key, this.cardWidth, this.cardHeight, (ctx) => paintStandardFace(this.game, ctx, card, face, this.cardWidth, this.cardHeight));
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* @internal A face painted before its font arrived would keep the fallback
|
|
483
|
+
* font forever, so faces are repainted once the font is ready.
|
|
484
|
+
*/
|
|
485
|
+
whenFontReady(face) {
|
|
486
|
+
const fonts = typeof document !== "undefined" ? document.fonts : undefined;
|
|
487
|
+
if (!fonts)
|
|
488
|
+
return;
|
|
489
|
+
const spec = `${face.fontWeight ?? "700"} 32px ${face.font}`;
|
|
490
|
+
if (fonts.check(spec))
|
|
491
|
+
return;
|
|
492
|
+
fonts.load(spec).then(() => {
|
|
493
|
+
for (const [key, card] of this._paintedFaces)
|
|
494
|
+
this.paintFace(key, card, face);
|
|
495
|
+
}, () => { });
|
|
496
|
+
}
|
|
497
|
+
/** @internal */
|
|
498
|
+
showSide(view, faceUp) {
|
|
499
|
+
const key = faceUp ? this.faceKey(view.card) : this._backKey;
|
|
500
|
+
view.sprite.setTexture(key);
|
|
501
|
+
view.shownFaceUp = faceUp;
|
|
502
|
+
this.applyScale(view);
|
|
503
|
+
}
|
|
504
|
+
/** @internal */
|
|
505
|
+
applyScale(view) {
|
|
506
|
+
const sprite = view.sprite;
|
|
507
|
+
const fit = Math.min(this.cardWidth / sprite.width, this.cardHeight / sprite.height);
|
|
508
|
+
sprite.scale = fit * (view.dragging ? 1.06 : 1);
|
|
509
|
+
}
|
|
510
|
+
/** @internal */
|
|
511
|
+
createView(card, x, y, layer) {
|
|
512
|
+
const sprite = this.game.add(new ImageSprite(this.game, this._backKey, x, y));
|
|
513
|
+
sprite.layer = layer;
|
|
514
|
+
const view = {
|
|
515
|
+
card,
|
|
516
|
+
sprite,
|
|
517
|
+
shownFaceUp: false,
|
|
518
|
+
toX: x,
|
|
519
|
+
toY: y,
|
|
520
|
+
fromX: x,
|
|
521
|
+
fromY: y,
|
|
522
|
+
elapsedMs: 0,
|
|
523
|
+
durationMs: 0,
|
|
524
|
+
moving: false,
|
|
525
|
+
flipping: false,
|
|
526
|
+
waitMs: 0,
|
|
527
|
+
nextDurationMs: null,
|
|
528
|
+
dragging: false,
|
|
529
|
+
grabX: 0,
|
|
530
|
+
grabY: 0,
|
|
531
|
+
waiters: this._pendingWaiters.get(card) ?? [],
|
|
532
|
+
};
|
|
533
|
+
this._pendingWaiters.delete(card);
|
|
534
|
+
this.showSide(view, card.faceUp);
|
|
535
|
+
this._views.set(card, view);
|
|
536
|
+
return view;
|
|
537
|
+
}
|
|
538
|
+
/** @internal */
|
|
539
|
+
removeView(card, view) {
|
|
540
|
+
this.game.destroySprite(view.sprite);
|
|
541
|
+
this._views.delete(card);
|
|
542
|
+
for (const waiter of view.waiters.splice(0))
|
|
543
|
+
waiter();
|
|
544
|
+
}
|
|
545
|
+
/** @internal */
|
|
546
|
+
isIdle(view) {
|
|
547
|
+
return !view.moving && !view.flipping && !view.dragging && view.waitMs <= 0;
|
|
548
|
+
}
|
|
549
|
+
/** @internal */
|
|
550
|
+
updateViews(dtMs) {
|
|
551
|
+
for (const pile of this._piles) {
|
|
552
|
+
const positions = pile._layout();
|
|
553
|
+
pile._cards.forEach((card, i) => {
|
|
554
|
+
const target = positions[i];
|
|
555
|
+
const restLayer = this.layer + (i + 1) * 0.01;
|
|
556
|
+
const view = this._views.get(card) ?? this.createView(card, target.x, target.y, restLayer);
|
|
557
|
+
this.updateView(view, target.x, target.y, restLayer, dtMs);
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
for (const view of this._views.values()) {
|
|
561
|
+
if (this.isIdle(view) && view.waiters.length > 0) {
|
|
562
|
+
for (const waiter of view.waiters.splice(0))
|
|
563
|
+
waiter();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/** @internal */
|
|
568
|
+
updateView(view, x, y, restLayer, dtMs) {
|
|
569
|
+
if (view.dragging)
|
|
570
|
+
return;
|
|
571
|
+
if (view.waitMs > 0) {
|
|
572
|
+
view.waitMs -= dtMs;
|
|
573
|
+
if (view.waitMs > 0)
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const sprite = view.sprite;
|
|
577
|
+
if (x !== view.toX || y !== view.toY) {
|
|
578
|
+
const duration = view.nextDurationMs ?? this.moveMs;
|
|
579
|
+
view.nextDurationMs = null;
|
|
580
|
+
view.toX = x;
|
|
581
|
+
view.toY = y;
|
|
582
|
+
if (duration <= 0) {
|
|
583
|
+
sprite.x = x;
|
|
584
|
+
sprite.y = y;
|
|
585
|
+
view.moving = false;
|
|
586
|
+
if (view.shownFaceUp !== view.card.faceUp && !view.flipping)
|
|
587
|
+
this.showSide(view, view.card.faceUp);
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
view.fromX = sprite.x;
|
|
591
|
+
view.fromY = sprite.y;
|
|
592
|
+
view.elapsedMs = 0;
|
|
593
|
+
view.durationMs = duration;
|
|
594
|
+
view.moving = true;
|
|
595
|
+
this._flightCount += 1;
|
|
596
|
+
sprite.layer = this.layer + 500 + (this._flightCount % 1000) * 0.001;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
else if (!view.moving) {
|
|
600
|
+
view.nextDurationMs = null;
|
|
601
|
+
}
|
|
602
|
+
if (view.shownFaceUp !== view.card.faceUp && !view.flipping)
|
|
603
|
+
this.startFlip(view);
|
|
604
|
+
if (view.moving) {
|
|
605
|
+
view.elapsedMs += dtMs;
|
|
606
|
+
const t = Math.min(1, view.elapsedMs / view.durationMs);
|
|
607
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
608
|
+
sprite.x = view.fromX + (view.toX - view.fromX) * eased;
|
|
609
|
+
sprite.y = view.fromY + (view.toY - view.fromY) * eased;
|
|
610
|
+
if (t >= 1)
|
|
611
|
+
view.moving = false;
|
|
612
|
+
}
|
|
613
|
+
if (!view.moving)
|
|
614
|
+
sprite.layer = restLayer;
|
|
615
|
+
}
|
|
616
|
+
/** @internal */
|
|
617
|
+
startFlip(view) {
|
|
618
|
+
const half = this.flipMs / 2;
|
|
619
|
+
if (half <= 0) {
|
|
620
|
+
this.showSide(view, view.card.faceUp);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
view.flipping = true;
|
|
624
|
+
const sprite = view.sprite;
|
|
625
|
+
this.game.animateDeform(sprite, 0, 1.06, 0.5, 0.5, half, () => {
|
|
626
|
+
if (!this._views.has(view.card))
|
|
627
|
+
return;
|
|
628
|
+
this.showSide(view, view.card.faceUp);
|
|
629
|
+
this.game.animateDeform(sprite, 1, 1, 0.5, 0.5, half, () => {
|
|
630
|
+
view.flipping = false;
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
// -------------------------------------------------------------------------
|
|
635
|
+
// Input
|
|
636
|
+
// -------------------------------------------------------------------------
|
|
637
|
+
/** @internal */
|
|
638
|
+
updateInput() {
|
|
639
|
+
const game = this.game;
|
|
640
|
+
if (!this.enabled) {
|
|
641
|
+
this._press = null;
|
|
642
|
+
this.cancelDrag();
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
// The pointer is in screen space and the piles and cards are in world space.
|
|
646
|
+
const x = game.pointerX;
|
|
647
|
+
const y = game.pointerY;
|
|
648
|
+
const worldX = x + game.scrollX;
|
|
649
|
+
const worldY = y + game.scrollY;
|
|
650
|
+
if (game.isPointerPressed()) {
|
|
651
|
+
this.cancelDrag();
|
|
652
|
+
const hit = this.hitTest(worldX, worldY);
|
|
653
|
+
this._press = hit ? { x, y, worldX, worldY, card: hit.card, pile: hit.pile, dragTried: false } : null;
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const press = this._press;
|
|
657
|
+
if (!press)
|
|
658
|
+
return;
|
|
659
|
+
if (game.isPointerDown()) {
|
|
660
|
+
if (!this._drag && !press.dragTried && Math.hypot(x - press.x, y - press.y) >= this.dragThreshold) {
|
|
661
|
+
this.startDrag(press);
|
|
662
|
+
}
|
|
663
|
+
if (this._drag)
|
|
664
|
+
this.followPointer(worldX, worldY);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
this._press = null;
|
|
668
|
+
// A cancelled touch was interrupted, not released: return the cards, tap nothing.
|
|
669
|
+
if (game.isPointerCancelled())
|
|
670
|
+
this.cancelDrag();
|
|
671
|
+
else if (this._drag)
|
|
672
|
+
this.finishDrag();
|
|
673
|
+
else if (Math.hypot(x - press.x, y - press.y) < this.dragThreshold)
|
|
674
|
+
this.onTap?.(press.card, press.pile);
|
|
675
|
+
}
|
|
676
|
+
/** @internal */
|
|
677
|
+
hitTest(x, y) {
|
|
678
|
+
const halfW = this.cardWidth / 2;
|
|
679
|
+
const halfH = this.cardHeight / 2;
|
|
680
|
+
for (let p = this._piles.length - 1; p >= 0; p--) {
|
|
681
|
+
const pile = this._piles[p];
|
|
682
|
+
const positions = pile._layout();
|
|
683
|
+
for (let i = pile._cards.length - 1; i >= 0; i--) {
|
|
684
|
+
const at = positions[i];
|
|
685
|
+
if (Math.abs(x - at.x) <= halfW && Math.abs(y - at.y) <= halfH) {
|
|
686
|
+
return { card: pile._cards[i], pile };
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
for (let p = this._piles.length - 1; p >= 0; p--) {
|
|
691
|
+
const pile = this._piles[p];
|
|
692
|
+
if (pile._cards.length === 0 && Math.abs(x - pile.x) <= halfW && Math.abs(y - pile.y) <= halfH) {
|
|
693
|
+
return { card: null, pile };
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
/** @internal */
|
|
699
|
+
startDrag(press) {
|
|
700
|
+
const { card, pile } = press;
|
|
701
|
+
press.dragTried = true;
|
|
702
|
+
if (!card || pile.drag === "none")
|
|
703
|
+
return;
|
|
704
|
+
const index = pile._cards.indexOf(card);
|
|
705
|
+
if (index < 0)
|
|
706
|
+
return;
|
|
707
|
+
let cards;
|
|
708
|
+
if (pile.drag === "tail")
|
|
709
|
+
cards = pile._cards.slice(index);
|
|
710
|
+
else if (pile.drag === "any")
|
|
711
|
+
cards = [card];
|
|
712
|
+
else if (index === pile._cards.length - 1)
|
|
713
|
+
cards = [card];
|
|
714
|
+
else
|
|
715
|
+
return;
|
|
716
|
+
if (cards.some((c) => !c.faceUp))
|
|
717
|
+
return;
|
|
718
|
+
if (this.canDrag && this.canDrag(cards, pile) === false)
|
|
719
|
+
return;
|
|
720
|
+
this._drag = { cards, from: pile };
|
|
721
|
+
cards.forEach((c, i) => {
|
|
722
|
+
const view = this._views.get(c);
|
|
723
|
+
if (!view)
|
|
724
|
+
return;
|
|
725
|
+
view.moving = false;
|
|
726
|
+
view.waitMs = 0;
|
|
727
|
+
view.dragging = true;
|
|
728
|
+
view.grabX = press.worldX - view.sprite.x;
|
|
729
|
+
view.grabY = press.worldY - view.sprite.y;
|
|
730
|
+
view.sprite.layer = this.layer + 1000 + i * 0.01;
|
|
731
|
+
this.applyScale(view);
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
/** @internal */
|
|
735
|
+
followPointer(x, y) {
|
|
736
|
+
for (const card of this._drag?.cards ?? []) {
|
|
737
|
+
const view = this._views.get(card);
|
|
738
|
+
if (!view)
|
|
739
|
+
continue;
|
|
740
|
+
view.sprite.x = x - view.grabX;
|
|
741
|
+
view.sprite.y = y - view.grabY;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/** @internal */
|
|
745
|
+
finishDrag() {
|
|
746
|
+
const drag = this._drag;
|
|
747
|
+
if (!drag)
|
|
748
|
+
return;
|
|
749
|
+
const lead = this._views.get(drag.cards[0]);
|
|
750
|
+
let target = null;
|
|
751
|
+
if (lead) {
|
|
752
|
+
let best = 0;
|
|
753
|
+
for (const pile of this._piles) {
|
|
754
|
+
if (pile === drag.from)
|
|
755
|
+
continue;
|
|
756
|
+
const area = this.dropOverlap(pile, lead.sprite.x, lead.sprite.y);
|
|
757
|
+
if (area <= best)
|
|
758
|
+
continue;
|
|
759
|
+
if (this.canDrop && this.canDrop(drag.cards, drag.from, pile) !== true)
|
|
760
|
+
continue;
|
|
761
|
+
best = area;
|
|
762
|
+
target = pile;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
this.releaseDrag();
|
|
766
|
+
if (!target)
|
|
767
|
+
return;
|
|
768
|
+
if (this.onDrop)
|
|
769
|
+
this.onDrop(drag.cards, drag.from, target);
|
|
770
|
+
else
|
|
771
|
+
this.move(drag.cards, target);
|
|
772
|
+
}
|
|
773
|
+
/** @internal Overlap between the dragged lead card and the spot a pile would take it. */
|
|
774
|
+
dropOverlap(pile, x, y) {
|
|
775
|
+
const at = pile._cards.length > 0 ? pile.cardPosition(pile._cards.length - 1) : { x: pile.x, y: pile.y };
|
|
776
|
+
const overlapX = Math.max(0, this.cardWidth - Math.abs(x - at.x));
|
|
777
|
+
const overlapY = Math.max(0, this.cardHeight - Math.abs(y - at.y));
|
|
778
|
+
return overlapX * overlapY;
|
|
779
|
+
}
|
|
780
|
+
/** @internal */
|
|
781
|
+
cancelDrag() {
|
|
782
|
+
if (this._drag)
|
|
783
|
+
this.releaseDrag();
|
|
784
|
+
}
|
|
785
|
+
/** @internal */
|
|
786
|
+
releaseDrag() {
|
|
787
|
+
const drag = this._drag;
|
|
788
|
+
if (!drag)
|
|
789
|
+
return;
|
|
790
|
+
this._drag = null;
|
|
791
|
+
for (const card of drag.cards) {
|
|
792
|
+
const view = this._views.get(card);
|
|
793
|
+
if (!view)
|
|
794
|
+
continue;
|
|
795
|
+
view.dragging = false;
|
|
796
|
+
// Forces a motion from where the card was dropped back to its resting place.
|
|
797
|
+
view.toX = Number.NaN;
|
|
798
|
+
view.toY = Number.NaN;
|
|
799
|
+
view.nextDurationMs = Math.round(this.moveMs * 0.8);
|
|
800
|
+
this.applyScale(view);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
function requireImage(game, key, option) {
|
|
805
|
+
if (typeof key !== "string" || !game.hasImage(key)) {
|
|
806
|
+
throw new Error(`MinimoJS cards: ${option} image '${key}' is not loaded. Card art always comes from the game's images; ` +
|
|
807
|
+
"load it with game.loadImage in game.onPreload.");
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
function finiteOr(value, fallback) {
|
|
811
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
812
|
+
}
|
|
813
|
+
function toCardList(cards) {
|
|
814
|
+
if (!cards)
|
|
815
|
+
return [];
|
|
816
|
+
if (Array.isArray(cards))
|
|
817
|
+
return [...cards];
|
|
818
|
+
return [cards];
|
|
819
|
+
}
|