cut / BeadQR /miniprogram /utils /intro.js
Ethscriptions's picture
上传 BeadQR 微信小程序项目源码
9de7b70 verified
Raw History Blame Contribute Delete
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 }