870 lines
29 KiB
C#
870 lines
29 KiB
C#
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
|
||
{
|
||
[Header("data")]
|
||
[SerializeField] private float delayAmountSeconds;
|
||
|
||
[Header("delay tapper")]
|
||
public TMP_InputField delayAmountNow;
|
||
public Slider runSlider;
|
||
public TMP_InputField bpmInputField;
|
||
public Button startButton;
|
||
public Button resetButton;
|
||
public Button saveButton;
|
||
public Button quitButton;
|
||
public TMP_Dropdown modeDropdown;
|
||
public Image[] modeSpecificImages; // 4个特定的Image
|
||
|
||
public Button endiasble_hori;
|
||
public GameObject hori_object;
|
||
|
||
[HideInInspector] public controllerSettings mainSettings;
|
||
public Button toggle_extra_ui;
|
||
public GameObject extra_ui_object;
|
||
|
||
public enum TapperMode { EightBeats, FourBeats }
|
||
private TapperMode currentMode = TapperMode.FourBeats;
|
||
|
||
public GameObject tapperHandlePrefab;
|
||
public GameObject tapperHandlePrefab_toput;
|
||
|
||
[Header("Audio Playback")]
|
||
public AudioSource audioSource;
|
||
public AudioSource hit_sfx_as;
|
||
public float musicStartTime = 0f;
|
||
public float musicEndTime = 0f;
|
||
|
||
[Header("Tapper Stats")]
|
||
[SerializeField] public List<string> tapValueOffsets = new List<string>();
|
||
// 每次点击相对最近拍点的原始偏移(秒)。用于最终的中位数 + 去异常值统计,
|
||
// 取代旧版"累计平均"(平均会被漏拍/误触严重拉偏且会随点击次数稀释)。
|
||
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;
|
||
|
||
private Coroutine sliderCoroutine;
|
||
private Coroutine audioCoroutine;
|
||
private bool isInternalSetting = false;
|
||
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()
|
||
{
|
||
// 强制确保 List 已初始化
|
||
if (tapValueOffsets == null) tapValueOffsets = new List<string>();
|
||
}
|
||
|
||
private void Start()
|
||
{
|
||
if (bpmInputField != null)
|
||
{
|
||
// 锁定 BPM 为 120 且不可编辑
|
||
isInternalSetting = true;
|
||
bpmInputField.text = "120";
|
||
bpmInputField.interactable = false; // 禁用交互
|
||
isInternalSetting = false;
|
||
|
||
// 监听点击或编辑事件
|
||
bpmInputField.onSelect.AddListener(delegate { OnInputFieldSelected(); });
|
||
bpmInputField.onValueChanged.AddListener(OnInputFieldChanged);
|
||
}
|
||
|
||
if (startButton != null)
|
||
{
|
||
// 在代码中显式绑定按钮,防止 Inspector 遗漏
|
||
startButton.onClick.RemoveListener(OnStartButtonClicked);
|
||
startButton.onClick.AddListener(OnStartButtonClicked);
|
||
|
||
// 允许点击按钮
|
||
startButton.interactable = true;
|
||
UpdateStartButtonText();
|
||
}
|
||
|
||
if (saveButton != null)
|
||
{
|
||
saveButton.onClick.RemoveListener(OnSaveButtonClicked);
|
||
saveButton.onClick.AddListener(OnSaveButtonClicked);
|
||
}
|
||
|
||
if (quitButton != null)
|
||
{
|
||
quitButton.onClick.RemoveListener(OnQuitButtonClicked);
|
||
quitButton.onClick.AddListener(OnQuitButtonClicked);
|
||
}
|
||
|
||
// 读取保存的延迟值
|
||
float savedDelay = PlayerPrefs.GetFloat(DELAY_PREFS_KEY, 0f);
|
||
if (delayAmountNow != null)
|
||
{
|
||
isInternalSetting = true;
|
||
delayAmountNow.text = Mathf.RoundToInt(savedDelay * 1000f).ToString();
|
||
isInternalSetting = false;
|
||
}
|
||
|
||
if (modeDropdown != null)
|
||
{
|
||
modeDropdown.onValueChanged.RemoveListener(OnModeChanged);
|
||
modeDropdown.onValueChanged.AddListener(OnModeChanged);
|
||
// 默认设置为四拍模式 (index 1)
|
||
modeDropdown.value = 1;
|
||
// 初始化显示状态
|
||
OnModeChanged(modeDropdown.value);
|
||
}
|
||
|
||
if (delayAmountNow != null)
|
||
{
|
||
delayAmountNow.onEndEdit.RemoveListener(OnDelayAmountEndEdit);
|
||
delayAmountNow.onEndEdit.AddListener(OnDelayAmountEndEdit);
|
||
}
|
||
|
||
if (resetButton != null)
|
||
{
|
||
resetButton.onClick.RemoveListener(OnResetButtonClicked);
|
||
resetButton.onClick.AddListener(OnResetButtonClicked);
|
||
}
|
||
|
||
if (endiasble_hori != null)
|
||
{
|
||
endiasble_hori.onClick.RemoveListener(OnEndisableHoriClicked);
|
||
endiasble_hori.onClick.AddListener(OnEndisableHoriClicked);
|
||
}
|
||
|
||
if (toggle_extra_ui != null)
|
||
{
|
||
toggle_extra_ui.onClick.RemoveListener(OnToggleExtraUIClicked);
|
||
toggle_extra_ui.onClick.AddListener(OnToggleExtraUIClicked);
|
||
}
|
||
|
||
SetupAudioOffsetUI();
|
||
|
||
// 开始加载音频
|
||
StartCoroutine(PrepareAudioAndEnableButton());
|
||
|
||
// 查找场景中所有的 AudioSource 并将非指定的 AudioSource 静音
|
||
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()
|
||
{
|
||
// 销毁时恢复音频源状态
|
||
RestoreAudioSources();
|
||
}
|
||
|
||
private void MuteOtherAudioSources()
|
||
{
|
||
originalMuteStates.Clear();
|
||
// 查找场景中所有的 AudioSource (包括非激活的)
|
||
AudioSource[] allAudioSources = Object.FindObjectsByType<AudioSource>(FindObjectsInactive.Include, FindObjectsSortMode.None);
|
||
foreach (var asrc in allAudioSources)
|
||
{
|
||
// 如果是指定的两个音频源之一,不静音
|
||
if (asrc == audioSource || asrc == hit_sfx_as)
|
||
{
|
||
// 确保它们是取消静音的,以便能听到声音
|
||
asrc.mute = false;
|
||
continue;
|
||
}
|
||
|
||
// 记录原始静音状态
|
||
if (!originalMuteStates.ContainsKey(asrc))
|
||
{
|
||
originalMuteStates[asrc] = asrc.mute;
|
||
asrc.mute = true; // 静音
|
||
}
|
||
}
|
||
Debug.Log($"[delayTapperPrefab] 已静音 {originalMuteStates.Count} 个音频源");
|
||
}
|
||
|
||
private void RestoreAudioSources()
|
||
{
|
||
int restoredCount = 0;
|
||
foreach (var kvp in originalMuteStates)
|
||
{
|
||
if (kvp.Key != null)
|
||
{
|
||
kvp.Key.mute = kvp.Value;
|
||
restoredCount++;
|
||
}
|
||
}
|
||
originalMuteStates.Clear();
|
||
Debug.Log($"[delayTapperPrefab] 已恢复 {restoredCount} 个音频源的静音状态");
|
||
}
|
||
|
||
public void OnResetButtonClicked()
|
||
{
|
||
Debug.Log("[delayTapperPrefab] Reset 按钮被点击");
|
||
|
||
// 1. 停止滚动和音频
|
||
StopAndResetTapper();
|
||
isRunning = false;
|
||
isPaused = false;
|
||
currentElapsed = 0f;
|
||
accumulatedPauseDsp = 0d;
|
||
pausedAtDsp = 0d;
|
||
UpdateStartButtonText();
|
||
|
||
// 2. 清空 Prefab 和采样数据
|
||
ClearTapperHandles();
|
||
|
||
// 3. 重置 UI 显示
|
||
if (delayAmountNow != null)
|
||
{
|
||
isInternalSetting = true;
|
||
delayAmountNow.text = "0";
|
||
isInternalSetting = false;
|
||
}
|
||
|
||
// 4. 取消按钮焦点
|
||
if (EventSystem.current != null)
|
||
{
|
||
EventSystem.current.SetSelectedGameObject(null);
|
||
}
|
||
}
|
||
|
||
private void OnEndisableHoriClicked()
|
||
{
|
||
if (hori_object != null)
|
||
{
|
||
bool isActive = hori_object.activeSelf;
|
||
hori_object.SetActive(!isActive);
|
||
Debug.Log($"[delayTapperPrefab] hori_object state toggled to: {!isActive}");
|
||
}
|
||
|
||
// 取消按钮焦点,防止空格触发重复点击
|
||
if (EventSystem.current != null)
|
||
{
|
||
EventSystem.current.SetSelectedGameObject(null);
|
||
}
|
||
}
|
||
|
||
private void OnToggleExtraUIClicked()
|
||
{
|
||
if (extra_ui_object != null)
|
||
{
|
||
bool isActive = extra_ui_object.activeSelf;
|
||
extra_ui_object.SetActive(!isActive);
|
||
Debug.Log($"[delayTapperPrefab] extra_ui_object state toggled to: {!isActive}");
|
||
}
|
||
|
||
// 取消按钮焦点,防止空格触发重复点击
|
||
if (EventSystem.current != null)
|
||
{
|
||
EventSystem.current.SetSelectedGameObject(null);
|
||
}
|
||
}
|
||
|
||
private void OnSaveButtonClicked()
|
||
{
|
||
if (delayAmountNow != null && float.TryParse(delayAmountNow.text, out float msValue))
|
||
{
|
||
float seconds = msValue / 1000f;
|
||
PlayerPrefs.SetFloat(DELAY_PREFS_KEY, seconds);
|
||
PlayerPrefs.Save();
|
||
gNotice.warning.display($"已保存全局延迟: {msValue}ms ({seconds:F3}s)");
|
||
Debug.Log($"[delayTapperPrefab] Saved delay to PlayerPrefs: {seconds}s");
|
||
|
||
// 刷新父界面的显示
|
||
if (mainSettings != null)
|
||
{
|
||
mainSettings.RefreshDelayText();
|
||
}
|
||
}
|
||
|
||
if (EventSystem.current != null)
|
||
{
|
||
EventSystem.current.SetSelectedGameObject(null);
|
||
}
|
||
|
||
// 保存后销毁物体
|
||
OnQuitButtonClicked();
|
||
}
|
||
|
||
private void OnQuitButtonClicked()
|
||
{
|
||
Debug.Log("[delayTapperPrefab] Quit 按钮被点击,销毁物体");
|
||
Destroy(this.gameObject);
|
||
}
|
||
|
||
private void UpdateStartButtonText()
|
||
{
|
||
if (startButton == null) return;
|
||
|
||
Text btnText = startButton.GetComponentInChildren<Text>();
|
||
if (btnText == null)
|
||
{
|
||
TMP_Text tmpText = startButton.GetComponentInChildren<TMP_Text>();
|
||
if (tmpText != null)
|
||
{
|
||
if (!isRunning) tmpText.text = "开始测试";
|
||
else if (isPaused) tmpText.text = "继续";
|
||
else tmpText.text = "暂停";
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!isRunning) btnText.text = "开始测试";
|
||
else if (isPaused) btnText.text = "继续";
|
||
else btnText.text = "暂停";
|
||
}
|
||
|
||
private void OnStartButtonClicked()
|
||
{
|
||
if (!isAudioReady)
|
||
{
|
||
gNotice.warning.display("音频未就绪");
|
||
return;
|
||
}
|
||
|
||
if (!isRunning)
|
||
{
|
||
// 第一次开始
|
||
isRunning = true;
|
||
isPaused = false;
|
||
currentElapsed = 0f;
|
||
StartTapperFromInput();
|
||
}
|
||
else
|
||
{
|
||
// 切换暂停/继续
|
||
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] 继续测试");
|
||
}
|
||
}
|
||
|
||
UpdateStartButtonText();
|
||
|
||
if (EventSystem.current != null)
|
||
{
|
||
EventSystem.current.SetSelectedGameObject(null);
|
||
}
|
||
}
|
||
|
||
private void OnDelayAmountEndEdit(string input)
|
||
{
|
||
if (isInternalSetting) return;
|
||
|
||
if (int.TryParse(input, out int value))
|
||
{
|
||
if (value < -1000 || value > 1000)
|
||
{
|
||
int clampedValue = Mathf.Clamp(value, -1000, 1000);
|
||
gNotice.warning.display($"延迟值超出范围 (-1000~1000),已重置为 {clampedValue}");
|
||
isInternalSetting = true;
|
||
delayAmountNow.text = clampedValue.ToString();
|
||
isInternalSetting = false;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
gNotice.warning.display("非法的延迟值输入,已重置为 0");
|
||
isInternalSetting = true;
|
||
delayAmountNow.text = "0";
|
||
isInternalSetting = false;
|
||
}
|
||
}
|
||
|
||
private void OnModeChanged(int index)
|
||
{
|
||
// 0 = 8拍模式, 1 = 4拍模式 (由 Dropdown 设置顺序决定)
|
||
currentMode = (index == 1) ? TapperMode.FourBeats : TapperMode.EightBeats;
|
||
|
||
// 如果是 4 拍模式,将 4 张图 alpha 设置为 0
|
||
if (modeSpecificImages != null)
|
||
{
|
||
foreach (var img in modeSpecificImages)
|
||
{
|
||
if (img != null)
|
||
{
|
||
Color c = img.color;
|
||
c.a = (currentMode == TapperMode.FourBeats) ? 0f : 1f;
|
||
img.color = c;
|
||
}
|
||
}
|
||
}
|
||
|
||
Debug.Log($"[delayTapperPrefab] 模式切换:{currentMode}");
|
||
|
||
// 切换模式时重置数据
|
||
StopAndResetTapper();
|
||
ClearTapperHandles();
|
||
}
|
||
|
||
private IEnumerator PrepareAudioAndEnableButton()
|
||
{
|
||
isAudioReady = false;
|
||
if (audioSource == null || audioSource.clip == null)
|
||
{
|
||
Debug.LogWarning("[delayTapperPrefab] 无音频源或片段");
|
||
isAudioReady = true;
|
||
yield break;
|
||
}
|
||
|
||
// 如果音频还未加载,则手动触发加载
|
||
if (audioSource.clip.loadState != AudioDataLoadState.Loaded)
|
||
{
|
||
Debug.Log($"[delayTapperPrefab] 正在加载音频: {audioSource.clip.name}...");
|
||
audioSource.clip.LoadAudioData();
|
||
|
||
// 等待加载完成
|
||
while (audioSource.clip.loadState == AudioDataLoadState.Loading)
|
||
{
|
||
yield return null;
|
||
}
|
||
|
||
if (audioSource.clip.loadState != AudioDataLoadState.Loaded)
|
||
{
|
||
Debug.LogError($"[delayTapperPrefab] 音频加载失败! 状态: {audioSource.clip.loadState}");
|
||
}
|
||
else
|
||
{
|
||
Debug.Log("[delayTapperPrefab] 音频加载成功");
|
||
isAudioReady = true;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
Debug.Log("[delayTapperPrefab] 音频已在内存中");
|
||
isAudioReady = true;
|
||
}
|
||
}
|
||
|
||
private void OnInputFieldSelected()
|
||
{
|
||
if (isInternalSetting) return;
|
||
Debug.Log("[delayTapperPrefab] InputField 被选中,停止播放并重置");
|
||
StopAndResetTapper();
|
||
}
|
||
|
||
private void OnInputFieldChanged(string val)
|
||
{
|
||
if (isInternalSetting) return;
|
||
Debug.Log("[delayTapperPrefab] InputField 内容改变,停止播放并重置");
|
||
StopAndResetTapper();
|
||
}
|
||
|
||
public void StartTapperFromInput()
|
||
{
|
||
Debug.Log("[delayTapperPrefab] StartTapperFromInput 按钮被点击");
|
||
|
||
// 0. 点击后立刻取消按钮焦点,防止空格触发重复点击
|
||
if (EventSystem.current != null)
|
||
{
|
||
EventSystem.current.SetSelectedGameObject(null);
|
||
}
|
||
|
||
if (!isAudioReady)
|
||
{
|
||
gNotice.warning.display("音频未就绪");
|
||
return;
|
||
}
|
||
|
||
// 强制锁定 BPM 为 120
|
||
BPM = 120;
|
||
if (bpmInputField != null)
|
||
{
|
||
isInternalSetting = true;
|
||
bpmInputField.text = "120";
|
||
isInternalSetting = false;
|
||
}
|
||
|
||
// 点击启动按钮时,手动清理一次旧数据
|
||
ClearTapperHandles();
|
||
|
||
RunTapperProcess();
|
||
StartAudioPlayback();
|
||
}
|
||
|
||
private void StartAudioPlayback()
|
||
{
|
||
if (audioSource == null || audioSource.clip == null)
|
||
{
|
||
Debug.LogWarning("[delayTapperPrefab] AudioSource 或 AudioClip 未分配,跳过播放");
|
||
return;
|
||
}
|
||
|
||
// 停止当前可能正在运行的播放协程
|
||
if (audioCoroutine != null) StopCoroutine(audioCoroutine);
|
||
|
||
// 强制停止当前音频并直接跳转到指定时间,确保没有重音或残留播放
|
||
audioSource.Stop();
|
||
float start = Mathf.Clamp(musicStartTime, 0f, audioSource.clip.length);
|
||
audioSource.time = start;
|
||
|
||
audioCoroutine = StartCoroutine(AudioPlaybackRoutine());
|
||
}
|
||
|
||
private void Update()
|
||
{
|
||
// 如果暂停,不执行任何 Update 逻辑
|
||
if (isPaused) return;
|
||
|
||
// 改进监听逻辑:只要 Slider 正在滚动,就允许记录空格。
|
||
// 用 Input System 的 Keyboard.current 捕获,并在按下的同一帧读取 AudioSettings.dspTime,
|
||
// 与游戏内判定同源(dspTime),消除 Time.deltaTime 帧量化误差。
|
||
if (sliderCoroutine != null && Keyboard.current != null && Keyboard.current.spaceKey.wasPressedThisFrame)
|
||
{
|
||
SpawnTapperHandle(AudioSettings.dspTime);
|
||
}
|
||
}
|
||
|
||
// 由 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. 用按键时刻的 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(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. 用中位数 + 去异常值(MAD)计算稳健延迟,取代旧的累计平均(易被漏拍/误触拉偏、随次数稀释)。
|
||
int finalDelayMs = Mathf.RoundToInt(ComputeRobustDelaySeconds() * 1000f);
|
||
|
||
// 4. 输出到 delayAmountNow
|
||
if (delayAmountNow != null)
|
||
{
|
||
isInternalSetting = true;
|
||
delayAmountNow.text = finalDelayMs.ToString();
|
||
isInternalSetting = false;
|
||
}
|
||
|
||
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;
|
||
|
||
// 获取 Slider 的 Handle
|
||
RectTransform handle = runSlider.handleRect;
|
||
if (handle == null) return;
|
||
|
||
// 实例化到指定父物体下
|
||
GameObject newHandle = Instantiate(tapperHandlePrefab, tapperHandlePrefab_toput.transform);
|
||
|
||
// 同步 Handle 的位置
|
||
newHandle.transform.position = handle.position;
|
||
newHandle.transform.rotation = handle.rotation;
|
||
|
||
// 播放点击音效
|
||
if (hit_sfx_as != null)
|
||
{
|
||
hit_sfx_as.Play();
|
||
}
|
||
}
|
||
|
||
/// <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 和累计数据");
|
||
if (tapperHandlePrefab_toput == null) return;
|
||
|
||
int childCount = tapperHandlePrefab_toput.transform.childCount;
|
||
for (int i = childCount - 1; i >= 0; i--)
|
||
{
|
||
Destroy(tapperHandlePrefab_toput.transform.GetChild(i).gameObject);
|
||
}
|
||
|
||
// 重置所有采样数据
|
||
tapValueOffsets.Clear();
|
||
rawOffsetSamples.Clear();
|
||
|
||
Debug.Log("[delayTapperPrefab] 已手动清理 TapperHandles 和所有采样数据");
|
||
}
|
||
|
||
private IEnumerator AudioPlaybackRoutine()
|
||
{
|
||
// 再次确保开始时间不超过结束时间且在音频长度范围内
|
||
float start = Mathf.Clamp(musicStartTime, 0f, audioSource.clip.length);
|
||
float end = Mathf.Clamp(musicEndTime, start, audioSource.clip.length);
|
||
|
||
if (end <= start)
|
||
{
|
||
Debug.LogWarning("[delayTapperPrefab] 音乐结束时间小于或等于开始时间,无法播放");
|
||
yield break;
|
||
}
|
||
|
||
// 此时直接开始播放即可,因为在 StartAudioPlayback 中已经跳转过时间
|
||
audioSource.Play();
|
||
Debug.Log($"[delayTapperPrefab] 音频开始播放(跳转):{start}s -> {end}s");
|
||
|
||
while (audioSource.isPlaying && audioSource.time < end)
|
||
{
|
||
yield return null;
|
||
}
|
||
|
||
audioSource.Stop();
|
||
Debug.Log("[delayTapperPrefab] 音频播放结束,停止 Slider 并清理数据");
|
||
|
||
// 重置运行状态和按钮文本
|
||
isRunning = false;
|
||
isPaused = false;
|
||
UpdateStartButtonText();
|
||
|
||
// 音频停止时,同时停止并重置 Slider
|
||
StopAndResetTapper();
|
||
|
||
// 清理所有数据和 Prefab
|
||
ClearTapperHandles();
|
||
}
|
||
|
||
public void StopAndResetTapper()
|
||
{
|
||
if (sliderCoroutine != null)
|
||
{
|
||
Debug.Log("[delayTapperPrefab] 停止当前滚动协程");
|
||
StopCoroutine(sliderCoroutine);
|
||
sliderCoroutine = null;
|
||
}
|
||
|
||
if (audioCoroutine != null)
|
||
{
|
||
StopCoroutine(audioCoroutine);
|
||
audioCoroutine = null;
|
||
}
|
||
|
||
if (audioSource != null && audioSource.isPlaying)
|
||
{
|
||
audioSource.Stop();
|
||
}
|
||
|
||
if (runSlider != null)
|
||
{
|
||
runSlider.value = 0f;
|
||
}
|
||
}
|
||
|
||
public void RunTapperProcess()
|
||
{
|
||
Debug.Log("[delayTapperPrefab] RunTapperProcess 准备启动协程");
|
||
if (sliderCoroutine != null) StopCoroutine(sliderCoroutine);
|
||
sliderCoroutine = StartCoroutine(RunTapperProcessCoroutine());
|
||
}
|
||
|
||
private IEnumerator RunTapperProcessCoroutine()
|
||
{
|
||
if (runSlider == null)
|
||
{
|
||
Debug.LogError("[delayTapperPrefab] runSlider 为空,无法开始滚动!");
|
||
yield break;
|
||
}
|
||
|
||
// 强制确保 Slider 的范围是 0 到 1
|
||
runSlider.minValue = 0f;
|
||
runSlider.maxValue = 1f;
|
||
|
||
while (true)
|
||
{
|
||
// 每次循环开始时清理视觉 Prefab (如果不是第一次启动)
|
||
// 注意:这里我们只清理生成的 Handle,不重置数据,因为数据是跨循环累计的
|
||
ClearVisualHandlesOnly();
|
||
|
||
// 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 = ElapsedFromDsp();
|
||
runSlider.value = Mathf.Clamp01(currentElapsed / cycleDuration);
|
||
}
|
||
yield return null;
|
||
}
|
||
|
||
currentElapsed = 0f;
|
||
runSlider.value = 0f;
|
||
}
|
||
}
|
||
|
||
private void ClearVisualHandlesOnly()
|
||
{
|
||
if (tapperHandlePrefab_toput == null) return;
|
||
int childCount = tapperHandlePrefab_toput.transform.childCount;
|
||
for (int i = childCount - 1; i >= 0; i--)
|
||
{
|
||
Destroy(tapperHandlePrefab_toput.transform.GetChild(i).gameObject);
|
||
}
|
||
}
|
||
|
||
public void on_close_img()
|
||
{
|
||
gNotice.recommendation.display("打开了!但是没有完全打开!", 2f, 2f);
|
||
}
|
||
}
|