MusicBeat

This commit is contained in:
SoulliesOfficial
2026-05-26 00:21:27 -04:00
parent 649b7a5ddc
commit b5cb6152ff
663 changed files with 534461 additions and 587 deletions

View File

@@ -34,6 +34,7 @@ namespace Cielonos.MainGame
public string spamGroupName;
public bool isEnabling;
public bool canTriggerHitEvent = true;
public List<string> tags = new List<string>();
public Action updateAction;
[Title("Submodules")]
@@ -58,6 +59,7 @@ namespace Cielonos.MainGame
this.itemSource = itemSource;
this.targetFractions = targetFractions.ToList();
this.canTriggerHitEvent = true;
this.tags = new List<string>();
attackSm = null;
timeSm = null;

View File

@@ -29,7 +29,7 @@ namespace Cielonos.MainGame
public override void Update()
{
speed += acceleration * (timeScaleCoefficient * attackArea.creator.selfTimeSm.DeltaTime);
speed = Mathf.Max(0, speed);
//speed = Mathf.Max(0, speed);
if ((disableNegative && speed < 0) || !canMove)
{

View File

@@ -186,7 +186,7 @@ namespace Cielonos.MainGame.Buffs
public class IntervalAction
{
public float timer;
float interval;
public float interval;
Action action;
public IntervalAction(Action action, float interval)

View File

@@ -7,14 +7,13 @@ namespace Cielonos.MainGame.Buffs.Character
public partial class Decay : CharacterBuffBase
{
public float accumulatedDamage;
public float damageInterval;
public float maximumDamagePerInterval;
public float Interval => timeSubmodule.intervalActions[0].interval;
public Decay(float damage, float damageInterval = 0.25f, float maximumDamagePerInterval = 10f)
public Decay(float damage, float damageInterval = 0.5f, float maximumDamagePerInterval = 10f)
{
Initialize(BuffType.Negative, BuffDispelLevel.Basic);
this.accumulatedDamage = damage;
this.damageInterval = damageInterval;
this.maximumDamagePerInterval = maximumDamagePerInterval;
this.contentSubmodule = new ContentSubmodule(this);
@@ -28,11 +27,12 @@ namespace Cielonos.MainGame.Buffs.Character
{
Decay existingDecay = existingBuff as Decay;
existingDecay!.accumulatedDamage += this.accumulatedDamage;
if (existingDecay.damageInterval > this.damageInterval)
float existingDecayInterval = existingDecay.timeSubmodule.intervalActions[0].interval;
float thisInterval = this.timeSubmodule.intervalActions[0].interval;
if (existingDecayInterval > thisInterval)
{
existingDecay.damageInterval = this.damageInterval;
existingDecay.timeSubmodule.intervalActions[0].SetInterval(existingDecay.damageInterval);
existingDecay.timeSubmodule.intervalActions[0].SetInterval(thisInterval);
}
if (existingDecay.maximumDamagePerInterval < this.maximumDamagePerInterval)
@@ -80,7 +80,7 @@ namespace Cielonos.MainGame.Buffs.Character
[Tooltip("初始衰变伤害总量。")]
public int damage;
[Tooltip("伤害间隔(秒)。")]
public float damageInterval = 0.25f;
public float damageInterval = 0.5f;
[Tooltip("每次间隔的最大伤害量。")]
public float maximumDamagePerInterval = 10f;

View File

@@ -1,46 +0,0 @@
using Cielonos.MainGame.UI;
using SLSUtilities.FunctionalAnimation;
using UnityEngine;
namespace Cielonos.MainGame.Buffs.Character
{
public class ElectronicDisturbance : CharacterBuffBase
{
public ElectronicDisturbance(int stackAmount)
{
Initialize(BuffType.Negative, BuffDispelLevel.Strong);
this.contentSubmodule = new ContentSubmodule(this);
this.unitedStackSubmodule = new UnitedStackSubmodule(this, stackAmount);
this.timeSubmodule = new TimeSubmodule(this, true);
this.timeSubmodule.AddIntervalAction(() =>
{
this.unitedStackSubmodule.ReduceStack(1);
}, 1f);
//this.independentStackSubmodule = new IndependentStackSubmodule(this, stackAmount, duration);
}
public override bool OnBuffApply(out CharacterBuffBase existingBuff)
{
if (FindExistingSameBuff(out existingBuff))
{
//existingBuff.independentStackSubmodule.Merge(this.independentStackSubmodule);
existingBuff.unitedStackSubmodule.AddStack(this.unitedStackSubmodule.stackAmount);
return false;
}
return true;
}
public override void OnBuffUpdate()
{
base.OnBuffUpdate();
if (unitedStackSubmodule.stackAmount >= 100)
{
new GeneralIncapacitation(5f).Apply(attachedCharacter, sourceCharacter);
Remove();
}
PlayerCanvas.BossInfoUIArea[attachedCharacter]?.UpdateArmor();
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ec8c49cf217c61f4895906d08e37e5b9

View File

@@ -31,7 +31,7 @@ namespace Cielonos.MainGame.FunctionalAnimation
public override void Invoke()
{
if (runtimeFuncAnim == null) return;
if (runtimeFuncAnim == null || mute) return;
runtimeFuncAnim.currentActionParams = this.parameters;

View File

@@ -48,7 +48,7 @@ namespace Cielonos.MainGame.Characters
foreach (KeyValuePair<string, float> timer in timerData.timers)
{
selfTimeSm.coolDownTimers[timer.Key] = new Timer(timer.Value);
selfTimeSm.coolDownTimers[timer.Key] = new CooldownTimer(timer.Value);
}
}
@@ -116,7 +116,7 @@ namespace Cielonos.MainGame.Characters
eventSm.onDeath.Invoke();
CombatManager.CoordinatorSm.ReleaseAll(this);
CombatManager.EnemySm.RemoveEnemy(this);
float deathProcessTime = 0f;
var deathFuncAnim = fullBodyFuncAnims.animDataList.Find(data => data.animInfo.animationName == "Death");
if (deathFuncAnim != null)

View File

@@ -22,6 +22,16 @@ namespace Cielonos.MainGame.Characters
base.InitializeSubcontrollers();
reactionSc.InitializeResistances(enemyRank);
}
protected override void Start()
{
base.Start();
if (fraction == Fraction.Enemy)
{
CombatManager.EnemySm.activeEnemiesList.Add(this);
}
}
}
public partial class Enemy
@@ -36,6 +46,7 @@ namespace Cielonos.MainGame.Characters
}
base.Die();
CombatManager.EnemySm.RemoveEnemy(this);
}
}
}

View File

@@ -63,10 +63,7 @@ namespace Cielonos.MainGame.Characters
{
selfTimeSm?.SetUp(this);
if (fraction == Fraction.Enemy)
{
CombatManager.EnemySm.activeEnemiesList.Add(this);
}
}
protected virtual void Update()

View File

@@ -18,7 +18,7 @@ namespace Cielonos.MainGame.Characters
private float enemyTimeScale => timeManager.enemyTimeScale.Value;
private float nonPlayerTimeScale => timeManager.nonPlayerTimeScale.Value;
public Dictionary<string, Timer> coolDownTimers = new Dictionary<string, Timer>();
public Dictionary<string, CooldownTimer> coolDownTimers = new Dictionary<string, CooldownTimer>();
public float TimeScale => owner.fraction switch
{
@@ -265,34 +265,20 @@ namespace Cielonos.MainGame.Characters
}
}
public class Timer
public class CooldownTimer : Timer
{
public float originalDuration; // 可选:记录最初设置的持续时间,方便重置时使用
public float duration;
public float currentTime;
public float Percentage => duration > 0 ? Mathf.Clamp01(currentTime / duration) : 1f;
public bool IsCompleted => currentTime >= duration;
public Timer(float duration)
public CooldownTimer(float duration) : base(duration)
{
this.originalDuration = duration;
this.duration = duration;
this.currentTime = 0f;
}
public void Update(float deltaTime)
{
if (!IsCompleted)
{
currentTime += deltaTime;
}
}
/// <summary>
/// 重置计时器,可以选择新的持续时间(如果不提供则使用原始持续时间)
/// </summary>
/// <param name="newDuration"></param>
public void Reset(float newDuration = -1f)
public override void Reset(float newDuration = -1f)
{
currentTime = 0f;
duration = newDuration >= 0f ? newDuration : originalDuration;

View File

@@ -31,6 +31,7 @@ namespace Cielonos.MainGame.Characters
}
SetupDash(inputDirection, true, length);
//player.vfxData.SpawnVFX("PerfectDodgeLine", player, player.bodyPartsSc.head);
};
player.operationSc.OnDodge += (length)=>
{
@@ -40,6 +41,7 @@ namespace Cielonos.MainGame.Characters
}
SetupDodge(length);
//player.vfxData.SpawnVFX("PerfectDodgeLine", player, player.bodyPartsSc.head);
};
}

