[RF] AquaMai configuration refactor (#82)

更新了配置文件格式,原有的配置文件将被自动无缝迁移,详情请见新的配置文件中的注释(例外:`SlideJudgeTweak` 不再默认启用)
旧配置文件将被重命名备份,如果更新到此版本遇到 Bug 请联系我们

Updated configuration file schema. The old config file will be migrated automatically and seamlessly. See the comments in the new configuration file for details. (Except for `SlideJudgeTweak` is no longer enabled by default)
Your old configuration file will be renamed as a backup. If you encounter any bug with this version, please contact us.
This commit is contained in:
Menci
2024-11-25 01:25:19 +08:00
committed by GitHub
parent e9ee31b22a
commit 37044dae01
217 changed files with 6051 additions and 3040 deletions

View File

@@ -0,0 +1,51 @@
using System;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager;
using Monitor;
using UnityEngine;
namespace AquaMai.Mods.Fancy.GamePlay;
[ConfigSection(
en: """
Make the judgment display of circular Slides align precisely with the judgment line (originally a bit off).
Just like in majdata.
""",
zh: """
让圆弧形的 Slide 的判定显示与判定线精确对齐 (原本会有一点歪)
就像 majdata 里那样
""")]
public class AlignCircleSlideJudgeDisplay
{
/*
* 这个 Patch 让圆弧形的 Slide 的判定显示与判定线精确对齐 (原本会有一点歪), 就像 majdata 里那样
*/
[HarmonyPostfix]
[HarmonyPatch(typeof(SlideRoot), "Initialize")]
private static void FixJudgePosition(
SlideRoot __instance, SlideType ___EndSlideType, SlideJudge ___JudgeObj
)
{
if (null != ___JudgeObj)
{
float z = ___JudgeObj.transform.localPosition.z;
if (___EndSlideType == SlideType.Slide_Circle_L)
{
float angle = -45.0f - 45.0f * __instance.EndButtonId;
double angleRad = Math.PI / 180.0 * (angle + 90 + 22.5 + 2.6415);
___JudgeObj.transform.localPosition = new Vector3(480f * (float)Math.Cos(angleRad), 480f * (float)Math.Sin(angleRad), z);
___JudgeObj.transform.localRotation = Quaternion.Euler(0.0f, 0.0f, angle);
}
else if (___EndSlideType == SlideType.Slide_Circle_R)
{
float angle = -45.0f * __instance.EndButtonId;
double angleRad = Math.PI / 180.0 * (angle + 90 - 22.5 - 2.6415);
___JudgeObj.transform.localPosition = new Vector3(480f * (float)Math.Cos(angleRad), 480f * (float)Math.Sin(angleRad), z);
___JudgeObj.transform.localRotation = Quaternion.Euler(0.0f, 0.0f, angle);
}
}
}
}

View File

@@ -0,0 +1,33 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Monitor;
using UnityEngine;
namespace AquaMai.Mods.Fancy.GamePlay;
[ConfigSection(
en: """
This Patch makes the Critical judgment of BreakSlide also flicker like BreakTap.
Recommended to use with custom skins (otherwise the visual effect may not be good).
""",
zh: """
这个 Patch 让 BreakSlide 的 Critical 判定也可以像 BreakTap 一样闪烁
推荐与自定义皮肤一起使用 (否则视觉效果可能并不好)
""")]
public class BreakSlideJudgeBlink
{
/*
* 这个 Patch 让 BreakSlide 的 Critical 判定也可以像 BreakTap 一样闪烁
* 推荐与自定义皮肤一起使用 (否则视觉效果可能并不好)
*/
[HarmonyPostfix]
[HarmonyPatch(typeof(SlideJudge), "UpdateBreakEffectAdd")]
private static void FixBreakSlideJudgeBlink(
SpriteRenderer ___SpriteRenderAdd, int ____addEffectCount
)
{
if (!___SpriteRenderAdd.gameObject.activeSelf) return;
float num = (____addEffectCount & 0b10) >> 1;
___SpriteRenderAdd.color = new Color(num, num, num, 1f);
}
}

View File

@@ -0,0 +1,433 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using AquaMai.Config.Attributes;
using DB;
using HarmonyLib;
using MAI2.Util;
using Manager;
using MelonLoader;
using Monitor;
using UnityEngine;
using AquaMai.Mods.Fancy.GamePlay.CustomNoteTypes.Libs;
namespace AquaMai.Mods.Fancy.GamePlay.CustomNoteTypes;
[ConfigCollapseNamespace]
[ConfigSection(
en: "Custom Note Types.",
zh: "自定义 Note 类型"
)]
public class CustomNoteTypes
{
/*
* ========== ========== ========== ========== ========== ========== ========== ==========
* 以下内容是为了添加新的 MA2 语法用于表示自定义的 note 类型
* The following part is to add new MA2 command to Sinmai (representing custom note types)
*
* New note types:
* 1. Slide Super-new Super-hot (NMSSS, BRSSS, EXSSS, BXSSS, CNSSS):
* Definition: ??SSS [bar] [grid] [start pos] [wait] [duration] [end pos] [slide code (string)]
* Represent a slide note with highly customized path (using slide code)
*
* TODO (?)
* Mine notes (P.S. Mine-slides will automatically progress itself)
* Individual tracing duration in conn. slides
* Touch-slides / slides not ending in group A
* Non-C TouchHold
* Spinning tailless star (something like 1$$)
* Hyper Speed Definition ?
*/
public static int TotalMa2RecordCount = -1;
public static int LastMa2RecordID = -1;
public static Array Ma2FileRecordData;
public static void OnAfterPatch()
{
var arrayTraverse = Traverse.Create(typeof(Ma2fileRecordID)).Field("s_Ma2fileRecord_Data");
var targetArray = arrayTraverse.GetValue<Array>();
var nextId = targetArray.Length;
object[][] newEntries =
[
[nextId++, "NMSSS", "过新过热Slide", NotesTypeID.Def.Slide, SlideType.Slide_MAX, 8, Ma2Category.MA2_Note, 2, 2, 2, 2, 2, 2, 0],
[nextId++, "BRSSS", "过新过热BreakSlide", NotesTypeID.Def.BreakSlide, SlideType.Slide_MAX, 8, Ma2Category.MA2_Note, 2, 2, 2, 2, 2, 2, 0],
[nextId++, "EXSSS", "过新过热ExSlide", NotesTypeID.Def.ExSlide, SlideType.Slide_MAX, 8, Ma2Category.MA2_Note, 2, 2, 2, 2, 2, 2, 0],
[nextId++, "BXSSS", "过新过热ExBreakSlide", NotesTypeID.Def.ExBreakSlide, SlideType.Slide_MAX, 8, Ma2Category.MA2_Note, 2, 2, 2, 2, 2, 2, 0],
[nextId++, "CNSSS", "过新过热ConnSlide", NotesTypeID.Def.ConnectSlide, SlideType.Slide_MAX, 8, Ma2Category.MA2_Note, 2, 2, 2, 2, 2, 2, 0],
];
// Ma2fileRecordID.Ma2fileRecord_Data is private, so we need this shit.
var structType = targetArray.GetValue(0).GetType();
var constructor = AccessTools.Constructor(structType,
[
typeof(int), typeof(string), typeof(string), typeof(NotesTypeID.Def), typeof(SlideType), typeof(int),
typeof(Ma2Category), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int),
typeof(int)
]);
Ma2FileRecordData = Array.CreateInstance(structType, targetArray.Length + newEntries.Length);
for (var i = 0; i < targetArray.Length; i++)
{
Ma2FileRecordData.SetValue(targetArray.GetValue(i), i);
}
for (var i = 0; i < newEntries.Length; i++)
{
var j = targetArray.Length + i;
var obj = constructor.Invoke(newEntries[i]);
Ma2FileRecordData.SetValue(obj, j);
}
arrayTraverse.SetValue(Ma2FileRecordData);
TotalMa2RecordCount = Ma2FileRecordData.Length;
LastMa2RecordID = TotalMa2RecordCount - 1;
MelonLogger.Msg($"[CustomNoteType] MA2 record data extended, total count: {TotalMa2RecordCount}");
// Initialize related classes ...
SlideDataBuilder.InitializeHitAreasLookup();
MelonLogger.Msg($"[CustomNoteType] HitAreasLookup initialized, total count: {SlideDataBuilder.HitAreasLookup.Count}");
}
[HarmonyPrefix]
[HarmonyPatch(typeof(Ma2fileRecordID), "findID")]
public static bool FindIDPrefix(string enumName, ref Ma2fileRecordID.Def __result)
{
// I don't know why but patching findID() leads to a completely invalid result
// Sometimes it will even throw an exception
// So I can only prefix it and override it
__result = Ma2fileRecordID.Def.Invalid;
for (var i = 0; i < TotalMa2RecordCount; i++)
{
var item = Ma2FileRecordData.GetValue(i);
if (Traverse.Create(item).Field<string>("enumName").Value == enumName)
{
__result = (Ma2fileRecordID.Def)i;
}
}
return false;
}
[HarmonyPatch]
public static class Ma2RecordValidation
{
public static IEnumerable<MethodBase> TargetMethods()
{
return
[
// AccessTools.Method(typeof(Ma2fileRecordID), "findID"),
AccessTools.Method(typeof(Ma2fileRecordID), "clamp"),
AccessTools.Method(typeof(Ma2fileRecordID), "getClampValue"),
AccessTools.Method(typeof(Ma2fileRecordID), "isValid"),
AccessTools.Method(typeof(Ma2fileRecordID_Extension), "isValid"),
];
}
public static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions)
{
foreach (var inst in instructions)
{
if (inst.LoadsConstant(142))
{
var instNew = new CodeInstruction(OpCodes.Ldsfld, AccessTools.Field(typeof(CustomNoteTypes), "TotalMa2RecordCount"));
yield return instNew;
}
else if (inst.LoadsConstant(141))
{
var instNew = new CodeInstruction(OpCodes.Ldsfld, AccessTools.Field(typeof(CustomNoteTypes), "LastMa2RecordID"));
yield return instNew;
}
else
{
yield return inst;
}
}
}
}
/*
* ========== ========== ========== ========== ========== ========== ========== ==========
* 以下内容是给新的 MA2 语法写解析器
*/
/*
* 给新建的 noteData 初始化应有的数据, 仅仅是照搬了 NotesReader.loadNote
*/
public static void PrepareBasicNoteData(NoteData noteData, NotesReader reader,
MA2Record record, int index, ref int noteIndex, OptionMirrorID mirrorMode)
{
noteData.type = record.getType().getNotesTypeId();
noteData.time.init(record.getBar(), record.getGrid(), reader);
noteData.end = noteData.time;
noteData.startButtonPos = MaiGeometry.MirrorInfo[(int)mirrorMode, record.getPos()];
noteData.index = index;
var num = record.getGrid() % 96;
if (num == 0)
{
noteData.beatType = NoteData.BeatType.BeatType04;
}
else if (num % 48 == 0)
{
noteData.beatType = NoteData.BeatType.BeatType08;
}
else if (num % 24 == 0)
{
noteData.beatType = NoteData.BeatType.BeatType16;
}
else if (num % 16 == 0)
{
noteData.beatType = NoteData.BeatType.BeatType24;
}
else
{
noteData.beatType = NoteData.BeatType.BeatTypeOther;
}
noteData.indexNote = noteIndex;
++noteIndex;
}
/*
* 给新建的 noteData 填入基本的 slide 相关数据, 仅仅是照搬了 NotesReader.loadNote
*/
public static void PrepareBasicSlideData(NoteData noteData, NotesReader reader, MA2Record record, int noteIndex,
ref int slideIndex, OptionMirrorID mirrorMode)
{
noteData.indexSlide = slideIndex++;
var slideData = noteData.slideData;
var slideWaitLen = record.getSlideWaitLen();
var slideShootLen = record.getSlideShootLen();
slideData.targetNote = MaiGeometry.MirrorInfo[(int)mirrorMode, record.getSlideEndPos()];
slideData.shoot.time.init(record.getBar(), record.getGrid() + slideWaitLen, reader);
slideData.shoot.index = noteIndex;
slideData.arrive.time.init(record.getBar(), record.getGrid() + slideWaitLen + slideShootLen, reader);
slideData.arrive.index = noteIndex;
noteData.end = slideData.arrive.time;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(NotesReader), "loadNote")]
public static bool LoadCustomNote(NotesReader __instance, ref bool __result, NotesData ____note, int ____playerID,
MA2Record rec, int index, ref int noteIndex, ref int slideIndex)
{
if (rec.getType() < Ma2fileRecordID.Def.End)
{
// builtin record type
return true;
}
MelonLogger.Msg($"[CustomNoteType] Custom note | {rec._str.Count} | {rec.getStr(0)} {rec.getStr(1)} {rec.getStr(2)} {rec.getStr(3)} {rec.getStr(4)} {rec.getStr(5)} {rec.getStr(6)} {rec.getStr(7)} {rec.getStr(8)}");
var flag = true;
switch (rec.getType().getEnumName())
{
case "NMSSS":
case "BRSSS":
case "EXSSS":
case "BXSSS":
case "CNSSS":
var noteData = new CustomSlideNoteData();
var mirrorMode = Singleton<GamePlayManager>.Instance.GetGameScore(____playerID).UserOption.MirrorMode;
PrepareBasicNoteData(noteData, __instance, rec, index, ref noteIndex, mirrorMode);
PrepareBasicSlideData(noteData, __instance, rec, noteIndex, ref slideIndex, mirrorMode);
var success = noteData.ParseSlideCode(rec.getStr(7), mirrorMode);
if (success)
{
____note._noteData.Add(noteData);
}
else
{
flag = false;
}
break;
default:
flag = false;
break;
}
__result = flag;
return false;
}
/*
* ========== ========== ========== ========== ========== ========== ========== ==========
* 以下内容是为了实现自定义 Slide
*
*/
/*
* 把 GetSlidePath 和 GetSlideHitArea 和 GetSlideLength 重定向到我可以控制的函数上, 并且多推几个参数进来
*/
[HarmonyPatch]
public static class SlideNoteDataHack
{
public static IEnumerable<MethodBase> TargetMethods()
{
return
[
AccessTools.Method(typeof(SlideRoot), "Initialize"),
AccessTools.Method(typeof(SlideRoot), "GetSlideArrowNum", [typeof(NoteData)]),
AccessTools.Method(typeof(StarNote), "Initialize"),
AccessTools.Method(typeof(BreakStarNote), "Initialize"),
];
}
public static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions)
{
var methodGetSlidePath = AccessTools.Method(typeof(SlideManager), "GetSlidePath");
var methodGetSlidePathRedirect = AccessTools.Method(typeof(CustomNoteTypes), "GetSlidePathRedirect");
var methodGetSlideHitArea = AccessTools.Method(typeof(SlideManager), "GetSlideHitArea");
var methodGetSlideHitAreaRedirect = AccessTools.Method(typeof(CustomNoteTypes), "GetSlideHitAreaRedirect");
var methodGetSlideLength = AccessTools.Method(typeof(SlideManager), "GetSlideLength");
var methodGetSlideLengthRedirect = AccessTools.Method(typeof(CustomNoteTypes), "GetSlideLengthRedirect");
var fieldSlideData = AccessTools.Field(typeof(NoteData), "slideData");
var oldInstList = new List<CodeInstruction>(instructions);
var newInstList = new List<CodeInstruction>();
CodeInstruction instToInject = null;
for (var i = 0; i < oldInstList.Count; ++i)
{
var inst = oldInstList[i];
if (inst.LoadsField(fieldSlideData))
{
// 以 GetSlidePath 为例, 我们需要把下面这个调用:
// Singleton<SlideManager>.Instance.GetSlidePath(
// noteData.slideData.type, noteData.startButtonPos,
// noteData.slideData.targetNote, this.ButtonId
// )
// 里的 noteData 拿到手
// 所以就记录上一次 ldfld NoteData::slideData 的位置, 往前找一个 IL code
// 找到的就是 load 这个 noteData 的位置
// 然后在后续调用 GetSlidePath 时, 先重复一遍 load 把这个 noteData 入栈, 然后重定向到一个新的函数上去
instToInject = oldInstList[i - 1];
newInstList.Add(inst);
}
else if (inst.Calls(methodGetSlidePath))
{
newInstList.Add(instToInject!.Clone());
newInstList.Add(new CodeInstruction(OpCodes.Call, methodGetSlidePathRedirect));
instToInject = null;
}
else if (inst.Calls(methodGetSlideHitArea))
{
newInstList.Add(instToInject!.Clone());
newInstList.Add(new CodeInstruction(OpCodes.Call, methodGetSlideHitAreaRedirect));
instToInject = null;
}
else if (inst.Calls(methodGetSlideLength))
{
newInstList.Add(instToInject!.Clone());
newInstList.Add(new CodeInstruction(OpCodes.Call, methodGetSlideLengthRedirect));
instToInject = null;
}
else
{
newInstList.Add(inst);
}
}
return newInstList;
}
}
public static List<Vector4> GetSlidePathRedirect(SlideManager instance, SlideType slideType, int start, int end,
int starButton, NoteData noteData)
{
// MelonLogger.Msg($"[CustomNoteType] GetSlidePath Redirected!");
// MelonLogger.Msg($"{noteData.indexNote} {noteData.indexSlide} {slideType} {start} {end} {starButton}");
if (noteData is CustomSlideNoteData data)
{
// MelonLogger.Msg($"[CustomNoteType] Successfully injected custom path {data.SlideCode}");
return data.SlidePathList[starButton];
}
return instance.GetSlidePath(slideType, start, end, starButton);
}
public static List<SlideManager.HitArea> GetSlideHitAreaRedirect(SlideManager instance, SlideType slideType,
int start, int end, int starButton, NoteData noteData)
{
// MelonLogger.Msg($"[CustomNoteType] GetSlideHitArea Redirected!");
// MelonLogger.Msg($"{noteData.indexNote} {noteData.indexSlide} {slideType} {start} {end} {starButton}");
if (noteData is CustomSlideNoteData data)
{
// MelonLogger.Msg($"[CustomNoteType] Successfully injected custom hit areas {data.SlideCode}");
return data.SlideHitAreasList[starButton];
}
return instance.GetSlideHitArea(slideType, start, end, starButton);
}
public static float GetSlideLengthRedirect(SlideManager instance, SlideType slideType,
int start, int end, NoteData noteData)
{
// MelonLogger.Msg($"[CustomNoteType] GetSlideLength Redirected!");
// MelonLogger.Msg($"{noteData.indexNote} {noteData.indexSlide} {slideType} {start} {end}");
if (noteData is CustomSlideNoteData data)
{
// MelonLogger.Msg($"[CustomNoteType] Successfully injected custom path length {data.SlideCode}");
return data.SlidePathLength;
}
return instance.GetSlideLength(slideType, start, end);
}
[HarmonyPatch]
public static class Debuging
{
public static IEnumerable<MethodBase> TargetMethods()
{
return
[
AccessTools.Method(typeof(SlideRoot), "Initialize"),
// AccessTools.Method(typeof(SlideRoot), "GetSlideArrowNum", []),
// AccessTools.Method(typeof(SlideRoot), "GetSlideArrowNum", [typeof(NoteData)]),
// AccessTools.Method(typeof(SlideRoot), "GetArrowData"),
// AccessTools.Method(typeof(SlideRoot), "totalDistance"),
// AccessTools.Method(typeof(SlideRoot), "GetActiveArrowNum"),
// AccessTools.Method(typeof(SlideJudge), "SetJudgeType"),
];
}
public static void Prefix(MethodBase __originalMethod, object[] __args)
{
var msg = "[CustomNoteType] Before ";
msg += __originalMethod.DeclaringType!.FullName + "." + __originalMethod.Name + " (";
var infos = __originalMethod.GetParameters()
.Select((x, i) => x.ParameterType.FullName + " " + x.Name + " = " + GetString(__args[i]))
.ToArray();
msg += infos.Length > 0 ? infos.Aggregate((a, b) => a + ", " + b) : "void";
msg += ")";
MelonLogger.Msg(msg);
}
public static void Postfix(MethodBase __originalMethod, object[] __args)
{
var msg = "[CustomNoteType] After ";
msg += __originalMethod.DeclaringType!.FullName + "." + __originalMethod.Name + " (";
var infos = __originalMethod.GetParameters()
.Select((x, i) => x.ParameterType.FullName + " " + x.Name + " = " + GetString(__args[i]))
.ToArray();
msg += infos.Length > 0 ? infos.Aggregate((a, b) => a + ", " + b) : "void";
msg += ")";
MelonLogger.Msg(msg);
}
public static string GetString(object value)
{
if (value is CustomSlideNoteData data)
{
return $"<CustomSlideNoteData {data.indexNote} {data.indexSlide} {data.SlideCode}>";
}
if (value is NoteData data2)
{
return $"<NoteData {data2.indexNote} {data2.indexSlide}>";
}
return value.ToString();
}
}
}

View File

@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Linq;
using DB;
using Manager;
using MelonLoader;
using UnityEngine;
namespace AquaMai.Mods.Fancy.GamePlay.CustomNoteTypes.Libs;
public class CustomSlideNoteData: NoteData
{
public string SlideCode;
public List<List<Vector4>> SlidePathList = new List<List<Vector4>>();
public List<List<SlideManager.HitArea>> SlideHitAreasList = new List<List<SlideManager.HitArea>>();
public float SlidePathLength;
public bool ParseSlideCode(string slideCode, OptionMirrorID mirrorMode)
{
if (string.IsNullOrEmpty(slideCode))
{
return false;
}
SlidePathList.Clear();
SlideHitAreasList.Clear();
this.SlideCode = slideCode;
var path = SlideCodeParser.Parse(slideCode);
if (path == null)
{
return false;
}
var arrowData = SlideDataBuilder.BuildArrowData(path);
SlidePathLength = (float)path.GetPathLength();
var hitAreaData = SlideDataBuilder.BuildHitAreas(path);
for (var i = 0; i < 8; i++)
{
SlidePathList.Add(SlideDataBuilder.ConvertAndRotateArrowData(arrowData, i, mirrorMode));
SlideHitAreasList.Add(SlideDataBuilder.ConvertAndRotateHitAreas(hitAreaData, i, mirrorMode));
}
var msg = string.Join(", ",
hitAreaData.Select(x => x.PanelAreas).Select(x => string.Join("/", x.Cast<InputManager.TouchPanelArea>())));
MelonLogger.Msg(msg);
this.slideData.type = path.GetEndType(mirrorMode);
return true;
}
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Numerics;
namespace AquaMai.Mods.Fancy.GamePlay.CustomNoteTypes.Libs;
public static class MaiGeometry
{
public struct CircleStruct(Complex center, double radius)
{
public Complex Center = center;
public double Radius = radius;
}
public static readonly double CanvasWidth = 1080.0;
public static readonly double MainRadius = 480.0;
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 EdgeTransferAngle = _theta;
public static readonly double PPQQTransferAngle =
Math.Acos((_s * _s + _b * _b - (_a - _s) * (_a - _s)) / (2 * _b * _s));
public static readonly double DefaultDistance = MainRadius * Math.PI / 32;
public static readonly int[,] MirrorInfo = new int[4, 17]
{
{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }, // Normal
{ 7, 6, 5, 4, 3, 2, 1, 0, 15, 14, 13, 12, 11, 10, 9, 8, 16 }, // L <-> R
{ 3, 2, 1, 0, 7, 6, 5, 4, 11, 10, 9, 8, 15, 14, 13, 12, 16 }, // U <-> D
{ 4, 5, 6, 7, 0, 1, 2, 3, 12, 13, 14, 15, 8, 9, 10, 11, 16 } // rotate 180 deg
};
/// <summary>
/// Note: idx is 1-based, not 0-based
/// </summary>
public static Complex PointGroupA(int idx)
{
var angle = Math.PI * (5.0 / 8.0 - idx / 4.0);
return Complex.FromPolarCoordinates(MainRadius, angle);
}
/// <summary>
/// Note: idx is 1-based, not 0-based
/// </summary>
public static Complex PointGroupB(int idx)
{
var angle = Math.PI * (5.0 / 8.0 - idx / 4.0);
return Complex.FromPolarCoordinates(GroupBRadius, angle);
}
public static Complex Center()
{
return Complex.Zero;
}
/// <summary>
/// idx 0 is center circle, idx 1~8 are ppqq circles, idx 9 is outer circle
/// </summary>
public static CircleStruct GetCircle(int idx)
{
if (idx == 0)
{
return new CircleStruct(Complex.Zero, CenterRadius);
}
if (idx == 9)
{
return new CircleStruct(Complex.Zero, MainRadius);
}
var angle = Math.PI * (3.0 / 4.0 - idx / 4.0);
var center = Complex.FromPolarCoordinates(PPQQRadius, angle);
return new CircleStruct(center, PPQQRadius);
}
/// <summary>
/// Note: idx is 1-based, not 0-based
/// </summary>
/// <returns>CircleStruct TransferCircle, double TransferStartAngle, double TransferEndAngle</returns>
public static Tuple<CircleStruct, double, double> TransferOutData(int idx, bool isccw)
{
var ppqqRad = Math.PI * (3.0 / 4.0 - idx / 4.0);
double startAngle, endAngle;
if (isccw)
{
startAngle = ppqqRad - PPQQTransferAngle;
endAngle = ppqqRad + EdgeTransferAngle;
}
else
{
startAngle = ppqqRad + PPQQTransferAngle;
endAngle = ppqqRad - EdgeTransferAngle;
}
var d = MainRadius - TransferRadius;
var center = Complex.FromPolarCoordinates(d, endAngle);
return new Tuple<CircleStruct, double, double>(new CircleStruct(center, TransferRadius),
Math.IEEERemainder(startAngle, Math.PI * 2), Math.IEEERemainder(endAngle, Math.PI * 2));
}
}

View File

@@ -0,0 +1,226 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using DB;
using Manager;
namespace AquaMai.Mods.Fancy.GamePlay.CustomNoteTypes.Libs;
public class ParametricSlidePath
{
public enum ParseMarker
{
None = 0,
SmoothAlign,
ForceAlign,
SharpCorner
}
public abstract class PathSegment
{
public ParseMarker ParseMarker = ParseMarker.None;
public double ArrowDistance = MaiGeometry.DefaultDistance;
public abstract bool DoAngleLerp { get; }
public abstract Complex GetPointAt(double t);
public abstract Complex GetTangentAt(double t);
public abstract double GetSegmentLength();
public void SetParseMarker(ParseMarker marker) => ParseMarker = marker;
public void SetArrowDistance(double distance) => ArrowDistance = distance;
}
public class LineSegment(Complex start, Complex end) : PathSegment
{
public readonly Complex StartPoint = start;
public readonly Complex EndPoint = end;
public override bool DoAngleLerp { 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;
}
}
public class ArcSegment(MaiGeometry.CircleStruct circle, double startAngle, double endAngle) : PathSegment
{
public readonly MaiGeometry.CircleStruct Circle = circle;
public readonly double StartAngle = startAngle;
public readonly double EndAngle = endAngle;
public override bool DoAngleLerp { get; } = true;
public override Complex GetPointAt(double t)
{
var angle = StartAngle + t * (EndAngle - StartAngle);
return Circle.Center + Complex.FromPolarCoordinates(Circle.Radius, angle);
}
public override Complex GetTangentAt(double t)
{
var angle = StartAngle + t * (EndAngle - StartAngle);
if (StartAngle < EndAngle)
{
return Complex.FromPolarCoordinates(1, angle) * Complex.ImaginaryOne;
}
else
{
return Complex.FromPolarCoordinates(-1, angle) * Complex.ImaginaryOne;
}
}
public override double GetSegmentLength()
{
return Math.Abs(EndAngle - StartAngle) * Circle.Radius;
}
}
public class CircleSegment(MaiGeometry.CircleStruct circle, double startAngle, bool isCcw) : PathSegment
{
public readonly MaiGeometry.CircleStruct Circle = circle;
public readonly double StartAngle = startAngle;
public readonly bool IsCcw = isCcw;
public override bool DoAngleLerp { get; } = true;
public override Complex GetPointAt(double t)
{
double angle;
if (IsCcw)
{
angle = StartAngle + t * Math.PI * 2f;
}
else
{
angle = StartAngle - t * Math.PI * 2f;
}
return Circle.Center + Complex.FromPolarCoordinates(Circle.Radius, angle);
}
public override Complex GetTangentAt(double t)
{
double angle;
if (IsCcw)
{
angle = StartAngle + t * Math.PI * 2f;
return Complex.FromPolarCoordinates(1, angle) * Complex.ImaginaryOne;
}
else
{
angle = StartAngle - t * Math.PI * 2f;
return Complex.FromPolarCoordinates(-1, angle) * Complex.ImaginaryOne;
}
}
public override double GetSegmentLength()
{
return Math.PI * Circle.Radius * 2;
}
}
public readonly PathSegment[] Segments;
public readonly double[] Fractions;
public readonly double[] AccumulatedLengths;
public ParametricSlidePath(IEnumerable<PathSegment> 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();
}
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[Segments.Length - 1];
}
var idx = Array.BinarySearch(Fractions, t);
if (idx < 0)
{
idx = ~idx; // first entry > t
}
// if idx >= 0 then idx is the entry == t
// so Fractions[idx-1] < t and Fractions[idx] >= t
// Note: Fractions[i] marks the end point of Segments[i]
if (idx >= Segments.Length)
{
segmentT = 1.0;
return Segments[Segments.Length - 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[AccumulatedLengths.Length - 1];
public Complex GetPointAt(double t)
{
var segment = GetSegmentAt(t, out var segT);
return segment.GetPointAt(segT);
}
public Complex GetTangentAt(double t)
{
var segment = GetSegmentAt(t, out var segT);
return segment.GetTangentAt(segT);
}
public SlideType GetEndType(OptionMirrorID mirrorMode)
{
var lastSegment = Segments[Segments.Length - 1];
var flip = mirrorMode == OptionMirrorID.LR || mirrorMode == OptionMirrorID.UD;
if (lastSegment is CircleSegment circle)
{
return circle.IsCcw != flip ? SlideType.Slide_Circle_L : SlideType.Slide_Circle_R;
}
if (lastSegment is ArcSegment arc)
{
return (arc.EndAngle > arc.StartAngle) != flip ? SlideType.Slide_Circle_L : SlideType.Slide_Circle_R;
}
return SlideType.Slide_Straight;
}
}

View File

@@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using MelonLoader;
namespace AquaMai.Mods.Fancy.GamePlay.CustomNoteTypes.Libs;
public static class SlideCodeParser
{
public enum CommandType
{
Invalid = -1,
NodeA = 0,
NodeB = 1,
NodeC = 2,
OrbitCCW = 3,
OrbitCW = 4,
NodeEnd = 5
}
public struct Command(CommandType type, int value)
{
public CommandType Type = type;
public int 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<Command> ParseCommands(string code)
{
if (!CommandChars.Contains(code[1]))
{
throw new ArgumentException($"the 2nd char should be a command");
}
if (code[code.Length - 2] != 'K')
{
throw new ArgumentException($"should end with 'K' command");
}
var commands = new List<Command>();
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 MaiGeometry.PointGroupA(cmd.Value);
case CommandType.NodeB:
return MaiGeometry.PointGroupB(cmd.Value);
case CommandType.NodeC:
return MaiGeometry.Center();
default:
throw new ArgumentException($"invalid type for node: {cmd.Type}");
}
}
public static void NodeToNode(SlidePathGenerator generator, Command last, Command current)
{
if (Command.IsSame(last, current)) return;
generator.LineToPoint(GetNodePosition(current));
}
public static void NodeToOrbit(SlidePathGenerator generator, Command last, Command current)
{
var isCcw = (current.Type == CommandType.OrbitCCW);
var node = GetNodePosition(last);
var orbit = MaiGeometry.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)
{
generator.TrySetLastParseMarker(ParametricSlidePath.ParseMarker.ForceAlign);
}
return; // node on circle, do nothing
}
if (diff.Magnitude < orbit.Radius)
throw new ArgumentException($"impossible: {last.Type}{last.Value} -> Orbit{current.Value}");
generator.TangentToCircle(orbit, isCcw);
}
public static void OrbitToNode(SlidePathGenerator generator, Command last, Command current)
{
var isCcw = (last.Type == CommandType.OrbitCCW);
var node = GetNodePosition(current);
var orbit = MaiGeometry.GetCircle(last.Value);
var diff = node - orbit.Center;
if (Math.Abs(diff.Magnitude - orbit.Radius) < 0.1)
{
generator.ArcToAngle(orbit.Center, diff.Phase, isCcw, false);
return;
}
if (diff.Magnitude < orbit.Radius)
throw new ArgumentException($"impossible: Orbit{last.Value} -> {current.Type}{current.Value}");
generator.ArcToTangentTowards(node, orbit.Center, isCcw);
generator.LineToPoint(node);
}
public static void OrbitToOrbit(SlidePathGenerator generator, Command last, Command current)
{
if (current.Type != last.Type) throw new ArgumentException($"orbit type mismatch");
var isCcw = (last.Type == CommandType.OrbitCCW);
var lastOrbit = MaiGeometry.GetCircle(last.Value);
var currentOrbit = MaiGeometry.GetCircle(current.Value);
if (current.Value == last.Value)
{
generator.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 = MaiGeometry.TransferOutData(last.Value, isCcw);
generator.ArcToAngle(lastOrbit.Center, data.Item2, isCcw, false);
generator.ArcToAngle(data.Item1.Center, data.Item3, isCcw, false);
generator.TrySetLastParseMarker(ParametricSlidePath.ParseMarker.SmoothAlign);
return;
}
if (last.Value == 9)
{
var data = MaiGeometry.TransferOutData(current.Value, !isCcw);
generator.ArcToAngle(lastOrbit.Center, data.Item3, isCcw, true);
generator.ArcToAngle(data.Item1.Center, data.Item2, isCcw, false);
return;
}
generator.ExternTangentTransfer(lastOrbit.Center, currentOrbit, isCcw);
}
public static ParametricSlidePath Parse(string code)
{
try
{
var commands = ParseCommands(code);
var lastCmd = commands[0];
// The first command is guarantee to be 'A'
var generator = SlidePathGenerator.BeginAt(MaiGeometry.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();
}
catch (ArgumentException e)
{
var msg = $"Invalid code: {code}";
if (e.Message != "")
{
msg += $", {e.Message}";
}
MelonLogger.Error(msg);
return null;
}
}
}

View File

@@ -0,0 +1,473 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using DB;
using Manager;
using Vector4 = UnityEngine.Vector4;
namespace AquaMai.Mods.Fancy.GamePlay.CustomNoteTypes.Libs;
public static class SlideDataBuilder
{
public readonly struct ArrowData(Complex point, Complex tangent, double length)
{
public readonly Complex Point = point;
public readonly Complex Tangent = tangent;
public readonly double Length = length;
}
public static List<ArrowData> BuildArrowData(ParametricSlidePath path)
{
var result = new List<ArrowData>();
var totalLength = path.GetPathLength();
var totalSegCount = path.Segments.Length;
var length = 0.0;
var segIdx = 0;
var isSwitching = false;
while (length < totalLength)
{
var t = length / totalLength;
var pt = path.GetPointAt(t);
var tg = path.GetTangentAt(t);
var t2 = (length + 10.0) / totalLength;
if ((path.GetTangentAt(t2) - tg).Magnitude < 0.2) // 0.2 -> ~ 11.48 deg apart
{
// use secant instead of tangent (for better visual quality)
tg = path.GetPointAt(t2) - pt;
tg /= tg.Magnitude;
}
if (isSwitching)
{
// around connecting point of 2 segments, smoothing the transition
var last = result[result.Count - 1];
var vec = pt - last.Point;
vec /= vec.Magnitude;
if ((tg - last.Tangent).Magnitude < 0.2)
{
var x = 0.5 * (last.Tangent + vec);
x /= x.Magnitude;
result[result.Count - 1] = new ArrowData(last.Point, x, last.Length);
tg = 0.5 * (tg + vec);
tg /= tg.Magnitude;
}
}
result.Add(new ArrowData(pt, tg, length));
isSwitching = false;
var nextLength = length + path.Segments[segIdx].ArrowDistance;
if (segIdx < totalSegCount - 1 && nextLength >= path.AccumulatedLengths[segIdx])
{
isSwitching = true;
if (path.Segments[segIdx].ParseMarker == ParametricSlidePath.ParseMarker.ForceAlign)
{
// in this case the next point is forced to be 1 unit after the connecting point
nextLength = path.AccumulatedLengths[segIdx] + path.Segments[segIdx + 1].ArrowDistance;
// P.S. 这种情况一般是出现在一条直线连接到外圈, 这个处理是为了让外圈的箭头对齐
}
if (path.Segments[segIdx + 1].ParseMarker == ParametricSlidePath.ParseMarker.SmoothAlign)
{
// arrow distance of the next segment is tempered in order to align arrow
var delta = path.AccumulatedLengths[segIdx + 1] - length;
var n = Math.Round(delta / MaiGeometry.DefaultDistance);
path.Segments[segIdx + 1].SetArrowDistance(delta / n);
nextLength = length + delta / n;
// P.S. 这种情况出现在 ppqq 圈进入外圈, 可以把转移轨道的箭头间距微调一下, 也是让外圈对齐
}
segIdx++;
}
length = nextLength;
}
// 把路径终点补上
result.Add(new ArrowData(path.GetPointAt(1.0), path.GetTangentAt(1.0), totalLength));
return result;
}
/// <summary>
/// Convert arrow data to sinmai format (Vector4)
/// </summary>
/// <param name="data">arrow data generated by BuildArrowData()</param>
/// <param name="starButton">button index of slide-star</param>
/// <param name="mirrorMode">mirror mode in user option</param>
/// <returns>sinmai format arrow data, referenced to slide-star</returns>
public static List<Vector4> ConvertAndRotateArrowData(IEnumerable<ArrowData> data, int starButton,
OptionMirrorID mirrorMode)
{
// SBGA 用 Vector4 存储了 slide 箭头的坐标与取向
// x, y 是平面坐标, z 是从起点到此处的路径长度 (px), w 是旋转的角度 (0 ~ 360 deg) (注意与切线方向差了 180 度)
// 坐标原点是屏幕中心, x 轴向右, y 轴向上
// w 的零点是朝向正右 (对应于箭头朝向正左), 逆时针为正方向
// 此外, sinmai 实际上是把所有 slide 路径相对于星星头存储的, 再在 SlideRoot 里通过 transform 转到合适的位置
// 判定区也是相对于星星头存储, 用 InputManager.ConvertTouchPanelRotatePush() 执行旋转
// 但是 slide code 定义的是绝对位置, 所以要逆向转回去, 以保证无论星星头在哪个键获取到的路径在处理过后都是一样的
// 然后还需要处理镜像的问题
var arrowList = new List<Vector4>();
var rotor = Complex.FromPolarCoordinates(1.0, Math.PI / 4.0 * starButton);
foreach (var arrow in data)
{
var pos = arrow.Point;
var tangent = arrow.Tangent;
switch (mirrorMode)
{
case OptionMirrorID.Normal:
break;
case OptionMirrorID.LR:
pos = Complex.Conjugate(pos) * -1.0;
tangent = Complex.Conjugate(tangent) * -1.0;
break;
case OptionMirrorID.UD:
pos = Complex.Conjugate(pos);
tangent = Complex.Conjugate(tangent);
break;
case OptionMirrorID.UDLR:
pos *= -1.0;
tangent *= -1.0;
break;
default:
break;
}
pos *= rotor;
tangent *= rotor;
var angle = tangent.Phase * 180.0 / Math.PI + 180.0; // Phase is in [-PI, PI]
arrowList.Add(new Vector4((float) pos.Real, (float) pos.Imaginary, (float) arrow.Length, (float) angle));
}
return arrowList;
}
public readonly struct HitAreaData(double push, double release, int[] areas)
{
public readonly double PushDistance = push;
public readonly double ReleaseDistance = release;
public readonly int[] PanelAreas = areas;
}
public static readonly Dictionary<int, HitAreaData[]> HitAreasLookup = new Dictionary<int, HitAreaData[]>();
public static void InitializeHitAreasLookup()
{
for (var i = 0; i < 8; i++)
{
for (var j = 0; j < 8; j++)
{
var diff = (j - i) & 7; // you know this is actually % 8 ... for same negative number compat
int tmp, tmp2;
// Ai -> Aj
var key = (i << 5) | j;
switch (diff)
{
case 1:
case 7:
HitAreasLookup[key] =
[
new HitAreaData(0.32, 0.68, [i]),
new HitAreaData(1.00, 1.00, [j])
];
break;
case 2:
tmp = (i + 1) & 7;
HitAreasLookup[key] =
[
new HitAreaData(0.20, 0.38, [i]),
new HitAreaData(0.62, 0.80, [tmp, tmp | 8]),
new HitAreaData(1.00, 1.00, [j])
];
break;
case 6:
tmp = (i - 1) & 7;
HitAreasLookup[key] =
[
new HitAreaData(0.20, 0.38, [i]),
new HitAreaData(0.62, 0.80, [tmp, tmp | 8]),
new HitAreaData(1.00, 1.00, [j])
];
break;
default:
break;
}
// Bi -> Bj
key = ((i | 8) << 5) | (j | 8);
switch (diff)
{
case 1:
case 7:
HitAreasLookup[key] =
[
new HitAreaData(0.44, 0.56, [i | 8]),
new HitAreaData(1.00, 1.00, [j | 8])
];
break;
case 2:
tmp = (i + 1) & 7;
HitAreasLookup[key] =
[
new HitAreaData(0.22, 0.35, [i | 8]),
new HitAreaData(0.65, 0.78, [tmp | 8, 16]),
new HitAreaData(1.00, 1.00, [j | 8])
];
break;
case 6:
tmp = (i - 1) & 7;
HitAreasLookup[key] =
[
new HitAreaData(0.22, 0.35, [i | 8]),
new HitAreaData(0.65, 0.78, [tmp | 8, 16]),
new HitAreaData(1.00, 1.00, [j | 8])
];
break;
case 3:
tmp = (i + 1) & 7;
tmp2 = (i + 2) & 7;
HitAreasLookup[key] =
[
new HitAreaData(0.15, 0.28, [i | 8]),
new HitAreaData(0.48, 0.52, [tmp | 8, 16]),
new HitAreaData(0.72, 0.85, [tmp2 | 8, 16]),
new HitAreaData(1.00, 1.00, [j | 8])
];
break;
case 5:
tmp = (i - 1) & 7;
tmp2 = (i - 2) & 7;
HitAreasLookup[key] =
[
new HitAreaData(0.15, 0.28, [i | 8]),
new HitAreaData(0.48, 0.52, [tmp | 8, 16]),
new HitAreaData(0.72, 0.85, [tmp2 | 8, 16]),
new HitAreaData(1.00, 1.00, [j | 8])
];
break;
default:
break;
}
// Ai <-> Bj
key = (i << 5) | (j | 8);
var key2 = ((j | 8) << 5) | i;
switch (diff)
{
case 0:
HitAreasLookup[key] =
[
new HitAreaData(0.60, 0.75, [i]),
new HitAreaData(1.00, 1.00, [j | 8])
];
HitAreasLookup[key2] =
[
new HitAreaData(0.25, 0.40, [j | 8]),
new HitAreaData(1.00, 1.00, [i])
];
break;
case 1:
case 7:
HitAreasLookup[key] =
[
new HitAreaData(0.45, 0.77, [i]),
new HitAreaData(1.00, 1.00, [j | 8])
];
HitAreasLookup[key2] =
[
new HitAreaData(0.23, 0.55, [j | 8]),
new HitAreaData(1.00, 1.00, [i])
];
break;
case 3:
tmp = (i + 1) & 7;
tmp2 = (i + 2) & 7;
HitAreasLookup[key] =
[
new HitAreaData(0.25, 0.34, [i]),
new HitAreaData(0.54, 0.68, [i | 8, tmp | 8]),
new HitAreaData(0.85, 0.90, [tmp2 | 8, 16]),
new HitAreaData(1.00, 1.00, [j | 8])
];
HitAreasLookup[key2] =
[
new HitAreaData(0.10, 0.15, [j | 8]),
new HitAreaData(0.32, 0.46, [tmp2 | 8, 16]),
new HitAreaData(0.66, 0.75, [i | 8, tmp | 8]),
new HitAreaData(1.00, 1.00, [i])
];
break;
case 5:
tmp = (i - 1) & 7;
tmp2 = (i - 2) & 7;
HitAreasLookup[key] =
[
new HitAreaData(0.25, 0.34, [i]),
new HitAreaData(0.54, 0.68, [i | 8, tmp | 8]),
new HitAreaData(0.85, 0.90, [tmp2 | 8, 16]),
new HitAreaData(1.00, 1.00, [j | 8])
];
HitAreasLookup[key2] =
[
new HitAreaData(0.10, 0.15, [j | 8]),
new HitAreaData(0.32, 0.46, [tmp2 | 8, 16]),
new HitAreaData(0.66, 0.75, [i | 8, tmp | 8]),
new HitAreaData(1.00, 1.00, [i])
];
break;
default:
break;
}
// C <-> Bj
key = (16 << 5) | (j | 8);
key2 = ((j | 8) << 5) | 16;
HitAreasLookup[key] =
[
new HitAreaData(0.50, 0.70, [16]),
new HitAreaData(1.00, 1.00, [j | 8])
];
HitAreasLookup[key2] =
[
new HitAreaData(0.30, 0.50, [j | 8]),
new HitAreaData(1.00, 1.00, [16])
];
}
}
}
public static List<HitAreaData> BuildHitAreas(ParametricSlidePath path)
{
var nodeList = new List<Tuple<int, double>>();
var totalLength = path.GetPathLength();
var count = (int)Math.Round(totalLength / 10.0);
int? lastNode = null;
var enterLength = 0.0;
for (var i = 0; i < count; i++)
{
var t = (double)i / count;
var pt = path.GetPointAt(t);
int? node = null;
if (pt.Magnitude < 55.0)
{
node = 16;
}
else for (var j = 0; j < 8; j++)
{
var phi = Math.PI * (3.0 / 8.0 - j / 4.0);
if ((pt - Complex.FromPolarCoordinates(440.0, phi)).Magnitude < 80.0)
{
node = j;
break;
}
if ((pt - Complex.FromPolarCoordinates(210.0, phi)).Magnitude < 45.0)
{
node = j | 8;
break;
}
}
if (lastNode != node)
{
var length = t * totalLength;
if (lastNode == null)
{
enterLength = length;
}
else
{
nodeList.Add(new Tuple<int, double>(lastNode.Value, (length + enterLength) / 2.0));
if (node != null)
{
enterLength = length;
}
}
}
lastNode = node;
}
nodeList.Add(new Tuple<int, double>(lastNode!.Value, totalLength));
nodeList[0] = new Tuple<int, double>(nodeList[0].Item1, 0.0);
var result = new List<HitAreaData>();
result.Add(new HitAreaData(0.0, 0.0, [nodeList[0].Item1]));
for (var i = 1; i < nodeList.Count; i++)
{
var key = (nodeList[i - 1].Item1 << 5) | nodeList[i].Item1;
var segmentLength = nodeList[i].Item2 - nodeList[i - 1].Item2;
var data = HitAreasLookup[key];
var area = result[result.Count - 1];
result[result.Count - 1] = new HitAreaData(
area.PushDistance + segmentLength * data[0].PushDistance,
area.ReleaseDistance + segmentLength * data[0].ReleaseDistance,
area.PanelAreas
);
for (var j = 1; j < data.Length; j++)
{
result.Add(new HitAreaData(
segmentLength * (data[j].PushDistance - data[j - 1].ReleaseDistance),
segmentLength * (data[j].ReleaseDistance - data[j].PushDistance),
data[j].PanelAreas
));
}
}
double lastPushDistance = 0.0;
if (path.GetEndType(OptionMirrorID.Normal) == SlideType.Slide_Straight)
{
var diff = nodeList[nodeList.Count - 1].Item1 - nodeList[nodeList.Count - 2].Item1;
diff %= 8;
lastPushDistance = diff switch
{
1 or 2 or 6 or 7 => 130.0,
_ => 159.0
};
}
else
{
lastPushDistance = 175.0;
}
var last2ndArea = result[result.Count - 2];
var lastArea = result[result.Count - 1];
var distance = last2ndArea.ReleaseDistance + lastArea.PushDistance + lastArea.ReleaseDistance;
result[result.Count - 2] = new HitAreaData(last2ndArea.PushDistance, distance - lastPushDistance, last2ndArea.PanelAreas);
result[result.Count - 1] = new HitAreaData(lastPushDistance, 0.0, lastArea.PanelAreas);
return result;
}
/// <summary>
/// Convert hit area data to sinmai format (Vector4)
/// </summary>
/// <param name="data">hit area data generated by BuildHitAreas()</param>
/// <param name="starButton">button index of slide-star</param>
/// <param name="mirrorMode">mirror mode in user option</param>
/// <returns>sinmai format arrow data, referenced to slide-star</returns>
public static List<SlideManager.HitArea> ConvertAndRotateHitAreas(IEnumerable<HitAreaData> data, int starButton,
OptionMirrorID mirrorMode)
{
var hitAreaList = new List<SlideManager.HitArea>();
foreach (var hitAreaData in data)
{
var hitArea = new SlideManager.HitArea();
hitArea.PushDistance = hitAreaData.PushDistance;
hitArea.ReleaseDistance = hitAreaData.ReleaseDistance;
foreach (var pad in hitAreaData.PanelAreas)
{
var converted = MaiGeometry.MirrorInfo[(int) mirrorMode, pad];
converted = converted == 16 ? 16 : (converted - starButton) & 0b111 | converted & 0b1000;
hitArea.HitPoints.Add((InputManager.TouchPanelArea) converted);
}
hitAreaList.Add(hitArea);
}
return hitAreaList;
}
}

View File

@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AquaMai.Mods.Fancy.GamePlay.CustomNoteTypes.Libs;
public class SlidePathGenerator
{
public List<ParametricSlidePath.PathSegment> PathSegments = new List<ParametricSlidePath.PathSegment>();
public Complex CurrentEndPoint = Complex.Zero;
public static SlidePathGenerator BeginAt(Complex point)
{
var obj = new SlidePathGenerator();
obj.CurrentEndPoint = point;
return obj;
}
public static double CalcTangentAngle(Complex point, MaiGeometry.CircleStruct circle, bool isCcw)
{
var hypot = point - circle.Center;
var angleDelta = Math.Acos(circle.Radius / hypot.Magnitude);
var tanAngle = hypot.Phase + (isCcw ? angleDelta : -angleDelta);
return Math.IEEERemainder(tanAngle, Math.PI * 2.0);
}
public void TrySetLastParseMarker(ParametricSlidePath.ParseMarker marker)
{
if (PathSegments.Count <= 0) return;
PathSegments[PathSegments.Count - 1].SetParseMarker(marker);
}
public void LineToPoint(Complex point)
{
PathSegments.Add(new ParametricSlidePath.LineSegment(CurrentEndPoint, point));
CurrentEndPoint = point;
}
public void TangentToCircle(MaiGeometry.CircleStruct circle, bool isCcw)
{
var inAngle = CalcTangentAngle(CurrentEndPoint, circle, isCcw);
var inPoint = Complex.FromPolarCoordinates(circle.Radius, inAngle) + circle.Center;
LineToPoint(inPoint);
}
/// <summary>Note: endAngle should be in range [-PI, PI]</summary>
public void ArcToAngle(Complex center, double endAngle, bool isCcw, bool skipIfZero)
{
var diff = CurrentEndPoint - center;
var circle = new MaiGeometry.CircleStruct(center, diff.Magnitude);
var startAngle = diff.Phase;
// startAngle and endAngle in range [-PI, PI]
if (isCcw)
{
if (startAngle > endAngle)
{
startAngle -= 2 * Math.PI;
}
if (Math.Abs(endAngle - startAngle) < 0.001)
{
if (skipIfZero) return;
endAngle += 2 * Math.PI;
}
}
else
{
if (startAngle < endAngle)
{
startAngle += 2 * Math.PI;
}
if (Math.Abs(endAngle - startAngle) < 0.001)
{
if (skipIfZero) return;
endAngle -= 2 * Math.PI;
}
}
var seg = new ParametricSlidePath.ArcSegment(circle, startAngle, endAngle);
PathSegments.Add(seg);
CurrentEndPoint = seg.GetPointAt(1f);
}
public void ArcToTangentTowards(Complex target, Complex center, bool isCcw)
{
var diff = CurrentEndPoint - center;
var endAngle = CalcTangentAngle(target, new MaiGeometry.CircleStruct(center, diff.Magnitude), !isCcw);
ArcToAngle(center, endAngle, isCcw, false);
}
public void FullCircle(Complex center, bool isCcw)
{
var diff = CurrentEndPoint - center;
var circle = new MaiGeometry.CircleStruct(center, diff.Magnitude);
PathSegments.Add(new ParametricSlidePath.CircleSegment(circle, diff.Phase, isCcw));
// CurrentEndPoint not changed
}
public void ExternTangentTransfer(Complex currentCenter, MaiGeometry.CircleStruct targetCircle, bool isCcw)
{
var diff = CurrentEndPoint - currentCenter;
double endAngle;
if (Math.Abs(diff.Magnitude - targetCircle.Radius) < 0.001)
{
// two circles are approximately same radius
var vector = targetCircle.Center - currentCenter;
vector *= isCcw ? -Complex.ImaginaryOne : Complex.ImaginaryOne;
endAngle = vector.Phase;
}
else if (targetCircle.Radius > diff.Magnitude)
{
// target circle larger
var helperCircle = new MaiGeometry.CircleStruct(targetCircle.Center, targetCircle.Radius - diff.Magnitude);
endAngle = CalcTangentAngle(currentCenter, helperCircle, isCcw);
}
else
{
var helperCircle = new MaiGeometry.CircleStruct(currentCenter, diff.Magnitude - targetCircle.Radius);
endAngle = CalcTangentAngle(targetCircle.Center, helperCircle, !isCcw);
}
ArcToAngle(currentCenter, endAngle, isCcw, false);
var inPoint = Complex.FromPolarCoordinates(targetCircle.Radius, endAngle) + targetCircle.Center;
LineToPoint(inPoint);
}
public ParametricSlidePath GeneratePath()
{
return new ParametricSlidePath(PathSegments);
}
}

View File

@@ -0,0 +1,50 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Monitor;
using TMPro;
using UI;
using UnityEngine;
namespace AquaMai.Mods.Fancy.GamePlay;
[ConfigSection(
en: """
Disable the TRACK X text, DX/Standard display box, and the derakkuma at the bottom of the screen in the song start screen.
For recording chart confirmation.
""",
zh: """
在歌曲开始界面, 把 TRACK X 字样, DX/标准谱面的显示框, 以及画面下方的滴蜡熊隐藏掉
录制谱面确认用
""")]
public class DisableTrackStartTabs
{
// 在歌曲开始界面, 把 TRACK X 字样, DX/标准谱面的显示框, 以及画面下方的滴蜡熊隐藏掉, 让他看起来不那么 sinmai, 更像是 majdata
[HarmonyPostfix]
[HarmonyPatch(typeof(TrackStartMonitor), "SetTrackStart")]
private static void DisableTabs(
SpriteCounter ____trackNumber, SpriteCounter ____bossTrackNumber, SpriteCounter ____utageTrackNumber,
MultipleImage ____musicTabImage, GameObject[] ____musicTabObj, GameObject ____derakkumaRoot,
TimelineRoot ____musicDetail
)
{
____trackNumber.transform.parent.gameObject.SetActive(false);
____bossTrackNumber.transform.parent.gameObject.SetActive(false);
____utageTrackNumber.transform.parent.gameObject.SetActive(false);
____musicTabImage.gameObject.SetActive(false);
____musicTabObj[0].gameObject.SetActive(false);
____musicTabObj[1].gameObject.SetActive(false);
____musicTabObj[2].gameObject.SetActive(false);
____derakkumaRoot.SetActive(false);
var traverse = Traverse.Create(____musicDetail);
traverse.Field<MultipleImage>("_achivement_Base").Value.ChangeSprite(1);
traverse.Field<MultipleImage>("_clearRank_Base").Value.ChangeSprite(1);
traverse.Field<TextMeshProUGUI>("_achivement_Text").Value.gameObject.SetActive(false);
traverse.Field<TextMeshProUGUI>("_achivement_decimal_Text").Value.gameObject.SetActive(false);
traverse.Field<TextMeshProUGUI>("_achivement_percent_Text").Value.gameObject.SetActive(false);
traverse.Field<MultipleImage>("_clearRank_Image").Value.gameObject.SetActive(false);
traverse.Field<GameObject>("_deluxScore_Obj").Value.SetActive(false);
traverse.Field<MultipleImage>("_comboRank_Image").Value.ChangeSprite(0);
traverse.Field<MultipleImage>("_syncRank_Image").Value.ChangeSprite(0);
}
}

View File

@@ -0,0 +1,129 @@
using System.Collections.Generic;
using AquaMai.Core.Attributes;
using AquaMai.Config.Attributes;
using HarmonyLib;
using MAI2.Util;
using Manager;
using Monitor;
using Monitor.Game;
using UnityEngine;
namespace AquaMai.Mods.Fancy.GamePlay;
[ConfigSection(
en: "Add notes sprite to the pool to prevent use up.",
zh: "增加更多待命的音符贴图,防止奇怪的自制谱用完音符贴图池")]
[EnableGameVersion(23000)]
public class ExtendNotesPool
{
[ConfigEntry(
en: "Number of objects to add.",
zh: "要增加的对象数量")]
private readonly static int count = 128;
[HarmonyPostfix]
[HarmonyPatch(typeof(GameCtrl), "CreateNotePool")]
public static void CreateNotePool(ref GameCtrl __instance,
GameObject ____tapListParent, List<TapNote> ____tapObjectList,
GameObject ____holdListParent, List<HoldNote> ____holdObjectList,
GameObject ____breakHoldListParent, List<BreakHoldNote> ____breakHoldObjectList,
GameObject ____starListParent, List<StarNote> ____starObjectList,
GameObject ____breakStarListParent, List<BreakStarNote> ____breakStarObjectList,
GameObject ____breakListParent, List<BreakNote> ____breakObjectList,
GameObject ____touchListParent, List<TouchNoteB> ____touchBObjectList,
GameObject ____touchCTapListParent, List<TouchNoteC> ____touchCTapObjectList,
GameObject ____touchCHoldListParent, List<TouchHoldC> ____touchCHoldObjectList,
GameObject ____slideListParent, List<SlideRoot> ____slideObjectList,
GameObject ____fanSlideListParent, List<SlideFan> ____fanSlideObjectList,
GameObject ____slideJudgeListParent, List<SlideJudge> ____judgeSlideObjectList,
GameObject ____guideListParent, List<NoteGuide> ____guideObjectList,
GameObject ____barGuideListParent, List<BarGuide> ____barGuideObjectList,
List<SpriteRenderer> ____arrowObjectList, List<BreakSlide> ____breakArrowObjectList
)
{
for (var i = 0; i < count; i++)
{
var tapNote = Object.Instantiate(GameNotePrefabContainer.Tap, ____tapListParent.transform);
tapNote.gameObject.SetActive(false);
tapNote.ParentTransform = ____tapListParent.transform;
____tapObjectList.Add(tapNote);
var holdNote = Object.Instantiate(GameNotePrefabContainer.Hold, ____holdListParent.transform);
holdNote.gameObject.SetActive(false);
holdNote.ParentTransform = ____holdListParent.transform;
____holdObjectList.Add(holdNote);
var breakHoldNote = Object.Instantiate(GameNotePrefabContainer.BreakHold, ____breakHoldListParent.transform);
breakHoldNote.gameObject.SetActive(false);
breakHoldNote.ParentTransform = ____holdListParent.transform;
____breakHoldObjectList.Add(breakHoldNote);
var starNote = Object.Instantiate(GameNotePrefabContainer.Star, ____starListParent.transform);
starNote.gameObject.SetActive(false);
starNote.ParentTransform = ____starListParent.transform;
____starObjectList.Add(starNote);
var breakStarNote = Object.Instantiate(GameNotePrefabContainer.BreakStar, ____breakStarListParent.transform);
breakStarNote.gameObject.SetActive(false);
breakStarNote.ParentTransform = ____breakStarListParent.transform;
____breakStarObjectList.Add(breakStarNote);
var breakNote = Object.Instantiate(GameNotePrefabContainer.Break, ____breakListParent.transform);
breakNote.gameObject.SetActive(false);
breakNote.ParentTransform = ____breakListParent.transform;
____breakObjectList.Add(breakNote);
var touchNoteB = Object.Instantiate(GameNotePrefabContainer.TouchTapB, ____touchListParent.transform);
touchNoteB.gameObject.SetActive(false);
touchNoteB.ParentTransform = ____touchListParent.transform;
____touchBObjectList.Add(touchNoteB);
var touchNoteC = Object.Instantiate(GameNotePrefabContainer.TouchTapC, ____touchCTapListParent.transform);
touchNoteC.gameObject.SetActive(false);
touchNoteC.ParentTransform = ____touchCTapListParent.transform;
____touchCTapObjectList.Add(touchNoteC);
var touchHoldC = Object.Instantiate(GameNotePrefabContainer.TouchHoldC, ____touchCHoldListParent.transform);
touchHoldC.gameObject.SetActive(false);
touchHoldC.ParentTransform = ____touchCHoldListParent.transform;
____touchCHoldObjectList.Add(touchHoldC);
var slideRoot = Object.Instantiate(GameNotePrefabContainer.Slide, ____slideListParent.transform);
slideRoot.gameObject.SetActive(false);
slideRoot.ParentTransform = ____slideListParent.transform;
____slideObjectList.Add(slideRoot);
var slideFan = Object.Instantiate(GameNotePrefabContainer.SlideFan, ____fanSlideListParent.transform);
slideFan.gameObject.SetActive(false);
slideFan.ParentTransform = ____fanSlideListParent.transform;
____fanSlideObjectList.Add(slideFan);
var slideJudge = Object.Instantiate(GameNotePrefabContainer.SlideJudge, ____slideJudgeListParent.transform);
slideJudge.gameObject.SetActive(false);
slideJudge.ParentTransform = ____slideJudgeListParent.transform;
slideJudge.SetOption(Singleton<GamePlayManager>.Instance.GetGameScore(__instance.MonitorIndex).UserOption.DispJudge);
____judgeSlideObjectList.Add(slideJudge);
for (var j = 0; j < 50; j++)
{
var spriteRenderer = Object.Instantiate(GameNotePrefabContainer.Arrow, ____slideListParent.transform);
spriteRenderer.gameObject.SetActive(false);
____arrowObjectList.Add(spriteRenderer);
var breakSlide = Object.Instantiate(GameNotePrefabContainer.BreakArrow, ____slideListParent.transform);
breakSlide.gameObject.SetActive(false);
____breakArrowObjectList.Add(breakSlide);
}
var noteGuide = Object.Instantiate(GameNotePrefabContainer.Guide, ____guideListParent.transform);
noteGuide.gameObject.SetActive(false);
noteGuide.ParentTransform = ____guideListParent.transform;
____guideObjectList.Add(noteGuide);
var barGuide = Object.Instantiate(GameNotePrefabContainer.BarGuide, ____barGuideListParent.transform);
barGuide.gameObject.SetActive(false);
barGuide.ParentTransform = ____barGuideListParent.transform;
____barGuideObjectList.Add(barGuide);
}
}
}

View File

@@ -0,0 +1,42 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Monitor;
namespace AquaMai.Mods.Fancy.GamePlay;
[ConfigSection(
en: """
Make the judgment display of WiFi Slide different in up and down (originally all WiFi judgment displays are towards the center), just like in majdata.
The reason for this bug is that SEGA forgot to assign EndButtonId to WiFi.
""",
zh: """
这个 Patch 让 WiFi Slide 的判定显示有上下的区别 (原本所有 WiFi 的判定显示都是朝向圆心的), 就像 majdata 里那样
这个 bug 产生的原因是 SBGA 忘记给 WiFi 的 EndButtonId 赋值了
""")]
public class FanJudgeFlip
{
/*
* 这个 Patch 让 WiFi Slide 的判定显示有上下的区别 (原本所有 WiFi 的判定显示都是朝向圆心的), 就像 majdata 里那样
* 这个 bug 产生的原因是 SBGA 忘记给 WiFi 的 EndButtonId 赋值了
* 不过需要注意的是, 考虑到圆弧形 Slide 的判定显示就是永远朝向圆心的, 我个人会觉得这个 Patch 关掉更好看一点
*/
[HarmonyPostfix]
[HarmonyPatch(typeof(SlideFan), "Initialize")]
private static void FixFanJudgeFilp(
int[] ___GoalButtonId, SlideJudge ___JudgeObj
)
{
if (null != ___JudgeObj)
{
if (2 <= ___GoalButtonId[1] && ___GoalButtonId[1] <= 5)
{
___JudgeObj.Flip(false);
___JudgeObj.transform.Rotate(0.0f, 0.0f, 180f);
}
else
{
___JudgeObj.Flip(true);
}
}
}
}

View File

@@ -0,0 +1,24 @@
using AquaMai.Config.Attributes;
using Fx;
using HarmonyLib;
using Monitor;
using UnityEngine;
namespace AquaMai.Mods.Fancy.GamePlay;
[ConfigSection(
en: "Hide hanabi completely.",
zh: "完全隐藏烟花")]
public class HideHanabi
{
[HarmonyPatch(typeof(TapCEffect), "SetUpParticle")]
[HarmonyPostfix]
public static void FixZeroSize(TapCEffect __instance, FX_Mai2_Note_Color ____particleControler)
{
var entities = ____particleControler.GetComponentsInChildren<ParticleSystemRenderer>(true);
foreach (var entity in entities)
{
entity.maxParticleSize = 0f;
}
}
}

View File

@@ -0,0 +1,86 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Monitor;
using UnityEngine;
namespace AquaMai.Mods.Fancy.GamePlay;
[ConfigSection(
en: """
More detailed judgment display.
Requires CustomSkins to be enabled and the resource file to be downloaded.
https://github.com/hykilpikonna/AquaDX/releases/download/nightly/JudgeDisplay4B.7z
""",
zh: """
更精细的判定表示
需开启 CustomSkins 并下载资源文件
https://github.com/hykilpikonna/AquaDX/releases/download/nightly/JudgeDisplay4B.7z
""")]
public class JudgeDisplay4B
{
// 精确到子判定的自定义判定显示, 需要启用自定义皮肤功能 (理论上不启用自定义皮肤不会崩游戏, 只不过此时这个功能显然不会生效)
[HarmonyPostfix]
[HarmonyPatch(typeof(SlideJudge), "Initialize")]
private static void SlideJudgeDisplay4B(
SpriteRenderer ___SpriteRenderAdd, SpriteRenderer ___SpriteRender,
SlideJudge.SlideJudgeType ____judgeType, SlideJudge.SlideAngle ____angle,
NoteJudge.ETiming judge, float msec, bool isBreak
)
{
var i = isBreak ? 1 : 0;
Sprite sprite = CustomSkins.CustomJudgeSlide[i, (int)____judgeType, (int)____angle, (int)judge];
if (sprite != null) {
___SpriteRender.sprite = sprite;
}
if (isBreak && judge == NoteJudge.ETiming.Critical)
{
sprite = CustomSkins.CustomJudgeSlide[i, (int)____judgeType, (int)____angle, (int) NoteJudge.ETiming.End];
if (sprite != null)
{
___SpriteRenderAdd.sprite = sprite;
}
}
}
[HarmonyPostfix]
[HarmonyPatch(typeof(JudgeGrade), "Initialize")]
private static void JudgeGradeDisplay4B(
SpriteRenderer ___SpriteRender,
NoteJudge.ETiming judge, float msec, NoteJudge.EJudgeType type
)
{
var i = (type == NoteJudge.EJudgeType.Break) ? 1 : 0;
Sprite sprite = CustomSkins.CustomJudge[i, (int)judge];
if (sprite != null) {
___SpriteRender.sprite = sprite;
}
}
[HarmonyPostfix]
[HarmonyPatch(typeof(JudgeGrade), "InitializeBreak")]
private static void JudgeGradeBreakDisplay4B(
SpriteRenderer ___SpriteRenderAdd,
NoteJudge.ETiming judge, float msec, NoteJudge.EJudgeType type
)
{
if (judge == NoteJudge.ETiming.Critical)
{
var sprite = CustomSkins.CustomJudge[1, (int) NoteJudge.ETiming.End];
if (sprite != null)
{
___SpriteRenderAdd.sprite = sprite;
}
}
}
[HarmonyPrefix]
[HarmonyPatch(typeof(JudgeGrade), "InitializeBreak")]
private static void InitializeBreakFix(ref NoteJudge.EJudgeType type)
{
type = NoteJudge.EJudgeType.Break;
}
}

View File

@@ -0,0 +1,39 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager;
namespace AquaMai.Mods.Fancy.GamePlay;
[ConfigSection(
en: """
Make the AutoPlay random judgment mode really randomize all judgments (down to sub-judgments).
The original random judgment will only produce all 15 judgment results from Miss(TooFast) ~ Critical ~ Miss(TooLate).
Here, it is changed to a triangular distribution to produce all 15 judgment results from Miss(TooFast) ~ Critical ~ Miss(TooLate).
Of course, it will not consider whether the original Note really has a corresponding judgment (such as Slide should not have non-Critical Prefect).
""",
zh: """
让 AutoPlay 的随机判定模式真的会随机产生所有的判定 (精确到子判定)
原本的随机判定只会等概率产生 Critical, LateGreat1st, LateGood, Miss(TooLate)
这里改成三角分布产生从 Miss(TooFast) ~ Critical ~ Miss(TooLate) 的所有 15 种判定结果
当然, 此处并不会考虑原本那个 Note 是不是真的有对应的判定 (比如 Slide 实际上不应该有小 p 之类的)
""")]
public class RealisticRandomJudge
{
// 让 AutoPlay 的随机判定模式真的会随机产生所有的判定 (精确到子判定)
// 原本的随机判定只会等概率产生 Critical, LateGreat1st, LateGood, Miss(TooLate)
// 这里改成三角分布产生从 Miss(TooFast) ~ Critical ~ Miss(TooLate) 的所有 15 种判定结果
// 当然, 此处并不会考虑原本那个 Note 是不是真的有对应的判定 (比如 Slide 实际上不应该有小 p 之类的)
[HarmonyPostfix]
[HarmonyPatch(typeof(GameManager), "AutoJudge")]
private static NoteJudge.ETiming RealAutoJudgeRandom(NoteJudge.ETiming retval)
{
if (GameManager.AutoPlay == GameManager.AutoPlayMode.Random)
{
var x = UnityEngine.Random.Range(0, 8);
x += UnityEngine.Random.Range(0, 8);
return (NoteJudge.ETiming) x;
}
return retval;
}
}

View File

@@ -0,0 +1,118 @@
using System.Collections.Generic;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager;
using Monitor;
using Monitor.Game;
using Process;
using UnityEngine;
namespace AquaMai.Mods.Fancy.GamePlay;
[ConfigSection(
en: "Make the Slide Track disappear with an inward-shrinking animation, similar to AstroDX.",
zh: "使 Slide Track 消失时有类似 AstroDX 一样的向内缩入的动画")]
public class SlideArrowAnimation
{
private static List<SpriteRenderer> _animatingSpriteRenderers = [];
[HarmonyTranspiler]
[HarmonyPatch(typeof(SlideRoot), "NoteCheck")]
private static IEnumerable<CodeInstruction> GetUnVisibleColorHook(IEnumerable<CodeInstruction> instructions)
{
var methodGetUnVisibleColor = AccessTools.Method(typeof(SlideRoot), "GetUnVisibleColor");
var oldInstList = new List<CodeInstruction>(instructions);
var newInstList = new List<CodeInstruction>();
for (var i = 0; i < oldInstList.Count; i++)
{
var inst = oldInstList[i];
if (inst.Calls(methodGetUnVisibleColor))
{
// 现在栈上应该有: SpriteRenderer, SlideRoot(this)
// 这一条 IL 会消耗 this, 调用 GetUnVisibleColor(), 推一个 Color 到栈上
// 然后接下来的一条 IL 是调用 SpriteRenderer.color 的 setter 把 SpriteRenderer 和 Color 一起消耗掉
// 我们现在直接用一个 static method 消耗掉 SpriteRenderer 和 this
// 所以要忽略当前 IL, 再忽略下一条 IL, 然后构造一个 Call
// ReSharper disable once ConvertClosureToMethodGroup
var redirect = CodeInstruction.Call((SpriteRenderer r, SlideRoot s) => OnSlideArrowDisable(r, s));
newInstList.Add(redirect);
i++; // 跳过下一条 IL
}
else
{
newInstList.Add(inst);
}
}
return newInstList;
}
public static void OnSlideArrowDisable(SpriteRenderer renderer, SlideRoot slideRoot)
{
_animatingSpriteRenderers.Add(renderer);
}
[HarmonyPostfix]
[HarmonyPatch(typeof(SlideRoot), "SetArrowObject")]
private static void RemoveArrowAnimation(GameObject arrowobj)
{
var spriteRenderer = arrowobj.GetComponent<SpriteRenderer>();
spriteRenderer.transform.localScale = Vector3.one;
if (_animatingSpriteRenderers.Contains(spriteRenderer))
{
_animatingSpriteRenderers.Remove(spriteRenderer);
}
}
[HarmonyPostfix]
[HarmonyPatch(typeof(SlideRoot), "SetBreakArrowObject")]
private static void RemoveBreakArrowAnimation(GameObject breakArrowobj)
{
var breakSlideObj = breakArrowobj.GetComponent<BreakSlide>();
breakSlideObj.SpriteRender.transform.localScale = Vector3.one;
if (_animatingSpriteRenderers.Contains(breakSlideObj.SpriteRender))
{
_animatingSpriteRenderers.Remove(breakSlideObj.SpriteRender);
}
}
[HarmonyPostfix]
[HarmonyPatch(typeof(GameCtrl), "UpdateNotes")]
private static void OnGameCtrlUpdateNotesLast()
{
for (var num = _animatingSpriteRenderers.Count - 1; num >= 0; num--)
{
var spriteRenderer = _animatingSpriteRenderers[num];
if (spriteRenderer == null || !spriteRenderer.gameObject.activeSelf)
{
_animatingSpriteRenderers.RemoveAt(num);
}
else
{
var localScale = spriteRenderer.transform.localScale;
var scale = localScale.y - NotesManager.GetAddMSec() / 150f;
if (scale <= 0)
{
spriteRenderer.transform.localScale = new Vector3(1f, 0f, 1f);
spriteRenderer.color = new Color(1f, 1f, 1f, 0f);
_animatingSpriteRenderers.RemoveAt(num);
}
else
{
localScale.y = scale;
spriteRenderer.color = new Color(1f, 1f, 1f, Mathf.Sqrt(scale));
spriteRenderer.transform.localScale = localScale;
}
}
}
}
[HarmonyPrefix]
[HarmonyPatch(typeof(GameProcess), "SetRelease")]
private static void OnBeforeGameProcessSetRelease()
{
_animatingSpriteRenderers.Clear();
}
}

View File

@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using AquaMai.Config.Attributes;
using HarmonyLib;
using MAI2.Util;
using Manager;
using Monitor;
using UnityEngine;
namespace AquaMai.Mods.Fancy.GamePlay;
[ConfigSection(
zh: "让星星在启动拍等待期间从 50% 透明度渐入为 100%,取代原本在击打星星头时就完成渐入",
en: "Slides will fade in instead of instantly appearing.")]
public class SlideFadeInTweak
{
[HarmonyPrefix]
[HarmonyPatch(typeof(SlideRoot), "UpdateAlpha")]
private static bool UpdateAlphaOverwrite(
SlideRoot __instance,
ref bool ___UpdateAlphaFlag,
float ___StartMsec, float ___AppearMsec, float ___StarLaunchMsec, float ___DefaultMsec,
int ____dispLaneNum, bool ___BreakFlag,
List<SpriteRenderer> ____spriteRenders, List<BreakSlide> ____breakSpriteRenders
)
{
if (!___UpdateAlphaFlag)
return false;
var currentMsec = NotesManager.GetCurrentMsec();
var slideSpeed = (int) Singleton<GamePlayManager>.Instance.GetGameScore(__instance.MonitorId).UserOption.SlideSpeed;
var defaultFadeInLength = (21 - slideSpeed) / 10.5f * ___DefaultMsec;
var fadeInFirstMsec = Math.Max(___StartMsec, ___AppearMsec - defaultFadeInLength);
var fadeInSecondMsec = Math.Max(___AppearMsec, ___StarLaunchMsec - defaultFadeInLength);
// var fadeInSecondMsec = ___AppearMsec;
var color = new Color(1f, 1f, 1f, 1f);
if (currentMsec >= ___StarLaunchMsec)
{
___UpdateAlphaFlag = false;
}
else if (currentMsec < fadeInFirstMsec)
{
color.a = 0.0f;
}
else if (fadeInFirstMsec <= currentMsec && currentMsec < ___AppearMsec)
{
var fadeInLength = Math.Min(200.0f, ___AppearMsec - fadeInFirstMsec);
color.a = 0.5f * Math.Min(1f, (currentMsec - fadeInFirstMsec) / fadeInLength);
}
else if (___AppearMsec <= currentMsec && currentMsec < fadeInSecondMsec)
{
color.a = 0.5f;
}
else if (fadeInSecondMsec <= currentMsec && currentMsec < ___StarLaunchMsec)
{
var fadeInLength = Math.Min(200.0f, ___StarLaunchMsec - fadeInSecondMsec);
// var fadeInLength = ___StarLaunchMsec - fadeInSecondMsec;
color.a = 0.5f + 0.5f * Math.Min(1f, (currentMsec - fadeInSecondMsec) / fadeInLength);
}
if (!___BreakFlag)
{
for (var index = 0; index < ____dispLaneNum; ++index)
{
if (index >= ____spriteRenders.Count) break;
____spriteRenders[index].color = color;
}
}
else
{
for (var index = 0; index < ____dispLaneNum; ++index)
{
if (index >= ____breakSpriteRenders.Count) break;
____breakSpriteRenders[index].SpriteRender.color = color;
}
}
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(SlideFan), "UpdateAlpha")]
private static bool UpdateFanAlphaOverwrite(
SlideRoot __instance,
float ___StartMsec, float ___AppearMsec, float ___StarLaunchMsec, float ___DefaultMsec,
Color ____defaultColor, SpriteRenderer[] ____spriteLines
)
{
var currentMsec = NotesManager.GetCurrentMsec();
var slideSpeed = (int) Singleton<GamePlayManager>.Instance.GetGameScore(__instance.MonitorId).UserOption.SlideSpeed;
var defaultFadeInLength = (21 - slideSpeed) / 10.5f * ___DefaultMsec;
var fadeInFirstMsec = Math.Max(___StartMsec, ___AppearMsec - defaultFadeInLength);
var fadeInSecondMsec = Math.Max(___AppearMsec, ___StarLaunchMsec - defaultFadeInLength);
// var fadeInSecondMsec = ___AppearMsec;
var color = ____defaultColor;
if (currentMsec < fadeInFirstMsec)
{
color.a = 0.0f;
}
else if (fadeInFirstMsec <= currentMsec && currentMsec < ___AppearMsec)
{
var fadeInLength = Math.Min(200.0f, ___AppearMsec - fadeInFirstMsec);
color.a = 0.3f * Math.Min(1f, (currentMsec - fadeInFirstMsec) / fadeInLength);
}
else if (___AppearMsec <= currentMsec && currentMsec < fadeInSecondMsec)
{
color.a = 0.3f;
}
else if (fadeInSecondMsec <= currentMsec && currentMsec < ___StarLaunchMsec)
{
var fadeInLength = Math.Min(200.0f, ___StarLaunchMsec - fadeInSecondMsec);
// var fadeInLength = ___StarLaunchMsec - fadeInSecondMsec;
color.a = 0.3f + 0.3f * Math.Min(1f, (currentMsec - fadeInSecondMsec) / fadeInLength);
}
else
{
color.a = 0.6f;
}
foreach (SpriteRenderer spriteLine in ____spriteLines)
spriteLine.color = color;
return false;
}
}

