Files
bansonic_beta_main/Assets/scripts/UI/UIResolutionAutoFitter.cs
2026-07-24 04:43:13 +08:00

434 lines
21 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 同继承 GraphicUIBackgroundCover 只改 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;
}
}