Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions AquaMai.Mods/Fancy/NextTrackTips.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
using AquaMai.Config.Attributes;
using AquaMai.Core.Attributes;
using AquaMai.Core.Helpers;
using HarmonyLib;
using MAI2.Util;
using Manager;
using MelonLoader;
using Monitor;
using Process;
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.UI;

namespace AquaMai.Mods.Fancy;

[ConfigSection(
"仿旧框下一曲目随机图像",
zh: "模仿旧框 FiNALE 在下一首曲目前显示随机提示图",
en: "Shows random tips image before next track, just like the good ol' FiNALE")]
[EnableGameVersion(22000)]
public class NextTrackTips
{
[ConfigEntry(
zh: "随机提示图目录,图像格式为 png",
en: "Tips image directory, only png images are supported")]
private static readonly string TipsDirectory = "LocalAssets/Tips";

private static readonly List<Sprite> _nextTrackSprites = [];

private static bool _timeCounterChanged = false;

private static readonly CommonWindow[] _hackyWindows = new CommonWindow[2];
private static IMessageMonitor[] _genericMonitorRefs = new IMessageMonitor[2];
Comment on lines +34 to +35

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

数组 _hackyWindows_genericMonitorRefs 被初始化为硬编码的大小 2。虽然游戏通常可能在 2 个监视器上运行,但依赖这一点会使代码变得脆弱。如果监视器的数量发生变化,可能会导致 IndexOutOfRangeException,因为循环是基于 ____monitors.Length 进行迭代的。

更健壮的做法是使用 List<T>,或者在首次需要时(例如在 OnStart 方法中)动态确定数组大小。

例如,对于 _hackyWindows,可以在 OnStart_Postfix 中这样做:
_hackyWindows = new CommonWindow[____monitors.Length];
然后继续执行循环。

对于 _genericMonitorRefs,它在 GenericProcess_OnStart_Postfix 中被重新赋值,但在 GenericProcess_OnRelease_Postfix 中被重置为一个固定大小的数组。更好的做法是在释放时将其设置为 Array.Empty<IMessageMonitor>()null


[HarmonyPrepare]
public static bool Initialize()
{
var resolvedDir = FileSystem.ResolvePath(TipsDirectory);
if (!Directory.Exists(resolvedDir))
{
MelonLogger.Error($"[NextTrackTips] Tips directory does not exist: {resolvedDir}");
return false;
}

var tipImgs = Directory.GetFiles(resolvedDir, "*.png", SearchOption.TopDirectoryOnly);

foreach (var tipImgPath in tipImgs)
{
try
{
var tex = new Texture2D(1, 1, TextureFormat.RGBA32, false);
tex.LoadImage(File.ReadAllBytes(tipImgPath));
_nextTrackSprites.Add(Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)));
}
catch (Exception e)
{
MelonLogger.Warning($"[NextTrackTips] Failed to load image {tipImgPath}: {e}");
}
}

if (_nextTrackSprites.Count < 1)
{
MelonLogger.Error($"[NextTrackTips] Tips directory seems empty or cannot load all images: {resolvedDir}");
return false;
}

return true;
}

[HarmonyPostfix]
[HarmonyPatch(typeof(GenericProcess), "OnStart")]
public static void GenericProcess_OnStart_Postfix(GenericMonitor[] ____monitors)
{
_genericMonitorRefs = ____monitors;
}

[HarmonyPostfix]
[HarmonyPatch(typeof(GenericProcess), "OnRelease")]
public static void GenericProcess_OnRelease_Postfix()
{
_genericMonitorRefs = new IMessageMonitor[2];
}

private static CommonWindow InitializeCommonWindowObject(CommonWindow prefab, Transform parent, int monitorIndex)
{
var window = UnityEngine.Object.Instantiate(prefab, parent);

window.Prepare(
_genericMonitorRefs[monitorIndex],
DB.WindowMessageID.NextTrackTips01,
DB.WindowPositionID.Middle,
Vector3.zero,
new WindowParam
{
changeSize = true,
sizeID = DB.WindowSizeID.LargeHorizontal,
hideTitle = true,
replaceText = true,
text = "",
directSprite = true,
sprite = _nextTrackSprites[UnityEngine.Random.Range(0, _nextTrackSprites.Count)]
}
);

// Some hacks to force the layout and "fix" spacing
var winLayout = window.transform.Find("IMG_Window").gameObject.GetComponent<HorizontalLayoutGroup>();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

这里的 .gameObject 访问是多余的。你可以直接在 Transform 或任何其他 Component 实例上调用 GetComponent<T>(),它会在该组件附加的 GameObject 上进行搜索。此问题也存在于第 139 和 202 行。

这可以提高代码的简洁性,是 Unity 开发中的常见做法。

        var winLayout = window.transform.Find("IMG_Window").GetComponent<HorizontalLayoutGroup>();

winLayout.spacing = 0.0f;
winLayout.padding = new RectOffset(40, 40, 40, 40);

return window;
}

