UX·FX v1.5 — 햅틱 단계 · 라이브 띠 · HERO 전광판 · Push 우선순위 · Shield · 이클립스 성공·실패 연출

This commit is contained in:
Sn-Kinos
2026-10-06 20:12:44 +09:00
parent a04626b003
commit 62b9637db9
30 changed files with 851 additions and 118 deletions
@@ -15,6 +15,7 @@ import '../quest/quest_evidence.dart' show ShakeProbe, deviceShakes;
import 'eclipse_data.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/ui/sheet_route.dart';
import '../../core/ui/fx_intensity.dart';
/// 「흔들어 방어」 — 팬덤 공동 Shake 참여 화면.
///
@@ -67,6 +68,9 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
/// 서버가 받아들인 내 누적 참여량.
int _mine = 0;
/// 받아들인 흔들기 일련번호 — 바뀔 때마다 Shield Pulse 한 번(v1.5 §5 「유효 Shake 마다 Shield Pulse / 원형 파동」).
int _pulse = 0;
/// 화면에 보이는 내 횟수 — 서버 확정값에 아직 보내지 않았거나 보내는 중인 흔들기를 더한다.
/// 흔드는 동안 서버 왕복(약 0.7초 묶음)을 기다리면 숫자가 늦게 올라 「얼마나 잘 흔들고
/// 있는지」 알 수 없었다. 확정값이 오면 그것으로 맞춰진다.
@@ -127,10 +131,13 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
return;
final qualify = _current?.qualifyShakes ?? 0;
final before = _shown;
setState(() => _pending += 1);
setState(() {
_pending += 1;
_pulse += 1;
});
// 화면을 보지 않고 흔들어도 알 수 있게 — 한 번 셀 때마다 짧게, 인정 기준을 넘는 순간 길게.
if (qualify > 0 && before < qualify && _shown >= qualify) {
HapticFeedback.heavyImpact();
fxHaptic(FxIntensity.large);
} else {
HapticFeedback.selectionClick();
}
@@ -444,6 +451,7 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
child: _DefendPlanet(
remaining: remaining,
pending: _pending,
pulse: _pulse,
image: _imageOf(widget.nebula),
),
),
@@ -704,8 +712,12 @@ class _DefendPlanet extends StatelessWidget {
required this.remaining,
required this.pending,
required this.image,
this.pulse = 0,
});
/// 흔들기 일련번호. 0 이면 아직 파동이 없다.
final int pulse;
final AssetImage image;
/// 남은 시간 비율 1 → 0.
@@ -755,6 +767,21 @@ class _DefendPlanet extends StatelessWidget {
CustomPaint(painter: _RemainingRing(remaining)),
),
_at(67.5, 300, 240, 240, Image(image: image)),
// Shield Pulse — 흔들기마다 행성 가장자리에서 원형 파동이 한 번 퍼진다. 동작 줄이기면 그리지 않는다.
if (pulse > 0 && !MediaQuery.disableAnimationsOf(context))
_at(
48,
280,
280,
280,
TweenAnimationBuilder<double>(
key: ValueKey('shield-pulse-$pulse'),
tween: Tween(begin: 0, end: 1),
duration: shieldPulseDuration,
builder: (context, t, _) =>
CustomPaint(painter: ShieldPulsePainter(t)),
),
),
// 좌상단 운석(`Group 573`)은 내보낸 자산이 없어 `Group 575` 를 뒤집어 쓴다.
_meteor(
38,
@@ -923,3 +950,31 @@ class _OutcomeDisc extends CustomPainter {
bool shouldRepaint(_OutcomeDisc old) =>
old.color != color || old.check != check;
}
/// Shield Pulse 한 번의 길이. 원문에 수치가 없어 ZAP 에너지 이동(380ms)과 같은 짧은 길이를 쓴다(해석 확인 항목).
const shieldPulseDuration = Duration(milliseconds: 380);
/// 행성 가장자리(반경 240/280)에서 바깥으로 퍼지며 사라지는 원형 파동. [t] 0 → 1.
class ShieldPulsePainter extends CustomPainter {
const ShieldPulsePainter(this.t);
final double t;
@override
void paint(Canvas canvas, Size size) {
if (t >= 1) return;
final r = size.shortestSide / 2;
canvas.drawCircle(
size.center(Offset.zero),
r * (240 / 280 + t * (1 - 240 / 280 + 0.1)),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3 * (1 - t) + 1
..color = AppColors.neonEmerald.withValues(alpha: 0.8 * (1 - t)),
);
}
@override
bool shouldRepaint(ShieldPulsePainter old) => old.t != t;
}