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);
@@ -0,0 +1,206 @@
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../galaxy_data.dart';
import 'galaxy_mini_map.dart';
/// HOME GALAXY 미니맵 · 「GALAXY 맵 확장」 — 발주사 프로토타입 v6.2 HTML 을 그대로 띄운다
/// (`assets/galaxy/uzu_hud.html` · 9/22 D-6 · 2026-10-05 대표 지시 「html 을 클론코딩하듯」).
///
/// 한 위젯이 두 모드를 다 맡는다. [expanded] 가 바뀌면 WebView 를 새로 만들지 않고 JS
/// `setMode` 만 부른다 — HOME 은 같은 [GlobalKey] 로 미니맵 자리와 확장 자리 사이를 옮긴다.
///
/// - 데이터: [GalaxySnapshot] 의 FPI 상위 10 + 내 행성 → `setGalaxy(json)`.
/// - 이벤트: HTML 이 `FanuzuBridge.postMessage` 로 보낸다 — `open`(미니맵 탭) · `hud`(HUD 탭 →
/// [onOpenPlanet]) · `planet`(행성 탭, 중심 전환은 HTML 이 이미 했다).
/// - 화면 밖 · 다른 화면에 가림 · 앱 백그라운드면 `fanuzuPause(true)` 로 rAF 를 멈춘다.
/// - 플랫폼 뷰가 없는 곳(위젯 테스트 · 데스크톱)에서는 같은 장면을 옮긴 [GalaxyMiniMap] 으로 대신한다.
class GalaxyWebView extends StatefulWidget {
const GalaxyWebView({
super.key,
required this.data,
required this.expanded,
required this.onOpen,
required this.onOpenPlanet,
this.miniSize = 93,
});
final GalaxySnapshot data;
final bool expanded;
final double miniSize;
final VoidCallback onOpen;
final ValueChanged<PlanetNode> onOpenPlanet;
/// WebView 를 쓸 수 있는가. 테스트에서 끄거나 켤 수 있다.
static bool enabled =
!kIsWeb &&
(Platform.isAndroid || Platform.isIOS) &&
!Platform.environment.containsKey('FLUTTER_TEST');
/// HTML 로 넘기는 행성 목록 — FPI 상위 10 + 순위 밖 내 행성(미니맵 painter 와 같은 규칙).
@visibleForTesting
static List<Map<String, Object>> payload(GalaxySnapshot data) {
final byFpi = [...data.planets]..sort((a, b) => b.fpi.compareTo(a.fpi));
final shown = byFpi.take(GalaxyMiniMapPainter.maxPlanets).toList();
final mine = data.mine;
if (mine != null && !shown.contains(mine)) shown.add(mine);
return [
for (final (i, p) in shown.indexed)
{
'id': p.id,
'name': p.name,
'rank': p.rank,
'fpi': p.fpi,
'live': p.online,
'color': galaxyPlanetColor(p, i).toARGB32() & 0xFFFFFF,
'mine': p.isMine,
},
];
}
@override
State<GalaxyWebView> createState() => _GalaxyWebViewState();
}
class _GalaxyWebViewState extends State<GalaxyWebView>
with WidgetsBindingObserver {
WebViewController? _web;
bool _ready = false;
bool? _paused;
bool _foreground = true;
ScrollPosition? _scroll;
@override
void initState() {
super.initState();
if (!GalaxyWebView.enabled) return;
WidgetsBinding.instance.addObserver(this);
_web = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..addJavaScriptChannel('FanuzuBridge', onMessageReceived: _onMessage)
..loadFlutterAsset('assets/galaxy/uzu_hud.html');
}
void _js(String code) {
if (_ready) _web?.runJavaScript(code);
}
void _push() {
_js('setMode(${jsonEncode(widget.expanded ? 'expanded' : 'mini')})');
_js('setGalaxy(${jsonEncode(GalaxyWebView.payload(widget.data))})');
}
void _onMessage(JavaScriptMessage m) {
final msg = jsonDecode(m.message) as Map<String, dynamic>;
switch (msg['type']) {
case 'ready':
_ready = true;
_push();
_paused = null;
_syncPause();
case 'open':
widget.onOpen();
case 'hud':
final p = widget.data.planets
.where((p) => p.id == msg['id'])
.firstOrNull;
if (p != null) widget.onOpenPlanet(p);
}
}
@override
void didUpdateWidget(GalaxyWebView old) {
super.didUpdateWidget(old);
if (_web == null) return;
if (!identical(old.data, widget.data)) {
_push();
} else if (old.expanded != widget.expanded) {
_js('setMode(${jsonEncode(widget.expanded ? 'expanded' : 'mini')})');
}
WidgetsBinding.instance.addPostFrameCallback((_) => _syncPause());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_web == null) return;
final scroll = Scrollable.maybeOf(context)?.position;
if (!identical(scroll, _scroll)) {
_scroll?.removeListener(_syncPause);
_scroll = scroll?..addListener(_syncPause);
}
// 다른 화면이 위에 올라오거나(ModalRoute) 탭이 바뀌면(TickerMode) 다시 판단한다.
ModalRoute.of(context);
TickerMode.valuesOf(context).enabled;
WidgetsBinding.instance.addPostFrameCallback((_) => _syncPause());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_foreground = state == AppLifecycleState.resumed;
_syncPause();
}
bool get _visible {
if (!mounted || !_foreground || !TickerMode.valuesOf(context).enabled) return false;
if (ModalRoute.of(context)?.isCurrent == false) return false;
final box = context.findRenderObject();
if (box is! RenderBox || !box.attached || !box.hasSize) return true;
final top = box.localToGlobal(Offset.zero).dy;
return top + box.size.height > 0 && top < MediaQuery.sizeOf(context).height;
}
void _syncPause() {
if (!_ready || !mounted) return;
final pause = !_visible;
if (pause == _paused) return;
_paused = pause;
_js('fanuzuPause($pause)');
}
@override
void dispose() {
_scroll?.removeListener(_syncPause);
if (_web != null) WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
final web = _web;
if (web == null) {
return widget.expanded
? GalaxyMiniMap.expanded(
data: widget.data,
onOpenPlanet: widget.onOpenPlanet,
)
: GalaxyMiniMap(
size: widget.miniSize,
data: widget.data,
onOpen: widget.onOpen,
);
}
final view = WebViewWidget(
controller: web,
// 확장 맵은 끌기 · 핀치를 모두 장면이 받는다(설명 프레임 2008:14486). 미니맵은 가로 끌기
// (회전)와 탭만 — 세로 끌기는 HOME 목록 스크롤로 남긴다.
gestureRecognizers: widget.expanded
? {Factory<OneSequenceGestureRecognizer>(EagerGestureRecognizer.new)}
: {
Factory<OneSequenceGestureRecognizer>(
HorizontalDragGestureRecognizer.new,
),
Factory<OneSequenceGestureRecognizer>(TapGestureRecognizer.new),
},
);
return widget.expanded
? view
: SizedBox.square(dimension: widget.miniSize, child: view);
}
}