Skip to content

Commit 54e5c27

Browse files
committed
feat: 新增 C2S/UGC/SUS 谱面格式支持
Parser-Converter-Generator 三层架构,统一 IChuChart 接口,Generator 内嵌转换逻辑。 5 种语言 i18n 覆盖,4 项 xUnit 测试,219/219 全过。
1 parent 9500348 commit 54e5c27

23 files changed

Lines changed: 1898 additions & 27 deletions

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,8 @@ riderModule.iml
88
.cursor
99
/.tmp*
1010
*scratch*
11-
*.lscache
11+
*.lscache
12+
13+
# 测试 dump 输出
14+
*_output.*
15+
placeholder.txt

chart/chu/C2sChart.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using MuConvert.chart;
2+
3+
namespace MuConvert.chu;
4+
5+
/**
6+
* C2S 格式谱面 IR(官方格式,RESOLUTION=384 tick/小节)。
7+
*/
8+
public class C2sChart : BaseChart<ChuNote>, IChuChart
9+
{
10+
public string Version { get; set; } = "1.08.00\t1.08.00";
11+
public int MusicId { get; set; }
12+
public int DifficultId { get; set; }
13+
public string Creator { get; set; } = "";
14+
public int Resolution { get; set; } = 384;
15+
public double DefBpm { get; set; } = 120.0;
16+
public List<(int Measure, int Offset, double Bpm)> BpmEvents = [];
17+
public List<(int Measure, int Offset, int Denom, int Num)> MetEvents = [];
18+
public List<(int Measure, int Offset, int Duration, double Multiplier)> SflEvents = [];
19+
20+
public override decimal StartBpm => (decimal)(BpmEvents.Count > 0 ? BpmEvents[0].Bpm : DefBpm);
21+
public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * Resolution + n.Offset) / 384m * 240m / StartBpm : 0;
22+
public override decimal EndTime => Notes.Count > 0 ? Notes.Max(n => n.Measure * Resolution + n.Offset + Math.Max(n.HoldDuration, Math.Max(n.SlideDuration, n.AirHoldDuration))) / 384m * 240m / StartBpm : 0;
23+
public override int TotalNotes => Notes.Count;
24+
}

chart/chu/ChuNote.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace MuConvert.chu;
2+
3+
/**
4+
* CHUNITHM 通用音符,C2S / UGC / SUS 共用此结构。
5+
*/
6+
public class ChuNote
7+
{
8+
/** 音符类型 (TAP, CHR, HLD, SLD, AIR, AHD 等) */
9+
public string Type { get; set; } = "TAP";
10+
/** 小节号 */
11+
public int Measure { get; set; }
12+
/** 小节内偏移 (C2S: 0–383, UGC/SUS: 0–1919) */
13+
public int Offset { get; set; }
14+
/** 起始列 (0–15) */
15+
public int Cell { get; set; }
16+
/** 宽度 (1–16) */
17+
public int Width { get; set; } = 1;
18+
/** HLD 持续时长 */
19+
public int HoldDuration { get; set; }
20+
/** SLD 持续时长 */
21+
public int SlideDuration { get; set; }
22+
/** SLD 终点列 */
23+
public int EndCell { get; set; }
24+
/** SLD 终点宽度 */
25+
public int EndWidth { get; set; } = 1;
26+
/** CHR/FLK 附加数据(方向等) */
27+
public string Extra { get; set; } = "";
28+
/** AIR/AHD 关联的目标音符类型 */
29+
public string TargetNote { get; set; } = "";
30+
/** AHD 持续时长 */
31+
public int AirHoldDuration { get; set; }
32+
/** Air Crush 起始高度 */
33+
public int StartHeight { get; set; }
34+
/** Air Crush 目标高度 */
35+
public int TargetHeight { get; set; }
36+
/** Air Crush 颜色 */
37+
public string NoteColor { get; set; } = "";
38+
}

chart/chu/IChuChart.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using MuConvert.chart;
2+
3+
namespace MuConvert.chu;
4+
5+
/**
6+
* CHUNITHM 所有谱面格式的统一接口,作为 Generator 的输入类型。
7+
*/
8+
public interface IChuChart : IBaseChart;

chart/chu/SusChart.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using MuConvert.chart;
2+
3+
namespace MuConvert.chu;
4+
5+
/**
6+
* SUS 格式谱面 IR(REQUEST=480 tick/拍,lane 0–31)。
7+
*/
8+
public class SusChart : BaseChart<ChuNote>, IChuChart
9+
{
10+
public string Title { get; set; } = "";
11+
public string Artist { get; set; } = "";
12+
public string Designer { get; set; } = "";
13+
public int TicksPerBeat { get; set; } = 480;
14+
public double Bpm { get; set; } = 120.0;
15+
16+
public override decimal StartBpm => (decimal)Bpm;
17+
public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * TicksPerBeat * 4 + n.Offset) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0;
18+
public override decimal EndTime => 0;
19+
public override int TotalNotes => Notes.Count;
20+
}

