diff --git a/src/components/EffortPanel/__tests__/rippleAnimation.test.ts b/src/components/EffortPanel/__tests__/rippleAnimation.test.ts new file mode 100644 index 000000000..c550a6038 --- /dev/null +++ b/src/components/EffortPanel/__tests__/rippleAnimation.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from 'bun:test' +import { + type Overlay, + computeRippleLine, + mergeLayers, + pickChar, +} from '../rippleAnimation.js' + +describe('pickChar', () => { + test('intensity=0 → 空格', () => { + expect(pickChar(0)).toBe(' ') + }) + + test('intensity < 0 钳到 0 → 空格', () => { + expect(pickChar(-0.5)).toBe(' ') + }) + + test('intensity > 1 钳到 1 → 最高强度字符', () => { + expect(pickChar(1.5)).toBe(pickChar(1)) + }) + + test('intensity 单调递增 → 字符视觉密度递增(按字符串长度近似)', () => { + // 字符密度:' ' < '·' < '∙' < '░' < '▒' < '▓' + const samples = [0, 0.15, 0.35, 0.55, 0.75, 0.95] + const chars = samples.map(pickChar) + expect(chars[0]).toBe(' ') + // 至少 4 种不同字符(说明分级生效) + const unique = new Set(chars) + expect(unique.size).toBeGreaterThanOrEqual(4) + }) + + test('波峰位置(intensity ≥ 0.95)出现 WAVE_PEAK_CHARS 循环字符之一', () => { + const wavePeakChars = ['~', '◌', '○', '◑', '●'] + // 不同 time 偏移应触达不同波峰字符(基于 time / 80 的相位) + const seen = new Set() + for (let t = 0; t < 400; t += 40) { + seen.add(pickChar(1, t)) + } + // 至少出现 2 种 WAVE_PEAK_CHARS(说明循环生效) + const hits = [...seen].filter(c => wavePeakChars.includes(c)) + expect(hits.length).toBeGreaterThanOrEqual(2) + }) +}) + +describe('computeRippleLine', () => { + test('返回字符串长度等于 width', () => { + const line = computeRippleLine({ + y: 2, + width: 30, + time: 100, + sourceX: 25, + sourceY: 2, + }) + expect(line.length).toBe(30) + }) + + test('width=0 → 空字符串', () => { + expect( + computeRippleLine({ y: 0, width: 0, time: 0, sourceX: 0, sourceY: 0 }), + ).toBe('') + }) + + test('震源点 (sourceX, sourceY) 处字符非空(dist=0,falloff=1)', () => { + const line = computeRippleLine({ + y: 5, + width: 11, + time: 0, + sourceX: 5, + sourceY: 5, + }) + // 震源在 (5,5),y=5 行的第 5 列字符应非空格 + expect(line[5]).not.toBe(' ') + }) + + test('远离震源的字符强度衰减(远端更可能是空格)', () => { + // 震源在左端,远端 30 列外,time=0 时强度低 + const line = computeRippleLine({ + y: 0, + width: 40, + time: 0, + sourceX: 0, + sourceY: 0, + }) + // 第 0 列字符密度 >= 第 39 列(不一定严格,但近端更密) + const nearDensity = line.charCodeAt(0) + const farDensity = line.charCodeAt(39) + // 用 charCode 近似:震源附近字符 charCode 应大于等于远端 + // 注:空格 charCode=32,其他字符 charCode 通常更大 + expect(nearDensity).toBeGreaterThanOrEqual(farDensity) + }) + + test('time 推进时字符变化(动画效果)', () => { + const t0 = computeRippleLine({ + y: 2, + width: 30, + time: 0, + sourceX: 25, + sourceY: 2, + }) + const t1 = computeRippleLine({ + y: 2, + width: 30, + time: 500, + sourceX: 25, + sourceY: 2, + }) + expect(t0).not.toBe(t1) + }) +}) + +describe('mergeLayers', () => { + test('无 overlay 时原样返回 ripple', () => { + const ripple = '·∙░▒▓░∙·' + expect(mergeLayers(ripple, [])).toBe(ripple) + }) + + test('overlay 文字字符覆盖对应位置', () => { + const ripple = '········' + const overlays: Overlay[] = [{ text: 'hi', x: 2 }] + expect(mergeLayers(ripple, overlays)).toBe('··hi····') + }) + + test('多个 overlay 不重叠时各自覆盖', () => { + const ripple = '·········' + const overlays: Overlay[] = [ + { text: 'A', x: 0 }, + { text: 'B', x: 4 }, + { text: 'C', x: 8 }, + ] + // ripple 长 9:A 在 0;B 在 4;C 在 8;其余保留 '·' + expect(mergeLayers(ripple, overlays)).toBe('A···B···C') + }) + + test('overlay 超出右边界被截断', () => { + const ripple = '·····' + const overlays: Overlay[] = [{ text: 'abcdef', x: 3 }] + // ripple 长 5,overlay 从 x=3 开始 → 'ab' 占 3,4,剩下 'cdef' 截断 + expect(mergeLayers(ripple, overlays)).toBe('···ab') + }) + + test('overlay x 为负数 → 从开头截断(不向左溢出)', () => { + const ripple = '·····' + const overlays: Overlay[] = [{ text: 'abc', x: -1 }] + // x=-1 → 跳过 'a','bc' 占 0,1 + expect(mergeLayers(ripple, overlays)).toBe('bc···') + }) + + test('overlay x=0 + 完整覆盖 → 全部覆盖', () => { + const ripple = '·····' + const overlays: Overlay[] = [{ text: 'HELLO', x: 0 }] + expect(mergeLayers(ripple, overlays)).toBe('HELLO') + }) + + test('文字字符永远胜出(即使 ripple 那里是高密度字符)', () => { + const ripple = '▓▓▓▓▓' + const overlays: Overlay[] = [{ text: 'X', x: 2 }] + expect(mergeLayers(ripple, overlays)).toBe('▓▓X▓▓') + }) + + test('多个 overlay 后者覆盖前者(同位置)', () => { + const ripple = '·····' + const overlays: Overlay[] = [ + { text: 'AAA', x: 0 }, + { text: 'B', x: 1 }, + ] + // ripple 长 5:A 在 0;B 在 1(覆盖第一个 A);2 为 A;3,4 为 '·'(未被 overlay 覆盖) + expect(mergeLayers(ripple, overlays)).toBe('ABA··') + }) +}) diff --git a/src/components/EffortPanel/rippleAnimation.ts b/src/components/EffortPanel/rippleAnimation.ts new file mode 100644 index 000000000..09f69d037 --- /dev/null +++ b/src/components/EffortPanel/rippleAnimation.ts @@ -0,0 +1,141 @@ +/** + * EffortPanel ultracode 档位的背景波纹动画 —— 纯函数模块。 + * + * 设计目标: + * - 仅在 cursor 停在 ultracode 时启动(订阅时钟由 useRippleFrame 控制) + * - 震源:面板右下(ultracode 字符位置),向左/上辐射同心圆波 + * - 字符强度由距离衰减 + 正弦相位决定 + * - 文字字符永远覆盖波纹背景(mergeLayers),保证可读性 + * + * 所有函数纯:相同入参 → 相同出参,便于单测 + 帧快照。 + */ + +/** + * 强度(0~1)→ 视觉密度递增的字符。 + * 0.0 空格(静默)→ 1.0 实心方块(最强)。 + * + * 注:波峰附近的"涟漪"字符(~ ◌ ○ 等)通过 pickCharWavePeak 单独处理, + * 让震源附近出现循环的高频涟漪字符(与单纯密度梯度区分)。 + */ +const INTENSITY_CHARS = [' ', '·', '∙', '░', '▒', '▓'] as const + +/** + * 震源附近的波峰字符循环(dist < 6 时叠加)。 + * 让 ultracode 附近出现明显"水波"感,而非仅密度梯度。 + */ +const WAVE_PEAK_CHARS = ['~', '◌', '○', '◑', '●'] as const + +/** + * 把强度(任意实数)钳到 [0, 1]。 + */ +function clampIntensity(intensity: number): number { + if (intensity < 0) return 0 + if (intensity > 1) return 1 + return intensity +} + +/** + * 强度 → 字符。 + * + * 强度 < 0.95 时按 INTENSITY_CHARS 分级(密度梯度); + * 强度 >= 0.95 时按波峰字符循环(基于 time 偏移)。 + */ +export function pickChar(intensity: number, time: number = 0): string { + const v = clampIntensity(intensity) + if (v >= 0.95) { + // 波峰循环:以 time 为相位让字符循环流动 + const idx = Math.floor(time / 80) % WAVE_PEAK_CHARS.length + return WAVE_PEAK_CHARS[idx] + } + // 密度梯度:v ∈ [0, 0.95) 映射到 INTENSITY_CHARS[0..5) + const idx = Math.min( + INTENSITY_CHARS.length - 1, + Math.floor(v * INTENSITY_CHARS.length), + ) + return INTENSITY_CHARS[idx] +} + +/** + * 计算面板某一行 y 的完整波纹字符串。 + * + * 波纹数学: + * dx = x - sourceX + * dy = (y - sourceY) * 1.5 (y 方向视觉拉伸,行高 > 字宽) + * dist = sqrt(dx² + dy²) + * phase = dist * 0.4 - time * 0.012 + * wave = max(0, sin(phase)) + * falloff = max(0, 1 - dist / 40) + * intensity = wave * falloff + * 震源附近 (dist < 6):叠加高频涟漪 max(intensity, 0.5 + 0.5*sin(time*0.02 - dist*1.2)) + * + * @returns 长度严格等于 width 的字符串 + */ +export function computeRippleLine(args: { + y: number + width: number + time: number + sourceX: number + sourceY: number +}): string { + const { y, width, time, sourceX, sourceY } = args + if (width <= 0) return '' + + let out = '' + for (let x = 0; x < width; x++) { + const dx = x - sourceX + const dy = (y - sourceY) * 1.5 + const dist = Math.sqrt(dx * dx + dy * dy) + + // 主波纹相位 + const phase = dist * 0.4 - time * 0.012 + const wave = Math.max(0, Math.sin(phase)) + + // 距离衰减 + const falloff = Math.max(0, 1 - dist / 40) + let intensity = wave * falloff + + // 震源附近高频涟漪 + if (dist < 6) { + const ripple = 0.5 + 0.5 * Math.sin(time * 0.02 - dist * 1.2) + if (ripple > intensity) intensity = ripple + } + + out += pickChar(intensity, time) + } + return out +} + +/** + * 文字 overlay:在某行的 x 位置覆盖 text 字符串。 + * 后渲染的 overlay 在相同位置覆盖先渲染的(last-write-wins)。 + */ +export type Overlay = { + text: string + /** 起始列;可为负(前缀被截断) */ + x: number +} + +/** + * 把 overlays 文字覆盖到 ripple 背景上。 + * - 文字字符永远胜出(即使背景是高密度字符) + * - 超出右边界的文字被截断 + * - x 为负时跳过前 |x| 个字符 + * + * @param ripple 原始波纹字符串(长度 = 行宽) + * @param overlays 要覆盖的文字列表 + * @returns 合成后的字符串(长度严格等于 ripple.length) + */ +export function mergeLayers(ripple: string, overlays: Overlay[]): string { + const chars = ripple.split('') + for (const overlay of overlays) { + const start = overlay.x + if (start >= chars.length) continue + for (let i = 0; i < overlay.text.length; i++) { + const targetIdx = start + i + if (targetIdx < 0) continue + if (targetIdx >= chars.length) break + chars[targetIdx] = overlay.text[i] + } + } + return chars.join('') +}