Compare commits
No commits in common. "ddc9d057fdc88773bb521f6563efb6e2069d16e8" and "d993d5557640730e43e624b9c6fcf050b4dbb13b" have entirely different histories.
ddc9d057fd
...
d993d55576
|
|
@ -1,84 +0,0 @@
|
|||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"**/*.d.ts",
|
||||
"**/coverage/**",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
importPlugin.flatConfigs.recommended,
|
||||
importPlugin.flatConfigs.typescript,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
rules: {
|
||||
// --- Project Conventions ---
|
||||
quotes: ["error", "single", { avoidEscape: true }],
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
indent: ["error", 2, { SwitchCase: 1 }],
|
||||
|
||||
// --- TypeScript ---
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ prefer: "type-imports" },
|
||||
],
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
|
||||
// --- Import Ordering ---
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
groups: [
|
||||
"builtin",
|
||||
"external",
|
||||
"internal",
|
||||
"parent",
|
||||
"sibling",
|
||||
"index",
|
||||
"object",
|
||||
"type",
|
||||
],
|
||||
"newlines-between": "always",
|
||||
alphabetize: { order: "asc", caseInsensitive: true },
|
||||
},
|
||||
],
|
||||
"import/no-unresolved": "off",
|
||||
"import/no-duplicates": "error",
|
||||
|
||||
// --- General ---
|
||||
"no-console": "warn",
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
eqeqeq: ["error", "always"],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.test.ts", "**/*.spec.ts"],
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -6,16 +6,10 @@
|
|||
"dev": "pnpm --filter sample-game dev",
|
||||
"build": "pnpm --filter boardgame-phaser build && pnpm --filter sample-game build",
|
||||
"build:framework": "pnpm --filter boardgame-phaser build",
|
||||
"preview": "pnpm --filter sample-game preview",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
"preview": "pnpm --filter sample-game preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.18.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"pnpm": {
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
/**
|
||||
* Centralized layout and style constants for sts-like-viewer.
|
||||
* All magic numbers should be defined here and imported where needed.
|
||||
*/
|
||||
|
||||
// ── Map Layout ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const MAP_CONFIG = {
|
||||
/** Horizontal spacing between map layers (left-to-right) */
|
||||
LAYER_HEIGHT: 110,
|
||||
/** Vertical spacing between nodes within a layer */
|
||||
NODE_SPACING: 140,
|
||||
/** Radius of each map node circle */
|
||||
NODE_RADIUS: 28,
|
||||
/** Total number of layers in the point-crawl map */
|
||||
TOTAL_LAYERS: 10,
|
||||
/** Maximum nodes in any single layer */
|
||||
MAX_NODES_PER_LAYER: 5,
|
||||
} as const;
|
||||
|
||||
// ── Grid Inventory ──────────────────────────────────────────────────────────
|
||||
|
||||
export const GRID_CONFIG = {
|
||||
/** Default grid width (columns) */
|
||||
WIDTH: 6,
|
||||
/** Default grid height (rows) */
|
||||
HEIGHT: 4,
|
||||
/** Cell size for the standalone grid viewer */
|
||||
VIEWER_CELL_SIZE: 60,
|
||||
/** Cell size for the encounter inventory widget */
|
||||
WIDGET_CELL_SIZE: 80,
|
||||
/** Gap between grid cells (pixels) */
|
||||
GRID_GAP: 2,
|
||||
} as const;
|
||||
|
||||
// ── Shape Viewer ────────────────────────────────────────────────────────────
|
||||
|
||||
export const SHAPE_CONFIG = {
|
||||
/** Cell size for rendering item shapes */
|
||||
CELL_SIZE: 40,
|
||||
/** Number of items per row in the shape viewer grid */
|
||||
ITEMS_PER_ROW: 4,
|
||||
/** Maximum items to display at once */
|
||||
MAX_ITEMS: 12,
|
||||
/** Horizontal spacing between shape previews */
|
||||
SPACING_X: 220,
|
||||
/** Vertical spacing between shape previews */
|
||||
SPACING_Y: 140,
|
||||
} as const;
|
||||
|
||||
// ── Combat Unit Widget ──────────────────────────────────────────────────────
|
||||
|
||||
export const COMBAT_WIDGET_CONFIG = {
|
||||
/** Default widget width */
|
||||
WIDTH: 240,
|
||||
/** Default widget height */
|
||||
HEIGHT: 120,
|
||||
/** HP bar width */
|
||||
HP_BAR_WIDTH: 180,
|
||||
/** HP bar height */
|
||||
HP_BAR_HEIGHT: 16,
|
||||
/** Buff/debuff icon size */
|
||||
BUFF_ICON_SIZE: 28,
|
||||
/** Gap between buff icons */
|
||||
BUFF_ICON_GAP: 6,
|
||||
} as const;
|
||||
|
||||
// ── UI / Button ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const UI_CONFIG = {
|
||||
/** Standard button width */
|
||||
BUTTON_WIDTH: 120,
|
||||
/** Standard button height */
|
||||
BUTTON_HEIGHT: 36,
|
||||
/** Large button width */
|
||||
BUTTON_WIDTH_LARGE: 220,
|
||||
/** Large button height */
|
||||
BUTTON_HEIGHT_LARGE: 50,
|
||||
/** Button background color */
|
||||
BUTTON_BG: 0x444466,
|
||||
/** Button hover background color */
|
||||
BUTTON_BG_HOVER: 0x555588,
|
||||
/** Button border color */
|
||||
BUTTON_BORDER: 0x7777aa,
|
||||
/** Button text color */
|
||||
BUTTON_TEXT_COLOR: '#ffffff',
|
||||
/** Button font size */
|
||||
BUTTON_FONT_SIZE: '16px',
|
||||
} as const;
|
||||
|
||||
// ── Colors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const NODE_COLORS = {
|
||||
start: 0x44aa44,
|
||||
end: 0xcc8844,
|
||||
minion: 0xcc4444,
|
||||
elite: 0xcc44cc,
|
||||
event: 0xaaaa44,
|
||||
camp: 0x44cccc,
|
||||
shop: 0x4488cc,
|
||||
curio: 0x8844cc,
|
||||
} as const;
|
||||
|
||||
export const NODE_LABELS = {
|
||||
start: '起点',
|
||||
end: '终点',
|
||||
minion: '战斗',
|
||||
elite: '精英',
|
||||
event: '事件',
|
||||
camp: '营地',
|
||||
shop: '商店',
|
||||
curio: '奇遇',
|
||||
} as const;
|
||||
|
||||
export const ITEM_COLORS = [
|
||||
0x4488cc, 0xcc8844, 0x44cc88, 0xcc4488, 0x8844cc, 0x44cccc,
|
||||
] as const;
|
||||
|
||||
// ── Positive/Negative Effects (for buff icons) ──────────────────────────────
|
||||
|
||||
export const POSITIVE_EFFECTS = new Set(['block', 'strength', 'dexterity', 'regen']);
|
||||
export const NEGATIVE_EFFECTS = new Set(['weak', 'vulnerable', 'frail', 'poison']);
|
||||
|
|
@ -1,22 +1,48 @@
|
|||
import Phaser from "phaser";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { UI_CONFIG, MAP_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
|
||||
import { MutableSignal } from "boardgame-core";
|
||||
import Phaser from 'phaser';
|
||||
import { ReactiveScene } from 'boardgame-phaser';
|
||||
import { MutableSignal } from 'boardgame-core';
|
||||
import {
|
||||
canMoveTo,
|
||||
moveToNode,
|
||||
getCurrentNode,
|
||||
getReachableChildren,
|
||||
isAtEndNode,
|
||||
isAtStartNode,
|
||||
type RunState,
|
||||
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 {
|
||||
/** 全局游戏状态(由 App.tsx 注入) */
|
||||
private gameState: MutableSignal<RunState>;
|
||||
|
||||
// Layout constants
|
||||
private readonly LAYER_HEIGHT = 110;
|
||||
private readonly NODE_SPACING = 140;
|
||||
private readonly NODE_RADIUS = 28;
|
||||
|
||||
// UI elements
|
||||
private hudContainer!: Phaser.GameObjects.Container;
|
||||
private hpText!: Phaser.GameObjects.Text;
|
||||
|
|
@ -36,7 +62,7 @@ export class GameFlowScene extends ReactiveScene {
|
|||
private nodeGraphics: Map<string, Phaser.GameObjects.Graphics> = new Map();
|
||||
|
||||
constructor(gameState: MutableSignal<RunState>) {
|
||||
super("GameFlowScene");
|
||||
super('GameFlowScene');
|
||||
this.gameState = gameState;
|
||||
}
|
||||
|
||||
|
|
@ -56,46 +82,31 @@ export class GameFlowScene extends ReactiveScene {
|
|||
this.hudContainer.add(hudBg);
|
||||
|
||||
// HP
|
||||
this.hpText = this.add
|
||||
.text(-150, 0, "", {
|
||||
fontSize: "16px",
|
||||
color: "#ff6666",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0, 0.5);
|
||||
this.hpText = this.add.text(-150, 0, '', {
|
||||
fontSize: '16px',
|
||||
color: '#ff6666',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0, 0.5);
|
||||
this.hudContainer.add(this.hpText);
|
||||
|
||||
// Gold
|
||||
this.goldText = this.add
|
||||
.text(-50, 0, "", {
|
||||
fontSize: "16px",
|
||||
color: "#ffcc44",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0, 0.5);
|
||||
this.goldText = this.add.text(-50, 0, '', {
|
||||
fontSize: '16px',
|
||||
color: '#ffcc44',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0, 0.5);
|
||||
this.hudContainer.add(this.goldText);
|
||||
|
||||
// Current node
|
||||
this.nodeText = this.add
|
||||
.text(50, 0, "", {
|
||||
fontSize: "16px",
|
||||
color: "#ffffff",
|
||||
})
|
||||
.setOrigin(0, 0.5);
|
||||
this.nodeText = this.add.text(50, 0, '', {
|
||||
fontSize: '16px',
|
||||
color: '#ffffff',
|
||||
}).setOrigin(0, 0.5);
|
||||
this.hudContainer.add(this.nodeText);
|
||||
|
||||
// Back to menu button
|
||||
createButton({
|
||||
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,
|
||||
this.createButton('返回菜单', width - 100, 25, 140, 36, async () => {
|
||||
await this.sceneController.launch('IndexScene');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -117,26 +128,18 @@ export class GameFlowScene extends ReactiveScene {
|
|||
private drawMap(): void {
|
||||
const { width, height } = this.scale;
|
||||
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)
|
||||
const maxLayer = TOTAL_LAYERS - 1;
|
||||
const mapWidth = maxLayer * LAYER_HEIGHT + 200;
|
||||
const mapHeight = (MAX_NODES_PER_LAYER - 1) * NODE_SPACING + 200;
|
||||
const maxLayer = 9;
|
||||
const maxNodesInLayer = 5;
|
||||
const mapWidth = maxLayer * this.LAYER_HEIGHT + 200;
|
||||
const mapHeight = (maxNodesInLayer - 1) * this.NODE_SPACING + 200;
|
||||
|
||||
// Create scrollable container
|
||||
this.mapContainer = this.add.container(width / 2, height / 2 + 50);
|
||||
|
||||
// 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);
|
||||
|
||||
const graphics = this.add.graphics();
|
||||
|
|
@ -144,7 +147,7 @@ export class GameFlowScene extends ReactiveScene {
|
|||
|
||||
const { map, currentNodeId } = state;
|
||||
const reachableChildren = getReachableChildren(state);
|
||||
const reachableIds = new Set(reachableChildren.map((n) => n.id));
|
||||
const reachableIds = new Set(reachableChildren.map(n => n.id));
|
||||
|
||||
// Draw edges
|
||||
graphics.lineStyle(2, 0x666666);
|
||||
|
|
@ -175,13 +178,9 @@ export class GameFlowScene extends ReactiveScene {
|
|||
this.mapContainer.add(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.fillCircle(posX, posY, NODE_RADIUS);
|
||||
nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
|
||||
|
||||
if (isCurrent) {
|
||||
nodeGraphics.lineStyle(3, 0xffff44);
|
||||
|
|
@ -190,109 +189,86 @@ export class GameFlowScene extends ReactiveScene {
|
|||
} else {
|
||||
nodeGraphics.lineStyle(2, 0x888888);
|
||||
}
|
||||
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
|
||||
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
|
||||
|
||||
// Node label
|
||||
const label = NODE_LABELS[node.type] ?? node.type;
|
||||
this.mapContainer.add(
|
||||
this.add
|
||||
.text(posX, posY, label, {
|
||||
fontSize: "11px",
|
||||
color: "#ffffff",
|
||||
fontStyle: isCurrent ? "bold" : "normal",
|
||||
})
|
||||
.setOrigin(0.5),
|
||||
this.add.text(posX, posY, label, {
|
||||
fontSize: '11px',
|
||||
color: '#ffffff',
|
||||
fontStyle: isCurrent ? 'bold' : 'normal',
|
||||
}).setOrigin(0.5)
|
||||
);
|
||||
|
||||
// Encounter name
|
||||
if (node.encounter) {
|
||||
this.mapContainer.add(
|
||||
this.add
|
||||
.text(posX, posY + NODE_RADIUS + 12, node.encounter.name, {
|
||||
fontSize: "10px",
|
||||
color: "#cccccc",
|
||||
})
|
||||
.setOrigin(0.5),
|
||||
this.add.text(posX, posY + this.NODE_RADIUS + 12, node.encounter.name, {
|
||||
fontSize: '10px',
|
||||
color: '#cccccc',
|
||||
}).setOrigin(0.5)
|
||||
);
|
||||
}
|
||||
|
||||
// Make reachable nodes interactive
|
||||
// Make reachable nodes interactive (add hitZone to mapContainer so positions match)
|
||||
if (isReachable) {
|
||||
const hitZone = this.add
|
||||
.circle(posX, posY, NODE_RADIUS, 0x000000, 0)
|
||||
const hitZone = this.add.circle(posX, posY, this.NODE_RADIUS, 0x000000, 0)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
this.mapContainer.add(hitZone);
|
||||
|
||||
hitZone.on("pointerover", () => {
|
||||
hitZone.on('pointerover', () => {
|
||||
this.hoveredNode = nodeId;
|
||||
nodeGraphics.clear();
|
||||
nodeGraphics.fillStyle(this.brightenColor(baseColor));
|
||||
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
|
||||
nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
|
||||
nodeGraphics.lineStyle(3, 0xaaddaa);
|
||||
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
|
||||
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
|
||||
});
|
||||
|
||||
hitZone.on("pointerout", () => {
|
||||
hitZone.on('pointerout', () => {
|
||||
this.hoveredNode = null;
|
||||
nodeGraphics.clear();
|
||||
nodeGraphics.fillStyle(baseColor);
|
||||
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
|
||||
nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
|
||||
nodeGraphics.lineStyle(2, 0xaaddaa);
|
||||
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
|
||||
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
|
||||
});
|
||||
|
||||
hitZone.on("pointerdown", () => {
|
||||
hitZone.on('pointerdown', () => {
|
||||
this.onNodeClick(nodeId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Setup drag-to-scroll with disposables cleanup
|
||||
const onPointerDown = (pointer: Phaser.Input.Pointer) => {
|
||||
// Setup drag-to-scroll
|
||||
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
||||
this.isDragging = true;
|
||||
this.dragStartX = pointer.x;
|
||||
this.dragStartY = pointer.y;
|
||||
this.dragStartContainerX = this.mapContainer.x;
|
||||
this.dragStartContainerY = this.mapContainer.y;
|
||||
};
|
||||
});
|
||||
|
||||
const onPointerMove = (pointer: Phaser.Input.Pointer) => {
|
||||
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
|
||||
if (!this.isDragging) return;
|
||||
this.mapContainer.x =
|
||||
this.dragStartContainerX + (pointer.x - this.dragStartX);
|
||||
this.mapContainer.y =
|
||||
this.dragStartContainerY + (pointer.y - this.dragStartY);
|
||||
};
|
||||
this.mapContainer.x = this.dragStartContainerX + (pointer.x - this.dragStartX);
|
||||
this.mapContainer.y = this.dragStartContainerY + (pointer.y - this.dragStartY);
|
||||
});
|
||||
|
||||
const onPointerUp = () => {
|
||||
this.input.on('pointerup', () => {
|
||||
this.isDragging = false;
|
||||
};
|
||||
});
|
||||
|
||||
this.input.on("pointerdown", onPointerDown);
|
||||
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);
|
||||
this.input.on('pointerout', () => {
|
||||
this.isDragging = false;
|
||||
});
|
||||
|
||||
// Hint text
|
||||
this.add
|
||||
.text(
|
||||
width / 2,
|
||||
this.scale.height - 20,
|
||||
"点击可到达的节点进入遭遇 | 拖拽滚动查看地图",
|
||||
{
|
||||
fontSize: "14px",
|
||||
color: "#888888",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5)
|
||||
.setDepth(200);
|
||||
this.add.text(width / 2, this.scale.height - 20, '点击可到达的节点进入遭遇 | 拖拽滚动查看地图', {
|
||||
fontSize: '14px',
|
||||
color: '#888888',
|
||||
}).setOrigin(0.5).setDepth(200);
|
||||
}
|
||||
|
||||
private async onNodeClick(nodeId: string): Promise<void> {
|
||||
|
|
@ -321,18 +297,18 @@ export class GameFlowScene extends ReactiveScene {
|
|||
// Launch encounter scene
|
||||
const currentNode = getCurrentNode(state);
|
||||
if (!currentNode || !currentNode.encounter) {
|
||||
console.warn("当前节点没有遭遇数据");
|
||||
console.warn('当前节点没有遭遇数据');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sceneController.launch("PlaceholderEncounterScene");
|
||||
await this.sceneController.launch('PlaceholderEncounterScene');
|
||||
}
|
||||
|
||||
private redrawMapHighlights(): void {
|
||||
const state = this.gameState.value;
|
||||
const { map, currentNodeId } = state;
|
||||
const reachableChildren = getReachableChildren(state);
|
||||
const reachableIds = new Set(reachableChildren.map((n) => n.id));
|
||||
const reachableIds = new Set(reachableChildren.map(n => n.id));
|
||||
|
||||
for (const [nodeId, nodeGraphics] of this.nodeGraphics) {
|
||||
const node = map.nodes.get(nodeId);
|
||||
|
|
@ -343,17 +319,9 @@ export class GameFlowScene extends ReactiveScene {
|
|||
const baseColor = NODE_COLORS[node.type] ?? 0x888888;
|
||||
|
||||
nodeGraphics.clear();
|
||||
const color = isCurrent
|
||||
? 0xffffff
|
||||
: isReachable
|
||||
? this.brightenColor(baseColor)
|
||||
: baseColor;
|
||||
const color = isCurrent ? 0xffffff : (isReachable ? this.brightenColor(baseColor) : baseColor);
|
||||
nodeGraphics.fillStyle(color);
|
||||
nodeGraphics.fillCircle(
|
||||
this.getNodeX(node),
|
||||
this.getNodeY(node),
|
||||
MAP_CONFIG.NODE_RADIUS,
|
||||
);
|
||||
nodeGraphics.fillCircle(this.getNodeX(node), this.getNodeY(node), this.NODE_RADIUS);
|
||||
|
||||
if (isCurrent) {
|
||||
nodeGraphics.lineStyle(3, 0xffff44);
|
||||
|
|
@ -362,11 +330,7 @@ export class GameFlowScene extends ReactiveScene {
|
|||
} else {
|
||||
nodeGraphics.lineStyle(2, 0x888888);
|
||||
}
|
||||
nodeGraphics.strokeCircle(
|
||||
this.getNodeX(node),
|
||||
this.getNodeY(node),
|
||||
MAP_CONFIG.NODE_RADIUS,
|
||||
);
|
||||
nodeGraphics.strokeCircle(this.getNodeX(node), this.getNodeY(node), this.NODE_RADIUS);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -375,65 +339,79 @@ export class GameFlowScene extends ReactiveScene {
|
|||
const state = this.gameState.value;
|
||||
|
||||
// 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
|
||||
this.add
|
||||
.text(width / 2, height / 2 - 40, "恭喜通关!", {
|
||||
fontSize: "36px",
|
||||
color: "#ffcc44",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(300);
|
||||
this.add.text(width / 2, height / 2 - 40, '恭喜通关!', {
|
||||
fontSize: '36px',
|
||||
color: '#ffcc44',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5).setDepth(300);
|
||||
|
||||
const { player } = state;
|
||||
this.add
|
||||
.text(
|
||||
width / 2,
|
||||
height / 2 + 20,
|
||||
`剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`,
|
||||
{
|
||||
fontSize: "20px",
|
||||
color: "#ffffff",
|
||||
align: "center",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5)
|
||||
.setDepth(300);
|
||||
this.add.text(width / 2, height / 2 + 20, `剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`, {
|
||||
fontSize: '20px',
|
||||
color: '#ffffff',
|
||||
align: 'center',
|
||||
}).setOrigin(0.5).setDepth(300);
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
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,
|
||||
});
|
||||
this.createButton('返回菜单', width / 2, height / 2 + 100, 200, 50, async () => {
|
||||
await this.sceneController.launch('IndexScene');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
private getNodeX(node: MapNode): number {
|
||||
return -500 + node.layerIndex * MAP_CONFIG.LAYER_HEIGHT;
|
||||
// Layers go left-to-right along X axis
|
||||
return -500 + node.layerIndex * this.LAYER_HEIGHT;
|
||||
}
|
||||
|
||||
private getNodeY(node: MapNode): number {
|
||||
const layer = this.gameState.value.map.layers[node.layerIndex];
|
||||
// Nodes within a layer are spread vertically along Y axis
|
||||
const state = this.gameState.value;
|
||||
const layer = state.map.layers[node.layerIndex];
|
||||
const nodeIndex = layer.nodeIds.indexOf(node.id);
|
||||
const totalNodes = layer.nodeIds.length;
|
||||
const layerHeight = (totalNodes - 1) * MAP_CONFIG.NODE_SPACING;
|
||||
return -layerHeight / 2 + nodeIndex * MAP_CONFIG.NODE_SPACING;
|
||||
const layerHeight = (totalNodes - 1) * this.NODE_SPACING;
|
||||
return -layerHeight / 2 + nodeIndex * this.NODE_SPACING;
|
||||
}
|
||||
|
||||
private brightenColor(color: number): number {
|
||||
// Simple color brightening
|
||||
const r = Math.min(255, ((color >> 16) & 0xff) + 40);
|
||||
const g = Math.min(255, ((color >> 8) & 0xff) + 40);
|
||||
const b = Math.min(255, (color & 0xff) + 40);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import Phaser from "phaser";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { GRID_CONFIG, UI_CONFIG, ITEM_COLORS } from "@/config";
|
||||
import Phaser from 'phaser';
|
||||
import {
|
||||
createGridInventory,
|
||||
placeItem,
|
||||
|
|
@ -11,49 +8,44 @@ import {
|
|||
type GridInventory,
|
||||
type InventoryItem,
|
||||
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 {
|
||||
private inventory: GridInventory<GameItemMeta>;
|
||||
private readonly CELL_SIZE = 60;
|
||||
private readonly GRID_WIDTH = 6;
|
||||
private readonly GRID_HEIGHT = 4;
|
||||
private gridOffsetX = 0;
|
||||
private gridOffsetY = 0;
|
||||
|
||||
constructor() {
|
||||
super("GridViewerScene");
|
||||
this.inventory = createGridInventory<GameItemMeta>(
|
||||
GRID_CONFIG.WIDTH,
|
||||
GRID_CONFIG.HEIGHT,
|
||||
);
|
||||
super('GridViewerScene');
|
||||
this.inventory = createGridInventory<GameItemMeta>(this.GRID_WIDTH, this.GRID_HEIGHT);
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
const { width, height } = this.scale;
|
||||
this.gridOffsetX =
|
||||
(width - GRID_CONFIG.WIDTH * GRID_CONFIG.VIEWER_CELL_SIZE) / 2;
|
||||
this.gridOffsetY =
|
||||
(height - GRID_CONFIG.HEIGHT * GRID_CONFIG.VIEWER_CELL_SIZE) / 2 + 20;
|
||||
this.gridOffsetX = (width - this.GRID_WIDTH * this.CELL_SIZE) / 2;
|
||||
this.gridOffsetY = (height - this.GRID_HEIGHT * this.CELL_SIZE) / 2 + 20;
|
||||
|
||||
this.placeSampleItems();
|
||||
this.drawGrid();
|
||||
this.drawItems();
|
||||
|
||||
this.add
|
||||
.text(width / 2, 30, "Grid Inventory Viewer (4x6)", {
|
||||
fontSize: "24px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(width / 2, 30, 'Grid Inventory Viewer (4x6)', {
|
||||
fontSize: '24px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.createControls();
|
||||
|
||||
this.add
|
||||
.text(width / 2, height - 40, "Hover over cells to see item details", {
|
||||
fontSize: "14px",
|
||||
color: "#aaaaaa",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(width / 2, height - 40, 'Hover over cells to see item details', {
|
||||
fontSize: '14px',
|
||||
color: '#aaaaaa',
|
||||
}).setOrigin(0.5);
|
||||
}
|
||||
|
||||
private placeSampleItems(): void {
|
||||
|
|
@ -72,12 +64,7 @@ export class GridViewerScene extends ReactiveScene {
|
|||
const item: InventoryItem<GameItemMeta> = {
|
||||
id: `item-${index}`,
|
||||
shape,
|
||||
transform: {
|
||||
offset: { x, y },
|
||||
rotation: 0,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
},
|
||||
transform: { offset: { x, y }, rotation: 0, flipX: false, flipY: false },
|
||||
meta: { itemData, shape },
|
||||
};
|
||||
placeItem(this.inventory, item);
|
||||
|
|
@ -87,28 +74,18 @@ export class GridViewerScene extends ReactiveScene {
|
|||
private drawGrid(): void {
|
||||
const graphics = this.add.graphics();
|
||||
|
||||
for (let y = 0; y < GRID_CONFIG.HEIGHT; y++) {
|
||||
for (let x = 0; x < GRID_CONFIG.WIDTH; x++) {
|
||||
const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
for (let y = 0; y < this.GRID_HEIGHT; y++) {
|
||||
for (let x = 0; x < this.GRID_WIDTH; x++) {
|
||||
const px = this.gridOffsetX + x * this.CELL_SIZE;
|
||||
const py = this.gridOffsetY + y * this.CELL_SIZE;
|
||||
|
||||
const isOccupied = this.inventory.occupiedCells.has(`${x},${y}`);
|
||||
const color = isOccupied ? 0x334455 : 0x222233;
|
||||
|
||||
graphics.fillStyle(color);
|
||||
graphics.fillRect(
|
||||
px + 1,
|
||||
py + 1,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
|
||||
);
|
||||
graphics.fillRect(px + 1, py + 1, this.CELL_SIZE - 2, this.CELL_SIZE - 2);
|
||||
graphics.lineStyle(1, 0x555577);
|
||||
graphics.strokeRect(
|
||||
px,
|
||||
py,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE,
|
||||
);
|
||||
graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -133,120 +110,72 @@ export class GridViewerScene extends ReactiveScene {
|
|||
const graphics = this.add.graphics();
|
||||
|
||||
for (const cell of cells) {
|
||||
const px = this.gridOffsetX + cell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
const py = this.gridOffsetY + cell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
const px = this.gridOffsetX + cell.x * this.CELL_SIZE;
|
||||
const py = this.gridOffsetY + cell.y * this.CELL_SIZE;
|
||||
|
||||
graphics.fillStyle(itemColor);
|
||||
graphics.fillRect(
|
||||
px + 2,
|
||||
py + 2,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
|
||||
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
|
||||
);
|
||||
graphics.fillRect(px + 2, py + 2, this.CELL_SIZE - 4, this.CELL_SIZE - 4);
|
||||
}
|
||||
|
||||
if (cells.length > 0) {
|
||||
const firstCell = cells[0];
|
||||
const px =
|
||||
this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
const py =
|
||||
this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
const px = this.gridOffsetX + firstCell.x * this.CELL_SIZE;
|
||||
const py = this.gridOffsetY + firstCell.y * this.CELL_SIZE;
|
||||
const itemName = item.meta?.itemData.name ?? item.id;
|
||||
|
||||
this.add
|
||||
.text(
|
||||
px + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
|
||||
py + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
|
||||
itemName,
|
||||
{
|
||||
fontSize: "11px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
this.add.text(px + this.CELL_SIZE / 2, py + this.CELL_SIZE / 2, itemName, {
|
||||
fontSize: '11px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
}
|
||||
|
||||
const adjacent = getAdjacentItems(this.inventory, itemId);
|
||||
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 px =
|
||||
this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||
const py =
|
||||
this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE - 20;
|
||||
const px = this.gridOffsetX + firstCell.x * this.CELL_SIZE;
|
||||
const py = this.gridOffsetY + firstCell.y * this.CELL_SIZE - 20;
|
||||
|
||||
this.add
|
||||
.text(
|
||||
px + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
|
||||
py,
|
||||
`邻接: ${adjacentNames}`,
|
||||
{
|
||||
fontSize: "10px",
|
||||
color: "#ffff88",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
this.add.text(px + this.CELL_SIZE / 2, py, `邻接: ${adjacentNames}`, {
|
||||
fontSize: '10px',
|
||||
color: '#ffff88',
|
||||
}).setOrigin(0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getItemColor(itemId: string): number {
|
||||
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||
return ITEM_COLORS[hash % ITEM_COLORS.length];
|
||||
const hash = itemId.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||
const colors = [0x4488cc, 0xcc8844, 0x44cc88, 0xcc4488, 0x8844cc, 0x44cccc];
|
||||
return colors[hash % colors.length];
|
||||
}
|
||||
|
||||
private createControls(): void {
|
||||
const { width, height } = this.scale;
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "返回菜单",
|
||||
x: 100,
|
||||
y: 40,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch("IndexScene");
|
||||
},
|
||||
this.createButton('返回菜单', 100, 40, async () => {
|
||||
await this.sceneController.launch('IndexScene');
|
||||
});
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "清空",
|
||||
x: width - 260,
|
||||
y: 40,
|
||||
onClick: async () => {
|
||||
this.inventory = createGridInventory<GameItemMeta>(
|
||||
GRID_CONFIG.WIDTH,
|
||||
GRID_CONFIG.HEIGHT,
|
||||
);
|
||||
this.createButton('清空', width - 260, 40, async () => {
|
||||
this.inventory = createGridInventory<GameItemMeta>(this.GRID_WIDTH, this.GRID_HEIGHT);
|
||||
await this.sceneController.restart();
|
||||
},
|
||||
});
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "随机填充",
|
||||
x: width - 130,
|
||||
y: 40,
|
||||
onClick: async () => {
|
||||
this.createButton('随机填充', width - 130, 40, async () => {
|
||||
this.randomFill();
|
||||
await this.sceneController.restart();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private randomFill(): void {
|
||||
this.inventory = createGridInventory<GameItemMeta>(
|
||||
GRID_CONFIG.WIDTH,
|
||||
GRID_CONFIG.HEIGHT,
|
||||
);
|
||||
this.inventory = createGridInventory<GameItemMeta>(this.GRID_WIDTH, this.GRID_HEIGHT);
|
||||
const items = data.desert.items;
|
||||
|
||||
let itemIndex = 0;
|
||||
for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) {
|
||||
for (let x = 0; x < GRID_CONFIG.WIDTH && itemIndex < items.length; x++) {
|
||||
for (let y = 0; y < this.GRID_HEIGHT && itemIndex < items.length; y++) {
|
||||
for (let x = 0; x < this.GRID_WIDTH && itemIndex < items.length; x++) {
|
||||
const itemData = items[itemIndex];
|
||||
const shape = parseShapeString(itemData.shape);
|
||||
|
||||
|
|
@ -261,12 +190,8 @@ export class GridViewerScene extends ReactiveScene {
|
|||
|
||||
let valid = true;
|
||||
for (const cell of occupiedCells) {
|
||||
const [cx, cy] = cell.split(",").map(Number);
|
||||
if (
|
||||
cx >= GRID_CONFIG.WIDTH ||
|
||||
cy >= GRID_CONFIG.HEIGHT ||
|
||||
this.inventory.occupiedCells.has(cell as `${number},${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}`)) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
|
|
@ -276,12 +201,7 @@ export class GridViewerScene extends ReactiveScene {
|
|||
const item: InventoryItem<GameItemMeta> = {
|
||||
id: `item-${itemIndex}`,
|
||||
shape,
|
||||
transform: {
|
||||
offset: { x, y },
|
||||
rotation: 0,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
},
|
||||
transform: { offset: { x, y }, rotation: 0, flipX: false, flipY: false },
|
||||
meta: { itemData, shape },
|
||||
};
|
||||
placeItem(this.inventory, item);
|
||||
|
|
@ -290,4 +210,30 @@ export class GridViewerScene extends ReactiveScene {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createButton(label: string, x: number, y: number, onClick: () => void): void {
|
||||
const buttonWidth = 120;
|
||||
const buttonHeight = 36;
|
||||
|
||||
const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x444466)
|
||||
.setStrokeStyle(2, 0x7777aa)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
const text = this.add.text(x, y, label, {
|
||||
fontSize: '16px',
|
||||
color: '#ffffff',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
bg.on('pointerover', () => {
|
||||
bg.setFillStyle(0x555588);
|
||||
text.setScale(1.05);
|
||||
});
|
||||
|
||||
bg.on('pointerout', () => {
|
||||
bg.setFillStyle(0x444466);
|
||||
text.setScale(1);
|
||||
});
|
||||
|
||||
bg.on('pointerdown', onClick);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import Phaser from "phaser";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { UI_CONFIG } from "@/config";
|
||||
import Phaser from 'phaser';
|
||||
import { ReactiveScene } from 'boardgame-phaser';
|
||||
|
||||
export class IndexScene extends ReactiveScene {
|
||||
constructor() {
|
||||
super("IndexScene");
|
||||
super('IndexScene');
|
||||
}
|
||||
|
||||
create(): void {
|
||||
|
|
@ -15,32 +13,24 @@ export class IndexScene extends ReactiveScene {
|
|||
const centerY = height / 2;
|
||||
|
||||
// Title
|
||||
this.add
|
||||
.text(centerX, centerY - 150, "Slay-the-Spire-Like Viewer", {
|
||||
fontSize: "36px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(centerX, centerY - 150, 'Slay-the-Spire-Like Viewer', {
|
||||
fontSize: '36px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Subtitle
|
||||
this.add
|
||||
.text(centerX, centerY - 100, "Choose a viewer to explore:", {
|
||||
fontSize: "18px",
|
||||
color: "#aaaaaa",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(centerX, centerY - 100, 'Choose a viewer to explore:', {
|
||||
fontSize: '18px',
|
||||
color: '#aaaaaa',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Buttons
|
||||
const buttons = [
|
||||
{ label: "开始游戏", scene: "GameFlowScene", y: centerY - 70 },
|
||||
{ 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: '开始游戏', scene: 'GameFlowScene', y: centerY - 70 },
|
||||
{ label: 'Map Viewer', scene: 'MapViewerScene', y: centerY },
|
||||
{ label: 'Grid Inventory Viewer', scene: 'GridViewerScene', y: centerY + 70 },
|
||||
{ label: 'Shape Viewer', scene: 'ShapeViewerScene', y: centerY + 140 },
|
||||
];
|
||||
|
||||
for (const btn of buttons) {
|
||||
|
|
@ -48,22 +38,34 @@ export class IndexScene extends ReactiveScene {
|
|||
}
|
||||
}
|
||||
|
||||
private createButton(
|
||||
label: string,
|
||||
targetScene: string,
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
createButton({
|
||||
scene: this,
|
||||
label,
|
||||
x,
|
||||
y,
|
||||
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
|
||||
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
|
||||
onClick: async () => {
|
||||
private createButton(label: string, targetScene: string, x: number, y: number): void {
|
||||
const buttonWidth = 300;
|
||||
const buttonHeight = 50;
|
||||
|
||||
// Background
|
||||
const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x333355)
|
||||
.setStrokeStyle(2, 0x6666aa)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
// Text
|
||||
const text = this.add.text(x, y, label, {
|
||||
fontSize: '20px',
|
||||
color: '#ffffff',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,39 @@
|
|||
import Phaser from "phaser";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { UI_CONFIG, MAP_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
|
||||
import { createRNG } from "boardgame-core";
|
||||
import {
|
||||
generatePointCrawlMap,
|
||||
data,
|
||||
type PointCrawlMap,
|
||||
type MapNode,
|
||||
type MapNodeType,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import Phaser from 'phaser';
|
||||
import { ReactiveScene } from 'boardgame-phaser';
|
||||
import { createRNG } from 'boardgame-core';
|
||||
import { generatePointCrawlMap, data, type PointCrawlMap, type MapNode, MapNodeType } from 'boardgame-core/samples/slay-the-spire-like';
|
||||
|
||||
const NODE_COLORS: Record<MapNodeType, number> = {
|
||||
[MapNodeType.Start]: 0x44aa44,
|
||||
[MapNodeType.End]: 0xcc8844,
|
||||
[MapNodeType.Minion]: 0xcc4444,
|
||||
[MapNodeType.Elite]: 0xcc44cc,
|
||||
[MapNodeType.Event]: 0xaaaa44,
|
||||
[MapNodeType.Camp]: 0x44cccc,
|
||||
[MapNodeType.Shop]: 0x4488cc,
|
||||
[MapNodeType.Curio]: 0x8844cc,
|
||||
};
|
||||
|
||||
const NODE_LABELS: Record<MapNodeType, string> = {
|
||||
[MapNodeType.Start]: '起点',
|
||||
[MapNodeType.End]: '终点',
|
||||
[MapNodeType.Minion]: '战斗',
|
||||
[MapNodeType.Elite]: '精英',
|
||||
[MapNodeType.Event]: '事件',
|
||||
[MapNodeType.Camp]: '篝火',
|
||||
[MapNodeType.Shop]: '商店',
|
||||
[MapNodeType.Curio]: '奇遇',
|
||||
};
|
||||
|
||||
export class MapViewerScene extends ReactiveScene {
|
||||
private map: PointCrawlMap | null = null;
|
||||
private seed: number = Date.now();
|
||||
|
||||
// Layout constants
|
||||
private readonly LAYER_HEIGHT = 110;
|
||||
private readonly NODE_SPACING = 140;
|
||||
private readonly NODE_RADIUS = 28;
|
||||
|
||||
// Scroll state
|
||||
private mapContainer!: Phaser.GameObjects.Container;
|
||||
private isDragging = false;
|
||||
|
|
@ -25,10 +44,14 @@ export class MapViewerScene extends ReactiveScene {
|
|||
|
||||
// Fixed UI (always visible, not scrolled)
|
||||
private titleText!: Phaser.GameObjects.Text;
|
||||
private backButtonBg!: Phaser.GameObjects.Rectangle;
|
||||
private backButtonText!: Phaser.GameObjects.Text;
|
||||
private regenButtonBg!: Phaser.GameObjects.Rectangle;
|
||||
private regenButtonText!: Phaser.GameObjects.Text;
|
||||
private legendContainer!: Phaser.GameObjects.Container;
|
||||
|
||||
constructor() {
|
||||
super("MapViewerScene");
|
||||
super('MapViewerScene');
|
||||
}
|
||||
|
||||
create(): void {
|
||||
|
|
@ -38,77 +61,86 @@ export class MapViewerScene extends ReactiveScene {
|
|||
}
|
||||
|
||||
private drawFixedUI(): void {
|
||||
const { width, height } = this.scale;
|
||||
const { width } = this.scale;
|
||||
|
||||
// Title
|
||||
this.titleText = this.add
|
||||
.text(width / 2, 30, "", {
|
||||
fontSize: "24px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(100);
|
||||
this.titleText = this.add.text(width / 2, 30, '', {
|
||||
fontSize: '24px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5).setDepth(100);
|
||||
|
||||
// Back button
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "返回菜单",
|
||||
x: 100,
|
||||
y: 40,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch("IndexScene");
|
||||
},
|
||||
depth: 100,
|
||||
this.backButtonBg = this.add.rectangle(100, 40, 140, 36, 0x444466)
|
||||
.setStrokeStyle(2, 0x7777aa)
|
||||
.setInteractive({ useHandCursor: true })
|
||||
.setDepth(100);
|
||||
this.backButtonText = this.add.text(100, 40, '返回菜单', {
|
||||
fontSize: '16px',
|
||||
color: '#ffffff',
|
||||
}).setOrigin(0.5).setDepth(100);
|
||||
|
||||
this.backButtonBg.on('pointerover', () => {
|
||||
this.backButtonBg.setFillStyle(0x555588);
|
||||
this.backButtonText.setScale(1.05);
|
||||
});
|
||||
this.backButtonBg.on('pointerout', () => {
|
||||
this.backButtonBg.setFillStyle(0x444466);
|
||||
this.backButtonText.setScale(1);
|
||||
});
|
||||
this.backButtonBg.on('pointerdown', async () => {
|
||||
await this.sceneController.launch('IndexScene');
|
||||
});
|
||||
|
||||
// Regenerate button
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "重新生成",
|
||||
x: width - 120,
|
||||
y: 40,
|
||||
onClick: () => {
|
||||
this.regenButtonBg = this.add.rectangle(width - 120, 40, 140, 36, 0x444466)
|
||||
.setStrokeStyle(2, 0x7777aa)
|
||||
.setInteractive({ useHandCursor: true })
|
||||
.setDepth(100);
|
||||
this.regenButtonText = this.add.text(width - 120, 40, '重新生成', {
|
||||
fontSize: '16px',
|
||||
color: '#ffffff',
|
||||
}).setOrigin(0.5).setDepth(100);
|
||||
|
||||
this.regenButtonBg.on('pointerover', () => {
|
||||
this.regenButtonBg.setFillStyle(0x555588);
|
||||
this.regenButtonText.setScale(1.05);
|
||||
});
|
||||
this.regenButtonBg.on('pointerout', () => {
|
||||
this.regenButtonBg.setFillStyle(0x444466);
|
||||
this.regenButtonText.setScale(1);
|
||||
});
|
||||
this.regenButtonBg.on('pointerdown', () => {
|
||||
this.seed = Date.now();
|
||||
this.mapContainer.destroy();
|
||||
this.drawMap();
|
||||
},
|
||||
depth: 100,
|
||||
});
|
||||
|
||||
// Legend (bottom-left, fixed)
|
||||
this.legendContainer = this.add.container(20, height - 180).setDepth(100);
|
||||
this.legendContainer = this.add.container(20, this.scale.height - 180).setDepth(100);
|
||||
const legendBg = this.add.rectangle(75, 80, 150, 160, 0x222222, 0.8);
|
||||
this.legendContainer.add(legendBg);
|
||||
|
||||
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;
|
||||
for (const [type, color] of Object.entries(NODE_COLORS)) {
|
||||
this.legendContainer.add(this.add.circle(20, offsetY, 8, color));
|
||||
this.legendContainer.add(
|
||||
this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], {
|
||||
fontSize: "12px",
|
||||
color: "#ffffff",
|
||||
}),
|
||||
this.add.circle(20, offsetY, 8, color)
|
||||
);
|
||||
this.legendContainer.add(
|
||||
this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], { fontSize: '12px', color: '#ffffff' })
|
||||
);
|
||||
offsetY += 20;
|
||||
}
|
||||
|
||||
// Hint text
|
||||
this.add
|
||||
.text(width / 2, height - 20, "拖拽滚动查看地图 (从左到右)", {
|
||||
fontSize: "14px",
|
||||
color: "#888888",
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(100);
|
||||
this.add.text(width / 2, this.scale.height - 20, '拖拽滚动查看地图 (从左到右)', {
|
||||
fontSize: '14px',
|
||||
color: '#888888',
|
||||
}).setOrigin(0.5).setDepth(100);
|
||||
}
|
||||
|
||||
private drawMap(): void {
|
||||
|
|
@ -116,28 +148,21 @@ export class MapViewerScene extends ReactiveScene {
|
|||
this.map = generatePointCrawlMap(rng, data.desert.encounters);
|
||||
|
||||
const { width, height } = this.scale;
|
||||
const {
|
||||
LAYER_HEIGHT,
|
||||
NODE_SPACING,
|
||||
NODE_RADIUS,
|
||||
TOTAL_LAYERS,
|
||||
MAX_NODES_PER_LAYER,
|
||||
} = MAP_CONFIG;
|
||||
|
||||
// Update title
|
||||
this.titleText.setText(`Map Viewer (Seed: ${this.seed})`);
|
||||
|
||||
// Calculate map bounds
|
||||
const maxLayer = TOTAL_LAYERS - 1;
|
||||
const mapWidth = maxLayer * LAYER_HEIGHT + 200;
|
||||
const mapHeight = (MAX_NODES_PER_LAYER - 1) * NODE_SPACING + 200;
|
||||
// Calculate map bounds (left-to-right: layers along X, nodes along Y)
|
||||
const maxLayer = 9; // TOTAL_LAYERS - 1 (10 layers: 0-9)
|
||||
const maxNodesInLayer = 5; // widest layer (settlement has 4 nodes)
|
||||
const mapWidth = maxLayer * this.LAYER_HEIGHT + 200;
|
||||
const mapHeight = (maxNodesInLayer - 1) * this.NODE_SPACING + 200;
|
||||
|
||||
// Create scrollable container
|
||||
this.mapContainer = this.add.container(width / 2, height / 2);
|
||||
|
||||
// Background panel for the map area
|
||||
const bg = this.add
|
||||
.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
|
||||
const bg = this.add.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
|
||||
.setOrigin(0.5);
|
||||
this.mapContainer.add(bg);
|
||||
|
||||
|
|
@ -168,77 +193,65 @@ export class MapViewerScene extends ReactiveScene {
|
|||
|
||||
// Node circle
|
||||
graphics.fillStyle(color);
|
||||
graphics.fillCircle(posX, posY, NODE_RADIUS);
|
||||
graphics.fillCircle(posX, posY, this.NODE_RADIUS);
|
||||
graphics.lineStyle(2, 0xffffff);
|
||||
graphics.strokeCircle(posX, posY, NODE_RADIUS);
|
||||
graphics.strokeCircle(posX, posY, this.NODE_RADIUS);
|
||||
|
||||
// Node label
|
||||
const label = NODE_LABELS[node.type as MapNodeType] ?? node.type;
|
||||
this.mapContainer.add(
|
||||
this.add
|
||||
.text(posX, posY, label, {
|
||||
fontSize: "12px",
|
||||
color: "#ffffff",
|
||||
})
|
||||
.setOrigin(0.5),
|
||||
this.add.text(posX, posY, label, {
|
||||
fontSize: '12px',
|
||||
color: '#ffffff',
|
||||
}).setOrigin(0.5)
|
||||
);
|
||||
|
||||
// Encounter name (if available)
|
||||
if (node.encounter) {
|
||||
this.mapContainer.add(
|
||||
this.add
|
||||
.text(posX, posY + NODE_RADIUS + 12, node.encounter.name, {
|
||||
fontSize: "10px",
|
||||
color: "#cccccc",
|
||||
})
|
||||
.setOrigin(0.5),
|
||||
this.add.text(posX, posY + this.NODE_RADIUS + 12, node.encounter.name, {
|
||||
fontSize: '10px',
|
||||
color: '#cccccc',
|
||||
}).setOrigin(0.5)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup drag-to-scroll with disposables cleanup
|
||||
const onPointerDown = (pointer: Phaser.Input.Pointer) => {
|
||||
// Setup drag-to-scroll
|
||||
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
||||
this.isDragging = true;
|
||||
this.dragStartX = pointer.x;
|
||||
this.dragStartY = pointer.y;
|
||||
this.dragStartContainerX = this.mapContainer.x;
|
||||
this.dragStartContainerY = this.mapContainer.y;
|
||||
};
|
||||
});
|
||||
|
||||
const onPointerMove = (pointer: Phaser.Input.Pointer) => {
|
||||
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
|
||||
if (!this.isDragging) return;
|
||||
this.mapContainer.x =
|
||||
this.dragStartContainerX + (pointer.x - this.dragStartX);
|
||||
this.mapContainer.y =
|
||||
this.dragStartContainerY + (pointer.y - this.dragStartY);
|
||||
};
|
||||
this.mapContainer.x = this.dragStartContainerX + (pointer.x - this.dragStartX);
|
||||
this.mapContainer.y = this.dragStartContainerY + (pointer.y - this.dragStartY);
|
||||
});
|
||||
|
||||
const onPointerUp = () => {
|
||||
this.input.on('pointerup', () => {
|
||||
this.isDragging = false;
|
||||
};
|
||||
});
|
||||
|
||||
this.input.on("pointerdown", onPointerDown);
|
||||
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);
|
||||
this.input.on('pointerout', () => {
|
||||
this.isDragging = false;
|
||||
});
|
||||
}
|
||||
|
||||
private getNodeX(node: MapNode): number {
|
||||
return -500 + node.layerIndex * MAP_CONFIG.LAYER_HEIGHT;
|
||||
// Layers go left-to-right along X axis
|
||||
return -500 + node.layerIndex * this.LAYER_HEIGHT;
|
||||
}
|
||||
|
||||
private getNodeY(node: MapNode): number {
|
||||
// Nodes within a layer are spread vertically along Y axis
|
||||
const layer = this.map!.layers[node.layerIndex];
|
||||
const nodeIndex = layer.nodeIds.indexOf(node.id);
|
||||
const totalNodes = layer.nodeIds.length;
|
||||
const layerHeight = (totalNodes - 1) * MAP_CONFIG.NODE_SPACING;
|
||||
return -layerHeight / 2 + nodeIndex * MAP_CONFIG.NODE_SPACING;
|
||||
const layerHeight = (totalNodes - 1) * this.NODE_SPACING;
|
||||
return -layerHeight / 2 + nodeIndex * this.NODE_SPACING;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
import Phaser from "phaser";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { UI_CONFIG, GRID_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
|
||||
import { MutableSignal } from "boardgame-core";
|
||||
import Phaser from 'phaser';
|
||||
import { ReactiveScene } from 'boardgame-phaser';
|
||||
import { MutableSignal } from 'boardgame-core';
|
||||
import {
|
||||
resolveEncounter,
|
||||
removeItem,
|
||||
type RunState,
|
||||
type EncounterResult,
|
||||
type MapNodeType,
|
||||
type MapNode,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { InventoryWidget } from "@/widgets/InventoryWidget";
|
||||
} from 'boardgame-core/samples/slay-the-spire-like';
|
||||
import { InventoryWidget } from '@/widgets/InventoryWidget';
|
||||
|
||||
/**
|
||||
* 占位符遭遇场景
|
||||
|
|
@ -23,7 +20,7 @@ export class PlaceholderEncounterScene extends ReactiveScene {
|
|||
private inventoryWidget: InventoryWidget | null = null;
|
||||
|
||||
constructor(gameState: MutableSignal<RunState>) {
|
||||
super("PlaceholderEncounterScene");
|
||||
super('PlaceholderEncounterScene');
|
||||
this.gameState = gameState;
|
||||
}
|
||||
|
||||
|
|
@ -34,9 +31,9 @@ export class PlaceholderEncounterScene extends ReactiveScene {
|
|||
|
||||
const gridCols = state.inventory.width;
|
||||
const gridRows = state.inventory.height;
|
||||
const cellSize = GRID_CONFIG.WIDGET_CELL_SIZE;
|
||||
const gridW = gridCols * cellSize + (gridCols - 1) * GRID_CONFIG.GRID_GAP;
|
||||
const gridH = gridRows * cellSize + (gridRows - 1) * GRID_CONFIG.GRID_GAP;
|
||||
const cellSize = 80;
|
||||
const gridW = gridCols * cellSize + (gridCols - 1) * 2;
|
||||
const gridH = gridRows * cellSize + (gridRows - 1) * 2;
|
||||
const leftPanelW = gridW + 40;
|
||||
|
||||
this.inventoryWidget = new InventoryWidget({
|
||||
|
|
@ -45,51 +42,34 @@ export class PlaceholderEncounterScene extends ReactiveScene {
|
|||
x: 60,
|
||||
y: (height - gridH) / 2 + 20,
|
||||
cellSize,
|
||||
gridGap: GRID_CONFIG.GRID_GAP,
|
||||
gridGap: 2,
|
||||
});
|
||||
|
||||
this.cameras.main.setBounds(0, 0, width, height);
|
||||
this.cameras.main.setScroll(0, 0);
|
||||
|
||||
// Panel background
|
||||
this.add
|
||||
.rectangle(
|
||||
60 + leftPanelW / 2,
|
||||
this.inventoryWidgetY(gridH),
|
||||
leftPanelW + 10,
|
||||
gridH + 50,
|
||||
0x111122,
|
||||
0.9,
|
||||
)
|
||||
.setStrokeStyle(2, 0x5555aa);
|
||||
this.add.rectangle(
|
||||
60 + leftPanelW / 2, this.inventoryWidgetY(gridH),
|
||||
leftPanelW + 10, gridH + 50,
|
||||
0x111122, 0.9
|
||||
).setStrokeStyle(2, 0x5555aa);
|
||||
|
||||
// "背包" title
|
||||
this.add
|
||||
.text(60 + gridW / 2, (height - gridH) / 2, "背包", {
|
||||
fontSize: "22px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(60 + gridW / 2, (height - gridH) / 2, '背包', {
|
||||
fontSize: '22px', color: '#ffffff', fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
const node = state.map.nodes.get(state.currentNodeId);
|
||||
if (!node || !node.encounter) {
|
||||
const rightX = leftPanelW + 80;
|
||||
this.add
|
||||
.text(rightX + 300, height / 2, "没有遭遇数据", {
|
||||
fontSize: "24px",
|
||||
color: "#ff4444",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(rightX + 300, height / 2, '没有遭遇数据', {
|
||||
fontSize: '24px', color: '#ff4444',
|
||||
}).setOrigin(0.5);
|
||||
return;
|
||||
}
|
||||
|
||||
this.drawRightPanel(
|
||||
node as MapNode & { encounter: { name: string; description: string } },
|
||||
leftPanelW,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
this.drawRightPanel(node, leftPanelW, width, height);
|
||||
}
|
||||
|
||||
private inventoryWidgetY(gridH: number): number {
|
||||
|
|
@ -97,16 +77,11 @@ export class PlaceholderEncounterScene extends ReactiveScene {
|
|||
return (height - gridH) / 2 + 20 + gridH / 2;
|
||||
}
|
||||
|
||||
private drawRightPanel(
|
||||
node: MapNode & { encounter: { name: string; description: string } },
|
||||
leftPanelW: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
private drawRightPanel(node: any, leftPanelW: number, width: number, height: number): void {
|
||||
const encounter = {
|
||||
type: node.type as MapNodeType,
|
||||
name: node.encounter.name,
|
||||
description: node.encounter.description,
|
||||
name: node.encounter.name as string,
|
||||
description: node.encounter.description as string,
|
||||
};
|
||||
const nodeId = node.id as string;
|
||||
|
||||
|
|
@ -115,85 +90,66 @@ export class PlaceholderEncounterScene extends ReactiveScene {
|
|||
const cx = rightX + rightW / 2;
|
||||
const cy = height / 2;
|
||||
|
||||
this.add
|
||||
.text(cx, cy - 180, "遭遇", {
|
||||
fontSize: "36px",
|
||||
color: "#fff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(cx, cy - 180, '遭遇', {
|
||||
fontSize: '36px', color: '#fff', fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
const typeLabel = this.getTypeLabel(encounter.type);
|
||||
const badgeColor = this.getTypeColor(encounter.type);
|
||||
this.add.rectangle(cx, cy - 110, 140, 40, badgeColor);
|
||||
this.add
|
||||
.text(cx, cy - 110, typeLabel, {
|
||||
fontSize: "18px",
|
||||
color: "#fff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(cx, cy - 110, typeLabel, {
|
||||
fontSize: '18px', color: '#fff', fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(cx, cy - 50, encounter.name, {
|
||||
fontSize: "28px",
|
||||
color: "#fff",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(cx, cy - 50, encounter.name, {
|
||||
fontSize: '28px', color: '#fff',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(cx, cy + 10, encounter.description || "(暂无描述)", {
|
||||
fontSize: "18px",
|
||||
color: "#bbb",
|
||||
wordWrap: { width: rightW - 40 },
|
||||
align: "center",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(cx, cy + 10, encounter.description || '(暂无描述)', {
|
||||
fontSize: '18px', color: '#bbb',
|
||||
wordWrap: { width: rightW - 40 }, align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(cx, cy + 80, `节点: ${nodeId}`, {
|
||||
fontSize: "14px",
|
||||
color: "#666",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(cx, cy + 80, `节点: ${nodeId}`, {
|
||||
fontSize: '14px', color: '#666',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(cx, cy + 130, "(此为占位符遭遇,后续将替换为真实遭遇场景)", {
|
||||
fontSize: "14px",
|
||||
color: "#ff8844",
|
||||
fontStyle: "italic",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(cx, cy + 130, '(此为占位符遭遇,后续将替换为真实遭遇场景)', {
|
||||
fontSize: '14px', color: '#ff8844', fontStyle: 'italic',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "完成遭遇",
|
||||
x: cx,
|
||||
y: cy + 200,
|
||||
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
|
||||
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
|
||||
onClick: async () => {
|
||||
this.createButton('完成遭遇', cx, cy + 200, 220, 50, async () => {
|
||||
await this.completeEncounter();
|
||||
},
|
||||
});
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "暂不处理",
|
||||
x: cx,
|
||||
y: cy + 270,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch("GameFlowScene");
|
||||
},
|
||||
this.createButton('暂不处理', cx, cy + 270, 220, 40, async () => {
|
||||
await this.sceneController.launch('GameFlowScene');
|
||||
});
|
||||
}
|
||||
|
||||
private getTypeLabel(type: MapNodeType): string {
|
||||
return NODE_LABELS[type] ?? type;
|
||||
const m: Record<MapNodeType, string> = {
|
||||
start: '起点', end: '终点', minion: '战斗', elite: '精英战斗',
|
||||
event: '事件', camp: '营地', shop: '商店', curio: '奇遇',
|
||||
};
|
||||
return m[type] ?? type;
|
||||
}
|
||||
|
||||
private getTypeColor(type: MapNodeType): number {
|
||||
return NODE_COLORS[type] ?? 0x888888;
|
||||
const m: Record<MapNodeType, number> = {
|
||||
start: 0x44aa44, end: 0xcc8844, minion: 0xcc4444, elite: 0xcc44cc,
|
||||
event: 0xaaaa44, camp: 0x44cccc, shop: 0x4488cc, curio: 0x8844cc,
|
||||
};
|
||||
return m[type] ?? 0x888888;
|
||||
}
|
||||
|
||||
private createButton(label: string, x: number, y: number, w: number, h: number, onClick: () => void): void {
|
||||
const bg = this.add.rectangle(x, y, w, h, 0x444466)
|
||||
.setStrokeStyle(2, 0x7777aa).setInteractive({ useHandCursor: true });
|
||||
const txt = this.add.text(x, y, label, { fontSize: '16px', color: '#fff' }).setOrigin(0.5);
|
||||
|
||||
bg.on('pointerover', () => { bg.setFillStyle(0x555588); txt.setScale(1.05); });
|
||||
bg.on('pointerout', () => { bg.setFillStyle(0x444466); txt.setScale(1); });
|
||||
bg.on('pointerdown', onClick);
|
||||
}
|
||||
|
||||
private async completeEncounter(): Promise<void> {
|
||||
|
|
@ -212,24 +168,18 @@ export class PlaceholderEncounterScene extends ReactiveScene {
|
|||
|
||||
const result: EncounterResult = this.generatePlaceholderResult(node.type);
|
||||
resolveEncounter(state, result);
|
||||
await this.sceneController.launch("GameFlowScene");
|
||||
await this.sceneController.launch('GameFlowScene');
|
||||
}
|
||||
|
||||
private generatePlaceholderResult(type: MapNodeType): EncounterResult {
|
||||
switch (type) {
|
||||
case "minion":
|
||||
return { hpLost: 8, goldEarned: 15 };
|
||||
case "elite":
|
||||
return { hpLost: 15, goldEarned: 30 };
|
||||
case "camp":
|
||||
return { hpGained: 15 };
|
||||
case "shop":
|
||||
return { goldEarned: 0 };
|
||||
case "curio":
|
||||
case "event":
|
||||
return { goldEarned: 20 };
|
||||
default:
|
||||
return {};
|
||||
case 'minion': return { hpLost: 8, goldEarned: 15 };
|
||||
case 'elite': return { hpLost: 15, goldEarned: 30 };
|
||||
case 'camp': return { hpGained: 15 };
|
||||
case 'shop': return { goldEarned: 0 };
|
||||
case 'curio':
|
||||
case 'event': return { goldEarned: 20 };
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
import Phaser from "phaser";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
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";
|
||||
import Phaser from 'phaser';
|
||||
import { ReactiveScene } from 'boardgame-phaser';
|
||||
import { parseShapeString, data, type ParsedShape } from 'boardgame-core/samples/slay-the-spire-like';
|
||||
|
||||
export class ShapeViewerScene extends ReactiveScene {
|
||||
private readonly CELL_SIZE = 40;
|
||||
private readonly ITEMS_PER_ROW = 4;
|
||||
|
||||
constructor() {
|
||||
super("ShapeViewerScene");
|
||||
super('ShapeViewerScene');
|
||||
}
|
||||
|
||||
create(): void {
|
||||
|
|
@ -22,15 +19,13 @@ export class ShapeViewerScene extends ReactiveScene {
|
|||
private drawShapeViewer(): void {
|
||||
this.children.removeAll();
|
||||
|
||||
const { width } = this.scale;
|
||||
const { width, height } = this.scale;
|
||||
|
||||
this.add
|
||||
.text(width / 2, 30, "Shape Viewer - Item Shapes", {
|
||||
fontSize: "24px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(width / 2, 30, 'Shape Viewer - Item Shapes', {
|
||||
fontSize: '24px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.drawAllShapes();
|
||||
}
|
||||
|
|
@ -38,127 +33,117 @@ export class ShapeViewerScene extends ReactiveScene {
|
|||
private drawAllShapes(): void {
|
||||
const { width } = this.scale;
|
||||
const startY = 80;
|
||||
const { SPACING_X, SPACING_Y, ITEMS_PER_ROW, MAX_ITEMS } = SHAPE_CONFIG;
|
||||
const spacingX = 220;
|
||||
const spacingY = 140;
|
||||
|
||||
const itemsToShow = data.desert.items.slice(0, MAX_ITEMS);
|
||||
const itemsToShow = data.desert.items.slice(0, 12);
|
||||
|
||||
for (let i = 0; i < itemsToShow.length; i++) {
|
||||
const itemData = itemsToShow[i];
|
||||
const shape = parseShapeString(itemData.shape);
|
||||
|
||||
const col = i % ITEMS_PER_ROW;
|
||||
const row = Math.floor(i / ITEMS_PER_ROW);
|
||||
const col = i % this.ITEMS_PER_ROW;
|
||||
const row = Math.floor(i / this.ITEMS_PER_ROW);
|
||||
|
||||
const x = 60 + col * SPACING_X;
|
||||
const y = startY + row * SPACING_Y;
|
||||
const x = 60 + col * spacingX;
|
||||
const y = startY + row * spacingY;
|
||||
|
||||
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 { CELL_SIZE } = SHAPE_CONFIG;
|
||||
|
||||
const shapeWidth = shape.width * CELL_SIZE;
|
||||
const shapeHeight = shape.height * CELL_SIZE;
|
||||
const shapeWidth = shape.width * this.CELL_SIZE;
|
||||
const shapeHeight = shape.height * this.CELL_SIZE;
|
||||
|
||||
this.add
|
||||
.text(startX + shapeWidth / 2, startY - 20, itemData.name, {
|
||||
fontSize: "14px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(startX + shapeWidth / 2, startY - 20, itemData.name, {
|
||||
fontSize: '14px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
for (let y = 0; y < shape.height; y++) {
|
||||
for (let x = 0; x < shape.width; x++) {
|
||||
if (shape.grid[y]?.[x]) {
|
||||
const px = startX + x * CELL_SIZE;
|
||||
const py = startY + y * CELL_SIZE;
|
||||
const px = startX + x * this.CELL_SIZE;
|
||||
const py = startY + y * this.CELL_SIZE;
|
||||
|
||||
const isOrigin = x === shape.originX && y === shape.originY;
|
||||
const color = isOrigin ? 0x88cc44 : 0x4488cc;
|
||||
|
||||
graphics.fillStyle(color);
|
||||
graphics.fillRect(px + 1, py + 1, CELL_SIZE - 2, CELL_SIZE - 2);
|
||||
graphics.fillRect(px + 1, py + 1, this.CELL_SIZE - 2, this.CELL_SIZE - 2);
|
||||
graphics.lineStyle(2, 0xffffff);
|
||||
graphics.strokeRect(px, py, CELL_SIZE, CELL_SIZE);
|
||||
graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE);
|
||||
|
||||
if (isOrigin) {
|
||||
this.add
|
||||
.text(px + CELL_SIZE / 2, py + CELL_SIZE / 2, "O", {
|
||||
fontSize: "16px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
this.add.text(px + this.CELL_SIZE / 2, py + this.CELL_SIZE / 2, 'O', {
|
||||
fontSize: '16px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.add
|
||||
.text(
|
||||
startX + shapeWidth / 2,
|
||||
startY + shapeHeight + 10,
|
||||
`形状: ${itemData.shape}`,
|
||||
{
|
||||
fontSize: "11px",
|
||||
color: "#aaaaaa",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 10, `形状: ${itemData.shape}`, {
|
||||
fontSize: '11px',
|
||||
color: '#aaaaaa',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(
|
||||
startX + shapeWidth / 2,
|
||||
startY + shapeHeight + 28,
|
||||
`类型: ${itemData.type} | 费用: ${itemData.costCount} ${itemData.costType}`,
|
||||
{
|
||||
fontSize: "11px",
|
||||
color: "#cccccc",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 28,
|
||||
`类型: ${itemData.type} | 费用: ${itemData.costCount} ${itemData.costType}`, {
|
||||
fontSize: '11px',
|
||||
color: '#cccccc',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(startX + shapeWidth / 2, startY + shapeHeight + 46, itemData.desc, {
|
||||
fontSize: "10px",
|
||||
color: "#888888",
|
||||
this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 46, itemData.desc, {
|
||||
fontSize: '10px',
|
||||
color: '#888888',
|
||||
wordWrap: { width: shapeWidth },
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
}).setOrigin(0.5);
|
||||
}
|
||||
|
||||
private createControls(): void {
|
||||
const { width, height } = this.scale;
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "返回菜单",
|
||||
x: 100,
|
||||
y: height - 40,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch("IndexScene");
|
||||
},
|
||||
this.createButton('返回菜单', 100, height - 40, async () => {
|
||||
await this.sceneController.launch('IndexScene');
|
||||
});
|
||||
|
||||
this.add
|
||||
.text(
|
||||
width / 2,
|
||||
height - 40,
|
||||
`Showing first ${SHAPE_CONFIG.MAX_ITEMS} items | Green = Origin | Blue = Normal`,
|
||||
{
|
||||
fontSize: "14px",
|
||||
color: "#aaaaaa",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
this.add.text(width / 2, height - 40,
|
||||
`Showing first 12 items | Green = Origin | Blue = Normal`, {
|
||||
fontSize: '14px',
|
||||
color: '#aaaaaa',
|
||||
}).setOrigin(0.5);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { h } from "preact";
|
||||
import { PhaserGame, PhaserScene } from "boardgame-phaser";
|
||||
import { useMemo } from "preact/hooks";
|
||||
import { IndexScene } from "@/scenes/IndexScene";
|
||||
import { MapViewerScene } from "@/scenes/MapViewerScene";
|
||||
import { GridViewerScene } from "@/scenes/GridViewerScene";
|
||||
import { ShapeViewerScene } from "@/scenes/ShapeViewerScene";
|
||||
import { GameFlowScene } from "@/scenes/GameFlowScene";
|
||||
import { PlaceholderEncounterScene } from "@/scenes/PlaceholderEncounterScene";
|
||||
import { createGameState } from "@/state/gameState";
|
||||
import { h } from 'preact';
|
||||
import { PhaserGame, PhaserScene } from 'boardgame-phaser';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { IndexScene } from '@/scenes/IndexScene';
|
||||
import { MapViewerScene } from '@/scenes/MapViewerScene';
|
||||
import { GridViewerScene } from '@/scenes/GridViewerScene';
|
||||
import { ShapeViewerScene } from '@/scenes/ShapeViewerScene';
|
||||
import { GameFlowScene } from '@/scenes/GameFlowScene';
|
||||
import { PlaceholderEncounterScene } from '@/scenes/PlaceholderEncounterScene';
|
||||
import { createGameState } from '@/state/gameState';
|
||||
|
||||
// 全局游戏状态单例
|
||||
const gameState = createGameState();
|
||||
|
|
@ -18,36 +18,18 @@ export default function App() {
|
|||
const gridViewerScene = useMemo(() => new GridViewerScene(), []);
|
||||
const shapeViewerScene = useMemo(() => new ShapeViewerScene(), []);
|
||||
const gameFlowScene = useMemo(() => new GameFlowScene(gameState), []);
|
||||
const placeholderEncounterScene = useMemo(
|
||||
() => new PlaceholderEncounterScene(gameState),
|
||||
[],
|
||||
);
|
||||
const placeholderEncounterScene = useMemo(() => new PlaceholderEncounterScene(gameState), []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 flex relative justify-center items-center">
|
||||
<PhaserGame
|
||||
initialScene="IndexScene"
|
||||
config={{ width: 1920, height: 1080 }}
|
||||
>
|
||||
<PhaserScene sceneKey="IndexScene" scene={indexScene as any} />
|
||||
<PhaserScene
|
||||
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 initialScene="IndexScene" config={{ width: 1920, height: 1080 }}>
|
||||
<PhaserScene sceneKey="IndexScene" scene={indexScene} />
|
||||
<PhaserScene sceneKey="MapViewerScene" scene={mapViewerScene} />
|
||||
<PhaserScene sceneKey="GridViewerScene" scene={gridViewerScene} />
|
||||
<PhaserScene sceneKey="ShapeViewerScene" scene={shapeViewerScene} />
|
||||
<PhaserScene sceneKey="GameFlowScene" scene={gameFlowScene} />
|
||||
<PhaserScene sceneKey="PlaceholderEncounterScene" scene={placeholderEncounterScene} />
|
||||
</PhaserGame>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
import Phaser from 'phaser';
|
||||
import { UI_CONFIG } from '@/config';
|
||||
|
||||
export interface ButtonOptions {
|
||||
/** The Phaser scene to create the button in */
|
||||
scene: Phaser.Scene;
|
||||
/** Button label text */
|
||||
label: string;
|
||||
/** X position (center of button) */
|
||||
x: number;
|
||||
/** Y position (center of button) */
|
||||
y: number;
|
||||
/** Button width (defaults to UI_CONFIG.BUTTON_WIDTH) */
|
||||
width?: number;
|
||||
/** Button height (defaults to UI_CONFIG.BUTTON_HEIGHT) */
|
||||
height?: number;
|
||||
/** Click handler */
|
||||
onClick: () => void;
|
||||
/** Phaser depth (defaults to 100) */
|
||||
depth?: number;
|
||||
/** Background color (defaults to UI_CONFIG.BUTTON_BG) */
|
||||
bgColor?: number;
|
||||
/** Hover background color (defaults to UI_CONFIG.BUTTON_BG_HOVER) */
|
||||
hoverBgColor?: number;
|
||||
/** Border color (defaults to UI_CONFIG.BUTTON_BORDER) */
|
||||
borderColor?: number;
|
||||
/** Text color (defaults to UI_CONFIG.BUTTON_TEXT_COLOR) */
|
||||
textColor?: string;
|
||||
/** Font size (defaults to UI_CONFIG.BUTTON_FONT_SIZE) */
|
||||
fontSize?: string;
|
||||
}
|
||||
|
||||
export interface ButtonObjects {
|
||||
/** Background rectangle */
|
||||
bg: Phaser.GameObjects.Rectangle;
|
||||
/** Text label */
|
||||
text: Phaser.GameObjects.Text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a styled interactive button in a Phaser scene.
|
||||
*
|
||||
* Handles hover effects (fill color change + text scale) and click callback.
|
||||
* Returns both the bg and text objects for further customization if needed.
|
||||
*/
|
||||
export function createButton(options: ButtonOptions): ButtonObjects {
|
||||
const {
|
||||
scene,
|
||||
label,
|
||||
x,
|
||||
y,
|
||||
width = UI_CONFIG.BUTTON_WIDTH,
|
||||
height = UI_CONFIG.BUTTON_HEIGHT,
|
||||
onClick,
|
||||
depth = 100,
|
||||
bgColor = UI_CONFIG.BUTTON_BG,
|
||||
hoverBgColor = UI_CONFIG.BUTTON_BG_HOVER,
|
||||
borderColor = UI_CONFIG.BUTTON_BORDER,
|
||||
textColor = UI_CONFIG.BUTTON_TEXT_COLOR,
|
||||
fontSize = UI_CONFIG.BUTTON_FONT_SIZE,
|
||||
} = options;
|
||||
|
||||
const bg = scene.add.rectangle(x, y, width, height, bgColor)
|
||||
.setStrokeStyle(2, borderColor)
|
||||
.setInteractive({ useHandCursor: true })
|
||||
.setDepth(depth);
|
||||
|
||||
const text = scene.add.text(x, y, label, {
|
||||
fontSize,
|
||||
color: textColor,
|
||||
}).setOrigin(0.5).setDepth(depth);
|
||||
|
||||
bg.on('pointerover', () => {
|
||||
bg.setFillStyle(hoverBgColor);
|
||||
text.setScale(1.05);
|
||||
});
|
||||
|
||||
bg.on('pointerout', () => {
|
||||
bg.setFillStyle(bgColor);
|
||||
text.setScale(1);
|
||||
});
|
||||
|
||||
bg.on('pointerdown', onClick);
|
||||
|
||||
return { bg, text };
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import Phaser from 'phaser';
|
||||
import type { CombatEntity, EnemyEntity, EffectTable } from 'boardgame-core/samples/slay-the-spire-like';
|
||||
|
||||
export interface CombatUnitWidgetOptions {
|
||||
scene: Phaser.Scene;
|
||||
x: number;
|
||||
y: number;
|
||||
entity: CombatEntity;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const HP_BAR_WIDTH = 180;
|
||||
const HP_BAR_HEIGHT = 16;
|
||||
const BUFF_ICON_SIZE = 28;
|
||||
const BUFF_ICON_GAP = 6;
|
||||
|
||||
const POSITIVE_EFFECTS = new Set(['block', 'strength', 'dexterity', 'regen']);
|
||||
const NEGATIVE_EFFECTS = new Set(['weak', 'vulnerable', 'frail', 'poison']);
|
||||
|
||||
export class CombatUnitWidget {
|
||||
private scene: Phaser.Scene;
|
||||
private container: Phaser.GameObjects.Container;
|
||||
private entity: CombatEntity;
|
||||
private width: number;
|
||||
private height: number;
|
||||
|
||||
private nameText!: Phaser.GameObjects.Text;
|
||||
private hpBarBg!: Phaser.GameObjects.Graphics;
|
||||
private hpBarFill!: Phaser.GameObjects.Graphics;
|
||||
private hpText!: Phaser.GameObjects.Text;
|
||||
private buffContainer!: Phaser.GameObjects.Container;
|
||||
private buffIcons: Phaser.GameObjects.Container[] = [];
|
||||
|
||||
constructor(options: CombatUnitWidgetOptions) {
|
||||
this.scene = options.scene;
|
||||
this.entity = options.entity;
|
||||
this.width = options.width ?? 240;
|
||||
this.height = options.height ?? 120;
|
||||
|
||||
this.container = this.scene.add.container(options.x, options.y);
|
||||
this.container.setSize(this.width, this.height);
|
||||
|
||||
this.drawBackground();
|
||||
this.drawName();
|
||||
this.drawHpBar();
|
||||
this.drawBuffs();
|
||||
}
|
||||
|
||||
private drawBackground(): void {
|
||||
const bg = this.scene.add.rectangle(0, 0, this.width, this.height, 0x1a1a2e, 0.9)
|
||||
.setStrokeStyle(2, 0x444477)
|
||||
.setOrigin(0, 0);
|
||||
this.container.add(bg);
|
||||
}
|
||||
|
||||
private drawName(): void {
|
||||
const entityName = 'enemy' in this.entity
|
||||
? (this.entity as EnemyEntity).enemy.name
|
||||
: 'Player';
|
||||
|
||||
this.nameText = this.scene.add.text(this.width / 2, 12, entityName, {
|
||||
fontSize: '16px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5, 0);
|
||||
this.container.add(this.nameText);
|
||||
}
|
||||
|
||||
private drawHpBar(): void {
|
||||
const barX = (this.width - HP_BAR_WIDTH) / 2;
|
||||
const barY = 36;
|
||||
|
||||
this.hpBarBg = this.scene.add.graphics();
|
||||
this.hpBarBg.fillStyle(0x333333);
|
||||
this.hpBarBg.fillRoundedRect(barX, barY, HP_BAR_WIDTH, HP_BAR_HEIGHT, 4);
|
||||
this.hpBarBg.lineStyle(1, 0x666666);
|
||||
this.hpBarBg.strokeRoundedRect(barX, barY, HP_BAR_WIDTH, HP_BAR_HEIGHT, 4);
|
||||
this.container.add(this.hpBarBg);
|
||||
|
||||
this.hpBarFill = this.scene.add.graphics();
|
||||
this.container.add(this.hpBarFill);
|
||||
|
||||
this.hpText = this.scene.add.text(this.width / 2, barY + HP_BAR_HEIGHT / 2, '', {
|
||||
fontSize: '12px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
this.container.add(this.hpText);
|
||||
|
||||
this.updateHpBar();
|
||||
}
|
||||
|
||||
private updateHpBar(): void {
|
||||
const barX = (this.width - HP_BAR_WIDTH) / 2;
|
||||
const barY = 36;
|
||||
const ratio = Math.max(0, this.entity.hp / this.entity.maxHp);
|
||||
|
||||
this.hpBarFill.clear();
|
||||
|
||||
let fillColor: number;
|
||||
if (ratio > 0.6) fillColor = 0x44aa44;
|
||||
else if (ratio > 0.3) fillColor = 0xccaa44;
|
||||
else fillColor = 0xcc4444;
|
||||
|
||||
const fillWidth = Math.max(0, (HP_BAR_WIDTH - 2) * ratio);
|
||||
if (fillWidth > 0) {
|
||||
this.hpBarFill.fillStyle(fillColor);
|
||||
this.hpBarFill.fillRoundedRect(barX + 1, barY + 1, fillWidth, HP_BAR_HEIGHT - 2, 3);
|
||||
}
|
||||
|
||||
this.hpText.setText(`${this.entity.hp}/${this.entity.maxHp}`);
|
||||
}
|
||||
|
||||
private drawBuffs(): void {
|
||||
this.buffContainer = this.scene.add.container(10, 62);
|
||||
this.container.add(this.buffContainer);
|
||||
this.refreshBuffs();
|
||||
}
|
||||
|
||||
private refreshBuffs(): void {
|
||||
for (const icon of this.buffIcons) {
|
||||
icon.destroy();
|
||||
}
|
||||
this.buffIcons = [];
|
||||
|
||||
const effects = this.entity.effects;
|
||||
let x = 0;
|
||||
const y = 0;
|
||||
|
||||
for (const [effectId, entry] of Object.entries(effects)) {
|
||||
if (entry.stacks <= 0) continue;
|
||||
|
||||
const icon = this.createBuffIcon(effectId, entry);
|
||||
icon.setPosition(x, y);
|
||||
this.buffContainer.add(icon);
|
||||
this.buffIcons.push(icon);
|
||||
|
||||
x += BUFF_ICON_SIZE + BUFF_ICON_GAP;
|
||||
|
||||
if (x + BUFF_ICON_SIZE > this.width - 20) {
|
||||
x = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createBuffIcon(effectId: string, entry: { data: { name: string; description: string }; stacks: number }): Phaser.GameObjects.Container {
|
||||
const icon = this.scene.add.container(0, 0);
|
||||
|
||||
const isPositive = POSITIVE_EFFECTS.has(effectId.toLowerCase());
|
||||
const isNegative = NEGATIVE_EFFECTS.has(effectId.toLowerCase());
|
||||
const bgColor = isPositive ? 0x226644 : isNegative ? 0x662222 : 0x444466;
|
||||
const borderColor = isPositive ? 0x44aa88 : isNegative ? 0xaa4444 : 0x7777aa;
|
||||
|
||||
const bg = this.scene.add.rectangle(0, 0, BUFF_ICON_SIZE, BUFF_ICON_SIZE, bgColor, 1)
|
||||
.setStrokeStyle(2, borderColor)
|
||||
.setOrigin(0, 0);
|
||||
icon.add(bg);
|
||||
|
||||
const label = this.getEffectLabel(effectId);
|
||||
const text = this.scene.add.text(BUFF_ICON_SIZE / 2, 2, label, {
|
||||
fontSize: '9px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5, 0);
|
||||
icon.add(text);
|
||||
|
||||
const stackText = this.scene.add.text(BUFF_ICON_SIZE / 2, BUFF_ICON_SIZE - 2, `${entry.stacks}`, {
|
||||
fontSize: '10px',
|
||||
color: '#ffcc44',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5, 1);
|
||||
icon.add(stackText);
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
private getEffectLabel(effectId: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
block: '🛡',
|
||||
strength: '💪',
|
||||
dexterity: '🤸',
|
||||
regen: '💚',
|
||||
weak: '⚡',
|
||||
vulnerable: '🔥',
|
||||
frail: '🩹',
|
||||
poison: '☠',
|
||||
};
|
||||
return labels[effectId.toLowerCase()] ?? effectId.substring(0, 3).toUpperCase();
|
||||
}
|
||||
|
||||
public update(entity: CombatEntity): void {
|
||||
this.entity = entity;
|
||||
this.updateHpBar();
|
||||
this.refreshBuffs();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.container.destroy();
|
||||
}
|
||||
|
||||
public getContainer(): Phaser.GameObjects.Container {
|
||||
return this.container;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,477 +0,0 @@
|
|||
import Phaser from "phaser";
|
||||
import {
|
||||
type InventoryItem,
|
||||
type GameItemMeta,
|
||||
type GridInventory,
|
||||
validatePlacement,
|
||||
transformShape,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
|
||||
import { DisposableBag } from "boardgame-phaser";
|
||||
|
||||
export interface DragSession {
|
||||
itemId: string;
|
||||
itemShape: InventoryItem<GameItemMeta>["shape"];
|
||||
itemTransform: InventoryItem<GameItemMeta>["transform"];
|
||||
itemMeta: InventoryItem<GameItemMeta>["meta"];
|
||||
ghostContainer: Phaser.GameObjects.Container;
|
||||
previewGraphics: Phaser.GameObjects.Graphics;
|
||||
disposables: DisposableBag;
|
||||
}
|
||||
|
||||
export interface DragControllerOptions {
|
||||
scene: Phaser.Scene;
|
||||
container: Phaser.GameObjects.Container;
|
||||
cellSize: number;
|
||||
gridGap: number;
|
||||
gridX: number;
|
||||
gridY: number;
|
||||
getInventory: () => GridInventory<GameItemMeta>;
|
||||
getItemColor: (itemId: string) => number;
|
||||
onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
|
||||
onCreateLostItem: (
|
||||
itemId: string,
|
||||
shape: InventoryItem<GameItemMeta>["shape"],
|
||||
transform: InventoryItem<GameItemMeta>["transform"],
|
||||
meta: InventoryItem<GameItemMeta>["meta"],
|
||||
x: number,
|
||||
y: number,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-driven drag controller using dragDropEventEffect from boardgame-phaser.
|
||||
* Manages ghost visuals, placement preview, rotation, and validation.
|
||||
*/
|
||||
export class DragController {
|
||||
private scene: Phaser.Scene;
|
||||
private container: Phaser.GameObjects.Container;
|
||||
private cellSize: number;
|
||||
private gridGap: number;
|
||||
private gridX: number;
|
||||
private gridY: number;
|
||||
private getInventory: () => GridInventory<GameItemMeta>;
|
||||
private getItemColor: (itemId: string) => number;
|
||||
private onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
|
||||
private onCreateLostItem: (
|
||||
itemId: string,
|
||||
shape: InventoryItem<GameItemMeta>["shape"],
|
||||
transform: InventoryItem<GameItemMeta>["transform"],
|
||||
meta: InventoryItem<GameItemMeta>["meta"],
|
||||
x: number,
|
||||
y: number,
|
||||
) => void;
|
||||
|
||||
private activeSession: DragSession | null = null;
|
||||
|
||||
constructor(options: DragControllerOptions) {
|
||||
this.scene = options.scene;
|
||||
this.container = options.container;
|
||||
this.cellSize = options.cellSize;
|
||||
this.gridGap = options.gridGap;
|
||||
this.gridX = options.gridX;
|
||||
this.gridY = options.gridY;
|
||||
this.getInventory = options.getInventory;
|
||||
this.getItemColor = options.getItemColor;
|
||||
this.onPlaceItem = options.onPlaceItem;
|
||||
this.onCreateLostItem = options.onCreateLostItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a drag session for an inventory item.
|
||||
* Uses dragDropEventEffect for pointer tracking and event emission.
|
||||
*/
|
||||
startDrag(
|
||||
itemId: string,
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
itemContainer: Phaser.GameObjects.Container,
|
||||
): () => void {
|
||||
const cells = this.getItemCells(item);
|
||||
const firstCell = cells[0];
|
||||
const worldX =
|
||||
this.container.x +
|
||||
this.gridX +
|
||||
firstCell.x * (this.cellSize + this.gridGap);
|
||||
const worldY =
|
||||
this.container.y +
|
||||
this.gridY +
|
||||
firstCell.y * (this.cellSize + this.gridGap);
|
||||
|
||||
const ghostContainer = this.createGhostContainer(
|
||||
worldX,
|
||||
worldY,
|
||||
item.shape,
|
||||
item.transform,
|
||||
this.getItemColor(itemId),
|
||||
);
|
||||
const previewGraphics = this.scene.add
|
||||
.graphics()
|
||||
.setDepth(999)
|
||||
.setAlpha(0.5);
|
||||
|
||||
const disposables = new DisposableBag();
|
||||
const session: DragSession = {
|
||||
itemId,
|
||||
itemShape: item.shape,
|
||||
itemTransform: {
|
||||
...item.transform,
|
||||
offset: { ...item.transform.offset },
|
||||
},
|
||||
itemMeta: item.meta,
|
||||
ghostContainer,
|
||||
previewGraphics,
|
||||
disposables,
|
||||
};
|
||||
|
||||
this.activeSession = session;
|
||||
|
||||
// Set up drag-drop event handling via framework utility
|
||||
const disposeDrag = dragDropEventEffect(
|
||||
itemContainer as Phaser.GameObjects.GameObject,
|
||||
disposables,
|
||||
);
|
||||
|
||||
itemContainer.on("dragstart", () => {
|
||||
ghostContainer.setVisible(true);
|
||||
});
|
||||
|
||||
itemContainer.on("dragmove", () => {
|
||||
this.handleDragMove(session);
|
||||
});
|
||||
|
||||
itemContainer.on("dragend", () => {
|
||||
this.handleDragEnd(session);
|
||||
disposeDrag();
|
||||
this.activeSession = null;
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposeDrag();
|
||||
this.destroySession(session);
|
||||
this.activeSession = null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a drag session for a lost item.
|
||||
*/
|
||||
startLostItemDrag(
|
||||
itemId: string,
|
||||
shape: InventoryItem<GameItemMeta>["shape"],
|
||||
transform: InventoryItem<GameItemMeta>["transform"],
|
||||
meta: InventoryItem<GameItemMeta>["meta"],
|
||||
lostContainer: Phaser.GameObjects.Container,
|
||||
): () => void {
|
||||
const pointer = this.scene.input.activePointer;
|
||||
const ghostContainer = this.createGhostContainer(
|
||||
pointer.x,
|
||||
pointer.y,
|
||||
shape,
|
||||
transform,
|
||||
this.getItemColor(itemId),
|
||||
);
|
||||
const previewGraphics = this.scene.add
|
||||
.graphics()
|
||||
.setDepth(999)
|
||||
.setAlpha(0.5);
|
||||
|
||||
const disposables = new DisposableBag();
|
||||
const session: DragSession = {
|
||||
itemId,
|
||||
itemShape: shape,
|
||||
itemTransform: { ...transform, offset: { ...transform.offset } },
|
||||
itemMeta: meta,
|
||||
ghostContainer,
|
||||
previewGraphics,
|
||||
disposables,
|
||||
};
|
||||
|
||||
this.activeSession = session;
|
||||
|
||||
const disposeDrag = dragDropEventEffect(
|
||||
lostContainer as Phaser.GameObjects.GameObject,
|
||||
disposables,
|
||||
);
|
||||
|
||||
lostContainer.on("dragstart", () => {
|
||||
ghostContainer.setVisible(true);
|
||||
});
|
||||
|
||||
lostContainer.on("dragmove", () => {
|
||||
this.handleDragMove(session);
|
||||
});
|
||||
|
||||
lostContainer.on("dragend", () => {
|
||||
this.handleDragEnd(session);
|
||||
disposeDrag();
|
||||
this.activeSession = null;
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposeDrag();
|
||||
this.destroySession(session);
|
||||
this.activeSession = null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the currently dragged item by 90 degrees.
|
||||
*/
|
||||
rotateDraggedItem(): void {
|
||||
if (!this.activeSession) return;
|
||||
|
||||
const currentRotation =
|
||||
(this.activeSession.itemTransform.rotation + 90) % 360;
|
||||
this.activeSession.itemTransform = {
|
||||
...this.activeSession.itemTransform,
|
||||
rotation: currentRotation,
|
||||
};
|
||||
|
||||
this.updateGhostVisuals(this.activeSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently dragging.
|
||||
*/
|
||||
isDragging(): boolean {
|
||||
return this.activeSession !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the item being dragged, or null.
|
||||
*/
|
||||
getDraggedItemId(): string | null {
|
||||
return this.activeSession?.itemId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current position of the dragged ghost container.
|
||||
*/
|
||||
getDraggedItemPosition(): { x: number; y: number } {
|
||||
if (!this.activeSession) return { x: 0, y: 0 };
|
||||
return {
|
||||
x: this.activeSession.ghostContainer.x,
|
||||
y: this.activeSession.ghostContainer.y,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up active session and destroy all visuals.
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.activeSession) {
|
||||
this.destroySession(this.activeSession);
|
||||
this.activeSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
private createGhostContainer(
|
||||
x: number,
|
||||
y: number,
|
||||
shape: InventoryItem<GameItemMeta>["shape"],
|
||||
transform: InventoryItem<GameItemMeta>["transform"],
|
||||
color: number,
|
||||
): Phaser.GameObjects.Container {
|
||||
const ghostContainer = this.scene.add.container(x, y).setDepth(1000);
|
||||
const ghostGraphics = this.scene.add.graphics();
|
||||
|
||||
const cells = transformShape(shape, transform);
|
||||
for (const cell of cells) {
|
||||
ghostGraphics.fillStyle(color, 0.7);
|
||||
ghostGraphics.fillRect(
|
||||
cell.x * (this.cellSize + this.gridGap),
|
||||
cell.y * (this.cellSize + this.gridGap),
|
||||
this.cellSize - 2,
|
||||
this.cellSize - 2,
|
||||
);
|
||||
ghostGraphics.lineStyle(2, 0xffffff);
|
||||
ghostGraphics.strokeRect(
|
||||
cell.x * (this.cellSize + this.gridGap),
|
||||
cell.y * (this.cellSize + this.gridGap),
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
}
|
||||
ghostContainer.add(ghostGraphics);
|
||||
|
||||
return ghostContainer;
|
||||
}
|
||||
|
||||
private updateGhostVisuals(session: DragSession): void {
|
||||
session.ghostContainer.removeAll(true);
|
||||
const ghostGraphics = this.scene.add.graphics();
|
||||
const color = this.getItemColor(session.itemId);
|
||||
|
||||
const cells = transformShape(session.itemShape, session.itemTransform);
|
||||
for (const cell of cells) {
|
||||
ghostGraphics.fillStyle(color, 0.7);
|
||||
ghostGraphics.fillRect(
|
||||
cell.x * (this.cellSize + this.gridGap),
|
||||
cell.y * (this.cellSize + this.gridGap),
|
||||
this.cellSize - 2,
|
||||
this.cellSize - 2,
|
||||
);
|
||||
ghostGraphics.lineStyle(2, 0xffffff);
|
||||
ghostGraphics.strokeRect(
|
||||
cell.x * (this.cellSize + this.gridGap),
|
||||
cell.y * (this.cellSize + this.gridGap),
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
}
|
||||
session.ghostContainer.add(ghostGraphics);
|
||||
}
|
||||
|
||||
private handleDragMove(session: DragSession): void {
|
||||
const pointer = this.scene.input.activePointer;
|
||||
session.ghostContainer.setPosition(pointer.x, pointer.y);
|
||||
|
||||
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
|
||||
session.previewGraphics.clear();
|
||||
|
||||
if (gridCell) {
|
||||
const inventory = this.getInventory();
|
||||
const testTransform = {
|
||||
...session.itemTransform,
|
||||
offset: { x: gridCell.x, y: gridCell.y },
|
||||
};
|
||||
const validation = validatePlacement(
|
||||
inventory,
|
||||
session.itemShape,
|
||||
testTransform,
|
||||
);
|
||||
|
||||
const cells = transformShape(session.itemShape, testTransform);
|
||||
for (const cell of cells) {
|
||||
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
|
||||
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
|
||||
|
||||
if (validation.valid) {
|
||||
session.previewGraphics.fillStyle(0x33ff33, 0.3);
|
||||
session.previewGraphics.fillRect(
|
||||
px,
|
||||
py,
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
session.previewGraphics.lineStyle(2, 0x33ff33);
|
||||
session.previewGraphics.strokeRect(
|
||||
px,
|
||||
py,
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
} else {
|
||||
session.previewGraphics.fillStyle(0xff3333, 0.3);
|
||||
session.previewGraphics.fillRect(
|
||||
px,
|
||||
py,
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
session.previewGraphics.lineStyle(2, 0xff3333);
|
||||
session.previewGraphics.strokeRect(
|
||||
px,
|
||||
py,
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleDragEnd(session: DragSession): void {
|
||||
const pointer = this.scene.input.activePointer;
|
||||
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
|
||||
const inventory = this.getInventory();
|
||||
|
||||
session.ghostContainer.destroy();
|
||||
session.previewGraphics.destroy();
|
||||
session.disposables.dispose();
|
||||
|
||||
if (gridCell) {
|
||||
const testTransform = {
|
||||
...session.itemTransform,
|
||||
offset: { x: gridCell.x, y: gridCell.y },
|
||||
};
|
||||
const validation = validatePlacement(
|
||||
inventory,
|
||||
session.itemShape,
|
||||
testTransform,
|
||||
);
|
||||
|
||||
if (validation.valid) {
|
||||
const item: InventoryItem<GameItemMeta> = {
|
||||
id: session.itemId,
|
||||
shape: session.itemShape,
|
||||
transform: testTransform,
|
||||
meta: session.itemMeta,
|
||||
};
|
||||
this.onPlaceItem(item);
|
||||
} else {
|
||||
this.onCreateLostItem(
|
||||
session.itemId,
|
||||
session.itemShape,
|
||||
session.itemTransform,
|
||||
session.itemMeta,
|
||||
session.ghostContainer.x,
|
||||
session.ghostContainer.y,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.onCreateLostItem(
|
||||
session.itemId,
|
||||
session.itemShape,
|
||||
session.itemTransform,
|
||||
session.itemMeta,
|
||||
session.ghostContainer.x,
|
||||
session.ghostContainer.y,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getWorldGridCell(
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
): { x: number; y: number } | null {
|
||||
const localX = worldX - this.container.x - this.gridX;
|
||||
const localY = worldY - this.container.y - this.gridY;
|
||||
|
||||
const cellX = Math.floor(localX / (this.cellSize + this.gridGap));
|
||||
const cellY = Math.floor(localY / (this.cellSize + this.gridGap));
|
||||
|
||||
const inventory = this.getInventory();
|
||||
if (
|
||||
cellX < 0 ||
|
||||
cellY < 0 ||
|
||||
cellX >= inventory.width ||
|
||||
cellY >= inventory.height
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x: cellX, y: cellY };
|
||||
}
|
||||
|
||||
private getItemCells(
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
): { x: number; y: number }[] {
|
||||
const cells: { x: number; y: number }[] = [];
|
||||
const { offset } = item.transform;
|
||||
for (let y = 0; y < item.shape.height; y++) {
|
||||
for (let x = 0; x < item.shape.width; x++) {
|
||||
if (item.shape.grid[y]?.[x]) {
|
||||
cells.push({ x: x + offset.x, y: y + offset.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
private destroySession(session: DragSession): void {
|
||||
session.disposables.dispose();
|
||||
session.ghostContainer.destroy();
|
||||
session.previewGraphics.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import Phaser from "phaser";
|
||||
|
||||
export interface GridBackgroundRendererOptions {
|
||||
scene: Phaser.Scene;
|
||||
parentContainer: Phaser.GameObjects.Container;
|
||||
cellSize: number;
|
||||
gridGap: number;
|
||||
gridX: number;
|
||||
gridY: number;
|
||||
/** Background fill color for each cell */
|
||||
cellBgColor?: number;
|
||||
/** Border/stroke color for each cell */
|
||||
cellBorderColor?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the static grid background (empty cells with borders).
|
||||
* Separated from item rendering so it can be drawn once and left alone.
|
||||
*/
|
||||
export class GridBackgroundRenderer {
|
||||
private scene: Phaser.Scene;
|
||||
private parentContainer: Phaser.GameObjects.Container;
|
||||
private cellSize: number;
|
||||
private gridGap: number;
|
||||
private gridX: number;
|
||||
private gridY: number;
|
||||
private cellBgColor: number;
|
||||
private cellBorderColor: number;
|
||||
|
||||
private graphics!: Phaser.GameObjects.Graphics;
|
||||
|
||||
constructor(options: GridBackgroundRendererOptions) {
|
||||
this.scene = options.scene;
|
||||
this.parentContainer = options.parentContainer;
|
||||
this.cellSize = options.cellSize;
|
||||
this.gridGap = options.gridGap;
|
||||
this.gridX = options.gridX;
|
||||
this.gridY = options.gridY;
|
||||
this.cellBgColor = options.cellBgColor ?? 0x1a1a2e;
|
||||
this.cellBorderColor = options.cellBorderColor ?? 0x444477;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the grid background for the given dimensions.
|
||||
* Should be called once during initialization.
|
||||
*/
|
||||
draw(width: number, height: number): void {
|
||||
this.graphics = this.scene.add.graphics();
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const px = this.gridX + x * (this.cellSize + this.gridGap);
|
||||
const py = this.gridY + y * (this.cellSize + this.gridGap);
|
||||
|
||||
this.graphics.fillStyle(this.cellBgColor);
|
||||
this.graphics.fillRect(px, py, this.cellSize, this.cellSize);
|
||||
this.graphics.lineStyle(2, this.cellBorderColor);
|
||||
this.graphics.strokeRect(px, py, this.cellSize, this.cellSize);
|
||||
}
|
||||
}
|
||||
|
||||
this.parentContainer.add(this.graphics);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the graphics object.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.graphics?.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,277 +0,0 @@
|
|||
import Phaser from "phaser";
|
||||
import { MutableSignal } from "boardgame-core";
|
||||
import {
|
||||
type InventoryItem,
|
||||
type GameItemMeta,
|
||||
type RunState,
|
||||
type GridInventory,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import type { Spawner } from "boardgame-phaser";
|
||||
|
||||
const ITEM_COLORS = [
|
||||
0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33,
|
||||
0xff6633,
|
||||
];
|
||||
|
||||
export interface InventoryItemSpawnerOptions {
|
||||
scene: Phaser.Scene;
|
||||
gameState: MutableSignal<RunState>;
|
||||
parentContainer: Phaser.GameObjects.Container;
|
||||
cellSize: number;
|
||||
gridGap: number;
|
||||
gridX: number;
|
||||
gridY: number;
|
||||
isLocked: () => boolean;
|
||||
isDragging: () => boolean;
|
||||
onItemDragStart: (
|
||||
itemId: string,
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
itemContainer: Phaser.GameObjects.Container,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawner for inventory items using the boardgame-phaser Spawner pattern.
|
||||
* Reactively spawns/despawns/updates item visuals when gameState.inventory changes.
|
||||
*
|
||||
* Items currently being dragged are excluded from getData() to prevent
|
||||
* the spawner from respawning them while they're in flight.
|
||||
*/
|
||||
export class InventoryItemSpawner implements Spawner<
|
||||
InventoryItem<GameItemMeta>,
|
||||
Phaser.GameObjects.Container
|
||||
> {
|
||||
private scene: Phaser.Scene;
|
||||
private gameState: MutableSignal<RunState>;
|
||||
private parentContainer: Phaser.GameObjects.Container;
|
||||
private cellSize: number;
|
||||
private gridGap: number;
|
||||
private gridX: number;
|
||||
private gridY: number;
|
||||
private isLocked: () => boolean;
|
||||
private isDragging: () => boolean;
|
||||
private onItemDragStart: (
|
||||
itemId: string,
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
itemContainer: Phaser.GameObjects.Container,
|
||||
) => void;
|
||||
|
||||
private colorMap = new Map<string, number>();
|
||||
private colorIdx = 0;
|
||||
private draggingIds = new Set<string>();
|
||||
|
||||
constructor(options: InventoryItemSpawnerOptions) {
|
||||
this.scene = options.scene;
|
||||
this.gameState = options.gameState;
|
||||
this.parentContainer = options.parentContainer;
|
||||
this.cellSize = options.cellSize;
|
||||
this.gridGap = options.gridGap;
|
||||
this.gridX = options.gridX;
|
||||
this.gridY = options.gridY;
|
||||
this.isLocked = options.isLocked;
|
||||
this.isDragging = options.isDragging;
|
||||
this.onItemDragStart = options.onItemDragStart;
|
||||
}
|
||||
|
||||
*getData(): Iterable<InventoryItem<GameItemMeta>> {
|
||||
const inventory = this.getInventory();
|
||||
for (const [, item] of inventory.items) {
|
||||
if (!this.draggingIds.has(item.id)) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getKey(item: InventoryItem<GameItemMeta>): string {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
onSpawn(item: InventoryItem<GameItemMeta>): Phaser.GameObjects.Container {
|
||||
const color =
|
||||
this.colorMap.get(item.id) ??
|
||||
ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length];
|
||||
this.colorMap.set(item.id, color);
|
||||
|
||||
const container = this.createItemVisuals(item, color);
|
||||
this.setupInteraction(item, container);
|
||||
this.parentContainer.add(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
onDespawn(
|
||||
obj: Phaser.GameObjects.Container,
|
||||
_item: InventoryItem<GameItemMeta>,
|
||||
): void {
|
||||
obj.removeAllListeners();
|
||||
obj.destroy();
|
||||
}
|
||||
|
||||
onUpdate(
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
obj: Phaser.GameObjects.Container,
|
||||
): void {
|
||||
const color = this.colorMap.get(item.id) ?? 0x888888;
|
||||
this.rebuildItemVisuals(obj, item, color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an item as being dragged so the spawner excludes it from getData().
|
||||
* Call this before removing the item from the inventory.
|
||||
*/
|
||||
markDragging(itemId: string): void {
|
||||
this.draggingIds.add(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmark an item after drag ends (placed or lost).
|
||||
*/
|
||||
unmarkDragging(itemId: string): void {
|
||||
this.draggingIds.delete(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color assigned to an item (creates one if not yet assigned).
|
||||
*/
|
||||
getItemColor(itemId: string): number {
|
||||
if (!this.colorMap.has(itemId)) {
|
||||
this.colorMap.set(
|
||||
itemId,
|
||||
ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length],
|
||||
);
|
||||
}
|
||||
return this.colorMap.get(itemId)!;
|
||||
}
|
||||
|
||||
private getInventory(): GridInventory<GameItemMeta> {
|
||||
return this.gameState.value
|
||||
.inventory as unknown as GridInventory<GameItemMeta>;
|
||||
}
|
||||
|
||||
private createItemVisuals(
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
color: number,
|
||||
): Phaser.GameObjects.Container {
|
||||
const container = this.scene.add.container(0, 0);
|
||||
|
||||
const graphics = this.scene.add.graphics();
|
||||
const cells = this.getItemCells(item);
|
||||
|
||||
for (const cell of cells) {
|
||||
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
|
||||
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
|
||||
|
||||
graphics.fillStyle(color);
|
||||
graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2);
|
||||
graphics.lineStyle(2, 0xffffff);
|
||||
graphics.strokeRect(px, py, this.cellSize, this.cellSize);
|
||||
}
|
||||
|
||||
const firstCell = cells[0];
|
||||
const name = item.meta?.itemData.name ?? item.id;
|
||||
const fontSize = Math.max(10, Math.floor(this.cellSize / 5));
|
||||
const text = this.scene.add
|
||||
.text(
|
||||
this.gridX +
|
||||
firstCell.x * (this.cellSize + this.gridGap) +
|
||||
this.cellSize / 2,
|
||||
this.gridY +
|
||||
firstCell.y * (this.cellSize + this.gridGap) +
|
||||
this.cellSize / 2,
|
||||
name,
|
||||
{ fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" },
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
|
||||
container.add(graphics);
|
||||
container.add(text);
|
||||
|
||||
const hitRect = new Phaser.Geom.Rectangle(
|
||||
this.gridX + firstCell.x * (this.cellSize + this.gridGap),
|
||||
this.gridY + firstCell.y * (this.cellSize + this.gridGap),
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
private rebuildItemVisuals(
|
||||
container: Phaser.GameObjects.Container,
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
color: number,
|
||||
): void {
|
||||
container.removeAll(true);
|
||||
|
||||
const graphics = this.scene.add.graphics();
|
||||
const cells = this.getItemCells(item);
|
||||
|
||||
for (const cell of cells) {
|
||||
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
|
||||
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
|
||||
|
||||
graphics.fillStyle(color);
|
||||
graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2);
|
||||
graphics.lineStyle(2, 0xffffff);
|
||||
graphics.strokeRect(px, py, this.cellSize, this.cellSize);
|
||||
}
|
||||
|
||||
const firstCell = cells[0];
|
||||
const name = item.meta?.itemData.name ?? item.id;
|
||||
const fontSize = Math.max(10, Math.floor(this.cellSize / 5));
|
||||
const text = this.scene.add
|
||||
.text(
|
||||
this.gridX +
|
||||
firstCell.x * (this.cellSize + this.gridGap) +
|
||||
this.cellSize / 2,
|
||||
this.gridY +
|
||||
firstCell.y * (this.cellSize + this.gridGap) +
|
||||
this.cellSize / 2,
|
||||
name,
|
||||
{ fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" },
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
|
||||
container.add(graphics);
|
||||
container.add(text);
|
||||
|
||||
const hitRect = new Phaser.Geom.Rectangle(
|
||||
this.gridX + firstCell.x * (this.cellSize + this.gridGap),
|
||||
this.gridY + firstCell.y * (this.cellSize + this.gridGap),
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
|
||||
}
|
||||
|
||||
private setupInteraction(
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
container: Phaser.GameObjects.Container,
|
||||
): void {
|
||||
container.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
|
||||
// Guard against stale events firing on destroyed containers
|
||||
if (!container.scene || !container.active) return;
|
||||
if (this.isLocked()) return;
|
||||
if (this.isDragging()) return;
|
||||
if (pointer.button === 0) {
|
||||
this.onItemDragStart(item.id, item, container);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getItemCells(
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
): { x: number; y: number }[] {
|
||||
const cells: { x: number; y: number }[] = [];
|
||||
const { offset } = item.transform;
|
||||
for (let y = 0; y < item.shape.height; y++) {
|
||||
for (let x = 0; x < item.shape.width; x++) {
|
||||
if (item.shape.grid[y]?.[x]) {
|
||||
cells.push({ x: x + offset.x, y: y + offset.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,20 @@
|
|||
import Phaser from "phaser";
|
||||
import { MutableSignal } from "boardgame-core";
|
||||
import { spawnEffect } from "boardgame-phaser";
|
||||
import Phaser from 'phaser';
|
||||
import { MutableSignal } from 'boardgame-core';
|
||||
import {
|
||||
type GridInventory,
|
||||
type InventoryItem,
|
||||
type GameItemMeta,
|
||||
type RunState,
|
||||
type CellKey,
|
||||
validatePlacement,
|
||||
removeItemFromGrid,
|
||||
placeItem,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { InventoryItemSpawner } from "./InventoryItemSpawner";
|
||||
import { GridBackgroundRenderer } from "./GridBackgroundRenderer";
|
||||
import { DragController } from "./DragController";
|
||||
import { LostItemManager } from "./LostItemManager";
|
||||
moveItem,
|
||||
rotateItem,
|
||||
transformShape,
|
||||
} from 'boardgame-core/samples/slay-the-spire-like';
|
||||
|
||||
const ITEM_COLORS = [0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33, 0xff6633];
|
||||
|
||||
export interface InventoryWidgetOptions {
|
||||
scene: Phaser.Scene;
|
||||
|
|
@ -24,15 +26,25 @@ export interface InventoryWidgetOptions {
|
|||
isLocked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory widget using the Spawner pattern for reactive item rendering.
|
||||
*
|
||||
* Architecture:
|
||||
* - InventoryItemSpawner + spawnEffect: reactive spawn/despawn/update of item visuals
|
||||
* - GridBackgroundRenderer: static grid background drawn once
|
||||
* - DragController: event-driven drag logic via dragDropEventEffect
|
||||
* - LostItemManager: tracks items dropped outside valid placement
|
||||
*/
|
||||
interface DragState {
|
||||
itemId: string;
|
||||
itemShape: InventoryItem<GameItemMeta>['shape'];
|
||||
itemTransform: InventoryItem<GameItemMeta>['transform'];
|
||||
itemMeta: InventoryItem<GameItemMeta>['meta'];
|
||||
ghostContainer: Phaser.GameObjects.Container;
|
||||
previewGraphics: Phaser.GameObjects.Graphics;
|
||||
dragOffsetX: number;
|
||||
dragOffsetY: number;
|
||||
}
|
||||
|
||||
interface LostItem {
|
||||
id: string;
|
||||
container: Phaser.GameObjects.Container;
|
||||
shape: InventoryItem<GameItemMeta>['shape'];
|
||||
transform: InventoryItem<GameItemMeta>['transform'];
|
||||
meta: InventoryItem<GameItemMeta>['meta'];
|
||||
}
|
||||
|
||||
export class InventoryWidget {
|
||||
private scene: Phaser.Scene;
|
||||
private gameState: MutableSignal<RunState>;
|
||||
|
|
@ -43,13 +55,18 @@ export class InventoryWidget {
|
|||
private gridY = 0;
|
||||
private isLocked: boolean;
|
||||
|
||||
private itemSpawner: InventoryItemSpawner;
|
||||
private backgroundRenderer: GridBackgroundRenderer;
|
||||
private dragController: DragController;
|
||||
private lostItemManager: LostItemManager;
|
||||
private itemContainers = new Map<string, Phaser.GameObjects.Container>();
|
||||
private itemGraphics = new Map<string, Phaser.GameObjects.Graphics>();
|
||||
private itemTexts = new Map<string, Phaser.GameObjects.Text>();
|
||||
private colorMap = new Map<string, number>();
|
||||
private colorIdx = 0;
|
||||
|
||||
private spawnDispose: (() => void) | null = null;
|
||||
private rightClickHandler!: (pointer: Phaser.Input.Pointer) => void;
|
||||
private gridGraphics!: Phaser.GameObjects.Graphics;
|
||||
private dragState: DragState | null = null;
|
||||
private lostItems = new Map<string, LostItem>();
|
||||
|
||||
private pointerMoveHandler: (pointer: Phaser.Input.Pointer) => void;
|
||||
private pointerUpHandler: (pointer: Phaser.Input.Pointer) => void;
|
||||
|
||||
constructor(options: InventoryWidgetOptions) {
|
||||
this.scene = options.scene;
|
||||
|
|
@ -58,146 +75,386 @@ export class InventoryWidget {
|
|||
this.gridGap = options.gridGap ?? 2;
|
||||
this.isLocked = options.isLocked ?? false;
|
||||
|
||||
const inventory = this.getInventory();
|
||||
const inventory = this.gameState.value.inventory;
|
||||
const gridW = inventory.width * this.cellSize + (inventory.width - 1) * this.gridGap;
|
||||
const gridH = inventory.height * this.cellSize + (inventory.height - 1) * this.gridGap;
|
||||
|
||||
this.container = this.scene.add.container(options.x, options.y);
|
||||
|
||||
// 1. Static grid background (drawn once)
|
||||
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.pointerMoveHandler = this.onPointerMove.bind(this);
|
||||
this.pointerUpHandler = this.onPointerUp.bind(this);
|
||||
|
||||
// 2. Reactive item spawner
|
||||
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.drawGridBackground(inventory.width, inventory.height, gridW, gridH);
|
||||
this.drawItems();
|
||||
this.setupInput();
|
||||
|
||||
this.scene.events.once("shutdown", () => this.destroy());
|
||||
this.scene.events.once('shutdown', () => this.destroy());
|
||||
}
|
||||
|
||||
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 handleItemDragStart(
|
||||
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);
|
||||
private drawGridBackground(width: number, height: number, gridW: number, gridH: number): void {
|
||||
this.gridGraphics = this.scene.add.graphics();
|
||||
|
||||
// Start drag session
|
||||
this.dragController.startDrag(itemId, item, itemContainer);
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
|
||||
private handlePlaceItem(item: InventoryItem<GameItemMeta>): void {
|
||||
this.gameState.produce((state) => {
|
||||
placeItem(state.inventory, item);
|
||||
this.container.add(this.gridGraphics);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Unmark dragging so spawner picks it up on next effect run
|
||||
this.itemSpawner.unmarkDragging(item.id);
|
||||
this.itemContainers.set(itemId, container);
|
||||
this.container.add(container);
|
||||
}
|
||||
|
||||
private handleCreateLostItem(
|
||||
itemId: string,
|
||||
shape: InventoryItem<GameItemMeta>["shape"],
|
||||
transform: InventoryItem<GameItemMeta>["transform"],
|
||||
meta: InventoryItem<GameItemMeta>["meta"],
|
||||
x: number,
|
||||
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 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 });
|
||||
}
|
||||
|
||||
private getLostItemShape(itemId: string) {
|
||||
return this.lostItemManager.getLostItem(itemId)?.shape!;
|
||||
}
|
||||
|
||||
private getLostItemTransform(itemId: string) {
|
||||
return this.lostItemManager.getLostItem(itemId)?.transform!;
|
||||
}
|
||||
|
||||
private getLostItemMeta(itemId: string) {
|
||||
return this.lostItemManager.getLostItem(itemId)?.meta!;
|
||||
return cells;
|
||||
}
|
||||
|
||||
private setupInput(): void {
|
||||
this.rightClickHandler = (pointer: Phaser.Input.Pointer) => {
|
||||
if (!this.dragController.isDragging()) return;
|
||||
if (pointer.button === 1) {
|
||||
this.dragController.rotateDraggedItem();
|
||||
this.scene.input.on('pointermove', this.pointerMoveHandler);
|
||||
this.scene.input.on('pointerup', this.pointerUpHandler);
|
||||
this.scene.input.on('pointerdown', this.onPointerDown.bind(this));
|
||||
}
|
||||
|
||||
private onPointerDown(pointer: Phaser.Input.Pointer): void {
|
||||
if (!this.dragState) return;
|
||||
if (pointer.button === 1) {
|
||||
this.rotateDraggedItem();
|
||||
}
|
||||
}
|
||||
|
||||
private startDrag(itemId: string, pointer: Phaser.Input.Pointer): void {
|
||||
const inventory = this.getInventory();
|
||||
const item = inventory.items.get(itemId);
|
||||
if (!item) return;
|
||||
|
||||
this.gameState.produce(state => {
|
||||
removeItemFromGrid(state.inventory, itemId);
|
||||
});
|
||||
this.removeItemVisuals(itemId);
|
||||
|
||||
const cells = this.getItemCells(item);
|
||||
const firstCell = cells[0];
|
||||
const itemWorldX = this.container.x + this.gridX + firstCell.x * (this.cellSize + this.gridGap);
|
||||
const itemWorldY = this.container.y + this.gridY + firstCell.y * (this.cellSize + this.gridGap);
|
||||
const dragOffsetX = pointer.x - itemWorldX;
|
||||
const dragOffsetY = pointer.y - itemWorldY;
|
||||
|
||||
const ghostContainer = this.scene.add.container(itemWorldX, itemWorldY).setDepth(1000);
|
||||
const ghostGraphics = this.scene.add.graphics();
|
||||
const color = this.colorMap.get(itemId) ?? 0x888888;
|
||||
|
||||
for (let y = 0; y < item.shape.height; y++) {
|
||||
for (let x = 0; x < item.shape.width; x++) {
|
||||
if (item.shape.grid[y]?.[x]) {
|
||||
ghostGraphics.fillStyle(color, 0.7);
|
||||
ghostGraphics.fillRect(x * (this.cellSize + this.gridGap), y * (this.cellSize + this.gridGap), this.cellSize - 2, this.cellSize - 2);
|
||||
ghostGraphics.lineStyle(2, 0xffffff);
|
||||
ghostGraphics.strokeRect(x * (this.cellSize + this.gridGap), y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
ghostContainer.add(ghostGraphics);
|
||||
|
||||
const previewGraphics = this.scene.add.graphics().setDepth(999).setAlpha(0.5);
|
||||
|
||||
this.dragState = {
|
||||
itemId,
|
||||
itemShape: item.shape,
|
||||
itemTransform: { ...item.transform, offset: { ...item.transform.offset } },
|
||||
itemMeta: item.meta,
|
||||
ghostContainer,
|
||||
previewGraphics,
|
||||
dragOffsetX,
|
||||
dragOffsetY,
|
||||
};
|
||||
}
|
||||
|
||||
private startLostItemDrag(itemId: string, pointer: Phaser.Input.Pointer): void {
|
||||
const lost = this.lostItems.get(itemId);
|
||||
if (!lost) return;
|
||||
|
||||
lost.container.destroy();
|
||||
this.lostItems.delete(itemId);
|
||||
|
||||
const ghostContainer = this.scene.add.container(pointer.x, pointer.y).setDepth(1000);
|
||||
const ghostGraphics = this.scene.add.graphics();
|
||||
const color = this.colorMap.get(itemId) ?? 0x888888;
|
||||
|
||||
const cells = transformShape(lost.shape, lost.transform);
|
||||
for (const cell of cells) {
|
||||
ghostGraphics.fillStyle(color, 0.7);
|
||||
ghostGraphics.fillRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize - 2, this.cellSize - 2);
|
||||
ghostGraphics.lineStyle(2, 0xffffff);
|
||||
ghostGraphics.strokeRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize);
|
||||
}
|
||||
ghostContainer.add(ghostGraphics);
|
||||
|
||||
const previewGraphics = this.scene.add.graphics().setDepth(999).setAlpha(0.5);
|
||||
|
||||
this.dragState = {
|
||||
itemId,
|
||||
itemShape: lost.shape,
|
||||
itemTransform: { ...lost.transform, offset: { ...lost.transform.offset } },
|
||||
itemMeta: lost.meta,
|
||||
ghostContainer,
|
||||
previewGraphics,
|
||||
dragOffsetX: 0,
|
||||
dragOffsetY: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private rotateDraggedItem(): void {
|
||||
if (!this.dragState) return;
|
||||
|
||||
const currentRotation = (this.dragState.itemTransform.rotation + 90) % 360;
|
||||
this.dragState.itemTransform = {
|
||||
...this.dragState.itemTransform,
|
||||
rotation: currentRotation,
|
||||
};
|
||||
|
||||
this.scene.input.on("pointerdown", this.rightClickHandler);
|
||||
this.updateGhostVisuals();
|
||||
}
|
||||
|
||||
private updateGhostVisuals(): void {
|
||||
if (!this.dragState) return;
|
||||
|
||||
this.dragState.ghostContainer.removeAll(true);
|
||||
const ghostGraphics = this.scene.add.graphics();
|
||||
const color = this.colorMap.get(this.dragState.itemId) ?? 0x888888;
|
||||
|
||||
const cells = transformShape(this.dragState.itemShape, this.dragState.itemTransform);
|
||||
for (const cell of cells) {
|
||||
ghostGraphics.fillStyle(color, 0.7);
|
||||
ghostGraphics.fillRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize - 2, this.cellSize - 2);
|
||||
ghostGraphics.lineStyle(2, 0xffffff);
|
||||
ghostGraphics.strokeRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize);
|
||||
}
|
||||
this.dragState.ghostContainer.add(ghostGraphics);
|
||||
}
|
||||
|
||||
private onPointerMove(pointer: Phaser.Input.Pointer): void {
|
||||
if (!this.dragState) return;
|
||||
|
||||
this.dragState.ghostContainer.setPosition(pointer.x - this.dragState.dragOffsetX, pointer.y - this.dragState.dragOffsetY);
|
||||
|
||||
const gridCell = this.getWorldGridCell(pointer.x - this.dragState.dragOffsetX, pointer.y - this.dragState.dragOffsetY);
|
||||
this.dragState.previewGraphics.clear();
|
||||
|
||||
if (gridCell) {
|
||||
const inventory = this.getInventory();
|
||||
const testTransform = { ...this.dragState.itemTransform, offset: { x: gridCell.x, y: gridCell.y } };
|
||||
const validation = validatePlacement(inventory, this.dragState.itemShape, testTransform);
|
||||
|
||||
const cells = transformShape(this.dragState.itemShape, testTransform);
|
||||
for (const cell of cells) {
|
||||
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
|
||||
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
|
||||
|
||||
if (validation.valid) {
|
||||
this.dragState.previewGraphics.fillStyle(0x33ff33, 0.3);
|
||||
this.dragState.previewGraphics.fillRect(px, py, this.cellSize, this.cellSize);
|
||||
this.dragState.previewGraphics.lineStyle(2, 0x33ff33);
|
||||
this.dragState.previewGraphics.strokeRect(px, py, this.cellSize, this.cellSize);
|
||||
} else {
|
||||
this.dragState.previewGraphics.fillStyle(0xff3333, 0.3);
|
||||
this.dragState.previewGraphics.fillRect(px, py, this.cellSize, this.cellSize);
|
||||
this.dragState.previewGraphics.lineStyle(2, 0xff3333);
|
||||
this.dragState.previewGraphics.strokeRect(px, py, this.cellSize, this.cellSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerUp(pointer: Phaser.Input.Pointer): void {
|
||||
if (!this.dragState) return;
|
||||
|
||||
const gridCell = this.getWorldGridCell(pointer.x - this.dragState.dragOffsetX, pointer.y - this.dragState.dragOffsetY);
|
||||
const inventory = this.getInventory();
|
||||
|
||||
this.dragState.ghostContainer.destroy();
|
||||
this.dragState.previewGraphics.destroy();
|
||||
|
||||
if (gridCell) {
|
||||
const testTransform = { ...this.dragState.itemTransform, offset: { x: gridCell.x, y: gridCell.y } };
|
||||
const validation = validatePlacement(inventory, this.dragState.itemShape, testTransform);
|
||||
|
||||
if (validation.valid) {
|
||||
this.gameState.produce(state => {
|
||||
const item: InventoryItem<GameItemMeta> = {
|
||||
id: this.dragState!.itemId,
|
||||
shape: this.dragState!.itemShape,
|
||||
transform: testTransform,
|
||||
meta: this.dragState!.itemMeta,
|
||||
};
|
||||
placeItem(state.inventory, item);
|
||||
});
|
||||
this.createItemVisualsFromDrag();
|
||||
} else {
|
||||
this.createLostItem();
|
||||
}
|
||||
} else {
|
||||
this.createLostItem();
|
||||
}
|
||||
|
||||
this.dragState = null;
|
||||
}
|
||||
|
||||
private createItemVisualsFromDrag(): void {
|
||||
if (!this.dragState) return;
|
||||
const inventory = this.getInventory();
|
||||
const item = inventory.items.get(this.dragState.itemId);
|
||||
if (item) {
|
||||
this.createItemVisuals(this.dragState.itemId, item);
|
||||
}
|
||||
}
|
||||
|
||||
private getWorldGridCell(worldX: number, worldY: number): { x: number; y: number } | null {
|
||||
const localX = worldX - this.container.x - this.gridX;
|
||||
const localY = worldY - this.container.y - this.gridY;
|
||||
|
||||
const cellX = Math.floor(localX / (this.cellSize + this.gridGap));
|
||||
const cellY = Math.floor(localY / (this.cellSize + this.gridGap));
|
||||
|
||||
if (cellX < 0 || cellY < 0 || cellX >= this.getInventory().width || cellY >= this.getInventory().height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x: cellX, y: cellY };
|
||||
}
|
||||
|
||||
private createLostItem(): void {
|
||||
if (!this.dragState) return;
|
||||
|
||||
const container = this.scene.add.container(
|
||||
this.dragState.ghostContainer.x,
|
||||
this.dragState.ghostContainer.y
|
||||
).setDepth(500);
|
||||
|
||||
const graphics = this.scene.add.graphics();
|
||||
const color = this.colorMap.get(this.dragState.itemId) ?? 0x888888;
|
||||
|
||||
const cells = transformShape(this.dragState.itemShape, this.dragState.itemTransform);
|
||||
for (const cell of cells) {
|
||||
graphics.fillStyle(color, 0.5);
|
||||
graphics.fillRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize - 2, this.cellSize - 2);
|
||||
graphics.lineStyle(2, 0xff4444);
|
||||
graphics.strokeRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize);
|
||||
}
|
||||
container.add(graphics);
|
||||
|
||||
const name = this.dragState.itemMeta?.itemData.name ?? this.dragState.itemId;
|
||||
const text = this.scene.add.text(0, -20, `${name} (lost)`, {
|
||||
fontSize: '12px',
|
||||
color: '#ff4444',
|
||||
fontStyle: 'italic',
|
||||
}).setOrigin(0.5);
|
||||
container.add(text);
|
||||
|
||||
const hitRect = new Phaser.Geom.Rectangle(0, 0, this.cellSize, this.cellSize);
|
||||
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
|
||||
|
||||
container.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
||||
if (this.isLocked) return;
|
||||
if (this.dragState) return;
|
||||
if (pointer.button === 0) {
|
||||
this.startLostItemDrag(this.dragState!.itemId, pointer);
|
||||
}
|
||||
});
|
||||
|
||||
this.lostItems.set(this.dragState.itemId, {
|
||||
id: this.dragState.itemId,
|
||||
container,
|
||||
shape: this.dragState.itemShape,
|
||||
transform: { ...this.dragState.itemTransform },
|
||||
meta: this.dragState.itemMeta,
|
||||
});
|
||||
}
|
||||
|
||||
private removeItemVisuals(itemId: string): void {
|
||||
this.itemContainers.get(itemId)?.destroy();
|
||||
this.itemGraphics.get(itemId)?.destroy();
|
||||
this.itemTexts.get(itemId)?.destroy();
|
||||
this.itemContainers.delete(itemId);
|
||||
this.itemGraphics.delete(itemId);
|
||||
this.itemTexts.delete(itemId);
|
||||
}
|
||||
|
||||
public setLocked(locked: boolean): void {
|
||||
|
|
@ -205,35 +462,52 @@ export class InventoryWidget {
|
|||
}
|
||||
|
||||
public getLostItems(): string[] {
|
||||
return this.lostItemManager.getLostItemIds();
|
||||
return Array.from(this.lostItems.keys());
|
||||
}
|
||||
|
||||
public clearLostItems(): void {
|
||||
this.lostItemManager.clear();
|
||||
for (const lost of this.lostItems.values()) {
|
||||
lost.container.destroy();
|
||||
}
|
||||
this.lostItems.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-sync of item visuals with current inventory state.
|
||||
* With spawnEffect this is usually automatic, but useful after
|
||||
* external state changes that don't trigger the effect.
|
||||
*/
|
||||
public refresh(): void {
|
||||
// The spawner effect automatically re-syncs when gameState.value changes.
|
||||
// If immediate refresh is needed, reading the signal triggers the effect.
|
||||
void this.gameState.value;
|
||||
const inventory = this.getInventory();
|
||||
|
||||
for (const itemId of this.itemContainers.keys()) {
|
||||
if (!inventory.items.has(itemId)) {
|
||||
this.removeItemVisuals(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [itemId, item] of inventory.items) {
|
||||
if (!this.itemContainers.has(itemId)) {
|
||||
this.createItemVisuals(itemId, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.scene.input.off("pointerdown", this.rightClickHandler);
|
||||
this.scene.input.off('pointermove', this.pointerMoveHandler);
|
||||
this.scene.input.off('pointerup', this.pointerUpHandler);
|
||||
|
||||
if (this.spawnDispose) {
|
||||
this.spawnDispose();
|
||||
this.spawnDispose = null;
|
||||
if (this.dragState) {
|
||||
this.dragState.ghostContainer.destroy();
|
||||
this.dragState.previewGraphics.destroy();
|
||||
this.dragState = null;
|
||||
}
|
||||
|
||||
this.dragController.destroy();
|
||||
this.lostItemManager.destroy();
|
||||
this.backgroundRenderer.destroy();
|
||||
this.clearLostItems();
|
||||
|
||||
for (const container of this.itemContainers.values()) {
|
||||
container.destroy();
|
||||
}
|
||||
this.itemContainers.clear();
|
||||
this.itemGraphics.clear();
|
||||
this.itemTexts.clear();
|
||||
|
||||
this.gridGraphics.destroy();
|
||||
this.container.destroy();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,157 +0,0 @@
|
|||
import Phaser from "phaser";
|
||||
import {
|
||||
type InventoryItem,
|
||||
type GameItemMeta,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
|
||||
export interface LostItem {
|
||||
id: string;
|
||||
container: Phaser.GameObjects.Container;
|
||||
shape: InventoryItem<GameItemMeta>["shape"];
|
||||
transform: InventoryItem<GameItemMeta>["transform"];
|
||||
meta: InventoryItem<GameItemMeta>["meta"];
|
||||
}
|
||||
|
||||
export interface LostItemManagerOptions {
|
||||
scene: Phaser.Scene;
|
||||
cellSize: number;
|
||||
gridGap: number;
|
||||
getItemColor: (itemId: string) => number;
|
||||
onLostItemDragStart: (
|
||||
itemId: string,
|
||||
container: Phaser.GameObjects.Container,
|
||||
) => void;
|
||||
isDragging: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages "lost" items — items that were dropped outside valid grid placement.
|
||||
* Renders them as semi-transparent red-bordered containers that can be re-dragged.
|
||||
*/
|
||||
export class LostItemManager {
|
||||
private scene: Phaser.Scene;
|
||||
private cellSize: number;
|
||||
private gridGap: number;
|
||||
private getItemColor: (itemId: string) => number;
|
||||
private onLostItemDragStart: (
|
||||
itemId: string,
|
||||
container: Phaser.GameObjects.Container,
|
||||
) => void;
|
||||
private isDragging: () => boolean;
|
||||
|
||||
private lostItems = new Map<string, LostItem>();
|
||||
|
||||
constructor(options: LostItemManagerOptions) {
|
||||
this.scene = options.scene;
|
||||
this.cellSize = options.cellSize;
|
||||
this.gridGap = options.gridGap;
|
||||
this.getItemColor = options.getItemColor;
|
||||
this.onLostItemDragStart = options.onLostItemDragStart;
|
||||
this.isDragging = options.isDragging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a visual representation of a lost item at the given position.
|
||||
*/
|
||||
createLostItem(
|
||||
itemId: string,
|
||||
shape: InventoryItem<GameItemMeta>["shape"],
|
||||
transform: InventoryItem<GameItemMeta>["transform"],
|
||||
meta: InventoryItem<GameItemMeta>["meta"],
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
const container = this.scene.add.container(x, y).setDepth(500);
|
||||
|
||||
const graphics = this.scene.add.graphics();
|
||||
const color = this.getItemColor(itemId);
|
||||
|
||||
for (let gy = 0; gy < shape.height; gy++) {
|
||||
for (let gx = 0; gx < shape.width; gx++) {
|
||||
if (shape.grid[gy]?.[gx]) {
|
||||
graphics.fillStyle(color, 0.5);
|
||||
graphics.fillRect(
|
||||
gx * (this.cellSize + this.gridGap),
|
||||
gy * (this.cellSize + this.gridGap),
|
||||
this.cellSize - 2,
|
||||
this.cellSize - 2,
|
||||
);
|
||||
graphics.lineStyle(2, 0xff4444);
|
||||
graphics.strokeRect(
|
||||
gx * (this.cellSize + this.gridGap),
|
||||
gy * (this.cellSize + this.gridGap),
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
container.add(graphics);
|
||||
|
||||
const name = meta?.itemData.name ?? itemId;
|
||||
const text = this.scene.add
|
||||
.text(0, -20, `${name} (lost)`, {
|
||||
fontSize: "12px",
|
||||
color: "#ff4444",
|
||||
fontStyle: "italic",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
container.add(text);
|
||||
|
||||
const hitRect = new Phaser.Geom.Rectangle(
|
||||
0,
|
||||
0,
|
||||
this.cellSize,
|
||||
this.cellSize,
|
||||
);
|
||||
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
|
||||
|
||||
container.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
|
||||
// Guard against stale events firing on destroyed containers
|
||||
if (!container.scene || !container.active) return;
|
||||
if (this.isDragging()) return;
|
||||
if (pointer.button === 0) {
|
||||
this.onLostItemDragStart(itemId, container);
|
||||
}
|
||||
});
|
||||
|
||||
this.lostItems.set(itemId, {
|
||||
id: itemId,
|
||||
container,
|
||||
shape,
|
||||
transform: { ...transform },
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all lost item IDs.
|
||||
*/
|
||||
getLostItemIds(): string[] {
|
||||
return Array.from(this.lostItems.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a lost item by ID, or undefined if not found.
|
||||
*/
|
||||
getLostItem(itemId: string): LostItem | undefined {
|
||||
return this.lostItems.get(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy and clear all lost items.
|
||||
*/
|
||||
clear(): void {
|
||||
for (const lost of this.lostItems.values()) {
|
||||
lost.container.destroy();
|
||||
}
|
||||
this.lostItems.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all managed visuals.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,15 +3,14 @@
|
|||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"boardgame-phaser": ["../framework/src/index.ts"],
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"noEmit": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
|
|||
2051
pnpm-lock.yaml
2051
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue