using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.UI; /// /// 全局分辨率自适应器:场景加载时自动扫描全屏背景类 Image(居中锚定 + 尺寸接近设计分辨率), /// 为其附加 UIBackgroundCover,使背景在任意宽高比(含超宽 32:9)下等比放大铺满、不留黑边、不失真。 /// /// 设计原则(对应三要求): /// 1) 零业务逻辑改动:不改脚本行为、不改对象引用,只在表现层调整背景 sizeDelta。 /// 2) 不改锚点/pivot/位置:位移动画与居中布局完全不受影响;16:9 屏计算结果等于原设计尺寸。 /// 3) 背景总是铺满屏幕。 /// /// 仅针对“全屏背景/遮罩”生效,普通按钮/面板/菜单等居中 UI 保持原样(它们本就该居中,不该拉伸)。 /// 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(); 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 _setupDoneCanvases = new HashSet(); // 靠边判定阈值:元素中心距设计画布中心超过该值(参考像素)才自动贴边。 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() != null) return; // 跳过:父级已有 UIEdgeFollow(子元素应相对父级布局,不独立贴边)。 Transform parent = rt.parent; while (parent != null) { if (parent.GetComponent() != null) return; if (parent.GetComponent() != 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(); } // 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(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 判断是否已处理,成本很低。 // 跳过自管理布局/动画的特殊 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(); if (scaler != null && scaler.GetComponent() == null) { scaler.gameObject.AddComponent(); } // 结算是"整块按 1920 写死"的居中卡片,不做 HUD 边缘跟随(否则打散版式)。 bool useEdgeFollowLayout = _gameplayActive && !IsSettlementCanvas(canvas); // 按名对已知独立小部件/HUD 容器附加 UIEdgeFollow(白名单,避免打散成组布局)。 RectTransform[] allRects = canvas.GetComponentsInChildren(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(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() == null) { // 统一用 Cover:等比放大到覆盖根 Canvas,过覆盖=无缝且永不为 0。 // 之前的 Stretch(填满父级)对嵌套在小尺寸容器下的全屏图(如 quhuiImage 的父级仅100x100) // 会把 sizeDelta 拉成 0 导致图片消失,故弃用。 img.gameObject.AddComponent(); } } // RawImage(如 main_main 的视频背景 videoDisplay,用 RenderTexture 显示视频): // RawImage 与 Image 同继承 Graphic,UIBackgroundCover 只改 sizeDelta 对二者一致; // 其 uvRect=(0,0,1,1) 铺满纹理、RT 为 16:9,等比 cover 不会拉伸变形。 // GetComponentsInChildren 抓不到 RawImage,故单独扫一遍。 RawImage[] rawImages = canvas.GetComponentsInChildren(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() == null) { raw.gameObject.AddComponent(); } } } // 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() == null) cam.gameObject.AddComponent(); _trackStabilizerAttached = true; } } // 相机分辨率微调器:挂到 effectEventController 单例对象上(它负责相机 pivot,微调经其安全施加)。 // 独立于上面的标志重试——effectEventController 可能稍晚才初始化。 if (!_cameraTunerAttached) { var eff = effectEventController.Instance; if (eff != null) { if (eff.GetComponent() == null) eff.gameObject.AddComponent(); _cameraTunerAttached = true; } } // 轨道缩放器:挂到 effectEventController(已是单例集中点)或任意对象,运行时找 track1-5 拉长。 if (!_trackScalerAttached) { var eff = effectEventController.Instance; if (eff != null) { if (eff.GetComponent() == null) eff.gameObject.AddComponent(); _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() == null) target.gameObject.AddComponent(); _enemyFollowAttached = true; } private bool _enemyFollowAttached; // 扫描并处理世界空间 Sprite 背景(覆盖 gameplay 相机渲染的底图)。 private static void ScanSpriteBackgrounds() { SpriteRenderer[] sprites = FindObjectsByType(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() != null) continue; sr.gameObject.AddComponent(); } } // 已知“独立、不属于任何布局组”的边角小部件/HUD 容器精确名单。 // 仅这些会被贴边跟随;功能界面的成组内容一律不动,避免打散版式。 private static readonly HashSet EdgeFollowWhitelist = new HashSet { // 主界面:世界聊天入口按钮(独立浮标,中心锚定+右偏移)。 "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 ParentChildEdgeFollow = new Dictionary { { "steamIcon", "Image" }, }; private static void TryAttachEdgeFollow(RectTransform rt) { if (rt == null) return; if (rt.GetComponent() != 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(); } // 判定 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; } /// /// 判定是否为全屏背景:居中锚定(非拉伸)且尺寸接近设计分辨率(或其 2 倍)。 /// 排除已经是全拉伸(0-1)的(那类天然铺满,无需处理)与尺寸偏小的普通 UI。 /// 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; } }