Files
Cielonos/Assets/Scripts/MainGame/Managers/CombatManager/CombatSystems/MusicBeatSystem/MusicBeatSystem.cs
SoulliesOfficial ddd387ef35 做不出来
2026-06-30 01:48:58 -04:00

813 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using Cielonos.MainGame.UI;
using Sirenix.OdinInspector;
using SLSUtilities.WwiseAssistance;
using UnityEngine;
namespace Cielonos.MainGame
{
[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; }
}
/// <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; }
/// <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;
#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;
/// <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;
#endregion
#region Private Fields
/// <summary>
/// 下一个待触发的节拍索引(在 beatMarkers 中的 index
/// </summary>
private int nextBeatIndex;
/// <summary>
/// 由 MusicSyncBeat 回调累计的原始拍号计数(每个 Entry 时清零),用于推算 CurrentBarBeat
/// </summary>
private int rawBeatCount;
/// <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;
[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. 触发该事件的 GameObject2. 当前播放的片段名称。
/// 返回值下一个要播放的片段名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>();
#endregion
#region Lifecycle
private void Awake()
{
bgmManager = AudioManager.Instance.backgroundMusicManager;
RegisterRhythmGameObject(gameObject);
}
private void Update()
{
if (!IsActive) return;
// 处理 Wwise 回调带来的时间校准
ProcessPendingSync();
// 处理 Entry/Prepare 等回调队列事件
ProcessPendingCallbacks();
if (!IsPlaying) return;
// 推进音乐时间
CurrentSongTime += Time.deltaTime;
// 检查并触发节拍事件
ProcessBeatEvents();
}
private void OnDestroy()
{
UnregisterRhythmGameObject(gameObject);
if (IsActive)
{
Deactivate();
}
}
#endregion
#region Activate / Deactivate
[Button("Play Music")]
public void Activate()
{
var controller = GetComponent<CombatMusicController>();
if (controller != null)
{
controller.PlayFunctionalMusic();
}
else
{
Debug.LogWarning("[MusicBeatSystem] No CombatMusicController or testData found to play music.");
}
}
/// <summary>
/// 激活节拍系统:使用 Wwise 事件和初始 Switch 直接启动动态音乐流程
/// </summary>
public void Activate(AK.Wwise.Event wwiseEvent, string initialSwitch = null)
{
if (IsActive)
{
Deactivate();
}
CurrentBeatData = null;
CurrentBPM = 240f; // 默认临时 BPM直到 Entry Cue 触发表格映射替换
CurrentSongTime = 0f;
nextBeatIndex = 0;
IsPlaying = false;
IsActive = true;
// 覆盖 BackgroundMusicManager
if (bgmManager != null)
{
bgmManager.StopMusic();
bgmManager.SetOverride(true);
}
if (wwiseEvent != null && wwiseEvent.IsValid())
{
uint callbackFlags = (uint)(
AkCallbackType.AK_MusicSyncBeat |
AkCallbackType.AK_MusicSyncEntry |
AkCallbackType.AK_MusicSyncUserCue |
AkCallbackType.AK_EndOfEvent
);
if (!string.IsNullOrEmpty(initialSwitch))
{
AkUnitySoundEngine.SetSwitch(musicSegmentSwitchGroup, initialSwitch, gameObject);
}
PlayerCanvas.CombatSystemsUIArea.beatTimelineUI.Initialize(this);
wwisePlayingID = wwiseEvent.Post(
gameObject,
callbackFlags,
OnWwiseMusicCallback,
null
);
if (wwisePlayingID == 0)
{
Debug.LogWarning("[MusicBeatSystem] Wwise Post returned playingID 0 for dynamic music event");
}
else
{
Debug.Log($"[MusicBeatSystem] Activated dynamic music flow with Event: '{wwiseEvent.Name}', playingID={wwisePlayingID}");
}
}
else
{
Debug.LogWarning("[MusicBeatSystem] No valid Wwise Event provided for dynamic music activation");
}
OnActivated?.Invoke(null);
}
/// <summary>
/// 停用节拍系统:停止追踪、恢复 BackgroundMusicManager 控制
/// </summary>
public void Deactivate()
{
if (!IsActive) return;
IsActive = false;
IsPlaying = false;
CurrentSongTime = 0f;
nextBeatIndex = 0;
pendingSyncTime = -1f;
pendingBeatDuration = -1f;
CurrentWwiseSegmentName = null;
// 停止 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>
internal void OnWwiseMusicCallback(object in_cookie, AkCallbackType in_type, AkCallbackInfo in_info)
{
GameObject go = GetGameObjectFromWwiseId(in_info.gameObjID);
if (go == null) go = gameObject;
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;
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
});
}
}
}
else
{
Debug.LogWarning($"[MusicBeatSystem] Received MusicSync callback with unexpected info type: {in_info.GetType().Name}");
}
}
else if (in_type == AkCallbackType.AK_MusicSyncUserCue && in_info is AkMusicSyncCallbackInfo cueInfo)
{
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
});
}
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;
}
}
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;
}
/// <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++;
// 更新 CurrentBarBeat根据 BPM 和小节拍数推算)
rawBeatCount++;
int beatsPerBar = CurrentBeatData.beatsPerBar > 0 ? CurrentBeatData.beatsPerBar : 4;
CurrentBarBeat = ((rawBeatCount - 1) % beatsPerBar) + 1;
OnBeat?.Invoke(beat);
}
}
#endregion
}
}