993 lines
33 KiB
Dart
993 lines
33 KiB
Dart
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.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;
|
||
|
||
/// 확장 맵의 줌(초점 거리 배율). 별밭은 그대로 두고 장면만 키운다.
|
||
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.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, 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();
|
||
}
|
||
}
|