feat(slay-the-spire-like): add automatic item placement to grid

inventory

Introduce `createItemIn` to automatically find the first valid
placement for a new item within the grid inventory.
This commit is contained in:
hypercross 2026-04-20 16:24:33 +08:00
parent 34cb828f60
commit ca9912a5f5
4 changed files with 214 additions and 135 deletions

View File

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

View File

@ -0,0 +1,52 @@
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,6 +6,8 @@ export type {
MutationResult, MutationResult,
PlacementResult, PlacementResult,
} from "./types"; } from "./types";
export type { GameItemMeta, GameItem } from "./types";
export { export {
createGridInventory, createGridInventory,
flipItem, flipItem,
@ -19,4 +21,4 @@ export {
validatePlacement, validatePlacement,
} from "./transform"; } from "./transform";
export type { GameItemMeta, GameItem } from "./types"; export { createItemIn } from "./factory";

View File

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