feat: deck generation

This commit is contained in:
hypercross 2026-04-14 15:46:08 +08:00
parent 4fbd65e98c
commit 760cfc9954
5 changed files with 502 additions and 0 deletions

View File

@ -0,0 +1,204 @@
import type { CellKey, GridInventory, InventoryItem } from '../grid-inventory/types';
import type { GameItemMeta } from '../progress/types';
import { createRegion, createRegionAxis } from '@/core/region';
import type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types';
/**
* Generates a unique card ID for a cell within an item.
*/
function generateCardId(itemId: string, cellIndex: number): string {
return `card-${itemId}-${cellIndex}`;
}
/**
* Collects all cell keys from an item's shape in a deterministic order.
* Iterates the shape grid row by row, left to right, top to bottom.
*/
function getItemCells(item: InventoryItem<GameItemMeta>): CellKey[] {
const cells: CellKey[] = [];
const { shape, transform } = item;
const { grid } = shape;
const { offset, rotation, flipX, flipY } = transform;
// Track local dimensions (may swap on rotation)
let localWidth = grid[0]?.length || 1;
let localHeight = grid.length;
for (let gy = 0; gy < grid.length; gy++) {
for (let gx = 0; gx < grid[gy].length; gx++) {
if (!grid[gy][gx]) continue;
// Start from grid coordinates
let x = gx;
let y = gy;
// Apply rotation (90 degree increments, clockwise)
const rotTimes = ((rotation % 4) + 4) % 4;
for (let r = 0; r < rotTimes; r++) {
const newX = localHeight - 1 - y;
const newY = x;
x = newX;
y = newY;
// Swap dimensions for next iteration
const tmp = localWidth;
localWidth = localHeight;
localHeight = tmp;
}
// Reset local dimensions for fresh computation per cell
localWidth = grid[0]?.length || 1;
localHeight = grid.length;
// Apply flips
if (flipX) {
x = -x;
}
if (flipY) {
y = -y;
}
// Apply offset
const finalX = x + offset.x;
const finalY = y + offset.y;
cells.push(`${finalX},${finalY}`);
}
}
return cells;
}
/**
* Creates a single card from an inventory item.
*
* @param itemId - The inventory item instance ID
* @param itemData - The CSV item data
* @param cellKey - The cell key ("x,y") this card represents
* @param cellIndex - Index of the cell for unique ID generation
*/
function createItemCard(
itemId: string,
itemData: GameItemMeta['itemData'],
cellKey: CellKey,
cellIndex: number
): GameCard {
const cardId = generateCardId(itemId, cellIndex);
return {
id: cardId,
regionId: '',
position: [],
sourceItemId: itemId,
itemData,
cellKey,
displayName: itemData.name,
description: itemData.desc,
};
}
/**
* Creates a status card that does not correspond to any inventory item.
* Status cards represent temporary effects like wounds, stuns, etc.
*
* @param id - Unique identifier for the card instance
* @param displayName - Display name (e.g. "伤口", "眩晕")
* @param description - Card description / ability text
*/
function createStatusCard(
id: string,
displayName: string,
description: string
): GameCard {
return {
id,
regionId: '',
position: [],
sourceItemId: null,
itemData: null,
cellKey: null,
displayName,
description,
};
}
/**
* Generates a complete player deck from the current inventory state.
*
* For each item in the inventory, N cards are generated where N is the
* number of cells the item occupies (shape.count).
*
* All generated cards are placed in the draw pile initially.
*
* @param inventory - The player's grid inventory
* @returns A PlayerDeck with all cards in the draw pile
*/
function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): PlayerDeck {
const cards: Record<string, GameCard> = {};
const drawPile: string[] = [];
for (const item of inventory.items.values()) {
const itemData = item.meta?.itemData;
if (!itemData) continue;
// Generate one card per occupied cell in the item's shape
const cellCount = item.shape.count;
const cells = getItemCells(item);
for (let i = 0; i < cellCount; i++) {
const cellKey = cells[i] ?? `${i},0`;
const card = createItemCard(item.id, itemData, cellKey, i);
cards[card.id] = card;
drawPile.push(card.id);
}
}
return {
cards,
drawPile,
hand: [],
discardPile: [],
exhaustPile: [],
};
}
/**
* Creates region definitions for deck management.
* Returns regions for draw pile, hand, discard pile, and exhaust pile.
*/
function createDeckRegions(): DeckRegions {
return {
drawPile: createRegion('drawPile', [
createRegionAxis('index', 0, 0), // unbounded
]),
hand: createRegion('hand', [
createRegionAxis('index', 0, 0),
]),
discardPile: createRegion('discardPile', [
createRegionAxis('index', 0, 0),
]),
exhaustPile: createRegion('exhaustPile', [
createRegionAxis('index', 0, 0),
]),
};
}
/**
* Creates an empty player deck structure.
*/
function createPlayerDeck(): PlayerDeck {
return {
cards: {},
drawPile: [],
hand: [],
discardPile: [],
exhaustPile: [],
};
}
export {
generateDeckFromInventory,
createStatusCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
};

View File

@ -0,0 +1,8 @@
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types';
export {
generateDeckFromInventory,
createStatusCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
} from './factory';

View File

@ -0,0 +1,73 @@
import type { Part } from '@/core/part';
import type { Region } from '@/core/region';
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
import type { CellKey } from '../grid-inventory/types';
/**
* Metadata for a game card.
* Bridges inventory item data with the card system.
*/
export interface GameCardMeta {
/**
* Source item instance ID that this card was generated from.
* `null` for status cards (e.g. wound, stun) that don't correspond to an inventory item.
*/
sourceItemId: string | null;
/**
* Original CSV item data. `null` for status cards.
*/
itemData: HeroItemFighter1 | null;
/**
* The cell key ("x,y") this card represents within the source item's shape.
* `null` for status cards.
*/
cellKey: CellKey | null;
/**
* Display name of the card.
* For item cards: derived from itemData.name.
* For status cards: custom name (e.g. "伤口", "眩晕").
*/
displayName: string;
/**
* Card description / ability text.
* For item cards: derived from itemData.desc.
* For status cards: custom description.
*/
description: string;
}
/**
* A card instance in the game.
* Cards are generated from inventory items or created as status effects.
*/
export type GameCard = Part<GameCardMeta>;
/**
* Player deck structure containing card pools.
*/
export interface PlayerDeck {
/** All cards indexed by ID */
cards: Record<string, GameCard>;
/** Card IDs in the draw pile */
drawPile: string[];
/** Card IDs in the player's hand */
hand: string[];
/** Card IDs in the discard pile */
discardPile: string[];
/** Card IDs in the exhaust pile (removed from combat) */
exhaustPile: string[];
}
/**
* Region structure for deck management.
*/
export interface DeckRegions {
/** Draw pile region */
drawPile: Region;
/** Hand region */
hand: Region;
/** Discard pile region */
discardPile: Region;
/** Exhaust pile region */
exhaustPile: Region;
}

View File

