refactor: transition to non child entity format

This commit is contained in:
2026-04-03 12:46:50 +08:00
parent cf7cfcd86b
commit eb0ebf5411
8 changed files with 425 additions and 478 deletions
+10 -18
View File
@@ -1,5 +1,3 @@
import {Entity} from "@/utils/entity";
import {Region} from "./region";
import {RNG} from "@/utils/rng";
export type Part = {
@@ -10,27 +8,21 @@ export type Part = {
alignments?: string[];
alignment?: string;
region: Entity<Region>;
regionId: string;
position: number[];
}
export function flip(part: Entity<Part>) {
part.produce(draft => {
if(!draft.sides)return;
draft.side = ((draft.side||0) + 1) % draft.sides;
});
export function flip(part: Part) {
if(!part.sides) return;
part.side = ((part.side || 0) + 1) % part.sides;
}
export function flipTo(part: Entity<Part>, side: number) {
part.produce(draft => {
if(!draft.sides || side >= draft.sides)return;
draft.side = side;
});
export function flipTo(part: Part, side: number) {
if(!part.sides || side >= part.sides) return;
part.side = side;
}
export function roll(part: Entity<Part>, rng: RNG) {
part.produce(draft => {
if(!draft.sides)return;
draft.side = rng.nextInt(draft.sides);
});
export function roll(part: Part, rng: RNG) {
if(!part.sides) return;
part.side = rng.nextInt(part.sides);
}
+84 -92
View File
@@ -1,12 +1,11 @@
import {batch, computed, ReadonlySignal, SignalOptions} from "@preact/signals-core";
import {Entity} from "@/utils/entity";
import {Part} from "./part";
import {RNG} from "@/utils/rng";
export type Region = {
id: string;
axes: RegionAxis[];
children: Entity<Part>[];
childIds: string[];
partMap: Record<string, string>;
}
export type RegionAxis = {
@@ -16,38 +15,39 @@ export type RegionAxis = {
align?: 'start' | 'end' | 'center';
}
export class RegionEntity extends Entity<Region> {
public readonly partsMap: ReadonlySignal<Record<string, Entity<Part>>>;
export function createRegion(id: string, axes: RegionAxis[]): Region {
return {
id,
axes,
childIds: [],
partMap: {},
};
}
public constructor(id: string, t?: Region, options?: SignalOptions<Region>) {
super(id, t, options);
this.partsMap = computed(() => {
const result: Record<string, Entity<Part>> = {};
for (const child of this.value.children) {
const key = child.value.position.join(',');
result[key] = child;
}
return result;
});
function buildPartMap(region: Region, parts: Record<string, Part>) {
const map: Record<string, string> = {};
for (const childId of region.childIds) {
const part = parts[childId];
if (part) {
map[part.position.join(',')] = childId;
}
}
return map;
}
export function applyAlign(region: Entity<Region>) {
batch(() => {
region.produce(applyAlignCore);
});
}
function applyAlignCore(region: Region) {
if (region.children.length === 0) return;
export function applyAlign(region: Region, parts: Record<string, Part>) {
if (region.childIds.length === 0) return;
for (let axisIndex = 0; axisIndex < region.axes.length; axisIndex++) {
const axis = region.axes[axisIndex];
if (!axis.align) continue;
const positionValues = new Set<number>();
for (const child of region.children) {
positionValues.add(child.value.position[axisIndex] ?? 0);
for (const childId of region.childIds) {
const part = parts[childId];
if (part) {
positionValues.add(part.position[axisIndex] ?? 0);
}
}
const sortedPositions = Array.from(positionValues).sort((a, b) => a - b);
@@ -75,86 +75,78 @@ function applyAlignCore(region: Region) {
});
}
for (const child of region.children) {
child.produce(draft => {
const currentPos = draft.position[axisIndex] ?? 0;
draft.position[axisIndex] = positionMap.get(currentPos) ?? currentPos;
});
for (const childId of region.childIds) {
const part = parts[childId];
if (part) {
const currentPos = part.position[axisIndex] ?? 0;
part.position[axisIndex] = positionMap.get(currentPos) ?? currentPos;
}
}
}
region.children.sort((a, b) => {
region.childIds.sort((aId, bId) => {
const a = parts[aId];
const b = parts[bId];
if (!a || !b) return 0;
for (let i = 0; i < region.axes.length; i++) {
const diff = (a.value.position[i] ?? 0) - (b.value.position[i] ?? 0);
const diff = (a.position[i] ?? 0) - (b.position[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
});
region.partMap = buildPartMap(region, parts);
}
export function shuffle(region: Entity<Region>, rng: RNG) {
batch(() => {
region.produce(region => shuffleCore(region, rng));
});
}
export function shuffle(region: Region, parts: Record<string, Part>, rng: RNG){
if (region.childIds.length <= 1) return;
function shuffleCore(region: Region, rng: RNG){
if (region.children.length <= 1) return;
const children = [...region.children];
for (let i = children.length - 1; i > 0; i--) {
const childIds = [...region.childIds];
for (let i = childIds.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1);
const posI = [...children[i].value.position];
const posJ = [...children[j].value.position];
children[i].produce(draft => {
draft.position = posJ;
});
children[j].produce(draft => {
draft.position = posI;
});
const partI = parts[childIds[i]];
const partJ = parts[childIds[j]];
if (!partI || !partJ) continue;
const posI = [...partI.position];
const posJ = [...partJ.position];
partI.position = posJ;
partJ.position = posI;
}
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(',')];
targetRegion.childIds.push(part.id);
if (position) {
part.position = position;
}
targetRegion.partMap[part.position.join(',')] = part.id;
part.regionId = targetRegion.id;
}
export function moveToRegionAll(parts: Part[], sourceRegion: Region, 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(',')];
targetRegion.childIds.push(part.id);
if (positions && positions[i]) {
part.position = positions[i];
}
targetRegion.partMap[part.position.join(',')] = part.id;
part.regionId = targetRegion.id;
}
}
export function moveToRegion(part: Entity<Part>, targetRegion: Entity<Region>, position?: number[]) {
const sourceRegion = part.value.region;
batch(() => {
sourceRegion.produce(draft => {
draft.children = draft.children.filter(c => c.id !== part.id);
});
targetRegion.produce(draft => {
draft.children.push(part);
});
part.produce(draft => {
draft.region = targetRegion;
if (position) draft.position = position;
});
});
export function removeFromRegion(part: Part, region: Region) {
region.childIds = region.childIds.filter(id => id !== part.id);
delete region.partMap[part.position.join(',')];
}
export function moveToRegionAll(parts: Entity<Part>[], targetRegion: Entity<Region>, positions?: number[][]) {
batch(() => {
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const sourceRegion = part.value.region;
sourceRegion.produce(draft => {
draft.children = draft.children.filter(c => c.id !== part.id);
});
targetRegion.produce(draft => {
draft.children.push(part);
});
part.produce(draft => {
draft.region = targetRegion;
if (positions && positions[i]) draft.position = positions[i];
});
}
});
}
export function removeFromRegion(part: Entity<Part>) {
const region = part.value.region;
batch(() => {
region.produce(draft => {
draft.children = draft.children.filter(c => c.id !== part.id);
});
});
}
+1 -1
View File
@@ -11,7 +11,7 @@ export type { Part } from './core/part';
export { flip, flipTo, roll } from './core/part';
export type { Region, RegionAxis } from './core/region';
export { applyAlign, shuffle, RegionEntity, moveToRegion, moveToRegionAll, removeFromRegion } from './core/region';
export { createRegion, applyAlign, shuffle, moveToRegion, moveToRegionAll, removeFromRegion } from './core/region';
// Utils
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
+81 -111
View File
@@ -1,4 +1,4 @@
import {createGameCommandRegistry, Part, Entity, entity, RegionEntity} from '@/index';
import {createGameCommandRegistry, Part, Entity, createRegion} from '@/index';
const BOARD_SIZE = 6;
const MAX_PIECES_PER_PLAYER = 8;
@@ -18,26 +18,15 @@ type Player = {
cat: PieceSupply;
};
type PlayerEntity = Entity<Player>;
function createPlayer(id: PlayerType): PlayerEntity {
return entity<Player>(id, {
id,
kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 },
cat: { supply: 0, placed: 0 },
});
}
type PlayerData = Record<PlayerType, Player>;
export function createInitialState() {
return {
board: new RegionEntity('board', {
id: 'board',
axes: [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
],
children: [],
}),
board: createRegion('board', [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
]),
pieces: [] as BoopPart[],
currentPlayer: 'white' as PlayerType,
winner: null as WinnerType,
players: {
@@ -46,26 +35,30 @@ export function createInitialState() {
},
};
}
function createPlayer(id: PlayerType): Player {
return {
id,
kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 },
cat: { supply: 0, placed: 0 },
};
}
export type BoopState = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<BoopState>();
export const registry = registration.registry;
// Player Entity helper functions
export function getPlayer(host: Entity<BoopState>, player: PlayerType): PlayerEntity {
export function getPlayer(host: Entity<BoopState>, player: PlayerType): Player {
return host.value.players[player];
}
export function decrementSupply(player: PlayerEntity, pieceType: PieceType) {
player.produce(p => {
p[pieceType].supply--;
p[pieceType].placed++;
});
export function decrementSupply(player: Player, pieceType: PieceType) {
player[pieceType].supply--;
player[pieceType].placed++;
}
export function incrementSupply(player: PlayerEntity, pieceType: PieceType, count?: number) {
player.produce(p => {
p[pieceType].supply += count ?? 1;
});
export function incrementSupply(player: Player, pieceType: PieceType, count?: number) {
player[pieceType].supply += count ?? 1;
}
registration.add('setup', async function() {
@@ -106,8 +99,8 @@ registration.add('turn <player>', async function(cmd) {
return `Cell (${row}, ${col}) is already occupied.`;
}
const playerEntity = getPlayer(this.context, player);
const supply = playerEntity.value[pieceType].supply;
const playerData = getPlayer(this.context, player);
const supply = playerData[pieceType].supply;
if (supply <= 0) {
return `No ${pieceType}s left in ${player}'s supply.`;
}
@@ -126,15 +119,9 @@ registration.add('turn <player>', async function(cmd) {
}
if (countPiecesOnBoard(this.context, turnPlayer) >= MAX_PIECES_PER_PLAYER) {
const board = getBoardRegion(this.context);
const partsMap = board.partsMap.value;
const availableKittens: Entity<BoopPart>[] = [];
for (const key in partsMap) {
const part = partsMap[key] as Entity<BoopPart>;
if (part.value.player === turnPlayer && part.value.pieceType === 'kitten') {
availableKittens.push(part);
}
}
const availableKittens = this.context.value.pieces.filter(
p => p.player === turnPlayer && p.pieceType === 'kitten'
);
if (availableKittens.length > 0) {
const graduateCmd = await this.prompt(
@@ -142,16 +129,16 @@ registration.add('turn <player>', async function(cmd) {
(command) => {
const [row, col] = command.params as [number, number];
const posKey = `${row},${col}`;
const part = availableKittens.find(p => `${p.value.position[0]},${p.value.position[1]}` === posKey);
const part = availableKittens.find(p => `${p.position[0]},${p.position[1]}` === posKey);
if (!part) return `No kitten at (${row}, ${col}).`;
return null;
}
);
const [row, col] = graduateCmd.params as [number, number];
const part = availableKittens.find(p => p.value.position[0] === row && p.value.position[1] === col)!;
const part = availableKittens.find(p => p.position[0] === row && p.position[1] === col)!;
removePieceFromBoard(this.context, part);
const playerEntity = getPlayer(this.context, turnPlayer);
incrementSupply(playerEntity, 'cat', 1);
const playerData = getPlayer(this.context, turnPlayer);
incrementSupply(playerData, 'cat', 1);
}
}
@@ -171,44 +158,44 @@ export function getBoardRegion(host: Entity<BoopState>) {
export function isCellOccupied(host: Entity<BoopState>, row: number, col: number): boolean {
const board = getBoardRegion(host);
return board.partsMap.value[`${row},${col}`] !== undefined;
return board.partMap[`${row},${col}`] !== undefined;
}
export function getPartAt(host: Entity<BoopState>, row: number, col: number): Entity<BoopPart> | null {
export function getPartAt(host: Entity<BoopState>, row: number, col: number): BoopPart | null {
const board = getBoardRegion(host);
return (board.partsMap.value[`${row},${col}`] as Entity<BoopPart> | undefined) || null;
const partId = board.partMap[`${row},${col}`];
if (!partId) return null;
return host.value.pieces.find(p => p.id === partId) || null;
}
export function placePiece(host: Entity<BoopState>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
const board = getBoardRegion(host);
const playerEntity = getPlayer(host, player);
const count = playerEntity.value[pieceType].placed + 1;
const playerData = getPlayer(host, player);
const count = playerData[pieceType].placed + 1;
const piece: BoopPart = {
id: `${player}-${pieceType}-${count}`,
region: board,
regionId: 'board',
position: [row, col],
player,
pieceType,
};
host.produce(s => {
const e = entity(piece.id, piece);
board.produce(draft => {
draft.children.push(e);
});
s.pieces.push(piece);
board.childIds.push(piece.id);
board.partMap[`${row},${col}`] = piece.id;
});
decrementSupply(playerEntity, pieceType);
decrementSupply(playerData, pieceType);
}
export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol: number, placedType: PieceType) {
const board = getBoardRegion(host);
const partsMap = board.partsMap.value;
const pieces = host.value.pieces;
const piecesToBoop: { part: Entity<BoopPart>; dr: number; dc: number }[] = [];
const piecesToBoop: { part: BoopPart; dr: number; dc: number }[] = [];
for (const key in partsMap) {
const part = partsMap[key] as Entity<BoopPart>;
const [r, c] = part.value.position;
for (const part of pieces) {
const [r, c] = part.position;
if (r === placedRow && c === placedCol) continue;
const dr = Math.sign(r - placedRow);
@@ -216,7 +203,7 @@ export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol
if (Math.abs(r - placedRow) <= 1 && Math.abs(c - placedCol) <= 1) {
const booperIsKitten = placedType === 'kitten';
const targetIsCat = part.value.pieceType === 'cat';
const targetIsCat = part.pieceType === 'cat';
if (booperIsKitten && targetIsCat) continue;
@@ -225,36 +212,38 @@ export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol
}
for (const { part, dr, dc } of piecesToBoop) {
const [r, c] = part.value.position;
const [r, c] = part.position;
const newRow = r + dr;
const newCol = c + dc;
if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) {
const pt = part.value.pieceType;
const pl = part.value.player;
const playerEntity = getPlayer(host, pl);
const pt = part.pieceType;
const pl = part.player;
const playerData = getPlayer(host, pl);
removePieceFromBoard(host, part);
incrementSupply(playerEntity, pt);
incrementSupply(playerData, pt);
continue;
}
if (isCellOccupied(host, newRow, newCol)) continue;
part.produce(p => {
p.position = [newRow, newCol];
});
part.position = [newRow, newCol];
board.partMap = Object.fromEntries(
board.childIds.map(id => {
const p = pieces.find(x => x.id === id)!;
return [p.position.join(','), id];
})
);
}
}
export function removePieceFromBoard(host: Entity<BoopState>, part: Entity<BoopPart>) {
export function removePieceFromBoard(host: Entity<BoopState>, part: BoopPart) {
const board = getBoardRegion(host);
const playerEntity = getPlayer(host, part.value.player);
board.produce(draft => {
draft.children = draft.children.filter(p => p.id !== part.id);
});
playerEntity.produce(p => {
p[part.value.pieceType].placed--;
});
const playerData = getPlayer(host, part.player);
board.childIds = board.childIds.filter(id => id !== part.id);
delete board.partMap[part.position.join(',')];
host.value.pieces = host.value.pieces.filter(p => p.id !== part.id);
playerData[part.pieceType].placed--;
}
const DIRECTIONS: [number, number][] = [
@@ -309,14 +298,12 @@ export function hasWinningLine(positions: number[][]): boolean {
}
export function checkGraduation(host: Entity<BoopState>, player: PlayerType): number[][][] {
const board = getBoardRegion(host);
const partsMap = board.partsMap.value;
const pieces = host.value.pieces;
const posSet = new Set<string>();
for (const key in partsMap) {
const part = partsMap[key] as Entity<BoopPart>;
if (part.value.player === player && part.value.pieceType === 'kitten') {
posSet.add(`${part.value.position[0]},${part.value.position[1]}`);
for (const part of pieces) {
if (part.player === player && part.pieceType === 'kitten') {
posSet.add(`${part.position[0]},${part.position[1]}`);
}
}
@@ -338,48 +325,31 @@ export function processGraduation(host: Entity<BoopState>, player: PlayerType, l
}
const board = getBoardRegion(host);
const partsMap = board.partsMap.value;
const partsToRemove: Entity<BoopPart>[] = [];
for (const key in partsMap) {
const part = partsMap[key] as Entity<BoopPart>;
if (part.value.player === player && part.value.pieceType === 'kitten' && allPositions.has(`${part.value.position[0]},${part.value.position[1]}`)) {
partsToRemove.push(part);
}
}
const partsToRemove = host.value.pieces.filter(
p => p.player === player && p.pieceType === 'kitten' && allPositions.has(`${p.position[0]},${p.position[1]}`)
);
for (const part of partsToRemove) {
removePieceFromBoard(host, part);
}
const count = partsToRemove.length;
const playerEntity = getPlayer(host, player);
incrementSupply(playerEntity, 'cat', count);
const playerData = getPlayer(host, player);
incrementSupply(playerData, 'cat', count);
}
export function countPiecesOnBoard(host: Entity<BoopState>, player: PlayerType): number {
const board = getBoardRegion(host);
const partsMap = board.partsMap.value;
let count = 0;
for (const key in partsMap) {
const part = partsMap[key] as Entity<BoopPart>;
if (part.value.player === player) count++;
}
return count;
const pieces = host.value.pieces;
return pieces.filter(p => p.player === player).length;
}
export function checkWinner(host: Entity<BoopState>): WinnerType {
const board = getBoardRegion(host);
const partsMap = board.partsMap.value;
const pieces = host.value.pieces;
for (const player of ['white', 'black'] as PlayerType[]) {
const positions: number[][] = [];
for (const key in partsMap) {
const part = partsMap[key] as Entity<BoopPart>;
if (part.value.player === player && part.value.pieceType === 'cat') {
positions.push(part.value.position);
}
}
const positions = pieces
.filter(p => p.player === player && p.pieceType === 'cat')
.map(p => p.position);
if (hasWinningLine(positions)) return player;
}
+12 -18
View File
@@ -1,4 +1,4 @@
import {createGameCommandRegistry, Part, Entity, entity, RegionEntity} from '@/index';
import {createGameCommandRegistry, Part, Entity, createRegion, moveToRegion} from '@/index';
const BOARD_SIZE = 3;
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
@@ -20,15 +20,11 @@ type TicTacToePart = Part & { player: PlayerType };
export function createInitialState() {
return {
board: new RegionEntity('board', {
id: 'board',
axes: [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
],
children: [],
}),
parts: [] as Entity<TicTacToePart>[],
board: createRegion('board', [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
]),
parts: [] as TicTacToePart[],
currentPlayer: 'X' as PlayerType,
winner: null as WinnerType,
turn: 0,
@@ -96,7 +92,7 @@ function isValidMove(row: number, col: number): boolean {
export function isCellOccupied(host: Entity<TicTacToeState>, row: number, col: number): boolean {
const board = host.value.board;
return board.partsMap.value[`${row},${col}`] !== undefined;
return board.partMap[`${row},${col}`] !== undefined;
}
export function hasWinningLine(positions: number[][]): boolean {
@@ -108,7 +104,7 @@ export function hasWinningLine(positions: number[][]): boolean {
}
export function checkWinner(host: Entity<TicTacToeState>): WinnerType {
const parts = host.value.parts.map((e: Entity<TicTacToePart>) => e.value);
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);
@@ -125,15 +121,13 @@ export function placePiece(host: Entity<TicTacToeState>, row: number, col: numbe
const moveNumber = host.value.parts.length + 1;
const piece: TicTacToePart = {
id: `piece-${player}-${moveNumber}`,
region: board,
regionId: 'board',
position: [row, col],
player,
};
host.produce(state => {
const e = entity(piece.id, piece)
state.parts.push(e);
board.produce(draft => {
draft.children.push(e);
});
state.parts.push(piece);
board.childIds.push(piece.id);
board.partMap[`${row},${col}`] = piece.id;
});
}