Browse Source

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
master
npmrun 3 weeks ago
parent
commit
3f47f09f60
  1. 214
      src/core/Game.ts
  2. 2
      src/main.ts
  3. 42
      src/stages/_global/page_0global.ts
  4. 11
      src/stages/initSceneLayout.ts
  5. 20
      src/stages/page_init.ts

214
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<void> {
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();
this.scheduleLayout();
}
private detectCurrentOrientation(): Orientation {
const isLandscape = window.innerWidth > window.innerHeight;
return isLandscape ? Orientation.Landscape : Orientation.Portrait;
updateView(): void {
let { w: pw, h: ph } = this.getViewportCssSize();
({ w: pw, h: ph } = this.syncViewportToPhysicalScreen(pw, ph));
if (pw < 1 || ph < 1) {
return;
}
updateView(): void {
const clientWidth = document.documentElement.clientWidth;
const clientHeight = document.documentElement.clientHeight;
this._lastGoodViewport = { w: pw, h: ph };
this.renderer.resize(clientWidth, clientHeight);
this.renderer.resize(pw, ph);
const currentOrientation = this.detectCurrentOrientation();
this._stage.pivot.set(0, 0);
if (this.orientation === Orientation.Landscape) {
if (currentOrientation === Orientation.Landscape) {
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 = clientWidth / this.designWidth;
const scaleRatio = pw / this.designWidth;
this._stage.scale.set(scaleRatio, scaleRatio);
this.info.width = clientWidth / scaleRatio;
this.info.height = clientHeight / scaleRatio;
} else {
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(0, 0);
const scaleRatio = clientHeight / this.designWidth;
this._stage.position.set(pw, 0);
const scaleRatio = ph / this.designWidth;
this._stage.scale.set(scaleRatio, scaleRatio);
this.info.width = clientHeight / scaleRatio;
this.info.height = clientWidth / scaleRatio;
}
this.info.width = ph / scaleRatio;
this.info.height = pw / scaleRatio;
} else {
if (currentOrientation === Orientation.Portrait) {
this._stage.rotation = 0;
/* 锁定横屏玩法:物理竖持时 */
this._stage.rotation = Math.PI / 2;
this._stage.position.set(0, 0);
const scaleRatio = clientWidth / this.designWidth;
const scaleRatio = ph / 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.info.width = ph / scaleRatio;
this.info.height = pw / scaleRatio;
}
this.render();

2
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);
}

42
src/stages/_global/page_0global.ts

@ -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<void> {
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<void> {
this.stage.sortableChildren = true;
this.stage.zIndex = 9999;
// const btnBg = Assets.get<Texture>("btn-bga");
// const btnBgPress = Assets.get<Texture>("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 {
}
}

11
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;

20
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));
}
}
}

Loading…
Cancel
Save