diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c45ee73..fbae4a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,22 +40,23 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Checkout pinned MajSimai + - name: Checkout MajSimai release tag uses: actions/checkout@v4 with: repository: TeamMajdata/MajSimai - ref: fdb2a3e39d8997a0abbf8b4679062d854473cc77 + # Keep this tag aligned with the NuGet version in MajRadar.csproj. + ref: refs/tags/2.2.3 path: external/MajSimai - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - - name: Restore with pinned MajSimai + - name: Restore with MajSimai release tag run: >- dotnet restore MajRadar.slnx -p:MajSimaiProject="$GITHUB_WORKSPACE/external/MajSimai/MajSimai.csproj" - - name: Test with pinned MajSimai + - name: Test with MajSimai release tag run: >- dotnet test MajRadar.slnx --no-restore -c Release -p:MajSimaiProject="$GITHUB_WORKSPACE/external/MajSimai/MajSimai.csproj" diff --git a/Documentation~/FallbackGeometry.md b/Documentation~/FallbackGeometry.md new file mode 100644 index 0000000..5c10977 --- /dev/null +++ b/Documentation~/FallbackGeometry.md @@ -0,0 +1,69 @@ +# Fallback extended-Slide geometry + +`new RadarRuntime()` and `new MajSimaiChartAdapter()` select +`DefaultExtendedSlideBarCountProvider.Instance` when the optional provider is +null. An explicitly supplied provider is authoritative, including its exceptions +and non-positive results. Standard Slides continue using the existing lookup +table and never call the extended-Slide provider. + +## Source and maintenance + +The geometry snapshot is adapted from TeamMajdata/MajdataPlay commit +`acd295d3f878f53ecd412d150936a465ed2e3d28` (GPL-3.0): + +https://github.com/TeamMajdata/MajdataPlay/tree/acd295d3f878f53ecd412d150936a465ed2e3d28/Assets/Scripts/Scenes/Game/Misc/Parsing + +`Runtime/MajSimaiAdapter/FallbackGeometry/` retains the pure `System.Numerics` +path parser, circles, path constructor and segments. Namespaces and top-level +visibility are changed to keep these implementation types internal. No Unity +types, assets, rendering or judgement-area lookup are needed. + +The provider follows `SlideDataBuilder.BuildArrowData(path).Length - 2` but only +counts placements: it does not allocate the arrow coordinate array. It preserves +the exact segment transition order, floating-point operations, default spacing, +`SmoothAlign` and `ForceAlign` rules. A total-length/divisor approximation would +change counts. Each call owns its path because alignment mutates segment spacing. +Invalid or non-advancing geometry throws and is converted into the existing +structured adaptation error at the public analysis boundary. + +The provider is synchronous, like the host-provider interface. Cancellation does +not interrupt a call midway; hosts should bound input sizes and analysis +concurrency. The fallback adds no process-wide result cache. + +Persisted analysis cache keys should include the MajRadar and MajSimai package +versions plus `DefaultExtendedSlideBarCountProvider.GeometryVersion`. When +updating this snapshot, change the geometry version and review the geometry +regressions against the upstream change. An injected host provider should +use its own geometry version instead. + +## Focused regression tests + +`DefaultExtendedSlideBarCountProviderTests` keeps 13 representative Play counts +covering A/B/C nodes, clockwise/counterclockwise orbits, repeated circles, +tangent transfers, and both alignment markers. Default selection, explicit +provider precedence, provider failures, malformed geometry and concurrent path +ownership are tested separately. Run them with `dotnet test MajRadar.slnx`. + +The previous 5,757-row snapshot and its generator were removed: the broad grid +duplicated the same path rules and encoded macOS floating-point boundary choices +as universal integer answers. .NET trigonometric operations use the native C +runtime and may differ across operating systems and architectures: + +https://learn.microsoft.com/en-us/dotnet/api/system.math.sin#remarks + +Four SmoothAlign paths (`8Q69K4`, `4P39K1`, `8Q69K8`, `1Q39K4`) end within a few +ULPs of a segment boundary or endpoint. Play's retained strict comparisons may +therefore include one additional arrow sample. Their tests explicitly allow +the two observed macOS/Ubuntu counts; other representative cases still require +exact values. Concurrency tests compare with sequential results from the same +process. The runtime algorithm and `GeometryVersion` are unchanged. + +The fallback follows the pinned Play algorithm on the running platform; it does +not promise bit-identical arrow counts across all platforms. Making those counts +platform-independent would require a shared numerical policy in Play and MajRadar, +not a test-only change. + +MajSimai 2.2.3 is required for end-to-end parsing: version 2.2.2 misclassifies +some extended paths containing A/B/C, even when the geometry provider is correct. +CI checks out the `2.2.3` release tag (`refs/tags/2.2.3`), aligned with the +NuGet dependency in `MajRadar.csproj`. This tag includes the same parser fix. diff --git a/Documentation~/MajdataPlay.md b/Documentation~/MajdataPlay.md index 60f5860..6426569 100644 --- a/Documentation~/MajdataPlay.md +++ b/Documentation~/MajdataPlay.md @@ -6,7 +6,10 @@ MajSimai assembly; the NuGet package is not installed into the Unity project. Extended `K` Slides are resolved through `IExtendedSlideBarCountProvider`. The sample provider calls Play's existing `SlideCodeParser` and `SlideDataBuilder`, -so MajRadar does not copy or depend on gameplay geometry. +so Play's current gameplay geometry always takes precedence over MajRadar's +built-in fallback. Hosts without a provider use a pinned Unity-free geometry +copy; see [FallbackGeometry.md](FallbackGeometry.md). An injected provider's +failure remains a failure and is never retried using the fallback. The sample `ChartRadarService` is a static Play-only facade. It owns one thread-safe `RadarRuntime` with the single supported provider, while each UI diff --git a/MajRadar.csproj b/MajRadar.csproj index ed9aac4..9bd538d 100644 --- a/MajRadar.csproj +++ b/MajRadar.csproj @@ -21,7 +21,7 @@ - + diff --git a/README.md b/README.md index 7cf53f1..0f8002b 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,9 @@ RadarResult result = await _runtime.ParseAndAnalyzeAsync( ### Slidecode 依赖 -扩展Slidecode的长度算法需要依赖Play自身的Parser。 +未注入 provider 时,MajRadar 使用内置的 `DefaultExtendedSlideBarCountProvider`, +它来自固定版本的 Play 纯 .NET 几何实现,后端无需安装 Unity。 +Play 仍应显式注入自己的 provider,以游戏当前使用的几何为准: ```csharp internal sealed class PlayExtendedSlideBarCountProvider @@ -72,8 +74,14 @@ internal sealed class PlayExtendedSlideBarCountProvider } ``` provider只接受SlideCode,例如 `1P6K7`,并且必须返回正数 arrow/bar count。 -provider 抛出的异常或非正结果会变成结构化适配失败。未提供 provider 时,普通谱面仍可 -正常分析;遇到 `K` Slide 会返回错误 +provider 抛出的异常或非正结果会变成结构化适配失败,不会再次尝试备用实现。 +只有构造时未提供 provider(或传入 `null`)才选择内置实现;普通 Slide 不受影响。 +备用实现无共享的单谱状态,可并发调用。数据库缓存版本应包含 +`DefaultExtendedSlideBarCountProvider.GeometryVersion`;宿主 provider 使用自己的版本标识。 +几何来源和对照测试见 [备用几何说明](Documentation~/FallbackGeometry.md)。 + +NuGet 依赖最低 MajSimai 2.2.3,其中修复了 `1A3P9K5` 等带 A/B/C 路径的 +Slidecode 被误识别为 Touch 的问题。 ### 取消与选歌状态 @@ -110,6 +118,8 @@ else if (!result.IsCancelled) - `RawValues` 和 `Scores` 始终保持公开形状:六个雷达轴加 `fitted_constant`;不可用项为 `null`。 - 只有七维 raw 全部成功时才生成 `FittedConstant` 和映射后的 score。 +- 拟合输出在模块内部限位;上下限由 `RegressionBetaModel` 的 + `MinimumFittedConstant` / `MaximumFittedConstant` 常量配置,当前为 0~18。 - `partial` 会保留已完成的特征,但不会生成拟合定数。 @@ -196,8 +206,10 @@ continues to own files, song metadata, chart type, artwork, and audio offsets. ### Extended Slide dependency -Extended `K` Slides need gameplay geometry supplied by the host. MajRadar keeps -the dependency narrow and does not reference MajdataPlay: +When no provider is supplied, extended `K` Slides use the built-in +`DefaultExtendedSlideBarCountProvider`, a pinned, Unity-free copy of Play's +geometry. Play should still inject its own provider so its current gameplay +geometry remains authoritative: ```csharp internal sealed class PlayExtendedSlideBarCountProvider @@ -213,8 +225,16 @@ internal sealed class PlayExtendedSlideBarCountProvider The provider receives a normalized SlideCode such as `1P6K7`. It must return a positive arrow/bar count. Provider exceptions and non-positive results become a -structured adaptation failure. Without a provider, ordinary charts still work, -while a chart containing a `K` Slide returns an error instead of guessing. +structured adaptation failure; they never trigger a retry through the fallback. +The fallback is selected only when the constructor receives no provider or +`null`. It creates a fresh path per call and can be shared concurrently. +Include `DefaultExtendedSlideBarCountProvider.GeometryVersion` in persistent +cache identities, or your host provider's own version when overriding it. +See [fallback geometry](Documentation~/FallbackGeometry.md) for provenance and +the focused geometry and provider regression tests. + +NuGet requires MajSimai 2.2.3 or newer. That release fixes Slidecodes containing +A/B/C path commands, such as `1A3P9K5`, being misclassified as Touch notes. Ordinary Slides, feature parameters, regression coefficients, and score mapping are built in and are not dependency-injected. @@ -266,6 +286,8 @@ else if (!result.IsCancelled) axes plus `fitted_constant`; unavailable entries are `null`. - `FittedConstant` and mapped scores are produced only when all seven raw features succeed. +- Predictions are clamped inside the module using `MinimumFittedConstant` and + `MaximumFittedConstant` in `RegressionBetaModel` (currently 0 and 18). - `partial` preserves completed feature results but does not produce a fitted constant. - `fitted_constant` is identity-mapped and is not on the radar axes' 0-250 diff --git a/Runtime/MajSimaiAdapter/DefaultExtendedSlideBarCountProvider.cs b/Runtime/MajSimaiAdapter/DefaultExtendedSlideBarCountProvider.cs new file mode 100644 index 0000000..1e638e5 --- /dev/null +++ b/Runtime/MajSimaiAdapter/DefaultExtendedSlideBarCountProvider.cs @@ -0,0 +1,74 @@ +using System; +using MajRadar.MajSimaiAdapter.FallbackGeometry; +using MajRadar.MajSimaiAdapter.FallbackGeometry.Slide; + +namespace MajRadar.MajSimaiAdapter; + +/// +/// Unity-free fallback for hosts without their own extended-Slide geometry. +/// Counts arrows using a pinned copy of MajdataPlay's path and alignment rules. +/// Each call creates its own path, so the shared instance is thread-safe. +/// +public sealed class DefaultExtendedSlideBarCountProvider : IExtendedSlideBarCountProvider +{ + /// Include this identifier in persistent analysis cache versions. + public const string GeometryVersion = "play-acd295d-count-v1"; + + public static DefaultExtendedSlideBarCountProvider Instance { get; } = new(); + + public int ResolveBarCount(string slideCode) + { + if (slideCode is null) + throw new ArgumentNullException(nameof(slideCode)); + if (slideCode.Length < 3 || slideCode[0] is < '1' or > '8' || + slideCode[^2] != 'K' || slideCode[^1] is < '1' or > '8') + throw new ArgumentException("Expected a normalized extended Slide code, such as 1P6K7.", nameof(slideCode)); + + var path = SlideCodeParser.Parse(slideCode); + var totalLength = path.GetPathLength(); + if (!IsFinite(totalLength) || totalLength <= 0) + throw new ArgumentException("Extended Slide path must have a finite positive length.", nameof(slideCode)); + + var currentLength = 0.0; + var segmentIndex = 0; + var samples = 0; + while (currentLength < totalLength) + { + samples = checked(samples + 1); + var segment = path.Segments[segmentIndex]; + var nextLength = currentLength + segment.ArrowDistance; + + // Keep the single-segment transition and floating-point operation order + // from Play's BuildArrowData. Rounding the total length is not equivalent. + if (segmentIndex < path.Segments.Length - 1 && + nextLength >= path.AccumulatedLengths[segmentIndex]) + { + var nextSegment = path.Segments[segmentIndex + 1]; + if (nextSegment.ParseMarker == SlideParseMarker.SmoothAlign) + { + var delta = path.AccumulatedLengths[segmentIndex + 1] - currentLength; + var n = Math.Round(delta / SlideGeo.DefaultDistance); + nextSegment.SetArrowDistance(delta / n); + nextLength = currentLength + delta / n; + } + if (segment.ParseMarker == SlideParseMarker.ForceAlign) + nextLength = path.AccumulatedLengths[segmentIndex] + nextSegment.ArrowDistance; + segmentIndex++; + } + + // Invalid geometry must fail rather than stall a server worker forever. + if (!IsFinite(nextLength) || nextLength <= currentLength) + throw new ArgumentException("Extended Slide arrow placement must advance by a finite positive distance.", nameof(slideCode)); + currentLength = nextLength; + } + + // BuildArrowData includes the starting sample and appends the endpoint. + // Its Length - 2 therefore equals the number of loop samples minus one. + var count = samples - 1; + if (count <= 0) + throw new ArgumentException("Extended Slide path has no arrows.", nameof(slideCode)); + return count; + } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); +} diff --git a/Runtime/MajSimaiAdapter/FallbackGeometry/ComplexCircle.cs b/Runtime/MajSimaiAdapter/FallbackGeometry/ComplexCircle.cs new file mode 100644 index 0000000..14854af --- /dev/null +++ b/Runtime/MajSimaiAdapter/FallbackGeometry/ComplexCircle.cs @@ -0,0 +1,21 @@ +// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28. +// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests. +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Text; + +namespace MajRadar.MajSimaiAdapter.FallbackGeometry +{ + internal readonly struct ComplexCircle + { + public readonly Complex Center; + public readonly double Radius; + + public ComplexCircle(Complex center, double radius) + { + Center = center; + Radius = radius; + } + } +} diff --git a/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/ParametricSlidePath.cs b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/ParametricSlidePath.cs new file mode 100644 index 0000000..70fe5bf --- /dev/null +++ b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/ParametricSlidePath.cs @@ -0,0 +1,177 @@ +// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28. +// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests. +using MajRadar.MajSimaiAdapter.FallbackGeometry.Slide.Segments; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text; + +namespace MajRadar.MajSimaiAdapter.FallbackGeometry.Slide +{ + + /// + ///

参数化的 slide 路径曲线

+ ///
+ internal class ParametricSlidePath + { + /// + ///

组成这条路径的所有片段

+ ///
+ public readonly PathSegment[] Segments; + + /// + ///

片段之间的切割点,Fractions[i]是第 i 个片段的终点的 t 值

+ ///
+ public readonly double[] Fractions; + + /// + ///

片段累积长度,AccumulatedLengths[i]是到第 i 个片段的终点为止经过的总长度

+ ///
+ public readonly double[] AccumulatedLengths; + + public ParametricSlidePath(IEnumerable pathSegments) + { + Segments = pathSegments.ToArray(); + if (Segments.Length == 0) + { + throw new ArgumentException("At least one path segment is required."); + } + + var lengths = Segments.Select(s => s.GetSegmentLength()); + var sum = 0.0; + AccumulatedLengths = lengths.Select(x => (sum += x)).ToArray(); + Fractions = AccumulatedLengths.Select(x => x / sum).ToArray(); + } + + /// + ///

获取 t 位置所在的片段

+ ///
+ /// 0 ~ 1 (both inclusive) + /// 这个点在返回片段上的 t 值,0 ~ 1 (both inclusive) + /// 片段对象 + public PathSegment GetSegmentAt(double t, out double segmentT) + { + if (t <= 0.0) + { + segmentT = 0.0; + return Segments[0]; + } + + if (t >= 1.0) + { + segmentT = 1.0; + return Segments[^1]; + } + + // The index of the specified value in the specified array, if value is found; + // otherwise, a negative number. + // + // If value is not found and value is less than one or more elements in array, + // the negative number returned is the bitwise complement of the index of the first element + // that is larger than value. + // + // If value is not found and value is greater than all elements in array, + // the negative number returned is the bitwise complement of (the index of the last element plus 1). + var idx = Array.BinarySearch(Fractions, t); + + if (idx < 0) + { + // t 不在 Fractions 里,此时 idx 的按位取反是 第一个比 t 大的元素 的索引 + idx = ~idx; + } + // 如果 idx >= 0 说明 t 在 Fractions 里,此时 idx 就是 t 的索引 + // 所以无论怎样都有 Fractions[idx-1] < t && Fractions[idx] >= t + // Note: Fractions[i] 是 Segments[i] 的终点 + + if (idx >= Segments.Length) + { + // t 超出了最后一段的终点,按理来说这不可能,因为越界情况已经处理掉了 + // 但可能会有舍入误差什么的…… + segmentT = 1.0; + return Segments[^1]; + } + + if (idx == 0) + { + segmentT = t / Fractions[0]; + return Segments[0]; + } + + segmentT = (t - Fractions[idx - 1]) / (Fractions[idx] - Fractions[idx - 1]); + return Segments[idx]; + } + + /// + ///

计算路径总长

+ ///
+ public double GetPathLength() => AccumulatedLengths[^1]; + + /// + /// 是否关于“A1 - C - A5”反射路径 + /// + public bool Reflected { get; private set; } = false; + /// + /// 顺时针旋转路径,多少个 45 度 + /// + public int Rotated45CW { get; private set; } = 0; + + private Complex _rotor = Complex.One; + + /// + /// 反射并旋转整条路径,先反射再旋转 + /// + /// 是否关于“A1 - C - A5”反射 + /// 顺时针旋转多少个 45 度,0~7 + public void SetRotoreflection(bool reflect, int rotate45CW) + { + Rotated45CW = rotate45CW; + Reflected = reflect; + var angle = reflect ? (135 - rotate45CW * 45) : (-rotate45CW * 45); + _rotor = Complex.FromPolarCoordinates(1, angle / 180.0 * Math.PI); + } + + private Complex CalcRotoreflection(Complex z) + { + var x = Reflected ? Complex.Conjugate(z) : z; + return x * _rotor; + } + + /// + ///

计算路径上某点的坐标,保证均匀插值

+ ///
+ /// 0 ~ 1 (both inclusive) + public virtual Complex GetPointAt(double t) + { + var segment = GetSegmentAt(t, out var segT); + return CalcRotoreflection(segment.GetPointAt(segT)); + } + + /// + ///

计算路径上某点处的有向切线,方向为路径前进方向

+ ///
+ /// 0 ~ 1 (both inclusive) + /// complex (magnitude = 1) + public virtual Complex GetTangentAt(double t) + { + var segment = GetSegmentAt(t, out var segT); + return CalcRotoreflection(segment.GetTangentAt(segT)); + } + + public virtual SlideEndShape GetEndShape() + { + var lastSegment = Segments[^1]; + if (lastSegment is CircleSegment circle) + { + return circle.IsCcw != Reflected ? SlideEndShape.CircleCCW : SlideEndShape.CircleCW; + } + + if (lastSegment is ArcSegment arc) + { + return (arc.EndRadian > arc.StartRadian) != Reflected ? SlideEndShape.CircleCCW : SlideEndShape.CircleCW; + } + + return SlideEndShape.Straight; + } + } +} diff --git a/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/Segments/ArcSegment.cs b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/Segments/ArcSegment.cs new file mode 100644 index 0000000..c4cc6ff --- /dev/null +++ b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/Segments/ArcSegment.cs @@ -0,0 +1,51 @@ +// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28. +// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests. +using MajRadar.MajSimaiAdapter.FallbackGeometry; +using System; +using System.Numerics; + +namespace MajRadar.MajSimaiAdapter.FallbackGeometry.Slide.Segments +{ + /// + ///

slide 圆弧片段,对角度进行线性插值,不会自动扣除一整圈

+ ///
+ internal class ArcSegment : PathSegment + { + public readonly ComplexCircle Circle; + public readonly double StartRadian; + public readonly double EndRadian; + + public ArcSegment(ComplexCircle circle, double startRadian, double endRadian) + { + Circle = circle; + StartRadian = startRadian; + EndRadian = endRadian; + } + + public override bool IsCurve { get; } = true; + + public override Complex GetPointAt(double t) + { + var angle = StartRadian + t * (EndRadian - StartRadian); + return Circle.Center + Complex.FromPolarCoordinates(Circle.Radius, angle); + } + + public override Complex GetTangentAt(double t) + { + var angle = StartRadian + t * (EndRadian - StartRadian); + if (StartRadian < EndRadian) + { + return Complex.FromPolarCoordinates(1, angle) * Complex.ImaginaryOne; + } + else + { + return Complex.FromPolarCoordinates(-1, angle) * Complex.ImaginaryOne; + } + } + + public override double GetSegmentLength() + { + return Math.Abs(EndRadian - StartRadian) * Circle.Radius; + } + } +} diff --git a/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/Segments/CircleSegment.cs b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/Segments/CircleSegment.cs new file mode 100644 index 0000000..be6b699 --- /dev/null +++ b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/Segments/CircleSegment.cs @@ -0,0 +1,62 @@ +// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28. +// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests. +using MajRadar.MajSimaiAdapter.FallbackGeometry; +using System; +using System.Numerics; + +namespace MajRadar.MajSimaiAdapter.FallbackGeometry.Slide.Segments +{ + /// + ///

slide 圆周片段,总之就是转一整圈

+ ///
+ internal class CircleSegment : PathSegment + { + public readonly ComplexCircle Circle; + public readonly double StartRadian; + public readonly bool IsCcw; + + public CircleSegment(ComplexCircle circle, double startRadian, bool isCcw) + { + Circle = circle; + StartRadian = startRadian; + IsCcw = isCcw; + } + + public override bool IsCurve { get; } = true; + + public override Complex GetPointAt(double t) + { + double angle; + if (IsCcw) + { + angle = StartRadian + t * Math.PI * 2.0; + } + else + { + angle = StartRadian - t * Math.PI * 2.0; + } + + return Circle.Center + Complex.FromPolarCoordinates(Circle.Radius, angle); + } + + public override Complex GetTangentAt(double t) + { + double angle; + if (IsCcw) + { + angle = StartRadian + t * Math.PI * 2.0; + return Complex.FromPolarCoordinates(1, angle) * Complex.ImaginaryOne; + } + else + { + angle = StartRadian - t * Math.PI * 2.0; + return Complex.FromPolarCoordinates(-1, angle) * Complex.ImaginaryOne; + } + } + + public override double GetSegmentLength() + { + return Math.PI * Circle.Radius * 2.0; + } + } +} diff --git a/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/Segments/LineSegment.cs b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/Segments/LineSegment.cs new file mode 100644 index 0000000..3f1f5bb --- /dev/null +++ b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/Segments/LineSegment.cs @@ -0,0 +1,39 @@ +// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28. +// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests. +using System.Numerics; + +namespace MajRadar.MajSimaiAdapter.FallbackGeometry.Slide.Segments +{ + /// + ///

slide 直线片段

+ ///
+ internal class LineSegment : PathSegment + { + public readonly Complex StartPoint; + public readonly Complex EndPoint; + + public LineSegment(Complex start, Complex end) + { + StartPoint = start; + EndPoint = end; + } + + public override bool IsCurve { get; } = false; + + public override Complex GetPointAt(double t) + { + return StartPoint + (EndPoint - StartPoint) * t; + } + + public override Complex GetTangentAt(double t) + { + var v = EndPoint - StartPoint; + return v / v.Magnitude; + } + + public override double GetSegmentLength() + { + return (EndPoint - StartPoint).Magnitude; + } + } +} diff --git a/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/Segments/PathSegment.cs b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/Segments/PathSegment.cs new file mode 100644 index 0000000..6d32455 --- /dev/null +++ b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/Segments/PathSegment.cs @@ -0,0 +1,55 @@ +// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28. +// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests. +using MajRadar.MajSimaiAdapter.FallbackGeometry; +using MajRadar.MajSimaiAdapter.FallbackGeometry.Slide; +using System.Numerics; + +namespace MajRadar.MajSimaiAdapter.FallbackGeometry.Slide.Segments +{ + /// + ///

一个 slide 路径片段的 abstract 类

+ ///
+ internal abstract class PathSegment + { + /// + ///

用来控制箭头对齐的标志

+ ///
+ public SlideParseMarker ParseMarker { get; private set; } = SlideParseMarker.None; + + /// + ///

这个参数表示这段路径上每隔多少距离放一个箭头

+ ///

默认值是判定圆周长的 1/64

+ ///
+ public double ArrowDistance { get; private set; } = SlideGeo.DefaultDistance; + + public abstract bool IsCurve { get; } + + /// + ///

计算路径上某点的坐标,保证均匀插值

+ ///
+ /// 0 ~ 1 (both inclusive) + public abstract Complex GetPointAt(double t); + + /// + ///

计算路径上某点处的有向切线,方向为路径前进方向

+ ///
+ /// 0 ~ 1 (both inclusive) + /// complex (magnitude = 1) + public abstract Complex GetTangentAt(double t); + + /// + ///

计算本段路径总长

+ ///
+ public abstract double GetSegmentLength(); + + /// + ///

设置控制箭头对齐的标志,给 parser 用

+ ///
+ public void SetParseMarker(SlideParseMarker marker) => ParseMarker = marker; + + /// + ///

设置本段路径的箭头排列间距

+ ///
+ public void SetArrowDistance(double distance) => ArrowDistance = distance; + } +} diff --git a/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/SlideEndShape.cs b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/SlideEndShape.cs new file mode 100644 index 0000000..876f89a --- /dev/null +++ b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/SlideEndShape.cs @@ -0,0 +1,14 @@ +// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28. +// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests. +namespace MajRadar.MajSimaiAdapter.FallbackGeometry.Slide +{ + /// + ///

slide 结尾的形状

+ ///
+ internal enum SlideEndShape + { + Straight, + CircleCCW, + CircleCW + } +} diff --git a/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/SlideParseMarker.cs b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/SlideParseMarker.cs new file mode 100644 index 0000000..342c653 --- /dev/null +++ b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/SlideParseMarker.cs @@ -0,0 +1,22 @@ +// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28. +// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests. +namespace MajRadar.MajSimaiAdapter.FallbackGeometry.Slide +{ + /// + ///

用来控制箭头对齐的标志

+ ///
+ internal enum SlideParseMarker + { + None = 0, + + /// + /// 调整箭头间距,以保证本段结束时箭头位置恰好对齐本段终点 + /// + SmoothAlign, + + /// + /// 不调整箭头间距,但本段结束时把箭头位置强制设为本段终点 + /// + ForceAlign, + } +} diff --git a/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/SlidePathConstructor.cs b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/SlidePathConstructor.cs new file mode 100644 index 0000000..810b6ac --- /dev/null +++ b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/SlidePathConstructor.cs @@ -0,0 +1,249 @@ +// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28. +// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests. +using MajRadar.MajSimaiAdapter.FallbackGeometry.Slide.Segments; +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Text; + +namespace MajRadar.MajSimaiAdapter.FallbackGeometry.Slide +{ + /// + /// 支持链式调用的 slide 路径构造工具 + /// + internal class SlidePathConstructor + { + public readonly List PathSegments = new(); + public Complex CurrentEndPoint = Complex.Zero; + + /// + ///

从指定点开始构造 slide 路径

+ ///
+ /// this + public static SlidePathConstructor BeginAt(Complex point) + { + var obj = new SlidePathConstructor + { + CurrentEndPoint = point + }; + return obj; + } + + /// + /// 从指定按键开始构造 slide 路径 + /// + /// 判定区,应当是 A 区(0~7) + /// this + public static SlidePathConstructor BeginAt(int sensorIdx) + { + return BeginAt(SlideGeo.GetPoint(sensorIdx)); + } + + /// + ///

为上一个路径片段添加控制箭头对齐的标志

+ ///
+ /// this + public SlidePathConstructor TrySetLastParseMarker(SlideParseMarker marker) + { + if (PathSegments.Count <= 0) return this; + PathSegments[^1].SetParseMarker(marker); + return this; + } + + /// + ///

添加一条直线前往指定点的路径

+ ///
+ /// this + public SlidePathConstructor LineToPoint(Complex point) + { + PathSegments.Add(new LineSegment(CurrentEndPoint, point)); + CurrentEndPoint = point; + return this; + } + + /// + ///

添加一条直线前往指定判定区的路径(仅支持 A、B、C 区且不是 Touch 的位置)

+ ///
+ /// this + public SlidePathConstructor LineToPoint(int sensorIdx) + { + return LineToPoint(SlideGeo.GetPoint(sensorIdx)); + } + + /// + ///

添加一条沿切线前往指定圆周的路径

+ ///
+ /// this + public SlidePathConstructor TangentToCircle(ComplexCircle circle, bool isCcw) + { + var inAngle = SlideGeo.CalcTangentAngle(CurrentEndPoint, circle, isCcw); + var inPoint = Complex.FromPolarCoordinates(circle.Radius, inAngle) + circle.Center; + return LineToPoint(inPoint); + } + + /// + ///

添加一条沿切线前往指定圆周的路径,圆周定义见MajGeo.GetCircle

+ ///
+ /// this + public SlidePathConstructor TangentToCircle(int circleIdx, bool isCcw) + { + return TangentToCircle(SlideGeo.GetCircle(circleIdx), isCcw); + } + + /// + ///

添加一条沿指定圆心绕行到指定辐角的路径

+ ///

P.S. 目标辐角是绝对坐标,以正右方向为 0

+ ///
+ /// 圆心 + /// 目标辐角,应在 -pi ~ pi + /// true 为逆时针绕行 + /// true 表示当目标辐角和当前位置相距过小时不进行操作,false 会多绕一圈 + /// this + public SlidePathConstructor ArcToAngle(Complex center, double endRad, bool isCcw, bool skipIfZero) + { + var diff = CurrentEndPoint - center; + var circle = new ComplexCircle(center, diff.Magnitude); + var startRad = diff.Phase; + // startAngle and endAngle in range [-PI, PI] + if (isCcw) + { + if (Math.Abs(endRad - startRad) < SlideGeo.EpsilonRad) + { + if (skipIfZero) return this; + endRad += 2 * Math.PI; + } + + if (startRad > endRad) + { + startRad -= 2 * Math.PI; + } + } + else + { + if (Math.Abs(endRad - startRad) < SlideGeo.EpsilonRad) + { + if (skipIfZero) return this; + endRad -= 2 * Math.PI; + } + + if (startRad < endRad) + { + startRad += 2 * Math.PI; + } + } + + var seg = new ArcSegment(circle, startRad, endRad); + PathSegments.Add(seg); + CurrentEndPoint = seg.GetPointAt(1f); + return this; + } + + /// + ///

添加一条沿指定圆心绕行到指定辐角的路径

+ ///

P.S. 目标辐角是绝对坐标,以正右方向为 0

+ ///
+ /// 相关圆,只会用圆心,见MajGeo.GetCircle + /// 目标辐角,应在 -pi ~ pi + /// true 为逆时针绕行 + /// true 表示当目标辐角和当前位置相等时不进行操作,false 会多绕一圈 + /// this + public SlidePathConstructor ArcToAngle(int circleIdx, double endRad, bool isCcw, bool skipIfZero) + { + return ArcToAngle(SlideGeo.GetCircle(circleIdx).Center, endRad, isCcw, skipIfZero); + } + + /// + ///

添加一条沿指定圆心绕行的圆弧,直到恰好达到前往指定目标点的切点

+ ///
+ /// 圆心 + /// 目标点 + /// true 为逆时针绕行 + /// this + public SlidePathConstructor ArcToTangentTowards(Complex target, Complex center, bool isCcw) + { + var diff = CurrentEndPoint - center; + var endRad = + SlideGeo.CalcTangentAngle(target, new ComplexCircle(center, diff.Magnitude), !isCcw); + return ArcToAngle(center, endRad, isCcw, false); + } + + /// + ///

添加一条沿指定圆心绕行的圆弧,直到恰好达到前往指定判定区的切点

+ ///
+ /// 相关圆,只会用圆心,见MajGeo.GetCircle + /// 目标判定区,仅支持 A、B、C 区且不是 Touch 的位置 + /// true 为逆时针绕行 + /// this + public SlidePathConstructor ArcToTangentTowards(int sensorIdx, int circleIdx, bool isCcw) + { + return ArcToTangentTowards(SlideGeo.GetPoint(sensorIdx), SlideGeo.GetCircle(circleIdx).Center, isCcw); + } + + /// + ///

添加一条沿指定圆心绕行一整圈的路径

+ ///
+ /// this + public SlidePathConstructor FullCircle(Complex center, bool isCcw) + { + var diff = CurrentEndPoint - center; + var circle = new ComplexCircle(center, diff.Magnitude); + PathSegments.Add(new CircleSegment(circle, diff.Phase, isCcw)); + // CurrentEndPoint not changed + return this; + } + + /// + ///

添加一条沿指定圆的圆心绕行一整圈的路径,忽略半径

+ ///
+ /// this + public SlidePathConstructor FullCircle(int circleIdx, bool isCcw) + { + return FullCircle(SlideGeo.GetCircle(circleIdx).Center, isCcw); + } + + /// + ///

添加一条沿外公切线前往指定圆周的直线路径

+ ///

首先圆弧至外公切线切点,然后走公切线进入目标圆周

+ ///
+ /// 当前圆的圆心 + /// 目标圆 + /// true 为逆时针绕行 + /// this + public SlidePathConstructor ExternTangentTransfer(Complex currentCenter, ComplexCircle targetCircle, + bool isCcw) + { + var diff = CurrentEndPoint - currentCenter; + double endRad; + if (Math.Abs(diff.Magnitude - targetCircle.Radius) < SlideGeo.Epsilon) + { + // 两个圆半径差不多相等 + var vector = targetCircle.Center - currentCenter; + vector *= isCcw ? -Complex.ImaginaryOne : Complex.ImaginaryOne; + endRad = vector.Phase; + } + else if (targetCircle.Radius > diff.Magnitude) + { + // 目标圆更大 + var helperCircle = new ComplexCircle(targetCircle.Center, targetCircle.Radius - diff.Magnitude); + endRad = SlideGeo.CalcTangentAngle(currentCenter, helperCircle, isCcw); + } + else + { + var helperCircle = new ComplexCircle(currentCenter, diff.Magnitude - targetCircle.Radius); + endRad = SlideGeo.CalcTangentAngle(targetCircle.Center, helperCircle, !isCcw); + } + ArcToAngle(currentCenter, endRad, isCcw, false); + var inPoint = Complex.FromPolarCoordinates(targetCircle.Radius, endRad) + targetCircle.Center; + LineToPoint(inPoint); + return this; + } + + /// + ///

结束 slide 路径,生成一个参数化 slide 路径对象

+ ///
+ public ParametricSlidePath GeneratePath() + { + return new ParametricSlidePath(PathSegments); + } + } +} diff --git a/Runtime/MajSimaiAdapter/FallbackGeometry/SlideCodeParser.cs b/Runtime/MajSimaiAdapter/FallbackGeometry/SlideCodeParser.cs new file mode 100644 index 0000000..ad3d973 --- /dev/null +++ b/Runtime/MajSimaiAdapter/FallbackGeometry/SlideCodeParser.cs @@ -0,0 +1,266 @@ +// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28. +// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests. +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Text; +using System.Linq; +using MajRadar.MajSimaiAdapter.FallbackGeometry.Slide; + +namespace MajRadar.MajSimaiAdapter.FallbackGeometry +{ + internal static class SlideCodeParser + { + public enum CommandType + { + Invalid = -1, + NodeA = 0, + NodeB = 1, + NodeC = 2, + OrbitCcw = 3, + OrbitCw = 4, + NodeEnd = 5 + } + + public readonly struct Command + { + public readonly CommandType Type; + public readonly int Value; + + public Command(CommandType type, int value) + { + Type = type; + Value = value; + } + + public static bool IsSame(Command a, Command b) + { + return a.Type == b.Type && a.Value == b.Value; + } + } + + public static readonly char[] CommandChars = { + 'A', 'B', 'C', 'P', 'Q', 'K' + }; + + public static int TryParseDigit(char c) + { + if (c >= '0' && c <= '9') return c - '0'; + return -1; + } + + public static List ParseCommands(string code) + { + if (!CommandChars.Contains(code[1])) + { + throw new ArgumentException($"the 2nd char should be a command"); + } + + if (code[^2] != 'K') + { + throw new ArgumentException($"should end with 'K' command"); + } + + var commands = new List(); + var currentType = CommandType.NodeA; + var value = TryParseDigit(code[0]); + if (value < 0) throw new ArgumentException($"invalid char '{code[0]}'"); + + commands.Add(new Command(currentType, value)); + + for (var ptr = 1; ptr < code.Length; ptr++) + { + var ch = code[ptr]; + if (CommandChars.Contains(ch)) + { + currentType = (CommandType)Array.IndexOf(CommandChars, ch); + if (currentType == CommandType.NodeC) + { + commands.Add(new Command(CommandType.NodeC, 0)); + } + } + else + { + value = TryParseDigit(ch); + if (value < 0) throw new ArgumentException($"invalid char '{ch}'"); + if (currentType == CommandType.NodeC) + { + throw new ArgumentException($"digit should not follow 'C'"); + } + commands.Add(new Command(currentType, value)); + } + } + return commands; + } + + public static Complex GetNodePosition(Command cmd) + { + switch (cmd.Type) + { + case CommandType.NodeA: + case CommandType.NodeEnd: + return SlideGeo.PointGroupA(cmd.Value); + case CommandType.NodeB: + return SlideGeo.PointGroupB(cmd.Value); + case CommandType.NodeC: + return SlideGeo.PointCenter(); + default: + throw new ArgumentException($"invalid type for node: {cmd.Type}"); + } + } + + public static void NodeToNode(SlidePathConstructor constructor, Command last, Command current) + { + if (Command.IsSame(last, current)) return; + constructor.LineToPoint(GetNodePosition(current)); + } + + public static void NodeToOrbit(SlidePathConstructor constructor, Command last, Command current) + { + var isCcw = (current.Type == CommandType.OrbitCcw); + var node = GetNodePosition(last); + var orbit = SlideGeo.GetCircle(current.Value); + var diff = node - orbit.Center; + if (Math.Abs(diff.Magnitude - orbit.Radius) < 0.1) + { + if (last.Type == CommandType.NodeA && current.Value == 9) + { + constructor.TrySetLastParseMarker(SlideParseMarker.ForceAlign); + } + return; // node on circle, do nothing + } + + if (diff.Magnitude < orbit.Radius) + throw new ArgumentException($"impossible: {last.Type}{last.Value} -> Orbit{current.Value}"); + + constructor.TangentToCircle(orbit, isCcw); + } + + public static void OrbitToNode(SlidePathConstructor constructor, Command last, Command current) + { + var isCcw = (last.Type == CommandType.OrbitCcw); + var node = GetNodePosition(current); + var orbit = SlideGeo.GetCircle(last.Value); + var diff = node - orbit.Center; + if (Math.Abs(diff.Magnitude - orbit.Radius) < 0.1) + { + constructor.ArcToAngle(orbit.Center, diff.Phase, isCcw, false); + return; + } + + if (diff.Magnitude < orbit.Radius) + throw new ArgumentException($"impossible: Orbit{last.Value} -> {current.Type}{current.Value}"); + + constructor.ArcToTangentTowards(node, orbit.Center, isCcw).LineToPoint(node); + } + + public static void OrbitToOrbit(SlidePathConstructor constructor, Command last, Command current) + { + if (current.Type != last.Type) throw new ArgumentException($"orbit type mismatch"); + + var isCcw = (last.Type == CommandType.OrbitCcw); + var lastOrbit = SlideGeo.GetCircle(last.Value); + var currentOrbit = SlideGeo.GetCircle(current.Value); + if (current.Value == last.Value) + { + constructor.FullCircle(lastOrbit.Center, isCcw); + return; + } + + if (last.Value == 0 && current.Value == 9 || last.Value == 9 && current.Value == 0) + throw new ArgumentException($"impossible: Orbit{last.Value} -> Orbit{current.Value}"); + + if (current.Value == 9) + { + var data = SlideGeo.TransferOutData(last.Value, isCcw); + constructor.ArcToAngle(lastOrbit.Center, data.Item2, isCcw, false) + .ArcToAngle(data.Item1.Center, data.Item3, isCcw, false) + .TrySetLastParseMarker(SlideParseMarker.SmoothAlign); + return; + } + + if (last.Value == 9) + { + var data = SlideGeo.TransferOutData(current.Value, !isCcw); + constructor.ArcToAngle(lastOrbit.Center, data.Item3, isCcw, true) + .ArcToAngle(data.Item1.Center, data.Item2, isCcw, false); + return; + } + + constructor.ExternTangentTransfer(lastOrbit.Center, currentOrbit, isCcw); + } + + /// + /// Parse a specific slide-code into a path object + /// + /// Slide-code string + /// Generated slide path + /// The slide-code contains some invalid command sequence + /// Command type overflow + public static ParametricSlidePath Parse(string code) + { + var commands = ParseCommands(code); + var lastCmd = commands[0]; + // The first command is guarantee to be 'A' + var generator = SlidePathConstructor.BeginAt(SlideGeo.PointGroupA(lastCmd.Value)); + + for (var i = 1; i < commands.Count; i++) + { + var cmd = commands[i]; + switch (cmd.Type) + { + case CommandType.NodeA: + case CommandType.NodeB: + case CommandType.NodeC: + case CommandType.NodeEnd: + { + switch (lastCmd.Type) + { + case CommandType.NodeA: + case CommandType.NodeB: + case CommandType.NodeC: + NodeToNode(generator, lastCmd, cmd); + break; + case CommandType.OrbitCcw: + case CommandType.OrbitCw: + OrbitToNode(generator, lastCmd, cmd); + break; + case CommandType.NodeEnd: + throw new ArgumentException($"'K' should be the last command"); + default: + throw new ArgumentOutOfRangeException(); + } + break; + } + case CommandType.OrbitCcw: + case CommandType.OrbitCw: + { + switch (lastCmd.Type) + { + case CommandType.NodeA: + case CommandType.NodeB: + case CommandType.NodeC: + NodeToOrbit(generator, lastCmd, cmd); + break; + case CommandType.OrbitCcw: + case CommandType.OrbitCw: + OrbitToOrbit(generator, lastCmd, cmd); + break; + case CommandType.NodeEnd: + throw new ArgumentException($"'K' should be the last command"); + default: + throw new ArgumentOutOfRangeException(); + } + break; + } + default: + throw new ArgumentOutOfRangeException(); + } + + lastCmd = cmd; + } + + return generator.GeneratePath(); + } + } +} diff --git a/Runtime/MajSimaiAdapter/FallbackGeometry/SlideGeo.cs b/Runtime/MajSimaiAdapter/FallbackGeometry/SlideGeo.cs new file mode 100644 index 0000000..216ae8f --- /dev/null +++ b/Runtime/MajSimaiAdapter/FallbackGeometry/SlideGeo.cs @@ -0,0 +1,146 @@ +// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28. +// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests. +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Text; + +namespace MajRadar.MajSimaiAdapter.FallbackGeometry +{ + internal static class SlideGeo + { + // Math.IEEERemainder 可以在burst直接用啊 + + /// + ///

这个参数定义了整个几何模型的长度单位

+ ///
+ public const double MainRadius = 4.80; + public const double Epsilon = 0.0001; + public const double EpsilonRad = 0.002; + + public static readonly double CenterRadius = MainRadius * Math.Cos(Math.PI * 3 / 8); + public static readonly double GroupBRadius = CenterRadius / Math.Cos(Math.PI / 8); + + private static readonly double _b = Math.Cos(Math.PI / 8) / 2; + private static readonly double _a = 1 - _b; + private static readonly double _theta = Math.PI / 4; + private static readonly double _s = (_a * _a + _b * _b - 2 * _a * _b * Math.Cos(_theta)) / + (2 * _a - 2 * _b * Math.Cos(_theta)); + + public static readonly double PPQQRadius = MainRadius * _b; + public static readonly double TransferRadius = MainRadius * (_b + _s); + public static readonly double EdgeTransferRadian = _theta; + public static readonly double PPQQTransferRadian = + Math.Acos((_s * _s + _b * _b - (_a - _s) * (_a - _s)) / (2 * _b * _s)); + + public static readonly double DefaultDistance = MainRadius * Math.PI / 32; + + /// + ///

A 区节点坐标(判定线上的八个点)

+ ///

Note: idx is 1-based, not 0-based

+ ///
+ public static Complex PointGroupA(int idx) + { + var radian = Math.PI * (5.0 / 8.0 - idx / 4.0); + return Complex.FromPolarCoordinates(MainRadius, radian); + } + + /// + ///

B 区节点坐标(不是 B 区 Touch 的位置)

+ ///

Note: idx is 1-based, not 0-based

+ ///
+ public static Complex PointGroupB(int idx) + { + var radian = Math.PI * (5.0 / 8.0 - idx / 4.0); + return Complex.FromPolarCoordinates(GroupBRadius, radian); + } + + /// + ///

C 区节点坐标(正中心)

+ ///
+ public static Complex PointCenter() + { + return Complex.Zero; + } + + /// + /// 获取指定判定区对应节点的坐标,判定区符合SensorType的定义,可取范围 0~16 + /// + public static Complex GetPoint(int sensorIdx) + { + switch (sensorIdx) + { + case >= 0 and <= 7: + return PointGroupA(sensorIdx + 1); + case >= 8 and <= 15: + return PointGroupB(sensorIdx - 7); + case 16: + return PointCenter(); + default: + return Complex.Zero; + } + } + + /// + ///

10 个圆的圆心坐标和半径

+ ///

idx 0 is center circle (pq circle),

+ ///

idx 1~8 are ppqq circles passing D[idx] panel,

+ ///

idx 9 is outer circle (judge circle / <> circle)

+ ///
+ public static ComplexCircle GetCircle(int idx) + { + if (idx == 0) + { + return new ComplexCircle(Complex.Zero, CenterRadius); + } + + if (idx == 9) + { + return new ComplexCircle(Complex.Zero, MainRadius); + } + + var radian = Math.PI * (3.0 / 4.0 - idx / 4.0); + var center = Complex.FromPolarCoordinates(PPQQRadius, radian); + return new ComplexCircle(center, PPQQRadius); + } + + /// + /// 计算指定点进入指定圆的切线 + /// + public static double CalcTangentAngle(Complex point, ComplexCircle circle, bool isCcw) + { + var hypot = point - circle.Center; + var deltaRad = Math.Acos(circle.Radius / hypot.Magnitude); + var tanRad = hypot.Phase + (isCcw ? deltaRad : -deltaRad); + return Math.IEEERemainder(tanRad, Math.PI * 2.0); + } + + /// + ///

ppqq 圆到判定线大圆的转移路径(slidecode 专用)

+ ///

Note: idx is 1-based, not 0-based

