minimojs 1.0.0-alpha.4 → 1.0.0-alpha.40

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 +374 -0
  58. package/dist/minimo-multiplayer.js +1310 -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,719 @@
1
+ import { DrawSprite } from "./minimo.js";
2
+ const DEFAULT_SPIN = { x: 600, y: 420, z: 90 };
3
+ const DEFAULT_LOOK = {
4
+ color: "#f4f1ea",
5
+ edgeColor: "rgba(0, 0, 0, 0.35)",
6
+ markColor: "#1b1f1d",
7
+ font: "monospace",
8
+ fontWeight: "700",
9
+ };
10
+ // The player looks at a die slightly from above and from the left, so a die
11
+ // at rest shows some of its sides and reads as a solid rather than a flat
12
+ // shape. The face that counts is the one facing the player before this tilt.
13
+ const VIEW = qMul(qAxisAngle([1, 0, 0], 0.28), qAxisAngle([0, 1, 0], 0.18));
14
+ const LIGHT = normalize([-0.45, 0.6, 0.75]);
15
+ const HIGHLIGHT = normalize(add(LIGHT, [0, 0, 1]));
16
+ /**
17
+ * A set of dice that share a look. It makes each die and advances all of them
18
+ * with one {@link Dice.update} call per frame.
19
+ *
20
+ * The set never decides a result: the game chooses each value, and the dice
21
+ * turn and land on it. The same value, spin, and duration turn a die the same
22
+ * way on every device, so a multiplayer room can put them in its shared state
23
+ * and every player sees the same roll.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * const dice = new Dice(game, { color: "#f4f1ea", font: '"Lilita One"' });
28
+ * const d20 = dice.create(20, { x: 200, y: 300, size: 72 });
29
+ * game.onUpdate = (dt) => dice.update(dt);
30
+ *
31
+ * const value = 1 + Math.floor(game.random() * 20);
32
+ * await d20.roll(value, { spin: { x: 540, y: -360, z: 90 }, ms: 1200 });
33
+ * ```
34
+ *
35
+ * ## In a design
36
+ *
37
+ * A design can place dice, so the creator moves them in the design editor. A
38
+ * die is a `module` object of a version 3 design whose `kind` is `"d4"`,
39
+ * `"d6"`, `"d8"`, `"d10"`, `"d12"`, or `"d20"`, and a design sets only its
40
+ * `x` and `y`. Its look and size are the set's, as for {@link Dice.create}
41
+ * without options, and it shows its 1 until the game rolls it.
42
+ *
43
+ * ```json
44
+ * { "id": "9a2c4e6f-1b3d-4f5a-8c7e-0d2f4b6a8c1e", "name": "d20", "type": "module", "module": "dice", "kind": "d20", "x": 360, "y": 640 }
45
+ * ```
46
+ *
47
+ * Create the set in the scene's `onCreate()` before the design's objects,
48
+ * then find the die by its name:
49
+ *
50
+ * ```ts
51
+ * onCreate() {
52
+ * this.dice = new Dice(game, { color: "#f4f1ea" });
53
+ * this.d20 = game.createObjectList(board.objects).findByName("d20");
54
+ * }
55
+ * ```
56
+ */
57
+ export class Dice {
58
+ constructor(game, look = {}) {
59
+ /** @internal */
60
+ this._dice = [];
61
+ this.game = game;
62
+ this.look = { ...look };
63
+ game._registerDesignModule("dice", (obj) => this._fromDesign(obj));
64
+ }
65
+ /** The dice of this set, in the order they were made. */
66
+ get dice() {
67
+ return this._dice;
68
+ }
69
+ /**
70
+ * Makes a die, adds it to the game, and returns it. The die is a sprite: the
71
+ * game can move it, give it gravity, and test its collisions while it turns.
72
+ * @param sides - `4`, `6`, `8`, `10`, `12`, or `20`.
73
+ * @param options - Position, size, first face, and any look over the set's.
74
+ */
75
+ create(sides, options = {}) {
76
+ const die = new Die(sides, { ...this.look, ...options });
77
+ this._dice.push(die);
78
+ this.game.add(die);
79
+ return die;
80
+ }
81
+ /** Removes a die from the game and from this set. */
82
+ remove(die) {
83
+ const index = this._dice.indexOf(die);
84
+ if (index >= 0)
85
+ this._dice.splice(index, 1);
86
+ this.game.destroySprite(die);
87
+ }
88
+ /**
89
+ * Builds a die placed in a design, with the set's look. The game adds it.
90
+ *
91
+ * @internal
92
+ */
93
+ _fromDesign(obj) {
94
+ const die = new Die(Number(obj.kind.slice(1)), { ...this.look });
95
+ die._pending = true;
96
+ this._dice.push(die);
97
+ return die;
98
+ }
99
+ /**
100
+ * Advances every die of the set. Call it once per frame from `onUpdate`.
101
+ * A die the game destroyed, such as by `game.reset()`, leaves the set.
102
+ */
103
+ update(dt) {
104
+ const alive = new Set(this.game.getSprites());
105
+ for (const die of [...this._dice]) {
106
+ if (!alive.has(die)) {
107
+ // A die a design built waits to be added, as with createObject's
108
+ // `add: false`; one that was in the game and left it is dropped.
109
+ if (!die._pending)
110
+ this._dice.splice(this._dice.indexOf(die), 1);
111
+ continue;
112
+ }
113
+ die._pending = false;
114
+ die.update(dt);
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * One die, drawn in simulated 3D: a real solid, turned, shaded, and projected
120
+ * onto the 2D canvas. It is a `DrawSprite`, so it has everything a sprite has.
121
+ * The module turns it in place; moving it is the game's, with the same
122
+ * physics as any other sprite.
123
+ *
124
+ * Make dice with {@link Dice.create}, which also advances them. A die made
125
+ * with `new Die` must be added with `game.add` and advanced with
126
+ * {@link Die.update} by the game.
127
+ */
128
+ export class Die extends DrawSprite {
129
+ /**
130
+ * @param sides - `4`, `6`, `8`, `10`, `12`, or `20`.
131
+ * @param options - Position, size, first face, and look.
132
+ */
133
+ constructor(sides, options = {}) {
134
+ const solid = solidFor(sides);
135
+ const size = Math.max(4, finiteOr(options.size, 64));
136
+ const scale = size / solid.restExtent;
137
+ const surface = Math.ceil(2 * solid.radius * scale + size * 0.06 + 4);
138
+ super(surface, surface, finiteOr(options.x, 0), finiteOr(options.y, 0));
139
+ /** Called with the value whenever the die comes to rest after turning. */
140
+ this.onLand = null;
141
+ /** @internal Radians per second, in world axes. */
142
+ this._spin = [0, 0, 0];
143
+ /** @internal */
144
+ this._turning = false;
145
+ /** @internal */
146
+ this._landing = null;
147
+ /** @internal */
148
+ this._waiting = [];
149
+ /** @internal Built from a design and not yet seen in the game. */
150
+ this._pending = false;
151
+ this.sides = sides;
152
+ this.size = size;
153
+ this.bodyWidth = size;
154
+ this.bodyHeight = size;
155
+ this._solid = solid;
156
+ this._scale = scale;
157
+ const { x: _x, y: _y, size: _size, value: _value, ...look } = options;
158
+ this._look = look;
159
+ this._orientation = restOrientation(solid, this._checkValue(options.value ?? 1));
160
+ }
161
+ /** @internal A design editor selects the die itself, not the room its surface keeps for turning. */
162
+ _editorBox() {
163
+ const scale = Number.isFinite(this.scale) ? Math.max(0, this.scale) : 1;
164
+ return { cx: this.renderX, cy: this.renderY, w: this.size * scale, h: this.size * scale };
165
+ }
166
+ /** The look of this die. Change it with {@link Die.setLook}. */
167
+ get look() {
168
+ return this._look;
169
+ }
170
+ /** Changes some of the die's look, keeping the rest. */
171
+ setLook(look) {
172
+ this._look = { ...this._look, ...look };
173
+ this.frozen = false;
174
+ }
175
+ /**
176
+ * The value facing the player: the result once the die is at rest, and
177
+ * whatever face is in front while it turns. A d4 is read at its top corner,
178
+ * as a real one is: its value is the corner pointing up.
179
+ */
180
+ get value() {
181
+ return valueFacing(this._solid, this._orientation);
182
+ }
183
+ /** `true` while the die turns, from a spin or a roll until it lands. */
184
+ get rolling() {
185
+ return this._turning;
186
+ }
187
+ /**
188
+ * Starts the die turning, or changes how it turns, and keeps it turning
189
+ * until {@link Die.land} or {@link Die.show}. A landing in progress stops,
190
+ * and the die turns freely again.
191
+ */
192
+ spin(spin) {
193
+ this._spin = spinVector(spin);
194
+ this._landing = null;
195
+ this._turning = true;
196
+ this.frozen = false;
197
+ }
198
+ /**
199
+ * Slows the die down and settles it on `value`, upright, in `ms`. The die
200
+ * keeps the direction and speed it was turning at, and slows to a stop
201
+ * exactly on its face.
202
+ * @returns A promise that resolves with the value when the die is at rest.
203
+ */
204
+ land(value, ms = 800) {
205
+ const result = this._checkValue(value);
206
+ const promise = this._wait();
207
+ const seconds = Math.max(0, finiteOr(ms, 800)) / 1000;
208
+ if (seconds === 0) {
209
+ this.show(result);
210
+ return promise;
211
+ }
212
+ const speed = length(this._spin);
213
+ const axis = speed > 0 ? scaleVec(this._spin, 1 / speed) : [0, 0, 1];
214
+ // The spin decays to zero over the landing, turning the die by half what
215
+ // it would have turned at full speed. A correction spread over the same
216
+ // time takes it from there to the chosen face.
217
+ const spunTo = qMul(qAxisAngle(axis, (speed * seconds) / 2), this._orientation);
218
+ const correction = qMul(restOrientation(this._solid, result), qConjugate(spunTo));
219
+ const [angle, correctionAxis] = qToAxisAngle(correction);
220
+ this._landing = {
221
+ from: this._orientation,
222
+ axis,
223
+ speed,
224
+ seconds,
225
+ elapsed: 0,
226
+ correctionAxis,
227
+ correctionAngle: angle,
228
+ value: result,
229
+ };
230
+ this._turning = true;
231
+ this.frozen = false;
232
+ return promise;
233
+ }
234
+ /**
235
+ * Throws the die: it starts turning with `spin` and lands on `value` after
236
+ * `ms`, slowing down all the way. This is the call most games need.
237
+ * @returns A promise that resolves with the value when the die is at rest.
238
+ */
239
+ roll(value, options = {}) {
240
+ const result = this._checkValue(value);
241
+ if (options.spin)
242
+ this._spin = spinVector(options.spin);
243
+ else if (!this._turning || length(this._spin) === 0)
244
+ this._spin = spinVector(DEFAULT_SPIN);
245
+ return this.land(result, finiteOr(options.ms, 1000));
246
+ }
247
+ /**
248
+ * Turns the die to `value` at once, with no animation, such as when a game
249
+ * restores its state. A die that was turning stops.
250
+ */
251
+ show(value) {
252
+ const result = this._checkValue(value);
253
+ this._orientation = restOrientation(this._solid, result);
254
+ this._spin = [0, 0, 0];
255
+ this._landing = null;
256
+ this._turning = false;
257
+ this.frozen = false;
258
+ this._settle(result, false);
259
+ }
260
+ /**
261
+ * Advances the die's turning. {@link Dice.update} calls it for every die of
262
+ * its set; call it yourself only for a die made with `new Die`.
263
+ */
264
+ update(dt) {
265
+ const seconds = Math.max(0, finiteOr(dt, 0));
266
+ if (!this._turning || seconds === 0)
267
+ return;
268
+ const landing = this._landing;
269
+ if (!landing) {
270
+ const speed = length(this._spin);
271
+ if (speed > 0) {
272
+ const step = qAxisAngle(scaleVec(this._spin, 1 / speed), speed * seconds);
273
+ this._orientation = qNormalize(qMul(step, this._orientation));
274
+ }
275
+ return;
276
+ }
277
+ landing.elapsed = Math.min(landing.seconds, landing.elapsed + seconds);
278
+ const t = landing.elapsed;
279
+ const T = landing.seconds;
280
+ if (t >= T) {
281
+ this._orientation = restOrientation(this._solid, landing.value);
282
+ this._spin = [0, 0, 0];
283
+ this._landing = null;
284
+ this._turning = false;
285
+ this._settle(landing.value, true);
286
+ return;
287
+ }
288
+ const spun = qMul(qAxisAngle(landing.axis, landing.speed * (t - (t * t) / (2 * T))), landing.from);
289
+ const u = t / T;
290
+ const eased = u * u * (3 - 2 * u);
291
+ this._orientation = qNormalize(qMul(qAxisAngle(landing.correctionAxis, landing.correctionAngle * eased), spun));
292
+ this._spin = scaleVec(landing.axis, landing.speed * (1 - u));
293
+ }
294
+ redraw(ctx) {
295
+ const solid = this._solid;
296
+ const look = this._look;
297
+ const s = this._scale;
298
+ const c = this.width / 2;
299
+ const toView = qMul(VIEW, this._orientation);
300
+ const points = solid.vertices.map((v) => qRotate(toView, v));
301
+ const project = (v) => [c + v[0] * s, c - v[1] * s];
302
+ const edgeWidth = Math.max(1, this.size * 0.025);
303
+ for (const face of solid.faces) {
304
+ const normal = qRotate(toView, face.normal);
305
+ if (normal[2] <= 1e-4)
306
+ continue;
307
+ const corners = face.verts.map((i) => project(points[i]));
308
+ ctx.save();
309
+ polygon(ctx, corners);
310
+ ctx.fillStyle = (face.value && look.faceColors?.[face.value]) || look.color || DEFAULT_LOOK.color;
311
+ ctx.fill();
312
+ ctx.clip();
313
+ this._drawMarks(ctx, face, toView, project);
314
+ const lit = Math.max(0, dot(normal, LIGHT));
315
+ polygon(ctx, corners);
316
+ ctx.fillStyle = `rgba(0, 0, 0, ${((1 - lit) * 0.5).toFixed(3)})`;
317
+ ctx.fill();
318
+ const shine = Math.pow(Math.max(0, dot(normal, HIGHLIGHT)), 24) * 0.35;
319
+ if (shine > 0.005) {
320
+ ctx.fillStyle = `rgba(255, 255, 255, ${shine.toFixed(3)})`;
321
+ ctx.fill();
322
+ }
323
+ ctx.restore();
324
+ polygon(ctx, corners);
325
+ ctx.lineJoin = "round";
326
+ ctx.lineWidth = edgeWidth;
327
+ ctx.strokeStyle = look.edgeColor || DEFAULT_LOOK.edgeColor;
328
+ ctx.stroke();
329
+ }
330
+ this.frozen = !this._turning;
331
+ }
332
+ /** @internal Draws one face's pips, numbers, or image, in the face's own plane. */
333
+ _drawMarks(ctx, face, toView, project) {
334
+ const solid = this._solid;
335
+ if (solid.sides === 4) {
336
+ // A d4 is read at its corners: each face shows, near each corner, the
337
+ // value of that corner, upright toward it.
338
+ for (const index of face.verts) {
339
+ const corner = solid.vertices[index];
340
+ const toCorner = sub(corner, face.center);
341
+ const origin = add(face.center, scaleVec(toCorner, 0.55));
342
+ const up = normalize(toCorner);
343
+ this._inFrame(ctx, origin, up, face, toView, project, () => {
344
+ this._drawValue(ctx, index + 1, solid.markScale * 0.62, false);
345
+ });
346
+ }
347
+ return;
348
+ }
349
+ this._inFrame(ctx, face.center, face.up, face, toView, project, () => {
350
+ const pips = solid.sides === 6 && (this._look.marks ?? "pips") === "pips";
351
+ this._drawValue(ctx, face.value, solid.markScale, pips);
352
+ });
353
+ }
354
+ /**
355
+ * @internal Runs `draw` in a frame on the face's plane: the origin at
356
+ * `origin`, `up` reading upright, and 100 units for the face's inradius.
357
+ */
358
+ _inFrame(ctx, origin, up, face, toView, project, draw) {
359
+ const right = cross(up, face.normal);
360
+ const unit = face.inradius / 100;
361
+ const o = project(qRotate(toView, origin));
362
+ const r = qRotate(toView, right);
363
+ const u = qRotate(toView, up);
364
+ const s = this._scale * unit;
365
+ ctx.save();
366
+ // Local x runs along the face's right, local y down the face, as on a canvas.
367
+ ctx.transform(r[0] * s, -r[1] * s, -u[0] * s, u[1] * s, o[0], o[1]);
368
+ draw();
369
+ ctx.restore();
370
+ }
371
+ /** @internal Draws a value's image, pips, or number, centered, in face units. */
372
+ _drawValue(ctx, value, markScale, pips) {
373
+ const look = this._look;
374
+ const imageKey = look.faces?.[value];
375
+ const image = imageKey ? this.game?.getImage(imageKey) : undefined;
376
+ if (image) {
377
+ const half = this.sides === 6 ? 100 : this.sides === 4 ? 70 : 110;
378
+ ctx.drawImage(image, -half, -half, half * 2, half * 2);
379
+ return;
380
+ }
381
+ ctx.fillStyle = look.markColor || DEFAULT_LOOK.markColor;
382
+ if (pips) {
383
+ for (const [px, py] of PIPS[value] ?? []) {
384
+ ctx.beginPath();
385
+ ctx.arc(px * 52, py * 52, 17, 0, Math.PI * 2);
386
+ ctx.fill();
387
+ }
388
+ return;
389
+ }
390
+ const fontSize = Math.round(100 * markScale);
391
+ const label = String(value);
392
+ ctx.font = `${look.fontWeight || DEFAULT_LOOK.fontWeight} ${fontSize}px ${look.font || DEFAULT_LOOK.font}`;
393
+ ctx.textAlign = "center";
394
+ ctx.textBaseline = "middle";
395
+ ctx.fillText(label, 0, 0);
396
+ if (value === 6 || value === 9) {
397
+ const width = ctx.measureText(label).width;
398
+ ctx.fillRect(-width / 2, fontSize * 0.42, width, Math.max(2, fontSize * 0.08));
399
+ }
400
+ }
401
+ /** @internal */
402
+ _checkValue(value) {
403
+ if (!Number.isInteger(value) || value < 1 || value > this.sides) {
404
+ throw new Error(`MinimoJS: A d${this.sides} has no face ${String(value)}; its values are 1 to ${this.sides}.`);
405
+ }
406
+ return value;
407
+ }
408
+ /** @internal */
409
+ _wait() {
410
+ return new Promise((resolve) => this._waiting.push(resolve));
411
+ }
412
+ /** @internal */
413
+ _settle(value, landed) {
414
+ const waiting = this._waiting;
415
+ this._waiting = [];
416
+ for (const resolve of waiting)
417
+ resolve(value);
418
+ if (landed)
419
+ this.onLand?.(value);
420
+ }
421
+ }
422
+ // Pip positions on a d6, in halves of the face's inradius.
423
+ const PIPS = {
424
+ 1: [[0, 0]],
425
+ 2: [[-1, -1], [1, 1]],
426
+ 3: [[-1, -1], [0, 0], [1, 1]],
427
+ 4: [[-1, -1], [1, -1], [-1, 1], [1, 1]],
428
+ 5: [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
429
+ 6: [[-1, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [1, 1]],
430
+ };
431
+ // ---------------------------------------------------------------------------
432
+ // The solids
433
+ // ---------------------------------------------------------------------------
434
+ const solids = new Map();
435
+ /** The solid of each kind of die, built once. */
436
+ function solidFor(sides) {
437
+ let solid = solids.get(sides);
438
+ if (!solid) {
439
+ solid = buildSolid(sides);
440
+ solids.set(sides, solid);
441
+ }
442
+ return solid;
443
+ }
444
+ function buildSolid(sides) {
445
+ const phi = (1 + Math.sqrt(5)) / 2;
446
+ let vertices;
447
+ let normals;
448
+ let markScale;
449
+ switch (sides) {
450
+ case 4:
451
+ vertices = [[1, 1, 1], [1, -1, -1], [-1, 1, -1], [-1, -1, 1]];
452
+ normals = vertices.map((v) => scaleVec(v, -1));
453
+ markScale = 1.6;
454
+ break;
455
+ case 6:
456
+ vertices = signs([1, 1, 1]);
457
+ normals = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]];
458
+ markScale = 1.2;
459
+ break;
460
+ case 8:
461
+ vertices = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]];
462
+ normals = signs([1, 1, 1]);
463
+ markScale = 1.15;
464
+ break;
465
+ case 10: {
466
+ // A pentagonal trapezohedron: two apexes and a zigzag ring. Its kites
467
+ // are flat only when the apex height matches the ring's zigzag.
468
+ const zig = 0.105;
469
+ const cos36 = Math.cos(Math.PI / 5);
470
+ const apex = (zig * (1 + cos36)) / (1 - cos36);
471
+ vertices = [[0, 0, apex], [0, 0, -apex]];
472
+ for (let i = 0; i < 10; i++) {
473
+ const angle = (i * Math.PI) / 5;
474
+ vertices.push([Math.cos(angle), Math.sin(angle), i % 2 === 0 ? zig : -zig]);
475
+ }
476
+ const ring = (i) => 2 + (i % 10);
477
+ normals = [];
478
+ for (let k = 0; k < 5; k++) {
479
+ normals.push(planeNormal(vertices[0], vertices[ring(2 * k)], vertices[ring(2 * k + 1)]));
480
+ normals.push(planeNormal(vertices[1], vertices[ring(2 * k + 1)], vertices[ring(2 * k + 2)]));
481
+ }
482
+ markScale = 1.35;
483
+ break;
484
+ }
485
+ case 12:
486
+ vertices = [
487
+ ...signs([1, 1, 1]),
488
+ ...signs([0, 1 / phi, phi]),
489
+ ...signs([1 / phi, phi, 0]),
490
+ ...signs([phi, 0, 1 / phi]),
491
+ ];
492
+ normals = [...signs([0, phi, 1]), ...signs([1, 0, phi]), ...signs([phi, 1, 0])];
493
+ markScale = 1.0;
494
+ break;
495
+ case 20:
496
+ vertices = [...signs([0, 1, phi]), ...signs([1, phi, 0]), ...signs([phi, 0, 1])];
497
+ normals = [
498
+ ...signs([1, 1, 1]),
499
+ ...signs([0, phi, 1 / phi]),
500
+ ...signs([1 / phi, 0, phi]),
501
+ ...signs([phi, 1 / phi, 0]),
502
+ ];
503
+ markScale = 1.05;
504
+ break;
505
+ default:
506
+ throw new Error(`MinimoJS: There is no d${String(sides)}; a die has 4, 6, 8, 10, 12, or 20 sides.`);
507
+ }
508
+ const radius = Math.max(...vertices.map(length));
509
+ const faces = normals.map((n) => buildFace(vertices, normalize(n), sides));
510
+ if (sides !== 4)
511
+ assignValues(faces, sides);
512
+ const solid = { sides, vertices, faces, markScale, restExtent: 1, radius };
513
+ const rest = restOrientation(solid, 1);
514
+ const seen = vertices.map((v) => qRotate(qMul(VIEW, rest), v));
515
+ const xs = seen.map((v) => v[0]);
516
+ const ys = seen.map((v) => v[1]);
517
+ solid.restExtent = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
518
+ return solid;
519
+ }
520
+ /** Every sign combination of a vertex's non-zero coordinates. */
521
+ function signs([x, y, z]) {
522
+ const out = [];
523
+ for (const sx of x === 0 ? [1] : [1, -1]) {
524
+ for (const sy of y === 0 ? [1] : [1, -1]) {
525
+ for (const sz of z === 0 ? [1] : [1, -1])
526
+ out.push([x * sx, y * sy, z * sz]);
527
+ }
528
+ }
529
+ return out;
530
+ }
531
+ function planeNormal(a, b, c) {
532
+ const n = normalize(cross(sub(b, a), sub(c, a)));
533
+ // Outward: away from the solid's center at the origin.
534
+ return dot(n, a) < 0 ? scaleVec(n, -1) : n;
535
+ }
536
+ /** The face of a convex solid on the plane with outward normal `n`. */
537
+ function buildFace(vertices, n, sides) {
538
+ const reach = Math.max(...vertices.map((v) => dot(v, n)));
539
+ const on = vertices.map((v, i) => [v, i]).filter(([v]) => dot(v, n) > reach - 1e-6);
540
+ const center = scaleVec(on.reduce((sum, [v]) => add(sum, v), [0, 0, 0]), 1 / on.length);
541
+ const e1 = normalize(sub(on[0][0], center));
542
+ const e2 = cross(n, e1);
543
+ const verts = on
544
+ .map(([v, i]) => ({ i, angle: Math.atan2(dot(sub(v, center), e2), dot(sub(v, center), e1)) }))
545
+ .sort((a, b) => a.angle - b.angle)
546
+ .map(({ i }) => i);
547
+ let inradius = Infinity;
548
+ for (let k = 0; k < verts.length; k++) {
549
+ const a = vertices[verts[k]];
550
+ const b = vertices[verts[(k + 1) % verts.length]];
551
+ const edge = normalize(sub(b, a));
552
+ const toCenter = sub(center, a);
553
+ inradius = Math.min(inradius, length(sub(toCenter, scaleVec(edge, dot(toCenter, edge)))));
554
+ }
555
+ let up;
556
+ if (sides === 6) {
557
+ // A square reads upright toward an edge.
558
+ up = normalize(sub(scaleVec(add(vertices[verts[0]], vertices[verts[1]]), 0.5), center));
559
+ }
560
+ else {
561
+ // A triangle, kite, or pentagon reads upright toward a corner: on a d10,
562
+ // the corner at the apex, the farthest from the face's center.
563
+ let far = verts[0];
564
+ for (const i of verts)
565
+ if (length(sub(vertices[i], center)) > length(sub(vertices[far], center)) + 1e-9)
566
+ far = i;
567
+ up = normalize(sub(vertices[far], center));
568
+ }
569
+ return { verts, normal: n, center, up, inradius, value: 0 };
570
+ }
571
+ /** Numbers the faces so opposite faces add up to `sides + 1`, as on real dice. */
572
+ function assignValues(faces, sides) {
573
+ let next = 1;
574
+ for (const face of faces) {
575
+ if (face.value)
576
+ continue;
577
+ const opposite = faces.find((other) => other !== face && dot(other.normal, face.normal) < -1 + 1e-6);
578
+ face.value = next;
579
+ if (opposite)
580
+ opposite.value = sides + 1 - next;
581
+ next++;
582
+ }
583
+ }
584
+ /** The orientation that shows `value` facing the player, upright. */
585
+ function restOrientation(solid, value) {
586
+ let n;
587
+ let up;
588
+ if (solid.sides === 4) {
589
+ // A face toward the player with the value's corner at its top: the face
590
+ // opposite the next corner, which is one of the three that touch it.
591
+ const face = solid.faces[value % 4];
592
+ n = face.normal;
593
+ up = normalize(sub(solid.vertices[value - 1], face.center));
594
+ }
595
+ else {
596
+ const face = solid.faces.find((f) => f.value === value);
597
+ n = face.normal;
598
+ up = face.up;
599
+ }
600
+ const right = cross(up, n);
601
+ return qFromRows(right, up, n);
602
+ }
603
+ /** The value facing the player in `orientation`. */
604
+ function valueFacing(solid, orientation) {
605
+ if (solid.sides === 4) {
606
+ let best = 0;
607
+ for (let i = 1; i < 4; i++) {
608
+ if (qRotate(orientation, solid.vertices[i])[1] > qRotate(orientation, solid.vertices[best])[1])
609
+ best = i;
610
+ }
611
+ return best + 1;
612
+ }
613
+ let best = solid.faces[0];
614
+ for (const face of solid.faces) {
615
+ if (qRotate(orientation, face.normal)[2] > qRotate(orientation, best.normal)[2])
616
+ best = face;
617
+ }
618
+ return best.value;
619
+ }
620
+ function spinVector(spin) {
621
+ const toRadians = Math.PI / 180;
622
+ // World axes: x right, y up, z toward the player. Screen-clockwise is a
623
+ // negative turn about z.
624
+ return [finiteOr(spin.x, 0) * toRadians, finiteOr(spin.y, 0) * toRadians, -finiteOr(spin.z, 0) * toRadians];
625
+ }
626
+ function polygon(ctx, corners) {
627
+ ctx.beginPath();
628
+ corners.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
629
+ ctx.closePath();
630
+ }
631
+ function finiteOr(value, fallback) {
632
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
633
+ }
634
+ // ---------------------------------------------------------------------------
635
+ // Vectors and quaternions
636
+ // ---------------------------------------------------------------------------
637
+ function add(a, b) {
638
+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
639
+ }
640
+ function sub(a, b) {
641
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
642
+ }
643
+ function scaleVec(a, k) {
644
+ return [a[0] * k, a[1] * k, a[2] * k];
645
+ }
646
+ function dot(a, b) {
647
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
648
+ }
649
+ function cross(a, b) {
650
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
651
+ }
652
+ function length(a) {
653
+ return Math.sqrt(dot(a, a));
654
+ }
655
+ function normalize(a) {
656
+ const l = length(a);
657
+ return l > 0 ? scaleVec(a, 1 / l) : [0, 0, 1];
658
+ }
659
+ function qAxisAngle(axis, angle) {
660
+ const half = angle / 2;
661
+ const s = Math.sin(half);
662
+ return [Math.cos(half), axis[0] * s, axis[1] * s, axis[2] * s];
663
+ }
664
+ function qMul(a, b) {
665
+ return [
666
+ a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3],
667
+ a[0] * b[1] + a[1] * b[0] + a[2] * b[3] - a[3] * b[2],
668
+ a[0] * b[2] - a[1] * b[3] + a[2] * b[0] + a[3] * b[1],
669
+ a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[0],
670
+ ];
671
+ }
672
+ function qConjugate(q) {
673
+ return [q[0], -q[1], -q[2], -q[3]];
674
+ }
675
+ function qNormalize(q) {
676
+ const l = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
677
+ return [q[0] / l, q[1] / l, q[2] / l, q[3] / l];
678
+ }
679
+ function qRotate(q, v) {
680
+ const [w, x, y, z] = q;
681
+ // v + 2w(q × v) + 2 q × (q × v), with q the vector part.
682
+ const tx = 2 * (y * v[2] - z * v[1]);
683
+ const ty = 2 * (z * v[0] - x * v[2]);
684
+ const tz = 2 * (x * v[1] - y * v[0]);
685
+ return [v[0] + w * tx + (y * tz - z * ty), v[1] + w * ty + (z * tx - x * tz), v[2] + w * tz + (x * ty - y * tx)];
686
+ }
687
+ /** The shortest turn a unit quaternion makes: its angle and axis. */
688
+ function qToAxisAngle(q) {
689
+ const [w, x, y, z] = q[0] < 0 ? [-q[0], -q[1], -q[2], -q[3]] : q;
690
+ const s = Math.hypot(x, y, z);
691
+ if (s < 1e-9)
692
+ return [0, [0, 0, 1]];
693
+ return [2 * Math.atan2(s, w), [x / s, y / s, z / s]];
694
+ }
695
+ /** The rotation whose matrix has these rows. */
696
+ function qFromRows(r0, r1, r2) {
697
+ const m00 = r0[0], m01 = r0[1], m02 = r0[2];
698
+ const m10 = r1[0], m11 = r1[1], m12 = r1[2];
699
+ const m20 = r2[0], m21 = r2[1], m22 = r2[2];
700
+ const trace = m00 + m11 + m22;
701
+ let q;
702
+ if (trace > 0) {
703
+ const s = Math.sqrt(trace + 1) * 2;
704
+ q = [s / 4, (m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s];
705
+ }
706
+ else if (m00 > m11 && m00 > m22) {
707
+ const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
708
+ q = [(m21 - m12) / s, s / 4, (m01 + m10) / s, (m02 + m20) / s];
709
+ }
710
+ else if (m11 > m22) {
711
+ const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
712
+ q = [(m02 - m20) / s, (m01 + m10) / s, s / 4, (m12 + m21) / s];
713
+ }
714
+ else {
715
+ const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
716
+ q = [(m10 - m01) / s, (m02 + m20) / s, (m12 + m21) / s, s / 4];
717
+ }
718
+ return qNormalize(q);
719
+ }