Compare commits

..

No commits in common. "ddc9d057fdc88773bb521f6563efb6e2069d16e8" and "d993d5557640730e43e624b9c6fcf050b4dbb13b" have entirely different histories.

19 changed files with 1222 additions and 4219 deletions

View File

@ -1,84 +0,0 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import importPlugin from "eslint-plugin-import";
export default tseslint.config(
{
ignores: [
"**/dist/**",
"**/node_modules/**",
"**/*.d.ts",
"**/coverage/**",
],
},
js.configs.recommended,
...tseslint.configs.recommended,
importPlugin.flatConfigs.recommended,
importPlugin.flatConfigs.typescript,
{
languageOptions: {
parserOptions: {
projectService: true,
ecmaVersion: 2020,
sourceType: "module",
},
},
},
{
files: ["**/*.ts", "**/*.tsx"],
rules: {
// --- Project Conventions ---
quotes: ["error", "single", { avoidEscape: true }],
"comma-dangle": ["error", "always-multiline"],
indent: ["error", 2, { SwitchCase: 1 }],
// --- TypeScript ---
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports" },
],
"@typescript-eslint/no-non-null-assertion": "warn",
// --- Import Ordering ---
"import/order": [
"error",
{
groups: [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
"object",
"type",
],
"newlines-between": "always",
alphabetize: { order: "asc", caseInsensitive: true },
},
],
"import/no-unresolved": "off",
"import/no-duplicates": "error",
// --- General ---
"no-console": "warn",
"prefer-const": "error",
"no-var": "error",
eqeqeq: ["error", "always"],
},
},
{
files: ["**/*.test.ts", "**/*.spec.ts"],
rules: {
"no-console": "off",
"@typescript-eslint/no-explicit-any": "off",
},
},
);

View File

@ -6,16 +6,10 @@
"dev": "pnpm --filter sample-game dev", "dev": "pnpm --filter sample-game dev",
"build": "pnpm --filter boardgame-phaser build && pnpm --filter sample-game build", "build": "pnpm --filter boardgame-phaser build && pnpm --filter sample-game build",
"build:framework": "pnpm --filter boardgame-phaser build", "build:framework": "pnpm --filter boardgame-phaser build",
"preview": "pnpm --filter sample-game preview", "preview": "pnpm --filter sample-game preview"
"lint": "eslint .",
"lint:fix": "eslint . --fix"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0",
"eslint": "^9.17.0",
"eslint-plugin-import": "^2.31.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"typescript-eslint": "^8.18.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"pnpm": { "pnpm": {

View File

@ -1,122 +0,0 @@
/**
* Centralized layout and style constants for sts-like-viewer.
* All magic numbers should be defined here and imported where needed.
*/
// ── Map Layout ──────────────────────────────────────────────────────────────
export const MAP_CONFIG = {
/** Horizontal spacing between map layers (left-to-right) */
LAYER_HEIGHT: 110,
/** Vertical spacing between nodes within a layer */
NODE_SPACING: 140,
/** Radius of each map node circle */
NODE_RADIUS: 28,
/** Total number of layers in the point-crawl map */
TOTAL_LAYERS: 10,
/** Maximum nodes in any single layer */
MAX_NODES_PER_LAYER: 5,
} as const;
// ── Grid Inventory ──────────────────────────────────────────────────────────
export const GRID_CONFIG = {
/** Default grid width (columns) */
WIDTH: 6,
/** Default grid height (rows) */
HEIGHT: 4,
/** Cell size for the standalone grid viewer */
VIEWER_CELL_SIZE: 60,
/** Cell size for the encounter inventory widget */
WIDGET_CELL_SIZE: 80,
/** Gap between grid cells (pixels) */
GRID_GAP: 2,
} as const;
// ── Shape Viewer ────────────────────────────────────────────────────────────
export const SHAPE_CONFIG = {
/** Cell size for rendering item shapes */
CELL_SIZE: 40,
/** Number of items per row in the shape viewer grid */
ITEMS_PER_ROW: 4,
/** Maximum items to display at once */
MAX_ITEMS: 12,
/** Horizontal spacing between shape previews */
SPACING_X: 220,
/** Vertical spacing between shape previews */
SPACING_Y: 140,
} as const;
// ── Combat Unit Widget ──────────────────────────────────────────────────────
export const COMBAT_WIDGET_CONFIG = {
/** Default widget width */
WIDTH: 240,
/** Default widget height */
HEIGHT: 120,
/** HP bar width */
HP_BAR_WIDTH: 180,
/** HP bar height */
HP_BAR_HEIGHT: 16,
/** Buff/debuff icon size */
BUFF_ICON_SIZE: 28,
/** Gap between buff icons */
BUFF_ICON_GAP: 6,
} as const;
// ── UI / Button ─────────────────────────────────────────────────────────────
export const UI_CONFIG = {
/** Standard button width */
BUTTON_WIDTH: 120,
/** Standard button height */
BUTTON_HEIGHT: 36,
/** Large button width */
BUTTON_WIDTH_LARGE: 220,
/** Large button height */
BUTTON_HEIGHT_LARGE: 50,
/** Button background color */
BUTTON_BG: 0x444466,
/** Button hover background color */
BUTTON_BG_HOVER: 0x555588,
/** Button border color */
BUTTON_BORDER: 0x7777aa,
/** Button text color */
BUTTON_TEXT_COLOR: '#ffffff',
/** Button font size */
BUTTON_FONT_SIZE: '16px',
} as const;
// ── Colors ──────────────────────────────────────────────────────────────────
export const NODE_COLORS = {
start: 0x44aa44,
end: 0xcc8844,
minion: 0xcc4444,
elite: 0xcc44cc,
event: 0xaaaa44,
camp: 0x44cccc,
shop: 0x4488cc,
curio: 0x8844cc,
} as const;
export const NODE_LABELS = {
start: '起点',
end: '终点',
minion: '战斗',
elite: '精英',
event: '事件',
camp: '营地',
shop: '商店',
curio: '奇遇',
} as const;
export const ITEM_COLORS = [
0x4488cc, 0xcc8844, 0x44cc88, 0xcc4488, 0x8844cc, 0x44cccc,
] as const;
// ── Positive/Negative Effects (for buff icons) ──────────────────────────────
export const POSITIVE_EFFECTS = new Set(['block', 'strength', 'dexterity', 'regen']);
export const NEGATIVE_EFFECTS = new Set(['weak', 'vulnerable', 'frail', 'poison']);

View File

@ -1,22 +1,48 @@
import Phaser from "phaser"; import Phaser from 'phaser';
import { ReactiveScene } from "boardgame-phaser"; import { ReactiveScene } from 'boardgame-phaser';
import { createButton } from "@/utils/createButton"; import { MutableSignal } from 'boardgame-core';
import { UI_CONFIG, MAP_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
import { MutableSignal } from "boardgame-core";
import { import {
canMoveTo, canMoveTo,
moveToNode, moveToNode,
getCurrentNode, getCurrentNode,
getReachableChildren, getReachableChildren,
isAtEndNode, isAtEndNode,
isAtStartNode,
type RunState, type RunState,
type MapNode, type MapNode,
} from "boardgame-core/samples/slay-the-spire-like"; } from 'boardgame-core/samples/slay-the-spire-like';
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 { export class GameFlowScene extends ReactiveScene {
/** 全局游戏状态(由 App.tsx 注入) */ /** 全局游戏状态(由 App.tsx 注入) */
private gameState: MutableSignal<RunState>; private gameState: MutableSignal<RunState>;
// Layout constants
private readonly LAYER_HEIGHT = 110;
private readonly NODE_SPACING = 140;
private readonly NODE_RADIUS = 28;
// UI elements // UI elements
private hudContainer!: Phaser.GameObjects.Container; private hudContainer!: Phaser.GameObjects.Container;
private hpText!: Phaser.GameObjects.Text; private hpText!: Phaser.GameObjects.Text;
@ -36,7 +62,7 @@ export class GameFlowScene extends ReactiveScene {
private nodeGraphics: Map<string, Phaser.GameObjects.Graphics> = new Map(); private nodeGraphics: Map<string, Phaser.GameObjects.Graphics> = new Map();
constructor(gameState: MutableSignal<RunState>) { constructor(gameState: MutableSignal<RunState>) {
super("GameFlowScene"); super('GameFlowScene');
this.gameState = gameState; this.gameState = gameState;
} }
@ -56,46 +82,31 @@ export class GameFlowScene extends ReactiveScene {
this.hudContainer.add(hudBg); this.hudContainer.add(hudBg);
// HP // HP
this.hpText = this.add this.hpText = this.add.text(-150, 0, '', {
.text(-150, 0, "", { fontSize: '16px',
fontSize: "16px", color: '#ff6666',
color: "#ff6666", fontStyle: 'bold',
fontStyle: "bold", }).setOrigin(0, 0.5);
})
.setOrigin(0, 0.5);
this.hudContainer.add(this.hpText); this.hudContainer.add(this.hpText);
// Gold // Gold
this.goldText = this.add this.goldText = this.add.text(-50, 0, '', {
.text(-50, 0, "", { fontSize: '16px',
fontSize: "16px", color: '#ffcc44',
color: "#ffcc44", fontStyle: 'bold',
fontStyle: "bold", }).setOrigin(0, 0.5);
})
.setOrigin(0, 0.5);
this.hudContainer.add(this.goldText); this.hudContainer.add(this.goldText);
// Current node // Current node
this.nodeText = this.add this.nodeText = this.add.text(50, 0, '', {
.text(50, 0, "", { fontSize: '16px',
fontSize: "16px", color: '#ffffff',
color: "#ffffff", }).setOrigin(0, 0.5);
})
.setOrigin(0, 0.5);
this.hudContainer.add(this.nodeText); this.hudContainer.add(this.nodeText);
// Back to menu button // Back to menu button
createButton({ this.createButton('返回菜单', width - 100, 25, 140, 36, async () => {
scene: this, await this.sceneController.launch('IndexScene');
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,
}); });
} }
@ -117,26 +128,18 @@ 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; 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) // Calculate map bounds (left-to-right: layers along X, nodes along Y)
const maxLayer = TOTAL_LAYERS - 1; const maxLayer = 9;
const mapWidth = maxLayer * LAYER_HEIGHT + 200; const maxNodesInLayer = 5;
const mapHeight = (MAX_NODES_PER_LAYER - 1) * NODE_SPACING + 200; const mapWidth = 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);
// Background panel // Background panel
const bg = this.add const bg = this.add.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5).setOrigin(0.5);
.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
.setOrigin(0.5);
this.mapContainer.add(bg); this.mapContainer.add(bg);
const graphics = this.add.graphics(); const graphics = this.add.graphics();
@ -144,7 +147,7 @@ export class GameFlowScene extends ReactiveScene {
const { map, currentNodeId } = state; const { map, currentNodeId } = state;
const reachableChildren = getReachableChildren(state); 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
graphics.lineStyle(2, 0x666666); graphics.lineStyle(2, 0x666666);
@ -175,13 +178,9 @@ export class GameFlowScene extends ReactiveScene {
this.mapContainer.add(nodeGraphics); this.mapContainer.add(nodeGraphics);
this.nodeGraphics.set(nodeId, nodeGraphics); this.nodeGraphics.set(nodeId, nodeGraphics);
const color = isCurrent const color = isCurrent ? 0xffffff : (isReachable ? this.brightenColor(baseColor) : baseColor);
? 0xffffff
: isReachable
? this.brightenColor(baseColor)
: baseColor;
nodeGraphics.fillStyle(color); nodeGraphics.fillStyle(color);
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS); nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
if (isCurrent) { if (isCurrent) {
nodeGraphics.lineStyle(3, 0xffff44); nodeGraphics.lineStyle(3, 0xffff44);
@ -190,109 +189,86 @@ export class GameFlowScene extends ReactiveScene {
} else { } else {
nodeGraphics.lineStyle(2, 0x888888); nodeGraphics.lineStyle(2, 0x888888);
} }
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS); nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
// Node label // Node label
const label = NODE_LABELS[node.type] ?? node.type; const label = NODE_LABELS[node.type] ?? node.type;
this.mapContainer.add( this.mapContainer.add(
this.add this.add.text(posX, posY, label, {
.text(posX, posY, label, { fontSize: '11px',
fontSize: "11px", color: '#ffffff',
color: "#ffffff", fontStyle: isCurrent ? 'bold' : 'normal',
fontStyle: isCurrent ? "bold" : "normal", }).setOrigin(0.5)
})
.setOrigin(0.5),
); );
// Encounter name // Encounter name
if (node.encounter) { if (node.encounter) {
this.mapContainer.add( this.mapContainer.add(
this.add this.add.text(posX, posY + this.NODE_RADIUS + 12, node.encounter.name, {
.text(posX, posY + NODE_RADIUS + 12, node.encounter.name, { fontSize: '10px',
fontSize: "10px", color: '#cccccc',
color: "#cccccc", }).setOrigin(0.5)
})
.setOrigin(0.5),
); );
} }
// Make reachable nodes interactive // Make reachable nodes interactive (add hitZone to mapContainer so positions match)
if (isReachable) { if (isReachable) {
const hitZone = this.add const hitZone = this.add.circle(posX, posY, this.NODE_RADIUS, 0x000000, 0)
.circle(posX, posY, NODE_RADIUS, 0x000000, 0)
.setInteractive({ useHandCursor: true }); .setInteractive({ useHandCursor: true });
this.mapContainer.add(hitZone); this.mapContainer.add(hitZone);
hitZone.on("pointerover", () => { hitZone.on('pointerover', () => {
this.hoveredNode = nodeId; this.hoveredNode = nodeId;
nodeGraphics.clear(); nodeGraphics.clear();
nodeGraphics.fillStyle(this.brightenColor(baseColor)); nodeGraphics.fillStyle(this.brightenColor(baseColor));
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS); nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
nodeGraphics.lineStyle(3, 0xaaddaa); nodeGraphics.lineStyle(3, 0xaaddaa);
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS); nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
}); });
hitZone.on("pointerout", () => { hitZone.on('pointerout', () => {
this.hoveredNode = null; this.hoveredNode = null;
nodeGraphics.clear(); nodeGraphics.clear();
nodeGraphics.fillStyle(baseColor); nodeGraphics.fillStyle(baseColor);
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS); nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
nodeGraphics.lineStyle(2, 0xaaddaa); nodeGraphics.lineStyle(2, 0xaaddaa);
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS); nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
}); });
hitZone.on("pointerdown", () => { hitZone.on('pointerdown', () => {
this.onNodeClick(nodeId); this.onNodeClick(nodeId);
}); });
} }
} }
// Setup drag-to-scroll with disposables cleanup // Setup drag-to-scroll
const onPointerDown = (pointer: Phaser.Input.Pointer) => { this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
this.isDragging = true; this.isDragging = true;
this.dragStartX = pointer.x; this.dragStartX = pointer.x;
this.dragStartY = pointer.y; this.dragStartY = pointer.y;
this.dragStartContainerX = this.mapContainer.x; this.dragStartContainerX = this.mapContainer.x;
this.dragStartContainerY = this.mapContainer.y; this.dragStartContainerY = this.mapContainer.y;
}; });
const onPointerMove = (pointer: Phaser.Input.Pointer) => { this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
if (!this.isDragging) return; if (!this.isDragging) return;
this.mapContainer.x = this.mapContainer.x = this.dragStartContainerX + (pointer.x - this.dragStartX);
this.dragStartContainerX + (pointer.x - this.dragStartX); this.mapContainer.y = this.dragStartContainerY + (pointer.y - this.dragStartY);
this.mapContainer.y = });
this.dragStartContainerY + (pointer.y - this.dragStartY);
};
const onPointerUp = () => { this.input.on('pointerup', () => {
this.isDragging = false; this.isDragging = false;
}; });
this.input.on("pointerdown", onPointerDown); this.input.on('pointerout', () => {
this.input.on("pointermove", onPointerMove); this.isDragging = false;
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 // Hint text
this.add this.add.text(width / 2, this.scale.height - 20, '点击可到达的节点进入遭遇 | 拖拽滚动查看地图', {
.text( fontSize: '14px',
width / 2, color: '#888888',
this.scale.height - 20, }).setOrigin(0.5).setDepth(200);
"点击可到达的节点进入遭遇 | 拖拽滚动查看地图",
{
fontSize: "14px",
color: "#888888",
},
)
.setOrigin(0.5)
.setDepth(200);
} }
private async onNodeClick(nodeId: string): Promise<void> { private async onNodeClick(nodeId: string): Promise<void> {
@ -321,18 +297,18 @@ export class GameFlowScene extends ReactiveScene {
// Launch encounter scene // Launch encounter scene
const currentNode = getCurrentNode(state); const currentNode = getCurrentNode(state);
if (!currentNode || !currentNode.encounter) { if (!currentNode || !currentNode.encounter) {
console.warn("当前节点没有遭遇数据"); console.warn('当前节点没有遭遇数据');
return; return;
} }
await this.sceneController.launch("PlaceholderEncounterScene"); await this.sceneController.launch('PlaceholderEncounterScene');
} }
private redrawMapHighlights(): void { private redrawMapHighlights(): void {
const state = this.gameState.value; const state = this.gameState.value;
const { map, currentNodeId } = state; const { map, currentNodeId } = state;
const reachableChildren = getReachableChildren(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) {
const node = map.nodes.get(nodeId); const node = map.nodes.get(nodeId);
@ -343,17 +319,9 @@ export class GameFlowScene extends ReactiveScene {
const baseColor = NODE_COLORS[node.type] ?? 0x888888; const baseColor = NODE_COLORS[node.type] ?? 0x888888;
nodeGraphics.clear(); nodeGraphics.clear();
const color = isCurrent const color = isCurrent ? 0xffffff : (isReachable ? this.brightenColor(baseColor) : baseColor);
? 0xffffff
: isReachable
? this.brightenColor(baseColor)
: baseColor;
nodeGraphics.fillStyle(color); nodeGraphics.fillStyle(color);
nodeGraphics.fillCircle( nodeGraphics.fillCircle(this.getNodeX(node), this.getNodeY(node), this.NODE_RADIUS);
this.getNodeX(node),
this.getNodeY(node),
MAP_CONFIG.NODE_RADIUS,
);
if (isCurrent) { if (isCurrent) {
nodeGraphics.lineStyle(3, 0xffff44); nodeGraphics.lineStyle(3, 0xffff44);
@ -362,11 +330,7 @@ export class GameFlowScene extends ReactiveScene {
} else { } else {
nodeGraphics.lineStyle(2, 0x888888); nodeGraphics.lineStyle(2, 0x888888);
} }
nodeGraphics.strokeCircle( nodeGraphics.strokeCircle(this.getNodeX(node), this.getNodeY(node), this.NODE_RADIUS);
this.getNodeX(node),
this.getNodeY(node),
MAP_CONFIG.NODE_RADIUS,
);
} }
} }
@ -375,65 +339,79 @@ export class GameFlowScene extends ReactiveScene {
const state = this.gameState.value; const state = this.gameState.value;
// Overlay // Overlay
const overlay = this.add const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7).setDepth(300);
.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7)
.setDepth(300);
// End message // End message
this.add this.add.text(width / 2, height / 2 - 40, '恭喜通关!', {
.text(width / 2, height / 2 - 40, "恭喜通关!", { fontSize: '36px',
fontSize: "36px", color: '#ffcc44',
color: "#ffcc44", fontStyle: 'bold',
fontStyle: "bold", }).setOrigin(0.5).setDepth(300);
})
.setOrigin(0.5)
.setDepth(300);
const { player } = state; const { player } = state;
this.add this.add.text(width / 2, height / 2 + 20, `剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`, {
.text( fontSize: '20px',
width / 2, color: '#ffffff',
height / 2 + 20, align: 'center',
`剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`, }).setOrigin(0.5).setDepth(300);
{
fontSize: "20px",
color: "#ffffff",
align: "center",
},
)
.setOrigin(0.5)
.setDepth(300);
createButton({ this.createButton('返回菜单', width / 2, height / 2 + 100, 200, 50, async () => {
scene: this, await this.sceneController.launch('IndexScene');
label: "返回菜单", }, 300);
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 { private getNodeX(node: MapNode): number {
return -500 + node.layerIndex * MAP_CONFIG.LAYER_HEIGHT; // Layers go left-to-right along X axis
return -500 + node.layerIndex * this.LAYER_HEIGHT;
} }
private getNodeY(node: MapNode): number { private getNodeY(node: MapNode): number {
const layer = this.gameState.value.map.layers[node.layerIndex]; // 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 nodeIndex = layer.nodeIds.indexOf(node.id);
const totalNodes = layer.nodeIds.length; const totalNodes = layer.nodeIds.length;
const layerHeight = (totalNodes - 1) * MAP_CONFIG.NODE_SPACING; const layerHeight = (totalNodes - 1) * this.NODE_SPACING;
return -layerHeight / 2 + nodeIndex * MAP_CONFIG.NODE_SPACING; return -layerHeight / 2 + nodeIndex * this.NODE_SPACING;
} }
private brightenColor(color: number): number { private brightenColor(color: number): number {
// Simple color brightening
const r = Math.min(255, ((color >> 16) & 0xff) + 40); const r = Math.min(255, ((color >> 16) & 0xff) + 40);
const g = Math.min(255, ((color >> 8) & 0xff) + 40); const g = Math.min(255, ((color >> 8) & 0xff) + 40);
const b = Math.min(255, (color & 0xff) + 40); const b = Math.min(255, (color & 0xff) + 40);
return (r << 16) | (g << 8) | b; 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

@ -1,7 +1,4 @@
import Phaser from "phaser"; import Phaser from 'phaser';
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { GRID_CONFIG, UI_CONFIG, ITEM_COLORS } from "@/config";
import { import {
createGridInventory, createGridInventory,
placeItem, placeItem,
@ -11,49 +8,44 @@ import {
type GridInventory, type GridInventory,
type InventoryItem, type InventoryItem,
type GameItemMeta, type GameItemMeta,
} from "boardgame-core/samples/slay-the-spire-like"; } from 'boardgame-core/samples/slay-the-spire-like';
import { ReactiveScene } from 'boardgame-phaser';
export class GridViewerScene extends ReactiveScene { export class GridViewerScene extends ReactiveScene {
private inventory: GridInventory<GameItemMeta>; private inventory: GridInventory<GameItemMeta>;
private readonly CELL_SIZE = 60;
private readonly GRID_WIDTH = 6;
private readonly GRID_HEIGHT = 4;
private gridOffsetX = 0; private gridOffsetX = 0;
private gridOffsetY = 0; private gridOffsetY = 0;
constructor() { constructor() {
super("GridViewerScene"); super('GridViewerScene');
this.inventory = createGridInventory<GameItemMeta>( this.inventory = createGridInventory<GameItemMeta>(this.GRID_WIDTH, this.GRID_HEIGHT);
GRID_CONFIG.WIDTH,
GRID_CONFIG.HEIGHT,
);
} }
create(): void { create(): void {
super.create(); super.create();
const { width, height } = this.scale; const { width, height } = this.scale;
this.gridOffsetX = this.gridOffsetX = (width - this.GRID_WIDTH * this.CELL_SIZE) / 2;
(width - GRID_CONFIG.WIDTH * GRID_CONFIG.VIEWER_CELL_SIZE) / 2; this.gridOffsetY = (height - this.GRID_HEIGHT * this.CELL_SIZE) / 2 + 20;
this.gridOffsetY =
(height - GRID_CONFIG.HEIGHT * GRID_CONFIG.VIEWER_CELL_SIZE) / 2 + 20;
this.placeSampleItems(); this.placeSampleItems();
this.drawGrid(); this.drawGrid();
this.drawItems(); this.drawItems();
this.add this.add.text(width / 2, 30, 'Grid Inventory Viewer (4x6)', {
.text(width / 2, 30, "Grid Inventory Viewer (4x6)", { fontSize: '24px',
fontSize: "24px", color: '#ffffff',
color: "#ffffff", fontStyle: 'bold',
fontStyle: "bold", }).setOrigin(0.5);
})
.setOrigin(0.5);
this.createControls(); this.createControls();
this.add this.add.text(width / 2, height - 40, 'Hover over cells to see item details', {
.text(width / 2, height - 40, "Hover over cells to see item details", { fontSize: '14px',
fontSize: "14px", color: '#aaaaaa',
color: "#aaaaaa", }).setOrigin(0.5);
})
.setOrigin(0.5);
} }
private placeSampleItems(): void { private placeSampleItems(): void {
@ -72,12 +64,7 @@ export class GridViewerScene extends ReactiveScene {
const item: InventoryItem<GameItemMeta> = { const item: InventoryItem<GameItemMeta> = {
id: `item-${index}`, id: `item-${index}`,
shape, shape,
transform: { transform: { offset: { x, y }, rotation: 0, flipX: false, flipY: false },
offset: { x, y },
rotation: 0,
flipX: false,
flipY: false,
},
meta: { itemData, shape }, meta: { itemData, shape },
}; };
placeItem(this.inventory, item); placeItem(this.inventory, item);
@ -87,28 +74,18 @@ export class GridViewerScene extends ReactiveScene {
private drawGrid(): void { private drawGrid(): void {
const graphics = this.add.graphics(); const graphics = this.add.graphics();
for (let y = 0; y < GRID_CONFIG.HEIGHT; y++) { for (let y = 0; y < this.GRID_HEIGHT; y++) {
for (let x = 0; x < GRID_CONFIG.WIDTH; x++) { for (let x = 0; x < this.GRID_WIDTH; x++) {
const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE; const px = this.gridOffsetX + x * this.CELL_SIZE;
const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE; const py = this.gridOffsetY + y * this.CELL_SIZE;
const isOccupied = this.inventory.occupiedCells.has(`${x},${y}`); const isOccupied = this.inventory.occupiedCells.has(`${x},${y}`);
const color = isOccupied ? 0x334455 : 0x222233; const color = isOccupied ? 0x334455 : 0x222233;
graphics.fillStyle(color); graphics.fillStyle(color);
graphics.fillRect( graphics.fillRect(px + 1, py + 1, this.CELL_SIZE - 2, this.CELL_SIZE - 2);
px + 1,
py + 1,
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
);
graphics.lineStyle(1, 0x555577); graphics.lineStyle(1, 0x555577);
graphics.strokeRect( graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE);
px,
py,
GRID_CONFIG.VIEWER_CELL_SIZE,
GRID_CONFIG.VIEWER_CELL_SIZE,
);
} }
} }
} }
@ -133,120 +110,72 @@ export class GridViewerScene extends ReactiveScene {
const graphics = this.add.graphics(); const graphics = this.add.graphics();
for (const cell of cells) { for (const cell of cells) {
const px = this.gridOffsetX + cell.x * GRID_CONFIG.VIEWER_CELL_SIZE; const px = this.gridOffsetX + cell.x * this.CELL_SIZE;
const py = this.gridOffsetY + cell.y * GRID_CONFIG.VIEWER_CELL_SIZE; const py = this.gridOffsetY + cell.y * this.CELL_SIZE;
graphics.fillStyle(itemColor); graphics.fillStyle(itemColor);
graphics.fillRect( graphics.fillRect(px + 2, py + 2, this.CELL_SIZE - 4, this.CELL_SIZE - 4);
px + 2,
py + 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
);
} }
if (cells.length > 0) { if (cells.length > 0) {
const firstCell = cells[0]; const firstCell = cells[0];
const px = const px = this.gridOffsetX + firstCell.x * this.CELL_SIZE;
this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE; const py = this.gridOffsetY + firstCell.y * this.CELL_SIZE;
const py =
this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
const itemName = item.meta?.itemData.name ?? item.id; const itemName = item.meta?.itemData.name ?? item.id;
this.add this.add.text(px + this.CELL_SIZE / 2, py + this.CELL_SIZE / 2, itemName, {
.text( fontSize: '11px',
px + GRID_CONFIG.VIEWER_CELL_SIZE / 2, color: '#ffffff',
py + GRID_CONFIG.VIEWER_CELL_SIZE / 2, fontStyle: 'bold',
itemName, }).setOrigin(0.5);
{
fontSize: "11px",
color: "#ffffff",
fontStyle: "bold",
},
)
.setOrigin(0.5);
} }
const adjacent = getAdjacentItems(this.inventory, itemId); const adjacent = getAdjacentItems(this.inventory, itemId);
if (adjacent.size > 0) { if (adjacent.size > 0) {
const adjacentNames = Array.from(adjacent.values()) const adjacentNames = Array.from(adjacent.values()).map(i => i.meta?.itemData.name ?? i.id).join(', ');
.map((i) => i.meta?.itemData.name ?? i.id)
.join(", ");
const firstCell = cells[0]; const firstCell = cells[0];
const px = const px = this.gridOffsetX + firstCell.x * this.CELL_SIZE;
this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE; const py = this.gridOffsetY + firstCell.y * this.CELL_SIZE - 20;
const py =
this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE - 20;
this.add this.add.text(px + this.CELL_SIZE / 2, py, `邻接: ${adjacentNames}`, {
.text( fontSize: '10px',
px + GRID_CONFIG.VIEWER_CELL_SIZE / 2, color: '#ffff88',
py, }).setOrigin(0.5);
`邻接: ${adjacentNames}`,
{
fontSize: "10px",
color: "#ffff88",
},
)
.setOrigin(0.5);
} }
} }
} }
private getItemColor(itemId: string): number { private getItemColor(itemId: string): number {
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0); const hash = itemId.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
return ITEM_COLORS[hash % ITEM_COLORS.length]; const colors = [0x4488cc, 0xcc8844, 0x44cc88, 0xcc4488, 0x8844cc, 0x44cccc];
return colors[hash % colors.length];
} }
private createControls(): void { private createControls(): void {
const { width, height } = this.scale; const { width, height } = this.scale;
createButton({ this.createButton('返回菜单', 100, 40, async () => {
scene: this, await this.sceneController.launch('IndexScene');
label: "返回菜单",
x: 100,
y: 40,
onClick: async () => {
await this.sceneController.launch("IndexScene");
},
}); });
createButton({ this.createButton('清空', width - 260, 40, async () => {
scene: this, this.inventory = createGridInventory<GameItemMeta>(this.GRID_WIDTH, this.GRID_HEIGHT);
label: "清空", await this.sceneController.restart();
x: width - 260,
y: 40,
onClick: async () => {
this.inventory = createGridInventory<GameItemMeta>(
GRID_CONFIG.WIDTH,
GRID_CONFIG.HEIGHT,
);
await this.sceneController.restart();
},
}); });
createButton({ this.createButton('随机填充', width - 130, 40, async () => {
scene: this, this.randomFill();
label: "随机填充", await this.sceneController.restart();
x: width - 130,
y: 40,
onClick: async () => {
this.randomFill();
await this.sceneController.restart();
},
}); });
} }
private randomFill(): void { private randomFill(): void {
this.inventory = createGridInventory<GameItemMeta>( this.inventory = createGridInventory<GameItemMeta>(this.GRID_WIDTH, this.GRID_HEIGHT);
GRID_CONFIG.WIDTH,
GRID_CONFIG.HEIGHT,
);
const items = data.desert.items; const items = data.desert.items;
let itemIndex = 0; let itemIndex = 0;
for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) { for (let y = 0; y < this.GRID_HEIGHT && itemIndex < items.length; y++) {
for (let x = 0; x < GRID_CONFIG.WIDTH && itemIndex < items.length; x++) { for (let x = 0; x < this.GRID_WIDTH && itemIndex < items.length; x++) {
const itemData = items[itemIndex]; const itemData = items[itemIndex];
const shape = parseShapeString(itemData.shape); const shape = parseShapeString(itemData.shape);
@ -261,12 +190,8 @@ export class GridViewerScene extends ReactiveScene {
let valid = true; let valid = true;
for (const cell of occupiedCells) { for (const cell of occupiedCells) {
const [cx, cy] = cell.split(",").map(Number); const [cx, cy] = cell.split(',').map(Number);
if ( if (cx >= this.GRID_WIDTH || cy >= this.GRID_HEIGHT || this.inventory.occupiedCells.has(cell as `${number},${number}`)) {
cx >= GRID_CONFIG.WIDTH ||
cy >= GRID_CONFIG.HEIGHT ||
this.inventory.occupiedCells.has(cell as `${number},${number}`)
) {
valid = false; valid = false;
break; break;
} }
@ -276,12 +201,7 @@ export class GridViewerScene extends ReactiveScene {
const item: InventoryItem<GameItemMeta> = { const item: InventoryItem<GameItemMeta> = {
id: `item-${itemIndex}`, id: `item-${itemIndex}`,
shape, shape,
transform: { transform: { offset: { x, y }, rotation: 0, flipX: false, flipY: false },
offset: { x, y },
rotation: 0,
flipX: false,
flipY: false,
},
meta: { itemData, shape }, meta: { itemData, shape },
}; };
placeItem(this.inventory, item); placeItem(this.inventory, item);
@ -290,4 +210,30 @@ export class GridViewerScene extends ReactiveScene {
} }
} }
} }
private createButton(label: string, x: number, y: number, onClick: () => void): void {
const buttonWidth = 120;
const buttonHeight = 36;
const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 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

@ -1,11 +1,9 @@
import Phaser from "phaser"; import Phaser from 'phaser';
import { ReactiveScene } from "boardgame-phaser"; import { ReactiveScene } from 'boardgame-phaser';
import { createButton } from "@/utils/createButton";
import { UI_CONFIG } from "@/config";
export class IndexScene extends ReactiveScene { export class IndexScene extends ReactiveScene {
constructor() { constructor() {
super("IndexScene"); super('IndexScene');
} }
create(): void { create(): void {
@ -15,32 +13,24 @@ export class IndexScene extends ReactiveScene {
const centerY = height / 2; const centerY = height / 2;
// Title // Title
this.add this.add.text(centerX, centerY - 150, 'Slay-the-Spire-Like Viewer', {
.text(centerX, centerY - 150, "Slay-the-Spire-Like Viewer", { fontSize: '36px',
fontSize: "36px", color: '#ffffff',
color: "#ffffff", fontStyle: 'bold',
fontStyle: "bold", }).setOrigin(0.5);
})
.setOrigin(0.5);
// Subtitle // Subtitle
this.add this.add.text(centerX, centerY - 100, 'Choose a viewer to explore:', {
.text(centerX, centerY - 100, "Choose a viewer to explore:", { fontSize: '18px',
fontSize: "18px", color: '#aaaaaa',
color: "#aaaaaa", }).setOrigin(0.5);
})
.setOrigin(0.5);
// Buttons // Buttons
const buttons = [ const buttons = [
{ label: "开始游戏", scene: "GameFlowScene", y: centerY - 70 }, { label: '开始游戏', scene: 'GameFlowScene', y: centerY - 70 },
{ label: "Map Viewer", scene: "MapViewerScene", y: centerY }, { label: 'Map Viewer', scene: 'MapViewerScene', y: centerY },
{ { label: 'Grid Inventory Viewer', scene: 'GridViewerScene', y: centerY + 70 },
label: "Grid Inventory Viewer", { label: 'Shape Viewer', scene: 'ShapeViewerScene', y: centerY + 140 },
scene: "GridViewerScene",
y: centerY + 70,
},
{ label: "Shape Viewer", scene: "ShapeViewerScene", y: centerY + 140 },
]; ];
for (const btn of buttons) { for (const btn of buttons) {
@ -48,22 +38,34 @@ export class IndexScene extends ReactiveScene {
} }
} }
private createButton( private createButton(label: string, targetScene: string, x: number, y: number): void {
label: string, const buttonWidth = 300;
targetScene: string, const buttonHeight = 50;
x: number,
y: number, // Background
): void { const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x333355)
createButton({ .setStrokeStyle(2, 0x6666aa)
scene: this, .setInteractive({ useHandCursor: true });
label,
x, // Text
y, const text = this.add.text(x, y, label, {
width: UI_CONFIG.BUTTON_WIDTH_LARGE, fontSize: '20px',
height: UI_CONFIG.BUTTON_HEIGHT_LARGE, color: '#ffffff',
onClick: async () => { }).setOrigin(0.5);
await this.sceneController.launch(targetScene);
}, // Hover effects
bg.on('pointerover', () => {
bg.setFillStyle(0x444477);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(0x333355);
text.setScale(1);
});
bg.on('pointerdown', async () => {
await this.sceneController.launch(targetScene);
}); });
} }
} }

View File

@ -1,20 +1,39 @@
import Phaser from "phaser"; import Phaser from 'phaser';
import { ReactiveScene } from "boardgame-phaser"; import { ReactiveScene } from 'boardgame-phaser';
import { createButton } from "@/utils/createButton"; import { createRNG } from 'boardgame-core';
import { UI_CONFIG, MAP_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config"; import { generatePointCrawlMap, data, type PointCrawlMap, type MapNode, MapNodeType } from 'boardgame-core/samples/slay-the-spire-like';
import { createRNG } from "boardgame-core";
import { const NODE_COLORS: Record<MapNodeType, number> = {
generatePointCrawlMap, [MapNodeType.Start]: 0x44aa44,
data, [MapNodeType.End]: 0xcc8844,
type PointCrawlMap, [MapNodeType.Minion]: 0xcc4444,
type MapNode, [MapNodeType.Elite]: 0xcc44cc,
type MapNodeType, [MapNodeType.Event]: 0xaaaa44,
} from "boardgame-core/samples/slay-the-spire-like"; [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 { export class MapViewerScene extends ReactiveScene {
private map: PointCrawlMap | null = null; private map: PointCrawlMap | null = null;
private seed: number = Date.now(); private seed: number = Date.now();
// Layout constants
private readonly LAYER_HEIGHT = 110;
private readonly NODE_SPACING = 140;
private readonly NODE_RADIUS = 28;
// Scroll state // Scroll state
private mapContainer!: Phaser.GameObjects.Container; private mapContainer!: Phaser.GameObjects.Container;
private isDragging = false; private isDragging = false;
@ -25,10 +44,14 @@ export class MapViewerScene extends ReactiveScene {
// Fixed UI (always visible, not scrolled) // Fixed UI (always visible, not scrolled)
private titleText!: Phaser.GameObjects.Text; 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; private legendContainer!: Phaser.GameObjects.Container;
constructor() { constructor() {
super("MapViewerScene"); super('MapViewerScene');
} }
create(): void { create(): void {
@ -38,77 +61,86 @@ export class MapViewerScene extends ReactiveScene {
} }
private drawFixedUI(): void { private drawFixedUI(): void {
const { width, height } = this.scale; const { width } = this.scale;
// Title // Title
this.titleText = this.add this.titleText = this.add.text(width / 2, 30, '', {
.text(width / 2, 30, "", { fontSize: '24px',
fontSize: "24px", color: '#ffffff',
color: "#ffffff", fontStyle: 'bold',
fontStyle: "bold", }).setOrigin(0.5).setDepth(100);
})
.setOrigin(0.5)
.setDepth(100);
// Back button // Back button
createButton({ this.backButtonBg = this.add.rectangle(100, 40, 140, 36, 0x444466)
scene: this, .setStrokeStyle(2, 0x7777aa)
label: "返回菜单", .setInteractive({ useHandCursor: true })
x: 100, .setDepth(100);
y: 40, this.backButtonText = this.add.text(100, 40, '返回菜单', {
onClick: async () => { fontSize: '16px',
await this.sceneController.launch("IndexScene"); color: '#ffffff',
}, }).setOrigin(0.5).setDepth(100);
depth: 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 // Regenerate button
createButton({ this.regenButtonBg = this.add.rectangle(width - 120, 40, 140, 36, 0x444466)
scene: this, .setStrokeStyle(2, 0x7777aa)
label: "重新生成", .setInteractive({ useHandCursor: true })
x: width - 120, .setDepth(100);
y: 40, this.regenButtonText = this.add.text(width - 120, 40, '重新生成', {
onClick: () => { fontSize: '16px',
this.seed = Date.now(); color: '#ffffff',
this.mapContainer.destroy(); }).setOrigin(0.5).setDepth(100);
this.drawMap();
}, this.regenButtonBg.on('pointerover', () => {
depth: 100, 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) // Legend (bottom-left, fixed)
this.legendContainer = this.add.container(20, height - 180).setDepth(100); this.legendContainer = this.add.container(20, this.scale.height - 180).setDepth(100);
const legendBg = this.add.rectangle(75, 80, 150, 160, 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(
this.add.text(10, 5, "图例:", { this.add.text(10, 5, '图例:', { fontSize: '14px', color: '#ffffff', fontStyle: 'bold' })
fontSize: "14px",
color: "#ffffff",
fontStyle: "bold",
}),
); );
let offsetY = 30; let offsetY = 30;
for (const [type, color] of Object.entries(NODE_COLORS)) { for (const [type, color] of Object.entries(NODE_COLORS)) {
this.legendContainer.add(this.add.circle(20, offsetY, 8, color));
this.legendContainer.add( this.legendContainer.add(
this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], { this.add.circle(20, offsetY, 8, color)
fontSize: "12px", );
color: "#ffffff", this.legendContainer.add(
}), this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], { fontSize: '12px', color: '#ffffff' })
); );
offsetY += 20; offsetY += 20;
} }
// Hint text // Hint text
this.add this.add.text(width / 2, this.scale.height - 20, '拖拽滚动查看地图 (从左到右)', {
.text(width / 2, height - 20, "拖拽滚动查看地图 (从左到右)", { fontSize: '14px',
fontSize: "14px", color: '#888888',
color: "#888888", }).setOrigin(0.5).setDepth(100);
})
.setOrigin(0.5)
.setDepth(100);
} }
private drawMap(): void { private drawMap(): void {
@ -116,28 +148,21 @@ export class MapViewerScene extends ReactiveScene {
this.map = generatePointCrawlMap(rng, data.desert.encounters); this.map = generatePointCrawlMap(rng, data.desert.encounters);
const { width, height } = this.scale; const { width, height } = this.scale;
const {
LAYER_HEIGHT,
NODE_SPACING,
NODE_RADIUS,
TOTAL_LAYERS,
MAX_NODES_PER_LAYER,
} = MAP_CONFIG;
// 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 = TOTAL_LAYERS - 1; const maxLayer = 9; // TOTAL_LAYERS - 1 (10 layers: 0-9)
const mapWidth = maxLayer * LAYER_HEIGHT + 200; const maxNodesInLayer = 5; // widest layer (settlement has 4 nodes)
const mapHeight = (MAX_NODES_PER_LAYER - 1) * NODE_SPACING + 200; const mapWidth = 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);
// Background panel for the map area // Background panel for the map area
const bg = this.add const bg = this.add.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
.setOrigin(0.5); .setOrigin(0.5);
this.mapContainer.add(bg); this.mapContainer.add(bg);
@ -168,77 +193,65 @@ export class MapViewerScene extends ReactiveScene {
// Node circle // Node circle
graphics.fillStyle(color); graphics.fillStyle(color);
graphics.fillCircle(posX, posY, NODE_RADIUS); graphics.fillCircle(posX, posY, this.NODE_RADIUS);
graphics.lineStyle(2, 0xffffff); graphics.lineStyle(2, 0xffffff);
graphics.strokeCircle(posX, posY, NODE_RADIUS); graphics.strokeCircle(posX, posY, this.NODE_RADIUS);
// Node label // Node label
const label = NODE_LABELS[node.type as MapNodeType] ?? node.type; const label = NODE_LABELS[node.type as MapNodeType] ?? node.type;
this.mapContainer.add( this.mapContainer.add(
this.add this.add.text(posX, posY, label, {
.text(posX, posY, label, { fontSize: '12px',
fontSize: "12px", color: '#ffffff',
color: "#ffffff", }).setOrigin(0.5)
})
.setOrigin(0.5),
); );
// Encounter name (if available) // Encounter name (if available)
if (node.encounter) { if (node.encounter) {
this.mapContainer.add( this.mapContainer.add(
this.add this.add.text(posX, posY + this.NODE_RADIUS + 12, node.encounter.name, {
.text(posX, posY + NODE_RADIUS + 12, node.encounter.name, { fontSize: '10px',
fontSize: "10px", color: '#cccccc',
color: "#cccccc", }).setOrigin(0.5)
})
.setOrigin(0.5),
); );
} }
} }
// Setup drag-to-scroll with disposables cleanup // Setup drag-to-scroll
const onPointerDown = (pointer: Phaser.Input.Pointer) => { this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
this.isDragging = true; this.isDragging = true;
this.dragStartX = pointer.x; this.dragStartX = pointer.x;
this.dragStartY = pointer.y; this.dragStartY = pointer.y;
this.dragStartContainerX = this.mapContainer.x; this.dragStartContainerX = this.mapContainer.x;
this.dragStartContainerY = this.mapContainer.y; this.dragStartContainerY = this.mapContainer.y;
}; });
const onPointerMove = (pointer: Phaser.Input.Pointer) => { this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
if (!this.isDragging) return; if (!this.isDragging) return;
this.mapContainer.x = this.mapContainer.x = this.dragStartContainerX + (pointer.x - this.dragStartX);
this.dragStartContainerX + (pointer.x - this.dragStartX); this.mapContainer.y = this.dragStartContainerY + (pointer.y - this.dragStartY);
this.mapContainer.y = });
this.dragStartContainerY + (pointer.y - this.dragStartY);
};
const onPointerUp = () => { this.input.on('pointerup', () => {
this.isDragging = false; this.isDragging = false;
}; });
this.input.on("pointerdown", onPointerDown); this.input.on('pointerout', () => {
this.input.on("pointermove", onPointerMove); this.isDragging = false;
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);
}); });
} }
private getNodeX(node: MapNode): number { private getNodeX(node: MapNode): number {
return -500 + node.layerIndex * MAP_CONFIG.LAYER_HEIGHT; // Layers go left-to-right along X axis
return -500 + node.layerIndex * this.LAYER_HEIGHT;
} }
private getNodeY(node: MapNode): number { private getNodeY(node: MapNode): number {
// Nodes within a layer are spread vertically along Y axis
const layer = this.map!.layers[node.layerIndex]; const layer = this.map!.layers[node.layerIndex];
const nodeIndex = layer.nodeIds.indexOf(node.id); const nodeIndex = layer.nodeIds.indexOf(node.id);
const totalNodes = layer.nodeIds.length; const totalNodes = layer.nodeIds.length;
const layerHeight = (totalNodes - 1) * MAP_CONFIG.NODE_SPACING; const layerHeight = (totalNodes - 1) * this.NODE_SPACING;
return -layerHeight / 2 + nodeIndex * MAP_CONFIG.NODE_SPACING; return -layerHeight / 2 + nodeIndex * this.NODE_SPACING;
} }
} }

View File

@ -1,17 +1,14 @@
import Phaser from "phaser"; import Phaser from 'phaser';
import { ReactiveScene } from "boardgame-phaser"; import { ReactiveScene } from 'boardgame-phaser';
import { createButton } from "@/utils/createButton"; import { MutableSignal } from 'boardgame-core';
import { UI_CONFIG, GRID_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
import { MutableSignal } from "boardgame-core";
import { import {
resolveEncounter, resolveEncounter,
removeItem, removeItem,
type RunState, type RunState,
type EncounterResult, type EncounterResult,
type MapNodeType, type MapNodeType,
type MapNode, } from 'boardgame-core/samples/slay-the-spire-like';
} from "boardgame-core/samples/slay-the-spire-like"; import { InventoryWidget } from '@/widgets/InventoryWidget';
import { InventoryWidget } from "@/widgets/InventoryWidget";
/** /**
* *
@ -23,7 +20,7 @@ export class PlaceholderEncounterScene extends ReactiveScene {
private inventoryWidget: InventoryWidget | null = null; private inventoryWidget: InventoryWidget | null = null;
constructor(gameState: MutableSignal<RunState>) { constructor(gameState: MutableSignal<RunState>) {
super("PlaceholderEncounterScene"); super('PlaceholderEncounterScene');
this.gameState = gameState; this.gameState = gameState;
} }
@ -34,9 +31,9 @@ export class PlaceholderEncounterScene extends ReactiveScene {
const gridCols = state.inventory.width; const gridCols = state.inventory.width;
const gridRows = state.inventory.height; const gridRows = state.inventory.height;
const cellSize = GRID_CONFIG.WIDGET_CELL_SIZE; const cellSize = 80;
const gridW = gridCols * cellSize + (gridCols - 1) * GRID_CONFIG.GRID_GAP; const gridW = gridCols * cellSize + (gridCols - 1) * 2;
const gridH = gridRows * cellSize + (gridRows - 1) * GRID_CONFIG.GRID_GAP; const gridH = gridRows * cellSize + (gridRows - 1) * 2;
const leftPanelW = gridW + 40; const leftPanelW = gridW + 40;
this.inventoryWidget = new InventoryWidget({ this.inventoryWidget = new InventoryWidget({
@ -45,51 +42,34 @@ export class PlaceholderEncounterScene extends ReactiveScene {
x: 60, x: 60,
y: (height - gridH) / 2 + 20, y: (height - gridH) / 2 + 20,
cellSize, cellSize,
gridGap: GRID_CONFIG.GRID_GAP, gridGap: 2,
}); });
this.cameras.main.setBounds(0, 0, width, height); this.cameras.main.setBounds(0, 0, width, height);
this.cameras.main.setScroll(0, 0); this.cameras.main.setScroll(0, 0);
// Panel background // Panel background
this.add this.add.rectangle(
.rectangle( 60 + leftPanelW / 2, this.inventoryWidgetY(gridH),
60 + leftPanelW / 2, leftPanelW + 10, gridH + 50,
this.inventoryWidgetY(gridH), 0x111122, 0.9
leftPanelW + 10, ).setStrokeStyle(2, 0x5555aa);
gridH + 50,
0x111122,
0.9,
)
.setStrokeStyle(2, 0x5555aa);
// "背包" title // "背包" title
this.add this.add.text(60 + gridW / 2, (height - gridH) / 2, '背包', {
.text(60 + gridW / 2, (height - gridH) / 2, "背包", { fontSize: '22px', color: '#ffffff', fontStyle: 'bold',
fontSize: "22px", }).setOrigin(0.5);
color: "#ffffff",
fontStyle: "bold",
})
.setOrigin(0.5);
const node = state.map.nodes.get(state.currentNodeId); const node = state.map.nodes.get(state.currentNodeId);
if (!node || !node.encounter) { if (!node || !node.encounter) {
const rightX = leftPanelW + 80; const rightX = leftPanelW + 80;
this.add this.add.text(rightX + 300, height / 2, '没有遭遇数据', {
.text(rightX + 300, height / 2, "没有遭遇数据", { fontSize: '24px', color: '#ff4444',
fontSize: "24px", }).setOrigin(0.5);
color: "#ff4444",
})
.setOrigin(0.5);
return; return;
} }
this.drawRightPanel( this.drawRightPanel(node, leftPanelW, width, height);
node as MapNode & { encounter: { name: string; description: string } },
leftPanelW,
width,
height,
);
} }
private inventoryWidgetY(gridH: number): number { private inventoryWidgetY(gridH: number): number {
@ -97,16 +77,11 @@ export class PlaceholderEncounterScene extends ReactiveScene {
return (height - gridH) / 2 + 20 + gridH / 2; return (height - gridH) / 2 + 20 + gridH / 2;
} }
private drawRightPanel( private drawRightPanel(node: any, leftPanelW: number, width: number, height: number): void {
node: MapNode & { encounter: { name: string; description: string } },
leftPanelW: number,
width: number,
height: number,
): void {
const encounter = { const encounter = {
type: node.type as MapNodeType, type: node.type as MapNodeType,
name: node.encounter.name, name: node.encounter.name as string,
description: node.encounter.description, description: node.encounter.description as string,
}; };
const nodeId = node.id as string; const nodeId = node.id as string;
@ -115,85 +90,66 @@ export class PlaceholderEncounterScene extends ReactiveScene {
const cx = rightX + rightW / 2; const cx = rightX + rightW / 2;
const cy = height / 2; const cy = height / 2;
this.add this.add.text(cx, cy - 180, '遭遇', {
.text(cx, cy - 180, "遭遇", { fontSize: '36px', color: '#fff', fontStyle: 'bold',
fontSize: "36px", }).setOrigin(0.5);
color: "#fff",
fontStyle: "bold",
})
.setOrigin(0.5);
const typeLabel = this.getTypeLabel(encounter.type); const typeLabel = this.getTypeLabel(encounter.type);
const badgeColor = this.getTypeColor(encounter.type); const badgeColor = this.getTypeColor(encounter.type);
this.add.rectangle(cx, cy - 110, 140, 40, badgeColor); this.add.rectangle(cx, cy - 110, 140, 40, badgeColor);
this.add this.add.text(cx, cy - 110, typeLabel, {
.text(cx, cy - 110, typeLabel, { fontSize: '18px', color: '#fff', fontStyle: 'bold',
fontSize: "18px", }).setOrigin(0.5);
color: "#fff",
fontStyle: "bold",
})
.setOrigin(0.5);
this.add this.add.text(cx, cy - 50, encounter.name, {
.text(cx, cy - 50, encounter.name, { fontSize: '28px', color: '#fff',
fontSize: "28px", }).setOrigin(0.5);
color: "#fff",
})
.setOrigin(0.5);
this.add this.add.text(cx, cy + 10, encounter.description || '(暂无描述)', {
.text(cx, cy + 10, encounter.description || "(暂无描述)", { fontSize: '18px', color: '#bbb',
fontSize: "18px", wordWrap: { width: rightW - 40 }, align: 'center',
color: "#bbb", }).setOrigin(0.5);
wordWrap: { width: rightW - 40 },
align: "center",
})
.setOrigin(0.5);
this.add this.add.text(cx, cy + 80, `节点: ${nodeId}`, {
.text(cx, cy + 80, `节点: ${nodeId}`, { fontSize: '14px', color: '#666',
fontSize: "14px", }).setOrigin(0.5);
color: "#666",
})
.setOrigin(0.5);
this.add this.add.text(cx, cy + 130, '(此为占位符遭遇,后续将替换为真实遭遇场景)', {
.text(cx, cy + 130, "(此为占位符遭遇,后续将替换为真实遭遇场景)", { fontSize: '14px', color: '#ff8844', fontStyle: 'italic',
fontSize: "14px", }).setOrigin(0.5);
color: "#ff8844",
fontStyle: "italic",
})
.setOrigin(0.5);
createButton({ this.createButton('完成遭遇', cx, cy + 200, 220, 50, async () => {
scene: this, await this.completeEncounter();
label: "完成遭遇",
x: cx,
y: cy + 200,
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
onClick: async () => {
await this.completeEncounter();
},
}); });
this.createButton('暂不处理', cx, cy + 270, 220, 40, async () => {
createButton({ await this.sceneController.launch('GameFlowScene');
scene: this,
label: "暂不处理",
x: cx,
y: cy + 270,
onClick: async () => {
await this.sceneController.launch("GameFlowScene");
},
}); });
} }
private getTypeLabel(type: MapNodeType): string { private getTypeLabel(type: MapNodeType): string {
return NODE_LABELS[type] ?? type; const m: Record<MapNodeType, string> = {
start: '起点', end: '终点', minion: '战斗', elite: '精英战斗',
event: '事件', camp: '营地', shop: '商店', curio: '奇遇',
};
return m[type] ?? type;
} }
private getTypeColor(type: MapNodeType): number { private getTypeColor(type: MapNodeType): number {
return NODE_COLORS[type] ?? 0x888888; 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> {
@ -212,24 +168,18 @@ export class PlaceholderEncounterScene extends ReactiveScene {
const result: EncounterResult = this.generatePlaceholderResult(node.type); const result: EncounterResult = this.generatePlaceholderResult(node.type);
resolveEncounter(state, result); resolveEncounter(state, 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 };
return { hpLost: 8, goldEarned: 15 }; case 'elite': return { hpLost: 15, goldEarned: 30 };
case "elite": case 'camp': return { hpGained: 15 };
return { hpLost: 15, goldEarned: 30 }; case 'shop': return { goldEarned: 0 };
case "camp": case 'curio':
return { hpGained: 15 }; case 'event': return { goldEarned: 20 };
case "shop": default: return {};
return { goldEarned: 0 };
case "curio":
case "event":
return { goldEarned: 20 };
default:
return {};
} }
} }
} }

View File

@ -1,16 +1,13 @@
import Phaser from "phaser"; import Phaser from 'phaser';
import { ReactiveScene } from "boardgame-phaser"; import { ReactiveScene } from 'boardgame-phaser';
import { createButton } from "@/utils/createButton"; import { parseShapeString, data, type ParsedShape } from 'boardgame-core/samples/slay-the-spire-like';
import { UI_CONFIG, SHAPE_CONFIG, NODE_LABELS, NODE_COLORS } from "@/config";
import {
parseShapeString,
data,
type ParsedShape,
} from "boardgame-core/samples/slay-the-spire-like";
export class ShapeViewerScene extends ReactiveScene { export class ShapeViewerScene extends ReactiveScene {
private readonly CELL_SIZE = 40;
private readonly ITEMS_PER_ROW = 4;
constructor() { constructor() {
super("ShapeViewerScene"); super('ShapeViewerScene');
} }
create(): void { create(): void {
@ -22,15 +19,13 @@ export class ShapeViewerScene extends ReactiveScene {
private drawShapeViewer(): void { private drawShapeViewer(): void {
this.children.removeAll(); this.children.removeAll();
const { width } = this.scale; const { width, height } = this.scale;
this.add this.add.text(width / 2, 30, 'Shape Viewer - Item Shapes', {
.text(width / 2, 30, "Shape Viewer - Item Shapes", { fontSize: '24px',
fontSize: "24px", color: '#ffffff',
color: "#ffffff", fontStyle: 'bold',
fontStyle: "bold", }).setOrigin(0.5);
})
.setOrigin(0.5);
this.drawAllShapes(); this.drawAllShapes();
} }
@ -38,127 +33,117 @@ export class ShapeViewerScene extends ReactiveScene {
private drawAllShapes(): void { private drawAllShapes(): void {
const { width } = this.scale; const { width } = this.scale;
const startY = 80; const startY = 80;
const { SPACING_X, SPACING_Y, ITEMS_PER_ROW, MAX_ITEMS } = SHAPE_CONFIG; const spacingX = 220;
const spacingY = 140;
const itemsToShow = data.desert.items.slice(0, MAX_ITEMS); const itemsToShow = data.desert.items.slice(0, 12);
for (let i = 0; i < itemsToShow.length; i++) { for (let i = 0; i < itemsToShow.length; i++) {
const itemData = itemsToShow[i]; const itemData = itemsToShow[i];
const shape = parseShapeString(itemData.shape); const shape = parseShapeString(itemData.shape);
const col = i % ITEMS_PER_ROW; const col = i % this.ITEMS_PER_ROW;
const row = Math.floor(i / ITEMS_PER_ROW); const row = Math.floor(i / this.ITEMS_PER_ROW);
const x = 60 + col * SPACING_X; const x = 60 + col * spacingX;
const y = startY + row * SPACING_Y; const y = startY + row * spacingY;
this.drawSingleShape(x, y, itemData, shape); this.drawSingleShape(x, y, itemData, shape);
} }
} }
private drawSingleShape( private drawSingleShape(startX: number, startY: number, itemData: any, shape: ParsedShape): void {
startX: number,
startY: number,
itemData: any,
shape: ParsedShape,
): void {
const graphics = this.add.graphics(); const graphics = this.add.graphics();
const { CELL_SIZE } = SHAPE_CONFIG;
const shapeWidth = shape.width * CELL_SIZE; const shapeWidth = shape.width * this.CELL_SIZE;
const shapeHeight = shape.height * CELL_SIZE; const shapeHeight = shape.height * this.CELL_SIZE;
this.add this.add.text(startX + shapeWidth / 2, startY - 20, itemData.name, {
.text(startX + shapeWidth / 2, startY - 20, itemData.name, { fontSize: '14px',
fontSize: "14px", color: '#ffffff',
color: "#ffffff", fontStyle: 'bold',
fontStyle: "bold", }).setOrigin(0.5);
})
.setOrigin(0.5);
for (let y = 0; y < shape.height; y++) { for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) { for (let x = 0; x < shape.width; x++) {
if (shape.grid[y]?.[x]) { if (shape.grid[y]?.[x]) {
const px = startX + x * CELL_SIZE; const px = startX + x * this.CELL_SIZE;
const py = startY + y * CELL_SIZE; const py = startY + y * this.CELL_SIZE;
const isOrigin = x === shape.originX && y === shape.originY; const isOrigin = x === shape.originX && y === shape.originY;
const color = isOrigin ? 0x88cc44 : 0x4488cc; const color = isOrigin ? 0x88cc44 : 0x4488cc;
graphics.fillStyle(color); graphics.fillStyle(color);
graphics.fillRect(px + 1, py + 1, CELL_SIZE - 2, CELL_SIZE - 2); graphics.fillRect(px + 1, py + 1, this.CELL_SIZE - 2, this.CELL_SIZE - 2);
graphics.lineStyle(2, 0xffffff); graphics.lineStyle(2, 0xffffff);
graphics.strokeRect(px, py, CELL_SIZE, CELL_SIZE); graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE);
if (isOrigin) { if (isOrigin) {
this.add this.add.text(px + this.CELL_SIZE / 2, py + this.CELL_SIZE / 2, 'O', {
.text(px + CELL_SIZE / 2, py + CELL_SIZE / 2, "O", { fontSize: '16px',
fontSize: "16px", color: '#ffffff',
color: "#ffffff", fontStyle: 'bold',
fontStyle: "bold", }).setOrigin(0.5);
})
.setOrigin(0.5);
} }
} }
} }
} }
this.add this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 10, `形状: ${itemData.shape}`, {
.text( fontSize: '11px',
startX + shapeWidth / 2, color: '#aaaaaa',
startY + shapeHeight + 10, }).setOrigin(0.5);
`形状: ${itemData.shape}`,
{
fontSize: "11px",
color: "#aaaaaa",
},
)
.setOrigin(0.5);
this.add this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 28,
.text( `类型: ${itemData.type} | 费用: ${itemData.costCount} ${itemData.costType}`, {
startX + shapeWidth / 2, fontSize: '11px',
startY + shapeHeight + 28, color: '#cccccc',
`类型: ${itemData.type} | 费用: ${itemData.costCount} ${itemData.costType}`, }).setOrigin(0.5);
{
fontSize: "11px",
color: "#cccccc",
},
)
.setOrigin(0.5);
this.add this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 46, itemData.desc, {
.text(startX + shapeWidth / 2, startY + shapeHeight + 46, itemData.desc, { fontSize: '10px',
fontSize: "10px", color: '#888888',
color: "#888888", wordWrap: { width: shapeWidth },
wordWrap: { width: shapeWidth }, }).setOrigin(0.5);
})
.setOrigin(0.5);
} }
private createControls(): void { private createControls(): void {
const { width, height } = this.scale; const { width, height } = this.scale;
createButton({ this.createButton('返回菜单', 100, height - 40, async () => {
scene: this, await this.sceneController.launch('IndexScene');
label: "返回菜单",
x: 100,
y: height - 40,
onClick: async () => {
await this.sceneController.launch("IndexScene");
},
}); });
this.add this.add.text(width / 2, height - 40,
.text( `Showing first 12 items | Green = Origin | Blue = Normal`, {
width / 2, fontSize: '14px',
height - 40, color: '#aaaaaa',
`Showing first ${SHAPE_CONFIG.MAX_ITEMS} items | Green = Origin | Blue = Normal`, }).setOrigin(0.5);
{ }
fontSize: "14px",
color: "#aaaaaa", private createButton(label: string, x: number, y: number, onClick: () => void): void {
}, const buttonWidth = 120;
) const buttonHeight = 36;
.setOrigin(0.5);
const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 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

@ -1,13 +1,13 @@
import { h } from "preact"; import { h } from 'preact';
import { PhaserGame, PhaserScene } from "boardgame-phaser"; import { PhaserGame, PhaserScene } from 'boardgame-phaser';
import { useMemo } from "preact/hooks"; import { useMemo } from 'preact/hooks';
import { IndexScene } from "@/scenes/IndexScene"; 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 { GameFlowScene } from '@/scenes/GameFlowScene';
import { PlaceholderEncounterScene } from "@/scenes/PlaceholderEncounterScene"; import { PlaceholderEncounterScene } from '@/scenes/PlaceholderEncounterScene';
import { createGameState } from "@/state/gameState"; import { createGameState } from '@/state/gameState';
// 全局游戏状态单例 // 全局游戏状态单例
const gameState = createGameState(); const gameState = createGameState();
@ -18,36 +18,18 @@ export default function App() {
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(gameState), []); const gameFlowScene = useMemo(() => new GameFlowScene(gameState), []);
const placeholderEncounterScene = useMemo( const placeholderEncounterScene = useMemo(() => new PlaceholderEncounterScene(gameState), []);
() => new PlaceholderEncounterScene(gameState),
[],
);
return ( return (
<div className="flex flex-col h-screen"> <div className="flex flex-col h-screen">
<div className="flex-1 flex relative justify-center items-center"> <div className="flex-1 flex relative justify-center items-center">
<PhaserGame <PhaserGame initialScene="IndexScene" config={{ width: 1920, height: 1080 }}>
initialScene="IndexScene" <PhaserScene sceneKey="IndexScene" scene={indexScene} />
config={{ width: 1920, height: 1080 }} <PhaserScene sceneKey="MapViewerScene" scene={mapViewerScene} />
> <PhaserScene sceneKey="GridViewerScene" scene={gridViewerScene} />
<PhaserScene sceneKey="IndexScene" scene={indexScene as any} /> <PhaserScene sceneKey="ShapeViewerScene" scene={shapeViewerScene} />
<PhaserScene <PhaserScene sceneKey="GameFlowScene" scene={gameFlowScene} />
sceneKey="MapViewerScene" <PhaserScene sceneKey="PlaceholderEncounterScene" scene={placeholderEncounterScene} />
scene={mapViewerScene as any}
/>
<PhaserScene
sceneKey="GridViewerScene"
scene={gridViewerScene as any}
/>
<PhaserScene
sceneKey="ShapeViewerScene"
scene={shapeViewerScene as any}
/>
<PhaserScene sceneKey="GameFlowScene" scene={gameFlowScene as any} />
<PhaserScene
sceneKey="PlaceholderEncounterScene"
scene={placeholderEncounterScene as any}
/>
</PhaserGame> </PhaserGame>
</div> </div>
</div> </div>

View File

@ -1,86 +0,0 @@
import Phaser from 'phaser';
import { UI_CONFIG } from '@/config';
export interface ButtonOptions {
/** The Phaser scene to create the button in */
scene: Phaser.Scene;
/** Button label text */
label: string;
/** X position (center of button) */
x: number;
/** Y position (center of button) */
y: number;
/** Button width (defaults to UI_CONFIG.BUTTON_WIDTH) */
width?: number;
/** Button height (defaults to UI_CONFIG.BUTTON_HEIGHT) */
height?: number;
/** Click handler */
onClick: () => void;
/** Phaser depth (defaults to 100) */
depth?: number;
/** Background color (defaults to UI_CONFIG.BUTTON_BG) */
bgColor?: number;
/** Hover background color (defaults to UI_CONFIG.BUTTON_BG_HOVER) */
hoverBgColor?: number;
/** Border color (defaults to UI_CONFIG.BUTTON_BORDER) */
borderColor?: number;
/** Text color (defaults to UI_CONFIG.BUTTON_TEXT_COLOR) */
textColor?: string;
/** Font size (defaults to UI_CONFIG.BUTTON_FONT_SIZE) */
fontSize?: string;
}
export interface ButtonObjects {
/** Background rectangle */
bg: Phaser.GameObjects.Rectangle;
/** Text label */
text: Phaser.GameObjects.Text;
}
/**
* Create a styled interactive button in a Phaser scene.
*
* Handles hover effects (fill color change + text scale) and click callback.
* Returns both the bg and text objects for further customization if needed.
*/
export function createButton(options: ButtonOptions): ButtonObjects {
const {
scene,
label,
x,
y,
width = UI_CONFIG.BUTTON_WIDTH,
height = UI_CONFIG.BUTTON_HEIGHT,
onClick,
depth = 100,
bgColor = UI_CONFIG.BUTTON_BG,
hoverBgColor = UI_CONFIG.BUTTON_BG_HOVER,
borderColor = UI_CONFIG.BUTTON_BORDER,
textColor = UI_CONFIG.BUTTON_TEXT_COLOR,
fontSize = UI_CONFIG.BUTTON_FONT_SIZE,
} = options;
const bg = scene.add.rectangle(x, y, width, height, bgColor)
.setStrokeStyle(2, borderColor)
.setInteractive({ useHandCursor: true })
.setDepth(depth);
const text = scene.add.text(x, y, label, {
fontSize,
color: textColor,
}).setOrigin(0.5).setDepth(depth);
bg.on('pointerover', () => {
bg.setFillStyle(hoverBgColor);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(bgColor);
text.setScale(1);
});
bg.on('pointerdown', onClick);
return { bg, text };
}

View File

@ -0,0 +1,205 @@
import Phaser from 'phaser';
import type { CombatEntity, EnemyEntity, EffectTable } from 'boardgame-core/samples/slay-the-spire-like';
export interface CombatUnitWidgetOptions {
scene: Phaser.Scene;
x: number;
y: number;
entity: CombatEntity;
width?: number;
height?: number;
}
const HP_BAR_WIDTH = 180;
const HP_BAR_HEIGHT = 16;
const BUFF_ICON_SIZE = 28;
const BUFF_ICON_GAP = 6;
const POSITIVE_EFFECTS = new Set(['block', 'strength', 'dexterity', 'regen']);
const NEGATIVE_EFFECTS = new Set(['weak', 'vulnerable', 'frail', 'poison']);
export class CombatUnitWidget {
private scene: Phaser.Scene;
private container: Phaser.GameObjects.Container;
private entity: CombatEntity;
private width: number;
private height: number;
private nameText!: Phaser.GameObjects.Text;
private hpBarBg!: Phaser.GameObjects.Graphics;
private hpBarFill!: Phaser.GameObjects.Graphics;
private hpText!: Phaser.GameObjects.Text;
private buffContainer!: Phaser.GameObjects.Container;
private buffIcons: Phaser.GameObjects.Container[] = [];
constructor(options: CombatUnitWidgetOptions) {
this.scene = options.scene;
this.entity = options.entity;
this.width = options.width ?? 240;
this.height = options.height ?? 120;
this.container = this.scene.add.container(options.x, options.y);
this.container.setSize(this.width, this.height);
this.drawBackground();
this.drawName();
this.drawHpBar();
this.drawBuffs();
}
private drawBackground(): void {
const bg = this.scene.add.rectangle(0, 0, this.width, this.height, 0x1a1a2e, 0.9)
.setStrokeStyle(2, 0x444477)
.setOrigin(0, 0);
this.container.add(bg);
}
private drawName(): void {
const entityName = 'enemy' in this.entity
? (this.entity as EnemyEntity).enemy.name
: 'Player';
this.nameText = this.scene.add.text(this.width / 2, 12, entityName, {
fontSize: '16px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5, 0);
this.container.add(this.nameText);
}
private drawHpBar(): void {
const barX = (this.width - HP_BAR_WIDTH) / 2;
const barY = 36;
this.hpBarBg = this.scene.add.graphics();
this.hpBarBg.fillStyle(0x333333);
this.hpBarBg.fillRoundedRect(barX, barY, HP_BAR_WIDTH, HP_BAR_HEIGHT, 4);
this.hpBarBg.lineStyle(1, 0x666666);
this.hpBarBg.strokeRoundedRect(barX, barY, HP_BAR_WIDTH, HP_BAR_HEIGHT, 4);
this.container.add(this.hpBarBg);
this.hpBarFill = this.scene.add.graphics();
this.container.add(this.hpBarFill);
this.hpText = this.scene.add.text(this.width / 2, barY + HP_BAR_HEIGHT / 2, '', {
fontSize: '12px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
this.container.add(this.hpText);
this.updateHpBar();
}
private updateHpBar(): void {
const barX = (this.width - HP_BAR_WIDTH) / 2;
const barY = 36;
const ratio = Math.max(0, this.entity.hp / this.entity.maxHp);
this.hpBarFill.clear();
let fillColor: number;
if (ratio > 0.6) fillColor = 0x44aa44;
else if (ratio > 0.3) fillColor = 0xccaa44;
else fillColor = 0xcc4444;
const fillWidth = Math.max(0, (HP_BAR_WIDTH - 2) * ratio);
if (fillWidth > 0) {
this.hpBarFill.fillStyle(fillColor);
this.hpBarFill.fillRoundedRect(barX + 1, barY + 1, fillWidth, HP_BAR_HEIGHT - 2, 3);
}
this.hpText.setText(`${this.entity.hp}/${this.entity.maxHp}`);
}
private drawBuffs(): void {
this.buffContainer = this.scene.add.container(10, 62);
this.container.add(this.buffContainer);
this.refreshBuffs();
}
private refreshBuffs(): void {
for (const icon of this.buffIcons) {
icon.destroy();
}
this.buffIcons = [];
const effects = this.entity.effects;
let x = 0;
const y = 0;
for (const [effectId, entry] of Object.entries(effects)) {
if (entry.stacks <= 0) continue;
const icon = this.createBuffIcon(effectId, entry);
icon.setPosition(x, y);
this.buffContainer.add(icon);
this.buffIcons.push(icon);
x += BUFF_ICON_SIZE + BUFF_ICON_GAP;
if (x + BUFF_ICON_SIZE > this.width - 20) {
x = 0;
}
}
}
private createBuffIcon(effectId: string, entry: { data: { name: string; description: string }; stacks: number }): Phaser.GameObjects.Container {
const icon = this.scene.add.container(0, 0);
const isPositive = POSITIVE_EFFECTS.has(effectId.toLowerCase());
const isNegative = NEGATIVE_EFFECTS.has(effectId.toLowerCase());
const bgColor = isPositive ? 0x226644 : isNegative ? 0x662222 : 0x444466;
const borderColor = isPositive ? 0x44aa88 : isNegative ? 0xaa4444 : 0x7777aa;
const bg = this.scene.add.rectangle(0, 0, BUFF_ICON_SIZE, BUFF_ICON_SIZE, bgColor, 1)
.setStrokeStyle(2, borderColor)
.setOrigin(0, 0);
icon.add(bg);
const label = this.getEffectLabel(effectId);
const text = this.scene.add.text(BUFF_ICON_SIZE / 2, 2, label, {
fontSize: '9px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5, 0);
icon.add(text);
const stackText = this.scene.add.text(BUFF_ICON_SIZE / 2, BUFF_ICON_SIZE - 2, `${entry.stacks}`, {
fontSize: '10px',
color: '#ffcc44',
fontStyle: 'bold',
}).setOrigin(0.5, 1);
icon.add(stackText);
return icon;
}
private getEffectLabel(effectId: string): string {
const labels: Record<string, string> = {
block: '🛡',
strength: '💪',
dexterity: '🤸',
regen: '💚',
weak: '⚡',
vulnerable: '🔥',
frail: '🩹',
poison: '☠',
};
return labels[effectId.toLowerCase()] ?? effectId.substring(0, 3).toUpperCase();
}
public update(entity: CombatEntity): void {
this.entity = entity;
this.updateHpBar();
this.refreshBuffs();
}
public destroy(): void {
this.container.destroy();
}
public getContainer(): Phaser.GameObjects.Container {
return this.container;
}
}

View File

@ -1,477 +0,0 @@
import Phaser from "phaser";
import {
type InventoryItem,
type GameItemMeta,
type GridInventory,
validatePlacement,
transformShape,
} from "boardgame-core/samples/slay-the-spire-like";
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
import { DisposableBag } from "boardgame-phaser";
export interface DragSession {
itemId: string;
itemShape: InventoryItem<GameItemMeta>["shape"];
itemTransform: InventoryItem<GameItemMeta>["transform"];
itemMeta: InventoryItem<GameItemMeta>["meta"];
ghostContainer: Phaser.GameObjects.Container;
previewGraphics: Phaser.GameObjects.Graphics;
disposables: DisposableBag;
}
export interface DragControllerOptions {
scene: Phaser.Scene;
container: Phaser.GameObjects.Container;
cellSize: number;
gridGap: number;
gridX: number;
gridY: number;
getInventory: () => GridInventory<GameItemMeta>;
getItemColor: (itemId: string) => number;
onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
onCreateLostItem: (
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
x: number,
y: number,
) => void;
}
/**
* Event-driven drag controller using dragDropEventEffect from boardgame-phaser.
* Manages ghost visuals, placement preview, rotation, and validation.
*/
export class DragController {
private scene: Phaser.Scene;
private container: Phaser.GameObjects.Container;
private cellSize: number;
private gridGap: number;
private gridX: number;
private gridY: number;
private getInventory: () => GridInventory<GameItemMeta>;
private getItemColor: (itemId: string) => number;
private onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
private onCreateLostItem: (
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
x: number,
y: number,
) => void;
private activeSession: DragSession | null = null;
constructor(options: DragControllerOptions) {
this.scene = options.scene;
this.container = options.container;
this.cellSize = options.cellSize;
this.gridGap = options.gridGap;
this.gridX = options.gridX;
this.gridY = options.gridY;
this.getInventory = options.getInventory;
this.getItemColor = options.getItemColor;
this.onPlaceItem = options.onPlaceItem;
this.onCreateLostItem = options.onCreateLostItem;
}
/**
* Start a drag session for an inventory item.
* Uses dragDropEventEffect for pointer tracking and event emission.
*/
startDrag(
itemId: string,
item: InventoryItem<GameItemMeta>,
itemContainer: Phaser.GameObjects.Container,
): () => void {
const cells = this.getItemCells(item);
const firstCell = cells[0];
const worldX =
this.container.x +
this.gridX +
firstCell.x * (this.cellSize + this.gridGap);
const worldY =
this.container.y +
this.gridY +
firstCell.y * (this.cellSize + this.gridGap);
const ghostContainer = this.createGhostContainer(
worldX,
worldY,
item.shape,
item.transform,
this.getItemColor(itemId),
);
const previewGraphics = this.scene.add
.graphics()
.setDepth(999)
.setAlpha(0.5);
const disposables = new DisposableBag();
const session: DragSession = {
itemId,
itemShape: item.shape,
itemTransform: {
...item.transform,
offset: { ...item.transform.offset },
},
itemMeta: item.meta,
ghostContainer,
previewGraphics,
disposables,
};
this.activeSession = session;
// Set up drag-drop event handling via framework utility
const disposeDrag = dragDropEventEffect(
itemContainer as Phaser.GameObjects.GameObject,
disposables,
);
itemContainer.on("dragstart", () => {
ghostContainer.setVisible(true);
});
itemContainer.on("dragmove", () => {
this.handleDragMove(session);
});
itemContainer.on("dragend", () => {
this.handleDragEnd(session);
disposeDrag();
this.activeSession = null;
});
return () => {
disposeDrag();
this.destroySession(session);
this.activeSession = null;
};
}
/**
* Start a drag session for a lost item.
*/
startLostItemDrag(
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
lostContainer: Phaser.GameObjects.Container,
): () => void {
const pointer = this.scene.input.activePointer;
const ghostContainer = this.createGhostContainer(
pointer.x,
pointer.y,
shape,
transform,
this.getItemColor(itemId),
);
const previewGraphics = this.scene.add
.graphics()
.setDepth(999)
.setAlpha(0.5);
const disposables = new DisposableBag();
const session: DragSession = {
itemId,
itemShape: shape,
itemTransform: { ...transform, offset: { ...transform.offset } },
itemMeta: meta,
ghostContainer,
previewGraphics,
disposables,
};
this.activeSession = session;
const disposeDrag = dragDropEventEffect(
lostContainer as Phaser.GameObjects.GameObject,
disposables,
);
lostContainer.on("dragstart", () => {
ghostContainer.setVisible(true);
});
lostContainer.on("dragmove", () => {
this.handleDragMove(session);
});
lostContainer.on("dragend", () => {
this.handleDragEnd(session);
disposeDrag();
this.activeSession = null;
});
return () => {
disposeDrag();
this.destroySession(session);
this.activeSession = null;
};
}
/**
* Rotate the currently dragged item by 90 degrees.
*/
rotateDraggedItem(): void {
if (!this.activeSession) return;
const currentRotation =
(this.activeSession.itemTransform.rotation + 90) % 360;
this.activeSession.itemTransform = {
...this.activeSession.itemTransform,
rotation: currentRotation,
};
this.updateGhostVisuals(this.activeSession);
}
/**
* Check if currently dragging.
*/
isDragging(): boolean {
return this.activeSession !== null;
}
/**
* Get the ID of the item being dragged, or null.
*/
getDraggedItemId(): string | null {
return this.activeSession?.itemId ?? null;
}
/**
* Get the current position of the dragged ghost container.
*/
getDraggedItemPosition(): { x: number; y: number } {
if (!this.activeSession) return { x: 0, y: 0 };
return {
x: this.activeSession.ghostContainer.x,
y: this.activeSession.ghostContainer.y,
};
}
/**
* Clean up active session and destroy all visuals.
*/
destroy(): void {
if (this.activeSession) {
this.destroySession(this.activeSession);
this.activeSession = null;
}
}
private createGhostContainer(
x: number,
y: number,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
color: number,
): Phaser.GameObjects.Container {
const ghostContainer = this.scene.add.container(x, y).setDepth(1000);
const ghostGraphics = this.scene.add.graphics();
const cells = transformShape(shape, transform);
for (const cell of cells) {
ghostGraphics.fillStyle(color, 0.7);
ghostGraphics.fillRect(
cell.x * (this.cellSize + this.gridGap),
cell.y * (this.cellSize + this.gridGap),
this.cellSize - 2,
this.cellSize - 2,
);
ghostGraphics.lineStyle(2, 0xffffff);
ghostGraphics.strokeRect(
cell.x * (this.cellSize + this.gridGap),
cell.y * (this.cellSize + this.gridGap),
this.cellSize,
this.cellSize,
);
}
ghostContainer.add(ghostGraphics);
return ghostContainer;
}
private updateGhostVisuals(session: DragSession): void {
session.ghostContainer.removeAll(true);
const ghostGraphics = this.scene.add.graphics();
const color = this.getItemColor(session.itemId);
const cells = transformShape(session.itemShape, session.itemTransform);
for (const cell of cells) {
ghostGraphics.fillStyle(color, 0.7);
ghostGraphics.fillRect(
cell.x * (this.cellSize + this.gridGap),
cell.y * (this.cellSize + this.gridGap),
this.cellSize - 2,
this.cellSize - 2,
);
ghostGraphics.lineStyle(2, 0xffffff);
ghostGraphics.strokeRect(
cell.x * (this.cellSize + this.gridGap),
cell.y * (this.cellSize + this.gridGap),
this.cellSize,
this.cellSize,
);
}
session.ghostContainer.add(ghostGraphics);
}
private handleDragMove(session: DragSession): void {
const pointer = this.scene.input.activePointer;
session.ghostContainer.setPosition(pointer.x, pointer.y);
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
session.previewGraphics.clear();
if (gridCell) {
const inventory = this.getInventory();
const testTransform = {
...session.itemTransform,
offset: { x: gridCell.x, y: gridCell.y },
};
const validation = validatePlacement(
inventory,
session.itemShape,
testTransform,
);
const cells = transformShape(session.itemShape, testTransform);
for (const cell of cells) {
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
if (validation.valid) {
session.previewGraphics.fillStyle(0x33ff33, 0.3);
session.previewGraphics.fillRect(
px,
py,
this.cellSize,
this.cellSize,
);
session.previewGraphics.lineStyle(2, 0x33ff33);
session.previewGraphics.strokeRect(
px,
py,
this.cellSize,
this.cellSize,
);
} else {
session.previewGraphics.fillStyle(0xff3333, 0.3);
session.previewGraphics.fillRect(
px,
py,
this.cellSize,
this.cellSize,
);
session.previewGraphics.lineStyle(2, 0xff3333);
session.previewGraphics.strokeRect(
px,
py,
this.cellSize,
this.cellSize,
);
}
}
}
}
private handleDragEnd(session: DragSession): void {
const pointer = this.scene.input.activePointer;
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
const inventory = this.getInventory();
session.ghostContainer.destroy();
session.previewGraphics.destroy();
session.disposables.dispose();
if (gridCell) {
const testTransform = {
...session.itemTransform,
offset: { x: gridCell.x, y: gridCell.y },
};
const validation = validatePlacement(
inventory,
session.itemShape,
testTransform,
);
if (validation.valid) {
const item: InventoryItem<GameItemMeta> = {
id: session.itemId,
shape: session.itemShape,
transform: testTransform,
meta: session.itemMeta,
};
this.onPlaceItem(item);
} else {
this.onCreateLostItem(
session.itemId,
session.itemShape,
session.itemTransform,
session.itemMeta,
session.ghostContainer.x,
session.ghostContainer.y,
);
}
} else {
this.onCreateLostItem(
session.itemId,
session.itemShape,
session.itemTransform,
session.itemMeta,
session.ghostContainer.x,
session.ghostContainer.y,
);
}
}
private getWorldGridCell(
worldX: number,
worldY: number,
): { x: number; y: number } | null {
const localX = worldX - this.container.x - this.gridX;
const localY = worldY - this.container.y - this.gridY;
const cellX = Math.floor(localX / (this.cellSize + this.gridGap));
const cellY = Math.floor(localY / (this.cellSize + this.gridGap));
const inventory = this.getInventory();
if (
cellX < 0 ||
cellY < 0 ||
cellX >= inventory.width ||
cellY >= inventory.height
) {
return null;
}
return { x: cellX, y: cellY };
}
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 destroySession(session: DragSession): void {
session.disposables.dispose();
session.ghostContainer.destroy();
session.previewGraphics.destroy();
}
}

View File

@ -1,71 +0,0 @@
import Phaser from "phaser";
export interface GridBackgroundRendererOptions {
scene: Phaser.Scene;
parentContainer: Phaser.GameObjects.Container;
cellSize: number;
gridGap: number;
gridX: number;
gridY: number;
/** Background fill color for each cell */
cellBgColor?: number;
/** Border/stroke color for each cell */
cellBorderColor?: number;
}
/**
* Renders the static grid background (empty cells with borders).
* Separated from item rendering so it can be drawn once and left alone.
*/
export class GridBackgroundRenderer {
private scene: Phaser.Scene;
private parentContainer: Phaser.GameObjects.Container;
private cellSize: number;
private gridGap: number;
private gridX: number;
private gridY: number;
private cellBgColor: number;
private cellBorderColor: number;
private graphics!: Phaser.GameObjects.Graphics;
constructor(options: GridBackgroundRendererOptions) {
this.scene = options.scene;
this.parentContainer = options.parentContainer;
this.cellSize = options.cellSize;
this.gridGap = options.gridGap;
this.gridX = options.gridX;
this.gridY = options.gridY;
this.cellBgColor = options.cellBgColor ?? 0x1a1a2e;
this.cellBorderColor = options.cellBorderColor ?? 0x444477;
}
/**
* Draw the grid background for the given dimensions.
* Should be called once during initialization.
*/
draw(width: number, height: number): void {
this.graphics = this.scene.add.graphics();
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const px = this.gridX + x * (this.cellSize + this.gridGap);
const py = this.gridY + y * (this.cellSize + this.gridGap);
this.graphics.fillStyle(this.cellBgColor);
this.graphics.fillRect(px, py, this.cellSize, this.cellSize);
this.graphics.lineStyle(2, this.cellBorderColor);
this.graphics.strokeRect(px, py, this.cellSize, this.cellSize);
}
}
this.parentContainer.add(this.graphics);
}
/**
* Destroy the graphics object.
*/
destroy(): void {
this.graphics?.destroy();
}
}

View File

@ -1,277 +0,0 @@
import Phaser from "phaser";
import { MutableSignal } from "boardgame-core";
import {
type InventoryItem,
type GameItemMeta,
type RunState,
type GridInventory,
} from "boardgame-core/samples/slay-the-spire-like";
import type { Spawner } from "boardgame-phaser";
const ITEM_COLORS = [
0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33,
0xff6633,
];
export interface InventoryItemSpawnerOptions {
scene: Phaser.Scene;
gameState: MutableSignal<RunState>;
parentContainer: Phaser.GameObjects.Container;
cellSize: number;
gridGap: number;
gridX: number;
gridY: number;
isLocked: () => boolean;
isDragging: () => boolean;
onItemDragStart: (
itemId: string,
item: InventoryItem<GameItemMeta>,
itemContainer: Phaser.GameObjects.Container,
) => void;
}
/**
* Spawner for inventory items using the boardgame-phaser Spawner pattern.
* Reactively spawns/despawns/updates item visuals when gameState.inventory changes.
*
* Items currently being dragged are excluded from getData() to prevent
* the spawner from respawning them while they're in flight.
*/
export class InventoryItemSpawner implements Spawner<
InventoryItem<GameItemMeta>,
Phaser.GameObjects.Container
> {
private scene: Phaser.Scene;
private gameState: MutableSignal<RunState>;
private parentContainer: Phaser.GameObjects.Container;
private cellSize: number;
private gridGap: number;
private gridX: number;
private gridY: number;
private isLocked: () => boolean;
private isDragging: () => boolean;
private onItemDragStart: (
itemId: string,
item: InventoryItem<GameItemMeta>,
itemContainer: Phaser.GameObjects.Container,
) => void;
private colorMap = new Map<string, number>();
private colorIdx = 0;
private draggingIds = new Set<string>();
constructor(options: InventoryItemSpawnerOptions) {
this.scene = options.scene;
this.gameState = options.gameState;
this.parentContainer = options.parentContainer;
this.cellSize = options.cellSize;
this.gridGap = options.gridGap;
this.gridX = options.gridX;
this.gridY = options.gridY;
this.isLocked = options.isLocked;
this.isDragging = options.isDragging;
this.onItemDragStart = options.onItemDragStart;
}
*getData(): Iterable<InventoryItem<GameItemMeta>> {
const inventory = this.getInventory();
for (const [, item] of inventory.items) {
if (!this.draggingIds.has(item.id)) {
yield item;
}
}
}
getKey(item: InventoryItem<GameItemMeta>): string {
return item.id;
}
onSpawn(item: InventoryItem<GameItemMeta>): Phaser.GameObjects.Container {
const color =
this.colorMap.get(item.id) ??
ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length];
this.colorMap.set(item.id, color);
const container = this.createItemVisuals(item, color);
this.setupInteraction(item, container);
this.parentContainer.add(container);
return container;
}
onDespawn(
obj: Phaser.GameObjects.Container,
_item: InventoryItem<GameItemMeta>,
): void {
obj.removeAllListeners();
obj.destroy();
}
onUpdate(
item: InventoryItem<GameItemMeta>,
obj: Phaser.GameObjects.Container,
): void {
const color = this.colorMap.get(item.id) ?? 0x888888;
this.rebuildItemVisuals(obj, item, color);
}
/**
* Mark an item as being dragged so the spawner excludes it from getData().
* Call this before removing the item from the inventory.
*/
markDragging(itemId: string): void {
this.draggingIds.add(itemId);
}
/**
* Unmark an item after drag ends (placed or lost).
*/
unmarkDragging(itemId: string): void {
this.draggingIds.delete(itemId);
}
/**
* Get the color assigned to an item (creates one if not yet assigned).
*/
getItemColor(itemId: string): number {
if (!this.colorMap.has(itemId)) {
this.colorMap.set(
itemId,
ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length],
);
}
return this.colorMap.get(itemId)!;
}
private getInventory(): GridInventory<GameItemMeta> {
return this.gameState.value
.inventory as unknown as GridInventory<GameItemMeta>;
}
private createItemVisuals(
item: InventoryItem<GameItemMeta>,
color: number,
): Phaser.GameObjects.Container {
const container = this.scene.add.container(0, 0);
const graphics = this.scene.add.graphics();
const cells = this.getItemCells(item);
for (const cell of cells) {
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
graphics.fillStyle(color);
graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2);
graphics.lineStyle(2, 0xffffff);
graphics.strokeRect(px, py, this.cellSize, this.cellSize);
}
const firstCell = cells[0];
const name = item.meta?.itemData.name ?? item.id;
const fontSize = Math.max(10, Math.floor(this.cellSize / 5));
const text = this.scene.add
.text(
this.gridX +
firstCell.x * (this.cellSize + this.gridGap) +
this.cellSize / 2,
this.gridY +
firstCell.y * (this.cellSize + this.gridGap) +
this.cellSize / 2,
name,
{ fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" },
)
.setOrigin(0.5);
container.add(graphics);
container.add(text);
const hitRect = new Phaser.Geom.Rectangle(
this.gridX + firstCell.x * (this.cellSize + this.gridGap),
this.gridY + firstCell.y * (this.cellSize + this.gridGap),
this.cellSize,
this.cellSize,
);
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
return container;
}
private rebuildItemVisuals(
container: Phaser.GameObjects.Container,
item: InventoryItem<GameItemMeta>,
color: number,
): void {
container.removeAll(true);
const graphics = this.scene.add.graphics();
const cells = this.getItemCells(item);
for (const cell of cells) {
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
graphics.fillStyle(color);
graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2);
graphics.lineStyle(2, 0xffffff);
graphics.strokeRect(px, py, this.cellSize, this.cellSize);
}
const firstCell = cells[0];
const name = item.meta?.itemData.name ?? item.id;
const fontSize = Math.max(10, Math.floor(this.cellSize / 5));
const text = this.scene.add
.text(
this.gridX +
firstCell.x * (this.cellSize + this.gridGap) +
this.cellSize / 2,
this.gridY +
firstCell.y * (this.cellSize + this.gridGap) +
this.cellSize / 2,
name,
{ fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" },
)
.setOrigin(0.5);
container.add(graphics);
container.add(text);
const hitRect = new Phaser.Geom.Rectangle(
this.gridX + firstCell.x * (this.cellSize + this.gridGap),
this.gridY + firstCell.y * (this.cellSize + this.gridGap),
this.cellSize,
this.cellSize,
);
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
}
private setupInteraction(
item: InventoryItem<GameItemMeta>,
container: Phaser.GameObjects.Container,
): void {
container.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
// Guard against stale events firing on destroyed containers
if (!container.scene || !container.active) return;
if (this.isLocked()) return;
if (this.isDragging()) return;
if (pointer.button === 0) {
this.onItemDragStart(item.id, item, container);
}
});
}
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;
}
}

View File

@ -1,18 +1,20 @@
import Phaser from "phaser"; import Phaser from 'phaser';
import { MutableSignal } from "boardgame-core"; import { MutableSignal } from 'boardgame-core';
import { spawnEffect } from "boardgame-phaser";
import { import {
type GridInventory, type GridInventory,
type InventoryItem, type InventoryItem,
type GameItemMeta, type GameItemMeta,
type RunState, type RunState,
type CellKey,
validatePlacement,
removeItemFromGrid, removeItemFromGrid,
placeItem, placeItem,
} from "boardgame-core/samples/slay-the-spire-like"; moveItem,
import { InventoryItemSpawner } from "./InventoryItemSpawner"; rotateItem,
import { GridBackgroundRenderer } from "./GridBackgroundRenderer"; transformShape,
import { DragController } from "./DragController"; } from 'boardgame-core/samples/slay-the-spire-like';
import { LostItemManager } from "./LostItemManager";
const ITEM_COLORS = [0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33, 0xff6633];
export interface InventoryWidgetOptions { export interface InventoryWidgetOptions {
scene: Phaser.Scene; scene: Phaser.Scene;
@ -24,15 +26,25 @@ export interface InventoryWidgetOptions {
isLocked?: boolean; isLocked?: boolean;
} }
/** interface DragState {
* Inventory widget using the Spawner pattern for reactive item rendering. itemId: string;
* itemShape: InventoryItem<GameItemMeta>['shape'];
* Architecture: itemTransform: InventoryItem<GameItemMeta>['transform'];
* - InventoryItemSpawner + spawnEffect: reactive spawn/despawn/update of item visuals itemMeta: InventoryItem<GameItemMeta>['meta'];
* - GridBackgroundRenderer: static grid background drawn once ghostContainer: Phaser.GameObjects.Container;
* - DragController: event-driven drag logic via dragDropEventEffect previewGraphics: Phaser.GameObjects.Graphics;
* - LostItemManager: tracks items dropped outside valid placement dragOffsetX: number;
*/ dragOffsetY: number;
}
interface LostItem {
id: string;
container: Phaser.GameObjects.Container;
shape: InventoryItem<GameItemMeta>['shape'];
transform: InventoryItem<GameItemMeta>['transform'];
meta: InventoryItem<GameItemMeta>['meta'];
}
export class InventoryWidget { export class InventoryWidget {
private scene: Phaser.Scene; private scene: Phaser.Scene;
private gameState: MutableSignal<RunState>; private gameState: MutableSignal<RunState>;
@ -43,13 +55,18 @@ export class InventoryWidget {
private gridY = 0; private gridY = 0;
private isLocked: boolean; private isLocked: boolean;
private itemSpawner: InventoryItemSpawner; private itemContainers = new Map<string, Phaser.GameObjects.Container>();
private backgroundRenderer: GridBackgroundRenderer; private itemGraphics = new Map<string, Phaser.GameObjects.Graphics>();
private dragController: DragController; private itemTexts = new Map<string, Phaser.GameObjects.Text>();
private lostItemManager: LostItemManager; private colorMap = new Map<string, number>();
private colorIdx = 0;
private spawnDispose: (() => void) | null = null; private gridGraphics!: Phaser.GameObjects.Graphics;
private rightClickHandler!: (pointer: Phaser.Input.Pointer) => void; private dragState: DragState | null = null;
private lostItems = new Map<string, LostItem>();
private pointerMoveHandler: (pointer: Phaser.Input.Pointer) => void;
private pointerUpHandler: (pointer: Phaser.Input.Pointer) => void;
constructor(options: InventoryWidgetOptions) { constructor(options: InventoryWidgetOptions) {
this.scene = options.scene; this.scene = options.scene;
@ -58,146 +75,386 @@ export class InventoryWidget {
this.gridGap = options.gridGap ?? 2; this.gridGap = options.gridGap ?? 2;
this.isLocked = options.isLocked ?? false; this.isLocked = options.isLocked ?? false;
const inventory = this.getInventory(); const inventory = this.gameState.value.inventory;
const gridW = inventory.width * this.cellSize + (inventory.width - 1) * this.gridGap;
const gridH = inventory.height * this.cellSize + (inventory.height - 1) * this.gridGap;
this.container = this.scene.add.container(options.x, options.y); this.container = this.scene.add.container(options.x, options.y);
// 1. Static grid background (drawn once) this.pointerMoveHandler = this.onPointerMove.bind(this);
this.backgroundRenderer = new GridBackgroundRenderer({ this.pointerUpHandler = this.onPointerUp.bind(this);
scene: this.scene,
parentContainer: this.container,
cellSize: this.cellSize,
gridGap: this.gridGap,
gridX: this.gridX,
gridY: this.gridY,
});
this.backgroundRenderer.draw(inventory.width, inventory.height);
// 2. Reactive item spawner this.drawGridBackground(inventory.width, inventory.height, gridW, gridH);
this.itemSpawner = new InventoryItemSpawner({ this.drawItems();
scene: this.scene,
gameState: this.gameState,
parentContainer: this.container,
cellSize: this.cellSize,
gridGap: this.gridGap,
gridX: this.gridX,
gridY: this.gridY,
isLocked: () => this.isLocked,
isDragging: () => this.dragController.isDragging(),
onItemDragStart: (itemId, item, itemContainer) => {
this.handleItemDragStart(itemId, item, itemContainer);
},
});
// 3. Drag controller
this.dragController = new DragController({
scene: this.scene,
container: this.container,
cellSize: this.cellSize,
gridGap: this.gridGap,
gridX: this.gridX,
gridY: this.gridY,
getInventory: () => this.getInventory(),
getItemColor: (id) => this.itemSpawner.getItemColor(id),
onPlaceItem: (item) => this.handlePlaceItem(item),
onCreateLostItem: (id, shape, transform, meta, x, y) =>
this.handleCreateLostItem(id, shape, transform, meta, x, y),
});
// 4. Lost item manager
this.lostItemManager = new LostItemManager({
scene: this.scene,
cellSize: this.cellSize,
gridGap: this.gridGap,
getItemColor: (id) => this.itemSpawner.getItemColor(id),
onLostItemDragStart: (id, lostContainer) =>
this.dragController.startLostItemDrag(
id,
this.getLostItemShape(id),
this.getLostItemTransform(id),
this.getLostItemMeta(id),
lostContainer,
),
isDragging: () => this.dragController.isDragging(),
});
// Activate the spawner effect (auto-cleans up on dispose)
this.spawnDispose = spawnEffect(this.itemSpawner);
// Right-click rotation handler
this.setupInput(); this.setupInput();
this.scene.events.once("shutdown", () => this.destroy()); this.scene.events.once('shutdown', () => this.destroy());
} }
private getInventory(): GridInventory<GameItemMeta> { private getInventory(): GridInventory<GameItemMeta> {
return this.gameState.value return this.gameState.value.inventory as unknown as GridInventory<GameItemMeta>;
.inventory as unknown as GridInventory<GameItemMeta>;
} }
private handleItemDragStart( private drawGridBackground(width: number, height: number, gridW: number, gridH: number): void {
itemId: string, this.gridGraphics = this.scene.add.graphics();
item: InventoryItem<GameItemMeta>,
itemContainer: Phaser.GameObjects.Container,
): void {
// Mark as dragging FIRST so spawner excludes it from getData().
// This prevents the spawner effect from destroying the container
// when we later update the inventory state.
this.itemSpawner.markDragging(itemId);
// Start drag session for (let y = 0; y < height; y++) {
this.dragController.startDrag(itemId, item, itemContainer); for (let x = 0; x < width; x++) {
const px = this.gridX + x * (this.cellSize + this.gridGap);
const py = this.gridY + y * (this.cellSize + this.gridGap);
this.gridGraphics.fillStyle(0x1a1a2e);
this.gridGraphics.fillRect(px, py, this.cellSize, this.cellSize);
this.gridGraphics.lineStyle(2, 0x444477);
this.gridGraphics.strokeRect(px, py, this.cellSize, this.cellSize);
}
}
this.container.add(this.gridGraphics);
} }
private handlePlaceItem(item: InventoryItem<GameItemMeta>): void { private drawItems(): void {
this.gameState.produce((state) => { const inventory = this.getInventory();
placeItem(state.inventory, item);
for (const [itemId, item] of inventory.items) {
if (this.itemContainers.has(itemId)) continue;
this.createItemVisuals(itemId, item);
}
}
private createItemVisuals(itemId: string, item: InventoryItem<GameItemMeta>): void {
const color = this.colorMap.get(itemId) ?? ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length];
this.colorMap.set(itemId, color);
const graphics = this.scene.add.graphics();
this.itemGraphics.set(itemId, graphics);
const cells = this.getItemCells(item);
for (const cell of cells) {
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
graphics.fillStyle(color);
graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2);
graphics.lineStyle(2, 0xffffff);
graphics.strokeRect(px, py, this.cellSize, this.cellSize);
}
const firstCell = cells[0];
const name = item.meta?.itemData.name ?? item.id;
const fontSize = Math.max(10, Math.floor(this.cellSize / 5));
const text = this.scene.add.text(
this.gridX + firstCell.x * (this.cellSize + this.gridGap) + this.cellSize / 2,
this.gridY + firstCell.y * (this.cellSize + this.gridGap) + this.cellSize / 2,
name,
{ fontSize: `${fontSize}px`, color: '#fff', fontStyle: 'bold' }
).setOrigin(0.5);
this.itemTexts.set(itemId, text);
const hitRect = new Phaser.Geom.Rectangle(
this.gridX + firstCell.x * (this.cellSize + this.gridGap),
this.gridY + firstCell.y * (this.cellSize + this.gridGap),
this.cellSize, this.cellSize
);
const container = this.scene.add.container(0, 0);
container.add(graphics);
container.add(text);
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
container.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
if (this.isLocked) return;
if (this.dragState) return;
if (pointer.button === 0) {
this.startDrag(itemId, pointer);
}
}); });
// Unmark dragging so spawner picks it up on next effect run this.itemContainers.set(itemId, container);
this.itemSpawner.unmarkDragging(item.id); this.container.add(container);
} }
private handleCreateLostItem( private getItemCells(item: InventoryItem<GameItemMeta>): { x: number; y: number }[] {
itemId: string, const cells: { x: number; y: number }[] = [];
shape: InventoryItem<GameItemMeta>["shape"], const { offset } = item.transform;
transform: InventoryItem<GameItemMeta>["transform"], for (let y = 0; y < item.shape.height; y++) {
meta: InventoryItem<GameItemMeta>["meta"], for (let x = 0; x < item.shape.width; x++) {
x: number, if (item.shape.grid[y]?.[x]) {
y: number, cells.push({ x: x + offset.x, y: y + offset.y });
): void { }
// Remove from inventory since it's dropped outside valid placement }
this.gameState.produce((state) => { }
removeItemFromGrid(state.inventory, itemId); return cells;
});
this.lostItemManager.createLostItem(itemId, shape, transform, meta, x, y);
// Unmark dragging — item is now "lost" and managed by LostItemManager
this.itemSpawner.unmarkDragging(itemId);
}
private getLostItemShape(itemId: string) {
return this.lostItemManager.getLostItem(itemId)?.shape!;
}
private getLostItemTransform(itemId: string) {
return this.lostItemManager.getLostItem(itemId)?.transform!;
}
private getLostItemMeta(itemId: string) {
return this.lostItemManager.getLostItem(itemId)?.meta!;
} }
private setupInput(): void { private setupInput(): void {
this.rightClickHandler = (pointer: Phaser.Input.Pointer) => { this.scene.input.on('pointermove', this.pointerMoveHandler);
if (!this.dragController.isDragging()) return; this.scene.input.on('pointerup', this.pointerUpHandler);
if (pointer.button === 1) { this.scene.input.on('pointerdown', this.onPointerDown.bind(this));
this.dragController.rotateDraggedItem(); }
private onPointerDown(pointer: Phaser.Input.Pointer): void {
if (!this.dragState) return;
if (pointer.button === 1) {
this.rotateDraggedItem();
}
}
private startDrag(itemId: string, pointer: Phaser.Input.Pointer): void {
const inventory = this.getInventory();
const item = inventory.items.get(itemId);
if (!item) return;
this.gameState.produce(state => {
removeItemFromGrid(state.inventory, itemId);
});
this.removeItemVisuals(itemId);
const cells = this.getItemCells(item);
const firstCell = cells[0];
const itemWorldX = this.container.x + this.gridX + firstCell.x * (this.cellSize + this.gridGap);
const itemWorldY = this.container.y + this.gridY + firstCell.y * (this.cellSize + this.gridGap);
const dragOffsetX = pointer.x - itemWorldX;
const dragOffsetY = pointer.y - itemWorldY;
const ghostContainer = this.scene.add.container(itemWorldX, itemWorldY).setDepth(1000);
const ghostGraphics = this.scene.add.graphics();
const color = this.colorMap.get(itemId) ?? 0x888888;
for (let y = 0; y < item.shape.height; y++) {
for (let x = 0; x < item.shape.width; x++) {
if (item.shape.grid[y]?.[x]) {
ghostGraphics.fillStyle(color, 0.7);
ghostGraphics.fillRect(x * (this.cellSize + this.gridGap), y * (this.cellSize + this.gridGap), this.cellSize - 2, this.cellSize - 2);
ghostGraphics.lineStyle(2, 0xffffff);
ghostGraphics.strokeRect(x * (this.cellSize + this.gridGap), y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize);
}
} }
}
ghostContainer.add(ghostGraphics);
const previewGraphics = this.scene.add.graphics().setDepth(999).setAlpha(0.5);
this.dragState = {
itemId,
itemShape: item.shape,
itemTransform: { ...item.transform, offset: { ...item.transform.offset } },
itemMeta: item.meta,
ghostContainer,
previewGraphics,
dragOffsetX,
dragOffsetY,
};
}
private startLostItemDrag(itemId: string, pointer: Phaser.Input.Pointer): void {
const lost = this.lostItems.get(itemId);
if (!lost) return;
lost.container.destroy();
this.lostItems.delete(itemId);
const ghostContainer = this.scene.add.container(pointer.x, pointer.y).setDepth(1000);
const ghostGraphics = this.scene.add.graphics();
const color = this.colorMap.get(itemId) ?? 0x888888;
const cells = transformShape(lost.shape, lost.transform);
for (const cell of cells) {
ghostGraphics.fillStyle(color, 0.7);
ghostGraphics.fillRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize - 2, this.cellSize - 2);
ghostGraphics.lineStyle(2, 0xffffff);
ghostGraphics.strokeRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize);
}
ghostContainer.add(ghostGraphics);
const previewGraphics = this.scene.add.graphics().setDepth(999).setAlpha(0.5);
this.dragState = {
itemId,
itemShape: lost.shape,
itemTransform: { ...lost.transform, offset: { ...lost.transform.offset } },
itemMeta: lost.meta,
ghostContainer,
previewGraphics,
dragOffsetX: 0,
dragOffsetY: 0,
};
}
private rotateDraggedItem(): void {
if (!this.dragState) return;
const currentRotation = (this.dragState.itemTransform.rotation + 90) % 360;
this.dragState.itemTransform = {
...this.dragState.itemTransform,
rotation: currentRotation,
}; };
this.scene.input.on("pointerdown", this.rightClickHandler); this.updateGhostVisuals();
}
private updateGhostVisuals(): void {
if (!this.dragState) return;
this.dragState.ghostContainer.removeAll(true);
const ghostGraphics = this.scene.add.graphics();
const color = this.colorMap.get(this.dragState.itemId) ?? 0x888888;
const cells = transformShape(this.dragState.itemShape, this.dragState.itemTransform);
for (const cell of cells) {
ghostGraphics.fillStyle(color, 0.7);
ghostGraphics.fillRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize - 2, this.cellSize - 2);
ghostGraphics.lineStyle(2, 0xffffff);
ghostGraphics.strokeRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize);
}
this.dragState.ghostContainer.add(ghostGraphics);
}
private onPointerMove(pointer: Phaser.Input.Pointer): void {
if (!this.dragState) return;
this.dragState.ghostContainer.setPosition(pointer.x - this.dragState.dragOffsetX, pointer.y - this.dragState.dragOffsetY);
const gridCell = this.getWorldGridCell(pointer.x - this.dragState.dragOffsetX, pointer.y - this.dragState.dragOffsetY);
this.dragState.previewGraphics.clear();
if (gridCell) {
const inventory = this.getInventory();
const testTransform = { ...this.dragState.itemTransform, offset: { x: gridCell.x, y: gridCell.y } };
const validation = validatePlacement(inventory, this.dragState.itemShape, testTransform);
const cells = transformShape(this.dragState.itemShape, testTransform);
for (const cell of cells) {
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
if (validation.valid) {
this.dragState.previewGraphics.fillStyle(0x33ff33, 0.3);
this.dragState.previewGraphics.fillRect(px, py, this.cellSize, this.cellSize);
this.dragState.previewGraphics.lineStyle(2, 0x33ff33);
this.dragState.previewGraphics.strokeRect(px, py, this.cellSize, this.cellSize);
} else {
this.dragState.previewGraphics.fillStyle(0xff3333, 0.3);
this.dragState.previewGraphics.fillRect(px, py, this.cellSize, this.cellSize);
this.dragState.previewGraphics.lineStyle(2, 0xff3333);
this.dragState.previewGraphics.strokeRect(px, py, this.cellSize, this.cellSize);
}
}
}
}
private onPointerUp(pointer: Phaser.Input.Pointer): void {
if (!this.dragState) return;
const gridCell = this.getWorldGridCell(pointer.x - this.dragState.dragOffsetX, pointer.y - this.dragState.dragOffsetY);
const inventory = this.getInventory();
this.dragState.ghostContainer.destroy();
this.dragState.previewGraphics.destroy();
if (gridCell) {
const testTransform = { ...this.dragState.itemTransform, offset: { x: gridCell.x, y: gridCell.y } };
const validation = validatePlacement(inventory, this.dragState.itemShape, testTransform);
if (validation.valid) {
this.gameState.produce(state => {
const item: InventoryItem<GameItemMeta> = {
id: this.dragState!.itemId,
shape: this.dragState!.itemShape,
transform: testTransform,
meta: this.dragState!.itemMeta,
};
placeItem(state.inventory, item);
});
this.createItemVisualsFromDrag();
} else {
this.createLostItem();
}
} else {
this.createLostItem();
}
this.dragState = null;
}
private createItemVisualsFromDrag(): void {
if (!this.dragState) return;
const inventory = this.getInventory();
const item = inventory.items.get(this.dragState.itemId);
if (item) {
this.createItemVisuals(this.dragState.itemId, item);
}
}
private getWorldGridCell(worldX: number, worldY: number): { x: number; y: number } | null {
const localX = worldX - this.container.x - this.gridX;
const localY = worldY - this.container.y - this.gridY;
const cellX = Math.floor(localX / (this.cellSize + this.gridGap));
const cellY = Math.floor(localY / (this.cellSize + this.gridGap));
if (cellX < 0 || cellY < 0 || cellX >= this.getInventory().width || cellY >= this.getInventory().height) {
return null;
}
return { x: cellX, y: cellY };
}
private createLostItem(): void {
if (!this.dragState) return;
const container = this.scene.add.container(
this.dragState.ghostContainer.x,
this.dragState.ghostContainer.y
).setDepth(500);
const graphics = this.scene.add.graphics();
const color = this.colorMap.get(this.dragState.itemId) ?? 0x888888;
const cells = transformShape(this.dragState.itemShape, this.dragState.itemTransform);
for (const cell of cells) {
graphics.fillStyle(color, 0.5);
graphics.fillRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize - 2, this.cellSize - 2);
graphics.lineStyle(2, 0xff4444);
graphics.strokeRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize);
}
container.add(graphics);
const name = this.dragState.itemMeta?.itemData.name ?? this.dragState.itemId;
const text = this.scene.add.text(0, -20, `${name} (lost)`, {
fontSize: '12px',
color: '#ff4444',
fontStyle: 'italic',
}).setOrigin(0.5);
container.add(text);
const hitRect = new Phaser.Geom.Rectangle(0, 0, this.cellSize, this.cellSize);
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
container.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
if (this.isLocked) return;
if (this.dragState) return;
if (pointer.button === 0) {
this.startLostItemDrag(this.dragState!.itemId, pointer);
}
});
this.lostItems.set(this.dragState.itemId, {
id: this.dragState.itemId,
container,
shape: this.dragState.itemShape,
transform: { ...this.dragState.itemTransform },
meta: this.dragState.itemMeta,
});
}
private removeItemVisuals(itemId: string): void {
this.itemContainers.get(itemId)?.destroy();
this.itemGraphics.get(itemId)?.destroy();
this.itemTexts.get(itemId)?.destroy();
this.itemContainers.delete(itemId);
this.itemGraphics.delete(itemId);
this.itemTexts.delete(itemId);
} }
public setLocked(locked: boolean): void { public setLocked(locked: boolean): void {
@ -205,35 +462,52 @@ export class InventoryWidget {
} }
public getLostItems(): string[] { public getLostItems(): string[] {
return this.lostItemManager.getLostItemIds(); return Array.from(this.lostItems.keys());
} }
public clearLostItems(): void { public clearLostItems(): void {
this.lostItemManager.clear(); for (const lost of this.lostItems.values()) {
lost.container.destroy();
}
this.lostItems.clear();
} }
/**
* Force re-sync of item visuals with current inventory state.
* With spawnEffect this is usually automatic, but useful after
* external state changes that don't trigger the effect.
*/
public refresh(): void { public refresh(): void {
// The spawner effect automatically re-syncs when gameState.value changes. const inventory = this.getInventory();
// If immediate refresh is needed, reading the signal triggers the effect.
void this.gameState.value; for (const itemId of this.itemContainers.keys()) {
if (!inventory.items.has(itemId)) {
this.removeItemVisuals(itemId);
}
}
for (const [itemId, item] of inventory.items) {
if (!this.itemContainers.has(itemId)) {
this.createItemVisuals(itemId, item);
}
}
} }
public destroy(): void { public destroy(): void {
this.scene.input.off("pointerdown", this.rightClickHandler); this.scene.input.off('pointermove', this.pointerMoveHandler);
this.scene.input.off('pointerup', this.pointerUpHandler);
if (this.spawnDispose) { if (this.dragState) {
this.spawnDispose(); this.dragState.ghostContainer.destroy();
this.spawnDispose = null; this.dragState.previewGraphics.destroy();
this.dragState = null;
} }
this.dragController.destroy(); this.clearLostItems();
this.lostItemManager.destroy();
this.backgroundRenderer.destroy(); for (const container of this.itemContainers.values()) {
container.destroy();
}
this.itemContainers.clear();
this.itemGraphics.clear();
this.itemTexts.clear();
this.gridGraphics.destroy();
this.container.destroy(); this.container.destroy();
} }
} }

View File

@ -1,157 +0,0 @@
import Phaser from "phaser";
import {
type InventoryItem,
type GameItemMeta,
} from "boardgame-core/samples/slay-the-spire-like";
export interface LostItem {
id: string;
container: Phaser.GameObjects.Container;
shape: InventoryItem<GameItemMeta>["shape"];
transform: InventoryItem<GameItemMeta>["transform"];
meta: InventoryItem<GameItemMeta>["meta"];
}
export interface LostItemManagerOptions {
scene: Phaser.Scene;
cellSize: number;
gridGap: number;
getItemColor: (itemId: string) => number;
onLostItemDragStart: (
itemId: string,
container: Phaser.GameObjects.Container,
) => void;
isDragging: () => boolean;
}
/**
* Manages "lost" items items that were dropped outside valid grid placement.
* Renders them as semi-transparent red-bordered containers that can be re-dragged.
*/
export class LostItemManager {
private scene: Phaser.Scene;
private cellSize: number;
private gridGap: number;
private getItemColor: (itemId: string) => number;
private onLostItemDragStart: (
itemId: string,
container: Phaser.GameObjects.Container,
) => void;
private isDragging: () => boolean;
private lostItems = new Map<string, LostItem>();
constructor(options: LostItemManagerOptions) {
this.scene = options.scene;
this.cellSize = options.cellSize;
this.gridGap = options.gridGap;
this.getItemColor = options.getItemColor;
this.onLostItemDragStart = options.onLostItemDragStart;
this.isDragging = options.isDragging;
}
/**
* Create a visual representation of a lost item at the given position.
*/
createLostItem(
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
x: number,
y: number,
): void {
const container = this.scene.add.container(x, y).setDepth(500);
const graphics = this.scene.add.graphics();
const color = this.getItemColor(itemId);
for (let gy = 0; gy < shape.height; gy++) {
for (let gx = 0; gx < shape.width; gx++) {
if (shape.grid[gy]?.[gx]) {
graphics.fillStyle(color, 0.5);
graphics.fillRect(
gx * (this.cellSize + this.gridGap),
gy * (this.cellSize + this.gridGap),
this.cellSize - 2,
this.cellSize - 2,
);
graphics.lineStyle(2, 0xff4444);
graphics.strokeRect(
gx * (this.cellSize + this.gridGap),
gy * (this.cellSize + this.gridGap),
this.cellSize,
this.cellSize,
);
}
}
}
container.add(graphics);
const name = meta?.itemData.name ?? itemId;
const text = this.scene.add
.text(0, -20, `${name} (lost)`, {
fontSize: "12px",
color: "#ff4444",
fontStyle: "italic",
})
.setOrigin(0.5);
container.add(text);
const hitRect = new Phaser.Geom.Rectangle(
0,
0,
this.cellSize,
this.cellSize,
);
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
container.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
// Guard against stale events firing on destroyed containers
if (!container.scene || !container.active) return;
if (this.isDragging()) return;
if (pointer.button === 0) {
this.onLostItemDragStart(itemId, container);
}
});
this.lostItems.set(itemId, {
id: itemId,
container,
shape,
transform: { ...transform },
meta,
});
}
/**
* Get all lost item IDs.
*/
getLostItemIds(): string[] {
return Array.from(this.lostItems.keys());
}
/**
* Get a lost item by ID, or undefined if not found.
*/
getLostItem(itemId: string): LostItem | undefined {
return this.lostItems.get(itemId);
}
/**
* Destroy and clear all lost items.
*/
clear(): void {
for (const lost of this.lostItems.values()) {
lost.container.destroy();
}
this.lostItems.clear();
}
/**
* Destroy all managed visuals.
*/
destroy(): void {
this.clear();
}
}

View File

@ -3,15 +3,14 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"], "@/*": ["src/*"]
"boardgame-phaser": ["../framework/src/index.ts"],
}, },
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "preact", "jsxImportSource": "preact",
"noEmit": true, "noEmit": true,
"declaration": false, "declaration": false,
"declarationMap": false, "declarationMap": false,
"sourceMap": false, "sourceMap": false
}, },
"include": ["src/**/*"], "include": ["src/**/*"]
} }

File diff suppressed because it is too large Load Diff