사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · HOME 진입 구획 · 미니맵 · 공통 확인 창 배치 수정
This commit is contained in:
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../core/data/planet_visual_state.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../notification/notification_screen.dart';
|
||||
import '../eclipse/eclipse_data.dart';
|
||||
@@ -25,6 +26,15 @@ import '../../core/i18n/app_locale.dart';
|
||||
/// 궤도 맵([GalaxyScreen])은 시안의 갤럭시맵 명세(HUD · 3D 갤럭시)를 구현한 화면이라
|
||||
/// 그대로 두고, 카드나 우측 상단 목록 아이콘에서 들어간다.
|
||||
class GalaxyHomeScreen extends StatefulWidget {
|
||||
/// 카드 그림 — HOME · 맵과 같이 팬덤 `stage` 로 고른다. Stage0 성운만 성운 그림이고
|
||||
/// 정식 행성은 시안 행성 그림(`image 8` · 파란 행성 벡터)을 칸마다 번갈아 쓴다.
|
||||
/// 예전엔 칸 순서로 골라 둘째 칸이 행성이어도 성운 그림이 됐다.
|
||||
static String cardImageOf(PlanetNode p, int i) => p.stage == 0
|
||||
? 'assets/figma/nebula-card.png'
|
||||
: (i.isEven
|
||||
? 'assets/figma/planet-detail-hero.png'
|
||||
: 'assets/figma/planet-earth.png');
|
||||
|
||||
const GalaxyHomeScreen({
|
||||
super.key,
|
||||
this.watchPlanet = noPlanetWatch,
|
||||
@@ -247,7 +257,7 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
Expanded(
|
||||
child: _PlanetCard(
|
||||
planet: p,
|
||||
image: _imageFor(i),
|
||||
image: GalaxyHomeScreen.cardImageOf(p, i),
|
||||
selected: p.isMine,
|
||||
onTap: () => _choose(p),
|
||||
),
|
||||
@@ -279,13 +289,6 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 카드 그림. 시안 세 칸(`image 8` · `image 15` · 파란 행성 벡터)을 순서대로 쓴다.
|
||||
static String _imageFor(int i) => switch (i) {
|
||||
0 => 'assets/figma/planet-detail-hero.png',
|
||||
1 => 'assets/figma/nebula-card.png',
|
||||
_ => 'assets/figma/planet-earth.png',
|
||||
};
|
||||
}
|
||||
|
||||
/// 행성 카드 — 시안 109×109 #2c2c2c r12, 그림 88(안쪽 11·10), 선택은 #ff7ddd 테두리와
|
||||
|
||||
@@ -31,7 +31,8 @@ import '../../core/i18n/app_locale.dart';
|
||||
/// 화면 대조 원장 F2 의 2번. **09-03 GALAXY 2.5D 1차 시연 대상**이다.
|
||||
///
|
||||
/// 명세대로 갖춘 것
|
||||
/// - 3d갤럭시 브라우징 줌인·줌아웃, 터치 이동 → [InteractiveViewer]
|
||||
/// - 3d갤럭시 브라우징 줌인·줌아웃 → [InteractiveViewer] 핀치. 터치 이동은 행성 탭 중심
|
||||
/// 전환이 맡고, 한 손가락 끌기는 궤도 회전이다(맵 팬 없음 — 장면이 화면 밖으로 밀리지 않게)
|
||||
/// - 내 행성 한가운데, 행성 터치 시 화면 중심 전환 이동
|
||||
/// - HUD 에 Rank · FPI 총점 · 접속자수, 터치 시 FPI 상세로
|
||||
/// - GALAXY 텍스트 좌측 하단 · 돋보기 우측 하단 → 검색 화면
|
||||
@@ -40,8 +41,7 @@ import '../../core/i18n/app_locale.dart';
|
||||
///
|
||||
/// 2026-09-09 검토회신서 3절이 지적한 것과 그 답
|
||||
/// - *드래그·회전에 관성과 감속이 없다* → 궤도 각속도를 [GalaxyClock] 이 물리로
|
||||
/// 굴린다. 드래그가 밀고, 손을 떼면 마찰이 기본 표류로 되돌린다. 이동(팬)의
|
||||
/// 관성은 [InteractiveViewer] 가 이미 가진 플링을 그대로 쓴다.
|
||||
/// 굴린다. 드래그가 밀고, 손을 떼면 마찰이 기본 표류로 되돌린다.
|
||||
/// - *행성 간 이동감이 없다* → 중심 전환이 순간이동이 아니라
|
||||
/// [GalaxyMotion.recenter] 동안 감속하며 날아간다. 목표 좌표는 매 프레임 다시
|
||||
/// 잡으므로 도는 행성도 정확히 잡는다.
|
||||
@@ -82,6 +82,10 @@ class _GalaxyScreenState extends State<GalaxyScreen>
|
||||
late Future<GalaxySnapshot> _snapshot;
|
||||
|
||||
final _clock = GalaxyClock();
|
||||
|
||||
@visibleForTesting
|
||||
GalaxyClock get clockForTest => _clock;
|
||||
|
||||
final _view = TransformationController();
|
||||
late final Ticker _ticker;
|
||||
|
||||
@@ -394,9 +398,12 @@ class _Map extends StatelessWidget {
|
||||
minScale: GalaxyMotion.minZoom,
|
||||
maxScale: GalaxyMotion.maxZoom,
|
||||
boundaryMargin: const EdgeInsets.all(240),
|
||||
// 이동(팬)의 관성은 InteractiveViewer 의 플링이 이미 준다.
|
||||
// 여기서 더하는 것은 **회전 관성**이다 — 가로로 끌면 궤도가 따라
|
||||
// 돌고, 손을 떼면 마찰이 기본 표류로 되돌린다.
|
||||
// 한 손가락 끌기는 맵을 옮기지 않고 **궤도를 돌린다**(프로토타입 View
|
||||
// Rotation · 별첨 1 ①-3 드래그 360° 회전). 팬을 켜 두면 가로로 끌 때
|
||||
// 장면이 통째로 밀려 화면 밖으로 사라진다(10/4 실기기). 손을 떼면
|
||||
// [GalaxyClock] 마찰이 기본 표류로 되돌린다(관성 · 체크리스트 3-2).
|
||||
// 핀치 줌과 행성 탭 중심 전환은 그대로다.
|
||||
panEnabled: false,
|
||||
onInteractionStart: (_) => onGrab(),
|
||||
// 세로 드래그·핀치 줌까지 넘기면 각속도를 덮어써 궤도가 선다.
|
||||
onInteractionUpdate: (d) => onDrag(
|
||||
|
||||
@@ -19,6 +19,8 @@ import '../../../core/i18n/app_locale.dart';
|
||||
/// - **게이지 조절 8개는 넣지 않는다(C-51).**
|
||||
/// - 색은 테마 결정 전까지 프로토타입 값을 쓴다(C-49).
|
||||
/// - 데이터는 받은 [GalaxySnapshot] 만 쓴다. 프로토타입의 표본 5행성은 쓰지 않는다.
|
||||
/// - 궤도는 FPI 순위, 첫 화면 시점은 내 행성 중앙(C-103). 140pt 에서는 내 행성 간략 카드
|
||||
/// 한 장만, 다른 행성 HUD 없음(C-105). 드래그 회전은 관성 없음(C-105).
|
||||
///
|
||||
/// 반응: 스냅샷이 바뀌면(순위·FPI) 행성이 새 궤도로 미끄러지고 HUD 숫자가 바뀐다(프로토타입
|
||||
/// `updateFPI`). 가로로 끌면 장면이 돈다(±180°, 프로토타입 View Rotation 범위). 누르면 [onOpen].
|
||||
@@ -33,7 +35,7 @@ class GalaxyMiniMap extends StatefulWidget {
|
||||
final GalaxySnapshot data;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
/// 정사각 한 변. HOME 행성 영역 우측 상단(Q-DS-06 권고안 A · 회신 전 임시).
|
||||
/// 정사각 한 변. HOME 은 약 140pt(C-105 · 자리는 C-113 → `HomeHero.miniMapAlignment`).
|
||||
final double size;
|
||||
|
||||
@override
|
||||
@@ -172,6 +174,10 @@ final _tanHalfFov = math.tan(35 * math.pi / 180);
|
||||
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;
|
||||
|
||||
@@ -293,18 +299,22 @@ ui.Picture _hudPicture(PlanetNode p, Color color, double share) {
|
||||
return rec.endRecording();
|
||||
}
|
||||
|
||||
/// 반지름 1 단위의 광륜(6R, 가산 합성) + 발광 구체.
|
||||
/// 프로토타입 광륜 반지름(행성 반지름 배수). `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,
|
||||
3,
|
||||
_auraR,
|
||||
Paint()
|
||||
..blendMode = BlendMode.plus
|
||||
..shader = ui.Gradient.radial(
|
||||
Offset.zero,
|
||||
3,
|
||||
_auraR,
|
||||
[
|
||||
c.withValues(alpha: 0.4),
|
||||
c.withValues(alpha: 0.15),
|
||||
@@ -360,19 +370,22 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
/// 그리는 행성 수(내 행성은 순위 밖이어도 더한다).
|
||||
static const maxPlanets = 10;
|
||||
|
||||
/// 간략 카드(내 행성)의 최대 폭(미니맵 폭 대비).
|
||||
static const hudFloor = 0.7;
|
||||
/// 간략 카드(내 행성)의 최대 폭(미니맵 폭 대비). 넘치면 카드를 통째로 줄인다(숫자는 자르지 않는다).
|
||||
static const hudFloor = 0.55;
|
||||
|
||||
/// 이 폭 이상이면 프로토타입 HUD 카드를 모든 행성에 그대로 그린다.
|
||||
static const fullHudMin = 280.0;
|
||||
|
||||
/// 간략 카드 글자 크기(pt) — 이름 · 둘째 줄.
|
||||
static const compactNamePt = 11.0;
|
||||
static const compactLinePt = 9.0;
|
||||
/// 간략 카드 글자 크기(pt) — 이름 · 숫자 줄. 프로토타입 텍스처의 줄 구성(이름 / RANK · FPI /
|
||||
/// LIVE)을 세 줄로 줄인 것이다. 140pt 에서 카드가 장면을 덮지 않게 한 줄을 짧게 둔다.
|
||||
static const compactNamePt = 9.0;
|
||||
static const compactLinePt = 8.0;
|
||||
|
||||
/// 간략 카드 둘째 줄 — `#순위 · FPI n · LIVE n`.
|
||||
static String compactLine(PlanetNode p) =>
|
||||
'#${p.rank} · FPI ${formatPop(p.fpi)} · LIVE ${formatPop(p.online)}';
|
||||
/// 간략 카드 숫자 줄 — `#순위 · 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;
|
||||
@@ -388,42 +401,49 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
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);
|
||||
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 + name.height + 2 + stats.height;
|
||||
final h = pad * 2 + textH;
|
||||
// 숫자 줄이 최대 폭을 넘을 때만 카드를 줄인다 — 줄을 자르면 순위·FPI 숫자가 사라진다.
|
||||
final k = math.min(1.0, maxW / w);
|
||||
final rec = ui.PictureRecorder();
|
||||
final canvas = Canvas(rec);
|
||||
final canvas = Canvas(rec)..scale(k);
|
||||
final box = Rect.fromLTWH(0, 0, w, h);
|
||||
canvas.drawRect(box, Paint()..color = const Color(0xE6000514));
|
||||
canvas.drawRect(
|
||||
@@ -433,8 +453,13 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
..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));
|
||||
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(
|
||||
@@ -445,13 +470,8 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
),
|
||||
Paint()..color = _emerald,
|
||||
);
|
||||
compactText = [
|
||||
(b.node.name.toUpperCase(), compactNamePt),
|
||||
(compactLine(b.node), compactLinePt),
|
||||
];
|
||||
name.dispose();
|
||||
stats.dispose();
|
||||
_compactSize = Size(w, h);
|
||||
compactText = [for (final (t, pt, _, _) in texts) (t, pt * k)];
|
||||
_compactSize = Size(w * k, h * k);
|
||||
return _compact = rec.endRecording();
|
||||
}
|
||||
|
||||
@@ -466,6 +486,15 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
@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;
|
||||
@@ -607,13 +636,14 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
..shader = ui.Gradient.radial(
|
||||
center,
|
||||
r,
|
||||
const [Color(0x0A10B981), Color(0x033B82F6), Color(0x00000000)],
|
||||
const [Color(0x0410B981), Color(0x013B82F6), Color(0x00000000)],
|
||||
const [0, 0.4, 0.8],
|
||||
),
|
||||
);
|
||||
canvas.restore();
|
||||
}
|
||||
// 프로토타입 별 8000개. 화면 넓이당 밀도는 390×844 화면 기준으로 맞춘다.
|
||||
// 프로토타입 별 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++) {
|
||||
@@ -632,7 +662,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
ui.PointMode.points,
|
||||
Float32List.fromList(pts),
|
||||
Paint()
|
||||
..strokeWidth = 0
|
||||
..strokeWidth = _dot
|
||||
..color = const Color(0xCCBFBFFF),
|
||||
);
|
||||
_bg = rec.endRecording();
|
||||
@@ -708,11 +738,9 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
}
|
||||
if (out.isNotEmpty) {
|
||||
_fill
|
||||
..strokeWidth = 0
|
||||
..strokeWidth = _dot
|
||||
..blendMode = BlendMode.plus
|
||||
..color = b.color.withValues(
|
||||
alpha: 0.9 * (2 * _scale * focal / d).clamp(0.2, 1.0),
|
||||
);
|
||||
..color = b.color.withValues(alpha: 0.9);
|
||||
canvas.drawRawPoints(ui.PointMode.points, out, _fill);
|
||||
_fill.blendMode = BlendMode.srcOver;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user