@dixy-ui/themes 0.0.0-stage → 0.1.0-beta.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 +104 -2
- package/dist/ansi/ornament.d.ts +3 -0
- package/dist/ansi/ornament.js +8 -0
- package/dist/ansi-panes.css +929 -0
- package/dist/art-deco.css +850 -0
- package/dist/atelier/ornament.d.ts +4 -0
- package/dist/atelier/ornament.js +9 -0
- package/dist/atomic/ornament.d.ts +3 -0
- package/dist/atomic/ornament.js +8 -0
- package/dist/atomic-lounge.css +946 -0
- package/dist/authoring.d.ts +142 -0
- package/dist/authoring.js +83 -0
- package/dist/azulejo/ornament.d.ts +3 -0
- package/dist/azulejo/ornament.js +8 -0
- package/dist/azulejo-tile.css +962 -0
- package/dist/blueprint/ornament.d.ts +3 -0
- package/dist/blueprint/ornament.js +8 -0
- package/dist/blueprint.css +897 -0
- package/dist/brutalist/ornament.d.ts +3 -0
- package/dist/brutalist/ornament.js +8 -0
- package/dist/brutalist-concrete.css +936 -0
- package/dist/bytes.json +162 -0
- package/dist/candy-clay.css +778 -0
- package/dist/catalog.d.ts +1346 -0
- package/dist/catalog.js +1379 -0
- package/dist/character.d.ts +103 -0
- package/dist/character.js +477 -0
- package/dist/chrome.d.ts +114 -0
- package/dist/chrome.js +247 -0
- package/dist/clay/ornament.d.ts +3 -0
- package/dist/clay/ornament.js +8 -0
- package/dist/compile-css.d.ts +3 -0
- package/dist/compile-css.js +133 -0
- package/dist/deco/ornament.d.ts +3 -0
- package/dist/deco/ornament.js +8 -0
- package/dist/diwali/ornament.d.ts +3 -0
- package/dist/diwali/ornament.js +8 -0
- package/dist/diwali-lights.css +894 -0
- package/dist/documents.d.ts +104 -0
- package/dist/documents.js +1411 -0
- package/dist/forest/ornament.d.ts +3 -0
- package/dist/forest/ornament.js +12 -0
- package/dist/foundations.d.ts +105 -0
- package/dist/foundations.js +140 -0
- package/dist/frame-corners.d.ts +5 -0
- package/dist/frame-corners.js +5 -0
- package/dist/frame-styles.d.ts +36 -0
- package/dist/frame-styles.js +146 -0
- package/dist/freeze.d.ts +2 -0
- package/dist/freeze.js +9 -0
- package/dist/freighter/ornament.d.ts +3 -0
- package/dist/freighter/ornament.js +8 -0
- package/dist/freighter.css +942 -0
- package/dist/glass/ornament.d.ts +3 -0
- package/dist/glass/ornament.js +8 -0
- package/dist/glass-ui.css +923 -0
- package/dist/hardware/ornament.d.ts +4 -0
- package/dist/hardware/ornament.js +10 -0
- package/dist/holi/ornament.d.ts +3 -0
- package/dist/holi/ornament.js +8 -0
- package/dist/holi-colours.css +887 -0
- package/dist/hud/ornament.d.ts +3 -0
- package/dist/hud/ornament.js +8 -0
- package/dist/light/ornament.d.ts +3 -0
- package/dist/light/ornament.js +10 -0
- package/dist/living-forest.css +917 -0
- package/dist/lunar/ornament.d.ts +3 -0
- package/dist/lunar/ornament.js +8 -0
- package/dist/lunar-new-year.css +811 -0
- package/dist/manifests.d.ts +2485 -0
- package/dist/manifests.js +264 -0
- package/dist/material-light.css +880 -0
- package/dist/neon/ornament.d.ts +3 -0
- package/dist/neon/ornament.js +8 -0
- package/dist/neon-night-market.css +944 -0
- package/dist/ocean/ornament.d.ts +3 -0
- package/dist/ocean/ornament.js +8 -0
- package/dist/ocean-depths.css +825 -0
- package/dist/orbital-hud.css +973 -0
- package/dist/organic-css.d.ts +3 -0
- package/dist/organic-css.js +23 -0
- package/dist/organic-geometry.d.ts +20 -0
- package/dist/organic-geometry.js +74 -0
- package/dist/organic-ornament.d.ts +9 -0
- package/dist/organic-ornament.js +28 -0
- package/dist/organic-paint-launch.d.ts +43 -0
- package/dist/organic-paint-launch.js +1156 -0
- package/dist/organic-paint-world.d.ts +24 -0
- package/dist/organic-paint-world.js +676 -0
- package/dist/organic-paint.d.ts +50 -0
- package/dist/organic-paint.js +695 -0
- package/dist/organic-runtime.d.ts +4 -0
- package/dist/organic-runtime.js +231 -0
- package/dist/organic-settings.d.ts +680 -0
- package/dist/organic-settings.js +433 -0
- package/dist/ornament-parts.d.ts +20 -0
- package/dist/ornament-parts.js +15 -0
- package/dist/papel-picado.css +904 -0
- package/dist/phosphor/ornament.d.ts +3 -0
- package/dist/phosphor/ornament.js +8 -0
- package/dist/phosphor-terminal.css +1011 -0
- package/dist/picado/ornament.d.ts +3 -0
- package/dist/picado/ornament.js +8 -0
- package/dist/prism/ornament.d.ts +4 -0
- package/dist/prism/ornament.js +9 -0
- package/dist/prism-glass.css +970 -0
- package/dist/recipes.d.ts +938 -0
- package/dist/recipes.js +939 -0
- package/dist/resolve.d.ts +40 -0
- package/dist/resolve.js +24 -0
- package/dist/retro/ornament.d.ts +5 -0
- package/dist/retro/ornament.js +10 -0
- package/dist/retro-os.css +867 -0
- package/dist/riso/ornament.d.ts +3 -0
- package/dist/riso/ornament.js +8 -0
- package/dist/riso-zine.css +786 -0
- package/dist/sail/ornament.d.ts +3 -0
- package/dist/sail/ornament.js +8 -0
- package/dist/scrapbook.css +685 -0
- package/dist/signal/ornament.d.ts +4 -0
- package/dist/signal/ornament.js +11 -0
- package/dist/skins/ansi-panes.d.ts +68 -0
- package/dist/skins/ansi-panes.js +68 -0
- package/dist/skins/art-deco.d.ts +68 -0
- package/dist/skins/art-deco.js +68 -0
- package/dist/skins/atomic-lounge.d.ts +68 -0
- package/dist/skins/atomic-lounge.js +68 -0
- package/dist/skins/azulejo-tile.d.ts +68 -0
- package/dist/skins/azulejo-tile.js +68 -0
- package/dist/skins/blueprint.d.ts +68 -0
- package/dist/skins/blueprint.js +68 -0
- package/dist/skins/brutalist-concrete.d.ts +68 -0
- package/dist/skins/brutalist-concrete.js +68 -0
- package/dist/skins/candy-clay.d.ts +57 -0
- package/dist/skins/candy-clay.js +57 -0
- package/dist/skins/diwali-lights.d.ts +68 -0
- package/dist/skins/diwali-lights.js +68 -0
- package/dist/skins/freighter.d.ts +68 -0
- package/dist/skins/freighter.js +68 -0
- package/dist/skins/glass-ui.d.ts +68 -0
- package/dist/skins/glass-ui.js +68 -0
- package/dist/skins/holi-colours.d.ts +68 -0
- package/dist/skins/holi-colours.js +68 -0
- package/dist/skins/living-forest.d.ts +68 -0
- package/dist/skins/living-forest.js +68 -0
- package/dist/skins/lunar-new-year.d.ts +68 -0
- package/dist/skins/lunar-new-year.js +68 -0
- package/dist/skins/material-light.d.ts +57 -0
- package/dist/skins/material-light.js +57 -0
- package/dist/skins/neon-night-market.d.ts +68 -0
- package/dist/skins/neon-night-market.js +68 -0
- package/dist/skins/ocean-depths.d.ts +57 -0
- package/dist/skins/ocean-depths.js +57 -0
- package/dist/skins/orbital-hud.d.ts +68 -0
- package/dist/skins/orbital-hud.js +68 -0
- package/dist/skins/papel-picado.d.ts +68 -0
- package/dist/skins/papel-picado.js +68 -0
- package/dist/skins/phosphor-terminal.d.ts +68 -0
- package/dist/skins/phosphor-terminal.js +68 -0
- package/dist/skins/prism-glass.d.ts +57 -0
- package/dist/skins/prism-glass.js +57 -0
- package/dist/skins/retro-os.d.ts +57 -0
- package/dist/skins/retro-os.js +57 -0
- package/dist/skins/riso-zine.d.ts +68 -0
- package/dist/skins/riso-zine.js +68 -0
- package/dist/skins/scrapbook.d.ts +57 -0
- package/dist/skins/scrapbook.js +57 -0
- package/dist/skins/solar-sail.d.ts +68 -0
- package/dist/skins/solar-sail.js +68 -0
- package/dist/skins/studio-hardware.d.ts +57 -0
- package/dist/skins/studio-hardware.js +57 -0
- package/dist/skins/sumi-ink.d.ts +68 -0
- package/dist/skins/sumi-ink.js +68 -0
- package/dist/skins/swiss-poster.d.ts +68 -0
- package/dist/skins/swiss-poster.js +68 -0
- package/dist/skins/tactile-atelier.d.ts +57 -0
- package/dist/skins/tactile-atelier.js +57 -0
- package/dist/skins/tokyo-transit.d.ts +68 -0
- package/dist/skins/tokyo-transit.js +68 -0
- package/dist/skins/winamp-signal.d.ts +57 -0
- package/dist/skins/winamp-signal.js +57 -0
- package/dist/skins/y2k-titanium.d.ts +57 -0
- package/dist/skins/y2k-titanium.js +57 -0
- package/dist/skins/yule-knit.d.ts +68 -0
- package/dist/skins/yule-knit.js +68 -0
- package/dist/solar-sail.css +901 -0
- package/dist/starters-launch.d.ts +361 -0
- package/dist/starters-launch.js +808 -0
- package/dist/studio-hardware.css +867 -0
- package/dist/sumi/ornament.d.ts +3 -0
- package/dist/sumi/ornament.js +8 -0
- package/dist/sumi-ink.css +733 -0
- package/dist/swiss/ornament.d.ts +3 -0
- package/dist/swiss/ornament.js +8 -0
- package/dist/swiss-poster.css +727 -0
- package/dist/tactile-atelier.css +922 -0
- package/dist/theme-document.schema.json +790 -0
- package/dist/theme-schema.d.ts +453 -0
- package/dist/theme-schema.js +416 -0
- package/dist/titanium/ornament.d.ts +3 -0
- package/dist/titanium/ornament.js +11 -0
- package/dist/tokyo-transit.css +756 -0
- package/dist/transit/ornament.d.ts +3 -0
- package/dist/transit/ornament.js +8 -0
- package/dist/winamp-signal.css +1048 -0
- package/dist/y2k-titanium.css +979 -0
- package/dist/yule/ornament.d.ts +3 -0
- package/dist/yule/ornament.js +8 -0
- package/dist/yule-knit.css +850 -0
- package/package.json +351 -3
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Painters for the 2026-12 launch frames: terminal (crt, panes), extras
|
|
3
|
+
* (swiss, azulejo, formwork, atomic), sci-fi (hud, hazard, orbit), festival
|
|
4
|
+
* (rangoli, papercut, picado, gulal, knit) and Glass UI (liquid).
|
|
5
|
+
*
|
|
6
|
+
* Same contract as organic-paint-world.ts: card space spans 0..w × 0..h with a
|
|
7
|
+
* 32px gutter; every painter clips itself away from the content box (`limits`),
|
|
8
|
+
* outlines only follow the card's own rounded rectangle via `trace()`, and
|
|
9
|
+
* motion runs only while `playing`, so reduced motion gets a still frame.
|
|
10
|
+
*/
|
|
11
|
+
import { paintState, rand, tau, trace, } from "./organic-paint.js";
|
|
12
|
+
const cornersOf = (w, h) => [
|
|
13
|
+
[0, 0, 1, 1],
|
|
14
|
+
[w, 0, -1, 1],
|
|
15
|
+
[w, h, -1, -1],
|
|
16
|
+
[0, h, 1, -1],
|
|
17
|
+
];
|
|
18
|
+
/** Keep every mark out of the content box, whatever the painter draws. */
|
|
19
|
+
function clipFrame(ctx, w, h, l) {
|
|
20
|
+
ctx.beginPath();
|
|
21
|
+
ctx.rect(-32, -32, w + 64, h + 64);
|
|
22
|
+
ctx.rect(l.inline, l.top, Math.max(0, w - l.inline * 2), Math.max(0, h - l.top - l.bottom));
|
|
23
|
+
ctx.clip("evenodd");
|
|
24
|
+
}
|
|
25
|
+
/** Light or dark surface, read from the computed paper colour. */
|
|
26
|
+
function isDark(color) {
|
|
27
|
+
const n = (color.match(/-?[\d.]+/g) ?? []).map(Number);
|
|
28
|
+
if (color.startsWith("color("))
|
|
29
|
+
return luma(n[0] * 255, n[1] * 255, n[2] * 255);
|
|
30
|
+
if (color.startsWith("oklch") || color.startsWith("oklab"))
|
|
31
|
+
return n[0] < (color.includes("%") ? 50 : 0.5);
|
|
32
|
+
if (n.length >= 4 && n[3] === 0)
|
|
33
|
+
return false;
|
|
34
|
+
return n.length >= 3 ? luma(n[0], n[1], n[2]) : false;
|
|
35
|
+
}
|
|
36
|
+
const luma = (r, g, b) => 0.2126 * r + 0.7152 * g + 0.0722 * b < 110;
|
|
37
|
+
function outline(ctx, p, color, width = 1, alpha = 1, offset = 0) {
|
|
38
|
+
trace(ctx, p, offset);
|
|
39
|
+
ctx.closePath();
|
|
40
|
+
ctx.strokeStyle = color;
|
|
41
|
+
ctx.lineWidth = width;
|
|
42
|
+
ctx.globalAlpha = alpha;
|
|
43
|
+
ctx.stroke();
|
|
44
|
+
ctx.globalAlpha = 1;
|
|
45
|
+
}
|
|
46
|
+
function glow(ctx, color, blur) {
|
|
47
|
+
ctx.shadowColor = color;
|
|
48
|
+
ctx.shadowBlur = blur;
|
|
49
|
+
}
|
|
50
|
+
function dot(ctx, x, y, r, color) {
|
|
51
|
+
ctx.beginPath();
|
|
52
|
+
ctx.arc(x, y, Math.max(0.2, r), 0, tau);
|
|
53
|
+
ctx.fillStyle = color;
|
|
54
|
+
ctx.fill();
|
|
55
|
+
}
|
|
56
|
+
function label(ctx, text, x, y, font, color, align = "left") {
|
|
57
|
+
ctx.font = font;
|
|
58
|
+
ctx.textAlign = align;
|
|
59
|
+
ctx.textBaseline = "middle";
|
|
60
|
+
ctx.fillStyle = color;
|
|
61
|
+
ctx.fillText(text, x, y);
|
|
62
|
+
return ctx.measureText(text).width;
|
|
63
|
+
}
|
|
64
|
+
/** An n-point star with alternating radii, used for bursts and sparkles. */
|
|
65
|
+
function star(ctx, x, y, outer, inner, points, turn = 0) {
|
|
66
|
+
ctx.beginPath();
|
|
67
|
+
for (let i = 0; i < points * 2; i++) {
|
|
68
|
+
const r = i % 2 ? inner : outer, a = turn + (i * Math.PI) / points - Math.PI / 2;
|
|
69
|
+
if (i)
|
|
70
|
+
ctx.lineTo(x + Math.cos(a) * r, y + Math.sin(a) * r);
|
|
71
|
+
else
|
|
72
|
+
ctx.moveTo(x + Math.cos(a) * r, y + Math.sin(a) * r);
|
|
73
|
+
}
|
|
74
|
+
ctx.closePath();
|
|
75
|
+
}
|
|
76
|
+
/** Run a painter body inside a saved, frame-clipped context. */
|
|
77
|
+
function framed(ctx, w, h, l, body) {
|
|
78
|
+
ctx.save();
|
|
79
|
+
clipFrame(ctx, w, h, l);
|
|
80
|
+
body();
|
|
81
|
+
ctx.restore();
|
|
82
|
+
}
|
|
83
|
+
function tierOf(w, h) {
|
|
84
|
+
if (w >= 440 && h >= 160)
|
|
85
|
+
return "hero";
|
|
86
|
+
if (w < 300 || h < 140)
|
|
87
|
+
return "small";
|
|
88
|
+
return "card";
|
|
89
|
+
}
|
|
90
|
+
/** A deterministic per-instance choice in 0..n-1. */
|
|
91
|
+
function choose(index, seed, salt, n) {
|
|
92
|
+
return Math.floor(rand(seed * 0.173 + index * 7.31 + salt * 3.7) * n) % n;
|
|
93
|
+
}
|
|
94
|
+
/** Distance from a point to the content box: soft marks are sized to stop short of it, never clipped by it. */
|
|
95
|
+
function room(x, y, w, h, l) {
|
|
96
|
+
const dx = Math.max(l.inline - x, 0, x - (w - l.inline)), dy = Math.max(l.top - y, 0, y - (h - l.bottom));
|
|
97
|
+
return Math.hypot(dx, dy);
|
|
98
|
+
}
|
|
99
|
+
const vt = (size) => `${size}px "VT323", "Share Tech Mono", ui-monospace, monospace`;
|
|
100
|
+
export const paintPhosphor = (ctx, p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
101
|
+
const s = paintState(settings), dark = isDark(colors.paper), tier = tierOf(w, h), quiet = s.style === "Quiet cursor";
|
|
102
|
+
if (dark)
|
|
103
|
+
glow(ctx, colors.a, 7);
|
|
104
|
+
// A double rule just outside the edge, like ╔═╗ drawn in box characters.
|
|
105
|
+
outline(ctx, p, colors.a, 1, dark ? 0.85 : 0.55, 3);
|
|
106
|
+
if (!quiet)
|
|
107
|
+
outline(ctx, p, colors.a, 1, dark ? 0.55 : 0.35, 6);
|
|
108
|
+
if (!quiet) {
|
|
109
|
+
const len = 8 + s.scale * 0.45 * (0.5 + s.corners / 100);
|
|
110
|
+
ctx.strokeStyle = colors.a;
|
|
111
|
+
ctx.lineWidth = 2;
|
|
112
|
+
for (const [x, y, sx, sy] of cornersOf(w, h)) {
|
|
113
|
+
const ox = x - sx * 6, oy = y - sy * 6;
|
|
114
|
+
ctx.beginPath();
|
|
115
|
+
ctx.moveTo(ox + sx * len, oy);
|
|
116
|
+
ctx.lineTo(ox, oy);
|
|
117
|
+
ctx.lineTo(ox, oy + sy * len);
|
|
118
|
+
ctx.stroke();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (s.style === "Raster ticks" && tier === "hero") {
|
|
122
|
+
ctx.strokeStyle = colors.b;
|
|
123
|
+
ctx.lineWidth = 1;
|
|
124
|
+
for (let x = 12, i = 0; x < w - 12; x += 8, i++) {
|
|
125
|
+
ctx.beginPath();
|
|
126
|
+
ctx.moveTo(x, -10);
|
|
127
|
+
ctx.lineTo(x, i % 8 ? -13 : -17);
|
|
128
|
+
ctx.stroke();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Status line inside the card's bottom margin; small cards keep only the cursor.
|
|
132
|
+
const y = h - Math.min(11, limits.bottom / 2);
|
|
133
|
+
let x = Math.max(8, limits.inline - 2);
|
|
134
|
+
if (tier !== "small" &&
|
|
135
|
+
(s.style === "Status line" || s.style === "Box corners")) {
|
|
136
|
+
x += label(ctx, "READY.", x, y, vt(16), colors.a) + 4;
|
|
137
|
+
if (shown > 35 && tier === "hero")
|
|
138
|
+
label(ctx, `MEM ${Math.round(64 * (shown / 100))}K OK`, w - Math.max(8, limits.inline - 2), y, vt(15), colors.c, "right");
|
|
139
|
+
}
|
|
140
|
+
if (!playing || clock % 1 < 0.55) {
|
|
141
|
+
ctx.fillStyle = colors.a;
|
|
142
|
+
ctx.fillRect(x, y - 6, 7, 12);
|
|
143
|
+
}
|
|
144
|
+
if (playing && s.motion > 0 && choose(index, s.seed, 1, 2) === 0) {
|
|
145
|
+
const q = p.at((((clock * s.motion) / 300) % 1) * p.total);
|
|
146
|
+
ctx.shadowBlur = dark ? 12 : 0;
|
|
147
|
+
dot(ctx, q.x + Math.cos(q.t - Math.PI / 2) * 3, q.y + Math.sin(q.t - Math.PI / 2) * 3, 1.8, colors.d);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
/* ───────────────────────── ANSI Panes ───────────────────────── */
|
|
151
|
+
const jb = (size, weight = 700) => `${weight} ${size}px "JetBrains Mono", ui-monospace, Menlo, monospace`;
|
|
152
|
+
const paneTitles = [
|
|
153
|
+
"main",
|
|
154
|
+
"logs",
|
|
155
|
+
"files",
|
|
156
|
+
"branches",
|
|
157
|
+
"status",
|
|
158
|
+
"jobs",
|
|
159
|
+
"diff",
|
|
160
|
+
];
|
|
161
|
+
const spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
162
|
+
export const paintPanes = (ctx, p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
163
|
+
const s = paintState(settings), seed = s.seed + index * 37, bare = s.style === "Bare panes";
|
|
164
|
+
// A single-line pane box just outside the card, like ┌─┐.
|
|
165
|
+
outline(ctx, p, colors.d, 1, 0.7, 5);
|
|
166
|
+
if (bare)
|
|
167
|
+
return;
|
|
168
|
+
// Title cut into the top rule: "─ branches ─".
|
|
169
|
+
const title = paneTitles[Math.floor(rand(seed) * paneTitles.length)];
|
|
170
|
+
ctx.font = jb(11);
|
|
171
|
+
const tw = ctx.measureText(title).width + 12, tx = Math.max(14, p.r + 8);
|
|
172
|
+
ctx.save();
|
|
173
|
+
ctx.globalCompositeOperation = "destination-out";
|
|
174
|
+
ctx.fillRect(tx - 2, -9, tw + 4, 8);
|
|
175
|
+
ctx.restore();
|
|
176
|
+
label(ctx, title, tx + 6, -5, jb(11), colors.a);
|
|
177
|
+
// Pane numbers on the right of the rule; the focused one is reverse video.
|
|
178
|
+
const panes = 1 + Math.round((shown / 100) * 3), focus = Math.floor(rand(seed + 3) * panes);
|
|
179
|
+
for (let i = 0; i < panes; i++) {
|
|
180
|
+
const bx = w - 14 - (panes - i) * 18;
|
|
181
|
+
ctx.fillStyle = i === focus ? colors.b : "transparent";
|
|
182
|
+
if (i === focus)
|
|
183
|
+
ctx.fillRect(bx - 1, -11, 14, 12);
|
|
184
|
+
label(ctx, String(i + 1), bx + 6, -5, jb(10), i === focus ? colors.paper || "#000" : colors.d, "center");
|
|
185
|
+
}
|
|
186
|
+
// Activity spinner at the top-right corner.
|
|
187
|
+
const frame = playing && s.motion > 0 ? Math.floor(clock * 10) % spinner.length : 0;
|
|
188
|
+
label(ctx, playing ? spinner[frame] : "●", w - 6, -5, jb(10, 400), colors.c, "center");
|
|
189
|
+
if (s.style === "Split panes") {
|
|
190
|
+
// Tee junctions where a split would meet the frame: ├ and ┤.
|
|
191
|
+
ctx.strokeStyle = colors.d;
|
|
192
|
+
ctx.lineWidth = 1;
|
|
193
|
+
for (const x of [-5, w + 5]) {
|
|
194
|
+
ctx.beginPath();
|
|
195
|
+
ctx.moveTo(x, h / 2);
|
|
196
|
+
ctx.lineTo(x + (x < 0 ? 8 : -8), h / 2);
|
|
197
|
+
ctx.stroke();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if ((s.style === "Key hints" || s.style === "Pane titles") &&
|
|
201
|
+
w > 240 &&
|
|
202
|
+
limits.bottom >= 14) {
|
|
203
|
+
// Keyboard hints sit in the card's own bottom margin, so dense grids
|
|
204
|
+
// never collide with the next card; narrow cards skip them.
|
|
205
|
+
const hints = [
|
|
206
|
+
["↑↓", "move"],
|
|
207
|
+
["⏎", "open"],
|
|
208
|
+
["/", "find"],
|
|
209
|
+
["?", "help"],
|
|
210
|
+
["q", "quit"],
|
|
211
|
+
];
|
|
212
|
+
let x = Math.max(8, limits.inline - 2);
|
|
213
|
+
const y = h - Math.min(11, limits.bottom / 2);
|
|
214
|
+
for (const [key, text] of hints) {
|
|
215
|
+
ctx.font = jb(10);
|
|
216
|
+
const kw = ctx.measureText(key).width + 6;
|
|
217
|
+
ctx.font = jb(10, 400);
|
|
218
|
+
const lw = ctx.measureText(text).width;
|
|
219
|
+
if (x + kw + lw + 4 > w)
|
|
220
|
+
break;
|
|
221
|
+
ctx.fillStyle = colors.d;
|
|
222
|
+
ctx.fillRect(x, y - 6, kw, 12);
|
|
223
|
+
ctx.save();
|
|
224
|
+
ctx.globalCompositeOperation = "destination-out";
|
|
225
|
+
label(ctx, key, x + kw / 2, y, jb(10), "#000", "center");
|
|
226
|
+
ctx.restore();
|
|
227
|
+
label(ctx, text, x + kw + 4, y, jb(10, 400), colors.d);
|
|
228
|
+
x += kw + lw + 14;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
/* ───────────────────────── Swiss Poster ───────────────────────── */
|
|
233
|
+
export const paintSwiss = (ctx, _p, w, h, settings, colors, _clock, _playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
234
|
+
const s = paintState(settings), tier = tierOf(w, h), len = 8 + s.scale * 0.25;
|
|
235
|
+
// Crop marks are the system: every sheet gets them.
|
|
236
|
+
ctx.strokeStyle = colors.d;
|
|
237
|
+
ctx.lineWidth = 1;
|
|
238
|
+
for (const [x, y, sx, sy] of cornersOf(w, h)) {
|
|
239
|
+
ctx.beginPath();
|
|
240
|
+
ctx.moveTo(x, y - sy * 6);
|
|
241
|
+
ctx.lineTo(x, y - sy * (6 + len));
|
|
242
|
+
ctx.moveTo(x - sx * 6, y);
|
|
243
|
+
ctx.lineTo(x - sx * (6 + len), y);
|
|
244
|
+
ctx.stroke();
|
|
245
|
+
}
|
|
246
|
+
if (s.style === "Bare grid")
|
|
247
|
+
return;
|
|
248
|
+
// The red block is an event, not a pattern: the hero always, other cards rarely.
|
|
249
|
+
const block = s.style === "Red block" ||
|
|
250
|
+
tier === "hero" ||
|
|
251
|
+
(tier === "card" && choose(index, s.seed, 2, 3) === 0);
|
|
252
|
+
if (block) {
|
|
253
|
+
const bh = Math.min(limits.top - 4, 6 + s.weight * 0.14), bw = w * (0.18 + (shown / 100) * 0.22), left = choose(index, s.seed, 3, 2) === 0 && tier !== "hero";
|
|
254
|
+
if (bh > 2) {
|
|
255
|
+
ctx.fillStyle = colors.a;
|
|
256
|
+
ctx.fillRect(left ? 0 : w - bw, 0, bw, bh);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if ((s.style === "Grid ticks" || s.style === "Crop marks") &&
|
|
260
|
+
tier === "hero") {
|
|
261
|
+
for (let i = 0; i <= 6; i++) {
|
|
262
|
+
const x = (w * i) / 6;
|
|
263
|
+
ctx.beginPath();
|
|
264
|
+
ctx.moveTo(x, h + 6);
|
|
265
|
+
ctx.lineTo(x, h + 11);
|
|
266
|
+
ctx.stroke();
|
|
267
|
+
if (i < 6)
|
|
268
|
+
label(ctx, String(i + 1).padStart(2, "0"), x + 3, h + 16, '600 8px "Inter Variable", Inter, sans-serif', colors.c);
|
|
269
|
+
}
|
|
270
|
+
const x = w + 12, y = h + 12, r = 5;
|
|
271
|
+
ctx.beginPath();
|
|
272
|
+
ctx.arc(x, y, r, 0, tau);
|
|
273
|
+
ctx.moveTo(x - r - 3, y);
|
|
274
|
+
ctx.lineTo(x + r + 3, y);
|
|
275
|
+
ctx.moveTo(x, y - r - 3);
|
|
276
|
+
ctx.lineTo(x, y + r + 3);
|
|
277
|
+
ctx.stroke();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
/* ───────────────────────── Azulejo Tile ───────────────────────── */
|
|
281
|
+
function tile(ctx, x, y, size, colors, rosette) {
|
|
282
|
+
const r = size / 2;
|
|
283
|
+
ctx.save();
|
|
284
|
+
ctx.translate(x, y);
|
|
285
|
+
ctx.fillStyle = colors.d;
|
|
286
|
+
ctx.strokeStyle = colors.a;
|
|
287
|
+
ctx.lineWidth = 1;
|
|
288
|
+
ctx.beginPath();
|
|
289
|
+
ctx.rect(-r, -r, size, size);
|
|
290
|
+
ctx.fill();
|
|
291
|
+
ctx.stroke();
|
|
292
|
+
ctx.save();
|
|
293
|
+
ctx.clip();
|
|
294
|
+
// Quarter circles in each corner join into rings when tiles meet.
|
|
295
|
+
ctx.lineWidth = Math.max(1, size * 0.07);
|
|
296
|
+
for (const [cx, cy] of [
|
|
297
|
+
[-r, -r],
|
|
298
|
+
[r, -r],
|
|
299
|
+
[r, r],
|
|
300
|
+
[-r, r],
|
|
301
|
+
]) {
|
|
302
|
+
ctx.beginPath();
|
|
303
|
+
ctx.arc(cx, cy, size * 0.3, 0, tau);
|
|
304
|
+
ctx.stroke();
|
|
305
|
+
}
|
|
306
|
+
// Four-petal flower (eight on a rosette).
|
|
307
|
+
const petals = rosette ? 8 : 4;
|
|
308
|
+
ctx.fillStyle = colors.b;
|
|
309
|
+
for (let i = 0; i < petals; i++) {
|
|
310
|
+
ctx.save();
|
|
311
|
+
ctx.rotate((i * tau) / petals);
|
|
312
|
+
ctx.beginPath();
|
|
313
|
+
ctx.ellipse(0, -r * 0.42, r * 0.16, r * 0.34, 0, 0, tau);
|
|
314
|
+
ctx.fill();
|
|
315
|
+
ctx.restore();
|
|
316
|
+
}
|
|
317
|
+
dot(ctx, 0, 0, r * 0.18, colors.c);
|
|
318
|
+
// Glaze highlight.
|
|
319
|
+
ctx.globalAlpha = 0.45;
|
|
320
|
+
ctx.strokeStyle = "#ffffff";
|
|
321
|
+
ctx.lineWidth = 1.2;
|
|
322
|
+
ctx.beginPath();
|
|
323
|
+
ctx.arc(-r * 0.2, -r * 0.2, r * 0.75, Math.PI * 1.05, Math.PI * 1.45);
|
|
324
|
+
ctx.stroke();
|
|
325
|
+
ctx.restore();
|
|
326
|
+
ctx.restore();
|
|
327
|
+
}
|
|
328
|
+
export const paintAzulejo = (ctx, p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
329
|
+
const s = paintState(settings), tier = tierOf(w, h), size = 14 + s.scale * 0.5, corners = cornersOf(w, h), at = choose(index, s.seed, 1, 4), rosette = s.style === "Floral rosette";
|
|
330
|
+
outline(ctx, p, colors.a, 1, 0.5, 4);
|
|
331
|
+
const corner = (k, scale) => {
|
|
332
|
+
const [x, y, sx, sy] = corners[k], t = size * scale;
|
|
333
|
+
tile(ctx, x - sx * t * 0.15, y - sy * t * 0.15, t, colors, rosette);
|
|
334
|
+
};
|
|
335
|
+
if (tier === "hero" || s.style === "Border run") {
|
|
336
|
+
// A frieze: a run of tiles along the top edge, finished by a corner tile at each end.
|
|
337
|
+
const small = size * 0.62, step = small + 1;
|
|
338
|
+
const run = Math.max(0, Math.floor((w - size * 2) / step) * (shown / 100));
|
|
339
|
+
const start = (w - run * step) / 2 + step / 2;
|
|
340
|
+
for (let i = 0; i < Math.floor(run); i++)
|
|
341
|
+
tile(ctx, start + i * step, -small / 2 - 4, small, colors, false);
|
|
342
|
+
corner(0, 1);
|
|
343
|
+
corner(1, 1);
|
|
344
|
+
}
|
|
345
|
+
else if (tier === "card") {
|
|
346
|
+
// One corner tile, with two smaller tiles trailing along the edge.
|
|
347
|
+
corner(at, 1);
|
|
348
|
+
const [x, y, sx, sy] = corners[at], small = size * 0.55;
|
|
349
|
+
for (let i = 1; i <= 2; i++)
|
|
350
|
+
tile(ctx, x + sx * (size * 0.5 + i * (small + 1)), y - sy * (small / 2 + 4), small, colors, false);
|
|
351
|
+
}
|
|
352
|
+
else if (s.style !== "Single tile" || at % 2 === 0)
|
|
353
|
+
corner(at, 0.75);
|
|
354
|
+
if (playing && s.motion > 0 && tier === "hero") {
|
|
355
|
+
const x = ((clock * s.motion * 0.6) % (w + 80)) - 40;
|
|
356
|
+
const g = ctx.createLinearGradient(x - 20, 0, x + 20, 0);
|
|
357
|
+
g.addColorStop(0, "#ffffff00");
|
|
358
|
+
g.addColorStop(0.5, "#ffffff66");
|
|
359
|
+
g.addColorStop(1, "#ffffff00");
|
|
360
|
+
ctx.fillStyle = g;
|
|
361
|
+
ctx.fillRect(x - 20, -32, 40, 32);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
/* ───────────────────────── Brutalist Concrete ───────────────────────── */
|
|
365
|
+
function tieHole(ctx, x, y, r, colors) {
|
|
366
|
+
dot(ctx, x, y + 0.8, r + 0.8, "rgba(255,255,255,.35)");
|
|
367
|
+
dot(ctx, x, y, r, colors.a);
|
|
368
|
+
dot(ctx, x - r * 0.25, y - r * 0.25, r * 0.45, "rgba(0,0,0,.35)");
|
|
369
|
+
}
|
|
370
|
+
export const paintFormwork = (ctx, p, w, h, settings, colors, _clock, _playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
371
|
+
const s = paintState(settings), tier = tierOf(w, h), r = 2 + s.scale * 0.06, inset = Math.max(6, Math.min(11, limits.top / 2, limits.inline / 2));
|
|
372
|
+
outline(ctx, p, colors.b, 1, 0.4, 3);
|
|
373
|
+
if (s.style === "Bare concrete")
|
|
374
|
+
return;
|
|
375
|
+
// Tie holes follow the formwork grid: corners always, a row on wider pours.
|
|
376
|
+
for (const [x, y, sx, sy] of cornersOf(w, h))
|
|
377
|
+
tieHole(ctx, x + sx * inset, y + sy * inset, r, colors);
|
|
378
|
+
if (tier !== "small") {
|
|
379
|
+
const bays = Math.max(1, Math.round((w - inset * 2) / (90 + (100 - shown))));
|
|
380
|
+
for (let i = 1; i < bays; i++) {
|
|
381
|
+
const x = inset + ((w - inset * 2) * i) / bays;
|
|
382
|
+
tieHole(ctx, x, inset, r, colors);
|
|
383
|
+
tieHole(ctx, x, h - inset, r, colors);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (s.style === "Board seams" ||
|
|
387
|
+
(s.style === "Tie holes" && tier === "hero")) {
|
|
388
|
+
ctx.strokeStyle = colors.b;
|
|
389
|
+
ctx.globalAlpha = 0.55;
|
|
390
|
+
ctx.lineWidth = 1;
|
|
391
|
+
for (let y = 18; y < h - 6; y += 18) {
|
|
392
|
+
ctx.beginPath();
|
|
393
|
+
ctx.moveTo(-3, y);
|
|
394
|
+
ctx.lineTo(-7 - s.weight * 0.06, y);
|
|
395
|
+
ctx.stroke();
|
|
396
|
+
}
|
|
397
|
+
ctx.globalAlpha = 1;
|
|
398
|
+
}
|
|
399
|
+
if (tier !== "small" &&
|
|
400
|
+
(s.style === "Stencil marks" || s.style === "Tie holes")) {
|
|
401
|
+
// Each pour gets its own stencil code; only the hero carries a survey level.
|
|
402
|
+
const code = "ABCDEF"[choose(index, s.seed, 1, 6)] +
|
|
403
|
+
"-0" +
|
|
404
|
+
(1 + choose(index, s.seed, 2, 9));
|
|
405
|
+
label(ctx, code, Math.max(8, limits.inline - 2), h - Math.min(11, limits.bottom / 2), '400 11px "Archivo Black", "Arial Black", sans-serif', colors.d);
|
|
406
|
+
if (tier === "hero") {
|
|
407
|
+
const x = w - limits.inline - 46, y = h - Math.min(11, limits.bottom / 2);
|
|
408
|
+
ctx.fillStyle = colors.c;
|
|
409
|
+
ctx.beginPath();
|
|
410
|
+
ctx.moveTo(x, y + 5);
|
|
411
|
+
ctx.lineTo(x - 5, y - 4);
|
|
412
|
+
ctx.lineTo(x + 5, y - 4);
|
|
413
|
+
ctx.closePath();
|
|
414
|
+
ctx.fill();
|
|
415
|
+
label(ctx, "+4.20", x + 9, y, mono(11), colors.d);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
/* ───────────────────────── Atomic Lounge ───────────────────────── */
|
|
420
|
+
export const paintAtomic = (ctx, _p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
421
|
+
const s = paintState(settings), tier = tierOf(w, h), seed = s.seed + index * 53, spin = playing ? clock * (s.motion / 400) : 0, big = 9 + s.scale * 0.5, corners = cornersOf(w, h), at = choose(index, s.seed, 1, 4), inks = [colors.a, colors.c, colors.b];
|
|
422
|
+
const burst = (x, y, r, color, turn) => {
|
|
423
|
+
star(ctx, x, y, r, r * 0.28, 8, turn);
|
|
424
|
+
ctx.fillStyle = color;
|
|
425
|
+
ctx.fill();
|
|
426
|
+
star(ctx, x, y, r * 0.55, r * 0.2, 4, turn + Math.PI / 8);
|
|
427
|
+
ctx.fill();
|
|
428
|
+
};
|
|
429
|
+
const boomerang = (x, y, size, turn, color) => {
|
|
430
|
+
ctx.save();
|
|
431
|
+
ctx.translate(x, y);
|
|
432
|
+
ctx.rotate(turn);
|
|
433
|
+
ctx.beginPath();
|
|
434
|
+
ctx.moveTo(-size, 0);
|
|
435
|
+
ctx.quadraticCurveTo(0, -size * 0.9, size, 0);
|
|
436
|
+
ctx.quadraticCurveTo(0, -size * 0.35, -size, 0);
|
|
437
|
+
ctx.fillStyle = color;
|
|
438
|
+
ctx.globalAlpha = 0.85;
|
|
439
|
+
ctx.fill();
|
|
440
|
+
ctx.restore();
|
|
441
|
+
};
|
|
442
|
+
const atom = (x, y, r) => {
|
|
443
|
+
ctx.strokeStyle = colors.b;
|
|
444
|
+
ctx.lineWidth = 1.2;
|
|
445
|
+
for (let k = 0; k < 3; k++) {
|
|
446
|
+
const rot = (k * Math.PI) / 3, a = spin * 6 + k * 2, ex = Math.cos(a) * r, ey = Math.sin(a) * r * 0.35;
|
|
447
|
+
ctx.beginPath();
|
|
448
|
+
ctx.ellipse(x, y, r, r * 0.35, rot, 0, tau);
|
|
449
|
+
ctx.stroke();
|
|
450
|
+
dot(ctx, x + ex * Math.cos(rot) - ey * Math.sin(rot), y + ex * Math.sin(rot) + ey * Math.cos(rot), 1.8, colors.a);
|
|
451
|
+
}
|
|
452
|
+
dot(ctx, x, y, 3, colors.c);
|
|
453
|
+
};
|
|
454
|
+
const [cx, cy, sx, sy] = corners[at];
|
|
455
|
+
if (tier === "hero") {
|
|
456
|
+
// A little constellation: burst on one top corner, an atom on the other,
|
|
457
|
+
// a boomerang swinging under the opposite bottom corner, sparkles between.
|
|
458
|
+
const top = at % 2;
|
|
459
|
+
burst(top ? w - 4 : 4, -4, big, colors.a, spin);
|
|
460
|
+
if (s.style !== "Lone star") {
|
|
461
|
+
atom(top ? 10 : w - 10, -8, big);
|
|
462
|
+
boomerang(top ? 18 : w - 18, h + 8, big * 1.2, top ? 0.2 : Math.PI - 0.2, colors.b);
|
|
463
|
+
const count = Math.round((shown / 100) * 7);
|
|
464
|
+
for (let i = 0; i < count; i++) {
|
|
465
|
+
const x = 40 + rand(seed + i) * (w - 80), y = -8 - rand(seed + i + 9) * 12;
|
|
466
|
+
star(ctx, x, y, 2.5 + rand(seed + i + 2) * 3, 1, 4, spin * 2);
|
|
467
|
+
ctx.fillStyle = i % 2 ? colors.b : colors.a;
|
|
468
|
+
ctx.fill();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
else if (tier === "card") {
|
|
473
|
+
const ink = inks[choose(index, s.seed, 2, 3)];
|
|
474
|
+
if (s.style === "Orbit atoms")
|
|
475
|
+
atom(cx - sx * 4, cy - sy * 4, big);
|
|
476
|
+
else if (s.style === "Boomerangs")
|
|
477
|
+
boomerang(cx + sx * 14, cy - sy * 6, big * 1.1, sy < 0 ? Math.PI : 0, colors.b);
|
|
478
|
+
else
|
|
479
|
+
burst(cx - sx * 4, cy - sy * 4, big * 0.8, ink, spin);
|
|
480
|
+
star(ctx, cx + sx * (big * 2.4), cy - sy * 9, 3, 1, 4, spin * 2);
|
|
481
|
+
ctx.fillStyle = colors.b;
|
|
482
|
+
ctx.fill();
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
star(ctx, cx - sx * 4, cy - sy * 4, 5, 1.6, 4, spin * 2);
|
|
486
|
+
ctx.fillStyle = inks[choose(index, s.seed, 2, 3)];
|
|
487
|
+
ctx.fill();
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
/* ───────────────────────── Orbital HUD ───────────────────────── */
|
|
491
|
+
const mono = (size) => `${size}px "Share Tech Mono", ui-monospace, monospace`;
|
|
492
|
+
export const paintHud = (ctx, p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
493
|
+
const s = paintState(settings), tier = tierOf(w, h), dark = isDark(colors.paper), seed = s.seed + index * 11, len = 10 + s.scale * 0.45 * (0.6 + s.corners / 100);
|
|
494
|
+
if (dark)
|
|
495
|
+
glow(ctx, colors.a, 6);
|
|
496
|
+
// Target brackets are the instrument's grammar: every panel has them.
|
|
497
|
+
ctx.strokeStyle = colors.a;
|
|
498
|
+
ctx.lineWidth = 2;
|
|
499
|
+
for (const [x, y, sx, sy] of cornersOf(w, h)) {
|
|
500
|
+
const ox = x - sx * 5, oy = y - sy * 5;
|
|
501
|
+
ctx.beginPath();
|
|
502
|
+
ctx.moveTo(ox + sx * len, oy);
|
|
503
|
+
ctx.lineTo(ox, oy);
|
|
504
|
+
ctx.lineTo(ox, oy + sy * len);
|
|
505
|
+
ctx.stroke();
|
|
506
|
+
}
|
|
507
|
+
ctx.shadowBlur = 0;
|
|
508
|
+
outline(ctx, p, colors.b, 1, 0.35, 2);
|
|
509
|
+
if (s.style === "Clean brackets")
|
|
510
|
+
return;
|
|
511
|
+
// Telemetry lives on the main display; other panels show one readout each.
|
|
512
|
+
const jitter = playing ? Math.floor(clock * 4) : 0, readouts = ["SYS", "PWR", "O₂", "LNK", "THR", "NAV"], name = readouts[choose(index, s.seed, 1, readouts.length)], value = (0.9 + rand(seed + jitter) * 0.09).toFixed(2);
|
|
513
|
+
if (tier !== "small")
|
|
514
|
+
label(ctx, `${name} ${value}`, len + 10, -10, mono(10), colors.a);
|
|
515
|
+
if (tier === "hero" &&
|
|
516
|
+
(s.style === "Telemetry" || s.style === "Target brackets")) {
|
|
517
|
+
ctx.strokeStyle = colors.b;
|
|
518
|
+
ctx.lineWidth = 1;
|
|
519
|
+
for (let y = len + 12, i = 0; y < h - len - 12; y += 6, i++) {
|
|
520
|
+
ctx.beginPath();
|
|
521
|
+
ctx.moveTo(-6, y);
|
|
522
|
+
ctx.lineTo(i % 5 ? -9 : -13, y);
|
|
523
|
+
ctx.stroke();
|
|
524
|
+
}
|
|
525
|
+
const dv = (3 + rand(seed + jitter + 1) * 0.4).toFixed(1);
|
|
526
|
+
if (shown > 30)
|
|
527
|
+
label(ctx, `Δv ${dv} KM/S`, w - len - 10, h + 11, mono(10), colors.a, "right");
|
|
528
|
+
}
|
|
529
|
+
// One reticle locks onto one panel at a time.
|
|
530
|
+
if (s.style === "Reticle" ||
|
|
531
|
+
tier === "hero" ||
|
|
532
|
+
choose(index, s.seed, 2, 4) === 0) {
|
|
533
|
+
const x = w + 6, y = -6, r = 6 + s.scale * 0.25, turn = playing ? clock * (s.motion / 60) : 0;
|
|
534
|
+
ctx.strokeStyle = colors.c;
|
|
535
|
+
ctx.lineWidth = 1.2;
|
|
536
|
+
ctx.beginPath();
|
|
537
|
+
ctx.arc(x, y, r * 0.45, 0, tau);
|
|
538
|
+
ctx.stroke();
|
|
539
|
+
for (let k = 0; k < 3; k++) {
|
|
540
|
+
ctx.beginPath();
|
|
541
|
+
ctx.arc(x, y, r, turn + (k * tau) / 3, turn + (k * tau) / 3 + 1.2);
|
|
542
|
+
ctx.stroke();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (playing && s.motion > 0 && tier === "hero") {
|
|
546
|
+
const span = w - len * 2, x = len + ((clock * s.motion * 1.2) % span);
|
|
547
|
+
ctx.strokeStyle = colors.d;
|
|
548
|
+
ctx.lineWidth = 2;
|
|
549
|
+
ctx.globalAlpha = 0.8;
|
|
550
|
+
ctx.beginPath();
|
|
551
|
+
ctx.moveTo(x, -2);
|
|
552
|
+
ctx.lineTo(Math.min(w - len, x + 24), -2);
|
|
553
|
+
ctx.stroke();
|
|
554
|
+
ctx.globalAlpha = 1;
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
/* ───────────────────────── Freighter ───────────────────────── */
|
|
558
|
+
function stripes(ctx, x, y, bw, bh, a, b) {
|
|
559
|
+
ctx.save();
|
|
560
|
+
ctx.beginPath();
|
|
561
|
+
ctx.rect(x, y, bw, bh);
|
|
562
|
+
ctx.clip();
|
|
563
|
+
ctx.fillStyle = a;
|
|
564
|
+
ctx.fillRect(x, y, bw, bh);
|
|
565
|
+
ctx.fillStyle = b;
|
|
566
|
+
for (let i = -bh; i < bw + bh; i += 14) {
|
|
567
|
+
ctx.beginPath();
|
|
568
|
+
ctx.moveTo(x + i, y + bh);
|
|
569
|
+
ctx.lineTo(x + i + bh, y);
|
|
570
|
+
ctx.lineTo(x + i + bh + 7, y);
|
|
571
|
+
ctx.lineTo(x + i + 7, y + bh);
|
|
572
|
+
ctx.closePath();
|
|
573
|
+
ctx.fill();
|
|
574
|
+
}
|
|
575
|
+
ctx.restore();
|
|
576
|
+
}
|
|
577
|
+
function rivet(ctx, x, y, r, colors) {
|
|
578
|
+
dot(ctx, x, y + 0.7, r, "rgba(0,0,0,.35)");
|
|
579
|
+
dot(ctx, x, y, r, colors.d);
|
|
580
|
+
dot(ctx, x - r * 0.3, y - r * 0.3, r * 0.4, "rgba(255,255,255,.55)");
|
|
581
|
+
}
|
|
582
|
+
export const paintHazard = (ctx, _p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
583
|
+
const s = paintState(settings), tier = tierOf(w, h), band = 6 + s.weight * 0.06, inset = Math.max(6, Math.min(8, limits.top / 2, limits.inline / 2)), r = 1.6 + s.scale * 0.04, left = choose(index, s.seed, 1, 2) === 0;
|
|
584
|
+
// Rivets hold every plate; seams only on the big hull panels.
|
|
585
|
+
for (const [x, y, sx, sy] of cornersOf(w, h))
|
|
586
|
+
rivet(ctx, x + sx * inset, y + sy * inset, r, colors);
|
|
587
|
+
if (tier === "hero" && s.style !== "Clean plate") {
|
|
588
|
+
const step = 22 + (100 - shown) * 0.4;
|
|
589
|
+
for (let x = inset + step; x < w - inset - step / 2; x += step) {
|
|
590
|
+
rivet(ctx, x, inset, r * 0.8, colors);
|
|
591
|
+
rivet(ctx, x, h - inset, r * 0.8, colors);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (s.style === "Clean plate")
|
|
595
|
+
return;
|
|
596
|
+
if (tier === "hero") {
|
|
597
|
+
stripes(ctx, 0, -band - 3, w, band, colors.a, colors.b);
|
|
598
|
+
label(ctx, "BAY 02", w - 4, h + 14, '400 12px "Archivo Black", "Arial Black", sans-serif', colors.d, "right");
|
|
599
|
+
}
|
|
600
|
+
else if (tier === "card") {
|
|
601
|
+
// A strip of hazard tape wrapped round one top corner.
|
|
602
|
+
const tw = Math.min(90, w * 0.4);
|
|
603
|
+
stripes(ctx, left ? 0 : w - tw, -band - 3, tw, band, colors.a, colors.b);
|
|
604
|
+
label(ctx, "MAX " + (20 + choose(index, s.seed, 2, 5) * 10) + "T", left ? w : 0, -7, mono(10), colors.d, left ? "right" : "left");
|
|
605
|
+
}
|
|
606
|
+
// A warning beacon on the hero only.
|
|
607
|
+
if (tier === "hero" && (!playing || s.motion === 0 || clock % 1.2 < 0.6)) {
|
|
608
|
+
glow(ctx, colors.c, 8);
|
|
609
|
+
dot(ctx, w + 8, -8, 3, colors.c);
|
|
610
|
+
ctx.shadowBlur = 0;
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
/* ───────────────────────── Solar Sail ───────────────────────── */
|
|
614
|
+
export const paintOrbit = (ctx, p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
615
|
+
const s = paintState(settings), tier = tierOf(w, h), seed = s.seed + index * 17, at = choose(index, s.seed, 1, 4), [cx, cy] = cornersOf(w, h)[at];
|
|
616
|
+
outline(ctx, p, colors.b, 1, 0.45, 3);
|
|
617
|
+
const stars = (count) => {
|
|
618
|
+
for (let i = 0; i < count; i++) {
|
|
619
|
+
const q = p.at(rand(seed + i) * p.total), o = 5 + rand(seed + i + 3) * 20, tw = playing ? 0.5 + 0.5 * Math.sin(clock * 2 + i) : 1;
|
|
620
|
+
ctx.globalAlpha = 0.35 + tw * 0.6;
|
|
621
|
+
dot(ctx, q.x + Math.cos(q.t - Math.PI / 2) * o, q.y + Math.sin(q.t - Math.PI / 2) * o, 0.6 + rand(seed + i + 5) * 0.9, colors.c);
|
|
622
|
+
}
|
|
623
|
+
ctx.globalAlpha = 1;
|
|
624
|
+
};
|
|
625
|
+
if (tier === "small")
|
|
626
|
+
return stars(Math.round((shown / 100) * 6));
|
|
627
|
+
const rings = tier === "hero" && s.style !== "Single orbit"
|
|
628
|
+
? 2 + Math.round((s.drape / 100) * 2)
|
|
629
|
+
: 1, sun = tier === "hero" || s.style === "Gold foil";
|
|
630
|
+
if (sun) {
|
|
631
|
+
const r = 10 + s.scale * 0.35, g = ctx.createLinearGradient(cx - r, cy - r, cx + r, cy + r);
|
|
632
|
+
g.addColorStop(0, colors.b);
|
|
633
|
+
g.addColorStop(0.45, colors.a);
|
|
634
|
+
g.addColorStop(0.6, "#fff6d8");
|
|
635
|
+
g.addColorStop(1, colors.a);
|
|
636
|
+
ctx.fillStyle = g;
|
|
637
|
+
ctx.beginPath();
|
|
638
|
+
ctx.arc(cx, cy, r, 0, tau);
|
|
639
|
+
ctx.fill();
|
|
640
|
+
}
|
|
641
|
+
ctx.strokeStyle = colors.a;
|
|
642
|
+
ctx.lineWidth = 1;
|
|
643
|
+
for (let k = 0; k < rings; k++) {
|
|
644
|
+
// Rings stop short of the content box, so they read as orbits, not clipped arcs.
|
|
645
|
+
const r = Math.min(22 + k * (10 + s.scale * 0.3), room(cx, cy, w, h, limits) - 2);
|
|
646
|
+
if (r < 8)
|
|
647
|
+
break;
|
|
648
|
+
ctx.globalAlpha = 0.85 - k * 0.15;
|
|
649
|
+
ctx.beginPath();
|
|
650
|
+
ctx.arc(cx, cy, r, 0, tau);
|
|
651
|
+
ctx.stroke();
|
|
652
|
+
if (k === rings - 1) {
|
|
653
|
+
const a = Math.PI * 0.75 +
|
|
654
|
+
(playing ? clock * (s.motion / 300) : 0) +
|
|
655
|
+
rand(seed) * tau;
|
|
656
|
+
dot(ctx, cx + Math.cos(a) * r, cy + Math.sin(a) * r, 2.4, colors.d);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
ctx.globalAlpha = 1;
|
|
660
|
+
if (tier === "hero" || s.style === "Star field")
|
|
661
|
+
stars(Math.round((shown / 100) * 22));
|
|
662
|
+
});
|
|
663
|
+
/* ───────────────────────── Diwali Lights ───────────────────────── */
|
|
664
|
+
function rangoli(ctx, x, y, r, colors, turn) {
|
|
665
|
+
const ring = (n, radius, len, wide, color, offset) => {
|
|
666
|
+
ctx.fillStyle = color;
|
|
667
|
+
for (let i = 0; i < n; i++) {
|
|
668
|
+
const a = turn + offset + (i * tau) / n;
|
|
669
|
+
ctx.save();
|
|
670
|
+
ctx.translate(x + Math.cos(a) * radius, y + Math.sin(a) * radius);
|
|
671
|
+
ctx.rotate(a + Math.PI / 2);
|
|
672
|
+
ctx.beginPath();
|
|
673
|
+
ctx.ellipse(0, 0, wide, len, 0, 0, tau);
|
|
674
|
+
ctx.fill();
|
|
675
|
+
ctx.restore();
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
ring(12, r * 0.78, r * 0.26, r * 0.1, colors.d, Math.PI / 12);
|
|
679
|
+
ring(8, r * 0.55, r * 0.3, r * 0.16, colors.a, 0);
|
|
680
|
+
ring(8, r * 0.3, r * 0.18, r * 0.1, colors.b, Math.PI / 8);
|
|
681
|
+
dot(ctx, x, y, r * 0.14, colors.c);
|
|
682
|
+
}
|
|
683
|
+
function diya(ctx, x, y, size, colors, flicker, dark) {
|
|
684
|
+
// Clay bowl.
|
|
685
|
+
ctx.fillStyle = "#b5562b";
|
|
686
|
+
ctx.beginPath();
|
|
687
|
+
ctx.ellipse(x, y, size, size * 0.45, 0, 0, Math.PI);
|
|
688
|
+
ctx.fill();
|
|
689
|
+
ctx.fillStyle = "#d9773f";
|
|
690
|
+
ctx.fillRect(x - size, y - 1, size * 2, 2);
|
|
691
|
+
// Flame.
|
|
692
|
+
const fh = size * (1.1 + flicker * 0.25);
|
|
693
|
+
const g = ctx.createRadialGradient(x, y - fh * 0.35, 0.5, x, y - fh * 0.35, fh * 0.7);
|
|
694
|
+
g.addColorStop(0, "#fffbe6");
|
|
695
|
+
g.addColorStop(0.4, colors.c);
|
|
696
|
+
g.addColorStop(1, colors.b);
|
|
697
|
+
if (dark)
|
|
698
|
+
glow(ctx, colors.b, 10);
|
|
699
|
+
ctx.fillStyle = g;
|
|
700
|
+
ctx.beginPath();
|
|
701
|
+
ctx.moveTo(x, y - fh);
|
|
702
|
+
ctx.quadraticCurveTo(x + size * 0.45, y - fh * 0.35, x, y - 1);
|
|
703
|
+
ctx.quadraticCurveTo(x - size * 0.45, y - fh * 0.35, x, y - fh);
|
|
704
|
+
ctx.fill();
|
|
705
|
+
ctx.shadowBlur = 0;
|
|
706
|
+
}
|
|
707
|
+
export const paintRangoli = (ctx, p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
708
|
+
const s = paintState(settings), tier = tierOf(w, h), dark = isDark(colors.paper), r = 12 + s.scale * 0.55, turn = playing ? clock * (s.motion / 2000) : 0, at = choose(index, s.seed, 1, 4), corners = cornersOf(w, h), flicker = (i) => playing && s.motion > 0 ? Math.sin(clock * 8 + i * 1.7) : 0, dsize = 5.5 + s.scale * 0.12,
|
|
709
|
+
// Lamps stand on the card's own floor, inside its bottom padding.
|
|
710
|
+
floor = h - Math.max(6, limits.bottom * 0.45);
|
|
711
|
+
// Everything lives inside this card's rounded rectangle, decorating its
|
|
712
|
+
// padding like a doorway, so neighbouring cards can never collide.
|
|
713
|
+
trace(ctx, p, 0);
|
|
714
|
+
ctx.closePath();
|
|
715
|
+
ctx.clip();
|
|
716
|
+
const corner = (k, size) => {
|
|
717
|
+
// Centre the motif just inside the rounded corner so its heart stays visible.
|
|
718
|
+
const [x, y, sx, sy] = corners[k], cx = x + sx * 7, cy = y + sy * 7;
|
|
719
|
+
rangoli(ctx, cx, cy, Math.min(size, room(cx, cy, w, h, limits) * 1.3), colors, turn);
|
|
720
|
+
};
|
|
721
|
+
const toran = () => {
|
|
722
|
+
// A marigold toran hung from the top edge: swags with mango leaves between them.
|
|
723
|
+
const swags = Math.max(2, Math.round(w / 120)), span = (w - 24) / swags, sag = Math.max(3, Math.min(8, limits.top / 2 - 6));
|
|
724
|
+
for (let k = 0; k < swags; k++) {
|
|
725
|
+
for (let t = 0; t <= 1.001; t += 0.1) {
|
|
726
|
+
const x = 12 + k * span + t * span, y = 3 + Math.sin(t * Math.PI) * sag;
|
|
727
|
+
dot(ctx, x, y, 2.4, Math.round(t * 10) % 2 ? colors.b : colors.c);
|
|
728
|
+
}
|
|
729
|
+
if (k < swags - 1) {
|
|
730
|
+
ctx.fillStyle = colors.d;
|
|
731
|
+
ctx.beginPath();
|
|
732
|
+
ctx.ellipse(12 + (k + 1) * span, 9, 2.2, 5.5, 0, 0, tau);
|
|
733
|
+
ctx.fill();
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
if (tier === "hero") {
|
|
738
|
+
if (s.style !== "Single diya")
|
|
739
|
+
toran();
|
|
740
|
+
// One rangoli anchors the composition on a bottom corner, lamps along the floor.
|
|
741
|
+
const k = at % 2 ? 2 : 3, x = corners[k][0];
|
|
742
|
+
if (s.style !== "Single diya")
|
|
743
|
+
corner(k, r * 1.6);
|
|
744
|
+
if (s.style !== "Marigold string") {
|
|
745
|
+
const step = 34 + (100 - shown) * 0.5;
|
|
746
|
+
for (let x2 = limits.inline, i = 0; x2 < w - limits.inline / 2; x2 += step, i++) {
|
|
747
|
+
if (Math.abs(x2 - x) < r * 1.8)
|
|
748
|
+
continue;
|
|
749
|
+
diya(ctx, x2, floor, dsize, colors, flicker(i), dark);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
else if (tier === "card") {
|
|
754
|
+
// A corner rangoli on this card's own corner; one lamp on the opposite side.
|
|
755
|
+
if (s.style !== "Single diya")
|
|
756
|
+
corner(at, r * 1.25);
|
|
757
|
+
const right = corners[at][0] === 0;
|
|
758
|
+
diya(ctx, right ? w - limits.inline / 2 - 4 : limits.inline / 2 + 4, floor, dsize, colors, flicker(index), dark);
|
|
759
|
+
}
|
|
760
|
+
else if (s.style === "Single diya") {
|
|
761
|
+
const right = at % 2 === 1;
|
|
762
|
+
diya(ctx, right ? w - limits.inline / 2 - 4 : limits.inline / 2 + 4, floor, dsize, colors, flicker(index), dark);
|
|
763
|
+
}
|
|
764
|
+
else
|
|
765
|
+
corner(at, r);
|
|
766
|
+
});
|
|
767
|
+
/* ───────────────────────── Lunar New Year ───────────────────────── */
|
|
768
|
+
function lantern(ctx, x, y, size, colors, sway) {
|
|
769
|
+
ctx.save();
|
|
770
|
+
ctx.translate(x, -22);
|
|
771
|
+
ctx.rotate(sway);
|
|
772
|
+
const cy = y + 22;
|
|
773
|
+
ctx.strokeStyle = colors.b;
|
|
774
|
+
ctx.lineWidth = 1;
|
|
775
|
+
ctx.beginPath();
|
|
776
|
+
ctx.moveTo(0, 0);
|
|
777
|
+
ctx.lineTo(0, cy - size * 1.1);
|
|
778
|
+
ctx.stroke();
|
|
779
|
+
ctx.fillStyle = colors.a;
|
|
780
|
+
ctx.beginPath();
|
|
781
|
+
ctx.ellipse(0, cy, size * 0.9, size, 0, 0, tau);
|
|
782
|
+
ctx.fill();
|
|
783
|
+
// Ribs and gold caps.
|
|
784
|
+
ctx.strokeStyle = "rgba(0,0,0,.18)";
|
|
785
|
+
for (const k of [-0.45, 0, 0.45]) {
|
|
786
|
+
ctx.beginPath();
|
|
787
|
+
ctx.ellipse(0, cy, size * 0.9 * Math.abs(k) + 0.1, size, 0, 0, tau);
|
|
788
|
+
ctx.stroke();
|
|
789
|
+
}
|
|
790
|
+
ctx.fillStyle = colors.b;
|
|
791
|
+
ctx.fillRect(-size * 0.45, cy - size - 2, size * 0.9, 3);
|
|
792
|
+
ctx.fillRect(-size * 0.45, cy + size - 1, size * 0.9, 3);
|
|
793
|
+
// Tassel.
|
|
794
|
+
ctx.strokeStyle = colors.b;
|
|
795
|
+
ctx.beginPath();
|
|
796
|
+
ctx.moveTo(0, cy + size + 2);
|
|
797
|
+
ctx.lineTo(0, cy + size + 8);
|
|
798
|
+
ctx.stroke();
|
|
799
|
+
ctx.restore();
|
|
800
|
+
}
|
|
801
|
+
export const paintPapercut = (ctx, _p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
802
|
+
const s = paintState(settings), tier = tierOf(w, h), seed = s.seed + index * 23, at = choose(index, s.seed, 1, 4), corners = cornersOf(w, h), size = 8 + s.scale * 0.15, sway = (k) => playing && s.motion > 0
|
|
803
|
+
? Math.sin(clock * 1.6 + k + rand(seed)) * 0.05 * (s.motion / 50)
|
|
804
|
+
: 0;
|
|
805
|
+
// A paper-cut fan opening outward from a corner: scalloped, with punched holes.
|
|
806
|
+
const fan = (k, R) => {
|
|
807
|
+
const [x, y, sx, sy] = corners[k], mid = Math.atan2(-sy, -sx), n = 8;
|
|
808
|
+
ctx.save();
|
|
809
|
+
ctx.translate(x + sx * 2, y + sy * 2);
|
|
810
|
+
ctx.fillStyle = colors.a;
|
|
811
|
+
ctx.beginPath();
|
|
812
|
+
ctx.moveTo(0, 0);
|
|
813
|
+
for (let i = 0; i <= n; i++) {
|
|
814
|
+
const a = mid - Math.PI / 4 + (i / n) * (Math.PI / 2), rr = i % 2 ? R * 0.84 : R;
|
|
815
|
+
ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr);
|
|
816
|
+
}
|
|
817
|
+
ctx.closePath();
|
|
818
|
+
ctx.fill();
|
|
819
|
+
ctx.globalCompositeOperation = "destination-out";
|
|
820
|
+
for (let i = 1; i < 4; i++) {
|
|
821
|
+
const a = mid - Math.PI / 4 + (i / 4) * (Math.PI / 2);
|
|
822
|
+
dot(ctx, Math.cos(a) * R * 0.6, Math.sin(a) * R * 0.6, R * 0.09, "#000");
|
|
823
|
+
}
|
|
824
|
+
dot(ctx, Math.cos(mid) * R * 0.3, Math.sin(mid) * R * 0.3, R * 0.07, "#000");
|
|
825
|
+
ctx.restore();
|
|
826
|
+
};
|
|
827
|
+
if (tier === "hero") {
|
|
828
|
+
if (s.style !== "Single lantern") {
|
|
829
|
+
const R = 3 + s.scale * 0.1, step = R * 2.4;
|
|
830
|
+
ctx.fillStyle = colors.a;
|
|
831
|
+
for (let x = R; x < w - R / 2; x += step) {
|
|
832
|
+
ctx.beginPath();
|
|
833
|
+
ctx.arc(x, -2, R, Math.PI, tau);
|
|
834
|
+
ctx.fill();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const right = at % 2 === 1, count = s.style === "Single lantern" ? 1 : 1 + Math.round((shown / 100) * 2);
|
|
838
|
+
for (let k = 0; k < count; k++) {
|
|
839
|
+
const x = right ? w - 22 - k * 30 : 22 + k * 30;
|
|
840
|
+
lantern(ctx, x, -size + 4 + (k % 2) * 4, size * (k ? 0.82 : 1), colors, sway(k));
|
|
841
|
+
}
|
|
842
|
+
fan(right ? 3 : 2, 18 + s.scale * 0.3);
|
|
843
|
+
}
|
|
844
|
+
else if (tier === "card") {
|
|
845
|
+
const top = at < 2 ? at : at - 2;
|
|
846
|
+
lantern(ctx, top ? w - 22 : 22, -size + 4, size * 0.9, colors, sway(index));
|
|
847
|
+
fan(top ? 3 : 2, 14 + s.scale * 0.25);
|
|
848
|
+
}
|
|
849
|
+
else
|
|
850
|
+
fan(at, 12 + s.scale * 0.2);
|
|
851
|
+
});
|
|
852
|
+
/* ───────────────────────── Papel Picado ───────────────────────── */
|
|
853
|
+
export const paintPicado = (ctx, _p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
854
|
+
const s = paintState(settings), tier = tierOf(w, h), seed = s.seed + index * 29, inks = [colors.a, colors.b, colors.c, colors.d], fw = 8 + s.scale * 0.2, fh = fw * 1.15, ink0 = choose(index, s.seed, 2, 4), right = choose(index, s.seed, 1, 2) === 1;
|
|
855
|
+
const flag = (x, y, i) => {
|
|
856
|
+
const flutter = playing && s.motion > 0
|
|
857
|
+
? Math.sin(clock * 3 + i) * 0.06 * (s.motion / 50)
|
|
858
|
+
: 0;
|
|
859
|
+
ctx.save();
|
|
860
|
+
ctx.translate(x, y);
|
|
861
|
+
ctx.transform(1, 0, flutter, 1, 0, 0);
|
|
862
|
+
ctx.beginPath();
|
|
863
|
+
ctx.moveTo(-fw / 2, 0);
|
|
864
|
+
ctx.lineTo(fw / 2, 0);
|
|
865
|
+
ctx.lineTo(fw / 2, fh * 0.8);
|
|
866
|
+
for (let k = 4; k >= 0; k--)
|
|
867
|
+
ctx.lineTo(-fw / 2 + (k * fw) / 4, fh * (k % 2 ? 1 : 0.8));
|
|
868
|
+
ctx.closePath();
|
|
869
|
+
ctx.fillStyle = inks[(i + ink0) % 4];
|
|
870
|
+
ctx.fill();
|
|
871
|
+
ctx.globalCompositeOperation = "destination-out";
|
|
872
|
+
dot(ctx, 0, fh * 0.42, fw * 0.16, "#000");
|
|
873
|
+
for (const [dx, dy] of [
|
|
874
|
+
[-0.28, 0.2],
|
|
875
|
+
[0.28, 0.2],
|
|
876
|
+
[-0.28, 0.64],
|
|
877
|
+
[0.28, 0.64],
|
|
878
|
+
])
|
|
879
|
+
dot(ctx, dx * fw, dy * fh, fw * 0.06, "#000");
|
|
880
|
+
ctx.restore();
|
|
881
|
+
};
|
|
882
|
+
// A string between two points with a natural sag, flags hung along it.
|
|
883
|
+
const string = (x0, x1, top, sag, count) => {
|
|
884
|
+
ctx.strokeStyle = colors.d;
|
|
885
|
+
ctx.lineWidth = 0.8;
|
|
886
|
+
ctx.beginPath();
|
|
887
|
+
ctx.moveTo(x0, top);
|
|
888
|
+
ctx.quadraticCurveTo((x0 + x1) / 2, top + sag * 2, x1, top);
|
|
889
|
+
ctx.stroke();
|
|
890
|
+
for (let i = 0; i < count; i++) {
|
|
891
|
+
const t = (i + 0.5) / count, x = x0 + (x1 - x0) * t, y = top + sag * 4 * t * (1 - t);
|
|
892
|
+
flag(x, y, i);
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
const sag = 4 + s.drape * 0.06;
|
|
896
|
+
if (tier === "hero") {
|
|
897
|
+
const step = fw + 4 + (100 - shown) * 0.12;
|
|
898
|
+
string(-8, w + 8, -19, sag, Math.floor((w + 16) / step));
|
|
899
|
+
if (s.style === "Paper confetti" || s.style === "Bunting") {
|
|
900
|
+
const count = Math.round((shown / 100) * 10);
|
|
901
|
+
for (let i = 0; i < count; i++) {
|
|
902
|
+
const x = rand(seed + i) * w, fall = playing
|
|
903
|
+
? (clock * s.motion * 0.15 + rand(seed + i + 7) * 20) % 20
|
|
904
|
+
: rand(seed + i + 7) * 20;
|
|
905
|
+
ctx.save();
|
|
906
|
+
ctx.translate(x, h + 6 + fall * 0.6);
|
|
907
|
+
ctx.rotate(rand(seed + i + 3) * tau + (playing ? clock : 0));
|
|
908
|
+
ctx.fillStyle = inks[i % 4];
|
|
909
|
+
ctx.fillRect(-2.5, -1.5, 5, 3);
|
|
910
|
+
ctx.restore();
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
else if (tier === "card") {
|
|
915
|
+
// A short string tied off at one corner, drooping toward the middle.
|
|
916
|
+
const span = w * 0.55, x0 = right ? w + 6 : -6, x1 = right ? w - span : span;
|
|
917
|
+
string(Math.min(x0, x1), Math.max(x0, x1), -19, sag * 0.8, 4);
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
// Two flags pinned to one corner.
|
|
921
|
+
const x = right ? w - 14 : 14;
|
|
922
|
+
flag(x - fw * 0.6, -16, 0);
|
|
923
|
+
flag(x + fw * 0.6, -15, 1);
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
/* ───────────────────────── Holi Colours ───────────────────────── */
|
|
927
|
+
export const paintGulal = (ctx, _p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
928
|
+
const s = paintState(settings), tier = tierOf(w, h), seed = s.seed + index * 41, inks = [colors.a, colors.b, colors.c, colors.d], drift = playing ? clock * (s.motion / 200) : 0, haze = s.style === "Soft haze", corners = cornersOf(w, h), at = choose(index, s.seed, 1, 4), ink = choose(index, s.seed, 2, 4);
|
|
929
|
+
// A powder cloud pushed outward from a corner. Its radius stops before the
|
|
930
|
+
// content box, so the soft edge is never sliced by the frame clip.
|
|
931
|
+
const cloud = (k, color, scale) => {
|
|
932
|
+
const [x, y, sx, sy] = corners[k], base = (18 + s.scale * 0.9) * scale, ox = Math.sin(drift + k) * 2, oy = Math.cos(drift * 0.8 + k) * 2;
|
|
933
|
+
for (let j = 0; j < 5; j++) {
|
|
934
|
+
const jx = x -
|
|
935
|
+
sx * (6 + rand(seed + k * 7 + j) * 10) +
|
|
936
|
+
ox +
|
|
937
|
+
(rand(seed + k + j * 3) - 0.5) * base * 0.6, jy = y - sy * (6 + rand(seed + k * 7 + j + 3) * 10) + oy, r = Math.min(base * (0.6 + rand(seed + j) * 0.4), room(jx, jy, w, h, limits) - 1);
|
|
938
|
+
if (r < 4)
|
|
939
|
+
continue;
|
|
940
|
+
const g = ctx.createRadialGradient(jx, jy, 0, jx, jy, r);
|
|
941
|
+
g.addColorStop(0, color);
|
|
942
|
+
g.addColorStop(1, "transparent");
|
|
943
|
+
ctx.globalAlpha = haze ? 0.28 : 0.5;
|
|
944
|
+
ctx.fillStyle = g;
|
|
945
|
+
ctx.beginPath();
|
|
946
|
+
ctx.arc(jx, jy, r, 0, tau);
|
|
947
|
+
ctx.fill();
|
|
948
|
+
}
|
|
949
|
+
ctx.globalAlpha = 1;
|
|
950
|
+
};
|
|
951
|
+
// Dry powder thrown along an edge, thinning away from its corner.
|
|
952
|
+
const speckle = (k, color, count) => {
|
|
953
|
+
const [x, y, sx, sy] = corners[k];
|
|
954
|
+
for (let i = 0; i < count; i++) {
|
|
955
|
+
const along = rand(seed + i + k * 13) ** 1.6 * Math.min(w, h) * 0.6, off = 3 + rand(seed + i + 1) * 18, edge = i % 2, px = edge ? x + sx * along : x - sx * off, py = edge ? y - sy * off : y + sy * along;
|
|
956
|
+
if (room(px, py, w, h, limits) < 2)
|
|
957
|
+
continue;
|
|
958
|
+
ctx.globalAlpha = 0.5 + rand(seed + i + 2) * 0.45;
|
|
959
|
+
dot(ctx, px, py, 0.6 + rand(seed + i + 3) * (s.style === "Splashes" ? 2.2 : 1.3), color);
|
|
960
|
+
}
|
|
961
|
+
ctx.globalAlpha = 1;
|
|
962
|
+
};
|
|
963
|
+
const count = Math.round((shown / 100) * 40);
|
|
964
|
+
if (tier === "hero") {
|
|
965
|
+
if (s.style !== "Speckle dust") {
|
|
966
|
+
cloud(at, inks[ink], 1.1);
|
|
967
|
+
cloud((at + 2) % 4, inks[(ink + 2) % 4], 0.9);
|
|
968
|
+
}
|
|
969
|
+
if (!haze) {
|
|
970
|
+
speckle(at, inks[ink], count);
|
|
971
|
+
speckle((at + 2) % 4, inks[(ink + 2) % 4], count);
|
|
972
|
+
speckle((at + 1) % 4, inks[(ink + 1) % 4], count / 3);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
else if (tier === "card") {
|
|
976
|
+
if (s.style !== "Speckle dust")
|
|
977
|
+
cloud(at, inks[ink], 0.85);
|
|
978
|
+
if (!haze)
|
|
979
|
+
speckle(at, inks[ink], count * 0.7);
|
|
980
|
+
}
|
|
981
|
+
else {
|
|
982
|
+
if (s.style !== "Speckle dust")
|
|
983
|
+
cloud(at, inks[ink], 0.6);
|
|
984
|
+
if (!haze)
|
|
985
|
+
speckle(at, inks[ink], count * 0.4);
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
/* ───────────────────────── Yule Knit ───────────────────────── */
|
|
989
|
+
function snowflake(ctx, x, y, r, color, turn) {
|
|
990
|
+
ctx.save();
|
|
991
|
+
ctx.translate(x, y);
|
|
992
|
+
ctx.rotate(turn);
|
|
993
|
+
ctx.strokeStyle = color;
|
|
994
|
+
ctx.lineWidth = 1;
|
|
995
|
+
for (let i = 0; i < 6; i++) {
|
|
996
|
+
ctx.rotate(tau / 6);
|
|
997
|
+
ctx.beginPath();
|
|
998
|
+
ctx.moveTo(0, 0);
|
|
999
|
+
ctx.lineTo(0, -r);
|
|
1000
|
+
ctx.moveTo(0, -r * 0.55);
|
|
1001
|
+
ctx.lineTo(r * 0.25, -r * 0.8);
|
|
1002
|
+
ctx.moveTo(0, -r * 0.55);
|
|
1003
|
+
ctx.lineTo(-r * 0.25, -r * 0.8);
|
|
1004
|
+
ctx.stroke();
|
|
1005
|
+
}
|
|
1006
|
+
ctx.restore();
|
|
1007
|
+
}
|
|
1008
|
+
export const paintKnit = (ctx, _p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
1009
|
+
const s = paintState(settings), tier = tierOf(w, h), seed = s.seed + index * 61, cell = 5 + s.scale * 0.08, at = choose(index, s.seed, 1, 4);
|
|
1010
|
+
const vStitch = (x, y, color) => {
|
|
1011
|
+
ctx.strokeStyle = color;
|
|
1012
|
+
ctx.lineWidth = cell * 0.36;
|
|
1013
|
+
ctx.lineCap = "round";
|
|
1014
|
+
ctx.beginPath();
|
|
1015
|
+
ctx.moveTo(x - cell * 0.28, y - cell * 0.3);
|
|
1016
|
+
ctx.lineTo(x, y + cell * 0.25);
|
|
1017
|
+
ctx.lineTo(x + cell * 0.28, y - cell * 0.3);
|
|
1018
|
+
ctx.stroke();
|
|
1019
|
+
};
|
|
1020
|
+
const flakes = (count, side) => {
|
|
1021
|
+
for (let i = 0; i < count; i++) {
|
|
1022
|
+
const fall = playing && s.motion > 0
|
|
1023
|
+
? (clock * s.motion * 0.12 + rand(seed + i) * 30) % 30
|
|
1024
|
+
: rand(seed + i) * 30, x = side
|
|
1025
|
+
? w + 6 + rand(seed + i + 2) * 16
|
|
1026
|
+
: -6 - rand(seed + i + 2) * 16, y = 8 + rand(seed + i + 4) * Math.min(h * 0.5, 120) + fall;
|
|
1027
|
+
snowflake(ctx, x, y, 3 + rand(seed + i + 5) * 3, colors.d, playing ? clock * 0.3 + i : i);
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
if (tier === "hero") {
|
|
1031
|
+
if (s.style === "Fair Isle band" || s.style === "Snowflakes") {
|
|
1032
|
+
// The yoke: a Fair Isle band across the top of the main panel only.
|
|
1033
|
+
const rows = 3, top = -rows * cell - 4;
|
|
1034
|
+
ctx.fillStyle = colors.a;
|
|
1035
|
+
ctx.fillRect(0, top - cell * 0.5, w, rows * cell + cell * 0.5);
|
|
1036
|
+
for (let r = 0; r < rows; r++)
|
|
1037
|
+
for (let x = cell / 2, i = 0; x < w; x += cell, i++)
|
|
1038
|
+
if (Math.abs((i % 4) - 2) === r || (r === 1 && i % 4 === 0))
|
|
1039
|
+
vStitch(x, top + r * cell + cell / 2, colors.c);
|
|
1040
|
+
ctx.fillStyle = colors.b;
|
|
1041
|
+
ctx.fillRect(0, -4, w, 2.5);
|
|
1042
|
+
ctx.fillRect(0, top - cell * 0.5 - 2.5, w, 2.5);
|
|
1043
|
+
}
|
|
1044
|
+
if (s.style === "Cable stitch") {
|
|
1045
|
+
ctx.strokeStyle = colors.a;
|
|
1046
|
+
ctx.lineWidth = 2.4;
|
|
1047
|
+
for (const phase of [0, Math.PI]) {
|
|
1048
|
+
ctx.beginPath();
|
|
1049
|
+
for (let y = 0; y <= h; y += 2) {
|
|
1050
|
+
const xx = -9 + Math.sin(y / 9 + phase) * 4;
|
|
1051
|
+
if (y)
|
|
1052
|
+
ctx.lineTo(xx, y);
|
|
1053
|
+
else
|
|
1054
|
+
ctx.moveTo(xx, y);
|
|
1055
|
+
}
|
|
1056
|
+
ctx.stroke();
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
flakes(Math.round((shown / 100) * 3), at % 2);
|
|
1060
|
+
}
|
|
1061
|
+
else if (tier === "card") {
|
|
1062
|
+
// A ribbed cuff at the hem, and a patch of snow on one side.
|
|
1063
|
+
ctx.fillStyle = colors.a;
|
|
1064
|
+
const cuff = 7 + s.weight * 0.05;
|
|
1065
|
+
ctx.fillRect(0, h + 3, w, cuff);
|
|
1066
|
+
ctx.strokeStyle = colors.c;
|
|
1067
|
+
ctx.globalAlpha = 0.35;
|
|
1068
|
+
ctx.lineWidth = 1;
|
|
1069
|
+
for (let x = 3; x < w; x += 4) {
|
|
1070
|
+
ctx.beginPath();
|
|
1071
|
+
ctx.moveTo(x, h + 4);
|
|
1072
|
+
ctx.lineTo(x, h + 2 + cuff);
|
|
1073
|
+
ctx.stroke();
|
|
1074
|
+
}
|
|
1075
|
+
ctx.globalAlpha = 1;
|
|
1076
|
+
flakes(2, at % 2);
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
1079
|
+
// A tiny knitted label: three stitches and a flake.
|
|
1080
|
+
const x0 = at % 2 ? w - 4 - cell * 3 : 4;
|
|
1081
|
+
for (let i = 0; i < 3; i++)
|
|
1082
|
+
vStitch(x0 + cell / 2 + i * cell, -8, i === 1 ? colors.b : colors.a);
|
|
1083
|
+
snowflake(ctx, at % 2 ? w + 10 : -10, 14, 4, colors.d, playing ? clock * 0.3 : 0);
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
/* ───────────────────────── Glass UI ───────────────────────── */
|
|
1087
|
+
export const paintLiquid = (ctx, p, w, h, settings, colors, clock, playing, _index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
|
|
1088
|
+
const s = paintState(settings), dark = isDark(colors.paper),
|
|
1089
|
+
// The light source drifts slowly while animated, like tilting the device.
|
|
1090
|
+
angle = -0.9 +
|
|
1091
|
+
(playing && s.motion > 0
|
|
1092
|
+
? Math.sin(clock * (s.motion / 120)) * 0.6
|
|
1093
|
+
: 0), lx = w / 2 + Math.cos(angle) * w, ly = h / 2 + Math.sin(angle) * h;
|
|
1094
|
+
if (s.style === "Clear") {
|
|
1095
|
+
outline(ctx, p, colors.a, 1, dark ? 0.25 : 0.6, 0.5);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
// Specular rim: a gradient stroke that is brightest where it faces the light.
|
|
1099
|
+
const rim = ctx.createLinearGradient(lx, ly, w - lx, h - ly);
|
|
1100
|
+
rim.addColorStop(0, colors.a);
|
|
1101
|
+
rim.addColorStop(0.45, "rgba(255,255,255,0)");
|
|
1102
|
+
rim.addColorStop(0.75, "rgba(255,255,255,0)");
|
|
1103
|
+
rim.addColorStop(1, colors.a);
|
|
1104
|
+
trace(ctx, p, 0.5);
|
|
1105
|
+
ctx.closePath();
|
|
1106
|
+
ctx.strokeStyle = rim;
|
|
1107
|
+
ctx.lineWidth = 1.2 + s.weight * 0.012;
|
|
1108
|
+
ctx.globalAlpha = dark ? 0.7 : 0.95;
|
|
1109
|
+
ctx.stroke();
|
|
1110
|
+
ctx.globalAlpha = 1;
|
|
1111
|
+
if (s.style === "Refraction" || s.style === "Specular rim") {
|
|
1112
|
+
// Refraction: a thin chromatic fringe hugging the inner edge.
|
|
1113
|
+
for (const [color, off] of [
|
|
1114
|
+
[colors.b, -2.2],
|
|
1115
|
+
[colors.c, -3.6],
|
|
1116
|
+
]) {
|
|
1117
|
+
ctx.globalAlpha = 0.18 + (shown / 100) * 0.25;
|
|
1118
|
+
outline(ctx, p, color, 1.2, ctx.globalAlpha, off);
|
|
1119
|
+
}
|
|
1120
|
+
ctx.globalAlpha = 1;
|
|
1121
|
+
}
|
|
1122
|
+
if (s.style === "Light catch" || s.style === "Specular rim") {
|
|
1123
|
+
// A soft caustic just below the top edge, where light enters the glass.
|
|
1124
|
+
const g = ctx.createRadialGradient(lx * 0.4 + w * 0.3, 0, 0, lx * 0.4 + w * 0.3, 0, w * 0.35);
|
|
1125
|
+
g.addColorStop(0, dark ? "rgba(255,255,255,.18)" : "rgba(255,255,255,.55)");
|
|
1126
|
+
g.addColorStop(1, "rgba(255,255,255,0)");
|
|
1127
|
+
ctx.fillStyle = g;
|
|
1128
|
+
ctx.fillRect(0, -6, w, Math.min(18, limits.top));
|
|
1129
|
+
}
|
|
1130
|
+
// A faint drop shadow line below, so the slab reads as lifted.
|
|
1131
|
+
ctx.strokeStyle = colors.d;
|
|
1132
|
+
ctx.globalAlpha = dark ? 0.35 : 0.12;
|
|
1133
|
+
ctx.lineWidth = 1;
|
|
1134
|
+
ctx.beginPath();
|
|
1135
|
+
ctx.moveTo(p.r, h + 3);
|
|
1136
|
+
ctx.lineTo(w - p.r, h + 3);
|
|
1137
|
+
ctx.stroke();
|
|
1138
|
+
ctx.globalAlpha = 1;
|
|
1139
|
+
});
|
|
1140
|
+
export const launchPainters = {
|
|
1141
|
+
crt: paintPhosphor,
|
|
1142
|
+
panes: paintPanes,
|
|
1143
|
+
swiss: paintSwiss,
|
|
1144
|
+
azulejo: paintAzulejo,
|
|
1145
|
+
formwork: paintFormwork,
|
|
1146
|
+
atomic: paintAtomic,
|
|
1147
|
+
hud: paintHud,
|
|
1148
|
+
hazard: paintHazard,
|
|
1149
|
+
orbit: paintOrbit,
|
|
1150
|
+
rangoli: paintRangoli,
|
|
1151
|
+
papercut: paintPapercut,
|
|
1152
|
+
picado: paintPicado,
|
|
1153
|
+
gulal: paintGulal,
|
|
1154
|
+
knit: paintKnit,
|
|
1155
|
+
liquid: paintLiquid,
|
|
1156
|
+
};
|