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.
- package/LICENSE +44 -0
- package/README.md +141 -0
- package/bin/install-skill.mjs +39 -0
- package/package.json +48 -0
- package/skill/motion-hub/SKILL.md +127 -0
- package/skill/motion-hub/effects/contour.html +279 -0
- package/skill/motion-hub/effects/dots-quiet.html +257 -0
- package/skill/motion-hub/effects/dots.html +263 -0
- package/skill/motion-hub/effects/flow.html +253 -0
- package/skill/motion-hub/effects/follow-tip.html +241 -0
- package/skill/motion-hub/effects/glow-card.html +236 -0
- package/skill/motion-hub/effects/grid.html +266 -0
- package/skill/motion-hub/effects/intro.html +302 -0
- package/skill/motion-hub/effects/pipeline.html +257 -0
- package/skill/motion-hub/effects/pull.html +373 -0
- package/skill/motion-hub/effects/scramble.html +248 -0
- package/skill/motion-hub/effects/sun-moon.html +235 -0
- package/skill/motion-hub/effects/theme-blinds.html +381 -0
- package/skill/motion-hub/effects/theme-circle.html +381 -0
- package/skill/motion-hub/effects/theme-dots.html +381 -0
- package/skill/motion-hub/effects/theme-fade.html +381 -0
- package/skill/motion-hub/effects/theme-flip.html +381 -0
- package/skill/motion-hub/effects/theme-halftone.html +381 -0
- package/skill/motion-hub/effects/theme-ink.html +381 -0
- package/skill/motion-hub/effects/theme-iris.html +381 -0
- package/skill/motion-hub/effects/theme-slice.html +381 -0
- package/skill/motion-hub/effects/theme-soft.html +381 -0
- package/skill/motion-hub/effects/theme-wave.html +381 -0
- package/skill/motion-hub/index.json +667 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
「坐标网格」 · Motion Hub · MIT + Commons Clause(可免费用于任何项目,不能售卖效果本身)
|
|
4
|
+
|
|
5
|
+
【怎么用】
|
|
6
|
+
这个文件可以直接双击打开,先看效果。
|
|
7
|
+
想放进自己的项目:把整个文件交给 AI(Claude / ChatGPT / Cursor 等),再说一句你的技术栈和想要的调整,
|
|
8
|
+
例如“把这个效果改写成 React 组件,放在首屏当背景,颜色换成我的品牌色”。下面的说明就是写给它看的。
|
|
9
|
+
|
|
10
|
+
【效果】
|
|
11
|
+
坐标纸背景(细格加每 4 格一条粗线)。鼠标拖出十字准线和实时坐标;经过的格子四角亮起红色角标,再慢慢暗下去。
|
|
12
|
+
|
|
13
|
+
【实现原理】
|
|
14
|
+
- 网格线分两遍画:细线一种透明度,粗线另一种。
|
|
15
|
+
- 沿上一帧到这一帧的鼠标路径,按半格步长插值,把经过的格子热度设为 1,快速划过也不会跳格。
|
|
16
|
+
- 每帧热度乘 0.955 衰减,按热度画四个 L 形角标(长度和透明度都随热度变化)。
|
|
17
|
+
- 最后画十字准线和坐标读数。
|
|
18
|
+
|
|
19
|
+
【可以调的参数】
|
|
20
|
+
- 格子边长 40px
|
|
21
|
+
- 粗线间隔 4 格
|
|
22
|
+
- 热度衰减 0.955
|
|
23
|
+
- 角标长度 6 + 4 × 热度
|
|
24
|
+
|
|
25
|
+
【注意】
|
|
26
|
+
- 线条坐标加 0.5px 对齐像素,1px 线才清晰。
|
|
27
|
+
- 颜色都来自 CSS 变量(paper / ink / dim / rule / accent),换成项目自己的颜色即可。
|
|
28
|
+
- 要处理 prefers-reduced-motion:系统要求减少动态效果时只显示静止画面。
|
|
29
|
+
- 组件卸载时要停掉动画循环、移除事件监听(mount 返回的 destroy)。
|
|
30
|
+
|
|
31
|
+
【opts 参数】(mount 的第二个参数,不传就用默认值;文件末尾的 mount 调用里是你在 Motion Hub 上调好的值)
|
|
32
|
+
- size:格子大小,默认 40(16–96px)
|
|
33
|
+
- major:粗线间隔,默认 4(2–8格)
|
|
34
|
+
- decay:余热保留,默认 0.955(0.85–0.99)
|
|
35
|
+
|
|
36
|
+
【代码结构】
|
|
37
|
+
- <style>:配色变量和这个效果用到的样式
|
|
38
|
+
- 第一段 <script>:MH 核心(注册表、噪声、缓动;canvasHost 负责画布尺寸、指针坐标、动画循环和暂停)
|
|
39
|
+
- 第二段 <script>:效果本身,核心是 MH.register 里的 mount(el, opts),返回 { pause, resume, destroy }
|
|
40
|
+
- 最后一段 <script>:把效果挂到 #fx 上并启动
|
|
41
|
+
-->
|
|
42
|
+
<html lang="zh-CN">
|
|
43
|
+
<head>
|
|
44
|
+
<meta charset="utf-8">
|
|
45
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
46
|
+
<title>坐标网格</title>
|
|
47
|
+
<style>
|
|
48
|
+
/* Motion Hub 效果样式:配色变量(Acid:浅色暖灰 + 紫罗兰,深色近黑 + 酸橙绿)+ 各效果用到的样式 + 整页过渡遮罩。按“@css 名称”标记分段,复制代码时只带用到的段。 */
|
|
49
|
+
:root{
|
|
50
|
+
--paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E;
|
|
51
|
+
--serif:"Songti SC","STSong","Noto Serif SC",serif;
|
|
52
|
+
--sans:"PingFang SC","Helvetica Neue","Noto Sans SC",sans-serif;
|
|
53
|
+
--latin:"Iowan Old Style",Palatino,Georgia,serif;
|
|
54
|
+
--mono:"SF Mono",Menlo,monospace;
|
|
55
|
+
}
|
|
56
|
+
[data-theme="night"]{--paper:#111113;--paper2:#1B1B1F;--ink:#F2F1F5;--body:#C4C2CC;--dim:#7E7B88;--rule:#2A2A30;--accent:#C6F21E;--on-accent:#111113;--hi:#8E75FF}
|
|
57
|
+
[data-theme="day"]{--paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E}
|
|
58
|
+
|
|
59
|
+
/* ───── 效果共用 ───── */
|
|
60
|
+
.mh-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
|
|
61
|
+
html,body{height:100%;margin:0;background:var(--paper)}
|
|
62
|
+
#fx{position:relative;height:100vh;overflow:hidden;container-type:size}
|
|
63
|
+
</style>
|
|
64
|
+
</head>
|
|
65
|
+
<body>
|
|
66
|
+
<div id="fx"></div>
|
|
67
|
+
<script>
|
|
68
|
+
// Motion Hub 核心:注册表 + 公共工具。所有效果文件都是普通 <script>(不是 ES 模块),
|
|
69
|
+
// 这样直接双击 index.html(file://)也能运行。每个效果调用 MH.register({...}) 把自己登记进来。
|
|
70
|
+
//
|
|
71
|
+
// 效果对象的约定:
|
|
72
|
+
// id 唯一标识(小写短横线)
|
|
73
|
+
// name 中文名
|
|
74
|
+
// cat 分类:背景 / 过渡 / 翻页 / 片头 / 交互
|
|
75
|
+
// tech 用到的技术(展示用)
|
|
76
|
+
// desc 一句话说明
|
|
77
|
+
// mount(el, opts) → { pause(), resume(), destroy() }
|
|
78
|
+
// el 是一个已经有尺寸的容器;opts.expanded 为 true 表示全屏体验(关掉幽灵光标、允许滚轮等)
|
|
79
|
+
// params 可调参数(可选),实验台据此生成参数面板,每项 { k, label, type, def, ... }:
|
|
80
|
+
// type 'range':数值滑块,带 min / max / step,可选 unit;'text':单行文字;'lines':多行文字,一行一项
|
|
81
|
+
// mount 拿到的 opts 已经按 def 补齐(register 里包了一层),效果代码直接读 opts.k 即可
|
|
82
|
+
window.MH = (() => {
|
|
83
|
+
const effects = [];
|
|
84
|
+
// 记下效果来自哪个文件(复制代码时只带这个文件)
|
|
85
|
+
const register = e => {
|
|
86
|
+
const src = document.currentScript && document.currentScript.src; e.file = e.file || (src ? src.split('/').pop() : '');
|
|
87
|
+
if (e.params) { const m = e.mount; e.mount = (el, opts = {}) => m.call(e, el, { ...defaults(e), ...opts }); }
|
|
88
|
+
effects.push(e);
|
|
89
|
+
};
|
|
90
|
+
const defaults = e => Object.fromEntries((e.params || []).map(p => [p.k, p.def]));
|
|
91
|
+
// 'lines' 参数:数组原样返回;字符串按行拆开,去掉空行
|
|
92
|
+
const lines = v => Array.isArray(v) ? v : String(v || '').split('\n').map(x => x.trim()).filter(Boolean);
|
|
93
|
+
const still = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
94
|
+
const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
|
|
95
|
+
const lerp = (a, b, k) => a + (b - a) * k;
|
|
96
|
+
const ease = {
|
|
97
|
+
in: t => t * t * t,
|
|
98
|
+
out: t => 1 - (1 - t) ** 3,
|
|
99
|
+
inOut: t => t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2,
|
|
100
|
+
back: t => { const c = 1.4; return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2; },
|
|
101
|
+
};
|
|
102
|
+
const hash = i => { const x = Math.sin(i * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
|
|
103
|
+
|
|
104
|
+
// 二维 Perlin 噪声,seed 决定排列表
|
|
105
|
+
function perlin(seed = 1) {
|
|
106
|
+
const P = new Uint8Array(512), p = [...Array(256).keys()];
|
|
107
|
+
let s = seed;
|
|
108
|
+
for (let i = 255; i > 0; i--) { s = (s * 16807) % 2147483647; const j = s % (i + 1); [p[i], p[j]] = [p[j], p[i]]; }
|
|
109
|
+
for (let i = 0; i < 512; i++) P[i] = p[i & 255];
|
|
110
|
+
const fade = t => t * t * t * (t * (t * 6 - 15) + 10), grad = (h, x, y) => ((h & 1) ? -x : x) + ((h & 2) ? -y : y);
|
|
111
|
+
return (x, y) => {
|
|
112
|
+
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255; x -= Math.floor(x); y -= Math.floor(y);
|
|
113
|
+
const u = fade(x), v = fade(y), a = P[X] + Y, b = P[X + 1] + Y;
|
|
114
|
+
const l1 = grad(P[a], x, y) + u * (grad(P[b], x - 1, y) - grad(P[a], x, y));
|
|
115
|
+
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));
|
|
116
|
+
return l1 + v * (l2 - l1);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 画布按设备像素比放大,画的时候仍用 CSS 像素坐标
|
|
121
|
+
function fit(cv, w, h) {
|
|
122
|
+
const dpr = Math.min(devicePixelRatio || 1, 2);
|
|
123
|
+
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
|
|
124
|
+
const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx;
|
|
125
|
+
}
|
|
126
|
+
// 从容器读主题色(--paper / --ink / --dim / --rule / --accent)
|
|
127
|
+
function colors(el) {
|
|
128
|
+
const cs = getComputedStyle(el), v = n => cs.getPropertyValue(n).trim();
|
|
129
|
+
const t = el.closest('[data-theme]');
|
|
130
|
+
return { paper: v('--paper'), ink: v('--ink'), dim: v('--dim'), rule: v('--rule'), accent: v('--accent'), night: !!t && t.dataset.theme === 'night' };
|
|
131
|
+
}
|
|
132
|
+
// 一段时长 ms 的补间,fn(k) 中 k 从 0 到 1;减少动态效果时直接到终点
|
|
133
|
+
const tween = (ms, fn) => new Promise(res => {
|
|
134
|
+
if (still()) { fn(1); return res(); }
|
|
135
|
+
const t0 = performance.now();
|
|
136
|
+
const f = now => { const k = clamp((now - t0) / ms); fn(k); k < 1 ? requestAnimationFrame(f) : res(); };
|
|
137
|
+
requestAnimationFrame(f);
|
|
138
|
+
});
|
|
139
|
+
// 尺寸缩放系数:全屏约 1,缩略图约 0.45,让小画面像“整页缩小”而不是挤满细节
|
|
140
|
+
const scaleOf = W => clamp(W / 1200, .45, 1);
|
|
141
|
+
|
|
142
|
+
// 幽灵光标:没人操作时,沿一条利萨如曲线在容器里慢慢移动,让缩略图自己演示交互
|
|
143
|
+
const ghostAt = (now, W, H, seed = 0) => {
|
|
144
|
+
const t = now / 1000 + seed * 7;
|
|
145
|
+
return { x: W * (.5 + .32 * Math.sin(t * .55) * Math.cos(t * .21)), y: H * (.5 + .3 * Math.sin(t * .83 + 1.3)) };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// 效果宿主:(可选)建画布、跟随尺寸、换算局部指针坐标、管动画循环和暂停。
|
|
149
|
+
// setup(h) 返回 { resize(W,H), frame(now), down(x,y), theme(), ghostClick },h 提供 ctx / W / H / P / s / colors()
|
|
150
|
+
// ghostClick: false 表示幽灵光标不替它“点击”(例如片头,点一下会重播)
|
|
151
|
+
function canvasHost(el, opts, setup, { canvas = true } = {}) {
|
|
152
|
+
const cv = canvas ? document.createElement('canvas') : null;
|
|
153
|
+
if (cv) { cv.className = 'mh-canvas'; el.append(cv); }
|
|
154
|
+
let ctx, W = 0, H = 0, raf = 0, running = false, lastReal = -1e9, lastGhostClick = 0;
|
|
155
|
+
const P = { x: -9999, y: -9999, real: false };
|
|
156
|
+
const seed = hash(effects.length + el.childElementCount);
|
|
157
|
+
const h = { cv, get ctx() { return ctx; }, get W() { return W; }, get H() { return H; }, P, s: 1, colors: () => colors(el), expanded: !!opts.expanded };
|
|
158
|
+
const api = setup(h);
|
|
159
|
+
const size = () => {
|
|
160
|
+
const r = el.getBoundingClientRect(); if (!r.width || !r.height) return;
|
|
161
|
+
W = r.width; H = r.height; h.s = scaleOf(W); if (cv) ctx = fit(cv, W, H); api.resize && api.resize(W, H);
|
|
162
|
+
if (!running) api.frame(performance.now());
|
|
163
|
+
};
|
|
164
|
+
const ro = new ResizeObserver(size); ro.observe(el);
|
|
165
|
+
const local = e => { const r = el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
|
|
166
|
+
const onMove = e => { [P.x, P.y] = local(e); P.real = true; lastReal = performance.now(); };
|
|
167
|
+
const onLeave = () => { P.real = false; P.x = P.y = -9999; };
|
|
168
|
+
const onDown = e => { if (e.target.closest('a, button')) return; lastReal = performance.now(); const [x, y] = local(e); api.down && api.down(x, y); };
|
|
169
|
+
el.addEventListener('pointermove', onMove); el.addEventListener('pointerleave', onLeave); el.addEventListener('pointerdown', onDown);
|
|
170
|
+
const mo = new MutationObserver(() => { api.theme && api.theme(); if (!running) api.frame(performance.now()); });
|
|
171
|
+
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-palette'] });
|
|
172
|
+
const tick = now => {
|
|
173
|
+
if (!running) return;
|
|
174
|
+
// 缩略图里 2.5 秒没人碰,就让幽灵光标接管;偶尔“点”一下展示点击效果
|
|
175
|
+
if (!h.expanded && now - lastReal > 2500) {
|
|
176
|
+
const g = ghostAt(now, W, H, seed); P.x = g.x; P.y = g.y;
|
|
177
|
+
if (api.down && api.ghostClick !== false && now - lastGhostClick > 4200) { lastGhostClick = now; api.down(g.x, g.y); }
|
|
178
|
+
}
|
|
179
|
+
if (W && H) api.frame(now);
|
|
180
|
+
raf = requestAnimationFrame(tick);
|
|
181
|
+
};
|
|
182
|
+
size();
|
|
183
|
+
return {
|
|
184
|
+
pause() { running = false; cancelAnimationFrame(raf); },
|
|
185
|
+
resume() { if (running) return; if (still()) { api.frame(performance.now()); return; } running = true; raf = requestAnimationFrame(tick); },
|
|
186
|
+
destroy() { this.pause(); ro.disconnect(); mo.disconnect(); el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); el.removeEventListener('pointerdown', onDown); cv && cv.remove(); },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 让 CSS 能对自定义属性做补间(遮罩半径等)
|
|
191
|
+
if (window.CSS && CSS.registerProperty) {
|
|
192
|
+
for (const name of ['--vr', '--vw']) { try { CSS.registerProperty({ name, syntax: '<length>', inherits: false, initialValue: '0px' }); } catch (e) { /* 已注册 */ } }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { effects, register, defaults, lines, still, clamp, lerp, ease, hash, perlin, fit, colors, tween, scaleOf, ghostAt, canvasHost };
|
|
196
|
+
})();
|
|
197
|
+
</script>
|
|
198
|
+
<script>
|
|
199
|
+
// 背景动效:画在 canvas 上,尺寸跟随容器,h.s 是缩放系数(全屏约 1,缩略图约 0.45)。
|
|
200
|
+
|
|
201
|
+
// ───── 坐标网格:坐标纸 + 十字准线 + 坐标读数;鼠标经过的格子“加热”,四角亮起角标后慢慢冷却 ─────
|
|
202
|
+
MH.register({
|
|
203
|
+
id: 'grid', name: '坐标网格', cat: '背景', tech: 'Canvas 2D · Heat Decay',
|
|
204
|
+
desc: '坐标纸背景,鼠标拖出十字准线和实时坐标;经过的格子四角亮起强调色角标,再慢慢暗下去。',
|
|
205
|
+
params: [
|
|
206
|
+
{ k: 'size', label: '格子大小', type: 'range', min: 16, max: 96, step: 2, def: 40, unit: 'px' },
|
|
207
|
+
{ k: 'major', label: '粗线间隔', type: 'range', min: 2, max: 8, step: 1, def: 4, unit: '格' },
|
|
208
|
+
{ k: 'decay', label: '余热保留', type: 'range', min: 0.85, max: 0.99, step: 0.005, def: 0.955 },
|
|
209
|
+
],
|
|
210
|
+
mount: (el, opts) => MH.canvasHost(el, opts, h => {
|
|
211
|
+
let C = h.colors(), S, cols, rows, heat;
|
|
212
|
+
const m = { px: -999, py: -999 };
|
|
213
|
+
return {
|
|
214
|
+
resize(W, H) { S = Math.max(10, opts.size * h.s); cols = Math.ceil(W / S) + 1; rows = Math.ceil(H / S) + 1; heat = new Float32Array(cols * rows); },
|
|
215
|
+
theme() { C = h.colors(); },
|
|
216
|
+
frame() {
|
|
217
|
+
const { ctx, W, H, P, s } = h, on = P.x > -999;
|
|
218
|
+
ctx.clearRect(0, 0, W, H); ctx.strokeStyle = C.dim; ctx.lineWidth = 1;
|
|
219
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
220
|
+
ctx.globalAlpha = pass ? (C.night ? .22 : .26) : (C.night ? .07 : .09); ctx.beginPath();
|
|
221
|
+
for (let i = 0; i < cols; i++) if ((i % opts.major === 0) === !!pass) { ctx.moveTo(i * S + .5, 0); ctx.lineTo(i * S + .5, H); }
|
|
222
|
+
for (let j = 0; j < rows; j++) if ((j % opts.major === 0) === !!pass) { ctx.moveTo(0, j * S + .5); ctx.lineTo(W, j * S + .5); }
|
|
223
|
+
ctx.stroke();
|
|
224
|
+
}
|
|
225
|
+
if (on && m.px > -999) { // 沿移动路径加热,快速划过也不跳格
|
|
226
|
+
const steps = Math.max(1, Math.ceil(Math.hypot(P.x - m.px, P.y - m.py) / (S / 2)));
|
|
227
|
+
for (let k = 0; k <= steps; k++) {
|
|
228
|
+
const i = Math.floor((m.px + (P.x - m.px) * k / steps) / S), j = Math.floor((m.py + (P.y - m.py) * k / steps) / S);
|
|
229
|
+
if (i >= 0 && i < cols && j >= 0 && j < rows) heat[j * cols + i] = 1;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
m.px = on ? P.x : -999; m.py = on ? P.y : -999;
|
|
233
|
+
ctx.strokeStyle = C.accent; ctx.lineWidth = 1.5;
|
|
234
|
+
for (let j = 0; j < rows; j++) for (let i = 0; i < cols; i++) {
|
|
235
|
+
const v = heat[j * cols + i]; if (v < .02) continue;
|
|
236
|
+
heat[j * cols + i] = v * opts.decay;
|
|
237
|
+
const x = i * S, y = j * S, L = (6 + v * 4) * s;
|
|
238
|
+
ctx.globalAlpha = v * .9; ctx.beginPath();
|
|
239
|
+
ctx.moveTo(x, y + L); ctx.lineTo(x, y); ctx.lineTo(x + L, y);
|
|
240
|
+
ctx.moveTo(x + S - L, y); ctx.lineTo(x + S, y); ctx.lineTo(x + S, y + L);
|
|
241
|
+
ctx.moveTo(x + S, y + S - L); ctx.lineTo(x + S, y + S); ctx.lineTo(x + S - L, y + S);
|
|
242
|
+
ctx.moveTo(x + L, y + S); ctx.lineTo(x, y + S); ctx.lineTo(x, y + S - L);
|
|
243
|
+
ctx.stroke();
|
|
244
|
+
}
|
|
245
|
+
if (on) {
|
|
246
|
+
ctx.globalAlpha = C.night ? .45 : .5; ctx.lineWidth = 1; ctx.beginPath();
|
|
247
|
+
ctx.moveTo(0, Math.round(P.y) + .5); ctx.lineTo(W, Math.round(P.y) + .5);
|
|
248
|
+
ctx.moveTo(Math.round(P.x) + .5, 0); ctx.lineTo(Math.round(P.x) + .5, H); ctx.stroke();
|
|
249
|
+
ctx.globalAlpha = .9; ctx.fillStyle = C.accent; ctx.font = `500 ${Math.max(9, 11 * s)}px "SF Mono",Menlo,monospace`;
|
|
250
|
+
ctx.fillText(`${Math.round(P.x)}, ${Math.round(P.y)}`, P.x + 10, P.y - 10);
|
|
251
|
+
}
|
|
252
|
+
ctx.globalAlpha = 1;
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
257
|
+
</script>
|
|
258
|
+
<script>
|
|
259
|
+
MH.effects.find(e => e.id === 'grid').mount(document.getElementById('fx'), {
|
|
260
|
+
"size": 40,
|
|
261
|
+
"major": 4,
|
|
262
|
+
"decay": 0.955
|
|
263
|
+
}).resume();
|
|
264
|
+
</script>
|
|
265
|
+
</body>
|
|
266
|
+
</html>
|
|
@@ -0,0 +1,302 @@
|
|
|
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
|
+
- 整个画面是时间的纯函数 frame(t):同一个 t 永远画出同一帧,所以能随时跳到结尾、重播,或按任意尺寸重画最后一帧。
|
|
15
|
+
- 把文字画到离屏 canvas 上(fillText 再加 strokeText 加粗细笔画),用 getImageData 当遮罩采样。
|
|
16
|
+
- 落在字形里的网格点后来变成强调色;字形里的网格缝隙(错开半格)作为飞入点的目标。
|
|
17
|
+
- 飞入点从屏幕外随机角度出发,沿二次贝塞尔弧线飞到目标,延迟按哈希错开。
|
|
18
|
+
- 波纹是几圈按时间外扩的高斯环,把经过的点往外推一下并放大。
|
|
19
|
+
|
|
20
|
+
【可以调的参数】
|
|
21
|
+
- 总时长 6.8s,定格 2.6s 后循环
|
|
22
|
+
- 网格间距 22px
|
|
23
|
+
- 字号 min(H × 0.42, W × 0.3)
|
|
24
|
+
- 字幕时间 3.4s 起每 0.65s 一个
|
|
25
|
+
|
|
26
|
+
【注意】
|
|
27
|
+
- 衬线字的细笔画只有一两个点宽,必须描边加粗,点阵字才压得住。
|
|
28
|
+
- 想在首页用:播完后把最后一帧留作背景,再把同一批点交给鼠标交互(推开、回弹)。
|
|
29
|
+
- 颜色都来自 CSS 变量(paper / ink / dim / rule / accent),换成项目自己的颜色即可。
|
|
30
|
+
- 要处理 prefers-reduced-motion:系统要求减少动态效果时只显示静止画面。
|
|
31
|
+
- 组件卸载时要停掉动画循环、移除事件监听(mount 返回的 destroy)。
|
|
32
|
+
|
|
33
|
+
【opts 参数】(mount 的第二个参数,不传就用默认值;文件末尾的 mount 调用里是你在 Motion Hub 上调好的值)
|
|
34
|
+
- text:拼出的文字,默认 "LJY"
|
|
35
|
+
- captions:字幕(一行一条),默认 "背景\n过渡\n翻页\n交互"
|
|
36
|
+
- gap:点间距,默认 22(10–40px)
|
|
37
|
+
|
|
38
|
+
【代码结构】
|
|
39
|
+
- <style>:配色变量和这个效果用到的样式
|
|
40
|
+
- 第一段 <script>:MH 核心(注册表、噪声、缓动;canvasHost 负责画布尺寸、指针坐标、动画循环和暂停)
|
|
41
|
+
- 第二段 <script>:效果本身,核心是 MH.register 里的 mount(el, opts),返回 { pause, resume, destroy }
|
|
42
|
+
- 最后一段 <script>:把效果挂到 #fx 上并启动
|
|
43
|
+
-->
|
|
44
|
+
<html lang="zh-CN">
|
|
45
|
+
<head>
|
|
46
|
+
<meta charset="utf-8">
|
|
47
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
48
|
+
<title>点阵拼字片头</title>
|
|
49
|
+
<style>
|
|
50
|
+
/* Motion Hub 效果样式:配色变量(Acid:浅色暖灰 + 紫罗兰,深色近黑 + 酸橙绿)+ 各效果用到的样式 + 整页过渡遮罩。按“@css 名称”标记分段,复制代码时只带用到的段。 */
|
|
51
|
+
:root{
|
|
52
|
+
--paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E;
|
|
53
|
+
--serif:"Songti SC","STSong","Noto Serif SC",serif;
|
|
54
|
+
--sans:"PingFang SC","Helvetica Neue","Noto Sans SC",sans-serif;
|
|
55
|
+
--latin:"Iowan Old Style",Palatino,Georgia,serif;
|
|
56
|
+
--mono:"SF Mono",Menlo,monospace;
|
|
57
|
+
}
|
|
58
|
+
[data-theme="night"]{--paper:#111113;--paper2:#1B1B1F;--ink:#F2F1F5;--body:#C4C2CC;--dim:#7E7B88;--rule:#2A2A30;--accent:#C6F21E;--on-accent:#111113;--hi:#8E75FF}
|
|
59
|
+
[data-theme="day"]{--paper:#F1F0EC;--paper2:#E4E2DC;--ink:#141316;--body:#3A383F;--dim:#85828C;--rule:#D6D3CC;--accent:#6B4EFF;--on-accent:#fff;--hi:#C6F21E}
|
|
60
|
+
|
|
61
|
+
/* ───── 效果共用 ───── */
|
|
62
|
+
.mh-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
|
|
63
|
+
html,body{height:100%;margin:0;background:var(--paper)}
|
|
64
|
+
#fx{position:relative;height:100vh;overflow:hidden;container-type:size}
|
|
65
|
+
</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<div id="fx"></div>
|
|
69
|
+
<script>
|
|
70
|
+
// Motion Hub 核心:注册表 + 公共工具。所有效果文件都是普通 <script>(不是 ES 模块),
|
|
71
|
+
// 这样直接双击 index.html(file://)也能运行。每个效果调用 MH.register({...}) 把自己登记进来。
|
|
72
|
+
//
|
|
73
|
+
// 效果对象的约定:
|
|
74
|
+
// id 唯一标识(小写短横线)
|
|
75
|
+
// name 中文名
|
|
76
|
+
// cat 分类:背景 / 过渡 / 翻页 / 片头 / 交互
|
|
77
|
+
// tech 用到的技术(展示用)
|
|
78
|
+
// desc 一句话说明
|
|
79
|
+
// mount(el, opts) → { pause(), resume(), destroy() }
|
|
80
|
+
// el 是一个已经有尺寸的容器;opts.expanded 为 true 表示全屏体验(关掉幽灵光标、允许滚轮等)
|
|
81
|
+
// params 可调参数(可选),实验台据此生成参数面板,每项 { k, label, type, def, ... }:
|
|
82
|
+
// type 'range':数值滑块,带 min / max / step,可选 unit;'text':单行文字;'lines':多行文字,一行一项
|
|
83
|
+
// mount 拿到的 opts 已经按 def 补齐(register 里包了一层),效果代码直接读 opts.k 即可
|
|
84
|
+
window.MH = (() => {
|
|
85
|
+
const effects = [];
|
|
86
|
+
// 记下效果来自哪个文件(复制代码时只带这个文件)
|
|
87
|
+
const register = e => {
|
|
88
|
+
const src = document.currentScript && document.currentScript.src; e.file = e.file || (src ? src.split('/').pop() : '');
|
|
89
|
+
if (e.params) { const m = e.mount; e.mount = (el, opts = {}) => m.call(e, el, { ...defaults(e), ...opts }); }
|
|
90
|
+
effects.push(e);
|
|
91
|
+
};
|
|
92
|
+
const defaults = e => Object.fromEntries((e.params || []).map(p => [p.k, p.def]));
|
|
93
|
+
// 'lines' 参数:数组原样返回;字符串按行拆开,去掉空行
|
|
94
|
+
const lines = v => Array.isArray(v) ? v : String(v || '').split('\n').map(x => x.trim()).filter(Boolean);
|
|
95
|
+
const still = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
96
|
+
const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
|
|
97
|
+
const lerp = (a, b, k) => a + (b - a) * k;
|
|
98
|
+
const ease = {
|
|
99
|
+
in: t => t * t * t,
|
|
100
|
+
out: t => 1 - (1 - t) ** 3,
|
|
101
|
+
inOut: t => t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2,
|
|
102
|
+
back: t => { const c = 1.4; return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2; },
|
|
103
|
+
};
|
|
104
|
+
const hash = i => { const x = Math.sin(i * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
|
|
105
|
+
|
|
106
|
+
// 二维 Perlin 噪声,seed 决定排列表
|
|
107
|
+
function perlin(seed = 1) {
|
|
108
|
+
const P = new Uint8Array(512), p = [...Array(256).keys()];
|
|
109
|
+
let s = seed;
|
|
110
|
+
for (let i = 255; i > 0; i--) { s = (s * 16807) % 2147483647; const j = s % (i + 1); [p[i], p[j]] = [p[j], p[i]]; }
|
|
111
|
+
for (let i = 0; i < 512; i++) P[i] = p[i & 255];
|
|
112
|
+
const fade = t => t * t * t * (t * (t * 6 - 15) + 10), grad = (h, x, y) => ((h & 1) ? -x : x) + ((h & 2) ? -y : y);
|
|
113
|
+
return (x, y) => {
|
|
114
|
+
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255; x -= Math.floor(x); y -= Math.floor(y);
|
|
115
|
+
const u = fade(x), v = fade(y), a = P[X] + Y, b = P[X + 1] + Y;
|
|
116
|
+
const l1 = grad(P[a], x, y) + u * (grad(P[b], x - 1, y) - grad(P[a], x, y));
|
|
117
|
+
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));
|
|
118
|
+
return l1 + v * (l2 - l1);
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 画布按设备像素比放大,画的时候仍用 CSS 像素坐标
|
|
123
|
+
function fit(cv, w, h) {
|
|
124
|
+
const dpr = Math.min(devicePixelRatio || 1, 2);
|
|
125
|
+
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
|
|
126
|
+
const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx;
|
|
127
|
+
}
|
|
128
|
+
// 从容器读主题色(--paper / --ink / --dim / --rule / --accent)
|
|
129
|
+
function colors(el) {
|
|
130
|
+
const cs = getComputedStyle(el), v = n => cs.getPropertyValue(n).trim();
|
|
131
|
+
const t = el.closest('[data-theme]');
|
|
132
|
+
return { paper: v('--paper'), ink: v('--ink'), dim: v('--dim'), rule: v('--rule'), accent: v('--accent'), night: !!t && t.dataset.theme === 'night' };
|
|
133
|
+
}
|
|
134
|
+
// 一段时长 ms 的补间,fn(k) 中 k 从 0 到 1;减少动态效果时直接到终点
|
|
135
|
+
const tween = (ms, fn) => new Promise(res => {
|
|
136
|
+
if (still()) { fn(1); return res(); }
|
|
137
|
+
const t0 = performance.now();
|
|
138
|
+
const f = now => { const k = clamp((now - t0) / ms); fn(k); k < 1 ? requestAnimationFrame(f) : res(); };
|
|
139
|
+
requestAnimationFrame(f);
|
|
140
|
+
});
|
|
141
|
+
// 尺寸缩放系数:全屏约 1,缩略图约 0.45,让小画面像“整页缩小”而不是挤满细节
|
|
142
|
+
const scaleOf = W => clamp(W / 1200, .45, 1);
|
|
143
|
+
|
|
144
|
+
// 幽灵光标:没人操作时,沿一条利萨如曲线在容器里慢慢移动,让缩略图自己演示交互
|
|
145
|
+
const ghostAt = (now, W, H, seed = 0) => {
|
|
146
|
+
const t = now / 1000 + seed * 7;
|
|
147
|
+
return { x: W * (.5 + .32 * Math.sin(t * .55) * Math.cos(t * .21)), y: H * (.5 + .3 * Math.sin(t * .83 + 1.3)) };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// 效果宿主:(可选)建画布、跟随尺寸、换算局部指针坐标、管动画循环和暂停。
|
|
151
|
+
// setup(h) 返回 { resize(W,H), frame(now), down(x,y), theme(), ghostClick },h 提供 ctx / W / H / P / s / colors()
|
|
152
|
+
// ghostClick: false 表示幽灵光标不替它“点击”(例如片头,点一下会重播)
|
|
153
|
+
function canvasHost(el, opts, setup, { canvas = true } = {}) {
|
|
154
|
+
const cv = canvas ? document.createElement('canvas') : null;
|
|
155
|
+
if (cv) { cv.className = 'mh-canvas'; el.append(cv); }
|
|
156
|
+
let ctx, W = 0, H = 0, raf = 0, running = false, lastReal = -1e9, lastGhostClick = 0;
|
|
157
|
+
const P = { x: -9999, y: -9999, real: false };
|
|
158
|
+
const seed = hash(effects.length + el.childElementCount);
|
|
159
|
+
const h = { cv, get ctx() { return ctx; }, get W() { return W; }, get H() { return H; }, P, s: 1, colors: () => colors(el), expanded: !!opts.expanded };
|
|
160
|
+
const api = setup(h);
|
|
161
|
+
const size = () => {
|
|
162
|
+
const r = el.getBoundingClientRect(); if (!r.width || !r.height) return;
|
|
163
|
+
W = r.width; H = r.height; h.s = scaleOf(W); if (cv) ctx = fit(cv, W, H); api.resize && api.resize(W, H);
|
|
164
|
+
if (!running) api.frame(performance.now());
|
|
165
|
+
};
|
|
166
|
+
const ro = new ResizeObserver(size); ro.observe(el);
|
|
167
|
+
const local = e => { const r = el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
|
|
168
|
+
const onMove = e => { [P.x, P.y] = local(e); P.real = true; lastReal = performance.now(); };
|
|
169
|
+
const onLeave = () => { P.real = false; P.x = P.y = -9999; };
|
|
170
|
+
const onDown = e => { if (e.target.closest('a, button')) return; lastReal = performance.now(); const [x, y] = local(e); api.down && api.down(x, y); };
|
|
171
|
+
el.addEventListener('pointermove', onMove); el.addEventListener('pointerleave', onLeave); el.addEventListener('pointerdown', onDown);
|
|
172
|
+
const mo = new MutationObserver(() => { api.theme && api.theme(); if (!running) api.frame(performance.now()); });
|
|
173
|
+
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-palette'] });
|
|
174
|
+
const tick = now => {
|
|
175
|
+
if (!running) return;
|
|
176
|
+
// 缩略图里 2.5 秒没人碰,就让幽灵光标接管;偶尔“点”一下展示点击效果
|
|
177
|
+
if (!h.expanded && now - lastReal > 2500) {
|
|
178
|
+
const g = ghostAt(now, W, H, seed); P.x = g.x; P.y = g.y;
|
|
179
|
+
if (api.down && api.ghostClick !== false && now - lastGhostClick > 4200) { lastGhostClick = now; api.down(g.x, g.y); }
|
|
180
|
+
}
|
|
181
|
+
if (W && H) api.frame(now);
|
|
182
|
+
raf = requestAnimationFrame(tick);
|
|
183
|
+
};
|
|
184
|
+
size();
|
|
185
|
+
return {
|
|
186
|
+
pause() { running = false; cancelAnimationFrame(raf); },
|
|
187
|
+
resume() { if (running) return; if (still()) { api.frame(performance.now()); return; } running = true; raf = requestAnimationFrame(tick); },
|
|
188
|
+
destroy() { this.pause(); ro.disconnect(); mo.disconnect(); el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); el.removeEventListener('pointerdown', onDown); cv && cv.remove(); },
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 让 CSS 能对自定义属性做补间(遮罩半径等)
|
|
193
|
+
if (window.CSS && CSS.registerProperty) {
|
|
194
|
+
for (const name of ['--vr', '--vw']) { try { CSS.registerProperty({ name, syntax: '<length>', inherits: false, initialValue: '0px' }); } catch (e) { /* 已注册 */ } }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { effects, register, defaults, lines, still, clamp, lerp, ease, hash, perlin, fit, colors, tween, scaleOf, ghostAt, canvasHost };
|
|
198
|
+
})();
|
|
199
|
+
</script>
|
|
200
|
+
<script>
|
|
201
|
+
// 点阵拼字片头:画面是时间 t 的纯函数 frame(t),同一个 t 永远画出同一帧,所以能随时跳到结尾、重播、在任意尺寸重画最后一帧。
|
|
202
|
+
// 0.0–1.3s 网格点从文字中心一圈圈亮起
|
|
203
|
+
// 1.2–3.4s 屏幕外的点沿弧线飞进来,在网格缝隙里拼出文字;字形里的网格点同时变成强调色
|
|
204
|
+
// 3.4–6.0s 字幕依次闪过,每出现一个,从文字中心荡开一圈波纹
|
|
205
|
+
// 6.0–6.8s 收住,最后一帧可以直接当背景“定格”
|
|
206
|
+
MH.register({
|
|
207
|
+
id: 'intro', name: '点阵拼字片头', cat: '片头', tech: 'Canvas 2D · Pure-Function Timeline · Text Sampling',
|
|
208
|
+
desc: '网格点亮起,屏幕外的点飞进来拼成文字,字幕依次闪过并荡开波纹;播完定格。点一下重播。',
|
|
209
|
+
usage: `MH.effects.find(e => e.id === 'intro').mount(el, { text: 'LJY', captions: ['…'] }).resume();`,
|
|
210
|
+
params: [
|
|
211
|
+
{ k: 'text', label: '拼出的文字', type: 'text', def: 'LJY' },
|
|
212
|
+
{ k: 'captions', label: '字幕(一行一条)', type: 'lines', def: '背景\n过渡\n翻页\n交互' },
|
|
213
|
+
{ k: 'gap', label: '点间距', type: 'range', min: 10, max: 40, step: 1, def: 22, unit: 'px' },
|
|
214
|
+
],
|
|
215
|
+
mount(el, opts) {
|
|
216
|
+
const TEXT = opts.text || 'LJY', CAPS = MH.lines(opts.captions);
|
|
217
|
+
const T_END = 6.8, HOLD = 2.6, { clamp, hash } = MH;
|
|
218
|
+
const ease = t => t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
219
|
+
const PULSES = [1.0, 3.4, 4.05, 4.7, 5.35], CAP_AT = k => 3.4 + k * .65;
|
|
220
|
+
let L = null, C, t0 = performance.now();
|
|
221
|
+
const layout = (W, H, s) => {
|
|
222
|
+
const GAP = Math.max(6, opts.gap * s), cx = W / 2, cy = H * .44, fs = Math.min(H * .42, W * .3);
|
|
223
|
+
const off = document.createElement('canvas'); off.width = Math.ceil(W); off.height = Math.ceil(H);
|
|
224
|
+
const c = off.getContext('2d');
|
|
225
|
+
c.font = `700 ${fs}px "Iowan Old Style", Palatino, Georgia, serif`;
|
|
226
|
+
if ('letterSpacing' in c) c.letterSpacing = `${fs * .06}px`;
|
|
227
|
+
c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(TEXT, cx, cy);
|
|
228
|
+
c.lineWidth = fs * .05; c.lineJoin = 'round'; c.strokeText(TEXT, cx, cy); // 加粗细笔画,点阵字才压得住
|
|
229
|
+
const mask = c.getImageData(0, 0, off.width, off.height).data;
|
|
230
|
+
const inside = (x, y) => { x = Math.round(x); y = Math.round(y); return x >= 0 && y >= 0 && x < off.width && y < off.height && mask[(y * off.width + x) * 4 + 3] > 120; };
|
|
231
|
+
const ox = (W % GAP) / 2, oy = (H % GAP) / 2, diag = Math.hypot(W, H), grid = [], fly = [];
|
|
232
|
+
let k = 0;
|
|
233
|
+
for (let y = oy; y < H; y += GAP) for (let x = ox; x < W; x += GAP) {
|
|
234
|
+
grid.push({ x, y, d: Math.hypot(x - cx, y - cy), letter: inside(x, y), h: hash(k++) });
|
|
235
|
+
if (inside(x + GAP / 2, y + GAP / 2)) {
|
|
236
|
+
const i = fly.length, a = hash(i * 3 + 1) * Math.PI * 2, R0 = diag * (.6 + .3 * hash(i * 3 + 2));
|
|
237
|
+
fly.push({ tx: x + GAP / 2, ty: y + GAP / 2, sx: cx + Math.cos(a) * R0, sy: cy + Math.sin(a) * R0, bend: (hash(i * 3 + 3) - .5) * .9, delay: 1.2 + 1.05 * hash(i * 7 + 5) });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { W, H, s, cx, cy, grid, fly, diag };
|
|
241
|
+
};
|
|
242
|
+
const frame = (ctx, t) => {
|
|
243
|
+
const { W, H, s, grid, fly, diag, cx, cy } = L;
|
|
244
|
+
ctx.clearRect(0, 0, W, H);
|
|
245
|
+
const base = new Path2D(), hot = new Path2D(), add = (p, x, y, r) => { p.moveTo(x + r, y); p.arc(x, y, r, 0, Math.PI * 2); };
|
|
246
|
+
for (const g of grid) {
|
|
247
|
+
const appear = clamp((t - g.d / diag * 1.2) / .3); if (appear <= 0) continue;
|
|
248
|
+
let bump = 0;
|
|
249
|
+
for (const p of PULSES) { const age = t - p; if (age > 0 && age < 1.6) bump += Math.exp(-(((g.d - age * 900 * s) / (46 * s)) ** 2)) * (1 - age / 1.6); }
|
|
250
|
+
const ang = Math.atan2(g.y - cy, g.x - cx), push = bump * 7 * s;
|
|
251
|
+
const lit = g.letter ? clamp((t - 2.5 - g.h * .6) / .4) : 0;
|
|
252
|
+
add(lit > .5 || bump > .45 ? hot : base, g.x + Math.cos(ang) * push, g.y + Math.sin(ang) * push, (1.05 + bump * 1.6 + lit * 1.2) * Math.max(.6, s) * (.4 + .6 * appear));
|
|
253
|
+
}
|
|
254
|
+
for (const f of fly) {
|
|
255
|
+
const p = clamp((t - f.delay) / 1.15); if (p <= 0) continue;
|
|
256
|
+
const e = ease(p), mx = (f.sx + f.tx) / 2 - (f.ty - f.sy) * f.bend, my = (f.sy + f.ty) / 2 + (f.tx - f.sx) * f.bend;
|
|
257
|
+
add(hot, (1 - e) ** 2 * f.sx + 2 * (1 - e) * e * mx + e * e * f.tx, (1 - e) ** 2 * f.sy + 2 * (1 - e) * e * my + e * e * f.ty, (1.2 + 1.1 * e) * Math.max(.6, s));
|
|
258
|
+
}
|
|
259
|
+
ctx.fillStyle = C.dim; ctx.globalAlpha = C.night ? .4 : .45; ctx.fill(base);
|
|
260
|
+
ctx.fillStyle = C.accent; ctx.globalAlpha = 1; ctx.fill(hot);
|
|
261
|
+
CAPS.forEach((cap, k) => { // 字幕:文字下方居中,带编号
|
|
262
|
+
const a = t - CAP_AT(k), life = .62; if (a < 0 || a > life) return;
|
|
263
|
+
const o = clamp(a / .12) * clamp((life - a) / .14), dy = (1 - clamp(a / .2)) * 18 * s, fs = Math.max(14, Math.min(W * .05, 56 * s));
|
|
264
|
+
ctx.globalAlpha = o; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic';
|
|
265
|
+
ctx.fillStyle = C.ink; ctx.font = `900 ${fs}px "Songti SC","STSong","Noto Serif SC",serif`; ctx.fillText(cap, W / 2, H * .86 + dy);
|
|
266
|
+
ctx.fillStyle = C.accent; ctx.font = `500 ${Math.max(9, fs * .24)}px "SF Mono",Menlo,monospace`;
|
|
267
|
+
ctx.fillText(`${String(k + 1).padStart(2, '0')} / ${String(CAPS.length).padStart(2, '0')}`, W / 2, H * .86 - fs * 1.05 + dy);
|
|
268
|
+
});
|
|
269
|
+
ctx.globalAlpha = 1;
|
|
270
|
+
};
|
|
271
|
+
return MH.canvasHost(el, opts, h => {
|
|
272
|
+
C = h.colors();
|
|
273
|
+
return {
|
|
274
|
+
ghostClick: false,
|
|
275
|
+
resize(W, H) { L = layout(W, H, h.s); },
|
|
276
|
+
theme() { C = h.colors(); },
|
|
277
|
+
down() { t0 = performance.now(); },
|
|
278
|
+
frame(now) {
|
|
279
|
+
if (!L) return;
|
|
280
|
+
if (MH.still()) return frame(h.ctx, T_END);
|
|
281
|
+
const t = ((now - t0) / 1000) % (T_END + HOLD); // 播完定格一会儿,再从头播
|
|
282
|
+
frame(h.ctx, Math.min(t, T_END));
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
</script>
|
|
289
|
+
<script>
|
|
290
|
+
MH.effects.find(e => e.id === 'intro').mount(document.getElementById('fx'), {
|
|
291
|
+
"text": "LJY",
|
|
292
|
+
"captions": [
|
|
293
|
+
"背景",
|
|
294
|
+
"过渡",
|
|
295
|
+
"翻页",
|
|
296
|
+
"交互"
|
|
297
|
+
],
|
|
298
|
+
"gap": 22
|
|
299
|
+
}).resume();
|
|
300
|
+
</script>
|
|
301
|
+
</body>
|
|
302
|
+
</html>
|