minimojs 1.0.0-alpha.21 → 1.0.0-alpha.23

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