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 createState() => _GalaxyMiniMapState(); } class _GalaxyMiniMapState extends State 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(0)); GalaxyMiniMapPainter._(this._frame) : super(repaint: _frame); final ValueNotifier _frame; /// 행성 위 HUD 네 줄 — 프로토타입 텍스처와 같은 문구. static List hudLines(PlanetNode p) => [ p.name.toUpperCase(), 'RANK #${p.rank}', '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; /// 그리는 행성 수(내 행성은 순위 밖이어도 더한다). 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 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, _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 = []; 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(); } }