[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,127 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Release</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{8731C0E0-53BE-4B1B-9828-193E738C6865}</ProjectGuid>
<OutputType>Library</OutputType>
<RootNamespace>AquaMai.Mods</RootNamespace>
<AssemblyName>AquaMai.Mods</AssemblyName>
<TargetFramework>net472</TargetFramework>
<FileAlignment>512</FileAlignment>
<Deterministic>true</Deterministic>
<LangVersion>12</LangVersion>
<NoWarn>414</NoWarn>
<AssemblySearchPaths>$(ProjectDir)../Libs/;$(AssemblySearchPaths)</AssemblySearchPaths>
<OutputPath>$(ProjectDir)../Output/</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugSymbols>false</DebugSymbols>
<DebugType>None</DebugType>
<Optimize>true</Optimize>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Prefer32Bit>false</Prefer32Bit>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DefineConstants>DEBUG</DefineConstants>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../AquaMai.Config/AquaMai.Config.csproj" />
<ProjectReference Include="../AquaMai.Core/AquaMai.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="mscorlib" />
<Reference Include="0Harmony" />
<Reference Include="AMDaemon.NET" />
<Reference Include="Assembly-CSharp" />
<Reference Include="Assembly-CSharp-firstpass" />
<Reference Include="MelonLoader" />
<Reference Include="Mono.Cecil" />
<Reference Include="Mono.Posix" />
<Reference Include="Mono.Security" />
<Reference Include="System" />
<Reference Include="System.Configuration" />
<Reference Include="System.Core" />
<Reference Include="System.Security" />
<Reference Include="System.Xml" />
<Reference Include="Unity.Analytics.DataPrivacy" />
<Reference Include="Unity.TextMeshPro" />
<Reference Include="UnityEngine" />
<Reference Include="UnityEngine.AccessibilityModule" />
<Reference Include="UnityEngine.AIModule" />
<Reference Include="UnityEngine.AnimationModule" />
<Reference Include="UnityEngine.ARModule" />
<Reference Include="UnityEngine.AssetBundleModule" />
<Reference Include="UnityEngine.AudioModule" />
<Reference Include="UnityEngine.BaselibModule" />
<Reference Include="UnityEngine.ClothModule" />
<Reference Include="UnityEngine.ClusterInputModule" />
<Reference Include="UnityEngine.ClusterRendererModule" />
<Reference Include="UnityEngine.CoreModule" />
<Reference Include="UnityEngine.CrashReportingModule" />
<Reference Include="UnityEngine.DirectorModule" />
<Reference Include="UnityEngine.FileSystemHttpModule" />
<Reference Include="UnityEngine.GameCenterModule" />
<Reference Include="UnityEngine.GridModule" />
<Reference Include="UnityEngine.HotReloadModule" />
<Reference Include="UnityEngine.ImageConversionModule" />
<Reference Include="UnityEngine.IMGUIModule" />
<Reference Include="UnityEngine.InputModule" />
<Reference Include="UnityEngine.JSONSerializeModule" />
<Reference Include="UnityEngine.LocalizationModule" />
<Reference Include="UnityEngine.Networking" />
<Reference Include="UnityEngine.ParticleSystemModule" />
<Reference Include="UnityEngine.PerformanceReportingModule" />
<Reference Include="UnityEngine.Physics2DModule" />
<Reference Include="UnityEngine.PhysicsModule" />
<Reference Include="UnityEngine.ProfilerModule" />
<Reference Include="UnityEngine.ScreenCaptureModule" />
<Reference Include="UnityEngine.SharedInternalsModule" />
<Reference Include="UnityEngine.SpatialTracking" />
<Reference Include="UnityEngine.SpriteMaskModule" />
<Reference Include="UnityEngine.SpriteShapeModule" />
<Reference Include="UnityEngine.StreamingModule" />
<Reference Include="UnityEngine.StyleSheetsModule" />
<Reference Include="UnityEngine.SubstanceModule" />
<Reference Include="UnityEngine.TerrainModule" />
<Reference Include="UnityEngine.TerrainPhysicsModule" />
<Reference Include="UnityEngine.TextCoreModule" />
<Reference Include="UnityEngine.TextRenderingModule" />
<Reference Include="UnityEngine.TilemapModule" />
<Reference Include="UnityEngine.Timeline" />
<Reference Include="UnityEngine.TimelineModule" />
<Reference Include="UnityEngine.TLSModule" />
<Reference Include="UnityEngine.UI" />
<Reference Include="UnityEngine.UIElementsModule" />
<Reference Include="UnityEngine.UIModule" />
<Reference Include="UnityEngine.UmbraModule" />
<Reference Include="UnityEngine.UNETModule" />
<Reference Include="UnityEngine.UnityAnalyticsModule" />
<Reference Include="UnityEngine.UnityConnectModule" />
<Reference Include="UnityEngine.UnityTestProtocolModule" />
<Reference Include="UnityEngine.UnityWebRequestAssetBundleModule" />
<Reference Include="UnityEngine.UnityWebRequestAudioModule" />
<Reference Include="UnityEngine.UnityWebRequestModule" />
<Reference Include="UnityEngine.UnityWebRequestTextureModule" />
<Reference Include="UnityEngine.UnityWebRequestWWWModule" />
<Reference Include="UnityEngine.VehiclesModule" />
<Reference Include="UnityEngine.VFXModule" />
<Reference Include="UnityEngine.VideoModule" />
<Reference Include="UnityEngine.VRModule" />
<Reference Include="UnityEngine.WindModule" />
<Reference Include="UnityEngine.XRModule" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Numerics" Private="true" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,29 @@
using AquaMai.Config.Attributes;
namespace AquaMai.Mods;
[ConfigSection(
en: """
These options have been deprecated and no longer work in the current version.
Remove them to get rid of the warning message at startup.
""",
zh: """
这些配置项已经被废弃,在当前版本不再生效
删除它们以去除启动时的警告信息
""",
exampleHidden: true)]
public class DeprecationWarning
{
[ConfigEntry(hideWhenDefault: true)]
public static readonly bool v1_0_ModKeyMap_TestMode;
// Print friendly warning messages here.
// Please keep them up-to-date while refactoring the config.
public static void OnBeforeAllPatch()
{
if (v1_0_ModKeyMap_TestMode)
{
MelonLoader.MelonLogger.Warning("ModKeyMap.TestMode has been deprecated (> v1.0). Please use GameSystem.KeyMap.Test instead.");
}
}
}

View File

@@ -0,0 +1,73 @@
using System.Collections.Generic;
using System.IO;
using AquaMai.Config.Attributes;
using AquaMai.Core.Helpers;
using HarmonyLib;
using Monitor;
using Process;
using UnityEngine;
using UnityEngine.UI;
namespace AquaMai.Mods.Fancy;
[ConfigSection(
en: "Replace the \"SEGA\" and \"ALL.Net\" logos with custom ones.",
zh: "用自定义的图片替换「SEGA」和「ALL.Net」的标志")]
public class CustomLogo
{
[ConfigEntry(
en: "Replace the \"SEGA\" logo with a random PNG image from this directory.",
zh: "从此目录中随机选择一张 PNG 图片用于「SEGA」标志")]
private static readonly string segaLogoDir = "LocalAssets/SegaLogo";
[ConfigEntry(
en: "Replace the \"ALL.Net\" logo with a random PNG image from this directory.",
zh: "从此目录中随机选择一张 PNG 图片用于「ALL.Net」标志")]
private static readonly string allNetLogoDir = "LocalAssets/AllNetLogo";
private readonly static List<Sprite> segaLogo = [];
private readonly static List<Sprite> allNetLogo = [];
public static void OnBeforePatch()
{
EnumSprite(segaLogo, FileSystem.ResolvePath(segaLogoDir));
EnumSprite(allNetLogo, FileSystem.ResolvePath(allNetLogoDir));
}
private static void EnumSprite(List<Sprite> collection, string path)
{
if (!Directory.Exists(path)) return;
foreach (var file in Directory.EnumerateFiles(path, "*.png"))
{
var data = File.ReadAllBytes(file);
var texture2D = new Texture2D(1, 1, TextureFormat.RGBA32, false);
if (texture2D.LoadImage(data))
{
collection.Add(Sprite.Create(texture2D, new Rect(0f, 0f, texture2D.width, texture2D.height), new Vector2(0.5f, 0.5f)));
}
}
}
[HarmonyPatch(typeof(AdvertiseProcess), "OnStart")]
[HarmonyPostfix]
private static void AdvProcessPostFix(AdvertiseMonitor[] ____monitors)
{
if (segaLogo.Count > 0)
{
var logo = segaLogo[UnityEngine.Random.Range(0, segaLogo.Count)];
foreach (var monitor in ____monitors)
{
monitor.transform.Find("Canvas/Main/SegaAllNet_LOGO/NUL_ADT_SegaAllNet_LOGO/SegaLogo").GetComponent<Image>().sprite = logo;
}
}
if (allNetLogo.Count > 0)
{
var logo = allNetLogo[UnityEngine.Random.Range(0, allNetLogo.Count)];
foreach (var monitor in ____monitors)
{
monitor.transform.Find("Canvas/Main/SegaAllNet_LOGO/NUL_ADT_SegaAllNet_LOGO/AllNetLogo").GetComponent<Image>().sprite = logo;
}
}
}
}

View File

@@ -0,0 +1,45 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager;
namespace AquaMai.Mods.Fancy;
[ConfigSection(
en: """
Custom shop name in photo.
Also enable shop name display in SDGA.
""",
zh: """
自定义拍照的店铺名称
同时在 SDGA 中会启用店铺名称的显示(但是不会在游戏里有设置)
""")]
public class CustomPlaceName
{
[ConfigEntry]
private static readonly string placeName = "";
[HarmonyPostfix]
[HarmonyPatch(typeof(OperationManager), "CheckAuth_Proc")]
public static void CheckAuth_Proc(OperationManager __instance)
{
if (string.IsNullOrEmpty(placeName))
{
return;
}
__instance.ShopData.ShopName = placeName;
__instance.ShopData.ShopNickName = placeName;
}
[HarmonyPostfix]
[HarmonyPatch(typeof(ResultCardBaseController), "Initialize")]
public static void Initialize(ResultCardBaseController __instance)
{
if (string.IsNullOrEmpty(placeName))
{
return;
}
__instance.SetVisibleStoreName(true);
}
}

View File

@@ -0,0 +1,402 @@
using System.Collections.Generic;
using System.IO;
using AquaMai.Config.Attributes;
using AquaMai.Core.Helpers;
using HarmonyLib;
using MelonLoader;
using Monitor;
using Monitor.Game;
using Process;
using UnityEngine;
namespace AquaMai.Mods.Fancy;
[ConfigSection(
en: """
Provide the ability to use custom skins (advanced feature).
Load skin textures from custom paths.
""",
zh: """
提供自定义皮肤的能力(高级功能)
从自定义路径中加载皮肤贴图
""")]
public class CustomSkins
{
[ConfigEntry]
private static readonly string skinsDir = "LocalAssets/Skins";
private static readonly List<string> ImageExts = [".png", ".jpg", ".jpeg"];
private static readonly List<string> SlideFanFields = ["_normalSlideFan", "_eachSlideFan", "_breakSlideFan", "_breakSlideFanEff"];
private static readonly List<string> CustomTrackStartFields = ["_musicBase", "_musicTab", "_musicLvBase", "_musicLvText"];
private static Sprite customOutline;
private readonly 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)
{
// 先确定确实有这个 Field, 如果没有的话可以直接跳过这个文件
var fieldTraverse = Traverse.Create(typeof(GameNoteImageContainer)).Field(fieldName);
if (!fieldTraverse.FieldExists())
{
MelonLogger.Msg($"[CustomNoteSkin] Cannot found field {fieldName}");
return false;
}
var fieldType = fieldTraverse.GetValueType();
if (!idx1.HasValue)
{
// 目标 Field 应当是单个 Sprite
if (fieldType != typeof(Sprite))
{
MelonLogger.Msg($"[CustomNoteSkin] Field {fieldName} is a {fieldType.Name}, not a Sprite");
return false;
}
var target = fieldTraverse.GetValue<Sprite>();
var pivot = new Vector2(target.pivot.x / target.rect.width, target.pivot.y / target.rect.height);
var custom = Sprite.Create(
texture, new Rect(0, 0, texture.width, texture.height), pivot, 1f,
0, SpriteMeshType.Tight, target.border
);
fieldTraverse.SetValue(custom);
}
else if (!idx2.HasValue)
{
// 目标 Field 是一维数组
if (fieldType != typeof(Sprite[]))
{
MelonLogger.Msg($"[CustomNoteSkin] Field {fieldName} is a {fieldType.Name}, not a Sprite[]");
return false;
}
var targetArray = fieldTraverse.GetValue<Sprite[]>();
var target = targetArray[idx1.Value];
var pivot = new Vector2(target.pivot.x / target.rect.width, target.pivot.y / target.rect.height);
var custom = Sprite.Create(
texture, new Rect(0, 0, texture.width, texture.height), pivot, 1f,
0, SpriteMeshType.Tight, target.border
);
targetArray[idx1.Value] = custom;
}
else
{
// 目标 Field 是二维数组
if (fieldType != typeof(Sprite[,]))
{
MelonLogger.Msg($"[CustomNoteSkin] Field {fieldName} is a {fieldType.Name}, not a Sprite[,]");
return false;
}
var targetArray = fieldTraverse.GetValue<Sprite[,]>();
var target = targetArray[idx1.Value, idx2.Value];
var pivot = new Vector2(target.pivot.x / target.rect.width, target.pivot.y / target.rect.height);
var custom = Sprite.Create(
texture, new Rect(0, 0, texture.width, texture.height), pivot, 1f,
0, SpriteMeshType.Tight, target.border
);
targetArray[idx1.Value, idx2.Value] = custom;
}
return true;
}
[HarmonyPostfix]
[HarmonyPatch(typeof(GameNotePrefabContainer), "Initialize")]
private static void LoadNoteSkin()
{
var resolvedDir = FileSystem.ResolvePath(skinsDir);
if (!Directory.Exists(resolvedDir)) return;
foreach (var laFile in Directory.EnumerateFiles(resolvedDir))
{
if (!ImageExts.Contains(Path.GetExtension(laFile).ToLowerInvariant())) continue;
var texture = new Texture2D(1, 1, TextureFormat.RGBA32, false);
texture.LoadImage(File.ReadAllBytes(laFile));
var name = Path.GetFileNameWithoutExtension(laFile);
var args = name.Split('_');
// 文件名的格式是 XXXXXXXX_A_B 表示 GameNoteImageContainer._XXXXXXXX[A, B]
// 视具体情况, A, B 可能不存在
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")
{
customOutline = 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 == "_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)
{
MelonLogger.Msg($"[CustomNoteSkin] Field {fieldName} needs a index");
continue;
}
var i = SlideFanFields.IndexOf(fieldName);
customSlideFan[i, idx1.Value] = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(1f, 0.5f), 1f);
MelonLogger.Msg($"[CustomNoteSkin] Successfully loaded {name}");
continue;
}
if (fieldName == "_touchJust")
{
traverse = Traverse.Create(GameNotePrefabContainer.TouchTapB);
var noticeObject = traverse.Field<GameObject>("NoticeObject").Value;
var target = noticeObject.GetComponent<SpriteRenderer>();
var pivot = new Vector2(
target.sprite.pivot.x / target.sprite.rect.width,
target.sprite.pivot.y / target.sprite.rect.height
);
var custom = Sprite.Create(
texture, new Rect(0, 0, texture.width, texture.height), pivot, 1f,
0, SpriteMeshType.Tight, target.sprite.border
);
target.sprite = custom;
traverse = Traverse.Create(GameNotePrefabContainer.TouchTapC);
noticeObject = traverse.Field<GameObject>("NoticeObject").Value;
noticeObject.GetComponent<SpriteRenderer>().sprite = custom;
MelonLogger.Msg($"[CustomNoteSkin] Successfully loaded {name}");
continue;
}
if (fieldName == "_touchHold")
{
if (!idx1.HasValue)
{
MelonLogger.Msg($"[CustomNoteSkin] Field {fieldName} needs a index");
continue;
}
traverse = Traverse.Create(GameNotePrefabContainer.TouchHoldC);
var target = traverse.Field<SpriteRenderer[]>("ColorsObject").Value;
var renderer = target[idx1.Value];
var pivot = new Vector2(
renderer.sprite.pivot.x / renderer.sprite.rect.width,
renderer.sprite.pivot.y / renderer.sprite.rect.height
);
var custom = Sprite.Create(
texture, new Rect(0, 0, texture.width, texture.height), pivot, 1f,
0, SpriteMeshType.Tight, renderer.sprite.border
);
renderer.sprite = custom;
MelonLogger.Msg($"[CustomNoteSkin] Successfully loaded {name}");
continue;
}
if (fieldName == "_normalTouchBorder")
{
if (!idx1.HasValue)
{
MelonLogger.Msg($"[CustomNoteSkin] Field {fieldName} needs a index");
continue;
}
traverse = Traverse.Create(GameNotePrefabContainer.TouchReserve);
var target = traverse.Field<Sprite[]>("_reserveSingleSprite").Value;
var targetSprite = target[idx1.Value - 2];
var pivot = new Vector2(
targetSprite.pivot.x / targetSprite.rect.width,
targetSprite.pivot.y / targetSprite.rect.height
);
target[idx1.Value - 2] = Sprite.Create(
texture, new Rect(0, 0, texture.width, texture.height), pivot, 1f,
0, SpriteMeshType.Tight, targetSprite.border
);
MelonLogger.Msg($"[CustomNoteSkin] Successfully loaded {name}");
continue;
}
if (fieldName == "_eachTouchBorder")
{
if (!idx1.HasValue)
{
MelonLogger.Msg($"[CustomNoteSkin] Field {fieldName} needs a index");
continue;
}
traverse = Traverse.Create(GameNotePrefabContainer.TouchReserve);
var target = traverse.Field<Sprite[]>("_reserveEachSprite").Value;
var targetSprite = target[idx1.Value - 2];
var pivot = new Vector2(
targetSprite.pivot.x / targetSprite.rect.width,
targetSprite.pivot.y / targetSprite.rect.height
);
target[idx1.Value - 2] = Sprite.Create(
texture, new Rect(0, 0, texture.width, texture.height), pivot, 1f,
0, SpriteMeshType.Tight, targetSprite.border
);
MelonLogger.Msg($"[CustomNoteSkin] Successfully loaded {name}");
continue;
}
if (LoadIntoGameNoteImageContainer(fieldName, idx1, idx2, texture))
{
MelonLogger.Msg($"[CustomNoteSkin] Successfully loaded {name}");
}
}
}
[HarmonyPostfix]
[HarmonyPatch(typeof(GameCtrl), "Initialize")]
private static void ChangeOutlineTexture(GameObject ____guideEndPointObj)
{
if (____guideEndPointObj != null && customOutline != null)
{
____guideEndPointObj.GetComponent<SpriteRenderer>().sprite = customOutline;
}
}
[HarmonyPostfix]
[HarmonyPatch(typeof(SlideFan), "Initialize")]
private static void ChangeFanTexture(
SpriteRenderer[] ____spriteLines, SpriteRenderer[] ____effectSprites, bool ___BreakFlag, bool ___EachFlag
)
{
Vector3 position;
Sprite sprite;
if (___BreakFlag)
{
for (var i = 0; i < 11; i++)
{
sprite = customSlideFan[2, i];
if (sprite != null)
{
____spriteLines[2 * i].sprite = sprite;
position = ____spriteLines[2 * i].transform.localPosition;
____spriteLines[2 * i].transform.localPosition = new Vector3(0, position.y, position.z);
____spriteLines[2 * i].color = Color.white;
____spriteLines[2 * i + 1].sprite = sprite;
position = ____spriteLines[2 * i + 1].transform.localPosition;
____spriteLines[2 * i + 1].transform.localPosition = new Vector3(0, position.y, position.z);
____spriteLines[2 * i + 1].color = Color.white;
}
sprite = customSlideFan[3, i];
if (sprite != null)
{
____effectSprites[2 * i].sprite = sprite;
position = ____effectSprites[2 * i].transform.localPosition;
____effectSprites[2 * i].transform.localPosition = new Vector3(0, position.y, position.z);
____effectSprites[2 * i].color = Color.white;
____effectSprites[2 * i + 1].sprite = sprite;
position = ____effectSprites[2 * i + 1].transform.localPosition;
____effectSprites[2 * i + 1].transform.localPosition = new Vector3(0, position.y, position.z);
____effectSprites[2 * i + 1].color = Color.white;
}
}
}
else if (___EachFlag)
{
for (var i = 0; i < 11; i++)
{
sprite = customSlideFan[1, i];
if (sprite != null)
{
____spriteLines[2 * i].sprite = sprite;
position = ____spriteLines[2 * i].transform.localPosition;
____spriteLines[2 * i].transform.localPosition = new Vector3(0, position.y, position.z);
____spriteLines[2 * i].color = Color.white;
____spriteLines[2 * i + 1].sprite = sprite;
position = ____spriteLines[2 * i + 1].transform.localPosition;
____spriteLines[2 * i + 1].transform.localPosition = new Vector3(0, position.y, position.z);
____spriteLines[2 * i + 1].color = Color.white;
}
}
}
else
{
for (var i = 0; i < 11; i++)
{
sprite = customSlideFan[0, i];
if (sprite != null)
{
____spriteLines[2 * i].sprite = sprite;
position = ____spriteLines[2 * i].transform.localPosition;
____spriteLines[2 * i].transform.localPosition = new Vector3(0, position.y, position.z);
____spriteLines[2 * i].color = Color.white;
____spriteLines[2 * i + 1].sprite = sprite;
position = ____spriteLines[2 * i + 1].transform.localPosition;
____spriteLines[2 * i + 1].transform.localPosition = new Vector3(0, position.y, position.z);
____spriteLines[2 * i + 1].color = Color.white;
}
}
}
}
}

View File

@@ -0,0 +1,78 @@
using System.Collections.Generic;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Monitor;
using UI;
using UnityEngine;
using UnityEngine.UI;
namespace AquaMai.Mods.Fancy;
[ConfigSection(
en: """
Custom track start difficulty image (not really custom difficulty).
Requires CustomSkins to be enabled.
Will load four image resources through custom skins: musicBase, musicTab, musicLvBase, musicLvText.
""",
zh: """
自定义在歌曲开始界面上显示的难度贴图 (并不是真的自定义难度)
需要启用自定义皮肤功能
会通过自定义皮肤加载四个图片资源: musicBase, musicTab, musicLvBase, musicLvText
""")]
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<ResultMonitor.SpriteSheet> ____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<MultipleImage>("_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];
}
}
}

View File

@@ -0,0 +1,30 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
namespace AquaMai.Mods.Fancy;
[ConfigSection(
en: "Set the version string displayed at the top-right corner of the screen.",
zh: "把右上角的版本更改为自定义文本")]
public class CustomVersionString
{
[ConfigEntry]
private static readonly string versionString = "";
/*
* Patch displayVersionString Property Getter
*/
[HarmonyPrefix]
[HarmonyPatch(typeof(MAI2System.Config), "displayVersionString", MethodType.Getter)]
public static bool GetDisplayVersionString(ref string __result)
{
if (string.IsNullOrEmpty(versionString))
{
return true;
}
__result = versionString;
// Return false to block the original method
return false;
}
}

