Files
2026-07-30 23:15:58 +08:00

208 lines
6.7 KiB
C#

using UnityEngine;
using UnityEngine.InputSystem;
using System;
/// <summary>
/// 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.
/// </summary>
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<int, double> OnTrackPressed;
public static event Action<int, double> 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;
}
/// <summary>
/// Create InputActions at runtime and bind them to the keys from KeyBindingManager.
/// Called once at Awake.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
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;
}
}