2026-05-26 00:21:27 -04:00
|
|
|
|
using System;
|
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
|
using Cielonos.MainGame.UI;
|
|
|
|
|
|
using Sirenix.OdinInspector;
|
|
|
|
|
|
using SLSUtilities.WwiseAssistance;
|
|
|
|
|
|
using UnityEngine;
|
|
|
|
|
|
|
|
|
|
|
|
namespace Cielonos.MainGame
|
|
|
|
|
|
{
|
2026-06-30 01:48:58 -04:00
|
|
|
|
[System.Serializable]
|
|
|
|
|
|
public struct SegmentDataMapping
|
|
|
|
|
|
{
|
|
|
|
|
|
[Tooltip("Wwise 中 Music Segment 的名称 (例如 BGM_Intensity_01)")]
|
|
|
|
|
|
public string segmentName;
|
|
|
|
|
|
[Tooltip("对应的 Unity MusicBeatData 谱面数据")]
|
|
|
|
|
|
public MusicBeatData beatData;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 局部节拍追踪器接口,用于接收物体上局部播放的音乐片段切换事件
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public interface ILocalRhythmTracker
|
|
|
|
|
|
{
|
|
|
|
|
|
void OnSegmentTransitioned(MusicBeatData localBeatData);
|
|
|
|
|
|
void ReceiveSyncBeat(float musicPositionSec, float beatDuration);
|
|
|
|
|
|
bool IsPlaying { get; }
|
|
|
|
|
|
float CurrentSongTime { get; }
|
|
|
|
|
|
MusicBeatData CurrentBeatData { get; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 00:21:27 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 音乐节拍战斗系统。激活时覆盖 BackgroundMusicManager 播放对应 BGM,
|
|
|
|
|
|
/// 通过 Wwise AK_MusicSyncBeat 回调 + MusicBeatData 谱面进行双轨节拍追踪,
|
|
|
|
|
|
/// 对外提供 Judge / IsOnBeat / GetBeatAccuracy 判定 API 和节拍事件
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public class MusicBeatSystem : CombatSystemBase
|
|
|
|
|
|
{
|
|
|
|
|
|
#region Constants
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Perfect 判定窗口(秒),节拍前后各此值范围内判定为 Perfect
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private const float PERFECT_TOLERANCE = 0.15f;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Good 判定窗口(秒)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private const float GOOD_TOLERANCE = 0.2f;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Miss 判定窗口(秒),超出此范围不触发判定
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private const float MISS_TOLERANCE = 0.2f;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Wwise 回调到 Unity 主线程的时间吸附阈值(秒),
|
|
|
|
|
|
/// 超过此值的校准可能是异常数据,忽略
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private const float SYNC_SNAP_THRESHOLD = 0.5f;
|
|
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Public State
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 当前加载的谱面数据
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[ShowInInspector, ReadOnly]
|
|
|
|
|
|
public MusicBeatData CurrentBeatData { get; private set; }
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 系统是否已激活(谱面已加载、BGM 已请求播放)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[ShowInInspector, ReadOnly]
|
|
|
|
|
|
public bool IsActive { get; private set; }
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Wwise 音乐是否已真正开始播放(首次 MusicSync 回调到来后为 true)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[ShowInInspector, ReadOnly]
|
|
|
|
|
|
public bool IsPlaying { get; private set; }
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 当前音乐播放时间(秒),由 deltaTime 推进 + Wwise 回调校准
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[ShowInInspector, ReadOnly]
|
|
|
|
|
|
public float CurrentSongTime { get; private set; }
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 当前实际 BPM(由 Wwise 回调报告的 beatDuration 反推,若无回调则使用谱面 BPM)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[ShowInInspector, ReadOnly]
|
|
|
|
|
|
public float CurrentBPM { get; private set; }
|
|
|
|
|
|
|
2026-06-30 01:48:58 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 当前播放的 Wwise Segment 名称 (例如 Func_00)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[ShowInInspector, ReadOnly]
|
|
|
|
|
|
public string CurrentWwiseSegmentName { get; private set; }
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 当前小节内的拍号(1-based)。由 Wwise MusicSyncBeat 回调每拍递增,在 Entry 时归 1。
|
|
|
|
|
|
/// 例如 4/4 拍时值为 1~4 循环。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[ShowInInspector, ReadOnly]
|
|
|
|
|
|
public int CurrentBarBeat { get; private set; } = 1;
|
|
|
|
|
|
|
2026-05-26 00:21:27 -04:00
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Events
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 每次到达谱面中的节拍时触发(UI 和敌人 AI 订阅)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public event Action<BeatMarker> OnBeat;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 玩家操作被判定后触发
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public event Action<BeatJudgement> OnPlayerBeatJudged;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 系统激活时触发
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public event Action<MusicBeatData> OnActivated;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 系统停用时触发
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public event Action OnDeactivated;
|
|
|
|
|
|
|
2026-06-30 01:48:58 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 当收到任何 Wwise User Cue 时触发。
|
|
|
|
|
|
/// 参数:1. playingID, 2. cueName。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public event Action<uint, string> OnUserCueReceived;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 当全局主音乐(MusicBeatSystem 自身 GameObject)收到 PrepareNext Cue 时触发。
|
|
|
|
|
|
/// BGM 系统可以监听此事件,在音乐边界精确地起播 Back 音轨。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public event Action OnGlobalPrepareNext;
|
|
|
|
|
|
|
2026-05-26 00:21:27 -04:00
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Private Fields
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 下一个待触发的节拍索引(在 beatMarkers 中的 index)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private int nextBeatIndex;
|
|
|
|
|
|
|
2026-06-30 01:48:58 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 由 MusicSyncBeat 回调累计的原始拍号计数(每个 Entry 时清零),用于推算 CurrentBarBeat
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private int rawBeatCount;
|
|
|
|
|
|
|
2026-05-26 00:21:27 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Wwise 播放实例 ID
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private uint wwisePlayingID;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// BackgroundMusicManager 引用缓存
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private BackgroundMusicManager bgmManager;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Wwise 回调产生的待处理校准数据(从回调线程安全传递到主线程)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[ShowInInspector]
|
|
|
|
|
|
private volatile float pendingSyncTime = -1f;
|
|
|
|
|
|
|
|
|
|
|
|
[ShowInInspector]
|
|
|
|
|
|
private volatile float pendingBeatDuration = -1f;
|
|
|
|
|
|
|
2026-06-30 01:48:58 -04:00
|
|
|
|
[Header("Dynamic Music Configurations")]
|
|
|
|
|
|
[Tooltip("Wwise 中控制音乐片段切换的 Switch Group 名称")]
|
|
|
|
|
|
public string musicSegmentSwitchGroup = "Music_Segment";
|
|
|
|
|
|
|
|
|
|
|
|
[Tooltip("Wwise 音乐片段与 Unity 谱面资产的映射表")]
|
|
|
|
|
|
public List<SegmentDataMapping> segmentMappings = new List<SegmentDataMapping>();
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 当音乐运行到 PrepareNext 标记时触发。
|
|
|
|
|
|
/// 参数:1. 触发该事件的 GameObject,2. 当前播放的片段名称。
|
|
|
|
|
|
/// 返回值:下一个要播放的片段名(Switch 值)。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public event Func<GameObject, string, string> OnPrepareNextSegment;
|
|
|
|
|
|
|
|
|
|
|
|
private struct PendingCallbackData
|
|
|
|
|
|
{
|
|
|
|
|
|
public enum Type { Entry, Prepare, BeatSync }
|
|
|
|
|
|
public Type callbackType;
|
|
|
|
|
|
public GameObject targetGO;
|
|
|
|
|
|
public string segmentName;
|
|
|
|
|
|
public float musicPositionSec;
|
|
|
|
|
|
public float beatDuration;
|
|
|
|
|
|
public uint playingID;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private readonly List<PendingCallbackData> pendingCallbacks = new List<PendingCallbackData>();
|
|
|
|
|
|
private readonly object callbackLock = new object();
|
|
|
|
|
|
|
|
|
|
|
|
private static readonly List<GameObject> activeRhythmGameObjects = new List<GameObject>();
|
|
|
|
|
|
|
2026-05-26 00:21:27 -04:00
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Lifecycle
|
|
|
|
|
|
|
|
|
|
|
|
private void Awake()
|
|
|
|
|
|
{
|
|
|
|
|
|
bgmManager = AudioManager.Instance.backgroundMusicManager;
|
2026-06-30 01:48:58 -04:00
|
|
|
|
RegisterRhythmGameObject(gameObject);
|
2026-05-26 00:21:27 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void Update()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!IsActive) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 处理 Wwise 回调带来的时间校准
|
|
|
|
|
|
ProcessPendingSync();
|
|
|
|
|
|
|
2026-06-30 01:48:58 -04:00
|
|
|
|
// 处理 Entry/Prepare 等回调队列事件
|
|
|
|
|
|
ProcessPendingCallbacks();
|
|
|
|
|
|
|
2026-05-26 00:21:27 -04:00
|
|
|
|
if (!IsPlaying) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 推进音乐时间
|
|
|
|
|
|
CurrentSongTime += Time.deltaTime;
|
|
|
|
|
|
|
|
|
|
|
|
// 检查并触发节拍事件
|
|
|
|
|
|
ProcessBeatEvents();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void OnDestroy()
|
|
|
|
|
|
{
|
2026-06-30 01:48:58 -04:00
|
|
|
|
UnregisterRhythmGameObject(gameObject);
|
2026-05-26 00:21:27 -04:00
|
|
|
|
if (IsActive)
|
|
|
|
|
|
{
|
|
|
|
|
|
Deactivate();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Activate / Deactivate
|
|
|
|
|
|
|
2026-06-30 01:48:58 -04:00
|
|
|
|
[Button("Play Music")]
|
2026-05-26 00:21:27 -04:00
|
|
|
|
public void Activate()
|
|
|
|
|
|
{
|
2026-06-30 01:48:58 -04:00
|
|
|
|
var controller = GetComponent<CombatMusicController>();
|
|
|
|
|
|
if (controller != null)
|
2026-05-26 00:21:27 -04:00
|
|
|
|
{
|
2026-06-30 01:48:58 -04:00
|
|
|
|
controller.PlayFunctionalMusic();
|
2026-05-26 00:21:27 -04:00
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
2026-06-30 01:48:58 -04:00
|
|
|
|
Debug.LogWarning("[MusicBeatSystem] No CombatMusicController or testData found to play music.");
|
2026-05-26 00:21:27 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-30 01:48:58 -04:00
|
|
|
|
|
2026-05-26 00:21:27 -04:00
|
|
|
|
/// <summary>
|
2026-06-30 01:48:58 -04:00
|
|
|
|
/// 激活节拍系统:使用 Wwise 事件和初始 Switch 直接启动动态音乐流程
|
2026-05-26 00:21:27 -04:00
|
|
|
|
/// </summary>
|
2026-06-30 01:48:58 -04:00
|
|
|
|
public void Activate(AK.Wwise.Event wwiseEvent, string initialSwitch = null)
|
2026-05-26 00:21:27 -04:00
|
|
|
|
{
|
|
|
|
|
|
if (IsActive)
|
|
|
|
|
|
{
|
|
|
|
|
|
Deactivate();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 01:48:58 -04:00
|
|
|
|
CurrentBeatData = null;
|
|
|
|
|
|
CurrentBPM = 240f; // 默认临时 BPM,直到 Entry Cue 触发表格映射替换
|
2026-05-26 00:21:27 -04:00
|
|
|
|
CurrentSongTime = 0f;
|
|
|
|
|
|
nextBeatIndex = 0;
|
|
|
|
|
|
IsPlaying = false;
|
|
|
|
|
|
IsActive = true;
|
|
|
|
|
|
|
2026-06-30 01:48:58 -04:00
|
|
|
|
// 覆盖 BackgroundMusicManager
|
2026-05-26 00:21:27 -04:00
|
|
|
|
if (bgmManager != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
bgmManager.StopMusic();
|
|
|
|
|
|
bgmManager.SetOverride(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 01:48:58 -04:00
|
|
|
|
if (wwiseEvent != null && wwiseEvent.IsValid())
|
2026-05-26 00:21:27 -04:00
|
|
|
|
{
|
2026-06-30 01:48:58 -04:00
|
|
|
|
uint callbackFlags = (uint)(
|
|
|
|
|
|
AkCallbackType.AK_MusicSyncBeat |
|
|
|
|
|
|
AkCallbackType.AK_MusicSyncEntry |
|
|
|
|
|
|
AkCallbackType.AK_MusicSyncUserCue |
|
|
|
|
|
|
AkCallbackType.AK_EndOfEvent
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrEmpty(initialSwitch))
|
|
|
|
|
|
{
|
|
|
|
|
|
AkUnitySoundEngine.SetSwitch(musicSegmentSwitchGroup, initialSwitch, gameObject);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 00:21:27 -04:00
|
|
|
|
PlayerCanvas.CombatSystemsUIArea.beatTimelineUI.Initialize(this);
|
2026-06-30 01:48:58 -04:00
|
|
|
|
wwisePlayingID = wwiseEvent.Post(
|
2026-05-26 00:21:27 -04:00
|
|
|
|
gameObject,
|
|
|
|
|
|
callbackFlags,
|
|
|
|
|
|
OnWwiseMusicCallback,
|
|
|
|
|
|
null
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (wwisePlayingID == 0)
|
|
|
|
|
|
{
|
2026-06-30 01:48:58 -04:00
|
|
|
|
Debug.LogWarning("[MusicBeatSystem] Wwise Post returned playingID 0 for dynamic music event");
|
2026-05-26 00:21:27 -04:00
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
2026-06-30 01:48:58 -04:00
|
|
|
|
Debug.Log($"[MusicBeatSystem] Activated dynamic music flow with Event: '{wwiseEvent.Name}', playingID={wwisePlayingID}");
|
2026-05-26 00:21:27 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
2026-06-30 01:48:58 -04:00
|
|
|
|
Debug.LogWarning("[MusicBeatSystem] No valid Wwise Event provided for dynamic music activation");
|
2026-05-26 00:21:27 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 01:48:58 -04:00
|
|
|
|
OnActivated?.Invoke(null);
|
2026-05-26 00:21:27 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 停用节拍系统:停止追踪、恢复 BackgroundMusicManager 控制
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public void Deactivate()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!IsActive) return;
|
|
|
|
|
|
|
|
|
|
|
|
IsActive = false;
|
|
|
|
|
|
IsPlaying = false;
|
|
|
|
|
|
CurrentSongTime = 0f;
|
|
|
|
|
|
nextBeatIndex = 0;
|
|
|
|
|
|
pendingSyncTime = -1f;
|
|
|
|
|
|
pendingBeatDuration = -1f;
|
2026-06-30 01:48:58 -04:00
|
|
|
|
CurrentWwiseSegmentName = null;
|
|
|
|
|
|
|
2026-05-26 00:21:27 -04:00
|
|
|
|
// 停止 MusicBeatSystem 自己 Post 的节拍音乐
|
|
|
|
|
|
if (wwisePlayingID != 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
AkUnitySoundEngine.StopPlayingID(wwisePlayingID);
|
|
|
|
|
|
wwisePlayingID = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果 beatData 有独立的 stopMusicEvent,也 Post 一下确保停止
|
|
|
|
|
|
if (CurrentBeatData != null && CurrentBeatData.stopMusicEvent != null && CurrentBeatData.stopMusicEvent.IsValid())
|
|
|
|
|
|
{
|
|
|
|
|
|
CurrentBeatData.stopMusicEvent.Post(gameObject);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 恢复 BackgroundMusicManager
|
|
|
|
|
|
if (bgmManager != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
bgmManager.SetOverride(false);
|
|
|
|
|
|
bgmManager.PlayMusic("NormalMusic");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
CurrentBeatData = null;
|
|
|
|
|
|
|
|
|
|
|
|
Debug.Log("[MusicBeatSystem] Deactivated");
|
|
|
|
|
|
OnDeactivated?.Invoke();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Judgement API
|
|
|
|
|
|
|
|
|
|
|
|
public BeatJudgement Judge()
|
|
|
|
|
|
{
|
|
|
|
|
|
return Judge(CurrentSongTime);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 判定指定时间点的操作是否卡拍
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="actionTime">操作发生时的 CurrentSongTime(通常传入 CurrentSongTime)</param>
|
|
|
|
|
|
/// <returns>判定结果,若超出 Miss 窗口则 accuracy 为 Miss</returns>
|
|
|
|
|
|
public BeatJudgement Judge(float actionTime)
|
|
|
|
|
|
{
|
|
|
|
|
|
BeatMarker nearest = CurrentBeatData?.GetNearestBeat(actionTime, MISS_TOLERANCE);
|
|
|
|
|
|
|
|
|
|
|
|
if (nearest == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
return new BeatJudgement(BeatAccuracy.Miss, float.MaxValue, null, 1f);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
float timeDiff = actionTime - nearest.time;
|
|
|
|
|
|
float absDiff = Mathf.Abs(timeDiff);
|
|
|
|
|
|
|
|
|
|
|
|
BeatAccuracy accuracy;
|
|
|
|
|
|
float normalized;
|
|
|
|
|
|
|
|
|
|
|
|
if (absDiff <= PERFECT_TOLERANCE)
|
|
|
|
|
|
{
|
|
|
|
|
|
accuracy = BeatAccuracy.Perfect;
|
|
|
|
|
|
normalized = absDiff / PERFECT_TOLERANCE;
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (absDiff <= GOOD_TOLERANCE)
|
|
|
|
|
|
{
|
|
|
|
|
|
accuracy = BeatAccuracy.Good;
|
|
|
|
|
|
normalized = absDiff / GOOD_TOLERANCE;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
accuracy = BeatAccuracy.Miss;
|
|
|
|
|
|
normalized = Mathf.Clamp01(absDiff / MISS_TOLERANCE);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var judgement = new BeatJudgement(accuracy, timeDiff, nearest, normalized);
|
|
|
|
|
|
OnPlayerBeatJudged?.Invoke(judgement);
|
|
|
|
|
|
return judgement;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 简化版判定,仅返回精度等级
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public BeatAccuracy GetBeatAccuracy(float actionTime)
|
|
|
|
|
|
{
|
|
|
|
|
|
return Judge(actionTime).accuracy;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 判定指定时间点是否处于节拍窗口内
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="actionTime">操作时间</param>
|
|
|
|
|
|
/// <param name="customTolerance">自定义容差,-1 表示使用 GOOD_TOLERANCE</param>
|
|
|
|
|
|
public bool IsOnBeat(float actionTime, float customTolerance = -1f)
|
|
|
|
|
|
{
|
|
|
|
|
|
float tolerance = customTolerance > 0f ? customTolerance : GOOD_TOLERANCE;
|
|
|
|
|
|
BeatMarker nearest = CurrentBeatData?.GetNearestBeat(actionTime, tolerance);
|
|
|
|
|
|
return nearest != null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Beat Query API
|
|
|
|
|
|
|
|
|
|
|
|
public BeatMarker GetNearestBeat(float tolerance)
|
|
|
|
|
|
{
|
|
|
|
|
|
return CurrentBeatData?.GetNearestBeat(CurrentSongTime, tolerance);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public BeatMarker GetLastBeat()
|
|
|
|
|
|
{
|
|
|
|
|
|
return CurrentBeatData?.GetLastBeat(CurrentSongTime);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 获取下一个节拍标记
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public BeatMarker GetNextBeat()
|
|
|
|
|
|
{
|
|
|
|
|
|
return CurrentBeatData?.GetNextBeat(CurrentSongTime);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 获取下一个带有特定 tag 的节拍标记
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public BeatMarker GetNextBeatWithTag(string tag)
|
|
|
|
|
|
{
|
|
|
|
|
|
return CurrentBeatData?.GetNextBeatWithTag(CurrentSongTime, tag);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 到下一拍的剩余时间(秒),若无下一拍返回 -1
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public float GetTimeUntilNextBeat()
|
|
|
|
|
|
{
|
|
|
|
|
|
BeatMarker next = GetNextBeat();
|
|
|
|
|
|
return next != null ? next.time - CurrentSongTime : -1f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 到指定节拍的剩余时间(秒)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public float GetTimeUntilBeat(BeatMarker beat)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (beat == null) return -1f;
|
|
|
|
|
|
return beat.time - CurrentSongTime;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 获取指定时间范围内的所有节拍标记
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public List<BeatMarker> GetBeatsInRange(float startTime, float endTime)
|
|
|
|
|
|
{
|
|
|
|
|
|
return CurrentBeatData?.GetBeatsInRange(startTime, endTime) ?? new List<BeatMarker>();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Internal - Wwise Callback
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Wwise 音乐回调处理器(可能在非主线程调用)
|
|
|
|
|
|
/// </summary>
|
2026-06-30 01:48:58 -04:00
|
|
|
|
internal void OnWwiseMusicCallback(object in_cookie, AkCallbackType in_type, AkCallbackInfo in_info)
|
2026-05-26 00:21:27 -04:00
|
|
|
|
{
|
2026-06-30 01:48:58 -04:00
|
|
|
|
GameObject go = GetGameObjectFromWwiseId(in_info.gameObjID);
|
|
|
|
|
|
if (go == null) go = gameObject;
|
|
|
|
|
|
|
2026-05-26 00:21:27 -04:00
|
|
|
|
if (in_type == AkCallbackType.AK_MusicSyncBeat)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (in_info is AkMusicSyncCallbackInfo syncInfo)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 从 Wwise 获取精确的音乐播放位置和节拍信息
|
|
|
|
|
|
// segmentInfo_iCurrentPosition 是当前 segment 内的播放位置(毫秒)
|
|
|
|
|
|
float musicPositionSec = syncInfo.segmentInfo_iCurrentPosition / 1000f;
|
|
|
|
|
|
float beatDuration = syncInfo.segmentInfo_fBeatDuration;
|
2026-06-30 01:48:58 -04:00
|
|
|
|
|
|
|
|
|
|
if (go == gameObject)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 将校准数据传递到主线程处理 (防空保护)
|
|
|
|
|
|
float offset = CurrentBeatData != null ? CurrentBeatData.audioStartOffset : 0f;
|
|
|
|
|
|
pendingSyncTime = musicPositionSec + offset;
|
|
|
|
|
|
pendingBeatDuration = beatDuration;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
lock (callbackLock)
|
|
|
|
|
|
{
|
|
|
|
|
|
pendingCallbacks.Add(new PendingCallbackData
|
|
|
|
|
|
{
|
|
|
|
|
|
callbackType = PendingCallbackData.Type.BeatSync,
|
|
|
|
|
|
targetGO = go,
|
|
|
|
|
|
segmentName = "",
|
|
|
|
|
|
musicPositionSec = musicPositionSec,
|
|
|
|
|
|
beatDuration = beatDuration
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-26 00:21:27 -04:00
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning($"[MusicBeatSystem] Received MusicSync callback with unexpected info type: {in_info.GetType().Name}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-30 01:48:58 -04:00
|
|
|
|
else if (in_type == AkCallbackType.AK_MusicSyncUserCue && in_info is AkMusicSyncCallbackInfo cueInfo)
|
2026-05-26 00:21:27 -04:00
|
|
|
|
{
|
2026-06-30 01:48:58 -04:00
|
|
|
|
string cueName = cueInfo.userCueName;
|
|
|
|
|
|
lock (callbackLock)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 所有的 User Cue 都会触发 OnUserCueReceived
|
|
|
|
|
|
pendingCallbacks.Add(new PendingCallbackData
|
|
|
|
|
|
{
|
|
|
|
|
|
callbackType = PendingCallbackData.Type.Prepare,
|
|
|
|
|
|
targetGO = go,
|
|
|
|
|
|
segmentName = cueName,
|
|
|
|
|
|
playingID = cueInfo.playingID
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 如果是 Entry_ 开头,则同时触发 Entry 谱面替换逻辑
|
|
|
|
|
|
if (!string.IsNullOrEmpty(cueName) && cueName.StartsWith("Entry_"))
|
|
|
|
|
|
{
|
|
|
|
|
|
string segmentName = cueName.Substring(6);
|
|
|
|
|
|
pendingCallbacks.Add(new PendingCallbackData
|
|
|
|
|
|
{
|
|
|
|
|
|
callbackType = PendingCallbackData.Type.Entry,
|
|
|
|
|
|
targetGO = go,
|
|
|
|
|
|
segmentName = segmentName
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (in_type == AkCallbackType.AK_EndOfEvent && in_info is AkEventCallbackInfo eventInfo)
|
|
|
|
|
|
{
|
|
|
|
|
|
lock (callbackLock)
|
|
|
|
|
|
{
|
|
|
|
|
|
pendingCallbacks.Add(new PendingCallbackData
|
|
|
|
|
|
{
|
|
|
|
|
|
callbackType = PendingCallbackData.Type.Prepare,
|
|
|
|
|
|
targetGO = go,
|
|
|
|
|
|
segmentName = "EndOfEvent",
|
|
|
|
|
|
playingID = eventInfo.playingID
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-05-26 00:21:27 -04:00
|
|
|
|
Debug.Log("[MusicBeatSystem] Music playback ended");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Internal - Beat Processing
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 处理来自 Wwise 回调的时间校准(在主线程 Update 中调用)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private void ProcessPendingSync()
|
|
|
|
|
|
{
|
|
|
|
|
|
float syncTime = pendingSyncTime;
|
|
|
|
|
|
float beatDur = pendingBeatDuration;
|
|
|
|
|
|
|
|
|
|
|
|
if (syncTime < 0f) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 消费 pending 数据
|
|
|
|
|
|
pendingSyncTime = -1f;
|
|
|
|
|
|
pendingBeatDuration = -1f;
|
|
|
|
|
|
|
|
|
|
|
|
// 首次收到回调,标记为正在播放
|
|
|
|
|
|
if (!IsPlaying)
|
|
|
|
|
|
{
|
|
|
|
|
|
IsPlaying = true;
|
|
|
|
|
|
CurrentSongTime = syncTime;
|
|
|
|
|
|
Debug.Log($"[MusicBeatSystem] First beat sync received, music is now playing. SyncTime={syncTime:F3}s");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 时间校准:将 currentSongTime 吸附到 Wwise 报告的位置
|
|
|
|
|
|
float drift = Mathf.Abs(CurrentSongTime - syncTime);
|
|
|
|
|
|
if (drift < SYNC_SNAP_THRESHOLD)
|
|
|
|
|
|
{
|
|
|
|
|
|
CurrentSongTime = syncTime;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning($"[MusicBeatSystem] Large sync drift detected: {drift:F3}s, ignoring calibration");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新实际 BPM
|
|
|
|
|
|
if (beatDur > 0f)
|
|
|
|
|
|
{
|
|
|
|
|
|
CurrentBPM = 60f / beatDur;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 01:48:58 -04:00
|
|
|
|
private void ProcessPendingCallbacks()
|
|
|
|
|
|
{
|
|
|
|
|
|
List<PendingCallbackData> localCallbacks = null;
|
|
|
|
|
|
|
|
|
|
|
|
lock (callbackLock)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (pendingCallbacks.Count > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
localCallbacks = new List<PendingCallbackData>(pendingCallbacks);
|
|
|
|
|
|
pendingCallbacks.Clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (localCallbacks == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var callback in localCallbacks)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (callback.callbackType == PendingCallbackData.Type.Prepare)
|
|
|
|
|
|
{
|
|
|
|
|
|
OnUserCueReceived?.Invoke(callback.playingID, callback.segmentName);
|
|
|
|
|
|
|
|
|
|
|
|
if (callback.segmentName == "PrepareNext" && callback.targetGO == gameObject)
|
|
|
|
|
|
{
|
|
|
|
|
|
string nextSegment = OnPrepareNextSegment?.Invoke(callback.targetGO, CurrentWwiseSegmentName);
|
|
|
|
|
|
if (!string.IsNullOrEmpty(nextSegment))
|
|
|
|
|
|
{
|
|
|
|
|
|
AkUnitySoundEngine.SetSwitch(musicSegmentSwitchGroup, nextSegment, callback.targetGO);
|
|
|
|
|
|
Debug.Log($"[MusicBeatSystem] PrepareNext: transition scheduled on '{callback.targetGO.name}' from '{CurrentWwiseSegmentName}' to '{nextSegment}'");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 通知所有订阅者:全局主音乐到达了 PrepareNext 边界
|
|
|
|
|
|
OnGlobalPrepareNext?.Invoke();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (callback.callbackType == PendingCallbackData.Type.BeatSync)
|
|
|
|
|
|
{
|
|
|
|
|
|
var localTracker = callback.targetGO.GetComponent<ILocalRhythmTracker>();
|
|
|
|
|
|
if (localTracker != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
localTracker.ReceiveSyncBeat(callback.musicPositionSec, callback.beatDuration);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (callback.callbackType == PendingCallbackData.Type.Entry)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Find mapped MusicBeatData
|
|
|
|
|
|
MusicBeatData mappedData = null;
|
|
|
|
|
|
if (segmentMappings != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var mapping in segmentMappings)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (mapping.segmentName == callback.segmentName)
|
|
|
|
|
|
{
|
|
|
|
|
|
mappedData = mapping.beatData;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (mappedData != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (callback.targetGO == gameObject)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Global Master BGM Transition
|
|
|
|
|
|
CurrentWwiseSegmentName = callback.segmentName;
|
|
|
|
|
|
CurrentBeatData = mappedData;
|
|
|
|
|
|
CurrentBPM = mappedData.bpm;
|
|
|
|
|
|
CurrentSongTime = mappedData.audioStartOffset;
|
|
|
|
|
|
nextBeatIndex = 0;
|
|
|
|
|
|
rawBeatCount = 0;
|
|
|
|
|
|
CurrentBarBeat = 1;
|
|
|
|
|
|
IsPlaying = true;
|
|
|
|
|
|
|
|
|
|
|
|
// Re-initialize UI
|
|
|
|
|
|
PlayerCanvas.CombatSystemsUIArea.beatTimelineUI.Initialize(this);
|
|
|
|
|
|
|
|
|
|
|
|
Debug.Log($"[MusicBeatSystem] Global Transitioned to Segment: '{callback.segmentName}', BPM={mappedData.bpm}");
|
|
|
|
|
|
OnActivated?.Invoke(mappedData);
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
// Local GameObject Segment Transition (e.g. Enemy)
|
|
|
|
|
|
var localTracker = callback.targetGO.GetComponent<ILocalRhythmTracker>();
|
|
|
|
|
|
if (localTracker != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
localTracker.OnSegmentTransitioned(mappedData);
|
|
|
|
|
|
}
|
|
|
|
|
|
Debug.Log($"[MusicBeatSystem] Local Segment Entry: '{callback.segmentName}' on GameObject '{callback.targetGO.name}'");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning($"[MusicBeatSystem] Unmapped segment entry callback: '{callback.segmentName}' on '{callback.targetGO.name}'");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static void RegisterRhythmGameObject(GameObject go)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (go == null) return;
|
|
|
|
|
|
lock (activeRhythmGameObjects)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!activeRhythmGameObjects.Contains(go))
|
|
|
|
|
|
{
|
|
|
|
|
|
activeRhythmGameObjects.Add(go);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static void UnregisterRhythmGameObject(GameObject go)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (go == null) return;
|
|
|
|
|
|
lock (activeRhythmGameObjects)
|
|
|
|
|
|
{
|
|
|
|
|
|
activeRhythmGameObjects.Remove(go);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private GameObject GetGameObjectFromWwiseId(ulong gameObjID)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (gameObjID == (ulong)gameObject.GetInstanceID()) return gameObject;
|
|
|
|
|
|
|
|
|
|
|
|
lock (activeRhythmGameObjects)
|
|
|
|
|
|
{
|
|
|
|
|
|
for (int i = activeRhythmGameObjects.Count - 1; i >= 0; i--)
|
|
|
|
|
|
{
|
|
|
|
|
|
var go = activeRhythmGameObjects[i];
|
|
|
|
|
|
if (go == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
activeRhythmGameObjects.RemoveAt(i);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((ulong)go.GetInstanceID() == gameObjID)
|
|
|
|
|
|
{
|
|
|
|
|
|
return go;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback lookup
|
|
|
|
|
|
#if UNITY_6000_0_OR_NEWER
|
|
|
|
|
|
var objs = FindObjectsByType<AkGameObj>(FindObjectsSortMode.None);
|
|
|
|
|
|
#else
|
|
|
|
|
|
var objs = FindObjectsOfType<AkGameObj>();
|
|
|
|
|
|
#endif
|
|
|
|
|
|
foreach (var obj in objs)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (obj != null && (ulong)obj.gameObject.GetInstanceID() == gameObjID)
|
|
|
|
|
|
{
|
|
|
|
|
|
RegisterRhythmGameObject(obj.gameObject);
|
|
|
|
|
|
return obj.gameObject;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 00:21:27 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 检查是否到达下一个节拍,触发 onBeat 事件
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private void ProcessBeatEvents()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (CurrentBeatData == null || CurrentBeatData.beatMarkers == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
var markers = CurrentBeatData.beatMarkers;
|
|
|
|
|
|
|
|
|
|
|
|
// 触发所有已经过去的未处理节拍
|
|
|
|
|
|
while (nextBeatIndex < markers.Count && CurrentSongTime >= markers[nextBeatIndex].time)
|
|
|
|
|
|
{
|
|
|
|
|
|
BeatMarker beat = markers[nextBeatIndex];
|
|
|
|
|
|
nextBeatIndex++;
|
|
|
|
|
|
|
2026-06-30 01:48:58 -04:00
|
|
|
|
// 更新 CurrentBarBeat(根据 BPM 和小节拍数推算)
|
|
|
|
|
|
rawBeatCount++;
|
|
|
|
|
|
int beatsPerBar = CurrentBeatData.beatsPerBar > 0 ? CurrentBeatData.beatsPerBar : 4;
|
|
|
|
|
|
CurrentBarBeat = ((rawBeatCount - 1) % beatsPerBar) + 1;
|
|
|
|
|
|
|
2026-05-26 00:21:27 -04:00
|
|
|
|
OnBeat?.Invoke(beat);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|