View File

@@ -0,0 +1,34 @@
using AquaMai.Config.Attributes;
using DB;
using HarmonyLib;
using MAI2.Util;
using Manager;
using Process;
namespace AquaMai.Mods.Fancy;
[ConfigSection(
en: "Play \"Master\" difficulty on Demo screen.",
zh: "在闲置时的演示画面上播放紫谱而不是绿谱")]
public class DemoMaster
{
[HarmonyPostfix]
[HarmonyPatch(typeof(AdvDemoProcess), "OnStart")]
public static void AdvDemoProcessPostStart()
{
for (int i = 0; i < 2; i++)
{
var userOption = Singleton<GamePlayManager>.Instance.GetGameScore(i).UserOption;
userOption.NoteSpeed = OptionNotespeedID.Speed6_5;
userOption.TouchSpeed = OptionTouchspeedID.Speed7_0;
}
}
[HarmonyPrefix]
[HarmonyPatch(typeof(GamePlayManager), "InitializeAdvertise")]
public static void PreInitializeAdvertise()
{
GameManager.SelectDifficultyID[0] = 3;
GameManager.SelectDifficultyID[1] = 3;
}
}

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;
}
}

View File

@@ -0,0 +1,18 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using UnityEngine;
namespace AquaMai.Mods.Fancy;
[ConfigSection(
en: "Remove the circle mask of the game screen.",
zh: "移除游戏画面的圆形遮罩")]
public class HideMask
{
[HarmonyPrefix]
[HarmonyPatch(typeof(Main.GameMain), "LateInitialize", typeof(MonoBehaviour), typeof(Transform), typeof(Transform))]
public static void LateInitialize(MonoBehaviour gameMainObject)
{
GameObject.Find("Mask").SetActive(false);
}
}

View File

@@ -0,0 +1,7 @@
# Fancy
All the fancy features, even if not required by most players, are welcomed to this category, whether for personalization, for beautify, for self-made charts or for other uncommon purposes.
These patches may not well-tested by the project maintainers and could be enabled only if you know what you're doing.
Patches affect the gameplay should go to the GamePlay subcategory.

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.IO;
using AquaMai.Config.Attributes;
using AquaMai.Core.Helpers;
using HarmonyLib;
using Mai2.Mai2Cue;
using MAI2.Util;
using Manager;
using MelonLoader;
namespace AquaMai.Mods.Fancy;
[ConfigSection(
en: """
Random BGM.
Put Mai2Cue.{acb,awb} of old version of the game in the configured directory and rename them.
Won't work with 2P mode.
""",
zh: """
在配置的目录下放置了旧版游戏的 Mai2Cue.{acb,awb} 并重命名的话,可以在播放游戏 BGM 的时候随机播放这里面的旧版游戏 BGM
无法在 2P 模式下工作
""")]
public class RandomBgm
{
[ConfigEntry]
private static readonly string mai2CueDir = "LocalAssets/Mai2Cue";
private static List<string> _acbs = new List<string>();
[HarmonyPostfix]
[HarmonyPatch(typeof(SoundManager), "Initialize")]
public static void Init()
{
var resolvedDir = FileSystem.ResolvePath(mai2CueDir);
if (!Directory.Exists(resolvedDir)) return;
var files = Directory.EnumerateFiles(resolvedDir);
foreach (var file in files)
{
if (!file.EndsWith(".acb")) continue;
// Seems there's limit for max opened ACB files
_acbs.Add(Path.ChangeExtension(file, null));
}
MelonLogger.Msg($"Random BGM loaded {_acbs.Count} files");
}
[HarmonyPrefix]
[HarmonyPatch(typeof(SoundManager), "Play")]
public static void PrePlay(ref SoundManager.AcbID acbID, int cueID)
{
if (acbID != SoundManager.AcbID.Default) return;
if (_acbs.Count == 0) return;
var cueIndex = (Cue)cueID;
switch (cueIndex)
{
case Cue.BGM_ENTRY:
case Cue.BGM_COLLECTION:
case Cue.BGM_RESULT_CLEAR:
case Cue.BGM_RESULT:
var acb = _acbs[UnityEngine.Random.Range(0, _acbs.Count)];
acbID = SoundManager.AcbID.Max;
var result = Singleton<SoundCtrl>.Instance.LoadCueSheet((int)acbID, acb);
MelonLogger.Msg($"Picked {acb} for {cueIndex}, result: {result}");
return;
default:
return;
}
}
[HarmonyPrefix]
[HarmonyPatch(typeof(SoundManager), "PlayBGM")]
public static bool PrePlayBGM(ref int target)
{
switch (target)
{
case 0:
return true;
case 1:
return false;
case 2:
target = 0;
return true;
default:
return false;
}
}
}

View File

@@ -0,0 +1,68 @@
using System;
using System.Diagnostics;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Process;
namespace AquaMai.Mods.Fancy;
[ConfigSection(
en: "Triggers for executing commands at certain events.",
zh: "在一定时机执行命令的触发器")]
public class Triggers
{
[ConfigEntry(
en: "Execute some command on game idle.",
zh: """
在游戏闲置的时候执行指定的命令脚本
比如说可以在游戏闲置是降低显示器的亮度
""")]
private static readonly string execOnIdle = "";
[ConfigEntry(
en: "Execute some command on game start.",
zh: "在玩家登录的时候执行指定的命令脚本")]
private static readonly string execOnEntry = "";
[HarmonyPrefix]
[HarmonyPatch(typeof(AdvertiseProcess), "OnStart")]
public static void AdvertiseProcessPreStart()
{
if (!string.IsNullOrWhiteSpace(execOnIdle))
{
Exec(execOnIdle);
}
}
[HarmonyPrefix]
[HarmonyPatch(typeof(EntryProcess), "OnStart")]
public static void EntryProcessPreStart()
{
if (!string.IsNullOrWhiteSpace(execOnEntry))
{
Exec(execOnEntry);
}
}
[HarmonyPrefix]
[HarmonyPatch(typeof(MusicSelectProcess), "OnStart")]
public static void MusicSelectProcessPreStart()
{
if (!string.IsNullOrWhiteSpace(execOnEntry))
{
Exec(execOnEntry);
}
}
private static void Exec(string command)
{
var process = new System.Diagnostics.Process();
process.StartInfo.FileName = "cmd.exe";
process.StartInfo.Arguments = "/c " + command;
process.StartInfo.UseShellExecute = true;
process.StartInfo.WindowStyle = ProcessWindowStyle.Hidden;
process.StartInfo.WorkingDirectory = Environment.CurrentDirectory;
process.Start();
}
}

View File

@@ -0,0 +1,139 @@
using System.Net;
using HarmonyLib;
using Manager;
using Net;
using UnityEngine;
using AquaMai.Config.Attributes;
using AquaMai.Core.Attributes;
namespace AquaMai.Mods.Fix;
[ConfigSection(exampleHidden: true, defaultOn: true)]
public class Common
{
[ConfigEntry]
private readonly static bool preventIniFileClear = true;
[EnableIf(nameof(preventIniFileClear))]
[HarmonyPrefix]
[HarmonyPatch(typeof(MAI2System.IniFile), "clear")]
private static bool PreIniFileClear()
{
return false;
}
[ConfigEntry]
private readonly static bool fixDebugInput = true;
[EnableIf(nameof(fixDebugInput))]
[HarmonyPrefix]
[HarmonyPatch(typeof(DebugInput), "GetKey")]
private static bool GetKey(ref bool __result, KeyCode name)
{
__result = UnityEngine.Input.GetKey(name);
return false;
}
[EnableIf(nameof(fixDebugInput))]
[HarmonyPrefix]
[HarmonyPatch(typeof(DebugInput), "GetKeyDown")]
private static bool GetKeyDown(ref bool __result, KeyCode name)
{
__result = UnityEngine.Input.GetKeyDown(name);
return false;
}
[EnableIf(nameof(fixDebugInput))]
[HarmonyPrefix]
[HarmonyPatch(typeof(DebugInput), "GetMouseButton")]
private static bool GetMouseButton(ref bool __result, int button)
{
__result = UnityEngine.Input.GetMouseButton(button);
return false;
}
[EnableIf(nameof(fixDebugInput))]
[HarmonyPrefix]
[HarmonyPatch(typeof(DebugInput), "GetMouseButtonDown")]
private static bool GetMouseButtonDown(ref bool __result, int button)
{
__result = UnityEngine.Input.GetMouseButtonDown(button);
return false;
}
[ConfigEntry]
private readonly static bool bypassCakeHashCheck = true;
[EnableIf(nameof(bypassCakeHashCheck))]
[HarmonyPostfix]
[HarmonyPatch(typeof(NetHttpClient), MethodType.Constructor)]
private static void OnNetHttpClientConstructor(NetHttpClient __instance)
{
// Bypass Cake.dll hash check
var tInstance = Traverse.Create(__instance).Field("isTrueDll");
if (tInstance.FieldExists())
{
tInstance.SetValue(true);
}
}
[ConfigEntry]
private readonly static bool restoreCertificateValidation = true;
[EnableIf(nameof(restoreCertificateValidation))]
[HarmonyPostfix]
[HarmonyPatch(typeof(NetHttpClient), "Create")]
private static void OnNetHttpClientCreate()
{
// Unset the certificate validation callback (SSL pinning) to restore the default behavior
ServicePointManager.ServerCertificateValidationCallback = null;
}
[ConfigEntry]
private readonly static bool forceNonTarget = true;
[EnableIf(nameof(forceNonTarget))]
[HarmonyPrefix]
[HarmonyPatch(typeof(MAI2System.Config), "IsTarget", MethodType.Getter)]
private static bool PreIsTarget(ref bool __result)
{
// Who is teaching others to set `Target = 1`?!
__result = false;
return false;
}
[ConfigEntry]
private readonly static bool forceIgnoreError = true;
[EnableIf(nameof(forceIgnoreError))]
[HarmonyPrefix]
[HarmonyPatch(typeof(MAI2System.Config), "IsIgnoreError", MethodType.Getter)]
private static bool PreIsIgnoreError(ref bool __result)
{
__result = true;
return false;
}
[ConfigEntry]
private readonly static bool bypassSpecialNumCheck = true;
public static void OnAfterPatch(HarmonyLib.Harmony h)
{
if (bypassSpecialNumCheck)
{
if (typeof(GameManager).GetMethod("CalcSpecialNum") is null) return;
h.PatchAll(typeof(CalcSpecialNumPatch));
}
}
private class CalcSpecialNumPatch
{
[HarmonyPrefix]
[HarmonyPatch(typeof(GameManager), "CalcSpecialNum")]
private static bool CalcSpecialNum(ref int __result)
{
__result = 1024;
return false;
}
}
}

View File

@@ -0,0 +1,212 @@
using System;
using System.Reflection;
using AquaMai.Config.Attributes;
using HarmonyLib;
using MAI2.Util;
using Manager;
using MelonLoader;
using Monitor;
using Process;
using UnityEngine;
namespace AquaMai.Mods.Fix;
[ConfigSection(exampleHidden: true, defaultOn: true)]
public class DebugFeature
{
public static bool IsPolyfill { get; private set; }
private static GameProcess _gameProcess;
private static MovieController _gameMovie;
private static GameMonitor[] _monitors;
private static object _debugFeatureOriginal;
private static System.Type _debugFeatureType;
[HarmonyPatch(typeof(GameProcess), "OnStart")]
[HarmonyPostfix]
public static void Init(GameProcess __instance, MovieController ____gameMovie, GameMonitor[] ____monitors)
{
_gameProcess = __instance;
_gameMovie = ____gameMovie;
_monitors = ____monitors;
PolyFill.timer = 0;
}
public static void OnBeforePatch(HarmonyLib.Harmony h)
{
var original = typeof(GameProcess).GetField("debugFeature", BindingFlags.NonPublic | BindingFlags.Instance);
if (original is null)
{
MelonLogger.Msg(" > [DebugFeature] Running Polyfill");
IsPolyfill = true;
h.PatchAll(typeof(PolyFill));
}
else
{
MelonLogger.Msg(" > [DebugFeature] Already included");
_debugFeatureType = typeof(GameProcess).GetNestedType("DebugFeature", BindingFlags.Instance | BindingFlags.NonPublic);
h.PatchAll(typeof(GetOriginal));
}
}
public static bool Pause
{
get
{
if (IsPolyfill)
{
return PolyFill.isPause;
}
return (bool)_debugFeatureType.GetField("_debugPause", BindingFlags.Instance | BindingFlags.Public).GetValue(_debugFeatureOriginal);
}
set
{
if (IsPolyfill)
{
PolyFill.isPause = value;
}
else
{
_debugFeatureType.GetField("_debugPause", BindingFlags.Instance | BindingFlags.Public).SetValue(_debugFeatureOriginal, value);
}
SoundManager.PauseMusic(value);
_gameMovie.Pause(value);
NotesManager.Pause(value);
}
}
public static void Seek(int msec)
{
Singleton<GamePlayManager>.Instance.Initialize();
if (IsPolyfill)
{
PolyFill.DebugTimeSkip(msec);
}
else
{
_debugFeatureType.GetMethod("DebugTimeSkip", BindingFlags.Instance | BindingFlags.Public).Invoke(_debugFeatureOriginal, new object[] { msec });
}
}
public static double CurrentPlayMsec
{
[Obsolete("不要用它,它有问题。用 PracticeMode.CurrentPlayMsec")]
get
{
if (IsPolyfill)
{
return PolyFill.timer;
}
return (double)_debugFeatureType.GetField("_debugTimer", BindingFlags.Instance | BindingFlags.Public).GetValue(_debugFeatureOriginal);
}
set
{
if (IsPolyfill)
{
PolyFill.timer = value;
}
else
{
_debugFeatureType.GetField("_debugTimer", BindingFlags.Instance | BindingFlags.Public).SetValue(_debugFeatureOriginal, value);
}
Seek(0);
}
}
private static class GetOriginal
{
[HarmonyPatch(typeof(GameProcess), "OnStart")]
[HarmonyPostfix]
public static void Postfix(object ___debugFeature)
{
_debugFeatureOriginal = ___debugFeature;
}
}
private static class PolyFill
{
public static bool isPause;
public static double timer;
public static void DebugTimeSkip(int addMsec)
{
_gameMovie.Pause(pauseFlag: true);
NotesManager.Pause(true);
if (addMsec >= 0)
{
timer += addMsec;
}
else
{
timer = timer + addMsec >= 0.0 ? timer + addMsec : 0.0;
}
_gameMovie.SetSeekFrame(timer);
SoundManager.SeekMusic((int)timer);
for (int i = 0; i < _monitors.Length; i++)
{
_monitors[i].Seek((int)timer);
}
// magic number, dont know why
NotesManager.StartPlay((int)timer + 91);
NotesManager.Pause(isPause);
if (!isPause)
{
SoundManager.PauseMusic(pause: false);
_gameMovie.Pause(pauseFlag: false);
}
else
{
_gameMovie.Pause(pauseFlag: true);
}
_gameProcess.UpdateNotes();
}
[HarmonyPatch(typeof(GameProcess), "OnUpdate")]
[HarmonyPostfix]
public static void Postfix(byte ____sequence)
{
if (____sequence != 4) return;
// GameSequence.Play
if (!isPause)
{
timer += GameManager.GetGameMSecAddD();
}
if (Input.GetKeyDown(KeyCode.Home))
{
GameManager.AutoPlay = (GameManager.AutoPlayMode)((int)(GameManager.AutoPlay + 1) % Enum.GetNames(typeof(GameManager.AutoPlayMode)).Length);
}
else if (Input.GetKeyDown(KeyCode.Return))
{
isPause = !isPause;
SoundManager.PauseMusic(isPause);
_gameMovie.Pause(isPause);
NotesManager.Pause(isPause);
}
else if (DebugInput.GetKeyDown(KeyCode.LeftArrow) || DebugInput.GetKeyDown(KeyCode.RightArrow))
{
var num23 = 0;
if (DebugInput.GetKeyDown(KeyCode.LeftArrow))
{
num23 = -1000;
}
if (DebugInput.GetKeyDown(KeyCode.RightArrow))
{
num23 = 1000;
}
int addMsec = ((!DebugInput.GetKey(KeyCode.LeftShift) && !DebugInput.GetKey(KeyCode.RightShift)) ? ((!DebugInput.GetKey(KeyCode.LeftControl) && !DebugInput.GetKey(KeyCode.RightControl)) ? num23 : (num23 * 10)) : (num23 * 5));
Singleton<GamePlayManager>.Instance.Initialize();
DebugTimeSkip(addMsec);
}
}
}
}

View File

@@ -0,0 +1,87 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager.Operation;
namespace AquaMai.Mods.Fix;
[ConfigSection(exampleHidden: true, defaultOn: true)]
public class DisableReboot
{
// IsAutoRebootNeeded
[HarmonyPrefix]
[HarmonyPatch(typeof(MaintenanceTimer), "IsAutoRebootNeeded")]
public static bool IsAutoRebootNeeded(ref bool __result)
{
__result = false;
return false;
}
// IsUnderServerMaintenance
[HarmonyPrefix]
[HarmonyPatch(typeof(MaintenanceTimer), "IsUnderServerMaintenance")]
public static bool IsUnderServerMaintenance(ref bool __result)
{
__result = false;
return false;
}
// RemainingMinutes
// Original: private int RemainingMinutes => (this._secServerMaintenance + 59) / 60;
[HarmonyPrefix]
[HarmonyPatch(typeof(MaintenanceTimer), "RemainingMinutes", MethodType.Getter)]
public static bool RemainingMinutes(ref int __result)
{
__result = 600;
return false;
}
// GetAutoRebootSec
[HarmonyPrefix]
[HarmonyPatch(typeof(MaintenanceTimer), "GetAutoRebootSec")]
public static bool GetAutoRebootSec(ref int __result)
{
__result = 60 * 60 * 10;
return false;
}
// GetServerMaintenanceSec
[HarmonyPrefix]
[HarmonyPatch(typeof(MaintenanceTimer), "GetServerMaintenanceSec")]
public static bool GetServerMaintenanceSec(ref int __result)
{
__result = 60 * 60 * 10;
return false;
}
// Execute
[HarmonyPrefix]
[HarmonyPatch(typeof(MaintenanceTimer), "Execute")]
public static bool Execute(MaintenanceTimer __instance)
{
return false;
}
// UpdateTimes
[HarmonyPrefix]
[HarmonyPatch(typeof(MaintenanceTimer), "UpdateTimes")]
public static bool UpdateTimes(MaintenanceTimer __instance)
{
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(ClosingTimer), "IsShowRemainingMinutes")]
public static bool IsShowRemainingMinutes(ref bool __result)
{
__result = false;
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(ClosingTimer), "IsClosed")]
public static bool IsClosed(ref bool __result)
{
__result = false;
return false;
}
}

View File

@@ -0,0 +1,21 @@
using AMDaemon.Allnet;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager;
using Manager.Operation;
namespace AquaMai.Mods.Fix;
[ConfigSection(exampleHidden: true, defaultOn: true)]
public class FixCheckAuth
{
[HarmonyPostfix]
[HarmonyPatch(typeof(OperationManager), "CheckAuth_Proc")]
private static void PostCheckAuthProc(ref OperationData ____operationData)
{
if (Auth.GameServerUri.StartsWith("http://") || Auth.GameServerUri.StartsWith("https://"))
{
____operationData.ServerUri = Auth.GameServerUri;
}
}
}

View File

@@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Reflection;
using AquaMai.Core.Attributes;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager;
namespace AquaMai.Mods.Fix.Legacy;
[ConfigSection(exampleHidden: true, defaultOn: true)]
[EnableGameVersion(23000)]
public class FixConnSlide
{
/* 这个 Patch 用于修复以下 bug:
* 非 ConnSlide 被错误解析为 ConnSlide (Fes 首日刹那旅程爆机 bug)
* 原 method 逻辑如下:
*
* if (this.IsSlideAll(noteData1.type) && (index1 + 1 < this._note._noteData.Count ? 1 : 0) != 0)
* {
* int targetNote = noteData1.slideData.targetNote;
* if (noteData1.slideData != null)
* targetNote = noteData1.slideData.targetNote;
* for (int index3 = index1; index3 < this._note._noteData.Count; ++index3)
* {
* NoteData noteData3 = this._note._noteData[index3];
* if (this.IsSlideAll(noteData3.type) && noteData3.time == noteData1.end && noteData3.startButtonPos == targetNote && noteData3.parent == null)
* {
* noteData3.parent = noteData1.parent;
* noteData1.child.Add(noteData3);
* noteData3.isUsed = true;
* noteData3.isJudged = true;
* break;
* }
* }
* }
*
* 修复 bug 需要把第二次调用 this.IsSlideAll() 更改为 this.IsConnectNote(), 这里使用 Transpiler 解决
*/
[HarmonyTranspiler]
[HarmonyPatch(typeof(NotesReader), "calcSlide")]
private static IEnumerable<CodeInstruction> Fix(IEnumerable<CodeInstruction> instructions)
{
List<CodeInstruction> instList = new List<CodeInstruction>(instructions);
bool found = false;
MethodInfo methodIsSlideAll = AccessTools.Method(typeof(NotesReader), "IsSlideAll");
MethodInfo methodIsConnectNote = AccessTools.Method(typeof(NotesReader), "IsConnectNote");
for (int i = 0; i < instList.Count; i++)
{
CodeInstruction inst = instList[i];
if (!found && inst.Calls(methodIsSlideAll))
{
found = true;
continue;
}
if (found && inst.Calls(methodIsSlideAll))
{
inst.operand = methodIsConnectNote;
// MelonLogger.Msg($"[FixConnSlide] Successfully patched NotesReader::calcSlide");
break;
}
}
return instList;
}
}

View File

