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)), ); } } }); }); }