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