好多新内容

This commit is contained in:
FloatGaming
2026-07-22 05:35:53 +08:00
parent b5d1cdcbc5
commit 15649d02f8
128 changed files with 5956 additions and 673 deletions
+419
View File
@@ -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 同继承 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;
}
}
}
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;
}
}