@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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -2
  3. package/dist/attachments.d.ts +79 -0
  4. package/dist/attachments.js +43 -0
  5. package/dist/audio.d.ts +19 -0
  6. package/dist/audio.js +187 -0
  7. package/dist/builtins.d.ts +25 -0
  8. package/dist/builtins.js +335 -0
  9. package/dist/cells.d.ts +33 -0
  10. package/dist/cells.js +53 -0
  11. package/dist/components.d.ts +14 -0
  12. package/dist/components.js +27 -0
  13. package/dist/definition-asset.d.ts +19 -0
  14. package/dist/definition-asset.js +60 -0
  15. package/dist/definition.d.ts +98 -0
  16. package/dist/definition.js +320 -0
  17. package/dist/dice-worker.d.ts +1 -0
  18. package/dist/dice-worker.js +3 -0
  19. package/dist/effects.d.ts +99 -0
  20. package/dist/effects.js +200 -0
  21. package/dist/entrances.d.ts +48 -0
  22. package/dist/entrances.js +54 -0
  23. package/dist/families.d.ts +17 -0
  24. package/dist/families.js +131 -0
  25. package/dist/glyphs.d.ts +29 -0
  26. package/dist/glyphs.js +270 -0
  27. package/dist/golden.json +17 -0
  28. package/dist/hull.d.ts +32 -0
  29. package/dist/hull.js +154 -0
  30. package/dist/index.d.ts +32 -0
  31. package/dist/index.js +324 -0
  32. package/dist/landing.d.ts +29 -0
  33. package/dist/landing.js +93 -0
  34. package/dist/layout.d.ts +75 -0
  35. package/dist/layout.js +137 -0
  36. package/dist/marks.d.ts +54 -0
  37. package/dist/marks.js +543 -0
  38. package/dist/material.d.ts +80 -0
  39. package/dist/material.js +343 -0
  40. package/dist/math.d.ts +39 -0
  41. package/dist/math.js +136 -0
  42. package/dist/mesh.d.ts +9 -0
  43. package/dist/mesh.js +105 -0
  44. package/dist/methods.d.ts +2 -0
  45. package/dist/methods.js +42 -0
  46. package/dist/numerals.d.ts +9 -0
  47. package/dist/numerals.js +47 -0
  48. package/dist/plugin.d.ts +41 -0
  49. package/dist/plugin.js +176 -0
  50. package/dist/preview.d.ts +32 -0
  51. package/dist/preview.js +135 -0
  52. package/dist/quality.d.ts +12 -0
  53. package/dist/quality.js +18 -0
  54. package/dist/resources.d.ts +85 -0
  55. package/dist/resources.js +191 -0
  56. package/dist/roll.d.ts +68 -0
  57. package/dist/roll.js +97 -0
  58. package/dist/settle.d.ts +53 -0
  59. package/dist/settle.js +96 -0
  60. package/dist/skin.d.ts +64 -0
  61. package/dist/skin.js +313 -0
  62. package/dist/sound.d.ts +33 -0
  63. package/dist/sound.js +296 -0
  64. package/dist/studio.d.ts +3 -0
  65. package/dist/studio.js +48 -0
  66. package/dist/table.d.ts +227 -0
  67. package/dist/table.js +1818 -0
  68. package/dist/test-worker.d.ts +1 -0
  69. package/dist/test-worker.js +12 -0
  70. package/dist/track.d.ts +70 -0
  71. package/dist/track.js +304 -0
  72. package/dist/tray.d.ts +2 -0
  73. package/dist/tray.js +43 -0
  74. package/dist/variants.d.ts +9 -0
  75. package/dist/variants.js +46 -0
  76. package/dist/windows.d.ts +25 -0
  77. package/dist/windows.js +77 -0
  78. package/dist/worker.d.ts +6 -0
  79. package/dist/worker.js +9 -0
  80. package/package.json +47 -4
