docs: 添加墨韵增强实现计划
This commit is contained in:
@@ -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<HTMLCanvasElement | null>(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(
|
||||
<div style={{ width: 375, height: 667 }}>
|
||||
<InkDataMorph />
|
||||
</div>
|
||||
);
|
||||
const canvas = container.querySelector('canvas');
|
||||
expect(canvas).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render on tablet viewport', () => {
|
||||
const { container } = render(
|
||||
<div style={{ width: 900, height: 600 }}>
|
||||
<InkDataMorph />
|
||||
</div>
|
||||
);
|
||||
const canvas = container.querySelector('canvas');
|
||||
expect(canvas).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render on desktop viewport with full effects', () => {
|
||||
const { container } = render(
|
||||
<div style={{ width: 1440, height: 900 }}>
|
||||
<InkDataMorph />
|
||||
</div>
|
||||
);
|
||||
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 使用
|
||||
Reference in New Issue
Block a user