429 lines
16 KiB
Dart
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.rank}'),
|
|
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();
|