434 lines
21 KiB
C#
434 lines
21 KiB
C#
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;
|
||
_trackScalerAttached = 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;
|
||
}
|
||
}
|
||
|
||
// 轨道缩放器:挂到 effectEventController(已是单例集中点)或任意对象,运行时找 track1-5 拉长。
|
||
if (!_trackScalerAttached)
|
||
{
|
||
var eff = effectEventController.Instance;
|
||
if (eff != null)
|
||
{
|
||
if (eff.GetComponent<TrackScaler>() == null)
|
||
eff.gameObject.AddComponent<TrackScaler>();
|
||
_trackScalerAttached = true;
|
||
}
|
||
}
|
||
}
|
||
private bool _trackStabilizerAttached;
|
||
private bool _cameraTunerAttached;
|
||
private bool _trackScalerAttached;
|
||
|
||
// 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;
|
||
}
|
||
}
|