Files
fanuzu/app/lib/features/galaxy/widgets/galaxy_field.dart
T

429 lines
16 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/live_planet.dart';
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<PlanetNode>? onOpenHistory;
final List<PlanetNode> planets;
final PlanetNode? selected;
final ValueChanged<PlanetNode> 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<PlanetNode> 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);
}
/// 행성 일러스트. 내 행성은 홈과 같은 그림, 나머지는 시안 행성 2종을 돌려 쓴다.
static String imageOf(PlanetNode p, int index) => 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<PlanetNode> 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<PlanetNode> a, List<PlanetNode> 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<PlanetNode> planets,
}) => _BackdropPainter(
planets: planets,
outerRank: GalaxyField.outerRankOf(planets),
);
/// 별밭 층의 그리기 비용.
@visibleForTesting
CustomPainter galaxyStarfieldPainterForTest() => const _StarfieldPainter();