GIVE 연타에 행성이 흔들리고 우하단에 [+1] · 10회 이상이면 [+10]과 자전 1.5배, 전송 중 연타는 다음 묶음으로 쌓임
This commit is contained in:
@@ -18,8 +18,12 @@ class LivePlanet25D extends StatelessWidget {
|
|||||||
this.glow = AppColors.neonViolet,
|
this.glow = AppColors.neonViolet,
|
||||||
this.animate = true,
|
this.animate = true,
|
||||||
this.lowEffects = false,
|
this.lowEffects = false,
|
||||||
|
this.spinBoost = false,
|
||||||
});
|
});
|
||||||
final VoidCallback? onOpenHistory;
|
final VoidCallback? onOpenHistory;
|
||||||
|
|
||||||
|
/// [Planet25D.spinBoost].
|
||||||
|
final bool spinBoost;
|
||||||
final String fandomId;
|
final String fandomId;
|
||||||
final PlanetWatch watch;
|
final PlanetWatch watch;
|
||||||
final String image;
|
final String image;
|
||||||
@@ -54,6 +58,7 @@ class LivePlanet25D extends StatelessWidget {
|
|||||||
heroEventIds: state?.heroEventIds,
|
heroEventIds: state?.heroEventIds,
|
||||||
animate: animate,
|
animate: animate,
|
||||||
lowEffects: lowEffects,
|
lowEffects: lowEffects,
|
||||||
|
spinBoost: spinBoost,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,8 +145,13 @@ class Planet25D extends StatefulWidget {
|
|||||||
this.onOpenHistory,
|
this.onOpenHistory,
|
||||||
this.lowEffects = false,
|
this.lowEffects = false,
|
||||||
this.animate = true,
|
this.animate = true,
|
||||||
|
this.spinBoost = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// GIVE 10회 이상 연타 중 — Figma `2033:15818` 「자전속도 1.5 배」. 본체는 자전하지 않으므로(C-185)
|
||||||
|
/// 구름 · 궤도 · 링 먼지 · 펄스가 도는 주기를 1.5배 빠르게 한다.
|
||||||
|
final bool spinBoost;
|
||||||
|
|
||||||
/// 다음 Stage 진화 5% 전 — 표면 아우라([PlanetVisualState.evolutionImminent]).
|
/// 다음 Stage 진화 5% 전 — 표면 아우라([PlanetVisualState.evolutionImminent]).
|
||||||
final bool evolutionImminent;
|
final bool evolutionImminent;
|
||||||
|
|
||||||
@@ -218,9 +223,12 @@ class _Planet25DState extends State<Planet25D>
|
|||||||
// 실패의 감소 폭은 원문에 수치가 없어 이클립스 진행 중과 같은 값을 쓴다(해석 확인 항목).
|
// 실패의 감소 폭은 원문에 수치가 없어 이클립스 진행 중과 같은 값을 쓴다(해석 확인 항목).
|
||||||
? eclipseLight
|
? eclipseLight
|
||||||
: 1;
|
: 1;
|
||||||
|
Duration get _cycle =>
|
||||||
|
Duration(milliseconds: (_period * 1000 / (widget.spinBoost ? 1.5 : 1)).round());
|
||||||
|
|
||||||
late final AnimationController _c = AnimationController(
|
late final AnimationController _c = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: Duration(seconds: _period),
|
duration: _cycle,
|
||||||
);
|
);
|
||||||
|
|
||||||
bool get _motionEnabled =>
|
bool get _motionEnabled =>
|
||||||
@@ -245,8 +253,9 @@ class _Planet25DState extends State<Planet25D>
|
|||||||
super.didUpdateWidget(old);
|
super.didUpdateWidget(old);
|
||||||
if (old.isHibernating != widget.isHibernating ||
|
if (old.isHibernating != widget.isHibernating ||
|
||||||
old.isEclipsed != widget.isEclipsed ||
|
old.isEclipsed != widget.isEclipsed ||
|
||||||
old.isHeroTime != widget.isHeroTime) {
|
old.isHeroTime != widget.isHeroTime ||
|
||||||
_c.duration = Duration(seconds: _period);
|
old.spinBoost != widget.spinBoost) {
|
||||||
|
_c.duration = _cycle;
|
||||||
if (_motionEnabled) _c.repeat();
|
if (_motionEnabled) _c.repeat();
|
||||||
}
|
}
|
||||||
_syncMotion();
|
_syncMotion();
|
||||||
|
|||||||
@@ -297,7 +297,8 @@ class _GalaxyScreenState extends State<GalaxyScreen>
|
|||||||
.clamp(0, 1 << 30)
|
.clamp(0, 1 << 30)
|
||||||
: _selectedIndex,
|
: _selectedIndex,
|
||||||
balance: _zap.balance,
|
balance: _zap.balance,
|
||||||
pending: _zap.pending,
|
// 보내는 중인 묶음까지 — 전송 중에도 연타가 쌓인다.
|
||||||
|
pending: _zap.queued,
|
||||||
sending: _zap.sending,
|
sending: _zap.sending,
|
||||||
touchSerial: _zap.touchSerial,
|
touchSerial: _zap.touchSerial,
|
||||||
zapResult: _zap.result,
|
zapResult: _zap.result,
|
||||||
@@ -671,9 +672,7 @@ class _Map extends StatelessWidget {
|
|||||||
myPop: (balance ?? data.myPop) - pending * data.zapPop,
|
myPop: (balance ?? data.myPop) - pending * data.zapPop,
|
||||||
// **이클립스와 무관하다.** ZAP 은 행성 성장 기여이며(B-23), 방어는
|
// **이클립스와 무관하다.** ZAP 은 행성 성장 기여이며(B-23), 방어는
|
||||||
// 「흔들어 방어」 화면에서 POP 없이 참여한다.
|
// 「흔들어 방어」 화면에서 POP 없이 참여한다.
|
||||||
blockedReason: sending
|
blockedReason: retryZap != null
|
||||||
? context.l10n.zapApplying
|
|
||||||
: retryZap != null
|
|
||||||
? context.l10n.zapNeedsCheck
|
? context.l10n.zapNeedsCheck
|
||||||
: data.mine == null
|
: data.mine == null
|
||||||
? context.l10n.zapNoPlanet
|
? context.l10n.zapNoPlanet
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ class ZapController extends ChangeNotifier {
|
|||||||
({String fandomId, int count, String key})? _batch;
|
({String fandomId, int count, String key})? _batch;
|
||||||
bool get needsRetry => _batch != null && !sending;
|
bool get needsRetry => _batch != null && !sending;
|
||||||
|
|
||||||
|
/// 아직 확정되지 않은 연타 — 보내는 중인 묶음 + 다음 묶음. 표시 잔액은 이만큼 미리 뺀다.
|
||||||
|
int get queued => pending + (sending ? _batch?.count ?? 0 : 0);
|
||||||
|
|
||||||
|
/// 이어서 누른 횟수 — Figma `2024:20758` 「0.5초간 [+1] 표시(연속 가능)」: [+1] 이 떠 있는
|
||||||
|
/// [streakWindow] 안에 다시 누르면 이어지고, 넘기면 1 부터 다시 센다.
|
||||||
|
int streak = 0;
|
||||||
|
Timer? _streakTimer;
|
||||||
|
String? _pendingFandom;
|
||||||
|
static const streakWindow = Duration(milliseconds: 500);
|
||||||
|
|
||||||
/// 지금 결과가 ZAP 이 아니라 퀘스트 보상 확정의 행성 반응인가([react]).
|
/// 지금 결과가 ZAP 이 아니라 퀘스트 보상 확정의 행성 반응인가([react]).
|
||||||
bool questReaction = false;
|
bool questReaction = false;
|
||||||
|
|
||||||
@@ -41,14 +51,25 @@ class ZapController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 보내는 중에도 받는다 — 다음 묶음으로 쌓였다가 앞 묶음이 확정되면 보낸다. 실패한 묶음이
|
||||||
|
/// 있으면([needsRetry]) 같은 키로 다시 확인하기 전까지 받지 않는다.
|
||||||
void tap(String fandomId) {
|
void tap(String fandomId) {
|
||||||
if (_disposed || sending || _batch != null || fandomId.isEmpty) return;
|
if (_disposed || needsRetry || fandomId.isEmpty) return;
|
||||||
|
// 쌓인 연타는 한 행성 몫이다. 다른 행성 탭은 쌓인 묶음이 나간 뒤에 받는다.
|
||||||
|
if (pending > 0 && fandomId != _pendingFandom) return;
|
||||||
|
_pendingFandom = fandomId;
|
||||||
questReaction = false;
|
questReaction = false;
|
||||||
if (pending == 0) fxHaptic(FxIntensity.small);
|
if (pending == 0) fxHaptic(FxIntensity.small);
|
||||||
|
streak = _streakTimer?.isActive == true ? streak + 1 : 1;
|
||||||
|
_streakTimer?.cancel();
|
||||||
|
_streakTimer = Timer(streakWindow, () {});
|
||||||
pending++;
|
pending++;
|
||||||
touchSerial++;
|
touchSerial++;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_burst ??= Timer(const Duration(milliseconds: 450), () => _flush(fandomId));
|
_burst ??= Timer(const Duration(milliseconds: 450), () {
|
||||||
|
_burst = null;
|
||||||
|
_flush(_pendingFandom!);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void retry() {
|
void retry() {
|
||||||
@@ -56,6 +77,7 @@ class ZapController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _flush(String fandomId) async {
|
Future<void> _flush(String fandomId) async {
|
||||||
|
// 보내는 중이면 확정 뒤(finally)에 이어 보낸다.
|
||||||
if (_disposed || sending) return;
|
if (_disposed || sending) return;
|
||||||
final batch =
|
final batch =
|
||||||
_batch ??
|
_batch ??
|
||||||
@@ -84,6 +106,10 @@ class ZapController extends ChangeNotifier {
|
|||||||
_batch = null;
|
_batch = null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
// 실패하면 뒤에 쌓인 연타는 보내지 않는다 — 실패 묶음을 같은 키로 다시 확인하는 것이 먼저다.
|
||||||
|
pending = 0;
|
||||||
|
_burst?.cancel();
|
||||||
|
_burst = null;
|
||||||
onFailure(switch (error) {
|
onFailure(switch (error) {
|
||||||
BackendException(code: 'E-POP-01') => appL10n.zapFailNoPop,
|
BackendException(code: 'E-POP-01') => appL10n.zapFailNoPop,
|
||||||
BackendException(code: 'E-POP-02') => appL10n.zapFailLedger,
|
BackendException(code: 'E-POP-02') => appL10n.zapFailLedger,
|
||||||
@@ -94,6 +120,7 @@ class ZapController extends ChangeNotifier {
|
|||||||
if (!_disposed) {
|
if (!_disposed) {
|
||||||
sending = false;
|
sending = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
if (_batch == null && pending > 0 && _burst == null) _flush(_pendingFandom!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,6 +129,7 @@ class ZapController extends ChangeNotifier {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_burst?.cancel();
|
_burst?.cancel();
|
||||||
|
_streakTimer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import '../../../core/ui/planet_fx_scope.dart';
|
import '../../../core/ui/planet_fx_scope.dart';
|
||||||
import '../../galaxy/galaxy_data.dart';
|
import '../../galaxy/galaxy_data.dart';
|
||||||
import '../../galaxy/zap_controller.dart';
|
import '../../galaxy/zap_controller.dart';
|
||||||
@@ -225,9 +226,14 @@ class _HomeHeroState extends State<HomeHero> {
|
|||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: _TapShake(
|
child: _PlanetReaction(
|
||||||
onTap: widget.onTapPlanet,
|
onTap: widget.onTapPlanet,
|
||||||
child: LivePlanet25D(
|
zap: hasZap ? widget.zap : null,
|
||||||
|
planetSize: math.min(
|
||||||
|
planet,
|
||||||
|
box.maxHeight,
|
||||||
|
),
|
||||||
|
builder: (spinBoost) => LivePlanet25D(
|
||||||
onOpenHistory:
|
onOpenHistory:
|
||||||
widget.onOpenHistory,
|
widget.onOpenHistory,
|
||||||
key: _planetKey,
|
key: _planetKey,
|
||||||
@@ -243,6 +249,7 @@ class _HomeHeroState extends State<HomeHero> {
|
|||||||
box.maxHeight,
|
box.maxHeight,
|
||||||
),
|
),
|
||||||
glow: AppColors.neonViolet,
|
glow: AppColors.neonViolet,
|
||||||
|
spinBoost: spinBoost,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -755,9 +762,10 @@ class _Nebula extends StatelessWidget {
|
|||||||
label: context.l10n.heroNebula,
|
label: context.l10n.heroNebula,
|
||||||
// Figma `2013:15377` 「성운 터치시 흔들림」 — 조건 전에도 흔들림 · 가벼운 햅틱으로 반응하고,
|
// Figma `2013:15377` 「성운 터치시 흔들림」 — 조건 전에도 흔들림 · 가벼운 햅틱으로 반응하고,
|
||||||
// 탄생 조건을 채웠으면 흔들린 뒤 행성 생성으로 간다.
|
// 탄생 조건을 채웠으면 흔들린 뒤 행성 생성으로 간다.
|
||||||
child: _TapShake(
|
child: _PlanetReaction(
|
||||||
onTap: ready && onBirth != null ? onBirth : () {},
|
onTap: ready && onBirth != null ? onBirth : () {},
|
||||||
child: Image.asset(
|
planetSize: 212,
|
||||||
|
builder: (_) => Image.asset(
|
||||||
'assets/figma/nebula-home.png',
|
'assets/figma/nebula-home.png',
|
||||||
width: 212,
|
width: 212,
|
||||||
height: 212,
|
height: 212,
|
||||||
@@ -1086,16 +1094,13 @@ class HomeZap extends StatelessWidget {
|
|||||||
showLabel: false,
|
showLabel: false,
|
||||||
onItem: onItem,
|
onItem: onItem,
|
||||||
zapPop: data.zapPop,
|
zapPop: data.zapPop,
|
||||||
myPop: (zap.balance ?? data.myPop) - zap.pending * data.zapPop,
|
myPop: (zap.balance ?? data.myPop) - zap.queued * data.zapPop,
|
||||||
// Figma `516:88102` Zap_버프(아이템 배수 · 지속시간)는 별첨 B B-22 「배수 기능 제거」 ·
|
// Figma `516:88102` Zap_버프(아이템 배수 · 지속시간)는 별첨 B B-22 「배수 기능 제거」 ·
|
||||||
// 10/3 통합회신(C-124) 「사용 시 Drop Pool 발생 후 HERO TIME 발동」이 대체한다 —
|
// 10/3 통합회신(C-124) 「사용 시 Drop Pool 발생 후 HERO TIME 발동」이 대체한다 —
|
||||||
// 같은 팬덤의 아이템 사용으로 HERO TIME 이 열리면 ZAP 이 Drop POP 참여로 바뀐다.
|
// 같은 팬덤의 아이템 사용으로 HERO TIME 이 열리면 ZAP 이 Drop POP 참여로 바뀐다.
|
||||||
isHeroTime: hero,
|
isHeroTime: hero,
|
||||||
blockedReason: zap.sending
|
// 보내는 중에도 누를 수 있다 — 연타는 다음 묶음으로 쌓인다(Figma `2033:15818` 10회 이상 연타).
|
||||||
? context.l10n.zapApplying
|
blockedReason: zap.needsRetry ? context.l10n.zapNeedsCheck : null,
|
||||||
: zap.needsRetry
|
|
||||||
? context.l10n.zapNeedsCheck
|
|
||||||
: null,
|
|
||||||
onZap: () => hero ? onHeroParticipate!() : zap.tap(fandomId),
|
onZap: () => hero ? onHeroParticipate!() : zap.tap(fandomId),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1104,54 +1109,159 @@ class HomeZap extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 행성 터치 반응 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번」 · UX·FX v1.5 §3 Light Haptic.
|
/// 행성 반응 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번」 · `2033:15818` 행성 리액션 1.
|
||||||
/// 흔들림이 끝나면 [onTap](커뮤니티 이동)을 부른다. [onTap] 이 없으면 아무 반응도 하지 않는다.
|
///
|
||||||
class _TapShake extends StatefulWidget {
|
/// - 행성 터치: Light Haptic · 흔들림 1번이 끝나면 [onTap](커뮤니티 이동).
|
||||||
const _TapShake({required this.child, this.onTap});
|
/// - GIVE: 누를 때마다 흔들림 1번 · 행성 우하단 [+1] 0.5초(연속 가능). 이어서 10회 이상이면
|
||||||
|
/// 흔들림 x2 · 자전 1.5배([Planet25D.spinBoost]) · [+10]. 숫자는 누른 횟수이고 기여 결과는
|
||||||
|
/// 서버 확정 뒤 [ZapFeedback] 이 따로 띄운다(v1.5 §1.2).
|
||||||
|
class _PlanetReaction extends StatefulWidget {
|
||||||
|
const _PlanetReaction({
|
||||||
|
required this.builder,
|
||||||
|
required this.planetSize,
|
||||||
|
this.onTap,
|
||||||
|
this.zap,
|
||||||
|
});
|
||||||
|
|
||||||
final Widget child;
|
final Widget Function(bool spinBoost) builder;
|
||||||
|
final double planetSize;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final ZapController? zap;
|
||||||
|
|
||||||
|
/// 「10 회 이상 터치시」.
|
||||||
|
static const boostStreak = 10;
|
||||||
|
|
||||||
|
/// 흔들림 한 번 길이.
|
||||||
|
static const shakeOnce = Duration(milliseconds: 320);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_TapShake> createState() => _TapShakeState();
|
State<_PlanetReaction> createState() => _PlanetReactionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TapShakeState extends State<_TapShake>
|
class _PlanetReactionState extends State<_PlanetReaction>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late final _shake = AnimationController(
|
late final _shake = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 320),
|
duration: _PlanetReaction.shakeOnce,
|
||||||
);
|
);
|
||||||
|
int _shakes = 1;
|
||||||
|
int? _seenSerial;
|
||||||
|
int _streak = 0;
|
||||||
|
Timer? _countTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_seenSerial = widget.zap?.touchSerial;
|
||||||
|
widget.zap?.addListener(_onZap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_PlanetReaction old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (old.zap != widget.zap) {
|
||||||
|
old.zap?.removeListener(_onZap);
|
||||||
|
_seenSerial = widget.zap?.touchSerial;
|
||||||
|
widget.zap?.addListener(_onZap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
widget.zap?.removeListener(_onZap);
|
||||||
|
_countTimer?.cancel();
|
||||||
_shake.dispose();
|
_shake.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onZap() {
|
||||||
|
final zap = widget.zap!;
|
||||||
|
if (zap.touchSerial == _seenSerial) return;
|
||||||
|
_seenSerial = zap.touchSerial;
|
||||||
|
if (zap.questReaction) return;
|
||||||
|
setState(() => _streak = zap.streak);
|
||||||
|
_countTimer?.cancel();
|
||||||
|
_countTimer = Timer(ZapController.streakWindow, () {
|
||||||
|
if (mounted) setState(() => _streak = 0);
|
||||||
|
});
|
||||||
|
_play(_streak >= _PlanetReaction.boostStreak ? 2 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TickerFuture? _play(int shakes) {
|
||||||
|
// 동작 줄이기면 흔들지 않는다 — 숫자 표시와 이동은 그대로.
|
||||||
|
if (MediaQuery.disableAnimationsOf(context)) return null;
|
||||||
|
_shakes = shakes;
|
||||||
|
_shake.duration = _PlanetReaction.shakeOnce * shakes;
|
||||||
|
return _shake.forward(from: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _opening = false;
|
||||||
|
|
||||||
|
/// 흔들림 한 번을 보여 준 뒤 이동한다. 그사이 GIVE 가 흔들림을 다시 시작해도 이동은 그대로 간다.
|
||||||
Future<void> _tap() async {
|
Future<void> _tap() async {
|
||||||
final onTap = widget.onTap;
|
final onTap = widget.onTap;
|
||||||
if (onTap == null || _shake.isAnimating) return;
|
if (onTap == null || _opening) return;
|
||||||
|
_opening = true;
|
||||||
fxHaptic(FxIntensity.micro);
|
fxHaptic(FxIntensity.micro);
|
||||||
if (!MediaQuery.disableAnimationsOf(context)) await _shake.forward(from: 0);
|
if (_play(1) != null) await Future<void>.delayed(_PlanetReaction.shakeOnce);
|
||||||
|
_opening = false;
|
||||||
if (mounted) onTap();
|
if (mounted) onTap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => GestureDetector(
|
Widget build(BuildContext context) {
|
||||||
behavior: HitTestBehavior.translucent,
|
final boost = _streak >= _PlanetReaction.boostStreak;
|
||||||
onTap: widget.onTap == null ? null : _tap,
|
return GestureDetector(
|
||||||
child: AnimatedBuilder(
|
behavior: HitTestBehavior.translucent,
|
||||||
animation: _shake,
|
onTap: widget.onTap == null ? null : _tap,
|
||||||
// 한 번 좌우로 흔들리고 제자리 — 진폭 6 · 감쇠.
|
child: Stack(
|
||||||
builder: (context, child) => Transform.translate(
|
clipBehavior: Clip.none,
|
||||||
offset: Offset(
|
alignment: Alignment.center,
|
||||||
math.sin(_shake.value * math.pi * 4) * 6 * (1 - _shake.value),
|
children: [
|
||||||
0,
|
AnimatedBuilder(
|
||||||
),
|
animation: _shake,
|
||||||
child: child,
|
// 좌우로 흔들리고 제자리 — 진폭 6 · 감쇠. 한 번에 [_shakes] 왕복.
|
||||||
|
builder: (context, child) => Transform.translate(
|
||||||
|
key: const ValueKey('planet-give-shake'),
|
||||||
|
offset: Offset(
|
||||||
|
math.sin(_shake.value * math.pi * 4 * _shakes) *
|
||||||
|
6 *
|
||||||
|
(1 - _shake.value),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
child: widget.builder(boost),
|
||||||
|
),
|
||||||
|
if (_streak > 0)
|
||||||
|
// 행성 우측 하단 — 행성 그림 상자([planetSize]) 안 오른쪽 아래.
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: widget.planetSize,
|
||||||
|
child: Align(
|
||||||
|
alignment: const Alignment(0.85, 0.85),
|
||||||
|
child: Text(
|
||||||
|
boost ? '+10' : '+1',
|
||||||
|
key: const ValueKey('planet-give-count'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: AppFonts.body,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.onCosmic,
|
||||||
|
shadows: [
|
||||||
|
Shadow(color: AppColors.neonViolet, blurRadius: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: widget.child,
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('전송 중 성공을 보여주지 않고 전송 중 연타로 중복 차감하지 않는다', (tester) async {
|
testWidgets('전송 중 성공을 보여주지 않고, 전송 중 연타는 앞 묶음 확정 뒤 새 키의 다음 묶음으로 한 번만 보낸다', (tester) async {
|
||||||
final repository = _DelayedGalaxy();
|
final repository = _DelayedGalaxy();
|
||||||
await _show(tester, repository);
|
await _show(tester, repository);
|
||||||
await tester.tap(find.byType(ZapButton));
|
await tester.tap(find.byType(ZapButton));
|
||||||
@@ -215,8 +215,16 @@ void main() {
|
|||||||
const ZapResult(txId: 'confirmed', balanceAfter: 12530),
|
const ZapResult(txId: 'confirmed', balanceAfter: 12530),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
expect(repository.counts, [1, 1]);
|
||||||
|
expect(repository.keys[1], isNot(repository.keys[0]));
|
||||||
|
expect(find.textContaining('기여 완료'), findsNothing);
|
||||||
|
repository.replies.last.complete(
|
||||||
|
const ZapResult(txId: 'confirmed-2', balanceAfter: 12520),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
await tester.pump(const Duration(seconds: 1));
|
await tester.pump(const Duration(seconds: 1));
|
||||||
expect(find.textContaining('기여 완료'), findsOneWidget);
|
expect(find.textContaining('기여 완료'), findsOneWidget);
|
||||||
|
expect(repository.replies, hasLength(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('응답 유실 재확인은 같은 멱등키를 쓰고 실패에는 성공 FX가 없다', (tester) async {
|
testWidgets('응답 유실 재확인은 같은 멱등키를 쓰고 실패에는 성공 FX가 없다', (tester) async {
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/core/ui/planet_25d.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/widgets/zap_button.dart';
|
||||||
|
import 'package:fancake/features/galaxy/zap_controller.dart';
|
||||||
|
import 'package:fancake/features/home/home_data.dart';
|
||||||
|
import 'package:fancake/features/home/home_screen.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_detail_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'eclipse_stubs.dart';
|
||||||
|
|
||||||
|
/// 서버 응답을 시험이 직접 풀어 주는 기여 저장소.
|
||||||
|
class _GatedGalaxy extends SampleGalaxyRepository {
|
||||||
|
_GatedGalaxy();
|
||||||
|
final counts = <int>[];
|
||||||
|
final fandoms = <String>[];
|
||||||
|
final gates = <Completer<ZapResult>>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ZapResult> zap({
|
||||||
|
required String fandomId,
|
||||||
|
required int count,
|
||||||
|
required String clientSeq,
|
||||||
|
}) {
|
||||||
|
counts.add(count);
|
||||||
|
fandoms.add(fandomId);
|
||||||
|
gates.add(Completer());
|
||||||
|
return gates.last.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 바로 확정하는 기여 저장소.
|
||||||
|
class _OkGalaxy extends SampleGalaxyRepository {
|
||||||
|
const _OkGalaxy();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ZapResult> zap({
|
||||||
|
required String fandomId,
|
||||||
|
required int count,
|
||||||
|
required String clientSeq,
|
||||||
|
}) async => ZapResult(txId: clientSeq, balanceAfter: 100000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Figma `2033:15818` 행성 리액션 1 · `2024:20758` 행성 2단계 — GIVE 1회 흔들림 1번 · [+1] 0.5초(연속 가능),
|
||||||
|
/// 10회 이상 흔들림 x2 · 자전 1.5배 · [+10].
|
||||||
|
void main() {
|
||||||
|
group('ZapController 연타', () {
|
||||||
|
testWidgets('전송 중에 누른 GIVE 도 버리지 않고 다음 묶음으로 보낸다', (tester) async {
|
||||||
|
final repo = _GatedGalaxy();
|
||||||
|
final zap = ZapController(repo, onFailure: (_) {});
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
zap.tap('f1');
|
||||||
|
}
|
||||||
|
await tester.pump(const Duration(milliseconds: 450));
|
||||||
|
expect(repo.counts, [3]);
|
||||||
|
expect(zap.sending, isTrue);
|
||||||
|
|
||||||
|
zap.tap('f1');
|
||||||
|
zap.tap('f1');
|
||||||
|
expect(zap.pending, 2);
|
||||||
|
expect(zap.queued, 5);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(milliseconds: 450));
|
||||||
|
expect(repo.counts, [3], reason: '앞 묶음이 확정되기 전에는 다음 묶음을 보내지 않는다');
|
||||||
|
|
||||||
|
repo.gates.first.complete(const ZapResult(txId: 'a', balanceAfter: 10));
|
||||||
|
await tester.pump();
|
||||||
|
expect(repo.counts, [3, 2]);
|
||||||
|
repo.gates.last.complete(const ZapResult(txId: 'b', balanceAfter: 8));
|
||||||
|
await tester.pump();
|
||||||
|
expect(zap.queued, 0);
|
||||||
|
zap.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('보내는 중에 다른 행성으로 누른 연타가 앞 행성으로 가지 않는다', (tester) async {
|
||||||
|
final repo = _GatedGalaxy();
|
||||||
|
final zap = ZapController(repo, onFailure: (_) {});
|
||||||
|
zap.tap('a');
|
||||||
|
await tester.pump(const Duration(milliseconds: 450));
|
||||||
|
zap.tap('b');
|
||||||
|
zap.tap('a');
|
||||||
|
expect(zap.pending, 1, reason: '쌓인 b 묶음에 a 를 섞지 않는다');
|
||||||
|
await tester.pump(const Duration(milliseconds: 450));
|
||||||
|
repo.gates.first.complete(const ZapResult(txId: 'a1', balanceAfter: 10));
|
||||||
|
await tester.pump();
|
||||||
|
expect(repo.fandoms, ['a', 'b']);
|
||||||
|
repo.gates.last.complete(const ZapResult(txId: 'b1', balanceAfter: 9));
|
||||||
|
await tester.pump();
|
||||||
|
zap.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('연속 횟수는 [+1] 이 떠 있는 0.5초 안에 다시 누를 때만 이어진다', (tester) async {
|
||||||
|
final zap = ZapController(_GatedGalaxy(), onFailure: (_) {});
|
||||||
|
zap.tap('f1');
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
zap.tap('f1');
|
||||||
|
expect(zap.streak, 2);
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
zap.tap('f1');
|
||||||
|
expect(zap.streak, 1);
|
||||||
|
zap.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('HOME 행성 GIVE 반응', () {
|
||||||
|
Future<void> pumpHome(WidgetTester tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1125, 2436);
|
||||||
|
tester.view.devicePixelRatio = 3;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: HomeScreen(
|
||||||
|
repository: const SampleHomeRepository(),
|
||||||
|
questRepository: const SampleQuestRepository(),
|
||||||
|
eclipse: const NoEclipseRepository(),
|
||||||
|
galaxyRepository: const _OkGalaxy(),
|
||||||
|
planetRepository: samplePlanetRepository,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
double shake(WidgetTester tester) => tester
|
||||||
|
.widget<Transform>(find.byKey(const ValueKey('planet-give-shake')))
|
||||||
|
.transform
|
||||||
|
.getTranslation()
|
||||||
|
.x;
|
||||||
|
|
||||||
|
testWidgets('GIVE 1회: 행성이 흔들리고 행성 우하단에 [+1] 이 0.5초 뜬다', (tester) async {
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
expect(shake(tester), isNot(0));
|
||||||
|
final label = find.byKey(const ValueKey('planet-give-count'));
|
||||||
|
expect(tester.widget<Text>(label).data, '+1');
|
||||||
|
final planet = tester.getRect(find.byType(Planet25D).first);
|
||||||
|
final at = tester.getCenter(label);
|
||||||
|
expect(at.dx, greaterThan(planet.center.dx));
|
||||||
|
expect(at.dy, greaterThan(planet.center.dy));
|
||||||
|
await tester.pump(const Duration(milliseconds: 450));
|
||||||
|
expect(label, findsNothing);
|
||||||
|
await tester.pump(const Duration(seconds: 3));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('행성 터치 직후 GIVE 가 흔들림을 다시 시작해도 커뮤니티 이동은 간다', (tester) async {
|
||||||
|
await pumpHome(tester);
|
||||||
|
await tester.tap(find.byType(Planet25D).first, warnIfMissed: false);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(find.byType(PlanetDetailScreen), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(seconds: 3));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('GIVE 10회 이상: [+10] · 자전 1.5배(Planet25D.spinBoost)', (tester) async {
|
||||||
|
await pumpHome(tester);
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
}
|
||||||
|
expect(find.text('+10'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.widget<Planet25D>(find.byType(Planet25D).first).spinBoost,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
expect(
|
||||||
|
tester.widget<Planet25D>(find.byType(Planet25D).first).spinBoost,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(seconds: 3));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user