208 lines
6.7 KiB
C#
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;
|
|
}
|
|
}
|