diff --git a/src/core/region.ts b/src/core/region.ts index 37dbee4..c82d523 100644 --- a/src/core/region.ts +++ b/src/core/region.ts @@ -1,5 +1,5 @@ import {Part} from "./part"; -import {RNG} from "@/utils/rng"; +import {ReadonlyRNG} from "@/utils/rng"; export type Region = { id: string; @@ -107,7 +107,7 @@ export function applyAlign(region: Region, parts: Record(region: Region, parts: Record>, rng: RNG){ +export function shuffle(region: Region, parts: Record>, rng: ReadonlyRNG){ if (region.childIds.length <= 1) return; const childIds = [...region.childIds]; 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 ad70d13..4ab3315 100644 --- a/src/samples/slay-the-spire-like/system/combat/effects.ts +++ b/src/samples/slay-the-spire-like/system/combat/effects.ts @@ -1,6 +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"; export function addEffect(effects: EffectTable, effect: EffectData, stacks: number){ let current = effects[effect.id]; @@ -43,7 +44,10 @@ export function onPlayerItemEffectUpkeep(entity: PlayerEntity){ } } -// TODO -export function onDraw(entity: PlayerEntity, cardId:string ){} -// TODO -export function onDiscard(entity: PlayerEntity, cardId: string){} +export function* getAliveEnemies(state: CombatState) { + for (let enemy of state.enemies) { + if (enemy.isAlive) { + yield enemy; + } + } +} 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 21bfd9e..bc73048 100644 --- a/src/samples/slay-the-spire-like/system/combat/prompts.ts +++ b/src/samples/slay-the-spire-like/system/combat/prompts.ts @@ -14,7 +14,7 @@ export async function promptMainAction(game: CombatGameContext){ action: 'end-turn' as 'end-turn' }; - const exists = game.value.player.deck.hand.includes(cardId); + const exists = game.value.player.deck.regions.hand.childIds.includes(cardId); if(!exists) throw `卡牌"${cardId}"不在手牌中`; const card = game.value.player.deck.cards[cardId]; 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 b8e17f2..3d21730 100644 --- a/src/samples/slay-the-spire-like/system/combat/triggers.ts +++ b/src/samples/slay-the-spire-like/system/combat/triggers.ts @@ -1,35 +1,132 @@ -import {createMiddlewareChain} from "../utils/middleware"; import {CombatGameContext} from "./types"; -import {getAliveEnemies} from "@/samples/slay-the-spire-like/system/combat/utils"; import { - onDiscard, onDraw, + addEntityEffect, + addItemEffect, + getAliveEnemies, onEntityEffectUpkeep, onPlayerItemEffectUpkeep } 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"; +import {createMiddlewareChain} from "@/utils/middleware"; +import {EffectData} from "@/samples/slay-the-spire-like/system/types"; +import {getAdjacentItems} from "@/samples/slay-the-spire-like/system/grid-inventory"; +import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress"; type TriggerTypes = { onCombatStart: {}, onTurnStart: { entityKey: "player" | string, }, onTurnEnd: { entityKey: "player" | string, }, - onShuffle: { entityKey: "player" | string, }, + onShuffle: {}, onCardPlayed: { cardId: string, targetId?: string }, onCardDiscarded: { cardId: string, }, onCardDrawn: { cardId: string, }, - onEffectApplied: { effectId: string, entityKey: "player" | string, stacks: number, }, + onDraw: {count: number}, + onEffectApplied: { effect: EffectData, entityKey: "player" | string, stacks: number, cardId?: string }, + onHpChange: { entityKey: "player" | string, amount: number}, } function createTriggers(){ - return { + const triggers = { onCombatStart: createTrigger("onCombatStart"), - onTurnStart: createTrigger("onTurnStart"), - onTurnEnd: createTrigger("onTurnEnd"), - onShuffle: createTrigger("onShuffle"), - onCardPlayed: createTrigger("onCardPlayed"), - onCardDiscarded: createTrigger("onCardDiscarded"), - onCardDrawn: createTrigger("onCardDrawn"), - onEffectApplied: createTrigger("onEffectApplied"), + onTurnStart: createTrigger("onTurnStart", async ctx => { + await ctx.game.produceAsync(draft => { + const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey); + if(entity) onEntityEffectUpkeep(entity); + if(entity === draft.player) + onPlayerItemEffectUpkeep(draft.player); + }) + }), + onTurnEnd: createTrigger("onTurnEnd", async ctx => { + if(ctx.entityKey !== "player")return; + const {regions} = ctx.game.value.player.deck; + for(const cardId of Object.values(regions.hand.childIds)){ + await triggers.onCardDiscarded.execute(ctx.game,{cardId}); + } + await ctx.game.produceAsync(draft => draft.player.energy = draft.player.maxEnergy); + await triggers.onDraw.execute(ctx.game,{count: 5}); + }), + onShuffle: createTrigger("onShuffle", async ctx => { + await ctx.game.produceAsync(draft => { + const {cards, regions} = draft.player.deck; + for(const cardId of Object.values(regions.discardPile.childIds)) + moveToRegion(cards[cardId], regions.discardPile, regions.drawPile); + shuffle(regions.drawPile, cards, ctx.game.rng); + }); + }), + onCardPlayed: createTrigger("onCardPlayed", async ctx => { + await ctx.game.produceAsync(draft => { + const {cards, regions} = draft.player.deck; + moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile); + }); + }), + onCardDiscarded: createTrigger("onCardDiscarded", async ctx => { + await ctx.game.produceAsync(draft => { + const {cards, regions} = draft.player.deck; + moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile); + }); + }), + onCardDrawn: createTrigger("onCardDrawn", async ctx => { + await ctx.game.produceAsync(draft => { + const {cards, regions} = draft.player.deck; + moveToRegion(cards[ctx.cardId], regions.drawPile, regions.hand); + }); + }), + onDraw: createTrigger("onDraw", async ctx => { + let toDraw = ctx.count; + while(toDraw > 0){ + let inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length; + if(inDraw <= 0) await triggers.onShuffle.execute(ctx.game,{}); + + inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length; + if(inDraw <= 0) break; + + const children = ctx.game.value.player.deck.regions.drawPile.childIds; + const cardId = children[children.length - 1]; + await triggers.onCardDrawn.execute(ctx.game,{cardId}); + toDraw--; + } + }), + onEffectApplied: createTrigger("onEffectApplied", async ctx => { + if(ctx.effect.lifecycle === 'instant') return; + + if(ctx.effect.lifecycle.startsWith("item")) { + if(ctx.cardId){ + const card = ctx.game.value.player.deck.cards[ctx.cardId]; + const nearby = getAdjacentItems(ctx.game.value.inventory, card.itemId); + for(const itemId of nearby.keys()){ + await ctx.game.produceAsync(draft => { + addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks); + }); + } + } + return; + } + + if(ctx.effect.lifecycle.startsWith('card')){ + await ctx.game.produceAsync(draft => { + // TODO + }); + return; + } + + await ctx.game.produceAsync(draft => { + const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey); + if(entity) addEntityEffect(entity, ctx.effect, ctx.stacks); + }) + }), + onHpChange: createTrigger("onHpChange", async ctx => { + await ctx.game.produceAsync(draft => { + const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey); + if(!entity) return; + entity.hp += ctx.amount; + entity.isAlive = entity.hp > 0; + draft.result = !draft.player.isAlive ? "defeat" : draft.enemies.every(e => !e.isAlive) ? "victory" : null; + }); + if(ctx.game.value.result) throw ctx.game.value; + }), } + return triggers; } export type Triggers = ReturnType export function createStartWith(build: (triggers: Triggers) => void){ @@ -38,56 +135,43 @@ export function createStartWith(build: (triggers: Triggers) => void){ return async function(game: CombatGameContext){ await triggers.onCombatStart.execute(game,{}); - // TODO at the end of a damage effect, if win/loss is achieved, break the loop with a throw - // catch the throw and return the result here - while(true){ - await triggers.onTurnStart.execute(game,{entityKey: "player"}); - await game.produceAsync(draft => { - onEntityEffectUpkeep(draft.player); - onPlayerItemEffectUpkeep(draft.player); - }); - while(true){ - const action = await promptMainAction(game); - if(action.action === "end-turn") break; - if(action.action === "play"){ - await game.produceAsync(draft => onDiscard(draft.player, action.cardId)); - await triggers.onCardPlayed.execute(game, action); + try { + while (true) { + await triggers.onTurnStart.execute(game, {entityKey: "player"}); + while (true) { + 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); + } + } + await triggers.onTurnEnd.execute(game, {entityKey: "player"}); + + for (const enemy of getAliveEnemies(game.value)) { + await triggers.onTurnStart.execute(game, {entityKey: enemy.id}); + } + // TODO execute enemy intent, then update with new one here + for (const enemy of getAliveEnemies(game.value)) { + await triggers.onTurnEnd.execute(game, {entityKey: enemy.id}); } } - for(const cardId of [...game.value.player.deck.hand]){ - await game.produceAsync(draft => onDiscard(draft.player, cardId)); - await triggers.onCardDiscarded.execute(game,{cardId}); - } - await triggers.onTurnEnd.execute(game,{entityKey: "player"}); - await game.produceAsync(draft => draft.player.energy = draft.player.maxEnergy); - for(let i = 0; i < 5; i++){ - const cardId = game.value.player.deck.drawPile[0]; // TODO: should this be drawPile[-1] ? - if(!cardId) break; - await game.produceAsync(draft => onDraw(draft.player, cardId)); - await triggers.onCardDrawn.execute(game,{cardId}); - } - - for(const enemy of getAliveEnemies(game.value)){ - await triggers.onTurnStart.execute(game,{entityKey: enemy.id}); - } - await game.produceAsync(draft => { - for(const enemy of getAliveEnemies(game.value)){ - onEntityEffectUpkeep(enemy); - } - }); - // TODO execute enemy intent, then update with new one here - for(const enemy of getAliveEnemies(game.value)){ - await triggers.onTurnEnd.execute(game,{entityKey: enemy.id}); - } + }catch(e){ + if(e === game.value) return game.value.result; + throw e; } } } -function createTrigger(event: TKey) { - type Ctx = TriggerTypes[TKey] & { event: TKey, game: CombatGameContext }; - const {use, execute} = createMiddlewareChain(); +type TriggerContext = TriggerTypes[TKey] & { event: TKey, game: CombatGameContext }; +function createTrigger(event: TKey, fallback?: (ctx: TriggerContext) => Promise) { + const {use, execute} = createMiddlewareChain,void>(fallback); return { use, - execute: (game: CombatGameContext, ctx: TriggerTypes[TKey]) => execute({...ctx, event, game}), + execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => { + const param = {...ctx, game, event}; + await execute(param); + return param; + }, } } \ No newline at end of file diff --git a/src/samples/slay-the-spire-like/system/combat/utils.ts b/src/samples/slay-the-spire-like/system/combat/utils.ts deleted file mode 100644 index 3da3b76..0000000 --- a/src/samples/slay-the-spire-like/system/combat/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {CombatState} from "./types"; - -export function* getAliveEnemies(state: CombatState) { - for (let enemy of state.enemies) { - if (enemy.isAlive) { - yield enemy; - } - } -} \ No newline at end of file diff --git a/src/samples/slay-the-spire-like/system/grid-inventory/transform.ts b/src/samples/slay-the-spire-like/system/grid-inventory/transform.ts index d29094f..ef3cff3 100644 --- a/src/samples/slay-the-spire-like/system/grid-inventory/transform.ts +++ b/src/samples/slay-the-spire-like/system/grid-inventory/transform.ts @@ -197,7 +197,7 @@ export function getItemAtCell>( * Gets all items adjacent to the given item (orthogonally, not diagonally). * Returns a Map of itemId -> item for deduplication. */ -export function getAdjacentItems = Record>( +export function getAdjacentItems( inventory: GridInventory, itemId: string ): Map> { diff --git a/src/samples/slay-the-spire-like/system/grid-inventory/types.ts b/src/samples/slay-the-spire-like/system/grid-inventory/types.ts index f022b9e..645388e 100644 --- a/src/samples/slay-the-spire-like/system/grid-inventory/types.ts +++ b/src/samples/slay-the-spire-like/system/grid-inventory/types.ts @@ -18,7 +18,7 @@ export interface CellCoordinate { * An item placed on the grid inventory. * @template TMeta - Optional metadata type for game-specific data */ -export interface InventoryItem> { +export interface InventoryItem { /** Unique item identifier */ id: string; /** Reference to the item's shape definition */ @@ -44,7 +44,7 @@ export type MutationResult = { success: true } | { success: false; reason: strin * Designed to be mutated directly inside a `mutative .produce()` callback. * @template TMeta - Optional metadata type for items */ -export interface GridInventory> { +export interface GridInventory { /** Board width in cells */ width: number; /** Board height in cells */ diff --git a/src/samples/slay-the-spire-like/system/utils/middleware.ts b/src/utils/middleware.ts similarity index 100% rename from src/samples/slay-the-spire-like/system/utils/middleware.ts rename to src/utils/middleware.ts