自部署多端同步:服务端、实时协作、口令登录与油画加载动画
- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器 - 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作 - 空间(标签)有独立地址,增删改通过接口同步到所有设备 - 口令登录(签名 Cookie 会话、登录限流、同源校验) - 图片上传到服务器,过大照片上传前自动压缩 - 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备 - 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush) - 选中样式与拖拽性能优化:拖动结束后才同步位置 - Docker / docker-compose 部署,README 与 CLAUDE.md 更新
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" fill="#f4f3ee"/>
|
||||
<g transform="rotate(-7 256 256)">
|
||||
<circle cx="256" cy="256" r="168" fill="#1d1e1d"/>
|
||||
<g stroke="#f4f3ee" stroke-width="16" stroke-linecap="round">
|
||||
<line x1="176" y1="190" x2="336" y2="190" transform="rotate(33 256 190)"/>
|
||||
<line x1="176" y1="256" x2="336" y2="256" transform="rotate(-30 256 256)"/>
|
||||
<line x1="176" y1="322" x2="336" y2="322" transform="rotate(17 256 322)"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "无界笺 Boundless",
|
||||
"short_name": "无界笺",
|
||||
"description": "一张可以无限延伸、在所有设备间同步的灵感平面。",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f4f3ee",
|
||||
"theme_color": "#f4f3ee",
|
||||
"lang": "zh-CN",
|
||||
"icons": [
|
||||
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/* =====================================================================
|
||||
* engine.js — the reveal loop, shared by every style.
|
||||
*
|
||||
* Lifted from loader.html without changing how anything paints: each
|
||||
* stroke is drawn once into an offscreen buffer, then copied onto the
|
||||
* visible canvas in slices along its direction so it looks drawn.
|
||||
*
|
||||
* A styleConfig is:
|
||||
* {
|
||||
* label: 'Impressionism',
|
||||
* canvasSize: 600,
|
||||
* loopMs: 9000,
|
||||
* holdMs: 1500,
|
||||
* palette: { ground, phrases, ...whatever the style's strokes want },
|
||||
* strokes: (palette) => [ descriptor, ... ],
|
||||
* vocabulary: [ fn, ... ], // for the gallery's Copy code button
|
||||
* }
|
||||
*
|
||||
* A descriptor is { fn, bb, axis, dir, dur, gap } — see D() below.
|
||||
*
|
||||
* Three p5.brush facts this file is built around, all verified in 2.2.2:
|
||||
* - scaleBrushes() multiplies the shared brush registry in place, so it
|
||||
* must run exactly once per page. See ensureBrushes().
|
||||
* - brush.load(target) is a global retarget, so it is re-issued whenever
|
||||
* a config is (re)loaded rather than only once in setup.
|
||||
* - brush.wash is deferred until the next drawing call. See flushWash().
|
||||
* ===================================================================== */
|
||||
|
||||
/* Canvas dimensions live at module scope so the style vocabularies can go on
|
||||
referencing bare W / H / m exactly as they did inside loader.html. The
|
||||
engine sets them from styleConfig.canvasSize before anything paints. */
|
||||
let W = 600, H = 600, m = 600;
|
||||
|
||||
/* ---- seeded helpers (all randomness goes through p5's seeded random) ---- */
|
||||
const rr = (a, b) => random(a, b);
|
||||
const pickOne = (arr) => arr[Math.floor(random(arr.length))];
|
||||
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
||||
const clampN = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
|
||||
/* ---- colour maths ---- */
|
||||
function hexRgb(h) { h = h.replace('#', ''); if (h.length === 3) h = h.split('').map((c) => c + c).join(''); return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); }
|
||||
function mixHex(a, b, t) { const A = hexRgb(a), B = hexRgb(b); return '#' + A.map((v, i) => Math.round(v + (B[i] - v) * t).toString(16).padStart(2, '0')).join(''); }
|
||||
function hexHsl(hex) {
|
||||
const [r, g, b] = hexRgb(hex).map((v) => v / 255);
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2;
|
||||
let h = 0, s = 0;
|
||||
if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); if (max === r) h = (g - b) / d + (g < b ? 6 : 0); else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; h /= 6; }
|
||||
return [h, s, l];
|
||||
}
|
||||
function hslHex(h, s, l) {
|
||||
const f = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; };
|
||||
let r, g, b;
|
||||
if (s === 0) { r = g = b = l; } else { const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q; r = f(p, q, h + 1 / 3); g = f(p, q, h); b = f(p, q, h - 1 / 3); }
|
||||
return '#' + [r, g, b].map((v) => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
function vivid(hex, amt) { if (!amt) return hex; const [h, s, l] = hexHsl(hex); return hslHex(h, s + (1 - s) * amt, l - (l - 0.45) * amt * 0.5); }
|
||||
|
||||
/* ---- geometry ---- */
|
||||
function jitterEllipse(cx, cy, rx, ry, n, jitter, rot = 0) {
|
||||
const pts = [], a0 = rr(0, TWO_PI), cr = Math.cos(rot), sr = Math.sin(rot);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = a0 + (i / n) * TWO_PI, k = 1 + rr(-jitter, jitter);
|
||||
const x = Math.cos(a) * rx * k, y = Math.sin(a) * ry * k;
|
||||
pts.push([cx + x * cr - y * sr, cy + x * sr + y * cr]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
function rosette(cx, cy, r, lobes, inner, rot = 0, jit = 0.05) {
|
||||
const n = Math.max(24, lobes * 6), pts = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = (i / n) * TWO_PI, k = inner + (1 - inner) * (0.5 + 0.5 * Math.cos(lobes * a));
|
||||
const rad = r * k * (1 + rr(-jit, jit));
|
||||
pts.push([cx + Math.cos(a + rot) * rad, cy + Math.sin(a + rot) * rad]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
function bandPolygon(y0, y1, wobble, n = 9) {
|
||||
const top = [], bottom = [];
|
||||
for (let i = 0; i <= n; i++) { const x = -10 + (W + 20) * (i / n); top.push([x, y0 + rr(-wobble, wobble)]); bottom.push([x, y1 + rr(-wobble, wobble)]); }
|
||||
return top.concat(bottom.reverse());
|
||||
}
|
||||
const bboxOf = (pts, pad) => {
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const [x, y] of pts) { if (x < x0) x0 = x; if (y < y0) y0 = y; if (x > x1) x1 = x; if (y > y1) y1 = y; }
|
||||
return [Math.max(0, Math.floor(x0 - pad)), Math.max(0, Math.floor(y0 - pad)), Math.min(W, Math.ceil(x1 + pad)), Math.min(H, Math.ceil(y1 + pad))];
|
||||
};
|
||||
|
||||
/* ---- style switches (fill, wash and stroke are independent in p5.brush,
|
||||
so each one turns the others off before drawing) ---- */
|
||||
function fillStyle(color, opacity, bleed, texture, border) { brush.noStroke(); brush.noHatch(); brush.noWash(); brush.fill(color, opacity); brush.fillBleed(bleed, 'out'); brush.fillTexture(texture, border); }
|
||||
function washStyle(color, opacity) { brush.noStroke(); brush.noHatch(); brush.noFill(); brush.wash(color, opacity); }
|
||||
function strokeStyle(name, color, weight) { brush.noFill(); brush.noWash(); brush.noHatch(); brush.set(name, color, weight); }
|
||||
|
||||
/* ---- descriptor constructor: each stroke is revealed along its axis over
|
||||
`dur` ms, like a brush travelling across the paper ---- */
|
||||
const D = (fn, bb, axis, dir, dur, gap = 60) => ({ fn, bb, axis, dir, dur, gap });
|
||||
const rdir = () => (random() < 0.5 ? 1 : -1);
|
||||
const raxis = () => (random() < 0.5 ? 'x' : 'y');
|
||||
|
||||
/* p5.brush queues flat washes and only renders them on the next drawing call
|
||||
(bleed fills are immediate). A tiny fully transparent fill off-canvas forces
|
||||
that flush so the buffer is complete before it is copied. */
|
||||
function flushWash() {
|
||||
brush.noStroke(); brush.noHatch(); brush.noWash();
|
||||
brush.fill('#000000', 0); brush.fillBleed(0); brush.fillTexture(0, 0);
|
||||
brush.polygon([[-8, -8], [-6, -8], [-6, -6]]);
|
||||
}
|
||||
|
||||
/* scaleBrushes() multiplies the shared brush registry in place, so calling it
|
||||
twice compounds. This runs once per page; a later call at a different scale
|
||||
is refused loudly rather than silently doubling every brush. */
|
||||
let brushScale = null;
|
||||
function ensureBrushes(scale) {
|
||||
if (brushScale !== null) {
|
||||
if (Math.abs(brushScale - scale) > 1e-6) {
|
||||
console.warn(`engine: brushes already scaled to ${brushScale}; ignoring request for ${scale}. ` +
|
||||
'scaleBrushes multiplies in place, so all canvases on a page must share one size.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
brush.add('flick', { weight: 0.7, vibration: 0.12, definition: 0.9, quality: 0.8, opacity: 200, spacing: 0.1, pressure: { curve: [0.25, 0.25], min_max: [1.1, 0.85] } });
|
||||
brush.add('rim', { weight: 0.45, vibration: 0.35, definition: 0.7, quality: 0.8, opacity: 190, spacing: 0.1, pressure: { curve: [0.15, 0.2], min_max: [1.2, 1] } });
|
||||
brush.scaleBrushes(scale);
|
||||
brushScale = scale;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* createEngine(styleConfig, opts)
|
||||
*
|
||||
* opts:
|
||||
* mount element the canvas is appended to (moved, if it already exists)
|
||||
* statusEl element that receives the palette's phrases (optional)
|
||||
* freeze progress 0..1 to freeze at instead of looping (optional)
|
||||
* nextConfig (cycle) => styleConfig, called at each cycle boundary.
|
||||
* Defaults to the config passed in. loader.html uses this to
|
||||
* keep its garden -> dawn -> willow -> wisteria rotation;
|
||||
* the engine itself knows nothing about rotation.
|
||||
* ===================================================================== */
|
||||
function createEngine(styleConfig, opts = {}) {
|
||||
let cfg = styleConfig;
|
||||
let buf, paint, canvasEl;
|
||||
let strokes = [], idx = 0;
|
||||
let startMs = null, currentCycle = -1, phaseIndex = -1, fadeTimer = null;
|
||||
let statusEl = opts.statusEl || null;
|
||||
const freeze = typeof opts.freeze === 'number' ? clamp01(opts.freeze) : null;
|
||||
const nextConfig = opts.nextConfig || (() => cfg);
|
||||
|
||||
function setup() {
|
||||
W = cfg.canvasSize; H = cfg.canvasSize; m = Math.min(W, H);
|
||||
const cnv = createCanvas(W, H, WEBGL);
|
||||
canvasEl = cnv.elt || cnv.canvas || document.querySelector('canvas');
|
||||
if (opts.mount) mount(opts.mount);
|
||||
|
||||
ensureBrushes(3 * m / 600);
|
||||
|
||||
// `paint` holds everything finished so far; `buf` is paint plus the stroke
|
||||
// being drawn. Drawing each stroke over a copy of the painting lets its wet
|
||||
// edges mix with the real colours underneath, and every copy between the
|
||||
// three surfaces is opaque (no fringes).
|
||||
paint = createGraphics(W, H, WEBGL);
|
||||
buf = createGraphics(W, H, WEBGL);
|
||||
paint.imageMode(CORNER); buf.imageMode(CORNER);
|
||||
brush.load(buf);
|
||||
// pre-warm the fill shaders into the buffer, then wipe it
|
||||
buf.push(); buf.translate(-W / 2, -H / 2);
|
||||
fillStyle('#7a86bd', 200, 0.3, 0.5, 0.3); brush.circle(W / 2, H / 2, 12);
|
||||
buf.pop(); buf.clear();
|
||||
imageMode(CORNER);
|
||||
background(cfg.palette.ground);
|
||||
}
|
||||
|
||||
/* Move the one canvas into a new parent. Used by the gallery to hand the
|
||||
live canvas from card to card. */
|
||||
function mount(el) {
|
||||
if (canvasEl && el && canvasEl.parentElement !== el) el.appendChild(canvasEl);
|
||||
}
|
||||
|
||||
/* Point the engine at a different style and start it from the first stroke. */
|
||||
function load(nextCfg) {
|
||||
cfg = nextCfg || cfg;
|
||||
// The canvas and the scaled brushes are both fixed at setup, so every style
|
||||
// sharing a page has to share its size. Say so rather than paint at the
|
||||
// wrong coordinates.
|
||||
if (cfg.canvasSize !== W) {
|
||||
console.warn(`engine: "${cfg.label}" wants canvasSize ${cfg.canvasSize} but the canvas is ${W}. ` +
|
||||
'Painting at ' + W + '. All styles on one page must share a canvasSize.');
|
||||
}
|
||||
brush.load(buf); // brush.load is a global retarget
|
||||
randomSeed(cfg.palette.seed); noiseSeed(cfg.palette.seed);
|
||||
strokes = cfg.strokes(cfg.palette);
|
||||
// give every stroke its own seed, then lay the timeline out end to end
|
||||
// and fit it to loopMs
|
||||
let cursor = 0;
|
||||
for (const s of strokes) { s.seed = Math.floor(random() * 1e9); s.begun = false; s.rev = null; s.start = cursor; cursor += s.dur + s.gap; }
|
||||
const k = cfg.loopMs / cursor;
|
||||
for (const s of strokes) { s.start *= k; s.dur *= k; s.end = s.start + s.dur; }
|
||||
idx = 0; phaseIndex = -1;
|
||||
paint.background(cfg.palette.ground);
|
||||
background(cfg.palette.ground);
|
||||
}
|
||||
|
||||
function beginStroke(s) {
|
||||
buf.clear();
|
||||
buf.push(); buf.translate(-W / 2, -H / 2);
|
||||
buf.image(paint, 0, 0, W, H); // start from the painting so far
|
||||
randomSeed(s.seed); noiseSeed(s.seed);
|
||||
s.fn();
|
||||
flushWash();
|
||||
buf.pop();
|
||||
s.begun = true; s.p = 0;
|
||||
}
|
||||
// a finished stroke becomes part of the painting
|
||||
function foldStroke() {
|
||||
paint.push(); paint.translate(-W / 2, -H / 2);
|
||||
paint.image(buf, 0, 0, W, H);
|
||||
paint.pop();
|
||||
}
|
||||
// draw the revealed part of the stroke in progress: a slice of its box,
|
||||
// growing along its direction
|
||||
function revealSlice(s) {
|
||||
const [x0, y0, x1, y1] = s.bb;
|
||||
if (x1 <= x0 || y1 <= y0 || s.p <= 0) return;
|
||||
if (s.axis === 'x') {
|
||||
const w = Math.round((x1 - x0) * s.p); if (w <= 0) return;
|
||||
const a = s.dir > 0 ? x0 : x1 - w;
|
||||
image(buf, a, y0, w, y1 - y0, a, y0, w, y1 - y0);
|
||||
} else {
|
||||
const h = Math.round((y1 - y0) * s.p); if (h <= 0) return;
|
||||
const a = s.dir > 0 ? y0 : y1 - h;
|
||||
image(buf, x0, a, x1 - x0, h, x0, a, x1 - x0, h);
|
||||
}
|
||||
}
|
||||
function paintUpTo(t) {
|
||||
while (idx < strokes.length && strokes[idx].start <= t) {
|
||||
const s = strokes[idx];
|
||||
if (!s.begun) beginStroke(s);
|
||||
s.p = Math.min(1, (t - s.start) / s.dur);
|
||||
if (s.p >= 1) { foldStroke(); idx++; } else break;
|
||||
}
|
||||
}
|
||||
function drawFrame(t) {
|
||||
paintUpTo(t);
|
||||
image(paint, 0, 0, W, H);
|
||||
const s = strokes[idx];
|
||||
if (s && s.begun) revealSlice(s);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
translate(-W / 2, -H / 2);
|
||||
if (freeze !== null) {
|
||||
if (!strokes.length) { load(nextConfig(0)); drawFrame(freeze * cfg.loopMs); updateStatus(freeze); noLoop(); }
|
||||
return;
|
||||
}
|
||||
if (startMs === null) startMs = millis();
|
||||
const now = millis() - startMs, cycleMs = cfg.loopMs + cfg.holdMs;
|
||||
const cycle = Math.floor(now / cycleMs), elapsed = now % cycleMs;
|
||||
if (cycle !== currentCycle) { currentCycle = cycle; load(nextConfig(cycle)); }
|
||||
drawFrame(Math.min(elapsed, cfg.loopMs));
|
||||
updateStatus(Math.min(elapsed / cfg.loopMs, 1));
|
||||
}
|
||||
|
||||
/* Restart from the first stroke on the next frame. */
|
||||
function restart() { startMs = null; currentCycle = -1; }
|
||||
|
||||
/* Paint up to t on demand, outside the animation loop. drawFrame is already
|
||||
incremental — paintUpTo resumes from where it left off — so the caller can
|
||||
walk t up over several frames to paint a whole picture without blocking.
|
||||
The gallery uses this to render each card's finished still at load. */
|
||||
function renderTo(t) { push(); translate(-W / 2, -H / 2); drawFrame(t); pop(); }
|
||||
|
||||
/* Point at a different style and restart. The strokes are not rebuilt here —
|
||||
the next draw() does that once, via nextConfig. Used by the gallery when
|
||||
the live canvas moves to another card. */
|
||||
function switchTo(nextCfg, nextStatusEl) {
|
||||
cfg = nextCfg || cfg;
|
||||
if (nextStatusEl !== undefined) {
|
||||
if (statusEl && statusEl !== nextStatusEl) { clearTimeout(fadeTimer); statusEl.textContent = ''; }
|
||||
statusEl = nextStatusEl;
|
||||
}
|
||||
restart();
|
||||
}
|
||||
|
||||
function updateStatus(progress) {
|
||||
if (!statusEl) return;
|
||||
const list = cfg.palette.phrases;
|
||||
if (!list || !list.length) return;
|
||||
let i;
|
||||
if (progress >= 1) i = list.length - 1;
|
||||
else { const active = Math.max(list.length - 1, 1); i = Math.min(Math.floor(progress * active), active - 1); }
|
||||
if (i === phaseIndex) return;
|
||||
const fresh = phaseIndex === -1;
|
||||
phaseIndex = i;
|
||||
clearTimeout(fadeTimer);
|
||||
if (fresh) { statusEl.classList.remove('fade'); statusEl.textContent = list[i]; return; }
|
||||
statusEl.classList.add('fade');
|
||||
fadeTimer = setTimeout(() => { statusEl.textContent = list[i]; statusEl.classList.remove('fade'); }, 250);
|
||||
}
|
||||
|
||||
return {
|
||||
setup, draw, load, mount, restart, switchTo, renderTo,
|
||||
get config() { return cfg; },
|
||||
get canvas() { return canvasEl; },
|
||||
};
|
||||
}
|
||||
|
||||
/* Every function above that a copied standalone file needs, in dependency
|
||||
order. The gallery serialises these with Function.prototype.toString().
|
||||
Keep in step when adding a shared helper. */
|
||||
const SHARED_FOR_COPY = [
|
||||
rr, pickOne, clamp01, clampN,
|
||||
hexRgb, mixHex, hexHsl, hslHex, vivid,
|
||||
jitterEllipse, rosette, bandPolygon, bboxOf,
|
||||
fillStyle, washStyle, strokeStyle,
|
||||
D, rdir, raxis,
|
||||
flushWash, ensureBrushes, createEngine,
|
||||
];
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Painting</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
<!-- 加载顺序不能变:先 p5,再 p5.brush(它会注册为 p5 插件),再引擎和画作 -->
|
||||
<script src="vendor/p5.min.js"></script>
|
||||
<script src="vendor/p5.brush.js"></script>
|
||||
<script src="engine.js"></script>
|
||||
<script src="style-impressionism.js"></script>
|
||||
<script src="style-abstraction.js"></script>
|
||||
<script src="loader.js"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
/* =====================================================================
|
||||
* loader.js — 无界笺的加载动画外壳。
|
||||
*
|
||||
* 绘画引擎与 12 幅画作来自 Painting Loaders
|
||||
* (https://painterly.design-tools.workers.dev,作者 Sasha / x.com/aleksksaa),
|
||||
* engine.js 与 style-*.js 原样保留。这里只负责:随机挑一幅(不与上次重复),
|
||||
* 把画布铺满本页,并通知父页面已经开始作画。
|
||||
*
|
||||
* 本页通过 iframe 嵌入,p5 的全局模式不会污染主应用。
|
||||
* ===================================================================== */
|
||||
|
||||
const PAINTINGS = [
|
||||
{ key: 'garden', style: 'impressionismGarden', name: '花园', label: 'Garden' },
|
||||
{ key: 'dawn', style: 'impressionismDawn', name: '晨曦', label: 'Dawn' },
|
||||
{ key: 'willow', style: 'impressionismWillow', name: '垂柳', label: 'Willow' },
|
||||
{ key: 'wisteria', style: 'impressionismWisteria', name: '紫藤', label: 'Wisteria' },
|
||||
{ key: 'orchard', style: 'impressionismOrchard', name: '果园', label: 'Orchard' },
|
||||
{ key: 'irises', style: 'impressionismIrises', name: '鸢尾', label: 'Irises' },
|
||||
{ key: 'poppies', style: 'impressionismPoppies', name: '虞美人', label: 'Poppies' },
|
||||
{ key: 'koi', style: 'impressionismKoi', name: '锦鲤', label: 'Koi' },
|
||||
{ key: 'roses', style: 'impressionismRoses', name: '玫瑰', label: 'Roses' },
|
||||
{ key: 'oranges', style: 'impressionismOranges', name: '橘子', label: 'Oranges' },
|
||||
{ key: 'sail', style: 'impressionismSail', name: '远帆', label: 'Sail' },
|
||||
{ key: 'dusk', style: 'abstractionDusk', name: '黄昏', label: 'Dusk' },
|
||||
];
|
||||
|
||||
const LAST_KEY = 'boundless-last-painting';
|
||||
|
||||
function choosePainting() {
|
||||
const wanted = new URLSearchParams(location.search).get('p');
|
||||
const exact = PAINTINGS.find((item) => item.key === wanted);
|
||||
if (exact) return exact;
|
||||
|
||||
let last = null;
|
||||
try { last = localStorage.getItem(LAST_KEY); } catch (e) { /* 隐私模式 */ }
|
||||
const pool = PAINTINGS.filter((item) => item.key !== last);
|
||||
const picked = pool[Math.floor(Math.random() * pool.length)] || PAINTINGS[0];
|
||||
try { localStorage.setItem(LAST_KEY, picked.key); } catch (e) { /* 隐私模式 */ }
|
||||
return picked;
|
||||
}
|
||||
|
||||
const painting = choosePainting();
|
||||
const config = window.STYLES[painting.style];
|
||||
document.documentElement.style.background = config.palette.ground;
|
||||
|
||||
function notifyParent(type) {
|
||||
if (window.parent === window) return;
|
||||
window.parent.postMessage(
|
||||
{ source: 'boundless-painterly', type, key: painting.key, name: painting.name, label: painting.label, ground: config.palette.ground },
|
||||
location.origin,
|
||||
);
|
||||
}
|
||||
|
||||
notifyParent('chosen');
|
||||
|
||||
let engine = null;
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// p5 全局模式:页面加载后若 window 上有 setup / draw 就会自动开始。
|
||||
// 系统要求减少动态效果时不作画,只留下画纸底色。
|
||||
if (!reduceMotion) {
|
||||
window.setup = function () {
|
||||
// 画布内部固定 600×600,再由 CSS 缩放;不跟随设备像素比可以省下不少显存
|
||||
pixelDensity(1);
|
||||
engine = createEngine(config, { mount: document.body });
|
||||
engine.setup();
|
||||
notifyParent('start');
|
||||
};
|
||||
window.draw = function () {
|
||||
if (engine) engine.draw();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/* =====================================================================
|
||||
* style-abstraction.js — Abstraction · Dusk.
|
||||
*
|
||||
* The playground's `abstract` generator at its dusk mood: no scene, no
|
||||
* subject. Wide soft columns of colour stained down the paper, translucent
|
||||
* marker glazes running with them, then a crowd of tall wet drips clustered
|
||||
* around two or three attractors, a last veil of glaze over the top and a
|
||||
* few light touches. Everything travels vertically, which is what makes it
|
||||
* read as poured rather than composed.
|
||||
*
|
||||
* Ported from playground.html rather than rewritten: the build draws the
|
||||
* same random numbers in the same order as the playground does, so at seed
|
||||
* 1874 this is the picture the mood was picked from. The one thing that
|
||||
* cannot carry over is per-stroke brush grain — the engine reseeds before
|
||||
* each stroke so it can redraw them independently — so the shapes, colours
|
||||
* and paint order match while the wet texture inside them differs.
|
||||
*
|
||||
* Same contract as the other style files: an IIFE exporting onto
|
||||
* window.STYLES, dials named S (Copy code emits `const S = …`), palette
|
||||
* carrying ground / seed / phrases, strokes naming its builder literally.
|
||||
* ===================================================================== */
|
||||
window.STYLES = window.STYLES || {};
|
||||
|
||||
(function () {
|
||||
// The dusk preset's dials, baked in. pattern picks the mark shape and flow
|
||||
// the direction everything runs; the rest are the playground's sliders.
|
||||
// detail and brushSize come from the playground's MARK_DEFAULTS, which the
|
||||
// abstract moods inherit rather than set.
|
||||
const S = {
|
||||
pattern: 'drips', flow: 'vertical',
|
||||
water: 0.7, coverage: 0.5, density: 0.35, scale: 1, soft: 0.6, glaze: 0.4, touch: 0.2,
|
||||
detail: 0.6, brushSize: 1,
|
||||
};
|
||||
|
||||
const PALETTE = {
|
||||
seed: 1874,
|
||||
ground: '#f4ece6', // warm paper, barely tinted
|
||||
washes: ['#8f7fa8', '#c68aa0', '#e6b39a', '#f0cfa0'], // violet → rose → apricot → sand
|
||||
accents: ['#6d5c8a', '#e28f8f', '#d9a56b'], // the drips
|
||||
highlights: ['#f9dc9c', '#fff3e0'], // the last touches
|
||||
phrases: ['creating…', 'washing…', 'flowing…', 'blooming…', 'finished'],
|
||||
};
|
||||
|
||||
// =====================================================================
|
||||
// Geometry
|
||||
// =====================================================================
|
||||
// The vertical twin of engine.js's bandPolygon: a column between x0 and x1,
|
||||
// both edges wobbling, run off the top and bottom of the canvas.
|
||||
function columnPolygon(x0, x1, wobble, n = 9) {
|
||||
const left = [], right = [];
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const y = -20 + (H + 40) * (i / n);
|
||||
left.push([x0 + rr(-wobble, wobble), y]);
|
||||
right.push([x1 + rr(-wobble, wobble), y]);
|
||||
}
|
||||
return left.concat(right.reverse());
|
||||
}
|
||||
|
||||
// The playground scheduled every stroke on a 0..1 timeline and painted them
|
||||
// in t order; the engine paints them in array order. Keeping the schedule and
|
||||
// sorting on it at the end reproduces the playground's paint order — which
|
||||
// marks land on top of which — and, because it draws its random numbers in
|
||||
// the same places, everything built after it.
|
||||
function schedule(items, t0, t1, jitter = 0.3) {
|
||||
const step = (t1 - t0) / Math.max(items.length, 1);
|
||||
items.forEach((it, i) => { it.t = t0 + step * (i + 0.5) + rr(-jitter, jitter) * step; });
|
||||
return items;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Drawing vocabulary
|
||||
// =====================================================================
|
||||
function glazeDraw(x0, y0, x1, y1, color, weight, field = 'hand') {
|
||||
brush.field(field); strokeStyle('marker', color, weight * S.brushSize);
|
||||
brush.line(x0, y0, x1, y1); brush.noField();
|
||||
}
|
||||
function flickDraw(x, y, len, a, color, weight) {
|
||||
strokeStyle('flick', color, weight * S.brushSize);
|
||||
brush.line(x - Math.cos(a) * len / 2, y - Math.sin(a) * len / 2, x + Math.cos(a) * len / 2, y + Math.sin(a) * len / 2);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Descriptor factories
|
||||
//
|
||||
// Nothing here calls raxis() / rdir(): the flow already says which way the
|
||||
// paint travels, and drawing extra random numbers would move every shape
|
||||
// built after it off the composition this mood was picked at.
|
||||
// =====================================================================
|
||||
const flowAxis = () => (S.flow === 'horizontal' ? 'x' : 'y');
|
||||
const longAxis = (bb) => ((bb[2] - bb[0]) >= (bb[3] - bb[1]) ? 'x' : 'y');
|
||||
|
||||
function dWash(color, opacity) {
|
||||
const pts = [[-20, -20], [W + 20, -20], [W + 20, H + 20], [-20, H + 20]];
|
||||
return D(() => { washStyle(color, opacity); brush.polygon(pts); }, [0, 0, W, H], flowAxis(), 1, 700, 120);
|
||||
}
|
||||
// One of the wide stained columns (or bands, or pools) the picture is built on.
|
||||
function dGround(pts, color, opacity, bleed, texture) {
|
||||
return D(() => { fillStyle(color, opacity, bleed, texture, 0.3); brush.polygon(pts); },
|
||||
bboxOf(pts, m * 0.25), flowAxis(), 1, 520, 90);
|
||||
}
|
||||
function dGlaze(x0, y0, x1, y1, color, weight, field) {
|
||||
const bb = bboxOf([[x0, y0], [x1, y1]], 14 + weight * 4 * S.brushSize);
|
||||
const axis = Math.abs(x1 - x0) >= Math.abs(y1 - y0) ? 'x' : 'y';
|
||||
const dir = axis === 'x' ? (Math.sign(x1 - x0) || 1) : (Math.sign(y1 - y0) || 1);
|
||||
const len = Math.hypot(x1 - x0, y1 - y0);
|
||||
return D(() => glazeDraw(x0, y0, x1, y1, color, weight, field), bb, axis, dir, 90 + len * 0.45, 45);
|
||||
}
|
||||
// A mark: at this water the bleed drifts well past the polygon, so the reveal
|
||||
// box is padded generously — the buffer holds only this stroke, so an
|
||||
// oversized box copies nothing but transparency. Drips are revealed downwards
|
||||
// whatever their shape, so each one runs rather than appearing.
|
||||
function dMark(pts, color, opacity, bleed, texture) {
|
||||
const bb = bboxOf(pts, m * 0.08 + 14);
|
||||
const axis = S.pattern === 'drips' ? 'y' : longAxis(bb);
|
||||
const extent = axis === 'x' ? bb[2] - bb[0] : bb[3] - bb[1];
|
||||
return D(() => { fillStyle(color, opacity, bleed, texture, 0.3); brush.polygon(pts); },
|
||||
bb, axis, 1, 180 + extent * 0.9, 70);
|
||||
}
|
||||
function dFlick(x, y, len, a, color, weight) {
|
||||
const ends = [[x - Math.cos(a) * len / 2, y - Math.sin(a) * len / 2], [x + Math.cos(a) * len / 2, y + Math.sin(a) * len / 2]];
|
||||
const axis = Math.abs(Math.cos(a)) >= Math.abs(Math.sin(a)) ? 'x' : 'y';
|
||||
return D(() => flickDraw(x, y, len, a, color, weight), bboxOf(ends, 10 + weight * 5), axis, 1, 90, 25);
|
||||
}
|
||||
// Small light strokes dropped near the marks already placed.
|
||||
function dTouches(points, C, count, angleFn, sizeMul = 1) {
|
||||
const items = [];
|
||||
count = Math.round(count * (0.4 + 0.6 * S.detail));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = points.length ? pickOne(points) : [rr(0, W), rr(0, H), 20];
|
||||
const x = p[0] + rr(-p[2], p[2]), y = p[1] + rr(-p[2] * 0.6, p[2] * 0.6);
|
||||
const color = random() < 0.85 ? pickOne(C.highlights) : pickOne(C.accents);
|
||||
const len = rr(8, 22) * S.scale * sizeMul, weight = rr(0.6, 1.1) * S.scale;
|
||||
items.push(dFlick(x, y, len, angleFn(), color, weight));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// The picture
|
||||
// =====================================================================
|
||||
function buildAbstraction(C) {
|
||||
const out = [];
|
||||
const group = (items, t0, t1) => { schedule(items, t0, t1); out.push(...items); };
|
||||
const { water, density: dens, scale: sc, soft } = S;
|
||||
const { washes, accents, highlights } = C;
|
||||
const horizontal = S.flow === 'horizontal', vertical = S.flow === 'vertical';
|
||||
|
||||
// 1. the coverage wash, flat, under everything
|
||||
if (S.coverage > 0.02) {
|
||||
const w = dWash(washes[0], Math.round(S.coverage * 200));
|
||||
w.t = 0.02;
|
||||
out.push(w);
|
||||
}
|
||||
|
||||
// 2. the ground: a few wide stains, one per wash colour, overlapping
|
||||
const nGround = 2 + Math.round(dens * 2);
|
||||
const ground = [];
|
||||
for (let i = 0; i < nGround; i++) {
|
||||
const color = washes[i % washes.length];
|
||||
const opacity = Math.round(225 - water * 80);
|
||||
const bleed = 0.25 + water * 0.55, texture = 0.3 + water * 0.5;
|
||||
let pts;
|
||||
if (horizontal) { const y0 = -20 + (i / nGround) * H + rr(-30, 30), h = H / nGround * rr(1.2, 1.8); pts = bandPolygon(y0, y0 + h, 14 + soft * 20); }
|
||||
else if (vertical) { const x0 = -20 + (i / nGround) * W + rr(-30, 30), w = W / nGround * rr(1.2, 1.8); pts = columnPolygon(x0, x0 + w, 14 + soft * 20); }
|
||||
else pts = jitterEllipse(rr(W * 0.2, W * 0.8), rr(H * 0.2, H * 0.8), W * rr(0.35, 0.6), H * rr(0.25, 0.45), 14, 0.1 + soft * 0.25, rr(-0.6, 0.6));
|
||||
ground.push(dGround(pts, color, opacity, bleed, texture));
|
||||
}
|
||||
group(ground, 0.03, 0.16);
|
||||
|
||||
// 3. glazes travelling with the flow. A quarter of them are held back as a
|
||||
// veil and laid over the marks at the end instead.
|
||||
const glazes = [];
|
||||
for (let i = 0; i < Math.round(S.glaze * 22); i++) {
|
||||
const color = random() < 0.7 ? pickOne(washes) : pickOne(accents);
|
||||
const weight = rr(1.2, 3.2) * Math.sqrt(sc);
|
||||
let x0, y0, x1, y1, field;
|
||||
if (horizontal) { y0 = rr(0, H); x0 = rr(-10, W * 0.5); x1 = x0 + rr(W * 0.3, W * 0.9); y1 = y0 + rr(-8, 8); field = random() < 0.25 ? 'waves' : 'hand'; }
|
||||
else if (vertical) { x0 = rr(0, W); y0 = rr(-10, H * 0.5); y1 = y0 + rr(H * 0.25, H * 0.8); x1 = x0 + rr(-8, 8); field = 'hand'; }
|
||||
else { x0 = rr(0, W); y0 = rr(0, H); const a = rr(-0.6, 0.6), len = rr(m * 0.2, m * 0.5); x1 = x0 + Math.cos(a) * len; y1 = y0 + Math.sin(a) * len; field = random() < 0.5 ? 'curved' : 'seabed'; }
|
||||
glazes.push(dGlaze(x0, y0, x1, y1, color, weight, field));
|
||||
}
|
||||
const veil = glazes.splice(0, Math.round(glazes.length * 0.25));
|
||||
group(glazes, 0.16, 0.30);
|
||||
|
||||
// 4. the marks. Most fall near two or three attractors, which is what gives
|
||||
// the picture its clusters and its empty right-hand side; the rest are
|
||||
// scattered anywhere. Painted biggest first so the small ones sit on top.
|
||||
const nMarks = Math.round((3 + dens * 34) * (S.pattern === 'dapple' ? 1.6 : 1));
|
||||
const attractors = [];
|
||||
for (let i = 0, n = 2 + Math.floor(rr(0, 3)); i < n; i++) attractors.push([rr(W * 0.15, W * 0.85), rr(H * 0.15, H * 0.85)]);
|
||||
const s0 = m * 0.06 * sc;
|
||||
const marks = [], centers = [];
|
||||
for (let i = 0; i < nMarks; i++) {
|
||||
let x, y;
|
||||
if (random() < 0.25) { x = rr(0, W); y = rr(0, H); }
|
||||
else { const a = pickOne(attractors); const sd = m * (S.pattern === 'dapple' ? 0.1 : 0.16); x = a[0] + randomGaussian(0, sd); y = a[1] + randomGaussian(0, sd); }
|
||||
const r = s0 * rr(0.5, 1.6);
|
||||
const color = random() < 0.2 ? pickOne(highlights) : pickOne(accents);
|
||||
const jit = 0.08 + soft * 0.3;
|
||||
let pts, opacity, bleed, texture;
|
||||
switch (S.pattern) {
|
||||
case 'bands': pts = jitterEllipse(x, y, r * rr(2.5, 4.5), r * rr(0.25, 0.5), 14, jit * 0.6); opacity = 205 - water * 60; bleed = 0.3 + water * 0.5; texture = 0.4 + water * 0.4; break;
|
||||
case 'petals': pts = jitterEllipse(x, y, r * rr(0.7, 1.1), r * rr(0.28, 0.45), 10, jit * 0.5, rr(0, TWO_PI)); opacity = 225 - water * 45; bleed = 0.12 + water * 0.3; texture = 0.3 + water * 0.3; break;
|
||||
case 'drips': pts = jitterEllipse(x, y, r * rr(0.3, 0.5), r * rr(2, 4), 14, jit * 0.7); opacity = 200 - water * 60; bleed = 0.4 + water * 0.4; texture = 0.4 + water * 0.4; break;
|
||||
case 'dapple': pts = jitterEllipse(x, y, r * 0.45, r * rr(0.35, 0.45), 10, jit); opacity = 200 - water * 40; bleed = 0.2 + water * 0.3; texture = 0.3 + water * 0.3; break;
|
||||
default: pts = jitterEllipse(x, y, r, r * rr(0.6, 1), 12, jit); opacity = 200 - water * 60; bleed = 0.3 + water * 0.5; texture = 0.4 + water * 0.4;
|
||||
}
|
||||
centers.push([x, y, r]);
|
||||
const d = dMark(pts, color, Math.round(opacity), bleed, texture);
|
||||
d.r = r;
|
||||
marks.push(d);
|
||||
}
|
||||
marks.sort((a, b) => b.r - a.r);
|
||||
group(marks, 0.30, 0.86);
|
||||
|
||||
// 5. the held-back glazes over the top, then the touches
|
||||
group(veil, 0.86, 0.92);
|
||||
const angle = () => (horizontal ? rr(-0.1, 0.1) : vertical ? HALF_PI + rr(-0.15, 0.15) : rr(0, TWO_PI));
|
||||
group(dTouches(centers, C, Math.round(S.touch * 20), angle), 0.92, 1.0);
|
||||
|
||||
// the engine paints in array order; t is the playground's paint order
|
||||
return out.sort((a, b) => a.t - b.t);
|
||||
}
|
||||
|
||||
STYLES.abstractionDusk = {
|
||||
key: 'abstraction-dusk',
|
||||
movement: 'Abstraction',
|
||||
note: 'The hour when color stops trying to be anything specific.',
|
||||
label: 'Dusk',
|
||||
canvasSize: 600,
|
||||
loopMs: 9000,
|
||||
holdMs: 1500,
|
||||
dials: S,
|
||||
palette: PALETTE,
|
||||
strokes: (palette) => buildAbstraction(palette),
|
||||
vocabulary: [columnPolygon, schedule, glazeDraw, flickDraw, flowAxis, longAxis,
|
||||
dWash, dGround, dGlaze, dMark, dFlick, dTouches, buildAbstraction],
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+8
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user