HOME 을 Figma 「홈」 구성으로 — TODAY'S QUEST · 미니맵·확장 맵(프로토타입 v6.2) · 행성 정보 박스 · 인큐베이터 · 아티스트 소식 · HOT ARCHIVE · 인기 K-INSIDE

This commit is contained in:
Sn-Kinos
2026-10-05 16:29:41 +09:00
parent f84d696c21
commit 54d0b23260
45 changed files with 3448 additions and 1111 deletions
@@ -30,7 +30,22 @@ class GalaxyMiniMap extends StatefulWidget {
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;
@@ -38,13 +53,18 @@ class GalaxyMiniMap extends StatefulWidget {
/// 정사각 한 변. 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 {
final _painter = GalaxyMiniMapPainter();
late final _painter = widget.expanded
? GalaxyMiniMapPainter.figmaScale()
: GalaxyMiniMapPainter();
late final Ticker _ticker = createTicker(_tick);
Duration _last = Duration.zero;
@@ -92,8 +112,58 @@ class _GalaxyMiniMapState extends State<GalaxyMiniMap>
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,
@@ -153,7 +223,7 @@ const _emerald = Color(0xFF10B981);
/// 행성 색 — 팬덤 `colorKey` 를 행성 색 팔레트([planetColors])로 칠한다.
/// 키가 없거나 모르는 값이면 FPI 순번으로 프로토타입 팔레트를 돈다.
Color _planetColor(PlanetNode p, int fpiIndex) =>
Color galaxyPlanetColor(PlanetNode p, int fpiIndex) =>
planetColorOf(p.colorKey) ?? _palette[fpiIndex % _palette.length];
/// 프로토타입 게이지 기본값. 조절 UI 는 없다(C-51).
@@ -201,6 +271,10 @@ class _Body {
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);
@@ -273,6 +347,8 @@ ui.Picture _hudPicture(PlanetNode p, Color color, double share) {
text: text,
style: TextStyle(
fontFamily: 'monospace',
// 프로토타입 JetBrains Mono 대신 기기 고정폭(Android monospace · iOS Menlo).
fontFamilyFallback: const ['Menlo', 'Courier', AppFonts.body],
fontSize: size,
fontWeight: weight,
color: c,
@@ -343,8 +419,27 @@ ui.Picture _spherePicture(Color c) {
/// 미니맵 장면 상태와 그리기. 상태는 위젯이 [advance]·[settle] 로 움직인다.
class GalaxyMiniMapPainter extends CustomPainter {
GalaxyMiniMapPainter() : this._(ValueNotifier<int>(0));
GalaxyMiniMapPainter._(this._frame) : super(repaint: _frame);
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;
@@ -367,6 +462,43 @@ class GalaxyMiniMapPainter extends CustomPainter {
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;
@@ -512,7 +644,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
(_bodies[p.id] ??= _Body(
p.id,
_bodies.length + 1.0,
)).set(p, _planetColor(p, i), share);
)).set(p, galaxyPlanetColor(p, i), share);
}
final ids = {for (final p in shown) p.id};
_bodies.removeWhere((id, b) {
@@ -524,6 +656,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
..clear()
..addAll(_bodies.values);
_mine = _order.where((b) => b.node.isMine).firstOrNull;
if (_focus != null && !_bodies.containsValue(_focus)) _focus = null;
_place(0);
_frame.value++;
}
@@ -559,7 +692,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
void _place(double follow) {
final c = math.cos(_rot);
final s = math.sin(_rot);
if (_mine case final m?) {
if (_focus ?? _mine case final m?) {
final a = _angle(m);
final lx = math.cos(a) * m.rad;
final lz = math.sin(a) * m.rad;
@@ -679,9 +812,10 @@ class GalaxyMiniMapPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final focal = size.height / 2 / _tanHalfFov;
if (size != _bgSize) _buildBackground(size, focal);
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);
@@ -747,6 +881,10 @@ class GalaxyMiniMapPainter extends CustomPainter {
// 광륜 + 구체.
final rPx = r * focal / d;
b
..sx = px
..sy = py
..sr = rPx;
canvas.save();
canvas.translate(px, py);
canvas.scale(rPx);
@@ -778,17 +916,41 @@ class GalaxyMiniMapPainter extends CustomPainter {
final hudHeight = 57.5 * f;
// [fullHudMin] 보다 작으면 프로토타입 카드는 몇 px 라 읽히지 않는다 — 내 행성만
// 간략 카드로 그리고 다른 행성 카드는 뺀다(궤도·행성만).
final full = size.width >= fullHudMin;
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);