256 lines
8.1 KiB
TypeScript
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;
|
|
}
|
|
}
|