@@ -0,0 +1,80 @@
using AquaMai.Core.Attributes;
using AquaMai.Config.Attributes;
using HarmonyLib;
using MAI2.Util;
using Manager;
using Monitor;
using Monitor.MusicSelect.ChainList;
using UnityEngine;
namespace AquaMai.Mods.Fix;
[EnableGameVersion(24000)]
[ConfigSection(exampleHidden: true, defaultOn: true)]
public class FixLevelDisplay
{
// Fix wrong position of level number's display for music levels with non-consistant display level and rate level (difficuly constant)
// Stock game charts have no such inconsistency, but custom charts may have (e.g. 10+ but unrated)
[HarmonyPostfix]
[HarmonyPatch(typeof(MusicChainCardObejct), "SetLevel")]
private static void FixLevelShiftMusicChainCardObejct(MusicLevelID levelID, SpriteCounter ____digitLevel, SpriteCounter ____doubleDigitLevel, bool utage, GameObject ____difficultyUtageQuesionMarkSingleDigit, GameObject ____difficultyUtageQuesionMarkDoubleDigit)
{
switch (levelID)
{
case > MusicLevelID.Level9P:
____digitLevel.gameObject.SetActive(value: false);
____doubleDigitLevel.gameObject.SetActive(value: true);
____doubleDigitLevel.ChangeText(levelID.GetLevelNum().PadRight(3));
break;
case >= MusicLevelID.None:
____digitLevel.gameObject.SetActive(value: true);
____doubleDigitLevel.gameObject.SetActive(value: false);
____digitLevel.ChangeText(levelID.GetLevelNum().PadRight(2));
break;
}
if (!utage) return;
switch (levelID)
{
case > MusicLevelID.Level9P:
____difficultyUtageQuesionMarkSingleDigit.SetActive(value: false);
____difficultyUtageQuesionMarkDoubleDigit.SetActive(value: true);
break;
case >= MusicLevelID.None:
____difficultyUtageQuesionMarkSingleDigit.SetActive(value: true);
____difficultyUtageQuesionMarkDoubleDigit.SetActive(value: false);
break;
}
}
[HarmonyPostfix]
[HarmonyPatch(typeof(SingleResultCardController), "SetLevel")]
private static void FixLevelShiftSingleResultCardController(MusicLevelID levelID, bool isUtage, ref SpriteCounter ____difficultySingle, ref SpriteCounter ____difficultyDouble, GameObject ____utageQuestionMarkSingleDigit, GameObject ____utageQuestionMarkDoubleDigit)
{
FixLevelShiftMusicChainCardObejct(levelID, ____difficultySingle, ____difficultyDouble, isUtage, ____utageQuestionMarkSingleDigit, ____utageQuestionMarkDoubleDigit);
}
[HarmonyPostfix]
[HarmonyPatch(typeof(TotalResultPlayer), "SetLevel")]
private static void FixLevelShiftTotalResultPlayer(MusicLevelID levelID, bool isUtage, ref SpriteCounter ____difficultySingle, ref SpriteCounter ____difficultyDouble, GameObject ____utageQuestionMarkSingleDigit, GameObject ____utageQuestionMarkDoubleDigit)
{
FixLevelShiftMusicChainCardObejct(levelID, ____difficultySingle, ____difficultyDouble, isUtage, ____utageQuestionMarkSingleDigit, ____utageQuestionMarkDoubleDigit);
}
[HarmonyPostfix]
[HarmonyPatch(typeof(ResultMonitor), "SetLevel")]
private static void FixLevelShiftTotalResultPlayer(MusicLevelID levelID, ref SpriteCounter ____difficultySingle, ref SpriteCounter ____difficultyDouble)
{
FixLevelShiftMusicChainCardObejct(levelID, ____difficultySingle, ____difficultyDouble, false, null, null);
}
[HarmonyPostfix]
[HarmonyPatch(typeof(TrackStartMonitor), "SetTrackStart")]
private static void FixLevelShiftTrackStartMonitor(int ___monitorIndex, ref SpriteCounter ____difficultySingle, ref SpriteCounter ____difficultyDouble, GameObject ____utageQuestionSingleDigit, GameObject ____utageQuestionDoubleDigit)
{
var music = Singleton<DataManager>.Instance.GetMusic(GameManager.SelectMusicID[___monitorIndex]);
var levelID = (MusicLevelID)music.notesData[GameManager.SelectDifficultyID[___monitorIndex]].musicLevelID;
FixLevelShiftMusicChainCardObejct(levelID, ____difficultySingle, ____difficultyDouble, music.name.id >= 100000, ____utageQuestionSingleDigit, ____utageQuestionDoubleDigit);
}
}

View File

@@ -0,0 +1,104 @@
using System.Collections.Generic;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager;
using Monitor;
namespace AquaMai.Mods.Fix;
[ConfigSection(exampleHidden: true, defaultOn: true)]
public class FixSlideAutoPlay
{
/* 这个 Patch 用于修复以下 bug:
* SlideFan 在 AutoPlay 时, 只有第一个箭头会消失
* 原 method 逻辑如下:
*
* if (this.IsNoteCheckTimeStartIgnoreJudgeWait())
* {
* // do something ...
* if (!GameManager.IsAutoPlay())
* {
* // do something ...
* for (int index = 0; index < this._arrowPrefubs.Length && (double) index < (double) num2 * 11.0; ++index)
* {
* // do something about displaying arrows ...
* }
* }
* else
* {
* float num4 = (currentMsec - this.StarLaunchMsec) / (this.StarArriveMsec - this.StarLaunchMsec - this.lastWaitTime);
* for (int index = 0; index < this._arrowPrefubs.Length && (double) index < (double) num4 * 1.0; ++index)
* {
* // do something about displaying arrows ...
* }
* if ((double) num4 > 1.0)
* num1 = 3;
* }
* // do something ...
* }
*
* 导致这个 bug 的原因是 else 分支的 for 循环终止条件写错了, 应该是 11.0 (因为有 11 个箭头), SBGA 写成了 1.0
* 这个 method 中一共只有 5 处 ldc.r4 的 IL Code, 依次为 10.0, 11.0, 1.0, 1.0, 0.0
* 修复 bug 需要把第三处的 1.0 更改为 11.0, 这里使用 Transpiler 解决
*/
[HarmonyTranspiler]
[HarmonyPatch(typeof(SlideFan), "NoteCheck")]
private static IEnumerable<CodeInstruction> FixFanAutoPlayArrow(IEnumerable<CodeInstruction> instructions)
{
List<CodeInstruction> instList = new List<CodeInstruction>(instructions);
bool found = false;
for (int i = 0; i < instList.Count; i++)
{
CodeInstruction inst = instList[i];
if (inst.LoadsConstant(11.0))
{
found = true;
}
if (found && inst.LoadsConstant(1.0))
{
inst.operand = 11.0f;
break;
}
}
return instList;
}
/* 这个 Patch 让 Slide 在 AutoPlay 的时候, 每个区仍然会分按下和松开两段进行推进 (加上 this._hitIn 的变化)
* 原 method 逻辑如下:
*
* if (!GameManager.IsAutoPlay())
* {
* // do somethings ...
* }
* else
* {
* float num1 = (currentMsec - this.StarLaunchMsec) / (this.StarArriveMsec - this.StarLaunchMsec - this.lastWaitTime);
* this._hitIndex = (int) ((double) this._hitAreaList.Count * (double) num1);
* if (this._hitIndex >= this._hitAreaList.Count)
* this._hitIndex = this._hitAreaList.Count - 1;
* if (this._hitIndex < 0)
* this._hitIndex = 0;
* int num2 = (int) ((double) this._dispLaneNum * this.GetDeleteArrowDistance());
* // do somethings ...
* }
*
* 现在要在 this.GetDeleteArrowDistance() 之前插入
* this._hitIn = ((float)this._hitAreaList.Count * num1 > (float)this._hitIndex + 0.5f);
* 这段代码, 可以采用 Prefix, GetDeleteArrowDistance() 只在两个地方调用过, 另一处就在上面的 if 分支中 (即非 AutoPlay 情况)
*/
[HarmonyPrefix]
[HarmonyPatch(typeof(SlideRoot), "GetDeleteArrowDistance")]
private static void FixSlideAutoPlayArrow(
SlideRoot __instance, ref bool ____hitIn, int ____hitIndex, List<SlideManager.HitArea> ____hitAreaList,
float ___StarLaunchMsec, float ___StarArriveMsec, float ___lastWaitTime
)
{
if (GameManager.IsAutoPlay())
{
float prop = (NotesManager.GetCurrentMsec() - ___StarLaunchMsec) / (___StarArriveMsec - ___StarLaunchMsec - ___lastWaitTime);
____hitIn = ____hitAreaList.Count * prop > ____hitIndex + 0.5f;
}
}
}

View File

@@ -0,0 +1,30 @@
using AquaMai.Core.Attributes;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager;
namespace AquaMai.Mods.Fix.Legacy;
[ConfigSection(exampleHidden: true, defaultOn: true)]
[EnableGameVersion(23000, 23499, noWarn: true)]
public class FixQuickRetry130
{
// Fix for the game not resetting Fast and Late counts when quick retrying
// For game version < 1.35.0
[HarmonyPostfix]
[HarmonyPatch(typeof(GamePlayManager), "SetQuickRetryFrag")]
public static void PostGamePlayManagerSetQuickRetryFrag(GamePlayManager __instance, bool flag)
{
// Since 1.35.0, `GameScoreList.Initialize()` resets the Fast and Late counts
if (flag && !Traverse.Create(typeof(GameScoreList)).Methods().Contains("Initialize"))
{
for (int i = 0; i < 4; i++)
{
var gameScoreList = __instance.GetGameScore(i);
var traverse = Traverse.Create(gameScoreList);
traverse.Property("Fast").SetValue((uint)0);
traverse.Property("Late").SetValue((uint)0);
}
}
}
}

View File

@@ -0,0 +1,7 @@
# Fix
Fix of the game's bugs or removal of the game's annoying unuseful "features".
Non-removal "Fix" patches should have no (negative, or any visual changing) side-effects on the original game.
All patches under "Fix" should enabled by default and hide in example. They could be still turned off manually in the config.

View File

@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Process;
using Util;
namespace AquaMai.Mods.Fix.Stability;
/**
* Fix character selection crashing due to missing character data
*/
[ConfigSection(exampleHidden: true, defaultOn: true)]
public class FixMissingCharaCrash
{
// Check if the return is null. If it is, make up a color
[HarmonyPostfix]
[HarmonyPatch(typeof(CharacterSelectProces), "GetMapColorData")]
public static void GetMapColorData(ref CharacterSelectProces __instance, ref CharacterMapColorData __result)
{
if (__result != null) return;
// 1 is a color that definitely exists
if (MapMaster.GetSlotData(1) == null)
{
MapMaster.GetSlotData(1).Load();
}
__result = MapMaster.GetSlotData(1);
}
// This is called when loading the music selection screen, to display characters on the top screen
[HarmonyPrefix]
[HarmonyPatch(typeof(Monitor.CommonMonitor), "SetCharacterSlot", new Type[] { typeof(MessageCharactorInfomationData) })]
public static bool SetCharacterSlot(ref MessageCharactorInfomationData data, Dictionary<int, CharacterSlotData> ____characterSlotData)
{
// Some characters are not found in this dictionary. We simply skip loading those characters
if (!____characterSlotData.ContainsKey(data.MapKey))
{
Console.Log($"Could not get CharacterSlotData for character [Index={data.Index}, MapKey={data.MapKey}], ignoring...");
return false;
}
return true;
}
}

View File

@@ -0,0 +1,49 @@
using AquaMai.Config.Attributes;
using AquaMai.Core.Attributes;
using HarmonyLib;
namespace AquaMai.Mods.GameSettings;
[ConfigSection(
en: "Set the game to Paid Play (lock credits) or Free Play.",
zh: "设置游戏为付费游玩(锁定可用点数)或免费游玩")]
public class CreditConfig
{
[ConfigEntry(
en: "Set to Free Play (set to false for Paid Play).",
zh: "是否免费游玩(设为 false 时为付费游玩)")]
private static readonly bool isFreePlay = true;
[HarmonyPrefix]
[HarmonyPatch(typeof(Manager.Credit), "IsFreePlay")]
private static bool PreIsFreePlay(ref bool __result)
{
__result = isFreePlay;
return false;
}
[ConfigEntry(
en: "Lock credits amount (only valid in Paid Play). Set to 0 to disable.",
zh: "锁定可用点数数量(仅在付费游玩时有效),设为 0 以禁用")]
private static readonly uint lockCredits = 24;
private static bool ShouldLockCredits => !isFreePlay && lockCredits > 0;
[EnableIf(nameof(ShouldLockCredits))]
[HarmonyPrefix]
[HarmonyPatch(typeof(Manager.Credit), "IsGameCostEnough")]
private static bool PreIsGameCostEnough(ref bool __result)
{
__result = true;
return false;
}
[EnableIf(nameof(ShouldLockCredits))]
[HarmonyPrefix]
[HarmonyPatch(typeof(AMDaemon.CreditUnit), "Credit", MethodType.Getter)]
private static bool PreCredit(ref uint __result)
{
__result = 24;
return false;
}
}

View File

@@ -0,0 +1,27 @@
using AMDaemon;
using AquaMai.Config.Attributes;
using HarmonyLib;
namespace AquaMai.Mods.GameSettings;
[ConfigSection(
en: "If you want to configure in-shop party-link, you should turn this off.",
zh: "如果要配置店内招募的话,应该要把这个关闭")]
public class ForceAsServer
{
[HarmonyPrefix]
[HarmonyPatch(typeof(LanInstall), "IsServer", MethodType.Getter)]
private static bool PreIsServer(ref bool __result)
{
__result = true;
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(Network), "IsLanAvailable", MethodType.Getter)]
private static bool PreIsLanAvailable(ref bool __result)
{
__result = false;
return false;
}
}

View File

@@ -0,0 +1,50 @@
using System.Threading;
using AquaMai.Config.Attributes;
using HarmonyLib;
using IO;
using Manager.UserDatas;
namespace AquaMai.Mods.GameSettings;
[ConfigSection(
en: "Globally adjust A/B judgment (unit same as in-game options) or increase touch delay.",
zh: "全局调整 A/B 判(单位和游戏里一样)或增加触摸延迟")]
public class JudgeAdjust
{
[ConfigEntry(
en: "Adjust A judgment.",
zh: "调整 A 判")]
private static readonly double a = 0;
[ConfigEntry(
en: "Adjust B judgment.",
zh: "调整 B 判")]
private static readonly double b = 0;
[ConfigEntry(
en: "Increase touch delay.",
zh: "增加触摸延迟")]
private static readonly int touchDelay = 0;
[HarmonyPostfix]
[HarmonyPatch(typeof(UserOption), "GetAdjustMSec")]
public static void GetAdjustMSec(ref float __result)
{
__result += (float)(a * 16.666666d);
}
[HarmonyPostfix]
[HarmonyPatch(typeof(UserOption), "GetJudgeTimingFrame")]
public static void GetJudgeTimingFrame(ref float __result)
{
__result += (float)b;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(NewTouchPanel), "Recv")]
public static void NewTouchPanelRecv()
{
if (touchDelay <= 0) return;
Thread.Sleep(touchDelay);
}
}

View File

@@ -0,0 +1,5 @@
# GameSettings
Overriding or adjusting the game settings that're originally configurable / modifiable, but made into patches for unification, flexibility or convenience.
Patches changing the way the game running / behaving which are not possible in the stock game may need to go to the GameSystem category.

View File

@@ -0,0 +1,154 @@
using System.Collections.Generic;
using System.Reflection;
using AquaMai.Config.Attributes;
using HarmonyLib;
using IO;
using Manager;
using MelonLoader;
namespace AquaMai.Mods.GameSettings;
[ConfigSection(
en: """
Use custom touch sensitivity.
When enabled, the settings in Test mode will not take effect.
When disabled, the settings in Test mode is used.
Sensitivity adjustments in Test mode are not linear.
Default sensitivity in area A: 90, 80, 70, 60, 50, 40, 30, 26, 23, 20, 10.
Default sensitivity in other areas: 70, 60, 50, 40, 30, 20, 15, 10, 5, 1, 1.
A setting of 0 in Test mode corresponds to 40, 20 here, -5 corresponds to 90, 70, +5 corresponds to 10, 1.
The higher the number in Test mode, the lower the number here, resulting in higher sensitivity for official machines.
For ADX, the sensitivity is reversed, so the higher the number here, the higher the sensitivity.
""",
zh: """
使用自定义触摸灵敏度
这里启用之后 Test 里的就不再起作用了
这里禁用之后就还是用 Test 里的调
在 Test 模式下调整的灵敏度不是线性的
A 区默认灵敏度 90, 80, 70, 60, 50, 40, 30, 26, 23, 20, 10
其他区域默认灵敏度 70, 60, 50, 40, 30, 20, 15, 10, 5, 1, 1
Test 里设置的 0 对应的是 40, 20 这一档,-5 是 90, 70+5 是 10, 1
Test 里的数字越大,这里的数字越小,对于官机来说,灵敏度更大
而 ADX 的灵敏度是反的,所以对于 ADX这里的数字越大灵敏度越大
""")]
public class TouchSensitivity
{
[ConfigEntry]
private static readonly byte A1 = 40;
[ConfigEntry]
private static readonly byte A2 = 40;
[ConfigEntry]
private static readonly byte A3 = 40;
[ConfigEntry]
private static readonly byte A4 = 40;
[ConfigEntry]
private static readonly byte A5 = 40;
[ConfigEntry]
private static readonly byte A6 = 40;
[ConfigEntry]
private static readonly byte A7 = 40;
[ConfigEntry]
private static readonly byte A8 = 40;
[ConfigEntry]
private static readonly byte B1 = 20;
[ConfigEntry]
private static readonly byte B2 = 20;
[ConfigEntry]
private static readonly byte B3 = 20;
[ConfigEntry]
private static readonly byte B4 = 20;
[ConfigEntry]
private static readonly byte B5 = 20;
[ConfigEntry]
private static readonly byte B6 = 20;
[ConfigEntry]
private static readonly byte B7 = 20;
[ConfigEntry]
private static readonly byte B8 = 20;
[ConfigEntry]
private static readonly byte C1 = 20;
[ConfigEntry]
private static readonly byte C2 = 20;
[ConfigEntry]
private static readonly byte D1 = 20;
[ConfigEntry]
private static readonly byte D2 = 20;
[ConfigEntry]
private static readonly byte D3 = 20;
[ConfigEntry]
private static readonly byte D4 = 20;
[ConfigEntry]
private static readonly byte D5 = 20;
[ConfigEntry]
private static readonly byte D6 = 20;
[ConfigEntry]
private static readonly byte D7 = 20;
[ConfigEntry]
private static readonly byte D8 = 20;
[ConfigEntry]
private static readonly byte E1 = 20;
[ConfigEntry]
private static readonly byte E2 = 20;
[ConfigEntry]
private static readonly byte E3 = 20;
[ConfigEntry]
private static readonly byte E4 = 20;
[ConfigEntry]
private static readonly byte E5 = 20;
[ConfigEntry]
private static readonly byte E6 = 20;
[ConfigEntry]
private static readonly byte E7 = 20;
[ConfigEntry]
private static readonly byte E8 = 20;
[HarmonyPrefix]
[HarmonyPatch(typeof(NewTouchPanel), "SetTouchPanelSensitivity")]
public static void SetTouchPanelSensitivityPrefix(List<byte> sensitivity)
{
var configType = typeof(TouchSensitivity);
for (var i = 0; i < 34; i++)
{
var area = (InputManager.TouchPanelArea)i;
var field = configType.GetField(area.ToString(), BindingFlags.NonPublic | BindingFlags.Static);
var value = (byte)field.GetValue(null);
sensitivity[i] = value;
}
MelonLogger.Msg("[TouchSensitivity] Applied");
}
}

View File

@@ -0,0 +1,146 @@
using System.Collections.Generic;
using System.Linq;
using AquaMai.Config.Attributes;
using AquaMai.Core.Helpers;
using HarmonyLib;
using MelonLoader;
using TMPro;
using UnityEngine;
using UnityEngine.TextCore.LowLevel;
namespace AquaMai.Mods.GameSystem.Assets;
[ConfigSection(
en: "Use custom font(s) as fallback or fully replace the original game font.",
zh: "使用自定义字体作为回退(解决中文字形缺失问题),或完全替换游戏原字体")]
public class Fonts
{
[ConfigEntry(
en: """
Font path(s).
Use semicolon to separate multiple paths for a fallback chain.
Microsoft YaHei Bold by default.
""",
zh: """
字体路径
使用分号分隔多个路径以构成 Fallback 链
默认为微软雅黑 Bold
""")]
private static readonly string paths = "%SYSTEMROOT%/Fonts/msyhbd.ttc";
[ConfigEntry(
en: "Add custom font(s) as fallback, use original game font when possible.",
zh: "将自定义字体作为游戏原字体的回退,尽可能使用游戏原字体")]
private static readonly bool addAsFallback = true;
private static List<TMP_FontAsset> fontAssets = [];
private static readonly List<TMP_FontAsset> processedFonts = [];
private static TMP_FontAsset replacementFontAsset;
private static List<TMP_FontAsset> fallbackFontAssets = [];
public static void OnBeforePatch()
{
var paths = Fonts.paths
.Split(';')
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(FileSystem.ResolvePath);
var fonts = paths
.Select(p =>
{
var font = new Font(p);
if (font == null)
{
MelonLogger.Warning($"[Fonts] Font not found: {p}");
}
return font;
})
.Where(f => f != null);
fontAssets = fonts
.Select(f => TMP_FontAsset.CreateFontAsset(f, 90, 9, GlyphRenderMode.SDFAA, 8192, 8192))
.ToList();
if (fontAssets.Count == 0)
{
MelonLogger.Warning("[Fonts] No font loaded.");
}
else if (addAsFallback)
{
fallbackFontAssets = fontAssets;
}
else
{
replacementFontAsset = fontAssets[0];
fallbackFontAssets = fontAssets.Skip(1).ToList();
}
}
[HarmonyPatch(typeof(TextMeshProUGUI), "Awake")]
[HarmonyPostfix]
public static void PostAwake(TextMeshProUGUI __instance)
{
if (fontAssets.Count == 0) return;
if (processedFonts.Contains(__instance.font)) return;
if (replacementFontAsset != null)
{
ProcessReplacement(__instance);
}
if (fallbackFontAssets.Count > 0)
{
ProcessFallback(__instance);
}
processedFonts.Add(__instance.font);
}
private static void ProcessReplacement(TextMeshProUGUI __instance)
{
# if DEBUG
MelonLogger.Msg($"{__instance.font.name} {__instance.text}");
# endif
var materialOrigin = __instance.fontMaterial;
var materialSharedOrigin = __instance.fontSharedMaterial;
__instance.font = replacementFontAsset;
# if DEBUG
MelonLogger.Msg($"shaderKeywords {materialOrigin.shaderKeywords.Join()} {__instance.fontMaterial.shaderKeywords.Join()}");
# endif
// __instance.fontSharedMaterial = materialSharedOrigin;
// 这样之后该有描边的地方整个字后面都是阴影,它不知道哪里是边
// materialOrigin.mainTexture = __instance.fontMaterial.mainTexture;
// materialOrigin.mainTextureOffset = __instance.fontMaterial.mainTextureOffset;
// materialOrigin.mainTextureScale = __instance.fontMaterial.mainTextureScale;
// __instance.fontMaterial.CopyPropertiesFromMaterial(materialOrigin);
// 这样了之后有描边了,但是描边很细
// __instance.fontMaterial.shader = materialOrigin.shader;
foreach (var keyword in materialOrigin.shaderKeywords)
{
__instance.fontMaterial.EnableKeyword(keyword);
}
// __instance.fontMaterial.globalIlluminationFlags = materialOrigin.globalIlluminationFlags;
// 原来是 underlay但是复制这三个属性之后就又变成整个字后面都是阴影了
// __instance.fontMaterial.SetFloat(ShaderUtilities.ID_UnderlayOffsetY, materialOrigin.GetFloat(ShaderUtilities.ID_UnderlayOffsetY));
// __instance.fontMaterial.SetFloat(ShaderUtilities.ID_UnderlayOffsetX, materialOrigin.GetFloat(ShaderUtilities.ID_UnderlayOffsetX));
// __instance.fontMaterial.SetFloat(ShaderUtilities.ID_UnderlayDilate, materialOrigin.GetFloat(ShaderUtilities.ID_UnderlayDilate));
// if(materialOrigin.shaderKeywords.Contains(ShaderUtilities.Keyword_Underlay))
// {
// __instance.fontMaterial.EnableKeyword(ShaderUtilities.Keyword_Glow);
// __instance.fontMaterial.SetFloat(ShaderUtilities.ID_GlowOuter, .5f);
// // __instance.fontMaterial.SetFloat(ShaderUtilities.ID_UnderlayOffsetX, materialOrigin.GetFloat(ShaderUtilities.ID_UnderlayOffsetX));
// }
}
private static void ProcessFallback(TextMeshProUGUI __instance)
{
foreach (var fontAsset in fallbackFontAssets)
{
__instance.font.fallbackFontAssetTable.Add(fontAsset);
}
}
}

View File

@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Linq;
using HarmonyLib;
using UnityEngine;
using Manager;
using Util;
using AquaMai.Config.Attributes;
namespace AquaMai.Mods.GameSystem.Assets;
[ConfigSection(
en: "Load all existing \".ab\" image resources regardless of the AssetBundleImages manifest.",
zh: """
加载所有存在的 .ab 图片资源(无视 AssetBundleImages.manifest
导入了删除曲包之类的话,应该需要开启这个
""")]
public class LoadAssetBundleWithoutManifest
{
private static HashSet<string> abFiles = new HashSet<string>();
[HarmonyPostfix]
[HarmonyPatch(typeof(OptionDataManager), "CheckAssetBundle")]
public static void PostCheckAssetBundle(ref Safe.ReadonlySortedDictionary<string, string> abs)
{
foreach (var ab in abs)
{
abFiles.Add(ab.Key);
}
}
[HarmonyPrefix]
[HarmonyPatch(typeof(AssetBundleManifest), "GetAllAssetBundles")]
public static bool PreGetAllAssetBundles(AssetBundleManifest __instance, ref string[] __result)
{
__result = abFiles.ToArray();
return false;
}
}

View File

@@ -0,0 +1,577 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using HarmonyLib;
using UnityEngine;
using System.Text.RegularExpressions;
using MAI2.Util;
using Manager;
using MelonLoader;
using Monitor;
using AquaMai.Config.Attributes;
using AquaMai.Core.Helpers;
namespace AquaMai.Mods.GameSystem.Assets;
[ConfigSection(
en: "Load asset images from the configured directory (for self-made charts).",
zh: "从指定目录下加载资源图片(自制谱用)")]
public class LoadLocalImages
{
[ConfigEntry]
private static readonly string localAssetsDir = "LocalAssets";
private static readonly string[] imageExts = [".jpg", ".png", ".jpeg"];
private static readonly Dictionary<string, string> jacketPaths = [];
private static readonly Dictionary<string, string> framePaths = [];
private static readonly Dictionary<string, string> platePaths = [];
private static readonly Dictionary<string, string> framemaskPaths = [];
private static readonly Dictionary<string, string> framepatternPaths = [];
private static readonly Dictionary<string, string> iconPaths = [];
private static readonly Dictionary<string, string> charaPaths = [];
private static readonly Dictionary<string, string> partnerPaths = [];
//private static readonly Dictionary<string, string> navicharaPaths = [];
private static readonly Dictionary<string, string> tabTitlePaths = [];
private static readonly Dictionary<string, string> localAssetsContents = [];
[HarmonyPrefix]
[HarmonyPatch(typeof(DataManager), "LoadMusicBase")]
public static void LoadMusicPostfix(List<string> ____targetDirs)
{
foreach (var aDir in ____targetDirs)
{
if (Directory.Exists(Path.Combine(aDir, @"AssetBundleImages\jacket")))
foreach (var file in Directory.GetFiles(Path.Combine(aDir, @"AssetBundleImages\jacket")))
{
if (!imageExts.Contains(Path.GetExtension(file).ToLowerInvariant())) continue;
var idStr = Path.GetFileName(file).Substring("ui_jacket_".Length, 6);
jacketPaths[idStr] = file;
}
if (Directory.Exists(Path.Combine(aDir, @"AssetBundleImages\frame")))
foreach (var file in Directory.GetFiles(Path.Combine(aDir, @"AssetBundleImages\frame")))
{
if (!imageExts.Contains(Path.GetExtension(file).ToLowerInvariant())) continue;
var idStr = Path.GetFileName(file).Substring("ui_frame_".Length, 6);
framePaths[idStr] = file;
}
if (Directory.Exists(Path.Combine(aDir, @"AssetBundleImages\nameplate")))
foreach (var file in Directory.GetFiles(Path.Combine(aDir, @"AssetBundleImages\nameplate")))
{
if (!imageExts.Contains(Path.GetExtension(file).ToLowerInvariant())) continue;
var idStr = Path.GetFileName(file).Substring("ui_plate_".Length, 6);
platePaths[idStr] = file;
}
if (Directory.Exists(Path.Combine(aDir, @"AssetBundleImages\framemask")))
foreach (var file in Directory.GetFiles(Path.Combine(aDir, @"AssetBundleImages\framemask")))
{
if (!imageExts.Contains(Path.GetExtension(file).ToLowerInvariant())) continue;
var idStr = Path.GetFileName(file).Substring("ui_framemask_".Length, 6);
framemaskPaths[idStr] = file;
}
if (Directory.Exists(Path.Combine(aDir, @"AssetBundleImages\framepattern")))
foreach (var file in Directory.GetFiles(Path.Combine(aDir, @"AssetBundleImages\framepattern")))
{
if (!imageExts.Contains(Path.GetExtension(file).ToLowerInvariant())) continue;
var idStr = Path.GetFileName(file).Substring("ui_framepattern_".Length, 6);
framepatternPaths[idStr] = file;
}
if (Directory.Exists(Path.Combine(aDir, @"AssetBundleImages\icon")))
foreach (var file in Directory.GetFiles(Path.Combine(aDir, @"AssetBundleImages\icon")))
{
if (!imageExts.Contains(Path.GetExtension(file).ToLowerInvariant())) continue;
var idStr = Path.GetFileName(file).Substring("ui_icon_".Length, 6);
iconPaths[idStr] = file;
}
if (Directory.Exists(Path.Combine(aDir, @"AssetBundleImages\chara")))
foreach (var file in Directory.GetFiles(Path.Combine(aDir, @"AssetBundleImages\chara")))
{
if (!imageExts.Contains(Path.GetExtension(file).ToLowerInvariant())) continue;
var idStr = Path.GetFileName(file).Substring("ui_chara_".Length, 6);
charaPaths[idStr] = file;
}
if (Directory.Exists(Path.Combine(aDir, @"AssetBundleImages\partner")))
foreach (var file in Directory.GetFiles(Path.Combine(aDir, @"AssetBundleImages\partner")))
{
if (!imageExts.Contains(Path.GetExtension(file).ToLowerInvariant())) continue;
var idStr = Path.GetFileName(file).Substring("ui_Partner_".Length, 6);
partnerPaths[idStr] = file;
}
//if (Directory.Exists(Path.Combine(aDir, @"AssetBundleImages\navichara\sprite\parts\ui_navichara_21")))
// foreach (var file in Directory.GetFiles(Path.Combine(aDir, @"AssetBundleImages\navichara\sprite\parts\ui_navichara_", charaid)))
//{
// if (!imageExts.Contains(Path.GetExtension(file).ToLowerInvariant())) continue;
//var idStr = Path.GetFileName(file).Substring("ui_navichara_".Length, 6);
// navicharaPaths[idStr] = file;
// }
if (Directory.Exists(Path.Combine(aDir, @"Common\Sprites\Tab\Title")))
foreach (var file in Directory.GetFiles(Path.Combine(aDir, @"Common\Sprites\Tab\Title")))
{
if (!imageExts.Contains(Path.GetExtension(file).ToLowerInvariant())) continue;
tabTitlePaths[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = file;
}
}
MelonLogger.Msg($"[LoadLocalImages] Loaded {jacketPaths.Count} Jacket, {platePaths.Count} NamePlate, {framePaths.Count} Frame, {framemaskPaths.Count} FrameMask, {framepatternPaths.Count} FramePattern, {iconPaths.Count} Icon, {charaPaths.Count} Chara, {partnerPaths.Count} PartnerLogo, {tabTitlePaths.Count} Tab Titles from AssetBundleImages.");
var resolvedDir = FileSystem.ResolvePath(localAssetsDir);
if (Directory.Exists(resolvedDir))
foreach (var laFile in Directory.EnumerateFiles(resolvedDir))
{
if (!imageExts.Contains(Path.GetExtension(laFile).ToLowerInvariant())) continue;
localAssetsContents[Path.GetFileNameWithoutExtension(laFile).ToLowerInvariant()] = laFile;
}
MelonLogger.Msg($"[LoadLocalImages] Loaded {localAssetsContents.Count} LocalAssets.");
}
private static string GetJacketPath(string id)
{
return localAssetsContents.TryGetValue(id, out var laPath) ? laPath : jacketPaths.GetValueOrDefault(id);
}
public static Texture2D GetJacketTexture2D(string id)
{
var path = GetJacketPath(id);
if (path == null)
{
return null;
}
var texture = new Texture2D(1, 1, TextureFormat.RGBA32, false);
texture.LoadImage(File.ReadAllBytes(path));
return texture;
}
public static Texture2D GetJacketTexture2D(int id)
{
return GetJacketTexture2D($"{id:000000}");
}
private static string GetFramePath(string id)
{
return framePaths.GetValueOrDefault(id);
}
public static Texture2D GetFrameTexture2D(string id)
{
var path = GetFramePath(id);
if (path == null)
{
return null;
}
var texture = new Texture2D(1, 1, TextureFormat.RGBA32, false);
texture.LoadImage(File.ReadAllBytes(path));
return texture;
}
private static string GetPlatePath(string id)
{
return platePaths.GetValueOrDefault(id);
}
public static Texture2D GetPlateTexture2D(string id)
{
var path = GetPlatePath(id);
if (path == null)
{
return null;
}
var texture = new Texture2D(1, 1, TextureFormat.RGBA32, false);
texture.LoadImage(File.ReadAllBytes(path));
return texture;
}
private static string GetFrameMaskPath(string id)
{
return framemaskPaths.GetValueOrDefault(id);
}
public static Texture2D GetFrameMaskTexture2D(string id)
{
var path = GetFrameMaskPath(id);
if (path == null)
{
return null;
}
var texture = new Texture2D(1, 1, TextureFormat.RGBA32, false);
texture.LoadImage(File.ReadAllBytes(path));
return texture;
}
private static string GetFramePatternPath(string id)
{
return framepatternPaths.GetValueOrDefault(id);
}
public static Texture2D GetFramePatternTexture2D(string id)
{
var path = GetFramePatternPath(id);
if (path == null)
{
return null;
}
var texture = new Texture2D(1, 1, TextureFormat.RGBA32, false);
texture.LoadImage(File.ReadAllBytes(path));
return texture;
}
private static string GetIconPath(string id)
{
return iconPaths.GetValueOrDefault(id);
}
public static Texture2D GetIconTexture2D(string id)
{
var path = GetIconPath(id);
if (path == null)
{
return null;
}
var texture = new Texture2D(1, 1, TextureFormat.RGBA32, false);
texture.LoadImage(File.ReadAllBytes(path));
return texture;
}
private static string GetCharaPath(string id)
{
return charaPaths.GetValueOrDefault(id);
}
public static Texture2D GetCharaTexture2D(string id)
{
var path = GetCharaPath(id);
if (path == null)
{
return null;
}
var texture = new Texture2D(1, 1, TextureFormat.RGBA32, false);
texture.LoadImage(File.ReadAllBytes(path));
return texture;
}
private static string GetPartnerPath(string id)
{
return partnerPaths.GetValueOrDefault(id);
}
public static Texture2D GetPartnerTexture2D(string id)
{
var path = GetPartnerPath(id);
if (path == null)
{
return null;
}
var texture = new Texture2D(1, 1, TextureFormat.RGBA32, false);
texture.LoadImage(File.ReadAllBytes(path));
return texture;
}
/*
[HarmonyPatch]
public static class TabTitleLoader
{
public static IEnumerable<MethodBase> TargetMethods()
{
// Fxxk unity
// game load tab title by call Resources.Load<Sprite> directly
// patching Resources.Load<Sprite> need this stuff
// var method = typeof(Resources).GetMethods(BindingFlags.Public | BindingFlags.Static).First(it => it.Name == "Load" && it.IsGenericMethod).MakeGenericMethod(typeof(Sprite));
// return [method];
// but it not work, game will blackscreen if add prefix or postfix
//
// patching AssetBundleManager.LoadAsset will lead game memory error
// return [AccessTools.Method(typeof(AssetBundleManager), "LoadAsset", [typeof(string)], [typeof(Object)])];
// and this is not work because game not using this
//
// we load them manually after game load and no need to hook the load progress
}
public static bool Prefix(string path, ref Object __result)
{
if (!path.StartsWith("Common/Sprites/Tab/Title/")) return true;
var filename = Path.GetFileNameWithoutExtension(path).ToLowerInvariant();
var locPath = localAssetsContents.TryGetValue(filename, out var laPath) ? laPath : tabTitlePaths.GetValueOrDefault(filename);
if (locPath is null) return true;
var texture = new Texture2D(1, 1);
texture.LoadImage(File.ReadAllBytes(locPath));
__result = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f));
MelonLogger.Msg($"GetTabTitleSpritePrefix {locPath} {__result}");
return false;
}
}
*/
[HarmonyPostfix]
[HarmonyPatch(typeof(MusicSelectMonitor), "Initialize")]
public static void TabTitleLoader(MusicSelectMonitor __instance, Dictionary<int, Sprite> ____genreSprite, Dictionary<int, Sprite> ____versionSprite)
{
var genres = Singleton<DataManager>.Instance.GetMusicGenres();
foreach (var (id, genre) in genres)
{
if (____genreSprite.GetValueOrDefault(id) is not null) continue;
var filename = genre.FileName.ToLowerInvariant();
var locPath = localAssetsContents.TryGetValue(filename, out var laPath) ? laPath : tabTitlePaths.GetValueOrDefault(filename);
if (locPath is null) continue;
var texture = new Texture2D(1, 1, TextureFormat.RGBA32, false);
texture.LoadImage(File.ReadAllBytes(locPath));
____genreSprite[id] = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f));
}
var versions = Singleton<DataManager>.Instance.GetMusicVersions();
foreach (var (id, version) in versions)
{
if (____versionSprite.GetValueOrDefault(id) is not null) continue;
var filename = version.FileName.ToLowerInvariant();
var locPath = localAssetsContents.TryGetValue(filename, out var laPath) ? laPath : tabTitlePaths.GetValueOrDefault(filename);
if (locPath is null) continue;
var texture = new Texture2D(1, 1, TextureFormat.RGBA32, false);
texture.LoadImage(File.ReadAllBytes(locPath));
____versionSprite[id] = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f));
}
}
[HarmonyPatch]
public static class JacketLoader
{
public static IEnumerable<MethodBase> TargetMethods()
{
var AM = typeof(AssetManager);
return [AM.GetMethod("GetJacketThumbTexture2D", [typeof(string)]), AM.GetMethod("GetJacketTexture2D", [typeof(string)])];
}
public static bool Prefix(string filename, ref Texture2D __result, AssetManager __instance)
{
var matches = Regex.Matches(filename, @"UI_Jacket_(\d+)(_s)?\.png");
if (matches.Count < 1)
{
return true;
}
var id = matches[0].Groups[1].Value;
var texture = GetJacketTexture2D(id);
__result = texture ?? __instance.LoadAsset<Texture2D>($"Jacket/UI_Jacket_{id}.png");
return false;
}
}
[HarmonyPatch]
public static class FrameLoader
{
public static IEnumerable<MethodBase> TargetMethods()
{
var AM = typeof(AssetManager);
return [AM.GetMethod("GetFrameThumbTexture2D", [typeof(string)]), AM.GetMethod("GetFrameTexture2D", [typeof(string)])];
}
public static bool Prefix(string filename, ref Texture2D __result, AssetManager __instance)
{
var matches = Regex.Matches(filename, @"UI_Frame_(\d+)(_s)?\.png");
if (matches.Count < 1)
{
return true;
}
var id = matches[0].Groups[1].Value;
var texture = GetFrameTexture2D(id);
__result = texture ?? __instance.LoadAsset<Texture2D>($"Frame/UI_Frame_{id}.png");
return false;
}
}
[HarmonyPatch]
public static class PlateLoader
{
public static IEnumerable<MethodBase> TargetMethods()
{
var AM = typeof(AssetManager);
return [AM.GetMethod("GetPlateTexture2D", [typeof(string)])];
}
public static bool Prefix(string filename, ref Texture2D __result, AssetManager __instance)
{
var matches = Regex.Matches(filename, @"UI_Plate_(\d+)\.png");
if (matches.Count < 1)
{
return true;
}
var id = matches[0].Groups[1].Value;
var texture = GetPlateTexture2D(id);
__result = texture ?? __instance.LoadAsset<Texture2D>($"NamePlate/UI_Plate_{id}.png");
return false;
}
}
[HarmonyPatch]
public static class FrameMaskLoader
{
public static IEnumerable<MethodBase> TargetMethods()
{
var AM = typeof(AssetManager);
return [AM.GetMethod("GetFrameMaskTexture2D", [typeof(string)])];
}
public static bool Prefix(string filename, ref Texture2D __result, AssetManager __instance)
{
var matches = Regex.Matches(filename, @"UI_FrameMask_(\d+)\.png");
if (matches.Count < 1)
{
return true;
}
var id = matches[0].Groups[1].Value;
var texture = GetFrameMaskTexture2D(id);
__result = texture ?? __instance.LoadAsset<Texture2D>($"FrameMask/UI_FrameMask_{id}.png");
return false;
}
}
[HarmonyPatch]
public static class FramePatternLoader
{
public static IEnumerable<MethodBase> TargetMethods()
{
var AM = typeof(AssetManager);
return [AM.GetMethod("GetFramePatternTexture2D", [typeof(string)])];
}
public static bool Prefix(string filename, ref Texture2D __result, AssetManager __instance)
{
var matches = Regex.Matches(filename, @"UI_FramePattern_(\d+)\.png");
if (matches.Count < 1)
{
return true;
}
var id = matches[0].Groups[1].Value;
var texture = GetFramePatternTexture2D(id);
__result = texture ?? __instance.LoadAsset<Texture2D>($"FramePattern/UI_FramePattern_{id}.png");
return false;
}
}
// Private | Instance
[HarmonyPrefix]
[HarmonyPatch(typeof(AssetManager), "GetIconTexture2D", typeof(string))]
public static bool IconLoader(string filename, ref Texture2D __result, AssetManager __instance)
{
var matches = Regex.Matches(filename, @"UI_Icon_(\d+)\.png");
if (matches.Count < 1)
{
return true;
}
var id = matches[0].Groups[1].Value;
var texture = GetIconTexture2D(id);
__result = texture ?? __instance.LoadAsset<Texture2D>($"Icon/UI_Icon_{id}.png");
return false;
}
[HarmonyPatch]
public static class CharaLoader
{
public static IEnumerable<MethodBase> TargetMethods()
{
var AM = typeof(AssetManager);
return [AM.GetMethod("GetCharacterTexture2D", [typeof(string)])];
}
public static bool Prefix(string filename, ref Texture2D __result, AssetManager __instance)
{
var matches = Regex.Matches(filename, @"UI_Chara_(\d+)\.png");
if (matches.Count < 1)
{
return true;
}
var id = matches[0].Groups[1].Value;
var texture = GetCharaTexture2D(id);
__result = texture ?? __instance.LoadAsset<Texture2D>($"Chara/UI_Chara_{id}.png");
return false;
}
}
[HarmonyPatch]
public static class PartnerLoader
{
public static IEnumerable<MethodBase> TargetMethods()
{
var AM = typeof(AssetManager);
return [AM.GetMethod("GetPartnerTexture2D", [typeof(string)])];
}
public static bool Prefix(string filename, ref Texture2D __result, AssetManager __instance)
{
var matches = Regex.Matches(filename, @"UI_Partner_(\d+)\.png");
if (matches.Count < 1)
{
return true;
}
var id = matches[0].Groups[1].Value;
var texture = GetPartnerTexture2D(id);
__result = texture ?? __instance.LoadAsset<Texture2D>($"Partner/UI_Partner_{id}.png");
return false;
}
}
/*
[HarmonyPatch]
public static class FrameLoader
{
public static IEnumerable<MethodBase> TargetMethods()
{
var AM = typeof(AssetManager);
return [AM.GetMethod("GetFrameThumbTexture2D", [typeof(string)]), AM.GetMethod("GetFrameTexture2D", [typeof(string)])];
}
public static bool Prefix(string filename, ref Texture2D __result, AssetManager __instance)
{
var matches = Regex.Matches(filename, @"UI_Frame_(\d+)\.png");
if (matches.Count < 1)
{
return true;
}
var id = matches[0].Groups[1].Value;
var texture = GetFrameTexture2D(id);
__result = texture ?? __instance.LoadAsset<Texture2D>($"Frame/UI_Frame_{id}.png");
return false;
}
}
*/
}

View File

@@ -0,0 +1,54 @@
using System.Linq;
using AquaMai.Config.Attributes;
using HarmonyLib;
using MAI2.Util;
using Manager;
using MelonLoader;
using Monitor.Game;
using UnityEngine;
namespace AquaMai.Mods.GameSystem.Assets;
[ConfigSection(
en: """
Use the png jacket above as MV if no .dat found in the movie folder.
Use together with `LoadLocalImages`.
""",
zh: """
如果 movie 文件夹中没有 dat 格式的 MV 的话,就用歌曲的封面做背景,而不是显示迪拉熊的笑脸
请和 `LoadLocalImages` 一起用
""")]
public class UseJacketAsDummyMovie
{
[HarmonyPostfix]
[HarmonyPatch(typeof(GameCtrl), "IsReady")]
public static void LoadLocalBgaAwake(GameObject ____movieMaskObj)
{
var music = Singleton<DataManager>.Instance.GetMusic(GameManager.SelectMusicID[0]);
if (music is null) return;
var moviePath = Singleton<OptionDataManager>.Instance.GetMovieDataPath($"{music.movieName.id:000000}") + ".dat";
if (!moviePath.Contains("dummy")) return;
var jacket = LoadLocalImages.GetJacketTexture2D(music.movieName.id);
if (jacket is null)
{
MelonLogger.Msg("No jacket found for music " + music);
return;
}
var components = ____movieMaskObj.GetComponentsInChildren<Component>(false);
var movies = components.Where(it => it.name == "Movie");
foreach (var movie in movies)
{
// If I create a new RawImage component, the jacket will be not be displayed
// I think it will be difficult to make it work with RawImage
// So I change the material that plays video to default sprite material
// The original player is actually a sprite renderer and plays video with a custom material
var sprite = movie.GetComponent<SpriteRenderer>();
sprite.sprite = Sprite.Create(jacket, new Rect(0, 0, jacket.width, jacket.height), new Vector2(0.5f, 0.5f));
sprite.material = new Material(Shader.Find("Sprites/Default"));
}
}
}

View File

@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Collections;
using System.Linq;
using HarmonyLib;
using Manager;
using MelonLoader;
using UnityEngine;
using AquaMai.Config.Attributes;
namespace AquaMai.Mods.GameSystem;
[ConfigSection(
en: """
Use custom CameraId rather than the default ones.
If enabled, you can customize the game to use the specified camera.
""",
zh: """
使用自定义的摄像头 ID 而不是默认的
启用后可以指定游戏使用的摄像头
""")]
public class CustomCameraId
{
[ConfigEntry(
en: "Print the camera list to the log when starting, can be used as a basis for modification.",
zh: "启动时打印摄像头列表到日志中,可以作为修改的依据")]
public static bool printCameraList;
[ConfigEntry(
en: "DX Pass 1P.",
zh: "DX Pass 1P")]
public static int leftQrCamera;
[ConfigEntry(
en: "DX Pass 2P.",
zh: "DX Pass 2P")]
public static int rightQrCamera;
[ConfigEntry(
en: "Player Camera.",
zh: "玩家摄像头")]
public static int photoCamera;
[ConfigEntry(
en: "WeChat QRCode Camera.",
zh: "二维码扫描摄像头")]
public static int chimeCamera;
private static readonly Dictionary<string, string> cameraTypeMap = new()
{
["LeftQrCamera"] = "QRLeft",
["RightQrCamera"] = "QRRight",
["PhotoCamera"] = "Photo",
["ChimeCamera"] = "Chime",
};
[HarmonyPrefix]
[HarmonyPatch(typeof(CameraManager), "CameraInitialize")]
public static bool CameraInitialize(CameraManager __instance, ref IEnumerator __result)
{
__result = CameraInitialize(__instance);
return false;
}
private static IEnumerator CameraInitialize(CameraManager __instance)
{
var textureCache = new WebCamTexture[WebCamTexture.devices.Length];
SortedDictionary<CameraManager.CameraTypeEnum, WebCamTexture> webCamTextures = [];
foreach (var (configEntry, cameraTypeName) in cameraTypeMap)
{
int deviceId = Traverse.Create(typeof(CustomCameraId)).Field(configEntry).GetValue<int>();
if (deviceId < 0 || deviceId >= WebCamTexture.devices.Length)
{
MelonLogger.Warning($"[CustomCameraId] Ignoring custom camera {configEntry}: camera ID {deviceId} out of range");
continue;
}
if (!Enum.TryParse<CameraManager.CameraTypeEnum>(cameraTypeName, out var cameraType))
{
MelonLogger.Warning($"[CustomCameraId] Ignoring custom camera {configEntry}: camera type {cameraTypeName} not present");
continue;
}
if (textureCache[deviceId] != null)
{
webCamTextures[cameraType] = textureCache[deviceId];
}
else
{
var webCamTexture = new WebCamTexture(WebCamTexture.devices[deviceId].name);
webCamTextures[cameraType] = webCamTexture;
textureCache[deviceId] = webCamTexture;
}
}
int textureCount = webCamTextures.Count;
__instance.isAvailableCamera = new bool[textureCount];
__instance.cameraProcMode = new CameraManager.CameraProcEnum[textureCount];
int textureIndex = 0;
foreach (var (cameraType, webCamTexture) in webCamTextures)
{
__instance.isAvailableCamera[textureIndex] = true;
__instance.cameraProcMode[textureIndex] = CameraManager.CameraProcEnum.Good;
CameraManager.DeviceId[(int)cameraType] = textureIndex;
textureIndex++;
}
Traverse.Create(__instance).Field("_webcamtex").SetValue(webCamTextures.Values.ToArray());
CameraManager.IsReady = true;
yield break;
}
public static void OnBeforePatch()
{
if (!printCameraList)
{
return;
}
WebCamDevice[] devices = WebCamTexture.devices;
string cameraList = "Connected Web Cameras:\n";
for (int i = 0; i < devices.Length; i++)
{
WebCamDevice webCamDevice = devices[i];
WebCamTexture webCamTexture = new WebCamTexture(webCamDevice.name);
webCamTexture.Play();
cameraList += "==================================================\n";
cameraList += "Name: " + webCamDevice.name + "\n";
cameraList += $"ID: {i}\n";
cameraList += $"Resolution: {webCamTexture.width} * {webCamTexture.height}\n";
cameraList += $"FPS: {webCamTexture.requestedFPS}\n";
webCamTexture.Stop();
}
cameraList += "==================================================";
foreach (var line in cameraList.Split('\n'))
{
MelonLogger.Msg($"[CustomCameraId] {line}");
}
}
}

View File

@@ -0,0 +1,65 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager;
using Monitor;
using Process;
using Process.Entry.State;
using Process.ModeSelect;
namespace AquaMai.Mods.GameSystem;
[ConfigSection(
en: """
Disable timers (hidden and set to 65535 seconds).
Not recommand to enable when SinglePlayer is off.
""",
zh: """
去除游戏中的倒计时(隐藏并设为 65535 秒)
没有开启单人模式时,不建议启用
""")]
public class DisableTimeout
{
[HarmonyPrefix]
[HarmonyPatch(typeof(TimerController), "PrepareTimer")]
public static void PrePrepareTimer(ref int second)
{
second = 65535;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(CommonTimer), "SetVisible")]
public static void CommonTimerSetVisible(ref bool isVisible)
{
isVisible = false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(EntryProcess), "DecrementTimerSecond")]
public static bool EntryProcessDecrementTimerSecond(ContextEntry ____context)
{
SoundManager.PlaySE(Mai2.Mai2Cue.Cue.SE_SYS_SKIP, 0);
____context.SetState(StateType.DoneEntry);
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(ModeSelectProcess), "UpdateInput")]
public static bool ModeSelectProcessUpdateInput(ModeSelectProcess __instance)
{
if (!InputManager.GetButtonDown(0, InputManager.ButtonSetting.Button05)) return true;
__instance.TimeSkipButtonAnim(InputManager.ButtonSetting.Button05);
SoundManager.PlaySE(Mai2.Mai2Cue.Cue.SE_SYS_SKIP, 0);
Traverse.Create(__instance).Method("TimeUp").GetValue();
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(PhotoEditProcess), "MainMenuUpdate")]
public static void PhotoEditProcess(PhotoEditMonitor[] ____monitors, PhotoEditProcess __instance)
{
if (!InputManager.GetButtonDown(0, InputManager.ButtonSetting.Button04)) return;
SoundManager.PlaySE(Mai2.Mai2Cue.Cue.SE_SYS_SKIP, 0);
____monitors[0].SetButtonPressed(InputManager.ButtonSetting.Button04);
Traverse.Create(__instance).Method("OnTimeUp").GetValue();
}
}

View File

@@ -0,0 +1,80 @@
using System.Reflection;
using AquaMai.Config.Attributes;
using AquaMai.Config.Types;
using HarmonyLib;
namespace AquaMai.Mods.GameSystem;
[ConfigSection(
en: "These settings will work regardless of whether you have enabled segatools' io4 emulation.",
zh: "这里的设置无论你是否启用了 segatools 的 io4 模拟都会工作")]
public class KeyMap
{
[ConfigEntry]
public static readonly KeyCodeID Test = (KeyCodeID)115;
[ConfigEntry]
private static readonly KeyCodeID Service = (KeyCodeID)5;
[ConfigEntry]
private static readonly KeyCodeID Button1_1P = (KeyCodeID)67;
[ConfigEntry]
private static readonly KeyCodeID Button2_1P = (KeyCodeID)49;
[ConfigEntry]
private static readonly KeyCodeID Button3_1P = (KeyCodeID)48;
[ConfigEntry]
private static readonly KeyCodeID Button4_1P = (KeyCodeID)47;
[ConfigEntry]
private static readonly KeyCodeID Button5_1P = (KeyCodeID)68;
[ConfigEntry]
private static readonly KeyCodeID Button6_1P = (KeyCodeID)70;
[ConfigEntry]
private static readonly KeyCodeID Button7_1P = (KeyCodeID)45;
[ConfigEntry]
private static readonly KeyCodeID Button8_1P = (KeyCodeID)61;
[ConfigEntry]
private static readonly KeyCodeID Select_1P = (KeyCodeID)25;
[ConfigEntry]
private static readonly KeyCodeID Button1_2P = (KeyCodeID)80;
[ConfigEntry]
private static readonly KeyCodeID Button2_2P = (KeyCodeID)81;
[ConfigEntry]
private static readonly KeyCodeID Button3_2P = (KeyCodeID)78;
[ConfigEntry]
private static readonly KeyCodeID Button4_2P = (KeyCodeID)75;
[ConfigEntry]
private static readonly KeyCodeID Button5_2P = (KeyCodeID)74;
[ConfigEntry]
private static readonly KeyCodeID Button6_2P = (KeyCodeID)73;
[ConfigEntry]
private static readonly KeyCodeID Button7_2P = (KeyCodeID)76;
[ConfigEntry]
private static readonly KeyCodeID Button8_2P = (KeyCodeID)79;
[ConfigEntry]
private static readonly KeyCodeID Select_2P = (KeyCodeID)84;
[HarmonyPatch(typeof(DB.JvsButtonTableRecord), MethodType.Constructor, typeof(int), typeof(string), typeof(string), typeof(int), typeof(string), typeof(int), typeof(int), typeof(int))]
[HarmonyPostfix]
public static void JvsButtonTableRecordConstructor(DB.JvsButtonTableRecord __instance, string Name)
{
var prop = (DB.KeyCodeID)typeof(KeyMap).GetField(Name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static).GetValue(null);
__instance.SubstituteKey = prop;
}
}

View File

@@ -0,0 +1,34 @@
using AquaMai.Core.Attributes;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager;
namespace AquaMai.Mods.GameSystem;
[ConfigSection(
en: "Hold the bottom four buttons (3456) for quick retry (like in Freedom Mode, default non-utage only).",
zh: "按住下方四个按钮3456快速重开本局游戏像在 Freedom Mode 中一样,默认仅对非宴谱有效)")]
[EnableGameVersion(23000)]
public class QuickRetry
{
[ConfigEntry(
en: "Force enable in Utage.",
zh: "在宴谱中强制启用")]
private static readonly bool enableInUtage = false;
[HarmonyPrefix]
[HarmonyPatch(typeof(Monitor.QuickRetry), "IsQuickRetryEnable")]
public static bool OnQuickRetryIsQuickRetryEnable(ref bool __result)
{
if (enableInUtage)
{
__result = true;
}
else
{
var isUtageProperty = Traverse.Create(typeof(GameManager)).Property("IsUtage");
__result = !isUtageProperty.PropertyExists() || !isUtageProperty.GetValue<bool>();
}
return false;
}
}

View File

@@ -0,0 +1,5 @@
# GameSystem
Patches changing the way the game running / behaving which are not possible in the stock game. See also the [GameSettings README](../GameSettings/README.md) for differences.
Game asset related patches should go to the Assets subcategory (or the Fancy category if they're too fancy).

View File

@@ -0,0 +1,63 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Net.Packet;
namespace AquaMai.Mods.GameSystem;
[ConfigSection(
en: """
If you are using an unmodified client, requests to the server will be encrypted by default, but requests to the private server should not be encrypted.
With this option enabled, the connection will not be encrypted, and the suffix added by different versions of the client to the API names are also removed.
Please keep this option enabled normally.
""",
zh: """
如果你在用未经修改的客户端,会默认加密到服务器的连接,而连接私服的时候不应该加密
开了这个选项之后就不会加密连接了,同时也会移除不同版本的客户端可能会对 API 接口加的后缀
正常情况下,请保持这个选项开启
""",
defaultOn: true)]
public class RemoveEncryption
{
[HarmonyPrefix]
[HarmonyPatch(typeof(Packet), "Obfuscator", typeof(string))]
public static bool PreObfuscator(string srcStr, ref string __result)
{
__result = srcStr.Replace("MaimaiExp", "").Replace("MaimaiChn", "");
return false;
}
[HarmonyPatch]
public class EncryptDecrypt
{
public static IEnumerable<MethodBase> TargetMethods()
{
var methods = AccessTools.TypeByName("Net.CipherAES").GetMethods();
return
[
methods.FirstOrDefault(it => it.Name == "Encrypt" && it.IsPublic),
methods.FirstOrDefault(it => it.Name == "Decrypt" && it.IsPublic)
];
}
public static bool Prefix(object[] __args, ref object __result)
{
if (__args.Length == 1)
{
// public static byte[] Encrypt(byte[] data)
// public static byte[] Decrypt(byte[] encryptData)
__result = __args[0];
}
else if (__args.Length == 2)
{
// public static bool Encrypt(byte[] data, out byte[] encryptData)
// public static bool Decrypt(byte[] encryptData, out byte[] plainData)
__args[1] = __args[0];
__result = true;
}
return false;
}
}
}

View File

@@ -0,0 +1,114 @@
using System.Collections.Generic;
using System.Reflection;
using AquaMai.Core;
using AquaMai.Core.Attributes;
using AquaMai.Config.Attributes;
using AquaMai.Mods.Fancy.GamePlay;
using HarmonyLib;
using MAI2.Util;
using Manager;
using MelonLoader;
using Monitor;
using Monitor.Common;
using Monitor.Entry;
using Monitor.Entry.Parts.Screens;
using UnityEngine;
using Fx;
using Type = System.Type;
namespace AquaMai.Mods.GameSystem;
// Hides the 2p (right hand side) UI.
// Note: this is not my original work. I simply interpreted the code and rewrote it as a mod.
[ConfigSection(
en: "Single player: Show 1P only, at the center of the screen.",
zh: "单人模式,不显示 2P")]
public class SinglePlayer
{
[HarmonyPatch]
public class WhateverInitialize
{
public static IEnumerable<MethodBase> TargetMethods()
{
var lateInitialize = AccessTools.Method(typeof(Main.GameMain), "LateInitialize", [typeof(MonoBehaviour), typeof(Transform), typeof(Transform)]);
if (lateInitialize is not null) return [lateInitialize];
return [AccessTools.Method(typeof(Main.GameMain), "Initialize", [typeof(MonoBehaviour), typeof(Transform), typeof(Transform)])];
}
public static void Prefix(MonoBehaviour gameMainObject, ref Transform left, ref Transform right)
{
left.transform.position = Vector3.zero;
right.localScale = Vector3.zero;
GameObject.Find("Mask").transform.position = new Vector3(540f, 0f, 0f);
}
}
[HarmonyPrefix]
[HarmonyPatch(typeof(MeshButton), "IsPointInPolygon", new Type[] { typeof(Vector2[]), typeof(Vector2) })]
public static bool IsPointInPolygon(Vector2[] polygon, ref Vector2 point, MeshButton __instance, ref bool __result)
{
__result = RectTransformUtility.RectangleContainsScreenPoint(__instance.GetComponent<RectTransform>(), point, Camera.main);
return false;
}
[EnableGameVersion(21500, noWarn: true)]
public class SkipTimer
{
[HarmonyPostfix]
[HarmonyPatch(typeof(EntryMonitor), "DecideEntry")]
public static void PostDecideEntry(EntryMonitor __instance)
{
# if DEBUG
MelonLogger.Msg("Confirm Entry");
# endif
TimeManager.MarkGameStartTime();
Singleton<EventManager>.Instance.UpdateEvent();
Singleton<ScoreRankingManager>.Instance.UpdateData();
__instance.Process.CreateDownloadProcess();
__instance.ProcessManager.SendMessage(new Message(ProcessType.CommonProcess, 30001));
__instance.ProcessManager.SendMessage(new Message(ProcessType.CommonProcess, 40000, 0, OperationInformationController.InformationType.Hide));
__instance.Process.SetNextProcess();
}
// To prevent the "長押受付終了" overlay from appearing
[HarmonyPrefix]
[HarmonyPatch(typeof(WaitPartner), "Open")]
public static bool WaitPartnerPreOpen()
{
return false;
}
}
[ConfigEntry(
en: "Fix hanabi effect under single-player mode (disabled automatically if HideHanabi is enabled).",
zh: "修复单人模式下的烟花效果(如果启用了 HideHanabi则会自动禁用")]
public static bool fixHanabi = true;
private static bool fixHanabiDisableImplied = false;
private static bool FixHanabiEnabled => fixHanabi && !fixHanabiDisableImplied;
[EnableIf(nameof(FixHanabiEnabled))]
[HarmonyPatch(typeof(TapCEffect), "SetUpParticle")]
[HarmonyPostfix]
public static void PostSetUpParticle(TapCEffect __instance, FX_Mai2_Note_Color ____particleControler)
{
var entities = ____particleControler.GetComponentsInChildren<ParticleSystemRenderer>(true);
foreach (var entity in entities)
{
entity.maxParticleSize = 1f;
}
}
public static void OnBeforePatch()
{
if (ConfigLoader.Config.GetSectionState(typeof(HideHanabi)).Enabled)
{
fixHanabiDisableImplied = true;
}
}
public static void OnAfterPatch()
{
Core.Helpers.GuiSizes.SinglePlayer = true;
}
}

View File

@@ -0,0 +1,69 @@
using System.Diagnostics;
using System.Linq;
using AquaMai.Config.Attributes;
using AquaMai.Config.Types;
using AquaMai.Core;
using AquaMai.Core.Attributes;
using AquaMai.Core.Helpers;
using AquaMai.Mods.UX;
using AquaMai.Mods.UX.PracticeMode;
using HarmonyLib;
using Manager;
namespace AquaMai.Mods.GameSystem;
[ConfigSection(
en: """
When enabled, test button must be long pressed to enter game test mode.
When test button is bound to other features, this option is enabled automatically.
""",
zh: """
启用后,测试键必须长按才能进入游戏测试模式
当测试键被绑定到其它功能时,此选项自动开启
""")]
[EnableImplicitlyIf(nameof(ShouldEnableImplicitly))]
public class TestProof
{
public static bool ShouldEnableImplicitly
{
get
{
(System.Type section, KeyCodeOrName key)[] featureKeys =
[
(typeof(OneKeyEntryEnd), OneKeyEntryEnd.key),
(typeof(OneKeyRetrySkip), OneKeyRetrySkip.retryKey),
(typeof(OneKeyRetrySkip), OneKeyRetrySkip.skipKey),
(typeof(HideSelfMadeCharts), HideSelfMadeCharts.key),
(typeof(PracticeMode), PracticeMode.key),
];
var keyMapEnabled = ConfigLoader.Config.GetSectionState(typeof(KeyMap)).Enabled;
return featureKeys.Any(it =>
// The feature is enabled and...
ConfigLoader.Config.GetSectionState(it.section).Enabled &&
(
// and the key is test, or...
it.key == KeyCodeOrName.Test ||
// or the key have been mapped to the same key as test.
(keyMapEnabled && it.key.ToString() == KeyMap.Test.ToString())));
}
}
[HarmonyPrefix]
[HarmonyPatch(typeof(InputManager), "GetSystemInputDown")]
public static bool GetSystemInputDown(ref bool __result, InputManager.SystemButtonSetting button, bool[] ___SystemButtonDown)
{
__result = ___SystemButtonDown[(int)button];
if (button != InputManager.SystemButtonSetting.ButtonTest)
return false;
var stackTrace = new StackTrace(); // get call stack
var stackFrames = stackTrace.GetFrames(); // get method calls (frames)
if (stackFrames.Any(it => it.GetMethod().Name == "DMD<Main.GameMainObject::Update>"))
{
__result = KeyListener.GetKeyDownOrLongPress(KeyCodeOrName.Test, true);
}
return false;
}
}

View File

@@ -0,0 +1,30 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using IO;
namespace AquaMai.Mods.GameSystem;
[ConfigSection(
en: """
Adjust the baud rate of the touch screen serial port, default value is 9600.
Requires hardware support. If you are unsure, don't use it.
""",
zh: """
调整触摸屏串口波特率,默认值 9600
需要硬件配合,如果你不清楚你是否可以使用,请不要使用
""")]
public class TouchPanelBaudRate
{
[ConfigEntry(
en: "Baud rate.",
zh: "波特率")]
private static readonly int baudRate = 9600;
[HarmonyPatch(typeof(NewTouchPanel), "Open")]
[HarmonyPrefix]
private static void OpenPrefix(ref int ___BaudRate)
{
if (baudRate <= 0) return;
___BaudRate = baudRate;
}
}

View File

@@ -0,0 +1,59 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Process;
using static Manager.InputManager;
namespace AquaMai.Mods.GameSystem;
[ConfigSection(
en: "Map touch actions to buttons.",
zh: "映射触摸操作至实体按键")]
public class TouchToButtonInput
{
private static bool _isPlaying = false;
[HarmonyPostfix]
[HarmonyPatch(typeof(GameProcess), "OnStart")]
public static void OnGameProcessStart(GameProcess __instance)
{
_isPlaying = true;
}
[HarmonyPostfix]
[HarmonyPatch(typeof(GameProcess), "OnRelease")]
public static void OnGameProcessRelease(GameProcess __instance)
{
_isPlaying = false;
}
[HarmonyPostfix]
[HarmonyPatch(typeof(Manager.InputManager), "GetButtonDown")]
public static void GetButtonDown(ref bool __result, int monitorId, ButtonSetting button)
{
if (_isPlaying || __result) return;
if (button.ToString().StartsWith("Button"))
{
__result = GetTouchPanelAreaDown(monitorId, (TouchPanelArea)button);
}
else if (button.ToString().Equals("Select"))
{
__result = GetTouchPanelAreaLongPush(monitorId, TouchPanelArea.C1, 500L) || GetTouchPanelAreaLongPush(monitorId, TouchPanelArea.C2, 500L);
}
}
[HarmonyPostfix]
[HarmonyPatch(typeof(Manager.InputManager), "GetButtonPush")]
public static void GetButtonPush(ref bool __result, int monitorId, ButtonSetting button)
{
if (_isPlaying || __result) return;
if (button.ToString().StartsWith("Button")) __result = GetTouchPanelAreaPush(monitorId, (TouchPanelArea)button);
}
[HarmonyPostfix]
[HarmonyPatch(typeof(Manager.InputManager), "GetButtonLongPush")]
public static void GetButtonLongPush(ref bool __result, int monitorId, ButtonSetting button, long msec)
{
if (_isPlaying || __result) return;
if (button.ToString().StartsWith("Button")) __result = GetTouchPanelAreaLongPush(monitorId, (TouchPanelArea)button, msec);
}
}

View File

@@ -0,0 +1,90 @@
using AquaMai.Config.Attributes;
using AquaMai.Core.Attributes;
using MAI2System;
using Manager;
using Manager.MaiStudio;
using HarmonyLib;
namespace AquaMai.Mods.GameSystem;
[ConfigSection(
en: "Unlock normally locked (including normally non-unlockable) game content.",
zh: "解锁原本锁定(包括正常途径无法解锁)的游戏内容")]
public class Unlock
{
[ConfigEntry(
en: "Unlock maps that are not in this version.",
zh: "解锁游戏里所有的区域,包括非当前版本的(并不会帮你跑完)")]
private static readonly bool maps = true;
[EnableIf(nameof(maps))]
[HarmonyPrefix]
[HarmonyPatch(typeof(MapData), "get_OpenEventId")]
public static bool get_OpenEventId(ref StringID __result)
{
// For any map, return the event ID 1 to unlock it
var id = new Manager.MaiStudio.Serialize.StringID
{
id = 1,
str = "無期限常時解放"
};
var sid = new StringID();
sid.Init(id);
__result = sid;
return false;
}
[ConfigEntry(
en: "Unlock normally event-only tickets.",
zh: "解锁游戏里所有可能的跑图券")]
private static readonly bool tickets = true;
[EnableIf(nameof(tickets))]
[HarmonyPrefix]
[HarmonyPatch(typeof(TicketData), "get_ticketEvent")]
public static bool get_ticketEvent(ref StringID __result)
{
// For any ticket, return the event ID 1 to unlock it
var id = new Manager.MaiStudio.Serialize.StringID
{
id = 1,
str = "無期限常時解放"
};
var sid = new StringID();
sid.Init(id);
__result = sid;
return false;
}
[EnableIf(nameof(tickets))]
[HarmonyPrefix]
[HarmonyPatch(typeof(TicketData), "get_maxCount")]
public static bool get_maxCount(ref int __result)
{
// Modify the maxTicketNum to 0
// this is because TicketManager.GetTicketData adds the ticket to the list if either
// the player owns at least one ticket or the maxTicketNum = 0
__result = 0;
return false;
}
[ConfigEntry(
en: "Unlock Utage without the need of DXRating 10000.",
zh: "不需要万分也可以进宴会场")]
private static readonly bool utage = true;
[EnableIf(nameof(utage))]
[EnableGameVersion(24000)]
[HarmonyPrefix]
[HarmonyPatch(typeof(GameManager), "CanUnlockUtageTotalJudgement")]
public static bool CanUnlockUtageTotalJudgement(out ConstParameter.ResultOfUnlockUtageJudgement result1P, out ConstParameter.ResultOfUnlockUtageJudgement result2P)
{
result1P = ConstParameter.ResultOfUnlockUtageJudgement.Unlocked;
result2P = ConstParameter.ResultOfUnlockUtageJudgement.Unlocked;
return false;
}
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using AquaMai.Config.Attributes;
using UnityEngine;
namespace AquaMai.Mods.GameSystem;
[ConfigSection(
en: "Windowed Mode / Window Settings.",
zh: "窗口化/窗口设置")]
public class Window
{
[ConfigEntry(
en: "Window the game.",
zh: "窗口化游戏")]
private static readonly bool windowed = false;
[ConfigEntry(
en: """
Window width (and height) for windowed mode, rendering resolution for fullscreen mode.
If set to 0, windowed mode will remember the user-set size, fullscreen mode will use the current display resolution.
""",
zh: """
宽度(和高度)窗口化时为游戏窗口大小,全屏时为渲染分辨率
如果设为 0窗口化将记住用户设定的大小全屏时将使用当前显示器分辨率
""")]
private static readonly int width = 0;
[ConfigEntry(
en: "Height, as above.",
zh: "高度,同上")]
private static readonly int height = 0;
private const int GWL_STYLE = -16;
private const int WS_WHATEVER = 0x14CF0000;
private static IntPtr hwnd = IntPtr.Zero;
public static void OnBeforePatch()
{
if (windowed)
{
var alreadyWindowed = Screen.fullScreenMode == FullScreenMode.Windowed;
if (width == 0 || height == 0)
{
Screen.fullScreenMode = FullScreenMode.Windowed;
}
else
{
alreadyWindowed = false;
Screen.SetResolution(width, height, FullScreenMode.Windowed);
}
hwnd = GetWindowHandle();
if (alreadyWindowed)
{
SetResizeable();
}
else
{
Task.Run(async () =>
{
await Task.Delay(3000);
// Screen.SetResolution has delay
SetResizeable();
});
}
}
else
{
var width = Window.width == 0 ? Display.main.systemWidth : Window.width;
var height = Window.height == 0 ? Display.main.systemHeight : Window.height;
Screen.SetResolution(width, height, FullScreenMode.FullScreenWindow);
}
}
public static void SetResizeable()
{
if (hwnd == IntPtr.Zero) return;
SetWindowLongPtr(hwnd, GWL_STYLE, WS_WHATEVER);
}
private delegate bool EnumThreadDelegate(IntPtr hwnd, IntPtr lParam);
[DllImport("user32.dll")]
static extern bool EnumThreadWindows(int dwThreadId, EnumThreadDelegate lpfn, IntPtr lParam);
[DllImport("Kernel32.dll")]
static extern int GetCurrentThreadId();
static IntPtr GetWindowHandle()
{
IntPtr returnHwnd = IntPtr.Zero;
var threadId = GetCurrentThreadId();
EnumThreadWindows(threadId,
(hWnd, lParam) =>
{
if (returnHwnd == IntPtr.Zero) returnHwnd = hWnd;
return true;
}, IntPtr.Zero);
return returnHwnd;
}
[DllImport("user32.dll")]
static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, int dwNewLong);
}

View File

@@ -0,0 +1,42 @@
using AquaMai.Config.Attributes;
namespace AquaMai.Mods;
// This class is for settings only. Don't patch anything here.
[ConfigSection(
en: "AquaMai's general settings.",
zh: "AquaMai 的通用设置",
alwaysEnabled: true)]
public class General
{
[ConfigEntry(
en: """
Language for mod UI (en and zh supported).
If empty, the system language will be used.
The config file will also be saved in this language.
""",
zh: """
Mod 界面的语言,支持 en 和 zh
如果为空,将使用系统语言
配置文件也将以此语言保存
""",
specialConfigEntry: SpecialConfigEntry.Locale)]
public static readonly string locale = "";
}
// Please add/remove corresponding entries in SetionNameOrder enum when adding/removing sections.
public enum SetionNameOrder
{
DeprecationWarning,
General,
Fix,
GameSystem_Assets,
GameSystem,
GameSettings,
Tweaks,
Tweaks_TimeSaving,
UX,
Utils,
Fancy
}

View File

@@ -0,0 +1,19 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager;
namespace AquaMai.Mods.Tweaks;
[ConfigSection(
en: "Prevent gray network caused by mistakenly thinking it's an AimeDB server issue.",
zh: "防止因错误认为 AimeDB 服务器问题引起的灰网,建议开启")]
public class IgnoreAimeServerError
{
[HarmonyPatch(typeof(OperationManager), "IsAliveAimeServer", MethodType.Getter)]
[HarmonyPrefix]
public static bool Prefix(ref bool __result)
{
__result = true;
return false;
}
}

View File

@@ -0,0 +1,22 @@
using AquaMai.Config.Attributes;
using UnityEngine;
namespace AquaMai.Mods.Tweaks;
[ConfigSection(
en: """
Force the frame rate limit to 60 FPS and disable vSync.
Do not use if your game has no issues.
""",
zh: """
强制设置帧率上限为 60 帧并关闭垂直同步
如果你的游戏没有问题,请不要使用
""")]
public class LockFrameRate
{
public static void OnBeforePatch()
{
Application.targetFrameRate = 60;
QualitySettings.vSyncCount = 0;
}
}

View File

@@ -0,0 +1,5 @@
# Tweaks
Patches to make the game more stable, more robust and less annoying. The game is playable at all without them, but sometimes they help a lot.
These patches don't change the way the game behaving, otherwise they may go to the GameSystem category.

View File

@@ -0,0 +1,29 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using MAI2.Util;
using Manager;
using Process;
namespace AquaMai.Mods.Tweaks;
[ConfigSection(
en: "Reset touch panel after playing track.",
zh: "在游玩一首曲目后重置触摸面板")]
public class ResetTouchAfterTrack
{
[HarmonyPostfix]
[HarmonyPatch(typeof(ResultProcess), "OnStart")]
public static void ResultProcessOnStart()
{
SingletonStateMachine<AmManager, AmManager.EState>.Instance.StartTouchPanel();
MelonLoader.MelonLogger.Msg("[TouchResetAfterTrack] Touch panel reset");
}
[HarmonyPostfix]
[HarmonyPatch(typeof(GamePlayManager), "SetQuickRetryFrag")]
public static void OnStart()
{
SingletonStateMachine<AmManager, AmManager.EState>.Instance.StartTouchPanel();
MelonLoader.MelonLogger.Msg("[TouchResetAfterTrack] Touch panel reset");
}
}

View File

@@ -0,0 +1,22 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Process.Entry.State;
namespace AquaMai.Mods.Tweaks;
[ConfigSection(
en: "Allow login with higher data version.",
zh: """
原先如果你的账号版本比当前游戏设定的版本高的话,就会不能登录
开了这个选项之后就可以登录了,不过你的账号版本还是会被设定为当前游戏的版本
""")]
public class SkipUserVersionCheck
{
[HarmonyPrefix]
[HarmonyPatch(typeof(ConfirmPlay), "IsValidVersion")]
public static bool IsValidVersion(ref bool __result)
{
__result = true;
return false;
}
}

View File

@@ -0,0 +1,52 @@
using AquaMai.Config.Attributes;
using DB;
using HarmonyLib;
using MAI2.Util;
using Manager;
using Monitor;
using Process;
using Process.Information;
namespace AquaMai.Mods.Tweaks.TimeSaving;
[ConfigSection(
en: "Directly enter the song selection screen after login.",
zh: "登录完成后直接进入选歌界面")]
public class EntryToMusicSelection
{
/*
* Highly experimental, may well break some stuff
* Works by overriding the info screen (where it shows new events and stuff)
* to directly exit to the music selection screen, skipping character and
* event selection, among others
*/
[HarmonyPrefix]
[HarmonyPatch(typeof(InformationProcess), "OnUpdate")]
public static bool OnUpdate(InformationProcess __instance, ProcessDataContainer ___container)
{
GameManager.SetMaxTrack();
// Set headphone volume
for (var i = 0; i < 2; i++)
{
var userData = UserDataManager.Instance.GetUserData(i);
if (userData.IsEntry)
{
OptionHeadphonevolumeID headPhoneVolume = userData.Option.HeadPhoneVolume;
SoundManager.SetHeadPhoneVolume(i, headPhoneVolume.GetValue());
}
}
___container.processManager.AddProcess(new MusicSelectProcess(___container));
___container.processManager.ReleaseProcess(__instance);
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(MapResultMonitor), "Initialize")]
public static void MapResultMonitorPreInitialize(int monIndex)
{
var userData = Singleton<UserDataManager>.Instance.GetUserData(monIndex);
var index = userData.MapList.FindIndex((m) => m.ID == userData.Detail.SelectMapID);
if (index >= 0) return;
userData.MapList.Clear();
}
}

