MusicBeat
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Cielonos.MainGame
|
||||
{
|
||||
public class CombatSystemBase : MonoBehaviour
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public partial class CombatManager
|
||||
{
|
||||
public static T GetCombatSystem<T>() where T : CombatSystemBase
|
||||
{
|
||||
return instance.combatSystems.Find(system => system is T) as T;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 646d8d8fcd0c04148bbe7a2e3e845a94
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 95be6808bef2a4145b757b9ba166f230
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace Cielonos.MainGame
|
||||
{
|
||||
/// <summary>
|
||||
/// 节拍判定精度等级
|
||||
/// </summary>
|
||||
public enum BeatAccuracy
|
||||
{
|
||||
Perfect,
|
||||
Good,
|
||||
Miss
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节拍判定结果,包含精度、时间差和对应节拍标记信息
|
||||
/// </summary>
|
||||
public struct BeatJudgement
|
||||
{
|
||||
/// <summary>
|
||||
/// 判定精度等级
|
||||
/// </summary>
|
||||
public BeatAccuracy accuracy;
|
||||
|
||||
/// <summary>
|
||||
/// 操作时间与最近节拍的时间差(秒),正值表示偏晚,负值表示偏早
|
||||
/// </summary>
|
||||
public float timeDiff;
|
||||
|
||||
/// <summary>
|
||||
/// 最近的节拍标记
|
||||
/// </summary>
|
||||
public BeatMarker nearestBeat;
|
||||
|
||||
/// <summary>
|
||||
/// 归一化精度值:0 = 完美卡拍,1 = 窗口边缘
|
||||
/// </summary>
|
||||
public float normalizedAccuracy;
|
||||
|
||||
public BeatJudgement(BeatAccuracy accuracy, float timeDiff, BeatMarker nearestBeat, float normalizedAccuracy)
|
||||
{
|
||||
this.accuracy = accuracy;
|
||||
this.timeDiff = timeDiff;
|
||||
this.nearestBeat = nearestBeat;
|
||||
this.normalizedAccuracy = normalizedAccuracy;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0263b68e56ad44c408cb3c7d4da6dbd9
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Cielonos.MainGame
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个节拍标记的数据结构,代表谱面中一个节拍点
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class BeatMarker : IComparable<BeatMarker>
|
||||
{
|
||||
/// <summary>
|
||||
/// 节拍时间点(秒)
|
||||
/// </summary>
|
||||
[Tooltip("节拍时间点(秒)")]
|
||||
public float time;
|
||||
|
||||
/// <summary>
|
||||
/// 节拍标签列表(如 "Accent", "EnemyAttack0", "PlayerCue" 等)
|
||||
/// </summary>
|
||||
[Tooltip("节拍标签列表")]
|
||||
public List<string> tags = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 所在小节索引(从 0 开始)
|
||||
/// </summary>
|
||||
[Tooltip("所在小节索引")]
|
||||
public int barIndex;
|
||||
|
||||
/// <summary>
|
||||
/// 小节内第几拍(从 0 开始)
|
||||
/// </summary>
|
||||
[Tooltip("小节内第几拍")]
|
||||
public int beatInBar;
|
||||
|
||||
public BeatMarker() { }
|
||||
|
||||
public BeatMarker(float time, List<string> tags = null, int barIndex = 0, int beatInBar = 0)
|
||||
{
|
||||
this.time = time;
|
||||
this.tags = tags ?? new List<string>();
|
||||
this.barIndex = barIndex;
|
||||
this.beatInBar = beatInBar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查此节拍是否包含指定 tag
|
||||
/// </summary>
|
||||
public bool HasTag(string tag)
|
||||
{
|
||||
return tags != null && tags.Contains(tag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按时间排序
|
||||
/// </summary>
|
||||
public int CompareTo(BeatMarker other)
|
||||
{
|
||||
if (other == null) return 1;
|
||||
return time.CompareTo(other.time);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e771794cfd829ae4ab2bce4396c556ff
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5a22f26f6e10a67439599837e4c36717
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,64 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Cielonos.MainGame.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 节拍预设数据,用于编辑器中快速给节拍标记应用 tags 和颜色
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class BeatPreset
|
||||
{
|
||||
/// <summary>
|
||||
/// 预设显示名称
|
||||
/// </summary>
|
||||
public string displayName;
|
||||
|
||||
/// <summary>
|
||||
/// 预设 tags,添加节拍时自动附带
|
||||
/// </summary>
|
||||
public List<string> tags;
|
||||
|
||||
/// <summary>
|
||||
/// 编辑器中该预设对应的颜色
|
||||
/// </summary>
|
||||
public Color color;
|
||||
|
||||
public BeatPreset()
|
||||
{
|
||||
displayName = "New Preset";
|
||||
tags = new List<string>();
|
||||
color = Color.cyan;
|
||||
}
|
||||
|
||||
public BeatPreset(string displayName, List<string> tags, Color color)
|
||||
{
|
||||
this.displayName = displayName;
|
||||
this.tags = tags ?? new List<string>();
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查指定 BeatMarker 的 tags 是否与本预设完全匹配
|
||||
/// </summary>
|
||||
public bool MatchesTags(List<string> markerTags)
|
||||
{
|
||||
if (markerTags == null || markerTags.Count == 0)
|
||||
return tags == null || tags.Count == 0;
|
||||
|
||||
if (tags == null || tags.Count != markerTags.Count)
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < tags.Count; i++)
|
||||
{
|
||||
if (!markerTags.Contains(tags[i]))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e8b585d86f9f54847ae6e73de15f584d
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b6a89c3d1ef423d4caeebb66af064d24
|
||||
@@ -0,0 +1,172 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 1bbb15cae81a83543bbc5da32d7a03b1, type: 3}
|
||||
m_Name: MusicBeatData
|
||||
m_EditorClassIdentifier: Assembly-CSharp::Cielonos.MainGame.MusicBeatData
|
||||
serializationData:
|
||||
SerializedFormat: 2
|
||||
SerializedBytes:
|
||||
ReferencedUnityObjects: []
|
||||
SerializedBytesString:
|
||||
Prefab: {fileID: 0}
|
||||
PrefabModificationsReferencedUnityObjects: []
|
||||
PrefabModifications: []
|
||||
SerializationNodes: []
|
||||
musicSwitch:
|
||||
idInternal: 0
|
||||
valueGuidInternal:
|
||||
groupIdInternal: 0
|
||||
groupGuidInternal:
|
||||
WwiseObjectReference: {fileID: 11400000, guid: 2b8eae6a9990bf041ab63c019254627c, type: 2}
|
||||
musicEvent:
|
||||
idInternal: 0
|
||||
valueGuidInternal:
|
||||
WwiseObjectReference: {fileID: 11400000, guid: 490938cc29815d54498a3ff46a7b7078, type: 2}
|
||||
stopMusicEvent:
|
||||
idInternal: 0
|
||||
valueGuidInternal:
|
||||
WwiseObjectReference: {fileID: 11400000, guid: 55dc807cde468a0429fb934d73c29793, type: 2}
|
||||
bpm: 185
|
||||
beatsPerBar: 8
|
||||
audioStartOffset: 0
|
||||
totalDuration: 11.676
|
||||
beatMarkers:
|
||||
- time: 0
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 0
|
||||
beatInBar: 0
|
||||
- time: 0.6486486
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 0
|
||||
beatInBar: 2
|
||||
- time: 1.2972972
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 0
|
||||
beatInBar: 4
|
||||
- time: 1.8041672
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 0
|
||||
beatInBar: 6
|
||||
- time: 2.2125
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 0
|
||||
beatInBar: 6
|
||||
- time: 2.5958335
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 1
|
||||
beatInBar: 0
|
||||
- time: 2.9875
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 1
|
||||
beatInBar: 1
|
||||
- time: 3.2432435
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 1
|
||||
beatInBar: 2
|
||||
- time: 3.4958336
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 1
|
||||
beatInBar: 3
|
||||
- time: 3.8918922
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 1
|
||||
beatInBar: 4
|
||||
- time: 4.1625
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 1
|
||||
beatInBar: 5
|
||||
- time: 4.3125
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 1
|
||||
beatInBar: 5
|
||||
- time: 4.5405407
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 1
|
||||
beatInBar: 6
|
||||
- time: 4.7625
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 1
|
||||
beatInBar: 7
|
||||
- time: 5.0625
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 2
|
||||
beatInBar: 0
|
||||
- time: 5.189189
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 2
|
||||
beatInBar: 0
|
||||
- time: 5.837837
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 2
|
||||
beatInBar: 2
|
||||
- time: 6.4864855
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 2
|
||||
beatInBar: 4
|
||||
- time: 7.1351337
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 2
|
||||
beatInBar: 6
|
||||
- time: 7.783782
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 3
|
||||
beatInBar: 0
|
||||
- time: 8.432431
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 3
|
||||
beatInBar: 2
|
||||
- time: 9.08108
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 3
|
||||
beatInBar: 4
|
||||
- time: 9.72973
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 3
|
||||
beatInBar: 6
|
||||
- time: 10.378379
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 4
|
||||
beatInBar: 0
|
||||
- time: 11.027028
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 4
|
||||
beatInBar: 2
|
||||
- time: 11.675677
|
||||
tags:
|
||||
- Normal
|
||||
barIndex: 4
|
||||
beatInBar: 4
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ab8425d4ccf05646a7447e09ef6b3b9
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Collections.Generic;
|
||||
using AK.Wwise;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
using Event = AK.Wwise.Event;
|
||||
|
||||
namespace Cielonos.MainGame
|
||||
{
|
||||
/// <summary>
|
||||
/// 音乐谱面数据容器,存储 BGM 元信息和手动/自动生成的节拍标记
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "MusicBeatData", menuName = "Cielonos/CombatSystem/MusicBeat/Data", order = 0)]
|
||||
public class MusicBeatData : SerializedScriptableObject
|
||||
{
|
||||
private const float DEFAULT_BPM = 120f;
|
||||
private const int DEFAULT_BEATS_PER_BAR = 4;
|
||||
|
||||
[TitleGroup("Wwise Configuration")]
|
||||
[Tooltip("Wwise 音乐 Switch,用于切换 BGM 变体")]
|
||||
public Switch musicSwitch;
|
||||
|
||||
[TitleGroup("Wwise Configuration")]
|
||||
[Tooltip("播放 BGM 使用的 Wwise Event(需注册 MusicSync 回调)")]
|
||||
public Event musicEvent;
|
||||
|
||||
[TitleGroup("Wwise Configuration")]
|
||||
[Tooltip("停止 BGM 使用的 Wwise Event")]
|
||||
public Event stopMusicEvent;
|
||||
|
||||
[TitleGroup("Music Properties")]
|
||||
[Tooltip("基准 BPM")]
|
||||
[MinValue(1f)]
|
||||
public float bpm = DEFAULT_BPM;
|
||||
|
||||
[TitleGroup("Music Properties")]
|
||||
[Tooltip("每小节拍数")]
|
||||
[MinValue(1)]
|
||||
public int beatsPerBar = DEFAULT_BEATS_PER_BAR;
|
||||
|
||||
[TitleGroup("Music Properties")]
|
||||
[Tooltip("音频起始偏移量(秒),用于对齐第一拍与音频起始的差异")]
|
||||
public float audioStartOffset;
|
||||
|
||||
[TitleGroup("Music Properties")]
|
||||
[Tooltip("音乐总时长(秒)")]
|
||||
[MinValue(0f)]
|
||||
public float totalDuration;
|
||||
|
||||
[TitleGroup("Beat Markers")]
|
||||
[Tooltip("手动标记的节拍列表")]
|
||||
[ListDrawerSettings(ShowFoldout = true)]
|
||||
public List<BeatMarker> beatMarkers = new List<BeatMarker>();
|
||||
|
||||
/// <summary>
|
||||
/// 单拍间隔时间(秒)
|
||||
/// </summary>
|
||||
public float BeatInterval => 60f / bpm;
|
||||
|
||||
/// <summary>
|
||||
/// 单小节时长(秒)
|
||||
/// </summary>
|
||||
public float BarDuration => BeatInterval * beatsPerBar;
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定时间之前的最后一个节拍标记
|
||||
/// </summary>
|
||||
/// <param name="time"></param>
|
||||
/// <returns></returns>
|
||||
public BeatMarker GetLastBeat(float time)
|
||||
{
|
||||
if (beatMarkers == null || beatMarkers.Count == 0) return null;
|
||||
|
||||
BeatMarker last = null;
|
||||
|
||||
for (int i = 0; i < beatMarkers.Count; i++)
|
||||
{
|
||||
if (beatMarkers[i].time <= time)
|
||||
{
|
||||
last = beatMarkers[i];
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取最接近指定时间的节拍标记
|
||||
/// </summary>
|
||||
/// <param name="time">目标时间(秒)</param>
|
||||
/// <param name="tolerance">最大容差(秒),超出此范围返回 null</param>
|
||||
/// <returns>最近的节拍标记,若超出容差则返回 null</returns>
|
||||
public BeatMarker GetNearestBeat(float time, float tolerance)
|
||||
{
|
||||
if (beatMarkers == null || beatMarkers.Count == 0) return null;
|
||||
|
||||
BeatMarker nearest = null;
|
||||
float minDiff = float.MaxValue;
|
||||
|
||||
for (int i = 0; i < beatMarkers.Count; i++)
|
||||
{
|
||||
float diff = Mathf.Abs(beatMarkers[i].time - time);
|
||||
if (diff < minDiff)
|
||||
{
|
||||
minDiff = diff;
|
||||
nearest = beatMarkers[i];
|
||||
}
|
||||
}
|
||||
|
||||
return minDiff <= tolerance ? nearest : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定时间之后最近的节拍标记
|
||||
/// </summary>
|
||||
public BeatMarker GetNextBeat(float currentTime)
|
||||
{
|
||||
if (beatMarkers == null) return null;
|
||||
|
||||
for (int i = 0; i < beatMarkers.Count; i++)
|
||||
{
|
||||
if (beatMarkers[i].time > currentTime)
|
||||
{
|
||||
return beatMarkers[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定时间之后最近的、带有特定 tag 的节拍标记
|
||||
/// </summary>
|
||||
public BeatMarker GetNextBeatWithTag(float currentTime, string tag)
|
||||
{
|
||||
if (beatMarkers == null) return null;
|
||||
|
||||
for (int i = 0; i < beatMarkers.Count; i++)
|
||||
{
|
||||
if (beatMarkers[i].time > currentTime && beatMarkers[i].HasTag(tag))
|
||||
{
|
||||
return beatMarkers[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取时间范围内的所有节拍标记
|
||||
/// </summary>
|
||||
public List<BeatMarker> GetBeatsInRange(float startTime, float endTime)
|
||||
{
|
||||
var result = new List<BeatMarker>();
|
||||
if (beatMarkers == null) return result;
|
||||
|
||||
for (int i = 0; i < beatMarkers.Count; i++)
|
||||
{
|
||||
if (beatMarkers[i].time >= startTime && beatMarkers[i].time <= endTime)
|
||||
{
|
||||
result.Add(beatMarkers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按 BPM 在指定范围内一键生成等距节拍
|
||||
/// </summary>
|
||||
/// <param name="startTime">生成起始时间(秒)</param>
|
||||
/// <param name="endTime">生成结束时间(秒)</param>
|
||||
/// <param name="defaultTags">每个生成的节拍默认附带的 tags</param>
|
||||
public void GenerateBeatsFromBPM(float startTime, float endTime, List<string> defaultTags = null)
|
||||
{
|
||||
float interval = BeatInterval;
|
||||
if (interval <= 0f) return;
|
||||
|
||||
// 对齐起始时间到 audioStartOffset
|
||||
float firstBeatTime = audioStartOffset;
|
||||
while (firstBeatTime < startTime)
|
||||
{
|
||||
firstBeatTime += interval;
|
||||
}
|
||||
|
||||
int beatCounter = Mathf.RoundToInt((firstBeatTime - audioStartOffset) / interval);
|
||||
|
||||
for (float t = firstBeatTime; t <= endTime; t += interval)
|
||||
{
|
||||
int bar = beatCounter / beatsPerBar;
|
||||
int beatInBar = beatCounter % beatsPerBar;
|
||||
|
||||
var marker = new BeatMarker(
|
||||
time: t,
|
||||
tags: defaultTags != null ? new List<string>(defaultTags) : new List<string>(),
|
||||
barIndex: bar,
|
||||
beatInBar: beatInBar
|
||||
);
|
||||
|
||||
beatMarkers.Add(marker);
|
||||
beatCounter++;
|
||||
}
|
||||
|
||||
SortBeats();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除所有节拍标记
|
||||
/// </summary>
|
||||
public void ClearBeats()
|
||||
{
|
||||
beatMarkers.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按时间排序所有节拍标记
|
||||
/// </summary>
|
||||
public void SortBeats()
|
||||
{
|
||||
beatMarkers.Sort();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 BPM 和拍号自动计算小节/拍索引
|
||||
/// </summary>
|
||||
public void RecalculateBarIndices()
|
||||
{
|
||||
float interval = BeatInterval;
|
||||
if (interval <= 0f) return;
|
||||
|
||||
for (int i = 0; i < beatMarkers.Count; i++)
|
||||
{
|
||||
float adjustedTime = beatMarkers[i].time - audioStartOffset;
|
||||
int totalBeat = Mathf.RoundToInt(adjustedTime / interval);
|
||||
beatMarkers[i].barIndex = totalBeat / beatsPerBar;
|
||||
beatMarkers[i].beatInBar = totalBeat % beatsPerBar;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1bbb15cae81a83543bbc5da32d7a03b1
|
||||
@@ -0,0 +1,514 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 21df832e7a88f7f4986d6f527fd864e5
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dbf4be8193951ed4f981381fee23ce76
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,83 @@
|
||||
using Lean.Pool;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Cielonos.MainGame
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个节拍标记的 UI 元素,由 BeatTimelineUI 通过 LeanPool 生成和管理。
|
||||
/// 根据 BeatMarker 的 tags 显示对应的外观,沿时间轴从右向左移动
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(RectTransform))]
|
||||
public class BeatMarkerUI : MonoBehaviour, IPoolable
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private RectTransform selfRect;
|
||||
[SerializeField] private Image iconImage;
|
||||
|
||||
/// <summary>
|
||||
/// 关联的节拍数据
|
||||
/// </summary>
|
||||
public BeatMarker BeatData { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 配置标记的外观和数据。由 BeatTimelineUI 在 Spawn 时调用
|
||||
/// </summary>
|
||||
/// <param name="data">节拍数据</param>
|
||||
/// <param name="pointerPrefab">通过 tag 优先级匹配到的 UI Prefab(读取其 Image 属性)</param>
|
||||
public void Setup(BeatMarker data, GameObject pointerPrefab)
|
||||
{
|
||||
BeatData = data;
|
||||
|
||||
// 如果有自定义 pointerPrefab,替换 iconImage 的 sprite/显示内容
|
||||
if (pointerPrefab != null && iconImage != null)
|
||||
{
|
||||
var prefabImage = pointerPrefab.GetComponent<Image>();
|
||||
if (prefabImage != null)
|
||||
{
|
||||
iconImage.sprite = prefabImage.sprite;
|
||||
iconImage.color = prefabImage.color;
|
||||
iconImage.material = prefabImage.material;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新标记在时间轴上的位置
|
||||
/// </summary>
|
||||
/// <param name="normalizedPosition">
|
||||
/// 归一化位置:0 = 判定线(左侧),1 = 时间轴最右侧
|
||||
/// </param>
|
||||
/// <param name="timelineWidth">时间轴的实际像素宽度</param>
|
||||
public void UpdatePosition(float normalizedPosition, float timelineWidth)
|
||||
{
|
||||
if (selfRect == null) return;
|
||||
|
||||
float xPos = normalizedPosition * timelineWidth;
|
||||
selfRect.anchoredPosition = new Vector2(xPos, selfRect.anchoredPosition.y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LeanPool 回调:对象被 Spawn 时调用
|
||||
/// </summary>
|
||||
public void OnSpawn()
|
||||
{
|
||||
BeatData = null;
|
||||
gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LeanPool 回调:对象被 Despawn 时调用
|
||||
/// </summary>
|
||||
public void OnDespawn()
|
||||
{
|
||||
BeatData = null;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (selfRect == null)
|
||||
selfRect = GetComponent<RectTransform>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 36a57766b67135442a9ff3a2463a49b4
|
||||
@@ -0,0 +1,452 @@
|
||||
using System.Collections.Generic;
|
||||
using Cielonos.Core;
|
||||
using Lean.Pool;
|
||||
using Sirenix.OdinInspector;
|
||||
using SLSUtilities.General;
|
||||
using SLSUtilities.UI;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Cielonos.MainGame
|
||||
{
|
||||
/// <summary>
|
||||
/// 屏幕下方的节拍滚动时间轴 UI 控制器。
|
||||
/// 节拍标记从右向左移动,到达判定线时代表「现在」。
|
||||
/// 支持 tag 优先级系统:当一个节拍有多个 tag 时,显示优先级最高且在 beatMarkerCollection 中有对应 Prefab 的 tag。
|
||||
/// 无 Prefab 对应的 tag 节拍将被忽略不显示
|
||||
/// </summary>
|
||||
public class BeatTimelineUI : UIElementBase
|
||||
{
|
||||
#region Constants
|
||||
|
||||
/// <summary>
|
||||
/// 默认预览的未来节拍数量
|
||||
/// </summary>
|
||||
private const int DEFAULT_PREVIEW_BEAT_COUNT = 4;
|
||||
|
||||
/// <summary>
|
||||
/// 节拍标记通过判定线后多少秒回收(留出一点视觉余量)
|
||||
/// </summary>
|
||||
private const float RECYCLE_OFFSET_SECONDS = 0.3f;
|
||||
|
||||
/// <summary>
|
||||
/// 判定反馈文本显示时长(秒)
|
||||
/// </summary>
|
||||
private const float JUDGEMENT_DISPLAY_DURATION = 0.6f;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialized Fields
|
||||
|
||||
[TitleGroup("Timeline References")]
|
||||
[Tooltip("判定线 Image,位于时间轴左侧")]
|
||||
[SerializeField] private RectTransform judgeLine;
|
||||
|
||||
[TitleGroup("Timeline References")]
|
||||
[Tooltip("节拍标记的父容器(标记在此 RectTransform 内移动)")]
|
||||
[SerializeField] private RectTransform markerContainer;
|
||||
|
||||
[TitleGroup("Timeline References")]
|
||||
[Tooltip("节拍标记的 Prefab 模板(用于 LeanPool Spawn)")]
|
||||
[SerializeField] private GameObject markerPrefab;
|
||||
|
||||
[TitleGroup("Timeline References")]
|
||||
[Tooltip("判定结果反馈文本")]
|
||||
[SerializeField] private TextMeshProUGUI judgementText;
|
||||
|
||||
[TitleGroup("Settings")]
|
||||
[Tooltip("预览的未来节拍数量")]
|
||||
[MinValue(1), MaxValue(8)]
|
||||
[SerializeField] private int previewBeatCount = DEFAULT_PREVIEW_BEAT_COUNT;
|
||||
|
||||
[TitleGroup("Tag Priority")]
|
||||
[Tooltip("Tag 优先级列表,索引越小优先级越高。只有在 beatMarkerCollection 中有对应 Prefab 的 tag 才会被显示")]
|
||||
[SerializeField] private List<string> tagPriorityOrder = new List<string>();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private State
|
||||
|
||||
private MusicBeatSystem beatSystem;
|
||||
private float timelineWidth;
|
||||
private float previewDuration;
|
||||
private bool isInitialized;
|
||||
|
||||
// Active markers tracked for position update and recycling
|
||||
private readonly List<BeatMarkerUI> activeMarkers = new List<BeatMarkerUI>();
|
||||
|
||||
// Track which beat indices are currently displayed to avoid duplicates
|
||||
private readonly HashSet<int> displayedBeatIndices = new HashSet<int>();
|
||||
|
||||
// Judgement display
|
||||
private float judgementDisplayTimer;
|
||||
|
||||
// Cached collection reference
|
||||
private MainGameBaseCollection baseCollection;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
|
||||
/// <summary>
|
||||
/// 初始化时间轴 UI,订阅 MusicBeatSystem 事件。
|
||||
/// 支持重复调用(会先取消旧订阅)
|
||||
/// </summary>
|
||||
public void Initialize(MusicBeatSystem system)
|
||||
{
|
||||
if (system == null)
|
||||
{
|
||||
Debug.LogError("[BeatTimelineUI] Initialize failed: system is null");
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复订阅:先取消旧事件
|
||||
if (beatSystem != null)
|
||||
{
|
||||
beatSystem.OnActivated -= OnSystemActivated;
|
||||
beatSystem.OnDeactivated -= OnSystemDeactivated;
|
||||
beatSystem.OnPlayerBeatJudged -= OnPlayerJudged;
|
||||
}
|
||||
|
||||
beatSystem = system;
|
||||
baseCollection = MainGameBaseCollection.Instance;
|
||||
|
||||
// Subscribe
|
||||
beatSystem.OnActivated += OnSystemActivated;
|
||||
beatSystem.OnDeactivated += OnSystemDeactivated;
|
||||
beatSystem.OnPlayerBeatJudged += OnPlayerJudged;
|
||||
|
||||
isInitialized = true;
|
||||
|
||||
Hide();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在判定线位置显示 Perfect/Good/Miss 反馈
|
||||
/// </summary>
|
||||
public void ShowJudgement(BeatJudgement judgement)
|
||||
{
|
||||
if (judgementText == null) return;
|
||||
|
||||
judgementText.gameObject.SetActive(true);
|
||||
judgementDisplayTimer = JUDGEMENT_DISPLAY_DURATION;
|
||||
|
||||
switch (judgement.accuracy)
|
||||
{
|
||||
case BeatAccuracy.Perfect:
|
||||
judgementText.text = "Perfect!";
|
||||
judgementText.color = new Color(1f, 0.9f, 0.2f); // Gold
|
||||
break;
|
||||
case BeatAccuracy.Good:
|
||||
judgementText.text = "Good";
|
||||
judgementText.color = new Color(0.3f, 1f, 0.5f); // Green
|
||||
break;
|
||||
case BeatAccuracy.Miss:
|
||||
judgementText.text = "Miss";
|
||||
judgementText.color = new Color(0.6f, 0.6f, 0.6f); // Gray
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示/隐藏时间轴 UI
|
||||
/// </summary>
|
||||
public void SetVisible(bool visible)
|
||||
{
|
||||
if (visible)
|
||||
Show();
|
||||
else
|
||||
Hide();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!isInitialized || beatSystem == null || !beatSystem.IsActive || !beatSystem.IsPlaying)
|
||||
return;
|
||||
|
||||
// 每帧刷新 timelineWidth,防止首帧布局未完成导致宽度为 0
|
||||
RefreshTimelineWidth();
|
||||
|
||||
UpdatePreviewDuration();
|
||||
|
||||
// 先生成新标记,再更新位置和回收旧标记
|
||||
// 确保新生成的标记在同一帧内即可被定位和显示
|
||||
SpawnUpcomingMarkers();
|
||||
UpdateMarkers();
|
||||
UpdateJudgementDisplay();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (beatSystem != null)
|
||||
{
|
||||
beatSystem.OnActivated -= OnSystemActivated;
|
||||
beatSystem.OnDeactivated -= OnSystemDeactivated;
|
||||
beatSystem.OnPlayerBeatJudged -= OnPlayerJudged;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
|
||||
private void OnSystemActivated(MusicBeatData beatData)
|
||||
{
|
||||
DespawnAllMarkers();
|
||||
displayedBeatIndices.Clear();
|
||||
|
||||
SetVisible(true);
|
||||
}
|
||||
|
||||
private void OnSystemDeactivated()
|
||||
{
|
||||
DespawnAllMarkers();
|
||||
displayedBeatIndices.Clear();
|
||||
SetVisible(false);
|
||||
}
|
||||
|
||||
private void OnPlayerJudged(BeatJudgement judgement)
|
||||
{
|
||||
ShowJudgement(judgement);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Core Update Logic
|
||||
|
||||
/// <summary>
|
||||
/// 刷新时间轴宽度。RectTransform 在 SetActive(true) 后的首帧可能尚未完成布局,
|
||||
/// 因此每帧检查并更新,直到获取到有效值
|
||||
/// </summary>
|
||||
private void RefreshTimelineWidth()
|
||||
{
|
||||
if (markerContainer == null) return;
|
||||
|
||||
float width = markerContainer.rect.width;
|
||||
if (width > 0f)
|
||||
{
|
||||
timelineWidth = width;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据当前 BPM 和预览拍数计算需要预览的时间范围
|
||||
/// </summary>
|
||||
private void UpdatePreviewDuration()
|
||||
{
|
||||
if (beatSystem.CurrentBeatData == null) return;
|
||||
previewDuration = beatSystem.CurrentBeatData.BeatInterval * previewBeatCount;
|
||||
if (previewDuration <= 0f) previewDuration = 2f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新所有活跃标记的位置,回收已过期的标记
|
||||
/// </summary>
|
||||
private void UpdateMarkers()
|
||||
{
|
||||
if (timelineWidth <= 0f) return;
|
||||
|
||||
float currentTime = beatSystem.CurrentSongTime;
|
||||
|
||||
for (int i = activeMarkers.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var marker = activeMarkers[i];
|
||||
if (marker == null)
|
||||
{
|
||||
activeMarkers.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// BeatData 可能在 Despawn 时被清除,视为无效标记
|
||||
if (marker.BeatData == null)
|
||||
{
|
||||
DespawnMarker(marker);
|
||||
activeMarkers.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
float beatTime = marker.BeatData.time;
|
||||
float timeUntilBeat = beatTime - currentTime;
|
||||
|
||||
// 已过判定线一段时间,回收
|
||||
if (timeUntilBeat < -RECYCLE_OFFSET_SECONDS)
|
||||
{
|
||||
DespawnMarker(marker);
|
||||
activeMarkers.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 归一化位置:0 = 判定线,1 = 时间轴最右端
|
||||
float normalizedPos = Mathf.Clamp01(timeUntilBeat / previewDuration);
|
||||
marker.UpdatePosition(normalizedPos, timelineWidth);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成即将到来的节拍标记
|
||||
/// </summary>
|
||||
private void SpawnUpcomingMarkers()
|
||||
{
|
||||
if (beatSystem.CurrentBeatData == null) return;
|
||||
if (timelineWidth <= 0f) return;
|
||||
|
||||
float currentTime = beatSystem.CurrentSongTime;
|
||||
float lookAheadEnd = currentTime + previewDuration;
|
||||
|
||||
var markers = beatSystem.CurrentBeatData.beatMarkers;
|
||||
if (markers == null) return;
|
||||
|
||||
for (int i = 0; i < markers.Count; i++)
|
||||
{
|
||||
// Skip already displayed
|
||||
if (displayedBeatIndices.Contains(i)) continue;
|
||||
|
||||
var beat = markers[i];
|
||||
|
||||
// Skip beats already past recycle window
|
||||
if (beat.time < currentTime - RECYCLE_OFFSET_SECONDS) continue;
|
||||
|
||||
// Beats are sorted by time; beyond preview range, stop
|
||||
if (beat.time > lookAheadEnd) break;
|
||||
|
||||
// Resolve which pointer to use based on tag priority
|
||||
GameObject pointer = ResolvePointerForBeat(beat);
|
||||
//Debug.Log($"[BeatTimelineUI] Resolving pointer for beat at {beat.time:F2}s with tags [{string.Join(", ", beat.tags)}]: {(pointer != null ? pointer.name : "None")}");
|
||||
// If no pointer found, skip this beat (e.g., enemy-only beats)
|
||||
if (pointer == null) continue;
|
||||
|
||||
// Spawn via LeanPool
|
||||
var markerUI = LeanPool.Spawn(markerPrefab, markerContainer).GetComponent<BeatMarkerUI>();
|
||||
if (markerUI == null)
|
||||
{
|
||||
Debug.LogWarning("[BeatTimelineUI] LeanPool.Spawn returned null");
|
||||
continue;
|
||||
}
|
||||
|
||||
markerUI.Setup(beat, pointer);
|
||||
|
||||
// 立即定位,避免闪烁
|
||||
float timeUntilBeat = beat.time - currentTime;
|
||||
float normalizedPos = Mathf.Clamp01(timeUntilBeat / previewDuration);
|
||||
markerUI.UpdatePosition(normalizedPos, timelineWidth);
|
||||
|
||||
activeMarkers.Add(markerUI);
|
||||
displayedBeatIndices.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新判定反馈文本的显示计时
|
||||
/// </summary>
|
||||
private void UpdateJudgementDisplay()
|
||||
{
|
||||
if (judgementText == null || !judgementText.gameObject.activeSelf) return;
|
||||
|
||||
judgementDisplayTimer -= Time.deltaTime;
|
||||
if (judgementDisplayTimer <= 0f)
|
||||
{
|
||||
judgementText.gameObject.SetActive(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fade out
|
||||
float alpha = Mathf.Clamp01(judgementDisplayTimer / JUDGEMENT_DISPLAY_DURATION);
|
||||
var color = judgementText.color;
|
||||
color.a = alpha;
|
||||
judgementText.color = color;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tag Priority Resolution
|
||||
|
||||
/// <summary>
|
||||
/// 根据 tag 优先级解析节拍应使用的 Pointer Prefab。
|
||||
/// 遍历节拍的 tags,按 tagPriorityOrder 的优先级排序,
|
||||
/// 返回第一个在 beatMarkerCollection 中有对应 Prefab 的 tag 的 Pointer。
|
||||
/// 若无任何 tag 匹配,返回 null(该节拍不显示)
|
||||
/// </summary>
|
||||
private GameObject ResolvePointerForBeat(BeatMarker beat)
|
||||
{
|
||||
if (baseCollection == null || beat == null)
|
||||
{
|
||||
Debug.LogError("[BeatTimelineUI] ResolvePointerForBeat failed: baseCollection or beat is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
SerializedDictionary<string, GameObject> markerCollection = baseCollection.beatMarkerCollection;
|
||||
|
||||
// No tags: check if there's a default pointer (empty string key or "Default")
|
||||
if (beat.tags == null || beat.tags.Count == 0)
|
||||
{
|
||||
if (markerCollection.TryGetValue("Normal", out var defaultPointer))
|
||||
return defaultPointer;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Single tag: fast path
|
||||
if (beat.tags.Count == 1)
|
||||
{
|
||||
if (markerCollection.TryGetValue(beat.tags[0], out var pointer))
|
||||
return pointer;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Multiple tags: use priority order
|
||||
for (int p = 0; p < tagPriorityOrder.Count; p++)
|
||||
{
|
||||
string priorityTag = tagPriorityOrder[p];
|
||||
if (beat.HasTag(priorityTag))
|
||||
{
|
||||
if (markerCollection.TryGetValue(priorityTag, out var pointer))
|
||||
return pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try any tag that has a pointer, in the order they appear on the beat
|
||||
for (int t = 0; t < beat.tags.Count; t++)
|
||||
{
|
||||
if (markerCollection.TryGetValue(beat.tags[t], out var pointer))
|
||||
return pointer;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region LeanPool Management
|
||||
|
||||
/// <summary>
|
||||
/// 通过 LeanPool 回收单个标记
|
||||
/// </summary>
|
||||
private void DespawnMarker(BeatMarkerUI marker)
|
||||
{
|
||||
if (marker == null) return;
|
||||
LeanPool.Despawn(marker);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 回收所有活跃标记
|
||||
/// </summary>
|
||||
private void DespawnAllMarkers()
|
||||
{
|
||||
for (int i = activeMarkers.Count - 1; i >= 0; i--)
|
||||
{
|
||||
DespawnMarker(activeMarkers[i]);
|
||||
}
|
||||
|
||||
activeMarkers.Clear();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dde7c59ff31b2714687c9abb8946ce2c
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa9203747caa53742a7172c97699500e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Cielonos.MainGame
|
||||
{
|
||||
public partial class RatingSystem : CombatSystemBase
|
||||
{
|
||||
public int rank;
|
||||
public float percent;
|
||||
|
||||
public List<RankInfo> rankInfos;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
InitializeRankInfos();
|
||||
}
|
||||
}
|
||||
|
||||
public partial class RatingSystem
|
||||
{
|
||||
public struct RankInfo
|
||||
{
|
||||
public string rankName;
|
||||
public float increaseMultiplier;
|
||||
public float decreaseMultiplier;
|
||||
|
||||
public RankInfo(string rankName, float increaseMultiplier, float decreaseMultiplier)
|
||||
{
|
||||
this.rankName = rankName;
|
||||
this.increaseMultiplier = increaseMultiplier;
|
||||
this.decreaseMultiplier = decreaseMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeRankInfos()
|
||||
{
|
||||
rankInfos = new List<RankInfo>
|
||||
{
|
||||
new RankInfo("D", 1f, 1f),
|
||||
new RankInfo("C", 0.95f, 1.25f),
|
||||
new RankInfo("B", 0.9f, 1.5f),
|
||||
new RankInfo("A", 0.85f, 1.75f),
|
||||
new RankInfo("S", 0.8f, 2f),
|
||||
new RankInfo("SS", 0.7f, 3f),
|
||||
new RankInfo("SSS", 0.6f, 5f)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 82bdd97abb7680844a888f3e3706c680
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 427a843db1afe264a82824693c208eb2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user