Spaces:
Running
Running
Download BeadQR/miniprogram/utils/intro.js from Ethscriptions/cut: direct link, hf CLI and curl.
- Browser
- Download file 24.9 kB
-
https://huggingface.co/spaces/Ethscriptions/cut/resolve/main/BeadQR/miniprogram/utils/intro.js
- Command line
-
hf download hf://spaces/Ethscriptions/cut/BeadQR/miniprogram/utils/intro.js
-
curl -L -o intro.js https://huggingface.co/spaces/Ethscriptions/cut/resolve/main/BeadQR/miniprogram/utils/intro.js
24.9 kB
| // 首屏动态演示(1:1) | |
| // 纯代码绘制:不占包体积(无视频/图片资源),小程序实时播放与宣传视频渲染共用同一条时间轴。 | |
| // 演示用的拼豆作品由真实配色引擎实时生成,并且确实可以被微信扫出 “Happy Birthday!”。 | |
| const { makeQr, createPlacement } = require('./qr') | |
| const { preparePalette } = require('./palette') | |
| const { computeArtwork } = require('./engine') | |
| const DEMO_TEXT = 'Happy Birthday!' | |
| const DURATION = 12 | |
| const U = 1080 // 设计稿边长,绘制时按实际像素缩放 | |
| const TAU = Math.PI * 2 | |
| const FLIGHT = 0.85 | |
| // 豆子半径(相对格宽):略大于 0.5,相邻豆子轻微相接,像熨烫后的成品,扫码更稳 | |
| const BEAD_R = 0.52 | |
| const C = { | |
| bg: '#f4efe6', | |
| ink: '#16211f', | |
| muted: '#6b7471', | |
| brand: '#0f766e', | |
| brand2: '#14b8a6', | |
| brandSoft: '#dcf1ec', | |
| board: '#fffdf8', | |
| dark: '#22302e', | |
| light: '#fbf8f1', | |
| wechat: '#07c160', | |
| } | |
| const SCENES = [ | |
| { a: 0, b: 2.6, title: '写一句话', sub: '扫码后想让人看到的话' }, | |
| { a: 2.6, b: 5.0, title: '自动编成二维码', sub: '每一颗拼豆,都是一个像素' }, | |
| { a: 5.0, b: 7.6, title: '融进你的照片', sub: '换上照片的颜色,依然能扫' }, | |
| { a: 7.6, b: 10.0, title: '微信扫一扫', sub: '拼好的作品,一扫就读出这句话' }, | |
| ] | |
| const CONFETTI = [ | |
| [0.17, 0.19, 0.045, '#ffd166'], [0.85, 0.2, 0.04, '#4cc9f0'], [0.13, 0.8, 0.04, '#2ec4b6'], | |
| [0.86, 0.82, 0.045, '#ffd166'], [0.5, 0.11, 0.03, '#2ec4b6'], [0.91, 0.51, 0.03, '#ff9f1c'], [0.08, 0.5, 0.03, '#4cc9f0'], | |
| ] | |
| const DECOR = [ | |
| [90, 150, 22, '#14b8a6'], [990, 120, 16, '#ffb703'], [60, 960, 18, '#ff7a45'], [1010, 930, 24, '#8ecae6'], | |
| [170, 1040, 12, '#ffb703'], [930, 330, 12, '#ff7a45'], [120, 520, 10, '#8ecae6'], [980, 700, 14, '#14b8a6'], | |
| ] | |
| // —— 工具 —— | |
| const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v) | |
| const lerp = (a, b, t) => a + (b - a) * t | |
| const prog = (t, a, b) => clamp01((t - a) / (b - a)) | |
| const easeOut = (t) => 1 - Math.pow(1 - t, 3) | |
| const easeIn = (t) => t * t * t | |
| const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2) | |
| const easeBack = (t) => { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2) } | |
| const bump = (t, a, b) => { const p = prog(t, a, b); return p > 0 && p < 1 ? Math.sin(Math.PI * p) : 0 } | |
| function rng(seed) { | |
| let a = seed >>> 0 | |
| return () => { | |
| a = (a + 0x6D2B79F5) >>> 0 | |
| let t = a | |
| t = Math.imul(t ^ (t >>> 15), t | 1) | |
| t ^= t + Math.imul(t ^ (t >>> 7), t | 61) | |
| return ((t ^ (t >>> 14)) >>> 0) / 4294967296 | |
| } | |
| } | |
| function rr(ctx, x, y, w, h, r) { | |
| r = Math.max(0, Math.min(r, w / 2, h / 2)) | |
| ctx.moveTo(x + r, y) | |
| ctx.arcTo(x + w, y, x + w, y + h, r) | |
| ctx.arcTo(x + w, y + h, x, y + h, r) | |
| ctx.arcTo(x, y + h, x, y, r) | |
| ctx.arcTo(x, y, x + w, y, r) | |
| ctx.closePath() | |
| } | |
| // 三层叠加的柔和阴影:比 shadowBlur 便宜得多,手机上逐帧绘制也流畅 | |
| function softShadow(ctx, x, y, w, h, r, alpha) { | |
| for (let i = 3; i >= 1; i -= 1) { | |
| const g = i * 8 | |
| ctx.fillStyle = `rgba(52,38,18,${(alpha / (i + 1)).toFixed(3)})` | |
| ctx.beginPath() | |
| rr(ctx, x - g / 2, y - g / 2 + g * 0.8, w + g, h + g, r + g / 2) | |
| ctx.fill() | |
| } | |
| } | |
| function heartPath(ctx, cx, cy, s) { | |
| ctx.moveTo(cx, cy + s * 0.9) | |
| ctx.bezierCurveTo(cx - s * 1.3, cy + s * 0.05, cx - s * 0.95, cy - s * 1.0, cx, cy - s * 0.42) | |
| ctx.bezierCurveTo(cx + s * 0.95, cy - s * 1.0, cx + s * 1.3, cy + s * 0.05, cx, cy + s * 0.9) | |
| ctx.closePath() | |
| } | |
| // 演示“照片”:暖色渐变 + 爱心 + 彩纸屑(矢量绘制,任意尺寸都清晰) | |
| function drawIllustration(ctx, x, y, s) { | |
| ctx.save() | |
| ctx.beginPath() | |
| ctx.rect(x, y, s, s) | |
| ctx.clip() | |
| let g = ctx.createLinearGradient(x, y, x, y + s) | |
| g.addColorStop(0, '#63c7b8') | |
| g.addColorStop(1, '#4aa3c9') | |
| ctx.fillStyle = g | |
| ctx.fillRect(x, y, s, s) | |
| g = ctx.createRadialGradient(x + s * 0.5, y + s * 0.48, 0, x + s * 0.5, y + s * 0.48, s * 0.55) | |
| g.addColorStop(0, 'rgba(255,255,255,0.35)') | |
| g.addColorStop(1, 'rgba(255,255,255,0)') | |
| ctx.fillStyle = g | |
| ctx.fillRect(x, y, s, s) | |
| CONFETTI.forEach(([cx, cy, r, col]) => { | |
| ctx.fillStyle = col | |
| ctx.beginPath() | |
| ctx.arc(x + cx * s, y + cy * s, r * s, 0, TAU) | |
| ctx.fill() | |
| }) | |
| g = ctx.createLinearGradient(x, y + s * 0.2, x, y + s * 0.85) | |
| g.addColorStop(0, '#ff6b88') | |
| g.addColorStop(1, '#d42a55') | |
| ctx.fillStyle = g | |
| ctx.beginPath() | |
| heartPath(ctx, x + s * 0.5, y + s * 0.55, s * 0.34) | |
| ctx.fill() | |
| ctx.fillStyle = 'rgba(255,255,255,0.55)' | |
| ctx.save() | |
| ctx.translate(x + s * 0.37, y + s * 0.42) | |
| ctx.rotate(-0.6) | |
| ctx.scale(1, 0.55) | |
| ctx.beginPath() | |
| ctx.arc(0, 0, s * 0.06, 0, TAU) | |
| ctx.fill() | |
| ctx.restore() | |
| ctx.restore() | |
| } | |
| // 用真实引擎生成演示作品:插画按格面积取平均色 → 配色引擎 | |
| function buildDemo(workCanvas, createCanvas) { | |
| const qr = makeQr(DEMO_TEXT, 'H') | |
| const placement = createPlacement(qr.size, { scale: 100, quiet: 1, hasImage: true }) | |
| const size = placement.size | |
| const k = 6 | |
| const px = size * k | |
| const canvas = createCanvas ? createCanvas(px, px) : workCanvas | |
| canvas.width = px | |
| canvas.height = px | |
| const ctx = canvas.getContext('2d') | |
| ctx.setTransform(1, 0, 0, 1, 0, 0) | |
| ctx.clearRect(0, 0, px, px) | |
| drawIllustration(ctx, 0, 0, px) | |
| const data = ctx.getImageData(0, 0, px, px).data | |
| const rgb = new Uint8Array(size * size * 3) | |
| for (let r = 0; r < size; r += 1) { | |
| for (let c = 0; c < size; c += 1) { | |
| let sr = 0; let sg = 0; let sb = 0 | |
| for (let y = 0; y < k; y += 1) { | |
| let o = ((r * k + y) * px + c * k) * 4 | |
| for (let x = 0; x < k; x += 1, o += 4) { sr += data[o]; sg += data[o + 1]; sb += data[o + 2] } | |
| } | |
| const i = (r * size + c) * 3 | |
| rgb[i] = sr / (k * k); rgb[i + 1] = sg / (k * k); rgb[i + 2] = sb / (k * k) | |
| } | |
| } | |
| const art = computeArtwork({ size, rgb }, qr, placement, preparePalette('221'), { modeId: 'balanced', coherence: 40, cleanup: true }) | |
| return { qr, placement, art } | |
| } | |
| // —— 豆子批量绘制:按颜色合并路径,每帧只需十几次填充 —— | |
| function addBead(groups, hex, x, y, r) { | |
| let list = groups.get(hex) | |
| if (!list) { list = []; groups.set(hex, list) } | |
| list.push(x, y, r) | |
| } | |
| // 小批量合并路径:一条路径里子路径太多时,部分渲染引擎反而更慢,每 12 颗提交一次最稳 | |
| const CHUNK = 12 | |
| function fillCircles(ctx, list, rs, ox, oy) { | |
| let n = 0 | |
| ctx.beginPath() | |
| for (let i = 0; i < list.length; i += 3) { | |
| const r = list[i + 2] * rs | |
| const x = list[i] + list[i + 2] * ox | |
| const y = list[i + 1] + list[i + 2] * oy | |
| ctx.moveTo(x + r, y) | |
| ctx.arc(x, y, r, 0, TAU) | |
| n += 1 | |
| if (n === CHUNK) { ctx.fill(); ctx.beginPath(); n = 0 } | |
| } | |
| if (n) ctx.fill() | |
| } | |
| function flushBeads(ctx, groups) { | |
| const all = [] | |
| groups.forEach((list, hex) => { | |
| ctx.fillStyle = hex | |
| fillCircles(ctx, list, 1, 0, 0) | |
| all.push(list) | |
| }) | |
| // 中孔:拼豆是一颗颗小管子 | |
| ctx.fillStyle = 'rgba(70,48,24,0.17)' | |
| all.forEach((list) => fillCircles(ctx, list, 0.3, 0, 0)) | |
| // 高光 | |
| ctx.fillStyle = 'rgba(255,255,255,0.4)' | |
| all.forEach((list) => fillCircles(ctx, list, 0.2, -0.42, -0.42)) | |
| groups.clear() | |
| } | |
| /** | |
| * @param opts { workCanvas?, createCanvas?(w,h), font?, loop? } | |
| * @returns { duration, text, artwork, draw(ctx, t, pixelWidth) } | |
| */ | |
| function createIntro(opts = {}) { | |
| const font = opts.font || 'sans-serif' | |
| const loop = opts.loop !== false | |
| const F = (weight, px) => `${weight} ${Math.round(px)}px ${font}` | |
| const { qr, placement, art } = buildDemo(opts.workCanvas, opts.createCanvas) | |
| const size = art.size | |
| const R = rng(20260929) | |
| const cells = [] | |
| for (let r = 0; r < size; r += 1) { | |
| for (let c = 0; c < size; c += 1) { | |
| const qc = c - placement.left | |
| const qrow = r - placement.top | |
| const dark = qc >= 0 && qrow >= 0 && qc < qr.size && qrow < qr.size && qr.dark[qrow * qr.size + qc] === 1 | |
| const idx = art.cells[r * size + c] | |
| cells.push({ | |
| c, | |
| r, | |
| qrHex: dark ? C.dark : C.light, | |
| finalHex: idx >= 0 ? art.pal.hexes[idx] : C.board, | |
| k: Math.max(0, Math.min(DEMO_TEXT.length - 1, Math.floor((c / size) * DEMO_TEXT.length + (R() - 0.5) * 2.5))), | |
| jx: R(), | |
| jy: R() - 0.5, | |
| bend: R() - 0.5, | |
| t0: 2.72 + (c / size) * 0.55 + R() * 0.55, | |
| tw: 5.95 + ((c + r) / (2 * (size - 1))) * 0.95 + R() * 0.08, | |
| }) | |
| } | |
| } | |
| const finders = [[0, 0], [qr.size - 7, 0], [0, qr.size - 7]].map(([x, y]) => [placement.left + x, placement.top + y]) | |
| const topColors = art.usage.slice(0, 8).map((u) => u.hex) | |
| const burst = Array.from({ length: 26 }, () => ({ | |
| a: -Math.PI / 2 + (R() - 0.5) * 2.2, | |
| v: 520 + R() * 520, | |
| r: 9 + R() * 8, | |
| hex: topColors[Math.floor(R() * topColors.length)] || C.brand2, | |
| })) | |
| const groups = new Map() | |
| const pegDots = [] | |
| for (let y = 20; y < U; y += 40) for (let x = 20; x < U; x += 40) pegDots.push(x, y, 2.4) | |
| let layout = null // 文本排版缓存(首帧测量) | |
| const textLayout = (ctx) => { | |
| if (layout) return layout | |
| ctx.font = F('bold', 64) | |
| const x0 = 170 | |
| const charX = [] | |
| for (let i = 0; i <= DEMO_TEXT.length; i += 1) charX.push(x0 + ctx.measureText(DEMO_TEXT.slice(0, i)).width) | |
| layout = { charX, y: 616 } | |
| return layout | |
| } | |
| const boardAt = (t) => { | |
| let cx = 540; let cy = 590; let B = 640 | |
| const a = easeInOut(prog(t, 7.6, 8.2)) | |
| cx = lerp(cx, 356, a); B = lerp(B, 560, a) | |
| const b = easeInOut(prog(t, 10.05, 10.75)) | |
| cx = lerp(cx, 540, b); cy = lerp(cy, 410, b); B = lerp(B, 450, b) | |
| return { cx, cy, B } | |
| } | |
| const gridOf = (L) => { | |
| const pad = L.B * 0.05 | |
| const cell = (L.B - pad * 2) / size | |
| return { cell, ox: L.cx - L.B / 2 + pad, oy: L.cy - L.B / 2 + pad } | |
| } | |
| const drawPlate = (ctx, L, alpha) => { | |
| const x = L.cx - L.B / 2 | |
| const y = L.cy - L.B / 2 | |
| const r = L.B * 0.045 | |
| ctx.globalAlpha = alpha | |
| softShadow(ctx, x, y, L.B, L.B, r, 0.16) | |
| ctx.fillStyle = C.board | |
| ctx.beginPath(); rr(ctx, x, y, L.B, L.B, r); ctx.fill() | |
| ctx.globalAlpha = 1 | |
| } | |
| // 背景:米色钉板 + 缓慢漂浮的装饰豆 | |
| const drawBackground = (ctx, t) => { | |
| ctx.fillStyle = C.bg | |
| ctx.fillRect(0, 0, U, U) | |
| ctx.fillStyle = 'rgba(60,45,25,0.065)' | |
| fillCircles(ctx, pegDots, 1, 0, 0) | |
| DECOR.forEach(([x, y, r, hex], i) => { | |
| const dx = Math.sin(t * 0.9 + i * 1.7) * 10 | |
| const dy = Math.cos(t * 0.7 + i * 2.3) * 12 | |
| ctx.globalAlpha = 0.55 | |
| addBead(groups, hex, x + dx, y + dy, r) | |
| }) | |
| flushBeads(ctx, groups) | |
| ctx.globalAlpha = 1 | |
| } | |
| const drawCaption = (ctx, t) => { | |
| ctx.textAlign = 'center' | |
| ctx.textBaseline = 'middle' | |
| SCENES.forEach((s, i) => { | |
| if (t < s.a || t > s.b) return | |
| const inP = easeOut(prog(t, s.a + 0.05, s.a + 0.5)) | |
| const outP = easeIn(prog(t, s.b - 0.32, s.b)) | |
| const alpha = inP * (1 - outP) | |
| if (alpha <= 0) return | |
| const dy = (1 - inP) * 26 - outP * 14 | |
| ctx.globalAlpha = alpha | |
| ctx.font = F('bold', 24) | |
| const label = `第 ${i + 1} 步` | |
| const w = ctx.measureText(label).width + 40 | |
| ctx.fillStyle = C.brandSoft | |
| ctx.beginPath(); rr(ctx, 540 - w / 2, 70 + dy, w, 46, 23); ctx.fill() | |
| ctx.fillStyle = C.brand | |
| ctx.fillText(label, 540, 94 + dy) | |
| ctx.fillStyle = C.ink | |
| ctx.font = F('bold', 60) | |
| ctx.fillText(s.title, 540, 170 + dy * 1.3) | |
| ctx.fillStyle = C.muted | |
| ctx.font = F('normal', 30) | |
| ctx.fillText(s.sub, 540, 232 + dy * 1.6) | |
| }) | |
| ctx.globalAlpha = 1 | |
| } | |
| const drawProgress = (ctx, t) => { | |
| const alpha = prog(t, 0, 0.35) * (1 - prog(t, 10.0, 10.4)) | |
| if (alpha <= 0) return | |
| ctx.globalAlpha = alpha | |
| const w = 110; const gap = 16; const x0 = 540 - (w * 4 + gap * 3) / 2 | |
| SCENES.forEach((s, i) => { | |
| const x = x0 + i * (w + gap) | |
| ctx.fillStyle = 'rgba(22,33,31,0.1)' | |
| ctx.beginPath(); rr(ctx, x, 1010, w, 8, 4); ctx.fill() | |
| const p = prog(t, s.a, s.b) | |
| if (p > 0) { | |
| ctx.fillStyle = C.brand | |
| ctx.beginPath(); rr(ctx, x, 1010, Math.max(8, w * p), 8, 4); ctx.fill() | |
| } | |
| }) | |
| ctx.globalAlpha = 1 | |
| } | |
| // 第 1 幕:输入框 + 打字 | |
| const drawInput = (ctx, t) => { | |
| if (t > 3.35) return | |
| const inP = easeBack(prog(t, 0.1, 0.65)) | |
| const outP = easeInOut(prog(t, 2.6, 3.15)) | |
| const alpha = prog(t, 0.1, 0.4) | |
| if (alpha <= 0) return | |
| const L = textLayout(ctx) | |
| const press = 1 - 0.08 * bump(t, 2.1, 2.4) | |
| const scale = lerp(0.88, 1, inP) * lerp(1, 0.95, outP) | |
| ctx.save() | |
| ctx.translate(540, 590) | |
| ctx.scale(scale, scale) | |
| ctx.translate(-540, -590) | |
| ctx.globalAlpha = alpha * (1 - outP) | |
| softShadow(ctx, 130, 495, 820, 190, 40, 0.14) | |
| ctx.fillStyle = '#ffffff' | |
| ctx.beginPath(); rr(ctx, 130, 495, 820, 190, 40); ctx.fill() | |
| ctx.textAlign = 'left' | |
| ctx.textBaseline = 'middle' | |
| ctx.fillStyle = C.muted | |
| ctx.font = F('normal', 24) | |
| ctx.fillText('扫码后显示的内容', 170, 548) | |
| // 发送按钮 | |
| ctx.save() | |
| ctx.translate(880, 590) | |
| ctx.scale(press, press) | |
| ctx.fillStyle = C.brand | |
| ctx.beginPath(); ctx.arc(0, 0, 44, 0, TAU); ctx.fill() | |
| ctx.strokeStyle = '#fff' | |
| ctx.lineWidth = 7 | |
| ctx.lineCap = 'round' | |
| ctx.lineJoin = 'round' | |
| ctx.beginPath(); ctx.moveTo(-14, 0); ctx.lineTo(14, 0); ctx.moveTo(2, -13); ctx.lineTo(15, 0); ctx.lineTo(2, 13); ctx.stroke() | |
| ctx.restore() | |
| ctx.restore() | |
| // 文字逐字出现,随后逐字化作豆子 | |
| const count = Math.floor(prog(t, 0.55, 1.9) * DEMO_TEXT.length + 0.0001) | |
| ctx.font = F('bold', 64) | |
| ctx.textAlign = 'left' | |
| ctx.fillStyle = C.ink | |
| for (let i = 0; i < count; i += 1) { | |
| const start = 2.7 + (i / DEMO_TEXT.length) * 0.55 | |
| const a = alpha * (1 - prog(t, start, start + 0.35)) | |
| if (a <= 0) continue | |
| ctx.globalAlpha = a | |
| ctx.fillText(DEMO_TEXT[i], L.charX[i], L.y) | |
| } | |
| const typing = t >= 0.55 && t <= 1.9 | |
| if (t < 2.55 && (typing || Math.floor(t / 0.4) % 2 === 0)) { | |
| ctx.globalAlpha = alpha | |
| ctx.fillStyle = C.brand2 | |
| ctx.fillRect(L.charX[count] + 4, L.y - 34, 5, 68) | |
| } | |
| ctx.globalAlpha = 1 | |
| } | |
| // 主画板上的豆子:飞入 → 黑白二维码 → 扫过换色 | |
| const drawMainBeads = (ctx, t, L) => { | |
| const G = gridOf(L) | |
| const TL = textLayout(ctx) | |
| const baseR = G.cell * BEAD_R | |
| for (let i = 0; i < cells.length; i += 1) { | |
| const b = cells[i] | |
| if (t < b.t0) continue | |
| const dx = G.ox + (b.c + 0.5) * G.cell | |
| const dy = G.oy + (b.r + 0.5) * G.cell | |
| let x = dx; let y = dy; let s = 1 | |
| const f = prog(t, b.t0, b.t0 + FLIGHT) | |
| if (f < 1) { | |
| const sx = TL.charX[b.k] + b.jx * (TL.charX[b.k + 1] - TL.charX[b.k]) | |
| const sy = TL.y + b.jy * 44 | |
| const cx = (sx + dx) / 2 + b.bend * 220 | |
| const cy = Math.min(sy, dy) - 110 - Math.abs(b.bend) * 160 | |
| const e = easeInOut(f) | |
| const u = 1 - e | |
| x = u * u * sx + 2 * u * e * cx + e * e * dx | |
| y = u * u * sy + 2 * u * e * cy + e * e * dy | |
| s = lerp(0.55, 1, easeOut(f)) | |
| } else { | |
| s = 1 + 0.16 * bump(t, b.t0 + FLIGHT, b.t0 + FLIGHT + 0.2) | |
| } | |
| s += 0.3 * bump(t, b.tw - 0.05, b.tw + 0.25) | |
| addBead(groups, t >= b.tw ? b.finalHex : b.qrHex, x, y, baseR * s) | |
| } | |
| flushBeads(ctx, groups) | |
| } | |
| const drawFinalBeads = (ctx, L) => { | |
| const G = gridOf(L) | |
| const r = G.cell * BEAD_R | |
| for (let i = 0; i < cells.length; i += 1) { | |
| const b = cells[i] | |
| addBead(groups, b.finalHex, G.ox + (b.c + 0.5) * G.cell, G.oy + (b.r + 0.5) * G.cell, r) | |
| } | |
| flushBeads(ctx, groups) | |
| } | |
| // 二维码定位图形高亮 | |
| const drawFinders = (ctx, t, L) => { | |
| const p = prog(t, 4.15, 4.95) | |
| if (p <= 0 || p >= 1) return | |
| const G = gridOf(L) | |
| const alpha = Math.sin(Math.PI * p) | |
| const grow = (1 - easeOut(p)) * 18 | |
| ctx.strokeStyle = C.brand2 | |
| ctx.lineWidth = 6 | |
| ctx.globalAlpha = alpha | |
| finders.forEach(([c, r]) => { | |
| const x = G.ox + c * G.cell - 6 - grow | |
| const y = G.oy + r * G.cell - 6 - grow | |
| const w = 7 * G.cell + 12 + grow * 2 | |
| ctx.beginPath(); rr(ctx, x, y, w, w, 16); ctx.stroke() | |
| }) | |
| ctx.globalAlpha = 1 | |
| } | |
| // 沿对角线扫过的光带 | |
| const drawGleam = (ctx, L, p, strength) => { | |
| if (p <= 0 || p >= 1) return | |
| const x = L.cx - L.B / 2 | |
| const y = L.cy - L.B / 2 | |
| const g = ctx.createLinearGradient(x, y, x + L.B, y + L.B) | |
| const s0 = clamp01(p - 0.12); const s1 = clamp01(p); const s2 = clamp01(p + 0.04) | |
| g.addColorStop(0, 'rgba(255,255,255,0)') | |
| if (s0 > 0) g.addColorStop(s0, 'rgba(255,255,255,0)') | |
| g.addColorStop(s1, `rgba(255,255,255,${strength})`) | |
| if (s2 < 1) g.addColorStop(s2, 'rgba(255,255,255,0)') | |
| g.addColorStop(1, 'rgba(255,255,255,0)') | |
| ctx.save() | |
| ctx.beginPath(); rr(ctx, x, y, L.B, L.B, L.B * 0.045); ctx.clip() | |
| ctx.fillStyle = g | |
| ctx.fillRect(x, y, L.B, L.B) | |
| ctx.restore() | |
| } | |
| // 第 3 幕:照片飞入并铺到画板上 | |
| const drawPhoto = (ctx, t, L) => { | |
| if (t < 4.95 || t > 6.65) return | |
| const a = easeOut(prog(t, 5.0, 5.55)) | |
| const b = easeInOut(prog(t, 5.6, 6.05)) | |
| let x = lerp(1320, 800, a); let y = lerp(250, 330, a); let rot = lerp(0.4, -0.12, a); let s = 300 | |
| x = lerp(x, L.cx, b); y = lerp(y, L.cy, b); rot = lerp(rot, 0, b); s = lerp(s, L.B * 0.9, b) | |
| const frame = lerp(14, 0, b) | |
| const alpha = 1 - prog(t, 6.0, 6.6) | |
| ctx.save() | |
| ctx.globalAlpha = alpha | |
| ctx.translate(x, y) | |
| ctx.rotate(rot) | |
| const half = s / 2 + frame | |
| if (b < 1) softShadow(ctx, -half, -half, half * 2, half * 2, 18, 0.2 * (1 - b)) | |
| ctx.fillStyle = '#ffffff' | |
| ctx.beginPath(); rr(ctx, -half, -half, half * 2, half * 2, 18); ctx.fill() | |
| ctx.beginPath(); rr(ctx, -s / 2, -s / 2, s, s, 10); ctx.clip() | |
| drawIllustration(ctx, -s / 2, -s / 2, s) | |
| ctx.restore() | |
| ctx.globalAlpha = 1 | |
| } | |
| // 第 4 幕:手机扫码 | |
| const drawPhone = (ctx, t) => { | |
| const enter = easeOut(prog(t, 7.75, 8.35)) | |
| const exit = easeIn(prog(t, 9.95, 10.4)) | |
| if (enter <= 0 || exit >= 1) return | |
| const w = 340; const h = 660 | |
| const px = 800 | |
| const py = 590 + (1 - enter) * 860 + exit * 860 | |
| const rot = lerp(0.16, -0.035, enter) | |
| ctx.save() | |
| ctx.translate(px, py) | |
| ctx.rotate(rot) | |
| softShadow(ctx, -w / 2, -h / 2, w, h, 60, 0.28) | |
| ctx.fillStyle = '#1b2321' | |
| ctx.beginPath(); rr(ctx, -w / 2, -h / 2, w, h, 60); ctx.fill() | |
| ctx.fillStyle = '#2c3633' | |
| ctx.fillRect(w / 2 - 1, -h / 2 + 150, 5, 70) | |
| // 屏幕 | |
| const sw = w - 24; const sh = h - 24 | |
| ctx.save() | |
| ctx.beginPath(); rr(ctx, -sw / 2, -sh / 2, sw, sh, 48); ctx.clip() | |
| ctx.fillStyle = '#0e1312' | |
| ctx.fillRect(-sw / 2, -sh / 2, sw, sh) | |
| const jx = Math.sin(t * 3.1) * 3 | |
| const jy = Math.cos(t * 2.3) * 3 | |
| const cam = { cx: jx, cy: -30 + jy, B: 250 } | |
| ctx.globalAlpha = 0.92 | |
| ctx.fillStyle = C.bg | |
| ctx.fillRect(-sw / 2, -sh / 2, sw, sh) | |
| ctx.globalAlpha = 1 | |
| drawPlate(ctx, cam, 1) | |
| drawFinalBeads(ctx, cam) | |
| ctx.fillStyle = 'rgba(10,15,14,0.45)' | |
| ctx.fillRect(-sw / 2, -sh / 2, sw, sh) | |
| // 取景框 | |
| const detect = prog(t, 9.0, 9.3) | |
| const snap = t >= 9.0 ? 1 + 0.1 * (1 - easeOut(detect)) : 1 | |
| const half = 146 * snap | |
| ctx.strokeStyle = t >= 9.0 ? C.wechat : '#ffffff' | |
| ctx.lineWidth = 6 | |
| ctx.lineCap = 'round' | |
| const arm = 34 | |
| ctx.beginPath() | |
| ;[[-1, -1], [1, -1], [1, 1], [-1, 1]].forEach(([sx, sy]) => { | |
| const x = cam.cx + sx * half | |
| const y = cam.cy + sy * half | |
| ctx.moveTo(x, y - sy * arm); ctx.lineTo(x, y); ctx.lineTo(x - sx * arm, y) | |
| }) | |
| ctx.stroke() | |
| // 扫描线 | |
| const sp = prog(t, 8.3, 9.0) | |
| if (sp > 0 && sp < 1) { | |
| const top = cam.cy - 140 | |
| const y = lerp(top, cam.cy + 140, easeInOut(sp)) | |
| const g = ctx.createLinearGradient(0, y - 80, 0, y) | |
| g.addColorStop(0, 'rgba(7,193,96,0)') | |
| g.addColorStop(1, 'rgba(7,193,96,0.35)') | |
| ctx.fillStyle = g | |
| ctx.fillRect(cam.cx - 140, Math.max(top, y - 80), 280, y - Math.max(top, y - 80)) | |
| ctx.fillStyle = C.wechat | |
| ctx.fillRect(cam.cx - 140, y - 2, 280, 4) | |
| } | |
| ctx.textAlign = 'center' | |
| ctx.textBaseline = 'middle' | |
| ctx.fillStyle = '#ffffff' | |
| ctx.font = F('bold', 24) | |
| ctx.fillText('扫一扫', 0, -sh / 2 + 78) | |
| if (t < 9.0) { | |
| ctx.globalAlpha = 0.7 | |
| ctx.font = F('normal', 19) | |
| ctx.fillText('将二维码放入框内', 0, cam.cy + 185) | |
| ctx.globalAlpha = 1 | |
| } | |
| const flash = t >= 9.0 ? 0.35 * (1 - prog(t, 9.0, 9.25)) : 0 | |
| if (flash > 0) { | |
| ctx.fillStyle = `rgba(255,255,255,${flash.toFixed(3)})` | |
| ctx.fillRect(-sw / 2, -sh / 2, sw, sh) | |
| } | |
| // 识别结果卡片 | |
| const rp = prog(t, 9.1, 9.5) | |
| if (rp > 0) { | |
| const cw = sw - 36; const ch = 170 | |
| const cy = sh / 2 - 18 - ch + (1 - easeBack(rp)) * 220 | |
| ctx.fillStyle = '#ffffff' | |
| ctx.beginPath(); rr(ctx, -cw / 2, cy, cw, ch, 28); ctx.fill() | |
| ctx.fillStyle = C.wechat | |
| ctx.beginPath(); ctx.arc(0, cy + 44, 22, 0, TAU); ctx.fill() | |
| ctx.strokeStyle = '#fff' | |
| ctx.lineWidth = 5 | |
| ctx.lineJoin = 'round' | |
| ctx.beginPath(); ctx.moveTo(-9, cy + 44); ctx.lineTo(-2, cy + 51); ctx.lineTo(10, cy + 37); ctx.stroke() | |
| ctx.fillStyle = C.ink | |
| ctx.font = F('bold', 30) | |
| ctx.fillText(DEMO_TEXT, 0, cy + 100) | |
| ctx.fillStyle = C.muted | |
| ctx.font = F('normal', 18) | |
| ctx.fillText('扫码结果', 0, cy + 138) | |
| } | |
| ctx.restore() | |
| // 灵动岛 | |
| ctx.fillStyle = '#000' | |
| ctx.beginPath(); rr(ctx, -52, -h / 2 + 26, 104, 30, 15); ctx.fill() | |
| ctx.restore() | |
| // 识别成功:豆子礼花 | |
| const bt = t - 9.2 | |
| if (bt > 0 && bt < 1.4) { | |
| const ox = px; const oy = py + 200 | |
| const fade = 1 - prog(bt, 0.9, 1.4) | |
| ctx.globalAlpha = fade | |
| burst.forEach((p) => { | |
| const x = ox + Math.cos(p.a) * p.v * bt | |
| const y = oy + Math.sin(p.a) * p.v * bt + 900 * bt * bt | |
| addBead(groups, p.hex, x, y, p.r) | |
| }) | |
| flushBeads(ctx, groups) | |
| ctx.globalAlpha = 1 | |
| } | |
| } | |
| // 片尾 | |
| const drawEnd = (ctx, t) => { | |
| if (t < 10.4) return | |
| ctx.textAlign = 'center' | |
| ctx.textBaseline = 'middle' | |
| const a = easeOut(prog(t, 10.45, 10.95)) | |
| ctx.globalAlpha = a | |
| ctx.fillStyle = C.ink | |
| ctx.font = F('bold', 64) | |
| ctx.fillText('豆维 · 可扫码的拼豆', 540, 745 + (1 - a) * 30) | |
| const b = easeOut(prog(t, 10.6, 11.1)) | |
| ctx.globalAlpha = b | |
| ctx.fillStyle = C.muted | |
| ctx.font = F('normal', 32) | |
| ctx.fillText('把一句话藏进拼豆里,微信一扫就能读出来', 540, 812 + (1 - b) * 30) | |
| const chips = ['MARD 标准色号', 'A4 打印图纸', '微信一扫即读'] | |
| ctx.font = F('bold', 26) | |
| const widths = chips.map((s) => ctx.measureText(s).width + 44) | |
| const total = widths.reduce((s, v) => s + v, 0) + 18 * (chips.length - 1) | |
| let x = 540 - total / 2 | |
| chips.forEach((label, i) => { | |
| const p = easeBack(prog(t, 10.85 + i * 0.12, 11.3 + i * 0.12)) | |
| if (p > 0) { | |
| ctx.globalAlpha = clamp01(p) | |
| const cy = 892 + (1 - p) * 24 | |
| ctx.fillStyle = C.brandSoft | |
| ctx.beginPath(); rr(ctx, x, cy - 26, widths[i], 52, 26); ctx.fill() | |
| ctx.fillStyle = C.brand | |
| ctx.fillText(label, x + widths[i] / 2, cy) | |
| } | |
| x += widths[i] + 18 | |
| }) | |
| ctx.globalAlpha = 1 | |
| } | |
| const draw = (ctx, time, pixelWidth) => { | |
| const t = loop ? ((time % DURATION) + DURATION) % DURATION : Math.min(time, DURATION) | |
| const k = pixelWidth / U | |
| ctx.setTransform(k, 0, 0, k, 0, 0) | |
| ctx.globalAlpha = 1 | |
| drawBackground(ctx, t) | |
| const L = boardAt(t) | |
| if (t >= 2.65) { | |
| const p = easeBack(prog(t, 2.65, 3.15)) | |
| const s = lerp(0.92, 1, p) | |
| const LS = { cx: L.cx, cy: L.cy, B: L.B * s } | |
| drawPlate(ctx, LS, prog(t, 2.65, 3.0)) | |
| } | |
| drawInput(ctx, t) | |
| if (t >= 2.72) drawMainBeads(ctx, t, L) | |
| drawFinders(ctx, t, L) | |
| drawGleam(ctx, L, prog(t, 5.9, 7.0), 0.55) | |
| drawGleam(ctx, L, prog(t, 11.0, 11.7), 0.4) | |
| drawPhoto(ctx, t, L) | |
| drawPhone(ctx, t) | |
| drawCaption(ctx, t) | |
| drawProgress(ctx, t) | |
| drawEnd(ctx, t) | |
| if (loop) { | |
| const fade = easeInOut(prog(t, 11.45, 12)) | |
| if (fade > 0) { | |
| ctx.globalAlpha = fade | |
| ctx.fillStyle = C.bg | |
| ctx.fillRect(0, 0, U, U) | |
| ctx.globalAlpha = 1 | |
| } | |
| } | |
| ctx.setTransform(1, 0, 0, 1, 0, 0) | |
| } | |
| // 关键时间点(供视频配乐对齐) | |
| const timeline = { landings: cells.map((b) => b.t0 + FLIGHT), wipes: cells.map((b) => b.tw) } | |
| return { duration: DURATION, text: DEMO_TEXT, artwork: art, draw, timeline } | |
| } | |
| module.exports = { createIntro, DEMO_TEXT, DURATION } | |