@ -9,20 +9,44 @@ import { Container, Graphics, Text, TextStyle, type Ticker } from "pixi.js";
const NODE_RADIUS = 24 ;
const NODE_GAP = 76 ;
const END_ACTION_COOLDOWN_MS = 400 ;
const PAD_X = 20 ;
const PAD_Y = 12 ;
const PAD_X = 22 ;
const PAD_Y = 14 ;
/** 顶栏 + 开局控件占用高度(用于把地图放到下方安全区) */
const TOP_CHROME_H = 200 ;
const SEED_INPUT_WIDTH = 220 ;
const TOP_CHROME_H = 208 ;
/** 竖屏下顶栏最多占屏高的比例,避免棋盘被挤得太小 */
const CHROME_HEIGHT_FRAC = 0.23 ;
const SEED_INPUT_MAX_W = 268 ;
const SEED_INPUT_H = 42 ;
/** 终局弹层卡片(与 relayout 居中计算一致) */
const RESULT_CARD_W = 304 ;
const RESULT_CARD_H = 196 ;
const DIFF_BTN_W = 82 ;
const DIFF_BTN_H = 40 ;
const DIFF_BTN_GAP = 94 ;
const START_BTN_W = 108 ;
const START_BTN_H = 44 ;
const TOKEN_LABEL_STYLE = new TextStyle ( {
fontSize : 15 ,
fontSize : 16 ,
fontWeight : "700" ,
fill : 0xffffff ,
fontFamily : "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif" ,
stroke : { color : 0x0f172a , width : 3 } ,
stroke : { color : 0x020617 , width : 4 } ,
} ) ;
function statusLabelZh ( status : string ) : string {
switch ( status ) {
case "playing" :
return "进行中" ;
case "win" :
return "胜利" ;
case "lose" :
return "终局" ;
default :
return status ;
}
}
type ChaseModelLike = Pick <
ChaseGameModel ,
"getState" | "moveThief" | "newRound" | "retryRound"
@ -42,16 +66,21 @@ type ChaseSceneOptions = {
export default class ChaseScene extends BaseScene {
stage = new Container ( ) ;
private readonly bgLayer = new Graphics ( ) ;
private readonly chromePanel = new Graphics ( ) ;
private model? : ChaseModelLike ;
private readonly modelFactory ? : ( ) = > ChaseModelLike ;
private hudText? : Text ;
private hudLegendText? : Text ;
private winCountText? : Text ;
private seedText? : Text ;
private setupText? : Text ;
private setupLayer = new Container ( ) ;
private graphLayer = new Container ( ) ;
private readonly resultDimmer = new Graphics ( ) ;
private resultOverlay = new Container ( ) ;
private resultText = new Text ( { text : "" } ) ;
private resultSeedText = new Text ( { text : "" } ) ;
private difficultyButtons : Record < Difficulty , Graphics > = {
easy : new Graphics ( ) ,
normal : new Graphics ( ) ,
@ -91,54 +120,97 @@ export default class ChaseScene extends BaseScene {
this . stage . sortableChildren = true ;
this . stage . eventMode = "passive" ;
this . hudText = new Text ( {
text : "Chase: loading..." ,
style : new TextStyle ( {
fontSize : 16 ,
fill : 0xe2e8f0 ,
fontFamily : "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif" ,
} ) ,
} ) ;
this . stage . addChild ( this . hudText ) ;
this . bgLayer . eventMode = "none" ;
this . chromePanel . eventMode = "none" ;
this . stage . addChild ( this . bgLayer ) ;
this . stage . addChild ( this . chromePanel ) ;
this . winCountText = new Text ( {
text : "" ,
style : new TextStyle ( {
fontSize : 17 ,
fill : 0xffffff ,
fontSize : 22 ,
fontWeight : "700" ,
fill : 0xf8fafc ,
letterSpacing : 0.5 ,
fontFamily : "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif" ,
dropShadow : {
alpha : 0.45 ,
angle : Math.PI / 2 ,
blur : 6 ,
color : 0x000000 ,
distance : 1 ,
} ,
} ) ,
} ) ;
this . winCountText . zIndex = 10 ;
this . stage . addChild ( this . winCountText ) ;
this . seedText = new Text ( {
text : "" ,
style : new TextStyle ( {
fontSize : 17 ,
fill : 0xffffff ,
fontFamily : "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif" ,
fontSize : 13 ,
fill : 0xc7d2fe ,
letterSpacing : 0.3 ,
fontFamily : "ui-monospace, 'Cascadia Code', 'Microsoft YaHei', monospace" ,
} ) ,
} ) ;
this . seedText . anchor . set ( 1 , 0 ) ;
this . seedText . zIndex = 10 ;
this . stage . addChild ( this . seedText ) ;
this . hudText = new Text ( {
text : "Chase: loading..." ,
style : new TextStyle ( {
fontSize : 15 ,
lineHeight : 20 ,
fontWeight : "500" ,
fill : 0xe2e8f0 ,
fontFamily : "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif" ,
} ) ,
} ) ;
this . hudText . zIndex = 10 ;
this . stage . addChild ( this . hudText ) ;
this . hudLegendText = new Text ( {
text : "" ,
style : new TextStyle ( {
fontSize : 12 ,
lineHeight : 17 ,
fill : 0x64748b ,
fontFamily : "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif" ,
} ) ,
} ) ;
this . hudLegendText . zIndex = 10 ;
this . stage . addChild ( this . hudLegendText ) ;
this . setupText = new Text ( {
text : "" ,
style : new TextStyle ( {
fontSize : 14 ,
fill : 0x93c5fd ,
fontSize : 12 ,
fill : 0x64748b ,
fontFamily : "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif" ,
} ) ,
} ) ;
this . setupText . zIndex = 10 ;
this . stage . addChild ( this . setupText ) ;
this . setupLayer . eventMode = "passive" ;
this . setupLayer . zIndex = 11 ;
this . stage . addChild ( this . setupLayer ) ;
this . buildSetupControls ( ) ;
this . graphLayer . eventMode = "passive" ;
this . graphLayer . zIndex = 20 ;
this . stage . addChild ( this . graphLayer ) ;
this . resultDimmer . eventMode = "static" ;
this . resultDimmer . cursor = "default" ;
this . resultDimmer . visible = false ;
this . resultDimmer . zIndex = 25 ;
this . stage . addChild ( this . resultDimmer ) ;
this . buildResultOverlay ( ) ;
this . resultOverlay . zIndex = 30 ;
this . stage . addChild ( this . resultOverlay ) ;
this . relayoutChrome ( ) ;
@ -188,16 +260,28 @@ export default class ChaseScene extends BaseScene {
}
const state = this . model . getState ( ) ;
this . winCountText . text = ` 成功次数: ${ state . winCount } ` ;
this . seedText . text = ` 当前 seed: ${ state . snapshot . seed } ` ;
const roleHint =
state . snapshot . status === "playing"
? "\n偷=你 · 兵=官兵 · 出=出口 · 点击黄圈可走格"
this . winCountText . text = ` 成功 · ${ state . winCount } ` ;
this . seedText . text = ` Seed ${ state . snapshot . seed } ` ;
const statusZh = statusLabelZh ( state . snapshot . status ) ;
this . hudText . text = ` 第 ${ state . turn } 回合 · ${ statusZh } · 偷 ${ state . snapshot . thiefNodeId } / 兵 ${ state . snapshot . guardNodeId } ` ;
if ( this . hudLegendText ) {
const playing = state . snapshot . status === "playing" ;
this . hudLegendText . visible = playing ;
this . hudLegendText . text = playing
? "偷=你 · 兵=官兵 · 出=出口 · 黄圈为可走格"
: "" ;
this . hudText . text = ` 回合 ${ state . turn } | ${ state . snapshot . status } | 偷@ ${ state . snapshot . thiefNodeId } 兵@ ${ state . snapshot . guardNodeId } ${ roleHint } ` ;
this . setupText . text = ` difficulty= ${ this . selectedDifficulty } | seed= ${ this . seedInput . trim ( ) || "(auto)" } | locked= ${ this . difficultyLocked ? "yes" : "no" } ` ;
}
this . setupText . text = ` 难度 ${ this . selectedDifficulty } · ${ this . difficultyLocked ? "对局中" : "开局前可调整 " } ` ;
this . drawDifficultyButtons ( ) ;
this . renderResultOverlay ( state . snapshot . status , state . loseReason ) ;
this . updateSeedInputPlayVisibility ( state . snapshot . status === "playing" ) ;
if ( this . resultSeedText ) {
const ended = state . snapshot . status !== "playing" ;
this . resultSeedText . visible = ended ;
if ( ended ) {
this . resultSeedText . text = ` 本局种子 ${ state . snapshot . seed } ` ;
}
}
const oldChildren = this . graphLayer . removeChildren ( ) ;
for ( const child of oldChildren ) {
@ -206,6 +290,20 @@ export default class ChaseScene extends BaseScene {
const nodes = Object . values ( state . snapshot . graph . nodes ) ;
const { offsetX , offsetY } = this . computeGraphCenterOffset ( nodes ) ;
const bounds = this . computeGraphPixelBounds ( nodes , offsetX , offsetY ) ;
const boardMat = new Graphics ( ) ;
const matPad = 56 ;
boardMat . roundRect (
bounds . minPx - matPad ,
bounds . minPy - matPad ,
bounds . maxPx - bounds . minPx + matPad * 2 ,
bounds . maxPy - bounds . minPy + matPad * 2 ,
36 ,
) ;
boardMat . fill ( { color : 0x030712 , alpha : 0.78 } ) ;
boardMat . stroke ( { width : 1.5 , color : 0x5b6578 , alpha : 0.5 } ) ;
this . graphLayer . addChild ( boardMat ) ;
const thiefNode = state . snapshot . graph . nodes [ state . snapshot . thiefNodeId ] ;
const availableMoves = thiefNode
@ -228,17 +326,22 @@ export default class ChaseScene extends BaseScene {
edgeLayer . moveTo ( fromX , fromY ) ;
edgeLayer . lineTo ( toX , toY ) ;
}
edgeLayer . stroke ( { width : 3.5 , color : 0xf1f5f9 , alpha : 0.55 } ) ;
edgeLayer . stroke ( { width : 2 , color : 0x818cf8 , alpha : 0.32 } ) ;
this . graphLayer . addChild ( edgeLayer ) ;
for ( const nodeId of availableMoves ) {
const node = state . snapshot . graph . nodes [ nodeId ] ;
if ( ! node ) {
continue ;
}
const glow = new Graphics ( ) ;
glow . circle ( 0 , 0 , NODE_RADIUS + 18 ) ;
glow . fill ( { color : 0xfbbf24 , alpha : 0.12 } ) ;
glow . position . set ( node . q * NODE_GAP + offsetX , node . r * NODE_GAP + offsetY ) ;
this . graphLayer . addChild ( glow ) ;
const highlight = new Graphics ( ) ;
highlight . label = "move-highlight" ;
highlight . circle ( 0 , 0 , NODE_RADIUS + 12 ) ;
highlight . stroke ( { width : 4 , color : 0xfde047 , alpha : 1 } ) ;
highlight . circle ( 0 , 0 , NODE_RADIUS + 11 ) ;
highlight . stroke ( { width : 3.5 , color : 0xfbbf24 , alpha : 1 } ) ;
highlight . position . set ( node . q * NODE_GAP + offsetX , node . r * NODE_GAP + offsetY ) ;
this . graphLayer . addChild ( highlight ) ;
}
@ -252,27 +355,51 @@ export default class ChaseScene extends BaseScene {
cell . cursor = "pointer" ;
cell . on ( "pointerdown" , ( ) = > this . handleNodeClick ( node . id ) ) ;
const body = new Graphics ( ) ;
body . circle ( 0 , 0 , NODE_RADIUS ) ;
const isThief = node . id === state . snapshot . thiefNodeId ;
const isGuard = node . id === state . snapshot . guardNodeId ;
const isExit = node . id === state . snapshot . exitNodeId ;
const isMove = availableMoveSet . has ( node . id ) ;
if ( isThief || isGuard ) {
const halo = new Graphics ( ) ;
halo . circle ( 0 , 0 , NODE_RADIUS + 9 ) ;
halo . fill ( {
color : isThief ? 0x22c55e : 0xf87171 ,
alpha : 0.22 ,
} ) ;
cell . addChild ( halo ) ;
}
const body = new Graphics ( ) ;
body . circle ( 0 , 0 , NODE_RADIUS ) ;
const color = isThief
? 0x16a34a
? 0x15803d
: isGuard
? 0xdc2626
? 0xb91c1c
: isExit
? 0x2563eb
? 0x1d4ed8
: isMove
? 0x0ea5e9
? 0x0e7490
: 0x475569 ;
const dim =
state . snapshot . status === "playing" && ! isThief && ! isGuard && ! isExit && ! isMove ;
body . fill ( { color , alpha : dim ? 0.42 : 0.98 } ) ;
body . stroke ( { width : isThief || isGuard ? 3 : 2 , color : 0xf8fafc , alpha : dim ? 0.35 : 0.95 } ) ;
body . fill ( { color , alpha : dim ? 0.38 : 1 } ) ;
body . stroke ( {
width : isThief || isGuard || isExit ? 3 : 2 ,
color : isExit ? 0x93c5fd : 0xf1f5f9 ,
alpha : dim ? 0.3 : 0.92 ,
} ) ;
cell . addChild ( body ) ;
const rim = new Graphics ( ) ;
rim . circle ( 0 , 0 , NODE_RADIUS + 2.5 ) ;
rim . stroke ( { width : 1 , color : 0x000000 , alpha : dim ? 0.12 : 0.22 } ) ;
cell . addChild ( rim ) ;
const inner = new Graphics ( ) ;
inner . circle ( - 5 , - 5 , NODE_RADIUS * 0.35 ) ;
inner . fill ( { color : 0xffffff , alpha : isThief || isGuard || isExit ? 0.2 : 0.08 } ) ;
cell . addChild ( inner ) ;
if ( isThief ) {
const t = new Text ( { text : "偷" , style : TOKEN_LABEL_STYLE } ) ;
t . anchor . set ( 0.5 , 0.5 ) ;
@ -298,19 +425,14 @@ export default class ChaseScene extends BaseScene {
this . graphLayer . addChild ( cell ) ;
}
this . applyGraphAutoscale ( no de s , offsetX , offsetY ) ;
this . applyGraphAutoscale ( bou nds, nodes . length ) ;
}
/** 在可视区内尽量放大棋盘(上限避免过大) */
private applyGraphAutoscale (
private computeGraphPixelBounds (
nodes : Array < { q : number ; r : number } > ,
offsetX : number ,
offsetY : number ,
) : void {
if ( nodes . length === 0 ) {
this . graphLayer . scale . set ( 1 ) ;
return ;
}
) : { minPx : number ; maxPx : number ; minPy : number ; maxPy : number ; bw : number ; bh : number } {
let minPx = Infinity ;
let maxPx = - Infinity ;
let minPy = Infinity ;
@ -325,9 +447,22 @@ export default class ChaseScene extends BaseScene {
}
const bw = Math . max ( maxPx - minPx , 40 ) ;
const bh = Math . max ( maxPy - minPy , 40 ) ;
return { minPx , maxPx , minPy , maxPy , bw , bh } ;
}
/** 在可视区内尽量放大棋盘(上限避免过大) */
private applyGraphAutoscale (
bounds : { bw : number ; bh : number } ,
nodeCount : number ,
) : void {
if ( nodeCount === 0 ) {
this . graphLayer . scale . set ( 1 ) ;
return ;
}
const { bw , bh } = bounds ;
const { width , height } = appRuntime . game . getInfo ( ) ;
const playTop = Math . min ( TOP_CHROME_H , height * 0.28 ) ;
const margin = 48 ;
const playTop = this . getPlayTop ( height ) ;
const margin = 44 ;
const availW = Math . max ( 120 , width - margin * 2 ) ;
const availH = Math . max ( 120 , height - playTop - margin * 2 ) ;
const sx = availW / bw ;
@ -360,32 +495,75 @@ export default class ChaseScene extends BaseScene {
return { offsetX : - cx , offsetY : - cy } ;
}
private getPlayTop ( screenHeight : number ) : number {
return Math . min ( TOP_CHROME_H , screenHeight * CHROME_HEIGHT_FRAC ) ;
}
private relayoutChrome ( ) : void {
const { width , height } = appRuntime . game . getInfo ( ) ;
const safeTop = PAD_Y ;
this . winCountText ? . position . set ( PAD_X , safeTop ) ;
this . seedText ? . position . set ( width - PAD_X , safeTop ) ;
this . hudText ? . position . set ( PAD_X , safeTop + 26 ) ;
this . setupText ? . position . set ( PAD_X , safeTop + 72 ) ;
this . setupLayer . position . set ( PAD_X , safeTop + 98 ) ;
const playTop = this . getPlayTop ( height ) ;
this . bgLayer . clear ( ) ;
this . bgLayer . rect ( 0 , 0 , width , height ) ;
this . bgLayer . fill ( { color : 0x040b14 } ) ;
this . bgLayer . rect ( 0 , 0 , width , height * 0.52 ) ;
this . bgLayer . fill ( { color : 0x132337 , alpha : 0.55 } ) ;
this . bgLayer . rect ( 0 , height * 0.55 , width , height * 0.45 ) ;
this . bgLayer . fill ( { color : 0x020617 , alpha : 0.65 } ) ;
this . bgLayer . zIndex = 0 ;
const panelPad = 14 ;
this . chromePanel . clear ( ) ;
this . chromePanel . roundRect ( panelPad , 8 , width - panelPad * 2 , playTop - 12 , 22 ) ;
this . chromePanel . fill ( { color : 0x0b1220 , alpha : 0.78 } ) ;
this . chromePanel . stroke ( { width : 1 , color : 0x64748b , alpha : 0.35 } ) ;
this . chromePanel . zIndex = 1 ;
this . winCountText ? . position . set ( PAD_X + 8 , safeTop + 6 ) ;
this . seedText ? . position . set ( width - PAD_X - 8 , safeTop + 8 ) ;
this . hudText ? . position . set ( PAD_X + 8 , safeTop + 34 ) ;
this . hudLegendText ? . position . set ( PAD_X + 8 , safeTop + 56 ) ;
this . setupText ? . position . set ( PAD_X + 8 , safeTop + 82 ) ;
this . setupLayer . position . set ( PAD_X + 8 , safeTop + 102 ) ;
const playTop = Math . min ( TOP_CHROME_H , height * 0.28 ) ;
const playCenterY = playTop + ( height - playTop ) / 2 ;
this . graphLayer . position . set ( width / 2 , playCenterY ) ;
this . resultOverlay . position . set ( width / 2 - 120 , playTop + 8 ) ;
this . resultDimmer . clear ( ) ;
this . resultDimmer . rect ( 0 , 0 , width , height ) ;
this . resultDimmer . fill ( { color : 0x020617 , alpha : 0.62 } ) ;
this . resultOverlay . position . set (
( width - RESULT_CARD_W ) / 2 ,
playCenterY - RESULT_CARD_H / 2 ,
) ;
this . positionSeedInputDom ( ) ;
this . positionSeedInputDom ( width ) ;
}
private positionSeedInputDom ( ) : void {
/** 终局时隐藏 HTML 种子框,避免与结果弹层叠成「第二块面板」 */
private updateSeedInputPlayVisibility ( playing : boolean ) : void {
if ( ! this . seedInputElement ) {
return ;
}
const topPx = this . setupLayer . position . y + 52 ;
this . seedInputElement . style . left = ` ${ PAD_X } px ` ;
this . seedInputElement . style . display = playing ? "" : "none" ;
}
private positionSeedInputDom ( viewWidth : number ) : void {
if ( ! this . seedInputElement ) {
return ;
}
const topPx = this . setupLayer . position . y + DIFF_BTN_H + 8 ;
const inputW = Math . min ( SEED_INPUT_MAX_W , Math . max ( 200 , viewWidth - ( PAD_X + 8 ) * 2 ) ) ;
this . seedInputElement . style . left = ` ${ PAD_X + 8 } px ` ;
this . seedInputElement . style . top = ` ${ topPx } px ` ;
this . seedInputElement . style . width = ` ${ SEED_INPUT_WIDTH } px ` ;
this . seedInputElement . style . width = ` ${ inputW } px ` ;
this . seedInputElement . style . height = ` ${ SEED_INPUT_H } px ` ;
}
/** 仅保留数字,避免非数字种子把布局撑乱;与开局随机逻辑一致 */
private normalizeSeedInputRaw ( raw : string ) : string {
return raw . replace ( /\D/g , "" ) . slice ( 0 , 12 ) ;
}
private handleNodeClick ( targetNodeId : NodeId ) : void {
@ -405,9 +583,9 @@ export default class ChaseScene extends BaseScene {
}
public setSeedInput ( seedInput : string ) : void {
this . seedInput = seedInput ;
this . seedInput = this . normalizeSeedInputRaw ( seedInput ) ;
if ( this . seedInputElement ) {
this . seedInputElement . value = seedInput ;
this . seedInputElement . value = this . seedInput ;
}
this . refreshView ( ) ;
}
@ -483,98 +661,147 @@ export default class ChaseScene extends BaseScene {
private buildSetupControls ( ) : void {
this . setupLayer . removeChildren ( ) ;
const difficulties : Difficulty [ ] = [ "easy" , "normal" , "hard" ] ;
const labelStyle = new TextStyle ( {
fontSize : 13 ,
fontWeight : "600" ,
fill : 0xf1f5f9 ,
letterSpacing : 0.4 ,
fontFamily : "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif" ,
} ) ;
difficulties . forEach ( ( difficulty , index ) = > {
const button = this . difficultyButtons [ difficulty ] ;
button . eventMode = "static" ;
button . cursor = "pointer" ;
button . removeAllListeners ( ) ;
button . on ( "pointerdown" , ( ) = > this . setDifficulty ( difficulty ) ) ;
button . position . set ( index * 96 , 0 ) ;
button . position . set ( index * DIFF_BTN_GAP , 0 ) ;
this . setupLayer . addChild ( button ) ;
const label = new Text ( {
text : difficulty ,
style : new TextStyle ( {
fontSize : 14 ,
fill : 0xffffff ,
fontFamily : "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif" ,
} ) ,
style : labelStyle ,
} ) ;
label . anchor . set ( 0.5 ) ;
label . position . set ( index * 96 + 38 , 18 ) ;
label . position . set ( index * DIFF_BTN_GAP + DIFF_BTN_W / 2 , DIFF_BTN_H / 2 ) ;
this . setupLayer . addChild ( label ) ;
} ) ;
const startX = 3 * DIFF_BTN_GAP + 12 ;
this . startButton . eventMode = "static" ;
this . startButton . cursor = "pointer" ;
this . startButton . removeAllListeners ( ) ;
this . startButton . on ( "pointerdown" , ( ) = > this . startGame ( ) ) ;
this . startButton . position . set ( 304 , 0 ) ;
this . startButton . position . set ( startX , 0 ) ;
this . setupLayer . addChild ( this . startButton ) ;
const startLabel = new Text ( {
text : "开始游戏" ,
style : new TextStyle ( {
fontSize : 14 ,
fontSize : 15 ,
fontWeight : "700" ,
fill : 0xffffff ,
fontFamily : "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif" ,
} ) ,
} ) ;
startLabel . anchor . set ( 0.5 ) ;
startLabel . position . set ( 304 + 48 , 18 ) ;
startLabel . position . set ( startX + START_BTN_W / 2 , START_BTN_H / 2 ) ;
this . setupLayer . addChild ( startLabel ) ;
this . drawDifficultyButtons ( ) ;
this . startButton . clear ( ) ;
this . startButton . roundRect ( 0 , 0 , 96 , 36 , 8 ) ;
this . startButton . fill ( { color : 0x2563eb , alpha : 0.95 } ) ;
this . startButton . roundRect ( 0 , 0 , START_BTN_W , START_BTN_H , 14 ) ;
this . startButton . fill ( { color : 0x1d4ed8 , alpha : 1 } ) ;
this . startButton . stroke ( { width : 1.5 , color : 0x93c5fd , alpha : 0.55 } ) ;
}
private buildResultOverlay ( ) : void {
this . resultOverlay . visible = false ;
this . resultOverlay . eventMode = "passive" ;
this . resultOverlay . eventMode = "static" ;
const card = new Graphics ( ) ;
card . roundRect ( 0 , 0 , RESULT_CARD_W , RESULT_CARD_H , 24 ) ;
card . fill ( { color : 0x0f172a , alpha : 0.98 } ) ;
card . stroke ( { width : 1.5 , color : 0x64748b , alpha : 0.5 } ) ;
this . resultOverlay . addChild ( card ) ;
const cardInner = new Graphics ( ) ;
cardInner . roundRect ( 6 , 6 , RESULT_CARD_W - 12 , RESULT_CARD_H - 12 , 18 ) ;
cardInner . stroke ( { width : 1 , color : 0xffffff , alpha : 0.06 } ) ;
this . resultOverlay . addChild ( cardInner ) ;
const cx = RESULT_CARD_W / 2 ;
this . resultText = new Text ( {
text : "" ,
style : new TextStyle ( {
fontSize : 24 ,
fill : 0xffffff ,
fontWeight : "700" ,
fill : 0xf8fafc ,
fontFamily : "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif" ,
align : "center" ,
dropShadow : {
alpha : 0.35 ,
angle : Math.PI / 2 ,
blur : 4 ,
color : 0x000000 ,
distance : 1 ,
} ,
} ) ,
} ) ;
this . resultText . anchor . set ( 0.5 , 0 ) ;
this . resultText . position . set ( cx , 22 ) ;
this . resultOverlay . addChild ( this . resultText ) ;
this . resultSeedText = new Text ( {
text : "" ,
style : new TextStyle ( {
fontSize : 13 ,
fill : 0x94a3b8 ,
fontFamily : "ui-monospace, 'Cascadia Code', 'Microsoft YaHei', monospace" ,
align : "center" ,
wordWrap : true ,
wordWrapWidth : RESULT_CARD_W - 40 ,
} ) ,
} ) ;
this . resultSeedText . anchor . set ( 0.5 , 0 ) ;
this . resultSeedText . position . set ( cx , 64 ) ;
this . resultOverlay . addChild ( this . resultSeedText ) ;
const btnY = 124 ;
this . retryButton . eventMode = "static" ;
this . retryButton . cursor = "pointer" ;
this . retryButton . removeAllListeners ( ) ;
this . retryButton . on ( "pointerdown" , ( ) = > this . handleRetryClick ( ) ) ;
this . retryButton . position . set ( 0 , 42 ) ;
this . retryButton . roundRect ( 0 , 0 , 86 , 34 , 8 ) ;
this . retryButton . fill ( { color : 0x2563eb , alpha : 0.95 } ) ;
this . retryButton . position . set ( 36 , btnY ) ;
this . retryButton . roundRect ( 0 , 0 , 100 , 42 , 12 ) ;
this . retryButton . fill ( { color : 0x1d4ed8 , alpha : 1 } ) ;
this . retryButton . stroke ( { width : 1 , color : 0x60a5fa , alpha : 0.45 } ) ;
this . resultOverlay . addChild ( this . retryButton ) ;
this . retryLabel = this . createOverlayLabel ( "重试" , 43 , 59 ) ;
this . retryLabel = this . createOverlayLabel ( "重试" , 36 + 50 , btnY + 21 ) ;
this . resultOverlay . addChild ( this . retryLabel ) ;
this . newGameButton . eventMode = "static" ;
this . newGameButton . cursor = "pointer" ;
this . newGameButton . removeAllListeners ( ) ;
this . newGameButton . on ( "pointerdown" , ( ) = > this . handleNewGameClick ( ) ) ;
this . newGameButton . position . set ( 98 , 42 ) ;
this . newGameButton . roundRect ( 0 , 0 , 96 , 34 , 8 ) ;
this . newGameButton . fill ( { color : 0x0ea5e9 , alpha : 0.95 } ) ;
this . newGameButton . position . set ( 168 , btnY ) ;
this . newGameButton . roundRect ( 0 , 0 , 100 , 42 , 12 ) ;
this . newGameButton . fill ( { color : 0x0e7490 , alpha : 1 } ) ;
this . newGameButton . stroke ( { width : 1 , color : 0x5eead4 , alpha : 0.4 } ) ;
this . resultOverlay . addChild ( this . newGameButton ) ;
this . newGameLabel = this . createOverlayLabel ( "新一局" , 146 , 59 ) ;
this . newGameLabel = this . createOverlayLabel ( "新一局" , 168 + 50 , btnY + 21 ) ;
this . resultOverlay . addChild ( this . newGameLabel ) ;
this . restartButton . eventMode = "static" ;
this . restartButton . cursor = "pointer" ;
this . restartButton . removeAllListeners ( ) ;
this . restartButton . on ( "pointerdown" , ( ) = > this . handleRestartClick ( ) ) ;
this . restartButton . position . set ( 0 , 42 ) ;
this . restartButton . roundRect ( 0 , 0 , 108 , 34 , 8 ) ;
this . restartButton . fill ( { color : 0x22c55e , alpha : 0.95 } ) ;
const restartX = ( RESULT_CARD_W - 124 ) / 2 ;
this . restartButton . position . set ( restartX , btnY ) ;
this . restartButton . roundRect ( 0 , 0 , 124 , 42 , 12 ) ;
this . restartButton . fill ( { color : 0x15803d , alpha : 1 } ) ;
this . restartButton . stroke ( { width : 1 , color : 0x86efac , alpha : 0.45 } ) ;
this . resultOverlay . addChild ( this . restartButton ) ;
this . restartLabel = this . createOverlayLabel ( "重新开始" , 54 , 59 ) ;
this . restartLabel = this . createOverlayLabel ( "重新开始" , restartX + 62 , btnY + 21 ) ;
this . resultOverlay . addChild ( this . restartLabel ) ;
}
@ -582,7 +809,8 @@ export default class ChaseScene extends BaseScene {
const label = new Text ( {
text ,
style : new TextStyle ( {
fontSize : 14 ,
fontSize : 15 ,
fontWeight : "600" ,
fill : 0xffffff ,
fontFamily : "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif" ,
} ) ,
@ -596,11 +824,19 @@ export default class ChaseScene extends BaseScene {
const drawButton = ( difficulty : Difficulty ) : void = > {
const button = this . difficultyButtons [ difficulty ] ;
button . clear ( ) ;
button . roundRect ( 0 , 0 , 76 , 36 , 8 ) ;
button . roundRect ( 0 , 0 , DIFF_BTN_W , DIFF_BTN_H , 12 ) ;
const selected = this . selectedDifficulty === difficulty ;
const lockedColor = this . difficultyLocked ? 0x475569 : selected ? 0x0ea5e9 : 0x1f2937 ;
button . fill ( { color : lockedColor , alpha : selected ? 0.95 : 0.88 } ) ;
button . stroke ( { width : selected ? 2 : 1 , color : 0xe2e8f0 , alpha : 0.9 } ) ;
const base = this . difficultyLocked
? 0x334155
: selected
? 0x1e3a8a
: 0x1e293b ;
button . fill ( { color : base , alpha : selected ? 1 : 0.9 } ) ;
button . stroke ( {
width : selected ? 2.5 : 1 ,
color : selected ? 0xfbbf24 : 0x475569 ,
alpha : selected ? 0.9 : 0.55 ,
} ) ;
button . cursor = this . difficultyLocked ? "not-allowed" : "pointer" ;
} ;
drawButton ( "easy" ) ;
@ -615,20 +851,31 @@ export default class ChaseScene extends BaseScene {
}
const input = doc . createElement ( "input" ) ;
input . type = "text" ;
input . placeholder = "Seed (optional)" ;
input . inputMode = "numeric" ;
input . pattern = "[0-9]*" ;
input . autocomplete = "off" ;
input . spellcheck = false ;
input . placeholder = "数字种子,留空随机" ;
input . value = this . seedInput ;
input . style . position = "fixed" ;
input . style . zIndex = "10" ;
input . style . boxSizing = "border-box" ;
input . style . padding = "6px 10px" ;
input . style . fontSize = "14px" ;
input . style . borderRadius = "6px" ;
input . style . border = "1px solid #94a3b8" ;
input . style . padding = "0 14px" ;
input . style . fontSize = "16px" ;
input . style . lineHeight = ` ${ SEED_INPUT_H } px ` ;
input . style . height = ` ${ SEED_INPUT_H } px ` ;
input . style . maxHeight = ` ${ SEED_INPUT_H } px ` ;
input . style . borderRadius = "10px" ;
input . style . border = "1px solid rgba(100, 116, 139, 0.45)" ;
input . style . background = "rgba(15, 23, 42, 0.92)" ;
input . style . color = "#e2e8f0" ;
input . style . outline = "none" ;
input . style . boxShadow = "0 4px 16px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.05)" ;
input . addEventListener ( "input" , this . handleSeedInputEvent ) ;
doc . body . appendChild ( input ) ;
this . seedInputElement = input ;
this . inputAttached = true ;
this . positionSeedInputDom ( ) ;
this . positionSeedInputDom ( appRuntime . game . getInfo ( ) . width ) ;
}
private detachSeedInputBridge ( ) : void {
@ -648,6 +895,9 @@ export default class ChaseScene extends BaseScene {
}
if ( this . stage . visible ) {
this . attachSeedInputBridge ( ) ;
if ( this . model ) {
this . updateSeedInputPlayVisibility ( this . model . getState ( ) . snapshot . status === "playing" ) ;
}
} else {
this . detachSeedInputBridge ( ) ;
}
@ -655,7 +905,11 @@ export default class ChaseScene extends BaseScene {
private readonly handleSeedInputEvent = ( event : Event ) : void = > {
const target = event . target as HTMLInputElement | null ;
this . seedInput = target ? . value ? ? "" ;
const next = this . normalizeSeedInputRaw ( target ? . value ? ? "" ) ;
if ( target && target . value !== next ) {
target . value = next ;
}
this . seedInput = next ;
this . refreshView ( ) ;
} ;
@ -675,11 +929,13 @@ export default class ChaseScene extends BaseScene {
) : void {
if ( status === "playing" ) {
this . resultOverlay . visible = false ;
this . resultDimmer . visible = false ;
this . endActionLocked = false ;
return ;
}
this . resultOverlay . visible = true ;
this . resultDimmer . visible = true ;
if ( status === "win" ) {
this . resultText . text = "成功逃脱!" ;
this . restartButton . visible = true ;