Compare commits

...

5 Commits

Author SHA1 Message Date
hypercross ddc9d057fd Chore: Add ESLint configuration and lint scripts 2026-04-19 10:39:01 +08:00
hypercross e14e41461f Fix drag race condition and stale pointer events
Mark items as dragging before updating inventory state to prevent
the spawner from destroying containers mid-drag. Move inventory
removal to drop time for lost items, and add guards to ignore
stale pointer events on inactive containers.
2026-04-19 00:43:43 +08:00
hypercross 5af7140958 Refactor inventory to use Spawner pattern
Replace manual ItemRenderer with InventoryItemSpawner and
GridBackgroundRenderer. Uses spawnEffect to automatically sync item
visuals with game state changes. Separates static grid rendering and
tracks dragged items to prevent spawner conflicts.
2026-04-19 00:24:20 +08:00
hypercross a7095c37fc Refactor inventory drag to use dragDropEventEffect
Replace manual pointer event handling with the framework's
dragDropEventEffect utility. Update DragController to manage
DisposableBag for cleanup and pass containers instead of
pointers to drag callbacks. Add framework path alias to
tsconfig and fix loop variable shadowing in LostItemManager.
2026-04-19 00:12:56 +08:00
hypercross 88d0c5bf55 Refactor: centralize config and split inventory
Move magic numbers and style constants to src/config.ts.
Decompose InventoryWidget into DragController, ItemRenderer,
and LostItemManager for better separation of concerns. Add
createButton utility and remove unused CombatUnitWidget.
Update scenes to import from centralized config.
2026-04-19 00:01:25 +08:00
19 changed files with 4219 additions and 1222 deletions

84
eslint.config.js Normal file
View File

@ -0,0 +1,84 @@
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,10 +6,16 @@
"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

@ -0,0 +1,122 @@
/**
* 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,48 +1,22 @@
import Phaser from 'phaser'; import Phaser from "phaser";
import { ReactiveScene } from 'boardgame-phaser'; import { ReactiveScene } from "boardgame-phaser";
import { MutableSignal } from 'boardgame-core'; import { createButton } from "@/utils/createButton";
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;
@ -62,7 +36,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;
} }
@ -82,31 +56,46 @@ export class GameFlowScene extends ReactiveScene {
this.hudContainer.add(hudBg); this.hudContainer.add(hudBg);
// HP // HP
this.hpText = this.add.text(-150, 0, '', { this.hpText = this.add
fontSize: '16px', .text(-150, 0, "", {
color: '#ff6666', fontSize: "16px",
fontStyle: 'bold', color: "#ff6666",
}).setOrigin(0, 0.5); fontStyle: "bold",
})
.setOrigin(0, 0.5);
this.hudContainer.add(this.hpText); this.hudContainer.add(this.hpText);
// Gold // Gold
this.goldText = this.add.text(-50, 0, '', { this.goldText = this.add
fontSize: '16px', .text(-50, 0, "", {
color: '#ffcc44', fontSize: "16px",
fontStyle: 'bold', color: "#ffcc44",
}).setOrigin(0, 0.5); fontStyle: "bold",
})
.setOrigin(0, 0.5);
this.hudContainer.add(this.goldText); this.hudContainer.add(this.goldText);
// Current node // Current node
this.nodeText = this.add.text(50, 0, '', { this.nodeText = this.add
fontSize: '16px', .text(50, 0, "", {
color: '#ffffff', fontSize: "16px",
}).setOrigin(0, 0.5); color: "#ffffff",
})
.setOrigin(0, 0.5);
this.hudContainer.add(this.nodeText); this.hudContainer.add(this.nodeText);
// Back to menu button // Back to menu button
this.createButton('返回菜单', width - 100, 25, 140, 36, async () => { createButton({
await this.sceneController.launch('IndexScene'); scene: this,
label: "返回菜单",
x: width - 100,
y: 25,
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
onClick: async () => {
await this.sceneController.launch("IndexScene");
},
depth: 200,
}); });
} }
@ -128,18 +117,26 @@ 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 = 9; const maxLayer = TOTAL_LAYERS - 1;
const maxNodesInLayer = 5; const mapWidth = maxLayer * LAYER_HEIGHT + 200;
const mapWidth = maxLayer * this.LAYER_HEIGHT + 200; const mapHeight = (MAX_NODES_PER_LAYER - 1) * NODE_SPACING + 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.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5).setOrigin(0.5); const bg = this.add
.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();
@ -147,7 +144,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);
@ -178,9 +175,13 @@ 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 ? 0xffffff : (isReachable ? this.brightenColor(baseColor) : baseColor); const color = isCurrent
? 0xffffff
: isReachable
? this.brightenColor(baseColor)
: baseColor;
nodeGraphics.fillStyle(color); nodeGraphics.fillStyle(color);
nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS); nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
if (isCurrent) { if (isCurrent) {
nodeGraphics.lineStyle(3, 0xffff44); nodeGraphics.lineStyle(3, 0xffff44);
@ -189,86 +190,109 @@ export class GameFlowScene extends ReactiveScene {
} else { } else {
nodeGraphics.lineStyle(2, 0x888888); nodeGraphics.lineStyle(2, 0x888888);
} }
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS); nodeGraphics.strokeCircle(posX, posY, 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.text(posX, posY, label, { this.add
fontSize: '11px', .text(posX, posY, label, {
color: '#ffffff', fontSize: "11px",
fontStyle: isCurrent ? 'bold' : 'normal', color: "#ffffff",
}).setOrigin(0.5) fontStyle: isCurrent ? "bold" : "normal",
})
.setOrigin(0.5),
); );
// Encounter name // Encounter name
if (node.encounter) { if (node.encounter) {
this.mapContainer.add( this.mapContainer.add(
this.add.text(posX, posY + this.NODE_RADIUS + 12, node.encounter.name, { this.add
fontSize: '10px', .text(posX, posY + NODE_RADIUS + 12, node.encounter.name, {
color: '#cccccc', fontSize: "10px",
}).setOrigin(0.5) color: "#cccccc",
})
.setOrigin(0.5),
); );
} }
// Make reachable nodes interactive (add hitZone to mapContainer so positions match) // Make reachable nodes interactive
if (isReachable) { if (isReachable) {
const hitZone = this.add.circle(posX, posY, this.NODE_RADIUS, 0x000000, 0) const hitZone = this.add
.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, this.NODE_RADIUS); nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
nodeGraphics.lineStyle(3, 0xaaddaa); nodeGraphics.lineStyle(3, 0xaaddaa);
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS); nodeGraphics.strokeCircle(posX, posY, 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, this.NODE_RADIUS); nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
nodeGraphics.lineStyle(2, 0xaaddaa); nodeGraphics.lineStyle(2, 0xaaddaa);
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS); nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
}); });
hitZone.on('pointerdown', () => { hitZone.on("pointerdown", () => {
this.onNodeClick(nodeId); this.onNodeClick(nodeId);
}); });
} }
} }
// Setup drag-to-scroll // Setup drag-to-scroll with disposables cleanup
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { const onPointerDown = (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;
}); };
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => { const onPointerMove = (pointer: Phaser.Input.Pointer) => {
if (!this.isDragging) return; if (!this.isDragging) return;
this.mapContainer.x = this.dragStartContainerX + (pointer.x - this.dragStartX); this.mapContainer.x =
this.mapContainer.y = this.dragStartContainerY + (pointer.y - this.dragStartY); this.dragStartContainerX + (pointer.x - this.dragStartX);
}); this.mapContainer.y =
this.dragStartContainerY + (pointer.y - this.dragStartY);
};
this.input.on('pointerup', () => { const onPointerUp = () => {
this.isDragging = false; this.isDragging = false;
}); };
this.input.on('pointerout', () => { this.input.on("pointerdown", onPointerDown);
this.isDragging = false; this.input.on("pointermove", onPointerMove);
this.input.on("pointerup", onPointerUp);
this.input.on("pointerout", onPointerUp);
this.disposables.add(() => {
this.input.off("pointerdown", onPointerDown);
this.input.off("pointermove", onPointerMove);
this.input.off("pointerup", onPointerUp);
this.input.off("pointerout", onPointerUp);
}); });
// Hint text // Hint text
this.add.text(width / 2, this.scale.height - 20, '点击可到达的节点进入遭遇 | 拖拽滚动查看地图', { this.add
fontSize: '14px', .text(
color: '#888888', width / 2,
}).setOrigin(0.5).setDepth(200); this.scale.height - 20,
"点击可到达的节点进入遭遇 | 拖拽滚动查看地图",
{
fontSize: "14px",
color: "#888888",
},
)
.setOrigin(0.5)
.setDepth(200);
} }
private async onNodeClick(nodeId: string): Promise<void> { private async onNodeClick(nodeId: string): Promise<void> {
@ -297,18 +321,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);
@ -319,9 +343,17 @@ 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 ? 0xffffff : (isReachable ? this.brightenColor(baseColor) : baseColor); const color = isCurrent
? 0xffffff
: isReachable
? this.brightenColor(baseColor)
: baseColor;
nodeGraphics.fillStyle(color); nodeGraphics.fillStyle(color);
nodeGraphics.fillCircle(this.getNodeX(node), this.getNodeY(node), this.NODE_RADIUS); nodeGraphics.fillCircle(
this.getNodeX(node),
this.getNodeY(node),
MAP_CONFIG.NODE_RADIUS,
);
if (isCurrent) { if (isCurrent) {
nodeGraphics.lineStyle(3, 0xffff44); nodeGraphics.lineStyle(3, 0xffff44);
@ -330,7 +362,11 @@ export class GameFlowScene extends ReactiveScene {
} else { } else {
nodeGraphics.lineStyle(2, 0x888888); nodeGraphics.lineStyle(2, 0x888888);
} }
nodeGraphics.strokeCircle(this.getNodeX(node), this.getNodeY(node), this.NODE_RADIUS); nodeGraphics.strokeCircle(
this.getNodeX(node),
this.getNodeY(node),
MAP_CONFIG.NODE_RADIUS,
);
} }
} }
@ -339,79 +375,65 @@ export class GameFlowScene extends ReactiveScene {
const state = this.gameState.value; const state = this.gameState.value;
// Overlay // Overlay
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7).setDepth(300); const overlay = this.add
.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7)
.setDepth(300);
// End message // End message
this.add.text(width / 2, height / 2 - 40, '恭喜通关!', { this.add
fontSize: '36px', .text(width / 2, height / 2 - 40, "恭喜通关!", {
color: '#ffcc44', fontSize: "36px",
fontStyle: 'bold', color: "#ffcc44",
}).setOrigin(0.5).setDepth(300); fontStyle: "bold",
})
.setOrigin(0.5)
.setDepth(300);
const { player } = state; const { player } = state;
this.add.text(width / 2, height / 2 + 20, `剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`, { this.add
fontSize: '20px', .text(
color: '#ffffff', width / 2,
align: 'center', height / 2 + 20,
}).setOrigin(0.5).setDepth(300); `剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`,
{
fontSize: "20px",
color: "#ffffff",
align: "center",
},
)
.setOrigin(0.5)
.setDepth(300);
this.createButton('返回菜单', width / 2, height / 2 + 100, 200, 50, async () => { createButton({
await this.sceneController.launch('IndexScene'); scene: this,
}, 300); label: "返回菜单",
x: width / 2,
y: height / 2 + 100,
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
onClick: async () => {
await this.sceneController.launch("IndexScene");
},
depth: 300,
});
} }
private getNodeX(node: MapNode): number { private getNodeX(node: MapNode): number {
// Layers go left-to-right along X axis return -500 + node.layerIndex * MAP_CONFIG.LAYER_HEIGHT;
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.gameState.value.map.layers[node.layerIndex];
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) * this.NODE_SPACING; const layerHeight = (totalNodes - 1) * MAP_CONFIG.NODE_SPACING;
return -layerHeight / 2 + nodeIndex * this.NODE_SPACING; return -layerHeight / 2 + nodeIndex * MAP_CONFIG.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,4 +1,7 @@
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,
@ -8,44 +11,49 @@ 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.GRID_WIDTH, this.GRID_HEIGHT); this.inventory = createGridInventory<GameItemMeta>(
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 = (width - this.GRID_WIDTH * this.CELL_SIZE) / 2; this.gridOffsetX =
this.gridOffsetY = (height - this.GRID_HEIGHT * this.CELL_SIZE) / 2 + 20; (width - GRID_CONFIG.WIDTH * GRID_CONFIG.VIEWER_CELL_SIZE) / 2;
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.text(width / 2, 30, 'Grid Inventory Viewer (4x6)', { this.add
fontSize: '24px', .text(width / 2, 30, "Grid Inventory Viewer (4x6)", {
color: '#ffffff', fontSize: "24px",
fontStyle: 'bold', color: "#ffffff",
}).setOrigin(0.5); fontStyle: "bold",
})
.setOrigin(0.5);
this.createControls(); this.createControls();
this.add.text(width / 2, height - 40, 'Hover over cells to see item details', { this.add
fontSize: '14px', .text(width / 2, height - 40, "Hover over cells to see item details", {
color: '#aaaaaa', fontSize: "14px",
}).setOrigin(0.5); color: "#aaaaaa",
})
.setOrigin(0.5);
} }
private placeSampleItems(): void { private placeSampleItems(): void {
@ -64,7 +72,12 @@ export class GridViewerScene extends ReactiveScene {
const item: InventoryItem<GameItemMeta> = { const item: InventoryItem<GameItemMeta> = {
id: `item-${index}`, id: `item-${index}`,
shape, shape,
transform: { offset: { x, y }, rotation: 0, flipX: false, flipY: false }, transform: {
offset: { x, y },
rotation: 0,
flipX: false,
flipY: false,
},
meta: { itemData, shape }, meta: { itemData, shape },
}; };
placeItem(this.inventory, item); placeItem(this.inventory, item);
@ -74,18 +87,28 @@ 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 < this.GRID_HEIGHT; y++) { for (let y = 0; y < GRID_CONFIG.HEIGHT; y++) {
for (let x = 0; x < this.GRID_WIDTH; x++) { for (let x = 0; x < GRID_CONFIG.WIDTH; x++) {
const px = this.gridOffsetX + x * this.CELL_SIZE; const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + y * this.CELL_SIZE; const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_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(px + 1, py + 1, this.CELL_SIZE - 2, this.CELL_SIZE - 2); graphics.fillRect(
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(px, py, this.CELL_SIZE, this.CELL_SIZE); graphics.strokeRect(
px,
py,
GRID_CONFIG.VIEWER_CELL_SIZE,
GRID_CONFIG.VIEWER_CELL_SIZE,
);
} }
} }
} }
@ -110,72 +133,120 @@ 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 * this.CELL_SIZE; const px = this.gridOffsetX + cell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + cell.y * this.CELL_SIZE; const py = this.gridOffsetY + cell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
graphics.fillStyle(itemColor); graphics.fillStyle(itemColor);
graphics.fillRect(px + 2, py + 2, this.CELL_SIZE - 4, this.CELL_SIZE - 4); graphics.fillRect(
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 = this.gridOffsetX + firstCell.x * this.CELL_SIZE; const px =
const py = this.gridOffsetY + firstCell.y * this.CELL_SIZE; this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_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.text(px + this.CELL_SIZE / 2, py + this.CELL_SIZE / 2, itemName, { this.add
fontSize: '11px', .text(
color: '#ffffff', px + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
fontStyle: 'bold', py + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
}).setOrigin(0.5); itemName,
{
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()).map(i => i.meta?.itemData.name ?? i.id).join(', '); const adjacentNames = Array.from(adjacent.values())
.map((i) => i.meta?.itemData.name ?? i.id)
.join(", ");
const firstCell = cells[0]; const firstCell = cells[0];
const px = this.gridOffsetX + firstCell.x * this.CELL_SIZE; const px =
const py = this.gridOffsetY + firstCell.y * this.CELL_SIZE - 20; this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py =
this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE - 20;
this.add.text(px + this.CELL_SIZE / 2, py, `邻接: ${adjacentNames}`, { this.add
fontSize: '10px', .text(
color: '#ffff88', px + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
}).setOrigin(0.5); py,
`邻接: ${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);
const colors = [0x4488cc, 0xcc8844, 0x44cc88, 0xcc4488, 0x8844cc, 0x44cccc]; return ITEM_COLORS[hash % ITEM_COLORS.length];
return colors[hash % colors.length];
} }
private createControls(): void { private createControls(): void {
const { width, height } = this.scale; const { width, height } = this.scale;
this.createButton('返回菜单', 100, 40, async () => { createButton({
await this.sceneController.launch('IndexScene'); scene: this,
label: "返回菜单",
x: 100,
y: 40,
onClick: async () => {
await this.sceneController.launch("IndexScene");
},
}); });
this.createButton('清空', width - 260, 40, async () => { createButton({
this.inventory = createGridInventory<GameItemMeta>(this.GRID_WIDTH, this.GRID_HEIGHT); scene: this,
label: "清空",
x: width - 260,
y: 40,
onClick: async () => {
this.inventory = createGridInventory<GameItemMeta>(
GRID_CONFIG.WIDTH,
GRID_CONFIG.HEIGHT,
);
await this.sceneController.restart(); await this.sceneController.restart();
},
}); });
this.createButton('随机填充', width - 130, 40, async () => { createButton({
scene: this,
label: "随机填充",
x: width - 130,
y: 40,
onClick: async () => {
this.randomFill(); this.randomFill();
await this.sceneController.restart(); await this.sceneController.restart();
},
}); });
} }
private randomFill(): void { private randomFill(): void {
this.inventory = createGridInventory<GameItemMeta>(this.GRID_WIDTH, this.GRID_HEIGHT); this.inventory = createGridInventory<GameItemMeta>(
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 < this.GRID_HEIGHT && itemIndex < items.length; y++) { for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) {
for (let x = 0; x < this.GRID_WIDTH && itemIndex < items.length; x++) { for (let x = 0; x < GRID_CONFIG.WIDTH && itemIndex < items.length; x++) {
const itemData = items[itemIndex]; const itemData = items[itemIndex];
const shape = parseShapeString(itemData.shape); const shape = parseShapeString(itemData.shape);
@ -190,8 +261,12 @@ 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 (cx >= this.GRID_WIDTH || cy >= this.GRID_HEIGHT || this.inventory.occupiedCells.has(cell as `${number},${number}`)) { if (
cx >= GRID_CONFIG.WIDTH ||
cy >= GRID_CONFIG.HEIGHT ||
this.inventory.occupiedCells.has(cell as `${number},${number}`)
) {
valid = false; valid = false;
break; break;
} }
@ -201,7 +276,12 @@ export class GridViewerScene extends ReactiveScene {
const item: InventoryItem<GameItemMeta> = { const item: InventoryItem<GameItemMeta> = {
id: `item-${itemIndex}`, id: `item-${itemIndex}`,
shape, shape,
transform: { offset: { x, y }, rotation: 0, flipX: false, flipY: false }, transform: {
offset: { x, y },
rotation: 0,
flipX: false,
flipY: false,
},
meta: { itemData, shape }, meta: { itemData, shape },
}; };
placeItem(this.inventory, item); placeItem(this.inventory, item);
@ -210,30 +290,4 @@ 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,9 +1,11 @@
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 {
@ -13,24 +15,32 @@ export class IndexScene extends ReactiveScene {
const centerY = height / 2; const centerY = height / 2;
// Title // Title
this.add.text(centerX, centerY - 150, 'Slay-the-Spire-Like Viewer', { this.add
fontSize: '36px', .text(centerX, centerY - 150, "Slay-the-Spire-Like Viewer", {
color: '#ffffff', fontSize: "36px",
fontStyle: 'bold', color: "#ffffff",
}).setOrigin(0.5); fontStyle: "bold",
})
.setOrigin(0.5);
// Subtitle // Subtitle
this.add.text(centerX, centerY - 100, 'Choose a viewer to explore:', { this.add
fontSize: '18px', .text(centerX, centerY - 100, "Choose a viewer to explore:", {
color: '#aaaaaa', fontSize: "18px",
}).setOrigin(0.5); color: "#aaaaaa",
})
.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: 'Shape Viewer', scene: 'ShapeViewerScene', y: centerY + 140 }, label: "Grid Inventory Viewer",
scene: "GridViewerScene",
y: centerY + 70,
},
{ label: "Shape Viewer", scene: "ShapeViewerScene", y: centerY + 140 },
]; ];
for (const btn of buttons) { for (const btn of buttons) {
@ -38,34 +48,22 @@ export class IndexScene extends ReactiveScene {
} }
} }
private createButton(label: string, targetScene: string, x: number, y: number): void { private createButton(
const buttonWidth = 300; label: string,
const buttonHeight = 50; targetScene: string,
x: number,
// Background y: number,
const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x333355) ): void {
.setStrokeStyle(2, 0x6666aa) createButton({
.setInteractive({ useHandCursor: true }); scene: this,
label,
// Text x,
const text = this.add.text(x, y, label, { y,
fontSize: '20px', width: UI_CONFIG.BUTTON_WIDTH_LARGE,
color: '#ffffff', height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
}).setOrigin(0.5); onClick: async () => {
// 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); await this.sceneController.launch(targetScene);
},
}); });
} }
} }

View File

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

View File

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

View File

@ -1,13 +1,16 @@
import Phaser from 'phaser'; import Phaser from "phaser";
import { ReactiveScene } from 'boardgame-phaser'; import { ReactiveScene } from "boardgame-phaser";
import { parseShapeString, data, type ParsedShape } from 'boardgame-core/samples/slay-the-spire-like'; import { createButton } from "@/utils/createButton";
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 {
@ -19,13 +22,15 @@ export class ShapeViewerScene extends ReactiveScene {
private drawShapeViewer(): void { private drawShapeViewer(): void {
this.children.removeAll(); this.children.removeAll();
const { width, height } = this.scale; const { width } = this.scale;
this.add.text(width / 2, 30, 'Shape Viewer - Item Shapes', { this.add
fontSize: '24px', .text(width / 2, 30, "Shape Viewer - Item Shapes", {
color: '#ffffff', fontSize: "24px",
fontStyle: 'bold', color: "#ffffff",
}).setOrigin(0.5); fontStyle: "bold",
})
.setOrigin(0.5);
this.drawAllShapes(); this.drawAllShapes();
} }
@ -33,117 +38,127 @@ 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 spacingX = 220; const { SPACING_X, SPACING_Y, ITEMS_PER_ROW, MAX_ITEMS } = SHAPE_CONFIG;
const spacingY = 140;
const itemsToShow = data.desert.items.slice(0, 12); const itemsToShow = data.desert.items.slice(0, MAX_ITEMS);
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 % this.ITEMS_PER_ROW; const col = i % ITEMS_PER_ROW;
const row = Math.floor(i / this.ITEMS_PER_ROW); const row = Math.floor(i / ITEMS_PER_ROW);
const x = 60 + col * spacingX; const x = 60 + col * SPACING_X;
const y = startY + row * spacingY; const y = startY + row * SPACING_Y;
this.drawSingleShape(x, y, itemData, shape); this.drawSingleShape(x, y, itemData, shape);
} }
} }
private drawSingleShape(startX: number, startY: number, itemData: any, shape: ParsedShape): void { private drawSingleShape(
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 * this.CELL_SIZE; const shapeWidth = shape.width * CELL_SIZE;
const shapeHeight = shape.height * this.CELL_SIZE; const shapeHeight = shape.height * CELL_SIZE;
this.add.text(startX + shapeWidth / 2, startY - 20, itemData.name, { this.add
fontSize: '14px', .text(startX + shapeWidth / 2, startY - 20, itemData.name, {
color: '#ffffff', fontSize: "14px",
fontStyle: 'bold', color: "#ffffff",
}).setOrigin(0.5); fontStyle: "bold",
})
.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 * this.CELL_SIZE; const px = startX + x * CELL_SIZE;
const py = startY + y * this.CELL_SIZE; const py = startY + y * 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, this.CELL_SIZE - 2, this.CELL_SIZE - 2); graphics.fillRect(px + 1, py + 1, CELL_SIZE - 2, CELL_SIZE - 2);
graphics.lineStyle(2, 0xffffff); graphics.lineStyle(2, 0xffffff);
graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE); graphics.strokeRect(px, py, CELL_SIZE, CELL_SIZE);
if (isOrigin) { if (isOrigin) {
this.add.text(px + this.CELL_SIZE / 2, py + this.CELL_SIZE / 2, 'O', { this.add
fontSize: '16px', .text(px + CELL_SIZE / 2, py + CELL_SIZE / 2, "O", {
color: '#ffffff', fontSize: "16px",
fontStyle: 'bold', color: "#ffffff",
}).setOrigin(0.5); fontStyle: "bold",
})
.setOrigin(0.5);
} }
} }
} }
} }
this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 10, `形状: ${itemData.shape}`, { this.add
fontSize: '11px', .text(
color: '#aaaaaa', startX + shapeWidth / 2,
}).setOrigin(0.5); startY + shapeHeight + 10,
`形状: ${itemData.shape}`,
{
fontSize: "11px",
color: "#aaaaaa",
},
)
.setOrigin(0.5);
this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 28, this.add
`类型: ${itemData.type} | 费用: ${itemData.costCount} ${itemData.costType}`, { .text(
fontSize: '11px', startX + shapeWidth / 2,
color: '#cccccc', startY + shapeHeight + 28,
}).setOrigin(0.5); `类型: ${itemData.type} | 费用: ${itemData.costCount} ${itemData.costType}`,
{
fontSize: "11px",
color: "#cccccc",
},
)
.setOrigin(0.5);
this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 46, itemData.desc, { this.add
fontSize: '10px', .text(startX + shapeWidth / 2, startY + shapeHeight + 46, itemData.desc, {
color: '#888888', fontSize: "10px",
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;
this.createButton('返回菜单', 100, height - 40, async () => { createButton({
await this.sceneController.launch('IndexScene'); scene: this,
label: "返回菜单",
x: 100,
y: height - 40,
onClick: async () => {
await this.sceneController.launch("IndexScene");
},
}); });
this.add.text(width / 2, height - 40, this.add
`Showing first 12 items | Green = Origin | Blue = Normal`, { .text(
fontSize: '14px', width / 2,
color: '#aaaaaa', height - 40,
}).setOrigin(0.5); `Showing first ${SHAPE_CONFIG.MAX_ITEMS} items | Green = Origin | Blue = Normal`,
} {
fontSize: "14px",
private createButton(label: string, x: number, y: number, onClick: () => void): void { color: "#aaaaaa",
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,18 +18,36 @@ 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(() => new PlaceholderEncounterScene(gameState), []); const placeholderEncounterScene = useMemo(
() => 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 initialScene="IndexScene" config={{ width: 1920, height: 1080 }}> <PhaserGame
<PhaserScene sceneKey="IndexScene" scene={indexScene} /> initialScene="IndexScene"
<PhaserScene sceneKey="MapViewerScene" scene={mapViewerScene} /> config={{ width: 1920, height: 1080 }}
<PhaserScene sceneKey="GridViewerScene" scene={gridViewerScene} /> >
<PhaserScene sceneKey="ShapeViewerScene" scene={shapeViewerScene} /> <PhaserScene sceneKey="IndexScene" scene={indexScene as any} />
<PhaserScene sceneKey="GameFlowScene" scene={gameFlowScene} /> <PhaserScene
<PhaserScene sceneKey="PlaceholderEncounterScene" scene={placeholderEncounterScene} /> sceneKey="MapViewerScene"
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

@ -0,0 +1,86 @@
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

@ -1,205 +0,0 @@
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

@ -0,0 +1,477 @@
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

@ -0,0 +1,71 @@
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

@ -0,0 +1,277 @@
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,20 +1,18 @@
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,
moveItem, } from "boardgame-core/samples/slay-the-spire-like";
rotateItem, import { InventoryItemSpawner } from "./InventoryItemSpawner";
transformShape, import { GridBackgroundRenderer } from "./GridBackgroundRenderer";
} from 'boardgame-core/samples/slay-the-spire-like'; import { DragController } from "./DragController";
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;
@ -26,25 +24,15 @@ export interface InventoryWidgetOptions {
isLocked?: boolean; isLocked?: boolean;
} }
interface DragState { /**
itemId: string; * Inventory widget using the Spawner pattern for reactive item rendering.
itemShape: InventoryItem<GameItemMeta>['shape']; *
itemTransform: InventoryItem<GameItemMeta>['transform']; * Architecture:
itemMeta: InventoryItem<GameItemMeta>['meta']; * - InventoryItemSpawner + spawnEffect: reactive spawn/despawn/update of item visuals
ghostContainer: Phaser.GameObjects.Container; * - GridBackgroundRenderer: static grid background drawn once
previewGraphics: Phaser.GameObjects.Graphics; * - DragController: event-driven drag logic via dragDropEventEffect
dragOffsetX: number; * - LostItemManager: tracks items dropped outside valid placement
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>;
@ -55,18 +43,13 @@ export class InventoryWidget {
private gridY = 0; private gridY = 0;
private isLocked: boolean; private isLocked: boolean;
private itemContainers = new Map<string, Phaser.GameObjects.Container>(); private itemSpawner: InventoryItemSpawner;
private itemGraphics = new Map<string, Phaser.GameObjects.Graphics>(); private backgroundRenderer: GridBackgroundRenderer;
private itemTexts = new Map<string, Phaser.GameObjects.Text>(); private dragController: DragController;
private colorMap = new Map<string, number>(); private lostItemManager: LostItemManager;
private colorIdx = 0;
private gridGraphics!: Phaser.GameObjects.Graphics; private spawnDispose: (() => void) | null = null;
private dragState: DragState | null = null; private rightClickHandler!: (pointer: Phaser.Input.Pointer) => void;
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;
@ -75,386 +58,146 @@ 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.gameState.value.inventory; const inventory = this.getInventory();
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);
this.pointerMoveHandler = this.onPointerMove.bind(this); // 1. Static grid background (drawn once)
this.pointerUpHandler = this.onPointerUp.bind(this); this.backgroundRenderer = new GridBackgroundRenderer({
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);
this.drawGridBackground(inventory.width, inventory.height, gridW, gridH); // 2. Reactive item spawner
this.drawItems(); this.itemSpawner = new InventoryItemSpawner({
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.inventory as unknown as GridInventory<GameItemMeta>; return this.gameState.value
.inventory as unknown as GridInventory<GameItemMeta>;
} }
private drawGridBackground(width: number, height: number, gridW: number, gridH: number): void { private handleItemDragStart(
this.gridGraphics = this.scene.add.graphics(); itemId: string,
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);
for (let y = 0; y < height; y++) { // Start drag session
for (let x = 0; x < width; x++) { this.dragController.startDrag(itemId, item, itemContainer);
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 {
} this.gameState.produce((state) => {
placeItem(state.inventory, item);
private drawItems(): void {
const inventory = this.getInventory();
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);
}
}); });
this.itemContainers.set(itemId, container); // Unmark dragging so spawner picks it up on next effect run
this.container.add(container); this.itemSpawner.unmarkDragging(item.id);
} }
private getItemCells(item: InventoryItem<GameItemMeta>): { x: number; y: number }[] { private handleCreateLostItem(
const cells: { x: number; y: number }[] = []; itemId: string,
const { offset } = item.transform; shape: InventoryItem<GameItemMeta>["shape"],
for (let y = 0; y < item.shape.height; y++) { transform: InventoryItem<GameItemMeta>["transform"],
for (let x = 0; x < item.shape.width; x++) { meta: InventoryItem<GameItemMeta>["meta"],
if (item.shape.grid[y]?.[x]) { x: number,
cells.push({ x: x + offset.x, y: y + offset.y }); y: number,
): void {
// Remove from inventory since it's dropped outside valid placement
this.gameState.produce((state) => {
removeItemFromGrid(state.inventory, itemId);
});
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!;
} }
return cells;
private getLostItemMeta(itemId: string) {
return this.lostItemManager.getLostItem(itemId)?.meta!;
} }
private setupInput(): void { private setupInput(): void {
this.scene.input.on('pointermove', this.pointerMoveHandler); this.rightClickHandler = (pointer: Phaser.Input.Pointer) => {
this.scene.input.on('pointerup', this.pointerUpHandler); if (!this.dragController.isDragging()) return;
this.scene.input.on('pointerdown', this.onPointerDown.bind(this));
}
private onPointerDown(pointer: Phaser.Input.Pointer): void {
if (!this.dragState) return;
if (pointer.button === 1) { if (pointer.button === 1) {
this.rotateDraggedItem(); this.dragController.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.updateGhostVisuals(); this.scene.input.on("pointerdown", this.rightClickHandler);
}
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 {
@ -462,52 +205,35 @@ export class InventoryWidget {
} }
public getLostItems(): string[] { public getLostItems(): string[] {
return Array.from(this.lostItems.keys()); return this.lostItemManager.getLostItemIds();
} }
public clearLostItems(): void { public clearLostItems(): void {
for (const lost of this.lostItems.values()) { this.lostItemManager.clear();
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 {
const inventory = this.getInventory(); // The spawner effect automatically re-syncs when gameState.value changes.
// If immediate refresh is needed, reading the signal triggers the effect.
for (const itemId of this.itemContainers.keys()) { void this.gameState.value;
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('pointermove', this.pointerMoveHandler); this.scene.input.off("pointerdown", this.rightClickHandler);
this.scene.input.off('pointerup', this.pointerUpHandler);
if (this.dragState) { if (this.spawnDispose) {
this.dragState.ghostContainer.destroy(); this.spawnDispose();
this.dragState.previewGraphics.destroy(); this.spawnDispose = null;
this.dragState = null;
} }
this.clearLostItems(); this.dragController.destroy();
this.lostItemManager.destroy();
for (const container of this.itemContainers.values()) { this.backgroundRenderer.destroy();
container.destroy();
}
this.itemContainers.clear();
this.itemGraphics.clear();
this.itemTexts.clear();
this.gridGraphics.destroy();
this.container.destroy(); this.container.destroy();
} }
} }

View File

@ -0,0 +1,157 @@
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,14 +3,15 @@
"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