feat(effort): 波纹动画纯函数 pickChar/computeRippleLine/mergeLayers + 18 测试

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-14 14:48:45 +08:00
parent 8bc1a33f3c
commit fe01c728f2
2 changed files with 310 additions and 0 deletions

View File

@@ -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<string>()
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=0falloff=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 长 9A 在 0B 在 4C 在 8其余保留 '·'
expect(mergeLayers(ripple, overlays)).toBe('A···B···C')
})
test('overlay 超出右边界被截断', () => {
const ripple = '·····'
const overlays: Overlay[] = [{ text: 'abcdef', x: 3 }]
// ripple 长 5overlay 从 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 长 5A 在 0B 在 1覆盖第一个 A2 为 A3,4 为 '·'(未被 overlay 覆盖)
expect(mergeLayers(ripple, overlays)).toBe('ABA··')
})
})

View File

@@ -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('')
}