using UnityEngine; using UnityEngine.InputSystem; using System; /// /// Manages gameplay input via Unity's Input System, creating InputActions at runtime /// and capturing sub-frame press timestamps (AudioSettings.dspTime) for precise judgement. /// Replaces legacy Input.GetKeyDown polling with event-driven callbacks. /// public class GameplayInputActions : MonoBehaviour { public static GameplayInputActions Instance { get; private set; } // Track press/release events. Args: (trackIndex, dspTime at the input event) for sub-frame precision. public static event Action OnTrackPressed; public static event Action OnTrackReleased; private InputAction[] trackActions = new InputAction[5]; private InputAction secondaryTrack3Action; private bool isEnabled; private void Awake() { if (Instance == null) { Instance = this; } else { Destroy(gameObject); return; } CreateActions(); } private void OnEnable() { EnableActions(); } private void OnDisable() { DisableActions(); } private void OnDestroy() { DisableActions(); DisposeActions(); if (Instance == this) Instance = null; } /// /// Create InputActions at runtime and bind them to the keys from KeyBindingManager. /// Called once at Awake. /// private void CreateActions() { string[] colors = { "red", "green", "yellow", "purple", "blue" }; for (int i = 0; i < 5; i++) { trackActions[i] = new InputAction($"Track{i}", InputActionType.Button); string bindingPath = KeyBindingManager.GetBindingPathForTrack(i); if (!string.IsNullOrEmpty(bindingPath)) { trackActions[i].AddBinding(bindingPath); } int trackIndex = i; // capture for closure trackActions[i].started += ctx => HandleTrackPress(trackIndex, ctx); trackActions[i].canceled += ctx => HandleTrackRelease(trackIndex, ctx); } // Secondary track 3 key (yellow副键) secondaryTrack3Action = new InputAction("Track3Secondary", InputActionType.Button); string secondaryPath = KeyBindingManager.GetSecondaryTrack3BindingPath(); if (!string.IsNullOrEmpty(secondaryPath)) { secondaryTrack3Action.AddBinding(secondaryPath); } secondaryTrack3Action.started += ctx => HandleTrackPress(2, ctx); // track 2 = yellow secondaryTrack3Action.canceled += ctx => HandleTrackRelease(2, ctx); } private void EnableActions() { if (isEnabled) return; foreach (var action in trackActions) { action?.Enable(); } secondaryTrack3Action?.Enable(); isEnabled = true; } private void DisableActions() { if (!isEnabled) return; foreach (var action in trackActions) { action?.Disable(); } secondaryTrack3Action?.Disable(); isEnabled = false; } private void DisposeActions() { foreach (var action in trackActions) { action?.Dispose(); } secondaryTrack3Action?.Dispose(); } /// /// Refresh bindings from KeyBindingManager after user rebinds a key. /// Uses ApplyBindingOverride so the action objects (and their event subscriptions) stay intact; /// each action has exactly one binding (index 0) created in CreateActions. /// public void RefreshBindings() { bool wasEnabled = isEnabled; DisableActions(); for (int i = 0; i < 5; i++) { if (trackActions[i] == null) continue; string path = KeyBindingManager.GetBindingPathForTrack(i); ApplySingleBinding(trackActions[i], path); } if (secondaryTrack3Action != null) { string secondaryPath = KeyBindingManager.GetSecondaryTrack3BindingPath(); ApplySingleBinding(secondaryTrack3Action, secondaryPath); } if (wasEnabled) EnableActions(); } // Set an action's single binding path. Actions are created with exactly one binding (index 0); // if none exists yet (empty path at creation), add one now. private static void ApplySingleBinding(InputAction action, string path) { if (string.IsNullOrEmpty(path)) return; if (action.bindings.Count == 0) { action.AddBinding(path); } else { action.ApplyBindingOverride(0, path); } } private void HandleTrackPress(int trackIndex, InputAction.CallbackContext context) { // Sub-frame precise timestamp: convert the Input System event time (realtime base) // into the dspTime axis the whole judgement chain already runs on. OnTrackPressed?.Invoke(trackIndex, ResolveEventDspTime(context)); } private void HandleTrackRelease(int trackIndex, InputAction.CallbackContext context) { OnTrackReleased?.Invoke(trackIndex, ResolveEventDspTime(context)); } // Upper bound (seconds) on how far back an event timestamp may sit before we distrust it. // Real input latency between the event occurring and this callback is a few ms at most; // anything larger means the timestamp is unusable (e.g. synthetic/queued event) and we // fall back to the previous behaviour for exact equivalence. private const double MaxEventLatencySeconds = 0.05; /// /// Convert an Input System event time (Time.realtimeSinceStartup base) to the /// AudioSettings.dspTime axis used by GameplayClock. The event physically occurred /// `latency` seconds before this callback runs, so the equivalent dsp instant is /// dspTime(now) - latency. Recovers sub-audio-buffer precision without introducing any /// systematic offset. Degrades EXACTLY to AudioSettings.dspTime (the prior behaviour) /// whenever the event timestamp is missing or out of a sane range — business logic equivalent. /// private static double ResolveEventDspTime(InputAction.CallbackContext context) { double dspNow = AudioSettings.dspTime; double eventTime = context.time; if (eventTime <= 0d) return dspNow; // no usable timestamp double realtimeNow = Time.realtimeSinceStartupAsDouble; double latency = realtimeNow - eventTime; // Negative (clock skew) or implausibly large latency → distrust, keep old behaviour. if (latency < 0d || latency > MaxEventLatencySeconds) return dspNow; return dspNow - latency; } }