展前冒死重构

This commit is contained in:
FloatGaming
2026-07-24 04:43:13 +08:00
parent d09597472b
commit 4eb70b0ac9
61 changed files with 74951 additions and 328 deletions
@@ -1,5 +1,7 @@
using UnityEngine;
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using System.Collections.Generic;
public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
{
@@ -23,51 +25,82 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
public GameObject good_judge_prefab;
public GameObject miss_judge_prefab;
[Header("Inspector")]
public GameObject trackSkill_prefab;
[Header("Inspector")]
public float baseScale = 1.0f;
public bool useRandomScale = true;
public float minScaleMultiplier = 0.8f;
public float maxScaleMultiplier = 1.2f;
[Header("Inspector")]
public float jumpForce = 5.0f; // Documentation text normalized.
public float gravity = -16f; // Documentation text normalized.
[Tooltip("Horizontal drift distance range for judge popup text. Adds a real arc instead of a straight pop.")]
public Vector2 horizontalDriftRange = new Vector2(0.25f, 0.65f);
[Tooltip("Random Z rotation range applied over the popup lifetime.")]
public float rotationRange = 8f;
[Tooltip("Temporary scale punch at the beginning of the popup.")]
public float scalePunch = 0.18f;
[Tooltip("How long the initial scale punch takes before settling back.")]
public float scalePunchDuration = 0.18f;
[System.Serializable]
public class PopupMotionSettings
{
public float jumpForce = 5.0f;
public float gravity = -16f;
[Tooltip("Horizontal drift distance range for popup text. Adds a real arc instead of a straight pop.")]
public Vector2 horizontalDriftRange = new Vector2(0.25f, 0.65f);
[Tooltip("Random Z rotation range applied over the popup lifetime.")]
public float rotationRange = 8f;
[Tooltip("Temporary scale punch at the beginning of the popup.")]
public float scalePunch = 0.18f;
[Tooltip("How long the initial scale punch takes before settling back.")]
public float scalePunchDuration = 0.18f;
public float fadeInTime = 0.1f;
public float fadeOutStartTime = 0.6f;
public float fadeOutDuration = 0.35f;
}
[Header("Inspector")]
public float fadeInTime = 0.1f; // Documentation text normalized.
public float fadeOutStartTime = 0.6f; // Documentation text normalized.
public float fadeOutDuration = 0.35f; // Documentation text normalized.
[Header("Judge Popup")]
public PopupMotionSettings judgePopup = new PopupMotionSettings()
{
jumpForce = 7.2f,
gravity = -16f,
horizontalDriftRange = new Vector2(0.25f, 0.65f),
rotationRange = 8f,
scalePunch = 0.18f,
scalePunchDuration = 0.18f,
fadeInTime = 0.1f,
fadeOutStartTime = 0.3f,
fadeOutDuration = 0.1f
};
[Header("Track Skill Popup")]
public PopupMotionSettings trackSkillPopup = new PopupMotionSettings()
{
jumpForce = 6.8f,
gravity = -12.5f,
horizontalDriftRange = new Vector2(0.18f, 0.48f),
rotationRange = 6f,
scalePunch = 0.14f,
scalePunchDuration = 0.16f,
fadeInTime = 0.08f,
fadeOutStartTime = 0.36f,
fadeOutDuration = 0.1f
};
[Header("Track Skill UI")]
[Tooltip("UI prefab spawned under world-space Effect nodes needs a small world scale. Judge SpriteRenderer prefabs still use baseScale directly.")]
public float trackSkillUiScaleMultiplier = 0.01f;
public int trackSkillSortingOrder = 9000;
// Cache spawn transforms to avoid accessing destroyed GameObject references.
private Transform redSpawn;
private Transform greenSpawn;
private Transform yellowSpawn;
private Transform purpleSpawn;
private Transform blueSpawn;
// Track last scene to detect scene changes
private int lastSceneIndex = -1;
private Coroutine prewarmJudgeCoroutine;
private readonly System.Collections.Generic.Dictionary<GameObject, System.Collections.Generic.Stack<GameObject>> judgePools
= new System.Collections.Generic.Dictionary<GameObject, System.Collections.Generic.Stack<GameObject>>();
private readonly System.Collections.Generic.HashSet<GameObject> rentedJudges
= new System.Collections.Generic.HashSet<GameObject>();
private readonly Dictionary<GameObject, Stack<GameObject>> judgePools = new Dictionary<GameObject, Stack<GameObject>>();
private readonly HashSet<GameObject> rentedJudges = new HashSet<GameObject>();
private void Awake()
{
if (Instance == null)
{
Instance = this;
// Documentation text normalized.
// Documentation text normalized.
}
else if (Instance != this)
{
@@ -81,13 +114,11 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
private void Start()
{
// Ensure spawn points are cached after all scene objects are initialized
CacheSpawnPointsIfNeeded();
}
private void OnEnable()
{
// Detect scene reload and refresh cache
int currentSceneIndex = UnityEngine.SceneManagement.SceneManager.GetActiveScene().buildIndex;
if (currentSceneIndex != lastSceneIndex)
{
@@ -103,10 +134,8 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
private void CacheSpawnPointsIfNeeded()
{
// Only recache if any of the references appear to be invalid (destroyed)
if (redSpawn == null || greenSpawn == null || yellowSpawn == null || purpleSpawn == null || blueSpawn == null)
{
// Check if the effect GameObjects are still valid
bool anyInvalid = (redEffect == null || redEffect.transform == null) ||
(greenEffect == null || greenEffect.transform == null) ||
(yellowEffect == null || yellowEffect.transform == null) ||
@@ -122,16 +151,15 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
private void CacheSpawnPoints()
{
// Important: check Unity "fake null" before accessing .transform
redSpawn = redEffect != null ? redEffect.transform : null;
greenSpawn = greenEffect != null ? greenEffect.transform : null;
yellowSpawn = yellowEffect != null ? yellowEffect.transform : null;
purpleSpawn = purpleEffect != null ? purpleEffect.transform : null;
blueSpawn = blueEffect != null ? blueEffect.transform : null;
if (JudgeManager.IsDebugEnabled)
{
Debug.Log($"[Animation_Generate] Cached spawn points: red={redSpawn!=null}, green={greenSpawn!=null}, yellow={yellowSpawn!=null}, purple={purpleSpawn!=null}, blue={blueSpawn!=null}");
Debug.Log($"[Animation_Generate] Cached spawn points: red={redSpawn != null}, green={greenSpawn != null}, yellow={yellowSpawn != null}, purple={purpleSpawn != null}, blue={blueSpawn != null}");
}
}
@@ -172,13 +200,37 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
instance.transform.localPosition = Vector3.zero;
instance.transform.localRotation = Quaternion.identity;
// Documentation text normalized.
float finalScale = baseScale;
if (useRandomScale) finalScale *= Random.Range(minScaleMultiplier, maxScaleMultiplier);
instance.transform.localScale = new Vector3(finalScale, finalScale, 1f);
// Documentation text normalized.
StartCoroutine(AnimateSprite(instance));
StartCoroutine(AnimatePopup(instance, judgePopup, false));
}
public void SpawnTrackSkillPrefab(string color, Sprite skillIcon, string skillName)
{
if (string.IsNullOrEmpty(color) || trackSkill_prefab == null)
return;
Transform spawnPoint = GetSpawnPoint(color);
if (spawnPoint == null)
return;
GameObject instance = Instantiate(trackSkill_prefab, spawnPoint, false);
if (instance == null)
return;
EnsureRenderableTrackSkillInstance(instance);
BindTrackSkillContent(instance, skillIcon, skillName);
instance.transform.localPosition = Vector3.zero;
instance.transform.localRotation = Quaternion.identity;
float finalScale = baseScale;
if (useRandomScale) finalScale *= Random.Range(minScaleMultiplier, maxScaleMultiplier);
ApplyTrackSkillScale(instance, finalScale);
instance.SetActive(true);
StartCoroutine(AnimatePopup(instance, trackSkillPopup, true));
}
public void PrewarmJudgePrefabs(int perPrefab = 1)
@@ -231,78 +283,98 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
prewarmJudgeCoroutine = null;
}
private IEnumerator AnimateSprite(GameObject obj)
private IEnumerator AnimatePopup(GameObject obj, PopupMotionSettings settings, bool destroyAtEnd = false)
{
if (obj == null) yield break;
if (settings == null) settings = judgePopup;
SpriteRenderer[] renderers = obj.GetComponentsInChildren<SpriteRenderer>(true);
Graphic[] graphics = obj.GetComponentsInChildren<Graphic>(true);
CanvasGroup canvasGroup = obj.GetComponent<CanvasGroup>();
float elapsed = 0f;
float vVelocity = jumpForce;
float totalLifeTime = fadeOutStartTime + fadeOutDuration;
float vVelocity = settings.jumpForce;
float fadeDuration = Mathf.Max(0.001f, settings.fadeOutDuration);
float estimatedApexTime = settings.gravity < -0.001f ? Mathf.Max(0f, settings.jumpForce / -settings.gravity) : settings.fadeOutStartTime;
float totalLifeTime = Mathf.Max(settings.fadeInTime + fadeDuration, estimatedApexTime + fadeDuration);
float driftSign = Random.value < 0.5f ? -1f : 1f;
float driftDistance = Random.Range(horizontalDriftRange.x, horizontalDriftRange.y) * driftSign;
float driftDistance = Random.Range(settings.horizontalDriftRange.x, settings.horizontalDriftRange.y) * driftSign;
float horizontalVelocity = totalLifeTime > 0.001f ? driftDistance / totalLifeTime : 0f;
float targetRotation = Random.Range(-rotationRange, rotationRange);
float targetRotation = Random.Range(-settings.rotationRange, settings.rotationRange);
Vector3 currentLocalPos = Vector3.zero;
Vector3 baseScaleValue = obj.transform.localScale;
bool fadeOutStarted = false;
float fadeOutElapsed = 0f;
float fadeOutStartAlpha = 1f;
float currentAlpha = 0f;
SetRenderersAlpha(renderers, 0f);
SetGraphicsAlpha(graphics, canvasGroup, 0f);
// Documentation text normalized.
while (elapsed < totalLifeTime)
while (!fadeOutStarted || fadeOutElapsed < fadeDuration)
{
if (obj == null) yield break;
float dt = Time.deltaTime;
elapsed += dt;
// Documentation text normalized.
vVelocity += gravity * dt;
float previousVelocity = vVelocity;
vVelocity += settings.gravity * dt;
currentLocalPos.x += horizontalVelocity * dt;
currentLocalPos.y += vVelocity * dt;
obj.transform.localPosition = currentLocalPos;
obj.transform.localRotation = Quaternion.Euler(0f, 0f, Mathf.Lerp(0f, targetRotation, Mathf.Clamp01(elapsed / totalLifeTime)));
float scaleT = scalePunchDuration > 0.001f ? Mathf.Clamp01(elapsed / scalePunchDuration) : 1f;
float punch = Mathf.Sin(scaleT * Mathf.PI) * scalePunch;
float scaleT = settings.scalePunchDuration > 0.001f ? Mathf.Clamp01(elapsed / settings.scalePunchDuration) : 1f;
float punch = Mathf.Sin(scaleT * Mathf.PI) * settings.scalePunch;
obj.transform.localScale = baseScaleValue * (1f + punch);
// Documentation text normalized.
float alpha;
bool reachedApex = previousVelocity > 0f && vVelocity <= 0f;
if (!fadeOutStarted && (reachedApex || elapsed >= totalLifeTime - fadeDuration))
{
fadeOutStarted = true;
fadeOutElapsed = 0f;
fadeOutStartAlpha = currentAlpha;
}
// Documentation text normalized.
if (elapsed < fadeInTime)
float alpha;
if (fadeOutStarted)
{
alpha = Mathf.InverseLerp(0f, fadeInTime, elapsed);
fadeOutElapsed += dt;
alpha = Mathf.Lerp(fadeOutStartAlpha, 0f, Mathf.Clamp01(fadeOutElapsed / fadeDuration));
}
// Documentation text normalized.
else if (elapsed > fadeOutStartTime)
else if (elapsed < settings.fadeInTime)
{
// Documentation text normalized.
alpha = Mathf.InverseLerp(totalLifeTime, fadeOutStartTime, elapsed);
alpha = Mathf.InverseLerp(0f, settings.fadeInTime, elapsed);
}
// Documentation text normalized.
else
{
alpha = 1f;
}
SetRenderersAlpha(renderers, Mathf.Clamp01(alpha));
alpha = Mathf.Clamp01(alpha);
currentAlpha = alpha;
SetRenderersAlpha(renderers, alpha);
SetGraphicsAlpha(graphics, canvasGroup, alpha);
yield return null;
}
// Documentation text normalized.
if (obj != null)
{
obj.transform.localScale = baseScaleValue;
obj.transform.localRotation = Quaternion.identity;
SetRenderersAlpha(renderers, 0f);
SetGraphicsAlpha(graphics, canvasGroup, 0f);
}
// Documentation text normalized.
if (obj != null) ReturnJudgeToPool(obj);
if (obj != null)
{
if (destroyAtEnd)
Destroy(obj);
else
ReturnJudgeToPool(obj);
}
}
private static void SetRenderersAlpha(SpriteRenderer[] renderers, float alpha)
@@ -322,6 +394,104 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
}
}
private static void SetGraphicsAlpha(Graphic[] graphics, CanvasGroup canvasGroup, float alpha)
{
if (canvasGroup != null)
{
canvasGroup.alpha = alpha;
return;
}
if (graphics == null)
return;
for (int i = 0; i < graphics.Length; i++)
{
Graphic graphic = graphics[i];
if (graphic == null)
continue;
Color c = graphic.color;
c.a = alpha;
graphic.color = c;
}
}
private void EnsureRenderableTrackSkillInstance(GameObject instance)
{
if (instance == null)
return;
if (instance.GetComponentInChildren<Graphic>(true) == null)
return;
Canvas canvas = instance.GetComponent<Canvas>();
if (canvas == null)
canvas = instance.AddComponent<Canvas>();
canvas.renderMode = RenderMode.WorldSpace;
canvas.overrideSorting = true;
canvas.sortingOrder = trackSkillSortingOrder;
canvas.pixelPerfect = false;
CanvasGroup canvasGroup = instance.GetComponent<CanvasGroup>();
if (canvasGroup == null)
canvasGroup = instance.AddComponent<CanvasGroup>();
canvasGroup.alpha = 1f;
}
private void ApplyTrackSkillScale(GameObject instance, float finalScale)
{
if (instance == null)
return;
bool isUiPrefab = instance.GetComponentInChildren<Graphic>(true) != null;
float appliedScale = isUiPrefab ? finalScale * trackSkillUiScaleMultiplier : finalScale;
instance.transform.localScale = new Vector3(appliedScale, appliedScale, 1f);
}
private void BindTrackSkillContent(GameObject instance, Sprite skillIcon, string skillName)
{
if (instance == null)
return;
Image targetImage = null;
Text targetText = null;
TrackSkillPrefab modern = instance.GetComponent<TrackSkillPrefab>();
if (modern != null)
{
modern.Bind(skillIcon, skillName);
targetImage = modern.skillImage;
targetText = modern.skillText;
}
else
{
trackSkillPrefab legacy = instance.GetComponent<trackSkillPrefab>();
if (legacy != null)
{
targetImage = legacy.track_skillIcon;
targetText = legacy.track_skillName;
}
}
if (targetImage == null)
targetImage = instance.GetComponentInChildren<Image>(true);
if (targetText == null)
targetText = instance.GetComponentInChildren<Text>(true);
if (targetImage != null)
{
targetImage.sprite = skillIcon;
targetImage.enabled = skillIcon != null;
}
if (targetText != null)
{
targetText.text = string.IsNullOrWhiteSpace(skillName) ? string.Empty : skillName;
}
}
private GameObject GetPooledJudge(GameObject prefab, Transform parent)
{
if (prefab == null || parent == null)
@@ -329,7 +499,7 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
if (!judgePools.TryGetValue(prefab, out var pool))
{
pool = new System.Collections.Generic.Stack<GameObject>();
pool = new Stack<GameObject>();
judgePools[prefab] = pool;
}
@@ -384,7 +554,7 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
if (!judgePools.TryGetValue(tag.prefabSource, out var pool))
{
pool = new System.Collections.Generic.Stack<GameObject>();
pool = new Stack<GameObject>();
judgePools[tag.prefabSource] = pool;
}
@@ -399,8 +569,6 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
{
if (string.IsNullOrEmpty(color)) return null;
// Documentation text normalized.
Transform point = null;
switch (color.ToLower())
{
@@ -409,7 +577,7 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
case "yellow": point = yellowSpawn; break;
case "purple": point = purpleSpawn; break;
case "blue": point = blueSpawn; break;
default:
default:
Debug.LogWarning($"[Animation_Generate] Unknown color: {color}");
return null;
}
@@ -425,7 +593,7 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
private GameObject GetJudgePrefab(string result)
{
if (string.IsNullOrEmpty(result)) return null;
GameObject prefab = null;
switch (result.ToLower())
{
@@ -433,7 +601,7 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
case "great": prefab = great_judge_prefab; break;
case "good": prefab = good_judge_prefab; break;
case "miss": prefab = miss_judge_prefab; break;
default:
default:
Debug.LogWarning($"[Animation_Generate] Unknown judge result: {result}");
return null;
}
@@ -1,55 +1,55 @@
using UnityEngine;
/// <summary>
/// 根据屏幕宽高比,对 gameplay 相机做"适当微调":叠加一点 X 旋转(俯角)与 Z 距离增量
/// 目的:不同分辨率下让轨道透视观感更协调(如超宽屏略微拉远/调俯角),与 contain 式 FOV 适配互补。
/// 根据屏幕宽高比,对 gameplay 相机做分辨率适配,让宽屏下轨道观感更接近设计(16:9)
///
/// 安全性(核心):**绝不直接写相机 transform**。所有微调通过 effectEventController.SetResolutionCameraAdjust
/// 施加到"漂移 pivot"(顶层基准节点),并由该方法同步漂移基准 z——因此与相机漂移、SmoothShake 抖动、
/// 技能偏移、冲刺特效等所有"写相机位姿"的业务逻辑完全不冲突(它们叠加在 pivot 之下或相机自身)。
/// 需求(用户指定):越宽的屏幕(21:9/32:9)轨道越缩在中间、显细,左右与 UI 空出大黑缝。
/// 解决方案:宽屏时把相机从设计基准(z=-8, X=-40°)逐步插值到"z 更高 + X 更朝水平"的目标位姿,
/// 让轨道透视放大、铺满视野,同时保持判定线距离/视距/业务逻辑不变(音符/判定用世界坐标,不受相机缩放影响)。
///
/// 用一个 bool(enableTuning)开关。关闭时把增量清零(恢复设计基准),行为与未加此脚本一致。
/// 增量相对"设计基准"计算,不累积;16:9 时增量为 0(与原设计逐像素一致)。
/// 操作对象:**直接改相机本体(Main Camera 子物体)自身的 local z 高度与 local X 旋转**
/// 不动其父/祖父 pivot(drift/skill pivot)。通过 effectEventController.SetResolutionCameraSelf 施加,
/// 由该方法处理与 SmoothShake(写相机 local)、冲刺(写相机 local z)的共享——空闲时写入持久生效,
/// 抖动/冲刺触发时会以当前 local 重新捕获基准。不碰任何 UI:UI 贴边逻辑独立,本脚本只改相机位姿。
///
/// 用一个 bool(enableTuning)开关。关闭时恢复设计基准(与未加此脚本逐像素一致)。
/// 16:9 及更窄(平板 1.6、竖屏)保持设计基准不动;宽屏线性插值;21:9 及更宽夹在最大偏移(用户指定的上限)。
/// </summary>
[DisallowMultipleComponent]
public sealed class CameraResolutionTuner : MonoBehaviour
{
[Tooltip("是否启用按分辨率微调相机(X旋转/Z)。关闭则恢复设计基准。")]
[Tooltip("是否启用按分辨率微调相机。关闭则恢复设计基准。")]
public bool enableTuning = true;
[Tooltip("设计基准宽高比。16:9 = 1.7778。此比例下增量为 0。")]
[Tooltip("设计基准宽高比。16:9 = 1.7778。此比例及更窄时无微调。")]
public float designAspect = 16f / 9f;
[Header("宽屏(aspect > 设计)微调")]
[Tooltip("每偏离设计宽高比 1.0,额外叠加的 X 旋转角度(正=更俯视)。建议很小。")]
public float widePerAspectRotX = 3f;
[Tooltip("每偏离设计宽高比 1.0,额外叠加的 Z 距离(负=相机后退拉远)。")]
public float widePerAspectZ = -1.0f;
[Tooltip("宽屏微调的绝对上限,避免极端超宽屏过度。")]
public float wideMaxRotX = 6f;
public float wideMaxZ = -2.0f;
[Tooltip("最大偏移的宽高比上限。21:9 = 2.3333。达到或超过此比例时夹在最大偏移。")]
public float maxOffsetAspect = 21f / 9f;
[Header("窄屏(aspect < 设计)微调")]
[Tooltip("每偏离设计宽高比 1.0,额外叠加的 X 旋转角度。")]
public float narrowPerAspectRotX = 0f;
[Tooltip("每偏离设计宽高比 1.0,额外叠加的 Z 距离。")]
public float narrowPerAspectZ = 0f;
public float narrowMaxRotX = 4f;
public float narrowMaxZ = 2.0f;
[Header("21:9 时相机本体(Main Camera)的目标 local 位姿(最大偏移)")]
[Tooltip("21:9 时相机本体的目标 local Z 高度。用户指定 2.5(相机自身 z=2.5)。")]
public float targetCameraZ = 2.5f;
[Tooltip("21:9 时相机本体的目标 local X 旋转角度。用户指定 5(相机自身 X=5°)。")]
public float targetCameraRotX = 5f;
private float _lastAspect = -1f;
private bool _lastEnabled;
private bool _applied; // 上次 Apply 是否成功写入相机(相机/单例未就绪时为 false,下一帧重试)
private void OnEnable()
{
_lastAspect = -1f;
_applied = false;
Apply();
}
private void Update()
{
float aspect = (float)Screen.width / Mathf.Max(1, Screen.height);
if (Mathf.Abs(aspect - _lastAspect) > 0.0001f || enableTuning != _lastEnabled)
// 宽高比/开关变化,或此前尚未成功写入(相机/单例未就绪),则重试。
if (!_applied || Mathf.Abs(aspect - _lastAspect) > 0.0001f || enableTuning != _lastEnabled)
{
Apply();
}
@@ -58,7 +58,7 @@ public sealed class CameraResolutionTuner : MonoBehaviour
private void Apply()
{
var eff = effectEventController.Instance;
if (eff == null) return;
if (eff == null) { _applied = false; return; }
float aspect = (float)Screen.width / Mathf.Max(1, Screen.height);
_lastAspect = aspect;
@@ -66,26 +66,30 @@ public sealed class CameraResolutionTuner : MonoBehaviour
if (!enableTuning)
{
eff.SetResolutionCameraAdjust(0f, 0f); // 恢复设计基准
// 关闭:t=0 → 相机本体回到 local 设计基准(运行时为 0/identity)。
_applied = eff.SetResolutionCameraSelf(0f, targetCameraRotX, targetCameraZ);
return;
}
float rotX = 0f;
float z = 0f;
// 16:9 及更窄:t=0(无微调,与设计逐像素一致)。
// 16:9 ~ 21:9:线性插值 t ∈ [0,1]。
// 21:9 及更宽:t=1(夹在最大偏移上限)。
float t = 0f;
if (aspect > designAspect)
{
float dev = aspect - designAspect; // 宽屏偏离量
rotX = Mathf.Clamp(widePerAspectRotX * dev, Mathf.Min(0f, wideMaxRotX), Mathf.Max(0f, wideMaxRotX));
z = Mathf.Clamp(widePerAspectZ * dev, Mathf.Min(0f, wideMaxZ), Mathf.Max(0f, wideMaxZ));
if (aspect >= maxOffsetAspect)
{
t = 1f; // 超宽屏:夹在最大偏移
}
else
{
// 插值区间:designAspect(16:9≈1.778) → maxOffsetAspect(21:9≈2.333)
t = (aspect - designAspect) / Mathf.Max(0.0001f, maxOffsetAspect - designAspect);
t = Mathf.Clamp01(t);
}
}
else if (aspect < designAspect)
{
float dev = designAspect - aspect; // 窄屏偏离量
rotX = Mathf.Clamp(narrowPerAspectRotX * dev, Mathf.Min(0f, narrowMaxRotX), Mathf.Max(0f, narrowMaxRotX));
z = Mathf.Clamp(narrowPerAspectZ * dev, Mathf.Min(0f, narrowMaxZ), Mathf.Max(0f, narrowMaxZ));
}
// 16:9rotX=z=0,无微调。
// aspect <= designAspectt=0(无微调)。
eff.SetResolutionCameraAdjust(rotX, z);
_applied = eff.SetResolutionCameraSelf(t, targetCameraRotX, targetCameraZ);
}
}
+77 -11
View File
@@ -12,6 +12,23 @@ public class HoldNote : BaseNote
private string noteID = string.Empty;
private NoteSegment segment = NoteSegment.None;
// 获取用于生成判定 prefab 的颜色。如果 noteColor 为空(初始化异常),从 trackIndex 推导兜底。
private string GetColorForPrefab()
{
if (!string.IsNullOrEmpty(noteColor)) return noteColor;
if (trackIndex >= 0 && trackIndex < 5)
{
string[] trackColors = { "red", "green", "yellow", "purple", "blue" };
string fallback = trackColors[trackIndex];
if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] noteColor was null, using trackIndex {trackIndex} -> {fallback}");
return fallback;
}
Debug.LogError($"[HoldNote] Cannot derive color: noteColor is null and trackIndex {trackIndex} is invalid");
return null;
}
private float releaseTime = 0f;
private bool hasReleased = true;
private bool hasEnteredLine = false;
@@ -810,19 +827,20 @@ public class HoldNote : BaseNote
return;
}
// If the key is still being held down when end segment enters judge zone, immediately judge.
// Autoplay must ignore physical input to guarantee Perfect at scheduledEndTime.
if (!GameConfig.autoPlayEnabled && IsHeld() && JudgeManager.Instance.IsStartJudged(noteID) && !isJudged)
// END 段到达判定区时,如果头部已判定且玩家还没松手,立即自动结束(不等松手)。
// 让玩家"长按到尾部"即视为完成,无需精确松手时机——更符合直觉且降低难度。
// Autoplay 必须忽略物理输入,保证在 scheduledEndTime 精确判定 Perfect。
if (JudgeManager.Instance.IsStartJudged(noteID) && !JudgeManager.Instance.HasNoteReleased(noteID) && !isJudged)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] End entered while key still held: {noteColor}, forcing Perfect");
// Record release time as current time (player is still holding)
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] End entered while note not yet released: {noteColor}, auto-finishing hold as Perfect");
// 记录松手时间为当前时刻(玩家按到尾部视为完美松手)
releaseTime = GameplayClock.NowSongTime;
hasReleased = true;
JudgeManager.Instance?.RegisterNoteReleased(noteID, true);
// Evaluate and judge the hold end - force Perfect since we reached the end while holding
// 判定并生成 prefab——force Perfect(按到尾部=完美完成)
EvaluateHoldEnd(releaseTime, false, true);
isJudged = true;
// Terminate input - mark key as released
// 终止输入:标记键已松开,停止持续特效
isHoldActive = false;
StopHoldFxLoop();
return;
@@ -1056,6 +1074,7 @@ public class HoldNote : BaseNote
if (result != "Miss")
{
TryRewriteNonMissJudgeToPerfect(trackIndex, ref result);
GameplayLevelRuleEventBus.NotifyHoldStarted(trackIndex, result, pressTime, noteData);
}
// show judge result and update combo/UI like short notes
@@ -1079,10 +1098,30 @@ public class HoldNote : BaseNote
TrackJudgeHitEffectController.PlayTrackHitFx(trackIndex);
}
// Do not spawn the judgement animation prefab for Start (head) presses to avoid duplicated prefabs
if (segment != NoteSegment.Start)
// Spawn judgement prefab:
// - START 段:只有 Miss 时才生成(点中的 Perfect/Good 不生成,避免头尾都弹;但 Miss 必须立即反馈)
// - END/BODY 段:总是生成(包括 Miss)
if (segment != NoteSegment.Start || result == "Miss")
{
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, result);
string colorForPrefab = GetColorForPrefab();
if (JudgeManager.IsDebugEnabled)
{
Debug.Log($"[HoldNote] Attempting to spawn prefab: segment={segment}, result={result}, noteColor={noteColor}, trackIndex={trackIndex}, derivedColor={colorForPrefab}, Instance={(Animation_GenerateJudgementSituationPrefab.Instance != null ? "EXISTS" : "NULL")}");
}
if (string.IsNullOrEmpty(colorForPrefab))
{
Debug.LogError($"[HoldNote] Cannot spawn prefab: colorForPrefab is null! noteColor={noteColor}, trackIndex={trackIndex}, segment={segment}, result={result}");
}
else if (Animation_GenerateJudgementSituationPrefab.Instance == null)
{
Debug.LogError($"[HoldNote] Cannot spawn prefab: Animation_GenerateJudgementSituationPrefab.Instance is NULL!");
}
else
{
Animation_GenerateJudgementSituationPrefab.Instance.SpawnJudgePrefab(colorForPrefab, result);
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] SpawnJudgePrefab called successfully for {colorForPrefab}, {result}");
}
}
if (segment != NoteSegment.Start)
@@ -1239,6 +1278,15 @@ public class HoldNote : BaseNote
AdjustCountsAfterRewriteToPerfect(trackIndex, originalEndJudge);
}
if (result == "Miss")
{
GameplayLevelRuleEventBus.NotifyHoldBroken(trackIndex, result, actualReleaseTime, noteData);
}
else
{
GameplayLevelRuleEventBus.NotifyHoldCompleted(trackIndex, result, actualReleaseTime, noteData);
}
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] END result for {noteID}: {result} (color={noteColor})");
// show UI/audio
@@ -1257,7 +1305,25 @@ public class HoldNote : BaseNote
}
// Ensure END always spawns judgement prefab (including Miss)
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, result);
string colorForPrefab = GetColorForPrefab();
if (JudgeManager.IsDebugEnabled)
{
Debug.Log($"[HoldNote.EvaluateHoldEnd] Attempting to spawn END prefab: result={result}, noteColor={noteColor}, trackIndex={trackIndex}, derivedColor={colorForPrefab}, Instance={(Animation_GenerateJudgementSituationPrefab.Instance != null ? "EXISTS" : "NULL")}");
}
if (string.IsNullOrEmpty(colorForPrefab))
{
Debug.LogError($"[HoldNote.EvaluateHoldEnd] Cannot spawn END prefab: colorForPrefab is null! noteColor={noteColor}, trackIndex={trackIndex}, result={result}");
}
else if (Animation_GenerateJudgementSituationPrefab.Instance == null)
{
Debug.LogError($"[HoldNote.EvaluateHoldEnd] Cannot spawn END prefab: Animation_GenerateJudgementSituationPrefab.Instance is NULL!");
}
else
{
Animation_GenerateJudgementSituationPrefab.Instance.SpawnJudgePrefab(colorForPrefab, result);
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote.EvaluateHoldEnd] SpawnJudgePrefab called successfully for {colorForPrefab}, {result}");
}
// --- Add scoring for End like short notes ---
try
@@ -12,7 +12,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 088ed1ed6b6731f43ad3166781e3e931, type: 3}
m_Name: NoteJudgeConfig
m_EditorClassIdentifier:
perfectRange: 0.15
greatRange: 0.2
goodRange: 0.25
missRange: 0.3
perfectRange: 0.1
greatRange: 0.15
goodRange: 0.2
missRange: 0.25
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2c5b2c74f439e604e8ffdbc589075131
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,65 @@
using System;
using UnityEngine;
public static class GameplayLevelRuleEventBus
{
public static event Action<int, string, float, NoteData> TapJudged;
public static event Action<int, string, float, NoteData> HoldStarted;
public static event Action<int, string, float, NoteData> HoldCompleted;
public static event Action<int, string, float, NoteData> HoldBroken;
public static event Action<int, float> AllySkillCast;
public static event Action<EnemyCombatant, int, EnemyData_SO> EnemySpawned;
public static event Action<EnemyCombatant, int, EnemyData_SO> EnemyDied;
public static event Action<EnemyCombatant, float, GameObject> EnemyDamaged;
public static event Action<EnemyCombatant> EnemyManaFull;
public static void NotifyTapJudged(int trackIndex, string result, float songTime, NoteData noteData)
{
TapJudged?.Invoke(trackIndex, result, songTime, noteData);
}
public static void NotifyHoldStarted(int trackIndex, string result, float songTime, NoteData noteData)
{
HoldStarted?.Invoke(trackIndex, result, songTime, noteData);
}
public static void NotifyHoldCompleted(int trackIndex, string result, float songTime, NoteData noteData)
{
HoldCompleted?.Invoke(trackIndex, result, songTime, noteData);
}
public static void NotifyHoldBroken(int trackIndex, string result, float songTime, NoteData noteData)
{
HoldBroken?.Invoke(trackIndex, result, songTime, noteData);
}
public static void NotifyAllySkillCast(int slotIndex, float songTime)
{
AllySkillCast?.Invoke(slotIndex, songTime);
}
public static void NotifyEnemySpawned(EnemyCombatant enemy, int stageIndex, EnemyData_SO data)
{
EnemySpawned?.Invoke(enemy, stageIndex, data);
}
public static void NotifyEnemyDied(EnemyCombatant enemy, int stageIndex, EnemyData_SO data)
{
EnemyDied?.Invoke(enemy, stageIndex, data);
}
public static void NotifyEnemyDamaged(EnemyCombatant enemy, float actualDamage, GameObject source)
{
EnemyDamaged?.Invoke(enemy, actualDamage, source);
}
public static void NotifyEnemyManaFull(EnemyCombatant enemy)
{
EnemyManaFull?.Invoke(enemy);
}
public static bool ShouldPreventEnemyDeath(EnemyCombatant enemy)
{
return LevelRuleController.Instance != null && LevelRuleController.Instance.ShouldPreventEnemyDeath(enemy);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: eaf68fc7a5ecfce41bb49bc77dfc89cf
@@ -0,0 +1,142 @@
using System;
using UnityEngine;
[CreateAssetMenu(fileName = "NewLevelRuleConfig", menuName = "Bansonic/Gameplay/Level Rule Config")]
public class LevelRuleConfig_SO : ScriptableObject
{
[Header("Match")]
public int songID = 0;
public string songName = string.Empty;
[Tooltip("-1 means any difficulty.")]
public int difficulty = -1;
[Header("Support Track")]
public bool useLeastNoteTrackAsSupportTrack = false;
[Tooltip("Used when useLeastNoteTrackAsSupportTrack is false. 0-4. -1 disables support-track logic.")]
public int supportTrackIndex = -1;
public int maxSupportCharges = 3;
public int adjacentSkillCastsPerSupportCharge = 3;
public float adjacentSkillCastWindow = 5f;
[Header("Echo")]
public bool enableAdjacentSkillEcho = false;
public float echoWindow = 1.5f;
public int echoThreshold = 3;
public int maxEchoStacks = 4;
public float echoResistancePerStack = 0.06f;
public float echoResistanceDuration = 4f;
public int echoManaPenalty = 0;
[Header("Conditional Rewards")]
public int allAlliesAliveScoreOnEnemyDeath = 0;
public bool awardAllAlliesAliveScorePerEnemy = false;
[Tooltip("At settlement: average living HP percent * this value is added as team score.")]
public int finalAverageHpPercentScore = 0;
[Header("Stages")]
public LevelRuleStage[] stages = Array.Empty<LevelRuleStage>();
public bool Matches(SongData song, int currentDifficulty)
{
if (song == null)
return false;
bool idMatches = songID != 0 && song.songID == songID;
bool nameMatches = !string.IsNullOrWhiteSpace(songName) && string.Equals(song.songName, songName, StringComparison.OrdinalIgnoreCase);
if (!idMatches && !nameMatches)
return false;
return difficulty < 0 || difficulty == currentDifficulty;
}
}
[Serializable]
public class LevelRuleStage
{
[Tooltip("-1 means match by current stage index. If enemyID > 0, enemy ID also has to match.")]
public int stageIndex = -1;
public int enemyID = 0;
[Tooltip("If > 0, this enemy is held at 1 HP before this song time.")]
public float minimumDeathSongTime = -1f;
public bool clearEchoOnSpawn = true;
public bool clearSupportChargesOnSpawn = false;
public LevelRuleAction[] onSpawn = Array.Empty<LevelRuleAction>();
public LevelRuleAction[] onManaFull = Array.Empty<LevelRuleAction>();
public LevelRuleAction[] onDeath = Array.Empty<LevelRuleAction>();
public LevelRuleAction[] onDamaged = Array.Empty<LevelRuleAction>();
public LevelRuleHoldAction[] onHoldStart = Array.Empty<LevelRuleHoldAction>();
public LevelRuleHoldAction[] onHoldComplete = Array.Empty<LevelRuleHoldAction>();
public LevelRuleHoldAction[] onHoldBreak = Array.Empty<LevelRuleHoldAction>();
public LevelRuleHpPhase[] hpPhases = Array.Empty<LevelRuleHpPhase>();
public bool Matches(int currentStageIndex, EnemyData_SO data)
{
if (stageIndex >= 0 && stageIndex != currentStageIndex)
return false;
if (enemyID > 0 && (data == null || data.enemyID != enemyID))
return false;
return stageIndex >= 0 || enemyID > 0;
}
}
[Serializable]
public class LevelRuleHpPhase
{
[Range(0f, 1f)]
public float hpPercentThreshold = 0.5f;
public bool triggerOnce = true;
public LevelRuleAction[] actions = Array.Empty<LevelRuleAction>();
}
[Serializable]
public class LevelRuleHoldAction
{
public LevelRuleAction[] actions = Array.Empty<LevelRuleAction>();
}
[Serializable]
public class LevelRuleAction
{
public string actionId = string.Empty;
public LevelRuleActionType actionType = LevelRuleActionType.None;
public LevelRuleTarget target = LevelRuleTarget.CurrentEnemy;
public float amount = 0f;
public bool amountScalesByDifficultyLevel = false;
public bool amountIsPercentOfMaxHp = false;
public float duration = 0f;
public int maxTriggersPerEnemy = 0;
public bool onlyIfAllAlliesAlive = false;
public bool requireSupportCharge = false;
public int scoreReward = 0;
}
public enum LevelRuleActionType
{
None,
Damage,
Heal,
ManaDelta,
AttackDelta,
ResistanceDeltaTimed,
HealReceivedMultiplierTimed,
AddTeamScore,
DamageCurrentEnemyByOwnAttack,
SpendSupportChargeForEnemyVulnerability,
ClearEnemyMana
}
public enum LevelRuleTarget
{
CurrentEnemy,
AllLivingAllies,
TriggerTrackAlly,
HighestCurrentManaAlly,
HighestAttackAlly,
HighestRecentHitTrackAlly,
HighestRecentSkillCastAlly
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0795ac37be9814449ab3d2b48aa3fdad
@@ -0,0 +1,921 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
public class LevelRuleController : MonoBehaviour
{
public static LevelRuleController Instance { get; private set; }
[Header("Rule Configs")]
public string resourcesPath = "so/levelRules";
public LevelRuleConfig_SO[] configOverrides = Array.Empty<LevelRuleConfig_SO>();
[Header("Runtime")]
[SerializeField] private bool verboseLogs = false;
private readonly Queue<float>[] recentHitTimes = new Queue<float>[5];
private readonly Queue<float>[] recentSkillCastTimes = new Queue<float>[5];
private readonly Dictionary<string, Coroutine> timedRoutines = new Dictionary<string, Coroutine>();
private readonly Dictionary<string, int> actionTriggerCounts = new Dictionary<string, int>();
private readonly HashSet<string> firedHpPhases = new HashSet<string>();
private LevelRuleConfig_SO activeConfig;
private LevelRuleStage currentStageRule;
private EnemyCombatant currentEnemy;
private EnemyData_SO currentEnemyData;
private SongData currentSong;
private int currentDifficulty = -1;
private int currentStageIndex = -1;
private int supportTrackIndex = -1;
private int supportCharges;
private int adjacentSupportCastCount;
private int lastSupportCastSlot = -1;
private float lastSupportCastTime = -999f;
private int echoStacks;
private int lastEchoCastSlot = -1;
private float lastEchoCastTime = -999f;
private float nextConfigResolveTime;
private int pendingSettlementScoreBonus;
private bool handlingEnemyDamagedActions;
public int PendingSettlementScoreBonus => Mathf.Max(0, pendingSettlementScoreBonus);
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void Bootstrap()
{
EnsureInstance();
}
public static LevelRuleController EnsureInstance()
{
if (Instance != null)
return Instance;
LevelRuleController existing = SceneObjectLookupCache.FindAny<LevelRuleController>();
if (existing != null)
{
Instance = existing;
return existing;
}
GameObject runtimeObject = new GameObject("__level_rule_controller_runtime");
DontDestroyOnLoad(runtimeObject);
Instance = runtimeObject.AddComponent<LevelRuleController>();
return Instance;
}
public static int ConsumePendingSettlementScoreBonus()
{
if (Instance == null)
return 0;
if (GameConfig.autoPlayEnabled)
{
Instance.pendingSettlementScoreBonus = 0;
return 0;
}
Instance.AddFinalAverageHpScoreIfConfigured();
int value = Mathf.Max(0, Instance.pendingSettlementScoreBonus);
Instance.pendingSettlementScoreBonus = 0;
return value;
}
private void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else if (Instance != this)
{
Destroy(gameObject);
return;
}
for (int i = 0; i < recentHitTimes.Length; i++)
{
recentHitTimes[i] = new Queue<float>();
recentSkillCastTimes[i] = new Queue<float>();
}
}
private void OnEnable()
{
SceneManager.sceneLoaded += HandleSceneLoaded;
GameplayLevelRuleEventBus.TapJudged += HandleTapJudged;
GameplayLevelRuleEventBus.HoldStarted += HandleHoldStarted;
GameplayLevelRuleEventBus.HoldCompleted += HandleHoldCompleted;
GameplayLevelRuleEventBus.HoldBroken += HandleHoldBroken;
GameplayLevelRuleEventBus.AllySkillCast += HandleAllySkillCast;
GameplayLevelRuleEventBus.EnemySpawned += HandleEnemySpawned;
GameplayLevelRuleEventBus.EnemyDied += HandleEnemyDied;
GameplayLevelRuleEventBus.EnemyDamaged += HandleEnemyDamaged;
GameplayLevelRuleEventBus.EnemyManaFull += HandleEnemyManaFull;
TryResolveActiveConfig(true);
}
private void OnDisable()
{
SceneManager.sceneLoaded -= HandleSceneLoaded;
GameplayLevelRuleEventBus.TapJudged -= HandleTapJudged;
GameplayLevelRuleEventBus.HoldStarted -= HandleHoldStarted;
GameplayLevelRuleEventBus.HoldCompleted -= HandleHoldCompleted;
GameplayLevelRuleEventBus.HoldBroken -= HandleHoldBroken;
GameplayLevelRuleEventBus.AllySkillCast -= HandleAllySkillCast;
GameplayLevelRuleEventBus.EnemySpawned -= HandleEnemySpawned;
GameplayLevelRuleEventBus.EnemyDied -= HandleEnemyDied;
GameplayLevelRuleEventBus.EnemyDamaged -= HandleEnemyDamaged;
GameplayLevelRuleEventBus.EnemyManaFull -= HandleEnemyManaFull;
}
private void Update()
{
if (Time.unscaledTime < nextConfigResolveTime)
return;
nextConfigResolveTime = Time.unscaledTime + 0.5f;
TryResolveActiveConfig(false);
}
private void HandleSceneLoaded(Scene scene, LoadSceneMode mode)
{
ResetRuntimeState();
nextConfigResolveTime = 0f;
TryResolveActiveConfig(true);
}
private void TryResolveActiveConfig(bool force)
{
BeatmapManager beatmapManager = BeatmapManager.Instance;
if (beatmapManager == null || beatmapManager.assignedSongData == null)
{
if (force)
SetActiveConfig(null, null, -1);
return;
}
SongData song = beatmapManager.assignedSongData;
int difficulty = beatmapManager.assignedDifficulty;
if (!force && activeConfig != null && currentSong == song && currentDifficulty == difficulty)
return;
LevelRuleConfig_SO matched = FindConfig(song, difficulty);
SetActiveConfig(matched, song, difficulty);
}
private LevelRuleConfig_SO FindConfig(SongData song, int difficulty)
{
if (configOverrides != null)
{
for (int i = 0; i < configOverrides.Length; i++)
{
LevelRuleConfig_SO config = configOverrides[i];
if (config != null && config.Matches(song, difficulty))
return config;
}
}
LevelRuleConfig_SO[] resources = Resources.LoadAll<LevelRuleConfig_SO>(resourcesPath);
if (resources != null)
{
for (int i = 0; i < resources.Length; i++)
{
LevelRuleConfig_SO config = resources[i];
if (config != null && config.Matches(song, difficulty))
return config;
}
}
return null;
}
private void SetActiveConfig(LevelRuleConfig_SO config, SongData song, int difficulty)
{
if (activeConfig == config && currentSong == song && currentDifficulty == difficulty)
return;
activeConfig = config;
currentSong = song;
currentDifficulty = difficulty;
ResetRuntimeState();
if (activeConfig != null)
{
supportTrackIndex = ResolveSupportTrackIndex();
if (verboseLogs)
Debug.Log($"[LevelRuleController] Active rule: {activeConfig.name}, supportTrack={supportTrackIndex}");
}
}
private void ResetRuntimeState()
{
currentStageRule = null;
currentEnemy = null;
currentEnemyData = null;
currentStageIndex = -1;
supportTrackIndex = -1;
supportCharges = 0;
adjacentSupportCastCount = 0;
lastSupportCastSlot = -1;
lastSupportCastTime = -999f;
echoStacks = 0;
lastEchoCastSlot = -1;
lastEchoCastTime = -999f;
pendingSettlementScoreBonus = 0;
actionTriggerCounts.Clear();
firedHpPhases.Clear();
for (int i = 0; i < recentHitTimes.Length; i++)
{
recentHitTimes[i].Clear();
recentSkillCastTimes[i].Clear();
}
foreach (Coroutine routine in timedRoutines.Values)
{
if (routine != null)
StopCoroutine(routine);
}
timedRoutines.Clear();
}
private int ResolveSupportTrackIndex()
{
if (activeConfig == null)
return -1;
if (!activeConfig.useLeastNoteTrackAsSupportTrack)
return activeConfig.supportTrackIndex >= 0 && activeConfig.supportTrackIndex < 5 ? activeConfig.supportTrackIndex : -1;
Beatmap beatmap = BeatmapManager.Instance != null ? BeatmapManager.Instance.beatmap : null;
if (beatmap == null || beatmap.notes == null || beatmap.notes.Length == 0)
return activeConfig.supportTrackIndex >= 0 && activeConfig.supportTrackIndex < 5 ? activeConfig.supportTrackIndex : -1;
int[] counts = new int[5];
for (int i = 0; i < beatmap.notes.Length; i++)
{
int track = beatmap.notes[i] != null ? beatmap.notes[i].trackIndex : -1;
if (track >= 0 && track < counts.Length)
counts[track]++;
}
int bestTrack = 0;
int bestCount = int.MaxValue;
for (int i = 0; i < counts.Length; i++)
{
if (counts[i] < bestCount)
{
bestCount = counts[i];
bestTrack = i;
}
}
return bestTrack;
}
private void HandleTapJudged(int trackIndex, string result, float songTime, NoteData noteData)
{
if (activeConfig == null)
return;
if (IsHit(result))
RecordRecent(recentHitTimes, trackIndex, songTime, 10f);
}
private void HandleHoldStarted(int trackIndex, string result, float songTime, NoteData noteData)
{
if (activeConfig == null || currentStageRule == null)
return;
if (IsHit(result))
RecordRecent(recentHitTimes, trackIndex, songTime, 10f);
ExecuteHoldActions(currentStageRule.onHoldStart, trackIndex);
}
private void HandleHoldCompleted(int trackIndex, string result, float songTime, NoteData noteData)
{
if (activeConfig == null || currentStageRule == null)
return;
if (IsHit(result))
RecordRecent(recentHitTimes, trackIndex, songTime, 10f);
ExecuteHoldActions(currentStageRule.onHoldComplete, trackIndex);
}
private void HandleHoldBroken(int trackIndex, string result, float songTime, NoteData noteData)
{
if (activeConfig == null || currentStageRule == null)
return;
ExecuteHoldActions(currentStageRule.onHoldBreak, trackIndex);
}
private void HandleAllySkillCast(int slotIndex, float songTime)
{
if (activeConfig == null)
return;
RecordRecent(recentSkillCastTimes, slotIndex, songTime, 10f);
UpdateSupportCharge(slotIndex, songTime);
UpdateEcho(slotIndex, songTime);
}
private void HandleEnemySpawned(EnemyCombatant enemy, int stageIndex, EnemyData_SO data)
{
if (activeConfig == null)
return;
currentEnemy = enemy;
currentEnemyData = data;
currentStageIndex = stageIndex;
currentStageRule = ResolveStageRule(stageIndex, data);
firedHpPhases.Clear();
actionTriggerCounts.Clear();
if (currentStageRule == null)
return;
if (currentStageRule.clearEchoOnSpawn)
ClearEcho();
if (currentStageRule.clearSupportChargesOnSpawn)
supportCharges = 0;
ExecuteActions(currentStageRule.onSpawn, -1, enemy);
}
private void HandleEnemyDied(EnemyCombatant enemy, int stageIndex, EnemyData_SO data)
{
if (activeConfig == null)
return;
LevelRuleStage stage = ResolveStageRule(stageIndex, data);
if (stage != null)
ExecuteActions(stage.onDeath, -1, enemy);
if (activeConfig.awardAllAlliesAliveScorePerEnemy && activeConfig.allAlliesAliveScoreOnEnemyDeath > 0 && AreAllConfiguredAlliesAlive())
AddPendingScore(activeConfig.allAlliesAliveScoreOnEnemyDeath);
}
private void HandleEnemyDamaged(EnemyCombatant enemy, float actualDamage, GameObject source)
{
if (activeConfig == null || currentStageRule == null || enemy == null || enemy != currentEnemy)
return;
if (!handlingEnemyDamagedActions)
{
handlingEnemyDamagedActions = true;
try
{
ExecuteActions(currentStageRule.onDamaged, -1, enemy);
}
finally
{
handlingEnemyDamagedActions = false;
}
}
EvaluateHpPhases(enemy);
}
private void HandleEnemyManaFull(EnemyCombatant enemy)
{
if (activeConfig == null || currentStageRule == null || enemy == null || enemy != currentEnemy)
return;
ExecuteActions(currentStageRule.onManaFull, -1, enemy);
}
public bool ShouldPreventEnemyDeath(EnemyCombatant enemy)
{
if (activeConfig == null || currentStageRule == null || enemy == null || enemy != currentEnemy)
return false;
if (currentStageRule.minimumDeathSongTime <= 0f)
return false;
return GameplayClock.NowSongTime < currentStageRule.minimumDeathSongTime;
}
private LevelRuleStage ResolveStageRule(int stageIndex, EnemyData_SO data)
{
if (activeConfig == null || activeConfig.stages == null)
return null;
for (int i = 0; i < activeConfig.stages.Length; i++)
{
LevelRuleStage stage = activeConfig.stages[i];
if (stage != null && stage.Matches(stageIndex, data))
return stage;
}
return null;
}
private void ExecuteHoldActions(LevelRuleHoldAction[] groups, int triggerTrack)
{
if (groups == null)
return;
for (int i = 0; i < groups.Length; i++)
{
LevelRuleHoldAction group = groups[i];
if (group == null)
continue;
ExecuteActions(group.actions, triggerTrack, currentEnemy);
}
}
private void ExecuteActions(LevelRuleAction[] actions, int triggerTrack, EnemyCombatant eventEnemy)
{
if (actions == null)
return;
for (int i = 0; i < actions.Length; i++)
ExecuteAction(actions[i], triggerTrack, eventEnemy);
}
private void ExecuteAction(LevelRuleAction action, int triggerTrack, EnemyCombatant eventEnemy)
{
if (action == null || action.actionType == LevelRuleActionType.None)
return;
if (action.onlyIfAllAlliesAlive && !AreAllConfiguredAlliesAlive())
return;
string key = BuildActionKey(action, eventEnemy);
if (action.maxTriggersPerEnemy > 0)
{
actionTriggerCounts.TryGetValue(key, out int count);
if (count >= action.maxTriggersPerEnemy)
return;
actionTriggerCounts[key] = count + 1;
}
if (action.requireSupportCharge && supportCharges <= 0)
return;
if (action.actionType == LevelRuleActionType.AddTeamScore)
{
AddPendingScore(Mathf.RoundToInt(ResolveRawAmount(action)));
if (action.scoreReward > 0)
AddPendingScore(action.scoreReward);
return;
}
if (action.actionType == LevelRuleActionType.SpendSupportChargeForEnemyVulnerability)
{
if (supportCharges <= 0 || currentEnemy == null)
return;
supportCharges--;
ApplyTimedResistance(currentEnemy, -Mathf.Abs(ResolveRawAmount(action)), Mathf.Max(0.01f, action.duration), key);
if (action.scoreReward > 0)
AddPendingScore(action.scoreReward);
return;
}
List<GameObject> targets = ResolveTargets(action.target, triggerTrack);
for (int i = 0; i < targets.Count; i++)
{
GameObject target = targets[i];
if (target == null)
continue;
ApplyActionToTarget(action, target, key);
}
}
private void ApplyActionToTarget(LevelRuleAction action, GameObject target, string key)
{
AllyCombatant ally = target.GetComponent<AllyCombatant>();
EnemyCombatant enemy = target.GetComponent<EnemyCombatant>();
float amount = ResolveAmountForTarget(action, ally, enemy);
switch (action.actionType)
{
case LevelRuleActionType.Damage:
if (ally != null) ally.ReceiveDamage(amount, currentEnemy != null ? currentEnemy.gameObject : null);
else if (enemy != null) enemy.ReceiveDamage(amount, currentEnemy != null ? currentEnemy.gameObject : null);
break;
case LevelRuleActionType.Heal:
if (ally != null) ally.ReceiveHeal(amount, currentEnemy != null ? currentEnemy.gameObject : null);
else if (enemy != null) enemy.ReceiveHeal(amount, currentEnemy != null ? currentEnemy.gameObject : null);
break;
case LevelRuleActionType.ManaDelta:
if (ally != null) ally.ModifyMana(Mathf.RoundToInt(amount), true, true);
else if (enemy != null) enemy.ModifyMana(Mathf.RoundToInt(amount), true);
break;
case LevelRuleActionType.AttackDelta:
if (ally != null) ally.ModifyAttack(Mathf.RoundToInt(amount));
else if (enemy != null) enemy.ModifyAttack(Mathf.RoundToInt(amount));
break;
case LevelRuleActionType.ResistanceDeltaTimed:
if (enemy != null) ApplyTimedResistance(enemy, amount, Mathf.Max(0.01f, action.duration), key);
break;
case LevelRuleActionType.HealReceivedMultiplierTimed:
ApplyTimedHealMultiplier(target, amount, Mathf.Max(0.01f, action.duration), key);
break;
case LevelRuleActionType.DamageCurrentEnemyByOwnAttack:
if (currentEnemy != null) currentEnemy.ReceiveDamage(Mathf.Max(0, currentEnemy.attack), currentEnemy.gameObject);
break;
case LevelRuleActionType.ClearEnemyMana:
if (enemy != null) enemy.ModifyMana(-enemy.currentMana, true);
break;
}
}
private float ResolveRawAmount(LevelRuleAction action)
{
if (action == null)
return 0f;
float amount = action.amount;
if (action.amountScalesByDifficultyLevel)
amount *= GetCurrentDifficultyLevel();
return amount;
}
private float ResolveAmountForTarget(LevelRuleAction action, AllyCombatant ally, EnemyCombatant enemy)
{
float amount = ResolveRawAmount(action);
if (!action.amountIsPercentOfMaxHp)
return amount;
if (ally != null)
return ally.maxHP * amount;
if (enemy != null)
return enemy.maxHP * amount;
return amount;
}
private List<GameObject> ResolveTargets(LevelRuleTarget target, int triggerTrack)
{
List<GameObject> targets = new List<GameObject>();
teamUIController ui = teamUIController.Instance;
switch (target)
{
case LevelRuleTarget.CurrentEnemy:
if (currentEnemy != null) targets.Add(currentEnemy.gameObject);
break;
case LevelRuleTarget.AllLivingAllies:
AddAllLivingAllies(targets, ui);
break;
case LevelRuleTarget.TriggerTrackAlly:
AddAllyBySlot(targets, ui, triggerTrack);
break;
case LevelRuleTarget.HighestCurrentManaAlly:
AddAllyBySlot(targets, ui, FindBestAllySlotByMana(ui));
break;
case LevelRuleTarget.HighestAttackAlly:
AddAllyBySlot(targets, ui, FindBestAllySlotByAttack(ui));
break;
case LevelRuleTarget.HighestRecentHitTrackAlly:
AddAllyBySlot(targets, ui, FindMostRecentCountTrack(recentHitTimes, 8f));
break;
case LevelRuleTarget.HighestRecentSkillCastAlly:
AddAllyBySlot(targets, ui, FindMostRecentCountTrack(recentSkillCastTimes, 8f));
break;
}
return targets;
}
private void AddAllLivingAllies(List<GameObject> targets, teamUIController ui)
{
if (ui == null)
return;
for (int i = 0; i < 5; i++)
{
GameObject go = ui.GetAllyObjectBySlot(i);
AllyCombatant ally = go != null ? go.GetComponent<AllyCombatant>() : null;
if (ally != null && !ally.IsDead && ally.maxHP > 0)
targets.Add(go);
}
}
private void AddAllyBySlot(List<GameObject> targets, teamUIController ui, int slot)
{
if (ui == null || slot < 0 || slot >= 5)
return;
GameObject go = ui.GetAllyObjectBySlot(slot);
if (go != null)
targets.Add(go);
}
private int FindBestAllySlotByMana(teamUIController ui)
{
int bestSlot = -1;
int bestMana = int.MinValue;
if (ui == null)
return -1;
for (int i = 0; i < 5; i++)
{
AllyCombatant ally = ui.GetAllyObjectBySlot(i)?.GetComponent<AllyCombatant>();
if (ally == null || ally.IsDead || ally.maxHP <= 0)
continue;
if (ally.currentMana > bestMana)
{
bestMana = ally.currentMana;
bestSlot = i;
}
}
return bestSlot;
}
private int FindBestAllySlotByAttack(teamUIController ui)
{
int bestSlot = -1;
int bestAttack = int.MinValue;
if (ui == null)
return -1;
for (int i = 0; i < 5; i++)
{
AllyCombatant ally = ui.GetAllyObjectBySlot(i)?.GetComponent<AllyCombatant>();
if (ally == null || ally.IsDead || ally.maxHP <= 0)
continue;
if (ally.attack > bestAttack)
{
bestAttack = ally.attack;
bestSlot = i;
}
}
return bestSlot;
}
private void UpdateSupportCharge(int slotIndex, float songTime)
{
if (activeConfig == null || supportTrackIndex < 0 || activeConfig.adjacentSkillCastsPerSupportCharge <= 0)
return;
if (lastSupportCastSlot >= 0 && Mathf.Abs(slotIndex - lastSupportCastSlot) == 1 && songTime - lastSupportCastTime <= activeConfig.adjacentSkillCastWindow)
adjacentSupportCastCount++;
else
adjacentSupportCastCount = 1;
lastSupportCastSlot = slotIndex;
lastSupportCastTime = songTime;
if (adjacentSupportCastCount >= activeConfig.adjacentSkillCastsPerSupportCharge)
{
supportCharges = Mathf.Clamp(supportCharges + 1, 0, Mathf.Max(0, activeConfig.maxSupportCharges));
adjacentSupportCastCount = 0;
if (verboseLogs) Debug.Log($"[LevelRuleController] Support charge +1 => {supportCharges}");
}
}
private void UpdateEcho(int slotIndex, float songTime)
{
if (activeConfig == null || !activeConfig.enableAdjacentSkillEcho)
return;
if (lastEchoCastSlot >= 0 && Mathf.Abs(slotIndex - lastEchoCastSlot) == 1 && songTime - lastEchoCastTime <= activeConfig.echoWindow)
echoStacks = Mathf.Clamp(echoStacks + 1, 0, Mathf.Max(1, activeConfig.maxEchoStacks));
else
echoStacks = 1;
lastEchoCastSlot = slotIndex;
lastEchoCastTime = songTime;
if (echoStacks >= activeConfig.echoThreshold && currentEnemy != null)
{
float delta = Mathf.Clamp(activeConfig.echoResistancePerStack * echoStacks, 0f, 0.6f);
ApplyTimedResistance(currentEnemy, delta, Mathf.Max(0.01f, activeConfig.echoResistanceDuration), "echo_resistance");
if (activeConfig.echoManaPenalty > 0)
{
GameObject allyGo = teamUIController.Instance != null ? teamUIController.Instance.GetAllyObjectBySlot(slotIndex) : null;
AllyCombatant ally = allyGo != null ? allyGo.GetComponent<AllyCombatant>() : null;
ally?.ModifyMana(-activeConfig.echoManaPenalty, true, true);
}
}
}
private void ClearEcho()
{
echoStacks = 0;
lastEchoCastSlot = -1;
lastEchoCastTime = -999f;
}
private void EvaluateHpPhases(EnemyCombatant enemy)
{
if (currentStageRule.hpPhases == null || enemy.maxHP <= 0)
return;
float hpPercent = Mathf.Clamp01((float)enemy.currentHP / enemy.maxHP);
for (int i = 0; i < currentStageRule.hpPhases.Length; i++)
{
LevelRuleHpPhase phase = currentStageRule.hpPhases[i];
if (phase == null)
continue;
string key = $"{currentStageIndex}:{enemy.GetInstanceID()}:phase:{i}";
if (phase.triggerOnce && firedHpPhases.Contains(key))
continue;
if (hpPercent <= phase.hpPercentThreshold)
{
firedHpPhases.Add(key);
ExecuteActions(phase.actions, -1, enemy);
}
}
}
private void ApplyTimedResistance(EnemyCombatant enemy, float delta, float duration, string key)
{
if (enemy == null)
return;
string routineKey = $"res:{enemy.GetInstanceID()}:{key}";
StopTimedRoutine(routineKey);
timedRoutines[routineKey] = StartCoroutine(TimedResistanceRoutine(enemy, delta, duration, routineKey));
}
private IEnumerator TimedResistanceRoutine(EnemyCombatant enemy, float delta, float duration, string routineKey)
{
if (enemy == null)
yield break;
enemy.damageResistance = Mathf.Clamp(enemy.damageResistance + delta, -0.95f, 0.6f);
yield return GameplayClock.WaitForSeconds(duration);
if (enemy != null)
enemy.damageResistance = Mathf.Clamp(enemy.damageResistance - delta, -0.95f, 0.6f);
timedRoutines.Remove(routineKey);
}
private void ApplyTimedHealMultiplier(GameObject target, float multiplier, float duration, string key)
{
ICombatant combatant = target != null ? target.GetComponent<ICombatant>() : null;
if (combatant == null)
return;
string routineKey = $"heal:{target.GetInstanceID()}:{key}";
StopTimedRoutine(routineKey);
timedRoutines[routineKey] = StartCoroutine(TimedHealMultiplierRoutine(target, combatant, multiplier, duration, routineKey));
}
private IEnumerator TimedHealMultiplierRoutine(GameObject target, ICombatant combatant, float multiplier, float duration, string routineKey)
{
if (target == null || combatant == null)
yield break;
Buff buff = new Buff(routineKey)
{
description = "Level rule heal received multiplier",
duration = duration,
healReceivedMultiplier = Mathf.Max(0f, multiplier)
};
combatant.ApplyBuff(buff, currentEnemy != null ? currentEnemy.gameObject : null);
yield return GameplayClock.WaitForSeconds(duration);
if (target != null)
combatant.RemoveBuff(routineKey);
timedRoutines.Remove(routineKey);
}
private void StopTimedRoutine(string key)
{
if (timedRoutines.TryGetValue(key, out Coroutine routine) && routine != null)
StopCoroutine(routine);
timedRoutines.Remove(key);
}
private void RecordRecent(Queue<float>[] buffers, int slot, float songTime, float keepWindow)
{
if (slot < 0 || slot >= buffers.Length)
return;
Queue<float> queue = buffers[slot];
queue.Enqueue(songTime);
while (queue.Count > 0 && songTime - queue.Peek() > keepWindow)
queue.Dequeue();
}
private int FindMostRecentCountTrack(Queue<float>[] buffers, float window)
{
float now = GameplayClock.NowSongTime;
int bestTrack = -1;
int bestCount = -1;
for (int i = 0; i < buffers.Length; i++)
{
Queue<float> queue = buffers[i];
while (queue.Count > 0 && now - queue.Peek() > window)
queue.Dequeue();
if (queue.Count > bestCount)
{
bestCount = queue.Count;
bestTrack = i;
}
}
return bestTrack;
}
private bool AreAllConfiguredAlliesAlive()
{
teamUIController ui = teamUIController.Instance;
if (ui == null)
return false;
bool hasAny = false;
for (int i = 0; i < 5; i++)
{
AllyCombatant ally = ui.GetAllyObjectBySlot(i)?.GetComponent<AllyCombatant>();
if (ally == null || ally.maxHP <= 0)
continue;
hasAny = true;
if (ally.IsDead)
return false;
}
return hasAny;
}
private int GetCurrentDifficultyLevel()
{
BeatmapManager beatmapManager = BeatmapManager.Instance;
SongData song = beatmapManager != null ? beatmapManager.assignedSongData : currentSong;
int difficulty = beatmapManager != null ? beatmapManager.assignedDifficulty : currentDifficulty;
if (song != null && song.chartFiles != null)
{
for (int i = 0; i < song.chartFiles.Count; i++)
{
ChartFileEntry entry = song.chartFiles[i];
if (entry != null && entry.difficulty == difficulty)
return Mathf.Max(1, Mathf.RoundToInt(entry.difficultyLEVEL));
}
}
return Mathf.Max(1, difficulty);
}
private void AddPendingScore(int amount)
{
if (amount <= 0 || GameConfig.autoPlayEnabled)
return;
pendingSettlementScoreBonus += amount;
if (verboseLogs) Debug.Log($"[LevelRuleController] Pending score +{amount}, total={pendingSettlementScoreBonus}");
}
private void AddFinalAverageHpScoreIfConfigured()
{
if (activeConfig == null || activeConfig.finalAverageHpPercentScore <= 0)
return;
teamUIController ui = teamUIController.Instance;
if (ui == null)
return;
float sumPercent = 0f;
int count = 0;
for (int i = 0; i < 5; i++)
{
AllyCombatant ally = ui.GetAllyObjectBySlot(i)?.GetComponent<AllyCombatant>();
if (ally == null || ally.maxHP <= 0)
continue;
sumPercent += Mathf.Clamp01((float)Mathf.Max(0, ally.currentHP) / ally.maxHP);
count++;
}
if (count <= 0)
return;
AddPendingScore(Mathf.RoundToInt((sumPercent / count) * activeConfig.finalAverageHpPercentScore));
}
private static bool IsHit(string result)
{
return !string.Equals(result, "Miss", StringComparison.OrdinalIgnoreCase);
}
private string BuildActionKey(LevelRuleAction action, EnemyCombatant enemy)
{
string id = !string.IsNullOrWhiteSpace(action.actionId) ? action.actionId : action.actionType.ToString();
int enemyId = enemy != null ? enemy.GetInstanceID() : 0;
return $"{currentStageIndex}:{enemyId}:{id}";
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8a0b6ecf635b99f40b1e485ca8180e6e
+69 -16
View File
@@ -5,6 +5,23 @@ public class Note : BaseNote
{
private KeyCode keyToPress;
private string noteColor;
// 获取用于生成判定 prefab 的颜色。如果 noteColor 为空(初始化异常),从 TrackIndex 推导兜底。
private string GetColorForPrefab()
{
if (!string.IsNullOrEmpty(noteColor)) return noteColor;
if (TrackIndex >= 0 && TrackIndex < 5)
{
string[] trackColors = { "red", "green", "yellow", "purple", "blue" };
string fallback = trackColors[TrackIndex];
if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[Note] noteColor was null, using trackIndex {TrackIndex} -> {fallback}");
return fallback;
}
Debug.LogError($"[Note] Cannot derive color: noteColor is null and TrackIndex {TrackIndex} is invalid");
return null;
}
private AnimationController anim;
private NoteController controller;
private bool isJudged = false;
@@ -233,10 +250,13 @@ public class Note : BaseNote
}
// Force miss if we've passed the deadline without being judged
if (!isJudged && GameplayClock.NowSongTime > missDeadlineTime && gameObject.activeSelf)
if (!isJudged && gameObject.activeSelf)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note] {noteColor} on track {TrackIndex} exceeded miss deadline at time {GameplayClock.NowSongTime:F3}, forcing Miss");
JudgeMiss();
if (GameplayClock.NowSongTime >= missDeadlineTime)
{
Debug.Log($"[Note.Update] Forcing miss for {noteColor} on track {TrackIndex}: now={GameplayClock.NowSongTime:F3}, deadline={missDeadlineTime:F3}");
JudgeMiss();
}
}
}
@@ -284,8 +304,12 @@ public class Note : BaseNote
JudgeSoundManager.Instance?.PlayJudgeSound(judgeResult);
teamUIController.Instance?.OnJudgeResult(judgeResult);
if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.AutoPlay] Spawning judge prefab: color={noteColor}, result={judgeResult}");
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, judgeResult);
string colorForPrefab = GetColorForPrefab();
if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.AutoPlay] Spawning judge prefab: color={colorForPrefab}, result={judgeResult}");
if (!string.IsNullOrEmpty(colorForPrefab))
{
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(colorForPrefab, judgeResult);
}
try
{
@@ -303,6 +327,7 @@ public class Note : BaseNote
try { SkillBuilder.Instance?.NotifyNoteHit(TrackIndex, judgeResult, SkillDefinition.NoteTypeTrigger.Tap); } catch { }
LogTapJudge(judgeResult, 0f, false, true, pressTime);
GameplayLevelRuleEventBus.NotifyTapJudged(TrackIndex, judgeResult, pressTime, noteData);
if (isSyncNote)
{
@@ -453,10 +478,14 @@ public class Note : BaseNote
InputManager.Instance?.ShowJudgeResult(TrackIndex, judgeResult);
JudgeSoundManager.Instance?.PlayJudgeSound(judgeResult);
teamUIController.Instance?.OnJudgeResult(judgeResult);
// Spawn judgment prefab - ensure noteColor is passed correctly
if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note] Spawning judge prefab: color={noteColor}, result={judgeResult}");
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, judgeResult);
// Spawn judgment prefab
string colorForPrefab = GetColorForPrefab();
if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note] Spawning judge prefab: color={colorForPrefab}, result={judgeResult}");
if (!string.IsNullOrEmpty(colorForPrefab))
{
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(colorForPrefab, judgeResult);
}
try
{
@@ -474,6 +503,7 @@ public class Note : BaseNote
try { SkillBuilder.Instance?.NotifyNoteHit(TrackIndex, judgeResult, SkillDefinition.NoteTypeTrigger.Tap); } catch { }
LogTapJudge(judgeResult, rawOffsetMs, rewrittenToPerfect, false, pressTime);
GameplayLevelRuleEventBus.NotifyTapJudged(TrackIndex, judgeResult, pressTime, noteData);
if (isSyncNote && judgeResult != "Miss")
{
@@ -533,8 +563,14 @@ public class Note : BaseNote
public void JudgeMiss()
{
Debug.Log($"[Note.JudgeMiss] Called for {noteColor} on track {TrackIndex}, isJudged={isJudged}");
// idempotent: this can be called from multiple paths
if (isJudged) return;
if (isJudged)
{
Debug.Log($"[Note.JudgeMiss] Already judged, skipping");
return;
}
isJudged = true;
ScoreManager.Instance.countMiss += 1;
@@ -549,10 +585,24 @@ public class Note : BaseNote
InputManager.Instance?.ShowJudgeResult(TrackIndex, "Miss");
JudgeSoundManager.Instance?.PlayJudgeSound("Miss");
teamUIController.Instance?.OnJudgeResult("Miss");
// Spawn judgment prefab for Miss - ensure noteColor is passed correctly
if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.JudgeMiss] Spawning miss judge prefab: color={noteColor}");
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, "Miss");
// 生成 Miss 判定 prefab(调试日志无条件输出,便于排查问题)
string colorForPrefab = GetColorForPrefab();
Debug.Log($"[Note.JudgeMiss] Attempting to spawn miss prefab: noteColor={noteColor}, TrackIndex={TrackIndex}, derivedColor={colorForPrefab}, Instance={(Animation_GenerateJudgementSituationPrefab.Instance != null ? "EXISTS" : "NULL")}");
if (string.IsNullOrEmpty(colorForPrefab))
{
Debug.LogError($"[Note.JudgeMiss] Cannot spawn miss prefab: colorForPrefab is null! noteColor={noteColor}, TrackIndex={TrackIndex}");
}
else if (Animation_GenerateJudgementSituationPrefab.Instance == null)
{
Debug.LogError($"[Note.JudgeMiss] Cannot spawn miss prefab: Animation_GenerateJudgementSituationPrefab.Instance is NULL!");
}
else
{
Animation_GenerateJudgementSituationPrefab.Instance.SpawnJudgePrefab(colorForPrefab, "Miss");
Debug.Log($"[Note.JudgeMiss] SpawnJudgePrefab called successfully for {colorForPrefab}");
}
try
{
@@ -570,6 +620,7 @@ public class Note : BaseNote
try { SkillBuilder.Instance?.NotifyNoteHit(TrackIndex, "Miss", SkillDefinition.NoteTypeTrigger.Tap); } catch { }
LogTapJudge("Miss", 0f, false, false, GameplayClock.NowSongTime);
GameplayLevelRuleEventBus.NotifyTapJudged(TrackIndex, "Miss", GameplayClock.NowSongTime, noteData);
// notify global judge manager that this note has been finally judged (miss)
JudgeManager.Instance?.NotifyNoteJudged();
@@ -623,6 +674,8 @@ public class Note : BaseNote
/// </summary>
public void SetJudgeZone(bool inZone)
{
Debug.Log($"[Note.SetJudgeZone] {noteColor} on track {TrackIndex}: inZone={inZone}, isJudged={isJudged}");
// leaving judge zone
if (!inZone)
{
@@ -630,11 +683,11 @@ public class Note : BaseNote
if (!isJudged)
{
// IMPORTANT: Don't call JudgeMiss directly here because SetJudgeZone is called
// from OnTriggerExit2D, which is in a physics callback.
// from OnTriggerExit2D, which is in a physics callback.
// Calling ReturnToPool (which deactivates the object) inside a physics callback
// causes "GameObject is already being activated or deactivated" errors.
// Instead, mark for miss and let Update handle it next frame.
if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.SetJudgeZone] Note {noteColor} left judge zone on track {TrackIndex}, will force Miss next frame");
Debug.Log($"[Note.SetJudgeZone] Note {noteColor} left judge zone on track {TrackIndex}, setting deadline to now");
missDeadlineTime = GameplayClock.NowSongTime; // Force deadline to now so Update will handle it
}
}
+70 -15
View File
@@ -35,8 +35,8 @@ public class NoteSpawner : MonoBehaviour
// Documentation text normalized.
[Tooltip("Multiplier applied to visual fall speed. Changing this will automatically adjust spawn timing so notes still arrive at their original beat times.")]
[Range(0.5f, 2f)]
public float speedMultiplier = 1f;
[Range(1f, 3f)]
public float speedMultiplier = 2f;
[Header("Calibration")]
[Tooltip("Tolerance (world units) for snapping middle segments to expected position after spawn.")]
@@ -84,8 +84,9 @@ public class NoteSpawner : MonoBehaviour
public GameObject animations;
// optional: constants for runtime clamping (kept for internal use)
private const float SpeedMultiplierMin = 0.5f;
private const float SpeedMultiplierMax = 2f;
// Player-facing speed is clamped to 1-3; FlowSpeedGlobalMultiplier makes the real range 2-6.
private const float SpeedMultiplierMin = 1f;
private const float SpeedMultiplierMax = 3f;
// 全局流速系数:与用户可调流速(speedMultiplier)相乘的独立系数,加快音符下落。
// 为什么用它而非 bpm 倍率:noteSpeed = 10.75 * sm * bpm / 240,若靠 ×bpm 提速,
// 长条音符的视觉长度由 ApplyVisualScale(sm) 决定(只随 sm 不随 bpm)、且分段数 segmentInterval=(60/bpm)/4
@@ -100,6 +101,28 @@ public class NoteSpawner : MonoBehaviour
get { return Mathf.Clamp(speedMultiplier, SpeedMultiplierMin, SpeedMultiplierMax) * FlowSpeedGlobalMultiplier; }
}
[Header("Note Fall Speed Source")]
[Tooltip("true=沿用谱面 BPM 决定下落速度(旧逻辑,高 BPM 歌音符更快);false=用固定参考 BPM" +
"下落速度不再随歌曲 BPM 变化,仅由流速 speedMultiplier 控制(全曲统一手感)。默认 false。")]
public bool useBpmForSpeed = false;
[Tooltip("useBpmForSpeed=false 时用作速度基准的固定参考 BPM。等价于“所有歌曲都按此 BPM 的手感下落”。" +
"建议 ≥60:地面粒子速度对 BPM 有 60 下限,低于 60 会使音符与粒子略微不同步。")]
public float referenceBpm = 120f;
// 速度/长条分段公式统一使用的“有效 BPM”。开=真实谱面 bpm;关=固定参考 bpm。
// 二者都同时喂给 noteSpeed 与 segmentInterval,故长条段间距(∝sm,bpm 相消)与地面粒子同步天然保持不变。
public float EffectiveBpm
{
get { return useBpmForSpeed ? bpm : Mathf.Max(1f, referenceBpm); }
}
[Header("Hold Note Body Fill")]
[Tooltip("true=新逻辑(默认):长条中段不拉伸(保持 prefab 原始大小),改为按流速生成更多中段密铺填满(流速越快段越多)。" +
"false=旧逻辑:中段数量固定,用 ApplyVisualScale(流速) 拉伸每段填满(流速越大越拉长)。" +
"两种方案下长条视觉长度一致、判定完全不变(中段纯视觉、end 段到达时间不变)。")]
public bool useMultiSegmentFill = true;
public NoteJudgeConfig judgeConfig; // Documentation text normalized.
private Beatmap beatmap;
private float startTime; // Absolute chart time anchor.
@@ -113,7 +136,9 @@ public class NoteSpawner : MonoBehaviour
private Dictionary<int, int> noteIndexToHoldId = new Dictionary<int, int>();
private const string NoteSpeedPrefKey = "noteSpeedMultiplier";
private const float NoteSpeedDefault = 1f;
private const string NoteSpeedDefaultVersionKey = "noteSpeedMultiplierDefaultVersion";
private const int NoteSpeedDefaultVersion = 2;
private const float NoteSpeedDefault = 2f;
private const float BaseTravelDistance = 10.75f;
private Coroutine spawnCoroutine;
@@ -146,10 +171,22 @@ public class NoteSpawner : MonoBehaviour
{
if (PlayerPrefs.HasKey(NoteSpeedPrefKey))
{
int version = PlayerPrefs.GetInt(NoteSpeedDefaultVersionKey, 0);
float saved = PlayerPrefs.GetFloat(NoteSpeedPrefKey, NoteSpeedDefault);
if (version < NoteSpeedDefaultVersion && Mathf.Approximately(saved, 3f))
{
saved = NoteSpeedDefault;
}
saved = Mathf.Clamp(saved, SpeedMultiplierMin, SpeedMultiplierMax);
PlayerPrefs.SetFloat(NoteSpeedPrefKey, saved);
PlayerPrefs.SetInt(NoteSpeedDefaultVersionKey, NoteSpeedDefaultVersion);
PlayerPrefs.Save();
return;
}
PlayerPrefs.SetFloat(NoteSpeedPrefKey, NoteSpeedDefault);
PlayerPrefs.SetInt(NoteSpeedDefaultVersionKey, NoteSpeedDefaultVersion);
PlayerPrefs.Save();
}
@@ -280,10 +317,13 @@ public class NoteSpawner : MonoBehaviour
// Cache parameters that don't change within the loop
float sm = EffectiveSpeedMultiplier;
float baseTravelTime = (60f / bpm) * 4f;
// 下落速度基准:useBpmForSpeed=true 用谱面 bpm(旧逻辑)false 用固定参考 bpm(速度不随歌曲变)。
// 判定时间不受影响:到达时刻只由 note.time 决定,speed 仅改视觉下落快慢与出现时机。
float speedBpm = EffectiveBpm;
float baseTravelTime = (60f / speedBpm) * 4f;
float baseNoteTravelTime = baseTravelTime / Mathf.Max(0.0001f, sm);
float noteSpeed = CalculateSpeed(baseNoteTravelTime);
float segmentInterval = (60f / bpm) / 4f;
float segmentInterval = (60f / speedBpm) / 4f;
EnsureJudgmentLine();
float[] laneTravelTimes = BuildLaneTravelTimes(noteSpeed);
@@ -453,9 +493,24 @@ public class NoteSpawner : MonoBehaviour
return -1;
}
int segmentCount = Mathf.CeilToInt(noteData.length / segmentInterval);
// 段间隔决定"每隔多少时间放一段"。段间距(世界坐标)= noteSpeed × 段间隔,而 noteSpeed ∝ 流速 sm。
// 旧逻辑(useMultiSegmentFill=false):段间隔不随 sm 变 → 段间距 ∝ sm,段数固定,
// 靠 ApplyVisualScale(sm) 把每段拉伸 sm 倍去填满变大的段间距(流速越大越拉长)。
// 新逻辑(useMultiSegmentFill=true):段间隔 ÷ sm → 段数 ∝ sm,而段间距 = noteSpeed × (段间隔/sm) = 恒定,
// 恰好等于不拉伸(scale=1)时 prefab 的原始段高,于是用等大的原始段密铺填满(流速越大段越多)。
// 两种方案下:end 段到达时间 = baseHit + length 完全不变(段数变但 actualSegmentInterval=length/count 抵消)
// 长条整体视觉长度一致;中段纯视觉、不参与计分/连击,故判定业务逻辑完全不受影响。
float effectiveSegmentInterval = segmentInterval;
if (useMultiSegmentFill)
{
effectiveSegmentInterval = segmentInterval / Mathf.Max(0.01f, sm);
}
int segmentCount = Mathf.CeilToInt(noteData.length / effectiveSegmentInterval);
if (segmentCount < 2) segmentCount = 2; // Force at least one middle segment between start and end.
float actualSegmentInterval = segmentCount > 0 ? noteData.length / segmentCount : segmentInterval;
float actualSegmentInterval = segmentCount > 0 ? noteData.length / segmentCount : effectiveSegmentInterval;
// 新逻辑下不拉伸(每段保持 prefab 原始大小);旧逻辑下按流速 sm 拉伸每段。
float holdVisualScale = useMultiSegmentFill ? 1f : sm;
Transform spawnPoint = spawnPoints[noteData.trackIndex];
// Documentation text normalized.
@@ -501,8 +556,8 @@ public class NoteSpawner : MonoBehaviour
// pass realtime hit time (startTime + note.time) and delay 0, include globalHitDelay
holdNote.Setup(holdNoteId, noteData.trackIndex, noteSpeed, baseHit, 0f, false, scheduledEndTime, noteData.color, key, "start", travelTime, judgeConfig, noteData, isSync);
// 使用流速倍率 sm 直接进行缩放,确保长条音符在 0.5-2.0 范围内依然能完美衔接
float visualScale = sm;
// 旧逻辑=按流速 sm 拉伸;新逻辑(多段填充)=不拉伸(holdVisualScale=1)。
float visualScale = holdVisualScale;
holdNote.ApplyVisualScale(visualScale);
// inform hold note of visual speed so it can adapt judgement windows if needed
holdNote.visualSpeedMultiplier = sm;
@@ -533,8 +588,8 @@ public class NoteSpawner : MonoBehaviour
// Documentation text normalized.
holdSeg.Setup(holdNoteId, noteData.trackIndex, noteSpeed, baseHit, segmentDelay, false, scheduledEndTime, noteData.color, key, "middle", travelTime, judgeConfig, noteData, false);
// 应用与开始段一致的流速缩放
float visualScaleMid = sm;
// 与开始段一致:旧逻辑拉伸、新逻辑不拉伸(靠更多段密铺填满)。
float visualScaleMid = holdVisualScale;
holdSeg.ApplyVisualScale(visualScaleMid);
holdSeg.visualSpeedMultiplier = sm;
@@ -565,8 +620,8 @@ public class NoteSpawner : MonoBehaviour
{
holdEnd.Setup(holdNoteId, noteData.trackIndex, noteSpeed, baseHit, endDelay, true, scheduledEndTime, noteData.color, key, "end", travelTime, judgeConfig, noteData, false);
// 同样应用流速缩放
float visualScaleEnd = sm;
// 与开始段一致:旧逻辑拉伸、新逻辑不拉伸。
float visualScaleEnd = holdVisualScale;
holdEnd.ApplyVisualScale(visualScaleEnd);
holdEnd.visualSpeedMultiplier = sm;
@@ -0,0 +1,120 @@
using UnityEngine;
/// <summary>
/// 按屏幕宽高比动态拉长轨道视觉物体(track1-5)的 Y 缩放,让宽屏下轨道更饱满。
///
/// 原理:宽屏时水平视野大、轨道相对缩在中间显细。降相机高度+抬头能让轨道透视放大,
/// 但仍不够饱满。再叠加"拉长轨道 Y 缩放"填满纵向视野,视觉更满。
///
/// 关键:轨道只是背景视觉,判定是几何驱动(音符世界 Y vs 判定线世界 Y)。
/// 缩放轨道 localScale 不影响音符生成/下落/判定——它们全用世界坐标,与轨道视觉无关。
///
/// 用法:挂到场景任意物体(会自动找 track1-5 或指定 trackParent)。运行时自动扫描并缩放。
/// </summary>
[DisallowMultipleComponent]
public sealed class TrackScaler : MonoBehaviour
{
[Tooltip("是否启用轨道缩放。关闭则恢复基准值 2.5。")]
public bool enableScaling = true;
[Tooltip("设计基准宽高比。16:9 = 1.7778。此比例下缩放为基准值。")]
public float designAspect = 16f / 9f;
[Tooltip("轨道 Y 缩放的基准值(场景序列化的初始值)。从这个值开始按宽高比放大。")]
public float baseScaleY = 2.5f;
[Header("宽屏(aspect > 设计)缩放")]
[Tooltip("每偏离设计宽高比 1.0,额外叠加的 Y 缩放倍数。宽屏拉长轨道让视觉更满。")]
public float widePerAspectScaleY = 0.8f;
[Tooltip("Y 缩放的绝对上限。避免极端超宽屏拉得过长。")]
public float maxScaleY = 4.5f;
[Tooltip("(可选)轨道的父节点。留空则自动全场景查找 track1-5。")]
public Transform trackParent;
private Transform[] _tracks;
private float _lastAspect = -1f;
private bool _lastEnabled;
private void OnEnable()
{
FindTracks();
_lastAspect = -1f;
Apply();
}
private void Update()
{
float aspect = (float)Screen.width / Mathf.Max(1, Screen.height);
if (Mathf.Abs(aspect - _lastAspect) > 0.0001f || enableScaling != _lastEnabled)
{
Apply();
}
}
private void FindTracks()
{
if (trackParent != null)
{
// 从指定父节点下找
_tracks = new Transform[5];
for (int i = 0; i < trackParent.childCount; i++)
{
Transform child = trackParent.GetChild(i);
if (child.name == "track1") _tracks[0] = child;
else if (child.name == "track2") _tracks[1] = child;
else if (child.name == "track3") _tracks[2] = child;
else if (child.name == "track4") _tracks[3] = child;
else if (child.name == "track5") _tracks[4] = child;
}
}
else
{
// 全场景查找(慢,但兜底)
_tracks = new Transform[5];
GameObject go1 = GameObject.Find("track1");
GameObject go2 = GameObject.Find("track2");
GameObject go3 = GameObject.Find("track3");
GameObject go4 = GameObject.Find("track4");
GameObject go5 = GameObject.Find("track5");
if (go1 != null) _tracks[0] = go1.transform;
if (go2 != null) _tracks[1] = go2.transform;
if (go3 != null) _tracks[2] = go3.transform;
if (go4 != null) _tracks[3] = go4.transform;
if (go5 != null) _tracks[4] = go5.transform;
}
}
private void Apply()
{
if (_tracks == null || _tracks.Length == 0)
{
FindTracks(); // 重试
if (_tracks == null || _tracks.Length == 0) return;
}
float aspect = (float)Screen.width / Mathf.Max(1, Screen.height);
_lastAspect = aspect;
_lastEnabled = enableScaling;
float targetScaleY = baseScaleY;
if (enableScaling && aspect > designAspect)
{
// 宽屏:额外拉长 Y
float dev = aspect - designAspect;
float extra = widePerAspectScaleY * dev;
targetScaleY = Mathf.Min(baseScaleY + extra, maxScaleY);
}
// aspect <= designAspect 或关闭:保持基准值
// 应用到所有轨道
for (int i = 0; i < _tracks.Length; i++)
{
if (_tracks[i] == null) continue;
Vector3 scale = _tracks[i].localScale;
scale.y = targetScaleY;
_tracks[i].localScale = scale;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 29e9752cc9128104bad251941d81ffa2
@@ -26,7 +26,9 @@ using UnityEngine;
public sealed class TrackScreenRatioStabilizer : MonoBehaviour
{
[Tooltip("是否启用轨道屏占比稳定(按宽高比补偿相机垂直FOV)。关闭则恢复设计FOV。")]
public bool enableStabilization = true;
// 关闭:contain 式补偿会在窄屏增大垂直FOV(镜头拉远)=轨道变细+视距变长,二者都不符合需求。
// 改由 CameraResolutionTuner(降Z+增X俯角)在保持垂直FOV恒定(视距恒定)的前提下让轨道铺开。
public bool enableStabilization = false;
[Tooltip("设计基准宽高比(轨道占比在此比例下为设计值)。16:9 = 1.7778。")]
public float designAspect = 16f / 9f;
@@ -0,0 +1,43 @@
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
public class TrackSkillPrefab : MonoBehaviour
{
[Header("UI")]
public Text skillText;
public Image skillImage;
private CanvasGroup _canvasGroup;
private void Awake()
{
_canvasGroup = GetComponent<CanvasGroup>();
if (_canvasGroup == null) _canvasGroup = gameObject.AddComponent<CanvasGroup>();
_canvasGroup.alpha = 0f;
}
public void Bind(Sprite icon, string skillName)
{
ResetForReuse();
if (skillImage != null)
{
skillImage.sprite = icon;
skillImage.enabled = icon != null;
}
if (skillText != null)
{
skillText.text = string.IsNullOrWhiteSpace(skillName) ? string.Empty : skillName;
}
}
private void ResetForReuse()
{
if (_canvasGroup != null) _canvasGroup.alpha = 0f;
transform.localRotation = Quaternion.identity;
transform.localScale = Vector3.one;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a51f5b8ae798a1e42a55f1654ed4237e
@@ -38,10 +38,11 @@ public class effectEventController : MonoBehaviour
// 分辨率相机微调:改的是"漂移 pivot"(顶层节点,承载 -40°/-8 基准),不碰相机自身,
// 因此不与 SmoothShake(写相机 local)、技能偏移(写技能 pivot)、冲刺(写相机 z)冲突。
// 改 pivot 的 X 旋转无任何系统覆盖;改 pivot 的 Z 必须同步 cameraDriftBaseLocalPosition.z
// 否则 UpdateCameraDrift/ResetCameraDriftIfNeeded 会用旧基准把 z 拉回。
// 改 pivot 的 X 旋转无任何系统覆盖;改 pivot 的 Y/Z 必须同步 cameraDriftBaseLocalPosition
// 否则 UpdateCameraDrift/ResetCameraDriftIfNeeded 会用旧基准把 Y/Z 拉回。
private bool _cameraBaseCaptured;
private float _designPivotRotX; // pivot 初始 X 旋转(如 -40)
private float _designPivotY; // pivot 初始 Y(如 -6,相机高度)
private float _designPivotZ; // pivot 初始 Z(如 -8)
private void Awake()
@@ -117,16 +118,17 @@ public class effectEventController : MonoBehaviour
if (cachedCameraDriftPivot == null) return;
}
_designPivotRotX = cachedCameraDriftPivot.localEulerAngles.x;
_designPivotY = cachedCameraDriftPivot.localPosition.y;
_designPivotZ = cachedCameraDriftPivot.localPosition.z;
_cameraBaseCaptured = true;
}
/// <summary>
/// 按分辨率微调相机:在设计基准上叠加 X 旋转增量Z 位置增量。
/// 改的是漂移 pivot(不动相机本身),且同步 cameraDriftBaseLocalPosition.z
/// 按分辨率微调相机:在设计基准上叠加 X 旋转增量、Y 高度增量、Z 位置增量。
/// 改的是漂移 pivot(不动相机本身),且同步 cameraDriftBaseLocalPosition 的 y/z
/// 与漂移/抖动/技能偏移/冲刺全部不冲突。可重复调用(总是相对设计基准,不累积)。
/// </summary>
public void SetResolutionCameraAdjust(float extraRotX, float extraZ)
public void SetResolutionCameraAdjust(float extraRotX, float extraZ, float extraY = 0f)
{
CaptureCameraBaseIfNeeded();
if (cachedCameraDriftPivot == null) return;
@@ -136,12 +138,91 @@ public class effectEventController : MonoBehaviour
euler.x = _designPivotRotX + extraRotX;
cachedCameraDriftPivot.localEulerAngles = euler;
// Z:设计值 + 增量,并同步漂移基准 z(漂移只改 x,y,z 恒取基准)。
// Y(高度)与 Z:设计值 + 增量。漂移每帧写 base + (perlinX, perlinY, 0)
// 故必须同步 cameraDriftBaseLocalPosition 的 y/z,否则下一帧漂移会用旧基准拉回。
float targetY = _designPivotY + extraY;
float targetZ = _designPivotZ + extraZ;
Vector3 lp = cachedCameraDriftPivot.localPosition;
lp.y = targetY;
lp.z = targetZ;
cachedCameraDriftPivot.localPosition = lp;
cameraDriftBaseLocalPosition.z = targetZ; // 关键:否则会被 UpdateCameraDrift/Reset 拉回
cameraDriftBaseLocalPosition.y = targetY; // 关键:否则会被 UpdateCameraDrift/Reset 拉回
cameraDriftBaseLocalPosition.z = targetZ;
}
// 相机本体(Main Camera)分辨率适配的 local 基准。运行时相机被重挂到 skill_pivot 之下、
// local 位姿被清零(见 EnsureCameraDriftPivot/EnsureCameraSkillPivot),故基准 z≈0、X≈0。
// 首次调用 SetResolutionCameraSelf 时惰性捕获,供插值 t=0 回到该基准。
private bool _cameraSelfBaseCaptured;
private float _cameraSelfBaseLocalZ;
private float _cameraSelfBaseLocalRotX;
/// <summary>
/// 按分辨率**直接调整相机本体(Main Camera 子物体)**的 local z 高度与 local X 旋转,
/// 不动其父/祖父 pivot。t=0 → 回到相机 local 设计基准(运行时为 0/identity,与未调整一致)
/// t=1 → 相机 local 到达 (targetLocalRotX, targetLocalZ)。始终相对基准插值,不累积。
///
/// 冲突处理:相机本体的 local 由 SmoothShake(抖动时每帧写)与冲刺(动画 local z)共享。
/// - 空闲时二者不写相机(SmoothShake 无活动协程即不 ApplySum),本次写入持久生效;
/// - 抖动/冲刺触发时会以"当前 local"为基准重新捕获(SaveDefaultValues / CacheCameraTransform)
/// 故结束后回到本方法设定的值。为兼容"抖动进行中改分辨率"的罕见情形,
/// 若相机上已有 SmoothShake 且正在抖动,同步其 startPosition/startRotation 基准。
/// </summary>
public bool SetResolutionCameraSelf(float t, float targetLocalRotX, float targetLocalZ)
{
if (cachedShakeTarget == null)
{
CacheShakeTarget();
if (cachedShakeTarget == null) return false; // 相机(Camera.main)尚未就绪,稍后重试
}
t = Mathf.Clamp01(t);
if (!_cameraSelfBaseCaptured)
{
_cameraSelfBaseLocalZ = cachedShakeTarget.localPosition.z;
_cameraSelfBaseLocalRotX = NormalizeSignedAngle(cachedShakeTarget.localEulerAngles.x);
_cameraSelfBaseCaptured = true;
}
float z = Mathf.Lerp(_cameraSelfBaseLocalZ, targetLocalZ, t);
float rotX = Mathf.Lerp(_cameraSelfBaseLocalRotX, targetLocalRotX, t);
Vector3 lp = cachedShakeTarget.localPosition;
lp.z = z;
cachedShakeTarget.localPosition = lp;
Vector3 euler = cachedShakeTarget.localEulerAngles;
euler.x = rotX;
cachedShakeTarget.localEulerAngles = euler;
SyncShakeBaseIfActive(lp, euler);
return true;
}
// 若相机本体上的 SmoothShake 正在抖动,同步其 start 基准,避免抖动进行中改分辨率被回弹覆盖。
private void SyncShakeBaseIfActive(Vector3 localPos, Vector3 localEuler)
{
if (cachedShakeTarget == null) return;
var shake = cachedShakeTarget.GetComponent<SmoothShakeFree.SmoothShake>();
if (shake == null) return;
if (shake.activeShakeRoutines == null || shake.activeShakeRoutines.Count == 0) return;
var type = typeof(SmoothShakeFree.SmoothShake);
const System.Reflection.BindingFlags flags =
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance;
var startPosField = type.GetField("startPosition", flags);
var startRotField = type.GetField("startRotation", flags);
if (startPosField != null) startPosField.SetValue(shake, localPos);
if (startRotField != null) startRotField.SetValue(shake, localEuler);
}
// Unity eulerAngles.x 存 [0,360)-40 存成 320;归一到 [-180,180) 便于插值(负=朝上)。
private static float NormalizeSignedAngle(float angle)
{
angle %= 360f;
if (angle > 180f) angle -= 360f;
else if (angle < -180f) angle += 360f;
return angle;
}
private void PrewarmShakeComponent()
@@ -20,6 +20,8 @@ public class globalNoteEffect : MonoBehaviour
public float cameraDashDuration = 0.12f;
[Header("Camera Wave FX")]
[Range(0f, 1f)]
public float cameraWaveIntensity = 1f;
public float cameraWaveEnterDuration = 0.12f;
public float cameraWaveRecoverDuration = 0.12f;
@@ -319,6 +321,7 @@ public class globalNoteEffect : MonoBehaviour
private void TriggerCameraWaveFx()
{
GlobalDistortionRuntimeController.TriggerWaveEffect(
Mathf.Clamp01(cameraWaveIntensity),
Mathf.Max(0.0001f, cameraWaveEnterDuration),
Mathf.Max(0.0001f, cameraWaveRecoverDuration)
);
@@ -357,12 +357,14 @@ public class groundParticularController : MonoBehaviour
float currentBPM = GetCurrentBPM();
float noteSpawnerSpeedMultiplier = 1.0f;
// 粒子速度须与音符下落同速:跟随 NoteSpawner 的“有效 BPM”(开关决定用谱面 bpm 还是固定参考 bpm)。
if (beatmapManager != null && beatmapManager.noteSpawner != null)
{
noteSpawnerSpeedMultiplier = beatmapManager.noteSpawner.EffectiveSpeedMultiplier;
currentBPM = beatmapManager.noteSpawner.EffectiveBpm;
}
float effectiveBPM = Mathf.Max(currentBPM, 60f);
float effectiveBPM = Mathf.Max(currentBPM, 60f);
float noteSpeed = (totalDist * effectiveBPM * noteSpawnerSpeedMultiplier) / 240f;
float step = noteSpeed * speedMultiplier * emissionSpeedMultiplier * perFrameMoveMultiplier * Time.deltaTime;
@@ -576,7 +576,8 @@ public class settlementController : MonoBehaviour
}
targetPmScore = sm.allSum_pmScore;
targetIdolScore = sm.allSum_idolScore;
int levelRuleScoreBonus = LevelRuleController.ConsumePendingSettlementScoreBonus();
targetIdolScore = sm.allSum_idolScore + levelRuleScoreBonus;
targetTotalScore = targetPmScore + targetIdolScore;
targetTotalPercent = (float)targetTotalScore / Mathf.Max(1, _1000000) * 100f;