Browse Source

refactor(chase): remove WelcomeScene and update ChaseScene HUD

- Delete WelcomeScene implementation from page_welcome.ts.
- Update ChaseScene HUD to improve text clarity and layout, including changes to win count and seed display.
- Adjust padding and dimensions for UI elements to enhance overall appearance and usability.
- Add new status label function for better status representation in the HUD.

Made-with: Cursor
master
npmrun 2 months ago
parent
commit
7391d3c14c
  1. 460
      src/stages/games/chase/page_chase.ts
  2. 167
      src/stages/page_welcome.ts
  3. 22
      tests/stages/page_chase.test.ts

460
src/stages/games/chase/page_chase.ts

@ -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(nodes, offsetX, offsetY);
this.applyGraphAutoscale(bounds, 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;

167
src/stages/page_welcome.ts

@ -1,167 +0,0 @@
import { Button } from "@/components/Button";
import { SceneType } from "@/enums/SceneType";
import { appRuntime } from "@/kernel/AppRuntime";
import { BaseScene } from "@/scene/BaseScene";
import position from "@/utils/Position";
import { Container, Graphics, Text, TextStyle } from "pixi.js";
type GuideItem = {
title: string;
hint: string;
};
const GUIDE_ITEMS: GuideItem[] = [
{ title: "1. 宝箱记忆", hint: "翻牌配对,考验短时记忆" },
{ title: "2. 节奏连击", hint: "跟随节拍点击,连击越高分越高" },
{ title: "3. 躲避训练", hint: "滑动角色闪避障碍,坚持更久" },
];
export default class WelcomeScene extends BaseScene {
stage = new Container();
private readonly game = appRuntime.game;
private titleText?: Text;
private descText?: Text;
private detailText?: Text;
private backBtn?: Button;
private itemRows: Container[] = [];
private readonly onResize = (): void => {
this.relayout();
};
constructor() {
super("welcome", SceneType.Normal);
}
protected async onSceneLayout(): Promise<void> {
this.stage.sortableChildren = true;
this.stage.eventMode = "passive";
this.titleText = new Text({
text: "子游戏引导",
style: new TextStyle({
fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif",
fontSize: 42,
fontWeight: "700",
fill: 0xffffff,
}),
});
this.titleText.anchor.set(0.5);
this.stage.addChild(this.titleText);
this.descText = new Text({
text: "选择一项查看玩法说明",
style: new TextStyle({
fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif",
fontSize: 20,
fill: 0xdbeafe,
}),
});
this.descText.anchor.set(0.5);
this.stage.addChild(this.descText);
this.detailText = new Text({
text: GUIDE_ITEMS[0].hint,
style: new TextStyle({
fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif",
fontSize: 18,
fill: 0xf8fafc,
}),
});
this.detailText.anchor.set(0.5);
this.stage.addChild(this.detailText);
this.itemRows = GUIDE_ITEMS.map((item, index) => this.createGuideRow(item, index));
for (const row of this.itemRows) {
this.stage.addChild(row);
}
this.highlightRow(0);
this.backBtn = new Button({
text: "返回开始",
variant: "secondary",
fontSize: 24,
padding: { x: 64, y: 26 },
position: () => position.get("center", "bottom", { y: -56, x: 0 }),
onClick: () => {
this.backBtn?.setDisabled(true);
this.backBtn?.setText("返回中...");
void this.changeScene("init");
},
});
this.stage.addChild(this.backBtn.getView());
this.relayout();
}
protected onSceneEnter(): void {
window.addEventListener("resize", this.onResize);
}
protected onSceneExit(): void {
window.removeEventListener("resize", this.onResize);
}
private createGuideRow(item: GuideItem, index: number): Container {
const row = new Container();
row.eventMode = "static";
row.cursor = "pointer";
const bg = new Graphics();
bg.roundRect(0, 0, 560, 68, 14);
bg.fill({ color: 0x10223f, alpha: 0.92 });
row.addChild(bg);
const title = new Text({
text: item.title,
style: new TextStyle({
fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif",
fontSize: 24,
fill: 0xffffff,
}),
});
title.anchor.set(0.5);
title.position.set(280, 34);
row.addChild(title);
row.on("pointerdown", () => this.highlightRow(index));
return row;
}
private highlightRow(targetIndex: number): void {
this.itemRows.forEach((row, index) => {
const bg = row.children[0] as Graphics | undefined;
if (!bg) {
return;
}
bg.clear();
if (index === targetIndex) {
bg.roundRect(0, 0, 560, 68, 14);
bg.fill({ color: 0x1d4ed8, alpha: 0.95 });
} else {
bg.roundRect(0, 0, 560, 68, 14);
bg.fill({ color: 0x10223f, alpha: 0.92 });
}
});
if (this.detailText) {
this.detailText.text = GUIDE_ITEMS[targetIndex].hint;
}
}
private relayout(): void {
const { height: H } = this.game.getInfo();
this.titleText?.position.copyFrom(position.get("center", H * 0.12));
this.descText?.position.copyFrom(position.get("center", H * 0.2));
this.itemRows.forEach((row, index) => {
row.position.copyFrom(position.get("center", H * 0.32 + index * 90));
row.pivot.set(280, 34);
});
this.detailText?.position.copyFrom(position.get("center", H * 0.66));
this.backBtn?.updateView();
}
}

22
tests/stages/page_chase.test.ts

@ -118,15 +118,15 @@ describe("chase stage skeleton", () => {
scene.refreshView();
const firstChildren = [...(scene as any).graphLayer.children];
expect(firstChildren.length).toBe(3);
expect(firstChildren.length).toBe(4);
scene.refreshView();
const secondChildren = [...(scene as any).graphLayer.children];
expect(secondChildren.length).toBe(3);
expect(secondChildren.length).toBe(4);
expect(secondChildren[0]).not.toBe(firstChildren[0]);
expect((firstChildren[0] as any).destroyed).toBe(true);
expect((firstChildren[1] as any).destroyed).toBe(true);
expect((firstChildren[2] as any).destroyed).toBe(true);
for (const c of firstChildren) {
expect((c as any).destroyed).toBe(true);
}
});
it("init start entry routes to chase", () => {
@ -395,8 +395,8 @@ describe("chase stage skeleton", () => {
} as any,
});
void (scene as any).onSceneLayout();
expect((scene as any).winCountText.text).toContain("成功次数: 0");
expect((scene as any).seedText.text).toContain("当前 seed:123");
expect((scene as any).winCountText.text).toContain("成功 · 0");
expect((scene as any).seedText.text).toContain("Seed 123");
});
it("refreshes hud after state change", () => {
@ -423,8 +423,8 @@ describe("chase stage skeleton", () => {
});
void (scene as any).onSceneLayout();
scene.refreshView();
expect((scene as any).winCountText.text).toContain("成功次数: 2");
expect((scene as any).seedText.text).toContain("当前 seed:999");
expect((scene as any).winCountText.text).toContain("成功 · 2");
expect((scene as any).seedText.text).toContain("Seed 999");
});
it("refreshes success counter on win state change", () => {
@ -452,7 +452,7 @@ describe("chase stage skeleton", () => {
});
void (scene as any).onSceneLayout();
scene.refreshView();
expect((scene as any).winCountText.text).toContain("成功次数: 1");
expect((scene as any).winCountText.text).toContain("成功 · 1");
});
it("refreshes seed text after starting a new round", () => {
@ -476,7 +476,7 @@ describe("chase stage skeleton", () => {
});
void (scene as any).onSceneLayout();
scene.refreshView();
expect((scene as any).seedText.text).toContain("当前 seed:2027");
expect((scene as any).seedText.text).toContain("Seed 2027");
});
it("renders move highlights matching available moves count", () => {

Loading…
Cancel
Save