View File

@@ -0,0 +1,19 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Monitor;
namespace AquaMai.Mods.Tweaks.TimeSaving;
[ConfigSection(
en: "Skip the \"Do not tap or slide vigorously\" screen, immediately proceed to the next screen once data is loaded.",
zh: "跳过“不要大力拍打或滑动哦”这个界面,数据一旦加载完就立马进入下一个界面")]
public class IWontTapOrSlideVigorously
{
[HarmonyPrefix]
[HarmonyPatch(typeof(PlInformationMonitor), "IsPlayPlInfoEnd")]
public static bool Patch(ref bool __result)
{
__result = true;
return false;
}
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Process;
using Process.Information;
namespace AquaMai.Mods.Tweaks.TimeSaving;
[ConfigSection(
en: "Skip possible prompts like \"New area discovered\", \"New songs added\", \"There are events\" during game login/registration.",
zh: "跳过登录 / 注册游戏时候可能的 “发现了新的区域哟” “乐曲增加” “有活动哟” 之类的提示")]
public class SkipEventInfo
{
[HarmonyPostfix]
[HarmonyPatch(typeof(InformationProcess), "OnStart")]
public static void InformationProcessPostStart(ref uint ____state)
{
____state = 3;
}
[HarmonyPostfix]
[HarmonyPatch(typeof(RegionalSelectProcess), "OnStart")]
public static void RegionalSelectProcessPreStart(ref Queue<int>[] ____discoverList)
{
____discoverList = new Queue<int>[] { new Queue<int>(), new Queue<int>() };
}
}

View File

@@ -0,0 +1,30 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Monitor;
using Process;
namespace AquaMai.Mods.Tweaks.TimeSaving;
[ConfigSection(
en: "Skip the \"Goodbye\" screen at the end of the game.",
zh: "跳过游戏结束的「再见」界面")]
public class SkipGoodbyeScreen
{
[HarmonyPrefix]
[HarmonyPatch(typeof(GameOverMonitor), "IsPlayEnd")]
public static bool GameOverMonitorPlayEnd(ref bool __result)
{
__result = true;
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(GameOverProcess), "OnUpdate")]
public static void GameOverProcessOnUpdate(ref GameOverProcess.GameOverSequence ____state)
{
if (____state == GameOverProcess.GameOverSequence.SkyChange)
{
____state = GameOverProcess.GameOverSequence.Disp;
}
}
}

View File

@@ -0,0 +1,32 @@
using System.Diagnostics;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Process;
namespace AquaMai.Mods.Tweaks.TimeSaving;
[ConfigSection(
en: "Skip useless 2s delays to speed up the game boot process.",
zh: """
在自检界面,每个屏幕结束的时候都会等两秒才进入下一个屏幕,很浪费时间
开了这个选项之后就不会等了
""")]
public class SkipStartupDelays
{
[HarmonyPrefix]
[HarmonyPatch(typeof(PowerOnProcess), "OnStart")]
public static void PrePowerOnStart(ref float ____waitTime)
{
____waitTime = 0f;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(StartupProcess), "OnUpdate")]
public static void PreStartupUpdate(byte ____state, ref Stopwatch ___timer)
{
if (____state == 8)
{
Traverse.Create(___timer).Field("elapsed").SetValue(2 * 10000000L);
}
}
}

View File

@@ -0,0 +1,31 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Monitor;
namespace AquaMai.Mods.Tweaks.TimeSaving;
[ConfigSection(
en: "Skip SDEZ's warning screen and logo shown after the POST sequence.",
zh: "跳过 SDEZ 启动时的 WARNING 界面")]
public class SkipStartupWarning
{
/*
* Patch PlayLogo to disable the warning screen
*/
[HarmonyPrefix]
[HarmonyPatch(typeof (WarningMonitor), "PlayLogo")]
public static bool PlayLogo()
{
// Return false to block the original method
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof (WarningMonitor), "IsLogoAnimationEnd")]
public static bool IsLogoAnimationEnd(ref bool __result)
{
// Always return true to indicate the animation has ended
__result = true;
return false;
}
}

View File

@@ -0,0 +1,19 @@
using AquaMai.Config.Attributes;
using HarmonyLib;
using Monitor;
namespace AquaMai.Mods.Tweaks.TimeSaving;
[ConfigSection(
en: "Skip TrackStart screen.",
zh: "跳过乐曲开始界面")]
public class SkipTrackStart
{
[HarmonyPrefix]
[HarmonyPatch(typeof (TrackStartMonitor), "IsEnd")]
public static bool IsEnd(ref bool __result)
{
__result = true;
return false;
}
}

View File

@@ -0,0 +1,26 @@
using AquaMai.Config.Attributes;
using AquaMai.Core.Attributes;
using AquaMai.Core.Helpers;
using AquaMai.Core.Resources;
using HarmonyLib;
using Process;
namespace AquaMai.Mods.UX;
[ConfigSection(exampleHidden: true, defaultOn: true)]
[EnableIf(nameof(isCiBuild))]
public class CiBuildAlert
{
# if CI
private static readonly bool isCiBuild = true;
# else
private static readonly bool isCiBuild = false;
# endif
[HarmonyPatch(typeof(AdvertiseProcess), "OnStart")]
[HarmonyPostfix]
public static void OnStart(AdvertiseProcess __instance)
{
MessageHelper.ShowMessage(Locale.CiBuildAlertContent, title: Locale.CiBuildAlertTitle);
}
}

View File

@@ -0,0 +1,136 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AquaMai.Config.Attributes;
using AquaMai.Config.Types;
using AquaMai.Core.Helpers;
using HarmonyLib;
using MAI2.Util;
using Manager;
using MelonLoader;
using Process;
using Util;
namespace AquaMai.Mods.UX;
[ConfigSection(
en: "One key to hide all self-made charts in the music select process. Or hide for some users.",
zh: "在选曲界面一键隐藏所有自制谱,或对一部分用户进行隐藏")]
public class HideSelfMadeCharts
{
[ConfigEntry(
en: "Key to toggle self-made charts.",
zh: "切换自制谱显示的按键")]
public static readonly KeyCodeOrName key = KeyCodeOrName.Test;
[ConfigEntry]
public static readonly bool longPress = false;
[ConfigEntry(
en: "One user ID per line in the file. Hide self-made charts when these users login.",
zh: "该文件中每行一个用户 ID当这些用户登录时隐藏自制谱")]
private static readonly string selfMadeChartsDenyUsersFile = "LocalAssets/SelfMadeChartsDenyUsers.txt";
[ConfigEntry(
en: "One user ID per line in the file. Only show self-made charts when these users login.",
zh: "该文件中每行一个用户 ID只有这些用户登录时才显示自制谱")]
private static readonly string selfMadeChartsWhiteListUsersFile = "LocalAssets/SelfMadeChartsWhiteListUsers.txt";
private static Safe.ReadonlySortedDictionary<int, Manager.MaiStudio.MusicData> _musics;
private static Safe.ReadonlySortedDictionary<int, Manager.MaiStudio.MusicData> _musicsNoneSelfMade;
private static bool isShowSelfMadeCharts = true;
private static bool isForceDisable;
[HarmonyPostfix]
[HarmonyPatch(typeof(DataManager), "GetMusics")]
public static void GetMusics(ref Safe.ReadonlySortedDictionary<int, Manager.MaiStudio.MusicData> __result, List<string> ____targetDirs)
{
if (_musics is null)
{
// init musics for the first time
if (__result.Count == 0) return;
_musics = __result;
var nonSelfMadeList = new SortedDictionary<int, Manager.MaiStudio.MusicData>();
var officialDirs = ____targetDirs.Where(it => File.Exists(Path.Combine(it, "DataConfig.xml")) || File.Exists(Path.Combine(it, "OfficialChartsMark.txt")));
foreach (var music in __result)
{
if (officialDirs.Any(it => MusicDirHelper.LookupPath(music.Value).StartsWith(it)))
{
nonSelfMadeList.Add(music.Key, music.Value);
}
}
_musicsNoneSelfMade = new Safe.ReadonlySortedDictionary<int, Manager.MaiStudio.MusicData>(nonSelfMadeList);
MelonLogger.Msg($"[HideSelfMadeCharts] All music count: {__result.Count}, Official music count: {_musicsNoneSelfMade.Count}");
}
var stackTrace = new StackTrace(); // get call stack
var stackFrames = stackTrace.GetFrames(); // get method calls (frames)
if (stackFrames.All(it => it.GetMethod().DeclaringType.Name != "MusicSelectProcess")) return;
if (isShowSelfMadeCharts && !isForceDisable) return;
__result = _musicsNoneSelfMade;
}
[HarmonyPostfix]
[HarmonyPatch(typeof(MusicSelectProcess), "OnUpdate")]
public static void MusicSelectProcessOnUpdate(ref MusicSelectProcess __instance)
{
if (isForceDisable) return;
if (!KeyListener.GetKeyDownOrLongPress(key, longPress)) return;
isShowSelfMadeCharts = !isShowSelfMadeCharts;
MelonLogger.Msg($"[HideSelfMadeCharts] isShowSelfMadeCharts: {isShowSelfMadeCharts}");
SharedInstances.ProcessDataContainer.processManager.AddProcess(new FadeProcess(SharedInstances.ProcessDataContainer, __instance, new MusicSelectProcess(SharedInstances.ProcessDataContainer)));
Task.Run(async () =>
{
await Task.Delay(1000);
MessageHelper.ShowMessage($"{(isShowSelfMadeCharts ? "Show" : "Hide")} Self-Made Charts");
});
}
[HarmonyPrefix]
[HarmonyPatch(typeof(MusicSelectProcess), "OnStart")]
public static void MusicSelectProcessOnStart(ref MusicSelectProcess __instance)
{
var denyPath = FileSystem.ResolvePath(selfMadeChartsDenyUsersFile);
if (File.Exists(denyPath))
{
var userIds = File.ReadAllLines(denyPath);
for (var i = 0; i < 2; i++)
{
var user = Singleton<UserDataManager>.Instance.GetUserData(i);
if (!user.IsEntry) continue;
if (!userIds.Contains(user.Detail.UserID.ToString())) continue;
isForceDisable = true;
return;
}
}
var whiteListPath = FileSystem.ResolvePath(selfMadeChartsWhiteListUsersFile);
if (File.Exists(whiteListPath))
{
var userIds = File.ReadAllLines(whiteListPath);
for (var i = 0; i < 2; i++)
{
var user = Singleton<UserDataManager>.Instance.GetUserData(i);
if (!user.IsEntry) continue;
if (userIds.Contains(user.Detail.UserID.ToString())) continue;
isForceDisable = true;
return;
}
}
isForceDisable = false;
}
[HarmonyPostfix]
[HarmonyPatch(typeof(EntryProcess), "OnStart")]
public static void EntryProcessOnStart(ref EntryProcess __instance)
{
// reset status on login
isShowSelfMadeCharts = true;
}
}

View File

@@ -0,0 +1,240 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AquaMai.Config.Attributes;
using AquaMai.Core.Helpers;
using AquaMai.Core.Resources;
using DB;
using HarmonyLib;
using MAI2.Util;
using MAI2System;
using Manager;
using Manager.MaiStudio;
using Manager.UserDatas;
using MelonLoader;
using Monitor.Entry.Parts.Screens;
using Net.Packet;
using Net.Packet.Helper;
using Process;
using Process.UserDataNet.State.UserDataULState;
using UnityEngine;
namespace AquaMai.Mods.UX;
[ConfigSection(
en: "Save immediate after playing a song.",
zh: "打完一首歌的时候立即向服务器保存成绩")]
public class ImmediateSave
{
[HarmonyPrefix]
[HarmonyPatch(typeof(StateULUserAime), "RequestUploadUserPlayLogData")]
public static bool PreRequestUploadUserPlayLogData(StateULUserAime __instance)
{
Traverse.Create(__instance).Method("RequestUploadUserPortraitData").GetValue();
return false;
}
private static SavingUi ui;
[HarmonyPostfix]
[HarmonyPatch(typeof(ResultProcess), "OnStart")]
public static void ResultProcessOnStart()
{
var doneCount = 0;
void CheckSaveDone()
{
doneCount++;
if (doneCount != 4) return;
if (ui == null) return;
UnityEngine.Object.Destroy(ui);
ui = null;
}
for (int i = 0; i < 2; i++)
{
var userData = Singleton<UserDataManager>.Instance.GetUserData(i);
if (!userData.IsEntry || userData.IsGuest())
{
doneCount += 2;
continue;
}
if (ui == null)
{
ui = SharedInstances.GameMainObject.gameObject.AddComponent<SavingUi>();
}
SaveDataFix(userData);
PacketHelper.StartPacket(Shim.CreatePacketUploadUserPlaylog(i, userData, (int)GameManager.MusicTrackNumber - 1,
delegate
{
MelonLogger.Msg("[ImmediateSave] Playlog saved");
CheckSaveDone();
},
delegate(PacketStatus err)
{
SoundManager.PlaySE(Mai2.Mai2Cue.Cue.SE_ENTRY_AIME_ERROR, i);
MelonLogger.Error("[ImmediateSave] Playlog save error");
MelonLogger.Error(err);
MessageHelper.ShowMessage("Playlog save error");
CheckSaveDone();
}));
PacketHelper.StartPacket(Shim.CreatePacketUpsertUserAll(i, userData, delegate(int code)
{
if (code == 1)
{
MelonLogger.Msg("[ImmediateSave] UserAll saved");
CheckSaveDone();
}
else
{
SoundManager.PlaySE(Mai2.Mai2Cue.Cue.SE_ENTRY_AIME_ERROR, i);
MelonLogger.Error("[ImmediateSave] UserAll upsert error");
MelonLogger.Error(code);
MessageHelper.ShowMessage("UserAll upsert error");
CheckSaveDone();
}
}, delegate(PacketStatus err)
{
SoundManager.PlaySE(Mai2.Mai2Cue.Cue.SE_ENTRY_AIME_ERROR, i);
MelonLogger.Error("[ImmediateSave] UserAll upsert error");
MelonLogger.Error(err);
MessageHelper.ShowMessage("UserAll upsert error");
CheckSaveDone();
}));
}
}
private static void SaveDataFix(UserData userData)
{
UserDetail detail = userData.Detail;
detail.EventWatchedDate = TimeManager.GetDateString(TimeManager.PlayBaseTime);
userData.CalcTotalValue();
float num = 0f;
try
{
if (userData.RatingList.RatingList.Any())
{
num = userData.RatingList.RatingList.Last().SingleRate;
}
}
catch
{
}
num = (float)Math.Ceiling((double)((num + 1f) / GameManager.TheoryRateBorderNum) * 10.0);
float num2 = 0f;
try
{
if (userData.RatingList.NextRatingList.Any())
{
num2 = userData.RatingList.NewRatingList.Last().SingleRate;
}
}
catch
{
}
num2 = (float)Math.Ceiling((double)((num2 + 1f) / GameManager.TheoryRateBorderNum) * 10.0);
string logDateString = TimeManager.GetLogDateString(TimeManager.PlayBaseTime);
string timeJp = (string.IsNullOrEmpty(userData.Detail.DailyBonusDate) ? TimeManager.GetLogDateString(0L) : userData.Detail.DailyBonusDate);
if (userData.IsEntry && userData.Detail.IsNetMember >= 2 && !GameManager.IsEventMode && TimeManager.GetUnixTime(logDateString) > TimeManager.GetUnixTime(timeJp) && Singleton<UserDataManager>.Instance.IsSingleUser() && !GameManager.IsFreedomMode && !GameManager.IsCourseMode && !DoneEntry.IsWeekdayBonus(userData))
{
userData.Detail.DailyBonusDate = logDateString;
}
List<UserRate> list = new List<UserRate>();
List<UserRate> list2 = new List<UserRate>();
IEnumerable<UserScore>[] scoreList = Shim.GetUserScoreList(userData);
List<UserRate> ratingList = userData.RatingList.RatingList;
List<UserRate> newRatingList = userData.RatingList.NewRatingList;
int achive = RatingTableID.Rate_22.GetAchive();
for (int j = 0; j < scoreList.Length; j++)
{
if (scoreList[j] == null)
{
continue;
}
foreach (UserScore item2 in scoreList[j])
{
if (achive <= item2.achivement)
{
continue;
}
MusicData music = Singleton<DataManager>.Instance.GetMusic(item2.id);
if (music == null)
{
continue;
}
UserRate item = new UserRate(item2.id, j, item2.achivement, (uint)music.version);
if (item.OldFlag)
{
if (num <= (float)item.Level && !ratingList.Contains(item))
{
list.Add(item);
}
}
else if (num2 <= (float)item.Level && !newRatingList.Contains(item))
{
list2.Add(item);
}
}
}
list.Sort();
list.Reverse();
if (list.Count > 10)
{
list.RemoveRange(10, list.Count - 10);
}
userData.RatingList.NextRatingList = list;
list2.Sort();
list2.Reverse();
if (list2.Count > 10)
{
list2.RemoveRange(10, list2.Count - 10);
}
userData.RatingList.NextNewRatingList = list2;
userData.Detail.LastPlayCredit = 0;
userData.Detail.LastPlayMode = 0;
if (GameManager.IsFreedomMode)
{
userData.Detail.LastPlayMode = 1;
}
if (GameManager.IsCourseMode)
{
userData.Detail.LastPlayMode = 2;
}
userData.Detail.LastGameId = ConstParameter.GameIDStr;
userData.Detail.LastRomVersion = Singleton<SystemConfig>.Instance.config.romVersionInfo.versionNo.versionString;
userData.Detail.LastDataVersion = Singleton<SystemConfig>.Instance.config.dataVersionInfo.versionNo.versionString;
}
private class SavingUi : MonoBehaviour
{
public void OnGUI()
{
var y = Screen.height * .075f;
var width = GuiSizes.FontSize * 20f;
var x = GuiSizes.PlayerCenter + GuiSizes.PlayerWidth / 2f - width;
var rect = new Rect(x, y, width, GuiSizes.LabelHeight * 2.5f);
var labelStyle = GUI.skin.GetStyle("label");
labelStyle.fontSize = (int)(GuiSizes.FontSize * 1.2);
labelStyle.alignment = TextAnchor.MiddleCenter;
GUI.Box(rect, "");
GUI.Label(rect, Locale.SavingDontExit);
}
}
}

View File

@@ -0,0 +1,247 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager;
using Monitor;
using Monitor.Result;
using Process;
using TMPro;
using UnityEngine;
using Object = UnityEngine.Object;
namespace AquaMai.Mods.UX;
[ConfigSection(
zh: "在游戏总结的计分板中显示击打误差的详细信息(以帧为单位)",
en: "Show detailed accuracy info in the score board.")]
public class JudgeAccuracyInfo
{
public class AccuracyEntryList
{
public List<float>[] DiffList = new List<float>[TableRowNames.Length];
public List<float>[] RawDiffList = new List<float>[TableRowNames.Length];
public HashSet<int> NoteIndices = new();
public AccuracyEntryList()
{
for (int i = 0; i < TableRowNames.Length; i++)
{
DiffList[i] = new List<float>();
RawDiffList[i] = new List<float>();
}
}
}
public static AccuracyEntryList[] EntryList = new AccuracyEntryList[2];
[HarmonyPostfix]
[HarmonyPatch(typeof(GameProcess), "OnStart")]
private static void OnGameProcessStartFinish()
{
for (int i = 0; i < EntryList.Length; i++)
{
EntryList[i] = new AccuracyEntryList();
}
}
[HarmonyPostfix]
[HarmonyPatch(typeof(ResultProcess), "OnRelease")]
private static void OnResultProcessReleaseFinish()
{
for (int i = 0; i < EntryList.Length; i++)
{
EntryList[i] = null;
Controllers[i] = null;
}
}
[HarmonyPatch]
public static class NoteBaseJudgePatch
{
public static IEnumerable<MethodBase> TargetMethods()
{
return
[
AccessTools.Method(typeof(NoteBase), "Judge"),
AccessTools.Method(typeof(HoldNote), "JudgeHoldHead"),
AccessTools.Method(typeof(BreakHoldNote), "JudgeHoldHead"),
AccessTools.Method(typeof(TouchNoteB), "Judge"),
AccessTools.Method(typeof(TouchHoldC), "JudgeHoldHead"),
];
}
public static void Postfix(
NoteBase __instance, bool __result,
float ___JudgeTimingDiffMsec, float ___AppearMsec, NoteJudge.EJudgeType ___JudgeType, int ___NoteIndex
)
{
var monitor = __instance.MonitorId;
if (!__result || EntryList[monitor].NoteIndices.Contains(___NoteIndex)) return;
EntryList[monitor].NoteIndices.Add(___NoteIndex);
var raw = (NotesManager.GetCurrentMsec() - ___AppearMsec) - NoteJudge.JudgeAdjustMs;
switch (___JudgeType)
{
case NoteJudge.EJudgeType.Tap:
case NoteJudge.EJudgeType.Break:
{
EntryList[monitor].DiffList[0].Add(___JudgeTimingDiffMsec);
EntryList[monitor].RawDiffList[0].Add(raw);
break;
}
case NoteJudge.EJudgeType.Touch:
{
EntryList[monitor].DiffList[2].Add(___JudgeTimingDiffMsec);
EntryList[monitor].RawDiffList[2].Add(raw);
break;
}
case NoteJudge.EJudgeType.ExTap:
{
EntryList[monitor].DiffList[3].Add(___JudgeTimingDiffMsec);
EntryList[monitor].RawDiffList[3].Add(raw);
break;
}
}
// MelonLogger.Msg($"{___JudgeType}: {___JudgeTimingDiffMsec}, {raw}");
}
}
[HarmonyPostfix]
[HarmonyPatch(typeof(SlideRoot), "Judge")]
private static void SlideRootJudgePatch(
SlideRoot __instance, bool __result,
float ___JudgeTimingDiffMsec, float ___TailMsec, float ___lastWaitTimeForJudge,
NoteJudge.EJudgeType ___JudgeType, int ___NoteIndex
)
{
var monitor = __instance.MonitorId;
if (!__result || EntryList[monitor].NoteIndices.Contains(___NoteIndex)) return;
EntryList[monitor].NoteIndices.Add(___NoteIndex);
var raw = (NotesManager.GetCurrentMsec() - ___TailMsec + ___lastWaitTimeForJudge) - NoteJudge.JudgeAdjustMs;
EntryList[monitor].DiffList[1].Add(___JudgeTimingDiffMsec - NoteJudge.JudgeAdjustMs);
EntryList[monitor].RawDiffList[1].Add(raw);
// MelonLogger.Msg($"{___JudgeType}: {___JudgeTimingDiffMsec}, {raw}");
}
[HarmonyPostfix]
[HarmonyPatch(typeof(ResultProcess), "OnStart")]
private static void OnResultProcessStartFinish(
ResultMonitor[] ____monitors, ResultProcess.ResultScoreViewType[] ____resultScoreViewType, UserData[] ____userData
)
{
foreach (var monitor in ____monitors)
{
var idx = monitor.MonitorIndex;
if (!____userData[idx].IsEntry) continue;
var fileName = $"Acc_Track_{GameManager.MusicTrackNumber}_Player_{idx}.txt";
var filePath = Path.Combine(Environment.CurrentDirectory, fileName);
using (var writer = new StreamWriter(filePath))
{
for (int i = 0; i < TableRowNames.Length; i++)
{
writer.WriteLine($"Row: {TableRowNames[i]}");
writer.WriteLine(" DiffList:");
writer.WriteLine($" {string.Join(", ", EntryList[idx].DiffList[i])}");
writer.WriteLine(" RawDiffList:");
writer.WriteLine($" {string.Join(", ", EntryList[idx].RawDiffList[i])}");
writer.WriteLine();
}
}
var controller = Traverse.Create(monitor).Field<ScoreBoardController>("_scoreBoardController").Value;
var newController = Object.Instantiate(controller, controller.transform);
newController.gameObject.GetComponent<Animator>().enabled = false;
newController.transform.localPosition = Vector3.zero;
var table = ExtractTextObjs(newController);
for (var i = 0; i < TableHead.Length; i++)
{
table[0, i].text = TableHead[i];
}
for (var i = 0; i < TableRowNames.Length; i++)
{
table[i + 1, 0].text = TableRowNames[i];
var num = EntryList[idx].DiffList[i].Count;
table[i + 1, 1].text = num.ToString();
if (num <= 0)
{
table[i + 1, 2].text = "——";
table[i + 1, 3].text = "——";
table[i + 1, 4].text = "——";
continue;
}
var average = EntryList[idx].DiffList[i].Average();
var averageFrame = average * 0.06f;
table[i + 1, 2].text = averageFrame.ToString("+0.00;-0.00;0.00", CultureInfo.InvariantCulture);
var averageRawFrame = EntryList[idx].RawDiffList[i].Average() * 0.06f;
table[i + 1, 3].text = averageRawFrame.ToString("+0.00;-0.00;0.00", CultureInfo.InvariantCulture);
if (num <= 1)
{
table[i + 1, 4].text = "——";
}
else
{
var deviSqr = EntryList[idx].DiffList[i].Sum(x => (x - average) * (x - average)) / (num - 1);
var devi = Mathf.Sqrt(deviSqr) * 0.06f;
table[i + 1, 4].text = devi.ToString("0.00", CultureInfo.InvariantCulture);
}
}
newController.gameObject.SetActive(____resultScoreViewType[idx] == ResultProcess.ResultScoreViewType.VSResult);
Controllers[idx] = newController;
}
}
private static readonly ScoreBoardController[] Controllers = new ScoreBoardController[2];
[HarmonyPostfix]
[HarmonyPatch(typeof(ResultMonitor), "ChangeScoreBoard")]
private static void OnChangeScoreBoard(
ResultMonitor __instance, ResultProcess.ResultScoreViewType resultScoreType
)
{
Controllers[__instance.MonitorIndex].gameObject.SetActive(resultScoreType == ResultProcess.ResultScoreViewType.VSResult);
}
private static readonly string[] RowNames = ["_tap", "_hold", "_slide", "_touch", "_break"];
private static readonly string[] ColumnNames = ["_critical", "_perfect", "_great", "_good", "_miss"];
private static readonly string[] TableHead = ["", "NUM", "AVG", "RAW", "S.D."];
private static readonly string[] TableRowNames = ["TAP", "SLD", "TCH", "EX"];
private static TextMeshProUGUI[,] ExtractTextObjs(ScoreBoardController controller)
{
var result = new TextMeshProUGUI[RowNames.Length, ColumnNames.Length];
for (var i = 0; i < RowNames.Length; i++)
{
for (int j = 0; j < ColumnNames.Length; j++)
{
var trav = Traverse.Create(controller)
.Field(RowNames[i])
.Field(ColumnNames[j]);
var text = trav.Field<TextMeshProUGUI>("_numberText").Value;
text.color = Color.black;
result[i, j] = text;
trav.GetValue<ScoreBoardColumnObject>().SetVisibleCloseBox(false);
}
}
return result;
}
}

View File

@@ -0,0 +1,65 @@
using System.Collections.Generic;
using AquaMai.Config.Attributes;
using AquaMai.Config.Types;
using AquaMai.Core.Helpers;
using HarmonyLib;
using Mai2.Mai2Cue;
using Main;
using Manager;
using MelonLoader;
using Process;
namespace AquaMai.Mods.UX;
[ConfigSection(
en: "One key to proceed to music select (during entry) or end current PC (during music select).",
zh: "一键跳过登录过程直接进入选歌界面,或在选歌界面直接结束本局游戏")]
public class OneKeyEntryEnd
{
[ConfigEntry]
public static readonly KeyCodeOrName key = KeyCodeOrName.Service;
[ConfigEntry]
public static readonly bool longPress = true;
[HarmonyPrefix]
[HarmonyPatch(typeof(GameMainObject), "Update")]
public static void OnGameMainObjectUpdate()
{
if (!KeyListener.GetKeyDownOrLongPress(key, longPress)) return;
MelonLogger.Msg("[QuickSkip] Activated");
var traverse = Traverse.Create(SharedInstances.ProcessDataContainer.processManager);
var processList = traverse.Field("_processList").GetValue<LinkedList<ProcessManager.ProcessControle>>();
ProcessBase processToRelease = null;
foreach (ProcessManager.ProcessControle process in processList)
{
switch (process.Process.ToString())
{
// After login
case "Process.ModeSelect.ModeSelectProcess":
case "Process.LoginBonus.LoginBonusProcess":
case "Process.RegionalSelectProcess":
case "Process.CharacterSelectProcess":
case "Process.TicketSelect.TicketSelectProcess":
processToRelease = process.Process;
break;
case "Process.MusicSelectProcess":
// Skip to save
SoundManager.PreviewEnd();
SoundManager.PlayBGM(Cue.BGM_COLLECTION, 2);
SharedInstances.ProcessDataContainer.processManager.AddProcess(new FadeProcess(SharedInstances.ProcessDataContainer, process.Process, new UnlockMusicProcess(SharedInstances.ProcessDataContainer)));
break;
}
}
if (processToRelease != null)
{
GameManager.SetMaxTrack();
SharedInstances.ProcessDataContainer.processManager.AddProcess(new FadeProcess(SharedInstances.ProcessDataContainer, processToRelease, new MusicSelectProcess(SharedInstances.ProcessDataContainer)));
}
}
}

View File

@@ -0,0 +1,46 @@
using AquaMai.Config.Attributes;
using AquaMai.Config.Types;
using AquaMai.Core.Helpers;
using HarmonyLib;
using MAI2.Util;
using Manager;
using Process;
namespace AquaMai.Mods.UX;
[ConfigSection(
en: "One key to retry (1.30+) or skip current chart in gameplay.",
zh: "在游戏中途一键重试1.30+)或跳过当前谱面")]
public class OneKeyRetrySkip
{
[ConfigEntry]
public static readonly KeyCodeOrName retryKey = KeyCodeOrName.Service;
[ConfigEntry]
public static readonly bool retryLongPress = false;
[ConfigEntry]
public static readonly KeyCodeOrName skipKey = KeyCodeOrName.Service;
[ConfigEntry]
public static readonly bool skipLongPress = true;
[HarmonyPostfix]
[HarmonyPatch(typeof(GameProcess), "OnUpdate")]
public static void PostGameProcessUpdate(GameProcess __instance, Message[] ____message, ProcessDataContainer ___container)
{
if (KeyListener.GetKeyDownOrLongPress(skipKey, skipLongPress))
{
var traverse = Traverse.Create(__instance);
___container.processManager.SendMessage(____message[0]);
Singleton<GamePlayManager>.Instance.SetSyncResult(0);
traverse.Method("SetRelease").GetValue();
}
if (KeyListener.GetKeyDownOrLongPress(retryKey, retryLongPress) && GameInfo.GameVersion >= 23000)
{
// This is original typo in Assembly-CSharp
Singleton<GamePlayManager>.Instance.SetQuickRetryFrag(flag: true);
}
}
}

View File

@@ -0,0 +1,151 @@
using System;
using AquaMai.Mods.Fix;
using AquaMai.Core.Helpers;
using AquaMai.Core.Resources;
using Manager;
using UnityEngine;
namespace AquaMai.Mods.UX.PracticeMode.Libs;
public class PracticeModeUI : MonoBehaviour
{
private static float windowTop => Screen.height - GuiSizes.PlayerWidth + GuiSizes.PlayerWidth * .22f;
private static float controlHeight => GuiSizes.PlayerWidth * .13f;
private static float sideButtonWidth => GuiSizes.PlayerWidth * .1f;
private static float centerButtonWidth => GuiSizes.PlayerWidth * .28f;
private static int fontSize => (int)(GuiSizes.PlayerWidth * .02f);
private static Rect GetButtonRect(int pos, int row)
{
float x;
float width;
switch (pos)
{
case 0:
x = GuiSizes.PlayerCenter - centerButtonWidth / 2 - sideButtonWidth - GuiSizes.Margin;
width = sideButtonWidth;
break;
case 1:
x = GuiSizes.PlayerCenter - centerButtonWidth / 2;
width = centerButtonWidth;
break;
case 2:
x = GuiSizes.PlayerCenter + centerButtonWidth / 2 + GuiSizes.Margin;
width = sideButtonWidth;
break;
default:
throw new ArgumentOutOfRangeException(nameof(pos), pos, null);
}
return new Rect(x, windowTop + (GuiSizes.Margin + controlHeight) * row + GuiSizes.Margin, width, controlHeight);
}
public void OnGUI()
{
var labelStyle = GUI.skin.GetStyle("label");
labelStyle.fontSize = fontSize;
labelStyle.alignment = TextAnchor.MiddleCenter;
var buttonStyle = GUI.skin.GetStyle("button");
buttonStyle.fontSize = fontSize;
GUI.Box(new Rect(
GuiSizes.PlayerCenter - centerButtonWidth / 2 - sideButtonWidth - GuiSizes.Margin * 2,
windowTop,
centerButtonWidth + sideButtonWidth * 2 + GuiSizes.Margin * 4,
controlHeight * 4 + GuiSizes.Margin * 5
), "");
GUI.Button(GetButtonRect(0, 0), Locale.SeekBackward);
GUI.Button(GetButtonRect(1, 0), Locale.Pause);
GUI.Button(GetButtonRect(2, 0), Locale.SeekForward);
if (PracticeMode.repeatStart == -1)
{
GUI.Button(GetButtonRect(0, 1), Locale.MarkRepeatStart);
GUI.Label(GetButtonRect(1, 1), Locale.RepeatNotSet);
}
else if (PracticeMode.repeatEnd == -1)
{
GUI.Button(GetButtonRect(0, 1), Locale.MarkRepeatEnd);
GUI.Label(GetButtonRect(1, 1), Locale.RepeatStartSet);
GUI.Button(GetButtonRect(2, 1), Locale.RepeatReset);
}
else
{
GUI.Label(GetButtonRect(1, 1), Locale.RepeatStartEndSet);
GUI.Button(GetButtonRect(2, 1), Locale.RepeatReset);
}
GUI.Button(GetButtonRect(0, 2), Locale.SpeedDown);
GUI.Label(GetButtonRect(1, 2), $"{Locale.Speed} {PracticeMode.speed * 100:000}%");
GUI.Button(GetButtonRect(2, 2), Locale.SpeedUp);
GUI.Button(GetButtonRect(1, 3), Locale.SpeedReset);
GUI.Label(GetButtonRect(0, 3), $"{TimeSpan.FromMilliseconds(PracticeMode.CurrentPlayMsec):mm\\:ss\\.fff}\n{TimeSpan.FromMilliseconds(NotesManager.Instance().getPlayFinalMsec()):mm\\:ss\\.fff}");
GUI.Button(GetButtonRect(2, 3), $"保持流速\n{(PracticeMode.keepNoteSpeed ? "ON" : "OFF")}");
}
public void Update()
{
if (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.E8))
{
PracticeMode.Seek(-1000);
}
else if (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.E2))
{
PracticeMode.Seek(1000);
}
else if (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B8) || InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B1))
{
DebugFeature.Pause = !DebugFeature.Pause;
if (!DebugFeature.Pause)
{
PracticeMode.Seek(0);
}
}
else if (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B7) && PracticeMode.repeatStart == -1)
{
PracticeMode.repeatStart = PracticeMode.CurrentPlayMsec;
}
else if (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B7) && PracticeMode.repeatEnd == -1)
{
PracticeMode.SetRepeatEnd(PracticeMode.CurrentPlayMsec);
}
else if (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B2))
{
PracticeMode.ClearRepeat();
}
else if (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B6))
{
PracticeMode.SpeedDown();
}
else if (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B3))
{
PracticeMode.SpeedUp();
}
else if (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B5) || InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B4))
{
PracticeMode.SpeedReset();
}
else if (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.E4))
{
PracticeMode.keepNoteSpeed = !PracticeMode.keepNoteSpeed;
PracticeMode.gameCtrl?.ResetOptionSpeed();
}
else if (
InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.A1) ||
InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.A2) ||
InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.A3) ||
InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.A4) ||
InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.A5) ||
InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.A6) ||
InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.A7) ||
InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.A8)
)
{
PracticeMode.ui = null;
Destroy(this);
}
}
}

View File

