谱面替换

This commit is contained in:
SoulliesOfficial
2026-07-30 09:28:43 -04:00
parent 58afd6acc1
commit 96619b1bb5
50 changed files with 1453 additions and 807 deletions

View File

@@ -213,7 +213,7 @@ Material:
- _Dst: 10
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EdgeValue: 0.9624716
- _EdgeValue: 0.59140563
- _EnvironmentReflections: 1
- _FNLfanxiangkaiguan: 0
- _Face: 1
@@ -258,7 +258,7 @@ Material:
- _Mask_scale: 1
- _Metallic: 0
- _OcclusionStrength: 1
- _Opacity: 0.037528396
- _Opacity: 0.40859437
- _Parallax: 0.005
- _Pass: 0
- _QueueOffset: 0

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ee86eb680edff0441812416293403139
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: a792caa2a59b77543b25793ed326a5bc
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 7c82452eec95d2140ac081f8c18042f3
guid: 0955e9cb35e5f834abe8a52400d9f81a
TextScriptImporter:
externalObjects: {}
userData:

View File

@@ -24,6 +24,7 @@ namespace Dreamteck.Splines
public enum EmitPoint { Beginning, Ending, Random, Ordered }
public enum MotionType { None, UseParticleSystem, FollowForward, FollowBackward, ByNormal, ByNormalRandomized }
public enum Wrap { Default, Loop }
public enum PathNodeSizeMode { LegacyOffset, ApplyPathNodeSize }
public bool is3D;
public float width = 10f;
@@ -51,6 +52,8 @@ namespace Dreamteck.Splines
public float minCycles = 1f;
[HideInInspector]
public float maxCycles = 1f;
[HideInInspector]
public PathNodeSizeMode pathNodeSizeMode = PathNodeSizeMode.LegacyOffset;
private Dictionary<uint, Particle> _particleDataMap = new Dictionary<uint, Particle>();
private ParticleSystem.Particle[] _particles = new ParticleSystem.Particle[0];

Binary file not shown.

View File

@@ -27,7 +27,7 @@ RectTransform:
m_GameObject: {fileID: 7671065637303904002}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 2, y: 2, z: 2}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}

View File

@@ -1 +1,2 @@
é虎h¸DAÉ a¼ª{„Pg…ÇßP@×w<C397>ÇÚY2héH<C3A9>ØU<15>^DÑmw £roV¶r<C2B6>'ƒU'(høB˜íú¼€þ4à‘ä—-øhR”‰ a'rY*®E¼À1ÇXð\¶qi´By4?±²FÔUÝyšÎôæ'°KlVGR§PkeO –û‹Ö´¬«"¢êßxIT~…v v•ÿç_H$þ:2âMH¯'òƒÑÓ™#pÔp<C394>Ð@
96×ySö(Ë¥šÜ`à ñ™YôýôèQÖÛù$HÆ/¬-„u»PûÁ'8nG_¼©ú07 ¹ø<I“c>ï7¯n<>j¿'S;‹Ð±/¾ÃÔˆgPE4¸Y[æ™Vö=EL#rfëpW¾5`”ª]<5D>:}r1Æ´
<rôúý!J. Õ<> ëRyÅÕ]JRéàs¬áP6€ÁL\ýÝ7 <37>GºKÉ¢üÔc‡_0L²/ÄEmMKœ

View File

@@ -1,2 +1 @@
7ã”Þ¨ñÂKî<4B>mI2!_YÀøFÂòô¨=²XmAÚøÊ<C3B8>. Þ;,<2C>7S’ö[aMß7ÚOÂÔ-GP^ùsäH€>¨VÛå¾þîP$Ó{¹3Tæ<54>¬ú)ní÷æ4´V0bOy@7<4D>ä&H×ñùÁÈ}ÅKJ¤I|ÁÑ6 qîΆÎ-|ÿ;ç&X<>Á
¤½ä2ÄnÒ.÷±t3íIÇ«]Îö•Ò~9Ň>ÑUÂ"¨2Pp¹vX~€³­ÌÍ ñjœâžæÅôÚß+ÝÎ=õZ°Ô'y ý
…Ús—´…@¼²OÝÏî<C38F>[”VAj§OôÚï€àdܰìnøf''l |QJæ¦K <0B>^£(e#À⻀<C2BB>zm°JIÁÞk ñÒ0ž

Binary file not shown.

View File

@@ -11790,7 +11790,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 99971dd6462223c4596d435e8acdcfb8, type: 3}
m_Name:
m_EditorClassIdentifier:
minSwipeDistance: 100
minSwipeDistance: 50
swipeAngleThreshold: 1
playerInput: {fileID: 1332204029}
holdingTouch0: 0

View File

@@ -11875,7 +11875,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 99971dd6462223c4596d435e8acdcfb8, type: 3}
m_Name:
m_EditorClassIdentifier:
minSwipeDistance: 100
minSwipeDistance: 25
swipeAngleThreshold: 1
playerInput: {fileID: 1332204029}
holdingTouch0: 0

View File

@@ -25327,7 +25327,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 0
m_IsActive: 1
--- !u!224 &1589396086
RectTransform:
m_ObjectHideFlags: 0
@@ -25358,9 +25358,9 @@ CanvasGroup:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1589396085}
m_Enabled: 1
m_Alpha: 1
m_Interactable: 1
m_BlocksRaycasts: 1
m_Alpha: 0
m_Interactable: 0
m_BlocksRaycasts: 0
m_IgnoreParentGroups: 0
--- !u!1001 &1609000271
PrefabInstance:
@@ -30066,7 +30066,7 @@ ParticleSystem:
ringBufferLoopRange: {x: 0, y: 1}
emitterVelocityMode: 1
looping: 1
prewarm: 1
prewarm: 0
playOnAwake: 1
useUnscaledTime: 0
autoRandomSeed: 1
@@ -34825,9 +34825,9 @@ CanvasGroup:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2019519601}
m_Enabled: 1
m_Alpha: 0
m_Interactable: 0
m_BlocksRaycasts: 0
m_Alpha: 1
m_Interactable: 1
m_BlocksRaycasts: 1
m_IgnoreParentGroups: 0
--- !u!114 &2019519604
MonoBehaviour:
@@ -34861,7 +34861,7 @@ MonoBehaviour:
m_UiScaleMode: 1
m_ReferencePixelsPerUnit: 100
m_ScaleFactor: 1
m_ReferenceResolution: {x: 1920, y: 1080}
m_ReferenceResolution: {x: 2560, y: 1440}
m_ScreenMatchMode: 0
m_MatchWidthOrHeight: 0
m_PhysicalUnit: 3

View File