chart/chu/UgcChart.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using MuConvert.chart;
2+
3+
namespace MuConvert.chu;
4+
5+
/**
6+
* UGC 格式谱面 IR(UMIGURI 格式,@TICKS=480 tick/拍)。
7+
*/
8+
public class UgcChart : BaseChart<ChuNote>, IChuChart
9+
{
10+
public string Version { get; set; } = "6";
11+
public string Title { get; set; } = "";
12+
public string Artist { get; set; } = "";
13+
public string Designer { get; set; } = "";
14+
public string Difficulty { get; set; } = "";
15+
public int Level { get; set; }
16+
public double Constant { get; set; }
17+
public string SongId { get; set; } = "";
18+
public int TicksPerBeat { get; set; } = 480;
19+
public List<(int Measure, int Num, int Den)> BeatEvents = [];
20+
public List<(int Measure, int Offset, double Bpm)> BpmEvents = [];
21+
public List<(int Measure, int Offset, double Multiplier)> SpeedEvents = [];
22+
23+
public override decimal StartBpm => (decimal)(BpmEvents.Count > 0 ? BpmEvents[0].Bpm : 120.0);
24+
public override decimal StartTime => Notes.Count > 0 ? Notes.Min(n => n.Measure * TicksPerBeat * 4 + n.Offset) / (decimal)(TicksPerBeat * 4) * 240m / StartBpm : 0;
25+
public override decimal EndTime => 0;
26+
public override int TotalNotes => Notes.Count;
27+
}

generator/chu/C2sGenerator.cs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
using System.Globalization;
2+
using System.Text;
3+
using MuConvert.chart;
4+
using MuConvert.generator;
5+
using MuConvert.utils;
6+
using static MuConvert.utils.Alert.LEVEL;
7+
8+
namespace MuConvert.chu;
9+
10+
/**
11+
* C2S 格式生成器。
12+
* 输入 IChuChart,内部自动转换后输出 C2S 文本。
13+
*/
14+
public class C2sGenerator : IGenerator<IChuChart>
15+
{
16+
private const int C2sResolution = 384;
17+
18+
public (string, List<Alert>) Generate(IChuChart chart)
19+
{
20+
var alerts = new List<Alert>();
21+
var c2s = ConvertToC2s(chart, alerts);
22+
var text = Serialize(c2s);
23+
return (text, alerts);
24+
}
25+
26+
private static C2sChart ConvertToC2s(IChuChart chart, List<Alert> alerts)
27+
{
28+
if (chart is C2sChart c2s) return c2s;
29+
30+
if (chart is UgcChart ugc)
31+
{
32+
var result = new C2sChart
33+
{
34+
Version = "1.08.00\t1.08.00",
35+
Creator = ugc.Designer,
36+
DefBpm = ugc.BpmEvents.Count > 0 ? ugc.BpmEvents[0].Bpm : 120.0,
37+
};
38+
foreach (var b in ugc.BpmEvents)
39+
result.BpmEvents.Add((b.Measure, ScaleDown(b.Offset, ugc.TicksPerBeat), b.Bpm));
40+
foreach (var b in ugc.BeatEvents)
41+
result.MetEvents.Add((b.Measure, 0, b.Den, b.Num));
42+
foreach (var n in ugc.Notes)
43+
result.Notes.Add(ScaleNote(n, ugc.TicksPerBeat));
44+
return result;
45+
}
46+
47+
if (chart is SusChart sus)
48+
{
49+
var result = new C2sChart { DefBpm = sus.Bpm };
50+
result.BpmEvents.Add((0, 0, sus.Bpm));
51+
foreach (var n in sus.Notes)
52+
result.Notes.Add(ScaleNote(n, sus.TicksPerBeat));
53+
return result;
54+
}
55+
56+
alerts.Add(new Alert(Warning, string.Format(Locale.ChuGeneratorUnsupported, "→ C2S")));
57+
return new C2sChart();
58+
}
59+
60+
private static ChuNote ScaleNote(ChuNote n, int tpb)
61+
{
62+
int scaleDown(int v) => (int)((long)v * (C2sResolution / 4) / tpb);
63+
return new ChuNote
64+
{
65+
Type = n.Type, Measure = n.Measure, Offset = scaleDown(n.Offset),
66+
Cell = n.Cell, Width = n.Width,
67+
HoldDuration = scaleDown(n.HoldDuration), SlideDuration = scaleDown(n.SlideDuration),
68+
EndCell = n.EndCell, EndWidth = n.EndWidth,
69+
Extra = n.Extra, TargetNote = n.TargetNote, AirHoldDuration = scaleDown(n.AirHoldDuration),
70+
StartHeight = n.StartHeight, TargetHeight = n.TargetHeight, NoteColor = n.NoteColor,
71+
};
72+
}
73+
74+
private static int ScaleDown(int ticks, int tpb) => (int)((long)ticks * (C2sResolution / 4) / tpb);
75+
76+
private static string Serialize(C2sChart chart)
77+
{
78+
var sb = new StringBuilder();
79+
sb.AppendLine($"VERSION\t{chart.Version}");
80+
sb.AppendLine($"MUSIC\t{chart.MusicId}");
81+
sb.AppendLine("SEQUENCEID\t0");
82+
sb.AppendLine($"DIFFICULT\t{chart.DifficultId:D2}");
83+
sb.AppendLine("LEVEL\t0.0");
84+
sb.AppendLine($"CREATOR\t{chart.Creator}");
85+
sb.AppendLine($"BPM_DEF\t{Fmt(chart.DefBpm)}\t{Fmt(chart.DefBpm)}\t{Fmt(chart.DefBpm)}\t{Fmt(chart.DefBpm)}");
86+
sb.AppendLine("MET_DEF\t4\t4");
87+
sb.AppendLine($"RESOLUTION\t{chart.Resolution}");
88+
sb.AppendLine($"CLK_DEF\t{chart.Resolution}");
89+
sb.AppendLine("PROGJUDGE_BPM\t240.000");
90+
sb.AppendLine("PROGJUDGE_AER\t0.999");
91+
sb.AppendLine("TUTORIAL\t0");
92+
sb.AppendLine();
93+
94+
foreach (var b in chart.BpmEvents)
95+
sb.AppendLine($"BPM\t{b.Measure}\t{b.Offset}\t{Fmt(b.Bpm)}");
96+
foreach (var m in chart.MetEvents)
97+
sb.AppendLine($"MET\t{m.Measure}\t{m.Offset}\t{m.Denom}\t{m.Num}");
98+
foreach (var s in chart.SflEvents)
99+
sb.AppendLine($"SFL\t{s.Measure}\t{s.Offset}\t{s.Duration}\t{Mlt(s.Multiplier)}");
100+
sb.AppendLine();
101+
102+
foreach (var n in chart.Notes.OrderBy(n => n.Measure * C2sResolution + n.Offset))
103+
sb.AppendLine(FormatNote(n));
104+
105+
sb.AppendLine();
106+
return sb.ToString();
107+
}
108+
109+
private static string FormatNote(ChuNote n) => n.Type switch
110+
{
111+
"TAP" => $"TAP\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}",
112+
"CHR" => $"CHR\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.Extra}",
113+
"HLD" or "HXD" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.HoldDuration}",
114+
"SLD" or "SLC" or "SXD" or "SXC" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.SlideDuration}\t{n.EndCell}\t{n.EndWidth}",
115+
"FLK" => $"FLK\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.Extra}",
116+
"AIR" or "AUR" or "AUL" or "ADW" or "ADR" or "ADL" => $"{n.Type}\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.TargetNote}",
117+
"AHD" => $"AHD\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{n.AirHoldDuration}",
118+
"MNE" => $"MNE\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}",
119+
_ => $"TAP\t{n.Measure}\t{n.Offset}\t{n.Cell}\t{n.Width}"
120+
};
121+
122+
private static string Fmt(double v) => v.ToString("0.000", CultureInfo.InvariantCulture);
123+
private static string Mlt(double v) => v.ToString("0.000000", CultureInfo.InvariantCulture);
124+
}

