diff --git a/AquaMai/AquaMai.csproj b/AquaMai/AquaMai.csproj index c5f711bd..c3fd3675 100644 --- a/AquaMai/AquaMai.csproj +++ b/AquaMai/AquaMai.csproj @@ -1,4 +1,4 @@ - + Release @@ -71,6 +71,9 @@ Libs\System.Configuration.dll + + Libs\System.Numerics.dll + Libs\System.Core.dll @@ -282,7 +285,7 @@ Libs\UnityEngine.XRModule.dll - + diff --git a/AquaMai/Fix/BreakSlideJudgeBlink.cs b/AquaMai/Fix/BreakSlideJudgeBlink.cs new file mode 100644 index 00000000..6c5fd10f --- /dev/null +++ b/AquaMai/Fix/BreakSlideJudgeBlink.cs @@ -0,0 +1,24 @@ +using HarmonyLib; +using Monitor; +using UnityEngine; + +namespace AquaMai.Fix; + +public class BreakSlideJudgeBlink +{ + /* + * 这个 Patch 让 BreakSlide 的 Critical 判定也可以像 BreakTap 一样闪烁 + * 推荐与自定义皮肤一起使用 (否则视觉效果可能并不好) + */ + [HarmonyPostfix] + [HarmonyPatch(typeof(SlideJudge), "UpdateBreakEffectAdd")] + private static void FixBreakSlideJudgeBlink( + SpriteRenderer ___SpriteRenderAdd, SpriteRenderer ___SpriteRender, + SlideJudge.SlideJudgeType ____judgeType, SlideJudge.SlideAngle ____angle + ) + { + if (!___SpriteRenderAdd.gameObject.activeSelf) return; + float num = ___SpriteRenderAdd.color.r; + ___SpriteRenderAdd.color = new Color(num, num, num, 1f); + } +} \ No newline at end of file diff --git a/AquaMai/Fix/FanJudgeFlip.cs b/AquaMai/Fix/FanJudgeFlip.cs new file mode 100644 index 00000000..5d3aeb45 --- /dev/null +++ b/AquaMai/Fix/FanJudgeFlip.cs @@ -0,0 +1,32 @@ +using HarmonyLib; +using Monitor; + +namespace AquaMai.Fix; + +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); + } + } + } +} \ No newline at end of file diff --git a/AquaMai/Fix/FixCircleSlideJudge.cs b/AquaMai/Fix/FixCircleSlideJudge.cs new file mode 100644 index 00000000..99dfde86 --- /dev/null +++ b/AquaMai/Fix/FixCircleSlideJudge.cs @@ -0,0 +1,43 @@ +using System; +using HarmonyLib; +using Manager; +using Monitor; +using Process; +using UnityEngine; + +namespace AquaMai.Fix; + +public class FixCircleSlideJudge +{ + /* + * 这个 Patch 让圆弧形的 Slide 的判定显示与判定线精确对齐 (原本会有一点歪), 就像 majdata 里那样 + * 我觉得这个 Patch 算是无副作用的, 可以默认开启 + */ + [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); + } + } + } + + +} diff --git a/AquaMai/Fix/SlideAutoPlayTweak.cs b/AquaMai/Fix/FixSlideAutoPlay.cs similarity index 99% rename from AquaMai/Fix/SlideAutoPlayTweak.cs rename to AquaMai/Fix/FixSlideAutoPlay.cs index 1e3b1916..88a41024 100644 --- a/AquaMai/Fix/SlideAutoPlayTweak.cs +++ b/AquaMai/Fix/FixSlideAutoPlay.cs @@ -5,7 +5,7 @@ using Monitor; namespace AquaMai.Fix; -public class SlideAutoPlayTweak +public class FixSlideAutoPlay { /* 这个 Patch 用于修复以下 bug: * SlideFan 在 AutoPlay 时, 只有第一个箭头会消失 diff --git a/AquaMai/Fix/SlideJudgeTweak.cs b/AquaMai/Fix/SlideJudgeTweak.cs deleted file mode 100644 index d0a8a546..00000000 --- a/AquaMai/Fix/SlideJudgeTweak.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using HarmonyLib; -using Manager; -using Monitor; -using Process; -using UnityEngine; - -namespace AquaMai.Fix; - -public class SlideJudgeTweak -{ - /* - * 这个 Patch 让 BreakSlide 的 Critical 判定也可以像 BreakTap 一样闪烁 - */ - [HarmonyPostfix] - [HarmonyPatch(typeof(SlideJudge), "UpdateBreakEffectAdd")] - private static void FixBreakSlideJudgeBlink( - SpriteRenderer ___SpriteRenderAdd, SpriteRenderer ___SpriteRender, - SlideJudge.SlideJudgeType ____judgeType, SlideJudge.SlideAngle ____angle - ) - { - if (!___SpriteRenderAdd.gameObject.activeSelf) return; - float num = ___SpriteRenderAdd.color.r; - ___SpriteRenderAdd.color = new Color(num, num, num, 0.3f); - if (num > 0.9f) - { - ___SpriteRender.sprite = GameNoteImageContainer.JudgeSlideCriticalBreak[(int) ____judgeType, (int) ____angle]; - } - else if (num < 0.1f) - { - ___SpriteRender.sprite = GameNoteImageContainer.JudgeSlideCritical[(int) ____judgeType, (int) ____angle]; - } - } - - /* - * 这个 Patch 让圆弧形的 Slide 的判定显示与判定线精确对齐 (原本会有一点歪), 就像 majdata 里那样 - */ - [HarmonyPostfix] - [HarmonyPatch(typeof(SlideRoot), "Initialize")] - private static void FixCircleSlideJudgePosition( - 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); - } - } - } - - /* - * 这个 Patch 让 Wifi Slide 的判定显示有上下的区别 (原本所有 Wifi 的判定显示都是朝向圆心的), 就像 majdata 里那样 - * 这个 bug 产生的原因是 SBGA 忘记给 Wifi 的 EndButtonId 赋值了 - * 不过需要注意的是, 考虑到圆弧形 Slide 的判定显示就是永远朝向圆心的, 我个人会觉得这个 Patch 关掉更好看一点 - * 所以这里把 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); - } - } - } -} diff --git a/AquaMai/Libs/System.Numerics.dll b/AquaMai/Libs/System.Numerics.dll new file mode 100644 index 00000000..2ac827b8 Binary files /dev/null and b/AquaMai/Libs/System.Numerics.dll differ diff --git a/AquaMai/MaimaiDX2077/CustomNoteTypePatch.cs b/AquaMai/MaimaiDX2077/CustomNoteTypePatch.cs new file mode 100644 index 00000000..62bb400b --- /dev/null +++ b/AquaMai/MaimaiDX2077/CustomNoteTypePatch.cs @@ -0,0 +1,426 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using DB; +using HarmonyLib; +using MAI2.Util; +using Manager; +using MelonLoader; +using Monitor; +using UnityEngine; + +namespace AquaMai.MaimaiDX2077; + +public class CustomNoteTypePatch +{ + /* + * ========== ========== ========== ========== ========== ========== ========== ========== + * 以下内容是为了添加新的 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 DoCustomPatch(HarmonyLib.Harmony h) + { + var arrayTraverse = Traverse.Create(typeof(Ma2fileRecordID)).Field("s_Ma2fileRecord_Data"); + var targetArray = arrayTraverse.GetValue(); + + 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("enumName").Value == enumName) + { + __result = (Ma2fileRecordID.Def) i; + } + } + + return false; + } + + [HarmonyPatch] + public static class Ma2RecordValidation + { + public static IEnumerable 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 Transpiler(IEnumerable instructions) + { + + foreach (var inst in instructions) + { + if (inst.LoadsConstant(142)) + { + var instNew = new CodeInstruction(OpCodes.Ldsfld, AccessTools.Field(typeof(CustomNoteTypePatch), "TotalMa2RecordCount")); + yield return instNew; + } + else if (inst.LoadsConstant(141)) + { + var instNew = new CodeInstruction(OpCodes.Ldsfld, AccessTools.Field(typeof(CustomNoteTypePatch), "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.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 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 Transpiler(IEnumerable instructions) + { + var methodGetSlidePath = AccessTools.Method(typeof(SlideManager), "GetSlidePath"); + var methodGetSlidePathRedirect = AccessTools.Method(typeof(CustomNoteTypePatch), "GetSlidePathRedirect"); + var methodGetSlideHitArea = AccessTools.Method(typeof(SlideManager), "GetSlideHitArea"); + var methodGetSlideHitAreaRedirect = AccessTools.Method(typeof(CustomNoteTypePatch), "GetSlideHitAreaRedirect"); + var methodGetSlideLength = AccessTools.Method(typeof(SlideManager), "GetSlideLength"); + var methodGetSlideLengthRedirect = AccessTools.Method(typeof(CustomNoteTypePatch), "GetSlideLengthRedirect"); + var fieldSlideData = AccessTools.Field(typeof(NoteData), "slideData"); + + var oldInstList = new List(instructions); + var newInstList = new List(); + CodeInstruction instToInject = null; + + for (var i = 0; i < oldInstList.Count; ++i) + { + var inst = oldInstList[i]; + if (inst.LoadsField(fieldSlideData)) + { + // 以 GetSlidePath 为例, 我们需要把下面这个调用: + // Singleton.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 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 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 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 $""; + } + + if (value is NoteData data2) + { + return $""; + } + + return value.ToString(); + } + } +} \ No newline at end of file diff --git a/AquaMai/MaimaiDX2077/CustomSlideNoteData.cs b/AquaMai/MaimaiDX2077/CustomSlideNoteData.cs new file mode 100644 index 00000000..1abc3f2f --- /dev/null +++ b/AquaMai/MaimaiDX2077/CustomSlideNoteData.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Linq; +using DB; +using Manager; +using MelonLoader; +using UnityEngine; + +namespace AquaMai.MaimaiDX2077; + +public class CustomSlideNoteData: NoteData +{ + public string SlideCode; + public List> SlidePathList = new List>(); + public List> SlideHitAreasList = new List>(); + 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()))); + MelonLogger.Msg(msg); + + this.slideData.type = path.GetEndType(mirrorMode); + + return true; + } +} \ No newline at end of file diff --git a/AquaMai/MaimaiDX2077/MaiGeometry.cs b/AquaMai/MaimaiDX2077/MaiGeometry.cs new file mode 100644 index 00000000..d0c4fab7 --- /dev/null +++ b/AquaMai/MaimaiDX2077/MaiGeometry.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using Manager; + +namespace AquaMai.MaimaiDX2077; + +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 + }; + + /// + /// Note: idx is 1-based, not 0-based + /// + public static Complex PointGroupA(int idx) + { + var angle = Math.PI * (5.0 / 8.0 - idx / 4.0); + return Complex.FromPolarCoordinates(MainRadius, angle); + } + + /// + /// Note: idx is 1-based, not 0-based + /// + 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; + } + + /// + /// idx 0 is center circle, idx 1~8 are ppqq circles, idx 9 is outer circle + /// + 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); + } + + /// + /// Note: idx is 1-based, not 0-based + /// + /// CircleStruct TransferCircle, double TransferStartAngle, double TransferEndAngle + public static Tuple 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(new CircleStruct(center, TransferRadius), + Math.IEEERemainder(startAngle, Math.PI * 2), Math.IEEERemainder(endAngle, Math.PI * 2)); + } +} \ No newline at end of file diff --git a/AquaMai/MaimaiDX2077/ParametricSlidePath.cs b/AquaMai/MaimaiDX2077/ParametricSlidePath.cs new file mode 100644 index 00000000..5b928025 --- /dev/null +++ b/AquaMai/MaimaiDX2077/ParametricSlidePath.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using DB; +using Manager; + +namespace AquaMai.MaimaiDX2077; + +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 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; + } +} \ No newline at end of file diff --git a/AquaMai/MaimaiDX2077/SlideCodeParser.cs b/AquaMai/MaimaiDX2077/SlideCodeParser.cs new file mode 100644 index 00000000..fc3ad74e --- /dev/null +++ b/AquaMai/MaimaiDX2077/SlideCodeParser.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using MelonLoader; + +namespace AquaMai.MaimaiDX2077; + +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 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(); + 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; + } + } +} \ No newline at end of file diff --git a/AquaMai/MaimaiDX2077/SlideDataBuilder.cs b/AquaMai/MaimaiDX2077/SlideDataBuilder.cs new file mode 100644 index 00000000..857db2c2 --- /dev/null +++ b/AquaMai/MaimaiDX2077/SlideDataBuilder.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using DB; +using Manager; +using MelonLoader; +using Vector4 = UnityEngine.Vector4; + +namespace AquaMai.MaimaiDX2077; + +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 BuildArrowData(ParametricSlidePath path) + { + var result = new List(); + 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; + } + + /// + /// Convert arrow data to sinmai format (Vector4) + /// + /// arrow data generated by BuildArrowData() + /// button index of slide-star + /// mirror mode in user option + /// sinmai format arrow data, referenced to slide-star + public static List ConvertAndRotateArrowData(IEnumerable 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(); + 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 HitAreasLookup = new Dictionary(); + + 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 BuildHitAreas(ParametricSlidePath path) + { + var nodeList = new List>(); + 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(lastNode.Value, (length + enterLength) / 2.0)); + if (node != null) + { + enterLength = length; + } + } + } + + lastNode = node; + } + nodeList.Add(new Tuple(lastNode!.Value, totalLength)); + nodeList[0] = new Tuple(nodeList[0].Item1, 0.0); + + var result = new List(); + 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; + } + + + + /// + /// Convert hit area data to sinmai format (Vector4) + /// + /// hit area data generated by BuildHitAreas() + /// button index of slide-star + /// mirror mode in user option + /// sinmai format arrow data, referenced to slide-star + public static List ConvertAndRotateHitAreas(IEnumerable data, int starButton, + OptionMirrorID mirrorMode) + { + var hitAreaList = new List(); + 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; + } +} \ No newline at end of file diff --git a/AquaMai/MaimaiDX2077/SlidePathGenerator.cs b/AquaMai/MaimaiDX2077/SlidePathGenerator.cs new file mode 100644 index 00000000..a22484f6 --- /dev/null +++ b/AquaMai/MaimaiDX2077/SlidePathGenerator.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace AquaMai.MaimaiDX2077; + +public class SlidePathGenerator +{ + public List PathSegments = new List(); + 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); + } + + /// Note: endAngle should be in range [-PI, PI] + 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); + } +} \ No newline at end of file diff --git a/AquaMai/Main.cs b/AquaMai/Main.cs index f15be7a3..62c5cf8e 100644 --- a/AquaMai/Main.cs +++ b/AquaMai/Main.cs @@ -6,10 +6,12 @@ using System.Runtime.InteropServices; using System.Threading; using AquaMai.Fix; using AquaMai.Helpers; +using AquaMai.MaimaiDX2077; using AquaMai.Resources; using AquaMai.Utils; using AquaMai.UX; using MelonLoader; +using Monitor; using Tomlet; using UnityEngine; @@ -162,7 +164,7 @@ namespace AquaMai Patch(typeof(DebugFeature)); if (GameInfo.GameVersion >= 23000) Patch(typeof(FixConnSlide)); - Patch(typeof(SlideAutoPlayTweak)); + Patch(typeof(FixSlideAutoPlay)); // Rename: SlideAutoPlayTweak -> FixSlideAutoPlay, 不过这个应该无副作用所以不需要改配置文件 if (GameInfo.GameVersion >= 24000) Patch(typeof(FixLevelDisplay)); // UX @@ -173,6 +175,28 @@ namespace AquaMai // Utils Patch(typeof(JudgeAdjust)); Patch(typeof(TouchPanelBaudRate)); + + // New Features & Changes + // 现在自定义皮肤相关的功能应该有 CustomSkin, JudgeDisplay4B, CustomTrackStartDiff + // 后续应该还会接着做, 所以也许可以考虑把自定义皮肤相关的部分单独分一类 ? + Patch(typeof(CustomSkins)); // Rename: CustomNoteSkin -> CustomSkins + Patch(typeof(JudgeDisplay4B)); + Patch(typeof(CustomTrackStartDiff)); + + Patch(typeof(RealisticRandomJudge)); // 本来是用来调试判定显示4B的, 觉得还挺有趣就单独做成功能了 + + Patch(typeof(DisableTrackStartTabs)); // 从 TrackStartProcessTweak 里单独拆出来了 + + // 以下三项拆分自 SlideJudgeTweak + Patch(typeof(FanJudgeFlip)); + Patch(typeof(BreakSlideJudgeBlink)); + Patch(typeof(FixCircleSlideJudge)); // 这个我觉得算无副作用, 可以常开 + + // 这是一项往 Sinmai 里加各种新 note 的企划, 目前只完成了可高度自定义形状的星星 + // 未来还会缓慢更新, 我建议单开一个功能分类 + // 注意需要往 UserLib 里放入 System.Numeric.dll + Patch(typeof(CustomNoteTypePatch)); + # if DEBUG Patch(typeof(LogNetworkErrors)); # endif diff --git a/AquaMai/UX/CustomNoteSkin.cs b/AquaMai/UX/CustomSkins.cs similarity index 81% rename from AquaMai/UX/CustomNoteSkin.cs rename to AquaMai/UX/CustomSkins.cs index a9d299ee..8768aea0 100644 --- a/AquaMai/UX/CustomNoteSkin.cs +++ b/AquaMai/UX/CustomSkins.cs @@ -10,13 +10,18 @@ using UnityEngine; namespace AquaMai.UX; -public class CustomNoteSkin +public class CustomSkins { private static readonly List ImageExts = [".png", ".jpg", ".jpeg"]; private static readonly List SlideFanFields = ["_normalSlideFan", "_eachSlideFan", "_breakSlideFan", "_breakSlideFanEff"]; + private static readonly List CustomTrackStartFields = ["_musicBase", "_musicTab", "_musicLvBase", "_musicLvText"]; private static Sprite customOutline; private static Sprite[,] customSlideFan = new Sprite[4, 11]; + + public static readonly Sprite[,] CustomJudge = new Sprite[2, ((int)NoteJudge.ETiming.End + 1)]; + public static readonly Sprite[,,,] CustomJudgeSlide = new Sprite[2, 3, 2, ((int)NoteJudge.ETiming.End + 1)]; + public static readonly Texture2D[] CustomTrackStart = new Texture2D[4]; private static bool LoadIntoGameNoteImageContainer(string fieldName, int? idx1, int? idx2, Texture2D texture) { @@ -105,8 +110,17 @@ public class CustomNoteSkin var fieldName = '_' + args[0]; int? idx1 = (args.Length < 2) ? null : (int.TryParse(args[1], out var temp) ? temp : null); int? idx2 = (args.Length < 3) ? null : (int.TryParse(args[2], out temp) ? temp : null); + int? idx3 = (args.Length < 4) ? null : (int.TryParse(args[3], out temp) ? temp : null); Traverse traverse; + + if (CustomTrackStartFields.Contains(fieldName)) + { + var i = CustomTrackStartFields.IndexOf(fieldName); + CustomTrackStart[i] = texture; + MelonLogger.Msg($"[CustomNoteSkin] Successfully loaded {name}"); + continue; + } if (fieldName == "_outline") { @@ -115,6 +129,59 @@ public class CustomNoteSkin continue; } + if (fieldName == "_judgeNormal" || fieldName == "_judgeBreak") + { + if (!idx1.HasValue) + { + MelonLogger.Msg($"[CustomNoteSkin] Field {fieldName} needs a index"); + continue; + } + + var i = (fieldName == "_judgeBreak") ? 1 : 0; + CustomJudge[i, idx1.Value] = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f), 1f); + MelonLogger.Msg($"[CustomNoteSkin] Successfully loaded {name}"); + continue; + } + + if (fieldName == "_judgeSlideNormal" || fieldName == "_judgeSlideBreak") + { + if (!idx1.HasValue || !idx2.HasValue || !idx3.HasValue) + { + MelonLogger.Msg($"[CustomNoteSkin] Field {fieldName} needs 3 indices"); + continue; + } + + var i = (fieldName == "_judgeSlideBreak") ? 1 : 0; + Vector2 pivot; + switch (idx1.Value) + { + case 0 when idx2.Value == 0: + pivot = new Vector2(0f, 0.5f); + break; + case 0 when idx2.Value == 1: + pivot = new Vector2(1f, 0.5f); + break; + case 1 when idx2.Value == 0: + pivot = new Vector2(0f, 0.3f); + break; + case 1 when idx2.Value == 1: + pivot = new Vector2(1f, 0.3f); + break; + case 2 when idx2.Value == 0: + pivot = new Vector2(0.5f, 0.8f); + break; + case 2 when idx2.Value == 1: + pivot = new Vector2(0.5f, 0.2f); + break; + default: + pivot = new Vector2(0.5f, 0.5f); + break; + } + CustomJudgeSlide[i, idx1.Value, idx2.Value, idx3.Value] = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), pivot, 1f); + MelonLogger.Msg($"[CustomNoteSkin] Successfully loaded {name}"); + continue; + } + if (SlideFanFields.Contains(fieldName)) { if (!idx1.HasValue) diff --git a/AquaMai/UX/CustomTrackStartDiff.cs b/AquaMai/UX/CustomTrackStartDiff.cs new file mode 100644 index 00000000..8ec82d97 --- /dev/null +++ b/AquaMai/UX/CustomTrackStartDiff.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using HarmonyLib; +using Monitor; +using UI; +using UnityEngine; +using UnityEngine.UI; + +namespace AquaMai.UX; + +public class CustomTrackStartDiff +{ + // 自定义在歌曲开始界面上显示的难度 (并不是真的自定义难度) + // 需要启用自定义皮肤功能 + // 会加载四个图片资源: musicBase, musicTab, musicLvBase, musicLvText + + [HarmonyPostfix] + [HarmonyPatch(typeof(TrackStartMonitor), "SetTrackStart")] + private static void DisableTabs( + MultipleImage ____musicBaseImage, + MultipleImage ____musicTabImage, + SpriteCounter ____difficultySingle, + SpriteCounter ____difficultyDouble, + Image ____levelTextImage, + List ____musicLevelSpriteSheets, + TimelineRoot ____musicDetail + ) + { + var texture = CustomSkins.CustomTrackStart[0]; + if (texture != null) + { + ____musicBaseImage.MultiSprites[6] = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f), 100f); + ____musicBaseImage.ChangeSprite(6); + } + + texture = CustomSkins.CustomTrackStart[1]; + if (texture != null) + { + ____musicTabImage.MultiSprites[6] = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f), 100f); + ____musicTabImage.ChangeSprite(6); + } + + texture = CustomSkins.CustomTrackStart[2]; + if (texture != null) + { + var lvBase = Traverse.Create(____musicDetail).Field("_lv_Base").Value; + lvBase.MultiSprites[6] = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f), 100f); + lvBase.ChangeSprite(6); + } + + texture = CustomSkins.CustomTrackStart[3]; + if (texture != null) + { + var original = ____musicLevelSpriteSheets[0].Sheet; + var sheet = new Sprite[original.Length]; + for (var i = 0; i < original.Length; i++) + { + var sprite = original[i]; + sheet[i] = Sprite.Create(texture, sprite.textureRect, new Vector2(0.5f, 0.5f), 100f); + } + + ____difficultySingle.SetSpriteSheet(sheet); + ____difficultyDouble.SetSpriteSheet(sheet); + ____levelTextImage.sprite = sheet[14]; + } + } +} \ No newline at end of file diff --git a/AquaMai/UX/DisableTrackStartTabs.cs b/AquaMai/UX/DisableTrackStartTabs.cs new file mode 100644 index 00000000..24536538 --- /dev/null +++ b/AquaMai/UX/DisableTrackStartTabs.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using HarmonyLib; +using Monitor; +using UI; +using UnityEngine; +using UnityEngine.UI; + +namespace AquaMai.UX; + +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 + ) + { + ____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); + } +} \ No newline at end of file diff --git a/AquaMai/UX/JudgeDisplay4B.cs b/AquaMai/UX/JudgeDisplay4B.cs new file mode 100644 index 00000000..92645931 --- /dev/null +++ b/AquaMai/UX/JudgeDisplay4B.cs @@ -0,0 +1,75 @@ +using HarmonyLib; +using Manager; +using Monitor; +using UnityEngine; + +namespace AquaMai.UX; + +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; + } + +} \ No newline at end of file diff --git a/AquaMai/UX/RealisticRandomJudge.cs b/AquaMai/UX/RealisticRandomJudge.cs new file mode 100644 index 00000000..d145b59f --- /dev/null +++ b/AquaMai/UX/RealisticRandomJudge.cs @@ -0,0 +1,25 @@ +using HarmonyLib; +using Manager; + +namespace AquaMai.UX; + +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; + } +} \ No newline at end of file diff --git a/AquaMai/UX/TrackStartProcessTweak.cs b/AquaMai/UX/TrackStartProcessTweak.cs index a16655d4..ebea887e 100644 --- a/AquaMai/UX/TrackStartProcessTweak.cs +++ b/AquaMai/UX/TrackStartProcessTweak.cs @@ -1,8 +1,10 @@ -using HarmonyLib; +using System.Collections.Generic; +using HarmonyLib; using Monitor; using Process; using UI; using UnityEngine; +using UnityEngine.UI; namespace AquaMai.UX; @@ -10,7 +12,6 @@ public class TrackStartProcessTweak { // 总之这个 Patch 没啥用, 是我个人用 sinmai 录谱面确认时用得到, 顺手也写进来了 // 具体而言就是推迟了歌曲开始界面的动画便于后期剪辑 - // 然后把“TRACK X”字样和 DX/标准谱面的显示框隐藏掉, 让他看起来不那么 sinmai, 更像是 majdata [HarmonyPrefix] [HarmonyPatch(typeof(TrackStartProcess), "OnUpdate")] @@ -65,19 +66,6 @@ public class TrackStartProcessTweak return true; } - [HarmonyPostfix] - [HarmonyPatch(typeof(TrackStartMonitor), "SetTrackStart")] - private static void DisableTabs( - SpriteCounter ____trackNumber, SpriteCounter ____bossTrackNumber, SpriteCounter ____utageTrackNumber, - MultipleImage ____musicTabImage, GameObject[] ____musicTabObj - ) - { - ____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); - } + } +