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

831 lines
27 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,
});
final GalaxySnapshot data;
final VoidCallback onOpen;
/// 정사각 한 변. HOME 은 약 140pt(C-105 · 자리는 C-113 → `HomeHero.miniMapAlignment`).
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;
/// 별·위성 점 크기(논리 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;
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();
}
/// 프로토타입 광륜 반지름(행성 반지름 배수). `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));
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.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.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 = 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, _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(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 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 = _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;
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();
}
}