|
|
@ -6,15 +6,23 @@ import { ChaseGameModel } from "@/game/chase/model"; |
|
|
import type { Difficulty, NodeId } from "@/game/chase/types"; |
|
|
import type { Difficulty, NodeId } from "@/game/chase/types"; |
|
|
import { Container, Graphics, Text, TextStyle, type Ticker } from "pixi.js"; |
|
|
import { Container, Graphics, Text, TextStyle, type Ticker } from "pixi.js"; |
|
|
|
|
|
|
|
|
const NODE_RADIUS = 18; |
|
|
const NODE_RADIUS = 24; |
|
|
const NODE_GAP = 58; |
|
|
const NODE_GAP = 76; |
|
|
const END_ACTION_COOLDOWN_MS = 400; |
|
|
const END_ACTION_COOLDOWN_MS = 400; |
|
|
const PAD_X = 20; |
|
|
const PAD_X = 20; |
|
|
const PAD_Y = 12; |
|
|
const PAD_Y = 12; |
|
|
/** 顶栏 + 开局控件占用高度(用于把地图放到下方安全区) */ |
|
|
/** 顶栏 + 开局控件占用高度(用于把地图放到下方安全区) */ |
|
|
const TOP_CHROME_H = 168; |
|
|
const TOP_CHROME_H = 200; |
|
|
const SEED_INPUT_WIDTH = 220; |
|
|
const SEED_INPUT_WIDTH = 220; |
|
|
|
|
|
|
|
|
|
|
|
const TOKEN_LABEL_STYLE = new TextStyle({ |
|
|
|
|
|
fontSize: 15, |
|
|
|
|
|
fontWeight: "700", |
|
|
|
|
|
fill: 0xffffff, |
|
|
|
|
|
fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", |
|
|
|
|
|
stroke: { color: 0x0f172a, width: 3 }, |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
type ChaseModelLike = Pick< |
|
|
type ChaseModelLike = Pick< |
|
|
ChaseGameModel, |
|
|
ChaseGameModel, |
|
|
"getState" | "moveThief" | "newRound" | "retryRound" |
|
|
"getState" | "moveThief" | "newRound" | "retryRound" |
|
|
@ -67,6 +75,9 @@ export default class ChaseScene extends BaseScene { |
|
|
private lastEndActionAt = 0; |
|
|
private lastEndActionAt = 0; |
|
|
private readonly onWindowResize = (): void => { |
|
|
private readonly onWindowResize = (): void => { |
|
|
this.relayoutChrome(); |
|
|
this.relayoutChrome(); |
|
|
|
|
|
if (this.model && this.hudText) { |
|
|
|
|
|
this.refreshView(); |
|
|
|
|
|
} |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
constructor(options: ChaseSceneOptions = {}) { |
|
|
constructor(options: ChaseSceneOptions = {}) { |
|
|
@ -179,7 +190,11 @@ export default class ChaseScene extends BaseScene { |
|
|
const state = this.model.getState(); |
|
|
const state = this.model.getState(); |
|
|
this.winCountText.text = `成功次数: ${state.winCount}`; |
|
|
this.winCountText.text = `成功次数: ${state.winCount}`; |
|
|
this.seedText.text = `当前 seed:${state.snapshot.seed}`; |
|
|
this.seedText.text = `当前 seed:${state.snapshot.seed}`; |
|
|
this.hudText.text = `Turn ${state.turn} | ${state.snapshot.status} | thief=${state.snapshot.thiefNodeId} guard=${state.snapshot.guardNodeId}`; |
|
|
const roleHint = |
|
|
|
|
|
state.snapshot.status === "playing" |
|
|
|
|
|
? "\n偷=你 · 兵=官兵 · 出=出口 · 点击黄圈可走格" |
|
|
|
|
|
: ""; |
|
|
|
|
|
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 = `difficulty=${this.selectedDifficulty} | seed=${this.seedInput.trim() || "(auto)"} | locked=${this.difficultyLocked ? "yes" : "no"}`; |
|
|
this.drawDifficultyButtons(); |
|
|
this.drawDifficultyButtons(); |
|
|
this.renderResultOverlay(state.snapshot.status, state.loseReason); |
|
|
this.renderResultOverlay(state.snapshot.status, state.loseReason); |
|
|
@ -196,6 +211,25 @@ export default class ChaseScene extends BaseScene { |
|
|
const availableMoves = thiefNode |
|
|
const availableMoves = thiefNode |
|
|
? thiefNode.neighbors.filter((id) => id !== state.snapshot.guardNodeId) |
|
|
? thiefNode.neighbors.filter((id) => id !== state.snapshot.guardNodeId) |
|
|
: []; |
|
|
: []; |
|
|
|
|
|
const availableMoveSet = new Set(availableMoves); |
|
|
|
|
|
|
|
|
|
|
|
// 先画边,避免压住节点与高亮
|
|
|
|
|
|
const edgeLayer = new Graphics(); |
|
|
|
|
|
for (const [fromId, toId] of state.snapshot.graph.edgeList) { |
|
|
|
|
|
const from = state.snapshot.graph.nodes[fromId]; |
|
|
|
|
|
const to = state.snapshot.graph.nodes[toId]; |
|
|
|
|
|
if (!from || !to) { |
|
|
|
|
|
continue; |
|
|
|
|
|
} |
|
|
|
|
|
const fromX = from.q * NODE_GAP + offsetX; |
|
|
|
|
|
const fromY = from.r * NODE_GAP + offsetY; |
|
|
|
|
|
const toX = to.q * NODE_GAP + offsetX; |
|
|
|
|
|
const toY = to.r * NODE_GAP + offsetY; |
|
|
|
|
|
edgeLayer.moveTo(fromX, fromY); |
|
|
|
|
|
edgeLayer.lineTo(toX, toY); |
|
|
|
|
|
} |
|
|
|
|
|
edgeLayer.stroke({ width: 3.5, color: 0xf1f5f9, alpha: 0.55 }); |
|
|
|
|
|
this.graphLayer.addChild(edgeLayer); |
|
|
for (const nodeId of availableMoves) { |
|
|
for (const nodeId of availableMoves) { |
|
|
const node = state.snapshot.graph.nodes[nodeId]; |
|
|
const node = state.snapshot.graph.nodes[nodeId]; |
|
|
if (!node) { |
|
|
if (!node) { |
|
|
@ -203,31 +237,103 @@ export default class ChaseScene extends BaseScene { |
|
|
} |
|
|
} |
|
|
const highlight = new Graphics(); |
|
|
const highlight = new Graphics(); |
|
|
highlight.label = "move-highlight"; |
|
|
highlight.label = "move-highlight"; |
|
|
highlight.circle(0, 0, NODE_RADIUS + 8); |
|
|
highlight.circle(0, 0, NODE_RADIUS + 12); |
|
|
highlight.stroke({ width: 3, color: 0xfacc15, alpha: 0.9 }); |
|
|
highlight.stroke({ width: 4, color: 0xfde047, alpha: 1 }); |
|
|
highlight.position.set(node.q * NODE_GAP + offsetX, node.r * NODE_GAP + offsetY); |
|
|
highlight.position.set(node.q * NODE_GAP + offsetX, node.r * NODE_GAP + offsetY); |
|
|
this.graphLayer.addChild(highlight); |
|
|
this.graphLayer.addChild(highlight); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
for (const node of nodes) { |
|
|
for (const node of nodes) { |
|
|
const nodeView = new Graphics(); |
|
|
const px = node.q * NODE_GAP + offsetX; |
|
|
nodeView.circle(0, 0, NODE_RADIUS); |
|
|
const py = node.r * NODE_GAP + offsetY; |
|
|
const color = |
|
|
const cell = new Container(); |
|
|
node.id === state.snapshot.thiefNodeId |
|
|
cell.position.set(px, py); |
|
|
? 0x22c55e |
|
|
cell.eventMode = "static"; |
|
|
: node.id === state.snapshot.guardNodeId |
|
|
cell.cursor = "pointer"; |
|
|
? 0xef4444 |
|
|
cell.on("pointerdown", () => this.handleNodeClick(node.id)); |
|
|
: node.id === state.snapshot.exitNodeId |
|
|
|
|
|
? 0x3b82f6 |
|
|
const body = new Graphics(); |
|
|
: 0x64748b; |
|
|
body.circle(0, 0, NODE_RADIUS); |
|
|
nodeView.fill({ color, alpha: 0.95 }); |
|
|
const isThief = node.id === state.snapshot.thiefNodeId; |
|
|
nodeView.stroke({ width: 2, color: 0xe2e8f0, alpha: 0.9 }); |
|
|
const isGuard = node.id === state.snapshot.guardNodeId; |
|
|
nodeView.position.set(node.q * NODE_GAP + offsetX, node.r * NODE_GAP + offsetY); |
|
|
const isExit = node.id === state.snapshot.exitNodeId; |
|
|
nodeView.eventMode = "static"; |
|
|
const isMove = availableMoveSet.has(node.id); |
|
|
nodeView.cursor = "pointer"; |
|
|
const color = isThief |
|
|
nodeView.on("pointerdown", () => this.handleNodeClick(node.id)); |
|
|
? 0x16a34a |
|
|
this.graphLayer.addChild(nodeView); |
|
|
: isGuard |
|
|
|
|
|
? 0xdc2626 |
|
|
|
|
|
: isExit |
|
|
|
|
|
? 0x2563eb |
|
|
|
|
|
: isMove |
|
|
|
|
|
? 0x0ea5e9 |
|
|
|
|
|
: 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 }); |
|
|
|
|
|
cell.addChild(body); |
|
|
|
|
|
|
|
|
|
|
|
if (isThief) { |
|
|
|
|
|
const t = new Text({ text: "偷", style: TOKEN_LABEL_STYLE }); |
|
|
|
|
|
t.anchor.set(0.5, 0.5); |
|
|
|
|
|
t.position.set(0, 0); |
|
|
|
|
|
cell.addChild(t); |
|
|
|
|
|
} else if (isGuard) { |
|
|
|
|
|
const mark = new Graphics(); |
|
|
|
|
|
mark.roundRect(-9, -11, 18, 20, 3); |
|
|
|
|
|
mark.fill({ color: 0xe2e8f0, alpha: 0.95 }); |
|
|
|
|
|
mark.stroke({ width: 1.5, color: 0x0f172a, alpha: 0.85 }); |
|
|
|
|
|
cell.addChild(mark); |
|
|
|
|
|
const t = new Text({ text: "兵", style: TOKEN_LABEL_STYLE }); |
|
|
|
|
|
t.anchor.set(0.5, 0.5); |
|
|
|
|
|
t.position.set(0, 0); |
|
|
|
|
|
cell.addChild(t); |
|
|
|
|
|
} else if (isExit) { |
|
|
|
|
|
const t = new Text({ text: "出", style: TOKEN_LABEL_STYLE }); |
|
|
|
|
|
t.anchor.set(0.5, 0.5); |
|
|
|
|
|
t.position.set(0, 0); |
|
|
|
|
|
cell.addChild(t); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
this.graphLayer.addChild(cell); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
this.applyGraphAutoscale(nodes, offsetX, offsetY); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** 在可视区内尽量放大棋盘(上限避免过大) */ |
|
|
|
|
|
private applyGraphAutoscale( |
|
|
|
|
|
nodes: Array<{ q: number; r: number }>, |
|
|
|
|
|
offsetX: number, |
|
|
|
|
|
offsetY: number, |
|
|
|
|
|
): void { |
|
|
|
|
|
if (nodes.length === 0) { |
|
|
|
|
|
this.graphLayer.scale.set(1); |
|
|
|
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
let minPx = Infinity; |
|
|
|
|
|
let maxPx = -Infinity; |
|
|
|
|
|
let minPy = Infinity; |
|
|
|
|
|
let maxPy = -Infinity; |
|
|
|
|
|
for (const n of nodes) { |
|
|
|
|
|
const px = n.q * NODE_GAP + offsetX; |
|
|
|
|
|
const py = n.r * NODE_GAP + offsetY; |
|
|
|
|
|
minPx = Math.min(minPx, px - NODE_RADIUS - 14); |
|
|
|
|
|
maxPx = Math.max(maxPx, px + NODE_RADIUS + 14); |
|
|
|
|
|
minPy = Math.min(minPy, py - NODE_RADIUS - 14); |
|
|
|
|
|
maxPy = Math.max(maxPy, py + NODE_RADIUS + 14); |
|
|
|
|
|
} |
|
|
|
|
|
const bw = Math.max(maxPx - minPx, 40); |
|
|
|
|
|
const bh = Math.max(maxPy - minPy, 40); |
|
|
|
|
|
const { width, height } = appRuntime.game.getInfo(); |
|
|
|
|
|
const playTop = Math.min(TOP_CHROME_H, height * 0.28); |
|
|
|
|
|
const margin = 48; |
|
|
|
|
|
const availW = Math.max(120, width - margin * 2); |
|
|
|
|
|
const availH = Math.max(120, height - playTop - margin * 2); |
|
|
|
|
|
const sx = availW / bw; |
|
|
|
|
|
const sy = availH / bh; |
|
|
|
|
|
const s = Math.min(1.85, Math.max(1.05, Math.min(sx, sy))); |
|
|
|
|
|
this.graphLayer.scale.set(s); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** 将节点包络中心移到局部原点,避免负坐标画进顶栏 */ |
|
|
/** 将节点包络中心移到局部原点,避免负坐标画进顶栏 */ |
|
|
@ -260,8 +366,8 @@ export default class ChaseScene extends BaseScene { |
|
|
this.winCountText?.position.set(PAD_X, safeTop); |
|
|
this.winCountText?.position.set(PAD_X, safeTop); |
|
|
this.seedText?.position.set(width - PAD_X, safeTop); |
|
|
this.seedText?.position.set(width - PAD_X, safeTop); |
|
|
this.hudText?.position.set(PAD_X, safeTop + 26); |
|
|
this.hudText?.position.set(PAD_X, safeTop + 26); |
|
|
this.setupText?.position.set(PAD_X, safeTop + 52); |
|
|
this.setupText?.position.set(PAD_X, safeTop + 72); |
|
|
this.setupLayer.position.set(PAD_X, safeTop + 78); |
|
|
this.setupLayer.position.set(PAD_X, safeTop + 98); |
|
|
|
|
|
|
|
|
const playTop = Math.min(TOP_CHROME_H, height * 0.28); |
|
|
const playTop = Math.min(TOP_CHROME_H, height * 0.28); |
|
|
const playCenterY = playTop + (height - playTop) / 2; |
|
|
const playCenterY = playTop + (height - playTop) / 2; |
|
|
@ -276,7 +382,7 @@ export default class ChaseScene extends BaseScene { |
|
|
if (!this.seedInputElement) { |
|
|
if (!this.seedInputElement) { |
|
|
return; |
|
|
return; |
|
|
} |
|
|
} |
|
|
const topPx = this.setupLayer.position.y + 48; |
|
|
const topPx = this.setupLayer.position.y + 52; |
|
|
this.seedInputElement.style.left = `${PAD_X}px`; |
|
|
this.seedInputElement.style.left = `${PAD_X}px`; |
|
|
this.seedInputElement.style.top = `${topPx}px`; |
|
|
this.seedInputElement.style.top = `${topPx}px`; |
|
|
this.seedInputElement.style.width = `${SEED_INPUT_WIDTH}px`; |
|
|
this.seedInputElement.style.width = `${SEED_INPUT_WIDTH}px`; |
|
|
|