minimojs 1.0.0-alpha.37 → 1.0.0-alpha.38

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