#region NextTrackProcess Patch
private static bool CheckNextTrackProcess(NextTrackProcess.NextTrackMode mode)
{
return mode != NextTrackProcess.NextTrackMode.FreedomTimeup && mode != NextTrackProcess.NextTrackMode.NeedAwake && mode != NextTrackProcess.NextTrackMode.GotoEnd;
}

[HarmonyPostfix]
[HarmonyPatch(typeof(NextTrackProcess), "ProcessingProcess")]
public static void ProcessingProcess_Postfix(NextTrackProcess.NextTrackMode ____mode, ref float ____timeCounter)
{
if (CheckNextTrackProcess(____mode) && !_timeCounterChanged)
{
____timeCounter = 5f;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

数值 5f 是一个“魔术数字”。它代表提示图片显示的时长。最好在类的顶部将其定义为一个命名的常量,以提高可读性并使其更易于修改。

建议在类顶部添加一个常量:
private const float TipDisplayDuration = 5f;
然后在这里使用它。

            ____timeCounter = TipDisplayDuration;

_timeCounterChanged = true;
}
}

[HarmonyPostfix]
[HarmonyPatch(typeof(NextTrackProcess), "OnStart")]
public static void OnStart_Postfix(NextTrackMonitor[] ____monitors, NextTrackProcess.NextTrackMode ____mode)
{
if (!CheckNextTrackProcess(____mode))
return;

var commonWindowPref = Resources.Load<GameObject>("Process/Generic/GenericProcess").transform.Find("Canvas/Main/MessageRoot/HorizontalSplitWindow").gameObject.GetComponent<CommonWindow>();

for (int i = 0; i < ____monitors.Length; ++i)
{
var currUser = Singleton<UserDataManager>.Instance.GetUserData(i);
if (currUser == null || !currUser.IsActiveUser)
continue;

var mainCanvas = ____monitors[i].transform.Find("Canvas/Main");
_hackyWindows[i] = InitializeCommonWindowObject(commonWindowPref, mainCanvas.transform, i);

// Play the sound effects and voice line
SoundManager.PlaySE(Mai2.Mai2Cue.Cue.JINGLE_NEXT_TRACK, i);

Mai2.Voice_Partner_000001.Cue nextTrackVoice = UnityEngine.Random.Range(0, 2) == 0 ? Mai2.Voice_Partner_000001.Cue.VO_000151 : Mai2.Voice_Partner_000001.Cue.VO_000152;
if (GameManager.MusicTrackNumber + 1U == GameManager.GetMaxTrackCount())
nextTrackVoice = Mai2.Voice_Partner_000001.Cue.VO_000153;
SoundManager.PlayPartnerVoice(nextTrackVoice, i);
}
}

[HarmonyPostfix]
[HarmonyPatch(typeof(NextTrackProcess), "OnLateUpdate")]
public static void OnLateUpdate_Postfix(NextTrackMonitor[] ____monitors)
{
for (int i = 0; i < ____monitors.Length; ++i)
_hackyWindows[i]?.UpdateView(GameManager.GetGameMSecAdd());
}

[HarmonyPostfix]
[HarmonyPatch(typeof(NextTrackProcess), "StartFadeIn")]
public static void StartFadeIn_Postfix(NextTrackMonitor[] ____monitors)
{
for (int i = 0; i < ____monitors.Length; ++i)
_hackyWindows[i]?.Close();
}

[HarmonyPrefix]
[HarmonyPatch(typeof(NextTrackProcess), "OnRelease")]
public static void OnRelease_Prefix(NextTrackMonitor[] ____monitors)
{
for (int i = 0; i < ____monitors.Length; ++i)
{
if (_hackyWindows[i] != null)
{
UnityEngine.Object.Destroy(_hackyWindows[i]);
_hackyWindows[i] = null;
}
}

_timeCounterChanged = false;
}
#endregion

#region KaleidxScopeFadeProcess Patch

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

用于 patch NextTrackProcess (lines 115-191) 和 KaleidxScopeFadeProcess (lines 193-257) 的逻辑非常相似,导致了大量代码重复。OnStart, OnLateUpdate, StartFadeIn, 和 OnRelease 这些 patch 方法的结构几乎完全相同。

可以将其重构为共享的辅助方法以提高可维护性。例如,你可以创建如下的辅助方法:

  • ShowTipsWindow(int monitorIndex, Transform parent)
  • UpdateTipsWindows()
  • CloseTipsWindows()
  • ReleaseTipsWindows()

然后 patch 方法只需调用这些辅助方法。主要的区别在于监视器/控制器列表的来源,可以将其作为参数传递。

[EnableGameVersion(25000, noWarn: true)]
[HarmonyPostfix]
[HarmonyPatch(typeof(KaleidxScopeFadeProcess), "OnStart")]
public static void KS_OnStart_Postfix(ProcessBase ___toProcess, List<KaleidxScopeFadeController> ___mainControllerList)
{
if (___toProcess.GetType() != typeof(MusicSelectProcess) || GameManager.MusicTrackNumber < 2) // WTF SBGA???

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

WTF SBGA??? 这样的注释是不专业的,对未来的维护者没有帮助。最好解释一下为什么这个检查是必要的。例如,描述这段代码是为了解决哪个特定的游戏行为或问题。这个问题在第 204 和 217 行也存在。

return;

var commonWindowPref = Resources.Load<GameObject>("Process/Generic/GenericProcess").transform.Find("Canvas/Main/MessageRoot/HorizontalSplitWindow").gameObject.GetComponent<CommonWindow>();

// WTF SBGA?
for (int i = 0; i < ___mainControllerList.Count; ++i)
{
var currUser = Singleton<UserDataManager>.Instance.GetUserData(i);
if (currUser == null || !currUser.IsActiveUser)
continue;

_hackyWindows[i] = InitializeCommonWindowObject(commonWindowPref, ___mainControllerList[i].transform, i);

// Play the sound effects and voice line
SoundManager.PlaySE(Mai2.Mai2Cue.Cue.JINGLE_NEXT_TRACK, i);

Mai2.Voice_Partner_000001.Cue nextTrackVoice = UnityEngine.Random.Range(0, 2) == 0 ? Mai2.Voice_Partner_000001.Cue.VO_000151 : Mai2.Voice_Partner_000001.Cue.VO_000152;
// WTF SBGA??
if (GameManager.MusicTrackNumber == GameManager.GetMaxTrackCount())
nextTrackVoice = Mai2.Voice_Partner_000001.Cue.VO_000153;
SoundManager.PlayPartnerVoice(nextTrackVoice, i);
}
}

[EnableGameVersion(25000, noWarn: true)]
[HarmonyPostfix]
[HarmonyPatch(typeof(KaleidxScopeFadeProcess), "OnLateUpdate")]
public static void KS_OnLateUpdate_Postfix(List<KaleidxScopeFadeController> ___mainControllerList, KaleidxScopeFadeState ___stateMachine)
{
for (int i = 0; i < ___mainControllerList.Count; ++i)
_hackyWindows[i]?.UpdateView(GameManager.GetGameMSecAdd());
}

[EnableGameVersion(25000, noWarn: true)]
[HarmonyPostfix]
[HarmonyPatch(typeof(KaleidxScopeFadeProcess), "StartFadeIn")]
public static void KS_StartFadeIn_Postfix(List<KaleidxScopeFadeController> ___mainControllerList)
{
for (int i = 0; i < ___mainControllerList.Count; ++i)
_hackyWindows[i]?.Close();
}

[EnableGameVersion(25000, noWarn: true)]
[HarmonyPrefix]
[HarmonyPatch(typeof(KaleidxScopeFadeProcess), "OnRelease")]
public static void KS_OnRelease_Prefix(List<KaleidxScopeFadeController> ___mainControllerList)
{
for (int i = 0; i < ___mainControllerList.Count; ++i)
{
if (_hackyWindows[i] != null)
{
UnityEngine.Object.Destroy(_hackyWindows[i]);
_hackyWindows[i] = null;
}
}
}
#endregion
}