@idle-screens/schema 2.0.0 → 2.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.
- package/dist/index.d.ts +112 -4
- package/dist/index.js +447 -32
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/saver-spec.schema.json +66 -3
package/dist/index.js
CHANGED
|
@@ -8,8 +8,20 @@ var LIMITS = {
|
|
|
8
8
|
// px/sec — bounds motion so nothing teleports
|
|
9
9
|
maxPulseAmp: 0.5,
|
|
10
10
|
// opacity breathing amplitude cap
|
|
11
|
-
minPulsePeriod: 500
|
|
11
|
+
minPulsePeriod: 500,
|
|
12
12
|
// ms — caps pulse at 2 Hz (WCAG flash threshold is 3 Hz)
|
|
13
|
+
maxSpin: 360,
|
|
14
|
+
// degrees/sec — one full revolution per second
|
|
15
|
+
maxGrowAmp: 0.8,
|
|
16
|
+
// size breathing amplitude cap (fraction of base size)
|
|
17
|
+
maxOrbitSpeed: 180,
|
|
18
|
+
// degrees/sec — half a revolution per second
|
|
19
|
+
maxLinksK: 8,
|
|
20
|
+
maxLinkLayerCount: 200,
|
|
21
|
+
minCyclePeriod: 500,
|
|
22
|
+
// ms — same flash-safety floor as pulse
|
|
23
|
+
referenceViewport: 1080
|
|
24
|
+
// for validating viewport-unit dimensional caps
|
|
13
25
|
};
|
|
14
26
|
|
|
15
27
|
// src/validate.ts
|
|
@@ -29,6 +41,9 @@ function validateSpec(spec) {
|
|
|
29
41
|
if (spec.motionIntensity !== void 0 && !["calm", "moderate", "energetic"].includes(spec.motionIntensity)) {
|
|
30
42
|
err("motionIntensity", "must be calm | moderate | energetic");
|
|
31
43
|
}
|
|
44
|
+
if (spec.units !== void 0 && spec.units !== "px" && spec.units !== "viewport") {
|
|
45
|
+
err("units", "must be 'px' | 'viewport'");
|
|
46
|
+
}
|
|
32
47
|
if (spec.background !== void 0) validateBackground(spec.background, err);
|
|
33
48
|
if (!Array.isArray(spec.layers) || spec.layers.length === 0) {
|
|
34
49
|
err("layers", "must be a non-empty array");
|
|
@@ -37,7 +52,7 @@ function validateSpec(spec) {
|
|
|
37
52
|
let total = 0;
|
|
38
53
|
spec.layers.forEach((layer, i) => {
|
|
39
54
|
total += isObj(layer) && isNum(layer.count) ? layer.count : 0;
|
|
40
|
-
validateLayer(layer, `layers[${i}]`, err);
|
|
55
|
+
validateLayer(layer, `layers[${i}]`, err, spec);
|
|
41
56
|
});
|
|
42
57
|
if (total > LIMITS.maxTotal) err("layers", `total entities ${total} exceeds cap ${LIMITS.maxTotal}`);
|
|
43
58
|
}
|
|
@@ -70,7 +85,7 @@ function validateBackground(bg, err) {
|
|
|
70
85
|
err("background.type", "must be solid | gradient");
|
|
71
86
|
}
|
|
72
87
|
}
|
|
73
|
-
function validateLayer(layer, path, err) {
|
|
88
|
+
function validateLayer(layer, path, err, spec) {
|
|
74
89
|
if (!isObj(layer)) return err(path, "must be an object");
|
|
75
90
|
if (!isNum(layer.count) || !Number.isInteger(layer.count) || layer.count < 1) {
|
|
76
91
|
err(`${path}.count`, "must be a positive integer");
|
|
@@ -120,8 +135,44 @@ function validateLayer(layer, path, err) {
|
|
|
120
135
|
}
|
|
121
136
|
}
|
|
122
137
|
}
|
|
138
|
+
if (layer.spin !== void 0) {
|
|
139
|
+
if (!isNum(layer.spin)) err(`${path}.spin`, "must be a number (degrees/sec)");
|
|
140
|
+
else if (Math.abs(layer.spin) > LIMITS.maxSpin) err(`${path}.spin`, `must be within \xB1${LIMITS.maxSpin} deg/sec`);
|
|
141
|
+
}
|
|
142
|
+
if (layer.grow !== void 0) {
|
|
143
|
+
if (!isObj(layer.grow)) err(`${path}.grow`, "must be an object");
|
|
144
|
+
else {
|
|
145
|
+
if (!isNum(layer.grow.amp) || layer.grow.amp <= 0 || layer.grow.amp > LIMITS.maxGrowAmp) {
|
|
146
|
+
err(`${path}.grow.amp`, `must be within 0..${LIMITS.maxGrowAmp}`);
|
|
147
|
+
}
|
|
148
|
+
if (!isNum(layer.grow.period) || layer.grow.period < LIMITS.minPulsePeriod) {
|
|
149
|
+
err(`${path}.grow.period`, `must be >= ${LIMITS.minPulsePeriod} ms (flash-safety floor)`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (layer.links !== void 0) {
|
|
154
|
+
if (!isObj(layer.links)) err(`${path}.links`, "must be an object");
|
|
155
|
+
else {
|
|
156
|
+
if (!isNum(layer.links.k) || !Number.isInteger(layer.links.k) || layer.links.k < 1 || layer.links.k > LIMITS.maxLinksK) {
|
|
157
|
+
err(`${path}.links.k`, `must be an integer 1..${LIMITS.maxLinksK}`);
|
|
158
|
+
}
|
|
159
|
+
if (!isNum(layer.links.maxDist) || layer.links.maxDist <= 0) {
|
|
160
|
+
err(`${path}.links.maxDist`, "must be > 0");
|
|
161
|
+
}
|
|
162
|
+
if (layer.links.color !== void 0) color(layer.links.color, `${path}.links.color`, err);
|
|
163
|
+
if (layer.links.alpha !== void 0 && (!isNum(layer.links.alpha) || layer.links.alpha < 0 || layer.links.alpha > 1)) {
|
|
164
|
+
err(`${path}.links.alpha`, "must be 0..1");
|
|
165
|
+
}
|
|
166
|
+
if (layer.links.width !== void 0 && (!isNum(layer.links.width) || layer.links.width <= 0)) {
|
|
167
|
+
err(`${path}.links.width`, "must be > 0");
|
|
168
|
+
}
|
|
169
|
+
if (isNum(layer.count) && layer.count > LIMITS.maxLinkLayerCount) {
|
|
170
|
+
err(`${path}.links`, `layer count must be <= ${LIMITS.maxLinkLayerCount} when links is set`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
123
174
|
validateSprite(layer.sprite, `${path}.sprite`, err);
|
|
124
|
-
validateMotion(layer.motion, `${path}.motion`, err);
|
|
175
|
+
validateMotion(layer.motion, `${path}.motion`, err, spec);
|
|
125
176
|
}
|
|
126
177
|
function validateSprite(sprite, path, err) {
|
|
127
178
|
if (!isObj(sprite)) return err(path, "must be an object");
|
|
@@ -129,6 +180,7 @@ function validateSprite(sprite, path, err) {
|
|
|
129
180
|
if (!Array.isArray(sprite.glyphs) || sprite.glyphs.length === 0 || !sprite.glyphs.every(isStr)) {
|
|
130
181
|
err(`${path}.glyphs`, "must be a non-empty array of strings");
|
|
131
182
|
}
|
|
183
|
+
validateCycle(sprite, path, err);
|
|
132
184
|
} else if (sprite.kind === "text") {
|
|
133
185
|
if (!Array.isArray(sprite.strings) || sprite.strings.length === 0 || !sprite.strings.every(isStr)) {
|
|
134
186
|
err(`${path}.strings`, "must be a non-empty array of strings");
|
|
@@ -143,19 +195,36 @@ function validateSprite(sprite, path, err) {
|
|
|
143
195
|
if (sprite.maxWidth !== void 0 && (!isNum(sprite.maxWidth) || sprite.maxWidth <= 0)) {
|
|
144
196
|
err(`${path}.maxWidth`, "must be a positive number");
|
|
145
197
|
}
|
|
198
|
+
validateCycle(sprite, path, err);
|
|
146
199
|
} else if (sprite.kind === "circle") {
|
|
147
200
|
if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
|
|
148
201
|
color(sprite.color, `${path}.color`, err);
|
|
149
202
|
if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
|
|
203
|
+
if (sprite.colors !== void 0) {
|
|
204
|
+
if (!Array.isArray(sprite.colors) || sprite.colors.length === 0) {
|
|
205
|
+
err(`${path}.colors`, "must be a non-empty array of hex colours");
|
|
206
|
+
} else {
|
|
207
|
+
sprite.colors.forEach((c, ci) => color(c, `${path}.colors[${ci}]`, err));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
150
210
|
} else {
|
|
151
211
|
err(`${path}.kind`, "must be emoji | text | circle");
|
|
152
212
|
}
|
|
153
213
|
}
|
|
154
|
-
function
|
|
214
|
+
function validateCycle(sprite, path, err) {
|
|
215
|
+
if (sprite.cycle === void 0) return;
|
|
216
|
+
if (!isObj(sprite.cycle)) return err(`${path}.cycle`, "must be an object");
|
|
217
|
+
if (!isNum(sprite.cycle.period) || sprite.cycle.period < LIMITS.minCyclePeriod) {
|
|
218
|
+
err(`${path}.cycle.period`, `must be >= ${LIMITS.minCyclePeriod} ms (flash-safety floor)`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function validateMotion(motion, path, err, spec) {
|
|
155
222
|
if (!isObj(motion)) return err(path, "must be an object");
|
|
223
|
+
const isViewport = isObj(spec) && spec.units === "viewport";
|
|
224
|
+
const speedCap = isViewport ? LIMITS.maxSpeed / LIMITS.referenceViewport : LIMITS.maxSpeed;
|
|
156
225
|
const speedOk = (v, p) => {
|
|
157
226
|
if (!isRange(v)) err(p, "must be a [min,max] range");
|
|
158
|
-
else if (v[1] >
|
|
227
|
+
else if (v[1] > speedCap) err(p, `speed exceeds cap ${isViewport ? speedCap.toFixed(2) + " viewport-units/sec" : LIMITS.maxSpeed + " px/sec"}`);
|
|
159
228
|
};
|
|
160
229
|
if (motion.type === "drift") {
|
|
161
230
|
speedOk(motion.speed, `${path}.speed`);
|
|
@@ -168,8 +237,20 @@ function validateMotion(motion, path, err) {
|
|
|
168
237
|
} else if (motion.type === "bounce") {
|
|
169
238
|
speedOk(motion.speed, `${path}.speed`);
|
|
170
239
|
} else if (motion.type === "static") {
|
|
240
|
+
} else if (motion.type === "orbit") {
|
|
241
|
+
if (!isRange(motion.speed)) err(`${path}.speed`, "must be a [min,max] range");
|
|
242
|
+
else 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`);
|
|
243
|
+
if (!isRange(motion.radius) || motion.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
|
|
244
|
+
if (motion.center !== void 0) {
|
|
245
|
+
if (!isObj(motion.center) || !isNum(motion.center.x) || !isNum(motion.center.y)) {
|
|
246
|
+
err(`${path}.center`, "must be {x, y} with numbers 0..1");
|
|
247
|
+
} else {
|
|
248
|
+
if (motion.center.x < 0 || motion.center.x > 1) err(`${path}.center.x`, "must be 0..1");
|
|
249
|
+
if (motion.center.y < 0 || motion.center.y > 1) err(`${path}.center.y`, "must be 0..1");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
171
252
|
} else {
|
|
172
|
-
err(`${path}.type`, "must be drift | rise | bounce | static");
|
|
253
|
+
err(`${path}.type`, "must be drift | rise | bounce | static | orbit");
|
|
173
254
|
}
|
|
174
255
|
}
|
|
175
256
|
function assertValidSpec(spec) {
|
|
@@ -187,12 +268,14 @@ function spriteVariants(sprite) {
|
|
|
187
268
|
if (sprite.kind === "text") return sprite.strings.length;
|
|
188
269
|
return 1;
|
|
189
270
|
}
|
|
190
|
-
function buildEntities(layer, rng, w, h) {
|
|
271
|
+
function buildEntities(layer, rng, w, h, scale = 1) {
|
|
191
272
|
const [smin, smax] = layer.size ?? [20, 40];
|
|
192
273
|
const variants = spriteVariants(layer.sprite);
|
|
274
|
+
const colorsLen = layer.sprite.kind === "circle" && layer.sprite.colors ? layer.sprite.colors.length : 0;
|
|
275
|
+
const cycle = layer.sprite.kind === "emoji" || layer.sprite.kind === "text" ? layer.sprite.cycle : void 0;
|
|
193
276
|
const out = [];
|
|
194
277
|
for (let i = 0; i < layer.count; i++) {
|
|
195
|
-
const size = layer.sprite.kind === "circle" ? rng.range(layer.sprite.radius[0], layer.sprite.radius[1]) * 2 : rng.range(smin, smax);
|
|
278
|
+
const size = layer.sprite.kind === "circle" ? rng.range(layer.sprite.radius[0], layer.sprite.radius[1]) * 2 * scale : rng.range(smin, smax) * scale;
|
|
196
279
|
let vx = 0;
|
|
197
280
|
let vy = 0;
|
|
198
281
|
let bob = 0;
|
|
@@ -201,22 +284,25 @@ function buildEntities(layer, rng, w, h) {
|
|
|
201
284
|
if (m.type === "static") {
|
|
202
285
|
motion = "static";
|
|
203
286
|
} else if (m.type === "drift") {
|
|
204
|
-
const s = rng.range(m.speed[0], m.speed[1]);
|
|
287
|
+
const s = rng.range(m.speed[0], m.speed[1]) * scale;
|
|
205
288
|
let angle = (m.angle ?? 0) * Math.PI / 180;
|
|
206
289
|
if (m.bidirectional && rng.next() < 0.5) angle = Math.PI - angle;
|
|
207
290
|
vx = s * Math.cos(angle);
|
|
208
291
|
vy = s * Math.sin(angle);
|
|
209
|
-
bob = m.bob ?? 0;
|
|
292
|
+
bob = (m.bob ?? 0) * scale;
|
|
210
293
|
} else if (m.type === "rise") {
|
|
211
294
|
motion = "rise";
|
|
212
|
-
vy = -rng.range(m.speed[0], m.speed[1]);
|
|
213
|
-
bob = m.sway ?? 0;
|
|
214
|
-
} else {
|
|
295
|
+
vy = -rng.range(m.speed[0], m.speed[1]) * scale;
|
|
296
|
+
bob = (m.sway ?? 0) * scale;
|
|
297
|
+
} else if (m.type === "bounce") {
|
|
215
298
|
motion = "bounce";
|
|
216
|
-
const s = rng.range(m.speed[0], m.speed[1]);
|
|
299
|
+
const s = rng.range(m.speed[0], m.speed[1]) * scale;
|
|
217
300
|
const a = rng.range(0, Math.PI * 2);
|
|
218
301
|
vx = s * Math.cos(a);
|
|
219
302
|
vy = s * Math.sin(a);
|
|
303
|
+
} else if (m.type === "orbit") {
|
|
304
|
+
motion = "orbit";
|
|
305
|
+
vx = rng.range(m.speed[0], m.speed[1]);
|
|
220
306
|
}
|
|
221
307
|
let x0;
|
|
222
308
|
let y0;
|
|
@@ -243,7 +329,20 @@ function buildEntities(layer, rng, w, h) {
|
|
|
243
329
|
alpha: layer.alpha ? rng.range(layer.alpha[0], layer.alpha[1]) : 1,
|
|
244
330
|
pulseAmp: layer.pulse?.amp ?? 0,
|
|
245
331
|
pulsePeriod: layer.pulse?.period ?? 1e3,
|
|
246
|
-
pulsePhase: layer.pulse ? rng.range(0, Math.PI * 2) : 0
|
|
332
|
+
pulsePhase: layer.pulse ? rng.range(0, Math.PI * 2) : 0,
|
|
333
|
+
// New draws AFTER pulsePhase — guarded so existing specs keep identical streams.
|
|
334
|
+
spinSpeed: layer.spin ? layer.spin * Math.PI / 180 : 0,
|
|
335
|
+
// deg/sec → rad/sec
|
|
336
|
+
spinPhase: layer.spin ? rng.range(0, Math.PI * 2) : 0,
|
|
337
|
+
growAmp: layer.grow?.amp ?? 0,
|
|
338
|
+
growPeriod: layer.grow?.period ?? 1e3,
|
|
339
|
+
growPhase: layer.grow ? rng.range(0, Math.PI * 2) : 0,
|
|
340
|
+
orbitR: m.type === "orbit" ? rng.range(m.radius[0], m.radius[1]) * scale : 0,
|
|
341
|
+
orbitCx: m.type === "orbit" ? (m.center?.x ?? 0.5) * w : 0,
|
|
342
|
+
orbitCy: m.type === "orbit" ? (m.center?.y ?? 0.5) * h : 0,
|
|
343
|
+
// New draws AFTER orbit — guarded so existing specs keep identical streams.
|
|
344
|
+
colorIndex: colorsLen > 0 ? rng.int(0, colorsLen - 1) : -1,
|
|
345
|
+
cyclePeriod: cycle ? cycle.period : 0
|
|
247
346
|
});
|
|
248
347
|
}
|
|
249
348
|
return out;
|
|
@@ -253,6 +352,15 @@ function alphaAt(e, t) {
|
|
|
253
352
|
const a = e.alpha + e.pulseAmp * Math.sin(t * 2 * Math.PI / e.pulsePeriod + e.pulsePhase);
|
|
254
353
|
return a < 0 ? 0 : a > 1 ? 1 : a;
|
|
255
354
|
}
|
|
355
|
+
function sizeAt(e, t) {
|
|
356
|
+
if (!e.growAmp) return e.size;
|
|
357
|
+
const s = e.size * (1 + e.growAmp * Math.sin(t * 2 * Math.PI / e.growPeriod + e.growPhase));
|
|
358
|
+
return s > 0 ? s : 0.1;
|
|
359
|
+
}
|
|
360
|
+
function rotationAt(e, t) {
|
|
361
|
+
if (!e.spinSpeed) return 0;
|
|
362
|
+
return e.spinPhase + e.spinSpeed * (t / 1e3);
|
|
363
|
+
}
|
|
256
364
|
function wrap(v, min, max) {
|
|
257
365
|
const range = max - min;
|
|
258
366
|
return ((v - min) % range + range) % range + min;
|
|
@@ -266,6 +374,14 @@ function reflect(v, min, max) {
|
|
|
266
374
|
}
|
|
267
375
|
function positionAt(e, t, w, h) {
|
|
268
376
|
if (e.motion === "static") return { x: e.x0, y: e.y0, flip: false };
|
|
377
|
+
if (e.motion === "orbit") {
|
|
378
|
+
const angle = e.phase + e.vx * Math.PI / 180 * (t / 1e3);
|
|
379
|
+
return {
|
|
380
|
+
x: e.orbitCx + e.orbitR * Math.cos(angle),
|
|
381
|
+
y: e.orbitCy + e.orbitR * Math.sin(angle),
|
|
382
|
+
flip: false
|
|
383
|
+
};
|
|
384
|
+
}
|
|
269
385
|
const dt = t / 1e3;
|
|
270
386
|
const m = e.size;
|
|
271
387
|
if (e.motion === "bounce") {
|
|
@@ -287,6 +403,43 @@ function positionAt(e, t, w, h) {
|
|
|
287
403
|
if (e.bob) y += e.bob * Math.sin(t / 500 + e.phase);
|
|
288
404
|
return { x, y, flip: e.headingLeft };
|
|
289
405
|
}
|
|
406
|
+
function spriteIndexAt(e, t, variants) {
|
|
407
|
+
if (!e.cyclePeriod || variants <= 1) return e.spriteIndex;
|
|
408
|
+
return Math.floor(t / e.cyclePeriod + e.phase / (2 * Math.PI)) % variants;
|
|
409
|
+
}
|
|
410
|
+
function linkPairs(positions, k, maxDist, doWrap, w, h) {
|
|
411
|
+
const n = positions.length;
|
|
412
|
+
const edges = /* @__PURE__ */ new Set();
|
|
413
|
+
const out = [];
|
|
414
|
+
for (let i = 0; i < n; i++) {
|
|
415
|
+
const pi = positions[i];
|
|
416
|
+
const neighbors = [];
|
|
417
|
+
for (let j = 0; j < n; j++) {
|
|
418
|
+
if (i === j) continue;
|
|
419
|
+
const pj = positions[j];
|
|
420
|
+
let dx = pj.x - pi.x;
|
|
421
|
+
let dy = pj.y - pi.y;
|
|
422
|
+
if (doWrap) {
|
|
423
|
+
if (Math.abs(dx) > w / 2) dx = dx > 0 ? dx - w : dx + w;
|
|
424
|
+
if (Math.abs(dy) > h / 2) dy = dy > 0 ? dy - h : dy + h;
|
|
425
|
+
}
|
|
426
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
427
|
+
if (dist <= maxDist) neighbors.push({ dist, j });
|
|
428
|
+
}
|
|
429
|
+
neighbors.sort((a, b) => a.dist - b.dist || a.j - b.j);
|
|
430
|
+
for (let ni = 0; ni < Math.min(k, neighbors.length); ni++) {
|
|
431
|
+
const j = neighbors[ni].j;
|
|
432
|
+
const lo = Math.min(i, j);
|
|
433
|
+
const hi = Math.max(i, j);
|
|
434
|
+
const key = `${lo}:${hi}`;
|
|
435
|
+
if (!edges.has(key)) {
|
|
436
|
+
edges.add(key);
|
|
437
|
+
out.push([lo, hi]);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
290
443
|
|
|
291
444
|
// src/compile.ts
|
|
292
445
|
import {
|
|
@@ -365,7 +518,8 @@ function easeSmooth(k) {
|
|
|
365
518
|
return c * c * (3 - 2 * c);
|
|
366
519
|
}
|
|
367
520
|
function structuralSignature(spec) {
|
|
368
|
-
return JSON.stringify(
|
|
521
|
+
return JSON.stringify([
|
|
522
|
+
spec.units,
|
|
369
523
|
spec.layers.map((l) => [
|
|
370
524
|
l.count,
|
|
371
525
|
l.size,
|
|
@@ -376,11 +530,14 @@ function structuralSignature(spec) {
|
|
|
376
530
|
l.flip,
|
|
377
531
|
l.alpha,
|
|
378
532
|
l.pulse,
|
|
533
|
+
l.spin,
|
|
534
|
+
l.grow,
|
|
379
535
|
l.sprite.kind,
|
|
380
536
|
l.sprite.kind === "circle" ? l.sprite.radius : void 0,
|
|
381
|
-
l.sprite.kind === "
|
|
537
|
+
l.sprite.kind === "circle" ? l.sprite.colors?.length : void 0,
|
|
538
|
+
l.sprite.kind === "emoji" ? [l.sprite.glyphs.length, l.sprite.cycle?.period] : l.sprite.kind === "text" ? [l.sprite.strings.length, l.sprite.cycle?.period] : void 0
|
|
382
539
|
])
|
|
383
|
-
);
|
|
540
|
+
]);
|
|
384
541
|
}
|
|
385
542
|
|
|
386
543
|
// src/compile.ts
|
|
@@ -463,7 +620,8 @@ var SpecInstance = class {
|
|
|
463
620
|
/** (Re)seed and place all entities — deterministic for the seed + size. */
|
|
464
621
|
rebuild() {
|
|
465
622
|
const rng = createRng(this.seed);
|
|
466
|
-
|
|
623
|
+
const scale = this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1;
|
|
624
|
+
this.layers = this.effSpec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, this.w, this.h, scale) }));
|
|
467
625
|
this.lastStructural = structuralSignature(this.effSpec);
|
|
468
626
|
}
|
|
469
627
|
start() {
|
|
@@ -498,45 +656,60 @@ var SpecInstance = class {
|
|
|
498
656
|
ctx.fillRect(0, 0, w, h);
|
|
499
657
|
if (bg.band) {
|
|
500
658
|
ctx.fillStyle = bg.band.color;
|
|
501
|
-
|
|
659
|
+
const bh = bg.band.height * (this.effSpec.units === "viewport" ? Math.min(w, h) : 1);
|
|
660
|
+
ctx.fillRect(0, h - bh, w, bh);
|
|
502
661
|
}
|
|
503
662
|
}
|
|
504
663
|
drawEntity(built, e, t) {
|
|
505
664
|
const { ctx } = this;
|
|
506
665
|
const p = positionAt(e, t, this.w, this.h);
|
|
507
666
|
const sprite = built.layer.sprite;
|
|
667
|
+
const sz = sizeAt(e, t);
|
|
668
|
+
const rot = rotationAt(e, t);
|
|
508
669
|
ctx.globalAlpha = alphaAt(e, t);
|
|
509
670
|
if (sprite.kind === "circle") {
|
|
510
|
-
const r =
|
|
671
|
+
const r = sz / 2;
|
|
672
|
+
const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
|
|
673
|
+
ctx.save();
|
|
674
|
+
if (rot) {
|
|
675
|
+
ctx.translate(p.x, p.y);
|
|
676
|
+
ctx.rotate(rot);
|
|
677
|
+
ctx.translate(-p.x, -p.y);
|
|
678
|
+
}
|
|
511
679
|
if (sprite.soft) {
|
|
512
680
|
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r);
|
|
513
|
-
g.addColorStop(0,
|
|
514
|
-
g.addColorStop(0.35, hexToRgba(
|
|
515
|
-
g.addColorStop(1, hexToRgba(
|
|
681
|
+
g.addColorStop(0, resolvedColor);
|
|
682
|
+
g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
|
|
683
|
+
g.addColorStop(1, hexToRgba(resolvedColor, 0));
|
|
516
684
|
ctx.fillStyle = g;
|
|
517
685
|
} else {
|
|
518
|
-
ctx.fillStyle =
|
|
686
|
+
ctx.fillStyle = resolvedColor;
|
|
519
687
|
}
|
|
520
688
|
ctx.beginPath();
|
|
521
689
|
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
|
|
522
690
|
ctx.fill();
|
|
691
|
+
ctx.restore();
|
|
523
692
|
return;
|
|
524
693
|
}
|
|
525
694
|
ctx.save();
|
|
526
695
|
ctx.translate(p.x, p.y);
|
|
696
|
+
if (rot) ctx.rotate(rot);
|
|
527
697
|
if (p.flip && built.layer.flip) ctx.scale(-1, 1);
|
|
528
698
|
ctx.textAlign = "center";
|
|
529
699
|
ctx.textBaseline = "middle";
|
|
530
700
|
if (sprite.kind === "emoji") {
|
|
531
|
-
|
|
532
|
-
ctx.
|
|
701
|
+
const idx = spriteIndexAt(e, t, sprite.glyphs.length);
|
|
702
|
+
ctx.font = `${sz}px serif`;
|
|
703
|
+
ctx.fillText(sprite.glyphs[idx] ?? sprite.glyphs[0], 0, 0);
|
|
533
704
|
} else {
|
|
534
705
|
ctx.textAlign = sprite.align ?? "center";
|
|
535
706
|
ctx.textBaseline = sprite.baseline ?? "middle";
|
|
536
|
-
ctx.font = sprite.font ?? `${
|
|
707
|
+
ctx.font = sprite.font ?? `${sz}px system-ui, sans-serif`;
|
|
537
708
|
ctx.fillStyle = sprite.color ?? "#e6e8ef";
|
|
538
|
-
const
|
|
539
|
-
|
|
709
|
+
const idx = spriteIndexAt(e, t, sprite.strings.length);
|
|
710
|
+
const text = sprite.strings[idx] ?? sprite.strings[0];
|
|
711
|
+
const mw = sprite.maxWidth ? sprite.maxWidth * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1) : void 0;
|
|
712
|
+
if (mw) ctx.fillText(text, 0, 0, mw);
|
|
540
713
|
else ctx.fillText(text, 0, 0);
|
|
541
714
|
}
|
|
542
715
|
ctx.restore();
|
|
@@ -579,6 +752,39 @@ var SpecInstance = class {
|
|
|
579
752
|
this.renderFrame(this.lastT, this.seed);
|
|
580
753
|
}
|
|
581
754
|
}
|
|
755
|
+
drawLinks(built, t) {
|
|
756
|
+
const { links } = built.layer;
|
|
757
|
+
if (!links) return;
|
|
758
|
+
const { ctx } = this;
|
|
759
|
+
const wrap2 = built.layer.wrap !== false;
|
|
760
|
+
const positions = built.entities.map((e) => positionAt(e, t, this.w, this.h));
|
|
761
|
+
const pairs = linkPairs(positions, links.k, links.maxDist * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1), wrap2, this.w, this.h);
|
|
762
|
+
const lw = (links.width ?? 1) * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1);
|
|
763
|
+
ctx.lineWidth = lw;
|
|
764
|
+
for (const [i, j] of pairs) {
|
|
765
|
+
const pi = positions[i];
|
|
766
|
+
const pj = positions[j];
|
|
767
|
+
const ei = built.entities[i];
|
|
768
|
+
let resolvedColor = links.color;
|
|
769
|
+
if (!resolvedColor) {
|
|
770
|
+
const sprite = built.layer.sprite;
|
|
771
|
+
if (sprite.kind === "circle") resolvedColor = sprite.colors?.[ei.colorIndex] ?? sprite.color;
|
|
772
|
+
else resolvedColor = "#e6e8ef";
|
|
773
|
+
}
|
|
774
|
+
ctx.globalAlpha = links.alpha ?? alphaAt(ei, t);
|
|
775
|
+
ctx.strokeStyle = resolvedColor;
|
|
776
|
+
let dx = pj.x - pi.x;
|
|
777
|
+
let dy = pj.y - pi.y;
|
|
778
|
+
if (wrap2) {
|
|
779
|
+
if (Math.abs(dx) > this.w / 2) dx = dx > 0 ? dx - this.w : dx + this.w;
|
|
780
|
+
if (Math.abs(dy) > this.h / 2) dy = dy > 0 ? dy - this.h : dy + this.h;
|
|
781
|
+
}
|
|
782
|
+
ctx.beginPath();
|
|
783
|
+
ctx.moveTo(pi.x, pi.y);
|
|
784
|
+
ctx.lineTo(pi.x + dx, pi.y + dy);
|
|
785
|
+
ctx.stroke();
|
|
786
|
+
}
|
|
787
|
+
}
|
|
582
788
|
/** Deterministic, frame-addressable render (shared by the rAF loop). */
|
|
583
789
|
renderFrame(t, _seed) {
|
|
584
790
|
this.stepTransition(t);
|
|
@@ -588,6 +794,7 @@ var SpecInstance = class {
|
|
|
588
794
|
this.drawBackground();
|
|
589
795
|
for (const built of this.layers) {
|
|
590
796
|
ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
|
|
797
|
+
this.drawLinks(built, t);
|
|
591
798
|
for (const e of built.entities) this.drawEntity(built, e, t);
|
|
592
799
|
}
|
|
593
800
|
ctx.globalAlpha = 1;
|
|
@@ -623,6 +830,76 @@ function compileSaver(spec) {
|
|
|
623
830
|
};
|
|
624
831
|
}
|
|
625
832
|
|
|
833
|
+
// src/advise.ts
|
|
834
|
+
import { createRng as createRng2 } from "@idle-screens/core";
|
|
835
|
+
function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
|
|
836
|
+
const warnings = [];
|
|
837
|
+
const w = viewport.width;
|
|
838
|
+
const h = viewport.height;
|
|
839
|
+
const scale = spec.units === "viewport" ? Math.min(w, h) : 1;
|
|
840
|
+
const rng = createRng2(spec.seed ?? 42);
|
|
841
|
+
const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale));
|
|
842
|
+
let totalEntities = 0;
|
|
843
|
+
let textLayerCount = 0;
|
|
844
|
+
let motionLayerCount = 0;
|
|
845
|
+
for (let li = 0; li < spec.layers.length; li++) {
|
|
846
|
+
const layer = spec.layers[li];
|
|
847
|
+
const entities = allEntities[li];
|
|
848
|
+
totalEntities += entities.length;
|
|
849
|
+
const isText = layer.sprite.kind === "text";
|
|
850
|
+
const isEmoji = layer.sprite.kind === "emoji";
|
|
851
|
+
const isStaticText = isText && layer.motion.type === "static";
|
|
852
|
+
if (isStaticText) textLayerCount++;
|
|
853
|
+
if (layer.motion.type !== "static") motionLayerCount++;
|
|
854
|
+
if (layer.sprite.kind === "circle") {
|
|
855
|
+
const maxR = entities.reduce((m, e) => Math.max(m, e.size / 2), 0);
|
|
856
|
+
const maxAlpha = entities.reduce((m, e) => Math.max(m, e.alpha), 0);
|
|
857
|
+
if (maxR * maxAlpha < 0.05) {
|
|
858
|
+
warnings.push({
|
|
859
|
+
path: `layers[${li}]`,
|
|
860
|
+
code: "invisible-layer",
|
|
861
|
+
message: `layer's max visible radius (${maxR.toFixed(1)}px \xD7 ${maxAlpha.toFixed(2)} alpha) is below perceptual floor`
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
let totalCoverage = 0;
|
|
867
|
+
for (let li = 0; li < spec.layers.length; li++) {
|
|
868
|
+
const layer = spec.layers[li];
|
|
869
|
+
const entities = allEntities[li];
|
|
870
|
+
for (const e of entities) {
|
|
871
|
+
const r = e.size / 2;
|
|
872
|
+
const pixArea = layer.sprite.kind === "circle" ? Math.PI * r * r : e.size * e.size;
|
|
873
|
+
totalCoverage += pixArea * e.alpha / (w * h);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (totalCoverage < 5e-4 && spec.layers.length > 0) {
|
|
877
|
+
warnings.push({
|
|
878
|
+
path: "layers",
|
|
879
|
+
code: "sparse-scene",
|
|
880
|
+
message: `alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 scene will look empty on most displays`
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
if (totalEntities > 500) {
|
|
884
|
+
warnings.push({
|
|
885
|
+
path: "layers",
|
|
886
|
+
code: "dense-scene",
|
|
887
|
+
message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
if (spec.layers.length > 0 && textLayerCount > 0 && motionLayerCount === 0) {
|
|
891
|
+
const textRatio = textLayerCount / spec.layers.length;
|
|
892
|
+
if (textRatio >= 0.8) {
|
|
893
|
+
warnings.push({
|
|
894
|
+
path: "layers",
|
|
895
|
+
code: "text-heavy",
|
|
896
|
+
message: "most layers are static text \u2014 reads as a document, not a screensaver"
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return warnings;
|
|
901
|
+
}
|
|
902
|
+
|
|
626
903
|
// src/examples/aquarium.ts
|
|
627
904
|
var AQUARIUM_SPEC = {
|
|
628
905
|
schemaVersion: 1,
|
|
@@ -655,6 +932,51 @@ var AQUARIUM_SPEC = {
|
|
|
655
932
|
]
|
|
656
933
|
};
|
|
657
934
|
|
|
935
|
+
// src/examples/constellation.ts
|
|
936
|
+
var CONSTELLATION_SPEC = {
|
|
937
|
+
schemaVersion: 1,
|
|
938
|
+
id: "constellation",
|
|
939
|
+
label: "Constellation",
|
|
940
|
+
seed: 73,
|
|
941
|
+
motionIntensity: "calm",
|
|
942
|
+
background: {
|
|
943
|
+
type: "gradient",
|
|
944
|
+
stops: [
|
|
945
|
+
{ at: 0, color: "#020212" },
|
|
946
|
+
{ at: 0.5, color: "#050520" },
|
|
947
|
+
{ at: 1, color: "#0a0830" }
|
|
948
|
+
]
|
|
949
|
+
},
|
|
950
|
+
layers: [
|
|
951
|
+
// Distant static stars (no links, just atmosphere)
|
|
952
|
+
{
|
|
953
|
+
count: 100,
|
|
954
|
+
sprite: { kind: "circle", radius: [1, 3], color: "#667799", colors: ["#8899cc", "#99aadd", "#aabbee", "#7788aa"] },
|
|
955
|
+
alpha: [0.2, 0.7],
|
|
956
|
+
pulse: { amp: 0.2, period: 3e3 },
|
|
957
|
+
motion: { type: "static" }
|
|
958
|
+
},
|
|
959
|
+
// Connected constellation nodes — slow drift, linked
|
|
960
|
+
{
|
|
961
|
+
key: "nodes",
|
|
962
|
+
count: 40,
|
|
963
|
+
sprite: { kind: "circle", radius: [2, 5], color: "#88aaff", colors: ["#88aaff", "#aaccff", "#ffcc88", "#ff8866"], soft: true },
|
|
964
|
+
alpha: [0.5, 0.9],
|
|
965
|
+
blend: "lighter",
|
|
966
|
+
motion: { type: "drift", speed: [2, 8], bidirectional: true, bob: 3 },
|
|
967
|
+
links: { k: 3, maxDist: 200, alpha: 0.15, width: 0.5 }
|
|
968
|
+
},
|
|
969
|
+
// Cycling asterism markers
|
|
970
|
+
{
|
|
971
|
+
count: 6,
|
|
972
|
+
sprite: { kind: "emoji", glyphs: ["\u2726", "\u2727", "\u22C6", "\u2217"], cycle: { period: 2e3 } },
|
|
973
|
+
size: [14, 22],
|
|
974
|
+
alpha: [0.4, 0.7],
|
|
975
|
+
motion: { type: "drift", speed: [1, 4], bidirectional: true }
|
|
976
|
+
}
|
|
977
|
+
]
|
|
978
|
+
};
|
|
979
|
+
|
|
658
980
|
// src/examples/dev-dashboard.ts
|
|
659
981
|
var DASHBOARD_SPEC = {
|
|
660
982
|
schemaVersion: 1,
|
|
@@ -1219,6 +1541,90 @@ var LANTERNS_SPEC = {
|
|
|
1219
1541
|
]
|
|
1220
1542
|
};
|
|
1221
1543
|
|
|
1544
|
+
// src/examples/orrery.ts
|
|
1545
|
+
var ORRERY_SPEC = {
|
|
1546
|
+
schemaVersion: 1,
|
|
1547
|
+
id: "orrery",
|
|
1548
|
+
label: "Orrery",
|
|
1549
|
+
seed: 7,
|
|
1550
|
+
motionIntensity: "calm",
|
|
1551
|
+
background: {
|
|
1552
|
+
type: "gradient",
|
|
1553
|
+
stops: [
|
|
1554
|
+
{ at: 0, color: "#020210" },
|
|
1555
|
+
{ at: 0.5, color: "#06051a" },
|
|
1556
|
+
{ at: 1, color: "#0a0820" }
|
|
1557
|
+
]
|
|
1558
|
+
},
|
|
1559
|
+
layers: [
|
|
1560
|
+
// Distant stars
|
|
1561
|
+
{
|
|
1562
|
+
count: 80,
|
|
1563
|
+
sprite: { kind: "circle", radius: [0.3, 1.2], color: "#8899cc" },
|
|
1564
|
+
alpha: [0.2, 0.8],
|
|
1565
|
+
pulse: { amp: 0.3, period: 3e3 },
|
|
1566
|
+
motion: { type: "static" }
|
|
1567
|
+
},
|
|
1568
|
+
// Sun corona (soft glow)
|
|
1569
|
+
{
|
|
1570
|
+
key: "corona",
|
|
1571
|
+
count: 1,
|
|
1572
|
+
sprite: { kind: "circle", radius: [60, 60], color: "#ffaa22", soft: true },
|
|
1573
|
+
blend: "lighter",
|
|
1574
|
+
alpha: [0.6, 0.6],
|
|
1575
|
+
grow: { amp: 0.15, period: 4e3 },
|
|
1576
|
+
motion: { type: "static" },
|
|
1577
|
+
position: { x: 0.5, y: 0.5 }
|
|
1578
|
+
},
|
|
1579
|
+
// Sun body
|
|
1580
|
+
{
|
|
1581
|
+
key: "sun",
|
|
1582
|
+
count: 1,
|
|
1583
|
+
sprite: { kind: "emoji", glyphs: ["\u2600\uFE0F"] },
|
|
1584
|
+
size: [48, 48],
|
|
1585
|
+
grow: { amp: 0.08, period: 4e3 },
|
|
1586
|
+
motion: { type: "static" },
|
|
1587
|
+
position: { x: 0.5, y: 0.5 }
|
|
1588
|
+
},
|
|
1589
|
+
// Inner ring — small fast planets
|
|
1590
|
+
{
|
|
1591
|
+
key: "inner",
|
|
1592
|
+
count: 3,
|
|
1593
|
+
sprite: { kind: "emoji", glyphs: ["\u{1FAA8}", "\u{1F534}", "\u{1F7E0}"] },
|
|
1594
|
+
size: [16, 22],
|
|
1595
|
+
spin: 30,
|
|
1596
|
+
motion: { type: "orbit", speed: [28, 42], radius: [80, 120] }
|
|
1597
|
+
},
|
|
1598
|
+
// Middle ring — gas giants
|
|
1599
|
+
{
|
|
1600
|
+
key: "middle",
|
|
1601
|
+
count: 2,
|
|
1602
|
+
sprite: { kind: "emoji", glyphs: ["\u{1F7E4}", "\u{1FA90}"] },
|
|
1603
|
+
size: [28, 38],
|
|
1604
|
+
spin: -15,
|
|
1605
|
+
motion: { type: "orbit", speed: [12, 20], radius: [160, 200] }
|
|
1606
|
+
},
|
|
1607
|
+
// Outer ring — ice giants
|
|
1608
|
+
{
|
|
1609
|
+
key: "outer",
|
|
1610
|
+
count: 2,
|
|
1611
|
+
sprite: { kind: "emoji", glyphs: ["\u{1F535}", "\u{1F48E}"] },
|
|
1612
|
+
size: [22, 30],
|
|
1613
|
+
spin: 10,
|
|
1614
|
+
motion: { type: "orbit", speed: [5, 10], radius: [240, 300] }
|
|
1615
|
+
},
|
|
1616
|
+
// Orbital dust ring (soft glow particles orbiting slowly)
|
|
1617
|
+
{
|
|
1618
|
+
count: 24,
|
|
1619
|
+
sprite: { kind: "circle", radius: [1, 3], color: "#ccaa66", soft: true },
|
|
1620
|
+
alpha: [0.15, 0.4],
|
|
1621
|
+
blend: "lighter",
|
|
1622
|
+
pulse: { amp: 0.2, period: 2500 },
|
|
1623
|
+
motion: { type: "orbit", speed: [2, 8], radius: [100, 320] }
|
|
1624
|
+
}
|
|
1625
|
+
]
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1222
1628
|
// src/examples/rain.ts
|
|
1223
1629
|
var RAIN_SPEC = {
|
|
1224
1630
|
schemaVersion: 1,
|
|
@@ -1338,7 +1744,9 @@ var SCHEMA_EXAMPLES = [
|
|
|
1338
1744
|
{ id: "snowfall", label: "Snowfall", spec: SNOWFALL_SPEC },
|
|
1339
1745
|
{ id: "lanterns", label: "Night Lanterns", spec: LANTERNS_SPEC },
|
|
1340
1746
|
{ id: "sakura", label: "Sakura Drift", spec: SAKURA_SPEC },
|
|
1341
|
-
{ id: "dev-dashboard", label: "Dev Dashboard", spec: DASHBOARD_SPEC }
|
|
1747
|
+
{ id: "dev-dashboard", label: "Dev Dashboard", spec: DASHBOARD_SPEC },
|
|
1748
|
+
{ id: "orrery", label: "Orrery", spec: ORRERY_SPEC },
|
|
1749
|
+
{ id: "constellation", label: "Constellation", spec: CONSTELLATION_SPEC }
|
|
1342
1750
|
];
|
|
1343
1751
|
var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
|
|
1344
1752
|
var EXAMPLE_BY_ID = Object.fromEntries(
|
|
@@ -1346,16 +1754,19 @@ var EXAMPLE_BY_ID = Object.fromEntries(
|
|
|
1346
1754
|
);
|
|
1347
1755
|
export {
|
|
1348
1756
|
AQUARIUM_SPEC,
|
|
1757
|
+
CONSTELLATION_SPEC,
|
|
1349
1758
|
DASHBOARD_SPEC,
|
|
1350
1759
|
EXAMPLE_BY_ID,
|
|
1351
1760
|
EXAMPLE_SPECS,
|
|
1352
1761
|
LANTERNS_SPEC,
|
|
1353
1762
|
LIMITS,
|
|
1763
|
+
ORRERY_SPEC,
|
|
1354
1764
|
RAIN_SPEC,
|
|
1355
1765
|
SAKURA_SPEC,
|
|
1356
1766
|
SCHEMA_EXAMPLES,
|
|
1357
1767
|
SCHEMA_VERSION,
|
|
1358
1768
|
SNOWFALL_SPEC,
|
|
1769
|
+
adviseSpec,
|
|
1359
1770
|
alphaAt,
|
|
1360
1771
|
applyDeltasToSpec,
|
|
1361
1772
|
assertValidSpec,
|
|
@@ -1363,9 +1774,13 @@ export {
|
|
|
1363
1774
|
compileSaver,
|
|
1364
1775
|
easeSmooth,
|
|
1365
1776
|
lerpSpec,
|
|
1777
|
+
linkPairs,
|
|
1366
1778
|
manifestFor,
|
|
1367
1779
|
positionAt,
|
|
1368
1780
|
resolveSpecPath,
|
|
1781
|
+
rotationAt,
|
|
1782
|
+
sizeAt,
|
|
1783
|
+
spriteIndexAt,
|
|
1369
1784
|
spriteVariants,
|
|
1370
1785
|
structuralSignature,
|
|
1371
1786
|
validateSpec
|