# 墨韵增强实现计划 > **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 **目标:** 升级 InkDataMorph 粒子束动画,实现4层墨韵晕染、不规则墨迹边缘、墨丝效果、宣纸纹理和飞白笔触,同时优化移动端表现。 **架构:** 在现有 Canvas 2D 粒子系统基础上,重构绘制管线——将单层径向渐变替换为4层晕染叠加,用极坐标噪声路径替代椭圆绘制,新增墨丝/飞白/宣纸纹理三个绘制层。响应式配置系统扩展新增字段以控制各效果的参数。 **技术栈:** React 19, Canvas 2D API, TypeScript, Jest --- ## 文件结构 | 文件 | 职责 | 操作 | |------|------|------| | `src/components/effects/ink-data-morph.tsx` | 核心动画组件,包含所有粒子逻辑和绘制 | 修改 | | `src/components/effects/ink-data-morph.test.tsx` | 单元测试 | 修改 | --- ### 任务 1:扩展数据模型和响应式配置 **文件:** - 修改:`src/components/effects/ink-data-morph.tsx:18-34` (Particle 接口) - 修改:`src/components/effects/ink-data-morph.tsx:36-52` (ResponsiveConfig 接口) - 修改:`src/components/effects/ink-data-morph.tsx:54-120` (getResponsiveConfig 函数) - 修改:`src/components/effects/ink-data-morph.tsx:375-418` (createParticle 函数) - [ ] **步骤 1:扩展 Particle 接口,新增6个字段** 在 Particle 接口中添加: ```typescript interface Particle { x: number; y: number; vx: number; vy: number; radius: number; initialRadius: number; dataRadius: number; opacity: number; toneIndex: number; rotation: number; scaleX: number; scaleY: number; isSplash: boolean; phase: 'spreading' | 'settling' | 'morphing' | 'complete' | 'fading'; spreadTime: number; maxSpreadTime: number; settleTime: number; morphProgress: number; targetX: number; targetY: number; delay: number; age: number; trail: TrailPoint[]; seed1: number; seed2: number; wobbleFactor: number; prevVx: number; prevVy: number; inkLayerCount: number; } ``` - [ ] **步骤 2:扩展 ResponsiveConfig 接口,新增6个字段** 在 ResponsiveConfig 接口中添加: ```typescript interface ResponsiveConfig { particleCount: number; centers: { x: number; y: number }[]; centerDistribution: number[]; targetXRange: [number, number]; targetYRange: [number, number]; connectionDistance: number; glowScale: number; gradientLayers: number; maxTrail: number; splashRatio: number; bgOrbScale: number; inkLayers: number; inkRadiusScale: number; inkStringThreshold: number; showFeibai: boolean; paperTextureSize: number; wobbleDetail: number; } ``` - [ ] **步骤 3:更新 getResponsiveConfig 函数,为三个断点添加新字段** 移动端 (<768): ```typescript inkLayers: 3, inkRadiusScale: 1.2, inkStringThreshold: 0.35, showFeibai: false, paperTextureSize: 128, wobbleDetail: 16, ``` 平板端 (768-1023): ```typescript inkLayers: 3, inkRadiusScale: 1.1, inkStringThreshold: 0.4, showFeibai: true, paperTextureSize: 128, wobbleDetail: 20, ``` 桌面端 (≥1024): ```typescript inkLayers: 4, inkRadiusScale: 1.0, inkStringThreshold: 0.5, showFeibai: true, paperTextureSize: 256, wobbleDetail: 24, ``` 同时更新三个断点的 particleCount:桌面 180、平板 120、移动 70。更新 maxTrail:桌面 6、平板 4、移动 2。 - [ ] **步骤 4:更新 createParticle 函数,初始化新字段** 在 createParticle 返回对象中添加: ```typescript seed1: Math.random() * Math.PI * 2, seed2: Math.random() * Math.PI * 2, wobbleFactor: 0.3, prevVx: 0, prevVy: 0, inkLayerCount: config.inkLayers, ``` - [ ] **步骤 5:运行 TypeScript 类型检查** 运行:`npx tsc --noEmit --pretty 2>&1 | head -30` 预期:无类型错误(新增字段在所有使用 Particle 的地方都需要初始化) - [ ] **步骤 6:Commit** ```bash git add src/components/effects/ink-data-morph.tsx git commit -m "refactor: 扩展 Particle 和 ResponsiveConfig 数据模型,新增墨韵增强字段" ``` --- ### 任务 2:实现4层晕染绘制系统 **文件:** - 修改:`src/components/effects/ink-data-morph.tsx` (drawParticle 函数,约 L260-340) - [ ] **步骤 1:新增 drawInkBlob 辅助函数(极坐标噪声路径绘制)** 在 drawInkDot 函数之后添加: ```typescript function drawInkBlob( ctx: CanvasRenderingContext2D, cx: number, cy: number, baseR: number, seed1: number, seed2: number, wobble: number, detail: number, rgb: string, opacity: number, ): void { ctx.beginPath(); for (let i = 0; i <= detail; i++) { const angle = (i / detail) * Math.PI * 2; const noise = Math.sin(angle * 3 + seed1) * 0.3 + Math.cos(angle * 5 + seed2) * 0.2; const r = Math.max(0.5, baseR * (1 + noise * wobble)); const x = cx + Math.cos(angle) * r; const y = cy + Math.sin(angle) * r; if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } } ctx.closePath(); ctx.fillStyle = `rgba(${rgb}, ${opacity})`; ctx.fill(); } ``` - [ ] **步骤 2:新增 drawInkLayers 函数(4层晕染叠加)** 在 drawInkBlob 之后添加: ```typescript function drawInkLayers( ctx: CanvasRenderingContext2D, p: Particle, config: ResponsiveConfig, ): void { const tone = TONES[p.toneIndex]; if (!tone) { return; } const isAccent = ACCENT_TONES.includes(p.toneIndex); const deepRgb = isAccent ? tone.rgb : (p.toneIndex <= 1 ? tone.rgb : TONES[0].rgb); const lightRgb = isAccent ? TONES[1].rgb : (p.toneIndex >= 2 ? tone.rgb : TONES[2].rgb); const r = Math.max(0.5, p.radius) * config.inkRadiusScale; const op = p.opacity; const detail = config.wobbleDetail; const layers: Array<{ radiusMul: number; opacityMul: number; rgb: string; wobble: number; useRect: boolean }> = [ { radiusMul: 5, opacityMul: 0.04, rgb: lightRgb, wobble: 0.25, useRect: true }, { radiusMul: 3, opacityMul: 0.12, rgb: lightRgb, wobble: 0.2, useRect: true }, { radiusMul: 1.5, opacityMul: 0.4, rgb: deepRgb, wobble: 0.12, useRect: false }, { radiusMul: 1, opacityMul: 0.9, rgb: deepRgb, wobble: 0.06, useRect: false }, ]; const layerCount = p.inkLayerCount; const startIdx = layers.length - layerCount; for (let i = startIdx; i < layers.length; i++) { const layer = layers[i]; if (!layer) { continue; } const layerR = r * layer.radiusMul; const layerOp = op * layer.opacityMul; if (layer.useRect) { const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, layerR); grad.addColorStop(0, `rgba(${layer.rgb}, ${layerOp})`); grad.addColorStop(0.5, `rgba(${layer.rgb}, ${layerOp * 0.3})`); grad.addColorStop(1, 'transparent'); ctx.fillStyle = grad; ctx.fillRect(p.x - layerR, p.y - layerR, layerR * 2, layerR * 2); } else { const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, layerR); grad.addColorStop(0, `rgba(${layer.rgb}, ${layerOp})`); grad.addColorStop(0.6, `rgba(${layer.rgb}, ${layerOp * 0.5})`); grad.addColorStop(1, 'transparent'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(p.x, p.y, layerR, 0, Math.PI * 2); ctx.fill(); drawInkBlob(ctx, p.x, p.y, layerR * 0.7, p.seed1, p.seed2, layer.wobble, detail, layer.rgb, layerOp * 0.6); } } } ``` - [ ] **步骤 3:重构 drawParticle 函数,使用 drawInkLayers 替代旧的绘制逻辑** 将 drawParticle 函数中 spreading 和 settling 阶段的绘制逻辑替换为调用 drawInkLayers。保留 drawTrail 调用。fading 和 complete/morphing 阶段保持不变。 新的 drawParticle 函数: ```typescript function drawParticle( ctx: CanvasRenderingContext2D, p: Particle, time: number, config: ResponsiveConfig, ): void { if (p.age < p.delay || p.opacity <= 0) { return; } const tone = TONES[p.toneIndex]; if (!tone) { return; } const rgb = tone.rgb; if (p.phase === 'spreading') { drawTrail(ctx, p, rgb); drawInkLayers(ctx, p, config); } else if (p.phase === 'settling') { drawInkLayers(ctx, p, config); if (config.showFeibai && !p.isSplash) { drawFeibai(ctx, p, rgb); } } else if (p.phase === 'fading') { const r = Math.max(0.5, p.radius); const grad1 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 1.5); grad1.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.6})`); grad1.addColorStop(1, 'transparent'); ctx.fillStyle = grad1; ctx.beginPath(); ctx.arc(p.x, p.y, r * 1.5, 0, Math.PI * 2); ctx.fill(); } else if (p.phase === 'morphing') { drawInkLayers(ctx, p, config); } else { const r = Math.max(0.5, p.radius); const pulse = 1 + Math.sin(time * 0.002 + p.targetX * 0.01) * 0.15; const glowR = r * 3 * pulse * config.glowScale; const grad2 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR); grad2.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.12})`); grad2.addColorStop(0.4, `rgba(${rgb}, ${p.opacity * 0.04})`); grad2.addColorStop(1, 'transparent'); ctx.fillStyle = grad2; ctx.fillRect(p.x - glowR, p.y - glowR, glowR * 2, glowR * 2); drawInkBlob(ctx, p.x, p.y, r, p.seed1, p.seed2, 0.08, config.wobbleDetail, rgb, p.opacity); } } ``` 注意:此步骤中 `drawFeibai` 函数尚未实现,先声明一个空函数占位,任务3中实现。 - [ ] **步骤 4:添加 drawFeibai 占位函数** ```typescript function drawFeibai( ctx: CanvasRenderingContext2D, p: Particle, rgb: string, ): void { } ``` - [ ] **步骤 5:删除不再使用的 drawInkDot 函数** 删除整个 drawInkDot 函数,因为已被 drawInkBlob 替代。 - [ ] **步骤 6:运行类型检查和 lint** 运行:`npx tsc --noEmit --pretty 2>&1 | head -30` 预期:无类型错误 运行:`npx eslint src/components/effects/ink-data-morph.tsx 2>&1 | head -20` 预期:无 lint 错误 - [ ] **步骤 7:Commit** ```bash git add src/components/effects/ink-data-morph.tsx git commit -m "feat: 实现4层墨韵晕染系统和极坐标噪声墨迹边缘" ``` --- ### 任务 3:实现飞白笔触效果 **文件:** - 修改:`src/components/effects/ink-data-morph.tsx` (drawFeibai 函数 + updateParticle 函数) - [ ] **步骤 1:更新 updateParticle 函数,记录 prevVx/prevVy 并更新 wobbleFactor** 在 updateParticle 函数的 spreading 阶段开头添加速度记录: ```typescript if (p.phase === 'spreading') { p.prevVx = p.vx; p.prevVy = p.vy; const spreadRatio = p.spreadTime / p.maxSpreadTime; p.wobbleFactor = lerp(0.3, 0.15, spreadRatio); // ... 其余 spreading 逻辑不变 } ``` - [ ] **步骤 2:实现 drawFeibai 函数** 替换占位函数为完整实现: ```typescript function drawFeibai( ctx: CanvasRenderingContext2D, p: Particle, rgb: string, ): void { const speed = Math.sqrt(p.prevVx * p.prevVx + p.prevVy * p.prevVy); if (speed < 0.1) { return; } const dirX = -p.prevVx / speed; const dirY = -p.prevVy / speed; const length = p.radius * (3 + Math.random() * 2); const endX = p.x + dirX * length; const endY = p.y + dirY * length; const perpX = -dirY; const perpY = dirX; const curvature = (Math.random() - 0.5) * length * 0.15; const cpX = (p.x + endX) / 2 + perpX * curvature; const cpY = (p.y + endY) / 2 + perpY * curvature; const grad = ctx.createLinearGradient(p.x, p.y, endX, endY); grad.addColorStop(0, `rgba(${rgb}, ${p.opacity * 0.25})`); grad.addColorStop(1, 'transparent'); ctx.save(); ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.quadraticCurveTo(cpX, cpY, endX, endY); ctx.strokeStyle = grad; ctx.lineWidth = Math.max(0.3, p.radius * 0.6); ctx.stroke(); ctx.restore(); } ``` - [ ] **步骤 3:运行类型检查** 运行:`npx tsc --noEmit --pretty 2>&1 | head -30` 预期:无类型错误 - [ ] **步骤 4:Commit** ```bash git add src/components/effects/ink-data-morph.tsx git commit -m "feat: 实现飞白笔触效果,settling阶段沿运动反方向绘制渐变笔触" ``` --- ### 任务 4:实现墨丝效果 **文件:** - 修改:`src/components/effects/ink-data-morph.tsx` (新增 drawInkStrings 函数 + animate 循环) - [ ] **步骤 1:新增 drawInkStrings 函数** 在 drawConnections 函数之后添加: ```typescript function drawInkStrings( ctx: CanvasRenderingContext2D, particles: Particle[], config: ResponsiveConfig, ): void { const spreading = particles.filter( p => p.phase === 'spreading' && p.age >= p.delay ); const threshold = config.connectionDistance * config.inkStringThreshold; ctx.save(); ctx.lineCap = 'round'; for (let i = 0; i < spreading.length; i++) { const a = spreading[i]; if (!a) { continue; } const toneA = TONES[a.toneIndex]; if (!toneA) { continue; } for (let j = i + 1; j < spreading.length; j++) { const b = spreading[j]; if (!b) { continue; } const toneB = TONES[b.toneIndex]; if (!toneB) { continue; } const dx = a.x - b.x; const dy = a.y - b.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < threshold && dist > 0) { const distRatio = 1 - dist / threshold; const alpha = distRatio * 0.15 * Math.min(a.opacity, b.opacity); const midWidth = 0.5 + distRatio * 1.5; const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2; const offset = dist * 0.08; const cpx = mx + (dy / dist) * offset; const cpy = my - (dx / dist) * offset; const grad = ctx.createLinearGradient(a.x, a.y, b.x, b.y); grad.addColorStop(0, `rgba(${toneA.rgb}, ${alpha * 0.3})`); grad.addColorStop(0.5, `rgba(${toneA.rgb}, ${alpha})`); grad.addColorStop(1, `rgba(${toneB.rgb}, ${alpha * 0.3})`); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.quadraticCurveTo(cpx, cpy, b.x, b.y); ctx.strokeStyle = grad; ctx.lineWidth = midWidth; ctx.stroke(); } } } ctx.restore(); } ``` - [ ] **步骤 2:在 animate 循环中调用 drawInkStrings** 在 animate 函数中,`drawBgOrbs` 之后、粒子绘制循环之前,添加: ```typescript drawInkStrings(ctx, particles, config); ``` - [ ] **步骤 3:运行类型检查** 运行:`npx tsc --noEmit --pretty 2>&1 | head -30` 预期:无类型错误 - [ ] **步骤 4:Commit** ```bash git add src/components/effects/ink-data-morph.tsx git commit -m "feat: 实现墨丝效果,spreading阶段近距离粒子间绘制渐变墨丝" ``` --- ### 任务 5:实现宣纸纹理叠加 **文件:** - 修改:`src/components/effects/ink-data-morph.tsx` (新增 generatePaperTexture 函数 + 组件内 textureRef + animate 循环) - [ ] **步骤 1:新增 generatePaperTexture 函数** 在 drawBgOrbs 函数之后添加: ```typescript function generatePaperTexture(size: number): HTMLCanvasElement { const offscreen = document.createElement('canvas'); offscreen.width = size; offscreen.height = size; const octx = offscreen.getContext('2d'); if (!octx) { return offscreen; } octx.fillStyle = '#FAFAF5'; octx.fillRect(0, 0, size, size); for (let i = 0; i < size * size * 0.15; i++) { const x = Math.random() * size; const y = Math.random() * size; const gray = 200 + Math.floor(Math.random() * 40); const alpha = 0.02 + Math.random() * 0.04; octx.fillStyle = `rgba(${gray}, ${gray}, ${gray}, ${alpha})`; octx.fillRect(x, y, 1, 1); } octx.strokeStyle = 'rgba(180, 175, 168, 0.015)'; octx.lineWidth = 0.5; for (let i = 0; i < size * 0.3; i++) { const y = Math.random() * size; const startX = Math.random() * size * 0.3; const endX = startX + size * (0.1 + Math.random() * 0.4); octx.beginPath(); octx.moveTo(startX, y); octx.lineTo(Math.min(endX, size), y + (Math.random() - 0.5) * 2); octx.stroke(); } return offscreen; } ``` - [ ] **步骤 2:新增 drawPaperTexture 函数** ```typescript function drawPaperTexture( ctx: CanvasRenderingContext2D, W: number, H: number, texture: HTMLCanvasElement, ): void { ctx.save(); ctx.globalCompositeOperation = 'overlay'; const pattern = ctx.createPattern(texture, 'repeat'); if (pattern) { ctx.fillStyle = pattern; ctx.fillRect(0, 0, W, H); } ctx.globalCompositeOperation = 'source-over'; ctx.restore(); } ``` - [ ] **步骤 3:在组件中添加 textureRef** 在组件内部,与其他 ref 一起添加: ```typescript const textureRef = useRef(null); ``` - [ ] **步骤 4:在 initCanvas 中生成纹理** 在 initCanvas 函数中,`const config = getResponsiveConfig(W);` 之后添加: ```typescript textureRef.current = generatePaperTexture(config.paperTextureSize); ``` - [ ] **步骤 5:在 animate 循环末尾调用 drawPaperTexture** 在 animate 函数中,`drawConnections` 之后添加: ```typescript if (textureRef.current) { drawPaperTexture(ctx, W, H, textureRef.current); } ``` - [ ] **步骤 6:在 drawStaticFinal 中也调用 drawPaperTexture** 在 drawStaticFinal 函数中,`drawConnections` 之后添加同样的纹理绘制代码。 - [ ] **步骤 7:运行类型检查和 lint** 运行:`npx tsc --noEmit --pretty 2>&1 | head -30` 预期:无类型错误 - [ ] **步骤 8:Commit** ```bash git add src/components/effects/ink-data-morph.tsx git commit -m "feat: 实现宣纸纹理叠加,使用OffscreenCanvas生成一次性噪声纹理" ``` --- ### 任务 6:更新单元测试 **文件:** - 修改:`src/components/effects/ink-data-morph.test.tsx` - [ ] **步骤 1:更新 MockCanvasContext,添加缺失的 mock 方法** 在 MockCanvasContext 类中添加: ```typescript closePath = jest.fn(); globalCompositeOperation = 'source-over'; createPattern = jest.fn(() => ({})); createLinearGradient = jest.fn(() => ({ addColorStop: jest.fn(), })); ``` - [ ] **步骤 2:添加晕染层数相关测试** 在 describe('Responsive behavior') 块中添加: ```typescript it('should render on mobile viewport with reduced effects', () => { const { container } = render(
); const canvas = container.querySelector('canvas'); expect(canvas).toBeInTheDocument(); }); it('should render on tablet viewport', () => { const { container } = render(
); const canvas = container.querySelector('canvas'); expect(canvas).toBeInTheDocument(); }); it('should render on desktop viewport with full effects', () => { const { container } = render(
); const canvas = container.querySelector('canvas'); expect(canvas).toBeInTheDocument(); }); ``` - [ ] **步骤 3:运行测试** 运行:`npx jest src/components/effects/ink-data-morph.test.tsx --no-coverage 2>&1` 预期:所有测试通过 - [ ] **步骤 4:Commit** ```bash git add src/components/effects/ink-data-morph.test.tsx git commit -m "test: 更新InkDataMorph单元测试,适配墨韵增强后的组件" ``` --- ### 任务 7:视觉验证与性能检查 **文件:** 无代码修改 - [ ] **步骤 1:启动开发服务器** 运行:`npm run dev` - [ ] **步骤 2:桌面端视觉验证** 在浏览器中打开 http://localhost:3000,观察: - 粒子是否呈现4层墨韵晕染效果(核心→浓墨→淡墨→水渍) - 粒子边缘是否不规则(墨迹形态而非圆点) - spreading 阶段是否有墨丝连接 - 背景是否有宣纸纹理质感 - settling 阶段是否有飞白笔触 - morphing → complete 阶段过渡是否自然 - [ ] **步骤 3:移动端视觉验证** 使用浏览器开发者工具切换到移动端视口(375×667),观察: - 粒子虽少但视觉表现力是否充足 - 晕染半径是否比桌面端更大(1.2倍系数) - 墨丝效果是否保留 - 无飞白效果(移动端 showFeibai: false) - 宣纸纹理是否可见 - [ ] **步骤 4:性能检查** 在桌面端和移动端分别打开 Chrome DevTools Performance 面板,录制5秒动画: - 桌面端帧率 ≥ 50fps - 移动端帧率 ≥ 30fps 如果帧率不达标,需要优化: - 减少 wobbleDetail(桌面 20、移动 12) - 减少 inkLayers(移动端降为 2) - 增大 inkStringThreshold(减少墨丝绘制数量) - [ ] **步骤 5:截图对比** 桌面端截图保存到 /tmp/hero-ink-wash-desktop.png 移动端截图保存到 /tmp/hero-ink-wash-mobile.png - [ ] **步骤 6:最终 Commit(如有微调)** ```bash git add -A git commit -m "fix: 墨韵增强视觉微调与性能优化" ``` --- ## 自检 ### 规格覆盖度 | 规格需求 | 对应任务 | |---------|---------| | 4层晕染系统 | 任务2 (drawInkLayers) | | 不规则墨迹边缘 | 任务2 (drawInkBlob) | | 墨丝效果 | 任务4 (drawInkStrings) | | 宣纸纹理叠加 | 任务5 (generatePaperTexture + drawPaperTexture) | | 飞白笔触 | 任务3 (drawFeibai) | | 移动端适配 | 任务1 (ResponsiveConfig 新字段) | | Particle 新增字段 | 任务1 | | ResponsiveConfig 新增字段 | 任务1 | | 性能考量 | 任务7 (验证步骤) | | 测试更新 | 任务6 | ### 占位符扫描 无 TODO/待定/后续实现。所有函数都有完整实现代码。 ### 类型一致性 - Particle.seed1/seed2/wobbleFactor/prevVx/prevVy/inkLayerCount 在任务1定义,任务2/3/4使用 - ResponsiveConfig.inkLayers/inkRadiusScale/inkStringThreshold/showFeibai/paperTextureSize/wobbleDetail 在任务1定义,任务2/3/4/5使用 - drawInkBlob 在任务2定义,任务2和任务2的 drawParticle 使用 - drawInkLayers 在任务2定义,任务2的 drawParticle 使用 - drawFeibai 在任务3定义,任务2的 drawParticle 使用 - drawInkStrings 在任务4定义,任务4的 animate 循环使用 - generatePaperTexture/drawPaperTexture 在任务5定义,任务5的 initCanvas/animate 使用