View File

@@ -0,0 +1,76 @@
using System.Collections.Generic;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Monitor;
using UnityEngine;
namespace AquaMai.Mods.Fancy.GamePlay;
[ConfigSection(
en: """
Invert the Slide hierarchy, so that the new Slide appears on top like Maimai classic.
Enable to support color changing effects achieved by overlaying multiple stars.
""",
zh: """
反转 Slide 层级, 使新出现的 Slide 像旧框一样显示在上层
启用以支持通过叠加多个星星达成的变色效果
""")]
public class SlideLayerReverse
{
[HarmonyPostfix]
[HarmonyPatch(typeof(SlideRoot), "Initialize")]
private static void CalcArrowLayer(
bool ___BreakFlag, List<SpriteRenderer> ____spriteRenders, List<BreakSlide> ____breakSpriteRenders,
int ___SlideIndex, int ____baseArrowSortingOrder
)
{
// 原本的 sortingOrder 是 -(SlideIndex + _baseArrowSortingOrder + index)
// 令 orderBase = SlideIndex + _baseArrowSortingOrder
// 分配给这条 slide 的 sortingOrder 范围是 -(orderBase + count - 1) ~ -(orderBase)
// 现在要保留 slide 内部箭头顺序, 但使得 slide 间次序反转
// 范围会变成 orderBase ~ orderBase + count - 1
// 其中原本是 -(orderBase) 的箭头应该调整为 orderBase + count - 1
var orderBase = ___SlideIndex + ____baseArrowSortingOrder; // SlideIndex + _baseArrowSortingOrder
if (!___BreakFlag)
{
var lastIdx = ____spriteRenders.Count - 1;
for (var index = 0; index < ____spriteRenders.Count; index++)
{
var renderer = ____spriteRenders[index];
renderer.sortingOrder = -32700 + orderBase + lastIdx - index;
}
}
else
{
var lastIdx = ____breakSpriteRenders.Count - 1;
for (var index = 0; index < ____breakSpriteRenders.Count; index++)
{
var breakSlide = ____breakSpriteRenders[index];
breakSlide.SetSortingOrder(-32700 + orderBase + lastIdx - index);
}
}
}
[HarmonyPostfix]
[HarmonyPatch(typeof(SlideFan), "Initialize")]
private static void CalcFanArrowLayer(
SpriteRenderer[] ____spriteLines, SpriteRenderer[] ____effectSprites,
int ___SlideIndex, int ____baseArrowSortingOrder
)
{
var orderBase = ___SlideIndex + ____baseArrowSortingOrder; // SlideIndex + _baseArrowSortingOrder
var lastIdx = ____spriteLines.Length - 1;
for (var index = 0; index < ____spriteLines.Length; index++)
{
var renderer = ____spriteLines[index];
renderer.sortingOrder = -32700 + orderBase + lastIdx - index;
}
lastIdx = ____effectSprites.Length - 1;
for (var index = 0; index < ____effectSprites.Length; index++)
{
var renderer = ____effectSprites[index];
renderer.sortingOrder = 1000 + orderBase + lastIdx - index;
}
}
}

