import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../core/theme/tokens.dart'; import '../../../core/ui/live_planet.dart'; import '../../../core/ui/planet_25d.dart' show planetShapeImage; import '../../../core/data/planet_visual_state.dart'; import '../galaxy_data.dart'; import '../galaxy_motion.dart'; import '../../../core/i18n/app_locale.dart'; /// 갤럭시 맵의 행성 배치와 그리기. /// /// Figma 「갤럭시맵 화면」 명세 — /// *규칙 : 3d갤럭시 브라우징 줌인 줌아웃 · 행성 나의행성 한가운데* /// *동작 : 3d갤럭시 터치 이동 · 행성 터치시 화면 중심 전환이동* /// /// 중심 행성([PlanetNode.isCenter] — 기본은 내 행성, Ranking View 는 FPI 1위)이 원점에 /// 서고 나머지가 그 둘레를 돈다. 반지름은 순위를 따르므로 **위치 자체가 순위 정보**다 — /// 가까울수록 상위다. 내 행성은 어느 보기에서든 분홍 글로우 · 굵은 이름으로 드러난다. /// /// 깊이감은 네 가지로 낸다(검토회신서 3절 *「브라우징·줌 및 공간 깊이감」*). /// 1. 궤도 타원을 눕힌다([GalaxyMotion.tilt]). /// 2. 궤도 앞쪽(화면 아래)의 행성을 크게, 뒤쪽을 작게([GalaxyMotion.depthScale]). /// 3. 뒤쪽을 옅게([GalaxyMotion.depthFade]) — 대기 원근. /// 4. 앞쪽 행성이 뒤쪽을 가리도록 **그리는 순서를 깊이로 정한다**. /// 별밭 시차는 화면 좌표라 여기가 아니라 [GalaxyStarfield] 가 가진다. class GalaxyField extends StatelessWidget { const GalaxyField({ super.key, this.watchPlanet = noPlanetWatch, this.onOpenHistory, required this.planets, required this.selected, required this.onSelect, required this.spin, required this.pulse, }); final PlanetWatch watchPlanet; final ValueChanged? onOpenHistory; final List planets; final PlanetNode? selected; final ValueChanged onSelect; /// 궤도 회전 각도(rad). [GalaxyClock] 이 굴린다. final double spin; /// 0~1 을 반복하는 값. 링 맥동에 쓴다. final double pulse; /// 맵 논리 크기. [InteractiveViewer] 가 이 위를 훑는다. static const double extent = 900; /// 가장 안쪽 궤도의 반지름. static const double innerRing = 120; /// 궤도가 그대로 벌어질 때의 고리 간격 — 행성이 몇 개 없을 때 쓰는 값이다. static const double ringGap = 52; /// 맵 가장자리에 남기는 여백. 행성 일러스트가 반지름의 1.6배까지 나가고 /// 깊이 배율이 거기에 1.26배를 더 얹는다. static const double edgeMargin = 60; /// 맵에서 가장 낮은 순위. 궤도 간격을 정하는 유일한 입력이다. static int outerRankOf(List planets) => planets.fold(1, (m, p) => p.rank > m ? p.rank : m); /// 궤도 반지름. /// /// **간격은 행성 수를 따라 좁아진다.** [ringGap] 을 그대로 쓰면 8위부터 궤도가 /// 맵 반쪽(`extent/2`)을 넘어 [Stack] 에 잘려 나간다 — 30개 중 20개, 100개 중 /// 90개가 사라진다. 바깥 궤도가 [edgeMargin] 안쪽에 서도록 간격을 나눈다. static double ringOf(int rank, int outerRank) { final rings = math.max(1, outerRank - 1); final gap = math.min( ringGap, (extent / 2 - innerRing - edgeMargin) / rings, ); return innerRing + (rank - 1) * gap; } /// 행성의 논리 좌표. 그리기·히트 테스트·HUD 가 같은 식을 써야 어긋나지 않는다. static Offset positionOf( PlanetNode p, int index, double spin, int outerRank, ) { if (p.isCenter) return const Offset(extent / 2, extent / 2); // 순위가 낮을수록 멀어진다. 중심 행성을 뺀 나머지를 고리에 흩는다. final ring = ringOf(p.rank, outerRank); final base = (index * 2 * math.pi) / 5.4; // 바깥 궤도가 더 느리게 돈다. 같은 속도로 돌면 판때기 한 장이 도는 것처럼 // 보여 깊이가 죽는다. final angle = base + spin * GalaxyMotion.orbitSpeedOf(p.rank); return Offset( extent / 2 + ring * math.cos(angle), extent / 2 + ring * math.sin(angle) * GalaxyMotion.tilt, ); } /// 순위가 낮을수록 작다. **바닥을 두는 이유**는 원식(`26 - rank*1.4`)이 /// 19위부터 음수가 되기 때문이다. /// ponytail: 바닥이 9 라 순위가 아주 낮은 구간은 크기가 같다. 100개처럼 /// 빽빽한 맵에서 겹쳐 보이면 [ringOf] 의 간격에 크기를 묶는다. static double radiusOf(PlanetNode p) => p.isCenter ? 34 : (26 - p.rank * 1.4).clamp(9.0, 26.0); /// -1(뒤·화면 위) ~ +1(앞·화면 아래) 의 깊이. static double depthOf(Offset at) => ((at.dy - extent / 2) / (extent / 2 * GalaxyMotion.tilt)).clamp( -1.0, 1.0, ); /// 2.5D 깊이 배율. 궤도 앞쪽일수록 크다. static double depthScaleOf(Offset at) => 1 + depthOf(at) * GalaxyMotion.depthScale; /// 대기 원근 — 뒤쪽 행성이 옅어진다. static double depthOpacityOf(Offset at) => 1 - (1 - depthOf(at)) / 2 * GalaxyMotion.depthFade; /// 행성이 화면에서 차지하는 반지름. HUD 가 이 위에 붙는다. static double screenRadiusOf( PlanetNode p, int index, double spin, int outerRank, ) { final at = positionOf(p, index, spin, outerRank); return radiusOf(p) * depthScaleOf(at); } /// 행성 일러스트 — 생성 때 고른 형태([planetShapeImage]). 형태가 없는 행성(성운 · 옛 데이터)만 /// 내 행성은 홈과 같은 그림, 나머지는 시안 행성 2종을 돌려 쓴다. 색은 [LivePlanet25D] 가 입힌다. static String imageOf(PlanetNode p, int index) => planetShapeImage(p.planetType) ?? (p.isMine ? 'assets/figma/planet-home.png' : (index.isEven ? 'assets/figma/planet-1.png' : 'assets/figma/planet-2.png')); @override Widget build(BuildContext context) { // 앞에 있는 행성이 뒤를 가려야 입체로 읽힌다. 인덱스는 궤도 각도를 정하는 // 값이라 바꿀 수 없으므로, 인덱스를 들고 다니면서 깊이로만 다시 세운다. final outerRank = outerRankOf(planets); final ordered = [ for (final (i, p) in planets.indexed) (i, p, positionOf(p, i, spin, outerRank)), ]..sort((a, b) => a.$3.dy.compareTo(b.$3.dy)); return SizedBox( width: extent, height: extent, child: Stack( children: [ // 궤도선은 순위가 바뀔 때만 달라진다. 같은 층에 두면 궤도가 도는 매 // 프레임 다시 래스터화되므로 RepaintBoundary 로 층을 갈라 한 번만 굽는다. Positioned.fill( child: RepaintBoundary( child: CustomPaint( painter: _BackdropPainter( planets: planets, outerRank: outerRank, ), isComplex: true, willChange: false, ), ), ), // 터치 대상은 그림이 아니라 실제 위젯이다. CustomPaint 안에서 히트 // 테스트를 손으로 계산하면 줌 배율이 바뀔 때마다 어긋난다. for (final (i, p, at) in ordered) _PlanetHit( // 유일한 것은 이름이 아니라 문서 ID 다 — 동명 팬덤이 둘이면 // 이름으로는 둘 다 선택 상태로 그려진다. 깊이 순으로 자리를 // 바꿔도 위젯이 살아남게 하는 키이기도 하다. key: ValueKey(p.id), planet: p, watchPlanet: watchPlanet, image: imageOf(p, i), center: at, radius: radiusOf(p) * depthScaleOf(at), opacity: depthOpacityOf(at), selected: selected?.id == p.id, onTap: () => onSelect(p), onOpenHistory: onOpenHistory == null ? null : () => onOpenHistory!(p), ), ], ), ); } } class _PlanetHit extends StatelessWidget { final PlanetWatch watchPlanet; final VoidCallback? onOpenHistory; const _PlanetHit({ required this.watchPlanet, this.onOpenHistory, super.key, required this.planet, required this.image, required this.center, required this.radius, required this.opacity, required this.selected, required this.onTap, }); final PlanetNode planet; final String image; final Offset center; final double radius; final double opacity; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { // 일러스트는 구름·장식이 본체 밖까지 나가 있어 반지름의 3배로 그린다. // 터치 타깃도 그 크기다. final side = radius * 3.2; final glow = planet.isMine ? AppColors.cosmicAccent : AppColors.neonViolet; // 선택한 행성은 깊이와 무관하게 또렷하다 — HUD 와 한 정보군으로 읽혀야 한다. final fade = selected ? 1.0 : opacity; return Positioned( left: center.dx - side / 2, top: center.dy - side / 2, width: side, height: side, child: Semantics( button: true, label: context.l10n.galaxyPlanetRank(planet.name, planet.rankLabel), selected: selected, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Opacity( opacity: fade, child: Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ // 2. 글로우 아우라 — 선택하면 진해진다. Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, gradient: RadialGradient( colors: [ glow.withValues(alpha: selected ? 0.55 : 0.32), glow.withValues(alpha: 0), ], stops: const [0.3, 0.75], ), ), ), ), // 3~5. 본체 일러스트(표면 장식·구름 포함). LivePlanet25D( onOpenHistory: onOpenHistory, fandomId: planet.id, watch: watchPlanet, image: image, size: side, stage: planet.stage, glow: glow, animate: selected, lowEffects: !selected, ), Positioned( bottom: -4, child: Text( planet.name, style: TextStyle( fontSize: planet.isMine ? 13 : 11, color: selected ? AppColors.cosmicAccent : AppColors.onCosmic, fontWeight: planet.isMine ? FontWeight.w600 : FontWeight.w400, shadows: const [ Shadow(color: Colors.black, blurRadius: 6), ], ), ), ), ], ), ), ), ), ); } } /// 움직이지 않는 배경 — 궤도선. /// /// 궤도 반지름은 순위를 따르므로 순위가 바뀔 때만 다시 그리면 된다. /// 별밭은 여기 없다. 시차를 주려면 화면 좌표에 있어야 해서 [GalaxyStarfield] 로 뺐다. class _BackdropPainter extends CustomPainter { const _BackdropPainter({required this.planets, required this.outerRank}); final List planets; final int outerRank; @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final orbit = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 1 ..color = AppColors.cosmicOutline.withValues(alpha: 0.5); for (final p in planets.where((p) => !p.isCenter)) { final ring = GalaxyField.ringOf(p.rank, outerRank); canvas.drawOval( Rect.fromCenter( center: center, width: ring * 2, height: ring * 2 * GalaxyMotion.tilt, ), orbit, ); } } /// 순위가 그대로면 다시 그리지 않는다 — 궤도 배치를 정하는 유일한 입력이다. @override bool shouldRepaint(_BackdropPainter old) => old.planets.length != planets.length || old.outerRank != outerRank || !_sameRanks(old.planets, planets); static bool _sameRanks(List a, List b) { for (var i = 0; i < a.length; i++) { if (a[i].rank != b[i].rank || a[i].isCenter != b[i].isCenter) return false; } return true; } } /// 별밭 — 맵보다 **덜** 움직여서 멀리 있어 보이게 하는 층(시차). /// /// 맵과 같이 움직이면 별이 맵에 붙은 무늬가 된다. 화면 좌표에 두고 /// [GalaxyMotion.parallax] 만큼만 따라오게 해야 「그 뒤의 공간」이 된다. /// 별 자체는 [RepaintBoundary] 안에서 한 번만 굽고 이동·확대는 합성으로 처리한다 /// — 매 프레임 별 220개를 다시 그리면 팬·줌이 끊긴다. class GalaxyStarfield extends StatelessWidget { const GalaxyStarfield({super.key, required this.view}); /// 맵의 현재 변환. 이동량과 배율을 여기서 읽는다. final Matrix4 view; @override Widget build(BuildContext context) { final t = view.getTranslation(); final scale = view.getMaxScaleOnAxis(); const limit = GalaxyMotion.parallaxLimit; final dx = (t.x * GalaxyMotion.parallax).clamp(-limit, limit); final dy = (t.y * GalaxyMotion.parallax).clamp(-limit, limit); return ClipRect( child: Transform.translate( offset: Offset(dx, dy), child: Transform.scale( scale: 1 + (scale - 1) * GalaxyMotion.parallaxZoom, child: OverflowBox( minWidth: 0, minHeight: 0, maxWidth: double.infinity, maxHeight: double.infinity, child: SizedBox( // 시차로 밀리는 만큼 화면보다 크게 그려 둔다. width: MediaQuery.sizeOf(context).width + limit * 2, height: MediaQuery.sizeOf(context).height + limit * 2, child: const RepaintBoundary( child: CustomPaint(painter: _StarfieldPainter()), ), ), ), ), ), ); } } class _StarfieldPainter extends CustomPainter { const _StarfieldPainter(); @override void paint(Canvas canvas, Size size) { // 좌표는 고정 시드로 뽑는다. 매번 새로 뽑으면 프레임마다 별이 튄다. final rnd = math.Random(20260831); for (var i = 0; i < 220; i++) { // 먼 별은 작고 옅다 — 별밭 안에서도 층이 진다. final depth = rnd.nextDouble(); canvas.drawCircle( Offset(rnd.nextDouble() * size.width, rnd.nextDouble() * size.height), 0.4 + depth * 1.1, Paint() ..color = AppColors.onCosmicVariant.withValues( alpha: 0.14 + depth * 0.24, ), ); } } @override bool shouldRepaint(_StarfieldPainter old) => false; } /// 배경 층의 그리기 비용. 이 층은 프레임마다 도는 것이 아니라 한 번만 굽는다. @visibleForTesting CustomPainter galaxyBackdropPainterForTest({ required List planets, }) => _BackdropPainter( planets: planets, outerRank: GalaxyField.outerRankOf(planets), ); /// 별밭 층의 그리기 비용. @visibleForTesting CustomPainter galaxyStarfieldPainterForTest() => const _StarfieldPainter();