226 lines
9.0 KiB
Dart
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)),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|