refactor: use mutative for entity signals
This commit is contained in:
parent
705e2f9396
commit
e78caf481d
|
|
@ -1,23 +1,22 @@
|
||||||
import {createEntityCollection} from "../utils/entity";
|
import {createEntityCollection, entity, Entity, EntityCollection} from "../utils/entity";
|
||||||
import {Part} from "./part";
|
import {Part} from "./part";
|
||||||
import {Region} from "./region";
|
import {Region} from "./region";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandRegistry,
|
CommandRegistry,
|
||||||
type CommandRunner, CommandRunnerContext,
|
type CommandRunner, CommandRunnerContext,
|
||||||
CommandRunnerContextExport, CommandSchema,
|
CommandRunnerContextExport, CommandSchema, createCommandRegistry,
|
||||||
createCommandRunnerContext, parseCommandSchema,
|
createCommandRunnerContext, parseCommandSchema,
|
||||||
PromptEvent
|
PromptEvent
|
||||||
} from "../utils/command";
|
} from "../utils/command";
|
||||||
import {AsyncQueue} from "../utils/async-queue";
|
import {AsyncQueue} from "../utils/async-queue";
|
||||||
import {signal, Signal} from "@preact/signals-core";
|
|
||||||
|
|
||||||
export interface IGameContext<TState extends Record<string, unknown> = {} > {
|
export interface IGameContext<TState extends Record<string, unknown> = {} > {
|
||||||
parts: ReturnType<typeof createEntityCollection<Part>>;
|
parts: EntityCollection<Part>;
|
||||||
regions: ReturnType<typeof createEntityCollection<Region>>;
|
regions: EntityCollection<Region>;
|
||||||
|
state: Entity<TState>;
|
||||||
commands: CommandRunnerContextExport<IGameContext<TState>>;
|
commands: CommandRunnerContextExport<IGameContext<TState>>;
|
||||||
prompts: AsyncQueue<PromptEvent>;
|
prompts: AsyncQueue<PromptEvent>;
|
||||||
state: Signal<TState>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGameContext<TState extends Record<string, unknown> = {} >(
|
export function createGameContext<TState extends Record<string, unknown> = {} >(
|
||||||
|
|
@ -27,14 +26,14 @@ export function createGameContext<TState extends Record<string, unknown> = {} >(
|
||||||
const parts = createEntityCollection<Part>();
|
const parts = createEntityCollection<Part>();
|
||||||
const regions = createEntityCollection<Region>();
|
const regions = createEntityCollection<Region>();
|
||||||
const prompts = new AsyncQueue<PromptEvent>();
|
const prompts = new AsyncQueue<PromptEvent>();
|
||||||
const state: TState = typeof initialState === 'function' ? (initialState as (() => TState))() : (initialState ?? {} as TState);
|
const state = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
parts,
|
parts,
|
||||||
regions,
|
regions,
|
||||||
prompts,
|
prompts,
|
||||||
commands: null!,
|
commands: null!,
|
||||||
state: signal(state),
|
state: entity('gameState', state),
|
||||||
} as IGameContext<TState>
|
} as IGameContext<TState>
|
||||||
|
|
||||||
ctx.commands = createCommandRunnerContext(commandRegistry, ctx);
|
ctx.commands = createCommandRunnerContext(commandRegistry, ctx);
|
||||||
|
|
@ -56,6 +55,11 @@ export function createGameContextFromModule<TState extends Record<string, unknow
|
||||||
return createGameContext(module.registry, module.createInitialState);
|
return createGameContext(module.registry, module.createInitialState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >(): CommandRegistry<IGameContext<TState>> {
|
||||||
|
return createCommandRegistry<IGameContext<TState>>();
|
||||||
|
}
|
||||||
|
</TState>
|
||||||
|
|
||||||
export function createGameCommand<TState extends Record<string, unknown> = {} , TResult = unknown>(
|
export function createGameCommand<TState extends Record<string, unknown> = {} , TResult = unknown>(
|
||||||
schema: CommandSchema | string,
|
schema: CommandSchema | string,
|
||||||
run: (this: CommandRunnerContext<IGameContext<TState>>, command: Command) => Promise<TResult>
|
run: (this: CommandRunnerContext<IGameContext<TState>>, command: Command) => Promise<TResult>
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,31 @@
|
||||||
import {Entity, EntityAccessor} from "../utils/entity";
|
import {Entity} from "../utils/entity";
|
||||||
import {Region} from "./region";
|
import {Region} from "./region";
|
||||||
import {RNG} from "../utils/rng";
|
import {RNG} from "../utils/rng";
|
||||||
|
|
||||||
export type Part = Entity & {
|
export type Part = {
|
||||||
// cards have 2 sides, dices have multiple, tokens have 1
|
id: string;
|
||||||
sides: number;
|
sides: number;
|
||||||
|
|
||||||
// mostly rotations, if relevant
|
|
||||||
alignments?: string[];
|
alignments?: string[];
|
||||||
|
|
||||||
// current side
|
|
||||||
side: number;
|
side: number;
|
||||||
// current alignment
|
|
||||||
alignment?: string;
|
alignment?: string;
|
||||||
|
region: Entity<Region>;
|
||||||
// current region
|
|
||||||
region: EntityAccessor<Region>;
|
|
||||||
// current position in region, expect to be the same length as region's axes
|
|
||||||
position: number[];
|
position: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function flip(part: Part) {
|
export function flip(part: Entity<Part>) {
|
||||||
part.side = (part.side + 1) % part.sides;
|
part.produce(draft => {
|
||||||
|
draft.side = (draft.side + 1) % draft.sides;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function flipTo(part: Part, side: number) {
|
export function flipTo(part: Entity<Part>, side: number) {
|
||||||
part.side = side;
|
part.produce(draft => {
|
||||||
|
draft.side = side;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function roll(part: Part, rng: RNG) {
|
export function roll(part: Entity<Part>, rng: RNG) {
|
||||||
part.side = rng.nextInt(part.sides);
|
part.produce(draft => {
|
||||||
|
draft.side = rng.nextInt(draft.sides);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import {Entity, EntityAccessor} from "../utils/entity";
|
import {Entity} from "../utils/entity";
|
||||||
import {Part} from "./part";
|
import {Part} from "./part";
|
||||||
import {RNG} from "../utils/rng";
|
import {RNG} from "../utils/rng";
|
||||||
|
|
||||||
export type Region = Entity & {
|
export type Region = {
|
||||||
// aligning axes of the region, expect a part's position to have a matching number of elements
|
id: string;
|
||||||
axes: RegionAxis[];
|
axes: RegionAxis[];
|
||||||
|
children: Entity<Part>[];
|
||||||
// current children; expect no overlapped positions
|
|
||||||
children: EntityAccessor<Part>[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RegionAxis = {
|
export type RegionAxis = {
|
||||||
|
|
@ -17,47 +15,35 @@ export type RegionAxis = {
|
||||||
align?: 'start' | 'end' | 'center';
|
align?: 'start' | 'end' | 'center';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function applyAlign(region: Entity<Region>) {
|
||||||
* for each axis, try to remove gaps in positions.
|
region.produce(applyAlignCore);
|
||||||
* - 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
|
function applyAlignCore(region: Region) {
|
||||||
* - sort children so that they're in ascending order on each axes.
|
|
||||||
* @param region
|
|
||||||
*/
|
|
||||||
export function applyAlign(region: Region){
|
|
||||||
if (region.children.length === 0) return;
|
if (region.children.length === 0) return;
|
||||||
|
|
||||||
// Process each axis independently while preserving spatial relationships
|
|
||||||
for (let axisIndex = 0; axisIndex < region.axes.length; axisIndex++) {
|
for (let axisIndex = 0; axisIndex < region.axes.length; axisIndex++) {
|
||||||
const axis = region.axes[axisIndex];
|
const axis = region.axes[axisIndex];
|
||||||
if (!axis.align) continue;
|
if (!axis.align) continue;
|
||||||
|
|
||||||
// Collect all unique position values on this axis, preserving original order
|
|
||||||
const positionValues = new Set<number>();
|
const positionValues = new Set<number>();
|
||||||
for (const accessor of region.children) {
|
for (const child of region.children) {
|
||||||
positionValues.add(accessor.value.position[axisIndex] ?? 0);
|
positionValues.add(child.value.position[axisIndex] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort position values
|
|
||||||
const sortedPositions = Array.from(positionValues).sort((a, b) => a - b);
|
const sortedPositions = Array.from(positionValues).sort((a, b) => a - b);
|
||||||
|
|
||||||
// Create position mapping: old position -> new position
|
|
||||||
const positionMap = new Map<number, number>();
|
const positionMap = new Map<number, number>();
|
||||||
|
|
||||||
if (axis.align === 'start' && axis.min !== undefined) {
|
if (axis.align === 'start' && axis.min !== undefined) {
|
||||||
// Compact from min, preserving relative order
|
|
||||||
sortedPositions.forEach((pos, index) => {
|
sortedPositions.forEach((pos, index) => {
|
||||||
positionMap.set(pos, axis.min! + index);
|
positionMap.set(pos, axis.min! + index);
|
||||||
});
|
});
|
||||||
} else if (axis.align === 'end' && axis.max !== undefined) {
|
} else if (axis.align === 'end' && axis.max !== undefined) {
|
||||||
// Compact towards max
|
|
||||||
const count = sortedPositions.length;
|
const count = sortedPositions.length;
|
||||||
sortedPositions.forEach((pos, index) => {
|
sortedPositions.forEach((pos, index) => {
|
||||||
positionMap.set(pos, axis.max! - (count - 1 - index));
|
positionMap.set(pos, axis.max! - (count - 1 - index));
|
||||||
});
|
});
|
||||||
} else if (axis.align === 'center') {
|
} else if (axis.align === 'center') {
|
||||||
// Center alignment
|
|
||||||
const count = sortedPositions.length;
|
const count = sortedPositions.length;
|
||||||
const min = axis.min ?? 0;
|
const min = axis.min ?? 0;
|
||||||
const max = axis.max ?? count - 1;
|
const max = axis.max ?? count - 1;
|
||||||
|
|
@ -70,14 +56,14 @@ export function applyAlign(region: Region){
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply position mapping to all parts
|
for (const child of region.children) {
|
||||||
for (const accessor of region.children) {
|
child.produce(draft => {
|
||||||
const currentPos = accessor.value.position[axisIndex] ?? 0;
|
const currentPos = draft.position[axisIndex] ?? 0;
|
||||||
accessor.value.position[axisIndex] = positionMap.get(currentPos) ?? currentPos;
|
draft.position[axisIndex] = positionMap.get(currentPos) ?? currentPos;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort children by all axes at the end
|
|
||||||
region.children.sort((a, b) => {
|
region.children.sort((a, b) => {
|
||||||
for (let i = 0; i < region.axes.length; i++) {
|
for (let i = 0; i < region.axes.length; i++) {
|
||||||
const diff = (a.value.position[i] ?? 0) - (b.value.position[i] ?? 0);
|
const diff = (a.value.position[i] ?? 0) - (b.value.position[i] ?? 0);
|
||||||
|
|
@ -87,21 +73,23 @@ export function applyAlign(region: Region){
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function shuffle(region: Entity<Region>, rng: RNG) {
|
||||||
* shuffle on each axis. for each axis, try to swap position.
|
region.produce(region => shuffleCore(region, rng));
|
||||||
* @param region
|
}
|
||||||
* @param rng
|
|
||||||
*/
|
function shuffleCore(region: Region, rng: RNG){
|
||||||
export function shuffle(region: Region, rng: RNG){
|
|
||||||
if (region.children.length <= 1) return;
|
if (region.children.length <= 1) return;
|
||||||
|
|
||||||
// Fisher-Yates 洗牌算法
|
|
||||||
const children = [...region.children];
|
const children = [...region.children];
|
||||||
for (let i = children.length - 1; i > 0; i--) {
|
for (let i = children.length - 1; i > 0; i--) {
|
||||||
const j = rng.nextInt(i + 1);
|
const j = rng.nextInt(i + 1);
|
||||||
// 交换两个 part 的整个 position 数组
|
const posI = [...children[i].value.position];
|
||||||
const temp = children[i].value.position;
|
const posJ = [...children[j].value.position];
|
||||||
children[i].value.position = children[j].value.position;
|
children[i].produce(draft => {
|
||||||
children[j].value.position = temp;
|
draft.position = posJ;
|
||||||
|
});
|
||||||
|
children[j].produce(draft => {
|
||||||
|
draft.position = posI;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,35 +1,30 @@
|
||||||
import { IGameContext, createGameCommand } from '../core/game';
|
import {createGameCommand, createGameCommandRegistry, IGameContext} from '../core/game';
|
||||||
import { createCommandRegistry, registerCommand } from '../utils/command';
|
import { registerCommand } from '../utils/command';
|
||||||
import type { Part } from '../core/part';
|
import type { Part } from '../core/part';
|
||||||
|
|
||||||
export type TicTacToeState = {
|
|
||||||
currentPlayer: 'X' | 'O';
|
|
||||||
winner: 'X' | 'O' | 'draw' | null;
|
|
||||||
moveCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicTacToeContext = IGameContext<TicTacToeState>;
|
|
||||||
|
|
||||||
type TurnResult = {
|
type TurnResult = {
|
||||||
winner: 'X' | 'O' | 'draw' | null;
|
winner: 'X' | 'O' | 'draw' | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createInitialState(): TicTacToeState {
|
export function createInitialState() {
|
||||||
return {
|
return {
|
||||||
currentPlayer: 'X',
|
currentPlayer: 'X' as 'X' | 'O',
|
||||||
winner: null,
|
winner: null as 'X' | 'O' | 'draw' | null,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
||||||
|
|
||||||
export function getBoardRegion(host: TicTacToeContext) {
|
export function getBoardRegion(host: IGameContext<TicTacToeState>) {
|
||||||
return host.regions.get('board');
|
return host.regions.get('board');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCellOccupied(host: TicTacToeContext, row: number, col: number): boolean {
|
export function isCellOccupied(host: IGameContext<TicTacToeState>, row: number, col: number): boolean {
|
||||||
const board = getBoardRegion(host);
|
const board = getBoardRegion(host);
|
||||||
return board.value.children.some(
|
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<TicTacToeState>): 'X' | 'O' | 'draw' | null {
|
||||||
const parts = Object.values(host.parts.collection.value).map((s: { value: Part }) => s.value);
|
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);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function placePiece(host: TicTacToeContext, row: number, col: number, moveCount: number) {
|
export function placePiece(host: IGameContext<TicTacToeState>, row: number, col: number, moveCount: number) {
|
||||||
const board = getBoardRegion(host);
|
const board = getBoardRegion(host);
|
||||||
const piece: Part = {
|
const piece: Part = {
|
||||||
id: `piece-${moveCount}`,
|
id: `piece-${moveCount}`,
|
||||||
|
|
@ -74,7 +69,9 @@ export function placePiece(host: TicTacToeContext, row: number, col: number, mov
|
||||||
position: [row, col],
|
position: [row, col],
|
||||||
};
|
};
|
||||||
host.parts.add(piece);
|
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<TicTacToeState, { winner: 'X' | 'O' | 'draw' | null }>(
|
const setup = createGameCommand<TicTacToeState, { winner: 'X' | 'O' | 'draw' | null }>(
|
||||||
|
|
@ -128,6 +125,6 @@ const turn = createGameCommand<TicTacToeState, TurnResult>(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const registry = createCommandRegistry<TicTacToeContext>();
|
export const registry = createGameCommandRegistry<TicTacToeState>();
|
||||||
registerCommand(registry, setup);
|
registerCommand(registry, setup);
|
||||||
registerCommand(registry, turn);
|
registerCommand(registry, turn);
|
||||||
|
|
@ -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 = {
|
export class Entity<T> extends Signal<T> {
|
||||||
id: string;
|
public constructor(public readonly id: string, t?: T, options?: SignalOptions<T>) {
|
||||||
};
|
super(t, options);
|
||||||
|
}
|
||||||
export type EntityAccessor<T extends Entity> = {
|
produce(fn: (draft: T) => void) {
|
||||||
id: string;
|
this.value = create(this.value, fn);
|
||||||
value: T;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createReactiveProxy<T extends Entity>(entitySignal: Signal<T>): T {
|
export function entity<T = undefined>(id: string, t?: T, options?: SignalOptions<T>) {
|
||||||
return new Proxy({} as T, {
|
return new Entity<T>(id, t, options);
|
||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createReactiveAccessor<T extends Entity>(id: string, entitySignal: Signal<T>): EntityAccessor<T> {
|
export type EntityCollection<T> = {
|
||||||
const proxy = createReactiveProxy(entitySignal);
|
collection: Signal<Record<string, Entity<T>>>;
|
||||||
return {
|
remove(...ids: string[]): void;
|
||||||
id,
|
add(...entities: (T & {id: string})[]): void;
|
||||||
get value() {
|
get(id: string): Entity<T>;
|
||||||
return proxy;
|
|
||||||
},
|
|
||||||
set value(value: T) {
|
|
||||||
entitySignal.value = value;
|
|
||||||
}
|
|
||||||
} as EntityAccessor<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEntityCollection<T extends Entity>() {
|
export function createEntityCollection<T>(): EntityCollection<T> {
|
||||||
const collection = signal({} as Record<string, Signal<T>>);
|
const collection = signal({} as Record<string, Entity<T>>);
|
||||||
const remove = (...ids: string[]) => {
|
const remove = (...ids: string[]) => {
|
||||||
collection.value = Object.fromEntries(
|
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 = {
|
||||||
...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<T> => {
|
const get = (id: string) => collection.value[id];
|
||||||
const entitySignal = collection.value[id];
|
|
||||||
if (!entitySignal) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
get value() {
|
|
||||||
return undefined as unknown as T;
|
|
||||||
},
|
|
||||||
set value(_value: T) {}
|
|
||||||
} as EntityAccessor<T>;
|
|
||||||
}
|
|
||||||
return createReactiveAccessor(id, entitySignal);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
collection,
|
collection,
|
||||||
remove,
|
remove,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue