Refactor Every namespace Keyword Usage

This commit is contained in:
copecone committed 2026-08-19 15:40:00 +09:00
1 parent 4e67cb9b09
commit 27be2141f7
44 files changed
+4545 -4471

No files matched your search

+64
View File
@@ -110,4 +110,68 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FFmpeg.AutoGen" Version="8.1.0" /> <PackageReference Include="FFmpeg.AutoGen" Version="8.1.0" />
</ItemGroup> </ItemGroup>
<!-- Build 후 XML 문서에서 ADOFAIRenderer.API 네임스페이스만 남기고 나머지 제거 -->
<UsingTask TaskName="FilterXmlDoc" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<XmlFilePath ParameterType="System.String" Required="true" />
<TargetNamespace ParameterType="System.String" Required="true" />
</ParameterGroup>
<Task>
<Using Namespace="System" />
<Using Namespace="System.IO" />
<Using Namespace="System.Collections.Generic" />
<Using Namespace="System.Xml.Linq" />
<Code Type="Fragment" Language="cs">
<![CDATA[
if (!File.Exists(XmlFilePath)) return true;
try
{
var doc = XDocument.Load(XmlFilePath);
var members = doc.Root?.Element("members");
if (members != null)
{
var prefix = TargetNamespace + ".";
var toRemove = new List<XElement>();
foreach (var member in members.Elements("member"))
{
var nameAttr = member.Attribute("name")?.Value;
if (string.IsNullOrEmpty(nameAttr)) continue;
// XML 주석 형식: T:네임스페이스.타입, M:메서드, P:프로퍼티, F:필드/열거형, E:이벤트 등
int colonIndex = nameAttr.IndexOf(':');
string fullName = colonIndex >= 0 ? nameAttr.Substring(colonIndex + 1) : nameAttr;
if (!fullName.StartsWith(prefix, StringComparison.Ordinal) && !fullName.Equals(TargetNamespace, StringComparison.Ordinal))
{
toRemove.Add(member);
}
}
foreach (var elem in toRemove)
{
elem.Remove();
}
doc.Save(XmlFilePath);
Log.LogMessage(Microsoft.Build.Framework.MessageImportance.High, $"[FilterXmlDoc] Kept '{TargetNamespace}' namespace docs. Removed {toRemove.Count} other members.");
}
}
catch (Exception ex)
{
Log.LogWarning($"[FilterXmlDoc] XML filtering failed: {ex.Message}");
}
]]>
</Code>
</Task>
</UsingTask>
<Target Name="FilterDocumentationFile" AfterTargets="Build" Condition="'$(GenerateDocumentationFile)' == 'true'">
<PropertyGroup>
<_XmlDocPath Condition="'$(_XmlDocPath)' == '' and Exists('$(TargetDir)$(TargetName).xml')">$(TargetDir)$(TargetName).xml</_XmlDocPath>
<_XmlDocPath Condition="'$(_XmlDocPath)' == '' and Exists('$(DocumentationFile)')">$(DocumentationFile)</_XmlDocPath>
</PropertyGroup>
<FilterXmlDoc Condition="'$(_XmlDocPath)' != ''" XmlFilePath="$(_XmlDocPath)" TargetNamespace="ADOFAIRenderer.API" />
</Target>
</Project> </Project>
+35 -4
View File
@@ -5,9 +5,10 @@ using System.IO;
namespace ADOFAIRenderer.API; namespace ADOFAIRenderer.API;
/// <summary> /// <summary>
/// Represents the result and metadata of a completed or cancelled render session. /// Represents the result and metadata of a completed or canceled render session.
/// </summary> /// </summary>
[SuppressMessage("ReSharper", "UnusedType.Global")] [SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class RenderResult public class RenderResult
{ {
/// <summary> /// <summary>
@@ -41,10 +42,20 @@ public class RenderResult
public long FileSizeBytes { get; } public long FileSizeBytes { get; }
/// <summary> /// <summary>
/// Error message or reason if rendering failed or was cancelled; <c>null</c> on success. /// Error message or reason if rendering failed or was canceled; <c>null</c> on success.
/// </summary> /// </summary>
public string? ErrorMessage { get; } public string? ErrorMessage { get; }
/// <summary>
/// Initializes a new instance of the <see cref="RenderResult"/> class.
/// </summary>
/// <param name="success">Whether the render session completed successfully.</param>
/// <param name="outputPath">The absolute output path of the rendered video file.</param>
/// <param name="totalFrames">Total number of video frames sent to the encoder.</param>
/// <param name="videoDuration">Calculated video duration in seconds.</param>
/// <param name="renderDuration">Total wall-clock time spent rendering.</param>
/// <param name="fileSizeBytes">File size of the output video in bytes.</param>
/// <param name="errorMessage">Error message or reason if failed or canceled; otherwise, <c>null</c>.</param>
public RenderResult( public RenderResult(
bool success, bool success,
string outputPath, string outputPath,
@@ -63,7 +74,15 @@ public class RenderResult
ErrorMessage = errorMessage; ErrorMessage = errorMessage;
} }
public static RenderResult CreateSuccess(string outputPath, long totalFrames, double fps, TimeSpan renderDuration) /// <summary>
/// Creates a successful <see cref="RenderResult"/> instance from the specified render metrics.
/// </summary>
/// <param name="outputPath">The path to the rendered video file.</param>
/// <param name="totalFrames">Total number of video frames sent to the encoder.</param>
/// <param name="fps">The effective frames per second used to compute video duration.</param>
/// <param name="renderDuration">Total wall-clock time spent rendering.</param>
/// <returns>A new <see cref="RenderResult"/> representing a successful render session.</returns>
internal static RenderResult CreateSuccess(string outputPath, long totalFrames, double fps, TimeSpan renderDuration)
{ {
var fileSizeBytes = 0L; var fileSizeBytes = 0L;
if (!string.IsNullOrEmpty(outputPath) && File.Exists(outputPath)) if (!string.IsNullOrEmpty(outputPath) && File.Exists(outputPath))
@@ -76,12 +95,24 @@ public class RenderResult
return new RenderResult(true, outputPath, totalFrames, videoDuration, renderDuration, fileSizeBytes); return new RenderResult(true, outputPath, totalFrames, videoDuration, renderDuration, fileSizeBytes);
} }
public static RenderResult CreateFailure(string? errorMessage, long totalFrames, double fps, TimeSpan renderDuration) /// <summary>
/// Creates a failed or cancelled <see cref="RenderResult"/> instance.
/// </summary>
/// <param name="errorMessage">Error message or reason for failure/cancellation.</param>
/// <param name="totalFrames">Total number of video frames sent to the encoder prior to termination.</param>
/// <param name="fps">The effective frames per second used to compute video duration.</param>
/// <param name="renderDuration">Total wall-clock time spent rendering prior to termination.</param>
/// <returns>A new <see cref="RenderResult"/> representing a failed or canceled render session.</returns>
internal static RenderResult CreateFailure(string? errorMessage, long totalFrames, double fps, TimeSpan renderDuration)
{ {
var videoDuration = fps > 0 ? totalFrames / fps : 0.0; var videoDuration = fps > 0 ? totalFrames / fps : 0.0;
return new RenderResult(false, string.Empty, totalFrames, videoDuration, renderDuration, 0, errorMessage ?? "Rendering failed or was cancelled."); return new RenderResult(false, string.Empty, totalFrames, videoDuration, renderDuration, 0, errorMessage ?? "Rendering failed or was cancelled.");
} }
/// <summary>
/// Returns a string summarizing the render result metrics or failure reason.
/// </summary>
/// <returns>A string representation of the <see cref="RenderResult"/>.</returns>
public override string ToString() public override string ToString()
{ {
return Success return Success
+11
View File
@@ -92,12 +92,22 @@ public class RenderSession
return this; return this;
} }
/// <summary>
/// Sets whether desktop notifications should be displayed when rendering completes.
/// </summary>
/// <param name="enabled"><c>true</c> to enable desktop notifications; otherwise, <c>false</c>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithNotification(bool enabled) public RenderSession WithNotification(bool enabled)
{ {
_notification = enabled; _notification = enabled;
return this; return this;
} }
/// <summary>
/// Adds a callback action to be invoked when the render session completes or fails.
/// </summary>
/// <param name="callback">The callback action that receives the resulting <see cref="RenderResult"/>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession OnComplete(Action<RenderResult> callback) public RenderSession OnComplete(Action<RenderResult> callback)
{ {
_onComplete += callback; _onComplete += callback;
@@ -109,6 +119,7 @@ public class RenderSession
/// </summary> /// </summary>
/// <param name="renderConfig">The configuration settings for the render session.</param> /// <param name="renderConfig">The configuration settings for the render session.</param>
/// <param name="levelPath">The file path of the level to render.</param> /// <param name="levelPath">The file path of the level to render.</param>
/// <exception cref="InvalidOperationException">Thrown when the output directory cannot be determined from the output path.</exception>
public void Start(RenderConfig renderConfig, string levelPath) public void Start(RenderConfig renderConfig, string levelPath)
{ {
RenderContext.Auto = _auto; RenderContext.Auto = _auto;
+49 -50
View File
@@ -1,66 +1,65 @@
using ADOFAIRenderer.UI; using ADOFAIRenderer.UI;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Core namespace ADOFAIRenderer.Core;
[DefaultExecutionOrder(int.MinValue)]
internal class FirstUpdateHook : MonoBehaviour
{ {
[DefaultExecutionOrder(int.MinValue)] private bool _checkedFFmpeg;
internal class FirstUpdateHook : MonoBehaviour private bool _checkedNvidiaDriver;
private void Update()
{ {
private bool _checkedFFmpeg; if (!_checkedFFmpeg)
private bool _checkedNvidiaDriver;
private void Update()
{ {
if (!_checkedFFmpeg) if (FFmpegDownloadUI.Instance) return;
if (!FFmpegDownloadUI.CheckFFmpegExists())
{ {
if (FFmpegDownloadUI.Instance) return; FFmpegDownloadUI.ShowDownloadUI();
return;
if (!FFmpegDownloadUI.CheckFFmpegExists())
{
FFmpegDownloadUI.ShowDownloadUI();
return;
}
_checkedFFmpeg = true;
}
if (!_checkedNvidiaDriver)
{
if (CommonWarningDialogUI.Instance) return;
_checkedNvidiaDriver = true;
CheckNvidiaDriverWarning();
}
if (RenderManager.Instance)
{
RenderManager.Instance.OnPreRenderUpdate();
} }
_checkedFFmpeg = true;
} }
private void CheckNvidiaDriverWarning() if (!_checkedNvidiaDriver)
{ {
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion(); if (CommonWarningDialogUI.Instance) return;
if (driverVer is null or >= 610.00f) return;
var currentVersion = driverVer.Value.ToString("F2"); _checkedNvidiaDriver = true;
CheckNvidiaDriverWarning();
}
CommonWarningDialogUI.ShowDialog( if (RenderManager.Instance)
"NVIDIA 그래픽 드라이버 업데이트 권장", {
$"현재 감지된 NVIDIA 그래픽 드라이버 버전: <b><color=#ff5555>{currentVersion}</color></b>\n\n하드웨어 가속(NVENC)을 사용하여 영상을 렌더링하려면 드라이버 버전 <b><color=#55ff55>610.00 이상</color></b>이 필요합니다.\n\n드라이버 버전이 낮으면 인코딩이 실패하거나 소프트웨어(CPU) 인코딩으로 대체되어 렌더링이 매우 느려집니다. 드라이버 업데이트를 진행해 주시기 바랍니다.", RenderManager.Instance.OnPreRenderUpdate();
new DialogButton("드라이버 업데이트", () =>
{
Application.OpenURL("https://www.nvidia.com/Download/index.aspx");
Application.Quit();
}, new Color(0.0f, 0.8f, 1.0f, 1.0f)),
new DialogButton("기능 비활성화", () =>
{
RenderMod.IsFeatureDisabled = true;
}),
new DialogButton("이번만 그냥 진행", () =>
{
// 아무 동작 안 함
})
);
} }
} }
private void CheckNvidiaDriverWarning()
{
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion();
if (driverVer is null or >= 610.00f) return;
var currentVersion = driverVer.Value.ToString("F2");
CommonWarningDialogUI.ShowDialog(
"NVIDIA 그래픽 드라이버 업데이트 권장",
$"현재 감지된 NVIDIA 그래픽 드라이버 버전: <b><color=#ff5555>{currentVersion}</color></b>\n\n하드웨어 가속(NVENC)을 사용하여 영상을 렌더링하려면 드라이버 버전 <b><color=#55ff55>610.00 이상</color></b>이 필요합니다.\n\n드라이버 버전이 낮으면 인코딩이 실패하거나 소프트웨어(CPU) 인코딩으로 대체되어 렌더링이 매우 느려집니다. 드라이버 업데이트를 진행해 주시기 바랍니다.",
new DialogButton("드라이버 업데이트", () =>
{
Application.OpenURL("https://www.nvidia.com/Download/index.aspx");
Application.Quit();
}, new Color(0.0f, 0.8f, 1.0f, 1.0f)),
new DialogButton("기능 비활성화", () =>
{
RenderMod.IsFeatureDisabled = true;
}),
new DialogButton("이번만 그냥 진행", () =>
{
// 아무 동작 안 함
})
);
}
} }
+21 -22
View File
@@ -3,33 +3,32 @@ using System.Reflection;
using HarmonyLib; using HarmonyLib;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Core namespace ADOFAIRenderer.Core;
[DefaultExecutionOrder(int.MaxValue)]
internal class PostRenderHook : MonoBehaviour
{ {
[DefaultExecutionOrder(int.MaxValue)] private FieldInfo? _camQuadMeshField;
internal class PostRenderHook : MonoBehaviour
private void Awake()
{ {
private FieldInfo? _camQuadMeshField; _camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh");
}
private void Awake() private void LateUpdate()
{ {
_camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh"); if (RenderManager.Instance)
} RenderManager.Instance.OnLastUpdate();
private void LateUpdate() StartCoroutine(PostFrameCoroutine());
{ }
if (RenderManager.Instance)
RenderManager.Instance.OnLastUpdate();
StartCoroutine(PostFrameCoroutine()); private IEnumerator PostFrameCoroutine()
} {
yield return new WaitForEndOfFrame();
private IEnumerator PostFrameCoroutine() var val = (MeshRenderer)_camQuadMeshField!.GetValue(scrCamera.instance);
{ if (RenderManager.Instance)
yield return new WaitForEndOfFrame(); RenderManager.Instance.OnPostFrame(val.sharedMaterial.mainTexture as RenderTexture);
var val = (MeshRenderer)_camQuadMeshField!.GetValue(scrCamera.instance);
if (RenderManager.Instance)
RenderManager.Instance.OnPostFrame(val.sharedMaterial.mainTexture as RenderTexture);
}
} }
} }
+69 -68
View File
@@ -1,78 +1,79 @@
namespace ADOFAIRenderer.Data namespace ADOFAIRenderer.Data;
/// <summary>
/// Base class for double input validation limits.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// </summary>
internal abstract class DoubleLimit
{ {
/// <summary> /// <summary>
/// Base class for double input validation limits. /// Validates the given double value against this limit.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// </summary> /// </summary>
internal abstract class DoubleLimit /// <param name="value">The parsed double value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </param>
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
public abstract bool Validate(double value, out string errorMessage);
}
/// <summary>
/// Requires the double value to be strictly positive (> 0).
/// </summary>
internal class PositiveOnlyDoubleLimit : DoubleLimit
{
public override bool Validate(double value, out string errorMessage)
{ {
/// <summary> if (value > 0)
/// Validates the given double value against this limit.
/// </summary>
/// <param name="value">The parsed double value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </param>
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
public abstract bool Validate(double value, out string errorMessage);
}
/// <summary>
/// Requires the double value to be strictly positive (> 0).
/// </summary>
internal class PositiveOnlyDoubleLimit : DoubleLimit
{
public override bool Validate(double value, out string errorMessage)
{ {
if (value > 0)
{
errorMessage = null!;
return true;
}
errorMessage = "값은 0보다 큰 양수여야 합니다.";
return false;
}
}
/// <summary>
/// Requires the double value to be within an inclusive [Min, Max] range.
/// Either bound can be null to represent an unbounded side.
/// </summary>
internal class RangeDoubleLimit : DoubleLimit
{
public double? Min { get; }
public double? Max { get; }
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
public RangeDoubleLimit(double? min, double? max)
{
Min = min;
Max = max;
}
public override bool Validate(double value, out string errorMessage)
{
if (Min.HasValue && value < Min.Value)
{
errorMessage = Max.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 숫자여야 합니다."
: $"값은 {Min.Value} 이상의 숫자여야 합니다.";
return false;
}
if (Max.HasValue && value > Max.Value)
{
errorMessage = Min.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 숫자여야 합니다."
: $"값은 {Max.Value} 이하의 숫자여야 합니다.";
return false;
}
errorMessage = null!; errorMessage = null!;
return true; return true;
} }
errorMessage = "값은 0보다 큰 양수여야 합니다.";
return false;
}
}
/// <summary>
/// Requires the double value to be within an inclusive [Min, Max] range.
/// Either bound can be null to represent an unbounded side.
/// </summary>
internal class RangeDoubleLimit : DoubleLimit
{
// ReSharper disable MemberCanBePrivate.Global
public double? Min { get; }
public double? Max { get; }
// ReSharper restore MemberCanBePrivate.Global
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
public RangeDoubleLimit(double? min, double? max)
{
Min = min;
Max = max;
}
public override bool Validate(double value, out string errorMessage)
{
if (value < Min)
{
errorMessage = Max.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 숫자여야 합니다."
: $"값은 {Min.Value} 이상의 숫자여야 합니다.";
return false;
}
if (value > Max)
{
errorMessage = Min.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 숫자여야 합니다."
: $"값은 {Max.Value} 이하의 숫자여야 합니다.";
return false;
}
errorMessage = null!;
return true;
} }
} }
+53 -54
View File
@@ -3,75 +3,74 @@ using System.Linq;
using MelonLoader; using MelonLoader;
using UnityEngine.UIElements; using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input namespace ADOFAIRenderer.Data.Input;
internal class DoubleInputValidator
{ {
internal class DoubleInputValidator private struct FieldEntry
{ {
private struct FieldEntry public List<DoubleLimit> Limits;
{ }
public List<DoubleLimit> Limits;
}
private readonly VisualElement _rootVisualElement; private readonly VisualElement _rootVisualElement;
private readonly Dictionary<string, FieldEntry> _fields = new(); private readonly Dictionary<string, FieldEntry> _fields = new();
public DoubleInputValidator(VisualElement rootVisualElement) public DoubleInputValidator(VisualElement rootVisualElement)
{ {
_rootVisualElement = rootVisualElement; _rootVisualElement = rootVisualElement;
} }
/// <summary> /// <summary>
/// Registers one or more <see cref="DoubleLimit"/>s for the field with the given name. /// Registers one or more <see cref="DoubleLimit"/>s for the field with the given name.
/// Multiple limits can be added to the same field — they are all checked in order. /// Multiple limits can be added to the same field — they are all checked in order.
/// </summary> /// </summary>
/// <param name="fieldName">The name of the DoubleField in the UXML.</param> /// <param name="fieldName">The name of the DoubleField in the UXML.</param>
/// <param name="limits">The limits to apply.</param> /// <param name="limits">The limits to apply.</param>
public DoubleInputValidator AddLimits(string fieldName, params DoubleLimit[] limits) public DoubleInputValidator AddLimits(string fieldName, params DoubleLimit[] limits)
{
if (!_fields.TryGetValue(fieldName, out var entry))
{ {
if (!_fields.TryGetValue(fieldName, out var entry)) entry = new FieldEntry
{ {
entry = new FieldEntry Limits = new List<DoubleLimit>()
{ };
Limits = new List<DoubleLimit>() }
};
entry.Limits.AddRange(limits);
_fields[fieldName] = entry;
return this;
}
/// <summary>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{
var field = _rootVisualElement.Q<DoubleField>(fieldName);
if (field == null)
{
Melon<RenderMod>.Logger.Warning(
$"DoubleField '{fieldName}'을(를) 찾을 수 없습니다. 유효성 검사를 건너뜁니다.");
continue;
} }
entry.Limits.AddRange(limits); var limits = entry.Limits;
_fields[fieldName] = entry;
return this;
}
/// <summary> field.RegisterValueChangedCallback(evt =>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{ {
var field = _rootVisualElement.Q<DoubleField>(fieldName); if (string.IsNullOrEmpty(field.text))
if (field == null)
{ {
Melon<RenderMod>.Logger.Warning( return;
$"DoubleField '{fieldName}'을(를) 찾을 수 없습니다. 유효성 검사를 건너뜁니다.");
continue;
} }
var limits = entry.Limits; if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
field.RegisterValueChangedCallback(evt =>
{ {
if (string.IsNullOrEmpty(field.text)) field.SetValueWithoutNotify(evt.previousValue);
{ }
return; });
}
if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
{
field.SetValueWithoutNotify(evt.previousValue);
}
});
}
} }
} }
} }
+66 -65
View File
@@ -1,86 +1,87 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using MelonLoader; using MelonLoader;
using UnityEngine.UIElements; using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input namespace ADOFAIRenderer.Data.Input;
/// <summary>
/// Binds a UI Toolkit <see cref="DropdownField"/> to an enum type.
/// Populates choices from enum names and provides typed access to the selected value.
/// </summary>
/// <typeparam name="T">The enum type to bind.</typeparam>
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
internal class EnumDropdownBinder<T> where T : struct, Enum
{ {
private readonly DropdownField? _dropdown;
private readonly List<T> _values;
private Action<T>? _onValueChanged;
/// <summary> /// <summary>
/// Binds a UI Toolkit <see cref="DropdownField"/> to an enum type. /// The currently selected enum value.
/// Populates choices from enum names and provides typed access to the selected value.
/// </summary> /// </summary>
/// <typeparam name="T">The enum type to bind.</typeparam> public T Value => _dropdown is { index: >= 0 } && _dropdown.index < _values.Count
internal class EnumDropdownBinder<T> where T : struct, Enum ? _values[_dropdown.index]
: default;
/// <summary>
/// Queries a <see cref="DropdownField"/> by name from the root VisualElement,
/// populates it with enum names, and selects the default value.
/// </summary>
/// <param name="rootVisualElement">The root VisualElement loaded from the UXML bundle.</param>
/// <param name="fieldName">The name of the DropdownField in the UXML.</param>
/// <param name="defaultValue">The initial selected value. Defaults to the first enum member.</param>
public EnumDropdownBinder(VisualElement rootVisualElement, string fieldName, T? defaultValue = null)
{ {
private readonly DropdownField? _dropdown; _values = Enum.GetValues(typeof(T)).Cast<T>().ToList();
private readonly List<T> _values;
private Action<T>? _onValueChanged;
/// <summary> _dropdown = rootVisualElement.Q<DropdownField>(fieldName);
/// The currently selected enum value. if (_dropdown == null)
/// </summary>
public T Value => _dropdown is { index: >= 0 } && _dropdown.index < _values.Count
? _values[_dropdown.index]
: default;
/// <summary>
/// Queries a <see cref="DropdownField"/> by name from the root VisualElement,
/// populates it with enum names, and selects the default value.
/// </summary>
/// <param name="rootVisualElement">The root VisualElement loaded from the UXML bundle.</param>
/// <param name="fieldName">The name of the DropdownField in the UXML.</param>
/// <param name="defaultValue">The initial selected value. Defaults to the first enum member.</param>
public EnumDropdownBinder(VisualElement rootVisualElement, string fieldName, T? defaultValue = null)
{ {
_values = Enum.GetValues(typeof(T)).Cast<T>().ToList(); Melon<RenderMod>.Logger.Error(
$"DropdownField '{fieldName}'을(를) 찾을 수 없습니다.");
_dropdown = rootVisualElement.Q<DropdownField>(fieldName); return;
if (_dropdown == null)
{
Melon<RenderMod>.Logger.Error(
$"DropdownField '{fieldName}'을(를) 찾을 수 없습니다.");
return;
}
_dropdown.choices = _values.Select(v => v.ToString()).ToList();
// Register the callback BEFORE setting index so the initial selection fires it too
_dropdown.RegisterValueChangedCallback(_ =>
{
_onValueChanged?.Invoke(Value);
});
var initial = defaultValue ?? _values[0];
_dropdown.index = _values.IndexOf(initial);
} }
/// <summary> _dropdown.choices = _values.Select(v => v.ToString()).ToList();
/// Registers a callback that fires when the selection changes, providing the new enum value.
/// </summary>
public void OnValueChanged(Action<T> callback)
{
_onValueChanged = callback;
// Fire immediately with current value so the caller gets the initial state // Register the callback BEFORE setting index so the initial selection fires it too
if (_dropdown != null) _dropdown.RegisterValueChangedCallback(_ =>
{ {
callback(Value); _onValueChanged?.Invoke(Value);
} });
var initial = defaultValue ?? _values[0];
_dropdown.index = _values.IndexOf(initial);
}
/// <summary>
/// Registers a callback that fires when the selection changes, providing the new enum value.
/// </summary>
public void OnValueChanged(Action<T> callback)
{
_onValueChanged = callback;
// Fire immediately with current value so the caller gets the initial state
if (_dropdown != null)
{
callback(Value);
} }
}
/// <summary> /// <summary>
/// Sets the selected enum value and updates the dropdown index. /// Sets the selected enum value and updates the dropdown index.
/// </summary> /// </summary>
/// <param name="value">The enum value to set.</param> /// <param name="value">The enum value to set.</param>
public void SetValue(T value) public void SetValue(T value)
{
if (_dropdown == null) return;
int idx = _values.IndexOf(value);
if (idx >= 0)
{ {
if (_dropdown == null) return; _dropdown.index = idx;
int idx = _values.IndexOf(value);
if (idx >= 0)
{
_dropdown.index = idx;
}
} }
} }
} }
+53 -54
View File
@@ -3,75 +3,74 @@ using System.Linq;
using MelonLoader; using MelonLoader;
using UnityEngine.UIElements; using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input namespace ADOFAIRenderer.Data.Input;
internal class IntegerInputValidator
{ {
internal class IntegerInputValidator private struct FieldEntry
{ {
private struct FieldEntry public List<IntegerLimit> Limits;
{ }
public List<IntegerLimit> Limits;
}
private readonly VisualElement _rootVisualElement; private readonly VisualElement _rootVisualElement;
private readonly Dictionary<string, FieldEntry> _fields = new(); private readonly Dictionary<string, FieldEntry> _fields = new();
public IntegerInputValidator(VisualElement rootVisualElement) public IntegerInputValidator(VisualElement rootVisualElement)
{ {
_rootVisualElement = rootVisualElement; _rootVisualElement = rootVisualElement;
} }
/// <summary> /// <summary>
/// Registers one or more <see cref="IntegerLimit"/>s for the field with the given name. /// Registers one or more <see cref="IntegerLimit"/>s for the field with the given name.
/// Multiple limits can be added to the same field — they are all checked in order. /// Multiple limits can be added to the same field — they are all checked in order.
/// </summary> /// </summary>
/// <param name="fieldName">The name of the IntegerField in the UXML.</param> /// <param name="fieldName">The name of the IntegerField in the UXML.</param>
/// <param name="limits">The limits to apply.</param> /// <param name="limits">The limits to apply.</param>
public IntegerInputValidator AddLimits(string fieldName, params IntegerLimit[] limits) public IntegerInputValidator AddLimits(string fieldName, params IntegerLimit[] limits)
{
if (!_fields.TryGetValue(fieldName, out var entry))
{ {
if (!_fields.TryGetValue(fieldName, out var entry)) entry = new FieldEntry
{ {
entry = new FieldEntry Limits = new List<IntegerLimit>()
{ };
Limits = new List<IntegerLimit>() }
};
entry.Limits.AddRange(limits);
_fields[fieldName] = entry;
return this;
}
/// <summary>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{
var field = _rootVisualElement.Q<IntegerField>(fieldName);
if (field == null)
{
Melon<RenderMod>.Logger.Warning(
$"IntegerField '{fieldName}'을(를) 찾을 수 없습니다. 유효성 검사를 건너뜁니다.");
continue;
} }
entry.Limits.AddRange(limits); var limits = entry.Limits;
_fields[fieldName] = entry;
return this;
}
/// <summary> field.RegisterValueChangedCallback(evt =>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{ {
var field = _rootVisualElement.Q<IntegerField>(fieldName); if (string.IsNullOrEmpty(field.text))
if (field == null)
{ {
Melon<RenderMod>.Logger.Warning( return;
$"IntegerField '{fieldName}'을(를) 찾을 수 없습니다. 유효성 검사를 건너뜁니다.");
continue;
} }
var limits = entry.Limits; if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
field.RegisterValueChangedCallback(evt =>
{ {
if (string.IsNullOrEmpty(field.text)) field.SetValueWithoutNotify(evt.previousValue);
{ }
return; });
}
if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
{
field.SetValueWithoutNotify(evt.previousValue);
}
});
}
} }
} }
} }
+69 -68
View File
@@ -1,78 +1,79 @@
namespace ADOFAIRenderer.Data namespace ADOFAIRenderer.Data;
/// <summary>
/// Base class for integer input validation limits.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// </summary>
internal abstract class IntegerLimit
{ {
/// <summary> /// <summary>
/// Base class for integer input validation limits. /// Validates the given integer value against this limit.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// </summary> /// </summary>
internal abstract class IntegerLimit /// <param name="value">The parsed integer value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </param>
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
public abstract bool Validate(int value, out string errorMessage);
}
/// <summary>
/// Requires the integer value to be strictly positive (> 0).
/// </summary>
internal class PositiveOnlyIntegerLimit : IntegerLimit
{
public override bool Validate(int value, out string errorMessage)
{ {
/// <summary> if (value > 0)
/// Validates the given integer value against this limit.
/// </summary>
/// <param name="value">The parsed integer value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </param>
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
public abstract bool Validate(int value, out string errorMessage);
}
/// <summary>
/// Requires the integer value to be strictly positive (> 0).
/// </summary>
internal class PositiveOnlyIntegerLimit : IntegerLimit
{
public override bool Validate(int value, out string errorMessage)
{ {
if (value > 0)
{
errorMessage = null!;
return true;
}
errorMessage = "값은 0보다 큰 양의 정수여야 합니다.";
return false;
}
}
/// <summary>
/// Requires the integer value to be within an inclusive [Min, Max] range.
/// Either bound can be null to represent an unbounded side.
/// </summary>
internal class RangeIntegerLimit : IntegerLimit
{
public int? Min { get; }
public int? Max { get; }
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
public RangeIntegerLimit(int? min, int? max)
{
Min = min;
Max = max;
}
public override bool Validate(int value, out string errorMessage)
{
if (Min.HasValue && value < Min.Value)
{
errorMessage = Max.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 정수여야 합니다."
: $"값은 {Min.Value} 이상의 정수여야 합니다.";
return false;
}
if (Max.HasValue && value > Max.Value)
{
errorMessage = Min.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 정수여야 합니다."
: $"값은 {Max.Value} 이하의 정수여야 합니다.";
return false;
}
errorMessage = null!; errorMessage = null!;
return true; return true;
} }
errorMessage = "값은 0보다 큰 양의 정수여야 합니다.";
return false;
}
}
/// <summary>
/// Requires the integer value to be within an inclusive [Min, Max] range.
/// Either bound can be null to represent an unbounded side.
/// </summary>
internal class RangeIntegerLimit : IntegerLimit
{
// ReSharper disable MemberCanBePrivate.Global
public int? Min { get; }
public int? Max { get; }
// ReSharper restore MemberCanBePrivate.Global
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
public RangeIntegerLimit(int? min, int? max)
{
Min = min;
Max = max;
}
public override bool Validate(int value, out string errorMessage)
{
if (value < Min)
{
errorMessage = Max.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 정수여야 합니다."
: $"값은 {Min.Value} 이상의 정수여야 합니다.";
return false;
}
if (value > Max)
{
errorMessage = Min.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 정수여야 합니다."
: $"값은 {Max.Value} 이하의 정수여야 합니다.";
return false;
}
errorMessage = null!;
return true;
} }
} }
+143 -144
View File
@@ -7,184 +7,183 @@ using MelonLoader.Utils;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace ADOFAIRenderer.Data namespace ADOFAIRenderer.Data;
internal class RenderPreset
{ {
internal class RenderPreset public string Name { get; set; } = string.Empty;
public RenderConfig Config { get; set; } = null!;
}
internal static class PresetManager
{
private static string GetPresetsDirectory()
{ {
public string Name { get; set; } = string.Empty; return Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "Presets");
public RenderConfig Config { get; set; } = null!;
} }
internal static class PresetManager private static string GetSafeFileName(string name)
{ {
private static string GetPresetsDirectory() return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
}
public static void SavePreset(string name, RenderConfig config)
{
if (string.IsNullOrWhiteSpace(name))
{ {
return Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "Presets"); throw new ArgumentException("프리셋 이름은 비어 있을 수 없습니다.");
} }
private static string GetSafeFileName(string name) string dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
{ {
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_')); Directory.CreateDirectory(dir);
} }
public static void SavePreset(string name, RenderConfig config) var preset = new RenderPreset
{ {
if (string.IsNullOrWhiteSpace(name)) Name = name,
{ Config = config
throw new ArgumentException("프리셋 이름은 비어 있을 수 없습니다."); };
}
string dir = GetPresetsDirectory(); string json = JsonConvert.SerializeObject(preset, Formatting.Indented);
if (!Directory.Exists(dir)) string safeName = GetSafeFileName(name);
{ string filePath = Path.Combine(dir, safeName + ".json");
Directory.CreateDirectory(dir); File.WriteAllText(filePath, json);
} }
var preset = new RenderPreset public static List<RenderPreset> GetPresets()
{ {
Name = name, var presets = new List<RenderPreset>();
Config = config var dir = GetPresetsDirectory();
};
string json = JsonConvert.SerializeObject(preset, Formatting.Indented); if (!Directory.Exists(dir)) return presets;
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
File.WriteAllText(filePath, json);
}
public static List<RenderPreset> GetPresets() try
{ {
var presets = new List<RenderPreset>(); var files = Directory.GetFiles(dir, "*.json");
var dir = GetPresetsDirectory(); foreach (var file in files)
if (!Directory.Exists(dir)) return presets;
try
{ {
var files = Directory.GetFiles(dir, "*.json"); try
foreach (var file in files)
{ {
try var json = File.ReadAllText(file);
var jObject = JsonConvert.DeserializeObject<JObject>(json);
if (jObject == null) continue;
var configToken = jObject["Config"];
if (configToken is JObject configObj)
{ {
var json = File.ReadAllText(file); TryChangeKey(configObj, "DelayAfterLevelEnd", "DelayAfterEnd");
var jObject = JsonConvert.DeserializeObject<JObject>(json);
if (jObject == null) continue;
var configToken = jObject["Config"]; // 1. legacy EncodingConfig -> VideoConfig
if (configToken is JObject configObj) if (configObj["EncodingConfig"] is JObject oldEncodingConfigObj)
{ {
TryChangeKey(configObj, "DelayAfterLevelEnd", "DelayAfterEnd"); configObj["VideoConfig"] ??= oldEncodingConfigObj;
configObj.Remove("EncodingConfig");
// 1. legacy EncodingConfig -> VideoConfig
if (configObj["EncodingConfig"] is JObject oldEncodingConfigObj)
{
configObj["VideoConfig"] ??= oldEncodingConfigObj;
configObj.Remove("EncodingConfig");
}
// 2. legacy top-level Codec -> VideoConfig.Codec
var legacyCodecToken = configObj["Codec"];
if (legacyCodecToken != null && legacyCodecToken.Type != JTokenType.Null)
{
if (configObj["VideoConfig"] == null || configObj["VideoConfig"]!.Type == JTokenType.Null)
{
configObj["VideoConfig"] = new JObject();
}
var videoConfigObj = (JObject)configObj["VideoConfig"]!;
videoConfigObj["Codec"] ??= legacyCodecToken;
configObj.Remove("Codec");
}
// 3. legacy top-level Audio properties -> AudioConfig
string[] legacyAudioProps = { "RenderAudio", "AudioSampleRate", "AudioChannels", "AudioBitrate" };
foreach (var prop in legacyAudioProps)
{
var legacyToken = configObj[prop];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
{
if (configObj["AudioConfig"] == null || configObj["AudioConfig"]!.Type == JTokenType.Null)
{
configObj["AudioConfig"] = new JObject();
}
var audioConfigObj = (JObject)configObj["AudioConfig"]!;
audioConfigObj[prop] ??= legacyToken;
}
configObj.Remove(prop);
}
if (configObj["AudioConfig"] != null && configObj["AudioConfig"]!.Type == JTokenType.Null)
{
configObj.Remove("AudioConfig");
}
// 4. legacy top-level Advanced properties -> AdvancedConfig
string[] legacyAdvancedProps = { "RandomSeed", "ProcessAudio", "NonTile" };
foreach (var prop in legacyAdvancedProps)
{
var legacyToken = configObj[prop];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
{
if (configObj["AdvancedConfig"] == null || configObj["AdvancedConfig"]!.Type == JTokenType.Null)
{
configObj["AdvancedConfig"] = new JObject();
}
var advancedConfigObj = (JObject)configObj["AdvancedConfig"]!;
advancedConfigObj[prop] ??= legacyToken;
}
configObj.Remove(prop);
}
if (configObj["AdvancedConfig"] != null && configObj["AdvancedConfig"]!.Type == JTokenType.Null)
{
configObj.Remove("AdvancedConfig");
}
} }
var preset = jObject.ToObject<RenderPreset>(); // 2. legacy top-level Codec -> VideoConfig.Codec
if (preset != null && !string.IsNullOrEmpty(preset.Name)) var legacyCodecToken = configObj["Codec"];
if (legacyCodecToken != null && legacyCodecToken.Type != JTokenType.Null)
{ {
presets.Add(preset); if (configObj["VideoConfig"] == null || configObj["VideoConfig"]!.Type == JTokenType.Null)
{
configObj["VideoConfig"] = new JObject();
}
var videoConfigObj = (JObject)configObj["VideoConfig"]!;
videoConfigObj["Codec"] ??= legacyCodecToken;
configObj.Remove("Codec");
}
// 3. legacy top-level Audio properties -> AudioConfig
string[] legacyAudioProps = { "RenderAudio", "AudioSampleRate", "AudioChannels", "AudioBitrate" };
foreach (var prop in legacyAudioProps)
{
var legacyToken = configObj[prop];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
{
if (configObj["AudioConfig"] == null || configObj["AudioConfig"]!.Type == JTokenType.Null)
{
configObj["AudioConfig"] = new JObject();
}
var audioConfigObj = (JObject)configObj["AudioConfig"]!;
audioConfigObj[prop] ??= legacyToken;
}
configObj.Remove(prop);
}
if (configObj["AudioConfig"] != null && configObj["AudioConfig"]!.Type == JTokenType.Null)
{
configObj.Remove("AudioConfig");
}
// 4. legacy top-level Advanced properties -> AdvancedConfig
string[] legacyAdvancedProps = { "RandomSeed", "ProcessAudio", "NonTile" };
foreach (var prop in legacyAdvancedProps)
{
var legacyToken = configObj[prop];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
{
if (configObj["AdvancedConfig"] == null || configObj["AdvancedConfig"]!.Type == JTokenType.Null)
{
configObj["AdvancedConfig"] = new JObject();
}
var advancedConfigObj = (JObject)configObj["AdvancedConfig"]!;
advancedConfigObj[prop] ??= legacyToken;
}
configObj.Remove(prop);
}
if (configObj["AdvancedConfig"] != null && configObj["AdvancedConfig"]!.Type == JTokenType.Null)
{
configObj.Remove("AdvancedConfig");
} }
} }
catch (Exception ex)
var preset = jObject.ToObject<RenderPreset>();
if (preset != null && !string.IsNullOrEmpty(preset.Name))
{ {
MelonLogger.Warning($"프리셋 파일 '{Path.GetFileName(file)}' 로드 중 오류 발생: {ex.Message}"); presets.Add(preset);
} }
} }
catch (Exception ex)
{
MelonLogger.Warning($"프리셋 파일 '{Path.GetFileName(file)}' 로드 중 오류 발생: {ex.Message}");
}
} }
catch (Exception ex) }
{ catch (Exception ex)
MelonLogger.Error($"프리셋 목록을 불러오는 중 오류 발생: {ex.Message}"); {
} MelonLogger.Error($"프리셋 목록을 불러오는 중 오류 발생: {ex.Message}");
return presets;
} }
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey) return presets;
}
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
{
var legacyToken = configObj[keyBefore];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
configObj[targetKey] = legacyToken;
configObj.Remove(keyBefore);
}
public static void DeletePreset(string name)
{
string dir = GetPresetsDirectory();
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
if (File.Exists(filePath))
{ {
var legacyToken = configObj[keyBefore]; File.Delete(filePath);
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
configObj[targetKey] = legacyToken;
configObj.Remove(keyBefore);
}
public static void DeletePreset(string name)
{
string dir = GetPresetsDirectory();
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
if (File.Exists(filePath))
{
File.Delete(filePath);
}
} }
} }
} }
+57 -58
View File
@@ -1,71 +1,70 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Util.FFmpeg; using ADOFAIRenderer.Util.FFmpeg;
namespace ADOFAIRenderer.Data namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class RenderConfig
{ {
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")] public double RenderFPS { get; set; }
public class RenderConfig public double VideoFPS { get; set; }
public int RenderWidth { get; set; }
public int RenderHeight { get; set; }
public VideoEncodingConfig VideoConfig { get; set; } = new();
public AudioEncodingConfig AudioConfig { get; set; } = new();
public AdvancedConfig AdvancedConfig { get; set; } = new();
public double DelayAfterEnd { get; set; }
public bool ShowJudgementText { get; set; } = false;
}
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class AdvancedConfig
{
public int? RandomSeed { get; set; } = null;
public bool ProcessAudio { get; set; } = true;
public bool NonTile { get; set; } = false;
public string FileExtension { get; set; } = "mkv";
}
public class VideoEncodingConfig
{
public Codec Codec { get; set; } = Codec.H264;
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
public int Bitrate { get; set; } = 30000;
public int MaxBitrate { get; set; } = 50000;
// ReSharper disable once InconsistentNaming
public int QP { get; set; } = 18;
public double KeyframeInterval { get; set; } = 2.0;
public bool ForceSoftwareEncoder { get; set; }
}
public class AudioEncodingConfig
{
private int _audioSampleRate = 44100;
private int _audioChannels = 2;
private int _audioBitrate = 256000;
public bool RenderAudio { get; set; } = true;
public int AudioSampleRate
{ {
public double RenderFPS { get; set; } get => _audioSampleRate <= 0 ? 44100 : _audioSampleRate;
public double VideoFPS { get; set; } set => _audioSampleRate = value <= 0 ? 44100 : value;
public int RenderWidth { get; set; }
public int RenderHeight { get; set; }
public VideoEncodingConfig VideoConfig { get; set; } = new();
public AudioEncodingConfig AudioConfig { get; set; } = new();
public AdvancedConfig AdvancedConfig { get; set; } = new();
public double DelayAfterEnd { get; set; }
public bool ShowJudgementText { get; set; } = false;
} }
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")] public int AudioChannels
public class AdvancedConfig
{ {
public int? RandomSeed { get; set; } = null; get => _audioChannels <= 0 ? 2 : _audioChannels;
public bool ProcessAudio { get; set; } = true; set => _audioChannels = value <= 0 ? 2 : value;
public bool NonTile { get; set; } = false;
public string FileExtension { get; set; } = "mkv";
} }
public class VideoEncodingConfig public int AudioBitrate
{ {
public Codec Codec { get; set; } = Codec.H264; get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR; set => _audioBitrate = value <= 0 ? 256000 : value;
public int Bitrate { get; set; } = 30000;
public int MaxBitrate { get; set; } = 50000;
// ReSharper disable once InconsistentNaming
public int QP { get; set; } = 18;
public double KeyframeInterval { get; set; } = 2.0;
public bool ForceSoftwareEncoder { get; set; }
}
public class AudioEncodingConfig
{
private int _audioSampleRate = 44100;
private int _audioChannels = 2;
private int _audioBitrate = 256000;
public bool RenderAudio { get; set; } = true;
public int AudioSampleRate
{
get => _audioSampleRate <= 0 ? 44100 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 44100 : value;
}
public int AudioChannels
{
get => _audioChannels <= 0 ? 2 : _audioChannels;
set => _audioChannels = value <= 0 ? 2 : value;
}
public int AudioBitrate
{
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
set => _audioBitrate = value <= 0 ? 256000 : value;
}
} }
} }
+12 -13
View File
@@ -1,21 +1,20 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Engine namespace ADOFAIRenderer.Patch.Engine;
[HarmonyPatch(typeof(AsyncInputUtils), "UpdateOffsetTime")]
// ReSharper disable once InconsistentNaming
public static class AsyncInputPatches
{ {
[HarmonyPatch(typeof(AsyncInputUtils), "UpdateOffsetTime")] [HarmonyPrefix]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static class AsyncInputPatches public static bool Prefix()
{ {
[HarmonyPrefix] if (!RenderManager.IsActive) return true;
// ReSharper disable once InconsistentNaming // Force offsetTick to match instantly without 1/100 damping division when rendering
public static bool Prefix() AsyncInputManager.offsetTick = AsyncInputManager.currFrameTick - (ulong)(scrConductor.instance.dspTime * 10000000.0);
{ AsyncInputManager.offsetTickUpdated = true;
if (!RenderManager.IsActive) return true; return false; // Skip the original damped offset calculation
// Force offsetTick to match instantly without 1/100 damping division when rendering
AsyncInputManager.offsetTick = AsyncInputManager.currFrameTick - (ulong)(scrConductor.instance.dspTime * 10000000.0);
AsyncInputManager.offsetTickUpdated = true;
return false; // Skip the original damped offset calculation
}
} }
} }
+87 -88
View File
@@ -4,110 +4,109 @@ using HarmonyLib;
using UnityEngine; using UnityEngine;
using UnityEngine.Audio; using UnityEngine.Audio;
namespace ADOFAIRenderer.Patch.Engine namespace ADOFAIRenderer.Patch.Engine;
[HarmonyPatch(typeof(AudioManager), "_Play")]
public static class AudioManagerPlayPatch
{ {
[HarmonyPatch(typeof(AudioManager), "_Play")] [HarmonyPrefix]
public static class AudioManagerPlayPatch public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
{ {
[HarmonyPrefix] if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
{ {
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) // Cache the clip on the main thread
RenderManager.Instance.AudioMixer?.CacheClip(snd);
}
return true; // Let the original method execute to prevent NullReferenceException
}
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __result, string snd, double time, AudioMixerGroup group, float volume, int priority)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio && __result != null)
{
// Convert absolute DSP time to relative time in seconds from render start
double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
// Register event to the offline mixer (default pitch = 1.0f) and map it to the AudioSource
RenderManager.Instance.AudioMixer?.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f);
}
}
}
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
public static class PlayWithEndTimePatch
{
[HarmonyPrefix]
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null)
{ {
// Cache the clip on the main thread // Cache the clip on the main thread
RenderManager.Instance.AudioMixer?.CacheClip(snd); mixer.CacheClip(snd);
}
return true; // Let the original method execute to prevent NullReferenceException
}
[HarmonyPostfix] // Convert absolute DSP times to relative times in seconds from render start
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __result, string snd, double time, AudioMixerGroup group, float volume, int priority)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio && __result != null)
{
// Convert absolute DSP time to relative time in seconds from render start
double relativeStartTime = time - RenderManager.Instance.dspTimeBase; double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase;
// Register event to the offline mixer (default pitch = 1.0f) and map it to the AudioSource // Register looping sound event to the offline mixer (default pitch = 1.0f)
RenderManager.Instance.AudioMixer?.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f); mixer.RegisterSoundEvent(snd, relativeStartTime, relativeEndTime, volume, 1.0f);
} }
} }
return true; // Let the original method execute
} }
}
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")] [HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
public static class PlayWithEndTimePatch public static class AudioSourcePitchPatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, float value)
{ {
[HarmonyPrefix] if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
{ {
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
{ {
var mixer = RenderManager.Instance.AudioMixer; soundEvent.Pitch = value;
if (mixer != null)
{
// Cache the clip on the main thread
mixer.CacheClip(snd);
// Convert absolute DSP times to relative times in seconds from render start
double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase;
// Register looping sound event to the offline mixer (default pitch = 1.0f)
mixer.RegisterSoundEvent(snd, relativeStartTime, relativeEndTime, volume, 1.0f);
}
}
return true; // Let the original method execute
}
}
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
public static class AudioSourcePitchPatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, float value)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
{
soundEvent.Pitch = value;
}
}
}
}
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
public static class AudioSourceSetScheduledEndTimePatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, double time)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
{
soundEvent.EndTime = time - RenderManager.Instance.dspTimeBase;
}
}
}
}
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
public static class AudioSourceStopPatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance);
} }
} }
} }
} }
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
public static class AudioSourceSetScheduledEndTimePatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, double time)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
{
soundEvent.EndTime = time - RenderManager.Instance.dspTimeBase;
}
}
}
}
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
public static class AudioSourceStopPatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance);
}
}
}
+117 -120
View File
@@ -1,168 +1,165 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util;
using ADOFAIRenderer.Util.Audio; using ADOFAIRenderer.Util.Audio;
using HarmonyLib; using HarmonyLib;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Patch.Engine namespace ADOFAIRenderer.Patch.Engine;
public static class RenderEnvironmentPatches
{ {
public static class RenderEnvironmentPatches private static double TimeSpent => RenderManager.Instance.frameCount / RenderManager.Instance.Configuration.RenderFPS;
[HarmonyPatch(typeof(Time), "time", MethodType.Getter)]
public static class TimePatch
{ {
private const bool RenderPatch = true; [SuppressMessage("ReSharper", "InconsistentNaming")]
private static double TimeSpent => RenderManager.Instance.frameCount / RenderManager.Instance.Configuration.RenderFPS; public static bool Prefix(ref float __result)
[HarmonyPatch(typeof(Time), "time", MethodType.Getter)]
public static class TimePatch
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref float __result) __result = (float)(TimeSpent + RenderManager.Instance.timeBase);
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(TimeSpent + RenderManager.Instance.timeBase);
return false;
}
} }
}
[HarmonyPatch(typeof(Time), "timeAsDouble", MethodType.Getter)] [HarmonyPatch(typeof(Time), "timeAsDouble", MethodType.Getter)]
public static class TimeAsDoublePatch public static class TimeAsDoublePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref double __result) __result = TimeSpent + RenderManager.Instance.timeBase;
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = TimeSpent + RenderManager.Instance.timeBase;
return false;
}
} }
}
[HarmonyPatch(typeof(Time), "unscaledTime", MethodType.Getter)] [HarmonyPatch(typeof(Time), "unscaledTime", MethodType.Getter)]
public static class UnscaledTimePatch public static class UnscaledTimePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref float __result) __result = (float)(TimeSpent + RenderManager.Instance.unscaledTimeBase);
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(TimeSpent + RenderManager.Instance.unscaledTimeBase);
return false;
}
} }
}
[HarmonyPatch(typeof(Time), "unscaledTimeAsDouble", MethodType.Getter)] [HarmonyPatch(typeof(Time), "unscaledTimeAsDouble", MethodType.Getter)]
public static class UnscaledTimeAsDoublePatch public static class UnscaledTimeAsDoublePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref double __result) __result = TimeSpent + RenderManager.Instance.unscaledTimeBase;
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = TimeSpent + RenderManager.Instance.unscaledTimeBase;
return false;
}
} }
}
[HarmonyPatch(typeof(AudioSettings), "dspTime", MethodType.Getter)] [HarmonyPatch(typeof(AudioSettings), "dspTime", MethodType.Getter)]
public static class DspTimePatch public static class DspTimePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref double __result) __result = TimeSpent + RenderManager.Instance.dspTimeBase;
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = TimeSpent + RenderManager.Instance.dspTimeBase;
return false;
}
} }
}
[HarmonyPatch(typeof(AudioSource), "PlayScheduled")] [HarmonyPatch(typeof(AudioSource), "PlayScheduled")]
public static class PlayScheduledPatch public static class PlayScheduledPatch
{
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, double time)
{ {
// ReSharper disable once InconsistentNaming return !RenderManager.IsActive;
public static bool Prefix(AudioSource __instance, double time)
{
return !RenderManager.IsActive;
}
} }
}
[HarmonyPatch(typeof(Time), "frameCount", MethodType.Getter)] [HarmonyPatch(typeof(Time), "frameCount", MethodType.Getter)]
public static class FrameCountPatch public static class FrameCountPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref int __result) __result = (int)(RenderManager.Instance.frameCount + RenderManager.Instance.frameCountBase);
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (int)(RenderManager.Instance.frameCount + RenderManager.Instance.frameCountBase);
return false;
}
} }
}
[HarmonyPatch(typeof(Time), "deltaTime", MethodType.Getter)] [HarmonyPatch(typeof(Time), "deltaTime", MethodType.Getter)]
public static class DeltaTimePatch public static class DeltaTimePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref float __result) __result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
return false;
}
} }
}
[HarmonyPatch(typeof(Time), "unscaledDeltaTime", MethodType.Getter)] [HarmonyPatch(typeof(Time), "unscaledDeltaTime", MethodType.Getter)]
public static class UnscaledDeltaTimePatch public static class UnscaledDeltaTimePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref float __result) __result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
return false;
}
} }
}
[HarmonyPatch(typeof(Screen), "width", MethodType.Getter)] [HarmonyPatch(typeof(Screen), "width", MethodType.Getter)]
public static class ScreenWidthPatch public static class ScreenWidthPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsActive || !RenderContext.ScreenPatch || !RenderManager.Instance) return true;
public static bool Prefix(ref int __result) __result = RenderManager.Instance.Configuration.RenderWidth;
{ return false;
if (!RenderManager.IsActive || !RenderContext.ScreenPatch || !RenderManager.Instance) return true;
__result = RenderManager.Instance.Configuration.RenderWidth;
return false;
}
} }
}
[HarmonyPatch(typeof(Screen), "height", MethodType.Getter)] [HarmonyPatch(typeof(Screen), "height", MethodType.Getter)]
public static class ScreenHeightPatch public static class ScreenHeightPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsActive || !RenderContext.ScreenPatch || !RenderManager.Instance) return true;
public static bool Prefix(ref int __result) __result = RenderManager.Instance.Configuration.RenderHeight;
{ return false;
if (!RenderManager.IsActive || !RenderContext.ScreenPatch || !RenderManager.Instance) return true;
__result = RenderManager.Instance.Configuration.RenderHeight;
return false;
}
} }
}
[HarmonyPatch(typeof(AudioSource), "GetSpectrumData", typeof(float[]), typeof(int), typeof(FFTWindow))] [HarmonyPatch(typeof(AudioSource), "GetSpectrumData", typeof(float[]), typeof(int), typeof(FFTWindow))]
public static class GetSpectrumDataPatch public static class GetSpectrumDataPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(AudioSource __instance, float[] samples, int channel, FFTWindow window)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio) return true;
public static bool Prefix(AudioSource __instance, float[] samples, int channel, FFTWindow window) AudioSpectrumGenerator.GetSpectrumData(samples, channel, window);
{ return false;
if (!RenderManager.IsCapturingActive || !RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio) return true;
AudioSpectrumGenerator.GetSpectrumData(samples, channel, window);
return false;
}
} }
}
[HarmonyPatch(typeof(AudioManager), "FindOrLoadAudioClipExternal", typeof(string), typeof(bool), typeof(float), typeof(bool))] [HarmonyPatch(typeof(AudioManager), "FindOrLoadAudioClipExternal", typeof(string), typeof(bool), typeof(float), typeof(bool))]
public static class FindOrLoadAudioClipExternalPatch public static class FindOrLoadAudioClipExternalPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static void Prefix(ref bool stream)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (RenderManager.Instance && RenderManager.Instance.OverrideLoading && RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio)
public static void Prefix(ref bool stream) stream = false;
{
if (RenderManager.Instance && RenderManager.Instance.OverrideLoading && RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio)
stream = false;
}
} }
} }
} }
+56 -57
View File
@@ -2,73 +2,72 @@ using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(EndscreenLanterns))]
// ReSharper disable once InconsistentNaming
public static class EndscreenLanternsPatch
{ {
[HarmonyPatch(typeof(EndscreenLanterns))] [HarmonyPrefix]
// ReSharper disable once InconsistentNaming [HarmonyPatch("Show")]
public static class EndscreenLanternsPatch [SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool ForceHide(EndscreenLanterns __instance, ref int __result)
{ {
[HarmonyPrefix] if (!RenderManager.IsActive) return true;
[HarmonyPatch("Show")] if (__instance != null)
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool ForceHide(EndscreenLanterns __instance, ref int __result)
{ {
if (!RenderManager.IsActive) return true; __instance.Hide();
if (__instance != null)
{
__instance.Hide();
}
__result = 0; // Return 0 lanterns shown
return false; // Skip original Show method
} }
__result = 0; // Return 0 lanterns shown
return false; // Skip original Show method
}
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("Hide")] [HarmonyPatch("Hide")]
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static void PrefixHide(EndscreenLanterns __instance) public static void PrefixHide(EndscreenLanterns __instance)
{
if (__instance == null) return;
if (!RenderManager.IsActive) return;
var traverse = Traverse.Create(__instance);
// Kill lampSequences (private Sequence[] lampSequences) via reflection
var lampSequencesField = traverse.Field("lampSequences");
var lampSequencesObj = lampSequencesField.GetValue();
if (lampSequencesObj is System.Array lampSequencesArray)
{ {
if (__instance == null) return; for (var i = 0; i < lampSequencesArray.Length; i++)
if (!RenderManager.IsActive) return;
var traverse = Traverse.Create(__instance);
// Kill lampSequences (private Sequence[] lampSequences) via reflection
var lampSequencesField = traverse.Field("lampSequences");
var lampSequencesObj = lampSequencesField.GetValue();
if (lampSequencesObj is System.Array lampSequencesArray)
{ {
for (var i = 0; i < lampSequencesArray.Length; i++) var item = lampSequencesArray.GetValue(i);
if (item == null) continue;
try
{ {
var item = lampSequencesArray.GetValue(i); var killMethod = item.GetType().GetMethod("Kill", new[] { typeof(bool) });
if (item == null) continue; killMethod?.Invoke(item, new object[] { false });
try
{
var killMethod = item.GetType().GetMethod("Kill", new[] { typeof(bool) });
killMethod?.Invoke(item, new object[] { false });
}
catch
{
// Ignore reflection errors
}
lampSequencesArray.SetValue(null, i);
} }
catch
{
// Ignore reflection errors
}
lampSequencesArray.SetValue(null, i);
} }
// Kill chainSequence (private Sequence chainSequence) via reflection
var chainSequenceField = traverse.Field("chainSequence");
var chainSequenceObj = chainSequenceField.GetValue();
if (chainSequenceObj == null) return;
try
{
var killMethod = chainSequenceObj.GetType().GetMethod("Kill", new[] { typeof(bool) });
killMethod?.Invoke(chainSequenceObj, new object[] { false });
}
catch
{
// Ignore reflection errors
}
chainSequenceField.SetValue(null);
} }
// Kill chainSequence (private Sequence chainSequence) via reflection
var chainSequenceField = traverse.Field("chainSequence");
var chainSequenceObj = chainSequenceField.GetValue();
if (chainSequenceObj == null) return;
try
{
var killMethod = chainSequenceObj.GetType().GetMethod("Kill", new[] { typeof(bool) });
killMethod?.Invoke(chainSequenceObj, new object[] { false });
}
catch
{
// Ignore reflection errors
}
chainSequenceField.SetValue(null);
} }
} }
+12 -13
View File
@@ -1,22 +1,21 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(Persistence))]
public static class PersistencePatch
{ {
[HarmonyPatch(typeof(Persistence))] [HarmonyPostfix]
public static class PersistencePatch [HarmonyPatch("skipIntroBehavior", MethodType.Getter)]
// ReSharper disable once InconsistentNaming
public static void LockSkipIntroBehavior(ref SkipIntroBehavior __result)
{ {
[HarmonyPostfix] if (RenderManager.IsActive &&
[HarmonyPatch("skipIntroBehavior", MethodType.Getter)] scrConductor.instance != null &&
// ReSharper disable once InconsistentNaming !scrConductor.instance.hasSongStarted)
public static void LockSkipIntroBehavior(ref SkipIntroBehavior __result)
{ {
if (RenderManager.IsActive && __result = SkipIntroBehavior.Off;
scrConductor.instance != null &&
!scrConductor.instance.hasSongStarted)
{
__result = SkipIntroBehavior.Off;
}
} }
} }
} }
+37 -38
View File
@@ -4,48 +4,47 @@ using MelonLoader;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scnEditor))]
// ReSharper disable once InconsistentNaming
public static class scnEditorPatch
{ {
[HarmonyPatch(typeof(scnEditor))] [HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static class scnEditorPatch public static void AddButtonRender(scnEditor __instance)
{ {
[HarmonyPostfix] if (RenderMod.IsFeatureDisabled) return;
[HarmonyPatch("Start")] if (!__instance || !__instance.buttonExit)
// ReSharper disable once InconsistentNaming
public static void AddButtonRender(scnEditor __instance)
{ {
if (RenderMod.IsFeatureDisabled) return; Melon<RenderMod>.Logger.Msg("scnEditor에 렌더 버튼을 만들 수 없습니다.");
if (!__instance || !__instance.buttonExit) return;
{
Melon<RenderMod>.Logger.Msg("scnEditor에 렌더 버튼을 만들 수 없습니다.");
return;
}
var cloneTarget = __instance.buttonExit!.gameObject;
var buttonContainer = cloneTarget.transform.parent!;
var buttonObj = Object.Instantiate(cloneTarget, buttonContainer)!;
buttonObj.name = "buttonRender";
var button = buttonObj.GetComponent<Button>()!;
// Clear persistent and non-persistent listeners from the original button
button.onClick = new Button.ButtonClickedEvent();
button.onClick.AddListener(RenderManager.StartRender);
// The button root has an Image component for the background (rounded box).
// We want to change the icon sprite, which is on a child GameObject.
var images = buttonObj.GetComponentsInChildren<Image>();
if (images.Length <= 1) return;
// ReSharper disable once Unity.UnknownResource
// images[0] is the background on the root, images[1] is the icon on the child
var sprite = Resources.Load<Sprite>("LevelEditor/LevelEvents/MoveCamera");
if (sprite == null) return;
var iconImage = images[1];
iconImage.sprite = sprite;
iconImage.rectTransform.sizeDelta = new Vector2(24, 24);
} }
var cloneTarget = __instance.buttonExit!.gameObject;
var buttonContainer = cloneTarget.transform.parent!;
var buttonObj = Object.Instantiate(cloneTarget, buttonContainer)!;
buttonObj.name = "buttonRender";
var button = buttonObj.GetComponent<Button>()!;
// Clear persistent and non-persistent listeners from the original button
button.onClick = new Button.ButtonClickedEvent();
button.onClick.AddListener(RenderManager.StartRender);
// The button root has an Image component for the background (rounded box).
// We want to change the icon sprite, which is on a child GameObject.
var images = buttonObj.GetComponentsInChildren<Image>();
if (images.Length <= 1) return;
// ReSharper disable once Unity.UnknownResource
// images[0] is the background on the root, images[1] is the icon on the child
var sprite = Resources.Load<Sprite>("LevelEditor/LevelEvents/MoveCamera");
if (sprite == null) return;
var iconImage = images[1];
iconImage.sprite = sprite;
iconImage.rectTransform.sizeDelta = new Vector2(24, 24);
} }
} }
+120 -121
View File
@@ -4,139 +4,138 @@ using ADOFAIRenderer.Util;
using HarmonyLib; using HarmonyLib;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scnGame))]
// ReSharper disable once InconsistentNaming
public static class scnGamePatch
{ {
[HarmonyPatch(typeof(scnGame))] [HarmonyPostfix]
// ReSharper disable once InconsistentNaming [HarmonyPatch("Play")]
public static class scnGamePatch public static void ProcessFloors()
{ {
[HarmonyPostfix] var nonTile = RenderManager.Instance.OverrideLoading &&
[HarmonyPatch("Play")] RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
public static void ProcessFloors()
if (!nonTile) return;
foreach (var floor in scrLevelMaker.instance.listFloors)
{ {
var nonTile = RenderManager.Instance.OverrideLoading && DisableRenderers(
RenderManager.Instance.Configuration.AdvancedConfig.NonTile; floor.floorRenderer.renderer, floor.legacyFloorSpriteRenderer,
floor.iconsprite, floor.outlineSprite,
floor.topGlow, floor.bottomGlow,
floor.multiplanetLine
);
if (!nonTile) return; if (floor.holdRenderer) DisableRenderer(floor.holdRenderer.m_meshRenderer);
foreach (var floor in scrLevelMaker.instance.listFloors)
{
DisableRenderers(
floor.floorRenderer.renderer, floor.legacyFloorSpriteRenderer,
floor.iconsprite, floor.outlineSprite,
floor.topGlow, floor.bottomGlow,
floor.multiplanetLine
);
if (floor.holdRenderer) DisableRenderer(floor.holdRenderer.m_meshRenderer); if (floor.dummyPlanets == null) continue;
foreach (var planet in floor.dummyPlanets)
if (floor.dummyPlanets == null) continue; DisablePlanetRenderer(planet);
foreach (var planet in floor.dummyPlanets)
DisablePlanetRenderer(planet);
}
var players = scrPlayerManager.instance.players;
if (players == null) return;
foreach (var player in players)
{
var planets = player.planetarySystem?.planetList;
if (planets == null) continue;
foreach (var planet in planets)
DisablePlanetRenderer(planet.planetRenderer);
}
return;
void DisablePlanetRenderer(PlanetRenderer planet)
{
if (planet.sprite) DisableRenderer(planet.sprite.meshRenderer);
DisableRenderers(
planet.coreParticles.GetRenderer(), planet.tailParticles.GetRenderer(),
planet.tailParticlesCoop.GetRenderer(), planet.sparks.GetRenderer(), planet.ring, planet.glow,
planet.faceSprite, planet.faceDetails, planet.samuraiSprite
);
}
void DisableRenderers(params Renderer?[] renderers)
{
foreach (var renderer in renderers) DisableRenderer(renderer);
}
void DisableRenderer(Renderer? renderer)
{
if (renderer) renderer.enabled = false;
}
} }
[HarmonyPrefix] var players = scrPlayerManager.instance.players;
[HarmonyPatch("UpdateVideo")] if (players == null) return;
// ReSharper disable once InconsistentNaming
public static bool ReplaceVideo(scnGame __instance) foreach (var player in players)
{ {
// Only replace when rendering is active var planets = player.planetarySystem?.planetList;
if (!RenderManager.IsActive) if (planets == null) continue;
{
// Remove FFmpeg player if it was attached previously
var oldPlayer = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (oldPlayer != null)
{
Object.Destroy(oldPlayer);
}
// Re-enable native player component
__instance.videoBG.enabled = true;
return true; // Execute original
}
string text = __instance.levelData.miscSettings.GetString("bgVideo"); foreach (var planet in planets)
if (string.IsNullOrEmpty(text)) DisablePlanetRenderer(planet.planetRenderer);
{ }
__instance.videoBG.gameObject.SetActive(false); return;
return false; // Skip original
}
__instance.videoBG.gameObject.SetActive(true); void DisablePlanetRenderer(PlanetRenderer planet)
{
if (planet.sprite) DisableRenderer(planet.sprite.meshRenderer);
DisableRenderers(
planet.coreParticles.GetRenderer(), planet.tailParticles.GetRenderer(),
planet.tailParticlesCoop.GetRenderer(), planet.sparks.GetRenderer(), planet.ring, planet.glow,
planet.faceSprite, planet.faceDetails, planet.samuraiSprite
);
}
// We can only play external video files via FFmpegNativeDecoder. void DisableRenderers(params Renderer?[] renderers)
// Internal/Bundle level background videos are built-in VideoClips, so we fallback to native Unity VideoPlayer. {
if (ADOBase.isInternalLevel || ADOBase.isBundleLevel) foreach (var renderer in renderers) DisableRenderer(renderer);
{ }
__instance.videoBG.enabled = true;
return true; // Execute original
}
// Custom level video path void DisableRenderer(Renderer? renderer)
var videoPath = Path.Combine(Path.GetDirectoryName(ADOBase.levelPath) ?? "", text); {
if (!File.Exists(videoPath)) if (renderer) renderer.enabled = false;
{
return true; // Let the original print error
}
// Disable native VideoPlayer component (to prevent it from attempting real-time playback and sync errors)
var targetCamera = __instance.videoBG.targetCamera;
if (!targetCamera)
{
var camGo = GameObject.Find("BGStaticCam");
if (camGo != null)
{
targetCamera = camGo.GetComponent<Camera>();
}
}
__instance.videoBG.enabled = false;
// Add or retrieve our custom FFmpeg video background player
var player = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (player == null)
{
player = __instance.videoBG.gameObject.AddComponent<FFmpegVideoBGPlayer>();
}
var isLooping = __instance.levelData.miscSettings.GetBool("loopVideo");
// Initialize player. If targetTexture is null (CameraFarPlane mode), it will build a quad in front of targetCamera.
player.Init(videoPath, __instance.videoBG.targetTexture, isLooping, targetCamera, __instance.videoBG.gameObject);
return false; // Skip original UpdateVideo
} }
} }
[HarmonyPrefix]
[HarmonyPatch("UpdateVideo")]
// ReSharper disable once InconsistentNaming
public static bool ReplaceVideo(scnGame __instance)
{
// Only replace when rendering is active
if (!RenderManager.IsActive)
{
// Remove FFmpeg player if it was attached previously
var oldPlayer = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (oldPlayer != null)
{
Object.Destroy(oldPlayer);
}
// Re-enable native player component
__instance.videoBG.enabled = true;
return true; // Execute original
}
string text = __instance.levelData.miscSettings.GetString("bgVideo");
if (string.IsNullOrEmpty(text))
{
__instance.videoBG.gameObject.SetActive(false);
return false; // Skip original
}
__instance.videoBG.gameObject.SetActive(true);
// We can only play external video files via FFmpegNativeDecoder.
// Internal/Bundle level background videos are built-in VideoClips, so we fall back to native Unity VideoPlayer.
if (ADOBase.isInternalLevel || ADOBase.isBundleLevel)
{
__instance.videoBG.enabled = true;
return true; // Execute original
}
// Custom level video path
var videoPath = Path.Combine(Path.GetDirectoryName(ADOBase.levelPath) ?? "", text);
if (!File.Exists(videoPath))
{
return true; // Let the original print error
}
// Disable native VideoPlayer component (to prevent it from attempting real-time playback and sync errors)
var targetCamera = __instance.videoBG.targetCamera;
if (!targetCamera)
{
var camGo = GameObject.Find("BGStaticCam");
if (camGo != null)
{
targetCamera = camGo.GetComponent<Camera>();
}
}
__instance.videoBG.enabled = false;
// Add or retrieve our custom FFmpeg video background player
var player = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (player == null)
{
player = __instance.videoBG.gameObject.AddComponent<FFmpegVideoBGPlayer>();
}
var isLooping = __instance.levelData.miscSettings.GetBool("loopVideo");
// Initialize player. If targetTexture is null (CameraFarPlane mode), it will build a quad in front of targetCamera.
player.Init(videoPath, __instance.videoBG.targetTexture, isLooping, targetCamera, __instance.videoBG.gameObject);
return false; // Skip original UpdateVideo
}
} }
+16 -17
View File
@@ -1,23 +1,22 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
{
[HarmonyPatch(typeof(scrCamera))]
// ReSharper disable once InconsistentNaming
public class scrCameraPatch
{
[HarmonyPrefix]
[HarmonyPatch("SetCustomFrameRate")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
private static bool FixCamRT(scrCamera __instance, bool enable, float frame)
{
// There is an issue that `mainTexture.Release` triggers even if `mainTexture` is `camRT`, so mod tries to fix it
if (enable || __instance.enableCustomFPS != enable) return true;
__instance.enableCustomFPS = enable; [HarmonyPatch(typeof(scrCamera))]
__instance.frameRate = frame; // ReSharper disable once InconsistentNaming
return false; public class scrCameraPatch
} {
[HarmonyPrefix]
[HarmonyPatch("SetCustomFrameRate")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
private static bool FixCamRT(scrCamera __instance, bool enable, float frame)
{
// There is an issue that `mainTexture.Release` triggers even if `mainTexture` is `camRT`, so mod tries to fix it
if (enable || __instance.enableCustomFPS != enable) return true;
__instance.enableCustomFPS = enable;
__instance.frameRate = frame;
return false;
} }
} }
+33 -34
View File
@@ -1,45 +1,44 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrController))]
// ReSharper disable once InconsistentNaming
public static class scrControllerPatch
{ {
[HarmonyPatch(typeof(scrController))] [HarmonyPrefix]
// ReSharper disable once InconsistentNaming [HarmonyPatch("TogglePauseGame")]
public static class scrControllerPatch public static bool PreventPause()
{ {
[HarmonyPrefix] return !RenderManager.IsActive;
[HarmonyPatch("TogglePauseGame")] }
public static bool PreventPause()
{
return !RenderManager.IsActive;
}
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("PortalTravelAction")] [HarmonyPatch("PortalTravelAction")]
public static bool PreventPortalTravel() public static bool PreventPortalTravel()
{ {
return !RenderManager.IsActive; return !RenderManager.IsActive;
} }
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("FailAction")] [HarmonyPatch("FailAction")]
public static bool PreventFailAction() public static bool PreventFailAction()
{ {
return !RenderManager.IsActive; return !RenderManager.IsActive;
} }
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("Fail2Action")] [HarmonyPatch("Fail2Action")]
public static bool PreventFail2Action() public static bool PreventFail2Action()
{ {
return !RenderManager.IsActive; return !RenderManager.IsActive;
} }
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("OnPlayerDied")] [HarmonyPatch("OnPlayerDied")]
public static bool PreventPlayerDied() public static bool PreventPlayerDied()
{ {
return !RenderManager.IsActive; return !RenderManager.IsActive;
}
} }
} }
+30 -31
View File
@@ -1,41 +1,40 @@
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrDecoration))]
// ReSharper disable once InconsistentNaming
public static class scrDecorationPatch
{ {
[HarmonyPatch(typeof(scrDecoration))] [HarmonyPrefix]
[HarmonyPatch("SetRotation")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static class scrDecorationPatch public static bool SetRotationPrefix(scrDecoration __instance)
{ {
[HarmonyPrefix] return __instance != null && __instance && __instance.pivotTrans != null;
[HarmonyPatch("SetRotation")] }
// ReSharper disable once InconsistentNaming
public static bool SetRotationPrefix(scrDecoration __instance)
{
return __instance != null && __instance && __instance.pivotTrans != null;
}
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("SetScale")] [HarmonyPatch("SetScale")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static bool SetScalePrefix(scrDecoration __instance) public static bool SetScalePrefix(scrDecoration __instance)
{ {
return __instance != null && __instance && __instance.pivotTrans != null; return __instance != null && __instance && __instance.pivotTrans != null;
} }
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("SetOpacity")] [HarmonyPatch("SetOpacity")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static bool SetOpacityPrefix(scrDecoration __instance) public static bool SetOpacityPrefix(scrDecoration __instance)
{ {
return __instance != null && __instance; return __instance != null && __instance;
} }
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("UpdatePosition")] [HarmonyPatch("UpdatePosition")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static bool UpdatePositionPrefix(scrDecoration __instance) public static bool UpdatePositionPrefix(scrDecoration __instance)
{ {
return __instance != null && __instance && __instance.pivotTrans != null; return __instance != null && __instance && __instance.pivotTrans != null;
}
} }
} }
+15 -16
View File
@@ -1,22 +1,21 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
{
[HarmonyPatch(typeof(scrFloor))]
// ReSharper disable once InconsistentNaming
public class scrFloorPatch
{
[HarmonyPrefix]
[HarmonyPatch("SpawnPortalParticles")]
// ReSharper disable once InconsistentNaming
public static bool SkipPortalSpawn(scrFloor __instance)
{
if (!scrLevelMaker.instance.listFloors.Contains(__instance)) return true;
var nonTile = RenderManager.Instance.OverrideLoading && [HarmonyPatch(typeof(scrFloor))]
RenderManager.Instance.Configuration.AdvancedConfig.NonTile; // ReSharper disable once InconsistentNaming
return !nonTile; public class scrFloorPatch
} {
[HarmonyPrefix]
[HarmonyPatch("SpawnPortalParticles")]
// ReSharper disable once InconsistentNaming
public static bool SkipPortalSpawn(scrFloor __instance)
{
if (!scrLevelMaker.instance.listFloors.Contains(__instance)) return true;
var nonTile = RenderManager.Instance.OverrideLoading &&
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
return !nonTile;
} }
} }
+7 -8
View File
@@ -1,15 +1,14 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrHitTextManager), "ShowHitText")]
// ReSharper disable once InconsistentNaming
public class scrHitTextManagerPatch
{ {
[HarmonyPatch(typeof(scrHitTextManager), "ShowHitText")] public static bool Prefix()
// ReSharper disable once InconsistentNaming
public class scrHitTextManagerPatch
{ {
public static bool Prefix() return !RenderManager.IsActive || RenderManager.Instance is { Configuration: { ShowJudgementText: true } };
{
return !RenderManager.IsActive || RenderManager.Instance is { Configuration: { ShowJudgementText: true } };
}
} }
} }
+9 -10
View File
@@ -1,17 +1,16 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrMistakesManager))]
// ReSharper disable once InconsistentNaming
public static class scrMistakesManagerPatch
{ {
[HarmonyPatch(typeof(scrMistakesManager))] [HarmonyPrefix]
// ReSharper disable once InconsistentNaming [HarmonyPatch("SaveCustom")]
public static class scrMistakesManagerPatch public static bool PreventSaveCustom()
{ {
[HarmonyPrefix] return !RenderManager.IsActive;
[HarmonyPatch("SaveCustom")]
public static bool PreventSaveCustom()
{
return !RenderManager.IsActive;
}
} }
} }
+14 -15
View File
@@ -1,21 +1,20 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
{
[HarmonyPatch(typeof(scrPlayerManager))]
// ReSharper disable once InconsistentNaming
public static class scrPlayerManagerPatch
{
[HarmonyPrefix]
[HarmonyPatch("AnyValidInputWasTriggered")]
// ReSharper disable once InconsistentNaming
private static bool IgnoreInput(ref bool __result)
{
if (!RenderManager.IsActive) return true;
__result = RenderManager.Instance && RenderManager.Instance.frameCount == 0 && RenderManager.Instance.countdown == 0; [HarmonyPatch(typeof(scrPlayerManager))]
return false; // ReSharper disable once InconsistentNaming
} public static class scrPlayerManagerPatch
{
[HarmonyPrefix]
[HarmonyPatch("AnyValidInputWasTriggered")]
// ReSharper disable once InconsistentNaming
private static bool IgnoreInput(ref bool __result)
{
if (!RenderManager.IsActive) return true;
__result = RenderManager.Instance && RenderManager.Instance.frameCount == 0 && RenderManager.Instance.countdown == 0;
return false;
} }
} }
+16 -17
View File
@@ -2,28 +2,27 @@ using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrPortalParticles))]
// ReSharper disable once InconsistentNaming
public static class scrPortalParticlesPatch
{ {
[HarmonyPatch(typeof(scrPortalParticles))] [HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static class scrPortalParticlesPatch public static void ForceScaledTime(scrPortalParticles __instance)
{ {
[HarmonyPostfix] if (!RenderManager.Instance || !RenderManager.Instance.OverrideLoading) return;
[HarmonyPatch("Start")] if (__instance == null) return;
// ReSharper disable once InconsistentNaming
public static void ForceScaledTime(scrPortalParticles __instance) var particleSystems = __instance.GetComponentsInChildren<ParticleSystem>(true);
foreach (var ps in particleSystems)
{ {
if (!RenderManager.Instance || !RenderManager.Instance.OverrideLoading) return; if (ps == null) continue;
if (__instance == null) return;
var particleSystems = __instance.GetComponentsInChildren<ParticleSystem>(true); var main = ps.main;
foreach (var ps in particleSystems) main.useUnscaledTime = false;
{
if (ps == null) continue;
var main = ps.main;
main.useUnscaledTime = false;
}
} }
} }
} }
+13 -14
View File
@@ -1,20 +1,19 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
{
[HarmonyPatch(typeof(scrPressToStart))]
// ReSharper disable once InconsistentNaming
public class scrPressToStartPatch
{
[HarmonyPrefix]
[HarmonyPatch("ShowText")]
private static bool PreventShowText()
{
if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true;
RenderManager.Instance.skipPressToStart = false; [HarmonyPatch(typeof(scrPressToStart))]
return false; // ReSharper disable once InconsistentNaming
} public class scrPressToStartPatch
{
[HarmonyPrefix]
[HarmonyPatch("ShowText")]
private static bool PreventShowText()
{
if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true;
RenderManager.Instance.skipPressToStart = false;
return false;
} }
} }
+21 -22
View File
@@ -4,34 +4,33 @@ using HarmonyLib;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrShowIfDebug))]
// ReSharper disable once InconsistentNaming
public static class scrShowIfDebugPatch
{ {
[HarmonyPatch(typeof(scrShowIfDebug))] [HarmonyPostfix]
// ReSharper disable once InconsistentNaming [HarmonyPatch("Update")]
public static class scrShowIfDebugPatch [SuppressMessage("ReSharper", "InconsistentNaming")]
private static void IgnoreInput(Text ___txt, Color ___defaultColor)
{ {
[HarmonyPostfix] if (!RenderManager.IsActive) return;
[HarmonyPatch("Update")]
[SuppressMessage("ReSharper", "InconsistentNaming")] ___txt.text = "Render";
private static void IgnoreInput(Text ___txt, Color ___defaultColor) if (RenderManager.Instance)
{ {
if (!RenderManager.IsActive) return; if (RenderManager.Instance.countdown > 0) ___txt.text = $"Render in {RenderManager.Instance.countdown}s..";
___txt.text = "Render"; if (RenderManager.Instance.wonFrame != -1)
if (RenderManager.Instance)
{ {
if (RenderManager.Instance.countdown > 0) ___txt.text = $"Render in {RenderManager.Instance.countdown}s.."; var elapsedFrames = RenderManager.Instance.frameCount - RenderManager.Instance.wonFrame;
var remainingTime = RenderManager.Instance.Configuration.DelayAfterEnd - elapsedFrames / RenderManager.Instance.Configuration.RenderFPS;
if (RenderManager.Instance.wonFrame != -1) if (remainingTime < 0) remainingTime = 0;
{ ___txt.text += $"\nWaiting for {remainingTime:F1}s..";
var elapsedFrames = RenderManager.Instance.frameCount - RenderManager.Instance.wonFrame;
var remainingTime = RenderManager.Instance.Configuration.DelayAfterEnd - elapsedFrames / RenderManager.Instance.Configuration.RenderFPS;
if (remainingTime < 0) remainingTime = 0;
___txt.text += $"\nWaiting for {remainingTime:F1}s..";
}
} }
___txt.color = ___defaultColor;
} }
___txt.color = ___defaultColor;
} }
} }
+10 -11
View File
@@ -1,21 +1,20 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrUIController))]
// ReSharper disable once InconsistentNaming
public class scrUIControllerPatch
{ {
[HarmonyPatch(typeof(scrUIController))] [HarmonyPostfix]
[HarmonyPatch("ShowEndscreenLanterns")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public class scrUIControllerPatch public static void PreventEndscreenLanternsPostfix(scrUIController __instance)
{ {
[HarmonyPostfix] if (RenderManager.IsActive && __instance != null)
[HarmonyPatch("ShowEndscreenLanterns")]
// ReSharper disable once InconsistentNaming
public static void PreventEndscreenLanternsPostfix(scrUIController __instance)
{ {
if (RenderManager.IsActive && __instance != null) __instance.HideEndscreenLanterns();
{
__instance.HideEndscreenLanterns();
}
} }
} }
} }
+1 -1
View File
@@ -7,7 +7,7 @@ using MelonLoader.Utils;
using UnityEngine; using UnityEngine;
using UnityEngine.EventSystems; using UnityEngine.EventSystems;
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.1.1-INDEV", "ADOFAI Editor Forum")] [assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.1.2-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")] [assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
namespace ADOFAIRenderer namespace ADOFAIRenderer
+311 -310
View File
@@ -7,317 +7,318 @@ using UnityEngine.UIElements;
using Button = UnityEngine.UI.Button; using Button = UnityEngine.UI.Button;
using Image = UnityEngine.UI.Image; using Image = UnityEngine.UI.Image;
namespace ADOFAIRenderer.UI namespace ADOFAIRenderer.UI;
internal class DialogButton
{ {
internal class DialogButton // ReSharper disable FieldCanBeMadeReadOnly.Global
public string Text;
public Action OnClick;
public Color? BackgroundColor;
// ReSharper restore FieldCanBeMadeReadOnly.Global
public DialogButton(string text, Action onClick, Color? backgroundColor = null)
{ {
public string Text; Text = text;
public Action OnClick; OnClick = onClick;
public Color? BackgroundColor; BackgroundColor = backgroundColor;
}
public DialogButton(string text, Action onClick, Color? backgroundColor = null) }
{
Text = text; internal class CommonWarningDialogUI : MonoBehaviour
OnClick = onClick; {
BackgroundColor = backgroundColor; public static CommonWarningDialogUI? Instance { get; private set; }
} private readonly List<VisualElement> _disabledElements = new();
}
private void Awake()
internal class CommonWarningDialogUI : MonoBehaviour {
{ Instance = this;
public static CommonWarningDialogUI? Instance { get; private set; } }
private readonly List<VisualElement> _disabledElements = new();
private void OnDestroy()
private void Awake() {
{ foreach (var elem in _disabledElements)
Instance = this; {
} if (elem != null)
{
private void OnDestroy() elem.SetEnabled(true);
{ }
foreach (var elem in _disabledElements) }
{ _disabledElements.Clear();
if (elem != null)
{ if (Instance == this)
elem.SetEnabled(true); {
} Instance = null;
} }
_disabledElements.Clear(); }
if (Instance == this) private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
{ {
Instance = null; TMP_FontAsset? font = null;
} if (RenderMod.FontBundle)
} {
try
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset() {
{ font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
TMP_FontAsset? font = null; }
if (RenderMod.FontBundle) catch
{ {
try // 로딩 실패
{ }
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF"); }
}
catch // 폴백 처리
{ if (!font)
// 로딩 실패 {
} try
} {
font = RDString.fontData.fontTMP;
// 폴백 처리 }
if (!font) catch
{ {
try // ignored
{ }
font = RDString.fontData.fontTMP; }
} if (!font)
catch {
{ // ReSharper disable once Unity.UnknownResource
// ignored font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
} }
}
if (!font) if (font) return font;
{ var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
// ReSharper disable once Unity.UnknownResource if (fonts.Length > 0) font = fonts[0];
font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
} return font!;
}
if (font) return font;
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>(); public static void ShowDialog(string title, string description, params DialogButton[] buttons)
if (fonts.Length > 0) font = fonts[0]; {
if (buttons.Length == 0) return;
return font!;
} var obj = new GameObject("CommonWarningDialogUI");
DontDestroyOnLoad(obj);
public static void ShowDialog(string title, string description, params DialogButton[] buttons) var dialog = obj.AddComponent<CommonWarningDialogUI>();
{ dialog.Initialize(title, description, buttons);
if (buttons.Length == 0) return; }
var obj = new GameObject("CommonWarningDialogUI"); private void Initialize(string title, string description, DialogButton[] buttons)
DontDestroyOnLoad(obj); {
var dialog = obj.AddComponent<CommonWarningDialogUI>(); if (!UnityEngine.EventSystems.EventSystem.current)
dialog.Initialize(title, description, buttons); {
} var eventSystemObj = new GameObject("EventSystem");
eventSystemObj.transform.SetParent(transform);
private void Initialize(string title, string description, DialogButton[] buttons) eventSystemObj.AddComponent<UnityEngine.EventSystems.EventSystem>();
{ eventSystemObj.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
if (!UnityEngine.EventSystems.EventSystem.current) }
{
var eventSystemObj = new GameObject("EventSystem"); foreach (var doc in FindObjectsByType<UIDocument>(FindObjectsSortMode.None))
eventSystemObj.transform.SetParent(transform); {
eventSystemObj.AddComponent<UnityEngine.EventSystems.EventSystem>(); if (doc && doc.rootVisualElement is { enabledSelf: true })
eventSystemObj.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>(); {
} doc.rootVisualElement.SetEnabled(false);
_disabledElements.Add(doc.rootVisualElement);
foreach (var doc in FindObjectsByType<UIDocument>(FindObjectsSortMode.None)) }
{ }
if (doc && doc.rootVisualElement != null && doc.rootVisualElement.enabledSelf)
{ // UI 레이어(5) 설정
doc.rootVisualElement.SetEnabled(false); gameObject.layer = 5;
_disabledElements.Add(doc.rootVisualElement);
} // Canvas 설정
} var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
// UI 레이어(5) 설정 canvas.sortingOrder = 32766; // FFmpegDownloadUI(32767) 바로 아래
gameObject.layer = 5;
// 해상도 대응 캔버스 스케일러 설정
// Canvas 설정 var scaler = gameObject.AddComponent<CanvasScaler>();
var canvas = gameObject.AddComponent<Canvas>(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
canvas.renderMode = RenderMode.ScreenSpaceOverlay; scaler.referenceResolution = new Vector2(1920, 1080);
canvas.sortingOrder = 32766; // FFmpegDownloadUI(32767) 바로 아래 scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
// 해상도 대응 캔버스 스케일러 설정
var scaler = gameObject.AddComponent<CanvasScaler>(); gameObject.AddComponent<GraphicRaycaster>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080); // 1. 전체 화면 오버레이 배경 생성
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight; var bgPanelObj = new GameObject("BackgroundPanel");
scaler.matchWidthOrHeight = 0.5f; bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5;
gameObject.AddComponent<GraphicRaycaster>();
var bgImage = bgPanelObj.AddComponent<Image>();
// 1. 전체 화면 오버레이 배경 생성 bgImage.color = new Color(0f, 0f, 0f, 0.65f); // 어두운 투명 오버레이
var bgPanelObj = new GameObject("BackgroundPanel"); bgImage.raycastTarget = true;
bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5; var bgRect = bgPanelObj.GetComponent<RectTransform>();
bgRect.anchorMin = Vector2.zero;
var bgImage = bgPanelObj.AddComponent<Image>(); bgRect.anchorMax = Vector2.one;
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // 어두운 투명 오버레이 bgRect.offsetMin = Vector2.zero;
bgImage.raycastTarget = true; bgRect.offsetMax = Vector2.zero;
var bgRect = bgPanelObj.GetComponent<RectTransform>(); var font = GetNanumSquareNeoBdFontAsset();
bgRect.anchorMin = Vector2.zero;
bgRect.anchorMax = Vector2.one; // 2. 모달 컨테이너 생성
bgRect.offsetMin = Vector2.zero; var modalContainerObj = new GameObject("ModalContainer");
bgRect.offsetMax = Vector2.zero; modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
modalContainerObj.layer = 5;
var font = GetNanumSquareNeoBdFontAsset();
var modalImage = modalContainerObj.AddComponent<Image>();
// 2. 모달 컨테이너 생성 modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 기존 UI의 배경 톤과 동일
var modalContainerObj = new GameObject("ModalContainer");
modalContainerObj.transform.SetParent(bgPanelObj.transform, false); var modalOutline = modalContainerObj.AddComponent<Outline>();
modalContainerObj.layer = 5; modalOutline.effectColor = Color.white;
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // 기존 UI 외곽선 스타일 일관성
var modalImage = modalContainerObj.AddComponent<Image>();
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 기존 UI의 배경 톤과 동일 var modalRect = modalContainerObj.GetComponent<RectTransform>();
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
var modalOutline = modalContainerObj.AddComponent<Outline>(); modalRect.anchorMax = new Vector2(0.5f, 0.5f);
modalOutline.effectColor = Color.white; modalRect.pivot = new Vector2(0.5f, 0.5f);
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // 기존 UI 외곽선 스타일 일관성 modalRect.sizeDelta = new Vector2(650, 360);
var modalRect = modalContainerObj.GetComponent<RectTransform>(); // 3. 타이틀 텍스트 생성
modalRect.anchorMin = new Vector2(0.5f, 0.5f); var titleObj = new GameObject("TitleText");
modalRect.anchorMax = new Vector2(0.5f, 0.5f); titleObj.transform.SetParent(modalContainerObj.transform, false);
modalRect.pivot = new Vector2(0.5f, 0.5f); titleObj.layer = 5;
modalRect.sizeDelta = new Vector2(650, 360);
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
// 3. 타이틀 텍스트 생성 titleText.text = title;
var titleObj = new GameObject("TitleText"); titleText.fontSize = 24;
titleObj.transform.SetParent(modalContainerObj.transform, false); titleText.font = font;
titleObj.layer = 5; titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // 경고 테마에 맞는 주황/황색 계열
titleText.alignment = TextAlignmentOptions.Center;
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
titleText.text = title; var titleRect = titleObj.GetComponent<RectTransform>();
titleText.fontSize = 24; titleRect.anchorMin = new Vector2(0, 1);
titleText.font = font; titleRect.anchorMax = new Vector2(1, 1);
titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // 경고 테마에 맞는 주황/황색 계열 titleRect.pivot = new Vector2(0.5f, 1);
titleText.alignment = TextAlignmentOptions.Center; titleRect.anchoredPosition = new Vector2(0, -35);
titleRect.sizeDelta = new Vector2(0, 40);
var titleRect = titleObj.GetComponent<RectTransform>();
titleRect.anchorMin = new Vector2(0, 1); // 4. 본문 설명 텍스트 생성
titleRect.anchorMax = new Vector2(1, 1); var descObj = new GameObject("DescriptionText");
titleRect.pivot = new Vector2(0.5f, 1); descObj.transform.SetParent(modalContainerObj.transform, false);
titleRect.anchoredPosition = new Vector2(0, -35); descObj.layer = 5;
titleRect.sizeDelta = new Vector2(0, 40);
var descText = descObj.AddComponent<TextMeshProUGUI>();
// 4. 본문 설명 텍스트 생성 descText.text = description;
var descObj = new GameObject("DescriptionText"); descText.fontSize = 20;
descObj.transform.SetParent(modalContainerObj.transform, false); descText.font = font;
descObj.layer = 5; descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
descText.alignment = TextAlignmentOptions.Center;
var descText = descObj.AddComponent<TextMeshProUGUI>(); descText.lineSpacing = 8;
descText.text = description;
descText.fontSize = 20; var descRect = descObj.GetComponent<RectTransform>();
descText.font = font; descRect.anchorMin = new Vector2(0, 0.5f);
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f); descRect.anchorMax = new Vector2(1, 1);
descText.alignment = TextAlignmentOptions.Center; descRect.pivot = new Vector2(0.5f, 0.5f);
descText.lineSpacing = 8; descRect.anchoredPosition = new Vector2(0, -65);
descRect.sizeDelta = new Vector2(-40, 120);
var descRect = descObj.GetComponent<RectTransform>();
descRect.anchorMin = new Vector2(0, 0.5f); // 5. 버튼 컨테이너 생성
descRect.anchorMax = new Vector2(1, 1); var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
descRect.pivot = new Vector2(0.5f, 0.5f); buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
descRect.anchoredPosition = new Vector2(0, -65); buttonContainerObj.layer = 5;
descRect.sizeDelta = new Vector2(-40, 120);
var btnContainerRect = buttonContainerObj.GetComponent<RectTransform>();
// 5. 버튼 컨테이너 생성 btnContainerRect.anchorMin = new Vector2(0, 0);
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform)); btnContainerRect.anchorMax = new Vector2(1, 0);
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false); btnContainerRect.pivot = new Vector2(0.5f, 0);
buttonContainerObj.layer = 5; btnContainerRect.anchoredPosition = new Vector2(0, 35);
btnContainerRect.sizeDelta = new Vector2(0, 50);
var btnContainerRect = buttonContainerObj.GetComponent<RectTransform>();
btnContainerRect.anchorMin = new Vector2(0, 0); // 6. 가변 버튼 동적 배치 및 스타일링
btnContainerRect.anchorMax = new Vector2(1, 0); float totalWidth = 560f;
btnContainerRect.pivot = new Vector2(0.5f, 0); float spacing = 15f;
btnContainerRect.anchoredPosition = new Vector2(0, 35); float buttonHeight = 45f;
btnContainerRect.sizeDelta = new Vector2(0, 50); int numButtons = buttons.Length;
// 6. 가변 버튼 동적 배치 및 스타일링 float buttonWidth = numButtons == 1
float totalWidth = 560f; ? 200f
float spacing = 15f; : (totalWidth - (spacing * (numButtons - 1))) / numButtons;
float buttonHeight = 45f;
int numButtons = buttons.Length; float startX = numButtons == 1
? 0
float buttonWidth = numButtons == 1 : (-totalWidth / 2f) + (buttonWidth / 2f);
? 200f
: (totalWidth - (spacing * (numButtons - 1))) / numButtons; for (int i = 0; i < numButtons; i++)
{
float startX = numButtons == 1 var btnData = buttons[i];
? 0 var btnObj = new GameObject($"Button_{i}");
: (-totalWidth / 2f) + (buttonWidth / 2f); btnObj.transform.SetParent(buttonContainerObj.transform, false);
btnObj.layer = 5;
for (int i = 0; i < numButtons; i++)
{ // 기본 색상 정의
var btnData = buttons[i]; Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // 커스텀 색상 또는 회색톤 기본값
var btnObj = new GameObject($"Button_{i}"); Color hoverColor = ApplyColorBrightness(baseColor, 1.2f);
btnObj.transform.SetParent(buttonContainerObj.transform, false); Color pressedColor = ApplyColorBrightness(baseColor, 0.8f);
btnObj.layer = 5;
var btnImage = btnObj.AddComponent<Image>();
// 기본 색상 정의 btnImage.color = baseColor;
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // 커스텀 색상 또는 회색톤 기본값 btnImage.raycastTarget = true;
Color hoverColor = ApplyColorBrightness(baseColor, 1.2f);
Color pressedColor = ApplyColorBrightness(baseColor, 0.8f); var btn = btnObj.AddComponent<Button>();
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor);
var btnImage = btnObj.AddComponent<Image>();
btnImage.color = baseColor; // 클릭 콜백 등록
btnImage.raycastTarget = true; btn.onClick.AddListener(() =>
{
var btn = btnObj.AddComponent<Button>(); try
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor); {
btnData.OnClick();
// 클릭 콜백 등록 }
btn.onClick.AddListener(() => catch (Exception ex)
{ {
try MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
{ }
btnData.OnClick(); Destroy(gameObject);
} });
catch (Exception ex)
{ var btnRect = btnObj.GetComponent<RectTransform>();
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}"); btnRect.anchorMin = new Vector2(0.5f, 0.5f);
} btnRect.anchorMax = new Vector2(0.5f, 0.5f);
Destroy(gameObject); btnRect.pivot = new Vector2(0.5f, 0.5f);
});
float posX = numButtons == 1
var btnRect = btnObj.GetComponent<RectTransform>(); ? 0
btnRect.anchorMin = new Vector2(0.5f, 0.5f); : startX + i * (buttonWidth + spacing);
btnRect.anchorMax = new Vector2(0.5f, 0.5f);
btnRect.pivot = new Vector2(0.5f, 0.5f); btnRect.anchoredPosition = new Vector2(posX, 0);
btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight);
float posX = numButtons == 1
? 0 // 텍스트 라벨 추가
: startX + i * (buttonWidth + spacing); var txtObj = new GameObject("Text");
txtObj.transform.SetParent(btnObj.transform, false);
btnRect.anchoredPosition = new Vector2(posX, 0); txtObj.layer = 5;
btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight);
var text = txtObj.AddComponent<TextMeshProUGUI>();
// 텍스트 라벨 추가 text.text = btnData.Text;
var txtObj = new GameObject("Text"); text.fontSize = 20;
txtObj.transform.SetParent(btnObj.transform, false); text.font = font;
txtObj.layer = 5; text.color = Color.white;
text.alignment = TextAlignmentOptions.Center;
var text = txtObj.AddComponent<TextMeshProUGUI>();
text.text = btnData.Text; var txtRect = txtObj.GetComponent<RectTransform>();
text.fontSize = 20; txtRect.anchorMin = Vector2.zero;
text.font = font; txtRect.anchorMax = Vector2.one;
text.color = Color.white; txtRect.offsetMin = Vector2.zero;
text.alignment = TextAlignmentOptions.Center; txtRect.offsetMax = Vector2.zero;
}
var txtRect = txtObj.GetComponent<RectTransform>(); }
txtRect.anchorMin = Vector2.zero;
txtRect.anchorMax = Vector2.one; private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
txtRect.offsetMin = Vector2.zero; {
txtRect.offsetMax = Vector2.zero; button.transition = Selectable.Transition.ColorTint;
} var colors = button.colors;
} colors.normalColor = normalColor;
colors.highlightedColor = hoverColor;
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor) colors.pressedColor = pressedColor;
{ colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
button.transition = Selectable.Transition.ColorTint; button.colors = colors;
var colors = button.colors; }
colors.normalColor = normalColor;
colors.highlightedColor = hoverColor; private Color ApplyColorBrightness(Color color, float factor)
colors.pressedColor = pressedColor; {
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f); float r = Mathf.Clamp01(color.r * factor);
button.colors = colors; float g = Mathf.Clamp01(color.g * factor);
} float b = Mathf.Clamp01(color.b * factor);
return new Color(r, g, b, color.a);
private Color ApplyColorBrightness(Color color, float factor)
{
float r = Mathf.Clamp01(color.r * factor);
float g = Mathf.Clamp01(color.g * factor);
float b = Mathf.Clamp01(color.b * factor);
return new Color(r, g, b, color.a);
}
} }
} }
+207 -208
View File
@@ -6,229 +6,228 @@ using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
namespace ADOFAIRenderer.UI namespace ADOFAIRenderer.UI;
internal class DebugMonitorUI : MonoBehaviour
{ {
internal class DebugMonitorUI : MonoBehaviour // --- Progress Bar UI Configuration Constants ---
private const float BarWidth = 800f;
private const float BarHeight = 28f;
private const float BarPosX = 0f;
private const float BarPosY = -420f; // Y position relative to screen center (Reference: 1920x1080)
private const int BarTextFontSize = 16; // 눈에 띄게 증가된 텍스트 크기
private static readonly Color BarBackgroundColor = new(0.08f, 0.08f, 0.1f, 1.0f);
private static readonly Color BarFillColor = new(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
private static readonly Color BarTextColor = new(1.0f, 1.0f, 1.0f, 1.0f);
// --- Progress Bar Outline Configuration Constants ---
private static readonly Color BarOutlineColor = Color.white; // 대비되는 흰색 윤곽선
private static readonly Vector2 BarOutlineDistance = new(3.0f, -3.0f); // 3px의 굵고 직관적인 윤곽선 두께
private TextMeshProUGUI _text = null!;
private GameObject _progressBarObj = null!;
private GameObject _progressBarFillObj = null!;
private RectTransform _fillRect = null!;
private TextMeshProUGUI _progressText = null!;
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
{ {
// --- Progress Bar UI Configuration Constants --- TMP_FontAsset? font = null;
private const float BarWidth = 800f; if (RenderMod.FontBundle != null)
private const float BarHeight = 28f;
private const float BarPosX = 0f;
private const float BarPosY = -420f; // Y position relative to screen center (Reference: 1920x1080)
private const int BarTextFontSize = 16; // 눈에 띄게 증가된 텍스트 크기
private static readonly Color BarBackgroundColor = new(0.08f, 0.08f, 0.1f, 1.0f);
private static readonly Color BarFillColor = new(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
private static readonly Color BarTextColor = new(1.0f, 1.0f, 1.0f, 1.0f);
// --- Progress Bar Outline Configuration Constants ---
private static readonly Color BarOutlineColor = Color.white; // 대비되는 흰색 윤곽선
private static readonly Vector2 BarOutlineDistance = new(3.0f, -3.0f); // 3px의 굵고 직관적인 윤곽선 두께
private TextMeshProUGUI _text = null!;
private GameObject _progressBarObj = null!;
private GameObject _progressBarFillObj = null!;
private RectTransform _fillRect = null!;
private TextMeshProUGUI _progressText = null!;
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
{ {
TMP_FontAsset? font = null; try
if (RenderMod.FontBundle != null)
{ {
try font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
{
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
}
catch
{
// 로딩 실패
}
} }
catch
// 폴백 처리: 에셋 번들에서 실패 시 게임 자체 NanumSquareNeo 또는 내장 TMP SDF 폰트 사용
if (font == null)
{ {
font = RDString.fontData.fontTMP; // 로딩 실패
} }
if (font == null)
{
// ReSharper disable once Unity.UnknownResource
font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
}
if (font != null) return font;
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
if (fonts.Length > 0) font = fonts[0];
return font!;
} }
private void Start() // 폴백 처리: 에셋 번들에서 실패 시 게임 자체 NanumSquareNeo 또는 내장 TMP SDF 폰트 사용
if (font == null)
{ {
// UI 레이어(5) 설정 font = RDString.fontData.fontTMP;
gameObject.layer = 5; }
if (font == null)
// Canvas 설정 {
var canvas = gameObject.AddComponent<Canvas>(); // ReSharper disable once Unity.UnknownResource
canvas.renderMode = RenderMode.ScreenSpaceOverlay; font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
canvas.sortingOrder = 9999;
// 해상도 다이내믹 스케일링 설정 (Reference: 1920x1080)
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
// 배경 패널 생성
var panelObj = new GameObject("BackgroundPanel");
panelObj.transform.SetParent(transform, false);
panelObj.layer = 5;
var image = panelObj.AddComponent<Image>();
image.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 약간 더 고급스러운 다크톤 아크릴 감성
var panelRect = panelObj.GetComponent<RectTransform>();
panelRect.anchorMin = new Vector2(1, 1);
panelRect.anchorMax = new Vector2(1, 1);
panelRect.pivot = new Vector2(1, 1);
panelRect.anchoredPosition = new Vector2(-30, -30);
panelRect.sizeDelta = new Vector2(360, 220);
// 텍스트 생성
var textObj = new GameObject("MonitorText");
textObj.transform.SetParent(panelObj.transform, false);
textObj.layer = 5;
_text = textObj.AddComponent<TextMeshProUGUI>();
_text.richText = true;
_text.fontSize = 16; // 13에서 16으로 증가
_text.alignment = TextAlignmentOptions.TopLeft;
// TMPro 텍스트 잘림 현상 예방
_text.textWrappingMode = TextWrappingModes.Normal;
_text.overflowMode = TextOverflowModes.Overflow;
// NanumSquareNeo-Bd SDF 폰트 로드 및 적용
var font = GetNanumSquareNeoBdFontAsset();
_text.font = font;
var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(20, 20); // 좀 더 넉넉하고 패딩 여백
textRect.offsetMax = new Vector2(-20, -20);
// --- Progress Bar UI Setup ---
// 1. Progress Bar Container
_progressBarObj = new GameObject("ProgressBar");
_progressBarObj.transform.SetParent(transform, false);
_progressBarObj.layer = 5;
var bgImage = _progressBarObj.AddComponent<Image>();
bgImage.color = BarBackgroundColor;
// 프로그레스 바 흰색 윤곽선 컴포넌트 추가
var bgOutline = _progressBarObj.AddComponent<Outline>();
bgOutline.effectColor = BarOutlineColor;
bgOutline.effectDistance = BarOutlineDistance;
var barRect = _progressBarObj.GetComponent<RectTransform>();
barRect.anchorMin = new Vector2(0.5f, 0.5f);
barRect.anchorMax = new Vector2(0.5f, 0.5f);
barRect.pivot = new Vector2(0.5f, 0.5f);
barRect.anchoredPosition = new Vector2(BarPosX, BarPosY);
barRect.sizeDelta = new Vector2(BarWidth, BarHeight);
// 2. Progress Bar Fill
_progressBarFillObj = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5;
var fillImage = _progressBarFillObj.AddComponent<Image>();
fillImage.color = BarFillColor;
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
_fillRect.anchorMin = new Vector2(0, 0);
_fillRect.anchorMax = new Vector2(0, 1);
_fillRect.pivot = new Vector2(0, 0.5f);
_fillRect.anchoredPosition = Vector2.zero;
_fillRect.sizeDelta = new Vector2(0, 0); // width will be updated dynamically in Update()
// 3. Progress Bar Text
var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5;
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
_progressText.richText = true;
_progressText.fontSize = BarTextFontSize; // 14에서 18로 증가
_progressText.alignment = TextAlignmentOptions.Center;
_progressText.font = font;
_progressText.color = BarTextColor;
// 프로그레스 바 내부 텍스트 아웃라인 추가 (진행율 가독성 확보)
var textOutline = progressTextObj.AddComponent<Outline>();
textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f);
textOutline.effectDistance = new Vector2(1.5f, -1.5f);
var textRectBar = progressTextObj.GetComponent<RectTransform>();
textRectBar.anchorMin = Vector2.zero;
textRectBar.anchorMax = Vector2.one;
textRectBar.offsetMin = Vector2.zero;
textRectBar.offsetMax = Vector2.zero;
} }
private void Update() if (font != null) return font;
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
if (fonts.Length > 0) font = fonts[0];
return font!;
}
private void Start()
{
// UI 레이어(5) 설정
gameObject.layer = 5;
// Canvas 설정
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 9999;
// 해상도 다이내믹 스케일링 설정 (Reference: 1920x1080)
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
// 배경 패널 생성
var panelObj = new GameObject("BackgroundPanel");
panelObj.transform.SetParent(transform, false);
panelObj.layer = 5;
var image = panelObj.AddComponent<Image>();
image.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 약간 더 고급스러운 다크톤 아크릴 감성
var panelRect = panelObj.GetComponent<RectTransform>();
panelRect.anchorMin = new Vector2(1, 1);
panelRect.anchorMax = new Vector2(1, 1);
panelRect.pivot = new Vector2(1, 1);
panelRect.anchoredPosition = new Vector2(-30, -30);
panelRect.sizeDelta = new Vector2(360, 220);
// 텍스트 생성
var textObj = new GameObject("MonitorText");
textObj.transform.SetParent(panelObj.transform, false);
textObj.layer = 5;
_text = textObj.AddComponent<TextMeshProUGUI>();
_text.richText = true;
_text.fontSize = 16; // 13에서 16으로 증가
_text.alignment = TextAlignmentOptions.TopLeft;
// TMPro 텍스트 잘림 현상 예방
_text.textWrappingMode = TextWrappingModes.Normal;
_text.overflowMode = TextOverflowModes.Overflow;
// NanumSquareNeo-Bd SDF 폰트 로드 및 적용
var font = GetNanumSquareNeoBdFontAsset();
_text.font = font;
var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(20, 20); // 좀 더 넉넉하고 패딩 여백
textRect.offsetMax = new Vector2(-20, -20);
// --- Progress Bar UI Setup ---
// 1. Progress Bar Container
_progressBarObj = new GameObject("ProgressBar");
_progressBarObj.transform.SetParent(transform, false);
_progressBarObj.layer = 5;
var bgImage = _progressBarObj.AddComponent<Image>();
bgImage.color = BarBackgroundColor;
// 프로그레스 바 흰색 윤곽선 컴포넌트 추가
var bgOutline = _progressBarObj.AddComponent<Outline>();
bgOutline.effectColor = BarOutlineColor;
bgOutline.effectDistance = BarOutlineDistance;
var barRect = _progressBarObj.GetComponent<RectTransform>();
barRect.anchorMin = new Vector2(0.5f, 0.5f);
barRect.anchorMax = new Vector2(0.5f, 0.5f);
barRect.pivot = new Vector2(0.5f, 0.5f);
barRect.anchoredPosition = new Vector2(BarPosX, BarPosY);
barRect.sizeDelta = new Vector2(BarWidth, BarHeight);
// 2. Progress Bar Fill
_progressBarFillObj = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5;
var fillImage = _progressBarFillObj.AddComponent<Image>();
fillImage.color = BarFillColor;
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
_fillRect.anchorMin = new Vector2(0, 0);
_fillRect.anchorMax = new Vector2(0, 1);
_fillRect.pivot = new Vector2(0, 0.5f);
_fillRect.anchoredPosition = Vector2.zero;
_fillRect.sizeDelta = new Vector2(0, 0); // width will be updated dynamically in Update()
// 3. Progress Bar Text
var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5;
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
_progressText.richText = true;
_progressText.fontSize = BarTextFontSize; // 14에서 18로 증가
_progressText.alignment = TextAlignmentOptions.Center;
_progressText.font = font;
_progressText.color = BarTextColor;
// 프로그레스 바 내부 텍스트 아웃라인 추가 (진행율 가독성 확보)
var textOutline = progressTextObj.AddComponent<Outline>();
textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f);
textOutline.effectDistance = new Vector2(1.5f, -1.5f);
var textRectBar = progressTextObj.GetComponent<RectTransform>();
textRectBar.anchorMin = Vector2.zero;
textRectBar.anchorMax = Vector2.one;
textRectBar.offsetMin = Vector2.zero;
textRectBar.offsetMax = Vector2.zero;
}
private void Update()
{
if (!_text) return;
var sb = new StringBuilder();
sb.AppendLine("<b><color=#00ccff>ADOFAI Renderer Realtime Monitor</color></b>");
sb.AppendLine();
var queueCount = 0;
if (RenderManager.Instance)
{ {
if (!_text) return; lock (RenderManager.Instance.ImageQueue)
var sb = new StringBuilder();
sb.AppendLine("<b><color=#00ccff>ADOFAI Renderer Realtime Monitor</color></b>");
sb.AppendLine();
var queueCount = 0;
if (RenderManager.Instance)
{ {
lock (RenderManager.Instance.ImageQueue) queueCount = RenderManager.Instance.ImageQueue.Count;
{
queueCount = RenderManager.Instance.ImageQueue.Count;
}
} }
sb.AppendLine($"• Queue: <b>{queueCount}</b> / {RenderManager.MaxQueuedFrames} frames");
sb.AppendLine($"• Rendered Frames: <b>{(RenderManager.Instance ? RenderManager.Instance.frameCount : 0)}</b>");
sb.AppendLine($"• Frames Sent: <b>{(RenderManager.Instance ? Interlocked.Read(ref RenderManager.Instance.framesSent) : 0)}</b>");
sb.AppendLine();
sb.AppendLine($"• Throttling Controls: {(RenderManager.Instance ? RenderManager.Instance.throttleCount : 0)} times");
sb.AppendLine($"• Readback Hard Failures: <color=#ff3333>{(RenderManager.Instance ? RenderManager.Instance.readbackFailureCount : 0)}</color> times");
sb.AppendLine($"• Render Skip-Retry: {(RenderManager.Instance ? RenderManager.Instance.skipCount : 0)} / {RenderManager.MaxRenderRetry}");
var memoryMb = GC.GetTotalMemory(false) / (1024.0 * 1024.0);
sb.AppendLine($"• Mono Heap Memory: {memoryMb:F2} MB");
_text.text = sb.ToString();
// Update Progress Bar
var progress = 0f;
var currentFloor = 0;
var totalFloors = 0;
if (scrController.instance && ADOBase.lm && ADOBase.lm.listFloors != null)
{
currentFloor = scrController.instance.currFloor ? scrController.instance.currFloor.seqID : 0;
totalFloors = ADOBase.lm.listFloors.Count - 1;
if (totalFloors > 0)
{
progress = Mathf.Clamp01((float)currentFloor / totalFloors);
}
}
if (_fillRect) _fillRect.sizeDelta = new Vector2(BarWidth * progress, 0);
if (_progressText) _progressText.text = $"{progress * 100f:F1}% ({currentFloor}/{totalFloors})";
} }
sb.AppendLine($"• Queue: <b>{queueCount}</b> / {RenderManager.MaxQueuedFrames} frames");
sb.AppendLine($"• Rendered Frames: <b>{(RenderManager.Instance ? RenderManager.Instance.frameCount : 0)}</b>");
sb.AppendLine($"• Frames Sent: <b>{(RenderManager.Instance ? Interlocked.Read(ref RenderManager.Instance.framesSent) : 0)}</b>");
sb.AppendLine();
sb.AppendLine($"• Throttling Controls: {(RenderManager.Instance ? RenderManager.Instance.throttleCount : 0)} times");
sb.AppendLine($"• Readback Hard Failures: <color=#ff3333>{(RenderManager.Instance ? RenderManager.Instance.readbackFailureCount : 0)}</color> times");
sb.AppendLine($"• Render Skip-Retry: {(RenderManager.Instance ? RenderManager.Instance.skipCount : 0)} / {RenderManager.MaxRenderRetry}");
var memoryMb = GC.GetTotalMemory(false) / (1024.0 * 1024.0);
sb.AppendLine($"• Mono Heap Memory: {memoryMb:F2} MB");
_text.text = sb.ToString();
// Update Progress Bar
var progress = 0f;
var currentFloor = 0;
var totalFloors = 0;
if (scrController.instance && ADOBase.lm && ADOBase.lm.listFloors != null)
{
currentFloor = scrController.instance.currFloor ? scrController.instance.currFloor.seqID : 0;
totalFloors = ADOBase.lm.listFloors.Count - 1;
if (totalFloors > 0)
{
progress = Mathf.Clamp01((float)currentFloor / totalFloors);
}
}
if (_fillRect) _fillRect.sizeDelta = new Vector2(BarWidth * progress, 0);
if (_progressText) _progressText.text = $"{progress * 100f:F1}% ({currentFloor}/{totalFloors})";
} }
} }
+105 -106
View File
@@ -4,128 +4,127 @@ using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
namespace ADOFAIRenderer.UI namespace ADOFAIRenderer.UI;
internal class EscapeHandlerUI : MonoBehaviour
{ {
internal class EscapeHandlerUI : MonoBehaviour private GameObject _containerObj = null!;
private TextMeshProUGUI _textComponent = null!;
private Stopwatch? _escapeStopwatch;
private void Start()
{ {
private GameObject _containerObj = null!; // UI 레이어(5) 설정
private TextMeshProUGUI _textComponent = null!; gameObject.layer = 5;
private Stopwatch? _escapeStopwatch;
private void Start() // Canvas 설정
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 10000; // 디버그 모니터보다 위에 오도록 설정
// 해상도 대응 캔버스 스케일러 설정
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
// 1. 컨테이너 및 배경 패널 오브젝트 생성
_containerObj = new GameObject("EscapeContainer");
_containerObj.transform.SetParent(transform, false);
_containerObj.layer = 5;
var bgImage = _containerObj.AddComponent<Image>();
// 고급스러운 다크톤 아크릴 감성
bgImage.color = new Color(0.05f, 0.05f, 0.07f, 0.85f);
var containerRect = _containerObj.GetComponent<RectTransform>();
containerRect.anchorMin = new Vector2(0.5f, 0.5f);
containerRect.anchorMax = new Vector2(0.5f, 0.5f);
containerRect.pivot = new Vector2(0.5f, 0.5f);
containerRect.anchoredPosition = Vector2.zero; // 화면 중앙
containerRect.sizeDelta = new Vector2(460, 160);
// 2. 텍스트 오브젝트 생성
var textObj = new GameObject("EscapeText");
textObj.transform.SetParent(_containerObj.transform, false);
textObj.layer = 5;
_textComponent = textObj.AddComponent<TextMeshProUGUI>();
_textComponent.alignment = TextAlignmentOptions.Center;
_textComponent.fontSize = 60;
_textComponent.textWrappingMode = TextWrappingModes.NoWrap;
_textComponent.richText = true;
// 게임의 기본 폰트 적용 (RDString.fontData.fontTMP)
if (RDString.fontData.fontTMP != null)
_textComponent.font = RDString.fontData.fontTMP;
var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(10, 10);
textRect.offsetMax = new Vector2(-10, -10);
// 초기 상태는 비활성화
_containerObj.SetActive(false);
}
private void Update()
{
// 렌더링이 더 이상 활성화되어 있지 않으면 자가 파괴
if (!RenderManager.IsActive)
{ {
// UI 레이어(5) 설정 Destroy(gameObject);
gameObject.layer = 5; return;
}
// Canvas 설정 if (Input.GetKey(KeyCode.Escape))
var canvas = gameObject.AddComponent<Canvas>(); {
canvas.renderMode = RenderMode.ScreenSpaceOverlay; if (_escapeStopwatch == null)
canvas.sortingOrder = 10000; // 디버그 모니터보다 위에 오도록 설정 {
_escapeStopwatch = new Stopwatch();
_escapeStopwatch.Start();
_containerObj.SetActive(true);
}
// 해상도 대응 캔버스 스케일러 설정 var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds;
var scaler = gameObject.AddComponent<CanvasScaler>(); var remaining = Mathf.Max(0f, 3f - elapsed);
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>(); // 남은 시간 텍스트 업데이트
_textComponent.text = $"<color=#FF5252>탈출까지 {remaining:F1}초..</color>\n<size=40><color=#DDDDDD>Esc 키를 계속 누르고 계세요</color></size>";
// 1. 컨테이너 및 배경 패널 오브젝트 생성 // 마이크로 펄스 애니메이션 적용 (누르는 동안 컨테이너 전체 크기가 부드럽게 커짐)
_containerObj = new GameObject("EscapeContainer"); var scale = 1.0f + elapsed / 3.0f * 0.15f;
_containerObj.transform.SetParent(transform, false); _containerObj.transform.localScale = new Vector3(scale, scale, 1f);
_containerObj.layer = 5;
var bgImage = _containerObj.AddComponent<Image>(); if (elapsed < 3f) return;
// 고급스러운 다크톤 아크릴 감성
bgImage.color = new Color(0.05f, 0.05f, 0.07f, 0.85f);
var containerRect = _containerObj.GetComponent<RectTransform>(); _escapeStopwatch.Stop();
containerRect.anchorMin = new Vector2(0.5f, 0.5f); _escapeStopwatch = null;
containerRect.anchorMax = new Vector2(0.5f, 0.5f); _containerObj.transform.localScale = Vector3.one; // 스케일 초기화
containerRect.pivot = new Vector2(0.5f, 0.5f);
containerRect.anchoredPosition = Vector2.zero; // 화면 중앙
containerRect.sizeDelta = new Vector2(460, 160);
// 2. 텍스트 오브젝트 생성
var textObj = new GameObject("EscapeText");
textObj.transform.SetParent(_containerObj.transform, false);
textObj.layer = 5;
_textComponent = textObj.AddComponent<TextMeshProUGUI>();
_textComponent.alignment = TextAlignmentOptions.Center;
_textComponent.fontSize = 60;
_textComponent.textWrappingMode = TextWrappingModes.NoWrap;
_textComponent.richText = true;
// 게임의 기본 폰트 적용 (RDString.fontData.fontTMP)
if (RDString.fontData.fontTMP != null)
_textComponent.font = RDString.fontData.fontTMP;
var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(10, 10);
textRect.offsetMax = new Vector2(-10, -10);
// 초기 상태는 비활성화
_containerObj.SetActive(false); _containerObj.SetActive(false);
// 렌더링 취소 및 씬 이탈
if (RenderManager.Instance)
RenderManager.Instance.CancelRender();
} }
else
private void Update()
{
// 렌더링이 더 이상 활성화되어 있지 않으면 자가 파괴
if (!RenderManager.IsActive)
{
Destroy(gameObject);
return;
}
if (Input.GetKey(KeyCode.Escape))
{
if (_escapeStopwatch == null)
{
_escapeStopwatch = new Stopwatch();
_escapeStopwatch.Start();
_containerObj.SetActive(true);
}
var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds;
var remaining = Mathf.Max(0f, 3f - elapsed);
// 남은 시간 텍스트 업데이트
_textComponent.text = $"<color=#FF5252>탈출까지 {remaining:F1}초..</color>\n<size=40><color=#DDDDDD>Esc 키를 계속 누르고 계세요</color></size>";
// 마이크로 펄스 애니메이션 적용 (누르는 동안 컨테이너 전체 크기가 부드럽게 커짐)
var scale = 1.0f + elapsed / 3.0f * 0.15f;
_containerObj.transform.localScale = new Vector3(scale, scale, 1f);
if (elapsed < 3f) return;
_escapeStopwatch.Stop();
_escapeStopwatch = null;
_containerObj.transform.localScale = Vector3.one; // 스케일 초기화
_containerObj.SetActive(false);
// 렌더링 취소 및 씬 이탈
if (RenderManager.Instance)
RenderManager.Instance.CancelRender();
}
else
{
if (_escapeStopwatch == null) return;
_escapeStopwatch.Stop();
_escapeStopwatch = null;
_containerObj.transform.localScale = Vector3.one; // 스케일 초기화
_containerObj.SetActive(false);
}
}
private void OnDestroy()
{ {
if (_escapeStopwatch == null) return; if (_escapeStopwatch == null) return;
_escapeStopwatch.Stop(); _escapeStopwatch.Stop();
_escapeStopwatch = null; _escapeStopwatch = null;
_containerObj.transform.localScale = Vector3.one; // 스케일 초기화
_containerObj.SetActive(false);
} }
} }
private void OnDestroy()
{
if (_escapeStopwatch == null) return;
_escapeStopwatch.Stop();
_escapeStopwatch = null;
}
} }
+653 -654
View File
File diff suppressed because it is too large. Load diff
+1034 -1035
View File
File diff suppressed because it is too large. Load diff
+80 -81
View File
@@ -1,101 +1,100 @@
using System; using System;
namespace ADOFAIRenderer.Util.Audio namespace ADOFAIRenderer.Util.Audio;
internal static class AudioResampler
{ {
internal static class AudioResampler public static float[] Resample(float[] inputSamples, int inputChannels, int inputFrequency, int targetChannels, int targetFrequency)
{ {
public static float[] Resample(float[] inputSamples, int inputChannels, int inputFrequency, int targetChannels, int targetFrequency) // ── Safety Validation ──
if (inputSamples.Length == 0 ||
inputChannels <= 0 || targetChannels <= 0 ||
inputFrequency <= 0 || targetFrequency <= 0)
{ {
// ── Safety Validation ── return Array.Empty<float>();
if (inputSamples.Length == 0 || }
inputChannels <= 0 || targetChannels <= 0 ||
inputFrequency <= 0 || targetFrequency <= 0) // If format is identical, return original samples
if (inputFrequency == targetFrequency && inputChannels == targetChannels)
return inputSamples;
int inputLength = inputSamples.Length / inputChannels;
int targetLength = (int)Math.Floor((double)inputLength * targetFrequency / inputFrequency);
float[] outputSamples = new float[targetLength * targetChannels];
for (int i = 0; i < targetLength; i++)
{
double srcIndex = (double)i * inputFrequency / targetFrequency;
long idxLow = (long)Math.Floor(srcIndex);
long idxHigh = idxLow + 1;
float t = (float)(srcIndex - idxLow);
if (idxHigh >= inputLength) idxHigh = inputLength - 1;
if (idxLow >= inputLength) idxLow = inputLength - 1;
if (targetChannels == 1) // 1. Mono Output
{ {
return Array.Empty<float>(); float valLow, valHigh;
if (inputChannels == 1) // Mono -> Mono
{
valLow = inputSamples[idxLow];
valHigh = inputSamples[idxHigh];
}
else // Multi -> Mono (Downmix: Average all channels)
{
float sumLow = 0f;
float sumHigh = 0f;
for (int c = 0; c < inputChannels; c++)
{
sumLow += inputSamples[idxLow * inputChannels + c];
sumHigh += inputSamples[idxHigh * inputChannels + c];
}
valLow = sumLow / inputChannels;
valHigh = sumHigh / inputChannels;
}
outputSamples[i] = valLow + t * (valHigh - valLow);
} }
else if (targetChannels == 2) // 2. Stereo Output
// If format is identical, return original samples
if (inputFrequency == targetFrequency && inputChannels == targetChannels)
return inputSamples;
int inputLength = inputSamples.Length / inputChannels;
int targetLength = (int)Math.Floor((double)inputLength * targetFrequency / inputFrequency);
float[] outputSamples = new float[targetLength * targetChannels];
for (int i = 0; i < targetLength; i++)
{ {
double srcIndex = (double)i * inputFrequency / targetFrequency; if (inputChannels == 1) // Mono -> Stereo (Duplicate to Left/Right)
long idxLow = (long)Math.Floor(srcIndex); {
long idxHigh = idxLow + 1; float valLow = inputSamples[idxLow];
float t = (float)(srcIndex - idxLow); float valHigh = inputSamples[idxHigh];
float val = valLow + t * (valHigh - valLow);
outputSamples[i * 2] = val;
outputSamples[i * 2 + 1] = val;
}
else // Stereo/Multi -> Stereo (Map Left/Right, ignore extra channels)
{
float valLowL = inputSamples[idxLow * inputChannels];
float valHighL = inputSamples[idxHigh * inputChannels];
outputSamples[i * 2] = valLowL + t * (valHighL - valLowL);
if (idxHigh >= inputLength) idxHigh = inputLength - 1; float valLowR = inputSamples[idxLow * inputChannels + 1];
if (idxLow >= inputLength) idxLow = inputLength - 1; float valHighR = inputSamples[idxHigh * inputChannels + 1];
outputSamples[i * 2 + 1] = valLowR + t * (valHighR - valLowR);
if (targetChannels == 1) // 1. Mono Output }
}
else // 3. Multi-channel Output (e.g., 5.1 Surround)
{
for (int c = 0; c < targetChannels; c++)
{ {
float valLow, valHigh; float valLow, valHigh;
if (inputChannels == 1) // Mono -> Mono if (c < inputChannels)
{ {
valLow = inputSamples[idxLow]; valLow = inputSamples[idxLow * inputChannels + c];
valHigh = inputSamples[idxHigh]; valHigh = inputSamples[idxHigh * inputChannels + c];
} }
else // Multi -> Mono (Downmix: Average all channels) else
{ {
float sumLow = 0f; valLow = inputSamples[idxLow * inputChannels];
float sumHigh = 0f; valHigh = inputSamples[idxHigh * inputChannels];
for (int c = 0; c < inputChannels; c++)
{
sumLow += inputSamples[idxLow * inputChannels + c];
sumHigh += inputSamples[idxHigh * inputChannels + c];
}
valLow = sumLow / inputChannels;
valHigh = sumHigh / inputChannels;
}
outputSamples[i] = valLow + t * (valHigh - valLow);
}
else if (targetChannels == 2) // 2. Stereo Output
{
if (inputChannels == 1) // Mono -> Stereo (Duplicate to Left/Right)
{
float valLow = inputSamples[idxLow];
float valHigh = inputSamples[idxHigh];
float val = valLow + t * (valHigh - valLow);
outputSamples[i * 2] = val;
outputSamples[i * 2 + 1] = val;
}
else // Stereo/Multi -> Stereo (Map Left/Right, ignore extra channels)
{
float valLowL = inputSamples[idxLow * inputChannels];
float valHighL = inputSamples[idxHigh * inputChannels];
outputSamples[i * 2] = valLowL + t * (valHighL - valLowL);
float valLowR = inputSamples[idxLow * inputChannels + 1];
float valHighR = inputSamples[idxHigh * inputChannels + 1];
outputSamples[i * 2 + 1] = valLowR + t * (valHighR - valLowR);
}
}
else // 3. Multi-channel Output (e.g., 5.1 Surround)
{
for (int c = 0; c < targetChannels; c++)
{
float valLow, valHigh;
if (c < inputChannels)
{
valLow = inputSamples[idxLow * inputChannels + c];
valHigh = inputSamples[idxHigh * inputChannels + c];
}
else
{
valLow = inputSamples[idxLow * inputChannels];
valHigh = inputSamples[idxHigh * inputChannels];
}
outputSamples[i * targetChannels + c] = valLow + t * (valHigh - valLow);
} }
outputSamples[i * targetChannels + c] = valLow + t * (valHigh - valLow);
} }
} }
return outputSamples;
} }
return outputSamples;
} }
} }
+209 -208
View File
@@ -3,238 +3,239 @@ using System.Collections.Generic;
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Util.Audio namespace ADOFAIRenderer.Util.Audio;
internal static class AudioSpectrumGenerator
{ {
internal static class AudioSpectrumGenerator // ReSharper disable MemberCanBePrivate.Global
public static float[]? AudioClipData { get; private set; }
public static int Channels { get; private set; }
public static int Frequency { get; private set; }
public static int TotalSamples { get; private set; }
// ReSharper restore MemberCanBePrivate.Global
private static long _cachedFrame = -1;
private static readonly Dictionary<int, float[]> CachedSpectrums = new();
public static void Initialize(AudioClip? clip)
{ {
public static float[]? AudioClipData { get; private set; } if (!clip)
public static int Channels { get; private set; }
public static int Frequency { get; private set; }
public static int TotalSamples { get; private set; }
private static long _cachedFrame = -1;
private static readonly Dictionary<int, float[]> CachedSpectrums = new();
public static void Initialize(AudioClip? clip)
{ {
if (!clip) Clear();
{ return;
Clear();
return;
}
try
{
Channels = clip.channels;
Frequency = clip.frequency;
int samplesCount = clip.samples;
float[] rawSamples = new float[samplesCount * Channels];
clip.GetData(rawSamples, 0);
if (Channels == 1)
{
AudioClipData = rawSamples;
TotalSamples = samplesCount;
}
else
{
AudioClipData = new float[samplesCount];
TotalSamples = samplesCount;
for (int i = 0; i < samplesCount; i++)
{
float sum = 0f;
for (int c = 0; c < Channels; c++)
{
sum += rawSamples[i * Channels + c];
}
AudioClipData[i] = sum / Channels;
}
}
MelonLoader.MelonLogger.Msg($"[AudioSpectrumGenerator] Cached audio clip: {clip.name}, Samples: {TotalSamples}, Frequency: {Frequency}Hz, Channels: {Channels}");
}
catch (Exception e)
{
MelonLoader.MelonLogger.Error($"[AudioSpectrumGenerator] Failed to initialize audio cache: {e}");
Clear();
}
} }
public static void Clear() try
{ {
AudioClipData = null; Channels = clip.channels;
TotalSamples = 0; Frequency = clip.frequency;
Channels = 0; int samplesCount = clip.samples;
Frequency = 0;
lock (CachedSpectrums) float[] rawSamples = new float[samplesCount * Channels];
clip.GetData(rawSamples, 0);
if (Channels == 1)
{ {
_cachedFrame = -1; AudioClipData = rawSamples;
TotalSamples = samplesCount;
}
else
{
AudioClipData = new float[samplesCount];
TotalSamples = samplesCount;
for (int i = 0; i < samplesCount; i++)
{
float sum = 0f;
for (int c = 0; c < Channels; c++)
{
sum += rawSamples[i * Channels + c];
}
AudioClipData[i] = sum / Channels;
}
}
MelonLoader.MelonLogger.Msg($"[AudioSpectrumGenerator] Cached audio clip: {clip.name}, Samples: {TotalSamples}, Frequency: {Frequency}Hz, Channels: {Channels}");
}
catch (Exception e)
{
MelonLoader.MelonLogger.Error($"[AudioSpectrumGenerator] Failed to initialize audio cache: {e}");
Clear();
}
}
public static void Clear()
{
AudioClipData = null;
TotalSamples = 0;
Channels = 0;
Frequency = 0;
lock (CachedSpectrums)
{
_cachedFrame = -1;
CachedSpectrums.Clear();
}
}
public static void GetSpectrumData(float[] samples, int channel, FFTWindow window)
{
if (AudioClipData == null || AudioClipData.Length == 0 || !scrConductor.instance || !scrConductor.instance.hasSongStarted || !scrConductor.instance.song)
{
Array.Clear(samples, 0, samples.Length);
return;
}
var conductor = scrConductor.instance;
var pitch = conductor.song.pitch;
var separateCountdownOffset = conductor.separateCountdownTime ?
conductor.crotchetAtStart * conductor.adjustedCountdownTicks : 0.0;
// Calculate the actual scheduled start time of the song
var songScheduledStartTime = conductor.skipOffset ?
conductor.dspTimeSong + conductor.addoffset / pitch :
conductor.dspTimeSong + separateCountdownOffset / pitch;
if (conductor.dspTime < songScheduledStartTime)
{
Array.Clear(samples, 0, samples.Length);
return;
}
var songTime = (conductor.dspTime - conductor.dspTimeSong) * pitch - separateCountdownOffset;
var n = samples.Length;
if (n == 0 || (n & (n - 1)) != 0) // Ensure it's a power of 2
{
Array.Clear(samples, 0, samples.Length);
return;
}
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1;
lock (CachedSpectrums)
{
if (currentFrame != _cachedFrame)
{
_cachedFrame = currentFrame;
CachedSpectrums.Clear(); CachedSpectrums.Clear();
} }
}
public static void GetSpectrumData(float[] samples, int channel, FFTWindow window) if (CachedSpectrums.TryGetValue(n, out var cached))
{
if (AudioClipData == null || AudioClipData.Length == 0 || !scrConductor.instance || !scrConductor.instance.hasSongStarted || !scrConductor.instance.song)
{ {
Array.Clear(samples, 0, samples.Length); Array.Copy(cached, samples, n);
return; return;
} }
var conductor = scrConductor.instance;
var pitch = conductor.song.pitch;
var separateCountdownOffset = conductor.separateCountdownTime ?
conductor.crotchetAtStart * conductor.adjustedCountdownTicks : 0.0;
// Calculate the actual scheduled start time of the song
var songScheduledStartTime = conductor.skipOffset ?
conductor.dspTimeSong + conductor.addoffset / pitch :
conductor.dspTimeSong + separateCountdownOffset / pitch;
if (conductor.dspTime < songScheduledStartTime)
{
Array.Clear(samples, 0, samples.Length);
return;
}
var songTime = (conductor.dspTime - conductor.dspTimeSong) * pitch - separateCountdownOffset;
var n = samples.Length;
if (n == 0 || (n & (n - 1)) != 0) // Ensure it's a power of 2
{
Array.Clear(samples, 0, samples.Length);
return;
}
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1;
lock (CachedSpectrums)
{
if (currentFrame != _cachedFrame)
{
_cachedFrame = currentFrame;
CachedSpectrums.Clear();
}
if (CachedSpectrums.TryGetValue(n, out var cached))
{
Array.Copy(cached, samples, n);
return;
}
}
var fftSize = 2 * n;
var frequency = Frequency;
var totalSamples = TotalSamples;
var currentSampleIndex = (long)(songTime * frequency);
var windowSamples = new float[fftSize];
for (var i = 0; i < fftSize; i++)
{
var idx = currentSampleIndex - fftSize / 2 + i;
if (idx >= 0 && idx < totalSamples)
{
windowSamples[i] = AudioClipData[idx];
}
else
{
windowSamples[i] = 0f;
}
}
var spectrum = new float[n];
CalculateFFT(windowSamples, spectrum);
lock (CachedSpectrums)
{
CachedSpectrums[n] = spectrum;
Array.Copy(spectrum, samples, n);
}
} }
private static void CalculateFFT(float[] samples, float[] spectrum) var fftSize = 2 * n;
var frequency = Frequency;
var totalSamples = TotalSamples;
var currentSampleIndex = (long)(songTime * frequency);
var windowSamples = new float[fftSize];
for (var i = 0; i < fftSize; i++)
{ {
var n = samples.Length; var idx = currentSampleIndex - fftSize / 2 + i;
if (idx >= 0 && idx < totalSamples)
// Apply Blackman-Harris window
const float a0 = 0.35875f;
const float a1 = 0.48829f;
const float a2 = 0.14128f;
const float a3 = 0.01168f;
var re = new float[n];
var im = new float[n];
for (var i = 0; i < n; i++)
{ {
var angle = 2.0 * Math.PI * i / (n - 1); windowSamples[i] = AudioClipData[idx];
var w = (float)(a0 - a1 * Math.Cos(angle) + a2 * Math.Cos(2.0 * angle) - a3 * Math.Cos(3.0 * angle));
re[i] = samples[i] * w;
im[i] = 0f;
} }
else
// Cooley-Tukey Radix-2 FFT
var m = (int)Math.Round(Math.Log(n, 2));
// Bit-reversal
var j = 0;
for (var i = 0; i < n - 1; i++)
{ {
if (i < j) windowSamples[i] = 0f;
}
}
var spectrum = new float[n];
CalculateFFT(windowSamples, spectrum);
lock (CachedSpectrums)
{
CachedSpectrums[n] = spectrum;
Array.Copy(spectrum, samples, n);
}
}
private static void CalculateFFT(float[] samples, float[] spectrum)
{
var n = samples.Length;
// Apply Blackman-Harris window
const float a0 = 0.35875f;
const float a1 = 0.48829f;
const float a2 = 0.14128f;
const float a3 = 0.01168f;
var re = new float[n];
var im = new float[n];
for (var i = 0; i < n; i++)
{
var angle = 2.0 * Math.PI * i / (n - 1);
var w = (float)(a0 - a1 * Math.Cos(angle) + a2 * Math.Cos(2.0 * angle) - a3 * Math.Cos(3.0 * angle));
re[i] = samples[i] * w;
im[i] = 0f;
}
// Cooley-Tukey Radix-2 FFT
var m = (int)Math.Round(Math.Log(n, 2));
// Bit-reversal
var j = 0;
for (var i = 0; i < n - 1; i++)
{
if (i < j)
{
var temp = re[i];
re[i] = re[j];
re[j] = temp;
temp = im[i];
im[i] = im[j];
im[j] = temp;
}
var k = n / 2;
while (k <= j)
{
j -= k;
k /= 2;
}
j += k;
}
// FFT stages
for (var stage = 1; stage <= m; stage++)
{
var le = 1 << stage;
var le2 = le / 2;
var ur = 1f;
var ui = 0f;
var wr = (float)Math.Cos(Math.PI / le2);
var wi = -(float)Math.Sin(Math.PI / le2);
for (var sub = 0; sub < le2; sub++)
{
for (var item = sub; item < n; item += le)
{ {
var temp = re[i]; var ip = item + le2;
re[i] = re[j]; var tr = re[ip] * ur - im[ip] * ui;
re[j] = temp; var ti = re[ip] * ui + im[ip] * ur;
temp = im[i]; re[ip] = re[item] - tr;
im[i] = im[j]; im[ip] = im[item] - ti;
im[j] = temp; re[item] += tr;
im[item] += ti;
} }
var k = n / 2; var tempUr = ur;
while (k <= j) ur = tempUr * wr - ui * wi;
{ ui = tempUr * wi + ui * wr;
j -= k;
k /= 2;
}
j += k;
} }
}
// FFT stages // Normalize and fill spectrum
for (var stage = 1; stage <= m; stage++) var factor = 2.0f / n;
{ var outLen = Math.Min(spectrum.Length, n / 2);
var le = 1 << stage; for (var i = 0; i < outLen; i++)
var le2 = le / 2; {
var ur = 1f; spectrum[i] = (float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor;
var ui = 0f;
var wr = (float)Math.Cos(Math.PI / le2);
var wi = -(float)Math.Sin(Math.PI / le2);
for (var sub = 0; sub < le2; sub++)
{
for (var item = sub; item < n; item += le)
{
var ip = item + le2;
var tr = re[ip] * ur - im[ip] * ui;
var ti = re[ip] * ui + im[ip] * ur;
re[ip] = re[item] - tr;
im[ip] = im[item] - ti;
re[item] += tr;
im[item] += ti;
}
var tempUr = ur;
ur = tempUr * wr - ui * wi;
ui = tempUr * wi + ui * wr;
}
}
// Normalize and fill spectrum
var factor = 2.0f / n;
var outLen = Math.Min(spectrum.Length, n / 2);
for (var i = 0; i < outLen; i++)
{
spectrum[i] = (float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor;
}
} }
} }
} }
+30 -31
View File
@@ -1,41 +1,40 @@
using System; using System;
namespace ADOFAIRenderer.Util.Audio namespace ADOFAIRenderer.Util.Audio;
internal class AudioSyncCalculator
{ {
internal class AudioSyncCalculator private readonly long _sampleRate;
private readonly (long Numerator, long Denominator) _fpsRational;
// ReSharper disable once NotAccessedField.Local
private long _currentSample;
private long _remainder;
public AudioSyncCalculator(long sampleRate, double fps)
{ {
private readonly long _sampleRate; _sampleRate = sampleRate;
private readonly (long Numerator, long Denominator) _fpsRational; _fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
// ReSharper disable once NotAccessedField.Local // NTSC
private long _currentSample; if (Math.Abs(fps - 29.97) < 0.001) _fpsRational = (30000, 1001);
private long _remainder; if (Math.Abs(fps - 59.94) < 0.001) _fpsRational = (60000, 1001);
if (Math.Abs(fps - 23.976) < 0.001 || Math.Abs(fps - 23.98) < 0.001) _fpsRational = (24000, 1001);
}
public AudioSyncCalculator(long sampleRate, double fps) public void Reset()
{ {
_sampleRate = sampleRate; _currentSample = 0;
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps); _remainder = 0;
}
// NTSC public int GetNextFrameSamples()
if (Math.Abs(fps - 29.97) < 0.001) _fpsRational = (30000, 1001); {
if (Math.Abs(fps - 59.94) < 0.001) _fpsRational = (60000, 1001); var numerator = _sampleRate * _fpsRational.Denominator + _remainder;
if (Math.Abs(fps - 23.976) < 0.001 || Math.Abs(fps - 23.98) < 0.001) _fpsRational = (24000, 1001); var samplesNeeded = numerator / _fpsRational.Numerator;
}
public void Reset() _remainder = numerator % _fpsRational.Numerator;
{ _currentSample += samplesNeeded;
_currentSample = 0; return (int)samplesNeeded;
_remainder = 0;
}
public int GetNextFrameSamples()
{
var numerator = _sampleRate * _fpsRational.Denominator + _remainder;
var samplesNeeded = numerator / _fpsRational.Numerator;
_remainder = numerator % _fpsRational.Numerator;
_currentSample += samplesNeeded;
return (int)samplesNeeded;
}
} }
} }
+17 -18
View File
@@ -1,24 +1,23 @@
using System; using System;
namespace ADOFAIRenderer.Util.Audio.Filter namespace ADOFAIRenderer.Util.Audio.Filter;
internal class Gain : IAudioFilter
{ {
internal class Gain : IAudioFilter public float Level = 0f;
public void Initialize(int sampleRate, int channels) { }
public float ProcessSample(float sample, int channel) =>
sample * (float)Math.Pow(10, Level / 20);
public void ProcessBlock(Span<float> buffer)
{ {
public float Level = 0f; for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
public void Initialize(int sampleRate, int channels) { }
public float ProcessSample(float sample, int channel) =>
sample * (float)Math.Pow(10, Level / 20);
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
public void Reset() { }
public double Latency => 0.0;
} }
public void Reset() { }
public double Latency => 0.0;
} }
+25 -26
View File
@@ -1,33 +1,32 @@
using System; using System;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
namespace ADOFAIRenderer.Util.Audio.Filter namespace ADOFAIRenderer.Util.Audio.Filter;
internal class HardClip : IAudioFilter
{ {
internal class HardClip : IAudioFilter // ReSharper disable once MemberCanBePrivate.Global
// ReSharper disable once FieldCanBeMadeReadOnly.Global
// ReSharper disable once ConvertToConstant.Global
public float Threshold = 1.0f;
public void Initialize(int sampleRate, int channels) { }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
{ {
// ReSharper disable once MemberCanBePrivate.Global if (sample > Threshold) return Threshold;
// ReSharper disable once FieldCanBeMadeReadOnly.Global if (sample < -Threshold) return -Threshold;
// ReSharper disable once ConvertToConstant.Global return sample;
public float Threshold = 1.0f;
public void Initialize(int sampleRate, int channels) { }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
{
if (sample > Threshold) return Threshold;
if (sample < -Threshold) return -Threshold;
return sample;
}
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
public void Reset() { }
public double Latency => 0.0;
} }
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
public void Reset() { }
public double Latency => 0.0;
} }
+8 -9
View File
@@ -1,16 +1,15 @@
using System; using System;
namespace ADOFAIRenderer.Util.Audio.Filter namespace ADOFAIRenderer.Util.Audio.Filter;
internal interface IAudioFilter
{ {
internal interface IAudioFilter void Initialize(int sampleRate, int channels);
{
void Initialize(int sampleRate, int channels);
float ProcessSample(float sample, int channel); float ProcessSample(float sample, int channel);
void ProcessBlock(Span<float> buffer); void ProcessBlock(Span<float> buffer);
void Reset(); void Reset();
double Latency { get; } double Latency { get; }
}
} }
+218 -219
View File
@@ -1,229 +1,228 @@
using System; using System;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
namespace ADOFAIRenderer.Util.Audio.Filter namespace ADOFAIRenderer.Util.Audio.Filter;
// ReSharper disable once InconsistentNaming
internal class RMSCompressor : IAudioFilter
{ {
// ReSharper disable once InconsistentNaming private float _thresholdDb = -20f;
internal class RMSCompressor : IAudioFilter private float _ratio = 4f;
private float _attackTime = 0.010f; // in seconds
private float _releaseTime = 0.100f; // in seconds
private float _kneeDb = 5f; // in dB
private float _rmsWindowTime = 0.030f; // in seconds
private float _lookAheadTime; // in seconds
private int _lookAheadSamples;
private int _sampleRate = 44100;
private int _channels = 2;
private float _attackCoef;
private float _releaseCoef;
private float _rmsCoef;
// Per-channel state
private float[] _rmsState = Array.Empty<float>(); // running mean-square (MS) state
private float[] _gainState = Array.Empty<float>(); // smoothed gain reduction in dB
private float[][] _delayBuffers = Array.Empty<float[]>(); // circular delay buffers
private int[] _delayWriteIndices = Array.Empty<int>();
public float LookAheadTime
{ {
private float _thresholdDb = -20f; get => _lookAheadTime;
private float _ratio = 4f; set
private float _attackTime = 0.010f; // in seconds
private float _releaseTime = 0.100f; // in seconds
private float _kneeDb = 5f; // in dB
private float _rmsWindowTime = 0.030f; // in seconds
private float _lookAheadTime; // in seconds
private int _lookAheadSamples;
private int _sampleRate = 44100;
private int _channels = 2;
private float _attackCoef;
private float _releaseCoef;
private float _rmsCoef;
// Per-channel state
private float[] _rmsState = Array.Empty<float>(); // running mean-square (MS) state
private float[] _gainState = Array.Empty<float>(); // smoothed gain reduction in dB
private float[][] _delayBuffers = Array.Empty<float[]>(); // circular delay buffers
private int[] _delayWriteIndices = Array.Empty<int>();
public float LookAheadTime
{ {
get => _lookAheadTime; _lookAheadTime = Math.Max(0f, Math.Min(1.0f, value));
set
{
_lookAheadTime = Math.Max(0f, Math.Min(1.0f, value));
UpdateLookAheadSettings();
}
}
public double Latency => _lookAheadTime;
public float ThresholdDb
{
get => _thresholdDb;
set => _thresholdDb = value;
}
public float Ratio
{
get => _ratio;
set => _ratio = Math.Max(1f, value);
}
public float AttackTime
{
get => _attackTime;
set
{
_attackTime = Math.Max(0.0001f, value);
UpdateAttackCoef();
}
}
public float ReleaseTime
{
get => _releaseTime;
set
{
_releaseTime = Math.Max(0.0001f, value);
UpdateReleaseCoef();
}
}
public float KneeDb
{
get => _kneeDb;
set => _kneeDb = Math.Max(0f, value);
}
public float RmsWindowTime
{
get => _rmsWindowTime;
set
{
_rmsWindowTime = Math.Max(0.0001f, value);
UpdateRmsCoef();
}
}
public void Initialize(int sampleRate, int channels)
{
_sampleRate = sampleRate;
_channels = channels;
UpdateAttackCoef();
UpdateReleaseCoef();
UpdateRmsCoef();
UpdateLookAheadSettings(); UpdateLookAheadSettings();
_rmsState = new float[channels];
_gainState = new float[channels];
Reset();
}
private void UpdateLookAheadSettings()
{
_lookAheadSamples = (int)Math.Round(_sampleRate * _lookAheadTime);
ReallocateDelayBuffers();
}
private void ReallocateDelayBuffers()
{
if (_channels <= 0) return;
_delayBuffers = new float[_channels][];
_delayWriteIndices = new int[_channels];
for (int i = 0; i < _channels; i++)
{
_delayBuffers[i] = _lookAheadSamples > 0 ? new float[_lookAheadSamples] : Array.Empty<float>();
_delayWriteIndices[i] = 0;
}
}
private void UpdateAttackCoef()
{
_attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime));
}
private void UpdateReleaseCoef()
{
_releaseCoef = (float)Math.Exp(-1.0 / (_sampleRate * _releaseTime));
}
private void UpdateRmsCoef()
{
_rmsCoef = (float)Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
{
if (channel < 0 || channel >= _channels)
{
return sample;
}
// Calculate running Mean-Square (MS) level
// v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n]
float sq = sample * sample;
_rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0f - _rmsCoef) * sq;
float rmsSq = Math.Max(0f, _rmsState[channel]);
// Convert to dB. 10 * log10(rmsSq) is equivalent to 20 * log10(sqrt(rmsSq))
float rmsDb = 10.0f * (float)Math.Log10(rmsSq + 1e-12f);
// Calculate static envelope curve (gain reduction in dB)
float gStatic = 0f;
if (_kneeDb > 0f)
{
if (rmsDb > _thresholdDb + _kneeDb * 0.5f)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
}
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5f)
{
float diff = rmsDb - _thresholdDb + _kneeDb * 0.5f;
gStatic = (1f / _ratio - 1f) * diff * diff / (2f * _kneeDb);
}
}
else
{
if (rmsDb > _thresholdDb)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
}
}
// Apply ballistics (attack and release) on the dB gain reduction
float lastGain = _gainState[channel];
float targetGain = gStatic;
// Note: Since targetGain and lastGain are <= 0 (gain reduction in dB),
// targetGain < lastGain means target gain reduction is larger (more negative),
// so we are in the attack phase.
float coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
float currentGain = coef * lastGain + (1f - coef) * targetGain;
_gainState[channel] = currentGain;
// Convert back to linear gain multiplier
float linearGain = (float)Math.Pow(10.0, currentGain / 20.0);
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
{
var buffer = _delayBuffers[channel];
int writeIdx = _delayWriteIndices[channel];
float delayedSample = buffer[writeIdx];
buffer[writeIdx] = sample;
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
return delayedSample * linearGain;
}
return sample * linearGain;
}
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
{
int channel = i % _channels;
buffer[i] = ProcessSample(buffer[i], channel);
}
}
public void Reset()
{
Array.Clear(_rmsState, 0, _rmsState.Length);
Array.Clear(_gainState, 0, _gainState.Length);
for (int i = 0; i < _delayBuffers.Length; i++)
{
Array.Clear(_delayBuffers[i], 0, _delayBuffers[i].Length);
}
Array.Clear(_delayWriteIndices, 0, _delayWriteIndices.Length);
} }
} }
public double Latency => _lookAheadTime;
public float ThresholdDb
{
get => _thresholdDb;
set => _thresholdDb = value;
}
public float Ratio
{
get => _ratio;
set => _ratio = Math.Max(1f, value);
}
public float AttackTime
{
get => _attackTime;
set
{
_attackTime = Math.Max(0.0001f, value);
UpdateAttackCoef();
}
}
public float ReleaseTime
{
get => _releaseTime;
set
{
_releaseTime = Math.Max(0.0001f, value);
UpdateReleaseCoef();
}
}
public float KneeDb
{
get => _kneeDb;
set => _kneeDb = Math.Max(0f, value);
}
public float RmsWindowTime
{
get => _rmsWindowTime;
set
{
_rmsWindowTime = Math.Max(0.0001f, value);
UpdateRmsCoef();
}
}
public void Initialize(int sampleRate, int channels)
{
_sampleRate = sampleRate;
_channels = channels;
UpdateAttackCoef();
UpdateReleaseCoef();
UpdateRmsCoef();
UpdateLookAheadSettings();
_rmsState = new float[channels];
_gainState = new float[channels];
Reset();
}
private void UpdateLookAheadSettings()
{
_lookAheadSamples = (int)Math.Round(_sampleRate * _lookAheadTime);
ReallocateDelayBuffers();
}
private void ReallocateDelayBuffers()
{
if (_channels <= 0) return;
_delayBuffers = new float[_channels][];
_delayWriteIndices = new int[_channels];
for (int i = 0; i < _channels; i++)
{
_delayBuffers[i] = _lookAheadSamples > 0 ? new float[_lookAheadSamples] : Array.Empty<float>();
_delayWriteIndices[i] = 0;
}
}
private void UpdateAttackCoef()
{
_attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime));
}
private void UpdateReleaseCoef()
{
_releaseCoef = (float)Math.Exp(-1.0 / (_sampleRate * _releaseTime));
}
private void UpdateRmsCoef()
{
_rmsCoef = (float)Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
{
if (channel < 0 || channel >= _channels)
{
return sample;
}
// Calculate running Mean-Square (MS) level
// v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n]
float sq = sample * sample;
_rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0f - _rmsCoef) * sq;
float rmsSq = Math.Max(0f, _rmsState[channel]);
// Convert to dB. 10 * log10(rmsSq) is equivalent to 20 * log10(sqrt(rmsSq))
float rmsDb = 10.0f * (float)Math.Log10(rmsSq + 1e-12f);
// Calculate static envelope curve (gain reduction in dB)
float gStatic = 0f;
if (_kneeDb > 0f)
{
if (rmsDb > _thresholdDb + _kneeDb * 0.5f)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
}
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5f)
{
float diff = rmsDb - _thresholdDb + _kneeDb * 0.5f;
gStatic = (1f / _ratio - 1f) * diff * diff / (2f * _kneeDb);
}
}
else
{
if (rmsDb > _thresholdDb)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
}
}
// Apply ballistics (attack and release) on the dB gain reduction
float lastGain = _gainState[channel];
float targetGain = gStatic;
// Note: Since targetGain and lastGain are <= 0 (gain reduction in dB),
// targetGain < lastGain means target gain reduction is larger (more negative),
// so we are in the attack phase.
float coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
float currentGain = coef * lastGain + (1f - coef) * targetGain;
_gainState[channel] = currentGain;
// Convert back to linear gain multiplier
float linearGain = (float)Math.Pow(10.0, currentGain / 20.0);
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
{
var buffer = _delayBuffers[channel];
int writeIdx = _delayWriteIndices[channel];
float delayedSample = buffer[writeIdx];
buffer[writeIdx] = sample;
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
return delayedSample * linearGain;
}
return sample * linearGain;
}
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
{
int channel = i % _channels;
buffer[i] = ProcessSample(buffer[i], channel);
}
}
public void Reset()
{
Array.Clear(_rmsState, 0, _rmsState.Length);
Array.Clear(_gainState, 0, _gainState.Length);
foreach (var delayBuffer in _delayBuffers)
{
Array.Clear(delayBuffer, 0, delayBuffer.Length);
}
Array.Clear(_delayWriteIndices, 0, _delayWriteIndices.Length);
}
} }
+332 -333
View File
@@ -5,413 +5,412 @@ using ADOFAIRenderer.Util.Audio.Filter;
using MelonLoader; using MelonLoader;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Util.Audio namespace ADOFAIRenderer.Util.Audio;
internal class AudioClipCache
{ {
internal class AudioClipCache private readonly Dictionary<string, CachedClipData> _cache = new();
public class CachedClipData
{ {
private readonly Dictionary<string, CachedClipData> _cache = new(); public float[] Samples = null!; // Pre-resampled to target channels and sample rate
public double Duration;
}
public class CachedClipData public CachedClipData? GetOrCache(string clipName, int targetChannels, int targetSampleRate, bool allowLoad = true)
{
if (string.IsNullOrEmpty(clipName)) return null;
lock (_cache)
{ {
public float[] Samples = null!; // Pre-resampled to target channels and sample rate if (_cache.TryGetValue(clipName, out var cached))
public double Duration; return cached;
}
public CachedClipData? GetOrCache(string clipName, int targetChannels, int targetSampleRate, bool allowLoad = true) if (!allowLoad) return null;
{
if (string.IsNullOrEmpty(clipName)) return null;
lock (_cache) if (!AudioManager.Instance) return null;
var clip = AudioManager.Instance.FindOrLoadAudioClip(clipName);
if (!clip) return null;
try
{ {
if (_cache.TryGetValue(clipName, out var cached)) var totalSamples = clip.samples;
return cached; var channels = clip.channels;
var rawSamples = new float[totalSamples * channels];
clip.GetData(rawSamples, 0);
if (!allowLoad) return null; // Auto-resample during cache generation
var resampled = AudioResampler.Resample(rawSamples, channels, clip.frequency, targetChannels, targetSampleRate);
if (!AudioManager.Instance) return null; var data = new CachedClipData
var clip = AudioManager.Instance.FindOrLoadAudioClip(clipName);
if (!clip) return null;
try
{ {
var totalSamples = clip.samples; Samples = resampled,
var channels = clip.channels; Duration = (double)resampled.Length / (targetChannels * targetSampleRate)
var rawSamples = new float[totalSamples * channels]; };
clip.GetData(rawSamples, 0);
// Auto-resample during cache generation _cache[clipName] = data;
var resampled = AudioResampler.Resample(rawSamples, channels, clip.frequency, targetChannels, targetSampleRate); return data;
var data = new CachedClipData
{
Samples = resampled,
Duration = (double)resampled.Length / (targetChannels * targetSampleRate)
};
_cache[clipName] = data;
return data;
}
catch (Exception e)
{
MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}");
return null;
}
} }
} catch (Exception e)
public void Clear()
{
lock (_cache)
{ {
_cache.Clear(); MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}");
return null;
} }
} }
} }
internal class OfflineAudioMixer public void Clear()
{ {
public class SoundEvent lock (_cache)
{ {
public string ClipName = null!; _cache.Clear();
public double StartTime; // In seconds relative to dspTimeBase
public double EndTime; // In seconds relative to dspTimeBase (-1 if one-shot)
public float Volume;
public float Pitch;
} }
}
}
private readonly List<SoundEvent> _activeEvents = new(); internal class OfflineAudioMixer
private ConditionalWeakTable<AudioSource, SoundEvent> _sourceMappings = new(); {
private readonly object _lock = new(); public class SoundEvent
{
public string ClipName = null!;
public double StartTime; // In seconds relative to dspTimeBase
public double EndTime; // In seconds relative to dspTimeBase (-1 if one-shot)
public float Volume;
public float Pitch;
}
// Target Audio Settings private readonly List<SoundEvent> _activeEvents = new();
private readonly int _targetChannels; private ConditionalWeakTable<AudioSource, SoundEvent> _sourceMappings = new();
private readonly int _targetSampleRate; private readonly object _lock = new();
private const float ClipVolume = .5f;
// Audio Filter Settings // Target Audio Settings
private readonly LinkedList<IAudioFilter> _filters = new(); private readonly int _targetChannels;
private readonly int _targetSampleRate;
private const float ClipVolume = .5f;
// BGM Cached Samples (Pre-resampled) // Audio Filter Settings
private float[]? _bgmSamples; private readonly LinkedList<IAudioFilter> _filters = new();
private double _bgmDuration;
// Clip Cache // BGM Cached Samples (Pre-resampled)
private readonly AudioClipCache _clipCache = new(); private float[]? _bgmSamples;
private bool _isPrimed; private double _bgmDuration;
public OfflineAudioMixer(int targetChannels, int targetSampleRate) // Clip Cache
private readonly AudioClipCache _clipCache = new();
private bool _isPrimed;
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
{
_targetChannels = targetChannels;
_targetSampleRate = targetSampleRate;
var rmsCompressor = new RMSCompressor
{ {
_targetChannels = targetChannels; ThresholdDb = -10, Ratio = 2,
_targetSampleRate = targetSampleRate; AttackTime = 0.1f, ReleaseTime = 0.1f,
KneeDb = 10, RmsWindowTime = 0.02f
};
rmsCompressor.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(rmsCompressor);
var rmsCompressor = new RMSCompressor var limiter = new RMSCompressor
{
ThresholdDb = -10, Ratio = 2,
AttackTime = 0.1f, ReleaseTime = 0.1f,
KneeDb = 10, RmsWindowTime = 0.02f
};
rmsCompressor.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(rmsCompressor);
var limiter = new RMSCompressor
{
ThresholdDb = 0, Ratio = float.PositiveInfinity,
AttackTime = 0f, ReleaseTime = 0.05f,
KneeDb = 0, RmsWindowTime = 0.02f,
LookAheadTime = 0.01f
};
limiter.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(limiter);
var hardClip = new HardClip();
hardClip.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(hardClip);
var truePeakSafetyGain = new Gain { Level = -1f };
truePeakSafetyGain.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(truePeakSafetyGain);
}
public void InitializeBGM(AudioClip? clip)
{ {
lock (_lock) ThresholdDb = 0, Ratio = float.PositiveInfinity,
{ AttackTime = 0f, ReleaseTime = 0.05f,
if (!clip) KneeDb = 0, RmsWindowTime = 0.02f,
{ LookAheadTime = 0.01f
_bgmSamples = null; };
_bgmDuration = 0; limiter.Initialize(_targetSampleRate, _targetChannels);
return; _filters.AddLast(limiter);
}
try var hardClip = new HardClip();
{ hardClip.Initialize(_targetSampleRate, _targetChannels);
int originalSamples = clip.samples; _filters.AddLast(hardClip);
int originalChannels = clip.channels;
float[] rawSamples = new float[originalSamples * originalChannels];
clip.GetData(rawSamples, 0);
// Pre-resample the BGM to the target format var truePeakSafetyGain = new Gain { Level = -1f };
_bgmSamples = AudioResampler.Resample(rawSamples, originalChannels, clip.frequency, _targetChannels, _targetSampleRate); truePeakSafetyGain.Initialize(_targetSampleRate, _targetChannels);
_bgmDuration = (double)_bgmSamples.Length / (_targetChannels * _targetSampleRate); _filters.AddLast(truePeakSafetyGain);
}
Melon<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}"); public void InitializeBGM(AudioClip? clip)
} {
catch (Exception e) lock (_lock)
{
Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}");
_bgmSamples = null;
_bgmDuration = 0;
}
}
}
public void CacheClip(string clipName)
{ {
_clipCache.GetOrCache(clipName, _targetChannels, _targetSampleRate, allowLoad: true); if (!clip)
}
public void RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch)
{
lock (_lock)
{ {
var ev = new SoundEvent
{
ClipName = clipName,
StartTime = startTime,
EndTime = endTime,
Volume = volume,
Pitch = pitch
};
_activeEvents.Add(ev);
if (!source) return;
_sourceMappings.Remove(source);
_sourceMappings.Add(source, ev);
}
}
public void RegisterSoundEvent(string clipName, double startTime, double endTime, float volume, float pitch)
{
RegisterSoundEvent(null, clipName, startTime, endTime, volume, pitch);
}
public bool TryGetSoundEvent(AudioSource? source, out SoundEvent ev)
{
if (source == null)
{
ev = null!;
return false;
}
lock (_lock)
{
return _sourceMappings.TryGetValue(source, out ev);
}
}
public bool RemoveSourceMapping(AudioSource source)
{
lock (_lock)
{
return _sourceMappings.Remove(source);
}
}
public void Reset()
{
lock (_lock)
{
_activeEvents.Clear();
_sourceMappings = new ConditionalWeakTable<AudioSource, SoundEvent>();
_bgmSamples = null; _bgmSamples = null;
_bgmDuration = 0; _bgmDuration = 0;
_clipCache.Clear(); return;
_isPrimed = false;
foreach (var filter in _filters)
{
filter.Reset();
}
} }
}
private void PrimeFilters(double totalLatency, double dspTimeBase)
{
int latencySamples = (int)Math.Round(totalLatency * _targetSampleRate);
if (latencySamples <= 0) return;
// Rent temporary buffer to prime filters with initial audio
float[] tempBuffer = System.Buffers.ArrayPool<float>.Shared.Rent(latencySamples * _targetChannels);
try try
{ {
Array.Clear(tempBuffer, 0, latencySamples * _targetChannels); int originalSamples = clip.samples;
int originalChannels = clip.channels;
float[] rawSamples = new float[originalSamples * originalChannels];
clip.GetData(rawSamples, 0);
// Mix starting from -totalLatency to 0 // Pre-resample the BGM to the target format
MixBGM(tempBuffer, latencySamples, -totalLatency, dspTimeBase, totalLatency); _bgmSamples = AudioResampler.Resample(rawSamples, originalChannels, clip.frequency, _targetChannels, _targetSampleRate);
MixEffectSounds(tempBuffer, latencySamples, -totalLatency, totalLatency); _bgmDuration = (double)_bgmSamples.Length / (_targetChannels * _targetSampleRate);
// Feed the buffer into filters to prime circular delay buffers Melon<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}");
foreach (var filter in _filters)
{
filter.ProcessBlock(tempBuffer.AsSpan(0, latencySamples * _targetChannels));
}
} }
finally catch (Exception e)
{ {
System.Buffers.ArrayPool<float>.Shared.Return(tempBuffer); Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}");
_bgmSamples = null;
_bgmDuration = 0;
} }
} }
}
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase) public void CacheClip(string clipName)
{
_clipCache.GetOrCache(clipName, _targetChannels, _targetSampleRate, allowLoad: true);
}
public void RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch)
{
lock (_lock)
{ {
// mixedBuffer size must be at least samplesNeeded * _targetChannels var ev = new SoundEvent
Array.Clear(mixedBuffer, 0, samplesNeeded * _targetChannels);
lock (_lock)
{ {
double totalLatency = 0; ClipName = clipName,
foreach (var filter in _filters) StartTime = startTime,
EndTime = endTime,
Volume = volume,
Pitch = pitch
};
_activeEvents.Add(ev);
if (!source) return;
_sourceMappings.Remove(source);
_sourceMappings.Add(source, ev);
}
}
public void RegisterSoundEvent(string clipName, double startTime, double endTime, float volume, float pitch)
{
RegisterSoundEvent(null, clipName, startTime, endTime, volume, pitch);
}
public bool TryGetSoundEvent(AudioSource? source, out SoundEvent ev)
{
if (source == null)
{
ev = null!;
return false;
}
lock (_lock)
{
return _sourceMappings.TryGetValue(source, out ev);
}
}
public bool RemoveSourceMapping(AudioSource source)
{
lock (_lock)
{
return _sourceMappings.Remove(source);
}
}
public void Reset()
{
lock (_lock)
{
_activeEvents.Clear();
_sourceMappings = new ConditionalWeakTable<AudioSource, SoundEvent>();
_bgmSamples = null;
_bgmDuration = 0;
_clipCache.Clear();
_isPrimed = false;
foreach (var filter in _filters)
{
filter.Reset();
}
}
}
private void PrimeFilters(double totalLatency, double dspTimeBase)
{
int latencySamples = (int)Math.Round(totalLatency * _targetSampleRate);
if (latencySamples <= 0) return;
// Rent temporary buffer to prime filters with initial audio
float[] tempBuffer = System.Buffers.ArrayPool<float>.Shared.Rent(latencySamples * _targetChannels);
try
{
Array.Clear(tempBuffer, 0, latencySamples * _targetChannels);
// Mix starting from -totalLatency to 0
MixBGM(tempBuffer, latencySamples, -totalLatency, dspTimeBase, totalLatency);
MixEffectSounds(tempBuffer, latencySamples, -totalLatency, totalLatency);
// Feed the buffer into filters to prime circular delay buffers
foreach (var filter in _filters)
{
filter.ProcessBlock(tempBuffer.AsSpan(0, latencySamples * _targetChannels));
}
}
finally
{
System.Buffers.ArrayPool<float>.Shared.Return(tempBuffer);
}
}
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase)
{
// mixedBuffer size must be at least samplesNeeded * _targetChannels
Array.Clear(mixedBuffer, 0, samplesNeeded * _targetChannels);
lock (_lock)
{
double totalLatency = 0;
foreach (var filter in _filters)
{
totalLatency += filter.Latency;
}
// Prime filters on first frame execution if latency exists
if (!_isPrimed && totalLatency > 0)
{
PrimeFilters(totalLatency, dspTimeBase);
_isPrimed = true;
}
// 1. Mix BGM
MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase, totalLatency);
// 2. Mix Hitsounds/Effect Sounds
MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime, totalLatency);
foreach (var filter in _filters)
filter.ProcessBlock(mixedBuffer.AsSpan(0, samplesNeeded * _targetChannels));
for (var i = 0; i < samplesNeeded * _targetChannels; i++)
{
if (Math.Abs(mixedBuffer[i]) > 1) Melon<RenderMod>.Logger.Msg("Warning Why Audio Sample Is Larger Than 1");
}
}
}
private void MixBGM(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase, double totalLatency)
{
if (_bgmSamples == null || _bgmSamples.Length == 0) return;
var conductor = scrConductor.instance;
if (!conductor) return;
double pitch = conductor.song.pitch;
var dspTimeSong = conductor.dspTimeSong;
var separateCountdownOffset = conductor.separateCountdownTime
? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks
: 0.0;
// BGM Playback start time relative to dspTimeBase
var time1 = dspTimeSong + separateCountdownOffset;
var relativeTime1 = time1 - dspTimeBase;
var adjustedRelativeTime1 = relativeTime1 - totalLatency;
var totalSamples = _bgmSamples.Length / _targetChannels;
for (int s = 0; s < samplesNeeded; s++)
{
double sampleTime = frameStartTime + (double)s / _targetSampleRate;
double clipTime = (sampleTime - adjustedRelativeTime1) * pitch;
if (clipTime >= 0 && clipTime < _bgmDuration)
{
double sampleIdxDouble = clipTime * _targetSampleRate;
long idxLow = (long)Math.Floor(sampleIdxDouble);
long idxHigh = idxLow + 1;
float t = (float)(sampleIdxDouble - idxLow);
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
for (var c = 0; c < _targetChannels; c++)
{ {
totalLatency += filter.Latency; float valLow = _bgmSamples[idxLow * _targetChannels + c];
} float valHigh = _bgmSamples[idxHigh * _targetChannels + c];
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * (scnGame.instance.levelData.volume * 0.01f)
// Prime filters on first frame execution if latency exists * ClipVolume;
if (!_isPrimed && totalLatency > 0)
{
PrimeFilters(totalLatency, dspTimeBase);
_isPrimed = true;
}
// 1. Mix BGM
MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase, totalLatency);
// 2. Mix Hitsounds/Effect Sounds
MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime, totalLatency);
foreach (var filter in _filters)
filter.ProcessBlock(mixedBuffer.AsSpan(0, samplesNeeded * _targetChannels));
for (var i = 0; i < samplesNeeded * _targetChannels; i++)
{
if (Math.Abs(mixedBuffer[i]) > 1) Melon<RenderMod>.Logger.Msg("Warning Why Audio Sample Is Larger Than 1");
} }
} }
} }
}
private void MixBGM(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase, double totalLatency) private void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double totalLatency)
{
for (var i = _activeEvents.Count - 1; i >= 0; i--)
{ {
if (_bgmSamples == null || _bgmSamples.Length == 0) return; var ev = _activeEvents[i];
var clipData = _clipCache.GetOrCache(ev.ClipName, _targetChannels, _targetSampleRate, allowLoad: false);
var conductor = scrConductor.instance; if (clipData == null)
if (!conductor) return;
double pitch = conductor.song.pitch;
var dspTimeSong = conductor.dspTimeSong;
var separateCountdownOffset = conductor.separateCountdownTime
? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks
: 0.0;
// BGM Playback start time relative to dspTimeBase
var time1 = dspTimeSong + separateCountdownOffset;
var relativeTime1 = time1 - dspTimeBase;
var adjustedRelativeTime1 = relativeTime1 - totalLatency;
var totalSamples = _bgmSamples.Length / _targetChannels;
for (int s = 0; s < samplesNeeded; s++)
{ {
double sampleTime = frameStartTime + (double)s / _targetSampleRate; _activeEvents.RemoveAt(i);
double clipTime = (sampleTime - adjustedRelativeTime1) * pitch; continue;
if (clipTime >= 0 && clipTime < _bgmDuration)
{
double sampleIdxDouble = clipTime * _targetSampleRate;
long idxLow = (long)Math.Floor(sampleIdxDouble);
long idxHigh = idxLow + 1;
float t = (float)(sampleIdxDouble - idxLow);
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
for (var c = 0; c < _targetChannels; c++)
{
float valLow = _bgmSamples[idxLow * _targetChannels + c];
float valHigh = _bgmSamples[idxHigh * _targetChannels + c];
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * (scnGame.instance.levelData.volume * 0.01f)
* ClipVolume;
}
}
} }
}
private void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double totalLatency) var isSoundFinished = false;
{ var totalSamples = clipData.Samples.Length / _targetChannels;
for (var i = _activeEvents.Count - 1; i >= 0; i--) double adjustedStartTime = ev.StartTime - totalLatency;
double adjustedEndTime = ev.EndTime >= 0 ? ev.EndTime - totalLatency : -1.0;
for (var s = 0; s < samplesNeeded; s++)
{ {
var ev = _activeEvents[i]; var sampleTime = frameStartTime + (double)s / _targetSampleRate;
var clipData = _clipCache.GetOrCache(ev.ClipName, _targetChannels, _targetSampleRate, allowLoad: false);
if (clipData == null) // If it is a looping sound and has reached or exceeded its end time, stop playing.
if (adjustedEndTime >= 0 && sampleTime >= adjustedEndTime)
{ {
_activeEvents.RemoveAt(i); isSoundFinished = true;
continue; break;
} }
var isSoundFinished = false; var elapsed = sampleTime - adjustedStartTime;
var totalSamples = clipData.Samples.Length / _targetChannels; if (elapsed < 0) continue; // Sound hasn't started yet
double adjustedStartTime = ev.StartTime - totalLatency;
double adjustedEndTime = ev.EndTime >= 0 ? ev.EndTime - totalLatency : -1.0;
for (var s = 0; s < samplesNeeded; s++) var soundTime = elapsed * ev.Pitch;
if (soundTime >= clipData.Duration)
{ {
var sampleTime = frameStartTime + (double)s / _targetSampleRate; if (ev.EndTime < 0) // One-shot
// If it is a looping sound and has reached or exceeded its end time, stop playing.
if (adjustedEndTime >= 0 && sampleTime >= adjustedEndTime)
{ {
isSoundFinished = true; isSoundFinished = true;
break; break;
} }
var elapsed = sampleTime - adjustedStartTime; // Looping
if (elapsed < 0) continue; // Sound hasn't started yet soundTime %= clipData.Duration;
var soundTime = elapsed * ev.Pitch;
if (soundTime >= clipData.Duration)
{
if (ev.EndTime < 0) // One-shot
{
isSoundFinished = true;
break;
}
// Looping
soundTime %= clipData.Duration;
}
// Linear interpolation for pitch shifting in the mixer loop
double sampleIdxDouble = soundTime * _targetSampleRate;
long idxLow = (long)Math.Floor(sampleIdxDouble);
long idxHigh = idxLow + 1;
float t = (float)(sampleIdxDouble - idxLow);
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
for (var c = 0; c < _targetChannels; c++)
{
float valLow = clipData.Samples[idxLow * _targetChannels + c];
float valHigh = clipData.Samples[idxHigh * _targetChannels + c];
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * ev.Volume
* ClipVolume * 2 / 3;
}
} }
// Clean up finished sound events (both one-shot and looping) // Linear interpolation for pitch shifting in the mixer loop
if (isSoundFinished) double sampleIdxDouble = soundTime * _targetSampleRate;
long idxLow = (long)Math.Floor(sampleIdxDouble);
long idxHigh = idxLow + 1;
float t = (float)(sampleIdxDouble - idxLow);
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
for (var c = 0; c < _targetChannels; c++)
{ {
_activeEvents.RemoveAt(i); float valLow = clipData.Samples[idxLow * _targetChannels + c];
float valHigh = clipData.Samples[idxHigh * _targetChannels + c];
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * ev.Volume
* ClipVolume * 2 / 3;
} }
} }
// Clean up finished sound events (both one-shot and looping)
if (isSoundFinished)
{
_activeEvents.RemoveAt(i);
}
} }
} }
} }