Compare commits
No commits in common. "fe0621cedf3fa37eb5f26b3eadf75788187d63a8" and "368d9942d22b0c8ac2c5ca41bd26a091b072101c" have entirely different histories.
fe0621cedf
...
368d9942d2
|
|
@ -1,4 +1,4 @@
|
||||||
import { type ReadonlySignal } from "@preact/signals-core";
|
import { effect, 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.addEffect(fn);
|
this.disposables.add(effect(fn));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ 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,5 +1,3 @@
|
||||||
import { effect } from "@preact/signals-core";
|
|
||||||
|
|
||||||
export interface IDisposable {
|
export interface IDisposable {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
@ -10,10 +8,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -26,10 +20,6 @@ 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,7 +2,6 @@
|
||||||
|
|
||||||
type PointerRecord = {
|
type PointerRecord = {
|
||||||
id: number;
|
id: number;
|
||||||
button: number;
|
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
|
|
@ -11,7 +10,6 @@ export enum DragDropEventType {
|
||||||
DOWN,
|
DOWN,
|
||||||
UP,
|
UP,
|
||||||
MOVE,
|
MOVE,
|
||||||
ALTBUTTON,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DragDropEvent = {
|
export type DragDropEvent = {
|
||||||
|
|
@ -31,22 +29,9 @@ 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 (down !== null) {
|
if (isDragging) return;
|
||||||
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 = {
|
down = { id: pointer.id, x: pointer.x, y: pointer.y };
|
||||||
id: pointer.id,
|
|
||||||
button: pointer.button,
|
|
||||||
x: pointer.x,
|
|
||||||
y: pointer.y,
|
|
||||||
};
|
|
||||||
|
|
||||||
const event: DragDropEvent = {
|
const event: DragDropEvent = {
|
||||||
type: DragDropEventType.DOWN,
|
type: DragDropEventType.DOWN,
|
||||||
|
|
@ -57,23 +42,12 @@ export function dragDropEventEffect(
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerUp(pointer: Phaser.Input.Pointer) {
|
function onPointerUp(pointer: Phaser.Input.Pointer) {
|
||||||
if (
|
if (!isDragging || !down || pointer.id !== down.id) return;
|
||||||
!isDragging ||
|
|
||||||
!down ||
|
|
||||||
pointer.id !== down.id ||
|
|
||||||
pointer.button !== down.button
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const deltaX = pointer.x - down.x;
|
|
||||||
const deltaY = pointer.y - down.y;
|
|
||||||
|
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
const event: DragDropEvent = {
|
const deltaX = pointer.x - down.x;
|
||||||
type: DragDropEventType.UP,
|
const deltaY = pointer.y - down.y;
|
||||||
deltaX,
|
const event: DragDropEvent = { type: DragDropEventType.UP, deltaX, deltaY };
|
||||||
deltaY,
|
|
||||||
};
|
|
||||||
callback(event);
|
callback(event);
|
||||||
down = null;
|
down = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,29 +33,6 @@ 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 ────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
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,11 +49,6 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
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,7 +1,6 @@
|
||||||
export enum SceneKey {
|
export enum SceneKey {
|
||||||
GridViewerScene = "GridViewerScene",
|
GridViewerScene = "GridViewerScene",
|
||||||
IndexScene = "IndexScene",
|
IndexScene = "IndexScene",
|
||||||
InventoryTestScene = "InventoryTestScene",
|
|
||||||
MapViewerScene = "MapViewerScene",
|
MapViewerScene = "MapViewerScene",
|
||||||
ShapeViewerScene = "ShapeViewerScene",
|
ShapeViewerScene = "ShapeViewerScene",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
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,7 +4,6 @@ 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 (
|
||||||
|
|
@ -12,7 +11,6 @@ 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