View File

@@ -26,14 +26,17 @@ namespace Cielonos.MainGame.Characters
RegisterOperations();
backpackSm ??= new BackpackSubmodule(this);
equipmentSm ??= new EquipmentSubmodule(this);
backpackSm.ObtainItem<FutureWand>();
backpackSm.ObtainItem<Polychrome>();
//backpackSm.ObtainItem<DualHarmony>();
backpackSm.ObtainItem<FutureWand>();
backpackSm.ObtainItem<Ascension>();
backpackSm.ObtainItem<BellowsThruster>();
backpackSm.ObtainItem<BlackHoleDisplacer>();
backpackSm.ObtainItem<DecayPropagator>();
backpackSm.ObtainItem<QuantumShieldGenerator>();
backpackSm.ObtainItem<DecayAccelerationCoil>();
backpackSm.ObtainItem<FusionInjector>();
backpackSm.ObtainItem<MissileSeparationMembrane>();
foreach (MainWeaponBase mainWeapon in backpackSm.mainWeapons)
{

View File

@@ -36,7 +36,7 @@ namespace Cielonos.MainGame.Characters
public bool isDuringCameraSwitch;
private const float CameraSwitchCooldown = 0.25f;
public CharacterBase lockTarget;
public Enemy lockTarget;
private float lastTargetSwitchTime;
private const float TargetSwitchCooldown = 0.25f;
public Transform targetPoint;
@@ -90,7 +90,7 @@ namespace Cielonos.MainGame.Characters
{
if(isDuringCameraSwitch) return;
CharacterBase target = CombatManager.EnemySm.GetNearestEnemy(50f);
Enemy target = CombatManager.EnemySm.GetNearestEnemy(50f);
if (target != null)
{
@@ -168,7 +168,7 @@ namespace Cielonos.MainGame.Characters
if (Time.time - lastTargetSwitchTime < TargetSwitchCooldown) return;
List<CharacterBase> sortedEnemies = CombatManager.EnemySm.GetVisibleEnemiesSortedByScreenX();
List<Enemy> sortedEnemies = CombatManager.EnemySm.GetVisibleEnemiesSortedByScreenX();
if (sortedEnemies.Count <= 1) return;
int currentIndex = sortedEnemies.IndexOf(lockTarget);
@@ -198,7 +198,7 @@ namespace Cielonos.MainGame.Characters
SetNewTarget(sortedEnemies[newIndex]);
}
private void SetNewTarget(CharacterBase newTarget)
private void SetNewTarget(Enemy newTarget)
{
if (lockTarget != null)
{

View File

@@ -49,6 +49,8 @@ namespace Cielonos.MainGame.Inventory
public AmmoSubmodule ammoSm;
[HideInEditorMode]
public OverloadSubmodule overloadSm;
[HideInEditorMode]
public AuraSubmodule auraSm;
[TitleGroup("Subcontrollers")]
public FeedbackSubcontroller feedbackSc;

View File

@@ -1,9 +0,0 @@
using UnityEngine;
namespace Cielonos.MainGame.Inventory.Collections
{
public class BlackHoleDisplacer : ExtenderBase
{
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 76c30d7d91254704cada46246e2e5a0d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,13 @@
using UnityEngine;
namespace Cielonos.MainGame.Inventory.Collections
{
/// <summary>
/// “黑洞”位移器 / “Black Hole” Displacer
/// FutureWand的扩展器使重攻击产生的黑洞向前/后移动攻击段数由4变为8
/// </summary>
public class BlackHoleDisplacer : ExtenderBase
{
}
}

View File

@@ -0,0 +1,13 @@
using UnityEngine;
namespace Cielonos.MainGame.Inventory.Collections
{
/// <summary>
/// 飞弹分离膜 / Missile Separation Membrane
/// FutureWand的扩展器使轻攻击的飞弹在击中敌人后分离一次飞向附近的敌人。
/// </summary>
public class MissileSeparationMembrane : ExtenderBase
{
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2fe9df091575f8f428a988fbe724dc3e

View File

@@ -0,0 +1,141 @@
using Cielonos.MainGame.Characters;
using SLSUtilities.FunctionalAnimation;
using SLSUtilities.WwiseAssistance;
using UnityEngine;
namespace Cielonos.MainGame.Inventory.Collections
{
public partial class DualHarmony : MainWeaponBase
{
private MusicBeatSystem MusicBeatSystem => CombatManager.GetCombatSystem<MusicBeatSystem>();
public CharacterBase currentTarget;
public override void OnEquipped()
{
base.OnEquipped();
RegisterFunctionsToAnimSc();
/*if(!player.inputSc.IsMoving) PlayTargetedAnimation("Equip");
viewObjects["Wand"].SetFadeAnim(0.5f);*/
}
public override void OnPrimaryPress()
{
if (functionSm["LightAttack"].IsAvailable() && fullBodyFuncAnimSm.CheckPlayability())
{
comboSm.main.NextCombo("L");
string funcAnimName = "Attack" + comboSm.main.GetCurrentNodeName();
FuncAnimData data = fullBodyFuncAnimSm.collection[funcAnimName];
float startUpTime = data.Interval(IntervalType.Active).StartTime;
float nextBeatTime = MusicBeatSystem.GetNextBeat().time - MusicBeatSystem.CurrentSongTime;
BeatMarker lastMarker = MusicBeatSystem.GetLastBeat();
float compensation = lastMarker.time - MusicBeatSystem.CurrentSongTime;
functionSm["LightAttack"].Execute();
float difference;
if (compensation > -startUpTime)
{
difference = startUpTime - nextBeatTime;
if (difference < 0f)
{
return;
}
}
else
{
difference = startUpTime;
}
currentTarget = CombatManager.EnemySm.GetNearestEnemy(25f);
if (currentTarget != null)
{
PlayTargetedAnimation(funcAnimName, currentTarget, 5f);
}
else
{
PlayTargetedAnimation(funcAnimName);
}
fullBodyFuncAnimSm.currentRuntimeFuncAnim.currentPlayTime = difference;
Debug.Log($"StartUpTime: {nextBeatTime}, FuncAnim Active StartTime: {startUpTime}, Difference: {difference}, Compensation: {compensation}");
}
}
public override void OnSecondaryPress()
{
/*if (functionSm["HeavyAttack"].IsAvailable() && fullBodyFuncAnimSm.CheckPlayability())
{
comboSm.main.NextCombo("R");
float startUpTime = MusicBeatSystem.GetNextBeat().time - MusicBeatSystem.CurrentSongTime;
string funcAnimName = "Attack" + comboSm.main.GetCurrentNodeName();
FuncAnimData data = fullBodyFuncAnimSm.collection[funcAnimName];
float difference = data.Interval(IntervalType.Active).StartTime - startUpTime;
if (difference < 0f)
{
return;
}
currentTarget = CombatManager.EnemySm.GetNearestEnemy(25f);
if (currentTarget != null)
{
PlayTargetedAnimation(funcAnimName, currentTarget, 5f);
}
else
{
PlayTargetedAnimation(funcAnimName);
}
fullBodyFuncAnimSm.currentRuntimeFuncAnim.currentPlayTime = difference;
Debug.Log($"StartUpTime: {startUpTime}, FuncAnim Active StartTime: {data.Interval(IntervalType.Active).StartTime}, Difference: {difference}");
}*/
}
public override void OnSpecialAPress()
{
CombatManager.GetCombatSystem<MusicBeatSystem>().Activate();
}
}
public partial class DualHarmony
{
private void FAPF_GenerateBullet(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringStringFloat p = rtFuncAnim.GetParams<CustomFunction.PC_StringStringFloat>();
GenerateProjectile(p.str0, attackData[p.str1], currentTarget, p.float0);
}
}
public partial class DualHarmony
{
private void GenerateProjectile(string vfxName, AttackUnit attackUnit,
CharacterBase target, float speed, bool hasMuzzleEffect = true, Transform muzzle = null)
{
if (hasMuzzleEffect)
{
muzzle ??= player.bodyPartsSc.leftHand;
vfxData.SpawnMuzzleVFX(vfxName, player, muzzle);
}
Projectile projectile = vfxData.SpawnVFX(vfxName, player).GetComponentInChildren<Projectile>();
Vector3 direction = player.transform.forward;
if (target != null)
{
direction = (target.centerPoint.position - projectile.transform.position).normalized;
}
projectile.Initialize(player, this, false, 1, Fraction.Enemy)
.SetAttackSubmodule<Projectile>(attackUnit)
.SetTimeSubmodule<Projectile>(10f)
.SetHitSubmodule<Projectile>()
.SetAdaptiveTraceMoveModule<Projectile>(target, speed, 5f, 180f, 30f, direction, detectRadius: 25f)
.SetRaycastSubmodule<Projectile>(default, 0.25f, 0.5f);
projectile.hitSm.AddHitSound(AK.EVENTS.FUTUREWAND_WEAKATTACKHIT);
projectile.SetImpulseSubmodule().WithDynamicForce(1f);
AudioManager.Post(AK.EVENTS.FUTUREWAND_NORMALBOLTRELEASE, projectile.gameObject);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a0fc3aa61a4c14f47a51b4e6d34bd731

View File

@@ -113,9 +113,9 @@ namespace Cielonos.MainGame.Inventory.Collections
private void FAPF_GenerateMultipleBolts(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringStringFloat p = rtFuncAnim.GetParams<CustomFunction.PC_StringStringFloat>();
List<CharacterBase> targets = CombatManager.EnemySm.GetEnemiesInRadius(player.transform.position, 25f);
List<Enemy> targets = CombatManager.EnemySm.GetEnemiesInRadius(player.transform.position, 25f);
if (targets.Count > 0) vfxData.SpawnMuzzleVFX(p.str0, player, muzzle);
foreach (CharacterBase target in targets)
foreach (Enemy target in targets)
{
GenerateProjectile(p.str0, attackData[p.str1], target, p.float0, false);
}
@@ -149,7 +149,7 @@ namespace Cielonos.MainGame.Inventory.Collections
.SetAttackSubmodule<Projectile>(attackUnit)
.SetTimeSubmodule<Projectile>(10f)
.SetHitSubmodule<Projectile>()
.SetAdaptiveTraceMoveModule<Projectile>(target, speed, 5f, 20f, 20f, direction, detectRadius: 25f)
.SetAdaptiveTraceMoveModule<Projectile>(target, speed, 5f, 180f, 30f, direction, detectRadius: 25f)
.SetRaycastSubmodule<Projectile>(default, 0.25f, 0.5f);
if (vfxName == "NormalBolt")
@@ -170,6 +170,26 @@ namespace Cielonos.MainGame.Inventory.Collections
{
new Fusion(fusionStack).Apply(enemy);
SubscribeFusionExplode(enemy);
if (!projectile.tags.Contains("Separation") && HasExtender<MissileSeparationMembrane>())
{
List<Enemy> nearbyEnemies = CombatManager.EnemySm.GetEnemiesInRadius(hitPosition, 25f).Exclude(enemy as Enemy);
if (nearbyEnemies.Count > 0)
{
Projectile separation = vfxData.SpawnVFX(vfxName, player, hitPosition, Quaternion.identity).GetComponentInChildren<Projectile>();
nearbyEnemies.TryGetRandom(out Enemy randomTarget);
Vector3 sepDirection = (randomTarget.centerPoint.position - hitPosition).normalized;
separation.Initialize(player, this, false, 1, Fraction.Enemy)
.SetAttackSubmodule<Projectile>(attackUnit)
.SetTimeSubmodule<Projectile>(10f)
.SetHitSubmodule<Projectile>()
.SetLinearDirectionMoveModule<Projectile>(sepDirection, speed, 5f);
separation.tags.Add("Separation");
separation.hitSm.AddCheckedObject(enemy.gameObject);
separation.SetRaycastSubmodule<Projectile>(default, 0.25f, 0.5f);
}
}
});
}
@@ -177,20 +197,26 @@ namespace Cielonos.MainGame.Inventory.Collections
{
vfxData.SpawnMuzzleVFX(vfxName, player, muzzle);
NormalArea area = vfxData.SpawnVFX(vfxName, player).GetComponentInChildren<NormalArea>();
area.Initialize<NormalArea>(player, this, Fraction.Enemy)
.SetAttackSubmodule<NormalArea>(attackUnit)
.SetTimeSubmodule<NormalArea>(3f, 0.2f, 0.8f)
.SetHitSubmodule<NormalArea>(0.1f, 4);
area.SetImpulseSubmodule().WithSuction(4f);
area.hitSm.AddHitSound(AK.EVENTS.FUTUREWAND_WEAKATTACKHIT);
AudioManager.Post(AK.EVENTS.FUTUREWAND_GROUNDAREA, area.gameObject);
if (HasExtender<BlackHoleDisplacer>()) //扩展器测试
if (!HasExtender<BlackHoleDisplacer>())
{
area.SetLinearDirectionMoveModule<NormalArea>(player.transform.forward, 0f, 25f, false, true, false);
area.Initialize<NormalArea>(player, this, Fraction.Enemy)
.SetAttackSubmodule<NormalArea>(attackUnit)
.SetTimeSubmodule<NormalArea>(3f, 0.2f, 0.8f)
.SetHitSubmodule<NormalArea>(0.1f, 4);
}
else
{
area.Initialize<NormalArea>(player, this, Fraction.Enemy)
.SetAttackSubmodule<NormalArea>(attackUnit)
.SetTimeSubmodule<NormalArea>(3f, 0.1f, 0.9f)
.SetHitSubmodule<NormalArea>(0.1f, 8)
.SetLinearDirectionMoveModule<NormalArea>(player.transform.forward, 25f, -50f, false, false, false);
}
area.SetImpulseSubmodule().WithSuction(4f);
area.hitSm.AddHitSound(AK.EVENTS.FUTUREWAND_WEAKATTACKHIT);
int fusionStack = attackUnit.GetSubmodule<AttackUnit.ParameterSubmodule>().GetParameter<int>("Buff_Fusion_Stack");
area.hitSm.AddHitEvent((enemy, hitPosition) =>
{

View File

@@ -152,12 +152,12 @@ namespace Cielonos.MainGame.Inventory.Collections
return;
}
List<CharacterBase> availableEnemies = CombatManager.EnemySm.GetEnemiesInRadius(player.transform.position, 4);
List<Enemy> availableEnemies = CombatManager.EnemySm.GetEnemiesInRadius(player.transform.position, 4);
//完美格挡+反击
if (player.reactionSc.blockSm.afterPerfectBlockTimer > 0)
{
CharacterBase target = CombatManager.EnemySm.GetBestEnemy(availableEnemies);
Enemy target = CombatManager.EnemySm.GetBestEnemy(availableEnemies);
if (PlayTargetedAnimation("BlockParryAttack", target))
{
player.reactionSc.blockSm.afterPerfectBlockTimer = 0;
@@ -212,8 +212,8 @@ namespace Cielonos.MainGame.Inventory.Collections
_ => "A"
};
List<CharacterBase> disruptable = CombatManager.EnemySm.GetDisruptableEnemies(availableEnemies);
CharacterBase target = CombatManager.EnemySm.GetScoredEnemies(availableEnemies)
List<Enemy> disruptable = CombatManager.EnemySm.GetDisruptableEnemies(availableEnemies);
Enemy target = CombatManager.EnemySm.GetScoredEnemies(availableEnemies)
.ApplyScoreModifier(disruptable, 0f, 1f).BestEnemy();
if (PlayTargetedAnimation("DisruptionAttack" + suffix, target))
@@ -247,7 +247,7 @@ namespace Cielonos.MainGame.Inventory.Collections
if (functionSm["HeavyAttack"].IsAvailable())
{
CharacterBase target = CombatManager.EnemySm.GetBestEnemy(availableEnemies);
Enemy target = CombatManager.EnemySm.GetBestEnemy(availableEnemies);
string nextNodeName = comboSm.main.GetNextNodeName("R");
bool keepAdsorption = nextNodeName is "RC";
if (PlayTargetedAnimation("Attack" + nextNodeName, target, 1f, keepAdsorption))
@@ -296,11 +296,11 @@ namespace Cielonos.MainGame.Inventory.Collections
player.reactionSc.blockSm.GetCurrentBlockSource().PerfectBlock(null, player.centerPosition);
RemoveBlock();*/
/*player.operationSc.Dodge();
player.operationSc.Dodge();
DodgeSource defaultDodge = DodgeSource.Default(player);
player.reactionSc.dodgeSm.ApplyDodge(defaultDodge);
player.reactionSc.dodgeSm.GetCurrentDodgeSource().PerfectDodge();
player.reactionSc.dodgeSm.RemoveDodge("DefaultDodge");*/
player.reactionSc.dodgeSm.RemoveDodge("DefaultDodge");
}
}
@@ -319,8 +319,8 @@ namespace Cielonos.MainGame.Inventory.Collections
private void FAPF_GenerateAirNormalSlash(RuntimeFuncAnim rtFuncAnim)
{
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
NormalArea slash = GenerateNormalSlash(p.str0, attackData[p.str1], "SingleNormalHit");
CustomFunction.PC_StringString p = rtFuncAnim.GetParams<CustomFunction.PC_StringString>();
GenerateNormalSlash(p.str0, attackData[p.str1], "SingleNormalHit");
}
private void FAPF_GenerateHeavySlash(RuntimeFuncAnim rtFuncAnim)
@@ -459,8 +459,7 @@ namespace Cielonos.MainGame.Inventory.Collections
AudioManager.Post(AK.EVENTS.DISRUPT, hitPosition);
};
slash.hitSm
.AddHitSound(AK.EVENTS.POLYCHROME_HEAVYATTACKLHIT)
slash.hitSm.AddHitSound(AK.EVENTS.POLYCHROME_HEAVYATTACKLHIT)
.AddHitEvent((enemy, hitPosition) =>
{
var positionShakeAction = feedbackSc.GetFeedbackData("HeavyHit").Action<CameraPositionShakeAction>("Camera");

View File

@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using Cielonos.MainGame.Buffs.Character;
using Cielonos.MainGame.Characters;
using SLSUtilities.General;
using UnityEngine;
namespace Cielonos.MainGame.Inventory.Collections
{
/// <summary>
/// 衰变加速线圈 / Decay Acceleration Coil (Passive, Graham)
/// 玩家光环范围15m内的敌人其 Decay 伤害间隔缩短 50%。离开光环后恢复。
/// </summary>
public class DecayAccelerationCoil : PassiveEquipmentBase
{
private const string EventKey = nameof(DecayAccelerationCoil);
/// <summary>
/// 记录已被修改 Decay 间隔的敌人及其原始间隔值,用于离开光环时恢复。
/// </summary>
private readonly Dictionary<CharacterBase, float> _modifiedEnemies = new();
public override void OnObtained()
{
base.OnObtained();
auraSm = new AuraSubmodule(this, passiveAttributeSm.GetItemAttribute("AuraRadius"));
Action<CharacterBase> onEnter = OnEnemyEnterAura;
Action<CharacterBase> onExit = OnEnemyExitAura;
Action<CharacterBase> onStay = OnEnemyStayAura;
auraSm.onOtherEnterAura.Add(EventKey, onEnter.ToPrioritized());
auraSm.onOtherExitAura.Add(EventKey, onExit.ToPrioritized());
auraSm.onOtherStayAura.Add(EventKey, onStay.ToPrioritized());
}
public override void OnDiscarded()
{
// 恢复所有被修改的 Decay 间隔
auraSm?.Dispose();
_modifiedEnemies.Clear();
base.OnDiscarded();
}
protected override void Update()
{
base.Update();
auraSm?.Update(player.selfTimeSm.DeltaTime);
}
/// <summary>
/// 敌人进入光环时,尝试加速其 Decay 间隔。
/// </summary>
private void OnEnemyEnterAura(CharacterBase enemy)
{
TryAccelerateDecay(enemy);
}
/// <summary>
/// 敌人持续处于光环内时,检查是否有新施加的 Decay 需要加速。
/// </summary>
private void OnEnemyStayAura(CharacterBase enemy)
{
if (_modifiedEnemies.ContainsKey(enemy)) return;
TryAccelerateDecay(enemy);
}
/// <summary>
/// 敌人离开光环时,恢复其 Decay 间隔。
/// </summary>
private void OnEnemyExitAura(CharacterBase enemy)
{
RestoreDecayInterval(enemy);
}
/// <summary>
/// 尝试加速目标身上的 Decay 伤害间隔。
/// </summary>
private void TryAccelerateDecay(CharacterBase enemy)
{
if (!enemy.buffSm.TryGetBuff(out Decay decay)) return;
if (_modifiedEnemies.ContainsKey(enemy)) return;
_modifiedEnemies[enemy] = decay.Interval;
float acceleratedInterval = decay.Interval * passiveAttributeSm.GetItemAttribute("IntervalMultiplier");
decay.timeSubmodule.intervalActions[0].SetInterval(acceleratedInterval);
}
/// <summary>
/// 恢复目标身上的 Decay 伤害间隔至原始值。
/// </summary>
private void RestoreDecayInterval(CharacterBase enemy)
{
if (!_modifiedEnemies.Remove(enemy, out float originalInterval)) return;
if (!enemy.buffSm.TryGetBuff(out Decay decay)) return;
decay.timeSubmodule.intervalActions[0].SetInterval(originalInterval);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 42c37456513125040b6ec211c2472168

View File

@@ -43,7 +43,7 @@ namespace Cielonos.MainGame.Inventory.Collections
private void SpreadDecay(Fusion fusion, AttackAreaBase attackArea)
{
CharacterBase target = fusion.attachedCharacter;
List<CharacterBase> affectedTargets = CombatManager.EnemySm.GetEnemiesInRadius(target.centerPosition, 10f);
List<Enemy> affectedTargets = CombatManager.EnemySm.GetEnemiesInRadius(target.centerPosition, 10f);
float decayDamage = passiveAttributeSm.GetItemAttribute("DecayDamage");
affectedTargets.ForEach(t => new Decay(decayDamage).Apply(t));
}

View File

@@ -0,0 +1,154 @@
using System.Collections.Generic;
using Cielonos.MainGame.Characters;
using SLSUtilities.General;
using SoftCircuits.Collections;
using UnityEngine;
namespace Cielonos.MainGame.Inventory
{
/// <summary>
/// 光环子模块。持续捕获指定半径内的角色,并通过 Enter / Stay / Exit 事件通知订阅方。
/// 不使用 Data ScriptableObject由道具在代码中直接构造。
/// </summary>
public class AuraSubmodule : SubmoduleBase<ItemBase>
{
/// <summary>
/// 当前处于光环内的角色集合。
/// </summary>
public HashSet<CharacterBase> charactersInAura = new HashSet<CharacterBase>();
/// <summary>
/// 其他角色进入光环时触发,参数为进入的角色。
/// </summary>
public OrderedDictionary<string, PrioritizedAction<CharacterBase>> onOtherEnterAura = new();
/// <summary>
/// 其他角色持续处于光环内时触发(按 stayInterval 间隔),参数为在光环内的角色。
/// </summary>
public OrderedDictionary<string, PrioritizedAction<CharacterBase>> onOtherStayAura = new();
/// <summary>
/// 其他角色离开光环时触发,参数为离开的角色。
/// </summary>
public OrderedDictionary<string, PrioritizedAction<CharacterBase>> onOtherExitAura = new();
/// <summary>
/// 光环半径。
/// </summary>
public float auraRadius;
/// <summary>
/// Stay 计时器,到达 stayInterval 后对所有在光环内的角色触发 onOtherStayAura。
/// </summary>
public Timer stayTimer;
/// <summary>
/// 物理检测的 LayerMask。
/// </summary>
private readonly int _detectionLayerMask;
private const int MaxDetectionCount = 32;
private readonly Collider[] _overlapBuffer = new Collider[MaxDetectionCount];
/// <summary>
/// 用于比较前后帧角色集合的临时缓冲。
/// </summary>
private readonly HashSet<CharacterBase> _currentFrameCharacters = new HashSet<CharacterBase>();
/// <summary>
/// 构造光环子模块。
/// </summary>
/// <param name="owner">所属道具。</param>
/// <param name="auraRadius">光环半径。</param>
/// <param name="stayInterval">Stay 事件触发间隔(秒),默认 0.5 秒。</param>
public AuraSubmodule(ItemBase owner, float auraRadius, float stayInterval = 0.5f) : base(owner)
{
this.auraRadius = auraRadius;
stayTimer = new Timer(stayInterval, isInfinite: true);
stayTimer.onComplete.Add(new PrioritizedAction(OnStayTimerComplete));
_detectionLayerMask = LayerMask.GetMask("HurtBox");
}
/// <summary>
/// 每帧调用,驱动物理检测和事件派发。
/// </summary>
public void Update(float deltaTime)
{
DetectCharacters();
stayTimer.Update(deltaTime);
}
/// <summary>
/// 清理所有追踪状态,对仍在光环内的角色触发 Exit 事件。
/// </summary>
public void Dispose()
{
foreach (var character in charactersInAura)
{
if (character != null)
{
onOtherExitAura.Invoke(character);
}
}
charactersInAura.Clear();
}
/// <summary>
/// 执行球形物理检测,比较前后帧角色集合,触发 Enter / Exit 事件。
/// </summary>
private void DetectCharacters()
{
_currentFrameCharacters.Clear();
Vector3 center = owner.player.transform.position;
int hitCount = Physics.OverlapSphereNonAlloc(center, auraRadius, _overlapBuffer, _detectionLayerMask);
for (int i = 0; i < hitCount; i++)
{
CharacterBase character = _overlapBuffer[i].GetComponentInParent<CharacterBase>();
if (character == null || character == owner.player) continue;
_currentFrameCharacters.Add(character);
}
// 检测新进入光环的角色
foreach (var character in _currentFrameCharacters)
{
if (charactersInAura.Add(character))
{
onOtherEnterAura.Invoke(character);
}
}
// 检测离开光环的角色(从旧集合中移除不在当前帧的角色)
charactersInAura.RemoveWhere(character =>
{
if (_currentFrameCharacters.Contains(character)) return false;
if (character != null)
{
onOtherExitAura.Invoke(character);
}
return true;
});
}
/// <summary>
/// Stay 计时器到期时,对所有在光环内的角色触发 onOtherStayAura。
/// </summary>
private void OnStayTimerComplete()
{
foreach (var character in charactersInAura)
{
if (character != null)
{
onOtherStayAura.Invoke(character);
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 84bc61c526c4a824a9267b1a5c2e3ff4

View File

@@ -27,7 +27,7 @@ namespace Cielonos.MainGame
//audioContainer.PlaySoundFX("Scan", scanOrigin.gameObject);
scannerEffect.StartScan(1);
List<CharacterBase> enemies = CombatManager.EnemySm.GetEnemiesInRadius(scanOrigin.position, 50f);
List<Enemy> enemies = CombatManager.EnemySm.GetEnemiesInRadius(scanOrigin.position, 50f);
enemies.ForEach(enemy => new Scanner_WeaknessDetection().Apply(enemy));
functionSm["Scan"].Execute();

View File

@@ -23,6 +23,10 @@ namespace Cielonos.MainGame
[ShowInInspector]
private CombatRoomSubmodule combatRoomSm;
[ShowInInspector]
[SerializeReference]
private List<CombatSystemBase> combatSystems;
protected override void Awake()
{

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fcd0dccf29bcb5a4496e2186a3bc72cf
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 646d8d8fcd0c04148bbe7a2e3e845a94

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 95be6808bef2a4145b757b9ba166f230
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0263b68e56ad44c408cb3c7d4da6dbd9

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e771794cfd829ae4ab2bce4396c556ff

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5a22f26f6e10a67439599837e4c36717
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e8b585d86f9f54847ae6e73de15f584d

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b6a89c3d1ef423d4caeebb66af064d24

View File

@@ -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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4ab8425d4ccf05646a7447e09ef6b3b9
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1bbb15cae81a83543bbc5da32d7a03b1

View File

@@ -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
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 21df832e7a88f7f4986d6f527fd864e5

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: dbf4be8193951ed4f981381fee23ce76
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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>();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 36a57766b67135442a9ff3a2463a49b4

View File

@@ -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
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dde7c59ff31b2714687c9abb8946ce2c

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: aa9203747caa53742a7172c97699500e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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)
};
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 82bdd97abb7680844a888f3e3706c680

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 427a843db1afe264a82824693c208eb2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -363,13 +363,12 @@ namespace Cielonos.MainGame
private HashSet<int> GetEliteExcludedSectors(Vector3 playerPos)
{
HashSet<int> excluded = new HashSet<int>();
List<CharacterBase> activeEnemies = CombatManager.EnemySm.activeEnemiesList;
List<Enemy> activeEnemies = CombatManager.EnemySm.activeEnemiesList;
for (int e = 0; e < activeEnemies.Count; e++)
{
CharacterBase character = activeEnemies[e];
if (character == null || character.statusSm.isDead) continue;
if (character is not Enemy enemy) continue;
Enemy enemy = activeEnemies[e];
if (enemy == null || enemy.statusSm.isDead) continue;
if (enemy.enemyRank < EnemyRank.Nexus) continue;
Vector3 elitePos = enemy.transform.position;

View File

@@ -16,24 +16,24 @@ namespace Cielonos.MainGame
Farthest
}
public List<CharacterBase> activeEnemiesList;
public List<Enemy> activeEnemiesList;
/// <summary>
/// 敌人从活跃列表中移除时触发(即敌人死亡/离场)。
/// 参数为被移除的敌人实例。
/// </summary>
public event Action<CharacterBase> OnEnemyRemoved;
public event Action<Enemy> OnEnemyRemoved;
public EnemySubmodule(CombatManager owner) : base(owner)
{
activeEnemiesList = new List<CharacterBase>();
activeEnemiesList = new List<Enemy>();
}
/// <summary>
/// 将敌人从活跃列表中移除并触发 OnEnemyRemoved 事件。
/// 由 Automata.Die() 调用,替代直接操作 activeEnemiesList。
/// </summary>
public void RemoveEnemy(CharacterBase enemy)
public void RemoveEnemy(Enemy enemy)
{
if (!activeEnemiesList.Remove(enemy)) return;
OnEnemyRemoved?.Invoke(enemy);
@@ -47,10 +47,10 @@ namespace Cielonos.MainGame
activeEnemiesList.Clear();
}
public List<CharacterBase> GetEnemiesInRadius(Vector3 origin, float radius, SortingType sortingType = SortingType.Nearest)
public List<Enemy> GetEnemiesInRadius(Vector3 origin, float radius, SortingType sortingType = SortingType.Nearest)
{
List<CharacterBase> enemiesInRadius = new List<CharacterBase>();
foreach (CharacterBase enemy in activeEnemiesList)
List<Enemy> enemiesInRadius = new List<Enemy>();
foreach (Enemy enemy in activeEnemiesList)
{
float enemyRadius = enemy.collisionSc.useCharacterController
? enemy.collisionSc.characterController.radius
@@ -78,12 +78,12 @@ namespace Cielonos.MainGame
/// <summary>
/// 获取所有可见敌人
/// </summary>
public List<CharacterBase> GetVisibleEnemies(float radius = 50f)
public List<Enemy> GetVisibleEnemies(float radius = 50f)
{
List<CharacterBase> result = new List<CharacterBase>();
List<CharacterBase> enemies = GetEnemiesInRadius(Player.transform.position, radius);
List<Enemy> result = new List<Enemy>();
List<Enemy> enemies = GetEnemiesInRadius(Player.transform.position, radius);
foreach (CharacterBase enemy in enemies)
foreach (Enemy enemy in enemies)
{
if (enemy == null || enemy.statusSm.isDead) continue;
@@ -104,9 +104,9 @@ namespace Cielonos.MainGame
/// <summary>
/// 获取按屏幕X坐标从左到右排序的可见敌人列表
/// </summary>
public List<CharacterBase> GetVisibleEnemiesSortedByScreenX(float radius = 50f)
public List<Enemy> GetVisibleEnemiesSortedByScreenX(float radius = 50f)
{
List<CharacterBase> visibleEnemies = GetVisibleEnemies(radius);
List<Enemy> visibleEnemies = GetVisibleEnemies(radius);
visibleEnemies.Sort((a, b) =>
{

View File

@@ -12,7 +12,7 @@ namespace Cielonos.MainGame
/// <summary>
/// 从已有的敌人列表中选取评分最高的目标。
/// </summary>
public CharacterBase GetBestEnemy(List<CharacterBase> enemies)
public Enemy GetBestEnemy(List<Enemy> enemies)
{
if (enemies.Count == 0) return null;
if (enemies.Count == 1) return enemies[0];
@@ -33,7 +33,7 @@ namespace Cielonos.MainGame
/// 从已有的敌人列表中进行评分,返回完整的评分列表(按分数从高到低)。
/// 适合后续通过 ApplyScoreModifier 施加修正。
/// </summary>
public List<TargetingScore> GetScoredEnemies(List<CharacterBase> enemies,
public List<TargetingScore> GetScoredEnemies(List<Enemy> enemies,
float radius = float.MaxValue, Transform origin = null)
{
List<TargetingScore> allScores = GetScoredEnemies(radius, origin);
@@ -55,12 +55,12 @@ namespace Cielonos.MainGame
/// 获取可被扰乱的敌人列表:
/// <para>目前获取的是当前可以受到Disruption打断且有对应Buff黄光红光等的敌人</para>
/// </summary>
public List<CharacterBase> GetDisruptableEnemies(List<CharacterBase> enemies,
public List<Enemy> GetDisruptableEnemies(List<Enemy> enemies,
Breakthrough.Type breakthroughType = Breakthrough.Type.Disruption)
{
List<CharacterBase> disruptableEnemies = new List<CharacterBase>();
List<Enemy> disruptableEnemies = new List<Enemy>();
foreach (CharacterBase enemy in enemies)
foreach (Enemy enemy in enemies)
{
bool hasBuff = enemy.buffSm.HasBuff<BreakthroughResistanceModification>();
if (!enemy.reactionSc.breakthroughResistances[breakthroughType].Value && hasBuff)

View File

@@ -15,7 +15,7 @@ namespace Cielonos.MainGame
/// </summary>
public struct TargetingScore
{
public CharacterBase target;
public Enemy target;
/// <summary>最终评分 = baseScore + bonusScore。</summary>
public float totalScore;
/// <summary>基础加权评分(由 GetScoredEnemies 计算,不受后续修正影响)。</summary>
@@ -61,7 +61,7 @@ namespace Cielonos.MainGame
origin = Player.transform;
}
List<CharacterBase> candidates = GetEnemiesInRadius(origin.position, radius);
List<Enemy> candidates = GetEnemiesInRadius(origin.position, radius);
if (candidates.Count == 0) return new List<TargetingScore>();
Camera camera = Player.viewSc.playerCamera;
@@ -111,7 +111,7 @@ namespace Cielonos.MainGame
List<TargetingScore> results = new List<TargetingScore>(candidates.Count);
foreach (CharacterBase enemy in candidates)
foreach (Enemy enemy in candidates)
{
if (enemy == null || enemy.statusSm.isDead) continue;
@@ -178,11 +178,11 @@ namespace Cielonos.MainGame
/// <summary>
/// 综合索敌的便捷方法:返回评分最高的单个敌人。
/// </summary>
public CharacterBase GetBestEnemy(float radius, Transform origin = null, Func<CharacterBase> overrideCandidate = null)
public Enemy GetBestEnemy(float radius, Transform origin = null, Func<Enemy> overrideCandidate = null)
{
if (overrideCandidate != null)
{
CharacterBase oc = overrideCandidate();
Enemy oc = overrideCandidate();
if (oc != null)
{
return oc;
@@ -196,10 +196,10 @@ namespace Cielonos.MainGame
/// <summary>
/// 综合索敌的便捷方法:返回评分前 N 的敌人列表。
/// </summary>
public List<CharacterBase> GetBestEnemies(float radius, int count, Transform origin = null)
public List<Enemy> GetBestEnemies(float radius, int count, Transform origin = null)
{
List<TargetingScore> scores = GetScoredEnemies(radius, origin);
List<CharacterBase> result = new List<CharacterBase>(Mathf.Min(count, scores.Count));
List<Enemy> result = new List<Enemy>(Mathf.Min(count, scores.Count));
for (int i = 0; i < scores.Count && i < count; i++)
{
result.Add(scores[i].target);
@@ -214,7 +214,7 @@ namespace Cielonos.MainGame
{
return GetBestEnemy(50f, null, ReturnLockon);
CharacterBase ReturnLockon()
Enemy ReturnLockon()
{
LockTargetSubmodule lockModule = MainGameManager.Player.viewSc.lockTargetModule;
if (lockModule.isLocking && lockModule.lockTarget != null)
@@ -229,17 +229,17 @@ namespace Cielonos.MainGame
public partial class EnemySubmodule
{
public CharacterBase GetNearestEnemy(float radius, Transform origin = null)
public Enemy GetNearestEnemy(float radius, Transform origin = null)
{
origin ??= Player.transform;
List<CharacterBase> candidates = GetEnemiesInRadius(origin.position, radius);
List<Enemy> candidates = GetEnemiesInRadius(origin.position, radius);
return candidates.FirstOrDefault();
}
public List<CharacterBase> GetNearestEnemies(float radius, int count, Transform origin = null)
public List<Enemy> GetNearestEnemies(float radius, int count, Transform origin = null)
{
origin ??= Player.transform;
List<CharacterBase> candidates = GetEnemiesInRadius(origin.position, radius);
List<Enemy> candidates = GetEnemiesInRadius(origin.position, radius);
return candidates.Take(count).ToList();
}
}
@@ -249,11 +249,11 @@ namespace Cielonos.MainGame
{
public static List<CombatManager.EnemySubmodule.TargetingScore> ApplyScoreModifier(
this List<CombatManager.EnemySubmodule.TargetingScore> scores,
List<CharacterBase> boostTargets, float amplifier, float offset = 0)
List<Enemy> boostTargets, float amplifier, float offset = 0)
{
return scores.ApplyScoreModifier(Predicate, amplifier, offset);
bool Predicate(CharacterBase target)
bool Predicate(Enemy target)
{
return boostTargets != null && boostTargets.Contains(target);
}
@@ -261,7 +261,7 @@ namespace Cielonos.MainGame
public static List<CombatManager.EnemySubmodule.TargetingScore> ApplyScoreModifier(
this List<CombatManager.EnemySubmodule.TargetingScore> scores,
Predicate<CharacterBase> match, float amplifier, float offset = 0)
Predicate<Enemy> match, float amplifier, float offset = 0)
{
bool changed = false;
for (int i = 0; i < scores.Count; i++)
@@ -283,14 +283,14 @@ namespace Cielonos.MainGame
return scores;
}
public static CharacterBase BestEnemy(this List<CombatManager.EnemySubmodule.TargetingScore> scores)
public static Enemy BestEnemy(this List<CombatManager.EnemySubmodule.TargetingScore> scores)
{
return scores.Count > 0 ? scores[0].target : null;
}
public static List<CharacterBase> BestEnemies(this List<CombatManager.EnemySubmodule.TargetingScore> scores, int count)
public static List<Enemy> BestEnemies(this List<CombatManager.EnemySubmodule.TargetingScore> scores, int count)
{
List<CharacterBase> result = new List<CharacterBase>(Mathf.Min(count, scores.Count));
List<Enemy> result = new List<Enemy>(Mathf.Min(count, scores.Count));
for (int i = 0; i < scores.Count && i < count; i++)
{
result.Add(scores[i].target);

View File

@@ -6,6 +6,7 @@ using DamageNumbersPro;
using Sirenix.OdinInspector;
using SLSUtilities.General;
using UnityEngine;
using UnityEngine.Serialization;
namespace Cielonos.MainGame
{
@@ -129,4 +130,12 @@ namespace Cielonos.MainGame
}
}
}
public partial class MainGameBaseCollection
{
//Music Beat Combat System
[FormerlySerializedAs("beatPointerCollection")]
public SerializedDictionary<string, GameObject> beatMarkerCollection = new SerializedDictionary<string, GameObject>();
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b17b064ea2aeb4c48a5f2a6a65b9a18b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,10 @@
using SLSUtilities.UI;
using UnityEngine;
namespace Cielonos.MainGame.UI
{
public class CombatSystemsUIArea : UIElementBase
{
public BeatTimelineUI beatTimelineUI;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c13d6c6eb35b815419a0a40dd308f6e7

View File

@@ -21,6 +21,8 @@ namespace Cielonos.MainGame.UI
[SerializeField]
private EnemyInfoUIArea enemyInfoUIArea;
[SerializeField]
private CombatSystemsUIArea combatSystemsUIArea;
[SerializeField]
private MainGamePages mainGamePages;
private void Update()
@@ -37,6 +39,7 @@ namespace Cielonos.MainGame.UI
public static PlayerInfoUIArea PlayerInfoUIArea => Instance.playerInfoUIArea;
public static BossInfoUIArea BossInfoUIArea => Instance.bossInfoUIArea;
public static EnemyInfoUIArea EnemyInfoUIArea => Instance.enemyInfoUIArea;
public static CombatSystemsUIArea CombatSystemsUIArea => Instance.combatSystemsUIArea;
public static MainGamePages MainGamePages => Instance.mainGamePages;
}
}