boardgame-phaser/packages/sts-like-viewer/src/scenes/GameFlowScene.ts

438 lines
13 KiB
TypeScript
Raw Normal View History

2026-04-14 13:51:11 +08:00
import Phaser from 'phaser';
import { ReactiveScene } from 'boardgame-phaser';
import {
createRunState,
canMoveTo,
moveToNode,
getCurrentNode,
getReachableChildren,
isAtEndNode,
type RunState,
type MapNode,
type EncounterResult,
type EncounterState,
} from 'boardgame-core/samples/slay-the-spire-like';
import { PlaceholderEncounterScene, type EncounterData } from './PlaceholderEncounterScene';
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 {
private runState: RunState;
private seed: number;
// 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();
constructor() {
super('GameFlowScene');
this.seed = Date.now();
this.runState = createRunState(this.seed);
}
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 {
const { player, currentNodeId, map } = this.runState;
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;
// Calculate map bounds
const maxLayer = 9;
const maxNodesInLayer = 5;
const mapWidth = (maxNodesInLayer - 1) * this.NODE_SPACING + 200;
const mapHeight = maxLayer * this.LAYER_HEIGHT + 200;
// 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);
const { map, currentNodeId } = this.runState;
const reachableChildren = getReachableChildren(this.runState);
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)
);
}
// Make reachable nodes interactive
if (isReachable) {
const hitZone = this.add.circle(posX, posY, this.NODE_RADIUS, 0x000000, 0)
.setInteractive({ useHandCursor: true });
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> {
if (!canMoveTo(this.runState, nodeId)) {
return;
}
// Move to target node
const result = moveToNode(this.runState, nodeId);
if (!result.success) {
console.warn(`无法移动到节点: ${result.reason}`);
return;
}
// Update visuals
this.updateHUD();
this.redrawMapHighlights();
// Check if at end node
if (isAtEndNode(this.runState)) {
this.showEndScreen();
return;
}
// Launch encounter scene
const currentNode = getCurrentNode(this.runState);
if (!currentNode || !currentNode.encounter) {
return;
}
// Create encounter data
const encounterData: EncounterData = {
runState: this.runState,
nodeId: currentNode.id,
encounter: {
type: currentNode.type,
name: currentNode.encounter.name,
description: currentNode.encounter.description,
},
onComplete: (result: EncounterResult) => {
// Encounter completed, update HUD
this.updateHUD();
this.redrawMapHighlights();
},
};
// Re-add encounter scene with new data
const phaserGame = this.phaserGame.value.game;
const encounterScene = new PlaceholderEncounterScene();
if (!phaserGame.scene.getScene('PlaceholderEncounterScene')) {
phaserGame.scene.add('PlaceholderEncounterScene', encounterScene, false, {
...encounterData,
phaserGame: this.phaserGame,
sceneController: this.sceneController,
});
}
await this.sceneController.launch('PlaceholderEncounterScene');
}
private redrawMapHighlights(): void {
const { map, currentNodeId } = this.runState;
const reachableChildren = getReachableChildren(this.runState);
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;
// 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);
const { player } = this.runState;
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 {
const layer = this.runState.map.layers[node.layerIndex];
const nodeIndex = layer.nodeIds.indexOf(node.id);
const totalNodes = layer.nodeIds.length;
const layerWidth = (totalNodes - 1) * this.NODE_SPACING;
return -layerWidth / 2 + nodeIndex * this.NODE_SPACING;
}
private getNodeY(node: MapNode): number {
return -600 + node.layerIndex * this.LAYER_HEIGHT;
}
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);
}
}