2026-04-14 13:51:11 +08:00
|
|
|
import Phaser from 'phaser';
|
|
|
|
|
import { ReactiveScene } from 'boardgame-phaser';
|
2026-04-14 14:21:51 +08:00
|
|
|
import { MutableSignal } from 'boardgame-core';
|
2026-04-14 13:51:11 +08:00
|
|
|
import {
|
|
|
|
|
canMoveTo,
|
|
|
|
|
moveToNode,
|
|
|
|
|
getCurrentNode,
|
|
|
|
|
getReachableChildren,
|
|
|
|
|
isAtEndNode,
|
2026-04-14 14:21:51 +08:00
|
|
|
isAtStartNode,
|
2026-04-14 13:51:11 +08:00
|
|
|
type RunState,
|
|
|
|
|
type MapNode,
|
|
|
|
|
} from 'boardgame-core/samples/slay-the-spire-like';
|
|
|
|
|
|
|
|
|
|
const NODE_COLORS: Record<string, number> = {
|
|
|
|
|
start: 0x44aa44,
|
|
|
|
|
end: 0xcc8844,
|
|
|
|
|
minion: 0xcc4444,
|
|
|
|
|
elite: 0xcc44cc,
|
|
|
|
|
event: 0xaaaa44,
|
|
|
|
|
camp: 0x44cccc,
|
|
|
|
|
shop: 0x4488cc,
|
|
|
|
|
curio: 0x8844cc,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const NODE_LABELS: Record<string, string> = {
|
|
|
|
|
start: '起点',
|
|
|
|
|
end: '终点',
|
|
|
|
|
minion: '战斗',
|
|
|
|
|
elite: '精英',
|
|
|
|
|
event: '事件',
|
|
|
|
|
camp: '营地',
|
|
|
|
|
shop: '商店',
|
|
|
|
|
curio: '奇遇',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export class GameFlowScene extends ReactiveScene {
|
2026-04-14 14:21:51 +08:00
|
|
|
/** 全局游戏状态(由 App.tsx 注入) */
|
|
|
|
|
private gameState: MutableSignal<RunState>;
|
2026-04-14 13:51:11 +08:00
|
|
|
|
|
|
|
|
// Layout constants
|
|
|
|
|
private readonly LAYER_HEIGHT = 110;
|
|
|
|
|
private readonly NODE_SPACING = 140;
|
|
|
|
|
private readonly NODE_RADIUS = 28;
|
|
|
|
|
|
|
|
|
|
// UI elements
|
|
|
|
|
private hudContainer!: Phaser.GameObjects.Container;
|
|
|
|
|
private hpText!: Phaser.GameObjects.Text;
|
|
|
|
|
private goldText!: Phaser.GameObjects.Text;
|
|
|
|
|
private nodeText!: Phaser.GameObjects.Text;
|
|
|
|
|
|
|
|
|
|
// Map elements
|
|
|
|
|
private mapContainer!: Phaser.GameObjects.Container;
|
|
|
|
|
private isDragging = false;
|
|
|
|
|
private dragStartX = 0;
|
|
|
|
|
private dragStartY = 0;
|
|
|
|
|
private dragStartContainerX = 0;
|
|
|
|
|
private dragStartContainerY = 0;
|
|
|
|
|
|
|
|
|
|
// Interaction
|
|
|
|
|
private hoveredNode: string | null = null;
|
|
|
|
|
private nodeGraphics: Map<string, Phaser.GameObjects.Graphics> = new Map();
|
|
|
|
|
|
2026-04-14 14:21:51 +08:00
|
|
|
constructor(gameState: MutableSignal<RunState>) {
|
2026-04-14 13:51:11 +08:00
|
|
|
super('GameFlowScene');
|
2026-04-14 14:21:51 +08:00
|
|
|
this.gameState = gameState;
|
2026-04-14 13:51:11 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
create(): void {
|
|
|
|
|
super.create();
|
|
|
|
|
this.drawHUD();
|
|
|
|
|
this.drawMap();
|
|
|
|
|
this.updateHUD();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private drawHUD(): void {
|
|
|
|
|
const { width } = this.scale;
|
|
|
|
|
|
|
|
|
|
// HUD background
|
|
|
|
|
const hudBg = this.add.rectangle(width / 2, 25, 400, 40, 0x111122, 0.8);
|
|
|
|
|
this.hudContainer = this.add.container(width / 2, 25).setDepth(200);
|
|
|
|
|
this.hudContainer.add(hudBg);
|
|
|
|
|
|
|
|
|
|
// HP
|
|
|
|
|
this.hpText = this.add.text(-150, 0, '', {
|
|
|
|
|
fontSize: '16px',
|
|
|
|
|
color: '#ff6666',
|
|
|
|
|
fontStyle: 'bold',
|
|
|
|
|
}).setOrigin(0, 0.5);
|
|
|
|
|
this.hudContainer.add(this.hpText);
|
|
|
|
|
|
|
|
|
|
// Gold
|
|
|
|
|
this.goldText = this.add.text(-50, 0, '', {
|
|
|
|
|
fontSize: '16px',
|
|
|
|
|
color: '#ffcc44',
|
|
|
|
|
fontStyle: 'bold',
|
|
|
|
|
}).setOrigin(0, 0.5);
|
|
|
|
|
this.hudContainer.add(this.goldText);
|
|
|
|
|
|
|
|
|
|
// Current node
|
|
|
|
|
this.nodeText = this.add.text(50, 0, '', {
|
|
|
|
|
fontSize: '16px',
|
|
|
|
|
color: '#ffffff',
|
|
|
|
|
}).setOrigin(0, 0.5);
|
|
|
|
|
this.hudContainer.add(this.nodeText);
|
|
|
|
|
|
|
|
|
|
// Back to menu button
|
|
|
|
|
this.createButton('返回菜单', width - 100, 25, 140, 36, async () => {
|
|
|
|
|
await this.sceneController.launch('IndexScene');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private updateHUD(): void {
|
2026-04-14 14:21:51 +08:00
|
|
|
const state = this.gameState.value;
|
|
|
|
|
const { player, currentNodeId, map } = state;
|
2026-04-14 13:51:11 +08:00
|
|
|
const currentNode = map.nodes.get(currentNodeId);
|
|
|
|
|
|
|
|
|
|
this.hpText.setText(`HP: ${player.currentHp}/${player.maxHp}`);
|
|
|
|
|
this.goldText.setText(`💰 ${player.gold}`);
|
|
|
|
|
|
|
|
|
|
if (currentNode) {
|
|
|
|
|
const typeLabel = NODE_LABELS[currentNode.type] ?? currentNode.type;
|
|
|
|
|
const encounterName = currentNode.encounter?.name ?? typeLabel;
|
|
|
|
|
this.nodeText.setText(`当前: ${encounterName}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private drawMap(): void {
|
|
|
|
|
const { width, height } = this.scale;
|
2026-04-14 14:21:51 +08:00
|
|
|
const state = this.gameState.value;
|
2026-04-14 13:51:11 +08:00
|
|
|
|
2026-04-14 14:21:51 +08:00
|
|
|
// Calculate map bounds (left-to-right: layers along X, nodes along Y)
|
2026-04-14 13:51:11 +08:00
|
|
|
const maxLayer = 9;
|
|
|
|
|
const maxNodesInLayer = 5;
|
2026-04-14 14:21:51 +08:00
|
|
|
const mapWidth = maxLayer * this.LAYER_HEIGHT + 200;
|
|
|
|
|
const mapHeight = (maxNodesInLayer - 1) * this.NODE_SPACING + 200;
|
2026-04-14 13:51:11 +08:00
|
|
|
|
|
|
|
|
// Create scrollable container
|
|
|
|
|
this.mapContainer = this.add.container(width / 2, height / 2 + 50);
|
|
|
|
|
|
|
|
|
|
// Background panel
|
|
|
|
|
const bg = this.add.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5).setOrigin(0.5);
|
|
|
|
|
this.mapContainer.add(bg);
|
|
|
|
|
|
|
|
|
|
const graphics = this.add.graphics();
|
|
|
|
|
this.mapContainer.add(graphics);
|
|
|
|
|
|
2026-04-14 14:21:51 +08:00
|
|
|
const { map, currentNodeId } = state;
|
|
|
|
|
const reachableChildren = getReachableChildren(state);
|
2026-04-14 13:51:11 +08:00
|
|
|
const reachableIds = new Set(reachableChildren.map(n => n.id));
|
|
|
|
|
|
|
|
|
|
// Draw edges
|
|
|
|
|
graphics.lineStyle(2, 0x666666);
|
|
|
|
|
for (const [nodeId, node] of map.nodes) {
|
|
|
|
|
const posX = this.getNodeX(node);
|
|
|
|
|
const posY = this.getNodeY(node);
|
|
|
|
|
|
|
|
|
|
for (const childId of node.childIds) {
|
|
|
|
|
const child = map.nodes.get(childId);
|
|
|
|
|
if (child) {
|
|
|
|
|
const childX = this.getNodeX(child);
|
|
|
|
|
const childY = this.getNodeY(child);
|
|
|
|
|
graphics.lineBetween(posX, posY, childX, childY);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw nodes
|
|
|
|
|
for (const [nodeId, node] of map.nodes) {
|
|
|
|
|
const posX = this.getNodeX(node);
|
|
|
|
|
const posY = this.getNodeY(node);
|
|
|
|
|
const isCurrent = nodeId === currentNodeId;
|
|
|
|
|
const isReachable = reachableIds.has(nodeId);
|
|
|
|
|
const baseColor = NODE_COLORS[node.type] ?? 0x888888;
|
|
|
|
|
|
|
|
|
|
// Node circle
|
|
|
|
|
const nodeGraphics = this.add.graphics();
|
|
|
|
|
this.mapContainer.add(nodeGraphics);
|
|
|
|
|
this.nodeGraphics.set(nodeId, nodeGraphics);
|
|
|
|
|
|
|
|
|
|
const color = isCurrent ? 0xffffff : (isReachable ? this.brightenColor(baseColor) : baseColor);
|
|
|
|
|
nodeGraphics.fillStyle(color);
|
|
|
|
|
nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
|
|
|
|
|
|
|
|
|
|
if (isCurrent) {
|
|
|
|
|
nodeGraphics.lineStyle(3, 0xffff44);
|
|
|
|
|
} else if (isReachable) {
|
|
|
|
|
nodeGraphics.lineStyle(2, 0xaaddaa);
|
|
|
|
|
} else {
|
|
|
|
|
nodeGraphics.lineStyle(2, 0x888888);
|
|
|
|
|
}
|
|
|
|
|
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
|
|
|
|
|
|
|
|
|
|
// Node label
|
|
|
|
|
const label = NODE_LABELS[node.type] ?? node.type;
|
|
|
|
|
this.mapContainer.add(
|
|
|
|
|
this.add.text(posX, posY, label, {
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
color: '#ffffff',
|
|
|
|
|
fontStyle: isCurrent ? 'bold' : 'normal',
|
|
|
|
|
}).setOrigin(0.5)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Encounter name
|
|
|
|
|
if (node.encounter) {
|
|
|
|
|
this.mapContainer.add(
|
|
|
|
|
this.add.text(posX, posY + this.NODE_RADIUS + 12, node.encounter.name, {
|
|
|
|
|
fontSize: '10px',
|
|
|
|
|
color: '#cccccc',
|
|
|
|
|
}).setOrigin(0.5)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 14:21:51 +08:00
|
|
|
// Make reachable nodes interactive (add hitZone to mapContainer so positions match)
|
2026-04-14 13:51:11 +08:00
|
|
|
if (isReachable) {
|
|
|
|
|
const hitZone = this.add.circle(posX, posY, this.NODE_RADIUS, 0x000000, 0)
|
|
|
|
|
.setInteractive({ useHandCursor: true });
|
2026-04-14 14:21:51 +08:00
|
|
|
this.mapContainer.add(hitZone);
|
2026-04-14 13:51:11 +08:00
|
|
|
|
|
|
|
|
hitZone.on('pointerover', () => {
|
|
|
|
|
this.hoveredNode = nodeId;
|
|
|
|
|
nodeGraphics.clear();
|
|
|
|
|
nodeGraphics.fillStyle(this.brightenColor(baseColor));
|
|
|
|
|
nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
|
|
|
|
|
nodeGraphics.lineStyle(3, 0xaaddaa);
|
|
|
|
|
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
hitZone.on('pointerout', () => {
|
|
|
|
|
this.hoveredNode = null;
|
|
|
|
|
nodeGraphics.clear();
|
|
|
|
|
nodeGraphics.fillStyle(baseColor);
|
|
|
|
|
nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
|
|
|
|
|
nodeGraphics.lineStyle(2, 0xaaddaa);
|
|
|
|
|
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
hitZone.on('pointerdown', () => {
|
|
|
|
|
this.onNodeClick(nodeId);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Setup drag-to-scroll
|
|
|
|
|
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
|
|
|
|
this.isDragging = true;
|
|
|
|
|
this.dragStartX = pointer.x;
|
|
|
|
|
this.dragStartY = pointer.y;
|
|
|
|
|
this.dragStartContainerX = this.mapContainer.x;
|
|
|
|
|
this.dragStartContainerY = this.mapContainer.y;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
|
|
|
|
|
if (!this.isDragging) return;
|
|
|
|
|
this.mapContainer.x = this.dragStartContainerX + (pointer.x - this.dragStartX);
|
|
|
|
|
this.mapContainer.y = this.dragStartContainerY + (pointer.y - this.dragStartY);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.input.on('pointerup', () => {
|
|
|
|
|
this.isDragging = false;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.input.on('pointerout', () => {
|
|
|
|
|
this.isDragging = false;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Hint text
|
|
|
|
|
this.add.text(width / 2, this.scale.height - 20, '点击可到达的节点进入遭遇 | 拖拽滚动查看地图', {
|
|
|
|
|
fontSize: '14px',
|
|
|
|
|
color: '#888888',
|
|
|
|
|
}).setOrigin(0.5).setDepth(200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async onNodeClick(nodeId: string): Promise<void> {
|
2026-04-14 14:21:51 +08:00
|
|
|
const state = this.gameState.value;
|
|
|
|
|
if (!canMoveTo(state, nodeId)) {
|
2026-04-14 13:51:11 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Move to target node
|
2026-04-14 14:21:51 +08:00
|
|
|
const result = moveToNode(state, nodeId);
|
2026-04-14 13:51:11 +08:00
|
|
|
if (!result.success) {
|
|
|
|
|
console.warn(`无法移动到节点: ${result.reason}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update visuals
|
|
|
|
|
this.updateHUD();
|
|
|
|
|
this.redrawMapHighlights();
|
|
|
|
|
|
|
|
|
|
// Check if at end node
|
2026-04-14 14:21:51 +08:00
|
|
|
if (isAtEndNode(state)) {
|
2026-04-14 13:51:11 +08:00
|
|
|
this.showEndScreen();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Launch encounter scene
|
2026-04-14 14:21:51 +08:00
|
|
|
const currentNode = getCurrentNode(state);
|
2026-04-14 13:51:11 +08:00
|
|
|
if (!currentNode || !currentNode.encounter) {
|
2026-04-14 14:21:51 +08:00
|
|
|
console.warn('当前节点没有遭遇数据');
|
2026-04-14 13:51:11 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.sceneController.launch('PlaceholderEncounterScene');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private redrawMapHighlights(): void {
|
2026-04-14 14:21:51 +08:00
|
|
|
const state = this.gameState.value;
|
|
|
|
|
const { map, currentNodeId } = state;
|
|
|
|
|
const reachableChildren = getReachableChildren(state);
|
2026-04-14 13:51:11 +08:00
|
|
|
const reachableIds = new Set(reachableChildren.map(n => n.id));
|
|
|
|
|
|
|
|
|
|
for (const [nodeId, nodeGraphics] of this.nodeGraphics) {
|
|
|
|
|
const node = map.nodes.get(nodeId);
|
|
|
|
|
if (!node) continue;
|
|
|
|
|
|
|
|
|
|
const isCurrent = nodeId === currentNodeId;
|
|
|
|
|
const isReachable = reachableIds.has(nodeId);
|
|
|
|
|
const baseColor = NODE_COLORS[node.type] ?? 0x888888;
|
|
|
|
|
|
|
|
|
|
nodeGraphics.clear();
|
|
|
|
|
const color = isCurrent ? 0xffffff : (isReachable ? this.brightenColor(baseColor) : baseColor);
|
|
|
|
|
nodeGraphics.fillStyle(color);
|
|
|
|
|
nodeGraphics.fillCircle(this.getNodeX(node), this.getNodeY(node), this.NODE_RADIUS);
|
|
|
|
|
|
|
|
|
|
if (isCurrent) {
|
|
|
|
|
nodeGraphics.lineStyle(3, 0xffff44);
|
|
|
|
|
} else if (isReachable) {
|
|
|
|
|
nodeGraphics.lineStyle(2, 0xaaddaa);
|
|
|
|
|
} else {
|
|
|
|
|
nodeGraphics.lineStyle(2, 0x888888);
|
|
|
|
|
}
|
|
|
|
|
nodeGraphics.strokeCircle(this.getNodeX(node), this.getNodeY(node), this.NODE_RADIUS);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private showEndScreen(): void {
|
|
|
|
|
const { width, height } = this.scale;
|
2026-04-14 14:21:51 +08:00
|
|
|
const state = this.gameState.value;
|
2026-04-14 13:51:11 +08:00
|
|
|
|
|
|
|
|
// Overlay
|
|
|
|
|
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7).setDepth(300);
|
|
|
|
|
|
|
|
|
|
// End message
|
|
|
|
|
this.add.text(width / 2, height / 2 - 40, '恭喜通关!', {
|
|
|
|
|
fontSize: '36px',
|
|
|
|
|
color: '#ffcc44',
|
|
|
|
|
fontStyle: 'bold',
|
|
|
|
|
}).setOrigin(0.5).setDepth(300);
|
|
|
|
|
|
2026-04-14 14:21:51 +08:00
|
|
|
const { player } = state;
|
2026-04-14 13:51:11 +08:00
|
|
|
this.add.text(width / 2, height / 2 + 20, `剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`, {
|
|
|
|
|
fontSize: '20px',
|
|
|
|
|
color: '#ffffff',
|
|
|
|
|
align: 'center',
|
|
|
|
|
}).setOrigin(0.5).setDepth(300);
|
|
|
|
|
|
|
|
|
|
this.createButton('返回菜单', width / 2, height / 2 + 100, 200, 50, async () => {
|
|
|
|
|
await this.sceneController.launch('IndexScene');
|
|
|
|
|
}, 300);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getNodeX(node: MapNode): number {
|
2026-04-14 14:21:51 +08:00
|
|
|
// Layers go left-to-right along X axis
|
|
|
|
|
return -500 + node.layerIndex * this.LAYER_HEIGHT;
|
2026-04-14 13:51:11 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getNodeY(node: MapNode): number {
|
2026-04-14 14:21:51 +08:00
|
|
|
// Nodes within a layer are spread vertically along Y axis
|
|
|
|
|
const state = this.gameState.value;
|
|
|
|
|
const layer = state.map.layers[node.layerIndex];
|
|
|
|
|
const nodeIndex = layer.nodeIds.indexOf(node.id);
|
|
|
|
|
const totalNodes = layer.nodeIds.length;
|
|
|
|
|
const layerHeight = (totalNodes - 1) * this.NODE_SPACING;
|
|
|
|
|
return -layerHeight / 2 + nodeIndex * this.NODE_SPACING;
|
2026-04-14 13:51:11 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private brightenColor(color: number): number {
|
|
|
|
|
// Simple color brightening
|
|
|
|
|
const r = Math.min(255, ((color >> 16) & 0xff) + 40);
|
|
|
|
|
const g = Math.min(255, ((color >> 8) & 0xff) + 40);
|
|
|
|
|
const b = Math.min(255, (color & 0xff) + 40);
|
|
|
|
|
return (r << 16) | (g << 8) | b;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createButton(
|
|
|
|
|
label: string,
|
|
|
|
|
x: number,
|
|
|
|
|
y: number,
|
|
|
|
|
width: number,
|
|
|
|
|
height: number,
|
|
|
|
|
onClick: () => void,
|
|
|
|
|
depth: number = 200
|
|
|
|
|
): void {
|
|
|
|
|
const bg = this.add.rectangle(x, y, width, height, 0x444466)
|
|
|
|
|
.setStrokeStyle(2, 0x7777aa)
|
|
|
|
|
.setInteractive({ useHandCursor: true })
|
|
|
|
|
.setDepth(depth);
|
|
|
|
|
|
|
|
|
|
const text = this.add.text(x, y, label, {
|
|
|
|
|
fontSize: '16px',
|
|
|
|
|
color: '#ffffff',
|
|
|
|
|
}).setOrigin(0.5).setDepth(depth);
|
|
|
|
|
|
|
|
|
|
bg.on('pointerover', () => {
|
|
|
|
|
bg.setFillStyle(0x555588);
|
|
|
|
|
text.setScale(1.05);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
bg.on('pointerout', () => {
|
|
|
|
|
bg.setFillStyle(0x444466);
|
|
|
|
|
text.setScale(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
bg.on('pointerdown', onClick);
|
|
|
|
|
}
|
|
|
|
|
}
|