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

993 lines
33 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 순위, 첫 화면 시점은 내 행성 중앙(C-103). 140pt 에서는 내 행성 간략 카드
/// 한 장만, 다른 행성 HUD 없음(C-105). 드래그 회전은 관성 없음(C-105).
///
/// 반응: 스냅샷이 바뀌면(순위·FPI) 행성이 새 궤도로 미끄러지고 HUD 숫자가 바뀐다(프로토타입
/// `updateFPI`). 가로로 끌면 장면이 돈다(±180°, 프로토타입 View Rotation 범위). 누르면 [onOpen].
class GalaxyMiniMap extends StatefulWidget {
const GalaxyMiniMap({
super.key,
required this.data,
required this.onOpen,
this.size = 140,
}) : expanded = false,
onOpenPlanet = null;
/// 「GALAXY 맵 확장」 — Figma 「홈_초기사용자」(`343:211127`)의 히어로 전체 갤럭시 맵.
/// 부모 크기를 채우고 모든 행성에 프로토타입 HUD 를 그린다. 설명 프레임 「갤럭시맵 화면」
/// (`2008:14486`)의 동작 — 끌어 회전(프로토타입 View Rotation ±180° · 관성 없음) ·
/// 두 손가락 줌인/줌아웃 · 행성 탭 = 화면 중심 전환 · HUD 탭 = [onOpenPlanet](FPI 상세).
const GalaxyMiniMap.expanded({
super.key,
required this.data,
required ValueChanged<PlanetNode> this.onOpenPlanet,
}) : expanded = true,
onOpen = _noop,
size = 0;
static void _noop() {}
final GalaxySnapshot data;
final VoidCallback onOpen;
/// 정사각 한 변. HOME 은 약 140pt(C-105 · 자리는 C-113 → `HomeHero.miniMapAlignment`).
final double size;
final bool expanded;
final ValueChanged<PlanetNode>? onOpenPlanet;
@override
State<GalaxyMiniMap> createState() => _GalaxyMiniMapState();
}
class _GalaxyMiniMapState extends State<GalaxyMiniMap>
with SingleTickerProviderStateMixin {
late final _painter = widget.expanded
? GalaxyMiniMapPainter.figmaScale()
: 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();
}
double _zoomStart = 1;
Widget _expanded(BuildContext context) {
final mine = widget.data.mine;
return LayoutBuilder(
builder: (context, box) => Semantics(
label: [
if (mine != null)
...GalaxyMiniMapPainter.hudLines(mine)
else
'GALAXY',
].join(' · '),
onTap: mine == null ? null : () => widget.onOpenPlanet!(mine),
child: GestureDetector(
excludeFromSemantics: true,
onScaleStart: (_) => _zoomStart = _painter.zoom,
onScaleUpdate: (d) {
if (d.pointerCount > 1) {
_painter.zoomTo(_zoomStart * d.scale);
} else {
_painter.turn(d.focalPointDelta.dx / box.maxWidth * math.pi);
}
if (!_ticker.isActive) _painter.settle();
},
onTapUp: (d) {
final hit = _painter.hitAt(d.localPosition);
if (hit == null) return;
if (hit.hud) {
widget.onOpenPlanet!(hit.planet);
} else {
_painter.focus(hit.planet.id);
if (!_ticker.isActive) _painter.settle();
}
},
child: ExcludeSemantics(
child: RepaintBoundary(
child: CustomPaint(
size: box.biggest,
painter: _painter,
isComplex: true,
willChange: true,
),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
if (widget.expanded) return _expanded(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 galaxyPlanetColor(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;
/// 별·위성 점 크기(논리 px). 프로토타입 점은 원근으로 1px 아래로 줄어 GL 최소 크기(버퍼
/// 1px, 픽셀비 2 → 0.5pt)로 찍힌다. 0 이면 1 물리 px 이라 3x 기기에서 1/3 로 옅어진다.
const _dot = 0.4;
/// 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;
/// 마지막 프레임의 화면 좌표·반지름·HUD 상자(탭 판정용).
double sx = 0, sy = 0, sr = 0;
Rect hudRect = Rect.zero;
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',
// 프로토타입 JetBrains Mono 대신 기기 고정폭(Android monospace · iOS Menlo).
fontFamilyFallback: const ['Menlo', 'Courier', AppFonts.body],
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();
}
/// 프로토타입 광륜 반지름(행성 반지름 배수). `aura.scale = R × curScale × 6` 이 다시
/// curScale(Mass x1.5)로 커지는 group 안에 있어 지름은 `6 × 1.5 R` 이다.
const _auraR = 3 * _massVolume;
/// 반지름 1 단위의 광륜(가산 합성) + 발광 구체.
ui.Picture _spherePicture(Color c) {
final rec = ui.PictureRecorder();
final canvas = Canvas(rec);
canvas.drawCircle(
Offset.zero,
_auraR,
Paint()
..blendMode = BlendMode.plus
..shader = ui.Gradient.radial(
Offset.zero,
_auraR,
[
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), null);
/// HOME 「GALAXY 맵 확장」 — Figma `343:211127` 축척. 화면 크기와 무관하게 초점 거리를
/// [figmaFocal] 로 고정하고, HUD 카드는 [figmaHud] 고정 크기로 그린다.
GalaxyMiniMapPainter.figmaScale() : this._(ValueNotifier<int>(0), figmaFocal);
GalaxyMiniMapPainter._(this._frame, this.fixedFocal) : super(repaint: _frame);
/// 고정 초점 거리(pt). null 이면 프로토타입처럼 세로 시야각 70°(`focal = h/2/tan35°`).
final double? fixedFocal;
/// Figma `343:211127` 은 히어로(375)에 프로토타입 캡처(801×555)를 원래 픽셀 크기로 넣었다
/// (y124 · 가로 가운데 x−213). 그 캡처의 내 행성(NewJeans, FPI 3200 → 반지름 31 × Mass 1.5)
/// 지름이 38pt 다(Figma F1-05 · 343:211127 실측).
/// `지름 = 2 × 46.5 × focal / 4031(카메라 거리)` → focal ≈ 1650pt. 같은 시야각이면
/// 창 높이 약 2,310px 의 장면이다 — 556pt 창의 프로토타입보다 약 4.2배 크다.
static const figmaFocal = 1650.0;
/// Figma 캡처의 내 행성 HUD 카드 실측 약 144×68pt → 텍스처 비율 2:1 로 144×72.
/// 글자는 텍스처(512) 기준 이름 50 → 14pt · FPI 40 → 11pt · RANK/LIVE 24 → 6.75pt.
static const figmaHud = Size(144, 72);
final ValueNotifier<int> _frame;
/// 행성 위 HUD 네 줄 — 프로토타입 텍스처와 같은 문구.
static List<String> hudLines(PlanetNode p) => [
p.name.toUpperCase(),
'RANK #${p.rankLabel}',
'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;
/// 확장 맵의 줌(초점 거리 배율). 별밭은 그대로 두고 장면만 키운다.
double zoom = 1;
static const minZoom = 0.5, maxZoom = 3.0;
void zoomTo(double z) {
zoom = z.clamp(minZoom, maxZoom);
_frame.value++;
}
/// 확장 맵에서 고른 행성 — 카메라가 이 행성을 가운데로 따라간다(없으면 내 행성).
_Body? _focus;
void focus(String id) => _focus = _bodies[id] ?? _focus;
@visibleForTesting
String? get focusId => (_focus ?? _mine)?.id;
/// 마지막 프레임 기준 탭 판정 — HUD 카드가 위(프로토타입 depthTest: false)라 먼저 본다.
({PlanetNode planet, bool hud})? hitAt(Offset p) {
for (final b in _order.reversed) {
if (b.hudRect.contains(p)) return (planet: b.node, hud: true);
}
for (final b in _order.reversed) {
// 작은 행성도 손끝으로 잡히게 최소 반경 24.
if ((Offset(b.sx, b.sy) - p).distance <= math.max(b.sr, 24)) {
return (planet: b.node, hud: false);
}
}
return null;
}
/// 행성의 마지막 화면 좌표 — 테스트가 탭할 자리.
@visibleForTesting
Offset? screenOf(String id) =>
_bodies[id] == null ? null : Offset(_bodies[id]!.sx, _bodies[id]!.sy);
@visibleForTesting
Rect? hudRectOf(String id) => _bodies[id]?.hudRect;
/// 그리는 행성 수(내 행성은 순위 밖이어도 더한다).
static const maxPlanets = 10;
/// 간략 카드(내 행성)의 최대 폭(미니맵 폭 대비). 넘치면 카드를 통째로 줄인다(숫자는 자르지 않는다).
static const hudFloor = 0.55;
/// 이 폭 이상이면 프로토타입 HUD 카드를 모든 행성에 그대로 그린다.
static const fullHudMin = 280.0;
/// 간략 카드 글자 크기(pt) — 이름 · 숫자 줄. 프로토타입 텍스처의 줄 구성(이름 / RANK · FPI /
/// LIVE)을 세 줄로 줄인 것이다. 140pt 에서 카드가 장면을 덮지 않게 한 줄을 짧게 둔다.
static const compactNamePt = 9.0;
static const compactLinePt = 8.0;
/// 간략 카드 숫자 줄 — `#순위 · FPI n` / `LIVE n`.
static List<String> compactLines(PlanetNode p) => [
'#${p.rankLabel} · 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 = 4.0, barW = 2.0, gap = 3.0;
TextPainter line(
String text,
double pt,
FontWeight weight,
Color c, [
double maxWidth = double.infinity,
]) => TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
fontFamily: AppFonts.body,
fontSize: pt,
fontWeight: weight,
height: 1.15,
color: c,
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: '…',
)..layout(maxWidth: maxWidth);
final texts = [
(b.node.name.toUpperCase(), compactNamePt, FontWeight.w800, b.color),
for (final t in compactLines(b.node))
(t, compactLinePt, FontWeight.w700, const Color(0xE6FFFFFF)),
];
final stats = [
for (final (t, pt, w, c) in texts.skip(1)) line(t, pt, w, c),
];
final statsW = stats.fold<double>(0, (m, l) => math.max(m, l.width));
// 긴 이름은 말줄임 — 카드 폭은 숫자 줄(또는 최대 폭)이 정한다.
final (nt, npt, nw, nc) = texts.first;
final nameMax = math.max(statsW, maxW - pad * 2 - gap - barW);
final lines = [line(nt, npt, nw, nc, nameMax), ...stats];
final textW = lines.fold<double>(0, (m, l) => math.max(m, l.width));
final textH = lines.fold<double>(0, (m, l) => m + l.height);
final w = pad * 2 + textW + gap + barW;
final h = pad * 2 + textH;
// 숫자 줄이 최대 폭을 넘을 때만 카드를 줄인다 — 줄을 자르면 순위·FPI 숫자가 사라진다.
final k = math.min(1.0, maxW / w);
final rec = ui.PictureRecorder();
final canvas = Canvas(rec)..scale(k);
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),
);
var y = pad;
for (final l in lines) {
l.paint(canvas, Offset(pad, y));
y += l.height;
l.dispose();
}
// 프로토타입 막대 `160 * fpi / 5000` — 1위 대비 비율.
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 = [for (final (t, pt, _, _) in texts) (t, pt * k)];
_compactSize = Size(w * k, h * k);
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;
/// 행성의 현재 월드 좌표(x, z). 장면 원점 = 화면 중앙. 없으면 null.
@visibleForTesting
Offset? worldOf(String id) =>
_bodies[id] == null ? null : Offset(_bodies[id]!.wx, _bodies[id]!.wz);
/// 마지막으로 그린 간략 카드 크기(논리 px).
@visibleForTesting
Size get compactSize => _compactSize;
/// 행성의 현재 궤도 반지름(월드 단위). 없으면 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, galaxyPlanetColor(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;
if (_focus != null && !_bodies.containsValue(_focus)) _focus = null;
_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 (_focus ?? _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(0x0410B981), Color(0x013B82F6), Color(0x00000000)],
const [0, 0.4, 0.8],
),
);
canvas.restore();
}
// 프로토타입 별 8000개. 미니맵은 휴대폰 전체 화면 장면의 축소판으로 보이게, 화면 넓이당
// 밀도를 390×844 기준으로 맞춘다(덜어내지 않으면 140pt 에 별이 5배 빽빽하다).
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 = _dot
..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 bgFocal = size.height / 2 / _tanHalfFov;
if (size != _bgSize) _buildBackground(size, bgFocal);
canvas.drawPicture(_bg!);
final focal = (fixedFocal ?? bgFocal) * zoom;
// 궤도 링 — 갤럭시 원점 중심, 두께 = 반지름/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 = _dot
..blendMode = BlendMode.plus
..color = b.color.withValues(alpha: 0.9);
canvas.drawRawPoints(ui.PointMode.points, out, _fill);
_fill.blendMode = BlendMode.srcOver;
}
// 광륜 + 구체.
final rPx = r * focal / d;
b
..sx = px
..sy = py
..sr = rPx;
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 = fixedFocal != null || size.width >= fullHudMin;
for (final b in _order) {
b.hudW = 0;
b.hudRect = Rect.zero;
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 (fixedFocal != null) {
// 고정 크기 카드 — 장면 축척 · 줌과 무관하게 같은 크기. 아래 변을 프로토타입 HUD
// 아래 변 자리(행성 위 HUD Dist 2)에 둔다.
const hud = figmaHud;
_project(b.wx, _scale * (b.baseR * _scale + 2), b.wz, focal, size);
if (_sd < 1) continue;
final rect = Rect.fromLTWH(
_sx - hud.width / 2,
_sy - hud.height,
hud.width,
hud.height,
);
b
..hudW = hud.width
..hudRect = rect;
canvas.save();
canvas.translate(rect.left, rect.top);
canvas.scale(hud.width / _hudW, hud.height / _hudH);
canvas.drawPicture(b.hud!);
canvas.restore();
continue;
}
if (full) {
b.hudW = w;
b.hudRect = Rect.fromLTWH(_sx - w / 2, _sy - w / 4, w, w / 2);
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();
}
}