@aethervtt/shard-dice 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +2 -2
- package/dist/attachments.d.ts +79 -0
- package/dist/attachments.js +43 -0
- package/dist/audio.d.ts +19 -0
- package/dist/audio.js +187 -0
- package/dist/builtins.d.ts +25 -0
- package/dist/builtins.js +335 -0
- package/dist/cells.d.ts +33 -0
- package/dist/cells.js +53 -0
- package/dist/components.d.ts +14 -0
- package/dist/components.js +27 -0
- package/dist/definition-asset.d.ts +19 -0
- package/dist/definition-asset.js +60 -0
- package/dist/definition.d.ts +98 -0
- package/dist/definition.js +320 -0
- package/dist/dice-worker.d.ts +1 -0
- package/dist/dice-worker.js +3 -0
- package/dist/effects.d.ts +99 -0
- package/dist/effects.js +200 -0
- package/dist/entrances.d.ts +48 -0
- package/dist/entrances.js +54 -0
- package/dist/families.d.ts +17 -0
- package/dist/families.js +131 -0
- package/dist/glyphs.d.ts +29 -0
- package/dist/glyphs.js +270 -0
- package/dist/golden.json +17 -0
- package/dist/hull.d.ts +32 -0
- package/dist/hull.js +154 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +324 -0
- package/dist/landing.d.ts +29 -0
- package/dist/landing.js +93 -0
- package/dist/layout.d.ts +75 -0
- package/dist/layout.js +137 -0
- package/dist/marks.d.ts +54 -0
- package/dist/marks.js +543 -0
- package/dist/material.d.ts +80 -0
- package/dist/material.js +343 -0
- package/dist/math.d.ts +39 -0
- package/dist/math.js +136 -0
- package/dist/mesh.d.ts +9 -0
- package/dist/mesh.js +105 -0
- package/dist/methods.d.ts +2 -0
- package/dist/methods.js +42 -0
- package/dist/numerals.d.ts +9 -0
- package/dist/numerals.js +47 -0
- package/dist/plugin.d.ts +41 -0
- package/dist/plugin.js +176 -0
- package/dist/preview.d.ts +32 -0
- package/dist/preview.js +135 -0
- package/dist/quality.d.ts +12 -0
- package/dist/quality.js +18 -0
- package/dist/resources.d.ts +85 -0
- package/dist/resources.js +191 -0
- package/dist/roll.d.ts +68 -0
- package/dist/roll.js +97 -0
- package/dist/settle.d.ts +53 -0
- package/dist/settle.js +96 -0
- package/dist/skin.d.ts +64 -0
- package/dist/skin.js +313 -0
- package/dist/sound.d.ts +33 -0
- package/dist/sound.js +296 -0
- package/dist/studio.d.ts +3 -0
- package/dist/studio.js +48 -0
- package/dist/table.d.ts +227 -0
- package/dist/table.js +1818 -0
- package/dist/test-worker.d.ts +1 -0
- package/dist/test-worker.js +12 -0
- package/dist/track.d.ts +70 -0
- package/dist/track.js +304 -0
- package/dist/tray.d.ts +2 -0
- package/dist/tray.js +43 -0
- package/dist/variants.d.ts +9 -0
- package/dist/variants.js +46 -0
- package/dist/windows.d.ts +25 -0
- package/dist/windows.js +77 -0
- package/dist/worker.d.ts +6 -0
- package/dist/worker.js +9 -0
- package/package.json +47 -4
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
import { convexFaces, pointsOf, symmetryGroup, tangentPolytope } from "./hull.js";
|
|
3
|
+
import {
|
|
4
|
+
cross,
|
|
5
|
+
dot,
|
|
6
|
+
EPSILON,
|
|
7
|
+
length,
|
|
8
|
+
normalize,
|
|
9
|
+
qKey,
|
|
10
|
+
qRotate,
|
|
11
|
+
round,
|
|
12
|
+
scale,
|
|
13
|
+
stableTangent,
|
|
14
|
+
sub,
|
|
15
|
+
tangentOf
|
|
16
|
+
} from "./math.js";
|
|
17
|
+
function invalid(message, path, hint) {
|
|
18
|
+
return new ShardError("dice/invalid-definition", message, {
|
|
19
|
+
path,
|
|
20
|
+
hint: hint ?? "See DieDefinition in @aethervtt/shard-dice."
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function dieHash(def) {
|
|
24
|
+
const canonical = JSON.stringify([
|
|
25
|
+
def.version,
|
|
26
|
+
def.id,
|
|
27
|
+
def.sides,
|
|
28
|
+
Array.from(def.vertices),
|
|
29
|
+
def.read,
|
|
30
|
+
Object.keys(def.values).sort().map((k) => [k, def.values[k]]),
|
|
31
|
+
Object.keys(def.labels ?? {}).map(Number).sort((a, b) => a - b).map((k) => [k, def.labels[k]]),
|
|
32
|
+
def.collider,
|
|
33
|
+
def.sizeMm,
|
|
34
|
+
def.bevel,
|
|
35
|
+
def.markScale ?? null,
|
|
36
|
+
def.oppositesSum ?? false
|
|
37
|
+
]);
|
|
38
|
+
let h = 2166136261;
|
|
39
|
+
for (let i = 0; i < canonical.length; i++) {
|
|
40
|
+
h ^= canonical.charCodeAt(i);
|
|
41
|
+
h = Math.imul(h, 16777619);
|
|
42
|
+
}
|
|
43
|
+
return h >>> 0;
|
|
44
|
+
}
|
|
45
|
+
const faceKey = (face) => [...face.vertices].sort((a, b) => a - b).join(":");
|
|
46
|
+
function validateDieDefinition(def) {
|
|
47
|
+
const errors = [];
|
|
48
|
+
if (def.version !== 1) errors.push(invalid(`Version ${def.version} isn't 1`, "/version"));
|
|
49
|
+
if (typeof def.id !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(def.id))
|
|
50
|
+
errors.push(invalid(`id "${def.id}" isn't a lowercase id`, "/id"));
|
|
51
|
+
if (!Number.isInteger(def.sides) || def.sides < 2 || def.sides > 100)
|
|
52
|
+
errors.push(invalid("sides must be an integer from 2 to 100", "/sides"));
|
|
53
|
+
const v = def.vertices;
|
|
54
|
+
if (!(v instanceof Float32Array) || v.length < 12 || v.length % 3 !== 0) {
|
|
55
|
+
errors.push(invalid("vertices needs 4 or more x y z points in a Float32Array", "/vertices"));
|
|
56
|
+
return errors;
|
|
57
|
+
}
|
|
58
|
+
const points = pointsOf(v);
|
|
59
|
+
const radius = Math.max(...points.map(length));
|
|
60
|
+
if (Math.abs(radius - 1) > 1e-3)
|
|
61
|
+
errors.push(invalid(`vertices reach radius ${radius.toFixed(4)}, not 1`, "/vertices"));
|
|
62
|
+
if (def.collider === "ball") {
|
|
63
|
+
points.forEach((p, i) => {
|
|
64
|
+
if (Math.abs(length(p) - 1) > 1e-3)
|
|
65
|
+
errors.push(invalid(`Ball cell ${i} isn't on the unit sphere`, `/vertices/${i * 3}`));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (!(def.sizeMm > 0)) errors.push(invalid("sizeMm must be positive", "/sizeMm"));
|
|
69
|
+
if (!(def.bevel >= 0 && def.bevel <= 0.3))
|
|
70
|
+
errors.push(invalid("bevel must be from 0 to 0.3", "/bevel"));
|
|
71
|
+
if (errors.length > 0) return errors;
|
|
72
|
+
const keys = [];
|
|
73
|
+
if (def.read === "vertex" || def.collider === "ball") {
|
|
74
|
+
for (let i = 0; i < points.length; i++) keys.push(String(i));
|
|
75
|
+
} else {
|
|
76
|
+
for (const face of convexFaces(points)) keys.push(faceKey(face));
|
|
77
|
+
}
|
|
78
|
+
if (keys.length !== def.sides) {
|
|
79
|
+
errors.push(
|
|
80
|
+
invalid(
|
|
81
|
+
`The shape has ${keys.length} ${def.read === "vertex" ? "vertices" : "faces"}, not ${def.sides}`,
|
|
82
|
+
"/sides"
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const seen = /* @__PURE__ */ new Map();
|
|
87
|
+
for (const key of keys) {
|
|
88
|
+
const value = def.values[key];
|
|
89
|
+
if (value === void 0) {
|
|
90
|
+
errors.push(
|
|
91
|
+
invalid(`Face ${key} has no value`, `/values/${key}`, `Give it one of 1..${def.sides}.`)
|
|
92
|
+
);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!Number.isInteger(value) || value < 1 || value > def.sides) {
|
|
96
|
+
errors.push(invalid(`Face ${key} has value ${value}, not 1..${def.sides}`, `/values/${key}`));
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const other = seen.get(value);
|
|
100
|
+
if (other !== void 0)
|
|
101
|
+
errors.push(invalid(`Faces ${other} and ${key} both have value ${value}`, `/values/${key}`));
|
|
102
|
+
seen.set(value, key);
|
|
103
|
+
}
|
|
104
|
+
for (const key of Object.keys(def.values)) {
|
|
105
|
+
if (!keys.includes(key))
|
|
106
|
+
errors.push(invalid(`values names ${key}, which isn't a face of the shape`, `/values/${key}`));
|
|
107
|
+
}
|
|
108
|
+
for (const key of Object.keys(def.labels ?? {})) {
|
|
109
|
+
const value = Number(key);
|
|
110
|
+
if (!seen.has(value))
|
|
111
|
+
errors.push(invalid(`labels names value ${key}, which no face has`, `/labels/${key}`));
|
|
112
|
+
}
|
|
113
|
+
if (def.oppositesSum && def.read === "face" && def.collider === "hull") {
|
|
114
|
+
const faces = convexFaces(points);
|
|
115
|
+
for (const face of faces) {
|
|
116
|
+
const opposite = faces.find((f) => dot(f.normal, face.normal) < -1 + 1e-4);
|
|
117
|
+
if (!opposite) {
|
|
118
|
+
errors.push(invalid(`Face ${faceKey(face)} has no opposite face`, "/oppositesSum"));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const a = def.values[faceKey(face)];
|
|
122
|
+
const b = def.values[faceKey(opposite)];
|
|
123
|
+
if (a !== void 0 && b !== void 0 && a + b !== def.sides + 1) {
|
|
124
|
+
errors.push(
|
|
125
|
+
invalid(
|
|
126
|
+
`Opposite faces ${faceKey(face)} (${a}) and ${faceKey(opposite)} (${b}) don't sum to ${def.sides + 1}`,
|
|
127
|
+
`/values/${faceKey(face)}`
|
|
128
|
+
)
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return errors;
|
|
134
|
+
}
|
|
135
|
+
function markUp(points, face) {
|
|
136
|
+
const d = face.vertices.map((i) => length(sub(points[i], face.center)));
|
|
137
|
+
const far = Math.max(...d);
|
|
138
|
+
const farthest = face.vertices.filter((_, k) => d[k] > far - 1e-4);
|
|
139
|
+
let toward;
|
|
140
|
+
if (farthest.length === 1) toward = points[farthest[0]];
|
|
141
|
+
else if (face.vertices.length % 2 === 1) toward = points[Math.min(...face.vertices)];
|
|
142
|
+
else {
|
|
143
|
+
const vs = face.vertices;
|
|
144
|
+
let best = [vs[0], vs[1]];
|
|
145
|
+
let bestKey = Number.POSITIVE_INFINITY;
|
|
146
|
+
for (let k = 0; k < vs.length; k++) {
|
|
147
|
+
const a = vs[k];
|
|
148
|
+
const b = vs[(k + 1) % vs.length];
|
|
149
|
+
const key = Math.min(a, b) * 1e3 + Math.max(a, b);
|
|
150
|
+
if (key < bestKey) {
|
|
151
|
+
bestKey = key;
|
|
152
|
+
best = [a, b];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
toward = scale(add3(points[best[0]], points[best[1]]), 0.5);
|
|
156
|
+
}
|
|
157
|
+
return tangentOf(sub(toward, face.center), face.normal);
|
|
158
|
+
}
|
|
159
|
+
const add3 = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
160
|
+
function footprintOf(points, down) {
|
|
161
|
+
let r = 0;
|
|
162
|
+
for (const p of points) {
|
|
163
|
+
const along = dot(p, down);
|
|
164
|
+
const x = sub(p, scale(down, along));
|
|
165
|
+
r = Math.max(r, length(x));
|
|
166
|
+
}
|
|
167
|
+
return r * 2;
|
|
168
|
+
}
|
|
169
|
+
const cache = /* @__PURE__ */ new Map();
|
|
170
|
+
const byObject = /* @__PURE__ */ new WeakMap();
|
|
171
|
+
function dieGeometry(def) {
|
|
172
|
+
const known = byObject.get(def);
|
|
173
|
+
if (known) return known;
|
|
174
|
+
const hash = dieHash(def);
|
|
175
|
+
const cached = cache.get(hash);
|
|
176
|
+
if (cached) {
|
|
177
|
+
byObject.set(def, cached);
|
|
178
|
+
return cached;
|
|
179
|
+
}
|
|
180
|
+
const errors = validateDieDefinition(def);
|
|
181
|
+
if (errors.length > 0) throw errors[0];
|
|
182
|
+
const points = pointsOf(def.vertices).map((p) => p.map(round));
|
|
183
|
+
const ball = def.collider === "ball";
|
|
184
|
+
const polytope = ball ? tangentPolytope(points.map(normalize)) : { points, faces: convexFaces(points) };
|
|
185
|
+
const symmetries = ball ? [] : symmetryGroup(points);
|
|
186
|
+
const directions = [];
|
|
187
|
+
let faceValues;
|
|
188
|
+
let vertexValues;
|
|
189
|
+
if (def.read === "vertex") {
|
|
190
|
+
vertexValues = points.map((_, i) => def.values[String(i)]);
|
|
191
|
+
faceValues = polytope.faces.map(() => void 0);
|
|
192
|
+
points.forEach((p, i) => {
|
|
193
|
+
directions.push({ value: vertexValues[i], normal: normalize(p), face: void 0 });
|
|
194
|
+
});
|
|
195
|
+
} else if (ball) {
|
|
196
|
+
faceValues = points.map((_, i) => def.values[String(i)]);
|
|
197
|
+
polytope.faces.forEach((face, i) => {
|
|
198
|
+
directions.push({ value: faceValues[i], normal: face.normal, face });
|
|
199
|
+
});
|
|
200
|
+
} else {
|
|
201
|
+
faceValues = polytope.faces.map((f) => def.values[faceKey(f)]);
|
|
202
|
+
polytope.faces.forEach((face, i) => {
|
|
203
|
+
directions.push({ value: faceValues[i], normal: face.normal, face });
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
directions.sort((a, b) => a.value - b.value);
|
|
207
|
+
const frames = [];
|
|
208
|
+
if (ball) {
|
|
209
|
+
for (const d of directions) {
|
|
210
|
+
const n = d.normal;
|
|
211
|
+
const pole = Math.abs(n[1]) > 0.999 ? [0, 0, n[1] > 0 ? -1 : 1] : [0, 1, 0];
|
|
212
|
+
const b = tangentOf(pole, n);
|
|
213
|
+
frames.push({ value: d.value, normal: n, tangent: cross(b, n), bitangent: b });
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
const ref = directions[0];
|
|
217
|
+
let refUp;
|
|
218
|
+
if (def.read === "vertex") {
|
|
219
|
+
const vertex = points.findIndex((_, i) => vertexValues[i] === ref.value);
|
|
220
|
+
const adjacent = polytope.faces.filter((f) => f.vertices.includes(vertex)).sort((a, b) => Math.min(...a.vertices) - Math.min(...b.vertices))[0];
|
|
221
|
+
refUp = adjacent ? tangentOf(sub(points[vertex], adjacent.center), ref.normal) : stableTangent(ref.normal);
|
|
222
|
+
} else {
|
|
223
|
+
refUp = markUp(points, ref.face);
|
|
224
|
+
}
|
|
225
|
+
for (const d of directions) {
|
|
226
|
+
const candidates = symmetries.filter((q2) => length(sub(qRotate(q2, ref.normal), d.normal)) < EPSILON * 10).sort((a, b2) => qKey(a).localeCompare(qKey(b2)));
|
|
227
|
+
const q = candidates[0];
|
|
228
|
+
if (!q) {
|
|
229
|
+
throw invalid(
|
|
230
|
+
`No symmetry of ${def.id} carries value ${ref.value}'s face to value ${d.value}'s`,
|
|
231
|
+
"/vertices",
|
|
232
|
+
"Dice need a symmetry group that moves every face to every other (fair dice have one)."
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const b = normalize(qRotate(q, refUp));
|
|
236
|
+
frames.push({ value: d.value, normal: d.normal, tangent: cross(b, d.normal), bitangent: b });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const up = frames[0].normal;
|
|
240
|
+
const down = scale(up, -1);
|
|
241
|
+
let rest = 0;
|
|
242
|
+
for (const p of polytope.points) rest = Math.max(rest, dot(p, down));
|
|
243
|
+
const geometry = {
|
|
244
|
+
definition: def,
|
|
245
|
+
hash,
|
|
246
|
+
points,
|
|
247
|
+
polytope,
|
|
248
|
+
faceValues,
|
|
249
|
+
vertexValues,
|
|
250
|
+
frames,
|
|
251
|
+
symmetries,
|
|
252
|
+
footprint: footprintOf(ball ? polytope.points : points, down),
|
|
253
|
+
restHeight: ball ? 1 : rest
|
|
254
|
+
};
|
|
255
|
+
cache.set(hash, geometry);
|
|
256
|
+
byObject.set(def, geometry);
|
|
257
|
+
return geometry;
|
|
258
|
+
}
|
|
259
|
+
function frameOf(geometry, value) {
|
|
260
|
+
const frame = Number.isInteger(value) ? geometry.frames[value - 1] : void 0;
|
|
261
|
+
if (!frame || frame.value !== value) {
|
|
262
|
+
throw new ShardError(
|
|
263
|
+
"dice/invalid-value",
|
|
264
|
+
`A ${geometry.definition.id} can't show ${value}: its values are 1 to ${geometry.frames.length}`,
|
|
265
|
+
{ hint: "The host decides the result; pass one the die has." }
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return frame;
|
|
269
|
+
}
|
|
270
|
+
function labelOf(def, value) {
|
|
271
|
+
return def.labels?.[value] ?? String(value);
|
|
272
|
+
}
|
|
273
|
+
const definitions = /* @__PURE__ */ new Map();
|
|
274
|
+
function defineDie(def) {
|
|
275
|
+
const existing = definitions.get(def.id);
|
|
276
|
+
if (existing) {
|
|
277
|
+
if (dieHash(existing) === dieHash(def)) return existing;
|
|
278
|
+
throw new ShardError(
|
|
279
|
+
"dice/registry-conflict",
|
|
280
|
+
`Die "${def.id}" is already defined, differently`,
|
|
281
|
+
{
|
|
282
|
+
hint: "Give the new definition its own id."
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const errors = validateDieDefinition(def);
|
|
287
|
+
if (errors.length > 0) throw errors[0];
|
|
288
|
+
definitions.set(def.id, def);
|
|
289
|
+
return def;
|
|
290
|
+
}
|
|
291
|
+
function findDie(id) {
|
|
292
|
+
return definitions.get(id);
|
|
293
|
+
}
|
|
294
|
+
function requireDie(id) {
|
|
295
|
+
const def = definitions.get(id);
|
|
296
|
+
if (!def) {
|
|
297
|
+
throw new ShardError("dice/unknown-die", `No die definition "${id}"`, {
|
|
298
|
+
hint: `Defined: ${[...definitions.keys()].join(", ")}. Register others with defineDie.`
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return def;
|
|
302
|
+
}
|
|
303
|
+
function allDice() {
|
|
304
|
+
return [...definitions.values()];
|
|
305
|
+
}
|
|
306
|
+
function physicalScale(geometry, reference) {
|
|
307
|
+
return geometry.definition.sizeMm / reference.definition.sizeMm * (reference.footprint / geometry.footprint);
|
|
308
|
+
}
|
|
309
|
+
export {
|
|
310
|
+
allDice,
|
|
311
|
+
defineDie,
|
|
312
|
+
dieGeometry,
|
|
313
|
+
dieHash,
|
|
314
|
+
findDie,
|
|
315
|
+
frameOf,
|
|
316
|
+
labelOf,
|
|
317
|
+
physicalScale,
|
|
318
|
+
requireDie,
|
|
319
|
+
validateDieDefinition
|
|
320
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type FieldValue, type Infer, ShardError } from '@aethervtt/shard-core';
|
|
2
|
+
import type { DiceRoll, PhysicalDie } from './roll.js';
|
|
3
|
+
declare const CONDITION: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").InferFields<{
|
|
4
|
+
kind: import("@aethervtt/shard-core").FieldType<"die" | "source" | "tag" | "total", "u8">;
|
|
5
|
+
die: import("@aethervtt/shard-core").FieldType<"any" | "d10" | "d100" | "d12" | "d20" | "d4" | "d6" | "d8" | "percentile", "u8">;
|
|
6
|
+
value: import("@aethervtt/shard-core").FieldType<number, "u16">;
|
|
7
|
+
face: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
8
|
+
state: import("@aethervtt/shard-core").FieldType<"any" | "dropped" | "kept", "u8">;
|
|
9
|
+
rank: import("@aethervtt/shard-core").FieldType<"any" | "high" | "low", "u8">;
|
|
10
|
+
compare: import("@aethervtt/shard-core").FieldType<"eq" | "gte" | "lte", "u8">;
|
|
11
|
+
total: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
12
|
+
source: import("@aethervtt/shard-core").FieldType<"gm" | "mine" | "other", "u8">;
|
|
13
|
+
tag: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
14
|
+
}>, "object">;
|
|
15
|
+
declare const EFFECT: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").InferFields<{
|
|
16
|
+
kind: import("@aethervtt/shard-core").FieldType<"attachment" | "entrance" | "lens-pulse" | "light-pulse" | "particle-burst" | "sound-accent", "u8">;
|
|
17
|
+
color: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Color, "f32">;
|
|
18
|
+
colors: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Color[], "object">;
|
|
19
|
+
intensity: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
20
|
+
count: import("@aethervtt/shard-core").FieldType<number, "u8">;
|
|
21
|
+
durationMs: import("@aethervtt/shard-core").FieldType<number, "u16">;
|
|
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">;
|
|
24
|
+
gain: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
25
|
+
radius: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
26
|
+
strength: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
27
|
+
attachment: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
28
|
+
entrance: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
29
|
+
params: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").JsonValue, "object">;
|
|
30
|
+
}>, "object">;
|
|
31
|
+
/** An effect recipe: `data/**\/*.dice-effect.json`. */
|
|
32
|
+
export declare const DiceEffectRecipe: import("@aethervtt/shard-assets").DataType<{
|
|
33
|
+
readonly version: import("@aethervtt/shard-core").FieldType<number, "u8">;
|
|
34
|
+
readonly id: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
35
|
+
readonly match: import("@aethervtt/shard-core").FieldType<"all" | "any", "u8">;
|
|
36
|
+
readonly conditions: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").InferFields<{
|
|
37
|
+
kind: import("@aethervtt/shard-core").FieldType<"die" | "source" | "tag" | "total", "u8">;
|
|
38
|
+
die: import("@aethervtt/shard-core").FieldType<"any" | "d10" | "d100" | "d12" | "d20" | "d4" | "d6" | "d8" | "percentile", "u8">;
|
|
39
|
+
value: import("@aethervtt/shard-core").FieldType<number, "u16">;
|
|
40
|
+
face: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
41
|
+
state: import("@aethervtt/shard-core").FieldType<"any" | "dropped" | "kept", "u8">;
|
|
42
|
+
rank: import("@aethervtt/shard-core").FieldType<"any" | "high" | "low", "u8">;
|
|
43
|
+
compare: import("@aethervtt/shard-core").FieldType<"eq" | "gte" | "lte", "u8">;
|
|
44
|
+
total: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
45
|
+
source: import("@aethervtt/shard-core").FieldType<"gm" | "mine" | "other", "u8">;
|
|
46
|
+
tag: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
47
|
+
}>[], "object">;
|
|
48
|
+
readonly effects: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").InferFields<{
|
|
49
|
+
kind: import("@aethervtt/shard-core").FieldType<"attachment" | "entrance" | "lens-pulse" | "light-pulse" | "particle-burst" | "sound-accent", "u8">;
|
|
50
|
+
color: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Color, "f32">;
|
|
51
|
+
colors: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Color[], "object">;
|
|
52
|
+
intensity: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
53
|
+
count: import("@aethervtt/shard-core").FieldType<number, "u8">;
|
|
54
|
+
durationMs: import("@aethervtt/shard-core").FieldType<number, "u16">;
|
|
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">;
|
|
57
|
+
gain: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
58
|
+
radius: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
59
|
+
strength: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
60
|
+
attachment: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
61
|
+
entrance: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
62
|
+
params: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").JsonValue, "object">;
|
|
63
|
+
}>[], "object">;
|
|
64
|
+
}, "dice/DiceEffectRecipe">;
|
|
65
|
+
export type DiceEffectRecipeValue = Infer<typeof DiceEffectRecipe>;
|
|
66
|
+
export type DiceEffect = FieldValue<typeof EFFECT>;
|
|
67
|
+
export type DiceCondition = FieldValue<typeof CONDITION>;
|
|
68
|
+
export declare const RECIPE_LIMITS: {
|
|
69
|
+
conditions: number;
|
|
70
|
+
effects: number;
|
|
71
|
+
particles: number;
|
|
72
|
+
colors: number;
|
|
73
|
+
minDurationMs: number;
|
|
74
|
+
maxDurationMs: number;
|
|
75
|
+
recipesPerRoll: number;
|
|
76
|
+
params: number;
|
|
77
|
+
};
|
|
78
|
+
/** A recipe from its JSON form, with defaults filled in. */
|
|
79
|
+
export declare function diceEffectRecipe(json: Record<string, unknown>): DiceEffectRecipeValue;
|
|
80
|
+
/** A recipe's problems, each with a path into it. */
|
|
81
|
+
export declare function recipeProblems(recipe: DiceEffectRecipeValue): ShardError[];
|
|
82
|
+
/** A recipe that matched, and the dice its effects play on. */
|
|
83
|
+
export interface MatchedRecipe {
|
|
84
|
+
recipe: DiceEffectRecipeValue;
|
|
85
|
+
/**
|
|
86
|
+
* Kept dice its effects anchor to: those meeting a die condition, or every kept die when it has
|
|
87
|
+
* none. Dropped dice never anchor effects, though conditions can match `state: 'dropped'`.
|
|
88
|
+
*/
|
|
89
|
+
anchors: number[];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The recipes that play for a roll: each distinct one (by id) whose conditions match, at most 4.
|
|
93
|
+
* `degraded` says a bound refused more.
|
|
94
|
+
*/
|
|
95
|
+
export declare function matchRecipes(recipes: readonly DiceEffectRecipeValue[], roll: DiceRoll, dice: readonly PhysicalDie[]): {
|
|
96
|
+
matched: MatchedRecipe[];
|
|
97
|
+
degraded: boolean;
|
|
98
|
+
};
|
|
99
|
+
export {};
|
package/dist/effects.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
event: t.handle("audio/AudioEvent", {
|
|
41
|
+
description: "sound-accent: optional authored event replacing the procedural cue."
|
|
42
|
+
}),
|
|
43
|
+
gain: t.f32({ default: 0.7, min: 0, max: 1, description: "sound-accent: 0..1." }),
|
|
44
|
+
radius: t.f32({
|
|
45
|
+
default: 150,
|
|
46
|
+
min: 0,
|
|
47
|
+
unit: "px",
|
|
48
|
+
description: "lens-pulse: field radius (CSS px)."
|
|
49
|
+
}),
|
|
50
|
+
strength: t.f32({
|
|
51
|
+
default: -0.6,
|
|
52
|
+
min: -1,
|
|
53
|
+
max: 1,
|
|
54
|
+
description: "lens-pulse: below 0 pulls in."
|
|
55
|
+
}),
|
|
56
|
+
attachment: t.string({ description: "attachment: the name given to defineDiceAttachment." }),
|
|
57
|
+
entrance: t.string({ description: "entrance: the name given to defineDiceEntrance." }),
|
|
58
|
+
params: t.json({
|
|
59
|
+
default: {},
|
|
60
|
+
description: "attachment, entrance: what the scene reads (at most 16 keys), so skins share one."
|
|
61
|
+
})
|
|
62
|
+
},
|
|
63
|
+
{ description: "One effect." }
|
|
64
|
+
);
|
|
65
|
+
const DiceEffectRecipe = defineDataType(
|
|
66
|
+
"dice/DiceEffectRecipe",
|
|
67
|
+
{
|
|
68
|
+
version: t.u8({ default: 1, min: 1, max: 1, description: "Format version: 1." }),
|
|
69
|
+
id: t.string({ description: "Unique per recipe; the same recipe on two skins plays once." }),
|
|
70
|
+
match: t.enum(["all", "any"], {
|
|
71
|
+
description: "Every condition, or any of them. No conditions: always."
|
|
72
|
+
}),
|
|
73
|
+
conditions: t.list(CONDITION, { description: "At most 8." }),
|
|
74
|
+
effects: t.list(EFFECT, { description: "1 to 3." })
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
extension: "dice-effect",
|
|
78
|
+
lazyDependencies: true,
|
|
79
|
+
description: "Effects a skin plays when a roll matches its conditions."
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
const RECIPE_LIMITS = {
|
|
83
|
+
conditions: 8,
|
|
84
|
+
effects: 3,
|
|
85
|
+
particles: 32,
|
|
86
|
+
colors: 4,
|
|
87
|
+
minDurationMs: 100,
|
|
88
|
+
maxDurationMs: 1200,
|
|
89
|
+
recipesPerRoll: 4,
|
|
90
|
+
params: 16
|
|
91
|
+
};
|
|
92
|
+
function diceEffectRecipe(json) {
|
|
93
|
+
return DiceEffectRecipe.deserialize(json);
|
|
94
|
+
}
|
|
95
|
+
function bounds(message, path) {
|
|
96
|
+
return new ShardError("dice/recipe-bounds", message, {
|
|
97
|
+
path,
|
|
98
|
+
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.`
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function recipeProblems(recipe) {
|
|
102
|
+
const out = [];
|
|
103
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(recipe.id))
|
|
104
|
+
out.push(
|
|
105
|
+
new ShardError("dice/invalid-recipe", `Recipe id "${recipe.id}" isn't a lowercase id`, {
|
|
106
|
+
path: "/id"
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
if (recipe.conditions.length > RECIPE_LIMITS.conditions)
|
|
110
|
+
out.push(
|
|
111
|
+
bounds(`Recipe "${recipe.id}" has ${recipe.conditions.length} conditions`, "/conditions")
|
|
112
|
+
);
|
|
113
|
+
if (recipe.effects.length < 1 || recipe.effects.length > RECIPE_LIMITS.effects)
|
|
114
|
+
out.push(bounds(`Recipe "${recipe.id}" has ${recipe.effects.length} effects`, "/effects"));
|
|
115
|
+
recipe.effects.forEach((e, i) => {
|
|
116
|
+
const path = `/effects/${i}`;
|
|
117
|
+
if (e.kind === "particle-burst") {
|
|
118
|
+
if (e.count < 1 || e.count > RECIPE_LIMITS.particles)
|
|
119
|
+
out.push(bounds(`A burst of ${e.count} particles`, `${path}/count`));
|
|
120
|
+
if (e.colors.length < 1 || e.colors.length > RECIPE_LIMITS.colors)
|
|
121
|
+
out.push(bounds(`A burst with ${e.colors.length} colors`, `${path}/colors`));
|
|
122
|
+
}
|
|
123
|
+
if (e.kind === "light-pulse" || e.kind === "particle-burst" || e.kind === "lens-pulse") {
|
|
124
|
+
if (e.durationMs < RECIPE_LIMITS.minDurationMs || e.durationMs > RECIPE_LIMITS.maxDurationMs)
|
|
125
|
+
out.push(bounds(`A ${e.durationMs} ms ${e.kind}`, `${path}/durationMs`));
|
|
126
|
+
}
|
|
127
|
+
if (e.kind === "attachment" && !e.attachment)
|
|
128
|
+
out.push(
|
|
129
|
+
new ShardError("dice/invalid-recipe", "An attachment effect names no attachment", {
|
|
130
|
+
path: `${path}/attachment`
|
|
131
|
+
})
|
|
132
|
+
);
|
|
133
|
+
if (e.kind === "entrance" && !e.entrance)
|
|
134
|
+
out.push(
|
|
135
|
+
new ShardError("dice/invalid-recipe", "An entrance effect names no entrance", {
|
|
136
|
+
path: `${path}/entrance`
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
const params = e.params;
|
|
140
|
+
if (params && typeof params === "object" && Object.keys(params).length > RECIPE_LIMITS.params)
|
|
141
|
+
out.push(bounds(`Params with ${Object.keys(params).length} keys`, `${path}/params`));
|
|
142
|
+
});
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
function dieMatches(c, d) {
|
|
146
|
+
if (c.die !== "any" && c.die !== d.kind) return false;
|
|
147
|
+
if (c.value !== 0 && c.value !== d.rolled) return false;
|
|
148
|
+
if (c.face && c.face !== d.label) return false;
|
|
149
|
+
if (c.state === "kept" && d.dropped) return false;
|
|
150
|
+
if (c.state === "dropped" && !d.dropped) return false;
|
|
151
|
+
if (c.rank === "high" && !d.high) return false;
|
|
152
|
+
if (c.rank === "low" && !d.low) return false;
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
function conditionMatches(c, roll, dice) {
|
|
156
|
+
switch (c.kind) {
|
|
157
|
+
case "total":
|
|
158
|
+
if (roll.total === void 0) return false;
|
|
159
|
+
return c.compare === "eq" ? roll.total === c.total : c.compare === "gte" ? roll.total >= c.total : roll.total <= c.total;
|
|
160
|
+
case "source": {
|
|
161
|
+
if (!roll.source) return false;
|
|
162
|
+
const who = roll.source.mine ? "mine" : roll.source.gm ? "gm" : "other";
|
|
163
|
+
return who === c.source;
|
|
164
|
+
}
|
|
165
|
+
case "tag":
|
|
166
|
+
return roll.tags?.includes(c.tag) ?? false;
|
|
167
|
+
default:
|
|
168
|
+
return dice.some((d) => dieMatches(c, d));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function matchRecipes(recipes, roll, dice) {
|
|
172
|
+
const unique = /* @__PURE__ */ new Map();
|
|
173
|
+
for (const r of recipes) if (!unique.has(r.id)) unique.set(r.id, r);
|
|
174
|
+
const matched = [];
|
|
175
|
+
let degraded = false;
|
|
176
|
+
for (const recipe of unique.values()) {
|
|
177
|
+
const cs = recipe.conditions;
|
|
178
|
+
const ok = cs.length === 0 || (recipe.match === "all" ? cs.every((c) => conditionMatches(c, roll, dice)) : cs.some((c) => conditionMatches(c, roll, dice)));
|
|
179
|
+
if (!ok) continue;
|
|
180
|
+
if (matched.length >= RECIPE_LIMITS.recipesPerRoll) {
|
|
181
|
+
degraded = true;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const dieConditions = cs.filter((c) => c.kind === "die");
|
|
185
|
+
const anchors = [];
|
|
186
|
+
dice.forEach((d, i) => {
|
|
187
|
+
if (d.dropped) return;
|
|
188
|
+
if (dieConditions.length === 0 || dieConditions.some((c) => dieMatches(c, d))) anchors.push(i);
|
|
189
|
+
});
|
|
190
|
+
matched.push({ recipe, anchors });
|
|
191
|
+
}
|
|
192
|
+
return { matched, degraded };
|
|
193
|
+
}
|
|
194
|
+
export {
|
|
195
|
+
DiceEffectRecipe,
|
|
196
|
+
RECIPE_LIMITS,
|
|
197
|
+
diceEffectRecipe,
|
|
198
|
+
matchRecipes,
|
|
199
|
+
recipeProblems
|
|
200
|
+
};
|
|
@@ -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[];
|