成就系统 巨大修改 新的ui 黑白效果 等一堆

This commit is contained in:
FloatGaming
2026-01-23 18:43:23 +08:00
parent f3ece026ca
commit 0e47864569
256 changed files with 134131 additions and 31192 deletions
@@ -39,13 +39,17 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
private Transform yellowSpawn;
private Transform purpleSpawn;
private Transform blueSpawn;
// Track last scene to detect scene changes
private int lastSceneIndex = -1;
private void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
// 注意:仅当第一次创建时保留,允许重新加载
// DontDestroyOnLoad(gameObject); // ← 移除此行以允许场景重新加载时的清理
}
else if (Instance != this)
{
@@ -54,29 +58,48 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
}
CacheSpawnPoints();
lastSceneIndex = UnityEngine.SceneManagement.SceneManager.GetActiveScene().buildIndex;
}
private void Start()
{
// Ensure spawn points are cached after all scene objects are initialized
if (redSpawn == null && redEffect != null)
{
CacheSpawnPoints();
}
CacheSpawnPointsIfNeeded();
}
private void OnEnable()
{
// In case this object survives scene changes / references are assigned late.
if (Instance == null)
{
Instance = this;
}
if (redSpawn == null && redEffect != null)
// Detect scene reload and refresh cache
int currentSceneIndex = UnityEngine.SceneManagement.SceneManager.GetActiveScene().buildIndex;
if (currentSceneIndex != lastSceneIndex)
{
if (GameConfig.verboseLogs) Debug.Log($"[Animation_Generate] Scene changed detected (from {lastSceneIndex} to {currentSceneIndex}), refreshing spawn point cache");
lastSceneIndex = currentSceneIndex;
CacheSpawnPoints();
}
else
{
CacheSpawnPointsIfNeeded();
}
}
private void CacheSpawnPointsIfNeeded()
{
// Only recache if any of the references appear to be invalid (destroyed)
if (redSpawn == null || greenSpawn == null || yellowSpawn == null || purpleSpawn == null || blueSpawn == null)
{
// Check if the effect GameObjects are still valid
bool anyInvalid = (redEffect == null || redEffect.transform == null) ||
(greenEffect == null || greenEffect.transform == null) ||
(yellowEffect == null || yellowEffect.transform == null) ||
(purpleEffect == null || purpleEffect.transform == null) ||
(blueEffect == null || blueEffect.transform == null);
if (anyInvalid || redSpawn == null)
{
CacheSpawnPoints();
}
}
}
private void CacheSpawnPoints()
@@ -217,12 +240,8 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
{
if (string.IsNullOrEmpty(color)) return null;
// If references were destroyed (Unity fake null) try to rebuild cache once.
if (redSpawn == null && greenSpawn == null && yellowSpawn == null && purpleSpawn == null && blueSpawn == null)
{
if (GameConfig.verboseLogs) Debug.Log("[Animation_Generate] Rebuilding spawn point cache");
CacheSpawnPoints();
}
// 在每次调用时检查是否需要重新缓存
CacheSpawnPointsIfNeeded();
Transform point = null;
switch (color.ToLower())
@@ -36,14 +36,19 @@ public class BeatmapManager : MonoBehaviour
Debug.LogWarning($"BeatmapManager.AcceptSongData: accepted song={(song==null?"NULL":song.songName)}, difficulty={difficulty}");
}
public Beatmap beatmap; // 当前谱面数据
public Beatmap beatmap; // ǰ
// 音符生成器引用
//
public NoteSpawner noteSpawner;
// teamUIController 引用,直接拖拽赋值
// teamUIController ãֱקֵ
public teamUIController uiController;
public Image bgSpriteImage; // ͼƬ
public GameObject bgSpriteObject;
public Image startCanvas_image;
// Extra fields from beatmap JSON (stored temporarily)
[HideInInspector] public string parsedTitle;
[HideInInspector] public string parsedComposer;
@@ -77,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("Scales the note amount to enemy HP. Example: NoteAmount * MaxMultiplier * BaseScale TotalHP")]
public float baseHpUnitScale = 1f;
// Total chart score and per-note score fields
@@ -85,23 +90,23 @@ public class BeatmapManager : MonoBehaviour
public int perNoteScore;
public int leftoverScore;
// JSON 文件加载谱面数据
// JSON ļ
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 字符串加载谱面(允许任意磁盘路径先读取后传入)
// ԭʼ JSON ַ·ȶȡ
public void LoadBeatmapFromJsonString(string json)
{
if (string.IsNullOrEmpty(json))
@@ -112,7 +117,7 @@ public class BeatmapManager : MonoBehaviour
ProcessJsonAndLoad(json);
}
// 解析 JSON 但不启动 NoteSpawner(用于 test mode 的延迟开始)
// JSON NoteSpawner test mode ӳٿʼ
public bool ParseJsonOnly(string json)
{
if (string.IsNullOrEmpty(json))
@@ -129,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;
}
@@ -203,22 +208,22 @@ public class BeatmapManager : MonoBehaviour
beatmap = parsed;
Debug.Log("ParseJsonOnly: parsed beatmap " + beatmap.title);
// 调用统一的敌人处理逻辑(包含 HP 计算和设置)
// ͳһĵ˴߼ HP ã
SetupEnemiesAndHP();
return true;
}
// 直接传入已经解析的 Beatmap(兼容旧调用)
// ֱӴѾ Beatmapݾɵã
public void LoadBeatmap(Beatmap loadedBeatmap)
{
beatmap = loadedBeatmap;
Debug.Log("谱面已加载(来自 Beatmap 对象):" + (beatmap != null ? beatmap.title : "null"));
Debug.Log("Ѽأ Beatmap 󣩣" + (beatmap != null ? beatmap.title : "null"));
// No globalDelaySeconds available in this path. Call NoteSpawner directly.
noteSpawner.LoadBeatmap(beatmap);
}
// 读取并加载当前谱面到音符生成器(备用)
// ȡصǰã
public void LoadBeatmapFromFile()
{
string path = Application.streamingAssetsPath + "/Emilia_demo.json";
@@ -229,11 +234,11 @@ public class BeatmapManager : MonoBehaviour
}
else
{
Debug.LogError("谱面文件未找到!");
Debug.LogError("ļδҵ");
}
}
// 新增:从 TextAsset 解析并根据 parseOnly 决定是否只是解析或直接加载
// TextAsset parseOnly ǷֻǽֱӼ
public bool LoadBeatmapFromTextAsset(TextAsset chartAsset, bool parseOnly = false)
{
if (chartAsset == null)
@@ -257,7 +262,7 @@ public class BeatmapManager : MonoBehaviour
}
}
// 新增:从 SongData chart TextAsset 加载谱面
// SongData chart TextAsset
public bool LoadBeatmapFromSongData(SongData song, int difficulty, bool parseOnly = false)
{
if (song == null)
@@ -275,18 +280,18 @@ public class BeatmapManager : MonoBehaviour
return LoadBeatmapFromTextAsset(ta, parseOnly);
}
// Start() 方法中初始化
// Start() гʼ
void Start()
{
// 被动接收上一个场景传递过来的 SongData(通过 BeatmapManager.pendingSongData
// һݹ SongDataͨ BeatmapManager.pendingSongData
if (pendingSongData != null)
{
Debug.LogWarning($"BeatmapManager.Start: pendingSongData detected: {pendingSongData.songName}, difficulty={pendingDifficulty}");
// pending 显示到 Inspector 字段,便于检查
// pending ʾ Inspector ֶΣڼ
assignedSongData = pendingSongData;
assignedDifficulty = pendingDifficulty;
// 尝试解析(只解析,不立即生成音符)
// Խֻ
bool ok = LoadBeatmapFromSongData(assignedSongData, assignedDifficulty, true);
if (!ok) Debug.LogWarning("BeatmapManager.Start: LoadBeatmapFromSongData failed");
else
@@ -328,6 +333,41 @@ public class BeatmapManager : MonoBehaviour
Debug.LogWarning("GameManager or its musicSource not found; audio not assigned");
}
// Assign fullscreen image from SongData to bgSprite if available
if (assignedSongData != null && assignedSongData.fullscreen_songPicture != null)
{
// Prefer setting a Scene GameObject's SpriteRenderer if provided
if (bgSpriteObject != null)
{
var sr = bgSpriteObject.GetComponentInChildren<SpriteRenderer>();
if (sr != null)
{
sr.sprite = assignedSongData.fullscreen_songPicture;
// ensure fully visible
sr.color = new Color(sr.color.r, sr.color.g, sr.color.b, 1f);
if (startCanvas_image != null)
{
startCanvas_image.sprite = assignedSongData.fullscreen_songPicture;
}
sr = null;
Debug.LogWarning($"Assigned SongData.fullscreen_songPicture to SpriteRenderer on bgSpriteObject for song {assignedSongData.songName}");
}
else
{
Debug.LogWarning("bgSpriteObject has no SpriteRenderer in children; cannot assign fullscreen sprite");
}
}
else if (bgSpriteImage != null)
{
bgSpriteImage.sprite = assignedSongData.fullscreen_songPicture;
Debug.LogWarning($"Assigned SongData.fullscreen_songPicture to bgSpriteImage for song {assignedSongData.songName}");
}
else
{
Debug.LogWarning("No bg target (bgSpriteObject or bgSpriteImage) assigned; fullscreen sprite not applied");
}
}
// Pause the system to show pause overlay (PauseManager will enable overlayRoot)
var pauseMgr = PauseManager.Instance ?? FindObjectOfType<PauseManager>();
if (pauseMgr != null)
@@ -346,20 +386,20 @@ public class BeatmapManager : MonoBehaviour
pendingDifficulty = -1;
}
// 如果处于测试模式,则跳过自动加载谱面
// ڲģʽԶ
if (GameConfig.testMode)
{
Debug.Log("Test mode active: skipping automatic beatmap load in BeatmapManager.Start");
}
else
{
// 原先默认加载的调试谱面已注释,改为仅在需要时手动恢复
// LoadBeatmap("Emilia_demo.json"); // 加载并实例化音符
// ԭĬϼصĵעͣΪҪʱֶָ
// LoadBeatmap("Emilia_demo.json"); // زʵ
Debug.LogWarning("Default automatic demo beatmap load is commented out to prioritize SO-provided charts.");
}
}
// 获取当前难度乘数
// ȡǰѶȳ
private float GetCurrentDifficultyMultiplier()
{
if (parsedDifficulty == 1) return ezMultiplier;
@@ -369,7 +409,7 @@ public class BeatmapManager : MonoBehaviour
return 1f;
}
// 统一处理敌人 ID 提取、发送给 UI 以及 HP 计算的逻辑
// ͳһ ID ȡ͸ UI Լ HP ߼
private void SetupEnemiesAndHP()
{
if (uiController == null)
@@ -438,13 +478,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;
}
@@ -497,10 +537,13 @@ public class BeatmapManager : MonoBehaviour
Debug.LogWarning("BeatmapExtra not found in JSON; populated limited fields from Beatmap.");
}
// Ensure amount and color statistics are populated even if missing from JSON
ValidateAndPopulateStatistics(parsed);
CalculateNoteScores();
beatmap = parsed;
Debug.Log("谱面已加载:" + beatmap.title);
Debug.Log("Ѽأ" + beatmap.title);
if (noteSpawner != null) noteSpawner.LoadBeatmap(beatmap);
else Debug.LogError("NoteSpawner is null in BeatmapManager.");
@@ -524,6 +567,39 @@ public class BeatmapManager : MonoBehaviour
}
}
private void ValidateAndPopulateStatistics(Beatmap parsed)
{
if (parsed == null || parsed.notes == null) return;
// Ensure note amount is correct
if (parsedNoteAmount <= 0)
{
parsedNoteAmount = parsed.notes.Length;
if (GameConfig.verboseLogs) Debug.Log($"[BeatmapManager] Calculated missing noteAmount: {parsedNoteAmount}");
}
// Ensure note statistics (counts by color) are populated
if (parsedNoteStatistics == null || parsedNoteStatistics.Length == 0)
{
var counts = new System.Collections.Generic.Dictionary<string, int>();
foreach (var note in parsed.notes)
{
if (string.IsNullOrEmpty(note.color)) continue;
string c = note.color.ToLower();
if (!counts.ContainsKey(c)) counts[c] = 0;
counts[c]++;
}
parsedNoteStatistics = new NoteStatistic[counts.Count];
int i = 0;
foreach (var kvp in counts)
{
parsedNoteStatistics[i++] = new NoteStatistic { colorType = kvp.Key, count = kvp.Value };
}
if (GameConfig.verboseLogs) Debug.Log($"[BeatmapManager] Calculated missing noteStatistics for {counts.Count} colors");
}
}
[System.Serializable]
private class BeatmapExtra
{
+524 -76
View File
@@ -3,24 +3,41 @@ using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using UnityEngine.Networking;
using UnityEngine.SceneManagement;
using UnityEngineInternal;
public class GameManager : MonoBehaviour
{
public BeatmapManager beatmapManager; // غ͹
public NoteSpawner noteSpawner; //
public AudioSource musicSource; // ֲ
public BeatmapManager beatmapManager; // Beatmap manager
public NoteSpawner noteSpawner; // Note spawner
public AudioSource musicSource; // Music audio source
public readyLetsGo readyLetsGo;
[Header("paths")]
// ٴӾ̬жȡ
// Paths for external test-mode resources
public string JSONPath;
public string audioPath;
public string bgPicPath;
// Optional: sprite renderer to show background image
[Header("canvas")]
public GameObject startCanvas;
public CanvasGroup cg_startCanvas;
public float canvasFadeTime;
private Coroutine fade_canvasGroup;
public CanvasGroup settleCG;
[Header("buttons")]
public Button startGame;
public Button backtoSelectingPage;
[Header("sprite renderer")]
public SpriteRenderer backgroundRenderer;
// NEW: UI Text to display banflag content from pressStart
[Header("Debug / Test")]
public Text banflagTextUI;
// new: display current song name on UI when assigning background
public Text songnameText;
[Header("UI Overlays")]
public UnityEngine.UI.Image blackMaskImage; // assign in inspector: full-screen black overlay
@@ -29,9 +46,25 @@ public class GameManager : MonoBehaviour
[Tooltip("Delay in seconds after unpausing (press Space) before starting audio and spawning. Configure in inspector.")]
public float playbackStartDelay = 3f;
[Tooltip("Delay in seconds before starting the black mask fade out. Configure in inspector.")]
public float blackMaskFadeStartDelay = 1.5f;
private bool pauseSubscribed = false;
private bool musicWasPlayingBeforePause = false;
// tracks whether actual music playback (the real game playback) has started
public bool PlaybackStarted { get; private set; } = false;
// Flag set by UI button to request start
private bool startRequested = false;
// Flag to control whether Escape can trigger return (disabled once startRequested is true)
private bool allowEscapeReturn = true;
// --- Statistics Tracking ---
private float sessionStartTime;
private SongData currentSong;
private bool timeRecorded = false;
private void SubscribeToPauseManager()
{
if (pauseSubscribed) return;
@@ -79,10 +112,15 @@ public class GameManager : MonoBehaviour
{
musicSource.Pause();
}
// Also pause Time.timeScale so playback delay coroutines are also paused
Time.timeScale = 0f;
}
else
{
// Do not change mute state here. External startup flow will unmute when appropriate.
// Resume Time.timeScale first
Time.timeScale = 1f;
// resume if it was playing before pause
if (musicWasPlayingBeforePause)
{
@@ -130,94 +168,194 @@ public class GameManager : MonoBehaviour
blackMaskImage.gameObject.SetActive(false);
}
private Coroutine playbackDelayCoroutine;
// Public helper to reliably unmute and start music playback, respecting delay.
public void PlayMusicWithDelay(float delaySeconds)
{
// reset flag until actual play occurs
PlaybackStarted = false;
// Stop any previous delay coroutine
if (playbackDelayCoroutine != null)
{
StopCoroutine(playbackDelayCoroutine);
playbackDelayCoroutine = null;
}
if (musicSource == null) return;
// ✅ FIX: Force stop any existing playback before unmute
try
{
// Ensure unmuted
musicSource.Stop();
musicSource.time = 0f;
}
catch { }
try
{
// ✅ FIX: Ensure unmuted BEFORE attempting to play
musicSource.mute = false;
Debug.Log("GameManager.PlayMusicWithDelay: musicSource unmuted");
}
catch { }
try
{
// If a delay is specified, stop current and schedule playback
// Reset audio time to 0 before playing to ensure playback starts from the beginning
musicSource.time = 0f;
if (delaySeconds > 0f)
{
// Stop any currently playing to avoid overlapping schedules
try { musicSource.Stop(); } catch { }
musicSource.PlayDelayed(delaySeconds);
Debug.Log($"GameManager.PlayMusicWithDelay: PlayDelayed({delaySeconds}) called");
// Use coroutine instead of PlayDelayed so pause state can interrupt it
playbackDelayCoroutine = StartCoroutine(DelayAndPlayMusic(delaySeconds));
}
else
{
// If already playing but paused, try UnPause; otherwise Play
if (musicSource.isPlaying)
{
// already playing - nothing to do
Debug.Log("GameManager.PlayMusicWithDelay: musicSource already playing");
}
else
{
// Try UnPause first (in case it was paused), otherwise Play
try { musicSource.UnPause(); Debug.Log("GameManager.PlayMusicWithDelay: UnPause called"); }
catch { musicSource.Play(); Debug.Log("GameManager.PlayMusicWithDelay: Play called"); }
}
// Immediate playback (no delay)
musicSource.time = 0f;
musicSource.Play();
PlaybackStarted = true;
Debug.Log("GameManager.PlayMusicWithDelay: Music playback started immediately (no delay)");
}
}
catch (System.Exception ex)
{
Debug.LogWarning($"PlayMusicWithDelay failed: {ex}");
try { musicSource.Play(); } catch { }
try
{
musicSource.time = 0f;
musicSource.Play();
PlaybackStarted = true;
}
catch { }
}
}
private IEnumerator DelayAndPlayMusic(float delaySeconds)
{
Debug.Log($"GameManager.DelayAndPlayMusic: waiting {delaySeconds} seconds before playing (respects pause state)");
// Wait using unscaled real time so this delay is not blocked when Time.timeScale==0
float elapsed = 0f;
while (elapsed < delaySeconds)
{
elapsed += Time.unscaledDeltaTime;
yield return null;
}
// After delay, play the music
if (musicSource != null)
{
try
{
// ✅ FIX: Double-check unmute before actual playback
musicSource.mute = false;
musicSource.time = 0f;
musicSource.Play();
PlaybackStarted = true;
Debug.Log($"GameManager.DelayAndPlayMusic: music playback started after {delaySeconds}s delay, mute={musicSource.mute}");
}
catch (System.Exception ex)
{
Debug.LogWarning($"DelayAndPlayMusic failed to play: {ex}");
}
}
playbackDelayCoroutine = null;
}
void Start()
{
Debug.Log("GameManager Start() ");
Debug.Log("GameManager Start() initialized");
// ؼǷΪ
if (beatmapManager == null) Debug.LogError("beatmapManager δֵ");
if (noteSpawner == null) Debug.LogError("noteSpawner δֵ");
if (musicSource == null) Debug.LogError("musicSource δֵ");
// --- Initialize Statistics ---
currentSong = BeatmapManager.pendingSongData ?? SongDataHolder.SelectedSongData;
if (currentSong != null)
{
currentSong.game_enterTimes++;
sessionStartTime = Time.realtimeSinceStartup;
Debug.Log($"[GameManager] {currentSong.songName} launch count incremented to {currentSong.game_enterTimes}");
}
else
{
Debug.LogWarning("[GameManager] No SongData found for statistics tracking");
}
// Ensure start canvas and its CanvasGroup are active and visible at scene start
if (startCanvas != null && !startCanvas.activeSelf) startCanvas.SetActive(true);
if (cg_startCanvas != null)
{
cg_startCanvas.alpha = 1f;
cg_startCanvas.interactable = true;
cg_startCanvas.blocksRaycasts = true;
}
// Try to apply assigned SongData (if any) to background and UI after scene load
StartCoroutine(WaitAndApplyAssignedSong(2f));
if (beatmapManager == null) Debug.LogError("beatmapManager is null");
if (noteSpawner == null) Debug.LogError("noteSpawner is null");
if (musicSource == null) Debug.LogError("musicSource is null");
// Subscribe to NoteSpawner.AllNotesSpawned to trigger action when spawning completes
if (noteSpawner != null)
{
noteSpawner.AllNotesSpawned += OnAllNotesSpawned;
}
// Wire up UI buttons if assigned
if (startGame != null)
{
// ensure we don't register duplicate listeners
try { startGame.onClick.RemoveListener(RequestStart); } catch { }
startGame.onClick.AddListener(RequestStart);
}
if (backtoSelectingPage != null)
{
try { backtoSelectingPage.onClick.RemoveListener(BackToSelectingPage); } catch { }
backtoSelectingPage.onClick.AddListener(BackToSelectingPage);
}
// preload audio fully and start muted playback to warm up audio decoding
if (musicSource != null && musicSource.clip != null)
{
musicSource.mute = true;
musicSource.loop = false;
musicSource.playOnAwake = false;
try
{
// Warm up decoding by playing briefly muted so the audio system decodes the clip.
musicSource.mute = true;
musicSource.Play();
Debug.Log("GameManager: warmed up audio playback (muted)");
// ✅ FIX: Stop immediately after warmup to avoid interference with later playback
// Wait a frame to ensure audio system processes the Play command
StartCoroutine(StopWarmupAfterFrame());
}
catch (System.Exception ex)
{
Debug.LogWarning("GameManager: failed to warm up audio: " + ex.Message);
}
settleCG.alpha = 0;
}
// pressStart Ѿȡ banflag ݣʾ UIˣ
// display banflag content if available
if (banflagTextUI != null && !string.IsNullOrEmpty(pressStart.banflagContent))
{
banflagTextUI.text = pressStart.banflagContent;
Debug.Log("Displayed banflag content from pressStart into banflagTextUI.");
}
// test mode ʹⲿ·ԤԴͣϵͳȴո
// choose startup path
if (GameConfig.testMode)
{
// ӳٸֵ Start() лȡ̬·ʱ pressStart.cs Ѿв
JSONPath = pressStart.testmode_beatmapJSON_path;
audioPath = pressStart.testmode_audioFile_path;
bgPicPath = pressStart.testmode_bgPicFile_path;
Debug.Log("Test mode active: using external paths to preload assets");
// also update banflag UI if content exists
if (banflagTextUI != null && !string.IsNullOrEmpty(pressStart.banflagContent))
{
banflagTextUI.text = pressStart.banflagContent;
@@ -241,13 +379,167 @@ public class GameManager : MonoBehaviour
blackMaskImage.color = cc;
blackMaskImage.gameObject.SetActive(true);
blackMaskImage.raycastTarget = true;
// start automatic fade from alpha=1 to 0 over 1 second after Start
StartCoroutine(FadeOutBlackMask(1f));
// start automatic fade from alpha=1 to 0 over 1 second after Start, with delay
StartCoroutine(DelayedFadeOutBlackMask(blackMaskFadeStartDelay, 1f));
}
catch { }
}
}
/// <summary>
/// Helper coroutine to stop audio warmup after one frame and unmute the source
/// </summary>
private IEnumerator StopWarmupAfterFrame()
{
yield return null; // wait one frame for audio system to process Play command
if (musicSource != null)
{
try
{
musicSource.Stop();
musicSource.mute = false; // ✅ Unmute immediately after warmup
musicSource.time = 0f;
Debug.Log("GameManager: warmup completed, audio stopped and unmuted");
}
catch (System.Exception ex)
{
Debug.LogWarning("GameManager: failed to stop warmup: " + ex.Message);
}
}
}
private void Update()
{
// Detect Space or yellow note key to request start (only before gameplay starts)
if (!startRequested)
{
KeyCode yellowKey = KeyBindingManager.GetKeyForColor("yellow");
// Check Space key
if (Input.GetKeyDown(KeyCode.Space))
{
RequestStart();
}
// Check yellow track key from key binding manager
else if (yellowKey != KeyCode.None && Input.GetKeyDown(yellowKey))
{
if (yellowKey == KeyCode.Space) return;
else RequestStart();
}
// Check Escape key to trigger return to selecting page (only before gameplay starts and allowed)
if (allowEscapeReturn && Input.GetKeyDown(KeyCode.Escape))
{
BackToSelectingPage();
}
}
// Once startRequested is true, Escape is disabled and gameplay begins countdown
}
private void OnDestroy()
{
RecordTotalPlayTime();
// Unsubscribe from NoteSpawner.AllNotesSpawned to avoid memory leaks
if (noteSpawner != null)
{
noteSpawner.AllNotesSpawned -= OnAllNotesSpawned;
}
// remove UI listeners
if (startGame != null)
{
startGame.onClick.RemoveListener(RequestStart);
}
if (backtoSelectingPage != null)
{
backtoSelectingPage.onClick.RemoveListener(BackToSelectingPage);
}
// stop canvas fade coroutine if running
if (fade_canvasGroup != null)
{
StopCoroutine(fade_canvasGroup);
fade_canvasGroup = null;
}
}
// Start the fade coroutine for the start canvas group (safe to call multiple times)
private void StartFadeStartCanvas()
{
if (cg_startCanvas == null || startCanvas == null) return;
if (fade_canvasGroup != null) StopCoroutine(fade_canvasGroup);
fade_canvasGroup = StartCoroutine(FadeStartCanvasCoroutine(canvasFadeTime));
}
private IEnumerator FadeStartCanvasCoroutine(float duration)
{
if (cg_startCanvas == null || startCanvas == null) yield break;
float elapsed = 0f;
float startA = cg_startCanvas.alpha;
while (elapsed < duration)
{
elapsed += Time.unscaledDeltaTime;
float frac = Mathf.Clamp01(elapsed / Mathf.Max(0.0001f, duration));
cg_startCanvas.alpha = Mathf.Lerp(startA, 0f, frac);
yield return null;
}
cg_startCanvas.alpha = 0f;
cg_startCanvas.interactable = false;
cg_startCanvas.blocksRaycasts = false;
startCanvas.SetActive(false);
fade_canvasGroup = null;
}
private void OnAllNotesSpawned()
{
// All notes have been spawned - trigger settlement decision logic
Debug.Log("[GameManager] All notes have been spawned. Initiating settlement countdown.");
if (noteSpawner != null)
{
noteSpawner.StartSettlementRoutine();
}
else
{
Debug.LogWarning("[GameManager] OnAllNotesSpawned: noteSpawner is null, cannot start settlement routine");
}
}
// Called by UI button to request the start countdown
public void RequestStart()
{
// Guard: prevent duplicate RequestStart execution
if (startRequested)
{
Debug.LogWarning("[GameManager] RequestStart already called, ignoring duplicate request");
return;
}
Debug.Log("[GameManager] RequestStart executed");
startRequested = true;
allowEscapeReturn = false; // Disable Escape immediately when start is requested
// Play ready sequence if available (null-check to avoid NullReferenceException)
if (readyLetsGo != null)
{
try
{
readyLetsGo.PlaySequence();
Debug.Log("[GameManager] readyLetsGo.PlaySequence() triggered");
}
catch (System.Exception ex)
{
Debug.LogError($"[GameManager] Error: {ex}");
}
}
else
{
Debug.LogWarning("[GameManager] readyLetsGo is null");
}
}
private IEnumerator HandleTestModeStartup()
{
// Preload audio from audioPath (absolute)
@@ -320,15 +612,16 @@ public class GameManager : MonoBehaviour
musicSource.clip = clip;
musicSource.loop = false;
musicSource.playOnAwake = false;
// Warm up decoding by playing briefly muted; avoid try/catch around yield (not allowed).
// Warm up decoding by playing briefly muted so the audio system decodes the clip.
musicSource.mute = true;
musicSource.Play();
// allow a few frames for audio system to start
yield return null;
musicSource.Stop();
Debug.Log("TestMode audio loaded into AudioSource");
// ✅ FIX: Stop immediately after warmup to avoid interference with later playback
StartCoroutine(StopWarmupAfterFrame());
// After loading audio, enable overlay and pause via PauseManager
var pm = PauseManager.Instance ?? FindObjectOfType<PauseManager>();
if (pm != null)
@@ -365,7 +658,7 @@ public class GameManager : MonoBehaviour
}
catch (System.Exception ex)
{
Debug.LogError($"ȡͼƬļ: {ex}");
Debug.LogError($"Failed to read background image file: {ex}");
}
if (imgBytes != null)
@@ -376,29 +669,34 @@ public class GameManager : MonoBehaviour
Sprite s = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f));
backgroundRenderer.sprite = s;
// ޸ĵ㣺 SpriteRenderer ͸Ϊ 255 (1.0f)
// ensure SpriteRenderer alpha is fully opaque
Color color = backgroundRenderer.color;
color.a = 1.0f;
backgroundRenderer.color = color;
// ֱӦɳ־¼ assignment
Debug.Log("Background sprite assigned (ensure your SpriteRenderer size fits scene)");
// Set song name text if available
if (songnameText != null)
{
songnameText.text = Path.GetFileNameWithoutExtension(bgPicPath);
}
}
else
{
Debug.LogError($"޷ֽڴ Texture2D: {bgPicPath}");
Debug.LogError($"Failed to create Texture2D from bytes: {bgPicPath}");
}
}
}
}
else
{
Debug.LogWarning("TestMode bgPicPath δá");
Debug.LogWarning("TestMode bgPicPath not provided");
}
}
else
{
Debug.LogWarning("backgroundRenderer δãء");
Debug.LogWarning("backgroundRenderer not assigned; skipping background preload");
}
// Read JSON from JSONPath and parse via BeatmapManager.ParseJsonOnly
@@ -417,14 +715,14 @@ public class GameManager : MonoBehaviour
}
catch (System.Exception ex)
{
Debug.LogError($"ȡ TestMode JSON ļ: {ex}");
Debug.LogError($"Failed to read TestMode JSON file: {ex}");
yield break;
}
bool parsed = beatmapManager.ParseJsonOnly(jsonContent);
if (!parsed)
{
Debug.LogError("ParseJsonOnly ʧܣֹͣģʽ");
Debug.LogError("ParseJsonOnly failed; aborting test mode startup");
yield break;
}
@@ -433,7 +731,7 @@ public class GameManager : MonoBehaviour
}
else
{
Debug.LogError("TestMode JSONPath δá");
Debug.LogError("TestMode JSONPath not provided");
yield break;
}
@@ -443,16 +741,25 @@ public class GameManager : MonoBehaviour
PauseManager.Instance?.Pause(true);
UpdateStatusOnConsole("Paused: press Space to start playback");
// Wait for space key down (use unscaled input loop)
while (!Input.GetKeyDown(KeyCode.Space))
// Determine yellow track key from key binding manager
KeyCode yellowKey = KeyBindingManager.GetKeyForColor("yellow");
// Wait for start request: Space, yellow key, or UI button
while (!startRequested && !Input.GetKeyDown(KeyCode.Space) && (yellowKey == KeyCode.None || !Input.GetKeyDown(yellowKey)))
{
yield return null;
}
// Clear request flag
startRequested = false;
// Kick off UI canvas fade-out when start requested
StartFadeStartCanvas();
// Resume using PauseManager
PauseManager.Instance?.Pause(false);
// small buffer to avoid hitching immediately after unpause: wait configured realtime delay
yield return new WaitForSecondsRealtime(Mathf.Max(0f, playbackStartDelay));
// Use scaled wait so pause (Escape) will pause this delay
yield return new WaitForSeconds(Mathf.Max(0f, playbackStartDelay));
// Start spawning using the parsed beatmap
if (beatmapManager.beatmap != null)
@@ -461,7 +768,7 @@ public class GameManager : MonoBehaviour
}
else
{
Debug.LogError("ʧܣbeatmap δΪ");
Debug.LogError("Beatmap missing after parsing");
yield break;
}
@@ -484,16 +791,25 @@ public class GameManager : MonoBehaviour
PauseManager.Instance?.Pause(true);
UpdateStatusOnConsole("Paused: press Space to start playback");
// Wait for space
while (!Input.GetKeyDown(KeyCode.Space))
// Determine yellow track key from key binding manager
KeyCode yellowKey = KeyBindingManager.GetKeyForColor("yellow");
// Wait for start request: Space, yellow key, or UI button
while (!startRequested && !Input.GetKeyDown(KeyCode.Space) && (yellowKey == KeyCode.None || !Input.GetKeyDown(yellowKey)))
{
yield return null;
}
// Clear request flag
startRequested = false;
// Kick off UI canvas fade-out when start requested
StartFadeStartCanvas();
// Resume
PauseManager.Instance?.Pause(false);
// small buffer to avoid hitching immediately after unpause: wait configured realtime delay
yield return new WaitForSecondsRealtime(Mathf.Max(0f, playbackStartDelay));
// Use scaled wait so pause (Escape) will pause this delay
yield return new WaitForSeconds(Mathf.Max(0f, playbackStartDelay));
// Start spawning using the existing parsed beatmap
if (beatmapManager.beatmap != null)
@@ -542,12 +858,12 @@ public class GameManager : MonoBehaviour
yield break;
}
// ԭ̣
// Original default demo load commented out; manual load only when needed
string beatmapFilePath = Path.Combine(Application.streamingAssetsPath, "Emilia_demo.json");
if (!File.Exists(beatmapFilePath))
{
Debug.LogError($"ļ: {beatmapFilePath}");
Debug.LogError($"Beatmap file missing: {beatmapFilePath}");
yield break;
}
@@ -555,55 +871,62 @@ public class GameManager : MonoBehaviour
bool parsed = beatmapManager.ParseJsonOnly(json);
if (!parsed)
{
Debug.LogError("ParseJsonOnly ʧ");
Debug.LogError("ParseJsonOnly failed");
yield break;
}
// ͣϵͳ
// Pause system
SubscribeToPauseManager();
PauseManager.Instance?.Pause(true);
UpdateStatusOnConsole("Paused: press Space to start playback");
// ȴո
while (!Input.GetKeyDown(KeyCode.Space))
// Determine yellow track key from key binding manager
KeyCode yellowKey2 = KeyBindingManager.GetKeyForColor("yellow");
// Wait for start request: Space, yellow key, or UI button
while (!startRequested && !Input.GetKeyDown(KeyCode.Space) && (yellowKey2 == KeyCode.None || !Input.GetKeyDown(yellowKey2)))
{
yield return null;
}
// ָ
PauseManager.Instance?.Pause(false);
// small buffer to avoid hitching immediately after unpause: wait configured realtime delay
yield return new WaitForSecondsRealtime(Mathf.Max(0f, playbackStartDelay));
// Clear request flag
startRequested = false;
//
// Kick off UI canvas fade-out when start requested
StartFadeStartCanvas();
// Resume
PauseManager.Instance?.Pause(false);
// Use scaled wait so pause (Escape) will pause this delay
yield return new WaitForSeconds(Mathf.Max(0f, playbackStartDelay));
// Start spawning
if (beatmapManager.beatmap != null)
{
noteSpawner.LoadBeatmap(beatmapManager.beatmap);
}
else
{
Debug.LogError("beatmap δ");
Debug.LogError("beatmap missing");
yield break;
}
// ű֣ӳٲ beatmapManager.globalDelaySeconds
// Load and play audio
if (!string.IsNullOrEmpty(beatmapManager.parsedMusicFile))
{
AudioClip musicClip = Resources.Load<AudioClip>(beatmapManager.parsedMusicFile);
if (musicClip == null)
{
Debug.LogError($"ļʧ: {beatmapManager.parsedMusicFile}");
Debug.LogError($"Failed to load parsed music file: {beatmapManager.parsedMusicFile}");
}
else
{
musicSource.clip = musicClip;
// ensure we don't accidentally autoplay when assigning clips
musicSource.playOnAwake = false;
musicSource.Stop();
float delay = beatmapManager.globalDelaySeconds;
PlayMusicWithDelay(delay);
// After assigning clip, enable overlay and pause
var pm3 = PauseManager.Instance ?? FindObjectOfType<PauseManager>();
if (pm3 != null)
{
@@ -614,7 +937,7 @@ public class GameManager : MonoBehaviour
}
else
{
Debug.LogError(" musicFile Ϊգ");
Debug.LogError("parsed musicFile empty");
}
UpdateStatusOnConsole("Playback started");
@@ -624,4 +947,129 @@ public class GameManager : MonoBehaviour
{
Debug.Log(message);
}
/// <summary>
/// Delay then fade out the black mask image. Waits for delaySeconds, then fades out over fadeDuration seconds (unscaled).
/// </summary>
private IEnumerator DelayedFadeOutBlackMask(float delaySeconds, float fadeDuration)
{
// Wait for the specified delay
float elapsed = 0f;
while (elapsed < delaySeconds)
{
elapsed += Time.unscaledDeltaTime;
yield return null;
}
// After delay, start the fade out
yield return StartCoroutine(FadeOutBlackMask(fadeDuration));
}
// Called when the back button is pressed; fades to black then loads selection scene
private void BackToSelectingPage()
{
RecordTotalPlayTime();
// restore time scale to normal so UI animations driven by unscaled or scaled time behave correctly
try { Time.timeScale = 1f; } catch { }
// start coroutine to fade mask then load
if (blackMaskImage == null)
{
SceneManager.LoadScene("selectYourSongFirst");
return;
}
StartCoroutine(FadeToBlackAndLoad("selectYourSongFirst", 0.25f));
}
private IEnumerator FadeToBlackAndLoad(string sceneName, float duration)
{
RecordTotalPlayTime();
if (blackMaskImage == null)
{
SceneManager.LoadScene(sceneName);
yield break;
}
// ensure image active and start from current alpha
if (!blackMaskImage.gameObject.activeSelf) blackMaskImage.gameObject.SetActive(true);
float startA = blackMaskImage.color.a;
float elapsed = 0f;
// enable raycast target while fading to block input
try { blackMaskImage.raycastTarget = true; } catch {}
while (elapsed < duration)
{
elapsed += Time.unscaledDeltaTime;
float frac = Mathf.Clamp01(elapsed / Mathf.Max(0.0001f, duration));
Color c = blackMaskImage.color;
c.a = Mathf.Lerp(startA, 1f, frac);
blackMaskImage.color = c;
yield return null;
}
// ensure fully opaque
Color fc = blackMaskImage.color;
fc.a = 1f;
blackMaskImage.color = fc;
// load scene
SceneManager.LoadScene(sceneName);
}
private IEnumerator WaitAndApplyAssignedSong(float timeoutSeconds)
{
float waited = 0f;
while (waited < timeoutSeconds)
{
if (beatmapManager != null && beatmapManager.assignedSongData != null)
{
ApplyAssignedSongToUI(beatmapManager.assignedSongData);
yield break;
}
waited += Time.unscaledDeltaTime;
yield return null;
}
// also consider pendingSongData static as fallback
if (BeatmapManager.pendingSongData != null)
{
ApplyAssignedSongToUI(BeatmapManager.pendingSongData);
}
}
private void ApplyAssignedSongToUI(SongData sd)
{
if (sd == null) return;
// set background sprite if present
if (backgroundRenderer != null && sd.fullscreen_songPicture != null)
{
backgroundRenderer.sprite = sd.fullscreen_songPicture;
Color color = backgroundRenderer.color;
color.a = 1f;
backgroundRenderer.color = color;
}
// set song name text if available
if (songnameText != null)
{
songnameText.text = sd.songName;
}
}
/// <summary>
/// Records the elapsed play time since the session started and adds it to the song's total play time.
/// Can be called multiple times, but only records once per session.
/// </summary>
public void RecordTotalPlayTime()
{
if (timeRecorded || currentSong == null) return;
float elapsed = Time.realtimeSinceStartup - sessionStartTime;
currentSong.time_totalPlayingTime += elapsed;
timeRecorded = true;
Debug.Log($"[GameManager] Recorded {elapsed:F2}s to total play time for {currentSong.songName}. New total: {currentSong.time_totalPlayingTime:F2}s");
}
}
+122 -46
View File
@@ -228,18 +228,10 @@ public class HoldNote : BaseNote
if (controller != null)
{
controller.ConfigureTiming(time, delay, travelTime);
// If the activation time is already in the past at the moment of Setup,
// the segment should have already traveled some distance. In that case
// snap its position forward so it appears at the correct location.
// Do this only when activation happened before now to avoid snapping
// freshly spawned segments that haven't started moving yet.
if (controller.ActivationTime < Time.time - 0.0001f)
{
// Use current transform.position as the spawn origin (NoteSpawner sets this before Setup).
CalibratePosition(transform.position, 0f);
}
// Keep all hold segments moving in absolute time so they stay visually connected.
float headActivationTime = time - travelTime;
float baseSpawnYOffset = -speed * delay;
controller.ConfigureAbsolutePositioning(transform.position, headActivationTime, baseSpawnYOffset);
}
else
{
@@ -273,15 +265,8 @@ public class HoldNote : BaseNote
{
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] START auto-Miss: {noteColor}");
// register as missed
JudgeManager.Instance.RegisterStartJudged(noteID, false);
InputManager.Instance?.ShowJudgeResult(trackIndex, "Miss");
JudgeSoundManager.Instance?.PlayJudgeSound("Miss");
teamUIController.Instance?.OnJudgeResult("Miss");
// Show judgement prefab for auto-miss as well (consistent with other notes)
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, "Miss");
// Register miss properly via EvaluateHoldEnd
EvaluateHoldEnd(Time.time, true);
isJudged = true;
ScheduleReturnToPool(0.2f);
@@ -291,6 +276,7 @@ public class HoldNote : BaseNote
{
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] END auto-Miss: {noteColor}");
HandleEnd(true);
// HandleEnd/EvaluateHoldEnd will release lock as needed
isJudged = true;
}
}
@@ -411,6 +397,23 @@ 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 (GameConfig.verboseLogs) Debug.Log($"[HoldNote] End entered while key still held: {noteColor}, calculating result immediately");
// Record release time as current time (player is still holding)
releaseTime = Time.time;
hasReleased = true;
JudgeManager.Instance?.RegisterNoteReleased(noteID, true);
// Evaluate and judge the hold end
EvaluateHoldEnd(releaseTime, false);
isJudged = true;
// Terminate input - mark key as released
isHoldActive = false;
AnimationController.Global?.StopHoldParticles();
return;
}
autoReturnCoroutine = StartCoroutine(DelayedAutoReturnCheck());
}
}
@@ -536,15 +539,19 @@ public class HoldNote : BaseNote
if (forceMiss)
{
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] START forced Miss: {noteColor}");
ScoreManager.Instance.countMiss += 1;
JudgeManager.Instance.RegisterStartJudged(noteID, false);
isHoldActive = false;
// Use EvaluateHoldEnd to centralize miss logic and statistics
EvaluateHoldEnd(Time.time, true);
// Still need to show judge result and update combo for the head segment
InputManager.Instance?.ShowJudgeResult(trackIndex, "Miss");
JudgeSoundManager.Instance?.PlayJudgeSound("Miss");
teamUIController.Instance?.OnJudgeResult("Miss");
// IMPORTANT: release lock immediately on miss
ReleaseTrackJudgeLockSafe();
// Also show judgement prefab for forced miss on start
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, "Miss");
// Return soon
ScheduleReturnToPool(0.2f);
return;
}
@@ -561,7 +568,11 @@ public class HoldNote : BaseNote
if (offset <= pRange)
{
result = "Perfect";
// Score statistics for Hold notes are now handled exclusively in EvaluateHoldEnd
// to ensure they only count as 1 note in the total sum.
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
// RecordOffset removed from here to prevent double counting.
// It will be called once in EvaluateHoldEnd.
JudgeManager.Instance.RegisterStartJudged(noteID, true);
isHoldActive = true;
PlayHitAnimation();
@@ -577,6 +588,7 @@ public class HoldNote : BaseNote
{
result = "Great";
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
// RecordOffset removed from here to prevent double counting
JudgeManager.Instance.RegisterStartJudged(noteID, true);
isHoldActive = true;
PlayHitAnimation();
@@ -590,6 +602,7 @@ public class HoldNote : BaseNote
{
result = "Good";
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
// RecordOffset removed from here to prevent double counting
JudgeManager.Instance.RegisterStartJudged(noteID, true);
isHoldActive = true;
PlayHitAnimation();
@@ -602,9 +615,13 @@ public class HoldNote : BaseNote
else
{
result = "Miss";
ScoreManager.Instance.countMiss += 1;
// For a Miss at the start, we immediately evaluate the end as a Miss too
// to ensure ScoreManager records exactly one Miss for this hold note.
JudgeManager.Instance.RegisterStartJudged(noteID, false);
isHoldActive = false;
EvaluateHoldEnd(Time.time, true);
try
{
bool triggered = SkillBuilder.Instance?.NotifyNoteHit(this.trackIndex, result, SkillDefinition.NoteTypeTrigger.Tap, this.noteID) ?? false;
@@ -614,11 +631,20 @@ public class HoldNote : BaseNote
{
Debug.LogError($"[HoldNote] NotifyNoteHit(Start Miss) threw: {ex}");
}
// IMPORTANT: release lock immediately on miss
ReleaseTrackJudgeLockSafe();
}
// show judge result and update combo/UI like short notes
InputManager.Instance?.ShowJudgeResult(trackIndex, result);
teamUIController.Instance?.OnJudgeResult(result);
// Only call OnJudgeResult on head if it's a hit, so it counts as 1 combo for the whole note.
// If it's a Miss, EvaluateHoldEnd(..., true) already handled the OnJudgeResult("Miss").
if (result != "Miss")
{
teamUIController.Instance?.OnJudgeResult(result);
}
// Play judge sound only for START segment
if (segment == NoteSegment.Start)
@@ -636,6 +662,12 @@ public class HoldNote : BaseNote
{
StartCoroutine(DelayedReturn());
}
// If start head was judged as Miss, ensure we return soon so it can't keep stale state
if (segment == NoteSegment.Start && result == "Miss")
{
ScheduleReturnToPool(0.2f);
}
}
private IEnumerator DelayedReturn()
@@ -672,7 +704,15 @@ public class HoldNote : BaseNote
{
result = "Miss";
ScoreManager.Instance.countMiss += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackMissCounts[trackIndex]++;
// Record a 0 offset for Miss to ensure total Offset Count matches note count
ScoreManager.Instance?.RecordOffset(0);
if (GameConfig.verboseLogs) Debug.LogWarning($"[HoldNote] END judged as Miss: {noteColor} reason={(forceMiss ? "forced Miss" : "start not judged")}");
// IMPORTANT: release lock on end miss too
ReleaseTrackJudgeLockSafe();
}
else
{
@@ -687,21 +727,30 @@ public class HoldNote : BaseNote
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++;
}
else if (frac > 0.5f)
{
result = "Great";
ScoreManager.Instance.countGreat += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++;
}
else
{
result = "Good";
ScoreManager.Instance.countGood += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++;
}
if (noteData != null && result != "Miss")
{
noteData.judgeOffsetMsEnd = (info.scheduledEnd - releaseTime) * 1000f;
float offsetEnd = (info.scheduledEnd - releaseTime) * 1000f;
noteData.judgeOffsetMsEnd = offsetEnd;
// Record the head offset (stored during HandleStart) as the representative timing for this note
float headOffset = noteData.judgeOffsetMs;
if (float.IsNaN(headOffset)) headOffset = 0;
ScoreManager.Instance?.RecordOffset(headOffset);
}
// cleanup pool
@@ -719,31 +768,45 @@ public class HoldNote : BaseNote
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++;
}
else if (frac > 0.5f)
{
result = "Great";
ScoreManager.Instance.countGreat += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++;
}
else
{
result = "Good";
ScoreManager.Instance.countGood += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++;
}
float rawOffsetMsEnd = (scheduledEndTime - actualReleaseTime) * 1000f;
if (noteData != null && result != "Miss")
{
noteData.judgeOffsetMsEnd = rawOffsetMsEnd;
// Fallback: use head offset if available, otherwise 0
float headOffset = noteData.judgeOffsetMs;
if (float.IsNaN(headOffset)) headOffset = 0;
ScoreManager.Instance?.RecordOffset(headOffset);
}
}
}
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] END result for {noteID}: {result} (color={noteColor})");
// show UI/audio and combo
// show UI/audio
InputManager.Instance?.ShowJudgeResult(trackIndex, result);
teamUIController.Instance?.OnJudgeResult(result);
// ONLY call OnJudgeResult on end if it's a Miss, to break the combo started at the head.
// If it's a hit, we don't call it again to avoid double-counting combo.
if (result == "Miss")
{
teamUIController.Instance?.OnJudgeResult(result);
}
// Ensure END always spawns judgement prefab (including Miss)
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, result);
@@ -830,11 +893,6 @@ public class HoldNote : BaseNote
private void HandleEnd(bool forceMiss = false)
{
if (!JudgeManager.Instance.TryResolveEnd(noteID))
{
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] END try-resolve failed: {noteColor}");
return;
}
// record release time and register release in JudgeManager
if (!hasReleased)
{
@@ -865,12 +923,9 @@ public class HoldNote : BaseNote
}
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] ReturnToPool called for {noteID} segment={segment} time={Time.time:F3}");
// Release lock if still held
if (TrackKeyManager.Instance != null)
{
TrackKeyManager.Instance.UnlockTrackForJudge(trackIndex, noteID);
}
// CRITICAL: Always release lock before returning to pool, regardless of state
ReleaseTrackJudgeLockSafe();
gameObject.SetActive(false);
@@ -884,6 +939,9 @@ public class HoldNote : BaseNote
public void ResetState()
{
// also release any stale lock from previous lifecycle
ReleaseTrackJudgeLockSafe();
hasReleased = false;
segment = NoteSegment.None;
keyToPress = KeyCode.None;
@@ -910,6 +968,15 @@ public class HoldNote : BaseNote
ResetVisualScale();
}
private void ReleaseTrackJudgeLockSafe()
{
// Defensive: any miss / pooling path should release the track lock.
if (TrackKeyManager.Instance != null && !string.IsNullOrEmpty(noteID))
{
TrackKeyManager.Instance.UnlockTrackForJudge(trackIndex, noteID);
}
}
public void ApplyVisualScale(float scaleY)
{
float s = Mathf.Clamp(scaleY, 0.1f, 4f);
@@ -942,12 +1009,21 @@ public class HoldNote : BaseNote
float activation = controller.ActivationTime;
float s = controller.CurrentSpeed;
float elapsed = Mathf.Max(0f, Time.time - activation);
Vector3 expected = spawnPointPosition + Vector3.down * s * elapsed;
float elapsed = Time.time - activation;
Vector3 expected = spawnPointPosition;
if (controller.UsesAbsolutePositioning)
{
expected.y -= (controller.BaseSpawnYOffset + s * elapsed);
}
else
{
expected += Vector3.down * s * Mathf.Max(0f, elapsed);
}
if (Vector3.Distance(transform.position, expected) > tolerance)
{
transform.position = expected;
}
}
}
}
@@ -6,12 +6,24 @@ public class HoldNoteController : MonoBehaviour
private bool isInsideJudgeZone = false;
public event Action<bool> OnJudgeZoneChanged;
private bool isMoving = false;
private float speed = 0f; // 运动速度
private float activationTime; // 何时开始移动(Time.time 基准)
private float speed = 0f;
private float activationTime;
// Absolute positioning for hold segments (keeps segments connected under frame spikes)
private bool useAbsolutePositioning = false;
private Vector3 spawnPosition;
private float baseSpawnYOffset = 0f; // can be negative to offset upward for delayed segments
private void Update()
{
// 到达激活时间后开始移动
if (useAbsolutePositioning)
{
if (!isMoving) return;
ApplyAbsolutePosition(Time.time);
return;
}
// Start moving after activation time in legacy mode
if (!isMoving && Time.time >= activationTime)
{
isMoving = true;
@@ -24,26 +36,24 @@ public class HoldNoteController : MonoBehaviour
}
/// <summary>
/// 设置该段的“到达判定线的目标时间”(hitTime) 与分段延迟(delay),并由此推导移动开始时间。
/// 注意:这里必须使用与 HoldNote.hitTime 一致的时间基准(NoteSpawner 传入的谱面实时点),
/// 否则用 Time.time + delay 会在首段/暂停/开歌延迟时产生系统性错位,导致永远 Miss。
/// Configure legacy timing (per-segment activation).
/// </summary>
public void ConfigureTiming(float baseHitTime, float segmentDelay, float travelTime)
{
// baseHitTime = startTime + noteData.time (+ globalHitDelay)
float hitTime = baseHitTime + segmentDelay;
activationTime = hitTime - travelTime;
isMoving = false;
useAbsolutePositioning = false;
}
/// <summary>
/// 兼容旧接口:不推荐。旧版用 Time.time 会导致时间基准不一致。
/// Legacy delay setter (not recommended).
/// </summary>
public void SetSegmentDelay(float segmentDelay)
{
// Fallback to old behavior if caller didn't use ConfigureTiming.
activationTime = Time.time + segmentDelay;
isMoving = false;
useAbsolutePositioning = false;
}
public void SetSpeed(float newSpeed)
@@ -51,6 +61,23 @@ public class HoldNoteController : MonoBehaviour
speed = newSpeed;
}
/// <summary>
/// Configure absolute positioning for hold segments so they stay connected.
/// spawnPoint should be the original spawn point for the lane.
/// baseSpawnYOffset is applied to keep delayed segments offset upward (can be negative).
/// </summary>
public void ConfigureAbsolutePositioning(Vector3 spawnPoint, float newActivationTime, float spawnYOffset)
{
spawnPosition = spawnPoint;
activationTime = newActivationTime;
baseSpawnYOffset = spawnYOffset;
useAbsolutePositioning = true;
isMoving = true;
// Snap immediately to the expected position to avoid a 1-frame pop.
ApplyAbsolutePosition(Time.time);
}
public void StopMovement()
{
isMoving = false;
@@ -79,4 +106,17 @@ public class HoldNoteController : MonoBehaviour
public float ActivationTime => activationTime;
public float CurrentSpeed => speed;
public bool UsesAbsolutePositioning => useAbsolutePositioning;
public float BaseSpawnYOffset => baseSpawnYOffset;
public Vector3 SpawnPosition => spawnPosition;
private void ApplyAbsolutePosition(float time)
{
float elapsed = time - activationTime;
float travelDistance = speed * elapsed;
Vector3 newPos = spawnPosition;
newPos.y -= (baseSpawnYOffset + travelDistance);
transform.position = newPos;
}
}
@@ -93,6 +93,29 @@ public class InputManager : MonoBehaviour
}
}
/// <summary>
/// Force a release event for all bound keys and reset UI indicators.
/// Useful for external "clear input" operations where the system should treat
/// all keys as released regardless of physical state.
/// </summary>
public void ForceReleaseAllKeys()
{
string[] colors = new string[] { "red", "green", "yellow", "purple", "blue" };
foreach (var color in colors)
{
KeyCode key = KeyBindingManager.GetKeyForColor(color);
if (key == KeyCode.None) continue;
try { OnKeyReleased?.Invoke(key); } catch { }
int index = GetIndexForColor(color);
if (trackKeyTexts != null && index >= 0 && index < trackKeyTexts.Length)
{
var txt = trackKeyTexts[index];
if (txt != null) txt.color = keyInactiveColor;
}
}
}
// Refresh displayed labels for key bindings (called after rebind)
public void RefreshKeyLabels()
{
@@ -12,7 +12,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 088ed1ed6b6731f43ad3166781e3e931, type: 3}
m_Name: NoteJudgeConfig
m_EditorClassIdentifier:
perfectRange: 0.05
greatRange: 0.1
goodRange: 0.15
missRange: 0.2
perfectRange: 0.1
greatRange: 0.15
goodRange: 0.2
missRange: 0.25
@@ -1,4 +1,5 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
@@ -7,6 +8,7 @@ public class JudgeManager : MonoBehaviour
[Header("settlement go")]
public GameObject settlement_go;
public settlementController sc;
public NoteSpawner ns;
public static JudgeManager Instance { get; private set; }
@@ -48,22 +50,49 @@ public class JudgeManager : MonoBehaviour
/// </summary>
public void OnAllNotesJudged()
{
// safe null checks and invocation
if (settlement_go != null)
if (settlement_go == null)
{
Debug.LogError("结算页面不存在!");
return;
}
// Activate settlement UI so components run their lifecycle, then call update next frame
try
{
settlement_go.SetActive(true);
}
catch (Exception ex)
{
Debug.LogWarning($"[JudgeManager] Failed to activate settlement_go: {ex}");
}
// Try to resolve settlementController reference if missing
if (sc == null && settlement_go != null)
{
sc = settlement_go.GetComponent<settlementController>() ?? settlement_go.GetComponentInChildren<settlementController>(true);
}
// Invoke the settlement update on next frame to ensure UI initialization finished
StartCoroutine(InvokeSettlementUpdateNextFrame());
}
private IEnumerator InvokeSettlementUpdateNextFrame()
{
yield return null; // wait one frame to allow Awake/Start/OnEnable
if (sc != null)
{
try
{
sc?.startSettlement_uiUpdate();
sc.startSettlement_uiUpdate();
}
catch (Exception ex)
{
Debug.LogWarning($"[JudgeManager] Exception calling startSettlement_uiUpdate: {ex}");
}
settlement_go.SetActive(true);
}
else
{
Debug.LogError("结算页面不存在!");
Debug.LogWarning("[JudgeManager] settlementController (sc) not found on settlement_go; cannot call startSettlement_uiUpdate().");
}
}
+35 -22
View File
@@ -134,11 +134,7 @@ public class Note : BaseNote
if (Mathf.Abs(pressTime - hitTime) > maxWindow)
{
// outside allowed window and not in judge zone -> release lock and ignore the press
if (hasLock)
{
TrackKeyManager.Instance?.UnlockTrackForJudge(TrackIndex, myId);
hasLock = false;
}
ReleaseTrackLock(myId);
if (GameConfig.verboseLogs) Debug.Log($"[Note] {noteColor} press rejected: outside timing window and not in judge zone");
return;
}
@@ -157,26 +153,32 @@ public class Note : BaseNote
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Perfect");
judgeResult = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (TrackIndex >= 0 && TrackIndex < 5) ScoreManager.Instance.trackPerfectCounts[TrackIndex]++;
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
ScoreManager.Instance.RecordOffset(rawOffsetMs);
}
else if (timeDifference <= judgeConfig.greatRange)
{
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Great");
judgeResult = "Great";
ScoreManager.Instance.countGreat += 1;
if (TrackIndex >= 0 && TrackIndex < 5) ScoreManager.Instance.trackGreatCounts[TrackIndex]++;
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
ScoreManager.Instance.RecordOffset(rawOffsetMs);
}
else if (timeDifference <= judgeConfig.goodRange)
{
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Good");
judgeResult = "Good";
ScoreManager.Instance.countGood += 1;
if (TrackIndex >= 0 && TrackIndex < 5) ScoreManager.Instance.trackGoodCounts[TrackIndex]++;
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
ScoreManager.Instance.RecordOffset(rawOffsetMs);
}
else
{
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Miss (timeout)");
judgeResult = "Miss";
JudgeMiss();
return;
}
}
else
@@ -186,16 +188,21 @@ public class Note : BaseNote
{
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Perfect");
judgeResult = "Perfect";
ScoreManager.Instance.countPerfect += 1;
// Add missing offset record for fallback path too if possible
ScoreManager.Instance.RecordOffset(0);
}
else if (timeDifference <= 0.15f)
{
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Great");
judgeResult = "Great";
ScoreManager.Instance.countGreat += 1;
ScoreManager.Instance.RecordOffset(0);
}
else
{
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Miss (timeout)");
judgeResult = "Miss";
JudgeMiss();
return;
}
}
@@ -275,6 +282,11 @@ public class Note : BaseNote
isJudged = true;
ScoreManager.Instance.countMiss += 1;
if (TrackIndex >= 0 && TrackIndex < 5) ScoreManager.Instance.trackMissCounts[TrackIndex]++;
// Record a 0 offset for Miss to ensure total Offset Count matches note count
ScoreManager.Instance?.RecordOffset(0);
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress} Miss");
InputManager.Instance?.ShowJudgeResult(TrackIndex, "Miss");
@@ -305,35 +317,36 @@ public class Note : BaseNote
// notify global judge manager that this note has been finally judged (miss)
JudgeManager.Instance?.NotifyNoteJudged();
// Remove from per-track queue and release lock immediately so next note can be judged
string myId = gameObject.GetInstanceID().ToString();
TrackKeyManager.Instance?.UnregisterKey(TrackIndex, myId);
ReturnToPool();
}
private void ReleaseTrackLock(string myId)
{
if (hasLock)
{
TrackKeyManager.Instance?.UnlockTrackForJudge(TrackIndex, myId);
hasLock = false;
}
ReturnToPool();
}
private void ReturnToPool()
{
InputManager.OnKeyPressed -= HandlePress;
// CRITICAL: Always release lock and unregister before deactivating
string myId = gameObject.GetInstanceID().ToString();
// Release lock if we still hold it
if (hasLock)
{
TrackKeyManager.Instance?.UnlockTrackForJudge(TrackIndex, gameObject.GetInstanceID().ToString());
hasLock = false;
}
ReleaseTrackLock(myId);
// Remove from per-track queue to avoid stale entries
TrackKeyManager.Instance?.UnregisterKey(TrackIndex, myId);
if (controller != null)
{
controller.ResetState();
}
// Ensure we are removed from per-track queue to avoid stale entries
TrackKeyManager.Instance?.UnregisterKey(TrackIndex, gameObject.GetInstanceID().ToString());
gameObject.SetActive(false);
NotePool.Instance.ReturnNote(gameObject, noteColor);
}
@@ -93,7 +93,8 @@ public class NoteController : MonoBehaviour
{
isInJudgeZone = true;
// register note by instance id so TrackKeyManager can prioritize
TrackKeyManager.Instance.RegisterKey(linkedNote.GetTrackIndex(), linkedNote.gameObject.GetInstanceID().ToString());
// tap notes are short notes without hold
TrackKeyManager.Instance.RegisterKey(linkedNote.GetTrackIndex(), linkedNote.gameObject.GetInstanceID().ToString(), "tap");
}
}
}
+40 -6
View File
@@ -11,8 +11,14 @@ public class NotePool : MonoBehaviour
public GameObject[] holdNotePrefabs; // ƬԤ
public GameObject[] holdNoteEndPrefabs; // βרóԤѡ
public GameObject[] startNotePrefabs; // startƬԤ
private int poolSize = 24; // ÿ͵شС (increased)
private int maxPoolSize = 120; // ص (increased)
[Header("Pool Size")]
public int poolSize = 24;
public int maxPoolSize = 300;
[Tooltip("Multiplier for hold segment pool size (per color).")]
public int holdSegmentPoolMultiplier = 8;
[Tooltip("Multiplier for hold end pool size (per color).")]
public int holdEndPoolMultiplier = 8;
// ɫĿӳÿ switch
private Dictionary<string, int> colorIndexMap;
@@ -118,10 +124,10 @@ public class NotePool : MonoBehaviour
private IEnumerator PrewarmCoroutine(int colorCount)
{
// Calculate targets
int noteTarget = poolSize; // per color
int startTarget = poolSize;
int holdTarget = poolSize * 5;
int endTarget = poolSize * 5;
int noteTarget = Mathf.Min(poolSize, maxPoolSize); // per color
int startTarget = Mathf.Min(poolSize, maxPoolSize);
int holdTarget = Mathf.Min(poolSize * Mathf.Max(1, holdSegmentPoolMultiplier), maxPoolSize);
int endTarget = Mathf.Min(poolSize * Mathf.Max(1, holdEndPoolMultiplier), maxPoolSize);
while (true)
{
@@ -237,6 +243,34 @@ public class NotePool : MonoBehaviour
{
if (obj == null || pool == null) return; // ֹ
// Defensive: unregister/unlock any TrackKeyManager state referencing this object to avoid stale queues/locks
try
{
// Try to determine track index from Note or HoldNote components
int trackIdx = -1;
var noteComp = obj.GetComponent<Note>();
if (noteComp != null)
{
trackIdx = noteComp.GetTrackIndex();
}
else
{
var holdComp = obj.GetComponent<HoldNote>();
if (holdComp != null)
{
trackIdx = holdComp.trackIndex;
}
}
string id = obj.GetInstanceID().ToString();
if (TrackKeyManager.Instance != null && trackIdx >= 0)
{
try { TrackKeyManager.Instance.UnregisterKey(trackIdx, id); } catch { }
try { TrackKeyManager.Instance.UnlockTrackForJudge(trackIdx, id); } catch { }
}
}
catch { }
// ظڳڣֱӺ
GamePlay.PoolItem pi = obj.GetComponent<GamePlay.PoolItem>();
if (pi != null && pi.inPool) return;
+437 -42
View File
@@ -1,9 +1,14 @@
using System;
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine.UI;
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; // 短音符预制体
@@ -42,6 +47,19 @@ public class NoteSpawner : MonoBehaviour
[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;
[Header("Immediate Settlement")]
[Tooltip("Optional. If set, this will be used to trigger settlement UI (JudgeManager.TriggerAllNotesJudged). If null, will fall back to JudgeManager.Instance.")]
public JudgeManager judgeManager;
[Tooltip("Optional UI Button. When clicked, will immediately stop spawning and enter settlement.")]
public Button immediateSettlementButton;
[Tooltip("Optional: the pause UI GameObject to disable when immediate settlement is triggered.")]
public GameObject pausePanel;
[Tooltip("Optional: animations GameObject to disable when entering settlement. Will be restored on Start().")]
public GameObject animations;
// optional: constants for runtime clamping (kept for internal use)
private const float SpeedMultiplierMin = 0.8f;
private const float SpeedMultiplierMax = 1.25f;
@@ -54,8 +72,17 @@ public class NoteSpawner : MonoBehaviour
private static int holdNoteIdCounter = 0; // 全局唯一长音符 ID 计数器
// map from beatmap note index -> assigned holdNoteId (for hold notes only)
private Dictionary<int, int> noteIndexToHoldId = new Dictionary<int, int>();
private const string NoteSpeedPrefKey = "noteSpeedMultiplier";
private Coroutine spawnCoroutine;
private Coroutine settlementCoroutine;
// Guard to avoid double-trigger / deadlock
private bool immediateSettlementTriggered = false;
private void Awake()
{
// Load saved visual speed multiplier before any spawning logic uses it
@@ -65,6 +92,83 @@ public class NoteSpawner : MonoBehaviour
if (GameConfig.verboseLogs) Debug.Log($"[NoteSpawner] Loaded speedMultiplier={speedMultiplier} from PlayerPrefs");
}
private void OnEnable()
{
// 每次场景启用/加载时:清空立刻结算状态,避免进入场景即锁定
ResetImmediateSettlementState();
BindImmediateSettlementButton();
// subscribe to JudgeManager.AllNotesJudged so we disable animations on normal settlement as well
TrySubscribeJudgeManager();
}
private void Start()
{
// restore animations active state on Start
if (animations != null)
{
try { animations.SetActive(true); }
catch { }
}
// ensure subscription if JudgeManager.Instance was not ready during OnEnable
TrySubscribeJudgeManager();
}
private void OnDisable()
{
UnbindImmediateSettlementButton();
TryUnsubscribeJudgeManager();
}
private void ResetImmediateSettlementState()
{
immediateSettlementTriggered = false;
// 不强制设置 isSpawning,这个状态由 LoadBeatmap 驱动;这里只清锁
}
private void BindImmediateSettlementButton()
{
if (immediateSettlementButton == null) return;
try { immediateSettlementButton.onClick.RemoveListener(ForceImmediateSettlement); } catch { }
immediateSettlementButton.onClick.AddListener(ForceImmediateSettlement);
}
private void UnbindImmediateSettlementButton()
{
if (immediateSettlementButton == null) return;
try { immediateSettlementButton.onClick.RemoveListener(ForceImmediateSettlement); } catch { }
}
private void TrySubscribeJudgeManager()
{
var jm = judgeManager != null ? judgeManager : JudgeManager.Instance;
if (jm != null)
{
try { jm.AllNotesJudged -= OnSettlementTriggered; } catch { }
jm.AllNotesJudged += OnSettlementTriggered;
}
}
private void TryUnsubscribeJudgeManager()
{
var jm = judgeManager != null ? judgeManager : JudgeManager.Instance;
if (jm != null)
{
try { jm.AllNotesJudged -= OnSettlementTriggered; } catch { }
}
}
private void OnSettlementTriggered()
{
// Disable animations when settlement begins
if (animations != null)
{
try { animations.SetActive(false); }
catch { }
}
}
public void LoadBeatmap(Beatmap loadedBeatmap)
{
if (isSpawning) return;
@@ -89,7 +193,9 @@ public class NoteSpawner : MonoBehaviour
bpm = beatmap.bpm;
startTime = Time.time;
Debug.Log($"歌曲开始时间: {startTime}");
StartCoroutine(SpawnNotes());
// keep reference so we can stop spawning when doing immediate settlement
spawnCoroutine = StartCoroutine(SpawnNotes());
}
private IEnumerator SpawnNotes()
@@ -101,8 +207,14 @@ public class NoteSpawner : MonoBehaviour
yield break;
}
foreach (NoteData note in beatmap.notes)
// iterate with index so we can map hold notes to generated ids
for (int i = 0; i < beatmap.notes.Length; i++)
{
// 允许外部在运行中打断生成(例如“立刻结算”)
if (!isSpawning)
yield break;
NoteData note = beatmap.notes[i];
// clamp speed multiplier to supported range
float sm = Mathf.Clamp(speedMultiplier, SpeedMultiplierMin, SpeedMultiplierMax);
@@ -128,7 +240,9 @@ public class NoteSpawner : MonoBehaviour
if (note.type == "hold")
{
SpawnHoldNote(note);
// create the hold note once and record its id mapping
int hid = SpawnHoldNote(note);
noteIndexToHoldId[i] = hid;
}
else
{
@@ -137,6 +251,110 @@ public class NoteSpawner : MonoBehaviour
}
isSpawning = false;
spawnCoroutine = null;
// Notify subscribers that all notes have been spawned
AllNotesSpawned?.Invoke();
// Settlement routine will be started by GameManager via StartSettlementRoutine() call
// after receiving the AllNotesSpawned event
}
/// <summary>
/// 立刻结算:
/// - 停止继续生成音符(停止 SpawnNotes 协程)
/// - 取消 NoteSpawner 自己的延迟结算协程(PostSpawnSettlementRoutine
/// - 立刻触发结算流程(JudgeManager.TriggerAllNotesJudged
/// 注意:该方法不会清理场上已生成的音符,仅停止后续生成并进入结算。
/// </summary>
[ContextMenu("Force Immediate Settlement")]
public void ForceImmediateSettlement()
{
if (immediateSettlementTriggered)
{
Debug.LogWarning("[NoteSpawner] ForceImmediateSettlement ignored: already triggered.");
return;
}
immediateSettlementTriggered = true;
Debug.LogWarning("[NoteSpawner] ForceImmediateSettlement called: stopping further note spawning and triggering settlement now.");
// If a pause UI is assigned, disable it immediately to avoid stuck paused UI during settlement
if (pausePanel != null)
{
try
{
pausePanel.SetActive(false);
Debug.Log("[NoteSpawner] pausePanel has been disabled by immediate settlement.");
}
catch (Exception ex)
{
Debug.LogWarning("[NoteSpawner] Failed to disable pausePanel: " + ex.Message);
}
}
// Also disable animations if assigned
if (animations != null)
{
try { animations.SetActive(false); }
catch { }
}
// Restore pause manager state if present, otherwise fallback to setting timeScale
try
{
var pm = PauseManager.Instance;
if (pm != null)
{
pm.Pause(false);
if (GameConfig.verboseLogs) Debug.Log("[NoteSpawner] PauseManager.Pause(false) called to resume time.");
}
else
{
Time.timeScale = 1f;
if (GameConfig.verboseLogs) Debug.Log("[NoteSpawner] PauseManager not found; Time.timeScale set to 1 as fallback.");
}
}
catch (Exception ex)
{
try { Time.timeScale = 1f; } catch { }
Debug.LogWarning("[NoteSpawner] Exception while restoring time scale: " + ex.Message);
}
// stop spawning loop
isSpawning = false;
// cancel spawn coroutine
if (spawnCoroutine != null)
{
try { StopCoroutine(spawnCoroutine); } catch { }
spawnCoroutine = null;
}
// cancel delayed settlement coroutine if any
if (settlementCoroutine != null)
{
try { StopCoroutine(settlementCoroutine); } catch { }
settlementCoroutine = null;
}
// Disable animations when settlement begins
if (animations != null)
{
try { animations.SetActive(false); }
catch { }
}
// trigger settlement
var jm = judgeManager != null ? judgeManager : JudgeManager.Instance;
if (jm != null)
{
jm.TriggerAllNotesJudged();
}
else
{
Debug.LogError("[NoteSpawner] ForceImmediateSettlement failed: JudgeManager reference missing.");
}
}
// 生成短音符
@@ -217,25 +435,26 @@ public class NoteSpawner : MonoBehaviour
}
}
public void SpawnHoldNote(NoteData noteData)
// Modified: return generated holdNoteId so callers can map notes to ids
public int SpawnHoldNote(NoteData noteData)
{
if (noteData.trackIndex < 0 || noteData.trackIndex >= spawnPoints.Length)
{
Debug.LogError("轨道索引超出范围!");
return;
return -1;
}
KeyCode key = KeyBindingManager.GetKeyForColor(noteData.color);
if (key == KeyCode.None)
{
Debug.LogError($"未找到颜色 {noteData.color} 对应的按键!");
return;
return -1;
}
if (string.IsNullOrEmpty(noteData.color))
{
Debug.LogError("[NoteSpawner] noteData.color 为空,无法生成音符!");
return;
return -1;
}
Debug.Log($"生成时间:{Time.time}");
@@ -271,19 +490,11 @@ public class NoteSpawner : MonoBehaviour
if (startObj == null)
{
Debug.LogError("对象池返回空 hold note start");
return;
return -1;
}
// compute per-segment compensation only if enabled
float startYOffset = 0f;
if (enableYOffsetCompensation)
{
float startActivation = baseHit - effectiveTravelTimeHold;
float timeSinceActivation = Time.time - startActivation;
startYOffset = Mathf.Max(0f, timeSinceActivation * holdSpeed);
}
startObj.transform.position = spawnPoint.position + Vector3.down * startYOffset;
// Hold segments use absolute positioning; spawn at the lane origin.
startObj.transform.position = spawnPoint.position;
startObj.transform.rotation = Quaternion.identity;
HoldNote holdNote = startObj.GetComponent<HoldNote>();
@@ -301,7 +512,7 @@ public class NoteSpawner : MonoBehaviour
else
{
Debug.LogError("长音符 start 部分缺少 HoldNote 组件!");
return;
return -1;
}
// 生成 Middle 片段(1 .. segmentCount-1
@@ -317,16 +528,7 @@ public class NoteSpawner : MonoBehaviour
continue;
}
float segYOffset = 0f;
if (enableYOffsetCompensation)
{
float segHit = baseHit + segmentDelay;
float segActivation = segHit - effectiveTravelTimeHold;
float timeSinceActivationSeg = Time.time - segActivation;
segYOffset = Mathf.Max(0f, timeSinceActivationSeg * holdSpeed);
}
segObj.transform.position = spawnPoint.position + Vector3.down * segYOffset;
segObj.transform.position = spawnPoint.position;
segObj.transform.rotation = Quaternion.identity;
HoldNote holdSeg = segObj.GetComponent<HoldNote>();
@@ -341,7 +543,7 @@ public class NoteSpawner : MonoBehaviour
holdSeg.visualSpeedMultiplier = smLocalHold;
// Immediately calibrate position and schedule additional checks to correct any offset
Vector3 calibSpawnPos = spawnPoint.position + Vector3.down * segYOffset;
Vector3 calibSpawnPos = spawnPoint.position;
holdSeg.CalibratePosition(calibSpawnPos, calibrateTolerance);
StartCoroutine(CalibrateAfterSpawn(holdSeg, calibSpawnPos));
}
@@ -356,21 +558,12 @@ public class NoteSpawner : MonoBehaviour
if (endObj == null)
{
Debug.LogError("对象池返回空 hold note 片段(end)!");
return;
return -1;
}
float endDelay = segmentCount * actualSegmentInterval; // 不按 speedMultiplier 缩放,使用谱面长度保证尾段紧随中段
float endYOffset = 0f;
if (enableYOffsetCompensation)
{
float endHit = baseHit + endDelay;
float endActivation = endHit - effectiveTravelTimeHold;
float timeSinceActivationEnd = Time.time - endActivation;
endYOffset = Mathf.Max(0f, timeSinceActivationEnd * holdSpeed);
}
endObj.transform.position = spawnPoint.position + Vector3.down * endYOffset;
endObj.transform.position = spawnPoint.position;
endObj.transform.rotation = Quaternion.identity;
// 为便于识别,将实例名追加后缀
@@ -389,7 +582,7 @@ public class NoteSpawner : MonoBehaviour
holdEnd.visualSpeedMultiplier = smLocalHold;
// schedule calibration for end as well to be safe
Vector3 calibEndPos = spawnPoint.position + Vector3.down * endYOffset;
Vector3 calibEndPos = spawnPoint.position;
holdEnd.CalibratePosition(calibEndPos, calibrateTolerance);
StartCoroutine(CalibrateAfterSpawn(holdEnd, calibEndPos));
}
@@ -397,6 +590,8 @@ public class NoteSpawner : MonoBehaviour
{
Debug.LogError("长音符 end 部分缺少 HoldNote 组件!");
}
return holdNoteId;
}
private IEnumerator CalibrateAfterSpawn(HoldNote seg, Vector3 spawnPos)
@@ -440,6 +635,206 @@ public class NoteSpawner : MonoBehaviour
return 10.75f / noteTravelTime;
}
/// <summary>
/// Public method to start the settlement routine. Called by GameManager when all notes have been spawned.
/// </summary>
public void StartSettlementRoutine()
{
if (GameConfig.verboseLogs) 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));
}
private IEnumerator PostSpawnSettlementRoutine(int lookback)
{
// 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--)
{
NoteData note = beatmap.notes[i];
if (note.time >= timeRangeStart)
{
relevantNotes.Add(note);
}
else
{
break;
}
}
// Reverse to process in chronological order
relevantNotes.Reverse();
if (GameConfig.verboseLogs) 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
bool hasHoldNotes = false;
float maxEndTime = float.MinValue;
NoteData maxEndNote = null;
foreach (var note in relevantNotes)
{
float noteEndTime;
if (note.type == "hold")
{
hasHoldNotes = true;
noteEndTime = note.time + note.length;
}
else
{
noteEndTime = note.time;
}
if (noteEndTime > maxEndTime)
{
maxEndTime = noteEndTime;
maxEndNote = note;
}
if (GameConfig.verboseLogs)
{
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;
}
}
if (GameConfig.verboseLogs)
{
Debug.Log($"[NoteSpawner] Max end note: type={maxEndNote?.type}, time={maxEndNote?.time:F3}, maxIsHold={maxIsHold}, initialWait={initialWait: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 (GameConfig.verboseLogs) 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 (GameConfig.verboseLogs) 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)
yield return null;
// Trigger settlement
try
{
if (JudgeManager.Instance != null)
{
JudgeManager.Instance.OnAllNotesJudged();
}
else
{
Debug.LogWarning("[NoteSpawner] JudgeManager.Instance is null when trying to trigger settlement");
}
}
catch (Exception ex)
{
Debug.LogWarning("[NoteSpawner] Exception while triggering settlement: " + ex);
}
}
private void ForceClearInputState(List<NoteData> relevantNotes)
{
// Fire UI key release handlers: reset InputManager key indicator colors
var im = InputManager.Instance;
if (im != null)
{
var texts = im.trackKeyTexts;
if (texts != null)
{
for (int ti = 0; ti < texts.Length; ti++)
{
if (texts[ti] != null)
texts[ti].color = im.keyInactiveColor;
}
}
}
// Reset per-frame consumption and unlock any track locks
if (TrackKeyManager.Instance != null)
{
TrackKeyManager.Instance.ResetConsumptionState();
TrackKeyManager.Instance.ClearAllLocks();
if (GameConfig.verboseLogs) Debug.Log("[NoteSpawner] TrackKeyManager consumption state reset and locks cleared");
}
// Mark all relevant hold notes as released in JudgeManager
// Only process if relevantNotes is provided (non-null)
if (relevantNotes != null && JudgeManager.Instance != null && beatmap != null && beatmap.notes != null)
{
foreach (var note in relevantNotes)
{
if (note == null) continue;
if (note.type == "hold")
{
// Find the index of this note in beatmap to get its hold ID
for (int i = 0; i < beatmap.notes.Length; i++)
{
if (beatmap.notes[i] == note && noteIndexToHoldId.ContainsKey(i))
{
int holdId = noteIndexToHoldId[i];
try { JudgeManager.Instance.RegisterNoteReleased(holdId.ToString(), true); }
catch { }
}
}
}
}
}
}
private void Update()
{
//if (globalGameTime.text=="0.01")
@@ -1,12 +1,15 @@
using System.Collections.Generic;
using UnityEngine;
using System.Collections;
public class TrackKeyManager : MonoBehaviour
{
public static TrackKeyManager Instance { get; private set; }
// store note instance IDs (as strings) in queue order per track
private Dictionary<int, Queue<string>> trackKeyMappings = new Dictionary<int, Queue<string>>();
// Format: trackIndex -> Queue of (noteId, noteType) tuples
private Dictionary<int, Queue<(string noteId, string noteType)>> trackKeyMappings =
new Dictionary<int, Queue<(string, string)>>();
// Track notes currently being judged to prevent double-judging the same note
// Format: trackIndex -> HashSet of note IDs being judged
@@ -14,11 +17,28 @@ public class TrackKeyManager : MonoBehaviour
// transient per-frame consumption: ensures one physical press only affects one note per track per frame
private Dictionary<int, int> trackConsumedFrame = new Dictionary<int, int>();
// track last frame we checked for focus loss
private int lastCheckedFrame = -1;
private bool lastFocusedState = true;
// --- Anti-deadlock housekeeping ---
// Some notes can be pooled/disabled without firing OnTriggerExit2D, leaving stale ids in the queue.
// To guarantee the track never blocks forever, each queued id gets a TTL.
// When TTL expires, we prune it as stale.
private readonly Dictionary<string, float> noteIdExpiry = new Dictionary<string, float>();
[Header("TrackKeyManager housekeeping")]
[Tooltip("Seconds a queued note id is allowed to stay without being removed. Prevents permanent deadlocks when OnTriggerExit2D is missed.")]
[Range(0.5f, 10f)]
public float queuedIdTtlSeconds = 3f;
[Tooltip("Maximum number of stale head entries to prune per track per call.")]
[Range(1, 20)]
public int pruneBatchSize = 5;
private Coroutine cleanupCoroutine;
private void Awake()
{
if (Instance == null)
@@ -29,14 +49,129 @@ public class TrackKeyManager : MonoBehaviour
{
Destroy(gameObject);
}
// start periodic cleanup to defend against stale locks that can survive missed Unregister/Unlock calls
StartCleanupRoutine();
}
private void OnDestroy()
{
StopCleanupRoutine();
}
private void StartCleanupRoutine()
{
if (cleanupCoroutine == null)
cleanupCoroutine = StartCoroutine(CleanupStaleLocksRoutine());
}
private void StopCleanupRoutine()
{
if (cleanupCoroutine != null)
{
StopCoroutine(cleanupCoroutine);
cleanupCoroutine = null;
}
}
// Periodically scan locks and queued ids to remove stale entries that can cause deadlocks.
private IEnumerator CleanupStaleLocksRoutine()
{
while (true)
{
// run once per second (unscaled so it runs during pause)
yield return new WaitForSecondsRealtime(1f);
float now = Time.unscaledTime;
// 1) Prune head entries for all tracks (defensive)
var trackKeys = new List<int>(trackKeyMappings.Keys);
foreach (var ti in trackKeys)
{
PruneStaleHeads(ti);
}
// 2) Inspect locks and release any that reference ids that are expired or not present in any queue
var trackLockKeys = new List<int>(trackNotesBeingJudged.Keys);
foreach (var t in trackLockKeys)
{
var locks = trackNotesBeingJudged[t];
if (locks == null || locks.Count == 0) continue;
var toRemove = new List<string>();
foreach (var noteId in locks)
{
bool hasExpiry = noteIdExpiry.TryGetValue(noteId, out var expiry);
bool expired = hasExpiry && now > expiry;
// If expiry missing or expired, consider the id stale and remove lock
if (!hasExpiry || expired)
{
toRemove.Add(noteId);
continue;
}
// Also if the queues do not contain this id anywhere, it is likely stale
bool foundInQueues = false;
foreach (var qpair in trackKeyMappings)
{
foreach (var item in qpair.Value)
{
if (!string.IsNullOrEmpty(item.noteId) && item.noteId == noteId)
{
foundInQueues = true;
break;
}
}
if (foundInQueues) break;
}
if (!foundInQueues)
{
toRemove.Add(noteId);
}
}
foreach (var id in toRemove)
{
locks.Remove(id);
noteIdExpiry.Remove(id);
if (GameConfig.verboseLogs) Debug.LogWarning($"[TrackKeyManager] Cleanup: removed stale lock id={id} on track {t}");
}
// If locks become empty, remove dictionary entry to keep structure clean
if (locks.Count == 0)
{
trackNotesBeingJudged.Remove(t);
}
}
// 3) Defensive: if a track has no queued ids but has consumed-frame or locks, clear them
var allTracks = new HashSet<int>();
foreach (var k in trackKeyMappings.Keys) allTracks.Add(k);
foreach (var k in trackNotesBeingJudged.Keys) allTracks.Add(k);
foreach (var k in trackConsumedFrame.Keys) allTracks.Add(k);
foreach (var track in allTracks)
{
bool hasQueue = trackKeyMappings.ContainsKey(track) && trackKeyMappings[track].Count > 0;
bool hasLocks = trackNotesBeingJudged.ContainsKey(track) && trackNotesBeingJudged[track].Count > 0;
if (!hasQueue && hasLocks)
{
// clear locks for this track
trackNotesBeingJudged.Remove(track);
if (GameConfig.verboseLogs) Debug.LogWarning($"[TrackKeyManager] Cleanup: cleared locks for empty track {track}");
}
}
}
}
private void Update()
{
// Clear per-frame consumption dictionary when focus is regained or at frame boundaries
int currentFrame = Time.frameCount;
bool isFocused = Application.isFocused;
// If we've moved to a new frame or focus state changed, clear consumption tracking
if (lastCheckedFrame != currentFrame || (isFocused && !lastFocusedState))
{
@@ -47,20 +182,65 @@ public class TrackKeyManager : MonoBehaviour
if (GameConfig.verboseLogs) Debug.Log("[TrackKeyManager] Focus regained, cleared track consumption");
}
}
lastCheckedFrame = currentFrame;
lastFocusedState = isFocused;
}
private void PruneStaleHeads(int trackIndex)
{
if (!trackKeyMappings.TryGetValue(trackIndex, out var q) || q == null || q.Count == 0)
return;
int pruned = 0;
float now = Time.unscaledTime;
while (q.Count > 0 && pruned < Mathf.Max(1, pruneBatchSize))
{
var head = q.Peek();
if (string.IsNullOrEmpty(head.noteId))
{
q.Dequeue();
pruned++;
continue;
}
// never prune if this id is currently locked for judgement
if (trackNotesBeingJudged.TryGetValue(trackIndex, out var locks) && locks != null && locks.Contains(head.noteId))
break;
// if an expiry exists and has passed, prune
if (noteIdExpiry.TryGetValue(head.noteId, out var expiry) && now > expiry)
{
q.Dequeue();
noteIdExpiry.Remove(head.noteId);
pruned++;
if (GameConfig.verboseLogs)
Debug.LogWarning($"[TrackKeyManager] Pruned stale head on track {trackIndex}: id={head.noteId} type={head.noteType} now={now:F2} expiry={expiry:F2}");
continue;
}
// head is still within TTL
break;
}
}
/// <summary>
/// 当音符进入判定区域时,添加音符 id 到队列
/// 当音符进入判定区域时,添加音符 id 到队列(包含note类型信息)
/// noteType: "tap" 或 "hold"
/// </summary>
public void RegisterKey(int trackIndex, string noteInstanceId)
public void RegisterKey(int trackIndex, string noteInstanceId, string noteType = "tap")
{
if (!trackKeyMappings.ContainsKey(trackIndex))
trackKeyMappings[trackIndex] = new Queue<string>();
trackKeyMappings[trackIndex] = new Queue<(string, string)>();
trackKeyMappings[trackIndex].Enqueue(noteInstanceId);
trackKeyMappings[trackIndex].Enqueue((noteInstanceId, noteType));
// record/refresh TTL so the id can't block forever if exit/unregister is missed
if (!string.IsNullOrEmpty(noteInstanceId))
{
noteIdExpiry[noteInstanceId] = Time.unscaledTime + Mathf.Max(0.25f, queuedIdTtlSeconds);
}
}
/// <summary>
@@ -69,24 +249,34 @@ public class TrackKeyManager : MonoBehaviour
public void UnregisterKey(int trackIndex, string noteInstanceId)
{
if (!trackKeyMappings.ContainsKey(trackIndex) || trackKeyMappings[trackIndex].Count == 0)
{
// still clear TTL record
if (!string.IsNullOrEmpty(noteInstanceId)) noteIdExpiry.Remove(noteInstanceId);
return;
}
// If the head matches, dequeue. Otherwise try to remove from queue by rebuilding it.
if (trackKeyMappings[trackIndex].Peek() == noteInstanceId)
if (trackKeyMappings[trackIndex].Count > 0 && trackKeyMappings[trackIndex].Peek().noteId == noteInstanceId)
{
trackKeyMappings[trackIndex].Dequeue();
if (!string.IsNullOrEmpty(noteInstanceId)) noteIdExpiry.Remove(noteInstanceId);
return;
}
// Otherwise remove matching id if present (preserve order of others)
var q = trackKeyMappings[trackIndex];
var temp = new Queue<string>();
var temp = new Queue<(string, string)>();
while (q.Count > 0)
{
var v = q.Dequeue();
if (v != noteInstanceId) temp.Enqueue(v);
var item = q.Dequeue();
if (item.noteId != noteInstanceId)
{
temp.Enqueue(item);
}
}
trackKeyMappings[trackIndex] = temp;
if (!string.IsNullOrEmpty(noteInstanceId)) noteIdExpiry.Remove(noteInstanceId);
}
/// <summary>
@@ -94,13 +284,29 @@ public class TrackKeyManager : MonoBehaviour
/// </summary>
public string GetCurrentNoteId(int trackIndex)
{
PruneStaleHeads(trackIndex);
if (trackKeyMappings.ContainsKey(trackIndex) && trackKeyMappings[trackIndex].Count > 0)
{
return trackKeyMappings[trackIndex].Peek();
return trackKeyMappings[trackIndex].Peek().noteId;
}
return null; // 无音符
}
/// <summary>
/// 获取当前队头note的类型("tap" 或 "hold"
/// </summary>
public string GetCurrentNoteType(int trackIndex)
{
PruneStaleHeads(trackIndex);
if (trackKeyMappings.ContainsKey(trackIndex) && trackKeyMappings[trackIndex].Count > 0)
{
return trackKeyMappings[trackIndex].Peek().noteType;
}
return null;
}
/// <summary>
/// Try to register a note as being judged on a track. Returns true if successful (no other note is currently being judged).
/// This prevents multiple notes on the same track from being judged by a single key press.
@@ -108,7 +314,10 @@ public class TrackKeyManager : MonoBehaviour
public bool TryLockTrackForJudge(int trackIndex, string noteId)
{
if (trackIndex < 0) return false;
// Before locking, prune any expired heads so they can't block judgement.
PruneStaleHeads(trackIndex);
if (!trackNotesBeingJudged.ContainsKey(trackIndex))
{
trackNotesBeingJudged[trackIndex] = new HashSet<string>();
@@ -131,11 +340,14 @@ public class TrackKeyManager : MonoBehaviour
public void UnlockTrackForJudge(int trackIndex, string noteId)
{
if (trackIndex < 0) return;
if (trackNotesBeingJudged.ContainsKey(trackIndex))
{
trackNotesBeingJudged[trackIndex].Remove(noteId);
}
// also clear any expiry record for this note id (it has finished its lifecycle)
if (!string.IsNullOrEmpty(noteId)) noteIdExpiry.Remove(noteId);
}
/// <summary>
@@ -146,14 +358,14 @@ public class TrackKeyManager : MonoBehaviour
{
if (trackIndex < 0) return false;
int currentFrame = Time.frameCount;
// If the recorded frame is old or invalid, allow consumption
if (!trackConsumedFrame.ContainsKey(trackIndex))
{
trackConsumedFrame[trackIndex] = currentFrame;
return true;
}
// If we're in a different frame, reset and allow
int recordedFrame = trackConsumedFrame[trackIndex];
if (recordedFrame != currentFrame)
@@ -161,11 +373,11 @@ public class TrackKeyManager : MonoBehaviour
trackConsumedFrame[trackIndex] = currentFrame;
return true;
}
// Same frame, already consumed
// Same frame, already consumed - reject
return false;
}
/// <summary>
/// Force reset the consumption state (useful for testing or specific scenarios)
/// </summary>
@@ -173,4 +385,16 @@ public class TrackKeyManager : MonoBehaviour
{
trackConsumedFrame.Clear();
}
/// <summary>
/// Force clear all track locks and per-frame consumption state.
/// Used to simulate a global input release (e.g. when ending playback).
/// </summary>
public void ClearAllLocks()
{
trackNotesBeingJudged.Clear();
trackConsumedFrame.Clear();
noteIdExpiry.Clear();
if (GameConfig.verboseLogs) Debug.Log("[TrackKeyManager] ClearAllLocks called: cleared locks and consumption state");
}
}
@@ -1,42 +1,58 @@
using UnityEngine;
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
using UnityEngine.Serialization;
using UnityEngine.SceneManagement;
using System.Collections;
using UnityEngine.Audio;
using UnityEditor;
public class settlementController : MonoBehaviour
{
[Header("结算界面之爹")]
[Header("֮")]
[SerializeField] private BeatmapManager bmm;
[SerializeField] private ScoreManager sm;
public GameManager gm;
private SongData thisSong_so;
private int maxScore_sum = 2000000;
[SerializeField] private Image thisSong_backPic;
[Header("跳转之按钮")]
[Header("ת֮ť")]
public Button exit_toSelectSongs;
public Button replay_thisGame;
public Button display_rankList;
public Button share_toSocialMedia;
[Header("文本和进度条")]
[Header("ıͽ")]
public Text songName_Text;
[Tooltip("当前关卡的进度百分比")]
[Tooltip("ǰؿĽȰٷֱ")]
public Text thisLevel_currentPercentage_Text;
public Text finalScore_Text;
public Text pmScoreSum_Text;
public Text idolScoreSum_Text;
public Text accuracy_Text;
public Image thisLevel_progressBar_Image;
[Header("奖励内容信息")]
[Header("׼ȷ")]
public float perfect_weight = 1f;
public float great_weight = 0.6666667f;
public float good_weight = 0.333333f;
public float miss_weight = 0;
[Header("Ϣ")]
public Text reward_playerEXP_Text;
public Text reward_money_Text;
public Text reward_idolEXP_bottle_Text;
[Header("给多少钱")]
[Header("Ŷӽ")]
public loadSettlementTeamPrefab settlementTeamLoader;
public Image mvp_hero_hd_image;
[Header("Ǯ")]
[SerializeField] private long moneyToGive_thisLevel;
// 得到音符统计
[Header("击打情况统计")]
// õͳ
[Header("ͳ")]
public Text perfectHitCount_Text;
public Text perfectHitPercent_Text;
public Image perfect_barFill_Image;
@@ -53,20 +69,135 @@ public class settlementController : MonoBehaviour
public Text missHitPercent_Text;
public Image miss_barFill_Image;
public Text gameNote_rate;
[Header("Timing Statistics")]
public Text earlyHitCount_Text;
public Text lateHitCount_Text;
public Text avgOffset_Text;
[Header("ϵͳ")]
[Tooltip("ֲ½")]
public AudioSource settlementAudioSource;
[Tooltip("ϷֲԭϷ֣")]
public AudioSource oriGameMusicSource;
[Tooltip("Ƶڵͨ˲")]
public AudioMixer gameMusicMixer;
[Tooltip("Ƶеͨ˲")]
public string lowpassParamName = "inGameMusic_lowpass";
[Tooltip("ͨ˲ʱ룩")]
public float lowpassFadeDuration = 2f;
[Tooltip(" CanvasGroupڵЧ")]
public CanvasGroup settlementCanvasGroup;
[Header("")]
[Tooltip("")]
public GameObject cdm;
private Coroutine musicTransitionCoroutine;
private Coroutine canvasFadeCoroutine;
private void Awake()
{
// Setup button listeners
if (replay_thisGame != null)
{
replay_thisGame.onClick.AddListener(OnReplayButtonClicked);
}
if (exit_toSelectSongs != null)
{
exit_toSelectSongs.onClick.AddListener(OnExitButtonClicked);
}
// CDM ڿʼʱֽ״̬
if (cdm != null)
{
cdm.SetActive(false);
if (GameConfig.verboseLogs) Debug.Log("[SettlementController] CDM object disabled at startup");
}
else
{
Debug.LogWarning("[SettlementController] CDM object is not assigned");
}
}
private void Start()
{
// 确保 LowPass 在开始时处于原始位置(通常是 22000Hz,即不滤波)
if (gameMusicMixer != null && !string.IsNullOrEmpty(lowpassParamName))
{
try { gameMusicMixer.SetFloat(lowpassParamName, 22000f); }
catch { }
}
}
private void OnDestroy()
{
if (replay_thisGame != null)
{
replay_thisGame.onClick.RemoveListener(OnReplayButtonClicked);
}
if (exit_toSelectSongs != null)
{
exit_toSelectSongs.onClick.RemoveListener(OnExitButtonClicked);
}
// ֹֹͣЭ
if (musicTransitionCoroutine != null)
{
StopCoroutine(musicTransitionCoroutine);
musicTransitionCoroutine = null;
}
// ֹͣ CanvasGroup Э
if (canvasFadeCoroutine != null)
{
StopCoroutine(canvasFadeCoroutine);
canvasFadeCoroutine = null;
}
}
// Start is called once before the first execution of Update after the MonoBehaviour is created
void getThisSong_info()
public void getThisSong_info()
{
thisSong_so = bmm.assignedSongData;
if (bmm != null)
{
thisSong_so = bmm.assignedSongData;
}
}
public void startSettlement_uiUpdate()
{
// --- ڽ̿ʼʱ CanvasGroup Ϊ͸ ---
InitializeSettlementCanvas();
// --- ý ---
if (cdm != null)
{
cdm.SetActive(true);
Debug.Log("[SettlementController] CDM object enabled at settlement start");
}
getThisSong_info();
if(!sm)
// --- Statistics: Record total play time at settlement ---
if (gm != null) gm.RecordTotalPlayTime();
else { var activeGM = FindObjectOfType<GameManager>(); if (activeGM != null) activeGM.RecordTotalPlayTime(); }
// ׼֣UI֮ǰ
PrepareSettlementMusic();
if(sm != null)
{
songName_Text.text = bmm.parsedTitle;
thisSong_backPic.sprite = bmm.assignedSongData.illustration;
thisSong_backPic.sprite = bmm.assignedSongData.fullscreen_songPicture;
finalScore_Text.text = (sm.allSum_pmScore + sm.allSum_idolScore).ToString();
pmScoreSum_Text.text = sm.allSum_pmScore.ToString();
@@ -86,11 +217,584 @@ 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();
perfectHitPercent_Text.text = ((float)sm.countPerfect / noteCountSum * 100).ToString("F1") + "%";
greatHitPercent_Text.text = ((float)sm.countGreat / noteCountSum * 100).ToString("F1") + "%";
goodHitPercent_Text.text = ((float)sm.countGood / noteCountSum * 100).ToString("F1") + "%";
missHitPercent_Text.text = ((float)sm.countMiss / noteCountSum * 100).ToString("F1") + "%";
accuracy_Text.text = ((float)(sm.countPerfect * perfect_weight + sm.countGreat * great_weight + sm.countGood * good_weight + sm.countMiss * miss_weight) / noteCountSum * 100).ToString("F3") + "%";
// Timing Statistics
if (earlyHitCount_Text != null) earlyHitCount_Text.text = sm.countEarly.ToString();
if (lateHitCount_Text != null) lateHitCount_Text.text = sm.countLate.ToString();
if (avgOffset_Text != null)
{
float avg = sm.offsetCount > 0 ? sm.totalOffsetMs / sm.offsetCount : 0f;
avgOffset_Text.text = avg.ToString("F2") + " ms";
}
}
else Debug.LogError("score manager is null");
// --- Achievement System: Load and display achievements ---
if (InGamePerformanceManager.Instance != null)
{
// Calculate total cure, damage and mana from teamUIController
float totalCure = 0;
float totalDamage = 0;
float totalMana = 0;
if (teamUIController.Instance != null)
{
if (teamUIController.Instance.totalHealProvided != null)
{
foreach (float heal in teamUIController.Instance.totalHealProvided)
{
totalCure += heal;
}
}
if (teamUIController.Instance.totalDamageDealt != null)
{
foreach (float dmg in teamUIController.Instance.totalDamageDealt)
{
totalDamage += dmg;
}
}
if (teamUIController.Instance.totalManaRestored != null)
{
foreach (float mana in teamUIController.Instance.totalManaRestored)
{
totalMana += mana;
}
}
Debug.Log($"[SettlementController] Stats Aggregated - Cure: {totalCure}, Damage: {totalDamage}, Mana: {totalMana}");
}
// Update achievement categories
InGamePerformanceManager.Instance.UpdateTotalCure(totalCure);
InGamePerformanceManager.Instance.UpdateTotalDamage(totalDamage);
InGamePerformanceManager.Instance.UpdateTotalManaRestored(totalMana);
// Load and display achievement prefabs
InGamePerformanceManager.Instance.LoadArchievePrefab();
}
// Persist run results into the SongData SO for this difficulty
// (update personal / idol / total records and per-entry progress if improved)
getThisSong_info(); // ensure thisSong_so set
if (thisSong_so != null && bmm != null)
{
int diff = bmm.assignedDifficulty;
if (diff >= 0)
{
int pm = sm != null ? sm.allSum_pmScore : 0;
int idol = sm != null ? sm.allSum_idolScore : 0;
int total = pm + idol;
// compare and update per-difficulty personal/idol records
int prevPm = thisSong_so.GetPersonalRecord(diff);
int prevIdol = thisSong_so.GetIdolRecord(diff);
if (pm > prevPm)
{
thisSong_so.UpdatePersonalRecord(diff, pm);
}
if (idol > prevIdol)
{
thisSong_so.UpdateIdolRecord(diff, idol);
}
// update last-run total (chart score map / entry.lastScoreForThisDifficulty)
thisSong_so.UpdateChartScore(diff, total);
// update entry convenience fields (total and progress) only if improved
if (thisSong_so.chartFiles != null)
{
var entry = thisSong_so.chartFiles.Find(e => e != null && e.difficulty == diff);
if (entry != null)
{
entry.totalPersonalRecordForThisDifficulty = thisSong_so.GetPersonalRecord(diff) + thisSong_so.GetIdolRecord(diff);
// compute progress as 0..1
float newProgress = Mathf.Clamp01((float)entry.totalPersonalRecordForThisDifficulty / (float)maxScore_sum);
if (newProgress > entry.levelProgressForThisDifficulty)
{
entry.levelProgressForThisDifficulty = newProgress;
}
// if this is the currently selected difficulty for this song, update SongData.current_levelProgress
if (thisSong_so.thisLevel_selectedDifficultyID == diff)
{
thisSong_so.current_levelProgress = Mathf.Max(thisSong_so.current_levelProgress, entry.levelProgressForThisDifficulty);
}
}
}
// refresh overall personalRecord cached value
thisSong_so.CalculateHighestPersonalRecord();
}
}
// Populate settlement team cards if a loader is assigned
if (settlementTeamLoader == null)
{
// try to auto-locate loader in scene if not assigned in inspector
settlementTeamLoader = FindObjectOfType<loadSettlementTeamPrefab>();
}
if (settlementTeamLoader != null)
{
// ensure loader has key references
if (settlementTeamLoader.sm == null) settlementTeamLoader.sm = sm ?? ScoreManager.Instance;
if (settlementTeamLoader.tuic == null) settlementTeamLoader.tuic = teamUIController.Instance;
if (settlementTeamLoader.gm == null) settlementTeamLoader.gm = gm ?? FindObjectOfType<GameManager>();
try { settlementTeamLoader.PopulateSettlementCards(); }
catch (System.Exception ex) { Debug.LogWarning("Failed to PopulateSettlementCards: " + ex); }
}
// ʼֹɣڽUIʾ
// ѡ MVP ͼƬ
SetMvpHeroImageFromTopScorer();
StartMusicTransition();
// --- ޸ģ߼ĩβʼ CanvasGroup ---
StartCanvasFadeIn();
}
/// <summary>
/// ʼ CanvasGroup Ϊ͸״̬
/// </summary>
private void InitializeSettlementCanvas()
{
if (settlementCanvasGroup != null)
{
settlementCanvasGroup.alpha = 0f;
settlementCanvasGroup.interactable = false;
settlementCanvasGroup.blocksRaycasts = false;
Debug.Log("[SettlementController] Settlement canvas initialized to transparent state");
}
else
{
Debug.LogWarning("[SettlementController] settlementCanvasGroup is not assigned, cannot initialize");
}
}
/// <summary>
/// dlcDataзҰǰDLC׼
/// </summary>
private void PrepareSettlementMusic()
{
// --- ޸ģƳ CanvasGroup ãڽ߼ĩβŵ ---
if (thisSong_so == null)
{
Debug.LogWarning("[SettlementController] thisSong_so is null, cannot prepare settlement music");
return;
}
if (settlementAudioSource == null)
{
Debug.LogWarning("[SettlementController] settlementAudioSource is not assigned, cannot prepare settlement music");
return;
}
// dlcData ScriptableObjects
dlcData[] allDlcs = Resources.LoadAll<dlcData>("");
dlcData foundDlc = null;
// DLCҰǰDLC
foreach (var dlc in allDlcs)
{
if (dlc == null || dlc.songList == null) continue;
if (dlc.songList.Contains(thisSong_so))
{
foundDlc = dlc;
break;
}
}
if (foundDlc == null)
{
Debug.LogWarning($"[SettlementController] No DLC found containing song '{thisSong_so.songName}'");
return;
}
if (foundDlc.settlementMusic == null)
{
Debug.LogWarning($"[SettlementController] DLC '{foundDlc.dlcName}' does not have settlement music assigned");
return;
}
// ýֵAudioSource
settlementAudioSource.clip = foundDlc.settlementMusic;
settlementAudioSource.loop = true;
settlementAudioSource.playOnAwake = false;
// ͣȴ
settlementAudioSource.volume = 1f;
settlementAudioSource.mute = true;
settlementAudioSource.Stop();
Debug.Log($"[SettlementController] Prepared settlement music from DLC '{foundDlc.dlcName}': {foundDlc.settlementMusic.name}");
// --- ޸ģƳ CanvasGroup ãڽ߼ĩβŵ ---
}
/// <summary>
/// ʼֹɣϷ֣ͨͨ˲Ȼ󲥷Ž
/// </summary>
private void StartMusicTransition()
{
if (musicTransitionCoroutine != null)
{
StopCoroutine(musicTransitionCoroutine);
}
musicTransitionCoroutine = StartCoroutine(MusicTransitionRoutine());
}
/// <summary>
/// ֹЭ̣
/// 1. Ϸֵĵͨ˲22000Hz𽥽0Hzͬʱ0
/// 2. ֹͣԭֲʼŽ
/// </summary>
private IEnumerator MusicTransitionRoutine()
{
float elapsed = 0f;
float startLowpass = 22000f;
float endLowpass = 0f;
// --- ¼ʼ ---
float startVolume = (oriGameMusicSource != null) ? oriGameMusicSource.volume : 1f;
// ԭϷԴͻڣеͨ˲
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");
}
// ɺ󣬿ʼŽ
if (settlementAudioSource != null && settlementAudioSource.clip != null)
{
try
{
settlementAudioSource.mute = false;
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)
{
Debug.LogWarning($"[SettlementController] Failed to start settlement music: {ex.Message}");
}
}
musicTransitionCoroutine = null;
}
/// <summary>
/// ʼ CanvasGroup Ч
/// </summary>
private void StartCanvasFadeIn()
{
if (canvasFadeCoroutine != null)
{
StopCoroutine(canvasFadeCoroutine);
}
canvasFadeCoroutine = StartCoroutine(CanvasFadeInRoutine());
}
/// <summary>
/// CanvasGroup Э̣0.25ڴ alpha=0 뵽 alpha=1
/// </summary>
private IEnumerator CanvasFadeInRoutine()
{
if (settlementCanvasGroup == null)
{
Debug.LogWarning("[SettlementController] settlementCanvasGroup is not assigned, cannot fade in");
yield break;
}
float fadeDuration = 0.25f;
float elapsed = 0f;
float startAlpha = settlementCanvasGroup.alpha;
float targetAlpha = 1f;
Debug.Log($"[SettlementController] Starting CanvasGroup fade-in from {startAlpha} to {targetAlpha} over {fadeDuration}s");
while (elapsed < fadeDuration)
{
elapsed += Time.unscaledDeltaTime;
float t = Mathf.Clamp01(elapsed / fadeDuration);
settlementCanvasGroup.alpha = Mathf.Lerp(startAlpha, targetAlpha, t);
yield return null;
}
// ȷ״̬
settlementCanvasGroup.alpha = targetAlpha;
settlementCanvasGroup.interactable = true;
settlementCanvasGroup.blocksRaycasts = true;
Debug.Log("[SettlementController] CanvasGroup fade-in complete");
canvasFadeCoroutine = null;
}
/// <summary>
/// ǰϷ - ¼ֺͳʼϷ״̬
/// </summary>
private void OnReplayButtonClicked()
{
Debug.Log("[SettlementController] Replay button clicked - restarting current game");
// ֤Ҫ
if (bmm == null)
{
Debug.LogError("[SettlementController] BeatmapManager is null, cannot replay");
return;
}
if (bmm.assignedSongData == null)
{
Debug.LogError("[SettlementController] AssignedSongData is null, cannot replay");
return;
}
// 浱ǰؿϢ¼أ
SongData songToReplay = bmm.assignedSongData;
int difficultyToReplay = bmm.assignedDifficulty;
if (difficultyToReplay < 0)
{
Debug.LogError("[SettlementController] AssignedDifficulty is invalid, cannot replay");
return;
}
Debug.Log($"[SettlementController] Reloading song: {songToReplay.songName}, difficulty: {difficultyToReplay}");
// ֹͣ
if (settlementAudioSource != null && settlementAudioSource.isPlaying)
{
settlementAudioSource.Stop();
}
// BeatmapManager Ϊһ׼
BeatmapManager.SetPendingSong(songToReplay, difficultyToReplay);
// ¼صǰGamePlay_gamePlay
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
/// <summary>
/// ѡ
/// </summary>
private void OnExitButtonClicked()
{
Debug.Log("[SettlementController] Exit button clicked - returning to song selection");
// ֹͣ
if (settlementAudioSource != null && settlementAudioSource.isPlaying)
{
settlementAudioSource.Stop();
}
// κδ
BeatmapManager.pendingSongData = null;
BeatmapManager.pendingDifficulty = -1;
// Ensure time scale restored
try { Time.timeScale = 1f; } catch {}
// If GameManager and its blackMaskImage available, start coroutine on gm to fade to black then load
if (gm != null && gm.blackMaskImage != null)
{
gm.StartCoroutine(FadeToBlackAndLoadOnGM("selectYourSongFirst", 0.25f));
}
else
{
SceneManager.LoadScene("selectYourSongFirst");
}
}
// Coroutine that will be started on the GameManager instance so that the gm's MonoBehaviour runs it
private IEnumerator FadeToBlackAndLoadOnGM(string sceneName, float duration)
{
if (gm == null || gm.blackMaskImage == null)
{
SceneManager.LoadScene(sceneName);
yield break;
}
var blackMask = gm.blackMaskImage;
// ensure image active
if (!blackMask.gameObject.activeSelf) blackMask.gameObject.SetActive(true);
float startA = blackMask.color.a;
float elapsed = 0f;
// enable raycast target while fading to block input
try { blackMask.raycastTarget = true; } catch {}
while (elapsed < duration)
{
elapsed += Time.unscaledDeltaTime;
float frac = Mathf.Clamp01(elapsed / Mathf.Max(0.0001f, duration));
Color c = blackMask.color;
c.a = Mathf.Lerp(startA, 1f, frac);
blackMask.color = c;
yield return null;
}
// ensure fully opaque
Color fc = blackMask.color;
fc.a = 1f;
blackMask.color = fc;
// load target scene
SceneManager.LoadScene(sceneName);
}
/// <summary>
/// ݸɫѡ߷ֵĽɫ AllyHero_SO е ally_hero_HD_image ֵ mvp_hero_hd_image
/// ʹ ScoreManager per-track pm sums˵ AllyCombatant.currentScore
/// </summary>
private void SetMvpHeroImageFromTopScorer()
{
if (mvp_hero_hd_image == null)
{
Debug.LogWarning("[SettlementController] mvp_hero_hd_image is not assigned");
return;
}
int topIndex = -1;
int[] scores = new int[5];
if (sm != null)
{
scores[0] = sm.red_pmScore_sum;
scores[1] = sm.green_pmScore_sum;
scores[2] = sm.yellow_pmScore_sum;
scores[3] = sm.purple_pmScore_sum;
scores[4] = sm.blue_pmScore_sum;
}
else
{
// fallback: read AllyCombatant.currentScore from scene
for (int i = 0; i < 5; i++)
{
var go = GameObject.Find($"ally_0{ i+1 }");
if (go != null)
{
var ac = go.GetComponent<AllyCombatant>();
scores[i] = ac != null ? ac.currentScore : 0;
}
else scores[i] = 0;
}
}
int max = -1;
for (int i = 0; i < scores.Length; i++)
{
if (scores[i] > max)
{
max = scores[i];
topIndex = i;
}
}
if (topIndex < 0 || max <= 0)
{
Debug.Log("[SettlementController] No top scorer found or scores are all zero");
// hide image
mvp_hero_hd_image.sprite = null;
mvp_hero_hd_image.color = new Color(mvp_hero_hd_image.color.r, mvp_hero_hd_image.color.g, mvp_hero_hd_image.color.b, 0f);
return;
}
// Try to resolve AllyHero_SO for the winning slot
AllyHero_SO heroSO = null;
// try SkillBuilder cache
if (SkillBuilder.Instance != null)
{
try { heroSO = SkillBuilder.Instance.GetAllyHeroSOBySlot(topIndex); }
catch { heroSO = null; }
}
// fallback: use teamUIController slot ids to find hero id -> load SO
if (heroSO == null && teamUIController.Instance != null && teamUIController.Instance.allySlotIds != null)
{
int allyId = -1;
if (topIndex >= 0 && topIndex < teamUIController.Instance.allySlotIds.Count)
allyId = teamUIController.Instance.allySlotIds[topIndex];
if (allyId > 0)
{
var arr = Resources.LoadAll<AllyHero_SO>("");
foreach (var a in arr)
{
if (a != null && a.ally_heroID == allyId) { heroSO = a; break; }
}
}
}
// final fallback: try direct Resources lookup by scanning all and picking first non-null for slot
if (heroSO == null)
{
var arr = Resources.LoadAll<AllyHero_SO>("");
if (arr != null && arr.Length > 0)
{
// attempt to match by name or just pick first
heroSO = arr[0] as AllyHero_SO;
}
}
if (heroSO != null && heroSO.ally_hero_HD_image != null)
{
mvp_hero_hd_image.sprite = heroSO.ally_hero_HD_image;
mvp_hero_hd_image.color = new Color(mvp_hero_hd_image.color.r, mvp_hero_hd_image.color.g, mvp_hero_hd_image.color.b, 1f);
Debug.Log($"[SettlementController] MVP assigned from slot {topIndex+1} with score {max}: {heroSO.ally_heroName}");
}
else
{
mvp_hero_hd_image.sprite = null;
mvp_hero_hd_image.color = new Color(mvp_hero_hd_image.color.r, mvp_hero_hd_image.color.g, mvp_hero_hd_image.color.b, 0f);
Debug.LogWarning("[SettlementController] MVP SO or HD image not found");
}
}
}