Skip to content

Commit b0e85a0

Browse files
committed
[R] 重构BaseChart类,将BpmList、MetList、Sort、Shift等方法全都放到BaseChart类去,让BaseChart类承担更通用的功能;MaiChart类现在只提供ClockCount、IsDxChart、Statistics等特定于maimai的属性,同时根据maimai自己的规则override相关的父类属性/方法。
1 parent 1e9fba6 commit b0e85a0

7 files changed

Lines changed: 193 additions & 140 deletions

File tree

chart/BPMList.cs

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,7 @@ public class BPMList : List<BPM>
1010
public BPMList() {}
1111
public BPMList(IEnumerable<BPM> bpms): base(bpms) {}
1212

13-
public Rational ToSecond(Rational barTime)
14-
{
15-
Utils.Assert(this[0].Time == 0, "BPM列表的开头必须为0时刻");
16-
Rational accumulation = 0;
17-
for (int i = 0; i < Count; i++)
18-
{
19-
var bpmRangeEnd = i < Count - 1 ? this[i + 1].Time : 999999;
20-
accumulation += 240 / (Rational)this[i].Bpm * (Utils.Min(barTime, bpmRangeEnd) - this[i].Time);
21-
if (barTime <= bpmRangeEnd) break;
22-
}
23-
return accumulation;
24-
}
13+
public Rational ToSecond(Rational barTime) => ConvertTime(0, barTime, null, 240);
2514

2615
public int FindIndex(Rational time)
2716
{
@@ -51,6 +40,72 @@ private string DebuggerDisplay()
5140

5241
return result;
5342
}
43+
44+
/**
45+
* 用于在不同格式的时间数值之间换算的函数。
46+
* 把从startTime起、长为value的一段分数时间,从srcBpm下换算到dstBpm下。
47+
*
48+
* 首先指出一个重要原理:Seconds格式下以秒为单位的时间,在数值上实际等价于 240bpm下的不变小节时间。这就是为什么可以构造一个通用的转换函数的原理。
49+
*
50+
* <param name="value">要被转换的时间值</param>
51+
* <param name="srcBpm">value原始值所基准的bpm。若为null,表示使用BPMList中动态的bpm(对应把Bar转换为其他类型的情况)</param>
52+
* <param name="dstBpm">想要换算到的目标bpm。若为null,表示使用BPMList中动态的bpm(对应把其他类型转换为Bar的情况)</param>
53+
*/
54+
internal Rational ConvertTime(Rational startTime, Rational value, decimal? srcBpm, decimal? dstBpm)
55+
{
56+
Rational? srcBpmR = srcBpm != null ? (Rational?)srcBpm : null;
57+
Rational? dstBpmR = dstBpm != null ? (Rational?)dstBpm : null;
58+
if (srcBpmR != null && dstBpmR != null)
59+
{
60+
// 静态的src和dst,直接算一下即可,无需遍历bpm表
61+
return (value * (dstBpmR.Value / srcBpmR.Value)).CanonicalForm;
62+
}
63+
else
64+
{
65+
var rangeStart = startTime;
66+
var bpmIndex = FindIndex(rangeStart);
67+
Utils.Assert(bpmIndex >= 0, "startTime不应该在BPM表的范围之外!是否是BPM表没有以0时刻为开头造成的?");
68+
Rational result = 0;
69+
Rational remain = value;
70+
while (remain > 0)
71+
{
72+
// 当前所处bpm区间的结束位置。如果当前已经是最后一个区间了,则结束位置写成一个很大的数就可以了,反正本轮remain一定会被清空
73+
var bpmRangeEnd = bpmIndex < Count - 1 ? this[bpmIndex + 1].Time : 9999999;
74+
// 本区间可以消耗掉remain的最大数量,以src的bpm为单位。
75+
Rational curRangeCapacity = bpmRangeEnd - rangeStart;
76+
77+
var srcBpmNow = srcBpmR; // 每次循环要复制一份srcBpm,不然直接改了srcBpm的话,再次循环时逻辑就不对了
78+
var dstBpmNow = dstBpmR;
79+
if (srcBpmNow == null)
80+
{ // 如果srcBpm传入的是None,说明应该使用当前的实时bpm作为srcBpm
81+
srcBpmNow = (Rational)this[bpmIndex].Bpm;
82+
// 此时capacity已经是以srcBpm为单位了,无需再转换
83+
}
84+
else if (dstBpmNow == null)
85+
{
86+
dstBpmNow = (Rational)this[bpmIndex].Bpm;
87+
// 此时capacity是基于可变bpm即dstBpm的,需要换算到srcBpm上
88+
curRangeCapacity *= (srcBpmNow.Value / dstBpmNow.Value);
89+
}
90+
91+
Rational toSubtract = curRangeCapacity < remain ? curRangeCapacity : remain; // 要从remain中减掉的量,应该是(剩余量,本bpm区间允许消耗量)的最小值
92+
remain -= toSubtract;
93+
result += toSubtract * (dstBpmNow!.Value / srcBpmNow.Value);
94+
95+
bpmIndex += 1;
96+
rangeStart = bpmRangeEnd;
97+
}
98+
99+
return result.CanonicalForm;
100+
}
101+
}
54102
}
55103

56104
public record BPM(Rational Time, decimal Bpm);
105+
106+
/**
107+
* 表示拍号的结构体。
108+
* 遵循英文的表达习惯,Numerator是每小节几拍,Denominator是每几分音符为一拍。
109+
* 注意MA2、C2S等格式中,MET的格式是Bar Tick Denominator Numerator,和常见顺序是反过来的。
110+
*/
111+
public record MET(Rational Time, int Numerator, int Denominator);

chart/BaseChart.cs

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
namespace MuConvert.chart;
1+
using MuConvert.utils;
2+
using Rationals;
3+
4+
namespace MuConvert.chart;
25

36
public interface IBaseChart;
47

@@ -7,30 +10,102 @@ public interface IBaseChart;
710
* 此类中提供了Notes列表,作为存储音符的核心列表;存储的音符类型是特定于谱面的(继承时传入泛型)
811
* 此外,应当重写以下四个抽象的getter。
912
*/
10-
public abstract class BaseChart<TNote> : IBaseChart
13+
public abstract class BaseChart<TNote>: IBaseChart where TNote: BaseNote
1114
{
1215
/**
1316
* 所有音符构成的列表
1417
*/
1518
public List<TNote> Notes = [];
1619

20+
/**
21+
* 所有BPM构成的列表
22+
*/
23+
public BPMList BpmList = [];
24+
25+
/**
26+
* 所有拍号声明构成的列表。
27+
*
28+
* 已知该内容,目前在游戏内暂无实质性的效果。maimai中无任何效果,chunnithm则会把这个作为显示小节和拍子参考线时候额依据、但也仅影响显示效果,对游戏本身无影响。
29+
*/
30+
public List<MET> MetList = [];
31+
32+
/**
33+
* 根据BPMList中的声明,将小节时间转换为秒。
34+
*/
35+
public Rational ToSecond(Rational barTime) => BpmList.ToSecond(barTime);
36+
1737
/**
1838
* 谱面开头的BPM
1939
*/
20-
public abstract decimal StartBpm { get; }
40+
public virtual decimal StartBpm {
41+
get
42+
{
43+
Utils.Assert(BpmList[0].Time == 0, "BPM列表的开头必须为0时刻");
44+
return BpmList[0].Bpm;
45+
}
46+
}
2147

2248
/**
23-
* 获得谱面开始的时刻(即谱面中第一个音符的开始时刻)。单位为秒
49+
* 获得谱面开始的时刻(即谱面中第一个音符的开始时刻)。单位为秒(注意单位与Note的Time不同!)
2450
*/
25-
public abstract decimal StartTime { get; }
51+
public virtual decimal StartTime => (decimal)ToSecond(Notes.First().Time);
2652

2753
/**
28-
* 获得谱面结束的时刻(即谱面中最后一个音符的完成时刻)。单位为秒
54+
* 获得谱面结束的时刻(即谱面中最后一个音符的完成时刻)。单位为秒(注意单位与Note的EndTime不同!)
55+
*/
56+
public virtual decimal EndTime => (decimal)ToSecond(Notes.Max(x=>x.EndTime));
57+
58+
/**
59+
* 总音符数量(物量)。
60+
*
61+
* 注:具体实现很可能会需要重写此方法,因为对绝大多数的游戏,“物量”都不是简单的等于Note数量的加和的,很可能会和判定等有关
2962
*/
30-
public abstract decimal EndTime { get; }
63+
public virtual int TotalNotes => Notes.Count;
64+
65+
// 内部使用,供子类重写,实现比只用Time更复杂的排序逻辑
66+
protected virtual IEnumerable<TNote> SortNotes()
67+
{
68+
return Notes.OrderBy(n => n.Time); // 默认按照Time排序,子类可以重写此方法实现更复杂的排序逻辑
69+
}
70+
71+
public virtual void Sort()
72+
{
73+
// 分别把BpmList和Notes,依照Time做稳定排序。排序务必要稳定!
74+
var sortedBpms = BpmList.OrderBy(b => b.Time).ToList(); // LINQ OrderBy 是稳定排序
75+
BpmList.Clear();
76+
BpmList.AddRange(sortedBpms);
77+
78+
MetList = MetList.OrderBy(x => x.Time).ToList();
79+
Notes = SortNotes().ToList(); // LINQ OrderBy 是稳定排序
80+
}
3181

3282
/**
33-
* 总音符数量(物量)
83+
* 对整首歌曲,应用一个偏移量进行整体平移。
84+
* <param name="offset">偏移量,正数表示歌曲整体向后,负数表示歌曲整体向前。</param>
85+
* <param name="bpm">上述偏移量所对应的Bpm。若不传,默认使用歌曲开头的BPM(即chart.StartBpm)。</param>
3486
*/
35-
public abstract int TotalNotes { get; }
87+
public virtual void Shift(Rational offset, decimal? bpm = null)
88+
{
89+
bpm ??= StartBpm;
90+
91+
if (offset < 0)
92+
{ // 向前平移。此时存在的一种极端情况就是指定的区间跨过了多个BPM区间。
93+
// 传入的bpm参数本质是一种写死的InvariantBar,因此要把它转为可变Bar,才是真正的要去应用的offset。
94+
offset = -BpmList.ConvertTime(0, -offset, bpm, null);
95+
}
96+
else if (offset > 0)
97+
{ // 向后平移。需要把传入的offset的量换算到乐曲开头BPM下,才是真正的量。
98+
offset = offset * (Rational)StartBpm / (Rational)bpm;
99+
}
100+
101+
// 对BpmList和MetList的处理:需要确保首项为0
102+
BpmList = new BPMList(BpmList.Select(x => x with { Time = x.Time + offset })
103+
.Skip(BpmList.Count(x => x.Time <= 0) - 1) // 至多只保留一个非正项,其他的舍弃。直接Count-1这么写是没有问题的,因为Skip传入-1等价于传入0。
104+
.Select((x, i) => i == 0 ? x with { Time = 0 } : x)); // 把第一项(唯一的可能非正项)强制设为0
105+
MetList = MetList.Select(x => x with { Time = x.Time + offset }) // 同上
106+
.Skip(MetList.Count(x => x.Time <= 0) - 1)
107+
.Select((x, i) => i == 0 ? x with { Time = 0 } : x).ToList();
108+
// Notes,直接丢弃所有负数项即可
109+
Notes = Notes.Select(x => { x.Time += offset; return x; }).Where(x => x.Time >= 0).ToList();
110+
}
36111
}

