이클립스 방어 횟수 즉시 표시·진동 · 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
+124 -26
View File
@@ -104,51 +104,149 @@ Stream<DateTime> deviceShakes() {
.map((s) => s.$2);
}
/// Music Shake 판정 한 박의 결과 — PRD-04 「Shake 판정: Perfect, Good, Miss, Combo」.
enum ShakeHit { perfect, good, miss }
/// Music Shake 판정 창 — `config/policy.musicShake`(운영값). 단위는 **박 길이 대비 비율**이다.
///
/// 🟡 **임시값 — 회신 대기.** PRD-04 · 상세정의 v1.6.1 §20 · 별첨 B 「Music Shake」 어디에도 판정 창
/// 수치가 없다(해석 확인 요청 2026-10-07 Music Shake **Q-MS-04**). 서버 `DEFAULT_POLICY.musicShake`
/// 와 같은 값이며, 운영 문서가 덮으면 그 값을 쓴다.
class ShakeWindows {
const ShakeWindows({this.perfect = 0.2, this.good = 0.5});
factory ShakeWindows.from(Map<String, dynamic> policy) {
final raw = policy['musicShake'];
final m = raw is Map ? raw : const {};
double read(String key, double fallback) {
final v = m[key];
return v is num && v > 0 ? v.toDouble() : fallback;
}
const d = ShakeWindows();
return ShakeWindows(
perfect: read('perfectWindow', d.perfect),
good: read('goodWindow', d.good),
);
}
final double perfect;
final double good;
}
/// 판정 중간값 — 화면이 그리는 수치.
typedef ShakeLive = ({
int perfect,
int good,
int miss,
int combo,
int maxCombo,
int judged,
ShakeHit? last,
});
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
///
/// 곡의 박(`quests.bpm`)마다 한 구간이다. 구간 박 시각 ±반 박 안에 흔들기가 있으면 맞춘 것,
/// 박에서 [perfectRatio] 박 이내면 perfect · 아니면 good, 없으면 miss. **등급은 서버가 다시
/// 매긴다** — 이 표가 스스로 모순되면(합 ≠ 구간 수) 서버가 F 로 끊는다.
/// 시각은 **영상 재생 시각**(0 = 영상 처음)이다 — 공식 YouTube 영상을 보며 흔드는 퀘스트라
/// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고, 흔들기는 가장 가까운 박
/// 하나에만 붙는다. 박과의 거리가 [ShakeWindows.perfect] 박 이내면 perfect, [ShakeWindows.good]
/// 박 이내면 good, 없으면 miss. **성공 판정은 서버가 다시 한다** — 표가 스스로 모순되면(합 ≠ 구간
/// 수) 서버가 끊는다.
class ShakeJudge {
ShakeJudge({required this.bpm, required this.segments, required this.start})
: assert(bpm > 0 && segments > 0);
ShakeJudge({
required this.bpm,
required this.segments,
this.windows = const ShakeWindows(),
}) : assert(bpm > 0 && segments > 0);
final double bpm;
final int segments;
final DateTime start;
final ShakeWindows windows;
/// perfect 로 보는 박과의 거리(박 단위). 🟡 표시 판정용 — 등급 문턱은 서버가 가진다.
static const perfectRatio = 0.2;
final _shakes = <DateTime>[];
/// 박 번호 → 가장 가까운 흔들기의 거리(박 비율).
final _best = <int, double>{};
Duration get beat => Duration(microseconds: (60e6 / bpm).round());
/// 마지막 구간의 판정 창(박 + 반 박)까지 끝나는 시각.
DateTime get end => start.add(beat * segments - beat ~/ 2);
/// 마지막 박의 판정 창이 닫히는 영상 시각.
Duration get end => Duration(
microseconds: (beat.inMicroseconds * (segments - 1 + windows.good)).round(),
);
void add(DateTime at) => _shakes.add(at);
/// 영상 시각 [at] 의 흔들기 한 번.
void add(Duration at) {
final pos = at.inMicroseconds / beat.inMicroseconds;
final i = pos.round();
if (i < 0 || i >= segments) return;
final d = (pos - i).abs();
if (d > windows.good) return;
final prev = _best[i];
if (prev == null || d < prev) _best[i] = d;
}
/// 구간별 판정 합계 — 서버 `ShakeJudgement` 와 같은 키.
Map<String, int> judgement() {
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0;
final beatUs = beat.inMicroseconds;
for (var i = 0; i < segments; i++) {
final at = start.microsecondsSinceEpoch + beatUs * i;
final best = _shakes
.map((s) => (s.microsecondsSinceEpoch - at).abs())
.where((d) => d < beatUs / 2)
.fold<int?>(null, (m, d) => m == null || d < m ? d : m);
if (best == null) {
/// 판정 창이 닫힌 박만 센다 — [now] 는 영상 시각.
ShakeLive live(Duration now) {
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0, judged = 0;
ShakeHit? last;
final pos = now.inMicroseconds / beat.inMicroseconds;
for (var i = 0; i < segments && i + windows.good <= pos; i++) {
judged++;
final d = _best[i];
if (d == null) {
miss++;
combo = 0;
last = ShakeHit.miss;
continue;
}
best <= beatUs * perfectRatio ? perfect++ : good++;
if (d <= windows.perfect) {
perfect++;
last = ShakeHit.perfect;
} else {
good++;
last = ShakeHit.good;
}
maxCombo = math.max(maxCombo, ++combo);
}
return {'perfect': perfect, 'good': good, 'miss': miss, 'maxCombo': maxCombo};
return (
perfect: perfect,
good: good,
miss: miss,
combo: combo,
maxCombo: maxCombo,
judged: judged,
last: last,
);
}
/// 구간별 판정 합계 — 서버 `ShakeJudgement` 와 같은 키. 아직 오지 않은 박은 miss 다.
Map<String, int> judgement() {
final v = live(end);
return {
'perfect': v.perfect,
'good': v.good,
'miss': v.miss + segments - v.judged,
'maxCombo': v.maxCombo,
};
}
}
/// YouTube 링크에서 영상 ID 를 꺼낸다 — `watch?v=` · `youtu.be/` · `/shorts/` · `/embed/` ·
/// `music.youtube.com`. YouTube 링크가 아니면 null. 서버 `videoIdOf` 와 같은 규칙.
String? youtubeVideoId(String url) {
final u = Uri.tryParse(url.trim());
if (u == null) return null;
final host = u.host.toLowerCase();
String? id;
if (host == 'youtu.be') {
id = u.pathSegments.firstOrNull;
} else if (host == 'youtube.com' || host.endsWith('.youtube.com')) {
final seg = u.pathSegments;
id = u.queryParameters['v'] ??
(seg.length >= 2 && const {'shorts', 'embed', 'live'}.contains(seg[0])
? seg[1]
: null);
}
return id != null && RegExp(r'^[A-Za-z0-9_-]{11}$').hasMatch(id) ? id : null;
}
/// 걸음 센서 — `pedometer` 의 `stepCountStream`.