package/dist/skin.js ADDED
@@ -0,0 +1,313 @@
1
+ import { defineDataType } from "@aethervtt/shard-assets";
2
+ import { ShardError, t } from "@aethervtt/shard-core";
3
+ import { DIE_KINDS, KIND_DEFINITIONS } from "./builtins.js";
4
+ import { requireDie } from "./definition.js";
5
+ import { recipeProblems } from "./effects.js";
6
+ import { defaultLayoutFor, layoutProblems } from "./layout.js";
7
+ import { findDiceFamily } from "./material.js";
8
+ const VARIANT = t.struct(
9
+ {
10
+ layout: t.handle("dice/FaceLayout", {
11
+ description: "The face layout; null prints numbers (pips on a d6)."
12
+ }),
13
+ bevel: t.f32({ min: 0, max: 0.3, description: "Chamfer; 0 keeps the definition's." }),
14
+ params: t.json({
15
+ default: {},
16
+ description: "Family parameters for this kind, over the skin\u2019s."
17
+ })
18
+ },
19
+ { description: "What changes for one kind of die." }
20
+ );
21
+ const IMPACT_SOUNDS = ["resin", "metal", "wood", "glass"];
22
+ const DiceSkin = defineDataType(
23
+ "dice/DiceSkin",
24
+ {
25
+ version: t.u8({ default: 1, min: 1, max: 1, description: "Format version: 1." }),
26
+ id: t.string({ description: "A name for tools and errors." }),
27
+ family: t.string({
28
+ default: "dice/SolidDice",
29
+ description: "The dice family (a material type defined with defineDiceFamily)."
30
+ }),
31
+ params: t.json({
32
+ default: {},
33
+ description: "The family's parameters: standard fields (baseColor, roughness\u2026), dice fields (markColor, edgeColor\u2026) and its own. Validated against the family's schema."
34
+ }),
35
+ variants: t.struct(
36
+ Object.fromEntries(DIE_KINDS.map((k) => [k, VARIANT])),
37
+ { description: "Per kind: layout, bevel and parameters." }
38
+ ),
39
+ sounds: t.struct(
40
+ {
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
+ }),
51
+ accent: t.enum(["", "resin-chime", "arcane-spark", "void-whump"], {
52
+ description: "A cue played when the dice land (with effects on). Empty: none."
53
+ })
54
+ },
55
+ { description: "Sounds." }
56
+ ),
57
+ effects: t.list(t.handle("dice/DiceEffectRecipe"), {
58
+ description: "Effect recipes, at most 4."
59
+ })
60
+ },
61
+ {
62
+ extension: "dice-skin",
63
+ lazyDependencies: true,
64
+ description: "A dice skin: family, parameters, variants, sounds, effects."
65
+ }
66
+ );
67
+ const RESERVED = ["marks", "dropped", "fade", "result", "resultTime", "alphaMode"];
68
+ const MAX_SKIN_EFFECTS = 4;
69
+ function checkParams(family, params, path, out) {
70
+ const f = findDiceFamily(family);
71
+ if (!f) return;
72
+ if (params === null || typeof params !== "object" || Array.isArray(params)) {
73
+ out.push(
74
+ new ShardError("dice/invalid-skin", `${path} must be an object of parameters`, { path })
75
+ );
76
+ return;
77
+ }
78
+ for (const key of Object.keys(params)) {
79
+ if (RESERVED.includes(key)) {
80
+ out.push(
81
+ new ShardError(
82
+ "dice/reserved-param",
83
+ `${path}/${key} is set by the package, not by skins`,
84
+ {
85
+ path: `${path}/${key}`,
86
+ hint: "Dropped dice, fades and results are the presentation\u2019s; the mark atlas is baked from the layout."
87
+ }
88
+ )
89
+ );
90
+ }
91
+ }
92
+ for (const err of f.type.schema.validate(params)) {
93
+ out.push(
94
+ new ShardError(err.code, err.message, {
95
+ path: `${path}${err.path && err.path !== "/" ? err.path : ""}`,
96
+ hint: err.hint ?? `Parameters of ${family}.`
97
+ })
98
+ );
99
+ }
100
+ }
101
+ function validateDiceSkin(skin, ctx = {}) {
102
+ const out = [];
103
+ for (const err of DiceSkin.validate(DiceSkin.serialize(skin))) out.push(err);
104
+ const family = findDiceFamily(skin.family);
105
+ if (!family) {
106
+ out.push(
107
+ new ShardError(
108
+ "dice/unknown-family",
109
+ `Skin "${skin.id}" names family "${skin.family}", which isn't defined`,
110
+ {
111
+ path: "/family",
112
+ hint: "Families: dice/SolidDice, dice/ResinDice, dice/MetalDice, dice/GlassDice, and those the host defines with defineDiceFamily."
113
+ }
114
+ )
115
+ );
116
+ } else {
117
+ checkParams(skin.family, skin.params, "/params", out);
118
+ }
119
+ for (const kind of DIE_KINDS) {
120
+ const variant = skin.variants[kind];
121
+ if (family) checkParams(skin.family, variant.params, `/variants/${kind}/params`, out);
122
+ if (variant.layout) {
123
+ const layout = ctx.layout?.(variant.layout);
124
+ if (!layout) {
125
+ out.push(
126
+ new ShardError(
127
+ "dice/unknown-layout",
128
+ `Skin "${skin.id}": the ${kind} layout isn't loaded`,
129
+ {
130
+ path: `/variants/${kind}/layout`
131
+ }
132
+ )
133
+ );
134
+ continue;
135
+ }
136
+ for (const id of KIND_DEFINITIONS[kind]) {
137
+ for (const err of layoutProblems(layout, requireDie(id))) {
138
+ out.push(
139
+ new ShardError(err.code, err.message, {
140
+ path: `/variants/${kind}/layout${err.path ?? ""}`,
141
+ hint: err.hint
142
+ })
143
+ );
144
+ }
145
+ }
146
+ }
147
+ }
148
+ if (skin.effects.length > MAX_SKIN_EFFECTS) {
149
+ out.push(
150
+ new ShardError(
151
+ "dice/recipe-bounds",
152
+ `Skin "${skin.id}" has ${skin.effects.length} effect recipes; at most ${MAX_SKIN_EFFECTS}`,
153
+ {
154
+ path: "/effects"
155
+ }
156
+ )
157
+ );
158
+ }
159
+ skin.effects.forEach((ref, i) => {
160
+ const recipe = ref ? ctx.recipe?.(ref) : void 0;
161
+ if (!recipe) {
162
+ out.push(
163
+ new ShardError(
164
+ "dice/unknown-recipe",
165
+ `Skin "${skin.id}": effect recipe ${i} isn't loaded`,
166
+ {
167
+ path: `/effects/${i}`
168
+ }
169
+ )
170
+ );
171
+ return;
172
+ }
173
+ for (const err of recipeProblems(recipe)) {
174
+ out.push(
175
+ new ShardError(err.code, err.message, {
176
+ path: `/effects/${i}${err.path ?? ""}`,
177
+ hint: err.hint
178
+ })
179
+ );
180
+ }
181
+ });
182
+ return out;
183
+ }
184
+ function diceSkin(json) {
185
+ return DiceSkin.deserialize(json);
186
+ }
187
+ function skinVariant(skin, kind, definition, layouts) {
188
+ const variant = skin.variants[kind];
189
+ const layout = (variant.layout ? layouts(variant.layout) : void 0) ?? defaultLayoutFor(requireDie(definition));
190
+ return {
191
+ layout,
192
+ bevel: variant.bevel > 0 ? variant.bevel : void 0,
193
+ params: { ...skin.params, ...variant.params }
194
+ };
195
+ }
196
+ const skinRef = (id) => ({
197
+ type: "dice/DiceSkin",
198
+ guid: `dice:skin/${id}`,
199
+ path: `dice:skin/${id}`
200
+ });
201
+ const DICE_SKINS = {
202
+ ivory: skinRef("ivory"),
203
+ obsidian: skinRef("obsidian"),
204
+ teal: skinRef("teal"),
205
+ ember: skinRef("ember"),
206
+ brass: skinRef("brass"),
207
+ frost: skinRef("frost")
208
+ };
209
+ const BUILTIN_SKINS = {
210
+ ivory: {
211
+ id: "ivory",
212
+ family: "dice/SolidDice",
213
+ params: {
214
+ baseColor: "#efe6d2",
215
+ roughness: 0.42,
216
+ markColor: "#1f1a14",
217
+ markRoughness: 0.7,
218
+ edgeColor: "#fff8ea",
219
+ edgeMix: 0.35,
220
+ fleck: 0.18,
221
+ fleckColor: "#c9b89a"
222
+ },
223
+ sounds: { impact: "resin" }
224
+ },
225
+ obsidian: {
226
+ id: "obsidian",
227
+ family: "dice/SolidDice",
228
+ params: {
229
+ baseColor: "#141318",
230
+ roughness: 0.2,
231
+ markColor: "#d9b45a",
232
+ markMetallic: 1,
233
+ markRoughness: 0.28,
234
+ markDepth: 0.8,
235
+ edgeColor: "#3a3642",
236
+ edgeMix: 0.4,
237
+ fleck: 0.3,
238
+ fleckColor: "#5b5470"
239
+ },
240
+ sounds: { impact: "resin" }
241
+ },
242
+ teal: {
243
+ id: "teal",
244
+ family: "dice/ResinDice",
245
+ params: {
246
+ baseColor: "#168c8c",
247
+ roughness: 0.18,
248
+ markColor: "#f4fff9",
249
+ markRoughness: 0.5,
250
+ edgeColor: "#37d8c9",
251
+ edgeMix: 0.25,
252
+ resinDeep: "#073f44",
253
+ resinGlow: "#37d8c9"
254
+ },
255
+ sounds: { impact: "resin", accent: "resin-chime" }
256
+ },
257
+ ember: {
258
+ id: "ember",
259
+ family: "dice/ResinDice",
260
+ params: {
261
+ baseColor: "#b8321a",
262
+ roughness: 0.2,
263
+ markColor: "#fff1c9",
264
+ markEmissive: 900,
265
+ edgeColor: "#ff9a3c",
266
+ edgeMix: 0.3,
267
+ resinDeep: "#3a0703",
268
+ resinGlow: "#ff8a2a",
269
+ resinLight: 5200,
270
+ resinSwirl: 0.75
271
+ },
272
+ sounds: { impact: "resin", accent: "arcane-spark" }
273
+ },
274
+ brass: {
275
+ id: "brass",
276
+ family: "dice/MetalDice",
277
+ params: {
278
+ baseColor: "#d7a64a",
279
+ roughness: 0.32,
280
+ markColor: "#1b120a",
281
+ markRoughness: 0.6,
282
+ markDepth: 0.9,
283
+ edgeColor: "#f6d68c",
284
+ edgeMix: 0.2
285
+ },
286
+ sounds: { impact: "metal" }
287
+ },
288
+ frost: {
289
+ id: "frost",
290
+ family: "dice/GlassDice",
291
+ params: {
292
+ baseColor: "#9fd6ff",
293
+ roughness: 0.05,
294
+ markColor: "#ffffff",
295
+ markEmissive: 400,
296
+ markDepth: 0.3,
297
+ edgeColor: "#e6f6ff",
298
+ edgeMix: 0.5,
299
+ glassClarity: 0.82
300
+ },
301
+ sounds: { impact: "glass", accent: "resin-chime" }
302
+ }
303
+ };
304
+ export {
305
+ BUILTIN_SKINS,
306
+ DICE_SKINS,
307
+ DiceSkin,
308
+ IMPACT_SOUNDS,
309
+ MAX_SKIN_EFFECTS,
310
+ diceSkin,
311
+ skinVariant,
312
+ validateDiceSkin
313
+ };
@@ -0,0 +1,33 @@
1
+ import { type AudioClipAsset } from '@aethervtt/shard-audio';
2
+ import type { ImpactSound } from './skin.js';
3
+ /** Mono 16-bit PCM WAV. */
4
+ export declare function wavBytes(samples: Float32Array, rate?: number): Uint8Array;
5
+ /** Strength layers baked per material: soft, medium, hard. */
6
+ export declare const IMPACT_LAYERS = 3;
7
+ /** Variations baked per layer. */
8
+ export declare const IMPACT_VARIATIONS = 6;
9
+ /** The strength layer (0 soft, 1 medium, 2 hard) for a contact's strength (0..1). */
10
+ export declare function impactLayer(strength: number): number;
11
+ /**
12
+ * One impact clip's samples: a material, on the tray or die-to-die, a strength layer (0 soft to 2
13
+ * hard) and a variation. Die-to-die rings two bodies and has no knock.
14
+ */
15
+ export declare function impactSamples(sound: ImpactSound, category: 'tray' | 'dice', layer: number, variation: number): Float32Array;
16
+ export declare const ACCENT_CUES: readonly ['resin-chime', 'arcane-spark', 'void-whump'];
17
+ export type AccentCue = (typeof ACCENT_CUES)[number];
18
+ /** An accent cue: two partials (chimes and sparks), or a falling sine with breath (the whump). */
19
+ export declare function accentSamples(cue: AccentCue): Float32Array;
20
+ /**
21
+ * Impact clips per material (layers × variations, tray and die-to-die) and accent cues, baked on
22
+ * first use. `prepare` bakes a material's impacts ahead of a roll, so the tumble only plays them.
23
+ */
24
+ export declare class DiceSoundBank {
25
+ private readonly impacts;
26
+ private readonly accents;
27
+ impact(sound: ImpactSound, category: 'tray' | 'dice', layer: number, variation: number): AudioClipAsset;
28
+ /** Every impact clip of a material, baked now; `each` sees each (to preload it). */
29
+ prepare(sound: ImpactSound, each?: (clip: AudioClipAsset) => void): void;
30
+ accent(cue: AccentCue): AudioClipAsset;
31
+ }
32
+ /** How strong a contact of this force is, 0..1 (Aether's curve). */
33
+ export declare function impactStrength(force: number): number;
package/dist/sound.js ADDED
@@ -0,0 +1,296 @@
1
+ import { audioClip } from "@aethervtt/shard-audio";
2
+ const RATE = 32e3;
3
+ function wavBytes(samples, rate = RATE) {
4
+ const bytes = new Uint8Array(44 + samples.length * 2);
5
+ const view = new DataView(bytes.buffer);
6
+ const text = (o, s) => {
7
+ for (let i = 0; i < s.length; i++) bytes[o + i] = s.charCodeAt(i);
8
+ };
9
+ text(0, "RIFF");
10
+ view.setUint32(4, 36 + samples.length * 2, true);
11
+ text(8, "WAVE");
12
+ text(12, "fmt ");
13
+ view.setUint32(16, 16, true);
14
+ view.setUint16(20, 1, true);
15
+ view.setUint16(22, 1, true);
16
+ view.setUint32(24, rate, true);
17
+ view.setUint32(28, rate * 2, true);
18
+ view.setUint16(32, 2, true);
19
+ view.setUint16(34, 16, true);
20
+ text(36, "data");
21
+ view.setUint32(40, samples.length * 2, true);
22
+ for (let i = 0; i < samples.length; i++) {
23
+ const s = Math.max(-1, Math.min(1, samples[i]));
24
+ view.setInt16(44 + i * 2, Math.round(s * 32767), true);
25
+ }
26
+ return bytes;
27
+ }
28
+ function noise(seed, n) {
29
+ const out = new Float32Array(n);
30
+ let state = seed | 1;
31
+ for (let i = 0; i < n; i++) {
32
+ state ^= state << 13;
33
+ state ^= state >>> 17;
34
+ state ^= state << 5;
35
+ out[i] = (state >>> 0) / 2147483648 - 1;
36
+ }
37
+ return out;
38
+ }
39
+ function jitter(seed) {
40
+ let state = seed | 1;
41
+ return () => {
42
+ state ^= state << 13;
43
+ state ^= state >>> 17;
44
+ state ^= state << 5;
45
+ return (state >>> 0) / 4294967296;
46
+ };
47
+ }
48
+ function biquad(x, kind, freq, q) {
49
+ const w = 2 * Math.PI * Math.min(freq, RATE * 0.45) / RATE;
50
+ const alpha = Math.sin(w) / (2 * q);
51
+ const cos = Math.cos(w);
52
+ let b0;
53
+ let b1;
54
+ let b2;
55
+ if (kind === "low") {
56
+ b0 = (1 - cos) / 2;
57
+ b1 = 1 - cos;
58
+ b2 = (1 - cos) / 2;
59
+ } else {
60
+ b0 = alpha;
61
+ b1 = 0;
62
+ b2 = -alpha;
63
+ }
64
+ const a0 = 1 + alpha;
65
+ const a1 = -2 * cos;
66
+ const a2 = 1 - alpha;
67
+ let x1 = 0;
68
+ let x2 = 0;
69
+ let y1 = 0;
70
+ let y2 = 0;
71
+ for (let i = 0; i < x.length; i++) {
72
+ const x0 = x[i];
73
+ const y0 = (b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0;
74
+ x2 = x1;
75
+ x1 = x0;
76
+ y2 = y1;
77
+ y1 = y0;
78
+ x[i] = y0;
79
+ }
80
+ }
81
+ function damped(x, hz, decay, amp, phase) {
82
+ const w = 2 * Math.PI * Math.min(hz, RATE * 0.45) / RATE;
83
+ const r = Math.exp(-1 / (decay * RATE));
84
+ const c = 2 * r * Math.cos(w);
85
+ const r2 = r * r;
86
+ let y2 = amp * Math.sin(phase);
87
+ let y1 = amp * r * Math.sin(w + phase);
88
+ x[0] = x[0] + y2;
89
+ if (x.length > 1) x[1] = x[1] + y1;
90
+ for (let i = 2; i < x.length; i++) {
91
+ const y = c * y1 - r2 * y2;
92
+ x[i] = x[i] + y;
93
+ y2 = y1;
94
+ y1 = y;
95
+ }
96
+ }
97
+ function normalizePeak(x, peak = 0.9) {
98
+ let m = 0;
99
+ for (let i = 0; i < x.length; i++) m = Math.max(m, Math.abs(x[i]));
100
+ if (m > 0) for (let i = 0; i < x.length; i++) x[i] = x[i] / m * peak;
101
+ return x;
102
+ }
103
+ const IMPACTS = {
104
+ resin: {
105
+ modes: [
106
+ [3150, 0.016, 1],
107
+ [4870, 0.011, 0.7],
108
+ [6420, 8e-3, 0.5],
109
+ [8350, 6e-3, 0.32]
110
+ ],
111
+ clack: { hz: 3400, q: 0.9, decay: 35e-4, level: 0.55 },
112
+ knock: [165, 0.014, 0.5],
113
+ contact: 8e-5,
114
+ seconds: 0.14
115
+ },
116
+ wood: {
117
+ modes: [
118
+ [1650, 9e-3, 1],
119
+ [2710, 7e-3, 0.6],
120
+ [4050, 5e-3, 0.4]
121
+ ],
122
+ clack: { hz: 2100, q: 0.8, decay: 4e-3, level: 0.7 },
123
+ knock: [140, 0.016, 0.6],
124
+ contact: 12e-5,
125
+ seconds: 0.12
126
+ },
127
+ metal: {
128
+ modes: [
129
+ [5230, 0.19, 1],
130
+ [7940, 0.13, 0.6],
131
+ [9870, 0.085, 0.45],
132
+ [12260, 0.055, 0.3]
133
+ ],
134
+ clack: { hz: 5200, q: 1.4, decay: 2e-3, level: 0.4 },
135
+ knock: [180, 0.011, 0.35],
136
+ contact: 5e-5,
137
+ seconds: 0.45
138
+ },
139
+ glass: {
140
+ modes: [
141
+ [4310, 0.07, 1],
142
+ [6480, 0.05, 0.62],
143
+ [8870, 0.034, 0.42],
144
+ [11240, 0.022, 0.28]
145
+ ],
146
+ clack: { hz: 4600, q: 1.8, decay: 18e-4, level: 0.38 },
147
+ knock: [170, 0.011, 0.35],
148
+ contact: 6e-5,
149
+ seconds: 0.32
150
+ }
151
+ };
152
+ const IMPACT_LAYERS = 3;
153
+ const IMPACT_VARIATIONS = 6;
154
+ const CONTACT = [4.4, 2, 1];
155
+ const CLACK = [0.5, 0.8, 1];
156
+ const KNOCK = [1.3, 1, 0.85];
157
+ const BRIGHT = [0.6, 0.85, 1];
158
+ function impactLayer(strength) {
159
+ return strength < 0.33 ? 0 : strength < 0.66 ? 1 : 2;
160
+ }
161
+ function impactSamples(sound, category, layer, variation) {
162
+ const r = IMPACTS[sound];
163
+ const n = Math.round(r.seconds * RATE);
164
+ const x = new Float32Array(n);
165
+ const seed = 5370206 + variation * 7919 + layer * 104729 + (category === "dice" ? 1299709 : 0);
166
+ const rand = jitter(seed);
167
+ const contact = r.contact * CONTACT[layer];
168
+ const through = (hz) => 1 / (1 + (2 * hz * contact) ** 2);
169
+ const bodies = category === "dice" ? 2 : 1;
170
+ for (let body = 0; body < bodies; body++) {
171
+ const shift = body === 0 ? 1 : 1.07;
172
+ for (const [hz, decay, level] of r.modes) {
173
+ const f = hz * shift * (0.9 + rand() * 0.2);
174
+ const a = level * (0.75 + rand() * 0.5) * through(f) * (body === 0 ? 1 : 0.8);
175
+ damped(x, f, decay * (0.85 + rand() * 0.3), a, rand() * Math.PI * 2);
176
+ }
177
+ }
178
+ const clack = noise(seed ^ 2654435769, n);
179
+ const k = Math.exp(-1 / (r.clack.decay * RATE));
180
+ let env = r.clack.level * CLACK[layer];
181
+ for (let i = 0; i < n; i++) {
182
+ clack[i] = clack[i] * env;
183
+ env *= k;
184
+ }
185
+ biquad(
186
+ clack,
187
+ "band",
188
+ r.clack.hz * (0.9 + rand() * 0.2) * (category === "dice" ? 1.25 : 1) * BRIGHT[layer],
189
+ r.clack.q
190
+ );
191
+ for (let i = 0; i < n; i++) x[i] = x[i] + clack[i];
192
+ if (category === "tray") {
193
+ const [hz, decay, level] = r.knock;
194
+ damped(x, hz * (0.92 + rand() * 0.16), decay, level * KNOCK[layer], 0);
195
+ const felt = noise(seed ^ 2246822507, n);
196
+ const kf = Math.exp(-1 / (8e-3 * RATE));
197
+ let ef = 0.3 * KNOCK[layer];
198
+ for (let i = 0; i < n; i++) {
199
+ felt[i] = felt[i] * ef;
200
+ ef *= kf;
201
+ }
202
+ biquad(felt, "low", 450, 0.7);
203
+ for (let i = 0; i < n; i++) x[i] = x[i] + felt[i];
204
+ }
205
+ const fade = Math.round(4e-3 * RATE);
206
+ let dc = 0;
207
+ let prev = 0;
208
+ const hp = Math.exp(-2 * Math.PI * 30 / RATE);
209
+ for (let i = 0; i < n; i++) {
210
+ const t = i / RATE;
211
+ const v = x[i] * (1 - Math.exp(-t / contact)) * Math.min(1, (n - 1 - i) / fade);
212
+ dc = v - prev + hp * dc;
213
+ prev = v;
214
+ x[i] = dc;
215
+ }
216
+ return normalizePeak(x);
217
+ }
218
+ const ACCENT_CUES = ["resin-chime", "arcane-spark", "void-whump"];
219
+ function accentSamples(cue) {
220
+ const whump = cue === "void-whump";
221
+ const seconds = whump ? 0.5 : 0.36;
222
+ const n = Math.round(seconds * RATE);
223
+ const x = new Float32Array(n);
224
+ if (whump) {
225
+ let phase = 0;
226
+ const breath = noise(24301, n);
227
+ biquad(breath, "low", 380, 0.7);
228
+ for (let i = 0; i < n; i++) {
229
+ const t = i / RATE;
230
+ const hz = 185 * (48 / 185) ** Math.min(1, t / 0.42);
231
+ phase += 2 * Math.PI * hz / RATE;
232
+ const env = Math.min(1, t / 0.016) * Math.exp(-t / 0.13);
233
+ x[i] = (Math.sin(phase) + 0.5 * breath[i]) * env;
234
+ }
235
+ } else {
236
+ const [a, b] = cue === "resin-chime" ? [740, 1110] : [980, 1470];
237
+ for (let i = 0; i < n; i++) {
238
+ const t = i / RATE;
239
+ const env = Math.min(1, t / 0.012) * Math.exp(-t / 0.09);
240
+ const late = t > 0.018 ? Math.exp(-(t - 0.018) / 0.09) : 0;
241
+ const tri = 2 / Math.PI * Math.asin(Math.sin(2 * Math.PI * b * t));
242
+ x[i] = Math.sin(2 * Math.PI * a * t) * env + 0.6 * tri * late;
243
+ }
244
+ }
245
+ return normalizePeak(x, 0.8);
246
+ }
247
+ class DiceSoundBank {
248
+ impacts = /* @__PURE__ */ new Map();
249
+ accents = /* @__PURE__ */ new Map();
250
+ impact(sound, category, layer, variation) {
251
+ const key = `${sound}:${category}:${layer}:${variation}`;
252
+ let clip = this.impacts.get(key);
253
+ if (!clip) {
254
+ clip = audioClip(wavBytes(impactSamples(sound, category, layer, variation)), {
255
+ id: `dice:impact/${key}`
256
+ });
257
+ this.impacts.set(key, clip);
258
+ }
259
+ return clip;
260
+ }
261
+ /** Every impact clip of a material, baked now; `each` sees each (to preload it). */
262
+ prepare(sound, each) {
263
+ for (const category of ["tray", "dice"]) {
264
+ for (let layer = 0; layer < IMPACT_LAYERS; layer++) {
265
+ for (let v = 0; v < IMPACT_VARIATIONS; v++) {
266
+ const key = `${sound}:${category}:${layer}:${v}`;
267
+ const fresh = !this.impacts.has(key);
268
+ const clip = this.impact(sound, category, layer, v);
269
+ if (fresh) each?.(clip);
270
+ }
271
+ }
272
+ }
273
+ }
274
+ accent(cue) {
275
+ let clip = this.accents.get(cue);
276
+ if (!clip) {
277
+ clip = audioClip(wavBytes(accentSamples(cue)), { id: `dice:accent/${cue}` });
278
+ this.accents.set(cue, clip);
279
+ }
280
+ return clip;
281
+ }
282
+ }
283
+ function impactStrength(force) {
284
+ return Math.min(1, Math.log1p(Math.max(0, force)) / 5.4);
285
+ }
286
+ export {
287
+ ACCENT_CUES,
288
+ DiceSoundBank,
289
+ IMPACT_LAYERS,
290
+ IMPACT_VARIATIONS,
291
+ accentSamples,
292
+ impactLayer,
293
+ impactSamples,
294
+ impactStrength,
295
+ wavBytes
296
+ };
@@ -0,0 +1,3 @@
1
+ import { Texture } from '@aethervtt/shard-texture';
2
+ /** A 256 × 128 rgba16float equirectangular studio: EnvironmentMap texture (usage 'hdr'). */
3
+ export declare function studioEnvironment(): Texture;
package/dist/studio.js ADDED
@@ -0,0 +1,48 @@
1
+ import { Texture, toHalf } from "@aethervtt/shard-texture";
2
+ const BOXES = [
3
+ { azimuth: -35, elevation: 58, width: 26, height: 16, intensity: 7, color: [1, 0.97, 0.92] },
4
+ { azimuth: 140, elevation: 30, width: 34, height: 6, intensity: 3.2, color: [0.85, 0.92, 1] },
5
+ { azimuth: 60, elevation: 12, width: 10, height: 10, intensity: 1.6, color: [1, 0.9, 0.8] }
6
+ ];
7
+ function studioEnvironment() {
8
+ const width = 256;
9
+ const height = 128;
10
+ const f = new Float32Array(width * height * 4);
11
+ for (let y = 0; y < height; y++) {
12
+ const elevation = 90 - (y + 0.5) / height * 180;
13
+ const up = Math.sin(elevation * Math.PI / 180);
14
+ const sky = up > 0 ? 0.35 + 0.45 * up : 0.12 + 0.2 * (1 + up);
15
+ for (let x = 0; x < width; x++) {
16
+ const azimuth = (x + 0.5) / width * 360 - 180;
17
+ let r = sky * 0.96;
18
+ let g = sky * 0.98;
19
+ let b = sky;
20
+ for (const box of BOXES) {
21
+ let da = Math.abs(azimuth - box.azimuth);
22
+ if (da > 180) da = 360 - da;
23
+ const dx = da / box.width;
24
+ const dy = Math.abs(elevation - box.elevation) / box.height;
25
+ const k = Math.max(0, 1 - Math.max(dx, dy) ** 6);
26
+ if (k <= 0) continue;
27
+ r += box.color[0] * box.intensity * k;
28
+ g += box.color[1] * box.intensity * k;
29
+ b += box.color[2] * box.intensity * k;
30
+ }
31
+ const o = (y * width + x) * 4;
32
+ f[o] = r;
33
+ f[o + 1] = g;
34
+ f[o + 2] = b;
35
+ f[o + 3] = 1;
36
+ }
37
+ }
38
+ return Texture.create({
39
+ width,
40
+ height,
41
+ format: "rgba16float",
42
+ usage: "hdr",
43
+ mips: [new Uint8Array(toHalf(f).buffer)]
44
+ });
45
+ }
46
+ export {
47
+ studioEnvironment
48
+ };