From 3f47f09f602d8ca9d8e9f19daca8414495e037cf Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 19 Apr 2026 17:51:02 +0800 Subject: [PATCH] refactor: update game orientation handling and viewport synchronization - Changed default game orientation from Landscape to Portrait in main.ts. - Enhanced Game class with new methods for viewport size management and physical layout detection. - Improved layout scheduling on orientation change and resize events. - Removed deprecated global page and adjusted scene initialization to use new layout configurations. Made-with: Cursor --- src/core/Game.ts | 232 +++++++++++++++++++++++++++++-------- src/main.ts | 2 +- src/stages/_global/page_0global.ts | 42 ------- src/stages/initSceneLayout.ts | 11 ++ src/stages/page_init.ts | 20 +++- 5 files changed, 213 insertions(+), 94 deletions(-) delete mode 100644 src/stages/_global/page_0global.ts create mode 100644 src/stages/initSceneLayout.ts diff --git a/src/core/Game.ts b/src/core/Game.ts index 241afe9..e399eed 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -23,6 +23,15 @@ class Game { height: 0, }; + /** 上次有效视口,避免横竖屏切换瞬间读到 0 或抖动尺寸 */ + private _lastGoodViewport = { w: 0, h: 0 }; + + private _layoutOuterRaf = 0; + private _layoutInnerRaf = 0; + /** 连续旋转时忽略过期的 setTimeout 回调 */ + private _orientationLayoutGen = 0; + private _rootResizeObserver: ResizeObserver | null = null; + private constructor() {} static getInstance(): Game { @@ -44,9 +53,136 @@ class Game { return this._ticker; } + /** + * 与渲染/缩放使用同一套 CSS 像素尺寸。 + * 微信等 WebView 第二次及以后旋转时,visualViewport 常滞后或与 layout 视口横竖不一致, + * 若仍优先 vv 会导致 resize 尺寸与 needRotation 错配(整屏背景、内容挤成一条)。 + */ + private getViewportCssSize(): { w: number; h: number } { + const el = document.documentElement; + const docW = el.clientWidth; + const docH = el.clientHeight; + + const vv = window.visualViewport; + if (vv && vv.width >= 1 && vv.height >= 1) { + const vvW = vv.width; + const vvH = vv.height; + if (docW >= 1 && docH >= 1) { + const docLand = docW > docH; + const vvLand = vvW > vvH; + if (docLand === vvLand) { + return { w: vvW, h: vvH }; + } + } else { + return { w: vvW, h: vvH }; + } + } + + if (docW >= 1 && docH >= 1) { + return { w: docW, h: docH }; + } + + const iw = window.innerWidth; + const ih = window.innerHeight; + if (iw >= 1 && ih >= 1) { + return { w: iw, h: ih }; + } + + if (this._lastGoodViewport.w >= 1 && this._lastGoodViewport.h >= 1) { + return { ...this._lastGoodViewport }; + } + + return { w: this.designWidth, h: Math.round((this.designWidth * 16) / 9) }; + } + + /** + * 小程序 WebView 常出现:物理已横屏,但 document 宽高仍是竖屏数值(pw < ph), + * 若不纠正则不会旋转根舞台 + resize 比例错误 → 只剩清屏底色。 + * + * 另一类滞后:document / getViewport 已横屏,innerWidth 仍竖屏且 matchMedia 未跟上, + * 若此时仍判为竖屏,syncViewport 会把正确的 (pw,ph) 强行换成竖屏 → 旋转/缩放与画布错配。 + * layout 视口(documentElement)与 inner 不一致时优先采信前者。 + */ + private physicalLayoutIsLandscape(): boolean { + const el = document.documentElement; + const docW = el.clientWidth; + const docH = el.clientHeight; + const iw = window.innerWidth; + const ih = window.innerHeight; + const mmLand = window.matchMedia("(orientation: landscape)").matches; + + if (docW >= 1 && docH >= 1 && iw >= 1 && ih >= 1) { + const docLand = docW > docH; + const innerLand = iw > ih; + if (docLand !== innerLand) { + if (docLand === mmLand) return docLand; + if (innerLand === mmLand) return innerLand; + return docLand; + } + } + + if (iw >= 1 && ih >= 1) { + if (iw > ih) return true; + /* 微信等:尺寸尚未交换,但 matchMedia 已切到横屏 */ + if (iw <= ih && mmLand) return true; + if (iw <= ih) return false; + } + try { + const t = screen.orientation?.type; + if (t?.includes("landscape")) return true; + if (t?.includes("portrait")) return false; + } catch { + /* 部分隐私模式 / 老内核 */ + } + return mmLand; + } + + /** 当「真实横竖」与当前 pw/ph 不一致时,交换宽高以与物理屏幕一致 */ + private syncViewportToPhysicalScreen(pw: number, ph: number): { w: number; h: number } { + if (pw < 1 || ph < 1) return { w: pw, h: ph }; + const land = this.physicalLayoutIsLandscape(); + if (land === (pw > ph)) { + return { w: pw, h: ph }; + } + /* 仅 max/min 只能把「竖屏尺寸」拉成横屏;物理竖屏但 layout 仍横屏时要用 min/max */ + if (land) { + return { w: Math.max(pw, ph), h: Math.min(pw, ph) }; + } + return { w: Math.min(pw, ph), h: Math.max(pw, ph) }; + } + + /** + * 先立刻 updateView,保证同一次 resize 里后注册的 scene 监听器能读到最新 getInfo; + * 再双 rAF 补一帧,应对 iOS / 部分 WebView 横竖屏后视口尺寸晚一拍才稳定的情况。 + */ + private readonly scheduleLayout = (): void => { + this.updateView(); + + cancelAnimationFrame(this._layoutOuterRaf); + cancelAnimationFrame(this._layoutInnerRaf); + this._layoutOuterRaf = requestAnimationFrame(() => { + this._layoutInnerRaf = requestAnimationFrame(() => { + this._layoutOuterRaf = 0; + this._layoutInnerRaf = 0; + this.updateView(); + }); + }); + }; + + /** WebView 常在 orientationchange 后晚多拍才稳定 layout,用代际号避免过期回调 */ + private readonly onOrientationChange = (): void => { + this.scheduleLayout(); + const gen = ++this._orientationLayoutGen; + for (const ms of [160, 360, 600]) { + window.setTimeout(() => { + if (gen !== this._orientationLayoutGen) return; + this.scheduleLayout(); + }, ms); + } + }; + async init(): Promise { - const screenWidth = document.documentElement.clientWidth; - const screenHeight = document.documentElement.clientHeight; + const { w: screenWidth, h: screenHeight } = this.getViewportCssSize(); this._stage = new Container(); this._stage.label = "root"; @@ -64,9 +200,14 @@ class Game { this.renderer.resize(screenWidth, screenHeight); document.body.appendChild(this.renderer.canvas); - window.addEventListener("resize", () => { - this.updateView(); - }); + window.addEventListener("resize", this.scheduleLayout); + window.addEventListener("orientationchange", this.onOrientationChange); + window.visualViewport?.addEventListener("resize", this.scheduleLayout); + + if (typeof ResizeObserver !== "undefined") { + this._rootResizeObserver = new ResizeObserver(() => this.scheduleLayout()); + this._rootResizeObserver.observe(document.documentElement); + } this._ticker = Ticker.shared; this._ticker.autoStart = true; @@ -89,54 +230,51 @@ class Game { setOrientation(orientation: Orientation): void { this.orientation = orientation; - this.updateView(); - } - - private detectCurrentOrientation(): Orientation { - const isLandscape = window.innerWidth > window.innerHeight; - return isLandscape ? Orientation.Landscape : Orientation.Portrait; + this.scheduleLayout(); } updateView(): void { - const clientWidth = document.documentElement.clientWidth; - const clientHeight = document.documentElement.clientHeight; + let { w: pw, h: ph } = this.getViewportCssSize(); + ({ w: pw, h: ph } = this.syncViewportToPhysicalScreen(pw, ph)); - this.renderer.resize(clientWidth, clientHeight); + if (pw < 1 || ph < 1) { + return; + } - const currentOrientation = this.detectCurrentOrientation(); + this._lastGoodViewport = { w: pw, h: ph }; - if (this.orientation === Orientation.Landscape) { - if (currentOrientation === Orientation.Landscape) { - this._stage.rotation = 0; - this._stage.position.set(0, 0); - const scaleRatio = clientWidth / this.designWidth; - this._stage.scale.set(scaleRatio, scaleRatio); - this.info.width = clientWidth / scaleRatio; - this.info.height = clientHeight / scaleRatio; - } else { - this._stage.rotation = Math.PI / 2; - this._stage.position.set(0, 0); - const scaleRatio = clientHeight / this.designWidth; - this._stage.scale.set(scaleRatio, scaleRatio); - this.info.width = clientHeight / scaleRatio; - this.info.height = clientWidth / scaleRatio; - } + this.renderer.resize(pw, ph); + + this._stage.pivot.set(0, 0); + + const physicalLandscape = pw > ph; + const lockedPortrait = this.orientation === Orientation.Portrait; + /** 物理方向与「锁定的游戏方向」不一致时需要旋转根舞台 */ + const needRotation = physicalLandscape === lockedPortrait; + + if (!needRotation) { + this._stage.rotation = 0; + this._stage.position.set(0, 0); + const scaleRatio = pw / this.designWidth; + this._stage.scale.set(scaleRatio, scaleRatio); + this.info.width = pw / scaleRatio; + this.info.height = ph / scaleRatio; + } else if (lockedPortrait) { + /* 锁定竖屏 + 物理横屏:+π/2 且 x=pw,逻辑原点对齐画布内;−π/2 且 y=pw 会把原点放到视口下方 */ + this._stage.rotation = Math.PI / 2; + this._stage.position.set(pw, 0); + const scaleRatio = ph / this.designWidth; + this._stage.scale.set(scaleRatio, scaleRatio); + this.info.width = ph / scaleRatio; + this.info.height = pw / scaleRatio; } else { - if (currentOrientation === Orientation.Portrait) { - this._stage.rotation = 0; - this._stage.position.set(0, 0); - const scaleRatio = clientWidth / this.designWidth; - this._stage.scale.set(scaleRatio, scaleRatio); - this.info.width = clientWidth / scaleRatio; - this.info.height = clientHeight / scaleRatio; - } else { - this._stage.rotation = -Math.PI / 2; - this._stage.position.set(0, clientWidth); - const scaleRatio = clientHeight / this.designWidth; - this._stage.scale.set(scaleRatio, scaleRatio); - this.info.width = clientHeight / scaleRatio; - this.info.height = clientWidth / scaleRatio; - } + /* 锁定横屏玩法:物理竖持时 */ + this._stage.rotation = Math.PI / 2; + this._stage.position.set(0, 0); + const scaleRatio = ph / this.designWidth; + this._stage.scale.set(scaleRatio, scaleRatio); + this.info.width = ph / scaleRatio; + this.info.height = pw / scaleRatio; } this.render(); @@ -147,4 +285,4 @@ class Game { } } -export default Game; \ No newline at end of file +export default Game; diff --git a/src/main.ts b/src/main.ts index f11a287..fb5f2d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,7 @@ import { initApp, game, sceneManager } from "./init"; void (async () => { try { await initApp(); - game.setOrientation(Orientation.Landscape); + game.setOrientation(Orientation.Portrait); } catch (e) { console.error("initApp failed", e); } diff --git a/src/stages/_global/page_0global.ts b/src/stages/_global/page_0global.ts deleted file mode 100644 index 14fb1ab..0000000 --- a/src/stages/_global/page_0global.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { SceneType } from "@/enums/SceneType"; -import { BaseScene } from "@/scene/BaseScene"; -import { Assets, Container } from "pixi.js"; - -export default class Global extends BaseScene { - stage: Container = new Container(); - - constructor() { - super("0global", SceneType.Resident); - } - - async loadBundle(): Promise { - Assets.add({ alias: "btn-bga", src: "/assets/images/button_square_depth_gloss.png" }); - Assets.add({ alias: "btn-bg-press", src: "/assets/images/button_square_depth_gradient.png" }); - await Assets.load("btn-bga"); - await Assets.load("btn-bg-press"); - } - - async layout(): Promise { - this.stage.sortableChildren = true; - this.stage.zIndex = 9999; - // const btnBg = Assets.get("btn-bga"); - // const btnBgPress = Assets.get("btn-bg-press"); - // this.btn = new Button({ - // text: "全局", - // bg: btnBg, - // pressBg: btnBgPress, - // position: () => position.get("center", "center", { y: 100, x: 0 }), - // onClick: () => { - // console.log("Button clicked"); - // }, - // }); - // this.stage.addChild(this.btn.getView()); - } - - onLoad(): void { - console.log("global page loaded"); - } - - onUnLoad(): void { - } -} diff --git a/src/stages/initSceneLayout.ts b/src/stages/initSceneLayout.ts new file mode 100644 index 0000000..35cdf6f --- /dev/null +++ b/src/stages/initSceneLayout.ts @@ -0,0 +1,11 @@ +/** + * Init 首屏在逻辑画布(Game.getInfo,与 designWidth 缩放一致)下的布局参数。 + * 分区:上区标题 → 中区角色 → 底区主按钮;改间距只改此处。 + */ +export const initSceneLayout = { + titleYRatio: 0.12, + subtitleYRatio: 0.2, + heroYRatio: 0.46, + /** 主按钮相对底边的上移(逻辑像素,与 position.get 的 options.y 一致) */ + startCtaOffsetY: -110, +} as const; diff --git a/src/stages/page_init.ts b/src/stages/page_init.ts index 236e205..65b023e 100644 --- a/src/stages/page_init.ts +++ b/src/stages/page_init.ts @@ -3,6 +3,7 @@ import { assetManager } from "@/core/AssetManager"; import Game from "@/core/Game"; import { BaseScene } from "@/scene/BaseScene"; import { SceneType } from "@/enums/SceneType"; +import { initSceneLayout } from "@/stages/initSceneLayout"; import position from "@/utils/Position"; import { AnimatedSprite, @@ -142,7 +143,11 @@ export default class InitScene extends BaseScene { pressBg: this.assets["btn-bg-press"], fontSize: 28, padding: { x: 72, y: 36 }, - position: () => position.get("center", "bottom", { y: -110, x: 0 }), + position: () => + position.get("center", "bottom", { + y: initSceneLayout.startCtaOffsetY, + x: 0, + }), onClick: () => { this.changeScene("welcome", { isHolderLast: false }); }, @@ -168,7 +173,9 @@ export default class InitScene extends BaseScene { private placeHeroElements(): void { const { width: W, height: H } = this.game.getInfo(); + const L = initSceneLayout; + /* 全屏层:背景 cover */ if (this.bg) { const tw = this.bg.texture.width; const th = this.bg.texture.height; @@ -178,15 +185,20 @@ export default class InitScene extends BaseScene { this.bg.position.set(W / 2, H / 2); } + /* 上区:标题 / 副标题(锚点中心,与 position 语义一致) */ if (this.titleText) { - this.titleText.position.set(W / 2, H * 0.12); + this.titleText.position.copyFrom(position.get("center", H * L.titleYRatio)); } if (this.subtitleText) { - this.subtitleText.position.set(W / 2, H * 0.2); + this.subtitleText.position.copyFrom( + position.get("center", H * L.subtitleYRatio) + ); } + + /* 中区:主视觉 */ if (this.pixie) { this.pixie.anchor.set(0.5); - this.pixie.position.set(W / 2, H * 0.46); + this.pixie.position.copyFrom(position.get("center", H * L.heroYRatio)); } } }