Files
bansonic_beta_main/Assets/scripts/gamePlay_gameplay/TrackKeyManager.cs
T

401 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using UnityEngine;
using System.Collections;
public class TrackKeyManager : MonoBehaviour
{
public static TrackKeyManager Instance { get; private set; }
// store note instance IDs (as strings) in queue order per track
// Format: trackIndex -> Queue of (noteId, noteType) tuples
private Dictionary<int, Queue<(string noteId, string noteType)>> trackKeyMappings =
new Dictionary<int, Queue<(string, string)>>();
// Track notes currently being judged to prevent double-judging the same note
// Format: trackIndex -> HashSet of note IDs being judged
private Dictionary<int, HashSet<string>> trackNotesBeingJudged = new Dictionary<int, HashSet<string>>();
// transient per-frame consumption: ensures one physical press only affects one note per track per frame
private Dictionary<int, int> trackConsumedFrame = new Dictionary<int, int>();
// track last frame we checked for focus loss
private int lastCheckedFrame = -1;
private bool lastFocusedState = true;
// --- Anti-deadlock housekeeping ---
// Some notes can be pooled/disabled without firing OnTriggerExit2D, leaving stale ids in the queue.
// To guarantee the track never blocks forever, each queued id gets a TTL.
// When TTL expires, we prune it as stale.
private readonly Dictionary<string, float> noteIdExpiry = new Dictionary<string, float>();
[Header("TrackKeyManager housekeeping")]
[Tooltip("Seconds a queued note id is allowed to stay without being removed. Prevents permanent deadlocks when OnTriggerExit2D is missed.")]
[Range(0.5f, 10f)]
public float queuedIdTtlSeconds = 3f;
[Tooltip("Maximum number of stale head entries to prune per track per call.")]
[Range(1, 20)]
public int pruneBatchSize = 5;
private Coroutine cleanupCoroutine;
private void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(gameObject);
}
// start periodic cleanup to defend against stale locks that can survive missed Unregister/Unlock calls
StartCleanupRoutine();
}
private void OnDestroy()
{
StopCleanupRoutine();
}
private void StartCleanupRoutine()
{
if (cleanupCoroutine == null)
cleanupCoroutine = StartCoroutine(CleanupStaleLocksRoutine());
}
private void StopCleanupRoutine()
{
if (cleanupCoroutine != null)
{
StopCoroutine(cleanupCoroutine);
cleanupCoroutine = null;
}
}
// Periodically scan locks and queued ids to remove stale entries that can cause deadlocks.
private IEnumerator CleanupStaleLocksRoutine()
{
while (true)
{
// run once per second (unscaled so it runs during pause)
yield return new WaitForSecondsRealtime(1f);
float now = Time.unscaledTime;
// 1) Prune head entries for all tracks (defensive)
var trackKeys = new List<int>(trackKeyMappings.Keys);
foreach (var ti in trackKeys)
{
PruneStaleHeads(ti);
}
// 2) Inspect locks and release any that reference ids that are expired or not present in any queue
var trackLockKeys = new List<int>(trackNotesBeingJudged.Keys);
foreach (var t in trackLockKeys)
{
var locks = trackNotesBeingJudged[t];
if (locks == null || locks.Count == 0) continue;
var toRemove = new List<string>();
foreach (var noteId in locks)
{
bool hasExpiry = noteIdExpiry.TryGetValue(noteId, out var expiry);
bool expired = hasExpiry && now > expiry;
// If expiry missing or expired, consider the id stale and remove lock
if (!hasExpiry || expired)
{
toRemove.Add(noteId);
continue;
}
// Also if the queues do not contain this id anywhere, it is likely stale
bool foundInQueues = false;
foreach (var qpair in trackKeyMappings)
{
foreach (var item in qpair.Value)
{
if (!string.IsNullOrEmpty(item.noteId) && item.noteId == noteId)
{
foundInQueues = true;
break;
}
}
if (foundInQueues) break;
}
if (!foundInQueues)
{
toRemove.Add(noteId);
}
}
foreach (var id in toRemove)
{
locks.Remove(id);
noteIdExpiry.Remove(id);
if (GameConfig.verboseLogs) Debug.LogWarning($"[TrackKeyManager] Cleanup: removed stale lock id={id} on track {t}");
}
// If locks become empty, remove dictionary entry to keep structure clean
if (locks.Count == 0)
{
trackNotesBeingJudged.Remove(t);
}
}
// 3) Defensive: if a track has no queued ids but has consumed-frame or locks, clear them
var allTracks = new HashSet<int>();
foreach (var k in trackKeyMappings.Keys) allTracks.Add(k);
foreach (var k in trackNotesBeingJudged.Keys) allTracks.Add(k);
foreach (var k in trackConsumedFrame.Keys) allTracks.Add(k);
foreach (var track in allTracks)
{
bool hasQueue = trackKeyMappings.ContainsKey(track) && trackKeyMappings[track].Count > 0;
bool hasLocks = trackNotesBeingJudged.ContainsKey(track) && trackNotesBeingJudged[track].Count > 0;
if (!hasQueue && hasLocks)
{
// clear locks for this track
trackNotesBeingJudged.Remove(track);
if (GameConfig.verboseLogs) Debug.LogWarning($"[TrackKeyManager] Cleanup: cleared locks for empty track {track}");
}
}
}
}
private void Update()
{
// Clear per-frame consumption dictionary when focus is regained or at frame boundaries
int currentFrame = Time.frameCount;
bool isFocused = Application.isFocused;
// If we've moved to a new frame or focus state changed, clear consumption tracking
if (lastCheckedFrame != currentFrame || (isFocused && !lastFocusedState))
{
// On focus regain, clear the consumed frame dictionary to allow input again
if (isFocused && !lastFocusedState)
{
trackConsumedFrame.Clear();
if (GameConfig.verboseLogs) Debug.Log("[TrackKeyManager] Focus regained, cleared track consumption");
}
}
lastCheckedFrame = currentFrame;
lastFocusedState = isFocused;
}
private void PruneStaleHeads(int trackIndex)
{
if (!trackKeyMappings.TryGetValue(trackIndex, out var q) || q == null || q.Count == 0)
return;
int pruned = 0;
float now = Time.unscaledTime;
while (q.Count > 0 && pruned < Mathf.Max(1, pruneBatchSize))
{
var head = q.Peek();
if (string.IsNullOrEmpty(head.noteId))
{
q.Dequeue();
pruned++;
continue;
}
// never prune if this id is currently locked for judgement
if (trackNotesBeingJudged.TryGetValue(trackIndex, out var locks) && locks != null && locks.Contains(head.noteId))
break;
// if an expiry exists and has passed, prune
if (noteIdExpiry.TryGetValue(head.noteId, out var expiry) && now > expiry)
{
q.Dequeue();
noteIdExpiry.Remove(head.noteId);
pruned++;
if (GameConfig.verboseLogs)
Debug.LogWarning($"[TrackKeyManager] Pruned stale head on track {trackIndex}: id={head.noteId} type={head.noteType} now={now:F2} expiry={expiry:F2}");
continue;
}
// head is still within TTL
break;
}
}
/// <summary>
/// 当音符进入判定区域时,添加音符 id 到队列(包含note类型信息)
/// noteType: "tap" 或 "hold"
/// </summary>
public void RegisterKey(int trackIndex, string noteInstanceId, string noteType = "tap")
{
if (!trackKeyMappings.ContainsKey(trackIndex))
trackKeyMappings[trackIndex] = new Queue<(string, string)>();
trackKeyMappings[trackIndex].Enqueue((noteInstanceId, noteType));
// record/refresh TTL so the id can't block forever if exit/unregister is missed
if (!string.IsNullOrEmpty(noteInstanceId))
{
noteIdExpiry[noteInstanceId] = Time.unscaledTime + Mathf.Max(0.25f, queuedIdTtlSeconds);
}
}
/// <summary>
/// 当音符离开判定区域时,移除音符 id
/// </summary>
public void UnregisterKey(int trackIndex, string noteInstanceId)
{
if (!trackKeyMappings.ContainsKey(trackIndex) || trackKeyMappings[trackIndex].Count == 0)
{
// still clear TTL record
if (!string.IsNullOrEmpty(noteInstanceId)) noteIdExpiry.Remove(noteInstanceId);
return;
}
// If the head matches, dequeue. Otherwise try to remove from queue by rebuilding it.
if (trackKeyMappings[trackIndex].Count > 0 && trackKeyMappings[trackIndex].Peek().noteId == noteInstanceId)
{
trackKeyMappings[trackIndex].Dequeue();
if (!string.IsNullOrEmpty(noteInstanceId)) noteIdExpiry.Remove(noteInstanceId);
return;
}
// Otherwise remove matching id if present (preserve order of others)
var q = trackKeyMappings[trackIndex];
var temp = new Queue<(string, string)>();
while (q.Count > 0)
{
var item = q.Dequeue();
if (item.noteId != noteInstanceId)
{
temp.Enqueue(item);
}
}
trackKeyMappings[trackIndex] = temp;
if (!string.IsNullOrEmpty(noteInstanceId)) noteIdExpiry.Remove(noteInstanceId);
}
/// <summary>
/// 获取轨道当前排在队首的音符实例 id(队列头)
/// </summary>
public string GetCurrentNoteId(int trackIndex)
{
PruneStaleHeads(trackIndex);
if (trackKeyMappings.ContainsKey(trackIndex) && trackKeyMappings[trackIndex].Count > 0)
{
return trackKeyMappings[trackIndex].Peek().noteId;
}
return null; // 无音符
}
/// <summary>
/// 获取当前队头note的类型("tap" 或 "hold"
/// </summary>
public string GetCurrentNoteType(int trackIndex)
{
PruneStaleHeads(trackIndex);
if (trackKeyMappings.ContainsKey(trackIndex) && trackKeyMappings[trackIndex].Count > 0)
{
return trackKeyMappings[trackIndex].Peek().noteType;
}
return null;
}
/// <summary>
/// Try to register a note as being judged on a track. Returns true if successful (no other note is currently being judged).
/// This prevents multiple notes on the same track from being judged by a single key press.
/// </summary>
public bool TryLockTrackForJudge(int trackIndex, string noteId)
{
if (trackIndex < 0) return false;
// Before locking, prune any expired heads so they can't block judgement.
PruneStaleHeads(trackIndex);
if (!trackNotesBeingJudged.ContainsKey(trackIndex))
{
trackNotesBeingJudged[trackIndex] = new HashSet<string>();
}
// If there are already notes being judged on this track, reject
if (trackNotesBeingJudged[trackIndex].Count > 0)
{
return false;
}
// Lock this note
trackNotesBeingJudged[trackIndex].Add(noteId);
return true;
}
/// <summary>
/// Release the lock for a note on a track. Call this after judgment is complete.
/// </summary>
public void UnlockTrackForJudge(int trackIndex, string noteId)
{
if (trackIndex < 0) return;
if (trackNotesBeingJudged.ContainsKey(trackIndex))
{
trackNotesBeingJudged[trackIndex].Remove(noteId);
}
// also clear any expiry record for this note id (it has finished its lifecycle)
if (!string.IsNullOrEmpty(noteId)) noteIdExpiry.Remove(noteId);
}
/// <summary>
/// Try to mark this track as consumed for the current frame. Returns true if this call
/// successfully consumes the track (no other note on the same track has consumed this frame).
/// </summary>
public bool TryConsumeTrackForFrame(int trackIndex)
{
if (trackIndex < 0) return false;
int currentFrame = Time.frameCount;
// If the recorded frame is old or invalid, allow consumption
if (!trackConsumedFrame.ContainsKey(trackIndex))
{
trackConsumedFrame[trackIndex] = currentFrame;
return true;
}
// If we're in a different frame, reset and allow
int recordedFrame = trackConsumedFrame[trackIndex];
if (recordedFrame != currentFrame)
{
trackConsumedFrame[trackIndex] = currentFrame;
return true;
}
// Same frame, already consumed - reject
return false;
}
/// <summary>
/// Force reset the consumption state (useful for testing or specific scenarios)
/// </summary>
public void ResetConsumptionState()
{
trackConsumedFrame.Clear();
}
/// <summary>
/// Force clear all track locks and per-frame consumption state.
/// Used to simulate a global input release (e.g. when ending playback).
/// </summary>
public void ClearAllLocks()
{
trackNotesBeingJudged.Clear();
trackConsumedFrame.Clear();
noteIdExpiry.Clear();
if (GameConfig.verboseLogs) Debug.Log("[TrackKeyManager] ClearAllLocks called: cleared locks and consumption state");
}
}