refactor: use mutative for entity signals

This commit is contained in:
2026-04-02 13:56:44 +08:00
parent 705e2f9396
commit e78caf481d
5 changed files with 100 additions and 150 deletions
+12 -8
View File
@@ -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<TState extends Record<string, unknown> = {} > {
parts: ReturnType<typeof createEntityCollection<Part>>;
regions: ReturnType<typeof createEntityCollection<Region>>;
parts: EntityCollection<Part>;
regions: EntityCollection<Region>;
state: Entity<TState>;
commands: CommandRunnerContextExport<IGameContext<TState>>;
prompts: AsyncQueue<PromptEvent>;
state: Signal<TState>
}
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 regions = createEntityCollection<Region>();
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 = {
parts,
regions,
prompts,
commands: null!,
state: signal(state),
state: entity('gameState', state),
} as IGameContext<TState>
ctx.commands = createCommandRunnerContext(commandRegistry, ctx);
@@ -56,6 +55,11 @@ export function createGameContextFromModule<TState extends Record<string, unknow
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>(
schema: CommandSchema | string,
run: (this: CommandRunnerContext<IGameContext<TState>>, command: Command) => Promise<TResult>
+16 -18
View File
@@ -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<Region>;
// current position in region, expect to be the same length as region's axes
region: Entity<Region>;
position: number[];
}
export function flip(part: Part) {
part.side = (part.side + 1) % part.sides;
export function flip(part: Entity<Part>) {
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<Part>, 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<Part>, rng: RNG) {
part.produce(draft => {
draft.side = rng.nextInt(draft.sides);
});
}
+30 -42
View File
@@ -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<Part>[];
children: Entity<Part>[];
}
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>) {
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<number>();
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<number, number>();
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<Region>, 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;
});
}
}
}