motion-hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/LICENSE +44 -0
  2. package/README.md +141 -0
  3. package/bin/install-skill.mjs +39 -0
  4. package/package.json +48 -0
  5. package/skill/motion-hub/SKILL.md +127 -0
  6. package/skill/motion-hub/effects/contour.html +279 -0
  7. package/skill/motion-hub/effects/dots-quiet.html +257 -0
  8. package/skill/motion-hub/effects/dots.html +263 -0
  9. package/skill/motion-hub/effects/flow.html +253 -0
  10. package/skill/motion-hub/effects/follow-tip.html +241 -0
  11. package/skill/motion-hub/effects/glow-card.html +236 -0
  12. package/skill/motion-hub/effects/grid.html +266 -0
  13. package/skill/motion-hub/effects/intro.html +302 -0
  14. package/skill/motion-hub/effects/pipeline.html +257 -0
  15. package/skill/motion-hub/effects/pull.html +373 -0
  16. package/skill/motion-hub/effects/scramble.html +248 -0
  17. package/skill/motion-hub/effects/sun-moon.html +235 -0
  18. package/skill/motion-hub/effects/theme-blinds.html +381 -0
  19. package/skill/motion-hub/effects/theme-circle.html +381 -0
  20. package/skill/motion-hub/effects/theme-dots.html +381 -0
  21. package/skill/motion-hub/effects/theme-fade.html +381 -0
  22. package/skill/motion-hub/effects/theme-flip.html +381 -0
  23. package/skill/motion-hub/effects/theme-halftone.html +381 -0
  24. package/skill/motion-hub/effects/theme-ink.html +381 -0
  25. package/skill/motion-hub/effects/theme-iris.html +381 -0
  26. package/skill/motion-hub/effects/theme-slice.html +381 -0
  27. package/skill/motion-hub/effects/theme-soft.html +381 -0
  28. package/skill/motion-hub/effects/theme-wave.html +381 -0
  29. package/skill/motion-hub/index.json +667 -0