@ -3,6 +3,16 @@ export { heroItemFighter1Data, encounterDesertData } from './data';
export { default as encounterDesertCsv } from './data/encounterDesert.csv';
export type { EncounterDesert } from './data/encounterDesert.csv';
// Deck
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './deck';
export {
generateDeckFromInventory,
createStatusCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
} from './deck';
// Grid Inventory
export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './grid-inventory';
export {

View File

@ -0,0 +1,207 @@
import { describe, it, expect } from 'vitest';
import {
generateDeckFromInventory,
createStatusCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
} from '@/samples/slay-the-spire-like/deck/factory';
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
import type { GameItemMeta } from '@/samples/slay-the-spire-like/progress/types';
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
/**
* Helper: create a minimal GameItemMeta for testing.
*/
function createTestMeta(name: string, desc: string, shapeStr: string): GameItemMeta {
const shape = parseShapeString(shapeStr);
return {
itemData: {
type: 'weapon',
name,
shape: shapeStr,
costType: 'energy',
costCount: 1,
targetType: 'single',
price: 10,
desc,
},
shape,
};
}
/**
* Helper: create a test inventory with some items.
*/
function createTestInventory(): GridInventory<GameItemMeta> {
const inv = createGridInventory<GameItemMeta>(6, 4);
// Item "短刀" with shape "oe" (2 cells)
const meta1 = createTestMeta('短刀', '【攻击3】【攻击3】', 'oe');
const item1: InventoryItem<GameItemMeta> = {
id: 'dagger-1',
shape: meta1.shape,
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
meta: meta1,
};
placeItem(inv, item1);
// Item "盾" with shape "oesw" (4 cells)
const meta2 = createTestMeta('盾', '【防御3】', 'oesw');
const item2: InventoryItem<GameItemMeta> = {
id: 'shield-1',
shape: meta2.shape,
transform: { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 0 } },
meta: meta2,
};
placeItem(inv, item2);
return inv;
}
describe('deck/factory', () => {
describe('generateCardId', () => {
it('should generate deterministic unique IDs', () => {
expect(generateCardId('item-1', 0)).toBe('card-item-1-0');
expect(generateCardId('item-1', 1)).toBe('card-item-1-1');
expect(generateCardId('item-2', 0)).toBe('card-item-2-0');
});
});
describe('createStatusCard', () => {
it('should create a card with null sourceItemId and itemData', () => {
const card = createStatusCard('wound-1', '伤口', '无法被弃牌');
expect(card.id).toBe('wound-1');
expect(card.sourceItemId).toBeNull();
expect(card.itemData).toBeNull();
expect(card.cellKey).toBeNull();
expect(card.displayName).toBe('伤口');
expect(card.description).toBe('无法被弃牌');
});
it('should have empty region and position', () => {
const card = createStatusCard('stun-1', '眩晕', '跳过出牌阶段');
expect(card.regionId).toBe('');
expect(card.position).toEqual([]);
});
});
describe('generateDeckFromInventory', () => {
it('should generate correct number of cards based on shape cell counts', () => {
const inv = createTestInventory();
// "短刀" has 2 cells, "盾" has 4 cells = 6 total
const deck = generateDeckFromInventory(inv);
expect(Object.keys(deck.cards).length).toBe(6);
expect(deck.drawPile.length).toBe(6);
expect(deck.hand).toEqual([]);
expect(deck.discardPile).toEqual([]);
expect(deck.exhaustPile).toEqual([]);
});
it('should link cards to their source items', () => {
const inv = createTestInventory();
const deck = generateDeckFromInventory(inv);
const daggerCards = Object.values(deck.cards).filter(
c => c.sourceItemId === 'dagger-1'
);
const shieldCards = Object.values(deck.cards).filter(
c => c.sourceItemId === 'shield-1'
);
expect(daggerCards.length).toBe(2);
expect(shieldCards.length).toBe(4);
// Verify item data
expect(daggerCards[0].itemData?.name).toBe('短刀');
expect(shieldCards[0].itemData?.name).toBe('盾');
});
it('should set displayName and description from item data', () => {
const inv = createTestInventory();
const deck = generateDeckFromInventory(inv);
for (const card of Object.values(deck.cards)) {
expect(card.displayName).toBeTruthy();
expect(card.description).toBeTruthy();
}
const daggerCard = Object.values(deck.cards).find(
c => c.itemData?.name === '短刀'
);
expect(daggerCard?.displayName).toBe('短刀');
expect(daggerCard?.description).toBe('【攻击3】【攻击3】');
});
it('should assign unique cell keys to each card from same item', () => {
const inv = createTestInventory();
const deck = generateDeckFromInventory(inv);
const daggerCards = Object.values(deck.cards).filter(
c => c.sourceItemId === 'dagger-1'
);
const cellKeys = daggerCards.map(c => c.cellKey);
const uniqueKeys = new Set(cellKeys);
expect(uniqueKeys.size).toBe(cellKeys.length);
});
it('should handle empty inventory', () => {
const inv = createGridInventory<GameItemMeta>(6, 4);
const deck = generateDeckFromInventory(inv);
expect(Object.keys(deck.cards).length).toBe(0);
expect(deck.drawPile).toEqual([]);
});
it('should place all cards in draw pile initially', () => {
const inv = createTestInventory();
const deck = generateDeckFromInventory(inv);
for (const cardId of deck.drawPile) {
expect(deck.cards[cardId]).toBeDefined();
}
// All cards are in draw pile
expect(new Set(deck.drawPile).size).toBe(Object.keys(deck.cards).length);
});
});
describe('createDeckRegions', () => {
it('should create regions for all deck zones', () => {
const regions = createDeckRegions();
expect(regions.drawPile.id).toBe('drawPile');
expect(regions.hand.id).toBe('hand');
expect(regions.discardPile.id).toBe('discardPile');
expect(regions.exhaustPile.id).toBe('exhaustPile');
});
it('should have empty childIds initially', () => {
const regions = createDeckRegions();
expect(regions.drawPile.childIds).toEqual([]);
expect(regions.hand.childIds).toEqual([]);
expect(regions.discardPile.childIds).toEqual([]);
expect(regions.exhaustPile.childIds).toEqual([]);
});
});
describe('createPlayerDeck', () => {
it('should create an empty deck structure', () => {
const deck = createPlayerDeck();
expect(deck.cards).toEqual({});
expect(deck.drawPile).toEqual([]);
expect(deck.hand).toEqual([]);
expect(deck.discardPile).toEqual([]);
expect(deck.exhaustPile).toEqual([]);
});
});
});