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

256 lines
8.1 KiB
TypeScript

import Phaser from 'phaser';
import { ReactiveScene } from 'boardgame-phaser';
import { generatePointCrawlMap, type PointCrawlMap, type MapNode, MapNodeType } from 'boardgame-core/samples/slay-the-spire-like';
const NODE_COLORS: Record<MapNodeType, number> = {
[MapNodeType.Start]: 0x44aa44,
[MapNodeType.End]: 0xcc8844,
[MapNodeType.Minion]: 0xcc4444,
[MapNodeType.Elite]: 0xcc44cc,
[MapNodeType.Event]: 0xaaaa44,
[MapNodeType.Camp]: 0x44cccc,
[MapNodeType.Shop]: 0x4488cc,
[MapNodeType.Curio]: 0x8844cc,
};
const NODE_LABELS: Record<MapNodeType, string> = {
[MapNodeType.Start]: '起点',
[MapNodeType.End]: '终点',
[MapNodeType.Minion]: '战斗',
[MapNodeType.Elite]: '精英',
[MapNodeType.Event]: '事件',
[MapNodeType.Camp]: '篝火',
[MapNodeType.Shop]: '商店',
[MapNodeType.Curio]: '奇遇',
};
export class MapViewerScene extends ReactiveScene {
private map: PointCrawlMap | null = null;
private seed: number = Date.now();
// Layout constants
private readonly LAYER_HEIGHT = 110;
private readonly NODE_SPACING = 140;
private readonly NODE_RADIUS = 28;
// Scroll state
private mapContainer!: Phaser.GameObjects.Container;
private isDragging = false;
private dragStartX = 0;
private dragStartContainerX = 0;
private dragStartY = 0;
private dragStartContainerY = 0;
// Fixed UI (always visible, not scrolled)
private titleText!: Phaser.GameObjects.Text;
private backButtonBg!: Phaser.GameObjects.Rectangle;
private backButtonText!: Phaser.GameObjects.Text;
private regenButtonBg!: Phaser.GameObjects.Rectangle;
private regenButtonText!: Phaser.GameObjects.Text;
private legendContainer!: Phaser.GameObjects.Container;
constructor() {
super('MapViewerScene');
}
create(): void {
super.create();
this.drawFixedUI();
this.drawMap();
}
private drawFixedUI(): void {
const { width } = this.scale;
// Title
this.titleText = this.add.text(width / 2, 30, '', {
fontSize: '24px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5).setDepth(100);
// Back button
this.backButtonBg = this.add.rectangle(100, 40, 140, 36, 0x444466)
.setStrokeStyle(2, 0x7777aa)
.setInteractive({ useHandCursor: true })
.setDepth(100);
this.backButtonText = this.add.text(100, 40, '返回菜单', {
fontSize: '16px',
color: '#ffffff',
}).setOrigin(0.5).setDepth(100);
this.backButtonBg.on('pointerover', () => {
this.backButtonBg.setFillStyle(0x555588);
this.backButtonText.setScale(1.05);
});
this.backButtonBg.on('pointerout', () => {
this.backButtonBg.setFillStyle(0x444466);
this.backButtonText.setScale(1);
});
this.backButtonBg.on('pointerdown', async () => {
await this.sceneController.launch('IndexScene');
});
// Regenerate button
this.regenButtonBg = this.add.rectangle(width - 120, 40, 140, 36, 0x444466)
.setStrokeStyle(2, 0x7777aa)
.setInteractive({ useHandCursor: true })
.setDepth(100);
this.regenButtonText = this.add.text(width - 120, 40, '重新生成', {
fontSize: '16px',
color: '#ffffff',
}).setOrigin(0.5).setDepth(100);
this.regenButtonBg.on('pointerover', () => {
this.regenButtonBg.setFillStyle(0x555588);
this.regenButtonText.setScale(1.05);
});
this.regenButtonBg.on('pointerout', () => {
this.regenButtonBg.setFillStyle(0x444466);
this.regenButtonText.setScale(1);
});
this.regenButtonBg.on('pointerdown', () => {
this.seed = Date.now();
this.mapContainer.destroy();
this.drawMap();
});
// Legend (bottom-left, fixed)
this.legendContainer = this.add.container(20, this.scale.height - 180).setDepth(100);
const legendBg = this.add.rectangle(75, 80, 150, 160, 0x222222, 0.8);
this.legendContainer.add(legendBg);
this.legendContainer.add(
this.add.text(10, 5, '图例:', { fontSize: '14px', color: '#ffffff', fontStyle: 'bold' })
);
let offsetY = 30;
for (const [type, color] of Object.entries(NODE_COLORS)) {
this.legendContainer.add(
this.add.circle(20, offsetY, 8, color)
);
this.legendContainer.add(
this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], { fontSize: '12px', color: '#ffffff' })
);
offsetY += 20;
}
// Hint text
this.add.text(width / 2, this.scale.height - 20, '拖拽滚动查看地图 (从左到右)', {
fontSize: '14px',
color: '#888888',
}).setOrigin(0.5).setDepth(100);
}
private drawMap(): void {
this.map = generatePointCrawlMap(this.seed);
const { width, height } = this.scale;
// Update title
this.titleText.setText(`Map Viewer (Seed: ${this.seed})`);
// Calculate map bounds (left-to-right: layers along X, nodes along Y)
const maxLayer = 9; // TOTAL_LAYERS - 1 (10 layers: 0-9)
const maxNodesInLayer = 5; // widest layer (settlement has 4 nodes)
const mapWidth = maxLayer * this.LAYER_HEIGHT + 200;
const mapHeight = (maxNodesInLayer - 1) * this.NODE_SPACING + 200;
// Create scrollable container
this.mapContainer = this.add.container(width / 2, height / 2);
// Background panel for the map area
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);
// Draw edges
graphics.lineStyle(2, 0x666666);
for (const [nodeId, node] of this.map.nodes) {
const posX = this.getNodeX(node);
const posY = this.getNodeY(node);
for (const childId of node.childIds) {
const child = this.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 this.map.nodes) {
const posX = this.getNodeX(node);
const posY = this.getNodeY(node);
const color = NODE_COLORS[node.type as MapNodeType] ?? 0x888888;
// Node circle
graphics.fillStyle(color);
graphics.fillCircle(posX, posY, this.NODE_RADIUS);
graphics.lineStyle(2, 0xffffff);
graphics.strokeCircle(posX, posY, this.NODE_RADIUS);
// Node label
const label = NODE_LABELS[node.type as MapNodeType] ?? node.type;
this.mapContainer.add(
this.add.text(posX, posY, label, {
fontSize: '12px',
color: '#ffffff',
}).setOrigin(0.5)
);
// Encounter name (if available)
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)
);
}
}
// 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;
});
}
private getNodeX(node: MapNode): number {
// Layers go left-to-right along X axis
return -500 + node.layerIndex * this.LAYER_HEIGHT;
}
private getNodeY(node: MapNode): number {
// 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;
}
}