refactor: clean up scenes and introduce SceneKey enum

- Remove redundant `GameFlowScene` and `PlaceholderEncounterScene`
- Introduce `SceneKey` enum for type-safe scene management
- Clean up unused imports and configuration references in existing
  scenes
- Standardize scene navigation using `SceneKey`
This commit is contained in:
hypercross 2026-04-20 15:36:12 +08:00
parent 1d803dd219
commit 033a8e4a40
7 changed files with 32 additions and 652 deletions

View File

@ -1,439 +0,0 @@
import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { UI_CONFIG, MAP_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
import { MutableSignal } from "boardgame-core";
import {
canMoveTo,
moveToNode,
getCurrentNode,
getReachableChildren,
isAtEndNode,
type RunState,
type MapNode,
} from "boardgame-core/samples/slay-the-spire-like";
export class GameFlowScene extends ReactiveScene {
/** 全局游戏状态(由 App.tsx 注入) */
private gameState: MutableSignal<RunState>;
// 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(gameState: MutableSignal<RunState>) {
super("GameFlowScene");
this.gameState = gameState;
}
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
createButton({
scene: this,
label: "返回菜单",
x: width - 100,
y: 25,
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
onClick: async () => {
await this.sceneController.launch("IndexScene");
},
depth: 200,
});
}
private updateHUD(): void {
const state = this.gameState.value;
const { player, currentNodeId, map } = state;
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;
const state = this.gameState.value;
const {
LAYER_HEIGHT,
NODE_SPACING,
NODE_RADIUS,
MAX_NODES_PER_LAYER,
TOTAL_LAYERS,
} = MAP_CONFIG;
// Calculate map bounds (left-to-right: layers along X, nodes along Y)
const maxLayer = TOTAL_LAYERS - 1;
const mapWidth = maxLayer * LAYER_HEIGHT + 200;
const mapHeight = (MAX_NODES_PER_LAYER - 1) * NODE_SPACING + 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 } = state;
const reachableChildren = getReachableChildren(state);
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, NODE_RADIUS);
if (isCurrent) {
nodeGraphics.lineStyle(3, 0xffff44);
} else if (isReachable) {
nodeGraphics.lineStyle(2, 0xaaddaa);
} else {
nodeGraphics.lineStyle(2, 0x888888);
}
nodeGraphics.strokeCircle(posX, posY, 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 + 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, NODE_RADIUS, 0x000000, 0)
.setInteractive({ useHandCursor: true });
this.mapContainer.add(hitZone);
hitZone.on("pointerover", () => {
this.hoveredNode = nodeId;
nodeGraphics.clear();
nodeGraphics.fillStyle(this.brightenColor(baseColor));
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
nodeGraphics.lineStyle(3, 0xaaddaa);
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
});
hitZone.on("pointerout", () => {
this.hoveredNode = null;
nodeGraphics.clear();
nodeGraphics.fillStyle(baseColor);
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
nodeGraphics.lineStyle(2, 0xaaddaa);
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
});
hitZone.on("pointerdown", () => {
this.onNodeClick(nodeId);
});
}
}
// Setup drag-to-scroll with disposables cleanup
const onPointerDown = (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;
};
const onPointerMove = (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);
};
const onPointerUp = () => {
this.isDragging = false;
};
this.input.on("pointerdown", onPointerDown);
this.input.on("pointermove", onPointerMove);
this.input.on("pointerup", onPointerUp);
this.input.on("pointerout", onPointerUp);
this.disposables.add(() => {
this.input.off("pointerdown", onPointerDown);
this.input.off("pointermove", onPointerMove);
this.input.off("pointerup", onPointerUp);
this.input.off("pointerout", onPointerUp);
});
// 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> {
const state = this.gameState.value;
if (!canMoveTo(state, nodeId)) {
return;
}
// Move to target node
const result = moveToNode(state, nodeId);
if (!result.success) {
console.warn(`无法移动到节点: ${result.reason}`);
return;
}
// Update visuals
this.updateHUD();
this.redrawMapHighlights();
// Check if at end node
if (isAtEndNode(state)) {
this.showEndScreen();
return;
}
// Launch encounter scene
const currentNode = getCurrentNode(state);
if (!currentNode || !currentNode.encounter) {
console.warn("当前节点没有遭遇数据");
return;
}
await this.sceneController.launch("PlaceholderEncounterScene");
}
private redrawMapHighlights(): void {
const state = this.gameState.value;
const { map, currentNodeId } = state;
const reachableChildren = getReachableChildren(state);
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),
MAP_CONFIG.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),
MAP_CONFIG.NODE_RADIUS,
);
}
}
private showEndScreen(): void {
const { width, height } = this.scale;
const state = this.gameState.value;
// 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 } = state;
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);
createButton({
scene: this,
label: "返回菜单",
x: width / 2,
y: height / 2 + 100,
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
onClick: async () => {
await this.sceneController.launch("IndexScene");
},
depth: 300,
});
}
private getNodeX(node: MapNode): number {
return -500 + node.layerIndex * MAP_CONFIG.LAYER_HEIGHT;
}
private getNodeY(node: MapNode): number {
const layer = this.gameState.value.map.layers[node.layerIndex];
const nodeIndex = layer.nodeIds.indexOf(node.id);
const totalNodes = layer.nodeIds.length;
const layerHeight = (totalNodes - 1) * MAP_CONFIG.NODE_SPACING;
return -layerHeight / 2 + nodeIndex * MAP_CONFIG.NODE_SPACING;
}
private brightenColor(color: number): number {
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;
}
}

View File

@ -1,7 +1,6 @@
import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { GRID_CONFIG, UI_CONFIG, ITEM_COLORS } from "@/config";
import { GRID_CONFIG, ITEM_COLORS } from "@/config";
import {
createGridInventory,
placeItem,
@ -12,6 +11,7 @@ import {
type InventoryItem,
type GameItemMeta,
} from "boardgame-core/samples/slay-the-spire-like";
import { SceneKey } from "./types";
export class GridViewerScene extends ReactiveScene {
private inventory: GridInventory<GameItemMeta>;
@ -57,7 +57,7 @@ export class GridViewerScene extends ReactiveScene {
}
private placeSampleItems(): void {
const items = data.desert.items;
const items = data.desert.getItems();
const sampleItems = [
{ index: 0, x: 0, y: 0 },
{ index: 3, x: 3, y: 0 },
@ -207,7 +207,7 @@ export class GridViewerScene extends ReactiveScene {
x: 100,
y: 40,
onClick: async () => {
await this.sceneController.launch("IndexScene");
await this.sceneController.launch(SceneKey.IndexScene);
},
});
@ -242,7 +242,7 @@ export class GridViewerScene extends ReactiveScene {
GRID_CONFIG.WIDTH,
GRID_CONFIG.HEIGHT,
);
const items = data.desert.items;
const items = data.desert.getItems();
let itemIndex = 0;
for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) {

View File

@ -2,6 +2,7 @@ import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { UI_CONFIG } from "@/config";
import { SceneKey } from "./types";
export class IndexScene extends ReactiveScene {
constructor() {
@ -32,15 +33,22 @@ export class IndexScene extends ReactiveScene {
.setOrigin(0.5);
// Buttons
const buttons = [
{ label: "开始游戏", scene: "GameFlowScene", y: centerY - 70 },
{ label: "Map Viewer", scene: "MapViewerScene", y: centerY },
const buttons: {
label: string;
scene: SceneKey;
y: number;
}[] = [
{ label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY },
{
label: "Grid Inventory Viewer",
scene: "GridViewerScene",
scene: SceneKey.GridViewerScene,
y: centerY + 70,
},
{ label: "Shape Viewer", scene: "ShapeViewerScene", y: centerY + 140 },
{
label: "Shape Viewer",
scene: SceneKey.ShapeViewerScene,
y: centerY + 140,
},
];
for (const btn of buttons) {
@ -50,7 +58,7 @@ export class IndexScene extends ReactiveScene {
private createButton(
label: string,
targetScene: string,
targetScene: SceneKey,
x: number,
y: number,
): void {

View File

@ -10,6 +10,7 @@ import {
type MapNode,
type MapNodeType,
} from "boardgame-core/samples/slay-the-spire-like";
import { SceneKey } from "./types";
export class MapViewerScene extends ReactiveScene {
private map: PointCrawlMap | null = null;
@ -57,7 +58,7 @@ export class MapViewerScene extends ReactiveScene {
x: 100,
y: 40,
onClick: async () => {
await this.sceneController.launch("IndexScene");
await this.sceneController.launch(SceneKey.IndexScene);
},
depth: 100,
});
@ -113,7 +114,7 @@ export class MapViewerScene extends ReactiveScene {
private drawMap(): void {
const rng = createRNG(this.seed);
this.map = generatePointCrawlMap(rng, data.desert.encounters);
this.map = generatePointCrawlMap(rng, data.desert.getEncounters());
const { width, height } = this.scale;
const {

View File

@ -1,196 +0,0 @@
import {
resolveEncounter,
type RunState,
type EncounterResult,
type MapNodeType,
type MapNode,
} from "boardgame-core/samples/slay-the-spire-like";
import { ReactiveScene } from "boardgame-phaser";
import type { MutableSignal } from "boardgame-core";
import { UI_CONFIG, GRID_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
import { createButton } from "@/utils/createButton";
/**
*
*/
export class PlaceholderEncounterScene extends ReactiveScene {
private gameState: MutableSignal<RunState>;
constructor(gameState: MutableSignal<RunState>) {
super("PlaceholderEncounterScene");
this.gameState = gameState;
}
create(): void {
super.create();
const { width, height } = this.scale;
const state = this.gameState.value;
const gridCols = state.inventory.width;
const gridRows = state.inventory.height;
const cellSize = GRID_CONFIG.WIDGET_CELL_SIZE;
const gridW = gridCols * cellSize + (gridCols - 1) * GRID_CONFIG.GRID_GAP;
const gridH = gridRows * cellSize + (gridRows - 1) * GRID_CONFIG.GRID_GAP;
const leftPanelW = gridW + 40;
this.cameras.main.setBounds(0, 0, width, height);
this.cameras.main.setScroll(0, 0);
// "背包" title
this.add
.text(60 + gridW / 2, (height - gridH) / 2, "背包", {
fontSize: "22px",
color: "#ffffff",
fontStyle: "bold",
})
.setOrigin(0.5);
const node = state.map.nodes.get(state.currentNodeId);
if (!node || !node.encounter) {
const rightX = leftPanelW + 80;
this.add
.text(rightX + 300, height / 2, "没有遭遇数据", {
fontSize: "24px",
color: "#ff4444",
})
.setOrigin(0.5);
return;
}
this.drawRightPanel(
node as MapNode & { encounter: { name: string; description: string } },
leftPanelW,
width,
height,
);
}
private drawRightPanel(
node: MapNode & { encounter: { name: string; description: string } },
leftPanelW: number,
width: number,
height: number,
): void {
const encounter = {
type: node.type as MapNodeType,
name: node.encounter.name,
description: node.encounter.description,
};
const nodeId = node.id as string;
const rightX = leftPanelW + 60;
const rightW = width - rightX - 40;
const cx = rightX + rightW / 2;
const cy = height / 2;
this.add
.text(cx, cy - 180, "遭遇", {
fontSize: "36px",
color: "#fff",
fontStyle: "bold",
})
.setOrigin(0.5);
const typeLabel = this.getTypeLabel(encounter.type);
const badgeColor = this.getTypeColor(encounter.type);
this.add.rectangle(cx, cy - 110, 140, 40, badgeColor);
this.add
.text(cx, cy - 110, typeLabel, {
fontSize: "18px",
color: "#fff",
fontStyle: "bold",
})
.setOrigin(0.5);
this.add
.text(cx, cy - 50, encounter.name, {
fontSize: "28px",
color: "#fff",
})
.setOrigin(0.5);
this.add
.text(cx, cy + 10, encounter.description || "(暂无描述)", {
fontSize: "18px",
color: "#bbb",
wordWrap: { width: rightW - 40 },
align: "center",
})
.setOrigin(0.5);
this.add
.text(cx, cy + 80, `节点: ${nodeId}`, {
fontSize: "14px",
color: "#666",
})
.setOrigin(0.5);
this.add
.text(cx, cy + 130, "(此为占位符遭遇,后续将替换为真实遭遇场景)", {
fontSize: "14px",
color: "#ff8844",
fontStyle: "italic",
})
.setOrigin(0.5);
createButton({
scene: this,
label: "完成遭遇",
x: cx,
y: cy + 200,
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
onClick: async () => {
await this.completeEncounter();
},
});
createButton({
scene: this,
label: "暂不处理",
x: cx,
y: cy + 270,
onClick: async () => {
await this.sceneController.launch("GameFlowScene");
},
});
}
private getTypeLabel(type: MapNodeType): string {
return NODE_LABELS[type] ?? type;
}
private getTypeColor(type: MapNodeType): number {
return NODE_COLORS[type] ?? 0x888888;
}
private async completeEncounter(): Promise<void> {
const state = this.gameState.value;
const node = state.map.nodes.get(state.currentNodeId);
if (!node || !node.encounter) return;
const result: EncounterResult = this.generatePlaceholderResult(node.type);
resolveEncounter(state, result);
await this.sceneController.launch("GameFlowScene");
}
private generatePlaceholderResult(type: MapNodeType): EncounterResult {
switch (type) {
case "minion":
return { hpLost: 8, goldEarned: 15 };
case "elite":
return { hpLost: 15, goldEarned: 30 };
case "camp":
return { hpGained: 15 };
case "shop":
return { goldEarned: 0 };
case "curio":
case "event":
return { goldEarned: 20 };
default:
return {};
}
}
}

View File

@ -1,12 +1,12 @@
import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { UI_CONFIG, SHAPE_CONFIG, NODE_LABELS, NODE_COLORS } from "@/config";
import { SHAPE_CONFIG } from "@/config";
import {
parseShapeString,
data,
type ParsedShape,
} from "boardgame-core/samples/slay-the-spire-like";
import { SceneKey } from "./types";
export class ShapeViewerScene extends ReactiveScene {
constructor() {
@ -40,7 +40,7 @@ export class ShapeViewerScene extends ReactiveScene {
const startY = 80;
const { SPACING_X, SPACING_Y, ITEMS_PER_ROW, MAX_ITEMS } = SHAPE_CONFIG;
const itemsToShow = data.desert.items.slice(0, MAX_ITEMS);
const itemsToShow = data.desert.getItems().slice(0, MAX_ITEMS);
for (let i = 0; i < itemsToShow.length; i++) {
const itemData = itemsToShow[i];
@ -145,7 +145,7 @@ export class ShapeViewerScene extends ReactiveScene {
x: 100,
y: height - 40,
onClick: async () => {
await this.sceneController.launch("IndexScene");
await this.sceneController.launch(SceneKey.IndexScene);
},
});

View File

@ -0,0 +1,6 @@
export enum SceneKey {
GridViewerScene = "GridViewerScene",
IndexScene = "IndexScene",
MapViewerScene = "MapViewerScene",
ShapeViewerScene = "ShapeViewerScene",
}