@@ -0,0 +1,282 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using AquaMai.Mods.Fix;
using AquaMai.Core.Helpers;
using AquaMai.Core.Resources;
using AquaMai.Mods.UX.PracticeMode.Libs;
using HarmonyLib;
using Manager;
using Monitor;
using Monitor.Game;
using Process;
using UnityEngine;
using AquaMai.Config.Attributes;
using AquaMai.Config.Types;
namespace AquaMai.Mods.UX.PracticeMode;
[ConfigCollapseNamespace]
[ConfigSection(
en: "Practice Mode.",
zh: "练习模式")]
public class PracticeMode
{
[ConfigEntry(
en: "Key to show Practice Mode UI.",
zh: "显示练习模式 UI 的按键")]
public static readonly KeyCodeOrName key = KeyCodeOrName.Test;
[ConfigEntry]
public static readonly bool longPress = false;
public static double repeatStart = -1;
public static double repeatEnd = -1;
public static float speed = 1;
private static CriAtomExPlayer player;
private static MovieMaterialMai2 movie;
public static GameCtrl gameCtrl;
public static bool keepNoteSpeed = false;
public static void SetRepeatEnd(double time)
{
if (repeatStart == -1)
{
MessageHelper.ShowMessage(Locale.RepeatStartTimeNotSet);
return;
}
if (time < repeatStart)
{
MessageHelper.ShowMessage(Locale.RepeatEndTimeLessThenStartTime);
return;
}
repeatEnd = time;
}
public static void ClearRepeat()
{
repeatStart = -1;
repeatEnd = -1;
}
public static void SetSpeed()
{
player.SetPitch((float)(1200 * Math.Log(speed, 2)));
// player.SetDspTimeStretchRatio(1 / speed);
player.UpdateAll();
movie.player.SetSpeed(speed);
gameCtrl?.ResetOptionSpeed();
}
private static IEnumerator SetSpeedCoroutineInner()
{
yield return null;
SetSpeed();
}
public static void SetSpeedCoroutine()
{
SharedInstances.GameMainObject.StartCoroutine(SetSpeedCoroutineInner());
}
public static void SpeedUp()
{
speed += .05f;
if (speed > 2)
{
speed = 2;
}
SetSpeed();
}
public static void SpeedDown()
{
speed -= .05f;
if (speed < 0.05)
{
speed = 0.05f;
}
SetSpeed();
}
public static void SpeedReset()
{
speed = 1;
SetSpeed();
}
public static void Seek(int addMsec)
{
// Debug feature 里面那个 timer 不能感知变速
// 为了和魔改版本统一polyfill 里面不修这个
// 这里重新实现一个能感知变速的 Seek
var msec = CurrentPlayMsec + addMsec;
if (msec < 0)
{
msec = 0;
}
CurrentPlayMsec = msec;
}
public static double CurrentPlayMsec
{
get => NotesManager.GetCurrentMsec() - 91;
set
{
DebugFeature.CurrentPlayMsec = value;
SetSpeedCoroutine();
}
}
public static PracticeModeUI ui;
[HarmonyPatch]
public class PatchNoteSpeed
{
public static IEnumerable<MethodBase> TargetMethods()
{
yield return AccessTools.Method(typeof(GameManager), "GetNoteSpeed");
yield return AccessTools.Method(typeof(GameManager), "GetTouchSpeed");
}
public static void Postfix(ref float __result)
{
if (!keepNoteSpeed) return;
__result /= speed;
}
}
[HarmonyPatch(typeof(GameProcess), "OnStart")]
[HarmonyPostfix]
public static void GameProcessPostStart()
{
repeatStart = -1;
repeatEnd = -1;
speed = 1;
ui = null;
}
[HarmonyPatch(typeof(GameProcess), "OnRelease")]
[HarmonyPostfix]
public static void GameProcessPostRelease()
{
repeatStart = -1;
repeatEnd = -1;
speed = 1;
ui = null;
}
[HarmonyPatch(typeof(GameCtrl), "Initialize")]
[HarmonyPostfix]
public static void GameCtrlPostInitialize(GameCtrl __instance)
{
gameCtrl = __instance;
}
# if DEBUG
[HarmonyPrefix]
[HarmonyPatch(typeof(GenericProcess), "OnUpdate")]
public static void OnGenericProcessUpdate(GenericMonitor[] ____monitors)
{
if (Input.GetKeyDown(KeyCode.F11))
{
____monitors[0].gameObject.AddComponent<PracticeModeUI>();
}
}
# endif
[HarmonyPatch(typeof(GameProcess), "OnUpdate")]
[HarmonyPostfix]
public static void GameProcessPostUpdate(GameProcess __instance, GameMonitor[] ____monitors)
{
if (KeyListener.GetKeyDownOrLongPress(key, longPress) && ui is null)
{
ui = ____monitors[0].gameObject.AddComponent<PracticeModeUI>();
}
if (repeatStart >= 0 && repeatEnd >= 0)
{
if (CurrentPlayMsec >= repeatEnd)
{
CurrentPlayMsec = repeatStart;
}
}
}
private static float startGap = -1f;
[HarmonyPatch(typeof(NotesManager), "StartPlay")]
[HarmonyPostfix]
public static void NotesManagerPostUpdateTimer(float msecStartGap)
{
startGap = msecStartGap;
}
[HarmonyPatch(typeof(NotesManager), "UpdateTimer")]
[HarmonyPrefix]
public static bool NotesManagerPostUpdateTimer(bool ____isPlaying, Stopwatch ____stopwatch, ref float ____curMSec, ref float ____curMSecPre, float ____msecStartGap)
{
var stackTrace = new StackTrace(); // get call stack
var stackFrames = stackTrace.GetFrames(); // get method calls (frames)
if(stackFrames.Select(it => it.GetMethod().DeclaringType.Name).Contains("AdvDemoProcess"))
{
return true;
}
if (startGap != -1f)
{
____curMSec = startGap;
____curMSecPre = startGap;
____stopwatch?.Reset();
startGap = -1f;
}
else
{
____curMSecPre = ____curMSec;
if (____isPlaying && ____stopwatch != null && !DebugFeature.Pause)
{
var num = (double)____stopwatch.ElapsedTicks / Stopwatch.Frequency * 1000.0 * speed;
____curMSec += (float)num;
____stopwatch.Reset();
____stopwatch.Start();
}
}
return false;
}
[HarmonyPatch(typeof(SoundCtrl), "Initialize")]
[HarmonyPostfix]
public static void SoundCtrlPostInitialize(SoundCtrl.InitParam param, Dictionary<int, object> ____players)
{
var wrapper = ____players[2];
player = (CriAtomExPlayer)wrapper.GetType().GetField("Player").GetValue(wrapper);
// var pool = new CriAtomExStandardVoicePool(1, 8, 96000, true, 2);
// pool.AttachDspTimeStretch();
// player.SetVoicePoolIdentifier(pool.identifier);
// debug
// var wrapper1 = ____players[7];
// var player1 = (CriAtomExPlayer)wrapper1.GetType().GetField("Player").GetValue(wrapper1);
// var pool = new CriAtomExStandardVoicePool(1, 8, 96000, true, 2);
// pool.AttachDspTimeStretch();
// player1.SetVoicePoolIdentifier(pool.identifier);
// player1.SetDspTimeStretchRatio(2);
}
[HarmonyPatch(typeof(MovieController), "Awake")]
[HarmonyPostfix]
public static void MovieControllerPostAwake(MovieMaterialMai2 ____moviePlayers)
{
movie = ____moviePlayers;
}
}

View File

@@ -0,0 +1,70 @@
using AquaMai.Config.Attributes;
using AquaMai.Core.Helpers;
using AquaMai.Core.Resources;
using HarmonyLib;
using MAI2.Util;
using Manager;
using Monitor;
using Process;
using UnityEngine;
namespace AquaMai.Mods.UX;
[ConfigSection(
en: "Show a \"skip\" button like AstroDX after the notes end.",
zh: "音符结束之后显示像 AstroDX 一样的「跳过」按钮")]
public class QuickEndPlay
{
private static int _timer;
[HarmonyPatch(typeof(GameProcess), "OnStart")]
[HarmonyPostfix]
public static void GameProcessPostStart(GameMonitor[] ____monitors)
{
_timer = 0;
____monitors[0].gameObject.AddComponent<Ui>();
}
[HarmonyPatch(typeof(GameProcess), "OnUpdate")]
[HarmonyPostfix]
public static void GameProcessPostUpdate(GameProcess __instance, Message[] ____message, ProcessDataContainer ___container, byte ____sequence)
{
switch (____sequence)
{
case 9:
_timer = 0;
break;
case > 4:
_timer++;
break;
default:
_timer = 0;
break;
}
if (_timer > 60 && (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B4) || InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.E4)))
{
var traverse = Traverse.Create(__instance);
___container.processManager.SendMessage(____message[0]);
Singleton<GamePlayManager>.Instance.SetSyncResult(0);
traverse.Method("SetRelease").GetValue();
}
}
private class Ui : MonoBehaviour
{
public void OnGUI()
{
if (_timer < 60) return;
// 这里重新 setup 一下 style 也可以
var x = GuiSizes.PlayerCenter;
var y = Screen.height - GuiSizes.PlayerWidth * .37f;
var width = GuiSizes.PlayerWidth * .25f;
var height = GuiSizes.PlayerWidth * .13f;
GUI.Box(new Rect(x, y, width, height), "");
GUI.Button(new Rect(x, y, width, height), Locale.Skip);
}
}
}

View File

@@ -0,0 +1,3 @@
# UX
Features aiming at improving the user experience and can't fit in other categories go here. Generally, UX features are triggered by some user action or let the user perceive.

View File

@@ -0,0 +1,143 @@
using System.Collections.Generic;
using System.Linq;
using AquaMai.Core.Attributes;
using AquaMai.Config.Attributes;
using AquaMai.Core.Helpers;
using AquaMai.Core.Resources;
using HarmonyLib;
using MAI2.Util;
using Manager;
using Manager.MaiStudio;
using Manager.UserDatas;
using Monitor;
using Process;
using UnityEngine;
namespace AquaMai.Mods.UX;
[EnableGameVersion(23500)]
[ConfigSection(
en: "Show detail of selected song in music selection screen.",
zh: "选歌界面显示选择的歌曲的详情")]
public class SelectionDetail
{
private static readonly Window[] window = new Window[2];
private static MusicSelectProcess.MusicSelectData SelectData { get; set; }
private static readonly int[] difficulty = new int[2];
[HarmonyPostfix]
[HarmonyPatch(typeof(MusicSelectMonitor), "UpdateRivalScore")]
public static void ScrollUpdate(MusicSelectProcess ____musicSelect, MusicSelectMonitor __instance)
{
int player;
if (__instance == ____musicSelect.MonitorArray[0])
{
player = 0;
}
else if (__instance == ____musicSelect.MonitorArray[1])
{
player = 1;
}
else
{
return;
}
if (window[player] != null)
{
window[player].Close();
}
var userData = Singleton<UserDataManager>.Instance.GetUserData(player);
if (!userData.IsEntry) return;
if (____musicSelect.IsRandomIndex()) return;
SelectData = ____musicSelect.GetMusic(0);
if (SelectData == null) return;
difficulty[player] = ____musicSelect.GetDifficulty(player);
window[player] = player == 0 ? __instance.gameObject.AddComponent<P1Window>() : __instance.gameObject.AddComponent<P2Window>();
}
private class P1Window : Window
{
protected override int player => 0;
}
private class P2Window : Window
{
protected override int player => 1;
}
private abstract class Window : MonoBehaviour
{
protected abstract int player { get; }
private UserData userData => Singleton<UserDataManager>.Instance.GetUserData(player);
public void OnGUI()
{
var dataToShow = new List<string>();
dataToShow.Add($"ID: {SelectData.MusicData.name.id}");
dataToShow.Add(MusicDirHelper.LookupPath(SelectData.MusicData.name.id).Split('/').Reverse().ToArray()[3]);
if (SelectData.MusicData.genreName is not null) // SelectData.MusicData.genreName.str may not correct
dataToShow.Add(Singleton<DataManager>.Instance.GetMusicGenre(SelectData.MusicData.genreName.id)?.genreName);
if (SelectData.MusicData.AddVersion is not null)
dataToShow.Add(Singleton<DataManager>.Instance.GetMusicVersion(SelectData.MusicData.AddVersion.id)?.genreName);
var notesData = SelectData.MusicData.notesData[difficulty[player]];
dataToShow.Add($"{notesData?.level}.{notesData?.levelDecimal}");
var rate = CalcB50(SelectData.MusicData, difficulty[player]);
if (rate > 0)
{
dataToShow.Add(string.Format(Locale.RatingUpWhenSSSp, rate));
}
var playCount = Shim.GetUserScoreList(userData)[difficulty[player]].FirstOrDefault(it => it.id == SelectData.MusicData.name.id)?.playcount ?? 0;
if (playCount > 0)
{
dataToShow.Add(string.Format(Locale.PlayCount, playCount));
}
var width = GuiSizes.FontSize * 15f;
var x = GuiSizes.PlayerCenter - width / 2f + GuiSizes.PlayerWidth * player;
var y = Screen.height * 0.87f;
var labelStyle = GUI.skin.GetStyle("label");
labelStyle.fontSize = GuiSizes.FontSize;
labelStyle.alignment = TextAnchor.MiddleCenter;
GUI.Box(new Rect(x, y, width, dataToShow.Count * GuiSizes.LabelHeight + 2 * GuiSizes.Margin), "");
for (var i = 0; i < dataToShow.Count; i++)
{
GUI.Label(new Rect(x, y + GuiSizes.Margin + i * GuiSizes.LabelHeight, width, GuiSizes.LabelHeight), dataToShow[i]);
}
}
private uint CalcB50(MusicData musicData, int difficulty)
{
var theory = new UserRate(musicData.name.id, difficulty, 1010000, (uint)musicData.version);
var list = theory.OldFlag ? userData.RatingList.RatingList : userData.RatingList.NewRatingList;
var userLowRate = list.Last();
var userSongRate = list.FirstOrDefault(it => it.MusicId == musicData.name.id && it.Level == difficulty);
if (!userSongRate.Equals(default(UserRate)))
{
return theory.SingleRate - userSongRate.SingleRate;
}
if (theory.SingleRate > userLowRate.SingleRate)
{
return theory.SingleRate - userLowRate.SingleRate;
}
return 0;
}
public void Close()
{
Destroy(this);
}
}
}

View File

@@ -0,0 +1,53 @@
using AquaMai.Config.Attributes;
using AquaMai.Core.Helpers;
using HarmonyLib;
using Main;
using UnityEngine;
namespace AquaMai.Mods.Utils;
[ConfigSection(
en: "Display framerate.",
zh: "显示帧率")]
public class DisplayFrameRate
{
[HarmonyPatch(typeof(GameMainObject), "Awake")]
[HarmonyPostfix]
public static void ShowUi(GameMainObject __instance)
{
__instance.gameObject.AddComponent<Ui>();
}
private class Ui : MonoBehaviour
{
private static float sampleTime = 1f;
private static int frame;
private static float time;
private static float fps;
public void OnGUI()
{
var labelStyle = GUI.skin.GetStyle("label");
labelStyle.fontSize = GuiSizes.FontSize;
labelStyle.alignment = TextAnchor.MiddleCenter;
const float x = 10f;
const float y = 10f;
var width = GuiSizes.FontSize * 7f;
var height = GuiSizes.LabelHeight * 1.5f;
frame += 1;
time += Time.deltaTime;
if (time >= sampleTime)
{
fps = frame / time;
frame = 0;
time = 0;
}
GUI.Box(new Rect(x, y, width, height), "");
GUI.Label(new Rect(x, y, width, height), $"{fps:0.0} FPS");
}
}
}

View File

@@ -0,0 +1,49 @@
using System.Diagnostics;
using AquaMai.Config.Attributes;
using HarmonyLib;
using Manager;
using Manager.Operation;
using MelonLoader;
using Net.Packet;
namespace AquaMai.Mods.Utils;
[ConfigSection(exampleHidden: true, defaultOn: true)]
public class LogNetworkErrors
{
[HarmonyPostfix]
[HarmonyPatch(typeof(Packet), "ProcImpl")]
public static void Postfix(PacketState __result, Packet __instance)
{
if (__result == PacketState.Error)
{
MelonLogger.Msg($"[LogNetworkErrors] {__instance.Query.Api}: {__instance.Status}");
}
}
[HarmonyPostfix]
[HarmonyPatch(typeof(DataDownloader), "NotifyOffline")]
public static void DataDownloader()
{
MelonLogger.Msg("[LogNetworkErrors] DataDownloader NotifyOffline");
var stackTrace = new StackTrace();
MelonLogger.Msg(stackTrace.ToString());
}
[HarmonyPostfix]
[HarmonyPatch(typeof(OnlineCheckInterval), "NotifyOffline")]
public static void OnlineCheckInterval()
{
MelonLogger.Msg("[LogNetworkErrors] OnlineCheckInterval NotifyOffline");
var stackTrace = new StackTrace();
MelonLogger.Msg(stackTrace.ToString());
}
[HarmonyPostfix]
[HarmonyPatch(typeof(OperationManager), "IsAliveAimeServer", MethodType.Getter)]
public static void IsAliveAimeServer(bool __result)
{
if (__result == false)
MelonLogger.Msg($"[LogNetworkErrors] IsAliveAimeServer Is {__result}");
}
}

View File

@@ -0,0 +1,22 @@
using System;
using AquaMai.Config.Attributes;
using HarmonyLib;
using MelonLoader;
using Net.Packet;
using Net.Packet.Mai2;
using Net.VO.Mai2;
namespace AquaMai.Mods.Utils;
[ConfigSection(
en: "Log user ID on login.",
zh: "登录时将 UserID 输出到日志")]
public class LogUserId
{
[HarmonyPostfix]
[HarmonyPatch(typeof(PacketGetUserPreview), MethodType.Constructor, typeof(ulong), typeof(string), typeof(Action<ulong, UserPreviewResponseVO>), typeof(Action<PacketStatus>))]
public static void Postfix(ulong userId)
{
MelonLogger.Msg($"[LogUserId] UserLogin: {userId}");
}
}

View File

@@ -0,0 +1,3 @@
# Utils
Debugging or diagnostic purpose utilities goes here. They don't affect the game play at all, but may show UIs if needed.

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading;
using AquaMai.Config.Attributes;
using AquaMai.Core.Helpers;
using HarmonyLib;
using Main;
using MelonLoader;
using UnityEngine;
namespace AquaMai.Mods.Utils;
[ConfigSection(
en: "Show error log in the game.",
zh: "在游戏中显示错误日志窗口而不是关闭游戏进程")]
public class ShowErrorLog
{
private static Ui _errorUi;
[HarmonyPostfix]
[HarmonyPatch(typeof(GameMain), "ExceptionHandler")]
private static void ExceptionHandler(GameMain __instance, Exception e)
{
if (_errorUi == null)
{
_errorUi = new GameObject("ErrorUI").AddComponent<Ui>();
_errorUi.gameObject.SetActive(true);
}
string logFile = $"{MAI2System.Path.ErrorLogPath}{DateTime.Now:yyyyMMddHHmmss}.log";
MelonLogger.Msg("Error Log:");
if (File.Exists(logFile))
{
MelonLogger.Error(File.ReadAllText(logFile));
_errorUi.SetErrorLog(File.ReadAllText(logFile));
}
else
{
MelonLogger.Error(e);
_errorUi.SetErrorLog(e.ToString());
}
Application.quitting += ApplicationOnQuitting;
_errorUi.StartCoroutine(_errorUi.Show());
}
private static void ApplicationOnQuitting()
{
Thread.Sleep(Timeout.Infinite);
}
private class Ui : MonoBehaviour
{
private string _errorLog = "";
public void SetErrorLog(string text)
{
_errorLog = "Error Log:\n" + text;
}
public void OnGUI()
{
var labelStyle = new GUIStyle(GUI.skin.label)
{
fontSize = GuiSizes.FontSize,
alignment = TextAnchor.MiddleLeft,
normal = new GUIStyleState(){textColor = Color.black}
};
var boxStyle = new GUIStyle(GUI.skin.box)
{
normal = new GUIStyleState() { background = Texture2D.whiteTexture }
};
int logLineCount = Regex.Matches(_errorLog, "\n").Count + 1;
float offset = GuiSizes.PlayerCenter * 0.12f;
var x = GuiSizes.PlayerCenter / 2f + offset / 2f;
var y = Screen.height / 1.8f;
var width = GuiSizes.PlayerCenter - offset;
var height = GuiSizes.LabelHeight * logLineCount + GuiSizes.Margin * 2;
GUI.Box(new Rect(x, y, width, height), "", boxStyle);
GUI.Label(new Rect(x, y, width, height), _errorLog, labelStyle);
if (!GuiSizes.SinglePlayer)
{
GUI.Box(new Rect(x + GuiSizes.PlayerWidth, y, width, height), "", boxStyle);
GUI.Label(new Rect(x + GuiSizes.PlayerWidth, y, width, height), _errorLog, labelStyle);
}
}
public IEnumerator Show()
{
while (true)
{
yield return null; // 让 Unity 处理一帧
}
}
}
}

View File

@@ -0,0 +1,72 @@
using System.Collections.Generic;
using AquaMai.Config.Attributes;
using AquaMai.Core.Helpers;
using AquaMai.Core.Resources;
using HarmonyLib;
using MAI2.Util;
using Manager;
using Monitor;
using Process;
using UnityEngine;
namespace AquaMai.Mods.Utils;
[ConfigSection(
en: "Show Network error detail in the game when gray network icon appears.",
zh: "出现灰网时显示原因")]
public class ShowNetErrorDetail
{
[HarmonyPatch(typeof(CommonProcess), "OnStart")]
[HarmonyPostfix]
public static void SetIconStatus(CommonMonitor[] ____monitors)
{
____monitors[0].gameObject.AddComponent<DetailUi>();
}
private class DetailUi : MonoBehaviour
{
public void OnGUI()
{
var errors = new List<string>();
if (!Singleton<OperationManager>.Instance.IsAliveServer)
{
errors.Add(Locale.NetErrIsAliveServer);
}
if (!Singleton<OperationManager>.Instance.IsAliveAimeReader)
{
errors.Add(Locale.NetErrIsAliveAimeReader);
}
if (!Singleton<OperationManager>.Instance.IsAliveAimeServer)
{
errors.Add(Locale.NetErrIsAliveAimeServer);
}
if (!Singleton<OperationManager>.Instance.WasDownloadSuccessOnce)
{
errors.Add(Locale.NetErrWasDownloadSuccessOnce);
}
if (errors.Count == 0)
{
return;
}
var labelStyle = GUI.skin.GetStyle("label");
labelStyle.fontSize = GuiSizes.FontSize;
labelStyle.alignment = TextAnchor.MiddleCenter;
var x = GuiSizes.PlayerCenter + GuiSizes.PlayerWidth * .2f;
var y = Screen.height * .01f;
var width = GuiSizes.FontSize * 15f;
var height = GuiSizes.LabelHeight * errors.Count + GuiSizes.Margin * 2;
GUI.Box(new Rect(x, y, width, height), "");
for (var i = 0; i < errors.Count; i++)
{
GUI.Label(new Rect(x, y + GuiSizes.Margin + GuiSizes.LabelHeight * i, width, GuiSizes.LabelHeight), errors[i]);
}
}
}
}