From e637b4f6aaa2802829813aad419fc9b494897866 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sun, 14 Jun 2026 15:52:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(effort):=20=E6=B3=A2=E7=BA=B9=20v4=20?= =?UTF-8?q?=E2=80=94=20=E5=B9=B3=E6=BB=91=E6=B3=A2=20+=20=E5=85=A8?= =?UTF-8?q?=E8=89=B2=E7=8E=AF=E6=97=8B=E8=BD=AC=20+=20=E6=B7=A1=E5=85=A5?= =?UTF-8?q?=E6=B7=A1=E5=87=BA=20+=20=E5=AE=BD=E5=BA=A6=E8=87=AA=E9=80=82?= =?UTF-8?q?=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 波函数改 (sin+1)/2:消除 max(0,sin) 平直暗带(约 6 行宽) - 主色相连续旋转(0.03°/ms,12s/圈全色环):蓝→紫→品红→红→橙→黄→绿→青 - 文字 overlay 同步色相旋转(rotateHue 应用到 Faster/▲/档位名/分隔线/副标签) - 淡入淡出动画:fadeColor/fadeCells + fade 状态机 ~300ms 进出过渡 - 副标签固定 ultracode 段下方,不跟随光标移动 - 顶部/底部各加一行纯波纹行,视觉一致 - 宽度自适应终端列数:窄则 72,宽则铺满(computeSegment/computeRippleSourceX) - 快捷键改 plain Text,不参与波纹背景渲染 - 新增 18 测试(fadeColor/fadeCells/rotateHue/getHueShiftAtTime) Co-Authored-By: glm-5.2 --- src/components/EffortPanel/EffortPanel.tsx | 193 ++++++++++++----- .../__tests__/rippleAnimation.test.ts | 205 +++++++++++++++++- src/components/EffortPanel/rippleAnimation.ts | 173 ++++++++++++++- src/components/EffortPanel/useRippleFrame.ts | 7 +- 4 files changed, 515 insertions(+), 63 deletions(-) diff --git a/src/components/EffortPanel/EffortPanel.tsx b/src/components/EffortPanel/EffortPanel.tsx index 6846868ad..b82673dad 100644 --- a/src/components/EffortPanel/EffortPanel.tsx +++ b/src/components/EffortPanel/EffortPanel.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { BaseText, Box, Text } from '@anthropic/ink'; +import { BaseText, Box, Text, useTerminalSize } from '@anthropic/ink'; import { useKeybindings } from '../../keybindings/useKeybinding.js'; import { type EffortValue, getDisplayedEffortLevel, getEffortEnvOverride } from '../../utils/effort.js'; import { @@ -22,30 +22,70 @@ import { applyOverlaysToCells, cellsToSegments, computeRippleCells, + fadeCells, + getHueShiftAtTime, + rotateHue, } from './rippleAnimation.js'; -// 每档固定宽度,Ink Box 自动对齐。PANEL_WIDTH = SEGMENT * 6。 -const SEGMENT = 12; -const PANEL_WIDTH = SEGMENT * PANEL_POSITIONS.length; +/** + * 每档最小宽度(足够装下 'ultracode' 9 字符 + 居中留白)。 + * 当终端窄时使用此值,保证最低可读性。 + */ +const MIN_SEGMENT = 12; + const SUBLABEL_ULTRACODE = 'xhigh + workflows'; // 颜色:与项目主题对齐(suggestion=Medium blue #5769F7)。 const COLOR_LABEL_SELECTED = '#5769F7'; // 选中档位(suggestion) -const COLOR_LABEL_DEFAULT = '#8a8a8a'; // 未选中档位(subtle gray) +const COLOR_LABEL_DEFAULT = '#7a8eff'; // 未选中档位(淡紫蓝,与波纹背景协调) const COLOR_OVERLAY = '#5769F7'; // Faster / Smarter / ▲ 等 overlay 文字 -// 波纹震源坐标(相对波纹区域坐标系,y=0 是档位名行)。 -// ultracode 字符在 SEGMENT*5=60 起始段内居中(9 字符 in 12 列 → 偏移 1.5 → 1), -// 中心列 ≈ 60 + 1 + 4 = 65。 -const RIPPLE_SOURCE_X = SEGMENT * 5 + 5; +// 淡入淡出每帧步长:60ms 间隔下 5 帧达到目标 ≈ 300ms 动画时长。 +const FADE_STEP = 0.2; + +// 波纹震源 y 坐标(相对波纹区域坐标系,y=0 是档位名行)。 const RIPPLE_SOURCE_Y = 0; /** - * 计算某段 idx 内居中文字的起始列。 - * 'ultracode' = 9 字符 in SEGMENT=12 → offset = floor((12-9)/2) = 1。 + * 根据终端宽度计算每档实际宽度(SEGMENT)。 + * + * 规则: + * - 留出 paddingX={1} 的左右各 1 列 → 可用宽度 = columns - 2 + * - 若可用宽度 <= MIN_SEGMENT * 6(72),用 MIN_SEGMENT(保持当前窄布局) + * - 否则铺满:floor(可用宽度 / 6) + * + * 即"窄则不变,宽则铺满"。最小宽度保证 'ultracode' 9 字符能正常显示。 */ -function segmentTextStartX(idx: number, textLen: number): number { - return SEGMENT * idx + Math.max(0, Math.floor((SEGMENT - textLen) / 2)); +function computeSegment(terminalColumns: number): number { + const available = terminalColumns - 2; // paddingX={1} 两侧 + const minNeeded = MIN_SEGMENT * PANEL_POSITIONS.length; + if (available <= minNeeded) return MIN_SEGMENT; + return Math.floor(available / PANEL_POSITIONS.length); +} + +/** + * 计算波纹震源 x 坐标(ultracode 段内 'ultracode' 标签的中心列)。 + * + * 'ultracode' 是 9 字符,在 SEGMENT 列内居中: + * offset = floor((SEGMENT - 9) / 2) + * labelCenter = SEGMENT * 5 + offset + 4 (4 是 9 字符串的中心偏移) + * + * SEGMENT=12 → 60 + 1 + 4 = 65(与历史值一致) + * SEGMENT=20 → 100 + 5 + 4 = 109 + */ +function computeRippleSourceX(segment: number): number { + const LABEL_LEN = 9; // 'ultracode' + const offset = Math.max(0, Math.floor((segment - LABEL_LEN) / 2)); + const labelCenter = Math.floor(LABEL_LEN / 2); // 4 + return segment * (PANEL_POSITIONS.length - 1) + offset + labelCenter; +} + +/** + * 计算某段 idx 内居中文字的起始列。 + * 动态 segment:textLen 字符在 segment 列内居中。 + */ +function segmentTextStartX(idx: number, textLen: number, segment: number): number { + return segment * idx + Math.max(0, Math.floor((segment - textLen) / 2)); } type Props = { @@ -56,6 +96,13 @@ type Props = { export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode { const setAppState = useSetAppState(); const model = useMainLoopModel(); + const { columns } = useTerminalSize(); + + // 自适应宽度:根据终端列数计算每档宽度。 + // 终端变化(resize)时 columns 改变 → 重新计算 → 重渲染。 + const segment = React.useMemo(() => computeSegment(columns), [columns]); + const panelWidth = segment * PANEL_POSITIONS.length; + const rippleSourceX = React.useMemo(() => computeRippleSourceX(segment), [segment]); const envOverride = getEffortEnvOverride(); const displayed = getDisplayedEffortLevel(model, appStateEffort); @@ -64,8 +111,23 @@ export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode const [cursor, setCursor] = React.useState(initialCursor); const [done, setDone] = React.useState(false); - const rippleActive = cursor === 'ultracode'; - const [rippleRef, time] = useRippleFrame(rippleActive); + const isOnUltracode = cursor === 'ultracode'; + const [fade, setFade] = React.useState(0); + // 仍在波纹模式:cursor 在 ultracode,或退出动画未结束(fade > 0) + const showingRipple = isOnUltracode || fade > 0.001; + const [rippleRef, time] = useRippleFrame(showingRipple); + + // 淡入淡出驱动:每 tick(time 推进)朝目标步进 FADE_STEP。 + // 退出动画完成后 fade 归零,showingRipple 变 false,时钟停止订阅。 + React.useEffect(() => { + if (!showingRipple) return; + const target = isOnUltracode ? 1 : 0; + setFade(prev => { + if (prev === target) return prev; + const next = target > prev ? prev + FADE_STEP : prev - FADE_STEP; + return target > prev ? Math.min(target, next) : Math.max(target, next); + }); + }, [time, isOnUltracode, showingRipple]); const handleConfirm = React.useCallback(() => { if (done) return; @@ -102,32 +164,42 @@ export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL; // 波纹行 cells 计算:返回该行所有 cell(含 overlay 文字) + // fade 控制背景颜色亮度(0 → 全 transparent,1 → 完整波纹)。 + // 文字 overlay 也乘以 fade,让进入/退出动画整体淡入淡出。 const renderRippleRow = React.useCallback( (relY: number, overlays: Overlay[]): Segment[] => { const cells = computeRippleCells({ y: relY + RIPPLE_SOURCE_Y, - width: PANEL_WIDTH, + width: panelWidth, time, - sourceX: RIPPLE_SOURCE_X, + sourceX: rippleSourceX, sourceY: RIPPLE_SOURCE_Y, }); const overlayed = applyOverlaysToCells(cells, overlays); - return cellsToSegments(overlayed); + const faded = fadeCells(overlayed, fade); + return cellsToSegments(faded); }, - [time], + [time, fade, panelWidth, rippleSourceX], ); return ( - + Effort {envActive && {`⚠ CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides this session`}} - {rippleActive ? ( - + {showingRipple ? ( + ) : ( <> - + ←/→ adjust · Enter confirm · Esc cancel @@ -139,17 +211,25 @@ export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode // ---- 普通模式(无波纹)---- -function PlainContent({ cursor }: { cursor: PanelPosition }): React.ReactNode { +function PlainContent({ + cursor, + segment, + panelWidth, +}: { + cursor: PanelPosition; + segment: number; + panelWidth: number; +}): React.ReactNode { return ( <> Faster Smarter - {'─'.repeat(PANEL_WIDTH)} + {'─'.repeat(panelWidth)} {PANEL_POSITIONS.map(p => ( - + {cursor === p ? '▲' : ' '} @@ -158,7 +238,7 @@ function PlainContent({ cursor }: { cursor: PanelPosition }): React.ReactNode { {PANEL_POSITIONS.map(p => ( - + {p} @@ -166,8 +246,8 @@ function PlainContent({ cursor }: { cursor: PanelPosition }): React.ReactNode { ))} - - + + {SUBLABEL_ULTRACODE} @@ -193,58 +273,73 @@ function PlainContent({ cursor }: { cursor: PanelPosition }): React.ReactNode { type RippleContentProps = { renderRow: (relY: number, overlays: Overlay[]) => Segment[]; cursor: PanelPosition; + fade: number; + segment: number; + panelWidth: number; + time: number; }; -function RippleContent({ renderRow, cursor }: RippleContentProps): React.ReactNode { - const cursorIdx = PANEL_POSITIONS.indexOf('ultracode'); +function RippleContent({ renderRow, cursor, segment, panelWidth, time }: RippleContentProps): React.ReactNode { + // 光标索引跟随 cursor(退出动画期间 cursor 已移到别处, + // 让 ▲ overlay 跟着移走,ultracode 段恢复普通背景色)。 + const cursorIdx = PANEL_POSITIONS.indexOf(cursor); + // 副标签固定在 ultracode 段下方,不跟随光标移动。 + const ultracodeIdx = PANEL_POSITIONS.length - 1; - const fasterOverlay: Overlay = { text: 'Faster', x: 0, color: COLOR_OVERLAY }; + // 文字颜色跟随波浪色相旋转:取当前 time 的 hueShift, + // 应用到所有 overlay 颜色,让文字与背景色环保持同步。 + const hueShift = getHueShiftAtTime(time); + const overlayColor = rotateHue(COLOR_OVERLAY, hueShift); + const labelSelectedColor = rotateHue(COLOR_LABEL_SELECTED, hueShift); + const labelDefaultColor = rotateHue(COLOR_LABEL_DEFAULT, hueShift); + + const fasterOverlay: Overlay = { text: 'Faster', x: 0, color: overlayColor }; const smarterOverlay: Overlay = { text: 'Smarter', - x: PANEL_WIDTH - 'Smarter'.length, - color: COLOR_OVERLAY, + x: panelWidth - 'Smarter'.length, + color: overlayColor, }; const separatorOverlay: Overlay = { - text: '─'.repeat(PANEL_WIDTH), + text: '─'.repeat(panelWidth), x: 0, - color: COLOR_LABEL_DEFAULT, + color: labelDefaultColor, }; const cursorOverlay: Overlay = { text: '▲', - x: segmentTextStartX(cursorIdx, 1), - color: COLOR_OVERLAY, + x: segmentTextStartX(cursorIdx, 1, segment), + color: overlayColor, }; const labelOverlays: Overlay[] = PANEL_POSITIONS.map((p, idx) => ({ text: p, - x: segmentTextStartX(idx, p.length), - color: p === cursor ? COLOR_LABEL_SELECTED : COLOR_LABEL_DEFAULT, + x: segmentTextStartX(idx, p.length, segment), + color: p === cursor ? labelSelectedColor : labelDefaultColor, })); const sublabelOverlay: Overlay = { text: SUBLABEL_ULTRACODE, - x: segmentTextStartX(cursorIdx, SUBLABEL_ULTRACODE.length), - color: COLOR_LABEL_DEFAULT, - }; - const hintOverlay: Overlay = { - text: '←/→ adjust · Enter confirm · Esc cancel', - x: 0, - color: COLOR_LABEL_DEFAULT, + x: segmentTextStartX(ultracodeIdx, SUBLABEL_ULTRACODE.length, segment), + color: labelDefaultColor, }; // 各行 y 坐标(相对震源 RIPPLE_SOURCE_Y = 档位名行) + // y=-4: 顶部纯波纹行(视觉一致,无 overlay) // y=-3: Faster/Smarter // y=-2: 分隔线 // y=-1: ▲ // y=0: 档位名(震源) // y=1: 副标签 - // y=2: 快捷键行(y 方向延伸覆盖到底部) + // y=2: 底部纯波纹行(视觉一致,无 overlay) + // + // 快捷键行:plain Text,不参与波纹渲染(无背景动画),紧贴底部波纹行。 return ( <> + - + + ←/→ adjust · Enter confirm · Esc cancel ); } diff --git a/src/components/EffortPanel/__tests__/rippleAnimation.test.ts b/src/components/EffortPanel/__tests__/rippleAnimation.test.ts index 8407a2cc8..9613a842d 100644 --- a/src/components/EffortPanel/__tests__/rippleAnimation.test.ts +++ b/src/components/EffortPanel/__tests__/rippleAnimation.test.ts @@ -6,7 +6,11 @@ import { applyOverlaysToCells, cellsToSegments, computeRippleCells, + fadeCells, + fadeColor, + getHueShiftAtTime, intensityToColor, + rotateHue, } from '../rippleAnimation.js' describe('intensityToColor', () => { @@ -40,6 +44,104 @@ describe('intensityToColor', () => { test('intensity=1 → suggestion 档(波峰最高档)', () => { expect(intensityToColor(1)).toBe('#5769F7') }) + + test('hueShift=0 → 与无 hueShift 相同(快路径)', () => { + for (const v of [0, 0.2, 0.5, 0.8, 1]) { + expect(intensityToColor(v, 0)).toBe(intensityToColor(v)) + } + }) + + test('hueShift ≠ 0 → 返回不同颜色(但仍是合法 hex)', () => { + const base = intensityToColor(0.8) + const shifted = intensityToColor(0.8, 30) + expect(shifted).toMatch(/^#[0-9a-fA-F]{6}$/) + expect(shifted).not.toBe(base) + }) + + test('hueShift 180° → 大致补色(亮色变暗色族)', () => { + // #5769F7 ≈ HSL(233, 91, 65),旋转 180° → HSL(53, 91, 65) ≈ 黄色系 + const shifted = intensityToColor(1, 180) + expect(shifted).toMatch(/^#[0-9a-fA-F]{6}$/) + // 不再是蓝紫族(R 分量应明显大于 B 分量) + const r = parseInt(shifted.slice(1, 3), 16) + const b = parseInt(shifted.slice(5, 7), 16) + expect(r).toBeGreaterThan(b) + }) +}) + +describe('rotateHue', () => { + test('hueShift=0 → 原样返回(快路径,无 round-trip 误差)', () => { + expect(rotateHue('#5769F7', 0)).toBe('#5769F7') + expect(rotateHue('#1a1f3a', 0)).toBe('#1a1f3a') + }) + + test('旋转 360° → 等同原色(一圈回起点,大小写无关)', () => { + expect(rotateHue('#5769F7', 360).toLowerCase()).toBe('#5769f7') + expect(rotateHue('#5769F7', -360).toLowerCase()).toBe('#5769f7') + }) + + test('旋转 ±n*360° → 等同原色(任意整圈)', () => { + expect(rotateHue('#3a4582', 720).toLowerCase()).toBe('#3a4582') + expect(rotateHue('#3a4582', -1080).toLowerCase()).toBe('#3a4582') + }) + + test('灰度色(saturation=0)旋转后不变', () => { + // #808080 = (128,128,128),saturation=0,旋转无意义 + expect(rotateHue('#808080', 90)).toBe('#808080') + }) + + test('非法 hex → 原样返回(防御式)', () => { + expect(rotateHue('not-a-color', 90)).toBe('not-a-color') + expect(rotateHue('#123', 90)).toBe('#123') + }) + + test('旋转后保持 6 位 hex 格式', () => { + const rotated = rotateHue('#5769F7', 45) + expect(rotated).toMatch(/^#[0-9a-fA-F]{6}$/) + }) +}) + +describe('getHueShiftAtTime', () => { + test('time=0 → 0', () => { + expect(getHueShiftAtTime(0)).toBe(0) + }) + + test('time > 0 → 在 [0, 360) 范围内(连续旋转,非负)', () => { + for (const t of [100, 500, 1000, 2000, 5000, 10000, 50000, 100000]) { + const shift = getHueShiftAtTime(t) + expect(shift).toBeGreaterThanOrEqual(0) + expect(shift).toBeLessThan(360) + } + }) + + test('time 推进 → hueShift 单调递增(模 360)', () => { + // 在一个周期内(12000ms),hueShift 应单调递增 + const samples = [0, 1000, 2000, 3000, 4000, 5000, 6000] + const shifts = samples.map(getHueShiftAtTime) + for (let i = 1; i < shifts.length; i++) { + expect(shifts[i]).toBeGreaterThan(shifts[i - 1]) + } + }) + + test('周期 12000ms(time=12000 应回到 0,模 360)', () => { + // 12000ms * 0.03 = 360,% 360 = 0 + const shift = getHueShiftAtTime(12000) + expect(shift).toBe(0) + }) + + test('半周期 6000ms → hueShift=180(对面色相)', () => { + // 6000ms * 0.03 = 180 + expect(getHueShiftAtTime(6000)).toBe(180) + }) + + test('四分之一周期 3000ms → hueShift=90', () => { + expect(getHueShiftAtTime(3000)).toBe(90) + }) + + test('多周期循环:time=24000 等同 time=0', () => { + expect(getHueShiftAtTime(24000)).toBe(0) + expect(getHueShiftAtTime(36000)).toBe(0) + }) }) describe('computeRippleCells', () => { @@ -95,8 +197,8 @@ describe('computeRippleCells', () => { ).toEqual([]) }) - test('震源点 time=0 时为波谷(最暗档),time 推进后出现亮档', () => { - // dist=0,time=0 时 phase = -0 = 0,sin(0)=0 → wave=0 → intensity=0 → 最暗档 + test('震源点 time=0 时为中间档((sin+1)/2 → intensity=0.5),time 推进后扫过波峰/波谷', () => { + // v5 平滑波:dist=0,time=0 时 phase=0,sin(0)=0,(0+1)/2=0.5 → intensity=0.5 → 中间档 const t0 = computeRippleCells({ y: 5, width: 11, @@ -104,9 +206,10 @@ describe('computeRippleCells', () => { sourceX: 5, sourceY: 5, }) - expect(t0[5].color).toBe('#1a1f3a') + // 0.5 * 7 = 3.5, floor = 3, RIPPLE_COLOR_STOPS[3] = '#2e3870' + expect(t0[5].color).toBe('#2e3870') - // time 推进,phase 变化,震源会扫过波峰 + // time 推进,phase 变化,震源会扫过波峰(亮档)和波谷(暗档) const t1 = computeRippleCells({ y: 5, width: 11, @@ -114,7 +217,8 @@ describe('computeRippleCells', () => { sourceX: 5, sourceY: 5, }) - expect(t1[5].color).not.toBe('#1a1f3a') + // 不同 time 不同颜色(动画推进) + expect(t1[5].color).not.toBe('#2e3870') }) test('覆盖半径扩大:dist=65(左侧远端)仍有非最暗颜色', () => { @@ -304,3 +408,94 @@ describe('cellsToSegments', () => { expect(cellsToSegments(cells)[0].text).toBe('123') }) }) + +describe('fadeColor', () => { + test('fade=1 → 原色(不变)', () => { + expect(fadeColor('#5769F7', 1)).toBe('#5769f7') + }) + + test('fade=0 → TRANSPARENT(cell 不渲染)', () => { + expect(fadeColor('#5769F7', 0)).toBe(TRANSPARENT) + }) + + test('fade ≤ 0.01 → TRANSPARENT(阈值)', () => { + expect(fadeColor('#5769F7', 0.01)).toBe(TRANSPARENT) + expect(fadeColor('#5769F7', 0.009)).toBe(TRANSPARENT) + }) + + test('fade=0.5 → RGB 各分量减半', () => { + // #5769F7 = (87, 105, 247),减半 → (44, 53, 124) = #2c357c + // Math.round(87*0.5)=44, Math.round(105*0.5)=53, Math.round(247*0.5)=124 + expect(fadeColor('#5769F7', 0.5)).toBe('#2c357c') + }) + + test('TRANSPARENT 输入 → 原样返回(不处理)', () => { + expect(fadeColor(TRANSPARENT, 1)).toBe(TRANSPARENT) + expect(fadeColor(TRANSPARENT, 0.5)).toBe(TRANSPARENT) + }) + + test('非法 hex 格式 → 原样返回(防御式)', () => { + expect(fadeColor('not-a-color', 0.5)).toBe('not-a-color') + expect(fadeColor('#123', 0.5)).toBe('#123') // 非 6 位 hex + }) + + test('fade < 0 钳到 0 → TRANSPARENT', () => { + expect(fadeColor('#5769F7', -0.5)).toBe(TRANSPARENT) + }) + + test('fade > 1 钳到 1 → 原色', () => { + expect(fadeColor('#5769F7', 1.5)).toBe('#5769f7') + }) + + test('结果始终为 6 位 hex(前导零补全)', () => { + // #010203 = (1, 2, 3),fade=0.5 → Math.round 后为 (1, 1, 2) = #010102 + // 但 1*0.5 = 0.5, Math.round(0.5) = 1( banker's rounding 在 JS 中是 round half up) + // 验证格式:6 位 hex + const result = fadeColor('#010203', 0.5) + expect(result).toMatch(/^#[0-9a-f]{6}$/) + }) +}) + +describe('fadeCells', () => { + test('空数组 → 空数组', () => { + expect(fadeCells([], 0.5)).toEqual([]) + }) + + test('每个 cell 的颜色按 fade 缩放,char 保留', () => { + const cells: Cell[] = [ + { char: ' ', color: '#5769F7' }, + { char: 'A', color: '#ffffff' }, + ] + const out = fadeCells(cells, 0.5) + expect(out[0]).toEqual({ char: ' ', color: '#2c357c' }) + // #ffffff = (255, 255, 255),fade=0.5 → (128, 128, 128) = #808080 + expect(out[1]).toEqual({ char: 'A', color: '#808080' }) + }) + + test('不修改原数组(防御式拷贝)', () => { + const cells: Cell[] = [{ char: ' ', color: '#5769F7' }] + const snapshot = cells.map(c => ({ ...c })) + fadeCells(cells, 0.5) + expect(cells).toEqual(snapshot) + }) + + test('TRANSPARENT cell 保持 TRANSPARENT', () => { + const cells: Cell[] = [ + { char: ' ', color: TRANSPARENT }, + { char: ' ', color: '#5769F7' }, + ] + const out = fadeCells(cells, 0.5) + expect(out[0].color).toBe(TRANSPARENT) + expect(out[1].color).toBe('#2c357c') + }) + + test('fade=0 → 所有非 transparent 颜色变 TRANSPARENT', () => { + const cells: Cell[] = [ + { char: ' ', color: '#5769F7' }, + { char: ' ', color: '#1a1f3a' }, + ] + const out = fadeCells(cells, 0) + expect(out[0].color).toBe(TRANSPARENT) + expect(out[1].color).toBe(TRANSPARENT) + }) +}) diff --git a/src/components/EffortPanel/rippleAnimation.ts b/src/components/EffortPanel/rippleAnimation.ts index 97e4c9da7..d22f333c3 100644 --- a/src/components/EffortPanel/rippleAnimation.ts +++ b/src/components/EffortPanel/rippleAnimation.ts @@ -23,6 +23,9 @@ * 最暗档用 #1a1f3a(紫黑,亮度 ~12%),不是纯黑——避免远端波谷 * 看起来像"硬黑边"。波峰最高升到 suggestion (#5769F7),避免与 * 文字 overlay(也用 suggestion 系)同色互相吞噬。 + * + * 这些是 base 颜色(hueShift=0 时返回)。生产代码会传 hueShift 让 + * 整个梯度绕色相环旋转,制造主色随时间漂移的视觉效果。 */ const RIPPLE_COLOR_STOPS = [ '#1a1f3a', // 0.00 ~ 0.14 — 最暗(紫黑底色,非纯黑) @@ -34,19 +37,128 @@ const RIPPLE_COLOR_STOPS = [ '#5769F7', // 0.84 ~ 1.00 — suggestion (波峰) ] as const +/** + * 色相连续旋转速度(度/ms)。 + * 周期 = 360 / 0.03 = 12000ms = 12s,远慢于波纹相位(~1.6s), + * 让主色漂移感"ambient"而非"动画"。 + * + * 连续旋转(非 sin 振荡)让色相 0~360° 全色环都被访问: + * 蓝 233° → 紫 270° → 品红 300° → 红 0° → 橙 30° → 黄 60° → + * 绿 120° → 青 180° → 蓝 233°(一圈)。 + */ +const HUE_ROTATION_DEG_PER_MS = 0.03 + +/** + * hex → {h, s, l}(h 单位度,s/l 为 0~1)。 + * + * 标准 RGB → HSL 转换。非法 hex(非 #rrggbb)→ h=0, s=0, l=0(黑)。 + */ +function hexToHsl(hex: string): { h: number; s: number; l: number } { + if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return { h: 0, s: 0, l: 0 } + const r = parseInt(hex.slice(1, 3), 16) / 255 + const g = parseInt(hex.slice(3, 5), 16) / 255 + const b = parseInt(hex.slice(5, 7), 16) / 255 + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const l = (max + min) / 2 + const d = max - min + if (d === 0) return { h: 0, s: 0, l } + const s = d / (1 - Math.abs(2 * l - 1)) + let h: number + if (max === r) { + h = 60 * (((g - b) / d) % 6) + } else if (max === g) { + h = 60 * ((b - r) / d + 2) + } else { + h = 60 * ((r - g) / d + 4) + } + if (h < 0) h += 360 + return { h, s, l } +} + +/** + * {h, s, l} → hex。 + * + * 标准 HSL → RGB 转换。h 自动 mod 360 处理。 + */ +function hslToHex(h: number, s: number, l: number): string { + const hNorm = ((h % 360) + 360) % 360 + const c = (1 - Math.abs(2 * l - 1)) * s + const hPrime = hNorm / 60 + const x = c * (1 - Math.abs((hPrime % 2) - 1)) + let r = 0 + let g = 0 + let b = 0 + if (hPrime < 1) { + r = c + g = x + } else if (hPrime < 2) { + r = x + g = c + } else if (hPrime < 3) { + g = c + b = x + } else if (hPrime < 4) { + g = x + b = c + } else if (hPrime < 5) { + r = x + b = c + } else { + r = c + b = x + } + const m = l - c / 2 + const toHex = (v: number): string => + Math.round((v + m) * 255) + .toString(16) + .padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +/** + * 把 hex 颜色绕色相环旋转 hueShift 度。 + * + * 保持饱和度和亮度不变,仅旋转 hue。用于让 RIPPLE_COLOR_STOPS 整体 + * 漂移到不同色相(蓝→青→紫→蓝循环),制造主色随时间变化的效果。 + * + * 非法 hex 原样返回(防御式)。 + */ +export function rotateHue(hex: string, hueShift: number): string { + if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return hex + if (hueShift === 0) return hex // 快路径:避免无意义 round-trip + const { h, s, l } = hexToHsl(hex) + return hslToHex(h + hueShift, s, l) +} + +/** + * 根据 time 计算当前色相偏移(度,连续旋转)。 + * + * 返回值始终在 [0, 360) 区间,单调递增(模 360)。 + * 周期约 12s 一圈,覆盖完整色环。 + */ +export function getHueShiftAtTime(time: number): number { + return (time * HUE_ROTATION_DEG_PER_MS) % 360 +} + /** * 强度(任意实数)→ 颜色字符串。 * * 钳到 [0, 1],按 RIPPLE_COLOR_STOPS 分级。永不返回 transparent。 * intensity=0 → 最暗档(#1a1f3a,作为面板底色)。 + * + * @param hueShift 整个色阶绕色相环旋转的度数(0 = base 颜色)。 + * 生产代码传 getHueShiftAtTime(time) 实现主色漂移。 + * 测试代码传 0(默认)获得确定性输出。 */ -export function intensityToColor(intensity: number): string { +export function intensityToColor(intensity: number, hueShift = 0): string { const v = intensity < 0 ? 0 : intensity > 1 ? 1 : intensity const idx = Math.min( RIPPLE_COLOR_STOPS.length - 1, Math.floor(v * RIPPLE_COLOR_STOPS.length), ) - return RIPPLE_COLOR_STOPS[idx] + const base = RIPPLE_COLOR_STOPS[idx] + return hueShift === 0 ? base : rotateHue(base, hueShift) } /** @@ -99,17 +211,22 @@ const RIPPLE_BG_CHAR = ' ' /** * 计算面板某一行 y 的完整波纹 cell 列表。 * - * 波纹数学(v3 — 简化,移除震源高频涟漪): + * 波纹数学(v6.1 — 平滑呼吸 + 主色全色环旋转): * dx = x - sourceX * dy = (y - sourceY) * 1.5 (y 方向视觉拉伸,行高 > 字宽) * dist = sqrt(dx² + dy²) * phase = dist * 0.35 - time * 0.004 (速度调慢至原 1/3) - * wave = max(0, sin(phase)) + * wave = (sin(phase) + 1) / 2 ([−1,1] → [0,1],平滑无平带) * falloff = max(0, 1 - dist / 90) (覆盖半径扩到 90) * intensity = wave * falloff + * hueShift = (time * 0.03) % 360 (连续旋转,12s 一圈全色环) + * color = intensityToColor(intensity, hueShift) + * + * v6.1 改 hueShift 为连续旋转(v6 是 sin±25° 振荡,色域太窄到不了 + * 红黄)。现在每 12s 走完一圈完整色环:蓝→紫→品红→红→橙→黄→绿→青→蓝。 + * 两个时间常数(相位 0.004 vs hue 0.03)解耦,让"流动"和"变色"不同步。 * * 每位置强度经 intensityToColor → 颜色字符串(永不 transparent),写入 cell。 - * 即使 intensity=0(波谷)也得到最暗档 #1a1f3a 作为面板底色。 * * @returns 长度严格等于 width 的 Cell 数组 */ @@ -123,6 +240,8 @@ export function computeRippleCells(args: { const { y, width, time, sourceX, sourceY } = args if (width <= 0) return [] + const hueShift = getHueShiftAtTime(time) + const cells: Cell[] = new Array(width) for (let x = 0; x < width; x++) { const dx = x - sourceX @@ -131,7 +250,8 @@ export function computeRippleCells(args: { // 主波纹相位(速度调慢:原 0.012 → 0.004,约 1/3 速) const phase = dist * 0.35 - time * 0.004 - const wave = Math.max(0, Math.sin(phase)) + // 平滑呼吸:[−1,1] → [0,1],无平带,无双倍频率 + const wave = (Math.sin(phase) + 1) / 2 // 距离衰减(覆盖半径扩到 90:原 40) const falloff = Math.max(0, 1 - dist / 90) @@ -139,7 +259,7 @@ export function computeRippleCells(args: { cells[x] = { char: RIPPLE_BG_CHAR, - color: intensityToColor(intensity), + color: intensityToColor(intensity, hueShift), } } return cells @@ -200,3 +320,42 @@ export function cellsToSegments(cells: Cell[]): Segment[] { segments.push(current) return segments } + +/** + * 把 hex 颜色按 fade 因子(0~1)缩放亮度。 + * + * 用于进入/退出动画: + * - fade ≤ 0.01 → TRANSPARENT(cell 不渲染背景,等同终端默认) + * - fade = 0.5 → 颜色 RGB 各分量减半(暗紫蓝) + * - fade = 1 → 原色(完整波纹) + * + * 非法 hex(非 #rrggbb 格式)原样返回(防御式)。 + */ +export function fadeColor(color: string, fade: number): string { + if (color === TRANSPARENT) return TRANSPARENT + const f = fade < 0 ? 0 : fade > 1 ? 1 : fade + if (f <= 0.01) return TRANSPARENT + if (!/^#[0-9a-fA-F]{6}$/.test(color)) return color + const r = parseInt(color.slice(1, 3), 16) + const g = parseInt(color.slice(3, 5), 16) + const b = parseInt(color.slice(5, 7), 16) + const fr = Math.round(r * f) + .toString(16) + .padStart(2, '0') + const fg = Math.round(g * f) + .toString(16) + .padStart(2, '0') + const fb = Math.round(b * f) + .toString(16) + .padStart(2, '0') + return `#${fr}${fg}${fb}` +} + +/** + * 把整行 cells 的颜色按 fade 缩放(用于进入/退出动画)。 + * + * 不修改原数组,返回新数组。 + */ +export function fadeCells(cells: Cell[], fade: number): Cell[] { + return cells.map(c => ({ char: c.char, color: fadeColor(c.color, fade) })) +} diff --git a/src/components/EffortPanel/useRippleFrame.ts b/src/components/EffortPanel/useRippleFrame.ts index c3019b105..72226e88d 100644 --- a/src/components/EffortPanel/useRippleFrame.ts +++ b/src/components/EffortPanel/useRippleFrame.ts @@ -6,13 +6,16 @@ const RIPPLE_INTERVAL_MS = 60 * ultracode 波纹动画 hook。 * * 设计: - * - 仅当 enabled=true(cursor === 'ultracode')时订阅时钟,pass null 时 - * useAnimationFrame 内部不订阅 ClockContext,setInterval 不触发。 + * - 仅当 enabled=true(cursor === 'ultracode' 或退出淡出未结束)时订阅时钟, + * pass null 时 useAnimationFrame 内部不订阅 ClockContext,setInterval 不触发。 * - 返回 [ref, time]:ref 附到波纹容器(驱动 viewport-pause),time * 用于 computeRippleLine 计算各行的波纹相位。 * * enabled=false 时返回 time=0(下游基于 enabled 直接不渲染波纹层, * 但 0 仍是合法值,避免意外的 phase 输出 NaN)。 + * + * 注意:调用方应传 showingRipple(on ultracode || fade > 0),不是 rippleActive, + * 这样退出动画期间时钟继续推进,fade useEffect 才有 tick 触发。 */ export function useRippleFrame( enabled: boolean,