Compare commits
3 Commits
7fb9edcbf0
...
706de4e33a
| Author | SHA1 | Date |
|---|---|---|
|
|
706de4e33a | |
|
|
5342f1c09d | |
|
|
3ca2a16e29 |
|
|
@ -7,6 +7,7 @@ type CleanupFn = void | (() => void);
|
||||||
// 前向声明,避免循环导入
|
// 前向声明,避免循环导入
|
||||||
export interface SceneController {
|
export interface SceneController {
|
||||||
launch(sceneKey: string): Promise<void>;
|
launch(sceneKey: string): Promise<void>;
|
||||||
|
restart(): Promise<void>;
|
||||||
currentScene: ReadonlySignal<string | null>;
|
currentScene: ReadonlySignal<string | null>;
|
||||||
isTransitioning: ReadonlySignal<boolean>;
|
isTransitioning: ReadonlySignal<boolean>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { ReactiveScene } from 'boardgame-phaser';
|
import { ReactiveScene } from 'boardgame-phaser';
|
||||||
|
import { MutableSignal } from 'boardgame-core';
|
||||||
import {
|
import {
|
||||||
createRunState,
|
|
||||||
canMoveTo,
|
canMoveTo,
|
||||||
moveToNode,
|
moveToNode,
|
||||||
getCurrentNode,
|
getCurrentNode,
|
||||||
getReachableChildren,
|
getReachableChildren,
|
||||||
isAtEndNode,
|
isAtEndNode,
|
||||||
|
isAtStartNode,
|
||||||
type RunState,
|
type RunState,
|
||||||
type MapNode,
|
type MapNode,
|
||||||
type EncounterResult,
|
|
||||||
type EncounterState,
|
|
||||||
} from 'boardgame-core/samples/slay-the-spire-like';
|
} from 'boardgame-core/samples/slay-the-spire-like';
|
||||||
import { PlaceholderEncounterScene, type EncounterData } from './PlaceholderEncounterScene';
|
|
||||||
|
|
||||||
const NODE_COLORS: Record<string, number> = {
|
const NODE_COLORS: Record<string, number> = {
|
||||||
start: 0x44aa44,
|
start: 0x44aa44,
|
||||||
|
|
@ -37,8 +35,8 @@ const NODE_LABELS: Record<string, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export class GameFlowScene extends ReactiveScene {
|
export class GameFlowScene extends ReactiveScene {
|
||||||
private runState: RunState;
|
/** 全局游戏状态(由 App.tsx 注入) */
|
||||||
private seed: number;
|
private gameState: MutableSignal<RunState>;
|
||||||
|
|
||||||
// Layout constants
|
// Layout constants
|
||||||
private readonly LAYER_HEIGHT = 110;
|
private readonly LAYER_HEIGHT = 110;
|
||||||
|
|
@ -63,10 +61,9 @@ export class GameFlowScene extends ReactiveScene {
|
||||||
private hoveredNode: string | null = null;
|
private hoveredNode: string | null = null;
|
||||||
private nodeGraphics: Map<string, Phaser.GameObjects.Graphics> = new Map();
|
private nodeGraphics: Map<string, Phaser.GameObjects.Graphics> = new Map();
|
||||||
|
|
||||||
constructor() {
|
constructor(gameState: MutableSignal<RunState>) {
|
||||||
super('GameFlowScene');
|
super('GameFlowScene');
|
||||||
this.seed = Date.now();
|
this.gameState = gameState;
|
||||||
this.runState = createRunState(this.seed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
|
|
@ -114,7 +111,8 @@ export class GameFlowScene extends ReactiveScene {
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateHUD(): void {
|
private updateHUD(): void {
|
||||||
const { player, currentNodeId, map } = this.runState;
|
const state = this.gameState.value;
|
||||||
|
const { player, currentNodeId, map } = state;
|
||||||
const currentNode = map.nodes.get(currentNodeId);
|
const currentNode = map.nodes.get(currentNodeId);
|
||||||
|
|
||||||
this.hpText.setText(`HP: ${player.currentHp}/${player.maxHp}`);
|
this.hpText.setText(`HP: ${player.currentHp}/${player.maxHp}`);
|
||||||
|
|
@ -129,12 +127,13 @@ export class GameFlowScene extends ReactiveScene {
|
||||||
|
|
||||||
private drawMap(): void {
|
private drawMap(): void {
|
||||||
const { width, height } = this.scale;
|
const { width, height } = this.scale;
|
||||||
|
const state = this.gameState.value;
|
||||||
|
|
||||||
// Calculate map bounds
|
// Calculate map bounds (left-to-right: layers along X, nodes along Y)
|
||||||
const maxLayer = 9;
|
const maxLayer = 9;
|
||||||
const maxNodesInLayer = 5;
|
const maxNodesInLayer = 5;
|
||||||
const mapWidth = (maxNodesInLayer - 1) * this.NODE_SPACING + 200;
|
const mapWidth = maxLayer * this.LAYER_HEIGHT + 200;
|
||||||
const mapHeight = maxLayer * this.LAYER_HEIGHT + 200;
|
const mapHeight = (maxNodesInLayer - 1) * this.NODE_SPACING + 200;
|
||||||
|
|
||||||
// Create scrollable container
|
// Create scrollable container
|
||||||
this.mapContainer = this.add.container(width / 2, height / 2 + 50);
|
this.mapContainer = this.add.container(width / 2, height / 2 + 50);
|
||||||
|
|
@ -146,8 +145,8 @@ export class GameFlowScene extends ReactiveScene {
|
||||||
const graphics = this.add.graphics();
|
const graphics = this.add.graphics();
|
||||||
this.mapContainer.add(graphics);
|
this.mapContainer.add(graphics);
|
||||||
|
|
||||||
const { map, currentNodeId } = this.runState;
|
const { map, currentNodeId } = state;
|
||||||
const reachableChildren = getReachableChildren(this.runState);
|
const reachableChildren = getReachableChildren(state);
|
||||||
const reachableIds = new Set(reachableChildren.map(n => n.id));
|
const reachableIds = new Set(reachableChildren.map(n => n.id));
|
||||||
|
|
||||||
// Draw edges
|
// Draw edges
|
||||||
|
|
@ -212,10 +211,11 @@ export class GameFlowScene extends ReactiveScene {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make reachable nodes interactive
|
// Make reachable nodes interactive (add hitZone to mapContainer so positions match)
|
||||||
if (isReachable) {
|
if (isReachable) {
|
||||||
const hitZone = this.add.circle(posX, posY, this.NODE_RADIUS, 0x000000, 0)
|
const hitZone = this.add.circle(posX, posY, this.NODE_RADIUS, 0x000000, 0)
|
||||||
.setInteractive({ useHandCursor: true });
|
.setInteractive({ useHandCursor: true });
|
||||||
|
this.mapContainer.add(hitZone);
|
||||||
|
|
||||||
hitZone.on('pointerover', () => {
|
hitZone.on('pointerover', () => {
|
||||||
this.hoveredNode = nodeId;
|
this.hoveredNode = nodeId;
|
||||||
|
|
@ -272,12 +272,13 @@ export class GameFlowScene extends ReactiveScene {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onNodeClick(nodeId: string): Promise<void> {
|
private async onNodeClick(nodeId: string): Promise<void> {
|
||||||
if (!canMoveTo(this.runState, nodeId)) {
|
const state = this.gameState.value;
|
||||||
|
if (!canMoveTo(state, nodeId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to target node
|
// Move to target node
|
||||||
const result = moveToNode(this.runState, nodeId);
|
const result = moveToNode(state, nodeId);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.warn(`无法移动到节点: ${result.reason}`);
|
console.warn(`无法移动到节点: ${result.reason}`);
|
||||||
return;
|
return;
|
||||||
|
|
@ -288,50 +289,25 @@ export class GameFlowScene extends ReactiveScene {
|
||||||
this.redrawMapHighlights();
|
this.redrawMapHighlights();
|
||||||
|
|
||||||
// Check if at end node
|
// Check if at end node
|
||||||
if (isAtEndNode(this.runState)) {
|
if (isAtEndNode(state)) {
|
||||||
this.showEndScreen();
|
this.showEndScreen();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Launch encounter scene
|
// Launch encounter scene
|
||||||
const currentNode = getCurrentNode(this.runState);
|
const currentNode = getCurrentNode(state);
|
||||||
if (!currentNode || !currentNode.encounter) {
|
if (!currentNode || !currentNode.encounter) {
|
||||||
|
console.warn('当前节点没有遭遇数据');
|
||||||
return;
|
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');
|
await this.sceneController.launch('PlaceholderEncounterScene');
|
||||||
}
|
}
|
||||||
|
|
||||||
private redrawMapHighlights(): void {
|
private redrawMapHighlights(): void {
|
||||||
const { map, currentNodeId } = this.runState;
|
const state = this.gameState.value;
|
||||||
const reachableChildren = getReachableChildren(this.runState);
|
const { map, currentNodeId } = state;
|
||||||
|
const reachableChildren = getReachableChildren(state);
|
||||||
const reachableIds = new Set(reachableChildren.map(n => n.id));
|
const reachableIds = new Set(reachableChildren.map(n => n.id));
|
||||||
|
|
||||||
for (const [nodeId, nodeGraphics] of this.nodeGraphics) {
|
for (const [nodeId, nodeGraphics] of this.nodeGraphics) {
|
||||||
|
|
@ -360,6 +336,7 @@ export class GameFlowScene extends ReactiveScene {
|
||||||
|
|
||||||
private showEndScreen(): void {
|
private showEndScreen(): void {
|
||||||
const { width, height } = this.scale;
|
const { width, height } = this.scale;
|
||||||
|
const state = this.gameState.value;
|
||||||
|
|
||||||
// Overlay
|
// Overlay
|
||||||
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7).setDepth(300);
|
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7).setDepth(300);
|
||||||
|
|
@ -371,7 +348,7 @@ export class GameFlowScene extends ReactiveScene {
|
||||||
fontStyle: 'bold',
|
fontStyle: 'bold',
|
||||||
}).setOrigin(0.5).setDepth(300);
|
}).setOrigin(0.5).setDepth(300);
|
||||||
|
|
||||||
const { player } = this.runState;
|
const { player } = state;
|
||||||
this.add.text(width / 2, height / 2 + 20, `剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`, {
|
this.add.text(width / 2, height / 2 + 20, `剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`, {
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
|
|
@ -384,15 +361,18 @@ export class GameFlowScene extends ReactiveScene {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNodeX(node: MapNode): number {
|
private getNodeX(node: MapNode): number {
|
||||||
const layer = this.runState.map.layers[node.layerIndex];
|
// Layers go left-to-right along X axis
|
||||||
const nodeIndex = layer.nodeIds.indexOf(node.id);
|
return -500 + node.layerIndex * this.LAYER_HEIGHT;
|
||||||
const totalNodes = layer.nodeIds.length;
|
|
||||||
const layerWidth = (totalNodes - 1) * this.NODE_SPACING;
|
|
||||||
return -layerWidth / 2 + nodeIndex * this.NODE_SPACING;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNodeY(node: MapNode): number {
|
private getNodeY(node: MapNode): number {
|
||||||
return -600 + node.layerIndex * this.LAYER_HEIGHT;
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
private brightenColor(color: number): number {
|
private brightenColor(color: number): number {
|
||||||
|
|
|
||||||
|
|
@ -116,8 +116,8 @@ export class MapViewerScene extends ReactiveScene {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Legend (bottom-left, fixed)
|
// Legend (bottom-left, fixed)
|
||||||
this.legendContainer = this.add.container(20, this.scale.height - 200).setDepth(100);
|
this.legendContainer = this.add.container(20, this.scale.height - 180).setDepth(100);
|
||||||
const legendBg = this.add.rectangle(75, 90, 150, 180, 0x222222, 0.8);
|
const legendBg = this.add.rectangle(75, 80, 150, 160, 0x222222, 0.8);
|
||||||
this.legendContainer.add(legendBg);
|
this.legendContainer.add(legendBg);
|
||||||
|
|
||||||
this.legendContainer.add(
|
this.legendContainer.add(
|
||||||
|
|
@ -132,11 +132,11 @@ export class MapViewerScene extends ReactiveScene {
|
||||||
this.legendContainer.add(
|
this.legendContainer.add(
|
||||||
this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], { fontSize: '12px', color: '#ffffff' })
|
this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], { fontSize: '12px', color: '#ffffff' })
|
||||||
);
|
);
|
||||||
offsetY += 22;
|
offsetY += 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hint text
|
// Hint text
|
||||||
this.add.text(width / 2, this.scale.height - 20, '拖拽滚动查看地图', {
|
this.add.text(width / 2, this.scale.height - 20, '拖拽滚动查看地图 (从左到右)', {
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#888888',
|
color: '#888888',
|
||||||
}).setOrigin(0.5).setDepth(100);
|
}).setOrigin(0.5).setDepth(100);
|
||||||
|
|
@ -150,11 +150,11 @@ export class MapViewerScene extends ReactiveScene {
|
||||||
// Update title
|
// Update title
|
||||||
this.titleText.setText(`Map Viewer (Seed: ${this.seed})`);
|
this.titleText.setText(`Map Viewer (Seed: ${this.seed})`);
|
||||||
|
|
||||||
// Calculate map bounds
|
// Calculate map bounds (left-to-right: layers along X, nodes along Y)
|
||||||
const maxLayer = 9; // TOTAL_LAYERS - 1 (10 layers: 0-9)
|
const maxLayer = 9; // TOTAL_LAYERS - 1 (10 layers: 0-9)
|
||||||
const maxNodesInLayer = 5; // widest layer (settlement has 4 nodes)
|
const maxNodesInLayer = 5; // widest layer (settlement has 4 nodes)
|
||||||
const mapWidth = (maxNodesInLayer - 1) * this.NODE_SPACING + 200;
|
const mapWidth = maxLayer * this.LAYER_HEIGHT + 200;
|
||||||
const mapHeight = maxLayer * this.LAYER_HEIGHT + 200;
|
const mapHeight = (maxNodesInLayer - 1) * this.NODE_SPACING + 200;
|
||||||
|
|
||||||
// Create scrollable container
|
// Create scrollable container
|
||||||
this.mapContainer = this.add.container(width / 2, height / 2);
|
this.mapContainer = this.add.container(width / 2, height / 2);
|
||||||
|
|
@ -240,14 +240,16 @@ export class MapViewerScene extends ReactiveScene {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNodeX(node: MapNode): number {
|
private getNodeX(node: MapNode): number {
|
||||||
const layer = this.map!.layers[node.layerIndex];
|
// Layers go left-to-right along X axis
|
||||||
const nodeIndex = layer.nodeIds.indexOf(node.id);
|
return -500 + node.layerIndex * this.LAYER_HEIGHT;
|
||||||
const totalNodes = layer.nodeIds.length;
|
|
||||||
const layerWidth = (totalNodes - 1) * this.NODE_SPACING;
|
|
||||||
return -layerWidth / 2 + nodeIndex * this.NODE_SPACING;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNodeY(node: MapNode): number {
|
private getNodeY(node: MapNode): number {
|
||||||
return -600 + node.layerIndex * this.LAYER_HEIGHT;
|
// Nodes within a layer are spread vertically along Y axis
|
||||||
|
const layer = this.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,187 +1,259 @@
|
||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { ReactiveScene } from 'boardgame-phaser';
|
import { ReactiveScene } from 'boardgame-phaser';
|
||||||
|
import { MutableSignal } from 'boardgame-core';
|
||||||
import {
|
import {
|
||||||
resolveEncounter,
|
resolveEncounter,
|
||||||
type RunState,
|
type RunState,
|
||||||
type EncounterResult,
|
type EncounterResult,
|
||||||
type MapNodeType,
|
type MapNodeType,
|
||||||
|
type InventoryItem,
|
||||||
|
type GameItemMeta,
|
||||||
} from 'boardgame-core/samples/slay-the-spire-like';
|
} 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 占位符遭遇场景
|
* 占位符遭遇场景
|
||||||
*
|
*
|
||||||
* 当前实现:显示遭遇信息并提供"完成遭遇"按钮
|
* 左侧显示背包网格(80x80 每格),右侧显示遭遇信息。
|
||||||
*
|
|
||||||
* 后续扩展:根据 encounter.type 路由到不同的专用遭遇场景
|
|
||||||
* - MapNodeType.Minion / Elite → CombatEncounterScene
|
|
||||||
* - MapNodeType.Shop → ShopEncounterScene
|
|
||||||
* - MapNodeType.Camp → CampEncounterScene
|
|
||||||
* - MapNodeType.Event → EventEncounterScene
|
|
||||||
* - MapNodeType.Curio → CurioEncounterScene
|
|
||||||
*/
|
*/
|
||||||
export class PlaceholderEncounterScene extends ReactiveScene<EncounterData> {
|
export class PlaceholderEncounterScene extends ReactiveScene {
|
||||||
constructor() {
|
private gameState: MutableSignal<RunState>;
|
||||||
|
|
||||||
|
// Grid constants
|
||||||
|
private readonly CELL_SIZE = 80;
|
||||||
|
private readonly GRID_GAP = 2;
|
||||||
|
private gridX = 0;
|
||||||
|
private gridY = 0;
|
||||||
|
|
||||||
|
constructor(gameState: MutableSignal<RunState>) {
|
||||||
super('PlaceholderEncounterScene');
|
super('PlaceholderEncounterScene');
|
||||||
|
this.gameState = gameState;
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
super.create();
|
super.create();
|
||||||
const { width, height } = this.scale;
|
const { width, height } = this.scale;
|
||||||
const centerX = width / 2;
|
const state = this.gameState.value;
|
||||||
const centerY = height / 2;
|
|
||||||
const { encounter, nodeId } = this.initData;
|
// ── Layout: split screen into left (grid) and right (encounter) ──
|
||||||
|
const gridCols = state.inventory.width; // 6
|
||||||
|
const gridRows = state.inventory.height; // 4
|
||||||
|
const gridW = gridCols * this.CELL_SIZE;
|
||||||
|
const gridH = gridRows * this.CELL_SIZE;
|
||||||
|
const leftPanelW = gridW + 40; // panel padding
|
||||||
|
|
||||||
|
this.gridX = 60;
|
||||||
|
this.gridY = (height - gridH) / 2 + 20;
|
||||||
|
|
||||||
|
// Ensure camera shows the full grid
|
||||||
|
this.cameras.main.setBounds(0, 0, width, height);
|
||||||
|
this.cameras.main.setScroll(0, 0);
|
||||||
|
|
||||||
|
// ── LEFT PANEL: inventory grid ──
|
||||||
|
this.drawLeftPanel(leftPanelW, gridW, gridH);
|
||||||
|
|
||||||
|
// ── RIGHT PANEL: encounter info ──
|
||||||
|
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, leftPanelW, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────── LEFT PANEL ─────────────────────
|
||||||
|
|
||||||
|
private drawLeftPanel(panelW: number, gridW: number, gridH: number): void {
|
||||||
|
// Panel background
|
||||||
|
this.add.rectangle(
|
||||||
|
this.gridX + panelW / 2, this.gridY + gridH / 2,
|
||||||
|
panelW + 10, gridH + 50,
|
||||||
|
0x111122, 0.9
|
||||||
|
).setStrokeStyle(2, 0x5555aa);
|
||||||
|
|
||||||
|
// "背包" title
|
||||||
|
this.add.text(this.gridX + gridW / 2, this.gridY - 20, '背包', {
|
||||||
|
fontSize: '22px', color: '#ffffff', fontStyle: 'bold',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
const graphics = this.add.graphics();
|
||||||
|
|
||||||
|
// Draw empty cell backgrounds
|
||||||
|
for (let y = 0; y < 4; y++) {
|
||||||
|
for (let x = 0; x < 6; x++) {
|
||||||
|
const px = this.gridX + x * this.CELL_SIZE;
|
||||||
|
const py = this.gridY + y * this.CELL_SIZE;
|
||||||
|
|
||||||
|
// Dark cell fill
|
||||||
|
graphics.fillStyle(0x1a1a2e);
|
||||||
|
graphics.fillRect(px + 1, py + 1, this.CELL_SIZE - 2, this.CELL_SIZE - 2);
|
||||||
|
|
||||||
|
// Cell border
|
||||||
|
graphics.lineStyle(2, 0x444477);
|
||||||
|
graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw items on top
|
||||||
|
this.drawItems(graphics);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawItems(graphics: Phaser.GameObjects.Graphics): void {
|
||||||
|
const state = this.gameState.value;
|
||||||
|
const palette = [0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33, 0xff6633];
|
||||||
|
const colorMap = new Map<string, number>();
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
for (const [id, item] of state.inventory.items) {
|
||||||
|
const color = colorMap.get(id) ?? palette[idx++ % palette.length];
|
||||||
|
colorMap.set(id, color);
|
||||||
|
|
||||||
|
const cells = this.getItemCells(item);
|
||||||
|
if (cells.length === 0) continue;
|
||||||
|
|
||||||
|
// Filled cells
|
||||||
|
for (const c of cells) {
|
||||||
|
const px = this.gridX + c.x * this.CELL_SIZE;
|
||||||
|
const py = this.gridY + c.y * this.CELL_SIZE;
|
||||||
|
|
||||||
|
graphics.fillStyle(color);
|
||||||
|
graphics.fillRect(px + 2, py + 2, this.CELL_SIZE - 4, this.CELL_SIZE - 4);
|
||||||
|
graphics.lineStyle(2, 0xffffff);
|
||||||
|
graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item name
|
||||||
|
const first = cells[0];
|
||||||
|
const name = item.meta?.itemData?.name ?? item.id;
|
||||||
|
this.add.text(
|
||||||
|
this.gridX + first.x * this.CELL_SIZE + this.CELL_SIZE / 2,
|
||||||
|
this.gridY + first.y * this.CELL_SIZE + this.CELL_SIZE / 2,
|
||||||
|
name, { fontSize: '12px', color: '#fff', fontStyle: 'bold' }
|
||||||
|
).setOrigin(0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────── RIGHT PANEL ─────────────────────
|
||||||
|
|
||||||
|
private drawRightPanel(node: any, leftPanelW: number, width: number, height: number): void {
|
||||||
|
const encounter = {
|
||||||
|
type: node.type as MapNodeType,
|
||||||
|
name: node.encounter.name as string,
|
||||||
|
description: node.encounter.description as string,
|
||||||
|
};
|
||||||
|
const nodeId = node.id as string;
|
||||||
|
|
||||||
|
const rightX = leftPanelW + 60;
|
||||||
|
const rightW = width - rightX - 40;
|
||||||
|
const cx = rightX + rightW / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
this.add.text(centerX, centerY - 150, '遭遇', {
|
this.add.text(cx, cy - 180, '遭遇', {
|
||||||
fontSize: '32px',
|
fontSize: '36px', color: '#fff', fontStyle: 'bold',
|
||||||
color: '#ffffff',
|
|
||||||
fontStyle: 'bold',
|
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
// Encounter type badge
|
// Type badge
|
||||||
const typeLabel = this.getEncounterTypeLabel(encounter.type);
|
const typeLabel = this.getTypeLabel(encounter.type);
|
||||||
const typeBg = this.add.rectangle(centerX, centerY - 80, 120, 36, this.getEncounterTypeColor(encounter.type));
|
const badgeColor = this.getTypeColor(encounter.type);
|
||||||
this.add.text(centerX, centerY - 80, typeLabel, {
|
this.add.rectangle(cx, cy - 110, 140, 40, badgeColor);
|
||||||
fontSize: '16px',
|
this.add.text(cx, cy - 110, typeLabel, {
|
||||||
color: '#ffffff',
|
fontSize: '18px', color: '#fff', fontStyle: 'bold',
|
||||||
fontStyle: 'bold',
|
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
// Encounter name
|
// Name
|
||||||
this.add.text(centerX, centerY - 30, encounter.name, {
|
this.add.text(cx, cy - 50, encounter.name, {
|
||||||
fontSize: '24px',
|
fontSize: '28px', color: '#fff',
|
||||||
color: '#ffffff',
|
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
// Encounter description
|
// Description
|
||||||
this.add.text(centerX, centerY + 20, encounter.description || '(暂无描述)', {
|
this.add.text(cx, cy + 10, encounter.description || '(暂无描述)', {
|
||||||
fontSize: '16px',
|
fontSize: '18px', color: '#bbb',
|
||||||
color: '#aaaaaa',
|
wordWrap: { width: rightW - 40 }, align: 'center',
|
||||||
wordWrap: { width: 600 },
|
|
||||||
align: 'center',
|
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
// Node ID info
|
// Node id
|
||||||
this.add.text(centerX, centerY + 80, `节点: ${nodeId}`, {
|
this.add.text(cx, cy + 80, `节点: ${nodeId}`, {
|
||||||
fontSize: '12px',
|
fontSize: '14px', color: '#666',
|
||||||
color: '#666666',
|
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
// Placeholder notice
|
// Placeholder notice
|
||||||
this.add.text(centerX, centerY + 130, '(此为占位符遭遇,后续将替换为真实遭遇场景)', {
|
this.add.text(cx, cy + 130, '(此为占位符遭遇,后续将替换为真实遭遇场景)', {
|
||||||
fontSize: '14px',
|
fontSize: '14px', color: '#ff8844', fontStyle: 'italic',
|
||||||
color: '#ff8844',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
// Complete button
|
// Buttons
|
||||||
this.createButton('完成遭遇', centerX, centerY + 200, 200, 50, async () => {
|
this.createButton('完成遭遇', cx, cy + 200, 220, 50, async () => {
|
||||||
await this.completeEncounter();
|
await this.completeEncounter();
|
||||||
});
|
});
|
||||||
|
this.createButton('暂不处理', cx, cy + 270, 220, 40, async () => {
|
||||||
// Back button (without resolving)
|
|
||||||
this.createButton('暂不处理', centerX, centerY + 270, 200, 40, async () => {
|
|
||||||
await this.sceneController.launch('GameFlowScene');
|
await this.sceneController.launch('GameFlowScene');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────── Helpers ─────────────────────
|
||||||
|
|
||||||
|
private getItemCells(item: InventoryItem<GameItemMeta>): { x: number; y: number }[] {
|
||||||
|
const cells: { x: number; y: number }[] = [];
|
||||||
|
const { offset } = item.transform;
|
||||||
|
for (let y = 0; y < item.shape.height; y++) {
|
||||||
|
for (let x = 0; x < item.shape.width; x++) {
|
||||||
|
if (item.shape.grid[y]?.[x]) {
|
||||||
|
cells.push({ x: x + offset.x, y: y + offset.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTypeLabel(type: MapNodeType): string {
|
||||||
|
const m: Record<MapNodeType, string> = {
|
||||||
|
start: '起点', end: '终点', minion: '战斗', elite: '精英战斗',
|
||||||
|
event: '事件', camp: '营地', shop: '商店', curio: '奇遇',
|
||||||
|
};
|
||||||
|
return m[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTypeColor(type: MapNodeType): number {
|
||||||
|
const m: Record<MapNodeType, number> = {
|
||||||
|
start: 0x44aa44, end: 0xcc8844, minion: 0xcc4444, elite: 0xcc44cc,
|
||||||
|
event: 0xaaaa44, camp: 0x44cccc, shop: 0x4488cc, curio: 0x8844cc,
|
||||||
|
};
|
||||||
|
return m[type] ?? 0x888888;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createButton(label: string, x: number, y: number, w: number, h: number, onClick: () => void): void {
|
||||||
|
const bg = this.add.rectangle(x, y, w, h, 0x444466)
|
||||||
|
.setStrokeStyle(2, 0x7777aa).setInteractive({ useHandCursor: true });
|
||||||
|
const txt = this.add.text(x, y, label, { fontSize: '16px', color: '#fff' }).setOrigin(0.5);
|
||||||
|
|
||||||
|
bg.on('pointerover', () => { bg.setFillStyle(0x555588); txt.setScale(1.05); });
|
||||||
|
bg.on('pointerout', () => { bg.setFillStyle(0x444466); txt.setScale(1); });
|
||||||
|
bg.on('pointerdown', onClick);
|
||||||
|
}
|
||||||
|
|
||||||
private async completeEncounter(): Promise<void> {
|
private async completeEncounter(): Promise<void> {
|
||||||
const { runState, nodeId, encounter, onComplete } = this.initData;
|
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);
|
||||||
const result: EncounterResult = this.generatePlaceholderResult(encounter.type);
|
resolveEncounter(state, result);
|
||||||
|
|
||||||
// 调用进度管理器结算遭遇
|
|
||||||
resolveEncounter(runState, result);
|
|
||||||
|
|
||||||
// 回调通知上层
|
|
||||||
onComplete(result);
|
|
||||||
|
|
||||||
// 返回游戏流程场景
|
|
||||||
await this.sceneController.launch('GameFlowScene');
|
await this.sceneController.launch('GameFlowScene');
|
||||||
}
|
}
|
||||||
|
|
||||||
private generatePlaceholderResult(type: MapNodeType): EncounterResult {
|
private generatePlaceholderResult(type: MapNodeType): EncounterResult {
|
||||||
// 根据遭遇类型生成不同的模拟结果
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'minion':
|
case 'minion': return { hpLost: 8, goldEarned: 15 };
|
||||||
case 'elite':
|
case 'elite': return { hpLost: 15, goldEarned: 30 };
|
||||||
return { hpLost: type === 'elite' ? 15 : 8, goldEarned: type === 'elite' ? 30 : 15 };
|
case 'camp': return { hpGained: 15 };
|
||||||
case 'camp':
|
case 'shop': return { goldEarned: 0 };
|
||||||
return { hpGained: 15 };
|
|
||||||
case 'shop':
|
|
||||||
return { goldEarned: 0 };
|
|
||||||
case 'curio':
|
case 'curio':
|
||||||
case 'event':
|
case 'event': return { goldEarned: 20 };
|
||||||
return { goldEarned: 20 };
|
default: return {};
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { MutableSignal, mutableSignal } from 'boardgame-core';
|
||||||
|
import { createRunState, type RunState } from 'boardgame-core/samples/slay-the-spire-like';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局游戏运行状态 Signal
|
||||||
|
*
|
||||||
|
* 在 App.tsx 中创建为单例,所有场景共享。
|
||||||
|
* 遭遇场景通过读取此 signal 的当前遭遇状态来构建 UI。
|
||||||
|
*/
|
||||||
|
export function createGameState(seed?: number): MutableSignal<RunState> {
|
||||||
|
return mutableSignal<RunState>(createRunState(seed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前遭遇数据(computed getter) */
|
||||||
|
export function currentEncounter(
|
||||||
|
gameState: MutableSignal<RunState>
|
||||||
|
): { nodeId: string; encounter: { name: string; description: string; type: string } } | null {
|
||||||
|
const state = gameState.value;
|
||||||
|
const node = state.map.nodes.get(state.currentNodeId);
|
||||||
|
if (!node || !node.encounter) return null;
|
||||||
|
return {
|
||||||
|
nodeId: node.id,
|
||||||
|
encounter: {
|
||||||
|
type: node.type,
|
||||||
|
name: node.encounter.name,
|
||||||
|
description: node.encounter.description,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -7,14 +7,18 @@ import { GridViewerScene } from '@/scenes/GridViewerScene';
|
||||||
import { ShapeViewerScene } from '@/scenes/ShapeViewerScene';
|
import { ShapeViewerScene } from '@/scenes/ShapeViewerScene';
|
||||||
import { GameFlowScene } from '@/scenes/GameFlowScene';
|
import { GameFlowScene } from '@/scenes/GameFlowScene';
|
||||||
import { PlaceholderEncounterScene } from '@/scenes/PlaceholderEncounterScene';
|
import { PlaceholderEncounterScene } from '@/scenes/PlaceholderEncounterScene';
|
||||||
|
import { createGameState } from '@/state/gameState';
|
||||||
|
|
||||||
|
// 全局游戏状态单例
|
||||||
|
const gameState = createGameState();
|
||||||
|
|
||||||
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 gameFlowScene = useMemo(() => new GameFlowScene(gameState), []);
|
||||||
const placeholderEncounterScene = useMemo(() => new PlaceholderEncounterScene(), []);
|
const placeholderEncounterScene = useMemo(() => new PlaceholderEncounterScene(gameState), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen">
|
<div className="flex flex-col h-screen">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue