Compare commits
No commits in common. "88eeee6ab751aef40dcf4e869332a299891a71ca" and "4bfc6db60fe620994229e0f3a95af40e81d1f7ae" have entirely different histories.
88eeee6ab7
...
4bfc6db60f
|
|
@ -1,26 +0,0 @@
|
||||||
# npc encounter (2): offer random trades, could be merchants or healer or something
|
|
||||||
# shelter (2): offer consumable restock and heal
|
|
||||||
# enemy (10): minor enemies
|
|
||||||
# elite (4): dangerous enemies
|
|
||||||
# boss (1): boss enemy
|
|
||||||
type,name,description
|
|
||||||
'npc'|'enemy'|'elite'|'boss'|'event'|'shelter',string,string
|
|
||||||
enemy,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。
|
|
||||||
enemy,蛇,概念:攻+强化。给玩家塞入蛇毒牌(消耗。一回合弃掉超过1张蛇毒时,受到6伤害)。
|
|
||||||
enemy,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。
|
|
||||||
enemy,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】。
|
|
||||||
enemy,风卷草,概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。
|
|
||||||
enemy,秃鹫,概念:攻+防。造成伤害后玩家获得秃鹫之眼(当你受到伤害时自动从手牌打出受到秃鹫的攻击)。
|
|
||||||
enemy,沙蝎,概念:攻+强化。【尾刺X】:玩家回合结束时受到沙蝎的X点攻击。受伤时失去等量【尾刺】。
|
|
||||||
enemy,幼沙虫,概念:防+强化。每回合第一次受伤时,玩家失去1点能量。
|
|
||||||
enemy,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。
|
|
||||||
enemy,沙匪,概念:攻特化。洗牌时,将一个随机物品的牌全部弃掉。
|
|
||||||
elite,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1)
|
|
||||||
elite,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。
|
|
||||||
elite,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。
|
|
||||||
elite,沙漠守卫,召唤木乃伊;会复活木乃伊2次。
|
|
||||||
boss,法老之灵,沙漠区域最终Boss。
|
|
||||||
npc,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。
|
|
||||||
npc,绿洲篝火,篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。
|
|
||||||
npc,迷失的旅人,提供任务:完成特定地点遭遇以获得独特奖励。
|
|
||||||
event,海市蜃楼,随机遭遇:可能获得宝藏或遭遇陷阱,使用d6双阶段结构结算。
|
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
type EncounterDesertTable = readonly {
|
|
||||||
readonly type: "npc" | "enemy" | "elite" | "boss" | "event" | "shelter";
|
|
||||||
readonly name: string;
|
|
||||||
readonly description: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
export type EncounterDesert = EncounterDesertTable[number];
|
|
||||||
|
|
||||||
declare const data: EncounterDesertTable;
|
|
||||||
export default data;
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
# targetType can be one of: single, none
|
# targetType can be one of: single, none
|
||||||
|
|
||||||
type,name,shape,costType,costCount,targetType,desc
|
type,name,shape,costType,costCount,targetType,desc
|
||||||
string,string,string,'energy'|'uses',int,'single'|'none',string
|
string,string,string,string,int,string,string
|
||||||
weapon,剑,oee,energy,1,single,【攻击2】【攻击2】
|
weapon,剑,oee,energy,1,single,【攻击2】【攻击2】
|
||||||
weapon,长斧,oees,energy,2,none,对全体【攻击5】
|
weapon,长斧,oees,energy,2,none,对全体【攻击5】
|
||||||
weapon,长枪,oeee,energy,1,single,【攻击2】【攻击2】【攻击2】
|
weapon,长枪,oeee,energy,1,single,【攻击2】【攻击2】【攻击2】
|
||||||
|
|
|
||||||
|
|
|
@ -2,13 +2,11 @@ type HeroItemFighter1Table = readonly {
|
||||||
readonly type: string;
|
readonly type: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly shape: string;
|
readonly shape: string;
|
||||||
readonly costType: "energy" | "uses";
|
readonly costType: string;
|
||||||
readonly costCount: number;
|
readonly costCount: number;
|
||||||
readonly targetType: "single" | "none";
|
readonly targetType: string;
|
||||||
readonly desc: string;
|
readonly desc: string;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
export type HeroItemFighter1 = HeroItemFighter1Table[number];
|
|
||||||
|
|
||||||
declare const data: HeroItemFighter1Table;
|
declare const data: HeroItemFighter1Table;
|
||||||
export default data;
|
export default data;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
import heroItemFighter1Csv from './heroItemFighter1.csv';
|
import heroItemFighter1Csv from './heroItemFighter1.csv';
|
||||||
import encounterDesertCsv from './encounterDesert.csv';
|
|
||||||
|
|
||||||
export const heroItemFighter1Data = heroItemFighter1Csv;
|
export const heroItemFighter1Data = heroItemFighter1Csv;
|
||||||
export const encounterDesertData = encounterDesertCsv;
|
|
||||||
export { default as encounterDesertCsv, type EncounterDesert } from './encounterDesert.csv';
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
export type { CellCoordinate, GridInventory, InventoryItem, PlacementResult } from './types';
|
|
||||||
export {
|
|
||||||
createGridInventory,
|
|
||||||
flipItem,
|
|
||||||
getAdjacentItems,
|
|
||||||
getItemAtCell,
|
|
||||||
getOccupiedCellSet,
|
|
||||||
moveItem,
|
|
||||||
placeItem,
|
|
||||||
removeItem,
|
|
||||||
rotateItem,
|
|
||||||
validatePlacement,
|
|
||||||
} from './transform';
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
import type { ParsedShape } from '../utils/parse-shape';
|
|
||||||
import type { Transform2D } from '../utils/shape-collision';
|
|
||||||
import {
|
|
||||||
checkBoardCollision,
|
|
||||||
checkBounds,
|
|
||||||
flipXTransform,
|
|
||||||
flipYTransform,
|
|
||||||
rotateTransform,
|
|
||||||
transformShape,
|
|
||||||
} from '../utils/shape-collision';
|
|
||||||
import type { GridInventory, InventoryItem, PlacementResult } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new empty grid inventory.
|
|
||||||
* Note: When used inside `.produce()`, call this before returning the draft.
|
|
||||||
*/
|
|
||||||
export function createGridInventory(width: number, height: number): GridInventory {
|
|
||||||
return {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
items: new Map<string, InventoryItem>(),
|
|
||||||
occupiedCells: new Set<string>(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a Set of occupied cell keys from a shape and transform.
|
|
||||||
*/
|
|
||||||
function getShapeCellKeys(shape: ParsedShape, transform: Transform2D): Set<string> {
|
|
||||||
const cells = transformShape(shape, transform);
|
|
||||||
return new Set(cells.map(c => `${c.x},${c.y}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates whether an item can be placed at the given transform.
|
|
||||||
* Checks bounds and collision with all other items.
|
|
||||||
*/
|
|
||||||
export function validatePlacement(
|
|
||||||
inventory: GridInventory,
|
|
||||||
shape: InventoryItem['shape'],
|
|
||||||
transform: Transform2D
|
|
||||||
): PlacementResult {
|
|
||||||
if (!checkBounds(shape, transform, inventory.width, inventory.height)) {
|
|
||||||
return { valid: false, reason: '超出边界' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkBoardCollision(shape, transform, inventory.occupiedCells)) {
|
|
||||||
return { valid: false, reason: '与已有物品重叠' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Places an item onto the grid.
|
|
||||||
* **Mutates directly** — call inside a `.produce()` callback.
|
|
||||||
* Does not validate; call `validatePlacement` first.
|
|
||||||
*/
|
|
||||||
export function placeItem(inventory: GridInventory, item: InventoryItem): void {
|
|
||||||
const cells = getShapeCellKeys(item.shape, item.transform);
|
|
||||||
for (const cellKey of cells) {
|
|
||||||
inventory.occupiedCells.add(cellKey);
|
|
||||||
}
|
|
||||||
inventory.items.set(item.id, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes an item from the grid by its ID.
|
|
||||||
* **Mutates directly** — call inside a `.produce()` callback.
|
|
||||||
*/
|
|
||||||
export function removeItem(inventory: GridInventory, itemId: string): void {
|
|
||||||
const item = inventory.items.get(itemId);
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
const cells = getShapeCellKeys(item.shape, item.transform);
|
|
||||||
for (const cellKey of cells) {
|
|
||||||
inventory.occupiedCells.delete(cellKey);
|
|
||||||
}
|
|
||||||
inventory.items.delete(itemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves an item to a new position with a new transform.
|
|
||||||
* **Mutates directly** — call inside a `.produce()` callback.
|
|
||||||
* Validates before applying; returns result indicating success.
|
|
||||||
*/
|
|
||||||
export function moveItem(
|
|
||||||
inventory: GridInventory,
|
|
||||||
itemId: string,
|
|
||||||
newTransform: Transform2D
|
|
||||||
): { success: true } | { success: false; reason: string } {
|
|
||||||
const item = inventory.items.get(itemId);
|
|
||||||
if (!item) {
|
|
||||||
return { success: false, reason: '物品不存在' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temporarily remove item's cells for validation
|
|
||||||
const oldCells = getShapeCellKeys(item.shape, item.transform);
|
|
||||||
for (const cellKey of oldCells) {
|
|
||||||
inventory.occupiedCells.delete(cellKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate new position
|
|
||||||
const validation = validatePlacement(inventory, item.shape, newTransform);
|
|
||||||
if (!validation.valid) {
|
|
||||||
// Restore old cells
|
|
||||||
for (const cellKey of oldCells) {
|
|
||||||
inventory.occupiedCells.add(cellKey);
|
|
||||||
}
|
|
||||||
return { success: false, reason: validation.reason };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply new transform
|
|
||||||
item.transform = newTransform;
|
|
||||||
|
|
||||||
// Add new cells
|
|
||||||
const newCells = getShapeCellKeys(item.shape, item.transform);
|
|
||||||
for (const cellKey of newCells) {
|
|
||||||
inventory.occupiedCells.add(cellKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotates an item by the given degrees (typically 90, 180, or 270).
|
|
||||||
* **Mutates directly** — call inside a `.produce()` callback.
|
|
||||||
* Validates before applying; returns result indicating success.
|
|
||||||
*/
|
|
||||||
export function rotateItem(
|
|
||||||
inventory: GridInventory,
|
|
||||||
itemId: string,
|
|
||||||
degrees: number
|
|
||||||
): { success: true } | { success: false; reason: string } {
|
|
||||||
const item = inventory.items.get(itemId);
|
|
||||||
if (!item) {
|
|
||||||
return { success: false, reason: '物品不存在' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const rotatedTransform = rotateTransform(item.transform, degrees);
|
|
||||||
return moveItem(inventory, itemId, rotatedTransform);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flips an item horizontally or vertically.
|
|
||||||
* **Mutates directly** — call inside a `.produce()` callback.
|
|
||||||
* Validates before applying; returns result indicating success.
|
|
||||||
*/
|
|
||||||
export function flipItem(
|
|
||||||
inventory: GridInventory,
|
|
||||||
itemId: string,
|
|
||||||
axis: 'x' | 'y'
|
|
||||||
): { success: true } | { success: false; reason: string } {
|
|
||||||
const item = inventory.items.get(itemId);
|
|
||||||
if (!item) {
|
|
||||||
return { success: false, reason: '物品不存在' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const flippedTransform = axis === 'x'
|
|
||||||
? flipXTransform(item.transform)
|
|
||||||
: flipYTransform(item.transform);
|
|
||||||
|
|
||||||
return moveItem(inventory, itemId, flippedTransform);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a copy of the occupied cells set.
|
|
||||||
*/
|
|
||||||
export function getOccupiedCellSet(inventory: GridInventory): Set<string> {
|
|
||||||
return new Set(inventory.occupiedCells);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds the item occupying the given cell, if any.
|
|
||||||
*/
|
|
||||||
export function getItemAtCell(
|
|
||||||
inventory: GridInventory,
|
|
||||||
x: number,
|
|
||||||
y: number
|
|
||||||
): InventoryItem | undefined {
|
|
||||||
const cellKey = `${x},${y}`;
|
|
||||||
if (!inventory.occupiedCells.has(cellKey)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of inventory.items.values()) {
|
|
||||||
const cells = getShapeCellKeys(item.shape, item.transform);
|
|
||||||
if (cells.has(cellKey)) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all items adjacent to the given item (orthogonally, not diagonally).
|
|
||||||
* Returns a Map of itemId -> item for deduplication.
|
|
||||||
*/
|
|
||||||
export function getAdjacentItems(
|
|
||||||
inventory: GridInventory,
|
|
||||||
itemId: string
|
|
||||||
): Map<string, InventoryItem> {
|
|
||||||
const item = inventory.items.get(itemId);
|
|
||||||
if (!item) {
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownCells = getShapeCellKeys(item.shape, item.transform);
|
|
||||||
const adjacent = new Map<string, InventoryItem>();
|
|
||||||
|
|
||||||
for (const cellKey of ownCells) {
|
|
||||||
const [cx, cy] = cellKey.split(',').map(Number);
|
|
||||||
const neighbors = [
|
|
||||||
`${cx + 1},${cy}`,
|
|
||||||
`${cx - 1},${cy}`,
|
|
||||||
`${cx},${cy + 1}`,
|
|
||||||
`${cx},${cy - 1}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const neighborKey of neighbors) {
|
|
||||||
if (inventory.occupiedCells.has(neighborKey) && !ownCells.has(neighborKey)) {
|
|
||||||
const neighborItem = getItemAtCell(inventory, ...neighborKey.split(',').map(Number) as [number, number]);
|
|
||||||
if (neighborItem) {
|
|
||||||
adjacent.set(neighborItem.id, neighborItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return adjacent;
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import type { ParsedShape } from '../utils/parse-shape';
|
|
||||||
import type { Transform2D } from '../utils/shape-collision';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple 2D coordinate for grid cells.
|
|
||||||
*/
|
|
||||||
export interface CellCoordinate {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An item placed on the grid inventory.
|
|
||||||
*/
|
|
||||||
export interface InventoryItem {
|
|
||||||
/** 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?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of a placement validation check.
|
|
||||||
*/
|
|
||||||
export type PlacementResult = { valid: true } | { valid: false; reason: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grid inventory state.
|
|
||||||
* Designed to be mutated directly inside a `mutative .produce()` callback.
|
|
||||||
*/
|
|
||||||
export interface GridInventory {
|
|
||||||
/** Board width in cells */
|
|
||||||
width: number;
|
|
||||||
/** Board height in cells */
|
|
||||||
height: number;
|
|
||||||
/** Map of itemId -> InventoryItem for all placed items */
|
|
||||||
items: Map<string, InventoryItem>;
|
|
||||||
/** Set of occupied cells in "x,y" format for O(1) collision lookups */
|
|
||||||
occupiedCells: Set<string>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
import { Mulberry32RNG, type RNG } from '@/utils/rng';
|
|
||||||
import encounterDesertCsv, { type EncounterDesert } from '../data/encounterDesert.csv';
|
|
||||||
import { MapNodeType } from './types';
|
|
||||||
import type { MapLayer, MapNode, PointCrawlMap } from './types';
|
|
||||||
|
|
||||||
/** Cache for parsed encounters by type */
|
|
||||||
const encountersByType = new Map<string, EncounterDesert[]>();
|
|
||||||
|
|
||||||
function indexEncounters(): void {
|
|
||||||
if (encountersByType.size > 0) return;
|
|
||||||
|
|
||||||
for (const encounter of encounterDesertCsv) {
|
|
||||||
const type = encounter.type;
|
|
||||||
if (!encountersByType.has(type)) {
|
|
||||||
encountersByType.set(type, []);
|
|
||||||
}
|
|
||||||
encountersByType.get(type)!.push(encounter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map from MapNodeType to encounter type key */
|
|
||||||
const NODE_TYPE_TO_ENCOUNTER: Partial<Record<MapNodeType, string>> = {
|
|
||||||
[MapNodeType.Combat]: 'enemy',
|
|
||||||
[MapNodeType.Elite]: 'elite',
|
|
||||||
[MapNodeType.Boss]: 'boss',
|
|
||||||
[MapNodeType.Event]: 'event',
|
|
||||||
[MapNodeType.NPC]: 'npc',
|
|
||||||
[MapNodeType.Shelter]: 'shelter',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picks a random encounter for the given node type.
|
|
||||||
* Returns undefined if no matching encounter exists.
|
|
||||||
*/
|
|
||||||
function pickEncounterForNode(type: MapNodeType, rng: RNG): EncounterDesert | undefined {
|
|
||||||
indexEncounters();
|
|
||||||
const encounterType = NODE_TYPE_TO_ENCOUNTER[type];
|
|
||||||
if (!encounterType) return undefined;
|
|
||||||
|
|
||||||
const pool = encountersByType.get(encounterType);
|
|
||||||
if (!pool || pool.length === 0) return undefined;
|
|
||||||
|
|
||||||
return pool[rng.nextInt(pool.length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Total number of layers (start + 11 intermediate + end) */
|
|
||||||
const TOTAL_LAYERS = 13;
|
|
||||||
|
|
||||||
/** Node type for each layer. Undefined layers use combat/elite mix. */
|
|
||||||
const LAYER_TYPE: Partial<Record<number, MapNodeType>> = {
|
|
||||||
0: MapNodeType.Start,
|
|
||||||
3: MapNodeType.Event,
|
|
||||||
6: MapNodeType.Shelter,
|
|
||||||
9: MapNodeType.NPC,
|
|
||||||
12: MapNodeType.Boss,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many nodes each layer should have.
|
|
||||||
* Diamond-ish shape: 1→2→3→4→5→5→5→5→4→3→2→1
|
|
||||||
*/
|
|
||||||
const LAYER_WIDTHS = [1, 2, 3, 4, 5, 5, 5, 5, 5, 4, 3, 2, 1];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a random point crawl map with layered directional graph.
|
|
||||||
*
|
|
||||||
* Invariants:
|
|
||||||
* - 13 layers (index 0 = start, index 12 = boss end)
|
|
||||||
* - Layer 3 = all events, layer 6 = shelters, layer 9 = NPCs
|
|
||||||
* - Every node has 1–2 outgoing edges to the next layer
|
|
||||||
* - Every node is reachable from start and can reach the end
|
|
||||||
*
|
|
||||||
* @param seed Random seed for reproducibility
|
|
||||||
*/
|
|
||||||
export function generatePointCrawlMap(seed?: number): PointCrawlMap {
|
|
||||||
const rng = new Mulberry32RNG(seed ?? Date.now());
|
|
||||||
const actualSeed = rng.getSeed();
|
|
||||||
|
|
||||||
const layers: MapLayer[] = [];
|
|
||||||
const nodes = new Map<string, MapNode>();
|
|
||||||
|
|
||||||
// Step 1: create layers and nodes
|
|
||||||
for (let i = 0; i < TOTAL_LAYERS; i++) {
|
|
||||||
const count = LAYER_WIDTHS[i];
|
|
||||||
const layerType = LAYER_TYPE[i];
|
|
||||||
const nodeIds: string[] = [];
|
|
||||||
|
|
||||||
for (let j = 0; j < count; j++) {
|
|
||||||
const id = `node-${i}-${j}`;
|
|
||||||
const type = layerType ?? pickLayerNodeType(i, rng);
|
|
||||||
const encounter = pickEncounterForNode(type, rng);
|
|
||||||
const node: MapNode = {
|
|
||||||
id,
|
|
||||||
layerIndex: i,
|
|
||||||
type,
|
|
||||||
childIds: [],
|
|
||||||
...(encounter ? { encounter: { name: encounter.name, description: encounter.description } } : {}),
|
|
||||||
};
|
|
||||||
nodes.set(id, node);
|
|
||||||
nodeIds.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
layers.push({ index: i, nodeIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: generate edges between each pair of consecutive layers
|
|
||||||
for (let i = 0; i < TOTAL_LAYERS - 1; i++) {
|
|
||||||
const sourceIds = layers[i].nodeIds;
|
|
||||||
const targetIds = layers[i + 1].nodeIds;
|
|
||||||
generateLayerEdges(sourceIds, targetIds, nodes, rng);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { layers, nodes, seed: actualSeed };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picks a node type for a general (non-fixed) layer.
|
|
||||||
* Elite nodes appear ~25% of the time, combat for the rest.
|
|
||||||
*/
|
|
||||||
function pickLayerNodeType(_layerIndex: number, rng: RNG): MapNodeType {
|
|
||||||
return rng.nextInt(4) === 0 ? MapNodeType.Elite : MapNodeType.Combat;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates edges between two consecutive layers.
|
|
||||||
*
|
|
||||||
* Constraints:
|
|
||||||
* - Each source node gets 1–2 edges to target nodes
|
|
||||||
* - Every target node has at least one incoming edge (no dead ends)
|
|
||||||
*/
|
|
||||||
function generateLayerEdges(
|
|
||||||
sourceIds: string[],
|
|
||||||
targetIds: string[],
|
|
||||||
nodes: Map<string, MapNode>,
|
|
||||||
rng: RNG
|
|
||||||
): void {
|
|
||||||
const sourceBranches = new Map<string, number>(); // id → current outgoing count
|
|
||||||
const targetIncoming = new Map<string, number>(); // id → current incoming count
|
|
||||||
for (const id of sourceIds) sourceBranches.set(id, 0);
|
|
||||||
for (const id of targetIds) targetIncoming.set(id, 0);
|
|
||||||
|
|
||||||
// --- Pass 1: give each source 1–2 targets, prioritising uncovered targets ---
|
|
||||||
const uncovered = new Set(targetIds);
|
|
||||||
|
|
||||||
for (const srcId of sourceIds) {
|
|
||||||
const branches = rng.nextInt(2) + 1; // 1 or 2
|
|
||||||
|
|
||||||
for (let b = 0; b < branches; b++) {
|
|
||||||
if (uncovered.size > 0) {
|
|
||||||
// Pick a random uncovered target
|
|
||||||
const arr = Array.from(uncovered);
|
|
||||||
const idx = rng.nextInt(arr.length);
|
|
||||||
const tgtId = arr[idx];
|
|
||||||
nodes.get(srcId)!.childIds.push(tgtId);
|
|
||||||
sourceBranches.set(srcId, sourceBranches.get(srcId)! + 1);
|
|
||||||
targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1);
|
|
||||||
uncovered.delete(tgtId);
|
|
||||||
} else if (sourceBranches.get(srcId)! < 2) {
|
|
||||||
// All targets covered; pick any random target
|
|
||||||
const tgtId = targetIds[rng.nextInt(targetIds.length)];
|
|
||||||
nodes.get(srcId)!.childIds.push(tgtId);
|
|
||||||
sourceBranches.set(srcId, sourceBranches.get(srcId)! + 1);
|
|
||||||
targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Pass 2: cover any remaining uncovered targets ---
|
|
||||||
for (const tgtId of uncovered) {
|
|
||||||
// Find a source that still has room (< 2 branches)
|
|
||||||
const available = sourceIds.filter(id => sourceBranches.get(id)! < 2);
|
|
||||||
if (available.length > 0) {
|
|
||||||
const srcId = available[rng.nextInt(available.length)];
|
|
||||||
nodes.get(srcId)!.childIds.push(tgtId);
|
|
||||||
sourceBranches.set(srcId, sourceBranches.get(srcId)! + 1);
|
|
||||||
targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1);
|
|
||||||
} else {
|
|
||||||
// All sources are at 2 branches; force-add to a random source
|
|
||||||
const srcId = sourceIds[rng.nextInt(sourceIds.length)];
|
|
||||||
nodes.get(srcId)!.childIds.push(tgtId);
|
|
||||||
targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Query helpers --
|
|
||||||
|
|
||||||
/** Returns the node with the given ID, or undefined. */
|
|
||||||
export function getNode(map: PointCrawlMap, nodeId: string): MapNode | undefined {
|
|
||||||
return map.nodes.get(nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns child nodes of the given node. */
|
|
||||||
export function getChildren(map: PointCrawlMap, node: MapNode): MapNode[] {
|
|
||||||
return node.childIds
|
|
||||||
.map(id => map.nodes.get(id))
|
|
||||||
.filter((n): n is MapNode => n !== undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns parent nodes of the given node (reverse lookup). */
|
|
||||||
export function getParents(map: PointCrawlMap, node: MapNode): MapNode[] {
|
|
||||||
const parents: MapNode[] = [];
|
|
||||||
const parentLayer = map.layers[node.layerIndex - 1];
|
|
||||||
if (!parentLayer) return parents;
|
|
||||||
|
|
||||||
for (const parentId of parentLayer.nodeIds) {
|
|
||||||
const parent = map.nodes.get(parentId);
|
|
||||||
if (parent?.childIds.includes(node.id)) {
|
|
||||||
parents.push(parent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parents;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if there is a directed path from `fromId` to `toId`.
|
|
||||||
*/
|
|
||||||
export function hasPath(map: PointCrawlMap, fromId: string, toId: string): boolean {
|
|
||||||
const visited = new Set<string>();
|
|
||||||
const stack = [fromId];
|
|
||||||
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const current = stack.pop()!;
|
|
||||||
if (current === toId) return true;
|
|
||||||
if (visited.has(current)) continue;
|
|
||||||
visited.add(current);
|
|
||||||
|
|
||||||
const node = map.nodes.get(current);
|
|
||||||
if (node) {
|
|
||||||
for (const childId of node.childIds) {
|
|
||||||
if (!visited.has(childId)) stack.push(childId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds all directed paths from `fromId` to `toId`.
|
|
||||||
* Returns arrays of node IDs representing each path.
|
|
||||||
* Beware: can be exponential in large maps.
|
|
||||||
*/
|
|
||||||
export function findAllPaths(map: PointCrawlMap, fromId: string, toId: string): string[][] {
|
|
||||||
const paths: string[][] = [];
|
|
||||||
const dfs = (current: string, path: string[]) => {
|
|
||||||
if (current === toId) {
|
|
||||||
paths.push([...path, current]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const node = map.nodes.get(current);
|
|
||||||
if (!node) return;
|
|
||||||
|
|
||||||
path.push(current);
|
|
||||||
for (const childId of node.childIds) {
|
|
||||||
dfs(childId, path);
|
|
||||||
}
|
|
||||||
path.pop();
|
|
||||||
};
|
|
||||||
|
|
||||||
dfs(fromId, []);
|
|
||||||
return paths;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
export { MapNodeType } from './types';
|
|
||||||
export type { MapNode, MapLayer, PointCrawlMap } from './types';
|
|
||||||
|
|
||||||
export { generatePointCrawlMap } from './generator';
|
|
||||||
export { getNode, getChildren, getParents, hasPath, findAllPaths } from './generator';
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
/**
|
|
||||||
* Types of nodes that can appear on the point crawl map.
|
|
||||||
*/
|
|
||||||
export enum MapNodeType {
|
|
||||||
Start = 'start',
|
|
||||||
Combat = 'combat',
|
|
||||||
Event = 'event',
|
|
||||||
Elite = 'elite',
|
|
||||||
Shelter = 'shelter',
|
|
||||||
NPC = 'npc',
|
|
||||||
Boss = 'boss',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single node on the map.
|
|
||||||
*/
|
|
||||||
export interface MapNode {
|
|
||||||
/** Unique identifier */
|
|
||||||
id: string;
|
|
||||||
/** Which layer this node belongs to */
|
|
||||||
layerIndex: number;
|
|
||||||
/** Semantic type of the node */
|
|
||||||
type: MapNodeType;
|
|
||||||
/** IDs of nodes in the next layer this node connects to */
|
|
||||||
childIds: string[];
|
|
||||||
/** Encounter data assigned to this node (from encounter CSV) */
|
|
||||||
encounter?: {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A horizontal layer of nodes at the same progression stage.
|
|
||||||
*/
|
|
||||||
export interface MapLayer {
|
|
||||||
/** Layer index (0 = start, last = end) */
|
|
||||||
index: number;
|
|
||||||
/** Ordered IDs of nodes in this layer */
|
|
||||||
nodeIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fully generated point crawl map.
|
|
||||||
*/
|
|
||||||
export interface PointCrawlMap {
|
|
||||||
/** Layers from start to end */
|
|
||||||
layers: MapLayer[];
|
|
||||||
/** All nodes keyed by ID */
|
|
||||||
nodes: Map<string, MapNode>;
|
|
||||||
/** RNG seed used for generation (for reproducibility) */
|
|
||||||
seed: number;
|
|
||||||
}
|
|
||||||
|
|
@ -1,485 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
|
||||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
|
||||||
import {
|
|
||||||
createGridInventory,
|
|
||||||
placeItem,
|
|
||||||
removeItem,
|
|
||||||
moveItem,
|
|
||||||
rotateItem,
|
|
||||||
flipItem,
|
|
||||||
getOccupiedCellSet,
|
|
||||||
getItemAtCell,
|
|
||||||
getAdjacentItems,
|
|
||||||
validatePlacement,
|
|
||||||
type GridInventory,
|
|
||||||
type InventoryItem,
|
|
||||||
} from '@/samples/slay-the-spire-like/grid-inventory';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: create a test inventory item.
|
|
||||||
*/
|
|
||||||
function createTestItem(id: string, shapeStr: string, transform = IDENTITY_TRANSFORM): InventoryItem {
|
|
||||||
const shape = parseShapeString(shapeStr);
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
shape,
|
|
||||||
transform: { ...transform },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('grid-inventory', () => {
|
|
||||||
describe('createGridInventory', () => {
|
|
||||||
it('should create an empty inventory with correct dimensions', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
expect(inv.width).toBe(6);
|
|
||||||
expect(inv.height).toBe(4);
|
|
||||||
expect(inv.items.size).toBe(0);
|
|
||||||
expect(inv.occupiedCells.size).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('placeItem', () => {
|
|
||||||
it('should place a single-cell item', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const item = createTestItem('sword', 'o');
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
expect(inv.items.size).toBe(1);
|
|
||||||
expect(inv.items.has('sword')).toBe(true);
|
|
||||||
expect(inv.occupiedCells.has('0,0')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should place a multi-cell item', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const item = createTestItem('axe', 'oee');
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
expect(inv.items.size).toBe(1);
|
|
||||||
expect(inv.occupiedCells.size).toBe(3);
|
|
||||||
expect(inv.occupiedCells.has('0,0')).toBe(true);
|
|
||||||
expect(inv.occupiedCells.has('1,0')).toBe(true);
|
|
||||||
expect(inv.occupiedCells.has('2,0')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should place multiple items', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const itemA = createTestItem('a', 'o');
|
|
||||||
const itemB = createTestItem('b', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 0 } });
|
|
||||||
placeItem(inv, itemA);
|
|
||||||
placeItem(inv, itemB);
|
|
||||||
|
|
||||||
expect(inv.items.size).toBe(2);
|
|
||||||
expect(inv.occupiedCells.size).toBe(2);
|
|
||||||
expect(inv.occupiedCells.has('0,0')).toBe(true);
|
|
||||||
expect(inv.occupiedCells.has('3,0')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('removeItem', () => {
|
|
||||||
it('should remove an item and free its cells', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const item = createTestItem('sword', 'oee');
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
removeItem(inv, 'sword');
|
|
||||||
|
|
||||||
expect(inv.items.size).toBe(0);
|
|
||||||
expect(inv.occupiedCells.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should only free the removed item\'s cells', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const itemA = createTestItem('a', 'o');
|
|
||||||
const itemB = createTestItem('b', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 0 } });
|
|
||||||
placeItem(inv, itemA);
|
|
||||||
placeItem(inv, itemB);
|
|
||||||
|
|
||||||
removeItem(inv, 'a');
|
|
||||||
|
|
||||||
expect(inv.items.size).toBe(1);
|
|
||||||
expect(inv.occupiedCells.size).toBe(1);
|
|
||||||
expect(inv.occupiedCells.has('0,0')).toBe(false);
|
|
||||||
expect(inv.occupiedCells.has('2,0')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should do nothing for non-existent item', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
removeItem(inv, 'nonexistent');
|
|
||||||
expect(inv.items.size).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validatePlacement', () => {
|
|
||||||
it('should return valid for empty board', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const shape = parseShapeString('o');
|
|
||||||
const result = validatePlacement(inv, shape, IDENTITY_TRANSFORM);
|
|
||||||
expect(result).toEqual({ valid: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return invalid for out of bounds', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const shape = parseShapeString('o');
|
|
||||||
const result = validatePlacement(inv, shape, {
|
|
||||||
...IDENTITY_TRANSFORM,
|
|
||||||
offset: { x: 6, y: 0 },
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ valid: false, reason: '超出边界' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return invalid for collision with existing item', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const existing = createTestItem('a', 'oee');
|
|
||||||
placeItem(inv, existing);
|
|
||||||
|
|
||||||
const shape = parseShapeString('o');
|
|
||||||
const result = validatePlacement(inv, shape, IDENTITY_TRANSFORM);
|
|
||||||
expect(result).toEqual({ valid: false, reason: '与已有物品重叠' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return valid when there is room nearby', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const existing = createTestItem('a', 'o');
|
|
||||||
placeItem(inv, existing);
|
|
||||||
|
|
||||||
const shape = parseShapeString('o');
|
|
||||||
const result = validatePlacement(inv, shape, {
|
|
||||||
...IDENTITY_TRANSFORM,
|
|
||||||
offset: { x: 1, y: 0 },
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ valid: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('moveItem', () => {
|
|
||||||
it('should move item to a new position', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const item = createTestItem('sword', 'o');
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
const result = moveItem(inv, 'sword', {
|
|
||||||
...IDENTITY_TRANSFORM,
|
|
||||||
offset: { x: 5, y: 3 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
|
||||||
expect(inv.occupiedCells.has('0,0')).toBe(false);
|
|
||||||
expect(inv.occupiedCells.has('5,3')).toBe(true);
|
|
||||||
expect(item.transform.offset).toEqual({ x: 5, y: 3 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject move that goes out of bounds', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const item = createTestItem('sword', 'o');
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
const result = moveItem(inv, 'sword', {
|
|
||||||
...IDENTITY_TRANSFORM,
|
|
||||||
offset: { x: 6, y: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: false, reason: '超出边界' });
|
|
||||||
expect(inv.occupiedCells.has('0,0')).toBe(true);
|
|
||||||
expect(item.transform.offset).toEqual({ x: 0, y: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject move that collides with another item', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const itemA = createTestItem('a', 'o');
|
|
||||||
const itemB = createTestItem('b', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 0 } });
|
|
||||||
placeItem(inv, itemA);
|
|
||||||
placeItem(inv, itemB);
|
|
||||||
|
|
||||||
const result = moveItem(inv, 'b', {
|
|
||||||
...IDENTITY_TRANSFORM,
|
|
||||||
offset: { x: 0, y: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: false, reason: '与已有物品重叠' });
|
|
||||||
expect(inv.occupiedCells.has('2,0')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error for non-existent item', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const result = moveItem(inv, 'ghost', IDENTITY_TRANSFORM);
|
|
||||||
expect(result).toEqual({ success: false, reason: '物品不存在' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should move multi-cell item correctly', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
// oes: cells at (0,0), (1,0), (1,1)
|
|
||||||
const item = createTestItem('axe', 'oes');
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
const newTransform = { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 1 } };
|
|
||||||
moveItem(inv, 'axe', newTransform);
|
|
||||||
|
|
||||||
// Old cells should be freed
|
|
||||||
expect(inv.occupiedCells.has('0,0')).toBe(false);
|
|
||||||
expect(inv.occupiedCells.has('1,0')).toBe(false);
|
|
||||||
expect(inv.occupiedCells.has('1,1')).toBe(false);
|
|
||||||
// New cells: (0,0)+offset(3,1)=(3,1), (1,0)+(3,1)=(4,1), (1,1)+(3,1)=(4,2)
|
|
||||||
expect(inv.occupiedCells.has('3,1')).toBe(true);
|
|
||||||
expect(inv.occupiedCells.has('4,1')).toBe(true);
|
|
||||||
expect(inv.occupiedCells.has('4,2')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('rotateItem', () => {
|
|
||||||
it('should rotate item by 90 degrees', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
// Horizontal line: (0,0), (1,0)
|
|
||||||
const item = createTestItem('bar', 'oe', {
|
|
||||||
...IDENTITY_TRANSFORM,
|
|
||||||
offset: { x: 0, y: 1 }, // Place away from edge so rotation stays in bounds
|
|
||||||
});
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
const result = rotateItem(inv, 'bar', 90);
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
|
||||||
expect(item.transform.rotation).toBe(90);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject rotation that goes out of bounds', () => {
|
|
||||||
const inv = createGridInventory(3, 3);
|
|
||||||
// Item at the edge: place a 2-wide item at x=1
|
|
||||||
const item = createTestItem('bar', 'oe', {
|
|
||||||
...IDENTITY_TRANSFORM,
|
|
||||||
offset: { x: 1, y: 0 },
|
|
||||||
});
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
// Rotating 90° would make it vertical starting at (1,0), going to (1,-1) -> out of bounds
|
|
||||||
const result = rotateItem(inv, 'bar', 90);
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: false, reason: '超出边界' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject rotation that collides', () => {
|
|
||||||
const inv = createGridInventory(4, 4);
|
|
||||||
const itemA = createTestItem('a', 'o');
|
|
||||||
const itemB = createTestItem('b', 'oe', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 0 } });
|
|
||||||
placeItem(inv, itemA);
|
|
||||||
placeItem(inv, itemB);
|
|
||||||
|
|
||||||
// Rotating b 90° would place cells at (2,0) and (2,-1) -> (2,-1) is out of bounds
|
|
||||||
// Let's try a different scenario: rotate b 270° -> (2,0) and (2,1) which is fine
|
|
||||||
// But rotating to collide with a at (0,0)... need item close to a
|
|
||||||
const itemC = createTestItem('c', 'os', { ...IDENTITY_TRANSFORM, offset: { x: 1, y: 0 } });
|
|
||||||
placeItem(inv, itemC);
|
|
||||||
|
|
||||||
// Rotating c 90° would give cells at (1,0) and (0,0) -> collision with a
|
|
||||||
const result = rotateItem(inv, 'c', 90);
|
|
||||||
expect(result).toEqual({ success: false, reason: '与已有物品重叠' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error for non-existent item', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const result = rotateItem(inv, 'ghost', 90);
|
|
||||||
expect(result).toEqual({ success: false, reason: '物品不存在' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('flipItem', () => {
|
|
||||||
it('should flip item horizontally', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const item = createTestItem('bar', 'oe');
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
const result = flipItem(inv, 'bar', 'x');
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
|
||||||
expect(item.transform.flipX).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should flip item vertically', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const item = createTestItem('bar', 'os');
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
const result = flipItem(inv, 'bar', 'y');
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
|
||||||
expect(item.transform.flipY).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject flip that causes collision', () => {
|
|
||||||
// oes local cells: (0,0),(1,0),(1,1). flipY: (0,1),(1,1),(1,0).
|
|
||||||
// Place flipper at offset (0,2): world cells (0,2),(1,2),(1,3).
|
|
||||||
// flipY gives local (0,1),(1,1),(1,0) + offset(0,2) = (0,3),(1,3),(1,2) — same cells rearranged.
|
|
||||||
// Need asymmetric shape where flip changes world position.
|
|
||||||
// Use oes at offset (0,0): cells (0,0),(1,0),(1,1). flipY: (0,1),(1,1),(1,0).
|
|
||||||
// Place blocker at (0,1) — which is NOT occupied by oes initially.
|
|
||||||
const inv = createGridInventory(4, 4);
|
|
||||||
const blocker = createTestItem('blocker', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 1 } });
|
|
||||||
// oes at (0,1): cells (0,1),(1,1),(1,2). This overlaps blocker at (0,1)!
|
|
||||||
// Let me try: blocker at (1,0), flipper at offset (0,2).
|
|
||||||
// flipper oes at (0,2): (0,2),(1,2),(1,3). blocker at (1,0) — no overlap.
|
|
||||||
// flipY: local (0,1),(1,1),(1,0) + offset(0,2) = (0,3),(1,3),(1,2). No collision with (1,0).
|
|
||||||
//
|
|
||||||
// Simpler: oe shape (width=2, height=1). flipY with height=1 is identity. Use os (width=1, height=2).
|
|
||||||
// os: (0,0),(0,1). flipY: (0,1),(0,0) — same cells.
|
|
||||||
// Need width>1 and height>1 asymmetric shape: oes
|
|
||||||
//
|
|
||||||
// Place flipper at (0,0): cells (0,0),(1,0),(1,1). Place blocker at (0,1) — but (0,1) is not occupied.
|
|
||||||
// flipY: (0,1),(1,1),(1,0). (0,1) hits blocker!
|
|
||||||
const inv2 = createGridInventory(4, 4);
|
|
||||||
const blocker2 = createTestItem('blocker', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 1 } });
|
|
||||||
const flipper2 = createTestItem('flipper', 'oes'); // at (0,0): (0,0),(1,0),(1,1)
|
|
||||||
placeItem(inv2, blocker2);
|
|
||||||
placeItem(inv2, flipper2);
|
|
||||||
|
|
||||||
const result = flipItem(inv2, 'flipper', 'y');
|
|
||||||
expect(result).toEqual({ success: false, reason: '与已有物品重叠' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error for non-existent item', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const result = flipItem(inv, 'ghost', 'x');
|
|
||||||
expect(result).toEqual({ success: false, reason: '物品不存在' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getOccupiedCellSet', () => {
|
|
||||||
it('should return a copy of occupied cells', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const item = createTestItem('a', 'oe');
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
const cells = getOccupiedCellSet(inv);
|
|
||||||
expect(cells).toEqual(new Set(['0,0', '1,0']));
|
|
||||||
|
|
||||||
// Mutating the copy should not affect the original
|
|
||||||
cells.clear();
|
|
||||||
expect(inv.occupiedCells.size).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getItemAtCell', () => {
|
|
||||||
it('should return item at occupied cell', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const item = createTestItem('sword', 'oee');
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
const found = getItemAtCell(inv, 1, 0);
|
|
||||||
expect(found).toBeDefined();
|
|
||||||
expect(found!.id).toBe('sword');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return undefined for empty cell', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const item = createTestItem('sword', 'o');
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
const found = getItemAtCell(inv, 5, 5);
|
|
||||||
expect(found).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct item when multiple items exist', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const itemA = createTestItem('a', 'o');
|
|
||||||
const itemB = createTestItem('b', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 2 } });
|
|
||||||
placeItem(inv, itemA);
|
|
||||||
placeItem(inv, itemB);
|
|
||||||
|
|
||||||
expect(getItemAtCell(inv, 0, 0)!.id).toBe('a');
|
|
||||||
expect(getItemAtCell(inv, 3, 2)!.id).toBe('b');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getAdjacentItems', () => {
|
|
||||||
it('should return orthogonally adjacent items', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const center = createTestItem('center', 'o', {
|
|
||||||
...IDENTITY_TRANSFORM,
|
|
||||||
offset: { x: 2, y: 2 },
|
|
||||||
});
|
|
||||||
const top = createTestItem('top', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 1 } });
|
|
||||||
const left = createTestItem('left', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 1, y: 2 } });
|
|
||||||
const right = createTestItem('right', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 2 } });
|
|
||||||
const bottom = createTestItem('bottom', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 3 } });
|
|
||||||
const diagonal = createTestItem('diagonal', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 1, y: 1 } });
|
|
||||||
|
|
||||||
placeItem(inv, center);
|
|
||||||
placeItem(inv, top);
|
|
||||||
placeItem(inv, left);
|
|
||||||
placeItem(inv, right);
|
|
||||||
placeItem(inv, bottom);
|
|
||||||
placeItem(inv, diagonal);
|
|
||||||
|
|
||||||
const adj = getAdjacentItems(inv, 'center');
|
|
||||||
expect(adj.size).toBe(4);
|
|
||||||
expect(adj.has('top')).toBe(true);
|
|
||||||
expect(adj.has('left')).toBe(true);
|
|
||||||
expect(adj.has('right')).toBe(true);
|
|
||||||
expect(adj.has('bottom')).toBe(true);
|
|
||||||
expect(adj.has('diagonal')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty for item with no neighbors', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const item = createTestItem('alone', 'o');
|
|
||||||
placeItem(inv, item);
|
|
||||||
|
|
||||||
const adj = getAdjacentItems(inv, 'alone');
|
|
||||||
expect(adj.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty for non-existent item', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
const adj = getAdjacentItems(inv, 'ghost');
|
|
||||||
expect(adj.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle multi-cell items with multiple adjacencies', () => {
|
|
||||||
const inv = createGridInventory(6, 4);
|
|
||||||
// Horizontal bar at (0,0)-(1,0)
|
|
||||||
const bar = createTestItem('bar', 'oe');
|
|
||||||
// Item above left cell
|
|
||||||
const topA = createTestItem('topA', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 0, y: -1 } });
|
|
||||||
// Item above right cell
|
|
||||||
const topB = createTestItem('topB', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 1, y: -1 } });
|
|
||||||
|
|
||||||
placeItem(inv, bar);
|
|
||||||
placeItem(inv, topA);
|
|
||||||
placeItem(inv, topB);
|
|
||||||
|
|
||||||
const adj = getAdjacentItems(inv, 'bar');
|
|
||||||
expect(adj.size).toBe(2);
|
|
||||||
expect(adj.has('topA')).toBe(true);
|
|
||||||
expect(adj.has('topB')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('integration: fill a 4x6 backpack', () => {
|
|
||||||
it('should place items fitting a slay-the-spire-like backpack', () => {
|
|
||||||
const inv = createGridInventory(4, 6);
|
|
||||||
|
|
||||||
// Sword: 1x3 horizontal at (0,0)
|
|
||||||
const sword = createTestItem('sword', 'oee');
|
|
||||||
// Shield: 2x2 at (0,1)
|
|
||||||
const shield = createTestItem('shield', 'oes', {
|
|
||||||
...IDENTITY_TRANSFORM,
|
|
||||||
offset: { x: 0, y: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(validatePlacement(inv, sword.shape, sword.transform)).toEqual({ valid: true });
|
|
||||||
placeItem(inv, sword);
|
|
||||||
|
|
||||||
expect(validatePlacement(inv, shield.shape, shield.transform)).toEqual({ valid: true });
|
|
||||||
placeItem(inv, shield);
|
|
||||||
|
|
||||||
expect(inv.items.size).toBe(2);
|
|
||||||
expect(inv.occupiedCells.size).toBe(6); // sword(3) + shield(3)
|
|
||||||
|
|
||||||
// Adjacent items should detect each other
|
|
||||||
const adjSword = getAdjacentItems(inv, 'sword');
|
|
||||||
expect(adjSword.has('shield')).toBe(true);
|
|
||||||
|
|
||||||
const adjShield = getAdjacentItems(inv, 'shield');
|
|
||||||
expect(adjShield.has('sword')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { generatePointCrawlMap } from '@/samples/slay-the-spire-like/map/generator';
|
|
||||||
import { MapNodeType } from '@/samples/slay-the-spire-like/map/types';
|
|
||||||
|
|
||||||
describe('generatePointCrawlMap', () => {
|
|
||||||
it('should generate a map with 13 layers', () => {
|
|
||||||
const map = generatePointCrawlMap(123);
|
|
||||||
expect(map.layers.length).toBe(13);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct fixed layer types', () => {
|
|
||||||
const map = generatePointCrawlMap(123);
|
|
||||||
const startNode = map.nodes.get('node-0-0');
|
|
||||||
const bossNode = map.nodes.get('node-12-0');
|
|
||||||
|
|
||||||
expect(startNode?.type).toBe(MapNodeType.Start);
|
|
||||||
expect(bossNode?.type).toBe(MapNodeType.Boss);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should assign encounters to nodes based on encounterDesert.csv', () => {
|
|
||||||
const map = generatePointCrawlMap(456);
|
|
||||||
|
|
||||||
// Check that nodes have encounters assigned
|
|
||||||
let combatWithEncounter = 0;
|
|
||||||
let eliteWithEncounter = 0;
|
|
||||||
let bossWithEncounter = 0;
|
|
||||||
let eventWithEncounter = 0;
|
|
||||||
let npcWithEncounter = 0;
|
|
||||||
let shelterWithEncounter = 0;
|
|
||||||
|
|
||||||
for (const node of map.nodes.values()) {
|
|
||||||
if (node.type === MapNodeType.Combat && node.encounter) {
|
|
||||||
combatWithEncounter++;
|
|
||||||
expect(node.encounter.name).toBeTruthy();
|
|
||||||
expect(node.encounter.description).toBeTruthy();
|
|
||||||
}
|
|
||||||
if (node.type === MapNodeType.Elite && node.encounter) {
|
|
||||||
eliteWithEncounter++;
|
|
||||||
expect(node.encounter.name).toBeTruthy();
|
|
||||||
expect(node.encounter.description).toBeTruthy();
|
|
||||||
}
|
|
||||||
if (node.type === MapNodeType.Boss && node.encounter) {
|
|
||||||
bossWithEncounter++;
|
|
||||||
expect(node.encounter.name).toBeTruthy();
|
|
||||||
expect(node.encounter.description).toBeTruthy();
|
|
||||||
}
|
|
||||||
if (node.type === MapNodeType.Event && node.encounter) {
|
|
||||||
eventWithEncounter++;
|
|
||||||
expect(node.encounter.name).toBeTruthy();
|
|
||||||
expect(node.encounter.description).toBeTruthy();
|
|
||||||
}
|
|
||||||
if (node.type === MapNodeType.NPC && node.encounter) {
|
|
||||||
npcWithEncounter++;
|
|
||||||
expect(node.encounter.name).toBeTruthy();
|
|
||||||
expect(node.encounter.description).toBeTruthy();
|
|
||||||
}
|
|
||||||
if (node.type === MapNodeType.Shelter && node.encounter) {
|
|
||||||
shelterWithEncounter++;
|
|
||||||
expect(node.encounter.name).toBeTruthy();
|
|
||||||
expect(node.encounter.description).toBeTruthy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have assigned at least some encounters
|
|
||||||
const totalWithEncounters =
|
|
||||||
combatWithEncounter +
|
|
||||||
eliteWithEncounter +
|
|
||||||
bossWithEncounter +
|
|
||||||
eventWithEncounter +
|
|
||||||
npcWithEncounter +
|
|
||||||
shelterWithEncounter;
|
|
||||||
|
|
||||||
expect(totalWithEncounters).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use correct encounter types for each node type', () => {
|
|
||||||
const map = generatePointCrawlMap(789);
|
|
||||||
|
|
||||||
for (const node of map.nodes.values()) {
|
|
||||||
if (node.encounter) {
|
|
||||||
// Encounter should match node type conceptually
|
|
||||||
// Combat nodes should have enemy encounters, elites should have elite encounters, etc.
|
|
||||||
if (node.type === MapNodeType.Boss) {
|
|
||||||
expect(node.encounter.description).toContain('Boss');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue