备份,准备做双端

This commit is contained in:
FloatGaming
2026-07-30 23:15:58 +08:00
parent add675e45d
commit ec4ec53545
88 changed files with 4050 additions and 872 deletions
+179 -29
View File
@@ -2,9 +2,11 @@ using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using TMPro;
using Bansonic;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
public class delayTapperPrefab : MonoBehaviour
{
@@ -43,8 +45,14 @@ public class delayTapperPrefab : MonoBehaviour
[Header("Tapper Stats")]
[SerializeField] public List<string> tapValueOffsets = new List<string>();
private float totalOffsetSum = 0f; // 用于计算平均值的累计偏移
private int tapCount = 0; // 累计点击次数
// 每次点击相对最近拍点的原始偏移(秒)。用于最终的中位数 + 去异常值统计,
// 取代旧版"累计平均"(平均会被漏拍/误触严重拉偏且会随点击次数稀释)。
private readonly List<float> rawOffsetSamples = new List<float>();
[Header("Audio Offset UI (device latency knob)")]
[Tooltip("音频偏移输入框(ms):只移动音乐播放时刻补偿设备/蓝牙延迟,绝不改判定。与输入校准偏移独立。")]
public TMP_InputField audioOffsetInput;
public Button audioOffsetSaveButton;
[SerializeField] private int BPM = 120;
@@ -54,10 +62,18 @@ public class delayTapperPrefab : MonoBehaviour
private bool isAudioReady = false;
private bool isPaused = false;
private bool isRunning = false;
// dspTime-based clock. currentElapsed is derived from AudioSettings.dspTime relative to
// the cycle's start dsp, NOT accumulated Time.deltaTime — this matches the game's
// GameplayClock and the audio thread exactly, removing frame-rate drift.
private double cycleStartDsp = 0d; // dspTime at which the current beat cycle began
private double pausedAtDsp = 0d; // dspTime captured when paused
private double accumulatedPauseDsp = 0d; // total paused duration within the current cycle
private float currentElapsed = 0f;
private Dictionary<AudioSource, bool> originalMuteStates = new Dictionary<AudioSource, bool>();
private const string DELAY_PREFS_KEY = "UserGlobalDelaySeconds";
private const string AUDIO_OFFSET_PREFS_KEY = "UserAudioOffsetSeconds"; // = GameManager.AudioOffsetPrefsKey
private void Awake()
{
@@ -146,6 +162,8 @@ public class delayTapperPrefab : MonoBehaviour
toggle_extra_ui.onClick.AddListener(OnToggleExtraUIClicked);
}
SetupAudioOffsetUI();
// 开始加载音频
StartCoroutine(PrepareAudioAndEnableButton());
@@ -153,6 +171,76 @@ public class delayTapperPrefab : MonoBehaviour
MuteOtherAudioSources();
}
/// <summary>
/// 音频偏移(设备延迟补偿)独立于输入校准偏移:它只移动音乐播放时刻(GameManager 读取
/// UserAudioOffsetSeconds),绝不改判定。此处提供一个独立输入框+保存按钮,读写该 PlayerPrefs。
/// </summary>
private void SetupAudioOffsetUI()
{
if (audioOffsetInput != null)
{
float savedAudioOffset = PlayerPrefs.GetFloat(AUDIO_OFFSET_PREFS_KEY, 0f);
isInternalSetting = true;
audioOffsetInput.text = Mathf.RoundToInt(savedAudioOffset * 1000f).ToString();
isInternalSetting = false;
audioOffsetInput.onEndEdit.RemoveListener(OnAudioOffsetEndEdit);
audioOffsetInput.onEndEdit.AddListener(OnAudioOffsetEndEdit);
}
if (audioOffsetSaveButton != null)
{
audioOffsetSaveButton.onClick.RemoveListener(OnAudioOffsetSaveClicked);
audioOffsetSaveButton.onClick.AddListener(OnAudioOffsetSaveClicked);
}
}
private void OnAudioOffsetEndEdit(string input)
{
if (isInternalSetting) return;
if (int.TryParse(input, out int value))
{
if (value < -1000 || value > 1000)
{
int clamped = Mathf.Clamp(value, -1000, 1000);
gNotice.warning.display($"音频偏移超出范围 (-1000~1000),已重置为 {clamped}");
isInternalSetting = true;
audioOffsetInput.text = clamped.ToString();
isInternalSetting = false;
}
}
else
{
gNotice.warning.display("非法的音频偏移输入,已重置为 0");
isInternalSetting = true;
audioOffsetInput.text = "0";
isInternalSetting = false;
}
}
private void OnAudioOffsetSaveClicked()
{
if (audioOffsetInput != null && int.TryParse(audioOffsetInput.text, out int msValue))
{
int clamped = Mathf.Clamp(msValue, -1000, 1000);
float seconds = clamped / 1000f;
PlayerPrefs.SetFloat(AUDIO_OFFSET_PREFS_KEY, seconds);
PlayerPrefs.Save();
gNotice.warning.display($"已保存音频偏移: {clamped}ms ({seconds:F3}s)");
Debug.Log($"[delayTapperPrefab] Saved audio offset to PlayerPrefs: {seconds}s");
}
else
{
gNotice.warning.display("音频偏移无效,未保存");
}
if (EventSystem.current != null)
{
EventSystem.current.SetSelectedGameObject(null);
}
}
private void OnDestroy()
{
// 销毁时恢复音频源状态
@@ -208,6 +296,8 @@ public class delayTapperPrefab : MonoBehaviour
isRunning = false;
isPaused = false;
currentElapsed = 0f;
accumulatedPauseDsp = 0d;
pausedAtDsp = 0d;
UpdateStartButtonText();
// 2. 清空 Prefab 和采样数据
@@ -336,13 +426,15 @@ public class delayTapperPrefab : MonoBehaviour
isPaused = !isPaused;
if (isPaused)
{
// 暂停
// 暂停:记录暂停时刻的 dspTime,冻结 cycle 时钟。
pausedAtDsp = AudioSettings.dspTime;
if (audioSource != null && audioSource.isPlaying) audioSource.Pause();
Debug.Log("[delayTapperPrefab] 暂停测试");
}
else
{
// 继续
// 继续:把暂停期间流逝的 dspTime 累加进 pause 补偿,使 cycle 时钟不计入暂停时长。
accumulatedPauseDsp += AudioSettings.dspTime - pausedAtDsp;
if (audioSource != null) audioSource.UnPause();
Debug.Log("[delayTapperPrefab] 继续测试");
}
@@ -515,33 +607,43 @@ public class delayTapperPrefab : MonoBehaviour
// 如果暂停,不执行任何 Update 逻辑
if (isPaused) return;
// 改进监听逻辑:只要 Slider 正在滚动,就允许记录空格
if (sliderCoroutine != null && Input.GetKeyDown(KeyCode.Space))
// 改进监听逻辑:只要 Slider 正在滚动,就允许记录空格
// 用 Input System 的 Keyboard.current 捕获,并在按下的同一帧读取 AudioSettings.dspTime
// 与游戏内判定同源(dspTime),消除 Time.deltaTime 帧量化误差。
if (sliderCoroutine != null && Keyboard.current != null && Keyboard.current.spaceKey.wasPressedThisFrame)
{
SpawnTapperHandle();
SpawnTapperHandle(AudioSettings.dspTime);
}
}
private void SpawnTapperHandle()
// 由 dspTime 推导当前 cycle 内已流逝时间(秒),与游戏时钟一致,无帧率漂移。
private float ElapsedFromDsp()
{
double reference = isPaused ? pausedAtDsp : AudioSettings.dspTime;
double elapsed = reference - cycleStartDsp - accumulatedPauseDsp;
return Mathf.Max(0f, (float)elapsed);
}
private void SpawnTapperHandle(double pressDsp)
{
Debug.Log("[delayTapperPrefab] SpawnTapperHandle 被调用");
if (runSlider == null) { Debug.LogError("runSlider 为空"); return; }
// 1. 直接基于当前时间(currentElapsed)计算偏移,不再依赖 Slider 的百分比,提高精度
// 1. 用按键时刻的 dspTime 推导 cycle 内流逝时间,再算相对最近拍点的偏移,精度到音频缓冲级。
double reference = pressDsp - cycleStartDsp - accumulatedPauseDsp;
float elapsedAtPress = Mathf.Max(0f, (float)reference);
float beatDuration = 60f / Mathf.Max(BPM, 1); // 每拍时长 (120BPM 为 0.5s)
float nearestBeatTime = Mathf.Round(currentElapsed / beatDuration) * beatDuration;
float offsetSeconds = currentElapsed - nearestBeatTime;
// 2. 记录差值到 List (Inspector 显示用,保留 ms 字符串格式)
float nearestBeatTime = Mathf.Round(elapsedAtPress / beatDuration) * beatDuration;
float offsetSeconds = elapsedAtPress - nearestBeatTime;
// 2. 记录原始样本(用于中位数/去异常),同时保留 ms 字符串到 Inspector List 便于查看。
rawOffsetSamples.Add(offsetSeconds);
string offsetStr = (offsetSeconds * 1000f).ToString("F2") + "ms";
tapValueOffsets.Add(offsetStr);
// 3. 计算平均偏移逻辑
tapCount++;
totalOffsetSum += offsetSeconds;
float averageOffsetSeconds = totalOffsetSum / tapCount;
int finalDelayMs = Mathf.RoundToInt(averageOffsetSeconds * 1000f);
// 3. 用中位数 + 去异常值(MAD)计算稳健延迟,取代旧的累计平均(易被漏拍/误触拉偏、随次数稀释)。
int finalDelayMs = Mathf.RoundToInt(ComputeRobustDelaySeconds() * 1000f);
// 4. 输出到 delayAmountNow
if (delayAmountNow != null)
{
@@ -550,7 +652,7 @@ public class delayTapperPrefab : MonoBehaviour
isInternalSetting = false;
}
Debug.Log($"[delayTapperPrefab] 模式={currentMode}, 点击#{tapCount}: Time={currentElapsed:F3}s, NearestBeat={nearestBeatTime:F3}s, Offset={offsetSeconds*1000f:F2}ms, AvgDelay={finalDelayMs}ms");
Debug.Log($"[delayTapperPrefab] 模式={currentMode}, 点击#{rawOffsetSamples.Count}: Time={elapsedAtPress:F3}s, NearestBeat={nearestBeatTime:F3}s, Offset={offsetSeconds*1000f:F2}ms, RobustDelay={finalDelayMs}ms");
if (tapperHandlePrefab == null || tapperHandlePrefab_toput == null) return;
@@ -572,6 +674,53 @@ public class delayTapperPrefab : MonoBehaviour
}
}
/// <summary>
/// 稳健地估计玩家的输入偏移(秒):先取样本中位数,再用 MAD(绝对中位差)剔除离群点,
/// 最后对保留样本取均值。相比累计平均,能抵抗漏拍、误触和偶发大偏差,
/// 且不会随点击次数增多而被历史值稀释。样本不足时直接返回中位数/单值。
/// </summary>
private float ComputeRobustDelaySeconds()
{
int n = rawOffsetSamples.Count;
if (n == 0) return 0f;
if (n == 1) return rawOffsetSamples[0];
float median = Median(rawOffsetSamples);
// MAD = median(|x_i - median|)。用 1.4826 换算成与标准差可比的尺度。
var absDeviations = new List<float>(n);
for (int i = 0; i < n; i++) absDeviations.Add(Mathf.Abs(rawOffsetSamples[i] - median));
float mad = Median(absDeviations);
float robustSigma = mad * 1.4826f;
// 阈值:偏离中位数超过 3 个稳健标准差的样本视为异常值。
// MAD 为 0(样本高度一致)时不剔除任何点,直接用中位数。
if (robustSigma <= 1e-6f) return median;
float threshold = 3f * robustSigma;
double sum = 0d;
int kept = 0;
for (int i = 0; i < n; i++)
{
if (Mathf.Abs(rawOffsetSamples[i] - median) <= threshold)
{
sum += rawOffsetSamples[i];
kept++;
}
}
return kept > 0 ? (float)(sum / kept) : median;
}
private static float Median(List<float> values)
{
int n = values.Count;
if (n == 0) return 0f;
var sorted = values.OrderBy(v => v).ToList();
int mid = n / 2;
return (n % 2 == 1) ? sorted[mid] : 0.5f * (sorted[mid - 1] + sorted[mid]);
}
private void ClearTapperHandles()
{
Debug.Log("[delayTapperPrefab] ClearTapperHandles 被显式调用,即将清空 List 和累计数据");
@@ -583,11 +732,10 @@ public class delayTapperPrefab : MonoBehaviour
Destroy(tapperHandlePrefab_toput.transform.GetChild(i).gameObject);
}
// 重置所有累计数据
// 重置所有采样数据
tapValueOffsets.Clear();
totalOffsetSum = 0f;
tapCount = 0;
rawOffsetSamples.Clear();
Debug.Log("[delayTapperPrefab] 已手动清理 TapperHandles 和所有采样数据");
}
@@ -678,20 +826,22 @@ public class delayTapperPrefab : MonoBehaviour
// 注意:这里我们只清理生成的 Handle,不重置数据,因为数据是跨循环累计的
ClearVisualHandlesOnly();
// 如果不是暂停后继续,则重置 currentElapsed
if (!isPaused) currentElapsed = 0f;
// 8拍模式走完 8 拍,4拍模式走完 4 拍
int beatsInCycle = (currentMode == TapperMode.FourBeats) ? 4 : 8;
float cycleDuration = (60f / Mathf.Max(BPM, 1)) * beatsInCycle;
// 用 dspTime 作为 cycle 时钟起点,与按键捕获同源。每个 cycle 重置 pause 累计。
cycleStartDsp = AudioSettings.dspTime;
accumulatedPauseDsp = 0d;
currentElapsed = 0f;
Debug.Log($"[delayTapperPrefab] 开始 {beatsInCycle} 拍滚动,BPM: {BPM}, 单次循环时长: {cycleDuration}s");
while (currentElapsed < cycleDuration)
{
if (!isPaused)
{
currentElapsed += Time.deltaTime;
currentElapsed = ElapsedFromDsp();
runSlider.value = Mathf.Clamp01(currentElapsed / cycleDuration);
}
yield return null;