Compare commits

..

No commits in common. "140b7aed861be3aa8d236a586aac58f6aad984a8" and "34cb828f60e4a4d20bd2c93089e1fa99fb70406e" have entirely different histories.

5 changed files with 140 additions and 338 deletions

View File

@ -52,7 +52,6 @@ export {
removeItem as removeItemFromGrid,
rotateItem,
validatePlacement,
createItemIn,
} from "./system/grid-inventory";
// Map

View File

@ -1,52 +0,0 @@
import { parseShapeString } from "../utils/parse-shape";
import type { ParsedShape } from "../utils/parse-shape";
import type { Transform2D } from "../utils/shape-collision";
import { placeItem, validatePlacement } from "./transform";
import type { GameItemMeta, GridInventory, MutationResult } from "./types";
import type { ItemData } from "../types";
/**
* Creates and places a GameItemMeta item into the grid inventory.
* Parses the shape from itemData.shape and finds the first valid placement automatically.
* **Mutates directly** call inside a `.produce()` callback.
*/
export function createItemIn(
inventory: GridInventory<GameItemMeta>,
id: string,
itemData: ItemData,
): MutationResult {
const shape = parseShapeString(itemData.shape);
const transform = findFirstValidPlacement(inventory, shape);
if (!transform) {
return { success: false, reason: "无可用位置" };
}
const meta: GameItemMeta = { itemData, shape };
placeItem(inventory, { id, shape, transform, meta });
return { success: true };
}
/**
* Searches for the first valid placement in the grid (row by row, left to right).
* Returns the transform or null if no valid placement exists.
*/
function findFirstValidPlacement(
inventory: GridInventory<GameItemMeta>,
shape: ParsedShape,
): Transform2D | null {
for (let y = 0; y <= inventory.height - shape.height; y++) {
for (let x = 0; x <= inventory.width - shape.width; x++) {
const transform: Transform2D = {
offset: { x, y },
rotation: 0,
flipX: false,
flipY: false,
};
const result = validatePlacement(inventory, shape, transform);
if (result.valid) {
return transform;
}
}
}
return null;
}

View File