generator/chu/SusGenerator.cs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System.Text;
2+
using MuConvert.chart;
3+
using MuConvert.generator;
4+
using MuConvert.utils;
5+
using static MuConvert.utils.Alert.LEVEL;
6+
7+
namespace MuConvert.chu;
8+
9+
/**
10+
* SUS 格式生成器。
11+
* 输入 IChuChart,内部自动转换后输出 SUS 文本。
12+
*/
13+
public class SusGenerator : IGenerator<IChuChart>
14+
{
15+
private const int SusTpb = 480;
16+
private const int C2sRsl = 384;
17+
18+
public (string, List<Alert>) Generate(IChuChart chart)
19+
{
20+
var alerts = new List<Alert>();
21+
var sus = ConvertToSus(chart, alerts);
22+
var text = Serialize(sus);
23+
return (text, alerts);
24+
}
25+
26+
private static SusChart ConvertToSus(IChuChart chart, List<Alert> alerts)
27+
{
28+
if (chart is SusChart sus) return sus;
29+
30+
double bpm = 120.0;
31+
string title = "", artist = "";
32+
33+
if (chart is C2sChart c2s)
34+
{
35+
bpm = c2s.BpmEvents.Count > 0 ? c2s.BpmEvents[0].Bpm : c2s.DefBpm;
36+
var result = new SusChart { Bpm = bpm, TicksPerBeat = SusTpb, Title = title, Artist = artist };
37+
foreach (var n in c2s.Notes) result.Notes.Add(ScaleUp(n));
38+
return result;
39+
}
40+
41+
if (chart is UgcChart ugc)
42+
{
43+
bpm = ugc.BpmEvents.Count > 0 ? ugc.BpmEvents[0].Bpm : 120.0;
44+
var result = new SusChart { Bpm = bpm, TicksPerBeat = SusTpb, Title = ugc.Title, Artist = ugc.Artist };
45+
foreach (var n in ugc.Notes) result.Notes.Add(ScaleUp(n));
46+
return result;
47+
}
48+
49+
alerts.Add(new Alert(Warning, string.Format(Locale.ChuGeneratorUnsupported, "→ SUS")));
50+
return new SusChart();
51+
}
52+
53+
private static ChuNote ScaleUp(ChuNote n)
54+
{
55+
int s(int v) => v * SusTpb / (C2sRsl / 4);
56+
return new ChuNote
57+
{
58+
Type = n.Type, Measure = n.Measure, Offset = s(n.Offset),
59+
Cell = n.Cell * 2, Width = n.Width * 2,
60+
HoldDuration = s(n.HoldDuration), SlideDuration = s(n.SlideDuration),
61+
EndCell = n.EndCell * 2, EndWidth = n.EndWidth * 2,
62+
Extra = n.Extra, TargetNote = n.TargetNote, AirHoldDuration = s(n.AirHoldDuration),
63+
};
64+
}
65+
66+
private static string Serialize(SusChart sus)
67+
{
68+
var sb = new StringBuilder();
69+
if (!string.IsNullOrEmpty(sus.Title)) sb.AppendLine($"#TITLE \"{sus.Title}\"");
70+
if (!string.IsNullOrEmpty(sus.Artist)) sb.AppendLine($"#ARTIST \"{sus.Artist}\"");
71+
if (!string.IsNullOrEmpty(sus.Designer)) sb.AppendLine($"#DESIGNER \"{sus.Designer}\"");
72+
sb.AppendLine($"#BPM_DEF {sus.Bpm:F2}");
73+
sb.AppendLine($"#REQUEST \"{sus.TicksPerBeat}\"");
74+
sb.AppendLine();
75+
76+
foreach (var n in sus.Notes.OrderBy(n => n.Measure).ThenBy(n => n.Offset))
77+
sb.AppendLine($"#{n.Measure:X2}{n.Offset:X3}:{FormatData(n)}");
78+
79+
return sb.ToString();
80+
}
81+
82+
private static string FormatData(ChuNote n)
83+
{
84+
string lw = $"{n.Cell:X2}{n.Width:X2}";
85+
string tc = TypeCode(n.Type);
86+
string dur = $"{(n.HoldDuration > 0 ? n.HoldDuration : n.SlideDuration > 0 ? n.SlideDuration : n.AirHoldDuration):X4}";
87+
return tc switch
88+
{
89+
"01" or "02" or "03" or "10" => $"{tc}{lw}",
90+
"05" or "08" => $"{tc}{lw}{dur}",
91+
"06" => $"{tc}{lw}{dur}{n.EndCell:X2}{n.EndWidth:X2}",
92+
"07" or "09" => $"{tc}{lw}{n.TargetNote}",
93+
_ => $"01{lw}"
94+
};
95+
}
96+
97+
private static string TypeCode(string t) => t switch
98+
{
99+
"TAP" => "01", "CHR" => "02", "FLK" => "03",
100+
"HLD" => "05", "SLD" => "06", "SLC" => "06",
101+
"AIR" => "07", "AUR" => "07", "AUL" => "07",
102+
"AHD" => "08", "ADW" => "09", "ADR" => "09", "ADL" => "09",
103+
"MNE" => "10", _ => "01"
104+
};
105+
}

0 commit comments

Comments
 (0)