Skip to content

Commit 71f0b9a

Browse files
committed
[+&R] 实现音击的T_xxx谱面物量统计计算逻辑。
同时重构了Mai的Statistics.cs,抽取出一个StatisticsUtils工具类,从而使得mai和ongeki共用相同的CalcHoldJudgeCount逻辑、计算Hold的中间判定个数。
1 parent 16736ba commit 71f0b9a

6 files changed

Lines changed: 155 additions & 44 deletions

File tree

chart/mai/Note.cs

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -45,26 +45,8 @@ protected Note(MaiChart chart, Rational time)
4545
public virtual string Modifiers => (IsBreak ? "b" : "") + (IsEx ? "x" : "");
4646

4747
// 当前音符落在了哪些BPM区间内、分别有多长。
48-
public List<(int bpmIdx, decimal bpm, Rational start, Rational len)> BpmRanges
49-
{
50-
get
51-
{
52-
List<(int, decimal, Rational, Rational)> result = [];
53-
var now = Time.CanonicalForm;
54-
var end = (Time + Duration.Bar).CanonicalForm;
55-
var isFirstRange = true; // 通过这个变量和对应的逻辑,确保返回的BpmRanges至少含有一个元素。即使note本身是0长度的,返回的BpmRanges也能有一个len=0的元素。
56-
while (now < end || isFirstRange)
57-
{
58-
var bpmIdx = Chart.BpmList.FindIndex(now);
59-
var curBpmRangeEnd = bpmIdx < Chart.BpmList.Count - 1 ? Chart.BpmList[bpmIdx + 1].Time : 999999; // 当前BPM区间的结束时刻
60-
var len = Utils.Min(end, curBpmRangeEnd) - now; // 音符落在本区间内的长度为,从当前时刻开始,到(本区间结束或音符结束的较早者)
61-
result.Add((bpmIdx, Chart.BpmList[bpmIdx].Bpm, now, len.CanonicalForm));
62-
now = (now + len).CanonicalForm;
63-
isFirstRange = false;
64-
}
65-
return result;
66-
}
67-
}
48+
public List<(int bpmIdx, decimal bpm, Rational start, Rational len)> BpmRanges =>
49+
StatisticsUtils.CalcBpmRanges(Time, EndTime, Chart);
6850

6951
internal virtual string DebuggerDisplay() => "";
7052

chart/mai/Statistics.cs

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,7 @@ private void AddNote(Note note)
4747
// T_JUDGE_HLD 原理应该是游戏DLL中的Manager.NotesReader.getProgJudgeGrid。
4848
// 但是具体的机制研究的也不是太明白,只是尽力实现了下
4949
if (note is Hold or TouchHold)
50-
{
51-
var bpmRanges = note.BpmRanges;
52-
foreach (var (_, bpm, _, len) in bpmRanges)
53-
{
54-
var gridSize = new Rational(getProgJudgeGrid(bpm), 384);
55-
T_JUDGE_HLD += Math.Max((int)(len / gridSize).Ceil(), 1);
56-
}
57-
}
50+
T_JUDGE_HLD += StatisticsUtils.CalcHoldJudgeCount(note.Time, note.EndTime, note.Chart);
5851
}
5952

6053
public Statistics(MaiChart chart)
@@ -127,11 +120,4 @@ public override string ToString()
127120

128121
private Rational _now = -1; // 计算双押个数用
129122
private int _nowFalseEachIndex = 0;
130-
131-
private int getProgJudgeGrid(decimal bpm)
132-
{
133-
if (bpm < 15) return 3;
134-
int exp = (int)Math.Min(Math.Floor(Math.Log2((double)bpm / 15)), 6);
135-
return 6 * (int)Math.Pow(2, exp);
136-
}
137123
}

chart/ogk/OgkChart.cs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using MuConvert.chart;
1+
using MuConvert.chart;
22
using MuConvert.utils;
33
using Rationals;
44

@@ -41,8 +41,61 @@ public class OgkChart: BaseChart<OgkNote>
4141

4242
public override decimal StartTime => Math.Min(base.StartTime, (decimal)ToSecond(Bullets.First().Time));
4343
public override decimal EndTime => Math.Max(base.EndTime, (decimal)ToSecond(Bullets.Last().Time));
44-
public override int TotalNotes => throw new NotImplementedException();
44+
public override int TotalNotes => CountNotes()["T_TOTAL"];
4545

46+
/**
47+
* 基于谱面IR,计算ogkr中T_xxx的几个统计量(即谱面的"物量"信息)。
48+
*
49+
* 返回的字典将包含以下七个key:
50+
* - T_TAP:非侧键Tap类判定数量。等于"Tap数量+Hold数量"(即Hold的头判也计入),不包括任何侧键
51+
* - T_HOLD:非侧键Hold类判定数量。等于每条非侧键Hold的"头判定+中间判定"之和
52+
* - T_SIDE:侧键Tap类判定数量。同T_TAP,但只计侧键
53+
* - T_SHOLD:侧键Hold类判定数量。同T_HOLD,但只计侧键
54+
* - T_FLICK:Flick数量
55+
* - T_BELL:Bell数量
56+
* - T_TOTAL:T_TAP + T_HOLD + T_SIDE + T_SHOLD + T_FLICK(注意,T_BELL不计入T_TOTAL)
57+
*/
58+
public Dictionary<string, int> CountNotes()
59+
{
60+
int tTap = 0, tSide = 0, tHold = 0, tShold = 0, tFlick = 0, tBell = 0;
61+
62+
foreach (var note in Notes)
63+
{
64+
switch (note)
65+
{
66+
case Bell:
67+
tBell++;
68+
break;
69+
case Flick:
70+
tFlick++;
71+
break;
72+
case Hold hold:
73+
{
74+
var judges = StatisticsUtils.CalcHoldJudgeCount(hold.Time, hold.EndTime, this, (int)ProgJudgeBpm);
75+
if (hold.Lane.Type == LaneType.Wall) { tSide++; tShold += judges; }
76+
else { tTap++; tHold += judges; }
77+
break;
78+
}
79+
case Tap tap:
80+
{
81+
if (tap.Lane.Type == LaneType.Wall) tSide++;
82+
else tTap++;
83+
break;
84+
}
85+
}
86+
}
87+
return new Dictionary<string, int>
88+
{
89+
["T_TOTAL"] = tTap + tHold + tSide + tShold + tFlick,
90+
["T_TAP"] = tTap,
91+
["T_HOLD"] = tHold,
92+
["T_SIDE"] = tSide,
93+
["T_SHOLD"] = tShold,
94+
["T_FLICK"] = tFlick,
95+
["T_BELL"] = tBell,
96+
};
97+
}
98+
4699
public override void Sort()
47100
{
48101
base.Sort();

tests/ogk/OgkrTestUtils.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public static DirectoryInfo FindTestsetRoot()
1111
return new DirectoryInfo(Path.Combine(dir ?? throw new DirectoryNotFoundException("Could not locate repo root."), "ogk", "testset"));
1212
}
1313

14-
public static IEnumerable<object[]> GetTestInputs(string dataDir)
14+
public static IEnumerable<OgkrTestInput[]> GetTestInputs(string dataDir)
1515
{
1616
var testsetRoot = Path.Combine(FindTestsetRoot().FullName, dataDir);
1717
if (!Directory.Exists(testsetRoot))

tests/ogk/Ogkr测试.cs

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ public class Ogkr测试
1212
public Ogkr测试(ITestOutputHelper output) => _output = output;
1313

1414
public static IEnumerable<object[]> GetTestInputs(string dataDir) => OgkrTestUtils.GetTestInputs(dataDir);
15+
16+
public static IEnumerable<object[]> GetTestInputsOnlyLv3(string dataDir) => GetTestInputs(dataDir)
17+
.Where(x=>int.TryParse(((OgkrTestInput)x[0]).DifficultyId, out var id) && id == 3);
1518

1619
[Theory(Skip = "ogkr的解析和生成还没实现完,所以暂时跳过")]
1720
[MemberData(nameof(GetTestInputs), "官谱")]
@@ -21,18 +24,56 @@ public void 解析Ogkr再生成回去(OgkrTestInput c)
2124

2225
var (chart, parseAlerts) = new OgkrParser().Parse(ogkrText);
2326
var (resultText, generateAlerts) = new OgkrGenerator().Generate(chart);
24-
25-
// 转出来的IR的基本健全性检查
26-
Assert.NotEmpty(chart.BpmList);
27-
Assert.True(chart.BpmList[0].Time == 0, "BpmList首项必须为0时刻");
28-
Assert.NotEmpty(chart.Notes);
29-
Assert.DoesNotContain(parseAlerts, a => a.Level == Alert.LEVEL.Error);
27+
AssertOgkChartOk(chart, parseAlerts.Concat(generateAlerts));
3028

3129
_output.WriteLine(string.Join('\n', parseAlerts));
3230
_output.WriteLine(string.Join('\n', generateAlerts));
3331

3432
OgkrTextComparer.AssertOgkrEqual(ogkrText, resultText);
3533
}
34+
35+
[Theory]
36+
[MemberData(nameof(GetTestInputsOnlyLv3), "官谱")]
37+
public void 物量统计测试(OgkrTestInput c)
38+
{
39+
var ogkrText = File.ReadAllText(c.OgkrPath, Encoding.UTF8);
40+
var (chart, parseAlerts) = new OgkrParser().Parse(ogkrText);
41+
AssertOgkChartOk(chart, parseAlerts);
42+
43+
// 从HEADER段直接解析原谱面中标注的T_xxx的期望值
44+
var expected = ParseExpectedTValues(ogkrText);
45+
var actual = chart.CountNotes();
46+
47+
// 收集所有不一致的项,统一报出,便于调试
48+
var diffs = new List<string>();
49+
foreach (var key in expected.Keys.OrderBy(k => k, StringComparer.Ordinal))
50+
{
51+
var a = actual.GetValueOrDefault(key, -1);
52+
if (a != expected[key]) diffs.Add($" {key}: expected={expected[key]}, actual={a}");
53+
}
54+
Assert.True(diffs.Count == 0, $"物量统计与原谱不一致:{Environment.NewLine}{string.Join(Environment.NewLine, diffs)}");
55+
}
56+
57+
private static Dictionary<string, int> ParseExpectedTValues(string text)
58+
{
59+
var result = new Dictionary<string, int>();
60+
foreach (var rawLine in text.Replace("\r\n", "\n").Split('\n'))
61+
{
62+
var parts = rawLine.Trim().Split('\t');
63+
if (parts.Length >= 2 && parts[0].StartsWith("T_") && int.TryParse(parts[1], out var v))
64+
result[parts[0]] = v;
65+
}
66+
return result;
67+
}
68+
69+
private void AssertOgkChartOk(OgkChart chart, IEnumerable<Alert> alerts)
70+
{
71+
// 转出来的IR的基本健全性检查
72+
Assert.NotEmpty(chart.BpmList);
73+
Assert.True(chart.BpmList[0].Time == 0, "BpmList首项必须为0时刻");
74+
Assert.NotEmpty(chart.Notes);
75+
Assert.DoesNotContain(alerts, a => a.Level == Alert.LEVEL.Error);
76+
}
3677
}
3778

3879
/// <summary>

utils/StatisticsUtils.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using MuConvert.chart;
2+
using Rationals;
3+
4+
namespace MuConvert.utils;
5+
6+
public static class StatisticsUtils
7+
{
8+
// 当前音符落在了哪些BPM区间内、分别有多长。
9+
public static List<(int bpmIdx, decimal bpm, Rational start, Rational len)> CalcBpmRanges<T>(Rational time, Rational endTime, BaseChart<T> chart) where T: BaseNote
10+
{
11+
List<(int, decimal, Rational, Rational)> result = [];
12+
var now = time.CanonicalForm;
13+
var end = endTime.CanonicalForm;
14+
var isFirstRange = true; // 通过这个变量和对应的逻辑,确保返回的BpmRanges至少含有一个元素。即使note本身是0长度的,返回的BpmRanges也能有一个len=0的元素。
15+
while (now < end || isFirstRange)
16+
{
17+
var bpmIdx = chart.BpmList.FindIndex(now);
18+
var curBpmRangeEnd = bpmIdx < chart.BpmList.Count - 1 ? chart.BpmList[bpmIdx + 1].Time : 999999; // 当前BPM区间的结束时刻
19+
var len = Utils.Min(end, curBpmRangeEnd) - now; // 音符落在本区间内的长度为,从当前时刻开始,到(本区间结束或音符结束的较早者)
20+
result.Add((bpmIdx, chart.BpmList[bpmIdx].Bpm, now, len.CanonicalForm));
21+
now = (now + len).CanonicalForm;
22+
isFirstRange = false;
23+
}
24+
return result;
25+
}
26+
27+
// 该实现等同于游戏DLL中的Manager.NotesReader.getProgJudgeGrid
28+
private static Rational GetProgJudgeGrid(decimal bpm, int progJudgeBpm = 240)
29+
{
30+
int exp = (int)Math.Floor(Math.Log2((double)bpm / progJudgeBpm)); // 相比于一拍,应该加倍的次数
31+
exp = Math.Clamp(exp, -5, 2); // 游戏DLL中的getProgJudgeGrid函数实现,满足输出范围一定在(3/384~384/384)之间(对应7.5<=bpm<1920),对超出上述范围的bpm会把结果clamp保持在这个范围内。因此我们也保持完全相同的实现。
32+
return (Rational)Math.Pow(2, exp) / 4; // 除以4是因为,progJudgeBpm下的Grid是一拍,一拍是1/4小节
33+
}
34+
35+
// 具体的机制研究的也不是太明白,只是尽力实现了下
36+
// 对该Hold所落入的每个BPM区间,都是每隔gridSize生成一个中间判定点
37+
internal static int CalcHoldJudgeCount<T>(Rational time, Rational endTime, BaseChart<T> chart, int progJudgeBpm = 240) where T : BaseNote
38+
{
39+
var bpmRanges = CalcBpmRanges(time, endTime, chart);
40+
41+
var result = 0;
42+
foreach (var (_, bpm, _, len) in bpmRanges)
43+
{
44+
var gridSize = GetProgJudgeGrid(bpm, progJudgeBpm);
45+
result += Math.Max((int)(len / gridSize).Ceil(), 1);
46+
}
47+
return result;
48+
}
49+
}

0 commit comments

Comments
 (0)