minimojs 1.0.0-alpha.4 → 1.0.0-alpha.41

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.
Files changed (71) hide show
  1. package/README.md +58 -20
  2. package/dist/internal/AnimationSystem.js +345 -0
  3. package/dist/internal/AssetSystem.d.ts +6 -0
  4. package/dist/internal/AssetSystem.js +281 -0
  5. package/dist/internal/BackgroundSystem.d.ts +1 -0
  6. package/dist/internal/BackgroundSystem.js +26 -0
  7. package/dist/internal/CanvasSystem.d.ts +1 -0
  8. package/dist/internal/CanvasSystem.js +51 -0
  9. package/dist/internal/DesignNamingCheck.d.ts +1 -0
  10. package/dist/internal/DesignNamingCheck.js +7 -0
  11. package/dist/internal/ExplosionSystem.d.ts +1 -0
  12. package/dist/internal/ExplosionSystem.js +540 -0
  13. package/dist/internal/InputSystem.d.ts +1 -0
  14. package/dist/internal/InputSystem.js +292 -0
  15. package/dist/internal/LoopSystem.d.ts +1 -0
  16. package/dist/internal/LoopSystem.js +61 -0
  17. package/dist/internal/PhysicsSystem.d.ts +1 -0
  18. package/dist/internal/PhysicsSystem.js +174 -0
  19. package/dist/internal/RenderSystem.d.ts +1 -0
  20. package/dist/internal/RenderSystem.js +1021 -0
  21. package/dist/internal/SoundSystem.d.ts +39 -0
  22. package/dist/internal/SoundSystem.js +292 -0
  23. package/dist/internal/SpriteSystem.d.ts +1 -0
  24. package/dist/internal/SpriteSystem.js +32 -0
  25. package/dist/internal/TextSystem.d.ts +1 -0
  26. package/dist/internal/TextSystem.js +16 -0
  27. package/dist/internal/TimerSystem.d.ts +1 -0
  28. package/dist/internal/TimerSystem.js +43 -0
  29. package/dist/internal/TrailSystem.d.ts +1 -0
  30. package/dist/internal/TrailSystem.js +116 -0
  31. package/dist/internal/TransitionSystem.d.ts +1 -0
  32. package/dist/internal/TransitionSystem.js +74 -0
  33. package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.d.ts +1 -0
  34. package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.js +198 -0
  35. package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.d.ts +1 -0
  36. package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.js +120 -0
  37. package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.d.ts +1 -0
  38. package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.js +599 -0
  39. package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.d.ts +1 -0
  40. package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.js +13 -0
  41. package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.d.ts +1 -0
  42. package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.js +447 -0
  43. package/dist/minimo-ambient.d.ts +299 -0
  44. package/dist/minimo-ambient.js +1445 -0
  45. package/dist/minimo-arcaderacer.d.ts +1431 -0
  46. package/dist/minimo-arcaderacer.js +2060 -0
  47. package/dist/minimo-cards.d.ts +359 -0
  48. package/dist/minimo-cards.js +819 -0
  49. package/dist/minimo-dice.d.ts +206 -0
  50. package/dist/minimo-dice.js +719 -0
  51. package/dist/minimo-fire.d.ts +282 -0
  52. package/dist/minimo-fire.js +930 -0
  53. package/dist/minimo-format.d.ts +632 -0
  54. package/dist/minimo-format.js +669 -0
  55. package/dist/minimo-leaderboard.d.ts +199 -0
  56. package/dist/minimo-leaderboard.js +566 -0
  57. package/dist/minimo-multiplayer.d.ts +387 -0
  58. package/dist/minimo-multiplayer.js +1323 -0
  59. package/dist/minimo.d.ts +1690 -233
  60. package/dist/minimo.js +2587 -892
  61. package/package.json +10 -3
  62. package/dist/animations.js +0 -30
  63. package/dist/audio.js +0 -17
  64. package/dist/game.js +0 -1105
  65. package/dist/input.js +0 -185
  66. package/dist/internal-types.js +0 -4
  67. package/dist/physics.js +0 -10
  68. package/dist/render.js +0 -75
  69. package/dist/sprite.js +0 -149
  70. package/dist/timers.js +0 -23
  71. /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
+ }