Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 43 additions & 73 deletions src/components/ParticleField/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
createFrame,
createGroups,
createMotion,
edgeRadius,
envelope,
follow,
frameGap,
Expand All @@ -18,12 +17,10 @@ import {
layoutParticles,
LEVELS,
levelStyle,
ringIntensity,
ringBase,
ringRadius,
spacingFor,
stepMotion,
TAU,
wavePhase,
} from './field';

const WIDTH = 1200;
Expand Down Expand Up @@ -58,42 +55,39 @@ describe('粒子光环计算', () => {
expect(layoutParticles(3840, 2160, s).count).toBeLessThan(2500);
});

it('软边在半径附近起伏,被搅动时运动反方向拉长', () => {
it('环半径只随时间呼吸(±5% 内),不随指针速度变化', () => {
const r = 300;
expect(envelope(0, r)).toBe(1);
expect(envelope(r * 0.6, r)).toBeLessThan(0.1);
expect(ringRadius(100, 100)).toBe(120);
expect(ringRadius(4000, 3000)).toBe(340);
for (let theta = 0; theta < TAU; theta += 0.2) {
const calm = edgeRadius(theta, 3, r, 0, 0);
expect(calm).toBeGreaterThan(r * 0.88);
expect(calm).toBeLessThan(r * 1.12);
for (let t = 0; t < 20; t += 0.05) {
expect(ringBase(r, t)).toBeGreaterThan(r * 0.95);
expect(ringBase(r, t)).toBeLessThan(r * 1.05);
}
// 向右移动(heading = 0):左侧(尾迹)比静止时更长,右侧(迎面)更短
expect(edgeRadius(Math.PI, 0, r, 1, 0)).toBeGreaterThan(edgeRadius(Math.PI, 0, r, 0, 0));
expect(edgeRadius(0, 0, r, 1, 0)).toBeLessThan(edgeRadius(0, 0, r, 0, 0));
expect(ringBase(r, 0)).toBe(r * (1 + 0.02));
});

it('静态帧:边缘附近最亮,远处不画;线段大致沿径向', () => {
it('静态帧:环带附近最亮,远处只剩基础明暗的淡点;线段大致沿径向', () => {
const { layout, frame, radius } = setup();
const motion = createMotion(WIDTH / 2, HEIGHT / 2);
const visible = computeFrame(layout, frame, motion, radius, 0, 0);
expect(visible).toBeGreaterThan(0);
expect(visible).toBeLessThan(layout.count);
// 基础明暗让全场隐约可见(对齐参考站的 ambient 项)
expect(visible).toBe(layout.count);

let nearSum = 0;
let nearCount = 0;
let innerSum = 0;
let innerCount = 0;
let farDrawn = 0;
let farMaxLevel = 0;
let maxTilt = 0;
for (let i = 0; i < layout.count; i++) {
const dx = layout.x[i] - motion.x;
const dy = layout.y[i] - motion.y;
const d = Math.hypot(dx, dy);
const u = d - edgeRadius(Math.atan2(dy, dx), 0, radius, 0, 0);
const u = d - ringBase(radius, 0);
const level = levelOf(frame.bucket[i]);
if (d > radius * 4 && level >= 0) farDrawn++;
if (d > radius * 4) farMaxLevel = Math.max(farMaxLevel, level);
if (Math.abs(u) < 10) {
nearSum += Math.max(level, 0);
nearCount++;
Expand All @@ -110,50 +104,24 @@ describe('粒子光环计算', () => {
maxTilt = Math.max(maxTilt, Math.abs(sx * dy - sy * dx) / (Math.hypot(sx, sy) * d));
}
}
expect(farDrawn).toBe(0);
// 远处最多到「淡点」档,不会画出亮线
expect(farMaxLevel).toBeLessThanOrEqual(1);
expect(nearSum / nearCount).toBeGreaterThan(innerSum / innerCount + 2);
// 摆动上限:浪坡 0.22 + 自身晃动 0.06 rad
expect(maxTilt).toBeLessThanOrEqual(Math.sin(0.28) + 1e-4);
// 摆动上限:自身晃动 0.08 rad
expect(maxTilt).toBeLessThanOrEqual(Math.sin(0.08) + 1e-4);
});

it('浪峰随时间向外推进,搅动时浪更急、反差更大', () => {
const r = 300;
const at = (u: number, t: number, energy = 0) =>
ringIntensity(envelope(u, r), Math.sin(wavePhase(u, r, t, energy)), energy);
// 某一时刻离边缘最近的亮度局部极大值
const crestNear = (from: number, t: number) => {
let best = Infinity;
let pos = from;
for (let u = -r * 0.5; u <= r * 0.5; u += 0.5) {
const isPeak = at(u, t) > at(u - 0.5, t) && at(u, t) >= at(u + 0.5, t);
if (isPeak && Math.abs(u - from) < best) {
best = Math.abs(u - from);
pos = u;
}
}
return pos;
};
const first = crestNear(0, 0);
expect(crestNear(first, 0.2)).toBeGreaterThan(first);
// 同一位置,扰动能量让浪推进得更快
expect(wavePhase(0, r, 1, 1)).toBeLessThan(wavePhase(0, r, 1, 0));
// 浪谷(crest = -1)在平静时仍保留大部分亮度,搅动时更暗
expect(ringIntensity(1, -1, 0)).toBeCloseTo(0.65);
expect(ringIntensity(1, -1, 1)).toBeLessThan(ringIntensity(1, -1, 0));
expect(ringIntensity(1, 1, 1)).toBe(1);
});

it('弹簧:位移平滑追上目标并带一点回弹;亮度快起慢落', () => {
it('位移指数回归:环移走后单调衰减、不回弹;亮度快起慢落', () => {
const { layout, frame, radius } = setup();
const motion = createMotion(WIDTH / 2, HEIGHT / 2);
computeFrame(layout, frame, motion, radius, 0, 0);
// 找一个正好在边缘、正右方的粒子
// 找一个正好在环带、正右方的粒子
let probe = -1;
let best = Infinity;
for (let i = 0; i < layout.count; i++) {
const dx = layout.x[i] - motion.x;
const dy = layout.y[i] - motion.y;
const u = Math.abs(Math.hypot(dx, dy) - edgeRadius(Math.atan2(dy, dx), 0, radius, 0, 0));
const u = Math.abs(Math.hypot(dx, dy) - ringBase(radius, 0));
if (dx > 0 && Math.abs(dy) < 20 && u < best) {
best = u;
probe = i;
Expand All @@ -164,7 +132,7 @@ describe('粒子光环计算', () => {
const glowOn = frame.glow[probe];
expect(glowOn).toBeGreaterThan(0.5);

// 光环瞬间移走:位移不会立刻归零,而是被弹簧拉回、越过零点后再回来
// 环瞬间移走:位移被指数拉回,只是变慢,不会越过零点(无弹簧回弹)
motion.x = -5000;
let t = 0;
for (let n = 0; n < 6; n++) computeFrame(layout, frame, motion, radius, (t += STEP), STEP);
Expand All @@ -177,24 +145,17 @@ describe('粒子光环计算', () => {
computeFrame(layout, frame, motion, radius, (t += STEP), STEP);
minOffset = Math.min(minOffset, frame.ox[probe]);
}
expect(minOffset).toBeLessThan(0);
expect(Math.abs(frame.ox[probe])).toBeLessThan(0.5);
expect(frame.glow[probe]).toBeLessThan(0.05);
expect(minOffset).toBeGreaterThanOrEqual(0);
expect(frame.ox[probe]).toBeLessThan(0.5);
expect(frame.glow[probe]).toBeLessThan(0.15);
});

it('中心移动越快扰动能量越高,静止后平息', () => {
it('环心按速率缓动跟随,dt <= 0 不改变状态', () => {
const motion = createMotion(0, 0);
let x = 0;
for (let n = 0; n < 60; n++) stepMotion(motion, (x += 30), 0, STEP, 30);
expect(motion.energy).toBeGreaterThan(0.6);
expect(motion.vx).toBeGreaterThan(0);
const stirred = motion.energy;
for (let n = 0; n < 60; n++) stepMotion(motion, x, 0, STEP, 30);
expect(motion.energy).toBeLessThan(stirred);
expect(motion.energy).toBeGreaterThan(stirred * 0.3);
for (let n = 0; n < 600; n++) stepMotion(motion, x, 0, STEP, 30);
expect(motion.energy).toBeLessThan(0.01);
// dt <= 0 不改变状态
// 有效速率 ≈1.2/s:一秒后走掉大半段,但还留着明显的「慢半拍」
for (let n = 0; n < 60; n++) stepMotion(motion, 100, 0, STEP, 1.3);
expect(motion.x).toBeGreaterThan(60);
expect(motion.x).toBeLessThan(95);
const before = { ...motion };
stepMotion(motion, 9999, 9999, 0, 30);
expect(motion).toEqual(before);
Expand All @@ -215,10 +176,10 @@ describe('粒子光环计算', () => {
}
});

it('缓动与帧率无关、游走点留在视口内、色环与档位合法', () => {
it('缓动与帧率无关、游走点留在视口内、补丁色板与档位合法', () => {
// 两个半帧与一个整帧落到同一位置
const half = follow(follow(0, 100, 0.008, 5), 100, 0.008, 5);
expect(half).toBeCloseTo(follow(0, 100, 0.016, 5), 10);
const half = follow(follow(0, 100, 0.008, 1.3), 100, 0.008, 1.3);
expect(half).toBeCloseTo(follow(0, 100, 0.016, 1.3), 10);
for (let t = 0; t < 120; t += 0.7) {
const [x, y] = idleFocus(t, 1000, 600);
expect(x).toBeGreaterThan(0);
Expand All @@ -229,9 +190,18 @@ describe('粒子光环计算', () => {
const [, top] = idleFocus(t, 390, 844);
expect(top).toBeLessThan(844 * 0.22);
}
const palette = buildPalette();
expect(palette).toHaveLength(HUES);
palette.forEach((c) => expect(c).toMatch(/^rgb\(\d{1,3},\d{1,3},\d{1,3}\)$/));
// 三色补丁(对齐参考站):暗色从 #7189ff 起步到黑;亮色蓝→红→黄
const dark = buildPalette('dark');
const light = buildPalette('light');
expect(dark).toHaveLength(HUES);
expect(light).toHaveLength(HUES);
dark.forEach((c) => expect(c).toMatch(/^rgb\(\d{1,3},\d{1,3},\d{1,3}\)$/));
expect(dark[0]).toBe('rgb(113,137,255)');
expect(dark[HUES - 1]).toBe('rgb(0,0,0)');
expect(light[0]).toBe('rgb(44,100,237)');
// 中段落在红补丁附近(允许插值的少量偏移)
expect(light[Math.floor(HUES / 2)]).toMatch(/^rgb\(2[0-9]{2},(?:[6-9][0-9]),[0-9]{1,2}\)$/);
expect(light[HUES - 1]).toBe('rgb(255,207,3)');
expect(levelStyle(LEVELS - 1, false).alpha).toBeCloseTo(1);
expect(levelStyle(0, true).alpha).toBeGreaterThan(levelStyle(0, false).alpha);
});
Expand Down
Loading
Loading