Music Shake 첫 박 시각 · 카운트다운 · 옛 일시 정지 판 정리
This commit is contained in:
@@ -178,6 +178,8 @@ export const fnQuestProgress = onCall(async (req) => {
|
|||||||
total: count(data, 'total', 1_000_000),
|
total: count(data, 'total', 1_000_000),
|
||||||
positionMs: count(data, 'positionMs', 86_400_000),
|
positionMs: count(data, 'positionMs', 86_400_000),
|
||||||
...(hits !== undefined ? { hits } : {}),
|
...(hits !== undefined ? { hits } : {}),
|
||||||
|
// 판을 시작할 때의 첫 박 — 운영값이 바뀌면 앱이 옛 일시 정지를 버리고 새 판으로 시작한다.
|
||||||
|
...(typeof data.firstBeatMs === 'number' ? { firstBeatMs: count(data, 'firstBeatMs', 86_400_000) } : {}),
|
||||||
...(tally ? { judgement: tally } : {}),
|
...(tally ? { judgement: tally } : {}),
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -119,6 +119,13 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
|
|||||||
if (typeof questSegments === 'number' && questSegments !== totalSegments) {
|
if (typeof questSegments === 'number' && questSegments !== totalSegments) {
|
||||||
fail(ERR.questAbnormal, '곡의 구간 수가 퀘스트 정의와 다릅니다.');
|
fail(ERR.questAbnormal, '곡의 구간 수가 퀘스트 정의와 다릅니다.');
|
||||||
}
|
}
|
||||||
|
// 첫 박 시각(영상 시각 기준 박 위치 `firstBeatMs + i·60/bpm`)도 같은 기준이어야 한다.
|
||||||
|
const questFirstBeat = quest.get('firstBeatMs');
|
||||||
|
const firstBeatMs = typeof questFirstBeat === 'number' ? questFirstBeat : 0;
|
||||||
|
const sentFirstBeat = req.data?.firstBeatMs;
|
||||||
|
if (sentFirstBeat !== undefined && sentFirstBeat !== firstBeatMs) {
|
||||||
|
fail(ERR.questAbnormal, '첫 박 시각이 퀘스트 정의와 다릅니다.');
|
||||||
|
}
|
||||||
const limit = await dailyLimitOf(questId);
|
const limit = await dailyLimitOf(questId);
|
||||||
const state = await progress(uid, questId, key);
|
const state = await progress(uid, questId, key);
|
||||||
if (state.count >= limit) {
|
if (state.count >= limit) {
|
||||||
@@ -134,7 +141,7 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
|
|||||||
type: 'musicShake',
|
type: 'musicShake',
|
||||||
fandomId: optionalStr(req.data, 'fandomId') ?? '',
|
fandomId: optionalStr(req.data, 'fandomId') ?? '',
|
||||||
value: judgement.perfect,
|
value: judgement.perfect,
|
||||||
evidence: { musicShake: { trackId, totalSegments, judgements: judgement } },
|
evidence: { musicShake: { trackId, totalSegments, firstBeatMs, judgements: judgement } },
|
||||||
questId,
|
questId,
|
||||||
verified: grade !== 'F',
|
verified: grade !== 'F',
|
||||||
rejectReason: grade === 'F' ? ERR.questIncomplete : null,
|
rejectReason: grade === 'F' ? ERR.questIncomplete : null,
|
||||||
|
|||||||
@@ -321,10 +321,10 @@ describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리
|
|||||||
|
|
||||||
it('Music Shake: 곡은 퀘스트 카탈로그와 대조하고 하루 한도는 곡이 아니라 퀘스트 단위다', async () => {
|
it('Music Shake: 곡은 퀘스트 카탈로그와 대조하고 하루 한도는 곡이 아니라 퀘스트 단위다', async () => {
|
||||||
await db().doc('quests/music-e2e').set({
|
await db().doc('quests/music-e2e').set({
|
||||||
type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 1,
|
type: 'musicShake', trackId: 't1', totalSegments: 10, firstBeatMs: 17_000, dailyLimit: 1,
|
||||||
});
|
});
|
||||||
const shake = (trackId: string, extra: Record<string, unknown> = {}) => call(fnQuestVerifyMusicShake, {
|
const shake = (trackId: string, extra: Record<string, unknown> = {}) => call(fnQuestVerifyMusicShake, {
|
||||||
questId: 'music-e2e', trackId, totalSegments: 10,
|
questId: 'music-e2e', trackId, totalSegments: 10, firstBeatMs: 17_000,
|
||||||
judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 }, ...extra,
|
judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 }, ...extra,
|
||||||
});
|
});
|
||||||
expect(await codeOf(() => shake('t1', { questId: 'no-such-music' }))).toBe('E-QST-01');
|
expect(await codeOf(() => shake('t1', { questId: 'no-such-music' }))).toBe('E-QST-01');
|
||||||
@@ -332,11 +332,15 @@ describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리
|
|||||||
expect(await codeOf(() => shake('t1', {
|
expect(await codeOf(() => shake('t1', {
|
||||||
totalSegments: 1, judgement: { perfect: 1, good: 0, miss: 0, maxCombo: 1 },
|
totalSegments: 1, judgement: { perfect: 1, good: 0, miss: 0, maxCombo: 1 },
|
||||||
}))).toBe('E-QST-02');
|
}))).toBe('E-QST-02');
|
||||||
|
// 첫 박 시각이 퀘스트 정의와 다르면 판정표를 받지 않는다.
|
||||||
|
expect(await codeOf(() => shake('t1', { firstBeatMs: 0 }))).toBe('E-QST-02');
|
||||||
// 동시 요청도 하루 1회. 곡 ID 를 바꿔도 다시 열리지 않는다.
|
// 동시 요청도 하루 1회. 곡 ID 를 바꿔도 다시 열리지 않는다.
|
||||||
await Promise.allSettled([shake('t1'), shake('t1')]);
|
await Promise.allSettled([shake('t1'), shake('t1')]);
|
||||||
expect(await codeOf(() => shake('t1'))).toBe('E-POP-03');
|
expect(await codeOf(() => shake('t1'))).toBe('E-POP-03');
|
||||||
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
|
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
|
||||||
expect(ledger.size).toBe(1);
|
expect(ledger.size).toBe(1);
|
||||||
|
const logs = await db().collection(`users/${UID}/activityLogs`).where('questId', '==', 'music-e2e').get();
|
||||||
|
expect(logs.docs[0]!.get('evidence.musicShake.firstBeatMs')).toBe(17_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 오늘의 출석으로 기본 보상 원장 행을 만든다 — 광고 증분의 기준 행이다. */
|
/** 오늘의 출석으로 기본 보상 원장 행을 만든다 — 광고 증분의 기준 행이다. */
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ class FirestoreHomeRepository implements HomeRepository {
|
|||||||
: FirestoreQuestRepository.pausedSessionOf(
|
: FirestoreQuestRepository.pausedSessionOf(
|
||||||
QuestType.parse(quest.strOr('type')),
|
QuestType.parse(quest.strOr('type')),
|
||||||
progress,
|
progress,
|
||||||
|
quest,
|
||||||
),
|
),
|
||||||
rewardPop:
|
rewardPop:
|
||||||
openTimeAttack(quest)?.rewardPop ??
|
openTimeAttack(quest)?.rewardPop ??
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class MusicShakePlayScreen extends StatefulWidget {
|
|||||||
required this.shakes,
|
required this.shakes,
|
||||||
this.player,
|
this.player,
|
||||||
this.judge,
|
this.judge,
|
||||||
|
this.countdown = const Duration(seconds: 3),
|
||||||
});
|
});
|
||||||
|
|
||||||
final MusicShakeSetup setup;
|
final MusicShakeSetup setup;
|
||||||
@@ -42,6 +43,10 @@ class MusicShakePlayScreen extends StatefulWidget {
|
|||||||
final ShakeJudge? judge;
|
final ShakeJudge? judge;
|
||||||
final Stream<DateTime> shakes;
|
final Stream<DateTime> shakes;
|
||||||
|
|
||||||
|
/// 재생을 시작(이어하기 포함)하면 영상을 멈춰 두고 세는 3·2·1 — 이클립스 오버레이 카운트다운
|
||||||
|
/// (사본 `373:444976`)과 같다. Figma 에 Music Shake 카운트다운 화면은 없다(대표 지시 · 확인 통보).
|
||||||
|
final Duration countdown;
|
||||||
|
|
||||||
/// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다.
|
/// 영상 플레이어. 기본은 [YoutubeEmbed]. 테스트는 상태를 직접 넣는 가짜를 준다.
|
||||||
final Widget Function(ValueChanged<ShakePlayerEvent> onEvent)? player;
|
final Widget Function(ValueChanged<ShakePlayerEvent> onEvent)? player;
|
||||||
|
|
||||||
@@ -55,10 +60,16 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
|||||||
ShakeJudge(
|
ShakeJudge(
|
||||||
bpm: widget.setup.bpm,
|
bpm: widget.setup.bpm,
|
||||||
segments: widget.setup.segments,
|
segments: widget.setup.segments,
|
||||||
|
firstBeat: widget.setup.firstBeat,
|
||||||
windows: widget.setup.windows,
|
windows: widget.setup.windows,
|
||||||
);
|
);
|
||||||
StreamSubscription<DateTime>? _sub;
|
StreamSubscription<DateTime>? _sub;
|
||||||
Timer? _tick;
|
Timer? _tick;
|
||||||
|
final _control = ShakePlayerControl();
|
||||||
|
|
||||||
|
/// 카운트다운 — 한 번 세었으면 true, 세는 중이면 [_countEnd] 가 있다.
|
||||||
|
bool _counted = false;
|
||||||
|
DateTime? _countEnd;
|
||||||
|
|
||||||
/// 영상 시계 — 마지막으로 받은 플레이어 시각과 그때의 단말 시각.
|
/// 영상 시계 — 마지막으로 받은 플레이어 시각과 그때의 단말 시각.
|
||||||
bool _playing = false;
|
bool _playing = false;
|
||||||
@@ -89,6 +100,17 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
|||||||
_playing ? _anchorMedia + wall.difference(_anchorWall) : null;
|
_playing ? _anchorMedia + wall.difference(_anchorWall) : null;
|
||||||
|
|
||||||
void _onEvent(ShakePlayerEvent e) {
|
void _onEvent(ShakePlayerEvent e) {
|
||||||
|
if (e.playing && !_counted && widget.countdown > Duration.zero) {
|
||||||
|
// 처음 재생(이어하기 포함)이 시작되면 영상을 멈춰 두고 센다 — 세는 동안은 판정하지 않는다.
|
||||||
|
_counted = true;
|
||||||
|
_countEnd = DateTime.now().add(widget.countdown);
|
||||||
|
_control.pauseAt(_judge.position);
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
if (_countEnd != null) {
|
||||||
|
_playing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_playing != e.playing && mounted) setState(() {});
|
if (_playing != e.playing && mounted) setState(() {});
|
||||||
_playing = e.playing;
|
_playing = e.playing;
|
||||||
_anchorMedia = e.position;
|
_anchorMedia = e.position;
|
||||||
@@ -98,6 +120,17 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onTick() {
|
void _onTick() {
|
||||||
|
final end = _countEnd;
|
||||||
|
if (end != null) {
|
||||||
|
if (DateTime.now().isBefore(end)) {
|
||||||
|
setState(() {});
|
||||||
|
} else {
|
||||||
|
_countEnd = null;
|
||||||
|
_control.play();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
final now = _mediaAt(DateTime.now());
|
final now = _mediaAt(DateTime.now());
|
||||||
if (now == null || _done) return;
|
if (now == null || _done) return;
|
||||||
final judged = _judge.live(now).judged;
|
final judged = _judge.live(now).judged;
|
||||||
@@ -120,6 +153,7 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final now = _mediaAt(DateTime.now()) ?? _anchorMedia;
|
final now = _mediaAt(DateTime.now()) ?? _anchorMedia;
|
||||||
final v = _judge.live(now);
|
final v = _judge.live(now);
|
||||||
|
final left = _countEnd?.difference(DateTime.now());
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.cosmicVoid,
|
backgroundColor: AppColors.cosmicVoid,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -138,7 +172,9 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Text(
|
Text(
|
||||||
switch (v.last) {
|
left != null
|
||||||
|
? '${(left.inMilliseconds / 1000).ceil().clamp(1, 99)}'
|
||||||
|
: switch (v.last) {
|
||||||
ShakeHit.perfect => 'PERFECT',
|
ShakeHit.perfect => 'PERFECT',
|
||||||
ShakeHit.good => 'GOOD',
|
ShakeHit.good => 'GOOD',
|
||||||
ShakeHit.miss => 'MISS',
|
ShakeHit.miss => 'MISS',
|
||||||
@@ -147,10 +183,10 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
|||||||
key: const ValueKey('shake-last'),
|
key: const ValueKey('shake-last'),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 34,
|
fontSize: left != null ? 62 : 34,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
height: 1.3,
|
height: left != null ? 1.4 : 1.3,
|
||||||
color: switch (v.last) {
|
color: left != null ? AppColors.onCosmic : switch (v.last) {
|
||||||
ShakeHit.perfect => AppColors.cosmicAccent,
|
ShakeHit.perfect => AppColors.cosmicAccent,
|
||||||
ShakeHit.good => const Color(0xFF67D3A9),
|
ShakeHit.good => const Color(0xFF67D3A9),
|
||||||
_ => const Color(0xFF777777),
|
_ => const Color(0xFF777777),
|
||||||
@@ -201,9 +237,21 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
|
|||||||
videoId: widget.setup.videoId,
|
videoId: widget.setup.videoId,
|
||||||
onEvent: onEvent,
|
onEvent: onEvent,
|
||||||
start: _judge.position,
|
start: _judge.position,
|
||||||
|
control: _control,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 플레이어를 밖에서 멈추고 켠다 — 카운트다운 동안 영상을 세워 두는 데 쓴다.
|
||||||
|
/// 플레이어가 붙기 전(테스트의 가짜 플레이어 포함)에는 아무 일도 하지 않는다.
|
||||||
|
class ShakePlayerControl {
|
||||||
|
void Function(String js)? _run;
|
||||||
|
|
||||||
|
void pauseAt(Duration at) =>
|
||||||
|
_run?.call('player.seekTo(${at.inMilliseconds / 1000},true);player.pauseVideo();');
|
||||||
|
|
||||||
|
void play() => _run?.call('player.playVideo();');
|
||||||
|
}
|
||||||
|
|
||||||
/// Perfect · Good · Miss · 최대 콤보 네 칸 — 진행 화면과 결과 카드가 같이 쓴다.
|
/// Perfect · Good · Miss · 최대 콤보 네 칸 — 진행 화면과 결과 카드가 같이 쓴다.
|
||||||
class ShakeTally extends StatelessWidget {
|
class ShakeTally extends StatelessWidget {
|
||||||
const ShakeTally({
|
const ShakeTally({
|
||||||
@@ -275,9 +323,11 @@ class YoutubeEmbed extends StatefulWidget {
|
|||||||
required this.videoId,
|
required this.videoId,
|
||||||
required this.onEvent,
|
required this.onEvent,
|
||||||
this.start = Duration.zero,
|
this.start = Duration.zero,
|
||||||
|
this.control,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String videoId;
|
final String videoId;
|
||||||
|
final ShakePlayerControl? control;
|
||||||
|
|
||||||
/// 재생 시작 시각 — 「이어하기」는 멈춘 자리(초 단위 내림)부터 튼다.
|
/// 재생 시작 시각 — 「이어하기」는 멈춘 자리(초 단위 내림)부터 튼다.
|
||||||
final Duration start;
|
final Duration start;
|
||||||
@@ -340,6 +390,7 @@ class _YoutubeEmbedState extends State<YoutubeEmbed> {
|
|||||||
if (web.platform case final AndroidWebViewController android) {
|
if (web.platform case final AndroidWebViewController android) {
|
||||||
unawaited(android.setMediaPlaybackRequiresUserGesture(false));
|
unawaited(android.setMediaPlaybackRequiresUserGesture(false));
|
||||||
}
|
}
|
||||||
|
widget.control?._run = (js) => unawaited(web.runJavaScript(js));
|
||||||
unawaited(
|
unawaited(
|
||||||
web.loadHtmlString(
|
web.loadHtmlString(
|
||||||
YoutubeEmbed.html(
|
YoutubeEmbed.html(
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ class QuestSession {
|
|||||||
this.position = Duration.zero,
|
this.position = Duration.zero,
|
||||||
this.hits = const [],
|
this.hits = const [],
|
||||||
this.judgement,
|
this.judgement,
|
||||||
|
this.firstBeat,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// [QuestStage.inProgress] · [QuestStage.paused] · [QuestStage.completed] 중 하나.
|
/// [QuestStage.inProgress] · [QuestStage.paused] · [QuestStage.completed] 중 하나.
|
||||||
@@ -384,6 +385,16 @@ class QuestSession {
|
|||||||
/// Music Shake 를 멈춘 영상 시각 — 「이어하기」 시작점.
|
/// Music Shake 를 멈춘 영상 시각 — 「이어하기」 시작점.
|
||||||
final Duration position;
|
final Duration position;
|
||||||
|
|
||||||
|
/// 이 일시 정지가 지금 운영값([setup])의 박 위치로 이어질 수 있는지. 첫 박이나 판 길이가 바뀌었으면 false.
|
||||||
|
bool fitsShake(MusicShakeSetup setup) => fits(setup.firstBeat, setup.segments);
|
||||||
|
|
||||||
|
/// [fitsShake] 와 같은 비교 — 운영값만 아는 곳(목록 · HOME · 캡슐은 퀘스트 문서를 읽는다)용.
|
||||||
|
bool fits(Duration firstBeat, int segments) =>
|
||||||
|
this.firstBeat == firstBeat && total == segments;
|
||||||
|
|
||||||
|
/// Music Shake 판을 시작할 때의 첫 박(`quests.firstBeatMs`). 없으면 첫 박 도입 전에 저장된 판이다.
|
||||||
|
final Duration? firstBeat;
|
||||||
|
|
||||||
/// Music Shake 박마다 가장 가까운 흔들기의 거리(박 비율). 흔들기가 없으면 -1.
|
/// Music Shake 박마다 가장 가까운 흔들기의 거리(박 비율). 흔들기가 없으면 -1.
|
||||||
final List<double> hits;
|
final List<double> hits;
|
||||||
|
|
||||||
@@ -414,6 +425,7 @@ class QuestCard {
|
|||||||
this.publishState = '',
|
this.publishState = '',
|
||||||
this.authorName = '',
|
this.authorName = '',
|
||||||
this.session,
|
this.session,
|
||||||
|
this.staleShake = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
@@ -423,6 +435,10 @@ class QuestCard {
|
|||||||
/// 서버에 남은 진행 세션(진행중 · 일시 정지 · 진행완료) — 원장 C-83. 없으면 null.
|
/// 서버에 남은 진행 세션(진행중 · 일시 정지 · 진행완료) — 원장 C-83. 없으면 null.
|
||||||
final QuestSession? session;
|
final QuestSession? session;
|
||||||
|
|
||||||
|
/// 운영값(첫 박 · 판 길이)이 바뀌어 이어갈 수 없는 Music Shake 세션이 서버에 남아 있다.
|
||||||
|
/// 이때 [session] 은 null 이고 카드는 「시작하기」로 보인다 — 시작하면 안내를 한 번 띄운다.
|
||||||
|
final bool staleShake;
|
||||||
|
|
||||||
/// 만든 사람의 공개 닉네임(`quests.authorName`). 운영 Quest 는 빈 값이다.
|
/// 만든 사람의 공개 닉네임(`quests.authorName`). 운영 Quest 는 빈 값이다.
|
||||||
/// 신고하기 「신고 정보 영역 — 작성자 닉네임」(Figma `548:21389`)에 쓴다.
|
/// 신고하기 「신고 정보 영역 — 작성자 닉네임」(Figma `548:21389`)에 쓴다.
|
||||||
final String authorName;
|
final String authorName;
|
||||||
@@ -473,6 +489,7 @@ class QuestCard {
|
|||||||
/// 상태·진행도만 바꾼 사본. [QuestCard] 는 불변이라 갈아 끼운다.
|
/// 상태·진행도만 바꾼 사본. [QuestCard] 는 불변이라 갈아 끼운다.
|
||||||
QuestCard copyWith({QuestStage? stage, int? progress}) => QuestCard(
|
QuestCard copyWith({QuestStage? stage, int? progress}) => QuestCard(
|
||||||
session: session,
|
session: session,
|
||||||
|
staleShake: staleShake,
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
categoryName: categoryName,
|
categoryName: categoryName,
|
||||||
@@ -500,7 +517,9 @@ class MusicShakeSetup {
|
|||||||
required this.title,
|
required this.title,
|
||||||
required this.bpm,
|
required this.bpm,
|
||||||
required this.segments,
|
required this.segments,
|
||||||
|
this.firstBeat = Duration.zero,
|
||||||
this.windows = const ShakeWindows(),
|
this.windows = const ShakeWindows(),
|
||||||
|
this.leadIn = const Duration(seconds: 3),
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 지정 공식 YouTube 영상(PRD-04 MVP 수용 기준) — `quests.link.url` 에서 꺼낸 ID.
|
/// 지정 공식 YouTube 영상(PRD-04 MVP 수용 기준) — `quests.link.url` 에서 꺼낸 ID.
|
||||||
@@ -512,9 +531,19 @@ class MusicShakeSetup {
|
|||||||
/// `quests.bpm` — Figma 「목표BPM」. 🟡 의미 · 입력 주체 회신 대기(Q-MS-01 · Q-MS-02).
|
/// `quests.bpm` — Figma 「목표BPM」. 🟡 의미 · 입력 주체 회신 대기(Q-MS-01 · Q-MS-02).
|
||||||
final double bpm;
|
final double bpm;
|
||||||
|
|
||||||
/// `quests.totalSegments` — 판정 박 수. 🟡 구간 패턴 정의 회신 대기(Q-MS-02).
|
/// `quests.totalSegments` — 판정 박 수. 곡 전체를 덮도록 운영자가 정한다. 🟡 회신 대기(Q-MS-02).
|
||||||
final int segments;
|
final int segments;
|
||||||
|
|
||||||
|
/// `quests.firstBeatMs` — 영상에서 첫 박 시각. 🟡 임시값 — 회신 대기(Q-MS-02 · Q-MS-03). 없으면 0.
|
||||||
|
final Duration firstBeat;
|
||||||
final ShakeWindows windows;
|
final ShakeWindows windows;
|
||||||
|
|
||||||
|
/// 첫 박 몇 초 전부터 영상을 트는가 — `config/policy.musicShake.leadInMs`(없으면 3초).
|
||||||
|
/// 🟡 임시값 — 회신 대기(해석 확인 요청서 MusicShake 「재생 시작 위치」 B안).
|
||||||
|
final Duration leadIn;
|
||||||
|
|
||||||
|
/// 새 판의 영상 시작 시각 — 첫 박 [leadIn] 전(0 아래로는 가지 않는다). 이어하기는 저장 위치다.
|
||||||
|
Duration get startAt => firstBeat > leadIn ? firstBeat - leadIn : Duration.zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
class QuestDetail {
|
class QuestDetail {
|
||||||
|
|||||||
@@ -850,12 +850,19 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
// 운영값(첫 박 · 판 길이)이 바뀐 뒤의 옛 일시 정지는 박 위치가 맞지 않는다 — 버리고 새 판으로 시작한다.
|
||||||
|
// 목록 · 상세가 이미 시작하기로 보이는 경우(staleShake)도 같은 안내를 한 번 한다.
|
||||||
|
if (detail.card.staleShake || (resume != null && !resume.fitsShake(setup))) {
|
||||||
|
resume = null;
|
||||||
|
_toast(context.l10n.questShakeRestart);
|
||||||
|
}
|
||||||
final judge = ShakeJudge(
|
final judge = ShakeJudge(
|
||||||
bpm: setup.bpm,
|
bpm: setup.bpm,
|
||||||
segments: setup.segments,
|
segments: setup.segments,
|
||||||
|
firstBeat: setup.firstBeat,
|
||||||
windows: setup.windows,
|
windows: setup.windows,
|
||||||
hits: resume?.hits ?? const [],
|
hits: resume?.hits ?? const [],
|
||||||
position: resume?.position ?? Duration.zero,
|
position: resume?.position ?? setup.startAt,
|
||||||
);
|
);
|
||||||
QuestSession snapshot(QuestStage state) => QuestSession(
|
QuestSession snapshot(QuestStage state) => QuestSession(
|
||||||
state: state,
|
state: state,
|
||||||
@@ -863,6 +870,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
total: setup.segments,
|
total: setup.segments,
|
||||||
position: judge.position,
|
position: judge.position,
|
||||||
hits: judge.hits,
|
hits: judge.hits,
|
||||||
|
firstBeat: setup.firstBeat,
|
||||||
);
|
);
|
||||||
setState(() => _stage = QuestStage.inProgress);
|
setState(() => _stage = QuestStage.inProgress);
|
||||||
// 진행 중 앱이 꺼져도 다음에 일시 정지(멈춘 자리)로 보이게 시작 시점을 남긴다.
|
// 진행 중 앱이 꺼져도 다음에 일시 정지(멈춘 자리)로 보이게 시작 시점을 남긴다.
|
||||||
@@ -900,6 +908,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
state: QuestStage.completed,
|
state: QuestStage.completed,
|
||||||
progress: setup.segments,
|
progress: setup.segments,
|
||||||
total: setup.segments,
|
total: setup.segments,
|
||||||
|
firstBeat: setup.firstBeat,
|
||||||
judgement: run.judgement(),
|
judgement: run.judgement(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ typedef ShakeLive = ({
|
|||||||
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
|
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
|
||||||
///
|
///
|
||||||
/// 시각은 **영상 재생 시각**(0 = 영상 처음)이다 — 공식 YouTube 영상을 보며 흔드는 퀘스트라
|
/// 시각은 **영상 재생 시각**(0 = 영상 처음)이다 — 공식 YouTube 영상을 보며 흔드는 퀘스트라
|
||||||
/// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고, 흔들기는 가장 가까운 박
|
/// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고 첫 박은 `quests.firstBeatMs` 이며(판 길이 `totalSegments` 는 곡 전체를 덮도록 운영자가 정한다), 흔들기는 가장 가까운 박
|
||||||
/// 하나에만 붙는다. 박과의 거리가 [ShakeWindows.perfect] 박 이내면 perfect, [ShakeWindows.good]
|
/// 하나에만 붙는다. 박과의 거리가 [ShakeWindows.perfect] 박 이내면 perfect, [ShakeWindows.good]
|
||||||
/// 박 이내면 good, 없으면 miss. **성공 판정은 서버가 다시 한다** — 표가 스스로 모순되면(합 ≠ 구간
|
/// 박 이내면 good, 없으면 miss. **성공 판정은 서버가 다시 한다** — 표가 스스로 모순되면(합 ≠ 구간
|
||||||
/// 수) 서버가 끊는다.
|
/// 수) 서버가 끊는다.
|
||||||
@@ -158,6 +158,7 @@ class ShakeJudge {
|
|||||||
ShakeJudge({
|
ShakeJudge({
|
||||||
required this.bpm,
|
required this.bpm,
|
||||||
required this.segments,
|
required this.segments,
|
||||||
|
this.firstBeat = Duration.zero,
|
||||||
this.windows = const ShakeWindows(),
|
this.windows = const ShakeWindows(),
|
||||||
List<double> hits = const [],
|
List<double> hits = const [],
|
||||||
this.position = Duration.zero,
|
this.position = Duration.zero,
|
||||||
@@ -170,6 +171,9 @@ class ShakeJudge {
|
|||||||
|
|
||||||
final double bpm;
|
final double bpm;
|
||||||
final int segments;
|
final int segments;
|
||||||
|
|
||||||
|
/// `quests.firstBeatMs` — 영상에서 첫 박이 오는 시각. 박 i 는 `firstBeat + i * 60/bpm` 이다.
|
||||||
|
final Duration firstBeat;
|
||||||
final ShakeWindows windows;
|
final ShakeWindows windows;
|
||||||
|
|
||||||
/// 마지막으로 본 영상 시각 — 진행 화면을 나가면 「이어하기」의 시작점이 된다.
|
/// 마지막으로 본 영상 시각 — 진행 화면을 나가면 「이어하기」의 시작점이 된다.
|
||||||
@@ -184,13 +188,19 @@ class ShakeJudge {
|
|||||||
Duration get beat => Duration(microseconds: (60e6 / bpm).round());
|
Duration get beat => Duration(microseconds: (60e6 / bpm).round());
|
||||||
|
|
||||||
/// 마지막 박의 판정 창이 닫히는 영상 시각.
|
/// 마지막 박의 판정 창이 닫히는 영상 시각.
|
||||||
Duration get end => Duration(
|
Duration get end =>
|
||||||
|
firstBeat +
|
||||||
|
Duration(
|
||||||
microseconds: (beat.inMicroseconds * (segments - 1 + windows.good)).round(),
|
microseconds: (beat.inMicroseconds * (segments - 1 + windows.good)).round(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// 영상 시각 [at] 이 첫 박에서 몇 박 떨어졌나.
|
||||||
|
double _beats(Duration at) =>
|
||||||
|
(at - firstBeat).inMicroseconds / beat.inMicroseconds;
|
||||||
|
|
||||||
/// 영상 시각 [at] 의 흔들기 한 번.
|
/// 영상 시각 [at] 의 흔들기 한 번.
|
||||||
void add(Duration at) {
|
void add(Duration at) {
|
||||||
final pos = at.inMicroseconds / beat.inMicroseconds;
|
final pos = _beats(at);
|
||||||
final i = pos.round();
|
final i = pos.round();
|
||||||
if (i < 0 || i >= segments) return;
|
if (i < 0 || i >= segments) return;
|
||||||
final d = (pos - i).abs();
|
final d = (pos - i).abs();
|
||||||
@@ -203,7 +213,7 @@ class ShakeJudge {
|
|||||||
ShakeLive live(Duration now) {
|
ShakeLive live(Duration now) {
|
||||||
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0, judged = 0;
|
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0, judged = 0;
|
||||||
ShakeHit? last;
|
ShakeHit? last;
|
||||||
final pos = now.inMicroseconds / beat.inMicroseconds;
|
final pos = _beats(now);
|
||||||
for (var i = 0; i < segments && i + windows.good <= pos; i++) {
|
for (var i = 0; i < segments && i + windows.good <= pos; i++) {
|
||||||
judged++;
|
judged++;
|
||||||
final d = _best[i];
|
final d = _best[i];
|
||||||
|
|||||||
@@ -158,13 +158,26 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
|
|
||||||
/// 목록 카드가 「일시 정지」로 보는 세션 — 진행완료가 아니고 Walk 가 아닌 남은 세션(앱이 꺼진
|
/// 목록 카드가 「일시 정지」로 보는 세션 — 진행완료가 아니고 Walk 가 아닌 남은 세션(앱이 꺼진
|
||||||
/// inProgress 포함). 카드 단계 판정([QuestStage.paused])과 같은 규칙이다. 아니면 null.
|
/// inProgress 포함). 카드 단계 판정([QuestStage.paused])과 같은 규칙이다. 아니면 null.
|
||||||
static QuestSession? pausedSessionOf(QuestType type, Map<String, dynamic> progress) {
|
static QuestSession? pausedSessionOf(
|
||||||
|
QuestType type,
|
||||||
|
Map<String, dynamic> progress,
|
||||||
|
Map<String, dynamic> quest,
|
||||||
|
) {
|
||||||
final s = sessionOf(progress);
|
final s = sessionOf(progress);
|
||||||
return s == null || type == QuestType.steps || s.state == QuestStage.completed
|
return s == null ||
|
||||||
|
type == QuestType.steps ||
|
||||||
|
s.state == QuestStage.completed ||
|
||||||
|
staleShake(type, quest, s)
|
||||||
? null
|
? null
|
||||||
: s;
|
: s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 운영값(첫 박 · 판 길이)이 바뀌어 이어갈 수 없는 Music Shake 세션인가 — [QuestSession.fitsShake] 와 같은 기준.
|
||||||
|
static bool staleShake(QuestType type, Map<String, dynamic> quest, QuestSession? s) =>
|
||||||
|
type == QuestType.musicShake &&
|
||||||
|
s != null &&
|
||||||
|
!s.fits(Duration(milliseconds: quest.intOr('firstBeatMs')), quest.intOr('totalSegments'));
|
||||||
|
|
||||||
/// 진행 세션(`questProgress.session` — `fn-quest-progress` 만 쓴다 · 원장 C-83).
|
/// 진행 세션(`questProgress.session` — `fn-quest-progress` 만 쓴다 · 원장 C-83).
|
||||||
static QuestSession? sessionOf(Map<String, dynamic> progress) {
|
static QuestSession? sessionOf(Map<String, dynamic> progress) {
|
||||||
final raw = progress['session'];
|
final raw = progress['session'];
|
||||||
@@ -185,6 +198,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
total: m.intOr('total'),
|
total: m.intOr('total'),
|
||||||
position: Duration(milliseconds: m.intOr('positionMs')),
|
position: Duration(milliseconds: m.intOr('positionMs')),
|
||||||
hits: hits is List ? [for (final h in hits) if (h is num) h.toDouble()] : const [],
|
hits: hits is List ? [for (final h in hits) if (h is num) h.toDouble()] : const [],
|
||||||
|
firstBeat: m['firstBeatMs'] is num ? Duration(milliseconds: m.intOr('firstBeatMs')) : null,
|
||||||
judgement: judgement is Map
|
judgement: judgement is Map
|
||||||
? {for (final e in judgement.entries) if (e.value is num) '${e.key}': (e.value as num).round()}
|
? {for (final e in judgement.entries) if (e.value is num) '${e.key}': (e.value as num).round()}
|
||||||
: null,
|
: null,
|
||||||
@@ -238,13 +252,17 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
: type == QuestType.musicShake
|
: type == QuestType.musicShake
|
||||||
? quest.intOr('bpm')
|
? quest.intOr('bpm')
|
||||||
: dailyLimit;
|
: dailyLimit;
|
||||||
final session = sessionOf(progress);
|
final saved = sessionOf(progress);
|
||||||
|
// 이어갈 수 없는 옛 Music Shake 판은 세션이 없는 것으로 본다 — 「시작하기」로 보이고 시작 때 안내한다.
|
||||||
|
final stale = staleShake(type, quest, saved);
|
||||||
|
final session = stale ? null : saved;
|
||||||
final cardGoal = authorUid.isEmpty ? goal : quest.intOr('goal', 1);
|
final cardGoal = authorUid.isEmpty ? goal : quest.intOr('goal', 1);
|
||||||
// 걸음은 화면 밖 걸음까지 실제 값(C-62) — 오늘 걸음, 없으면 세션의 마지막 값.
|
// 걸음은 화면 밖 걸음까지 실제 값(C-62) — 오늘 걸음, 없으면 세션의 마지막 값.
|
||||||
final walked = steps && session != null ? walk ?? session.progress : null;
|
final walked = steps && session != null ? walk ?? session.progress : null;
|
||||||
|
|
||||||
return QuestCard(
|
return QuestCard(
|
||||||
session: session,
|
session: session,
|
||||||
|
staleShake: stale,
|
||||||
id: id,
|
id: id,
|
||||||
title: questTitle(quest),
|
title: questTitle(quest),
|
||||||
categoryName: type.category,
|
categoryName: type.category,
|
||||||
@@ -561,6 +579,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
'total': session.total,
|
'total': session.total,
|
||||||
'positionMs': session.position.inMilliseconds,
|
'positionMs': session.position.inMilliseconds,
|
||||||
if (session.hits.isNotEmpty) 'hits': session.hits,
|
if (session.hits.isNotEmpty) 'hits': session.hits,
|
||||||
|
if (session.firstBeat != null) 'firstBeatMs': session.firstBeat!.inMilliseconds,
|
||||||
'judgement': ?session.judgement,
|
'judgement': ?session.judgement,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -792,12 +811,18 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
if (videoId == null || bpm is! num || bpm <= 0 || segments <= 0) {
|
if (videoId == null || bpm is! num || bpm <= 0 || segments <= 0) {
|
||||||
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
|
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
|
||||||
}
|
}
|
||||||
|
final policy = await _policy();
|
||||||
|
final shake = policy['musicShake'];
|
||||||
|
// 🟡 임시값 — 회신 대기(해석 확인 요청서 MusicShake 「재생 시작 위치」): 첫 박 3초 전부터 튼다.
|
||||||
|
final leadIn = shake is Map ? shake['leadInMs'] : null;
|
||||||
return MusicShakeSetup(
|
return MusicShakeSetup(
|
||||||
videoId: videoId,
|
videoId: videoId,
|
||||||
title: link.strOr('title', questTitle(quest)),
|
title: link.strOr('title', questTitle(quest)),
|
||||||
bpm: bpm.toDouble(),
|
bpm: bpm.toDouble(),
|
||||||
segments: segments,
|
segments: segments,
|
||||||
windows: ShakeWindows.from(await _policy()),
|
firstBeat: Duration(milliseconds: quest.intOr('firstBeatMs')),
|
||||||
|
windows: ShakeWindows.from(policy),
|
||||||
|
leadIn: Duration(milliseconds: leadIn is num && leadIn >= 0 ? leadIn.round() : 3000),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,6 +854,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
'questId': card.id,
|
'questId': card.id,
|
||||||
'trackId': trackId,
|
'trackId': trackId,
|
||||||
'totalSegments': segments,
|
'totalSegments': segments,
|
||||||
|
if (run != null) 'firstBeatMs': run.firstBeat.inMilliseconds,
|
||||||
'judgement': judgement,
|
'judgement': judgement,
|
||||||
'occurredAt': DateTime.now().toUtc().toIso8601String(),
|
'occurredAt': DateTime.now().toUtc().toIso8601String(),
|
||||||
...await _fandomField(),
|
...await _fandomField(),
|
||||||
|
|||||||
@@ -586,6 +586,7 @@
|
|||||||
"questDetails": "Details",
|
"questDetails": "Details",
|
||||||
"questInProgress": "In progress",
|
"questInProgress": "In progress",
|
||||||
"questResume": "Resume",
|
"questResume": "Resume",
|
||||||
|
"questShakeRestart": "The quest settings changed, so you will start from the beginning.",
|
||||||
"questPaused": "Paused",
|
"questPaused": "Paused",
|
||||||
"questTypeAttend": "Check-in",
|
"questTypeAttend": "Check-in",
|
||||||
"questTypeSteps": "Walking",
|
"questTypeSteps": "Walking",
|
||||||
|
|||||||
@@ -586,6 +586,7 @@
|
|||||||
"questDetails": "Detalles",
|
"questDetails": "Detalles",
|
||||||
"questInProgress": "En curso",
|
"questInProgress": "En curso",
|
||||||
"questResume": "Reanudar",
|
"questResume": "Reanudar",
|
||||||
|
"questShakeRestart": "La configuración de la misión cambió, así que empiezas desde el principio.",
|
||||||
"questPaused": "En pausa",
|
"questPaused": "En pausa",
|
||||||
"questTypeAttend": "Asistencia",
|
"questTypeAttend": "Asistencia",
|
||||||
"questTypeSteps": "Caminar",
|
"questTypeSteps": "Caminar",
|
||||||
|
|||||||
@@ -586,6 +586,7 @@
|
|||||||
"questDetails": "詳細",
|
"questDetails": "詳細",
|
||||||
"questInProgress": "進行中",
|
"questInProgress": "進行中",
|
||||||
"questResume": "再開する",
|
"questResume": "再開する",
|
||||||
|
"questShakeRestart": "クエストの設定が変わったため、最初から始めます",
|
||||||
"questPaused": "一時停止",
|
"questPaused": "一時停止",
|
||||||
"questTypeAttend": "出席",
|
"questTypeAttend": "出席",
|
||||||
"questTypeSteps": "ウォーキング",
|
"questTypeSteps": "ウォーキング",
|
||||||
|
|||||||
@@ -1163,6 +1163,7 @@
|
|||||||
"questDetails": "자세히",
|
"questDetails": "자세히",
|
||||||
"questInProgress": "진행중",
|
"questInProgress": "진행중",
|
||||||
"questResume": "이어하기",
|
"questResume": "이어하기",
|
||||||
|
"questShakeRestart": "퀘스트 설정이 바뀌어 처음부터 시작합니다",
|
||||||
"questPaused": "일시 정지",
|
"questPaused": "일시 정지",
|
||||||
"questTypeAttend": "출석",
|
"questTypeAttend": "출석",
|
||||||
"questTypeSteps": "걷기",
|
"questTypeSteps": "걷기",
|
||||||
|
|||||||
@@ -3620,6 +3620,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'이어하기'**
|
/// **'이어하기'**
|
||||||
String get questResume;
|
String get questResume;
|
||||||
|
|
||||||
|
/// No description provided for @questShakeRestart.
|
||||||
|
///
|
||||||
|
/// In ko, this message translates to:
|
||||||
|
/// **'퀘스트 설정이 바뀌어 처음부터 시작합니다'**
|
||||||
|
String get questShakeRestart;
|
||||||
|
|
||||||
/// No description provided for @questPaused.
|
/// No description provided for @questPaused.
|
||||||
///
|
///
|
||||||
/// In ko, this message translates to:
|
/// In ko, this message translates to:
|
||||||
|
|||||||
@@ -2036,6 +2036,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get questResume => 'Resume';
|
String get questResume => 'Resume';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get questShakeRestart =>
|
||||||
|
'The quest settings changed, so you will start from the beginning.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get questPaused => 'Paused';
|
String get questPaused => 'Paused';
|
||||||
|
|
||||||
|
|||||||
@@ -2048,6 +2048,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get questResume => 'Reanudar';
|
String get questResume => 'Reanudar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get questShakeRestart =>
|
||||||
|
'La configuración de la misión cambió, así que empiezas desde el principio.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get questPaused => 'En pausa';
|
String get questPaused => 'En pausa';
|
||||||
|
|
||||||
|
|||||||
@@ -1976,6 +1976,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get questResume => '再開する';
|
String get questResume => '再開する';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get questShakeRestart => 'クエストの設定が変わったため、最初から始めます';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get questPaused => '一時停止';
|
String get questPaused => '一時停止';
|
||||||
|
|
||||||
|
|||||||
@@ -1977,6 +1977,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get questResume => '이어하기';
|
String get questResume => '이어하기';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get questShakeRestart => '퀘스트 설정이 바뀌어 처음부터 시작합니다';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get questPaused => '일시 정지';
|
String get questPaused => '일시 정지';
|
||||||
|
|
||||||
|
|||||||
@@ -1974,6 +1974,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get questResume => '继续';
|
String get questResume => '继续';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get questShakeRestart => '任务设置已更改,将从头开始';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get questPaused => '已暂停';
|
String get questPaused => '已暂停';
|
||||||
|
|
||||||
|
|||||||
@@ -586,6 +586,7 @@
|
|||||||
"questDetails": "详情",
|
"questDetails": "详情",
|
||||||
"questInProgress": "进行中",
|
"questInProgress": "进行中",
|
||||||
"questResume": "继续",
|
"questResume": "继续",
|
||||||
|
"questShakeRestart": "任务设置已更改,将从头开始",
|
||||||
"questPaused": "已暂停",
|
"questPaused": "已暂停",
|
||||||
"questTypeAttend": "签到",
|
"questTypeAttend": "签到",
|
||||||
"questTypeSteps": "步行",
|
"questTypeSteps": "步行",
|
||||||
|
|||||||
@@ -2158,6 +2158,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get questScanQrDenied => _remote['questScanQrDenied'] ?? _base.questScanQrDenied;
|
String get questScanQrDenied => _remote['questScanQrDenied'] ?? _base.questScanQrDenied;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get questShakeRestart => _remote['questShakeRestart'] ?? _base.questShakeRestart;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get questStart => _remote['questStart'] ?? _base.questStart;
|
String get questStart => _remote['questStart'] ?? _base.questStart;
|
||||||
|
|
||||||
|
|||||||
@@ -263,10 +263,10 @@ void main() {
|
|||||||
test('일시 정지 세션은 HOME 줄에도 「분류 · 일시 정지 · n/m」로 보인다 — 10/6 A-1', () async {
|
test('일시 정지 세션은 HOME 줄에도 「분류 · 일시 정지 · n/m」로 보인다 — 10/6 A-1', () async {
|
||||||
final db = await _seed();
|
final db = await _seed();
|
||||||
await db.doc('quests/shake').set({
|
await db.doc('quests/shake').set({
|
||||||
'active': true, 'title': '박자 맞춰 흔들기', 'type': 'musicShake',
|
'active': true, 'title': '박자 맞춰 흔들기', 'type': 'musicShake', 'totalSegments': 32,
|
||||||
});
|
});
|
||||||
await db.doc('users/$_uid/questProgress/shake').set({
|
await db.doc('users/$_uid/questProgress/shake').set({
|
||||||
'session': {'state': 'paused', 'progress': 14, 'total': 32},
|
'session': {'state': 'paused', 'progress': 14, 'total': 32, 'firstBeatMs': 0},
|
||||||
});
|
});
|
||||||
final quest = (await FirestoreHomeRepository(_backend(db)).load()).quests.single;
|
final quest = (await FirestoreHomeRepository(_backend(db)).load()).quests.single;
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -19,6 +19,36 @@ void main() {
|
|||||||
segments: 3,
|
segments: 3,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('첫 박 · 판 길이가 바뀐 뒤의 옛 일시 정지는 이어하지 않는다 (10/6 대표 지적)', () {
|
||||||
|
const fresh = MusicShakeSetup(
|
||||||
|
videoId: 'v', title: 't', bpm: 114, segments: 368,
|
||||||
|
firstBeat: Duration(milliseconds: 24276),
|
||||||
|
);
|
||||||
|
// 첫 박 도입 전(32박, 첫 박 0)에 저장된 판.
|
||||||
|
const old = QuestSession(state: QuestStage.paused, progress: 19, total: 32);
|
||||||
|
expect(old.fitsShake(fresh), isFalse);
|
||||||
|
const same = QuestSession(
|
||||||
|
state: QuestStage.paused, progress: 19, total: 368,
|
||||||
|
firstBeat: Duration(milliseconds: 24276),
|
||||||
|
);
|
||||||
|
expect(same.fitsShake(fresh), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('새 판은 첫 박 leadIn 전부터 틀고, 첫 박이 더 이르면 0 이다 (10/6 B안)', () {
|
||||||
|
const s = MusicShakeSetup(
|
||||||
|
videoId: 'v', title: 't', bpm: 114, segments: 368,
|
||||||
|
firstBeat: Duration(milliseconds: 24276),
|
||||||
|
);
|
||||||
|
expect(s.startAt, const Duration(milliseconds: 21276));
|
||||||
|
expect(
|
||||||
|
const MusicShakeSetup(
|
||||||
|
videoId: 'v', title: 't', bpm: 60, segments: 3,
|
||||||
|
firstBeat: Duration(seconds: 2),
|
||||||
|
).startAt,
|
||||||
|
Duration.zero,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('재생 중 흔들기만 영상 시각으로 판정 · 끝나면 판정표를 돌려준다', (tester) async {
|
testWidgets('재생 중 흔들기만 영상 시각으로 판정 · 끝나면 판정표를 돌려준다', (tester) async {
|
||||||
final shakes = StreamController<DateTime>();
|
final shakes = StreamController<DateTime>();
|
||||||
addTearDown(shakes.close);
|
addTearDown(shakes.close);
|
||||||
@@ -35,6 +65,7 @@ void main() {
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => MusicShakePlayScreen(
|
builder: (_) => MusicShakePlayScreen(
|
||||||
setup: setup,
|
setup: setup,
|
||||||
|
countdown: Duration.zero,
|
||||||
shakes: shakes.stream,
|
shakes: shakes.stream,
|
||||||
player: (onEvent) {
|
player: (onEvent) {
|
||||||
player = onEvent;
|
player = onEvent;
|
||||||
@@ -141,6 +172,7 @@ void main() {
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => MusicShakePlayScreen(
|
builder: (_) => MusicShakePlayScreen(
|
||||||
setup: setup,
|
setup: setup,
|
||||||
|
countdown: Duration.zero,
|
||||||
shakes: shakes.stream,
|
shakes: shakes.stream,
|
||||||
judge: judge,
|
judge: judge,
|
||||||
player: (onEvent) {
|
player: (onEvent) {
|
||||||
@@ -181,4 +213,48 @@ void main() {
|
|||||||
expect(resume, contains('player.seekTo(9.91,true);player.playVideo();'));
|
expect(resume, contains('player.seekTo(9.91,true);player.playVideo();'));
|
||||||
expect(YoutubeEmbed.html('vid'), isNot(contains('playVideo')));
|
expect(YoutubeEmbed.html('vid'), isNot(contains('playVideo')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('재생을 시작하면 3·2·1 을 세는 동안 판정하지 않고, 0 이 되면 판정이 시작된다', (tester) async {
|
||||||
|
final shakes = StreamController<DateTime>();
|
||||||
|
addTearDown(shakes.close);
|
||||||
|
late ValueChanged<ShakePlayerEvent> player;
|
||||||
|
final judge = ShakeJudge(bpm: 60, segments: 3);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: MusicShakePlayScreen(
|
||||||
|
setup: setup,
|
||||||
|
countdown: const Duration(milliseconds: 500), // 화면은 단말 시계로 센다
|
||||||
|
shakes: shakes.stream,
|
||||||
|
judge: judge,
|
||||||
|
player: (onEvent) {
|
||||||
|
player = onEvent;
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
player((playing: true, position: Duration.zero, ended: false));
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
expect(find.text('1'), findsOneWidget);
|
||||||
|
shakes.add(DateTime.now()); // 세는 동안의 흔들기는 세지 않는다.
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 700)));
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
expect(find.text('1'), findsNothing);
|
||||||
|
player((playing: true, position: Duration.zero, ended: false));
|
||||||
|
shakes.add(DateTime.now());
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
expect(judge.hits[0], inInclusiveRange(0, 0.01));
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('박은 firstBeat 부터 — 인트로의 흔들기는 박이 아니다', () {
|
||||||
|
final j = ShakeJudge(bpm: 60, segments: 2, firstBeat: const Duration(seconds: 17))
|
||||||
|
..add(const Duration(seconds: 1))
|
||||||
|
..add(const Duration(seconds: 17))
|
||||||
|
..add(const Duration(milliseconds: 18300));
|
||||||
|
expect(j.end, const Duration(milliseconds: 18500));
|
||||||
|
expect(j.judgement(), {'perfect': 1, 'good': 1, 'miss': 0, 'maxCombo': 2});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ service cloud.firestore {
|
|||||||
}, SetOptions(merge: true));
|
}, SetOptions(merge: true));
|
||||||
await db.doc('quests/shake').set({
|
await db.doc('quests/shake').set({
|
||||||
'type': 'musicshake', 'title': '응원 흔들기', 'dailyLimit': 1,
|
'type': 'musicshake', 'title': '응원 흔들기', 'dailyLimit': 1,
|
||||||
'totalSegments': 3, 'bpm': 120,
|
'totalSegments': 3, 'bpm': 120, 'firstBeatMs': 17000,
|
||||||
'link': {'url': 'https://youtu.be/WMweEpGlu_U', 'title': 'BTS - Butter'},
|
'link': {'url': 'https://youtu.be/WMweEpGlu_U', 'title': 'BTS - Butter'},
|
||||||
});
|
});
|
||||||
final backend = _RecordingBackend(db);
|
final backend = _RecordingBackend(db);
|
||||||
@@ -423,20 +423,22 @@ service cloud.firestore {
|
|||||||
expect(setup.videoId, 'WMweEpGlu_U');
|
expect(setup.videoId, 'WMweEpGlu_U');
|
||||||
expect(setup.title, 'BTS - Butter');
|
expect(setup.title, 'BTS - Butter');
|
||||||
expect((setup.bpm, setup.segments), (120.0, 3));
|
expect((setup.bpm, setup.segments), (120.0, 3));
|
||||||
|
expect(setup.firstBeat, const Duration(seconds: 17));
|
||||||
expect((setup.windows.perfect, setup.windows.good), (0.1, 0.4));
|
expect((setup.windows.perfect, setup.windows.good), (0.1, 0.4));
|
||||||
|
|
||||||
// 판을 끝내기 전에는 서버를 부르지 않는다.
|
// 판을 끝내기 전에는 서버를 부르지 않는다.
|
||||||
await expectLater(repo.verify(card), throwsA(isA<BackendException>()));
|
await expectLater(repo.verify(card), throwsA(isA<BackendException>()));
|
||||||
expect(backend.calls, isEmpty);
|
expect(backend.calls, isEmpty);
|
||||||
|
|
||||||
repo.recordShake(ShakeJudge(bpm: 120, segments: 3, windows: setup.windows)
|
repo.recordShake(ShakeJudge(bpm: 120, segments: 3, firstBeat: setup.firstBeat, windows: setup.windows)
|
||||||
..add(Duration.zero));
|
..add(setup.firstBeat));
|
||||||
await repo.verify(card);
|
await repo.verify(card);
|
||||||
final call = backend.calls.single;
|
final call = backend.calls.single;
|
||||||
expect(call.name, 'fnQuestVerifyMusicShake');
|
expect(call.name, 'fnQuestVerifyMusicShake');
|
||||||
expect(call.payload['questId'], 'shake');
|
expect(call.payload['questId'], 'shake');
|
||||||
expect(call.payload['trackId'], 'WMweEpGlu_U');
|
expect(call.payload['trackId'], 'WMweEpGlu_U');
|
||||||
expect(call.payload['totalSegments'], 3);
|
expect(call.payload['totalSegments'], 3);
|
||||||
|
expect(call.payload['firstBeatMs'], 17000);
|
||||||
expect(call.payload['judgement'], {'perfect': 1, 'good': 0, 'miss': 2, 'maxCombo': 1});
|
expect(call.payload['judgement'], {'perfect': 1, 'good': 0, 'miss': 2, 'maxCombo': 1});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -661,7 +663,7 @@ service cloud.firestore {
|
|||||||
final ref = db.doc('users/$_uid/questProgress/shake');
|
final ref = db.doc('users/$_uid/questProgress/shake');
|
||||||
final repo = FirestoreQuestRepository(_RecordingBackend(db));
|
final repo = FirestoreQuestRepository(_RecordingBackend(db));
|
||||||
|
|
||||||
await ref.set({'session': {'state': 'inProgress', 'progress': 43, 'total': 60}});
|
await ref.set({'session': {'state': 'inProgress', 'progress': 43, 'total': 60, 'firstBeatMs': 0}});
|
||||||
var card = _card(await repo.load(), 'shake');
|
var card = _card(await repo.load(), 'shake');
|
||||||
// 앱이 꺼진 진행중도 진행 화면 밖이므로 일시 정지다.
|
// 앱이 꺼진 진행중도 진행 화면 밖이므로 일시 정지다.
|
||||||
expect(card.stage, QuestStage.paused);
|
expect(card.stage, QuestStage.paused);
|
||||||
@@ -669,7 +671,7 @@ service cloud.firestore {
|
|||||||
expect(QuestTab.inProgress.accepts(card), isTrue);
|
expect(QuestTab.inProgress.accepts(card), isTrue);
|
||||||
|
|
||||||
await ref.set({'session': {
|
await ref.set({'session': {
|
||||||
'state': 'completed', 'progress': 60, 'total': 60,
|
'state': 'completed', 'progress': 60, 'total': 60, 'firstBeatMs': 0,
|
||||||
'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30},
|
'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30},
|
||||||
}});
|
}});
|
||||||
card = _card(await repo.load(), 'shake');
|
card = _card(await repo.load(), 'shake');
|
||||||
@@ -680,7 +682,7 @@ service cloud.firestore {
|
|||||||
test('목록에서 바로 받는 Music Shake 는 서버 세션의 판정표를 보낸다', () async {
|
test('목록에서 바로 받는 Music Shake 는 서버 세션의 판정표를 보낸다', () async {
|
||||||
final db = await shakeSeed();
|
final db = await shakeSeed();
|
||||||
await db.doc('users/$_uid/questProgress/shake').set({'session': {
|
await db.doc('users/$_uid/questProgress/shake').set({'session': {
|
||||||
'state': 'completed', 'progress': 60, 'total': 60,
|
'state': 'completed', 'progress': 60, 'total': 60, 'firstBeatMs': 0,
|
||||||
'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30},
|
'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30},
|
||||||
}});
|
}});
|
||||||
final backend = _RecordingBackend(db);
|
final backend = _RecordingBackend(db);
|
||||||
@@ -733,11 +735,11 @@ service cloud.firestore {
|
|||||||
'type': 'musicshake', 'title': '두 번째', 'totalSegments': 30, 'bpm': 100,
|
'type': 'musicshake', 'title': '두 번째', 'totalSegments': 30, 'bpm': 100,
|
||||||
});
|
});
|
||||||
await db.doc('users/$_uid/questProgress/shake').set({'session': {
|
await db.doc('users/$_uid/questProgress/shake').set({'session': {
|
||||||
'state': 'paused', 'progress': 43, 'total': 60,
|
'state': 'paused', 'progress': 43, 'total': 60, 'firstBeatMs': 0,
|
||||||
'updatedAt': Timestamp.fromMillisecondsSinceEpoch(2000),
|
'updatedAt': Timestamp.fromMillisecondsSinceEpoch(2000),
|
||||||
}});
|
}});
|
||||||
await db.doc('users/$_uid/questProgress/shake2').set({'session': {
|
await db.doc('users/$_uid/questProgress/shake2').set({'session': {
|
||||||
'state': 'paused', 'progress': 3, 'total': 30,
|
'state': 'paused', 'progress': 3, 'total': 30, 'firstBeatMs': 0,
|
||||||
'updatedAt': Timestamp.fromMillisecondsSinceEpoch(1000),
|
'updatedAt': Timestamp.fromMillisecondsSinceEpoch(1000),
|
||||||
}});
|
}});
|
||||||
final repo = FirestoreQuestRepository(_RecordingBackend(db));
|
final repo = FirestoreQuestRepository(_RecordingBackend(db));
|
||||||
@@ -745,5 +747,23 @@ service cloud.firestore {
|
|||||||
expect(card?.id, 'shake');
|
expect(card?.id, 'shake');
|
||||||
expect((card?.session?.progress, card?.session?.total), (43, 60));
|
expect((card?.session?.progress, card?.session?.total), (43, 60));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('운영값이 바뀐 옛 Music Shake 판은 일시 정지로 보이지 않고 시작하기다 (10/6)', () async {
|
||||||
|
final db = await shakeSeed();
|
||||||
|
await db.doc('quests/shake').update({'firstBeatMs': 24276});
|
||||||
|
await db.doc('users/$_uid/questProgress/shake').set({'session': {
|
||||||
|
'state': 'paused', 'progress': 43, 'total': 60, 'firstBeatMs': 0,
|
||||||
|
}});
|
||||||
|
final repo = FirestoreQuestRepository(_RecordingBackend(db));
|
||||||
|
final card = _card(await repo.load(), 'shake');
|
||||||
|
expect((card.stage, card.session, card.staleShake), (QuestStage.notStarted, null, true));
|
||||||
|
expect(await repo.pausedQuest(), isNull);
|
||||||
|
// 맞는 판은 그대로 일시 정지다.
|
||||||
|
await db.doc('users/$_uid/questProgress/shake').set({'session': {
|
||||||
|
'state': 'paused', 'progress': 43, 'total': 60, 'firstBeatMs': 24276,
|
||||||
|
}});
|
||||||
|
final ok = _card(await repo.load(), 'shake');
|
||||||
|
expect((ok.stage, ok.staleShake), (QuestStage.paused, false));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user