GIVE 연타에 행성이 흔들리고 우하단에 [+1] · 10회 이상이면 [+10]과 자전 1.5배, 전송 중 연타는 다음 묶음으로 쌓임

This commit is contained in:
Sn-Kinos
2026-10-22 01:25:27 +09:00
parent 3f59c5070e
commit a2739a37f1
7 changed files with 389 additions and 45 deletions
+145 -35
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import '../../../core/ui/planet_fx_scope.dart';
import '../../galaxy/galaxy_data.dart';
import '../../galaxy/zap_controller.dart';
@@ -225,9 +226,14 @@ class _HomeHeroState extends State<HomeHero> {
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: _TapShake(
child: _PlanetReaction(
onTap: widget.onTapPlanet,
child: LivePlanet25D(
zap: hasZap ? widget.zap : null,
planetSize: math.min(
planet,
box.maxHeight,
),
builder: (spinBoost) => LivePlanet25D(
onOpenHistory:
widget.onOpenHistory,
key: _planetKey,
@@ -243,6 +249,7 @@ class _HomeHeroState extends State<HomeHero> {
box.maxHeight,
),
glow: AppColors.neonViolet,
spinBoost: spinBoost,
),
),
),
@@ -755,9 +762,10 @@ class _Nebula extends StatelessWidget {
label: context.l10n.heroNebula,
// Figma `2013:15377` 「성운 터치시 흔들림」 — 조건 전에도 흔들림 · 가벼운 햅틱으로 반응하고,
// 탄생 조건을 채웠으면 흔들린 뒤 행성 생성으로 간다.
child: _TapShake(
child: _PlanetReaction(
onTap: ready && onBirth != null ? onBirth : () {},
child: Image.asset(
planetSize: 212,
builder: (_) => Image.asset(
'assets/figma/nebula-home.png',
width: 212,
height: 212,
@@ -1086,16 +1094,13 @@ class HomeZap extends StatelessWidget {
showLabel: false,
onItem: onItem,
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 「배수 기능 제거」 ·
// 10/3 통합회신(C-124) 「사용 시 Drop Pool 발생 후 HERO TIME 발동」이 대체한다 —
// 같은 팬덤의 아이템 사용으로 HERO TIME 이 열리면 ZAP 이 Drop POP 참여로 바뀐다.
isHeroTime: hero,
blockedReason: zap.sending
? context.l10n.zapApplying
: zap.needsRetry
? context.l10n.zapNeedsCheck
: null,
// 보내는 중에도 누를 수 있다 — 연타는 다음 묶음으로 쌓인다(Figma `2033:15818` 10회 이상 연타).
blockedReason: zap.needsRetry ? context.l10n.zapNeedsCheck : null,
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.
/// 흔들림이 끝나면 [onTap](커뮤니티 이동)을 부른다. [onTap] 이 없으면 아무 반응도 하지 않는다.
class _TapShake extends StatefulWidget {
const _TapShake({required this.child, this.onTap});
/// 행성 반응 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번」 · `2033:15818` 행성 리액션 1.
///
/// - 행성 터치: Light Haptic · 흔들림 1번이 끝나면 [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 ZapController? zap;
/// 「10 회 이상 터치시」.
static const boostStreak = 10;
/// 흔들림 한 번 길이.
static const shakeOnce = Duration(milliseconds: 320);
@override
State<_TapShake> createState() => _TapShakeState();
State<_PlanetReaction> createState() => _PlanetReactionState();
}
class _TapShakeState extends State<_TapShake>
class _PlanetReactionState extends State<_PlanetReaction>
with SingleTickerProviderStateMixin {
late final _shake = AnimationController(
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
void dispose() {
widget.zap?.removeListener(_onZap);
_countTimer?.cancel();
_shake.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 {
final onTap = widget.onTap;
if (onTap == null || _shake.isAnimating) return;
if (onTap == null || _opening) return;
_opening = true;
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();
}
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: widget.onTap == null ? null : _tap,
child: AnimatedBuilder(
animation: _shake,
// 한 번 좌우로 흔들리고 제자리 — 진폭 6 · 감쇠.
builder: (context, child) => Transform.translate(
offset: Offset(
math.sin(_shake.value * math.pi * 4) * 6 * (1 - _shake.value),
0,
),
child: child,
Widget build(BuildContext context) {
final boost = _streak >= _PlanetReaction.boostStreak;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: widget.onTap == null ? null : _tap,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
AnimatedBuilder(
animation: _shake,
// 좌우로 흔들리고 제자리 — 진폭 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,
),
);
);
}
}