Skip to content

Commit b54b7e9

Browse files
committed
[+] UgcGenerator Slide、Air Hold、Air Slide的正确实现
1 parent 52af0a3 commit b54b7e9

4 files changed

Lines changed: 150 additions & 34 deletions

File tree

generator/chu/C2sGenerator.cs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ private static string Serialize(ChuChart chart, List<Alert> alerts)
6767
return sb.ToString();
6868
}
6969

70+
private static List<string> allowedAirColors = ["DEF"]; // TODO 搞清楚UGC里的'I'颜色,在C2S里,对应的字符串是什么
71+
private static string AirColorTag(ChuNote n)
72+
{
73+
if (allowedAirColors.Contains(n.Tag)) return n.Tag;
74+
else return "DEF";
75+
}
76+
7077
private static string? FormatNote(ChuNote n, int tpm, List<Alert> alerts)
7178
{
7279
var (m, o) = Utils.BarAndTick(n.Time, tpm);
@@ -79,13 +86,8 @@ private static string Serialize(ChuChart chart, List<Alert> alerts)
7986
"SLD" or "SLC" or "SXD" or "SXC" => $"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{durTicks}\t{n.EndCell}\t{n.EndWidth}",
8087
"FLK" => $"FLK\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.Tag}",
8188
"AIR" or "AUR" or "AUL" or "ADW" or "ADR" or "ADL" =>
82-
string.IsNullOrEmpty(n.Tag)
83-
? $"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.TargetNote}"
84-
: $"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{n.Tag}",
85-
"AHD" or "AHX" =>
86-
string.IsNullOrEmpty(n.Tag)
87-
? $"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{durTicks}"
88-
: $"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{durTicks}\t{n.Tag}",
89+
$"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{AirColorTag(n)}",
90+
"AHD" or "AHX" => $"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{durTicks}\t{AirColorTag(n)}",
8991
"ASD" or "ASC" => FormatAsdAsc(n, m, o, durTicks),
9092
"ALD" => FormatAld(n, m, o),
9193
"MNE" => $"MNE\t{m}\t{o}\t{n.Cell}\t{n.Width}",
@@ -101,9 +103,9 @@ private static string Serialize(ChuChart chart, List<Alert> alerts)
101103

102104
private static string FormatAsdAsc(ChuNote n, int m, int o, int durTicks)
103105
{
104-
var e0 = n.ExtraData.Count > 0 ? n.ExtraData[0] : 0;
105-
var e1 = n.ExtraData.Count > 1 ? n.ExtraData[1] : 0;
106-
return $"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{e0}\t{durTicks}\t{n.EndCell}\t{n.EndWidth}\t{e1}\t{n.Tag}";
106+
var e0 = n.ExtraData.Count > 0 ? n.ExtraData[0] : 5;
107+
var e1 = n.ExtraData.Count > 1 ? n.ExtraData[1] : 5;
108+
return $"{n.Type}\t{m}\t{o}\t{n.Cell}\t{n.Width}\t{n.TargetNote}\t{e0}\t{durTicks}\t{n.EndCell}\t{n.EndWidth}\t{e1}\t{AirColorTag(n)}";
107109
}
108110

109111
private static string FormatAld(ChuNote n, int m, int o)

generator/chu/UgcGenerator.cs

Lines changed: 123 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,16 @@ public class UgcGenerator : IGenerator<ChuChart>
1111
public (string, List<Alert>) Generate(ChuChart chart)
1212
{
1313
var alerts = new List<Alert>();
14-
var text = Serialize(chart);
14+
var text = Serialize(chart, alerts);
1515
return (text, alerts);
1616
}
1717

18-
private static string Serialize(ChuChart ugc)
18+
private static string Serialize(ChuChart ugc, List<Alert> alerts)
1919
{
2020
ugc.Sort();
2121

2222
var sb = new StringBuilder();
23-
sb.AppendLine("@VER\t6");
23+
sb.AppendLine("@VER\t8");
2424
if (!string.IsNullOrEmpty(ugc.Title)) sb.AppendLine($"@TITLE\t{ugc.Title}");
2525
if (!string.IsNullOrEmpty(ugc.Artist)) sb.AppendLine($"@ARTIST\t{ugc.Artist}");
2626
if (!string.IsNullOrEmpty(ugc.Designer)) sb.AppendLine($"@DESIGN\t{ugc.Designer}");
@@ -51,24 +51,123 @@ private static string Serialize(ChuChart ugc)
5151
sb.AppendLine("@ENDHEAD");
5252
sb.AppendLine();
5353

54+
// UGC Slide / AIR-SLIDE (v8):
55+
// - Chains (ChuNote.Previous) serialize as ONE parent line + follower lines (#OffsetTick from parent time).
56+
// - Ground slide: parent `s`, followers `>s` / `>c` + end cell/width.
57+
// - Air slide: parent `S` + cell/width + hh (base-36 ×2, C2S/UGC height units) + N/I; followers `>s`/`>c` + xw + hh.
58+
// - First segment may attach to TAP/HLD via Previous; only skip emit when Previous is another segment of the same chain.
59+
var slideChains = BuildSlideChains(ugc.Notes);
60+
5461
foreach (var n in ugc.Notes)
5562
{
63+
if (IsSlideChainNote(n.Type) && n.Previous != null && IsSlideChainNote(n.Previous.Type))
64+
continue; // 是Slide且不是第一段Slide,则应当已经被处理过了,直接跳过
65+
5666
var (m, o) = Utils.BarAndTick(n.Time, RSL);
57-
sb.Append($"#{m}'{o}:{UCode(n, RSL)}");
67+
var ucode = UCode(n);
68+
if (ucode == "")
69+
{
70+
alerts.Add(new Alert(Alert.LEVEL.Warning, $"UGC Generator遇到了不支持的音符类型: {n.Type}", n.Time, (double)ugc.ToSecond(n.Time)));
71+
continue;
72+
}
73+
sb.Append($"#{m}'{o}:{ucode}");
5874
sb.AppendLine();
75+
76+
if (IsSlideChainNote(n.Type))
77+
{
78+
if (slideChains.TryGetValue(n, out var segments))
79+
{
80+
var isAir = IsAirSlideType(n.Type);
81+
foreach (var seg in segments)
82+
{
83+
var endTicks = Utils.Tick(seg.EndTime - n.Time, RSL);
84+
if (endTicks <= 0) continue;
85+
if (isAir)
86+
sb.AppendLine($"#{endTicks}>{SlideFollowerMarker(seg.Type)}{IntToHex(seg.EndCell)}{IntToHex(seg.EndWidth)}{EncodeUgcAirHeight2(AirSlideFollowerHeight(seg))}");
87+
else
88+
sb.AppendLine($"#{endTicks}>{SlideFollowerMarker(seg.Type)}{IntToHex(seg.EndCell)}{IntToHex(seg.EndWidth)}");
89+
}
90+
}
91+
continue;
92+
}
93+
5994
var durTicks = Utils.Tick(n.Duration, RSL);
60-
if (n.Type == "HLD" && durTicks > 0)
95+
if (n.Type is "HLD" or "HXD" && durTicks > 0)
6196
sb.AppendLine($"#{durTicks}>s");
62-
else if (n.Type == "SLD" && durTicks > 0)
63-
sb.AppendLine($"#{durTicks}>s{Hx(n.EndCell)}{Hw(n.EndWidth)}");
97+
else if (n.Type is "AHD" or "AHX" && durTicks > 0)
98+
{
99+
var marker = (n.Type == "AHX") ? 'c' : 's';
100+
sb.AppendLine($"#{durTicks}>{marker}");
101+
}
64102
}
65103
return sb.ToString();
66104
}
67105

68-
private static string UCode(ChuNote n, int tpm)
106+
private static Dictionary<ChuNote, List<ChuNote>> BuildSlideChains(List<ChuNote> notes)
107+
{
108+
var chains = new Dictionary<ChuNote, List<ChuNote>>();
109+
foreach (var n in notes)
110+
{
111+
if (!IsSlideChainNote(n.Type)) continue;
112+
var head = GetSlideHead(n);
113+
if (!chains.TryGetValue(head, out var list))
114+
chains[head] = list = [];
115+
list.Add(n);
116+
}
117+
118+
// Order segments by their end time so follower ticks are increasing.
119+
foreach (var (_, segs) in chains)
120+
{
121+
segs.Sort((a, b) =>
122+
{
123+
var t = a.EndTime.CompareTo(b.EndTime);
124+
if (t != 0) return t;
125+
// stable-ish tie-breakers
126+
t = a.Time.CompareTo(b.Time);
127+
if (t != 0) return t;
128+
t = string.CompareOrdinal(a.Type, b.Type);
129+
if (t != 0) return t;
130+
return 0;
131+
});
132+
}
133+
134+
// For a valid chain, follower ticks should be strictly increasing; if the chart has
135+
// degenerate segments, later code simply skips non-positive offsets.
136+
return chains;
137+
}
138+
139+
private static ChuNote GetSlideHead(ChuNote n)
140+
{
141+
var cur = n;
142+
while (cur.Previous != null && IsSlideChainNote(cur.Previous.Type))
143+
cur = cur.Previous;
144+
return cur;
145+
}
146+
147+
private static bool IsSlideType(string t) => t is "SLD" or "SLC" or "SXD" or "SXC";
148+
private static bool IsAirSlideType(string t) => t is "ASD" or "ASC";
149+
private static bool IsSlideChainNote(string t) => IsSlideType(t) || IsAirSlideType(t);
150+
private static char SlideFollowerMarker(string t) => t is "SLC" or "SXC" or "ASC" ? 'c' : 's';
151+
152+
/// <summary> C2S col.6 / follower height: integer stored as two base-36 digits (Umiguri v8 AIR-SLIDE). </summary>
153+
private static string EncodeUgcAirHeight2(int value)
154+
{
155+
var v = Math.Clamp(value * 10, 0, 35 * 36 + 35);
156+
var hi = v / 36;
157+
var lo = v % 36;
158+
return $"{IntToHex(hi)}{IntToHex(lo)}";
159+
}
160+
161+
private static int AirSlideParentStartHeight(ChuNote head) => 8; // TODO 现在暂时写死,之后应该改成从ExtraData等地方读取
162+
private static int AirSlideFollowerHeight(ChuNote seg) => 8; // TODO 现在暂时写死,之后应该改成从ExtraData等地方读取
163+
164+
private static Dictionary<string, string> AirDirections = new()
69165
{
70-
string c = Hx(n.Cell), w = Hw(n.Width);
71-
var durTicks = Utils.Tick(n.Duration, tpm);
166+
["AIR"] = "UC", ["AUR"] = "UR", ["AUL"] = "UL", ["ADW"] = "DC", ["ADR"] = "DR", ["ADL"] = "DL",
167+
};
168+
private static string UCode(ChuNote n)
169+
{
170+
string c = IntToHex(n.Cell), w = IntToHex(n.Width);
72171
var targetNote = string.IsNullOrEmpty(n.TargetNote) ? "N" : n.TargetNote;
73172
return n.Type switch
74173
{
@@ -79,17 +178,21 @@ private static string UCode(ChuNote n, int tpm)
79178
"SLC" or "SXC" => $"s{c}{w}",
80179
"FLK" => $"f{c}{w}A",
81180
"MNE" => $"d{c}{w}",
82-
"AIR" => $"a{c}{w}UC{targetNote}",
83-
"AUR" => $"a{c}{w}UR{targetNote}",
84-
"AUL" => $"a{c}{w}UL{targetNote}",
85-
"AHD" or "AHX" => $"a{c}{w}HD{targetNote}_{durTicks}",
86-
"ADW" => $"a{c}{w}DC{targetNote}",
87-
"ADR" => $"a{c}{w}DR{targetNote}",
88-
"ADL" => $"a{c}{w}DL{targetNote}",
89-
_ => $"t{c}{w}"
181+
// AIR-SLIDE (v8): #BarTick:S x w hh c
182+
"ASD" or "ASC" => $"S{c}{w}{EncodeUgcAirHeight2(AirSlideParentStartHeight(n))}{AirHoldColorSuffix(n)}",
183+
"AIR" or "AUR" or "AUL" or "ADW" or "ADR" or "ADL" => $"a{c}{w}{AirDirections[n.Type]}{targetNote}{AirHoldColorSuffix(n)}",
184+
// AIR-HOLD (v8): #BarTick:H x w c + 子行 #OffsetTick:s / :c(见 Umiguri Chart v8 doc)
185+
"AHD" or "AHX" => $"H{c}{w}{AirHoldColorSuffix(n)}",
186+
_ => ""
90187
};
91188
}
92189

93-
private static string Hx(int v) => "0123456789ABCDEF"[Math.Clamp(v, 0, 15)].ToString();
94-
private static string Hw(int v) => "123456789ABCDEFG"[Math.Clamp(v - 1, 0, 15)].ToString();
190+
private static string IntToHex(int v) => "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"[Math.Clamp(v, 0, 35)].ToString();
191+
192+
private static readonly Dictionary<string, string> AirColor = new()
193+
{
194+
["DEF"] = "N",
195+
["I"] = "I", // TODO 搞清楚UGC里的'I'颜色,在C2S里,对应的字符串是什么
196+
};
197+
private static string AirHoldColorSuffix(ChuNote n) => AirColor.GetValueOrDefault(n.Tag, "N");
95198
}

parser/chu/UgcParser.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ public class UgcParser: BaseChuParser
3030
["D"] = "DW",
3131
["C"] = "CE",
3232
};
33+
34+
private static readonly Dictionary<string, string> AirColor = new()
35+
{
36+
["N"] = "DEF",
37+
["I"] = "I", // TODO 搞清楚UGC里的'I'颜色,在C2S里,对应的字符串是什么
38+
};
3339

3440
public override (ChuChart, List<Alert>) Parse(string text)
3541
{
@@ -351,8 +357,8 @@ private static int ParseHoldNote(bool isAirHold, string[] lines, int idx, string
351357

352358
if (isAirHold)
353359
{
354-
// 解析颜色数据。目前只解析、不使用。
355-
_ = code.Last(); // var colorChar 颜色标记 N/I
360+
var colorChar = code.Last(); // 颜色标记 N/I
361+
note.Tag = AirColor[colorChar.ToString()];
356362
}
357363

358364
bool foundFirst = false;
@@ -384,11 +390,13 @@ private static int ParseSlideNote(bool isAirSlide, string[] lines, int idx, stri
384390
previousNote.EndCell = previousNote.Cell;
385391
previousNote.EndWidth = previousNote.Width;
386392

393+
string colorTag = "";
387394
if (isAirSlide)
388395
{
389-
// 解析高度和颜色数据。目前只解析、不使用。
396+
var colorChar = code.Last(); // 颜色标记 N/I
397+
colorTag = AirColor[colorChar.ToString()];
398+
// 解析高度数据。目前只解析、不使用。
390399
TryParseUgcBase36Int2(code.AsSpan(3, code.Length - 4), out _); // out var startHeight 起始的高度值
391-
_ = code.Last(); // var colorChar 颜色标记 N/I
392400
}
393401

394402
bool foundFirst = false;
@@ -412,6 +420,8 @@ private static int ParseSlideNote(bool isAirSlide, string[] lines, int idx, stri
412420
EndCell = endCell, EndWidth = endWidth,
413421
Previous = foundFirst ? previousNote : null,
414422
};
423+
if (isAirSlide) note.Tag = colorTag;
424+
415425
chart.Notes.Add(note);
416426
previousNote = note;
417427
idx++;

tests/chu/ChuTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ private static string SnapshotNote(ChuNote note)
5555
{
5656
Rational r => r.CanonicalForm.ToString(),
5757
List<int> list => string.Join(",", list),
58+
string s => s == "DEF" ? "" : s,
5859
null => "",
5960
_ => v.ToString() ?? "",
6061
};

0 commit comments

Comments
 (0)