이클립스 방어 횟수 즉시 표시·진동 · Music Shake 영상 재생 중 흔들기 판정 · 결과 공유

This commit is contained in:
Sn-Kinos
2026-10-05 21:39:22 +09:00
parent 65669b969e
commit 17c658b272
30 changed files with 1337 additions and 144 deletions
@@ -44,9 +44,6 @@ class FirestoreQuestRepository implements QuestRepository {
/// 마지막 Music Shake 한 판의 판정표. 다 끝난 판만 서버에 보낸다.
ShakeJudge? _shakeRun;
/// 곡 BPM 이 문서에 없을 때 — 🟡 운영 Quest 는 `quests.bpm` 을 채운다.
static const defaultBpm = 100.0;
/// 오늘 걸은 수를 내는 기준선. 하루 단위라 앱을 껐다 켜도 이어진다.
final WalkBaseline _baseline;
@@ -166,9 +163,10 @@ class FirestoreQuestRepository implements QuestRepository {
final steps = type == QuestType.steps;
final goal = steps
? tiers[count.clamp(0, tiers.length - 1)]
// Music Shake 의 목표는 곡의 구간 수다 — 진행도가 「n / 구간 수」로 찬다.
// Music Shake 의 목표 줄은 Figma 「목표BPM 350」(`480:14466` · `480:14506`) — 곡 BPM 이다.
// 없으면 0(화면은 「-」). 🟡 「목표BPM」의 의미는 회신 대기(Q-MS-01).
: type == QuestType.musicShake
? quest.intOr('totalSegments', dailyLimit)
? quest.intOr('bpm')
: dailyLimit;
return QuestCard(
@@ -447,7 +445,6 @@ class FirestoreQuestRepository implements QuestRepository {
@override
Stream<int> progressStream(QuestCard card) {
if (card.type == QuestType.musicShake) return _shakeProgress(card);
if (card.type != QuestType.steps) return const Stream<int>.empty();
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
@@ -519,49 +516,53 @@ class FirestoreQuestRepository implements QuestRepository {
});
}
/// 곡 박마다 한 구간 — 시작부터 박 수를 세어 내고, 마지막 구간 판정 창이 닫히면 구간 수를 낸다.
/// 흔들기는 그동안만 모은다.
Stream<int> _shakeProgress(QuestCard card) async* {
_shakeRun = null;
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
@override
Future<MusicShakeSetup> musicShakeSetup(String questId) async {
final quest = (await _backend.db.doc('quests/$questId').get()).data() ??
const <String, dynamic>{};
final link = quest['link'] is Map
? Map<String, dynamic>.from(quest['link'] as Map)
: const <String, dynamic>{};
final videoId = youtubeVideoId(link.strOr('url'));
final bpm = quest['bpm'];
final segments = quest.intOr('totalSegments');
final bpm = quest['bpm'] is num && (quest['bpm'] as num) > 0
? (quest['bpm'] as num).toDouble()
: defaultBpm;
if (segments <= 0) {
// 영상 · BPM · 구간 수 중 하나라도 없으면 판을 열지 않는다 — 기본값을 지어내지 않는다.
if (videoId == null || bpm is! num || bpm <= 0 || segments <= 0) {
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
}
final run = ShakeJudge(bpm: bpm, segments: segments, start: DateTime.now());
final sub = _shakes().listen(run.add, onError: (_) {});
try {
for (var i = 1; i < segments; i++) {
await Future<void>.delayed(run.beat);
yield i;
}
await Future<void>.delayed(run.end.difference(DateTime.now()));
_shakeRun = run;
yield segments;
} finally {
await sub.cancel();
}
return MusicShakeSetup(
videoId: videoId,
title: link.strOr('title', _title(quest)),
bpm: bpm.toDouble(),
segments: segments,
windows: ShakeWindows.from(await _policy()),
);
}
@override
Stream<DateTime> shakeEvents() => _shakes();
@override
void recordShake(ShakeJudge run) => _shakeRun = run;
Future<Map<String, dynamic>> _verifyShake(QuestCard card) async {
final run = _shakeRun;
if (run == null) throw BackendException('E-QST-01', appL10n.questNoShake);
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
const <String, dynamic>{};
final ids = quest['trackIds'];
// 곡 ID 는 서버 대조용이다 — 화면에 띄우지 않는다.
final trackId = ids is List && ids.isNotEmpty ? '${ids.first}' : quest.strOr('trackId');
final link = quest['link'] is Map ? Map<String, dynamic>.from(quest['link'] as Map) : null;
// 곡 ID 는 서버 대조용이다 — 화면에 띄우지 않는다. 카탈로그가 없으면 영상 ID 다.
final trackId = ids is List && ids.isNotEmpty
? '${ids.first}'
: quest.strOr('trackId', youtubeVideoId(link?.strOr('url') ?? '') ?? '');
_shakeRun = null;
return _backend.call('fnQuestVerifyMusicShake', {
'questId': card.id,
'trackId': trackId,
'totalSegments': run.segments,
'judgement': run.judgement(),
'occurredAt': run.end.toUtc().toIso8601String(),
'occurredAt': DateTime.now().toUtc().toIso8601String(),
...await _fandomField(),
});
}