Files
fanuzu/app/test/galaxy_motion_test.dart
T

226 lines
9.0 KiB
Dart

import 'dart:math' as math;
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_motion.dart';
import 'package:fancake/features/galaxy/widgets/galaxy_field.dart';
import 'package:flutter_test/flutter_test.dart';
/// 검토회신서 3절 *「드래그/회전 시 자연스러운 관성과 감속」*·*「공간 깊이감」*의
/// 회귀 방지. 눈으로 보는 값이라 화면 없이 물리만 잰다.
void main() {
group('회전 관성 (GalaxyClock)', () {
test('손을 대지 않으면 기본 표류 속도로 돈다', () {
final clock = GalaxyClock();
for (var i = 0; i < 60; i++) {
clock.advance(1 / 60);
}
expect(clock.velocity, closeTo(GalaxyMotion.idleSpin, 1e-9));
expect(clock.spin, closeTo(GalaxyMotion.idleSpin, 1e-6));
});
test('드래그 속도가 각속도를 만든다 — 거리가 아니라 속도다', () {
final fast = GalaxyClock()..drag(30); // 한 프레임에 30px
fast.advance(1 / 60);
final slow = GalaxyClock();
// 같은 30px 를 열 프레임에 나눠 끈다. 거리는 같고 속도는 1/10 이다.
for (var i = 0; i < 10; i++) {
slow.drag(3);
slow.advance(1 / 60);
}
expect(fast.velocity, greaterThan(slow.velocity * 5));
});
test('손을 떼면 감속해서 기본 표류로 돌아온다', () {
final clock = GalaxyClock()..drag(40);
clock.advance(1 / 60);
final kicked = clock.velocity;
expect(kicked, greaterThan(GalaxyMotion.idleSpin));
// 한 e-폴딩(1/friction 초) 뒤에는 초과분이 1/e 만 남는다.
final steps = (1 / GalaxyMotion.spinFriction * 60).round();
for (var i = 0; i < steps; i++) {
clock.advance(1 / 60);
}
final excess = clock.velocity - GalaxyMotion.idleSpin;
expect(excess, closeTo((kicked - GalaxyMotion.idleSpin) / math.e, 0.02));
// 오래 두면 결국 기본 표류다 — 영원히 돌지 않는다.
for (var i = 0; i < 600; i++) {
clock.advance(1 / 60);
}
expect(clock.velocity, closeTo(GalaxyMotion.idleSpin, 1e-6));
});
// onInteractionUpdate 가 focalPointDelta.dx 를 무조건 넘기면
// 세로 드래그·핀치 줌이 각속도를 덮어써 궤도가 선다.
test('세로 드래그·핀치 줌은 각속도를 바꾸지 않는다', () {
final dragged = GalaxyClock();
final pinched = GalaxyClock();
for (var i = 0; i < 30; i++) {
// 세로로 끄는 동안에도 가로 성분은 조금씩 섞여 들어온다.
dragged.drag(GalaxyMotion.spinDragOf(1, const Offset(3, -18)));
// 핀치는 손가락이 둘이다 — 초점이 옆으로 밀려도 회전이 아니다.
pinched.drag(GalaxyMotion.spinDragOf(2, const Offset(24, 2)));
dragged.advance(1 / 60);
pinched.advance(1 / 60);
}
expect(dragged.velocity, closeTo(GalaxyMotion.idleSpin, 1e-9));
expect(pinched.velocity, closeTo(GalaxyMotion.idleSpin, 1e-9));
// 가로 드래그는 여전히 민다 — 필터가 회전 자체를 죽이면 안 된다.
final swiped = GalaxyClock()
..drag(GalaxyMotion.spinDragOf(1, const Offset(24, -6)));
swiped.advance(1 / 60);
expect(swiped.velocity, greaterThan(GalaxyMotion.idleSpin));
});
test('아무리 세게 던져도 상한을 넘지 않는다', () {
final clock = GalaxyClock()..drag(100000);
clock.advance(1 / 60);
expect(clock.velocity, lessThanOrEqualTo(GalaxyMotion.maxSpin));
});
});
group('깊이감 (GalaxyField)', () {
const front = Offset(GalaxyField.extent / 2, GalaxyField.extent);
const back = Offset(GalaxyField.extent / 2, 0);
test('앞쪽 행성이 크고 뒤쪽이 작다', () {
expect(
GalaxyField.depthScaleOf(front),
greaterThan(GalaxyField.depthScaleOf(back)),
);
});
test('뒤쪽 행성이 옅다 — 대기 원근', () {
expect(GalaxyField.depthOpacityOf(front), closeTo(1, 1e-9));
expect(
GalaxyField.depthOpacityOf(back),
closeTo(1 - GalaxyMotion.depthFade, 1e-9),
);
});
test('바깥 궤도가 더 느리게 돈다', () {
expect(
GalaxyMotion.orbitSpeedOf(6),
lessThan(GalaxyMotion.orbitSpeedOf(2)),
);
});
// 기본 궤도 속도가 프로토타입의 1/4~1/5 이었다.
// 프로토타입은 `time = Date.now()*0.0005` 에 `speed = 0.8/(rank+1)` 를 곱한다.
test('기본 궤도 각속도가 프로토타입과 같다 — 0.4/(rank+1) rad/s', () {
for (final rank in [2, 3, 6, 12]) {
expect(
GalaxyMotion.idleSpin * GalaxyMotion.orbitSpeedOf(rank),
closeTo(0.4 / (rank + 1), 1e-9),
reason: '$rank위',
);
}
// rank 2 는 1회전 47초다.
expect(
2 * math.pi / (GalaxyMotion.idleSpin * GalaxyMotion.orbitSpeedOf(2)),
closeTo(47, 0.5),
);
});
test('내 행성은 회전과 무관하게 한가운데다', () {
const mine = PlanetNode(
id: 'bts', name: 'BTS', rank: 1, fpi: 1, members: 1, online: 1,
isMine: true,
);
expect(
GalaxyField.positionOf(mine, 0, 9.9, 6),
const Offset(GalaxyField.extent / 2, GalaxyField.extent / 2),
);
});
// 정책 원장 B-21 — 기본은 내 Primary 중심, Ranking View 는 FPI 상위 중심.
test('Ranking View 는 FPI 1위(성운 제외)를 가운데 두고 내 행성은 순위 궤도를 돈다', () {
const snapshot = GalaxySnapshot(
myPop: 0,
planets: [
PlanetNode(id: 'neb', name: '성운', rank: 1, fpi: 900, members: 1, online: 1, stage: 0),
PlanetNode(id: 'top', name: 'TOP', rank: 1, fpi: 900, members: 1, online: 1, stage: 3),
PlanetNode(id: 'tie', name: 'TIE', rank: 1, fpi: 900, members: 1, online: 1, stage: 2),
PlanetNode(id: 'mine', name: '내 행성', rank: 4, fpi: 10, members: 1, online: 1, isMine: true),
],
);
const center = Offset(GalaxyField.extent / 2, GalaxyField.extent / 2);
expect(snapshot.planets.where((p) => p.isCenter).map((p) => p.id), ['mine']);
final ranking = snapshot.rankingView.planets;
expect(ranking.where((p) => p.isCenter).map((p) => p.id), ['top']);
expect(GalaxyField.positionOf(ranking[1], 1, 3.3, 4), center);
final mine = ranking.last;
expect(mine.isMine, isTrue, reason: '내 행성 강조는 보기와 무관하다');
expect(GalaxyField.positionOf(mine, 3, 3.3, 4), isNot(center));
expect(GalaxyField.radiusOf(ranking[1]), greaterThan(GalaxyField.radiusOf(mine)));
});
test('궤도는 눕는다 — 세로 폭이 가로 폭보다 좁다', () {
const other = PlanetNode(
id: 'ive', name: 'IVE', rank: 4, fpi: 1, members: 1, online: 1,
);
var maxDx = 0.0;
var maxDy = 0.0;
for (var i = 0; i < 200; i++) {
final at = GalaxyField.positionOf(other, 1, i * 0.1, 6);
maxDx = math.max(maxDx, (at.dx - GalaxyField.extent / 2).abs());
maxDy = math.max(maxDy, (at.dy - GalaxyField.extent / 2).abs());
}
expect(maxDy, lessThan(maxDx));
});
// extent=900 에 간격 52 를 그대로 쓰면 8위부터 궤도가 맵 반쪽을
// 넘어 Stack 에 잘리고, 반지름은 19위부터 음수가 된다.
test('행성이 많아도 전부 맵 안에 남는다 — 6·30·100개', () {
for (final n in [6, 30, 100]) {
final planets = [
for (var i = 0; i < n; i++)
PlanetNode(
id: 'p$i',
name: 'p$i',
rank: i + 1,
fpi: 1,
members: 1,
online: 1,
isMine: i == 0,
),
];
final outer = GalaxyField.outerRankOf(planets);
expect(outer, n);
for (final (i, p) in planets.indexed) {
final r = GalaxyField.radiusOf(p);
expect(r, greaterThan(0), reason: '$n개 중 ${p.rank}위의 반지름');
for (var s = 0; s < 40; s++) {
final at = GalaxyField.positionOf(p, i, s * 0.37, outer);
// 눈에 보이는 반지름은 일러스트 1.6배에 깊이 배율까지 얹은 값이다.
final edge = r * GalaxyField.depthScaleOf(at) * 1.6;
expect(at.dx - edge, greaterThanOrEqualTo(0), reason: '$n개 ${p.rank}위 좌');
expect(at.dx + edge, lessThanOrEqualTo(GalaxyField.extent),
reason: '$n개 ${p.rank}위 우');
expect(at.dy - edge, greaterThanOrEqualTo(0), reason: '$n개 ${p.rank}위 상');
expect(at.dy + edge, lessThanOrEqualTo(GalaxyField.extent),
reason: '$n개 ${p.rank}위 하');
}
}
}
});
test('궤도는 순위 순서를 지킨다 — 상위가 안쪽이다', () {
for (final outer in [6, 30, 100]) {
for (var rank = 2; rank < outer; rank++) {
expect(
GalaxyField.ringOf(rank, outer),
lessThan(GalaxyField.ringOf(rank + 1, outer)),
);
}
}
});
});
}