refactor: add some part management utilities

This commit is contained in:
2026-04-03 15:00:25 +08:00
parent 86714e7837
commit 4bf6eb2f6b
9 changed files with 170 additions and 63 deletions
+88
View File
@@ -0,0 +1,88 @@
import {Part} from "./part";
export type PartTemplate<TMeta = {}> = Omit<Partial<Part<TMeta>>, 'id'> & TMeta;
export type PartPool<TMeta = {}> = {
parts: Part<TMeta>[];
template: PartTemplate<TMeta>;
draw(): Part<TMeta> | undefined;
return(part: Part<TMeta>): void;
remaining(): number;
};
export function createPart<TMeta = {}>(
template: PartTemplate<TMeta>,
id: string
): Part<TMeta> {
return {
regionId: '',
position: [],
...template,
id,
} as Part<TMeta>;
}
export function createParts<TMeta = {}>(
template: PartTemplate<TMeta>,
count: number,
idPrefix: string
): Part<TMeta>[] {
const parts: Part<TMeta>[] = [];
for (let i = 0; i < count; i++) {
parts.push(createPart(template, `${idPrefix}-${i + 1}`));
}
return parts;
}
export function createPartPool<TMeta = {}>(
template: PartTemplate<TMeta>,
count: number,
idPrefix: string
): PartPool<TMeta> {
const parts = createParts(template, count, idPrefix);
const available = [...parts];
return {
parts,
template,
draw() {
return available.pop();
},
return(part: Part<TMeta>) {
part.regionId = '';
part.position = [];
available.push(part);
},
remaining() {
return available.length;
},
};
}
export function mergePartPools<TMeta = {}>(
...pools: PartPool<TMeta>[]
): PartPool<TMeta> {
if (pools.length === 0) {
return createPartPool({} as PartTemplate<TMeta>, 0, 'merged');
}
const allParts = pools.flatMap(p => p.parts);
const template = pools[0].template;
const available = allParts.filter(p => p.regionId === '');
return {
parts: allParts,
template,
draw() {
return available.pop();
},
return(part: Part<TMeta>) {
part.regionId = '';
part.position = [];
available.push(part);
},
remaining() {
return available.length;
},
};
}
+19 -5
View File
@@ -1,6 +1,6 @@
import {RNG} from "@/utils/rng";
export type Part = {
export type Part<TMeta = {}> = {
id: string;
sides?: number;
@@ -10,19 +10,33 @@ export type Part = {
alignment?: string;
regionId: string;
position: number[];
}
} & TMeta;
export function flip(part: Part) {
export function flip<TMeta>(part: Part<TMeta>) {
if(!part.sides) return;
part.side = ((part.side || 0) + 1) % part.sides;
}
export function flipTo(part: Part, side: number) {
export function flipTo<TMeta>(part: Part<TMeta>, side: number) {
if(!part.sides || side >= part.sides) return;
part.side = side;
}
export function roll(part: Part, rng: RNG) {
export function roll<TMeta>(part: Part<TMeta>, rng: RNG) {
if(!part.sides) return;
part.side = rng.nextInt(part.sides);
}
export function findPartById<TMeta>(parts: Part<TMeta>[], id: string): Part<TMeta> | undefined {
return parts.find(p => p.id === id);
}
export function isCellOccupied<TMeta>(parts: Part<TMeta>[], regionId: string, position: number[]): boolean {
const posKey = position.join(',');
return parts.some(p => p.regionId === regionId && p.position.join(',') === posKey);
}
export function getPartAtPosition<TMeta>(parts: Part<TMeta>[], regionId: string, position: number[]): Part<TMeta> | undefined {
const posKey = position.join(',');
return parts.find(p => p.regionId === regionId && p.position.join(',') === posKey);
}
+14 -10
View File
@@ -24,7 +24,7 @@ export function createRegion(id: string, axes: RegionAxis[]): Region {
};
}
function buildPartMap(region: Region, parts: Record<string, Part>) {
function buildPartMap<TMeta>(region: Region, parts: Record<string, Part<TMeta>>) {
const map: Record<string, string> = {};
for (const childId of region.childIds) {
const part = parts[childId];
@@ -35,7 +35,7 @@ function buildPartMap(region: Region, parts: Record<string, Part>) {
return map;
}
export function applyAlign(region: Region, parts: Record<string, Part>) {
export function applyAlign<TMeta>(region: Region, parts: Record<string, Part<TMeta>>) {
if (region.childIds.length === 0) return;
for (let axisIndex = 0; axisIndex < region.axes.length; axisIndex++) {
@@ -98,7 +98,7 @@ export function applyAlign(region: Region, parts: Record<string, Part>) {
region.partMap = buildPartMap(region, parts);
}
export function shuffle(region: Region, parts: Record<string, Part>, rng: RNG){
export function shuffle<TMeta>(region: Region, parts: Record<string, Part<TMeta>>, rng: RNG){
if (region.childIds.length <= 1) return;
const childIds = [...region.childIds];
@@ -117,9 +117,11 @@ export function shuffle(region: Region, parts: Record<string, Part>, rng: RNG){
region.partMap = buildPartMap(region, parts);
}
export function moveToRegion(part: Part, sourceRegion: Region, targetRegion: Region, position?: number[]) {
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
delete sourceRegion.partMap[part.position.join(',')];
export function moveToRegion<TMeta>(part: Part<TMeta>, sourceRegion: Region | null, targetRegion: Region, position?: number[]) {
if (sourceRegion && part.regionId === sourceRegion.id) {
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
delete sourceRegion.partMap[part.position.join(',')];
}
targetRegion.childIds.push(part.id);
if (position) {
@@ -130,11 +132,13 @@ export function moveToRegion(part: Part, sourceRegion: Region, targetRegion: Reg
part.regionId = targetRegion.id;
}
export function moveToRegionAll(parts: Part[], sourceRegion: Region, targetRegion: Region, positions?: number[][]) {
export function moveToRegionAll<TMeta>(parts: Part<TMeta>[], sourceRegion: Region | null, targetRegion: Region, positions?: number[][]) {
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
delete sourceRegion.partMap[part.position.join(',')];
if (sourceRegion && part.regionId === sourceRegion.id) {
sourceRegion.childIds = sourceRegion.childIds.filter(id => id !== part.id);
delete sourceRegion.partMap[part.position.join(',')];
}
targetRegion.childIds.push(part.id);
if (positions && positions[i]) {
@@ -146,7 +150,7 @@ export function moveToRegionAll(parts: Part[], sourceRegion: Region, targetRegio
}
}
export function removeFromRegion(part: Part, region: Region) {
export function removeFromRegion<TMeta>(part: Part<TMeta>, region: Region) {
region.childIds = region.childIds.filter(id => id !== part.id);
delete region.partMap[part.position.join(',')];
}
+4 -1
View File
@@ -8,7 +8,10 @@ export type { IGameContext } from './core/game';
export { createGameContext, createGameCommandRegistry } from './core/game';
export type { Part } from './core/part';
export { flip, flipTo, roll } from './core/part';
export { flip, flipTo, roll, findPartById, isCellOccupied, getPartAtPosition } from './core/part';
export type { PartTemplate, PartPool } from './core/part-factory';
export { createPart, createParts, createPartPool, mergePartPools } from './core/part-factory';
export type { Region, RegionAxis } from './core/region';
export { createRegion, applyAlign, shuffle, moveToRegion, moveToRegionAll, removeFromRegion } from './core/region';
+8 -15
View File
@@ -1,4 +1,4 @@
import {createGameCommandRegistry, Part, MutableSignal, createRegion} from '@/index';
import {createGameCommandRegistry, Part, MutableSignal, createRegion, createPart, isCellOccupied as isCellOccupiedUtil, getPartAtPosition} from '@/index';
const BOARD_SIZE = 6;
const MAX_PIECES_PER_PLAYER = 8;
@@ -8,7 +8,7 @@ export type PlayerType = 'white' | 'black';
export type PieceType = 'kitten' | 'cat';
export type WinnerType = PlayerType | 'draw' | null;
type BoopPart = Part & { player: PlayerType; pieceType: PieceType };
type BoopPart = Part<{ player: PlayerType; pieceType: PieceType }>;
type PieceSupply = { supply: number; placed: number };
@@ -157,15 +157,11 @@ export function getBoardRegion(host: MutableSignal<BoopState>) {
}
export function isCellOccupied(host: MutableSignal<BoopState>, row: number, col: number): boolean {
const board = getBoardRegion(host);
return board.partMap[`${row},${col}`] !== undefined;
return isCellOccupiedUtil(host.value.pieces, 'board', [row, col]);
}
export function getPartAt(host: MutableSignal<BoopState>, row: number, col: number): BoopPart | null {
const board = getBoardRegion(host);
const partId = board.partMap[`${row},${col}`];
if (!partId) return null;
return host.value.pieces.find(p => p.id === partId) || null;
return getPartAtPosition(host.value.pieces, 'board', [row, col]) || null;
}
export function placePiece(host: MutableSignal<BoopState>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
@@ -173,13 +169,10 @@ export function placePiece(host: MutableSignal<BoopState>, row: number, col: num
const playerData = getPlayer(host, player);
const count = playerData[pieceType].placed + 1;
const piece: BoopPart = {
id: `${player}-${pieceType}-${count}`,
regionId: 'board',
position: [row, col],
player,
pieceType,
};
const piece = createPart<{ player: PlayerType; pieceType: PieceType }>(
{ regionId: 'board', position: [row, col], player, pieceType },
`${player}-${pieceType}-${count}`
);
host.produce(s => {
s.pieces.push(piece);
board.childIds.push(piece.id);
+11 -14
View File
@@ -1,4 +1,4 @@
import {createGameCommandRegistry, Part, MutableSignal, createRegion, moveToRegion} from '@/index';
import {createGameCommandRegistry, Part, MutableSignal, createRegion, createPart, isCellOccupied as isCellOccupiedUtil} from '@/index';
const BOARD_SIZE = 3;
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
@@ -16,7 +16,7 @@ const WINNING_LINES: number[][][] = [
export type PlayerType = 'X' | 'O';
export type WinnerType = PlayerType | 'draw' | null;
type TicTacToePart = Part & { player: PlayerType };
type TicTacToePart = Part<{ player: PlayerType }>;
export function createInitialState() {
return {
@@ -24,7 +24,7 @@ export function createInitialState() {
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
]),
parts: {} as Record<string, TicTacToePart>,
parts: [] as TicTacToePart[],
currentPlayer: 'X' as PlayerType,
winner: null as WinnerType,
turn: 0,
@@ -91,8 +91,7 @@ function isValidMove(row: number, col: number): boolean {
}
export function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean {
const board = host.value.board;
return board.partMap[`${row},${col}`] !== undefined;
return isCellOccupiedUtil(host.value.parts, 'board', [row, col]);
}
export function hasWinningLine(positions: number[][]): boolean {
@@ -104,7 +103,7 @@ export function hasWinningLine(positions: number[][]): boolean {
}
export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType {
const parts = Object.values(host.value.parts);
const parts = host.value.parts;
const xPositions = parts.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
const oPositions = parts.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
@@ -118,15 +117,13 @@ export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType {
export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col: number, player: PlayerType) {
const board = host.value.board;
const moveNumber = Object.keys(host.value.parts).length + 1;
const piece: TicTacToePart = {
id: `piece-${player}-${moveNumber}`,
regionId: 'board',
position: [row, col],
player,
};
const moveNumber = host.value.parts.length + 1;
const piece = createPart<{ player: PlayerType }>(
{ regionId: 'board', position: [row, col], player },
`piece-${player}-${moveNumber}`
);
host.produce(state => {
state.parts[piece.id] = piece;
state.parts.push(piece);
board.childIds.push(piece.id);
board.partMap[`${row},${col}`] = piece.id;
});