chart/BaseNote.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using Rationals;
2+
3+
namespace MuConvert.chart;
4+
5+
public abstract class BaseNote
6+
{
7+
/**
8+
* 音符的开始时刻。以小节为单位(分数时间)
9+
*/
10+
public virtual Rational Time { get; set => field = value.CanonicalForm; }
11+
12+
/**
13+
* 音符的结束时刻。以小节为单位(分数时间)
14+
*
15+
* 默认实现中实现为等于开始时刻(瞬间音符,没有持续时间)。有持续时间的音符应当重写此属性。
16+
*/
17+
public virtual Rational EndTime => Time;
18+
}

chart/mai/Duration.cs

Lines changed: 6 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public Rational Bar
4545
case Type.Bar:
4646
return _data;
4747
case Type.InvariantBar:
48-
return ConvertTime(_data, (Rational)InvariantBpm, null);
48+
return ConvertTime(_data, InvariantBpm, null);
4949
case Type.Seconds:
5050
return ConvertTime(_data, 240, null); // seconds秒数可以等效为240bpm下的小节数
5151
default:
@@ -68,9 +68,9 @@ public Rational InvariantBar
6868
case Type.InvariantBar:
6969
return _data;
7070
case Type.Bar:
71-
return ConvertTime(_data, null, (Rational)InvariantBpm);
71+
return ConvertTime(_data, null, InvariantBpm);
7272
case Type.Seconds:
73-
return ConvertTime(_data, 240, (Rational)InvariantBpm); // seconds秒数可以等效为240bpm下的小节数
73+
return ConvertTime(_data, 240, InvariantBpm); // seconds秒数可以等效为240bpm下的小节数
7474
default:
7575
throw new InvalidOperationException();
7676
}
@@ -93,7 +93,7 @@ public Rational Seconds
9393
case Type.Bar:
9494
return ConvertTime(_data, null, 240); // seconds秒数可以等效为240bpm下的小节数
9595
case Type.InvariantBar:
96-
return ConvertTime(_data, (Rational)InvariantBpm, 240);
96+
return ConvertTime(_data, InvariantBpm, 240);
9797
default:
9898
throw new InvalidOperationException();
9999
}
@@ -105,69 +105,14 @@ public Rational Seconds
105105
}
106106
}
107107

