Compare commits

..

No commits in common. "fe0621cedf3fa37eb5f26b3eadf75788187d63a8" and "368d9942d22b0c8ac2c5ca41bd26a091b072101c" have entirely different histories.

13 changed files with 8 additions and 707 deletions

View File

@ -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));
} }
} }

View File

@ -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 {

View File

@ -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;

View File

@ -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;
} }

View File

@ -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 ────────────────────────────────────────────────────────────

View File

@ -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",
});
}
}

View File

@ -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,
),
);
}

View File

@ -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) {

View File

@ -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);
});
}
}

View File

@ -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",
} }

View File

@ -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];
}
}

View File

@ -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;
}

View File

@ -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} />