mirror of
https://github.com/ppy/osu.git
synced 2026-05-16 04:52:35 +08:00
eab02a2aa5
Mostly closes https://github.com/ppy/osu/issues/33505. Compare https://github.com/ppy/osu/blob/97e6187f0d7c3dbee896596a623e34627135bf0e/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs#L56-L59 I say "mostly" here because I'm rather skeptical that this is 100% rock solid still, for one reason - namely that the game stores path control point coordinates relative to the head, then turns them into absolute coordinates when encoding, and then on decoding turns them back into coordinates relative to the head, which in floating-point world is a Bad Idea because of round-off error. But I'm not fixing that without introducing a completely new beatmap format or rewriting half the editor to address that, so I'll just pretend that I don't know any of this until someone notices.
671 lines
29 KiB
C#
671 lines
29 KiB
C#
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
|
// See the LICENCE file in the repository root for full licence text.
|
|
|
|
using osuTK;
|
|
using osu.Game.Rulesets.Objects.Types;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using osu.Game.Beatmaps.Formats;
|
|
using osu.Game.Audio;
|
|
using System.Linq;
|
|
using osu.Framework.Utils;
|
|
using osu.Game.Beatmaps.ControlPoints;
|
|
using osu.Game.Beatmaps.Legacy;
|
|
using osu.Game.Skinning;
|
|
using osu.Game.Utils;
|
|
using System.Buffers;
|
|
|
|
namespace osu.Game.Rulesets.Objects.Legacy
|
|
{
|
|
/// <summary>
|
|
/// A HitObjectParser to parse legacy Beatmaps.
|
|
/// </summary>
|
|
public class ConvertHitObjectParser : HitObjectParser
|
|
{
|
|
/// <summary>
|
|
/// The offset to apply to all time values.
|
|
/// </summary>
|
|
private readonly double offset;
|
|
|
|
/// <summary>
|
|
/// The .osu format (beatmap) version.
|
|
/// </summary>
|
|
private readonly int formatVersion;
|
|
|
|
/// <summary>
|
|
/// Whether the current hitobject is the first hitobject in the beatmap.
|
|
/// </summary>
|
|
private bool firstObject = true;
|
|
|
|
/// <summary>
|
|
/// The last parsed hitobject.
|
|
/// </summary>
|
|
private ConvertHitObject? lastObject;
|
|
|
|
internal ConvertHitObjectParser(double offset, int formatVersion)
|
|
{
|
|
this.offset = offset;
|
|
this.formatVersion = formatVersion;
|
|
}
|
|
|
|
public override HitObject Parse(string text)
|
|
{
|
|
string[] split = text.Split(',');
|
|
|
|
Vector2 pos =
|
|
formatVersion >= LegacyBeatmapEncoder.FIRST_LAZER_VERSION
|
|
? new Vector2(Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE))
|
|
: new Vector2((int)Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE));
|
|
|
|
double startTime = Parsing.ParseDouble(split[2]) + offset;
|
|
|
|
LegacyHitObjectType type = (LegacyHitObjectType)Parsing.ParseInt(split[3]);
|
|
|
|
int comboOffset = (int)(type & LegacyHitObjectType.ComboOffset) >> 4;
|
|
type &= ~LegacyHitObjectType.ComboOffset;
|
|
|
|
bool combo = type.HasFlag(LegacyHitObjectType.NewCombo);
|
|
type &= ~LegacyHitObjectType.NewCombo;
|
|
|
|
var soundType = (LegacyHitSoundType)Parsing.ParseInt(split[4]);
|
|
var bankInfo = new SampleBankInfo();
|
|
|
|
ConvertHitObject? result = null;
|
|
|
|
if (type.HasFlag(LegacyHitObjectType.Circle))
|
|
{
|
|
result = createHitCircle(pos, combo, comboOffset);
|
|
|
|
if (split.Length > 5)
|
|
readCustomSampleBanks(split[5], bankInfo);
|
|
}
|
|
else if (type.HasFlag(LegacyHitObjectType.Slider))
|
|
{
|
|
double? length = null;
|
|
|
|
int repeatCount = Parsing.ParseInt(split[6]);
|
|
|
|
if (repeatCount > 9000)
|
|
throw new FormatException(@"Repeat count is way too high");
|
|
|
|
// osu-stable treated the first span of the slider as a repeat, but no repeats are happening
|
|
repeatCount = Math.Max(0, repeatCount - 1);
|
|
|
|
if (split.Length > 7)
|
|
{
|
|
length = Math.Max(0, Parsing.ParseDouble(split[7], Parsing.MAX_COORDINATE_VALUE));
|
|
if (length == 0)
|
|
length = null;
|
|
}
|
|
|
|
if (split.Length > 10)
|
|
readCustomSampleBanks(split[10], bankInfo, true);
|
|
|
|
// One node for each repeat + the start and end nodes
|
|
int nodes = repeatCount + 2;
|
|
|
|
// Populate node sample bank infos with the default hit object sample bank
|
|
var nodeBankInfos = new List<SampleBankInfo>();
|
|
for (int i = 0; i < nodes; i++)
|
|
nodeBankInfos.Add(bankInfo.Clone());
|
|
|
|
// Read any per-node sample banks
|
|
if (split.Length > 9 && split[9].Length > 0)
|
|
{
|
|
string[] sets = split[9].Split('|');
|
|
|
|
for (int i = 0; i < nodes; i++)
|
|
{
|
|
if (i >= sets.Length)
|
|
break;
|
|
|
|
SampleBankInfo info = nodeBankInfos[i];
|
|
readCustomSampleBanks(sets[i], info);
|
|
}
|
|
}
|
|
|
|
// Populate node sound types with the default hit object sound type
|
|
var nodeSoundTypes = new List<LegacyHitSoundType>();
|
|
for (int i = 0; i < nodes; i++)
|
|
nodeSoundTypes.Add(soundType);
|
|
|
|
// Read any per-node sound types
|
|
if (split.Length > 8 && split[8].Length > 0)
|
|
{
|
|
string[] adds = split[8].Split('|');
|
|
|
|
for (int i = 0; i < nodes; i++)
|
|
{
|
|
if (i >= adds.Length)
|
|
break;
|
|
|
|
int.TryParse(adds[i], out int sound);
|
|
nodeSoundTypes[i] = (LegacyHitSoundType)sound;
|
|
}
|
|
}
|
|
|
|
// Generate the final per-node samples
|
|
var nodeSamples = new List<IList<HitSampleInfo>>(nodes);
|
|
for (int i = 0; i < nodes; i++)
|
|
nodeSamples.Add(convertSoundType(nodeSoundTypes[i], nodeBankInfos[i]));
|
|
|
|
result = createSlider(pos, combo, comboOffset, convertPathString(split[5], pos), length, repeatCount, nodeSamples);
|
|
}
|
|
else if (type.HasFlag(LegacyHitObjectType.Spinner))
|
|
{
|
|
double duration = Math.Max(0, Parsing.ParseDouble(split[5]) + offset - startTime);
|
|
|
|
result = createSpinner(new Vector2(512, 384) / 2, combo, duration);
|
|
|
|
if (split.Length > 6)
|
|
readCustomSampleBanks(split[6], bankInfo);
|
|
}
|
|
else if (type.HasFlag(LegacyHitObjectType.Hold))
|
|
{
|
|
// Note: Hold is generated by BMS converts
|
|
|
|
double endTime = Math.Max(startTime, Parsing.ParseDouble(split[2]));
|
|
|
|
if (split.Length > 5 && !string.IsNullOrEmpty(split[5]))
|
|
{
|
|
string[] ss = split[5].Split(':');
|
|
endTime = Math.Max(startTime, Parsing.ParseDouble(ss[0]));
|
|
readCustomSampleBanks(string.Join(':', ss.Skip(1)), bankInfo);
|
|
}
|
|
|
|
result = createHold(pos, endTime + offset - startTime);
|
|
}
|
|
|
|
if (result == null)
|
|
throw new InvalidDataException($"Unknown hit object type: {split[3]}");
|
|
|
|
result.StartTime = startTime;
|
|
result.LegacyType = type;
|
|
|
|
if (result.Samples.Count == 0)
|
|
result.Samples = convertSoundType(soundType, bankInfo);
|
|
|
|
firstObject = false;
|
|
|
|
return result;
|
|
}
|
|
|
|
private void readCustomSampleBanks(string str, SampleBankInfo bankInfo, bool banksOnly = false)
|
|
{
|
|
if (string.IsNullOrEmpty(str))
|
|
return;
|
|
|
|
string[] split = str.Split(':');
|
|
|
|
var bank = (LegacySampleBank)Parsing.ParseInt(split[0]);
|
|
if (!Enum.IsDefined(bank))
|
|
bank = LegacySampleBank.Normal;
|
|
|
|
var addBank = (LegacySampleBank)Parsing.ParseInt(split[1]);
|
|
if (!Enum.IsDefined(addBank))
|
|
addBank = LegacySampleBank.Normal;
|
|
|
|
string? stringBank = bank.ToString().ToLowerInvariant();
|
|
string? stringAddBank = addBank.ToString().ToLowerInvariant();
|
|
|
|
if (stringBank == @"none")
|
|
stringBank = null;
|
|
|
|
if (stringAddBank == @"none")
|
|
{
|
|
bankInfo.EditorAutoBank = true;
|
|
stringAddBank = null;
|
|
}
|
|
else
|
|
bankInfo.EditorAutoBank = false;
|
|
|
|
bankInfo.BankForNormal = stringBank;
|
|
bankInfo.BankForAdditions = string.IsNullOrEmpty(stringAddBank) ? stringBank : stringAddBank;
|
|
|
|
if (banksOnly) return;
|
|
|
|
if (split.Length > 2)
|
|
bankInfo.CustomSampleBank = Parsing.ParseInt(split[2]);
|
|
|
|
if (split.Length > 3)
|
|
bankInfo.Volume = Math.Max(0, Parsing.ParseInt(split[3]));
|
|
|
|
bankInfo.Filename = split.Length > 4 ? split[4] : null;
|
|
}
|
|
|
|
private PathType convertPathType(string input)
|
|
{
|
|
switch (input[0])
|
|
{
|
|
default:
|
|
case 'C':
|
|
return PathType.CATMULL;
|
|
|
|
case 'B':
|
|
if (input.Length > 1 && int.TryParse(input.AsSpan(1), out int degree) && degree > 0)
|
|
return PathType.BSpline(degree);
|
|
|
|
return PathType.BEZIER;
|
|
|
|
case 'L':
|
|
return PathType.LINEAR;
|
|
|
|
case 'P':
|
|
return PathType.PERFECT_CURVE;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts a given point string into a set of path control points.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A point string takes the form: X|1:1|2:2|2:2|3:3|Y|1:1|2:2.
|
|
/// This has three segments:
|
|
/// <list type="number">
|
|
/// <item>
|
|
/// <description>X: { (1,1), (2,2) } (implicit segment)</description>
|
|
/// </item>
|
|
/// <item>
|
|
/// <description>X: { (2,2), (3,3) } (implicit segment)</description>
|
|
/// </item>
|
|
/// <item>
|
|
/// <description>Y: { (3,3), (1,1), (2, 2) } (explicit segment)</description>
|
|
/// </item>
|
|
/// </list>
|
|
/// </remarks>
|
|
/// <param name="pointString">The point string.</param>
|
|
/// <param name="offset">The positional offset to apply to the control points.</param>
|
|
/// <returns>All control points in the resultant path.</returns>
|
|
private PathControlPoint[] convertPathString(string pointString, Vector2 offset)
|
|
{
|
|
// This code takes on the responsibility of handling explicit segments of the path ("X" & "Y" from above). Implicit segments are handled by calls to convertPoints().
|
|
string[] pointStringSplit = pointString.Split('|');
|
|
|
|
var pointsBuffer = ArrayPool<Vector2>.Shared.Rent(pointStringSplit.Length);
|
|
var segmentsBuffer = ArrayPool<(PathType Type, int StartIndex)>.Shared.Rent(pointStringSplit.Length);
|
|
int currentPointsIndex = 0;
|
|
int currentSegmentsIndex = 0;
|
|
|
|
try
|
|
{
|
|
foreach (string s in pointStringSplit)
|
|
{
|
|
if (char.IsLetter(s[0]))
|
|
{
|
|
// The start of a new segment(indicated by having an alpha character at position 0).
|
|
var pathType = convertPathType(s);
|
|
segmentsBuffer[currentSegmentsIndex++] = (pathType, currentPointsIndex);
|
|
|
|
// First segment is prepended by an extra zero point
|
|
if (currentPointsIndex == 0)
|
|
pointsBuffer[currentPointsIndex++] = Vector2.Zero;
|
|
}
|
|
else
|
|
{
|
|
pointsBuffer[currentPointsIndex++] = readPoint(s, offset);
|
|
}
|
|
}
|
|
|
|
int pointsCount = currentPointsIndex;
|
|
int segmentsCount = currentSegmentsIndex;
|
|
var controlPoints = new List<ArraySegment<PathControlPoint>>(pointsCount);
|
|
var allPoints = new ArraySegment<Vector2>(pointsBuffer, 0, pointsCount);
|
|
|
|
for (int i = 0; i < segmentsCount; i++)
|
|
{
|
|
if (i < segmentsCount - 1)
|
|
{
|
|
int startIndex = segmentsBuffer[i].StartIndex;
|
|
int endIndex = segmentsBuffer[i + 1].StartIndex;
|
|
controlPoints.AddRange(convertPoints(segmentsBuffer[i].Type, allPoints.Slice(startIndex, endIndex - startIndex), pointsBuffer[endIndex]));
|
|
}
|
|
else
|
|
{
|
|
int startIndex = segmentsBuffer[i].StartIndex;
|
|
controlPoints.AddRange(convertPoints(segmentsBuffer[i].Type, allPoints.Slice(startIndex), null));
|
|
}
|
|
}
|
|
|
|
return mergeControlPointsLists(controlPoints);
|
|
}
|
|
finally
|
|
{
|
|
ArrayPool<Vector2>.Shared.Return(pointsBuffer);
|
|
ArrayPool<(PathType, int)>.Shared.Return(segmentsBuffer);
|
|
}
|
|
|
|
Vector2 readPoint(string value, Vector2 startPos)
|
|
{
|
|
string[] vertexSplit = value.Split(':');
|
|
|
|
Vector2 pos = formatVersion >= LegacyBeatmapEncoder.FIRST_LAZER_VERSION
|
|
? new Vector2(Parsing.ParseFloat(vertexSplit[0], Parsing.MAX_COORDINATE_VALUE), Parsing.ParseFloat(vertexSplit[1], Parsing.MAX_COORDINATE_VALUE))
|
|
: new Vector2((int)Parsing.ParseFloat(vertexSplit[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(vertexSplit[1], Parsing.MAX_COORDINATE_VALUE));
|
|
pos -= startPos;
|
|
return pos;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts a given point list into a set of path segments.
|
|
/// </summary>
|
|
/// <param name="type">The path type of the point list.</param>
|
|
/// <param name="points">The point list.</param>
|
|
/// <param name="endPoint">Any extra endpoint to consider as part of the points. This will NOT be returned.</param>
|
|
/// <returns>The set of points contained by <paramref name="points"/> as one or more segments of the path.</returns>
|
|
private IEnumerable<ArraySegment<PathControlPoint>> convertPoints(PathType type, ArraySegment<Vector2> points, Vector2? endPoint)
|
|
{
|
|
var vertices = new PathControlPoint[points.Count];
|
|
|
|
// Parse into control points.
|
|
for (int i = 0; i < points.Count; i++)
|
|
vertices[i] = new PathControlPoint { Position = points[i] };
|
|
|
|
// Edge-case rules (to match stable).
|
|
if (type == PathType.PERFECT_CURVE)
|
|
{
|
|
int endPointLength = endPoint == null ? 0 : 1;
|
|
|
|
if (formatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION)
|
|
{
|
|
if (vertices.Length + endPointLength != 3)
|
|
type = PathType.BEZIER;
|
|
else if (isLinear(points[0], points[1], endPoint ?? points[2]))
|
|
{
|
|
// osu-stable special-cased colinear perfect curves to a linear path
|
|
type = PathType.LINEAR;
|
|
}
|
|
}
|
|
else if (vertices.Length + endPointLength > 3)
|
|
// Lazer supports perfect curves with less than 3 points and colinear points
|
|
type = PathType.BEZIER;
|
|
}
|
|
|
|
// The first control point must have a definite type.
|
|
vertices[0].Type = type;
|
|
|
|
// A path can have multiple implicit segments of the same type if there are two sequential control points with the same position.
|
|
// To handle such cases, this code may return multiple path segments with the final control point in each segment having a non-null type.
|
|
// For the point string X|1:1|2:2|2:2|3:3, this code returns the segments:
|
|
// X: { (1,1), (2, 2) }
|
|
// X: { (3, 3) }
|
|
// Note: (2, 2) is not returned in the second segments, as it is implicit in the path.
|
|
int startIndex = 0;
|
|
int endIndex = 0;
|
|
|
|
while (++endIndex < vertices.Length)
|
|
{
|
|
// Keep incrementing while an implicit segment doesn't need to be started.
|
|
if (vertices[endIndex].Position != vertices[endIndex - 1].Position)
|
|
continue;
|
|
|
|
// Legacy CATMULL sliders don't support multiple segments, so adjacent CATMULL segments should be treated as a single one.
|
|
// Importantly, this is not applied to the first control point, which may duplicate the slider path's position
|
|
// resulting in a duplicate (0,0) control point in the resultant list.
|
|
if (type == PathType.CATMULL && endIndex > 1 && formatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION)
|
|
continue;
|
|
|
|
// The last control point of each segment is not allowed to start a new implicit segment.
|
|
if (endIndex == vertices.Length - 1)
|
|
continue;
|
|
|
|
// Force a type on the last point, and return the current control point set as a segment.
|
|
vertices[endIndex - 1].Type = type;
|
|
yield return new ArraySegment<PathControlPoint>(vertices, startIndex, endIndex - startIndex);
|
|
|
|
// Skip the current control point - as it's the same as the one that's just been returned.
|
|
startIndex = endIndex + 1;
|
|
}
|
|
|
|
if (startIndex < endIndex)
|
|
yield return new ArraySegment<PathControlPoint>(vertices, startIndex, endIndex - startIndex);
|
|
|
|
static bool isLinear(Vector2 p0, Vector2 p1, Vector2 p2)
|
|
=> Precision.AlmostEquals(0, (p1.Y - p0.Y) * (p2.X - p0.X)
|
|
- (p1.X - p0.X) * (p2.Y - p0.Y));
|
|
}
|
|
|
|
private PathControlPoint[] mergeControlPointsLists(List<ArraySegment<PathControlPoint>> controlPointList)
|
|
{
|
|
int totalCount = 0;
|
|
|
|
foreach (var arr in controlPointList)
|
|
totalCount += arr.Count;
|
|
|
|
var mergedArray = new PathControlPoint[totalCount];
|
|
int copyIndex = 0;
|
|
|
|
foreach (var arr in controlPointList)
|
|
{
|
|
arr.AsSpan().CopyTo(mergedArray.AsSpan(copyIndex));
|
|
copyIndex += arr.Count;
|
|
}
|
|
|
|
return mergedArray;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a legacy Hit-type hit object.
|
|
/// </summary>
|
|
/// <param name="position">The position of the hit object.</param>
|
|
/// <param name="newCombo">Whether the hit object creates a new combo.</param>
|
|
/// <param name="comboOffset">When starting a new combo, the offset of the new combo relative to the current one.</param>
|
|
/// <returns>The hit object.</returns>
|
|
private ConvertHitObject createHitCircle(Vector2 position, bool newCombo, int comboOffset)
|
|
{
|
|
return lastObject = new ConvertHitCircle
|
|
{
|
|
Position = position,
|
|
NewCombo = firstObject || lastObject is ConvertSpinner || newCombo,
|
|
ComboOffset = newCombo ? comboOffset : 0
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creats a legacy Slider-type hit object.
|
|
/// </summary>
|
|
/// <param name="position">The position of the hit object.</param>
|
|
/// <param name="newCombo">Whether the hit object creates a new combo.</param>
|
|
/// <param name="comboOffset">When starting a new combo, the offset of the new combo relative to the current one.</param>
|
|
/// <param name="controlPoints">The slider control points.</param>
|
|
/// <param name="length">The slider length.</param>
|
|
/// <param name="repeatCount">The slider repeat count.</param>
|
|
/// <param name="nodeSamples">The samples to be played when the slider nodes are hit. This includes the head and tail of the slider.</param>
|
|
/// <returns>The hit object.</returns>
|
|
private ConvertHitObject createSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount,
|
|
IList<IList<HitSampleInfo>> nodeSamples)
|
|
{
|
|
return lastObject = new ConvertSlider
|
|
{
|
|
Position = position,
|
|
NewCombo = firstObject || lastObject is ConvertSpinner || newCombo,
|
|
ComboOffset = newCombo ? comboOffset : 0,
|
|
Path = new SliderPath(controlPoints, length),
|
|
NodeSamples = nodeSamples,
|
|
RepeatCount = repeatCount
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a legacy Spinner-type hit object.
|
|
/// </summary>
|
|
/// <param name="position">The position of the hit object.</param>
|
|
/// <param name="newCombo">Whether the hit object creates a new combo.</param>
|
|
/// <param name="duration">The spinner duration.</param>
|
|
/// <returns>The hit object.</returns>
|
|
private ConvertHitObject createSpinner(Vector2 position, bool newCombo, double duration)
|
|
{
|
|
return lastObject = new ConvertSpinner
|
|
{
|
|
Position = position,
|
|
Duration = duration,
|
|
NewCombo = newCombo
|
|
// Spinners cannot have combo offset.
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a legacy Hold-type hit object.
|
|
/// </summary>
|
|
/// <param name="position">The position of the hit object.</param>
|
|
/// <param name="duration">The hold duration.</param>
|
|
private ConvertHitObject createHold(Vector2 position, double duration)
|
|
{
|
|
return lastObject = new ConvertHold
|
|
{
|
|
Position = position,
|
|
Duration = duration
|
|
};
|
|
}
|
|
|
|
private List<HitSampleInfo> convertSoundType(LegacyHitSoundType type, SampleBankInfo bankInfo)
|
|
{
|
|
var soundTypes = new List<HitSampleInfo>();
|
|
|
|
if (string.IsNullOrEmpty(bankInfo.Filename))
|
|
{
|
|
soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.BankForNormal, bankInfo.Volume, true, bankInfo.CustomSampleBank,
|
|
// if the sound type doesn't have the Normal flag set, attach it anyway as a layered sample.
|
|
// None also counts as a normal non-layered sample: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds
|
|
type != LegacyHitSoundType.None && !type.HasFlag(LegacyHitSoundType.Normal)));
|
|
}
|
|
else
|
|
{
|
|
// Todo: This should set the normal SampleInfo if the specified sample file isn't found, but that's a pretty edge-case scenario
|
|
soundTypes.Add(new FileHitSampleInfo(bankInfo.Filename, bankInfo.Volume));
|
|
}
|
|
|
|
if (type.HasFlag(LegacyHitSoundType.Finish))
|
|
soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank));
|
|
|
|
if (type.HasFlag(LegacyHitSoundType.Whistle))
|
|
soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank));
|
|
|
|
if (type.HasFlag(LegacyHitSoundType.Clap))
|
|
soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank));
|
|
|
|
return soundTypes;
|
|
}
|
|
|
|
private class SampleBankInfo
|
|
{
|
|
/// <summary>
|
|
/// An optional overriding filename which causes all bank/sample specifications to be ignored.
|
|
/// </summary>
|
|
public string? Filename;
|
|
|
|
/// <summary>
|
|
/// The bank identifier to use for the base ("hitnormal") sample.
|
|
/// Transferred to <see cref="HitSampleInfo.Bank"/> when appropriate.
|
|
/// </summary>
|
|
public string? BankForNormal;
|
|
|
|
/// <summary>
|
|
/// The bank identifier to use for additions ("hitwhistle", "hitfinish", "hitclap").
|
|
/// Transferred to <see cref="HitSampleInfo.Bank"/> when appropriate.
|
|
/// </summary>
|
|
public string? BankForAdditions;
|
|
|
|
/// <summary>
|
|
/// Hit sample volume (0-100).
|
|
/// See <see cref="HitSampleInfo.Volume"/>.
|
|
/// </summary>
|
|
public int Volume;
|
|
|
|
/// <summary>
|
|
/// The index of the custom sample bank. Is only used if 2 or above for "reasons".
|
|
/// This will add a suffix to lookups, allowing extended bank lookups (ie. "normal-hitnormal-2").
|
|
/// See <see cref="HitSampleInfo.Suffix"/>.
|
|
/// </summary>
|
|
public int CustomSampleBank;
|
|
|
|
/// <summary>
|
|
/// Whether the bank for additions should be inherited from the normal sample in edit.
|
|
/// </summary>
|
|
public bool EditorAutoBank = true;
|
|
|
|
public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone();
|
|
}
|
|
|
|
public class LegacyHitSampleInfo : HitSampleInfo, IEquatable<LegacyHitSampleInfo>
|
|
{
|
|
public readonly int CustomSampleBank;
|
|
|
|
/// <summary>
|
|
/// Whether this hit sample is layered.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Layered hit samples are automatically added in all modes (except osu!mania), but can be disabled
|
|
/// using the <see cref="SkinConfiguration.LegacySetting.LayeredHitSounds"/> skin config option.
|
|
/// </remarks>
|
|
public readonly bool IsLayered;
|
|
|
|
/// <summary>
|
|
/// Whether a bank was specified locally to the relevant hitobject.
|
|
/// If <c>false</c>, a bank will be retrieved from the closest control point.
|
|
/// </summary>
|
|
public bool BankSpecified;
|
|
|
|
public LegacyHitSampleInfo(string name, string? bank = null, int volume = 0, bool editorAutoBank = false, int customSampleBank = 0, bool isLayered = false)
|
|
: base(name, bank ?? SampleControlPoint.DEFAULT_BANK, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume, editorAutoBank)
|
|
{
|
|
CustomSampleBank = customSampleBank;
|
|
BankSpecified = !string.IsNullOrEmpty(bank);
|
|
IsLayered = isLayered;
|
|
}
|
|
|
|
public sealed override HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default,
|
|
Optional<bool> newEditorAutoBank = default)
|
|
=> With(newName, newBank, newVolume, newEditorAutoBank);
|
|
|
|
public virtual LegacyHitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<int> newVolume = default,
|
|
Optional<bool> newEditorAutoBank = default, Optional<int> newCustomSampleBank = default, Optional<bool> newIsLayered = default)
|
|
=> new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newEditorAutoBank.GetOr(EditorAutoBank), newCustomSampleBank.GetOr(CustomSampleBank),
|
|
newIsLayered.GetOr(IsLayered));
|
|
|
|
public bool Equals(LegacyHitSampleInfo? other)
|
|
// The additions to equality checks here are *required* to ensure that pooling works correctly.
|
|
// Of note, `IsLayered` may cause the usage of `SampleVirtual` instead of an actual sample (in cases playback is not required).
|
|
// Removing it would cause samples which may actually require playback to potentially source for a `SampleVirtual` sample pool.
|
|
=> base.Equals(other) && CustomSampleBank == other.CustomSampleBank && IsLayered == other.IsLayered;
|
|
|
|
public override bool Equals(object? obj)
|
|
=> obj is LegacyHitSampleInfo other && Equals(other);
|
|
|
|
public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), CustomSampleBank, IsLayered);
|
|
}
|
|
|
|
private class FileHitSampleInfo : LegacyHitSampleInfo, IEquatable<FileHitSampleInfo>
|
|
{
|
|
public readonly string Filename;
|
|
|
|
public FileHitSampleInfo(string filename, int volume)
|
|
// Force CSS=1 to make sure that the LegacyBeatmapSkin does not fall back to the user skin.
|
|
// Note that this does not change the lookup names, as they are overridden locally.
|
|
: base(string.Empty, customSampleBank: 1, volume: volume)
|
|
{
|
|
Filename = filename;
|
|
}
|
|
|
|
public override IEnumerable<string> LookupNames => new[]
|
|
{
|
|
Filename,
|
|
Path.ChangeExtension(Filename, null)
|
|
};
|
|
|
|
public sealed override LegacyHitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<int> newVolume = default,
|
|
Optional<bool> newEditorAutoBank = default, Optional<int> newCustomSampleBank = default, Optional<bool> newIsLayered = default)
|
|
=> new FileHitSampleInfo(Filename, newVolume.GetOr(Volume));
|
|
|
|
public bool Equals(FileHitSampleInfo? other)
|
|
=> base.Equals(other) && Filename == other.Filename;
|
|
|
|
public override bool Equals(object? obj)
|
|
=> obj is FileHitSampleInfo other && Equals(other);
|
|
|
|
public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), Filename);
|
|
}
|
|
}
|
|
}
|