refactor: type rewrite

This commit is contained in:
hypercross 2026-04-17 10:00:14 +08:00
parent a469b4024a
commit 1c238aec3a
9 changed files with 69 additions and 304 deletions

View File

@ -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<GameItemMeta> 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<TMeta>`** — `items: Map<string, InventoryItem<TMeta>>` + `occupiedCells: Set<CellKey>` for O(1) collision. Mutated directly inside `.produce()`.
- **`InventoryItem<TMeta>`** — id, shape (ParsedShape), transform (Transform2D), meta. Shape + transform determines which cells are occupied.
- **`GameCard`** — a `Part<GameCardMeta>` 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

View File

@ -42,3 +42,8 @@ export function onPlayerItemEffectUpkeep(entity: PlayerEntity){
}
}
}
// TODO
export function onDraw(entity: PlayerEntity, cardId:string ){}
// TODO
export function onDiscard(entity: PlayerEntity, cardId: string){}

View File

@ -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});

View File

@ -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<string, {data: EffectData, stacks: number}>;
@ -39,6 +41,7 @@ export type LootEntry = {
export type CombatState = {
enemies: EnemyEntity[];
player: PlayerEntity;
inventory: GridInventory<GameItemMeta>;
phase: CombatPhase;
turnNumber: number;

View File

@ -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) {

View File

@ -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<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.
*/
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<GameItemMeta>): PlayerDeck {
const cards: Record<string, GameCard> = {};
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,
};

View File

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

View File

@ -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<GameCardMeta>;
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[];
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;
}

View File

@ -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;
}
/**