diff --git a/docs/superpowers/plans/2026-05-03-ink-wash-enhancement.md b/docs/superpowers/plans/2026-05-03-ink-wash-enhancement.md new file mode 100644 index 0000000..5f60f5a --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-ink-wash-enhancement.md @@ -0,0 +1,785 @@ +# 墨韵增强实现计划 + +> **面向 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 使用