@dice-o-rolla/dice-engine 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/dist/dice-engine.d.ts +7 -2
- package/dist/dice-engine.js +386 -41
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/types.d.ts +40 -0
- package/dist/visual-presets.d.ts +7 -0
- package/dist/visual-presets.js +20 -0
- package/package.json +16 -7
package/README.md
CHANGED
|
@@ -19,7 +19,9 @@ engine.destroy();
|
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
The browser entry point initializes Rapier WASM, creates the Three.js renderer, uses Web Crypto for
|
|
22
|
-
throw generation, and releases partially initialized resources if composition fails.
|
|
22
|
+
throw generation, and releases partially initialized resources if composition fails. Concurrent
|
|
23
|
+
`initialize()` calls coalesce, while every roll promise terminates by settlement, cancellation,
|
|
24
|
+
timeout, or failure.
|
|
23
25
|
|
|
24
26
|
## Custom adapters
|
|
25
27
|
|
|
@@ -32,6 +34,17 @@ await engine.initialize();
|
|
|
32
34
|
|
|
33
35
|
The main entry point depends on domain contracts rather than concrete Rapier or Three.js types.
|
|
34
36
|
|
|
37
|
+
## Visual presets and optional effects
|
|
38
|
+
|
|
39
|
+
`registerVisualPreset()` associates a logical die with a validated physical geometry, scale, face
|
|
40
|
+
labels, and optional value map. `skinId` and `soundPackId` are opaque application-owned references;
|
|
41
|
+
the engine does not load assets. Skins and sound definitions belong in the optional
|
|
42
|
+
`@dice-o-rolla/dice-assets` package, which is not a dependency of the engine.
|
|
43
|
+
|
|
44
|
+
The engine emits `die:spawn` and `die:remove` lifecycle events. Collision events are opt-in through
|
|
45
|
+
`DiceEngineOptions.collisionEvents`, bounded by `maxEventsPerFrame`, and suitable for an external
|
|
46
|
+
sound or effects adapter.
|
|
47
|
+
|
|
35
48
|
## Supported notation
|
|
36
49
|
|
|
37
50
|
The initial grammar supports standard polyhedral expressions and integer modifiers, including:
|
|
@@ -39,11 +52,19 @@ The initial grammar supports standard polyhedral expressions and integer modifie
|
|
|
39
52
|
```text
|
|
40
53
|
d20
|
|
41
54
|
4d6 + 2
|
|
55
|
+
4d6kh3
|
|
56
|
+
2d20kl1
|
|
57
|
+
5d20s{1=-2,17..19=1,20=2}
|
|
42
58
|
d%
|
|
43
59
|
d100
|
|
44
60
|
d66
|
|
45
61
|
```
|
|
46
62
|
|
|
63
|
+
Keep/drop rolls retain every physical die in `result.dice` and expose the selection through
|
|
64
|
+
`included`. Score maps expose each contribution through `score`; unlisted faces contribute zero.
|
|
65
|
+
Selection is applied before scoring, followed by integer modifiers. Paired `d%`, `d100`, and `d66`
|
|
66
|
+
terms currently reject keep/drop and score operations.
|
|
67
|
+
|
|
47
68
|
Default resource limits reject oversized notation, more than 50 logical or physical dice, and more
|
|
48
69
|
than eight pending rolls. Consumers may lower or explicitly raise these limits through
|
|
49
70
|
`DiceEngineOptions.limits` after testing their target devices.
|
|
@@ -52,7 +73,11 @@ than eight pending rolls. Consumers may lower or explicitly raise these limits t
|
|
|
52
73
|
|
|
53
74
|
Client-side results are not authoritative for rankings, prizes, or wagering. Call `destroy()` when
|
|
54
75
|
the engine is no longer needed to release frame scheduling, observers, physics resources, WebGL
|
|
55
|
-
resources, and the renderer canvas.
|
|
76
|
+
resources, and the renderer canvas. `clear()` keeps the engine reusable; `destroy()` is idempotent
|
|
77
|
+
and final. See the canonical
|
|
78
|
+
[lifecycle and runtime contract](https://github.com/creepiest-space/dice-o-rolla/blob/main/docs/engine.md).
|
|
79
|
+
Consumers upgrading from `0.1` should also review the
|
|
80
|
+
[0.2 migration notes](https://github.com/creepiest-space/dice-o-rolla/blob/main/docs/migration-0.2.md).
|
|
56
81
|
|
|
57
82
|
## License
|
|
58
83
|
|
package/dist/dice-engine.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { TypedEventEmitter } from '@dice-o-rolla/dice-core';
|
|
2
2
|
import type { RollResult } from '@dice-o-rolla/dice-core';
|
|
3
|
-
import type
|
|
4
|
-
import type { DiceEngineEvents, DiceEngineFacade, DiceEngineOptions, DiceTheme, RollOptions } from './types.js';
|
|
3
|
+
import { type RendererViewport, type VisualPresetDescriptor } from '@dice-o-rolla/dice-renderer';
|
|
4
|
+
import type { DiceEngineEvents, DiceEngineFacade, DiceEngineOptions, DiceTheme, RegisterEngineVisualPresetOptions, RollOptions } from './types.js';
|
|
5
|
+
import { type PhysicalDieType } from './visual-presets.js';
|
|
5
6
|
export declare class DiceEngine extends TypedEventEmitter<DiceEngineEvents> implements DiceEngineFacade {
|
|
6
7
|
#private;
|
|
7
8
|
constructor(options: DiceEngineOptions);
|
|
@@ -12,5 +13,9 @@ export declare class DiceEngine extends TypedEventEmitter<DiceEngineEvents> impl
|
|
|
12
13
|
resize(viewport: RendererViewport): void;
|
|
13
14
|
setTheme(theme: Partial<DiceTheme>): DiceTheme;
|
|
14
15
|
get theme(): DiceTheme;
|
|
16
|
+
registerVisualPreset(source: VisualPresetDescriptor, options?: RegisterEngineVisualPresetOptions): VisualPresetDescriptor;
|
|
17
|
+
unregisterVisualPreset(id: string): boolean;
|
|
18
|
+
setVisualPreset(dieType: PhysicalDieType, presetId: string): void;
|
|
19
|
+
getVisualPreset(dieType: PhysicalDieType): VisualPresetDescriptor;
|
|
15
20
|
destroy(): void;
|
|
16
21
|
}
|
package/dist/dice-engine.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createRollResult, getNotationModifier, isDieType, mathRandomSource, parseNotation, TypedEventEmitter, } from '@dice-o-rolla/dice-core';
|
|
2
2
|
import { getDieGeometry, hasDieGeometry, resolveFace } from '@dice-o-rolla/dice-geometry';
|
|
3
3
|
import { SettlingDetector, ThrowGenerator } from '@dice-o-rolla/dice-physics';
|
|
4
|
+
import { createVisualPresetDescriptor, VisualPresetRegistry, } from '@dice-o-rolla/dice-renderer';
|
|
4
5
|
import { DEFAULT_THEME, defaultFrameScheduler } from './defaults.js';
|
|
5
6
|
import { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, } from './errors.js';
|
|
7
|
+
import { getStandardVisualPresetId, isPhysicalDieType, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, } from './visual-presets.js';
|
|
6
8
|
const DEFAULT_SETTLING = {
|
|
7
9
|
linearVelocityThreshold: 0.08,
|
|
8
10
|
angularVelocityThreshold: 0.08,
|
|
@@ -45,6 +47,7 @@ const DEFAULT_LIMITS = Object.freeze({
|
|
|
45
47
|
maxPhysicalDice: 50,
|
|
46
48
|
maxQueuedRolls: 8,
|
|
47
49
|
});
|
|
50
|
+
const DEFAULT_COLLISION_EVENTS = Object.freeze({ enabled: false, maxEventsPerFrame: 32 });
|
|
48
51
|
const D100_TENS_LABELS = Object.freeze({
|
|
49
52
|
1: 10,
|
|
50
53
|
2: 20,
|
|
@@ -71,12 +74,21 @@ function snapshotSession(session) {
|
|
|
71
74
|
function toRenderState(die) {
|
|
72
75
|
return {
|
|
73
76
|
id: die.id,
|
|
77
|
+
presetId: die.preset.id,
|
|
74
78
|
geometryId: die.geometryType,
|
|
79
|
+
scale: die.preset.scale ?? 1,
|
|
75
80
|
...(die.faceLabels === undefined ? {} : { faceLabels: die.faceLabels }),
|
|
76
81
|
previous: die.previous,
|
|
77
82
|
current: die.current,
|
|
78
83
|
};
|
|
79
84
|
}
|
|
85
|
+
function mergeFaceLabels(preset, roll) {
|
|
86
|
+
if (preset === undefined)
|
|
87
|
+
return roll;
|
|
88
|
+
if (roll === undefined)
|
|
89
|
+
return preset;
|
|
90
|
+
return Object.freeze({ ...preset, ...roll });
|
|
91
|
+
}
|
|
80
92
|
function assertPositive(value, name) {
|
|
81
93
|
if (!Number.isFinite(value) || value <= 0) {
|
|
82
94
|
throw new RangeError(`${name} must be a positive finite number`);
|
|
@@ -99,13 +111,18 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
99
111
|
#tray;
|
|
100
112
|
#diceMaterial;
|
|
101
113
|
#limits;
|
|
114
|
+
#collisionEvents;
|
|
115
|
+
#visualPresets = new VisualPresetRegistry(STANDARD_VISUAL_PRESETS);
|
|
116
|
+
#visualPresetIds = new Map();
|
|
102
117
|
#queue = [];
|
|
103
118
|
#displayedDieIds = new Set();
|
|
119
|
+
#dieEvents = new Map();
|
|
104
120
|
#active;
|
|
105
121
|
#frameToken;
|
|
106
122
|
#lastFrameMs = 0;
|
|
107
123
|
#accumulatorSeconds = 0;
|
|
108
124
|
#nextSessionId = 1;
|
|
125
|
+
#initialization;
|
|
109
126
|
#initialized = false;
|
|
110
127
|
#destroyed = false;
|
|
111
128
|
#theme;
|
|
@@ -124,6 +141,11 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
124
141
|
this.#tray = options.tray ?? DEFAULT_TRAY;
|
|
125
142
|
this.#diceMaterial = options.diceMaterial ?? DEFAULT_DICE_MATERIAL;
|
|
126
143
|
this.#limits = Object.freeze({ ...DEFAULT_LIMITS, ...options.limits });
|
|
144
|
+
this.#collisionEvents = Object.freeze({
|
|
145
|
+
...DEFAULT_COLLISION_EVENTS,
|
|
146
|
+
...options.collisionEvents,
|
|
147
|
+
});
|
|
148
|
+
assertPositiveSafeInteger(this.#collisionEvents.maxEventsPerFrame, 'collisionEvents.maxEventsPerFrame');
|
|
127
149
|
for (const [name, value] of [
|
|
128
150
|
['maxNotationLength', this.#limits.maxNotationLength],
|
|
129
151
|
['maxLogicalDice', this.#limits.maxLogicalDice],
|
|
@@ -132,16 +154,33 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
132
154
|
]) {
|
|
133
155
|
assertPositiveSafeInteger(value, `limits.${name}`);
|
|
134
156
|
}
|
|
157
|
+
for (const type of PHYSICAL_DIE_TYPES) {
|
|
158
|
+
this.#visualPresetIds.set(type, getStandardVisualPresetId(type));
|
|
159
|
+
}
|
|
160
|
+
for (const preset of options.visualPresets ?? [])
|
|
161
|
+
this.registerVisualPreset(preset);
|
|
162
|
+
for (const type of PHYSICAL_DIE_TYPES) {
|
|
163
|
+
const presetId = options.visualPresetIds?.[type];
|
|
164
|
+
if (presetId !== undefined)
|
|
165
|
+
this.setVisualPreset(type, presetId);
|
|
166
|
+
}
|
|
135
167
|
this.#theme = this.#mergeTheme(options.theme ?? {});
|
|
136
168
|
}
|
|
137
169
|
async initialize() {
|
|
138
170
|
this.#assertAlive();
|
|
139
171
|
if (this.#initialized)
|
|
140
172
|
return;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
this.#
|
|
144
|
-
this.#
|
|
173
|
+
if (this.#initialization !== undefined)
|
|
174
|
+
return this.#initialization;
|
|
175
|
+
const initialization = this.#performInitialization();
|
|
176
|
+
this.#initialization = initialization;
|
|
177
|
+
try {
|
|
178
|
+
await initialization;
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
if (this.#initialization === initialization)
|
|
182
|
+
this.#initialization = undefined;
|
|
183
|
+
}
|
|
145
184
|
}
|
|
146
185
|
roll(notation, options = {}) {
|
|
147
186
|
try {
|
|
@@ -196,10 +235,8 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
196
235
|
}
|
|
197
236
|
for (const task of this.#queue.splice(0))
|
|
198
237
|
this.#rejectCancelled(task);
|
|
199
|
-
this.#physics.clear();
|
|
200
|
-
this.#renderer.clear();
|
|
201
|
-
this.#displayedDieIds.clear();
|
|
202
238
|
this.#accumulatorSeconds = 0;
|
|
239
|
+
this.#clearRenderedDice('cleared');
|
|
203
240
|
}
|
|
204
241
|
resize(viewport) {
|
|
205
242
|
this.#assertReady();
|
|
@@ -216,15 +253,102 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
216
253
|
get theme() {
|
|
217
254
|
return this.#theme;
|
|
218
255
|
}
|
|
256
|
+
registerVisualPreset(source, options = {}) {
|
|
257
|
+
this.#assertAlive();
|
|
258
|
+
const preset = createVisualPresetDescriptor(source);
|
|
259
|
+
this.#assertValidVisualPreset(preset);
|
|
260
|
+
if ([...this.#dieEvents.values()].some((event) => event.presetId === preset.id)) {
|
|
261
|
+
throw new Error(`Visual preset "${preset.id}" is currently in use`);
|
|
262
|
+
}
|
|
263
|
+
const existing = this.#visualPresets.get(preset.id);
|
|
264
|
+
if (existing !== undefined && options.replace !== true) {
|
|
265
|
+
throw new Error(`Visual preset "${preset.id}" is already registered`);
|
|
266
|
+
}
|
|
267
|
+
if (this.#initialized)
|
|
268
|
+
this.#renderer.registerPreset(preset);
|
|
269
|
+
this.#visualPresets.register(preset, options.replace === undefined ? {} : { replace: options.replace });
|
|
270
|
+
if (options.makeDefault === true) {
|
|
271
|
+
if (!isPhysicalDieType(preset.dieType)) {
|
|
272
|
+
throw new Error(`Visual preset has an invalid physical die type: ${preset.dieType}`);
|
|
273
|
+
}
|
|
274
|
+
this.#visualPresetIds.set(preset.dieType, preset.id);
|
|
275
|
+
}
|
|
276
|
+
return preset;
|
|
277
|
+
}
|
|
278
|
+
unregisterVisualPreset(id) {
|
|
279
|
+
this.#assertAlive();
|
|
280
|
+
if (STANDARD_VISUAL_PRESETS.some((preset) => preset.id === id)) {
|
|
281
|
+
throw new Error(`Built-in visual preset "${id}" cannot be unregistered`);
|
|
282
|
+
}
|
|
283
|
+
const preset = this.#visualPresets.unregister(id);
|
|
284
|
+
if (preset === undefined)
|
|
285
|
+
return false;
|
|
286
|
+
if (!isPhysicalDieType(preset.dieType)) {
|
|
287
|
+
throw new Error(`Visual preset has an invalid physical die type: ${preset.dieType}`);
|
|
288
|
+
}
|
|
289
|
+
const type = preset.dieType;
|
|
290
|
+
if (this.#visualPresetIds.get(type) === id) {
|
|
291
|
+
this.#visualPresetIds.set(type, getStandardVisualPresetId(type));
|
|
292
|
+
}
|
|
293
|
+
if (this.#initialized)
|
|
294
|
+
this.#renderer.unregisterPreset(id);
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
setVisualPreset(dieType, presetId) {
|
|
298
|
+
this.#assertAlive();
|
|
299
|
+
if (!PHYSICAL_DIE_TYPES.includes(dieType)) {
|
|
300
|
+
throw new RangeError(`${dieType} is not a physical die type`);
|
|
301
|
+
}
|
|
302
|
+
const preset = this.#visualPresets.get(presetId);
|
|
303
|
+
if (preset === undefined)
|
|
304
|
+
throw new RangeError(`Unknown visual preset: ${presetId}`);
|
|
305
|
+
if (preset.dieType !== dieType) {
|
|
306
|
+
throw new RangeError(`Visual preset "${presetId}" is for ${preset.dieType}, not ${dieType}`);
|
|
307
|
+
}
|
|
308
|
+
this.#visualPresetIds.set(dieType, presetId);
|
|
309
|
+
}
|
|
310
|
+
getVisualPreset(dieType) {
|
|
311
|
+
this.#assertAlive();
|
|
312
|
+
const id = this.#visualPresetIds.get(dieType);
|
|
313
|
+
const preset = id === undefined ? undefined : this.#visualPresets.get(id);
|
|
314
|
+
if (preset === undefined)
|
|
315
|
+
throw new Error(`No visual preset is selected for ${dieType}`);
|
|
316
|
+
return preset;
|
|
317
|
+
}
|
|
219
318
|
destroy() {
|
|
220
319
|
if (this.#destroyed)
|
|
221
320
|
return;
|
|
222
|
-
this
|
|
223
|
-
this.#
|
|
224
|
-
this.#
|
|
321
|
+
this.#destroyed = true;
|
|
322
|
+
this.#frameToken?.cancel();
|
|
323
|
+
this.#frameToken = undefined;
|
|
324
|
+
if (this.#active !== undefined) {
|
|
325
|
+
const task = this.#active.task;
|
|
326
|
+
this.#active = undefined;
|
|
327
|
+
this.#rejectCancelled(task);
|
|
328
|
+
}
|
|
329
|
+
for (const task of this.#queue.splice(0))
|
|
330
|
+
this.#rejectCancelled(task);
|
|
331
|
+
this.#forgetRenderedDice('destroyed');
|
|
332
|
+
const cleanupErrors = this.#runCleanup([
|
|
333
|
+
() => this.#renderer.destroy(),
|
|
334
|
+
() => this.#physics.destroy(),
|
|
335
|
+
]);
|
|
225
336
|
super.clear();
|
|
337
|
+
this.#initialization = undefined;
|
|
226
338
|
this.#initialized = false;
|
|
227
|
-
|
|
339
|
+
if (cleanupErrors.length > 0) {
|
|
340
|
+
throw new AggregateError(cleanupErrors, 'DiceEngine teardown failed');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async #performInitialization() {
|
|
344
|
+
this.#physics.configureTray(this.#tray);
|
|
345
|
+
this.#physics.setCollisionEventsEnabled(this.#collisionEvents.enabled);
|
|
346
|
+
await this.#renderer.initialize();
|
|
347
|
+
this.#assertAlive();
|
|
348
|
+
for (const preset of this.#visualPresets.list())
|
|
349
|
+
this.#renderer.registerPreset(preset);
|
|
350
|
+
this.#renderer.setTheme(this.#theme);
|
|
351
|
+
this.#initialized = true;
|
|
228
352
|
}
|
|
229
353
|
#createTask(notation, parsed, signal) {
|
|
230
354
|
const session = {
|
|
@@ -285,6 +409,7 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
285
409
|
try {
|
|
286
410
|
for (const spec of specs) {
|
|
287
411
|
const geometry = getDieGeometry(spec.geometryType);
|
|
412
|
+
const faceLabels = mergeFaceLabels(spec.preset.faceLabels, spec.faceLabels);
|
|
288
413
|
const id = `${task.session.id}:die-${index++}`;
|
|
289
414
|
const generated = this.#throwGenerator.generate();
|
|
290
415
|
const position = this.#placeDie(generated.position, index - 1, totalDice);
|
|
@@ -295,7 +420,7 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
295
420
|
kind: 'convex-hull',
|
|
296
421
|
vertices: geometry.vertices.map(([x, y, z]) => ({ x, y, z })),
|
|
297
422
|
},
|
|
298
|
-
scale: 1,
|
|
423
|
+
scale: spec.preset.scale ?? 1,
|
|
299
424
|
mass: 1,
|
|
300
425
|
material: this.#diceMaterial,
|
|
301
426
|
position,
|
|
@@ -306,8 +431,12 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
306
431
|
id,
|
|
307
432
|
type: spec.type,
|
|
308
433
|
geometryType: spec.geometryType,
|
|
434
|
+
preset: spec.preset,
|
|
435
|
+
expressionIndex: spec.expressionIndex,
|
|
436
|
+
...(spec.selection === undefined ? {} : { selection: spec.selection }),
|
|
437
|
+
...(spec.scoreRules === undefined ? {} : { scoreRules: spec.scoreRules }),
|
|
309
438
|
...(spec.component === undefined ? {} : { component: spec.component }),
|
|
310
|
-
...(
|
|
439
|
+
...(faceLabels === undefined ? {} : { faceLabels }),
|
|
311
440
|
body,
|
|
312
441
|
detector: new SettlingDetector(this.#settling),
|
|
313
442
|
previous: state,
|
|
@@ -316,57 +445,90 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
316
445
|
dice.push(die);
|
|
317
446
|
this.#renderer.createDie(toRenderState(die));
|
|
318
447
|
body.applyImpulse(generated.impulse, generated.torqueImpulse);
|
|
448
|
+
const event = Object.freeze({
|
|
449
|
+
sessionId: task.session.id,
|
|
450
|
+
dieId: id,
|
|
451
|
+
dieType: spec.type,
|
|
452
|
+
presetId: spec.preset.id,
|
|
453
|
+
...(spec.preset.skinId === undefined ? {} : { skinId: spec.preset.skinId }),
|
|
454
|
+
...(spec.preset.soundPackId === undefined
|
|
455
|
+
? {}
|
|
456
|
+
: { soundPackId: spec.preset.soundPackId }),
|
|
457
|
+
});
|
|
458
|
+
this.#dieEvents.set(id, event);
|
|
459
|
+
this.emit('die:spawn', event);
|
|
319
460
|
}
|
|
320
461
|
return dice;
|
|
321
462
|
}
|
|
322
463
|
catch (error) {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
464
|
+
const cleanupErrors = this.#removeDiceSafely(dice, 'failed');
|
|
465
|
+
if (cleanupErrors.length === 0)
|
|
466
|
+
throw error;
|
|
467
|
+
throw new AggregateError([error, ...cleanupErrors], 'Failed to create dice', {
|
|
468
|
+
cause: error,
|
|
469
|
+
});
|
|
328
470
|
}
|
|
329
471
|
}
|
|
330
472
|
#createPhysicalSpecs(task) {
|
|
331
473
|
const specs = [];
|
|
332
474
|
let groupIndex = 0;
|
|
333
|
-
for (const expression of task.parsed.expressions) {
|
|
475
|
+
for (const [expressionIndex, expression] of task.parsed.expressions.entries()) {
|
|
334
476
|
if (expression.kind === 'modifier')
|
|
335
477
|
continue;
|
|
336
478
|
if (expression.kind === 'dice') {
|
|
337
479
|
const type = `d${expression.sides}`;
|
|
338
480
|
if (!isDieType(type))
|
|
339
481
|
throw new RangeError(`${type} is not a standard die type`);
|
|
482
|
+
if (!isPhysicalDieType(type))
|
|
483
|
+
throw new RangeError(`${type} is not a physical die type`);
|
|
484
|
+
const preset = this.getVisualPreset(type);
|
|
340
485
|
for (let count = 0; count < expression.count; count += 1) {
|
|
341
|
-
specs.push({
|
|
486
|
+
specs.push({
|
|
487
|
+
type,
|
|
488
|
+
geometryType: this.#getPresetGeometryType(preset),
|
|
489
|
+
preset,
|
|
490
|
+
expressionIndex,
|
|
491
|
+
...(expression.selection === undefined ? {} : { selection: expression.selection }),
|
|
492
|
+
...(expression.score === undefined ? {} : { scoreRules: expression.score }),
|
|
493
|
+
});
|
|
342
494
|
}
|
|
343
495
|
continue;
|
|
344
496
|
}
|
|
345
497
|
for (let count = 0; count < expression.count; count += 1) {
|
|
346
498
|
const groupId = `${task.session.id}:group-${groupIndex++}`;
|
|
347
499
|
if (expression.type === 'd100') {
|
|
500
|
+
const preset = this.getVisualPreset('d10');
|
|
348
501
|
specs.push({
|
|
349
502
|
type: 'd100',
|
|
350
|
-
geometryType:
|
|
503
|
+
geometryType: this.#getPresetGeometryType(preset),
|
|
504
|
+
preset,
|
|
505
|
+
expressionIndex,
|
|
351
506
|
component: { groupId, groupType: 'd100', role: 'tens' },
|
|
352
507
|
faceLabels: D100_TENS_LABELS,
|
|
353
508
|
});
|
|
354
509
|
specs.push({
|
|
355
510
|
type: 'd10',
|
|
356
|
-
geometryType:
|
|
511
|
+
geometryType: this.#getPresetGeometryType(preset),
|
|
512
|
+
preset,
|
|
513
|
+
expressionIndex,
|
|
357
514
|
component: { groupId, groupType: 'd100', role: 'units' },
|
|
358
515
|
});
|
|
359
516
|
continue;
|
|
360
517
|
}
|
|
518
|
+
const preset = this.getVisualPreset('d6');
|
|
361
519
|
specs.push({
|
|
362
520
|
type: 'd6',
|
|
363
|
-
geometryType:
|
|
521
|
+
geometryType: this.#getPresetGeometryType(preset),
|
|
522
|
+
preset,
|
|
523
|
+
expressionIndex,
|
|
364
524
|
component: { groupId, groupType: 'd66', role: 'tens' },
|
|
365
525
|
faceLabels: D66_TENS_LABELS,
|
|
366
526
|
});
|
|
367
527
|
specs.push({
|
|
368
528
|
type: 'd6',
|
|
369
|
-
geometryType:
|
|
529
|
+
geometryType: this.#getPresetGeometryType(preset),
|
|
530
|
+
preset,
|
|
531
|
+
expressionIndex,
|
|
370
532
|
component: { groupId, groupType: 'd66', role: 'units' },
|
|
371
533
|
});
|
|
372
534
|
}
|
|
@@ -404,10 +566,39 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
404
566
|
const frameDelta = Math.max(0, Math.min((timestampMs - this.#lastFrameMs) / 1_000, this.#maxFrameDeltaSeconds));
|
|
405
567
|
this.#lastFrameMs = timestampMs;
|
|
406
568
|
this.#accumulatorSeconds += frameDelta;
|
|
569
|
+
let emittedCollisionEvents = 0;
|
|
407
570
|
while (this.#accumulatorSeconds >= this.#fixedStepSeconds) {
|
|
408
571
|
for (const die of active.dice)
|
|
409
572
|
die.previous = die.current;
|
|
410
573
|
this.#physics.step(this.#fixedStepSeconds);
|
|
574
|
+
if (this.#collisionEvents.enabled) {
|
|
575
|
+
for (const collision of this.#physics.drainCollisionEvents()) {
|
|
576
|
+
if (emittedCollisionEvents >= this.#collisionEvents.maxEventsPerFrame)
|
|
577
|
+
continue;
|
|
578
|
+
const event = this.#dieEvents.get(collision.dieId);
|
|
579
|
+
if (event === undefined)
|
|
580
|
+
continue;
|
|
581
|
+
this.emit('die:collision', Object.freeze({
|
|
582
|
+
...event,
|
|
583
|
+
...(collision.otherDieId === undefined ? {} : { otherDieId: collision.otherDieId }),
|
|
584
|
+
started: collision.started,
|
|
585
|
+
}));
|
|
586
|
+
emittedCollisionEvents += 1;
|
|
587
|
+
}
|
|
588
|
+
for (const impact of this.#physics.drainImpactEvents()) {
|
|
589
|
+
if (emittedCollisionEvents >= this.#collisionEvents.maxEventsPerFrame)
|
|
590
|
+
continue;
|
|
591
|
+
const event = this.#dieEvents.get(impact.dieId);
|
|
592
|
+
if (event === undefined)
|
|
593
|
+
continue;
|
|
594
|
+
this.emit('die:impact', Object.freeze({
|
|
595
|
+
...event,
|
|
596
|
+
...(impact.otherDieId === undefined ? {} : { otherDieId: impact.otherDieId }),
|
|
597
|
+
force: impact.force,
|
|
598
|
+
}));
|
|
599
|
+
emittedCollisionEvents += 1;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
411
602
|
this.#accumulatorSeconds -= this.#fixedStepSeconds;
|
|
412
603
|
for (const die of active.dice) {
|
|
413
604
|
die.current = die.body.getState();
|
|
@@ -439,18 +630,19 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
439
630
|
}
|
|
440
631
|
}
|
|
441
632
|
#createDieResult(die, faceValue) {
|
|
633
|
+
const mappedValue = die.preset.valueMap?.[faceValue] ?? faceValue;
|
|
442
634
|
if (die.component === undefined) {
|
|
443
635
|
if (die.type === 'd100')
|
|
444
636
|
throw new Error('A d100 result requires percentile component data');
|
|
445
|
-
return Object.freeze({ id: die.id, type: die.type, value:
|
|
637
|
+
return Object.freeze({ id: die.id, type: die.type, value: mappedValue });
|
|
446
638
|
}
|
|
447
639
|
const { groupId, groupType, role } = die.component;
|
|
448
|
-
const digit = groupType === 'd100' ?
|
|
640
|
+
const digit = groupType === 'd100' ? mappedValue % 10 : mappedValue;
|
|
449
641
|
return Object.freeze({
|
|
450
642
|
id: die.id,
|
|
451
643
|
type: die.type,
|
|
452
644
|
value: role === 'tens' ? digit * 10 : digit,
|
|
453
|
-
component: Object.freeze({ groupId, groupType, role, faceValue }),
|
|
645
|
+
component: Object.freeze({ groupId, groupType, role, faceValue: mappedValue }),
|
|
454
646
|
});
|
|
455
647
|
}
|
|
456
648
|
#completeActive(active) {
|
|
@@ -459,11 +651,7 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
459
651
|
session.completedAt = this.#now();
|
|
460
652
|
if (session.startedAt === undefined)
|
|
461
653
|
throw new Error('Active roll has no start time');
|
|
462
|
-
const diceResults = active.dice
|
|
463
|
-
if (die.result === undefined)
|
|
464
|
-
throw new Error(`Die ${die.id} has no settled result`);
|
|
465
|
-
return die.result;
|
|
466
|
-
});
|
|
654
|
+
const diceResults = this.#applyRollRules(active.dice);
|
|
467
655
|
const result = createRollResult({
|
|
468
656
|
id: session.id,
|
|
469
657
|
notation: session.notation,
|
|
@@ -480,14 +668,70 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
480
668
|
this.emit('roll:complete', result);
|
|
481
669
|
this.#startNext();
|
|
482
670
|
}
|
|
671
|
+
#applyRollRules(dice) {
|
|
672
|
+
const expressionGroups = new Map();
|
|
673
|
+
for (const die of dice) {
|
|
674
|
+
const group = expressionGroups.get(die.expressionIndex) ?? [];
|
|
675
|
+
group.push(die);
|
|
676
|
+
expressionGroups.set(die.expressionIndex, group);
|
|
677
|
+
}
|
|
678
|
+
const inclusion = new Map();
|
|
679
|
+
for (const group of expressionGroups.values()) {
|
|
680
|
+
const selection = group[0]?.selection;
|
|
681
|
+
if (selection === undefined)
|
|
682
|
+
continue;
|
|
683
|
+
const ranked = group
|
|
684
|
+
.map((die, index) => {
|
|
685
|
+
if (die.result === undefined)
|
|
686
|
+
throw new Error(`Die ${die.id} has no settled result`);
|
|
687
|
+
return { die, index, value: die.result.value };
|
|
688
|
+
})
|
|
689
|
+
.toSorted((left, right) => {
|
|
690
|
+
const highest = selection.operator === 'kh' || selection.operator === 'dh';
|
|
691
|
+
const valueOrder = highest ? right.value - left.value : left.value - right.value;
|
|
692
|
+
return valueOrder === 0 ? left.index - right.index : valueOrder;
|
|
693
|
+
});
|
|
694
|
+
const selected = new Set(ranked.slice(0, selection.count).map(({ die }) => die.id));
|
|
695
|
+
const keepsSelected = selection.operator === 'kh' || selection.operator === 'kl';
|
|
696
|
+
for (const die of group) {
|
|
697
|
+
inclusion.set(die.id, keepsSelected ? selected.has(die.id) : !selected.has(die.id));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return dice.map((die) => {
|
|
701
|
+
if (die.result === undefined)
|
|
702
|
+
throw new Error(`Die ${die.id} has no settled result`);
|
|
703
|
+
const included = inclusion.get(die.id);
|
|
704
|
+
const score = die.scoreRules === undefined
|
|
705
|
+
? undefined
|
|
706
|
+
: this.#scoreFace(die.result.value, die.scoreRules);
|
|
707
|
+
if (included === undefined && score === undefined)
|
|
708
|
+
return die.result;
|
|
709
|
+
if (die.result.component !== undefined) {
|
|
710
|
+
throw new Error(`Paired die ${die.id} cannot use keep/drop or score rules`);
|
|
711
|
+
}
|
|
712
|
+
return Object.freeze({
|
|
713
|
+
...die.result,
|
|
714
|
+
...(included === undefined ? {} : { included }),
|
|
715
|
+
...(score === undefined ? {} : { score }),
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
#scoreFace(value, rules) {
|
|
720
|
+
return rules.find((rule) => value >= rule.minimum && value <= rule.maximum)?.score ?? 0;
|
|
721
|
+
}
|
|
483
722
|
#cancelActive(task) {
|
|
484
723
|
this.#frameToken?.cancel();
|
|
485
724
|
this.#frameToken = undefined;
|
|
486
725
|
const active = this.#active;
|
|
487
726
|
this.#active = undefined;
|
|
488
|
-
|
|
489
|
-
this.#removeDice(active.dice);
|
|
727
|
+
const cleanupErrors = active === undefined ? [] : this.#removeDiceSafely(active.dice, 'cancelled');
|
|
490
728
|
this.#rejectCancelled(task);
|
|
729
|
+
if (cleanupErrors.length > 0) {
|
|
730
|
+
this.emit('error', {
|
|
731
|
+
session: snapshotSession(task.session),
|
|
732
|
+
error: new AggregateError(cleanupErrors, `Failed to clean up ${task.session.id}`),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
491
735
|
this.#startNext();
|
|
492
736
|
}
|
|
493
737
|
#rejectCancelled(task) {
|
|
@@ -499,8 +743,12 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
499
743
|
}
|
|
500
744
|
#failActive(active, error) {
|
|
501
745
|
this.#active = undefined;
|
|
502
|
-
this.#
|
|
503
|
-
this.#failTask(active.task,
|
|
746
|
+
const cleanupErrors = this.#removeDiceSafely(active.dice, 'failed');
|
|
747
|
+
this.#failTask(active.task, cleanupErrors.length === 0
|
|
748
|
+
? error
|
|
749
|
+
: new AggregateError([error, ...cleanupErrors], `Roll ${active.task.session.id} failed`, {
|
|
750
|
+
cause: error,
|
|
751
|
+
}));
|
|
504
752
|
}
|
|
505
753
|
#failTask(task, error) {
|
|
506
754
|
task.session.state = 'failed';
|
|
@@ -510,18 +758,81 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
510
758
|
this.emit('error', { session: snapshotSession(task.session), error });
|
|
511
759
|
this.#startNext();
|
|
512
760
|
}
|
|
513
|
-
#
|
|
761
|
+
#removeDiceSafely(dice, reason) {
|
|
762
|
+
const errors = [];
|
|
514
763
|
for (const die of dice) {
|
|
515
|
-
|
|
516
|
-
|
|
764
|
+
try {
|
|
765
|
+
this.#removeDie(die.id, reason);
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
if (error instanceof AggregateError)
|
|
769
|
+
errors.push(...error.errors);
|
|
770
|
+
else
|
|
771
|
+
errors.push(error);
|
|
772
|
+
}
|
|
517
773
|
}
|
|
774
|
+
return errors;
|
|
518
775
|
}
|
|
519
776
|
#removeDisplayedDice() {
|
|
777
|
+
const cleanupErrors = [];
|
|
520
778
|
for (const id of this.#displayedDieIds) {
|
|
521
|
-
|
|
522
|
-
|
|
779
|
+
try {
|
|
780
|
+
this.#removeDie(id, 'replaced');
|
|
781
|
+
}
|
|
782
|
+
catch (error) {
|
|
783
|
+
if (error instanceof AggregateError)
|
|
784
|
+
cleanupErrors.push(...error.errors);
|
|
785
|
+
else
|
|
786
|
+
cleanupErrors.push(error);
|
|
787
|
+
}
|
|
523
788
|
}
|
|
524
789
|
this.#displayedDieIds.clear();
|
|
790
|
+
if (cleanupErrors.length > 0) {
|
|
791
|
+
throw new AggregateError(cleanupErrors, 'Failed to remove displayed dice');
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
#removeDie(id, reason) {
|
|
795
|
+
const cleanupErrors = this.#runCleanup([
|
|
796
|
+
() => this.#physics.removeDie(id),
|
|
797
|
+
() => this.#renderer.removeDie(id),
|
|
798
|
+
]);
|
|
799
|
+
const event = this.#dieEvents.get(id);
|
|
800
|
+
if (event !== undefined) {
|
|
801
|
+
this.#dieEvents.delete(id);
|
|
802
|
+
this.emit('die:remove', Object.freeze({ ...event, reason }));
|
|
803
|
+
}
|
|
804
|
+
if (cleanupErrors.length > 0) {
|
|
805
|
+
throw new AggregateError(cleanupErrors, `Failed to remove die ${id}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
#clearRenderedDice(reason) {
|
|
809
|
+
const cleanupErrors = this.#runCleanup([
|
|
810
|
+
() => this.#physics.clear(),
|
|
811
|
+
() => this.#renderer.clear(),
|
|
812
|
+
]);
|
|
813
|
+
this.#forgetRenderedDice(reason);
|
|
814
|
+
if (cleanupErrors.length > 0) {
|
|
815
|
+
throw new AggregateError(cleanupErrors, 'Failed to clear rendered dice');
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
#forgetRenderedDice(reason) {
|
|
819
|
+
for (const event of this.#dieEvents.values()) {
|
|
820
|
+
this.emit('die:remove', Object.freeze({ ...event, reason }));
|
|
821
|
+
}
|
|
822
|
+
this.#dieEvents.clear();
|
|
823
|
+
this.#displayedDieIds.clear();
|
|
824
|
+
}
|
|
825
|
+
#runCleanup(actions) {
|
|
826
|
+
const errors = [];
|
|
827
|
+
for (const action of actions) {
|
|
828
|
+
try {
|
|
829
|
+
action();
|
|
830
|
+
}
|
|
831
|
+
catch (error) {
|
|
832
|
+
errors.push(error);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return errors;
|
|
525
836
|
}
|
|
526
837
|
#detachAbort(task) {
|
|
527
838
|
if (task.signal !== undefined && task.abortListener !== undefined) {
|
|
@@ -571,6 +882,40 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
571
882
|
}
|
|
572
883
|
return merged;
|
|
573
884
|
}
|
|
885
|
+
#assertValidVisualPreset(preset) {
|
|
886
|
+
if (!isDieType(preset.dieType) || !isPhysicalDieType(preset.dieType)) {
|
|
887
|
+
throw new RangeError(`Visual preset die type is not supported: ${preset.dieType}`);
|
|
888
|
+
}
|
|
889
|
+
if (!isDieType(preset.geometryId) || !hasDieGeometry(preset.geometryId)) {
|
|
890
|
+
throw new RangeError(`Visual preset geometry is not registered: ${preset.geometryId}`);
|
|
891
|
+
}
|
|
892
|
+
const geometry = getDieGeometry(preset.geometryId);
|
|
893
|
+
const faceValues = new Set(geometry.faces.map((face) => face.value));
|
|
894
|
+
const logicalSides = Number(preset.dieType.slice(1));
|
|
895
|
+
if (preset.valueMap !== undefined) {
|
|
896
|
+
const mappedFaces = Object.keys(preset.valueMap).map(Number);
|
|
897
|
+
if (mappedFaces.length !== faceValues.size ||
|
|
898
|
+
mappedFaces.some((face) => !faceValues.has(face))) {
|
|
899
|
+
throw new RangeError('Visual preset valueMap must map every geometry face exactly once');
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
for (const face of faceValues) {
|
|
903
|
+
const value = preset.valueMap?.[face] ?? face;
|
|
904
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > logicalSides) {
|
|
905
|
+
throw new RangeError(`Visual preset maps geometry face ${face} outside ${preset.dieType}`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (preset.faceLabels !== undefined &&
|
|
909
|
+
Object.keys(preset.faceLabels).some((face) => !faceValues.has(Number(face)))) {
|
|
910
|
+
throw new RangeError('Visual preset faceLabels contain a face absent from its geometry');
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
#getPresetGeometryType(preset) {
|
|
914
|
+
if (!isDieType(preset.geometryId) || !hasDieGeometry(preset.geometryId)) {
|
|
915
|
+
throw new RangeError(`Visual preset geometry is not registered: ${preset.geometryId}`);
|
|
916
|
+
}
|
|
917
|
+
return preset.geometryId;
|
|
918
|
+
}
|
|
574
919
|
#assertReady() {
|
|
575
920
|
this.#assertAlive();
|
|
576
921
|
if (!this.#initialized)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { DiceEngine } from './dice-engine.js';
|
|
2
2
|
export { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, } from './errors.js';
|
|
3
3
|
export type { RollLimit } from './errors.js';
|
|
4
|
-
export type { DiceEngineEvents, DiceEngineFacade, DiceEngineLimits, DiceEngineOptions, DiceMaterialType, DiceTheme, FrameScheduler, FrameToken, RollOptions, } from './types.js';
|
|
4
|
+
export type { DiceEngineEvents, DiceEngineFacade, DiceEngineLimits, DiceEngineOptions, DiceCollisionEvent, DiceImpactEvent, DiceCollisionEventOptions, DiceMaterialType, DiceRemovalReason, DiceRemoveEvent, DiceTheme, DiceVisualEvent, FrameScheduler, FrameToken, RegisterEngineVisualPresetOptions, RollOptions, } from './types.js';
|
|
5
|
+
export { getStandardVisualPresetId, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, type PhysicalDieType, } from './visual-presets.js';
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { DiceEngine } from './dice-engine.js';
|
|
2
2
|
export { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, } from './errors.js';
|
|
3
|
+
export { getStandardVisualPresetId, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, } from './visual-presets.js';
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { DieResult, RandomSource, RollMode, RollResult, RollSession } from '@dice-o-rolla/dice-core';
|
|
2
2
|
import type { DicePhysicsMaterial, PhysicsWorld, SettlingOptions, ThrowGeneratorOptions, TrayOptions } from '@dice-o-rolla/dice-physics';
|
|
3
3
|
import type { DiceRenderer, RendererTheme, RendererViewport } from '@dice-o-rolla/dice-renderer';
|
|
4
|
+
import type { RegisterVisualPresetOptions, VisualPresetDescriptor } from '@dice-o-rolla/dice-renderer';
|
|
5
|
+
import type { PhysicalDieType } from './visual-presets.js';
|
|
4
6
|
export interface FrameToken {
|
|
5
7
|
cancel(): void;
|
|
6
8
|
}
|
|
@@ -13,18 +15,49 @@ export interface RollOptions {
|
|
|
13
15
|
readonly mode?: RollMode;
|
|
14
16
|
readonly signal?: AbortSignal;
|
|
15
17
|
}
|
|
18
|
+
export interface RegisterEngineVisualPresetOptions extends RegisterVisualPresetOptions {
|
|
19
|
+
readonly makeDefault?: boolean;
|
|
20
|
+
}
|
|
16
21
|
export interface DiceEngineLimits {
|
|
17
22
|
readonly maxNotationLength: number;
|
|
18
23
|
readonly maxLogicalDice: number;
|
|
19
24
|
readonly maxPhysicalDice: number;
|
|
20
25
|
readonly maxQueuedRolls: number;
|
|
21
26
|
}
|
|
27
|
+
export interface DiceVisualEvent {
|
|
28
|
+
readonly sessionId: string;
|
|
29
|
+
readonly dieId: string;
|
|
30
|
+
readonly dieType: string;
|
|
31
|
+
readonly presetId: string;
|
|
32
|
+
readonly skinId?: string;
|
|
33
|
+
readonly soundPackId?: string;
|
|
34
|
+
}
|
|
35
|
+
export type DiceRemovalReason = 'replaced' | 'cancelled' | 'failed' | 'cleared' | 'destroyed';
|
|
36
|
+
export interface DiceRemoveEvent extends DiceVisualEvent {
|
|
37
|
+
readonly reason: DiceRemovalReason;
|
|
38
|
+
}
|
|
39
|
+
export interface DiceCollisionEvent extends DiceVisualEvent {
|
|
40
|
+
readonly otherDieId?: string;
|
|
41
|
+
readonly started: boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface DiceImpactEvent extends DiceVisualEvent {
|
|
44
|
+
readonly otherDieId?: string;
|
|
45
|
+
readonly force: number;
|
|
46
|
+
}
|
|
47
|
+
export interface DiceCollisionEventOptions {
|
|
48
|
+
readonly enabled: boolean;
|
|
49
|
+
readonly maxEventsPerFrame: number;
|
|
50
|
+
}
|
|
22
51
|
export interface DiceEngineEvents {
|
|
23
52
|
readonly 'roll:start': RollSession;
|
|
53
|
+
readonly 'die:spawn': DiceVisualEvent;
|
|
24
54
|
readonly 'die:settled': {
|
|
25
55
|
readonly sessionId: string;
|
|
26
56
|
readonly die: DieResult;
|
|
27
57
|
};
|
|
58
|
+
readonly 'die:remove': DiceRemoveEvent;
|
|
59
|
+
readonly 'die:collision': DiceCollisionEvent;
|
|
60
|
+
readonly 'die:impact': DiceImpactEvent;
|
|
28
61
|
readonly 'roll:complete': RollResult;
|
|
29
62
|
readonly 'roll:cancel': RollSession;
|
|
30
63
|
readonly 'theme:change': DiceTheme;
|
|
@@ -47,6 +80,9 @@ export interface DiceEngineOptions {
|
|
|
47
80
|
readonly diceMaterial?: DicePhysicsMaterial;
|
|
48
81
|
readonly theme?: Partial<DiceTheme>;
|
|
49
82
|
readonly limits?: Partial<DiceEngineLimits>;
|
|
83
|
+
readonly visualPresets?: readonly VisualPresetDescriptor[];
|
|
84
|
+
readonly visualPresetIds?: Partial<Readonly<Record<PhysicalDieType, string>>>;
|
|
85
|
+
readonly collisionEvents?: Partial<DiceCollisionEventOptions>;
|
|
50
86
|
}
|
|
51
87
|
export interface DiceEngineFacade {
|
|
52
88
|
initialize(): Promise<void>;
|
|
@@ -55,5 +91,9 @@ export interface DiceEngineFacade {
|
|
|
55
91
|
clear(): void;
|
|
56
92
|
resize(viewport: RendererViewport): void;
|
|
57
93
|
setTheme(theme: Partial<DiceTheme>): DiceTheme;
|
|
94
|
+
registerVisualPreset(preset: VisualPresetDescriptor, options?: RegisterEngineVisualPresetOptions): VisualPresetDescriptor;
|
|
95
|
+
unregisterVisualPreset(id: string): boolean;
|
|
96
|
+
setVisualPreset(dieType: PhysicalDieType, presetId: string): void;
|
|
97
|
+
getVisualPreset(dieType: PhysicalDieType): VisualPresetDescriptor;
|
|
58
98
|
destroy(): void;
|
|
59
99
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DieType } from '@dice-o-rolla/dice-core';
|
|
2
|
+
import type { VisualPresetDescriptor } from '@dice-o-rolla/dice-renderer';
|
|
3
|
+
export type PhysicalDieType = Exclude<DieType, 'd100'>;
|
|
4
|
+
export declare const PHYSICAL_DIE_TYPES: readonly PhysicalDieType[];
|
|
5
|
+
export declare const STANDARD_VISUAL_PRESETS: readonly VisualPresetDescriptor[];
|
|
6
|
+
export declare function getStandardVisualPresetId(dieType: PhysicalDieType): string;
|
|
7
|
+
export declare function isPhysicalDieType(value: unknown): value is PhysicalDieType;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const PHYSICAL_DIE_TYPES = Object.freeze([
|
|
2
|
+
'd4',
|
|
3
|
+
'd6',
|
|
4
|
+
'd8',
|
|
5
|
+
'd10',
|
|
6
|
+
'd12',
|
|
7
|
+
'd20',
|
|
8
|
+
]);
|
|
9
|
+
export const STANDARD_VISUAL_PRESETS = Object.freeze(PHYSICAL_DIE_TYPES.map((dieType) => Object.freeze({
|
|
10
|
+
id: `standard:${dieType}`,
|
|
11
|
+
dieType,
|
|
12
|
+
geometryId: dieType,
|
|
13
|
+
scale: 1,
|
|
14
|
+
})));
|
|
15
|
+
export function getStandardVisualPresetId(dieType) {
|
|
16
|
+
return `standard:${dieType}`;
|
|
17
|
+
}
|
|
18
|
+
export function isPhysicalDieType(value) {
|
|
19
|
+
return PHYSICAL_DIE_TYPES.some((type) => type === value);
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dice-o-rolla/dice-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Framework-neutral physical dice engine with Rapier and Three.js browser adapters.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dice",
|
|
@@ -10,7 +10,16 @@
|
|
|
10
10
|
"three.js",
|
|
11
11
|
"typescript"
|
|
12
12
|
],
|
|
13
|
+
"homepage": "https://github.com/creepiest-space/dice-o-rolla#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/creepiest-space/dice-o-rolla/issues"
|
|
16
|
+
},
|
|
13
17
|
"license": "Apache-2.0",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/creepiest-space/dice-o-rolla.git",
|
|
21
|
+
"directory": "packages/dice-engine"
|
|
22
|
+
},
|
|
14
23
|
"files": [
|
|
15
24
|
"dist",
|
|
16
25
|
"README.md",
|
|
@@ -38,12 +47,12 @@
|
|
|
38
47
|
"registry": "https://registry.npmjs.org/"
|
|
39
48
|
},
|
|
40
49
|
"dependencies": {
|
|
41
|
-
"@dice-o-rolla/dice-core": "0.
|
|
42
|
-
"@dice-o-rolla/dice-geometry": "0.
|
|
43
|
-
"@dice-o-rolla/dice-physics": "0.
|
|
44
|
-
"@dice-o-rolla/dice-physics-rapier": "0.
|
|
45
|
-
"@dice-o-rolla/dice-renderer": "0.
|
|
46
|
-
"@dice-o-rolla/dice-renderer-three": "0.
|
|
50
|
+
"@dice-o-rolla/dice-core": "0.2.0",
|
|
51
|
+
"@dice-o-rolla/dice-geometry": "0.2.0",
|
|
52
|
+
"@dice-o-rolla/dice-physics": "0.2.0",
|
|
53
|
+
"@dice-o-rolla/dice-physics-rapier": "0.2.0",
|
|
54
|
+
"@dice-o-rolla/dice-renderer": "0.2.0",
|
|
55
|
+
"@dice-o-rolla/dice-renderer-three": "0.2.0"
|
|
47
56
|
},
|
|
48
57
|
"engines": {
|
|
49
58
|
"node": ">=20.0.0"
|