@ -23,6 +23,15 @@ class Game {
height : 0 ,
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 ( ) { }
private constructor ( ) { }
static getInstance ( ) : Game {
static getInstance ( ) : Game {
@ -44,9 +53,136 @@ class Game {
return this . _ticker ;
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 > {
async init ( ) : Promise < void > {
const screenWidth = document . documentElement . clientWidth ;
const { w : screenWidth , h : screenHeight } = this . getViewportCssSize ( ) ;
const screenHeight = document . documentElement . clientHeight ;
this . _stage = new Container ( ) ;
this . _stage = new Container ( ) ;
this . _stage . label = "root" ;
this . _stage . label = "root" ;
@ -64,9 +200,14 @@ class Game {
this . renderer . resize ( screenWidth , screenHeight ) ;
this . renderer . resize ( screenWidth , screenHeight ) ;
document . body . appendChild ( this . renderer . canvas ) ;
document . body . appendChild ( this . renderer . canvas ) ;
window . addEventListener ( "resize" , ( ) = > {
window . addEventListener ( "resize" , this . scheduleLayout ) ;
this . updateView ( ) ;
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 = Ticker . shared ;
this . _ticker . autoStart = true ;
this . _ticker . autoStart = true ;
@ -89,54 +230,51 @@ class Game {
setOrientation ( orientation : Orientation ) : void {
setOrientation ( orientation : Orientation ) : void {
this . orientation = orientation ;
this . orientation = orientation ;
this . updateView ( ) ;
this . scheduleLayout ( ) ;
}
private detectCurrentOrientation ( ) : Orientation {
const isLandscape = window . innerWidth > window . innerHeight ;
return isLandscape ? Orientation.Landscape : Orientation.Portrait ;
}
}
updateView ( ) : void {
updateView ( ) : void {
const clientWidth = document . documentElement . clientWidth ;
let { w : pw , h : ph } = this . getViewportCssSize ( ) ;
const clientHeight = document . documentElement . clientHeight ;
( { 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 ) {
this . renderer . resize ( pw , ph ) ;
if ( currentOrientation === Orientation . Landscape ) {
this . _stage . rotation = 0 ;
this . _stage . pivot . set ( 0 , 0 ) ;
this . _stage . position . set ( 0 , 0 ) ;
const scaleRatio = clientWidth / this . designWidth ;
const physicalLandscape = pw > ph ;
this . _stage . scale . set ( scaleRatio , scaleRatio ) ;
const lockedPortrait = this . orientation === Orientation . Portrait ;
this . info . width = clientWidth / scaleRatio ;
/** 物理方向与「锁定的游戏方向」不一致时需要旋转根舞台 */
this . info . height = clientHeight / scaleRatio ;
const needRotation = physicalLandscape === lockedPortrait ;
} else {
this . _stage . rotation = Math . PI / 2 ;
if ( ! needRotation ) {
this . _stage . position . set ( 0 , 0 ) ;
this . _stage . rotation = 0 ;
const scaleRatio = clientHeight / this . designWidth ;
this . _stage . position . set ( 0 , 0 ) ;
this . _stage . scale . set ( scaleRatio , scaleRatio ) ;
const scaleRatio = pw / this . designWidth ;
this . info . width = clientHeight / scaleRatio ;
this . _stage . scale . set ( scaleRatio , scaleRatio ) ;
this . info . height = clientWidth / 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 {
} else {
if ( currentOrientation === Orientation . Portrait ) {
/* 锁定横屏玩法:物理竖持时 */
this . _stage . rotation = 0 ;
this . _stage . rotation = Math . PI / 2 ;
this . _stage . position . set ( 0 , 0 ) ;
this . _stage . position . set ( 0 , 0 ) ;
const scaleRatio = clientWidth / this . designWidth ;
const scaleRatio = ph / this . designWidth ;
this . _stage . scale . set ( scaleRatio , scaleRatio ) ;
this . _stage . scale . set ( scaleRatio , scaleRatio ) ;
this . info . width = clientWidth / scaleRatio ;
this . info . width = ph / scaleRatio ;
this . info . height = clientHeight / scaleRatio ;
this . info . height = pw / 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 . render ( ) ;
this . render ( ) ;