515 lines
16 KiB
C#
515 lines
16 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using Cielonos.MainGame.UI;
|
||
using Sirenix.OdinInspector;
|
||
using SLSUtilities.WwiseAssistance;
|
||
using UnityEngine;
|
||
|
||
namespace Cielonos.MainGame
|
||
{
|
||
/// <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; }
|
||
|
||
#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;
|
||
|
||
#endregion
|
||
|
||
#region Private Fields
|
||
|
||
/// <summary>
|
||
/// 下一个待触发的节拍索引(在 beatMarkers 中的 index)
|
||
/// </summary>
|
||
private int nextBeatIndex;
|
||
|
||
/// <summary>
|
||
/// Wwise 播放实例 ID
|
||
/// </summary>
|
||
private uint wwisePlayingID;
|
||
|
||
/// <summary>
|
||
/// BackgroundMusicManager 引用缓存
|
||
/// </summary>
|
||
private BackgroundMusicManager bgmManager;
|
||
|
||
/// <summary>
|
||
/// Wwise 回调产生的待处理校准数据(从回调线程安全传递到主线程)
|
||
/// </summary>
|
||
[ShowInInspector]
|
||
private volatile float pendingSyncTime = -1f;
|
||
|
||
/// <summary>
|
||
/// Wwise 回调报告的 beatDuration(秒),用于反推实际 BPM
|
||
/// </summary>
|
||
[ShowInInspector]
|
||
private volatile float pendingBeatDuration = -1f;
|
||
|
||
#endregion
|
||
|
||
#region Lifecycle
|
||
|
||
public MusicBeatData testData;
|
||
|
||
private void Awake()
|
||
{
|
||
bgmManager = AudioManager.Instance.backgroundMusicManager;
|
||
}
|
||
|
||
private void Update()
|
||
{
|
||
if (!IsActive) return;
|
||
|
||
// 处理 Wwise 回调带来的时间校准
|
||
ProcessPendingSync();
|
||
|
||
if (!IsPlaying) return;
|
||
|
||
// 推进音乐时间
|
||
CurrentSongTime += Time.deltaTime;
|
||
|
||
// 检查并触发节拍事件
|
||
ProcessBeatEvents();
|
||
}
|
||
|
||
private void OnDestroy()
|
||
{
|
||
if (IsActive)
|
||
{
|
||
Deactivate();
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Activate / Deactivate
|
||
|
||
[Button("Activate Test Data")]
|
||
public void Activate()
|
||
{
|
||
if (testData != null)
|
||
{
|
||
Activate(testData);
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("[MusicBeatSystem] No test data assigned for activation");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 激活节拍系统:加载谱面、覆盖 BGM、注册 Wwise 回调
|
||
/// </summary>
|
||
/// <param name="beatData">要加载的谱面数据</param>
|
||
public void Activate(MusicBeatData beatData)
|
||
{
|
||
if (beatData == null)
|
||
{
|
||
Debug.LogError("[MusicBeatSystem] Activate failed: beatData is null");
|
||
return;
|
||
}
|
||
|
||
if (IsActive)
|
||
{
|
||
Deactivate();
|
||
}
|
||
|
||
CurrentBeatData = beatData;
|
||
CurrentBPM = beatData.bpm;
|
||
CurrentSongTime = 0f;
|
||
nextBeatIndex = 0;
|
||
IsPlaying = false;
|
||
IsActive = true;
|
||
|
||
// 覆盖 BackgroundMusicManager:先停止当前 BGM,再标记覆盖
|
||
if (bgmManager != null)
|
||
{
|
||
bgmManager.StopMusic();
|
||
bgmManager.SetOverride(true);
|
||
}
|
||
|
||
// 在 MusicBeatSystem 自身的 gameObject 上播放节拍音乐
|
||
// 与 BackgroundMusicManager 的 gameObject 隔离,避免 Stop Event 作用域冲突
|
||
if (beatData.musicEvent != null && beatData.musicEvent.IsValid())
|
||
{
|
||
uint callbackFlags = (uint)(AkCallbackType.AK_MusicSyncBeat | AkCallbackType.AK_EndOfEvent);
|
||
beatData.musicSwitch.SetValue(gameObject); // 设置 Switch 以选择正确的音乐变体
|
||
PlayerCanvas.CombatSystemsUIArea.beatTimelineUI.Initialize(this);
|
||
wwisePlayingID = beatData.musicEvent.Post(
|
||
gameObject,
|
||
callbackFlags,
|
||
OnWwiseMusicCallback,
|
||
null
|
||
);
|
||
|
||
if (wwisePlayingID == 0)
|
||
{
|
||
Debug.LogWarning("[MusicBeatSystem] Wwise Post returned playingID 0, music may not play. " +
|
||
"Check: 1) musicEvent references a Music type (not Sound SFX) " +
|
||
"2) SoundBank is loaded 3) gameObject is active");
|
||
}
|
||
else
|
||
{
|
||
Debug.Log($"[MusicBeatSystem] Activated with '{beatData.name}', playingID={wwisePlayingID}, " +
|
||
$"posting on GameObject '{gameObject.name}'");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// 无 Wwise Event 时,使用纯谱面模式(仅基于 deltaTime 和谱面数据)
|
||
Debug.LogWarning("[MusicBeatSystem] No valid Wwise Event on beatData, running in offline mode");
|
||
IsPlaying = true;
|
||
}
|
||
|
||
OnActivated?.Invoke(beatData);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 停用节拍系统:停止追踪、恢复 BackgroundMusicManager 控制
|
||
/// </summary>
|
||
public void Deactivate()
|
||
{
|
||
if (!IsActive) return;
|
||
|
||
IsActive = false;
|
||
IsPlaying = false;
|
||
CurrentSongTime = 0f;
|
||
nextBeatIndex = 0;
|
||
pendingSyncTime = -1f;
|
||
pendingBeatDuration = -1f;
|
||
|
||
// 停止 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>
|
||
private void OnWwiseMusicCallback(object in_cookie, AkCallbackType in_type, AkCallbackInfo in_info)
|
||
{
|
||
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;
|
||
|
||
// 将校准数据传递到主线程处理
|
||
pendingSyncTime = musicPositionSec + CurrentBeatData.audioStartOffset;
|
||
pendingBeatDuration = beatDuration;
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning($"[MusicBeatSystem] Received MusicSync callback with unexpected info type: {in_info.GetType().Name}");
|
||
}
|
||
}
|
||
else if (in_type == AkCallbackType.AK_EndOfEvent)
|
||
{
|
||
// 音乐播放结束
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <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++;
|
||
|
||
OnBeat?.Invoke(beat);
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
}
|