好多新内容
This commit is contained in:
@@ -713,7 +713,9 @@ public class BgmUiBinder : MonoBehaviour
|
||||
var scaler = go.GetComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920f, 1080f);
|
||||
scaler.matchWidthOrHeight = 0.5f;
|
||||
// 锁高度缩放:分辨率跟随屏幕后,非 16:9 屏也能保证各 UI 层一致缩放、不错位。
|
||||
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
|
||||
scaler.matchWidthOrHeight = 1f;
|
||||
DontDestroyOnLoad(go);
|
||||
persistent = go;
|
||||
}
|
||||
|
||||
@@ -1758,9 +1758,9 @@ RectTransform:
|
||||
- {fileID: 1447481020518725738}
|
||||
m_Father: {fileID: 4008714089197239054}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: -628.13, y: -267.01}
|
||||
m_AnchorMin: {x: 0, y: 0.5}
|
||||
m_AnchorMax: {x: 0, y: 0.5}
|
||||
m_AnchoredPosition: {x: 331.87, y: -267.01}
|
||||
m_SizeDelta: {x: 100, y: 100}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &8373938424588989755
|
||||
@@ -2124,9 +2124,9 @@ RectTransform:
|
||||
- {fileID: 1318901128213550095}
|
||||
m_Father: {fileID: 5634981615641515618}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: 323.3, y: 5}
|
||||
m_AnchorMin: {x: 1, y: 0.5}
|
||||
m_AnchorMax: {x: 1, y: 0.5}
|
||||
m_AnchoredPosition: {x: -636.7, y: 5}
|
||||
m_SizeDelta: {x: 100, y: 100}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!1 &1512308382957859215
|
||||
@@ -2161,9 +2161,9 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 6434860682258445909}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: -886.6095, y: -440.45862}
|
||||
m_AnchorMin: {x: 0, y: 0.5}
|
||||
m_AnchorMax: {x: 0, y: 0.5}
|
||||
m_AnchoredPosition: {x: 73.3905, y: -440.45862}
|
||||
m_SizeDelta: {x: 146.7813, y: 61.0829}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &7958189264278634160
|
||||
@@ -3023,9 +3023,9 @@ RectTransform:
|
||||
- {fileID: 5977557715898357349}
|
||||
m_Father: {fileID: 5634981615641515618}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: -280.4, y: 5.78}
|
||||
m_AnchorMin: {x: 0, y: 0.5}
|
||||
m_AnchorMax: {x: 0, y: 0.5}
|
||||
m_AnchoredPosition: {x: 679.6, y: 5.78}
|
||||
m_SizeDelta: {x: 2410.4807, y: 100}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &4595481187193100221
|
||||
@@ -3592,9 +3592,9 @@ RectTransform:
|
||||
- {fileID: 7954660427444040773}
|
||||
m_Father: {fileID: 5634981615641515618}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: -520.8, y: -0.00005340576}
|
||||
m_AnchorMin: {x: 0, y: 0.5}
|
||||
m_AnchorMax: {x: 0, y: 0.5}
|
||||
m_AnchoredPosition: {x: 439.2, y: -0.00005340576}
|
||||
m_SizeDelta: {x: 128, y: 30}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!1 &2463687307556080059
|
||||
@@ -6388,9 +6388,9 @@ RectTransform:
|
||||
- {fileID: 6358183742841940414}
|
||||
m_Father: {fileID: 5634981615641515618}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: 518.05, y: 5}
|
||||
m_AnchorMin: {x: 1, y: 0.5}
|
||||
m_AnchorMax: {x: 1, y: 0.5}
|
||||
m_AnchoredPosition: {x: -441.95, y: 5}
|
||||
m_SizeDelta: {x: 100, y: 100}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!1 &5054239668136224504
|
||||
@@ -6478,7 +6478,7 @@ MonoBehaviour:
|
||||
m_ScaleFactor: 1
|
||||
m_ReferenceResolution: {x: 1920, y: 1080}
|
||||
m_ScreenMatchMode: 0
|
||||
m_MatchWidthOrHeight: 0.5
|
||||
m_MatchWidthOrHeight: 1
|
||||
m_PhysicalUnit: 3
|
||||
m_FallbackScreenDPI: 96
|
||||
m_DefaultSpriteDPI: 96
|
||||
@@ -10513,9 +10513,9 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 5634981615641515618}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: -825.01624, y: 0}
|
||||
m_AnchorMin: {x: 0, y: 0.5}
|
||||
m_AnchorMax: {x: 0, y: 0.5}
|
||||
m_AnchoredPosition: {x: 134.984, y: 0}
|
||||
m_SizeDelta: {x: 307.5676, y: 50}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &8992617676759434598
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 全屏背景/遮罩自适应(Cover):保持原始宽高比,等比放大到覆盖整个根 Canvas,超出部分裁切。
|
||||
/// 过覆盖 => 任意宽高比都无缝铺满、且 sizeDelta 永不为 0;对纯色遮罩与艺术背景都正确、不失真。
|
||||
/// 仅调整 sizeDelta(表现层),不改锚点/位置/pivot,不影响居中布局与位移动画。
|
||||
/// 16:9 屏下结果等于设计尺寸,与原始逐像素一致。由 UIResolutionAutoFitter 自动附加。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(RectTransform))]
|
||||
[DisallowMultipleComponent]
|
||||
public class UIBackgroundCover : MonoBehaviour
|
||||
{
|
||||
public float sourceAspect = 16f / 9f;
|
||||
|
||||
private RectTransform rt;
|
||||
private RectTransform coverTarget;
|
||||
private float lastW = -1f, lastH = -1f;
|
||||
private bool aspectResolved;
|
||||
// 重入锁:设置 sizeDelta 会同步触发 OnRectTransformDimensionsChange,
|
||||
// 若不拦截会无限递归导致 StackOverflow 崩溃。
|
||||
private bool _isFitting;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
rt = GetComponent<RectTransform>();
|
||||
ResolveAspectFromInitialSize();
|
||||
ResolveTarget();
|
||||
Fit(true);
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
// 每帧检测 coverTarget 实际尺寸变化(而非只看 Screen):Unity 布局延迟更新,
|
||||
// Screen 变化后 CanvasScaler 可能过几帧才把新尺寸写入 rootCanvas.rect。
|
||||
// Fit(false) 内部按 lastW/lastH 判重,尺寸未变时零开销,无需额外节流。
|
||||
if (coverTarget == null) ResolveTarget();
|
||||
if (coverTarget != null)
|
||||
{
|
||||
float pw = coverTarget.rect.width;
|
||||
float ph = coverTarget.rect.height;
|
||||
if (!Mathf.Approximately(pw, lastW) || !Mathf.Approximately(ph, lastH))
|
||||
{
|
||||
Fit(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>用元素初始 sizeDelta 推断原始宽高比,避免写死。</summary>
|
||||
private void ResolveAspectFromInitialSize()
|
||||
{
|
||||
if (aspectResolved) return;
|
||||
if (rt == null) rt = GetComponent<RectTransform>();
|
||||
Vector2 s = rt.sizeDelta;
|
||||
if (s.x > 1f && s.y > 1f) sourceAspect = s.x / s.y;
|
||||
aspectResolved = true;
|
||||
}
|
||||
|
||||
private void OnRectTransformDimensionsChange()
|
||||
{
|
||||
if (_isFitting) return; // 拦截自身写入触发的递归回调
|
||||
Fit(false);
|
||||
}
|
||||
|
||||
private void ResolveTarget()
|
||||
{
|
||||
Canvas canvas = GetComponentInParent<Canvas>();
|
||||
if (canvas != null)
|
||||
{
|
||||
Canvas root = canvas.rootCanvas != null ? canvas.rootCanvas : canvas;
|
||||
coverTarget = root.GetComponent<RectTransform>();
|
||||
}
|
||||
}
|
||||
|
||||
private void Fit(bool force)
|
||||
{
|
||||
if (_isFitting) return;
|
||||
if (rt == null) rt = GetComponent<RectTransform>();
|
||||
if (coverTarget == null) ResolveTarget();
|
||||
if (coverTarget == null || sourceAspect <= 0f) return;
|
||||
|
||||
float pw = coverTarget.rect.width;
|
||||
float ph = coverTarget.rect.height;
|
||||
if (pw <= 0f || ph <= 0f) return;
|
||||
if (!force && Mathf.Approximately(pw, lastW) && Mathf.Approximately(ph, lastH)) return;
|
||||
lastW = pw; lastH = ph;
|
||||
|
||||
float targetAspect = pw / ph;
|
||||
float w, h;
|
||||
if (targetAspect > sourceAspect) { w = pw; h = pw / sourceAspect; }
|
||||
else { h = ph; w = ph * sourceAspect; }
|
||||
|
||||
Vector2 newSize = new Vector2(w, h);
|
||||
if (rt.sizeDelta != newSize)
|
||||
{
|
||||
_isFitting = true;
|
||||
rt.sizeDelta = newSize;
|
||||
_isFitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f19fe0a8ae05114bba0f36c5945c104
|
||||
@@ -0,0 +1,50 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 让 CanvasScaler 的 matchWidthOrHeight 随屏幕宽高比动态切换,实现“设计区域永不裁切”(contain):
|
||||
/// - 屏幕比设计更宽(>16:9):锁高度(match=1),左右由背景 cover 填满,UI 不被竖向裁。
|
||||
/// - 屏幕比设计更窄(<16:9,如 16:10 / 4:3):锁宽度(match=0),上下由背景 cover 填满,UI 不被横向裁。
|
||||
/// 这样 1920x1080 设计范围内的所有 UI 在任意宽高比下都完整可见,仅“更宽松”的一侧留出由背景填充的余量。
|
||||
///
|
||||
/// 满足三要求:
|
||||
/// 1) 不改业务逻辑、不改对象引用;只调 CanvasScaler 的一个表现参数。
|
||||
/// 2) 16:9 屏下 match=1 与原状态一致,逐像素不变;只改缩放基准,不动任何锚点/位置/动画。
|
||||
/// 3) 窄屏不再裁切、宽屏铺满,配合背景 cover 始终填满屏幕。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CanvasScaler))]
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class UICanvasMatchFitter : MonoBehaviour
|
||||
{
|
||||
private const float DesignAspect = 1920f / 1080f;
|
||||
|
||||
private CanvasScaler scaler;
|
||||
private float lastAspect = -1f;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
scaler = GetComponent<CanvasScaler>();
|
||||
Apply();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// 分辨率/窗口变化时才重算。
|
||||
float aspect = (float)Screen.width / Mathf.Max(1, Screen.height);
|
||||
if (Mathf.Abs(aspect - lastAspect) > 0.0001f) Apply();
|
||||
}
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
if (scaler == null) scaler = GetComponent<CanvasScaler>();
|
||||
if (scaler == null) return;
|
||||
if (scaler.uiScaleMode != CanvasScaler.ScaleMode.ScaleWithScreenSize) return;
|
||||
|
||||
float aspect = (float)Screen.width / Mathf.Max(1, Screen.height);
|
||||
lastAspect = aspect;
|
||||
|
||||
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
|
||||
// 更宽→锁高度(1);更窄→锁宽度(0)。等于始终匹配“更受限的一侧”,设计区域完整可见。
|
||||
scaler.matchWidthOrHeight = aspect >= DesignAspect ? 1f : 0f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1a18755a72ae63a43b21a20c8f4489ca
|
||||
@@ -0,0 +1,114 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 让一个“中心锚定 + 固定横向偏移”的顶层 HUD 容器,在任意宽高比下横向跟随其设计所属的屏幕边缘。
|
||||
/// 只处理横向(X),纵向(Y)完全不动;保持 1920 设计宽度下的视觉位置逐像素不变。
|
||||
///
|
||||
/// 为什么用组件而非直接改业务脚本坐标:许多 HUD 的位置由业务脚本在运行时写死设置
|
||||
/// (relative 到这些容器)。本组件只移动“容器”本身到边缘相对位置,容器内部由脚本布置的
|
||||
/// 子元素保持相对关系不变——既实现了“跟随边缘”,又不触碰任何业务坐标逻辑。
|
||||
///
|
||||
/// 满足三要求:1) 不改业务逻辑/引用;2) 仅横向、16:9 逐像素一致、不动 Y 与动画;3) 跟随边缘。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(RectTransform))]
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class UIEdgeFollow : MonoBehaviour
|
||||
{
|
||||
public enum Edge { Left, Right, Auto }
|
||||
|
||||
public Edge edge = Edge.Auto;
|
||||
|
||||
// 设计基准宽度:所有 gap 以此为参考,保证与首次附加时的分辨率无关。
|
||||
private const float DesignWidth = 1920f;
|
||||
|
||||
private RectTransform rt;
|
||||
private RectTransform rootRt;
|
||||
private float gapX; // 元素中心距目标边缘的间距(设计参考像素,恒定)
|
||||
private Edge resolved;
|
||||
private bool measured;
|
||||
private float lastRootW = -1f;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
rt = GetComponent<RectTransform>();
|
||||
ResolveRoot();
|
||||
Measure();
|
||||
ApplyIfNeeded(true);
|
||||
}
|
||||
|
||||
private void ResolveRoot()
|
||||
{
|
||||
Canvas c = GetComponentInParent<Canvas>();
|
||||
if (c != null)
|
||||
{
|
||||
Canvas root = c.rootCanvas != null ? c.rootCanvas : c;
|
||||
rootRt = root.GetComponent<RectTransform>();
|
||||
}
|
||||
}
|
||||
|
||||
// 元素在画布局部空间的设计 X(以画布中心为原点)。用世界坐标换算,正确处理嵌套父级
|
||||
// (如 bbt 挂在 logo 下,anchoredPosition 相对 logo 而非画布)。
|
||||
private float designCanvasX;
|
||||
|
||||
private void Measure()
|
||||
{
|
||||
if (measured || rt == null || rootRt == null) return;
|
||||
// 仅处理横向未拉伸 + 中心锚定的元素。
|
||||
if (Mathf.Abs(rt.anchorMax.x - rt.anchorMin.x) > 0.01f) return;
|
||||
if (Mathf.Abs(rt.anchorMin.x - 0.5f) > 0.01f) return;
|
||||
|
||||
// 用元素世界位置换算到"画布局部空间"的 X(画布中心为原点)。
|
||||
// 关键:InverseTransformPoint 已返回画布参考单位(reference pixels),且与分辨率无关
|
||||
//(anchoredPosition 固定在参考单位、画布缩放被 Inverse 抵消),直接用即可。
|
||||
// 绝不能再除以 rootW 归一——那会在非 16:9(rootW≠1920)时把坐标算错,元素停在中间。
|
||||
// 这对直接子级(uid/score)和嵌套子级(bbt 挂在 logo 下)都正确。
|
||||
float rootW = rootRt.rect.width;
|
||||
if (rootW <= 0f) return;
|
||||
designCanvasX = rootRt.InverseTransformPoint(rt.position).x; // 画布中心为原点,参考单位
|
||||
|
||||
// gap = 元素中心到"设计左/右缘"的距离(参考单位,恒定)。设计画布宽=DesignWidth。
|
||||
float distFromDesignLeft = DesignWidth * 0.5f + designCanvasX; // 到左缘距离
|
||||
resolved = edge;
|
||||
if (resolved == Edge.Auto)
|
||||
resolved = designCanvasX < 0f ? Edge.Left : Edge.Right;
|
||||
|
||||
gapX = resolved == Edge.Left ? distFromDesignLeft : (DesignWidth - distFromDesignLeft);
|
||||
measured = true;
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (rootRt == null) { ResolveRoot(); return; }
|
||||
if (!measured) { Measure(); if (!measured) return; }
|
||||
float rootW = rootRt.rect.width;
|
||||
if (Mathf.Abs(rootW - lastRootW) > 0.5f) ApplyIfNeeded(false);
|
||||
}
|
||||
|
||||
private void ApplyIfNeeded(bool force)
|
||||
{
|
||||
if (rt == null || rootRt == null) return;
|
||||
if (!measured) { Measure(); if (!measured) return; }
|
||||
|
||||
float rootW = rootRt.rect.width;
|
||||
if (rootW <= 0f) return;
|
||||
lastRootW = rootW;
|
||||
|
||||
// 目标画布局部 X(画布中心为原点,参考单位):当前画布半宽=rootW/2。
|
||||
// 左贴边:目标 = -rootW/2 + gapX;右贴边:目标 = rootW/2 - gapX。
|
||||
float halfW = rootW * 0.5f;
|
||||
float targetCanvasLocalX = resolved == Edge.Left ? (-halfW + gapX) : (halfW - gapX);
|
||||
|
||||
// 当前画布局部 X;若已到位则跳过。
|
||||
Vector3 worldCur = rt.position;
|
||||
Vector3 canvasCur = rootRt.InverseTransformPoint(worldCur);
|
||||
if (Mathf.Abs(targetCanvasLocalX - canvasCur.x) <= 0.01f) return;
|
||||
|
||||
// 画布局部目标 → 世界目标点 → 自身父空间 anchoredPosition.x(仅横向)。
|
||||
Vector3 worldTarget = rootRt.TransformPoint(new Vector3(targetCanvasLocalX, canvasCur.y, canvasCur.z));
|
||||
float worldDeltaX = worldTarget.x - worldCur.x;
|
||||
Vector3 parentScale = rt.parent != null ? rt.parent.lossyScale : Vector3.one;
|
||||
float parentScaleX = Mathf.Abs(parentScale.x) < 1e-6f ? 1f : parentScale.x;
|
||||
Vector2 anchoredNow = rt.anchoredPosition;
|
||||
rt.anchoredPosition = new Vector2(anchoredNow.x + worldDeltaX / parentScaleX, anchoredNow.y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8745b948635db4f4187b2001878439b2
|
||||
@@ -0,0 +1,419 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 全局分辨率自适应器:场景加载时自动扫描全屏背景类 Image(居中锚定 + 尺寸接近设计分辨率),
|
||||
/// 为其附加 UIBackgroundCover,使背景在任意宽高比(含超宽 32:9)下等比放大铺满、不留黑边、不失真。
|
||||
///
|
||||
/// 设计原则(对应三要求):
|
||||
/// 1) 零业务逻辑改动:不改脚本行为、不改对象引用,只在表现层调整背景 sizeDelta。
|
||||
/// 2) 不改锚点/pivot/位置:位移动画与居中布局完全不受影响;16:9 屏计算结果等于原设计尺寸。
|
||||
/// 3) 背景总是铺满屏幕。
|
||||
///
|
||||
/// 仅针对“全屏背景/遮罩”生效,普通按钮/面板/菜单等居中 UI 保持原样(它们本就该居中,不该拉伸)。
|
||||
/// </summary>
|
||||
public sealed class UIResolutionAutoFitter : MonoBehaviour
|
||||
{
|
||||
private const string BootstrapObjectName = "[UIResolutionAutoFitter]";
|
||||
private static UIResolutionAutoFitter _instance;
|
||||
|
||||
// 设计基准分辨率(与全工程 CanvasScaler referenceResolution 一致)。
|
||||
private const float DesignWidth = 1920f;
|
||||
private const float DesignHeight = 1080f;
|
||||
|
||||
// 判定为“全屏背景”的尺寸容差:宽高需分别接近设计分辨率或其 2 倍(部分背景做了 3840x2160)。
|
||||
private const float SizeTolerance = 96f;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
private static void Bootstrap()
|
||||
{
|
||||
if (_instance != null) return;
|
||||
GameObject go = new GameObject(BootstrapObjectName);
|
||||
_instance = go.AddComponent<UIResolutionAutoFitter>();
|
||||
DontDestroyOnLoad(go);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
SceneManager.sceneLoaded += OnSceneLoaded;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
SceneManager.sceneLoaded -= OnSceneLoaded;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 处理启动时已经加载好的首个场景。
|
||||
StartCoroutine(FitAfterFrame());
|
||||
// 低频常驻补扫:覆盖“任意时刻才实例化/打开”的 prefab 全屏底图
|
||||
//(结算、歌曲详情、角色/设置面板等可能在会话中随时弹出)。
|
||||
// 每次只给尚未挂 UIBackgroundCover 的背景加组件,已处理的直接跳过,单次开销很低。
|
||||
StartCoroutine(PeriodicRescan());
|
||||
}
|
||||
|
||||
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
|
||||
{
|
||||
// 新场景的 Canvas 是新实例,旧记录已失效,清空以便重新 setup。
|
||||
_setupDoneCanvases.Clear();
|
||||
_enemyFollowAttached = false;
|
||||
_trackStabilizerAttached = false;
|
||||
_cameraTunerAttached = false;
|
||||
StartCoroutine(FitAfterFrame());
|
||||
}
|
||||
|
||||
private IEnumerator FitAfterFrame()
|
||||
{
|
||||
// 等一帧让 Canvas/布局初始化完成,再扫描(避免 sizeDelta 尚未就绪)。
|
||||
yield return null;
|
||||
ScanAndFit();
|
||||
}
|
||||
|
||||
// 当前是否为 gameplay 场景(名字含 gameplay/gamePlay)。仅该场景启用 HUD 自动贴边。
|
||||
private bool _gameplayActive;
|
||||
|
||||
// 已完成一次性 setup(match 适配 + 边缘跟随)的 Canvas 集合,避免每次扫描重复全树遍历。
|
||||
// 切场景时清空(Canvas 会重建为新实例)。背景 cover 扫描不受此影响,仍每次执行。
|
||||
private readonly HashSet<Canvas> _setupDoneCanvases = new HashSet<Canvas>();
|
||||
|
||||
// 靠边判定阈值:元素中心距设计画布中心超过该值(参考像素)才自动贴边。
|
||||
private const float EdgeOffsetThreshold = 300f;
|
||||
|
||||
// 自动贴边时跳过的名字关键字:全屏背景/遮罩(由 cover 负责)与天然应居中的元素。
|
||||
private static readonly string[] AutoEdgeSkip =
|
||||
{
|
||||
"bg", "background", "mask", "black", "white", "遮罩", "背景",
|
||||
"pause", "已暂停", "start", "count", "321", "go"
|
||||
};
|
||||
|
||||
private void UpdateGameplayFlag()
|
||||
{
|
||||
string sn = SceneManager.GetActiveScene().name;
|
||||
_gameplayActive = !string.IsNullOrEmpty(sn)
|
||||
&& sn.ToLowerInvariant().Contains("gameplay");
|
||||
}
|
||||
|
||||
// 按横向偏移自动贴边(仅 gameplay HUD 顶层容器用)。
|
||||
private static void TryAutoEdgeFollowByOffset(RectTransform rt, float canvasWidth)
|
||||
{
|
||||
if (rt == null) return;
|
||||
if (rt.GetComponent<UIEdgeFollow>() != null) return;
|
||||
// 跳过:父级已有 UIEdgeFollow(子元素应相对父级布局,不独立贴边)。
|
||||
Transform parent = rt.parent;
|
||||
while (parent != null)
|
||||
{
|
||||
if (parent.GetComponent<UIEdgeFollow>() != null) return;
|
||||
if (parent.GetComponent<Canvas>() != null) break; // 到达 Canvas 根停止
|
||||
parent = parent.parent;
|
||||
}
|
||||
// 仅横向未拉伸 + 锚点接近中心。
|
||||
if (Mathf.Abs(rt.anchorMax.x - rt.anchorMin.x) > 0.01f) return;
|
||||
if (Mathf.Abs(rt.anchorMin.x - 0.5f) > 0.01f) return;
|
||||
string lower = rt.gameObject.name.ToLowerInvariant();
|
||||
for (int i = 0; i < AutoEdgeSkip.Length; i++)
|
||||
if (lower.Contains(AutoEdgeSkip[i])) return;
|
||||
// 全屏尺寸的排除(背景类)。
|
||||
if (rt.sizeDelta.x >= canvasWidth - 1f) return;
|
||||
// 计算元素中心的绝对X坐标(相对Canvas中心),用实际Canvas宽度而非硬编码1920。
|
||||
float absX = 0.5f * canvasWidth + rt.anchoredPosition.x;
|
||||
float canvasCenter = 0.5f * canvasWidth;
|
||||
if (Mathf.Abs(absX - canvasCenter) < EdgeOffsetThreshold) return;
|
||||
rt.gameObject.AddComponent<UIEdgeFollow>();
|
||||
}
|
||||
|
||||
// gameplay 场景:递归扫描 Canvas 下所有 RectTransform,对符合条件的元素附加 UIEdgeFollow。
|
||||
// 深度优先遍历,覆盖嵌套的角色面板/技能指示器等。每个元素独立判定,避免打散成组布局。
|
||||
private static void RecursiveTryAutoEdgeFollow(RectTransform canvasRt)
|
||||
{
|
||||
if (canvasRt == null) return;
|
||||
float canvasWidth = canvasRt.rect.width;
|
||||
if (canvasWidth <= 0f) canvasWidth = DesignWidth; // fallback
|
||||
RecursiveTryAutoEdgeFollowInternal(canvasRt, canvasWidth);
|
||||
}
|
||||
|
||||
private static void RecursiveTryAutoEdgeFollowInternal(RectTransform rt, float canvasWidth)
|
||||
{
|
||||
if (rt == null) return;
|
||||
// 先尝试给当前元素附加(若符合条件)。
|
||||
TryAutoEdgeFollowByOffset(rt, canvasWidth);
|
||||
// 再递归处理所有子级。
|
||||
for (int i = 0; i < rt.childCount; i++)
|
||||
{
|
||||
RectTransform child = rt.GetChild(i) as RectTransform;
|
||||
if (child != null) RecursiveTryAutoEdgeFollowInternal(child, canvasWidth);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator PeriodicRescan()
|
||||
{
|
||||
// 0.5s 兼顾"面板打开后尽快铺满背景"与常驻开销。
|
||||
// 注意:单次扫描含 FindObjectsByType + 逐 Canvas 的 GetComponentsInChildren(会分配数组),
|
||||
// 属较重操作,间隔不宜过短(0.25s 会带来可感的 GC/CPU 尖峰)。ScanAndFit 内部对已处理的
|
||||
// Canvas(match/edgefollow)与已挂 cover 的 Image 都会跳过,避免重复重活。
|
||||
var wait = new WaitForSecondsRealtime(0.5f);
|
||||
while (true)
|
||||
{
|
||||
yield return wait;
|
||||
ScanAndFit();
|
||||
}
|
||||
}
|
||||
|
||||
private void ScanAndFit()
|
||||
{
|
||||
UpdateGameplayFlag();
|
||||
Canvas[] canvases = FindObjectsByType<Canvas>(FindObjectsInactive.Include, FindObjectsSortMode.None);
|
||||
for (int i = 0; i < canvases.Length; i++)
|
||||
{
|
||||
Canvas canvas = canvases[i];
|
||||
if (canvas == null) continue;
|
||||
|
||||
// 注意:不能按 Canvas 缓存跳过——像商店/邮件等面板是运行时挂到“已存在的场景 Canvas”下的,
|
||||
// 若跳过已见 Canvas,这些后加入的全屏背景就永远不会被 cover(会露出背后界面)。
|
||||
// 改为在下面对每个 Image 用 GetComponent<UIBackgroundCover> 判断是否已处理,成本很低。
|
||||
|
||||
// 跳过自管理布局/动画的特殊 UI(如全局通知 gNotice):它在 Awake 缓存自身位置并做
|
||||
// 弹出/上浮/缩放动画,若我们再改其 scaler/背景会造成实例化瞬间的跳动。名字或组件识别。
|
||||
if (ShouldSkipCanvas(canvas)) continue;
|
||||
|
||||
// 性能:match 适配器与边缘跟随都是"每个 Canvas 一次性附加",无需每次扫描都重做
|
||||
// (它们含 GetComponentsInChildren 全树遍历 + 递归,较重)。已处理过的 Canvas 直接跳过这部分,
|
||||
// 只让下面便宜的背景 cover 扫描继续每次执行(它需捕获运行时新挂入的面板底图)。
|
||||
bool canvasSetupDone = _setupDoneCanvases.Contains(canvas);
|
||||
if (!canvasSetupDone)
|
||||
{
|
||||
// 所有带 CanvasScaler 的 Canvas 统一用 UICanvasMatchFitter:宽屏锁高度、窄屏锁宽度,
|
||||
// 保证缩放系数正确、设计区域完整可见。绝不能宽屏锁宽度(会把 UI 放大数倍)。
|
||||
CanvasScaler scaler = canvas.GetComponent<CanvasScaler>();
|
||||
if (scaler != null && scaler.GetComponent<UICanvasMatchFitter>() == null)
|
||||
{
|
||||
scaler.gameObject.AddComponent<UICanvasMatchFitter>();
|
||||
}
|
||||
|
||||
// 结算是"整块按 1920 写死"的居中卡片,不做 HUD 边缘跟随(否则打散版式)。
|
||||
bool useEdgeFollowLayout = _gameplayActive && !IsSettlementCanvas(canvas);
|
||||
|
||||
// 按名对已知独立小部件/HUD 容器附加 UIEdgeFollow(白名单,避免打散成组布局)。
|
||||
RectTransform[] allRects = canvas.GetComponentsInChildren<RectTransform>(true);
|
||||
for (int r = 0; r < allRects.Length; r++)
|
||||
{
|
||||
RectTransform rt = allRects[r];
|
||||
if (rt == null) continue;
|
||||
if (EdgeFollowWhitelist.Contains(rt.gameObject.name))
|
||||
{
|
||||
TryAttachEdgeFollow(rt);
|
||||
}
|
||||
// 父级名+子级名精确定位:某些边角元素的偏移在通用名(如"Image")的子级上,
|
||||
// 直接把通用名加白名单会误伤同名对象,故用"父级名/子级名"精确命中。
|
||||
// 例:steamIcon 状态图标的偏移在其子物体 "Image"(左下角,-1751.5,-916)上。
|
||||
else if (rt.parent != null && ParentChildEdgeFollow.TryGetValue(rt.parent.name, out string childName)
|
||||
&& rt.gameObject.name == childName)
|
||||
{
|
||||
TryAttachEdgeFollow(rt);
|
||||
}
|
||||
}
|
||||
|
||||
// gameplay 非结算 HUD:递归对中心锚定、明显靠边的元素附加 UIEdgeFollow。
|
||||
if (useEdgeFollowLayout && scaler != null)
|
||||
{
|
||||
RectTransform canvasRt = canvas.transform as RectTransform;
|
||||
if (canvasRt != null) RecursiveTryAutoEdgeFollow(canvasRt);
|
||||
}
|
||||
|
||||
_setupDoneCanvases.Add(canvas);
|
||||
}
|
||||
|
||||
// 只处理根 Canvas 下的层级;子 Canvas 会被其根 Canvas 的遍历覆盖到。
|
||||
Image[] images = canvas.GetComponentsInChildren<Image>(true);
|
||||
for (int j = 0; j < images.Length; j++)
|
||||
{
|
||||
Image img = images[j];
|
||||
if (img == null) continue;
|
||||
RectTransform rt = img.rectTransform;
|
||||
if (rt == null) continue;
|
||||
if (!IsFullscreenBackground(rt)) continue;
|
||||
if (img.GetComponent<UIBackgroundCover>() == null)
|
||||
{
|
||||
// 统一用 Cover:等比放大到覆盖根 Canvas,过覆盖=无缝且永不为 0。
|
||||
// 之前的 Stretch(填满父级)对嵌套在小尺寸容器下的全屏图(如 quhuiImage 的父级仅100x100)
|
||||
// 会把 sizeDelta 拉成 0 导致图片消失,故弃用。
|
||||
img.gameObject.AddComponent<UIBackgroundCover>();
|
||||
}
|
||||
}
|
||||
|
||||
// RawImage(如 main_main 的视频背景 videoDisplay,用 RenderTexture 显示视频):
|
||||
// RawImage 与 Image 同继承 Graphic,UIBackgroundCover 只改 sizeDelta 对二者一致;
|
||||
// 其 uvRect=(0,0,1,1) 铺满纹理、RT 为 16:9,等比 cover 不会拉伸变形。
|
||||
// GetComponentsInChildren<Image> 抓不到 RawImage,故单独扫一遍。
|
||||
RawImage[] rawImages = canvas.GetComponentsInChildren<RawImage>(true);
|
||||
for (int j = 0; j < rawImages.Length; j++)
|
||||
{
|
||||
RawImage raw = rawImages[j];
|
||||
if (raw == null) continue;
|
||||
RectTransform rt = raw.rectTransform;
|
||||
if (rt == null) continue;
|
||||
if (!IsFullscreenBackground(rt)) continue;
|
||||
if (raw.GetComponent<UIBackgroundCover>() == null)
|
||||
{
|
||||
raw.gameObject.AddComponent<UIBackgroundCover>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gameplay 世界空间背景(SpriteRenderer,如 bg_replace):由相机渲染、非 UI,
|
||||
// 超宽屏下相机视野扩大会露出背景边缘外的黑边。扫描低 sortingOrder 的全屏 Sprite 背景,
|
||||
// 附加 UISpriteBackgroundCover 按宽高比放大 localScale 覆盖。仅 gameplay 场景处理。
|
||||
if (_gameplayActive)
|
||||
{
|
||||
ScanSpriteBackgrounds();
|
||||
ScanEnemyFollow();
|
||||
ScanTrackRatioStabilizer();
|
||||
}
|
||||
}
|
||||
|
||||
// gameplay 主相机:附加 TrackScreenRatioStabilizer,按宽高比补偿垂直FOV,
|
||||
// 使轨道横向屏占比在不同分辨率下大致恒定(避免超宽屏轨道太窄误触)。仅附加一次。
|
||||
private void ScanTrackRatioStabilizer()
|
||||
{
|
||||
if (!_trackStabilizerAttached)
|
||||
{
|
||||
Camera cam = Camera.main;
|
||||
if (cam != null)
|
||||
{
|
||||
if (cam.GetComponent<TrackScreenRatioStabilizer>() == null)
|
||||
cam.gameObject.AddComponent<TrackScreenRatioStabilizer>();
|
||||
_trackStabilizerAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 相机分辨率微调器:挂到 effectEventController 单例对象上(它负责相机 pivot,微调经其安全施加)。
|
||||
// 独立于上面的标志重试——effectEventController 可能稍晚才初始化。
|
||||
if (!_cameraTunerAttached)
|
||||
{
|
||||
var eff = effectEventController.Instance;
|
||||
if (eff != null)
|
||||
{
|
||||
if (eff.GetComponent<CameraResolutionTuner>() == null)
|
||||
eff.gameObject.AddComponent<CameraResolutionTuner>();
|
||||
_cameraTunerAttached = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
private bool _trackStabilizerAttached;
|
||||
private bool _cameraTunerAttached;
|
||||
|
||||
// gameplay 敌人容器(普通 Transform,非 RectTransform):让其跟随屏幕左缘。
|
||||
// 敌人显示对象是 enemyObject_toWiggle,其定位父级是名为 empty 的普通 Transform,
|
||||
// 挂在 enemyHealthUI 的 ScreenSpaceCamera Canvas 下。给该定位父级附加 UITransformEdgeFollow(左)。
|
||||
private void ScanEnemyFollow()
|
||||
{
|
||||
if (_enemyFollowAttached) return; // 已附加则不再每次 GameObject.Find(字符串查找较贵)。
|
||||
GameObject enemyObj = GameObject.Find("enemyObject_toWiggle");
|
||||
if (enemyObj == null) return; // Find 只找 active 对象;敌人未激活时下次补扫再处理。
|
||||
// 取其定位父级 empty(承载横向偏移的那层);找不到就用自身。
|
||||
Transform target = enemyObj.transform.parent != null ? enemyObj.transform.parent : enemyObj.transform;
|
||||
if (target.GetComponent<UITransformEdgeFollow>() == null)
|
||||
target.gameObject.AddComponent<UITransformEdgeFollow>();
|
||||
_enemyFollowAttached = true;
|
||||
}
|
||||
private bool _enemyFollowAttached;
|
||||
|
||||
// 扫描并处理世界空间 Sprite 背景(覆盖 gameplay 相机渲染的底图)。
|
||||
private static void ScanSpriteBackgrounds()
|
||||
{
|
||||
SpriteRenderer[] sprites = FindObjectsByType<SpriteRenderer>(FindObjectsInactive.Include, FindObjectsSortMode.None);
|
||||
for (int i = 0; i < sprites.Length; i++)
|
||||
{
|
||||
SpriteRenderer sr = sprites[i];
|
||||
if (sr == null) continue;
|
||||
// 仅处理"背景类":sortingOrder 很低(<= -100,如 bg_replace 的 -999)。
|
||||
// 这类是铺底背景,需覆盖;普通游戏 Sprite(音符/角色/特效)不动。
|
||||
if (sr.sortingOrder > -100) continue;
|
||||
if (sr.GetComponent<UISpriteBackgroundCover>() != null) continue;
|
||||
sr.gameObject.AddComponent<UISpriteBackgroundCover>();
|
||||
}
|
||||
}
|
||||
|
||||
// 已知“独立、不属于任何布局组”的边角小部件/HUD 容器精确名单。
|
||||
// 仅这些会被贴边跟随;功能界面的成组内容一律不动,避免打散版式。
|
||||
private static readonly HashSet<string> EdgeFollowWhitelist = new HashSet<string>
|
||||
{
|
||||
// 主界面:世界聊天入口按钮(独立浮标,中心锚定+右偏移)。
|
||||
"chat",
|
||||
// Main_main 主菜单边角独立元素:跟随屏幕边缘。
|
||||
// 不含 logo/game_Logo/selectServer/anyButton(选服/进入按钮与logo,保持设计位置)。
|
||||
"exit", "help", "supporting", "steamIcon", "uid", "fdLogo", "logo_fdLogo",
|
||||
// 版本号文本(左上角,挂在 logo 下)。logo 保持居中不动,故 bbt 需独立贴左缘。
|
||||
// 名字在 Main_main 唯一,直接加名不会误伤。
|
||||
"bbt",
|
||||
};
|
||||
|
||||
// 父级名 → 子级名:偏移在通用名子级上的边角元素(直接把通用名加白名单会误伤同名对象)。
|
||||
// steamIcon(父)本身偏移为0,真正贴左下角的是它名为 "Image" 的子级(-1751.5,-916)。
|
||||
private static readonly Dictionary<string, string> ParentChildEdgeFollow = new Dictionary<string, string>
|
||||
{
|
||||
{ "steamIcon", "Image" },
|
||||
};
|
||||
|
||||
private static void TryAttachEdgeFollow(RectTransform rt)
|
||||
{
|
||||
if (rt == null) return;
|
||||
if (rt.GetComponent<UIEdgeFollow>() != null) return;
|
||||
// 仅横向未拉伸 + 锚点接近中心(其余情形本就贴边或拉伸,无需处理)。
|
||||
if (Mathf.Abs(rt.anchorMax.x - rt.anchorMin.x) > 0.01f) return;
|
||||
if (Mathf.Abs(rt.anchorMin.x - 0.5f) > 0.01f) return;
|
||||
rt.gameObject.AddComponent<UIEdgeFollow>();
|
||||
}
|
||||
|
||||
// 判定 Canvas 是否属于结算(settlement)界面:祖先链里有名为 settlement 的对象。
|
||||
// 结算是整块按 1920 写死的居中布局(靠 match=1 锁高度天然居中),不做 HUD 边缘跟随以免打散版式。
|
||||
private static bool IsSettlementCanvas(Canvas canvas)
|
||||
{
|
||||
if (canvas == null) return false;
|
||||
Transform t = canvas.transform;
|
||||
while (t != null)
|
||||
{
|
||||
if (t.gameObject.name == "settlement") return true;
|
||||
t = t.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 跳过自管理的特殊 UI 根:按名匹配(gNotice 全局通知实例)。
|
||||
private static bool ShouldSkipCanvas(Canvas canvas)
|
||||
{
|
||||
if (canvas == null) return false;
|
||||
string n = canvas.gameObject.name;
|
||||
// gNotice 实例根名 "GlobalNoticeSystem_SceneInstance" 及其 prefab 名。
|
||||
if (n.Contains("GlobalNoticeSystem")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判定是否为全屏背景:居中锚定(非拉伸)且尺寸接近设计分辨率(或其 2 倍)。
|
||||
/// 排除已经是全拉伸(0-1)的(那类天然铺满,无需处理)与尺寸偏小的普通 UI。
|
||||
/// </summary>
|
||||
private static bool IsFullscreenBackground(RectTransform rt)
|
||||
{
|
||||
Vector2 aMin = rt.anchorMin;
|
||||
Vector2 aMax = rt.anchorMax;
|
||||
|
||||
// 已全拉伸:无需处理。
|
||||
bool stretched = Mathf.Abs(aMax.x - aMin.x) > 0.99f && Mathf.Abs(aMax.y - aMin.y) > 0.99f;
|
||||
if (stretched) return false;
|
||||
|
||||
// 仅处理居中/固定锚定(锚点宽高跨度接近 0)。
|
||||
bool fixedAnchor = Mathf.Abs(aMax.x - aMin.x) < 0.01f && Mathf.Abs(aMax.y - aMin.y) < 0.01f;
|
||||
if (!fixedAnchor) return false;
|
||||
|
||||
Vector2 size = rt.sizeDelta;
|
||||
bool matchDesign = Mathf.Abs(size.x - DesignWidth) < SizeTolerance
|
||||
&& Mathf.Abs(size.y - DesignHeight) < SizeTolerance;
|
||||
bool matchDouble = Mathf.Abs(size.x - DesignWidth * 2f) < SizeTolerance * 2f
|
||||
&& Mathf.Abs(size.y - DesignHeight * 2f) < SizeTolerance * 2f;
|
||||
return matchDesign || matchDouble;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5ab6a774083052748a8da59390e563dc
|
||||
@@ -0,0 +1,62 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// gameplay 世界空间背景(SpriteRenderer,如 bg_replace)自适应覆盖。
|
||||
/// 该背景由透视相机渲染、带旋转/缩放,尺寸按 16:9 设计。超宽屏(21:9/32:9)下相机
|
||||
/// 水平视野扩大会看到背景边缘之外的黑边,故按屏幕宽高比等比放大其 localScale 使其覆盖。
|
||||
///
|
||||
/// 仅调整 localScale(表现层),不改旋转/位置/材质/引用,不影响业务逻辑与动画。
|
||||
/// 16:9 屏下缩放系数=1,与原设计逐像素一致。由 UIResolutionAutoFitter 自动附加。
|
||||
///
|
||||
/// 满足三要求:1) 不改业务逻辑/引用;2) 16:9 缩放=1、不动旋转位置;3) 超宽屏铺满。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(SpriteRenderer))]
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class UISpriteBackgroundCover : MonoBehaviour
|
||||
{
|
||||
private const float DesignAspect = 16f / 9f;
|
||||
|
||||
private Transform tf;
|
||||
private Vector3 baseScale; // 初始设计缩放(16:9 基准),永不变
|
||||
private bool baseCaptured;
|
||||
private int lastW = -1, lastH = -1;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
tf = transform;
|
||||
CaptureBaseScale();
|
||||
Apply();
|
||||
}
|
||||
|
||||
private void CaptureBaseScale()
|
||||
{
|
||||
if (baseCaptured) return;
|
||||
baseScale = tf.localScale;
|
||||
baseCaptured = true;
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (Screen.width != lastW || Screen.height != lastH)
|
||||
{
|
||||
Apply();
|
||||
}
|
||||
}
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
if (!baseCaptured) CaptureBaseScale();
|
||||
lastW = Screen.width;
|
||||
lastH = Screen.height;
|
||||
|
||||
float aspect = (float)Screen.width / Mathf.Max(1, Screen.height);
|
||||
// 更宽的屏:水平视野变宽,需按 aspect/DesignAspect 放大以覆盖两侧。
|
||||
// 更窄的屏:垂直视野相对更高,需按 DesignAspect/aspect 放大以覆盖上下。
|
||||
// 取二者较大值(cover),保证任一方向都不露黑边。16:9 时系数=1。
|
||||
float widthFactor = aspect > DesignAspect ? aspect / DesignAspect : 1f;
|
||||
float heightFactor = aspect < DesignAspect ? DesignAspect / aspect : 1f;
|
||||
float factor = Mathf.Max(widthFactor, heightFactor);
|
||||
|
||||
tf.localScale = baseScale * factor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d9611e36b8928ad4183b10e194cfa053
|
||||
@@ -0,0 +1,104 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 让一个"普通 Transform"(非 RectTransform,如 gameplay 敌人容器 empty/enemyObject)在分辨率变化时
|
||||
/// 横向跟随其所属 Canvas 的某一屏幕边缘,保持到该边缘的间距恒定。
|
||||
///
|
||||
/// 用于敌人这类"用普通 Transform 本地坐标定位、挂在 ScreenSpaceCamera Canvas 下"的对象——
|
||||
/// 它们无锚点,UIEdgeFollow(要求 RectTransform)用不了。本组件自测量:运行时读取对象在所属
|
||||
/// Canvas 局部空间的 X,算出到选定边缘的间距并保持恒定,与该 Canvas 的精确坐标系无关(健壮)。
|
||||
///
|
||||
/// 仅改 localPosition.x(表现层),不改 Y/Z/旋转/缩放/引用,不与只改 alpha/scaleY 的入场动画冲突。
|
||||
/// 满足三要求:1) 不改业务逻辑/引用;2) 仅横向,首帧于设计分辨率(16:9)测量则位置逐像素不变;
|
||||
/// 3) 跟随边缘。
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class UITransformEdgeFollow : MonoBehaviour
|
||||
{
|
||||
public enum Edge { Left, Right }
|
||||
|
||||
[SerializeField] private Edge edge = Edge.Left;
|
||||
|
||||
// 设计基准宽度:gap 以 16:9(1920)设计画布为基准计算,与首次附加时的实际分辨率无关,
|
||||
// 保证 16:9 逐像素一致、其它分辨率下敌人始终贴同一边缘、露出同样部分。
|
||||
private const float DesignWidth = 1920f;
|
||||
|
||||
private Transform tf;
|
||||
private RectTransform rootRt; // 所属 Canvas 的 RectTransform
|
||||
private float gap; // 对象中心到选定边缘的间距(设计参考单位,恒定)
|
||||
private bool measured;
|
||||
private float lastRootW = -1f;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
tf = transform;
|
||||
ResolveRoot();
|
||||
Measure();
|
||||
}
|
||||
|
||||
private void ResolveRoot()
|
||||
{
|
||||
Canvas c = GetComponentInParent<Canvas>();
|
||||
if (c != null)
|
||||
{
|
||||
Canvas root = c.rootCanvas != null ? c.rootCanvas : c;
|
||||
rootRt = root.GetComponent<RectTransform>();
|
||||
}
|
||||
}
|
||||
|
||||
private void Measure()
|
||||
{
|
||||
if (measured || tf == null || rootRt == null) return;
|
||||
float rootW = rootRt.rect.width;
|
||||
if (rootW <= 0f) return;
|
||||
|
||||
// 对象在 Canvas 局部空间的 X。该值由固定 localPosition 决定,与分辨率无关(缩放均匀),
|
||||
// 即为设计位置。Canvas 关于中心对称(rect 以 0 为中心),故设计左/右缘 = ∓DesignWidth/2。
|
||||
float localX = rootRt.InverseTransformPoint(tf.position).x;
|
||||
float designLeft = -DesignWidth * 0.5f; // 16:9 设计左缘 = -960
|
||||
float designRight = DesignWidth * 0.5f; // 16:9 设计右缘 = +960
|
||||
// gap = 对象中心到选定边缘的距离(设计参考单位,恒定)。
|
||||
// 敌人 gap 会是负值(设计上就在左缘之外,只露右半)——正是要保持的视觉。
|
||||
gap = edge == Edge.Left ? (localX - designLeft) : (designRight - localX);
|
||||
lastRootW = rootW;
|
||||
measured = true;
|
||||
|
||||
// 应用一次,使当前分辨率立即到位(不必等分辨率变化)。
|
||||
ApplyToEdge();
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (rootRt == null) { ResolveRoot(); return; }
|
||||
if (!measured) { Measure(); return; }
|
||||
|
||||
float rootW = rootRt.rect.width;
|
||||
if (Mathf.Abs(rootW - lastRootW) <= 0.5f) return;
|
||||
lastRootW = rootW;
|
||||
ApplyToEdge();
|
||||
}
|
||||
|
||||
// 让对象中心距当前画布选定边缘保持恒定的 gap(设计参考单位)。
|
||||
// 画布关于中心对称,故当前左缘 = -rootW/2、右缘 = +rootW/2。
|
||||
private void ApplyToEdge()
|
||||
{
|
||||
if (tf == null || rootRt == null || !measured) return;
|
||||
float rootW = rootRt.rect.width;
|
||||
if (rootW <= 0f) return;
|
||||
|
||||
float curLeft = -rootW * 0.5f;
|
||||
float curRight = rootW * 0.5f;
|
||||
float targetLocalX = edge == Edge.Left ? (curLeft + gap) : (curRight - gap);
|
||||
|
||||
float curLocalX = rootRt.InverseTransformPoint(tf.position).x;
|
||||
float deltaLocalX = targetLocalX - curLocalX;
|
||||
if (Mathf.Abs(deltaLocalX) <= 0.01f) return;
|
||||
|
||||
// 把 Canvas 局部 X 位移换算到对象自身父空间的 localPosition.x。
|
||||
Vector3 worldCur = tf.position;
|
||||
Vector3 localCurInCanvas = rootRt.InverseTransformPoint(worldCur);
|
||||
Vector3 worldTarget = rootRt.TransformPoint(new Vector3(targetLocalX, localCurInCanvas.y, localCurInCanvas.z));
|
||||
Vector3 parentTarget = tf.parent != null ? tf.parent.InverseTransformPoint(worldTarget) : worldTarget;
|
||||
tf.localPosition = new Vector3(parentTarget.x, tf.localPosition.y, tf.localPosition.z);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2faaf8d3e94085a4784340c9bf1db125
|
||||
@@ -409,7 +409,9 @@ public class UnimplementedFeaturePrompt : MonoBehaviour
|
||||
{
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920f, 1080f);
|
||||
scaler.matchWidthOrHeight = 0.5f;
|
||||
// 锁高度缩放:分辨率跟随屏幕后,非 16:9 屏也能保证各 UI 层一致缩放、不错位。
|
||||
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
|
||||
scaler.matchWidthOrHeight = 1f;
|
||||
}
|
||||
|
||||
return canvas;
|
||||
|
||||
@@ -27,6 +27,8 @@ public sealed class FriendCardViewData
|
||||
public string SteamId;
|
||||
public string DisplayName;
|
||||
public string AvatarUrl;
|
||||
public string PlayerTitleId;
|
||||
public string PlayerTitle;
|
||||
public string StatusText;
|
||||
public bool IsOnline;
|
||||
public bool IsStarred;
|
||||
@@ -362,6 +364,12 @@ public class friendSystem : MonoBehaviour
|
||||
steamFriend != null ? steamFriend.AvatarUrl : string.Empty,
|
||||
NetworkManager.ResolveAvatarUrl(gameFriend.friend_avatar, steamId),
|
||||
steamId),
|
||||
PlayerTitleId = !string.IsNullOrWhiteSpace(gameFriend.player_title_id)
|
||||
? gameFriend.player_title_id
|
||||
: (steamFriend != null ? steamFriend.PlayerTitleId : string.Empty),
|
||||
PlayerTitle = !string.IsNullOrWhiteSpace(gameFriend.player_title)
|
||||
? gameFriend.player_title
|
||||
: (steamFriend != null ? steamFriend.PlayerTitle : string.Empty),
|
||||
StatusText = mergedStatus,
|
||||
IsOnline = mergedOnline,
|
||||
IsStarred = LoadStarredState(steamId),
|
||||
@@ -419,6 +427,12 @@ public class friendSystem : MonoBehaviour
|
||||
steamFriend != null ? steamFriend.AvatarUrl : string.Empty,
|
||||
NetworkManager.ResolveAvatarUrl(profile.avatar_url, steamId),
|
||||
steamId),
|
||||
PlayerTitleId = !string.IsNullOrWhiteSpace(profile.player_title_id)
|
||||
? profile.player_title_id
|
||||
: (!string.IsNullOrWhiteSpace(profile.primary_honor?.honor_key) ? profile.primary_honor.honor_key : string.Empty),
|
||||
PlayerTitle = !string.IsNullOrWhiteSpace(profile.player_title)
|
||||
? profile.player_title
|
||||
: (!string.IsNullOrWhiteSpace(profile.primary_honor?.honor_name) ? profile.primary_honor.honor_name : string.Empty),
|
||||
StatusText = mergedStatus,
|
||||
IsOnline = mergedOnline,
|
||||
IsStarred = LoadStarredState(steamId),
|
||||
|
||||
+166
-16
@@ -1,3 +1,4 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
@@ -7,36 +8,70 @@ public class gTransBlack : MonoBehaviour
|
||||
[SerializeField] private Canvas b_Canvas;
|
||||
[SerializeField] private CanvasGroup b_CG;
|
||||
[SerializeField] private Text nowLoadingText;
|
||||
[SerializeField] private int postSceneLoadCameraRefreshFrames = 12;
|
||||
|
||||
public Canvas Canvas => b_Canvas;
|
||||
public CanvasGroup CanvasGroup => b_CG;
|
||||
|
||||
private Camera boundCamera;
|
||||
private Coroutine cameraRefreshRoutine;
|
||||
private int lastScreenWidth;
|
||||
private int lastScreenHeight;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
EnsureReferences();
|
||||
ApplySceneCamera();
|
||||
ConfigureCanvasForLetterbox();
|
||||
ApplySceneCamera(true);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
SceneManager.sceneLoaded -= HandleSceneLoaded;
|
||||
SceneManager.sceneLoaded += HandleSceneLoaded;
|
||||
ApplySceneCamera();
|
||||
SceneManager.activeSceneChanged -= HandleActiveSceneChanged;
|
||||
SceneManager.activeSceneChanged += HandleActiveSceneChanged;
|
||||
|
||||
ConfigureCanvasForLetterbox();
|
||||
StartCameraRefreshRoutine();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
SceneManager.sceneLoaded -= HandleSceneLoaded;
|
||||
SceneManager.activeSceneChanged -= HandleActiveSceneChanged;
|
||||
|
||||
if (cameraRefreshRoutine != null)
|
||||
{
|
||||
StopCoroutine(cameraRefreshRoutine);
|
||||
cameraRefreshRoutine = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (Screen.width != lastScreenWidth || Screen.height != lastScreenHeight || NeedsCameraRefresh())
|
||||
{
|
||||
ConfigureCanvasForLetterbox();
|
||||
ApplySceneCamera(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleSceneLoaded(Scene scene, LoadSceneMode mode)
|
||||
{
|
||||
ApplySceneCamera();
|
||||
StartCameraRefreshRoutine();
|
||||
}
|
||||
|
||||
private void HandleActiveSceneChanged(Scene oldScene, Scene newScene)
|
||||
{
|
||||
StartCameraRefreshRoutine();
|
||||
}
|
||||
|
||||
public void RefreshCanvasCamera()
|
||||
{
|
||||
ApplySceneCamera();
|
||||
ConfigureCanvasForLetterbox();
|
||||
ApplySceneCamera(true);
|
||||
StartCameraRefreshRoutine();
|
||||
}
|
||||
|
||||
public void SetNowLoadingText(string text)
|
||||
@@ -64,7 +99,7 @@ public class gTransBlack : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySceneCamera()
|
||||
private void ConfigureCanvasForLetterbox()
|
||||
{
|
||||
EnsureReferences();
|
||||
|
||||
@@ -73,10 +108,9 @@ public class gTransBlack : MonoBehaviour
|
||||
return;
|
||||
}
|
||||
|
||||
if (b_Canvas.renderMode == RenderMode.ScreenSpaceOverlay)
|
||||
if (b_Canvas.renderMode != RenderMode.ScreenSpaceCamera)
|
||||
{
|
||||
b_Canvas.worldCamera = null;
|
||||
return;
|
||||
b_Canvas.renderMode = RenderMode.ScreenSpaceCamera;
|
||||
}
|
||||
|
||||
if (b_Canvas.transform.localScale == Vector3.zero)
|
||||
@@ -84,19 +118,107 @@ public class gTransBlack : MonoBehaviour
|
||||
b_Canvas.transform.localScale = Vector3.one;
|
||||
}
|
||||
|
||||
if (b_Canvas.planeDistance <= 0f)
|
||||
{
|
||||
b_Canvas.planeDistance = 100f;
|
||||
}
|
||||
|
||||
b_Canvas.sortingOrder = short.MaxValue;
|
||||
|
||||
CanvasScaler scaler = b_Canvas.GetComponent<CanvasScaler>();
|
||||
if (scaler != null)
|
||||
{
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920f, 1080f);
|
||||
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
|
||||
// 锁高度缩放:分辨率跟随屏幕后,非 16:9 屏也能保证各 UI 层一致缩放、不错位。
|
||||
scaler.matchWidthOrHeight = 1f;
|
||||
}
|
||||
}
|
||||
|
||||
private bool ApplySceneCamera(bool force)
|
||||
{
|
||||
EnsureReferences();
|
||||
|
||||
if (b_Canvas == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ConfigureCanvasForLetterbox();
|
||||
|
||||
Camera targetCamera = ResolveFirstSceneCamera();
|
||||
if (targetCamera == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (force || b_Canvas.worldCamera != targetCamera || boundCamera != targetCamera)
|
||||
{
|
||||
b_Canvas.worldCamera = targetCamera;
|
||||
boundCamera = targetCamera;
|
||||
}
|
||||
|
||||
lastScreenWidth = Screen.width;
|
||||
lastScreenHeight = Screen.height;
|
||||
Canvas.ForceUpdateCanvases();
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool NeedsCameraRefresh()
|
||||
{
|
||||
if (b_Canvas == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (boundCamera == null || !boundCamera.isActiveAndEnabled)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (b_Canvas.renderMode != RenderMode.ScreenSpaceCamera)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return b_Canvas.worldCamera != boundCamera;
|
||||
}
|
||||
|
||||
private void StartCameraRefreshRoutine()
|
||||
{
|
||||
if (!isActiveAndEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
b_Canvas.worldCamera = targetCamera;
|
||||
if (cameraRefreshRoutine != null)
|
||||
{
|
||||
StopCoroutine(cameraRefreshRoutine);
|
||||
}
|
||||
|
||||
cameraRefreshRoutine = StartCoroutine(RefreshCameraForSeveralFrames());
|
||||
}
|
||||
|
||||
private IEnumerator RefreshCameraForSeveralFrames()
|
||||
{
|
||||
int frames = Mathf.Max(1, postSceneLoadCameraRefreshFrames);
|
||||
for (int i = 0; i < frames; i++)
|
||||
{
|
||||
ApplySceneCamera(true);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
yield return new WaitForEndOfFrame();
|
||||
ApplySceneCamera(true);
|
||||
cameraRefreshRoutine = null;
|
||||
}
|
||||
|
||||
private static Camera ResolveFirstSceneCamera()
|
||||
{
|
||||
Scene activeScene = SceneManager.GetActiveScene();
|
||||
Camera mainCam = Camera.main;
|
||||
if (mainCam != null && mainCam.gameObject.scene.IsValid() && mainCam.gameObject.scene.isLoaded)
|
||||
if (IsUsableSceneCamera(mainCam, activeScene, true))
|
||||
{
|
||||
return mainCam;
|
||||
}
|
||||
@@ -105,19 +227,47 @@ public class gTransBlack : MonoBehaviour
|
||||
for (int i = 0; i < cameras.Length; i++)
|
||||
{
|
||||
Camera camera = cameras[i];
|
||||
if (camera == null || !camera.isActiveAndEnabled)
|
||||
if (IsUsableSceneCamera(camera, activeScene, true))
|
||||
{
|
||||
continue;
|
||||
return camera;
|
||||
}
|
||||
}
|
||||
|
||||
if (!camera.gameObject.scene.IsValid() || !camera.gameObject.scene.isLoaded)
|
||||
if (IsUsableSceneCamera(mainCam, activeScene, false))
|
||||
{
|
||||
return mainCam;
|
||||
}
|
||||
|
||||
for (int i = 0; i < cameras.Length; i++)
|
||||
{
|
||||
Camera camera = cameras[i];
|
||||
if (IsUsableSceneCamera(camera, activeScene, false))
|
||||
{
|
||||
continue;
|
||||
return camera;
|
||||
}
|
||||
|
||||
return camera;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsUsableSceneCamera(Camera camera, Scene preferredScene, bool requirePreferredScene)
|
||||
{
|
||||
if (camera == null || !camera.isActiveAndEnabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Scene cameraScene = camera.gameObject.scene;
|
||||
if (!cameraScene.IsValid() || !cameraScene.isLoaded)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (requirePreferredScene && cameraScene != preferredScene)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ public class AnimationController : MonoBehaviour
|
||||
private bool isHolding = false;
|
||||
private static bool loggedMissingHitParticleSource;
|
||||
|
||||
// Track active particle instances for immediate cleanup
|
||||
private List<GameObject> activeParticles = new List<GameObject>();
|
||||
// Track active hold-loop particle instances for immediate cleanup on hold release.
|
||||
private readonly List<GameObject> activeHoldParticles = new List<GameObject>();
|
||||
|
||||
// Per-hit FX pooling. PlayDestroyAnimation used to Instantiate + Destroy three
|
||||
// objects on every judge (hit particle, hit ring, judge text prefab). Under dense
|
||||
@@ -53,7 +53,18 @@ public class AnimationController : MonoBehaviour
|
||||
private sealed class FxCacheEntry
|
||||
{
|
||||
public ParticleSystem[] Systems;
|
||||
public Animator[] Animators;
|
||||
public float Lifetime;
|
||||
// 模板(prefab/场景源)的设计本地旋转。特效作者常给 ring 之类加了朝向(如 -90°X),
|
||||
// 播放时必须沿用,否则扁平粒子侧向对相机几乎不可见。
|
||||
public Quaternion LocalRotation;
|
||||
// 根粒子系统:只对它 Play(true)(withChildren 级联到激活的子系统),避免对每个子系统重复触发 burst。
|
||||
public ParticleSystem RootSystem;
|
||||
// clone 时每个子物体的原始 activeSelf:复用时恢复到这个初始态。
|
||||
// 关键:特效里有默认 inactive 的备用子层(如 CFXR3 的 "Ring"、trackStar 的 Spikes/Aura),
|
||||
// 绝不能被复用逻辑盲目激活(会粒子变多);而 stopAction=Disable 误关的该显示层要恢复回来。
|
||||
public Transform[] ChildTransforms;
|
||||
public bool[] ChildInitialActive;
|
||||
}
|
||||
|
||||
private readonly Dictionary<GameObject, Queue<GameObject>> fxPools =
|
||||
@@ -73,6 +84,7 @@ public class AnimationController : MonoBehaviour
|
||||
private int fxRentCounter;
|
||||
private readonly Dictionary<GameObject, int> fxRentId = new Dictionary<GameObject, int>();
|
||||
private Transform fxPoolRoot;
|
||||
private string hitRingPathUnderParticleSource;
|
||||
|
||||
private void EnsureFxPoolRoot()
|
||||
{
|
||||
@@ -85,6 +97,71 @@ public class AnimationController : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
private static Transform GetVisibleFxParent(Transform candidate)
|
||||
{
|
||||
return candidate != null && candidate.gameObject.activeInHierarchy ? candidate : null;
|
||||
}
|
||||
|
||||
private static string BuildRelativePath(Transform root, Transform child)
|
||||
{
|
||||
if (root == null || child == null || child == root || !child.IsChildOf(root))
|
||||
return null;
|
||||
|
||||
var names = new List<string>();
|
||||
Transform current = child;
|
||||
while (current != null && current != root)
|
||||
{
|
||||
names.Add(current.name);
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
names.Reverse();
|
||||
return names.Count > 0 ? string.Join("/", names.ToArray()) : null;
|
||||
}
|
||||
|
||||
private void CacheNestedRingPath()
|
||||
{
|
||||
hitRingPathUnderParticleSource = null;
|
||||
if (hit_particular_object == null || hit_ring_object == null)
|
||||
return;
|
||||
|
||||
Transform particleRoot = hit_particular_object.transform;
|
||||
Transform ringTransform = hit_ring_object.transform;
|
||||
if (ringTransform != particleRoot && ringTransform.IsChildOf(particleRoot))
|
||||
{
|
||||
hitRingPathUnderParticleSource = BuildRelativePath(particleRoot, ringTransform);
|
||||
}
|
||||
}
|
||||
|
||||
private Transform GetNestedRingCloneForParticleTemplate(GameObject template, GameObject instance)
|
||||
{
|
||||
if (template != hit_particular_object || instance == null || string.IsNullOrEmpty(hitRingPathUnderParticleSource))
|
||||
return null;
|
||||
|
||||
return instance.transform.Find(hitRingPathUnderParticleSource);
|
||||
}
|
||||
|
||||
private static ParticleSystem[] FilterParticleSystems(ParticleSystem[] systems, Transform excludedRoot)
|
||||
{
|
||||
if (systems == null || systems.Length == 0 || excludedRoot == null)
|
||||
return systems ?? new ParticleSystem[0];
|
||||
|
||||
var filtered = new List<ParticleSystem>(systems.Length);
|
||||
foreach (var ps in systems)
|
||||
{
|
||||
if (ps == null)
|
||||
continue;
|
||||
|
||||
Transform t = ps.transform;
|
||||
if (t == excludedRoot || t.IsChildOf(excludedRoot))
|
||||
continue;
|
||||
|
||||
filtered.Add(ps);
|
||||
}
|
||||
|
||||
return filtered.ToArray();
|
||||
}
|
||||
|
||||
// Creates one instance of the template, caches its particle systems + lifetime,
|
||||
// warms the play path once, and leaves it stopped/cleared ready for reuse.
|
||||
private GameObject CreateFxInstance(GameObject template)
|
||||
@@ -92,20 +169,55 @@ public class AnimationController : MonoBehaviour
|
||||
if (template == null)
|
||||
return null;
|
||||
|
||||
GameObject instance = Instantiate(template);
|
||||
var systems = instance.GetComponentsInChildren<ParticleSystem>(true);
|
||||
EnsureFxPoolRoot();
|
||||
GameObject instance = Instantiate(template, fxPoolRoot, false);
|
||||
instance.SetActive(false);
|
||||
Transform excludedNestedRing = GetNestedRingCloneForParticleTemplate(template, instance);
|
||||
if (excludedNestedRing != null)
|
||||
{
|
||||
excludedNestedRing.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
var systems = FilterParticleSystems(instance.GetComponentsInChildren<ParticleSystem>(true), excludedNestedRing);
|
||||
if (systems == null)
|
||||
systems = new ParticleSystem[0];
|
||||
var animators = instance.GetComponentsInChildren<Animator>(true);
|
||||
if (animators == null)
|
||||
animators = new Animator[0];
|
||||
|
||||
// 根粒子系统(实例根上的 PS,或第一个未被排除的 PS)。只对它 Play(true) 级联播放。
|
||||
ParticleSystem rootPs = instance.GetComponent<ParticleSystem>();
|
||||
if (rootPs == null && systems.Length > 0) rootPs = systems[0];
|
||||
|
||||
// 记录每个子物体(含自身外的所有后代)的初始 activeSelf——此刻已处理完嵌套 ring 的 SetActive(false),
|
||||
// 即为"设计初始态"。复用时据此恢复,既不激活默认关闭的备用层,也能修好被 stopAction=Disable 误关的层。
|
||||
Transform[] allChildren = instance.GetComponentsInChildren<Transform>(true);
|
||||
var childList = new List<Transform>(allChildren.Length);
|
||||
var childActive = new List<bool>(allChildren.Length);
|
||||
foreach (var ct in allChildren)
|
||||
{
|
||||
if (ct == null || ct == instance.transform) continue; // 跳过根(根由 SetActive 单独控制)
|
||||
childList.Add(ct);
|
||||
childActive.Add(ct.gameObject.activeSelf);
|
||||
}
|
||||
|
||||
var entry = new FxCacheEntry
|
||||
{
|
||||
Systems = systems,
|
||||
Lifetime = ComputeFxLifetime(systems)
|
||||
Animators = animators,
|
||||
Lifetime = ComputeFxLifetime(systems, animators),
|
||||
// 记录模板设计本地旋转(clone 自 template,此时 localRotation 即模板设计朝向)。
|
||||
LocalRotation = template.transform.localRotation,
|
||||
RootSystem = rootPs,
|
||||
ChildTransforms = childList.ToArray(),
|
||||
ChildInitialActive = childActive.ToArray()
|
||||
};
|
||||
|
||||
foreach (var ps in systems)
|
||||
{
|
||||
if (ps == null) continue;
|
||||
var main = ps.main;
|
||||
main.stopAction = ParticleSystemStopAction.None;
|
||||
try { ps.Play(true); } catch { }
|
||||
try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { }
|
||||
try { ps.Clear(true); } catch { }
|
||||
@@ -130,6 +242,7 @@ public class AnimationController : MonoBehaviour
|
||||
GameObject pooled = pool.Dequeue();
|
||||
if (pooled != null && fxCache.TryGetValue(pooled, out entry))
|
||||
{
|
||||
pooled.transform.SetParent(null, false);
|
||||
rentToken = MarkRented(pooled);
|
||||
return pooled;
|
||||
}
|
||||
@@ -168,15 +281,34 @@ public class AnimationController : MonoBehaviour
|
||||
return;
|
||||
|
||||
fxRentId.Remove(instance);
|
||||
activeParticles.Remove(instance);
|
||||
activeHoldParticles.Remove(instance);
|
||||
|
||||
if (fxCache.TryGetValue(instance, out var entry) && entry.Systems != null)
|
||||
if (fxCache.TryGetValue(instance, out var entry))
|
||||
{
|
||||
foreach (var ps in entry.Systems)
|
||||
if (entry.Systems != null)
|
||||
{
|
||||
if (ps == null) continue;
|
||||
try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { }
|
||||
try { ps.Clear(true); } catch { }
|
||||
foreach (var ps in entry.Systems)
|
||||
{
|
||||
if (ps == null) continue;
|
||||
var main = ps.main;
|
||||
main.stopAction = ParticleSystemStopAction.None;
|
||||
try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { }
|
||||
try { ps.Clear(true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// 回收时把子物体激活态恢复到 clone 初始态:stopAction=Disable 的层播放完被 SetActive(false),
|
||||
// 若不恢复,下次复用就少一层(粒子变少/ring 不出现)。默认 inactive 的备用层仍保持关闭。
|
||||
if (entry.ChildTransforms != null && entry.ChildInitialActive != null)
|
||||
{
|
||||
for (int i = 0; i < entry.ChildTransforms.Length; i++)
|
||||
{
|
||||
var ct = entry.ChildTransforms[i];
|
||||
if (ct == null) continue;
|
||||
bool want = entry.ChildInitialActive[i];
|
||||
if (ct.gameObject.activeSelf != want)
|
||||
ct.gameObject.SetActive(want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +333,10 @@ public class AnimationController : MonoBehaviour
|
||||
|
||||
private IEnumerator RecycleFxRoutine(GameObject instance, int rentToken, float delay)
|
||||
{
|
||||
yield return GameplayClock.WaitForSeconds(delay);
|
||||
// Visual FX lifetimes must not depend on the DSP/song clock. If the chart
|
||||
// clock pauses, resets, or is not initialized, pooled particles still need
|
||||
// to return or the pool eventually runs out of reusable visible instances.
|
||||
yield return new WaitForSecondsRealtime(Mathf.Max(0f, delay));
|
||||
if (instance == null)
|
||||
yield break;
|
||||
|
||||
@@ -214,21 +349,124 @@ public class AnimationController : MonoBehaviour
|
||||
ReturnFxInstance(instance);
|
||||
}
|
||||
|
||||
private static float ComputeFxLifetime(ParticleSystem[] systems)
|
||||
private void ReturnAllRentedFx()
|
||||
{
|
||||
float maxLifetime = 0f;
|
||||
if (systems == null)
|
||||
return maxLifetime;
|
||||
|
||||
foreach (var ps in systems)
|
||||
var snapshot = new List<GameObject>(rentedFx);
|
||||
foreach (var instance in snapshot)
|
||||
{
|
||||
if (ps == null) continue;
|
||||
var main = ps.main;
|
||||
var lifetime = main.startLifetime;
|
||||
float life = lifetime.constantMax;
|
||||
if (life <= 0f) life = lifetime.constant;
|
||||
if (life > maxLifetime) maxLifetime = life;
|
||||
ReturnFxInstance(instance);
|
||||
}
|
||||
|
||||
activeHoldParticles.Clear();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
holdActive = false;
|
||||
holdParticleCoroutine = null;
|
||||
ReturnAllRentedFx();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (Global == this)
|
||||
{
|
||||
Global = null;
|
||||
}
|
||||
|
||||
ReturnAllRentedFx();
|
||||
}
|
||||
|
||||
private static void RestartFxPlayback(FxCacheEntry entry, Color tint)
|
||||
{
|
||||
if (entry == null)
|
||||
return;
|
||||
|
||||
// 第一步:把所有子物体的激活态恢复到 clone 初始态。
|
||||
// 这修复"粒子逐渐变少":stopAction=Disable 的粒子层播放完会被 Unity SetActive(false),
|
||||
// 回收/复用时若不恢复就少一层。同时不会激活默认 inactive 的备用层(它们初始态就是 false)。
|
||||
if (entry.ChildTransforms != null && entry.ChildInitialActive != null)
|
||||
{
|
||||
for (int i = 0; i < entry.ChildTransforms.Length; i++)
|
||||
{
|
||||
var ct = entry.ChildTransforms[i];
|
||||
if (ct == null) continue;
|
||||
bool want = entry.ChildInitialActive[i];
|
||||
if (ct.gameObject.activeSelf != want)
|
||||
ct.gameObject.SetActive(want);
|
||||
}
|
||||
}
|
||||
|
||||
// 第二步:停止并清空所有粒子系统的残留粒子,设 stopAction=None 防止播放结束再次自我禁用,
|
||||
// 并刷新 startColor。此处只 Stop/Clear,不逐个 Play。
|
||||
if (entry.Systems != null)
|
||||
{
|
||||
foreach (var ps in entry.Systems)
|
||||
{
|
||||
if (ps == null) continue;
|
||||
var main = ps.main;
|
||||
main.stopAction = ParticleSystemStopAction.None;
|
||||
main.startColor = tint;
|
||||
try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { }
|
||||
try { ps.Clear(true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// 第三步:只对根系统 Play(true)——withChildren 会级联播放当前处于激活态的子系统,
|
||||
// 不会触发默认 inactive 备用层。绝不逐个子系统 Play(那会对同层重复 burst=粒子变多)。
|
||||
if (entry.RootSystem != null)
|
||||
{
|
||||
try { entry.RootSystem.Play(true); } catch { }
|
||||
}
|
||||
else if (entry.Systems != null && entry.Systems.Length > 0 && entry.Systems[0] != null)
|
||||
{
|
||||
try { entry.Systems[0].Play(true); } catch { }
|
||||
}
|
||||
|
||||
// 这两个打击特效无 Animator;保留极简 Rebind 仅对"本就处于激活态"的 Animator(其它含动画的特效复用兜底),
|
||||
// 绝不为此激活任何子节点(那正是上一版把隐藏备用层强开导致粒子变多的错误)。
|
||||
if (entry.Animators != null)
|
||||
{
|
||||
foreach (var animator in entry.Animators)
|
||||
{
|
||||
if (animator == null || !animator.isActiveAndEnabled) continue;
|
||||
try { animator.Rebind(); animator.Update(0f); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static float ComputeFxLifetime(ParticleSystem[] systems, Animator[] animators)
|
||||
{
|
||||
float maxLifetime = 0.5f;
|
||||
if (systems != null)
|
||||
{
|
||||
foreach (var ps in systems)
|
||||
{
|
||||
if (ps == null) continue;
|
||||
var main = ps.main;
|
||||
var lifetime = main.startLifetime;
|
||||
float life = lifetime.constantMax;
|
||||
if (life <= 0f) life = lifetime.constant;
|
||||
maxLifetime = Mathf.Max(maxLifetime, main.duration + life + 0.25f);
|
||||
}
|
||||
}
|
||||
|
||||
if (animators != null)
|
||||
{
|
||||
foreach (var animator in animators)
|
||||
{
|
||||
if (animator == null || animator.runtimeAnimatorController == null)
|
||||
continue;
|
||||
|
||||
foreach (var clip in animator.runtimeAnimatorController.animationClips)
|
||||
{
|
||||
if (clip != null)
|
||||
maxLifetime = Mathf.Max(maxLifetime, clip.length + 0.1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxLifetime;
|
||||
}
|
||||
|
||||
@@ -245,95 +483,140 @@ public class AnimationController : MonoBehaviour
|
||||
public TextMeshProUGUI purple_track_judgementText;
|
||||
public TextMeshProUGUI blue_track_judgementText;
|
||||
|
||||
// 当前正在按住的长音符集合(按 hold note id)。不能按 trackIndex 记账:
|
||||
// 同一轨道上的旧段回收/下一条长音符会互相 Stop,导致长按后半段特效频率越来越低。
|
||||
private readonly HashSet<int> _activeHoldKeys = new HashSet<int>();
|
||||
// 各长音符当前颜色,供 routine 逐条播放对应色。
|
||||
private readonly Dictionary<int, string> _holdKeyColor = new Dictionary<int, string>();
|
||||
|
||||
public void StartHoldParticles(string color)
|
||||
{
|
||||
// Prefer the Global controller if available so Start/Stop always affect the same instance
|
||||
StartHoldParticles(color, 0);
|
||||
}
|
||||
|
||||
public void StartHoldParticles(string color, int holdKey)
|
||||
{
|
||||
if (AnimationController.Global != null && AnimationController.Global != this)
|
||||
{
|
||||
AnimationController.Global.InternalStartHoldParticles(color);
|
||||
AnimationController.Global.InternalStartHoldParticles(color, holdKey);
|
||||
return;
|
||||
}
|
||||
|
||||
InternalStartHoldParticles(color);
|
||||
InternalStartHoldParticles(color, holdKey);
|
||||
}
|
||||
|
||||
public void StopHoldParticles()
|
||||
{
|
||||
StopHoldParticles(0);
|
||||
}
|
||||
|
||||
public void StopHoldParticles(int holdKey)
|
||||
{
|
||||
if (AnimationController.Global != null && AnimationController.Global != this)
|
||||
{
|
||||
AnimationController.Global.InternalStopHoldParticles();
|
||||
AnimationController.Global.InternalStopHoldParticles(holdKey);
|
||||
return;
|
||||
}
|
||||
|
||||
InternalStopHoldParticles();
|
||||
InternalStopHoldParticles(holdKey);
|
||||
}
|
||||
|
||||
// Internal implementations operate on this instance
|
||||
private void InternalStartHoldParticles(string color)
|
||||
private void InternalStartHoldParticles(string color, int holdKey)
|
||||
{
|
||||
if (holdActive) return;
|
||||
|
||||
holdActive = true;
|
||||
currentHoldColor = color;
|
||||
_activeHoldKeys.Add(holdKey);
|
||||
_holdKeyColor[holdKey] = color;
|
||||
|
||||
if (holdParticleCoroutine != null)
|
||||
// 只要有轨道在按住就保证 routine 在跑;重复调用不会重启(避免 tick 相位错乱)。
|
||||
holdActive = true;
|
||||
if (holdParticleCoroutine == null)
|
||||
{
|
||||
StopCoroutine(holdParticleCoroutine);
|
||||
holdParticleCoroutine = StartCoroutine(HoldParticleRoutine());
|
||||
}
|
||||
|
||||
holdParticleCoroutine = StartCoroutine(HoldParticleRoutine(color));
|
||||
}
|
||||
|
||||
private void InternalStopHoldParticles()
|
||||
private void InternalStopHoldParticles(int holdKey)
|
||||
{
|
||||
holdActive = false;
|
||||
// 本长音符结束:从活跃集合移除。仍有其它长音符按住时,routine 继续跑。
|
||||
_activeHoldKeys.Remove(holdKey);
|
||||
_holdKeyColor.Remove(holdKey);
|
||||
|
||||
if (_activeHoldKeys.Count > 0)
|
||||
{
|
||||
return; // 还有别的长音符在按,不停整体循环。
|
||||
}
|
||||
|
||||
// 全部松手:只停止后续 tick。已经发出的命中特效是 one-shot,
|
||||
// 让它按自身 lifetime 自然回收,避免刚生成就被清掉造成后段频率变低。
|
||||
holdActive = false;
|
||||
if (holdParticleCoroutine != null)
|
||||
{
|
||||
try { StopCoroutine(holdParticleCoroutine); } catch { }
|
||||
holdParticleCoroutine = null;
|
||||
}
|
||||
|
||||
// Immediately return all active pooled instances (was Destroy). Returning is
|
||||
// idempotent and token-guarded, so a later timed RecycleFxRoutine for the same
|
||||
// instance becomes a no-op. Iterate a snapshot because ReturnFxInstance removes
|
||||
// the instance from activeParticles (mutating the list mid-enumeration would throw).
|
||||
var snapshot = activeParticles.ToArray();
|
||||
foreach (var p in snapshot)
|
||||
{
|
||||
if (p != null)
|
||||
{
|
||||
ReturnFxInstance(p);
|
||||
}
|
||||
}
|
||||
activeParticles.Clear();
|
||||
activeHoldParticles.Clear();
|
||||
}
|
||||
|
||||
private IEnumerator HoldParticleRoutine(string color)
|
||||
private IEnumerator HoldParticleRoutine()
|
||||
{
|
||||
while (holdActive)
|
||||
float nextTickTime = Time.unscaledTime + Mathf.Max(0.01f, holdParticleInterval);
|
||||
|
||||
while (holdActive && _activeHoldKeys.Count > 0)
|
||||
{
|
||||
float waitSeconds = Mathf.Max(0.01f, holdParticleInterval);
|
||||
yield return GameplayClock.WaitForSeconds(waitSeconds);
|
||||
if (!holdActive) break;
|
||||
PlayDestroyAnimation(color);
|
||||
while (holdActive && _activeHoldKeys.Count > 0 && Time.unscaledTime < nextTickTime)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
|
||||
if (!holdActive || _activeHoldKeys.Count == 0) break;
|
||||
nextTickTime += waitSeconds;
|
||||
if (Time.unscaledTime - nextTickTime > waitSeconds)
|
||||
{
|
||||
nextTickTime = Time.unscaledTime + waitSeconds;
|
||||
}
|
||||
|
||||
// 为当前所有按住的轨道各播一次持续粒子(逐轨对应色)。
|
||||
try
|
||||
{
|
||||
// 复制到临时数组避免迭代中集合被改。
|
||||
_holdTickBuffer.Clear();
|
||||
foreach (var kv in _holdKeyColor) _holdTickBuffer.Add(kv.Value);
|
||||
for (int i = 0; i < _holdTickBuffer.Count; i++)
|
||||
{
|
||||
PlayDestroyAnimation(_holdTickBuffer[i], true);
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[AnimationController] Hold particle tick failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
holdParticleCoroutine = null;
|
||||
}
|
||||
private readonly List<string> _holdTickBuffer = new List<string>();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (isGlobalController)
|
||||
// 自动当选 Global:场景里没有实例勾选 isGlobalController(全为 false),导致 Global 恒 null,
|
||||
// tap/长条判定的 PlayDestroyAnimation(Global ?? anim) 从不执行、hold 状态在多个 note 实例间错乱。
|
||||
// 判据:真正配置了特效源(hit_particular_object + hit_ring_object)的那个场景实例才是全局控制器;
|
||||
// note 预制体上挂的 20 个 AnimationController 这两个字段为空,不参与竞选。
|
||||
bool isConfiguredController = isGlobalController
|
||||
|| (hit_particular_object != null && hit_ring_object != null);
|
||||
if (isConfiguredController)
|
||||
{
|
||||
if (Global != null && Global != this)
|
||||
{
|
||||
Debug.LogWarning("[AnimationController] Duplicate global detected, destroying self");
|
||||
Destroy(this);
|
||||
return;
|
||||
if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[AnimationController] Duplicate global detected, ignoring self");
|
||||
// 不 Destroy(this):该组件可能挂在还承载其它引用的场景对象上,仅放弃全局身份即可。
|
||||
}
|
||||
else
|
||||
{
|
||||
Global = this;
|
||||
if (JudgeManager.IsDebugEnabled) Debug.Log("[AnimationController] Global AnimationController registered");
|
||||
}
|
||||
|
||||
Global = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
if (JudgeManager.IsDebugEnabled) Debug.Log("[AnimationController] Global AnimationController registered");
|
||||
}
|
||||
|
||||
// Animator cache
|
||||
@@ -378,9 +661,37 @@ public class AnimationController : MonoBehaviour
|
||||
"or tag the scene particle source with 'HitParticleSource', or place a GameObject named 'HitParticleSource' in the scene.");
|
||||
}
|
||||
}
|
||||
|
||||
CacheNestedRingPath();
|
||||
|
||||
// 从根上消除"开局克隆继承模板活粒子":把作为特效模板的场景源(hit_particular_object / hit_ring_object)
|
||||
// 自身的粒子系统停播并清空,使其成为纯静态模板、永不持有活粒子。这样任何时刻 Instantiate 出来的
|
||||
// 克隆都从零粒子开始,开局与后期完全一致(不再前多后少)。
|
||||
StopAndClearTemplate(hit_particular_object);
|
||||
StopAndClearTemplate(hit_ring_object);
|
||||
}
|
||||
|
||||
private static void StopAndClearTemplate(GameObject template)
|
||||
{
|
||||
if (template == null) return;
|
||||
var systems = template.GetComponentsInChildren<ParticleSystem>(true);
|
||||
if (systems == null) return;
|
||||
foreach (var ps in systems)
|
||||
{
|
||||
if (ps == null) continue;
|
||||
var main = ps.main;
|
||||
main.playOnAwake = false; // 模板绝不自动播放
|
||||
try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { }
|
||||
try { ps.Clear(true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public void PlayDestroyAnimation(string color)
|
||||
{
|
||||
PlayDestroyAnimation(color, false);
|
||||
}
|
||||
|
||||
private void PlayDestroyAnimation(string color, bool trackAsHoldParticle)
|
||||
{
|
||||
// Only use the scene object as the template. No prefab fallback.
|
||||
if (hit_particular_object == null)
|
||||
@@ -409,6 +720,8 @@ public class AnimationController : MonoBehaviour
|
||||
case "blue": if (blueEffect != null) { spawnPos = blueEffect.transform.position; parentTransform = blueEffect.transform; } break;
|
||||
}
|
||||
|
||||
parentTransform = GetVisibleFxParent(parentTransform);
|
||||
|
||||
// Documentation text normalized.
|
||||
Color trackColor;
|
||||
string hexCode = GetHexCodeForColor(color);
|
||||
@@ -420,84 +733,88 @@ public class AnimationController : MonoBehaviour
|
||||
if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"Failed to parse hex color for '{color}' ({hexCode}). Using white.");
|
||||
}
|
||||
|
||||
SpawnJudgePrefabByTrackText(color);
|
||||
// Rent a pooled copy of the scene particle source (was Instantiate + Destroy per hit).
|
||||
GameObject particleInstance = RentFxInstance(source, out var particleEntry, out int particleToken);
|
||||
if (particleInstance == null)
|
||||
// 判定字(PERFECT/GREAT 等)统一且仅由 Animation_GenerateJudgementSituationPrefab 负责生成,
|
||||
// 本方法只负责打击粒子 + ring,不碰判定字。
|
||||
|
||||
// 【彻底去池化】前几轮所有"粒子变多/变少、ring 不激活、复用状态错乱"都源于对象池复用时
|
||||
// 子节点激活态/粒子播放态无法可靠复位。改回最朴素可靠的 Instantiate + 定时 Destroy:
|
||||
// 每次判定 new 一个全新干净实例(与 prefab 设计态完全一致),播放完销毁。无复用=无脏状态。
|
||||
// 这正是加池化之前本就正常工作的行为。
|
||||
|
||||
// 粒子本体:以场景源为模板实例化一份全新拷贝。
|
||||
SpawnOneShotFx(source, spawnPos, parentTransform, trackColor, true);
|
||||
|
||||
// Ring(叠加环):tap / hold 头尾 / hold 持续 tick 都按同一频率生成一次性实例。
|
||||
// 实例会按自身生命周期 Destroy,不会修改或启动场景里的模板对象。
|
||||
if (hit_ring_object != null)
|
||||
{
|
||||
Debug.LogError("Failed to instantiate hit_particular_object");
|
||||
TriggerAnimatorEffect(color);
|
||||
return;
|
||||
SpawnOneShotFx(hit_ring_object, spawnPos, parentTransform, trackColor, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 实例化一个一次性特效(粒子/ring),播放后按自身生命周期 Destroy。无对象池、无复用。
|
||||
// applyTint=true 时把粒子 startColor 染成轨道色(粒子本体);ring 保留自身配色(false)。
|
||||
private void SpawnOneShotFx(GameObject template, Vector3 spawnPos, Transform parentTransform, Color tint, bool applyTint)
|
||||
{
|
||||
if (template == null) return;
|
||||
|
||||
GameObject instance = Instantiate(template);
|
||||
if (instance == null) return;
|
||||
|
||||
// 去除粒子本体克隆里"自带的嵌套 ring":hit_ring_object 本身是 hit_particular_object 的子物体,
|
||||
// Instantiate(粒子本体) 会连它一起克隆(原色,未染色) → 与我另外单独实例化的染色 ring 叠成双层。
|
||||
// 先 SetActive(false) 立即让它停播,并从下面的系统列表中排除,再销毁;只保留单独实例化的染色 ring。
|
||||
Transform nestedRing = null;
|
||||
if (template == hit_particular_object && !string.IsNullOrEmpty(hitRingPathUnderParticleSource))
|
||||
{
|
||||
nestedRing = instance.transform.Find(hitRingPathUnderParticleSource);
|
||||
if (nestedRing != null)
|
||||
{
|
||||
nestedRing.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Place at spawn position, then optionally parent under the effect object so it
|
||||
// moves with UI/slot (worldPositionStays=true keeps world pos at spawnPos).
|
||||
particleInstance.transform.SetParent(parentTransform, false);
|
||||
particleInstance.transform.position = spawnPos;
|
||||
particleInstance.transform.rotation = Quaternion.identity;
|
||||
particleInstance.SetActive(true);
|
||||
// 定位:跟随分轨效果对象(worldPositionStays=false 保留模板设计的本地朝向,如 ring 的 -90°X)。
|
||||
instance.transform.SetParent(parentTransform, false);
|
||||
instance.transform.position = spawnPos;
|
||||
instance.transform.localRotation = template.transform.localRotation;
|
||||
instance.SetActive(true);
|
||||
|
||||
var systems = particleEntry != null ? particleEntry.Systems : null;
|
||||
// 排除嵌套 ring 的粒子系统,避免把那层原色 ring 播出来。
|
||||
var systems = FilterParticleSystems(instance.GetComponentsInChildren<ParticleSystem>(true), nestedRing);
|
||||
if (nestedRing != null) Destroy(nestedRing.gameObject);
|
||||
float lifetime = 0.5f;
|
||||
if (systems != null && systems.Length > 0)
|
||||
{
|
||||
lifetime = ComputeFxLifetime(systems, null);
|
||||
// 关键修复(开局粒子多、之后变少的衰减):模板 hit_particular_object 是活动场景对象,
|
||||
// Instantiate 会连同模板粒子系统"当前缓冲里的活粒子"一起克隆——歌曲早期模板残留/预热粒子多
|
||||
// → 克隆多;随模板粒子消亡 → 克隆少。故必须把克隆继承的模拟状态彻底重置为"时间0、零粒子",
|
||||
// 再从头播放,保证每次一致、与模板当时状态无关。
|
||||
foreach (var ps in systems)
|
||||
{
|
||||
if (ps == null) continue;
|
||||
var main = ps.main;
|
||||
main.startColor = trackColor;
|
||||
ps.Play();
|
||||
if (applyTint) main.startColor = tint;
|
||||
try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { }
|
||||
try { ps.Clear(true); } catch { }
|
||||
// Simulate(0, withChildren:false, restart:true) 强制该系统回到时间 0 的干净态,
|
||||
// 抹掉 Instantiate 继承的任何已模拟粒子(单靠 Clear 有时不彻底)。
|
||||
try { ps.Simulate(0f, false, true); } catch { }
|
||||
try { ps.Clear(true); } catch { }
|
||||
}
|
||||
|
||||
// Recycle after cached lifetime + small buffer (token-guarded).
|
||||
StartCoroutine(RecycleFxRoutine(particleInstance, particleToken, Mathf.Max(0.5f, particleEntry.Lifetime + 0.1f)));
|
||||
|
||||
// Track for immediate cleanup on hold stop.
|
||||
activeParticles.Add(particleInstance);
|
||||
// 全部重置后,只对根系统 Play(true) 级联播放,从零开始正常发射。
|
||||
var rootPs = instance.GetComponent<ParticleSystem>();
|
||||
if (rootPs == null) rootPs = systems[0];
|
||||
try { rootPs.Play(true); } catch { }
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("No ParticleSystem components found on hit_particular_object instance");
|
||||
ReturnFxInstance(particleInstance);
|
||||
TriggerAnimatorEffect(color);
|
||||
var animators = instance.GetComponentsInChildren<Animator>(true);
|
||||
lifetime = ComputeFxLifetime(null, animators);
|
||||
}
|
||||
|
||||
// Ring effect (overlay) if assigned.
|
||||
if (hit_ring_object != null)
|
||||
{
|
||||
GameObject ringInstance = RentFxInstance(hit_ring_object, out var ringEntry, out int ringToken);
|
||||
if (ringInstance != null)
|
||||
{
|
||||
// Parent before activation to avoid transform surprises when Simulation Space = Local.
|
||||
ringInstance.transform.SetParent(parentTransform, false);
|
||||
ringInstance.transform.position = spawnPos;
|
||||
ringInstance.transform.rotation = Quaternion.identity;
|
||||
ringInstance.SetActive(true);
|
||||
|
||||
var ringSystems = ringEntry != null ? ringEntry.Systems : null;
|
||||
if (ringSystems != null && ringSystems.Length > 0)
|
||||
{
|
||||
foreach (var rps in ringSystems)
|
||||
{
|
||||
if (rps == null) continue;
|
||||
var rmain = rps.main;
|
||||
rmain.startColor = trackColor;
|
||||
rps.Play();
|
||||
}
|
||||
|
||||
StartCoroutine(RecycleFxRoutine(ringInstance, ringToken, Mathf.Max(0.5f, ringEntry.Lifetime + 0.1f)));
|
||||
activeParticles.Add(ringInstance);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (JudgeManager.IsDebugEnabled) Debug.LogWarning("hit_ring_object has no ParticleSystem components");
|
||||
ReturnFxInstance(ringInstance);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("Failed to instantiate hit_ring_object");
|
||||
}
|
||||
}
|
||||
Destroy(instance, Mathf.Max(0.5f, lifetime + 0.1f));
|
||||
}
|
||||
|
||||
// Documentation text normalized.
|
||||
@@ -556,65 +873,9 @@ public class AnimationController : MonoBehaviour
|
||||
break;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Documentation text normalized.
|
||||
/// </summary>
|
||||
private void SpawnJudgePrefabByTrackText(string color)
|
||||
{
|
||||
TextMeshProUGUI targetText = null;
|
||||
Transform spawnPoint = null;
|
||||
|
||||
// Documentation text normalized.
|
||||
switch (color)
|
||||
{
|
||||
case "red": targetText = red_track_judgementText; spawnPoint = redEffect != null ? redEffect.transform : null; break;
|
||||
case "green": targetText = green_track_judgementText; spawnPoint = greenEffect != null ? greenEffect.transform : null; break;
|
||||
case "yellow": targetText = yellow_track_judgementText; spawnPoint = yellowEffect != null ? yellowEffect.transform : null; break;
|
||||
case "purple": targetText = purple_track_judgementText; spawnPoint = purpleEffect != null ? purpleEffect.transform : null; break;
|
||||
case "blue": targetText = blue_track_judgementText; spawnPoint = blueEffect != null ? blueEffect.transform : null; break;
|
||||
}
|
||||
if (targetText != null && spawnPoint != null)
|
||||
{
|
||||
// Documentation text normalized.
|
||||
if (JudgeManager.IsDebugEnabled) Debug.Log($"判定 {color} 当前读取到的文字为 [{targetText.text}]");
|
||||
}
|
||||
|
||||
// Documentation text normalized.
|
||||
if (targetText != null && spawnPoint != null && !string.IsNullOrEmpty(targetText.text))
|
||||
{
|
||||
DoExecutePrefabSpawn(targetText.text, spawnPoint);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Documentation text normalized.
|
||||
/// </summary>
|
||||
private void DoExecutePrefabSpawn(string judgeResult, Transform spawnTransform)
|
||||
{
|
||||
GameObject prefabToUse = null;
|
||||
|
||||
// Documentation text normalized.
|
||||
if (judgeResult.Contains("Perfect")) prefabToUse = perfect_judge_prefab;
|
||||
else if (judgeResult.Contains("Great")) prefabToUse = great_judge_prefab;
|
||||
else if (judgeResult.Contains("Good")) prefabToUse = good_judge_prefab;
|
||||
else if (judgeResult.Contains("Miss")) prefabToUse = miss_judge_prefab;
|
||||
|
||||
if (prefabToUse != null)
|
||||
{
|
||||
// Rent a pooled copy of the judge-text prefab (was Instantiate + Destroy per hit).
|
||||
GameObject instance = RentFxInstance(prefabToUse, out _, out int judgeToken);
|
||||
if (instance == null)
|
||||
return;
|
||||
|
||||
instance.transform.SetParent(null, false);
|
||||
instance.transform.position = spawnTransform.position;
|
||||
instance.transform.rotation = Quaternion.identity;
|
||||
instance.SetActive(true);
|
||||
|
||||
// Same 1.0s visible lifetime as before, token-guarded recycle instead of Destroy.
|
||||
StartCoroutine(RecycleFxRoutine(instance, judgeToken, 1.0f));
|
||||
}
|
||||
}
|
||||
// 注:判定字生成已完全移交给 Animation_GenerateJudgementSituationPrefab。
|
||||
// 原 SpawnJudgePrefabByTrackText / DoExecutePrefabSpawn 是重复的第二套判定字来源,已删除,
|
||||
// 避免同一次判定出现两套判定字重叠(一大一小)。judge prefab 字段仅供 prewarm 预热池使用。
|
||||
|
||||
/// <summary>
|
||||
/// Public API to prewarm particle instances. Call during loading/pause time to avoid hitch on first use.
|
||||
|
||||
@@ -32,6 +32,14 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
|
||||
[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;
|
||||
|
||||
[Header("Inspector")]
|
||||
public float fadeInTime = 0.1f; // Documentation text normalized.
|
||||
@@ -50,6 +58,8 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
|
||||
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 void Awake()
|
||||
{
|
||||
@@ -160,6 +170,7 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
|
||||
return;
|
||||
|
||||
instance.transform.localPosition = Vector3.zero;
|
||||
instance.transform.localRotation = Quaternion.identity;
|
||||
|
||||
// Documentation text normalized.
|
||||
float finalScale = baseScale;
|
||||
@@ -205,6 +216,10 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
|
||||
try
|
||||
{
|
||||
instance = CreatePooledJudge(prefab);
|
||||
if (instance != null)
|
||||
{
|
||||
rentedJudges.Add(instance);
|
||||
}
|
||||
ReturnJudgeToPool(instance);
|
||||
}
|
||||
catch { }
|
||||
@@ -220,21 +235,18 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
|
||||
{
|
||||
if (obj == null) yield break;
|
||||
|
||||
SpriteRenderer sr = obj.GetComponent<SpriteRenderer>();
|
||||
SpriteRenderer[] renderers = obj.GetComponentsInChildren<SpriteRenderer>(true);
|
||||
float elapsed = 0f;
|
||||
float vVelocity = jumpForce;
|
||||
Vector3 currentLocalPos = Vector3.zero;
|
||||
|
||||
// Documentation text normalized.
|
||||
float totalLifeTime = fadeOutStartTime + fadeOutDuration;
|
||||
float driftSign = Random.value < 0.5f ? -1f : 1f;
|
||||
float driftDistance = Random.Range(horizontalDriftRange.x, horizontalDriftRange.y) * driftSign;
|
||||
float horizontalVelocity = totalLifeTime > 0.001f ? driftDistance / totalLifeTime : 0f;
|
||||
float targetRotation = Random.Range(-rotationRange, rotationRange);
|
||||
Vector3 currentLocalPos = Vector3.zero;
|
||||
Vector3 baseScaleValue = obj.transform.localScale;
|
||||
|
||||
// Documentation text normalized.
|
||||
if (sr != null)
|
||||
{
|
||||
Color c = sr.color;
|
||||
c.a = 0f;
|
||||
sr.color = c;
|
||||
}
|
||||
SetRenderersAlpha(renderers, 0f);
|
||||
|
||||
// Documentation text normalized.
|
||||
while (elapsed < totalLifeTime)
|
||||
@@ -246,45 +258,46 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
|
||||
|
||||
// Documentation text normalized.
|
||||
vVelocity += 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;
|
||||
obj.transform.localScale = baseScaleValue * (1f + punch);
|
||||
|
||||
// Documentation text normalized.
|
||||
if (sr != null)
|
||||
float alpha;
|
||||
|
||||
// Documentation text normalized.
|
||||
if (elapsed < fadeInTime)
|
||||
{
|
||||
float alpha;
|
||||
|
||||
// Documentation text normalized.
|
||||
if (elapsed < fadeInTime)
|
||||
{
|
||||
alpha = Mathf.InverseLerp(0f, fadeInTime, elapsed);
|
||||
}
|
||||
// Documentation text normalized.
|
||||
else if (elapsed > fadeOutStartTime)
|
||||
{
|
||||
// Documentation text normalized.
|
||||
alpha = Mathf.InverseLerp(totalLifeTime, fadeOutStartTime, elapsed);
|
||||
}
|
||||
// Documentation text normalized.
|
||||
else
|
||||
{
|
||||
alpha = 1f;
|
||||
}
|
||||
|
||||
Color c = sr.color;
|
||||
c.a = Mathf.Clamp01(alpha);
|
||||
sr.color = c;
|
||||
alpha = Mathf.InverseLerp(0f, fadeInTime, elapsed);
|
||||
}
|
||||
// Documentation text normalized.
|
||||
else if (elapsed > fadeOutStartTime)
|
||||
{
|
||||
// Documentation text normalized.
|
||||
alpha = Mathf.InverseLerp(totalLifeTime, fadeOutStartTime, elapsed);
|
||||
}
|
||||
// Documentation text normalized.
|
||||
else
|
||||
{
|
||||
alpha = 1f;
|
||||
}
|
||||
|
||||
SetRenderersAlpha(renderers, Mathf.Clamp01(alpha));
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Documentation text normalized.
|
||||
if (obj != null && sr != null)
|
||||
if (obj != null)
|
||||
{
|
||||
Color c = sr.color;
|
||||
c.a = 0f;
|
||||
sr.color = c;
|
||||
obj.transform.localScale = baseScaleValue;
|
||||
obj.transform.localRotation = Quaternion.identity;
|
||||
SetRenderersAlpha(renderers, 0f);
|
||||
}
|
||||
|
||||
// Documentation text normalized.
|
||||
@@ -292,6 +305,23 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
|
||||
if (obj != null) ReturnJudgeToPool(obj);
|
||||
}
|
||||
|
||||
private static void SetRenderersAlpha(SpriteRenderer[] renderers, float alpha)
|
||||
{
|
||||
if (renderers == null)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < renderers.Length; i++)
|
||||
{
|
||||
SpriteRenderer sr = renderers[i];
|
||||
if (sr == null)
|
||||
continue;
|
||||
|
||||
Color c = sr.color;
|
||||
c.a = alpha;
|
||||
sr.color = c;
|
||||
}
|
||||
}
|
||||
|
||||
private GameObject GetPooledJudge(GameObject prefab, Transform parent)
|
||||
{
|
||||
if (prefab == null || parent == null)
|
||||
@@ -319,6 +349,7 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
|
||||
|
||||
instance.transform.SetParent(parent, false);
|
||||
instance.SetActive(true);
|
||||
rentedJudges.Add(instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
@@ -341,6 +372,9 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour
|
||||
if (instance == null)
|
||||
return;
|
||||
|
||||
if (!rentedJudges.Remove(instance))
|
||||
return;
|
||||
|
||||
var tag = instance.GetComponent<PooledJudgeTag>();
|
||||
if (tag == null || tag.prefabSource == null)
|
||||
{
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using System.IO;
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using UnityEngine.SceneManagement;
|
||||
using Bansonic;
|
||||
|
||||
public class BeatmapManager : MonoBehaviour
|
||||
{
|
||||
private static readonly bool VerboseLogs = false;
|
||||
private const string MissingSongDataFallbackScene = "selectYourSongFirst";
|
||||
public static BeatmapManager Instance { get; private set; }
|
||||
private bool missingSongDataRedirectStarted;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -315,25 +320,43 @@ public class BeatmapManager : MonoBehaviour
|
||||
void Start()
|
||||
{
|
||||
// 如果 assignedSongData 为空,尝试从 SongDataHolder 获取,作为最后的兜底
|
||||
if (pendingSongData == null && assignedSongData == null)
|
||||
{
|
||||
assignedSongData = SongDataHolder.SelectedSongData;
|
||||
if (pendingSongData == null && assignedSongData == null)
|
||||
{
|
||||
assignedSongData = SongDataHolder.SelectedSongData;
|
||||
if (VerboseLogs) Debug.Log($"[BeatmapManager] No pending song, fallback to SongDataHolder: {(assignedSongData != null ? assignedSongData.songName : "null")}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!GameConfig.testMode && pendingSongData == null && assignedSongData == null)
|
||||
{
|
||||
RedirectToSongSelectForMissingSongData("no pending SongData and no assigned SongData");
|
||||
return;
|
||||
}
|
||||
|
||||
// 无论如何,在加载谱面前先同步敌人编队到 teamUIController
|
||||
SyncEnemyListToUI();
|
||||
|
||||
if (pendingSongData != null)
|
||||
{
|
||||
if (VerboseLogs) Debug.Log($"BeatmapManager.Start: pendingSongData detected: {pendingSongData.songName}, difficulty={pendingDifficulty}");
|
||||
// Documentation text normalized.
|
||||
assignedSongData = pendingSongData;
|
||||
assignedDifficulty = pendingDifficulty;
|
||||
|
||||
// Documentation text normalized.
|
||||
bool ok = LoadBeatmapFromSongData(assignedSongData, assignedDifficulty, true);
|
||||
if (!ok) Debug.LogWarning("BeatmapManager.Start: LoadBeatmapFromSongData failed");
|
||||
if (pendingSongData != null || assignedSongData != null)
|
||||
{
|
||||
SongData startupSongData = pendingSongData != null ? pendingSongData : assignedSongData;
|
||||
int startupDifficulty = ResolveStartupDifficulty(startupSongData, pendingSongData != null ? pendingDifficulty : assignedDifficulty);
|
||||
if (VerboseLogs) Debug.Log($"BeatmapManager.Start: startup SongData detected: {startupSongData.songName}, difficulty={startupDifficulty}");
|
||||
// Documentation text normalized.
|
||||
assignedSongData = startupSongData;
|
||||
assignedDifficulty = startupDifficulty;
|
||||
|
||||
// Documentation text normalized.
|
||||
bool ok = LoadBeatmapFromSongData(assignedSongData, assignedDifficulty, true);
|
||||
if (!ok)
|
||||
{
|
||||
Debug.LogWarning("BeatmapManager.Start: LoadBeatmapFromSongData failed");
|
||||
pendingSongData = null;
|
||||
pendingDifficulty = -1;
|
||||
if (!GameConfig.testMode)
|
||||
{
|
||||
RedirectToSongSelectForMissingSongData("SongData exists but chart load failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (VerboseLogs) Debug.Log("BeatmapManager.Start: chart parsed from SongData, attempting to assign audio and pause system");
|
||||
@@ -438,12 +461,82 @@ public class BeatmapManager : MonoBehaviour
|
||||
if (VerboseLogs) Debug.Log("Default automatic demo beatmap load is commented out to prioritize SO-provided charts.");
|
||||
}
|
||||
|
||||
UpdateGameplayUI();
|
||||
|
||||
}
|
||||
|
||||
private void UpdateGameplayUI()
|
||||
{
|
||||
UpdateGameplayUI();
|
||||
|
||||
}
|
||||
|
||||
private int ResolveStartupDifficulty(SongData song, int requestedDifficulty)
|
||||
{
|
||||
if (requestedDifficulty >= 0)
|
||||
{
|
||||
return requestedDifficulty;
|
||||
}
|
||||
|
||||
if (song != null)
|
||||
{
|
||||
int selectedDifficulty = Mathf.Clamp(song.thisLevel_selectedDifficultyID, 0, 3);
|
||||
if (song.GetResolvedChartFile(selectedDifficulty) != null)
|
||||
{
|
||||
return selectedDifficulty;
|
||||
}
|
||||
|
||||
int songDifficulty = Mathf.Clamp(song.difficultyID, 0, 3);
|
||||
if (song.GetResolvedChartFile(songDifficulty) != null)
|
||||
{
|
||||
return songDifficulty;
|
||||
}
|
||||
}
|
||||
|
||||
if (song != null && song.chartFiles != null)
|
||||
{
|
||||
for (int i = 0; i < song.chartFiles.Count; i++)
|
||||
{
|
||||
ChartFileEntry entry = song.chartFiles[i];
|
||||
if (entry != null && entry.chartFile != null)
|
||||
{
|
||||
return entry.difficulty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void RedirectToSongSelectForMissingSongData(string reason)
|
||||
{
|
||||
if (missingSongDataRedirectStarted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
missingSongDataRedirectStarted = true;
|
||||
Debug.LogWarning($"[BeatmapManager] Missing valid SongData ({reason}). Returning to {MissingSongDataFallbackScene}.");
|
||||
try { Time.timeScale = 1f; } catch { }
|
||||
StartCoroutine(LoadMissingSongDataFallbackScene());
|
||||
}
|
||||
|
||||
private IEnumerator LoadMissingSongDataFallbackScene()
|
||||
{
|
||||
yield return null;
|
||||
|
||||
if (gTransition.LoadScene(MissingSongDataFallbackScene, LoadSceneMode.Single))
|
||||
{
|
||||
while (gTransition.IsBusy)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
AsyncOperation asyncLoad = SceneManager.LoadSceneAsync(MissingSongDataFallbackScene, LoadSceneMode.Single);
|
||||
while (asyncLoad != null && !asyncLoad.isDone)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateGameplayUI()
|
||||
{
|
||||
if (assignedSongData == null) return;
|
||||
|
||||
if (gameplay_songname != null)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 根据屏幕宽高比,对 gameplay 相机做"适当微调":叠加一点 X 旋转(俯角)与 Z 距离增量。
|
||||
/// 目的:不同分辨率下让轨道透视观感更协调(如超宽屏略微拉远/调俯角),与 contain 式 FOV 适配互补。
|
||||
///
|
||||
/// 安全性(核心):**绝不直接写相机 transform**。所有微调通过 effectEventController.SetResolutionCameraAdjust
|
||||
/// 施加到"漂移 pivot"(顶层基准节点),并由该方法同步漂移基准 z——因此与相机漂移、SmoothShake 抖动、
|
||||
/// 技能偏移、冲刺特效等所有"写相机位姿"的业务逻辑完全不冲突(它们叠加在 pivot 之下或相机自身)。
|
||||
///
|
||||
/// 用一个 bool(enableTuning)开关。关闭时把增量清零(恢复设计基准),行为与未加此脚本一致。
|
||||
/// 增量相对"设计基准"计算,不累积;16:9 时增量为 0(与原设计逐像素一致)。
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class CameraResolutionTuner : MonoBehaviour
|
||||
{
|
||||
[Tooltip("是否启用按分辨率微调相机(X旋转/Z)。关闭则恢复设计基准。")]
|
||||
public bool enableTuning = true;
|
||||
|
||||
[Tooltip("设计基准宽高比。16:9 = 1.7778。此比例下增量为 0。")]
|
||||
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;
|
||||
|
||||
[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;
|
||||
|
||||
private float _lastAspect = -1f;
|
||||
private bool _lastEnabled;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_lastAspect = -1f;
|
||||
Apply();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
float aspect = (float)Screen.width / Mathf.Max(1, Screen.height);
|
||||
if (Mathf.Abs(aspect - _lastAspect) > 0.0001f || enableTuning != _lastEnabled)
|
||||
{
|
||||
Apply();
|
||||
}
|
||||
}
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
var eff = effectEventController.Instance;
|
||||
if (eff == null) return;
|
||||
|
||||
float aspect = (float)Screen.width / Mathf.Max(1, Screen.height);
|
||||
_lastAspect = aspect;
|
||||
_lastEnabled = enableTuning;
|
||||
|
||||
if (!enableTuning)
|
||||
{
|
||||
eff.SetResolutionCameraAdjust(0f, 0f); // 恢复设计基准
|
||||
return;
|
||||
}
|
||||
|
||||
float rotX = 0f;
|
||||
float z = 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));
|
||||
}
|
||||
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:9:rotX=z=0,无微调。
|
||||
|
||||
eff.SetResolutionCameraAdjust(rotX, z);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a3cb6af3bd8ac8f46af425d446ea3222
|
||||
@@ -1331,6 +1331,22 @@ public class GameManager : MonoBehaviour
|
||||
|
||||
private IEnumerator HandleNormalModeStartup()
|
||||
{
|
||||
bool hasStartupSongDataBeforeParse =
|
||||
BeatmapManager.pendingSongData != null
|
||||
|| SongDataHolder.SelectedSongData != null
|
||||
|| (beatmapManager != null && beatmapManager.assignedSongData != null);
|
||||
|
||||
if (hasStartupSongDataBeforeParse && beatmapManager != null && beatmapManager.beatmap == null)
|
||||
{
|
||||
const int parseWaitFrameLimit = 120;
|
||||
int waitedFrames = 0;
|
||||
while (waitedFrames < parseWaitFrameLimit && beatmapManager != null && beatmapManager.beatmap == null)
|
||||
{
|
||||
waitedFrames++;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
// If a Beatmap was already provided by previous scene (pendingSongData path), skip loading default JSON
|
||||
if (beatmapManager != null && beatmapManager.beatmap != null)
|
||||
{
|
||||
@@ -1411,6 +1427,25 @@ public class GameManager : MonoBehaviour
|
||||
yield break;
|
||||
}
|
||||
|
||||
bool hasStartupSongData =
|
||||
BeatmapManager.pendingSongData != null
|
||||
|| SongDataHolder.SelectedSongData != null
|
||||
|| (beatmapManager != null && beatmapManager.assignedSongData != null);
|
||||
|
||||
if (!hasStartupSongData)
|
||||
{
|
||||
Debug.LogWarning("[GameManager] No SongData available for normal gameplay startup. Returning to selectYourSongFirst.");
|
||||
yield return FadeToBlackAndLoad("selectYourSongFirst", 0.25f);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (hasStartupSongData)
|
||||
{
|
||||
Debug.LogWarning("[GameManager] SongData was available, but BeatmapManager did not produce a beatmap. Returning to selectYourSongFirst.");
|
||||
yield return FadeToBlackAndLoad("selectYourSongFirst", 0.25f);
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Original default demo load commented out; manual load only when needed
|
||||
string beatmapFilePath = Path.Combine(Application.streamingAssetsPath, "Emilia_demo.json");
|
||||
|
||||
|
||||
@@ -216,6 +216,9 @@ public class HoldNote : BaseNote
|
||||
if (controller != null)
|
||||
{
|
||||
controller.OnJudgeZoneChanged += OnJudgeZoneChanged;
|
||||
// 去物理判定:进入/离开判定区的分段逻辑由 controller 几何驱动回调触发(替代自身 OnTrigger)。
|
||||
controller.OnZoneEnterGeometry += HandleZoneEnter;
|
||||
controller.OnZoneExitGeometry += HandleZoneExit;
|
||||
}
|
||||
|
||||
cachedJudgeManager = JudgeManager.Instance;
|
||||
@@ -322,6 +325,8 @@ public class HoldNote : BaseNote
|
||||
if (controller != null)
|
||||
{
|
||||
controller.OnJudgeZoneChanged -= OnJudgeZoneChanged;
|
||||
controller.OnZoneEnterGeometry -= HandleZoneEnter;
|
||||
controller.OnZoneExitGeometry -= HandleZoneExit;
|
||||
}
|
||||
|
||||
if (JudgeManager.Instance != null)
|
||||
@@ -342,13 +347,53 @@ public class HoldNote : BaseNote
|
||||
|
||||
private void PlayHitAnimation()
|
||||
{
|
||||
var controller = AnimationController.Global ?? anim;
|
||||
var controller = GetAnimationController();
|
||||
if (controller != null)
|
||||
{
|
||||
controller.PlayDestroyAnimation(noteColor);
|
||||
}
|
||||
}
|
||||
|
||||
private void PlayMiddleSegmentHitFx()
|
||||
{
|
||||
PlayHitAnimation();
|
||||
TrackJudgeHitEffectController.PlayTrackHitFx(trackIndex);
|
||||
}
|
||||
|
||||
private void EnsureHoldParticleLoop()
|
||||
{
|
||||
if (!isHoldActive || string.IsNullOrEmpty(noteColor))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 按长音符 id 记账,避免同轨道旧段回收误停当前长按特效。
|
||||
GetAnimationController()?.StartHoldParticles(noteColor, id);
|
||||
}
|
||||
|
||||
private AnimationController GetAnimationController()
|
||||
{
|
||||
if (AnimationController.Global != null)
|
||||
return AnimationController.Global;
|
||||
|
||||
if (anim == null)
|
||||
{
|
||||
anim = SceneObjectLookupCache.FindAny<AnimationController>();
|
||||
}
|
||||
|
||||
return anim;
|
||||
}
|
||||
|
||||
private void StartHoldFxLoop()
|
||||
{
|
||||
GetAnimationController()?.StartHoldParticles(noteColor, id);
|
||||
}
|
||||
|
||||
private void StopHoldFxLoop()
|
||||
{
|
||||
GetAnimationController()?.StopHoldParticles(id);
|
||||
}
|
||||
|
||||
private IEnumerator DelayedDisableAnimation(GameObject animationObject, float delay)
|
||||
{
|
||||
// No-op: we used to disable the shared AnimationController here which stopped coroutines.
|
||||
@@ -481,24 +526,43 @@ public class HoldNote : BaseNote
|
||||
if (!isHoldActive && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID) && keyHeld)
|
||||
{
|
||||
isHoldActive = true;
|
||||
AnimationController.Global?.StartHoldParticles(noteColor);
|
||||
StartHoldFxLoop();
|
||||
if (debugEnabled) Debug.Log($"[HoldNote] Start particles started for {noteID} color={noteColor} at time={now:F3}");
|
||||
}
|
||||
|
||||
if (isHoldActive && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID) && keyHeld)
|
||||
{
|
||||
EnsureHoldParticleLoop();
|
||||
}
|
||||
|
||||
// Auto-miss checks when inside judge line
|
||||
if (hasEnteredLine)
|
||||
{
|
||||
float missRangeScaled = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier;
|
||||
// 只对"尚未判定"的 start 段做超时 auto-Miss。已成功判定的 start 段(玩家已按下、
|
||||
// 正持续按住)绝不能在此被误判 Miss——否则 EvaluateHoldEnd(forceMiss) 会调 StopHoldParticles,
|
||||
// 把长按持续特效杀掉。物理版时 collider 早已离开判定区不会走到这里;几何版 exit 更晚才暴露此隐患。
|
||||
bool startAlreadyJudged = jm != null && jm.IsStartJudged(noteID);
|
||||
if (segment == NoteSegment.Start && now > hitTime + missRangeScaled)
|
||||
{
|
||||
if (debugEnabled) Debug.Log($"[HoldNote] START auto-Miss: {noteColor}");
|
||||
|
||||
// Register miss properly via EvaluateHoldEnd
|
||||
EvaluateHoldEnd(now, true);
|
||||
|
||||
isJudged = true;
|
||||
ScheduleReturnToPool(0.2f);
|
||||
return;
|
||||
if (!startAlreadyJudged)
|
||||
{
|
||||
if (debugEnabled) Debug.Log($"[HoldNote] START auto-Miss: {noteColor}");
|
||||
// 未判定的 start 超时 → 正常 Miss。
|
||||
EvaluateHoldEnd(now, true);
|
||||
isJudged = true;
|
||||
ScheduleReturnToPool(0.2f);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 已成功判定的 start 段:其视觉/判定使命已完成(end 段负责收尾),
|
||||
// 静默回收即可,绝不能走 EvaluateHoldEnd(会 StopHoldParticles 杀掉长按持续特效)。
|
||||
// isHoldActive 由 middle/end 段各自维持,不受本段回收影响。
|
||||
isJudged = true;
|
||||
ScheduleReturnToPool(0.2f);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
else if (segment == NoteSegment.End && now > scheduledEndTime + missRangeScaled)
|
||||
@@ -515,7 +579,7 @@ public class HoldNote : BaseNote
|
||||
if (isHoldActive && keyUp)
|
||||
{
|
||||
isHoldActive = false;
|
||||
AnimationController.Global?.StopHoldParticles();
|
||||
StopHoldFxLoop();
|
||||
if (debugEnabled) Debug.Log($"[HoldNote] KeyUp {keyToPress} detected. NoteID: {noteID}, Segment: {segment}, Type: {type}");
|
||||
|
||||
if (!hasReleased)
|
||||
@@ -571,15 +635,18 @@ public class HoldNote : BaseNote
|
||||
}
|
||||
else if (segment == NoteSegment.Middle
|
||||
&& jm != null && jm.IsStartJudged(noteID)
|
||||
&& isHoldActive
|
||||
&& keyHeld
|
||||
&& !jm.HasNoteReleased(noteID))
|
||||
{
|
||||
isHoldActive = true;
|
||||
EnsureHoldParticleLoop();
|
||||
if (now >= hitTime)
|
||||
{
|
||||
if (hasEnteredLine)
|
||||
{
|
||||
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Middle passed: {noteColor}");
|
||||
PlayHitAnimation();
|
||||
// 按住阶段粒子由 AnimationController 的固定 tick 统一生成。
|
||||
// 中段经过只负责保持循环,不额外加打一组,避免长音符前半段因中段密集而粒子频率过高。
|
||||
jm?.RegisterMiddlePassed(noteID);
|
||||
ScheduleReturnToPool(0.2f);
|
||||
isJudged = true;
|
||||
@@ -610,6 +677,11 @@ public class HoldNote : BaseNote
|
||||
isHoldActive = true;
|
||||
}
|
||||
|
||||
if (isHoldActive && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID))
|
||||
{
|
||||
EnsureHoldParticleLoop();
|
||||
}
|
||||
|
||||
if (segment == NoteSegment.Start)
|
||||
{
|
||||
bool startResolved = jm != null && jm.IsStartJudged(noteID);
|
||||
@@ -642,7 +714,7 @@ public class HoldNote : BaseNote
|
||||
if (now >= hitTime && hasEnteredLine)
|
||||
{
|
||||
if (debugEnabled) Debug.Log($"[HoldNote.AutoPlay] Middle passed: {noteColor}");
|
||||
PlayHitAnimation();
|
||||
// 按住阶段粒子由 AnimationController 的固定 tick 统一生成。
|
||||
jm?.RegisterMiddlePassed(noteID);
|
||||
ScheduleReturnToPool(0.2f);
|
||||
isJudged = true;
|
||||
@@ -690,7 +762,7 @@ public class HoldNote : BaseNote
|
||||
JudgeManager.Instance.RegisterStartJudged(noteID, true);
|
||||
isHoldActive = true;
|
||||
PlayHitAnimation();
|
||||
AnimationController.Global?.StartHoldParticles(noteColor);
|
||||
StartHoldFxLoop();
|
||||
|
||||
holdStartTime = pressTime;
|
||||
HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData);
|
||||
@@ -707,10 +779,9 @@ public class HoldNote : BaseNote
|
||||
globalNoteEffect.TryTrigger(noteData != null ? noteData.noteFunction : null);
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D collision)
|
||||
// 去物理判定:原 OnTriggerEnter2D 的分段逻辑,改由 controller 几何驱动调用,逻辑完全一致。
|
||||
private void HandleZoneEnter()
|
||||
{
|
||||
if (!collision.CompareTag("JudgmentLine")) return;
|
||||
|
||||
hasEnteredLine = true;
|
||||
|
||||
if (segment == NoteSegment.Start)
|
||||
@@ -753,7 +824,7 @@ public class HoldNote : BaseNote
|
||||
isJudged = true;
|
||||
// Terminate input - mark key as released
|
||||
isHoldActive = false;
|
||||
AnimationController.Global?.StopHoldParticles();
|
||||
StopHoldFxLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -761,10 +832,9 @@ public class HoldNote : BaseNote
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D collision)
|
||||
// 去物理判定:原 OnTriggerExit2D 的分段逻辑,改由 controller 几何驱动调用,逻辑完全一致。
|
||||
private void HandleZoneExit()
|
||||
{
|
||||
if (!collision.CompareTag("JudgmentLine")) return;
|
||||
|
||||
if (segment == NoteSegment.Start)
|
||||
{
|
||||
UnregisterQueueIfNeeded();
|
||||
@@ -922,7 +992,7 @@ public class HoldNote : BaseNote
|
||||
JudgeManager.Instance.RegisterStartJudged(noteID, true);
|
||||
isHoldActive = true;
|
||||
PlayHitAnimation();
|
||||
AnimationController.Global?.StartHoldParticles(noteColor);
|
||||
StartHoldFxLoop();
|
||||
|
||||
// record when the hold started so we can compute held fraction later
|
||||
holdStartTime = pressTime;
|
||||
@@ -938,7 +1008,7 @@ public class HoldNote : BaseNote
|
||||
JudgeManager.Instance.RegisterStartJudged(noteID, true);
|
||||
isHoldActive = true;
|
||||
PlayHitAnimation();
|
||||
AnimationController.Global?.StartHoldParticles(noteColor);
|
||||
StartHoldFxLoop();
|
||||
|
||||
holdStartTime = pressTime;
|
||||
|
||||
@@ -952,7 +1022,7 @@ public class HoldNote : BaseNote
|
||||
JudgeManager.Instance.RegisterStartJudged(noteID, true);
|
||||
isHoldActive = true;
|
||||
PlayHitAnimation();
|
||||
AnimationController.Global?.StartHoldParticles(noteColor);
|
||||
StartHoldFxLoop();
|
||||
|
||||
holdStartTime = pressTime;
|
||||
|
||||
@@ -1260,7 +1330,21 @@ public class HoldNote : BaseNote
|
||||
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Hold end NotifyNoteHit called: TrackIndex={TrackIndex} trackIndexField={trackIndex} result={result} triggeredHold={triggeredHold} triggeredTap={triggeredTap}");
|
||||
}
|
||||
|
||||
AnimationController.Global?.StopHoldParticles();
|
||||
// 只有命中(非 Miss)才播放打击特效;Miss 不该触发击打效果。
|
||||
// 与上面 1253-1256 的 TrackJudgeHitEffectController 守卫保持一致。
|
||||
if (result != "Miss")
|
||||
{
|
||||
PlayHitAnimation();
|
||||
}
|
||||
|
||||
// 关键修复(长条结束仍按住→持续特效不停):hold 已完整结算,标记该 note 为"已释放/已完结"。
|
||||
// 否则玩家继续按住时,其它仍存活的段 Update 满足 (isHoldActive && IsStartJudged && !HasNoteReleased
|
||||
// && keyHeld) 会再次 EnsureHoldParticleLoop 重启持续 tick——即使 hold 逻辑上已经结束。
|
||||
// 置 released 后,所有段的 Update 门槛 !HasNoteReleased 不再成立,持续循环不会被重启。
|
||||
hasReleased = true;
|
||||
JudgeManager.Instance?.RegisterNoteReleased(noteID, true);
|
||||
|
||||
StopHoldFxLoop();
|
||||
|
||||
// Release the track lock immediately after end evaluation so the next hold can start on time
|
||||
ReleaseTrackJudgeLockSafe();
|
||||
@@ -1390,10 +1474,18 @@ public class HoldNote : BaseNote
|
||||
hitTime = 0f;
|
||||
scheduledEndTime = 0f;
|
||||
hasEnteredLine = false;
|
||||
// 普通 start/middle 段回收时,同一条长音符可能仍在持续按住,不能在这里停掉持续特效。
|
||||
// 真正停止由 keyUp / end 判定路径负责;这里只兜底处理 end 段池化泄漏。
|
||||
if (isHoldActive && segment == NoteSegment.End)
|
||||
{
|
||||
StopHoldFxLoop();
|
||||
}
|
||||
isJudged = false;
|
||||
isHoldActive = false;
|
||||
hasBeenHeldFromStart = false;
|
||||
prevTrackHeld = false;
|
||||
// 去物理判定:池化复用时重置 controller 的几何判定状态(重新算阈值、清进入标志)。
|
||||
if (controller != null) controller.ResetJudgeZoneGeometry();
|
||||
|
||||
holdStartTime = -1f;
|
||||
|
||||
|
||||
@@ -5,34 +5,86 @@ public class HoldNoteController : MonoBehaviour
|
||||
{
|
||||
private bool isInsideJudgeZone = false;
|
||||
public event Action<bool> OnJudgeZoneChanged;
|
||||
// 去物理判定:几何驱动的进入/离开回调(替代 HoldNote 自身的 OnTriggerEnter2D/Exit2D)。
|
||||
public event Action OnZoneEnterGeometry;
|
||||
public event Action OnZoneExitGeometry;
|
||||
private bool isMoving = false;
|
||||
private float speed = 0f;
|
||||
private float activationTime;
|
||||
|
||||
// 去物理判定:用几何阈值复现 Trigger 进入/离开时刻。
|
||||
private Collider2D _col;
|
||||
private float _enterY, _exitY;
|
||||
private bool _thresholdsReady;
|
||||
|
||||
// Absolute positioning for hold segments (keeps segments connected under frame spikes)
|
||||
private bool useAbsolutePositioning = false;
|
||||
private Vector3 spawnPosition;
|
||||
private float baseSpawnYOffset = 0f; // can be negative to offset upward for delayed segments
|
||||
private float visualOffset = 0f; // additional Y offset for visual compensation
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_col = GetComponent<Collider2D>();
|
||||
// 去物理判定:关闭 Rigidbody2D 模拟,消除每帧移动刚体的物理开销(判定改几何驱动)。
|
||||
var rb = GetComponent<Rigidbody2D>();
|
||||
if (rb != null) rb.simulated = false;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (useAbsolutePositioning)
|
||||
{
|
||||
if (!isMoving) return;
|
||||
ApplyAbsolutePosition(GameplayClock.NowSongTime);
|
||||
return;
|
||||
if (isMoving) ApplyAbsolutePosition(GameplayClock.NowSongTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Start moving after activation time in legacy mode
|
||||
if (!isMoving && GameplayClock.NowSongTime >= activationTime)
|
||||
{
|
||||
isMoving = true;
|
||||
}
|
||||
|
||||
if (isMoving)
|
||||
{
|
||||
transform.Translate(Vector3.down * speed * Time.deltaTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Start moving after activation time in legacy mode
|
||||
if (!isMoving && GameplayClock.NowSongTime >= activationTime)
|
||||
// 去物理判定:用几何阈值复现进入/离开判定区(替代 OnTriggerEnter2D/Exit2D)。
|
||||
UpdateJudgeZoneByGeometry();
|
||||
}
|
||||
|
||||
private void UpdateJudgeZoneByGeometry()
|
||||
{
|
||||
if (!_thresholdsReady)
|
||||
{
|
||||
isMoving = true;
|
||||
if (!JudgeZoneGeometry.LineReady) return;
|
||||
if (_col == null) _col = GetComponent<Collider2D>();
|
||||
if (!JudgeZoneGeometry.TryGetThresholds(_col, transform, out _enterY, out _exitY)) return;
|
||||
_thresholdsReady = true;
|
||||
}
|
||||
|
||||
if (isMoving)
|
||||
float noteY = transform.position.y;
|
||||
if (!isInsideJudgeZone && noteY <= _enterY)
|
||||
{
|
||||
transform.Translate(Vector3.down * speed * Time.deltaTime);
|
||||
isInsideJudgeZone = true;
|
||||
// 顺序与原物理一致:先置 hasEnteredLine(OnJudgeZoneChanged),再跑 HoldNote 分段逻辑。
|
||||
OnJudgeZoneChanged?.Invoke(true);
|
||||
OnZoneEnterGeometry?.Invoke();
|
||||
// 单帧穿透:一帧内越过整个判定区,立即补离开,行为与物理一致。
|
||||
if (noteY <= _exitY)
|
||||
{
|
||||
isInsideJudgeZone = false;
|
||||
OnJudgeZoneChanged?.Invoke(false);
|
||||
OnZoneExitGeometry?.Invoke();
|
||||
}
|
||||
}
|
||||
else if (isInsideJudgeZone && noteY <= _exitY)
|
||||
{
|
||||
isInsideJudgeZone = false;
|
||||
OnJudgeZoneChanged?.Invoke(false);
|
||||
OnZoneExitGeometry?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,20 +140,13 @@ public class HoldNoteController : MonoBehaviour
|
||||
visualOffset = 0f;
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D collision)
|
||||
// 去物理判定:进入/离开判定区改由 UpdateJudgeZoneByGeometry 驱动,原 OnTrigger 回调移除。
|
||||
|
||||
// 池化复用时重置几何判定状态。
|
||||
public void ResetJudgeZoneGeometry()
|
||||
{
|
||||
if (!collision.CompareTag("JudgmentLine")) return;
|
||||
|
||||
isInsideJudgeZone = true;
|
||||
OnJudgeZoneChanged?.Invoke(true);
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D collision)
|
||||
{
|
||||
if (!collision.CompareTag("JudgmentLine")) return;
|
||||
|
||||
isInsideJudgeZone = false;
|
||||
OnJudgeZoneChanged?.Invoke(false);
|
||||
_thresholdsReady = false;
|
||||
}
|
||||
|
||||
public bool IsInsideJudgArea()
|
||||
|
||||
@@ -46,10 +46,12 @@ public class InputManager : MonoBehaviour
|
||||
private KeyCode[] cachedKeys = new KeyCode[5];
|
||||
private bool pauseBlockedLastFrame = false;
|
||||
|
||||
// Per-track held state. Written by both keyboard (Update) and touch (PressTrack/
|
||||
// Per-track held count. Written by both keyboard (Update) and touch (PressTrack/
|
||||
// ReleaseTrack) so hold-note logic can query one platform-agnostic source instead
|
||||
// of polling Input.GetKey directly (which cannot be driven by touch on Android).
|
||||
private bool[] trackHeld = new bool[5];
|
||||
// Count-based tracking fixes multi-touch on the same lane: one finger lifting no
|
||||
// longer releases the lane while another finger is still holding it.
|
||||
private int[] trackHeldCount = new int[5];
|
||||
|
||||
/// <summary>
|
||||
/// True while the given track (0-4) is currently held, regardless of whether the
|
||||
@@ -57,8 +59,8 @@ public class InputManager : MonoBehaviour
|
||||
/// </summary>
|
||||
public bool IsTrackHeld(int trackIndex)
|
||||
{
|
||||
if (trackIndex < 0 || trackIndex >= trackHeld.Length) return false;
|
||||
return trackHeld[trackIndex];
|
||||
if (trackIndex < 0 || trackIndex >= trackHeldCount.Length) return false;
|
||||
return trackHeldCount[trackIndex] > 0;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
@@ -145,9 +147,10 @@ public class InputManager : MonoBehaviour
|
||||
public void PressTrack(int index)
|
||||
{
|
||||
if (index < 0 || index >= TrackColors.Length) return;
|
||||
if (trackHeld[index]) return; // already held; avoid duplicate press events
|
||||
bool wasHeld = trackHeldCount[index] > 0;
|
||||
trackHeldCount[index]++;
|
||||
|
||||
trackHeld[index] = true;
|
||||
if (wasHeld) return; // already held; avoid duplicate press events
|
||||
|
||||
KeyCode key = cachedKeys[index];
|
||||
if (key != KeyCode.None)
|
||||
@@ -170,9 +173,10 @@ public class InputManager : MonoBehaviour
|
||||
public void ReleaseTrack(int index)
|
||||
{
|
||||
if (index < 0 || index >= TrackColors.Length) return;
|
||||
if (!trackHeld[index]) return; // not held; nothing to release
|
||||
if (trackHeldCount[index] <= 0) return; // not held; nothing to release
|
||||
|
||||
trackHeld[index] = false;
|
||||
trackHeldCount[index]--;
|
||||
if (trackHeldCount[index] > 0) return; // another finger/key is still holding this lane
|
||||
|
||||
KeyCode key = cachedKeys[index];
|
||||
if (key != KeyCode.None)
|
||||
@@ -204,10 +208,14 @@ public class InputManager : MonoBehaviour
|
||||
for (int i = 0; i < TrackColors.Length; i++)
|
||||
{
|
||||
string color = TrackColors[i];
|
||||
trackHeld[i] = false; // clear held state so hold notes don't see a stale press after pause/clear
|
||||
bool wasHeld = trackHeldCount[i] > 0;
|
||||
trackHeldCount[i] = 0; // clear held state so hold notes don't see a stale press after pause/clear
|
||||
KeyCode key = KeyBindingManager.GetKeyForColor(color);
|
||||
if (key == KeyCode.None) continue;
|
||||
try { OnKeyReleased?.Invoke(key); } catch { }
|
||||
if (wasHeld)
|
||||
{
|
||||
try { OnKeyReleased?.Invoke(key); } catch { }
|
||||
}
|
||||
|
||||
int index = GetIndexForColor(color);
|
||||
if (laneSprites != null && index >= 0 && index < laneSprites.Length)
|
||||
|
||||
@@ -12,7 +12,7 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: 088ed1ed6b6731f43ad3166781e3e931, type: 3}
|
||||
m_Name: NoteJudgeConfig
|
||||
m_EditorClassIdentifier:
|
||||
perfectRange: 0.1
|
||||
greatRange: 0.15
|
||||
goodRange: 0.2
|
||||
missRange: 0.2
|
||||
perfectRange: 0.15
|
||||
greatRange: 0.2
|
||||
goodRange: 0.25
|
||||
missRange: 0.3
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 判定区几何工具:把"音符 Collider 与判定线 Collider 在 Y 轴是否重叠"这一原本由
|
||||
/// Physics2D 触发器(OnTriggerEnter2D/Exit2D)判断的条件,改为纯几何/时间计算,
|
||||
/// 用于去掉每帧移动 Collider 触发的 Physics2D 开销,同时保持判定"进入/离开判定区"
|
||||
/// 的时刻与原物理触发**完全一致**。
|
||||
///
|
||||
/// 原理:判定线静态不动(世界 Y 固定),音符沿 -Y 匀速下落。两个 AxisAligned Box 在 Y 轴
|
||||
/// 重叠 ⇔ 音符 collider 上边缘 ≥ 判定线下边缘 且 音符 collider 下边缘 ≤ 判定线上边缘。
|
||||
/// 用音符 transform.position.y 表达为一对阈值:
|
||||
/// 进入(overlap 开始): noteY ≤ lineTop + noteHalfH - noteOffY
|
||||
/// 离开(overlap 结束): noteY < lineBottom - noteHalfH - noteOffY
|
||||
/// 这与物理 trigger 的 enter/exit 触发时刻等价(同一 AABB 重叠判据)。
|
||||
///
|
||||
/// 判定结果本身仍由 hitTime(时间)决定,本类只复现"何时注册/注销到 TrackKeyManager"。
|
||||
/// </summary>
|
||||
public static class JudgeZoneGeometry
|
||||
{
|
||||
private static bool _lineCaptured;
|
||||
private static float _lineTopY; // 判定线 collider 世界上边缘
|
||||
private static float _lineBottomY; // 判定线 collider 世界下边缘
|
||||
|
||||
public static bool LineReady => _lineCaptured;
|
||||
|
||||
/// <summary>
|
||||
/// 捕获判定线 collider 的世界 Y 范围(只需一次,判定线静态不动)。
|
||||
/// 传入任一带 tag "JudgmentLine" 的 Collider2D。
|
||||
/// </summary>
|
||||
public static void CaptureLine(Collider2D lineCollider)
|
||||
{
|
||||
if (_lineCaptured || lineCollider == null) return;
|
||||
Transform t = lineCollider.transform;
|
||||
if (lineCollider is BoxCollider2D box)
|
||||
{
|
||||
// 直接由 size/offset/世界缩放算,避免依赖 bounds 的物理同步时机。
|
||||
float scaleY = Mathf.Abs(t.lossyScale.y);
|
||||
float centerY = t.position.y + box.offset.y * scaleY;
|
||||
float halfH = box.size.y * 0.5f * scaleY;
|
||||
_lineTopY = centerY + halfH;
|
||||
_lineBottomY = centerY - halfH;
|
||||
}
|
||||
else
|
||||
{
|
||||
Physics2D.SyncTransforms();
|
||||
Bounds b = lineCollider.bounds;
|
||||
_lineTopY = b.max.y;
|
||||
_lineBottomY = b.min.y;
|
||||
}
|
||||
_lineCaptured = true;
|
||||
}
|
||||
|
||||
/// <summary>切场景/重开时清空,让新判定线重新捕获。</summary>
|
||||
public static void Reset()
|
||||
{
|
||||
_lineCaptured = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从音符自身 Collider 计算它的进入/离开阈值(以 transform.position.y 表示)。
|
||||
/// halfH = collider 世界半高;offY = collider 中心相对 transform 原点的 Y 偏移(世界)。
|
||||
/// </summary>
|
||||
public static bool TryGetThresholds(Collider2D noteCollider, Transform noteTf,
|
||||
out float enterY, out float exitY)
|
||||
{
|
||||
enterY = 0f; exitY = 0f;
|
||||
if (!_lineCaptured || noteCollider == null || noteTf == null) return false;
|
||||
|
||||
float halfH, offY;
|
||||
// 关键:不能用 Collider2D.bounds——本工程 Physics2D AutoSyncTransforms=0,
|
||||
// 且音符 Rigidbody2D.simulated=false,bounds 不随 transform 主动重算而滞后。
|
||||
// 对 hold 的 middle/end 段(被 baseSpawnYOffset 大幅上移)会算出错误的 offY,
|
||||
// 导致 enterY/exitY 整体错位、中尾段几乎进不了判定区。
|
||||
// 改为直接用 BoxCollider2D 的 size/offset + 世界缩放计算(与物理同步时机无关)。
|
||||
if (noteCollider is BoxCollider2D box)
|
||||
{
|
||||
float scaleY = Mathf.Abs(noteTf.lossyScale.y);
|
||||
halfH = box.size.y * 0.5f * scaleY; // collider 世界半高
|
||||
offY = box.offset.y * scaleY; // collider 中心相对 transform 原点的世界 Y 偏移
|
||||
}
|
||||
else
|
||||
{
|
||||
// 其它 collider 类型:先强制同步一次再读 bounds,尽量准确。
|
||||
Physics2D.SyncTransforms();
|
||||
Bounds b = noteCollider.bounds;
|
||||
halfH = b.extents.y;
|
||||
offY = b.center.y - noteTf.position.y;
|
||||
}
|
||||
|
||||
// overlap 开始:音符下边缘(noteY+offY-halfH) ≤ 判定线上边缘。
|
||||
enterY = _lineTopY + halfH - offY;
|
||||
// overlap 结束:音符上边缘(noteY+offY+halfH) < 判定线下边缘。
|
||||
exitY = _lineBottomY - halfH - offY;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3cb21799f3a295d4480155f158f4b841
|
||||
@@ -157,6 +157,13 @@ public class Note : BaseNote
|
||||
float.NaN);
|
||||
}
|
||||
|
||||
// 缓存本音符的实例ID字符串:GameObject 实例ID终身不变(池化复用也不变),
|
||||
// 缓存后避免每次判定/进出判定区都 GetInstanceID().ToString() 分配字符串(热路径 GC)。
|
||||
private string _cachedId;
|
||||
private string CachedId => _cachedId ?? (_cachedId = gameObject.GetInstanceID().ToString());
|
||||
// 供 NoteController 等复用同一缓存id(避免它们各自再 ToString 分配)。
|
||||
public string GetCachedInstanceId() => CachedId;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
anim = AnimationController.Global;
|
||||
@@ -207,7 +214,7 @@ public class Note : BaseNote
|
||||
// Release lock if we still hold it
|
||||
if (hasLock)
|
||||
{
|
||||
TrackKeyManager.Instance?.UnlockTrackForJudge(TrackIndex, gameObject.GetInstanceID().ToString());
|
||||
TrackKeyManager.Instance?.UnlockTrackForJudge(TrackIndex, CachedId);
|
||||
hasLock = false;
|
||||
}
|
||||
}
|
||||
@@ -237,7 +244,7 @@ public class Note : BaseNote
|
||||
{
|
||||
if (isJudged) return;
|
||||
|
||||
string myId = gameObject.GetInstanceID().ToString();
|
||||
string myId = CachedId;
|
||||
|
||||
// Acquire the track lock to stay compatible with the existing single-judge-per-track invariants.
|
||||
bool locked = false;
|
||||
@@ -322,7 +329,7 @@ public class Note : BaseNote
|
||||
if (isJudged || key != keyToPress)
|
||||
return;
|
||||
|
||||
string myId = gameObject.GetInstanceID().ToString();
|
||||
string myId = CachedId;
|
||||
|
||||
// Try to acquire lock for this note (prevents multiple notes on same track being judged at once)
|
||||
if (TrackKeyManager.Instance != null && !TrackKeyManager.Instance.TryLockTrackForJudge(TrackIndex, myId))
|
||||
@@ -502,7 +509,7 @@ public class Note : BaseNote
|
||||
}
|
||||
|
||||
// Remove from per-track queue immediately so following notes become head
|
||||
string myId = gameObject.GetInstanceID().ToString();
|
||||
string myId = CachedId;
|
||||
TrackKeyManager.Instance?.UnregisterKey(TrackIndex, myId);
|
||||
|
||||
// Release lock if we held it
|
||||
@@ -584,7 +591,7 @@ public class Note : BaseNote
|
||||
InputManager.OnKeyPressed -= HandlePress;
|
||||
|
||||
// CRITICAL: Always release lock and unregister before deactivating
|
||||
string myId = gameObject.GetInstanceID().ToString();
|
||||
string myId = CachedId;
|
||||
|
||||
// Release lock if we still hold it
|
||||
ReleaseTrackLock(myId);
|
||||
|
||||
@@ -9,6 +9,11 @@ public class NoteController : MonoBehaviour
|
||||
private ParticleSystem hitEffect;
|
||||
private Note linkedNote;
|
||||
private bool useAbsolutePositioning = false;
|
||||
|
||||
// 去物理判定:用几何阈值复现原 Trigger 的进入/离开时刻。
|
||||
private Collider2D _col;
|
||||
private float _enterY, _exitY;
|
||||
private bool _thresholdsReady;
|
||||
|
||||
// For absolute position tracking instead of relative translate
|
||||
private Vector3 spawnPosition;
|
||||
@@ -20,6 +25,12 @@ public class NoteController : MonoBehaviour
|
||||
hitEffect = GetComponentInChildren<ParticleSystem>();
|
||||
linkedNote = GetComponent<Note>();
|
||||
spawnPosition = transform.position;
|
||||
_col = GetComponent<Collider2D>();
|
||||
// 去物理判定:判定改为几何驱动,音符不再需要 Physics2D 模拟。
|
||||
// 关闭 Rigidbody2D 模拟,消除每帧移动 kinematic 刚体触发的物理同步/重叠检测开销。
|
||||
// 保留 Collider 组件本身(仅用于读取其 bounds 计算阈值,不参与物理)。
|
||||
var rb = GetComponent<Rigidbody2D>();
|
||||
if (rb != null) rb.simulated = false;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
@@ -29,12 +40,41 @@ public class NoteController : MonoBehaviour
|
||||
// Use absolute positioning based on elapsed time since activation
|
||||
float elapsedSinceActivation = GameplayClock.NowSongTime - activationTime;
|
||||
float travelDistance = speed * Mathf.Max(0f, elapsedSinceActivation);
|
||||
|
||||
|
||||
// Position = spawnPoint + initial offset + downward travel
|
||||
Vector3 newPos = spawnPosition;
|
||||
newPos.y -= (baseSpawnYOffset + travelDistance);
|
||||
transform.position = newPos;
|
||||
}
|
||||
|
||||
// 去物理判定:用音符 Y 与阈值的比较复现 OnTriggerEnter2D/Exit2D 的进入/离开时刻。
|
||||
UpdateJudgeZoneByGeometry();
|
||||
}
|
||||
|
||||
private void UpdateJudgeZoneByGeometry()
|
||||
{
|
||||
if (linkedNote == null) return;
|
||||
if (!_thresholdsReady)
|
||||
{
|
||||
// 判定线捕获后,用音符自身 collider 计算进入/离开阈值(只算一次)。
|
||||
if (!JudgeZoneGeometry.LineReady) return;
|
||||
if (!JudgeZoneGeometry.TryGetThresholds(_col, transform, out _enterY, out _exitY)) return;
|
||||
_thresholdsReady = true;
|
||||
}
|
||||
|
||||
float noteY = transform.position.y;
|
||||
// 进入:noteY 下降到 ≤ enterY 且尚未进入。
|
||||
if (!isInJudgeZone && noteY <= _enterY)
|
||||
{
|
||||
EnterJudgeZone();
|
||||
// 单帧穿透(帧尖峰导致一帧内越过整个判定区):立即补一次离开,行为与物理一致。
|
||||
if (noteY <= _exitY) ExitJudgeZone();
|
||||
}
|
||||
// 离开:noteY 继续下降到 ≤ exitY 且当前在区内。
|
||||
else if (isInJudgeZone && noteY <= _exitY)
|
||||
{
|
||||
ExitJudgeZone();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetSpeed(float newSpeed)
|
||||
@@ -89,6 +129,7 @@ public class NoteController : MonoBehaviour
|
||||
baseSpawnYOffset = 0f;
|
||||
spawnPosition = transform.position;
|
||||
useAbsolutePositioning = false;
|
||||
_thresholdsReady = false; // 池化复用:下次重新计算阈值。
|
||||
|
||||
if (hitEffect != null)
|
||||
{
|
||||
@@ -97,31 +138,23 @@ public class NoteController : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D collision)
|
||||
// 进入判定区(原 OnTriggerEnter2D 逻辑,保持完全一致)。
|
||||
private void EnterJudgeZone()
|
||||
{
|
||||
if (collision.CompareTag("JudgmentLine"))
|
||||
{
|
||||
if (linkedNote != null)
|
||||
{
|
||||
isInJudgeZone = true;
|
||||
// register note by instance id so TrackKeyManager can prioritize
|
||||
// tap notes are short notes without hold
|
||||
TrackKeyManager.Instance.RegisterKey(linkedNote.GetTrackIndex(), linkedNote.gameObject.GetInstanceID().ToString(), "tap", linkedNote.HitTime);
|
||||
}
|
||||
}
|
||||
if (linkedNote == null) return;
|
||||
isInJudgeZone = true;
|
||||
// register note by instance id so TrackKeyManager can prioritize
|
||||
// tap notes are short notes without hold
|
||||
TrackKeyManager.Instance.RegisterKey(linkedNote.GetTrackIndex(), linkedNote.GetCachedInstanceId(), "tap", linkedNote.HitTime);
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D collision)
|
||||
// 离开判定区(原 OnTriggerExit2D 逻辑,保持完全一致)。
|
||||
private void ExitJudgeZone()
|
||||
{
|
||||
if (collision.CompareTag("JudgmentLine"))
|
||||
{
|
||||
if (linkedNote != null)
|
||||
{
|
||||
isInJudgeZone = false;
|
||||
TrackKeyManager.Instance.UnregisterKey(linkedNote.GetTrackIndex(), linkedNote.gameObject.GetInstanceID().ToString());
|
||||
linkedNote.SetJudgeZone(isInJudgeZone);
|
||||
}
|
||||
}
|
||||
if (linkedNote == null) return;
|
||||
isInJudgeZone = false;
|
||||
TrackKeyManager.Instance.UnregisterKey(linkedNote.GetTrackIndex(), linkedNote.GetCachedInstanceId());
|
||||
linkedNote.SetJudgeZone(isInJudgeZone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -86,7 +86,14 @@ public class NoteSpawner : MonoBehaviour
|
||||
// optional: constants for runtime clamping (kept for internal use)
|
||||
private const float SpeedMultiplierMin = 0.5f;
|
||||
private const float SpeedMultiplierMax = 2f;
|
||||
private const float FlowSpeedGlobalMultiplier = 1.5f;
|
||||
// 全局流速系数:与用户可调流速(speedMultiplier)相乘的独立系数,加快音符下落。
|
||||
// 为什么用它而非 bpm 倍率:noteSpeed = 10.75 * sm * bpm / 240,若靠 ×bpm 提速,
|
||||
// 长条音符的视觉长度由 ApplyVisualScale(sm) 决定(只随 sm 不随 bpm)、且分段数 segmentInterval=(60/bpm)/4
|
||||
// 也会变,导致长条视觉与下落速度不匹配而错乱。故 bpm 方案不可行;sm 才是让 noteSpeed 与
|
||||
// 长条视觉/粒子一致缩放的唯一安全杠杆。按用户指示设为 2。
|
||||
// 判定不受影响:音符恒在 hitTime 抵达判定线,speed 仅改视觉下落快慢与出现时机,
|
||||
// 判定链只依赖真实音符时间(hitTime ± range),与 speed 无共享变量。
|
||||
private const float FlowSpeedGlobalMultiplier = 2.0f;
|
||||
|
||||
public float EffectiveSpeedMultiplier
|
||||
{
|
||||
@@ -125,6 +132,8 @@ public class NoteSpawner : MonoBehaviour
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 去物理判定:清空上一场景/上一局缓存的判定线几何,重新捕获(判定线位置可能变)。
|
||||
JudgeZoneGeometry.Reset();
|
||||
EnsureDefaultNoteSpeedPreference();
|
||||
// Load saved visual speed multiplier before any spawning logic uses it
|
||||
float saved = PlayerPrefs.GetFloat(NoteSpeedPrefKey, NoteSpeedDefault);
|
||||
@@ -576,9 +585,18 @@ public class NoteSpawner : MonoBehaviour
|
||||
|
||||
private void EnsureJudgmentLine()
|
||||
{
|
||||
if (judgmentLine != null) return;
|
||||
GameObject go = GameObject.FindGameObjectWithTag("JudgmentLine");
|
||||
if (go != null) judgmentLine = go.transform;
|
||||
if (judgmentLine == null)
|
||||
{
|
||||
GameObject go = GameObject.FindGameObjectWithTag("JudgmentLine");
|
||||
if (go != null) judgmentLine = go.transform;
|
||||
}
|
||||
// 去物理判定:捕获判定线 collider 的世界 Y 范围(只一次),供音符用几何阈值判进出判定区。
|
||||
if (judgmentLine != null && !JudgeZoneGeometry.LineReady)
|
||||
{
|
||||
Collider2D lineCol = judgmentLine.GetComponent<Collider2D>();
|
||||
if (lineCol == null) lineCol = judgmentLine.GetComponentInChildren<Collider2D>();
|
||||
if (lineCol != null) JudgeZoneGeometry.CaptureLine(lineCol);
|
||||
}
|
||||
}
|
||||
|
||||
private float[] BuildLaneTravelTimes(float noteSpeed)
|
||||
|
||||
@@ -43,6 +43,9 @@ public class TrackJudgeHitEffectController : MonoBehaviour
|
||||
|
||||
private readonly Queue<GameObject> particlePool = new Queue<GameObject>();
|
||||
private Transform particlePoolRoot;
|
||||
private readonly HashSet<GameObject> rentedParticles = new HashSet<GameObject>();
|
||||
private readonly Dictionary<GameObject, int> particleRentIds = new Dictionary<GameObject, int>();
|
||||
private int particleRentCounter;
|
||||
|
||||
// Per-instance cache: the particle systems and computed lifetime are resolved once
|
||||
// when an instance is created, so the burst hot path never calls
|
||||
@@ -72,6 +75,21 @@ public class TrackJudgeHitEffectController : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
ReturnAllRentedParticles();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (Instance == this)
|
||||
{
|
||||
Instance = null;
|
||||
}
|
||||
|
||||
ReturnAllRentedParticles();
|
||||
}
|
||||
|
||||
public static void PlayTrackHitFx(int trackIndex)
|
||||
{
|
||||
var controller = Instance != null ? Instance : SceneObjectLookupCache.FindAny<TrackJudgeHitEffectController>();
|
||||
@@ -271,7 +289,8 @@ public class TrackJudgeHitEffectController : MonoBehaviour
|
||||
if (instance == null)
|
||||
return;
|
||||
|
||||
instance.transform.SetParent(parent, false);
|
||||
Transform safeParent = parent != null && parent.gameObject.activeInHierarchy ? parent : transform;
|
||||
instance.transform.SetParent(safeParent, false);
|
||||
instance.transform.localPosition = trackParticleLocalPosition;
|
||||
instance.transform.localRotation = Quaternion.Euler(trackParticleLocalEulerAngles);
|
||||
instance.transform.localScale = trackParticleLocalScale;
|
||||
@@ -280,7 +299,7 @@ public class TrackJudgeHitEffectController : MonoBehaviour
|
||||
Color tint = GetTrackColor(trackIndex);
|
||||
ApplyParticleColor(entry, tint);
|
||||
|
||||
StartCoroutine(RecycleParticleRoutine(instance, entry, Mathf.Max(0.05f, entry.Lifetime)));
|
||||
StartCoroutine(RecycleParticleRoutine(instance, entry, CurrentParticleRentToken(instance), Mathf.Max(0.05f, entry.Lifetime)));
|
||||
}
|
||||
|
||||
// Prewarm the track particle pool during load so the first judge does not pay
|
||||
@@ -354,27 +373,65 @@ public class TrackJudgeHitEffectController : MonoBehaviour
|
||||
{
|
||||
GameObject pooled = particlePool.Dequeue();
|
||||
if (pooled != null && particleCache.TryGetValue(pooled, out entry))
|
||||
{
|
||||
pooled.transform.SetParent(null, false);
|
||||
MarkParticleRented(pooled);
|
||||
return pooled;
|
||||
}
|
||||
}
|
||||
|
||||
GameObject created = CreateParticleInstance();
|
||||
if (created != null && particleCache.TryGetValue(created, out entry))
|
||||
{
|
||||
MarkParticleRented(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
entry = null;
|
||||
return created;
|
||||
}
|
||||
|
||||
private IEnumerator RecycleParticleRoutine(GameObject instance, ParticleCacheEntry entry, float delay)
|
||||
private int MarkParticleRented(GameObject instance)
|
||||
{
|
||||
if (useUnscaledTime)
|
||||
yield return new WaitForSecondsRealtime(delay);
|
||||
else
|
||||
yield return GameplayClock.WaitForSeconds(delay);
|
||||
if (instance == null)
|
||||
return -1;
|
||||
|
||||
rentedParticles.Add(instance);
|
||||
int token = ++particleRentCounter;
|
||||
particleRentIds[instance] = token;
|
||||
return token;
|
||||
}
|
||||
|
||||
private int CurrentParticleRentToken(GameObject instance)
|
||||
{
|
||||
return instance != null && particleRentIds.TryGetValue(instance, out int token) ? token : -1;
|
||||
}
|
||||
|
||||
private IEnumerator RecycleParticleRoutine(GameObject instance, ParticleCacheEntry entry, int rentToken, float delay)
|
||||
{
|
||||
// Short visual FX should always recycle in real time. Tying them to
|
||||
// GameplayClock can leak rented objects while the song clock is paused.
|
||||
yield return new WaitForSecondsRealtime(Mathf.Max(0f, delay));
|
||||
|
||||
if (instance == null)
|
||||
yield break;
|
||||
|
||||
if (CurrentParticleRentToken(instance) != rentToken)
|
||||
yield break;
|
||||
|
||||
ReturnParticleInstance(instance, entry);
|
||||
}
|
||||
|
||||
private void ReturnParticleInstance(GameObject instance, ParticleCacheEntry entry)
|
||||
{
|
||||
if (instance == null)
|
||||
return;
|
||||
|
||||
if (!rentedParticles.Remove(instance))
|
||||
return;
|
||||
|
||||
particleRentIds.Remove(instance);
|
||||
|
||||
if (entry != null && entry.Systems != null)
|
||||
{
|
||||
foreach (var ps in entry.Systems)
|
||||
@@ -391,6 +448,19 @@ public class TrackJudgeHitEffectController : MonoBehaviour
|
||||
particlePool.Enqueue(instance);
|
||||
}
|
||||
|
||||
private void ReturnAllRentedParticles()
|
||||
{
|
||||
var snapshot = new List<GameObject>(rentedParticles);
|
||||
foreach (var instance in snapshot)
|
||||
{
|
||||
if (instance == null)
|
||||
continue;
|
||||
|
||||
particleCache.TryGetValue(instance, out var entry);
|
||||
ReturnParticleInstance(instance, entry);
|
||||
}
|
||||
}
|
||||
|
||||
private Color GetTrackColor(int trackIndex)
|
||||
{
|
||||
if (trackColors != null && trackIndex >= 0 && trackIndex < trackColors.Length)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 让 gameplay 画面在任意分辨率/宽高比下都"完整可见、绝不裁剪"(contain 容纳式),
|
||||
/// 同时尽量维持轨道的可点击尺寸。避免此前 cover 式补偿(缩小FOV=镜头拉近)把上下/背景裁掉的问题。
|
||||
///
|
||||
/// 原理(透视相机、垂直 FOV 控制):轨道/背景是世界空间对象(Z=0 平面)。
|
||||
/// 垂直半可视世界高 ∝ tan(vFov/2);水平半可视世界宽 ∝ tan(vFov/2) · aspect。
|
||||
/// 要"包含"住 16:9 设计画面(设计横向范围 + 设计纵向范围)且绝不裁剪,取二者所需 FOV 的较大值:
|
||||
/// tan(vFov/2) = tan(designVFov/2) · max(1, designAspect / aspect)
|
||||
/// - 16:9:max(1,1)=1 → vFov = designVFov(与原设计逐像素一致)。
|
||||
/// - 更宽(21:9/32:9,aspect>designAspect):max=1 → vFov 保持 designVFov。
|
||||
/// 纵向完整不变、横向自然更宽(显示更多内容/背景),一寸不裁;轨道显得细些——
|
||||
/// 由已有的点击区兜底(OverlapPointAll+最近轨道匹配,横向无缝)保证仍好点,不误触。
|
||||
/// - 更窄(4:3,aspect<designAspect):max>1 → vFov 增大(镜头拉远),保证设计横向范围完整可见,
|
||||
/// 纵向显示更多,同样不裁剪。
|
||||
/// 本式**永不缩小 FOV**(不拉近),因此绝不会裁掉设计画面——这是与之前 cover 式的本质区别。
|
||||
///
|
||||
/// 只调相机一个表现参数(fieldOfView),不动任何 Transform;音符/轨道/点击区在同一世界平面,
|
||||
/// 相对对齐不受 FOV 影响;TrackTouchInput 每帧用当前相机状态做屏幕→世界命中,自动跟随。
|
||||
///
|
||||
/// 用一个 bool(enableStabilization)开关。关闭时恢复设计 FOV,行为与未加此脚本一致。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Camera))]
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class TrackScreenRatioStabilizer : MonoBehaviour
|
||||
{
|
||||
[Tooltip("是否启用轨道屏占比稳定(按宽高比补偿相机垂直FOV)。关闭则恢复设计FOV。")]
|
||||
public bool enableStabilization = true;
|
||||
|
||||
[Tooltip("设计基准宽高比(轨道占比在此比例下为设计值)。16:9 = 1.7778。")]
|
||||
public float designAspect = 16f / 9f;
|
||||
|
||||
[Tooltip("设计基准垂直FOV(该相机在16:9下的原始FOV)。留 0 则启动时自动取相机当前FOV。")]
|
||||
public float designVerticalFov = 0f;
|
||||
|
||||
[Tooltip("补偿后垂直FOV的安全范围,避免极端宽高比下过小/过大。")]
|
||||
public float minFov = 20f;
|
||||
public float maxFov = 100f;
|
||||
|
||||
private Camera _cam;
|
||||
private float _lastAspect = -1f;
|
||||
private bool _lastEnabled;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_cam = GetComponent<Camera>();
|
||||
// 自动记录设计 FOV(仅一次):此时相机 FOV 即 16:9 设计值。
|
||||
if (designVerticalFov <= 0f && _cam != null)
|
||||
{
|
||||
designVerticalFov = _cam.fieldOfView;
|
||||
}
|
||||
_lastAspect = -1f; // 强制下一次 Apply
|
||||
Apply();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
float aspect = GetAspect();
|
||||
// 宽高比或开关变化时才重算。
|
||||
if (Mathf.Abs(aspect - _lastAspect) > 0.0001f || enableStabilization != _lastEnabled)
|
||||
{
|
||||
Apply();
|
||||
}
|
||||
}
|
||||
|
||||
private float GetAspect()
|
||||
{
|
||||
// 优先用相机自身 aspect(可能被 viewport 影响),否则用屏幕。
|
||||
if (_cam != null && _cam.aspect > 0.0001f) return _cam.aspect;
|
||||
return (float)Screen.width / Mathf.Max(1, Screen.height);
|
||||
}
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
if (_cam == null) _cam = GetComponent<Camera>();
|
||||
if (_cam == null || _cam.orthographic) return; // 仅透视相机适用
|
||||
if (designVerticalFov <= 0f) designVerticalFov = _cam.fieldOfView;
|
||||
|
||||
float aspect = GetAspect();
|
||||
_lastAspect = aspect;
|
||||
_lastEnabled = enableStabilization;
|
||||
|
||||
if (!enableStabilization)
|
||||
{
|
||||
_cam.fieldOfView = designVerticalFov; // 关闭:恢复设计 FOV
|
||||
return;
|
||||
}
|
||||
|
||||
// contain(容纳、绝不裁剪):tan(vFov/2) = tan(designVFov/2) · max(1, designAspect/aspect)
|
||||
// max(1, …) 保证 FOV 永不小于设计值(不拉近、不裁上下);窄屏才放大以容纳设计横向范围。
|
||||
float designHalfTan = Mathf.Tan(designVerticalFov * 0.5f * Mathf.Deg2Rad);
|
||||
float widen = (aspect > 0.0001f) ? Mathf.Max(1f, designAspect / aspect) : 1f;
|
||||
float targetHalfTan = designHalfTan * widen;
|
||||
float targetFov = 2f * Mathf.Atan(targetHalfTan) * Mathf.Rad2Deg;
|
||||
_cam.fieldOfView = Mathf.Clamp(targetFov, minFov, maxFov);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 95e86c3f767fdb84fa3ea1cb479fd141
|
||||
@@ -68,6 +68,11 @@ public class TrackTouchInput : MonoBehaviour
|
||||
|
||||
private void Update()
|
||||
{
|
||||
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
|
||||
// Windows 平台:禁用轨道点击/触摸轮询,只依赖按键输入。
|
||||
// 编辑器(Win)也一并禁用,便于在 PC 上以键盘方式测试真实 Windows 行为。
|
||||
return;
|
||||
#else
|
||||
var im = InputManager.Instance;
|
||||
if (im == null) return;
|
||||
|
||||
@@ -80,6 +85,7 @@ public class TrackTouchInput : MonoBehaviour
|
||||
{
|
||||
ProcessMouse(im, cam);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>处理所有并发触点,返回本帧触点数量。</summary>
|
||||
@@ -198,6 +204,7 @@ public class TrackTouchInput : MonoBehaviour
|
||||
/// </summary>
|
||||
private int ResolveTrack(Camera cam, Vector2 screenPos)
|
||||
{
|
||||
EnsureOverlapFilter();
|
||||
Vector2 point;
|
||||
|
||||
if (forceOrthographicMode || cam.orthographic)
|
||||
@@ -222,12 +229,58 @@ public class TrackTouchInput : MonoBehaviour
|
||||
point = new Vector2(world.x, world.y);
|
||||
}
|
||||
|
||||
Collider2D hit = Physics2D.OverlapPoint(point, hitLayers);
|
||||
if (hit == null) return -1;
|
||||
// 用 OverlapPointAll 遍历所有命中的碰撞体,挑出带 TrackTouchZone 的那个。
|
||||
// 关键修复:判定线附近同一屏幕点常密集着 note 的 Collider2D 与 judgeline 触发器,
|
||||
// 单值版 OverlapPoint 只返回其中一个,若不是 tap zone 就丢判定(=点了轨道却没反应)。
|
||||
// 遍历全部命中即可穿透这些遮挡,可靠找到轨道点击区。
|
||||
int count = Physics2D.OverlapPoint(point, _overlapFilter, _overlapResults);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Collider2D hit = _overlapResults[i];
|
||||
if (hit == null) continue;
|
||||
TrackTouchZone zone = hit.GetComponent<TrackTouchZone>();
|
||||
if (zone == null) zone = hit.GetComponentInParent<TrackTouchZone>();
|
||||
if (zone != null) return zone.trackIndex;
|
||||
}
|
||||
|
||||
TrackTouchZone zone = hit.GetComponent<TrackTouchZone>();
|
||||
if (zone == null) zone = hit.GetComponentInParent<TrackTouchZone>();
|
||||
return zone != null ? zone.trackIndex : -1;
|
||||
// 兜底:没有精确命中任何 zone(点落在轨道间缝隙或紧邻边缘)。
|
||||
// 取横向最近的 zone,只要触点在其 Collider 的纵向范围内即算命中——
|
||||
// 等效于把相邻轨道点击区在横向补满、消除缝隙,且不误触远离轨道区的点击。
|
||||
return ResolveNearestZone(point);
|
||||
}
|
||||
|
||||
// 横向最近轨道兜底:在所有 zone 中找触点纵向落在其包围盒内、且横向距离最近的一个。
|
||||
private int ResolveNearestZone(Vector2 point)
|
||||
{
|
||||
var zones = TrackTouchZone.All;
|
||||
int best = -1;
|
||||
float bestDist = float.MaxValue;
|
||||
for (int i = 0; i < zones.Count; i++)
|
||||
{
|
||||
TrackTouchZone z = zones[i];
|
||||
if (z == null || z.Collider == null) continue;
|
||||
Bounds b = z.Collider.bounds;
|
||||
// 纵向必须落在该点击区范围内(留一点容差),避免点屏幕别处也触发轨道。
|
||||
if (point.y < b.min.y - 0.1f || point.y > b.max.y + 0.1f) continue;
|
||||
float dx = Mathf.Abs(point.x - b.center.x);
|
||||
if (dx < bestDist) { bestDist = dx; best = z.trackIndex; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// OverlapPoint 的复用缓冲与过滤器(避免每次触摸分配 GC)。
|
||||
private static readonly Collider2D[] _overlapResults = new Collider2D[16];
|
||||
private ContactFilter2D _overlapFilter;
|
||||
private bool _filterReady;
|
||||
|
||||
private void EnsureOverlapFilter()
|
||||
{
|
||||
if (_filterReady) return;
|
||||
_overlapFilter = new ContactFilter2D();
|
||||
_overlapFilter.useLayerMask = true;
|
||||
_overlapFilter.SetLayerMask(hitLayers);
|
||||
_overlapFilter.useTriggers = true; // tap zone 可能是触发器,需包含
|
||||
_filterReady = true;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
|
||||
@@ -37,11 +37,27 @@ public class TrackTouchZone : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
// 全部启用中的 zone 注册表:供 TrackTouchInput 在"点到轨道间缝隙/边缘"时做最近轨道兜底,
|
||||
// 无需改场景碰撞体尺寸即可消除轨道间空隙导致的漏点。
|
||||
private static readonly System.Collections.Generic.List<TrackTouchZone> _all =
|
||||
new System.Collections.Generic.List<TrackTouchZone>();
|
||||
public static System.Collections.Generic.IReadOnlyList<TrackTouchZone> All => _all;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_collider = GetComponent<Collider2D>();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (!_all.Contains(this)) _all.Add(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_all.Remove(this);
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
// 在所有晃动逻辑(Update)之后对齐,避免落后一帧。
|
||||
|
||||
@@ -36,6 +36,14 @@ public class effectEventController : MonoBehaviour
|
||||
private float cameraDriftSeedX;
|
||||
private float cameraDriftSeedY;
|
||||
|
||||
// 分辨率相机微调:改的是"漂移 pivot"(顶层节点,承载 -40°/-8 基准),不碰相机自身,
|
||||
// 因此不与 SmoothShake(写相机 local)、技能偏移(写技能 pivot)、冲刺(写相机 z)冲突。
|
||||
// 改 pivot 的 X 旋转无任何系统覆盖;改 pivot 的 Z 必须同步 cameraDriftBaseLocalPosition.z,
|
||||
// 否则 UpdateCameraDrift/ResetCameraDriftIfNeeded 会用旧基准把 z 拉回。
|
||||
private bool _cameraBaseCaptured;
|
||||
private float _designPivotRotX; // pivot 初始 X 旋转(如 -40)
|
||||
private float _designPivotZ; // pivot 初始 Z(如 -8)
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null)
|
||||
@@ -60,9 +68,23 @@ public class effectEventController : MonoBehaviour
|
||||
CacheTrackCrashController();
|
||||
}
|
||||
|
||||
// 门控:漂移关闭且已归位后,跳过每帧的 Perlin/缓存查找;开关切换时才归位一次。
|
||||
private bool _driftResetDone;
|
||||
|
||||
private void Update()
|
||||
{
|
||||
UpdateCameraDrift();
|
||||
if (enableCameraDrift)
|
||||
{
|
||||
_driftResetDone = false;
|
||||
UpdateCameraDrift();
|
||||
}
|
||||
else if (!_driftResetDone)
|
||||
{
|
||||
// 关闭瞬间归位一次,之后不再每帧空跑。
|
||||
UpdateCameraDrift();
|
||||
_driftResetDone = true;
|
||||
}
|
||||
|
||||
UpdatePlayerSkillCameraOffset();
|
||||
}
|
||||
|
||||
@@ -85,6 +107,43 @@ public class effectEventController : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
// 捕获漂移 pivot 的设计基准(X旋转/Z)一次。pivot 建好后调用即安全。
|
||||
private void CaptureCameraBaseIfNeeded()
|
||||
{
|
||||
if (_cameraBaseCaptured) return;
|
||||
if (cachedCameraDriftPivot == null)
|
||||
{
|
||||
CacheShakeTarget(); // 确保 pivot 已建立
|
||||
if (cachedCameraDriftPivot == null) return;
|
||||
}
|
||||
_designPivotRotX = cachedCameraDriftPivot.localEulerAngles.x;
|
||||
_designPivotZ = cachedCameraDriftPivot.localPosition.z;
|
||||
_cameraBaseCaptured = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按分辨率微调相机:在设计基准上叠加 X 旋转增量与 Z 位置增量。
|
||||
/// 改的是漂移 pivot(不动相机本身),且同步 cameraDriftBaseLocalPosition.z,
|
||||
/// 与漂移/抖动/技能偏移/冲刺全部不冲突。可重复调用(总是相对设计基准,不累积)。
|
||||
/// </summary>
|
||||
public void SetResolutionCameraAdjust(float extraRotX, float extraZ)
|
||||
{
|
||||
CaptureCameraBaseIfNeeded();
|
||||
if (cachedCameraDriftPivot == null) return;
|
||||
|
||||
// X 旋转:设计值 + 增量。pivot 的 rotation 无系统写入,直接设安全。
|
||||
Vector3 euler = cachedCameraDriftPivot.localEulerAngles;
|
||||
euler.x = _designPivotRotX + extraRotX;
|
||||
cachedCameraDriftPivot.localEulerAngles = euler;
|
||||
|
||||
// Z:设计值 + 增量,并同步漂移基准 z(漂移只改 x,y,z 恒取基准)。
|
||||
float targetZ = _designPivotZ + extraZ;
|
||||
Vector3 lp = cachedCameraDriftPivot.localPosition;
|
||||
lp.z = targetZ;
|
||||
cachedCameraDriftPivot.localPosition = lp;
|
||||
cameraDriftBaseLocalPosition.z = targetZ; // 关键:否则会被 UpdateCameraDrift/Reset 拉回
|
||||
}
|
||||
|
||||
private void PrewarmShakeComponent()
|
||||
{
|
||||
if (cachedShakeTarget == null)
|
||||
|
||||
@@ -88,6 +88,17 @@ public class groundParticularController : MonoBehaviour
|
||||
private float[] _audioSamples = new float[128];
|
||||
private float _currentAverageVolume = 0f;
|
||||
private float _emitTimer = 0f;
|
||||
|
||||
// 性能:复用一个 MaterialPropertyBlock,避免每颗粒子 new(GC)。
|
||||
private MaterialPropertyBlock _sharedPropBlock;
|
||||
// 性能:缓存粒子的 Renderer,避免 Emit/MoveRoutine 每次 GetComponent。
|
||||
private readonly Dictionary<GameObject, Renderer> _rendererCache = new Dictionary<GameObject, Renderer>();
|
||||
// 性能:记录已对某共享材质设过 renderQueue,避免重复写。
|
||||
private readonly HashSet<Material> _renderQueueApplied = new HashSet<Material>();
|
||||
// Shader 属性 ID 缓存(比字符串查找快,且避免每帧字符串)。
|
||||
private static readonly int _headColorId = Shader.PropertyToID("_HeadColor");
|
||||
private static readonly int _tailColorId = Shader.PropertyToID("_TailColor");
|
||||
private static readonly int _moveDirId = Shader.PropertyToID("_MoveDir");
|
||||
|
||||
// 材质均衡分配逻辑
|
||||
private List<MaterialConfig> _cachedEnabledConfigs = new List<MaterialConfig>();
|
||||
@@ -99,11 +110,15 @@ public class groundParticularController : MonoBehaviour
|
||||
private void Awake()
|
||||
{
|
||||
if (startPoint != null) _originalStartScale = startPoint.localScale;
|
||||
_sharedPropBlock = new MaterialPropertyBlock();
|
||||
|
||||
_pool = new ObjectPool<GameObject>(
|
||||
createFunc: () => {
|
||||
GameObject go = Instantiate(particlePrefab);
|
||||
go.SetActive(false);
|
||||
// 创建时缓存 Renderer,后续 Emit/Move 复用,避免每颗粒子 GetComponent。
|
||||
var r = go.GetComponent<Renderer>();
|
||||
if (r != null) _rendererCache[go] = r;
|
||||
return go;
|
||||
},
|
||||
actionOnGet: (obj) => {
|
||||
@@ -155,10 +170,11 @@ public class groundParticularController : MonoBehaviour
|
||||
// 批量生成
|
||||
for (int i = 0; i < emitBatchSize; i++)
|
||||
{
|
||||
// 引入混乱度:随机延迟发射
|
||||
// 引入混乱度:随机延迟发射。用协程替代 Invoke(nameof)——
|
||||
// 后者每次做字符串反射+分配;协程延迟无字符串开销。
|
||||
if (chaosRandomDelay > 0)
|
||||
{
|
||||
Invoke(nameof(DelayedEmit), Random.Range(0f, chaosRandomDelay));
|
||||
StartCoroutine(DelayedEmitRoutine(Random.Range(0f, chaosRandomDelay)));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -168,11 +184,22 @@ public class groundParticularController : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
private void DelayedEmit()
|
||||
private System.Collections.IEnumerator DelayedEmitRoutine(float delay)
|
||||
{
|
||||
if (delay > 0f) yield return new WaitForSeconds(delay);
|
||||
Emit();
|
||||
}
|
||||
|
||||
// 取粒子缓存的 Renderer;未缓存(异常情况)则补取一次。
|
||||
private Renderer GetCachedRenderer(GameObject particle)
|
||||
{
|
||||
if (particle == null) return null;
|
||||
if (_rendererCache.TryGetValue(particle, out Renderer r) && r != null) return r;
|
||||
r = particle.GetComponent<Renderer>();
|
||||
if (r != null) _rendererCache[particle] = r;
|
||||
return r;
|
||||
}
|
||||
|
||||
private void AnalyzeAudio()
|
||||
{
|
||||
if (musicSource == null || !musicSource.isPlaying || !useAudioAnalysis)
|
||||
@@ -233,7 +260,7 @@ public class groundParticularController : MonoBehaviour
|
||||
particle.layer = layer;
|
||||
}
|
||||
|
||||
var renderer = particle.GetComponent<Renderer>();
|
||||
Renderer renderer = GetCachedRenderer(particle);
|
||||
if (renderer != null)
|
||||
{
|
||||
// 设置渲染层级
|
||||
@@ -242,11 +269,17 @@ public class groundParticularController : MonoBehaviour
|
||||
|
||||
if (selectedConfig != null)
|
||||
{
|
||||
// 使用实例化材质以允许独立修改 RenderQueue
|
||||
renderer.material = selectedConfig.Value.material;
|
||||
if (renderQueueOffset != 0)
|
||||
Material mat = selectedConfig.Value.material;
|
||||
// 用共享材质(sharedMaterial)避免克隆实例产生 GC / 破坏合批。
|
||||
// renderQueue 是全局统一配置(renderQueueOffset),只需对每个材质设一次即可,
|
||||
// 所有用该材质的粒子渲染队列一致,视觉与原来相同。
|
||||
if (renderQueueOffset != 0 && _renderQueueApplied.Add(mat))
|
||||
{
|
||||
renderer.material.renderQueue = selectedConfig.Value.material.renderQueue + renderQueueOffset;
|
||||
mat.renderQueue = mat.renderQueue + renderQueueOffset;
|
||||
}
|
||||
if (renderer.sharedMaterial != mat)
|
||||
{
|
||||
renderer.sharedMaterial = mat;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,9 +343,9 @@ public class groundParticularController : MonoBehaviour
|
||||
|
||||
particle.transform.position = spawnWorldPos;
|
||||
|
||||
// 获取材质属性块用于动态修改颜色 (不产生材质实例 GC)
|
||||
MaterialPropertyBlock propBlock = new MaterialPropertyBlock();
|
||||
var renderer = particle.GetComponent<Renderer>();
|
||||
// 复用共享属性块(不 new,避免每颗粒子 GC)与缓存的 Renderer。
|
||||
MaterialPropertyBlock propBlock = _sharedPropBlock;
|
||||
Renderer renderer = GetCachedRenderer(particle);
|
||||
|
||||
while (particle != null && particle.activeInHierarchy)
|
||||
{
|
||||
@@ -346,10 +379,10 @@ public class groundParticularController : MonoBehaviour
|
||||
Color tailColor = config.Value.colorGradient.Evaluate(0f);
|
||||
|
||||
renderer.GetPropertyBlock(propBlock);
|
||||
// 对应 Shader 中的属性
|
||||
propBlock.SetColor("_HeadColor", headColor);
|
||||
propBlock.SetColor("_TailColor", tailColor);
|
||||
propBlock.SetVector("_MoveDir", moveDir);
|
||||
// 用缓存的属性 ID(避免每帧字符串查找)
|
||||
propBlock.SetColor(_headColorId, headColor);
|
||||
propBlock.SetColor(_tailColorId, tailColor);
|
||||
propBlock.SetVector(_moveDirId, moveDir);
|
||||
renderer.SetPropertyBlock(propBlock);
|
||||
}
|
||||
|
||||
|
||||
@@ -1377,6 +1377,7 @@ namespace GameServer.Client
|
||||
data["display_name"]?.ToString(),
|
||||
data["avatar_url"]?.ToString());
|
||||
}
|
||||
Enqueue(() => ApplyHonorUpdate(data));
|
||||
_socialConnectedTcs?.TrySetResult(true);
|
||||
return;
|
||||
case "unread_count":
|
||||
@@ -1454,6 +1455,11 @@ namespace GameServer.Client
|
||||
Enqueue(() => ApplyPresenceUpdate(presence));
|
||||
return;
|
||||
}
|
||||
case "honor_updated":
|
||||
{
|
||||
Enqueue(() => ApplyHonorUpdate(data));
|
||||
return;
|
||||
}
|
||||
case "presence_updated":
|
||||
return;
|
||||
case "friend_removed":
|
||||
@@ -2126,13 +2132,169 @@ namespace GameServer.Client
|
||||
|
||||
entry.friend_name = NetworkManager.ResolveDisplayName(presence.display_name, presence.steam_id, false);
|
||||
entry.friend_avatar = NetworkManager.ResolveAvatarUrl(presence.avatar_url, presence.steam_id);
|
||||
entry.player_title_id = presence.player_title_id ?? string.Empty;
|
||||
entry.player_title = presence.player_title ?? string.Empty;
|
||||
entry.online = !string.Equals(presence.status, "OFFLINE", StringComparison.OrdinalIgnoreCase);
|
||||
entry.status = presence.status;
|
||||
|
||||
UpsertFriend(entry);
|
||||
ApplyTitleUpdate(presence.steam_id, entry.player_title_id, entry.player_title);
|
||||
OnFriendsListChanged?.Invoke(GetFriendsSnapshot());
|
||||
}
|
||||
|
||||
private void ApplyHonorUpdate(JObject data)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string steamId = data["steam_id"]?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(steamId))
|
||||
{
|
||||
steamId = NetworkManager.Instance != null ? NetworkManager.Instance.SteamId : string.Empty;
|
||||
}
|
||||
|
||||
JObject primaryHonor = data["primary_honor"] as JObject;
|
||||
string titleId = primaryHonor?["honor_key"]?.ToString() ?? data["player_title_id"]?.ToString() ?? string.Empty;
|
||||
string title = primaryHonor?["honor_name"]?.ToString() ?? data["player_title"]?.ToString() ?? string.Empty;
|
||||
ApplyTitleUpdate(steamId, titleId, title);
|
||||
}
|
||||
|
||||
private void ApplyTitleUpdate(string steamId, string titleId, string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(steamId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string safeTitleId = titleId ?? string.Empty;
|
||||
string safeTitle = title ?? string.Empty;
|
||||
bool roomChanged = ApplyTitleToCurrentRoom(steamId, safeTitleId, safeTitle);
|
||||
bool friendsChanged = ApplyTitleToFriends(steamId, safeTitleId, safeTitle);
|
||||
bool roomChatChanged = ApplyTitleToMessages(_roomChatMessages, steamId, safeTitleId, safeTitle);
|
||||
bool worldChatChanged = ApplyTitleToMessages(_worldChatMessages, steamId, safeTitleId, safeTitle);
|
||||
bool privateChatChanged = false;
|
||||
|
||||
foreach (List<ArenaRoomChatMessage> messages in _privateChatMessages.Values)
|
||||
{
|
||||
privateChatChanged |= ApplyTitleToMessages(messages, steamId, safeTitleId, safeTitle);
|
||||
}
|
||||
|
||||
if (roomChanged)
|
||||
{
|
||||
OnRoomSnapshotChanged?.Invoke(CurrentRoom);
|
||||
}
|
||||
|
||||
if (friendsChanged)
|
||||
{
|
||||
OnFriendsListChanged?.Invoke(GetFriendsSnapshot());
|
||||
}
|
||||
|
||||
if (roomChatChanged)
|
||||
{
|
||||
OnRoomChatMessagesChanged?.Invoke(GetRoomChatMessages());
|
||||
}
|
||||
|
||||
if (worldChatChanged)
|
||||
{
|
||||
OnWorldChatMessagesChanged?.Invoke(GetWorldChatMessages());
|
||||
}
|
||||
|
||||
if (privateChatChanged)
|
||||
{
|
||||
foreach (string partnerId in _privateChatMessages.Keys.ToList())
|
||||
{
|
||||
OnPrivateChatMessagesChanged?.Invoke(partnerId, GetPrivateChatMessages(partnerId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool ApplyTitleToCurrentRoom(string steamId, string titleId, string title)
|
||||
{
|
||||
if (CurrentRoom == null || CurrentRoom.participants == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
foreach (ArenaRoomParticipant participant in CurrentRoom.participants)
|
||||
{
|
||||
if (participant == null || !string.Equals(participant.steam_id, steamId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(participant.player_title_id ?? string.Empty, titleId, StringComparison.Ordinal))
|
||||
{
|
||||
participant.player_title_id = titleId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(participant.player_title ?? string.Empty, title, StringComparison.Ordinal))
|
||||
{
|
||||
participant.player_title = title;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private bool ApplyTitleToFriends(string steamId, string titleId, string title)
|
||||
{
|
||||
if (!_friends.TryGetValue(steamId, out SocialFriendEntry friend) || friend == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
if (!string.Equals(friend.player_title_id ?? string.Empty, titleId, StringComparison.Ordinal))
|
||||
{
|
||||
friend.player_title_id = titleId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(friend.player_title ?? string.Empty, title, StringComparison.Ordinal))
|
||||
{
|
||||
friend.player_title = title;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static bool ApplyTitleToMessages(IEnumerable<ArenaRoomChatMessage> messages, string steamId, string titleId, string title)
|
||||
{
|
||||
if (messages == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
foreach (ArenaRoomChatMessage message in messages)
|
||||
{
|
||||
if (message == null || !string.Equals(GetChatMessageSteamId(message), steamId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(message.player_title_id ?? string.Empty, titleId, StringComparison.Ordinal))
|
||||
{
|
||||
message.player_title_id = titleId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(message.player_title ?? string.Empty, title, StringComparison.Ordinal))
|
||||
{
|
||||
message.player_title = title;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private void RemoveFriend(string steamId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(steamId))
|
||||
@@ -2947,6 +3109,16 @@ namespace GameServer.Client
|
||||
participant.avatar_url = existing.avatar_url;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(participant.player_title_id))
|
||||
{
|
||||
participant.player_title_id = existing.player_title_id;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(participant.player_title))
|
||||
{
|
||||
participant.player_title = existing.player_title;
|
||||
}
|
||||
|
||||
if (participant.join_order <= 0)
|
||||
{
|
||||
participant.join_order = existing.join_order;
|
||||
|
||||
@@ -107,6 +107,22 @@ namespace GameServer.Client
|
||||
[JsonProperty("total_plays")] public int total_plays;
|
||||
[JsonProperty("actime")] public string actime;
|
||||
[JsonProperty("u_reg_id")] public int u_reg_id;
|
||||
[JsonProperty("player_title_id")] public string player_title_id;
|
||||
[JsonProperty("player_title")] public string player_title;
|
||||
[JsonProperty("primary_honor")] public HonorData primary_honor;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class HonorData
|
||||
{
|
||||
[JsonProperty("id")] public int id;
|
||||
[JsonProperty("honor_key")] public string honor_key;
|
||||
[JsonProperty("honor_name")] public string honor_name;
|
||||
[JsonProperty("honor_desc")] public string honor_desc;
|
||||
[JsonProperty("honor_tier")] public string honor_tier;
|
||||
[JsonProperty("granted_by")] public string granted_by;
|
||||
[JsonProperty("granted_at")] public string granted_at;
|
||||
[JsonProperty("expires_at")] public string expires_at;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -285,6 +301,8 @@ namespace GameServer.Client
|
||||
[JsonProperty("uid")] public int uid;
|
||||
[JsonProperty("display_name")] public string display_name;
|
||||
[JsonProperty("avatar_url")] public string avatar_url;
|
||||
[JsonProperty("player_title_id")] public string player_title_id;
|
||||
[JsonProperty("player_title")] public string player_title;
|
||||
[JsonProperty("is_host")] public bool is_host;
|
||||
[JsonProperty("is_ready")] public bool is_ready;
|
||||
[JsonProperty("join_order")] public int join_order;
|
||||
@@ -337,6 +355,7 @@ namespace GameServer.Client
|
||||
[JsonProperty("receiver_id")] public string receiver_id;
|
||||
[JsonProperty("uid")] public int uid;
|
||||
[JsonProperty("display_name")] public string display_name;
|
||||
[JsonProperty("player_title_id")] public string player_title_id;
|
||||
[JsonProperty("player_title")] public string player_title;
|
||||
[JsonProperty("avatar_url")] public string avatar_url;
|
||||
[JsonProperty("content")] public string content;
|
||||
@@ -371,6 +390,8 @@ namespace GameServer.Client
|
||||
[JsonProperty("uid")] public int uid;
|
||||
[JsonProperty("friend_name")] public string friend_name;
|
||||
[JsonProperty("friend_avatar")] public string friend_avatar;
|
||||
[JsonProperty("player_title_id")] public string player_title_id;
|
||||
[JsonProperty("player_title")] public string player_title;
|
||||
[JsonProperty("online")] public bool online;
|
||||
[JsonProperty("status")] public string status;
|
||||
[JsonProperty("since")] public string since;
|
||||
@@ -476,6 +497,8 @@ namespace GameServer.Client
|
||||
[JsonProperty("uid")] public int uid;
|
||||
[JsonProperty("display_name")] public string display_name;
|
||||
[JsonProperty("avatar_url")] public string avatar_url;
|
||||
[JsonProperty("player_title_id")] public string player_title_id;
|
||||
[JsonProperty("player_title")] public string player_title;
|
||||
[JsonProperty("status")] public string status;
|
||||
}
|
||||
|
||||
|
||||
@@ -366,6 +366,7 @@ public class NetworkManager : MonoBehaviour
|
||||
}
|
||||
|
||||
ProfileData data = await GetJson<ProfileData>(BuildApiUrl($"/api/profile/{targetSteamId}"), CancellationToken.None);
|
||||
NormalizeProfileTitle(data);
|
||||
CacheIdentity(targetSteamId, data != null ? data.display_name : null, data != null ? data.avatar_url : null);
|
||||
return data;
|
||||
}
|
||||
@@ -439,11 +440,35 @@ public class NetworkManager : MonoBehaviour
|
||||
}
|
||||
|
||||
ProfileData data = await GetJson<ProfileData>(BuildApiUrl($"/api/players/uid/{uid}"), CancellationToken.None);
|
||||
NormalizeProfileTitle(data);
|
||||
string identitySteamId = data != null ? data.steam_id : null;
|
||||
CacheIdentity(identitySteamId, data != null ? data.display_name : null, data != null ? data.avatar_url : null);
|
||||
return data;
|
||||
}
|
||||
|
||||
private static void NormalizeProfileTitle(ProfileData data)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.primary_honor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(data.player_title_id))
|
||||
{
|
||||
data.player_title_id = data.primary_honor.honor_key;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(data.player_title))
|
||||
{
|
||||
data.player_title = data.primary_honor.honor_name;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SteamFriendsImportResponse> ImportSteamFriends(IEnumerable<string> steamFriends)
|
||||
{
|
||||
if (OnlineModeSettings.IsLocalOnlyMode)
|
||||
|
||||
@@ -39,6 +39,9 @@ public class LocalizationRuntimeBootstrap : MonoBehaviour
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 引擎主循环已运行,此处安全地完成 Localization(Addressables)初始化——
|
||||
// 绝不能在 BeforeSceneLoad 的 Bootstrap() 里同步等它(会死锁)。
|
||||
LocalizationService.EnsureLocalizationSettingsInitialized();
|
||||
StartCoroutine(ApplyLocalizationDeferred());
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,9 @@ public static class LocalizationService
|
||||
return results;
|
||||
}
|
||||
|
||||
// Addressables/LocalizationSettings 是否已完成初始化(与基础初始化分离)。
|
||||
private static bool _settingsInitialized;
|
||||
|
||||
public static void EnsureInitialized()
|
||||
{
|
||||
if (_initialized)
|
||||
@@ -98,14 +101,41 @@ public static class LocalizationService
|
||||
return;
|
||||
}
|
||||
|
||||
// 只做"安全的同步基础初始化":语言偏好 + 运行时回退包(从 StreamingAssets 读)。
|
||||
// Get()/TryGet() 会先查表、查不到再走这些回退包,所以此时本地化已可用。
|
||||
_currentLanguageCode = NormalizeLanguageCode(PlayerPrefs.GetString(LanguagePrefKey, ResolveStartupLanguageCode()));
|
||||
PlayerPrefs.SetString(LanguagePrefKey, _currentLanguageCode);
|
||||
PlayerPrefs.Save();
|
||||
LoadRuntimeFallbackPack(DefaultLanguageCode);
|
||||
LoadRuntimeFallbackPack("en-US");
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
// 关键(修复启动死锁):绝不在此处同步 WaitForCompletion 等 Addressables 初始化。
|
||||
// 该方法可能在 [RuntimeInitializeOnLoadMethod(BeforeSceneLoad)] 阶段被调用,
|
||||
// 此时引擎主循环尚未运行,WaitForCompletion 会自旋泵送 Addressables 异步链,
|
||||
// 而其 continuation 需要主线程/尚未就绪的子系统 → WaitOnAddress 永远等不到信号而死锁。
|
||||
// 改为把 LocalizationSettings 初始化推迟到引擎就绪后(EnsureLocalizationSettingsInitialized)。
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 Unity Localization(Addressables 表数据库)。必须在引擎主循环运行后调用
|
||||
/// (如首帧协程 / 场景加载后),不能在 BeforeSceneLoad 同步阶段调用,否则死锁。
|
||||
/// 未完成前,本地化走运行时回退包,不影响 Get() 可用性。
|
||||
/// </summary>
|
||||
public static void EnsureLocalizationSettingsInitialized()
|
||||
{
|
||||
if (_settingsInitialized) return;
|
||||
|
||||
lock (SyncRoot)
|
||||
{
|
||||
if (_settingsInitialized) return;
|
||||
if (!_initialized) EnsureInitialized();
|
||||
|
||||
if (LocalizationSettings.HasSettings)
|
||||
{
|
||||
// 引擎已就绪,此时 WaitForCompletion 安全(异步链能被正常泵送完成)。
|
||||
LocalizationSettings.InitializeSynchronously = true;
|
||||
var init = LocalizationSettings.InitializationOperation;
|
||||
if (!init.IsDone)
|
||||
@@ -123,13 +153,15 @@ public static class LocalizationService
|
||||
ApplySelectedLocale(_currentLanguageCode, savePreference: true, notifyListeners: false);
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
_settingsInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetLanguage(string languageCode)
|
||||
{
|
||||
EnsureInitialized();
|
||||
// 运行时切换语言由 UI 触发(引擎已就绪),此处确保 Localization 设置已初始化,安全。
|
||||
EnsureLocalizationSettingsInitialized();
|
||||
string normalized = NormalizeLanguageCode(languageCode);
|
||||
if (string.Equals(_currentLanguageCode, normalized, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
||||
@@ -943,8 +943,10 @@ public class globalChatSystem : MonoBehaviour
|
||||
playerMessagePrefab controller = instance.GetComponentInChildren<playerMessagePrefab>();
|
||||
if (controller != null)
|
||||
{
|
||||
string titleId = !string.IsNullOrWhiteSpace(message.player_title_id) ? message.player_title_id : string.Empty;
|
||||
string title = !string.IsNullOrWhiteSpace(message.player_title) ? message.player_title : string.Empty;
|
||||
controller.Bind(senderSteamId, avatarUrl, displayName, title, message.content, bubbleColor);
|
||||
PlayerTitleResolver.ResolvedTitle resolvedTitle = PlayerTitleResolver.Resolve(titleId, title);
|
||||
controller.Bind(senderSteamId, avatarUrl, displayName, resolvedTitle.titleId, resolvedTitle.titleText, message.content, bubbleColor);
|
||||
}
|
||||
|
||||
return instance;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
[CreateAssetMenu(fileName = "PlayerTitleLibrary", menuName = "Bansonic/Player Title Library")]
|
||||
public class PlayerTitleLibrary : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public class PlayerTitleEntry
|
||||
{
|
||||
public string titleId;
|
||||
public Sprite titleSprite;
|
||||
public string chineseName;
|
||||
public string englishName;
|
||||
}
|
||||
|
||||
[SerializeField] private List<PlayerTitleEntry> titles = new List<PlayerTitleEntry>();
|
||||
|
||||
private Dictionary<string, PlayerTitleEntry> lookup;
|
||||
|
||||
public bool TryGetTitle(string key, out PlayerTitleEntry entry)
|
||||
{
|
||||
EnsureLookup();
|
||||
|
||||
entry = null;
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return lookup.TryGetValue(NormalizeKey(key), out entry) && entry != null;
|
||||
}
|
||||
|
||||
private void EnsureLookup()
|
||||
{
|
||||
if (lookup != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lookup = new Dictionary<string, PlayerTitleEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < titles.Count; i++)
|
||||
{
|
||||
PlayerTitleEntry entry = titles[i];
|
||||
if (entry == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddLookup(entry.titleId, entry);
|
||||
AddLookup(entry.chineseName, entry);
|
||||
AddLookup(entry.englishName, entry);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddLookup(string key, PlayerTitleEntry entry)
|
||||
{
|
||||
string normalized = NormalizeKey(key);
|
||||
if (!string.IsNullOrEmpty(normalized) && !lookup.ContainsKey(normalized))
|
||||
{
|
||||
lookup.Add(normalized, entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeKey(string key)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(key) ? string.Empty : key.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a77a5d11c6274a94bb16e9d477a17301
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,66 @@
|
||||
using UnityEngine;
|
||||
|
||||
public static class PlayerTitleResolver
|
||||
{
|
||||
private const string PlayerTitleLibraryResourcePath = "so/player_titles/PlayerTitleLibrary";
|
||||
|
||||
private static PlayerTitleLibrary cachedTitleLibrary;
|
||||
|
||||
public readonly struct ResolvedTitle
|
||||
{
|
||||
public readonly string titleId;
|
||||
public readonly string titleText;
|
||||
public readonly Sprite titleSprite;
|
||||
|
||||
public bool HasSprite => titleSprite != null;
|
||||
public bool HasText => !string.IsNullOrWhiteSpace(titleText);
|
||||
public bool HasAny => HasSprite || HasText;
|
||||
|
||||
public ResolvedTitle(string titleId, string titleText, Sprite titleSprite)
|
||||
{
|
||||
this.titleId = titleId ?? string.Empty;
|
||||
this.titleText = titleText ?? string.Empty;
|
||||
this.titleSprite = titleSprite;
|
||||
}
|
||||
}
|
||||
|
||||
public static ResolvedTitle Resolve(string titleId, string fallbackText)
|
||||
{
|
||||
string safeTitleId = string.IsNullOrWhiteSpace(titleId) ? string.Empty : titleId.Trim();
|
||||
string safeFallbackText = string.IsNullOrWhiteSpace(fallbackText) ? string.Empty : fallbackText.Trim();
|
||||
Sprite sprite = null;
|
||||
string resolvedText = safeFallbackText;
|
||||
|
||||
PlayerTitleLibrary library = GetTitleLibrary();
|
||||
if (library != null && !string.IsNullOrEmpty(safeTitleId)
|
||||
&& library.TryGetTitle(safeTitleId, out PlayerTitleLibrary.PlayerTitleEntry entry)
|
||||
&& entry != null)
|
||||
{
|
||||
sprite = entry.titleSprite;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolvedText))
|
||||
{
|
||||
resolvedText = !string.IsNullOrWhiteSpace(entry.chineseName)
|
||||
? entry.chineseName.Trim()
|
||||
: (!string.IsNullOrWhiteSpace(entry.englishName) ? entry.englishName.Trim() : safeTitleId);
|
||||
}
|
||||
}
|
||||
|
||||
return new ResolvedTitle(safeTitleId, resolvedText, sprite);
|
||||
}
|
||||
|
||||
public static Sprite ResolveSprite(string titleId)
|
||||
{
|
||||
return Resolve(titleId, string.Empty).titleSprite;
|
||||
}
|
||||
|
||||
public static PlayerTitleLibrary GetTitleLibrary()
|
||||
{
|
||||
if (cachedTitleLibrary == null)
|
||||
{
|
||||
cachedTitleLibrary = Resources.Load<PlayerTitleLibrary>(PlayerTitleLibraryResourcePath);
|
||||
}
|
||||
|
||||
return cachedTitleLibrary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fa6b1900d3d4e05499405619dcf2ff36
|
||||
@@ -13,6 +13,7 @@ using Steamworks;
|
||||
public class playerMessagePrefab : MonoBehaviour
|
||||
{
|
||||
[SerializeField] Image playerProfile;
|
||||
[SerializeField] Image playerTitleImage;
|
||||
[SerializeField] Text playerTitle;
|
||||
[SerializeField] Text playerName;
|
||||
[SerializeField] Text messageText;
|
||||
@@ -48,14 +49,16 @@ public class playerMessagePrefab : MonoBehaviour
|
||||
}
|
||||
|
||||
public void Bind(string steamId, string avatarUrl, string displayName, string title, string content, Color bubbleColor)
|
||||
{
|
||||
Bind(steamId, avatarUrl, displayName, string.Empty, title, content, bubbleColor);
|
||||
}
|
||||
|
||||
public void Bind(string steamId, string avatarUrl, string displayName, string titleId, string title, string content, Color bubbleColor)
|
||||
{
|
||||
_boundSteamId = steamId ?? string.Empty;
|
||||
_boundAvatarUrl = avatarUrl ?? string.Empty;
|
||||
|
||||
if (playerTitle != null)
|
||||
{
|
||||
playerTitle.text = string.IsNullOrWhiteSpace(title) ? string.Empty : title;
|
||||
}
|
||||
BindPlayerTitle(titleId, title);
|
||||
|
||||
if (playerName != null)
|
||||
{
|
||||
@@ -79,6 +82,51 @@ public class playerMessagePrefab : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
private void BindPlayerTitle(string titleId, string title)
|
||||
{
|
||||
EnsureTitleImageReference();
|
||||
|
||||
PlayerTitleResolver.ResolvedTitle resolvedTitle = PlayerTitleResolver.Resolve(titleId, title);
|
||||
bool hasTitleSprite = resolvedTitle.HasSprite;
|
||||
bool hasTitleText = resolvedTitle.HasText;
|
||||
|
||||
if (playerTitleImage != null)
|
||||
{
|
||||
playerTitleImage.sprite = resolvedTitle.titleSprite;
|
||||
playerTitleImage.enabled = hasTitleSprite;
|
||||
playerTitleImage.gameObject.SetActive(hasTitleSprite);
|
||||
if (!hasTitleSprite)
|
||||
{
|
||||
playerTitleImage.sprite = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (playerTitle != null)
|
||||
{
|
||||
playerTitle.text = !hasTitleSprite && hasTitleText ? resolvedTitle.titleText : string.Empty;
|
||||
playerTitle.gameObject.SetActive(!hasTitleSprite && hasTitleText);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureTitleImageReference()
|
||||
{
|
||||
if (playerTitleImage != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Transform[] children = GetComponentsInChildren<Transform>(true);
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
{
|
||||
Transform child = children[i];
|
||||
if (child != null && child.name == "玩家称号image")
|
||||
{
|
||||
playerTitleImage = child.GetComponent<Image>();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task WarmAvatarCacheAsync(string steamId, string avatarUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(steamId) && string.IsNullOrWhiteSpace(avatarUrl))
|
||||
|
||||
@@ -363,7 +363,7 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 63.484863}
|
||||
m_AnchoredPosition: {x: 0, y: 62.853333}
|
||||
m_SizeDelta: {x: 40, y: 40}
|
||||
m_Pivot: {x: 0.5, y: 1}
|
||||
--- !u!222 &225269693782171328
|
||||
@@ -439,7 +439,7 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 118, y: -2}
|
||||
m_AnchoredPosition: {x: 243, y: -2}
|
||||
m_SizeDelta: {x: 116, y: 20}
|
||||
m_Pivot: {x: 0, y: 0.5}
|
||||
--- !u!222 &2431985745475878602
|
||||
@@ -533,7 +533,7 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 128, y: -63.484863}
|
||||
m_AnchoredPosition: {x: 128, y: -62.853333}
|
||||
m_SizeDelta: {x: 256, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &5490276556566493605
|
||||
@@ -576,6 +576,42 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
m_HorizontalFit: 0
|
||||
m_VerticalFit: 2
|
||||
--- !u!1 &3193694844664355718
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 5657557821638488651}
|
||||
m_Layer: 0
|
||||
m_Name: GameObject
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &5657557821638488651
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3193694844664355718}
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 9105474240358439909}
|
||||
m_Father: {fileID: 4409505319181468130}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 178, y: 3}
|
||||
m_SizeDelta: {x: 120, y: 30}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!1 &3794995701616312333
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -616,7 +652,7 @@ RectTransform:
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 1, y: 1}
|
||||
m_AnchoredPosition: {x: 0, y: -143.99913}
|
||||
m_SizeDelta: {x: 0, y: 63.484863}
|
||||
m_SizeDelta: {x: 0, y: 62.853333}
|
||||
m_Pivot: {x: 0.5, y: 1}
|
||||
--- !u!114 &6728628998878729785
|
||||
MonoBehaviour:
|
||||
@@ -631,6 +667,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
playerProfile: {fileID: 5695022340294760842}
|
||||
playerTitleImage: {fileID: 9105474240358439911}
|
||||
playerTitle: {fileID: 5888169934928062198}
|
||||
playerName: {fileID: 7841361681403526646}
|
||||
messageText: {fileID: 5951147724285136445}
|
||||
@@ -691,7 +728,7 @@ MonoBehaviour:
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: 63.484863
|
||||
m_PreferredHeight: 62.853333
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 2
|
||||
@@ -743,6 +780,7 @@ RectTransform:
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 423933864671952839}
|
||||
- {fileID: 5657557821638488651}
|
||||
- {fileID: 8469056815856914825}
|
||||
m_Father: {fileID: 4696069065802349809}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
@@ -905,3 +943,99 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
maxPreferredWidth: 810
|
||||
--- !u!1 &9105474240358439908
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 9105474240358439909}
|
||||
- component: {fileID: 9105474240358439910}
|
||||
- component: {fileID: 9105474240358439911}
|
||||
- component: {fileID: 9105474240358439912}
|
||||
m_Layer: 0
|
||||
m_Name: playerTitleImage
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &9105474240358439909
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 9105474240358439908}
|
||||
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 5657557821638488651}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: -60, y: -7}
|
||||
m_SizeDelta: {x: 120, y: 30}
|
||||
m_Pivot: {x: 0, y: 0.5}
|
||||
--- !u!222 &9105474240358439910
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 9105474240358439908}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &9105474240358439911
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 9105474240358439908}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 0
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Sprite: {fileID: 0}
|
||||
m_Type: 0
|
||||
m_PreserveAspect: 1
|
||||
m_FillCenter: 1
|
||||
m_FillMethod: 4
|
||||
m_FillAmount: 1
|
||||
m_FillClockwise: 1
|
||||
m_FillOrigin: 0
|
||||
m_UseSpriteMesh: 0
|
||||
m_PixelsPerUnitMultiplier: 1
|
||||
--- !u!114 &9105474240358439912
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 9105474240358439908}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 64
|
||||
m_PreferredHeight: 20
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
|
||||
@@ -139,6 +139,42 @@ MonoBehaviour:
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &1187413930585374572
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 9034680032282737475}
|
||||
m_Layer: 0
|
||||
m_Name: GameObject
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &9034680032282737475
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1187413930585374572}
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 9105474240358439909}
|
||||
m_Father: {fileID: 4409505319181468130}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 120, y: 30}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!1 &1296804937323052155
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -174,7 +210,7 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 121, y: -2}
|
||||
m_AnchoredPosition: {x: 246, y: -2}
|
||||
m_SizeDelta: {x: 113, y: 20}
|
||||
m_Pivot: {x: 0, y: 0.5}
|
||||
--- !u!222 &6919442420098058668
|
||||
@@ -363,7 +399,7 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 62.501953}
|
||||
m_AnchoredPosition: {x: 0, y: 62.500916}
|
||||
m_SizeDelta: {x: 40, y: 40}
|
||||
m_Pivot: {x: 0.5, y: 1}
|
||||
--- !u!222 &225269693782171328
|
||||
@@ -533,7 +569,7 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 3840, y: -62.501953}
|
||||
m_AnchoredPosition: {x: 2560, y: -62.500916}
|
||||
m_SizeDelta: {x: 256, y: 0}
|
||||
m_Pivot: {x: 1, y: 0.5}
|
||||
--- !u!114 &5490276556566493605
|
||||
@@ -616,7 +652,7 @@ RectTransform:
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 1, y: 1}
|
||||
m_AnchoredPosition: {x: 0, y: -143.99913}
|
||||
m_SizeDelta: {x: 0, y: 62.501953}
|
||||
m_SizeDelta: {x: 0, y: 62.500916}
|
||||
m_Pivot: {x: 0.5, y: 1}
|
||||
--- !u!114 &6728628998878729785
|
||||
MonoBehaviour:
|
||||
@@ -631,6 +667,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
playerProfile: {fileID: 5695022340294760842}
|
||||
playerTitleImage: {fileID: 9105474240358439911}
|
||||
playerTitle: {fileID: 5888169934928062198}
|
||||
playerName: {fileID: 7841361681403526646}
|
||||
messageText: {fileID: 5951147724285136445}
|
||||
@@ -691,7 +728,7 @@ MonoBehaviour:
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: 62.501953
|
||||
m_PreferredHeight: 62.500916
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 2
|
||||
@@ -743,6 +780,7 @@ RectTransform:
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 8469056815856914825}
|
||||
- {fileID: 9034680032282737475}
|
||||
- {fileID: 423933864671952839}
|
||||
m_Father: {fileID: 4696069065802349809}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
@@ -905,3 +943,99 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
maxPreferredWidth: 810
|
||||
--- !u!1 &9105474240358439908
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 9105474240358439909}
|
||||
- component: {fileID: 9105474240358439910}
|
||||
- component: {fileID: 9105474240358439911}
|
||||
- component: {fileID: 9105474240358439912}
|
||||
m_Layer: 0
|
||||
m_Name: playerTitleImage
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &9105474240358439909
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 9105474240358439908}
|
||||
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 9034680032282737475}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: -60, y: -7}
|
||||
m_SizeDelta: {x: 120, y: 30}
|
||||
m_Pivot: {x: 0, y: 0.5}
|
||||
--- !u!222 &9105474240358439910
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 9105474240358439908}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &9105474240358439911
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 9105474240358439908}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 0
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Sprite: {fileID: 0}
|
||||
m_Type: 0
|
||||
m_PreserveAspect: 1
|
||||
m_FillCenter: 1
|
||||
m_FillMethod: 4
|
||||
m_FillAmount: 1
|
||||
m_FillClockwise: 1
|
||||
m_FillOrigin: 0
|
||||
m_UseSpriteMesh: 0
|
||||
m_PixelsPerUnitMultiplier: 1
|
||||
--- !u!114 &9105474240358439912
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 9105474240358439908}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 64
|
||||
m_PreferredHeight: 20
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
|
||||
@@ -814,7 +814,11 @@ public class roomDetails : MonoBehaviour
|
||||
if (controller != null)
|
||||
{
|
||||
string avatarUrl = GameServer.Client.NetworkManager.ResolveAvatarUrl(message.avatar_url, senderSteamId);
|
||||
controller.Bind(senderSteamId, avatarUrl, displayName, title, message.content, bubbleColor);
|
||||
string titleId = message != null && !string.IsNullOrWhiteSpace(message.player_title_id)
|
||||
? message.player_title_id
|
||||
: string.Empty;
|
||||
PlayerTitleResolver.ResolvedTitle resolvedTitle = PlayerTitleResolver.Resolve(titleId, title);
|
||||
controller.Bind(senderSteamId, avatarUrl, displayName, resolvedTitle.titleId, resolvedTitle.titleText, message.content, bubbleColor);
|
||||
}
|
||||
|
||||
UI_OrderedEntryAnimator.PlayFadeOnly(instance, Mathf.Max(0, messageParent.childCount - 1), 0.14f, 0.01f, true);
|
||||
|
||||
@@ -294,7 +294,7 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: -413.0012, y: -70.99978}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 100, y: 100}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &5886067044144465411
|
||||
|
||||
Reference in New Issue
Block a user