长音符再修复 加入了关卡连接 优化了一些默认加载

This commit is contained in:
FloatGaming
2026-01-13 11:22:39 +08:00
parent 2738089af1
commit 2f9d4aae0c
426 changed files with 299413 additions and 1050 deletions
@@ -451,4 +451,77 @@ public class AnimationController : MonoBehaviour
}
}
/// <summary>
/// Public API to prewarm particle instances. Call during loading/pause time to avoid hitch on first use.
/// This will instantiate the configured scene particle sources (hit_particular_object / hit_ring_object)
/// and briefly play their ParticleSystems to force any internal setup.
/// </summary>
public void PrewarmParticles(int perTemplate = 2)
{
// start coroutine to avoid blocking main thread with many instantiations
StartCoroutine(PrewarmCoroutine(perTemplate));
}
private IEnumerator PrewarmCoroutine(int perTemplate)
{
// List of templates to warm
var templates = new List<GameObject>();
if (hit_particular_object != null) templates.Add(hit_particular_object);
if (hit_ring_object != null) templates.Add(hit_ring_object);
for (int i = 0; i < templates.Count; i++)
{
var prefab = templates[i];
for (int j = 0; j < perTemplate; j++)
{
GameObject inst = null;
try
{
inst = Instantiate(prefab, this.transform);
inst.SetActive(true);
var systems = inst.GetComponentsInChildren<ParticleSystem>(true);
if (systems != null)
{
foreach (var ps in systems)
{
try { ps.Play(); } catch { }
}
}
}
catch { }
// wait a frame to allow any internal initialization to run
yield return null;
// stop and destroy the instance to free memory - the warmup work is done
if (inst != null)
{
var systems = inst.GetComponentsInChildren<ParticleSystem>(true);
if (systems != null)
{
foreach (var ps in systems)
{
try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { }
}
}
Destroy(inst);
}
// small yield to spread work across frames
yield return null;
}
}
yield break;
}
/// <summary>
/// Public routine variant that callers can yield on to wait until prewarm completes.
/// Use this when you need to ensure particle templates are fully instantiated and torn down
/// before proceeding (to avoid hiccups at first real use).
/// </summary>
public IEnumerator PrewarmParticlesRoutine(int perTemplate = 2)
{
yield return StartCoroutine(PrewarmCoroutine(perTemplate));
}
}
@@ -33,10 +33,39 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
public float fadeOutStartTime = 0.6f; // 生成后第几秒开始渐隐
public float fadeOutDuration = 0.3f; // 渐隐动画持续时长
// Cache spawn transforms to avoid accessing destroyed GameObject references.
private Transform redSpawn;
private Transform greenSpawn;
private Transform yellowSpawn;
private Transform purpleSpawn;
private Transform blueSpawn;
private void Awake()
{
if (Instance == null) Instance = this;
else Destroy(gameObject);
else
{
Destroy(gameObject);
return;
}
CacheSpawnPoints();
}
private void OnEnable()
{
// In case this object survives scene changes / references are assigned late.
if (redSpawn == null && redEffect != null) CacheSpawnPoints();
}
private void CacheSpawnPoints()
{
// Important: check Unity "fake null" before accessing .transform
redSpawn = redEffect != null ? redEffect.transform : null;
greenSpawn = greenEffect != null ? greenEffect.transform : null;
yellowSpawn = yellowEffect != null ? yellowEffect.transform : null;
purpleSpawn = purpleEffect != null ? purpleEffect.transform : null;
blueSpawn = blueEffect != null ? blueEffect.transform : null;
}
public void SpawnJudgePrefab(string color, string judgeResult)
@@ -95,7 +124,7 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
// --- 2. 透明度逻辑 (基于秒数) ---
if (sr != null)
{
float alpha = 1f;
float alpha;
// 渐显阶段:当前时间 < fadeInTime
if (elapsed < fadeInTime)
@@ -138,19 +167,28 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
private Transform GetSpawnPoint(string color)
{
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)
{
CacheSpawnPoints();
}
switch (color.ToLower())
{
case "red": return redEffect?.transform;
case "green": return greenEffect?.transform;
case "yellow": return yellowEffect?.transform;
case "purple": return purpleEffect?.transform;
case "blue": return blueEffect?.transform;
case "red": return redSpawn;
case "green": return greenSpawn;
case "yellow": return yellowSpawn;
case "purple": return purpleSpawn;
case "blue": return blueSpawn;
default: return null;
}
}
private GameObject GetJudgePrefab(string result)
{
if (string.IsNullOrEmpty(result)) return null;
switch (result.ToLower())
{
case "perfect": return perfect_judge_prefab;
@@ -5,6 +5,37 @@ using System.Collections.Generic;
public class BeatmapManager : MonoBehaviour
{
// static holder to accept SongData passed from previous scene before this manager exists
public static SongData pendingSongData = null;
public static int pendingDifficulty = -1;
/// <summary>
/// Called by previous scene to hand over a SongData SO and difficulty to this manager after scene load.
/// This stores the pair in static fields so BeatmapManager.Start can pick them up.
/// </summary>
public static void SetPendingSong(SongData song, int difficulty)
{
pendingSongData = song;
pendingDifficulty = difficulty;
Debug.LogWarning($"BeatmapManager.SetPendingSong called: song={(song==null?"NULL":song.songName)}, difficulty={difficulty}");
}
[Header("Assigned SongData (set at runtime from previous scene or via Inspector)")]
public SongData assignedSongData; // visible in Inspector so you can see the SO passed from previous scene
[Header("Assigned difficulty (for inspector visibility)")]
public int assignedDifficulty = -1;
/// <summary>
/// Public API to accept a SongData at runtime (can be called by other scripts after scene load)
/// Also sets inspector-exposed fields so you can visually confirm in Editor during play.
/// </summary>
public void AcceptSongData(SongData song, int difficulty)
{
assignedSongData = song;
assignedDifficulty = difficulty;
Debug.LogWarning($"BeatmapManager.AcceptSongData: accepted song={(song==null?"NULL":song.songName)}, difficulty={difficulty}");
}
public Beatmap beatmap; // 当前谱面数据
// 音符生成器引用
@@ -45,9 +76,6 @@ public class BeatmapManager : MonoBehaviour
public float inMultiplier = 2f;
public float imMultiplier = 3f;
// 新增:基础生命值缩放因子(Base HP Unit Scale
// 用于将 Note 数量(例如 709)缩放到 40000 左右的范围。
// 计算公式:NoteAmount * DifficultyMult * BaseScale ≈ TotalHP
[Header("HP Calculation Settings")]
[Tooltip("Scales the note amount to enemy HP. Example: NoteAmount * MaxMultiplier * BaseScale ≈ TotalHP")]
public float baseHpUnitScale = 1f;
@@ -205,9 +233,119 @@ public class BeatmapManager : MonoBehaviour
}
}
// 新增:从 TextAsset 解析并根据 parseOnly 决定是否只是解析或直接加载
public bool LoadBeatmapFromTextAsset(TextAsset chartAsset, bool parseOnly = false)
{
if (chartAsset == null)
{
Debug.LogWarning("LoadBeatmapFromTextAsset: chartAsset is null");
return false;
}
if (string.IsNullOrEmpty(chartAsset.text))
{
Debug.LogWarning("LoadBeatmapFromTextAsset: chartAsset.text is null or empty");
return false;
}
if (parseOnly)
{
return ParseJsonOnly(chartAsset.text);
}
else
{
ProcessJsonAndLoad(chartAsset.text);
return true;
}
}
// 新增:从 SongData 的 chart TextAsset 加载谱面
public bool LoadBeatmapFromSongData(SongData song, int difficulty, bool parseOnly = false)
{
if (song == null)
{
Debug.LogWarning("LoadBeatmapFromSongData: song is null");
return false;
}
TextAsset ta = song.GetChartFile(difficulty);
if (ta == null)
{
Debug.LogWarning($"LoadBeatmapFromSongData: chart TextAsset for difficulty {difficulty} is null on song {song.songName}");
return false;
}
Debug.LogWarning($"LoadBeatmapFromSongData: loading chart for song {song.songName}, difficulty {difficulty}, parseOnly={parseOnly}, chartSize={(ta.text != null ? ta.text.Length : 0)}");
return LoadBeatmapFromTextAsset(ta, parseOnly);
}
// 在 Start() 方法中初始化
void Start()
{
// 被动接收上一个场景传递过来的 SongData(通过 BeatmapManager.pendingSongData
if (pendingSongData != null)
{
Debug.LogWarning($"BeatmapManager.Start: pendingSongData detected: {pendingSongData.songName}, difficulty={pendingDifficulty}");
// 将 pending 显示到 Inspector 字段,便于检查
assignedSongData = pendingSongData;
assignedDifficulty = pendingDifficulty;
// 尝试解析(只解析,不立即生成音符)
bool ok = LoadBeatmapFromSongData(assignedSongData, assignedDifficulty, true);
if (!ok) Debug.LogWarning("BeatmapManager.Start: LoadBeatmapFromSongData failed");
else
{
Debug.LogWarning("BeatmapManager.Start: chart parsed from SongData, attempting to assign audio and pause system");
// try to assign audio to GameManager.musicSource
var gm = FindObjectOfType<GameManager>();
if (gm != null && gm.musicSource != null)
{
if (assignedSongData != null && assignedSongData.audioFile != null)
{
gm.musicSource.clip = assignedSongData.audioFile;
gm.musicSource.loop = false;
Debug.LogWarning("Assigned SongData.audioFile to GameManager.musicSource.clip (from SO)");
}
else if (!string.IsNullOrEmpty(parsedMusicFile))
{
Debug.LogWarning($"Attempting Resources.Load for audio: {parsedMusicFile}");
var ac = Resources.Load<AudioClip>(parsedMusicFile);
if (ac != null)
{
gm.musicSource.clip = ac;
gm.musicSource.loop = false;
Debug.LogWarning("Assigned audio via Resources.Load(parsedMusicFile)");
}
else
{
Debug.LogWarning($"Resources.Load failed for '{parsedMusicFile}'");
}
}
else
{
Debug.LogWarning("No audio available on SongData and parsedMusicFile empty");
}
}
else
{
Debug.LogWarning("GameManager or its musicSource not found; audio not assigned");
}
// Pause the system to show pause overlay (PauseManager will enable overlayRoot)
var pauseMgr = PauseManager.Instance ?? FindObjectOfType<PauseManager>();
if (pauseMgr != null)
{
pauseMgr.Pause(true);
Debug.LogWarning("System paused after loading chart and audio to allow player to start");
}
else
{
Debug.LogWarning("PauseManager not found in scene; cannot pause system automatically");
}
}
// clear pending so it doesn't run again
pendingSongData = null;
pendingDifficulty = -1;
}
// 如果处于测试模式,则跳过自动加载谱面
if (GameConfig.testMode)
{
@@ -215,20 +353,20 @@ public class BeatmapManager : MonoBehaviour
}
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()
{
// 假设 1=EZ, 2=HD, 3=IN, 4=IM
if (parsedDifficulty == 1) return ezMultiplier;
if (parsedDifficulty == 2) return hdMultiplier;
if (parsedDifficulty == 3) return inMultiplier;
if (parsedDifficulty == 4) return imMultiplier;
return 1f; // 默认值
return 1f;
}
// 统一处理敌人 ID 提取、发送给 UI 以及 HP 计算的逻辑
@@ -242,7 +380,6 @@ public class BeatmapManager : MonoBehaviour
if (!parsedEnemyListIsEmpty && parsedColorSegments != null && parsedColorSegments.Length > 0)
{
// 1. 提取敌人 IDs
List<int> enemyIds = new List<int>();
foreach (var segment in parsedColorSegments)
{
@@ -256,43 +393,29 @@ public class BeatmapManager : MonoBehaviour
enemyIds.Add(enemyId);
}
// 2. 补齐 5 个槽位
while (enemyIds.Count < 5)
{
enemyIds.Add(0);
}
while (enemyIds.Count < 5) enemyIds.Add(0);
// A. 传递 ID 列表给 uiController
Debug.Log($"Assigning enemySlotIds to teamUIController: {string.Join(",", enemyIds)}");
uiController.enemySlotIds = enemyIds;
// B. 让 UI 控制器加载 SO (此时 SO 的 HP 仍是默认值)
uiController.PopulateEnemySOsFromIds();
// C. 计算并应用 HP 逻辑
if (parsedNoteAmount > 0)
{
float currentMultiplier = GetCurrentDifficultyMultiplier();
// 计算总 HP:音符数 * 难度乘数 * 基础缩放
float totalCalculatedHP = parsedNoteAmount * currentMultiplier * baseHpUnitScale;
// 计算活跃敌人数量(非0 ID
int activeEnemyCount = enemyIds.FindAll(id => id != 0).Count;
if (activeEnemyCount == 0) activeEnemyCount = 1; // 防止除零
if (activeEnemyCount == 0) activeEnemyCount = 1;
// 计算单个敌人 HP (总血量 / 敌人数量,均匀分配)
int individualMaxHP = Mathf.RoundToInt(totalCalculatedHP / activeEnemyCount);
Debug.Log($"[BeatmapManager] HP Calc -> Notes: {parsedNoteAmount}, Multiplier: {currentMultiplier}, Total: {totalCalculatedHP:F0}, ActiveEnemies: {activeEnemyCount}, Per Enemy: {individualMaxHP}");
// D. 【关键调用】将计算出的 HP 回填到 teamUIController 中的 SO 中
uiController.ApplyCalculatedEnemyHP(individualMaxHP);
}
}
else
{
// 如果没有敌人,也要通知 UI 控制器清空列表
Debug.Log("Parsed enemy list is empty, clearing UI.");
uiController.enemySlotIds = new List<int> { 0, 0, 0, 0, 0 };
uiController.PopulateEnemySOsFromIds();
@@ -308,7 +431,6 @@ public class BeatmapManager : MonoBehaviour
return;
}
// Parse main beatmap
Beatmap parsed = null;
try
{
@@ -326,17 +448,11 @@ public class BeatmapManager : MonoBehaviour
return;
}
// Parse extras using BeatmapExtra which contains the requested fields and types
BeatmapExtra extra = null;
try
{
extra = JsonUtility.FromJson<BeatmapExtra>(json);
}
catch { extra = null; }
try { extra = JsonUtility.FromJson<BeatmapExtra>(json); } catch { extra = null; }
if (extra != null)
{
// store into manager fields
parsedTitle = extra.title;
parsedComposer = extra.composer;
parsedIllustrator = extra.illustrator;
@@ -354,24 +470,16 @@ public class BeatmapManager : MonoBehaviour
parsedNoteAmount = extra.noteAmount;
globalDelaySeconds = extra.globalDelaySeconds;
// If JSON contains absolute paths for music/background, copy into parsed object for later use
if (!string.IsNullOrEmpty(extra.musicFile)) parsed.musicFile = extra.musicFile;
if (!string.IsNullOrEmpty(extra.backgroundFile)) parsed.backgroundFile = extra.backgroundFile;
Debug.Log($"Beatmap extras parsed: title={parsedTitle}, composer={parsedComposer}, duration={parsedDuration}, bpm={parsedBpm}, difficulty={parsedDifficulty}, noteAmount={parsedNoteAmount}, globalDelaySeconds={globalDelaySeconds}");
// New fields
parsedEnemyListIsEmpty = extra.enemyList_isEmpty;
if (!parsedEnemyListIsEmpty)
{
parsedColorSegments = extra.colorSegments;
}
if (!parsedEnemyListIsEmpty) parsedColorSegments = extra.colorSegments;
parsedNoteStatistics = extra.noteStatistics;
parsedTrackStates = extra.trackStates;
}
else
{
// Fallback to populate from parsed Beatmap where possible
parsedTitle = parsed.title;
parsedComposer = parsed.composer;
parsedIllustrator = parsed.illustrator;
@@ -382,7 +490,6 @@ public class BeatmapManager : MonoBehaviour
parsedMusicFile = parsed.musicFile;
parsedBackgroundFile = parsed.backgroundFile;
// convert types
parsedDuration = Mathf.RoundToInt(parsed.duration);
parsedBpm = Mathf.RoundToInt(parsed.bpm);
parsedDifficulty = parsed.difficulty;
@@ -390,29 +497,17 @@ public class BeatmapManager : MonoBehaviour
Debug.LogWarning("BeatmapExtra not found in JSON; populated limited fields from Beatmap.");
}
// Calculate per-note and leftover scores
CalculateNoteScores();
// NOTE: Do NOT modify parsed.notes here. globalDelaySeconds will be applied to audio playback instead.
// assign and hand off to spawner
beatmap = parsed;
Debug.Log("谱面已加载:" + beatmap.title);
if (noteSpawner != null)
{
noteSpawner.LoadBeatmap(beatmap);
}
else
{
Debug.LogError("NoteSpawner is null in BeatmapManager.");
}
if (noteSpawner != null) noteSpawner.LoadBeatmap(beatmap);
else Debug.LogError("NoteSpawner is null in BeatmapManager.");
// 调用统一的敌人处理逻辑(包含 HP 计算和设置)
SetupEnemiesAndHP();
}
// Calculate per-note and leftover scores based on parsedNoteAmount
private void CalculateNoteScores()
{
if (parsedNoteAmount > 0)
@@ -429,7 +524,6 @@ public class BeatmapManager : MonoBehaviour
}
}
// Helper class used to parse extra fields from JSON that Beatmap doesn't define
[System.Serializable]
private class BeatmapExtra
{
@@ -449,43 +543,18 @@ public class BeatmapManager : MonoBehaviour
public int noteAmount;
public float globalDelaySeconds;
// New fields
public bool enemyList_isEmpty;
public ColorSegment[] colorSegments;
public NoteStatistic[] noteStatistics;
public TrackStates trackStates;
}
// New serializable classes for the extra fields
[System.Serializable]
public class ColorSegment
{
public string enemyID;
public float percentage;
}
public class ColorSegment { public string enemyID; public float percentage; }
[System.Serializable]
public class NoteStatistic
{
public string colorType;
public int count;
}
public class NoteStatistic { public string colorType; public int count; }
[System.Serializable]
public class TrackStates
{
public bool trackFading_active;
public TrackState red;
public TrackState green;
public TrackState yellow;
public TrackState purple;
public TrackState blue;
}
public class TrackStates { public bool trackFading_active; public TrackState red; public TrackState green; public TrackState yellow; public TrackState purple; public TrackState blue; }
[System.Serializable]
public class TrackState
{
public bool isOn;
public float fadeTime;
}
public class TrackState { public bool isOn; public float fadeTime; }
}
+319 -73
View File
@@ -2,14 +2,15 @@ using System.IO;
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using UnityEngine.Networking;
public class GameManager : MonoBehaviour
{
public BeatmapManager beatmapManager; // 负责加载和管理谱面
public NoteSpawner noteSpawner; // 音符生成器
public AudioSource musicSource; // 音乐播放器
public BeatmapManager beatmapManager; // غ͹
public NoteSpawner noteSpawner; //
public AudioSource musicSource; // ֲ
[Header("paths")]
// 变量声明不再从静态变量中读取
// ٴӾ̬жȡ
public string JSONPath;
public string audioPath;
public string bgPicPath;
@@ -21,7 +22,15 @@ public class GameManager : MonoBehaviour
[Header("Debug / Test")]
public Text banflagTextUI;
[Header("UI Overlays")]
public UnityEngine.UI.Image blackMaskImage; // assign in inspector: full-screen black overlay
[Header("Playback")]
[Tooltip("Delay in seconds after unpausing (press Space) before starting audio and spawning. Configure in inspector.")]
public float playbackStartDelay = 3f;
private bool pauseSubscribed = false;
private bool musicWasPlayingBeforePause = false;
private void SubscribeToPauseManager()
{
@@ -60,37 +69,148 @@ public class GameManager : MonoBehaviour
{
if (musicSource != null)
{
if (isPaused)
try
{
musicSource.Pause();
if (isPaused)
{
// remember whether music was playing and pause it to stop timeline advancing
musicWasPlayingBeforePause = musicSource.isPlaying;
if (musicWasPlayingBeforePause)
{
musicSource.Pause();
}
}
else
{
// Do not change mute state here. External startup flow will unmute when appropriate.
// resume if it was playing before pause
if (musicWasPlayingBeforePause)
{
try { musicSource.UnPause(); } catch { }
}
// reset flag
musicWasPlayingBeforePause = false;
}
}
catch { }
}
}
/// <summary>
/// Fade out the black mask image over duration seconds (unscaled), then disable its raycast target so UI passes through.
/// </summary>
public IEnumerator FadeOutBlackMask(float duration)
{
if (blackMaskImage == null) yield break;
// ensure image active
if (!blackMaskImage.gameObject.activeSelf) blackMaskImage.gameObject.SetActive(true);
float t = 0f;
Color c = blackMaskImage.color;
float startA = c.a;
while (t < duration)
{
t += Time.unscaledDeltaTime;
float frac = Mathf.Clamp01(t / Mathf.Max(0.0001f, duration));
c.a = Mathf.Lerp(startA, 0f, frac);
blackMaskImage.color = c;
yield return null;
}
// ensure fully transparent
c.a = 0f;
blackMaskImage.color = c;
// disable raycast so underlying UI can receive input
try { blackMaskImage.raycastTarget = false; } catch { }
// optionally deactivate the overlay to save draw calls
blackMaskImage.gameObject.SetActive(false);
}
// Public helper to reliably unmute and start music playback, respecting delay.
public void PlayMusicWithDelay(float delaySeconds)
{
if (musicSource == null) return;
try
{
// Ensure unmuted
musicSource.mute = false;
}
catch { }
try
{
// If a delay is specified, stop current and schedule playback
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");
}
else
{
musicSource.UnPause();
// 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"); }
}
}
}
catch (System.Exception ex)
{
Debug.LogWarning($"PlayMusicWithDelay failed: {ex}");
try { musicSource.Play(); } catch { }
}
}
void Start()
{
Debug.Log("GameManager Start() 被调用");
Debug.Log("GameManager Start() ");
// 检查关键组件是否为空
if (beatmapManager == null) Debug.LogError("beatmapManager 未赋值!");
if (noteSpawner == null) Debug.LogError("noteSpawner 未赋值!");
if (musicSource == null) Debug.LogError("musicSource 未赋值!");
// ؼǷΪ
if (beatmapManager == null) Debug.LogError("beatmapManager δֵ");
if (noteSpawner == null) Debug.LogError("noteSpawner δֵ");
if (musicSource == null) Debug.LogError("musicSource δֵ");
// 如果 pressStart 已经读取了 banflag 内容,则把它显示在 UI(如果分配了)
// 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
{
musicSource.Play();
Debug.Log("GameManager: warmed up audio playback (muted)");
}
catch (System.Exception ex)
{
Debug.LogWarning("GameManager: failed to warm up audio: " + ex.Message);
}
}
// pressStart Ѿȡ banflag ݣʾ UIˣ
if (banflagTextUI != null && !string.IsNullOrEmpty(pressStart.banflagContent))
{
banflagTextUI.text = pressStart.banflagContent;
Debug.Log("Displayed banflag content from pressStart into banflagTextUI.");
}
// test mode 下使用外部路径预加载资源并暂停系统,等待按空格继续
// test mode ʹⲿ·ԤԴͣϵͳȴո
if (GameConfig.testMode)
{
// 延迟赋值:在 Start() 中获取静态路径,此时 pressStart.cs 已经运行并更新了它们
// ӳٸֵ Start() лȡ̬·ʱ pressStart.cs Ѿв
JSONPath = pressStart.testmode_beatmapJSON_path;
audioPath = pressStart.testmode_audioFile_path;
bgPicPath = pressStart.testmode_bgPicFile_path;
@@ -110,6 +230,22 @@ public class GameManager : MonoBehaviour
{
StartCoroutine(HandleNormalModeStartup());
}
// Ensure black mask blocks input and is fully opaque at scene start (will fade out after loading).
if (blackMaskImage != null)
{
try
{
Color cc = blackMaskImage.color;
cc.a = 1f;
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));
}
catch { }
}
}
private IEnumerator HandleTestModeStartup()
@@ -123,42 +259,92 @@ public class GameManager : MonoBehaviour
}
else
{
// 注意:这里仍然使用过时的 WWW,建议替换为 UnityWebRequest
// Use UnityWebRequestMultimedia instead of WWW which is deprecated and can be unreliable for file:// audio
string url = "file://" + audioPath;
using (var www = new WWW(url))
// determine audio type from extension for best compatibility
AudioType audioType = AudioType.UNKNOWN;
string ext = Path.GetExtension(audioPath)?.ToLowerInvariant();
if (!string.IsNullOrEmpty(ext))
{
yield return www;
if (!string.IsNullOrEmpty(www.error))
switch (ext)
{
Debug.LogError($"加载测试模式音频失败: {www.error}");
case ".wav": audioType = AudioType.WAV; break;
case ".ogg": audioType = AudioType.OGGVORBIS; break;
case ".mp3": audioType = AudioType.MPEG; break;
case ".aif":
case ".aiff": audioType = AudioType.AIFF; break;
default: audioType = AudioType.UNKNOWN; break;
}
}
using (var uwr = UnityWebRequestMultimedia.GetAudioClip(url, audioType))
{
yield return uwr.SendWebRequest();
#if UNITY_2020_1_OR_NEWER
if (uwr.result == UnityWebRequest.Result.ConnectionError || uwr.result == UnityWebRequest.Result.ProtocolError)
#else
if (uwr.isNetworkError || uwr.isHttpError)
#endif
{
Debug.LogError($"TestMode audio load failed: {uwr.error}");
}
else
{
try
AudioClip clip = DownloadHandlerAudioClip.GetContent(uwr);
if (clip == null)
{
AudioClip clip = www.GetAudioClip(false, false);
if (clip == null)
Debug.LogError("Failed to obtain AudioClip from downloaded data");
}
else
{
// wait for audio data to be loaded if necessary
float waitStart = Time.realtimeSinceStartup;
while (clip.loadState == AudioDataLoadState.Loading && Time.realtimeSinceStartup - waitStart < 5f)
{
Debug.LogError("从文件获取 AudioClip 失败");
yield return null;
}
if (clip.loadState != AudioDataLoadState.Loaded)
{
Debug.LogWarning($"AudioClip loadState is {clip.loadState}. It may still stream or be incomplete.");
}
if (musicSource == null)
{
Debug.LogError("musicSource is null - cannot assign TestMode audio");
}
else
{
clip.name = Path.GetFileName(audioPath);
musicSource.clip = clip;
musicSource.loop = false;
musicSource.playOnAwake = false;
// Warm up decoding by playing briefly muted; avoid try/catch around yield (not allowed).
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");
// After loading audio, enable overlay and pause via PauseManager
var pm = PauseManager.Instance ?? FindObjectOfType<PauseManager>();
if (pm != null)
{
pm.Pause(true);
Debug.Log("PauseManager.Pause(true) invoked after TestMode audio load");
}
}
}
catch (System.Exception ex)
{
Debug.LogError($"将音频设置到 AudioSource 时出错: {ex}");
}
}
}
}
}
else
{
Debug.LogWarning("TestMode audioPath 未配置。");
Debug.LogWarning("TestMode audioPath not provided");
}
// Preload background image into sprite renderer
@@ -179,7 +365,7 @@ public class GameManager : MonoBehaviour
}
catch (System.Exception ex)
{
Debug.LogError($"读取背景图片文件出错: {ex}");
Debug.LogError($"ȡͼƬļ: {ex}");
}
if (imgBytes != null)
@@ -190,29 +376,29 @@ 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)
// ޸ĵ㣺 SpriteRenderer ͸Ϊ 255 (1.0f)
Color color = backgroundRenderer.color;
color.a = 1.0f;
backgroundRenderer.color = color;
// 保持比例不变的最大适应由场景布局决定,记录 assignment
// ֱӦɳ־¼ assignment
Debug.Log("Background sprite assigned (ensure your SpriteRenderer size fits scene)");
}
else
{
Debug.LogError($"无法从字节创建 Texture2D: {bgPicPath}");
Debug.LogError($"޷ֽڴ Texture2D: {bgPicPath}");
}
}
}
}
else
{
Debug.LogWarning("TestMode bgPicPath 未配置。");
Debug.LogWarning("TestMode bgPicPath δá");
}
}
else
{
Debug.LogWarning("backgroundRenderer 未配置,跳过背景加载。");
Debug.LogWarning("backgroundRenderer δãء");
}
// Read JSON from JSONPath and parse via BeatmapManager.ParseJsonOnly
@@ -231,14 +417,14 @@ public class GameManager : MonoBehaviour
}
catch (System.Exception ex)
{
Debug.LogError($"读取 TestMode JSON 文件出错: {ex}");
Debug.LogError($"ȡ TestMode JSON ļ: {ex}");
yield break;
}
bool parsed = beatmapManager.ParseJsonOnly(jsonContent);
if (!parsed)
{
Debug.LogError("ParseJsonOnly 失败,停止启动测试模式。");
Debug.LogError("ParseJsonOnly ʧܣֹͣģʽ");
yield break;
}
@@ -247,7 +433,7 @@ public class GameManager : MonoBehaviour
}
else
{
Debug.LogError("TestMode JSONPath 未配置。");
Debug.LogError("TestMode JSONPath δá");
yield break;
}
@@ -265,6 +451,8 @@ public class GameManager : MonoBehaviour
// 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));
// Start spawning using the parsed beatmap
if (beatmapManager.beatmap != null)
@@ -273,40 +461,93 @@ public class GameManager : MonoBehaviour
}
else
{
Debug.LogError("启动播放失败:beatmap 未解析或为空");
Debug.LogError("ʧܣbeatmap δΪ");
yield break;
}
// If audio clip was loaded into musicSource.clip, play now with delay from parsed globalDelaySeconds
float delaySeconds = beatmapManager != null ? beatmapManager.globalDelaySeconds : 0f;
if (musicSource.clip != null)
{
if (delaySeconds > 0f)
{
musicSource.PlayDelayed(delaySeconds);
Debug.Log($"Scheduled test-mode music to play after {delaySeconds} seconds.");
}
else
{
musicSource.Play();
}
}
else
{
Debug.LogWarning("TestMode: no audio clip loaded into AudioSource; skipping play.");
}
PlayMusicWithDelay(delaySeconds);
UpdateStatusOnConsole("Playback started");
}
private IEnumerator HandleNormalModeStartup()
{
// 加载谱面但不生成音符
// If a Beatmap was already provided by previous scene (pendingSongData path), skip loading default JSON
if (beatmapManager != null && beatmapManager.beatmap != null)
{
Debug.Log("HandleNormalModeStartup: existing beatmap found on BeatmapManager; skipping default JSON load.");
// Pause the system until Space pressed
SubscribeToPauseManager();
PauseManager.Instance?.Pause(true);
UpdateStatusOnConsole("Paused: press Space to start playback");
// Wait for space
while (!Input.GetKeyDown(KeyCode.Space))
{
yield return null;
}
// 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));
// Start spawning using the existing parsed beatmap
if (beatmapManager.beatmap != null)
{
noteSpawner.LoadBeatmap(beatmapManager.beatmap);
}
else
{
Debug.LogError("HandleNormalModeStartup: beatmap was expected but is null");
yield break;
}
// Play background music if assigned
if (musicSource != null && musicSource.clip != null)
{
float delay = beatmapManager != null ? beatmapManager.globalDelaySeconds : 0f;
PlayMusicWithDelay(delay);
}
else
{
// try load via parsedMusicFile if available
if (beatmapManager != null && !string.IsNullOrEmpty(beatmapManager.parsedMusicFile))
{
AudioClip musicClip = Resources.Load<AudioClip>(beatmapManager.parsedMusicFile);
if (musicClip != null)
{
musicSource.clip = musicClip;
// ensure we don't accidentally autoplay when assigning clips
musicSource.playOnAwake = false;
musicSource.Stop();
float delay = beatmapManager.globalDelaySeconds;
PlayMusicWithDelay(delay);
// ensure overlay shows and pause logic runs after loading the clip
var pm2 = PauseManager.Instance ?? FindObjectOfType<PauseManager>();
if (pm2 != null)
{
pm2.Pause(true);
Debug.Log("PauseManager.Pause(true) invoked after assigning musicClip in normal startup");
}
}
}
}
UpdateStatusOnConsole("Playback started");
yield break;
}
// ԭ̣
string beatmapFilePath = Path.Combine(Application.streamingAssetsPath, "Emilia_demo.json");
if (!File.Exists(beatmapFilePath))
{
Debug.LogError($"谱面文件不存在: {beatmapFilePath}");
Debug.LogError($"ļ: {beatmapFilePath}");
yield break;
}
@@ -314,61 +555,66 @@ public class GameManager : MonoBehaviour
bool parsed = beatmapManager.ParseJsonOnly(json);
if (!parsed)
{
Debug.LogError("ParseJsonOnly 失败");
Debug.LogError("ParseJsonOnly ʧ");
yield break;
}
// 暂停系统
// ͣϵͳ
SubscribeToPauseManager();
PauseManager.Instance?.Pause(true);
UpdateStatusOnConsole("Paused: press Space to start playback");
// 等待空格
// ȴո
while (!Input.GetKeyDown(KeyCode.Space))
{
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));
// 生成音符
//
if (beatmapManager.beatmap != null)
{
noteSpawner.LoadBeatmap(beatmapManager.beatmap);
}
else
{
Debug.LogError("beatmap 未解析");
Debug.LogError("beatmap δ");
yield break;
}
// 播放背景音乐(延迟播放由 beatmapManager.globalDelaySeconds 决定)
// ű֣ӳٲ beatmapManager.globalDelaySeconds
if (!string.IsNullOrEmpty(beatmapManager.parsedMusicFile))
{
AudioClip musicClip = Resources.Load<AudioClip>(beatmapManager.parsedMusicFile);
if (musicClip == null)
{
Debug.LogError($"音乐文件加载失败: {beatmapManager.parsedMusicFile}");
Debug.LogError($"ļʧ: {beatmapManager.parsedMusicFile}");
}
else
{
musicSource.clip = musicClip;
// ensure we don't accidentally autoplay when assigning clips
musicSource.playOnAwake = false;
musicSource.Stop();
float delay = beatmapManager.globalDelaySeconds;
if (delay > 0f)
PlayMusicWithDelay(delay);
// After assigning clip, enable overlay and pause
var pm3 = PauseManager.Instance ?? FindObjectOfType<PauseManager>();
if (pm3 != null)
{
musicSource.PlayDelayed(delay);
Debug.Log($"Scheduled music to play after {delay} seconds.");
}
else
{
musicSource.Play();
pm3.Pause(true);
Debug.Log("PauseManager.Pause(true) invoked after assigning parsedMusicFile clip");
}
}
}
else
{
Debug.LogError("谱面中 musicFile 为空!");
Debug.LogError(" musicFile Ϊգ");
}
UpdateStatusOnConsole("Playback started");
+282 -227
View File
@@ -18,6 +18,7 @@ public class HoldNote : BaseNote
private bool hasEnteredLine = false;
private Coroutine autoReturnCoroutine;
private Coroutine scheduledReturnCoroutine;
private HoldNoteController controller;
private AnimationController anim;
@@ -32,14 +33,15 @@ public class HoldNote : BaseNote
public KeyCode key;
public string type; // "start", "middle", or "end"
private bool isJudged = false; // 分段判定标记,一旦判定(无论成功失败)就设为true,避免重复判定
private bool isHoldActive = false; // 长按有效标记:Start段成功判定且按键未松开
// Flags and runtime state
private bool isJudged = false; // whether this segment has been judged (true = evaluation done)
private bool isHoldActive = false; // whether the hold is currently active (player is holding)
// 新增变量
private bool hasBeenHeldFromStart = false; // 进入判定区时是否已按住 (用于辅助判断,但不再直接阻断判定)
// whether this middle segment was held from the start (used for logic checks)
private bool hasBeenHeldFromStart = false;
[Header("判定区间配置")]
public NoteJudgeConfig judgeConfig; // 判定区间配置,需在预制体或生成时赋值
[Header("Judge configuration")]
public NoteJudgeConfig judgeConfig; // judge windows configuration
private NoteData noteData;
// store original transform scale so we can restore on reset
@@ -48,13 +50,33 @@ public class HoldNote : BaseNote
private Transform visualTransform = null;
private Vector3 originalVisualLocalScale = Vector3.one;
[Header("判定调整(仅对长音符生效)")]
[Header("Hold judgement adjustments")]
[Tooltip("Multiplier applied to judgement windows for hold notes. >1 makes hold judgement more lenient (wider windows).")]
[Range(1f, 2f)]
public float holdWindowMultiplier = 1.3f;
public float visualSpeedMultiplier = 1f; // injected from NoteSpawner to adapt windows when visual speed changes
// track when the hold actually started (real-time) so we can compute held fraction
private float holdStartTime = -1f;
// Cache allies per track to avoid GameObject.Find on first judgement.
private static AllyCombatant[] allyCache;
private static AllyCombatant GetAllyForTrackCached(int trackIndex)
{
if (trackIndex < 0) return null;
if (allyCache == null || allyCache.Length < 10) allyCache = new AllyCombatant[10];
if (allyCache[trackIndex] != null) return allyCache[trackIndex];
var allyGo = GameObject.Find($"ally_0{trackIndex + 1}");
if (allyGo != null)
{
allyCache[trackIndex] = allyGo.GetComponent<AllyCombatant>();
}
return allyCache[trackIndex];
}
private void Awake()
{
controller = GetComponent<HoldNoteController>();
@@ -71,7 +93,7 @@ public class HoldNote : BaseNote
if (anim == null)
{
Debug.LogWarning("HoldNote: No AnimationController found (Global/local/scene). Particle effects will be unavailable.");
if (GameConfig.verboseLogs) Debug.LogWarning("HoldNote: No AnimationController found (Global/local/scene). Particle effects will be unavailable.");
}
if (controller != null)
@@ -166,48 +188,90 @@ public class HoldNote : BaseNote
this.hasEnteredLine = false;
this.hasReleased = false;
// 判定区间配置检查
if (judgeConfig == null)
Debug.LogError($"HoldNoteJudgeConfig is null! 判定区间配置未传入!track={trackIndex}");
{
if (GameConfig.verboseLogs) Debug.LogError($"HoldNoteJudgeConfig is null! judgeConfig not assigned. track={trackIndex}");
}
else
Debug.Log($"HoldNoteJudgeConfig: perfect={judgeConfig.perfectRange}, great={judgeConfig.greatRange}, good={judgeConfig.goodRange}, miss={judgeConfig.missRange}");
{
if (GameConfig.verboseLogs) Debug.Log($"HoldNoteJudgeConfig: perfect={judgeConfig.perfectRange}, great={judgeConfig.greatRange}, good={judgeConfig.goodRange}, miss={judgeConfig.missRange}");
}
this.noteData = noteData;
// Debug info to help investigate end note visibility
Debug.Log($"[HoldNote.Setup] id={noteID} segment={segment} type={type} hitTime={hitTime:F2} scheduledEnd={scheduledEndTime:F2} color={color} track={trackIndex}");
if (GameConfig.verboseLogs)
{
Debug.Log($"[HoldNote.Setup] id={noteID} segment={segment} type={type} hitTime={hitTime:F2} scheduledEnd={scheduledEndTime:F2} color={color} track={trackIndex}");
Debug.Log($"[HoldNote.Setup] Controller present={(controller != null)}, AnimationController global={(AnimationController.Global != null)}, localAnim={(anim != null)}");
}
// 在 Setup 时注册初始状态
// Register initial judge state in JudgeManager
JudgeManager.Instance?.RegisterNoteReleased(noteID, false);
if (segment == NoteSegment.Start)
JudgeManager.Instance?.RegisterStartJudged(noteID, false); // 初始为未判定
JudgeManager.Instance?.RegisterStartJudged(noteID, false); // initially mark start as not judged
if (segment == NoteSegment.End)
JudgeManager.Instance?.RegisterScheduledEndTime(noteID, scheduledEndTime);
controller?.SetSpeed(speed);
controller?.SetSegmentDelay(delay);
// IMPORTANT: configure timing using the same timebase as hitTime.
// NoteSpawner/Setup passes 'time' as the base realtime hit point already (startTime + note.time + globalHitDelay).
// We need travelTime so the segment starts moving at (hitTime - travelTime) rather than (Time.time + delay).
float travelTime = 0f;
if (speed > 0.0001f)
{
// NoteSpawner.CalculateSpeed uses: speed = 10.75f / travelTime, so travelTime = 10.75f / speed
travelTime = 10.75f / speed;
}
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);
}
}
else
{
// fallback
controller?.SetSegmentDelay(delay);
}
// Removed: CalibratePosition for delay==0f was causing Start segments to snap to incorrect positions
// if they started moving immediately after Setup (due to activationTime <= Time.time).
// Start segments should begin at spawnPoint and move from there naturally.
}
private void Update()
{
// 如果当前片段已经判定过,直接返回
// If already judged, skip heavy logic
if (isJudged) return;
// 重新启动长按粒子协程,如果长按状态恢复且协程未运行
// If start was judged and player is holding key, start hold effects
if (!isHoldActive && JudgeManager.Instance.IsStartJudged(noteID) && !JudgeManager.Instance.HasNoteReleased(noteID) && Input.GetKey(keyToPress))
{
isHoldActive = true;
AnimationController.Global?.StartHoldParticles(noteColor);
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Start particles started for {noteID} color={noteColor} at time={Time.time:F3}");
}
// 自动 Miss 判定:当音符经过判定线且未被判定时
// Auto-miss checks when inside judge line
if (hasEnteredLine && !isJudged)
{
float missRangeScaled = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier;
if(segment == NoteSegment.Start && Time.time > hitTime + missRangeScaled)
if (segment == NoteSegment.Start && Time.time > hitTime + missRangeScaled)
{
Debug.Log($"[HoldNote] START段超时自动Miss(自动): {noteColor}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] START auto-Miss: {noteColor}");
// register as missed
JudgeManager.Instance.RegisterStartJudged(noteID, false);
@@ -216,64 +280,63 @@ public class HoldNote : BaseNote
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");
isJudged = true;
ReturnToPool();
ScheduleReturnToPool(0.2f);
}
else if (segment == NoteSegment.End && Time.time > scheduledEndTime + missRangeScaled)
{
Debug.Log($"[HoldNote] END段超时自动Miss: {noteColor}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] END auto-Miss: {noteColor}");
HandleEnd(true);
isJudged = true;
}
}
// **通用长按状态更新:**
// Handle key release while holding
if (isHoldActive && Input.GetKeyUp(keyToPress))
{
isHoldActive = false;
AnimationController.Global?.StopHoldParticles();
Debug.Log($"[HoldNote] 按键 {keyToPress} 松开,长按状态失效。NoteID: {noteID}, Segment: {segment}, Type: {type}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] KeyUp {keyToPress} detected. NoteID: {noteID}, Segment: {segment}, Type: {type}");
// 当玩家松开按键时,向 JudgeManager 注册该长音符已被释放,确保尾部能正确检查释放状态
if (!hasReleased)
{
hasReleased = true;
releaseTime = Time.time;
JudgeManager.Instance?.RegisterNoteReleased(noteID, true);
Debug.Log($"[HoldNote] RegisterNoteReleased on KeyUp. NoteID: {noteID}, releaseTime={releaseTime:F2}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] RegisterNoteReleased on KeyUp. NoteID: {noteID}, releaseTime={releaseTime:F2}");
EvaluateHoldEnd(releaseTime, false);
}
}
// Start段判定
// allow early keypress within judgement window even before collider entered (helps slow visual speeds)
// Start judgement input handling
if (segment == NoteSegment.Start)
{
if (Input.GetKeyDown(keyToPress))
{
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] KeyDown detected for {noteID} key={keyToPress} time={Time.time:F3} hitTime={hitTime:F3} hasEnteredLine={hasEnteredLine}");
float pressTimeLocal = Time.time;
float maxWindow = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier;
if (Mathf.Abs(pressTimeLocal - hitTime) <= maxWindow)
{
HandleStart(false);
isJudged = true; // mark judged to avoid duplicates
}
else
{
// key pressed but outside hold window -> ignore (do not mark judged)
// do not mark isJudged here; Start remains active for hold
}
}
}
else if (segment == NoteSegment.Start && hasEnteredLine)
{
// legacy fallback (shouldn't normally hit because above handles Start)
// legacy fallback
if (Input.GetKeyDown(keyToPress))
{
HandleStart(false);
isJudged = true; // 无论成功失败都标记为已判定,避免重复尝试
isJudged = true;
}
}
// Middle段判定:中段只负责视觉/回收,不做 End 判定统计
else if (segment == NoteSegment.Middle
&& JudgeManager.Instance.IsStartJudged(noteID)
&& isHoldActive
@@ -283,16 +346,14 @@ public class HoldNote : BaseNote
{
if (hasEnteredLine)
{
Debug.Log($"[HoldNote] Middle段通过 (持续长按): {noteColor}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Middle passed: {noteColor}");
PlayHitAnimation();
// 把通过事件上报给 JudgeManager(记录中段通过)
JudgeManager.Instance?.RegisterMiddlePassed(noteID);
ReturnToPool();
ScheduleReturnToPool(0.2f);
isJudged = true;
}
}
}
// End段判定:现在 End 段判定由独立的尾部音符负责(即本对象仍可判定,但更重要的是尾部note会决定最终判定)
else if (segment == NoteSegment.End
&& JudgeManager.Instance.IsStartJudged(noteID)
&& !JudgeManager.Instance.HasNoteReleased(noteID))
@@ -314,7 +375,7 @@ public class HoldNote : BaseNote
if (segment == NoteSegment.Middle)
{
hasBeenHeldFromStart = Input.GetKey(keyToPress);
Debug.Log($"[HoldNote] Middle段进入判定线: color={noteColor}, isStartJudged={JudgeManager.Instance.IsStartJudged(noteID)}, hasReleased={JudgeManager.Instance.HasNoteReleased(noteID)}, isKeyHeld={Input.GetKey(keyToPress)}, hasBeenHeldFromStart={hasBeenHeldFromStart}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Middle enter: color={noteColor}, isStartJudged={JudgeManager.Instance.IsStartJudged(noteID)}, hasReleased={JudgeManager.Instance.HasNoteReleased(noteID)}, isKeyHeld={Input.GetKey(keyToPress)}, hasBeenHeldFromStart={hasBeenHeldFromStart}");
if (autoReturnCoroutine != null)
StopCoroutine(autoReturnCoroutine);
@@ -324,13 +385,12 @@ public class HoldNote : BaseNote
if (autoReturnCoroutine != null)
StopCoroutine(autoReturnCoroutine);
// 如果在尾部进入判定区时玩家已经释放按键,则此时应立即做 End 判定
// If already released before reaching the line, handle end judgement immediately
if (JudgeManager.Instance.HasNoteReleased(noteID) && !isJudged)
{
Debug.Log($"[HoldNote] End段进入判定线但已经释放,立即执行 End 判定: {noteColor}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] End entered but note already released: {noteColor}");
HandleEnd();
isJudged = true;
// ReturnToPool handled inside HandleEnd
return;
}
@@ -346,17 +406,16 @@ public class HoldNote : BaseNote
{
if (!JudgeManager.Instance.IsStartJudged(noteID))
{
Debug.Log($"[HoldNote] Start段离开判定线未判定,补偿 Miss 并回收: {noteColor}");
// 确保未判定的 Start 段在离开判定线时登记为 Miss
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Start left judge zone without being judged -> Miss: {noteColor}");
HandleStart(true);
isJudged = true;
ReturnToPool();
ScheduleReturnToPool(0.2f);
}
}
else if (segment == NoteSegment.Middle)
{
Debug.Log($"[HoldNote] Middle段离开判定线强制回收: {noteColor}");
ReturnToPool();
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Middle left judge zone: {noteColor}");
ScheduleReturnToPool(0.2f);
}
else if (segment == NoteSegment.End)
{
@@ -366,44 +425,36 @@ public class HoldNote : BaseNote
autoReturnCoroutine = null;
}
// 在尾部离开判定线时,按如下规则处理:
// - 如果头部未判定:补偿 Miss 并回收
// - 如果头部已判定且玩家已经释放:执行 End 判定并回收
// - 如果头部已判定且玩家仍在按住:不要强制 Miss,也不要立即回收,等待玩家松手或超时处理
if (!isJudged)
{
if (!JudgeManager.Instance.IsStartJudged(noteID))
{
Debug.Log($"[HoldNote] End段离开判定线且头部未判定,补偿 Miss: {noteColor}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] End left judge zone and start was not judged -> Miss: {noteColor}");
if (!JudgeManager.Instance.HasNoteReleased(noteID))
HandleEnd(true);
ReturnToPool();
ScheduleReturnToPool(0.2f);
}
else
{
if (JudgeManager.Instance.HasNoteReleased(noteID))
{
Debug.Log($"[HoldNote] End段离开判定线且已释放,执行 End 判定: {noteColor}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] End left judge zone and was released -> HandleEnd: {noteColor}");
HandleEnd();
isJudged = true;
ReturnToPool();
}
else
{
// 头部已判定且玩家仍在按住:保持显示,不作回收或判定,等待玩家松手或超时处理
Debug.Log($"[HoldNote] End段离开判定线,头部已判定且仍在按住,保持显示等待松手或超时: {noteColor}");
ScheduleReturnToPool(0.2f);
}
}
}
else
{
ReturnToPool();
ScheduleReturnToPool(0.2f);
}
}
}
private IEnumerator DelayedAutoReturnCheck()
{
// yield one frame to ensure any state changes are settled
yield return null;
if (segment == NoteSegment.End)
@@ -411,35 +462,72 @@ public class HoldNote : BaseNote
float timeToWait = Mathf.Max(0f, scheduledEndTime - Time.time);
if (timeToWait > 0f)
{
yield return new WaitForSeconds(timeToWait);
// use scaled time here so pause affects this wait
float target = Time.time + timeToWait;
while (Time.time < target)
{
yield return null;
}
}
if (!gameObject.activeSelf)
// If the object was deactivated while waiting, bail out
if (!gameObject.activeInHierarchy)
{
yield break;
}
// wait a short buffer after arriving to judge line before returning to pool
ScheduleReturnToPool(0.2f);
}
}
// Schedules a return-to-pool after a short delay. Cancels any previously scheduled return.
private void ScheduleReturnToPool(float delay)
{
if (scheduledReturnCoroutine != null)
{
StopCoroutine(scheduledReturnCoroutine);
scheduledReturnCoroutine = null;
}
if (!gameObject.activeInHierarchy)
{
return;
}
scheduledReturnCoroutine = StartCoroutine(DelayedReturnToPool(delay));
}
private IEnumerator DelayedReturnToPool(float delay)
{
yield return new WaitForSeconds(delay);
if (gameObject.activeSelf)
{
ReturnToPool();
}
scheduledReturnCoroutine = null;
}
private void HandleStart(bool forceMiss = false)
{
if (!JudgeManager.Instance.TryResolveStart(noteID))
{
Debug.Log($"[HoldNote] START 已被判定,跳过: {noteColor}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] START try-resolve failed: {noteColor}");
return;
}
// If forced miss (e.g. leaving judge zone without press), register miss immediately
if (forceMiss)
{
Debug.Log($"[HoldNote] START 强制 Miss (未按下): {noteColor}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] START forced Miss: {noteColor}");
ScoreManager.Instance.countMiss += 1;
JudgeManager.Instance.RegisterStartJudged(noteID, false);
isHoldActive = false;
InputManager.Instance?.ShowJudgeResult(trackIndex, "Miss");
JudgeSoundManager.Instance?.PlayJudgeSound("Miss");
teamUIController.Instance?.OnJudgeResult("Miss");
// Also show judgement prefab for forced miss on start
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, "Miss");
return;
}
@@ -456,32 +544,43 @@ public class HoldNote : BaseNote
if (offset <= pRange)
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
JudgeManager.Instance.RegisterStartJudged(noteID, true);
isHoldActive = true;
PlayHitAnimation();
AnimationController.Global?.StartHoldParticles(noteColor);
// record when the hold started so we can compute held fraction later
holdStartTime = pressTime;
// store into pool for use on end
HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData);
}
else if (offset <= gRange)
{
result = "Great";
ScoreManager.Instance.countGreat += 1;
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
JudgeManager.Instance.RegisterStartJudged(noteID, true);
isHoldActive = true;
PlayHitAnimation();
AnimationController.Global?.StartHoldParticles(noteColor);
holdStartTime = pressTime;
HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData);
}
else if (offset <= gdRange)
{
result = "Good";
ScoreManager.Instance.countGood += 1;
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
JudgeManager.Instance.RegisterStartJudged(noteID, true);
isHoldActive = true;
PlayHitAnimation();
AnimationController.Global?.StartHoldParticles(noteColor);
holdStartTime = pressTime;
HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData);
}
else
{
@@ -492,7 +591,7 @@ public class HoldNote : BaseNote
try
{
bool triggered = SkillBuilder.Instance?.NotifyNoteHit(this.trackIndex, result, SkillDefinition.NoteTypeTrigger.Tap, this.noteID) ?? false;
Debug.Log($"[HoldNote] START Miss NotifyNoteHit fired for slot {trackIndex}: {triggered}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] START Miss NotifyNoteHit fired for slot {trackIndex}: {triggered}");
}
catch (System.Exception ex)
{
@@ -500,30 +599,20 @@ public class HoldNote : BaseNote
}
}
// show judge result and play sound / update combo like short notes
// show judge result and update combo/UI like short notes
InputManager.Instance?.ShowJudgeResult(trackIndex, result);
JudgeSoundManager.Instance?.PlayJudgeSound(result);
teamUIController.Instance?.OnJudgeResult(result);
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, result); // 是否启动头音符判定跳字
// --- Add scoring for Start like short notes ---
try
// Play judge sound only for START segment
if (segment == NoteSegment.Start)
{
var allyGo = GameObject.Find($"ally_0{TrackIndex + 1}");
if (allyGo != null)
{
var ally = allyGo.GetComponent<AllyCombatant>();
if (ally != null)
{
int added = ally.AddScoreForJudge(result);
float efficiency = ally.scoreEfficiency;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency);
}
}
JudgeSoundManager.Instance?.PlayJudgeSound(result);
}
catch (System.Exception ex)
// Do not spawn the judgement animation prefab for Start (head) presses to avoid duplicated prefabs
if (segment != NoteSegment.Start)
{
Debug.LogWarning($"[HoldNote] Failed to add per-track score for START: {ex}");
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, result);
}
if (segment != NoteSegment.Start)
@@ -537,136 +626,120 @@ public class HoldNote : BaseNote
yield return new WaitForSeconds(0.05f);
if (gameObject.activeSelf)
{
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] DelayedReturn: returning {noteID} to pool at time={Time.time:F3}");
ReturnToPool();
}
}
private void HandleEnd(bool forceMiss = false)
// central evaluation for hold end, given an actual releaseTime (real-time) or forceMiss
private void EvaluateHoldEnd(float actualReleaseTime, bool forceMiss)
{
if (isJudged) return; // already evaluated
// ensure we only resolve once per note
if (!JudgeManager.Instance.TryResolveEnd(noteID))
{
Debug.Log($"[HoldNote] END 已被判定,跳过: {noteColor}");
return;
}
// 记录释放时间并在 JudgeManager 中登记已释放状态
if (!hasReleased)
{
releaseTime = Time.time;
hasReleased = true;
JudgeManager.Instance?.RegisterNoteReleased(noteID, true);
}
string result;
float rawOffsetMsEnd = 0f;
// 如果玩家在尾部进入判定区前就松手,则仅登记释放,不在此处进行最终判定和回收,除非是强制 Miss
if (!hasEnteredLine && !forceMiss)
{
Debug.Log($"[HoldNote] HandleEnd: 提前松手,记录释放但等待尾部进入判定区再判定. NoteID={noteID}, color={noteColor}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] EvaluateHoldEnd: TryResolveEnd failed for {noteID}");
return;
}
// 现在进行正常判定逻辑
releaseTime = Time.time;
releaseTime = actualReleaseTime;
hasReleased = true;
JudgeManager.Instance.RegisterNoteReleased(noteID, true);
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] EvaluateHoldEnd: noteID={noteID} releaseTime={releaseTime:F3} scheduledEnd={scheduledEndTime:F3} forceMiss={forceMiss}");
string result;
if (forceMiss || !JudgeManager.Instance.IsStartJudged(noteID))
{
result = "Miss";
// if (JudgeManager.Instance.TryResolveEnd(noteID))
{
if (result == "Perfect") ScoreManager.Instance.countPerfect += 1;
else if (result == "Great") ScoreManager.Instance.countGreat += 1;
else if (result == "Good") ScoreManager.Instance.countGood += 1;
else ScoreManager.Instance.countMiss += 1;
}
Debug.LogWarning($"[HoldNote] END判定失败({noteColor}):{(forceMiss ? "Miss" : "")}");
ScoreManager.Instance.countMiss += 1;
if (GameConfig.verboseLogs) Debug.LogWarning($"[HoldNote] END judged as Miss: {noteColor} reason={(forceMiss ? "forced Miss" : "start not judged")}");
}
else
{
float diff = Mathf.Abs(releaseTime - scheduledEndTime);
rawOffsetMsEnd = (scheduledEndTime - releaseTime) * 1000f;
if (judgeConfig != null)
// try to use pool record for accurate press duration
if (HoldNoteJudgePool.TryGet(noteID, out var info))
{
// apply hold-specific multiplier to end judgement windows as well
float pEnd = judgeConfig.perfectRange * holdWindowMultiplier;
float gEnd = judgeConfig.greatRange * holdWindowMultiplier;
float gdEnd = judgeConfig.goodRange * holdWindowMultiplier;
float mEnd = judgeConfig.missRange * holdWindowMultiplier;
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Found pool info for {noteID}: pressTime={info.pressTime:F3} length={info.length:F3} scheduledEnd={info.scheduledEnd:F3}");
float playerHeld = Mathf.Clamp(releaseTime - info.pressTime, 0f, info.length);
float frac = info.length <= 0f ? 0f : (playerHeld / info.length);
if (diff <= pEnd)
if (frac > 0.8f)
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
}
else if (diff <= gEnd)
else if (frac > 0.5f)
{
result = "Great";
}
else if (diff <= gdEnd)
{
result = "Good";
}
else if (diff <= mEnd)
{
result = "Miss";
ScoreManager.Instance.countGreat += 1;
}
else
{
result = "Miss";
result = "Good";
ScoreManager.Instance.countGood += 1;
}
if (noteData != null && result != "Miss")
{
noteData.judgeOffsetMsEnd = (info.scheduledEnd - releaseTime) * 1000f;
}
// cleanup pool
HoldNoteJudgePool.Unregister(noteID);
}
else
{
if (diff <= 0.1f * holdWindowMultiplier)
if (GameConfig.verboseLogs) Debug.LogWarning($"[HoldNote] No pool info for {noteID}, falling back to local held fraction calculation");
// fallback logic if no pool info
float holdRequired = Mathf.Max(0.0001f, scheduledEndTime - hitTime);
float held = Mathf.Clamp(actualReleaseTime - hitTime, 0f, holdRequired);
float frac = holdRequired <= 0f ? 0f : (held / holdRequired);
if (frac > 0.8f)
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
}
else if (diff < 0.2f * holdWindowMultiplier)
else if (frac > 0.5f)
{
result = "Great";
}
else if (diff < 0.3f * holdWindowMultiplier)
{
result = "Good";
ScoreManager.Instance.countGreat += 1;
}
else
{
result = "Bad";
result = "Good";
ScoreManager.Instance.countGood += 1;
}
}
// --- 新增:根据最终确定的 result 统计 ---
if (result == "Perfect") ScoreManager.Instance.countPerfect += 1;
else if (result == "Great") ScoreManager.Instance.countGreat += 1;
else if (result == "Good") ScoreManager.Instance.countGood += 1;
else ScoreManager.Instance.countMiss += 1;
// --- 新增:记录偏差数据到 NoteData ---
if (noteData != null && result != "Miss")
{
noteData.judgeOffsetMsEnd = rawOffsetMsEnd;
float rawOffsetMsEnd = (scheduledEndTime - actualReleaseTime) * 1000f;
if (noteData != null && result != "Miss")
{
noteData.judgeOffsetMsEnd = rawOffsetMsEnd;
}
}
}
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] END result for {noteID}: {result} (color={noteColor})");
// show UI/audio and combo
InputManager.Instance?.ShowJudgeResult(trackIndex, result);
teamUIController.Instance?.OnJudgeResult(result); // 新增:更新combo计数
teamUIController.Instance?.OnJudgeResult(result);
// Ensure END always spawns judgement prefab (including Miss)
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, result);
// --- Add scoring for End like short notes ---
try
{
var allyGo = GameObject.Find($"ally_0{TrackIndex + 1}");
if (allyGo != null)
var ally = GetAllyForTrackCached(TrackIndex);
if (ally != null)
{
var ally = allyGo.GetComponent<AllyCombatant>();
if (ally != null)
{
int added = ally.AddScoreForJudge(result);
float efficiency = ally.scoreEfficiency;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency);
}
int added = ally.AddScoreForJudge(result);
float efficiency = ally.scoreEfficiency;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency);
}
}
catch (System.Exception ex)
@@ -677,26 +750,12 @@ public class HoldNote : BaseNote
// Notify SkillBuilder so Hold notes can trigger skills configured for Hold
if (JudgeManager.Instance.TryTriggerSkill(noteID))
{
var sb = SkillBuilder.Instance;
if (sb == null)
{
Debug.LogWarning($"[HoldNote] SkillBuilder.Instance is null when trying to notify for track {TrackIndex}");
}
else
{
var so = sb.GetAllyHeroSOBySlot(TrackIndex);
var def = so?.GetPrimarySkill();
Debug.Log($"[HoldNote] About to NotifyNoteHit: TrackIndex={TrackIndex} trackIndexField={trackIndex} so={(so != null ? so.name : "null")} skill={(def != null ? def.skillId : "null")} trigger={(def != null ? def.triggerCondition.ToString() : "-")}");
}
bool triggeredHold = false;
bool triggeredTap = false;
// Build candidate slot indices to try: prefer instance field, then BaseNote.TrackIndex, then try to match UI slots
var candidates = new List<int>();
candidates.Add(this.trackIndex);
if (!candidates.Contains(this.TrackIndex)) candidates.Add(this.TrackIndex);
// try to resolve by matching this object to UI slots
var ui = teamUIController.Instance;
if (ui != null)
{
@@ -712,9 +771,6 @@ public class HoldNote : BaseNote
}
}
Debug.Log($"[HoldNote] NotifyNoteHit candidate slots: {string.Join(",", candidates)}");
// Try each candidate: prefer Hold notification. Stop on first successful trigger.
foreach (var slot in candidates)
{
try
@@ -725,14 +781,9 @@ public class HoldNote : BaseNote
{
Debug.LogError($"[HoldNote] NotifyNoteHit(Hold) threw for slot {slot}: {ex}");
}
if (triggeredHold)
{
Debug.Log($"[HoldNote] NotifyNoteHit succeeded (Hold) for slot {slot}");
break;
}
if (triggeredHold) break;
}
// If no candidate triggered via Hold, try Tap fallback on same candidate list
if (!triggeredHold)
{
foreach (var slot in candidates)
@@ -745,68 +796,56 @@ public class HoldNote : BaseNote
{
Debug.LogError($"[HoldNote] NotifyNoteHit(Tap) threw for slot {slot}: {ex}");
}
if (triggeredTap)
{
Debug.Log($"[HoldNote] NotifyNoteHit succeeded (Tap fallback) for slot {slot}");
break;
}
if (triggeredTap) break;
}
}
// _hasTriggeredOnThisHold = true;
Debug.Log($"[HoldNote] Hold end NotifyNoteHit called: TrackIndex={TrackIndex} trackIndexField={trackIndex} result={result} triggeredHold={triggeredHold} triggeredTap={triggeredTap}");
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] Hold end NotifyNoteHit called: TrackIndex={TrackIndex} trackIndexField={trackIndex} result={result} triggeredHold={triggeredHold} triggeredTap={triggeredTap}");
}
Debug.Log($"[HoldNote] END判定结果: {noteColor} {result} (release={releaseTime:F2}, target={scheduledEndTime:F2})");
AnimationController.Global?.StopHoldParticles();
ReturnToPool();
isJudged = true;
ScheduleReturnToPool(0.2f);
}
private void OnDisable()
private void HandleEnd(bool forceMiss = false)
{
if (segment == NoteSegment.End)
if (!JudgeManager.Instance.TryResolveEnd(noteID))
{
AnimationController.Global?.StopHoldParticles();
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] END try-resolve failed: {noteColor}");
return;
}
if (autoReturnCoroutine != null)
// record release time and register release in JudgeManager
if (!hasReleased)
{
StopCoroutine(autoReturnCoroutine);
autoReturnCoroutine = null;
}
if (segment == NoteSegment.End &&
!isJudged && // 尚未判定
JudgeManager.Instance.IsStartJudged(noteID)) // 头部已判定
{
// 只在头部已判定且玩家已松手时补偿 End 判定为 Miss;否则不要强制 Miss
if (JudgeManager.Instance.HasNoteReleased(noteID) && !isHoldActive)
if (Input.GetKey(keyToPress))
{
Debug.Log($"[HoldNote] OnDisable补偿End段判定(已释放): {noteColor}");
if (JudgeManager.Instance.TryResolveEnd(noteID))
{
HandleEnd();
}
isJudged = true;
releaseTime = scheduledEndTime;
}
else
{
Debug.Log($"[HoldNote] OnDisableEnd段未判定且尚有按键状态,跳过强制 Miss: {noteColor}");
releaseTime = Time.time;
}
hasReleased = true;
JudgeManager.Instance?.RegisterNoteReleased(noteID, true);
}
controller?.StopMovement();
// central evaluation
EvaluateHoldEnd(releaseTime, forceMiss);
}
private void ReturnToPool()
{
if (!gameObject.activeSelf) return; // 再次检查以防止对象已被禁用
if (!gameObject.activeSelf) return;
if (segment == NoteSegment.Start && !hasEnteredLine)
{
Debug.LogWarning($"[HoldNote] 阻止回收尚未进入判定线的 Start段: {noteColor}");
if (GameConfig.verboseLogs) Debug.LogWarning($"[HoldNote] Refusing to return a Start segment that never entered judge zone: {noteColor}");
return;
}
if (GameConfig.verboseLogs) Debug.Log($"[HoldNote] ReturnToPool called for {noteID} segment={segment} time={Time.time:F3}");
gameObject.SetActive(false);
if (segment == NoteSegment.Start)
@@ -828,9 +867,10 @@ public class HoldNote : BaseNote
scheduledEndTime = 0f;
hasEnteredLine = false;
isJudged = false;
isHoldActive = false; // 重置长按标记
hasBeenHeldFromStart = false; // 重置
// _hasTriggeredOnThisHold = false; // 重置
isHoldActive = false;
hasBeenHeldFromStart = false;
holdStartTime = -1f;
if (autoReturnCoroutine != null)
{
@@ -846,7 +886,6 @@ public class HoldNote : BaseNote
public void ApplyVisualScale(float scaleY)
{
// clamp to reasonable range to avoid extreme distortion
float s = Mathf.Clamp(scaleY, 0.1f, 4f);
if (visualTransform != null)
{
@@ -869,4 +908,20 @@ public class HoldNote : BaseNote
else
transform.localScale = originalLocalScale;
}
public void CalibratePosition(Vector3 spawnPointPosition, float tolerance = 0.02f)
{
if (controller == null) controller = GetComponent<HoldNoteController>();
if (controller == null) return;
float activation = controller.ActivationTime;
float s = controller.CurrentSpeed;
float elapsed = Mathf.Max(0f, Time.time - activation);
Vector3 expected = spawnPointPosition + Vector3.down * s * elapsed;
if (Vector3.Distance(transform.position, expected) > tolerance)
{
transform.position = expected;
}
}
}
@@ -6,12 +6,12 @@ public class HoldNoteController : MonoBehaviour
private bool isInsideJudgeZone = false;
public event Action<bool> OnJudgeZoneChanged;
private bool isMoving = false;
private float speed = 0f; // 音符下落速度
private float activationTime; // 用于延迟启用下落
private float speed = 0f; // 运动速度
private float activationTime; // 何时开始移动(Time.time 基准)
private void Update()
{
// **控制音符下落**
// 到达激活时间后开始移动
if (!isMoving && Time.time >= activationTime)
{
isMoving = true;
@@ -22,30 +22,40 @@ public class HoldNoteController : MonoBehaviour
transform.Translate(Vector3.down * speed * Time.deltaTime);
}
}
/// <summary>
/// 设置音符延迟下落的时间
/// 设置该段的“到达判定线的目标时间”(hitTime) 与分段延迟(delay),并由此推导移动开始时间
/// 注意:这里必须使用与 HoldNote.hitTime 一致的时间基准(NoteSpawner 传入的谱面实时点),
/// 否则用 Time.time + delay 会在首段/暂停/开歌延迟时产生系统性错位,导致永远 Miss。
/// </summary>
public void SetSegmentDelay(float segmentDelay)
public void ConfigureTiming(float baseHitTime, float segmentDelay, float travelTime)
{
activationTime = Time.time + segmentDelay;
// baseHitTime = startTime + noteData.time (+ globalHitDelay)
float hitTime = baseHitTime + segmentDelay;
activationTime = hitTime - travelTime;
isMoving = false;
}
/// <summary>
/// 设置音符的下落速度
/// 兼容旧接口:不推荐。旧版用 Time.time 会导致时间基准不一致。
/// </summary>
public void SetSegmentDelay(float segmentDelay)
{
// Fallback to old behavior if caller didn't use ConfigureTiming.
activationTime = Time.time + segmentDelay;
isMoving = false;
}
public void SetSpeed(float newSpeed)
{
speed = newSpeed;
}
/// <summary>
/// 停止音符下落(当音符被击中或Miss时)
/// </summary>
public void StopMovement()
{
isMoving = false;
}
private void OnTriggerEnter2D(Collider2D collision)
{
if (!collision.CompareTag("JudgmentLine")) return;
@@ -66,4 +76,7 @@ public class HoldNoteController : MonoBehaviour
{
return isInsideJudgeZone;
}
public float ActivationTime => activationTime;
public float CurrentSpeed => speed;
}
@@ -0,0 +1,54 @@
using System.Collections.Generic;
using UnityEngine;
public static class HoldNoteJudgePool
{
public class HoldInfo
{
public string noteID;
public float pressTime; // real-time when player pressed start
public float hitTime; // scheduled hit time (real-time)
public float scheduledEnd; // scheduled end time (real-time)
public float length; // scheduledEnd - hitTime
public string color;
public int trackIndex;
public object noteData; // store as object to avoid type dependency
}
private static readonly Dictionary<string, HoldInfo> pool = new Dictionary<string, HoldInfo>();
public static void RegisterStart(string noteID, float pressTime, float hitTime, float scheduledEnd, string color, int trackIndex, object noteData)
{
if (string.IsNullOrEmpty(noteID)) return;
HoldInfo info = new HoldInfo
{
noteID = noteID,
pressTime = pressTime,
hitTime = hitTime,
scheduledEnd = scheduledEnd,
length = Mathf.Max(0.0001f, scheduledEnd - hitTime),
color = color,
trackIndex = trackIndex,
noteData = noteData
};
pool[noteID] = info;
Debug.Log($"[HoldNoteJudgePool] Registered start for {noteID} press={pressTime:F3} hit={hitTime:F3} end={scheduledEnd:F3}");
}
public static bool TryGet(string noteID, out HoldInfo info)
{
if (string.IsNullOrEmpty(noteID)) { info = null; return false; }
return pool.TryGetValue(noteID, out info);
}
public static void Unregister(string noteID)
{
if (string.IsNullOrEmpty(noteID)) return;
pool.Remove(noteID);
}
public static void ClearAll()
{
pool.Clear();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f43e60cf3d44b294b8a5d786de110f9b
+184 -158
View File
@@ -1,158 +1,184 @@
using System.Collections.Generic;
using UnityEngine;
public class JudgeManager : MonoBehaviour
{
public static JudgeManager Instance { get; private set; }
// ===== 原有数据 =====
private Dictionary<KeyCode, Queue<Note>> judgeQueues = new Dictionary<KeyCode, Queue<Note>>();
private Dictionary<string, bool> startJudgedNotes = new Dictionary<string, bool>();
private Dictionary<string, bool> releasedNotes = new Dictionary<string, bool>();
private Dictionary<string, float> noteEndTimes = new Dictionary<string, float>();
private Dictionary<string, int> middlePassedCounts = new Dictionary<string, int>();
// ===== 新增:长音符全局互斥状态 =====
private class HoldJudgeState
{
public bool startResolved; // Start 是否已经判定过(成功 or 失败)
public bool endResolved; // End 是否已经判定过
public bool skillTriggered; // 技能是否已触发
}
private Dictionary<string, HoldJudgeState> holdStates = new Dictionary<string, HoldJudgeState>();
private HoldJudgeState GetHoldState(string noteID)
{
if (!holdStates.ContainsKey(noteID))
holdStates[noteID] = new HoldJudgeState();
return holdStates[noteID];
}
private void Awake()
{
if (Instance == null) Instance = this;
else Destroy(gameObject);
}
// ===== 新增:判定互斥闸门 =====
public bool TryResolveStart(string noteID)
{
var s = GetHoldState(noteID);
if (s.startResolved) return false;
s.startResolved = true;
return true;
}
public bool TryResolveEnd(string noteID)
{
var s = GetHoldState(noteID);
if (s.endResolved) return false;
s.endResolved = true;
return true;
}
public bool TryTriggerSkill(string noteID)
{
var s = GetHoldState(noteID);
if (s.skillTriggered) return false;
s.skillTriggered = true;
return true;
}
public void ClearHoldState(string noteID)
{
if (holdStates.ContainsKey(noteID))
holdStates.Remove(noteID);
}
// ===== 原有接口(保持不变) =====
public void RegisterScheduledEndTime(string noteID, float endTime)
{
noteEndTimes[noteID] = endTime;
}
public float GetScheduledEndTime(string noteID)
{
return noteEndTimes.ContainsKey(noteID) ? noteEndTimes[noteID] : 0f;
}
public void RegisterStartJudged(string noteID, bool state)
{
startJudgedNotes[noteID] = state;
}
public bool IsStartJudged(string noteID)
{
return startJudgedNotes.ContainsKey(noteID) && startJudgedNotes[noteID];
}
public void RegisterNoteReleased(string noteID, bool state)
{
releasedNotes[noteID] = state;
Debug.Log($"[JudgeManager] RegisterNoteReleased: {noteID} = {state}");
}
public bool HasNoteReleased(string noteID)
{
return releasedNotes.ContainsKey(noteID) && releasedNotes[noteID];
}
public void RegisterNote(KeyCode key, Note note)
{
if (!judgeQueues.ContainsKey(key))
judgeQueues[key] = new Queue<Note>();
if (!judgeQueues[key].Contains(note))
judgeQueues[key].Enqueue(note);
}
public void UnregisterNote(KeyCode key, Note note)
{
if (!judgeQueues.ContainsKey(key)) return;
Queue<Note> newQueue = new Queue<Note>();
while (judgeQueues[key].Count > 0)
{
Note n = judgeQueues[key].Dequeue();
if (n != note)
newQueue.Enqueue(n);
else if (!n.IsJudged())
n.JudgeMiss();
}
judgeQueues[key] = newQueue;
}
public void JudgeEarliestNote(KeyCode key)
{
if (judgeQueues.ContainsKey(key) && judgeQueues[key].Count > 0)
{
Note note = judgeQueues[key].Dequeue();
if (note != null && !note.IsJudged())
note.Judge();
}
}
public void RegisterMiddlePassed(string noteID)
{
if (!middlePassedCounts.ContainsKey(noteID))
middlePassedCounts[noteID] = 0;
middlePassedCounts[noteID]++;
Debug.Log($"[JudgeManager] Middle passed for {noteID}, total={middlePassedCounts[noteID]}");
}
public int GetMiddlePassedCount(string noteID)
{
return middlePassedCounts.ContainsKey(noteID) ? middlePassedCounts[noteID] : 0;
}
public void ClearNoteRecord(string noteID)
{
startJudgedNotes.Remove(noteID);
releasedNotes.Remove(noteID);
noteEndTimes.Remove(noteID);
middlePassedCounts.Remove(noteID);
ClearHoldState(noteID);
}
}
using System.Collections.Generic;
using UnityEngine;
public class JudgeManager : MonoBehaviour
{
public static JudgeManager Instance { get; private set; }
// ===== ԭ =====
private Dictionary<KeyCode, Queue<Note>> judgeQueues = new Dictionary<KeyCode, Queue<Note>>();
private Dictionary<string, bool> startJudgedNotes = new Dictionary<string, bool>();
private Dictionary<string, bool> releasedNotes = new Dictionary<string, bool>();
private Dictionary<string, float> noteEndTimes = new Dictionary<string, float>();
private Dictionary<string, int> middlePassedCounts = new Dictionary<string, int>();
// ===== ȫֻ״̬ =====
private class HoldJudgeState
{
public bool startResolved; // Start ǷѾжɹ or ʧܣ
public bool endResolved; // End ǷѾж
public bool skillTriggered; // ǷѴ
}
private Dictionary<string, HoldJudgeState> holdStates = new Dictionary<string, HoldJudgeState>();
private HoldJudgeState GetHoldState(string noteID)
{
if (!holdStates.ContainsKey(noteID))
holdStates[noteID] = new HoldJudgeState();
return holdStates[noteID];
}
private void Awake()
{
if (Instance == null) Instance = this;
else Destroy(gameObject);
}
// ===== жբ =====
public bool TryResolveStart(string noteID)
{
var s = GetHoldState(noteID);
if (s.startResolved)
{
Debug.LogWarning($"[JudgeManager] TryResolveStart: already resolved start for {noteID}");
return false;
}
s.startResolved = true;
Debug.Log($"[JudgeManager] TryResolveStart: start resolved for {noteID} at time={Time.time:F3}");
return true;
}
public bool TryResolveEnd(string noteID)
{
var s = GetHoldState(noteID);
if (s.endResolved)
{
Debug.LogWarning($"[JudgeManager] TryResolveEnd: already resolved end for {noteID}");
return false;
}
s.endResolved = true;
Debug.Log($"[JudgeManager] TryResolveEnd: end resolved for {noteID} at time={Time.time:F3}");
return true;
}
public bool TryTriggerSkill(string noteID)
{
var s = GetHoldState(noteID);
if (s.skillTriggered)
{
Debug.LogWarning($"[JudgeManager] TryTriggerSkill: already triggered for {noteID}");
return false;
}
s.skillTriggered = true;
Debug.Log($"[JudgeManager] TryTriggerSkill: skill triggered for {noteID} at time={Time.time:F3}");
return true;
}
public void ClearHoldState(string noteID)
{
if (holdStates.ContainsKey(noteID))
{
holdStates.Remove(noteID);
Debug.Log($"[JudgeManager] ClearHoldState: cleared state for {noteID}");
}
}
// ===== ԭнӿڣֲ䣩 =====
public void RegisterScheduledEndTime(string noteID, float endTime)
{
noteEndTimes[noteID] = endTime;
Debug.Log($"[JudgeManager] RegisterScheduledEndTime: {noteID} -> {endTime:F3}");
}
public float GetScheduledEndTime(string noteID)
{
return noteEndTimes.ContainsKey(noteID) ? noteEndTimes[noteID] : 0f;
}
public void RegisterStartJudged(string noteID, bool state)
{
startJudgedNotes[noteID] = state;
Debug.Log($"[JudgeManager] RegisterStartJudged: {noteID} = {state} at time={Time.time:F3}");
}
public bool IsStartJudged(string noteID)
{
return startJudgedNotes.ContainsKey(noteID) && startJudgedNotes[noteID];
}
public void RegisterNoteReleased(string noteID, bool state)
{
releasedNotes[noteID] = state;
Debug.Log($"[JudgeManager] RegisterNoteReleased: {noteID} = {state}");
}
public bool HasNoteReleased(string noteID)
{
return releasedNotes.ContainsKey(noteID) && releasedNotes[noteID];
}
public void RegisterNote(KeyCode key, Note note)
{
if (!judgeQueues.ContainsKey(key))
judgeQueues[key] = new Queue<Note>();
if (!judgeQueues[key].Contains(note))
judgeQueues[key].Enqueue(note);
Debug.Log($"[JudgeManager] RegisterNote: key={key} note={note.name} time={Time.time:F3} queueSize={judgeQueues[key].Count}");
}
public void UnregisterNote(KeyCode key, Note note)
{
if (!judgeQueues.ContainsKey(key)) return;
Queue<Note> newQueue = new Queue<Note>();
while (judgeQueues[key].Count > 0)
{
Note n = judgeQueues[key].Dequeue();
if (n != note)
newQueue.Enqueue(n);
else if (!n.IsJudged())
n.JudgeMiss();
}
judgeQueues[key] = newQueue;
Debug.Log($"[JudgeManager] UnregisterNote: key={key} removed {note.name}, newQueueSize={judgeQueues[key].Count}");
}
public void JudgeEarliestNote(KeyCode key)
{
if (judgeQueues.ContainsKey(key) && judgeQueues[key].Count > 0)
{
Note note = judgeQueues[key].Dequeue();
if (note != null && !note.IsJudged())
{
Debug.Log($"[JudgeManager] JudgeEarliestNote: judging {note.name} for key={key} at time={Time.time:F3}");
note.Judge();
}
}
}
public void RegisterMiddlePassed(string noteID)
{
if (!middlePassedCounts.ContainsKey(noteID))
middlePassedCounts[noteID] = 0;
middlePassedCounts[noteID]++;
Debug.Log($"[JudgeManager] Middle passed for {noteID}, total={middlePassedCounts[noteID]}");
}
public int GetMiddlePassedCount(string noteID)
{
return middlePassedCounts.ContainsKey(noteID) ? middlePassedCounts[noteID] : 0;
}
public void ClearNoteRecord(string noteID)
{
startJudgedNotes.Remove(noteID);
releasedNotes.Remove(noteID);
noteEndTimes.Remove(noteID);
middlePassedCounts.Remove(noteID);
ClearHoldState(noteID);
Debug.Log($"[JudgeManager] ClearNoteRecord: cleared records for {noteID}");
}
}
+56 -57
View File
@@ -10,8 +10,25 @@ public class Note : BaseNote
private NoteData noteData;
[Header("判定区间配置")]
public NoteJudgeConfig judgeConfig; // 判定区间配置,需在预制体或生成时赋值
[Header("ж")]
public NoteJudgeConfig judgeConfig; // жãԤʱֵ
// Cache allies per track to avoid GameObject.Find on every judge.
private static AllyCombatant[] allyCache;
private static AllyCombatant GetAllyForTrackCached(int trackIndex)
{
if (trackIndex < 0) return null;
if (allyCache == null || allyCache.Length < 10) allyCache = new AllyCombatant[10];
if (allyCache[trackIndex] != null) return allyCache[trackIndex];
var allyGo = GameObject.Find($"ally_0{trackIndex + 1}");
if (allyGo != null)
{
allyCache[trackIndex] = allyGo.GetComponent<AllyCombatant>();
}
return allyCache[trackIndex];
}
private void Awake()
{
@@ -37,11 +54,15 @@ public class Note : BaseNote
InputManager.OnKeyPressed += HandlePress;
// 判定区间配置检查
// жü
if (judgeConfig == null)
Debug.LogError($"NoteJudgeConfig is null! 判定区间配置未传入!track={trackIndex}");
{
if (GameConfig.verboseLogs) Debug.LogError($"NoteJudgeConfig is null! жδ룡track={trackIndex}");
}
else
Debug.Log($"NoteJudgeConfig: perfect={judgeConfig.perfectRange}, great={judgeConfig.greatRange}, good={judgeConfig.goodRange}, miss={judgeConfig.missRange}");
{
if (GameConfig.verboseLogs) Debug.Log($"NoteJudgeConfig: perfect={judgeConfig.perfectRange}, great={judgeConfig.greatRange}, good={judgeConfig.goodRange}, miss={judgeConfig.missRange}");
}
}
private void OnDestroy()
@@ -51,12 +72,12 @@ public class Note : BaseNote
private void HandlePress(KeyCode key)
{
// 只判定正确轨道且在判定区
// ֻжȷж
if (isJudged || key != keyToPress || (controller != null && !controller.IsInJudgeZone()))
return;
float timeDifference = Mathf.Abs(Time.time - hitTime);
// 计算正负偏差:正值提前,负值落后
// ƫֵǰֵ
float rawOffsetMs = (hitTime - Time.time) * 1000f;
string judgeResult = null;
@@ -64,37 +85,29 @@ public class Note : BaseNote
{
if (timeDifference <= judgeConfig.perfectRange)
{
Debug.Log($"{keyToPress}: Perfect");
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Perfect");
judgeResult = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
}
else if (timeDifference <= judgeConfig.greatRange)
{
Debug.Log($"{keyToPress}: Great");
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Great");
judgeResult = "Great";
ScoreManager.Instance.countGreat += 1;
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
}
else if (timeDifference <= judgeConfig.goodRange)
{
Debug.Log($"{keyToPress}: Good");
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Good");
judgeResult = "Good";
ScoreManager.Instance.countGood += 1;
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
}
else if (timeDifference <= judgeConfig.missRange)
{
Debug.Log($"{keyToPress}: Miss");
judgeResult = "Miss";
/*ScoreManager.Instance.countMiss += 1;
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;*/
}
else
{
Debug.Log($"{keyToPress}: Miss");
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Miss");
judgeResult = "Miss";
// Do not return here - let shared post-judge logic run so NotifyNoteHit is invoked for Miss as well
}
}
else
@@ -102,43 +115,36 @@ public class Note : BaseNote
// fallback timing
if (timeDifference <= 0.08f)
{
Debug.Log($"{keyToPress}: Perfect");
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Perfect");
judgeResult = "Perfect";
}
else if (timeDifference <= 0.15f)
{
Debug.Log($"{keyToPress}: Great");
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Great");
judgeResult = "Great";
}
else
{
Debug.Log($"{keyToPress}: Miss");
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress}: Miss");
judgeResult = "Miss";
// Do not return here - let shared post-judge logic run so NotifyNoteHit is invoked for Miss as well
}
}
// 显示判定信息
if (!string.IsNullOrEmpty(judgeResult))
{
InputManager.Instance?.ShowJudgeResult(TrackIndex, judgeResult);
JudgeSoundManager.Instance?.PlayJudgeSound(judgeResult);
teamUIController.Instance?.OnJudgeResult(judgeResult); // 更新combo计数
teamUIController.Instance?.OnJudgeResult(judgeResult);
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, judgeResult);
// Calculate score addition via AllyCombatant and add to per-track pm sums
try
{
var allyGo = GameObject.Find($"ally_0{TrackIndex + 1}");
if (allyGo != null)
var ally = GetAllyForTrackCached(TrackIndex);
if (ally != null)
{
var ally = allyGo.GetComponent<AllyCombatant>();
if (ally != null)
{
int added = ally.AddScoreForJudge(judgeResult);
float efficiency = ally.scoreEfficiency;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency);
}
int added = ally.AddScoreForJudge(judgeResult);
float efficiency = ally.scoreEfficiency;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency);
}
}
catch (System.Exception ex)
@@ -146,9 +152,9 @@ public class Note : BaseNote
Debug.LogWarning($"[Note] Failed to add per-track score: {ex}");
}
// Notify SkillBuilder about this note hit so OnNoteHit skills may trigger (tap notes)
try { SkillBuilder.Instance?.NotifyNoteHit(TrackIndex, judgeResult, SkillDefinition.NoteTypeTrigger.Tap); } catch { }
}
Judge();
}
@@ -178,29 +184,26 @@ public class Note : BaseNote
public void JudgeMiss()
{
// idempotent: this can be called from multiple paths
if (isJudged) return;
isJudged = true;
ScoreManager.Instance.countMiss += 1;
Debug.Log($"{keyToPress} Miss");
// Ensure UI shows Miss and combo is updated when a note auto-misses
if (GameConfig.verboseLogs) Debug.Log($"{keyToPress} Miss");
InputManager.Instance?.ShowJudgeResult(TrackIndex, "Miss");
JudgeSoundManager.Instance?.PlayJudgeSound("Miss");
teamUIController.Instance?.OnJudgeResult("Miss");
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, "Miss");
// Add score for Miss as well (some systems may give 0)
try
{
var allyGo = GameObject.Find($"ally_0{TrackIndex + 1}");
if (allyGo != null)
var ally = GetAllyForTrackCached(TrackIndex);
if (ally != null)
{
var ally = allyGo.GetComponent<AllyCombatant>();
if (ally != null)
{
int added = ally.AddScoreForJudge("Miss");
float efficiency = ally.scoreEfficiency;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency);
}
int added = ally.AddScoreForJudge("Miss");
float efficiency = ally.scoreEfficiency;
ScoreManager.Instance?.AddPmScoreForTrack(TrackIndex, added, efficiency);
}
}
catch (System.Exception ex)
@@ -208,9 +211,8 @@ public class Note : BaseNote
Debug.LogWarning($"[Note] Failed to add per-track score for Miss: {ex}");
}
// Notify SkillBuilder about Miss so OnNoteHit skills configured for Miss can trigger
try { SkillBuilder.Instance?.NotifyNoteHit(TrackIndex, "Miss", SkillDefinition.NoteTypeTrigger.Tap); } catch { }
// InputManager.Instance?.ShowJudgeResult(TrackIndex, "Miss"); // 已在HandlePress中调用 for manual presses
ReturnToPool();
}
@@ -236,17 +238,14 @@ public class Note : BaseNote
}
/// <summary>
/// Controller 通知 Note 是否进入判定区
/// Controller ֪ͨ Note Ƿж
/// </summary>
public void SetJudgeZone(bool inZone)
{
// 离开判定区且未判定则判 Miss
if (!inZone && !isJudged)
// leaving judge zone without being judged -> Miss
if (!inZone)
{
// Show Miss and update combo
InputManager.Instance?.ShowJudgeResult(TrackIndex, "Miss");
JudgeSoundManager.Instance?.PlayJudgeSound("Miss");
teamUIController.Instance?.OnJudgeResult("Miss");
// JudgeMiss already handles UI/sound and prevents double execution.
JudgeMiss();
}
}
@@ -5,7 +5,7 @@ public class NoteController : MonoBehaviour
{
private float speed;
private bool isMoving = true;
private bool isInJudgeZone = false; // 判定区域标记
private bool isInJudgeZone = false; // ж
private ParticleSystem hitEffect;
private Note linkedNote;
@@ -59,7 +59,7 @@ public class NoteController : MonoBehaviour
}
}
// OnTriggerEnter2D OnTriggerExit2D 挂载在 Controller
// OnTriggerEnter2D OnTriggerExit2D Controller
private void OnTriggerEnter2D(Collider2D collision)
{
@@ -92,7 +92,7 @@ public class NoteController : MonoBehaviour
/// <summary>
/// Note 查询是否处于判定区域
/// Note ѯǷж
/// </summary>
public bool IsInJudgeZone()
{
+397 -309
View File
@@ -1,309 +1,397 @@
using System.Collections.Generic;
using UnityEngine;
using GamePlay; // for PoolItem
public class NotePool : MonoBehaviour
{
public static NotePool Instance { get; private set; }
public GameObject[] notePrefabs; // 短音符预制体(按颜色存储)
public GameObject[] holdNotePrefabs; // 长音符片段预制体
public GameObject[] holdNoteEndPrefabs; // 新增:尾部专用长音符预制体(可选)
public GameObject[] startNotePrefabs; // 长音符start片段预制体
private int poolSize = 12; // 每种类型的音符池大小
private int maxPoolSize = 60; // 池的最大容量
// 颜色到索引的快速映射,避免每次 switch
private Dictionary<string, int> colorIndexMap;
private Dictionary<int, Stack<GameObject>> notePools; // 短音符对象池
private Dictionary<int, Stack<GameObject>> holdNotePools; // 长音符片段对象池
private Dictionary<int, Stack<GameObject>> holdNoteEndPools; // 新增:尾部长音符对象池
private Dictionary<int, Stack<GameObject>> startNotePools; // 长音符start片段对象池
// 容器物体用于在层级中组织池中的对象,便于调试和管理
private Transform notePoolContainer;
private Transform holdPoolContainer;
private Transform startPoolContainer;
// 控制调试输出,默认关闭以提升性能
public bool verboseLogging = false;
private void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(gameObject);
return;
}
// 初始化颜色映射
colorIndexMap = new Dictionary<string, int>
{
{ "red", 0 },
{ "green", 1 },
{ "yellow", 2 },
{ "purple", 3 },
{ "blue", 4 }
};
// 创建容器
notePoolContainer = new GameObject("_NotePool_Notes").transform;
notePoolContainer.SetParent(transform, false);
holdPoolContainer = new GameObject("_NotePool_HoldSegments").transform;
holdPoolContainer.SetParent(transform, false);
startPoolContainer = new GameObject("_NotePool_StartSegments").transform;
startPoolContainer.SetParent(transform, false);
notePools = new Dictionary<int, Stack<GameObject>>();
holdNotePools = new Dictionary<int, Stack<GameObject>>();
holdNoteEndPools = new Dictionary<int, Stack<GameObject>>();
startNotePools = new Dictionary<int, Stack<GameObject>>();
int colorCount = Mathf.Max(1, notePrefabs != null ? notePrefabs.Length : 0);
for (int i = 0; i < colorCount; i++)
{
notePools[i] = new Stack<GameObject>();
holdNotePools[i] = new Stack<GameObject>();
holdNoteEndPools[i] = new Stack<GameObject>();
startNotePools[i] = new Stack<GameObject>();
for (int j = 0; j < poolSize; j++)
{
AddToPool(notePools[i], notePrefabs[i], notePoolContainer);
AddToPool(startNotePools[i], startNotePrefabs[i], startPoolContainer);
}
for (int j = 0; j < poolSize * 5; j++)
{
AddToPool(holdNotePools[i], holdNotePrefabs[i], holdPoolContainer);
// 如果专用尾部预制体存在则初始化尾部池,否则使用中段预制体
if (holdNoteEndPrefabs != null && holdNoteEndPrefabs.Length > i && holdNoteEndPrefabs[i] != null)
AddToPool(holdNoteEndPools[i], holdNoteEndPrefabs[i], holdPoolContainer);
else
AddToPool(holdNoteEndPools[i], holdNotePrefabs[i], holdPoolContainer);
}
}
}
private GameObject GetObjectFromPool(Stack<GameObject> pool, GameObject prefab, Transform container)
{
if (pool == null)
return InstantiateAndPrepare(prefab);
if (pool.Count > 0)
{
GameObject obj = pool.Pop();
if (obj == null)
{
// 如果池中对象已被销毁,创建新对象
obj = InstantiateAndPrepare(prefab);
}
else
{
// 优化:使用 PoolItem 标识来检查是否来自同一 prefab
GamePlay.PoolItem pi = obj.GetComponent<GamePlay.PoolItem>();
if (pi == null || prefab == null || pi.prefabName != prefab.name)
{
// 销毁错误类型的对象并替换为正确的 prefab 实例
if (pi == null && verboseLogging) Debug.LogWarning("PoolItem missing on pooled object, replacing.");
Destroy(obj);
obj = InstantiateAndPrepare(prefab);
}
}
obj.transform.SetParent(null);
obj.SetActive(true);
// mark as taken from pool
GamePlay.PoolItem takenPi = obj.GetComponent<GamePlay.PoolItem>();
if (takenPi != null) takenPi.inPool = false;
return obj;
}
else
{
// 池空时直接实例化新对象
GameObject obj = InstantiateAndPrepare(prefab);
obj.SetActive(true);
return obj;
}
}
private GameObject InstantiateAndPrepare(GameObject prefab)
{
if (prefab == null) return null;
GameObject obj = Instantiate(prefab);
GamePlay.PoolItem pi = obj.GetComponent<GamePlay.PoolItem>();
if (pi == null) pi = obj.AddComponent<GamePlay.PoolItem>();
pi.prefabName = prefab.name;
pi.inPool = false;
return obj;
}
private void ReturnObjectToPool(Stack<GameObject> pool, GameObject obj, Transform container)
{
if (obj == null || pool == null) return; // 防止空引用
// 避免重复归还:如果对象已在池内,直接忽略
GamePlay.PoolItem pi = obj.GetComponent<GamePlay.PoolItem>();
if (pi != null && pi.inPool) return;
// 将对象移动到池容器并重置变换
obj.transform.SetParent(container, false);
obj.transform.localPosition = Vector3.zero;
obj.transform.localRotation = Quaternion.identity;
obj.transform.localScale = Vector3.one;
// 设为非激活状态以便复用
obj.SetActive(false);
// 标记为已在池内
if (pi != null) pi.inPool = true;
if (pool.Count < maxPoolSize)
{
pool.Push(obj);
}
else
{
// 如果池已满,销毁多余对象以节省内存
Destroy(obj);
}
}
private void AddToPool(Stack<GameObject> pool, GameObject prefab, Transform container)
{
if (prefab == null) return;
if (pool.Count < maxPoolSize)
{
GameObject obj = Instantiate(prefab, container);
// ensure PoolItem exists and is marked in pool
GamePlay.PoolItem pi = obj.GetComponent<GamePlay.PoolItem>() ?? obj.AddComponent<GamePlay.PoolItem>();
pi.prefabName = prefab.name;
pi.inPool = true;
obj.SetActive(false);
pool.Push(obj);
}
}
public GameObject GetNote(string color)
{
int colorIndex = GetColorIndexFromName(color);
return GetObjectFromPool(notePools[colorIndex], notePrefabs[colorIndex], notePoolContainer);
}
public GameObject GetStartNote(string color)
{
int colorIndex = GetColorIndexFromName(color);
return GetObjectFromPool(startNotePools[colorIndex], startNotePrefabs[colorIndex], startPoolContainer);
}
public GameObject GetHoldNoteSegment(string color)
{
int colorIndex = GetColorIndexFromName(color);
return GetObjectFromPool(holdNotePools[colorIndex], holdNotePrefabs[colorIndex], holdPoolContainer);
}
// 新增:从尾部专用池获取尾部片段(如果未配置尾部预制体则降级为中段)
public GameObject GetHoldNoteEndSegment(string color)
{
int colorIndex = GetColorIndexFromName(color);
GameObject prefab = (holdNoteEndPrefabs != null && holdNoteEndPrefabs.Length > colorIndex && holdNoteEndPrefabs[colorIndex] != null)
? holdNoteEndPrefabs[colorIndex]
: holdNotePrefabs[colorIndex];
return GetObjectFromPool(holdNoteEndPools[colorIndex], prefab, holdPoolContainer);
}
public void ReturnNote(GameObject note, string color)
{
if (note == null) return;
NoteController noteScript = note.GetComponent<NoteController>();
if (noteScript != null)
{
noteScript.ResetState();
}
int idx = GetColorIndexFromName(color);
ReturnObjectToPool(notePools[idx], note, notePoolContainer);
}
public void ReturnStartNote(GameObject startNote, string color)
{
if (startNote == null) return;
if (string.IsNullOrEmpty(color))
{
if (verboseLogging) Debug.LogWarning($" 归还 StartNote 时 color 为空!音符名: {startNote.name}");
Destroy(startNote);
return;
}
HoldNote holdNote = startNote.GetComponent<HoldNote>();
if (holdNote != null)
{
holdNote.ResetState(); // 确保重置音符状态
}
int idx = GetColorIndexFromName(color);
ReturnObjectToPool(startNotePools[idx], startNote, startPoolContainer);
}
// 新增:归还尾部片段
public void ReturnHoldNoteEndSegment(GameObject holdNoteEnd, string color)
{
if (holdNoteEnd == null) return;
if (string.IsNullOrEmpty(color))
{
if (verboseLogging) Debug.LogWarning($" 归还 HoldNoteEndSegment 时 color 为空!音符名: {holdNoteEnd.name}");
Destroy(holdNoteEnd);
return;
}
HoldNote holdNoteScript = holdNoteEnd.GetComponent<HoldNote>();
if (holdNoteScript != null)
{
holdNoteScript.ResetState(); // 确保重置音符状态
}
int idx = GetColorIndexFromName(color);
ReturnObjectToPool(holdNoteEndPools[idx], holdNoteEnd, holdPoolContainer);
}
public void ReturnHoldNoteSegment(GameObject holdNote, string color)
{
if (holdNote == null) return;
if (string.IsNullOrEmpty(color))
{
if (verboseLogging) Debug.LogWarning($" 归还 HoldNoteSegment 时 color 为空!音符名: {holdNote.name}");
Destroy(holdNote);
return;
}
HoldNote holdNoteScript = holdNote.GetComponent<HoldNote>();
if (holdNoteScript != null)
{
holdNoteScript.ResetState(); // 确保重置音符状态
}
int idx = GetColorIndexFromName(color);
ReturnObjectToPool(holdNotePools[idx], holdNote, holdPoolContainer);
}
private int GetColorIndexFromName(string colorName)
{
if (string.IsNullOrEmpty(colorName)) return 0;
if (colorIndexMap != null && colorIndexMap.TryGetValue(colorName, out int idx)) return idx;
if (verboseLogging) Debug.LogError($"未识别的颜色: {colorName},默认使用红色");
return 0;
}
}
public class PoolItem : MonoBehaviour
{
public string prefabName;
public bool inPool;
}
using System.Collections.Generic;
using UnityEngine;
using GamePlay; // for PoolItem
using System.Collections;
public class NotePool : MonoBehaviour
{
public static NotePool Instance { get; private set; }
public GameObject[] notePrefabs; // Ԥɫ
public GameObject[] holdNotePrefabs; // ƬԤ
public GameObject[] holdNoteEndPrefabs; // βרóԤѡ
public GameObject[] startNotePrefabs; // startƬԤ
private int poolSize = 24; // ÿ͵شС (increased)
private int maxPoolSize = 120; // ص (increased)
// ɫĿӳÿ switch
private Dictionary<string, int> colorIndexMap;
private Dictionary<int, Stack<GameObject>> notePools; //
private Dictionary<int, Stack<GameObject>> holdNotePools; // Ƭζ
private Dictionary<int, Stack<GameObject>> holdNoteEndPools; // β
private Dictionary<int, Stack<GameObject>> startNotePools; // startƬζ
// ڲ㼶֯еĶ󣬱ڵԺ͹
private Transform notePoolContainer;
private Transform holdPoolContainer;
private Transform startPoolContainer;
// ƵĬϹر
public bool verboseLogging = false;
[Header("Prewarm Settings")]
[Tooltip("If true, the pool will be prewarmed across multiple frames to avoid a large GC/instantiation spike at scene load.")]
public bool prewarmOnStart = true;
[Tooltip("Number of instantiated pool items to create per frame during prewarming.")]
// Raised default so heavy pools finish prewarming faster and reduce hitch at first spawn.
public int prewarmPerFrame = 200;
// internal prewarm coroutine handle
private Coroutine prewarmCoroutine = null;
// Expose whether prewarm has completed
public bool IsPrewarmed => prewarmCoroutine == null;
private void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(gameObject);
return;
}
// ʼɫӳ
colorIndexMap = new Dictionary<string, int>
{
{ "red", 0 },
{ "green", 1 },
{ "yellow", 2 },
{ "purple", 3 },
{ "blue", 4 }
};
//
notePoolContainer = new GameObject("_NotePool_Notes").transform;
notePoolContainer.SetParent(transform, false);
holdPoolContainer = new GameObject("_NotePool_HoldSegments").transform;
holdPoolContainer.SetParent(transform, false);
startPoolContainer = new GameObject("_NotePool_StartSegments").transform;
startPoolContainer.SetParent(transform, false);
notePools = new Dictionary<int, Stack<GameObject>>();
holdNotePools = new Dictionary<int, Stack<GameObject>>();
holdNoteEndPools = new Dictionary<int, Stack<GameObject>>();
startNotePools = new Dictionary<int, Stack<GameObject>>();
int colorCount = Mathf.Max(1, notePrefabs != null ? notePrefabs.Length : 0);
for (int i = 0; i < colorCount; i++)
{
notePools[i] = new Stack<GameObject>();
holdNotePools[i] = new Stack<GameObject>();
holdNoteEndPools[i] = new Stack<GameObject>();
startNotePools[i] = new Stack<GameObject>();
}
// minimal immediate entries to ensure safety (one each)
for (int i = 0; i < colorCount; i++)
{
if (notePrefabs != null && i < notePrefabs.Length && notePrefabs[i] != null)
AddToPool(notePools[i], notePrefabs[i], notePoolContainer);
if (startNotePrefabs != null && i < startNotePrefabs.Length && startNotePrefabs[i] != null)
AddToPool(startNotePools[i], startNotePrefabs[i], startPoolContainer);
if (holdNotePrefabs != null && i < holdNotePrefabs.Length && holdNotePrefabs[i] != null)
AddToPool(holdNotePools[i], holdNotePrefabs[i], holdPoolContainer);
if (holdNoteEndPrefabs != null && i < holdNoteEndPrefabs.Length && holdNoteEndPrefabs[i] != null)
AddToPool(holdNoteEndPools[i], holdNoteEndPrefabs[i], holdPoolContainer);
}
if (prewarmOnStart)
{
prewarmCoroutine = StartCoroutine(PrewarmCoroutine(colorCount));
}
// Also ensure particle and judge prefab warm-up if AnimationController exists in scene.
var anim = AnimationController.Global ?? FindObjectOfType<AnimationController>();
if (anim != null)
{
anim.PrewarmParticles(2);
if (verboseLogging) Debug.Log("NotePool: requested AnimationController prewarm");
}
}
private IEnumerator PrewarmCoroutine(int colorCount)
{
// Calculate targets
int noteTarget = poolSize; // per color
int startTarget = poolSize;
int holdTarget = poolSize * 5;
int endTarget = poolSize * 5;
while (true)
{
int createdThisFrame = 0;
bool allReached = true;
for (int i = 0; i < colorCount; i++)
{
// note pool
while (notePools[i].Count < noteTarget && createdThisFrame < prewarmPerFrame)
{
if (notePrefabs != null && i < notePrefabs.Length && notePrefabs[i] != null)
AddToPool(notePools[i], notePrefabs[i], notePoolContainer);
createdThisFrame++;
}
if (notePools[i].Count < noteTarget) allReached = false;
// start pool
while (startNotePools[i].Count < startTarget && createdThisFrame < prewarmPerFrame)
{
if (startNotePrefabs != null && i < startNotePrefabs.Length && startNotePrefabs[i] != null)
AddToPool(startNotePools[i], startNotePrefabs[i], startPoolContainer);
createdThisFrame++;
}
if (startNotePools[i].Count < startTarget) allReached = false;
// hold middle pool
while (holdNotePools[i].Count < holdTarget && createdThisFrame < prewarmPerFrame)
{
if (holdNotePrefabs != null && i < holdNotePrefabs.Length && holdNotePrefabs[i] != null)
AddToPool(holdNotePools[i], holdNotePrefabs[i], holdPoolContainer);
createdThisFrame++;
}
if (holdNotePools[i].Count < holdTarget) allReached = false;
// hold end pool
while (holdNoteEndPools[i].Count < endTarget && createdThisFrame < prewarmPerFrame)
{
if (holdNoteEndPrefabs != null && i < holdNoteEndPrefabs.Length && holdNoteEndPrefabs[i] != null)
AddToPool(holdNoteEndPools[i], holdNoteEndPrefabs[i], holdPoolContainer);
else if (holdNotePrefabs != null && i < holdNotePrefabs.Length && holdNotePrefabs[i] != null)
AddToPool(holdNoteEndPools[i], holdNotePrefabs[i], holdPoolContainer);
createdThisFrame++;
}
if (holdNoteEndPools[i].Count < endTarget) allReached = false;
if (createdThisFrame >= prewarmPerFrame)
break;
}
if (allReached) break;
yield return null;
}
prewarmCoroutine = null;
}
private GameObject GetObjectFromPool(Stack<GameObject> pool, GameObject prefab, Transform container)
{
if (pool == null)
return InstantiateAndPrepare(prefab);
if (pool.Count > 0)
{
GameObject obj = pool.Pop();
if (obj == null)
{
// жѱ٣
obj = InstantiateAndPrepare(prefab);
}
else
{
// Żʹ PoolItem ʶǷͬһ prefab
GamePlay.PoolItem pi = obj.GetComponent<GamePlay.PoolItem>();
if (pi == null || prefab == null || pi.prefabName != prefab.name)
{
// ٴ͵Ķ滻Ϊȷ prefab ʵ
if (pi == null && verboseLogging) Debug.LogWarning("PoolItem missing on pooled object, replacing.");
Destroy(obj);
obj = InstantiateAndPrepare(prefab);
}
}
obj.transform.SetParent(null);
obj.SetActive(true);
// mark as taken from pool
GamePlay.PoolItem takenPi = obj.GetComponent<GamePlay.PoolItem>();
if (takenPi != null) takenPi.inPool = false;
return obj;
}
else
{
// ؿʱֱʵ
GameObject obj = InstantiateAndPrepare(prefab);
obj.SetActive(true);
return obj;
}
}
private GameObject InstantiateAndPrepare(GameObject prefab)
{
if (prefab == null) return null;
GameObject obj = Instantiate(prefab);
GamePlay.PoolItem pi = obj.GetComponent<GamePlay.PoolItem>();
if (pi == null) pi = obj.AddComponent<GamePlay.PoolItem>();
pi.prefabName = prefab.name;
pi.inPool = false;
return obj;
}
private void ReturnObjectToPool(Stack<GameObject> pool, GameObject obj, Transform container)
{
if (obj == null || pool == null) return; // ֹ
// ظڳڣֱӺ
GamePlay.PoolItem pi = obj.GetComponent<GamePlay.PoolItem>();
if (pi != null && pi.inPool) return;
// ƶñ任
obj.transform.SetParent(container, false);
obj.transform.localPosition = Vector3.zero;
obj.transform.localRotation = Quaternion.identity;
obj.transform.localScale = Vector3.one;
// ΪǼ״̬Ա㸴
obj.SetActive(false);
// Ϊڳ
if (pi != null) pi.inPool = true;
if (pool.Count < maxPoolSize)
{
pool.Push(obj);
}
else
{
// ٶԽʡڴ
Destroy(obj);
}
}
private void AddToPool(Stack<GameObject> pool, GameObject prefab, Transform container)
{
if (prefab == null) return;
if (pool.Count < maxPoolSize)
{
GameObject obj = Instantiate(prefab, container);
// ensure PoolItem exists and is marked in pool
GamePlay.PoolItem pi = obj.GetComponent<GamePlay.PoolItem>() ?? obj.AddComponent<GamePlay.PoolItem>();
pi.prefabName = prefab.name;
pi.inPool = true;
obj.SetActive(false);
pool.Push(obj);
}
}
public GameObject GetNote(string color)
{
int colorIndex = GetColorIndexFromName(color);
return GetObjectFromPool(notePools[colorIndex], notePrefabs[colorIndex], notePoolContainer);
}
public GameObject GetStartNote(string color)
{
int colorIndex = GetColorIndexFromName(color);
return GetObjectFromPool(startNotePools[colorIndex], startNotePrefabs[colorIndex], startPoolContainer);
}
public GameObject GetHoldNoteSegment(string color)
{
int colorIndex = GetColorIndexFromName(color);
return GetObjectFromPool(holdNotePools[colorIndex], holdNotePrefabs[colorIndex], holdPoolContainer);
}
// βרóػȡβƬΣδβԤ򽵼ΪжΣ
public GameObject GetHoldNoteEndSegment(string color)
{
int colorIndex = GetColorIndexFromName(color);
GameObject prefab = (holdNoteEndPrefabs != null && holdNoteEndPrefabs.Length > colorIndex && holdNoteEndPrefabs[colorIndex] != null)
? holdNoteEndPrefabs[colorIndex]
: holdNotePrefabs[colorIndex];
return GetObjectFromPool(holdNoteEndPools[colorIndex], prefab, holdPoolContainer);
}
public void ReturnNote(GameObject note, string color)
{
if (note == null) return;
NoteController noteScript = note.GetComponent<NoteController>();
if (noteScript != null)
{
noteScript.ResetState();
}
int idx = GetColorIndexFromName(color);
ReturnObjectToPool(notePools[idx], note, notePoolContainer);
}
public void ReturnStartNote(GameObject startNote, string color)
{
if (startNote == null) return;
if (string.IsNullOrEmpty(color))
{
if (verboseLogging) Debug.LogWarning($" 黹 StartNote ʱ color Ϊգ: {startNote.name}");
Destroy(startNote);
return;
}
HoldNote holdNote = startNote.GetComponent<HoldNote>();
if (holdNote != null)
{
holdNote.ResetState(); // ȷ״̬
}
int idx = GetColorIndexFromName(color);
ReturnObjectToPool(startNotePools[idx], startNote, startPoolContainer);
}
// 黹βƬ
public void ReturnHoldNoteEndSegment(GameObject holdNoteEnd, string color)
{
if (holdNoteEnd == null) return;
if (string.IsNullOrEmpty(color))
{
if (verboseLogging) Debug.LogWarning($" 黹 HoldNoteEndSegment ʱ color Ϊգ: {holdNoteEnd.name}");
Destroy(holdNoteEnd);
return;
}
HoldNote holdNoteScript = holdNoteEnd.GetComponent<HoldNote>();
if (holdNoteScript != null)
{
holdNoteScript.ResetState(); // ȷ״̬
}
int idx = GetColorIndexFromName(color);
ReturnObjectToPool(holdNoteEndPools[idx], holdNoteEnd, holdPoolContainer);
}
public void ReturnHoldNoteSegment(GameObject holdNote, string color)
{
if (holdNote == null) return;
if (string.IsNullOrEmpty(color))
{
if (verboseLogging) Debug.LogWarning($" 黹 HoldNoteSegment ʱ color Ϊգ: {holdNote.name}");
Destroy(holdNote);
return;
}
HoldNote holdNoteScript = holdNote.GetComponent<HoldNote>();
if (holdNoteScript != null)
{
holdNoteScript.ResetState(); // ȷ״̬
}
int idx = GetColorIndexFromName(color);
ReturnObjectToPool(holdNotePools[idx], holdNote, holdPoolContainer);
}
private int GetColorIndexFromName(string colorName)
{
if (string.IsNullOrEmpty(colorName)) return 0;
if (colorIndexMap != null && colorIndexMap.TryGetValue(colorName, out int idx)) return idx;
if (verboseLogging) Debug.LogError($"δʶɫ: {colorName}Ĭʹúɫ");
return 0;
}
}
public class PoolItem : MonoBehaviour
{
public string prefabName;
public bool inPool;
}
@@ -1,4 +1,4 @@
using UnityEngine;
using UnityEngine;
using System.Collections;
using TMPro;
@@ -29,6 +29,14 @@ public class NoteSpawner : MonoBehaviour
[Range(0.8f, 1.25f)]
public float speedMultiplier = 1f;
[Header("Calibration")]
[Tooltip("Tolerance (world units) for snapping middle segments to expected position after spawn.")]
public float calibrateTolerance = 0.02f;
[Tooltip("How many calibration checks to perform after spawn (spread over frames).")]
public int calibrateChecks = 2;
[Tooltip("Interval (seconds realtime) between calibration checks.")]
public float calibrateInterval = 0.01f;
// optional: constants for runtime clamping (kept for internal use)
private const float SpeedMultiplierMin = 0.8f;
private const float SpeedMultiplierMax = 1.25f;
@@ -94,7 +102,15 @@ public class NoteSpawner : MonoBehaviour
if (GameConfig.verboseLogs) Debug.Log($"delay: {delay}");
if (delay > 0)
yield return new WaitForSeconds(delay);
{
// Use scaled-time wait so spawning is paused while Time.timeScale==0 (PauseManager pause)
float target = Time.time + delay;
// Wait using frames so this loop respects Time.timeScale (Time.time won't advance when paused)
while (Time.time < target)
{
yield return null;
}
}
if (note.type == "hold")
{
@@ -263,6 +279,10 @@ public class NoteSpawner : MonoBehaviour
float visualScaleMid = 0.9f * smLocalHold + 0.1f;
holdSeg.ApplyVisualScale(visualScaleMid);
holdSeg.visualSpeedMultiplier = smLocalHold;
// Immediately calibrate position and schedule additional checks to correct any offset
holdSeg.CalibratePosition(spawnPoint.position, calibrateTolerance);
StartCoroutine(CalibrateAfterSpawn(holdSeg, spawnPoint.position));
}
else
{
@@ -298,6 +318,10 @@ public class NoteSpawner : MonoBehaviour
float visualScaleEnd = 0.9f * smLocalHold + 0.1f;
holdEnd.ApplyVisualScale(visualScaleEnd);
holdEnd.visualSpeedMultiplier = smLocalHold;
// schedule calibration for end as well to be safe
holdEnd.CalibratePosition(spawnPoint.position, calibrateTolerance);
StartCoroutine(CalibrateAfterSpawn(holdEnd, spawnPoint.position));
}
else
{
@@ -305,6 +329,16 @@ public class NoteSpawner : MonoBehaviour
}
}
private IEnumerator CalibrateAfterSpawn(HoldNote seg, Vector3 spawnPos)
{
if (seg == null) yield break;
for (int i = 0; i < Mathf.Max(1, calibrateChecks); i++)
{
yield return new WaitForSecondsRealtime(calibrateInterval);
if (seg == null || !seg.gameObject.activeSelf) yield break;
seg.CalibratePosition(spawnPos, calibrateTolerance);
}
}
private float CalculateSpeed(float noteTravelTime)
{