@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.
- package/dist/audio.d.ts +19 -0
- package/dist/audio.js +187 -0
- package/dist/effects.d.ts +2 -0
- package/dist/effects.js +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/plugin.js +2 -0
- package/dist/preview.js +13 -1
- package/dist/skin.d.ts +3 -0
- package/dist/skin.js +10 -0
- package/dist/table.d.ts +2 -0
- package/dist/table.js +83 -22
- package/package.json +15 -15
package/dist/audio.d.ts
ADDED
|
@@ -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
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
|
|
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:
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
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(
|
|
1022
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
24
|
-
"@aethervtt/shard-audio": "0.
|
|
25
|
-
"@aethervtt/shard-core": "0.
|
|
26
|
-
"@aethervtt/shard-gpu": "0.
|
|
27
|
-
"@aethervtt/shard-mesh": "0.
|
|
28
|
-
"@aethervtt/shard-particles": "0.
|
|
29
|
-
"@aethervtt/shard-physics": "0.
|
|
30
|
-
"@aethervtt/shard-protocol": "0.
|
|
31
|
-
"@aethervtt/shard-render": "0.
|
|
32
|
-
"@aethervtt/shard-runtime": "0.
|
|
33
|
-
"@aethervtt/shard-text": "0.
|
|
34
|
-
"@aethervtt/shard-texture": "0.
|
|
35
|
-
"@aethervtt/shard-transform": "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": "
|
|
47
|
+
"buildSha": "32c22a3944ed23f5d1baa90bf7cc9fae724f5fd8"
|
|
48
48
|
}
|
|
49
49
|
}
|