超大量的更新 修复很多问题,gameplay特效初步

This commit is contained in:
2026-02-14 23:46:20 +08:00
parent ef8eb67259
commit 3a7a0b4669
360 changed files with 85670 additions and 4144 deletions
@@ -1,6 +1,6 @@
using UnityEngine;
using UnityEngine;
using TMPro;
using System.Collections; // ֶ Coroutine ֧
using System.Collections; // Documentation text normalized.
using System.Collections.Generic; // For List
public class AnimationController : MonoBehaviour
@@ -9,11 +9,11 @@ public class AnimationController : MonoBehaviour
public static AnimationController Global;
public GameObject redEffect; // ɫ򶯻
public GameObject greenEffect; // ɫ򶯻
public GameObject yellowEffect; // ɫ򶯻
public GameObject purpleEffect; // ɫ򶯻
public GameObject blueEffect; // ɫ򶯻
public GameObject redEffect; // Documentation text normalized.
public GameObject greenEffect; // Documentation text normalized.
public GameObject yellowEffect; // Documentation text normalized.
public GameObject purpleEffect; // Documentation text normalized.
public GameObject blueEffect; // Documentation text normalized.
private Animator redAnimator;
private Animator greenAnimator;
@@ -25,13 +25,13 @@ public class AnimationController : MonoBehaviour
public GameObject hit_particular_object;
public GameObject hit_ring_object;
[Header("ɫ (Hex Color Codes)")]
[Header("Inspector")]
public string redColorHex = "#FF9390";
public string greenColorHex = "#38F6CA";
public string yellowColorHex = "#FFE1A2";
public string purpleColorHex = "#F083E4";
public string blueColorHex = "#7AF9FF";
[Header("Hold ")]
[Header("Inspector")]
public float holdParticleInterval = 0.2f;
private Coroutine holdParticleCoroutine;
@@ -45,13 +45,13 @@ public class AnimationController : MonoBehaviour
// Track active particle instances for immediate cleanup
private List<GameObject> activeParticles = new List<GameObject>();
[Header("жƮprefabs")]
[Header("Inspector")]
public GameObject perfect_judge_prefab;
public GameObject great_judge_prefab;
public GameObject good_judge_prefab;
public GameObject miss_judge_prefab;
[Header("ж (Legacy Text)")]
// Inspector нӦ UI Text
[Header("Inspector")]
// Documentation text normalized.
public TextMeshProUGUI red_track_judgementText;
public TextMeshProUGUI green_track_judgementText;
public TextMeshProUGUI yellow_track_judgementText;
@@ -223,11 +223,11 @@ public class AnimationController : MonoBehaviour
case "blue": if (blueEffect != null) { spawnPos = blueEffect.transform.position; parentTransform = blueEffect.transform; } break;
}
// ȡɫ
// Documentation text normalized.
Color trackColor;
string hexCode = GetHexCodeForColor(color);
// Խ 16 ɫʧܣʹðɫΪĬֵ
// Documentation text normalized.
if (!ColorUtility.TryParseHtmlString(hexCode, out trackColor))
{
trackColor = Color.white;
@@ -351,7 +351,7 @@ public class AnimationController : MonoBehaviour
}
}
// ڸɫƻȡӦ 16 ƴ
// Documentation text normalized.
private string GetHexCodeForColor(string color)
{
switch (color)
@@ -361,7 +361,7 @@ public class AnimationController : MonoBehaviour
case "yellow": return yellowColorHex;
case "purple": return purpleColorHex;
case "blue": return blueColorHex;
default: return "#FFFFFF"; // ĬϷذɫ
default: return "#FFFFFF"; // Documentation text normalized.
}
}
@@ -408,14 +408,14 @@ public class AnimationController : MonoBehaviour
}
}
/// <summary>
/// ݴɫƵλӦ UI Text ȡ
/// Documentation text normalized.
/// </summary>
private void SpawnJudgePrefabByTrackText(string color)
{
TextMeshProUGUI targetText = null;
Transform spawnPoint = null;
// 1. ƥӦıλ
// Documentation text normalized.
switch (color)
{
case "red": targetText = red_track_judgementText; spawnPoint = redEffect != null ? redEffect.transform : null; break;
@@ -426,11 +426,11 @@ public class AnimationController : MonoBehaviour
}
if (targetText != null && spawnPoint != null)
{
// 打印调试信息,确认判定到底读取到的是什么文字
if (JudgeManager.IsDebugEnabled) Debug.Log($"判定 {color} 当前读取到的文字为: [{targetText.text}]");
// Documentation text normalized.
if (JudgeManager.IsDebugEnabled) Debug.Log($"判定 {color} 当前读取到的文字为 [{targetText.text}]");
}
// 2. ҵııΪգִ߼
// Documentation text normalized.
if (targetText != null && spawnPoint != null && !string.IsNullOrEmpty(targetText.text))
{
DoExecutePrefabSpawn(targetText.text, spawnPoint);
@@ -438,13 +438,13 @@ public class AnimationController : MonoBehaviour
}
/// <summary>
/// ִʵĺ
/// Documentation text normalized.
/// </summary>
private void DoExecutePrefabSpawn(string judgeResult, Transform spawnTransform)
{
GameObject prefabToUse = null;
// ַƥȷ InputManager дַһ£
// Documentation text normalized.
if (judgeResult.Contains("Perfect")) prefabToUse = perfect_judge_prefab;
else if (judgeResult.Contains("Great")) prefabToUse = great_judge_prefab;
else if (judgeResult.Contains("Good")) prefabToUse = good_judge_prefab;
@@ -452,10 +452,10 @@ public class AnimationController : MonoBehaviour
if (prefabToUse != null)
{
// ڶӦЧж Prefab
// Documentation text normalized.
GameObject instance = Instantiate(prefabToUse, spawnTransform.position, Quaternion.identity);
// Զ٣ֹѻ
// Documentation text normalized.
Destroy(instance, 1.0f);
}
}
@@ -1,37 +1,37 @@
using UnityEngine;
using UnityEngine;
using System.Collections;
public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
{
public static Animation_GenerateJudgementSituationPrefab Instance;
[Header("λ ()")]
[Header("Inspector")]
public GameObject redEffect;
public GameObject greenEffect;
public GameObject yellowEffect;
public GameObject purpleEffect;
public GameObject blueEffect;
[Header("жƮ Prefabs")]
[Header("Inspector")]
public GameObject perfect_judge_prefab;
public GameObject great_judge_prefab;
public GameObject good_judge_prefab;
public GameObject miss_judge_prefab;
[Header("")]
[Header("Inspector")]
public float baseScale = 1.0f;
public bool useRandomScale = true;
public float minScaleMultiplier = 0.8f;
public float maxScaleMultiplier = 1.2f;
[Header("Ծ")]
public float jumpForce = 5.0f; // ϵijʼٶ
public float gravity = -12.0f; //
[Header("Inspector")]
public float jumpForce = 5.0f; // Documentation text normalized.
public float gravity = -16f; // Documentation text normalized.
[Header("ʱ͸ȿ ()")]
public float fadeInTime = 0.1f; // ɺɽ (0->1)
public float fadeOutStartTime = 0.6f; // ɺڼ뿪ʼ
public float fadeOutDuration = 0.3f; // ʱ
[Header("Inspector")]
public float fadeInTime = 0.1f; // Documentation text normalized.
public float fadeOutStartTime = 0.6f; // Documentation text normalized.
public float fadeOutDuration = 0.35f; // Documentation text normalized.
// Cache spawn transforms to avoid accessing destroyed GameObject references.
private Transform redSpawn;
@@ -48,8 +48,8 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
if (Instance == null)
{
Instance = this;
// עһδʱ¼
// DontDestroyOnLoad(gameObject); // Ƴ¼ʱ
// Documentation text normalized.
// Documentation text normalized.
}
else if (Instance != this)
{
@@ -150,12 +150,12 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
GameObject instance = Instantiate(prefabToUse, spawnPoint);
instance.transform.localPosition = Vector3.zero;
//
// Documentation text normalized.
float finalScale = baseScale;
if (useRandomScale) finalScale *= Random.Range(minScaleMultiplier, maxScaleMultiplier);
instance.transform.localScale = new Vector3(finalScale, finalScale, 1f);
// ˶߼
// Documentation text normalized.
StartCoroutine(AnimateSprite(instance));
}
@@ -168,10 +168,10 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
float vVelocity = jumpForce;
Vector3 currentLocalPos = Vector3.zero;
// ڣʼʱ + ʱ
// Documentation text normalized.
float totalLifeTime = fadeOutStartTime + fadeOutDuration;
// ʼ͸
// Documentation text normalized.
if (sr != null)
{
Color c = sr.color;
@@ -179,7 +179,7 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
sr.color = c;
}
// ֻҪûͳִ
// Documentation text normalized.
while (elapsed < totalLifeTime)
{
if (obj == null) yield break;
@@ -187,28 +187,28 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
float dt = Time.deltaTime;
elapsed += dt;
// --- 1. λ ---
// Documentation text normalized.
vVelocity += gravity * dt;
currentLocalPos.y += vVelocity * dt;
obj.transform.localPosition = currentLocalPos;
// --- 2. ͸߼ () ---
// Documentation text normalized.
if (sr != null)
{
float alpha;
// Խ׶Σǰʱ < fadeInTime
// Documentation text normalized.
if (elapsed < fadeInTime)
{
alpha = Mathf.InverseLerp(0f, fadeInTime, elapsed);
}
// ׶Σǰʱ > fadeOutStartTime
// Documentation text normalized.
else if (elapsed > fadeOutStartTime)
{
// fadeOutStartTime totalLifeTime ֮ 1 䵽 0
// Documentation text normalized.
alpha = Mathf.InverseLerp(totalLifeTime, fadeOutStartTime, elapsed);
}
// мȫʾ׶
// Documentation text normalized.
else
{
alpha = 1f;
@@ -222,7 +222,7 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
yield return null;
}
// --- ȷһ֡ȫ͸ ---
// Documentation text normalized.
if (obj != null && sr != null)
{
Color c = sr.color;
@@ -230,8 +230,7 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
sr.color = c;
}
// ȴȾɣֹɾ
yield return new WaitForEndOfFrame();
// Documentation text normalized.
if (obj != null) Destroy(obj);
}
@@ -240,8 +239,7 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
{
if (string.IsNullOrEmpty(color)) return null;
// ÿεʱǷҪ»
CacheSpawnPointsIfNeeded();
// Documentation text normalized.
Transform point = null;
switch (color.ToLower())
@@ -287,4 +285,4 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
return prefab;
}
}
}
+21 -21
View File
@@ -1,18 +1,18 @@
using System;
using System;
using UnityEngine;
[Serializable]
public class NoteData
{
public int trackIndex; // 轨道索引
public float time; // 音符出现时间(秒)
public string color; // 音符颜色(如 "red", "blue"
public string type; // 音符类型("tap" = 单击,"hold" = 长按)
public float length; // 音符长度(仅用于长按音符,单位:秒)
public int trackIndex; // Documentation text normalized.
public float time; // Documentation text normalized.
public string color; // Documentation text normalized.
public string type; // Documentation text normalized.
public float length; // Documentation text normalized.
// New fields for judgement recording
// judgeOffsetMs: signed offset in milliseconds recorded at judgement time
// positive = early (玩家按在目标时间之前), negative = late (玩家按在目标时间之后)
// Documentation text normalized.
// For tap notes this stores the single judgement offset. For hold notes, start judgement stored in judgeOffsetMs,
// and end judgement stored in judgeOffsetMsEnd. Misses do not write offsets (left as NaN).
public float judgeOffsetMs = float.NaN;
@@ -26,27 +26,27 @@ public class NoteData
[Serializable]
public class TrackData
{
public string color; // 轨道颜色
public string color; // Documentation text normalized.
}
[Serializable]
public class Beatmap
{
public string title; // 歌曲名
public string composer; // 作曲家
public string illustrator; // 画师
public string charter; // 谱师
public string beatmapId; // 谱面自定编号
public float duration; // 歌曲时长(秒)
public string title; // Documentation text normalized.
public string composer; // Documentation text normalized.
public string illustrator; // Documentation text normalized.
public string charter; // Documentation text normalized.
public string beatmapId; // Documentation text normalized.
public float duration; // Documentation text normalized.
public float bpm; // BPM
public int difficulty; // 难度(数字)
public string difficultyName;// 难度代号(如 "Easy", "Hard", "Expert"
public string createdDate; // 谱面创建日期(字符串格式:yyyy-MM-dd)
public string musicFile; // 音乐文件路径
public string backgroundFile;// 曲绘文件路径
public int difficulty; // Documentation text normalized.
public string difficultyName;// Documentation text normalized.
public string createdDate; // Documentation text normalized.
public string musicFile; // Documentation text normalized.
public string backgroundFile;// Documentation text normalized.
public TrackData[] tracks; // 轨道信息(每个轨道的颜色)
public NoteData[] notes; // 音符列表
public TrackData[] tracks; // Documentation text normalized.
public NoteData[] notes; // Documentation text normalized.
public bool is_official = true;
}
@@ -36,15 +36,15 @@ public class BeatmapManager : MonoBehaviour
Debug.LogWarning($"BeatmapManager.AcceptSongData: accepted song={(song==null?"NULL":song.songName)}, difficulty={difficulty}");
}
public Beatmap beatmap; // ǰ
public Beatmap beatmap; // Documentation text normalized.
//
// Documentation text normalized.
public NoteSpawner noteSpawner;
// teamUIController ãֱקֵ
// Documentation text normalized.
public teamUIController uiController;
public Image bgSpriteImage; // ͼƬ
public Image bgSpriteImage; // Documentation text normalized.
public GameObject bgSpriteObject;
public Image startCanvas_image;
@@ -82,7 +82,7 @@ public class BeatmapManager : MonoBehaviour
public float imMultiplier = 3f;
[Header("HP Calculation Settings")]
[Tooltip("Scales the note amount to enemy HP. Example: NoteAmount * MaxMultiplier * BaseScale TotalHP")]
[Tooltip("Documentation text normalized.")]
public float baseHpUnitScale = 1f;
// Total chart score and per-note score fields
@@ -90,23 +90,23 @@ public class BeatmapManager : MonoBehaviour
public int perNoteScore;
public int leftoverScore;
// JSON ļ
// Documentation text normalized.
public void LoadBeatmap(string fileName)
{
string path = Application.streamingAssetsPath + "/" + fileName;
if (File.Exists(path))
{
string json = File.ReadAllText(path);
Debug.Log(" JSON Ѷȡ: " + path);
Debug.Log("读取 JSON 路径: " + path);
ProcessJsonAndLoad(json);
}
else
{
Debug.LogError("ļڣ" + path);
Debug.LogError("文件未找到: " + path);
}
}
// ԭʼ JSON ַ·ȶȡ
// Documentation text normalized.
public void LoadBeatmapFromJsonString(string json)
{
if (string.IsNullOrEmpty(json))
@@ -117,7 +117,7 @@ public class BeatmapManager : MonoBehaviour
ProcessJsonAndLoad(json);
}
// JSON NoteSpawner test mode ӳٿʼ
// Documentation text normalized.
public bool ParseJsonOnly(string json)
{
if (string.IsNullOrEmpty(json))
@@ -134,13 +134,13 @@ public class BeatmapManager : MonoBehaviour
}
catch (System.Exception ex)
{
Debug.LogError($"ParseJsonOnly Beatmap ʧ: {ex}");
Debug.LogError($"ParseJsonOnly 解析 Beatmap 失败: {ex}");
return false;
}
if (parsed == null)
{
Debug.LogError("ParseJsonOnly: Beatmap null");
Debug.LogError("ParseJsonOnly: 解析 Beatmap 结果为 null");
return false;
}
@@ -201,14 +201,14 @@ public class BeatmapManager : MonoBehaviour
}
// Calculate per-note and leftover scores
CalculateNoteScores();
CalculateNoteScores(parsed);
// NOTE: Do NOT modify parsed.notes here. globalDelaySeconds will be applied to audio playback instead.
beatmap = parsed;
Debug.Log("ParseJsonOnly: parsed beatmap " + beatmap.title);
// ͳһĵ˴߼ HP ã
// Documentation text normalized.
SetupEnemiesAndHP();
ApplyTrackScoreCaps(beatmap);
ApplyTrackScoreCaps(beatmap);
@@ -216,17 +216,19 @@ public class BeatmapManager : MonoBehaviour
return true;
}
// ֱӴѾ Beatmapݾɵã
// Documentation text normalized.
public void LoadBeatmap(Beatmap loadedBeatmap)
{
beatmap = loadedBeatmap;
Debug.Log("Ѽأ Beatmap 󣩣" + (beatmap != null ? beatmap.title : "null"));
parsedNoteAmount = (beatmap != null && beatmap.notes != null) ? beatmap.notes.Length : 0;
CalculateNoteScores(beatmap);
Debug.Log("谱面加载完成,当前 Beatmap 标题: " + (beatmap != null ? beatmap.title : "null"));
// No globalDelaySeconds available in this path. Call NoteSpawner directly.
noteSpawner.LoadBeatmap(beatmap);
ApplyTrackScoreCaps(beatmap);
}
// ȡصǰã
// Documentation text normalized.
public void LoadBeatmapFromFile()
{
string path = Application.streamingAssetsPath + "/Emilia_demo.json";
@@ -237,11 +239,11 @@ public class BeatmapManager : MonoBehaviour
}
else
{
Debug.LogError("ļδҵ");
Debug.LogError("默认谱面文件未找到");
}
}
// TextAsset parseOnly ǷֻǽֱӼ
// Documentation text normalized.
public bool LoadBeatmapFromTextAsset(TextAsset chartAsset, bool parseOnly = false)
{
if (chartAsset == null)
@@ -265,7 +267,7 @@ public class BeatmapManager : MonoBehaviour
}
}
// SongData chart TextAsset
// Documentation text normalized.
public bool LoadBeatmapFromSongData(SongData song, int difficulty, bool parseOnly = false)
{
if (song == null)
@@ -283,18 +285,27 @@ public class BeatmapManager : MonoBehaviour
return LoadBeatmapFromTextAsset(ta, parseOnly);
}
// Start() гʼ
// Documentation text normalized.
void Start()
{
// һݹ SongDataͨ BeatmapManager.pendingSongData
// 如果 assignedSongData 为空,尝试从 SongDataHolder 获取,作为最后的兜底
if (pendingSongData == null && assignedSongData == null)
{
assignedSongData = SongDataHolder.SelectedSongData;
Debug.Log($"[BeatmapManager] No pending song, fallback to SongDataHolder: {(assignedSongData != null ? assignedSongData.songName : "null")}");
}
// 无论如何,在加载谱面前先同步敌人编队到 teamUIController
SyncEnemyListToUI();
if (pendingSongData != null)
{
Debug.LogWarning($"BeatmapManager.Start: pendingSongData detected: {pendingSongData.songName}, difficulty={pendingDifficulty}");
// pending ʾ Inspector ֶΣڼ
// Documentation text normalized.
assignedSongData = pendingSongData;
assignedDifficulty = pendingDifficulty;
// Խֻ
// Documentation text normalized.
bool ok = LoadBeatmapFromSongData(assignedSongData, assignedDifficulty, true);
if (!ok) Debug.LogWarning("BeatmapManager.Start: LoadBeatmapFromSongData failed");
else
@@ -389,20 +400,20 @@ public class BeatmapManager : MonoBehaviour
pendingDifficulty = -1;
}
// ڲģʽԶ
// Documentation text normalized.
if (GameConfig.testMode)
{
Debug.Log("Test mode active: skipping automatic beatmap load in BeatmapManager.Start");
}
else
{
// ԭĬϼصĵעͣΪҪʱֶָ
// LoadBeatmap("Emilia_demo.json"); // زʵ
// Documentation text normalized.
// Documentation text normalized.
Debug.LogWarning("Default automatic demo beatmap load is commented out to prioritize SO-provided charts.");
}
}
// ȡǰѶȳ
// Documentation text normalized.
private float GetCurrentDifficultyMultiplier()
{
if (parsedDifficulty == 1) return ezMultiplier;
@@ -412,7 +423,26 @@ public class BeatmapManager : MonoBehaviour
return 1f;
}
// ͳһ ID ȡ͸ UI Լ HP ߼
private void SyncEnemyListToUI()
{
if (uiController == null) return;
List<int> enemyIds = new List<int>();
// 优先从 SO 读取
if (assignedSongData != null && assignedSongData.enemyList != null && assignedSongData.enemyList.Count > 0)
{
enemyIds = new List<int>(assignedSongData.enemyList);
}
// 确保 5 个槽位:截断或补 0
while (enemyIds.Count < 5) enemyIds.Add(0);
if (enemyIds.Count > 5) enemyIds = enemyIds.GetRange(0, 5);
uiController.enemySlotIds = enemyIds;
Debug.Log($"[BeatmapManager] SyncEnemyListToUI: {string.Join(",", enemyIds)}");
}
private void SetupEnemiesAndHP()
{
if (uiController == null)
@@ -421,9 +451,17 @@ public class BeatmapManager : MonoBehaviour
return;
}
if (!parsedEnemyListIsEmpty && parsedColorSegments != null && parsedColorSegments.Length > 0)
List<int> enemyIds = new List<int>();
// 优先使用 SongData SO 中配置的敌人列表
if (assignedSongData != null && assignedSongData.enemyList != null && assignedSongData.enemyList.Count > 0)
{
enemyIds = new List<int>(assignedSongData.enemyList);
Debug.Log($"[BeatmapManager] Using enemyList from SongData SO: {string.Join(",", enemyIds)}");
}
// 如果 SO 中没有,则尝试从谱面 JSON 解析
else if (!parsedEnemyListIsEmpty && parsedColorSegments != null && parsedColorSegments.Length > 0)
{
List<int> enemyIds = new List<int>();
foreach (var segment in parsedColorSegments)
{
Debug.Log($"Processing colorSegment: enemyID='{segment.enemyID}', percentage={segment.percentage}");
@@ -435,33 +473,29 @@ public class BeatmapManager : MonoBehaviour
}
enemyIds.Add(enemyId);
}
while (enemyIds.Count < 5) enemyIds.Add(0);
Debug.Log($"Assigning enemySlotIds to teamUIController: {string.Join(",", enemyIds)}");
uiController.enemySlotIds = enemyIds;
uiController.PopulateEnemySOsFromIds();
if (parsedNoteAmount > 0)
{
float currentMultiplier = GetCurrentDifficultyMultiplier();
float totalCalculatedHP = parsedNoteAmount * currentMultiplier * baseHpUnitScale;
int activeEnemyCount = enemyIds.FindAll(id => id != 0).Count;
if (activeEnemyCount == 0) activeEnemyCount = 1;
int individualMaxHP = Mathf.RoundToInt(totalCalculatedHP / activeEnemyCount);
Debug.Log($"[BeatmapManager] HP Calc -> Notes: {parsedNoteAmount}, Multiplier: {currentMultiplier}, Total: {totalCalculatedHP:F0}, ActiveEnemies: {activeEnemyCount}, Per Enemy: {individualMaxHP}");
uiController.ApplyCalculatedEnemyHP(individualMaxHP);
}
Debug.Log($"[BeatmapManager] Using enemyList from Beatmap JSON: {string.Join(",", enemyIds)}");
}
else
// 确保列表始终为 5 个槽位
while (enemyIds.Count < 5) enemyIds.Add(0);
if (enemyIds.Count > 5) enemyIds = enemyIds.GetRange(0, 5);
uiController.enemySlotIds = enemyIds;
uiController.PopulateEnemySOsFromIds();
if (parsedNoteAmount > 0)
{
Debug.Log("Parsed enemy list is empty, clearing UI.");
uiController.enemySlotIds = new List<int> { 0, 0, 0, 0, 0 };
uiController.PopulateEnemySOsFromIds();
float currentMultiplier = GetCurrentDifficultyMultiplier();
float totalCalculatedHP = parsedNoteAmount * currentMultiplier * baseHpUnitScale;
int activeEnemyCount = enemyIds.FindAll(id => id != 0).Count;
if (activeEnemyCount == 0) activeEnemyCount = 1;
int individualMaxHP = Mathf.RoundToInt(totalCalculatedHP / activeEnemyCount);
Debug.Log($"[BeatmapManager] HP Calc -> Notes: {parsedNoteAmount}, Multiplier: {currentMultiplier}, Total: {totalCalculatedHP:F0}, ActiveEnemies: {activeEnemyCount}, Per Enemy: {individualMaxHP}");
uiController.ApplyCalculatedEnemyHP(individualMaxHP);
}
}
@@ -481,13 +515,13 @@ public class BeatmapManager : MonoBehaviour
}
catch (System.Exception ex)
{
Debug.LogError($" Beatmap ʧ: {ex}");
Debug.LogError($"解析 Beatmap 失败: {ex}");
return;
}
if (parsed == null)
{
Debug.LogError(" Beatmap null");
Debug.LogError("解析 Beatmap 结果为 null");
return;
}
@@ -543,10 +577,10 @@ public class BeatmapManager : MonoBehaviour
// Ensure amount and color statistics are populated even if missing from JSON
ValidateAndPopulateStatistics(parsed);
CalculateNoteScores();
CalculateNoteScores(parsed);
beatmap = parsed;
Debug.Log("Ѽأ" + beatmap.title);
Debug.Log("谱面加载完成: " + beatmap.title);
if (noteSpawner != null) noteSpawner.LoadBeatmap(beatmap);
else Debug.LogError("NoteSpawner is null in BeatmapManager.");
@@ -554,13 +588,31 @@ public class BeatmapManager : MonoBehaviour
SetupEnemiesAndHP();
}
private void CalculateNoteScores()
private int GetEffectiveNoteCount(Beatmap sourceBeatmap = null)
{
if (parsedNoteAmount > 0)
int noteCount = 0;
var src = sourceBeatmap != null ? sourceBeatmap : beatmap;
if (src != null && src.notes != null)
noteCount = src.notes.Length;
if (noteCount <= 0)
noteCount = Mathf.Max(0, parsedNoteAmount);
if (noteCount > 0)
parsedNoteAmount = noteCount;
return noteCount;
}
private void CalculateNoteScores(Beatmap sourceBeatmap = null)
{
int noteCount = GetEffectiveNoteCount(sourceBeatmap);
if (noteCount > 0)
{
perNoteScore = totalChartScore / parsedNoteAmount;
leftoverScore = totalChartScore % parsedNoteAmount;
Debug.Log($"Calculated perNoteScore: {perNoteScore}, leftoverScore: {leftoverScore}");
perNoteScore = totalChartScore / noteCount;
leftoverScore = totalChartScore % noteCount;
Debug.Log($"Calculated perNoteScore: {perNoteScore}, leftoverScore: {leftoverScore}, noteCount: {noteCount}");
}
else
{
@@ -618,9 +670,10 @@ public class BeatmapManager : MonoBehaviour
counts[idx]++;
}
int effectiveNoteCount = GetEffectiveNoteCount(parsed);
int per = perNoteScore;
if (per <= 0 && parsedNoteAmount > 0)
per = totalChartScore / parsedNoteAmount;
if (per <= 0 && effectiveNoteCount > 0)
per = totalChartScore / effectiveNoteCount;
var ui = teamUIController.Instance;
for (int i = 0; i < trackCount; i++)
+26 -1
View File
@@ -1,11 +1,36 @@
using UnityEngine;
public static class GameConfig
{
// Toggle detailed logging for debugging. Keep false in production for performance.
public static bool verboseLogs = false;
// Toggle skill effect logging.
public static bool skillDebugMode = true;
public static bool skillDebugMode = false;
// When true, gameplay will not automatically read JSON beatmaps on Start.
public static bool testMode = false;
public const string PrefKey_AutoPlayEnabled = "AutoPlayEnabled";
// When true, notes will be judged automatically as Perfect (autoplay).
public static bool autoPlayEnabled = false;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void LoadOnBoot()
{
LoadPrefs();
}
public static void LoadPrefs()
{
autoPlayEnabled = PlayerPrefs.GetInt(PrefKey_AutoPlayEnabled, 0) == 1;
}
public static void SetAutoPlayEnabled(bool enabled)
{
autoPlayEnabled = enabled;
PlayerPrefs.SetInt(PrefKey_AutoPlayEnabled, enabled ? 1 : 0);
PlayerPrefs.Save();
}
}
@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
@@ -186,7 +186,7 @@ public class GameManager : MonoBehaviour
if (musicSource == null) return;
// ✅ FIX: Force stop any existing playback before unmute
// Documentation text normalized.
try
{
musicSource.Stop();
@@ -196,7 +196,7 @@ public class GameManager : MonoBehaviour
try
{
// ✅ FIX: Ensure unmuted BEFORE attempting to play
// Documentation text normalized.
musicSource.mute = false;
Debug.Log("GameManager.PlayMusicWithDelay: musicSource unmuted");
}
@@ -251,7 +251,7 @@ public class GameManager : MonoBehaviour
{
try
{
// ✅ FIX: Double-check unmute before actual playback
// Documentation text normalized.
musicSource.mute = false;
musicSource.time = 0f;
musicSource.Play();
@@ -330,7 +330,7 @@ public class GameManager : MonoBehaviour
musicSource.mute = true;
musicSource.Play();
Debug.Log("GameManager: warmed up audio playback (muted)");
// ✅ FIX: Stop immediately after warmup to avoid interference with later playback
// Documentation text normalized.
// Wait a frame to ensure audio system processes the Play command
StartCoroutine(StopWarmupAfterFrame());
}
@@ -399,7 +399,7 @@ public class GameManager : MonoBehaviour
try
{
musicSource.Stop();
musicSource.mute = false; // ✅ Unmute immediately after warmup
musicSource.mute = false; // Documentation text normalized.
musicSource.time = 0f;
Debug.Log("GameManager: warmup completed, audio stopped and unmuted");
}
@@ -620,7 +620,7 @@ public class GameManager : MonoBehaviour
yield return null;
Debug.Log("TestMode audio loaded into AudioSource");
// ✅ FIX: Stop immediately after warmup to avoid interference with later playback
// Documentation text normalized.
StartCoroutine(StopWarmupAfterFrame());
// After loading audio, enable overlay and pause via PauseManager
+147 -9
View File
@@ -12,7 +12,6 @@ public class HoldNote : BaseNote
private string noteID = string.Empty;
private NoteSegment segment = NoteSegment.None;
private new float hitTime = 0f;
private float releaseTime = 0f;
private bool hasReleased = true;
private bool hasEnteredLine = false;
@@ -68,6 +67,7 @@ public class HoldNote : BaseNote
// Cache allies per track to avoid GameObject.Find on first judgement.
private static AllyCombatant[] allyCache;
private static BeatmapManager beatmapManagerCache;
private struct RendererMaterialCache
{
@@ -92,6 +92,40 @@ public class HoldNote : BaseNote
return allyCache[trackIndex];
}
private static BeatmapManager GetBeatmapManagerCached()
{
if (beatmapManagerCache != null) return beatmapManagerCache;
beatmapManagerCache = Object.FindAnyObjectByType<BeatmapManager>();
return beatmapManagerCache;
}
private static float JudgeToPmMultiplier(string judgeResult)
{
switch (judgeResult)
{
case "Perfect": return 1f;
case "Great": return 0.75f;
case "Good": return 0.5f;
case "Miss": return 0f;
default: return 0f;
}
}
private static int ComputePmDeltaFromJudge(string judgeResult, AllyCombatant ally)
{
int baseScore = 0;
var bmm = ally != null ? ally.bmm : null;
if (bmm == null) bmm = GetBeatmapManagerCached();
if (bmm != null) baseScore = Mathf.Max(0, bmm.perNoteScore);
if (baseScore <= 0 && ally != null) baseScore = Mathf.Max(0, ally.baseTrackScore);
if (baseScore <= 0) return 0;
float mult = JudgeToPmMultiplier(judgeResult);
return Mathf.FloorToInt(baseScore * mult);
}
private JudgeManager cachedJudgeManager;
private TrackKeyManager cachedTrackKeyManager;
@@ -362,6 +396,12 @@ public class HoldNote : BaseNote
if (now < hitTime - 0.5f) return;
}
if (GameConfig.autoPlayEnabled)
{
UpdateAutoplay(now, jm, tkm, debugEnabled);
return;
}
bool keyHeld = Input.GetKey(keyToPress);
bool keyDown = Input.GetKeyDown(keyToPress);
bool keyUp = Input.GetKeyUp(keyToPress);
@@ -491,6 +531,104 @@ public class HoldNote : BaseNote
}
}
private void UpdateAutoplay(float now, JudgeManager jm, TrackKeyManager tkm, bool debugEnabled)
{
// Keep the "hold active" state alive for middle/end segments once the start has been judged.
if (!isHoldActive && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID))
{
isHoldActive = true;
}
if (segment == NoteSegment.Start)
{
bool startResolved = jm != null && jm.IsStartJudged(noteID);
if (!startResolved && now >= hitTime)
{
// Ensure we are the front-most note in the judge queue (if any)
var headId = tkm?.GetCurrentNoteId(trackIndex);
if (headId != null && headId != queueNoteId)
{
return;
}
float pressTimeLocal = hitTime; // force Perfect regardless of frame timing
float maxWindow = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier;
if (tkm != null && !tkm.IsBestCandidate(trackIndex, queueNoteId, pressTimeLocal, maxWindow))
{
return;
}
HandleStartAutoplayPerfect(pressTimeLocal);
}
return;
}
if (segment == NoteSegment.Middle)
{
if (jm != null && jm.IsStartJudged(noteID) && isHoldActive && !jm.HasNoteReleased(noteID))
{
if (now >= hitTime && hasEnteredLine)
{
if (debugEnabled) Debug.Log($"[HoldNote.AutoPlay] Middle passed: {noteColor}");
PlayHitAnimation();
jm?.RegisterMiddlePassed(noteID);
ScheduleReturnToPool(0.2f);
isJudged = true;
}
}
return;
}
if (segment == NoteSegment.End)
{
if (jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID))
{
if (now >= scheduledEndTime)
{
// Defensive: ensure pool info exists so EvaluateHoldEnd resolves to Perfect deterministically.
if (!HoldNoteJudgePool.TryGet(noteID, out _))
{
if (noteData != null) noteData.judgeOffsetMs = 0f;
HoldNoteJudgePool.RegisterStart(noteID, hitTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData);
}
EvaluateHoldEnd(scheduledEndTime, false);
}
}
return;
}
}
private void HandleStartAutoplayPerfect(float pressTime)
{
// release from queue so following notes can become head
UnregisterQueueIfNeeded();
if (!JudgeManager.Instance.TryResolveStart(noteID))
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote.AutoPlay] START try-resolve failed: {noteColor}");
return;
}
const string result = "Perfect";
float rawOffsetMs = (hitTime - pressTime) * 1000f;
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
JudgeManager.Instance.RegisterStartJudged(noteID, true);
isHoldActive = true;
PlayHitAnimation();
AnimationController.Global?.StartHoldParticles(noteColor);
holdStartTime = pressTime;
HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData);
InputManager.Instance?.ShowJudgeResult(trackIndex, result);
teamUIController.Instance?.OnJudgeResult(result);
JudgeSoundManager.Instance?.PlayJudgeSound(result);
}
private void OnTriggerEnter2D(Collider2D collision)
{
if (!collision.CompareTag("JudgmentLine")) return;
@@ -523,8 +661,9 @@ public class HoldNote : BaseNote
return;
}
// NEW: If the key is still being held down when end segment enters judge zone, immediately judge
if (Input.GetKey(keyToPress) && JudgeManager.Instance.IsStartJudged(noteID) && !isJudged)
// If the key is still being held down when end segment enters judge zone, immediately judge.
// Autoplay must ignore physical input to guarantee Perfect at scheduledEndTime.
if (!GameConfig.autoPlayEnabled && Input.GetKey(keyToPress) && JudgeManager.Instance.IsStartJudged(noteID) && !isJudged)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] End entered while key still held: {noteColor}, calculating result immediately");
// Record release time as current time (player is still holding)
@@ -945,12 +1084,11 @@ public class HoldNote : BaseNote
try
{
var ally = GetAllyForTrackCached(TrackIndex);
if (ally != null)
{
int added = ally.AddScoreForJudge(result);
float efficiency = ally.scoreEfficiency;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency);
}
if (ally != null) ally.AddScoreForJudge(result);
int pmDelta = ComputePmDeltaFromJudge(result, ally);
float efficiency = (ally != null && !ally.IsDead) ? ally.scoreEfficiency : 0f;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, pmDelta, efficiency);
}
catch (System.Exception ex)
{
@@ -11,30 +11,36 @@ public class InputManager : MonoBehaviour
private static readonly string[] TrackColors = { "red", "green", "yellow", "purple", "blue" };
[Header("жʾTMPInspectorӣ")]
[Header("Inspector")]
public TextMeshProUGUI[] trackJudgeTexts = new TextMeshProUGUI[5];
[Header("ָʾıжıֱӦD,F,Space,J,K")]
[Header("Inspector")]
public Text[] trackKeyTexts = new Text[5];
[Header("Ƿʾж")]
[Header("Lane Background Settings")]
[Tooltip("The sprites for the 5 lanes (Red, Green, Yellow, Purple, Blue)")]
public SpriteRenderer[] laneSprites = new SpriteRenderer[5];
[Tooltip("Alpha value when pressed (0-255)")]
public float pressedAlpha = 30f;
[Header("Inspector")]
public bool showJudgeText = true;
// /Ǽɫ
[Header("ɫ")]
[Tooltip("ʱɫ")]
// Documentation text normalized.
[Header("Inspector")]
[Tooltip("Documentation text normalized.")]
public Color keyActiveColor = Color.yellow;
[Tooltip("δʱɫĬϺɫ")]
[Tooltip("Documentation text normalized.")]
public Color keyInactiveColor = Color.black;
// жӦɫ
[Header("жӦɫ")]
// Documentation text normalized.
public Color perfectColor = Color.yellow;
public Color greatColor = Color.green;
public Color goodColor = Color.cyan;
public Color missColor = Color.red;
private KeyCode[] cachedKeys = new KeyCode[5];
private bool pauseBlockedLastFrame = false;
private void Awake()
{
@@ -43,10 +49,10 @@ public class InputManager : MonoBehaviour
else
Destroy(gameObject);
// 初始化按键缓存
// Documentation text normalized.
RefreshKeyCache();
// ʼıΪǼɫ
// Documentation text normalized.
if (trackKeyTexts != null)
{
foreach (var t in trackKeyTexts)
@@ -66,12 +72,40 @@ public class InputManager : MonoBehaviour
private void Start()
{
// Ensure UI shows current bindings from KeyBindingManager/PlayerPrefs
// Ensure runtime input and UI labels both use current bindings.
RefreshKeyCache();
RefreshKeyLabels();
// Initialize lane sprites to 0 alpha
if (laneSprites != null)
{
foreach (var sprite in laneSprites)
{
if (sprite != null) SetSpriteAlpha(sprite, 0f);
}
}
}
private void SetSpriteAlpha(SpriteRenderer sprite, float alpha255)
{
if (sprite == null) return;
Color c = sprite.color;
c.a = Mathf.Clamp01(alpha255 / 255f);
sprite.color = c;
}
private void Update()
{
bool pauseBlocked = IsBlockedByPause();
if (pauseBlocked)
{
if (!pauseBlockedLastFrame)
ForceReleaseAllKeys();
pauseBlockedLastFrame = true;
return;
}
pauseBlockedLastFrame = false;
for (int i = 0; i < TrackColors.Length; i++)
{
KeyCode key = cachedKeys[i];
@@ -82,10 +116,15 @@ public class InputManager : MonoBehaviour
if (Input.GetKeyDown(key))
{
OnKeyPressed?.Invoke(key);
// JudgeManager ͳһж
JudgeManager.Instance.JudgeEarliestNote(key);
// Documentation text normalized.
// ʱöӦİıɫΪɫã
// Update lane sprite alpha
if (laneSprites != null && index >= 0 && index < laneSprites.Length)
{
SetSpriteAlpha(laneSprites[index], pressedAlpha);
}
// Documentation text normalized.
if (trackKeyTexts != null && index >= 0 && index < trackKeyTexts.Length)
{
var txt = trackKeyTexts[index];
@@ -97,7 +136,13 @@ public class InputManager : MonoBehaviour
{
OnKeyReleased?.Invoke(key);
// ɿʱָΪǼɫ
// Update lane sprite alpha
if (laneSprites != null && index >= 0 && index < laneSprites.Length)
{
SetSpriteAlpha(laneSprites[index], 0f);
}
// Documentation text normalized.
if (trackKeyTexts != null && index >= 0 && index < trackKeyTexts.Length)
{
var txt = trackKeyTexts[index];
@@ -108,6 +153,12 @@ public class InputManager : MonoBehaviour
}
}
private bool IsBlockedByPause()
{
var pm = PauseManager.Instance;
return pm != null && pm.IsPaused;
}
/// <summary>
/// Force a release event for all bound keys and reset UI indicators.
/// Useful for external "clear input" operations where the system should treat
@@ -123,6 +174,10 @@ public class InputManager : MonoBehaviour
try { OnKeyReleased?.Invoke(key); } catch { }
int index = GetIndexForColor(color);
if (laneSprites != null && index >= 0 && index < laneSprites.Length)
{
SetSpriteAlpha(laneSprites[index], 0f);
}
if (trackKeyTexts != null && index >= 0 && index < trackKeyTexts.Length)
{
var txt = trackKeyTexts[index];
@@ -134,19 +189,24 @@ public class InputManager : MonoBehaviour
// Refresh displayed labels for key bindings (called after rebind)
public void RefreshKeyLabels()
{
if (trackKeyTexts == null) return;
for (int i = 0; i < TrackColors.Length && i < trackKeyTexts.Length; i++)
if (trackKeyTexts != null)
{
var txt = trackKeyTexts[i];
if (txt == null) continue;
KeyCode k = KeyBindingManager.GetKeyForColor(TrackColors[i]);
txt.text = KeyBindingManager.GetDisplayName(k);
for (int i = 0; i < TrackColors.Length && i < trackKeyTexts.Length; i++)
{
var txt = trackKeyTexts[i];
if (txt == null) continue;
KeyCode k = KeyBindingManager.GetKeyForColor(TrackColors[i]);
txt.text = KeyBindingManager.GetDisplayName(k);
}
}
// Keep runtime input cache in sync with latest bindings even when label UI is missing.
RefreshKeyCache();
}
private int GetIndexForColor(string color)
{
// trackJudgeTexts ͬӳ
// Documentation text normalized.
switch (color)
{
case "red": return 0;
@@ -159,8 +219,7 @@ public class InputManager : MonoBehaviour
}
/// <summary>
/// ָжıɫ
/// </summary>
/// Documentation text normalized.
public void ShowJudgeResult(int trackIndex, string result)
{
if (!showJudgeText) return;
@@ -12,7 +12,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 088ed1ed6b6731f43ad3166781e3e931, type: 3}
m_Name: NoteJudgeConfig
m_EditorClassIdentifier:
perfectRange: 0.1
greatRange: 0.15
goodRange: 0.2
missRange: 0.25
perfectRange: 0.15
greatRange: 0.2
goodRange: 0.25
missRange: 0.3
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
@@ -32,20 +32,21 @@ public class JudgeManager : MonoBehaviour
// total/remaining notes tracking for end-of-song detection
private int totalNotes = 0;
private int remainingNotes = 0;
private bool settlementTriggeredThisChart = false;
// ===== ԭ =====
// Documentation text normalized.
private Dictionary<KeyCode, Queue<Note>> judgeQueues = new Dictionary<KeyCode, Queue<Note>>();
private Dictionary<string, bool> startJudgedNotes = new Dictionary<string, bool>();
private Dictionary<string, bool> releasedNotes = new Dictionary<string, bool>();
private Dictionary<string, float> noteEndTimes = new Dictionary<string, float>();
private Dictionary<string, int> middlePassedCounts = new Dictionary<string, int>();
// ===== ȫֻ״̬ =====
// Documentation text normalized.
private class HoldJudgeState
{
public bool startResolved; // Start ǷѾжɹ or ʧܣ
public bool endResolved; // End ǷѾж
public bool skillTriggered; // ǷѴ
public bool startResolved; // Documentation text normalized.
public bool endResolved; // Documentation text normalized.
public bool skillTriggered; // Documentation text normalized.
}
private Dictionary<string, HoldJudgeState> holdStates = new Dictionary<string, HoldJudgeState>();
@@ -70,6 +71,13 @@ public class JudgeManager : MonoBehaviour
/// </summary>
public void OnAllNotesJudged()
{
if (settlementTriggeredThisChart)
{
if (IsDebugEnabled) Debug.Log("[JudgeManager] OnAllNotesJudged ignored: settlement already triggered for this chart.");
return;
}
settlementTriggeredThisChart = true;
if (settlement_go == null)
{
if (IsDebugEnabled) Debug.LogError("结算页面不存在!");
@@ -157,6 +165,7 @@ public class JudgeManager : MonoBehaviour
{
totalNotes = Mathf.Max(0, total);
remainingNotes = totalNotes;
settlementTriggeredThisChart = false;
if (IsDebugEnabled) Debug.Log($"[JudgeManager] SetTotalNotes: total={totalNotes}");
}
@@ -176,7 +185,7 @@ public class JudgeManager : MonoBehaviour
}
}
// ===== жխ =====
// Documentation text normalized.
public bool TryResolveStart(string noteID)
{
var s = GetHoldState(noteID);
@@ -225,7 +234,7 @@ public class JudgeManager : MonoBehaviour
}
}
// ===== ԭнӿڣֲ䣩 =====
// Documentation text normalized.
public void RegisterScheduledEndTime(string noteID, float endTime)
{
@@ -1,15 +1,15 @@
using UnityEngine;
using UnityEngine;
public class Notes : MonoBehaviour
{
public int trackIndex; //
public float hitTime; // Ӧñеʱ
private bool canBeJudged = false; // Ƿж
private bool isHit = false; // Ƿѱ
public int trackIndex; // Documentation text normalized.
public float hitTime; // Documentation text normalized.
private bool canBeJudged = false; // Documentation text normalized.
private bool isHit = false; // Documentation text normalized.
private void Update()
{
// ֻнж󣬲Զmiss
// Documentation text normalized.
if (canBeJudged && !isHit && Time.timeSinceLevelLoad > hitTime + 0.3f)
{
JudgeMiss();
@@ -34,7 +34,7 @@ public class Notes : MonoBehaviour
public void JudgeNote()
{
if (!canBeJudged || isHit) return; // ڽжܱж
if (!canBeJudged || isHit) return; // Documentation text normalized.
float currentTime = Time.timeSinceLevelLoad;
float timeDifference = Mathf.Abs(currentTime - hitTime);
@@ -59,10 +59,10 @@ public class Notes : MonoBehaviour
private void Recycle()
{
// NotePoolȹ黹أ
// Documentation text normalized.
if (NotePool.Instance != null)
{
NotePool.Instance.ReturnNote(gameObject, "red"); // color δʱĬ redϲʱ
NotePool.Instance.ReturnNote(gameObject, "red"); // Documentation text normalized.
}
else
{
+129 -18
View File
@@ -1,4 +1,4 @@
using UnityEngine;
using UnityEngine;
public class Note : BaseNote
{
@@ -11,11 +11,11 @@ public class Note : BaseNote
private NoteData noteData;
[Header("判定配置")]
public NoteJudgeConfig judgeConfig; // 判定窗口配置,包含判定时间范围
[Header("Inspector")]
public NoteJudgeConfig judgeConfig; // Documentation text normalized.
// Cache allies per track to avoid GameObject.Find on every judge.
private static AllyCombatant[] allyCache;
private static BeatmapManager beatmapManagerCache;
// Timeout handling - force miss if note isn't judged by this time
private float missDeadlineTime = -1f;
@@ -34,6 +34,40 @@ public class Note : BaseNote
return allyCache[trackIndex];
}
private static BeatmapManager GetBeatmapManagerCached()
{
if (beatmapManagerCache != null) return beatmapManagerCache;
beatmapManagerCache = Object.FindAnyObjectByType<BeatmapManager>();
return beatmapManagerCache;
}
private static float JudgeToPmMultiplier(string judgeResult)
{
switch (judgeResult)
{
case "Perfect": return 1f;
case "Great": return 0.75f;
case "Good": return 0.5f;
case "Miss": return 0f;
default: return 0f;
}
}
private static int ComputePmDeltaFromJudge(string judgeResult, AllyCombatant ally)
{
int baseScore = 0;
var bmm = ally != null ? ally.bmm : null;
if (bmm == null) bmm = GetBeatmapManagerCached();
if (bmm != null) baseScore = Mathf.Max(0, bmm.perNoteScore);
if (baseScore <= 0 && ally != null) baseScore = Mathf.Max(0, ally.baseTrackScore);
if (baseScore <= 0) return 0;
float mult = JudgeToPmMultiplier(judgeResult);
return Mathf.FloorToInt(baseScore * mult);
}
private void Awake()
{
anim = GetComponent<AnimationController>();
@@ -63,10 +97,9 @@ public class Note : BaseNote
InputManager.OnKeyPressed += HandlePress;
// 判定配置检查
if (judgeConfig == null)
{
if (JudgeManager.IsDebugEnabled) Debug.LogError($"NoteJudgeConfig is null! 判定配置未赋值!track={trackIndex}");
if (JudgeManager.IsDebugEnabled) Debug.LogError($"NoteJudgeConfig is null! 判定配置未赋值track={trackIndex}");
}
else
{
@@ -87,6 +120,17 @@ public class Note : BaseNote
private void Update()
{
// Autoplay: automatically judge notes as Perfect without user input.
if (!isJudged && GameConfig.autoPlayEnabled && gameObject.activeSelf)
{
// Only judge once the note reaches its scheduled hit time.
if (Time.time >= hitTime)
{
TryAutoJudgePerfect();
}
return;
}
// Force miss if we've passed the deadline without being judged
if (!isJudged && Time.time > missDeadlineTime && gameObject.activeSelf)
{
@@ -95,8 +139,77 @@ public class Note : BaseNote
}
}
private void TryAutoJudgePerfect()
{
if (isJudged) return;
string myId = gameObject.GetInstanceID().ToString();
// Acquire the track lock to stay compatible with the existing single-judge-per-track invariants.
bool locked = false;
if (TrackKeyManager.Instance != null)
{
if (!TrackKeyManager.Instance.TryLockTrackForJudge(TrackIndex, myId))
return;
locked = true;
}
hasLock = locked;
float pressTime = hitTime; // force Perfect regardless of frame timing
float maxWindow = (judgeConfig?.missRange ?? 0.5f);
// If not in judge zone, still allow judgement (we're using scheduled timing).
if (TrackKeyManager.Instance != null)
{
if (!TrackKeyManager.Instance.IsBestCandidate(TrackIndex, myId, pressTime, maxWindow))
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.AutoPlay] Ignored because another note is a better candidate on track {TrackIndex}");
ReleaseTrackLock(myId);
return;
}
}
// Record Perfect stats and offsets
ScoreManager.Instance.countPerfect += 1;
if (TrackIndex >= 0 && TrackIndex < 5) ScoreManager.Instance.trackPerfectCounts[TrackIndex]++;
if (noteData != null) noteData.judgeOffsetMs = 0f;
ScoreManager.Instance.RecordOffset(0f);
const string judgeResult = "Perfect";
InputManager.Instance?.ShowJudgeResult(TrackIndex, judgeResult);
JudgeSoundManager.Instance?.PlayJudgeSound(judgeResult);
teamUIController.Instance?.OnJudgeResult(judgeResult);
if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.AutoPlay] Spawning judge prefab: color={noteColor}, result={judgeResult}");
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, judgeResult);
try
{
var ally = GetAllyForTrackCached(TrackIndex);
if (ally != null) ally.AddScoreForJudge(judgeResult);
int pmDelta = ComputePmDeltaFromJudge(judgeResult, ally);
float efficiency = (ally != null && !ally.IsDead) ? ally.scoreEfficiency : 0f;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, pmDelta, efficiency);
}
catch (System.Exception ex)
{
if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[Note.AutoPlay] Failed to add per-track score: {ex}");
}
try { SkillBuilder.Instance?.NotifyNoteHit(TrackIndex, judgeResult, SkillDefinition.NoteTypeTrigger.Tap); } catch { }
Judge();
}
private void HandlePress(KeyCode key)
{
// Autoplay ignores player input to guarantee Perfect.
if (GameConfig.autoPlayEnabled)
return;
// only proceed for matching key and not already judged
if (isJudged || key != keyToPress)
return;
@@ -224,12 +337,11 @@ public class Note : BaseNote
try
{
var ally = GetAllyForTrackCached(TrackIndex);
if (ally != null)
{
int added = ally.AddScoreForJudge(judgeResult);
float efficiency = ally.scoreEfficiency;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency);
}
if (ally != null) ally.AddScoreForJudge(judgeResult);
int pmDelta = ComputePmDeltaFromJudge(judgeResult, ally);
float efficiency = (ally != null && !ally.IsDead) ? ally.scoreEfficiency : 0f;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, pmDelta, efficiency);
}
catch (System.Exception ex)
{
@@ -305,12 +417,11 @@ public class Note : BaseNote
try
{
var ally = GetAllyForTrackCached(TrackIndex);
if (ally != null)
{
int added = ally.AddScoreForJudge("Miss");
float efficiency = ally.scoreEfficiency;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency);
}
if (ally != null) ally.AddScoreForJudge("Miss");
int pmDelta = ComputePmDeltaFromJudge("Miss", ally);
float efficiency = (ally != null && !ally.IsDead) ? ally.scoreEfficiency : 0f;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, pmDelta, efficiency);
}
catch (System.Exception ex)
{
@@ -1,12 +1,12 @@
using System.Collections;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public enum NoteColor
{
Red, // ºìÉ«Òô·û
Green, // ÂÌÉ«Òô·û
Yellow, // »ÆÉ«Òô·û
Purple, // ×ÏÉ«Òô·û
Blue // À¶É«Òô·û
Red, // Documentation text normalized.
Green, // Documentation text normalized.
Yellow, // Documentation text normalized.
Purple, // Documentation text normalized.
Blue // Documentation text normalized.
}
@@ -1,12 +1,11 @@
using System.Collections;
using System.Collections;
using UnityEngine;
public class NoteController : MonoBehaviour
{
private float speed;
private bool isMoving = true;
private bool isInJudgeZone = false; // 判定范围内
private bool isInJudgeZone = false; // Documentation text normalized.
private ParticleSystem hitEffect;
private Note linkedNote;
@@ -1,12 +1,12 @@
using UnityEngine;
using UnityEngine;
/// <summary>
/// 音符判定区间配置,支持在 Inspector 中调整
/// Documentation text normalized.
/// </summary>
[CreateAssetMenu(fileName = "NoteJudgeConfig", menuName = "Game/NoteJudgeConfig")]
public class NoteJudgeConfig : ScriptableObject
{
[Header("判定区间(单位:秒)")]
[Header("Inspector")]
[Tooltip("Perfect")]
public float perfectRange = 0.1f;
[Tooltip("Great")]
+60 -44
View File
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using UnityEngine;
using GamePlay; // for PoolItem
using System.Collections;
@@ -7,10 +7,10 @@ public class NotePool : MonoBehaviour
{
public static NotePool Instance { get; private set; }
public GameObject[] notePrefabs; // Ԥɫ
public GameObject[] holdNotePrefabs; // ƬԤ
public GameObject[] holdNoteEndPrefabs; // βרóԤѡ
public GameObject[] startNotePrefabs; // startƬԤ
public GameObject[] notePrefabs; // Documentation text normalized.
public GameObject[] holdNotePrefabs; // Documentation text normalized.
public GameObject[] holdNoteEndPrefabs; // Documentation text normalized.
public GameObject[] startNotePrefabs; // Documentation text normalized.
[Header("Pool Size")]
public int poolSize = 24;
@@ -20,20 +20,19 @@ public class NotePool : MonoBehaviour
[Tooltip("Multiplier for hold end pool size (per color).")]
public int holdEndPoolMultiplier = 8;
// ɫĿӳÿ switch
// Documentation text normalized.
private Dictionary<string, int> colorIndexMap;
private Dictionary<int, Stack<GameObject>> notePools; //
private Dictionary<int, Stack<GameObject>> holdNotePools; // Ƭζ
private Dictionary<int, Stack<GameObject>> holdNoteEndPools; // β
private Dictionary<int, Stack<GameObject>> startNotePools; // startƬζ
// ڲ㼶֯еĶ󣬱ڵԺ͹
private Dictionary<int, Stack<GameObject>> notePools; // Documentation text normalized.
private Dictionary<int, Stack<GameObject>> holdNotePools;
private Dictionary<int, Stack<GameObject>> holdNoteEndPools;
private Dictionary<int, Stack<GameObject>> startNotePools;
// Documentation text normalized.
private Transform notePoolContainer;
private Transform holdPoolContainer;
private Transform startPoolContainer;
// ƵĬϹر
[Header("Debug")]
public bool verboseLogging = false;
[Header("Prewarm Settings")]
@@ -61,7 +60,7 @@ public class NotePool : MonoBehaviour
return;
}
// ʼɫӳ
// Documentation text normalized.
colorIndexMap = new Dictionary<string, int>
{
{ "red", 0 },
@@ -71,7 +70,7 @@ public class NotePool : MonoBehaviour
{ "blue", 4 }
};
//
// Documentation text normalized.
notePoolContainer = new GameObject("_NotePool_Notes").transform;
notePoolContainer.SetParent(transform, false);
holdPoolContainer = new GameObject("_NotePool_HoldSegments").transform;
@@ -195,16 +194,15 @@ public class NotePool : MonoBehaviour
GameObject obj = pool.Pop();
if (obj == null)
{
// жѱ٣
obj = InstantiateAndPrepare(prefab);
// Documentation text normalized.
}
else
{
// Żʹ PoolItem ʶǷͬһ prefab
// Documentation text normalized.
GamePlay.PoolItem pi = obj.GetComponent<GamePlay.PoolItem>();
if (pi == null || prefab == null || pi.prefabName != prefab.name)
{
// ٴ͵Ķ滻Ϊȷ prefab ʵ
// Documentation text normalized.
if (pi == null && verboseLogging) Debug.LogWarning("PoolItem missing on pooled object, replacing.");
Destroy(obj);
obj = InstantiateAndPrepare(prefab);
@@ -215,13 +213,20 @@ public class NotePool : MonoBehaviour
// mark as taken from pool
GamePlay.PoolItem takenPi = obj.GetComponent<GamePlay.PoolItem>();
if (takenPi != null) takenPi.inPool = false;
if (takenPi != null)
{
takenPi.inPool = false;
if (takenPi.initialLocalScaleCaptured)
{
obj.transform.localScale = takenPi.initialLocalScale;
}
}
return obj;
}
else
{
// ؿʱֱʵ
// Documentation text normalized.
GameObject obj = InstantiateAndPrepare(prefab);
obj.SetActive(true);
return obj;
@@ -236,12 +241,17 @@ public class NotePool : MonoBehaviour
if (pi == null) pi = obj.AddComponent<GamePlay.PoolItem>();
pi.prefabName = prefab.name;
pi.inPool = false;
if (!pi.initialLocalScaleCaptured)
{
pi.initialLocalScale = obj.transform.localScale;
pi.initialLocalScaleCaptured = true;
}
return obj;
}
private void ReturnObjectToPool(Stack<GameObject> pool, GameObject obj, Transform container)
{
if (obj == null || pool == null) return; // ֹ
if (obj == null || pool == null) return; // Documentation text normalized.
// Defensive: unregister/unlock any TrackKeyManager state referencing this object to avoid stale queues/locks
try
@@ -271,21 +281,29 @@ public class NotePool : MonoBehaviour
}
catch { }
// ظڳڣֱӺ
// Documentation text normalized.
GamePlay.PoolItem pi = obj.GetComponent<GamePlay.PoolItem>();
if (pi == null)
{
pi = obj.AddComponent<GamePlay.PoolItem>();
}
if (!pi.initialLocalScaleCaptured)
{
pi.initialLocalScale = obj.transform.localScale;
pi.initialLocalScaleCaptured = true;
}
if (pi != null && pi.inPool) return;
// ƶñ任
// Documentation text normalized.
obj.transform.SetParent(container, false);
obj.transform.localPosition = Vector3.zero;
obj.transform.localRotation = Quaternion.identity;
obj.transform.localScale = Vector3.one;
obj.transform.localScale = pi.initialLocalScaleCaptured ? pi.initialLocalScale : obj.transform.localScale;
// ΪǼ״̬Ա㸴
// Documentation text normalized.
obj.SetActive(false);
// Ϊڳ
if (pi != null) pi.inPool = true;
// Documentation text normalized.
if (pool.Count < maxPoolSize)
{
@@ -293,7 +311,7 @@ public class NotePool : MonoBehaviour
}
else
{
// ٶԽʡڴ
// Documentation text normalized.
Destroy(obj);
}
}
@@ -308,6 +326,11 @@ public class NotePool : MonoBehaviour
GamePlay.PoolItem pi = obj.GetComponent<GamePlay.PoolItem>() ?? obj.AddComponent<GamePlay.PoolItem>();
pi.prefabName = prefab.name;
pi.inPool = true;
if (!pi.initialLocalScaleCaptured)
{
pi.initialLocalScale = obj.transform.localScale;
pi.initialLocalScaleCaptured = true;
}
obj.SetActive(false);
pool.Push(obj);
@@ -332,7 +355,6 @@ public class NotePool : MonoBehaviour
return GetObjectFromPool(holdNotePools[colorIndex], holdNotePrefabs[colorIndex], holdPoolContainer);
}
// βרóػȡβƬΣδβԤ򽵼ΪжΣ
public GameObject GetHoldNoteEndSegment(string color)
{
int colorIndex = GetColorIndexFromName(color);
@@ -359,7 +381,7 @@ public class NotePool : MonoBehaviour
if (startNote == null) return;
if (string.IsNullOrEmpty(color))
{
if (verboseLogging) Debug.LogWarning($" StartNote ʱ color Ϊգ: {startNote.name}");
if (verboseLogging) Debug.LogWarning($"归还 StartNote color 为空或无效: {startNote.name}");
Destroy(startNote);
return;
}
@@ -367,20 +389,20 @@ public class NotePool : MonoBehaviour
HoldNote holdNote = startNote.GetComponent<HoldNote>();
if (holdNote != null)
{
holdNote.ResetState(); // ȷ״̬
holdNote.ResetState(); // Documentation text normalized.
}
int idx = GetColorIndexFromName(color);
ReturnObjectToPool(startNotePools[idx], startNote, startPoolContainer);
}
// 黹βƬ
// Documentation text normalized.
public void ReturnHoldNoteEndSegment(GameObject holdNoteEnd, string color)
{
if (holdNoteEnd == null) return;
if (string.IsNullOrEmpty(color))
{
if (verboseLogging) Debug.LogWarning($" HoldNoteEndSegment ʱ color Ϊգ: {holdNoteEnd.name}");
if (verboseLogging) Debug.LogWarning($"归还 HoldNoteEndSegment color 为空或无效: {holdNoteEnd.name}");
Destroy(holdNoteEnd);
return;
}
@@ -388,7 +410,7 @@ public class NotePool : MonoBehaviour
HoldNote holdNoteScript = holdNoteEnd.GetComponent<HoldNote>();
if (holdNoteScript != null)
{
holdNoteScript.ResetState(); // ȷ״̬
holdNoteScript.ResetState(); // Documentation text normalized.
}
int idx = GetColorIndexFromName(color);
@@ -400,7 +422,7 @@ public class NotePool : MonoBehaviour
if (holdNote == null) return;
if (string.IsNullOrEmpty(color))
{
if (verboseLogging) Debug.LogWarning($" HoldNoteSegment ʱ color Ϊգ: {holdNote.name}");
if (verboseLogging) Debug.LogWarning($"归还 HoldNoteSegment color 为空或无效: {holdNote.name}");
Destroy(holdNote);
return;
}
@@ -408,7 +430,7 @@ public class NotePool : MonoBehaviour
HoldNote holdNoteScript = holdNote.GetComponent<HoldNote>();
if (holdNoteScript != null)
{
holdNoteScript.ResetState(); // ȷ״̬
holdNoteScript.ResetState(); // Documentation text normalized.
}
int idx = GetColorIndexFromName(color);
@@ -419,13 +441,7 @@ public class NotePool : MonoBehaviour
{
if (string.IsNullOrEmpty(colorName)) return 0;
if (colorIndexMap != null && colorIndexMap.TryGetValue(colorName, out int idx)) return idx;
if (verboseLogging) Debug.LogError($"δʶɫ: {colorName}Ĭʹúɫ");
if (verboseLogging) Debug.LogError($"未识别颜色名: {colorName},默认使用红色");
return 0;
}
}
public class PoolItem : MonoBehaviour
{
public string prefabName;
public bool inPool;
}
+51 -160
View File
@@ -1,4 +1,4 @@
using System;
using System;
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
@@ -9,27 +9,23 @@ public class NoteSpawner : MonoBehaviour
{
public event Action AllNotesSpawned; // invoked when all notes have been spawned
public NotePool notePool; // 引用 NotePool
public Transform[] spawnPoints; // 对应轨道的生成点
public GameObject[] notePrefabs; // 短音符预制体
public NotePool notePool; // Documentation text normalized.
public Transform[] spawnPoints; // Documentation text normalized.
public GameObject[] notePrefabs; // Documentation text normalized.
public TextMeshProUGUI globalGameTime;
// 长音符预制体数组(分别为 start, middle, end
public GameObject[] holdNoteStartPrefabs;
// Documentation text normalized.
public GameObject[] holdNoteMiddlePrefabs;
public GameObject[] holdNoteEndPrefabs;
public float spawnOffset = 0f; // 生成音符时的时间偏移量
public float bpm;
public float spawnOffset = 0f; // Documentation text normalized.
[Header("Global timing adjustments")]
[Tooltip("Global additional realtime offset (seconds) added to hit times for all notes. Use to test input latency or adjust judgement timing. Default 0. Can be negative to make notes arrive earlier.")]
public float globalHitDelay = 0f;
// 新增:视觉下落速度倍率(public,可在 Inspector 调整)
// 默认 1.0 = 原始速度。>1 加速下落(视觉更快),<1 减速下落(视觉更慢)。
[Header("Visual speed settings")]
// Documentation text normalized.
[Tooltip("Multiplier applied to visual fall speed. Changing this will automatically adjust spawn timing so notes still arrive at their original beat times.")]
[Range(0.8f, 1.25f)]
public float speedMultiplier = 1f;
@@ -44,7 +40,7 @@ public class NoteSpawner : MonoBehaviour
private WaitForSecondsRealtime calibrateWait;
private float calibrateWaitSeconds = float.NaN;
// 控制是否启用“追赶偏移量(y-offset compensation)”计算,默认关闭
// Documentation text normalized.
[Header("Early compensation (experimental)")]
[Tooltip("When enabled, spawn positioning will compensate each segment's position based on its own activation time so newly spawned segments appear at their expected traveled position. Default: OFF.")]
public bool enableYOffsetCompensation = false;
@@ -66,13 +62,13 @@ public class NoteSpawner : MonoBehaviour
private const float SpeedMultiplierMin = 0.8f;
private const float SpeedMultiplierMax = 1.25f;
public NoteJudgeConfig judgeConfig; // 判定区间配置,需在 Inspector 赋值
public NoteJudgeConfig judgeConfig; // Documentation text normalized.
private Beatmap beatmap;
private float startTime; // 存储歌曲开始时间
private float startTime; // Documentation text normalized.
private float bpm = 120f;
private bool isSpawning = false;
// 生成唯一 id(改为自增计数器,避免随机冲突)
// Documentation text normalized.
private static int holdNoteIdCounter = 0;
// map from beatmap note index -> assigned holdNoteId (for hold notes only)
@@ -100,8 +96,7 @@ public class NoteSpawner : MonoBehaviour
private void OnEnable()
{
// 每次场景启用/加载时:清空立刻结算状态,避免进入场景即锁定
ResetImmediateSettlementState();
// Documentation text normalized.
BindImmediateSettlementButton();
// subscribe to JudgeManager.AllNotesJudged so we disable animations on normal settlement as well
@@ -130,7 +125,7 @@ public class NoteSpawner : MonoBehaviour
private void ResetImmediateSettlementState()
{
immediateSettlementTriggered = false;
// 不强制设置 isSpawning,这个状态由 LoadBeatmap 驱动;这里只清锁
// Documentation text normalized.
}
private void BindImmediateSettlementButton()
@@ -208,7 +203,7 @@ public class NoteSpawner : MonoBehaviour
{
if (beatmap == null || beatmap.notes == null)
{
Debug.LogError("谱面数据为空!");
Debug.LogError("Beatmap note data is null.");
isSpawning = false;
yield break;
}
@@ -223,7 +218,7 @@ public class NoteSpawner : MonoBehaviour
// iterate with index so we can map hold notes to generated ids
for (int i = 0; i < beatmap.notes.Length; i++)
{
// 允许外部在运行中打断生成(例如“立刻结算”)
// Documentation text normalized.
if (!isSpawning)
yield break;
@@ -273,26 +268,25 @@ public class NoteSpawner : MonoBehaviour
return key;
}
// 生成短音符
public void SpawnNote(NoteData noteData, float sm, float effectiveTravelTime, float noteSpeed)
{
if (noteData.trackIndex < 0 || noteData.trackIndex >= spawnPoints.Length)
{
Debug.LogError("轨道索引超出范围!");
Debug.LogError("Track index out of range.");
return;
}
KeyCode key = GetCachedKeyCode(noteData.color);
if (key == KeyCode.None)
{
Debug.LogError($"未找到颜色 {noteData.color} 对应的按键!请检查 KeyBindingManager 是否正确初始化。");
Debug.LogError($"No key binding found for note color '{noteData.color}'.");
return;
}
GameObject note = notePool.GetNote(noteData.color);
if (note == null)
{
if (JudgeManager.IsDebugEnabled) Debug.LogError("对象池返回了一个空音符!");
if (JudgeManager.IsDebugEnabled) Debug.LogError("NotePool returned a null short note object.");
return;
}
@@ -337,7 +331,7 @@ public class NoteSpawner : MonoBehaviour
}
else
{
Debug.LogError("音符预制体缺少 Note 组件!");
Debug.LogError("Note prefab is missing Note component.");
}
}
@@ -346,7 +340,7 @@ public class NoteSpawner : MonoBehaviour
{
if (noteData.trackIndex < 0 || noteData.trackIndex >= spawnPoints.Length)
{
Debug.LogError("轨道索引超出范围!");
Debug.LogError("Track index out of range.");
return -1;
}
@@ -364,28 +358,25 @@ public class NoteSpawner : MonoBehaviour
}
int segmentCount = Mathf.CeilToInt(noteData.length / segmentInterval);
if (segmentCount < 1) segmentCount = 1; // 至少一个片段
// 如果用节拍计算的片段间隔与实际长度不整除,实际用于分段的间隔应当按实际长度均分,
// 以确保尾部音符排在最后一个中段之后并且到达时间等于谱面结束时间。
float actualSegmentInterval = noteData.length / segmentCount; // 实际用于中段间隔
if (segmentCount < 1) segmentCount = 1; // Documentation text normalized.
float actualSegmentInterval = segmentCount > 0 ? noteData.length / segmentCount : segmentInterval;
Transform spawnPoint = spawnPoints[noteData.trackIndex];
// scheduledEndTime 使用谱面时间(note.time + length),但转换为实时
// Documentation text normalized.
float rawScheduledEnd = startTime + (noteData.time + noteData.length) + globalHitDelay;
float scheduledEndTime = Mathf.Max(0f, rawScheduledEnd); // clamp to non-negative
// 生成唯一 id(改为自增计数器,避免随机冲突)
// Documentation text normalized.
int holdNoteId = ++holdNoteIdCounter;
// base realtime for hits
float rawBase = startTime + noteData.time + globalHitDelay;
float baseHit = Mathf.Max(0f, rawBase);
// 生成 Start 段
GameObject startObj = notePool.GetStartNote(noteData.color);
if (startObj == null)
{
Debug.LogError("对象池返回空 hold note start");
Debug.LogError("NotePool returned null hold start object.");
return -1;
}
@@ -407,20 +398,18 @@ public class NoteSpawner : MonoBehaviour
}
else
{
Debug.LogError("长音符 start 部分缺少 HoldNote 组件!");
Debug.LogError("Hold start object is missing HoldNote component.");
return -1;
}
// 生成 Middle 片段(1 .. segmentCount-1
for (int i = 1; i < segmentCount; i++)
{
// keep segment delays based on actualSegmentInterval (unscaled) so middle pieces are consecutive regardless of visual speed
float segmentDelay = i * actualSegmentInterval; // 不按 speedMultiplier 缩放,保证中段连续
float segmentDelay = i * actualSegmentInterval; // Documentation text normalized.
GameObject segObj = notePool.GetHoldNoteSegment(noteData.color);
if (segObj == null)
{
Debug.LogError("对象池返回空 hold note 片段!");
Debug.LogError("NotePool returned null hold segment object.");
continue;
}
@@ -430,7 +419,7 @@ public class NoteSpawner : MonoBehaviour
HoldNote holdSeg = segObj.GetComponent<HoldNote>();
if (holdSeg != null)
{
// 中段都标记为 middlepass realtime base time and segmentDelay
// Documentation text normalized.
holdSeg.Setup(holdNoteId, noteData.trackIndex, noteSpeed, baseHit, segmentDelay, false, scheduledEndTime, noteData.color, key, "middle", judgeConfig, noteData);
// apply same visual scale so middle pieces visually connect
@@ -445,11 +434,10 @@ public class NoteSpawner : MonoBehaviour
}
else
{
Debug.LogError("长音符片段缺少 HoldNote 组件!");
Debug.LogError("Hold segment object is missing HoldNote component.");
}
}
// 始终在中段之后生成一个明确的 End 段(尾部音符)
GameObject endObj = notePool.GetHoldNoteEndSegment(noteData.color);
if (endObj == null)
{
@@ -457,8 +445,7 @@ public class NoteSpawner : MonoBehaviour
return -1;
}
float endDelay = segmentCount * actualSegmentInterval; // 不按 speedMultiplier 缩放,使用谱面长度保证尾段紧随中段
float endDelay = segmentCount * actualSegmentInterval; // Documentation text normalized.
endObj.transform.position = spawnPoint.position;
endObj.transform.rotation = Quaternion.identity;
@@ -479,19 +466,14 @@ public class NoteSpawner : MonoBehaviour
}
else
{
Debug.LogError("长音符 end 部分缺少 HoldNote 组件!");
Debug.LogError("Hold end object is missing HoldNote component.");
}
return holdNoteId;
}
/// <summary>
/// 立刻结算:
/// - 停止继续生成音符(停止 SpawnNotes 协程)
/// - 取消 NoteSpawner 自己的延迟结算协程(PostSpawnSettlementRoutine
/// - 立刻触发结算流程(JudgeManager.TriggerAllNotesJudged
/// 注意:该方法不会清理场上已生成的音符,仅停止后续生成并进入结算。
/// </summary>
/// Documentation text normalized.
[ContextMenu("Force Immediate Settlement")]
public void ForceImmediateSettlement()
{
@@ -595,8 +577,7 @@ public class NoteSpawner : MonoBehaviour
}
/// <summary>
/// 校验短音符的位置,防止其因计时偏差而位移
/// </summary>
/// Documentation text normalized.
private IEnumerator CalibrateNoteAfterSpawn(NoteController noteController, Vector3 expectedSpawnPos)
{
if (noteController == null) yield break;
@@ -642,129 +623,39 @@ public class NoteSpawner : MonoBehaviour
if (JudgeManager.IsDebugEnabled) Debug.Log("[NoteSpawner] StartSettlementRoutine called - beginning settlement countdown");
// keep reference so it can be cancelled by ForceImmediateSettlement
if (settlementCoroutine != null) StopCoroutine(settlementCoroutine);
settlementCoroutine = StartCoroutine(PostSpawnSettlementRoutine(5));
settlementCoroutine = StartCoroutine(PostSpawnSettlementRoutine());
}
private IEnumerator PostSpawnSettlementRoutine(int lookback)
private IEnumerator PostSpawnSettlementRoutine()
{
// Get relevant notes: last notes within 3 seconds before the last note time
if (beatmap == null || beatmap.notes == null || beatmap.notes.Length == 0)
yield break;
NoteData lastNote = beatmap.notes[beatmap.notes.Length - 1];
float lastNoteTime = lastNote.time;
float timeRangeStart = lastNoteTime - 3f; // 3 seconds window instead of 0.5 seconds
// Collect notes within the range [timeRangeStart, lastNoteTime]
List<NoteData> relevantNotes = new List<NoteData>();
for (int i = beatmap.notes.Length - 1; i >= 0; i--)
float chartEndTime = 0f;
for (int i = 0; i < beatmap.notes.Length; i++)
{
NoteData note = beatmap.notes[i];
if (note.time >= timeRangeStart)
{
relevantNotes.Add(note);
}
else
{
break;
}
if (note == null) continue;
float end = note.type == "hold" ? (note.time + note.length) : note.time;
if (end > chartEndTime) chartEndTime = end;
}
chartEndTime += Mathf.Max(0f, globalHitDelay);
// Reverse to process in chronological order
relevantNotes.Reverse();
if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Found {relevantNotes.Count} relevant notes in range [{timeRangeStart:F3}, {lastNoteTime:F3}]");
// Analyze notes: check if any are hold notes and find the latest ending note
float maxEndTime = float.MinValue;
NoteData maxEndNote = null;
foreach (var note in relevantNotes)
{
float noteEndTime;
if (note.type == "hold")
{
noteEndTime = note.time + note.length;
}
else
{
noteEndTime = note.time;
}
if (noteEndTime > maxEndTime)
{
maxEndTime = noteEndTime;
maxEndNote = note;
}
if (JudgeManager.IsDebugEnabled)
{
Debug.Log($"[NoteSpawner] Note: type={note.type}, time={note.time:F3}, endTime={noteEndTime:F3}");
}
}
// Determine initial wait and type of max end note
float initialWait = 0f;
bool maxIsHold = false;
if (maxEndNote != null)
{
if (maxEndNote.type == "hold")
{
maxIsHold = true;
initialWait = maxEndNote.length;
}
else
{
maxIsHold = false;
initialWait = 0f;
}
}
const float postChartDelay = 2f;
float settlementTime = startTime + chartEndTime + postChartDelay;
if (JudgeManager.IsDebugEnabled)
{
Debug.Log($"[NoteSpawner] Max end note: type={maxEndNote?.type}, time={maxEndNote?.time:F3}, maxIsHold={maxIsHold}, initialWait={initialWait:F3}");
Debug.Log($"[NoteSpawner] Settlement scheduled at chartEnd+{postChartDelay:F1}s " +
$"(chartEnd={chartEndTime:F3}, startTime={startTime:F3}, now={Time.time:F3})");
}
// Log final chosen extension method and total extension (initial wait + final buffer) as an error for visibility
float finalBuffer = 3f; // final buffer used by settlement routine (changed to 3s)
float totalWait = initialWait + finalBuffer;
string methodName = maxIsHold ? "hold" : "tap";
Debug.LogError($"[NoteSpawner] Settlement extension chosen: method={methodName}, initialWait={initialWait:F3}s, finalBuffer={finalBuffer:F3}s, totalWait={totalWait:F3}s");
// Clear the reference list to release memory after use
relevantNotes.Clear();
relevantNotes = null;
// Wait for initial period (hold length or 0)
if (initialWait > 0f)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Waiting for initial hold length: {initialWait:F3}s");
float target = Time.time + initialWait;
while (Time.time < target)
yield return null;
}
// Final buffer: use finalBuffer variable
if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Entering final {finalBuffer} s settlement buffer");
// Wait until 1s remaining, then clear input (clear at finalBuffer-1 seconds mark)
float timeBeforeClear = Mathf.Max(0f, finalBuffer - 1f);
if (timeBeforeClear > 0f)
{
float tBefore = Time.time + timeBeforeClear;
while (Time.time < tBefore)
yield return null;
}
// Force clear keyboard/input state at the 1s-before-end mark
ForceClearInputState(null);
// Wait the remaining 1s
float tAfter = Time.time + 1f;
while (Time.time < tAfter)
while (Time.time < settlementTime)
yield return null;
ForceClearInputState(null);
// Trigger settlement
try
{
@@ -7,5 +7,7 @@ namespace GamePlay
{
public string prefabName;
public bool inPool = false;
public Vector3 initialLocalScale = Vector3.one;
public bool initialLocalScaleCaptured = false;
}
}
@@ -0,0 +1,183 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// Runtime UI feed for skill triggers.
/// Writes to "score/Skill" in the active scene:
/// - newest entry at the top
/// - keeps last 5 entries
/// If "score/Skill" does not exist, it will be created under "score".
/// </summary>
public static class SkillTriggerFeedUI
{
public static bool RuntimeEnabled = false;
private const int MaxEntries = 5;
private static readonly List<string> s_entries = new List<string>(MaxEntries);
// Per request: use legacy UI.Text only (not TMP).
private static Text s_feedText;
private static int s_boundTextInstanceId;
private static bool s_warnedMissingScore;
public static void Push(string idolName, string skillName)
{
if (!RuntimeEnabled)
{
TryHideLegacyFeedRoot();
return;
}
if (string.IsNullOrWhiteSpace(idolName)) idolName = "Unknown";
if (string.IsNullOrWhiteSpace(skillName)) skillName = "UnknownSkill";
EnsureBound();
if (!HasBoundText()) return;
s_entries.Insert(0, $"{idolName} - {skillName}");
if (s_entries.Count > MaxEntries)
{
s_entries.RemoveRange(MaxEntries, s_entries.Count - MaxEntries);
}
UpdateText();
}
public static void Clear()
{
if (!RuntimeEnabled)
{
TryHideLegacyFeedRoot();
return;
}
s_entries.Clear();
UpdateText();
}
private static bool HasBoundText()
{
if (s_feedText != null && !s_feedText) s_feedText = null;
return s_feedText != null;
}
private static void EnsureBound()
{
if (HasBoundText()) return;
GameObject skillRoot = GameObject.Find("score/Skill");
if (skillRoot == null)
{
var score = GameObject.Find("score");
if (score == null)
{
if (!s_warnedMissingScore)
{
Debug.LogWarning("[SkillTriggerFeedUI] Cannot find GameObject 'score'. Skill trigger feed UI is disabled in this scene.");
s_warnedMissingScore = true;
}
return;
}
// Create a minimal "score/Skill" container if missing.
skillRoot = new GameObject("Skill", typeof(RectTransform));
skillRoot.transform.SetParent(score.transform, false);
var rt = skillRoot.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0.5f, 0.5f);
rt.anchorMax = new Vector2(0.5f, 0.5f);
rt.pivot = new Vector2(0.5f, 0.5f);
rt.anchoredPosition = new Vector2(776f, 277f);
rt.sizeDelta = new Vector2(160f, 30f);
}
// Prefer using the existing "sumScoreText" node as an anchor (it is already positioned correctly in the scene),
// but render with legacy UI.Text (Chinese glyphs require the same font as the UI, not builtin Arial).
Transform feed = skillRoot.transform.Find("SkillFeedText");
RectTransform referenceRect = null;
var sumScore = skillRoot.transform.Find("sumScoreText");
if (sumScore != null) referenceRect = sumScore as RectTransform;
if (feed == null && sumScore != null) feed = sumScore;
if (feed == null)
{
var go = new GameObject("SkillFeedText", typeof(RectTransform));
go.transform.SetParent(skillRoot.transform, false);
feed = go.transform;
}
s_feedText = feed.GetComponent<Text>();
if (s_feedText == null) s_feedText = feed.gameObject.AddComponent<Text>();
if (s_feedText != null) s_feedText.enabled = true;
if (feed != null && feed.gameObject != null && !feed.gameObject.activeSelf) feed.gameObject.SetActive(true);
// If this node used to be TMP (e.g. "sumScoreText"), ensure TMP renderer is disabled so we only use legacy Text.
try
{
var tmp = feed.GetComponent("TMP_Text") as Behaviour;
if (tmp != null) tmp.enabled = false;
}
catch { }
// Configure for multi-line feed (legacy Text)
s_feedText.raycastTarget = false;
// Use the same font as the header under score/Skill so Chinese characters can render.
var headerText = skillRoot.GetComponent<Text>();
if (headerText != null && headerText.font != null)
s_feedText.font = headerText.font;
if (s_feedText.font == null)
s_feedText.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
s_feedText.fontSize = 20;
s_feedText.color = Color.black;
s_feedText.alignment = TextAnchor.UpperLeft;
s_feedText.horizontalOverflow = HorizontalWrapMode.Overflow;
s_feedText.verticalOverflow = VerticalWrapMode.Overflow;
s_feedText.text = string.Empty;
var frt = feed.GetComponent<RectTransform>();
if (frt != null)
{
if (referenceRect != null)
{
frt.anchorMin = referenceRect.anchorMin;
frt.anchorMax = referenceRect.anchorMax;
frt.pivot = referenceRect.pivot;
frt.anchoredPosition = referenceRect.anchoredPosition;
frt.sizeDelta = referenceRect.sizeDelta;
}
else
{
frt.anchorMin = new Vector2(0.5f, 0.5f);
frt.anchorMax = new Vector2(0.5f, 0.5f);
frt.pivot = new Vector2(0.5f, 0.5f);
frt.anchoredPosition = new Vector2(49.8f, 0f);
frt.sizeDelta = new Vector2(200f, 50f);
}
}
int newId = 0;
if (s_feedText != null) newId = s_feedText.GetInstanceID();
// If we re-bound (new scene/play session), clear old entries to avoid leaking previous runs.
if (newId != 0 && newId != s_boundTextInstanceId)
{
s_boundTextInstanceId = newId;
s_entries.Clear();
UpdateText();
}
}
private static void UpdateText()
{
if (!HasBoundText()) return;
// Newest first.
s_feedText.text = string.Join("\n", s_entries);
}
private static void TryHideLegacyFeedRoot()
{
GameObject feed = GameObject.Find("score/Skill");
if (feed != null && feed.activeSelf)
feed.SetActive(false);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5cf61e3891454d44b8fc5d856c79661a
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using UnityEngine;
using System.Collections;
@@ -247,8 +247,8 @@ public class TrackKeyManager : MonoBehaviour
}
/// <summary>
/// жʱ id УnoteϢ
/// noteType: "tap" "hold"
/// Documentation text normalized.
/// Documentation text normalized.
/// </summary>
public void RegisterKey(int trackIndex, string noteInstanceId, string noteType = "tap", float hitTime = float.NaN)
{
@@ -273,7 +273,7 @@ public class TrackKeyManager : MonoBehaviour
}
/// <summary>
/// жʱƳ id
/// Documentation text normalized.
/// </summary>
public void UnregisterKey(int trackIndex, string noteInstanceId)
{
@@ -327,7 +327,7 @@ public class TrackKeyManager : MonoBehaviour
}
/// <summary>
/// ȡǰڶ׵ʵ idͷ
/// Documentation text normalized.
/// </summary>
public string GetCurrentNoteId(int trackIndex)
{
@@ -337,11 +337,11 @@ public class TrackKeyManager : MonoBehaviour
{
return trackKeyMappings[trackIndex].Peek().noteId;
}
return null; //
return null; // Documentation text normalized.
}
/// <summary>
/// ȡǰͷnoteͣ"tap" "hold"
/// Documentation text normalized.
/// </summary>
public string GetCurrentNoteType(int trackIndex)
{
@@ -1,22 +1,22 @@
using System.Collections;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class gameplay_global : MonoBehaviour
{
public static gameplay_global Instance; //单例
public static gameplay_global Instance; // Documentation text normalized.
public bool game_isStarted = false; //游戏开始
public bool game_isPaused = false; //游戏暂停
public bool game_isEnded = false; //游戏结束,读到了最后的chart_is_end
public bool game_isStarted = false; // Documentation text normalized.
public bool game_isPaused = false; // Documentation text normalized.
public bool game_isEnded = false; // Documentation text normalized.
public bool k1_pressed = false; //按下了按键
public bool k1_pressed = false; // Documentation text normalized.
public bool k2_pressed = false;
public bool k3_pressed = false;
public bool k4_pressed = false;
public bool k5_pressed = false;
public bool k1_inJudgeZone = false;//进入了判定区域
public bool k1_inJudgeZone = false;// Documentation text normalized.
public bool k2_inJudgeZone = false;
public bool k3_inJudgeZone = false;
public bool k4_inJudgeZone = false;
@@ -28,21 +28,21 @@ public class gameplay_global : MonoBehaviour
public int difficulty_ID;
public string difficulty_Name;
public float startTime; //记录开始游戏的时间 用于谱面note加载时机计算
public float streamMultiply = 5f; //note流速
public float offset = -0.2f; //延迟
public float lifeTime = 0.15f; //perfect good lost判定提示效果的显示时间
public float top_position_Y; //top按钮的y坐标 用于确定note生成位置的y坐标
public int perfectScore;//perfect分值
public int goodScore;//good分值
public int score;//float类型的精确总分
public float multiNoteOffset = 0.26f;//用于多押调整错位的偏移量
public int combo = 0;//连击数
public int noteSum = 0;//谱面总物量
public int perfectNum = 0;//当前perfect数
public int goodNum = 0;//当前good数
public int missNum = 0;//当前miss数
public Vector3 k1Pos, k2Pos, k3Pos, k4Pos; //四个键位的坐标 用于确定note生成位置
public float startTime; // Documentation text normalized.
public float streamMultiply = 5f; // Documentation text normalized.
public float offset = -0.2f; // Documentation text normalized.
public float lifeTime = 0.15f; // Documentation text normalized.
public float top_position_Y; // Documentation text normalized.
public int perfectScore;// Documentation text normalized.
public int goodScore;// Documentation text normalized.
public int score;// Documentation text normalized.
public float multiNoteOffset = 0.26f;// Documentation text normalized.
public int combo = 0;// Documentation text normalized.
public int noteSum = 0;// Documentation text normalized.
public int perfectNum = 0;// Documentation text normalized.
public int goodNum = 0;// Documentation text normalized.
public int missNum = 0;// Documentation text normalized.
public Vector3 k1Pos, k2Pos, k3Pos, k4Pos; // Documentation text normalized.
public KeyCode key1 = KeyCode.D;
public KeyCode key2 = KeyCode.F;
public KeyCode key3 = KeyCode.J;
@@ -0,0 +1,814 @@
using UnityEngine;
using UnityEngine.UI;
using System;
using System.Collections;
using SmoothShakeFree;
using Random = UnityEngine.Random;
/// <summary>
/// 控制游戏过程中的打击特效及其他图形效果
/// </summary>
public class GfxController : MonoBehaviour
{
public static GfxController Instance { get; private set; }
[Header("Combatant Objects")]
public GameObject enemyMoveObject;
public GameObject ally01MoveObject;
public GameObject ally02MoveObject;
public GameObject ally03MoveObject;
public GameObject ally04MoveObject;
public GameObject ally05MoveObject;
[Header("Hit Effects - Enemy")]
public GameObject enemyObject;
[Header("Hit Effects - Allies")]
public GameObject ally01Object;
public GameObject ally02Object;
public GameObject ally03Object;
public GameObject ally04Object;
public GameObject ally05Object;
[Header("Prefabs")]
public GameObject hitFX;
public GameObject projectileFX;
public GameObject explosionFX;
public GameObject k_o_fx;
public GameObject koFatherObject;
[Header("Hit Effect Settings - Enemy")]
public float enemyOffsetX = 0f;
public float enemyOffsetY = 0f;
public float enemyFxScale = 1f;
public bool useEnemyRandomScale = false;
public float enemyMinFxScale = 0.8f;
public float enemyMaxFxScale = 1.2f;
[Range(0f, 1f)]
public float enemyFxTransparency = 1f;
public int enemyFxSortingOrder = 10;
[Header("Hit Effect Settings - Ally")]
public float allyOffsetX = 0f;
public float allyOffsetY = 0f;
public float allyFxScale = 1f;
public bool useAllyRandomScale = false;
public float allyMinFxScale = 0.8f;
public float allyMaxFxScale = 1.2f;
[Range(0f, 1f)]
public float allyFxTransparency = 1f;
public int allyFxSortingOrder = 10;
[Header("Projectile Settings")]
public float projectileDuration = 0.5f;
public AnimationCurve projectileSpeedCurve = AnimationCurve.Linear(0, 0, 1, 1);
[Range(0f, 1f)]
public float projectileTransparency = 1f;
public float projectileScale = 1f;
[Tooltip("弹道颜色控制 (影响 Trail 和 Particle)")]
public Gradient projectileColor;
[Header("敌人攻击控件")]
[Tooltip("Miss时敌人攻击角色的弹道特效")]
public GameObject enemyMissProjectilePrefab;
[Tooltip("Miss弹道颜色控制")]
public Gradient enemyAttackProjectileColor;
[Tooltip("Miss弹道移动时间")]
public float enemyAttackProjectileDuration = 0.4f;
[Tooltip("Miss弹道缩放")]
public float enemyAttackProjectileScale = 1.0f;
[Tooltip("Miss弹道层级")]
public int enemyAttackProjectileSortingOrder = 15;
[Tooltip("Miss弹道到达时的Hit特效缩放")]
public float enemyAttackHitFxScale = 1.0f;
[Tooltip("Miss弹道速度曲线")]
public AnimationCurve enemyAttackSpeedCurve = AnimationCurve.Linear(0, 0, 1, 1);
public float koFxScale = 1f;
[Tooltip("弹道到达终点时的随机偏移范围 - X轴")]
public float projectileRandomOffsetX = 0f;
[Tooltip("弹道到达终点时的随机偏移范围 - Y轴")]
public float projectileRandomOffsetY = 0f;
[Tooltip("弹道到达终点时的随机偏移范围 - Z轴")]
public float projectileRandomOffsetZ = 0f;
public int projectileSortingOrder = 10;
[Header("Enemy Hurt Shake Settings")]
public Vector3 hitShakeAmplitude = new Vector3(10f, 10f, 0f);
public Vector3 hitShakeFrequency = new Vector3(20f, 20f, 0f);
public float hitShakeDuration = 0.2f;
[Header("Ally Hurt Shake Settings")]
public Vector3 allyHitShakeAmplitude = new Vector3(8f, 8f, 0f);
public Vector3 allyHitShakeFrequency = new Vector3(25f, 25f, 0f);
public float allyHitShakeDuration = 0.15f;
private void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(gameObject);
}
}
/// <summary>
/// 在指定目标位置播放受击特效
/// </summary>
/// <param name="target">受击者物体</param>
/// <param name="parent">父物体(特效将生成在该物体下)</param>
/// <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)
{
if (target == null || hitFX == null)
{
if (hitFX == null) Debug.LogWarning("[GfxController] hitFX Prefab 未在 Inspector 中分配!");
return;
}
// 触发受击震动效果
TriggerShake(target, isEnemy);
// 如果是敌人,触发闪红效果 (使用 teamUIController 已有的逻辑)
if (isEnemy && teamUIController.Instance != null)
{
teamUIController.Instance.TriggerEnemyHurtFlash();
}
else if (!isEnemy && teamUIController.Instance != null)
{
// 尝试从物体名称或组件中识别槽位并触发闪红
int slot = -1;
var ally = target.GetComponent<AllyCombatant>();
if (ally != null) slot = ally.slotIndex;
else
{
string n = target.name.ToLower();
if (n.Contains("ally_01")) slot = 0;
else if (n.Contains("ally_02")) slot = 1;
else if (n.Contains("ally_03")) slot = 2;
else if (n.Contains("ally_04")) slot = 3;
else if (n.Contains("ally_05")) slot = 4;
}
if (slot != -1)
{
teamUIController.Instance.TriggerAllyHurtFlash(slot);
}
}
// 尝试自动解析挂载点,如果未提供 parent
GameObject visualParent = parent != null ? parent : ResolveGfxMountPoint(target);
// 根据身份选择参数
float offsetX = isEnemy ? enemyOffsetX : allyOffsetX;
float offsetY = isEnemy ? enemyOffsetY : allyOffsetY;
// 计算缩放逻辑
float fxScale;
if (scaleOverride.HasValue)
{
fxScale = scaleOverride.Value;
}
else if (isEnemy)
{
fxScale = useEnemyRandomScale ? Random.Range(enemyMinFxScale, enemyMaxFxScale) : enemyFxScale;
}
else
{
fxScale = useAllyRandomScale ? Random.Range(allyMinFxScale, allyMaxFxScale) : allyFxScale;
}
float transparency = isEnemy ? enemyFxTransparency : allyFxTransparency;
GameObject fx;
Vector3 offsetVector = new Vector3(offsetX, offsetY, 0f);
if (visualParent != null)
{
// 将缩放应用到挂载点物体(visualParent)本身
visualParent.transform.localScale = Vector3.one * fxScale;
// 作为子物体生成
fx = Instantiate(hitFX, visualParent.transform);
// 确保特效在 UI 层级中显示在最前方
fx.transform.SetAsLastSibling();
// 检查是否是 UI 元素 (RectTransform)
RectTransform rt = fx.GetComponent<RectTransform>();
if (rt != null)
{
if (customWorldPos.HasValue)
{
// 如果有自定义世界坐标,将其转换为本地 UI 坐标
rt.position = customWorldPos.Value;
}
else
{
rt.anchoredPosition = new Vector2(offsetX, offsetY);
}
rt.localRotation = Quaternion.identity;
// 确保 UI 特效的原始大小正确,且 localScale 为 1(继承 parent 的缩放)
rt.sizeDelta = hitFX.GetComponent<RectTransform>().sizeDelta;
rt.localScale = Vector3.one;
}
else
{
if (customWorldPos.HasValue)
{
fx.transform.position = customWorldPos.Value;
}
else
{
fx.transform.localPosition = offsetVector;
}
fx.transform.localRotation = Quaternion.identity;
fx.transform.localScale = Vector3.one;
}
}
else
{
Vector3 spawnPos = customWorldPos.HasValue ? customWorldPos.Value : (target.transform.position + offsetVector);
fx = Instantiate(hitFX, spawnPos, Quaternion.identity);
fx.transform.localScale = Vector3.one * fxScale;
}
// 针对粒子系统的特殊处理
ParticleSystem[] allParticles = fx.GetComponentsInChildren<ParticleSystem>();
foreach (var ps in allParticles)
{
var main = ps.main;
// 强制修复缩放:设置 Scaling Mode 为 Hierarchy,使粒子大小跟随 Transform 缩放
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
}
// 应用透明度并处理渲染
ApplyTransparencyAndRender(fx, transparency, isEnemy ? enemyFxSortingOrder : allyFxSortingOrder);
if (allParticles.Length > 0)
{
foreach (var ps in allParticles)
{
// 确保粒子在生成时立即播放
ps.Play(true);
// 设置停止动作,如果是根节点粒子,设置自动销毁
if (ps.gameObject == fx)
{
var m = ps.main;
m.stopAction = ParticleSystemStopAction.Destroy;
}
}
}
else
{
// 如果不是粒子系统(例如动画 Prefab),在固定时间后销毁(默认 2 秒)
Destroy(fx, 2.0f);
}
}
/// <summary>
/// 从发射源向目标发射一个拖尾特效
/// </summary>
/// <param name="source">发射源逻辑物体</param>
/// <param name="target">目标逻辑物体</param>
/// <param name="onComplete">到达终点后的回调(可选,返回弹道终点的世界坐标)</param>
public void PlayProjectile(GameObject source, GameObject target, Action<Vector3> onComplete = null)
{
if (source == null || target == null || projectileFX == null)
{
if (projectileFX == null) Debug.LogWarning("[GfxController] projectileFX Prefab 未分配!");
onComplete?.Invoke(target != null ? target.transform.position : Vector3.zero);
return;
}
// --- 核心修正:将逻辑物体映射到 UI 挂载点 ---
GameObject visualSource = ResolveGfxMountPoint(source);
GameObject visualTarget = ResolveGfxMountPoint(target);
// 如果映射失败,则退而求其次使用原始物体的 Transform
Transform startTransform = visualSource != null ? visualSource.transform : source.transform;
Transform endTransform = visualTarget != null ? visualTarget.transform : target.transform;
// 在源物体位置实例化
GameObject projectile = Instantiate(projectileFX, startTransform.position, Quaternion.identity);
// 如果是在 UI 环境下,设置正确的父物体(通常是 GfxController 所在的 Canvas 层级)
projectile.transform.SetParent(this.transform, true);
projectile.transform.localScale = Vector3.one * projectileScale;
// 应用透明度
ApplyTransparencyAndRender(projectile, projectileTransparency, projectileSortingOrder);
// 应用颜色
ApplyColorToEffect(projectile, projectileColor);
// 启动移动协程
Vector3 targetRandomOffset = Vector3.zero;
if (projectileRandomOffsetX > 0f || projectileRandomOffsetY > 0f || projectileRandomOffsetZ > 0f)
{
targetRandomOffset = new Vector3(
Random.Range(-projectileRandomOffsetX, projectileRandomOffsetX),
Random.Range(-projectileRandomOffsetY, projectileRandomOffsetY),
Random.Range(-projectileRandomOffsetZ, projectileRandomOffsetZ)
);
}
StartCoroutine(MoveProjectileCoroutine(projectile, startTransform, endTransform, targetRandomOffset, onComplete));
}
/// <summary>
/// 将逻辑战斗物体(Ally/Enemy)映射到 GfxController 中定义的 UI 挂载点
/// </summary>
private GameObject ResolveGfxMountPoint(GameObject logicObj)
{
if (logicObj == null) return null;
// 检查是否是敌人
if (logicObj.name == "thisEnemy" || logicObj.GetComponent<EnemyCombatant>() != null)
{
return enemyObject;
}
// 检查是否是盟友 (ally_01 到 ally_05)
var ally = logicObj.GetComponent<AllyCombatant>();
if (ally != null)
{
return GetAllyObject(ally.slotIndex);
}
// 如果名字符合模式也可以识别
string name = logicObj.name.ToLower();
if (name.Contains("ally_01")) return ally01Object;
if (name.Contains("ally_02")) return ally02Object;
if (name.Contains("ally_03")) return ally03Object;
if (name.Contains("ally_04")) return ally04Object;
if (name.Contains("ally_05")) return ally05Object;
return null;
}
private IEnumerator MoveProjectileCoroutine(GameObject projectile, Transform start, Transform end, Vector3 targetOffset, Action<Vector3> onComplete)
{
float elapsed = 0f;
Vector3 startPos = start.position;
Vector3 lastEndPos = startPos;
while (elapsed < projectileDuration)
{
if (projectile == null)
{
onComplete?.Invoke(lastEndPos);
yield break;
}
elapsed += Time.deltaTime;
float normalizedTime = Mathf.Clamp01(elapsed / projectileDuration);
// 使用动画曲线计算插值比例
float t = projectileSpeedCurve.Evaluate(normalizedTime);
// 如果目标还在,更新目标位置(支持动态移动的目标)
Vector3 currentEndPos = end != null ? end.position + targetOffset : projectile.transform.position;
lastEndPos = currentEndPos;
projectile.transform.position = Vector3.Lerp(startPos, currentEndPos, t);
// 可选:让拖尾朝向移动方向
if (t > 0)
{
Vector3 direction = currentEndPos - startPos;
if (direction != Vector3.zero)
{
projectile.transform.rotation = Quaternion.LookRotation(Vector3.forward, direction);
}
}
yield return null;
}
// 到达终点,触发回调并传回终点坐标
onComplete?.Invoke(lastEndPos);
// 到达后销毁
if (projectile != null)
{
// 如果有粒子系统,先停止发射让残余拖尾自然消失,或者直接销毁
ParticleSystem[] ps = projectile.GetComponentsInChildren<ParticleSystem>();
if (ps.Length > 0)
{
foreach (var p in ps) p.Stop(true, ParticleSystemStopBehavior.StopEmitting);
Destroy(projectile, 1.0f); // 给一点时间让残余粒子消失
}
else
{
Destroy(projectile);
}
}
}
/// <summary>
/// 为特效物体及其子物体中的 TrailRenderer 和 ParticleSystem 应用颜色渐变
/// </summary>
private void ApplyColorToEffect(GameObject effectObj, Gradient gradient)
{
if (effectObj == null || gradient == null) return;
// 检查 Gradient 是否有效(至少有一个 alpha key 或 color key 且不全是透明)
// 如果用户没有设置颜色,则保持原样
if (gradient.colorKeys.Length <= 1 && gradient.alphaKeys.Length <= 1 &&
gradient.colorKeys[0].color == Color.white && gradient.alphaKeys[0].alpha == 0)
{
// 简单的空检查,如果 Gradient 看起来是默认未设置状态则跳过
// 注意:Unity 默认 Gradient 通常是全白不透明,这里可以根据需要调整判断
}
// 应用于 TrailRenderer
var trails = effectObj.GetComponentsInChildren<TrailRenderer>(true);
foreach (var trail in trails)
{
trail.colorGradient = gradient;
}
// 应用于 ParticleSystem (主要影响起始颜色)
var particles = effectObj.GetComponentsInChildren<ParticleSystem>(true);
foreach (var ps in particles)
{
var main = ps.main;
main.startColor = new ParticleSystem.MinMaxGradient(gradient);
}
}
/// <summary>
/// 为特效物体及其子物体中的渲染器应用透明度和层级
/// </summary>
private void ApplyTransparencyAndRender(GameObject fx, float transparency, int sortingOrder)
{
// 1. 处理所有普通渲染器 (MeshRenderer, SpriteRenderer 等)
Renderer[] renderers = fx.GetComponentsInChildren<Renderer>();
foreach (var r in renderers)
{
// 设置排序层级
r.sortingOrder = sortingOrder;
// 处理材质透明度
if (transparency < 1f)
{
foreach (var mat in r.materials)
{
if (mat.HasProperty("_Color"))
{
Color c = mat.color;
c.a *= transparency;
mat.color = c;
}
else if (mat.HasProperty("_BaseColor")) // URP 命名
{
Color c = mat.GetColor("_BaseColor");
c.a *= transparency;
mat.SetColor("_BaseColor", c);
}
}
}
}
// 2. 处理粒子系统颜色 (考虑不同模式)
ParticleSystem[] particles = fx.GetComponentsInChildren<ParticleSystem>();
foreach (var ps in particles)
{
var main = ps.main;
// 处理 Start Color
var startColor = main.startColor;
if (startColor.mode == ParticleSystemGradientMode.Color)
{
Color c = startColor.color;
c.a *= transparency;
main.startColor = c;
}
else if (startColor.mode == ParticleSystemGradientMode.TwoColors)
{
Color c1 = startColor.colorMin;
Color c2 = startColor.colorMax;
c1.a *= transparency;
c2.a *= transparency;
main.startColor = new ParticleSystem.MinMaxGradient(c1, c2);
}
}
// 3. 处理 TrailRenderer (拖尾特效的核心组件)
TrailRenderer[] trails = fx.GetComponentsInChildren<TrailRenderer>();
foreach (var trail in trails)
{
if (transparency < 1f)
{
Gradient gradient = trail.colorGradient;
GradientAlphaKey[] alphaKeys = gradient.alphaKeys;
for (int i = 0; i < alphaKeys.Length; i++)
{
alphaKeys[i].alpha *= transparency;
}
gradient.SetKeys(gradient.colorKeys, alphaKeys);
trail.colorGradient = gradient;
}
}
}
public void PlayKOFX(GameObject target)
{
if (target == null || k_o_fx == null)
{
if (k_o_fx == null) Debug.LogWarning("[GfxController] k_o_fx Prefab 未分配!");
return;
}
GameObject fx;
// 优先使用指定的 KO 父物体
if (koFatherObject != null)
{
// 控制父物体的缩放
koFatherObject.transform.localScale = Vector3.one * koFxScale;
fx = Instantiate(k_o_fx, koFatherObject.transform);
RectTransform rt = fx.GetComponent<RectTransform>();
if (rt != null)
{
rt.anchoredPosition = Vector2.zero;
rt.localRotation = Quaternion.identity;
rt.localScale = Vector3.one; // 预设物体缩放回归 1,由父物体控制
}
else
{
fx.transform.localPosition = Vector3.zero;
fx.transform.localRotation = Quaternion.identity;
fx.transform.localScale = Vector3.one;
}
}
else
{
// 备份逻辑:映射到 UI 挂载点
GameObject visualParent = ResolveGfxMountPoint(target);
if (visualParent != null)
{
fx = Instantiate(k_o_fx, visualParent.transform);
fx.transform.SetAsLastSibling();
RectTransform rt = fx.GetComponent<RectTransform>();
if (rt != null)
{
rt.anchoredPosition = Vector2.zero;
rt.localRotation = Quaternion.identity;
rt.localScale = Vector3.one * koFxScale;
}
else
{
fx.transform.localPosition = Vector3.zero;
fx.transform.localRotation = Quaternion.identity;
fx.transform.localScale = Vector3.one * koFxScale;
}
}
else
{
fx = Instantiate(k_o_fx, target.transform.position, Quaternion.identity);
fx.transform.localScale = Vector3.one * koFxScale;
}
}
// 动画播放完后销毁
Destroy(fx, 3.0f);
}
/// <summary>
/// 当 Miss 发生时,从敌人向指定角色发射弹道,并在到达时触发受击效果及实际扣血
/// </summary>
/// <returns>是否成功启动弹道逻辑。如果返回 false,调用方应执行保底扣血。</returns>
public bool PlayEnemyAttackOnMiss(int allySlotIndex, float damageAmount, AllyCombatant allyInstance)
{
// 尝试自动寻找敌人挂载点,如果未分配
if (enemyObject == null || !enemyObject.activeInHierarchy)
{
var enemy = GameObject.Find("thisEnemy");
if (enemy != null) enemyObject = enemy;
}
if (enemyObject == null || !enemyObject.activeInHierarchy)
{
Debug.LogWarning("[GfxController] enemyObject 为空且未能在场景中找到 'thisEnemy',无法播放 Miss 攻击特效。");
return false;
}
GameObject targetAllyObj = GetAllyObject(allySlotIndex);
if (targetAllyObj == null)
{
Debug.LogWarning($"[GfxController] 找不到槽位 {allySlotIndex} 的盟友物体。");
return false;
}
GameObject projectilePrefab = enemyMissProjectilePrefab != null ? enemyMissProjectilePrefab : projectileFX;
if (projectilePrefab == null)
{
Debug.LogWarning("[GfxController] 未分配任何弹道 Prefab。");
return false;
}
// 生成弹道
GameObject projectile = Instantiate(projectilePrefab, enemyObject.transform.position, Quaternion.identity);
projectile.transform.SetParent(this.transform, true);
projectile.transform.localScale = Vector3.one * enemyAttackProjectileScale;
// 应用层级
ApplyTransparencyAndRender(projectile, projectileTransparency, enemyAttackProjectileSortingOrder);
// 应用颜色
ApplyColorToEffect(projectile, enemyAttackProjectileColor);
// 启动移动协程
StartCoroutine(MoveEnemyAttackProjectileCoroutine(projectile, enemyObject.transform, targetAllyObj.transform, allySlotIndex, damageAmount, allyInstance));
return true;
}
private IEnumerator MoveEnemyAttackProjectileCoroutine(GameObject projectile, Transform start, Transform end, int allySlotIndex, float damageAmount, AllyCombatant allyInstance)
{
float elapsed = 0f;
Vector3 startPos = start.position;
Vector3 lastEndPos = startPos;
while (elapsed < enemyAttackProjectileDuration)
{
if (projectile == null) yield break;
elapsed += Time.deltaTime;
float normalizedTime = Mathf.Clamp01(elapsed / enemyAttackProjectileDuration);
float t = enemyAttackSpeedCurve.Evaluate(normalizedTime);
Vector3 currentEndPos = end != null ? end.position : lastEndPos;
lastEndPos = currentEndPos;
projectile.transform.position = Vector3.Lerp(startPos, currentEndPos, t);
// 朝向目标
Vector3 direction = currentEndPos - projectile.transform.position;
if (direction != Vector3.zero)
{
projectile.transform.rotation = Quaternion.LookRotation(Vector3.forward, direction);
}
yield return null;
}
// 到达终点
if (projectile != null)
{
// 停止粒子系统
ParticleSystem[] ps = projectile.GetComponentsInChildren<ParticleSystem>();
foreach (var p in ps) { var m = p.main; m.loop = false; }
Destroy(projectile, 0.5f);
}
// 播放 Hit 特效、闪红,并执行实际扣血
GameObject targetAllyObj = GetAllyObject(allySlotIndex);
if (targetAllyObj != null)
{
// 1. 播放 Hit FX 并触发闪红(PlayHitFX 内部已处理闪红)
PlayHitFX(targetAllyObj, null, false, lastEndPos, enemyAttackHitFxScale);
// 2. 执行实际扣血逻辑 (使用传入的实例,更准确)
if (allyInstance != null)
{
allyInstance.ReceiveDamage(damageAmount, null);
}
else
{
// 备选方案:尝试从物体获取
var ally = targetAllyObj.GetComponent<AllyCombatant>();
if (ally != null) ally.ReceiveDamage(damageAmount, null);
}
}
}
/// <summary>
/// 根据槽位索引获取盟友特效挂载点
/// </summary>
public GameObject GetAllyObject(int slotIndex)
{
switch (slotIndex)
{
case 0: return ally01Object;
case 1: return ally02Object;
case 2: return ally03Object;
case 3: return ally04Object;
case 4: return ally05Object;
default: return null;
}
}
/// <summary>
/// 获取盟友/敌人的移动父物体(用于震动)
/// </summary>
public GameObject GetMoveObject(GameObject targetObj)
{
if (targetObj == null) return null;
// 1. 检查是否是敌人(逻辑物体或受击挂载点)
if (targetObj.name == "thisEnemy" ||
targetObj == enemyObject ||
targetObj.GetComponent<EnemyCombatant>() != null)
{
return enemyMoveObject;
}
// 2. 检查是否是盟友逻辑物体
var ally = targetObj.GetComponent<AllyCombatant>();
if (ally != null)
{
return GetAllyMoveObject(ally.slotIndex);
}
// 3. 检查是否是盟友受击挂载点 (ally01Object - ally05Object)
if (targetObj == ally01Object) return ally01MoveObject;
if (targetObj == ally02Object) return ally02MoveObject;
if (targetObj == ally03Object) return ally03MoveObject;
if (targetObj == ally04Object) return ally04MoveObject;
if (targetObj == ally05Object) return ally05MoveObject;
// 4. 通过名称模式兜底识别
string n = targetObj.name.ToLower();
if (n.Contains("ally_01") || n.Contains("ally01")) return ally01MoveObject;
if (n.Contains("ally_02") || n.Contains("ally02")) return ally02MoveObject;
if (n.Contains("ally_03") || n.Contains("ally03")) return ally03MoveObject;
if (n.Contains("ally_04") || n.Contains("ally04")) return ally04MoveObject;
if (n.Contains("ally_05") || n.Contains("ally05")) return ally05MoveObject;
return null;
}
private GameObject GetAllyMoveObject(int slotIndex)
{
switch (slotIndex)
{
case 0: return ally01MoveObject;
case 1: return ally02MoveObject;
case 2: return ally03MoveObject;
case 3: return ally04MoveObject;
case 4: return ally05MoveObject;
default: return null;
}
}
/// <summary>
/// 触发指定目标的震动效果
/// </summary>
private void TriggerShake(GameObject target, bool isEnemy)
{
GameObject moveObj = GetMoveObject(target);
if (moveObj == null) return;
SmoothShake ss = moveObj.GetComponent<SmoothShake>();
if (ss == null) ss = moveObj.AddComponent<SmoothShake>();
// 修复:确保 Shaker 实例已初始化,避免 NullReferenceException
if (ss.positionShake == null) ss.positionShake = new Shaker();
if (ss.rotationShake == null) ss.rotationShake = new Shaker();
// 选择震动参数
Vector3 amplitude = isEnemy ? hitShakeAmplitude : allyHitShakeAmplitude;
Vector3 frequency = isEnemy ? hitShakeFrequency : allyHitShakeFrequency;
float duration = isEnemy ? hitShakeDuration : allyHitShakeDuration;
// 配置震动参数
ss.positionShake.noiseType = Shaker.NoiseType.SineWave;
ss.positionShake.amplitude = amplitude;
ss.positionShake.frequency = frequency;
// 旋转震动重置为0,防止干扰
ss.rotationShake.amplitude = Vector3.zero;
// 配置时间参数
ss.timeSettings.constantShake = false;
ss.timeSettings.fadeInDuration = 0.05f;
ss.timeSettings.holdDuration = duration * 0.4f;
ss.timeSettings.fadeOutDuration = duration * 0.55f;
// 确保动画曲线有值(否则震动无法淡出)
if (ss.timeSettings.fadeInCurve == null || ss.timeSettings.fadeInCurve.length == 0)
ss.timeSettings.fadeInCurve = AnimationCurve.Linear(0, 0, 1, 1);
if (ss.timeSettings.fadeOutCurve == null || ss.timeSettings.fadeOutCurve.length == 0)
ss.timeSettings.fadeOutCurve = AnimationCurve.Linear(0, 1, 1, 0);
// 初始化内部数组(插件在 AddComponent 时 Awake 已经运行,由于当时字段为空,需要重新同步数组)
ss.shakers = new Shaker[] { ss.positionShake, ss.rotationShake };
ss.sum = new Vector3[2];
// 启动震动
ss.StartShake();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 08299b53bd516b2458386f07dbbf4249
@@ -9,22 +9,25 @@ using UnityEditor;
public class settlementController : MonoBehaviour
{
[Header("管理器与基础引用")]
[Header("Inspector")]
[SerializeField] private BeatmapManager bmm;
[SerializeField] private ScoreManager sm;
public GameManager gm;
private SongData thisSong_so;
private int maxScore_sum = 2000000;
[Header("1000000")]
public int _1000000 = 1000000;
[Header("song image")]
[SerializeField] private Image thisSong_backPic;
[Header("跳转控制按钮")]
[Header("Inspector")]
public Button exit_toSelectSongs;
public Button replay_thisGame;
public Button display_rankList;
public Button share_toSocialMedia;
[Header("文本与进度显示")]
[Header("Text And Progress")]
public Text songName_Text;
[Tooltip("当前关卡的进度百分比")]
[Tooltip("Documentation text normalized.")]
public Text thisLevel_currentPercentage_Text;
public Text finalScore_Text;
public Text pmScoreSum_Text;
@@ -33,26 +36,26 @@ public class settlementController : MonoBehaviour
public Image thisLevel_progressBar_Image;
[Header("准确度权重算法")]
[Header("Accuracy Weights")]
public float perfect_weight = 1f;
public float great_weight = 0.6666667f;
public float good_weight = 0.333333f;
public float miss_weight = 0;
[Header("结算奖励信息")]
[Header("Inspector")]
public Text reward_playerEXP_Text;
public Text reward_money_Text;
public Text reward_idolEXP_bottle_Text;
[Header("队伍展示")]
[Header("Inspector")]
public loadSettlementTeamPrefab settlementTeamLoader;
public Image mvp_hero_hd_image;
[Header("结算获得金钱")]
[Header("Inspector")]
[SerializeField] private long moneyToGive_thisLevel;
// 得到评分统计
[Header("音符判定统计")]
// Documentation text normalized.
[Header("Inspector")]
public Text perfectHitCount_Text;
public Text perfectHitPercent_Text;
public Image perfect_barFill_Image;
@@ -76,35 +79,36 @@ public class settlementController : MonoBehaviour
public Text lateHitCount_Text;
public Text avgOffset_Text;
[Header("结算音频控制")]
[Tooltip("结算界面背景音乐,播放结算流程")]
[Header("Inspector")]
[Tooltip("Settlement background music clip used during the settlement flow.")]
public AudioSource settlementAudioSource;
[Tooltip("游戏背景音乐(通常是原游戏曲目)")]
[Tooltip("Original gameplay music source (usually the chart song). ")]
public AudioSource oriGameMusicSource;
[Tooltip("音频混音器,用于低通滤波等效果")]
[Tooltip("Documentation text normalized.")]
public AudioMixer gameMusicMixer;
[Tooltip("音频混音器中的低通滤波参数名")]
[Tooltip("Documentation text normalized.")]
public string lowpassParamName = "inGameMusic_lowpass";
[Tooltip("低通滤波器渐变持续时间(秒)")]
[Tooltip("Duration of lowpass transition in seconds.")]
public float lowpassFadeDuration = 2f;
[Tooltip("结算界面 CanvasGroup,用于淡入效果")]
[Tooltip("Settlement CanvasGroup used for fade-in.")]
public CanvasGroup settlementCanvasGroup;
[Header("结算控制组件")]
[Tooltip("结算控制管理器")]
[Header("Inspector")]
[Tooltip("Settlement control manager object.")]
public GameObject cdm;
private Coroutine musicTransitionCoroutine;
private Coroutine canvasFadeCoroutine;
private bool settlementUiInitialized = false;
private void Awake()
{
// 设置按钮监听
// Documentation text normalized.
if (replay_thisGame != null)
{
replay_thisGame.onClick.AddListener(OnReplayButtonClicked);
@@ -115,7 +119,7 @@ public class settlementController : MonoBehaviour
exit_toSelectSongs.onClick.AddListener(OnExitButtonClicked);
}
// 确保 CDM 对象在开始时处于关闭状态
// Documentation text normalized.
if (cdm != null)
{
cdm.SetActive(false);
@@ -129,7 +133,7 @@ public class settlementController : MonoBehaviour
private void Start()
{
// 确保 LowPass 在开始时处于原始位置(通常是 22000Hz,即不滤波)
// Documentation text normalized.
if (gameMusicMixer != null && !string.IsNullOrEmpty(lowpassParamName))
{
try { gameMusicMixer.SetFloat(lowpassParamName, 22000f); }
@@ -149,14 +153,14 @@ public class settlementController : MonoBehaviour
exit_toSelectSongs.onClick.RemoveListener(OnExitButtonClicked);
}
// 停止音乐过渡协程
// Documentation text normalized.
if (musicTransitionCoroutine != null)
{
StopCoroutine(musicTransitionCoroutine);
musicTransitionCoroutine = null;
}
// 停止 CanvasGroup 渐变协程
// Documentation text normalized.
if (canvasFadeCoroutine != null)
{
StopCoroutine(canvasFadeCoroutine);
@@ -175,10 +179,17 @@ public class settlementController : MonoBehaviour
public void startSettlement_uiUpdate()
{
// --- 确保结算界面开始时 CanvasGroup 为透明 ---
if (settlementUiInitialized)
{
Debug.Log("[SettlementController] startSettlement_uiUpdate ignored: settlement already initialized.");
return;
}
settlementUiInitialized = true;
// Documentation text normalized.
InitializeSettlementCanvas();
// --- 启用结算控制管理器 ---
// Documentation text normalized.
if (cdm != null)
{
cdm.SetActive(true);
@@ -191,7 +202,7 @@ public class settlementController : MonoBehaviour
if (gm != null) gm.RecordTotalPlayTime();
else { var activeGM = FindAnyObjectByType<GameManager>(); if (activeGM != null) activeGM.RecordTotalPlayTime(); }
// 准备结算音乐(在UI更新之前)
// Documentation text normalized.
PrepareSettlementMusic();
if(sm != null)
@@ -202,8 +213,8 @@ public class settlementController : MonoBehaviour
finalScore_Text.text = (sm.allSum_pmScore + sm.allSum_idolScore).ToString();
pmScoreSum_Text.text = sm.allSum_pmScore.ToString();
idolScoreSum_Text.text = sm.allSum_idolScore.ToString();
thisLevel_currentPercentage_Text.text = ((float)(sm.allSum_pmScore + sm.allSum_idolScore) / maxScore_sum * 100).ToString("F2") + "%";
thisLevel_progressBar_Image.fillAmount = (float)(sm.allSum_pmScore + sm.allSum_idolScore) / maxScore_sum;
thisLevel_currentPercentage_Text.text = ((float)(sm.allSum_pmScore + sm.allSum_idolScore) / _1000000 * 100).ToString("F2") + "%";
thisLevel_progressBar_Image.fillAmount = (float)(sm.allSum_pmScore + sm.allSum_idolScore) / _1000000;
int noteCountSum = sm.countPerfect + sm.countGreat + sm.countGood + sm.countMiss;
@@ -217,7 +228,7 @@ public class settlementController : MonoBehaviour
good_barFill_Image.fillAmount = (float)sm.countGood / noteCountSum;
miss_barFill_Image.fillAmount = (float)sm.countMiss / noteCountSum;
gameNote_rate.text = "已完成: " + noteCountSum.ToString() + "/" + bmm.parsedNoteAmount.ToString();
gameNote_rate.text = "宸插畬鎴? " + noteCountSum.ToString() + "/" + bmm.parsedNoteAmount.ToString();
perfectHitPercent_Text.text = ((float)sm.countPerfect / noteCountSum * 100).ToString("F1") + "%";
greatHitPercent_Text.text = ((float)sm.countGreat / noteCountSum * 100).ToString("F1") + "%";
@@ -295,19 +306,19 @@ public class settlementController : MonoBehaviour
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)
{
// 进度计算 (0..1)
// Documentation text normalized.
float newProgress = Mathf.Clamp01((float)total / (float)maxScore_sum);
if (newProgress > entry.levelProgressForThisDifficulty)
{
@@ -320,6 +331,9 @@ public class settlementController : MonoBehaviour
}
}
}
// --- 更新:保存到持久化存储(Build包必需) ---
thisSong_so.SavePersistent();
}
}
@@ -340,17 +354,17 @@ public class settlementController : MonoBehaviour
catch (System.Exception ex) { Debug.LogWarning("Failed to PopulateSettlementCards: " + ex); }
}
// 初始化结算完成,在界面UI显示后
// 尝试选择 MVP 英雄的大图
// Documentation text normalized.
// Documentation text normalized.
SetMvpHeroImageFromTopScorer();
StartMusicTransition();
// --- 修改:在所有逻辑末尾开始 CanvasGroup 渐变 ---
// Documentation text normalized.
StartCanvasFadeIn();
}
/// <summary>
/// 初始化结算界面的 CanvasGroup 为透明状态
/// Documentation text normalized.
/// </summary>
private void InitializeSettlementCanvas()
{
@@ -367,16 +381,14 @@ public class settlementController : MonoBehaviour
}
}
// 缓存 AllyHero_SO 数组,避免在结算界面多次调用昂贵的 Resources.LoadAll
// Documentation text normalized.
private static AllyHero_SO[] _cachedAllyHeroSOs;
/// <summary>
/// 从所有dlcData中查找并准备当前歌曲所属DLC的结算音乐
/// Documentation text normalized.
/// </summary>
private void PrepareSettlementMusic()
{
// --- 修改:移除 CanvasGroup 相关设置,已移动到结算逻辑末尾调用 ---
if (thisSong_so == null)
{
Debug.LogWarning("[SettlementController] thisSong_so is null, cannot prepare settlement music");
@@ -389,58 +401,48 @@ public class settlementController : MonoBehaviour
return;
}
// 使用异步或分帧逻辑来查找 DLC
StartCoroutine(PrepareSettlementMusicRoutine());
}
private IEnumerator PrepareSettlementMusicRoutine()
{
// 加载 dlcData ScriptableObjects
dlcData[] allDlcs = Resources.LoadAll<dlcData>("");
dlcData foundDlc = null;
// 遍历所有DLC数据以找到包含当前歌曲的DLC
for (int i = 0; i < allDlcs.Length; i++)
{
var dlc = allDlcs[i];
dlcData dlc = allDlcs[i];
if (dlc == null || dlc.songList == null) continue;
if (dlc.songList.Contains(thisSong_so))
{
foundDlc = dlc;
break;
}
// 每处理 20 个 DLC 等待一帧
if (i > 0 && i % 20 == 0) yield return null;
if (!dlc.songList.Contains(thisSong_so)) continue;
foundDlc = dlc;
break;
}
if (foundDlc == null)
{
Debug.LogWarning($"[SettlementController] No DLC found containing song '{thisSong_so.songName}'");
yield break;
return;
}
if (foundDlc.settlementMusic == null)
{
Debug.LogWarning($"[SettlementController] DLC '{foundDlc.dlcName}' does not have settlement music assigned");
yield break;
return;
}
bool clipChanged = settlementAudioSource.clip != foundDlc.settlementMusic;
if (clipChanged)
{
settlementAudioSource.Stop();
settlementAudioSource.clip = foundDlc.settlementMusic;
settlementAudioSource.time = 0f;
}
// 设置结算音乐的AudioSource
settlementAudioSource.clip = foundDlc.settlementMusic;
settlementAudioSource.loop = true;
settlementAudioSource.playOnAwake = false;
// 初始音量并静音等待播放
settlementAudioSource.volume = 1f;
settlementAudioSource.mute = true;
settlementAudioSource.Stop();
settlementAudioSource.mute = false;
Debug.Log($"[SettlementController] Prepared settlement music from DLC '{foundDlc.dlcName}': {foundDlc.settlementMusic.name}");
}
/// <summary>
/// 开始音乐过渡,将原游戏背景音乐淡出,然后播放结算音乐
/// Documentation text normalized.
/// </summary>
private void StartMusicTransition()
{
@@ -452,69 +454,79 @@ public class settlementController : MonoBehaviour
}
/// <summary>
/// 音乐过渡协程:
/// 1. 将游戏音乐的低通滤波器从 22000Hz 逐渐降至 0Hz,同时音量降至 0
/// 2. 渐变完成后停止原音轨并开始播放结算音乐
/// Documentation text normalized.
/// Documentation text normalized.
/// Documentation text normalized.
/// </summary>
private IEnumerator MusicTransitionRoutine()
{
float elapsed = 0f;
float startLowpass = 22000f;
float endLowpass = 0f;
float transitionDuration = Mathf.Max(0.01f, lowpassFadeDuration);
// --- 获取当前音量作为起始点 ---
float startVolume = (oriGameMusicSource != null) ? oriGameMusicSource.volume : 1f;
bool settlementMusicStarted = false;
float settlementStartVolume = 0f;
// 如果原游戏音轨和混音器参数存在,执行低通滤波和音量淡出
if (oriGameMusicSource != null && gameMusicMixer != null && !string.IsNullOrEmpty(lowpassParamName))
{
Debug.Log($"[SettlementController] Starting lowpass and volume fade over {lowpassFadeDuration}s");
while (elapsed < lowpassFadeDuration)
{
elapsed += Time.unscaledDeltaTime;
float t = Mathf.Clamp01(elapsed / Mathf.Max(0.0001f, lowpassFadeDuration));
// 1. 设置低通滤波器
float currentLowpass = Mathf.Lerp(startLowpass, endLowpass, t);
try { gameMusicMixer.SetFloat(lowpassParamName, currentLowpass); }
catch (System.Exception ex) { Debug.LogWarning($"Mixer error: {ex.Message}"); }
// 2. --- 修改:平滑降低音量 ---
oriGameMusicSource.volume = Mathf.Lerp(startVolume, 0f, t);
yield return null;
}
// 确保最终状态
try { gameMusicMixer.SetFloat(lowpassParamName, endLowpass); } catch { }
// --- 修改:音量归零并暂停/停止 ---
oriGameMusicSource.volume = 0f;
oriGameMusicSource.Pause(); // 也可以使用 .Stop()
Debug.Log("[SettlementController] Lowpass and volume fade complete, game music paused.");
}
else
{
Debug.LogWarning("[SettlementController] oriGameMusicSource or gameMusicMixer not assigned, skipping fade");
}
// 过渡完成后,开始播放结算音乐
// Start settlement music immediately to avoid delayed playback at settlement start.
if (settlementAudioSource != null && settlementAudioSource.clip != null)
{
try
{
settlementAudioSource.mute = false;
if (!settlementAudioSource.isPlaying)
{
settlementAudioSource.volume = 0f;
settlementAudioSource.Play();
settlementStartVolume = 0f;
}
else
{
settlementStartVolume = Mathf.Clamp01(settlementAudioSource.volume);
}
settlementMusicStarted = true;
Debug.Log($"[SettlementController] Settlement music active: {settlementAudioSource.clip.name}");
}
catch (System.Exception ex)
{
Debug.LogWarning($"[SettlementController] Failed to start settlement music immediately: {ex.Message}");
}
}
if (oriGameMusicSource != null)
{
Debug.Log($"[SettlementController] Starting game-music fade out over {transitionDuration}s");
while (elapsed < transitionDuration)
{
elapsed += Time.unscaledDeltaTime;
float t = Mathf.Clamp01(elapsed / transitionDuration);
oriGameMusicSource.volume = Mathf.Lerp(startVolume, 0f, t);
if (settlementMusicStarted && settlementAudioSource != null)
settlementAudioSource.volume = Mathf.Lerp(settlementStartVolume, 1f, t);
yield return null;
}
oriGameMusicSource.volume = 0f;
oriGameMusicSource.Pause();
Debug.Log("[SettlementController] Game music fade complete, game music paused.");
}
else
{
Debug.LogWarning("[SettlementController] oriGameMusicSource not assigned, skipping game music fade");
if (settlementMusicStarted && settlementAudioSource != null)
settlementAudioSource.volume = 1f;
}
if (!settlementMusicStarted && settlementAudioSource != null && settlementAudioSource.clip != null)
{
try
{
settlementAudioSource.mute = false;
settlementAudioSource.volume = 1f;
settlementAudioSource.Play();
Debug.Log($"[SettlementController] Settlement music started: {settlementAudioSource.clip.name}");
// --- 一旦结算音乐开始播放,立刻将 LowPass 设置回原位确保音频正常 ---
if (gameMusicMixer != null && !string.IsNullOrEmpty(lowpassParamName))
{
gameMusicMixer.SetFloat(lowpassParamName, 22000f);
Debug.Log("[SettlementController] Reset lowpass to 22000Hz for normal audio quality.");
}
}
catch (System.Exception ex)
{
@@ -526,7 +538,7 @@ public class settlementController : MonoBehaviour
}
/// <summary>
/// 开始 CanvasGroup 渐入效果
/// Documentation text normalized.
/// </summary>
private void StartCanvasFadeIn()
{
@@ -538,7 +550,7 @@ public class settlementController : MonoBehaviour
}
/// <summary>
/// CanvasGroup 渐入协程:0.25秒内从 alpha=0 渐变到 alpha=1
/// Documentation text normalized.
/// </summary>
private IEnumerator CanvasFadeInRoutine()
{
@@ -563,7 +575,7 @@ public class settlementController : MonoBehaviour
yield return null;
}
// ȷ״̬
// Documentation text normalized.
settlementCanvasGroup.alpha = targetAlpha;
settlementCanvasGroup.interactable = true;
settlementCanvasGroup.blocksRaycasts = true;
@@ -574,13 +586,13 @@ public class settlementController : MonoBehaviour
}
/// <summary>
/// 重新开始当前游戏 - 重新加载场景并重置游戏状态
/// Documentation text normalized.
/// </summary>
private void OnReplayButtonClicked()
{
Debug.Log("[SettlementController] Replay button clicked - restarting current game");
// 验证必要引用
// Documentation text normalized.
if (bmm == null)
{
Debug.LogError("[SettlementController] BeatmapManager is null, cannot replay");
@@ -593,7 +605,7 @@ public class settlementController : MonoBehaviour
return;
}
// 保存当前关卡信息用于重新加载
// Documentation text normalized.
SongData songToReplay = bmm.assignedSongData;
int difficultyToReplay = bmm.assignedDifficulty;
@@ -605,16 +617,16 @@ public class settlementController : MonoBehaviour
Debug.Log($"[SettlementController] Reloading song: {songToReplay.songName}, difficulty: {difficultyToReplay}");
// 停止结算音乐
// Documentation text normalized.
if (settlementAudioSource != null && settlementAudioSource.isPlaying)
{
settlementAudioSource.Stop();
}
// 设置 BeatmapManager 为下一次加载准备
// Documentation text normalized.
BeatmapManager.SetPendingSong(songToReplay, difficultyToReplay);
// 重新加载当前场景
// Documentation text normalized.
StartCoroutine(LoadSceneAsync(SceneManager.GetActiveScene().name));
}
@@ -628,19 +640,19 @@ public class settlementController : MonoBehaviour
}
/// <summary>
/// 返回选曲界面
/// Documentation text normalized.
/// </summary>
private void OnExitButtonClicked()
{
Debug.Log("[SettlementController] Exit button clicked - returning to song selection");
// 停止结算音乐
// Documentation text normalized.
if (settlementAudioSource != null && settlementAudioSource.isPlaying)
{
settlementAudioSource.Stop();
}
// 清除任何待处理的歌曲信息
// Documentation text normalized.
BeatmapManager.pendingSongData = null;
BeatmapManager.pendingDifficulty = -1;
@@ -702,8 +714,8 @@ public class settlementController : MonoBehaviour
}
/// <summary>
/// 根据各角色(按槽位选出最高分的角色),设置 AllyHero_SO 中的 ally_hero_HD_image 赋值给 mvp_hero_hd_image
/// 这里使用 ScoreManager 的 per-track pm sums,如果没有则从场景中的 AllyCombatant.currentScore 读取
/// Documentation text normalized.
/// Documentation text normalized.
/// </summary>
private void SetMvpHeroImageFromTopScorer()
{
@@ -814,3 +826,4 @@ public class settlementController : MonoBehaviour
}
}
}