HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent c4e9a9430e
commit 5de04ac281
44 changed files with 14898 additions and 16 deletions
@@ -0,0 +1,398 @@
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();
@@ -0,0 +1,802 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/planet_25d.dart';
import '../../../core/format.dart';
import '../galaxy_data.dart';
import '../../../core/i18n/app_locale.dart';
/// HOME GALAXY 미니맵 — 발주사 프로토타입 v6.2(`UZU HUD (2).html`)
/// 의 3D 장면을 [CustomPainter] 로 옮긴 것(정책 원장 C-49).
///
/// - 카메라·궤도·행성 크기·HUD 배치는 프로토타입 수식 그대로다. 게이지 값은 프로토타입
/// 기본값(Mass x1.5 · Orbit x1.0 · HUD x1.0 · Camera 2000 · Rotation 0°)으로 고정한다.
/// - **게이지 조절 8개는 넣지 않는다(C-51).**
/// - 색은 테마 결정 전까지 프로토타입 값을 쓴다(C-49).
/// - 데이터는 받은 [GalaxySnapshot] 만 쓴다. 프로토타입의 표본 5행성은 쓰지 않는다.
///
/// 반응: 스냅샷이 바뀌면(순위·FPI) 행성이 새 궤도로 미끄러지고 HUD 숫자가 바뀐다(프로토타입
/// `updateFPI`). 가로로 끌면 장면이 돈다(±180°, 프로토타입 View Rotation 범위). 누르면 [onOpen].
class GalaxyMiniMap extends StatefulWidget {
const GalaxyMiniMap({
super.key,
required this.data,
required this.onOpen,
this.size = 140,
});
final GalaxySnapshot data;
final VoidCallback onOpen;
/// 정사각 한 변. HOME 행성 영역 우측 상단(Q-DS-06 권고안 A · 회신 전 임시).
final double size;
@override
State<GalaxyMiniMap> createState() => _GalaxyMiniMapState();
}
class _GalaxyMiniMapState extends State<GalaxyMiniMap>
with SingleTickerProviderStateMixin {
final _painter = GalaxyMiniMapPainter();
late final Ticker _ticker = createTicker(_tick);
Duration _last = Duration.zero;
@override
void initState() {
super.initState();
_painter.update(widget.data.planets);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final motion =
Planet25D.animationsEnabled && !MediaQuery.disableAnimationsOf(context);
if (motion && !_ticker.isActive) {
_last = Duration.zero;
_ticker.start();
} else if (!motion) {
_ticker.stop();
_painter.settle();
}
}
@override
void didUpdateWidget(GalaxyMiniMap old) {
super.didUpdateWidget(old);
if (identical(old.data, widget.data)) return;
_painter.update(widget.data.planets);
if (!_ticker.isActive) _painter.settle();
}
void _tick(Duration now) {
final dt = _last == Duration.zero
? 0.0
: (now - _last).inMicroseconds / Duration.microsecondsPerSecond;
_last = now;
// 백그라운드 복귀처럼 프레임을 오래 건너뛴 뒤 한 번에 튀지 않게 한다.
_painter.advance(dt.clamp(0.0, 1 / 20));
}
@override
void dispose() {
_ticker.dispose();
_painter.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final mine = widget.data.mine;
return Semantics(
button: true,
label: [
if (mine != null) ...GalaxyMiniMapPainter.hudLines(mine) else 'GALAXY',
context.l10n.galaxyMapOpen,
].join(' · '),
onTap: widget.onOpen,
child: GestureDetector(
excludeFromSemantics: true,
onHorizontalDragUpdate: (d) {
_painter.turn(d.delta.dx / widget.size * math.pi);
if (!_ticker.isActive) _painter.settle();
},
child: ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.md),
child: ExcludeSemantics(
child: Stack(
children: [
// 매 프레임 다시 그리므로 HOME 히어로와 레이어를 가른다.
RepaintBoundary(
child: CustomPaint(
size: Size.square(widget.size),
painter: _painter,
isComplex: true,
willChange: true,
),
),
// 잉크는 불투명 장면 위에 얹어야 보인다.
Positioned.fill(
child: Material(
type: MaterialType.transparency,
child: InkWell(
excludeFromSemantics: true,
onTap: widget.onOpen,
),
),
),
],
),
),
),
),
);
}
}
/// 프로토타입 색(C-49 — 테마 결정 전까지 유지).
const _palette = [
Color(0xFF00F2FF),
Color(0xFF8B5CF6),
Color(0xFF10B981),
Color(0xFFF43F5E),
Color(0xFFF59E0B),
];
const _emerald = Color(0xFF10B981);
/// 행성 색 — 팬덤 `colorKey` 를 행성 색 팔레트([planetColors])로 칠한다.
/// 키가 없거나 모르는 값이면 FPI 순번으로 프로토타입 팔레트를 돈다.
Color _planetColor(PlanetNode p, int fpiIndex) =>
planetColorOf(p.colorKey) ?? _palette[fpiIndex % _palette.length];
/// 프로토타입 게이지 기본값. 조절 UI 는 없다(C-51).
const _massVolume = 1.5;
const _orbitSpacing = 450 * _massVolume * 1.0;
const _camY = 2000.0;
const _camZ = 3500.0;
final _camLen = math.sqrt(_camY * _camY + _camZ * _camZ);
/// 카메라가 원점을 본다 — 전방 (0, fy, fz) · 위 (0, uy, uz) · 오른쪽 (1, 0, 0).
final _fy = -_camY / _camLen;
final _fz = -_camZ / _camLen;
final _uy = _camZ / _camLen;
final _uz = -_camY / _camLen;
final _tanHalfFov = math.tan(35 * math.pi / 180);
/// 프로토타입 HUD 텍스처 크기.
const _hudW = 512.0;
const _hudH = 256.0;
/// 1 논리 픽셀보다 가는 선은 머리카락 선(1 물리 픽셀)으로.
double _px(double w) => w < 1 ? 0 : w;
/// 60fps 기준 한 프레임 lerp 계수 [k] 를 경과 시간에 맞춘다.
double _lerpK(double k, double dt) => 1 - math.pow(1 - k, dt * 60).toDouble();
class _Body {
_Body(this.id, this.phase);
final String id;
/// 프로토타입은 궤도 위상에 행성 id(1~5)를 더한다. 여기서는 처음 들어온 순번.
final double phase;
late PlanetNode node;
Object? _key;
double share = 0;
double rad = 0;
double target = 0;
double wx = 0;
double wz = 0;
double depth = 0;
double hudW = 0;
ui.Picture? hud;
ui.Picture? sphere;
Float32List sat = Float32List(0);
Float32List satOut = Float32List(0);
/// 프로토타입 `15 + fpi / 200` — fpi 는 슬라이더 최대 5000 척도로 옮긴 값.
double get baseR => 15 + share * 25;
int get ringCount => node.rank == 1 ? 3 : (node.rank <= 3 ? 2 : 1);
void set(PlanetNode p, Color color, double s) {
node = p;
target = (p.rank - 1) * _orbitSpacing;
final key = (p.name, p.rank, p.fpi, p.online, color, s);
if (key == _key) return;
_key = key;
share = s;
dispose();
hud = _hudPicture(p, color, s);
sphere = _spherePicture(color);
// 프로토타입 위성 수 `fpi / 10`.
final n = (s * 5000 / 10).floor();
final rnd = math.Random(p.id.hashCode);
sat = Float32List(n * 3);
satOut = Float32List(n * 2);
for (var j = 0; j < n; j++) {
final r = 1.8 + rnd.nextDouble() * 2.5;
final theta = rnd.nextDouble() * math.pi * 2;
final phi = rnd.nextDouble() * math.pi;
sat[j * 3] = r * math.sin(phi) * math.cos(theta);
sat[j * 3 + 1] = r * math.sin(phi) * math.sin(theta);
sat[j * 3 + 2] = r * math.cos(phi);
}
this.color = color;
}
Color color = _emerald;
void dispose() {
hud?.dispose();
sphere?.dispose();
hud = null;
sphere = null;
}
}
/// 프로토타입 `generateHUDTexture` — 512×256 텍스처를 한 번만 그려 두고 매 프레임 변환해 찍는다.
ui.Picture _hudPicture(PlanetNode p, Color color, double share) {
final rec = ui.PictureRecorder();
final canvas = Canvas(rec);
const box = Rect.fromLTWH(10, 10, 492, 236);
canvas.drawRect(
box,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 4
..color = color.withValues(alpha: 0.8),
);
canvas.drawRect(box, Paint()..color = const Color(0xE6000514));
canvas.save();
canvas.clipRect(box);
final lines = GalaxyMiniMapPainter.hudLines(p);
for (final (text, baseline, size, weight, c) in [
(lines[0], 80.0, 50.0, FontWeight.w800, color),
(lines[1], 120.0, 24.0, FontWeight.w700, const Color(0xB3FFFFFF)),
(lines[2], 180.0, 40.0, FontWeight.w800, _emerald),
(lines[3], 220.0, 24.0, FontWeight.w700, const Color(0xE6FFFFFF)),
]) {
final tp = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
fontFamily: 'monospace',
fontSize: size,
fontWeight: weight,
color: c,
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
)..layout();
tp.paint(
canvas,
Offset(
40,
baseline - tp.computeDistanceToActualBaseline(TextBaseline.alphabetic),
),
);
tp.dispose();
}
canvas.restore();
// 프로토타입 막대 `160 * fpi / 5000` — 1위 대비 비율로 옮겼다.
canvas.drawRect(
Rect.fromLTWH(450, 40, 20, 160 * share),
Paint()..color = _emerald,
);
return rec.endRecording();
}
/// 반지름 1 단위의 광륜(6R, 가산 합성) + 발광 구체.
ui.Picture _spherePicture(Color c) {
final rec = ui.PictureRecorder();
final canvas = Canvas(rec);
canvas.drawCircle(
Offset.zero,
3,
Paint()
..blendMode = BlendMode.plus
..shader = ui.Gradient.radial(
Offset.zero,
3,
[
c.withValues(alpha: 0.4),
c.withValues(alpha: 0.15),
c.withValues(alpha: 0),
],
const [0, 0.2, 0.6],
),
);
canvas.drawCircle(
Offset.zero,
1,
Paint()
..shader = ui.Gradient.radial(
const Offset(-0.35, -0.35),
1.35,
[
Color.lerp(c, const Color(0xFFFFFFFF), 0.6)!,
c,
Color.lerp(c, const Color(0xFF000000), 0.3)!,
],
const [0, 0.45, 1],
),
);
return rec.endRecording();
}
/// 미니맵 장면 상태와 그리기. 상태는 위젯이 [advance]·[settle] 로 움직인다.
class GalaxyMiniMapPainter extends CustomPainter {
GalaxyMiniMapPainter() : this._(ValueNotifier<int>(0));
GalaxyMiniMapPainter._(this._frame) : super(repaint: _frame);
final ValueNotifier<int> _frame;
/// 행성 위 HUD 네 줄 — 프로토타입 텍스처와 같은 문구.
static List<String> hudLines(PlanetNode p) => [
p.name.toUpperCase(),
'RANK #${p.rank}',
'FPI: ${p.fpi}',
'LIVE: ${formatPop(p.online)}',
];
final _bodies = <String, _Body>{};
final _order = <_Body>[];
_Body? _mine;
double _time = 0;
double _scale = 1;
double _rot = 0;
double _rotTarget = 0;
double _gx = 0;
double _gz = 0;
/// 그리는 행성 수(내 행성은 순위 밖이어도 더한다).
static const maxPlanets = 10;
/// 간략 카드(내 행성)의 최대 폭(미니맵 폭 대비).
static const hudFloor = 0.7;
/// 이 폭 이상이면 프로토타입 HUD 카드를 모든 행성에 그대로 그린다.
static const fullHudMin = 280.0;
/// 간략 카드 글자 크기(pt) — 이름 · 둘째 줄.
static const compactNamePt = 11.0;
static const compactLinePt = 9.0;
/// 간략 카드 둘째 줄 — `#순위 · FPI n · LIVE n`.
static String compactLine(PlanetNode p) =>
'#${p.rank} · FPI ${formatPop(p.fpi)} · LIVE ${formatPop(p.online)}';
ui.Picture? _compact;
Object? _compactKey;
Size _compactSize = Size.zero;
/// 마지막으로 만든 간략 카드의 줄과 글자 크기. 없으면 비어 있다.
@visibleForTesting
List<(String, double)> compactText = const [];
/// 내 행성 간략 카드. 데이터·최대 폭이 바뀔 때만 다시 만든다.
ui.Picture _compactCard(_Body b, double maxW) {
final key = (b._key, maxW);
if (key == _compactKey && _compact != null) return _compact!;
_compact?.dispose();
_compactKey = key;
const pad = 6.0, barW = 3.0, gap = 4.0;
final textMax = maxW - pad * 2 - barW - gap;
TextPainter line(String text, double pt, FontWeight weight, Color c) =>
TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
fontFamily: AppFonts.body,
fontSize: pt,
fontWeight: weight,
height: 1.2,
color: c,
),
),
textDirection: TextDirection.ltr,
maxLines: 2,
ellipsis: '…',
)..layout(maxWidth: textMax);
final name = line(
b.node.name.toUpperCase(),
compactNamePt,
FontWeight.w800,
b.color,
);
// 좁으면 LIVE 앞에서 둘째 줄이 한 번 꺾인다(글자 크기는 줄이지 않는다).
final stats = line(
compactLine(b.node),
compactLinePt,
FontWeight.w700,
const Color(0xE6FFFFFF),
);
final textW = math.max(name.width, stats.width);
final w = pad * 2 + textW + gap + barW;
final h = pad * 2 + name.height + 2 + stats.height;
final rec = ui.PictureRecorder();
final canvas = Canvas(rec);
final box = Rect.fromLTWH(0, 0, w, h);
canvas.drawRect(box, Paint()..color = const Color(0xE6000514));
canvas.drawRect(
box,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = b.color.withValues(alpha: 0.8),
);
name.paint(canvas, const Offset(pad, pad));
stats.paint(canvas, Offset(pad, pad + name.height + 2));
final barH = h - pad * 2;
canvas.drawRect(
Rect.fromLTWH(
w - pad - barW,
pad + barH * (1 - b.share),
barW,
barH * b.share,
),
Paint()..color = _emerald,
);
compactText = [
(b.node.name.toUpperCase(), compactNamePt),
(compactLine(b.node), compactLinePt),
];
name.dispose();
stats.dispose();
_compactSize = Size(w, h);
return _compact = rec.endRecording();
}
@visibleForTesting
double get rotation => _rot;
/// 마지막 프레임에 그린 HUD 카드 폭(논리 px). 안 그렸으면 0.
@visibleForTesting
double? hudWidthOf(String id) => _bodies[id]?.hudW;
/// 행성에 칠한 색. 없으면 null.
@visibleForTesting
Color? colorOf(String id) => _bodies[id]?.color;
/// 행성의 현재 궤도 반지름(월드 단위). 없으면 null.
@visibleForTesting
double? orbitOf(String id) => _bodies[id]?.rad;
void update(List<PlanetNode> planets) {
// FPI 상위 [maxPlanets] 개 + 내 행성.
final byFpi = [...planets]..sort((a, b) => b.fpi.compareTo(a.fpi));
final shown = byFpi.take(maxPlanets).toList();
final mine = planets.where((p) => p.isMine).firstOrNull;
if (mine != null && !shown.contains(mine)) shown.add(mine);
final top = shown.fold<int>(0, (m, p) => math.max(m, p.fpi));
for (final (i, p) in shown.indexed) {
final share = top <= 0 ? 0.0 : (p.fpi / top).clamp(0.0, 1.0);
(_bodies[p.id] ??= _Body(
p.id,
_bodies.length + 1.0,
)).set(p, _planetColor(p, i), share);
}
final ids = {for (final p in shown) p.id};
_bodies.removeWhere((id, b) {
if (ids.contains(id)) return false;
b.dispose();
return true;
});
_order
..clear()
..addAll(_bodies.values);
_mine = _order.where((b) => b.node.isMine).firstOrNull;
_place(0);
_frame.value++;
}
void turn(double radians) =>
_rotTarget = (_rotTarget + radians).clamp(-math.pi, math.pi);
/// 움직임 없이 목표 상태로 바로 놓는다(애니메이션 끔 · 테스트).
void settle() {
_scale = _massVolume;
_rot = _rotTarget;
for (final b in _order) {
b.rad = b.target;
}
_place(1);
_frame.value++;
}
/// 프로토타입 `animate()` 한 프레임 — lerp 계수는 60fps 기준 값을 [dt] 에 맞춘다.
void advance(double dt) {
_time += dt;
_scale += (_massVolume - _scale) * _lerpK(0.05, dt);
final k = _lerpK(0.03, dt);
for (final b in _order) {
b.rad += (b.target - b.rad) * k;
}
_rot += (_rotTarget - _rot) * _lerpK(0.05, dt);
_place(_lerpK(0.08, dt));
_frame.value++;
}
/// 궤도 위치 → 월드 좌표. 장면은 내 행성을 가운데로 따라간다(계수 [follow]).
void _place(double follow) {
final c = math.cos(_rot);
final s = math.sin(_rot);
if (_mine case final m?) {
final a = _angle(m);
final lx = math.cos(a) * m.rad;
final lz = math.sin(a) * m.rad;
// 프로토타입은 회전 전 좌표로 따라간다. 회전 중에도 내 행성이 가운데 있도록 회전 뒤 좌표를 쓴다.
_gx += (-(lx * c + lz * s) - _gx) * follow;
_gz += (-(-lx * s + lz * c) - _gz) * follow;
}
for (final b in _order) {
final a = _angle(b);
final lx = math.cos(a) * b.rad;
final lz = math.sin(a) * b.rad;
b.wx = lx * c + lz * s + _gx;
b.wz = -lx * s + lz * c + _gz;
}
}
/// 프로토타입 `time * speed + id` — time = ms × 0.0005, speed = 0.8 / (rank + 1).
double _angle(_Body b) => _time * 0.5 * 0.8 / (b.node.rank + 1) + b.phase;
// ── 투영 결과(할당 없이 필드로 돌려준다) ──
double _sx = 0;
double _sy = 0;
double _sd = 0;
void _project(double x, double y, double z, double focal, Size size) {
final vy = y - _camY;
final vz = z - _camZ;
_sd = vy * _fy + vz * _fz;
_sx = size.width / 2 + x / _sd * focal;
_sy = size.height / 2 - (vy * _uy + vz * _uz) / _sd * focal;
}
/// 수평 원이 카메라에서 납작해 보이는 비율.
double _fore(double x, double z) {
final dz = z - _camZ;
return _camY / math.sqrt(x * x + _camY * _camY + dz * dz);
}
Size _bgSize = Size.zero;
ui.Picture? _bg;
Paint? _scan;
final _stroke = Paint()..style = PaintingStyle.stroke;
final _fill = Paint();
/// 별밭·성운 — 장면 밖(갤럭시 그룹 밖)이라 카메라가 고정이면 크기마다 한 번만 그린다.
/// ponytail: 프로토타입의 별 회전(0.0001rad/프레임)은 생략 — 분당 0.4° 라 미니맵에서 보이지 않는다.
void _buildBackground(Size size, double focal) {
_bgSize = size;
_bg?.dispose();
final rec = ui.PictureRecorder();
final canvas = Canvas(rec);
canvas.drawRect(
Offset.zero & size,
Paint()..color = const Color(0xFF000000),
);
final rnd = math.Random(62);
for (var i = 0; i < 8; i++) {
final x = (rnd.nextDouble() - 0.5) * 2000;
final y = (rnd.nextDouble() - 0.5) * 500;
final z = (rnd.nextDouble() - 0.5) * 2000;
_project(x, y, z, focal, size);
if (_sd < 1) continue;
final r = 1500 * focal / _sd;
final center = Offset(_sx, _sy);
canvas.save();
canvas.translate(_sx, _sy);
canvas.scale(1, _fore(x, z));
canvas.translate(-_sx, -_sy);
canvas.drawCircle(
center,
r,
Paint()
..blendMode = BlendMode.plus
..shader = ui.Gradient.radial(
center,
r,
const [Color(0x0A10B981), Color(0x033B82F6), Color(0x00000000)],
const [0, 0.4, 0.8],
),
);
canvas.restore();
}
// 프로토타입 별 8000개. 화면 넓이당 밀도는 390×844 화면 기준으로 맞춘다.
final keep = (size.width * size.height / (390 * 844)).clamp(0.0, 1.0);
final pts = <double>[];
for (var i = 0; i < 8000; i++) {
final x = (rnd.nextDouble() - 0.5) * 15000;
final y = (rnd.nextDouble() - 0.5) * 15000;
final z = (rnd.nextDouble() - 0.5) * 15000;
final pick = rnd.nextDouble();
_project(x, y, z, focal, size);
if (_sd < 1 || pick > keep) continue;
if (_sx < 0 || _sy < 0 || _sx > size.width || _sy > size.height) continue;
pts
..add(_sx)
..add(_sy);
}
canvas.drawRawPoints(
ui.PointMode.points,
Float32List.fromList(pts),
Paint()
..strokeWidth = 0
..color = const Color(0xCCBFBFFF),
);
_bg = rec.endRecording();
_scan = Paint()
..shader = ui.Gradient.linear(
Offset.zero,
const Offset(0, 4),
const [Color(0x0010B981), Color(0x1410B981), Color(0x0010B981)],
const [0, 0.5, 1],
);
}
static int _farFirst(_Body a, _Body b) => b.depth.compareTo(a.depth);
@override
void paint(Canvas canvas, Size size) {
final focal = size.height / 2 / _tanHalfFov;
if (size != _bgSize) _buildBackground(size, focal);
canvas.drawPicture(_bg!);
// 궤도 링 — 갤럭시 원점 중심, 두께 = 반지름/100, 흰색 15%.
_project(_gx, 0, _gz, focal, size);
final gx = _sx, gy = _sy, gd = _sd;
final gFore = _fore(_gx, _gz);
_stroke.color = const Color(0x26FFFFFF);
for (final b in _order) {
// ponytail: 카메라 평면에 걸치는 큰 궤도는 건너뛴다 — 원근 타원 근사가 깨지는 구간.
if (b.rad < 1 || gd - b.rad < 100) continue;
final rx = b.rad * focal / gd;
_stroke.strokeWidth = _px(b.rad / 100 * focal / gd);
canvas.drawOval(
Rect.fromCenter(
center: Offset(gx, gy),
width: rx * 2,
height: rx * 2 * gFore,
),
_stroke,
);
}
for (final b in _order) {
_project(b.wx, 0, b.wz, focal, size);
b.depth = _sd;
}
_order.sort(_farFirst);
final satRot = -1.8 * _time; // 프로토타입 -0.03rad/프레임
final sc = math.cos(satRot), ss = math.sin(satRot);
for (final b in _order) {
final r = b.baseR * _scale;
// 근평면 — 카메라에 반지름 2배 이내로 붙은 행성은 광륜·띠 투영이 뒤집히므로 뺀다.
if (b.depth < r * 2) continue;
_project(b.wx, 0, b.wz, focal, size);
final px = _sx, py = _sy, d = _sd;
// 위성 — 행성 반지름 1.8~4.3 배 구각.
final sat = b.sat, out = b.satOut;
for (var j = 0, n = sat.length ~/ 3; j < n; j++) {
final x = sat[j * 3] * r,
y = sat[j * 3 + 1] * r,
z = sat[j * 3 + 2] * r;
_project(
b.wx + x * sc + z * ss,
y,
b.wz - x * ss + z * sc,
focal,
size,
);
// 카메라 뒤 점은 화면 밖으로 보낸다(버퍼 길이는 그대로).
final behind = _sd < 1;
out[j * 2] = behind ? -1e5 : _sx;
out[j * 2 + 1] = behind ? -1e5 : _sy;
}
if (out.isNotEmpty) {
_fill
..strokeWidth = 0
..blendMode = BlendMode.plus
..color = b.color.withValues(
alpha: 0.9 * (2 * _scale * focal / d).clamp(0.2, 1.0),
);
canvas.drawRawPoints(ui.PointMode.points, out, _fill);
_fill.blendMode = BlendMode.srcOver;
}
// 광륜 + 구체.
final rPx = r * focal / d;
canvas.save();
canvas.translate(px, py);
canvas.scale(rPx);
canvas.drawPicture(b.sphere!);
canvas.restore();
// 행성 띠 — 1위 3줄, 2~3위 2줄, 나머지 1줄.
final fore = _fore(b.wx, b.wz);
for (var i = 0; i < b.ringCount; i++) {
final inner = b.baseR * (1.6 + i * 0.15) * _scale;
final thick = b.baseR * 0.03 * _scale;
final rx = (inner + thick / 2) * focal / d;
_stroke
..strokeWidth = _px(thick * focal / d)
..color = b.color.withValues(alpha: 0.3 / (i + 1));
canvas.drawOval(
Rect.fromCenter(
center: Offset(px, py),
width: rx * 2,
height: rx * 2 * fore,
),
_stroke,
);
}
}
// HUD — 깊이 검사 없이 맨 위(프로토타입 depthTest: false).
final f = math.pow(_scale, 0.22).toDouble();
final hudHeight = 57.5 * f;
// [fullHudMin] 보다 작으면 프로토타입 카드는 몇 px 라 읽히지 않는다 — 내 행성만
// 간략 카드로 그리고 다른 행성 카드는 뺀다(궤도·행성만).
final full = size.width >= fullHudMin;
for (final b in _order) {
b.hudW = 0;
if (!full && !identical(b, _mine)) continue;
if (b.depth < b.baseR * _scale * 2) continue;
final y = _scale * (b.baseR * _scale + hudHeight / 2 + 2);
_project(b.wx, y, b.wz, focal, size);
if (_sd < 1) continue;
final w = 115 * f * _scale * focal / _sd;
if (full) {
b.hudW = w;
canvas.save();
canvas.translate(_sx - w / 2, _sy - w / 4);
canvas.scale(w / _hudW, w / 2 / _hudH);
canvas.drawPicture(b.hud!);
canvas.restore();
continue;
}
final card = _compactCard(b, size.width * hudFloor);
b.hudW = _compactSize.width;
canvas.save();
// 아래 변은 프로토타입 카드 자리(행성 바로 위)에 두고 위로 키운다 — 행성을 가리지 않게.
canvas.translate(
_sx - _compactSize.width / 2,
_sy + w / 4 - _compactSize.height,
);
canvas.drawPicture(card);
canvas.restore();
}
// 스캔라인 — 6초 주기로 위에서 아래로.
final scanY = -4 + (_time % 6) / 6 * (size.height + 4);
canvas.save();
canvas.translate(0, scanY);
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, 4), _scan!);
canvas.restore();
}
@override
bool shouldRepaint(GalaxyMiniMapPainter old) => !identical(old, this);
void dispose() {
for (final b in _order) {
b.dispose();
}
_bg?.dispose();
_compact?.dispose();
_frame.dispose();
}
}
@@ -0,0 +1,309 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../../../core/i18n/app_locale.dart';
const _bolt = AssetImage('assets/figma/icon-bolt.png');
const _item = AssetImage('assets/figma/icon-item.png');
const _zapGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF4BC2AE), Color(0xFF4F98C8), Color(0xFF5669DC)],
);
/// Zap — Figma 「Zap 버튼」 명세 그대로.
///
/// *요소 : Zap 기본 · Zap 옵션 · 기여 · 아이템 · 닫기*
///
/// **「자동(오토 도네이션)」은 두지 않는다.** 별첨 1 ① 시나리오 4 가 적었으나 별첨 B
/// 가 상위 기준이고 거기에 없는 기능이다(공통 기준 2항).
/// *규칙 : 화면 하단 위치 고정 · 디폴트 Zap 기본 노출 ·*
/// ***행성/성운 가입 또는 생성한 유저에게만 노출***
/// *동작 : 기본 클릭 시 어드민에서 설정한 기여 POP 만큼 내 POP 차감 후 행성 POP 에 추가 ·*
/// ***2초 롱프레스 시 옵션으로 전환***
///
/// 차감 수치가 **어드민 설정값**이라는 것이 명세에 박혀 있다. 화면에 상수로 박지
/// 않고 [zapPop] 으로 받는다 — 정책 설계서 §P-08 의 Admin 운영값이다.
class ZapButton extends StatefulWidget {
const ZapButton({
super.key,
required this.zapPop,
required this.myPop,
required this.onZap,
this.blockedReason,
this.isHeroTime = false,
this.onContribute,
this.onItem,
this.round = false,
this.showLabel = true,
});
/// 한 번에 기여할 POP. Admin 운영값.
final int zapPop;
/// 내 활동 POP 잔액. 모자라면 누를 수 없다.
final int myPop;
/// HERO 참여는 개인 POP 차감이 아니라 선물 수령 화면으로 진입한다.
final bool isHeroTime;
/// 전송 중·소속 없음 등 입력을 막는 사유. Eclipse 방어와는 무관하다.
final String? blockedReason;
final VoidCallback onZap;
final VoidCallback? onContribute;
final VoidCallback? onItem;
/// HOME 의 원형 Zap — Figma 사본 `252:159354` 「Frame 1984081111」(52 원 · 청록→파랑
/// 그라디언트 · 번개 + 「기여」). 옵션은 위에 기여, 아래 줄에 닫기·아이템(`Frame 1984081131`).
final bool round;
/// 원형 기본 상태의 「기여」 라벨. 성운 상태 사본(`344:213778`)은 번개만 있다.
final bool showLabel;
/// 명세의 「2초간 롱프레싱」.
static const longPress = Duration(seconds: 2);
@override
State<ZapButton> createState() => _ZapButtonState();
}
class _ZapButtonState extends State<ZapButton> {
bool _optionsOpen = false;
@override
void didUpdateWidget(ZapButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isHeroTime != widget.isHeroTime) _optionsOpen = false;
}
bool get _canZap =>
widget.blockedReason == null && (widget.isHeroTime || widget.myPop >= widget.zapPop);
@override
Widget build(BuildContext context) => AnimatedSize(
duration: const Duration(milliseconds: 180),
alignment: Alignment.bottomCenter,
child: widget.round
? (_optionsOpen ? _roundOptions(context) : _roundBasic(context))
: (_optionsOpen ? _options(context) : _basic(context)),
);
Widget _roundBasic(BuildContext context) => RawGestureDetector(
gestures: {
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(duration: ZapButton.longPress),
(recognizer) =>
recognizer.onLongPress = () =>
setState(() => _optionsOpen = true),
),
},
// Tooltip 은 쓰지 않는다 — 자체 길게 누르기가 2초 옵션 전환을 가로챈다.
child: Semantics(
label: _canZap
? (widget.isHeroTime
? 'ZAP+ · HERO TIME'
: 'ZAP ${widget.zapPop} POP')
: (widget.blockedReason ?? context.l10n.zapNotEnoughPop),
child: Opacity(
opacity: _canZap ? 1 : 0.4,
child: _RoundZap(
icon: const ImageIcon(_bolt, size: 20, color: AppColors.onCosmic),
label: widget.isHeroTime ? 'ZAP+' : context.l10n.commonContribute,
fill: _zapGradient,
glow: true,
onTap: _canZap ? widget.onZap : null,
showLabel: widget.showLabel,
),
),
),
);
Widget _roundOptions(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
_RoundZap(
icon: const ImageIcon(_bolt, size: 20, color: AppColors.onCosmic),
label: context.l10n.commonContribute,
fill: _zapGradient,
glow: true,
onTap: widget.onContribute,
),
const SizedBox(height: AppSpace.s5),
// 사본의 왼쪽 「자동」 자리는 비운다(클래스 주석 — 별첨 B 에 없는 기능).
Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 52 + AppSpace.s6),
_RoundZap(
icon: const ImageIcon(
AppIcons.x,
size: 24,
color: AppColors.onCosmic,
),
label: context.l10n.commonClose,
showLabel: false,
ring: true,
onTap: () => setState(() => _optionsOpen = false),
),
const SizedBox(width: AppSpace.s6),
_RoundZap(
icon: const ImageIcon(_item, size: 20, color: AppColors.onCosmic),
label: context.l10n.commonItem,
onTap: widget.onItem,
),
],
),
],
);
Widget _basic(BuildContext context) => RawGestureDetector(
gestures: {
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(duration: ZapButton.longPress),
(recognizer) =>
recognizer.onLongPress = () =>
setState(() => _optionsOpen = true),
),
},
child: FilledButton.icon(
onPressed: _canZap ? widget.onZap : null,
style: FilledButton.styleFrom(
backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent,
minimumSize: const Size.fromHeight(AppSizing.touchMinApp),
),
icon: const Icon(Icons.bolt),
label: Text(
_canZap
? widget.isHeroTime ? 'ZAP+ · HERO TIME' : 'ZAP ${widget.zapPop} POP'
: (widget.blockedReason ?? context.l10n.zapNotEnoughPop),
),
),
);
Widget _options(BuildContext context) => Container(
padding: const EdgeInsets.all(AppSpace.s2),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceRaised,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.cosmicOutline),
),
child: Row(
children: [
_Option(
icon: Icons.volunteer_activism_outlined,
label: context.l10n.commonContribute,
onTap: widget.onContribute,
),
_Option(
icon: Icons.card_giftcard_outlined,
label: context.l10n.commonItem,
onTap: widget.onItem,
),
_Option(
icon: Icons.close,
label: context.l10n.commonClose,
onTap: () => setState(() => _optionsOpen = false),
),
],
),
);
}
class _Option extends StatelessWidget {
const _Option({required this.icon, required this.label, this.onTap});
final IconData icon;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
constraints: const BoxConstraints(minHeight: AppSizing.touchMinApp),
padding: const EdgeInsets.symmetric(vertical: AppSpace.s2),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20, color: AppColors.onCosmic),
const SizedBox(height: 2),
Text(label, style: t.labelSmall),
],
),
),
),
);
}
}
/// 원형 Zap 한 칸 — 52 원 · 패딩 8 · 아이콘 20 위 10pt 라벨(42dot Medium).
class _RoundZap extends StatelessWidget {
const _RoundZap({
required this.icon,
required this.label,
this.onTap,
this.fill,
this.glow = false,
this.ring = false,
this.showLabel = true,
});
final Widget icon;
final String label;
final VoidCallback? onTap;
final Gradient? fill;
final bool glow;
final bool ring;
final bool showLabel;
@override
Widget build(BuildContext context) => Semantics(
button: true,
label: label,
child: GestureDetector(
onTap: onTap,
child: Container(
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: fill,
color: fill == null && !ring ? const Color(0xFF050505) : null,
border: ring ? Border.all(color: const Color(0xFF4F98C8)) : null,
boxShadow: glow
? const [BoxShadow(color: Color(0xFF547BD4), blurRadius: 6)]
: null,
),
alignment: Alignment.center,
child: showLabel
? Column(
mainAxisSize: MainAxisSize.min,
children: [
icon,
Text(
label,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 10,
fontWeight: FontWeight.w500,
height: 1.6,
color: AppColors.onCosmic,
),
),
],
)
: icon,
),
),
);
}
@@ -0,0 +1,209 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/planet_fx_scope.dart';
import '../../../core/format.dart';
import '../galaxy_data.dart';
import '../../../core/i18n/app_locale.dart';
/// v1.5 §3: 입력 에너지는 즉시, 흡수·기여 결과는 서버 거래 확정 후에만 표시한다.
class ZapFeedback extends StatefulWidget {
const ZapFeedback({
super.key,
required this.touchSerial,
required this.pending,
required this.result,
required this.source,
required this.target,
required this.radius,
});
final int touchSerial;
final bool pending;
final ZapResult? result;
final Offset source;
final Offset target;
final double radius;
@override
State<ZapFeedback> createState() => _ZapFeedbackState();
}
class _ZapFeedbackState extends State<ZapFeedback>
with TickerProviderStateMixin {
late final _travel = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 380),
);
late final _absorb = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
);
final _seen = <String>{};
bool _showResult = false;
@override
void didUpdateWidget(ZapFeedback old) {
super.didUpdateWidget(old);
if (widget.touchSerial != old.touchSerial) {
_showResult = false;
_travel.forward(from: 0);
}
final result = widget.result;
if (result != null && result.txId.isNotEmpty && _seen.add(result.txId)) {
_showResult = true;
_absorb.forward(from: 0);
}
}
@override
void dispose() {
_travel.dispose();
_absorb.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final reduced = MediaQuery.disableAnimationsOf(context);
final strongFx = PlanetFxScope.of(context)?.busy ?? false;
return AnimatedBuilder(
animation: Listenable.merge([_travel, _absorb]),
builder: (context, _) {
final result = widget.result;
final showResult = _showResult && !widget.pending && _absorb.value < 1;
return Stack(
children: [
if (!reduced && !strongFx)
Positioned.fill(
child: CustomPaint(
painter: _ZapPainter(
source: widget.source,
target: widget.target,
radius: widget.radius,
travel: _travel.value,
travelling: _travel.isAnimating,
absorption: showResult ? _absorb.value : null,
),
),
),
if (widget.pending || showResult)
Positioned(
left: 16,
right: 16,
bottom: 128,
child: Center(
child: Semantics(
liveRegion: true,
child: DecoratedBox(
decoration: BoxDecoration(
color: AppColors.cosmicSurface,
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
child: Text(
widget.pending
? context.l10n.zapApplying
: result?.contributedPop == null
? context.l10n.zapDone
: context.l10n.zapDonePop(
formatPop(result!.contributedPop!),
),
style: Theme.of(context).textTheme.labelLarge,
),
),
),
),
),
),
],
);
},
);
}
}
class _ZapPainter extends CustomPainter {
const _ZapPainter({
required this.source,
required this.target,
required this.radius,
required this.travel,
required this.travelling,
required this.absorption,
});
final Offset source;
final Offset target;
final double radius;
final double travel;
final bool travelling;
final double? absorption;
@override
void paint(Canvas canvas, Size size) {
const color = AppColors.cosmicAccent;
if (travelling) {
final t = Curves.easeOutCubic.transform(travel);
// 짧은 직선 이동, 잔상은 끝점 근처 3개로 제한한다.
for (var i = 2; i >= 0; i--) {
final p = Offset.lerp(source, target, (t - i * 0.04).clamp(0.0, 1.0))!;
canvas.drawCircle(
p,
5.0 - i,
Paint()
..color = color.withValues(
alpha: (1 - i * 0.25) * (1 - travel * 0.5),
),
);
}
}
final a = absorption;
if (a == null || a > 0.5) return;
final t = a * 2;
canvas.drawCircle(
target,
radius * (1 + t * 0.5),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3 * (1 - t) + 0.5
..color = color.withValues(alpha: 0.75 * (1 - t)),
);
canvas.drawCircle(
target,
radius * 1.5,
Paint()
..shader = RadialGradient(
colors: [
color.withValues(alpha: 0.3 * (1 - t)),
color.withValues(alpha: 0),
],
).createShader(Rect.fromCircle(center: target, radius: radius * 1.5)),
);
for (var i = 0; i < 8; i++) {
final angle = i * math.pi / 4;
final p =
target +
Offset(math.cos(angle), math.sin(angle)) * radius * (1.5 - t * 0.5);
canvas.drawCircle(
p,
2 * (1 - t),
Paint()..color = color.withValues(alpha: 1 - t),
);
}
}
@override
bool shouldRepaint(_ZapPainter old) =>
old.source != source ||
old.target != target ||
old.radius != radius ||
old.travel != travel ||
old.travelling != travelling ||
old.absorption != absorption;
}