@aethervtt/shard-dice 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.
@@ -0,0 +1,19 @@
1
+ import { type AssetRef, type World } from '@aethervtt/shard-core';
2
+ /** Default category for dice, including procedural fallback sounds. */
3
+ export declare const DICE_AUDIO_BUS = "dice";
4
+ /** Preserve host controls; a configured mixer must explicitly include its dice route. */
5
+ export declare function ensureDiceAudioBus(world: World): void;
6
+ /** A roll owns preparation and instances, independently of rendering and the host's storage. */
7
+ export declare class DiceAudioSession {
8
+ private readonly bindings;
9
+ private readonly leases;
10
+ private readonly instances;
11
+ private closed;
12
+ private readonly world;
13
+ private readonly gain;
14
+ constructor(world: World, gain: number);
15
+ prepare(refs: readonly AssetRef[]): Promise<void>;
16
+ /** Returns true for configured bindings, even when silent, so defaults do not also play. */
17
+ play(ref: AssetRef | null, seed: number, strength: number, pitch: number, value: number, high: boolean, low: boolean, count: number, contact?: number, gain?: number): boolean;
18
+ dispose(): void;
19
+ }
package/dist/audio.js ADDED
@@ -0,0 +1,187 @@
1
+ import {
2
+ AudioBuses,
3
+ AudioEvent,
4
+ AudioEventsState,
5
+ AudioMixState,
6
+ AudioState,
7
+ audioEvents,
8
+ playAudioEvent,
9
+ prepareAudio,
10
+ stopAudioEvent
11
+ } from "@aethervtt/shard-audio";
12
+ import { ShardError } from "@aethervtt/shard-core";
13
+ const DICE_AUDIO_BUS = "dice";
14
+ const INPUTS = ["strength", "pitch", "value", "high", "low", "count", "contact"];
15
+ function ensureDiceAudioBus(world) {
16
+ if (!world.hasResource(AudioState)) return;
17
+ const graph = world.tryResource(AudioMixState)?.graph;
18
+ if (graph && !graph.buses.some((bus) => bus.id === DICE_AUDIO_BUS && !bus.return))
19
+ throw new ShardError("dice/audio-bus", "The active mixer needs an ordinary dice bus", {
20
+ hint: "Add dice under master in the host mixer before rolling.",
21
+ path: "/buses"
22
+ });
23
+ const buses = world.resource(AudioBuses);
24
+ buses[DICE_AUDIO_BUS] ??= { volume: 1, muted: false, parent: "master" };
25
+ if (buses[DICE_AUDIO_BUS].parent !== "master" || buses[DICE_AUDIO_BUS].userDomain === false)
26
+ throw new ShardError(
27
+ "dice/audio-bus",
28
+ "Dice must be an ordinary category directly under master",
29
+ {
30
+ hint: "Independent dice user controls require their own category, including effect tails.",
31
+ path: "/buses/dice"
32
+ }
33
+ );
34
+ }
35
+ function key(ref) {
36
+ return ref.guid ?? ref.path ?? "";
37
+ }
38
+ class DiceAudioSession {
39
+ bindings = /* @__PURE__ */ new Map();
40
+ leases = [];
41
+ instances = [];
42
+ closed = false;
43
+ world;
44
+ gain;
45
+ constructor(world, gain) {
46
+ this.world = world;
47
+ this.gain = gain;
48
+ }
49
+ async prepare(refs) {
50
+ if (this.closed || this.gain <= 0 || !this.world.hasResource(AudioState)) return;
51
+ ensureDiceAudioBus(this.world);
52
+ if (refs.length && !this.world.hasResource(AudioEventsState))
53
+ throw new ShardError(
54
+ "dice/audio-events-missing",
55
+ "Authored dice sounds need audioEventsPlugin",
56
+ {
57
+ hint: "Install audioEventsPlugin alongside audioPlugin, or remove the event bindings."
58
+ }
59
+ );
60
+ try {
61
+ for (const ref of refs) {
62
+ if (this.closed) return;
63
+ if (this.bindings.has(key(ref))) continue;
64
+ const lease = await prepareAudio(this.world, ref);
65
+ if (this.closed) {
66
+ lease.release();
67
+ return;
68
+ }
69
+ this.leases.push(lease);
70
+ const plan = lease.events[0];
71
+ if (!plan || lease.events.length !== 1)
72
+ throw new ShardError("dice/audio-event", "Dice bindings require one audio event");
73
+ const definition = plan.definition;
74
+ if (definition.mode !== "oneshot" || definition.layers.some((layer) => layer.spatial))
75
+ throw new ShardError(
76
+ "dice/audio-event",
77
+ "Dice presentation events must be flat one-shots"
78
+ );
79
+ const buses = this.world.resource(AudioBuses);
80
+ for (const layer of definition.layers) {
81
+ let bus = layer.bus || definition.bus;
82
+ let depth = 0;
83
+ while (bus && bus !== DICE_AUDIO_BUS && depth++ < 64) bus = buses[bus]?.parent ?? "";
84
+ if (bus !== DICE_AUDIO_BUS)
85
+ throw new ShardError("dice/audio-bus", "Dice event layers must route through dice", {
86
+ path: `/layers/${layer.id}/bus`,
87
+ hint: "Set the event bus to dice or a child bus in the host mixer."
88
+ });
89
+ }
90
+ const inputs = [];
91
+ for (const parameter of definition.parameters) {
92
+ if (!INPUTS.includes(parameter.name)) continue;
93
+ if (parameter.readonly || parameter.kind === "label" || ["strength", "pitch"].includes(parameter.name) && parameter.kind !== "continuous")
94
+ throw new ShardError("dice/audio-parameter", `Invalid dice input ${parameter.name}`, {
95
+ path: `/parameters/${parameter.name}`,
96
+ hint: "Use writable numeric inputs; strength and pitch must be continuous."
97
+ });
98
+ inputs.push(parameter.name);
99
+ }
100
+ this.bindings.set(key(ref), { ref: plan.source ?? ref, inputs, revision: plan.revision });
101
+ }
102
+ } catch (error) {
103
+ this.dispose();
104
+ throw error;
105
+ }
106
+ }
107
+ /** Returns true for configured bindings, even when silent, so defaults do not also play. */
108
+ play(ref, seed, strength, pitch, value, high, low, count, contact = 0, gain = 1) {
109
+ if (!ref) return false;
110
+ if (this.closed || this.gain <= 0 || !this.world.hasResource(AudioState)) return true;
111
+ const binding = this.bindings.get(key(ref));
112
+ if (!binding)
113
+ throw new ShardError("dice/audio-not-prepared", "Prepare the dice event before playback");
114
+ const current = this.world.resource(AudioEvent.store).get(binding.ref);
115
+ if (!current || JSON.stringify(AudioEvent.serialize(current)) !== binding.revision)
116
+ throw new ShardError("dice/audio-changed", "A dice event changed during its roll", {
117
+ hint: "Start a new roll to prepare the changed event and validate its routing and inputs."
118
+ });
119
+ const parameters = {};
120
+ for (let i = 0; i < binding.inputs.length; i++) {
121
+ const input = binding.inputs[i];
122
+ switch (input) {
123
+ case "strength":
124
+ parameters[input] = strength;
125
+ break;
126
+ case "pitch":
127
+ parameters[input] = pitch;
128
+ break;
129
+ case "value":
130
+ parameters[input] = value;
131
+ break;
132
+ case "high":
133
+ parameters[input] = high ? 1 : 0;
134
+ break;
135
+ case "low":
136
+ parameters[input] = low ? 1 : 0;
137
+ break;
138
+ case "count":
139
+ parameters[input] = count;
140
+ break;
141
+ case "contact":
142
+ parameters[input] = contact;
143
+ break;
144
+ }
145
+ }
146
+ const state = audioEvents(this.world);
147
+ let keep = 0;
148
+ for (let i = 0; i < this.instances.length; i++) {
149
+ const id = this.instances[i];
150
+ for (let j = 0; j < state.instances.length; j++) {
151
+ const instance = state.instances[j];
152
+ if (instance?.id === id && instance.state !== "ended" && instance.state !== "failed") {
153
+ this.instances[keep++] = id;
154
+ break;
155
+ }
156
+ }
157
+ }
158
+ this.instances.length = keep;
159
+ try {
160
+ this.instances.push(
161
+ playAudioEvent(this.world, binding.ref, {
162
+ seed: seed >>> 0,
163
+ volume: this.gain * gain,
164
+ parameters
165
+ })
166
+ );
167
+ } catch (error) {
168
+ if (!(error instanceof ShardError) || error.code !== "audio/capacity") throw error;
169
+ }
170
+ return true;
171
+ }
172
+ dispose() {
173
+ if (this.closed) return;
174
+ this.closed = true;
175
+ if (this.world.hasResource(AudioEventsState))
176
+ for (const id of this.instances) stopAudioEvent(this.world, id, { release: 0 });
177
+ for (const lease of this.leases) lease.release();
178
+ this.instances.length = 0;
179
+ this.leases.length = 0;
180
+ this.bindings.clear();
181
+ }
182
+ }
183
+ export {
184
+ DICE_AUDIO_BUS,
185
+ DiceAudioSession,
186
+ ensureDiceAudioBus
187
+ };
package/dist/effects.d.ts CHANGED
@@ -20,6 +20,7 @@ declare const EFFECT: import("@aethervtt/shard-core").FieldType<import("@aetherv
20
20
  count: import("@aethervtt/shard-core").FieldType<number, "u8">;
21
21
  durationMs: import("@aethervtt/shard-core").FieldType<number, "u16">;
22
22
  cue: import("@aethervtt/shard-core").FieldType<"arcane-spark" | "resin-chime" | "void-whump", "u8">;
23
+ event: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").AssetRef<"audio/AudioEvent"> | null, "object">;
23
24
  gain: import("@aethervtt/shard-core").FieldType<number, "f32">;
24
25
  radius: import("@aethervtt/shard-core").FieldType<number, "f32">;
25
26
  strength: import("@aethervtt/shard-core").FieldType<number, "f32">;
@@ -52,6 +53,7 @@ export declare const DiceEffectRecipe: import("@aethervtt/shard-assets").DataTyp
52
53
  count: import("@aethervtt/shard-core").FieldType<number, "u8">;
53
54
  durationMs: import("@aethervtt/shard-core").FieldType<number, "u16">;
54
55
  cue: import("@aethervtt/shard-core").FieldType<"arcane-spark" | "resin-chime" | "void-whump", "u8">;
56
+ event: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").AssetRef<"audio/AudioEvent"> | null, "object">;
55
57
  gain: import("@aethervtt/shard-core").FieldType<number, "f32">;
56
58
  radius: import("@aethervtt/shard-core").FieldType<number, "f32">;
57
59
  strength: import("@aethervtt/shard-core").FieldType<number, "f32">;
package/dist/effects.js CHANGED
@@ -37,6 +37,9 @@ const EFFECT = t.struct(
37
37
  cue: t.enum(["resin-chime", "arcane-spark", "void-whump"], {
38
38
  description: "sound-accent: the cue."
39
39
  }),
40
+ event: t.handle("audio/AudioEvent", {
41
+ description: "sound-accent: optional authored event replacing the procedural cue."
42
+ }),
40
43
  gain: t.f32({ default: 0.7, min: 0, max: 1, description: "sound-accent: 0..1." }),
41
44
  radius: t.f32({
42
45
  default: 150,
@@ -72,6 +75,7 @@ const DiceEffectRecipe = defineDataType(
72
75
  },
73
76
  {
74
77
  extension: "dice-effect",
78
+ lazyDependencies: true,
75
79
  description: "Effects a skin plays when a roll matches its conditions."
76
80
  }
77
81
  );
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { DICE_AUDIO_BUS } from './audio.js';
1
2
  export { allDiceAttachments, DICE_BUDGETS, type DiceAttachmentContext, type DiceAttachmentDef, type DiceSceneContext, defineDiceAttachment, findDiceAttachment, MAX_ATTACHMENTS, setDiceBudgets, } from './attachments.js';
2
3
  export { BUILTIN_DICE, D4, D6, D8, D10, D10_TENS, D12, D20, D100_BALL, DIE_KINDS, type DieKind, KIND_DEFINITIONS, kindSides, percentileValues, registerBuiltinDice, } from './builtins.js';
3
4
  export { CELL_PAD, type CellLayout, cellLayout, type FaceCell, MARK_RANGE } from './cells.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { DICE_AUDIO_BUS } from "./audio.js";
1
2
  import {
2
3
  allDiceAttachments,
3
4
  DICE_BUDGETS,
@@ -188,6 +189,7 @@ export {
188
189
  D4,
189
190
  D6,
190
191
  D8,
192
+ DICE_AUDIO_BUS,
191
193
  DICE_BUDGETS,
192
194
  DICE_CLEANUP_STEP,
193
195
  DICE_CONTACTS,
package/dist/plugin.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import { definePlugin } from "@aethervtt/shard-runtime";
20
20
  import { Textures } from "@aethervtt/shard-texture";
21
21
  import { lookAt, Transform } from "@aethervtt/shard-transform";
22
+ import { ensureDiceAudioBus } from "./audio.js";
22
23
  import { KIND_DEFINITIONS, registerBuiltinDice } from "./builtins.js";
23
24
  import { DiceDie, DiceRollRequest } from "./components.js";
24
25
  import { DieDefinitionAsset } from "./definition-asset.js";
@@ -119,6 +120,7 @@ function dicePlugin(options = {}) {
119
120
  for (const family of allDiceFamilies())
120
121
  if (family.module) shaders[family.module.path] = family.module.source;
121
122
  registerShaders(world.resource(Shaders), shaders);
123
+ ensureDiceAudioBus(world);
122
124
  const table = world.resource(DiceTable);
123
125
  const quad = world.resource(Meshes).add(floorQuad(), "dice:tray");
124
126
  const trayMaterial = world.resource(Materials).add(new MaterialAsset({}, DiceTray), "dice:tray");
package/dist/preview.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { assetServer } from "@aethervtt/shard-assets";
1
2
  import { ShardError } from "@aethervtt/shard-core";
2
3
  import { encodePng } from "@aethervtt/shard-protocol";
3
4
  import {
@@ -15,6 +16,7 @@ import {
15
16
  Shaders
16
17
  } from "@aethervtt/shard-render";
17
18
  import { FrameDemand, LOADING_DEMAND } from "@aethervtt/shard-runtime";
19
+ import { Fonts } from "@aethervtt/shard-text";
18
20
  import { lookAt, Transform } from "@aethervtt/shard-transform";
19
21
  import { dieGeometry, requireDie } from "./definition.js";
20
22
  import { restingHeight, restRotation } from "./landing.js";
@@ -43,7 +45,17 @@ async function renderDiceThumbnail(app, options) {
43
45
  }
44
46
  const g = dieGeometry(requireDie(die.definition));
45
47
  const skinKey = options.skin.guid ?? options.skin.path ?? "";
46
- const entry = table.resources.acquire(g, skinKey, skin, die.kind, (r) => table.layout(r), {});
48
+ const layoutRef = skin.variants[die.kind].layout;
49
+ if (layoutRef && !table.layout(layoutRef)) await assetServer(world).load(layoutRef);
50
+ const layout = layoutRef ? table.layout(layoutRef) : void 0;
51
+ if (layout) {
52
+ for (const mark of [layout.default, ...layout.overrides.map((override) => override.mark)])
53
+ if (mark.kind === "text" && mark.font && !world.tryResource(Fonts)?.get(mark.font))
54
+ await assetServer(world).load(mark.font);
55
+ }
56
+ const entry = table.resources.acquire(g, skinKey, skin, die.kind, (r) => table.layout(r), {
57
+ font: (ref) => world.tryResource(Fonts)?.get(ref)
58
+ });
47
59
  const material = table.resources.material(entry, {
48
60
  blended: options.blended ?? table.resources.blendedByNature(entry),
49
61
  dropped: false
package/dist/skin.d.ts CHANGED
@@ -22,6 +22,9 @@ export declare const DiceSkin: import("@aethervtt/shard-assets").DataType<{
22
22
  }>, "object">>>, "object">;
23
23
  readonly sounds: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").InferFields<{
24
24
  impact: import("@aethervtt/shard-core").FieldType<"glass" | "metal" | "resin" | "wood", "u8">;
25
+ impactEvent: import("@aethervtt/shard-core").FieldType<AssetRef<"audio/AudioEvent"> | null, "object">;
26
+ entranceEvent: import("@aethervtt/shard-core").FieldType<AssetRef<"audio/AudioEvent"> | null, "object">;
27
+ resultEvent: import("@aethervtt/shard-core").FieldType<AssetRef<"audio/AudioEvent"> | null, "object">;
25
28
  accent: import("@aethervtt/shard-core").FieldType<"" | "arcane-spark" | "resin-chime" | "void-whump", "u8">;
26
29
  }>, "object">;
27
30
  readonly effects: import("@aethervtt/shard-core").FieldType<(AssetRef<"dice/DiceEffectRecipe"> | null)[], "object">;
package/dist/skin.js CHANGED
@@ -39,6 +39,15 @@ const DiceSkin = defineDataType(
39
39
  sounds: t.struct(
40
40
  {
41
41
  impact: t.enum(IMPACT_SOUNDS, { description: "What contacts sound like." }),
42
+ impactEvent: t.handle("audio/AudioEvent", {
43
+ description: "Optional authored hit, replacing procedural contacts and landing knocks."
44
+ }),
45
+ entranceEvent: t.handle("audio/AudioEvent", {
46
+ description: "Optional authored sound when an entrance scene starts."
47
+ }),
48
+ resultEvent: t.handle("audio/AudioEvent", {
49
+ description: "Optional authored landing accent, under the one-cue budget."
50
+ }),
42
51
  accent: t.enum(["", "resin-chime", "arcane-spark", "void-whump"], {
43
52
  description: "A cue played when the dice land (with effects on). Empty: none."
44
53
  })
@@ -51,6 +60,7 @@ const DiceSkin = defineDataType(
51
60
  },
52
61
  {
53
62
  extension: "dice-skin",
63
+ lazyDependencies: true,
54
64
  description: "A dice skin: family, parameters, variants, sounds, effects."
55
65
  }
56
66
  );
package/dist/table.d.ts CHANGED
@@ -91,6 +91,8 @@ export declare class DiceTableState {
91
91
  recipe(ref: AssetRef): DiceEffectRecipeValue | undefined;
92
92
  /** Loads what the roll's layouts need (fonts), makes the dice's resources, then records or places. */
93
93
  private prepare;
94
+ private prepareAudio;
95
+ private eventSound;
94
96
  private loadFonts;
95
97
  private acquire;
96
98
  private screenUp;
package/dist/table.js CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  findDiceAttachment,
44
44
  MAX_ATTACHMENTS
45
45
  } from "./attachments.js";
46
+ import { DICE_AUDIO_BUS, DiceAudioSession } from "./audio.js";
46
47
  import { DiceDie } from "./components.js";
47
48
  import { dieGeometry, requireDie } from "./definition.js";
48
49
  import {
@@ -101,7 +102,7 @@ const PICK = new Int32Array(IMPACTS_PER_FRAME);
101
102
  const POWER = new Float32Array(IMPACTS_PER_FRAME);
102
103
  const IMPACT_VOLUME = [0, 0];
103
104
  const IMPACT_PITCH = [0, 0];
104
- const IMPACT_SOUND = { bus: "sfx", volume: IMPACT_VOLUME, pitch: IMPACT_PITCH };
105
+ const IMPACT_SOUND = { bus: DICE_AUDIO_BUS, volume: IMPACT_VOLUME, pitch: IMPACT_PITCH };
105
106
  const ACCENT_LEVEL = 0.4;
106
107
  function screenOf(world, camera, x, y, z, out) {
107
108
  const cam = world.resource(Cameras).get(camera);
@@ -226,6 +227,8 @@ class DiceTableState {
226
227
  degraded: false,
227
228
  live: [],
228
229
  voices: [],
230
+ cuePlayed: false,
231
+ audio: new DiceAudioSession(this.world, Math.max(0, Math.min(1, roll.soundGain ?? 1))),
229
232
  shadowsBefore: void 0,
230
233
  trayShadow: Number.NaN,
231
234
  fps: 0,
@@ -361,6 +364,8 @@ class DiceTableState {
361
364
  await this.loadSkins(p);
362
365
  if (p.done) return;
363
366
  this.chooseEntrances(p);
367
+ await this.prepareAudio(p);
368
+ if (p.done) return;
364
369
  this.acquire(p);
365
370
  p.request = rollTrackRequest(
366
371
  p.roll,
@@ -380,6 +385,7 @@ class DiceTableState {
380
385
  if (recording) this.recordings++;
381
386
  if (p.soundGain > 0 && this.world.tryResource(AudioState)) {
382
387
  for (const skin of new Set(p.skins)) {
388
+ if (skin.sounds.impactEvent) continue;
383
389
  this.sounds.prepare(skin.sounds.impact, (clip) => preloadSound(this.world, clip));
384
390
  }
385
391
  }
@@ -398,6 +404,41 @@ class DiceTableState {
398
404
  else this.fail(p, err);
399
405
  }
400
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
+ }
401
442
  async loadFonts(p) {
402
443
  const fonts = this.world.tryResource(Fonts);
403
444
  const wanted = /* @__PURE__ */ new Map();
@@ -665,20 +706,24 @@ class DiceTableState {
665
706
  if (this.resources.nextRelease() <= now) this.resources.sweep(now);
666
707
  const p = this.current;
667
708
  if (!p || p.done) return;
668
- const dt = Math.min(world.resource(Time).delta, MAX_STEP_S);
669
- if (p.phase === "tumble") this.tumble(p, dt);
670
- if (!Number.isNaN(p.landedAt)) this.settleLook(p, now);
671
- if (!Number.isNaN(p.fadeFrom)) this.fade(p, now);
672
- if (p.entrances.length > 0) this.entrancesFrame(p, now);
673
- if (p.runs.length > 0) this.accent(p, now);
674
- if (p.phase === "accent" && now >= p.accentEndsAt) this.rest(p);
675
- this.attachmentsFrame(p, now);
676
- if (p.fps > 0 && !p.reduced && p.entities.length > 0 && !p.done) {
677
- const demand = world.resource(FrameDemand);
678
- if (p.fps === Number.POSITIVE_INFINITY) demand.hold(ANIMATED_DEMAND);
679
- else demand.after(1e3 / p.fps);
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);
680
726
  }
681
- if (p.phase === "rest" && now >= p.restEndsAt) this.finish(p, "finished");
682
727
  }
683
728
  tumble(p, dt) {
684
729
  const world = this.world;
@@ -767,6 +812,7 @@ class DiceTableState {
767
812
  p.lastImpact[a] = t;
768
813
  if (b >= 0) p.lastImpact[b] = t;
769
814
  const tray = b < 0;
815
+ if (this.eventSound(p, p.skins[a].sounds.impactEvent, a, k, strength, tray ? 0 : 1)) return;
770
816
  const variation = ((Math.imul(k + 1, 2654435761) ^ p.soundSeed) >>> 0) % IMPACT_VARIATIONS;
771
817
  const clip = this.sounds.impact(
772
818
  p.skins[a].sounds.impact,
@@ -799,11 +845,12 @@ class DiceTableState {
799
845
  const audio = world.tryResource(AudioState);
800
846
  if (audio && p.soundGain > 0) {
801
847
  const i = p.placed.findIndex(Boolean);
802
- if (i >= 0) {
848
+ if (i >= 0 && !this.eventSound(p, p.skins[i].sounds.impactEvent, i, 1e5 + i, 0.5)) {
803
849
  const clip = this.sounds.impact(p.skins[i].sounds.impact, "tray", 1, 0);
804
850
  const level = 0.14 * p.soundGain;
805
851
  p.voices.push(
806
852
  playSound(world, clip, {
853
+ bus: DICE_AUDIO_BUS,
807
854
  volume: [level * 0.8, level],
808
855
  pitch: [p.pitch[i] * 0.9, p.pitch[i] * 0.98]
809
856
  })
@@ -971,6 +1018,7 @@ class DiceTableState {
971
1018
  }
972
1019
  /** Starts a matched recipe's effects on `anchors`. Returns how long the longest runs, ms. */
973
1020
  playRecipe(p, m, anchors, now, budget) {
1021
+ if (anchors.length === 0) return 0;
974
1022
  let longest = 0;
975
1023
  const at = this.centroid(p, anchors);
976
1024
  for (const effect of m.recipe.effects) {
@@ -980,9 +1028,11 @@ class DiceTableState {
980
1028
  continue;
981
1029
  }
982
1030
  if (effect.kind === "sound-accent") {
983
- if (budget.cue) continue;
1031
+ if (budget.cue || p.cuePlayed) continue;
984
1032
  budget.cue = true;
985
- this.accentSound(p, effect.cue, effect.gain);
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);
986
1036
  continue;
987
1037
  }
988
1038
  if (!at) continue;
@@ -1017,9 +1067,12 @@ class DiceTableState {
1017
1067
  if (p.playedRecipes.has(m.recipe.id)) continue;
1018
1068
  longest = Math.max(longest, this.playRecipe(p, m, m.anchors, now, budget));
1019
1069
  }
1020
- if (!budget.cue) {
1021
- const i = p.dice.findIndex((d, k) => !d.dropped && p.skins[k].sounds.accent);
1022
- if (i >= 0) this.accentSound(p, p.skins[i].sounds.accent, 0.6);
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);
1023
1076
  }
1024
1077
  if (longest === 0 && p.live.length === 0) return false;
1025
1078
  p.phase = "accent";
@@ -1030,7 +1083,9 @@ class DiceTableState {
1030
1083
  accentSound(p, cue, gain) {
1031
1084
  if (!this.world.tryResource(AudioState) || p.soundGain <= 0) return;
1032
1085
  const level = ACCENT_LEVEL * gain * p.soundGain;
1033
- p.voices.push(playSound(this.world, this.sounds.accent(cue), { volume: level }));
1086
+ p.voices.push(
1087
+ playSound(this.world, this.sounds.accent(cue), { bus: DICE_AUDIO_BUS, volume: level })
1088
+ );
1034
1089
  }
1035
1090
  startRun(p, run) {
1036
1091
  const world = this.world;
@@ -1208,7 +1263,7 @@ class DiceTableState {
1208
1263
  const g = p.soundGain;
1209
1264
  const v = options.volume ?? 1;
1210
1265
  const volume = typeof v === "number" ? v * g : [v[0] * g, v[1] * g];
1211
- const voice = playSound(world, clip, { ...options, volume });
1266
+ const voice = playSound(world, clip, { bus: DICE_AUDIO_BUS, ...options, volume });
1212
1267
  p.voices.push(voice);
1213
1268
  return voice;
1214
1269
  },
@@ -1457,6 +1512,8 @@ class DiceTableState {
1457
1512
  `Entrance "${run.name}" spawned more than its ${def.vertices} vertices; its die drops in instead.`
1458
1513
  );
1459
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);
1460
1517
  }
1461
1518
  }
1462
1519
  /** Each frame while an entrance is alive: scenes, the die's landing, its result ramp. */
@@ -1536,6 +1593,8 @@ class DiceTableState {
1536
1593
  entranceImpact(p, run) {
1537
1594
  const strength = run.def?.impact ?? 0.8;
1538
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;
1539
1598
  const variation = ((p.soundSeed ^ Math.imul(run.die + 7, 2654435761)) >>> 0) % IMPACT_VARIATIONS;
1540
1599
  const clip = this.sounds.impact(
1541
1600
  p.skins[run.die].sounds.impact,
@@ -1547,6 +1606,7 @@ class DiceTableState {
1547
1606
  const pitch = p.pitch[run.die] ?? 1;
1548
1607
  p.voices.push(
1549
1608
  playSound(this.world, clip, {
1609
+ bus: DICE_AUDIO_BUS,
1550
1610
  volume: [level * 0.8, level],
1551
1611
  pitch: [pitch * 0.94, pitch * 1.06]
1552
1612
  })
@@ -1571,6 +1631,7 @@ class DiceTableState {
1571
1631
  p.done = true;
1572
1632
  p.unlisten?.();
1573
1633
  p.abort.abort();
1634
+ p.audio.dispose();
1574
1635
  const world = this.world;
1575
1636
  const now = this.options.now();
1576
1637
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aethervtt/shard-dice",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -20,19 +20,19 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@aethervtt/shard-assets": "0.1.0",
24
- "@aethervtt/shard-audio": "0.1.0",
25
- "@aethervtt/shard-core": "0.1.0",
26
- "@aethervtt/shard-gpu": "0.1.0",
27
- "@aethervtt/shard-mesh": "0.1.0",
28
- "@aethervtt/shard-particles": "0.1.0",
29
- "@aethervtt/shard-physics": "0.1.0",
30
- "@aethervtt/shard-protocol": "0.1.0",
31
- "@aethervtt/shard-render": "0.1.0",
32
- "@aethervtt/shard-runtime": "0.1.0",
33
- "@aethervtt/shard-text": "0.1.0",
34
- "@aethervtt/shard-texture": "0.1.0",
35
- "@aethervtt/shard-transform": "0.1.0"
23
+ "@aethervtt/shard-assets": "0.2.0",
24
+ "@aethervtt/shard-audio": "0.2.0",
25
+ "@aethervtt/shard-core": "0.2.0",
26
+ "@aethervtt/shard-gpu": "0.2.0",
27
+ "@aethervtt/shard-mesh": "0.2.0",
28
+ "@aethervtt/shard-particles": "0.2.0",
29
+ "@aethervtt/shard-physics": "0.2.0",
30
+ "@aethervtt/shard-protocol": "0.2.0",
31
+ "@aethervtt/shard-render": "0.2.0",
32
+ "@aethervtt/shard-runtime": "0.2.0",
33
+ "@aethervtt/shard-text": "0.2.0",
34
+ "@aethervtt/shard-texture": "0.2.0",
35
+ "@aethervtt/shard-transform": "0.2.0"
36
36
  },
37
37
  "repository": {
38
38
  "type": "git",
@@ -44,6 +44,6 @@
44
44
  "provenance": true
45
45
  },
46
46
  "shard": {
47
- "buildSha": "8e14c9896f82a3554b156f1ffdd68f2cc3e7d78a"
47
+ "buildSha": "32c22a3944ed23f5d1baa90bf7cc9fae724f5fd8"
48
48
  }
49
49
  }