特效新内容。修复gameplay致命bug和分数保存bug

This commit is contained in:
FloatGaming
2026-02-16 01:55:18 +08:00
parent 3a7a0b4669
commit 9f7c1c57b7
206 changed files with 112684 additions and 857 deletions
+111 -6
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
@@ -88,12 +88,19 @@ public class AllyCombatant : MonoBehaviour, ICombatant
private Vector3[] skillIconBaseScales;
private bool[] skillIconBaseScaleCached;
[Tooltip("最多显示的已释放技能图标数量 (默认1)")]
public int maxSkillHistoryCount = 1;
[Tooltip("无新技能释放时,技能图标自动渐隐的时间 (秒)")]
public float skillIconAutoFadeTime = 2f;
private Coroutine fadeHealthCoroutine;
private Coroutine fadeManaCoroutine;
private List<Buff> activeBuffs = new List<Buff>();
private Coroutine skillIconEnterCoroutine;
private Image skillIconEnterAnimatingImage;
private Coroutine skillIconAutoFadeCoroutine;
private float lastSkillIconPushTime = -1f;
// Track skills that are currently active because HP percent condition is satisfied.
private HashSet<string> _activeHpPercentSkills = new HashSet<string>();
@@ -146,6 +153,32 @@ public class AllyCombatant : MonoBehaviour, ICombatant
if (GameConfig.verboseLogs) Debug.Log(message);
}
private void Update()
{
// Check for skill icon auto-fade
if (Application.isPlaying && skillIconAutoFadeTime > 0f && lastSkillIconPushTime > 0f)
{
// Only start coroutine if not already running and time exceeded
if (skillIconAutoFadeCoroutine == null && Time.time - lastSkillIconPushTime >= skillIconAutoFadeTime)
{
// Double check visibility
bool anyVisible = false;
if (skillIconOccupied != null)
{
for (int i = 0; i < skillIconOccupied.Length; i++)
{
if (skillIconOccupied[i]) { anyVisible = true; break; }
}
}
if (anyVisible)
{
skillIconAutoFadeCoroutine = StartCoroutine(SkillIconAutoFadeRoutine());
}
}
}
}
private void Start()
{
// Resolve UI and optionally pull data from SO
@@ -482,6 +515,13 @@ public class AllyCombatant : MonoBehaviour, ICombatant
Image slot = skillIconSlots[i];
if (slot == null) continue;
// Enforce max count
if (i >= maxSkillHistoryCount)
{
slot.enabled = false;
continue;
}
bool occupied = i < skillIconOccupied.Length && skillIconOccupied[i];
slot.enabled = occupied || slot.sprite != null;
@@ -554,16 +594,29 @@ public class AllyCombatant : MonoBehaviour, ICombatant
if (icon == null) return;
if (skillIconSlots == null || skillIconSlots.Length == 0) ResolveHudExtras();
if (skillIconSlots == null || skillIconSlots.Length == 0) return;
// Reset auto-fade logic
lastSkillIconPushTime = Time.time;
if (skillIconAutoFadeCoroutine != null)
{
StopCoroutine(skillIconAutoFadeCoroutine);
skillIconAutoFadeCoroutine = null;
}
int limit = Mathf.Clamp(maxSkillHistoryCount, 0, skillIconSlots.Length);
if (limit <= 0) return;
EnsureSkillIconSlotScaleCache();
NormalizeSkillIconSlotsVisualState();
// Capture the icon being pushed out (oldest) for exit animation.
Image lastSlot = skillIconSlots[skillIconSlots.Length - 1];
// Capture the icon being pushed out (oldest active within limit) for exit animation.
int lastIndex = limit - 1;
Image lastSlot = skillIconSlots[lastIndex];
Sprite removedSprite = null;
Color removedColor = Color.white;
bool removedOccupied = skillIconOccupied != null &&
skillIconSlots.Length - 1 < skillIconOccupied.Length &&
skillIconOccupied[skillIconSlots.Length - 1];
lastIndex < skillIconOccupied.Length &&
skillIconOccupied[lastIndex];
if (removedOccupied && lastSlot != null && lastSlot.sprite != null)
{
removedSprite = lastSlot.sprite;
@@ -571,7 +624,7 @@ public class AllyCombatant : MonoBehaviour, ICombatant
}
// Shift down (oldest drops off the end).
for (int i = skillIconSlots.Length - 1; i > 0; i--)
for (int i = lastIndex; i > 0; i--)
{
var prev = skillIconSlots[i - 1];
var cur = skillIconSlots[i];
@@ -601,6 +654,19 @@ public class AllyCombatant : MonoBehaviour, ICombatant
skillIconOccupied[0] = true;
PlaySkillIconEnterAnim(first);
}
// Clear slots beyond limit
for (int i = limit; i < skillIconSlots.Length; i++)
{
if (skillIconSlots[i] != null)
{
skillIconSlots[i].sprite = null;
skillIconSlots[i].enabled = false;
}
if (skillIconOccupied != null && i < skillIconOccupied.Length)
skillIconOccupied[i] = false;
}
ApplySkillIconSlotVisibility();
// Play exit animation for the removed icon (if any).
@@ -610,6 +676,45 @@ public class AllyCombatant : MonoBehaviour, ICombatant
}
}
private IEnumerator SkillIconAutoFadeRoutine()
{
if (skillIconSlots == null || skillIconOccupied == null)
{
skillIconAutoFadeCoroutine = null;
yield break;
}
// Fading from Left to Right (0 -> N)
// Assuming slot 0 is the newest and leftmost.
// If the user meant visual order, 0 is usually left.
int limit = Mathf.Clamp(maxSkillHistoryCount, 0, skillIconSlots.Length);
for (int i = 0; i < limit; i++)
{
if (i >= skillIconOccupied.Length) break;
// If new skill interrupts, this coroutine is stopped by PushSkillIcon
if (skillIconOccupied[i] && skillIconSlots[i] != null && skillIconSlots[i].sprite != null)
{
// Play exit animation for this slot
PlaySkillIconExitAnim(skillIconSlots[i], skillIconSlots[i].sprite, skillIconSlots[i].color);
// Clear the slot
skillIconSlots[i].sprite = null;
skillIconSlots[i].enabled = false;
skillIconOccupied[i] = false;
// Wait small interval between fades
yield return new WaitForSeconds(0.15f);
}
}
ApplySkillIconSlotVisibility();
skillIconAutoFadeCoroutine = null;
// Reset time so it doesn't loop immediately (though checks anyVisible)
lastSkillIconPushTime = -1f;
}
private void PlaySkillIconEnterAnim(Image img)
{
if (img == null) return;
+13 -1
View File
@@ -82,12 +82,24 @@ public class EffectSystem : MonoBehaviour
// Captured target for callback
GameObject targetToHit = t;
bool isEnemyTarget = targetIsEnemy;
// Calculate damage info for explosion effect (Ally -> Enemy only)
(float damage, float maxHealth)? damageInfo = null;
if (isEnemyTarget && effectType == EffectType.DamageSingleEnemy)
{
var ec = targetToHit.GetComponent<EnemyCombatant>();
if (ec != null)
{
float effectiveDamage = amount * (1f - ec.damageResistance);
damageInfo = (effectiveDamage, (float)ec.maxHP);
}
}
GfxController.Instance.PlayProjectile(source, t, (finalPos) => {
// Only play hit FX when projectile arrives
if (targetToHit != null && GfxController.Instance != null)
{
GfxController.Instance.PlayHitFX(targetToHit, null, isEnemyTarget, finalPos);
GfxController.Instance.PlayHitFX(targetToHit, null, isEnemyTarget, finalPos, null, damageInfo);
}
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
using System.Collections;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
+4 -1
View File
@@ -29,10 +29,13 @@ namespace Bansonic
int totalFiles = 0;
int corruptedFiles = 0;
// 在主线程提前获取路径,避免子线程调用 Unity API 报错
string persistentPath = Application.persistentDataPath;
// 执行异步检查任务
await Task.Run(() =>
{
string path = Application.persistentDataPath;
string path = persistentPath;
string[] files = Directory.GetFiles(path, "SongData_*.json");
totalFiles = files.Length;
@@ -2,6 +2,7 @@ using System.IO;
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
using TMPro;
public class BeatmapManager : MonoBehaviour
{
@@ -34,6 +35,7 @@ public class BeatmapManager : MonoBehaviour
assignedSongData = song;
assignedDifficulty = difficulty;
Debug.LogWarning($"BeatmapManager.AcceptSongData: accepted song={(song==null?"NULL":song.songName)}, difficulty={difficulty}");
UpdateGameplayUI();
}
public Beatmap beatmap; // Documentation text normalized.
@@ -44,10 +46,14 @@ public class BeatmapManager : MonoBehaviour
// Documentation text normalized.
public teamUIController uiController;
public Image bgSpriteImage; // Documentation text normalized.
[Header("UI Display")]
public Text gameplay_songname;
public TextMeshProUGUI gameplay_difficultyID;
public TextMeshProUGUI gameplay_difficultyName;
public GameObject bgSpriteObject;
public Image bgMainImage; // 场景主背景图 (UI Image)
public Image startCanvas_image;
public SpriteRenderer bgSpriteRenderer; // 场景 3D 背景图 (SpriteRenderer)
// Extra fields from beatmap JSON (stored temporarily)
[HideInInspector] public string parsedTitle;
@@ -347,38 +353,36 @@ public class BeatmapManager : MonoBehaviour
Debug.LogWarning("GameManager or its musicSource not found; audio not assigned");
}
// Assign fullscreen image from SongData to bgSprite if available
// Assign fullscreen image from SongData to background images if available
if (assignedSongData != null && assignedSongData.fullscreen_songPicture != null)
{
// Prefer setting a Scene GameObject's SpriteRenderer if provided
if (bgSpriteObject != null)
Sprite bgSprite = assignedSongData.fullscreen_songPicture;
if (bgMainImage != null)
{
var sr = bgSpriteObject.GetComponentInChildren<SpriteRenderer>();
if (sr != null)
{
sr.sprite = assignedSongData.fullscreen_songPicture;
// ensure fully visible
sr.color = new Color(sr.color.r, sr.color.g, sr.color.b, 1f);
if (startCanvas_image != null)
{
startCanvas_image.sprite = assignedSongData.fullscreen_songPicture;
}
sr = null;
Debug.LogWarning($"Assigned SongData.fullscreen_songPicture to SpriteRenderer on bgSpriteObject for song {assignedSongData.songName}");
}
else
{
Debug.LogWarning("bgSpriteObject has no SpriteRenderer in children; cannot assign fullscreen sprite");
}
bgMainImage.sprite = bgSprite;
// ensure fully visible
bgMainImage.color = new Color(bgMainImage.color.r, bgMainImage.color.g, bgMainImage.color.b, 1f);
Debug.LogWarning($"Assigned SongData.fullscreen_songPicture to bgMainImage for song {assignedSongData.songName}");
}
else if (bgSpriteImage != null)
if (bgSpriteRenderer != null)
{
bgSpriteImage.sprite = assignedSongData.fullscreen_songPicture;
Debug.LogWarning($"Assigned SongData.fullscreen_songPicture to bgSpriteImage for song {assignedSongData.songName}");
bgSpriteRenderer.sprite = bgSprite;
Debug.LogWarning($"Assigned SongData.fullscreen_songPicture to bgSpriteRenderer for song {assignedSongData.songName}");
// 释放引用以节省内存(按要求:完毕后令 sprite renderer = null
bgSpriteRenderer = null;
}
else
if (startCanvas_image != null)
{
Debug.LogWarning("No bg target (bgSpriteObject or bgSpriteImage) assigned; fullscreen sprite not applied");
startCanvas_image.sprite = bgSprite;
Debug.LogWarning($"Assigned SongData.fullscreen_songPicture to startCanvas_image for song {assignedSongData.songName}");
}
if (bgMainImage == null && startCanvas_image == null)
{
Debug.LogWarning("No background Image targets assigned in BeatmapManager; fullscreen sprite not applied");
}
}
@@ -408,8 +412,52 @@ public class BeatmapManager : MonoBehaviour
else
{
// Documentation text normalized.
// Documentation text normalized.
Debug.LogWarning("Default automatic demo beatmap load is commented out to prioritize SO-provided charts.");
// Documentation text normalized.
Debug.LogWarning("Default automatic demo beatmap load is commented out to prioritize SO-provided charts.");
}
UpdateGameplayUI();
}
private void UpdateGameplayUI()
{
if (assignedSongData == null) return;
if (gameplay_songname != null)
{
gameplay_songname.text = assignedSongData.songName;
}
if (gameplay_difficultyID != null)
{
// Find the ChartFileEntry matching the assignedDifficulty to get difficultyLEVEL (star rating)
float diffLevel = 0;
if (assignedSongData.chartFiles != null)
{
foreach (var entry in assignedSongData.chartFiles)
{
if (entry.difficulty == assignedDifficulty)
{
diffLevel = entry.difficultyLEVEL;
break;
}
}
}
gameplay_difficultyID.text = diffLevel.ToString();
}
if (gameplay_difficultyName != null)
{
string diffText = "Unknown";
switch (assignedDifficulty)
{
case 0: diffText = "Easy"; break;
case 1: diffText = "Hard"; break;
case 2: diffText = "Incredible"; break;
case 3: diffText = "Impossible"; break;
}
gameplay_difficultyName.text = diffText;
}
}
@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
@@ -204,18 +204,18 @@ public class GameManager : MonoBehaviour
try
{
// Reset audio time to 0 before playing to ensure playback starts from the beginning
musicSource.time = 0f;
float startTime = 0f;
if (delaySeconds < 0f)
startTime = -delaySeconds;
if (delaySeconds > 0f)
{
// Use coroutine instead of PlayDelayed so pause state can interrupt it
playbackDelayCoroutine = StartCoroutine(DelayAndPlayMusic(delaySeconds));
}
else
{
// Immediate playback (no delay)
musicSource.time = 0f;
float clampedStartTime = ClampAudioStartTime(startTime);
musicSource.time = clampedStartTime;
musicSource.Play();
PlaybackStarted = true;
Debug.Log("GameManager.PlayMusicWithDelay: Music playback started immediately (no delay)");
@@ -226,7 +226,8 @@ public class GameManager : MonoBehaviour
Debug.LogWarning($"PlayMusicWithDelay failed: {ex}");
try
{
musicSource.time = 0f;
float clampedStartTime = ClampAudioStartTime(delaySeconds < 0f ? -delaySeconds : 0f);
musicSource.time = clampedStartTime;
musicSource.Play();
PlaybackStarted = true;
}
@@ -234,6 +235,14 @@ public class GameManager : MonoBehaviour
}
}
private float ClampAudioStartTime(float startTime)
{
if (musicSource == null || musicSource.clip == null)
return Mathf.Max(0f, startTime);
return Mathf.Clamp(startTime, 0f, musicSource.clip.length);
}
private IEnumerator DelayAndPlayMusic(float delaySeconds)
{
Debug.Log($"GameManager.DelayAndPlayMusic: waiting {delaySeconds} seconds before playing (respects pause state)");
@@ -37,6 +37,13 @@ public class GfxController : MonoBehaviour
public GameObject k_o_fx;
public GameObject koFatherObject;
[Header("Explosion Effect Settings")]
[Tooltip("当单次伤害超过敌人最大生命值的百分比时触发爆炸 (0.2 = 20%)")]
public float explosionDamageThresholdRatio = 0.2f;
[Tooltip("爆炸特效是否使用独立的缩放设置 (否则跟随Hit特效缩放)")]
public bool useExplosionSeparateScale = false;
public float explosionFxScale = 1.5f;
[Header("Hit Effect Settings - Enemy")]
public float enemyOffsetX = 0f;
public float enemyOffsetY = 0f;
@@ -123,11 +130,26 @@ public class GfxController : MonoBehaviour
/// <param name="isEnemy">是否为敌人受击</param>
/// <param name="customWorldPos">可选:自定义世界坐标播放(如匹配弹道终点)</param>
/// <param name="scaleOverride">可选:强制指定特效缩放倍率</param>
public void PlayHitFX(GameObject target, GameObject parent = null, bool isEnemy = false, Vector3? customWorldPos = null, float? scaleOverride = null)
/// <param name="damageInfo">可选:传入伤害信息以判断是否触发爆炸特效 (damage, maxHealth)</param>
public void PlayHitFX(GameObject target, GameObject parent = null, bool isEnemy = false, Vector3? customWorldPos = null, float? scaleOverride = null, (float damage, float maxHealth)? damageInfo = null)
{
if (target == null || hitFX == null)
// Determine which FX prefab to use (Hit or Explosion)
GameObject fxPrefab = hitFX;
bool isExplosion = false;
if (isEnemy && damageInfo.HasValue && explosionFX != null)
{
if (hitFX == null) Debug.LogWarning("[GfxController] hitFX Prefab 未在 Inspector 中分配!");
float ratio = damageInfo.Value.damage / damageInfo.Value.maxHealth;
if (ratio >= explosionDamageThresholdRatio)
{
fxPrefab = explosionFX;
isExplosion = true;
}
}
if (target == null || fxPrefab == null)
{
if (fxPrefab == null) Debug.LogWarning($"[GfxController] {(isExplosion ? "ExplosionFX" : "HitFX")} Prefab 未在 Inspector 中分配!");
return;
}
@@ -176,7 +198,14 @@ public class GfxController : MonoBehaviour
}
else if (isEnemy)
{
fxScale = useEnemyRandomScale ? Random.Range(enemyMinFxScale, enemyMaxFxScale) : enemyFxScale;
if (isExplosion && useExplosionSeparateScale)
{
fxScale = explosionFxScale;
}
else
{
fxScale = useEnemyRandomScale ? Random.Range(enemyMinFxScale, enemyMaxFxScale) : enemyFxScale;
}
}
else
{
@@ -194,7 +223,7 @@ public class GfxController : MonoBehaviour
visualParent.transform.localScale = Vector3.one * fxScale;
// 作为子物体生成
fx = Instantiate(hitFX, visualParent.transform);
fx = Instantiate(fxPrefab, visualParent.transform);
// 确保特效在 UI 层级中显示在最前方
fx.transform.SetAsLastSibling();
@@ -215,7 +244,7 @@ public class GfxController : MonoBehaviour
rt.localRotation = Quaternion.identity;
// 确保 UI 特效的原始大小正确,且 localScale 为 1(继承 parent 的缩放)
rt.sizeDelta = hitFX.GetComponent<RectTransform>().sizeDelta;
rt.sizeDelta = fxPrefab.GetComponent<RectTransform>().sizeDelta;
rt.localScale = Vector3.one;
}
else
@@ -235,7 +264,7 @@ public class GfxController : MonoBehaviour
else
{
Vector3 spawnPos = customWorldPos.HasValue ? customWorldPos.Value : (target.transform.position + offsetVector);
fx = Instantiate(hitFX, spawnPos, Quaternion.identity);
fx = Instantiate(fxPrefab, spawnPos, Quaternion.identity);
fx.transform.localScale = Vector3.one * fxScale;
}
@@ -0,0 +1,371 @@
using UnityEngine;
using UnityEngine.Pool;
using System.Collections.Generic;
public class groundParticularController : MonoBehaviour
{
[System.Serializable]
public struct MaterialConfig
{
public Material material;
public bool isEnabled;
[Tooltip("是否为该材质启用渐变颜色覆盖")]
public bool useGradientOverride;
[Tooltip("粒子生命周期内的颜色渐变 (仅在 useGradientOverride 开启时有效)")]
public Gradient colorGradient;
}
[Header("References")]
[Tooltip("粒子的起点")]
[SerializeField] private Transform startPoint;
[Tooltip("粒子的终点")]
[SerializeField] private Transform endPoint;
[Tooltip("作为粒子的预制体")]
[SerializeField] private GameObject particlePrefab;
[Header("Visuals")]
[Tooltip("粒子可用的材质配置列表")]
[SerializeField] private List<MaterialConfig> particleMaterials = new List<MaterialConfig>();
[Tooltip("粒子最小缩放比例")]
[SerializeField] private float minParticleScale = 0.8f;
[Tooltip("粒子最大缩放比例")]
[SerializeField] private float maxParticleScale = 1.2f;
[Header("Settings")]
[Tooltip("性能门控:是否启用粒子生成")]
public bool enableParticles = true;
[Tooltip("粒子的物理层级 (Layer Name)")]
[SerializeField] private string physicsLayerName = "particles";
[Tooltip("粒子的渲染层级名称 (Sorting Layer)")]
[SerializeField] private string sortingLayerName = "Default";
[Tooltip("粒子的渲染层级编号 (Order in Layer)")]
[SerializeField] private int sortingOrder = 0;
[Tooltip("渲染队列偏移 (Render Queue Offset),用于解决 3D 物体与 UI 遮挡问题。数值越小越先渲染(被遮挡)")]
[SerializeField] private int renderQueueOffset = 0;
[Header("Spawn Quantity")]
[Tooltip("基础发射频率(每秒发射次数)")]
[SerializeField] private float baseEmitRate = 10f;
[Tooltip("每次发射生成的粒子数量 (Batch Size) - 增加此数值可显著增加粒子密度")]
[SerializeField] private int emitBatchSize = 1;
[Header("Spawn Randomness")]
[Tooltip("混乱度:随机时间偏移范围")]
[SerializeField] private float chaosRandomDelay = 0.05f;
[Tooltip("出生点 X 轴随机偏移范围")]
[SerializeField] private float spawnOffsetX = 0.5f;
[Tooltip("出生点 Z 轴 (深度) 随机偏移范围")]
[SerializeField] private float spawnOffsetY = 0.5f;
[Tooltip("速率系数,用于调节基础移动速度")]
[SerializeField] private float speedMultiplier = 0.1f;
[Tooltip("起点缩放抖动强度 (基于音频电平)")]
[SerializeField] private float shakeIntensity = 0.1f;
[Header("External References")]
[Tooltip("粒子生成的父物体")]
[SerializeField] private Transform particleParent;
[Tooltip("主音频源,用于同步音乐状态")]
public AudioSource musicSource;
[Tooltip("赋值对应的 BeatmapManager 以获取当前歌曲的 BPM")]
public BeatmapManager beatmapManager;
[Header("Audio Analysis")]
public bool useAudioAnalysis = true;
[Range(0, 1)] public float audioSensitivity = 0.5f;
[Tooltip("音频对发射频率的影响强度")]
[SerializeField] private float audioEmitBoost = 50f;
private float[] _audioSamples = new float[128];
private float _currentAverageVolume = 0f;
private float _emitTimer = 0f;
// 材质均衡分配逻辑
private List<MaterialConfig> _cachedEnabledConfigs = new List<MaterialConfig>();
private int _materialIndex = 0;
private IObjectPool<GameObject> _pool;
private Vector3 _originalStartScale;
private void Awake()
{
if (startPoint != null) _originalStartScale = startPoint.localScale;
_pool = new ObjectPool<GameObject>(
createFunc: () => {
GameObject go = Instantiate(particlePrefab);
go.SetActive(false);
return go;
},
actionOnGet: (obj) => {
obj.SetActive(true);
// 解决对象池拉线问题:如果球体带拖尾,获取时必须清除旧路径
var trail = obj.GetComponent<TrailRenderer>();
if (trail != null) trail.Clear();
},
actionOnRelease: (obj) => obj.SetActive(false),
actionOnDestroy: (obj) => Destroy(obj),
defaultCapacity: 30
);
}
private void Update()
{
// 执行实时音频 analysis(获取音量强度)
AnalyzeAudio();
// 持续发射逻辑
UpdateContinuousEmit();
// 强制打印当前状态
if (Time.frameCount % 60 == 0)
{
string audioStatus = musicSource != null ? (musicSource.isPlaying ? "Playing" : "Paused/Stopped") : "Null";
Debug.Log($"[GroundParticular] Vol: {_currentAverageVolume:F3}, Status: {audioStatus}");
}
// 起点缩放抖动
if (startPoint != null)
{
float shakeEffect = _currentAverageVolume * shakeIntensity;
startPoint.localScale = _originalStartScale * (1f + shakeEffect);
}
}
private void UpdateContinuousEmit()
{
// 门控检查:如果玩家禁用了粒子,则不执行生成逻辑
if (!enableParticles || musicSource == null || !musicSource.isPlaying) return;
// 计算当前瞬间的发射频率:基础频率 + 音频增益
float currentRate = baseEmitRate + (_currentAverageVolume * audioEmitBoost);
if (currentRate <= 0) return;
float interval = 1f / currentRate;
_emitTimer += Time.deltaTime;
if (_emitTimer >= interval)
{
_emitTimer = 0;
// 批量生成
for (int i = 0; i < emitBatchSize; i++)
{
// 引入混乱度:随机延迟发射
if (chaosRandomDelay > 0)
{
Invoke(nameof(DelayedEmit), Random.Range(0f, chaosRandomDelay));
}
else
{
Emit();
}
}
}
}
private void DelayedEmit()
{
Emit();
}
private void AnalyzeAudio()
{
if (musicSource == null || !musicSource.isPlaying || !useAudioAnalysis)
{
_currentAverageVolume = 0;
return;
}
// 使用 GetOutputData 获取实时波形采样数据(电平)
musicSource.GetOutputData(_audioSamples, 0);
float sum = 0;
for (int i = 0; i < _audioSamples.Length; i++)
{
sum += Mathf.Abs(_audioSamples[i]);
}
// 计算平均振幅并应用敏感度
_currentAverageVolume = (sum / _audioSamples.Length) * audioSensitivity * 2.0f;
}
/// <summary>
/// 发射一个粒子
/// </summary>
public void Emit()
{
if (startPoint == null || endPoint == null || particlePrefab == null) return;
// 1. 获取粒子
GameObject particle = _pool.Get();
// 2. 设置层级
particle.transform.SetParent(particleParent != null ? particleParent : null);
// 3. 计算缩放
float randomScale = Random.Range(minParticleScale, maxParticleScale);
float audioBoost = useAudioAnalysis ? (1f + _currentAverageVolume) : 1f;
particle.transform.localScale = particlePrefab.transform.localScale * randomScale * audioBoost;
// 4. 计算位置 (全表面随机 - XZ 平面)
// 修改为 X 和 Z 轴随机,以覆盖地面 (Ground) 的水平和深度方向
Vector3 surfaceRandomPos = new Vector3(Random.Range(-0.5f, 0.5f), 0f, Random.Range(-0.5f, 0.5f));
Vector3 finalLocalPos = surfaceRandomPos + new Vector3(
Random.Range(-spawnOffsetX, spawnOffsetX),
0f,
Random.Range(-spawnOffsetY, spawnOffsetY)
);
particle.transform.position = startPoint.TransformPoint(finalLocalPos);
particle.transform.rotation = startPoint.rotation;
// 5. 材质均衡选取逻辑
MaterialConfig? selectedConfig = GetBalancedMaterialConfig();
// 设置物理层级
int layer = LayerMask.NameToLayer(physicsLayerName);
if (layer != -1)
{
particle.layer = layer;
}
var renderer = particle.GetComponent<Renderer>();
if (renderer != null)
{
// 设置渲染层级
renderer.sortingLayerName = sortingLayerName;
renderer.sortingOrder = sortingOrder;
if (selectedConfig != null)
{
// 使用实例化材质以允许独立修改 RenderQueue
renderer.material = selectedConfig.Value.material;
if (renderQueueOffset != 0)
{
renderer.material.renderQueue = selectedConfig.Value.material.renderQueue + renderQueueOffset;
}
}
}
// 6. 开始移动
StartCoroutine(MoveRoutine(particle, finalLocalPos, selectedConfig));
}
/// <summary>
/// 轮询式均衡选取材质,确保每种材质数量大致相同
/// </summary>
private MaterialConfig? GetBalancedMaterialConfig()
{
if (particleMaterials == null || particleMaterials.Count == 0) return null;
// 过滤出所有启用的材质
_cachedEnabledConfigs.Clear();
foreach (var config in particleMaterials)
{
if (config.isEnabled && config.material != null)
_cachedEnabledConfigs.Add(config);
}
if (_cachedEnabledConfigs.Count == 0) return null;
// 轮询选取
_materialIndex = (_materialIndex + 1) % _cachedEnabledConfigs.Count;
return _cachedEnabledConfigs[_materialIndex];
}
private System.Collections.IEnumerator MoveRoutine(GameObject particle, Vector3 initialLocalPos, MaterialConfig? config)
{
if (startPoint == null || endPoint == null) yield break;
// 【关键修复】在协程开始时,缓存起点的世界坐标
Vector3 spawnWorldPos = startPoint.TransformPoint(initialLocalPos);
Vector3 currentStartPos = startPoint.position;
Vector3 currentEndPos = endPoint.position;
Vector3 moveDir = (currentEndPos - currentStartPos).normalized;
float totalDist = Vector3.Distance(currentStartPos, currentEndPos);
float traveledDist = 0f;
particle.transform.position = spawnWorldPos;
// 获取材质属性块用于动态修改颜色 (不产生材质实例 GC)
MaterialPropertyBlock propBlock = new MaterialPropertyBlock();
var renderer = particle.GetComponent<Renderer>();
while (particle != null && particle.activeInHierarchy)
{
// 如果游戏暂停,则等待直到取消暂停
if (PauseManager.Instance != null && PauseManager.Instance.IsPaused)
{
yield return new WaitUntil(() => !PauseManager.Instance.IsPaused);
}
float currentBPM = GetCurrentBPM();
float noteSpawnerSpeedMultiplier = 1.0f;
if (beatmapManager != null && beatmapManager.noteSpawner != null)
{
noteSpawnerSpeedMultiplier = beatmapManager.noteSpawner.speedMultiplier;
}
float effectiveBPM = Mathf.Max(currentBPM, 60f);
float noteSpeed = (totalDist * effectiveBPM * noteSpawnerSpeedMultiplier) / 240f;
float step = noteSpeed * speedMultiplier * Time.deltaTime;
if (step <= 0) step = 0.01f;
traveledDist += step;
particle.transform.position += moveDir * step;
// 【动态空间渐变色逻辑】
if (config != null && config.Value.useGradientOverride && renderer != null)
{
// 获取渐变的首尾颜色
Color headColor = config.Value.colorGradient.Evaluate(1f);
Color tailColor = config.Value.colorGradient.Evaluate(0f);
renderer.GetPropertyBlock(propBlock);
// 对应 Shader 中的属性
propBlock.SetColor("_HeadColor", headColor);
propBlock.SetColor("_TailColor", tailColor);
propBlock.SetVector("_MoveDir", moveDir);
renderer.SetPropertyBlock(propBlock);
}
if (traveledDist >= totalDist)
{
if (particle.activeInHierarchy) _pool.Release(particle);
yield break;
}
yield return null;
}
}
private float GetCurrentBPM()
{
if (beatmapManager != null && beatmapManager.assignedSongData != null)
{
return beatmapManager.assignedSongData.bpm;
}
return 120f;
}
private void OnDrawGizmosSelected()
{
if (startPoint == null) return;
// 绘制起点的发射区域(完全匹配 Cube 的视觉大小)
Gizmos.matrix = startPoint.localToWorldMatrix;
Gizmos.color = Color.cyan;
Gizmos.DrawWireCube(Vector3.zero, Vector3.one);
if (endPoint != null)
{
Gizmos.matrix = Matrix4x4.identity; // 切回世界坐标画线
Gizmos.color = Color.red;
Gizmos.DrawLine(startPoint.position, endPoint.position);
Gizmos.matrix = endPoint.localToWorldMatrix;
Gizmos.DrawWireCube(Vector3.zero, Vector3.one);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ee19a6040a62c2248b337879e955533a
@@ -304,36 +304,7 @@ public class settlementController : MonoBehaviour
{
int pm = sm != null ? sm.allSum_pmScore : 0;
int idol = sm != null ? sm.allSum_idolScore : 0;
int total = pm + idol;
// Documentation text normalized.
thisSong_so.UpdateDifficultyRecord(diff, pm, idol);
// Documentation text normalized.
thisSong_so.UpdateChartScore(diff, total);
// Documentation text normalized.
if (thisSong_so.chartFiles != null)
{
var entry = thisSong_so.chartFiles.Find(e => e != null && e.difficulty == diff);
if (entry != null)
{
// Documentation text normalized.
float newProgress = Mathf.Clamp01((float)total / (float)maxScore_sum);
if (newProgress > entry.levelProgressForThisDifficulty)
{
entry.levelProgressForThisDifficulty = newProgress;
}
if (thisSong_so.thisLevel_selectedDifficultyID == diff)
{
thisSong_so.current_levelProgress = Mathf.Max(thisSong_so.current_levelProgress, entry.levelProgressForThisDifficulty);
}
}
}
// --- 更新:保存到持久化存储(Build包必需) ---
thisSong_so.SavePersistent();
thisSong_so.ApplySettlementResult(diff, pm, idol, maxScore_sum);
}
}
@@ -826,4 +797,3 @@ public class settlementController : MonoBehaviour
}
}
}
@@ -1,4 +1,4 @@
using UnityEngine;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Collections.Generic;
@@ -71,6 +71,25 @@ public class teamUIController : MonoBehaviour
public GameObject ally04_object;
public GameObject ally05_object;
[Header("Victory Animation")]
[Tooltip("The object to move when all enemies are defeated")]
public GameObject victoryMoveObject;
[Tooltip("Target X position (AnchoredPosition X for UI, World X for others)")]
public float victoryTargetX;
[Tooltip("Movement curve (Time 0->1, Value 0->1 recommended)")]
public AnimationCurve victoryMoveCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
[Tooltip("Duration of the movement in seconds")]
public float victoryMoveDuration = 2f;
[Tooltip("Delay before the victory animation starts (seconds)")]
public float victoryAnimationDelay = 3f;
[Tooltip("The image to fade saturation on victory")]
public Image victorySaturationImage;
[Tooltip("Duration for the saturation fade (seconds)")]
public float victorySaturationDuration = 2f;
private Coroutine _victoryMoveCoroutine;
private Coroutine _victorySaturationCoroutine;
[Header("Ally Statistics (Real-time)")]
[Tooltip("Total damage dealt to enemies by each of the 5 ally slots.")]
public float[] totalDamageDealt = new float[5];
@@ -997,6 +1016,18 @@ public class teamUIController : MonoBehaviour
// Start is called once before the first execution of Update after the MonoBehaviour is created
void Start()
{
// Saturation material assignment moved to PlayVictoryAnimation as requested
/*
// Initialize saturation image if assigned
if (victorySaturationImage != null && grayScaleMaterial != null)
{
// Assign the grayscale material instance to the image
victorySaturationImage.material = new Material(grayScaleMaterial);
// Use _Saturation property ID
victorySaturationImage.material.SetFloat(SaturationID, 1f);
}
*/
// Load ally slot IDs from PlayerPrefs
allySlotIds[0] = PlayerPrefs.GetInt("selected_heroSlot01_heroID", 0);
allySlotIds[1] = PlayerPrefs.GetInt("selected_heroSlot02_heroID", 0);
@@ -1371,7 +1402,7 @@ public class teamUIController : MonoBehaviour
// All enemies processed: show empty/finished state but keep UI visible
if (objectFather_enemy != null) objectFather_enemy.SetActive(true);
if (currentEnemy_nameText != null) currentEnemy_nameText.text = "(All Defeated)";
if (currentEnemy_characterImage != null) currentEnemy_characterImage.sprite = null;
// if (currentEnemy_characterImage != null) currentEnemy_characterImage.sprite = null;
if (currentEnemy_healthImage != null) currentEnemy_healthImage.fillAmount = 0f;
if (currentEnemy_fadehealthImage != null) currentEnemy_fadehealthImage.fillAmount = 0f;
if (currentEnemy_healthRate != null) currentEnemy_healthRate.text = "0/0";
@@ -1379,8 +1410,11 @@ public class teamUIController : MonoBehaviour
if (currentEnemy_fademanaImage != null) currentEnemy_fademanaImage.fillAmount = 0f;
if (currentEnemy_typeText != null) currentEnemy_typeText.text = "(普通敌人)";
UpdateEnemyListText();
// Documentation text normalized.
// Update total health bar to 0
UpdateAllEnemyTotalHealthUIImmediate();
// Trigger victory animation
PlayVictoryAnimation();
return;
}
@@ -1451,14 +1485,14 @@ public class teamUIController : MonoBehaviour
{
if (enemyList_rateText == null) return;
// 使用实际识别出的敌人数组长度作为总数
// Use actual recognized enemy count
int total = recognizedEnemySOs != null ? recognizedEnemySOs.Length : 0;
int displayIndex = 0;
if (total > 0)
{
// enemyCurrentCount 是从 0 开始的索引
// 确保显示编号在 1 total 之间
// enemyCurrentCount is 0-based index
// ensure display is 1 to total
displayIndex = Mathf.Clamp(enemyCurrentCount + 1, 1, total);
}
@@ -1467,6 +1501,96 @@ public class teamUIController : MonoBehaviour
enemyList_rateText.text = _sb.ToString();
}
private void PlayVictoryAnimation()
{
// 1. Victory Movement (Delayed)
if (victoryMoveObject != null)
{
if (_victoryMoveCoroutine != null) StopCoroutine(_victoryMoveCoroutine);
_victoryMoveCoroutine = StartCoroutine(VictoryMoveRoutine());
}
// 2. Victory Saturation Set (Immediate)
if (victorySaturationImage != null)
{
if (_victorySaturationCoroutine != null) StopCoroutine(_victorySaturationCoroutine);
// Assign material only when victory occurs
if (grayScaleMaterial != null)
{
victorySaturationImage.material = new Material(grayScaleMaterial);
}
// Immediately set saturation to 0 (Grayscale)
victorySaturationImage.material.SetFloat(SaturationID, 0f);
}
}
private IEnumerator VictoryMoveRoutine()
{
if (victoryMoveObject == null) yield break;
// Wait for the specified delay before starting movement
if (victoryAnimationDelay > 0f)
yield return new WaitForSeconds(victoryAnimationDelay);
float timer = 0f;
float duration = victoryMoveDuration > 0 ? victoryMoveDuration : 2f;
RectTransform rt = victoryMoveObject.GetComponent<RectTransform>();
Transform tf = victoryMoveObject.transform;
float startX = 0f;
bool isUI = (rt != null);
if (isUI) startX = rt.anchoredPosition.x;
else startX = tf.position.x;
// If curve is not set, default to EaseInOut
if (victoryMoveCurve == null || victoryMoveCurve.length == 0)
victoryMoveCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
while (timer < duration)
{
timer += Time.deltaTime;
float progress = Mathf.Clamp01(timer / duration);
float curveValue = victoryMoveCurve.Evaluate(progress);
float newX = Mathf.LerpUnclamped(startX, victoryTargetX, curveValue);
if (isUI)
{
Vector2 pos = rt.anchoredPosition;
pos.x = newX;
rt.anchoredPosition = pos;
}
else
{
Vector3 pos = tf.position;
pos.x = newX;
tf.position = pos;
}
yield return null;
}
// Final set
if (isUI)
{
Vector2 pos = rt.anchoredPosition;
pos.x = victoryTargetX;
rt.anchoredPosition = pos;
}
else
{
Vector3 pos = tf.position;
pos.x = victoryTargetX;
tf.position = pos;
}
}
// VictorySaturationRoutine removed as requested (immediate set)
private void OnEnemyDiedHandler(EnemyCombatant e)
{
// advance to next enemy after death