108-
/**
109-
* 用于在不同格式的时间数值之间转换的函数。
110-
*
111-
* 首先指出一个重要原理:Seconds格式下以秒为单位的时间,在数值上实际等价于 240bpm下的不变小节时间。这就是为什么可以构造一个通用的转换函数的原理。
112-
*
113-
* <param name="value">要被转换的时间值</param>
114-
* <param name="srcBpm">指定value所对应的源bpm。若为None,表示使用BPMList中动态的bpm(对应把Bar转换为其他类型的情况)</param>
115-
* <param name="dstBpm">转换的目标bpm。若为None,表示使用BPMList中动态的bpm(对应把其他类型转换为Bar的情况)</param>
116-
*/
117-
private Rational ConvertTime(Rational value, Rational? srcBpm, Rational? dstBpm)
108+
private Rational ConvertTime(Rational value, decimal? srcBpm, decimal? dstBpm)
118109
{
119110
var startTime = _note.Time;
120111
if (_note is Slide slide && slide.WaitTime != this)
121112
{ // 如果我不是WaitTime,则我是Duration,则应加上等待时间
122113
startTime += slide.WaitTime.Bar;
123114
}
124-
return ConvertTime(startTime, value, srcBpm, dstBpm, BpmList);
125-
}
126-
127-
internal static Rational ConvertTime(Rational startTime, Rational value, Rational? srcBpm, Rational? dstBpm, BPMList bpmList)
128-
{
129-
if (srcBpm != null && dstBpm != null)
130-
{
131-
// 静态的src和dst,直接算一下即可,无需遍历bpm表
132-
return (value * (dstBpm.Value / srcBpm.Value)).CanonicalForm;
133-
}
134-
else
135-
{
136-
var rangeStart = startTime;
137-
var bpmIndex = bpmList.FindIndex(rangeStart);
138-
Rational result = 0;
139-
Rational remain = value;
140-
while (remain > 0)
141-
{
142-
// 当前所处bpm区间的结束位置。如果当前已经是最后一个区间了,则结束位置写成一个很大的数就可以了,反正本轮remain一定会被清空
143-
var bpmRangeEnd = bpmIndex < bpmList.Count - 1 ? bpmList[bpmIndex + 1].Time : 9999999;
144-
// 本区间可以消耗掉remain的最大数量,以src的bpm为单位。
145-
Rational curRangeCapacity = bpmRangeEnd - rangeStart;
146-
147-
var srcBpmNow = srcBpm; // 每次循环要复制一份srcBpm,不然直接改了srcBpm的话,再次循环时逻辑就不对了
148-
var dstBpmNow = dstBpm;
149-
if (srcBpmNow == null)
150-
{ // 如果srcBpm传入的是None,说明应该使用当前的实时bpm作为srcBpm
151-
srcBpmNow = (Rational)bpmList[bpmIndex].Bpm;
152-
// 此时capacity已经是以srcBpm为单位了,无需再转换
153-
}
154-
else if (dstBpmNow == null)
155-
{
156-
dstBpmNow = (Rational)bpmList[bpmIndex].Bpm;
157-
// 此时capacity是基于可变bpm即dstBpm的,需要换算到srcBpm上
158-
curRangeCapacity *= (srcBpmNow.Value / dstBpmNow.Value);
159-
}
160-
161-
Rational toSubtract = curRangeCapacity < remain ? curRangeCapacity : remain; // 要从remain中减掉的量,应该是(剩余量,本bpm区间允许消耗量)的最小值
162-
remain -= toSubtract;
163-
result += toSubtract * (dstBpmNow!.Value / srcBpmNow.Value);
164-
165-
bpmIndex += 1;
166-
rangeStart = bpmRangeEnd;
167-
}
168-
169-
return result.CanonicalForm;
170-
}
115+
return BpmList.ConvertTime(startTime, value, srcBpm, dstBpm);
171116
}
172117

173118
public static Duration operator +(Duration a, Duration b)

0 commit comments

Comments
 (0)