@@ -0,0 +1,279 @@
1
+ <!doctype html>
2
+ <!--
3
+ 「等高线」 · Motion Hub · MIT + Commons Clause(可免费用于任何项目,不能售卖效果本身)
4
+
5
+ 【怎么用】
6
+ 这个文件可以直接双击打开,先看效果。
7
+ 想放进自己的项目:把整个文件交给 AI(Claude / ChatGPT / Cursor 等),再说一句你的技术栈和想要的调整,
8
+ 例如“把这个效果改写成 React 组件,放在首屏当背景,颜色换成我的品牌色”。下面的说明就是写给它看的。
9
+
10
+ 【效果】
11
+ 背景是一片缓慢漂移的地形等高线。鼠标所在处隆起一座小山,最高的几圈线变成强调色;点击会从点击处荡开一圈圈波纹。
12
+
13
+ 【实现原理】
14
+ - 用两层 Perlin 噪声叠加成高度场,随时间平移,让地形缓慢漂移。
15
+ - 在网格上用 marching squares 按固定高度间隔描等值线(16 种格子情况,线段端点按高度线性插值),每 5 条加深一条。
16
+ - 鼠标处叠加一个高斯“山包”,位置和强度都平滑跟随;高于阈值的等值线用强调色画。
17
+ - 点击记录一个波源:高度场叠加一圈向外传播、随距离和时间衰减的余弦波。
18
+
19
+ 【可以调的参数】
20
+ - 网格边长 CELL(越小越细、越耗性能,默认 14px)
21
+ - 等值线间隔 0.12
22
+ - 噪声缩放 1/420
23
+ - 漂移速度 0.02
24
+ - 山包半径 √33800 ≈ 184px
25
+ - 波纹速度 420px/s,寿命 2.6s
26
+
27
+ 【注意】
28
+ - 每帧重算整片网格,1440×900 约 100×65 格。更大的屏幕可以增大 CELL 或降低帧率。
29
+ - 所有线段按高度分组,每组一次 beginPath / stroke,不要逐段 stroke。
30
+ - 颜色都来自 CSS 变量(paper / ink / dim / rule / accent),换成项目自己的颜色即可。
31
+ - 要处理 prefers-reduced-motion:系统要求减少动态效果时只显示静止画面。
32
+ - 组件卸载时要停掉动画循环、移除事件监听(mount 返回的 destroy)。
33
+
34
+ 【opts 参数】(mount 的第二个参数,不传就用默认值;文件末尾的 mount 调用里是你在 Motion Hub 上调好的值)
35
+ - cell:网格精度,默认 14(6–28px)
36
+ - step:等高距,默认 0.12(0.06–0.3)
37
+ - speed:漂移速度,默认 1(0–5×)
38
+ - hill:隆起高度,默认 0.9(0–2)
39
+
40
+ 【代码结构】
41
+ - <style>:配色变量和这个效果用到的样式
42
+ - 第一段 <script>:MH 核心(注册表、噪声、缓动;canvasHost 负责画布尺寸、指针坐标、动画循环和暂停)
43
+ - 第二段 <script>:效果本身,核心是 MH.register 里的 mount(el, opts),返回 { pause, resume, destroy }
44
+ - 最后一段 <script>:把效果挂到 #fx 上并启动
45
+ -->
46
+ <html lang="zh-CN">
47
+ <head>
48
+ <meta charset="utf-8">
49
+ <meta name="viewport" content="width=device-width,initial-scale=1">
50
+ <title>等高线</title>
51
+ <style>
52
+ /* Motion Hub 效果样式:配色变量(Acid:浅色暖灰 + 紫罗兰,深色近黑 + 酸橙绿)+ 各效果用到的样式 + 整页过渡遮罩。按“@css 名称”标记分段,复制代码时只带用到的段。 */
53
+ :root{
54
+ --paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E;
55
+ --serif:"Songti SC","STSong","Noto Serif SC",serif;
56
+ --sans:"PingFang SC","Helvetica Neue","Noto Sans SC",sans-serif;
57
+ --latin:"Iowan Old Style",Palatino,Georgia,serif;
58
+ --mono:"SF Mono",Menlo,monospace;
59
+ }
60
+ [data-theme="night"]{--paper:#111113;--paper2:#1B1B1F;--ink:#F2F1F5;--body:#C4C2CC;--dim:#7E7B88;--rule:#2A2A30;--accent:#C6F21E;--on-accent:#111113;--hi:#8E75FF}
61
+ [data-theme="day"]{--paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E}
62
+
63
+ /* ───── 效果共用 ───── */
64
+ .mh-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
65
+ html,body{height:100%;margin:0;background:var(--paper)}
66
+ #fx{position:relative;height:100vh;overflow:hidden;container-type:size}
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <div id="fx"></div>
71
+ <script>
72
+ // Motion Hub 核心:注册表 + 公共工具。所有效果文件都是普通 <script>(不是 ES 模块),
73
+ // 这样直接双击 index.html(file://)也能运行。每个效果调用 MH.register({...}) 把自己登记进来。
74
+ //
75
+ // 效果对象的约定:
76
+ // id 唯一标识(小写短横线)
77
+ // name 中文名
78
+ // cat 分类:背景 / 过渡 / 翻页 / 片头 / 交互
79
+ // tech 用到的技术(展示用)
80
+ // desc 一句话说明
81
+ // mount(el, opts) → { pause(), resume(), destroy() }
82
+ // el 是一个已经有尺寸的容器;opts.expanded 为 true 表示全屏体验(关掉幽灵光标、允许滚轮等)
83
+ // params 可调参数(可选),实验台据此生成参数面板,每项 { k, label, type, def, ... }:
84
+ // type 'range':数值滑块,带 min / max / step,可选 unit;'text':单行文字;'lines':多行文字,一行一项
85
+ // mount 拿到的 opts 已经按 def 补齐(register 里包了一层),效果代码直接读 opts.k 即可
86
+ window.MH = (() => {
87
+ const effects = [];
88
+ // 记下效果来自哪个文件(复制代码时只带这个文件)
89
+ const register = e => {
90
+ const src = document.currentScript && document.currentScript.src; e.file = e.file || (src ? src.split('/').pop() : '');
91
+ if (e.params) { const m = e.mount; e.mount = (el, opts = {}) => m.call(e, el, { ...defaults(e), ...opts }); }
92
+ effects.push(e);
93
+ };
94
+ const defaults = e => Object.fromEntries((e.params || []).map(p => [p.k, p.def]));
95
+ // 'lines' 参数:数组原样返回;字符串按行拆开,去掉空行
96
+ const lines = v => Array.isArray(v) ? v : String(v || '').split('\n').map(x => x.trim()).filter(Boolean);
97
+ const still = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
98
+ const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
99
+ const lerp = (a, b, k) => a + (b - a) * k;
100
+ const ease = {
101
+ in: t => t * t * t,
102
+ out: t => 1 - (1 - t) ** 3,
103
+ inOut: t => t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2,
104
+ back: t => { const c = 1.4; return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2; },
105
+ };
106
+ const hash = i => { const x = Math.sin(i * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
107
+
108
+ // 二维 Perlin 噪声,seed 决定排列表
109
+ function perlin(seed = 1) {
110
+ const P = new Uint8Array(512), p = [...Array(256).keys()];
111
+ let s = seed;
112
+ for (let i = 255; i > 0; i--) { s = (s * 16807) % 2147483647; const j = s % (i + 1); [p[i], p[j]] = [p[j], p[i]]; }
113
+ for (let i = 0; i < 512; i++) P[i] = p[i & 255];
114
+ const fade = t => t * t * t * (t * (t * 6 - 15) + 10), grad = (h, x, y) => ((h & 1) ? -x : x) + ((h & 2) ? -y : y);
115
+ return (x, y) => {
116
+ const X = Math.floor(x) & 255, Y = Math.floor(y) & 255; x -= Math.floor(x); y -= Math.floor(y);
117
+ const u = fade(x), v = fade(y), a = P[X] + Y, b = P[X + 1] + Y;
118
+ const l1 = grad(P[a], x, y) + u * (grad(P[b], x - 1, y) - grad(P[a], x, y));
119
+ const l2 = grad(P[a + 1], x, y - 1) + u * (grad(P[b + 1], x - 1, y - 1) - grad(P[a + 1], x, y - 1));
120
+ return l1 + v * (l2 - l1);
121
+ };
122
+ }
123
+
124
+ // 画布按设备像素比放大,画的时候仍用 CSS 像素坐标
125
+ function fit(cv, w, h) {
126
+ const dpr = Math.min(devicePixelRatio || 1, 2);
127
+ cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
128
+ const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx;
129
+ }
130
+ // 从容器读主题色(--paper / --ink / --dim / --rule / --accent)
131
+ function colors(el) {
132
+ const cs = getComputedStyle(el), v = n => cs.getPropertyValue(n).trim();
133
+ const t = el.closest('[data-theme]');
134
+ return { paper: v('--paper'), ink: v('--ink'), dim: v('--dim'), rule: v('--rule'), accent: v('--accent'), night: !!t && t.dataset.theme === 'night' };
135
+ }
136
+ // 一段时长 ms 的补间,fn(k) 中 k 从 0 到 1;减少动态效果时直接到终点
137
+ const tween = (ms, fn) => new Promise(res => {
138
+ if (still()) { fn(1); return res(); }
139
+ const t0 = performance.now();
140
+ const f = now => { const k = clamp((now - t0) / ms); fn(k); k < 1 ? requestAnimationFrame(f) : res(); };
141
+ requestAnimationFrame(f);
142
+ });
143
+ // 尺寸缩放系数:全屏约 1,缩略图约 0.45,让小画面像“整页缩小”而不是挤满细节
144
+ const scaleOf = W => clamp(W / 1200, .45, 1);
145
+
146
+ // 幽灵光标:没人操作时,沿一条利萨如曲线在容器里慢慢移动,让缩略图自己演示交互
147
+ const ghostAt = (now, W, H, seed = 0) => {
148
+ const t = now / 1000 + seed * 7;
149
+ return { x: W * (.5 + .32 * Math.sin(t * .55) * Math.cos(t * .21)), y: H * (.5 + .3 * Math.sin(t * .83 + 1.3)) };
150
+ };
151
+
152
+ // 效果宿主:(可选)建画布、跟随尺寸、换算局部指针坐标、管动画循环和暂停。
153
+ // setup(h) 返回 { resize(W,H), frame(now), down(x,y), theme(), ghostClick },h 提供 ctx / W / H / P / s / colors()
154
+ // ghostClick: false 表示幽灵光标不替它“点击”(例如片头,点一下会重播)
155
+ function canvasHost(el, opts, setup, { canvas = true } = {}) {
156
+ const cv = canvas ? document.createElement('canvas') : null;
157
+ if (cv) { cv.className = 'mh-canvas'; el.append(cv); }
158
+ let ctx, W = 0, H = 0, raf = 0, running = false, lastReal = -1e9, lastGhostClick = 0;
159
+ const P = { x: -9999, y: -9999, real: false };
160
+ const seed = hash(effects.length + el.childElementCount);
161
+ const h = { cv, get ctx() { return ctx; }, get W() { return W; }, get H() { return H; }, P, s: 1, colors: () => colors(el), expanded: !!opts.expanded };
162
+ const api = setup(h);
163
+ const size = () => {
164
+ const r = el.getBoundingClientRect(); if (!r.width || !r.height) return;
165
+ W = r.width; H = r.height; h.s = scaleOf(W); if (cv) ctx = fit(cv, W, H); api.resize && api.resize(W, H);
166
+ if (!running) api.frame(performance.now());
167
+ };
168
+ const ro = new ResizeObserver(size); ro.observe(el);
169
+ const local = e => { const r = el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
170
+ const onMove = e => { [P.x, P.y] = local(e); P.real = true; lastReal = performance.now(); };
171
+ const onLeave = () => { P.real = false; P.x = P.y = -9999; };
172
+ const onDown = e => { if (e.target.closest('a, button')) return; lastReal = performance.now(); const [x, y] = local(e); api.down && api.down(x, y); };
173
+ el.addEventListener('pointermove', onMove); el.addEventListener('pointerleave', onLeave); el.addEventListener('pointerdown', onDown);
174
+ const mo = new MutationObserver(() => { api.theme && api.theme(); if (!running) api.frame(performance.now()); });
175
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-palette'] });
176
+ const tick = now => {
177
+ if (!running) return;
178
+ // 缩略图里 2.5 秒没人碰,就让幽灵光标接管;偶尔“点”一下展示点击效果
179
+ if (!h.expanded && now - lastReal > 2500) {
180
+ const g = ghostAt(now, W, H, seed); P.x = g.x; P.y = g.y;
181
+ if (api.down && api.ghostClick !== false && now - lastGhostClick > 4200) { lastGhostClick = now; api.down(g.x, g.y); }
182
+ }
183
+ if (W && H) api.frame(now);
184
+ raf = requestAnimationFrame(tick);
185
+ };
186
+ size();
187
+ return {
188
+ pause() { running = false; cancelAnimationFrame(raf); },
189
+ resume() { if (running) return; if (still()) { api.frame(performance.now()); return; } running = true; raf = requestAnimationFrame(tick); },
190
+ destroy() { this.pause(); ro.disconnect(); mo.disconnect(); el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); el.removeEventListener('pointerdown', onDown); cv && cv.remove(); },
191
+ };
192
+ }
193
+
194
+ // 让 CSS 能对自定义属性做补间(遮罩半径等)
195
+ if (window.CSS && CSS.registerProperty) {
196
+ for (const name of ['--vr', '--vw']) { try { CSS.registerProperty({ name, syntax: '<length>', inherits: false, initialValue: '0px' }); } catch (e) { /* 已注册 */ } }
197
+ }
198
+
199
+ return { effects, register, defaults, lines, still, clamp, lerp, ease, hash, perlin, fit, colors, tween, scaleOf, ghostAt, canvasHost };
200
+ })();
201
+ </script>
202
+ <script>
203
+ // 背景动效:画在 canvas 上,尺寸跟随容器,h.s 是缩放系数(全屏约 1,缩略图约 0.45)。
204
+
205
+ // ───── 等高线:平滑噪声当地形,每隔一个高度描一圈线(marching squares);鼠标处隆起,点击泛起波纹 ─────
206
+ MH.register({
207
+ id: 'contour', name: '等高线', cat: '背景', tech: 'Canvas 2D · Perlin Noise · Marching Squares',
208
+ desc: '地形缓慢漂移,鼠标处隆起一座小山,最高的几圈变成强调色;点击会荡开一圈圈波纹。',
209
+ params: [
210
+ { k: 'cell', label: '网格精度', type: 'range', min: 6, max: 28, step: 1, def: 14, unit: 'px' },
211
+ { k: 'step', label: '等高距', type: 'range', min: 0.06, max: 0.3, step: 0.01, def: 0.12 },
212
+ { k: 'speed', label: '漂移速度', type: 'range', min: 0, max: 5, step: 0.1, def: 1, unit: '×' },
213
+ { k: 'hill', label: '隆起高度', type: 'range', min: 0, max: 2, step: 0.05, def: 0.9 },
214
+ ],
215
+ mount: (el, opts) => MH.canvasHost(el, opts, h => {
216
+ const noise = MH.perlin(3), LEVELS = [], m = { x: -999, y: -999, a: 0 }, waves = [];
217
+ for (let l = -1.2; l <= 1.65; l += opts.step) LEVELS.push(l);
218
+ let C = h.colors(), CELL, cols, rows, field;
219
+ return {
220
+ resize(W, H) { CELL = Math.max(4, opts.cell * h.s); cols = Math.ceil(W / CELL) + 1; rows = Math.ceil(H / CELL) + 1; field = new Float32Array(cols * rows); },
221
+ theme() { C = h.colors(); },
222
+ down(x, y) { waves.push({ x, y, t0: performance.now() }); if (waves.length > 4) waves.shift(); },
223
+ frame(now) {
224
+ const { ctx, W, H, P, s } = h, t = now / 1000 * .02 * opts.speed, SC = 1 / (420 * s);
225
+ const on = P.x > -999;
226
+ if (on && m.x < -900) { m.x = P.x; m.y = P.y; }
227
+ m.x += ((on ? P.x : m.x) - m.x) * .12; m.y += ((on ? P.y : m.y) - m.y) * .12; m.a += ((on ? 1 : 0) - m.a) * .06;
228
+ const R2 = 33800 * s * s, live = waves.filter(w => now - w.t0 < 2600);
229
+ for (let j = 0; j < rows; j++) for (let i = 0; i < cols; i++) {
230
+ const px = i * CELL, py = j * CELL, wx = px * SC, wy = py * SC;
231
+ let v = noise(wx + t, wy) * 1.1 + noise(wx * 2.1 - t * .7, wy * 2.1 + 9) * .45;
232
+ if (m.a > .01) { const dx = px - m.x, dy = py - m.y; v += opts.hill * m.a * Math.exp(-(dx * dx + dy * dy) / R2); }
233
+ for (const w of live) {
234
+ const age = (now - w.t0) / 1000, d = Math.hypot(px - w.x, py - w.y), front = age * 420 * s;
235
+ if (Math.abs(d - front) < 160 * s) v += .35 * Math.cos((d - front) / (26 * s)) * Math.exp(-(((d - front) / (90 * s)) ** 2)) * (1 - age / 2.6);
236
+ }
237
+ field[j * cols + i] = v;
238
+ }
239
+ ctx.clearRect(0, 0, W, H); ctx.lineWidth = 1;
240
+ for (let li = 0; li < LEVELS.length; li++) {
241
+ const L = LEVELS[li], hot = L >= 1;
242
+ ctx.strokeStyle = hot ? C.accent : C.dim;
243
+ ctx.globalAlpha = hot ? .6 : (li % 5 === 0 ? (C.night ? .32 : .42) : (C.night ? .16 : .22));
244
+ ctx.beginPath();
245
+ for (let j = 0; j < rows - 1; j++) for (let i = 0; i < cols - 1; i++) {
246
+ const a = field[j * cols + i], b = field[j * cols + i + 1], c = field[(j + 1) * cols + i + 1], d = field[(j + 1) * cols + i];
247
+ const k = (a > L) | ((b > L) << 1) | ((c > L) << 2) | ((d > L) << 3);
248
+ if (k === 0 || k === 15) continue;
249
+ const x0 = i * CELL, y0 = j * CELL, R = x0 + CELL, B = y0 + CELL;
250
+ const Tx = x0 + (L - a) / (b - a) * CELL, Ry = y0 + (L - b) / (c - b) * CELL, Bx = x0 + (L - d) / (c - d) * CELL, Ly = y0 + (L - a) / (d - a) * CELL;
251
+ switch (k) {
252
+ case 1: case 14: ctx.moveTo(x0, Ly); ctx.lineTo(Tx, y0); break;
253
+ case 2: case 13: ctx.moveTo(Tx, y0); ctx.lineTo(R, Ry); break;
254
+ case 3: case 12: ctx.moveTo(x0, Ly); ctx.lineTo(R, Ry); break;
255
+ case 4: case 11: ctx.moveTo(R, Ry); ctx.lineTo(Bx, B); break;
256
+ case 6: case 9: ctx.moveTo(Tx, y0); ctx.lineTo(Bx, B); break;
257
+ case 7: case 8: ctx.moveTo(x0, Ly); ctx.lineTo(Bx, B); break;
258
+ case 5: ctx.moveTo(x0, Ly); ctx.lineTo(Tx, y0); ctx.moveTo(R, Ry); ctx.lineTo(Bx, B); break;
259
+ case 10: ctx.moveTo(Tx, y0); ctx.lineTo(R, Ry); ctx.moveTo(x0, Ly); ctx.lineTo(Bx, B); break;
260
+ }
261
+ }
262
+ ctx.stroke();
263
+ }
264
+ ctx.globalAlpha = 1;
265
+ },
266
+ };
267
+ }),
268
+ });
269
+ </script>
270
+ <script>
271
+ MH.effects.find(e => e.id === 'contour').mount(document.getElementById('fx'), {
272
+ "cell": 14,
273
+ "step": 0.12,
274
+ "speed": 1,
275
+ "hill": 0.9
276
+ }).resume();
277
+ </script>
278
+ </body>
279
+ </html>
@@ -0,0 +1,257 @@
1
+ <!doctype html>
2
+ <!--
3
+ 「安静点阵」 · Motion Hub · MIT + Commons Clause(可免费用于任何项目,不能售卖效果本身)
4
+
5
+ 【怎么用】
6
+ 这个文件可以直接双击打开,先看效果。
7
+ 想放进自己的项目:把整个文件交给 AI(Claude / ChatGPT / Cursor 等),再说一句你的技术栈和想要的调整,
8
+ 例如“把这个效果改写成 React 组件,放在首屏当背景,颜色换成我的品牌色”。下面的说明就是写给它看的。
9
+
10
+ 【效果】
11
+ 和磁性点阵同一套网格,但更安静:点更淡,推开的范围和力度更小,没有点击冲击波。适合长文、详情页这种以阅读为主的页面。
12
+
13
+ 【实现原理】
14
+ - 同磁性点阵:静止位置 + 径向推力 + 弹簧阻尼回弹。
15
+ - 推力减半(1.6),半径 110px,点的最大半径 2.4,整体透明度 0.3。
16
+
17
+ 【可以调的参数】
18
+ - 点间距 26px
19
+ - 推开半径 110px
20
+ - 推力 1.6
21
+ - 透明度 0.3(深色 0.26)
22
+
23
+ 【注意】
24
+ - 阅读页的背景越克制越好,宁可让人几乎察觉不到。
25
+ - 颜色都来自 CSS 变量(paper / ink / dim / rule / accent),换成项目自己的颜色即可。
26
+ - 要处理 prefers-reduced-motion:系统要求减少动态效果时只显示静止画面。
27
+ - 组件卸载时要停掉动画循环、移除事件监听(mount 返回的 destroy)。
28
+
29
+ 【opts 参数】(mount 的第二个参数,不传就用默认值;文件末尾的 mount 调用里是你在 Motion Hub 上调好的值)
30
+ - gap:点间距,默认 26(12–60px)
31
+ - radius:推开半径,默认 110(40–320px)
32
+ - spring:弹簧刚度,默认 0.06(0.01–0.2)
33
+ - damping:阻尼,默认 0.82(0.6–0.95)
34
+
35
+ 【代码结构】
36
+ - <style>:配色变量和这个效果用到的样式
37
+ - 第一段 <script>:MH 核心(注册表、噪声、缓动;canvasHost 负责画布尺寸、指针坐标、动画循环和暂停)
38
+ - 第二段 <script>:效果本身,核心是 MH.register 里的 mount(el, opts),返回 { pause, resume, destroy }
39
+ - 最后一段 <script>:把效果挂到 #fx 上并启动
40
+ -->
41
+ <html lang="zh-CN">
42
+ <head>
43
+ <meta charset="utf-8">
44
+ <meta name="viewport" content="width=device-width,initial-scale=1">
45
+ <title>安静点阵</title>
46
+ <style>
47
+ /* Motion Hub 效果样式:配色变量(Acid:浅色暖灰 + 紫罗兰,深色近黑 + 酸橙绿)+ 各效果用到的样式 + 整页过渡遮罩。按“@css 名称”标记分段,复制代码时只带用到的段。 */
48
+ :root{
49
+ --paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E;
50
+ --serif:"Songti SC","STSong","Noto Serif SC",serif;
51
+ --sans:"PingFang SC","Helvetica Neue","Noto Sans SC",sans-serif;
52
+ --latin:"Iowan Old Style",Palatino,Georgia,serif;
53
+ --mono:"SF Mono",Menlo,monospace;
54
+ }
55
+ [data-theme="night"]{--paper:#111113;--paper2:#1B1B1F;--ink:#F2F1F5;--body:#C4C2CC;--dim:#7E7B88;--rule:#2A2A30;--accent:#C6F21E;--on-accent:#111113;--hi:#8E75FF}
56
+ [data-theme="day"]{--paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E}
57
+
58
+ /* ───── 效果共用 ───── */
59
+ .mh-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
60
+ html,body{height:100%;margin:0;background:var(--paper)}
61
+ #fx{position:relative;height:100vh;overflow:hidden;container-type:size}
62
+ </style>
63
+ </head>
64
+ <body>
65
+ <div id="fx"></div>
66
+ <script>
67
+ // Motion Hub 核心:注册表 + 公共工具。所有效果文件都是普通 <script>(不是 ES 模块),
68
+ // 这样直接双击 index.html(file://)也能运行。每个效果调用 MH.register({...}) 把自己登记进来。
69
+ //
70
+ // 效果对象的约定:
71
+ // id 唯一标识(小写短横线)
72
+ // name 中文名
73
+ // cat 分类:背景 / 过渡 / 翻页 / 片头 / 交互
74
+ // tech 用到的技术(展示用)
75
+ // desc 一句话说明
76
+ // mount(el, opts) → { pause(), resume(), destroy() }
77
+ // el 是一个已经有尺寸的容器;opts.expanded 为 true 表示全屏体验(关掉幽灵光标、允许滚轮等)
78
+ // params 可调参数(可选),实验台据此生成参数面板,每项 { k, label, type, def, ... }:
79
+ // type 'range':数值滑块,带 min / max / step,可选 unit;'text':单行文字;'lines':多行文字,一行一项
80
+ // mount 拿到的 opts 已经按 def 补齐(register 里包了一层),效果代码直接读 opts.k 即可
81
+ window.MH = (() => {
82
+ const effects = [];
83
+ // 记下效果来自哪个文件(复制代码时只带这个文件)
84
+ const register = e => {
85
+ const src = document.currentScript && document.currentScript.src; e.file = e.file || (src ? src.split('/').pop() : '');
86
+ if (e.params) { const m = e.mount; e.mount = (el, opts = {}) => m.call(e, el, { ...defaults(e), ...opts }); }
87
+ effects.push(e);
88
+ };
89
+ const defaults = e => Object.fromEntries((e.params || []).map(p => [p.k, p.def]));
90
+ // 'lines' 参数:数组原样返回;字符串按行拆开,去掉空行
91
+ const lines = v => Array.isArray(v) ? v : String(v || '').split('\n').map(x => x.trim()).filter(Boolean);
92
+ const still = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
93
+ const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
94
+ const lerp = (a, b, k) => a + (b - a) * k;
95
+ const ease = {
96
+ in: t => t * t * t,
97
+ out: t => 1 - (1 - t) ** 3,
98
+ inOut: t => t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2,
99
+ back: t => { const c = 1.4; return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2; },
100
+ };
101
+ const hash = i => { const x = Math.sin(i * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
102
+
103
+ // 二维 Perlin 噪声,seed 决定排列表
104
+ function perlin(seed = 1) {
105
+ const P = new Uint8Array(512), p = [...Array(256).keys()];
106
+ let s = seed;
107
+ for (let i = 255; i > 0; i--) { s = (s * 16807) % 2147483647; const j = s % (i + 1); [p[i], p[j]] = [p[j], p[i]]; }
108
+ for (let i = 0; i < 512; i++) P[i] = p[i & 255];
109
+ const fade = t => t * t * t * (t * (t * 6 - 15) + 10), grad = (h, x, y) => ((h & 1) ? -x : x) + ((h & 2) ? -y : y);
110
+ return (x, y) => {
111
+ const X = Math.floor(x) & 255, Y = Math.floor(y) & 255; x -= Math.floor(x); y -= Math.floor(y);
112
+ const u = fade(x), v = fade(y), a = P[X] + Y, b = P[X + 1] + Y;
113
+ const l1 = grad(P[a], x, y) + u * (grad(P[b], x - 1, y) - grad(P[a], x, y));
114
+ const l2 = grad(P[a + 1], x, y - 1) + u * (grad(P[b + 1], x - 1, y - 1) - grad(P[a + 1], x, y - 1));
115
+ return l1 + v * (l2 - l1);
116
+ };
117
+ }
118
+
119
+ // 画布按设备像素比放大,画的时候仍用 CSS 像素坐标
120
+ function fit(cv, w, h) {
121
+ const dpr = Math.min(devicePixelRatio || 1, 2);
122
+ cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
123
+ const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx;
124
+ }
125
+ // 从容器读主题色(--paper / --ink / --dim / --rule / --accent)
126
+ function colors(el) {
127
+ const cs = getComputedStyle(el), v = n => cs.getPropertyValue(n).trim();
128
+ const t = el.closest('[data-theme]');
129
+ return { paper: v('--paper'), ink: v('--ink'), dim: v('--dim'), rule: v('--rule'), accent: v('--accent'), night: !!t && t.dataset.theme === 'night' };
130
+ }
131
+ // 一段时长 ms 的补间,fn(k) 中 k 从 0 到 1;减少动态效果时直接到终点
132
+ const tween = (ms, fn) => new Promise(res => {
133
+ if (still()) { fn(1); return res(); }
134
+ const t0 = performance.now();
135
+ const f = now => { const k = clamp((now - t0) / ms); fn(k); k < 1 ? requestAnimationFrame(f) : res(); };
136
+ requestAnimationFrame(f);
137
+ });
138
+ // 尺寸缩放系数:全屏约 1,缩略图约 0.45,让小画面像“整页缩小”而不是挤满细节
139
+ const scaleOf = W => clamp(W / 1200, .45, 1);
140
+
141
+ // 幽灵光标:没人操作时,沿一条利萨如曲线在容器里慢慢移动,让缩略图自己演示交互
142
+ const ghostAt = (now, W, H, seed = 0) => {
143
+ const t = now / 1000 + seed * 7;
144
+ return { x: W * (.5 + .32 * Math.sin(t * .55) * Math.cos(t * .21)), y: H * (.5 + .3 * Math.sin(t * .83 + 1.3)) };
145
+ };
146
+
147
+ // 效果宿主:(可选)建画布、跟随尺寸、换算局部指针坐标、管动画循环和暂停。
148
+ // setup(h) 返回 { resize(W,H), frame(now), down(x,y), theme(), ghostClick },h 提供 ctx / W / H / P / s / colors()
149
+ // ghostClick: false 表示幽灵光标不替它“点击”(例如片头,点一下会重播)
150
+ function canvasHost(el, opts, setup, { canvas = true } = {}) {
151
+ const cv = canvas ? document.createElement('canvas') : null;
152
+ if (cv) { cv.className = 'mh-canvas'; el.append(cv); }
153
+ let ctx, W = 0, H = 0, raf = 0, running = false, lastReal = -1e9, lastGhostClick = 0;
154
+ const P = { x: -9999, y: -9999, real: false };
155
+ const seed = hash(effects.length + el.childElementCount);
156
+ const h = { cv, get ctx() { return ctx; }, get W() { return W; }, get H() { return H; }, P, s: 1, colors: () => colors(el), expanded: !!opts.expanded };
157
+ const api = setup(h);
158
+ const size = () => {
159
+ const r = el.getBoundingClientRect(); if (!r.width || !r.height) return;
160
+ W = r.width; H = r.height; h.s = scaleOf(W); if (cv) ctx = fit(cv, W, H); api.resize && api.resize(W, H);
161
+ if (!running) api.frame(performance.now());
162
+ };
163
+ const ro = new ResizeObserver(size); ro.observe(el);
164
+ const local = e => { const r = el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
165
+ const onMove = e => { [P.x, P.y] = local(e); P.real = true; lastReal = performance.now(); };
166
+ const onLeave = () => { P.real = false; P.x = P.y = -9999; };
167
+ const onDown = e => { if (e.target.closest('a, button')) return; lastReal = performance.now(); const [x, y] = local(e); api.down && api.down(x, y); };
168
+ el.addEventListener('pointermove', onMove); el.addEventListener('pointerleave', onLeave); el.addEventListener('pointerdown', onDown);
169
+ const mo = new MutationObserver(() => { api.theme && api.theme(); if (!running) api.frame(performance.now()); });
170
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-palette'] });
171
+ const tick = now => {
172
+ if (!running) return;
173
+ // 缩略图里 2.5 秒没人碰,就让幽灵光标接管;偶尔“点”一下展示点击效果
174
+ if (!h.expanded && now - lastReal > 2500) {
175
+ const g = ghostAt(now, W, H, seed); P.x = g.x; P.y = g.y;
176
+ if (api.down && api.ghostClick !== false && now - lastGhostClick > 4200) { lastGhostClick = now; api.down(g.x, g.y); }
177
+ }
178
+ if (W && H) api.frame(now);
179
+ raf = requestAnimationFrame(tick);
180
+ };
181
+ size();
182
+ return {
183
+ pause() { running = false; cancelAnimationFrame(raf); },
184
+ resume() { if (running) return; if (still()) { api.frame(performance.now()); return; } running = true; raf = requestAnimationFrame(tick); },
185
+ destroy() { this.pause(); ro.disconnect(); mo.disconnect(); el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); el.removeEventListener('pointerdown', onDown); cv && cv.remove(); },
186
+ };
187
+ }
188
+
189
+ // 让 CSS 能对自定义属性做补间(遮罩半径等)
190
+ if (window.CSS && CSS.registerProperty) {
191
+ for (const name of ['--vr', '--vw']) { try { CSS.registerProperty({ name, syntax: '<length>', inherits: false, initialValue: '0px' }); } catch (e) { /* 已注册 */ } }
192
+ }
193
+
194
+ return { effects, register, defaults, lines, still, clamp, lerp, ease, hash, perlin, fit, colors, tween, scaleOf, ghostAt, canvasHost };
195
+ })();
196
+ </script>
197
+ <script>
198
+ // 背景动效:画在 canvas 上,尺寸跟随容器,h.s 是缩放系数(全屏约 1,缩略图约 0.45)。
199
+
200
+ // ───── 磁性点阵:每个点有静止位置,被鼠标推开、被弹簧拉回;推得越远越大、越接近强调色;点击发出冲击波 ─────
201
+ function dotField(quiet) {
202
+ return (el, opts) => MH.canvasHost(el, opts, h => {
203
+ let C = h.colors(), dots = [], GAP, R;
204
+ const waves = [];
205
+ return {
206
+ resize(W, H) {
207
+ GAP = Math.max(8, opts.gap * h.s); R = opts.radius * h.s; dots = [];
208
+ const ox = (W % GAP) / 2, oy = (H % GAP) / 2;
209
+ for (let y = oy; y < H; y += GAP) for (let x = ox; x < W; x += GAP) dots.push({ hx: x, hy: y, x, y, vx: 0, vy: 0 });
210
+ },
211
+ theme() { C = h.colors(); },
212
+ down: quiet ? null : (x, y) => { waves.push({ x, y, t0: performance.now() }); if (waves.length > 3) waves.shift(); },
213
+ frame(now) {
214
+ const { ctx, W, H, P, s } = h, t = now / 1000, live = waves.filter(w => now - w.t0 < 1800);
215
+ const push = quiet ? 1.6 : 3.2, base = new Path2D(), hot = new Path2D();
216
+ for (const d of dots) {
217
+ const dx = d.x - P.x, dy = d.y - P.y, dist = Math.hypot(dx, dy);
218
+ if (dist < R && dist > .1) { const f = (1 - dist / R) ** 2 * push * s; d.vx += dx / dist * f; d.vy += dy / dist * f; }
219
+ for (const w of live) {
220
+ const age = (now - w.t0) / 1000, wx = d.hx - w.x, wy = d.hy - w.y, wd = Math.hypot(wx, wy), front = age * 700 * s;
221
+ const g = Math.exp(-(((wd - front) / (40 * s)) ** 2)) * (1 - age / 1.8) * 1.3 * s;
222
+ if (g > .01 && wd > 1) { d.vx += wx / wd * g; d.vy += wy / wd * g; }
223
+ }
224
+ d.vx += (d.hx - d.x) * opts.spring; d.vy += (d.hy - d.y) * opts.spring; d.vx *= opts.damping; d.vy *= opts.damping; d.x += d.vx; d.y += d.vy;
225
+ const off = Math.hypot(d.x - d.hx, d.y - d.hy) / s;
226
+ const breathe = quiet ? 0 : Math.sin(d.hx * .012 / s + d.hy * .008 / s - t * 1.2) * .25;
227
+ const r = Math.min(1.1 + breathe + off * (quiet ? .06 : .09), quiet ? 2.4 : 3.6) * Math.max(.7, s);
228
+ const p = off > (quiet ? 6 : 7) ? hot : base;
229
+ p.moveTo(d.x + r, d.y); p.arc(d.x, d.y, r, 0, Math.PI * 2);
230
+ }
231
+ ctx.clearRect(0, 0, W, H);
232
+ ctx.fillStyle = C.dim; ctx.globalAlpha = quiet ? (C.night ? .26 : .3) : (C.night ? .38 : .42); ctx.fill(base);
233
+ ctx.fillStyle = C.accent; ctx.globalAlpha = quiet ? .6 : .85; ctx.fill(hot); ctx.globalAlpha = 1;
234
+ },
235
+ };
236
+ });
237
+ }
238
+ // 可调参数:点间距、推开半径、弹簧刚度(拉回的力)、阻尼(每帧保留的速度,越大越“弹”)
239
+ const dotParams = radius => [
240
+ { k: 'gap', label: '点间距', type: 'range', min: 12, max: 60, step: 1, def: 26, unit: 'px' },
241
+ { k: 'radius', label: '推开半径', type: 'range', min: 40, max: 320, step: 5, def: radius, unit: 'px' },
242
+ { k: 'spring', label: '弹簧刚度', type: 'range', min: 0.01, max: 0.2, step: 0.005, def: 0.06 },
243
+ { k: 'damping', label: '阻尼', type: 'range', min: 0.6, max: 0.95, step: 0.01, def: 0.82 },
244
+ ];
245
+ MH.register({ id: 'dots', name: '磁性点阵', cat: '背景', tech: 'Canvas 2D · Spring-Damper Physics', desc: '整屏点阵被鼠标推开再弹回,推开的点变大、变成强调色;点击发出一圈冲击波。', params: dotParams(150), mount: dotField(false) });
246
+ MH.register({ id: 'dots-quiet', name: '安静点阵', cat: '背景', tech: 'Canvas 2D · Spring-Damper Physics', params: dotParams(110), desc: '同一套点阵的阅读版:点更淡、推开的范围和力度更小,没有冲击波,适合长文页面。', mount: dotField(true) });
247
+ </script>
248
+ <script>
249
+ MH.effects.find(e => e.id === 'dots-quiet').mount(document.getElementById('fx'), {
250
+ "gap": 26,
251
+ "radius": 110,
252
+ "spring": 0.06,
253
+ "damping": 0.82
254
+ }).resume();
255
+ </script>
256
+ </body>
257
+ </html>