using System; using System.Collections; using System.Collections.Generic; using System.Linq; using DG.Tweening; using Dreamteck; using Dreamteck.Splines; using Dreamteck.Splines.Primitives; using ichni.RhythmGame; using Ichni; using Ichni.Editor; using Ichni.RhythmGame; using Ichni.RhythmGame.Beatmap; using TMPro; using UniRx; using Unity.VisualScripting; // [修复] 统一命名空间引用 using UnityEngine; using UnityEngine.InputSystem; namespace ichni.RhythmGame // [修复] 修正命名空间首字母大写,符合C#规范 { public partial class FastNoteTracker : GameElement, IBeChangeInExport { public Track TrackedTrack { get => parentElement as Track; } private bool _isEnabled = false; public float horizonWidth = 5f; public bool IsEnabled { get => _isEnabled; set { _isEnabled = value; Refresh(); } } BaseElement_BM IBeChangeInExport.MatchingExportElement { get => null; set { } } public List BeatLines = new List(); public List Beats = new List(); public override void Refresh() { base.Refresh(); UpdateBeatLine(); } public int BeatDiver = 1; // [建议] 变量名修正为 BeatDiver (原本是 Beatdiver) private LineRenderer selectedLine = null; // 工具方法:计算线条的两个端点位置 private void CalculateLinePositions(SplineSample sample, out Vector3 pos0, out Vector3 pos1) { Vector3 sideOffset = sample.rotation * Vector3.left * horizonWidth; pos0 = sample.position + sideOffset; pos1 = sample.position - sideOffset; } // 工具方法:配置 LineRenderer 的基础属性 private void SetupLineRenderer(LineRenderer line, Vector3 pos0, Vector3 pos1, Color color, bool refreshWidth = true) { line.useWorldSpace = true; line.positionCount = 2; line.SetPosition(0, pos0); line.SetPosition(1, pos1); if (refreshWidth) { line.startWidth = 0.05f; line.endWidth = 0.05f; } line.material = EditorManager.instance.basePrefabs.defaultTrailMaterial; line.startColor = line.endColor = color; } private void RefreshMeshCollider(LineRenderer lineA, LineRenderer lineB) { MeshCollider col = lineA.GetComponent(); if (col == null) col = lineA.gameObject.AddComponent(); Vector3 a1 = lineA.GetPosition(0); Vector3 a2 = lineA.GetPosition(1); Vector3 b1 = lineB.GetPosition(0); Vector3 b2 = lineB.GetPosition(1); if (Vector3.Distance(a1, b1) < 0.001f) { if (col.sharedMesh != null) col.sharedMesh.Clear(); return; } // 转换为局部坐标 Vector3[] newVertices = new Vector3[] { lineA.transform.InverseTransformPoint(a1), lineA.transform.InverseTransformPoint(a2), lineA.transform.InverseTransformPoint(b1), lineA.transform.InverseTransformPoint(b2) }; Mesh mesh = col.sharedMesh; if (mesh == null) { mesh = new Mesh(); mesh.name = "BeatLineMesh"; } else { // [关键优化]:如果新旧顶点差异极小,则不刷新 Mesh,防止物理系统抖动 if (mesh.vertexCount == 4 && Vector3.Distance(mesh.vertices[0], newVertices[0]) < 0.0001f) { return; } mesh.Clear(); } mesh.vertices = newVertices; mesh.triangles = new int[] { 0, 1, 2, 2, 1, 3, 2, 1, 0, 3, 1, 2 }; mesh.RecalculateNormals(); mesh.RecalculateBounds(); col.sharedMesh = mesh; // 确保 Tag 正确 if (!lineA.CompareTag("LineRenderer")) lineA.tag = "LineRenderer"; } public void UpdateBeatLine() { // 1. 清理 foreach (var line in BeatLines) if (line != null) Destroy(line.gameObject); BeatLines.Clear(); Beats.Clear(); if (!_isEnabled) return; // 获取数据源 SplineComputer splineComputer = TrackedTrack.trackPathSubmodule.path; var trackTime = TrackedTrack.trackTimeSubmodule as TrackTimeSubmoduleMovable; float beatStart = EditorManager.instance.songInformation.beatManager.GetBeatFromTime(trackTime.trackStartTime); float beatEnd = EditorManager.instance.songInformation.beatManager.GetBeatFromTime(trackTime.trackEndTime); // 2. 批量生成 for (float b = beatStart - 1; b <= beatEnd + 1f / BeatDiver; b += 1f / BeatDiver) { float timeAtBeat = EditorManager.instance.songInformation.beatManager.GetTimeFromBeat(b); float trackPercent = Mathf.Clamp01(trackTime.GetTrackPercentRaw(timeAtBeat)); CalculateLinePositions(splineComputer.Evaluate(trackPercent), out Vector3 p0, out Vector3 p1); GameObject obj = Instantiate(EditorManager.instance.basePrefabs.emptyObject, this.transform); LineRenderer lr = obj.AddComponent(); Color color = (Mathf.Abs(b - Mathf.Round(b)) <= 0.01f) ? Color.green : Color.cyan; SetupLineRenderer(lr, p0, p1, color); float bi = (trackPercent >= 1f) ? EditorManager.instance.songInformation.beatManager.GetBeatFromTime(trackTime.trackEndTime) : b; Beats.Add(bi < 0 ? 0f : bi); BeatLines.Add(lr); } // 3. 批量生成 Collider for (int i = 0; i < BeatLines.Count - 1; i++) RefreshMeshCollider(BeatLines[i], BeatLines[i + 1]); } public void AdjustBeatLine() { if (!_isEnabled) { foreach (var line in BeatLines) if (line != null) line.gameObject.SetActive(false); return; } SplineComputer splineComputer = TrackedTrack.trackPathSubmodule.path; var trackTime = TrackedTrack.trackTimeSubmodule as TrackTimeSubmoduleMovable; float beatStart = EditorManager.instance.songInformation.beatManager.GetBeatFromTime(trackTime.trackStartTime); float beatEnd = EditorManager.instance.songInformation.beatManager.GetBeatFromTime(trackTime.trackEndTime); int index = 0; // 重置 Beats 列表以匹配新的位置 Beats.Clear(); for (float b = beatStart - 1; b <= beatEnd + 1f / BeatDiver; b += 1f / BeatDiver) { float timeAtBeat = EditorManager.instance.songInformation.beatManager.GetTimeFromBeat(b); float trackPercent = Mathf.Clamp01(trackTime.GetTrackPercentRaw(timeAtBeat)); // 如果超出范围,跳过更新(除非你想一直显示) // 这里沿用你 UpdateBeatLine 的逻辑 CalculateLinePositions(splineComputer.Evaluate(trackPercent), out Vector3 p0, out Vector3 p1); LineRenderer lr; if (index < BeatLines.Count) { lr = BeatLines[index]; lr.gameObject.SetActive(true); } else { GameObject obj = Instantiate(EditorManager.instance.basePrefabs.emptyObject, this.transform); lr = obj.AddComponent(); BeatLines.Add(lr); } Color color = (Mathf.Abs(b - Mathf.Round(b)) <= 0.01f) ? Color.green : Color.cyan; SetupLineRenderer(lr, p0, p1, color, false); float bi = (trackPercent >= 1f) ? EditorManager.instance.songInformation.beatManager.GetBeatFromTime(trackTime.trackEndTime) : b; Beats.Add(bi < 0 ? 0f : bi); index++; } for (int i = index; i < BeatLines.Count; i++) BeatLines[i].gameObject.SetActive(false); for (int i = 0; i < index - 1; i++) RefreshMeshCollider(BeatLines[i], BeatLines[i + 1]); } public bool ForceRefresh = false; void Update() { if (ForceRefresh) { AdjustBeatLine(); } if (InputListener.instance.isPointerOverUI) return; CastRay(); if (IsEnabled && selectedLine != null) { DetectNote(); } } public void CastRay() { if (EditorManager.instance.cameraManager.currentCamera == null) return; Ray ray = EditorManager.instance.cameraManager.currentCamera.ScreenPointToRay(Mouse.current.position.ReadValue()); RaycastHit hit; // Debug.DrawRay(ray.origin, ray.direction * 100f, Color.red); if (Physics.RaycastAll(ray).FirstOrDefault(h => h.collider.CompareTag("LineRenderer")).collider != null) { hit = Physics.RaycastAll(ray).First(h => h.collider.CompareTag("LineRenderer")); LineRenderer hoveredLine = hit.collider.GetComponent(); if (BeatLines.Contains(hoveredLine)) { if (Mouse.current.leftButton.wasPressedThisFrame) { Debug.Log($"Clicked on line area: {hoveredLine.gameObject.name} at {hit.point}"); } if (selectedLine != hoveredLine) { if (selectedLine != null) { selectedLine.startWidth = 0.05f; selectedLine.endWidth = 0.05f; } selectedLine = hoveredLine; selectedLine.startWidth = 0.15f; selectedLine.endWidth = 0.15f; } } } else { if (selectedLine != null) { selectedLine.startWidth = 0.05f; selectedLine.endWidth = 0.05f; selectedLine = null; } } } public void InputDetected() { // if(Keyboard) } public void DetectNote() { if (Keyboard.current.digit1Key.wasPressedThisFrame) AddNote(0); else if (Keyboard.current.digit2Key.wasPressedThisFrame) AddNote(1); else if (Keyboard.current.digit3Key.wasPressedThisFrame) AddNote(2); else if (Keyboard.current.digit4Key.wasPressedThisFrame) AddNote(3); } public void AddNote(int NoteCode) { if (!EditorManager.instance.useNotePrefab) { LogWindow.Log("Please enable \"Note Prefab\" in EditorManager", Color.red); return; } if (selectedLine == null) return; float time = EditorManager.instance.songInformation.beatManager.GetTimeFromBeat( Beats[BeatLines.IndexOf(selectedLine)] ); NoteBase a = null; switch (NoteCode) { case 0: a = Tap.GenerateElement("New Tap", Guid.NewGuid(), new List(), true, TrackedTrack, time); a.noteVisual.transformSubmodule.Refresh(); break; case 3: a = Hold.GenerateElement("New Hold", Guid.NewGuid(), new List(), true, TrackedTrack, time, time + 0.5f); a.noteVisual.transformSubmodule.Refresh(); Observable.NextFrame().Subscribe(_ => { StartCoroutine(DraggingHold((Hold)a)); }); break; case 1: a = Stay.GenerateElement("New Stay", Guid.NewGuid(), new List(), true, TrackedTrack, time); a.noteVisual.transformSubmodule.Refresh(); break; case 2: a = Flick.GenerateElement("New Flick", Guid.NewGuid(), new List(), true, TrackedTrack, time, new List()); a.noteVisual.transformSubmodule.Refresh(); break; default: break; } Observable.NextFrame().Subscribe(_ => { CreateTextHint(a); }); } private IEnumerator DraggingHold(Hold hold) { GameObject obj = Instantiate(EditorManager.instance.basePrefabs.emptyObject, this.transform); LineRenderer lr = obj.AddComponent(); lr.startWidth = lr.endWidth = 0.05f; lr.positionCount = 2; lr.material = EditorManager.instance.basePrefabs.defaultTrailMaterial; lr.startColor = lr.endColor = Color.yellow; lr.SetPosition(0, hold.noteVisual.transform.position); lr.SetPosition(1, hold.noteVisual.transform.position); while (Keyboard.current.digit4Key.isPressed && selectedLine != null) { yield return null; try { float time = EditorManager.instance.songInformation.beatManager.GetTimeFromBeat( Beats[BeatLines.IndexOf(selectedLine)] ); hold.holdEndTime = time < hold.exactJudgeTime ? hold.exactJudgeTime + 0.1f : time; hold.noteVisual.transformSubmodule.Refresh(); lr.SetPosition(1, (selectedLine.GetPosition(0) + selectedLine.GetPosition(1)) / 2); } catch (Exception e) { Debug.LogWarning(e); break; } } yield return null; Destroy(lr.gameObject); CreateTextHint(hold); } private void CreateTextHint(NoteBase noteBase) { // 1. 创建一个新的 GameObject string content = noteBase.elementName; Vector3 worldPos = noteBase.noteVisual.transform.position; GameObject hintObj = new GameObject("NoteHint_" + content); hintObj.transform.position = worldPos; // 2. 添加 TextMeshPro 组件 (注意是 TextMeshPro,不是 TextMeshProUGUI) // 因为我们在 3D 空间生成,所以使用非 UI 版本 TextMeshPro text = hintObj.AddComponent(); // 3. 配置文字属性 text.text = content; text.fontSize = 6; // 根据你的缩放调整大小 text.alignment = TextAlignmentOptions.Center; text.color = Color.yellow; // 4. (可选) 设置渲染层级,确保它在轨道上方显示 // text.sortingOrder = 100; // 5. 动画效果:向上移动 1 个单位,并同时淡出 // 1 秒后执行 hintObj.transform.DOScale(Vector3.one * 1.2f, 1f).SetEase(Ease.OutBack).From(Vector3.zero); hintObj.transform.DOMoveY(worldPos.y + 1f, 1f); text.DOFade(0, 1f).OnComplete(() => Destroy(hintObj)); // 7. 让文字始终面向相机 (Billboard 效果) // 如果你的相机视角是固定的,可以直接设置旋转 hintObj.transform.LookAt(hintObj.transform.position + Camera.main.transform.rotation * Vector3.forward, EditorManager.instance.cameraManager.currentCamera.transform.rotation * Vector3.up); } } // 后面的 Inspector 和 Export 代码逻辑暂时不需要大改,只要注意变量名 Beatdiver -> BeatDiver public partial class FastNoteTracker { public BaseElement_BM MatchingExportElement { get => null; set => throw new System.NotImplementedException(); } public void SaveExportBM() { } public override void SetUpInspector() { base.SetUpInspector(); IHaveInspection inspector = EditorManager.instance.uiManager.inspector; var container = inspector.GenerateContainer("Fast Note Tracker"); var sub = container.GenerateSubcontainer(2); inspector.GenerateToggle(this, sub, "Enabled", nameof(IsEnabled)); // 修正变量名 inspector.GenerateInputField(this, sub, "Beat Diver", nameof(BeatDiver)); inspector.GenerateToggle(this, sub, "Force refresh (cost++)", nameof(ForceRefresh)); inspector.GenerateInputField(this, sub, "Horizon Width", nameof(horizonWidth)); } public static FastNoteTracker GenerateElement(string elementName, Guid id, List tags, bool isFirstGenerated, GameElement parentElement) { FastNoteTracker fastNoteTracker = Instantiate(EditorManager.instance.basePrefabs.emptyObject, parentElement.transform) .AddComponent(); if (parentElement is not Track) { LogWindow.Log("FastNoteTracker must be a child of Track element."); return null; } fastNoteTracker.Initialize(elementName, id, tags, isFirstGenerated, parentElement); return fastNoteTracker; } public override void Initialize(string elementName, Guid id, List tags, bool isFirstGenerated, GameElement parentElement) { base.Initialize(elementName, id, tags, isFirstGenerated, parentElement); //parentElement.refreshAction += AdjustBeatLine; } public override void SaveBM() { matchedBM = new FastNoteTracker_BM(elementName, elementGuid, tags, parentElement.elementGuid); } } } namespace Ichni.RhythmGame.Beatmap { public class FastNoteTracker_BM : GameElement_BM { public FastNoteTracker_BM(string elementName, Guid id, List tags, Guid attachedElementGuid) { this.elementName = elementName; this.elementGuid = id; this.tags = tags; this.attachedElementGuid = attachedElementGuid; } public override GameElement DuplicateBM(GameElement attached) { return FastNoteTracker.GenerateElement(elementName, elementGuid, tags, false, attached); } public override void ExecuteBM() { FastNoteTracker.GenerateElement(elementName, elementGuid, tags, false, GetElement(attachedElementGuid)); } } }