备份,准备做双端

This commit is contained in:
2026-07-30 23:15:58 +08:00
parent add675e45d
commit ec4ec53545
88 changed files with 4050 additions and 872 deletions
+333 -98
View File
@@ -42,8 +42,14 @@ public class HoldNote : BaseNote
private string queueNoteId = null;
private bool isQueued = false;
// Cached instance-id string used by NotePool to unregister without re-allocating.
// May be null if this segment never registered a queue id (e.g. non-start segment).
public string GetQueueNoteId() => queueNoteId;
public int id;
public int trackIndex;
// Reused by EvaluateHoldEnd's skill-trigger dispatch to avoid a per-hold-end List<int> allocation.
private readonly List<int> _holdEndCandidatesBuffer = new List<int>(4);
public float speed;
public float startTime;
public float delay;
@@ -65,6 +71,40 @@ public class HoldNote : BaseNote
// Input.GetKeyDown/Up directly (which touch cannot drive on Android).
private bool prevTrackHeld = false;
// Latest press/release dspTime captured from the Input System (via InputManager events)
// for this note's track. When the polled keyDown/keyUp edge fires this frame, we convert
// the captured dspTime → song time for sub-frame-accurate timing instead of reading the
// frame-quantized NowSongTime. Falls back to NowSongTime if no event fired (e.g. touch
// paths that don't carry a distinct timestamp still work via the AudioSettings.dspTime default).
private double lastPressDspTime = double.NaN;
private double lastReleaseDspTime = double.NaN;
private void OnTrackPressedDsp(int track, double dspTime)
{
if (track == trackIndex) lastPressDspTime = dspTime;
}
private void OnTrackReleasedDsp(int track, double dspTime)
{
if (track == trackIndex) lastReleaseDspTime = dspTime;
}
// Convert the most-recent captured press dspTime to song time; fall back to NowSongTime.
private float PressSongTime()
{
return double.IsNaN(lastPressDspTime)
? GameplayClock.NowSongTime
: GameplayClock.SongTimeFromDsp(lastPressDspTime);
}
// Convert the most-recent captured release dspTime to song time; fall back to NowSongTime.
private float ReleaseSongTime()
{
return double.IsNaN(lastReleaseDspTime)
? GameplayClock.NowSongTime
: GameplayClock.SongTimeFromDsp(lastReleaseDspTime);
}
// Query the shared held-state table (keyboard OR touch). Falls back to legacy
// Input.GetKey if the InputManager is somehow absent so editor/standalone still works.
private bool IsHeld()
@@ -96,6 +136,26 @@ public class HoldNote : BaseNote
// track when the hold actually started (real-time) so we can compute held fraction
private float holdStartTime = -1f;
// ==== 单条 Tiled 长音符 body(纯视觉,不参与任何判定/计分) ====
// 原本中段由 N 个 middle 段密铺组成;本 body 用一条 Tiled sprite 覆盖 head→end 的整段,
// 按绝对时间下落,底边在判定线处被"消耗"(drain)end 到线时回收。
// 它绝不注册 JudgeManager/TrackKeyManager 状态,start/end 判定路径完全不受影响。
// 抗失真关键:Tiled 会按 sprite 原生像素在"局部空间"平铺,再乘 transform 世界缩放;
// prefab 里烘焙的非等比 Y 缩放(如 3.75)会把每块拉长导致失真。故 body 实例把渲染器
// transform 的 Y 缩放归一为与 X 相同(等比),长度改由 SpriteRenderer.size.y 承载(世界单位)。
private bool bodyMode = false;
private bool bodyReturning = false;
private float bodyTravelTime;
private float bodyHeadActivationTime; // head 发车的绝对歌曲时间(= baseHit - travelTime)
private float bodyLengthSeconds; // 长音符时长(秒)
private float bodySpeed; // 世界速度(单位/秒)
private float bodySpawnX, bodySpawnZ; // 车道原点(x,z 固定)
private float bodySpawnY; // 发车高度(head 在 activation 时刻的 Y)
private SpriteRenderer[] bodyRenderers;
private SpriteDrawMode[] bodyRendererOrigDrawMode;
private Vector2[] bodyRendererOrigSize;
private Vector3[] bodyRendererOrigLocalScale;
// Cache allies per track to avoid GameObject.Find on first judgement.
private static AllyCombatant[] allyCache;
private static BeatmapManager beatmapManagerCache;
@@ -335,6 +395,8 @@ public class HoldNote : BaseNote
private void OnDisable()
{
prevTrackHeld = false;
InputManager.OnTrackPressedWithDspTime -= OnTrackPressedDsp;
InputManager.OnTrackReleasedWithDspTime -= OnTrackReleasedDsp;
}
private void OnDestroy()
@@ -360,6 +422,8 @@ public class HoldNote : BaseNote
private void OnEnable()
{
ResetState();
InputManager.OnTrackPressedWithDspTime += OnTrackPressedDsp;
InputManager.OnTrackReleasedWithDspTime += OnTrackReleasedDsp;
}
private void PlayHitAnimation()
@@ -459,7 +523,7 @@ public class HoldNote : BaseNote
}
else
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"HoldNoteJudgeConfig: perfect={judgeConfig.perfectRange}, great={judgeConfig.greatRange}, good={judgeConfig.goodRange}, miss={judgeConfig.missRange}");
if (JudgeManager.IsDebugEnabled) Debug.Log($"HoldNoteJudgeConfig: perfectE={judgeConfig.perfectEarlyRange} perfectL={judgeConfig.perfectLateRange}, greatE={judgeConfig.greatEarlyRange} greatL={judgeConfig.greatLateRange}, goodE={judgeConfig.goodEarlyRange} goodL={judgeConfig.goodLateRange}, miss={judgeConfig.missRange}");
}
this.noteData = noteData;
@@ -508,6 +572,13 @@ public class HoldNote : BaseNote
private void Update()
{
// 纯视觉 body:只更新位置/尺寸(drain),不进入任何判定逻辑。
if (bodyMode)
{
UpdateBodyVisual(GameplayClock.NowSongTime);
return;
}
if (isJudged) return;
var jm = cachedJudgeManager;
@@ -525,14 +596,25 @@ public class HoldNote : BaseNote
bool keyUp = !keyHeld && prevTrackHeld;
prevTrackHeld = keyHeld;
// Optimization: Early exit if the note is far from judgment line and hasn't entered yet
if (!hasEnteredLine)
{
// For all segments, if they haven't triggered collision yet and are far away, skip input checks
// We use travelTime as a safe buffer. Only check input if note is within 0.5s of hitTime.
if (now < hitTime - 0.5f) return;
}
// 池化泄漏兜底:不依赖 hasEnteredLine / 几何进入离开事件。
// 正常情况下 middle/start/end 由 HandleZoneEnter/Exit(几何驱动)或第 556 行
// 正常情况下 middle/start/end 由 HandleZoneEnter/Exit(几何驱动)或下方
// in-zone auto-miss 负责回收;但当几何阈值未就绪、或帧尖峰导致音符 Y 未被采样到判定区内时,
// 上述事件可能永不触发,音符会一直下落不回池,严重拖累性能(尤其 useMultiSegmentFill 使
// 中段数量随速度倍增)。此兜底在"正常判定窗口 + backstopBuffer"之后才触发,严格晚于所有正常
// 路径,因此对正常音符零行为改变——仅回收那些几何事件从未触发、否则永远泄漏的段。
// 【性能】本 block 放在上方"远距离早退"之后:兜底触发要求 now > hitTime(或 scheduledEndTime)
// + missRange + 0.5,而早退只在 now < hitTime - 0.5 时发生,两条件互斥,故早退帧本就不会命中
// 任何兜底分支。移到早退后仅避免对数百个"离判定线尚远"的段每帧空算,行为与之前完全等效。
{
float missRangeBackstop = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier;
float missRangeBackstop = (judgeConfig != null ? judgeConfig.missRange : 0.2f) * holdWindowMultiplier;
const float backstopBuffer = 0.5f;
bool startJudgedBs = jm != null && jm.IsStartJudged(noteID);
@@ -541,9 +623,9 @@ public class HoldNote : BaseNote
if (scheduledEndTime > 0f && now > scheduledEndTime + missRangeBackstop + backstopBuffer)
{
// 兜底触发时音符早已越过判定线,几何仅是漏采样。置 hasEnteredLine 让 ReturnToPool
// 的 Start 守卫(第 1519 行,拒收未入线 Start)不再阻挡回收。
// 的 Start 守卫(拒收未入线 Start)不再阻挡回收。
hasEnteredLine = true;
// 已按住到尾部的 End 早已在第 677 行按 Perfect 自动完成并 return;
// 已按住到尾部的 End 早已在下方按 Perfect 自动完成并 return;
// 走到这里说明几何事件从未触发。按原 HandleZoneExit(End) 的收尾语义处理。
bool releasedBs = jm != null && jm.HasNoteReleased(noteID);
if (!startJudgedBs)
@@ -562,10 +644,10 @@ public class HoldNote : BaseNote
else if (hitTime > 0f && now > hitTime + missRangeBackstop + backstopBuffer)
{
// 兜底触发时音符早已越过判定线,几何仅是漏采样。置 hasEnteredLine 让 ReturnToPool
// 的 Start 守卫(第 1519 行,拒收未入线 Start)不再阻挡回收。
// 的 Start 守卫(拒收未入线 Start)不再阻挡回收。
hasEnteredLine = true;
// Start 未判定 → 走正常 Miss(与第 569 行一致);已判定的 start / middle → 静默回收
// (与第 579-581 行、第 883 行一致,绝不 StopHoldParticles 杀掉长按持续特效)。
// Start 未判定 → 走正常 Miss;已判定的 start / middle → 静默回收
// (绝不 StopHoldParticles 杀掉长按持续特效)。
if (segment == NoteSegment.Start && !startJudgedBs)
{
EvaluateHoldEnd(now, true);
@@ -576,14 +658,6 @@ public class HoldNote : BaseNote
}
}
// Optimization: Early exit if the note is far from judgment line and hasn't entered yet
if (!hasEnteredLine)
{
// For all segments, if they haven't triggered collision yet and are far away, skip input checks
// We use travelTime as a safe buffer. Only check input if note is within 0.5s of hitTime.
if (now < hitTime - 0.5f) return;
}
if (GameConfig.autoPlayEnabled)
{
UpdateAutoplay(now, jm, tkm, debugEnabled);
@@ -606,7 +680,7 @@ public class HoldNote : BaseNote
// Auto-miss checks when inside judge line
if (hasEnteredLine)
{
float missRangeScaled = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier;
float missRangeScaled = (judgeConfig != null ? judgeConfig.missRange : 0.2f) * holdWindowMultiplier;
// 只对"尚未判定"的 start 段做超时 auto-Miss。已成功判定的 start 段(玩家已按下、
// 正持续按住)绝不能在此被误判 Miss——否则 EvaluateHoldEnd(forceMiss) 会调 StopHoldParticles
// 把长按持续特效杀掉。物理版时 collider 早已离开判定区不会走到这里;几何版 exit 更晚才暴露此隐患。
@@ -653,7 +727,7 @@ public class HoldNote : BaseNote
if (!hasReleased)
{
hasReleased = true;
releaseTime = now;
releaseTime = ReleaseSongTime();
jm?.RegisterNoteReleased(noteID, true);
if (debugEnabled) Debug.Log($"[HoldNote] RegisterNoteReleased on KeyUp. NoteID: {noteID}, releaseTime={releaseTime:F2}");
@@ -679,8 +753,10 @@ public class HoldNote : BaseNote
if (debugEnabled)
Debug.Log($"[HoldNote] KeyDown detected for {noteID} key={keyToPress} time={now:F3} hitTime={hitTime:F3} hasEnteredLine={hasEnteredLine}");
float pressTimeLocal = now;
float maxWindow = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier;
float pressTimeLocal = PressSongTime();
float maxWindow = (judgeConfig != null
? Mathf.Max(judgeConfig.goodEarlyRange, judgeConfig.goodLateRange)
: 0.3f) * holdWindowMultiplier;
// Only consume the frame if this hold start is actually within the timing window.
if (Mathf.Abs(pressTimeLocal - hitTime) > maxWindow)
@@ -776,7 +852,9 @@ public class HoldNote : BaseNote
}
float pressTimeLocal = hitTime; // force Perfect regardless of frame timing
float maxWindow = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier;
float maxWindow = (judgeConfig != null
? Mathf.Max(judgeConfig.goodEarlyRange, judgeConfig.goodLateRange)
: 0.3f) * holdWindowMultiplier;
if (tkm != null && !tkm.IsBestCandidate(trackIndex, queueNoteId, pressTimeLocal, maxWindow))
{
@@ -1053,17 +1131,22 @@ public class HoldNote : BaseNote
return;
}
float pressTime = GameplayClock.NowSongTime;
float pressTime = PressSongTime();
float rawOffsetMs = (hitTime - pressTime) * 1000f;
float offset = Mathf.Abs(pressTime - hitTime);
float offset = pressTime - hitTime; // positive = late, negative = early
string result;
float visualScaleFactor = Mathf.Clamp(visualSpeedMultiplier, 0.5f, 2f);
float pRange = (judgeConfig?.perfectRange ?? 0.1f) * holdWindowMultiplier * visualScaleFactor;
float gRange = (judgeConfig?.greatRange ?? 0.2f) * holdWindowMultiplier * visualScaleFactor;
float gdRange = (judgeConfig?.goodRange ?? 0.3f) * holdWindowMultiplier * visualScaleFactor;
float windowScale = holdWindowMultiplier * visualScaleFactor;
if (offset <= pRange)
float perfectEarly = (judgeConfig?.perfectEarlyRange ?? 0.1f) * windowScale;
float perfectLate = (judgeConfig?.perfectLateRange ?? 0.1f) * windowScale;
float greatEarly = (judgeConfig?.greatEarlyRange ?? 0.2f) * windowScale;
float greatLate = (judgeConfig?.greatLateRange ?? 0.2f) * windowScale;
float goodEarly = (judgeConfig?.goodEarlyRange ?? 0.3f) * windowScale;
float goodLate = (judgeConfig?.goodLateRange ?? 0.3f) * windowScale;
if (offset >= -perfectEarly && offset <= perfectLate)
{
result = "Perfect";
// Score statistics for Hold notes are now handled exclusively in EvaluateHoldEnd
@@ -1082,7 +1165,7 @@ public class HoldNote : BaseNote
// store into pool for use on end
HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData);
}
else if (offset <= gRange)
else if (offset >= -greatEarly && offset <= greatLate)
{
result = "Great";
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
@@ -1096,7 +1179,7 @@ public class HoldNote : BaseNote
HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData);
}
else if (offset <= gdRange)
else if (offset >= -goodEarly && offset <= goodLate)
{
result = "Good";
if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs;
@@ -1256,83 +1339,38 @@ public class HoldNote : BaseNote
}
else
{
// try to use pool record for accurate press duration
// 松手时刻判定:以玩家松手时间(releaseTime)与预定结束时间(scheduledEndTime)的绝对时间差
// 评级,与头段"按下时刻判定"完全对称,不再依赖按住时长占比或几何进出判定。
// forcePerfect(按住到尾部自动完成)仍直接判 Perfect。
float endScheduled = scheduledEndTime;
if (HoldNoteJudgePool.TryGet(noteID, out var info))
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Found pool info for {noteID}: pressTime={info.pressTime:F3} length={info.length:F3} scheduledEnd={info.scheduledEnd:F3}");
float playerHeld = Mathf.Clamp(releaseTime - info.pressTime, 0f, info.length);
float frac = info.length <= 0f ? 0f : (playerHeld / info.length);
if (forcePerfect || frac > 0.7f)
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++;
}
else if (frac > 0.4f)
{
result = "Great";
ScoreManager.Instance.countGreat += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++;
}
else
{
result = "Good";
ScoreManager.Instance.countGood += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++;
}
if (noteData != null && result != "Miss")
{
float offsetEnd = (info.scheduledEnd - releaseTime) * 1000f;
noteData.judgeOffsetMsEnd = offsetEnd;
// Record the head offset (stored during HandleStart) as the representative timing for this note
float headOffset = noteData.judgeOffsetMs;
if (float.IsNaN(headOffset)) headOffset = 0;
ScoreManager.Instance?.RecordOffset(headOffset);
}
// cleanup pool
endScheduled = info.scheduledEnd;
HoldNoteJudgePool.Unregister(noteID);
}
else
{
if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] No pool info for {noteID}, falling back to local held fraction calculation");
// fallback logic if no pool info
float holdRequired = Mathf.Max(0.0001f, scheduledEndTime - hitTime);
float held = Mathf.Clamp(actualReleaseTime - hitTime, 0f, holdRequired);
float frac = holdRequired <= 0f ? 0f : (held / holdRequired);
if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] No pool info for {noteID}, using field scheduledEndTime for release timing");
}
if (forcePerfect || frac > 0.7f)
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++;
}
else if (frac > 0.4f)
{
result = "Great";
ScoreManager.Instance.countGreat += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++;
}
else
{
result = "Good";
ScoreManager.Instance.countGood += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++;
}
float endOffset = Mathf.Abs(releaseTime - endScheduled);
result = GradeAndCountHoldEndByReleaseTiming(forcePerfect, endOffset);
float rawOffsetMsEnd = (scheduledEndTime - actualReleaseTime) * 1000f;
if (noteData != null && result != "Miss")
{
noteData.judgeOffsetMsEnd = rawOffsetMsEnd;
// Fallback: use head offset if available, otherwise 0
float headOffset = noteData.judgeOffsetMs;
if (float.IsNaN(headOffset)) headOffset = 0;
ScoreManager.Instance?.RecordOffset(headOffset);
}
if (result == "Miss")
{
// 松手时刻严重偏离预定结束点 → 记 Miss(与头段 Miss 语义一致)。
if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] END judged as Miss by release timing: {noteColor} endOffset={endOffset:F3}");
ScoreManager.Instance?.RecordOffset(0);
ReleaseTrackJudgeLockSafe();
}
else if (noteData != null)
{
noteData.judgeOffsetMsEnd = (endScheduled - releaseTime) * 1000f;
// 以头段偏移作为本音符的代表性时序记录(与原逻辑一致,保证每条长音符只记一次 offset)。
float headOffset = noteData.judgeOffsetMs;
if (float.IsNaN(headOffset)) headOffset = 0;
ScoreManager.Instance?.RecordOffset(headOffset);
}
}
@@ -1412,7 +1450,8 @@ public class HoldNote : BaseNote
bool triggeredHold = false;
bool triggeredTap = false;
var candidates = new List<int>();
var candidates = _holdEndCandidatesBuffer;
candidates.Clear();
candidates.Add(this.trackIndex);
if (!candidates.Contains(this.TrackIndex)) candidates.Add(this.TrackIndex);
var ui = teamUIController.Instance;
@@ -1488,6 +1527,50 @@ public class HoldNote : BaseNote
ScheduleReturnToPool(0.2f);
}
// 按"松手时刻偏移"评级并累加对应计数,返回评级字符串。与头段 HandleStart 的判定窗口
// 计算方式一致(perfect/great/good * holdWindowMultiplier * visualScaleFactor),超出 good 窗口判 Miss。
// forcePerfect(按住到尾部自动完成)直接判 Perfect。
private string GradeAndCountHoldEndByReleaseTiming(bool forcePerfect, float endOffset)
{
float visualScaleFactor = Mathf.Clamp(visualSpeedMultiplier, 0.5f, 2f);
float windowScale = holdWindowMultiplier * visualScaleFactor;
float perfectEarly = (judgeConfig?.perfectEarlyRange ?? 0.1f) * windowScale;
float perfectLate = (judgeConfig?.perfectLateRange ?? 0.1f) * windowScale;
float greatEarly = (judgeConfig?.greatEarlyRange ?? 0.2f) * windowScale;
float greatLate = (judgeConfig?.greatLateRange ?? 0.2f) * windowScale;
float goodEarly = (judgeConfig?.goodEarlyRange ?? 0.3f) * windowScale;
float goodLate = (judgeConfig?.goodLateRange ?? 0.3f) * windowScale;
// endOffset = releaseTime - scheduledEndTime, positive = late, negative = early
string result;
if (forcePerfect || (endOffset >= -perfectEarly && endOffset <= perfectLate))
{
result = "Perfect";
ScoreManager.Instance.countPerfect += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++;
}
else if (endOffset >= -greatEarly && endOffset <= greatLate)
{
result = "Great";
ScoreManager.Instance.countGreat += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++;
}
else if (endOffset >= -goodEarly && endOffset <= goodLate)
{
result = "Good";
ScoreManager.Instance.countGood += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++;
}
else
{
result = "Miss";
ScoreManager.Instance.countMiss += 1;
if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackMissCounts[trackIndex]++;
}
return result;
}
private void ApplyJudgedMaterial()
{
if (JudgeManager.Instance == null || !JudgeManager.Instance.enableJudgedMaterial) return;
@@ -1590,6 +1673,23 @@ public class HoldNote : BaseNote
public void ResetState()
{
// 纯视觉 body 回收:还原渲染器到 prefab 原始状态,退出 body 模式,不走判定相关重置。
if (bodyMode || bodyReturning)
{
// body 与 head/end 共享 noteIDOnHoldAlphaSync 会让 body 也 ApplyJudgedMaterial
// (与原中段行为一致)。回收时必须还原材质,否则换过的材质会残留到下次池化复用。
RestoreOriginalMaterials();
RestoreBodyRenderers();
bodyMode = false;
bodyReturning = false;
segment = NoteSegment.None;
noteColor = string.Empty;
noteID = string.Empty;
type = string.Empty;
if (controller != null) controller.DisableVisualBodyMode();
return;
}
// also release any stale lock from previous lifecycle
ReleaseTrackJudgeLockSafe();
@@ -1616,6 +1716,8 @@ public class HoldNote : BaseNote
isHoldActive = false;
hasBeenHeldFromStart = false;
prevTrackHeld = false;
lastPressDspTime = double.NaN;
lastReleaseDspTime = double.NaN;
// 去物理判定:池化复用时重置 controller 的几何判定状态(重新算阈值、清进入标志)。
if (controller != null) controller.ResetJudgeZoneGeometry();
@@ -1660,6 +1762,139 @@ public class HoldNote : BaseNote
}
}
// 以纯视觉 body 身份初始化:一条 Tiled 长条覆盖 head→end 整段,按绝对时间下落,
// end 越过判定线后回收(时机与原分段一致)。不注册任何判定/计分状态。
// baseHit=head 到达判定线的绝对歌曲时间;lengthSeconds=长音符时长;speed=世界速度;
// travelTime=从发车到判定线的时间;spawnPoint=车道原点。
public void SetupBody(int holdNoteId, int trackIndexArg, float speed, float baseHit,
float lengthSeconds, float travelTime, string color, Vector3 spawnPoint)
{
this.id = holdNoteId;
this.noteID = holdNoteId.ToString();
this.trackIndex = trackIndexArg;
this.TrackIndex = trackIndexArg;
this.color = color;
this.noteColor = color;
this.type = "body";
this.segment = NoteSegment.None; // 不参与任何 segment 判定分支
bodyMode = true;
bodyReturning = false;
bodySpeed = Mathf.Max(0f, speed);
bodyLengthSeconds = Mathf.Max(0f, lengthSeconds);
bodyTravelTime = Mathf.Max(0f, travelTime);
bodyHeadActivationTime = baseHit - bodyTravelTime;
bodySpawnX = spawnPoint.x;
bodySpawnZ = spawnPoint.z;
bodySpawnY = spawnPoint.y;
if (controller == null) controller = GetComponent<HoldNoteController>();
controller?.EnableVisualBodyMode();
CaptureBodyRenderers();
UpdateBodyVisual(GameplayClock.NowSongTime);
}
// 缓存 body 上的所有 SpriteRenderer 并切到 Tiled。
// 不改 transform 缩放(保留 prefab 里 outer/inner 各自的构图比例),长度全部交给 size.y:
// 世界高度 = size.y × lossyScale.y ⇒ 设 size.y = 目标世界高度 / lossyScale.y。
// 相比原来的 Simple+整体拉伸,Tiled 会沿 Y 重复贴图而非把单张越拉越长,消除拉伸失真。
private void CaptureBodyRenderers()
{
if (bodyRenderers == null)
{
bodyRenderers = GetComponentsInChildren<SpriteRenderer>(true);
int n = bodyRenderers.Length;
bodyRendererOrigDrawMode = new SpriteDrawMode[n];
bodyRendererOrigSize = new Vector2[n];
bodyRendererOrigLocalScale = new Vector3[n];
for (int i = 0; i < n; i++)
{
var sr = bodyRenderers[i];
if (sr == null) continue;
bodyRendererOrigDrawMode[i] = sr.drawMode;
bodyRendererOrigSize[i] = sr.size;
bodyRendererOrigLocalScale[i] = sr.transform.localScale;
}
}
for (int i = 0; i < bodyRenderers.Length; i++)
{
var sr = bodyRenderers[i];
if (sr == null) continue;
sr.drawMode = SpriteDrawMode.Tiled;
sr.tileMode = SpriteTileMode.Continuous;
// 不改 transform 缩放(保留 outer/inner 各自构图比例)。宽度取一块 tile 宽,
// 使世界宽度 = sprite 原生宽 × lossyScale.x,与原 Simple 模式一致,仅水平不平铺。
if (sr.sprite != null)
{
var sz = sr.size;
sz.x = sr.sprite.bounds.size.x;
sr.size = sz;
}
}
}
// 逐帧驱动 body 的位置(纯视觉,不触判定)。
// 复刻原 N 个 middle 段的整体行为:一条定高长条(全高 = speed×length)随 head 绝对时间下落、
// 穿过判定线继续下移,直到 end 越过判定线后(≈ scheduledEnd)再回收——与原分段"经过判定线
// 后 ~0.2s 回收"的滚动/消失时机一致,不做额外的底部裁剪(避免改变既有视觉行为)。
private void UpdateBodyVisual(float t)
{
if (!bodyMode) return;
float scheduledEnd = bodyHeadActivationTime + bodyTravelTime + bodyLengthSeconds;
if (t > scheduledEnd + 0.2f) { ReturnBodyToPool(); return; }
float elapsed = t - bodyHeadActivationTime;
float headY = bodySpawnY - bodySpeed * elapsed; // head(底端)世界 Y
float fullHeight = bodySpeed * bodyLengthSeconds; // 定高(= end 高出 head 的量)
float centerY = headY + fullHeight * 0.5f;
transform.position = new Vector3(bodySpawnX, centerY, bodySpawnZ);
ApplyBodyHeight(fullHeight);
}
// 世界高度 → 每个渲染器的 size.y(= 目标世界高度 / 该渲染器世界 Y 缩放)。
private void ApplyBodyHeight(float worldHeight)
{
if (bodyRenderers == null) return;
for (int i = 0; i < bodyRenderers.Length; i++)
{
var sr = bodyRenderers[i];
if (sr == null) continue;
float ly = Mathf.Abs(sr.transform.lossyScale.y);
if (ly < 1e-4f) ly = 1f;
var sz = sr.size;
sz.y = Mathf.Max(0f, worldHeight / ly);
sr.size = sz;
}
}
private void ReturnBodyToPool()
{
if (bodyReturning) return;
bodyReturning = true;
if (!gameObject.activeSelf) return;
gameObject.SetActive(false);
if (NotePool.Instance != null)
NotePool.Instance.ReturnHoldNoteSegment(gameObject, noteColor);
}
// 池化复用/回收时把 body 渲染器还原到 prefab 原始状态。
private void RestoreBodyRenderers()
{
if (bodyRenderers == null) return;
for (int i = 0; i < bodyRenderers.Length; i++)
{
var sr = bodyRenderers[i];
if (sr == null) continue;
sr.drawMode = bodyRendererOrigDrawMode[i];
sr.size = bodyRendererOrigSize[i];
sr.transform.localScale = bodyRendererOrigLocalScale[i];
}
}
public void ApplyVisualScale(float scaleY)
{
float s = Mathf.Clamp(scaleY, 0.1f, 4f);