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:
parent
423cc7c841
commit
f336a989e2
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: {};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { RunState, EncounterState } from "./types";
|
||||
export { buildCombatState } from "./combat";
|
||||
export { generateInstanceId } from "./shop";
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export function generateInstanceId(counter: { value: number }): string {
|
||||
counter.value++;
|
||||
return `item-${counter.value}`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -15,4 +15,10 @@ export {
|
|||
findAllPaths,
|
||||
} from "./generator";
|
||||
|
||||
export { canMoveTo, moveToNode } from "./navigation";
|
||||
export {
|
||||
canMoveTo,
|
||||
moveToNode,
|
||||
getReachableChildren,
|
||||
isAtEndNode,
|
||||
isAtStartNode,
|
||||
} from "./navigation";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 [
|
||||
|
|
|
|||
Loading…
Reference in New Issue