+ ///
+ /// CircleStruct TransferCircle, double TransferStartAngle, double TransferEndAngle + public static (ComplexCircle, double, double) TransferOutData(int idx, bool isCcw) + { + var ppqqRad = Math.PI * (3.0 / 4.0 - idx / 4.0); + double startRad, endRad; + if (isCcw) + { + startRad = ppqqRad - PPQQTransferRadian; + endRad = ppqqRad + EdgeTransferRadian; + } + else + { + startRad = ppqqRad + PPQQTransferRadian; + endRad = ppqqRad - EdgeTransferRadian; + } + var d = MainRadius - TransferRadius; + var center = Complex.FromPolarCoordinates(d, endRad); + return ( + new ComplexCircle(center, TransferRadius), + Math.IEEERemainder(startRad, Math.PI * 2), + Math.IEEERemainder(endRad, Math.PI * 2) + ); + } + } +} diff --git a/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs b/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs index dbede2f..6430ab3 100644 --- a/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs +++ b/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs @@ -3,6 +3,9 @@ namespace MajRadar.MajSimaiAdapter; /// /// Supplies the gameplay arrow count for a normalized extended K Slide code. /// Standard Slides continue to use MajRadar's fixed reference table. +/// When no provider is supplied, the adapter uses +/// . An explicit provider is +/// authoritative: its errors are reported without retrying the fallback. /// Implementations used by a shared RadarRuntime must be thread-safe and /// must not access Unity main-thread-only objects. /// diff --git a/Runtime/MajSimaiAdapter/MajSimaiChartAdapter.cs b/Runtime/MajSimaiAdapter/MajSimaiChartAdapter.cs index 3cdc6cb..b0184d7 100644 --- a/Runtime/MajSimaiAdapter/MajSimaiChartAdapter.cs +++ b/Runtime/MajSimaiAdapter/MajSimaiChartAdapter.cs @@ -27,7 +27,7 @@ public sealed class MajSimaiChartAdapter public MajSimaiChartAdapter(IExtendedSlideBarCountProvider? extendedSlides = null) { - _slidePaths = new SlidePathResolver(extendedSlides); + _slidePaths = new SlidePathResolver(extendedSlides ?? DefaultExtendedSlideBarCountProvider.Instance); } public async Task ParseAndAdaptAsync( diff --git a/Runtime/Regression/RegressionBetaModel.cs b/Runtime/Regression/RegressionBetaModel.cs index e64b2f7..e57132f 100644 --- a/Runtime/Regression/RegressionBetaModel.cs +++ b/Runtime/Regression/RegressionBetaModel.cs @@ -6,6 +6,10 @@ namespace MajRadar.Regression; public sealed class RegressionBetaModel { + // Output limits can be tuned independently of the fitted coefficients. + internal const double MinimumFittedConstant = 0; + internal const double MaximumFittedConstant = 18; + public IReadOnlyList InputFeatures => RadarFeatureNames.ModelInputOrder; public double Predict(IReadOnlyList rawValues) @@ -32,7 +36,7 @@ public double Predict(IReadOnlyList rawValues) result += coefficients[coefficientIndex++] * normalized[left] * normalized[right]; if (coefficientIndex != coefficients.Length || double.IsNaN(result) || double.IsInfinity(result)) throw new InvalidOperationException("Regression prediction is invalid."); - return result; + return Math.Clamp(result, MinimumFittedConstant, MaximumFittedConstant); } public double Predict(IReadOnlyDictionary rawFeatures) diff --git a/Runtime/Runtime/RadarRuntime.cs b/Runtime/Runtime/RadarRuntime.cs index 2ad6233..e7fa82e 100644 --- a/Runtime/Runtime/RadarRuntime.cs +++ b/Runtime/Runtime/RadarRuntime.cs @@ -81,6 +81,7 @@ FittedConstant is not null && MappingVersion is not null && /// Unity-free public entry point for Play and standalone callers. Instances do /// not retain chart or result state and may be reused concurrently when the /// injected extended-Slide provider is thread-safe. +/// Without an injected provider, the built-in Play geometry snapshot is used. /// public sealed class RadarRuntime { diff --git a/Samples~/MajdataPlay/README.md b/Samples~/MajdataPlay/README.md index d59a643..cf9978f 100644 --- a/Samples~/MajdataPlay/README.md +++ b/Samples~/MajdataPlay/README.md @@ -6,7 +6,21 @@ wires the single Play geometry implementation directly into one reusable ```csharp var snapshot = await ChartRadarService.AnalyzeAsync(chart, cancellationToken); + +var noteScore = snapshot.GetScore(RadarOutputDimension.Note); +var trickyRaw = snapshot.GetRawValue(RadarOutputDimension.SlideTricky); ``` Play remains responsible for selection generation, cancellation ownership, logging, UI updates, and caching the lightweight `ChartRadarSnapshot`. + +The explicitly injected Play provider takes precedence over MajRadar's built-in +fallback. A host without Play geometry can use `new RadarRuntime()` directly; +the fallback is selected only when no provider is supplied. Exceptions or +non-positive results from an injected provider remain structured failures. + +Existing string access remains available for dynamic UI code: + +```csharp +var noteScore = snapshot.Scores["note"]; +``` diff --git a/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs b/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs index e183d12..246abdf 100644 --- a/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs +++ b/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs @@ -284,7 +284,7 @@ public async Task UngroupedNoHeadSlideContributesToIntensityButNotSlideGroupFeat [Theory] [InlineData("(0){4}1,E", "non-finite or non-positive")] - [InlineData("(120){4}1K5[4:1],E", "Extended K Slides")] + [InlineData("(120){4}1P0Q0K5[4:1],E", "orbit type mismatch")] public async Task AdaptationFailuresReturnDataWithoutLeakingExceptions( string inote, string expectedError) @@ -360,14 +360,19 @@ public void FeatureFailuresAreIsolatedInsideRadarAnalyzer() [Theory] [MemberData(nameof(RegressionVectors))] - public void EmbeddedRegressionMatchesFrozenPythonVectors(double[] raw, double expected) + public void EmbeddedRegressionMatchesPythonVectorsWithinOutputLimits(double[] raw, double expected) { var actual = new RegressionBetaModel().Predict(raw); - Assert.Equal(expected, actual, 9); + Assert.Equal(Math.Clamp(expected, + RegressionBetaModel.MinimumFittedConstant, + RegressionBetaModel.MaximumFittedConstant), actual, 9); } public static IEnumerable RegressionVectors() { + // Unbounded Python oracle outputs exercise both ends of the configured range. + yield return Vector(new[] { 8.0, 15.0, 20000.0, 10.0, 3.0, 10.0, 1.0 }, -136224.7610705242); + yield return Vector(new[] { 8.0, 15.0, 20.0, 10.0, 3.0, 10.0, 1000.0 }, 435992.9429645393); yield return Vector(new[] { 6.727708533441772, 10.213333333333333, 0.0, 3.500000000000045, 2.3435703486210016, 5.223136339994041, 0.32687803060913356 }, 13.18826234659077); yield return Vector(new[] { 5.3448652850017435, 9.373333333333333, 5.151860505357921, 3.8688372913206157, 1.527537949608422, 3.4463156768134153, 0.5098107142089721 }, 13.04867082557079); yield return Vector(new[] { 6.556341447489688, 9.728309501643864, 2.7754445459480404, 4.146342997793008, 1.977721867042476, 10.365354504785971, 0.276398788581657 }, 13.335359785629928); diff --git a/Tests~/MajRadar.Tests/DefaultExtendedSlideBarCountProviderTests.cs b/Tests~/MajRadar.Tests/DefaultExtendedSlideBarCountProviderTests.cs new file mode 100644 index 0000000..d714ddf --- /dev/null +++ b/Tests~/MajRadar.Tests/DefaultExtendedSlideBarCountProviderTests.cs @@ -0,0 +1,159 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using MajRadar.Core; +using MajRadar.MajSimaiAdapter; +using MajRadar.Runtime; +using Xunit; + +namespace MajRadar.Tests; + +public sealed class DefaultExtendedSlideBarCountProviderTests +{ + [Theory] + [InlineData("1K3", 14)] + [InlineData("1K5", 20)] + [InlineData("1CK5", 20)] + [InlineData("1B3K5", 22)] + [InlineData("1P0K5", 21)] + [InlineData("1Q0K5", 21)] + [InlineData("1P6K7", 46)] + [InlineData("1Q6K7", 35)] + [InlineData("1P99K3", 111)] + [InlineData("1Q99K3", 80)] + [InlineData("1A3P9K5", 62)] + [InlineData("1P69K7", 72)] + [InlineData("1Q69K7", 35)] + public void RepresentativeGeometryMatchesPlayCounts(string code, int expected) + { + Assert.Equal(expected, DefaultExtendedSlideBarCountProvider.Instance.ResolveBarCount(code)); + } + + [Theory] + [InlineData("8Q69K4", 76, 77)] + [InlineData("4P39K1", 22, 23)] + [InlineData("8Q69K8", 44, 45)] + [InlineData("1Q39K4", 22, 23)] + public void AlignmentBoundaryCountsAllowPlatformRounding(string code, int minimum, int maximum) + { + // These specific SmoothAlign paths land within a few ULPs of a segment + // boundary or endpoint. Math's native implementation varies by OS/CPU, + // so the retained Play comparisons can include one extra arrow sample. + // The two observed macOS/Ubuntu outcomes are allowed only for these cases; + // ordinary geometry above still requires an exact count. + Assert.InRange(DefaultExtendedSlideBarCountProvider.Instance.ResolveBarCount(code), minimum, maximum); + } + + [Theory] + [InlineData("1K5")] + [InlineData("1P6K7")] + [InlineData("1P69K7")] + [InlineData("1A3P9K5")] + public async Task RuntimeSupportsExtendedSlidesWithoutHostGeometry(string code) + { + var result = await new RadarRuntime().ParseAndAnalyzeAsync($"(120){{4}}{code}[4:1],2,E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + Assert.NotNull(result.FittedConstant); + var slide = Assert.Single(result.ChartInput!.Events, item => item.Kind == RadarEventKind.Slide); + Assert.True(Assert.Single(slide.SlidePath!).BarCount > 0); + } + + [Fact] + public async Task HostProviderIsAuthoritative() + { + var provider = new RecordingProvider(1234); + var result = await new RadarRuntime(provider).ParseAndAnalyzeAsync("(120){4}1K5[4:1],E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var slide = Assert.Single(result.ChartInput!.Events, item => item.Kind == RadarEventKind.Slide); + Assert.Equal(1234, Assert.Single(slide.SlidePath!).BarCount); + Assert.Equal(1, provider.Calls); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task InvalidHostResultDoesNotSwitchToFallback(int value) + { + var provider = new RecordingProvider(value); + var result = await new RadarRuntime(provider).ParseAndAnalyzeAsync("(120){4}1K5[4:1],E"); + + Assert.False(result.IsSuccess); + Assert.Contains("Non-positive arrow count", Assert.Single(result.Errors)); + Assert.Equal(1, provider.Calls); + } + + [Fact] + public async Task HostFailureDoesNotSwitchToFallback() + { + var provider = new RecordingProvider(1) { Throw = true }; + var result = await new RadarRuntime(provider).ParseAndAnalyzeAsync("(120){4}1K5[4:1],E"); + + Assert.False(result.IsSuccess); + Assert.Contains("host geometry unavailable", Assert.Single(result.Errors)); + Assert.Equal(1, provider.Calls); + } + + [Fact] + public async Task StandardSlidesDoNotCallExtendedProvider() + { + var provider = new RecordingProvider(1) { Throw = true }; + var result = await new RadarRuntime(provider).ParseAndAnalyzeAsync("(120){4}1-5[4:1],E"); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + Assert.Equal(0, provider.Calls); + } + + [Fact] + public async Task ConcurrentCallsDoNotShareMutableAlignmentState() + { + var provider = DefaultExtendedSlideBarCountProvider.Instance; + // Compare parallel calls with this platform's sequential results, rather + // than importing floating-point boundary decisions from another machine. + var cases = new[] { "1P69K7", "1A3P9K5", "8Q69K4", "4P39K1", "8Q69K8", "1Q39K4" } + .Select(code => (Code: code, Count: provider.ResolveBarCount(code))).ToArray(); + var runtime = new RadarRuntime(); + await Task.WhenAll(Enumerable.Range(0, 32).Select(async index => + { + var item = cases[index % cases.Length]; + var result = await runtime.ParseAndAnalyzeAsync($"(120){{4}}{item.Code}[4:1],2,E"); + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var slide = Assert.Single(result.ChartInput!.Events, x => x.Kind == RadarEventKind.Slide); + Assert.Equal(item.Count, Assert.Single(slide.SlidePath!).BarCount); + })); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("1")] + [InlineData("1K")] + [InlineData("0K5")] + [InlineData("1K9")] + [InlineData("1Z5K7")] + [InlineData("1K1")] + [InlineData("1C1K5")] + [InlineData("1P0Q0K5")] + [InlineData("1P09K5")] + public void InvalidGeometryIsRejected(string? code) + { + Assert.ThrowsAny(() => + DefaultExtendedSlideBarCountProvider.Instance.ResolveBarCount(code!)); + } + + private sealed class RecordingProvider : IExtendedSlideBarCountProvider + { + private readonly int _value; + public int Calls { get; private set; } + public bool Throw { get; init; } + public RecordingProvider(int value) => _value = value; + + public int ResolveBarCount(string slideCode) + { + Calls++; + if (Throw) throw new InvalidOperationException("host geometry unavailable"); + return _value; + } + } +} diff --git a/Tests~/MajRadar.Tests/MajSimaiChartAdapterTests.cs b/Tests~/MajRadar.Tests/MajSimaiChartAdapterTests.cs index bc7c721..e06061c 100644 --- a/Tests~/MajRadar.Tests/MajSimaiChartAdapterTests.cs +++ b/Tests~/MajRadar.Tests/MajSimaiChartAdapterTests.cs @@ -285,15 +285,15 @@ public void InterpretsOnlyTheSlidePathRetainedByMajSimai( } [Fact] - public async Task ExtendedSlideReturnsFailureWithoutThrowingAcrossThePublicBoundary() + public async Task ExtendedSlideUsesFallbackWhenNoProviderIsSupplied() { var adapter = new MajSimaiChartAdapter(); var result = await adapter.ParseAndAdaptAsync("(120){4}1K5[4:1],E"); - Assert.False(result.IsSuccess); - Assert.Null(result.Chart); - Assert.NotEmpty(result.Errors); + Assert.True(result.IsSuccess, string.Join("; ", result.Errors)); + var slide = Assert.Single(result.Chart!.Events, item => item.Kind == RadarEventKind.Slide); + Assert.True(Assert.Single(slide.SlidePath!).BarCount > 0); } [Fact] diff --git a/Tests~/MajRadar.Tests/PlayIntegrationSampleTests.cs b/Tests~/MajRadar.Tests/PlayIntegrationSampleTests.cs index 48ee5a7..c494883 100644 --- a/Tests~/MajRadar.Tests/PlayIntegrationSampleTests.cs +++ b/Tests~/MajRadar.Tests/PlayIntegrationSampleTests.cs @@ -1,4 +1,5 @@ using MajRadar.MajSimaiAdapter; +using MajRadar.Runtime; using Xunit; namespace MajdataPlay.Utils.ChartRadar @@ -15,6 +16,21 @@ namespace MajRadar.Tests public sealed class PlayIntegrationSampleTests { + [Theory] + [InlineData(RadarOutputDimension.Note, "note")] + [InlineData(RadarOutputDimension.Peak, "peak")] + [InlineData(RadarOutputDimension.Sweep, "sweep")] + [InlineData(RadarOutputDimension.SlideTricky, "slide_tricky")] + [InlineData(RadarOutputDimension.SlideSequence, "slide_sequence")] + [InlineData(RadarOutputDimension.Jack, "jack")] + [InlineData(RadarOutputDimension.FittedConstant, "fitted_constant")] + public void EnumMapsToExistingPublicKey( + RadarOutputDimension dimension, + string expectedKey) + { + Assert.Equal(expectedKey, RadarOutputDimensions.Key(dimension)); + } + [Fact] public async Task ThinServiceReturnsLightweightSuccessfulSnapshot() { @@ -25,6 +41,15 @@ public async Task ThinServiceReturnsLightweightSuccessfulSnapshot() Assert.Equal("ok", snapshot.Status); Assert.NotNull(snapshot.FittedConstant); Assert.All(snapshot.Scores.Values, value => Assert.NotNull(value)); + Assert.Equal( + snapshot.Scores[RadarOutputDimensions.Note], + snapshot.GetScore(RadarOutputDimension.Note)); + Assert.Equal( + snapshot.RawValues[RadarOutputDimensions.SlideTricky], + snapshot.GetRawValue(RadarOutputDimension.SlideTricky)); + Assert.Equal( + snapshot.FittedConstant, + snapshot.GetScore(RadarOutputDimension.FittedConstant)); } [Fact]