Skip to content

Commit 89f1894

Browse files
committed
Merge remote-tracking branch 'origin/dev'
2 parents 9e20ca6 + 41f6803 commit 89f1894

32 files changed

Lines changed: 53202 additions & 77 deletions

README.md

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ MuConvert - 新一代多功能音游转谱器
33

44
MuConvert 是一个多功能的音游转谱器。目前支持maimai、chunithm的谱面格式转换,未来还可能加入更多游戏/更多格式支持。
55
- maimai:支持 Simai(自制谱社区最主流格式,[MajdataEdit](https://majdata.net/edit)[Visual Maimai](https://github.com/CH3COOOHH/Visual-Maimai-Release)等都是这种格式)与 MA2(官方游戏格式)的双向互转。
6-
- chunithm:支持 UGC([Umiguri](https://umgr.inonote.jp/en/)的格式)与 C2S(官方游戏格式)的双向互转;
6+
- CHUNITHM:支持 UGC([Umiguri](https://umgr.inonote.jp/en/)的格式)与 C2S(官方游戏格式)的双向互转;
77
- 此外,还实验性地支持SUS与上述格式的双向互转(注意目前支持还不太完善,可能有较多bug,如遇bug欢迎反馈)
8+
- Ongeki:由于这款游戏尚无普遍通用的社区自制谱格式,因此支持的是OGKR(官方游戏格式)的解析和生成逻辑,而非直接的谱面转换。详见下文[Ongeki游戏支持](#Ongeki游戏支持)部分所述。
89

910
> Kind reminder: To reduce developers’ workload, this README is maintained only in Chinese. We recommend using an LLM to translate and read this document.
1011
@@ -15,7 +16,7 @@ MuConvert 是一个多功能的音游转谱器。目前支持maimai、chunithm
1516
- 工具+基础库:既可以直接当作命令行工具使用,也可以把它作为一个C#依赖库嵌入到你的工程里。
1617
- 可扩展的架构设计:本项目以中间表示(Chart类)为核心,通过为每种语言编写parser、将语言解析为统一的中间表示对象,再为每种语言编写generator,实现任意两个语言间的互转。
1718
- 项目设计具有良好的可扩展性,您可轻松按照自己的需求定制自己的语言格式,也可直接把解析得到的Chart对象拿来服务于您自己的下游项目如谱面播放器等。
18-
- 多游戏支持:基于上述良好的可扩展性,本项目一套代码可提供对maimai、chunithm两款游戏共五种格式的支持,未来还可能加入ongeki等更多游戏
19+
- 多游戏支持:基于上述良好的可扩展性,本项目一套代码可提供对maimai、CHUNITHM、Ongeki三款游戏共六种格式的支持,未来还可能加入更多游戏/更多格式
1920

2021
## 使用文档
2122
本项目具有两种使用方式:
@@ -109,6 +110,8 @@ MuConvert "D:\charts\Song\0003_00.c2s"
109110
110111
</details>
111112
113+
> Ongeki游戏支持方面,目前仅支持对OGKR一种格式的解析和生成,不是直接两种格式间转换,因此没有直接调用CLI程序的入口,只能以代码调用的方式使用。详见下文[Ongeki游戏支持](#Ongeki游戏支持)部分所述。
114+
112115
### 2) 将本项目作为依赖库使用
113116
#### 导入依赖库
114117
- **推荐做法**:把本仓库作为 git submodule 引入你的工程仓库,然后把 `MuConvert.csproj` 加入你的 `.sln`/`.slnx`
@@ -160,14 +163,43 @@ return maidataText; // maidataText即为转谱结果
160163
var (c2sChart, alerts) = new C2sParser().Parse(c2sText); // 解析 C2S 谱面字符串
161164
var (ugcChart, alerts) = new UgcParser().Parse(ugcText); // 解析 UGC 谱面字符串
162165
// 以上得到的c2sChart、ugcChart,都是ChuChart类型的谱面表示对象;
163-
// alerts是解析过程中可能产生的警告信息等,建议打印出来。
166+
// alerts是解析过程中可能产生的警告信息等,建议打印出来(直接对Alert对象调用ToString()即可)
164167
165168
var (c2sText, alerts) = new C2sGenerator().Generate(ugcChart); // UGC -> C2S
166169
var (ugcText, alerts) = new UgcGenerator().Generate(c2sChart); // C2S -> UGC
167170
// 各种Generator的Generate方法,均接受 ChuChart(可将任一 Parser 产出的 ChuChart 互相传入)。
168171
// 同上,alerts是生成过程中可能产生的警告信息等,建议打印出来。
169172
```
170173
174+
#### Ongeki游戏支持
175+
由于这款游戏尚无普遍通用的社区自制谱格式,因此目前MuConvert支持的仅有OGKR(官方游戏格式)这一种格式的解析和生成。
176+
解析得到的谱面表示对象为`OgkChart`类,内含`Notes`(正键、侧键、bell等各种音符)、`Lanes`(轨道和引导线)、`Bullets`(子弹)等字段、足以表达一个谱面的所有信息。具体的含义和用法,`chart/ogk/OgkChart.cs`的代码中有丰富的注释,请参见代码中的注释。
177+
178+
关于其用途,您可以考虑把本项目作为您其他Ongeki相关项目的一个依赖库,通过使用其中的OGKR解析和/或OGKR生成的代码逻辑,来简化/优化您的开发工作,避免关注Ongeki游戏谱面格式的繁杂细节,直接把精力放在您的项目的核心。
179+
例如,如果您正在开发一个Ongeki的谱面播放器,您就可以直接使用`OgkrParser`得到`OgkChart`对象,该对象中内置了关于谱面的所有细节信息;其中的音符类型(`OgkNote`)等上面都实现了丰富的方法,如小节时间`Time`、绝对时间`ToSecond()`等,可以直接调用使用、以便播放器直接计算各个音符在某一时刻的位置,而不必自己去解析OGKR文本、处理各种复杂的谱面相关细节问题了。
180+
181+
以下示例展示了如何解析OGKR文本为OgkChart对象,做一点小小的改动(本例中为把第一个Hold的时长加倍),再生成回OGKR格式的文本。
182+
> 以下 C# 示例中的各类均位于命名空间 `MuConvert.ogk`中,使用时需添加 `using MuConvert.ogk;`
183+
```csharp
184+
// 首先使用File.ReadAllText等方法,将谱面整体读取为字符串
185+
var (ogkChart, alerts) = new OngekiParser().Parse(ogkrText); // 解析 OGKR 谱面字符串
186+
// ogkChart即为OgkChart类的对象,包含了谱面中的所有信息;alerts是解析过程中可能产生的警告信息等,建议打印出来(直接对Alert对象调用ToString()即可)。
187+
188+
// 对ogkChart进行一些你想要的改动,例如把第一个Hold的时长加倍
189+
foreach (OgkNote note in ogkChart.Notes)
190+
{
191+
if (note is Hold hold)
192+
{
193+
var originDuration = hold.EndTime - hold.StartTime; // 计算原始时长
194+
hold.EndTime = originDuration * 2 + hold.StartTime; // 把第一个Hold的时长加倍
195+
break;
196+
}
197+
}
198+
199+
var (ogkrText, alerts) = new OngekiGenerator().Generate(ogkChart); // 将 OgkChart 对象导出为 OGKR 文本
200+
// 同上,alerts是生成过程中可能产生的警告信息等,建议打印出来。
201+
```
202+
171203
#### parser和generator的选项
172204
- 部分parser和generator,在其构造参数中带有可选的选项参数,可以控制转谱时的一些行为。
173205
- SimaiParser带有以下选项:
@@ -219,6 +251,8 @@ finally
219251
220252
- **中间表示 IR(Chart)**:MuConvert 内部统一的谱面数据结构
221253
- 对maimai,类型为 `MuConvert.mai.MaiChart`
254+
- 对CHUNITHM,类型为 `MuConvert.chu.ChuChart`
255+
- 对Ongeki,类型为 `MuConvert.ogk.OgkChart`
222256
- 关键字段包括 `Chart.BpmList``Chart.Notes`,以及 `Touch/Hold/Slide` 等具体 `Note` 子类
223257
224258
- **generator(生成器)**:把中间表示转回“目标格式文本”
@@ -356,3 +390,12 @@ public sealed class FooGenerator : IGenerator<MaiChart>
356390
- 详见上文[多语言(i18n)相关](#多语言i18n相关)部分的说明。
357391
7. **CLI**(可选):如需实现CLI,可在`Program.cs`中增加相应的功能。
358392
393+
### Ongeki谱面的数据结构实现
394+
- 关于ogkr格式中各种指令的含义,可参考[ogkr格式详解](https://github.com/MikiraSora/OngekiFumianDescription/blob/master/ongeki.md)和[补充说明](https://github.com/MikiraSora/OngekiFumianDescription/blob/master/description.md)。
395+
- ogkr格式当中,大量的采用列举Pallete列表+使用ID引用具体的数据的实现方式。
396+
- 例如子弹`BLT`指令,要通过`strId`引用`[B_PALLETE]`中内容,Tap、Hold等音符需要通过`groupId`引用`Lane`相关的内容,等
397+
- 而我们的数据结构实现的过程中,采用直接的对象引用而不是ID字符串引用;同时对于`BulletPallete``Lane`,谱面数据结构中**不会保存其ID**
398+
- 因此,推荐的做法是:
399+
- 在Parser的解析过程中,在Parser内部缓存ID和具体对象的映射关系,每次解析完一个`[B_PALLETE]``Lane`对象时就把它放进该内部缓存中,以供后面解析具体的`BLT`、Tap时直接找到对象。
400+
- 在Generator的生成过程中,也是在内部建立缓存、为每个相关对象编好ID,然后生成引用这些对象的具体音符的时候直接写入编号。
401+
- 也就是说,**ID相关的逻辑应当由Parser和Generator内部自行实现,谱面数据结构中不会保存ID。**

chart/BaseChart.cs

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,40 @@ public abstract class BaseChart<TNote>: IBaseChart where TNote: BaseNote
3232
/**
3333
* 所有拍号声明构成的列表。
3434
*
35-
* 已知该内容,目前在游戏内暂无实质性的效果。maimai中无任何效果,chunnithm则会把这个作为显示小节和拍子参考线时候额依据、但也仅影响显示效果,对游戏本身无影响。
35+
* 已知该内容,目前在游戏内暂无实质性的效果。maimai中无任何效果,chunithm则会把这个作为显示小节和拍子参考线时候额依据、但也仅影响显示效果,对游戏本身无影响。
3636
*/
3737
public List<MET> MetList = [];
3838

39+
/**
40+
* 所有变速声明构成的列表。
41+
*
42+
* maimai不会用到,ongeki和chunithm才会用到。
43+
*/
44+
public List<(Rational Time, Rational Duration, decimal Multiplier)> SflList = [];
45+
46+
/**
47+
* 谱面开头的“哒哒哒哒”声音的个数。
48+
*
49+
* 具体而言,有两种指定方式:
50+
* 1. 直接设置本属性。则会利用游戏内部自带的CLK_DEF机制,会按每拍一次的频率生成指定个数的"哒"声。
51+
* 2. 设置ExplicitClocks属性。这个直接对应于谱面中的CLK语句,有几行就是几声。
52+
*/
53+
public int ClockCount
54+
{
55+
get => ExplicitClocks?.Count ?? field;
56+
set
57+
{
58+
field = value;
59+
ExplicitClocks = null;
60+
}
61+
} = 4;
62+
63+
/**
64+
* 这是MA2/OGKR语句中,通过CLK指令所显式指定的哒哒哒哒的时刻。
65+
* 除去极个别官谱外,一般来说极少会用到。详见ClockCount属性上的注释。
66+
*/
67+
public List<Rational>? ExplicitClocks;
68+
3969
/**
4070
* 根据BPMList中的声明,将小节时间转换为秒。
4171
*/
@@ -83,6 +113,9 @@ public virtual void Sort()
83113
BpmList.AddRange(sortedBpms);
84114

85115
MetList = MetList.OrderBy(x => x.Time).ToList();
116+
SflList = SflList.OrderBy(x => x.Time).ToList();
117+
if (ExplicitClocks != null) ExplicitClocks = ExplicitClocks.Order().ToList();
118+
86119
Notes = SortNotes().ToList(); // LINQ OrderBy 是稳定排序
87120
}
88121

@@ -94,16 +127,7 @@ public virtual void Sort()
94127
public virtual void Shift(Rational offset, decimal? bpm = null)
95128
{
96129
bpm ??= StartBpm;
97-
98-
if (offset < 0)
99-
{ // 向前平移。此时存在的一种极端情况就是指定的区间跨过了多个BPM区间。
100-
// 传入的bpm参数本质是一种写死的InvariantBar,因此要把它转为可变Bar,才是真正的要去应用的offset。
101-
offset = -BpmList.ConvertTime(0, -offset, bpm, null);
102-
}
103-
else if (offset > 0)
104-
{ // 向后平移。需要把传入的offset的量换算到乐曲开头BPM下,才是真正的量。
105-
offset = offset * (Rational)StartBpm / (Rational)bpm;
106-
}
130+
offset = _calcOffsetForShift(offset, bpm.Value);
107131

108132
// 对BpmList和MetList的处理:需要确保首项为0
109133
BpmList = new BPMList(BpmList.Select(x => x with { Time = x.Time + offset })
@@ -113,6 +137,12 @@ public virtual void Shift(Rational offset, decimal? bpm = null)
113137
.Skip(MetList.Count(x => x.Time <= 0) - 1)
114138
.Select((x, i) => i == 0 ? x with { Time = 0 } : x).ToList();
115139

140+
// 对SflList和ExplicitClocks的处理:加上offset后,只保留>=0的项
141+
SflList = SflList.Select(x => x with { Time = x.Time + offset })
142+
.Where(x => x.Time >= 0).ToList();
143+
if (ExplicitClocks != null)
144+
ExplicitClocks = ExplicitClocks.Select(x => x + offset).Where(x => x >= 0).ToList();
145+
116146
// Notes,时间加上offset,但注意需要对children进行递归操作
117147
HashSet<BaseNote> processed = [];
118148
Notes = Notes.Where(x => addOffset(x).Time >= 0).ToList();
@@ -128,4 +158,18 @@ BaseNote addOffset(BaseNote note)
128158
return note;
129159
}
130160
}
161+
162+
protected Rational _calcOffsetForShift(Rational offset, decimal bpm)
163+
{
164+
if (offset < 0)
165+
{ // 向前平移。此时存在的一种极端情况就是指定的区间跨过了多个BPM区间。
166+
// 传入的bpm参数本质是一种写死的InvariantBar,因此要把它转为可变Bar,才是真正的要去应用的offset。
167+
offset = -BpmList.ConvertTime(0, -offset, bpm, null);
168+
}
169+
else if (offset > 0)
170+
{ // 向后平移。需要把传入的offset的量换算到乐曲开头BPM下,才是真正的量。
171+
offset = offset * (Rational)StartBpm / (Rational)bpm;
172+
}
173+
return offset;
174+
}
131175
}

chart/BaseNote.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@ public abstract class BaseNote
1212
/**
1313
* 音符的结束时刻。以小节为单位(分数时间)
1414
*
15-
* 默认实现中实现为等于开始时刻(瞬间音符,没有持续时间)。有持续时间的音符应当重写此属性。
15+
* 默认实现中实现为等于开始时刻(瞬间音符,没有持续时间),且该属性只能get不能set。有持续时间的音符应当重写此属性。
1616
*/
17-
public virtual Rational EndTime => Time;
17+
public virtual Rational EndTime
18+
{
19+
get => Time;
20+
set => throw new InvalidOperationException("Cannot directly manipulate Note's EndTime. You may want to manipulate the 'Duration' property on the note.");
21+
}
1822

1923
/**
2024
* 如果有某个音符,包含另一个/一些音符作为子音符,这些子音符本身不会出现在Chart的Notes列表内:

chart/chu/ChuChart.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,4 @@ public class ChuChart : BaseChart<ChuNote>
1212
public string DisplayLevel { get; set; } = ""; // 显示等级,字符串
1313
public decimal Level { get; set; } // 定数,小数
1414
public string MusicId { get; set; } = "0";
15-
public List<(Rational Time, Rational Duration, decimal Multiplier)> SflList = []; // 所有变速声明构成的列表。
1615
}

chart/mai/MaiChart.cs

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,11 @@
11
using MuConvert.chart;
2-
using MuConvert.utils;
3-
using Rationals;
42

53
namespace MuConvert.mai;
64

75
public class MaiChart: BaseChart<Note>
86
{
97
public string DefaultTouchSize = "M1";
108

11-
public int ClockCount
12-
{
13-
get => ExplicitClocks?.Count ?? field;
14-
set
15-
{
16-
field = value;
17-
ExplicitClocks = null;
18-
}
19-
} = 4;
20-
219
/**
2210
* 获得谱面开始的时刻(即谱面中第一个音符的开始时刻)。
2311
*
@@ -33,14 +21,6 @@ public int ClockCount
3321
note is Slide { segments.Count: > 1 }); // 星星段数大于1(fes星星)
3422

3523
public Statistics Statistics => new(this);
36-
37-
/**
38-
* 这是MA2语句中,通过CLK指令所显式指定的哒哒哒哒的时刻。
39-
* 一般来说极少会用到,这里只是忠实地记录一下;一方面符合我们“0信息损失”的原则、忠实地记录铺面中的信息;
40-
* 另一方面,可以用作ClockCount自动推导的来源之一。
41-
* 普通用户理论上极少会用到这个东西。
42-
*/
43-
public List<Rational>? ExplicitClocks;
4424

4525
protected override IEnumerable<Note> SortNotes()
4626
{

chart/mai/Note.cs

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55

66
namespace MuConvert.mai;
77

8+
/**
9+
* maimai的Note基类
10+
*/
811
public abstract class Note: BaseNote
912
{
1013
public readonly MaiChart Chart;
@@ -42,26 +45,8 @@ protected Note(MaiChart chart, Rational time)
4245
public virtual string Modifiers => (IsBreak ? "b" : "") + (IsEx ? "x" : "");
4346

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

6651
internal virtual string DebuggerDisplay() => "";
6752

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
}

0 commit comments

Comments
 (0)