@@ -58,9 +58,18 @@ namespace Ichni.RhythmGame
#region [] Main Update Logic
public virtual void UpdateEffect(float judgeTime)
{
EffectState state = CheckEffectState(judgeTime);
float songTime = CoreServices.TimeProvider.SongTime;
// 零时长特效必须在到达触发时间的当帧执行。
// 若继续进入 Middle 分支,不仅会把 Adjust() 延迟到下一帧,还会产生 0 / 0 的 NaN 进度。
if (isInstantEffect)
{
UpdateInstantEffect(judgeTime, songTime);
return;
}
EffectState state = CheckEffectState(judgeTime);
if (state == EffectState.Before && nowEffectState != EffectState.Before)
{
nowEffectState = EffectState.Before;
@@ -86,6 +95,27 @@ namespace Ichni.RhythmGame
}
}
private void UpdateInstantEffect(float triggerTime, float songTime)
{
if (songTime < triggerTime)
{
if (nowEffectState != EffectState.Before)
{
nowEffectState = EffectState.Before;
effectProgressPercent = 0;
Recover();
}
return;
}
if (nowEffectState == EffectState.After) return;
nowEffectState = EffectState.After;
effectProgressPercent = 1;
Adjust();
}
protected virtual EffectState CheckEffectState(float triggerTime)
{
float songTime = CoreServices.TimeProvider.SongTime;

View File

@@ -27,8 +27,9 @@ namespace Ichni.RhythmGame
}
}
public override bool CheckJudgeAvailability(InputUnit inputUnit)
public override bool CheckJudgeAvailability(InputJudgeContext context)
{
InputUnit inputUnit = context.InputUnit;
if (inputUnit is InputUnitSwipe swipe && note is Flick flick)
{
return flick.CheckSwipeDirection(swipe);

View File

@@ -44,7 +44,15 @@ namespace Ichni.RhythmGame
}
public virtual bool CheckJudgeAvailability(InputUnit inputUnit)
public virtual void UpdateSpatialState(float songTime, Vector2 screenPosition)
{
}
public virtual void ResetSpatialState()
{
}
public virtual bool CheckJudgeAvailability(InputJudgeContext context)
{
throw new NotImplementedException();
}

View File

@@ -1,5 +1,3 @@
using System.Collections;
using Ichni.RhythmGame.Beatmap;
using UnityEngine;
namespace Ichni.RhythmGame
@@ -7,11 +5,42 @@ namespace Ichni.RhythmGame
public class TouchAreaJudgeUnit : NoteJudgeUnit
{
#region [] Properties
public float ellipseXMultiplier = 1.25f;
public float ellipseYMultiplier = 1.0f;
// Global tuning values. Speed uses 1080p-reference pixels per second;
// sample distance is normalized by the current screen height.
public static float DirectionalMajorAxisMultiplier = 1.10f;
public static float DirectionalMinorAxisMultiplier = 0.80f;
public static float FallbackCircleMultiplier = 0.94f;
// 面积缩放为 75% 时,长短轴半径需要统一乘以 sqrt(0.75),才能保持原有椭圆比例。
public static float JudgeAreaLinearScale = 0.866f;
public static float MinimumDirectionalSpeed = 30f;
public static float DirectionPersistenceDuration = 0.15f;
public static float MaximumSampleInterval = 0.10f;
public static float MaximumSampleDistanceRatio = 0.25f;
public static float MaximumInputSampleExtrapolation = 0.05f;
public float areaRadius;
private static float CurrentScreenRatio => Screen.height / 1080f; // 以1080p为基准的屏幕缩放比例
private ScreenSample _previousSample;
private ScreenSample _currentSample;
private bool _hasPreviousSample;
private bool _hasCurrentSample;
private Vector2 _lastStableDirection;
private float _lastStableDirectionSongTime;
private bool _hasStableDirection;
private static float CurrentScreenRatio => Mathf.Max(Screen.height / 1080f, 0.0001f);
private struct ScreenSample
{
public float songTime;
public Vector2 position;
public ScreenSample(float songTime, Vector2 position)
{
this.songTime = songTime;
this.position = position;
}
}
#endregion
#region [] Initialization
@@ -29,26 +58,97 @@ namespace Ichni.RhythmGame
public override void UpdateJudge()
{
if ((note is not Hold && note.isFirstJudged)||(note is Hold && note.isFinalJudged)) return;
Vector2 noteScreenPosition = note.noteScreenPosition;
RectTransform canvasRect = GameManager.Instance.judgeHintCanvas.GetComponent<RectTransform>();
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRect, noteScreenPosition, null, out Vector2 uiPosition))
if (judgeHintImage == null ||
(note is not Hold && note.isFirstJudged) ||
(note is Hold && note.isFinalJudged)) return;
GetJudgeGeometry(CoreServices.TimeProvider.SongTime, out Vector2 center, out Vector2 majorDirection,
out float majorRadius, out float minorRadius);
Canvas canvas = GameManager.Instance.judgeHintCanvas;
RectTransform canvasRect = canvas.GetComponent<RectTransform>();
Camera eventCamera = canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera;
Vector2 minorDirection = new Vector2(-majorDirection.y, majorDirection.x);
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRect, center, eventCamera,
out Vector2 uiPosition) &&
RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRect,
center + majorDirection * majorRadius, eventCamera, out Vector2 uiMajorPoint) &&
RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRect,
center + minorDirection * minorRadius, eventCamera, out Vector2 uiMinorPoint))
{
Vector2 uiMajorAxis = uiMajorPoint - uiPosition;
Vector2 uiMinorAxis = uiMinorPoint - uiPosition;
judgeHintImage.anchoredPosition = uiPosition;
judgeHintImage.sizeDelta = new Vector2(areaRadius * 2 * ellipseXMultiplier, areaRadius * 2 * ellipseYMultiplier) * CurrentScreenRatio;
judgeHintImage.sizeDelta = new Vector2(uiMajorAxis.magnitude * 2f, uiMinorAxis.magnitude * 2f);
judgeHintImage.localRotation = Quaternion.Euler(0f, 0f,
Mathf.Atan2(uiMajorAxis.y, uiMajorAxis.x) * Mathf.Rad2Deg);
}
}
public override bool CheckJudgeAvailability(InputUnit inputUnit)
public override void ResetSpatialState()
{
Vector2 inputScreenPosition = inputUnit.inputPosition;
Vector2 noteScreenPosition = note.noteScreenPosition != Vector2.zero ? note.noteScreenPosition : note.GetScreenPosition();
float scaledBaseRadius = areaRadius * CurrentScreenRatio;
_previousSample = default;
_currentSample = default;
_hasPreviousSample = false;
_hasCurrentSample = false;
_lastStableDirection = Vector2.zero;
_lastStableDirectionSongTime = 0f;
_hasStableDirection = false;
}
float dx = Mathf.Abs(inputScreenPosition.x - noteScreenPosition.x) / ellipseXMultiplier;
float dy = Mathf.Abs(inputScreenPosition.y - noteScreenPosition.y) / ellipseYMultiplier;
public override void UpdateSpatialState(float songTime, Vector2 screenPosition)
{
if (!IsFinite(songTime) || !IsFinite(screenPosition)) return;
if ((dx * dx) + (dy * dy) <= (scaledBaseRadius * scaledBaseRadius))
ScreenSample newSample = new ScreenSample(songTime, screenPosition);
if (!_hasCurrentSample)
{
_currentSample = newSample;
_hasCurrentSample = true;
return;
}
if (Mathf.Approximately(songTime, _currentSample.songTime))
{
_currentSample = newSample;
return;
}
if (!IsValidTransition(_currentSample, newSample))
{
_currentSample = newSample;
_hasPreviousSample = false;
_hasStableDirection = false;
return;
}
_previousSample = _currentSample;
_currentSample = newSample;
_hasPreviousSample = true;
Vector2 displacement = _currentSample.position - _previousSample.position;
float duration = _currentSample.songTime - _previousSample.songTime;
float referenceSpeed = displacement.magnitude / CurrentScreenRatio / duration;
if (displacement.sqrMagnitude > Mathf.Epsilon && referenceSpeed >= MinimumDirectionalSpeed)
{
_lastStableDirection = displacement.normalized;
_lastStableDirectionSongTime = _currentSample.songTime;
_hasStableDirection = true;
}
}
public override bool CheckJudgeAvailability(InputJudgeContext context)
{
InputUnit inputUnit = context.InputUnit;
GetJudgeGeometry(context.InputSongTime, out Vector2 center, out Vector2 majorDirection,
out float majorRadius, out float minorRadius);
Vector2 inputOffset = context.InputPosition - center;
Vector2 minorDirection = new Vector2(-majorDirection.y, majorDirection.x);
float along = Vector2.Dot(inputOffset, majorDirection) / Mathf.Max(majorRadius, Mathf.Epsilon);
float across = Vector2.Dot(inputOffset, minorDirection) / Mathf.Max(minorRadius, Mathf.Epsilon);
if ((along * along) + (across * across) <= 1f)
{
if (inputUnit is InputUnitSwipe swipe && note is Flick flick)
{
@@ -59,6 +159,93 @@ namespace Ichni.RhythmGame
return false;
}
public Vector2 GetJudgeCenter(float songTime)
{
return ResolveCenter(songTime);
}
private void GetJudgeGeometry(float songTime, out Vector2 center, out Vector2 majorDirection,
out float majorRadius, out float minorRadius)
{
center = ResolveCenter(songTime);
float scaledBaseRadius = Mathf.Max(areaRadius, 0f) * CurrentScreenRatio * JudgeAreaLinearScale;
if (TryResolveDirection(songTime, out majorDirection))
{
majorRadius = scaledBaseRadius * DirectionalMajorAxisMultiplier;
minorRadius = scaledBaseRadius * DirectionalMinorAxisMultiplier;
return;
}
majorDirection = Vector2.right;
majorRadius = scaledBaseRadius * FallbackCircleMultiplier;
minorRadius = majorRadius;
}
private Vector2 ResolveCenter(float songTime)
{
if (!_hasCurrentSample)
{
return note.noteScreenPosition != Vector2.zero
? note.noteScreenPosition
: note.GetScreenPosition();
}
if (!_hasPreviousSample) return _currentSample.position;
if (songTime <= _previousSample.songTime) return _previousSample.position;
if (songTime >= _currentSample.songTime) return _currentSample.position;
float interpolation = Mathf.InverseLerp(_previousSample.songTime, _currentSample.songTime, songTime);
return Vector2.LerpUnclamped(_previousSample.position, _currentSample.position, interpolation);
}
private bool TryResolveDirection(float songTime, out Vector2 direction)
{
if (_hasPreviousSample &&
songTime >= _previousSample.songTime - MaximumInputSampleExtrapolation &&
songTime <= _currentSample.songTime + MaximumInputSampleExtrapolation)
{
Vector2 displacement = _currentSample.position - _previousSample.position;
float duration = _currentSample.songTime - _previousSample.songTime;
float referenceSpeed = displacement.magnitude / CurrentScreenRatio / duration;
if (displacement.sqrMagnitude > Mathf.Epsilon && referenceSpeed >= MinimumDirectionalSpeed)
{
direction = displacement.normalized;
return true;
}
}
if (_hasStableDirection &&
Mathf.Abs(songTime - _lastStableDirectionSongTime) <= DirectionPersistenceDuration)
{
direction = _lastStableDirection;
return true;
}
direction = Vector2.right;
return false;
}
private static bool IsValidTransition(ScreenSample from, ScreenSample to)
{
float duration = to.songTime - from.songTime;
if (duration <= Mathf.Epsilon || duration > MaximumSampleInterval) return false;
float screenHeight = Mathf.Max(Screen.height, 1f);
float distanceRatio = Vector2.Distance(from.position, to.position) / screenHeight;
return distanceRatio <= MaximumSampleDistanceRatio;
}
private static bool IsFinite(float value)
{
return !float.IsNaN(value) && !float.IsInfinity(value);
}
private static bool IsFinite(Vector2 value)
{
return IsFinite(value.x) && IsFinite(value.y);
}
#endregion
}

View File

@@ -12,6 +12,14 @@ namespace Ichni.RhythmGame
public NoteBase note;
public NoteVisualBase noteVisual;
#endregion
/// <summary>
/// 在 Note 缓存最终判定姿态之前执行。
/// 只有确实需要调整打击位置的主题特效才应重写此方法,避免改变其他主题的 Note 表现。
/// </summary>
public virtual void PrepareJudgedEffectPose()
{
}
}

View File

@@ -51,8 +51,10 @@ namespace Ichni.RhythmGame
#endregion
#region [] Main Update
public override bool ManualUpdate(float currentSongTime)
public override void ManualPrepareForJudge(float currentSongTime)
{
// 这里按“本帧 SongTime”把 Flick 放入可检查列表;真正消费 Swipe 时,
// 还必须用 InputJudgeContext.InputSongTime 再检查一次输入发生时间。
if (!isFirstJudged && !isDuringJudging &&
currentSongTime >= exactJudgeTime + judgeIntervals.beforeMiss.intervalStart &&
!GameManager.Instance.noteJudgeManager.checkingFlickList.Contains(this))
@@ -61,8 +63,13 @@ namespace Ichni.RhythmGame
GameManager.Instance.noteJudgeManager.checkingFlickList.Add(this);
}
base.ManualPrepareForJudge(currentSongTime);
}
public override bool ManualResolveAfterJudge(float currentSongTime)
{
ExecuteFinalJudge(currentSongTime);
return base.ManualUpdate(currentSongTime);
return base.ManualResolveAfterJudge(currentSongTime);
}
protected override void RemoveFromCheckingList()
@@ -73,11 +80,16 @@ namespace Ichni.RhythmGame
#endregion
#region [] Judgement Logic
public bool CheckJudgeAvailability(InputUnitSwipe inputUnitSwipe)
public bool CheckJudgeAvailability(InputJudgeContext context)
{
if (context.InputUnit is not InputUnitSwipe) return false;
// 不能让窗口外的早期 Swipe 抢先把 Flick 以 Miss 状态移出 checkingFlickList。
if (!IsWithinStartJudgeWindow(context.InputSongTime)) return false;
foreach (var judgeUnit in NoteJudgeSubmodule.judgeUnitList)
{
if (!judgeUnit.CheckJudgeAvailability(inputUnitSwipe)) return false;
if (!judgeUnit.CheckJudgeAvailability(context)) return false;
}
return true;
}
@@ -150,9 +162,6 @@ namespace Ichni.RhythmGame
}
}
#endregion
#region [] Beatmap Method
#endregion
}
}

View File

@@ -73,10 +73,8 @@ namespace Ichni.RhythmGame
#endregion
#region [] Main Update
public override bool ManualUpdate(float currentSongTime)
public override void ManualPrepareForJudge(float currentSongTime)
{
if (!base.ManualUpdate(currentSongTime)) return false;
if (!isFirstJudged && !isDuringJudging &&
currentSongTime >= exactJudgeTime + judgeIntervals.beforeMiss.intervalStart &&
!GameManager.Instance.noteJudgeManager.checkingHoldList.Contains(this))
@@ -85,6 +83,43 @@ namespace Ichni.RhythmGame
GameManager.Instance.noteJudgeManager.checkingHoldList.Add(this);
}
// isHolding 会在上一帧末尾重置。已经开始的 Hold 必须保留上一帧收缩后的
// Visual供本帧 Touch 判定使用;否则 UpdateNoteInTrack 会先把头部展开回起点。
if (isFirstJudged && !isFinalJudged)
{
UpdateJudgeSpatialState(currentSongTime);
SetJudgeArea();
return;
}
base.ManualPrepareForJudge(currentSongTime);
}
protected override void UpdateSpatialStateBeforeJudgeScreenSample(float currentSongTime)
{
if (!isFirstJudged && exactJudgeTime < currentSongTime)
{
SlowOffsetAfterExactJudgeTime();
}
}
protected override void UpdateJudgeSpatialState(float currentSongTime)
{
if (isDuringJudging)
{
UpdateJudgeScreenPosition(currentSongTime);
}
if (perfectNoteScreenPosition == -Vector2.one && exactJudgeTime <= currentSongTime)
{
perfectNoteScreenPosition = noteScreenPosition;
}
}
public override bool ManualResolveAfterJudge(float currentSongTime)
{
if (!base.ManualResolveAfterJudge(currentSongTime)) return false;
if (!GameManager.Instance.songPlayer.isUpdating || isFinalJudged) return true;
if (SettingsManager.instance.settingsSaveData.autoPlay && isFirstJudged) ExecuteProcessJudge();
@@ -99,21 +134,14 @@ namespace Ichni.RhythmGame
bufferTimer -= Time.deltaTime;
}
// Touch 判定已经在本帧设置 isHolding。现在使用最新 holdingTime 收缩 Hold
// 再采样实际头部位置,保证下一帧判定区域与 Visual 保持一致。
if (isOnTrack) UpdateNoteInTrack(currentSongTime);
if (isDuringJudging) noteScreenPosition = GetScreenPosition();
if (isDuringJudging) UpdateJudgeScreenPosition(currentSongTime);
SetJudgeArea();
if (!isFirstJudged && exactJudgeTime < currentSongTime) SlowOffsetAfterExactJudgeTime();
UpdateHoldEffects();
if (SettingsManager.instance.settingsSaveData.autoPlay && !isFirstJudged && currentSongTime >= exactJudgeTime)
{
ExecuteStartJudge(currentSongTime);
}
CheckHoldLifeCycle(currentSongTime);
return true;
@@ -154,22 +182,18 @@ namespace Ichni.RhythmGame
#endregion
#region [] Judgement Logic
public bool CheckJudgeAvailability(InputUnitTap inputUnitTap)
public bool CheckJudgeAvailability(InputJudgeContext context)
{
if (isFirstJudged) return false;
foreach (var judgeUnit in NoteJudgeSubmodule.judgeUnitList)
{
if (!judgeUnit.CheckJudgeAvailability(inputUnitTap)) return false;
}
return true;
}
if (context.InputUnit is InputUnitTap && isFirstJudged) return false;
if (context.InputUnit is InputUnitTouch && !isFirstJudged) return false;
if (context.InputUnit is not InputUnitTap && context.InputUnit is not InputUnitTouch) return false;
// 只限制 Hold 头部的 Tap起判成功后的 Touch 需要在整段 Hold 生命周期持续生效。
if (context.InputUnit is InputUnitTap && !IsWithinStartJudgeWindow(context.InputSongTime)) return false;
public bool CheckJudgeAvailability(InputUnitTouch inputUnitTouch)
{
if (!isFirstJudged) return false;
foreach (var judgeUnit in NoteJudgeSubmodule.judgeUnitList)
{
if (!judgeUnit.CheckJudgeAvailability(inputUnitTouch)) return false;
if (!judgeUnit.CheckJudgeAvailability(context)) return false;
}
return true;
}

View File

@@ -52,6 +52,8 @@ namespace Ichni.RhythmGame
protected NoteJudgeType judgedType;
protected bool isJudgedAndDestroying;
protected float destroyTimer;
protected Pose judgedEffectPose;
protected bool hasJudgedEffectPose;
// 用于记录是否离开屏幕的最后一帧有效坐标
protected Vector2 lastValidScreenPosition = -Vector2.one;
@@ -75,8 +77,19 @@ namespace Ichni.RhythmGame
missEffects = GetEffectListSafe("Miss");
afterJudgeEffects = GetEffectListSafe("AfterJudge");
noteScreenPosition = Vector2.zero;
perfectNoteScreenPosition = -Vector2.one;
lastValidScreenPosition = -Vector2.one; // 初始化
judgedEffectPose = default;
hasJudgedEffectPose = false;
if (NoteJudgeSubmodule?.judgeUnitList != null)
{
for (int i = 0; i < NoteJudgeSubmodule.judgeUnitList.Count; i++)
{
NoteJudgeSubmodule.judgeUnitList[i].ResetSpatialState();
}
}
float beyondTime = 0f;
@@ -147,7 +160,37 @@ namespace Ichni.RhythmGame
#endregion
#region [] Main Update & Visual Calculate
public virtual bool ManualUpdate(float currentSongTime)
public virtual void ManualPrepareForJudge(float currentSongTime)
{
if (isJudgedAndDestroying || isFinalJudged) return;
// 判定前必须先完成本帧的轨迹、屏幕坐标与判定区域采样。
UpdateNoteInTrack(currentSongTime);
UpdateSpatialStateBeforeJudgeScreenSample(currentSongTime);
UpdateJudgeSpatialState(currentSongTime);
SetJudgeArea();
}
protected virtual void UpdateSpatialStateBeforeJudgeScreenSample(float currentSongTime)
{
}
protected virtual void UpdateJudgeSpatialState(float currentSongTime)
{
if (perfectNoteScreenPosition != -Vector2.one) return;
if (isDuringJudging)
{
UpdateJudgeScreenPosition(currentSongTime);
}
if (exactJudgeTime <= currentSongTime)
{
perfectNoteScreenPosition = noteScreenPosition;
}
}
public virtual bool ManualResolveAfterJudge(float currentSongTime)
{
// 若被 Judge 判定击中进入到特效摧毁倒计时
if (isJudgedAndDestroying)
@@ -168,21 +211,6 @@ namespace Ichni.RhythmGame
if (isFinalJudged) return true;
// 1. 轨迹更新
UpdateNoteInTrack(currentSongTime);
// 2. 屏幕坐标系计算缓存
if (perfectNoteScreenPosition == -Vector2.one)
{
if (isDuringJudging)
{
noteScreenPosition = GetScreenPosition();
}
if (exactJudgeTime <= currentSongTime) // 代替 GameManager.Instance.songTime
{
perfectNoteScreenPosition = noteScreenPosition;
}
}
SetJudgeArea();
if (generateEffects != null)
{
for (int i = 0; i < generateEffects.Count; i++)
@@ -237,6 +265,17 @@ namespace Ichni.RhythmGame
}
}
protected void UpdateJudgeScreenPosition(float songTime)
{
noteScreenPosition = GetScreenPosition();
if (NoteJudgeSubmodule?.judgeUnitList == null) return;
for (int i = 0; i < NoteJudgeSubmodule.judgeUnitList.Count; i++)
{
NoteJudgeSubmodule.judgeUnitList[i].UpdateSpatialState(songTime, noteScreenPosition);
}
}
protected virtual void SetJudgeArea()
{
if (!SettingsManager.instance.settingsSaveData.debugMode || NoteJudgeSubmodule?.judgeUnitList == null) return;
@@ -332,6 +371,67 @@ namespace Ichni.RhythmGame
return judgeIntervals.GetNoteJudgeType(timeDifference);
}
public NoteJudgeType PreviewStartJudgeType(float triggerTime)
{
// Keep candidate resolution pure. The actual Note state changes only after one candidate is selected.
return GetStartJudgeType(triggerTime - exactJudgeTime);
}
/// <summary>
/// 判断输入的真实发生时间是否位于该 Note 的起始判定窗口内。
/// checking 列表使用本帧 SongTime 维护,而输入时间可能比本帧时间早几毫秒;
/// 因此必须单独过滤窗口外输入,不能把它当成可 fallback 的 Miss 候选。
/// </summary>
public bool IsWithinStartJudgeWindow(float inputSongTime)
{
float timeDifference = inputSongTime - exactJudgeTime;
return timeDifference >= judgeIntervals.beforeMiss.intervalStart &&
timeDifference <= judgeIntervals.afterMiss;
}
/// <summary>
/// 读取最终判定发生当帧缓存的 JudgeEffect 世界姿态。
/// 特效应使用该姿态,而不是在延迟执行时重新读取 SongTime 或已经切换后的轨道/摄像机状态。
/// </summary>
public bool TryGetJudgedEffectPose(out Pose pose)
{
pose = judgedEffectPose;
return hasJudgedEffectPose;
}
private void CaptureJudgedEffectPose()
{
hasJudgedEffectPose = false;
Transform judgeEffectTransform = noteVisual?.judgeEffect?.transform;
if (judgeEffectTransform == null) return;
judgedEffectPose = new Pose(judgeEffectTransform.position, judgeEffectTransform.rotation);
hasJudgedEffectPose = true;
}
private void PrepareJudgedEffectPose(NoteJudgeType judgeType)
{
List<EffectBase> judgedEffects = judgeType switch
{
NoteJudgeType.Perfect => perfectEffects,
NoteJudgeType.Good => goodEffects,
NoteJudgeType.Bad => badEffects,
NoteJudgeType.Miss => missEffects,
_ => null
};
if (judgedEffects == null) return;
for (int i = 0; i < judgedEffects.Count; i++)
{
if (judgedEffects[i] is NoteEffectBase noteEffect)
{
noteEffect.PrepareJudgedEffectPose();
}
}
}
protected virtual void ExecuteJudge(NoteJudgeType judgeType, float triggerTime)
{
isDuringJudging = false;
@@ -339,6 +439,10 @@ namespace Ichni.RhythmGame
judgedTriggerTime = triggerTime;
judgedType = judgeType;
// 判定之后轨道、摄像机或 Note 层级都可能立即变化,必须先冻结本次打击特效的世界姿态。
PrepareJudgedEffectPose(judgeType);
CaptureJudgedEffectPose();
switch (judgeType)
{
case NoteJudgeType.Perfect:

View File

@@ -48,7 +48,7 @@ namespace Ichni.RhythmGame
#endregion
#region [] Main Update
public override bool ManualUpdate(float currentSongTime)
public override void ManualPrepareForJudge(float currentSongTime)
{
if (!isFirstJudged && !isDuringJudging &&
currentSongTime >= exactJudgeTime + judgeIntervals.beforeMiss.intervalStart &&
@@ -58,8 +58,13 @@ namespace Ichni.RhythmGame
GameManager.Instance.noteJudgeManager.checkingStayList.Add(this);
}
base.ManualPrepareForJudge(currentSongTime);
}
public override bool ManualResolveAfterJudge(float currentSongTime)
{
ExecuteFinalJudge(currentSongTime);
return base.ManualUpdate(currentSongTime);
return base.ManualResolveAfterJudge(currentSongTime);
}
protected override void RemoveFromCheckingList()
@@ -70,11 +75,14 @@ namespace Ichni.RhythmGame
#endregion
#region [] Judgement Logic
public bool CheckJudgeAvailability(InputUnitTouch inputUnitTouch)
public bool CheckJudgeAvailability(InputJudgeContext context)
{
if (context.InputUnit is not InputUnitTouch) return false;
if (!IsWithinStartJudgeWindow(context.InputSongTime)) return false;
foreach (var judgeUnit in NoteJudgeSubmodule.judgeUnitList)
{
if (!judgeUnit.CheckJudgeAvailability(inputUnitTouch)) return false;
if (!judgeUnit.CheckJudgeAvailability(context)) return false;
}
return true;
}

View File

@@ -43,7 +43,7 @@ namespace Ichni.RhythmGame
#endregion
#region [] Main Update
public override bool ManualUpdate(float currentSongTime)
public override void ManualPrepareForJudge(float currentSongTime)
{
if (!isFirstJudged && !isDuringJudging &&
currentSongTime >= exactJudgeTime + judgeIntervals.beforeMiss.intervalStart &&
@@ -52,7 +52,8 @@ namespace Ichni.RhythmGame
isDuringJudging = true;
GameManager.Instance.noteJudgeManager.checkingTapList.Add(this);
}
return base.ManualUpdate(currentSongTime);
base.ManualPrepareForJudge(currentSongTime);
}
protected override void RemoveFromCheckingList()
@@ -63,11 +64,14 @@ namespace Ichni.RhythmGame
#endregion
#region [] Judgement Logic
public bool CheckJudgeAvailability(InputUnitTap inputUnitTap)
public bool CheckJudgeAvailability(InputJudgeContext context)
{
if (context.InputUnit is not InputUnitTap) return false;
if (!IsWithinStartJudgeWindow(context.InputSongTime)) return false;
foreach (var judgeUnit in NoteJudgeSubmodule.judgeUnitList)
{
if (!judgeUnit.CheckJudgeAvailability(inputUnitTap)) return false;
if (!judgeUnit.CheckJudgeAvailability(context)) return false;
}
return true;
}

View File

@@ -23,7 +23,7 @@ namespace Ichni
/// Phase 4 TrackCore — 更新轨道时间、裁剪区间Track 通过 IScheduledElement 注册)
/// Phase 5 TrackFollower — CrossTrackPoint / HeadPoint / PercentPoint
/// [TrackManager.ManualLateUpdate — 清除 refreshedThisFrame 标记]
/// Phase 6 Note — NoteManager 内部驱动(激活 + 更新 + 重置)
/// Phase 6 Note — Note Prepare → Input Judge → Note Resolve → Hold Reset
/// Phase 7 Effect — ParticleEmitter / TimeEffectsCollection / ParticleTracker / DTM 元素 / LookAt 旋转覆盖
/// Phase 8 Misc — SkyboxSubsetter / JudgeTrigger / LowPriorityActions
///
@@ -198,7 +198,11 @@ namespace Ichni
public void TickEarly(float songTime)
{
_isUpdating = GameManager.Instance.songPlayer.isUpdating;
if (!_isUpdating) return;
if (!_isUpdating)
{
GameManager.Instance.noteJudgeManager.DiscardPendingInputs();
return;
}
_cachedSongTime = songTime;
@@ -257,11 +261,13 @@ namespace Ichni
GameManager.Instance.trackManager.ManualLateUpdate(songTime);
}
// ─── Phase 6: Note由 NoteManager 内部驱动)─────────────────────────
// ─── Phase 6: Note空间准备 → 输入判定 → 生命周期结算 → Hold 重置)──
using (PhaseMarkers[PhaseIndex(UpdatePhase.Note)].Auto())
{
TickPhaseElements(UpdatePhase.Note, songTime);
GameManager.Instance.noteManager.ManualUpdate(songTime);
GameManager.Instance.noteManager.ManualPrepareForJudge(songTime);
GameManager.Instance.noteJudgeManager.ProcessPendingInputs(songTime);
GameManager.Instance.noteManager.ManualResolveAfterJudge(songTime);
GameManager.Instance.noteManager.ManualLateUpdate(songTime);
}

View File

@@ -10,6 +10,7 @@ using Sirenix.OdinInspector;
using TMPro;
using UnityEngine.InputSystem.Controls;
using UnityEngine.InputSystem.EnhancedTouch;
using UnityEngine.InputSystem.LowLevel;
using UnityEngine.UI;
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;
using TouchPhase = UnityEngine.InputSystem.TouchPhase;
@@ -27,7 +28,7 @@ namespace Ichni.RhythmGame
// =====================================================================
[Header("划动设置 (Swipe Settings)")] [Tooltip("识别为划动的最小移动距离(像素)")] [SerializeField]
private float minSwipeDistance = 100f;
private float minSwipeDistance = 50f;
[SerializeField] private float swipeAngleThreshold = 1f;
@@ -42,12 +43,13 @@ namespace Ichni.RhythmGame
{
public int TouchId;
public Vector2 StartPosition;
public float StartTime;
public Vector2 LastPosition;
public Vector2 LastSwipeDirection = Vector2.zero;
public bool isFirstSwipe = true;
}
private readonly Dictionary<int, TouchState> _activeTouches = new Dictionary<int, TouchState>();
private readonly Dictionary<int, TouchState> _activeTouches = new Dictionary<int, TouchState>(10);
private readonly Stack<TouchState> _touchStatePool = new Stack<TouchState>(10);
// 为鼠标模拟专门设置一个固定的Touch ID
private const int MOUSE_TOUCH_ID = 999;
@@ -70,7 +72,20 @@ namespace Ichni.RhythmGame
RegisterActionsInputs();
#else
Debug.Log("已启用真实触摸输入");
Debug.Log("已启用真实触摸输入");
#endif
}
private void OnEnable()
{
// GameInputManager owns its EnhancedTouch dependency instead of relying on CwInput initialization.
if (!EnhancedTouchSupport.enabled)
{
EnhancedTouchSupport.Enable();
}
#if UNITY_EDITOR || UNITY_STANDALONE
SetDesktopInputActionsEnabled(Touchscreen.current == null);
#endif
}
@@ -83,44 +98,64 @@ namespace Ichni.RhythmGame
// 使用预处理指令区分平台
#if UNITY_EDITOR || UNITY_STANDALONE
HandleHolding();
// Device Simulator and physical desktop touchscreens expose a Touchscreen device.
// In that case use the same EnhancedTouch path as mobile builds.
bool useTouchInput = Touchscreen.current != null;
SetDesktopInputActionsEnabled(!useTouchInput);
if (useTouchInput)
{
ProcessRealTouchInput();
}
else
{
HandleHolding();
}
#else
ProcessRealTouchInput();
ProcessRealTouchInput();
#endif
}
private void OnDisable()
{
#if UNITY_EDITOR || UNITY_STANDALONE
foreach (TouchState state in _activeTouches.Values)
{
RecycleTouchState(state);
}
_activeTouches.Clear();
#if UNITY_EDITOR || UNITY_STANDALONE
SetDesktopInputActionsEnabled(false);
#else
#endif
}
private void OnTap(int id, Vector2 position)
private void OnTap(int id, Vector2 position, double inputEventTime)
{
if (SettingsManager.instance.settingsSaveData.debugMode)
{
GenerateTapMark(id, position);
}
GameManager.Instance.noteJudgeManager.SetNewInputUnitTap(id, position);
GameManager.Instance.noteJudgeManager.SetNewInputUnitTap(id, position, inputEventTime);
}
private void OnTouch(int id, Vector2 position)
private void OnTouch(int id, Vector2 position, double inputEventTime)
{
if (SettingsManager.instance.settingsSaveData.debugMode)
{
GenerateTouchMark(id, position);
}
GameManager.Instance.noteJudgeManager.SetNewInputUnitTouch(id, position);
GameManager.Instance.noteJudgeManager.SetNewInputUnitTouch(id, position, inputEventTime);
}
private void OnSwipe(int id, Vector2 position, bool isGeneric, bool isFirst, Vector2 direction)
private void OnSwipe(int id, Vector2 position, bool isGeneric, bool isFirst, Vector2 direction,
double inputEventTime, bool showDebugFeedback = true)
{
if (SettingsManager.instance.settingsSaveData.debugMode)
// 此处只负责把已经识别出的 Swipe 记录为输入样本。白色/红色箭头是采集层反馈,
// 是否命中 Flick 要等 LateUpdate 中 Note 完成空间采样后再由 NoteJudgeManager 决定。
if (showDebugFeedback && SettingsManager.instance.settingsSaveData.debugMode)
{
GenerateSwipeMark(id, position, isGeneric, isFirst, direction);
@@ -128,7 +163,7 @@ namespace Ichni.RhythmGame
else Debug.Log($"划动更新 - ID: {id}, 位置: {position}, 方向: {direction}");
}
GameManager.Instance.noteJudgeManager.SetNewInputUnitSwipe(id, position, isGeneric, isFirst, direction);
GameManager.Instance.noteJudgeManager.SetNewInputUnitSwipe(id, position, isGeneric, isFirst, direction, inputEventTime);
}
#if UNITY_EDITOR || UNITY_STANDALONE
@@ -140,47 +175,48 @@ namespace Ichni.RhythmGame
if (Mouse.current == null) return;
Vector2 position = Mouse.current.position.ReadValue();
double inputEventTime = InputState.currentTime;
if (Mouse.current.leftButton.wasPressedThisFrame)
{
ProcessInputEvent(MOUSE_TOUCH_ID, TouchPhase.Began, position);
ProcessInputEvent(MOUSE_TOUCH_ID, TouchPhase.Began, position, inputEventTime);
}
else if (Mouse.current.leftButton.isPressed)
{
// 如果鼠标位置有变化则为Moved否则为Stationary
if (Mouse.current.delta.ReadValue().sqrMagnitude > 0.1f)
{
ProcessInputEvent(MOUSE_TOUCH_ID, TouchPhase.Moved, position);
ProcessInputEvent(MOUSE_TOUCH_ID, TouchPhase.Moved, position, inputEventTime);
}
else
{
ProcessInputEvent(MOUSE_TOUCH_ID, TouchPhase.Stationary, position);
ProcessInputEvent(MOUSE_TOUCH_ID, TouchPhase.Stationary, position, inputEventTime);
}
}
else if (Mouse.current.leftButton.wasReleasedThisFrame)
{
ProcessInputEvent(MOUSE_TOUCH_ID, TouchPhase.Ended, position);
ProcessInputEvent(MOUSE_TOUCH_ID, TouchPhase.Ended, position, inputEventTime);
}
if (Mouse.current.rightButton.wasPressedThisFrame)
{
ProcessInputEvent(MOUSE_TOUCH_ID + 1, TouchPhase.Began, position);
ProcessInputEvent(MOUSE_TOUCH_ID + 1, TouchPhase.Began, position, inputEventTime);
}
else if (Mouse.current.rightButton.isPressed)
{
// 如果鼠标位置有变化则为Moved否则为Stationary
if (Mouse.current.delta.ReadValue().sqrMagnitude > 0.1f)
{
ProcessInputEvent(MOUSE_TOUCH_ID + 1, TouchPhase.Moved, position);
ProcessInputEvent(MOUSE_TOUCH_ID + 1, TouchPhase.Moved, position, inputEventTime);
}
else
{
ProcessInputEvent(MOUSE_TOUCH_ID + 1, TouchPhase.Stationary, position);
ProcessInputEvent(MOUSE_TOUCH_ID + 1, TouchPhase.Stationary, position, inputEventTime);
}
}
else if (Mouse.current.rightButton.wasReleasedThisFrame)
{
ProcessInputEvent(MOUSE_TOUCH_ID + 1, TouchPhase.Ended, position);
ProcessInputEvent(MOUSE_TOUCH_ID + 1, TouchPhase.Ended, position, inputEventTime);
}
}
#endif
@@ -201,7 +237,7 @@ namespace Ichni.RhythmGame
if (ctx.performed)
{
Vector2 inputPosition = new Vector2(-600 + Screen.width * 0.5f, 200f);
OnTap(0, inputPosition);
OnTap(0, inputPosition, ctx.time);
holdingTouch0 = true;
}
};
@@ -211,7 +247,7 @@ namespace Ichni.RhythmGame
if (ctx.performed)
{
Vector2 inputPosition = new Vector2(-200 + Screen.width * 0.5f, 200f);
OnTap(1, inputPosition);
OnTap(1, inputPosition, ctx.time);
holdingTouch1 = true;
}
};
@@ -221,7 +257,7 @@ namespace Ichni.RhythmGame
if (ctx.performed)
{
Vector2 inputPosition = new Vector2(200 + Screen.width * 0.5f, 200f);
OnTap(2, inputPosition);
OnTap(2, inputPosition, ctx.time);
holdingTouch2 = true;
}
};
@@ -231,7 +267,7 @@ namespace Ichni.RhythmGame
if (ctx.performed)
{
Vector2 inputPosition = new Vector2(600 + Screen.width * 0.5f, 200f);
OnTap(3, inputPosition);
OnTap(3, inputPosition, ctx.time);
holdingTouch3 = true;
}
};
@@ -285,36 +321,52 @@ namespace Ichni.RhythmGame
};
}
private void SetDesktopInputActionsEnabled(bool shouldEnable)
{
if (gameInput == null || gameInput.Game.enabled == shouldEnable) return;
if (shouldEnable)
{
gameInput.Game.Enable();
}
else
{
gameInput.Game.Disable();
}
}
private void HandleHolding()
{
double inputEventTime = InputState.currentTime;
if (holdingTouch0)
{
Vector2 inputPosition = new Vector2(-600 + Screen.width * 0.5f, 200f);
OnTouch(0, inputPosition);
OnTouch(0, inputPosition, inputEventTime);
}
if (holdingTouch1)
{
Vector2 inputPosition = new Vector2(-200 + Screen.width * 0.5f, 200f);
OnTouch(1, inputPosition);
OnTouch(1, inputPosition, inputEventTime);
}
if (holdingTouch2)
{
Vector2 inputPosition = new Vector2(200 + Screen.width * 0.5f, 200f);
OnTouch(2, inputPosition);
OnTouch(2, inputPosition, inputEventTime);
}
if (holdingTouch3)
{
Vector2 inputPosition = new Vector2(600 + Screen.width * 0.5f, 200f);
OnTouch(3, inputPosition);
OnTouch(3, inputPosition, inputEventTime);
}
if (holdingSwipe0)
{
Vector2 inputPosition = new Vector2(Screen.width * 0.5f, 200f);
OnSwipe(0, inputPosition, true, false, Vector2.zero);
OnSwipe(0, inputPosition, true, false, Vector2.zero, inputEventTime);
}
}
#endif
@@ -328,10 +380,14 @@ namespace Ichni.RhythmGame
foreach (Touch touch in Touch.activeTouches)
{
double inputEventTime = touch.phase == TouchPhase.Stationary
? InputState.currentTime
: touch.time;
ProcessInputEvent(
touch.touchId,
touch.phase,
touch.screenPosition
touch.screenPosition,
inputEventTime
);
}
}
@@ -339,29 +395,31 @@ namespace Ichni.RhythmGame
/// <summary>
/// 所有输入事件的核心处理函数,无论是真实触摸还是鼠标模拟都会调用它。
/// </summary>
private void ProcessInputEvent(int touchId, TouchPhase phase, Vector2 position)
private void ProcessInputEvent(int touchId, TouchPhase phase, Vector2 position, double inputEventTime)
{
switch (phase)
{
case TouchPhase.Began:
var newState = new TouchState
if (_activeTouches.TryGetValue(touchId, out TouchState existingState))
{
TouchId = touchId,
StartPosition = position,
StartTime = CoreServices.TimeProvider.SongTime,
LastSwipeDirection = Vector2.zero,
isFirstSwipe = true
};
RecycleTouchState(existingState);
}
TouchState newState = GetTouchState(touchId, position);
_activeTouches[touchId] = newState;
OnTap(touchId, position);
OnTouch(touchId, position);
OnTap(touchId, position, inputEventTime);
OnTouch(touchId, position, inputEventTime);
break;
case TouchPhase.Moved:
if (_activeTouches.TryGetValue(touchId, out TouchState movedState))
{
OnTouch(touchId, position);
DetectSwipe(movedState, position);
OnTouch(touchId, position, inputEventTime);
bool emittedNewSwipeSegment = DetectSwipe(movedState, position, inputEventTime);
if (!emittedNewSwipeSegment && !movedState.isFirstSwipe)
{
EmitContinuingSwipe(movedState, position, inputEventTime, true);
}
}
break;
@@ -369,15 +427,21 @@ namespace Ichni.RhythmGame
case TouchPhase.Stationary:
if (_activeTouches.TryGetValue(touchId, out TouchState stationaryState))
{
OnTouch(touchId, position);
OnTouch(touchId, position, inputEventTime);
if (!stationaryState.isFirstSwipe)
{
// Stationary keeps the Swipe available for judgment, but it is not a new movement sample.
EmitContinuingSwipe(stationaryState, position, inputEventTime, false);
}
}
break;
case TouchPhase.Ended:
if (_activeTouches.ContainsKey(touchId))
if (_activeTouches.TryGetValue(touchId, out TouchState endedState))
{
_activeTouches.Remove(touchId);
RecycleTouchState(endedState);
if (SettingsManager.instance.settingsSaveData.debugMode)
{
GenerateEndMark(position);
@@ -387,9 +451,10 @@ namespace Ichni.RhythmGame
break;
case TouchPhase.Canceled:
if (_activeTouches.ContainsKey(touchId))
if (_activeTouches.TryGetValue(touchId, out TouchState canceledState))
{
_activeTouches.Remove(touchId);
RecycleTouchState(canceledState);
if (SettingsManager.instance.settingsSaveData.debugMode)
{
GenerateCanceledMark(position);
@@ -401,24 +466,67 @@ namespace Ichni.RhythmGame
}
/// <summary>
/// 检测划动逻辑 (无需修改)
/// 检测新的划动段。首次超过阈值后,该触点会保持 Swipe 状态直到 Ended/Canceled。
/// </summary>
private void DetectSwipe(TouchState state, Vector2 currentPosition)
private bool DetectSwipe(TouchState state, Vector2 currentPosition, double inputEventTime)
{
Vector2 swipeVector = currentPosition - state.StartPosition;
if (swipeVector.magnitude < minSwipeDistance) return;
Vector2 movementDelta = currentPosition - state.LastPosition;
state.LastPosition = currentPosition;
Vector2 direction = swipeVector.normalized;
// 检查是否是新的划动方向
if (Vector2.Dot(direction, state.LastSwipeDirection) <= swipeAngleThreshold)
if (state.isFirstSwipe)
{
OnSwipe(state.TouchId, state.StartPosition, false, state.isFirstSwipe, direction);
Vector2 initialSwipeVector = currentPosition - state.StartPosition;
if (initialSwipeVector.sqrMagnitude < minSwipeDistance * minSwipeDistance) return false;
Vector2 direction = initialSwipeVector.normalized;
OnSwipe(state.TouchId, currentPosition, false, true, direction, inputEventTime);
state.LastSwipeDirection = direction;
state.StartPosition = currentPosition;
state.StartTime = CoreServices.TimeProvider.SongTime;
state.isFirstSwipe = false;
return true;
}
if (movementDelta.sqrMagnitude > Mathf.Epsilon)
{
Vector2 direction = movementDelta.normalized;
if (Vector2.Dot(direction, state.LastSwipeDirection) <= swipeAngleThreshold)
{
state.LastSwipeDirection = direction;
}
}
// Swipe 激活后,每个移动采样都使用当前触点位置;不再等待新的距离阈值。
EmitContinuingSwipe(state, currentPosition, inputEventTime, true);
return true;
}
private void EmitContinuingSwipe(TouchState state, Vector2 currentPosition, double inputEventTime,
bool showDebugFeedback)
{
OnSwipe(state.TouchId, currentPosition, false, false, state.LastSwipeDirection, inputEventTime,
showDebugFeedback);
}
private TouchState GetTouchState(int touchId, Vector2 startPosition)
{
TouchState state = _touchStatePool.Count > 0 ? _touchStatePool.Pop() : new TouchState();
state.TouchId = touchId;
state.StartPosition = startPosition;
state.LastPosition = startPosition;
state.LastSwipeDirection = Vector2.zero;
state.isFirstSwipe = true;
return state;
}
private void RecycleTouchState(TouchState state)
{
if (state == null) return;
state.TouchId = 0;
state.StartPosition = Vector2.zero;
state.LastPosition = Vector2.zero;
state.LastSwipeDirection = Vector2.zero;
state.isFirstSwipe = true;
_touchStatePool.Push(state);
}
private void GenerateTapMark(int id, Vector2 pos)

View File

@@ -4,7 +4,7 @@ MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: -13
executionOrder: 15
icon: {instanceID: 0}
userData:
assetBundleName:

View File

@@ -1,9 +1,9 @@
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Ichni.RhythmGame;
using Sirenix.OdinInspector;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
namespace Ichni
{
@@ -18,16 +18,14 @@ namespace Ichni
public List<InputUnitTouch> inputUnitTouchList;
public List<InputUnitSwipe> inputUnitSwipeList;
private List<Tap> _availableTapsBuffer = new List<Tap>(30);
private readonly List<NoteBase> _startCandidatesBuffer = new List<NoteBase>(40);
private readonly NoteCandidateComparer _candidateComparer = new NoteCandidateComparer();
private List<Hold> _availableHoldsBuffer = new List<Hold>(10);
private List<Flick> _availableFlicksBuffer = new List<Flick>(10);
private List<Stay> _availableStaysBuffer = new List<Stay>(30);
private List<Tap> _minTapsBuffer = new List<Tap>(5);
private List<Hold> _minHoldsBuffer = new List<Hold>(5);
private List<Flick> _minFlicksBuffer = new List<Flick>(5);
private InputUnitSwipe _cachedAssumedSwipe = new InputUnitSwipe(0, Vector2.zero, true, true, Vector2.zero);
private readonly Stack<InputUnitTap> _tapInputPool = new Stack<InputUnitTap>(10);
private readonly Stack<InputUnitTouch> _touchInputPool = new Stack<InputUnitTouch>(10);
private readonly Stack<InputUnitSwipe> _swipeInputPool = new Stack<InputUnitSwipe>(10);
private readonly InputUnitSwipe _assumedSwipeInput = new InputUnitSwipe();
private void Start()
{
@@ -35,207 +33,84 @@ namespace Ichni
checkingStayList = new List<Stay>();
checkingHoldList = new List<Hold>();
checkingFlickList = new List<Flick>();
inputUnitTapList = new List<InputUnitTap>();
inputUnitTouchList = new List<InputUnitTouch>();
inputUnitSwipeList = new List<InputUnitSwipe>();
inputUnitTapList = new List<InputUnitTap>(10);
inputUnitTouchList = new List<InputUnitTouch>(10);
inputUnitSwipeList = new List<InputUnitSwipe>(10);
}
public void Update()
public void ProcessPendingInputs(float songTime)
{
if (!GameManager.Instance.songPlayer.isPlaying)
{
ClearInputUnits();
return;
}
float songTime = CoreServices.TimeProvider.SongTime;
double inputTime = InputState.currentTime;
// GameInputManager 在 Update 中只入队;这里位于 LateUpdate 的 Note 空间准备之后,
// 使用同一帧已经更新好的判定区域消费 Swipe。
foreach (var swipe in inputUnitSwipeList)
{
_availableFlicksBuffer.Clear();
InputJudgeContext inputContext = CreateInputJudgeContext(swipe, songTime, inputTime);
_startCandidatesBuffer.Clear();
foreach (Flick flick in checkingFlickList)
{
if (flick.CheckJudgeAvailability(swipe))
if (flick.CheckJudgeAvailability(inputContext))
{
_availableFlicksBuffer.Add(flick);
_startCandidatesBuffer.Add(flick);
}
}
if (_availableFlicksBuffer.Count > 0)
{
if (swipe.isFirst) // 原 tapSwipeList 逻辑
{
GetAllMinNotes(_availableFlicksBuffer, _minFlicksBuffer); // 提醒:内部也需防 new List
if (_minFlicksBuffer.Count == 1) _minFlicksBuffer[0].ExecuteStartJudge(songTime);
else if (_minFlicksBuffer.Count > 1) GetNearestNote(_minFlicksBuffer, swipe.inputPosition).ExecuteTapJudge(songTime);
}
else // 原 holdSwipeList 逻辑
{
_availableFlicksBuffer.Sort();
_availableFlicksBuffer[0].ExecuteStartJudge(songTime);
}
}
ExecuteSwipeCandidates(swipe, inputContext);
}
/*foreach (InputUnitSwipe tapSwipe in tapSwipeList)
{
List<Flick> availableFlicks = new List<Flick>();
foreach (Flick flick in checkingFlickList)
{
if (flick.CheckJudgeAvailability(tapSwipe))
{
availableFlicks.Add(flick);
}
}
List<Flick> minFlicks = GetAllMinNotes(availableFlicks);
if (minFlicks.Count == 1)
{
minFlicks[0].ExecuteStartJudge();
}
else if (minFlicks.Count > 1)
{
GetNearestNote(minFlicks, tapSwipe.inputPosition).ExecuteTapJudge();
}
}
foreach (InputUnitSwipe inputUnitSwipe in holdSwipeList)
{
List<Flick> availableFlicks = new List<Flick>();
foreach (Flick flick in checkingFlickList)
{
if (flick.CheckJudgeAvailability(inputUnitSwipe))
{
availableFlicks.Add(flick);
}
}
if (availableFlicks.Count > 0)
{
availableFlicks.Sort();
Flick closestFlick = availableFlicks[0];
closestFlick.ExecuteStartJudge();
}
}*/
foreach (InputUnitTap inputUnitTap in inputUnitTapList)
{
_availableTapsBuffer.Clear();
_availableHoldsBuffer.Clear();
InputJudgeContext inputContext = CreateInputJudgeContext(inputUnitTap, songTime, inputTime);
_startCandidatesBuffer.Clear();
foreach (Tap tap in checkingTapList)
{
if (tap.CheckJudgeAvailability(inputUnitTap)) _availableTapsBuffer.Add(tap);
if (tap.CheckJudgeAvailability(inputContext)) _startCandidatesBuffer.Add(tap);
}
foreach (Hold hold in checkingHoldList)
{
if (hold.CheckJudgeAvailability(inputUnitTap)) _availableHoldsBuffer.Add(hold);
if (hold.CheckJudgeAvailability(inputContext)) _startCandidatesBuffer.Add(hold);
}
bool haveTap = _availableTapsBuffer.Count > 0;
bool haveHold = _availableHoldsBuffer.Count > 0;
NoteBase selectedCandidate = SelectStartCandidate(_startCandidatesBuffer, inputContext,
fallbackToFirst: true);
if (selectedCandidate == null) continue;
Flick closestFlick = null;
InputUnitSwipe assumedSwipe = new InputUnitSwipe(inputUnitTap.fingerId, inputUnitTap.inputPosition, true, true, Vector2.zero);
_assumedSwipeInput.Set(inputUnitTap.fingerId, inputUnitTap.inputPosition,
true, true, Vector2.zero, inputUnitTap.inputEventTime);
InputJudgeContext assumedSwipeContext = inputContext.WithInputUnit(_assumedSwipeInput);
_startCandidatesBuffer.Clear();
foreach (Flick flick in checkingFlickList)
{
if (flick.CheckJudgeAvailability(assumedSwipe))
if (flick.CheckJudgeAvailability(assumedSwipeContext))
{
if (closestFlick == null || flick.exactJudgeTime < closestFlick.exactJudgeTime)
{
closestFlick = flick;
}
_startCandidatesBuffer.Add(flick);
}
}
if (haveHold && haveTap)
{
GetAllMinNotes(_availableHoldsBuffer, _minHoldsBuffer);
GetAllMinNotes(_availableTapsBuffer, _minTapsBuffer);
Hold closestHold = _minHoldsBuffer[0];
Tap closestTap = _minTapsBuffer[0];
bool holdBlockedByFlick = false;
bool tapBlockedByFlick = false;
Flick blockingFlick = SelectStartCandidate(_startCandidatesBuffer, assumedSwipeContext,
fallbackToFirst: false) as Flick;
if (blockingFlick != null &&
blockingFlick.exactJudgeTime < selectedCandidate.exactJudgeTime - 0.05f) continue;
if (closestFlick != null)
{
if (closestFlick.exactJudgeTime < closestHold.exactJudgeTime - 0.05f)
{
holdBlockedByFlick = true;
}
if (closestFlick.exactJudgeTime < closestTap.exactJudgeTime - 0.05f)
{
tapBlockedByFlick = true;
}
}
if (!holdBlockedByFlick && closestHold.exactJudgeTime < closestTap.exactJudgeTime - 0.05f)
{
if (_minHoldsBuffer.Count == 1)
{
_minHoldsBuffer[0].ExecuteStartJudge(songTime);
}
else
{
GetNearestNote(_minHoldsBuffer, inputUnitTap.inputPosition).ExecuteStartJudge(songTime);
}
}
else if (!tapBlockedByFlick)
{
if (_minTapsBuffer.Count == 1)
{
_minTapsBuffer[0].ExecuteStartJudge(songTime);
}
else
{
GetNearestNote(_minTapsBuffer, inputUnitTap.inputPosition).ExecuteStartJudge(songTime);
}
}
}
else if (haveHold)
{
GetAllMinNotes(_availableHoldsBuffer, _minHoldsBuffer);
bool holdBlockedByFlick = closestFlick != null && closestFlick.exactJudgeTime < _minHoldsBuffer[0].exactJudgeTime - 0.05f;
if (!holdBlockedByFlick)
{
if (_minHoldsBuffer.Count == 1)
{
_minHoldsBuffer[0].ExecuteStartJudge(songTime);
}
else
{
GetNearestNote(_minHoldsBuffer, inputUnitTap.inputPosition).ExecuteStartJudge(songTime);
}
}
}
else if (haveTap)
{
GetAllMinNotes(_availableTapsBuffer, _minTapsBuffer);
bool tapBlockedByFlick = closestFlick != null && closestFlick.exactJudgeTime < _minTapsBuffer[0].exactJudgeTime - 0.05f;
if (!tapBlockedByFlick)
{
if (_minTapsBuffer.Count == 1)
{
_minTapsBuffer[0].ExecuteStartJudge(songTime);
}
else
{
GetNearestNote(_minTapsBuffer, inputUnitTap.inputPosition).ExecuteStartJudge(songTime);
}
}
}
selectedCandidate.ExecuteStartJudge(inputContext.InputSongTime);
}
foreach (InputUnitTouch inputUnitTouch in inputUnitTouchList)
{
InputJudgeContext inputContext = CreateInputJudgeContext(inputUnitTouch, songTime, inputTime);
_availableStaysBuffer.Clear();
foreach (Stay stay in checkingStayList)
{
if (stay.CheckJudgeAvailability(inputUnitTouch))
if (stay.CheckJudgeAvailability(inputContext))
{
_availableStaysBuffer.Add(stay);
}
@@ -244,7 +119,7 @@ namespace Ichni
_availableHoldsBuffer.Clear();
foreach (Hold hold in checkingHoldList)
{
if (hold.CheckJudgeAvailability(inputUnitTouch))
if (hold.CheckJudgeAvailability(inputContext))
{
_availableHoldsBuffer.Add(hold);
}
@@ -252,7 +127,7 @@ namespace Ichni
foreach (Stay stay in _availableStaysBuffer)
{
stay.ExecuteStartJudge(songTime);
stay.ExecuteStartJudge(inputContext.InputSongTime);
}
foreach (Hold hold in _availableHoldsBuffer)
@@ -261,76 +136,235 @@ namespace Ichni
}
}
inputUnitTapList.Clear();
inputUnitTouchList.Clear();
inputUnitSwipeList.Clear();
ClearInputUnits();
}
public void DiscardPendingInputs()
{
ClearInputUnits();
}
private void ClearInputUnits()
{
if (inputUnitTapList != null)
{
for (int i = 0; i < inputUnitTapList.Count; i++) _tapInputPool.Push(inputUnitTapList[i]);
inputUnitTapList.Clear();
}
if (inputUnitTouchList != null)
{
for (int i = 0; i < inputUnitTouchList.Count; i++) _touchInputPool.Push(inputUnitTouchList[i]);
inputUnitTouchList.Clear();
}
if (inputUnitSwipeList != null)
{
for (int i = 0; i < inputUnitSwipeList.Count; i++) _swipeInputPool.Push(inputUnitSwipeList[i]);
inputUnitSwipeList.Clear();
}
}
private static float ResolveInputSongTime(InputUnit inputUnit, float currentSongTime, double currentInputTime)
{
// Input System 时间戳与 currentTime 使用同一时钟。用队列年龄回推 SongTime
// 避免 LateUpdate 消费时把输入时刻错误替换为更晚的当前歌曲时间。
double inputAge = currentInputTime - inputUnit.inputEventTime;
if (double.IsNaN(inputAge) || double.IsInfinity(inputAge) || inputAge < 0d)
{
inputAge = 0d;
}
return currentSongTime - (float)inputAge;
}
private static InputJudgeContext CreateInputJudgeContext(InputUnit inputUnit, float currentSongTime,
double currentInputTime)
{
return new InputJudgeContext(inputUnit,
ResolveInputSongTime(inputUnit, currentSongTime, currentInputTime));
}
public void SetNewInputUnitTap(int fingerId, Vector2 inputPosition)
{
InputUnitTap inputUnitTap = new InputUnitTap(fingerId, inputPosition);
if(!inputUnitTapList.Exists(x => x.fingerId == fingerId))
{
inputUnitTapList.Add(inputUnitTap);
}
SetNewInputUnitTap(fingerId, inputPosition, InputState.currentTime);
}
public void SetNewInputUnitTap(int fingerId, Vector2 inputPosition, double inputEventTime)
{
if (ContainsFinger(inputUnitTapList, fingerId)) return;
InputUnitTap inputUnitTap = _tapInputPool.Count > 0 ? _tapInputPool.Pop() : new InputUnitTap();
inputUnitTap.Set(fingerId, inputPosition, inputEventTime);
inputUnitTapList.Add(inputUnitTap);
}
public void SetNewInputUnitTouch(int fingerId, Vector2 inputPosition)
{
InputUnitTouch inputUnitTouch = new InputUnitTouch(fingerId, inputPosition);
if(!inputUnitTouchList.Exists(x => x.fingerId == fingerId))
{
inputUnitTouchList.Add(inputUnitTouch);
}
SetNewInputUnitTouch(fingerId, inputPosition, InputState.currentTime);
}
public void SetNewInputUnitTouch(int fingerId, Vector2 inputPosition, double inputEventTime)
{
if (ContainsFinger(inputUnitTouchList, fingerId)) return;
InputUnitTouch inputUnitTouch = _touchInputPool.Count > 0 ? _touchInputPool.Pop() : new InputUnitTouch();
inputUnitTouch.Set(fingerId, inputPosition, inputEventTime);
inputUnitTouchList.Add(inputUnitTouch);
}
public void SetNewInputUnitSwipe(int fingerId, Vector2 inputPosition, bool isGeneric, bool isFirst, Vector2 delta)
{
InputUnitSwipe inputUnitSwipe = new InputUnitSwipe(fingerId, inputPosition, isGeneric, isFirst, delta);
if(!inputUnitSwipeList.Exists(x => x.fingerId == fingerId))
SetNewInputUnitSwipe(fingerId, inputPosition, isGeneric, isFirst, delta, InputState.currentTime);
}
public void SetNewInputUnitSwipe(int fingerId, Vector2 inputPosition, bool isGeneric, bool isFirst, Vector2 delta, double inputEventTime)
{
// 每根手指每帧只保留一个 Swipe 样本;下一帧队列清空后,同一条长划动仍可继续触发 Flick。
if (ContainsFinger(inputUnitSwipeList, fingerId)) return;
InputUnitSwipe inputUnitSwipe = _swipeInputPool.Count > 0 ? _swipeInputPool.Pop() : new InputUnitSwipe();
inputUnitSwipe.Set(fingerId, inputPosition, isGeneric, isFirst, delta, inputEventTime);
inputUnitSwipeList.Add(inputUnitSwipe);
}
private static bool ContainsFinger<TInput>(List<TInput> inputs, int fingerId) where TInput : InputUnit
{
for (int i = 0; i < inputs.Count; i++)
{
inputUnitSwipeList.Add(inputUnitSwipe);
if (inputs[i].fingerId == fingerId) return true;
}
return false;
}
}
public partial class NoteJudgeManager
{
private void GetAllMinNotes<T>(List<T> availableFlicks, List<T> destination) where T : NoteBase
private void ExecuteSwipeCandidates(InputUnitSwipe swipe, InputJudgeContext context)
{
destination.Clear();
float minTime = float.MaxValue;
foreach (T note in availableFlicks)
// 单个 Swipe 样本只选择一个候选;长划动会在后续帧继续产生样本,
// 因而可以依次触发后续 Flick而不会让同一输入广播命中全部重叠 Note。
Flick selectedFlick = SelectStartCandidate(_startCandidatesBuffer, context,
fallbackToFirst: true) as Flick;
if (selectedFlick == null) return;
ExecuteSwipeJudge(selectedFlick, swipe, context.InputSongTime);
}
private void ExecuteSwipeJudge(Flick flick, InputUnitSwipe swipe, float inputSongTime)
{
if (swipe.isFirst && CountCandidatesAtJudgeTime(flick.exactJudgeTime) > 1)
{
if (note.exactJudgeTime < minTime)
{
minTime = note.exactJudgeTime;
destination.Clear();
destination.Add(note);
}
else if (Mathf.Approximately(note.exactJudgeTime, minTime))
{
destination.Add(note);
}
flick.ExecuteTapJudge(inputSongTime);
}
else
{
flick.ExecuteStartJudge(inputSongTime);
}
}
private T GetNearestNote<T>(List<T> notes, Vector2 inputPosition) where T : NoteBase
private NoteBase SelectStartCandidate(List<NoteBase> candidates, InputJudgeContext context,
bool fallbackToFirst)
{
float minDistance = float.MaxValue;
T closestNote = null;
foreach (T note in notes)
if (candidates.Count == 0) return null;
_candidateComparer.SetContext(context);
candidates.Sort(_candidateComparer);
for (int i = 0; i < candidates.Count; i++)
{
float distance = Vector2.Distance(inputPosition, note.noteScreenPosition);
if (distance < minDistance)
// 这里只预览判定等级,不修改 Combo、音频、特效或 Note 生命周期。
NoteBase.NoteJudgeType previewType = candidates[i].PreviewStartJudgeType(context.InputSongTime);
if (previewType is NoteBase.NoteJudgeType.Perfect or NoteBase.NoteJudgeType.Good)
{
minDistance = distance;
closestNote = note;
return candidates[i];
}
}
return closestNote;
return fallbackToFirst ? candidates[0] : null;
}
private int CountCandidatesAtJudgeTime(float exactJudgeTime)
{
int count = 0;
for (int i = 0; i < _startCandidatesBuffer.Count; i++)
{
if (Mathf.Approximately(_startCandidatesBuffer[i].exactJudgeTime, exactJudgeTime))
{
count++;
}
}
return count;
}
private sealed class NoteCandidateComparer : IComparer<NoteBase>
{
private Vector2 _inputPosition;
private float _inputSongTime;
public void SetContext(InputJudgeContext context)
{
_inputPosition = context.InputPosition;
_inputSongTime = context.InputSongTime;
}
public int Compare(NoteBase left, NoteBase right)
{
if (ReferenceEquals(left, right)) return 0;
if (left == null) return 1;
if (right == null) return -1;
int timeComparison = left.exactJudgeTime.CompareTo(right.exactJudgeTime);
if (timeComparison != 0) return timeComparison;
float leftDistance = (GetCandidateScreenPosition(left, _inputSongTime) - _inputPosition).sqrMagnitude;
float rightDistance = (GetCandidateScreenPosition(right, _inputSongTime) - _inputPosition).sqrMagnitude;
int distanceComparison = leftDistance.CompareTo(rightDistance);
if (distanceComparison != 0) return distanceComparison;
return left.elementGuid.CompareTo(right.elementGuid);
}
private static Vector2 GetCandidateScreenPosition(NoteBase note, float inputSongTime)
{
if (note.NoteJudgeSubmodule?.judgeUnitList != null)
{
for (int i = 0; i < note.NoteJudgeSubmodule.judgeUnitList.Count; i++)
{
if (note.NoteJudgeSubmodule.judgeUnitList[i] is TouchAreaJudgeUnit touchArea)
{
return touchArea.GetJudgeCenter(inputSongTime);
}
}
}
return note.noteScreenPosition;
}
}
}
public readonly struct InputJudgeContext
{
// InputUnit 保存设备事件InputSongTime 保存该事件映射到谱面时间轴后的发生时刻。
// JudgeUnit 必须使用本上下文,不能在判定途中重新读取当前 SongTime。
public InputUnit InputUnit { get; }
public float InputSongTime { get; }
public int FingerId => InputUnit.fingerId;
public Vector2 InputPosition => InputUnit.inputPosition;
public double InputEventTime => InputUnit.inputEventTime;
public InputJudgeContext(InputUnit inputUnit, float inputSongTime)
{
InputUnit = inputUnit ?? throw new System.ArgumentNullException(nameof(inputUnit));
InputSongTime = inputSongTime;
}
public InputJudgeContext WithInputUnit(InputUnit inputUnit)
{
return new InputJudgeContext(inputUnit, InputSongTime);
}
}
@@ -338,23 +372,54 @@ namespace Ichni
{
public int fingerId;
public Vector2 inputPosition;
public double inputEventTime;
}
public class InputUnitTap : InputUnit
{
public InputUnitTap()
{
}
public InputUnitTap(int fingerId, Vector2 inputPosition)
: this(fingerId, inputPosition, InputState.currentTime)
{
}
public InputUnitTap(int fingerId, Vector2 inputPosition, double inputEventTime)
{
Set(fingerId, inputPosition, inputEventTime);
}
public void Set(int fingerId, Vector2 inputPosition, double inputEventTime)
{
this.fingerId = fingerId;
this.inputPosition = inputPosition;
this.inputEventTime = inputEventTime;
}
}
public class InputUnitTouch : InputUnit
{
public InputUnitTouch()
{
}
public InputUnitTouch(int fingerId, Vector2 inputPosition)
: this(fingerId, inputPosition, InputState.currentTime)
{
}
public InputUnitTouch(int fingerId, Vector2 inputPosition, double inputEventTime)
{
Set(fingerId, inputPosition, inputEventTime);
}
public void Set(int fingerId, Vector2 inputPosition, double inputEventTime)
{
this.fingerId = fingerId;
this.inputPosition = inputPosition;
this.inputEventTime = inputEventTime;
}
}
@@ -363,10 +428,27 @@ namespace Ichni
public Vector2 swipeDirection;
public bool isGeneric;
public bool isFirst;
public InputUnitSwipe()
{
}
public InputUnitSwipe(int fingerId, Vector2 inputPosition, bool isGeneric, bool isFirst, Vector2 swipeDirection)
: this(fingerId, inputPosition, isGeneric, isFirst, swipeDirection, InputState.currentTime)
{
}
public InputUnitSwipe(int fingerId, Vector2 inputPosition, bool isGeneric, bool isFirst, Vector2 swipeDirection,
double inputEventTime)
{
Set(fingerId, inputPosition, isGeneric, isFirst, swipeDirection, inputEventTime);
}
public void Set(int fingerId, Vector2 inputPosition, bool isGeneric, bool isFirst, Vector2 swipeDirection,
double inputEventTime)
{
this.fingerId = fingerId;
this.inputPosition = inputPosition;
this.inputEventTime = inputEventTime;
this.isGeneric = isGeneric;
this.isFirst = isFirst;
this.swipeDirection = swipeDirection.normalized;

View File

@@ -4,7 +4,7 @@ MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: -12
executionOrder: 18
icon: {instanceID: 0}
userData:
assetBundleName:

View File

@@ -28,7 +28,7 @@ namespace Ichni.RhythmGame
#endregion
#region [] Manager-Driven Tick
public void ManualUpdate(float currentSongTime)
public void ManualPrepareForJudge(float currentSongTime)
{
if (!GameManager.Instance.songPlayer.isUpdating) return;
@@ -42,10 +42,21 @@ namespace Ichni.RhythmGame
_nextNoteIndex++;
}
// 2. 集中处理场内活跃 Note (倒序遍历以防越界)
// 2. 判定前更新场内 Note 的最终空间状态与候选列表。
for (int i = _activeNotes.Count - 1; i >= 0; i--)
{
if (!_activeNotes[i].ManualUpdate(currentSongTime))
_activeNotes[i].ManualPrepareForJudge(currentSongTime);
}
}
public void ManualResolveAfterJudge(float currentSongTime)
{
if (!GameManager.Instance.songPlayer.isUpdating) return;
// 输入判定完成后再处理 Miss、Hold buffer、特效与销毁。
for (int i = _activeNotes.Count - 1; i >= 0; i--)
{
if (!_activeNotes[i].ManualResolveAfterJudge(currentSongTime))
{
_activeNotes.RemoveAt(i);
}

View File

@@ -4,7 +4,7 @@ MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
executionOrder: -14
icon: {instanceID: 0}
userData:
assetBundleName:

View File

@@ -1,6 +1,6 @@
ManifestFileVersion: 0
UnityVersion: 6000.3.7f1
CRC: 3109658480
CRC: 53704479
HashAppended: 0
AssetBundleManifest:
AssetBundleInfos:

View File

@@ -1,16 +1,16 @@
ManifestFileVersion: 0
UnityVersion: 6000.3.7f1
CRC: 2950436852
CRC: 3236759806
Hashes:
AssetFileHash:
serializedVersion: 2
Hash: d96a269b1a7c59262f46b605d1f572d6
Hash: 83fbc969fd57eaab900477c21478720c
TypeTreeHash:
serializedVersion: 2
Hash: dfed41e3b38758e5f34df30221d719dc
IncrementalBuildHash:
serializedVersion: 2
Hash: debfd94a19fd386044ef29acc180950b
Hash: 5415c53667acfa3eee32fdad43ac1d0a
HashAppended: 0
ClassTypes:
- Class: 1

View File

@@ -1,16 +1,16 @@
ManifestFileVersion: 0
UnityVersion: 6000.3.7f1
CRC: 3768715475
CRC: 4192970564
Hashes:
AssetFileHash:
serializedVersion: 2
Hash: cee2c3b5a44828ab368863b9490257c3
Hash: d290c68c3266302b3a1da71ea21801c4
TypeTreeHash:
serializedVersion: 2
Hash: 8b453097cf816e7e6fe791950bd1564f
Hash: 6d9f9b8bc1074349261a0dbbf7f0f9f8
IncrementalBuildHash:
serializedVersion: 2
Hash: 7a3537e33b1ecab07e9a0bb7956f55bd
Hash: 98fb70226d12a3b6d725a6203ec4c3bc
HashAppended: 0
ClassTypes:
- Class: 1
@@ -37,6 +37,10 @@ ClassTypes:
Script: {fileID: 11500000, guid: 0def5156137c6d14082064fa7b5d5247, type: 3}
- Class: 114
Script: {fileID: 11500000, guid: 77ab80dc0820f9d4a8cd9fddbbc89057, type: 3}
- Class: 114
Script: {fileID: 11500000, guid: 2cc178b2441bca14eb1321c83761ab6f, type: 3}
- Class: 114
Script: {fileID: 11500000, guid: 8ba3055fc57abcb41811796e6a0ed3aa, type: 3}
- Class: 114
Script: {fileID: 11500000, guid: e8bab02f6d349f6418ccfe0674b93013, type: 3}
- Class: 114
@@ -47,10 +51,14 @@ ClassTypes:
Script: {fileID: 11500000, guid: 677671f1d5140b2489dc52c18792b8f5, type: 3}
- Class: 114
Script: {fileID: 11500000, guid: f853de2eedbaded4489d7b898fef5926, type: 3}
- Class: 114
Script: {fileID: 11500000, guid: 23b750fa4c234eb995b6be5e65b56dba, type: 3}
- Class: 114
Script: {fileID: 11500000, guid: 638fbd0e919667f47a93485dd230c71f, type: 3}
- Class: 114
Script: {fileID: 11500000, guid: eb907071d10d7324995ff3aefbc2f1df, type: 3}
- Class: 114
Script: {fileID: 11500000, guid: 922e341faf5784c41b3b0466e0468972, type: 3}
- Class: 115
Script: {instanceID: 0}
- Class: 135
@@ -81,6 +89,7 @@ SerializeReferenceClassIdentifiers:
- AssemblyName: Sirenix.Serialization
ClassName: Sirenix.Serialization.SerializationData
Assets:
- Assets/ThemeBundles/DepartureToMultiverse/Prefabs/Environment/DTM_CircleSpinTrails.prefab
- Assets/ThemeBundles/DepartureToMultiverse/Prefabs/DTM_NoteVisualTap.prefab
- Assets/ThemeBundles/DepartureToMultiverse/Materials/Skybox/DTM_Skybox_Starry.mat
- Assets/ThemeBundles/DepartureToMultiverse/Prefabs/Environment/DTM_RandomGridTube.prefab
@@ -88,8 +97,10 @@ Assets:
- Assets/ThemeBundles/DepartureToMultiverse/Textures/Skybox/DTM_SunMask0.png
- Assets/ThemeBundles/DepartureToMultiverse/Prefabs/DTM_NoteVisualFlick.prefab
- Assets/ThemeBundles/DepartureToMultiverse/Prefabs/DTM_Trail2.prefab
- Assets/ThemeBundles/DepartureToMultiverse/Prefabs/Decorations/DTM_ConstellationCube.prefab
- Assets/ThemeBundles/DepartureToMultiverse/Prefabs/Environment/DTM_GlobalFog.prefab
- Assets/ThemeBundles/DepartureToMultiverse/Prefabs/DTM_Trail.prefab
- Assets/ThemeBundles/DepartureToMultiverse/Prefabs/Decorations/Constellation.prefab
- Assets/ThemeBundles/DepartureToMultiverse/Textures/Skybox/DTM_SunMask1.png
- Assets/ThemeBundles/DepartureToMultiverse/Prefabs/Environment/DTM_StarrySkybox.prefab
- Assets/ThemeBundles/DepartureToMultiverse/Prefabs/DTM_NoteVisualHold.prefab

View File

@@ -1,16 +1,16 @@
ManifestFileVersion: 0
UnityVersion: 6000.3.7f1
CRC: 3125059414
CRC: 1833553277
Hashes:
AssetFileHash:
serializedVersion: 2
Hash: da0193547f358bc1bf0378e49649aa75
Hash: e08a9beb2e31cec7d5d5c5f2ed2bcccf
TypeTreeHash:
serializedVersion: 2
Hash: 8ab77e54d824a64c30bc20496c5f5926
IncrementalBuildHash:
serializedVersion: 2
Hash: 284ba86ec2440e4ef2177b0e314946a9
Hash: 93d69c94859430eb482017e88174278c
HashAppended: 0
ClassTypes:
- Class: 1

View File

@@ -10,7 +10,7 @@ Hashes:
Hash: bc9f87c7e64ff656997db0f2b2a91703
IncrementalBuildHash:
serializedVersion: 2
Hash: 02555d72e66a23410baf71e006ec9a39
Hash: bac15f27dfb157ff22724a110e8702fe
HashAppended: 0
ClassTypes:
- Class: 28

View File

@@ -28,6 +28,12 @@ namespace Ichni.RhythmGame.ThemeBundles.DepartureToMultiverse
{
effectParticle = LeanPool.Spawn(noteVisual.effectPrefabList[2], noteVisual.judgeEffect.transform).GetComponent<ParticleSystem>();
effectParticle.transform.SetParent(GameManager.Instance.cameraManager.gameCamera.transform);
if (note.TryGetJudgedEffectPose(out Pose pose))
{
effectParticle.transform.SetPositionAndRotation(pose.position, pose.rotation);
}
effectParticle.Play();
noteVisual.noteMain.SetActive(false);

View File

@@ -28,6 +28,12 @@ namespace Ichni.RhythmGame.ThemeBundles.DepartureToMultiverse
{
effectParticle = LeanPool.Spawn(noteVisual.effectPrefabList[1], noteVisual.judgeEffect.transform).GetComponent<ParticleSystem>();
effectParticle.transform.SetParent(GameManager.Instance.cameraManager.gameCamera.transform);
if (note.TryGetJudgedEffectPose(out Pose pose))
{
effectParticle.transform.SetPositionAndRotation(pose.position, pose.rotation);
}
effectParticle.Play();
noteVisual.noteMain.SetActive(false);

View File

@@ -25,12 +25,24 @@ namespace Ichni.RhythmGame.ThemeBundles.DepartureToMultiverse
noteVisual.noteMain.SetActive(true);
}
public override void PrepareJudgedEffectPose()
{
// DTM 的 Perfect 打击点需要与判定当帧的轨道头对齐,并在移动后立即由 Note 缓存世界姿态。
note.SetPerfectPosition();
}
public override void Adjust()
{
note.SetPerfectPosition();
//effectParticle = LeanPool.Spawn(noteVisual.effectPrefabList[0], noteVisual.judgeEffect.transform).GetComponent<ParticleSystem>();
effectParticle = LeanPool.Spawn(noteVisual.effectPrefabList[0], noteVisual.judgeEffect.transform).GetComponent<ParticleSystem>();
effectParticle.transform.SetParent(GameManager.Instance.cameraManager.gameCamera.transform);
// 摄像机或 Track 可能与判定发生在同一时间点切换,粒子必须回到判定当帧缓存的位置。
if (note.TryGetJudgedEffectPose(out Pose pose))
{
effectParticle.transform.SetPositionAndRotation(pose.position, pose.rotation);
}
effectParticle.Play();
noteVisual.noteMain.SetActive(false);

File diff suppressed because it is too large Load Diff