diff --git a/src/samples/slay-the-spire-like/deck/factory.ts b/src/samples/slay-the-spire-like/deck/factory.ts new file mode 100644 index 0000000..b038403 --- /dev/null +++ b/src/samples/slay-the-spire-like/deck/factory.ts @@ -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): 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): PlayerDeck { + const cards: Record = {}; + 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, +}; diff --git a/src/samples/slay-the-spire-like/deck/index.ts b/src/samples/slay-the-spire-like/deck/index.ts new file mode 100644 index 0000000..3178415 --- /dev/null +++ b/src/samples/slay-the-spire-like/deck/index.ts @@ -0,0 +1,8 @@ +export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types'; +export { + generateDeckFromInventory, + createStatusCard, + createDeckRegions, + createPlayerDeck, + generateCardId, +} from './factory'; diff --git a/src/samples/slay-the-spire-like/deck/types.ts b/src/samples/slay-the-spire-like/deck/types.ts new file mode 100644 index 0000000..58d81d7 --- /dev/null +++ b/src/samples/slay-the-spire-like/deck/types.ts @@ -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; + +/** + * Player deck structure containing card pools. + */ +export interface PlayerDeck { + /** All cards indexed by ID */ + cards: Record; + /** 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; +} diff --git a/src/samples/slay-the-spire-like/index.ts b/src/samples/slay-the-spire-like/index.ts index b4079ee..d5d5eb4 100644 --- a/src/samples/slay-the-spire-like/index.ts +++ b/src/samples/slay-the-spire-like/index.ts @@ -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 { diff --git a/tests/samples/slay-the-spire-like/deck/factory.test.ts b/tests/samples/slay-the-spire-like/deck/factory.test.ts new file mode 100644 index 0000000..ad19354 --- /dev/null +++ b/tests/samples/slay-the-spire-like/deck/factory.test.ts @@ -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 { + const inv = createGridInventory(6, 4); + + // Item "短刀" with shape "oe" (2 cells) + const meta1 = createTestMeta('短刀', '【攻击3】【攻击3】', 'oe'); + const item1: InventoryItem = { + 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 = { + 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(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([]); + }); + }); +});