feat: add game process scene

This commit is contained in:
hypercross 2026-04-14 13:51:11 +08:00
parent 65684e6cf5
commit 7fb9edcbf0
4 changed files with 634 additions and 3 deletions

View File

@ -0,0 +1,437 @@
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);
}
}

View File

@ -27,9 +27,10 @@ export class IndexScene extends ReactiveScene {
// Buttons // Buttons
const buttons = [ const buttons = [
{ label: 'Map Viewer', scene: 'MapViewerScene', y: centerY - 20 }, { label: '开始游戏', scene: 'GameFlowScene', y: centerY - 70 },
{ label: 'Grid Inventory Viewer', scene: 'GridViewerScene', y: centerY + 50 }, { label: 'Map Viewer', scene: 'MapViewerScene', y: centerY },
{ label: 'Shape Viewer', scene: 'ShapeViewerScene', y: centerY + 120 }, { label: 'Grid Inventory Viewer', scene: 'GridViewerScene', y: centerY + 70 },
{ label: 'Shape Viewer', scene: 'ShapeViewerScene', y: centerY + 140 },
]; ];
for (const btn of buttons) { for (const btn of buttons) {

View File

@ -0,0 +1,187 @@
import Phaser from 'phaser';
import { ReactiveScene } from 'boardgame-phaser';
import {
resolveEncounter,
type RunState,
type EncounterResult,
type MapNodeType,
} from 'boardgame-core/samples/slay-the-spire-like';
/** 遭遇场景接收的数据 */
export interface EncounterData {
runState: RunState;
nodeId: string;
encounter: { type: MapNodeType; name: string; description: string };
onComplete: (result: EncounterResult) => void;
}
/**
*
*
* "完成遭遇"
*
* encounter.type
* - MapNodeType.Minion / Elite CombatEncounterScene
* - MapNodeType.Shop ShopEncounterScene
* - MapNodeType.Camp CampEncounterScene
* - MapNodeType.Event EventEncounterScene
* - MapNodeType.Curio CurioEncounterScene
*/
export class PlaceholderEncounterScene extends ReactiveScene<EncounterData> {
constructor() {
super('PlaceholderEncounterScene');
}
create(): void {
super.create();
const { width, height } = this.scale;
const centerX = width / 2;
const centerY = height / 2;
const { encounter, nodeId } = this.initData;
// Title
this.add.text(centerX, centerY - 150, '遭遇', {
fontSize: '32px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
// Encounter type badge
const typeLabel = this.getEncounterTypeLabel(encounter.type);
const typeBg = this.add.rectangle(centerX, centerY - 80, 120, 36, this.getEncounterTypeColor(encounter.type));
this.add.text(centerX, centerY - 80, typeLabel, {
fontSize: '16px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
// Encounter name
this.add.text(centerX, centerY - 30, encounter.name, {
fontSize: '24px',
color: '#ffffff',
}).setOrigin(0.5);
// Encounter description
this.add.text(centerX, centerY + 20, encounter.description || '(暂无描述)', {
fontSize: '16px',
color: '#aaaaaa',
wordWrap: { width: 600 },
align: 'center',
}).setOrigin(0.5);
// Node ID info
this.add.text(centerX, centerY + 80, `节点: ${nodeId}`, {
fontSize: '12px',
color: '#666666',
}).setOrigin(0.5);
// Placeholder notice
this.add.text(centerX, centerY + 130, '(此为占位符遭遇,后续将替换为真实遭遇场景)', {
fontSize: '14px',
color: '#ff8844',
fontStyle: 'italic',
}).setOrigin(0.5);
// Complete button
this.createButton('完成遭遇', centerX, centerY + 200, 200, 50, async () => {
await this.completeEncounter();
});
// Back button (without resolving)
this.createButton('暂不处理', centerX, centerY + 270, 200, 40, async () => {
await this.sceneController.launch('GameFlowScene');
});
}
private async completeEncounter(): Promise<void> {
const { runState, nodeId, encounter, onComplete } = this.initData;
// 生成模拟遭遇结果
const result: EncounterResult = this.generatePlaceholderResult(encounter.type);
// 调用进度管理器结算遭遇
resolveEncounter(runState, result);
// 回调通知上层
onComplete(result);
// 返回游戏流程场景
await this.sceneController.launch('GameFlowScene');
}
private generatePlaceholderResult(type: MapNodeType): EncounterResult {
// 根据遭遇类型生成不同的模拟结果
switch (type) {
case 'minion':
case 'elite':
return { hpLost: type === 'elite' ? 15 : 8, goldEarned: type === 'elite' ? 30 : 15 };
case 'camp':
return { hpGained: 15 };
case 'shop':
return { goldEarned: 0 };
case 'curio':
case 'event':
return { goldEarned: 20 };
default:
return {};
}
}
private getEncounterTypeLabel(type: MapNodeType): string {
const labels: Record<MapNodeType, string> = {
start: '起点',
end: '终点',
minion: '战斗',
elite: '精英战斗',
event: '事件',
camp: '营地',
shop: '商店',
curio: '奇遇',
};
return labels[type] ?? type;
}
private getEncounterTypeColor(type: MapNodeType): number {
const colors: Record<MapNodeType, number> = {
start: 0x44aa44,
end: 0xcc8844,
minion: 0xcc4444,
elite: 0xcc44cc,
event: 0xaaaa44,
camp: 0x44cccc,
shop: 0x4488cc,
curio: 0x8844cc,
};
return colors[type] ?? 0x888888;
}
private createButton(
label: string,
x: number,
y: number,
width: number,
height: number,
onClick: () => void
): void {
const bg = this.add.rectangle(x, y, width, height, 0x444466)
.setStrokeStyle(2, 0x7777aa)
.setInteractive({ useHandCursor: true });
const text = this.add.text(x, y, label, {
fontSize: '16px',
color: '#ffffff',
}).setOrigin(0.5);
bg.on('pointerover', () => {
bg.setFillStyle(0x555588);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(0x444466);
text.setScale(1);
});
bg.on('pointerdown', onClick);
}
}

View File

@ -5,12 +5,16 @@ import { IndexScene } from '@/scenes/IndexScene';
import { MapViewerScene } from '@/scenes/MapViewerScene'; import { MapViewerScene } from '@/scenes/MapViewerScene';
import { GridViewerScene } from '@/scenes/GridViewerScene'; import { GridViewerScene } from '@/scenes/GridViewerScene';
import { ShapeViewerScene } from '@/scenes/ShapeViewerScene'; import { ShapeViewerScene } from '@/scenes/ShapeViewerScene';
import { GameFlowScene } from '@/scenes/GameFlowScene';
import { PlaceholderEncounterScene } from '@/scenes/PlaceholderEncounterScene';
export default function App() { export default function App() {
const indexScene = useMemo(() => new IndexScene(), []); const indexScene = useMemo(() => new IndexScene(), []);
const mapViewerScene = useMemo(() => new MapViewerScene(), []); const mapViewerScene = useMemo(() => new MapViewerScene(), []);
const gridViewerScene = useMemo(() => new GridViewerScene(), []); const gridViewerScene = useMemo(() => new GridViewerScene(), []);
const shapeViewerScene = useMemo(() => new ShapeViewerScene(), []); const shapeViewerScene = useMemo(() => new ShapeViewerScene(), []);
const gameFlowScene = useMemo(() => new GameFlowScene(), []);
const placeholderEncounterScene = useMemo(() => new PlaceholderEncounterScene(), []);
return ( return (
<div className="flex flex-col h-screen"> <div className="flex flex-col h-screen">
@ -20,6 +24,8 @@ export default function App() {
<PhaserScene sceneKey="MapViewerScene" scene={mapViewerScene} /> <PhaserScene sceneKey="MapViewerScene" scene={mapViewerScene} />
<PhaserScene sceneKey="GridViewerScene" scene={gridViewerScene} /> <PhaserScene sceneKey="GridViewerScene" scene={gridViewerScene} />
<PhaserScene sceneKey="ShapeViewerScene" scene={shapeViewerScene} /> <PhaserScene sceneKey="ShapeViewerScene" scene={shapeViewerScene} />
<PhaserScene sceneKey="GameFlowScene" scene={gameFlowScene} />
<PhaserScene sceneKey="PlaceholderEncounterScene" scene={placeholderEncounterScene} />
</PhaserGame> </PhaserGame>
</div> </div>
</div> </div>