이클립스 방어 횟수 즉시 표시·진동 · Music Shake 영상 재생 중 흔들기 판정 · 결과 공유
This commit is contained in:
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user