UX·FX v1.5 — 햅틱 단계 · 라이브 띠 · HERO 전광판 · Push 우선순위 · Shield · 이클립스 성공·실패 연출
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -14,13 +14,57 @@ import '../../../core/i18n/app_locale.dart';
|
||||
/// 별첨 B 「Global Ticker」로 Eclipse 외의 종류가 뒤에 들어오므로 종류마다 한 줄씩
|
||||
/// 늘려 간다. 모르는 종류는 공지로 읽어 티커가 멈추지 않게 한다.
|
||||
String liveEventMessage(LiveEvent event) => switch (event.kind) {
|
||||
LiveEventKind.eclipseDefense =>
|
||||
appL10n.liveEclipseDefense(event.nickname),
|
||||
LiveEventKind.artistHotTime =>
|
||||
appL10n.liveHotTime(event.nickname),
|
||||
LiveEventKind.heroGift =>
|
||||
appL10n.liveHeroGift(event.nickname),
|
||||
LiveEventKind.campaign =>
|
||||
appL10n.liveCampaign(event.nickname),
|
||||
LiveEventKind.eclipseDefense => appL10n.liveEclipseDefense(event.nickname),
|
||||
LiveEventKind.artistHotTime => appL10n.liveHotTime(event.nickname),
|
||||
LiveEventKind.heroGift => appL10n.liveHeroGift(event.nickname),
|
||||
LiveEventKind.campaign => appL10n.liveCampaign(event.nickname),
|
||||
LiveEventKind.notice => '[LIVE] ${event.nickname}',
|
||||
};
|
||||
|
||||
/// 홈 라이브 띠(전광판)에 올릴 한 줄. [hero] 면 띠가 HERO TIME 참여 진입이 된다.
|
||||
typedef TickerLine = ({String text, bool hero});
|
||||
|
||||
/// 띠 한 줄 고르기 — 원장 C-78 순서(위기 → 성취)대로 핵심 이벤트가 먼저 오른다.
|
||||
///
|
||||
/// 1. 이클립스 발생 소식 — 그 뒤 이벤트(방어 참여 등)가 아직 없을 때.
|
||||
/// 2. 방어 중 집약 — UX·FX v1.5 §1.1 「개인 이벤트를 연속 티커로 쌓기보다 집단 상태로 집약」 ·
|
||||
/// §5.1 「참여자 수 변화 중심」 · 기능명세 §2.10~§2.12 「지금 N 명이 함께 방어 중」. N 은 서버가
|
||||
/// 인정한 고유 참여자 수([EclipseState.defense])이며 2명 이상일 때 집약한다(「다수」 해석 — 해석 확인 항목).
|
||||
/// 3. HERO TIME — v1.5 §6 전광판 「공개 닉네임 + 대상 팬덤 + 진행 중 CTA」.
|
||||
/// 4. 최신 이벤트 한 건, 없으면 [fallback].
|
||||
TickerLine? tickerLine({
|
||||
required EclipseState? eclipse,
|
||||
required bool heroTime,
|
||||
required List<String> heroNicknames,
|
||||
required List<LiveEvent> events,
|
||||
required String fandomName,
|
||||
required DateTime now,
|
||||
String? fallback,
|
||||
}) {
|
||||
final event = events.isEmpty ? null : events.first;
|
||||
if (eclipse != null && eclipse.result == null) {
|
||||
final started = eclipse.startAt;
|
||||
if (event == null || started == null || event.at.isBefore(started)) {
|
||||
return (
|
||||
text: appL10n.eclipseLiveOccurred(eclipse.fandomName),
|
||||
hero: false,
|
||||
);
|
||||
}
|
||||
if (eclipse.canShake(now) && eclipse.defense >= 2) {
|
||||
return (
|
||||
text: appL10n.liveEclipseDefending('${eclipse.defense}'),
|
||||
hero: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (heroTime) {
|
||||
return (
|
||||
text: heroNicknames.isEmpty
|
||||
? appL10n.liveHeroTime(fandomName)
|
||||
: appL10n.liveHeroTimeBy(fandomName, heroNicknames.join(' · ')),
|
||||
hero: true,
|
||||
);
|
||||
}
|
||||
if (event != null) return (text: liveEventMessage(event), hero: false);
|
||||
return fallback == null ? null : (text: fallback, hero: false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user