Compare commits
14 Commits
368d9942d2
...
fe0621cedf
| Author | SHA1 | Date |
|---|---|---|
|
|
fe0621cedf | |
|
|
30e2d9ac36 | |
|
|
c9f573ef86 | |
|
|
cc796d1e11 | |
|
|
fd347e7a62 | |
|
|
19dfafeb30 | |
|
|
b2862e34d9 | |
|
|
fd41db2a89 | |
|
|
f03e6c5a7b | |
|
|
7039938a72 | |
|
|
45844ea800 | |
|
|
29b516c371 | |
|
|
5884e74d8d | |
|
|
b06876ccc0 |
|
|
@ -1,4 +1,4 @@
|
||||||
import { effect, type ReadonlySignal } from "@preact/signals-core";
|
import { type ReadonlySignal } from "@preact/signals-core";
|
||||||
import { Scene } from "phaser";
|
import { Scene } from "phaser";
|
||||||
|
|
||||||
import { DisposableBag, type IDisposable } from "../utils";
|
import { DisposableBag, type IDisposable } from "../utils";
|
||||||
|
|
@ -85,6 +85,6 @@ export abstract class ReactiveScene<TData = object>
|
||||||
|
|
||||||
/** 注册响应式监听(场景关闭时自动清理) */
|
/** 注册响应式监听(场景关闭时自动清理) */
|
||||||
public addEffect(fn: () => CleanupFn): void {
|
public addEffect(fn: () => CleanupFn): void {
|
||||||
this.disposables.add(effect(fn));
|
this.disposables.addEffect(fn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
|
||||||
parent: "phaser-container",
|
parent: "phaser-container",
|
||||||
backgroundColor: "#f9fafb",
|
backgroundColor: "#f9fafb",
|
||||||
scene: [],
|
scene: [],
|
||||||
|
disableContextMenu: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface PhaserGameProps {
|
export interface PhaserGameProps {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { effect } from "@preact/signals-core";
|
||||||
|
|
||||||
export interface IDisposable {
|
export interface IDisposable {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
@ -8,6 +10,10 @@ export class DisposableBag implements IDisposable {
|
||||||
private _disposables = new Set<DisposableItem>();
|
private _disposables = new Set<DisposableItem>();
|
||||||
private _isDisposed = false;
|
private _isDisposed = false;
|
||||||
|
|
||||||
|
constructor(go?: Phaser.GameObjects.GameObject) {
|
||||||
|
if (go) go.on("shutdown", () => this.dispose());
|
||||||
|
}
|
||||||
|
|
||||||
get isDisposed(): boolean {
|
get isDisposed(): boolean {
|
||||||
return this._isDisposed;
|
return this._isDisposed;
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +26,10 @@ export class DisposableBag implements IDisposable {
|
||||||
this._disposables.add(item);
|
this._disposables.add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addEffect(fn: () => void) {
|
||||||
|
this.add(effect(fn));
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (this._isDisposed) return;
|
if (this._isDisposed) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
type PointerRecord = {
|
type PointerRecord = {
|
||||||
id: number;
|
id: number;
|
||||||
|
button: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
|
|
@ -10,6 +11,7 @@ export enum DragDropEventType {
|
||||||
DOWN,
|
DOWN,
|
||||||
UP,
|
UP,
|
||||||
MOVE,
|
MOVE,
|
||||||
|
ALTBUTTON,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DragDropEvent = {
|
export type DragDropEvent = {
|
||||||
|
|
@ -29,9 +31,22 @@ export function dragDropEventEffect(
|
||||||
let down: PointerRecord | null = null;
|
let down: PointerRecord | null = null;
|
||||||
|
|
||||||
function onPointerDown(pointer: Phaser.Input.Pointer) {
|
function onPointerDown(pointer: Phaser.Input.Pointer) {
|
||||||
if (isDragging) return;
|
if (down !== null) {
|
||||||
|
if (pointer.button === down.button) return;
|
||||||
|
callback({
|
||||||
|
type: DragDropEventType.ALTBUTTON,
|
||||||
|
deltaX: pointer.x - down.x,
|
||||||
|
deltaY: pointer.y - down.y,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
down = { id: pointer.id, x: pointer.x, y: pointer.y };
|
down = {
|
||||||
|
id: pointer.id,
|
||||||
|
button: pointer.button,
|
||||||
|
x: pointer.x,
|
||||||
|
y: pointer.y,
|
||||||
|
};
|
||||||
|
|
||||||
const event: DragDropEvent = {
|
const event: DragDropEvent = {
|
||||||
type: DragDropEventType.DOWN,
|
type: DragDropEventType.DOWN,
|
||||||
|
|
@ -42,12 +57,23 @@ export function dragDropEventEffect(
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerUp(pointer: Phaser.Input.Pointer) {
|
function onPointerUp(pointer: Phaser.Input.Pointer) {
|
||||||
if (!isDragging || !down || pointer.id !== down.id) return;
|
if (
|
||||||
|
!isDragging ||
|
||||||
|
!down ||
|
||||||
|
pointer.id !== down.id ||
|
||||||
|
pointer.button !== down.button
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
isDragging = false;
|
|
||||||
const deltaX = pointer.x - down.x;
|
const deltaX = pointer.x - down.x;
|
||||||
const deltaY = pointer.y - down.y;
|
const deltaY = pointer.y - down.y;
|
||||||
const event: DragDropEvent = { type: DragDropEventType.UP, deltaX, deltaY };
|
|
||||||
|
isDragging = false;
|
||||||
|
const event: DragDropEvent = {
|
||||||
|
type: DragDropEventType.UP,
|
||||||
|
deltaX,
|
||||||
|
deltaY,
|
||||||
|
};
|
||||||
callback(event);
|
callback(event);
|
||||||
down = null;
|
down = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,29 @@ export const GRID_CONFIG = {
|
||||||
WIDGET_CELL_SIZE: 80,
|
WIDGET_CELL_SIZE: 80,
|
||||||
/** Gap between grid cells (pixels) */
|
/** Gap between grid cells (pixels) */
|
||||||
GRID_GAP: 2,
|
GRID_GAP: 2,
|
||||||
|
/** Empty cell background color */
|
||||||
|
CELL_EMPTY_COLOR: 0x222233,
|
||||||
|
/** Occupied cell background color */
|
||||||
|
CELL_OCCUPIED_COLOR: 0x334455,
|
||||||
|
/** Grid line color */
|
||||||
|
GRID_LINE_COLOR: 0x555577,
|
||||||
|
/** Title text style */
|
||||||
|
TITLE_STYLE: {
|
||||||
|
fontSize: "24px",
|
||||||
|
color: "#888",
|
||||||
|
fontStyle: "bold",
|
||||||
|
} as const,
|
||||||
|
/** Subtitle/hint text style */
|
||||||
|
SUBTITLE_STYLE: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#aaaaaa",
|
||||||
|
} as const,
|
||||||
|
/** Item name text style */
|
||||||
|
ITEM_NAME_STYLE: {
|
||||||
|
fontSize: "11px",
|
||||||
|
color: "#888",
|
||||||
|
fontStyle: "bold",
|
||||||
|
} as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ── Shape Viewer ────────────────────────────────────────────────────────────
|
// ── Shape Viewer ────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
import Phaser from "phaser";
|
||||||
|
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
|
||||||
|
import { GRID_CONFIG } from "@/config";
|
||||||
|
import {
|
||||||
|
IDENTITY_TRANSFORM,
|
||||||
|
ParsedShape,
|
||||||
|
Point2D,
|
||||||
|
Transform2D,
|
||||||
|
transformShape,
|
||||||
|
type GameItem,
|
||||||
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { DisposableBag } from "../../../framework/dist";
|
||||||
|
import { InventoryItemState } from "@/state/InventoryItemState";
|
||||||
|
|
||||||
|
export interface InventoryItemContainerCallbacks {
|
||||||
|
onMoveItem: (
|
||||||
|
itemId: string,
|
||||||
|
newX: number,
|
||||||
|
newY: number,
|
||||||
|
newRotation: number,
|
||||||
|
) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InventoryItemContainer extends Phaser.GameObjects.Container {
|
||||||
|
private itemState: InventoryItemState;
|
||||||
|
private hitArea: Point2D[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
private gridOffsetX: number,
|
||||||
|
private gridOffsetY: number,
|
||||||
|
private callbacks: InventoryItemContainerCallbacks,
|
||||||
|
) {
|
||||||
|
super(scene, gridOffsetX, gridOffsetY);
|
||||||
|
scene.add.existing(this);
|
||||||
|
|
||||||
|
const graphics = this.scene.add.graphics();
|
||||||
|
graphics.setPosition(
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
|
||||||
|
);
|
||||||
|
const label = this.scene.add.text(
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
|
||||||
|
"",
|
||||||
|
GRID_CONFIG.ITEM_NAME_STYLE,
|
||||||
|
);
|
||||||
|
this.add([graphics, label]);
|
||||||
|
this.setupInteractive();
|
||||||
|
|
||||||
|
this.itemState = new InventoryItemState();
|
||||||
|
|
||||||
|
const disposables = new DisposableBag(this);
|
||||||
|
|
||||||
|
disposables.addEffect(() => {
|
||||||
|
label.setText(this.itemState.name.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
disposables.add(this.setupDnDEffect());
|
||||||
|
|
||||||
|
disposables.addEffect(() => {
|
||||||
|
graphics.clear();
|
||||||
|
if (!this.itemState.shape.value) return;
|
||||||
|
this.renderGraphics(
|
||||||
|
graphics,
|
||||||
|
this.itemState.shape.value,
|
||||||
|
this.itemState.color.value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
disposables.addEffect(() => {
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: graphics,
|
||||||
|
rotation: -(this.itemState.previewRotation.value * Math.PI) / 180,
|
||||||
|
duration: 150,
|
||||||
|
ease: "Power2",
|
||||||
|
});
|
||||||
|
this.hitArea = this.updateHitArea(this.itemState.previewRotation.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
disposables.addEffect(() => {
|
||||||
|
if (!this.itemState.transform.value) return;
|
||||||
|
this.snapBack(this.itemState.transform.value);
|
||||||
|
this.itemState.setPreviewRotation(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHitArea(value: number): Point2D[] {
|
||||||
|
const shape = this.itemState.shape.value;
|
||||||
|
if (!shape) return [];
|
||||||
|
|
||||||
|
const rotation = value;
|
||||||
|
const cells = transformShape(shape, {
|
||||||
|
rotation,
|
||||||
|
offset: { x: 0, y: 0 },
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
});
|
||||||
|
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||||
|
|
||||||
|
return cells.map((cell) => ({
|
||||||
|
x: (cell.x - cells[0].x) * cellSize,
|
||||||
|
y: (cell.y - cells[0].y) * cellSize,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setItem(item: GameItem): void {
|
||||||
|
this.itemState.setItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGraphics(
|
||||||
|
graphics: Phaser.GameObjects.Graphics,
|
||||||
|
shape: ParsedShape,
|
||||||
|
itemColor: number,
|
||||||
|
) {
|
||||||
|
const cells = transformShape(shape, IDENTITY_TRANSFORM);
|
||||||
|
|
||||||
|
for (const cell of cells) {
|
||||||
|
const localX = (cell.x - cells[0].x) * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||||
|
const localY = (cell.y - cells[0].y) * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||||
|
|
||||||
|
graphics.fillStyle(itemColor);
|
||||||
|
graphics.fillRect(
|
||||||
|
localX + 2 - GRID_CONFIG.VIEWER_CELL_SIZE / 2,
|
||||||
|
localY + 2 - GRID_CONFIG.VIEWER_CELL_SIZE / 2,
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupInteractive() {
|
||||||
|
this.setScrollFactor(0);
|
||||||
|
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||||
|
this.setInteractive({
|
||||||
|
hitArea: this,
|
||||||
|
hitAreaCallback: (
|
||||||
|
hitArea: InventoryItemContainer,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
) =>
|
||||||
|
hitArea.hitArea.some(
|
||||||
|
(cell) =>
|
||||||
|
x >= cell.x &&
|
||||||
|
x < cell.x + cellSize &&
|
||||||
|
y >= cell.y &&
|
||||||
|
y < cell.y + cellSize,
|
||||||
|
),
|
||||||
|
useHandCursor: true,
|
||||||
|
} as Phaser.Types.Input.InputConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupDnDEffect() {
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
return dragDropEventEffect(this, (event) => {
|
||||||
|
if (event.type === DragDropEventType.DOWN) {
|
||||||
|
startX = this.x;
|
||||||
|
startY = this.y;
|
||||||
|
this.setAlpha(0.7);
|
||||||
|
} else if (event.type === DragDropEventType.MOVE) {
|
||||||
|
this.x = startX + event.deltaX;
|
||||||
|
this.y = startY + event.deltaY;
|
||||||
|
} else if (event.type === DragDropEventType.ALTBUTTON) {
|
||||||
|
this.itemState.addPreviewRotation(90);
|
||||||
|
} else if (event.type === DragDropEventType.UP) {
|
||||||
|
this.setAlpha(1);
|
||||||
|
const finalRotation = this.itemState.previewRotation.peek();
|
||||||
|
if (!this.handleDragEnd(finalRotation)) {
|
||||||
|
this.itemState.setPreviewRotation(0);
|
||||||
|
const t = this.itemState.transform.peek();
|
||||||
|
t && this.snapBack(t);
|
||||||
|
} else {
|
||||||
|
this.itemState.setPreviewRotation(0);
|
||||||
|
}
|
||||||
|
startX = startY = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDragEnd(finalRotation: number): boolean {
|
||||||
|
const item = this.itemState.item;
|
||||||
|
if (!item) return false;
|
||||||
|
|
||||||
|
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||||
|
const shapeWidth = item.shape?.width ?? 1;
|
||||||
|
const shapeHeight = item.shape?.height ?? 1;
|
||||||
|
|
||||||
|
const x = this.x - this.gridOffsetX;
|
||||||
|
const y = this.y - this.gridOffsetY;
|
||||||
|
const targetX = Math.round(x / cellSize);
|
||||||
|
const targetY = Math.round(y / cellSize);
|
||||||
|
|
||||||
|
const clampedX = Math.max(0, Math.min(targetX, 10 - shapeWidth));
|
||||||
|
const clampedY = Math.max(0, Math.min(targetY, 10 - shapeHeight));
|
||||||
|
|
||||||
|
if (
|
||||||
|
clampedX !== item.transform.offset.x ||
|
||||||
|
clampedY !== item.transform.offset.y ||
|
||||||
|
finalRotation !== 0
|
||||||
|
) {
|
||||||
|
return this.callbacks.onMoveItem(
|
||||||
|
item.id,
|
||||||
|
clampedX,
|
||||||
|
clampedY,
|
||||||
|
finalRotation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapBack(transform: Transform2D): void {
|
||||||
|
const { x, y } = transform.offset;
|
||||||
|
const targetX = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||||
|
const targetY = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||||
|
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
x: targetX,
|
||||||
|
y: targetY,
|
||||||
|
duration: 150,
|
||||||
|
ease: "Power2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import Phaser from "phaser";
|
||||||
|
import type { Spawner } from "boardgame-phaser";
|
||||||
|
import type {
|
||||||
|
GameItemMeta,
|
||||||
|
InventoryItem,
|
||||||
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import type { InventorySignal } from "@/state/inventory";
|
||||||
|
import { spawnEffect } from "boardgame-phaser";
|
||||||
|
import { InventoryItemContainer } from "./InventoryItemContainer";
|
||||||
|
import type { InventoryItemContainerCallbacks } from "./InventoryItemContainer";
|
||||||
|
|
||||||
|
export interface InventoryItemSpawnerCallbacks extends InventoryItemContainerCallbacks {}
|
||||||
|
|
||||||
|
export class InventoryItemSpawner implements Spawner<
|
||||||
|
[string, InventoryItem<GameItemMeta>],
|
||||||
|
InventoryItemContainer
|
||||||
|
> {
|
||||||
|
constructor(
|
||||||
|
private scene: Phaser.Scene,
|
||||||
|
private inventorySignal: InventorySignal,
|
||||||
|
private gridOffsetX: number,
|
||||||
|
private gridOffsetY: number,
|
||||||
|
private callbacks: InventoryItemSpawnerCallbacks,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
*getData(): Iterable<[string, InventoryItem<GameItemMeta>]> {
|
||||||
|
const inventory = this.inventorySignal.value;
|
||||||
|
yield* inventory.items.entries();
|
||||||
|
}
|
||||||
|
|
||||||
|
getKey(entry: [string, InventoryItem<GameItemMeta>]): string {
|
||||||
|
return entry[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
onSpawn(
|
||||||
|
entry: [string, InventoryItem<GameItemMeta>],
|
||||||
|
): InventoryItemContainer | null {
|
||||||
|
const [itemId, item] = entry;
|
||||||
|
|
||||||
|
const container = new InventoryItemContainer(
|
||||||
|
this.scene,
|
||||||
|
this.gridOffsetX,
|
||||||
|
this.gridOffsetY,
|
||||||
|
{
|
||||||
|
onMoveItem: (id, newX, newY, newRotation) => {
|
||||||
|
return this.callbacks.onMoveItem(id, newX, newY, newRotation);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
container.setItem(item);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(
|
||||||
|
entry: [string, InventoryItem<GameItemMeta>],
|
||||||
|
container: InventoryItemContainer,
|
||||||
|
): void {
|
||||||
|
const [itemId, item] = entry;
|
||||||
|
|
||||||
|
container.setItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDespawn(container: InventoryItemContainer): void {
|
||||||
|
// TODO: add tween
|
||||||
|
container.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInventoryItemSpawner(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
inventorySignal: InventorySignal,
|
||||||
|
gridOffsetX: number,
|
||||||
|
gridOffsetY: number,
|
||||||
|
callbacks: InventoryItemSpawnerCallbacks,
|
||||||
|
) {
|
||||||
|
return spawnEffect(
|
||||||
|
new InventoryItemSpawner(
|
||||||
|
scene,
|
||||||
|
inventorySignal,
|
||||||
|
gridOffsetX,
|
||||||
|
gridOffsetY,
|
||||||
|
callbacks,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,11 @@ export class IndexScene extends ReactiveScene {
|
||||||
scene: SceneKey.ShapeViewerScene,
|
scene: SceneKey.ShapeViewerScene,
|
||||||
y: centerY + 140,
|
y: centerY + 140,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Inventory Test",
|
||||||
|
scene: SceneKey.InventoryTestScene,
|
||||||
|
y: centerY + 210,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const btn of buttons) {
|
for (const btn of buttons) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { ReactiveScene } from "boardgame-phaser";
|
||||||
|
import { createButton } from "@/utils/createButton";
|
||||||
|
import { GRID_CONFIG } from "@/config";
|
||||||
|
import { createInventorySignal, moveItem } from "@/state/inventory";
|
||||||
|
import { createItemIn, data } from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
import { createInventoryItemSpawner } from "@/gameobjects/InventoryItemSpawner";
|
||||||
|
import { SceneKey } from "./types";
|
||||||
|
|
||||||
|
export class InventoryTestScene extends ReactiveScene {
|
||||||
|
private inventorySignal = createInventorySignal();
|
||||||
|
private gridOffsetX = 0;
|
||||||
|
private gridOffsetY = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("InventoryTestScene");
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
super.create();
|
||||||
|
|
||||||
|
const { width, height } = this.scale;
|
||||||
|
const inventory = this.inventorySignal.value;
|
||||||
|
const invWidth = inventory.width;
|
||||||
|
const invHeight = inventory.height;
|
||||||
|
|
||||||
|
this.gridOffsetX = (width - invWidth * GRID_CONFIG.VIEWER_CELL_SIZE) / 2;
|
||||||
|
this.gridOffsetY =
|
||||||
|
(height - invHeight * GRID_CONFIG.VIEWER_CELL_SIZE) / 2 + 40;
|
||||||
|
|
||||||
|
this.drawGrid(invWidth, invHeight);
|
||||||
|
this.setupItemSpawner();
|
||||||
|
|
||||||
|
this.add
|
||||||
|
.text(
|
||||||
|
width / 2,
|
||||||
|
30,
|
||||||
|
"Inventory Signal Test (4x6)",
|
||||||
|
GRID_CONFIG.TITLE_STYLE,
|
||||||
|
)
|
||||||
|
.setOrigin(0.5);
|
||||||
|
|
||||||
|
this.createControls();
|
||||||
|
|
||||||
|
this.add
|
||||||
|
.text(
|
||||||
|
width / 2,
|
||||||
|
height - 40,
|
||||||
|
"Drag items to move them",
|
||||||
|
GRID_CONFIG.SUBTITLE_STYLE,
|
||||||
|
)
|
||||||
|
.setOrigin(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawGrid(invWidth: number, invHeight: number): void {
|
||||||
|
const graphics = this.add.graphics();
|
||||||
|
|
||||||
|
this.addEffect(() => {
|
||||||
|
for (let y = 0; y < invHeight; y++) {
|
||||||
|
for (let x = 0; x < invWidth; x++) {
|
||||||
|
const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||||
|
const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
|
||||||
|
|
||||||
|
const isOccupied = this.inventorySignal.value.occupiedCells.has(
|
||||||
|
`${x},${y}`,
|
||||||
|
);
|
||||||
|
graphics.fillStyle(
|
||||||
|
isOccupied
|
||||||
|
? GRID_CONFIG.CELL_OCCUPIED_COLOR
|
||||||
|
: GRID_CONFIG.CELL_EMPTY_COLOR,
|
||||||
|
);
|
||||||
|
graphics.fillRect(
|
||||||
|
px + 1,
|
||||||
|
py + 1,
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
|
||||||
|
);
|
||||||
|
graphics.lineStyle(1, GRID_CONFIG.GRID_LINE_COLOR);
|
||||||
|
graphics.strokeRect(
|
||||||
|
px,
|
||||||
|
py,
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE,
|
||||||
|
GRID_CONFIG.VIEWER_CELL_SIZE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupItemSpawner(): void {
|
||||||
|
const spawner = createInventoryItemSpawner(
|
||||||
|
this,
|
||||||
|
this.inventorySignal,
|
||||||
|
this.gridOffsetX,
|
||||||
|
this.gridOffsetY,
|
||||||
|
{
|
||||||
|
onMoveItem: (
|
||||||
|
itemId: string,
|
||||||
|
newX: number,
|
||||||
|
newY: number,
|
||||||
|
newRotation: number,
|
||||||
|
) => {
|
||||||
|
return moveItem(
|
||||||
|
this.inventorySignal,
|
||||||
|
itemId,
|
||||||
|
newX,
|
||||||
|
newY,
|
||||||
|
newRotation,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.disposables.add(spawner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createControls(): void {
|
||||||
|
const { width } = this.scale;
|
||||||
|
|
||||||
|
createButton({
|
||||||
|
scene: this,
|
||||||
|
label: "返回菜单",
|
||||||
|
x: 100,
|
||||||
|
y: 40,
|
||||||
|
onClick: async () => {
|
||||||
|
await this.sceneController.launch(SceneKey.IndexScene);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createButton({
|
||||||
|
scene: this,
|
||||||
|
label: "添加道具",
|
||||||
|
x: width - 300,
|
||||||
|
y: 40,
|
||||||
|
onClick: () => {
|
||||||
|
this.addRandomItem();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createButton({
|
||||||
|
scene: this,
|
||||||
|
label: "移除最后一个",
|
||||||
|
x: width - 150,
|
||||||
|
y: 40,
|
||||||
|
onClick: () => {
|
||||||
|
this.removeLastItem();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private addRandomItem(): void {
|
||||||
|
const items = data.desert.getItems();
|
||||||
|
|
||||||
|
this.inventorySignal.produce((inventory) => {
|
||||||
|
const usedIndices = new Set<number>();
|
||||||
|
|
||||||
|
for (const item of inventory.items.values()) {
|
||||||
|
const match = item.id.match(/^item-(\d+)-/);
|
||||||
|
if (match) {
|
||||||
|
usedIndices.add(parseInt(match[1], 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let availableIndex = 0;
|
||||||
|
while (usedIndices.has(availableIndex) && availableIndex < items.length) {
|
||||||
|
availableIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableIndex >= items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemData = items[availableIndex];
|
||||||
|
const id = `item-${availableIndex}-${Date.now().toString(16).slice(-4)}`;
|
||||||
|
createItemIn(inventory, id, itemData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeLastItem(): void {
|
||||||
|
this.inventorySignal.produce((inventory) => {
|
||||||
|
const items = Array.from(inventory.items.entries());
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [lastId] = items[items.length - 1];
|
||||||
|
inventory.items.delete(lastId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export enum SceneKey {
|
export enum SceneKey {
|
||||||
GridViewerScene = "GridViewerScene",
|
GridViewerScene = "GridViewerScene",
|
||||||
IndexScene = "IndexScene",
|
IndexScene = "IndexScene",
|
||||||
|
InventoryTestScene = "InventoryTestScene",
|
||||||
MapViewerScene = "MapViewerScene",
|
MapViewerScene = "MapViewerScene",
|
||||||
ShapeViewerScene = "ShapeViewerScene",
|
ShapeViewerScene = "ShapeViewerScene",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { computed, ReadonlySignal, signal, Signal } from "@preact/signals-core";
|
||||||
|
import { ITEM_COLORS } from "@/config";
|
||||||
|
import {
|
||||||
|
type GameItem,
|
||||||
|
type ParsedShape,
|
||||||
|
type Transform2D,
|
||||||
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
|
||||||
|
export class InventoryItemState {
|
||||||
|
private readonly _item: Signal<GameItem | undefined>;
|
||||||
|
private readonly _previewRotation: Signal<number>;
|
||||||
|
|
||||||
|
readonly name: ReadonlySignal<string>;
|
||||||
|
readonly shape: ReadonlySignal<ParsedShape | undefined>;
|
||||||
|
readonly color: ReadonlySignal<number>;
|
||||||
|
readonly transform: ReadonlySignal<Transform2D | undefined>;
|
||||||
|
readonly previewRotation: ReadonlySignal<number>;
|
||||||
|
|
||||||
|
constructor(initialItem?: GameItem) {
|
||||||
|
this._item = signal(initialItem);
|
||||||
|
this._previewRotation = signal(0);
|
||||||
|
|
||||||
|
this.name = computed(() => {
|
||||||
|
const item = this._item.value;
|
||||||
|
return item?.meta?.itemData.name ?? item?.id ?? "";
|
||||||
|
});
|
||||||
|
|
||||||
|
this.shape = computed(() => this._item.value?.shape);
|
||||||
|
|
||||||
|
this.color = computed(() => this.computeColor(this._item.value?.id ?? ""));
|
||||||
|
|
||||||
|
this.transform = computed(() => this._item.value?.transform);
|
||||||
|
|
||||||
|
this.previewRotation = computed(() => {
|
||||||
|
const base = this._item.value?.transform?.rotation ?? 0;
|
||||||
|
return (base + this._previewRotation.value) % 360;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get item(): GameItem | undefined {
|
||||||
|
return this._item.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setItem(item: GameItem): void {
|
||||||
|
this._item.value = item;
|
||||||
|
this._previewRotation.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewRotation(rotation: number): void {
|
||||||
|
this._previewRotation.value = rotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
addPreviewRotation(rotation: number): void {
|
||||||
|
this._previewRotation.value += rotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeColor(itemId: string): number {
|
||||||
|
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||||
|
return ITEM_COLORS[hash % ITEM_COLORS.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { create } from "mutative";
|
||||||
|
import { mutableSignal } from "boardgame-core";
|
||||||
|
import {
|
||||||
|
createGridInventory,
|
||||||
|
createItemIn,
|
||||||
|
data,
|
||||||
|
GameItemMeta,
|
||||||
|
placeItem,
|
||||||
|
removeItemFromGrid,
|
||||||
|
Transform2D,
|
||||||
|
validatePlacement,
|
||||||
|
} from "boardgame-core/samples/slay-the-spire-like";
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
return Math.random().toString(16).slice(-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InventorySignal = ReturnType<typeof createInventorySignal>;
|
||||||
|
|
||||||
|
export function createInventorySignal() {
|
||||||
|
const inventory = createGridInventory<GameItemMeta>(4, 6);
|
||||||
|
|
||||||
|
const startingItems = data.desert.getStartingItems();
|
||||||
|
for (const d of startingItems) {
|
||||||
|
createItemIn(inventory, `${d.id}-${genId()}`, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mutableSignal(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an item to a new position in the inventory.
|
||||||
|
* Returns true if the move was successful, false if the new position is invalid.
|
||||||
|
*/
|
||||||
|
export function moveItem(
|
||||||
|
inventorySignal: InventorySignal,
|
||||||
|
itemId: string,
|
||||||
|
newX: number,
|
||||||
|
newY: number,
|
||||||
|
newRotation?: number,
|
||||||
|
): boolean {
|
||||||
|
const inventory = inventorySignal.value;
|
||||||
|
const item = inventory.items.get(itemId);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTransform: Transform2D = {
|
||||||
|
offset: { x: newX, y: newY },
|
||||||
|
rotation: newRotation === undefined ? item.transform.rotation : newRotation,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const removed = create(inventory, (inv) => {
|
||||||
|
removeItemFromGrid(inv, itemId);
|
||||||
|
});
|
||||||
|
const validation = validatePlacement(removed, item.shape, newTransform);
|
||||||
|
if (!validation.valid) return false;
|
||||||
|
|
||||||
|
inventorySignal.produce((inv) => {
|
||||||
|
const item = inv.items.get(itemId)!;
|
||||||
|
removeItemFromGrid(inv, itemId);
|
||||||
|
item.transform = newTransform;
|
||||||
|
placeItem(inv, item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ 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 { GAME_CONFIG } from "@/config";
|
import { GAME_CONFIG } from "@/config";
|
||||||
|
import { InventoryTestScene } from "@/scenes/InventoryTestScene";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -11,6 +12,7 @@ export default function App() {
|
||||||
<div className="flex-1 flex relative justify-center items-center">
|
<div className="flex-1 flex relative justify-center items-center">
|
||||||
<PhaserGame initialScene="IndexScene" config={GAME_CONFIG}>
|
<PhaserGame initialScene="IndexScene" config={GAME_CONFIG}>
|
||||||
<PhaserScene scene={IndexScene} />
|
<PhaserScene scene={IndexScene} />
|
||||||
|
<PhaserScene scene={InventoryTestScene} />
|
||||||
<PhaserScene scene={MapViewerScene} />
|
<PhaserScene scene={MapViewerScene} />
|
||||||
<PhaserScene scene={GridViewerScene} />
|
<PhaserScene scene={GridViewerScene} />
|
||||||
<PhaserScene scene={ShapeViewerScene} />
|
<PhaserScene scene={ShapeViewerScene} />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue