Compare commits
No commits in common. "368d9942d22b0c8ac2c5ca41bd26a091b072101c" and "82df3f2a2f83242b062a69ba1be04c711523ef41" have entirely different histories.
368d9942d2
...
82df3f2a2f
|
|
@ -32,8 +32,8 @@ packages/my-game/
|
|||
|
||||
### 1. ReactiveScene / GameHostScene
|
||||
Extend `ReactiveScene`(`packages\framework\src\scenes\ReactiveScene.ts`) to use reactive integration features.
|
||||
- Access game context for scene navigation
|
||||
- Use `this.disposables` for auto-cleanup on shutdown.
|
||||
- Use `initData` for context data passed from the `<PhaserScene/>` preact component.
|
||||
|
||||
### 2. Spawner Pattern
|
||||
Implement `Spawner<TData, TObj>` for data-driven objects.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as js from "@eslint";
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { signal, useSignal, useSignalEffect } from "@preact/signals";
|
||||
import Phaser, { AUTO } from "phaser";
|
||||
import { createContext } from "preact";
|
||||
import { useContext, useEffect, useRef } from "preact/hooks";
|
||||
import { signal, useSignal, useSignalEffect } from '@preact/signals';
|
||||
import Phaser, { AUTO } from 'phaser';
|
||||
import { createContext } from 'preact';
|
||||
import { useContext, useEffect, useRef } from 'preact/hooks';
|
||||
|
||||
import {
|
||||
FadeScene as FadeSceneClass,
|
||||
FADE_SCENE_KEY,
|
||||
} from "../scenes/FadeScene";
|
||||
} from '../scenes/FadeScene';
|
||||
|
||||
import type { ReactiveScene } from "../scenes";
|
||||
import type { ReadonlySignal } from "@preact/signals-core";
|
||||
import type { ReactiveScene } from '../scenes';
|
||||
import type { ReadonlySignal } from '@preact/signals-core';
|
||||
|
||||
export interface SceneController {
|
||||
/** 启动场景(带淡入淡出过渡) */
|
||||
|
|
@ -34,8 +34,8 @@ export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
|
|||
type: AUTO,
|
||||
width: 560,
|
||||
height: 560,
|
||||
parent: "phaser-container",
|
||||
backgroundColor: "#f9fafb",
|
||||
parent: 'phaser-container',
|
||||
backgroundColor: '#f9fafb',
|
||||
scene: [],
|
||||
};
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ export function PhaserGame(props: PhaserGameProps) {
|
|||
const sceneController: SceneController = {
|
||||
async launch(sceneKey: string) {
|
||||
if (isTransitioning.value) {
|
||||
console.warn("SceneController: 正在进行场景切换");
|
||||
console.warn('SceneController: 正在进行场景切换');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -120,12 +120,12 @@ export function PhaserGame(props: PhaserGameProps) {
|
|||
},
|
||||
async restart() {
|
||||
if (isTransitioning.value) {
|
||||
console.warn("SceneController: 正在进行场景切换");
|
||||
console.warn('SceneController: 正在进行场景切换');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentScene.value) {
|
||||
console.warn("SceneController: 没有当前场景,无法 restart");
|
||||
console.warn('SceneController: 没有当前场景,无法 restart');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -192,8 +192,8 @@ export function PhaserGame(props: PhaserGameProps) {
|
|||
}
|
||||
|
||||
export interface PhaserSceneProps<TData = {}> {
|
||||
sceneKey?: string;
|
||||
scene: ReactiveScene<TData> | { new (): ReactiveScene<TData> };
|
||||
sceneKey: string;
|
||||
scene: ReactiveScene<TData>;
|
||||
data?: TData;
|
||||
children?: any;
|
||||
}
|
||||
|
|
@ -214,18 +214,16 @@ export function PhaserScene<TData = {}>(props: PhaserSceneProps<TData>) {
|
|||
const game = ctx.game;
|
||||
|
||||
// 注册场景到 Phaser(但不启动)
|
||||
const scene = "scene" in props.scene ? props.scene : new props.scene();
|
||||
const sceneKey = props.sceneKey ?? scene.sys.settings.key;
|
||||
if (!game.scene.getScene(sceneKey)) {
|
||||
if (!game.scene.getScene(props.sceneKey)) {
|
||||
const initData = {
|
||||
...props.data,
|
||||
phaserGame: phaserGameSignal,
|
||||
sceneController: ctx.sceneController,
|
||||
};
|
||||
game.scene.add(sceneKey, props.scene, false, initData);
|
||||
game.scene.add(props.sceneKey, props.scene, false, initData);
|
||||
}
|
||||
|
||||
sceneSignal.value = scene;
|
||||
sceneSignal.value = props.scene;
|
||||
registered.current = true;
|
||||
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -113,13 +113,6 @@ export const MENU_BUTTON = {
|
|||
height: 40,
|
||||
} as const;
|
||||
|
||||
// Text positioning
|
||||
export const TEXT_POSITION = {
|
||||
titleX: 40,
|
||||
titleY: 40,
|
||||
infoX: 40,
|
||||
} as const;
|
||||
|
||||
// Animation durations (in ms)
|
||||
export const ANIMATIONS = {
|
||||
pawnSpawn: 300,
|
||||
|
|
@ -146,23 +139,6 @@ export const CARD_GRID = {
|
|||
gridSize: 5,
|
||||
} as const;
|
||||
|
||||
// Visual style constants
|
||||
export const VISUAL = {
|
||||
pawnRadius: CELL_SIZE / 3,
|
||||
pawnStrokeWidth: 2,
|
||||
selectionRingOffset: 5,
|
||||
selectionRingStrokeWidth: 3,
|
||||
highlightOuterRadius: CELL_SIZE / 3,
|
||||
highlightInnerRadius: CELL_SIZE / 4,
|
||||
highlightHitAreaRadius: CELL_SIZE / 3,
|
||||
cardStrokeWidth: 2,
|
||||
cardTitleOffset: 16,
|
||||
cardPlayerOffset: 16,
|
||||
cardDisabledAlpha: 0.8,
|
||||
overlayAlpha: 0.6,
|
||||
cardBackgroundDepth: -1,
|
||||
} as const;
|
||||
|
||||
// Helper function to convert board coordinates to screen coordinates
|
||||
export function boardToScreen(
|
||||
boardX: number,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
import type { Card } from "@/game/onitama";
|
||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
|
||||
import {
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
COLORS,
|
||||
FONTS,
|
||||
CARD_GRID,
|
||||
VISUAL,
|
||||
} from "@/config";
|
||||
import { CARD_WIDTH, CARD_HEIGHT, COLORS, FONTS, CARD_GRID } from "@/config";
|
||||
|
||||
export interface CardRenderOptions {
|
||||
card: Card;
|
||||
|
|
@ -35,17 +28,12 @@ export class CardRenderer {
|
|||
// Create background rectangle
|
||||
const bg = this.scene.add
|
||||
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, COLORS.cardBg, 1)
|
||||
.setStrokeStyle(VISUAL.cardStrokeWidth, COLORS.cardStroke);
|
||||
.setStrokeStyle(2, COLORS.cardStroke);
|
||||
container.add(bg);
|
||||
|
||||
// Create title text
|
||||
const title = this.scene.add
|
||||
.text(
|
||||
0,
|
||||
-CARD_HEIGHT / 2 + VISUAL.cardTitleOffset,
|
||||
card.id,
|
||||
FONTS.cardTitle,
|
||||
)
|
||||
.text(0, -CARD_HEIGHT / 2 + 16, card.id, FONTS.cardTitle)
|
||||
.setOrigin(0.5);
|
||||
container.add(title);
|
||||
|
||||
|
|
@ -54,12 +42,7 @@ export class CardRenderer {
|
|||
|
||||
// Create starting player text
|
||||
const playerText = this.scene.add
|
||||
.text(
|
||||
0,
|
||||
CARD_HEIGHT / 2 - VISUAL.cardPlayerOffset,
|
||||
card.startingPlayer,
|
||||
FONTS.cardPlayer,
|
||||
)
|
||||
.text(0, CARD_HEIGHT / 2 - 16, card.startingPlayer, FONTS.cardPlayer)
|
||||
.setOrigin(0.5);
|
||||
container.add(playerText);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
|
||||
import {
|
||||
CELL_SIZE,
|
||||
COLORS,
|
||||
VISUAL,
|
||||
createHighlightInnerPulseTween,
|
||||
createHighlightOuterPulseTween,
|
||||
} from "@/config";
|
||||
|
|
@ -37,7 +37,7 @@ export class HighlightRenderer {
|
|||
const outerCircle = this.scene.add.circle(
|
||||
0,
|
||||
0,
|
||||
VISUAL.highlightOuterRadius,
|
||||
CELL_SIZE / 3,
|
||||
COLORS.black,
|
||||
0.2,
|
||||
);
|
||||
|
|
@ -47,7 +47,7 @@ export class HighlightRenderer {
|
|||
const innerCircle = this.scene.add.circle(
|
||||
0,
|
||||
0,
|
||||
VISUAL.highlightInnerRadius,
|
||||
CELL_SIZE / 4,
|
||||
COLORS.black,
|
||||
0.4,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||
|
||||
import { CELL_SIZE, COLORS, FONTS, VISUAL } from "@/config";
|
||||
import { CELL_SIZE, COLORS, FONTS } from "@/config";
|
||||
|
||||
export type PawnType = "master" | "student";
|
||||
export type PawnOwner = "red" | "black";
|
||||
|
|
@ -31,8 +31,8 @@ export class PawnRenderer {
|
|||
// Create background circle
|
||||
const bgColor = owner === "red" ? COLORS.red : COLORS.black;
|
||||
const circle = this.scene.add
|
||||
.circle(0, 0, VISUAL.pawnRadius, bgColor, 1)
|
||||
.setStrokeStyle(VISUAL.pawnStrokeWidth, COLORS.pawnStroke);
|
||||
.circle(0, 0, CELL_SIZE / 3, bgColor, 1)
|
||||
.setStrokeStyle(2, COLORS.pawnStroke);
|
||||
container.add(circle);
|
||||
|
||||
// Create label text
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
|||
import {
|
||||
CELL_SIZE,
|
||||
COLORS,
|
||||
VISUAL,
|
||||
createSelectionShowTween,
|
||||
createSelectionRingPulseTween,
|
||||
createSelectionHideTween,
|
||||
|
|
@ -32,21 +31,8 @@ export class SelectionRenderer {
|
|||
parent: Phaser.GameObjects.Container | Phaser.GameObjects.GameObject,
|
||||
): Phaser.GameObjects.Arc {
|
||||
const ring = this.scene.add
|
||||
.arc(
|
||||
0,
|
||||
0,
|
||||
VISUAL.pawnRadius + VISUAL.selectionRingOffset,
|
||||
0,
|
||||
360,
|
||||
false,
|
||||
COLORS.highlight,
|
||||
0,
|
||||
)
|
||||
.setStrokeStyle(
|
||||
VISUAL.selectionRingStrokeWidth,
|
||||
COLORS.highlightStroke,
|
||||
1,
|
||||
)
|
||||
.arc(0, 0, CELL_SIZE / 3 + 5, 0, 360, false, COLORS.highlight, 0)
|
||||
.setStrokeStyle(3, COLORS.highlightStroke, 1)
|
||||
.setAlpha(0);
|
||||
|
||||
// Add to parent at index 0 (behind other visuals)
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import {
|
|||
COLORS,
|
||||
FONTS,
|
||||
MENU_BUTTON,
|
||||
TEXT_POSITION,
|
||||
VISUAL,
|
||||
getBoardCenter,
|
||||
getCardLabelPosition,
|
||||
colorToStr,
|
||||
|
|
@ -81,12 +79,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||
});
|
||||
|
||||
// Info text
|
||||
this.infoText = this.add.text(
|
||||
TEXT_POSITION.infoX,
|
||||
BOARD_OFFSET.y,
|
||||
"",
|
||||
FONTS.info,
|
||||
);
|
||||
this.infoText = this.add.text(40, BOARD_OFFSET.y, "", FONTS.info);
|
||||
|
||||
// Update info text when UI state changes
|
||||
this.addEffect(() => {
|
||||
|
|
@ -156,12 +149,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||
|
||||
g.strokePath();
|
||||
|
||||
this.add.text(
|
||||
TEXT_POSITION.titleX,
|
||||
TEXT_POSITION.titleY,
|
||||
"Onitama",
|
||||
FONTS.title,
|
||||
);
|
||||
this.add.text(40, 40, "Onitama", FONTS.title);
|
||||
}
|
||||
|
||||
private setupInput(): void {
|
||||
|
|
@ -300,7 +288,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
|||
boardWidth,
|
||||
boardHeight,
|
||||
COLORS.overlayBg,
|
||||
VISUAL.overlayAlpha,
|
||||
0.6,
|
||||
)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import type { Spawner } from "boardgame-phaser";
|
|||
import {
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
COLORS,
|
||||
VISUAL,
|
||||
getCardPosition,
|
||||
createCardMoveTween,
|
||||
createCardRotateTween,
|
||||
|
|
@ -104,7 +102,7 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
|||
.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0)
|
||||
.setStrokeStyle(lineWidth, color)
|
||||
.setAlpha(0)
|
||||
.setDepth(VISUAL.cardBackgroundDepth);
|
||||
.setDepth(-1);
|
||||
this.highlightRect = rect;
|
||||
this.addAt(this.highlightRect, 0);
|
||||
|
||||
|
|
@ -163,7 +161,7 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
|||
// 创建一个 effect 来持续监听高亮状态变化
|
||||
const dispose = effect(() => {
|
||||
if (scene.uiState.value.selectedCard === this._cardId) {
|
||||
this.highlight(COLORS.highlight, VISUAL.selectionRingStrokeWidth);
|
||||
this.highlight(0xfbbf24, 3);
|
||||
} else {
|
||||
this.unhighlight();
|
||||
}
|
||||
|
|
@ -278,7 +276,7 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
|||
// 设置悬停效果
|
||||
container.on("pointerover", () => {
|
||||
if (this.scene.uiState.value.selectedCard !== data.cardId) {
|
||||
container.setAlpha(VISUAL.cardDisabledAlpha);
|
||||
container.setAlpha(0.8);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import type { Spawner } from "boardgame-phaser";
|
|||
import {
|
||||
boardToScreen,
|
||||
CELL_SIZE,
|
||||
VISUAL,
|
||||
createHighlightSpawnTween,
|
||||
createHighlightDespawnTween,
|
||||
createHighlightClickFeedbackTween,
|
||||
|
|
@ -84,7 +83,7 @@ export class HighlightSpawner implements Spawner<
|
|||
this.renderer.render(container, { x: data.x, y: data.y });
|
||||
|
||||
// 设置交互区域
|
||||
const hitArea = new Geom.Circle(0, 0, VISUAL.highlightHitAreaRadius);
|
||||
const hitArea = new Geom.Circle(0, 0, CELL_SIZE / 3);
|
||||
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
|
||||
if (container.input) {
|
||||
container.input.cursor = "pointer";
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@
|
|||
* All magic numbers should be defined here and imported where needed.
|
||||
*/
|
||||
|
||||
import { PhaserGameProps } from "boardgame-phaser";
|
||||
|
||||
// ── Map Layout ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const MAP_CONFIG = {
|
||||
|
|
@ -85,9 +83,9 @@ export const UI_CONFIG = {
|
|||
/** Button border color */
|
||||
BUTTON_BORDER: 0x7777aa,
|
||||
/** Button text color */
|
||||
BUTTON_TEXT_COLOR: "#ffffff",
|
||||
BUTTON_TEXT_COLOR: '#ffffff',
|
||||
/** Button font size */
|
||||
BUTTON_FONT_SIZE: "16px",
|
||||
BUTTON_FONT_SIZE: '16px',
|
||||
} as const;
|
||||
|
||||
// ── Colors ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -104,14 +102,14 @@ export const NODE_COLORS = {
|
|||
} as const;
|
||||
|
||||
export const NODE_LABELS = {
|
||||
start: "起点",
|
||||
end: "终点",
|
||||
minion: "战斗",
|
||||
elite: "精英",
|
||||
event: "事件",
|
||||
camp: "营地",
|
||||
shop: "商店",
|
||||
curio: "奇遇",
|
||||
start: '起点',
|
||||
end: '终点',
|
||||
minion: '战斗',
|
||||
elite: '精英',
|
||||
event: '事件',
|
||||
camp: '营地',
|
||||
shop: '商店',
|
||||
curio: '奇遇',
|
||||
} as const;
|
||||
|
||||
export const ITEM_COLORS = [
|
||||
|
|
@ -120,20 +118,5 @@ export const ITEM_COLORS = [
|
|||
|
||||
// ── 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",
|
||||
]);
|
||||
|
||||
export const GAME_CONFIG: Phaser.Types.Core.GameConfig = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
};
|
||||
export const POSITIVE_EFFECTS = new Set(['block', 'strength', 'dexterity', 'regen']);
|
||||
export const NEGATIVE_EFFECTS = new Set(['weak', 'vulnerable', 'frail', 'poison']);
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { GameUI } from "boardgame-phaser";
|
||||
import "./style.css";
|
||||
import { h } from 'preact';
|
||||
import { GameUI } from 'boardgame-phaser';
|
||||
import './style.css';
|
||||
import App from "@/ui/App";
|
||||
|
||||
const ui = new GameUI({
|
||||
container: "ui-root",
|
||||
container: document.getElementById('ui-root')!,
|
||||
root: <App/>,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,439 @@
|
|||
import Phaser from "phaser";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { UI_CONFIG, MAP_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
|
||||
import { MutableSignal } from "boardgame-core";
|
||||
import {
|
||||
canMoveTo,
|
||||
moveToNode,
|
||||
getCurrentNode,
|
||||
getReachableChildren,
|
||||
isAtEndNode,
|
||||
type RunState,
|
||||
type MapNode,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
|
||||
export class GameFlowScene extends ReactiveScene {
|
||||
/** 全局游戏状态(由 App.tsx 注入) */
|
||||
private gameState: MutableSignal<RunState>;
|
||||
|
||||
// UI elements
|
||||
private hudContainer!: Phaser.GameObjects.Container;
|
||||
private hpText!: Phaser.GameObjects.Text;
|
||||
private goldText!: Phaser.GameObjects.Text;
|
||||
private nodeText!: Phaser.GameObjects.Text;
|
||||
|
||||
// Map elements
|
||||
private mapContainer!: Phaser.GameObjects.Container;
|
||||
private isDragging = false;
|
||||
private dragStartX = 0;
|
||||
private dragStartY = 0;
|
||||
private dragStartContainerX = 0;
|
||||
private dragStartContainerY = 0;
|
||||
|
||||
// Interaction
|
||||
private hoveredNode: string | null = null;
|
||||
private nodeGraphics: Map<string, Phaser.GameObjects.Graphics> = new Map();
|
||||
|
||||
constructor(gameState: MutableSignal<RunState>) {
|
||||
super("GameFlowScene");
|
||||
this.gameState = gameState;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
this.drawHUD();
|
||||
this.drawMap();
|
||||
this.updateHUD();
|
||||
}
|
||||
|
||||
private drawHUD(): void {
|
||||
const { width } = this.scale;
|
||||
|
||||
// HUD background
|
||||
const hudBg = this.add.rectangle(width / 2, 25, 400, 40, 0x111122, 0.8);
|
||||
this.hudContainer = this.add.container(width / 2, 25).setDepth(200);
|
||||
this.hudContainer.add(hudBg);
|
||||
|
||||
// HP
|
||||
this.hpText = this.add
|
||||
.text(-150, 0, "", {
|
||||
fontSize: "16px",
|
||||
color: "#ff6666",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0, 0.5);
|
||||
this.hudContainer.add(this.hpText);
|
||||
|
||||
// Gold
|
||||
this.goldText = this.add
|
||||
.text(-50, 0, "", {
|
||||
fontSize: "16px",
|
||||
color: "#ffcc44",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0, 0.5);
|
||||
this.hudContainer.add(this.goldText);
|
||||
|
||||
// Current node
|
||||
this.nodeText = this.add
|
||||
.text(50, 0, "", {
|
||||
fontSize: "16px",
|
||||
color: "#ffffff",
|
||||
})
|
||||
.setOrigin(0, 0.5);
|
||||
this.hudContainer.add(this.nodeText);
|
||||
|
||||
// Back to menu button
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "返回菜单",
|
||||
x: width - 100,
|
||||
y: 25,
|
||||
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
|
||||
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch("IndexScene");
|
||||
},
|
||||
depth: 200,
|
||||
});
|
||||
}
|
||||
|
||||
private updateHUD(): void {
|
||||
const state = this.gameState.value;
|
||||
const { player, currentNodeId, map } = state;
|
||||
const currentNode = map.nodes.get(currentNodeId);
|
||||
|
||||
this.hpText.setText(`HP: ${player.currentHp}/${player.maxHp}`);
|
||||
this.goldText.setText(`💰 ${player.gold}`);
|
||||
|
||||
if (currentNode) {
|
||||
const typeLabel = NODE_LABELS[currentNode.type] ?? currentNode.type;
|
||||
const encounterName = currentNode.encounter?.name ?? typeLabel;
|
||||
this.nodeText.setText(`当前: ${encounterName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private drawMap(): void {
|
||||
const { width, height } = this.scale;
|
||||
const state = this.gameState.value;
|
||||
const {
|
||||
LAYER_HEIGHT,
|
||||
NODE_SPACING,
|
||||
NODE_RADIUS,
|
||||
MAX_NODES_PER_LAYER,
|
||||
TOTAL_LAYERS,
|
||||
} = MAP_CONFIG;
|
||||
|
||||
// Calculate map bounds (left-to-right: layers along X, nodes along Y)
|
||||
const maxLayer = TOTAL_LAYERS - 1;
|
||||
const mapWidth = maxLayer * LAYER_HEIGHT + 200;
|
||||
const mapHeight = (MAX_NODES_PER_LAYER - 1) * NODE_SPACING + 200;
|
||||
|
||||
// Create scrollable container
|
||||
this.mapContainer = this.add.container(width / 2, height / 2 + 50);
|
||||
|
||||
// Background panel
|
||||
const bg = this.add
|
||||
.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
|
||||
.setOrigin(0.5);
|
||||
this.mapContainer.add(bg);
|
||||
|
||||
const graphics = this.add.graphics();
|
||||
this.mapContainer.add(graphics);
|
||||
|
||||
const { map, currentNodeId } = state;
|
||||
const reachableChildren = getReachableChildren(state);
|
||||
const reachableIds = new Set(reachableChildren.map((n) => n.id));
|
||||
|
||||
// Draw edges
|
||||
graphics.lineStyle(2, 0x666666);
|
||||
for (const [nodeId, node] of map.nodes) {
|
||||
const posX = this.getNodeX(node);
|
||||
const posY = this.getNodeY(node);
|
||||
|
||||
for (const childId of node.childIds) {
|
||||
const child = map.nodes.get(childId);
|
||||
if (child) {
|
||||
const childX = this.getNodeX(child);
|
||||
const childY = this.getNodeY(child);
|
||||
graphics.lineBetween(posX, posY, childX, childY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
for (const [nodeId, node] of map.nodes) {
|
||||
const posX = this.getNodeX(node);
|
||||
const posY = this.getNodeY(node);
|
||||
const isCurrent = nodeId === currentNodeId;
|
||||
const isReachable = reachableIds.has(nodeId);
|
||||
const baseColor = NODE_COLORS[node.type] ?? 0x888888;
|
||||
|
||||
// Node circle
|
||||
const nodeGraphics = this.add.graphics();
|
||||
this.mapContainer.add(nodeGraphics);
|
||||
this.nodeGraphics.set(nodeId, nodeGraphics);
|
||||
|
||||
const color = isCurrent
|
||||
? 0xffffff
|
||||
: isReachable
|
||||
? this.brightenColor(baseColor)
|
||||
: baseColor;
|
||||
nodeGraphics.fillStyle(color);
|
||||
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
|
||||
|
||||
if (isCurrent) {
|
||||
nodeGraphics.lineStyle(3, 0xffff44);
|
||||
} else if (isReachable) {
|
||||
nodeGraphics.lineStyle(2, 0xaaddaa);
|
||||
} else {
|
||||
nodeGraphics.lineStyle(2, 0x888888);
|
||||
}
|
||||
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
|
||||
|
||||
// Node label
|
||||
const label = NODE_LABELS[node.type] ?? node.type;
|
||||
this.mapContainer.add(
|
||||
this.add
|
||||
.text(posX, posY, label, {
|
||||
fontSize: "11px",
|
||||
color: "#ffffff",
|
||||
fontStyle: isCurrent ? "bold" : "normal",
|
||||
})
|
||||
.setOrigin(0.5),
|
||||
);
|
||||
|
||||
// Encounter name
|
||||
if (node.encounter) {
|
||||
this.mapContainer.add(
|
||||
this.add
|
||||
.text(posX, posY + NODE_RADIUS + 12, node.encounter.name, {
|
||||
fontSize: "10px",
|
||||
color: "#cccccc",
|
||||
})
|
||||
.setOrigin(0.5),
|
||||
);
|
||||
}
|
||||
|
||||
// Make reachable nodes interactive
|
||||
if (isReachable) {
|
||||
const hitZone = this.add
|
||||
.circle(posX, posY, NODE_RADIUS, 0x000000, 0)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
this.mapContainer.add(hitZone);
|
||||
|
||||
hitZone.on("pointerover", () => {
|
||||
this.hoveredNode = nodeId;
|
||||
nodeGraphics.clear();
|
||||
nodeGraphics.fillStyle(this.brightenColor(baseColor));
|
||||
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
|
||||
nodeGraphics.lineStyle(3, 0xaaddaa);
|
||||
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
|
||||
});
|
||||
|
||||
hitZone.on("pointerout", () => {
|
||||
this.hoveredNode = null;
|
||||
nodeGraphics.clear();
|
||||
nodeGraphics.fillStyle(baseColor);
|
||||
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
|
||||
nodeGraphics.lineStyle(2, 0xaaddaa);
|
||||
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
|
||||
});
|
||||
|
||||
hitZone.on("pointerdown", () => {
|
||||
this.onNodeClick(nodeId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Setup drag-to-scroll with disposables cleanup
|
||||
const onPointerDown = (pointer: Phaser.Input.Pointer) => {
|
||||
this.isDragging = true;
|
||||
this.dragStartX = pointer.x;
|
||||
this.dragStartY = pointer.y;
|
||||
this.dragStartContainerX = this.mapContainer.x;
|
||||
this.dragStartContainerY = this.mapContainer.y;
|
||||
};
|
||||
|
||||
const onPointerMove = (pointer: Phaser.Input.Pointer) => {
|
||||
if (!this.isDragging) return;
|
||||
this.mapContainer.x =
|
||||
this.dragStartContainerX + (pointer.x - this.dragStartX);
|
||||
this.mapContainer.y =
|
||||
this.dragStartContainerY + (pointer.y - this.dragStartY);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
this.isDragging = false;
|
||||
};
|
||||
|
||||
this.input.on("pointerdown", onPointerDown);
|
||||
this.input.on("pointermove", onPointerMove);
|
||||
this.input.on("pointerup", onPointerUp);
|
||||
this.input.on("pointerout", onPointerUp);
|
||||
|
||||
this.disposables.add(() => {
|
||||
this.input.off("pointerdown", onPointerDown);
|
||||
this.input.off("pointermove", onPointerMove);
|
||||
this.input.off("pointerup", onPointerUp);
|
||||
this.input.off("pointerout", onPointerUp);
|
||||
});
|
||||
|
||||
// Hint text
|
||||
this.add
|
||||
.text(
|
||||
width / 2,
|
||||
this.scale.height - 20,
|
||||
"点击可到达的节点进入遭遇 | 拖拽滚动查看地图",
|
||||
{
|
||||
fontSize: "14px",
|
||||
color: "#888888",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5)
|
||||
.setDepth(200);
|
||||
}
|
||||
|
||||
private async onNodeClick(nodeId: string): Promise<void> {
|
||||
const state = this.gameState.value;
|
||||
if (!canMoveTo(state, nodeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Move to target node
|
||||
const result = moveToNode(state, nodeId);
|
||||
if (!result.success) {
|
||||
console.warn(`无法移动到节点: ${result.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update visuals
|
||||
this.updateHUD();
|
||||
this.redrawMapHighlights();
|
||||
|
||||
// Check if at end node
|
||||
if (isAtEndNode(state)) {
|
||||
this.showEndScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
// Launch encounter scene
|
||||
const currentNode = getCurrentNode(state);
|
||||
if (!currentNode || !currentNode.encounter) {
|
||||
console.warn("当前节点没有遭遇数据");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sceneController.launch("PlaceholderEncounterScene");
|
||||
}
|
||||
|
||||
private redrawMapHighlights(): void {
|
||||
const state = this.gameState.value;
|
||||
const { map, currentNodeId } = state;
|
||||
const reachableChildren = getReachableChildren(state);
|
||||
const reachableIds = new Set(reachableChildren.map((n) => n.id));
|
||||
|
||||
for (const [nodeId, nodeGraphics] of this.nodeGraphics) {
|
||||
const node = map.nodes.get(nodeId);
|
||||
if (!node) continue;
|
||||
|
||||
const isCurrent = nodeId === currentNodeId;
|
||||
const isReachable = reachableIds.has(nodeId);
|
||||
const baseColor = NODE_COLORS[node.type] ?? 0x888888;
|
||||
|
||||
nodeGraphics.clear();
|
||||
const color = isCurrent
|
||||
? 0xffffff
|
||||
: isReachable
|
||||
? this.brightenColor(baseColor)
|
||||
: baseColor;
|
||||
nodeGraphics.fillStyle(color);
|
||||
nodeGraphics.fillCircle(
|
||||
this.getNodeX(node),
|
||||
this.getNodeY(node),
|
||||
MAP_CONFIG.NODE_RADIUS,
|
||||
);
|
||||
|
||||
if (isCurrent) {
|
||||
nodeGraphics.lineStyle(3, 0xffff44);
|
||||
} else if (isReachable) {
|
||||
nodeGraphics.lineStyle(2, 0xaaddaa);
|
||||
} else {
|
||||
nodeGraphics.lineStyle(2, 0x888888);
|
||||
}
|
||||
nodeGraphics.strokeCircle(
|
||||
this.getNodeX(node),
|
||||
this.getNodeY(node),
|
||||
MAP_CONFIG.NODE_RADIUS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private showEndScreen(): void {
|
||||
const { width, height } = this.scale;
|
||||
const state = this.gameState.value;
|
||||
|
||||
// Overlay
|
||||
const overlay = this.add
|
||||
.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7)
|
||||
.setDepth(300);
|
||||
|
||||
// End message
|
||||
this.add
|
||||
.text(width / 2, height / 2 - 40, "恭喜通关!", {
|
||||
fontSize: "36px",
|
||||
color: "#ffcc44",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(300);
|
||||
|
||||
const { player } = state;
|
||||
this.add
|
||||
.text(
|
||||
width / 2,
|
||||
height / 2 + 20,
|
||||
`剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`,
|
||||
{
|
||||
fontSize: "20px",
|
||||
color: "#ffffff",
|
||||
align: "center",
|
||||
},
|
||||
)
|
||||
.setOrigin(0.5)
|
||||
.setDepth(300);
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "返回菜单",
|
||||
x: width / 2,
|
||||
y: height / 2 + 100,
|
||||
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
|
||||
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch("IndexScene");
|
||||
},
|
||||
depth: 300,
|
||||
});
|
||||
}
|
||||
|
||||
private getNodeX(node: MapNode): number {
|
||||
return -500 + node.layerIndex * MAP_CONFIG.LAYER_HEIGHT;
|
||||
}
|
||||
|
||||
private getNodeY(node: MapNode): number {
|
||||
const layer = this.gameState.value.map.layers[node.layerIndex];
|
||||
const nodeIndex = layer.nodeIds.indexOf(node.id);
|
||||
const totalNodes = layer.nodeIds.length;
|
||||
const layerHeight = (totalNodes - 1) * MAP_CONFIG.NODE_SPACING;
|
||||
return -layerHeight / 2 + nodeIndex * MAP_CONFIG.NODE_SPACING;
|
||||
}
|
||||
|
||||
private brightenColor(color: number): number {
|
||||
const r = Math.min(255, ((color >> 16) & 0xff) + 40);
|
||||
const g = Math.min(255, ((color >> 8) & 0xff) + 40);
|
||||
const b = Math.min(255, (color & 0xff) + 40);
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import Phaser from "phaser";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { GRID_CONFIG, ITEM_COLORS } from "@/config";
|
||||
import { GRID_CONFIG, UI_CONFIG, ITEM_COLORS } from "@/config";
|
||||
import {
|
||||
createGridInventory,
|
||||
placeItem,
|
||||
|
|
@ -11,7 +12,6 @@ import {
|
|||
type InventoryItem,
|
||||
type GameItemMeta,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { SceneKey } from "./types";
|
||||
|
||||
export class GridViewerScene extends ReactiveScene {
|
||||
private inventory: GridInventory<GameItemMeta>;
|
||||
|
|
@ -57,7 +57,7 @@ export class GridViewerScene extends ReactiveScene {
|
|||
}
|
||||
|
||||
private placeSampleItems(): void {
|
||||
const items = data.desert.getItems();
|
||||
const items = data.desert.items;
|
||||
const sampleItems = [
|
||||
{ index: 0, x: 0, y: 0 },
|
||||
{ index: 3, x: 3, y: 0 },
|
||||
|
|
@ -207,7 +207,7 @@ export class GridViewerScene extends ReactiveScene {
|
|||
x: 100,
|
||||
y: 40,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch(SceneKey.IndexScene);
|
||||
await this.sceneController.launch("IndexScene");
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -242,7 +242,7 @@ export class GridViewerScene extends ReactiveScene {
|
|||
GRID_CONFIG.WIDTH,
|
||||
GRID_CONFIG.HEIGHT,
|
||||
);
|
||||
const items = data.desert.getItems();
|
||||
const items = data.desert.items;
|
||||
|
||||
let itemIndex = 0;
|
||||
for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import Phaser from "phaser";
|
|||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { UI_CONFIG } from "@/config";
|
||||
import { SceneKey } from "./types";
|
||||
|
||||
export class IndexScene extends ReactiveScene {
|
||||
constructor() {
|
||||
|
|
@ -33,22 +32,15 @@ export class IndexScene extends ReactiveScene {
|
|||
.setOrigin(0.5);
|
||||
|
||||
// Buttons
|
||||
const buttons: {
|
||||
label: string;
|
||||
scene: SceneKey;
|
||||
y: number;
|
||||
}[] = [
|
||||
{ label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY },
|
||||
const buttons = [
|
||||
{ label: "开始游戏", scene: "GameFlowScene", y: centerY - 70 },
|
||||
{ label: "Map Viewer", scene: "MapViewerScene", y: centerY },
|
||||
{
|
||||
label: "Grid Inventory Viewer",
|
||||
scene: SceneKey.GridViewerScene,
|
||||
scene: "GridViewerScene",
|
||||
y: centerY + 70,
|
||||
},
|
||||
{
|
||||
label: "Shape Viewer",
|
||||
scene: SceneKey.ShapeViewerScene,
|
||||
y: centerY + 140,
|
||||
},
|
||||
{ label: "Shape Viewer", scene: "ShapeViewerScene", y: centerY + 140 },
|
||||
];
|
||||
|
||||
for (const btn of buttons) {
|
||||
|
|
@ -58,7 +50,7 @@ export class IndexScene extends ReactiveScene {
|
|||
|
||||
private createButton(
|
||||
label: string,
|
||||
targetScene: SceneKey,
|
||||
targetScene: string,
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
type MapNode,
|
||||
type MapNodeType,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { SceneKey } from "./types";
|
||||
|
||||
export class MapViewerScene extends ReactiveScene {
|
||||
private map: PointCrawlMap | null = null;
|
||||
|
|
@ -58,7 +57,7 @@ export class MapViewerScene extends ReactiveScene {
|
|||
x: 100,
|
||||
y: 40,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch(SceneKey.IndexScene);
|
||||
await this.sceneController.launch("IndexScene");
|
||||
},
|
||||
depth: 100,
|
||||
});
|
||||
|
|
@ -114,7 +113,7 @@ export class MapViewerScene extends ReactiveScene {
|
|||
|
||||
private drawMap(): void {
|
||||
const rng = createRNG(this.seed);
|
||||
this.map = generatePointCrawlMap(rng, data.desert.getEncounters());
|
||||
this.map = generatePointCrawlMap(rng, data.desert.encounters);
|
||||
|
||||
const { width, height } = this.scale;
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
import Phaser from "phaser";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { UI_CONFIG, GRID_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
|
||||
import { MutableSignal } from "boardgame-core";
|
||||
import {
|
||||
resolveEncounter,
|
||||
removeItem,
|
||||
type RunState,
|
||||
type EncounterResult,
|
||||
type MapNodeType,
|
||||
type MapNode,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { InventoryWidget } from "@/widgets/InventoryWidget";
|
||||
|
||||
/**
|
||||
* 占位符遭遇场景
|
||||
*
|
||||
* 左侧显示背包网格(使用 InventoryWidget),右侧显示遭遇信息。
|
||||
*/
|
||||
export class PlaceholderEncounterScene extends ReactiveScene {
|
||||
private gameState: MutableSignal<RunState>;
|
||||
private inventoryWidget: InventoryWidget | null = null;
|
||||
|
||||
constructor(gameState: MutableSignal<RunState>) {
|
||||
super("PlaceholderEncounterScene");
|
||||
this.gameState = gameState;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
super.create();
|
||||
const { width, height } = this.scale;
|
||||
const state = this.gameState.value;
|
||||
|
||||
const gridCols = state.inventory.width;
|
||||
const gridRows = state.inventory.height;
|
||||
const cellSize = GRID_CONFIG.WIDGET_CELL_SIZE;
|
||||
const gridW = gridCols * cellSize + (gridCols - 1) * GRID_CONFIG.GRID_GAP;
|
||||
const gridH = gridRows * cellSize + (gridRows - 1) * GRID_CONFIG.GRID_GAP;
|
||||
const leftPanelW = gridW + 40;
|
||||
|
||||
this.inventoryWidget = new InventoryWidget({
|
||||
scene: this,
|
||||
gameState: this.gameState,
|
||||
x: 60,
|
||||
y: (height - gridH) / 2 + 20,
|
||||
cellSize,
|
||||
gridGap: GRID_CONFIG.GRID_GAP,
|
||||
});
|
||||
|
||||
this.cameras.main.setBounds(0, 0, width, height);
|
||||
this.cameras.main.setScroll(0, 0);
|
||||
|
||||
// Panel background
|
||||
this.add
|
||||
.rectangle(
|
||||
60 + leftPanelW / 2,
|
||||
this.inventoryWidgetY(gridH),
|
||||
leftPanelW + 10,
|
||||
gridH + 50,
|
||||
0x111122,
|
||||
0.9,
|
||||
)
|
||||
.setStrokeStyle(2, 0x5555aa);
|
||||
|
||||
// "背包" title
|
||||
this.add
|
||||
.text(60 + gridW / 2, (height - gridH) / 2, "背包", {
|
||||
fontSize: "22px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
const node = state.map.nodes.get(state.currentNodeId);
|
||||
if (!node || !node.encounter) {
|
||||
const rightX = leftPanelW + 80;
|
||||
this.add
|
||||
.text(rightX + 300, height / 2, "没有遭遇数据", {
|
||||
fontSize: "24px",
|
||||
color: "#ff4444",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
return;
|
||||
}
|
||||
|
||||
this.drawRightPanel(
|
||||
node as MapNode & { encounter: { name: string; description: string } },
|
||||
leftPanelW,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
}
|
||||
|
||||
private inventoryWidgetY(gridH: number): number {
|
||||
const { height } = this.scale;
|
||||
return (height - gridH) / 2 + 20 + gridH / 2;
|
||||
}
|
||||
|
||||
private drawRightPanel(
|
||||
node: MapNode & { encounter: { name: string; description: string } },
|
||||
leftPanelW: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
const encounter = {
|
||||
type: node.type as MapNodeType,
|
||||
name: node.encounter.name,
|
||||
description: node.encounter.description,
|
||||
};
|
||||
const nodeId = node.id as string;
|
||||
|
||||
const rightX = leftPanelW + 60;
|
||||
const rightW = width - rightX - 40;
|
||||
const cx = rightX + rightW / 2;
|
||||
const cy = height / 2;
|
||||
|
||||
this.add
|
||||
.text(cx, cy - 180, "遭遇", {
|
||||
fontSize: "36px",
|
||||
color: "#fff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
const typeLabel = this.getTypeLabel(encounter.type);
|
||||
const badgeColor = this.getTypeColor(encounter.type);
|
||||
this.add.rectangle(cx, cy - 110, 140, 40, badgeColor);
|
||||
this.add
|
||||
.text(cx, cy - 110, typeLabel, {
|
||||
fontSize: "18px",
|
||||
color: "#fff",
|
||||
fontStyle: "bold",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(cx, cy - 50, encounter.name, {
|
||||
fontSize: "28px",
|
||||
color: "#fff",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(cx, cy + 10, encounter.description || "(暂无描述)", {
|
||||
fontSize: "18px",
|
||||
color: "#bbb",
|
||||
wordWrap: { width: rightW - 40 },
|
||||
align: "center",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(cx, cy + 80, `节点: ${nodeId}`, {
|
||||
fontSize: "14px",
|
||||
color: "#666",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(cx, cy + 130, "(此为占位符遭遇,后续将替换为真实遭遇场景)", {
|
||||
fontSize: "14px",
|
||||
color: "#ff8844",
|
||||
fontStyle: "italic",
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "完成遭遇",
|
||||
x: cx,
|
||||
y: cy + 200,
|
||||
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
|
||||
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
|
||||
onClick: async () => {
|
||||
await this.completeEncounter();
|
||||
},
|
||||
});
|
||||
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "暂不处理",
|
||||
x: cx,
|
||||
y: cy + 270,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch("GameFlowScene");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getTypeLabel(type: MapNodeType): string {
|
||||
return NODE_LABELS[type] ?? type;
|
||||
}
|
||||
|
||||
private getTypeColor(type: MapNodeType): number {
|
||||
return NODE_COLORS[type] ?? 0x888888;
|
||||
}
|
||||
|
||||
private async completeEncounter(): Promise<void> {
|
||||
const state = this.gameState.value;
|
||||
const node = state.map.nodes.get(state.currentNodeId);
|
||||
if (!node || !node.encounter) return;
|
||||
|
||||
// Clear lost items from inventory
|
||||
if (this.inventoryWidget) {
|
||||
const lostIds = this.inventoryWidget.getLostItems();
|
||||
for (const lostId of lostIds) {
|
||||
removeItem(state, lostId);
|
||||
}
|
||||
this.inventoryWidget.clearLostItems();
|
||||
}
|
||||
|
||||
const result: EncounterResult = this.generatePlaceholderResult(node.type);
|
||||
resolveEncounter(state, result);
|
||||
await this.sceneController.launch("GameFlowScene");
|
||||
}
|
||||
|
||||
private generatePlaceholderResult(type: MapNodeType): EncounterResult {
|
||||
switch (type) {
|
||||
case "minion":
|
||||
return { hpLost: 8, goldEarned: 15 };
|
||||
case "elite":
|
||||
return { hpLost: 15, goldEarned: 30 };
|
||||
case "camp":
|
||||
return { hpGained: 15 };
|
||||
case "shop":
|
||||
return { goldEarned: 0 };
|
||||
case "curio":
|
||||
case "event":
|
||||
return { goldEarned: 20 };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import Phaser from "phaser";
|
||||
import { ReactiveScene } from "boardgame-phaser";
|
||||
import { createButton } from "@/utils/createButton";
|
||||
import { SHAPE_CONFIG } from "@/config";
|
||||
import { UI_CONFIG, SHAPE_CONFIG, NODE_LABELS, NODE_COLORS } from "@/config";
|
||||
import {
|
||||
parseShapeString,
|
||||
data,
|
||||
type ParsedShape,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { SceneKey } from "./types";
|
||||
|
||||
export class ShapeViewerScene extends ReactiveScene {
|
||||
constructor() {
|
||||
|
|
@ -17,19 +17,6 @@ export class ShapeViewerScene extends ReactiveScene {
|
|||
super.create();
|
||||
this.drawShapeViewer();
|
||||
this.createControls();
|
||||
this.createBackButton();
|
||||
}
|
||||
|
||||
private createBackButton(): void {
|
||||
createButton({
|
||||
scene: this,
|
||||
label: "← Back",
|
||||
x: 80,
|
||||
y: 30,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch(SceneKey.IndexScene);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private drawShapeViewer(): void {
|
||||
|
|
@ -53,7 +40,7 @@ export class ShapeViewerScene extends ReactiveScene {
|
|||
const startY = 80;
|
||||
const { SPACING_X, SPACING_Y, ITEMS_PER_ROW, MAX_ITEMS } = SHAPE_CONFIG;
|
||||
|
||||
const itemsToShow = data.desert.getItems().slice(0, MAX_ITEMS);
|
||||
const itemsToShow = data.desert.items.slice(0, MAX_ITEMS);
|
||||
|
||||
for (let i = 0; i < itemsToShow.length; i++) {
|
||||
const itemData = itemsToShow[i];
|
||||
|
|
@ -158,7 +145,7 @@ export class ShapeViewerScene extends ReactiveScene {
|
|||
x: 100,
|
||||
y: height - 40,
|
||||
onClick: async () => {
|
||||
await this.sceneController.launch(SceneKey.IndexScene);
|
||||
await this.sceneController.launch("IndexScene");
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
export enum SceneKey {
|
||||
GridViewerScene = "GridViewerScene",
|
||||
IndexScene = "IndexScene",
|
||||
MapViewerScene = "MapViewerScene",
|
||||
ShapeViewerScene = "ShapeViewerScene",
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { MutableSignal, mutableSignal, createRNG } from 'boardgame-core';
|
||||
import { createRunState, generatePointCrawlMap, data, type RunState, type ItemData } from 'boardgame-core/samples/slay-the-spire-like';
|
||||
|
||||
/**
|
||||
* 全局游戏运行状态 Signal
|
||||
*
|
||||
* 在 App.tsx 中创建为单例,所有场景共享。
|
||||
* 遭遇场景通过读取此 signal 的当前遭遇状态来构建 UI。
|
||||
*/
|
||||
export function createGameState(seed?: number): MutableSignal<RunState> {
|
||||
const actualSeed = seed ?? Date.now();
|
||||
const rng = createRNG(actualSeed);
|
||||
const encounters = data.desert.encounters;
|
||||
const map = generatePointCrawlMap(rng, encounters);
|
||||
const starterItems: ItemData[] = data.desert.items.slice(0, 3);
|
||||
return mutableSignal<RunState>(createRunState(map, starterItems));
|
||||
}
|
||||
|
||||
/** 获取当前遭遇数据(computed getter) */
|
||||
export function currentEncounter(
|
||||
gameState: MutableSignal<RunState>
|
||||
): { nodeId: string; encounter: { name: string; description: string; type: string } } | null {
|
||||
const state = gameState.value;
|
||||
const node = state.map.nodes.get(state.currentNodeId);
|
||||
if (!node || !node.encounter) return null;
|
||||
return {
|
||||
nodeId: node.id,
|
||||
encounter: {
|
||||
type: node.type,
|
||||
name: node.encounter.name,
|
||||
description: node.encounter.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,19 +1,53 @@
|
|||
import { h } from "preact";
|
||||
import { PhaserGame, PhaserScene } from "boardgame-phaser";
|
||||
import { useMemo } from "preact/hooks";
|
||||
import { IndexScene } from "@/scenes/IndexScene";
|
||||
import { MapViewerScene } from "@/scenes/MapViewerScene";
|
||||
import { GridViewerScene } from "@/scenes/GridViewerScene";
|
||||
import { ShapeViewerScene } from "@/scenes/ShapeViewerScene";
|
||||
import { GAME_CONFIG } from "@/config";
|
||||
import { GameFlowScene } from "@/scenes/GameFlowScene";
|
||||
import { PlaceholderEncounterScene } from "@/scenes/PlaceholderEncounterScene";
|
||||
import { createGameState } from "@/state/gameState";
|
||||
|
||||
// 全局游戏状态单例
|
||||
const gameState = createGameState();
|
||||
|
||||
export default function App() {
|
||||
const indexScene = useMemo(() => new IndexScene(), []);
|
||||
const mapViewerScene = useMemo(() => new MapViewerScene(), []);
|
||||
const gridViewerScene = useMemo(() => new GridViewerScene(), []);
|
||||
const shapeViewerScene = useMemo(() => new ShapeViewerScene(), []);
|
||||
const gameFlowScene = useMemo(() => new GameFlowScene(gameState), []);
|
||||
const placeholderEncounterScene = useMemo(
|
||||
() => new PlaceholderEncounterScene(gameState),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 flex relative justify-center items-center">
|
||||
<PhaserGame initialScene="IndexScene" config={GAME_CONFIG}>
|
||||
<PhaserScene scene={IndexScene} />
|
||||
<PhaserScene scene={MapViewerScene} />
|
||||
<PhaserScene scene={GridViewerScene} />
|
||||
<PhaserScene scene={ShapeViewerScene} />
|
||||
<PhaserGame
|
||||
initialScene="IndexScene"
|
||||
config={{ width: 1920, height: 1080 }}
|
||||
>
|
||||
<PhaserScene sceneKey="IndexScene" scene={indexScene as any} />
|
||||
<PhaserScene
|
||||
sceneKey="MapViewerScene"
|
||||
scene={mapViewerScene as any}
|
||||
/>
|
||||
<PhaserScene
|
||||
sceneKey="GridViewerScene"
|
||||
scene={gridViewerScene as any}
|
||||
/>
|
||||
<PhaserScene
|
||||
sceneKey="ShapeViewerScene"
|
||||
scene={shapeViewerScene as any}
|
||||
/>
|
||||
<PhaserScene sceneKey="GameFlowScene" scene={gameFlowScene as any} />
|
||||
<PhaserScene
|
||||
sceneKey="PlaceholderEncounterScene"
|
||||
scene={placeholderEncounterScene as any}
|
||||
/>
|
||||
</PhaserGame>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
import Phaser from "phaser";
|
||||
import { MutableSignal } from "boardgame-core";
|
||||
import { spawnEffect } from "boardgame-phaser";
|
||||
import {
|
||||
type GridInventory,
|
||||
type InventoryItem,
|
||||
type GameItemMeta,
|
||||
type RunState,
|
||||
removeItemFromGrid,
|
||||
placeItem,
|
||||
} from "boardgame-core/samples/slay-the-spire-like";
|
||||
import { InventoryItemSpawner } from "./InventoryItemSpawner";
|
||||
import { GridBackgroundRenderer } from "./GridBackgroundRenderer";
|
||||
import { DragController } from "./DragController";
|
||||
import { LostItemManager } from "./LostItemManager";
|
||||
|
||||
export interface InventoryWidgetOptions {
|
||||
scene: Phaser.Scene;
|
||||
gameState: MutableSignal<RunState>;
|
||||
x: number;
|
||||
y: number;
|
||||
cellSize: number;
|
||||
gridGap?: number;
|
||||
isLocked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory widget using the Spawner pattern for reactive item rendering.
|
||||
*
|
||||
* Architecture:
|
||||
* - InventoryItemSpawner + spawnEffect: reactive spawn/despawn/update of item visuals
|
||||
* - GridBackgroundRenderer: static grid background drawn once
|
||||
* - DragController: event-driven drag logic via dragDropEventEffect
|
||||
* - LostItemManager: tracks items dropped outside valid placement
|
||||
*/
|
||||
export class InventoryWidget {
|
||||
private scene: Phaser.Scene;
|
||||
private gameState: MutableSignal<RunState>;
|
||||
private container: Phaser.GameObjects.Container;
|
||||
private cellSize: number;
|
||||
private gridGap: number;
|
||||
private gridX = 0;
|
||||
private gridY = 0;
|
||||
private isLocked: boolean;
|
||||
|
||||
private itemSpawner: InventoryItemSpawner;
|
||||
private backgroundRenderer: GridBackgroundRenderer;
|
||||
private dragController: DragController;
|
||||
private lostItemManager: LostItemManager;
|
||||
|
||||
private spawnDispose: (() => void) | null = null;
|
||||
private rightClickHandler!: (pointer: Phaser.Input.Pointer) => void;
|
||||
|
||||
constructor(options: InventoryWidgetOptions) {
|
||||
this.scene = options.scene;
|
||||
this.gameState = options.gameState;
|
||||
this.cellSize = options.cellSize;
|
||||
this.gridGap = options.gridGap ?? 2;
|
||||
this.isLocked = options.isLocked ?? false;
|
||||
|
||||
const inventory = this.getInventory();
|
||||
|
||||
this.container = this.scene.add.container(options.x, options.y);
|
||||
|
||||
// 1. Static grid background (drawn once)
|
||||
this.backgroundRenderer = new GridBackgroundRenderer({
|
||||
scene: this.scene,
|
||||
parentContainer: this.container,
|
||||
cellSize: this.cellSize,
|
||||
gridGap: this.gridGap,
|
||||
gridX: this.gridX,
|
||||
gridY: this.gridY,
|
||||
});
|
||||
this.backgroundRenderer.draw(inventory.width, inventory.height);
|
||||
|
||||
// 2. Reactive item spawner
|
||||
this.itemSpawner = new InventoryItemSpawner({
|
||||
scene: this.scene,
|
||||
gameState: this.gameState,
|
||||
parentContainer: this.container,
|
||||
cellSize: this.cellSize,
|
||||
gridGap: this.gridGap,
|
||||
gridX: this.gridX,
|
||||
gridY: this.gridY,
|
||||
isLocked: () => this.isLocked,
|
||||
isDragging: () => this.dragController.isDragging(),
|
||||
onItemDragStart: (itemId, item, itemContainer) => {
|
||||
this.handleItemDragStart(itemId, item, itemContainer);
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Drag controller
|
||||
this.dragController = new DragController({
|
||||
scene: this.scene,
|
||||
container: this.container,
|
||||
cellSize: this.cellSize,
|
||||
gridGap: this.gridGap,
|
||||
gridX: this.gridX,
|
||||
gridY: this.gridY,
|
||||
getInventory: () => this.getInventory(),
|
||||
getItemColor: (id) => this.itemSpawner.getItemColor(id),
|
||||
onPlaceItem: (item) => this.handlePlaceItem(item),
|
||||
onCreateLostItem: (id, shape, transform, meta, x, y) =>
|
||||
this.handleCreateLostItem(id, shape, transform, meta, x, y),
|
||||
});
|
||||
|
||||
// 4. Lost item manager
|
||||
this.lostItemManager = new LostItemManager({
|
||||
scene: this.scene,
|
||||
cellSize: this.cellSize,
|
||||
gridGap: this.gridGap,
|
||||
getItemColor: (id) => this.itemSpawner.getItemColor(id),
|
||||
onLostItemDragStart: (id, lostContainer) =>
|
||||
this.dragController.startLostItemDrag(
|
||||
id,
|
||||
this.getLostItemShape(id),
|
||||
this.getLostItemTransform(id),
|
||||
this.getLostItemMeta(id),
|
||||
lostContainer,
|
||||
),
|
||||
isDragging: () => this.dragController.isDragging(),
|
||||
});
|
||||
|
||||
// Activate the spawner effect (auto-cleans up on dispose)
|
||||
this.spawnDispose = spawnEffect(this.itemSpawner);
|
||||
|
||||
// Right-click rotation handler
|
||||
this.setupInput();
|
||||
|
||||
this.scene.events.once("shutdown", () => this.destroy());
|
||||
}
|
||||
|
||||
private getInventory(): GridInventory<GameItemMeta> {
|
||||
return this.gameState.value
|
||||
.inventory as unknown as GridInventory<GameItemMeta>;
|
||||
}
|
||||
|
||||
private handleItemDragStart(
|
||||
itemId: string,
|
||||
item: InventoryItem<GameItemMeta>,
|
||||
itemContainer: Phaser.GameObjects.Container,
|
||||
): void {
|
||||
// Mark as dragging FIRST so spawner excludes it from getData().
|
||||
// This prevents the spawner effect from destroying the container
|
||||
// when we later update the inventory state.
|
||||
this.itemSpawner.markDragging(itemId);
|
||||
|
||||
// Start drag session
|
||||
this.dragController.startDrag(itemId, item, itemContainer);
|
||||
}
|
||||
|
||||
private handlePlaceItem(item: InventoryItem<GameItemMeta>): void {
|
||||
this.gameState.produce((state) => {
|
||||
placeItem(state.inventory, item);
|
||||
});
|
||||
|
||||
// Unmark dragging so spawner picks it up on next effect run
|
||||
this.itemSpawner.unmarkDragging(item.id);
|
||||
}
|
||||
|
||||
private handleCreateLostItem(
|
||||
itemId: string,
|
||||
shape: InventoryItem<GameItemMeta>["shape"],
|
||||
transform: InventoryItem<GameItemMeta>["transform"],
|
||||
meta: InventoryItem<GameItemMeta>["meta"],
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
// Remove from inventory since it's dropped outside valid placement
|
||||
this.gameState.produce((state) => {
|
||||
removeItemFromGrid(state.inventory, itemId);
|
||||
});
|
||||
|
||||
this.lostItemManager.createLostItem(itemId, shape, transform, meta, x, y);
|
||||
|
||||
// Unmark dragging — item is now "lost" and managed by LostItemManager
|
||||
this.itemSpawner.unmarkDragging(itemId);
|
||||
}
|
||||
|
||||
private 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 {
|
||||
this.rightClickHandler = (pointer: Phaser.Input.Pointer) => {
|
||||
if (!this.dragController.isDragging()) return;
|
||||
if (pointer.button === 1) {
|
||||
this.dragController.rotateDraggedItem();
|
||||
}
|
||||
};
|
||||
|
||||
this.scene.input.on("pointerdown", this.rightClickHandler);
|
||||
}
|
||||
|
||||
public setLocked(locked: boolean): void {
|
||||
this.isLocked = locked;
|
||||
}
|
||||
|
||||
public getLostItems(): string[] {
|
||||
return this.lostItemManager.getLostItemIds();
|
||||
}
|
||||
|
||||
public clearLostItems(): void {
|
||||
this.lostItemManager.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-sync of item visuals with current inventory state.
|
||||
* With spawnEffect this is usually automatic, but useful after
|
||||
* external state changes that don't trigger the effect.
|
||||
*/
|
||||
public refresh(): void {
|
||||
// The spawner effect automatically re-syncs when gameState.value changes.
|
||||
// If immediate refresh is needed, reading the signal triggers the effect.
|
||||
void this.gameState.value;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.scene.input.off("pointerdown", this.rightClickHandler);
|
||||
|
||||
if (this.spawnDispose) {
|
||||
this.spawnDispose();
|
||||
this.spawnDispose = null;
|
||||
}
|
||||
|
||||
this.dragController.destroy();
|
||||
this.lostItemManager.destroy();
|
||||
this.backgroundRenderer.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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue