From 1c238aec3a6e238c187c1031884d30ad2dad5b92 Mon Sep 17 00:00:00 2001 From: hypercross Date: Fri, 17 Apr 2026 10:00:14 +0800 Subject: [PATCH] refactor: type rewrite --- src/samples/slay-the-spire-like/AGENTS.md | 102 ----------- .../system/combat/effects.ts | 7 +- .../system/combat/triggers.ts | 23 ++- .../system/combat/types.ts | 3 + .../system/combat/utils.ts | 2 +- .../system/deck/factory.ts | 168 +++--------------- .../slay-the-spire-like/system/deck/index.ts | 3 +- .../slay-the-spire-like/system/deck/types.ts | 59 ++---- .../system/progress/types.ts | 6 +- 9 files changed, 69 insertions(+), 304 deletions(-) delete mode 100644 src/samples/slay-the-spire-like/AGENTS.md diff --git a/src/samples/slay-the-spire-like/AGENTS.md b/src/samples/slay-the-spire-like/AGENTS.md deleted file mode 100644 index f878bf5..0000000 --- a/src/samples/slay-the-spire-like/AGENTS.md +++ /dev/null @@ -1,102 +0,0 @@ -# slay-the-spire-like - -A Slay the Spire + Backpack Heroes hybrid roguelike sample. Players explore a point-crawl map, manage a tetris-style grid inventory, and fight enemies using cards generated from their equipment. - -## Game Design Docs - -Design docs are in the markdown files at this level: -- `01-overview.md` — core game concept, zones, encounter structure, combat rules, buff/debuff system -- `02-fighter.md` — Fighter class items (weapons, armor, tools, consumables, relics) -- `03-desert.md` — Desert zone enemies (minions, elites, boss) -- `data/rules.md` — combat state machine, turn order, effect timing rules - -## Module Structure - -This is **not** a `GameModule` yet — there is no `createInitialState`/`start`/`registry` wired up to `createGameHost`. The code is a library of subsystems that can be composed into a game module. - -### Subsystems - -| Directory | Purpose | Key exports | -|-----------|---------|-------------| -| `progress/` | Run state, player HP/gold, inventory management, map progression | `createRunState`, `moveToNode`, `resolveEncounter`, `damagePlayer`, `healPlayer`, `addItemFromCsv`, `removeItem`, `getReachableChildren` | -| `map/` | Point-crawl map generation and traversal | `generatePointCrawlMap`, `getNode`, `getChildren`, `getParents`, `hasPath`, `findAllPaths` | -| `grid-inventory/` | Tetris-style grid placement (place, move, rotate, flip items) | `createGridInventory`, `placeItem`, `removeItem`, `moveItem`, `rotateItem`, `flipItem`, `validatePlacement`, `getAdjacentItems` | -| `deck/` | Card/deck system (draw pile, hand, discard, exhaust) | `generateDeckFromInventory`, `createStatusCard`, `createDeckRegions`, `createPlayerDeck` | -| `data/` | CSV game data loaded via `inline-schema/csv-loader`. `.d.ts` files are auto-generated by the csv-loader plugin — do not edit by hand. | `heroItemFighter1Data`, `encounterDesertData`, `enemyDesertData`, `enemyIntentDesertData`, `effectDesertData`, `statusCardDesertData` | -| `dialogue/` | Yarn Spinner dialogue files (placeholder). Loaded via `yarn-spinner-loader`, a local peer dependency at `../yarn-spinner-loader` (like `inline-schema`, it can be changed and published if needed). | `encounters` yarnproject | -| `utils/` | Shape parsing and collision math | `parseShapeString`, `checkCollision`, `checkBounds`, `transformShape`, `rotateTransform`, `flipXTransform`, `flipYTransform` | - -### Data flow - -``` -CSV files (data/) - → inline-schema/csv-loader → typed JS objects (e.g. HeroItemFighter1) - → parseShapeString() converts shape strings → ParsedShape - → GridInventory holds placed items - → generateDeckFromInventory() generates cards per occupied cell -``` - -### Key types - -- **`RunState`** — top-level state: seed, map, player, inventory, currentNodeId, encounter state, resolved set. Designed for `MutableSignal.produce()` mutation. -- **`GridInventory`** — `items: Map>` + `occupiedCells: Set` for O(1) collision. Mutated directly inside `.produce()`. -- **`InventoryItem`** — id, shape (ParsedShape), transform (Transform2D), meta. Shape + transform determines which cells are occupied. -- **`GameCard`** — a `Part` bridging inventory items to the deck system. `sourceItemId` links back to the inventory item; `null` for status cards. -- **`PointCrawlMap`** — layered DAG: 10 layers (start → wild×2 → settlement → wild×2 → settlement → wild×2 → end). Wild = 3 nodes, Settlement = 4 nodes. -- **`MapNode`** — id, type (MapNodeType enum), childIds, optional encounter data from CSV. - -### Map generation - -`generatePointCrawlMap(seed?)` produces a deterministic map: -- 10 layers: Start → Wild(3) → Wild(3) → Settlement(4) → Wild(3) → Wild(3) → Settlement(4) → Wild(3) → Wild(3) → End -- Settlement layers guarantee ≥1 camp, ≥1 shop, ≥1 curio (4th slot random) -- Wild pair types are optimized to minimize same-type repetition -- Edge patterns avoid crossings: Start→all wild, Wild→Wild 1:1, Wild↔Settlement 3:4 or 4:3, Wild→all End - -### Shape system - -Items have shapes defined as movement strings parsed by `parseShapeString`: -- `o` = origin cell, `n/s/e/w` = move + fill, `r` = return to previous position -- Example: `"oesw"` = 2×2 block (origin, east, south, west = full square) -- Example: `"oe"` = 1×2 horizontal -- Example: `"onrersrw"` = cross/X shape - -Shapes are positioned via `Transform2D` (offset, rotation, flipX, flipY) and validated against the 6×4 grid. - -### Grid inventory - -All mutation functions (`placeItem`, `removeItem`, `moveItem`, `rotateItem`, `flipItem`) mutate the `GridInventory` **directly** — they must be called inside `produce()` callbacks. `validatePlacement` checks bounds + collisions before placement. - -### Card generation - -`generateDeckFromInventory(inventory)` creates one card per occupied cell in each item's shape. Cards carry `GameCardMeta` linking back to the source item and cell position. Status cards (wound, venom, etc.) are created separately via `createStatusCard`. - -## CSV data format - -All CSVs use `inline-schema` typed headers. The first row is a comment header, the second row is the schema row with types and references: -- `'energy'|'uses'` — union type -- `@enemyDesert` — foreign key reference to another CSV -- `[effect: @effectDesert; number][]` — array of structured references - -### heroItemFighter1.csv columns - -| Column | Type | Notes | -|--------|------|-------| -| type | `'weapon'|'armor'|'consumable'|'tool'` | | -| name | string | Display name (Chinese) | -| shape | string | Movement string for `parseShapeString` | -| costType | `'energy'|'uses'` | Energy = per-turn cost; Uses = limited uses | -| costCount | int | Cost amount | -| targetType | `'single'|'none'` | | -| price | int | Shop price | -| desc | string | Ability description (Chinese) | -| effects | `['self'|'target'|'all'|'random'; @effectDesert; number][]` | Effect references | - -## Conventions - -- Chinese is used for all user-facing strings (item names, error messages, effect descriptions) -- Discriminated union result types: `{ success: true } | { success: false, reason: string }` -- Mutation functions mutate state directly (inside `produce()`); validation is separate -- `Map` and `Set` are used in `GridInventory` and `PointCrawlMap` (not plain objects) — requires careful handling with `mutative` since it drafts Maps/Sets differently than plain objects -- Starter items defined in `progress/index.ts`: `['治疗药剂', '绷带', '水袋', '短刀', '剑']` -- Default player stats: 50 HP, 50 gold, 6×4 inventory \ No newline at end of file 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 68c54bb..ad70d13 100644 --- a/src/samples/slay-the-spire-like/system/combat/effects.ts +++ b/src/samples/slay-the-spire-like/system/combat/effects.ts @@ -41,4 +41,9 @@ export function onPlayerItemEffectUpkeep(entity: PlayerEntity){ addItemEffect(entity, itemKey, effect.data, -effect.stacks); } } -} \ No newline at end of file +} + +// TODO +export function onDraw(entity: PlayerEntity, cardId:string ){} +// TODO +export function onDiscard(entity: PlayerEntity, cardId: string){} 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 a36aff9..b8e17f2 100644 --- a/src/samples/slay-the-spire-like/system/combat/triggers.ts +++ b/src/samples/slay-the-spire-like/system/combat/triggers.ts @@ -2,6 +2,7 @@ import {createMiddlewareChain} from "../utils/middleware"; import {CombatGameContext} from "./types"; import {getAliveEnemies} from "@/samples/slay-the-spire-like/system/combat/utils"; import { + onDiscard, onDraw, onEntityEffectUpkeep, onPlayerItemEffectUpkeep } from "@/samples/slay-the-spire-like/system/combat/effects"; @@ -12,7 +13,7 @@ type TriggerTypes = { onTurnStart: { entityKey: "player" | string, }, onTurnEnd: { entityKey: "player" | string, }, onShuffle: { entityKey: "player" | string, }, - onCardPlayed: { cardId: string, }, + onCardPlayed: { cardId: string, targetId?: string }, onCardDiscarded: { cardId: string, }, onCardDrawn: { cardId: string, }, onEffectApplied: { effectId: string, entityKey: "player" | string, stacks: number, }, @@ -37,6 +38,8 @@ 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 => { @@ -46,11 +49,23 @@ export function createStartWith(build: (triggers: Triggers) => void){ while(true){ const action = await promptMainAction(game); if(action.action === "end-turn") break; - //TODO resolve action here + if(action.action === "play"){ + await game.produceAsync(draft => onDiscard(draft.player, action.cardId)); + await triggers.onCardPlayed.execute(game, action); + } + } + for(const cardId of [...game.value.player.deck.hand]){ + await game.produceAsync(draft => onDiscard(draft.player, cardId)); + await triggers.onCardDiscarded.execute(game,{cardId}); } - // TODO discard cards here await triggers.onTurnEnd.execute(game,{entityKey: "player"}); - // TODO recover energy, draw new cards here + 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}); diff --git a/src/samples/slay-the-spire-like/system/combat/types.ts b/src/samples/slay-the-spire-like/system/combat/types.ts index 011dc73..7a0c874 100644 --- a/src/samples/slay-the-spire-like/system/combat/types.ts +++ b/src/samples/slay-the-spire-like/system/combat/types.ts @@ -1,6 +1,8 @@ import type { PlayerDeck } from "../deck/types"; import {EnemyData, IntentData} from "@/samples/slay-the-spire-like/system/types"; import {EffectData} from "@/samples/slay-the-spire-like/system/types"; +import {GridInventory} from "@/samples/slay-the-spire-like/system/grid-inventory"; +import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress"; export type EffectTable = Record; @@ -39,6 +41,7 @@ export type LootEntry = { export type CombatState = { enemies: EnemyEntity[]; player: PlayerEntity; + inventory: GridInventory; phase: CombatPhase; turnNumber: number; diff --git a/src/samples/slay-the-spire-like/system/combat/utils.ts b/src/samples/slay-the-spire-like/system/combat/utils.ts index c263707..3da3b76 100644 --- a/src/samples/slay-the-spire-like/system/combat/utils.ts +++ b/src/samples/slay-the-spire-like/system/combat/utils.ts @@ -1,4 +1,4 @@ -import {CombatState} from "@/samples/slay-the-spire-like/system"; +import {CombatState} from "./types"; export function* getAliveEnemies(state: CombatState) { for (let enemy of state.enemies) { diff --git a/src/samples/slay-the-spire-like/system/deck/factory.ts b/src/samples/slay-the-spire-like/system/deck/factory.ts index 5c4b268..6129e79 100644 --- a/src/samples/slay-the-spire-like/system/deck/factory.ts +++ b/src/samples/slay-the-spire-like/system/deck/factory.ts @@ -1,188 +1,66 @@ -import type { CellKey, GridInventory, InventoryItem } from '../grid-inventory/types'; +import {moveToRegion } from '@/core/region'; +import { createRegion } from '@/core/region'; +import type { GridInventory } from '../grid-inventory/types'; import type { GameItemMeta } from '../progress/types'; -import { createRegion, createRegionAxis } from '@/core/region'; -import type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types'; -import { cardDesertData } from '../data'; +import type { CardData } from '../types'; +import type {DeckRegions, GameCard, PlayerDeck} 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. - */ -function createItemCard( - itemId: string, - itemData: GameItemMeta['itemData'], - cellKey: CellKey, - cellIndex: number -): GameCard { - const cardId = generateCardId(itemId, cellIndex); - const cardData = cardDesertData.find(c => c.id === itemData.card.id); - +function createCard( itemId: string, cardData: CardData, cellIndex: number): GameCard { return { - id: cardId, + id: generateCardId(itemId, cellIndex), regionId: '', position: [], - sourceItemId: itemId, - itemData: cardData ?? null, - cellKey, - displayName: cardData?.name ?? itemData.name, - description: cardData?.desc ?? '', + itemId, + cardData }; } -/** - * Creates a status card that does not correspond to any inventory item. - * Status cards represent temporary effects like wounds, stuns, etc. - */ -function createStatusCard( - id: string, - displayName: string, - description: string -): GameCard { +function createDeckRegions(): DeckRegions { return { - id, - regionId: '', - position: [], - sourceItemId: null, - itemData: null, - cellKey: null, - displayName, - description, + drawPile: createRegion('drawPile', []), + hand: createRegion('hand', []), + discardPile: createRegion('discardPile', []), + exhaustPile: createRegion('exhaustPile', []), }; } -/** - * Generates a complete player deck from the current inventory state. - */ function generateDeckFromInventory(inventory: GridInventory): PlayerDeck { const cards: Record = {}; - const drawPile: string[] = []; + const regions = createDeckRegions(); 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); + const count = item.shape.count; + for (let i = 0; i < count; i++) { + const card = createCard(item.id, itemData.card, i); cards[card.id] = card; - drawPile.push(card.id); + moveToRegion(card, null, regions.drawPile); } } return { cards, - drawPile, - hand: [], - discardPile: [], - exhaustPile: [], + regions }; } -/** - * Creates region definitions for deck management. - */ -function createDeckRegions(): DeckRegions { - return { - drawPile: createRegion('drawPile', [ - createRegionAxis('index', 0, 0), - ]), - 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: [], + regions: createDeckRegions(), }; } export { generateDeckFromInventory, - createStatusCard, - createDeckRegions, + createCard, createPlayerDeck, + createDeckRegions, generateCardId, }; diff --git a/src/samples/slay-the-spire-like/system/deck/index.ts b/src/samples/slay-the-spire-like/system/deck/index.ts index 3178415..32c7ffa 100644 --- a/src/samples/slay-the-spire-like/system/deck/index.ts +++ b/src/samples/slay-the-spire-like/system/deck/index.ts @@ -1,8 +1,7 @@ export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types'; export { generateDeckFromInventory, - createStatusCard, - createDeckRegions, + createCard, createPlayerDeck, generateCardId, } from './factory'; diff --git a/src/samples/slay-the-spire-like/system/deck/types.ts b/src/samples/slay-the-spire-like/system/deck/types.ts index 3cd4941..31d377c 100644 --- a/src/samples/slay-the-spire-like/system/deck/types.ts +++ b/src/samples/slay-the-spire-like/system/deck/types.ts @@ -1,40 +1,14 @@ import type { Part } from '@/core/part'; -import type { Region } from '@/core/region'; -import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv'; -import type { CardDesert } from '../data/cardDesert.csv'; -import type { CellKey } from '../grid-inventory/types'; +import {CardData} from "@/samples/slay-the-spire-like/system/types"; +import {Region} from "@/core/region"; /** * 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 card data from cardDesert.csv. `null` for status cards not in the CSV. - */ - itemData: CardDesert | 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; + cardData: CardData; + itemId: string; } /** @@ -49,26 +23,17 @@ export type GameCard = Part; 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[]; + + regions: DeckRegions; } -/** - * Region structure for deck management. - */ -export interface DeckRegions { - /** Draw pile region */ +export interface DeckRegions{ + /** Card IDs in the draw pile */ drawPile: Region; - /** Hand region */ + /** Card IDs in the player's hand */ hand: Region; - /** Discard pile region */ + /** Card IDs in the discard pile */ discardPile: Region; - /** Exhaust pile region */ + /** Card IDs in the exhaust pile (removed from combat) */ exhaustPile: Region; -} +} \ No newline at end of file diff --git a/src/samples/slay-the-spire-like/system/progress/types.ts b/src/samples/slay-the-spire-like/system/progress/types.ts index 2ece9dc..ee8a019 100644 --- a/src/samples/slay-the-spire-like/system/progress/types.ts +++ b/src/samples/slay-the-spire-like/system/progress/types.ts @@ -1,7 +1,7 @@ import type { PointCrawlMap } from '../map/types'; import type { GridInventory, InventoryItem } from '../grid-inventory/types'; import type { ParsedShape } from '../utils/parse-shape'; -import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv'; +import {ItemData} from "@/samples/slay-the-spire-like/system/types"; /** * Result of an encounter (combat, event, etc.). @@ -35,9 +35,11 @@ export interface EncounterState { */ export interface GameItemMeta { /** Original CSV item data */ - itemData: HeroItemFighter1; + itemData: ItemData; /** Parsed shape for grid placement */ shape: ParsedShape; + /** Consumed uses, if card cost type is uses**/ + depletion?: number; } /**