@idle-screens/schema 2.1.0 → 2.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/index.d.ts +92 -4
- package/dist/index.js +433 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/saver-spec.schema.json +20 -0
package/dist/index.js
CHANGED
|
@@ -20,8 +20,16 @@ var LIMITS = {
|
|
|
20
20
|
maxLinkLayerCount: 200,
|
|
21
21
|
minCyclePeriod: 500,
|
|
22
22
|
// ms — same flash-safety floor as pulse
|
|
23
|
-
referenceViewport: 1080
|
|
23
|
+
referenceViewport: 1080,
|
|
24
24
|
// for validating viewport-unit dimensional caps
|
|
25
|
+
maxTrailLength: 5e3,
|
|
26
|
+
// ms — cap trail duration
|
|
27
|
+
maxTrailSamples: 24,
|
|
28
|
+
// dots per trail
|
|
29
|
+
minDriftPeriod: 1e4,
|
|
30
|
+
// ms — background drift floor (10 s)
|
|
31
|
+
maxDriftAmount: 0.3
|
|
32
|
+
// fraction of gradient stop shift
|
|
25
33
|
};
|
|
26
34
|
|
|
27
35
|
// src/validate.ts
|
|
@@ -30,10 +38,45 @@ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
|
30
38
|
var isStr = (v) => typeof v === "string";
|
|
31
39
|
var isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
32
40
|
var isRange = (v) => Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] <= v[1];
|
|
41
|
+
var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "units", "background", "layers"]);
|
|
42
|
+
var KNOWN_LAYER = /* @__PURE__ */ new Set([
|
|
43
|
+
"count",
|
|
44
|
+
"sprite",
|
|
45
|
+
"motion",
|
|
46
|
+
"size",
|
|
47
|
+
"wrap",
|
|
48
|
+
"flip",
|
|
49
|
+
"alpha",
|
|
50
|
+
"blend",
|
|
51
|
+
"region",
|
|
52
|
+
"pulse",
|
|
53
|
+
"spin",
|
|
54
|
+
"grow",
|
|
55
|
+
"key",
|
|
56
|
+
"position",
|
|
57
|
+
"trail",
|
|
58
|
+
"links"
|
|
59
|
+
]);
|
|
60
|
+
var KNOWN_CIRCLE = /* @__PURE__ */ new Set(["kind", "radius", "color", "soft", "colors"]);
|
|
61
|
+
var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
|
|
62
|
+
var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle"]);
|
|
63
|
+
var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob"]);
|
|
64
|
+
var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway"]);
|
|
65
|
+
var KNOWN_BOUNCE = /* @__PURE__ */ new Set(["type", "speed"]);
|
|
66
|
+
var KNOWN_STATIC = /* @__PURE__ */ new Set(["type"]);
|
|
67
|
+
var KNOWN_ORBIT = /* @__PURE__ */ new Set(["type", "speed", "radius", "center"]);
|
|
68
|
+
var KNOWN_BG_SOLID = /* @__PURE__ */ new Set(["type", "color"]);
|
|
69
|
+
var KNOWN_BG_GRADIENT = /* @__PURE__ */ new Set(["type", "stops", "band", "drift"]);
|
|
70
|
+
var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key"]);
|
|
71
|
+
function unknownKeys(obj, known) {
|
|
72
|
+
return Object.keys(obj).filter((k) => !known.has(k));
|
|
73
|
+
}
|
|
33
74
|
function validateSpec(spec) {
|
|
34
75
|
const errors = [];
|
|
76
|
+
const warnings = [];
|
|
35
77
|
const err = (path, message) => void errors.push({ path, message });
|
|
36
|
-
|
|
78
|
+
const warn = (path, code, message) => void warnings.push({ path, code, message });
|
|
79
|
+
if (!isObj(spec)) return { valid: false, errors: [{ path: "", message: "spec must be an object" }], warnings: [] };
|
|
37
80
|
if (spec.schemaVersion !== SCHEMA_VERSION) err("schemaVersion", `must be ${SCHEMA_VERSION}`);
|
|
38
81
|
if (!isStr(spec.id) || spec.id.trim() === "") err("id", "must be a non-empty string");
|
|
39
82
|
if (!isStr(spec.label) || spec.label.trim() === "") err("label", "must be a non-empty string");
|
|
@@ -44,7 +87,10 @@ function validateSpec(spec) {
|
|
|
44
87
|
if (spec.units !== void 0 && spec.units !== "px" && spec.units !== "viewport") {
|
|
45
88
|
err("units", "must be 'px' | 'viewport'");
|
|
46
89
|
}
|
|
47
|
-
|
|
90
|
+
for (const k of unknownKeys(spec, KNOWN_TOP)) {
|
|
91
|
+
warn(k, "unknown-property", `unknown top-level property '${k}' \u2014 will be ignored`);
|
|
92
|
+
}
|
|
93
|
+
if (spec.background !== void 0) validateBackground(spec.background, err, warn);
|
|
48
94
|
if (!Array.isArray(spec.layers) || spec.layers.length === 0) {
|
|
49
95
|
err("layers", "must be a non-empty array");
|
|
50
96
|
} else {
|
|
@@ -52,19 +98,22 @@ function validateSpec(spec) {
|
|
|
52
98
|
let total = 0;
|
|
53
99
|
spec.layers.forEach((layer, i) => {
|
|
54
100
|
total += isObj(layer) && isNum(layer.count) ? layer.count : 0;
|
|
55
|
-
validateLayer(layer, `layers[${i}]`, err, spec);
|
|
101
|
+
validateLayer(layer, `layers[${i}]`, err, warn, spec);
|
|
56
102
|
});
|
|
57
103
|
if (total > LIMITS.maxTotal) err("layers", `total entities ${total} exceeds cap ${LIMITS.maxTotal}`);
|
|
58
104
|
}
|
|
59
|
-
return { valid: errors.length === 0, errors };
|
|
105
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
60
106
|
}
|
|
61
107
|
function color(v, path, err) {
|
|
62
108
|
if (!isStr(v) || !HEX.test(v)) err(path, "must be a hex colour like #0a3a52");
|
|
63
109
|
}
|
|
64
|
-
function validateBackground(bg, err) {
|
|
110
|
+
function validateBackground(bg, err, warn) {
|
|
65
111
|
if (!isObj(bg)) return err("background", "must be an object");
|
|
66
112
|
if (bg.type === "solid") {
|
|
67
113
|
color(bg.color, "background.color", err);
|
|
114
|
+
for (const k of unknownKeys(bg, KNOWN_BG_SOLID)) {
|
|
115
|
+
warn(`background.${k}`, "unknown-property", `unknown background property '${k}' \u2014 will be ignored`);
|
|
116
|
+
}
|
|
68
117
|
} else if (bg.type === "gradient") {
|
|
69
118
|
if (!Array.isArray(bg.stops) || bg.stops.length < 2) {
|
|
70
119
|
err("background.stops", "gradient needs at least 2 stops");
|
|
@@ -74,6 +123,17 @@ function validateBackground(bg, err) {
|
|
|
74
123
|
color(isObj(s) ? s.color : void 0, `background.stops[${i}].color`, err);
|
|
75
124
|
});
|
|
76
125
|
}
|
|
126
|
+
if (bg.drift !== void 0) {
|
|
127
|
+
if (!isObj(bg.drift)) err("background.drift", "must be an object");
|
|
128
|
+
else {
|
|
129
|
+
if (!isNum(bg.drift.period) || bg.drift.period < LIMITS.minDriftPeriod) {
|
|
130
|
+
err("background.drift.period", `must be >= ${LIMITS.minDriftPeriod} ms`);
|
|
131
|
+
}
|
|
132
|
+
if (bg.drift.amount !== void 0 && (!isNum(bg.drift.amount) || bg.drift.amount <= 0 || bg.drift.amount > LIMITS.maxDriftAmount)) {
|
|
133
|
+
err("background.drift.amount", `must be > 0 and <= ${LIMITS.maxDriftAmount}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
77
137
|
if (bg.band !== void 0) {
|
|
78
138
|
if (!isObj(bg.band)) err("background.band", "must be an object");
|
|
79
139
|
else {
|
|
@@ -81,12 +141,24 @@ function validateBackground(bg, err) {
|
|
|
81
141
|
if (!isNum(bg.band.height) || bg.band.height <= 0) err("background.band.height", "must be > 0");
|
|
82
142
|
}
|
|
83
143
|
}
|
|
144
|
+
for (const k of unknownKeys(bg, KNOWN_BG_GRADIENT)) {
|
|
145
|
+
warn(`background.${k}`, "unknown-property", `unknown background property '${k}' \u2014 will be ignored`);
|
|
146
|
+
}
|
|
84
147
|
} else {
|
|
85
148
|
err("background.type", "must be solid | gradient");
|
|
86
149
|
}
|
|
87
150
|
}
|
|
88
|
-
function validateLayer(layer, path, err, spec) {
|
|
151
|
+
function validateLayer(layer, path, err, warn, spec) {
|
|
89
152
|
if (!isObj(layer)) return err(path, "must be an object");
|
|
153
|
+
for (const k of unknownKeys(layer, KNOWN_LAYER)) {
|
|
154
|
+
if (k === "id") {
|
|
155
|
+
warn(`${path}.id`, "misplaced-property", `'id' is not a layer property \u2014 did you mean 'key'? Use 'key' to name a layer`);
|
|
156
|
+
} else if (k === "depth") {
|
|
157
|
+
warn(`${path}.depth`, "unknown-property", `'depth' is not yet supported \u2014 fake parallax with size/speed/alpha across layers instead`);
|
|
158
|
+
} else {
|
|
159
|
+
warn(`${path}.${k}`, "unknown-property", `unknown layer property '${k}' \u2014 will be ignored`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
90
162
|
if (!isNum(layer.count) || !Number.isInteger(layer.count) || layer.count < 1) {
|
|
91
163
|
err(`${path}.count`, "must be a positive integer");
|
|
92
164
|
} else if (layer.count > LIMITS.maxPerLayer) {
|
|
@@ -171,17 +243,31 @@ function validateLayer(layer, path, err, spec) {
|
|
|
171
243
|
}
|
|
172
244
|
}
|
|
173
245
|
}
|
|
174
|
-
|
|
175
|
-
|
|
246
|
+
if (layer.trail !== void 0) {
|
|
247
|
+
if (!isObj(layer.trail)) err(`${path}.trail`, "must be an object");
|
|
248
|
+
else {
|
|
249
|
+
if (!isNum(layer.trail.length) || layer.trail.length <= 0 || layer.trail.length > LIMITS.maxTrailLength) {
|
|
250
|
+
err(`${path}.trail.length`, `must be > 0 and <= ${LIMITS.maxTrailLength} ms`);
|
|
251
|
+
}
|
|
252
|
+
if (layer.trail.fade !== void 0 && (!isNum(layer.trail.fade) || layer.trail.fade < 0 || layer.trail.fade > 1)) {
|
|
253
|
+
err(`${path}.trail.fade`, "must be 0..1");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
validateSprite(layer.sprite, `${path}.sprite`, err, warn);
|
|
258
|
+
validateMotion(layer.motion, `${path}.motion`, err, warn, spec);
|
|
176
259
|
}
|
|
177
|
-
function validateSprite(sprite, path, err) {
|
|
260
|
+
function validateSprite(sprite, path, err, warn) {
|
|
178
261
|
if (!isObj(sprite)) return err(path, "must be an object");
|
|
262
|
+
let knownSet;
|
|
179
263
|
if (sprite.kind === "emoji") {
|
|
264
|
+
knownSet = KNOWN_EMOJI;
|
|
180
265
|
if (!Array.isArray(sprite.glyphs) || sprite.glyphs.length === 0 || !sprite.glyphs.every(isStr)) {
|
|
181
266
|
err(`${path}.glyphs`, "must be a non-empty array of strings");
|
|
182
267
|
}
|
|
183
268
|
validateCycle(sprite, path, err);
|
|
184
269
|
} else if (sprite.kind === "text") {
|
|
270
|
+
knownSet = KNOWN_TEXT;
|
|
185
271
|
if (!Array.isArray(sprite.strings) || sprite.strings.length === 0 || !sprite.strings.every(isStr)) {
|
|
186
272
|
err(`${path}.strings`, "must be a non-empty array of strings");
|
|
187
273
|
}
|
|
@@ -197,6 +283,7 @@ function validateSprite(sprite, path, err) {
|
|
|
197
283
|
}
|
|
198
284
|
validateCycle(sprite, path, err);
|
|
199
285
|
} else if (sprite.kind === "circle") {
|
|
286
|
+
knownSet = KNOWN_CIRCLE;
|
|
200
287
|
if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
|
|
201
288
|
color(sprite.color, `${path}.color`, err);
|
|
202
289
|
if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
|
|
@@ -209,6 +296,14 @@ function validateSprite(sprite, path, err) {
|
|
|
209
296
|
}
|
|
210
297
|
} else {
|
|
211
298
|
err(`${path}.kind`, "must be emoji | text | circle");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
for (const k of unknownKeys(sprite, knownSet)) {
|
|
302
|
+
if (LAYER_PROPS_ON_SPRITE.has(k)) {
|
|
303
|
+
warn(`${path}.${k}`, "misplaced-property", `'${k}' belongs on the layer, not inside sprite \u2014 move it up one level`);
|
|
304
|
+
} else {
|
|
305
|
+
warn(`${path}.${k}`, "unknown-property", `unknown sprite property '${k}' \u2014 will be ignored`);
|
|
306
|
+
}
|
|
212
307
|
}
|
|
213
308
|
}
|
|
214
309
|
function validateCycle(sprite, path, err) {
|
|
@@ -218,7 +313,7 @@ function validateCycle(sprite, path, err) {
|
|
|
218
313
|
err(`${path}.cycle.period`, `must be >= ${LIMITS.minCyclePeriod} ms (flash-safety floor)`);
|
|
219
314
|
}
|
|
220
315
|
}
|
|
221
|
-
function validateMotion(motion, path, err, spec) {
|
|
316
|
+
function validateMotion(motion, path, err, warn, spec) {
|
|
222
317
|
if (!isObj(motion)) return err(path, "must be an object");
|
|
223
318
|
const isViewport = isObj(spec) && spec.units === "viewport";
|
|
224
319
|
const speedCap = isViewport ? LIMITS.maxSpeed / LIMITS.referenceViewport : LIMITS.maxSpeed;
|
|
@@ -226,20 +321,40 @@ function validateMotion(motion, path, err, spec) {
|
|
|
226
321
|
if (!isRange(v)) err(p, "must be a [min,max] range");
|
|
227
322
|
else if (v[1] > speedCap) err(p, `speed exceeds cap ${isViewport ? speedCap.toFixed(2) + " viewport-units/sec" : LIMITS.maxSpeed + " px/sec"}`);
|
|
228
323
|
};
|
|
324
|
+
let knownSet;
|
|
229
325
|
if (motion.type === "drift") {
|
|
326
|
+
knownSet = KNOWN_DRIFT;
|
|
230
327
|
speedOk(motion.speed, `${path}.speed`);
|
|
231
328
|
if (motion.angle !== void 0 && !isNum(motion.angle)) err(`${path}.angle`, "must be a number (degrees)");
|
|
232
329
|
if (motion.bidirectional !== void 0 && typeof motion.bidirectional !== "boolean") err(`${path}.bidirectional`, "must be a boolean");
|
|
233
330
|
if (motion.bob !== void 0 && !isNum(motion.bob)) err(`${path}.bob`, "must be a number");
|
|
331
|
+
if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
|
|
332
|
+
warn(`${path}.speed`, "near-zero-speed", `max speed is ${motion.speed[1]} px/sec \u2014 entities will appear frozen. Typical range: 10\u2013200 px/sec`);
|
|
333
|
+
}
|
|
234
334
|
} else if (motion.type === "rise") {
|
|
335
|
+
knownSet = KNOWN_RISE;
|
|
235
336
|
speedOk(motion.speed, `${path}.speed`);
|
|
236
337
|
if (motion.sway !== void 0 && !isNum(motion.sway)) err(`${path}.sway`, "must be a number");
|
|
338
|
+
if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
|
|
339
|
+
warn(`${path}.speed`, "near-zero-speed", `max speed is ${motion.speed[1]} px/sec \u2014 entities will appear frozen. Typical range: 5\u201380 px/sec`);
|
|
340
|
+
}
|
|
237
341
|
} else if (motion.type === "bounce") {
|
|
342
|
+
knownSet = KNOWN_BOUNCE;
|
|
238
343
|
speedOk(motion.speed, `${path}.speed`);
|
|
344
|
+
if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
|
|
345
|
+
warn(`${path}.speed`, "near-zero-speed", `max speed is ${motion.speed[1]} px/sec \u2014 entities will appear frozen. Typical range: 20\u2013150 px/sec`);
|
|
346
|
+
}
|
|
239
347
|
} else if (motion.type === "static") {
|
|
348
|
+
knownSet = KNOWN_STATIC;
|
|
240
349
|
} else if (motion.type === "orbit") {
|
|
350
|
+
knownSet = KNOWN_ORBIT;
|
|
241
351
|
if (!isRange(motion.speed)) err(`${path}.speed`, "must be a [min,max] range");
|
|
242
|
-
else
|
|
352
|
+
else {
|
|
353
|
+
if (Math.abs(motion.speed[0]) > LIMITS.maxOrbitSpeed || Math.abs(motion.speed[1]) > LIMITS.maxOrbitSpeed) err(`${path}.speed`, `orbit speed exceeds cap ${LIMITS.maxOrbitSpeed} deg/sec`);
|
|
354
|
+
if (Math.max(Math.abs(motion.speed[0]), Math.abs(motion.speed[1])) < 1) {
|
|
355
|
+
warn(`${path}.speed`, "near-zero-speed", `orbit speed is near zero \u2014 entities will appear frozen. Typical range: 5\u201360 deg/sec`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
243
358
|
if (!isRange(motion.radius) || motion.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
|
|
244
359
|
if (motion.center !== void 0) {
|
|
245
360
|
if (!isObj(motion.center) || !isNum(motion.center.x) || !isNum(motion.center.y)) {
|
|
@@ -251,6 +366,10 @@ function validateMotion(motion, path, err, spec) {
|
|
|
251
366
|
}
|
|
252
367
|
} else {
|
|
253
368
|
err(`${path}.type`, "must be drift | rise | bounce | static | orbit");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
for (const k of unknownKeys(motion, knownSet)) {
|
|
372
|
+
warn(`${path}.${k}`, "unknown-property", `unknown motion property '${k}' \u2014 will be ignored`);
|
|
254
373
|
}
|
|
255
374
|
}
|
|
256
375
|
function assertValidSpec(spec) {
|
|
@@ -268,13 +387,17 @@ function spriteVariants(sprite) {
|
|
|
268
387
|
if (sprite.kind === "text") return sprite.strings.length;
|
|
269
388
|
return 1;
|
|
270
389
|
}
|
|
271
|
-
function buildEntities(layer, rng, w, h, scale = 1) {
|
|
390
|
+
function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
|
|
391
|
+
const effectiveCount = countScale === 1 ? layer.count : Math.max(1, Math.min(
|
|
392
|
+
Math.round(layer.count * countScale),
|
|
393
|
+
layer.links ? LIMITS.maxLinkLayerCount : LIMITS.maxPerLayer
|
|
394
|
+
));
|
|
272
395
|
const [smin, smax] = layer.size ?? [20, 40];
|
|
273
396
|
const variants = spriteVariants(layer.sprite);
|
|
274
397
|
const colorsLen = layer.sprite.kind === "circle" && layer.sprite.colors ? layer.sprite.colors.length : 0;
|
|
275
398
|
const cycle = layer.sprite.kind === "emoji" || layer.sprite.kind === "text" ? layer.sprite.cycle : void 0;
|
|
276
399
|
const out = [];
|
|
277
|
-
for (let i = 0; i <
|
|
400
|
+
for (let i = 0; i < effectiveCount; i++) {
|
|
278
401
|
const size = layer.sprite.kind === "circle" ? rng.range(layer.sprite.radius[0], layer.sprite.radius[1]) * 2 * scale : rng.range(smin, smax) * scale;
|
|
279
402
|
let vx = 0;
|
|
280
403
|
let vy = 0;
|
|
@@ -513,6 +636,32 @@ function lerpSpec(from, to, k) {
|
|
|
513
636
|
};
|
|
514
637
|
return walk(from, to);
|
|
515
638
|
}
|
|
639
|
+
function steerablePaths(spec) {
|
|
640
|
+
if (!spec || typeof spec !== "object") return [];
|
|
641
|
+
const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units"]);
|
|
642
|
+
const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
|
|
643
|
+
const out = [];
|
|
644
|
+
const walk = (node, prefix, key) => {
|
|
645
|
+
if (Array.isArray(node) && INDEXED.has(key)) {
|
|
646
|
+
node.forEach((child, i) => walk(child, `${prefix}.${i}`, ""));
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (Array.isArray(node)) {
|
|
650
|
+
if (prefix) out.push(prefix);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (node && typeof node === "object") {
|
|
654
|
+
for (const [k, v] of Object.entries(node)) {
|
|
655
|
+
if (SKIP.has(k)) continue;
|
|
656
|
+
walk(v, prefix ? `${prefix}.${k}` : k, k);
|
|
657
|
+
}
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (prefix) out.push(prefix);
|
|
661
|
+
};
|
|
662
|
+
walk(spec, "", "");
|
|
663
|
+
return out;
|
|
664
|
+
}
|
|
516
665
|
function easeSmooth(k) {
|
|
517
666
|
const c = Math.max(0, Math.min(1, k));
|
|
518
667
|
return c * c * (3 - 2 * c);
|
|
@@ -617,11 +766,16 @@ var SpecInstance = class {
|
|
|
617
766
|
this.canvas.height = Math.max(1, Math.round(this.h * dpr));
|
|
618
767
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
619
768
|
}
|
|
620
|
-
/** (Re)seed and place all entities — deterministic for the seed +
|
|
769
|
+
/** (Re)seed and place all entities — deterministic for the seed + viewport. */
|
|
621
770
|
rebuild() {
|
|
622
771
|
const rng = createRng(this.seed);
|
|
623
772
|
const scale = this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1;
|
|
624
|
-
|
|
773
|
+
let countScale = scale > 1 ? Math.min(this.w, this.h) / LIMITS.referenceViewport : 1;
|
|
774
|
+
if (countScale > 1) {
|
|
775
|
+
const rawTotal = this.effSpec.layers.reduce((s, l) => s + Math.round(l.count * countScale), 0);
|
|
776
|
+
if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
|
|
777
|
+
}
|
|
778
|
+
this.layers = this.effSpec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, this.w, this.h, scale, countScale) }));
|
|
625
779
|
this.lastStructural = structuralSignature(this.effSpec);
|
|
626
780
|
}
|
|
627
781
|
start() {
|
|
@@ -642,7 +796,7 @@ var SpecInstance = class {
|
|
|
642
796
|
this.lastT = now - this.startT + this.baseT;
|
|
643
797
|
this.renderFrame(this.lastT, this.seed);
|
|
644
798
|
}
|
|
645
|
-
drawBackground() {
|
|
799
|
+
drawBackground(t) {
|
|
646
800
|
const { ctx, w, h } = this;
|
|
647
801
|
const bg = this.effSpec.background;
|
|
648
802
|
if (!bg || bg.type === "solid") {
|
|
@@ -651,7 +805,17 @@ var SpecInstance = class {
|
|
|
651
805
|
return;
|
|
652
806
|
}
|
|
653
807
|
const g = ctx.createLinearGradient(0, 0, 0, h);
|
|
654
|
-
|
|
808
|
+
const drift = bg.drift;
|
|
809
|
+
for (let i = 0; i < bg.stops.length; i++) {
|
|
810
|
+
const s = bg.stops[i];
|
|
811
|
+
let at = s.at;
|
|
812
|
+
if (drift) {
|
|
813
|
+
const amount = drift.amount ?? 0.15;
|
|
814
|
+
const phase = i / bg.stops.length * Math.PI * 2;
|
|
815
|
+
at = Math.max(0, Math.min(1, at + amount * Math.sin(t * 2 * Math.PI / drift.period + phase)));
|
|
816
|
+
}
|
|
817
|
+
g.addColorStop(at, s.color);
|
|
818
|
+
}
|
|
655
819
|
ctx.fillStyle = g;
|
|
656
820
|
ctx.fillRect(0, 0, w, h);
|
|
657
821
|
if (bg.band) {
|
|
@@ -660,6 +824,52 @@ var SpecInstance = class {
|
|
|
660
824
|
ctx.fillRect(0, h - bh, w, bh);
|
|
661
825
|
}
|
|
662
826
|
}
|
|
827
|
+
drawTrail(built, e, t) {
|
|
828
|
+
const trail = built.layer.trail;
|
|
829
|
+
if (!trail) return;
|
|
830
|
+
const { ctx, w, h } = this;
|
|
831
|
+
const fade = trail.fade ?? 1;
|
|
832
|
+
const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
|
|
833
|
+
const headAlpha = alphaAt(e, t);
|
|
834
|
+
const headSize = sizeAt(e, t);
|
|
835
|
+
const sprite = built.layer.sprite;
|
|
836
|
+
const resolvedColor = sprite.kind === "circle" ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
|
|
837
|
+
const isSoft = sprite.kind === "circle" && sprite.soft;
|
|
838
|
+
const wrap2 = built.layer.wrap !== false;
|
|
839
|
+
const head = positionAt(e, t, w, h);
|
|
840
|
+
let prevX = head.x;
|
|
841
|
+
let prevY = head.y;
|
|
842
|
+
for (let s = 1; s <= n; s++) {
|
|
843
|
+
const k = s / n;
|
|
844
|
+
const pastT = t - k * trail.length;
|
|
845
|
+
if (pastT < 0) break;
|
|
846
|
+
const pos = positionAt(e, pastT, w, h);
|
|
847
|
+
if (wrap2) {
|
|
848
|
+
const dx = pos.x - prevX;
|
|
849
|
+
const dy = pos.y - prevY;
|
|
850
|
+
if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) break;
|
|
851
|
+
}
|
|
852
|
+
prevX = pos.x;
|
|
853
|
+
prevY = pos.y;
|
|
854
|
+
const a = headAlpha * (1 - k * fade);
|
|
855
|
+
if (a <= 0) break;
|
|
856
|
+
const r = headSize / 2 * (1 - k * 0.7);
|
|
857
|
+
if (r < 0.2) break;
|
|
858
|
+
ctx.globalAlpha = a;
|
|
859
|
+
if (isSoft) {
|
|
860
|
+
const g = ctx.createRadialGradient(pos.x, pos.y, 0, pos.x, pos.y, r);
|
|
861
|
+
g.addColorStop(0, resolvedColor);
|
|
862
|
+
g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
|
|
863
|
+
g.addColorStop(1, hexToRgba(resolvedColor, 0));
|
|
864
|
+
ctx.fillStyle = g;
|
|
865
|
+
} else {
|
|
866
|
+
ctx.fillStyle = resolvedColor;
|
|
867
|
+
}
|
|
868
|
+
ctx.beginPath();
|
|
869
|
+
ctx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
|
|
870
|
+
ctx.fill();
|
|
871
|
+
}
|
|
872
|
+
}
|
|
663
873
|
drawEntity(built, e, t) {
|
|
664
874
|
const { ctx } = this;
|
|
665
875
|
const p = positionAt(e, t, this.w, this.h);
|
|
@@ -791,11 +1001,14 @@ var SpecInstance = class {
|
|
|
791
1001
|
const { ctx } = this;
|
|
792
1002
|
ctx.globalAlpha = 1;
|
|
793
1003
|
ctx.globalCompositeOperation = "source-over";
|
|
794
|
-
this.drawBackground();
|
|
1004
|
+
this.drawBackground(t);
|
|
795
1005
|
for (const built of this.layers) {
|
|
796
1006
|
ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
|
|
797
1007
|
this.drawLinks(built, t);
|
|
798
|
-
for (const e of built.entities)
|
|
1008
|
+
for (const e of built.entities) {
|
|
1009
|
+
this.drawTrail(built, e, t);
|
|
1010
|
+
this.drawEntity(built, e, t);
|
|
1011
|
+
}
|
|
799
1012
|
}
|
|
800
1013
|
ctx.globalAlpha = 1;
|
|
801
1014
|
ctx.globalCompositeOperation = "source-over";
|
|
@@ -837,8 +1050,9 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
|
|
|
837
1050
|
const w = viewport.width;
|
|
838
1051
|
const h = viewport.height;
|
|
839
1052
|
const scale = spec.units === "viewport" ? Math.min(w, h) : 1;
|
|
1053
|
+
const countScale = scale > 1 ? Math.min(w, h) / LIMITS.referenceViewport : 1;
|
|
840
1054
|
const rng = createRng2(spec.seed ?? 42);
|
|
841
|
-
const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale));
|
|
1055
|
+
const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
|
|
842
1056
|
let totalEntities = 0;
|
|
843
1057
|
let textLayerCount = 0;
|
|
844
1058
|
let motionLayerCount = 0;
|
|
@@ -847,10 +1061,16 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
|
|
|
847
1061
|
const entities = allEntities[li];
|
|
848
1062
|
totalEntities += entities.length;
|
|
849
1063
|
const isText = layer.sprite.kind === "text";
|
|
850
|
-
const isEmoji = layer.sprite.kind === "emoji";
|
|
851
1064
|
const isStaticText = isText && layer.motion.type === "static";
|
|
852
1065
|
if (isStaticText) textLayerCount++;
|
|
853
1066
|
if (layer.motion.type !== "static") motionLayerCount++;
|
|
1067
|
+
if (layer.trail && layer.motion.type === "static") {
|
|
1068
|
+
warnings.push({
|
|
1069
|
+
path: `layers[${li}].trail`,
|
|
1070
|
+
code: "trail-on-static",
|
|
1071
|
+
message: "trail has no effect on static entities \u2014 they have no past positions to draw"
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
854
1074
|
if (layer.sprite.kind === "circle") {
|
|
855
1075
|
const maxR = entities.reduce((m, e) => Math.max(m, e.size / 2), 0);
|
|
856
1076
|
const maxAlpha = entities.reduce((m, e) => Math.max(m, e.alpha), 0);
|
|
@@ -897,9 +1117,148 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
|
|
|
897
1117
|
});
|
|
898
1118
|
}
|
|
899
1119
|
}
|
|
1120
|
+
for (let li = 0; li < spec.layers.length; li++) {
|
|
1121
|
+
const layer = spec.layers[li];
|
|
1122
|
+
if (!layer.links) continue;
|
|
1123
|
+
const entities = allEntities[li];
|
|
1124
|
+
const positions = entities.map((e) => positionAt(e, 0, w, h));
|
|
1125
|
+
const maxDist = layer.links.maxDist * scale;
|
|
1126
|
+
const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
|
|
1127
|
+
const maxPossible = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
|
|
1128
|
+
if (maxPossible > 0 && pairs.length / maxPossible < 0.1) {
|
|
1129
|
+
warnings.push({
|
|
1130
|
+
path: `layers[${li}].links`,
|
|
1131
|
+
code: "link-starvation",
|
|
1132
|
+
message: `only ${pairs.length}/${maxPossible} possible edges formed \u2014 raise count or widen links.maxDist`
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
for (let li = 0; li < spec.layers.length; li++) {
|
|
1137
|
+
const layer = spec.layers[li];
|
|
1138
|
+
if (layer.motion.type === "static") continue;
|
|
1139
|
+
const entities = allEntities[li];
|
|
1140
|
+
if (entities.length < 3) continue;
|
|
1141
|
+
const speeds = entities.map((e) => Math.sqrt(e.vx * e.vx + e.vy * e.vy));
|
|
1142
|
+
const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length;
|
|
1143
|
+
if (mean === 0) continue;
|
|
1144
|
+
const maxDev = Math.max(...speeds.map((s) => Math.abs(s - mean)));
|
|
1145
|
+
if (maxDev / mean < 0.05) {
|
|
1146
|
+
warnings.push({
|
|
1147
|
+
path: `layers[${li}].motion`,
|
|
1148
|
+
code: "uniform-motion",
|
|
1149
|
+
message: "all entities move at nearly identical speed \u2014 widen the speed range for visual variety"
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
if (totalEntities >= 8) {
|
|
1154
|
+
const positions = allEntities.flatMap((ents) => ents.map((e) => positionAt(e, 0, w, h)));
|
|
1155
|
+
const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
|
|
1156
|
+
const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
|
|
1157
|
+
const offX = Math.abs(cx / w - 0.5);
|
|
1158
|
+
const offY = Math.abs(cy / h - 0.5);
|
|
1159
|
+
if (offX > 0.2 || offY > 0.2) {
|
|
1160
|
+
warnings.push({
|
|
1161
|
+
path: "layers",
|
|
1162
|
+
code: "off-center",
|
|
1163
|
+
message: `centroid at (${(cx / w).toFixed(2)}, ${(cy / h).toFixed(2)}) \u2014 composition is heavily offset from center`
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
900
1167
|
return warnings;
|
|
901
1168
|
}
|
|
902
1169
|
|
|
1170
|
+
// src/describe.ts
|
|
1171
|
+
import { createRng as createRng3 } from "@idle-screens/core";
|
|
1172
|
+
function describeScene(spec, options = {}) {
|
|
1173
|
+
const { width: w, height: h } = options.viewport ?? { width: 1920, height: 1080 };
|
|
1174
|
+
const seed = options.seed ?? spec.seed ?? 42;
|
|
1175
|
+
const times = options.times ?? [0, 5e3, 15e3];
|
|
1176
|
+
const scale = spec.units === "viewport" ? Math.min(w, h) : 1;
|
|
1177
|
+
const countScale = scale > 1 ? Math.min(w, h) / LIMITS.referenceViewport : 1;
|
|
1178
|
+
const rng = createRng3(seed);
|
|
1179
|
+
const builtLayers = spec.layers.map((layer) => ({
|
|
1180
|
+
layer,
|
|
1181
|
+
entities: buildEntities(layer, rng, w, h, scale, countScale)
|
|
1182
|
+
}));
|
|
1183
|
+
const snapshots = times.map((t) => {
|
|
1184
|
+
const layerSnapshots = builtLayers.map(({ layer, entities }) => {
|
|
1185
|
+
const positions = entities.map((e) => positionAt(e, t, w, h));
|
|
1186
|
+
const cx = positions.length > 0 ? positions.reduce((s, p) => s + p.x, 0) / positions.length : 0;
|
|
1187
|
+
const cy = positions.length > 0 ? positions.reduce((s, p) => s + p.y, 0) / positions.length : 0;
|
|
1188
|
+
let pixArea = 0;
|
|
1189
|
+
for (const e of entities) {
|
|
1190
|
+
const r = e.size / 2;
|
|
1191
|
+
pixArea += layer.sprite.kind === "circle" ? Math.PI * r * r : e.size * e.size;
|
|
1192
|
+
}
|
|
1193
|
+
const coverage = pixArea / (w * h);
|
|
1194
|
+
const meanAlpha = entities.length > 0 ? entities.reduce((s, e) => s + e.alpha, 0) / entities.length : 0;
|
|
1195
|
+
let linksDrawn = null;
|
|
1196
|
+
let linksExpected = null;
|
|
1197
|
+
let connectedComponents = null;
|
|
1198
|
+
let isolatedNodes = null;
|
|
1199
|
+
if (layer.links) {
|
|
1200
|
+
const maxDist = layer.links.maxDist * scale;
|
|
1201
|
+
const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
|
|
1202
|
+
linksDrawn = pairs.length;
|
|
1203
|
+
linksExpected = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
|
|
1204
|
+
const parent = Array.from({ length: entities.length }, (_, i) => i);
|
|
1205
|
+
const find = (x) => {
|
|
1206
|
+
while (parent[x] !== x) {
|
|
1207
|
+
parent[x] = parent[parent[x]];
|
|
1208
|
+
x = parent[x];
|
|
1209
|
+
}
|
|
1210
|
+
return x;
|
|
1211
|
+
};
|
|
1212
|
+
for (const [a, b] of pairs) {
|
|
1213
|
+
const ra = find(a);
|
|
1214
|
+
const rb = find(b);
|
|
1215
|
+
if (ra !== rb) parent[ra] = rb;
|
|
1216
|
+
}
|
|
1217
|
+
const roots = new Set(entities.map((_, i) => find(i)));
|
|
1218
|
+
connectedComponents = roots.size;
|
|
1219
|
+
const degree = new Uint16Array(entities.length);
|
|
1220
|
+
for (const [a, b] of pairs) {
|
|
1221
|
+
degree[a]++;
|
|
1222
|
+
degree[b]++;
|
|
1223
|
+
}
|
|
1224
|
+
isolatedNodes = degree.filter((d) => d === 0).length;
|
|
1225
|
+
}
|
|
1226
|
+
return {
|
|
1227
|
+
key: layer.key,
|
|
1228
|
+
count: entities.length,
|
|
1229
|
+
centroid: { x: +(cx / w).toFixed(3), y: +(cy / h).toFixed(3) },
|
|
1230
|
+
coverage: +coverage.toFixed(4),
|
|
1231
|
+
meanAlpha: +meanAlpha.toFixed(3),
|
|
1232
|
+
linksDrawn,
|
|
1233
|
+
linksExpected,
|
|
1234
|
+
connectedComponents,
|
|
1235
|
+
isolatedNodes
|
|
1236
|
+
};
|
|
1237
|
+
});
|
|
1238
|
+
const warnings = [];
|
|
1239
|
+
for (const ls of layerSnapshots) {
|
|
1240
|
+
const label = ls.key ?? `layer`;
|
|
1241
|
+
if (ls.coverage * ls.meanAlpha < 5e-4) {
|
|
1242
|
+
warnings.push(`${label}: effectively invisible (coverage\xD7alpha=${(ls.coverage * ls.meanAlpha).toFixed(5)})`);
|
|
1243
|
+
}
|
|
1244
|
+
if (ls.linksDrawn !== null && ls.linksExpected !== null && ls.linksExpected > 0) {
|
|
1245
|
+
const ratio = ls.linksDrawn / ls.linksExpected;
|
|
1246
|
+
if (ratio < 0.1) {
|
|
1247
|
+
warnings.push(`${label}: link starvation \u2014 ${ls.linksDrawn}/${ls.linksExpected} edges formed`);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
if (ls.isolatedNodes !== null && ls.count > 0 && ls.isolatedNodes / ls.count > 0.5) {
|
|
1251
|
+
warnings.push(`${label}: ${ls.isolatedNodes}/${ls.count} nodes isolated \u2014 graph is disconnected`);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
return { t, viewport: { w, h }, layers: layerSnapshots, warnings };
|
|
1255
|
+
});
|
|
1256
|
+
return {
|
|
1257
|
+
spec: { id: spec.id, label: spec.label, units: spec.units ?? "px" },
|
|
1258
|
+
snapshots
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
903
1262
|
// src/examples/aquarium.ts
|
|
904
1263
|
var AQUARIUM_SPEC = {
|
|
905
1264
|
schemaVersion: 1,
|
|
@@ -932,6 +1291,53 @@ var AQUARIUM_SPEC = {
|
|
|
932
1291
|
]
|
|
933
1292
|
};
|
|
934
1293
|
|
|
1294
|
+
// src/examples/comets.ts
|
|
1295
|
+
var COMETS_SPEC = {
|
|
1296
|
+
schemaVersion: 1,
|
|
1297
|
+
id: "comets",
|
|
1298
|
+
label: "Comet Shower",
|
|
1299
|
+
seed: 42,
|
|
1300
|
+
motionIntensity: "moderate",
|
|
1301
|
+
background: {
|
|
1302
|
+
type: "gradient",
|
|
1303
|
+
stops: [
|
|
1304
|
+
{ at: 0, color: "#0a0015" },
|
|
1305
|
+
{ at: 0.5, color: "#0d1b2a" },
|
|
1306
|
+
{ at: 1, color: "#1b0a2e" }
|
|
1307
|
+
],
|
|
1308
|
+
drift: { period: 25e3, amount: 0.12 }
|
|
1309
|
+
},
|
|
1310
|
+
layers: [
|
|
1311
|
+
{
|
|
1312
|
+
key: "stars",
|
|
1313
|
+
count: 120,
|
|
1314
|
+
sprite: { kind: "circle", radius: [0.5, 2], color: "#ffffff", soft: true },
|
|
1315
|
+
motion: { type: "static" },
|
|
1316
|
+
alpha: [0.3, 0.7],
|
|
1317
|
+
pulse: { amp: 0.15, period: 3e3 }
|
|
1318
|
+
},
|
|
1319
|
+
{
|
|
1320
|
+
key: "comets",
|
|
1321
|
+
count: 8,
|
|
1322
|
+
sprite: { kind: "circle", radius: [3, 8], color: "#88ccff", soft: true },
|
|
1323
|
+
motion: { type: "drift", speed: [80, 200], angle: 225 },
|
|
1324
|
+
alpha: [0.7, 1],
|
|
1325
|
+
blend: "lighter",
|
|
1326
|
+
trail: { length: 1500, fade: 1 }
|
|
1327
|
+
},
|
|
1328
|
+
{
|
|
1329
|
+
key: "fireflies",
|
|
1330
|
+
count: 20,
|
|
1331
|
+
sprite: { kind: "circle", radius: [2, 5], color: "#ffdd44", soft: true },
|
|
1332
|
+
motion: { type: "drift", speed: [10, 30], bidirectional: true, bob: 20 },
|
|
1333
|
+
alpha: [0.4, 0.8],
|
|
1334
|
+
pulse: { amp: 0.3, period: 2e3 },
|
|
1335
|
+
blend: "lighter",
|
|
1336
|
+
trail: { length: 800 }
|
|
1337
|
+
}
|
|
1338
|
+
]
|
|
1339
|
+
};
|
|
1340
|
+
|
|
935
1341
|
// src/examples/constellation.ts
|
|
936
1342
|
var CONSTELLATION_SPEC = {
|
|
937
1343
|
schemaVersion: 1,
|
|
@@ -1746,7 +2152,8 @@ var SCHEMA_EXAMPLES = [
|
|
|
1746
2152
|
{ id: "sakura", label: "Sakura Drift", spec: SAKURA_SPEC },
|
|
1747
2153
|
{ id: "dev-dashboard", label: "Dev Dashboard", spec: DASHBOARD_SPEC },
|
|
1748
2154
|
{ id: "orrery", label: "Orrery", spec: ORRERY_SPEC },
|
|
1749
|
-
{ id: "constellation", label: "Constellation", spec: CONSTELLATION_SPEC }
|
|
2155
|
+
{ id: "constellation", label: "Constellation", spec: CONSTELLATION_SPEC },
|
|
2156
|
+
{ id: "comets", label: "Comet Shower", spec: COMETS_SPEC }
|
|
1750
2157
|
];
|
|
1751
2158
|
var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
|
|
1752
2159
|
var EXAMPLE_BY_ID = Object.fromEntries(
|
|
@@ -1754,6 +2161,7 @@ var EXAMPLE_BY_ID = Object.fromEntries(
|
|
|
1754
2161
|
);
|
|
1755
2162
|
export {
|
|
1756
2163
|
AQUARIUM_SPEC,
|
|
2164
|
+
COMETS_SPEC,
|
|
1757
2165
|
CONSTELLATION_SPEC,
|
|
1758
2166
|
DASHBOARD_SPEC,
|
|
1759
2167
|
EXAMPLE_BY_ID,
|
|
@@ -1772,6 +2180,7 @@ export {
|
|
|
1772
2180
|
assertValidSpec,
|
|
1773
2181
|
buildEntities,
|
|
1774
2182
|
compileSaver,
|
|
2183
|
+
describeScene,
|
|
1775
2184
|
easeSmooth,
|
|
1776
2185
|
lerpSpec,
|
|
1777
2186
|
linkPairs,
|
|
@@ -1782,6 +2191,7 @@ export {
|
|
|
1782
2191
|
sizeAt,
|
|
1783
2192
|
spriteIndexAt,
|
|
1784
2193
|
spriteVariants,
|
|
2194
|
+
steerablePaths,
|
|
1785
2195
|
structuralSignature,
|
|
1786
2196
|
validateSpec
|
|
1787
2197
|
};
|