@ -6,8 +6,6 @@ export type {
MutationResult,
PlacementResult,
} from "./types";
export type { GameItemMeta, GameItem } from "./types";
export {
createGridInventory,
flipItem,
@ -21,4 +19,4 @@ export {
validatePlacement,
} from "./transform";
export { createItemIn } from "./factory";
export type { GameItemMeta, GameItem } from "./types";

View File

@ -1,46 +1,34 @@
import type { ParsedShape } from "../utils/parse-shape";
import type { Transform2D } from "../utils/shape-collision";
import type { ParsedShape } from '../utils/parse-shape';
import type { Transform2D } from '../utils/shape-collision';
import {
checkBoardCollision,
checkBounds,
flipXTransform,
flipYTransform,
rotateTransform,
transformShape,
} from "../utils/shape-collision";
import type {
CellKey,
GridInventory,
InventoryItem,
MutationResult,
PlacementResult,
} from "./types";
checkBoardCollision,
checkBounds,
flipXTransform,
flipYTransform,
rotateTransform,
transformShape,
} from '../utils/shape-collision';
import type { CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './types';
/**
* Creates a new empty grid inventory.
* Note: When used inside `.produce()`, call this before returning the draft.
*/
export function createGridInventory<TMeta = Record<string, unknown>>(
width: number,
height: number,
): GridInventory<TMeta> {
return {
width,
height,
items: new Map<string, InventoryItem<TMeta>>(),
occupiedCells: new Set<CellKey>(),
};
export function createGridInventory<TMeta = Record<string, unknown>>(width: number, height: number): GridInventory<TMeta> {
return {
width,
height,
items: new Map<string, InventoryItem<TMeta>>(),
occupiedCells: new Set<CellKey>(),
};
}
/**
* Builds a Set of occupied cell keys from a shape and transform.
*/
function getShapeCellKeys(
shape: ParsedShape,
transform: Transform2D,
): Set<CellKey> {
const cells = transformShape(shape, transform);
return new Set(cells.map((c) => `${c.x},${c.y}` as CellKey));
function getShapeCellKeys(shape: ParsedShape, transform: Transform2D): Set<CellKey> {
const cells = transformShape(shape, transform);
return new Set(cells.map(c => `${c.x},${c.y}` as CellKey));
}
/**
@ -48,19 +36,19 @@ function getShapeCellKeys(
* Checks bounds and collision with all other items.
*/
export function validatePlacement<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
shape: InventoryItem<TMeta>["shape"],
transform: Transform2D,
inventory: GridInventory<TMeta>,
shape: InventoryItem<TMeta>['shape'],
transform: Transform2D
): PlacementResult {
if (!checkBounds(shape, transform, inventory.width, inventory.height)) {
return { valid: false, reason: "超出边界" };
}
if (!checkBounds(shape, transform, inventory.width, inventory.height)) {
return { valid: false, reason: '超出边界' };
}
if (checkBoardCollision(shape, transform, inventory.occupiedCells)) {
return { valid: false, reason: "与已有物品重叠" };
}
if (checkBoardCollision(shape, transform, inventory.occupiedCells)) {
return { valid: false, reason: '与已有物品重叠' };
}
return { valid: true };
return { valid: true };
}
/**
@ -68,33 +56,27 @@ export function validatePlacement<TMeta = Record<string, unknown>>(
* **Mutates directly** call inside a `.produce()` callback.
* Does not validate; call `validatePlacement` first.
*/
export function placeItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
item: InventoryItem<TMeta>,
): void {
const cells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of cells) {
inventory.occupiedCells.add(cellKey);
}
inventory.items.set(item.id, item);
export function placeItem<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>, item: InventoryItem<TMeta>): void {
const cells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of cells) {
inventory.occupiedCells.add(cellKey);
}
inventory.items.set(item.id, item);
}
/**
* Removes an item from the grid by its ID.
* **Mutates directly** call inside a `.produce()` callback.
*/
export function removeItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
): void {
const item = inventory.items.get(itemId);
if (!item) return;
export function removeItem<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>, itemId: string): void {
const item = inventory.items.get(itemId);
if (!item) return;
const cells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of cells) {
inventory.occupiedCells.delete(cellKey);
}
inventory.items.delete(itemId);
const cells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of cells) {
inventory.occupiedCells.delete(cellKey);
}
inventory.items.delete(itemId);
}
/**
@ -103,41 +85,41 @@ export function removeItem<TMeta = Record<string, unknown>>(
* Validates before applying; returns result indicating success.
*/
export function moveItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
newTransform: Transform2D,
inventory: GridInventory<TMeta>,
itemId: string,
newTransform: Transform2D
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: "物品不存在" };
}
// Temporarily remove item's cells for validation
const oldCells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of oldCells) {
inventory.occupiedCells.delete(cellKey);
}
// Validate new position
const validation = validatePlacement(inventory, item.shape, newTransform);
if (!validation.valid) {
// Restore old cells
for (const cellKey of oldCells) {
inventory.occupiedCells.add(cellKey);
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: '物品不存在' };
}
return { success: false, reason: validation.reason };
}
// Apply new transform
item.transform = newTransform;
// Temporarily remove item's cells for validation
const oldCells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of oldCells) {
inventory.occupiedCells.delete(cellKey);
}
// Add new cells
const newCells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of newCells) {
inventory.occupiedCells.add(cellKey);
}
// Validate new position
const validation = validatePlacement(inventory, item.shape, newTransform);
if (!validation.valid) {
// Restore old cells
for (const cellKey of oldCells) {
inventory.occupiedCells.add(cellKey);
}
return { success: false, reason: validation.reason };
}
return { success: true };
// Apply new transform
item.transform = newTransform;
// Add new cells
const newCells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of newCells) {
inventory.occupiedCells.add(cellKey);
}
return { success: true };
}
/**
@ -146,17 +128,17 @@ export function moveItem<TMeta = Record<string, unknown>>(
* Validates before applying; returns result indicating success.
*/
export function rotateItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
degrees: number,
inventory: GridInventory<TMeta>,
itemId: string,
degrees: number
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: "物品不存在" };
}
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: '物品不存在' };
}
const rotatedTransform = rotateTransform(item.transform, degrees);
return moveItem(inventory, itemId, rotatedTransform);
const rotatedTransform = rotateTransform(item.transform, degrees);
return moveItem(inventory, itemId, rotatedTransform);
}
/**
@ -165,53 +147,50 @@ export function rotateItem<TMeta = Record<string, unknown>>(
* Validates before applying; returns result indicating success.
*/
export function flipItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
axis: "x" | "y",
inventory: GridInventory<TMeta>,
itemId: string,
axis: 'x' | 'y'
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: "物品不存在" };
}
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: '物品不存在' };
}
const flippedTransform =
axis === "x"
? flipXTransform(item.transform)
: flipYTransform(item.transform);
const flippedTransform = axis === 'x'
? flipXTransform(item.transform)
: flipYTransform(item.transform);
return moveItem(inventory, itemId, flippedTransform);
return moveItem(inventory, itemId, flippedTransform);
}
/**
* Returns a copy of the occupied cells set.
*/
export function getOccupiedCellSet<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
): Set<CellKey> {
return new Set(inventory.occupiedCells);
export function getOccupiedCellSet<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>): Set<CellKey> {
return new Set(inventory.occupiedCells);
}
/**
* Finds the item occupying the given cell, if any.
*/
export function getItemAtCell<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
x: number,
y: number,
inventory: GridInventory<TMeta>,
x: number,
y: number
): InventoryItem<TMeta> | undefined {
const cellKey = `${x},${y}` as CellKey;
if (!inventory.occupiedCells.has(cellKey)) {
return undefined;
}
for (const item of inventory.items.values()) {
const cells = getShapeCellKeys(item.shape, item.transform);
if (cells.has(cellKey)) {
return item;
const cellKey = `${x},${y}` as CellKey;
if (!inventory.occupiedCells.has(cellKey)) {
return undefined;
}
}
return undefined;
for (const item of inventory.items.values()) {
const cells = getShapeCellKeys(item.shape, item.transform);
if (cells.has(cellKey)) {
return item;
}
}
return undefined;
}
/**
@ -219,39 +198,36 @@ export function getItemAtCell<TMeta = Record<string, unknown>>(
* Returns a Map of itemId -> item for deduplication.
*/
export function getAdjacentItems<TMeta>(
inventory: GridInventory<TMeta>,
itemId: string,
inventory: GridInventory<TMeta>,
itemId: string
): Map<string, InventoryItem<TMeta>> {
const item = inventory.items.get(itemId);
if (!item) {
return new Map();
}
const ownCells = getShapeCellKeys(item.shape, item.transform);
const adjacent = new Map<string, InventoryItem<TMeta>>();
for (const cellKey of ownCells) {
const [cx, cy] = cellKey.split(",").map(Number);
const neighbors: CellKey[] = [
`${cx + 1},${cy}` as CellKey,
`${cx - 1},${cy}` as CellKey,
`${cx},${cy + 1}` as CellKey,
`${cx},${cy - 1}` as CellKey,
];
for (const neighborKey of neighbors) {
if (
inventory.occupiedCells.has(neighborKey) &&
!ownCells.has(neighborKey)
) {
const [nx, ny] = neighborKey.split(",").map(Number);
const neighborItem = getItemAtCell(inventory, nx, ny);
if (neighborItem) {
adjacent.set(neighborItem.id, neighborItem);
}
}
const item = inventory.items.get(itemId);
if (!item) {
return new Map();
}
}
return adjacent;
const ownCells = getShapeCellKeys(item.shape, item.transform);
const adjacent = new Map<string, InventoryItem<TMeta>>();
for (const cellKey of ownCells) {
const [cx, cy] = cellKey.split(',').map(Number);
const neighbors: CellKey[] = [
`${cx + 1},${cy}` as CellKey,
`${cx - 1},${cy}` as CellKey,
`${cx},${cy + 1}` as CellKey,
`${cx},${cy - 1}` as CellKey,
];
for (const neighborKey of neighbors) {
if (inventory.occupiedCells.has(neighborKey) && !ownCells.has(neighborKey)) {
const [nx, ny] = neighborKey.split(',').map(Number);
const neighborItem = getItemAtCell(inventory, nx, ny);
if (neighborItem) {
adjacent.set(neighborItem.id, neighborItem);
}
}
}
}
return adjacent;
}

View File

@ -15,48 +15,6 @@ import {
type GridInventory,
type InventoryItem,
} from "@/samples/slay-the-spire-like/system/grid-inventory";
import { createItemIn } from "@/samples/slay-the-spire-like/system/grid-inventory/factory";
import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import type {
CardData,
ItemData,
} from "@/samples/slay-the-spire-like/system/types";
/**
* Helper: create a minimal CardData for testing.
*/
function createTestCardData(id: string, name: string, desc: string): CardData {
return {
id,
name,
desc,
type: "item",
costType: "energy",
costCount: 1,
targetType: "single",
effects: [],
};
}
/**
* Helper: create a minimal ItemData for testing.
*/
function createTestItemData(
id: string,
name: string,
shapeStr: string,
desc: string,
): ItemData {
return {
id,
type: "weapon",
name,
shape: shapeStr,
card: createTestCardData(id, name, desc),
price: 10,
description: desc,
};
}
/**
* Helper: create a test inventory item.
@ -577,81 +535,4 @@ describe("grid-inventory", () => {
expect(adjShield.has("sword")).toBe(true);
});
});
describe("createItemIn", () => {
it("should place item at first valid position", () => {
const inv = createGridInventory<GameItemMeta>(6, 4);
const itemData = createTestItemData("sword", "长剑", "oee", "攻击");
const result = createItemIn(inv, "sword-1", itemData);
expect(result).toEqual({ success: true });
expect(inv.items.size).toBe(1);
expect(inv.occupiedCells.size).toBe(3); // shape "oee" has 3 cells
expect(inv.items.get("sword-1")?.meta?.itemData.id).toBe("sword");
});
it("should skip occupied cells and find next valid position", () => {
const inv = createGridInventory<GameItemMeta>(6, 4);
// Place first item manually at origin
const firstItem = createTestItem("existing", "oee");
placeItem(inv, firstItem);
const itemData = createTestItemData("shield", "盾牌", "o", "防御");
const result = createItemIn(inv, "shield-1", itemData);
expect(result).toEqual({ success: true });
// Shield should be at x=3, y=0 (first available spot after "oee" at x=0-2)
const placedItem = inv.items.get("shield-1");
expect(placedItem?.transform.offset).toEqual({ x: 3, y: 0 });
});
it("should return error when no valid placement exists", () => {
const inv = createGridInventory<GameItemMeta>(3, 3);
// Fill the grid completely with 1x1 items
for (let i = 0; i < 9; i++) {
const itemData = createTestItemData(`item${i}`, `物品${i}`, "o", "");
createItemIn(inv, `item-${i}`, itemData);
}
// Try to place one more item
const itemData = createTestItemData("overflow", "溢出", "oee", "");
const result = createItemIn(inv, "overflow-1", itemData);
expect(result).toEqual({ success: false, reason: "无可用位置" });
});
it("should handle multi-cell items correctly", () => {
const inv = createGridInventory<GameItemMeta>(4, 4);
const itemData = createTestItemData("lshape", "L形", "oes", "特殊");
const result = createItemIn(inv, "lshape-1", itemData);
expect(result).toEqual({ success: true });
expect(inv.items.size).toBe(1);
// Shape "oes" has 3 cells
expect(inv.occupiedCells.size).toBe(3);
});
it("should not place item when inventory is completely full", () => {
const inv = createGridInventory<GameItemMeta>(2, 2);
// Fill with 1x1 items
const itemData = createTestItemData("small", "小物品", "o", "");
createItemIn(inv, "small-1", itemData);
createItemIn(inv, "small-2", itemData);
createItemIn(inv, "small-3", itemData);
createItemIn(inv, "small-4", itemData);
// Grid is now full
const overflowItem = createTestItemData("overflow", "溢出", "o", "");
const result = createItemIn(inv, "overflow-5", overflowItem);
expect(result).toEqual({ success: false, reason: "无可用位置" });
expect(inv.items.size).toBe(4);
});
});
});