갤럭시 검색 결과에서 행성 상세로 이동

This commit is contained in:
Sn-Kinos
2026-10-05 17:39:23 +09:00
parent 54d0b23260
commit 8a1b824307
11 changed files with 394 additions and 171 deletions
+71 -11
View File
@@ -96,8 +96,7 @@ class GalaxySnapshot {
/// 명세 규칙 — *행성/성운 가입 또는 생성한 유저에게만 노출* (Zap 버튼).
final bool joined;
PlanetNode? get mine =>
planets.where((p) => p.isMine).firstOrNull;
PlanetNode? get mine => planets.where((p) => p.isMine).firstOrNull;
/// Ranking View — 원장 B-21 「Ranking View 는 FPI 상위 중심」. FPI 1위 행성을 가운데
/// 두고 나머지는 기본 보기와 같이 순위 궤도를 돈다. Stage0 성운은 랭킹 모집단이 아니므로
@@ -133,7 +132,11 @@ class GalaxySnapshot {
/// ZAP 한 번(또는 연타 묶음)의 결과. 서버가 확정한 잔액만 담는다.
class ZapResult {
const ZapResult({required this.txId, required this.balanceAfter, this.contributedPop});
const ZapResult({
required this.txId,
required this.balanceAfter,
this.contributedPop,
});
final String txId;
@@ -203,16 +206,73 @@ class SampleGalaxyRepository implements GalaxyRepository {
online: 1204,
isMine: true,
),
PlanetNode(id: 'newjeans', name: 'NewJeans', rank: 2, fpi: 271905, members: 11230, online: 980),
PlanetNode(id: 'seventeen', name: 'SEVENTEEN', rank: 3, fpi: 254120, members: 10450, online: 872),
PlanetNode(id: 'ive', name: 'IVE', rank: 4, fpi: 198740, members: 8320, online: 611),
PlanetNode(id: 'aespa', name: 'aespa', rank: 5, fpi: 176250, members: 7640, online: 540),
PlanetNode(id: 'lesserafim', name: 'LE SSERAFIM', rank: 6, fpi: 152080, members: 6910, online: 402),
PlanetNode(
id: 'newjeans',
name: 'NewJeans',
rank: 2,
fpi: 271905,
members: 11230,
online: 980,
),
PlanetNode(
id: 'seventeen',
name: 'SEVENTEEN',
rank: 3,
fpi: 254120,
members: 10450,
online: 872,
),
PlanetNode(
id: 'ive',
name: 'IVE',
rank: 4,
fpi: 198740,
members: 8320,
online: 611,
),
PlanetNode(
id: 'aespa',
name: 'aespa',
rank: 5,
fpi: 176250,
members: 7640,
online: 540,
),
PlanetNode(
id: 'lesserafim',
name: 'LE SSERAFIM',
rank: 6,
fpi: 152080,
members: 6910,
online: 402,
),
],
memberships: const [
PlanetNode(id: 'bts', name: 'BTS', rank: 1, fpi: 284310, members: 12840, online: 1204, isMine: true),
PlanetNode(id: 'newjeans', name: 'NewJeans', rank: 2, fpi: 271905, members: 11230, online: 980),
PlanetNode(id: 'seventeen', name: 'SEVENTEEN', rank: 3, fpi: 254120, members: 10450, online: 872),
PlanetNode(
id: 'bts',
name: 'BTS',
rank: 1,
fpi: 284310,
members: 12840,
online: 1204,
isMine: true,
),
PlanetNode(
id: 'newjeans',
name: 'NewJeans',
rank: 2,
fpi: 271905,
members: 11230,
online: 980,
),
PlanetNode(
id: 'seventeen',
name: 'SEVENTEEN',
rank: 3,
fpi: 254120,
members: 10450,
online: 872,
),
],
);
}
@@ -77,7 +77,10 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
if (!ok || !mounted) return;
try {
await widget.repository.setPrimary(p.id);
if (mounted) setState(() { _snapshot = widget.repository.load(); });
if (mounted)
setState(() {
_snapshot = widget.repository.load();
});
} on BackendException catch (e) {
// 30일 대기(E-FDM-04) 등 서버 사유를 그대로 알린다.
if (mounted) await showAlertDialog(context, e.message);
@@ -101,11 +104,14 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
final myPop = (await _snapshot).myPop;
final fandomId = await navigator.push(
MaterialPageRoute<String>(
builder: (_) => NebulaCreateScreen(repository: widget.home, myPop: myPop),
builder: (_) =>
NebulaCreateScreen(repository: widget.home, myPop: myPop),
),
);
if (fandomId == null || !mounted) return;
setState(() { _snapshot = widget.repository.load(); });
setState(() {
_snapshot = widget.repository.load();
});
await navigator.push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
@@ -182,6 +188,22 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
MaterialPageRoute<void>(
builder: (_) => GalaxySearchScreen(
repository: widget.repository,
onOpen: (p) =>
Navigator.of(
context,
rootNavigator: true,
).push(
MaterialPageRoute<void>(
builder: (_) =>
PlanetDetailScreen(
repository: widget
.planetRepository(p.id),
watchPlanet:
widget.watchPlanet,
eclipse: widget.eclipse,
),
),
),
),
),
),
@@ -278,9 +300,7 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
left: 0,
right: 0,
bottom: 16,
child: Center(
child: _NebulaPill(onTap: _createNebula),
),
child: Center(child: _NebulaPill(onTap: _createNebula)),
),
],
);
+4 -2
View File
@@ -132,8 +132,10 @@ class GalaxyClock extends ChangeNotifier {
if (_dragged != 0) {
// 손이 닿아 있는 동안의 각속도는 **드래그 거리가 아니라 드래그 속도**를
// 따른다. 거리로 누적하면 천천히 오래 끌기만 해도 팽이처럼 돌아 버린다.
_velocity = (_dragged / dt * GalaxyMotion.dragSpinGain)
.clamp(-GalaxyMotion.maxSpin, GalaxyMotion.maxSpin);
_velocity = (_dragged / dt * GalaxyMotion.dragSpinGain).clamp(
-GalaxyMotion.maxSpin,
GalaxyMotion.maxSpin,
);
_dragged = 0;
} else {
// 지수 감쇠 — 프레임 간격이 흔들려도 같은 시간에 같은 만큼 준다.
@@ -1,4 +1,5 @@
import 'package:cloud_firestore/cloud_firestore.dart' show QueryDocumentSnapshot;
import 'package:cloud_firestore/cloud_firestore.dart'
show QueryDocumentSnapshot;
import '../../core/data/backend.dart';
import '../planet/planet_data.dart' show rankWithTies;
@@ -58,7 +59,8 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
// fandomId 오름차순으로 고정한다 — 쿼리 순서(동점은 ID 내림차순)를 그대로 쓰지 않는다.
// Stage0 성운은 순위를 매기기 **전에** 뺀다(B-08 · B-17) — 성운이 끼면 정식 행성의
// 순위 번호가 밀린다. 성운은 맨 바깥 궤도 자리(정식 수 + 1)에 둔다.
bool isNebula(QueryDocumentSnapshot<Map<String, dynamic>> d) => d.data()['stage'] == 0;
bool isNebula(QueryDocumentSnapshot<Map<String, dynamic>> d) =>
d.data()['stage'] == 0;
final formal = fandoms.where((d) => !isNebula(d)).toList();
final planets = <PlanetNode>[
for (final (doc, rank) in rankWithTies(
@@ -79,7 +81,8 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
]);
for (final doc in missing) {
final data = doc.data();
if (data != null) byId[doc.id] = _node(doc.id, data, formal.length + 1, myFandomId);
if (data != null)
byId[doc.id] = _node(doc.id, data, formal.length + 1, myFandomId);
}
return GalaxySnapshot(
@@ -93,7 +96,12 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
);
}
PlanetNode _node(String id, Map<String, dynamic> data, int rank, String myFandomId) => PlanetNode(
PlanetNode _node(
String id,
Map<String, dynamic> data,
int rank,
String myFandomId,
) => PlanetNode(
id: id,
stage: switch (data['stage']) {
int n when n >= 0 && n <= 6 => n,
@@ -124,7 +132,10 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
required String clientSeq,
}) async {
final snapshot = await _backend.db.doc('config/policy').get();
final zapPop = (snapshot.data() ?? const <String, dynamic>{}).intOr('zapPop', 10);
final zapPop = (snapshot.data() ?? const <String, dynamic>{}).intOr(
'zapPop',
10,
);
final data = await _backend.call('fnPopSpend', {
'popType': 'activity',
'amount': zapPop * count,
@@ -135,9 +146,17 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
'refPath': 'fandoms/$fandomId',
});
final lots = data['lotRefs'];
final contributedPop = lots is List && lots.isNotEmpty && lots.every(
(lot) => lot is Map && lot['delta'] is int && (lot['delta'] as int) < 0,
) ? lots.fold<int>(0, (sum, lot) => sum - (lot['delta'] as int)) : null;
final contributedPop =
lots is List &&
lots.isNotEmpty &&
lots.every(
(lot) =>
lot is Map &&
lot['delta'] is int &&
(lot['delta'] as int) < 0,
)
? lots.fold<int>(0, (sum, lot) => sum - (lot['delta'] as int))
: null;
return ZapResult(
contributedPop: contributedPop,
txId: data['txId'] as String? ?? '',
+150 -103
View File
@@ -125,7 +125,8 @@ class _GalaxyScreenState extends State<GalaxyScreen>
void initState() {
super.initState();
_snapshot = widget.repository.load();
_zap = ZapController(widget.repository, onFailure: _notify)..addListener(_onZapChanged);
_zap = ZapController(widget.repository, onFailure: _notify)
..addListener(_onZapChanged);
_fly = AnimationController(vsync: this, duration: GalaxyMotion.recenter)
..addListener(_onFly);
_ticker = createTicker(_tick);
@@ -182,12 +183,18 @@ class _GalaxyScreenState extends State<GalaxyScreen>
void _follow() {
final p = _selected;
if (p == null || _viewport.isEmpty || _fly.isAnimating) return;
final at = GalaxyField.positionOf(p, _selectedIndex, _clock.spin, _outerRank);
final at = GalaxyField.positionOf(
p,
_selectedIndex,
_clock.spin,
_outerRank,
);
final s = _view.value.getMaxScaleOnAxis();
final tx = _viewport.width / 2 - at.dx * s;
final ty = _viewport.height / 2 - at.dy * s;
final m = _view.value;
if ((m.storage[12] - tx).abs() < 0.01 && (m.storage[13] - ty).abs() < 0.01) {
if ((m.storage[12] - tx).abs() < 0.01 &&
(m.storage[13] - ty).abs() < 0.01) {
return;
}
_view.value = m.clone()
@@ -258,50 +265,57 @@ class _GalaxyScreenState extends State<GalaxyScreen>
return LiveStreamBuilder<EclipseState?>(
key: ValueKey('eclipse-${myFandomId ?? 'mine'}'),
create: () => widget.eclipse.watch(fandomId: myFandomId),
builder: (context, eclipse) => LiveStreamBuilder<PlanetVisualState>(
key: ValueKey(('galaxy-orbit', (_selected ?? data.mine)?.id)),
create: () => widget.watchPlanet((_selected ?? data.mine)?.id ?? ''),
builder: (context, state) {
_clock.idleSpeedScale = state?.isHibernating == true
? 0.25
: state?.isEclipsed == true
? 0.5
: state?.isHeroTime == true
? 1.5
: 1;
return _Map(
data: data,
watchPlanet: widget.watchPlanet,
eclipse: eclipse,
eclipseRepository: widget.eclipse,
repository: widget.repository,
planetRepository: widget.planetRepository,
clock: _clock,
view: _view,
selected: _selected ?? data.mine,
selectedIndex: _selected == null
? data.planets.indexWhere((p) => p.isMine).clamp(0, 1 << 30)
: _selectedIndex,
balance: _zap.balance,
pending: _zap.pending,
sending: _zap.sending,
touchSerial: _zap.touchSerial,
zapResult: _zap.result,
retryZap: _zap.needsRetry ? _zap.retry : null,
outerRank: _outerRank,
ranking: _ranking,
onToggleRanking: () => _toggleRanking(snap.data!),
fly: _fly,
onSelect: _center,
onDrag: _clock.drag,
onGrab: () {
// 날아가는 도중에 손을 대면 사용자가 이긴다.
if (_fly.isAnimating) _fly.stop();
builder: (context, eclipse) =>
LiveStreamBuilder<PlanetVisualState>(
key: ValueKey((
'galaxy-orbit',
(_selected ?? data.mine)?.id,
)),
create: () =>
widget.watchPlanet((_selected ?? data.mine)?.id ?? ''),
builder: (context, state) {
_clock.idleSpeedScale = state?.isHibernating == true
? 0.25
: state?.isEclipsed == true
? 0.5
: state?.isHeroTime == true
? 1.5
: 1;
return _Map(
data: data,
watchPlanet: widget.watchPlanet,
eclipse: eclipse,
eclipseRepository: widget.eclipse,
repository: widget.repository,
planetRepository: widget.planetRepository,
clock: _clock,
view: _view,
selected: _selected ?? data.mine,
selectedIndex: _selected == null
? data.planets
.indexWhere((p) => p.isMine)
.clamp(0, 1 << 30)
: _selectedIndex,
balance: _zap.balance,
pending: _zap.pending,
sending: _zap.sending,
touchSerial: _zap.touchSerial,
zapResult: _zap.result,
retryZap: _zap.needsRetry ? _zap.retry : null,
outerRank: _outerRank,
ranking: _ranking,
onToggleRanking: () => _toggleRanking(snap.data!),
fly: _fly,
onSelect: _center,
onDrag: _clock.drag,
onGrab: () {
// 날아가는 도중에 손을 대면 사용자가 이긴다.
if (_fly.isAnimating) _fly.stop();
},
onZap: _zap.tap,
);
},
onZap: _zap.tap,
);
},
),
),
);
},
),
@@ -433,9 +447,14 @@ class _Map extends StatelessWidget {
child: AnimatedBuilder(
animation: clock,
builder: (context, _) => GalaxyField(
onOpenHistory: (planet) => Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => PlanetHistoryScreen(repository: planetRepository(planet.id), fandomName: planet.name),
)),
onOpenHistory: (planet) => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlanetHistoryScreen(
repository: planetRepository(planet.id),
fandomName: planet.name,
),
),
),
planets: data.planets,
watchPlanet: watchPlanet,
selected: selected,
@@ -522,7 +541,19 @@ class _Map extends StatelessWidget {
tooltip: context.l10n.commonSearch,
onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => GalaxySearchScreen(repository: repository),
builder: (_) => GalaxySearchScreen(
repository: repository,
onOpen: (p) =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository(p.id),
watchPlanet: watchPlanet,
eclipse: eclipseRepository,
),
),
),
),
),
),
icon: const Icon(Icons.search),
@@ -540,24 +571,31 @@ class _Map extends StatelessWidget {
if (eclipse case final e?)
EclipseBannerCard(
state: e,
onTap: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(builder: (_) => EclipseDefendScreen(
repository: eclipseRepository, fandomId: e.fandomId,
)),
),
onTap: () =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => EclipseDefendScreen(
repository: eclipseRepository,
fandomId: e.fandomId,
),
),
),
),
if (selected case final target?)
HeroTimeBillboard(
fandomId: target.id,
fandomName: target.name,
watch: watchPlanet,
onOpen: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(builder: (_) => PlanetDetailScreen(
repository: planetRepository(target.id),
watchPlanet: watchPlanet,
eclipse: eclipseRepository,
)),
),
onOpen: () =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository(target.id),
watchPlanet: watchPlanet,
eclipse: eclipseRepository,
),
),
),
),
],
),
@@ -618,48 +656,54 @@ class _Map extends StatelessWidget {
key: ValueKey(('zap-mode', data.mine?.id)),
create: () => watchPlanet(data.mine?.id ?? ''),
builder: (context, state) {
final hero = state?.isHeroTime == true &&
final hero =
state?.isHeroTime == true &&
state?.heroEndsAt?.isAfter(DateTime.now()) == true &&
pending == 0 && !sending && retryZap == null;
pending == 0 &&
!sending &&
retryZap == null;
return ZapButton(
isHeroTime: hero,
// 단가는 `config/policy.zapPop` 이다 — Figma 「Zap 버튼」이
// 어드민 설정값이라 못 박았다.
zapPop: data.zapPop,
// 표시 잔액 = 서버 확정값(없으면 스냅샷) − 아직 안 보낸 연타분.
myPop: (balance ?? data.myPop) - pending * data.zapPop,
// **이클립스와 무관하다.** ZAP 은 행성 성장 기여이며(B-23), 방어는
// 「흔들어 방어」 화면에서 POP 없이 참여한다.
blockedReason: sending
? context.l10n.zapApplying
: retryZap != null
? context.l10n.zapNeedsCheck
: data.mine == null
? context.l10n.zapNoPlanet
: null,
onZap: () {
if (data.mine case final mine?) {
if (hero) {
showModalBottomSheet<void>(
context: context, isScrollControlled: true,
builder: (_) => HeroParticipationSheet(
fandomId: mine.id, watch: watchPlanet,
repository: planetRepository(mine.id),
),
);
return;
}
onSelect(
mine,
data.planets.indexOf(mine),
viewport,
animate: !MediaQuery.disableAnimationsOf(context),
);
onZap(mine.id);
}
},
onContribute: () => _todo(context, context.l10n.commonContribute),
onItem: () => _todo(context, context.l10n.commonItem),
isHeroTime: hero,
// 단가는 `config/policy.zapPop` 이다 — Figma 「Zap 버튼」이
// 어드민 설정값이라 못 박았다.
zapPop: data.zapPop,
// 표시 잔액 = 서버 확정값(없으면 스냅샷) − 아직 안 보낸 연타분.
myPop: (balance ?? data.myPop) - pending * data.zapPop,
// **이클립스와 무관하다.** ZAP 은 행성 성장 기여이며(B-23), 방어는
// 「흔들어 방어」 화면에서 POP 없이 참여한다.
blockedReason: sending
? context.l10n.zapApplying
: retryZap != null
? context.l10n.zapNeedsCheck
: data.mine == null
? context.l10n.zapNoPlanet
: null,
onZap: () {
if (data.mine case final mine?) {
if (hero) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => HeroParticipationSheet(
fandomId: mine.id,
watch: watchPlanet,
repository: planetRepository(mine.id),
),
);
return;
}
onSelect(
mine,
data.planets.indexOf(mine),
viewport,
animate: !MediaQuery.disableAnimationsOf(context),
);
onZap(mine.id);
}
},
onContribute: () =>
_todo(context, context.l10n.commonContribute),
onItem: () => _todo(context, context.l10n.commonItem),
);
},
),
@@ -866,7 +910,10 @@ class _HudCard extends StatelessWidget {
),
),
// Stage0 성운은 랭킹 모집단이 아니다(B-17).
_Metric(label: 'RANK', value: planet.stage == 0 ? '-' : '${planet.rank}'),
_Metric(
label: 'RANK',
value: planet.stage == 0 ? '-' : '${planet.rank}',
),
],
),
const SizedBox(height: AppSpace.s1),
@@ -16,10 +16,14 @@ class GalaxySearchScreen extends StatefulWidget {
const GalaxySearchScreen({
super.key,
this.repository = const SampleGalaxyRepository(),
this.onOpen,
});
final GalaxyRepository repository;
/// 결과 줄을 누르면 그 행성 상세로 — 상세 화면 저장소는 부르는 쪽이 안다.
final void Function(PlanetNode planet)? onOpen;
@override
State<GalaxySearchScreen> createState() => _GalaxySearchScreenState();
}
@@ -153,7 +157,12 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
padding: const EdgeInsets.all(AppSpace.s4),
itemCount: hits.length,
separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s2),
itemBuilder: (context, i) => _Hit(planet: hits[i]),
itemBuilder: (context, i) => _Hit(
planet: hits[i],
onTap: widget.onOpen == null
? null
: () => widget.onOpen!(hits[i]),
),
);
},
),
@@ -257,40 +266,45 @@ class _NoHit extends StatelessWidget {
}
class _Hit extends StatelessWidget {
const _Hit({required this.planet});
const _Hit({required this.planet, this.onTap});
final PlanetNode planet;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Container(
padding: const EdgeInsets.all(AppSpace.s4),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Row(
children: [
Text(
'${planet.rank}',
style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent),
),
const SizedBox(width: AppSpace.s4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(planet.name, style: t.bodyMedium),
Text(
context.l10n.galaxyMembers(formatPop(planet.members)),
style: t.labelSmall,
),
],
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(AppSpace.s4),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Row(
children: [
Text(
'${planet.rank}',
style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent),
),
),
Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall),
],
const SizedBox(width: AppSpace.s4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(planet.name, style: t.bodyMedium),
Text(
context.l10n.galaxyMembers(formatPop(planet.members)),
style: t.labelSmall,
),
],
),
),
Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall),
],
),
),
);
}
@@ -73,12 +73,20 @@ class GalaxyField extends StatelessWidget {
/// 90개가 사라진다. 바깥 궤도가 [edgeMargin] 안쪽에 서도록 간격을 나눈다.
static double ringOf(int rank, int outerRank) {
final rings = math.max(1, outerRank - 1);
final gap = math.min(ringGap, (extent / 2 - innerRing - edgeMargin) / rings);
final gap = math.min(
ringGap,
(extent / 2 - innerRing - edgeMargin) / rings,
);
return innerRing + (rank - 1) * gap;
}
/// 행성의 논리 좌표. 그리기·히트 테스트·HUD 가 같은 식을 써야 어긋나지 않는다.
static Offset positionOf(PlanetNode p, int index, double spin, int outerRank) {
static Offset positionOf(
PlanetNode p,
int index,
double spin,
int outerRank,
) {
if (p.isCenter) return const Offset(extent / 2, extent / 2);
// 순위가 낮을수록 멀어진다. 중심 행성을 뺀 나머지를 고리에 흩는다.
final ring = ringOf(p.rank, outerRank);
@@ -101,7 +109,10 @@ class GalaxyField extends StatelessWidget {
/// -1(뒤·화면 위) ~ +1(앞·화면 아래) 의 깊이.
static double depthOf(Offset at) =>
((at.dy - extent / 2) / (extent / 2 * GalaxyMotion.tilt)).clamp(-1.0, 1.0);
((at.dy - extent / 2) / (extent / 2 * GalaxyMotion.tilt)).clamp(
-1.0,
1.0,
);
/// 2.5D 깊이 배율. 궤도 앞쪽일수록 크다.
static double depthScaleOf(Offset at) =>
@@ -125,7 +136,9 @@ class GalaxyField extends StatelessWidget {
/// 행성 일러스트. 내 행성은 홈과 같은 그림, 나머지는 시안 행성 2종을 돌려 쓴다.
static String imageOf(PlanetNode p, int index) => p.isMine
? 'assets/figma/planet-home.png'
: (index.isEven ? 'assets/figma/planet-1.png' : 'assets/figma/planet-2.png');
: (index.isEven
? 'assets/figma/planet-1.png'
: 'assets/figma/planet-2.png');
@override
Widget build(BuildContext context) {
@@ -147,7 +160,10 @@ class GalaxyField extends StatelessWidget {
Positioned.fill(
child: RepaintBoundary(
child: CustomPaint(
painter: _BackdropPainter(planets: planets, outerRank: outerRank),
painter: _BackdropPainter(
planets: planets,
outerRank: outerRank,
),
isComplex: true,
willChange: false,
),
@@ -169,7 +185,9 @@ class GalaxyField extends StatelessWidget {
opacity: depthOpacityOf(at),
selected: selected?.id == p.id,
onTap: () => onSelect(p),
onOpenHistory: onOpenHistory == null ? null : () => onOpenHistory!(p),
onOpenHistory: onOpenHistory == null
? null
: () => onOpenHistory!(p),
),
],
),
@@ -243,8 +261,17 @@ class _PlanetHit extends StatelessWidget {
),
),
// 3~5. 본체 일러스트(표면 장식·구름 포함).
LivePlanet25D(onOpenHistory: onOpenHistory, fandomId: planet.id, watch: watchPlanet, image: image, size: side, stage: planet.stage, glow: glow,
animate: selected, lowEffects: !selected),
LivePlanet25D(
onOpenHistory: onOpenHistory,
fandomId: planet.id,
watch: watchPlanet,
image: image,
size: side,
stage: planet.stage,
glow: glow,
animate: selected,
lowEffects: !selected,
),
Positioned(
bottom: -4,
child: Text(
@@ -257,7 +284,9 @@ class _PlanetHit extends StatelessWidget {
fontWeight: planet.isMine
? FontWeight.w600
: FontWeight.w400,
shadows: const [Shadow(color: Colors.black, blurRadius: 6)],
shadows: const [
Shadow(color: Colors.black, blurRadius: 6),
],
),
),
),
@@ -309,7 +338,8 @@ class _BackdropPainter extends CustomPainter {
static bool _sameRanks(List<PlanetNode> a, List<PlanetNode> b) {
for (var i = 0; i < a.length; i++) {
if (a[i].rank != b[i].rank || a[i].isCenter != b[i].isCenter) return false;
if (a[i].rank != b[i].rank || a[i].isCenter != b[i].isCenter)
return false;
}
return true;
}
@@ -148,7 +148,8 @@ class _GalaxyWebViewState extends State<GalaxyWebView>
}
bool get _visible {
if (!mounted || !_foreground || !TickerMode.valuesOf(context).enabled) return false;
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;
@@ -80,7 +80,8 @@ class _ZapButtonState extends State<ZapButton> {
}
bool get _canZap =>
widget.blockedReason == null && (widget.isHeroTime || widget.myPop >= widget.zapPop);
widget.blockedReason == null &&
(widget.isHeroTime || widget.myPop >= widget.zapPop);
@override
Widget build(BuildContext context) => AnimatedSize(
@@ -180,7 +181,9 @@ class _ZapButtonState extends State<ZapButton> {
icon: const Icon(Icons.bolt),
label: Text(
_canZap
? widget.isHeroTime ? 'ZAP+ · HERO TIME' : 'ZAP ${widget.zapPop} POP'
? widget.isHeroTime
? 'ZAP+ · HERO TIME'
: 'ZAP ${widget.zapPop} POP'
: (widget.blockedReason ?? context.l10n.zapNotEnoughPop),
),
),