diff --git a/src/core/game.ts b/src/core/game.ts index 35b1e31..5d07ef5 100644 --- a/src/core/game.ts +++ b/src/core/game.ts @@ -1,23 +1,22 @@ -import {createEntityCollection} from "../utils/entity"; +import {createEntityCollection, entity, Entity, EntityCollection} from "../utils/entity"; import {Part} from "./part"; import {Region} from "./region"; import { Command, CommandRegistry, type CommandRunner, CommandRunnerContext, - CommandRunnerContextExport, CommandSchema, + CommandRunnerContextExport, CommandSchema, createCommandRegistry, createCommandRunnerContext, parseCommandSchema, PromptEvent } from "../utils/command"; import {AsyncQueue} from "../utils/async-queue"; -import {signal, Signal} from "@preact/signals-core"; export interface IGameContext = {} > { - parts: ReturnType>; - regions: ReturnType>; + parts: EntityCollection; + regions: EntityCollection; + state: Entity; commands: CommandRunnerContextExport>; prompts: AsyncQueue; - state: Signal } export function createGameContext = {} >( @@ -27,14 +26,14 @@ export function createGameContext = {} >( const parts = createEntityCollection(); const regions = createEntityCollection(); const prompts = new AsyncQueue(); - const state: TState = typeof initialState === 'function' ? (initialState as (() => TState))() : (initialState ?? {} as TState); + const state = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState; const ctx = { parts, regions, prompts, commands: null!, - state: signal(state), + state: entity('gameState', state), } as IGameContext ctx.commands = createCommandRunnerContext(commandRegistry, ctx); @@ -56,6 +55,11 @@ export function createGameContextFromModule = {} >(): CommandRegistry> { + return createCommandRegistry>(); +} + + export function createGameCommand = {} , TResult = unknown>( schema: CommandSchema | string, run: (this: CommandRunnerContext>, command: Command) => Promise diff --git a/src/core/part.ts b/src/core/part.ts index f0b64db..5638577 100644 --- a/src/core/part.ts +++ b/src/core/part.ts @@ -1,33 +1,31 @@ -import {Entity, EntityAccessor} from "../utils/entity"; +import {Entity} from "../utils/entity"; import {Region} from "./region"; import {RNG} from "../utils/rng"; -export type Part = Entity & { - // cards have 2 sides, dices have multiple, tokens have 1 +export type Part = { + id: string; sides: number; - - // mostly rotations, if relevant alignments?: string[]; - - // current side side: number; - // current alignment alignment?: string; - - // current region - region: EntityAccessor; - // current position in region, expect to be the same length as region's axes + region: Entity; position: number[]; } -export function flip(part: Part) { - part.side = (part.side + 1) % part.sides; +export function flip(part: Entity) { + part.produce(draft => { + draft.side = (draft.side + 1) % draft.sides; + }); } -export function flipTo(part: Part, side: number) { - part.side = side; +export function flipTo(part: Entity, side: number) { + part.produce(draft => { + draft.side = side; + }); } -export function roll(part: Part, rng: RNG) { - part.side = rng.nextInt(part.sides); +export function roll(part: Entity, rng: RNG) { + part.produce(draft => { + draft.side = rng.nextInt(draft.sides); + }); } diff --git a/src/core/region.ts b/src/core/region.ts index 5205aac..43c1f22 100644 --- a/src/core/region.ts +++ b/src/core/region.ts @@ -1,13 +1,11 @@ -import {Entity, EntityAccessor} from "../utils/entity"; +import {Entity} from "../utils/entity"; import {Part} from "./part"; import {RNG} from "../utils/rng"; -export type Region = Entity & { - // aligning axes of the region, expect a part's position to have a matching number of elements +export type Region = { + id: string; axes: RegionAxis[]; - - // current children; expect no overlapped positions - children: EntityAccessor[]; + children: Entity[]; } export type RegionAxis = { @@ -17,47 +15,35 @@ export type RegionAxis = { align?: 'start' | 'end' | 'center'; } -/** - * for each axis, try to remove gaps in positions. - * - if min exists and align is start, and there are parts at (for example) min+2 and min+4, then move them to min and min+1 - * - if max exists and align is end, and there are parts at (for example) max-2 and max-4, then move them to max-1 and max-3 - * - for center, move parts to the center, possibly creating parts placed at 0.5 positions - * - sort children so that they're in ascending order on each axes. - * @param region - */ -export function applyAlign(region: Region){ +export function applyAlign(region: Entity) { + region.produce(applyAlignCore); +} + +function applyAlignCore(region: Region) { if (region.children.length === 0) return; - // Process each axis independently while preserving spatial relationships for (let axisIndex = 0; axisIndex < region.axes.length; axisIndex++) { const axis = region.axes[axisIndex]; if (!axis.align) continue; - // Collect all unique position values on this axis, preserving original order const positionValues = new Set(); - for (const accessor of region.children) { - positionValues.add(accessor.value.position[axisIndex] ?? 0); + for (const child of region.children) { + positionValues.add(child.value.position[axisIndex] ?? 0); } - // Sort position values const sortedPositions = Array.from(positionValues).sort((a, b) => a - b); - - // Create position mapping: old position -> new position const positionMap = new Map(); if (axis.align === 'start' && axis.min !== undefined) { - // Compact from min, preserving relative order sortedPositions.forEach((pos, index) => { positionMap.set(pos, axis.min! + index); }); } else if (axis.align === 'end' && axis.max !== undefined) { - // Compact towards max const count = sortedPositions.length; sortedPositions.forEach((pos, index) => { positionMap.set(pos, axis.max! - (count - 1 - index)); }); } else if (axis.align === 'center') { - // Center alignment const count = sortedPositions.length; const min = axis.min ?? 0; const max = axis.max ?? count - 1; @@ -70,14 +56,14 @@ export function applyAlign(region: Region){ }); } - // Apply position mapping to all parts - for (const accessor of region.children) { - const currentPos = accessor.value.position[axisIndex] ?? 0; - accessor.value.position[axisIndex] = positionMap.get(currentPos) ?? currentPos; + for (const child of region.children) { + child.produce(draft => { + const currentPos = draft.position[axisIndex] ?? 0; + draft.position[axisIndex] = positionMap.get(currentPos) ?? currentPos; + }); } } - // Sort children by all axes at the end region.children.sort((a, b) => { for (let i = 0; i < region.axes.length; i++) { const diff = (a.value.position[i] ?? 0) - (b.value.position[i] ?? 0); @@ -87,21 +73,23 @@ export function applyAlign(region: Region){ }); } -/** - * shuffle on each axis. for each axis, try to swap position. - * @param region - * @param rng - */ -export function shuffle(region: Region, rng: RNG){ +export function shuffle(region: Entity, rng: RNG) { + region.produce(region => shuffleCore(region, rng)); +} + +function shuffleCore(region: Region, rng: RNG){ if (region.children.length <= 1) return; - // Fisher-Yates 洗牌算法 const children = [...region.children]; for (let i = children.length - 1; i > 0; i--) { const j = rng.nextInt(i + 1); - // 交换两个 part 的整个 position 数组 - const temp = children[i].value.position; - children[i].value.position = children[j].value.position; - children[j].value.position = temp; + 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; + }); } -} +} \ No newline at end of file diff --git a/src/samples/tic-tac-toe.ts b/src/samples/tic-tac-toe.ts index 05423e5..54f13e8 100644 --- a/src/samples/tic-tac-toe.ts +++ b/src/samples/tic-tac-toe.ts @@ -1,35 +1,30 @@ -import { IGameContext, createGameCommand } from '../core/game'; -import { createCommandRegistry, registerCommand } from '../utils/command'; +import {createGameCommand, createGameCommandRegistry, IGameContext} from '../core/game'; +import { registerCommand } from '../utils/command'; import type { Part } from '../core/part'; -export type TicTacToeState = { - currentPlayer: 'X' | 'O'; - winner: 'X' | 'O' | 'draw' | null; - moveCount: number; -}; - -export type TicTacToeContext = IGameContext; - type TurnResult = { winner: 'X' | 'O' | 'draw' | null; }; -export function createInitialState(): TicTacToeState { +export function createInitialState() { return { - currentPlayer: 'X', - winner: null, + currentPlayer: 'X' as 'X' | 'O', + winner: null as 'X' | 'O' | 'draw' | null, moveCount: 0, }; } +export type TicTacToeState = ReturnType; -export function getBoardRegion(host: TicTacToeContext) { +export function getBoardRegion(host: IGameContext) { return host.regions.get('board'); } -export function isCellOccupied(host: TicTacToeContext, row: number, col: number): boolean { +export function isCellOccupied(host: IGameContext, row: number, col: number): boolean { const board = getBoardRegion(host); return board.value.children.some( - (child: { value: { position: number[] } }) => child.value.position[0] === row && child.value.position[1] === col + part => { + return part.value.position[0] === row && part.value.position[1] === col; + } ); } @@ -52,7 +47,7 @@ export function hasWinningLine(positions: number[][]): boolean { ); } -export function checkWinner(host: TicTacToeContext): 'X' | 'O' | 'draw' | null { +export function checkWinner(host: IGameContext): 'X' | 'O' | 'draw' | null { const parts = Object.values(host.parts.collection.value).map((s: { value: Part }) => s.value); const xPositions = parts.filter((_: Part, i: number) => i % 2 === 0).map((p: Part) => p.position); @@ -64,7 +59,7 @@ export function checkWinner(host: TicTacToeContext): 'X' | 'O' | 'draw' | null { return null; } -export function placePiece(host: TicTacToeContext, row: number, col: number, moveCount: number) { +export function placePiece(host: IGameContext, row: number, col: number, moveCount: number) { const board = getBoardRegion(host); const piece: Part = { id: `piece-${moveCount}`, @@ -74,7 +69,9 @@ export function placePiece(host: TicTacToeContext, row: number, col: number, mov position: [row, col], }; host.parts.add(piece); - board.value.children.push(host.parts.get(piece.id)); + board.produce(draft => { + draft.children.push(host.parts.get(piece.id)); + }); } const setup = createGameCommand( @@ -128,6 +125,6 @@ const turn = createGameCommand( } ); -export const registry = createCommandRegistry(); +export const registry = createGameCommandRegistry(); registerCommand(registry, setup); registerCommand(registry, turn); \ No newline at end of file diff --git a/src/utils/entity.ts b/src/utils/entity.ts index 7cff6bd..b50993e 100644 --- a/src/utils/entity.ts +++ b/src/utils/entity.ts @@ -1,80 +1,43 @@ -import {Signal, signal} from "@preact/signals-core"; +import {Signal, signal, SignalOptions} from "@preact/signals-core"; +import {create} from 'mutative'; -export type Entity = { - id: string; -}; - -export type EntityAccessor = { - id: string; - value: T; +export class Entity extends Signal { + public constructor(public readonly id: string, t?: T, options?: SignalOptions) { + super(t, options); + } + produce(fn: (draft: T) => void) { + this.value = create(this.value, fn); + } } -function createReactiveProxy(entitySignal: Signal): T { - return new Proxy({} as T, { - get(_target, prop) { - const current = entitySignal.value; - const value = current[prop as keyof T]; - if (typeof value === 'function') { - return value.bind(current); - } - return value; - }, - set(_target, prop, value) { - const current = entitySignal.value; - entitySignal.value = { ...current, [prop]: value }; - return true; - }, - ownKeys(_target) { - return Reflect.ownKeys(entitySignal.value); - }, - getOwnPropertyDescriptor(_target, prop) { - return Reflect.getOwnPropertyDescriptor(entitySignal.value, prop); - }, - }); +export function entity(id: string, t?: T, options?: SignalOptions) { + return new Entity(id, t, options); } -function createReactiveAccessor(id: string, entitySignal: Signal): EntityAccessor { - const proxy = createReactiveProxy(entitySignal); - return { - id, - get value() { - return proxy; - }, - set value(value: T) { - entitySignal.value = value; - } - } as EntityAccessor; +export type EntityCollection = { + collection: Signal>>; + remove(...ids: string[]): void; + add(...entities: (T & {id: string})[]): void; + get(id: string): Entity; } -export function createEntityCollection() { - const collection = signal({} as Record>); +export function createEntityCollection(): EntityCollection { + const collection = signal({} as Record>); const remove = (...ids: string[]) => { collection.value = Object.fromEntries( - Object.entries(collection.value).filter(([id]) => !ids.includes(id)), + Object.entries(collection.value).filter(([id]) => !ids.includes(id)), ); }; - - const add = (...entities: T[]) => { + + const add = (...entities: (T & {id: string})[]) => { collection.value = { ...collection.value, - ...Object.fromEntries(entities.map((entity) => [entity.id, signal(entity)])), + ...Object.fromEntries(entities.map((e) => [e.id, entity(e.id, e)])), }; }; - - const get = (id: string): EntityAccessor => { - const entitySignal = collection.value[id]; - if (!entitySignal) { - return { - id, - get value() { - return undefined as unknown as T; - }, - set value(_value: T) {} - } as EntityAccessor; - } - return createReactiveAccessor(id, entitySignal); - } - + + const get = (id: string) => collection.value[id]; + return { collection, remove,