View File

@@ -0,0 +1,77 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Process;
using UnityEngine;
namespace AquaMai.Mods.Fancy.GamePlay;
[ConfigSection(
en: """
Delayed the animation of the song start screen.
For recording chart confirmation.
""",
zh: """
推迟了歌曲开始界面的动画
录制谱面确认用
""")]
public class TrackStartProcessTweak
{
// 总之这个 Patch 没啥用, 是我个人用 sinmai 录谱面确认时用得到, 顺手也写进来了
// 具体而言就是推迟了歌曲开始界面的动画便于后期剪辑
[HarmonyPrefix]
[HarmonyPatch(typeof(TrackStartProcess), "OnUpdate")]
private static bool DelayAnimation(
TrackStartProcess.TrackStartSequence ____state,
ref float ____timeCounter,
ProcessDataContainer ___container
)
{
if (____state == TrackStartProcess.TrackStartSequence.Wait)
{
// 将开始动画(就是“噔噔, 噔 噔噔”)推迟
float temp = ____timeCounter + Time.deltaTime;
if (____timeCounter < 1.0f && temp >= 1.0f)
{
// 这是用来让转场动画继续播放的, 原本就是这个时候 notify 的同时开始播放开始动画
// 现在把开始动画往后延
___container.processManager.NotificationFadeIn();
}
____timeCounter = temp;
if (____timeCounter >= 3.0f)
{
return true;
// 原 method 的逻辑是这样
// case TrackStartProcess.TrackStartSequence.Wait:
// this._timeCounter += Time.deltaTime;
// if ((double) this._timeCounter >= 1.0)
// {
// this._timeCounter = 0.0f;
// this._state = TrackStartProcess.TrackStartSequence.Disp;
// /* 一些开始播放开始动画的代码 */
// this.container.processManager.NotificationFadeIn();
// break;
// }
// break;
// 所以只要在 prefix 里面等到 timeCounter 达到我们想要的值以后再执行原 method 就好
// 这里有个细节: NotificationFadeIn() 会被执行两遍, 这其实不好, 是个潜在 bug
// 不过由于此处把开始动画往后推了 2s, 转场动画已经结束把 Process 释放掉了, 所以第二遍会找不到 Process 就没效果
}
return false;
}
else if (____state == TrackStartProcess.TrackStartSequence.DispEnd)
{
// 将开始动画结束以后的转场动画推迟
____timeCounter += Time.deltaTime; // timeCounter 会在先前由原本的 method 归零
if (____timeCounter >= 1.0f)
{
return true;
}
return false;
}
return true;
}
}