@aethervtt/shard-dice 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +2 -2
- package/dist/attachments.d.ts +79 -0
- package/dist/attachments.js +43 -0
- package/dist/audio.d.ts +19 -0
- package/dist/audio.js +187 -0
- package/dist/builtins.d.ts +25 -0
- package/dist/builtins.js +335 -0
- package/dist/cells.d.ts +33 -0
- package/dist/cells.js +53 -0
- package/dist/components.d.ts +14 -0
- package/dist/components.js +27 -0
- package/dist/definition-asset.d.ts +19 -0
- package/dist/definition-asset.js +60 -0
- package/dist/definition.d.ts +98 -0
- package/dist/definition.js +320 -0
- package/dist/dice-worker.d.ts +1 -0
- package/dist/dice-worker.js +3 -0
- package/dist/effects.d.ts +99 -0
- package/dist/effects.js +200 -0
- package/dist/entrances.d.ts +48 -0
- package/dist/entrances.js +54 -0
- package/dist/families.d.ts +17 -0
- package/dist/families.js +131 -0
- package/dist/glyphs.d.ts +29 -0
- package/dist/glyphs.js +270 -0
- package/dist/golden.json +17 -0
- package/dist/hull.d.ts +32 -0
- package/dist/hull.js +154 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +324 -0
- package/dist/landing.d.ts +29 -0
- package/dist/landing.js +93 -0
- package/dist/layout.d.ts +75 -0
- package/dist/layout.js +137 -0
- package/dist/marks.d.ts +54 -0
- package/dist/marks.js +543 -0
- package/dist/material.d.ts +80 -0
- package/dist/material.js +343 -0
- package/dist/math.d.ts +39 -0
- package/dist/math.js +136 -0
- package/dist/mesh.d.ts +9 -0
- package/dist/mesh.js +105 -0
- package/dist/methods.d.ts +2 -0
- package/dist/methods.js +42 -0
- package/dist/numerals.d.ts +9 -0
- package/dist/numerals.js +47 -0
- package/dist/plugin.d.ts +41 -0
- package/dist/plugin.js +176 -0
- package/dist/preview.d.ts +32 -0
- package/dist/preview.js +135 -0
- package/dist/quality.d.ts +12 -0
- package/dist/quality.js +18 -0
- package/dist/resources.d.ts +85 -0
- package/dist/resources.js +191 -0
- package/dist/roll.d.ts +68 -0
- package/dist/roll.js +97 -0
- package/dist/settle.d.ts +53 -0
- package/dist/settle.js +96 -0
- package/dist/skin.d.ts +64 -0
- package/dist/skin.js +313 -0
- package/dist/sound.d.ts +33 -0
- package/dist/sound.js +296 -0
- package/dist/studio.d.ts +3 -0
- package/dist/studio.js +48 -0
- package/dist/table.d.ts +227 -0
- package/dist/table.js +1818 -0
- package/dist/test-worker.d.ts +1 -0
- package/dist/test-worker.js +12 -0
- package/dist/track.d.ts +70 -0
- package/dist/track.js +304 -0
- package/dist/tray.d.ts +2 -0
- package/dist/tray.js +43 -0
- package/dist/variants.d.ts +9 -0
- package/dist/variants.js +46 -0
- package/dist/windows.d.ts +25 -0
- package/dist/windows.js +77 -0
- package/dist/worker.d.ts +6 -0
- package/dist/worker.js +9 -0
- package/package.json +47 -4
package/dist/table.js
ADDED
|
@@ -0,0 +1,1818 @@
|
|
|
1
|
+
import { assetServer } from "@aethervtt/shard-assets";
|
|
2
|
+
import { AudioState, playSound, preloadSound, stopSound } from "@aethervtt/shard-audio";
|
|
3
|
+
import {
|
|
4
|
+
defineResource,
|
|
5
|
+
ShardError
|
|
6
|
+
} from "@aethervtt/shard-core";
|
|
7
|
+
import { ParticleEffect, ParticleEffects, ParticleSystem } from "@aethervtt/shard-particles";
|
|
8
|
+
import { sampleTrack, trackHash } from "@aethervtt/shard-physics/track";
|
|
9
|
+
import {
|
|
10
|
+
createTrackClient
|
|
11
|
+
} from "@aethervtt/shard-physics/worker";
|
|
12
|
+
import {
|
|
13
|
+
Camera3d,
|
|
14
|
+
Cameras,
|
|
15
|
+
clearLensFields,
|
|
16
|
+
clearScreenEffects,
|
|
17
|
+
DirectionalLight,
|
|
18
|
+
EnvironmentMap,
|
|
19
|
+
Exposure,
|
|
20
|
+
Gpu,
|
|
21
|
+
InstanceData,
|
|
22
|
+
LensFields,
|
|
23
|
+
Materials,
|
|
24
|
+
Mesh3d,
|
|
25
|
+
MeshMaterial,
|
|
26
|
+
NotShadowCaster,
|
|
27
|
+
NotShadowReceiver,
|
|
28
|
+
OffscreenTarget,
|
|
29
|
+
PointLight,
|
|
30
|
+
publishLensField,
|
|
31
|
+
publishScreenEffect,
|
|
32
|
+
RenderStats,
|
|
33
|
+
RenderTargets,
|
|
34
|
+
renderOwner,
|
|
35
|
+
ScreenEffects,
|
|
36
|
+
Visibility,
|
|
37
|
+
Window
|
|
38
|
+
} from "@aethervtt/shard-render";
|
|
39
|
+
import { FrameDemand, LogResource, Time } from "@aethervtt/shard-runtime";
|
|
40
|
+
import { Fonts } from "@aethervtt/shard-text";
|
|
41
|
+
import { lookAt, Transform } from "@aethervtt/shard-transform";
|
|
42
|
+
import {
|
|
43
|
+
findDiceAttachment,
|
|
44
|
+
MAX_ATTACHMENTS
|
|
45
|
+
} from "./attachments.js";
|
|
46
|
+
import { DICE_AUDIO_BUS, DiceAudioSession } from "./audio.js";
|
|
47
|
+
import { DiceDie } from "./components.js";
|
|
48
|
+
import { dieGeometry, requireDie } from "./definition.js";
|
|
49
|
+
import {
|
|
50
|
+
DiceEffectRecipe,
|
|
51
|
+
matchRecipes
|
|
52
|
+
} from "./effects.js";
|
|
53
|
+
import {
|
|
54
|
+
findDiceEntrance,
|
|
55
|
+
MAX_ENTRANCES
|
|
56
|
+
} from "./entrances.js";
|
|
57
|
+
import { floorUp, landingCorrection, naturalValue, restingHeight, restRotation } from "./landing.js";
|
|
58
|
+
import { FaceLayout } from "./layout.js";
|
|
59
|
+
import { DICE_FIELDS } from "./material.js";
|
|
60
|
+
import { hashString } from "./math.js";
|
|
61
|
+
import { resolveDiceQuality } from "./quality.js";
|
|
62
|
+
import { DiceResources, RELEASE_AFTER_MS } from "./resources.js";
|
|
63
|
+
import { expandRoll, rollTrackRequest, viewportTray } from "./roll.js";
|
|
64
|
+
import { DICE_SETTLE_RULE, diceSettle } from "./settle.js";
|
|
65
|
+
import { DiceSkin, validateDiceSkin } from "./skin.js";
|
|
66
|
+
import {
|
|
67
|
+
DiceSoundBank,
|
|
68
|
+
IMPACT_VARIATIONS,
|
|
69
|
+
impactLayer,
|
|
70
|
+
impactStrength
|
|
71
|
+
} from "./sound.js";
|
|
72
|
+
import {
|
|
73
|
+
DICE_CONTACTS,
|
|
74
|
+
diceSettleParams,
|
|
75
|
+
diceTrackScene,
|
|
76
|
+
entranceSpots,
|
|
77
|
+
laneLayout,
|
|
78
|
+
placeDie,
|
|
79
|
+
placedFrom,
|
|
80
|
+
placementSpots,
|
|
81
|
+
unlandedDice
|
|
82
|
+
} from "./track.js";
|
|
83
|
+
import { meshVertices } from "./windows.js";
|
|
84
|
+
const PRESENTATION_DEMAND = "dice/presentation";
|
|
85
|
+
const ATTACHMENT_DEMAND = "dice/attachments";
|
|
86
|
+
const ANIMATED_DEMAND = "dice/animated";
|
|
87
|
+
const ENTRANCE_DEMAND = "dice/entrance";
|
|
88
|
+
const ENTRANCE_WAIT_MS = 1500;
|
|
89
|
+
const SKIP_WIND_DOWN_MS = 400;
|
|
90
|
+
const ENTRANCE_DROP_MS = 300;
|
|
91
|
+
const MAX_STEP_S = 1 / 15;
|
|
92
|
+
const RESULT_RAMP_MS = 350;
|
|
93
|
+
const FADE_MS = 150;
|
|
94
|
+
const PREVIEW_TRAY = { halfWidth: 2.6, halfDepth: 1.8 };
|
|
95
|
+
let warmSlot = 0;
|
|
96
|
+
const POS = new Float64Array(3);
|
|
97
|
+
const ROT = new Float64Array(4);
|
|
98
|
+
const SCREEN = new Float64Array(2);
|
|
99
|
+
const IMPACTS_PER_FRAME = 4;
|
|
100
|
+
const IMPACT_GAP_S = 0.045;
|
|
101
|
+
const PICK = new Int32Array(IMPACTS_PER_FRAME);
|
|
102
|
+
const POWER = new Float32Array(IMPACTS_PER_FRAME);
|
|
103
|
+
const IMPACT_VOLUME = [0, 0];
|
|
104
|
+
const IMPACT_PITCH = [0, 0];
|
|
105
|
+
const IMPACT_SOUND = { bus: DICE_AUDIO_BUS, volume: IMPACT_VOLUME, pitch: IMPACT_PITCH };
|
|
106
|
+
const ACCENT_LEVEL = 0.4;
|
|
107
|
+
function screenOf(world, camera, x, y, z, out) {
|
|
108
|
+
const cam = world.resource(Cameras).get(camera);
|
|
109
|
+
if (!cam) return false;
|
|
110
|
+
const m = cam.viewProj;
|
|
111
|
+
const w = m[3] * x + m[7] * y + m[11] * z + m[15];
|
|
112
|
+
if (w <= 0) return false;
|
|
113
|
+
const cx = (m[0] * x + m[4] * y + m[8] * z + m[12]) / w;
|
|
114
|
+
const cy = (m[1] * x + m[5] * y + m[9] * z + m[13]) / w;
|
|
115
|
+
out[0] = (cx + 1) / 2 * (cam.displayWidth / cam.pixelRatio);
|
|
116
|
+
out[1] = (1 - cy) / 2 * (cam.displayHeight / cam.pixelRatio);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
class DiceTableState {
|
|
120
|
+
/** The last failure's error (`'failed'` outcomes). */
|
|
121
|
+
lastError = void 0;
|
|
122
|
+
/** Recordings sent to the track worker (reduced motion sends none). */
|
|
123
|
+
recordings = 0;
|
|
124
|
+
resources;
|
|
125
|
+
sounds = new DiceSoundBank();
|
|
126
|
+
options;
|
|
127
|
+
camera = -1;
|
|
128
|
+
light = -1;
|
|
129
|
+
floor = -1;
|
|
130
|
+
/** Where shadows fall on the floor (x, z), from the key light: blobs lean that way. */
|
|
131
|
+
shadowDir = [0.63, -0.78];
|
|
132
|
+
/** The tray material and quad, for the floor and contact blobs. */
|
|
133
|
+
trayMaterial;
|
|
134
|
+
quad;
|
|
135
|
+
world;
|
|
136
|
+
client;
|
|
137
|
+
current;
|
|
138
|
+
nextId = 1;
|
|
139
|
+
disposed = false;
|
|
140
|
+
fitted = { aspect: 0, halfWidth: 0, halfDepth: 0 };
|
|
141
|
+
warned = /* @__PURE__ */ new Set();
|
|
142
|
+
/** Warm-ups waiting for the next frame (0065). */
|
|
143
|
+
frameWaiters = [];
|
|
144
|
+
constructor(world, options) {
|
|
145
|
+
this.world = world;
|
|
146
|
+
this.options = options;
|
|
147
|
+
this.resources = new DiceResources(world);
|
|
148
|
+
}
|
|
149
|
+
/** Where the presentation is: `'idle'` with none. */
|
|
150
|
+
get phase() {
|
|
151
|
+
return this.current?.phase ?? "idle";
|
|
152
|
+
}
|
|
153
|
+
/** The track client, started on first use; call early to have the worker warm. */
|
|
154
|
+
tracks() {
|
|
155
|
+
this.client ??= createTrackClient({
|
|
156
|
+
spawn: this.options.worker,
|
|
157
|
+
rules: { [DICE_SETTLE_RULE]: diceSettle }
|
|
158
|
+
});
|
|
159
|
+
return this.client;
|
|
160
|
+
}
|
|
161
|
+
// --- the host's calls ---------------------------------------------------------------------------
|
|
162
|
+
/**
|
|
163
|
+
* Presents a roll the host decided. Resolves once it's over: `'finished'` after the rest,
|
|
164
|
+
* `'dismissed'`, `'cancelled'` (the signal aborted), or `'failed'` (with the error in
|
|
165
|
+
* `lastError`; never a half-played roll). Rejects with `dice/busy` while another plays, unless
|
|
166
|
+
* `replace`.
|
|
167
|
+
*/
|
|
168
|
+
play(roll, options = {}) {
|
|
169
|
+
if (this.disposed) {
|
|
170
|
+
this.lastError = new ShardError("dice/disposed", "The dice table was disposed");
|
|
171
|
+
return Promise.resolve("failed");
|
|
172
|
+
}
|
|
173
|
+
if (this.current) {
|
|
174
|
+
if (!options.replace) {
|
|
175
|
+
return Promise.reject(
|
|
176
|
+
new ShardError("dice/busy", "A roll is already playing", {
|
|
177
|
+
hint: "Queue rolls in the host, dismiss this one, or pass { replace: true }."
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
this.finish(this.current, "dismissed");
|
|
182
|
+
}
|
|
183
|
+
let resolve;
|
|
184
|
+
const done = new Promise((r) => {
|
|
185
|
+
resolve = r;
|
|
186
|
+
});
|
|
187
|
+
const p = {
|
|
188
|
+
id: this.nextId++,
|
|
189
|
+
roll,
|
|
190
|
+
dice: [],
|
|
191
|
+
skins: [],
|
|
192
|
+
skinKeys: [],
|
|
193
|
+
quality: { tier: "full", reason: "" },
|
|
194
|
+
reduced: roll.motion === "reduced",
|
|
195
|
+
effects: false,
|
|
196
|
+
attachments: false,
|
|
197
|
+
lens: false,
|
|
198
|
+
soundGain: Math.max(0, Math.min(1, roll.soundGain ?? 1)),
|
|
199
|
+
phase: "simulating",
|
|
200
|
+
resolve,
|
|
201
|
+
done: false,
|
|
202
|
+
abort: new AbortController(),
|
|
203
|
+
unlisten: void 0,
|
|
204
|
+
tray: roll.tray ?? viewportTray(this.aspect()),
|
|
205
|
+
request: void 0,
|
|
206
|
+
track: void 0,
|
|
207
|
+
hash: void 0,
|
|
208
|
+
corrections: new Float64Array(0),
|
|
209
|
+
natural: [],
|
|
210
|
+
placed: [],
|
|
211
|
+
entries: [],
|
|
212
|
+
blended: [],
|
|
213
|
+
entities: [],
|
|
214
|
+
blobs: [],
|
|
215
|
+
time: 0,
|
|
216
|
+
nextContact: 0,
|
|
217
|
+
lastImpact: new Float64Array(0),
|
|
218
|
+
pitch: new Float32Array(0),
|
|
219
|
+
soundSeed: hashString(`${roll.seed ?? roll.id}:sound`),
|
|
220
|
+
landedAt: Number.NaN,
|
|
221
|
+
fadeFrom: Number.NaN,
|
|
222
|
+
restEndsAt: Number.POSITIVE_INFINITY,
|
|
223
|
+
restMs: void 0,
|
|
224
|
+
accentEndsAt: 0,
|
|
225
|
+
runs: [],
|
|
226
|
+
matched: [],
|
|
227
|
+
degraded: false,
|
|
228
|
+
live: [],
|
|
229
|
+
voices: [],
|
|
230
|
+
cuePlayed: false,
|
|
231
|
+
audio: new DiceAudioSession(this.world, Math.max(0, Math.min(1, roll.soundGain ?? 1))),
|
|
232
|
+
shadowsBefore: void 0,
|
|
233
|
+
trayShadow: Number.NaN,
|
|
234
|
+
fps: 0,
|
|
235
|
+
physical: [],
|
|
236
|
+
bodyOf: new Int32Array(0),
|
|
237
|
+
entrances: [],
|
|
238
|
+
entranceSkips: [],
|
|
239
|
+
playedRecipes: /* @__PURE__ */ new Set(),
|
|
240
|
+
readyAt: Number.NaN
|
|
241
|
+
};
|
|
242
|
+
this.current = p;
|
|
243
|
+
const signal = options.signal;
|
|
244
|
+
if (signal?.aborted) {
|
|
245
|
+
this.finish(p, "cancelled");
|
|
246
|
+
return done;
|
|
247
|
+
}
|
|
248
|
+
if (signal) {
|
|
249
|
+
const onAbort = () => this.finish(p, "cancelled");
|
|
250
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
251
|
+
p.unlisten = () => signal.removeEventListener("abort", onAbort);
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
this.check(p);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
this.fail(p, err);
|
|
257
|
+
return done;
|
|
258
|
+
}
|
|
259
|
+
this.world.resource(FrameDemand).hold(PRESENTATION_DEMAND);
|
|
260
|
+
void this.prepare(p);
|
|
261
|
+
return done;
|
|
262
|
+
}
|
|
263
|
+
/** Plays a roll on a fixed seed and tray, for skin pickers. Replaces what's playing. */
|
|
264
|
+
preview(roll, options = {}) {
|
|
265
|
+
return this.play(
|
|
266
|
+
{ ...roll, seed: "preview", tray: PREVIEW_TRAY },
|
|
267
|
+
{ ...options, replace: true }
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
/** Ends the presentation now: `'dismissed'`. False when nothing plays. */
|
|
271
|
+
dismiss() {
|
|
272
|
+
if (!this.current) return false;
|
|
273
|
+
this.finish(this.current, "dismissed");
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
/** Shortens the rest to at most `ms` from now (or from when the dice land, if they haven't). */
|
|
277
|
+
shortenRest(ms) {
|
|
278
|
+
const p = this.current;
|
|
279
|
+
if (!p) return;
|
|
280
|
+
const wait = Math.max(0, ms);
|
|
281
|
+
p.restMs = Math.min(p.restMs ?? Number.POSITIVE_INFINITY, wait);
|
|
282
|
+
if (p.phase === "rest") {
|
|
283
|
+
p.restEndsAt = Math.min(p.restEndsAt, this.options.now() + wait);
|
|
284
|
+
this.world.resource(FrameDemand).after(wait);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// --- presentation -------------------------------------------------------------------------------
|
|
288
|
+
/** Everything that can fail before anything plays: the roll's values, its skins and layouts. */
|
|
289
|
+
check(p) {
|
|
290
|
+
const roll = p.roll;
|
|
291
|
+
p.dice = expandRoll(roll, this.options.kinds);
|
|
292
|
+
if (p.dice.length > this.options.maxDice) {
|
|
293
|
+
throw new ShardError(
|
|
294
|
+
"dice/invalid-roll",
|
|
295
|
+
`The table shows at most ${this.options.maxDice} dice`,
|
|
296
|
+
{
|
|
297
|
+
path: "dice"
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
p.quality = resolveDiceQuality(p.dice.length, roll.quality);
|
|
302
|
+
const large = p.quality.tier === "large-pool";
|
|
303
|
+
p.effects = !p.reduced && roll.effects !== false && !large;
|
|
304
|
+
p.attachments = p.effects && p.quality.tier === "full";
|
|
305
|
+
p.lens = p.attachments;
|
|
306
|
+
const store = this.world.resource(DiceSkin.store);
|
|
307
|
+
const cache = /* @__PURE__ */ new Map();
|
|
308
|
+
for (const d of p.dice) {
|
|
309
|
+
const key = d.skin.guid ?? d.skin.path ?? "";
|
|
310
|
+
let skin = cache.get(key);
|
|
311
|
+
if (!skin) {
|
|
312
|
+
skin = store.get(d.skin);
|
|
313
|
+
if (!skin) {
|
|
314
|
+
throw new ShardError("dice/unknown-skin", `No dice skin ${key}`, {
|
|
315
|
+
path: `dice[${d.source}].skin`,
|
|
316
|
+
hint: "Add skins to DiceSkin.store (or load their .dice-skin.json files) before rolling them."
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
cache.set(key, skin);
|
|
320
|
+
}
|
|
321
|
+
p.skins.push(skin);
|
|
322
|
+
p.skinKeys.push(key);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Loads what the roll's skins reference (layouts, recipes, the layouts' fonts), then checks the
|
|
327
|
+
* skins: an unknown family, a layout that can't show a value, a recipe over its bounds.
|
|
328
|
+
*/
|
|
329
|
+
async loadSkins(p) {
|
|
330
|
+
const pending = /* @__PURE__ */ new Map();
|
|
331
|
+
const want = (ref, have) => {
|
|
332
|
+
if (ref && !have) pending.set(ref.guid ?? ref.path ?? "", ref);
|
|
333
|
+
};
|
|
334
|
+
const skins = new Set(p.skins);
|
|
335
|
+
for (const skin of skins) {
|
|
336
|
+
for (const kind of Object.keys(skin.variants)) {
|
|
337
|
+
const l = skin.variants[kind].layout;
|
|
338
|
+
want(l, l && this.layout(l));
|
|
339
|
+
}
|
|
340
|
+
for (const r of skin.effects) want(r, r && this.recipe(r));
|
|
341
|
+
}
|
|
342
|
+
if (pending.size > 0)
|
|
343
|
+
await Promise.all([...pending.values()].map((r) => assetServer(this.world).load(r)));
|
|
344
|
+
if (p.done) return;
|
|
345
|
+
await this.loadFonts(p);
|
|
346
|
+
if (p.done) return;
|
|
347
|
+
for (const skin of skins) {
|
|
348
|
+
const errors = validateDiceSkin(skin, {
|
|
349
|
+
layout: (r) => this.layout(r),
|
|
350
|
+
recipe: (r) => this.recipe(r)
|
|
351
|
+
});
|
|
352
|
+
if (errors.length > 0) throw errors[0];
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
layout(ref) {
|
|
356
|
+
return this.world.resource(FaceLayout.store).get(ref);
|
|
357
|
+
}
|
|
358
|
+
recipe(ref) {
|
|
359
|
+
return this.world.resource(DiceEffectRecipe.store).get(ref);
|
|
360
|
+
}
|
|
361
|
+
/** Loads what the roll's layouts need (fonts), makes the dice's resources, then records or places. */
|
|
362
|
+
async prepare(p) {
|
|
363
|
+
try {
|
|
364
|
+
await this.loadSkins(p);
|
|
365
|
+
if (p.done) return;
|
|
366
|
+
this.chooseEntrances(p);
|
|
367
|
+
await this.prepareAudio(p);
|
|
368
|
+
if (p.done) return;
|
|
369
|
+
this.acquire(p);
|
|
370
|
+
p.request = rollTrackRequest(
|
|
371
|
+
p.roll,
|
|
372
|
+
p.physical.map((i) => p.dice[i]),
|
|
373
|
+
p.tray
|
|
374
|
+
);
|
|
375
|
+
if (p.reduced) {
|
|
376
|
+
this.placeReduced(p);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const request = p.request;
|
|
380
|
+
const recording = p.physical.length > 0 ? this.tracks().record(diceTrackScene(request), {
|
|
381
|
+
signal: p.abort.signal,
|
|
382
|
+
settle: { rule: DICE_SETTLE_RULE, params: diceSettleParams(request) },
|
|
383
|
+
contacts: DICE_CONTACTS
|
|
384
|
+
}) : void 0;
|
|
385
|
+
if (recording) this.recordings++;
|
|
386
|
+
if (p.soundGain > 0 && this.world.tryResource(AudioState)) {
|
|
387
|
+
for (const skin of new Set(p.skins)) {
|
|
388
|
+
if (skin.sounds.impactEvent) continue;
|
|
389
|
+
this.sounds.prepare(skin.sounds.impact, (clip) => preloadSound(this.world, clip));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
for (const run of p.entrances) void this.readyEntrance(p, run);
|
|
393
|
+
const track = recording ? await recording : void 0;
|
|
394
|
+
if (p.done) return;
|
|
395
|
+
p.readyAt = this.options.now();
|
|
396
|
+
this.land(p, track);
|
|
397
|
+
this.spawnDice(p);
|
|
398
|
+
if (track) p.phase = "tumble";
|
|
399
|
+
else this.landed(p);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
if (p.done) return;
|
|
402
|
+
const code = err.code;
|
|
403
|
+
if (code === "physics/track-cancelled" && p.abort.signal.aborted) this.finish(p, "cancelled");
|
|
404
|
+
else this.fail(p, err);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async prepareAudio(p) {
|
|
408
|
+
if (p.soundGain <= 0 || p.reduced || !this.world.hasResource(AudioState)) return;
|
|
409
|
+
const refs = [];
|
|
410
|
+
for (const skin of new Set(p.skins)) {
|
|
411
|
+
if (skin.sounds.impactEvent) refs.push(skin.sounds.impactEvent);
|
|
412
|
+
if (p.effects && skin.sounds.resultEvent) refs.push(skin.sounds.resultEvent);
|
|
413
|
+
}
|
|
414
|
+
for (const run of p.entrances) {
|
|
415
|
+
const ref = p.skins[run.die].sounds.entranceEvent;
|
|
416
|
+
if (ref && run.def && !run.fallback) refs.push(ref);
|
|
417
|
+
}
|
|
418
|
+
if (p.effects)
|
|
419
|
+
for (const matched of p.matched) {
|
|
420
|
+
if (matched.anchors.length === 0) continue;
|
|
421
|
+
for (const effect of matched.recipe.effects)
|
|
422
|
+
if (effect.kind === "sound-accent" && effect.event) refs.push(effect.event);
|
|
423
|
+
}
|
|
424
|
+
await p.audio.prepare(refs);
|
|
425
|
+
}
|
|
426
|
+
eventSound(p, ref, die, salt, strength = 1, contact = 0, gain = 1) {
|
|
427
|
+
if (!ref) return false;
|
|
428
|
+
const d = p.dice[die];
|
|
429
|
+
return p.audio.play(
|
|
430
|
+
ref,
|
|
431
|
+
p.soundSeed ^ Math.imul(salt + 1, 2654435761),
|
|
432
|
+
strength,
|
|
433
|
+
p.pitch[die] ?? 1,
|
|
434
|
+
d.rolled,
|
|
435
|
+
d.high,
|
|
436
|
+
d.low,
|
|
437
|
+
p.dice.length,
|
|
438
|
+
contact,
|
|
439
|
+
gain
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
async loadFonts(p) {
|
|
443
|
+
const fonts = this.world.tryResource(Fonts);
|
|
444
|
+
const wanted = /* @__PURE__ */ new Map();
|
|
445
|
+
for (let i = 0; i < p.dice.length; i++) {
|
|
446
|
+
const variant = p.skins[i].variants[p.dice[i].kind];
|
|
447
|
+
const layout = variant.layout ? this.layout(variant.layout) : void 0;
|
|
448
|
+
if (!layout) continue;
|
|
449
|
+
for (const m of [layout.default, ...layout.overrides.map((o) => o.mark)]) {
|
|
450
|
+
if (m.kind === "text" && m.font && !fonts?.get(m.font))
|
|
451
|
+
wanted.set(m.font.guid ?? m.font.path ?? "", m.font);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (wanted.size === 0) return;
|
|
455
|
+
await Promise.all([...wanted.values()].map((ref) => assetServer(this.world).load(ref)));
|
|
456
|
+
}
|
|
457
|
+
acquire(p) {
|
|
458
|
+
const fonts = this.world.tryResource(Fonts);
|
|
459
|
+
const large = p.quality.tier === "large-pool";
|
|
460
|
+
for (let i = 0; i < p.dice.length; i++) {
|
|
461
|
+
const d = p.dice[i];
|
|
462
|
+
const g = dieGeometry(requireDie(d.definition));
|
|
463
|
+
const entry = this.resources.acquire(
|
|
464
|
+
g,
|
|
465
|
+
p.skinKeys[i],
|
|
466
|
+
p.skins[i],
|
|
467
|
+
d.kind,
|
|
468
|
+
(r) => this.layout(r),
|
|
469
|
+
{
|
|
470
|
+
font: (r) => fonts?.get(r)
|
|
471
|
+
}
|
|
472
|
+
);
|
|
473
|
+
p.entries.push(entry);
|
|
474
|
+
p.fps = Math.max(p.fps, entry.family.fps);
|
|
475
|
+
const see = large ? this.resources.fixedBlend(entry) : this.resources.blendedByNature(entry);
|
|
476
|
+
p.blended.push(p.reduced || d.dropped || see);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
screenUp() {
|
|
480
|
+
const t = this.world.tryGet(this.camera, Transform);
|
|
481
|
+
if (!t) return [0, 0, -1];
|
|
482
|
+
const q = t.rotation;
|
|
483
|
+
const x = 2 * (q[0] * q[1] - q[3] * q[2]);
|
|
484
|
+
const z = 2 * (q[1] * q[2] + q[3] * q[0]);
|
|
485
|
+
return floorUp([x, 0, z]);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* The track is in (or there's none: every die enters): which dice land, where the rest go, each
|
|
489
|
+
* die's correction, and where entrance dice will land (0065).
|
|
490
|
+
*/
|
|
491
|
+
land(p, track) {
|
|
492
|
+
const up = this.screenUp();
|
|
493
|
+
const n = p.dice.length;
|
|
494
|
+
p.corrections = new Float64Array(n * 4);
|
|
495
|
+
for (let i = 0; i < n; i++) p.corrections[i * 4 + 3] = 1;
|
|
496
|
+
p.placed = p.dice.map(() => null);
|
|
497
|
+
p.natural = p.dice.map((d) => d.value);
|
|
498
|
+
if (track) {
|
|
499
|
+
p.track = track;
|
|
500
|
+
p.hash = trackHash(track);
|
|
501
|
+
const request = p.request;
|
|
502
|
+
const unlanded = unlandedDice(track, request);
|
|
503
|
+
const spots = placementSpots(track, request, unlanded);
|
|
504
|
+
const o = track.steps * track.bodyCount;
|
|
505
|
+
for (let b = 0; b < p.physical.length; b++) {
|
|
506
|
+
const i = p.physical[b];
|
|
507
|
+
const d = p.dice[i];
|
|
508
|
+
const g = dieGeometry(requireDie(d.definition));
|
|
509
|
+
p.placed[i] = unlanded[b];
|
|
510
|
+
if (unlanded[b]) {
|
|
511
|
+
placeDie(track, b, spots[b], restRotation(g, d.value, up), g, d.scale);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const final = track.rotations.subarray((o + b) * 4, (o + b) * 4 + 4);
|
|
515
|
+
p.natural[i] = naturalValue(g, final);
|
|
516
|
+
p.corrections.set(landingCorrection(g, final, d.value, up), i * 4);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
this.placeEntrances(p, up);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Rest poses for entrance dice: free spots near what each entrance wants (the tray's center by
|
|
523
|
+
* default), clear of where the physical dice ended, target up with the readable twist.
|
|
524
|
+
*/
|
|
525
|
+
placeEntrances(p, up) {
|
|
526
|
+
if (p.entrances.length === 0) return;
|
|
527
|
+
const taken = [];
|
|
528
|
+
const track = p.track;
|
|
529
|
+
if (track) {
|
|
530
|
+
const o = track.steps * track.bodyCount;
|
|
531
|
+
for (let b = 0; b < track.bodyCount; b++) {
|
|
532
|
+
taken.push([track.positions[(o + b) * 3], 0, track.positions[(o + b) * 3 + 2]]);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const wants = p.entrances.map((run) => {
|
|
536
|
+
const spot = run.def?.spot;
|
|
537
|
+
return spot && spot !== "center" ? [spot[0] * p.tray.halfWidth, 0, spot[1] * p.tray.halfDepth] : [0, 0, 0];
|
|
538
|
+
});
|
|
539
|
+
const all = p.dice.map((d) => ({
|
|
540
|
+
definition: d.definition,
|
|
541
|
+
scale: d.scale,
|
|
542
|
+
dropped: d.dropped
|
|
543
|
+
}));
|
|
544
|
+
const spots = entranceSpots(p.tray, all, taken, wants);
|
|
545
|
+
p.entrances.forEach((run, k) => {
|
|
546
|
+
const d = p.dice[run.die];
|
|
547
|
+
const g = dieGeometry(requireDie(d.definition));
|
|
548
|
+
const rotation = restRotation(g, d.value, up);
|
|
549
|
+
run.rest = {
|
|
550
|
+
position: [spots[k][0], restingHeight(g, rotation, d.scale), spots[k][2]],
|
|
551
|
+
rotation: [rotation[0], rotation[1], rotation[2], rotation[3]]
|
|
552
|
+
};
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
/** Reduced motion: no physics. Each die rests in its lane, target up and upright, fading in. */
|
|
556
|
+
placeReduced(p) {
|
|
557
|
+
const up = this.screenUp();
|
|
558
|
+
const lanes = laneLayout(p.request);
|
|
559
|
+
p.corrections = new Float64Array(p.dice.length * 4);
|
|
560
|
+
for (let i = 0; i < p.dice.length; i++) p.corrections[i * 4 + 3] = 1;
|
|
561
|
+
p.placed = p.dice.map(() => null);
|
|
562
|
+
p.natural = p.dice.map((d) => d.value);
|
|
563
|
+
this.spawnDice(p, (i, pos, rot) => {
|
|
564
|
+
const d = p.dice[i];
|
|
565
|
+
const g = dieGeometry(requireDie(d.definition));
|
|
566
|
+
const q = restRotation(g, d.value, up);
|
|
567
|
+
rot.set(q);
|
|
568
|
+
pos[0] = lanes[i][0];
|
|
569
|
+
pos[1] = restingHeight(g, q, d.scale);
|
|
570
|
+
pos[2] = lanes[i][2];
|
|
571
|
+
});
|
|
572
|
+
p.fadeFrom = this.options.now();
|
|
573
|
+
this.resources.setAll(p.entries, "fade", 0);
|
|
574
|
+
this.resources.setAll(p.entries, "result", 1);
|
|
575
|
+
this.resources.setAll(p.entries, "resultTime", this.world.resource(Time).elapsed);
|
|
576
|
+
this.rest(p);
|
|
577
|
+
}
|
|
578
|
+
geometryOf(d) {
|
|
579
|
+
return dieGeometry(requireDie(d.definition));
|
|
580
|
+
}
|
|
581
|
+
/** Spawns the dice (and contact blobs) at their first pose. */
|
|
582
|
+
spawnDice(p, pose) {
|
|
583
|
+
const world = this.world;
|
|
584
|
+
const large = p.quality.tier === "large-pool";
|
|
585
|
+
p.lastImpact = new Float64Array(p.dice.length).fill(Number.NEGATIVE_INFINITY);
|
|
586
|
+
p.pitch = Float32Array.from(p.dice, (d) => Math.sqrt(16 / requireDie(d.definition).sizeMm));
|
|
587
|
+
if (large && world.isAlive(this.light)) {
|
|
588
|
+
const light = world.get(this.light, DirectionalLight);
|
|
589
|
+
p.shadowsBefore = light.shadows;
|
|
590
|
+
if (light.shadows) world.set(this.light, DirectionalLight, { shadows: false });
|
|
591
|
+
}
|
|
592
|
+
for (let i = 0; i < p.dice.length; i++) {
|
|
593
|
+
const d = p.dice[i];
|
|
594
|
+
const entry = p.entries[i];
|
|
595
|
+
const run = p.entrances.find((r) => r.die === i);
|
|
596
|
+
if (pose) pose(i, POS, ROT);
|
|
597
|
+
else if (run) {
|
|
598
|
+
POS.set(run.rest.position);
|
|
599
|
+
ROT.set(run.rest.rotation);
|
|
600
|
+
} else this.samplePose(p, i, 0, POS, ROT);
|
|
601
|
+
const material = this.resources.material(entry, {
|
|
602
|
+
blended: p.blended[i],
|
|
603
|
+
dropped: d.dropped,
|
|
604
|
+
entrance: run ? i : void 0
|
|
605
|
+
});
|
|
606
|
+
if (run) {
|
|
607
|
+
run.material = material;
|
|
608
|
+
world.resource(Materials).get(material)?.set({ result: 0, resultTime: 0 });
|
|
609
|
+
}
|
|
610
|
+
const s = d.scale;
|
|
611
|
+
p.entities.push(
|
|
612
|
+
world.spawn(
|
|
613
|
+
...large ? [NotShadowReceiver] : [],
|
|
614
|
+
...run ? [[Visibility, { mode: "hidden" }]] : [],
|
|
615
|
+
[Mesh3d, { mesh: entry.mesh }],
|
|
616
|
+
[MeshMaterial, { material }],
|
|
617
|
+
[
|
|
618
|
+
Transform,
|
|
619
|
+
{
|
|
620
|
+
translation: [POS[0], POS[1], POS[2]],
|
|
621
|
+
rotation: [ROT[0], ROT[1], ROT[2], ROT[3]],
|
|
622
|
+
scale: [s, s, s]
|
|
623
|
+
}
|
|
624
|
+
],
|
|
625
|
+
[InstanceData, { x: d.value, y: 0 }],
|
|
626
|
+
[
|
|
627
|
+
DiceDie,
|
|
628
|
+
{
|
|
629
|
+
index: i,
|
|
630
|
+
kind: d.kind,
|
|
631
|
+
definition: d.definition,
|
|
632
|
+
value: d.value,
|
|
633
|
+
label: d.label,
|
|
634
|
+
dropped: d.dropped
|
|
635
|
+
}
|
|
636
|
+
]
|
|
637
|
+
)
|
|
638
|
+
);
|
|
639
|
+
const blob = p.blended[i] || large;
|
|
640
|
+
p.blobs.push(blob ? this.spawnBlob(d, POS) : -1);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
spawnBlob(d, pos) {
|
|
644
|
+
const r = this.blobRadius(d);
|
|
645
|
+
const lean = this.blobLean(d, pos[1]);
|
|
646
|
+
return this.world.spawn(
|
|
647
|
+
[Mesh3d, { mesh: this.quad }],
|
|
648
|
+
[MeshMaterial, { material: this.trayMaterial }],
|
|
649
|
+
[
|
|
650
|
+
Transform,
|
|
651
|
+
{
|
|
652
|
+
translation: [
|
|
653
|
+
pos[0] + this.shadowDir[0] * lean,
|
|
654
|
+
3e-3,
|
|
655
|
+
pos[2] + this.shadowDir[1] * lean
|
|
656
|
+
],
|
|
657
|
+
scale: [r, 1, r]
|
|
658
|
+
}
|
|
659
|
+
],
|
|
660
|
+
[InstanceData, { x: this.blobStrength(d, pos[1]), y: 0 }],
|
|
661
|
+
NotShadowCaster,
|
|
662
|
+
NotShadowReceiver
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
blobRadius(d) {
|
|
666
|
+
return this.geometryOf(d).footprint * d.scale * 0.95;
|
|
667
|
+
}
|
|
668
|
+
/** How far a blob sits from under its die, toward the shadows: more as the die rises. */
|
|
669
|
+
blobLean(d, y) {
|
|
670
|
+
const rest = this.geometryOf(d).restHeight * d.scale;
|
|
671
|
+
return this.blobRadius(d) * 0.28 + Math.max(0, y - rest) * 0.35;
|
|
672
|
+
}
|
|
673
|
+
blobStrength(d, y) {
|
|
674
|
+
const rest = this.geometryOf(d).restHeight * d.scale;
|
|
675
|
+
return Math.max(0.02, 1 - Math.max(0, y - rest) / 1.6);
|
|
676
|
+
}
|
|
677
|
+
/** A die's pose at `time`, with its correction: rotation · C. Allocates nothing. */
|
|
678
|
+
samplePose(p, i, time, pos, rot) {
|
|
679
|
+
sampleTrack(p.track, time, p.bodyOf[i], pos, rot);
|
|
680
|
+
const c = p.corrections;
|
|
681
|
+
const o = i * 4;
|
|
682
|
+
const ax = rot[0];
|
|
683
|
+
const ay = rot[1];
|
|
684
|
+
const az = rot[2];
|
|
685
|
+
const aw = rot[3];
|
|
686
|
+
const bx = c[o];
|
|
687
|
+
const by = c[o + 1];
|
|
688
|
+
const bz = c[o + 2];
|
|
689
|
+
const bw = c[o + 3];
|
|
690
|
+
rot[0] = aw * bx + ax * bw + ay * bz - az * by;
|
|
691
|
+
rot[1] = aw * by - ax * bz + ay * bw + az * bx;
|
|
692
|
+
rot[2] = aw * bz + ax * by - ay * bx + az * bw;
|
|
693
|
+
rot[3] = aw * bw - ax * bx - ay * by - az * bz;
|
|
694
|
+
}
|
|
695
|
+
// --- frames -------------------------------------------------------------------------------------
|
|
696
|
+
/** One frame of the table: the systems call it in Update. */
|
|
697
|
+
frame() {
|
|
698
|
+
const world = this.world;
|
|
699
|
+
const now = this.options.now();
|
|
700
|
+
const waiters = this.frameWaiters;
|
|
701
|
+
if (waiters.length > 0) {
|
|
702
|
+
this.frameWaiters = [];
|
|
703
|
+
for (let i = 0; i < waiters.length; i++) waiters[i]();
|
|
704
|
+
}
|
|
705
|
+
this.fitCamera();
|
|
706
|
+
if (this.resources.nextRelease() <= now) this.resources.sweep(now);
|
|
707
|
+
const p = this.current;
|
|
708
|
+
if (!p || p.done) return;
|
|
709
|
+
try {
|
|
710
|
+
const dt = Math.min(world.resource(Time).delta, MAX_STEP_S);
|
|
711
|
+
if (p.phase === "tumble") this.tumble(p, dt);
|
|
712
|
+
if (!Number.isNaN(p.landedAt)) this.settleLook(p, now);
|
|
713
|
+
if (!Number.isNaN(p.fadeFrom)) this.fade(p, now);
|
|
714
|
+
if (p.entrances.length > 0) this.entrancesFrame(p, now);
|
|
715
|
+
if (p.runs.length > 0) this.accent(p, now);
|
|
716
|
+
if (p.phase === "accent" && now >= p.accentEndsAt) this.rest(p);
|
|
717
|
+
this.attachmentsFrame(p, now);
|
|
718
|
+
if (p.fps > 0 && !p.reduced && p.entities.length > 0 && !p.done) {
|
|
719
|
+
const demand = world.resource(FrameDemand);
|
|
720
|
+
if (p.fps === Number.POSITIVE_INFINITY) demand.hold(ANIMATED_DEMAND);
|
|
721
|
+
else demand.after(1e3 / p.fps);
|
|
722
|
+
}
|
|
723
|
+
if (p.phase === "rest" && now >= p.restEndsAt) this.finish(p, "finished");
|
|
724
|
+
} catch (error) {
|
|
725
|
+
this.fail(p, error);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
tumble(p, dt) {
|
|
729
|
+
const world = this.world;
|
|
730
|
+
const track = p.track;
|
|
731
|
+
p.time += dt;
|
|
732
|
+
const n = p.physical.length;
|
|
733
|
+
for (let b = 0; b < n; b++) {
|
|
734
|
+
const i = p.physical[b];
|
|
735
|
+
const e = p.entities[i];
|
|
736
|
+
this.samplePose(p, i, p.time, POS, ROT);
|
|
737
|
+
const table = world.entityTableUnchecked(e);
|
|
738
|
+
const row = world.entityRowUnchecked(e);
|
|
739
|
+
const tr = table.column(Transform, "translation");
|
|
740
|
+
const rt = table.column(Transform, "rotation");
|
|
741
|
+
tr[row * 3] = POS[0];
|
|
742
|
+
tr[row * 3 + 1] = POS[1];
|
|
743
|
+
tr[row * 3 + 2] = POS[2];
|
|
744
|
+
rt[row * 4] = ROT[0];
|
|
745
|
+
rt[row * 4 + 1] = ROT[1];
|
|
746
|
+
rt[row * 4 + 2] = ROT[2];
|
|
747
|
+
rt[row * 4 + 3] = ROT[3];
|
|
748
|
+
table.markChanged(Transform, row);
|
|
749
|
+
const blob = p.blobs[i];
|
|
750
|
+
if (blob >= 0) {
|
|
751
|
+
const bt = world.entityTableUnchecked(blob);
|
|
752
|
+
const br = world.entityRowUnchecked(blob);
|
|
753
|
+
const btr = bt.column(Transform, "translation");
|
|
754
|
+
const lean = this.blobLean(p.dice[i], POS[1]);
|
|
755
|
+
btr[br * 3] = POS[0] + this.shadowDir[0] * lean;
|
|
756
|
+
btr[br * 3 + 2] = POS[2] + this.shadowDir[1] * lean;
|
|
757
|
+
bt.markChanged(Transform, br);
|
|
758
|
+
bt.column(InstanceData, "x")[br] = this.blobStrength(p.dice[i], POS[1]);
|
|
759
|
+
bt.markChanged(InstanceData, br);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
this.contacts(p);
|
|
763
|
+
if (p.time >= track.steps * track.step) this.landed(p);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Impact sounds up to the current step, as the track recorded them: at most one per die every
|
|
767
|
+
* 45 ms, and the strongest four in a frame. Allocates nothing without audio.
|
|
768
|
+
*/
|
|
769
|
+
contacts(p) {
|
|
770
|
+
const track = p.track;
|
|
771
|
+
const c = track.contacts;
|
|
772
|
+
const step = Math.floor(p.time / track.step);
|
|
773
|
+
const placedStep = placedFrom(track);
|
|
774
|
+
const audio = this.world.tryResource(AudioState);
|
|
775
|
+
let picked = 0;
|
|
776
|
+
while (p.nextContact < c.steps.length && c.steps[p.nextContact] <= step) {
|
|
777
|
+
const k = p.nextContact++;
|
|
778
|
+
const s = c.steps[k];
|
|
779
|
+
const a = p.physical[c.a[k]];
|
|
780
|
+
const b = c.b[k] >= 0 ? p.physical[c.b[k]] : -1;
|
|
781
|
+
if (s >= placedStep && (p.placed[a] || b >= 0 && p.placed[b])) continue;
|
|
782
|
+
if (!audio || p.soundGain <= 0) continue;
|
|
783
|
+
const t = s * track.step;
|
|
784
|
+
if (t - p.lastImpact[a] < IMPACT_GAP_S) continue;
|
|
785
|
+
if (b >= 0 && t - p.lastImpact[b] < IMPACT_GAP_S) continue;
|
|
786
|
+
const strength = impactStrength(c.force[k]);
|
|
787
|
+
if (strength < 0.03) continue;
|
|
788
|
+
if (picked < IMPACTS_PER_FRAME) {
|
|
789
|
+
PICK[picked] = k;
|
|
790
|
+
POWER[picked++] = strength;
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
let weakest = 0;
|
|
794
|
+
for (let j = 1; j < IMPACTS_PER_FRAME; j++) if (POWER[j] < POWER[weakest]) weakest = j;
|
|
795
|
+
if (strength > POWER[weakest]) {
|
|
796
|
+
PICK[weakest] = k;
|
|
797
|
+
POWER[weakest] = strength;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
for (let j = 0; j < picked; j++) this.impact(p, PICK[j], POWER[j], picked);
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* One impact: a variation of its strength layer, louder faster than strength (light touches stay
|
|
804
|
+
* light), at a pitch and level picked around the contact's (0035's ranges).
|
|
805
|
+
*/
|
|
806
|
+
impact(p, k, strength, together) {
|
|
807
|
+
const track = p.track;
|
|
808
|
+
const c = track.contacts;
|
|
809
|
+
const a = p.physical[c.a[k]];
|
|
810
|
+
const b = c.b[k] >= 0 ? p.physical[c.b[k]] : -1;
|
|
811
|
+
const t = c.steps[k] * track.step;
|
|
812
|
+
p.lastImpact[a] = t;
|
|
813
|
+
if (b >= 0) p.lastImpact[b] = t;
|
|
814
|
+
const tray = b < 0;
|
|
815
|
+
if (this.eventSound(p, p.skins[a].sounds.impactEvent, a, k, strength, tray ? 0 : 1)) return;
|
|
816
|
+
const variation = ((Math.imul(k + 1, 2654435761) ^ p.soundSeed) >>> 0) % IMPACT_VARIATIONS;
|
|
817
|
+
const clip = this.sounds.impact(
|
|
818
|
+
p.skins[a].sounds.impact,
|
|
819
|
+
tray ? "tray" : "dice",
|
|
820
|
+
impactLayer(strength),
|
|
821
|
+
variation
|
|
822
|
+
);
|
|
823
|
+
const level = strength ** 1.5 * (tray ? 0.24 : 0.16) * p.soundGain * (together > 2 ? 0.8 : 1);
|
|
824
|
+
const pitch = tray ? p.pitch[a] : Math.sqrt(p.pitch[a] * p.pitch[b]);
|
|
825
|
+
IMPACT_VOLUME[0] = level * 0.8;
|
|
826
|
+
IMPACT_VOLUME[1] = level;
|
|
827
|
+
IMPACT_PITCH[0] = pitch * 0.94;
|
|
828
|
+
IMPACT_PITCH[1] = pitch * 1.06;
|
|
829
|
+
p.voices.push(playSound(this.world, clip, IMPACT_SOUND));
|
|
830
|
+
}
|
|
831
|
+
/** The tumble ended: results light up, large pools get their shadow, effects or the rest begin. */
|
|
832
|
+
landed(p) {
|
|
833
|
+
const world = this.world;
|
|
834
|
+
const now = this.options.now();
|
|
835
|
+
p.landedAt = now;
|
|
836
|
+
this.resources.setAll(p.entries, "resultTime", world.resource(Time).elapsed);
|
|
837
|
+
if (p.quality.tier === "large-pool" && p.shadowsBefore && world.isAlive(this.light)) {
|
|
838
|
+
const tray = world.resource(Materials).get(this.trayMaterial);
|
|
839
|
+
if (tray) {
|
|
840
|
+
p.trayShadow = tray.value.shadowOpacity;
|
|
841
|
+
tray.set({ shadowOpacity: 0 });
|
|
842
|
+
}
|
|
843
|
+
world.set(this.light, DirectionalLight, { shadows: true });
|
|
844
|
+
}
|
|
845
|
+
const audio = world.tryResource(AudioState);
|
|
846
|
+
if (audio && p.soundGain > 0) {
|
|
847
|
+
const i = p.placed.findIndex(Boolean);
|
|
848
|
+
if (i >= 0 && !this.eventSound(p, p.skins[i].sounds.impactEvent, i, 1e5 + i, 0.5)) {
|
|
849
|
+
const clip = this.sounds.impact(p.skins[i].sounds.impact, "tray", 1, 0);
|
|
850
|
+
const level = 0.14 * p.soundGain;
|
|
851
|
+
p.voices.push(
|
|
852
|
+
playSound(world, clip, {
|
|
853
|
+
bus: DICE_AUDIO_BUS,
|
|
854
|
+
volume: [level * 0.8, level],
|
|
855
|
+
pitch: [p.pitch[i] * 0.9, p.pitch[i] * 0.98]
|
|
856
|
+
})
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (p.entrances.length > 0) {
|
|
861
|
+
p.phase = "entrance";
|
|
862
|
+
world.resource(FrameDemand).hold(ENTRANCE_DEMAND);
|
|
863
|
+
this.nextEntrance(p, now);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
this.afterLanding(p, now);
|
|
867
|
+
}
|
|
868
|
+
/** Every die is down: the accent if anything plays in it, else the rest. */
|
|
869
|
+
afterLanding(p, now) {
|
|
870
|
+
if (p.effects && this.startAccent(p, now)) return;
|
|
871
|
+
this.rest(p);
|
|
872
|
+
}
|
|
873
|
+
/** The result ramp, over 350 ms from landing. */
|
|
874
|
+
settleLook(p, now) {
|
|
875
|
+
const t = Math.min(1, (now - p.landedAt) / RESULT_RAMP_MS);
|
|
876
|
+
this.resources.setAll(p.entries, "result", t);
|
|
877
|
+
if (!Number.isNaN(p.trayShadow)) this.crossfadeShadows(p, t);
|
|
878
|
+
if (t >= 1) {
|
|
879
|
+
p.landedAt = Number.NaN;
|
|
880
|
+
if (p.phase === "rest" && p.live.every((a) => !a.update)) {
|
|
881
|
+
this.world.resource(FrameDemand).release(PRESENTATION_DEMAND);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* A large pool's landing, t from 0 to 1: the tray's shadow in, the opaque dice's blobs out
|
|
887
|
+
* (despawned at 1). Allocates nothing.
|
|
888
|
+
*/
|
|
889
|
+
crossfadeShadows(p, t) {
|
|
890
|
+
const world = this.world;
|
|
891
|
+
world.resource(Materials).get(this.trayMaterial)?.set({ shadowOpacity: p.trayShadow * t });
|
|
892
|
+
for (let i = 0; i < p.blobs.length; i++) {
|
|
893
|
+
const blob = p.blobs[i];
|
|
894
|
+
if (blob < 0 || p.blended[i]) continue;
|
|
895
|
+
if (t >= 1) {
|
|
896
|
+
world.despawn(blob);
|
|
897
|
+
p.blobs[i] = -1;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
const bt = world.entityTableUnchecked(blob);
|
|
901
|
+
const br = world.entityRowUnchecked(blob);
|
|
902
|
+
const y = world.entityTableUnchecked(p.entities[i]).column(Transform, "translation");
|
|
903
|
+
const dy = y[world.entityRowUnchecked(p.entities[i]) * 3 + 1];
|
|
904
|
+
bt.column(InstanceData, "x")[br] = Math.max(1e-3, this.blobStrength(p.dice[i], dy) * (1 - t));
|
|
905
|
+
bt.markChanged(InstanceData, br);
|
|
906
|
+
}
|
|
907
|
+
if (t >= 1) p.trayShadow = Number.NaN;
|
|
908
|
+
}
|
|
909
|
+
/** Reduced motion's fade in, over 150 ms. */
|
|
910
|
+
fade(p, now) {
|
|
911
|
+
const t = Math.min(1, (now - p.fadeFrom) / FADE_MS);
|
|
912
|
+
this.resources.setAll(p.entries, "fade", t);
|
|
913
|
+
if (t >= 1) {
|
|
914
|
+
p.fadeFrom = Number.NaN;
|
|
915
|
+
this.world.resource(FrameDemand).release(PRESENTATION_DEMAND);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
rest(p) {
|
|
919
|
+
p.phase = "rest";
|
|
920
|
+
const wait = p.restMs ?? this.options.restMs;
|
|
921
|
+
p.restEndsAt = this.options.now() + wait;
|
|
922
|
+
const demand = this.world.resource(FrameDemand);
|
|
923
|
+
demand.after(wait);
|
|
924
|
+
if (Number.isNaN(p.landedAt) && Number.isNaN(p.fadeFrom)) demand.release(PRESENTATION_DEMAND);
|
|
925
|
+
}
|
|
926
|
+
// --- effects ------------------------------------------------------------------------------------
|
|
927
|
+
/**
|
|
928
|
+
* The roll's recipes, once each, and which dice carry each one (their skin names it): a
|
|
929
|
+
* recipe's conditions read the whole roll, but its effects play on its own skin's dice.
|
|
930
|
+
*/
|
|
931
|
+
recipesOf(p) {
|
|
932
|
+
const recipes = [];
|
|
933
|
+
const carriers = /* @__PURE__ */ new Map();
|
|
934
|
+
p.skins.forEach((skin, i) => {
|
|
935
|
+
for (const ref of skin.effects) {
|
|
936
|
+
if (!ref) continue;
|
|
937
|
+
const r = this.recipe(ref);
|
|
938
|
+
if (!r) continue;
|
|
939
|
+
let set = carriers.get(r.id);
|
|
940
|
+
if (!set) {
|
|
941
|
+
set = /* @__PURE__ */ new Set();
|
|
942
|
+
carriers.set(r.id, set);
|
|
943
|
+
recipes.push(r);
|
|
944
|
+
}
|
|
945
|
+
set.add(i);
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
return { recipes, carriers };
|
|
949
|
+
}
|
|
950
|
+
centroid(p, anchors) {
|
|
951
|
+
if (anchors.length === 0) return void 0;
|
|
952
|
+
let x = 0;
|
|
953
|
+
let z = 0;
|
|
954
|
+
for (const i of anchors) {
|
|
955
|
+
const t = this.world.get(p.entities[i], Transform).translation;
|
|
956
|
+
x += t[0];
|
|
957
|
+
z += t[2];
|
|
958
|
+
}
|
|
959
|
+
return [x / anchors.length, 0.6, z / anchors.length];
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* The roll's recipes, matched once (the conditions read only the roll), and the entrance dice they
|
|
963
|
+
* choose (0065): at most 2, in the full tier, with effects on and full motion.
|
|
964
|
+
*/
|
|
965
|
+
chooseEntrances(p) {
|
|
966
|
+
const { recipes, carriers } = this.recipesOf(p);
|
|
967
|
+
const { matched, degraded } = matchRecipes(recipes, p.roll, p.dice);
|
|
968
|
+
for (const m of matched) m.anchors = m.anchors.filter((i) => carriers.get(m.recipe.id)?.has(i));
|
|
969
|
+
p.matched = matched;
|
|
970
|
+
p.degraded = degraded;
|
|
971
|
+
const barred = p.reduced ? "reduced motion" : p.quality.tier !== "full" ? `the ${p.quality.tier} tier` : !p.effects ? "effects off" : null;
|
|
972
|
+
const chosen = /* @__PURE__ */ new Set();
|
|
973
|
+
for (const m of matched) {
|
|
974
|
+
for (const effect of m.recipe.effects) {
|
|
975
|
+
if (effect.kind !== "entrance") continue;
|
|
976
|
+
for (const i of m.anchors) {
|
|
977
|
+
if (chosen.has(i)) continue;
|
|
978
|
+
const name = effect.entrance;
|
|
979
|
+
if (barred || p.entrances.length >= MAX_ENTRANCES) {
|
|
980
|
+
p.entranceSkips.push({ die: i, name, reason: barred ?? `over ${MAX_ENTRANCES}` });
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
chosen.add(i);
|
|
984
|
+
const def = findDiceEntrance(name);
|
|
985
|
+
if (!def) this.entranceUnavailable(name, `no entrance "${name}" is defined`);
|
|
986
|
+
p.entrances.push({
|
|
987
|
+
name,
|
|
988
|
+
die: i,
|
|
989
|
+
def,
|
|
990
|
+
params: effect.params ?? {},
|
|
991
|
+
recipe: m,
|
|
992
|
+
rest: { position: [0, 0, 0], rotation: [0, 0, 0, 1] },
|
|
993
|
+
state: "waiting",
|
|
994
|
+
fallback: def ? null : "undefined",
|
|
995
|
+
ready: !def,
|
|
996
|
+
started: Number.NaN,
|
|
997
|
+
landedAt: Number.NaN,
|
|
998
|
+
windDownUntil: Number.POSITIVE_INFINITY,
|
|
999
|
+
skipped: false,
|
|
1000
|
+
entities: [],
|
|
1001
|
+
ctx: void 0,
|
|
1002
|
+
material: void 0
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
p.physical = [];
|
|
1008
|
+
p.bodyOf = new Int32Array(p.dice.length).fill(-1);
|
|
1009
|
+
for (let i = 0; i < p.dice.length; i++) {
|
|
1010
|
+
if (chosen.has(i)) continue;
|
|
1011
|
+
p.bodyOf[i] = p.physical.length;
|
|
1012
|
+
p.physical.push(i);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
/** Budgets for one burst of effects: one light, one cue, 32 particles. */
|
|
1016
|
+
static budget() {
|
|
1017
|
+
return { light: false, cue: false, particles: 0 };
|
|
1018
|
+
}
|
|
1019
|
+
/** Starts a matched recipe's effects on `anchors`. Returns how long the longest runs, ms. */
|
|
1020
|
+
playRecipe(p, m, anchors, now, budget) {
|
|
1021
|
+
if (anchors.length === 0) return 0;
|
|
1022
|
+
let longest = 0;
|
|
1023
|
+
const at = this.centroid(p, anchors);
|
|
1024
|
+
for (const effect of m.recipe.effects) {
|
|
1025
|
+
if (effect.kind === "entrance") continue;
|
|
1026
|
+
if (effect.kind === "attachment") {
|
|
1027
|
+
for (const i of anchors) this.attach(p, effect, i, now);
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
if (effect.kind === "sound-accent") {
|
|
1031
|
+
if (budget.cue || p.cuePlayed) continue;
|
|
1032
|
+
budget.cue = true;
|
|
1033
|
+
p.cuePlayed = true;
|
|
1034
|
+
if (!this.eventSound(p, effect.event, anchors[0], 2e5 + anchors[0], 1, 0, effect.gain))
|
|
1035
|
+
this.accentSound(p, effect.cue, effect.gain);
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
if (!at) continue;
|
|
1039
|
+
if (effect.kind === "light-pulse") {
|
|
1040
|
+
if (budget.light) continue;
|
|
1041
|
+
budget.light = true;
|
|
1042
|
+
}
|
|
1043
|
+
if (effect.kind === "particle-burst") {
|
|
1044
|
+
if (budget.particles >= 32) continue;
|
|
1045
|
+
budget.particles += effect.count;
|
|
1046
|
+
}
|
|
1047
|
+
if (effect.kind === "lens-pulse" && !p.lens) continue;
|
|
1048
|
+
longest = Math.max(longest, effect.durationMs);
|
|
1049
|
+
const run = {
|
|
1050
|
+
kind: effect.kind,
|
|
1051
|
+
effect,
|
|
1052
|
+
at,
|
|
1053
|
+
source: p.entities[anchors[0]],
|
|
1054
|
+
entity: void 0,
|
|
1055
|
+
started: now
|
|
1056
|
+
};
|
|
1057
|
+
this.startRun(p, run);
|
|
1058
|
+
p.runs.push(run);
|
|
1059
|
+
}
|
|
1060
|
+
return longest;
|
|
1061
|
+
}
|
|
1062
|
+
/** Starts the accent phase if anything plays in it. */
|
|
1063
|
+
startAccent(p, now) {
|
|
1064
|
+
let longest = 0;
|
|
1065
|
+
const budget = DiceTableState.budget();
|
|
1066
|
+
for (const m of p.matched) {
|
|
1067
|
+
if (p.playedRecipes.has(m.recipe.id)) continue;
|
|
1068
|
+
longest = Math.max(longest, this.playRecipe(p, m, m.anchors, now, budget));
|
|
1069
|
+
}
|
|
1070
|
+
if (!budget.cue && !p.cuePlayed) {
|
|
1071
|
+
const i = p.dice.findIndex(
|
|
1072
|
+
(d, k) => !d.dropped && (p.skins[k].sounds.resultEvent || p.skins[k].sounds.accent)
|
|
1073
|
+
);
|
|
1074
|
+
if (i >= 0 && !this.eventSound(p, p.skins[i].sounds.resultEvent, i, 3e5 + i))
|
|
1075
|
+
this.accentSound(p, p.skins[i].sounds.accent, 0.6);
|
|
1076
|
+
}
|
|
1077
|
+
if (longest === 0 && p.live.length === 0) return false;
|
|
1078
|
+
p.phase = "accent";
|
|
1079
|
+
p.accentEndsAt = now + longest;
|
|
1080
|
+
return true;
|
|
1081
|
+
}
|
|
1082
|
+
/** An accent cue, a little above the hardest impact at full gain. */
|
|
1083
|
+
accentSound(p, cue, gain) {
|
|
1084
|
+
if (!this.world.tryResource(AudioState) || p.soundGain <= 0) return;
|
|
1085
|
+
const level = ACCENT_LEVEL * gain * p.soundGain;
|
|
1086
|
+
p.voices.push(
|
|
1087
|
+
playSound(this.world, this.sounds.accent(cue), { bus: DICE_AUDIO_BUS, volume: level })
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
startRun(p, run) {
|
|
1091
|
+
const world = this.world;
|
|
1092
|
+
const e = run.effect;
|
|
1093
|
+
if (run.kind === "light-pulse") {
|
|
1094
|
+
run.entity = world.spawn(
|
|
1095
|
+
[
|
|
1096
|
+
PointLight,
|
|
1097
|
+
{
|
|
1098
|
+
color: [e.color[0], e.color[1], e.color[2], 1],
|
|
1099
|
+
intensity: 0,
|
|
1100
|
+
range: 5,
|
|
1101
|
+
shadows: false
|
|
1102
|
+
}
|
|
1103
|
+
],
|
|
1104
|
+
[Transform, { translation: run.at }]
|
|
1105
|
+
);
|
|
1106
|
+
} else if (run.kind === "particle-burst") {
|
|
1107
|
+
const effects = world.tryResource(ParticleEffects);
|
|
1108
|
+
if (!effects) {
|
|
1109
|
+
this.warnOnce(
|
|
1110
|
+
"particles",
|
|
1111
|
+
"dice/feature-missing",
|
|
1112
|
+
"A dice particle burst needs particlesPlugin; it was skipped."
|
|
1113
|
+
);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
const colors = e.colors.length > 0 ? e.colors : [e.color];
|
|
1117
|
+
const seconds = e.durationMs / 1e3;
|
|
1118
|
+
const effect = ParticleEffect.fromJson({
|
|
1119
|
+
emitters: [
|
|
1120
|
+
{
|
|
1121
|
+
name: "burst",
|
|
1122
|
+
capacity: 32,
|
|
1123
|
+
spawn: { rate: 0, bursts: [{ time: 0, count: Math.min(32, e.count) }] },
|
|
1124
|
+
shape: { type: "sphere", radius: 0.15 },
|
|
1125
|
+
init: {
|
|
1126
|
+
lifetime: [seconds * 0.6, seconds],
|
|
1127
|
+
speed: [0.8, 1.9],
|
|
1128
|
+
size: [0.03, 0.07],
|
|
1129
|
+
color: colors[0]
|
|
1130
|
+
},
|
|
1131
|
+
update: [
|
|
1132
|
+
{ module: "gravity", acceleration: [0, -0.9, 0] },
|
|
1133
|
+
{ module: "drag", coefficient: 0.6 },
|
|
1134
|
+
{
|
|
1135
|
+
module: "color-over-life",
|
|
1136
|
+
gradient: colors.map((c, k) => [
|
|
1137
|
+
colors.length === 1 ? 0 : k / (colors.length - 1),
|
|
1138
|
+
c,
|
|
1139
|
+
1 - k / colors.length * 0.5
|
|
1140
|
+
]).concat([[1, colors[colors.length - 1], 0]])
|
|
1141
|
+
}
|
|
1142
|
+
],
|
|
1143
|
+
render: { blend: "additive", emissive: 3e4 }
|
|
1144
|
+
}
|
|
1145
|
+
]
|
|
1146
|
+
});
|
|
1147
|
+
run.entity = world.spawn(
|
|
1148
|
+
[
|
|
1149
|
+
ParticleSystem,
|
|
1150
|
+
{
|
|
1151
|
+
effect: effects.add(effect),
|
|
1152
|
+
seed: hashString(`${p.roll.id}:burst`),
|
|
1153
|
+
backend: "cpu"
|
|
1154
|
+
}
|
|
1155
|
+
],
|
|
1156
|
+
[Transform, { translation: run.at }]
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
accent(p, now) {
|
|
1161
|
+
const world = this.world;
|
|
1162
|
+
for (const run of p.runs) {
|
|
1163
|
+
const t = Math.min(1, (now - run.started) / Math.max(1, run.effect.durationMs));
|
|
1164
|
+
const envelope = Math.sin(Math.PI * Math.max(0, Math.min(1, t)));
|
|
1165
|
+
if (run.kind === "light-pulse" && run.entity !== void 0 && world.isAlive(run.entity)) {
|
|
1166
|
+
world.set(run.entity, PointLight, { intensity: envelope * run.effect.intensity * 180 });
|
|
1167
|
+
} else if (run.kind === "lens-pulse" && p.lens && t < 1) {
|
|
1168
|
+
if (screenOf(world, this.camera, run.at[0], 0.4, run.at[2], SCREEN)) {
|
|
1169
|
+
publishLensField(world, {
|
|
1170
|
+
screen: [SCREEN[0], SCREEN[1]],
|
|
1171
|
+
radius: run.effect.radius,
|
|
1172
|
+
strength: run.effect.strength * envelope,
|
|
1173
|
+
ttlMs: 120,
|
|
1174
|
+
source: run.source
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
// --- attachments ------------------------------------------------------------------------------
|
|
1181
|
+
attach(p, effect, die, now) {
|
|
1182
|
+
if (!p.attachments) return;
|
|
1183
|
+
const d = p.dice[die];
|
|
1184
|
+
if (d.dropped) return;
|
|
1185
|
+
if (p.live.length >= MAX_ATTACHMENTS) return;
|
|
1186
|
+
const name = effect.attachment;
|
|
1187
|
+
const def = findDiceAttachment(name);
|
|
1188
|
+
if (!def) {
|
|
1189
|
+
this.warnOnce(
|
|
1190
|
+
`attachment:${name}`,
|
|
1191
|
+
"dice/unknown-attachment",
|
|
1192
|
+
`No dice attachment "${name}"; a recipe names it.`
|
|
1193
|
+
);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
const world = this.world;
|
|
1197
|
+
const params = effect.params ?? {};
|
|
1198
|
+
const ctx = this.sceneContext(p, die, params);
|
|
1199
|
+
const entities = def.spawn(ctx);
|
|
1200
|
+
if (meshVertices(world, entities) > def.vertices) {
|
|
1201
|
+
for (const e of entities) if (world.isAlive(e)) world.despawn(e);
|
|
1202
|
+
this.warnOnce(
|
|
1203
|
+
`attachment-budget:${name}`,
|
|
1204
|
+
"dice/attachment-budget",
|
|
1205
|
+
`Attachment "${name}" spawned more than its ${def.vertices} vertices; it was ended.`
|
|
1206
|
+
);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
p.live.push({ name, die, entities, started: now, ctx, update: def.update });
|
|
1210
|
+
if (def.update) world.resource(FrameDemand).hold(ATTACHMENT_DEMAND);
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* What a scene on a die can reach and do (attachments, and entrances, 0065): the die, the camera,
|
|
1214
|
+
* its screen point, lens fields and screen effects on it, sound at the roll's gain.
|
|
1215
|
+
*/
|
|
1216
|
+
sceneContext(p, die, params) {
|
|
1217
|
+
const world = this.world;
|
|
1218
|
+
const entity = p.entities[die];
|
|
1219
|
+
const d = p.dice[die];
|
|
1220
|
+
const where = (at) => at ?? world.get(entity, Transform).translation;
|
|
1221
|
+
return {
|
|
1222
|
+
world,
|
|
1223
|
+
die: entity,
|
|
1224
|
+
kind: d.kind,
|
|
1225
|
+
value: d.rolled,
|
|
1226
|
+
label: d.label,
|
|
1227
|
+
scale: d.scale,
|
|
1228
|
+
params,
|
|
1229
|
+
camera: this.camera,
|
|
1230
|
+
seed: hashString(`${p.roll.seed ?? p.roll.id}:scene:${die}`),
|
|
1231
|
+
soundGain: p.soundGain,
|
|
1232
|
+
screen: (point) => {
|
|
1233
|
+
const t = where(point);
|
|
1234
|
+
return screenOf(world, this.camera, t[0], t[1], t[2], SCREEN) ? [SCREEN[0], SCREEN[1]] : null;
|
|
1235
|
+
},
|
|
1236
|
+
lens: (field) => {
|
|
1237
|
+
if (!p.lens || p.done) return false;
|
|
1238
|
+
const t = world.get(entity, Transform).translation;
|
|
1239
|
+
if (!screenOf(world, this.camera, t[0], t[1], t[2], SCREEN)) return false;
|
|
1240
|
+
return publishLensField(world, {
|
|
1241
|
+
screen: [SCREEN[0], SCREEN[1]],
|
|
1242
|
+
radius: field.radius,
|
|
1243
|
+
strength: field.strength,
|
|
1244
|
+
ttlMs: field.ttlMs ?? 250,
|
|
1245
|
+
source: entity
|
|
1246
|
+
});
|
|
1247
|
+
},
|
|
1248
|
+
effect: (e) => {
|
|
1249
|
+
if (!p.lens || p.done) return false;
|
|
1250
|
+
const t = where(e.at);
|
|
1251
|
+
if (!screenOf(world, this.camera, t[0], t[1], t[2], SCREEN)) return false;
|
|
1252
|
+
return publishScreenEffect(world, {
|
|
1253
|
+
kind: e.kind,
|
|
1254
|
+
screen: [SCREEN[0], SCREEN[1]],
|
|
1255
|
+
radius: e.radius,
|
|
1256
|
+
ttlMs: e.ttlMs ?? 250,
|
|
1257
|
+
source: entity,
|
|
1258
|
+
params: e.params ?? {}
|
|
1259
|
+
});
|
|
1260
|
+
},
|
|
1261
|
+
sound: (clip, options = {}) => {
|
|
1262
|
+
if (p.done || p.soundGain <= 0 || !world.tryResource(AudioState)) return -1;
|
|
1263
|
+
const g = p.soundGain;
|
|
1264
|
+
const v = options.volume ?? 1;
|
|
1265
|
+
const volume = typeof v === "number" ? v * g : [v[0] * g, v[1] * g];
|
|
1266
|
+
const voice = playSound(world, clip, { bus: DICE_AUDIO_BUS, ...options, volume });
|
|
1267
|
+
p.voices.push(voice);
|
|
1268
|
+
return voice;
|
|
1269
|
+
},
|
|
1270
|
+
cue: (cue, gain = 1) => {
|
|
1271
|
+
if (!p.done) this.accentSound(p, cue, gain);
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
attachmentsFrame(p, now) {
|
|
1276
|
+
if (p.live.length === 0) return;
|
|
1277
|
+
const world = this.world;
|
|
1278
|
+
for (let k = p.live.length - 1; k >= 0; k--) {
|
|
1279
|
+
const a = p.live[k];
|
|
1280
|
+
if (!a.update) continue;
|
|
1281
|
+
if (a.update(a.ctx, (now - a.started) / 1e3, a.entities) === false) {
|
|
1282
|
+
for (const e of a.entities) if (world.isAlive(e)) world.despawn(e);
|
|
1283
|
+
clearLensFields(world, p.entities[a.die]);
|
|
1284
|
+
p.live.splice(k, 1);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
if (!p.live.some((a) => a.update)) world.resource(FrameDemand).release(ATTACHMENT_DEMAND);
|
|
1288
|
+
}
|
|
1289
|
+
// --- entrances (0065) ---------------------------------------------------------------------------
|
|
1290
|
+
/**
|
|
1291
|
+
* Lands every entrance still to come or playing, now: each die snaps to its rest pose, and scenes
|
|
1292
|
+
* get 400 ms to wind down (`ctx.skipped`). False when no entrance is left to skip.
|
|
1293
|
+
*/
|
|
1294
|
+
skip() {
|
|
1295
|
+
const p = this.current;
|
|
1296
|
+
if (!p || p.done) return false;
|
|
1297
|
+
const now = this.options.now();
|
|
1298
|
+
const left = p.entrances.filter((r) => r.state === "waiting" || r.state === "playing");
|
|
1299
|
+
if (left.length === 0) return false;
|
|
1300
|
+
for (const run of left) {
|
|
1301
|
+
run.skipped = true;
|
|
1302
|
+
run.windDownUntil = now + SKIP_WIND_DOWN_MS;
|
|
1303
|
+
}
|
|
1304
|
+
for (const run of left) {
|
|
1305
|
+
if (run.state === "waiting") {
|
|
1306
|
+
run.state = "playing";
|
|
1307
|
+
run.started = now;
|
|
1308
|
+
}
|
|
1309
|
+
this.landEntrance(p, run, now);
|
|
1310
|
+
}
|
|
1311
|
+
return true;
|
|
1312
|
+
}
|
|
1313
|
+
entranceUnavailable(name, why) {
|
|
1314
|
+
this.warnOnce(
|
|
1315
|
+
`entrance:${name}`,
|
|
1316
|
+
"dice/entrance-unavailable",
|
|
1317
|
+
`Entrance "${name}" can't play (${why}); its die drops into its spot instead.`
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
/** While the physics records: the entrance's assets, then its scene drawn once. */
|
|
1321
|
+
async readyEntrance(p, run) {
|
|
1322
|
+
const def = run.def;
|
|
1323
|
+
if (!def) return;
|
|
1324
|
+
try {
|
|
1325
|
+
const assets = def.assets ?? [];
|
|
1326
|
+
if (assets.length > 0) await Promise.all(assets.map((r) => assetServer(this.world).load(r)));
|
|
1327
|
+
if (p.done) return;
|
|
1328
|
+
await this.warmEntrance(p, run, def);
|
|
1329
|
+
} catch (err) {
|
|
1330
|
+
if (p.done) return;
|
|
1331
|
+
run.fallback ??= "assets failed";
|
|
1332
|
+
this.entranceUnavailable(run.name, `its assets failed: ${err.message}`);
|
|
1333
|
+
}
|
|
1334
|
+
run.ready = true;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Draws an entrance's scene once, away from everything, to an offscreen target on the app's
|
|
1338
|
+
* device (as thumbnails are), so its uploads are done and its pipelines compiled before it plays.
|
|
1339
|
+
* The warm copy's lens fields, screen effects and sounds go nowhere.
|
|
1340
|
+
*/
|
|
1341
|
+
async warmEntrance(p, run, def) {
|
|
1342
|
+
const world = this.world;
|
|
1343
|
+
const d = p.dice[run.die];
|
|
1344
|
+
const entry = p.entries[run.die];
|
|
1345
|
+
const g = dieGeometry(requireDie(d.definition));
|
|
1346
|
+
const rotation = restRotation(g, d.value);
|
|
1347
|
+
const x = 3e4 + warmSlot++ % 16 * 50;
|
|
1348
|
+
const rest = {
|
|
1349
|
+
position: [x, restingHeight(g, rotation, d.scale), 0],
|
|
1350
|
+
rotation: [rotation[0], rotation[1], rotation[2], rotation[3]]
|
|
1351
|
+
};
|
|
1352
|
+
const target = new OffscreenTarget(world.resource(Gpu), {
|
|
1353
|
+
label: "dice-entrance-warm",
|
|
1354
|
+
width: 64,
|
|
1355
|
+
height: 64
|
|
1356
|
+
});
|
|
1357
|
+
const targetRef = world.resource(RenderTargets).add(target, `dice:warm/${x}`);
|
|
1358
|
+
const material = this.resources.material(entry, {
|
|
1359
|
+
blended: p.blended[run.die],
|
|
1360
|
+
dropped: false,
|
|
1361
|
+
entrance: run.die
|
|
1362
|
+
});
|
|
1363
|
+
const s = d.scale;
|
|
1364
|
+
const die = world.spawn(
|
|
1365
|
+
[Mesh3d, { mesh: entry.mesh }],
|
|
1366
|
+
[MeshMaterial, { material }],
|
|
1367
|
+
[Transform, { translation: rest.position, rotation: rest.rotation, scale: [s, s, s] }],
|
|
1368
|
+
[InstanceData, { x: d.value, y: 0 }]
|
|
1369
|
+
);
|
|
1370
|
+
const eye = [x, rest.position[1] + 30 * s, 0];
|
|
1371
|
+
const camera = world.spawn(
|
|
1372
|
+
[Camera3d, { fovY: 35, clearColor: [0, 0, 0, 0], target: targetRef, order: 49 }],
|
|
1373
|
+
[Exposure, { ev100: 13 }],
|
|
1374
|
+
[Transform, { translation: eye, rotation: lookAt(eye, rest.position, [0, 0, -1]) }]
|
|
1375
|
+
);
|
|
1376
|
+
const env = world.tryGet(this.camera, EnvironmentMap);
|
|
1377
|
+
if (env) world.add(camera, EnvironmentMap, { texture: env.texture, intensity: env.intensity });
|
|
1378
|
+
let entities = [];
|
|
1379
|
+
try {
|
|
1380
|
+
const ctx = this.entranceContext(p, run, die, camera, rest, false);
|
|
1381
|
+
entities = def.spawn(ctx);
|
|
1382
|
+
if (meshVertices(world, entities) > def.vertices) {
|
|
1383
|
+
run.fallback = "over budget";
|
|
1384
|
+
this.warnOnce(
|
|
1385
|
+
`entrance-budget:${run.name}`,
|
|
1386
|
+
"dice/entrance-budget",
|
|
1387
|
+
`Entrance "${run.name}" spawned more than its ${def.vertices} vertices; its die drops in instead.`
|
|
1388
|
+
);
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
def.update?.(ctx, 0, entities);
|
|
1392
|
+
const view = `camera:${camera}`;
|
|
1393
|
+
const pipelines = world.resource(Gpu).pipelines;
|
|
1394
|
+
let complete = 0;
|
|
1395
|
+
for (let i = 0; i < 240 && !p.done && complete < 3; i++) {
|
|
1396
|
+
await this.nextFrame();
|
|
1397
|
+
const stats = world.resource(RenderStats).get(view);
|
|
1398
|
+
const done = stats && stats.drawCalls > 0 && stats.pending === 0 && pipelines.pending === 0 && pipelines.skipped === 0;
|
|
1399
|
+
complete = done ? complete + 1 : 0;
|
|
1400
|
+
if (!done) await pipelines.whenIdle();
|
|
1401
|
+
}
|
|
1402
|
+
} finally {
|
|
1403
|
+
for (const e of entities) if (world.isAlive(e)) world.despawn(e);
|
|
1404
|
+
world.despawn(die);
|
|
1405
|
+
world.despawn(camera);
|
|
1406
|
+
world.resource(RenderTargets).delete(targetRef.guid);
|
|
1407
|
+
target.destroy();
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Resolves at the table's next frame (an idle app is woken for it); code after the `await` runs
|
|
1412
|
+
* once that frame has rendered, with its stats in.
|
|
1413
|
+
*/
|
|
1414
|
+
nextFrame() {
|
|
1415
|
+
const next = new Promise((resolve) => this.frameWaiters.push(resolve));
|
|
1416
|
+
this.world.wake();
|
|
1417
|
+
return next;
|
|
1418
|
+
}
|
|
1419
|
+
/** An entrance's context: live on its die, or (warming up) on a stand-in, going nowhere. */
|
|
1420
|
+
entranceContext(p, run, die, camera, rest, live) {
|
|
1421
|
+
const world = this.world;
|
|
1422
|
+
const base = this.sceneContext(p, run.die, run.params);
|
|
1423
|
+
const s = p.dice[run.die].scale;
|
|
1424
|
+
return {
|
|
1425
|
+
...base,
|
|
1426
|
+
die,
|
|
1427
|
+
camera,
|
|
1428
|
+
screen: live ? base.screen : () => null,
|
|
1429
|
+
lens: live ? base.lens : () => false,
|
|
1430
|
+
effect: live ? base.effect : () => false,
|
|
1431
|
+
sound: live ? base.sound : () => -1,
|
|
1432
|
+
cue: live ? base.cue : () => {
|
|
1433
|
+
},
|
|
1434
|
+
rest,
|
|
1435
|
+
landAt: (run.def?.landAtMs ?? 0) / 1e3,
|
|
1436
|
+
get skipped() {
|
|
1437
|
+
return run.skipped;
|
|
1438
|
+
},
|
|
1439
|
+
pose: (position, rotation, scale) => {
|
|
1440
|
+
if (live && run.state !== "playing") return;
|
|
1441
|
+
const table = world.entityTableUnchecked(die);
|
|
1442
|
+
const row = world.entityRowUnchecked(die);
|
|
1443
|
+
const tr = table.column(Transform, "translation");
|
|
1444
|
+
const rt = table.column(Transform, "rotation");
|
|
1445
|
+
const sc = table.column(Transform, "scale");
|
|
1446
|
+
for (let k = 0; k < 3; k++) tr[row * 3 + k] = position[k];
|
|
1447
|
+
for (let k = 0; k < 4; k++) rt[row * 4 + k] = rotation[k];
|
|
1448
|
+
const z = scale ?? s;
|
|
1449
|
+
for (let k = 0; k < 3; k++) sc[row * 3 + k] = z;
|
|
1450
|
+
table.markChanged(Transform, row);
|
|
1451
|
+
},
|
|
1452
|
+
show: (visible) => {
|
|
1453
|
+
if (live && run.state !== "playing") return;
|
|
1454
|
+
world.set(die, Visibility, { mode: visible ? "visible" : "hidden" });
|
|
1455
|
+
},
|
|
1456
|
+
material: (fields) => {
|
|
1457
|
+
for (const key of Object.keys(fields)) {
|
|
1458
|
+
if (key in DICE_FIELDS || key === "alphaMode") {
|
|
1459
|
+
throw new ShardError(
|
|
1460
|
+
"dice/reserved-param",
|
|
1461
|
+
`An entrance can't set the dice field "${key}"`,
|
|
1462
|
+
{
|
|
1463
|
+
path: key,
|
|
1464
|
+
hint: "Set the family's own fields; the table sets the dice fields."
|
|
1465
|
+
}
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
if (run.material) world.resource(Materials).get(run.material)?.set(fields);
|
|
1470
|
+
}
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
/** Starts the next entrance whose turn it is; with every die down, the accent (or the rest). */
|
|
1474
|
+
nextEntrance(p, now) {
|
|
1475
|
+
for (const run of p.entrances) {
|
|
1476
|
+
if (run.state === "playing") return;
|
|
1477
|
+
if (run.state === "waiting") {
|
|
1478
|
+
this.startEntrance(p, run, now);
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
if (p.phase === "entrance") this.afterLanding(p, now);
|
|
1483
|
+
}
|
|
1484
|
+
startEntrance(p, run, now) {
|
|
1485
|
+
if (!run.ready && !run.skipped) {
|
|
1486
|
+
const wait = this.options.entranceWaitMs;
|
|
1487
|
+
if (now < p.readyAt + wait) return;
|
|
1488
|
+
run.fallback ??= "assets late";
|
|
1489
|
+
this.entranceUnavailable(
|
|
1490
|
+
run.name,
|
|
1491
|
+
`it wasn't ready ${this.options.entranceWaitMs} ms after the physics`
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
run.state = "playing";
|
|
1495
|
+
run.started = now;
|
|
1496
|
+
const def = run.def;
|
|
1497
|
+
if (run.skipped || run.fallback || !def) {
|
|
1498
|
+
if (!run.skipped) this.world.set(p.entities[run.die], Visibility, { mode: "visible" });
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
const world = this.world;
|
|
1502
|
+
run.ctx = this.entranceContext(p, run, p.entities[run.die], this.camera, run.rest, true);
|
|
1503
|
+
run.entities = def.spawn(run.ctx);
|
|
1504
|
+
if (meshVertices(world, run.entities) > def.vertices) {
|
|
1505
|
+
for (const e of run.entities) if (world.isAlive(e)) world.despawn(e);
|
|
1506
|
+
run.entities = [];
|
|
1507
|
+
run.ctx = void 0;
|
|
1508
|
+
run.fallback = "over budget";
|
|
1509
|
+
this.warnOnce(
|
|
1510
|
+
`entrance-budget:${run.name}`,
|
|
1511
|
+
"dice/entrance-budget",
|
|
1512
|
+
`Entrance "${run.name}" spawned more than its ${def.vertices} vertices; its die drops in instead.`
|
|
1513
|
+
);
|
|
1514
|
+
world.set(p.entities[run.die], Visibility, { mode: "visible" });
|
|
1515
|
+
} else {
|
|
1516
|
+
this.eventSound(p, p.skins[run.die].sounds.entranceEvent, run.die, 4e5 + run.die);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
/** Each frame while an entrance is alive: scenes, the die's landing, its result ramp. */
|
|
1520
|
+
entrancesFrame(p, now) {
|
|
1521
|
+
const world = this.world;
|
|
1522
|
+
if (p.phase === "entrance" && !p.entrances.some((r) => r.state === "playing"))
|
|
1523
|
+
this.nextEntrance(p, now);
|
|
1524
|
+
for (const run of p.entrances) {
|
|
1525
|
+
if (run.state === "done" || run.state === "waiting") continue;
|
|
1526
|
+
const ms = now - run.started;
|
|
1527
|
+
const def = run.def;
|
|
1528
|
+
const scene = run.ctx !== void 0 && def !== void 0;
|
|
1529
|
+
if (run.state === "playing") {
|
|
1530
|
+
if (!scene) {
|
|
1531
|
+
const t = run.skipped ? 1 : Math.min(1, ms / ENTRANCE_DROP_MS);
|
|
1532
|
+
this.dropPose(p, run, t);
|
|
1533
|
+
if (t >= 1) this.landEntrance(p, run, now);
|
|
1534
|
+
} else if (run.skipped || ms >= def.landAtMs) this.landEntrance(p, run, now);
|
|
1535
|
+
}
|
|
1536
|
+
let over = true;
|
|
1537
|
+
if (scene) {
|
|
1538
|
+
const keep = def.update ? def.update(run.ctx, ms / 1e3, run.entities) !== false : true;
|
|
1539
|
+
over = !keep || ms >= def.durationMs || now >= run.windDownUntil;
|
|
1540
|
+
if (over && run.state === "playing") this.landEntrance(p, run, now);
|
|
1541
|
+
}
|
|
1542
|
+
if (run.state === "landed") {
|
|
1543
|
+
const t = Math.min(1, (now - run.landedAt) / RESULT_RAMP_MS);
|
|
1544
|
+
if (run.material) world.resource(Materials).get(run.material)?.set({ result: t });
|
|
1545
|
+
if (over && t >= 1) this.endEntrance(run);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
let alive = false;
|
|
1549
|
+
for (const run of p.entrances) if (run.state !== "done") alive = true;
|
|
1550
|
+
if (!alive) world.resource(FrameDemand).release(ENTRANCE_DEMAND);
|
|
1551
|
+
}
|
|
1552
|
+
/** The placed drop, t from 0 to 1: from above its spot down onto it, easing in. */
|
|
1553
|
+
dropPose(p, run, t) {
|
|
1554
|
+
const world = this.world;
|
|
1555
|
+
const e = p.entities[run.die];
|
|
1556
|
+
const table = world.entityTableUnchecked(e);
|
|
1557
|
+
const row = world.entityRowUnchecked(e);
|
|
1558
|
+
const tr = table.column(Transform, "translation");
|
|
1559
|
+
const rt = table.column(Transform, "rotation");
|
|
1560
|
+
const fall = (1 - t * t) * 2.2 * p.dice[run.die].scale;
|
|
1561
|
+
tr[row * 3] = run.rest.position[0];
|
|
1562
|
+
tr[row * 3 + 1] = run.rest.position[1] + fall;
|
|
1563
|
+
tr[row * 3 + 2] = run.rest.position[2];
|
|
1564
|
+
for (let k = 0; k < 4; k++) rt[row * 4 + k] = run.rest.rotation[k];
|
|
1565
|
+
table.markChanged(Transform, row);
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* The die lands: its rest pose, shown, its result from now (its own material), its impact, and
|
|
1569
|
+
* the effects of the recipe that chose it, on it. Then the next entrance's turn.
|
|
1570
|
+
*/
|
|
1571
|
+
landEntrance(p, run, now) {
|
|
1572
|
+
if (run.state !== "playing") return;
|
|
1573
|
+
const world = this.world;
|
|
1574
|
+
run.state = "landed";
|
|
1575
|
+
run.landedAt = now;
|
|
1576
|
+
const e = p.entities[run.die];
|
|
1577
|
+
const s = p.dice[run.die].scale;
|
|
1578
|
+
world.set(e, Transform, {
|
|
1579
|
+
translation: run.rest.position,
|
|
1580
|
+
rotation: run.rest.rotation,
|
|
1581
|
+
scale: [s, s, s]
|
|
1582
|
+
});
|
|
1583
|
+
world.set(e, Visibility, { mode: "visible" });
|
|
1584
|
+
if (run.material) {
|
|
1585
|
+
world.resource(Materials).get(run.material)?.set({ result: 0, resultTime: world.resource(Time).elapsed });
|
|
1586
|
+
}
|
|
1587
|
+
this.entranceImpact(p, run);
|
|
1588
|
+
p.playedRecipes.add(run.recipe.recipe.id);
|
|
1589
|
+
if (p.effects) this.playRecipe(p, run.recipe, [run.die], now, DiceTableState.budget());
|
|
1590
|
+
if (p.phase === "entrance") this.nextEntrance(p, now);
|
|
1591
|
+
}
|
|
1592
|
+
/** The skin's impact as the die lands, as hard as the entrance says (default 0.8). */
|
|
1593
|
+
entranceImpact(p, run) {
|
|
1594
|
+
const strength = run.def?.impact ?? 0.8;
|
|
1595
|
+
if (strength <= 0 || p.soundGain <= 0 || !this.world.tryResource(AudioState)) return;
|
|
1596
|
+
if (this.eventSound(p, p.skins[run.die].sounds.impactEvent, run.die, 5e5 + run.die, strength))
|
|
1597
|
+
return;
|
|
1598
|
+
const variation = ((p.soundSeed ^ Math.imul(run.die + 7, 2654435761)) >>> 0) % IMPACT_VARIATIONS;
|
|
1599
|
+
const clip = this.sounds.impact(
|
|
1600
|
+
p.skins[run.die].sounds.impact,
|
|
1601
|
+
"tray",
|
|
1602
|
+
impactLayer(strength),
|
|
1603
|
+
variation
|
|
1604
|
+
);
|
|
1605
|
+
const level = strength ** 1.5 * 0.24 * p.soundGain;
|
|
1606
|
+
const pitch = p.pitch[run.die] ?? 1;
|
|
1607
|
+
p.voices.push(
|
|
1608
|
+
playSound(this.world, clip, {
|
|
1609
|
+
bus: DICE_AUDIO_BUS,
|
|
1610
|
+
volume: [level * 0.8, level],
|
|
1611
|
+
pitch: [pitch * 0.94, pitch * 1.06]
|
|
1612
|
+
})
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
endEntrance(run) {
|
|
1616
|
+
run.state = "done";
|
|
1617
|
+
for (const e of run.entities) if (this.world.isAlive(e)) this.world.despawn(e);
|
|
1618
|
+
run.entities = [];
|
|
1619
|
+
}
|
|
1620
|
+
// --- the end --------------------------------------------------------------------------------------
|
|
1621
|
+
fail(p, err) {
|
|
1622
|
+
this.lastError = err instanceof ShardError ? err : new ShardError("dice/failed", err instanceof Error ? err.message : String(err), {
|
|
1623
|
+
cause: err
|
|
1624
|
+
});
|
|
1625
|
+
this.world.tryResource(LogResource)?.error(this.lastError);
|
|
1626
|
+
this.finish(p, "failed");
|
|
1627
|
+
}
|
|
1628
|
+
/** Ends a presentation: despawns what it spawned, lets go of frames, sounds and resources. */
|
|
1629
|
+
finish(p, outcome) {
|
|
1630
|
+
if (p.done) return;
|
|
1631
|
+
p.done = true;
|
|
1632
|
+
p.unlisten?.();
|
|
1633
|
+
p.abort.abort();
|
|
1634
|
+
p.audio.dispose();
|
|
1635
|
+
const world = this.world;
|
|
1636
|
+
const now = this.options.now();
|
|
1637
|
+
{
|
|
1638
|
+
for (const a of p.live) for (const e of a.entities) if (world.isAlive(e)) world.despawn(e);
|
|
1639
|
+
for (const run of p.entrances)
|
|
1640
|
+
for (const e of run.entities) if (world.isAlive(e)) world.despawn(e);
|
|
1641
|
+
for (const run of p.runs)
|
|
1642
|
+
if (run.entity !== void 0 && world.isAlive(run.entity)) world.despawn(run.entity);
|
|
1643
|
+
if (world.hasResource(LensFields)) for (const e of p.entities) clearLensFields(world, e);
|
|
1644
|
+
if (world.hasResource(ScreenEffects)) for (const e of p.entities) clearScreenEffects(world, e);
|
|
1645
|
+
for (const e of p.entities) if (world.isAlive(e)) world.despawn(e);
|
|
1646
|
+
for (const e of p.blobs) if (e >= 0 && world.isAlive(e)) world.despawn(e);
|
|
1647
|
+
if (p.shadowsBefore && world.isAlive(this.light)) {
|
|
1648
|
+
world.set(this.light, DirectionalLight, { shadows: true });
|
|
1649
|
+
}
|
|
1650
|
+
if (!Number.isNaN(p.trayShadow)) {
|
|
1651
|
+
world.resource(Materials).get(this.trayMaterial)?.set({ shadowOpacity: p.trayShadow });
|
|
1652
|
+
}
|
|
1653
|
+
if (world.tryResource(AudioState)) for (const v of p.voices) stopSound(world, v);
|
|
1654
|
+
const demand = world.resource(FrameDemand);
|
|
1655
|
+
demand.release(PRESENTATION_DEMAND);
|
|
1656
|
+
demand.release(ATTACHMENT_DEMAND);
|
|
1657
|
+
demand.release(ANIMATED_DEMAND);
|
|
1658
|
+
demand.release(ENTRANCE_DEMAND);
|
|
1659
|
+
}
|
|
1660
|
+
for (const entry of p.entries) this.resources.release(entry, now);
|
|
1661
|
+
if (p.entries.length > 0) world.resource(FrameDemand).after(RELEASE_AFTER_MS + 16);
|
|
1662
|
+
p.live.length = 0;
|
|
1663
|
+
p.runs.length = 0;
|
|
1664
|
+
p.phase = "idle";
|
|
1665
|
+
if (this.current === p) this.current = void 0;
|
|
1666
|
+
p.resolve(outcome);
|
|
1667
|
+
}
|
|
1668
|
+
warnOnce(key, code, message) {
|
|
1669
|
+
if (this.warned.has(key)) return;
|
|
1670
|
+
this.warned.add(key);
|
|
1671
|
+
this.world.tryResource(LogResource)?.log("warn", message, { code });
|
|
1672
|
+
}
|
|
1673
|
+
// --- the camera -------------------------------------------------------------------------------
|
|
1674
|
+
/** The dice view's aspect: its camera's target, or the window. */
|
|
1675
|
+
aspect() {
|
|
1676
|
+
const cam = this.world.resource(Cameras).get(this.camera);
|
|
1677
|
+
if (cam && cam.displayHeight > 0) return cam.displayWidth / cam.displayHeight;
|
|
1678
|
+
const window = this.world.tryResource(Window);
|
|
1679
|
+
return window && window.height > 0 ? window.width / window.height : 16 / 9;
|
|
1680
|
+
}
|
|
1681
|
+
/** The tray the camera frames: the roll's, or the viewport's while idle. */
|
|
1682
|
+
fitCamera() {
|
|
1683
|
+
const world = this.world;
|
|
1684
|
+
if (!world.isAlive(this.camera)) return;
|
|
1685
|
+
const aspect = this.aspect();
|
|
1686
|
+
const f = this.fitted;
|
|
1687
|
+
const tray = this.current?.tray;
|
|
1688
|
+
if (Math.abs(f.aspect - aspect) < 1e-4 && (tray ? f.halfWidth === tray.halfWidth && f.halfDepth === tray.halfDepth : f.halfWidth === 0))
|
|
1689
|
+
return;
|
|
1690
|
+
const fit = tray ?? viewportTray(aspect);
|
|
1691
|
+
f.aspect = aspect;
|
|
1692
|
+
f.halfWidth = tray ? tray.halfWidth : 0;
|
|
1693
|
+
f.halfDepth = tray ? tray.halfDepth : 0;
|
|
1694
|
+
const cam = world.get(this.camera, Transform);
|
|
1695
|
+
const fov = this.fovY() * Math.PI / 180 / 2;
|
|
1696
|
+
const tan = Math.tan(fov);
|
|
1697
|
+
const h = Math.max(fit.halfDepth / tan, fit.halfWidth / (tan * aspect)) * 1.12 + 1.2;
|
|
1698
|
+
world.set(this.camera, Transform, {
|
|
1699
|
+
translation: [cam.translation[0], h, cam.translation[2]]
|
|
1700
|
+
});
|
|
1701
|
+
if (world.isAlive(this.floor)) {
|
|
1702
|
+
world.set(this.floor, Transform, { scale: [fit.halfWidth * 1.4, 1, fit.halfDepth * 1.4] });
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
fovY() {
|
|
1706
|
+
return this.options.fovY;
|
|
1707
|
+
}
|
|
1708
|
+
/** The GPU device was lost: a playing roll fails (it would show nothing), fields cleared. */
|
|
1709
|
+
deviceLost(message) {
|
|
1710
|
+
if (!this.current) return;
|
|
1711
|
+
this.fail(
|
|
1712
|
+
this.current,
|
|
1713
|
+
new ShardError("dice/device-lost", `The GPU device was lost mid-roll: ${message}`, {
|
|
1714
|
+
hint: "The renderer recreates the device; roll again."
|
|
1715
|
+
})
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
// --- teardown and inspection --------------------------------------------------------------------
|
|
1719
|
+
dispose() {
|
|
1720
|
+
if (this.disposed) return;
|
|
1721
|
+
if (this.current) this.finish(this.current, "dismissed");
|
|
1722
|
+
this.disposed = true;
|
|
1723
|
+
for (const resolve of this.frameWaiters.splice(0)) resolve();
|
|
1724
|
+
this.client?.dispose();
|
|
1725
|
+
this.resources.dispose();
|
|
1726
|
+
}
|
|
1727
|
+
describe() {
|
|
1728
|
+
const world = this.world;
|
|
1729
|
+
const p = this.current;
|
|
1730
|
+
const track = p?.track;
|
|
1731
|
+
const stats = world.tryResource(RenderStats)?.get(`camera:${this.camera}`);
|
|
1732
|
+
const gpu = world.tryResource(Gpu);
|
|
1733
|
+
const fields = world.tryResource(LensFields)?.fields ?? [];
|
|
1734
|
+
const mine = new Set(p?.entities ?? []);
|
|
1735
|
+
return {
|
|
1736
|
+
phase: this.phase,
|
|
1737
|
+
quality: p ? p.quality : null,
|
|
1738
|
+
motion: p ? p.reduced ? "reduced" : "full" : null,
|
|
1739
|
+
effects: p ? p.effects : null,
|
|
1740
|
+
// Animated families: the rate their dice keep frames coming at while shown.
|
|
1741
|
+
animated: p && p.fps > 0 && !p.reduced ? Number.isFinite(p.fps) ? p.fps : "display" : null,
|
|
1742
|
+
roll: p ? { id: p.roll.id, seed: p.roll.seed ?? p.roll.id, tray: p.tray } : null,
|
|
1743
|
+
dice: (p?.dice ?? []).map((d, i) => ({
|
|
1744
|
+
index: i,
|
|
1745
|
+
kind: d.kind,
|
|
1746
|
+
definition: d.definition,
|
|
1747
|
+
value: d.value,
|
|
1748
|
+
label: d.label,
|
|
1749
|
+
natural: p?.natural[i] ?? null,
|
|
1750
|
+
// What's on top right now, read from the die as drawn.
|
|
1751
|
+
shown: p && p.entities[i] !== void 0 && world.isAlive(p.entities[i]) ? naturalValue(this.geometryOf(d), world.get(p.entities[i], Transform).rotation) : null,
|
|
1752
|
+
correction: p && p.corrections.length > i * 4 ? Array.from(p.corrections.subarray(i * 4, i * 4 + 4)) : null,
|
|
1753
|
+
placed: p?.placed[i] ?? null,
|
|
1754
|
+
dropped: d.dropped,
|
|
1755
|
+
scale: d.scale,
|
|
1756
|
+
skin: d.skin.guid ?? d.skin.path ?? null,
|
|
1757
|
+
entity: p?.entities[i] ?? null
|
|
1758
|
+
})),
|
|
1759
|
+
track: track ? {
|
|
1760
|
+
hash: p.hash.toString(16).padStart(8, "0"),
|
|
1761
|
+
sceneHash: track.sceneHash.toString(16).padStart(8, "0"),
|
|
1762
|
+
steps: track.steps,
|
|
1763
|
+
// The dice in the physics (entrance dice are not, 0065).
|
|
1764
|
+
bodies: track.bodyCount,
|
|
1765
|
+
settled: track.settled,
|
|
1766
|
+
maxStepsHit: track.maxStepsHit,
|
|
1767
|
+
simulationMs: Math.round(track.simulationMs * 10) / 10,
|
|
1768
|
+
contacts: track.contacts.steps.length,
|
|
1769
|
+
time: p.time
|
|
1770
|
+
} : null,
|
|
1771
|
+
placed: (p?.placed ?? []).flatMap((r, i) => r ? [{ index: i, reason: r }] : []),
|
|
1772
|
+
recipes: (p?.matched ?? []).map((m) => ({ id: m.recipe.id, anchors: m.anchors })),
|
|
1773
|
+
recipesDegraded: p?.degraded ?? false,
|
|
1774
|
+
drawCalls: stats?.drawCalls ?? null,
|
|
1775
|
+
attachments: (p?.live ?? []).map((a) => ({
|
|
1776
|
+
name: a.name,
|
|
1777
|
+
die: a.die,
|
|
1778
|
+
entities: a.entities.length
|
|
1779
|
+
})),
|
|
1780
|
+
lensFields: fields.filter((f) => mine.has(f.source)).map((f) => ({ ...f, screen: [...f.screen] })),
|
|
1781
|
+
// Entrances (0065): each playing die's scene, and matches that didn't play, with why.
|
|
1782
|
+
entrances: (p?.entrances ?? []).map((r) => ({
|
|
1783
|
+
name: r.name,
|
|
1784
|
+
die: r.die,
|
|
1785
|
+
state: r.state,
|
|
1786
|
+
ready: r.ready,
|
|
1787
|
+
seconds: Number.isNaN(r.started) ? null : (this.options.now() - r.started) / 1e3,
|
|
1788
|
+
landAtMs: r.def?.landAtMs ?? null,
|
|
1789
|
+
durationMs: r.def?.durationMs ?? null,
|
|
1790
|
+
rest: r.rest,
|
|
1791
|
+
fallback: r.fallback,
|
|
1792
|
+
skipped: r.skipped
|
|
1793
|
+
})),
|
|
1794
|
+
entranceSkips: p?.entranceSkips ?? [],
|
|
1795
|
+
screenEffects: (world.tryResource(ScreenEffects)?.effects ?? []).filter((e) => mine.has(e.source)).map((e) => ({ ...e, screen: [...e.screen] })),
|
|
1796
|
+
resources: { ...this.resources.stats(), list: this.resources.describe() },
|
|
1797
|
+
gpu: gpu ? gpu.stats(renderOwner(world)) : null,
|
|
1798
|
+
worker: { recordings: this.recordings, spawns: this.client?.spawns ?? 0 },
|
|
1799
|
+
lastError: this.lastError ? {
|
|
1800
|
+
code: this.lastError.code,
|
|
1801
|
+
message: this.lastError.message,
|
|
1802
|
+
path: this.lastError.path ?? null
|
|
1803
|
+
} : null
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
const DiceTable = defineResource("dice/DiceTable", {
|
|
1808
|
+
description: "The dice table (0054): play(roll, { signal, replace }), dismiss(), shortenRest(ms), preview(roll), phase, lastError, describe()."
|
|
1809
|
+
});
|
|
1810
|
+
export {
|
|
1811
|
+
ANIMATED_DEMAND,
|
|
1812
|
+
DiceTable,
|
|
1813
|
+
DiceTableState,
|
|
1814
|
+
ENTRANCE_DEMAND,
|
|
1815
|
+
ENTRANCE_WAIT_MS,
|
|
1816
|
+
PRESENTATION_DEMAND,
|
|
1817
|
+
PREVIEW_TRAY
|
|
1818
|
+
};
|