Compare commits
6 Commits
6ac5ac4b9a
...
4bfc6db60f
| Author | SHA1 | Date |
|---|---|---|
|
|
4bfc6db60f | |
|
|
238254c4e5 | |
|
|
6281044f14 | |
|
|
18eae59891 | |
|
|
6dc85b443e | |
|
|
d5f65fa6cd |
|
|
@ -25,9 +25,9 @@ Zone based Point Crawl:
|
||||||
|
|
||||||
3种牌(攻击/防御/强化)攻击克强化克防御克攻击,强化比sts强化窄但效果更大
|
3种牌(攻击/防御/强化)攻击克强化克防御克攻击,强化比sts强化窄但效果更大
|
||||||
|
|
||||||
受伤时伤口先占格子再扣血,牌少的角色受掉血影响大但血更厚
|
洗牌堆时洗入两张《疲劳》(1费/消耗)
|
||||||
|
|
||||||
背包物品指定相邻物品,对其所有卡牌生效
|
背包物品可能会引用相邻的物品、同一行的物品、同一列的物品等
|
||||||
|
|
||||||
部分物品带使用次数限制,药瓶喝完可以在篝火补
|
部分物品带使用次数限制,药瓶喝完可以在篝火补
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
# type can be one of: weapon, armor, consumable, tool
|
||||||
|
#
|
||||||
|
# shape represents a tetris-like shape, consult ../utils/parse-shape.ts
|
||||||
|
# n
|
||||||
|
# w o e
|
||||||
|
# s
|
||||||
|
# costType can be one of: energy, uses
|
||||||
|
#
|
||||||
|
# targetType can be one of: single, none
|
||||||
|
|
||||||
|
type,name,shape,costType,costCount,targetType,desc
|
||||||
|
string,string,string,string,int,string,string
|
||||||
|
weapon,剑,oee,energy,1,single,【攻击2】【攻击2】
|
||||||
|
weapon,长斧,oees,energy,2,none,对全体【攻击5】
|
||||||
|
weapon,长枪,oeee,energy,1,single,【攻击2】【攻击2】【攻击2】
|
||||||
|
weapon,短刀,oe,energy,1,single,【攻击3】【攻击3】
|
||||||
|
weapon,飞镖,o,energy,0,single,【攻击1】,抓一张牌
|
||||||
|
weapon,十字弩,onrersrw,energy,2,single,【攻击6】。对同一目标打出其他十字弩
|
||||||
|
armor,盾,oesw,energy,1,none,【防御3】
|
||||||
|
armor,斗笠,oerwrn,energy,2,none,【防御8】
|
||||||
|
armor,披风,oers,energy,1,none,【防御2】,下回合【防御2】
|
||||||
|
armor,护腕,o,energy,0,none,【防御1】,抓1张牌
|
||||||
|
armor,大盾,oesswn,energy,1,none,【防御5】
|
||||||
|
armor,锁子甲,oers,energy,1,none,本回合受到伤害-3
|
||||||
|
consumable,绷带,o,uses,3,none,从牌堆或弃牌堆随机移除1张伤口
|
||||||
|
consumable,淬毒药剂,o,uses,3,none,周围物品的【攻击】+2
|
||||||
|
consumable,强固药剂,o,uses,3,none,周围物品的【防御】+2
|
||||||
|
consumable,活力药剂,o,uses,3,none,获得1点能量
|
||||||
|
consumable,集中药剂,o,uses,3,none,抓2张牌
|
||||||
|
consumable,治疗药剂,o,uses,3,none,从牌堆或弃牌堆移除3张伤口
|
||||||
|
tool,水袋,os,energy,1,none,下回合开始时,获得1能量,抓2张牌
|
||||||
|
tool,绳索,ose,energy,1,none,周围物品的牌【防御】+2直到打出
|
||||||
|
tool,腰带,owre,energy,0,none,从牌堆周围物品的牌当中选择一张加入手牌
|
||||||
|
tool,火把,on,energy,1,none,下次打出周围物品的牌时,将其消耗并获得1能量
|
||||||
|
tool,磨刀石,o,energy,1,none,周围物品的牌【攻击】+3直到打出
|
||||||
|
tool,铁匠锤,oers,energy,1,none,从牌堆/弃牌堆选择一张牌,随机变为一张周围物品的牌
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
type HeroItemFighter1Table = readonly {
|
||||||
|
readonly type: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly shape: string;
|
||||||
|
readonly costType: string;
|
||||||
|
readonly costCount: number;
|
||||||
|
readonly targetType: string;
|
||||||
|
readonly desc: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
declare const data: HeroItemFighter1Table;
|
||||||
|
export default data;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import heroItemFighter1Csv from './heroItemFighter1.csv';
|
||||||
|
|
||||||
|
export const heroItemFighter1Data = heroItemFighter1Csv;
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
/**
|
||||||
|
* Parsed shape result containing the grid and sizing information.
|
||||||
|
*/
|
||||||
|
export interface ParsedShape {
|
||||||
|
/** 2D boolean grid representing filled cells */
|
||||||
|
grid: boolean[][];
|
||||||
|
/** Grid width (number of columns) */
|
||||||
|
width: number;
|
||||||
|
/** Grid height (number of rows) */
|
||||||
|
height: number;
|
||||||
|
/** Number of occupied (filled) cells */
|
||||||
|
count: number;
|
||||||
|
/** Origin X coordinate within the grid */
|
||||||
|
originX: number;
|
||||||
|
/** Origin Y coordinate within the grid */
|
||||||
|
originY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a movement string into a 2D boolean grid.
|
||||||
|
* Rules:
|
||||||
|
* o: Fill current cell and set origin
|
||||||
|
* n, s, e, w: Move one step and fill the cell
|
||||||
|
* r: Return to the previous cell
|
||||||
|
*/
|
||||||
|
export function parseShapeString(input: string): ParsedShape {
|
||||||
|
let curX = 0;
|
||||||
|
let curY = 0;
|
||||||
|
let originX = 0;
|
||||||
|
let originY = 0;
|
||||||
|
let originSet = false;
|
||||||
|
|
||||||
|
// Track unique filled coordinates
|
||||||
|
const filledPoints = new Set<string>();
|
||||||
|
// Stack to track movement history for the 'r' command
|
||||||
|
const history: { x: number; y: number }[] = [];
|
||||||
|
|
||||||
|
const fill = (x: number, y: number) => {
|
||||||
|
filledPoints.add(`${x},${y}`);
|
||||||
|
history.push({ x, y });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const char of input.toLowerCase()) {
|
||||||
|
switch (char) {
|
||||||
|
case 'o':
|
||||||
|
if (!originSet) {
|
||||||
|
originX = curX;
|
||||||
|
originY = curY;
|
||||||
|
originSet = true;
|
||||||
|
}
|
||||||
|
fill(curX, curY);
|
||||||
|
break;
|
||||||
|
case 'n':
|
||||||
|
curY -= 1;
|
||||||
|
fill(curX, curY);
|
||||||
|
break;
|
||||||
|
case 's':
|
||||||
|
curY += 1;
|
||||||
|
fill(curX, curY);
|
||||||
|
break;
|
||||||
|
case 'e':
|
||||||
|
curX += 1;
|
||||||
|
fill(curX, curY);
|
||||||
|
break;
|
||||||
|
case 'w':
|
||||||
|
curX -= 1;
|
||||||
|
fill(curX, curY);
|
||||||
|
break;
|
||||||
|
case 'r':
|
||||||
|
if (history.length > 1) {
|
||||||
|
history.pop(); // Remove current position
|
||||||
|
const prev = history[history.length - 1];
|
||||||
|
curX = prev.x;
|
||||||
|
curY = prev.y;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filledPoints.size === 0) {
|
||||||
|
return { grid: [[]], width: 0, height: 1, count: 0, originX: 0, originY: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate bounding box to normalize the array
|
||||||
|
const coords = Array.from(filledPoints).map(p => p.split(',').map(Number));
|
||||||
|
const minX = Math.min(...coords.map(c => c[0]));
|
||||||
|
const maxX = Math.max(...coords.map(c => c[0]));
|
||||||
|
const minY = Math.min(...coords.map(c => c[1]));
|
||||||
|
const maxY = Math.max(...coords.map(c => c[1]));
|
||||||
|
|
||||||
|
const width = maxX - minX + 1;
|
||||||
|
const height = maxY - minY + 1;
|
||||||
|
|
||||||
|
// Initialize the grid
|
||||||
|
const grid: boolean[][] = Array.from({ length: height }, () =>
|
||||||
|
Array(width).fill(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map the points into the grid using offsets
|
||||||
|
for (const [x, y] of coords) {
|
||||||
|
grid[y - minY][x - minX] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize origin coordinates relative to the grid
|
||||||
|
const normalizedOriginX = originX - minX;
|
||||||
|
const normalizedOriginY = originY - minY;
|
||||||
|
|
||||||
|
return { grid, width, height, count: filledPoints.size, originX: normalizedOriginX, originY: normalizedOriginY };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
import type { ParsedShape } from './parse-shape';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a 2D point in grid coordinates.
|
||||||
|
*/
|
||||||
|
export interface Point2D {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2D transformation to apply to a shape.
|
||||||
|
*/
|
||||||
|
export interface Transform2D {
|
||||||
|
/** Translation offset in grid units */
|
||||||
|
offset: Point2D;
|
||||||
|
/** Rotation in degrees (0, 90, 180, 270) */
|
||||||
|
rotation: number;
|
||||||
|
/** Whether to flip horizontally */
|
||||||
|
flipX: boolean;
|
||||||
|
/** Whether to flip vertically */
|
||||||
|
flipY: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default transform (identity).
|
||||||
|
*/
|
||||||
|
export const IDENTITY_TRANSFORM: Transform2D = {
|
||||||
|
offset: { x: 0, y: 0 },
|
||||||
|
rotation: 0,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all occupied cell coordinates from a shape.
|
||||||
|
*/
|
||||||
|
export function getOccupiedCells(shape: ParsedShape): Point2D[] {
|
||||||
|
const cells: Point2D[] = [];
|
||||||
|
for (let y = 0; y < shape.height; y++) {
|
||||||
|
for (let x = 0; x < shape.width; x++) {
|
||||||
|
if (shape.grid[y]?.[x]) {
|
||||||
|
cells.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a 2D transformation to a point.
|
||||||
|
*/
|
||||||
|
export function transformPoint(
|
||||||
|
point: Point2D,
|
||||||
|
transform: Transform2D,
|
||||||
|
shapeWidth: number,
|
||||||
|
shapeHeight: number
|
||||||
|
): Point2D {
|
||||||
|
let { x, y } = point;
|
||||||
|
|
||||||
|
// Apply flips
|
||||||
|
if (transform.flipX) {
|
||||||
|
x = shapeWidth - 1 - x;
|
||||||
|
}
|
||||||
|
if (transform.flipY) {
|
||||||
|
y = shapeHeight - 1 - y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rotation (around origin 0,0)
|
||||||
|
const rotation = ((transform.rotation % 360) + 360) % 360;
|
||||||
|
let rotatedX = x;
|
||||||
|
let rotatedY = y;
|
||||||
|
|
||||||
|
switch (rotation) {
|
||||||
|
case 90:
|
||||||
|
rotatedX = y;
|
||||||
|
rotatedY = -x;
|
||||||
|
break;
|
||||||
|
case 180:
|
||||||
|
rotatedX = -x;
|
||||||
|
rotatedY = -y;
|
||||||
|
break;
|
||||||
|
case 270:
|
||||||
|
rotatedX = -y;
|
||||||
|
rotatedY = x;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply offset
|
||||||
|
return {
|
||||||
|
x: rotatedX + transform.offset.x,
|
||||||
|
y: rotatedY + transform.offset.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms a shape and returnss its occupied cells in world coordinates.
|
||||||
|
*/
|
||||||
|
export function transformShape(shape: ParsedShape, transform: Transform2D): Point2D[] {
|
||||||
|
const cells = getOccupiedCells(shape);
|
||||||
|
return cells.map(cell =>
|
||||||
|
transformPoint(cell, transform, shape.width, shape.height)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if two transformed shapes collide (share any occupied cell).
|
||||||
|
*/
|
||||||
|
export function checkCollision(
|
||||||
|
shapeA: ParsedShape,
|
||||||
|
transformA: Transform2D,
|
||||||
|
shapeB: ParsedShape,
|
||||||
|
transformB: Transform2D
|
||||||
|
): boolean {
|
||||||
|
const cellsA = transformShape(shapeA, transformA);
|
||||||
|
const cellsB = transformShape(shapeB, transformB);
|
||||||
|
|
||||||
|
const setA = new Set(cellsA.map(c => `${c.x},${c.y}`));
|
||||||
|
|
||||||
|
for (const cell of cellsB) {
|
||||||
|
if (setA.has(`${cell.x},${cell.y}`)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a transformed shape collides with any occupied cells on a board.
|
||||||
|
* @param shape The shape to check
|
||||||
|
* @param transform The transform to apply to the shape
|
||||||
|
* @param occupiedCells Set of occupied board cells in "x,y" format
|
||||||
|
*/
|
||||||
|
export function checkBoardCollision(
|
||||||
|
shape: ParsedShape,
|
||||||
|
transform: Transform2D,
|
||||||
|
occupiedCells: Set<string>
|
||||||
|
): boolean {
|
||||||
|
const cells = transformShape(shape, transform);
|
||||||
|
|
||||||
|
for (const cell of cells) {
|
||||||
|
if (occupiedCells.has(`${cell.x},${cell.y}`)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a transformed shape is within bounds.
|
||||||
|
* @param shape The shape to check
|
||||||
|
* @param transform The transform to apply to the shape
|
||||||
|
* @param boardWidth Board width
|
||||||
|
* @param boardHeight Board height
|
||||||
|
*/
|
||||||
|
export function checkBounds(
|
||||||
|
shape: ParsedShape,
|
||||||
|
transform: Transform2D,
|
||||||
|
boardWidth: number,
|
||||||
|
boardHeight: number
|
||||||
|
): boolean {
|
||||||
|
const cells = transformShape(shape, transform);
|
||||||
|
|
||||||
|
for (const cell of cells) {
|
||||||
|
if (cell.x < 0 || cell.x >= boardWidth || cell.y < 0 || cell.y >= boardHeight) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a placement is both in bounds and collision-free.
|
||||||
|
* @returns Object with `valid` flag and optional `reason` string
|
||||||
|
*/
|
||||||
|
export function validatePlacement(
|
||||||
|
shape: ParsedShape,
|
||||||
|
transform: Transform2D,
|
||||||
|
boardWidth: number,
|
||||||
|
boardHeight: number,
|
||||||
|
occupiedCells: Set<string>
|
||||||
|
): { valid: true } | { valid: false; reason: string } {
|
||||||
|
if (!checkBounds(shape, transform, boardWidth, boardHeight)) {
|
||||||
|
return { valid: false, reason: '超出边界' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkBoardCollision(shape, transform, occupiedCells)) {
|
||||||
|
return { valid: false, reason: '与已有形状重叠' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotates a transform by the given degrees.
|
||||||
|
* @param current The current transform
|
||||||
|
* @param degrees Degrees to rotate (typically 90, 180, or 270)
|
||||||
|
*/
|
||||||
|
export function rotateTransform(current: Transform2D, degrees: number): Transform2D {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
rotation: ((current.rotation + degrees) % 360 + 360) % 360,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flips a transform horizontally.
|
||||||
|
*/
|
||||||
|
export function flipXTransform(current: Transform2D): Transform2D {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
flipX: !current.flipX,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flips a transform vertically.
|
||||||
|
*/
|
||||||
|
export function flipYTransform(current: Transform2D): Transform2D {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
flipY: !current.flipY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@ const WINNING_LINES: number[][][] = [
|
||||||
|
|
||||||
export type PlayerType = 'X' | 'O';
|
export type PlayerType = 'X' | 'O';
|
||||||
export type WinnerType = PlayerType | 'draw' | null;
|
export type WinnerType = PlayerType | 'draw' | null;
|
||||||
type TicTacToePart = Part<{ player: PlayerType }>;
|
export type TicTacToePart = Part<{ player: PlayerType }>;
|
||||||
|
|
||||||
export function createInitialState() {
|
export function createInitialState() {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { heroItemFighter1Data } from '@/samples/slay-the-spire-like/data';
|
||||||
|
|
||||||
|
describe('heroItemFighter1.csv import', () => {
|
||||||
|
it('should import data as an array', () => {
|
||||||
|
expect(Array.isArray(heroItemFighter1Data)).toBe(true);
|
||||||
|
expect(heroItemFighter1Data.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have expected number of items', () => {
|
||||||
|
// CSV has 24 data rows (excluding header and type rows)
|
||||||
|
expect(heroItemFighter1Data.length).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct fields for each item', () => {
|
||||||
|
for (const item of heroItemFighter1Data) {
|
||||||
|
expect(item).toHaveProperty('type');
|
||||||
|
expect(item).toHaveProperty('name');
|
||||||
|
expect(item).toHaveProperty('shape');
|
||||||
|
expect(item).toHaveProperty('costType');
|
||||||
|
expect(item).toHaveProperty('costCount');
|
||||||
|
expect(item).toHaveProperty('targetType');
|
||||||
|
expect(item).toHaveProperty('desc');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse costCount as number', () => {
|
||||||
|
for (const item of heroItemFighter1Data) {
|
||||||
|
expect(typeof item.costCount).toBe('number');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain expected items by name', () => {
|
||||||
|
const names = heroItemFighter1Data.map(item => item.name);
|
||||||
|
expect(names).toContain('剑');
|
||||||
|
expect(names).toContain('盾');
|
||||||
|
expect(names).toContain('绷带');
|
||||||
|
expect(names).toContain('火把');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid type values', () => {
|
||||||
|
const validTypes = ['weapon', 'armor', 'consumable', 'tool'];
|
||||||
|
for (const item of heroItemFighter1Data) {
|
||||||
|
expect(validTypes).toContain(item.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid costType values', () => {
|
||||||
|
const validCostTypes = ['energy', 'uses'];
|
||||||
|
for (const item of heroItemFighter1Data) {
|
||||||
|
expect(validCostTypes).toContain(item.costType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid targetType values', () => {
|
||||||
|
const validTargetTypes = ['single', 'none'];
|
||||||
|
for (const item of heroItemFighter1Data) {
|
||||||
|
expect(validTargetTypes).toContain(item.targetType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct item counts by type', () => {
|
||||||
|
const typeCounts = heroItemFighter1Data.reduce((acc, item) => {
|
||||||
|
acc[item.type] = (acc[item.type] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
expect(typeCounts['weapon']).toBe(6);
|
||||||
|
expect(typeCounts['armor']).toBe(6);
|
||||||
|
expect(typeCounts['consumable']).toBe(6);
|
||||||
|
expect(typeCounts['tool']).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,355 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
||||||
|
import {
|
||||||
|
checkCollision,
|
||||||
|
checkBoardCollision,
|
||||||
|
checkBounds,
|
||||||
|
validatePlacement,
|
||||||
|
transformShape,
|
||||||
|
getOccupiedCells,
|
||||||
|
IDENTITY_TRANSFORM,
|
||||||
|
} from '@/samples/slay-the-spire-like/utils/shape-collision';
|
||||||
|
|
||||||
|
describe('parseShapeString', () => {
|
||||||
|
it('should parse a single cell with o', () => {
|
||||||
|
const result = parseShapeString('o');
|
||||||
|
expect(result.grid).toEqual([[true]]);
|
||||||
|
expect(result.width).toBe(1);
|
||||||
|
expect(result.height).toBe(1);
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
expect(result.originX).toBe(0);
|
||||||
|
expect(result.originY).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse a horizontal line', () => {
|
||||||
|
const result = parseShapeString('oee');
|
||||||
|
expect(result.width).toBe(3);
|
||||||
|
expect(result.height).toBe(1);
|
||||||
|
expect(result.count).toBe(3);
|
||||||
|
expect(result.grid).toEqual([[true, true, true]]);
|
||||||
|
expect(result.originX).toBe(0);
|
||||||
|
expect(result.originY).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse a vertical line', () => {
|
||||||
|
const result = parseShapeString('oss');
|
||||||
|
expect(result.width).toBe(1);
|
||||||
|
expect(result.height).toBe(3);
|
||||||
|
expect(result.count).toBe(3);
|
||||||
|
expect(result.grid).toEqual([[true], [true], [true]]);
|
||||||
|
expect(result.originX).toBe(0);
|
||||||
|
expect(result.originY).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse an L shape', () => {
|
||||||
|
const result = parseShapeString('oes');
|
||||||
|
expect(result.width).toBe(2);
|
||||||
|
expect(result.height).toBe(2);
|
||||||
|
expect(result.count).toBe(3);
|
||||||
|
expect(result.grid).toEqual([
|
||||||
|
[true, true],
|
||||||
|
[false, true],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle return command', () => {
|
||||||
|
const result = parseShapeString('oeerww');
|
||||||
|
expect(result.width).toBe(4);
|
||||||
|
expect(result.height).toBe(1);
|
||||||
|
expect(result.count).toBe(4);
|
||||||
|
expect(result.grid).toEqual([[true, true, true, true]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle case insensitivity', () => {
|
||||||
|
const resultLower = parseShapeString('oes');
|
||||||
|
const resultUpper = parseShapeString('OES');
|
||||||
|
expect(resultLower.grid).toEqual(resultUpper.grid);
|
||||||
|
expect(resultLower.count).toBe(resultUpper.count);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty grid for empty input', () => {
|
||||||
|
const result = parseShapeString('');
|
||||||
|
expect(result.grid).toEqual([[]]);
|
||||||
|
expect(result.width).toBe(0);
|
||||||
|
expect(result.height).toBe(1);
|
||||||
|
expect(result.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track origin correctly', () => {
|
||||||
|
// eeso: e(1,0), e(2,0), s(2,1), o sets origin at (2,1)
|
||||||
|
// After normalization: minX=1, minY=0, so originX = 2-1 = 1, originY = 1-0 = 1
|
||||||
|
const result = parseShapeString('eeso');
|
||||||
|
expect(result.originX).toBe(1);
|
||||||
|
expect(result.originY).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track origin at first o only', () => {
|
||||||
|
const result = parseShapeString('oes');
|
||||||
|
expect(result.originX).toBe(0);
|
||||||
|
expect(result.originY).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex T shape', () => {
|
||||||
|
// oewers: o(0,0), e(1,0), w(0,0), e(1,0), r->(0,0), s(0,1)
|
||||||
|
// Filled: (0,0), (1,0), (0,1) - 3 cells
|
||||||
|
const result = parseShapeString('oewers');
|
||||||
|
expect(result.width).toBe(2);
|
||||||
|
expect(result.height).toBe(2);
|
||||||
|
expect(result.count).toBe(3);
|
||||||
|
expect(result.grid).toEqual([
|
||||||
|
[true, true],
|
||||||
|
[true, false],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shape-collision', () => {
|
||||||
|
describe('getOccupiedCells', () => {
|
||||||
|
it('should return cells for a single cell shape', () => {
|
||||||
|
const shape = parseShapeString('o');
|
||||||
|
const cells = getOccupiedCells(shape);
|
||||||
|
expect(cells).toEqual([{ x: 0, y: 0 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return cells for a horizontal line', () => {
|
||||||
|
const shape = parseShapeString('oe');
|
||||||
|
const cells = getOccupiedCells(shape);
|
||||||
|
expect(cells).toEqual([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return cells for an L shape', () => {
|
||||||
|
const shape = parseShapeString('oes');
|
||||||
|
const cells = getOccupiedCells(shape);
|
||||||
|
expect(cells).toEqual([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkCollision', () => {
|
||||||
|
it('should detect collision between overlapping shapes', () => {
|
||||||
|
const shapeA = parseShapeString('o');
|
||||||
|
const shapeB = parseShapeString('o');
|
||||||
|
|
||||||
|
const result = checkCollision(
|
||||||
|
shapeA,
|
||||||
|
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||||
|
shapeB,
|
||||||
|
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } }
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not detect collision between non-overlapping shapes', () => {
|
||||||
|
const shapeA = parseShapeString('o');
|
||||||
|
const shapeB = parseShapeString('o');
|
||||||
|
|
||||||
|
const result = checkCollision(
|
||||||
|
shapeA,
|
||||||
|
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||||
|
shapeB,
|
||||||
|
{ ...IDENTITY_TRANSFORM, offset: { x: 2, y: 0 } }
|
||||||
|
);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect collision with adjacent shapes', () => {
|
||||||
|
const shapeA = parseShapeString('o');
|
||||||
|
const shapeB = parseShapeString('o');
|
||||||
|
|
||||||
|
const result = checkCollision(
|
||||||
|
shapeA,
|
||||||
|
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||||
|
shapeB,
|
||||||
|
{ ...IDENTITY_TRANSFORM, offset: { x: 1, y: 0 } }
|
||||||
|
);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect collision with rotation', () => {
|
||||||
|
const shapeA = parseShapeString('oe');
|
||||||
|
const shapeB = parseShapeString('os');
|
||||||
|
|
||||||
|
// shapeA is horizontal at (0,0)-(1,0)
|
||||||
|
// shapeB rotated 90° becomes vertical at (0,0)-(0,1)
|
||||||
|
// They should collide at (0,0)
|
||||||
|
const result = checkCollision(
|
||||||
|
shapeA,
|
||||||
|
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||||
|
shapeB,
|
||||||
|
{ ...IDENTITY_TRANSFORM, rotation: 90, offset: { x: 0, y: 0 } }
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkBoardCollision', () => {
|
||||||
|
it('should detect collision with occupied cells', () => {
|
||||||
|
const shape = parseShapeString('oe');
|
||||||
|
const occupied = new Set(['0,0', '1,0']);
|
||||||
|
|
||||||
|
const result = checkBoardCollision(shape, IDENTITY_TRANSFORM, occupied);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not detect collision with empty board', () => {
|
||||||
|
const shape = parseShapeString('oe');
|
||||||
|
const occupied = new Set<string>();
|
||||||
|
|
||||||
|
const result = checkBoardCollision(shape, IDENTITY_TRANSFORM, occupied);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect collision after translation', () => {
|
||||||
|
const shape = parseShapeString('oe');
|
||||||
|
const occupied = new Set(['5,5', '6,5']);
|
||||||
|
|
||||||
|
const result = checkBoardCollision(
|
||||||
|
shape,
|
||||||
|
{ ...IDENTITY_TRANSFORM, offset: { x: 5, y: 5 } },
|
||||||
|
occupied
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkBounds', () => {
|
||||||
|
it('should return true for shape within bounds', () => {
|
||||||
|
const shape = parseShapeString('oe');
|
||||||
|
|
||||||
|
const result = checkBounds(shape, IDENTITY_TRANSFORM, 10, 10);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for shape outside bounds', () => {
|
||||||
|
const shape = parseShapeString('oe');
|
||||||
|
|
||||||
|
const result = checkBounds(
|
||||||
|
shape,
|
||||||
|
{ ...IDENTITY_TRANSFORM, offset: { x: 9, y: 0 } },
|
||||||
|
10,
|
||||||
|
10
|
||||||
|
);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for negative coordinates', () => {
|
||||||
|
const shape = parseShapeString('oe');
|
||||||
|
|
||||||
|
const result = checkBounds(
|
||||||
|
shape,
|
||||||
|
{ ...IDENTITY_TRANSFORM, offset: { x: -1, y: 0 } },
|
||||||
|
10,
|
||||||
|
10
|
||||||
|
);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for shape at boundary edge', () => {
|
||||||
|
const shape = parseShapeString('o');
|
||||||
|
|
||||||
|
const result = checkBounds(
|
||||||
|
shape,
|
||||||
|
{ ...IDENTITY_TRANSFORM, offset: { x: 9, y: 9 } },
|
||||||
|
10,
|
||||||
|
10
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validatePlacement', () => {
|
||||||
|
it('should return valid for good placement', () => {
|
||||||
|
const shape = parseShapeString('oe');
|
||||||
|
const occupied = new Set<string>();
|
||||||
|
|
||||||
|
const result = validatePlacement(shape, IDENTITY_TRANSFORM, 10, 10, occupied);
|
||||||
|
expect(result).toEqual({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return invalid for out of bounds', () => {
|
||||||
|
const shape = parseShapeString('oe');
|
||||||
|
const occupied = new Set<string>();
|
||||||
|
|
||||||
|
const result = validatePlacement(
|
||||||
|
shape,
|
||||||
|
{ ...IDENTITY_TRANSFORM, offset: { x: 9, y: 0 } },
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
occupied
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ valid: false, reason: '超出边界' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return invalid for collision', () => {
|
||||||
|
const shape = parseShapeString('oe');
|
||||||
|
const occupied = new Set(['0,0', '1,0']);
|
||||||
|
|
||||||
|
const result = validatePlacement(shape, IDENTITY_TRANSFORM, 10, 10, occupied);
|
||||||
|
expect(result).toEqual({ valid: false, reason: '与已有形状重叠' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('transformShape', () => {
|
||||||
|
it('should apply translation correctly', () => {
|
||||||
|
const shape = parseShapeString('o');
|
||||||
|
const transform = { ...IDENTITY_TRANSFORM, offset: { x: 5, y: 3 } };
|
||||||
|
|
||||||
|
const cells = transformShape(shape, transform);
|
||||||
|
expect(cells).toEqual([{ x: 5, y: 3 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply 90° rotation correctly', () => {
|
||||||
|
const shape = parseShapeString('oe');
|
||||||
|
const transform = { ...IDENTITY_TRANSFORM, rotation: 90 };
|
||||||
|
|
||||||
|
const cells = transformShape(shape, transform);
|
||||||
|
expect(cells).toEqual([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 0, y: -1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply horizontal flip correctly', () => {
|
||||||
|
const shape = parseShapeString('oe');
|
||||||
|
const transform = { ...IDENTITY_TRANSFORM, flipX: true };
|
||||||
|
|
||||||
|
const cells = transformShape(shape, transform);
|
||||||
|
expect(cells).toEqual([
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply vertical flip correctly', () => {
|
||||||
|
const shape = parseShapeString('os');
|
||||||
|
const transform = { ...IDENTITY_TRANSFORM, flipY: true };
|
||||||
|
|
||||||
|
const cells = transformShape(shape, transform);
|
||||||
|
expect(cells).toEqual([
|
||||||
|
{ x: 0, y: 1 },
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should combine rotation and translation', () => {
|
||||||
|
const shape = parseShapeString('os');
|
||||||
|
const transform = {
|
||||||
|
...IDENTITY_TRANSFORM,
|
||||||
|
rotation: 90,
|
||||||
|
offset: { x: 10, y: 10 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const cells = transformShape(shape, transform);
|
||||||
|
expect(cells).toEqual([
|
||||||
|
{ x: 10, y: 10 },
|
||||||
|
{ x: 11, y: 10 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue