refactor(slay-the-spire-like): restructure encounter and progress

systems

Refactor the sample game logic by moving encounter-related logic from
the monolithic `progress` system into a dedicated `encounter` module.
This includes:

- Moving combat state construction and run state management to
  `system/encounter`.
- Decoupling player stats and inventory from the general progress
  logic.
- Improving type safety for `EncounterData` using generics.
- Adding navigation helpers to the map system.
- Cleaning up imports and improving code formatting.
This commit is contained in:
hypercross 2026-04-20 13:00:30 +08:00
parent 423cc7c841
commit f336a989e2
16 changed files with 361 additions and 795 deletions

View File

@ -38,6 +38,8 @@ export type {
InventoryItem,
MutationResult,
PlacementResult,
GameItem,
GameItemMeta,
} from "./system/grid-inventory";
export {
createGridInventory,
@ -69,43 +71,8 @@ export {
} from "./system/map";
// Progress / Run
export type {
EncounterResult,
EncounterState,
GameItem,
GameItemMeta,
PlayerState,
RunMutationResult,
RunState,
} from "./system/progress";
export {
assignEncounterToNode,
assignEncountersFromPool,
assignAllEncounters,
buildCombatState,
createEnemyEntities,
getCurrentEncounterData,
isCombatEncounter,
startEncounter,
resolveCombatEncounter,
createRunState,
canMoveTo,
moveToNode,
resolveEncounter,
isEncounterResolved,
damagePlayer,
healPlayer,
setMaxHp,
addGold,
spendGold,
addItem,
removeItem,
getCurrentNode,
getReachableChildren,
getUnresolvedChildren,
isAtStartNode,
isAtEndNode,
} from "./system/progress";
export type { EncounterState, RunState } from "./system/encounter";
export { buildCombatState } from "./system/encounter";
// Combat
export type {

View File

@ -16,8 +16,6 @@ import { promptMainAction } from "@/samples/slay-the-spire-like/system/combat/pr
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: {};

View File

@ -1,66 +1,71 @@
import {moveToRegion } from '@/core/region';
import { createRegion } from '@/core/region';
import type { GridInventory } from '../grid-inventory/types';
import type { GameItemMeta } from '../progress/types';
import type { CardData } from '../types';
import type {DeckRegions, GameCard, PlayerDeck} from './types';
import { moveToRegion } from "@/core/region";
import { createRegion } from "@/core/region";
import type { GameItemMeta, GridInventory } from "../grid-inventory/types";
import type { CardData } from "../types";
import type { DeckRegions, GameCard, PlayerDeck } from "./types";
function generateCardId(itemId: string, cellIndex: number): string {
return `card-${itemId}-${cellIndex}`;
return `card-${itemId}-${cellIndex}`;
}
function createCard( itemId: string, cardData: CardData, cellIndex: number): GameCard {
return {
id: generateCardId(itemId, cellIndex),
regionId: '',
position: [],
itemId,
cardData
};
function createCard(
itemId: string,
cardData: CardData,
cellIndex: number,
): GameCard {
return {
id: generateCardId(itemId, cellIndex),
regionId: "",
position: [],
itemId,
cardData,
};
}
function createDeckRegions(): DeckRegions {
return {
drawPile: createRegion('drawPile', []),
hand: createRegion('hand', []),
discardPile: createRegion('discardPile', []),
exhaustPile: createRegion('exhaustPile', []),
};
return {
drawPile: createRegion("drawPile", []),
hand: createRegion("hand", []),
discardPile: createRegion("discardPile", []),
exhaustPile: createRegion("exhaustPile", []),
};
}
function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): PlayerDeck {
const cards: Record<string, GameCard> = {};
const regions = createDeckRegions();
function generateDeckFromInventory(
inventory: GridInventory<GameItemMeta>,
): PlayerDeck {
const cards: Record<string, GameCard> = {};
const regions = createDeckRegions();
for (const item of inventory.items.values()) {
const itemData = item.meta?.itemData;
if (!itemData) continue;
for (const item of inventory.items.values()) {
const itemData = item.meta?.itemData;
if (!itemData) continue;
const count = item.shape.count;
for (let i = 0; i < count; i++) {
const card = createCard(item.id, itemData.card, i);
cards[card.id] = card;
moveToRegion(card, null, regions.drawPile);
}
const count = item.shape.count;
for (let i = 0; i < count; i++) {
const card = createCard(item.id, itemData.card, i);
cards[card.id] = card;
moveToRegion(card, null, regions.drawPile);
}
}
return {
cards,
regions
};
return {
cards,
regions,
};
}
function createPlayerDeck(): PlayerDeck {
return {
cards: {},
regions: createDeckRegions(),
};
return {
cards: {},
regions: createDeckRegions(),
};
}
export {
generateDeckFromInventory,
createCard,
createPlayerDeck,
createDeckRegions,
generateCardId,
generateDeckFromInventory,
createCard,
createPlayerDeck,
createDeckRegions,
generateCardId,
};

View File

@ -0,0 +1,107 @@
import {
CombatState,
EffectTable,
EnemyEntity,
PlayerEntity,
} from "../combat/types";
import { generateDeckFromInventory } from "../deck";
import { GridInventory } from "../grid-inventory";
import { GameItemMeta } from "../grid-inventory/types";
import { EffectData, EncounterData, EnemyData, IntentData } from "../types";
import { CombatEncounterState, RunState } from "./types";
export function buildCombatState(
runState: RunState,
inventory: GridInventory<GameItemMeta>,
encounter: CombatEncounterState,
): CombatState {
const deck = generateDeckFromInventory(inventory);
const player = createPlayerEntity(runState, deck);
const enemies = createEnemyEntities(encounter.data);
return {
enemies,
player,
phase: "playerTurn",
turnNumber: 1,
result: null,
loot: [],
};
}
function createEnemyEntities(encounter: EncounterData): EnemyEntity[] {
const enemies: EnemyEntity[] = [];
let instanceCounter = 0;
for (const [enemyData, hp, encounterBuffs] of encounter.enemies) {
const instanceId = `${enemyData.id}-${instanceCounter++}`;
const intents = buildIntentMap(enemyData);
const initialIntent = findInitialIntent(enemyData);
const effects = buildEffectTable(encounterBuffs);
const entity: EnemyEntity = {
id: instanceId,
enemy: enemyData,
hp,
maxHp: hp,
isAlive: true,
effects,
intents,
currentIntent: initialIntent,
};
enemies.push(entity);
}
return enemies;
}
/**
* Builds a map of intent ID -> IntentData for an enemy.
*/
function buildIntentMap(enemy: EnemyData): Record<string, IntentData> {
const intents: Record<string, IntentData> = {};
for (const intent of enemy.intents) {
intents[intent.id] = intent;
}
return intents;
}
/**
* Finds the initial intent ID for an enemy.
*/
function findInitialIntent(enemy: EnemyData): IntentData {
for (const intent of enemy.intents) {
if (intent.initialIntent) {
return intent;
}
}
if (enemy.intents.length === 0) {
throw new Error(`Enemy "${enemy.id}" has no intents`);
}
return enemy.intents[0];
}
function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable {
const table: EffectTable = {};
for (const [effect, stacks] of buffs) {
table[effect.id] = { data: effect, stacks };
}
return table;
}
function createPlayerEntity(
runState: RunState,
deck: ReturnType<typeof generateDeckFromInventory>,
): PlayerEntity {
return {
id: "player",
hp: runState.currentHp,
maxHp: runState.maxHp,
isAlive: runState.currentHp > 0,
energy: 3,
maxEnergy: 3,
deck,
itemEffects: {},
effects: {},
};
}

View File

@ -0,0 +1,3 @@
export { RunState, EncounterState } from "./types";
export { buildCombatState } from "./combat";
export { generateInstanceId } from "./shop";

View File

@ -0,0 +1,52 @@
import { RunState } from "./types";
const DEFAULT_MAX_HP = 50;
const DEFAULT_GOLD = 50;
export function createRunState(startNode: string): RunState {
return {
maxHp: DEFAULT_MAX_HP,
currentHp: DEFAULT_MAX_HP,
gold: DEFAULT_GOLD,
_idCounter: { value: 0 },
};
}
export function damagePlayer(runState: RunState, amount: number): void {
runState.currentHp = Math.max(0, runState.currentHp - amount);
}
export function healPlayer(runState: RunState, amount: number): void {
runState.currentHp = Math.min(runState.maxHp, runState.currentHp + amount);
}
export function setMaxHp(runState: RunState, newMax: number): void {
const diff = Math.max(0, newMax - runState.maxHp);
runState.maxHp = newMax;
runState.currentHp = Math.max(0, Math.min(newMax, runState.currentHp + diff));
}
/**
* Adds gold to the player.
*/
export function addGold(runState: RunState, amount: number): void {
runState.gold += amount;
}
/**
* Spends gold. Returns false if the player doesn't have enough gold.
*/
export function spendGold(
runState: RunState,
amount: number,
): { success: true } | { success: false; reason: string } {
if (amount <= 0) {
return { success: false, reason: "金额必须大于零" };
}
if (runState.gold < amount) {
return { success: false, reason: "金币不足" };
}
runState.gold -= amount;
return { success: true };
}

View File

@ -0,0 +1,4 @@
export function generateInstanceId(counter: { value: number }): string {
counter.value++;
return `item-${counter.value}`;
}

View File

@ -0,0 +1,42 @@
import { GridInventory } from "../grid-inventory";
import { GameItemMeta } from "../grid-inventory/types";
import { EncounterData } from "../types";
export interface RunState {
_idCounter: { value: number };
currentHp: number;
maxHp: number;
gold: number;
}
export type EncounterState =
| CombatEncounterState
| ShopEncounterState
| CurioEncounterState
| CampEncounterState
| DialogueEncounterState;
export type CombatEncounterState = {
data: EncounterData<"minion" | "elite">;
blocked: boolean;
};
export type ShopEncounterState = {
data: EncounterData<"shop">;
items: GridInventory<GameItemMeta & { sellPrice: number }>;
};
export type CurioEncounterState = {
data: EncounterData<"curio">;
items: GridInventory<GameItemMeta>;
};
export type CampEncounterState = {
data: EncounterData<"camp">;
};
export type DialogueEncounterState = {
data: EncounterData<"event">;
blocked: boolean;
};

View File

@ -1,13 +1,22 @@
export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './types';
export type {
CellCoordinate,
CellKey,
GridInventory,
InventoryItem,
MutationResult,
PlacementResult,
} from "./types";
export {
createGridInventory,
flipItem,
getAdjacentItems,
getItemAtCell,
getOccupiedCellSet,
moveItem,
placeItem,
removeItem,
rotateItem,
validatePlacement,
} from './transform';
createGridInventory,
flipItem,
getAdjacentItems,
getItemAtCell,
getOccupiedCellSet,
moveItem,
placeItem,
removeItem,
rotateItem,
validatePlacement,
} from "./transform";
export type { GameItemMeta, GameItem } from "./types";

View File

@ -1,5 +1,6 @@
import type { ParsedShape } from '../utils/parse-shape';
import type { Transform2D } from '../utils/shape-collision';
import { ItemData } from "../types";
import type { ParsedShape } from "../utils/parse-shape";
import type { Transform2D } from "../utils/shape-collision";
/**
* String key representing a grid cell in "x,y" format.
@ -10,8 +11,8 @@ export type CellKey = `${number},${number}`;
* Simple 2D coordinate for grid cells.
*/
export interface CellCoordinate {
x: number;
y: number;
x: number;
y: number;
}
/**
@ -19,25 +20,29 @@ export interface CellCoordinate {
* @template TMeta - Optional metadata type for game-specific data
*/
export interface InventoryItem<TMeta> {
/** Unique item identifier */
id: string;
/** Reference to the item's shape definition */
shape: ParsedShape;
/** Current transformation (position, rotation, flips) */
transform: Transform2D;
/** Optional metadata for game-specific data */
meta?: TMeta;
/** Unique item identifier */
id: string;
/** Reference to the item's shape definition */
shape: ParsedShape;
/** Current transformation (position, rotation, flips) */
transform: Transform2D;
/** Optional metadata for game-specific data */
meta?: TMeta;
}
/**
* Result of a placement validation check.
*/
export type PlacementResult = { valid: true } | { valid: false; reason: string };
export type PlacementResult =
| { valid: true }
| { valid: false; reason: string };
/**
* Result of a mutation operation (move, rotate, flip).
*/
export type MutationResult = { success: true } | { success: false; reason: string };
export type MutationResult =
| { success: true }
| { success: false; reason: string };
/**
* Grid inventory state.
@ -45,12 +50,21 @@ export type MutationResult = { success: true } | { success: false; reason: strin
* @template TMeta - Optional metadata type for items
*/
export interface GridInventory<TMeta> {
/** Board width in cells */
width: number;
/** Board height in cells */
height: number;
/** Map of itemId -> InventoryItem for all placed items */
items: Map<string, InventoryItem<TMeta>>;
/** Set of occupied cells in "x,y" format for O(1) collision lookups */
occupiedCells: Set<CellKey>;
/** Board width in cells */
width: number;
/** Board height in cells */
height: number;
/** Map of itemId -> InventoryItem for all placed items */
items: Map<string, InventoryItem<TMeta>>;
/** Set of occupied cells in "x,y" format for O(1) collision lookups */
occupiedCells: Set<CellKey>;
}
export interface GameItemMeta {
itemData: ItemData;
shape: ParsedShape;
consumedUses?: number;
startEffects?: Record<string, number>;
tradePrice?: number;
}
export type GameItem = InventoryItem<GameItemMeta>;

View File

@ -15,4 +15,10 @@ export {
findAllPaths,
} from "./generator";
export { canMoveTo, moveToNode } from "./navigation";
export {
canMoveTo,
moveToNode,
getReachableChildren,
isAtEndNode,
isAtStartNode,
} from "./navigation";

View File

@ -31,3 +31,35 @@ export function moveToNode(
navigator.visitedNodes.add(targetNodeId);
return true;
}
export function* getReachableChildren(
map: PointCrawlMap,
navigator: PointCrawlMapNavigator,
) {
const currentNode = getNode(map, navigator.currentNodeId);
if (!currentNode) return;
for (const id of currentNode.childIds) {
const node = getNode(map, id);
if (!node) continue;
yield node;
}
}
export function isAtStartNode(
map: PointCrawlMap,
navigator: PointCrawlMapNavigator,
): boolean {
return navigator.currentNodeId === map.layers[0]?.nodes[0]?.id;
}
/**
* Checks if the current encounter is the end node.
*/
export function isAtEndNode(
map: PointCrawlMap,
navigator: PointCrawlMapNavigator,
): boolean {
const endLayer = map.layers[map.layers.length - 1];
return endLayer?.nodes[0]?.id === navigator.currentNodeId;
}

View File

@ -1,256 +0,0 @@
import type { PointCrawlMap } from "../map/types";
import type {
CombatState,
EnemyEntity,
PlayerEntity,
EffectTable,
} from "../combat/types";
import type {
EncounterData,
EnemyData,
EffectData,
IntentData,
} from "../types";
import type { RunState } from "./types";
import { generateDeckFromInventory } from "../deck/factory";
import { ReadonlyRNG } from "@/utils/rng";
// -- Encounter assignment to nodes --
/**
* Assigns an encounter from a pool to a specific node.
* Replaces any existing encounter on that node.
*/
export function assignEncounterToNode(
map: PointCrawlMap,
nodeId: string,
encounter: EncounterData,
): void {
const node = map.nodes.get(nodeId);
if (!node) {
throw new Error(`Node "${nodeId}" not found`);
}
node.encounter = encounter;
}
/**
* Assigns encounters from a typed pool to all unassigned nodes of matching type.
* Uses RNG for random selection; each encounter can be assigned multiple times.
*/
export function assignEncountersFromPool(
map: PointCrawlMap,
encounterPool: EncounterData[],
rng: ReadonlyRNG,
): void {
if (encounterPool.length === 0) return;
for (const node of map.nodes.values()) {
if (node.type === "start" || node.type === "end") continue;
if (node.encounter) continue;
const assigned = encounterPool[rng.nextInt(encounterPool.length)];
node.encounter = assigned;
}
}
/**
* Batch-assigns encounters for all node types from a typed index.
* Keys in the index should match encounter type strings (e.g. 'minion', 'elite').
*/
export function assignAllEncounters(
map: PointCrawlMap,
encounterIndex: Map<string, EncounterData[]>,
rng: ReadonlyRNG,
): void {
for (const node of map.nodes.values()) {
if (node.type === "start" || node.type === "end") continue;
if (node.encounter) continue;
const encounterType = node.type;
const pool = encounterIndex.get(encounterType);
if (!pool || pool.length === 0) continue;
node.encounter = pool[rng.nextInt(pool.length)];
}
}
// -- CombatState construction --
/**
* Builds a full CombatState from an encounter and the current run state.
* - Creates EnemyEntity instances with HP, initial buffs, and intents
* - Creates PlayerEntity with energy (3), deck from inventory, and HP from run state
* - Sets initial phase to 'playerTurn', turn 1
*/
export function buildCombatState(runState: RunState): CombatState {
const encounter = getCurrentEncounterData(runState);
if (!encounter)
throw new Error(`No encounter found for node ${runState.currentNodeId}`);
const enemies = createEnemyEntities(encounter);
const deck = generateDeckFromInventory(runState.inventory);
const player = createPlayerEntity(runState, deck);
return {
enemies,
player,
phase: "playerTurn",
turnNumber: 1,
result: null,
loot: [],
};
}
/**
* Creates EnemyEntity instances from encounter enemy definitions.
* Each enemy gets: HP from encounter tuple, initial buffs from encounter, intents from enemy definition.
*/
export function createEnemyEntities(encounter: EncounterData): EnemyEntity[] {
const enemies: EnemyEntity[] = [];
let instanceCounter = 0;
for (const [enemyData, hp, encounterBuffs] of encounter.enemies) {
const instanceId = `${enemyData.id}-${instanceCounter++}`;
const intents = buildIntentMap(enemyData);
const initialIntent = findInitialIntent(enemyData);
const effects = buildEffectTable(encounterBuffs);
const entity: EnemyEntity = {
id: instanceId,
enemy: enemyData,
hp,
maxHp: hp,
isAlive: true,
effects,
intents,
currentIntent: initialIntent,
};
enemies.push(entity);
}
return enemies;
}
/**
* Builds a map of intent ID -> IntentData for an enemy.
*/
function buildIntentMap(enemy: EnemyData): Record<string, IntentData> {
const intents: Record<string, IntentData> = {};
for (const intent of enemy.intents) {
intents[intent.id] = intent;
}
return intents;
}
/**
* Finds the initial intent ID for an enemy.
*/
function findInitialIntent(enemy: EnemyData): IntentData {
for (const intent of enemy.intents) {
if (intent.initialIntent) {
return intent;
}
}
if (enemy.intents.length === 0) {
throw new Error(`Enemy "${enemy.id}" has no intents`);
}
return enemy.intents[0];
}
/**
* Builds an EffectTable from encounter buff definitions.
*/
function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable {
const table: EffectTable = {};
for (const [effect, stacks] of buffs) {
table[effect.id] = { data: effect, stacks };
}
return table;
}
/**
* Creates a PlayerEntity from the run state and deck.
*/
function createPlayerEntity(
runState: RunState,
deck: ReturnType<typeof generateDeckFromInventory>,
): PlayerEntity {
return {
id: "player",
hp: runState.player.currentHp,
maxHp: runState.player.maxHp,
isAlive: runState.player.currentHp > 0,
energy: 3,
maxEnergy: 3,
deck,
itemEffects: {},
effects: {},
};
}
// -- Encounter lifecycle --
/**
* Gets the encounter data for the current node.
*/
export function getCurrentEncounterData(
runState: RunState,
): EncounterData | undefined {
const node = runState.map.nodes.get(runState.currentNodeId);
return node?.encounter;
}
/**
* Checks if the current node has a combat encounter.
*/
export function isCombatEncounter(runState: RunState): boolean {
const encounter = getCurrentEncounterData(runState);
return encounter !== undefined && encounter.enemies.length > 0;
}
/**
* Starts the encounter at the current node.
* Returns the constructed CombatState, or null if no combat encounter.
*/
export function startEncounter(runState: RunState): CombatState | null {
const encounter = getCurrentEncounterData(runState);
if (!encounter || encounter.enemies.length === 0) {
return null;
}
return buildCombatState(runState);
}
/**
* Resolves a completed combat and applies rewards to the run state.
* Handles: gold loot, item rewards, HP changes.
* Marks the encounter as resolved.
*/
export function resolveCombatEncounter(
runState: RunState,
combatState: CombatState,
): { success: true } | { success: false; reason: string } {
if (runState.currentEncounter.resolved) {
return { success: false, reason: "该遭遇已解决" };
}
// Apply HP from combat state back to run state
runState.player.currentHp = Math.max(0, combatState.player.hp);
// Apply loot
for (const loot of combatState.loot) {
if (loot.type === "gold") {
runState.player.gold += loot.amount;
}
// Item rewards are handled by the caller via addItem()
}
// Mark as resolved
runState.currentEncounter.resolved = true;
runState.currentEncounter.result = {
hpLost: runState.player.maxHp - runState.player.currentHp,
};
runState.resolvedNodeIds.add(runState.currentNodeId);
return { success: true };
}

View File

@ -1,326 +0,0 @@
import { getNode } from '../map/generator';
import type {MapNode, PointCrawlMap} from '../map/types';
import { placeItem, validatePlacement, createGridInventory, removeItem as gridRemoveItem } from '../grid-inventory/transform';
import type { GameItem, GameItemMeta, RunMutationResult, RunState } from './types';
import type { GridInventory } from '../grid-inventory/types';
import { IDENTITY_TRANSFORM, type Transform2D } from '../utils/shape-collision';
import { parseShapeString, type ParsedShape } from '../utils/parse-shape';
import {ItemData} from "@/samples/slay-the-spire-like/system/types";
// Re-export types
export type {
EncounterResult,
EncounterState,
GameItem,
GameItemMeta,
PlayerState,
RunMutationResult,
RunState,
} from './types';
// Re-export encounter construction functions
export {
assignEncounterToNode,
assignEncountersFromPool,
assignAllEncounters,
buildCombatState,
createEnemyEntities,
getCurrentEncounterData,
isCombatEncounter,
startEncounter,
resolveCombatEncounter,
} from './encounter';
// -- Constants --
const INVENTORY_WIDTH = 6;
const INVENTORY_HEIGHT = 4;
const DEFAULT_MAX_HP = 50;
const DEFAULT_GOLD = 50;
// -- Run creation --
/**
* Creates a new run state with a generated map, player stats, and starter inventory.
*
*/
export function createRunState(map: PointCrawlMap, starterItems: ItemData[]): RunState {
// Find the start node
const startNode = map.layers[0].nodes[0];
// Create empty inventory
const inventory = createGridInventory<GameItemMeta>(INVENTORY_WIDTH, INVENTORY_HEIGHT);
const idCounter = { value: 0 };
// Place starter items
for (const itemData of starterItems) {
const shape = parseShapeString(itemData.shape);
const itemInstance = tryPlaceItemInInventory(inventory, itemData, shape, idCounter);
if (!itemInstance) {
// Inventory too small; item skipped
}
}
return {
map,
player: {
maxHp: DEFAULT_MAX_HP,
currentHp: DEFAULT_MAX_HP,
gold: DEFAULT_GOLD,
},
inventory,
currentNodeId: startNode.id,
currentEncounter: {
nodeId: startNode.id,
resolved: false,
},
resolvedNodeIds: new Set<string>(),
_idCounter: idCounter,
};
}
// -- Movement --
/**
* Checks whether the player can move to the target node.
* The target must be a child of the current node.
*/
export function canMoveTo(runState: RunState, targetNodeId: string): boolean {
const currentNode = getNode(runState.map, runState.currentNodeId);
if (!currentNode) return false;
return currentNode.childIds.includes(targetNodeId);
}
/**
* Moves the player to the target node.
* The target must be a direct child of the current node.
*
* @returns `{ success: true }` on success, or `{ success: false, reason: string }` on failure.
*/
export function moveToNode(runState: RunState, targetNodeId: string): RunMutationResult {
if (!canMoveTo(runState, targetNodeId)) {
return { success: false, reason: '无法移动到该节点' };
}
const targetNode = getNode(runState.map, targetNodeId);
if (!targetNode) {
return { success: false, reason: '目标节点不存在' };
}
// Update current position
runState.currentNodeId = targetNodeId;
// Create new encounter state for the target node
const isResolved = runState.resolvedNodeIds.has(targetNodeId);
runState.currentEncounter = {
nodeId: targetNodeId,
resolved: isResolved,
};
return { success: true };
}
// -- Encounter management --
/**
* Marks the current encounter as resolved with optional result data.
*/
export function resolveEncounter(runState: RunState, result?: { goldEarned?: number; hpLost?: number; hpGained?: number; itemRewards?: string[] }): RunMutationResult {
if (runState.currentEncounter.resolved) {
return { success: false, reason: '该遭遇已解决' };
}
runState.currentEncounter.resolved = true;
runState.currentEncounter.result = result;
runState.resolvedNodeIds.add(runState.currentNodeId);
// Apply result effects
if (result) {
if (result.goldEarned) {
runState.player.gold += result.goldEarned;
}
if (result.hpLost) {
runState.player.currentHp = Math.max(0, runState.player.currentHp - result.hpLost);
}
if (result.hpGained) {
runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + result.hpGained);
}
}
return { success: true };
}
/**
* Checks whether the encounter at the given node has been resolved.
*/
export function isEncounterResolved(runState: RunState, nodeId: string): boolean {
return runState.resolvedNodeIds.has(nodeId);
}
// -- Player stats --
/**
* Damages the player. Clamps HP to 0 minimum.
*/
export function damagePlayer(runState: RunState, amount: number): void {
runState.player.currentHp = Math.max(0, runState.player.currentHp - amount);
}
/**
* Heals the player. Clamps HP to maxHp.
*/
export function healPlayer(runState: RunState, amount: number): void {
runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + amount);
}
/**
* Sets the player's maximum HP and adjusts current HP proportionally.
*/
export function setMaxHp(runState: RunState, newMax: number): void {
const diff = newMax - runState.player.maxHp;
runState.player.maxHp = newMax;
runState.player.currentHp = Math.max(0, Math.min(newMax, runState.player.currentHp + diff));
}
/**
* Adds gold to the player.
*/
export function addGold(runState: RunState, amount: number): void {
runState.player.gold += amount;
}
/**
* Spends gold. Returns false if the player doesn't have enough gold.
*/
export function spendGold(runState: RunState, amount: number): RunMutationResult {
if (amount <= 0) {
return { success: false, reason: '金额必须大于零' };
}
if (runState.player.gold < amount) {
return { success: false, reason: '金币不足' };
}
runState.player.gold -= amount;
return { success: true };
}
// -- Inventory management --
/**
* Adds an item from CSV data to the inventory.
* Finds the first available valid position.
*
* @returns The placed item instance, or undefined if no valid position exists.
*/
export function addItem(
runState: RunState,
itemData: ItemData
): GameItem | undefined {
const shape = parseShapeString(itemData.shape);
return tryPlaceItemInInventory(runState.inventory, itemData, shape, runState._idCounter);
}
/**
* Removes an item from the inventory by its instance ID.
*/
export function removeItem(runState: RunState, instanceId: string): void {
gridRemoveItem(runState.inventory, instanceId);
}
// -- Query helpers --
/**
* Returns the node the player is currently at.
*/
export function getCurrentNode(runState: RunState): MapNode | undefined {
return getNode(runState.map, runState.currentNodeId);
}
/**
* Returns all reachable child nodes from the current position.
*/
export function getReachableChildren(runState: RunState): MapNode[] {
const currentNode = getNode(runState.map, runState.currentNodeId);
if (!currentNode) return [];
return currentNode.childIds
.map(id => getNode(runState.map, id))
.filter((n): n is MapNode => n !== undefined);
}
/**
* Returns available children that haven't been resolved yet.
*/
export function getUnresolvedChildren(runState: RunState): MapNode[] {
return getReachableChildren(runState).filter(
node => !runState.resolvedNodeIds.has(node.id)
);
}
/**
* Checks if the current encounter is the start node (no encounter).
*/
export function isAtStartNode(runState: RunState): boolean {
return runState.currentNodeId === runState.map.layers[0]?.nodes[0]?.id;
}
/**
* Checks if the current encounter is the end node.
*/
export function isAtEndNode(runState: RunState): boolean {
const endLayer = runState.map.layers[runState.map.layers.length - 1];
return endLayer?.nodes[0]?.id === runState.currentNodeId;
}
// -- Internal helpers --
/**
* Generates a unique item instance ID.
*/
function generateInstanceId(counter: { value: number }): string {
counter.value++;
return `item-${counter.value}`;
}
/**
* Tries to place an item in the inventory by scanning all possible positions.
* Returns the placed item instance, or undefined if no valid position exists.
*/
function tryPlaceItemInInventory(
inventory: GridInventory<GameItemMeta>,
itemData: ItemData,
shape: ParsedShape,
idCounter: { value: number }
): GameItem | undefined {
const instanceId = generateInstanceId(idCounter);
// Try to find a valid position
for (let y = 0; y <= inventory.height - shape.height; y++) {
for (let x = 0; x <= inventory.width - shape.width; x++) {
const transform = {
...IDENTITY_TRANSFORM,
offset: { x, y },
};
const validation = validatePlacement(inventory, shape, transform);
if (validation.valid) {
const item: GameItem = {
id: instanceId,
shape,
transform,
meta: {
itemData,
shape,
},
};
placeItem(inventory, item);
return item;
}
}
}
return undefined;
}

View File

@ -1,91 +0,0 @@
import type { PointCrawlMap } from "../map/types";
import type { GridInventory, InventoryItem } from "../grid-inventory/types";
import type { ParsedShape } from "../utils/parse-shape";
import { ItemData } from "@/samples/slay-the-spire-like/system/types";
/**
* Result of an encounter (combat, event, etc.).
*/
export interface EncounterResult {
/** Gold earned from the encounter */
goldEarned?: number;
/** HP lost during the encounter */
hpLost?: number;
/** HP gained (e.g., from camp heal) */
hpGained?: number;
/** Item IDs rewarded */
itemRewards?: string[];
}
/**
* Runtime state of an encounter at a specific node.
*/
export interface EncounterState {
/** The node ID where this encounter is located */
nodeId: string;
/** Whether the encounter has been resolved */
resolved: boolean;
/** Optional result data after resolution */
result?: EncounterResult;
}
/**
* Metadata attached to each inventory item instance.
* Bridges CSV item data with the grid inventory system.
*/
export interface GameItemMeta {
/** Original CSV item data */
itemData: ItemData;
/** Parsed shape for grid placement */
shape: ParsedShape;
/** Consumed uses, if card cost type is uses**/
consumedUses?: number;
/** Effects applied to the item */
effects?: Record<string, number>;
}
/**
* An item instance in the game inventory.
* Extends InventoryItem with game-specific metadata.
*/
export type GameItem = InventoryItem<GameItemMeta>;
/**
* Player runtime state.
*/
export interface PlayerState {
/** Maximum HP */
maxHp: number;
/** Current HP */
currentHp: number;
/** Current gold */
gold: number;
}
/**
* Full run state for a game session.
* Designed to be used inside `MutableSignal.produce()` callbacks.
*/
export interface RunState {
/** Generated point crawl map */
map: PointCrawlMap;
/** Player HP and gold */
player: PlayerState;
/** Grid inventory with placed items */
inventory: GridInventory<GameItemMeta>;
/** Current node ID where the player is located */
currentNodeId: string;
/** State of the encounter at the current node */
currentEncounter: EncounterState;
/** Set of node IDs whose encounters have been resolved */
resolvedNodeIds: Set<string>;
/** Internal counter for generating unique item instance IDs */
_idCounter: { value: number };
}
/**
* Result of a mutation operation on the run state.
*/
export type RunMutationResult =
| { success: true }
| { success: false; reason: string };

View File

@ -56,9 +56,9 @@ export type EncounterType =
| "shop"
| "camp"
| "curio";
export type EncounterData = {
export type EncounterData<T extends EncounterType = EncounterType> = {
readonly id: string;
readonly type: EncounterType;
readonly type: T;
readonly name: string;
readonly description: string;
readonly enemies: readonly [