@aethervtt/shard-dice 0.0.0-stage → 0.1.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 (78) 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/builtins.d.ts +25 -0
  6. package/dist/builtins.js +335 -0
  7. package/dist/cells.d.ts +33 -0
  8. package/dist/cells.js +53 -0
  9. package/dist/components.d.ts +14 -0
  10. package/dist/components.js +27 -0
  11. package/dist/definition-asset.d.ts +19 -0
  12. package/dist/definition-asset.js +60 -0
  13. package/dist/definition.d.ts +98 -0
  14. package/dist/definition.js +320 -0
  15. package/dist/dice-worker.d.ts +1 -0
  16. package/dist/dice-worker.js +3 -0
  17. package/dist/effects.d.ts +97 -0
  18. package/dist/effects.js +196 -0
  19. package/dist/entrances.d.ts +48 -0
  20. package/dist/entrances.js +54 -0
  21. package/dist/families.d.ts +17 -0
  22. package/dist/families.js +131 -0
  23. package/dist/glyphs.d.ts +29 -0
  24. package/dist/glyphs.js +270 -0
  25. package/dist/golden.json +17 -0
  26. package/dist/hull.d.ts +32 -0
  27. package/dist/hull.js +154 -0
  28. package/dist/index.d.ts +31 -0
  29. package/dist/index.js +322 -0
  30. package/dist/landing.d.ts +29 -0
  31. package/dist/landing.js +93 -0
  32. package/dist/layout.d.ts +75 -0
  33. package/dist/layout.js +137 -0
  34. package/dist/marks.d.ts +54 -0
  35. package/dist/marks.js +543 -0
  36. package/dist/material.d.ts +80 -0
  37. package/dist/material.js +343 -0
  38. package/dist/math.d.ts +39 -0
  39. package/dist/math.js +136 -0
  40. package/dist/mesh.d.ts +9 -0
  41. package/dist/mesh.js +105 -0
  42. package/dist/methods.d.ts +2 -0
  43. package/dist/methods.js +42 -0
  44. package/dist/numerals.d.ts +9 -0
  45. package/dist/numerals.js +47 -0
  46. package/dist/plugin.d.ts +41 -0
  47. package/dist/plugin.js +174 -0
  48. package/dist/preview.d.ts +32 -0
  49. package/dist/preview.js +123 -0
  50. package/dist/quality.d.ts +12 -0
  51. package/dist/quality.js +18 -0
  52. package/dist/resources.d.ts +85 -0
  53. package/dist/resources.js +191 -0
  54. package/dist/roll.d.ts +68 -0
  55. package/dist/roll.js +97 -0
  56. package/dist/settle.d.ts +53 -0
  57. package/dist/settle.js +96 -0
  58. package/dist/skin.d.ts +61 -0
  59. package/dist/skin.js +303 -0
  60. package/dist/sound.d.ts +33 -0
  61. package/dist/sound.js +296 -0
  62. package/dist/studio.d.ts +3 -0
  63. package/dist/studio.js +48 -0
  64. package/dist/table.d.ts +225 -0
  65. package/dist/table.js +1757 -0
  66. package/dist/test-worker.d.ts +1 -0
  67. package/dist/test-worker.js +12 -0
  68. package/dist/track.d.ts +70 -0
  69. package/dist/track.js +304 -0
  70. package/dist/tray.d.ts +2 -0
  71. package/dist/tray.js +43 -0
  72. package/dist/variants.d.ts +9 -0
  73. package/dist/variants.js +46 -0
  74. package/dist/windows.d.ts +25 -0
  75. package/dist/windows.js +77 -0
  76. package/dist/worker.d.ts +6 -0
  77. package/dist/worker.js +9 -0
  78. package/package.json +47 -4
@@ -0,0 +1,196 @@
1
+ import { defineDataType } from "@aethervtt/shard-assets";
2
+ import { ShardError, t } from "@aethervtt/shard-core";
3
+ import { DIE_KINDS } from "./builtins.js";
4
+ const CONDITION = t.struct(
5
+ {
6
+ kind: t.enum(["die", "total", "source", "tag"], {
7
+ description: "die: a die of the roll matches; total: the roll total compares; source: who rolled; tag: a host tag."
8
+ }),
9
+ die: t.enum(["any", ...DIE_KINDS], { description: "die: the kind (any)." }),
10
+ value: t.u16({ description: "die: the value (0: any)." }),
11
+ face: t.string({ description: "die: the printed label on top ('00'); empty: any." }),
12
+ state: t.enum(["any", "kept", "dropped"], { description: "die: kept or dropped." }),
13
+ rank: t.enum(["any", "high", "low"], { description: "die: flagged high or low by the host." }),
14
+ compare: t.enum(["eq", "gte", "lte"], { description: "total: how it compares." }),
15
+ total: t.f32({ description: "total: the value compared with." }),
16
+ source: t.enum(["mine", "gm", "other"], { description: "source: who rolled." }),
17
+ tag: t.string({ description: "tag: a host semantic tag." })
18
+ },
19
+ { description: "One condition." }
20
+ );
21
+ const EFFECT = t.struct(
22
+ {
23
+ kind: t.enum(
24
+ ["light-pulse", "particle-burst", "sound-accent", "lens-pulse", "attachment", "entrance"],
25
+ {
26
+ description: "light-pulse: a point light swells and fades; particle-burst: CPU particles; sound-accent: a cue; lens-pulse: a lens field (0063) on the die; attachment: a registered scene on each matching die; entrance: a registered scene brings each matching die in instead of the tumble (0065)."
27
+ }
28
+ ),
29
+ color: t.color({ default: [1, 0.82, 0.45, 1], description: "light-pulse: color." }),
30
+ colors: t.list(t.color(), { description: "particle-burst: 1 to 4 colors." }),
31
+ intensity: t.f32({ default: 2, min: 0, max: 8, description: "light-pulse: 0..8." }),
32
+ count: t.u8({ default: 24, description: "particle-burst: particles, at most 32." }),
33
+ durationMs: t.u16({
34
+ default: 700,
35
+ description: "light-pulse, particle-burst, lens-pulse: 100..1200 ms."
36
+ }),
37
+ cue: t.enum(["resin-chime", "arcane-spark", "void-whump"], {
38
+ description: "sound-accent: the cue."
39
+ }),
40
+ gain: t.f32({ default: 0.7, min: 0, max: 1, description: "sound-accent: 0..1." }),
41
+ radius: t.f32({
42
+ default: 150,
43
+ min: 0,
44
+ unit: "px",
45
+ description: "lens-pulse: field radius (CSS px)."
46
+ }),
47
+ strength: t.f32({
48
+ default: -0.6,
49
+ min: -1,
50
+ max: 1,
51
+ description: "lens-pulse: below 0 pulls in."
52
+ }),
53
+ attachment: t.string({ description: "attachment: the name given to defineDiceAttachment." }),
54
+ entrance: t.string({ description: "entrance: the name given to defineDiceEntrance." }),
55
+ params: t.json({
56
+ default: {},
57
+ description: "attachment, entrance: what the scene reads (at most 16 keys), so skins share one."
58
+ })
59
+ },
60
+ { description: "One effect." }
61
+ );
62
+ const DiceEffectRecipe = defineDataType(
63
+ "dice/DiceEffectRecipe",
64
+ {
65
+ version: t.u8({ default: 1, min: 1, max: 1, description: "Format version: 1." }),
66
+ id: t.string({ description: "Unique per recipe; the same recipe on two skins plays once." }),
67
+ match: t.enum(["all", "any"], {
68
+ description: "Every condition, or any of them. No conditions: always."
69
+ }),
70
+ conditions: t.list(CONDITION, { description: "At most 8." }),
71
+ effects: t.list(EFFECT, { description: "1 to 3." })
72
+ },
73
+ {
74
+ extension: "dice-effect",
75
+ description: "Effects a skin plays when a roll matches its conditions."
76
+ }
77
+ );
78
+ const RECIPE_LIMITS = {
79
+ conditions: 8,
80
+ effects: 3,
81
+ particles: 32,
82
+ colors: 4,
83
+ minDurationMs: 100,
84
+ maxDurationMs: 1200,
85
+ recipesPerRoll: 4,
86
+ params: 16
87
+ };
88
+ function diceEffectRecipe(json) {
89
+ return DiceEffectRecipe.deserialize(json);
90
+ }
91
+ function bounds(message, path) {
92
+ return new ShardError("dice/recipe-bounds", message, {
93
+ path,
94
+ hint: `Recipes hold at most ${RECIPE_LIMITS.conditions} conditions and ${RECIPE_LIMITS.effects} effects; bursts ${RECIPE_LIMITS.particles} particles; durations ${RECIPE_LIMITS.minDurationMs}\u2013${RECIPE_LIMITS.maxDurationMs} ms; scene params ${RECIPE_LIMITS.params} keys.`
95
+ });
96
+ }
97
+ function recipeProblems(recipe) {
98
+ const out = [];
99
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(recipe.id))
100
+ out.push(
101
+ new ShardError("dice/invalid-recipe", `Recipe id "${recipe.id}" isn't a lowercase id`, {
102
+ path: "/id"
103
+ })
104
+ );
105
+ if (recipe.conditions.length > RECIPE_LIMITS.conditions)
106
+ out.push(
107
+ bounds(`Recipe "${recipe.id}" has ${recipe.conditions.length} conditions`, "/conditions")
108
+ );
109
+ if (recipe.effects.length < 1 || recipe.effects.length > RECIPE_LIMITS.effects)
110
+ out.push(bounds(`Recipe "${recipe.id}" has ${recipe.effects.length} effects`, "/effects"));
111
+ recipe.effects.forEach((e, i) => {
112
+ const path = `/effects/${i}`;
113
+ if (e.kind === "particle-burst") {
114
+ if (e.count < 1 || e.count > RECIPE_LIMITS.particles)
115
+ out.push(bounds(`A burst of ${e.count} particles`, `${path}/count`));
116
+ if (e.colors.length < 1 || e.colors.length > RECIPE_LIMITS.colors)
117
+ out.push(bounds(`A burst with ${e.colors.length} colors`, `${path}/colors`));
118
+ }
119
+ if (e.kind === "light-pulse" || e.kind === "particle-burst" || e.kind === "lens-pulse") {
120
+ if (e.durationMs < RECIPE_LIMITS.minDurationMs || e.durationMs > RECIPE_LIMITS.maxDurationMs)
121
+ out.push(bounds(`A ${e.durationMs} ms ${e.kind}`, `${path}/durationMs`));
122
+ }
123
+ if (e.kind === "attachment" && !e.attachment)
124
+ out.push(
125
+ new ShardError("dice/invalid-recipe", "An attachment effect names no attachment", {
126
+ path: `${path}/attachment`
127
+ })
128
+ );
129
+ if (e.kind === "entrance" && !e.entrance)
130
+ out.push(
131
+ new ShardError("dice/invalid-recipe", "An entrance effect names no entrance", {
132
+ path: `${path}/entrance`
133
+ })
134
+ );
135
+ const params = e.params;
136
+ if (params && typeof params === "object" && Object.keys(params).length > RECIPE_LIMITS.params)
137
+ out.push(bounds(`Params with ${Object.keys(params).length} keys`, `${path}/params`));
138
+ });
139
+ return out;
140
+ }
141
+ function dieMatches(c, d) {
142
+ if (c.die !== "any" && c.die !== d.kind) return false;
143
+ if (c.value !== 0 && c.value !== d.rolled) return false;
144
+ if (c.face && c.face !== d.label) return false;
145
+ if (c.state === "kept" && d.dropped) return false;
146
+ if (c.state === "dropped" && !d.dropped) return false;
147
+ if (c.rank === "high" && !d.high) return false;
148
+ if (c.rank === "low" && !d.low) return false;
149
+ return true;
150
+ }
151
+ function conditionMatches(c, roll, dice) {
152
+ switch (c.kind) {
153
+ case "total":
154
+ if (roll.total === void 0) return false;
155
+ return c.compare === "eq" ? roll.total === c.total : c.compare === "gte" ? roll.total >= c.total : roll.total <= c.total;
156
+ case "source": {
157
+ if (!roll.source) return false;
158
+ const who = roll.source.mine ? "mine" : roll.source.gm ? "gm" : "other";
159
+ return who === c.source;
160
+ }
161
+ case "tag":
162
+ return roll.tags?.includes(c.tag) ?? false;
163
+ default:
164
+ return dice.some((d) => dieMatches(c, d));
165
+ }
166
+ }
167
+ function matchRecipes(recipes, roll, dice) {
168
+ const unique = /* @__PURE__ */ new Map();
169
+ for (const r of recipes) if (!unique.has(r.id)) unique.set(r.id, r);
170
+ const matched = [];
171
+ let degraded = false;
172
+ for (const recipe of unique.values()) {
173
+ const cs = recipe.conditions;
174
+ const ok = cs.length === 0 || (recipe.match === "all" ? cs.every((c) => conditionMatches(c, roll, dice)) : cs.some((c) => conditionMatches(c, roll, dice)));
175
+ if (!ok) continue;
176
+ if (matched.length >= RECIPE_LIMITS.recipesPerRoll) {
177
+ degraded = true;
178
+ continue;
179
+ }
180
+ const dieConditions = cs.filter((c) => c.kind === "die");
181
+ const anchors = [];
182
+ dice.forEach((d, i) => {
183
+ if (d.dropped) return;
184
+ if (dieConditions.length === 0 || dieConditions.some((c) => dieMatches(c, d))) anchors.push(i);
185
+ });
186
+ matched.push({ recipe, anchors });
187
+ }
188
+ return { matched, degraded };
189
+ }
190
+ export {
191
+ DiceEffectRecipe,
192
+ RECIPE_LIMITS,
193
+ diceEffectRecipe,
194
+ matchRecipes,
195
+ recipeProblems
196
+ };
@@ -0,0 +1,48 @@
1
+ import { type AssetRef, type Entity } from '@aethervtt/shard-core';
2
+ import { type DiceSceneContext } from './attachments.js';
3
+ /** Entrance dice in one roll; more matches tumble. */
4
+ export declare const MAX_ENTRANCES = 2;
5
+ /** What an entrance's scene can reach and do: a scene's context, and the die it brings in. */
6
+ export interface DiceEntranceContext extends DiceSceneContext {
7
+ /** Where the die lands: the table's choice, target up with the readable twist. */
8
+ rest: {
9
+ position: [number, number, number];
10
+ rotation: [number, number, number, number];
11
+ };
12
+ /** When it lands, seconds from the scene's start. */
13
+ landAt: number;
14
+ /** The host skipped: the die has landed; wind down within 400 ms. */
15
+ readonly skipped: boolean;
16
+ /** Places the die this frame, before it lands (the table owns it after). */
17
+ pose(position: ArrayLike<number>, rotation: ArrayLike<number>, scale?: number): void;
18
+ /** Shows or hides the die before it lands (it starts hidden; landing shows it). */
19
+ show(visible: boolean): void;
20
+ /** Sets the family's own fields on this die's material (not the dice fields). */
21
+ material(fields: Record<string, unknown>): void;
22
+ }
23
+ export interface DiceEntranceDef {
24
+ /** Vertices it draws, within `DICE_BUDGETS.entranceVertices`: declared, and held to. */
25
+ vertices: number;
26
+ /** The whole scene, in ms, within `DICE_BUDGETS.entranceMs`. */
27
+ durationMs: number;
28
+ /** When the die lands, in ms from the start: at most `durationMs`. */
29
+ landAtMs: number;
30
+ /** Where it wants the die: the tray's center, or [u, v] in −1..1 of the tray. Default center. */
31
+ spot?: 'center' | readonly [number, number];
32
+ /** How hard the die lands, 0..1: its impact sound. Default 0.8. */
33
+ impact?: number;
34
+ /** Loaded before it plays; a roll waits at most 1.5 s for them after the physics is ready. */
35
+ assets?: readonly AssetRef[];
36
+ /** Spawns the scene; returns what it spawned (the table despawns it). */
37
+ spawn(ctx: DiceEntranceContext): Entity[];
38
+ /** Every frame from the start, with seconds since. Return false to end it early. */
39
+ update?(ctx: DiceEntranceContext, seconds: number, entities: readonly Entity[]): boolean | undefined;
40
+ }
41
+ /**
42
+ * Registers a scene that brings a die in (0065). Throws `dice/entrance-budget` for a scene over the
43
+ * budgets, `dice/invalid-entrance` for a landing outside it, `dice/registry-conflict` for a name
44
+ * taken.
45
+ */
46
+ export declare function defineDiceEntrance(name: string, def: DiceEntranceDef): DiceEntranceDef;
47
+ export declare function findDiceEntrance(name: string): DiceEntranceDef | undefined;
48
+ export declare function allDiceEntrances(): string[];
@@ -0,0 +1,54 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ import { DICE_BUDGETS } from "./attachments.js";
3
+ const MAX_ENTRANCES = 2;
4
+ const entrances = /* @__PURE__ */ new Map();
5
+ function budget(name, message) {
6
+ return new ShardError("dice/entrance-budget", `Entrance "${name}" ${message}`, {
7
+ hint: "Trim the scene, or raise DICE_BUDGETS with setDiceBudgets before defining it."
8
+ });
9
+ }
10
+ function defineDiceEntrance(name, def) {
11
+ if (!(def.vertices > 0) || def.vertices > DICE_BUDGETS.entranceVertices) {
12
+ throw budget(
13
+ name,
14
+ `declares ${def.vertices} vertices; the budget is ${DICE_BUDGETS.entranceVertices}`
15
+ );
16
+ }
17
+ if (!(def.durationMs > 0) || def.durationMs > DICE_BUDGETS.entranceMs) {
18
+ throw budget(name, `runs ${def.durationMs} ms; the budget is ${DICE_BUDGETS.entranceMs}`);
19
+ }
20
+ if (!(def.landAtMs >= 0 && def.landAtMs <= def.durationMs)) {
21
+ throw new ShardError(
22
+ "dice/invalid-entrance",
23
+ `Entrance "${name}" lands at ${def.landAtMs} ms, outside its ${def.durationMs} ms`,
24
+ { hint: "landAtMs is when the die lands: between 0 and durationMs.", path: "landAtMs" }
25
+ );
26
+ }
27
+ const spot = def.spot;
28
+ if (spot && spot !== "center" && !(Math.abs(spot[0]) <= 1 && Math.abs(spot[1]) <= 1)) {
29
+ throw new ShardError("dice/invalid-entrance", `Entrance "${name}" wants a spot off the tray`, {
30
+ hint: "spot is 'center', or [u, v] with each in \u22121..1 of the tray.",
31
+ path: "spot"
32
+ });
33
+ }
34
+ const existing = entrances.get(name);
35
+ if (existing && existing !== def) {
36
+ throw new ShardError("dice/registry-conflict", `Entrance "${name}" is already defined`, {
37
+ hint: "Give the new entrance its own name."
38
+ });
39
+ }
40
+ entrances.set(name, def);
41
+ return def;
42
+ }
43
+ function findDiceEntrance(name) {
44
+ return entrances.get(name);
45
+ }
46
+ function allDiceEntrances() {
47
+ return [...entrances.keys()].sort();
48
+ }
49
+ export {
50
+ MAX_ENTRANCES,
51
+ allDiceEntrances,
52
+ defineDiceEntrance,
53
+ findDiceEntrance
54
+ };
@@ -0,0 +1,17 @@
1
+ /** Opaque, one color, a few flecks in the pour. */
2
+ export declare const SOLID: import("./material.js").DiceFamily;
3
+ /**
4
+ * Translucent-looking resin without transparency: a tonal depth fake. Looking straight into a
5
+ * face sees deep color, swirled through the body; grazing angles and the chamfer catch light like
6
+ * thin resin does. The face on top glows a little once landed.
7
+ */
8
+ export declare const RESIN: import("./material.js").DiceFamily;
9
+ /** Metal: tinted by baseColor, brushed, with polished worn edges and enamel marks. */
10
+ export declare const METAL: import("./material.js").DiceFamily;
11
+ /**
12
+ * Glass: blended, clear through the middle of a face and bright at grazing angles, with opaque
13
+ * marks. Blended dice cast no shadow, so they get a contact blob on the tray. Always see-through,
14
+ * but not a fixed blend: the large-pool tier draws it opaque, so its dice batch.
15
+ */
16
+ export declare const GLASS: import("./material.js").DiceFamily;
17
+ export declare const BUILTIN_FAMILIES: readonly [import("./material.js").DiceFamily, import("./material.js").DiceFamily, import("./material.js").DiceFamily, import("./material.js").DiceFamily];
@@ -0,0 +1,131 @@
1
+ import { t } from "@aethervtt/shard-core";
2
+ import { defineDiceFamily } from "./material.js";
3
+ const SOLID = defineDiceFamily("dice/SolidDice", {
4
+ fields: {
5
+ fleck: t.f32({
6
+ default: 0.25,
7
+ min: 0,
8
+ max: 1,
9
+ description: "How many flecks show in the body."
10
+ }),
11
+ fleckColor: t.color({ default: [1, 1, 1, 1], description: "Fleck color (linear)." }),
12
+ fleckScale: t.f32({
13
+ default: 16,
14
+ min: 1,
15
+ description: "Fleck density, per unit of die radius."
16
+ })
17
+ },
18
+ surface: `
19
+ let speck = smoothstep(0.8, 0.88, dice_noise(local * F.fleckScale));
20
+ p.base_color = mix(p.base_color, F.fleckColor.rgb, speck * F.fleck * m.face);`,
21
+ description: "Solid dice: an opaque body, optional flecks."
22
+ });
23
+ const RESIN = defineDiceFamily("dice/ResinDice", {
24
+ fields: {
25
+ resinDeep: t.color({
26
+ default: [0.02, 0.18, 0.2, 1],
27
+ description: "The deep tone, looking into the body."
28
+ }),
29
+ resinGlow: t.color({
30
+ default: [0.3, 0.9, 0.82, 1],
31
+ description: "The light tone, at thin edges."
32
+ }),
33
+ resinDepth: t.f32({
34
+ default: 0.85,
35
+ min: 0,
36
+ max: 1,
37
+ description: "How much of the tones replace baseColor."
38
+ }),
39
+ resinSwirl: t.f32({ default: 0.55, min: 0, max: 1, description: "Swirl through the pour." }),
40
+ resinScale: t.f32({
41
+ default: 2.4,
42
+ min: 0.1,
43
+ description: "Swirl size, per unit of die radius."
44
+ }),
45
+ resinLight: t.f32({
46
+ default: 2600,
47
+ min: 0,
48
+ unit: "cd/m\xB2",
49
+ description: "Light at thin edges."
50
+ }),
51
+ resinResultGlow: t.f32({
52
+ default: 3e3,
53
+ min: 0,
54
+ unit: "cd/m\xB2",
55
+ description: "Glow of the marks on top once landed."
56
+ }),
57
+ resinOpacity: t.f32({
58
+ default: 1,
59
+ min: 0.2,
60
+ max: 1,
61
+ description: "Below 1, the resin is see-through: its material blends (no shadow; a contact blob instead)."
62
+ })
63
+ },
64
+ surface: `
65
+ let nv = clamp(dot(p.normal, v), 0.0, 1.0);
66
+ let swirl = dice_fbm(local * F.resinScale + vec3f(value * 1.7));
67
+ let depth = clamp(pow(nv, 0.6) * (1.0 - 0.5 * F.resinSwirl) + swirl * F.resinSwirl * 0.6, 0.0, 1.0);
68
+ let tone = mix(F.resinGlow.rgb, F.resinDeep.rgb, depth);
69
+ p.base_color = mix(p.base_color, tone, F.resinDepth);
70
+ let thin = pow(1.0 - nv, 3.0) + (1.0 - m.face) * 0.45;
71
+ p.emissive += F.resinGlow.rgb * F.resinLight * thin;
72
+ p.emissive += look.mark_color.rgb * F.resinResultGlow * look.result * m.top * m.coverage;
73
+ p.roughness = min(p.roughness, 0.22);
74
+ p.alpha = F.resinOpacity;`,
75
+ translucent: (params) => typeof params.resinOpacity === "number" && params.resinOpacity < 1,
76
+ description: "Resin dice: a tonal depth fake, swirl, glowing thin edges, a lit result face."
77
+ });
78
+ const METAL = defineDiceFamily("dice/MetalDice", {
79
+ fields: {
80
+ metalWear: t.f32({ default: 0.6, min: 0, max: 1, description: "Polished, brighter edges." }),
81
+ metalGrain: t.f32({
82
+ default: 0.4,
83
+ min: 0,
84
+ max: 1,
85
+ description: "Brushed streaks in the roughness."
86
+ })
87
+ },
88
+ surface: `
89
+ p.metallic = 1.0;
90
+ let grain = dice_noise(local * vec3f(70.0, 2.5, 70.0));
91
+ p.roughness = clamp(p.roughness * (1.0 + (grain - 0.5) * F.metalGrain), 0.05, 1.0);
92
+ let edge = 1.0 - m.face;
93
+ p.roughness = mix(p.roughness, p.roughness * 0.35, edge * F.metalWear);
94
+ p.base_color = mix(p.base_color, min(p.base_color * 1.3 + vec3f(0.04), vec3f(1.0)), edge * F.metalWear);`,
95
+ description: "Metal dice: brushed, worn bright at the edges, enamel marks."
96
+ });
97
+ const GLASS = defineDiceFamily("dice/GlassDice", {
98
+ fields: {
99
+ glassClarity: t.f32({
100
+ default: 0.8,
101
+ min: 0,
102
+ max: 1,
103
+ description: "How clear the body is face-on."
104
+ }),
105
+ glassSparkle: t.f32({
106
+ default: 0.5,
107
+ min: 0,
108
+ max: 1,
109
+ description: "Inner glints as the die turns."
110
+ })
111
+ },
112
+ translucent: () => true,
113
+ surface: `
114
+ let nv = clamp(dot(p.normal, v), 0.0, 1.0);
115
+ let fresnel = pow(1.0 - nv, 2.5);
116
+ p.alpha = mix(1.0 - F.glassClarity, 0.9, fresnel) * m.face + (1.0 - m.face) * 0.55;
117
+ p.roughness = min(p.roughness, 0.08);
118
+ p.metallic = 0.0;
119
+ let glint = pow(dice_noise(local * 5.0 + v * 2.5), 10.0) * F.glassSparkle;
120
+ p.emissive += p.base_color * glint * 9000.0;
121
+ p.alpha = max(p.alpha, min(1.0, glint * 2.0));`,
122
+ description: "Glass dice: clear bodies, bright rims, opaque marks."
123
+ });
124
+ const BUILTIN_FAMILIES = [SOLID, RESIN, METAL, GLASS];
125
+ export {
126
+ BUILTIN_FAMILIES,
127
+ GLASS,
128
+ METAL,
129
+ RESIN,
130
+ SOLID
131
+ };
@@ -0,0 +1,29 @@
1
+ import type { OutlineCommand } from '@aethervtt/shard-text';
2
+ export interface DiceGlyphDef {
3
+ /** SVG path data (M L H V C S Q T A Z, absolute or relative), in `viewBox` units, y down. */
4
+ path: string;
5
+ /** min-x, min-y, width, height. Default 0 0 100 100. */
6
+ viewBox?: [number, number, number, number];
7
+ }
8
+ export interface DiceGlyph {
9
+ name: string;
10
+ /** The outline in glyph units: y up, the viewBox's middle at the origin, its height 1. */
11
+ commands: OutlineCommand[];
12
+ /** Width over height of the viewBox. */
13
+ aspect: number;
14
+ }
15
+ /** SVG path data to outline commands (still y down, in path units). */
16
+ export declare function parseSvgPath(d: string, name?: string): OutlineCommand[];
17
+ /** Maps commands through (x, y) → (a·x + b, c·y + d). */
18
+ declare function transform(cmds: OutlineCommand[], a: number, b: number, c: number, d: number): OutlineCommand[];
19
+ /**
20
+ * Registers a glyph a face layout can name. The same path again is a no-op; a different one under
21
+ * a taken name throws `dice/registry-conflict`.
22
+ */
23
+ export declare function defineDiceGlyph(name: string, def: DiceGlyphDef): DiceGlyph;
24
+ export declare function findDiceGlyph(name: string): DiceGlyph | undefined;
25
+ export declare function allDiceGlyphs(): string[];
26
+ export { transform as transformOutline };
27
+ /** Built-in glyphs, in a 100 × 100 box. */
28
+ export declare const BUILTIN_GLYPHS: Readonly<Record<string, DiceGlyphDef>>;
29
+ export declare function registerBuiltinGlyphs(): void;