diff --git a/src/samples/slay-the-spire-like/system/combat/effects.ts b/src/samples/slay-the-spire-like/system/combat/effects.ts index 7ea77a1..df01961 100644 --- a/src/samples/slay-the-spire-like/system/combat/effects.ts +++ b/src/samples/slay-the-spire-like/system/combat/effects.ts @@ -1,7 +1,7 @@ -import {CombatEntity, EffectTable} from "./types"; -import {EffectData} from "@/samples/slay-the-spire-like/system/types"; -import {PlayerEntity} from "@/samples/slay-the-spire-like/system/combat/types"; -import {CombatState} from "./types"; +import {CombatEntity, CombatState, EffectTable, PlayerEntity} from "./types"; +import {CardData, EffectData} from "@/samples/slay-the-spire-like/system/types"; +import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress/types"; +import {GridInventory} from "@/samples/slay-the-spire-like/system/grid-inventory/types"; export function addEffect(effects: EffectTable, effect: EffectData, stacks: number){ let current = effects[effect.id]; @@ -82,4 +82,28 @@ export function* getAliveEnemies(state: CombatState) { export function getCombatEntity(state: CombatState, entityKey: string){ return entityKey === 'player' ? state.player : state.enemies.find(e => e.id === entityKey); -} \ No newline at end of file +} + +export function canPlayCard(player: PlayerEntity, costType: CardData['costType'], costCount: number, itemId: string, inventory: GridInventory): boolean { + if (costType === 'energy') { + return player.energy >= costCount; + } + if (costType === 'uses') { + const item = inventory.items.get(itemId); + if (!item || !item.meta) return false; + const depletion = item.meta.depletion ?? 0; + return depletion < costCount; + } + return true; +} + +export function payCardCost(player: PlayerEntity, costType: CardData['costType'], costCount: number, itemId: string, inventory: GridInventory): void { + if (costType === 'energy') { + player.energy -= costCount; + } else if (costType === 'uses') { + const item = inventory.items.get(itemId); + if (item && item.meta) { + item.meta.depletion = (item.meta.depletion ?? 0) + costCount; + } + } +} diff --git a/src/samples/slay-the-spire-like/system/combat/prompts.ts b/src/samples/slay-the-spire-like/system/combat/prompts.ts index bc73048..26f8bf7 100644 --- a/src/samples/slay-the-spire-like/system/combat/prompts.ts +++ b/src/samples/slay-the-spire-like/system/combat/prompts.ts @@ -1,5 +1,6 @@ import { createPromptDef } from "@/core/game"; import {CombatGameContext} from "./types"; +import {canPlayCard} from "@/samples/slay-the-spire-like/system/combat/effects"; export const prompts = { mainAction: createPromptDef<[string, string?]>( @@ -18,7 +19,12 @@ export async function promptMainAction(game: CombatGameContext){ if(!exists) throw `卡牌"${cardId}"不在手牌中`; const card = game.value.player.deck.cards[cardId]; - const {targetType} = card.cardData; + const {cardData, itemId} = card; + if(!canPlayCard(game.value.player, cardData.costType, cardData.costCount, itemId, game.value.inventory)){ + throw `无法支付卡牌"${cardId}"的费用`; + } + + const {targetType} = cardData; if(targetType === 'single'){ if(!targetId) throw `请指定目标`; const target = game.value.enemies.find(e => e.id === targetId); diff --git a/src/samples/slay-the-spire-like/system/combat/triggers.ts b/src/samples/slay-the-spire-like/system/combat/triggers.ts index 88895c1..46aa392 100644 --- a/src/samples/slay-the-spire-like/system/combat/triggers.ts +++ b/src/samples/slay-the-spire-like/system/combat/triggers.ts @@ -4,7 +4,7 @@ import { addItemEffect, getAliveEnemies, onEntityPostureDamage, onEntityEffectUpkeep, - onPlayerItemEffectUpkeep, onItemDiscard, onItemPlay + onPlayerItemEffectUpkeep, onItemDiscard, onItemPlay, payCardCost } from "@/samples/slay-the-spire-like/system/combat/effects"; import {promptMainAction} from "@/samples/slay-the-spire-like/system/combat/prompts"; import {moveToRegion, shuffle} from "@/core/region"; @@ -60,8 +60,10 @@ function createTriggers(){ onCardPlayed: createTrigger("onCardPlayed", async ctx => { await ctx.game.produceAsync(draft => { const {cards, regions} = draft.player.deck; - moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile); - onItemPlay(draft.player, cards[ctx.cardId].itemId); + const card = cards[ctx.cardId]; + payCardCost(draft.player, card.cardData.costType, card.cardData.costCount, card.itemId, draft.inventory); + moveToRegion(card, regions.hand, regions.discardPile); + onItemPlay(draft.player, card.itemId); }); }), onCardDiscarded: createTrigger("onCardDiscarded", async ctx => { @@ -190,7 +192,6 @@ export function createStartWith(build: (triggers: Triggers) => void){ const action = await promptMainAction(game); if (action.action === "end-turn") break; if (action.action === "play") { - // TODO: energy/use consumption handling await triggers.onCardPlayed.execute(game, action); } } diff --git a/tests/samples/slay-the-spire-like/combat/effects.test.ts b/tests/samples/slay-the-spire-like/combat/effects.test.ts index da8ac69..a70d399 100644 --- a/tests/samples/slay-the-spire-like/combat/effects.test.ts +++ b/tests/samples/slay-the-spire-like/combat/effects.test.ts @@ -10,14 +10,47 @@ import { onItemDiscard, getAliveEnemies, getCombatEntity, + canPlayCard, + payCardCost, } from '@/samples/slay-the-spire-like/system/combat/effects'; import type { CombatEntity, CombatState, EffectTable, PlayerEntity, EnemyEntity } from '@/samples/slay-the-spire-like/system/combat/types'; import type { EffectData } from '@/samples/slay-the-spire-like/system/types'; +import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory/types'; +import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types'; +import type { ParsedShape } from '@/samples/slay-the-spire-like/system/utils/parse-shape'; +import type { Transform2D } from '@/samples/slay-the-spire-like/system/utils/shape-collision'; function createEffect(id: string, lifecycle: EffectData['lifecycle']): EffectData { return { id, name: id, description: '', lifecycle }; } +function createCard(id: string, costType: 'energy' | 'uses' | 'none', costCount: number) { + return { id, name: id, desc: '', type: 'item' as const, costType, costCount, targetType: 'none' as const, effects: [] as const }; +} + +function createItem(itemId: string, cardId: string, costType: 'energy' | 'uses' | 'none', costCount: number, depletion = 0): InventoryItem { + return { + id: itemId, + shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape, + transform: { x: 0, y: 0, rotation: 0, flipX: false, flipY: false } as unknown as Transform2D, + meta: { + itemData: { id: itemId, type: 'weapon', name: itemId, shape: '1x1', card: createCard(cardId, costType, costCount), price: 0, description: '' }, + shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape, + depletion: costType === 'uses' ? depletion : undefined, + }, + }; +} + +function createInventory(items: InventoryItem[]): GridInventory { + const map = new Map>(); + const occupied = new Set(); + for (const item of items) { + map.set(item.id, item); + occupied.add(`${item.transform.x},${item.transform.y}`); + } + return { width: 6, height: 4, items: map, occupiedCells: occupied }; +} + function createCombatEntity(hp = 10, maxHp = 10): CombatEntity { return { effects: {}, @@ -414,4 +447,104 @@ describe('combat/effects', () => { expect(entity).toBeUndefined(); }); }); + + describe('canPlayCard', () => { + it('should allow playing energy card when player has enough energy', () => { + const player = createPlayerEntity(); + player.energy = 3; + const inventory = createInventory([]); + + const result = canPlayCard(player, 'energy', 2, 'any', inventory); + + expect(result).toBe(true); + }); + + it('should reject playing energy card when player lacks energy', () => { + const player = createPlayerEntity(); + player.energy = 1; + const inventory = createInventory([]); + + const result = canPlayCard(player, 'energy', 2, 'any', inventory); + + expect(result).toBe(false); + }); + + it('should allow playing uses card when item has remaining uses', () => { + const player = createPlayerEntity(); + const item = createItem('potion-1', 'potion-card', 'uses', 3, 1); + const inventory = createInventory([item]); + + const result = canPlayCard(player, 'uses', 3, 'potion-1', inventory); + + expect(result).toBe(true); + }); + + it('should reject playing uses card when item is depleted', () => { + const player = createPlayerEntity(); + const item = createItem('potion-1', 'potion-card', 'uses', 3, 3); + const inventory = createInventory([item]); + + const result = canPlayCard(player, 'uses', 3, 'potion-1', inventory); + + expect(result).toBe(false); + }); + + it('should reject playing uses card when item not in inventory', () => { + const player = createPlayerEntity(); + const inventory = createInventory([]); + + const result = canPlayCard(player, 'uses', 1, 'missing', inventory); + + expect(result).toBe(false); + }); + + it('should always allow playing none cost card', () => { + const player = createPlayerEntity(); + player.energy = 0; + const inventory = createInventory([]); + + const result = canPlayCard(player, 'none', 0, 'any', inventory); + + expect(result).toBe(true); + }); + }); + + describe('payCardCost', () => { + it('should deduct energy for energy cost card', () => { + const player = createPlayerEntity(); + player.energy = 3; + const inventory = createInventory([]); + + payCardCost(player, 'energy', 2, 'any', inventory); + + expect(player.energy).toBe(1); + }); + + it('should increment depletion for uses cost card', () => { + const player = createPlayerEntity(); + const item = createItem('potion-1', 'potion-card', 'uses', 3, 1); + const inventory = createInventory([item]); + + payCardCost(player, 'uses', 3, 'potion-1', inventory); + + expect(item.meta?.depletion).toBe(4); + }); + + it('should do nothing for none cost card', () => { + const player = createPlayerEntity(); + player.energy = 3; + const inventory = createInventory([]); + + payCardCost(player, 'none', 0, 'any', inventory); + + expect(player.energy).toBe(3); + }); + + it('should handle missing item gracefully for uses cost', () => { + const player = createPlayerEntity(); + const inventory = createInventory([]); + + expect(() => payCardCost(player, 'uses', 1, 'missing', inventory)).not.toThrow(); + }); + }); });