Compare commits
8 Commits
82df3f2a2f
...
368d9942d2
| Author | SHA1 | Date |
|---|---|---|
|
|
368d9942d2 | |
|
|
22817945cc | |
|
|
85bc4f5400 | |
|
|
033a8e4a40 | |
|
|
1d803dd219 | |
|
|
2d412eedb5 | |
|
|
f32aca2543 | |
|
|
34a7cff964 |
|
|
@ -32,8 +32,8 @@ packages/my-game/
|
||||||
|
|
||||||
### 1. ReactiveScene / GameHostScene
|
### 1. ReactiveScene / GameHostScene
|
||||||
Extend `ReactiveScene`(`packages\framework\src\scenes\ReactiveScene.ts`) to use reactive integration features.
|
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 `this.disposables` for auto-cleanup on shutdown.
|
||||||
|
- Use `initData` for context data passed from the `<PhaserScene/>` preact component.
|
||||||
|
|
||||||
### 2. Spawner Pattern
|
### 2. Spawner Pattern
|
||||||
Implement `Spawner<TData, TObj>` for data-driven objects.
|
Implement `Spawner<TData, TObj>` for data-driven objects.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import js from "@eslint/js";
|
import * as js from "@eslint";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
import importPlugin from "eslint-plugin-import";
|
import importPlugin from "eslint-plugin-import";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { signal, useSignal, useSignalEffect } from '@preact/signals';
|
import { signal, useSignal, useSignalEffect } from "@preact/signals";
|
||||||
import Phaser, { AUTO } from 'phaser';
|
import Phaser, { AUTO } from "phaser";
|
||||||
import { createContext } from 'preact';
|
import { createContext } from "preact";
|
||||||
import { useContext, useEffect, useRef } from 'preact/hooks';
|
import { useContext, useEffect, useRef } from "preact/hooks";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FadeScene as FadeSceneClass,
|
FadeScene as FadeSceneClass,
|
||||||
FADE_SCENE_KEY,
|
FADE_SCENE_KEY,
|
||||||
} from '../scenes/FadeScene';
|
} from "../scenes/FadeScene";
|
||||||
|
|
||||||
import type { ReactiveScene } from '../scenes';
|
import type { ReactiveScene } from "../scenes";
|
||||||
import type { ReadonlySignal } from '@preact/signals-core';
|
import type { ReadonlySignal } from "@preact/signals-core";
|
||||||
|
|
||||||
export interface SceneController {
|
export interface SceneController {
|
||||||
/** 启动场景(带淡入淡出过渡) */
|
/** 启动场景(带淡入淡出过渡) */
|
||||||
|
|
@ -34,8 +34,8 @@ export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
|
||||||
type: AUTO,
|
type: AUTO,
|
||||||
width: 560,
|
width: 560,
|
||||||
height: 560,
|
height: 560,
|
||||||
parent: 'phaser-container',
|
parent: "phaser-container",
|
||||||
backgroundColor: '#f9fafb',
|
backgroundColor: "#f9fafb",
|
||||||
scene: [],
|
scene: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ export function PhaserGame(props: PhaserGameProps) {
|
||||||
const sceneController: SceneController = {
|
const sceneController: SceneController = {
|
||||||
async launch(sceneKey: string) {
|
async launch(sceneKey: string) {
|
||||||
if (isTransitioning.value) {
|
if (isTransitioning.value) {
|
||||||
console.warn('SceneController: 正在进行场景切换');
|
console.warn("SceneController: 正在进行场景切换");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,12 +120,12 @@ export function PhaserGame(props: PhaserGameProps) {
|
||||||
},
|
},
|
||||||
async restart() {
|
async restart() {
|
||||||
if (isTransitioning.value) {
|
if (isTransitioning.value) {
|
||||||
console.warn('SceneController: 正在进行场景切换');
|
console.warn("SceneController: 正在进行场景切换");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentScene.value) {
|
if (!currentScene.value) {
|
||||||
console.warn('SceneController: 没有当前场景,无法 restart');
|
console.warn("SceneController: 没有当前场景,无法 restart");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,8 +192,8 @@ export function PhaserGame(props: PhaserGameProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PhaserSceneProps<TData = {}> {
|
export interface PhaserSceneProps<TData = {}> {
|
||||||
sceneKey: string;
|
sceneKey?: string;
|
||||||
scene: ReactiveScene<TData>;
|
scene: ReactiveScene<TData> | { new (): ReactiveScene<TData> };
|
||||||
data?: TData;
|
data?: TData;
|
||||||
children?: any;
|
children?: any;
|
||||||
}
|
}
|
||||||
|
|
@ -214,16 +214,18 @@ export function PhaserScene<TData = {}>(props: PhaserSceneProps<TData>) {
|
||||||
const game = ctx.game;
|
const game = ctx.game;
|
||||||
|
|
||||||
// 注册场景到 Phaser(但不启动)
|
// 注册场景到 Phaser(但不启动)
|
||||||
if (!game.scene.getScene(props.sceneKey)) {
|
const scene = "scene" in props.scene ? props.scene : new props.scene();
|
||||||
|
const sceneKey = props.sceneKey ?? scene.sys.settings.key;
|
||||||
|
if (!game.scene.getScene(sceneKey)) {
|
||||||
const initData = {
|
const initData = {
|
||||||
...props.data,
|
...props.data,
|
||||||
phaserGame: phaserGameSignal,
|
phaserGame: phaserGameSignal,
|
||||||
sceneController: ctx.sceneController,
|
sceneController: ctx.sceneController,
|
||||||
};
|
};
|
||||||
game.scene.add(props.sceneKey, props.scene, false, initData);
|
game.scene.add(sceneKey, props.scene, false, initData);
|
||||||
}
|
}
|
||||||
|
|
||||||
sceneSignal.value = props.scene;
|
sceneSignal.value = scene;
|
||||||
registered.current = true;
|
registered.current = true;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,13 @@ export const MENU_BUTTON = {
|
||||||
height: 40,
|
height: 40,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// Text positioning
|
||||||
|
export const TEXT_POSITION = {
|
||||||
|
titleX: 40,
|
||||||
|
titleY: 40,
|
||||||
|
infoX: 40,
|
||||||
|
} as const;
|
||||||
|
|
||||||
// Animation durations (in ms)
|
// Animation durations (in ms)
|
||||||
export const ANIMATIONS = {
|
export const ANIMATIONS = {
|
||||||
pawnSpawn: 300,
|
pawnSpawn: 300,
|
||||||
|
|
@ -139,6 +146,23 @@ export const CARD_GRID = {
|
||||||
gridSize: 5,
|
gridSize: 5,
|
||||||
} as const;
|
} 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
|
// Helper function to convert board coordinates to screen coordinates
|
||||||
export function boardToScreen(
|
export function boardToScreen(
|
||||||
boardX: number,
|
boardX: number,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import type { Card } from "@/game/onitama";
|
import type { Card } from "@/game/onitama";
|
||||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||||
|
|
||||||
import { CARD_WIDTH, CARD_HEIGHT, COLORS, FONTS, CARD_GRID } from "@/config";
|
import {
|
||||||
|
CARD_WIDTH,
|
||||||
|
CARD_HEIGHT,
|
||||||
|
COLORS,
|
||||||
|
FONTS,
|
||||||
|
CARD_GRID,
|
||||||
|
VISUAL,
|
||||||
|
} from "@/config";
|
||||||
|
|
||||||
export interface CardRenderOptions {
|
export interface CardRenderOptions {
|
||||||
card: Card;
|
card: Card;
|
||||||
|
|
@ -28,12 +35,17 @@ export class CardRenderer {
|
||||||
// Create background rectangle
|
// Create background rectangle
|
||||||
const bg = this.scene.add
|
const bg = this.scene.add
|
||||||
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, COLORS.cardBg, 1)
|
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, COLORS.cardBg, 1)
|
||||||
.setStrokeStyle(2, COLORS.cardStroke);
|
.setStrokeStyle(VISUAL.cardStrokeWidth, COLORS.cardStroke);
|
||||||
container.add(bg);
|
container.add(bg);
|
||||||
|
|
||||||
// Create title text
|
// Create title text
|
||||||
const title = this.scene.add
|
const title = this.scene.add
|
||||||
.text(0, -CARD_HEIGHT / 2 + 16, card.id, FONTS.cardTitle)
|
.text(
|
||||||
|
0,
|
||||||
|
-CARD_HEIGHT / 2 + VISUAL.cardTitleOffset,
|
||||||
|
card.id,
|
||||||
|
FONTS.cardTitle,
|
||||||
|
)
|
||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
container.add(title);
|
container.add(title);
|
||||||
|
|
||||||
|
|
@ -42,7 +54,12 @@ export class CardRenderer {
|
||||||
|
|
||||||
// Create starting player text
|
// Create starting player text
|
||||||
const playerText = this.scene.add
|
const playerText = this.scene.add
|
||||||
.text(0, CARD_HEIGHT / 2 - 16, card.startingPlayer, FONTS.cardPlayer)
|
.text(
|
||||||
|
0,
|
||||||
|
CARD_HEIGHT / 2 - VISUAL.cardPlayerOffset,
|
||||||
|
card.startingPlayer,
|
||||||
|
FONTS.cardPlayer,
|
||||||
|
)
|
||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
container.add(playerText);
|
container.add(playerText);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CELL_SIZE,
|
|
||||||
COLORS,
|
COLORS,
|
||||||
|
VISUAL,
|
||||||
createHighlightInnerPulseTween,
|
createHighlightInnerPulseTween,
|
||||||
createHighlightOuterPulseTween,
|
createHighlightOuterPulseTween,
|
||||||
} from "@/config";
|
} from "@/config";
|
||||||
|
|
@ -37,7 +37,7 @@ export class HighlightRenderer {
|
||||||
const outerCircle = this.scene.add.circle(
|
const outerCircle = this.scene.add.circle(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
CELL_SIZE / 3,
|
VISUAL.highlightOuterRadius,
|
||||||
COLORS.black,
|
COLORS.black,
|
||||||
0.2,
|
0.2,
|
||||||
);
|
);
|
||||||
|
|
@ -47,7 +47,7 @@ export class HighlightRenderer {
|
||||||
const innerCircle = this.scene.add.circle(
|
const innerCircle = this.scene.add.circle(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
CELL_SIZE / 4,
|
VISUAL.highlightInnerRadius,
|
||||||
COLORS.black,
|
COLORS.black,
|
||||||
0.4,
|
0.4,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||||
|
|
||||||
import { CELL_SIZE, COLORS, FONTS } from "@/config";
|
import { CELL_SIZE, COLORS, FONTS, VISUAL } from "@/config";
|
||||||
|
|
||||||
export type PawnType = "master" | "student";
|
export type PawnType = "master" | "student";
|
||||||
export type PawnOwner = "red" | "black";
|
export type PawnOwner = "red" | "black";
|
||||||
|
|
@ -31,8 +31,8 @@ export class PawnRenderer {
|
||||||
// Create background circle
|
// Create background circle
|
||||||
const bgColor = owner === "red" ? COLORS.red : COLORS.black;
|
const bgColor = owner === "red" ? COLORS.red : COLORS.black;
|
||||||
const circle = this.scene.add
|
const circle = this.scene.add
|
||||||
.circle(0, 0, CELL_SIZE / 3, bgColor, 1)
|
.circle(0, 0, VISUAL.pawnRadius, bgColor, 1)
|
||||||
.setStrokeStyle(2, COLORS.pawnStroke);
|
.setStrokeStyle(VISUAL.pawnStrokeWidth, COLORS.pawnStroke);
|
||||||
container.add(circle);
|
container.add(circle);
|
||||||
|
|
||||||
// Create label text
|
// Create label text
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { OnitamaScene } from "@/scenes/OnitamaScene";
|
||||||
import {
|
import {
|
||||||
CELL_SIZE,
|
CELL_SIZE,
|
||||||
COLORS,
|
COLORS,
|
||||||
|
VISUAL,
|
||||||
createSelectionShowTween,
|
createSelectionShowTween,
|
||||||
createSelectionRingPulseTween,
|
createSelectionRingPulseTween,
|
||||||
createSelectionHideTween,
|
createSelectionHideTween,
|
||||||
|
|
@ -31,8 +32,21 @@ export class SelectionRenderer {
|
||||||
parent: Phaser.GameObjects.Container | Phaser.GameObjects.GameObject,
|
parent: Phaser.GameObjects.Container | Phaser.GameObjects.GameObject,
|
||||||
): Phaser.GameObjects.Arc {
|
): Phaser.GameObjects.Arc {
|
||||||
const ring = this.scene.add
|
const ring = this.scene.add
|
||||||
.arc(0, 0, CELL_SIZE / 3 + 5, 0, 360, false, COLORS.highlight, 0)
|
.arc(
|
||||||
.setStrokeStyle(3, COLORS.highlightStroke, 1)
|
0,
|
||||||
|
0,
|
||||||
|
VISUAL.pawnRadius + VISUAL.selectionRingOffset,
|
||||||
|
0,
|
||||||
|
360,
|
||||||
|
false,
|
||||||
|
COLORS.highlight,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.setStrokeStyle(
|
||||||
|
VISUAL.selectionRingStrokeWidth,
|
||||||
|
COLORS.highlightStroke,
|
||||||
|
1,
|
||||||
|
)
|
||||||
.setAlpha(0);
|
.setAlpha(0);
|
||||||
|
|
||||||
// Add to parent at index 0 (behind other visuals)
|
// Add to parent at index 0 (behind other visuals)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
COLORS,
|
COLORS,
|
||||||
FONTS,
|
FONTS,
|
||||||
MENU_BUTTON,
|
MENU_BUTTON,
|
||||||
|
TEXT_POSITION,
|
||||||
|
VISUAL,
|
||||||
getBoardCenter,
|
getBoardCenter,
|
||||||
getCardLabelPosition,
|
getCardLabelPosition,
|
||||||
colorToStr,
|
colorToStr,
|
||||||
|
|
@ -79,7 +81,12 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Info text
|
// Info text
|
||||||
this.infoText = this.add.text(40, BOARD_OFFSET.y, "", FONTS.info);
|
this.infoText = this.add.text(
|
||||||
|
TEXT_POSITION.infoX,
|
||||||
|
BOARD_OFFSET.y,
|
||||||
|
"",
|
||||||
|
FONTS.info,
|
||||||
|
);
|
||||||
|
|
||||||
// Update info text when UI state changes
|
// Update info text when UI state changes
|
||||||
this.addEffect(() => {
|
this.addEffect(() => {
|
||||||
|
|
@ -149,7 +156,12 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||||
|
|
||||||
g.strokePath();
|
g.strokePath();
|
||||||
|
|
||||||
this.add.text(40, 40, "Onitama", FONTS.title);
|
this.add.text(
|
||||||
|
TEXT_POSITION.titleX,
|
||||||
|
TEXT_POSITION.titleY,
|
||||||
|
"Onitama",
|
||||||
|
FONTS.title,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupInput(): void {
|
private setupInput(): void {
|
||||||
|
|
@ -288,7 +300,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
|
||||||
boardWidth,
|
boardWidth,
|
||||||
boardHeight,
|
boardHeight,
|
||||||
COLORS.overlayBg,
|
COLORS.overlayBg,
|
||||||
0.6,
|
VISUAL.overlayAlpha,
|
||||||
)
|
)
|
||||||
.setInteractive({ useHandCursor: true });
|
.setInteractive({ useHandCursor: true });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import type { Spawner } from "boardgame-phaser";
|
||||||
import {
|
import {
|
||||||
CARD_WIDTH,
|
CARD_WIDTH,
|
||||||
CARD_HEIGHT,
|
CARD_HEIGHT,
|
||||||
|
COLORS,
|
||||||
|
VISUAL,
|
||||||
getCardPosition,
|
getCardPosition,
|
||||||
createCardMoveTween,
|
createCardMoveTween,
|
||||||
createCardRotateTween,
|
createCardRotateTween,
|
||||||
|
|
@ -102,7 +104,7 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
||||||
.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0)
|
.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0)
|
||||||
.setStrokeStyle(lineWidth, color)
|
.setStrokeStyle(lineWidth, color)
|
||||||
.setAlpha(0)
|
.setAlpha(0)
|
||||||
.setDepth(-1);
|
.setDepth(VISUAL.cardBackgroundDepth);
|
||||||
this.highlightRect = rect;
|
this.highlightRect = rect;
|
||||||
this.addAt(this.highlightRect, 0);
|
this.addAt(this.highlightRect, 0);
|
||||||
|
|
||||||
|
|
@ -161,7 +163,7 @@ export class CardContainer extends Phaser.GameObjects.Container {
|
||||||
// 创建一个 effect 来持续监听高亮状态变化
|
// 创建一个 effect 来持续监听高亮状态变化
|
||||||
const dispose = effect(() => {
|
const dispose = effect(() => {
|
||||||
if (scene.uiState.value.selectedCard === this._cardId) {
|
if (scene.uiState.value.selectedCard === this._cardId) {
|
||||||
this.highlight(0xfbbf24, 3);
|
this.highlight(COLORS.highlight, VISUAL.selectionRingStrokeWidth);
|
||||||
} else {
|
} else {
|
||||||
this.unhighlight();
|
this.unhighlight();
|
||||||
}
|
}
|
||||||
|
|
@ -276,7 +278,7 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
|
||||||
// 设置悬停效果
|
// 设置悬停效果
|
||||||
container.on("pointerover", () => {
|
container.on("pointerover", () => {
|
||||||
if (this.scene.uiState.value.selectedCard !== data.cardId) {
|
if (this.scene.uiState.value.selectedCard !== data.cardId) {
|
||||||
container.setAlpha(0.8);
|
container.setAlpha(VISUAL.cardDisabledAlpha);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Spawner } from "boardgame-phaser";
|
||||||
import {
|
import {
|
||||||
boardToScreen,
|
boardToScreen,
|
||||||
CELL_SIZE,
|
CELL_SIZE,
|
||||||
|
VISUAL,
|
||||||
createHighlightSpawnTween,
|
createHighlightSpawnTween,
|
||||||
createHighlightDespawnTween,
|
createHighlightDespawnTween,
|
||||||
createHighlightClickFeedbackTween,
|
createHighlightClickFeedbackTween,
|
||||||
|
|
@ -83,7 +84,7 @@ export class HighlightSpawner implements Spawner<
|
||||||
this.renderer.render(container, { x: data.x, y: data.y });
|
this.renderer.render(container, { x: data.x, y: data.y });
|
||||||
|
|
||||||
// 设置交互区域
|
// 设置交互区域
|
||||||
const hitArea = new Geom.Circle(0, 0, CELL_SIZE / 3);
|
const hitArea = new Geom.Circle(0, 0, VISUAL.highlightHitAreaRadius);
|
||||||
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
|
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
|
||||||
if (container.input) {
|
if (container.input) {
|
||||||
container.input.cursor = "pointer";
|
container.input.cursor = "pointer";
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
* All magic numbers should be defined here and imported where needed.
|
* All magic numbers should be defined here and imported where needed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { PhaserGameProps } from "boardgame-phaser";
|
||||||
|
|
||||||
// ── Map Layout ──────────────────────────────────────────────────────────────
|
// ── Map Layout ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const MAP_CONFIG = {
|
export const MAP_CONFIG = {
|
||||||
|
|
@ -83,9 +85,9 @@ export const UI_CONFIG = {
|
||||||
/** Button border color */
|
/** Button border color */
|
||||||
BUTTON_BORDER: 0x7777aa,
|
BUTTON_BORDER: 0x7777aa,
|
||||||
/** Button text color */
|
/** Button text color */
|
||||||
BUTTON_TEXT_COLOR: '#ffffff',
|
BUTTON_TEXT_COLOR: "#ffffff",
|
||||||
/** Button font size */
|
/** Button font size */
|
||||||
BUTTON_FONT_SIZE: '16px',
|
BUTTON_FONT_SIZE: "16px",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ── Colors ──────────────────────────────────────────────────────────────────
|
// ── Colors ──────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -102,14 +104,14 @@ export const NODE_COLORS = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const NODE_LABELS = {
|
export const NODE_LABELS = {
|
||||||
start: '起点',
|
start: "起点",
|
||||||
end: '终点',
|
end: "终点",
|
||||||
minion: '战斗',
|
minion: "战斗",
|
||||||
elite: '精英',
|
elite: "精英",
|
||||||
event: '事件',
|
event: "事件",
|
||||||
camp: '营地',
|
camp: "营地",
|
||||||
shop: '商店',
|
shop: "商店",
|
||||||
curio: '奇遇',
|
curio: "奇遇",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ITEM_COLORS = [
|
export const ITEM_COLORS = [
|
||||||
|
|
@ -118,5 +120,20 @@ export const ITEM_COLORS = [
|
||||||
|
|
||||||
// ── Positive/Negative Effects (for buff icons) ──────────────────────────────
|
// ── Positive/Negative Effects (for buff icons) ──────────────────────────────
|
||||||
|
|
||||||
export const POSITIVE_EFFECTS = new Set(['block', 'strength', 'dexterity', 'regen']);
|
export const POSITIVE_EFFECTS = new Set([
|
||||||
export const NEGATIVE_EFFECTS = new Set(['weak', 'vulnerable', 'frail', 'poison']);
|
"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,
|
||||||
|
};
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { h } from 'preact';
|
import { GameUI } from "boardgame-phaser";
|
||||||
import { GameUI } from 'boardgame-phaser';
|
import "./style.css";
|
||||||
import './style.css';
|
|
||||||
import App from "@/ui/App";
|
import App from "@/ui/App";
|
||||||
|
|
||||||
const ui = new GameUI({
|
const ui = new GameUI({
|
||||||
container: document.getElementById('ui-root')!,
|
container: "ui-root",
|
||||||
root: <App />,
|
root: <App />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,439 +0,0 @@
|
||||||
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,7 +1,6 @@
|
||||||
import Phaser from "phaser";
|
|
||||||
import { ReactiveScene } from "boardgame-phaser";
|
import { ReactiveScene } from "boardgame-phaser";
|
||||||
import { createButton } from "@/utils/createButton";
|
import { createButton } from "@/utils/createButton";
|
||||||
import { GRID_CONFIG, UI_CONFIG, ITEM_COLORS } from "@/config";
|
import { GRID_CONFIG, ITEM_COLORS } from "@/config";
|
||||||
import {
|
import {
|
||||||
createGridInventory,
|
createGridInventory,
|
||||||
placeItem,
|
placeItem,
|
||||||
|
|
@ -12,6 +11,7 @@ import {
|
||||||
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 { SceneKey } from "./types";
|
||||||
|
|
||||||
export class GridViewerScene extends ReactiveScene {
|
export class GridViewerScene extends ReactiveScene {
|
||||||
private inventory: GridInventory<GameItemMeta>;
|
private inventory: GridInventory<GameItemMeta>;
|
||||||
|
|
@ -57,7 +57,7 @@ export class GridViewerScene extends ReactiveScene {
|
||||||
}
|
}
|
||||||
|
|
||||||
private placeSampleItems(): void {
|
private placeSampleItems(): void {
|
||||||
const items = data.desert.items;
|
const items = data.desert.getItems();
|
||||||
const sampleItems = [
|
const sampleItems = [
|
||||||
{ index: 0, x: 0, y: 0 },
|
{ index: 0, x: 0, y: 0 },
|
||||||
{ index: 3, x: 3, y: 0 },
|
{ index: 3, x: 3, y: 0 },
|
||||||
|
|
@ -207,7 +207,7 @@ export class GridViewerScene extends ReactiveScene {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 40,
|
y: 40,
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
await this.sceneController.launch("IndexScene");
|
await this.sceneController.launch(SceneKey.IndexScene);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -242,7 +242,7 @@ export class GridViewerScene extends ReactiveScene {
|
||||||
GRID_CONFIG.WIDTH,
|
GRID_CONFIG.WIDTH,
|
||||||
GRID_CONFIG.HEIGHT,
|
GRID_CONFIG.HEIGHT,
|
||||||
);
|
);
|
||||||
const items = data.desert.items;
|
const items = data.desert.getItems();
|
||||||
|
|
||||||
let itemIndex = 0;
|
let itemIndex = 0;
|
||||||
for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) {
|
for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import Phaser from "phaser";
|
||||||
import { ReactiveScene } from "boardgame-phaser";
|
import { ReactiveScene } from "boardgame-phaser";
|
||||||
import { createButton } from "@/utils/createButton";
|
import { createButton } from "@/utils/createButton";
|
||||||
import { UI_CONFIG } from "@/config";
|
import { UI_CONFIG } from "@/config";
|
||||||
|
import { SceneKey } from "./types";
|
||||||
|
|
||||||
export class IndexScene extends ReactiveScene {
|
export class IndexScene extends ReactiveScene {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -32,15 +33,22 @@ export class IndexScene extends ReactiveScene {
|
||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
const buttons = [
|
const buttons: {
|
||||||
{ label: "开始游戏", scene: "GameFlowScene", y: centerY - 70 },
|
label: string;
|
||||||
{ label: "Map Viewer", scene: "MapViewerScene", y: centerY },
|
scene: SceneKey;
|
||||||
|
y: number;
|
||||||
|
}[] = [
|
||||||
|
{ label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY },
|
||||||
{
|
{
|
||||||
label: "Grid Inventory Viewer",
|
label: "Grid Inventory Viewer",
|
||||||
scene: "GridViewerScene",
|
scene: SceneKey.GridViewerScene,
|
||||||
y: centerY + 70,
|
y: centerY + 70,
|
||||||
},
|
},
|
||||||
{ label: "Shape Viewer", scene: "ShapeViewerScene", y: centerY + 140 },
|
{
|
||||||
|
label: "Shape Viewer",
|
||||||
|
scene: SceneKey.ShapeViewerScene,
|
||||||
|
y: centerY + 140,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const btn of buttons) {
|
for (const btn of buttons) {
|
||||||
|
|
@ -50,7 +58,7 @@ export class IndexScene extends ReactiveScene {
|
||||||
|
|
||||||
private createButton(
|
private createButton(
|
||||||
label: string,
|
label: string,
|
||||||
targetScene: string,
|
targetScene: SceneKey,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
): void {
|
): void {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
type MapNode,
|
type MapNode,
|
||||||
type MapNodeType,
|
type MapNodeType,
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { SceneKey } from "./types";
|
||||||
|
|
||||||
export class MapViewerScene extends ReactiveScene {
|
export class MapViewerScene extends ReactiveScene {
|
||||||
private map: PointCrawlMap | null = null;
|
private map: PointCrawlMap | null = null;
|
||||||
|
|
@ -57,7 +58,7 @@ export class MapViewerScene extends ReactiveScene {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 40,
|
y: 40,
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
await this.sceneController.launch("IndexScene");
|
await this.sceneController.launch(SceneKey.IndexScene);
|
||||||
},
|
},
|
||||||
depth: 100,
|
depth: 100,
|
||||||
});
|
});
|
||||||
|
|
@ -113,7 +114,7 @@ export class MapViewerScene extends ReactiveScene {
|
||||||
|
|
||||||
private drawMap(): void {
|
private drawMap(): void {
|
||||||
const rng = createRNG(this.seed);
|
const rng = createRNG(this.seed);
|
||||||
this.map = generatePointCrawlMap(rng, data.desert.encounters);
|
this.map = generatePointCrawlMap(rng, data.desert.getEncounters());
|
||||||
|
|
||||||
const { width, height } = this.scale;
|
const { width, height } = this.scale;
|
||||||
const {
|
const {
|
||||||
|
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
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 { ReactiveScene } from "boardgame-phaser";
|
||||||
import { createButton } from "@/utils/createButton";
|
import { createButton } from "@/utils/createButton";
|
||||||
import { UI_CONFIG, SHAPE_CONFIG, NODE_LABELS, NODE_COLORS } from "@/config";
|
import { SHAPE_CONFIG } from "@/config";
|
||||||
import {
|
import {
|
||||||
parseShapeString,
|
parseShapeString,
|
||||||
data,
|
data,
|
||||||
type ParsedShape,
|
type ParsedShape,
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { SceneKey } from "./types";
|
||||||
|
|
||||||
export class ShapeViewerScene extends ReactiveScene {
|
export class ShapeViewerScene extends ReactiveScene {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -17,6 +17,19 @@ export class ShapeViewerScene extends ReactiveScene {
|
||||||
super.create();
|
super.create();
|
||||||
this.drawShapeViewer();
|
this.drawShapeViewer();
|
||||||
this.createControls();
|
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 {
|
private drawShapeViewer(): void {
|
||||||
|
|
@ -40,7 +53,7 @@ export class ShapeViewerScene extends ReactiveScene {
|
||||||
const startY = 80;
|
const startY = 80;
|
||||||
const { SPACING_X, SPACING_Y, ITEMS_PER_ROW, MAX_ITEMS } = SHAPE_CONFIG;
|
const { SPACING_X, SPACING_Y, ITEMS_PER_ROW, MAX_ITEMS } = SHAPE_CONFIG;
|
||||||
|
|
||||||
const itemsToShow = data.desert.items.slice(0, MAX_ITEMS);
|
const itemsToShow = data.desert.getItems().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];
|
||||||
|
|
@ -145,7 +158,7 @@ export class ShapeViewerScene extends ReactiveScene {
|
||||||
x: 100,
|
x: 100,
|
||||||
y: height - 40,
|
y: height - 40,
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
await this.sceneController.launch("IndexScene");
|
await this.sceneController.launch(SceneKey.IndexScene);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export enum SceneKey {
|
||||||
|
GridViewerScene = "GridViewerScene",
|
||||||
|
IndexScene = "IndexScene",
|
||||||
|
MapViewerScene = "MapViewerScene",
|
||||||
|
ShapeViewerScene = "ShapeViewerScene",
|
||||||
|
}
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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,53 +1,19 @@
|
||||||
import { h } from "preact";
|
|
||||||
import { PhaserGame, PhaserScene } from "boardgame-phaser";
|
import { PhaserGame, PhaserScene } from "boardgame-phaser";
|
||||||
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 { GAME_CONFIG } from "@/config";
|
||||||
import { PlaceholderEncounterScene } from "@/scenes/PlaceholderEncounterScene";
|
|
||||||
import { createGameState } from "@/state/gameState";
|
|
||||||
|
|
||||||
// 全局游戏状态单例
|
|
||||||
const gameState = createGameState();
|
|
||||||
|
|
||||||
export default function App() {
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-screen">
|
<div className="flex flex-col h-screen">
|
||||||
<div className="flex-1 flex relative justify-center items-center">
|
<div className="flex-1 flex relative justify-center items-center">
|
||||||
<PhaserGame
|
<PhaserGame initialScene="IndexScene" config={GAME_CONFIG}>
|
||||||
initialScene="IndexScene"
|
<PhaserScene scene={IndexScene} />
|
||||||
config={{ width: 1920, height: 1080 }}
|
<PhaserScene scene={MapViewerScene} />
|
||||||
>
|
<PhaserScene scene={GridViewerScene} />
|
||||||
<PhaserScene sceneKey="IndexScene" scene={indexScene as any} />
|
<PhaserScene scene={ShapeViewerScene} />
|
||||||
<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>
|
</PhaserGame>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,477 +0,0 @@
|
||||||
import Phaser from "phaser";
|
|
||||||
import {
|
|
||||||
type InventoryItem,
|
|
||||||
type GameItemMeta,
|
|
||||||
type GridInventory,
|
|
||||||
validatePlacement,
|
|
||||||
transformShape,
|
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
|
||||||
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
|
|
||||||
import { DisposableBag } from "boardgame-phaser";
|
|
||||||
|
|
||||||
export interface DragSession {
|
|
||||||
itemId: string;
|
|
||||||
itemShape: InventoryItem<GameItemMeta>["shape"];
|
|
||||||
itemTransform: InventoryItem<GameItemMeta>["transform"];
|
|
||||||
itemMeta: InventoryItem<GameItemMeta>["meta"];
|
|
||||||
ghostContainer: Phaser.GameObjects.Container;
|
|
||||||
previewGraphics: Phaser.GameObjects.Graphics;
|
|
||||||
disposables: DisposableBag;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DragControllerOptions {
|
|
||||||
scene: Phaser.Scene;
|
|
||||||
container: Phaser.GameObjects.Container;
|
|
||||||
cellSize: number;
|
|
||||||
gridGap: number;
|
|
||||||
gridX: number;
|
|
||||||
gridY: number;
|
|
||||||
getInventory: () => GridInventory<GameItemMeta>;
|
|
||||||
getItemColor: (itemId: string) => number;
|
|
||||||
onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
|
|
||||||
onCreateLostItem: (
|
|
||||||
itemId: string,
|
|
||||||
shape: InventoryItem<GameItemMeta>["shape"],
|
|
||||||
transform: InventoryItem<GameItemMeta>["transform"],
|
|
||||||
meta: InventoryItem<GameItemMeta>["meta"],
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event-driven drag controller using dragDropEventEffect from boardgame-phaser.
|
|
||||||
* Manages ghost visuals, placement preview, rotation, and validation.
|
|
||||||
*/
|
|
||||||
export class DragController {
|
|
||||||
private scene: Phaser.Scene;
|
|
||||||
private container: Phaser.GameObjects.Container;
|
|
||||||
private cellSize: number;
|
|
||||||
private gridGap: number;
|
|
||||||
private gridX: number;
|
|
||||||
private gridY: number;
|
|
||||||
private getInventory: () => GridInventory<GameItemMeta>;
|
|
||||||
private getItemColor: (itemId: string) => number;
|
|
||||||
private onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
|
|
||||||
private onCreateLostItem: (
|
|
||||||
itemId: string,
|
|
||||||
shape: InventoryItem<GameItemMeta>["shape"],
|
|
||||||
transform: InventoryItem<GameItemMeta>["transform"],
|
|
||||||
meta: InventoryItem<GameItemMeta>["meta"],
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
private activeSession: DragSession | null = null;
|
|
||||||
|
|
||||||
constructor(options: DragControllerOptions) {
|
|
||||||
this.scene = options.scene;
|
|
||||||
this.container = options.container;
|
|
||||||
this.cellSize = options.cellSize;
|
|
||||||
this.gridGap = options.gridGap;
|
|
||||||
this.gridX = options.gridX;
|
|
||||||
this.gridY = options.gridY;
|
|
||||||
this.getInventory = options.getInventory;
|
|
||||||
this.getItemColor = options.getItemColor;
|
|
||||||
this.onPlaceItem = options.onPlaceItem;
|
|
||||||
this.onCreateLostItem = options.onCreateLostItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a drag session for an inventory item.
|
|
||||||
* Uses dragDropEventEffect for pointer tracking and event emission.
|
|
||||||
*/
|
|
||||||
startDrag(
|
|
||||||
itemId: string,
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
itemContainer: Phaser.GameObjects.Container,
|
|
||||||
): () => void {
|
|
||||||
const cells = this.getItemCells(item);
|
|
||||||
const firstCell = cells[0];
|
|
||||||
const worldX =
|
|
||||||
this.container.x +
|
|
||||||
this.gridX +
|
|
||||||
firstCell.x * (this.cellSize + this.gridGap);
|
|
||||||
const worldY =
|
|
||||||
this.container.y +
|
|
||||||
this.gridY +
|
|
||||||
firstCell.y * (this.cellSize + this.gridGap);
|
|
||||||
|
|
||||||
const ghostContainer = this.createGhostContainer(
|
|
||||||
worldX,
|
|
||||||
worldY,
|
|
||||||
item.shape,
|
|
||||||
item.transform,
|
|
||||||
this.getItemColor(itemId),
|
|
||||||
);
|
|
||||||
const previewGraphics = this.scene.add
|
|
||||||
.graphics()
|
|
||||||
.setDepth(999)
|
|
||||||
.setAlpha(0.5);
|
|
||||||
|
|
||||||
const disposables = new DisposableBag();
|
|
||||||
const session: DragSession = {
|
|
||||||
itemId,
|
|
||||||
itemShape: item.shape,
|
|
||||||
itemTransform: {
|
|
||||||
...item.transform,
|
|
||||||
offset: { ...item.transform.offset },
|
|
||||||
},
|
|
||||||
itemMeta: item.meta,
|
|
||||||
ghostContainer,
|
|
||||||
previewGraphics,
|
|
||||||
disposables,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.activeSession = session;
|
|
||||||
|
|
||||||
// Set up drag-drop event handling via framework utility
|
|
||||||
const disposeDrag = dragDropEventEffect(
|
|
||||||
itemContainer as Phaser.GameObjects.GameObject,
|
|
||||||
disposables,
|
|
||||||
);
|
|
||||||
|
|
||||||
itemContainer.on("dragstart", () => {
|
|
||||||
ghostContainer.setVisible(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
itemContainer.on("dragmove", () => {
|
|
||||||
this.handleDragMove(session);
|
|
||||||
});
|
|
||||||
|
|
||||||
itemContainer.on("dragend", () => {
|
|
||||||
this.handleDragEnd(session);
|
|
||||||
disposeDrag();
|
|
||||||
this.activeSession = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposeDrag();
|
|
||||||
this.destroySession(session);
|
|
||||||
this.activeSession = null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a drag session for a lost item.
|
|
||||||
*/
|
|
||||||
startLostItemDrag(
|
|
||||||
itemId: string,
|
|
||||||
shape: InventoryItem<GameItemMeta>["shape"],
|
|
||||||
transform: InventoryItem<GameItemMeta>["transform"],
|
|
||||||
meta: InventoryItem<GameItemMeta>["meta"],
|
|
||||||
lostContainer: Phaser.GameObjects.Container,
|
|
||||||
): () => void {
|
|
||||||
const pointer = this.scene.input.activePointer;
|
|
||||||
const ghostContainer = this.createGhostContainer(
|
|
||||||
pointer.x,
|
|
||||||
pointer.y,
|
|
||||||
shape,
|
|
||||||
transform,
|
|
||||||
this.getItemColor(itemId),
|
|
||||||
);
|
|
||||||
const previewGraphics = this.scene.add
|
|
||||||
.graphics()
|
|
||||||
.setDepth(999)
|
|
||||||
.setAlpha(0.5);
|
|
||||||
|
|
||||||
const disposables = new DisposableBag();
|
|
||||||
const session: DragSession = {
|
|
||||||
itemId,
|
|
||||||
itemShape: shape,
|
|
||||||
itemTransform: { ...transform, offset: { ...transform.offset } },
|
|
||||||
itemMeta: meta,
|
|
||||||
ghostContainer,
|
|
||||||
previewGraphics,
|
|
||||||
disposables,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.activeSession = session;
|
|
||||||
|
|
||||||
const disposeDrag = dragDropEventEffect(
|
|
||||||
lostContainer as Phaser.GameObjects.GameObject,
|
|
||||||
disposables,
|
|
||||||
);
|
|
||||||
|
|
||||||
lostContainer.on("dragstart", () => {
|
|
||||||
ghostContainer.setVisible(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
lostContainer.on("dragmove", () => {
|
|
||||||
this.handleDragMove(session);
|
|
||||||
});
|
|
||||||
|
|
||||||
lostContainer.on("dragend", () => {
|
|
||||||
this.handleDragEnd(session);
|
|
||||||
disposeDrag();
|
|
||||||
this.activeSession = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposeDrag();
|
|
||||||
this.destroySession(session);
|
|
||||||
this.activeSession = null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotate the currently dragged item by 90 degrees.
|
|
||||||
*/
|
|
||||||
rotateDraggedItem(): void {
|
|
||||||
if (!this.activeSession) return;
|
|
||||||
|
|
||||||
const currentRotation =
|
|
||||||
(this.activeSession.itemTransform.rotation + 90) % 360;
|
|
||||||
this.activeSession.itemTransform = {
|
|
||||||
...this.activeSession.itemTransform,
|
|
||||||
rotation: currentRotation,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.updateGhostVisuals(this.activeSession);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if currently dragging.
|
|
||||||
*/
|
|
||||||
isDragging(): boolean {
|
|
||||||
return this.activeSession !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the ID of the item being dragged, or null.
|
|
||||||
*/
|
|
||||||
getDraggedItemId(): string | null {
|
|
||||||
return this.activeSession?.itemId ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current position of the dragged ghost container.
|
|
||||||
*/
|
|
||||||
getDraggedItemPosition(): { x: number; y: number } {
|
|
||||||
if (!this.activeSession) return { x: 0, y: 0 };
|
|
||||||
return {
|
|
||||||
x: this.activeSession.ghostContainer.x,
|
|
||||||
y: this.activeSession.ghostContainer.y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up active session and destroy all visuals.
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
if (this.activeSession) {
|
|
||||||
this.destroySession(this.activeSession);
|
|
||||||
this.activeSession = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createGhostContainer(
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
shape: InventoryItem<GameItemMeta>["shape"],
|
|
||||||
transform: InventoryItem<GameItemMeta>["transform"],
|
|
||||||
color: number,
|
|
||||||
): Phaser.GameObjects.Container {
|
|
||||||
const ghostContainer = this.scene.add.container(x, y).setDepth(1000);
|
|
||||||
const ghostGraphics = this.scene.add.graphics();
|
|
||||||
|
|
||||||
const cells = transformShape(shape, transform);
|
|
||||||
for (const cell of cells) {
|
|
||||||
ghostGraphics.fillStyle(color, 0.7);
|
|
||||||
ghostGraphics.fillRect(
|
|
||||||
cell.x * (this.cellSize + this.gridGap),
|
|
||||||
cell.y * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize - 2,
|
|
||||||
this.cellSize - 2,
|
|
||||||
);
|
|
||||||
ghostGraphics.lineStyle(2, 0xffffff);
|
|
||||||
ghostGraphics.strokeRect(
|
|
||||||
cell.x * (this.cellSize + this.gridGap),
|
|
||||||
cell.y * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ghostContainer.add(ghostGraphics);
|
|
||||||
|
|
||||||
return ghostContainer;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateGhostVisuals(session: DragSession): void {
|
|
||||||
session.ghostContainer.removeAll(true);
|
|
||||||
const ghostGraphics = this.scene.add.graphics();
|
|
||||||
const color = this.getItemColor(session.itemId);
|
|
||||||
|
|
||||||
const cells = transformShape(session.itemShape, session.itemTransform);
|
|
||||||
for (const cell of cells) {
|
|
||||||
ghostGraphics.fillStyle(color, 0.7);
|
|
||||||
ghostGraphics.fillRect(
|
|
||||||
cell.x * (this.cellSize + this.gridGap),
|
|
||||||
cell.y * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize - 2,
|
|
||||||
this.cellSize - 2,
|
|
||||||
);
|
|
||||||
ghostGraphics.lineStyle(2, 0xffffff);
|
|
||||||
ghostGraphics.strokeRect(
|
|
||||||
cell.x * (this.cellSize + this.gridGap),
|
|
||||||
cell.y * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
session.ghostContainer.add(ghostGraphics);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDragMove(session: DragSession): void {
|
|
||||||
const pointer = this.scene.input.activePointer;
|
|
||||||
session.ghostContainer.setPosition(pointer.x, pointer.y);
|
|
||||||
|
|
||||||
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
|
|
||||||
session.previewGraphics.clear();
|
|
||||||
|
|
||||||
if (gridCell) {
|
|
||||||
const inventory = this.getInventory();
|
|
||||||
const testTransform = {
|
|
||||||
...session.itemTransform,
|
|
||||||
offset: { x: gridCell.x, y: gridCell.y },
|
|
||||||
};
|
|
||||||
const validation = validatePlacement(
|
|
||||||
inventory,
|
|
||||||
session.itemShape,
|
|
||||||
testTransform,
|
|
||||||
);
|
|
||||||
|
|
||||||
const cells = transformShape(session.itemShape, testTransform);
|
|
||||||
for (const cell of cells) {
|
|
||||||
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
|
|
||||||
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
|
|
||||||
|
|
||||||
if (validation.valid) {
|
|
||||||
session.previewGraphics.fillStyle(0x33ff33, 0.3);
|
|
||||||
session.previewGraphics.fillRect(
|
|
||||||
px,
|
|
||||||
py,
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
session.previewGraphics.lineStyle(2, 0x33ff33);
|
|
||||||
session.previewGraphics.strokeRect(
|
|
||||||
px,
|
|
||||||
py,
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
session.previewGraphics.fillStyle(0xff3333, 0.3);
|
|
||||||
session.previewGraphics.fillRect(
|
|
||||||
px,
|
|
||||||
py,
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
session.previewGraphics.lineStyle(2, 0xff3333);
|
|
||||||
session.previewGraphics.strokeRect(
|
|
||||||
px,
|
|
||||||
py,
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDragEnd(session: DragSession): void {
|
|
||||||
const pointer = this.scene.input.activePointer;
|
|
||||||
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
|
|
||||||
const inventory = this.getInventory();
|
|
||||||
|
|
||||||
session.ghostContainer.destroy();
|
|
||||||
session.previewGraphics.destroy();
|
|
||||||
session.disposables.dispose();
|
|
||||||
|
|
||||||
if (gridCell) {
|
|
||||||
const testTransform = {
|
|
||||||
...session.itemTransform,
|
|
||||||
offset: { x: gridCell.x, y: gridCell.y },
|
|
||||||
};
|
|
||||||
const validation = validatePlacement(
|
|
||||||
inventory,
|
|
||||||
session.itemShape,
|
|
||||||
testTransform,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (validation.valid) {
|
|
||||||
const item: InventoryItem<GameItemMeta> = {
|
|
||||||
id: session.itemId,
|
|
||||||
shape: session.itemShape,
|
|
||||||
transform: testTransform,
|
|
||||||
meta: session.itemMeta,
|
|
||||||
};
|
|
||||||
this.onPlaceItem(item);
|
|
||||||
} else {
|
|
||||||
this.onCreateLostItem(
|
|
||||||
session.itemId,
|
|
||||||
session.itemShape,
|
|
||||||
session.itemTransform,
|
|
||||||
session.itemMeta,
|
|
||||||
session.ghostContainer.x,
|
|
||||||
session.ghostContainer.y,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.onCreateLostItem(
|
|
||||||
session.itemId,
|
|
||||||
session.itemShape,
|
|
||||||
session.itemTransform,
|
|
||||||
session.itemMeta,
|
|
||||||
session.ghostContainer.x,
|
|
||||||
session.ghostContainer.y,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getWorldGridCell(
|
|
||||||
worldX: number,
|
|
||||||
worldY: number,
|
|
||||||
): { x: number; y: number } | null {
|
|
||||||
const localX = worldX - this.container.x - this.gridX;
|
|
||||||
const localY = worldY - this.container.y - this.gridY;
|
|
||||||
|
|
||||||
const cellX = Math.floor(localX / (this.cellSize + this.gridGap));
|
|
||||||
const cellY = Math.floor(localY / (this.cellSize + this.gridGap));
|
|
||||||
|
|
||||||
const inventory = this.getInventory();
|
|
||||||
if (
|
|
||||||
cellX < 0 ||
|
|
||||||
cellY < 0 ||
|
|
||||||
cellX >= inventory.width ||
|
|
||||||
cellY >= inventory.height
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { x: cellX, y: cellY };
|
|
||||||
}
|
|
||||||
|
|
||||||
private getItemCells(
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
): { x: number; y: number }[] {
|
|
||||||
const cells: { x: number; y: number }[] = [];
|
|
||||||
const { offset } = item.transform;
|
|
||||||
for (let y = 0; y < item.shape.height; y++) {
|
|
||||||
for (let x = 0; x < item.shape.width; x++) {
|
|
||||||
if (item.shape.grid[y]?.[x]) {
|
|
||||||
cells.push({ x: x + offset.x, y: y + offset.y });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cells;
|
|
||||||
}
|
|
||||||
|
|
||||||
private destroySession(session: DragSession): void {
|
|
||||||
session.disposables.dispose();
|
|
||||||
session.ghostContainer.destroy();
|
|
||||||
session.previewGraphics.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import Phaser from "phaser";
|
|
||||||
|
|
||||||
export interface GridBackgroundRendererOptions {
|
|
||||||
scene: Phaser.Scene;
|
|
||||||
parentContainer: Phaser.GameObjects.Container;
|
|
||||||
cellSize: number;
|
|
||||||
gridGap: number;
|
|
||||||
gridX: number;
|
|
||||||
gridY: number;
|
|
||||||
/** Background fill color for each cell */
|
|
||||||
cellBgColor?: number;
|
|
||||||
/** Border/stroke color for each cell */
|
|
||||||
cellBorderColor?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the static grid background (empty cells with borders).
|
|
||||||
* Separated from item rendering so it can be drawn once and left alone.
|
|
||||||
*/
|
|
||||||
export class GridBackgroundRenderer {
|
|
||||||
private scene: Phaser.Scene;
|
|
||||||
private parentContainer: Phaser.GameObjects.Container;
|
|
||||||
private cellSize: number;
|
|
||||||
private gridGap: number;
|
|
||||||
private gridX: number;
|
|
||||||
private gridY: number;
|
|
||||||
private cellBgColor: number;
|
|
||||||
private cellBorderColor: number;
|
|
||||||
|
|
||||||
private graphics!: Phaser.GameObjects.Graphics;
|
|
||||||
|
|
||||||
constructor(options: GridBackgroundRendererOptions) {
|
|
||||||
this.scene = options.scene;
|
|
||||||
this.parentContainer = options.parentContainer;
|
|
||||||
this.cellSize = options.cellSize;
|
|
||||||
this.gridGap = options.gridGap;
|
|
||||||
this.gridX = options.gridX;
|
|
||||||
this.gridY = options.gridY;
|
|
||||||
this.cellBgColor = options.cellBgColor ?? 0x1a1a2e;
|
|
||||||
this.cellBorderColor = options.cellBorderColor ?? 0x444477;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw the grid background for the given dimensions.
|
|
||||||
* Should be called once during initialization.
|
|
||||||
*/
|
|
||||||
draw(width: number, height: number): void {
|
|
||||||
this.graphics = this.scene.add.graphics();
|
|
||||||
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
const px = this.gridX + x * (this.cellSize + this.gridGap);
|
|
||||||
const py = this.gridY + y * (this.cellSize + this.gridGap);
|
|
||||||
|
|
||||||
this.graphics.fillStyle(this.cellBgColor);
|
|
||||||
this.graphics.fillRect(px, py, this.cellSize, this.cellSize);
|
|
||||||
this.graphics.lineStyle(2, this.cellBorderColor);
|
|
||||||
this.graphics.strokeRect(px, py, this.cellSize, this.cellSize);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.parentContainer.add(this.graphics);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy the graphics object.
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
this.graphics?.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
import Phaser from "phaser";
|
|
||||||
import { MutableSignal } from "boardgame-core";
|
|
||||||
import {
|
|
||||||
type InventoryItem,
|
|
||||||
type GameItemMeta,
|
|
||||||
type RunState,
|
|
||||||
type GridInventory,
|
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
|
||||||
import type { Spawner } from "boardgame-phaser";
|
|
||||||
|
|
||||||
const ITEM_COLORS = [
|
|
||||||
0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33,
|
|
||||||
0xff6633,
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface InventoryItemSpawnerOptions {
|
|
||||||
scene: Phaser.Scene;
|
|
||||||
gameState: MutableSignal<RunState>;
|
|
||||||
parentContainer: Phaser.GameObjects.Container;
|
|
||||||
cellSize: number;
|
|
||||||
gridGap: number;
|
|
||||||
gridX: number;
|
|
||||||
gridY: number;
|
|
||||||
isLocked: () => boolean;
|
|
||||||
isDragging: () => boolean;
|
|
||||||
onItemDragStart: (
|
|
||||||
itemId: string,
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
itemContainer: Phaser.GameObjects.Container,
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spawner for inventory items using the boardgame-phaser Spawner pattern.
|
|
||||||
* Reactively spawns/despawns/updates item visuals when gameState.inventory changes.
|
|
||||||
*
|
|
||||||
* Items currently being dragged are excluded from getData() to prevent
|
|
||||||
* the spawner from respawning them while they're in flight.
|
|
||||||
*/
|
|
||||||
export class InventoryItemSpawner implements Spawner<
|
|
||||||
InventoryItem<GameItemMeta>,
|
|
||||||
Phaser.GameObjects.Container
|
|
||||||
> {
|
|
||||||
private scene: Phaser.Scene;
|
|
||||||
private gameState: MutableSignal<RunState>;
|
|
||||||
private parentContainer: Phaser.GameObjects.Container;
|
|
||||||
private cellSize: number;
|
|
||||||
private gridGap: number;
|
|
||||||
private gridX: number;
|
|
||||||
private gridY: number;
|
|
||||||
private isLocked: () => boolean;
|
|
||||||
private isDragging: () => boolean;
|
|
||||||
private onItemDragStart: (
|
|
||||||
itemId: string,
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
itemContainer: Phaser.GameObjects.Container,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
private colorMap = new Map<string, number>();
|
|
||||||
private colorIdx = 0;
|
|
||||||
private draggingIds = new Set<string>();
|
|
||||||
|
|
||||||
constructor(options: InventoryItemSpawnerOptions) {
|
|
||||||
this.scene = options.scene;
|
|
||||||
this.gameState = options.gameState;
|
|
||||||
this.parentContainer = options.parentContainer;
|
|
||||||
this.cellSize = options.cellSize;
|
|
||||||
this.gridGap = options.gridGap;
|
|
||||||
this.gridX = options.gridX;
|
|
||||||
this.gridY = options.gridY;
|
|
||||||
this.isLocked = options.isLocked;
|
|
||||||
this.isDragging = options.isDragging;
|
|
||||||
this.onItemDragStart = options.onItemDragStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
*getData(): Iterable<InventoryItem<GameItemMeta>> {
|
|
||||||
const inventory = this.getInventory();
|
|
||||||
for (const [, item] of inventory.items) {
|
|
||||||
if (!this.draggingIds.has(item.id)) {
|
|
||||||
yield item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getKey(item: InventoryItem<GameItemMeta>): string {
|
|
||||||
return item.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSpawn(item: InventoryItem<GameItemMeta>): Phaser.GameObjects.Container {
|
|
||||||
const color =
|
|
||||||
this.colorMap.get(item.id) ??
|
|
||||||
ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length];
|
|
||||||
this.colorMap.set(item.id, color);
|
|
||||||
|
|
||||||
const container = this.createItemVisuals(item, color);
|
|
||||||
this.setupInteraction(item, container);
|
|
||||||
this.parentContainer.add(container);
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
onDespawn(
|
|
||||||
obj: Phaser.GameObjects.Container,
|
|
||||||
_item: InventoryItem<GameItemMeta>,
|
|
||||||
): void {
|
|
||||||
obj.removeAllListeners();
|
|
||||||
obj.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdate(
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
obj: Phaser.GameObjects.Container,
|
|
||||||
): void {
|
|
||||||
const color = this.colorMap.get(item.id) ?? 0x888888;
|
|
||||||
this.rebuildItemVisuals(obj, item, color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark an item as being dragged so the spawner excludes it from getData().
|
|
||||||
* Call this before removing the item from the inventory.
|
|
||||||
*/
|
|
||||||
markDragging(itemId: string): void {
|
|
||||||
this.draggingIds.add(itemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unmark an item after drag ends (placed or lost).
|
|
||||||
*/
|
|
||||||
unmarkDragging(itemId: string): void {
|
|
||||||
this.draggingIds.delete(itemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the color assigned to an item (creates one if not yet assigned).
|
|
||||||
*/
|
|
||||||
getItemColor(itemId: string): number {
|
|
||||||
if (!this.colorMap.has(itemId)) {
|
|
||||||
this.colorMap.set(
|
|
||||||
itemId,
|
|
||||||
ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.colorMap.get(itemId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getInventory(): GridInventory<GameItemMeta> {
|
|
||||||
return this.gameState.value
|
|
||||||
.inventory as unknown as GridInventory<GameItemMeta>;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createItemVisuals(
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
color: number,
|
|
||||||
): Phaser.GameObjects.Container {
|
|
||||||
const container = this.scene.add.container(0, 0);
|
|
||||||
|
|
||||||
const graphics = this.scene.add.graphics();
|
|
||||||
const cells = this.getItemCells(item);
|
|
||||||
|
|
||||||
for (const cell of cells) {
|
|
||||||
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
|
|
||||||
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
|
|
||||||
|
|
||||||
graphics.fillStyle(color);
|
|
||||||
graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2);
|
|
||||||
graphics.lineStyle(2, 0xffffff);
|
|
||||||
graphics.strokeRect(px, py, this.cellSize, this.cellSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstCell = cells[0];
|
|
||||||
const name = item.meta?.itemData.name ?? item.id;
|
|
||||||
const fontSize = Math.max(10, Math.floor(this.cellSize / 5));
|
|
||||||
const text = this.scene.add
|
|
||||||
.text(
|
|
||||||
this.gridX +
|
|
||||||
firstCell.x * (this.cellSize + this.gridGap) +
|
|
||||||
this.cellSize / 2,
|
|
||||||
this.gridY +
|
|
||||||
firstCell.y * (this.cellSize + this.gridGap) +
|
|
||||||
this.cellSize / 2,
|
|
||||||
name,
|
|
||||||
{ fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" },
|
|
||||||
)
|
|
||||||
.setOrigin(0.5);
|
|
||||||
|
|
||||||
container.add(graphics);
|
|
||||||
container.add(text);
|
|
||||||
|
|
||||||
const hitRect = new Phaser.Geom.Rectangle(
|
|
||||||
this.gridX + firstCell.x * (this.cellSize + this.gridGap),
|
|
||||||
this.gridY + firstCell.y * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
private rebuildItemVisuals(
|
|
||||||
container: Phaser.GameObjects.Container,
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
color: number,
|
|
||||||
): void {
|
|
||||||
container.removeAll(true);
|
|
||||||
|
|
||||||
const graphics = this.scene.add.graphics();
|
|
||||||
const cells = this.getItemCells(item);
|
|
||||||
|
|
||||||
for (const cell of cells) {
|
|
||||||
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
|
|
||||||
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
|
|
||||||
|
|
||||||
graphics.fillStyle(color);
|
|
||||||
graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2);
|
|
||||||
graphics.lineStyle(2, 0xffffff);
|
|
||||||
graphics.strokeRect(px, py, this.cellSize, this.cellSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstCell = cells[0];
|
|
||||||
const name = item.meta?.itemData.name ?? item.id;
|
|
||||||
const fontSize = Math.max(10, Math.floor(this.cellSize / 5));
|
|
||||||
const text = this.scene.add
|
|
||||||
.text(
|
|
||||||
this.gridX +
|
|
||||||
firstCell.x * (this.cellSize + this.gridGap) +
|
|
||||||
this.cellSize / 2,
|
|
||||||
this.gridY +
|
|
||||||
firstCell.y * (this.cellSize + this.gridGap) +
|
|
||||||
this.cellSize / 2,
|
|
||||||
name,
|
|
||||||
{ fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" },
|
|
||||||
)
|
|
||||||
.setOrigin(0.5);
|
|
||||||
|
|
||||||
container.add(graphics);
|
|
||||||
container.add(text);
|
|
||||||
|
|
||||||
const hitRect = new Phaser.Geom.Rectangle(
|
|
||||||
this.gridX + firstCell.x * (this.cellSize + this.gridGap),
|
|
||||||
this.gridY + firstCell.y * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupInteraction(
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
container: Phaser.GameObjects.Container,
|
|
||||||
): void {
|
|
||||||
container.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
|
|
||||||
// Guard against stale events firing on destroyed containers
|
|
||||||
if (!container.scene || !container.active) return;
|
|
||||||
if (this.isLocked()) return;
|
|
||||||
if (this.isDragging()) return;
|
|
||||||
if (pointer.button === 0) {
|
|
||||||
this.onItemDragStart(item.id, item, container);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getItemCells(
|
|
||||||
item: InventoryItem<GameItemMeta>,
|
|
||||||
): { x: number; y: number }[] {
|
|
||||||
const cells: { x: number; y: number }[] = [];
|
|
||||||
const { offset } = item.transform;
|
|
||||||
for (let y = 0; y < item.shape.height; y++) {
|
|
||||||
for (let x = 0; x < item.shape.width; x++) {
|
|
||||||
if (item.shape.grid[y]?.[x]) {
|
|
||||||
cells.push({ x: x + offset.x, y: y + offset.y });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cells;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
import Phaser from "phaser";
|
|
||||||
import {
|
|
||||||
type InventoryItem,
|
|
||||||
type GameItemMeta,
|
|
||||||
} from "boardgame-core/samples/slay-the-spire-like";
|
|
||||||
|
|
||||||
export interface LostItem {
|
|
||||||
id: string;
|
|
||||||
container: Phaser.GameObjects.Container;
|
|
||||||
shape: InventoryItem<GameItemMeta>["shape"];
|
|
||||||
transform: InventoryItem<GameItemMeta>["transform"];
|
|
||||||
meta: InventoryItem<GameItemMeta>["meta"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LostItemManagerOptions {
|
|
||||||
scene: Phaser.Scene;
|
|
||||||
cellSize: number;
|
|
||||||
gridGap: number;
|
|
||||||
getItemColor: (itemId: string) => number;
|
|
||||||
onLostItemDragStart: (
|
|
||||||
itemId: string,
|
|
||||||
container: Phaser.GameObjects.Container,
|
|
||||||
) => void;
|
|
||||||
isDragging: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages "lost" items — items that were dropped outside valid grid placement.
|
|
||||||
* Renders them as semi-transparent red-bordered containers that can be re-dragged.
|
|
||||||
*/
|
|
||||||
export class LostItemManager {
|
|
||||||
private scene: Phaser.Scene;
|
|
||||||
private cellSize: number;
|
|
||||||
private gridGap: number;
|
|
||||||
private getItemColor: (itemId: string) => number;
|
|
||||||
private onLostItemDragStart: (
|
|
||||||
itemId: string,
|
|
||||||
container: Phaser.GameObjects.Container,
|
|
||||||
) => void;
|
|
||||||
private isDragging: () => boolean;
|
|
||||||
|
|
||||||
private lostItems = new Map<string, LostItem>();
|
|
||||||
|
|
||||||
constructor(options: LostItemManagerOptions) {
|
|
||||||
this.scene = options.scene;
|
|
||||||
this.cellSize = options.cellSize;
|
|
||||||
this.gridGap = options.gridGap;
|
|
||||||
this.getItemColor = options.getItemColor;
|
|
||||||
this.onLostItemDragStart = options.onLostItemDragStart;
|
|
||||||
this.isDragging = options.isDragging;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a visual representation of a lost item at the given position.
|
|
||||||
*/
|
|
||||||
createLostItem(
|
|
||||||
itemId: string,
|
|
||||||
shape: InventoryItem<GameItemMeta>["shape"],
|
|
||||||
transform: InventoryItem<GameItemMeta>["transform"],
|
|
||||||
meta: InventoryItem<GameItemMeta>["meta"],
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
): void {
|
|
||||||
const container = this.scene.add.container(x, y).setDepth(500);
|
|
||||||
|
|
||||||
const graphics = this.scene.add.graphics();
|
|
||||||
const color = this.getItemColor(itemId);
|
|
||||||
|
|
||||||
for (let gy = 0; gy < shape.height; gy++) {
|
|
||||||
for (let gx = 0; gx < shape.width; gx++) {
|
|
||||||
if (shape.grid[gy]?.[gx]) {
|
|
||||||
graphics.fillStyle(color, 0.5);
|
|
||||||
graphics.fillRect(
|
|
||||||
gx * (this.cellSize + this.gridGap),
|
|
||||||
gy * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize - 2,
|
|
||||||
this.cellSize - 2,
|
|
||||||
);
|
|
||||||
graphics.lineStyle(2, 0xff4444);
|
|
||||||
graphics.strokeRect(
|
|
||||||
gx * (this.cellSize + this.gridGap),
|
|
||||||
gy * (this.cellSize + this.gridGap),
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
container.add(graphics);
|
|
||||||
|
|
||||||
const name = meta?.itemData.name ?? itemId;
|
|
||||||
const text = this.scene.add
|
|
||||||
.text(0, -20, `${name} (lost)`, {
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "#ff4444",
|
|
||||||
fontStyle: "italic",
|
|
||||||
})
|
|
||||||
.setOrigin(0.5);
|
|
||||||
container.add(text);
|
|
||||||
|
|
||||||
const hitRect = new Phaser.Geom.Rectangle(
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
this.cellSize,
|
|
||||||
this.cellSize,
|
|
||||||
);
|
|
||||||
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
|
|
||||||
|
|
||||||
container.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
|
|
||||||
// Guard against stale events firing on destroyed containers
|
|
||||||
if (!container.scene || !container.active) return;
|
|
||||||
if (this.isDragging()) return;
|
|
||||||
if (pointer.button === 0) {
|
|
||||||
this.onLostItemDragStart(itemId, container);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.lostItems.set(itemId, {
|
|
||||||
id: itemId,
|
|
||||||
container,
|
|
||||||
shape,
|
|
||||||
transform: { ...transform },
|
|
||||||
meta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all lost item IDs.
|
|
||||||
*/
|
|
||||||
getLostItemIds(): string[] {
|
|
||||||
return Array.from(this.lostItems.keys());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a lost item by ID, or undefined if not found.
|
|
||||||
*/
|
|
||||||
getLostItem(itemId: string): LostItem | undefined {
|
|
||||||
return this.lostItems.get(itemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy and clear all lost items.
|
|
||||||
*/
|
|
||||||
clear(): void {
|
|
||||||
for (const lost of this.lostItems.values()) {
|
|
||||||
lost.container.destroy();
|
|
||||||
}
|
|
||||||
this.lostItems.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy all managed visuals.
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
this.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue