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 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? onOpenPlanet; @override State createState() => _GalaxyMiniMapState(); } class _GalaxyMiniMapState extends State 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(0), null); /// HOME 「GALAXY 맵 확장」 — Figma `343:211127` 축척. 화면 크기와 무관하게 초점 거리를 /// [figmaFocal] 로 고정하고, HUD 카드는 [figmaHud] 고정 크기로 그린다. GalaxyMiniMapPainter.figmaScale() : this._(ValueNotifier(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 _frame; /// 행성 위 HUD 네 줄 — 프로토타입 텍스처와 같은 문구. static List hudLines(PlanetNode p) => [ p.name.toUpperCase(), 'RANK #${p.rankLabel}', 'FPI: ${p.fpi}', 'LIVE: ${formatPop(p.online)}', ]; final _bodies = {}; 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 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(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(0, (m, l) => math.max(m, l.width)); final textH = lines.fold(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 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(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 = []; 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(); } }