mirror of
https://github.com/motajs/template.git
synced 2026-09-12 09:48:49 +08:00
feat(03-03): add async-safe replay commands
- Preserve replay collection context through async decorator settlement - Add stable command items with awaited movement and pathfinding - Return explicit failures for invalid item and equipment actions
This commit is contained in:
parent
d909e973e7
commit
e7714894a0
@ -15,14 +15,22 @@ interface IReplaySafetyDetailQueue {
|
||||
readonly collection: IReplaySafetyCollection;
|
||||
}
|
||||
|
||||
type ReplayDecorator = <
|
||||
Return,
|
||||
This,
|
||||
Func extends (this: This, ...args: any[]) => Return
|
||||
>(
|
||||
method: Func,
|
||||
context: ClassMethodDecoratorContext
|
||||
) => (this: This, ...args: any[]) => Return;
|
||||
interface IPromiseLike {
|
||||
then(
|
||||
onFulfilled?: (value: unknown) => unknown,
|
||||
onRejected?: (reason: unknown) => unknown
|
||||
): unknown;
|
||||
}
|
||||
|
||||
type ReplayMethod<This, Args extends unknown[], Return> = (
|
||||
this: This,
|
||||
...args: Args
|
||||
) => Return;
|
||||
|
||||
type ReplayDecorator = <This, Args extends unknown[], Return>(
|
||||
method: ReplayMethod<This, Args, Return>,
|
||||
context: ClassMethodDecoratorContext<This, ReplayMethod<This, Args, Return>>
|
||||
) => ReplayMethod<This, Args, Return>;
|
||||
|
||||
/** 本次收集使用的录像系统 */
|
||||
let replaySystem: IReplaySystem | null = null;
|
||||
@ -46,6 +54,21 @@ let detailCode = 0;
|
||||
/** 录像收集详细信息队列,保留 50 个以确保可以在控制台重复输出 */
|
||||
const detailQueue: IReplaySafetyDetailQueue[] = [];
|
||||
|
||||
function isPromiseLike(value: unknown): value is IPromiseLike {
|
||||
if (!value || (typeof value !== 'object' && typeof value !== 'function')) {
|
||||
return false;
|
||||
}
|
||||
return typeof (value as { then?: unknown }).then === 'function';
|
||||
}
|
||||
|
||||
function resetReplaySafetyCollection(): void {
|
||||
replaySystem = null;
|
||||
collecting = false;
|
||||
beforeLength = 0;
|
||||
shouldIgnore = false;
|
||||
currentCollection = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录像安全性检查收集,一般情况下不需要手动调用此接口
|
||||
* @param system 录像系统对象
|
||||
@ -58,6 +81,7 @@ export function beginReplaySafetyCollection(system: IReplaySystem): void {
|
||||
replaySystem = system;
|
||||
collecting = true;
|
||||
beforeLength = system.route.length;
|
||||
shouldIgnore = false;
|
||||
collection.messages.length = 0;
|
||||
currentCollection = collection;
|
||||
}
|
||||
@ -70,30 +94,34 @@ export function endReplaySafetyCollection(): void {
|
||||
logger.warn(160);
|
||||
return;
|
||||
}
|
||||
if (shouldIgnore) return;
|
||||
if (replaySystem.route.length > beforeLength) return;
|
||||
if (collection.messages.length === 0) return;
|
||||
try {
|
||||
if (shouldIgnore) return;
|
||||
if (replaySystem.route.length > beforeLength) return;
|
||||
if (collection.messages.length === 0) return;
|
||||
|
||||
// 需要把收集内容输出,这里只输出一层,完整输出需要在控制台手动调用
|
||||
const code = detailCode++;
|
||||
const col: IReplaySafetyCollection = {
|
||||
name: `detail#${code}`,
|
||||
messages: collection.messages.slice()
|
||||
};
|
||||
const command = `Mota.require('@user/data-common').logReplaySafetyDetail(${code});`;
|
||||
const simplified = [...collection.messages]
|
||||
.sort((a, b) => b.messages.length - a.messages.length)
|
||||
.map(v => v.name)
|
||||
.join('\n');
|
||||
logger.warn(161, command, simplified);
|
||||
// 需要把收集内容输出,这里只输出一层,完整输出需要在控制台手动调用
|
||||
const code = detailCode++;
|
||||
const col: IReplaySafetyCollection = {
|
||||
name: `detail#${code}`,
|
||||
messages: collection.messages.slice()
|
||||
};
|
||||
const command = `Mota.require('@user/data-common').logReplaySafetyDetail(${code});`;
|
||||
const simplified = [...collection.messages]
|
||||
.sort((a, b) => b.messages.length - a.messages.length)
|
||||
.map(v => v.name)
|
||||
.join('\n');
|
||||
logger.warn(161, command, simplified);
|
||||
|
||||
while (detailQueue.length > 50) {
|
||||
detailQueue.shift();
|
||||
while (detailQueue.length > 50) {
|
||||
detailQueue.shift();
|
||||
}
|
||||
detailQueue.push({
|
||||
code,
|
||||
collection: col
|
||||
});
|
||||
} finally {
|
||||
resetReplaySafetyCollection();
|
||||
}
|
||||
detailQueue.push({
|
||||
code,
|
||||
collection: col
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -133,16 +161,14 @@ export function logReplaySafetyDetail(code: number): void {
|
||||
* @param message 如果录像安全检查出现问题,那么会使用此作为信息输出
|
||||
*/
|
||||
export function shouldReplay(message: string): ReplayDecorator {
|
||||
return function <
|
||||
Return,
|
||||
This,
|
||||
Args extends any[],
|
||||
Func extends (this: This, ...args: Args) => Return
|
||||
>(
|
||||
return function <This, Args extends unknown[], Return>(
|
||||
this: This,
|
||||
method: Func,
|
||||
context: ClassMethodDecoratorContext
|
||||
): (this: This, ...args: Args) => Return {
|
||||
method: ReplayMethod<This, Args, Return>,
|
||||
context: ClassMethodDecoratorContext<
|
||||
This,
|
||||
ReplayMethod<This, Args, Return>
|
||||
>
|
||||
): ReplayMethod<This, Args, Return> {
|
||||
return function (this: This, ...args: Args): Return {
|
||||
const before = currentCollection;
|
||||
if (!before) return method.apply(this, args);
|
||||
@ -158,7 +184,18 @@ export function shouldReplay(message: string): ReplayDecorator {
|
||||
|
||||
currentCollection = newCollection;
|
||||
const result = method.apply(this, args);
|
||||
currentCollection = before;
|
||||
if (isPromiseLike(result)) {
|
||||
Promise.resolve(result).then(
|
||||
() => {
|
||||
currentCollection = before;
|
||||
},
|
||||
() => {
|
||||
currentCollection = before;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
currentCollection = before;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
};
|
||||
@ -169,12 +206,10 @@ export function shouldReplay(message: string): ReplayDecorator {
|
||||
* 不要使用此方法来规避控制台的录像错误警告,否则很可能导致录像出错。
|
||||
*/
|
||||
export function ignoreReplay(): ReplayDecorator {
|
||||
return function <
|
||||
Return,
|
||||
This,
|
||||
Args extends any[],
|
||||
Func extends (this: This, ...args: Args) => Return
|
||||
>(this: This, method: Func): (this: This, ...args: Args) => Return {
|
||||
return function <This, Args extends unknown[], Return>(
|
||||
this: This,
|
||||
method: ReplayMethod<This, Args, Return>
|
||||
): ReplayMethod<This, Args, Return> {
|
||||
return function (this: This, ...args: Args): Return {
|
||||
shouldIgnore = true;
|
||||
return method.apply(this, args);
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export * from './array';
|
||||
export * from './func';
|
||||
export * from './sandbox';
|
||||
export * from './system';
|
||||
export * from './types';
|
||||
|
||||
257
packages-user/data-state/src/replay/commands.test.ts
Normal file
257
packages-user/data-state/src/replay/commands.test.ts
Normal file
@ -0,0 +1,257 @@
|
||||
import { logger } from '@motajs/common';
|
||||
import {
|
||||
FaceDirection,
|
||||
IReplayStepHandler,
|
||||
IMoverController,
|
||||
beginReplaySafetyCollection,
|
||||
endReplaySafetyCollection,
|
||||
logReplaySafetyDetail,
|
||||
shouldReplay
|
||||
} from '@user/data-common';
|
||||
import { EquipStatus } from '@user/data-base';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createCoreState } from '../core';
|
||||
import { ReplaySystem } from '../../../data-common/src/replay/system';
|
||||
import {
|
||||
createReplayCommandItems,
|
||||
registerReplayCommandItems
|
||||
} from './commands';
|
||||
import {
|
||||
IReplayCommandItem,
|
||||
ReplayCommandCode,
|
||||
REPLAY_COMMAND_ORDER
|
||||
} from './types';
|
||||
|
||||
function step(
|
||||
command: number,
|
||||
params: IReplayStepHandler['params']
|
||||
): IReplayStepHandler {
|
||||
return { command, params, index: 0 };
|
||||
}
|
||||
|
||||
function controller(onEnd: Promise<void>): Readonly<IMoverController> {
|
||||
return {
|
||||
done: false,
|
||||
onEnd,
|
||||
push: () => {},
|
||||
insert: () => {},
|
||||
stop: () => onEnd
|
||||
};
|
||||
}
|
||||
|
||||
describe('replay commands', () => {
|
||||
// 验证默认 command item 只按稳定 enum 顺序提供八个实现
|
||||
it('creates the approved command order without module-owned numbering', () => {
|
||||
const state = createCoreState();
|
||||
const items = createReplayCommandItems(state);
|
||||
expect(items.map(item => item.code)).toEqual(REPLAY_COMMAND_ORDER);
|
||||
expect(items).toHaveLength(8);
|
||||
expect(items.map(item => item.command.execute)).toHaveLength(8);
|
||||
});
|
||||
|
||||
// 验证顶层注册器按稳定顺序注册并拒绝重复 code
|
||||
it('registers commands in order and rejects duplicate codes', () => {
|
||||
const state = createCoreState();
|
||||
const replay = new ReplaySystem();
|
||||
const items = createReplayCommandItems(state);
|
||||
registerReplayCommandItems(replay, items);
|
||||
expect(
|
||||
REPLAY_COMMAND_ORDER.every(code => replay.getCommand(code))
|
||||
).toBe(true);
|
||||
|
||||
const duplicate: IReplayCommandItem[] = items.map((item, index) =>
|
||||
index === 1 ? { ...item, code: ReplayCommandCode.Up } : item
|
||||
);
|
||||
expect(() =>
|
||||
registerReplayCommandItems(new ReplaySystem(), duplicate)
|
||||
).toThrow('Duplicate replay command code');
|
||||
});
|
||||
|
||||
// 验证四向移动在 controller.onEnd 兑现前不会完成 command
|
||||
it('awaits a directional movement controller', async () => {
|
||||
const state = createCoreState();
|
||||
const items = createReplayCommandItems(state);
|
||||
const deferred = Promise.withResolvers<void>();
|
||||
const mover = state.hero.location.mover;
|
||||
const move = vi.spyOn(mover, 'step');
|
||||
const start = vi
|
||||
.spyOn(mover, 'start')
|
||||
.mockReturnValue(controller(deferred.promise));
|
||||
const result = items[ReplayCommandCode.Right].command.execute(
|
||||
step(ReplayCommandCode.Right, [])
|
||||
);
|
||||
let settled = false;
|
||||
void result.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
expect(move).toHaveBeenCalledWith(FaceDirection.Right);
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
deferred.resolve();
|
||||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
// 验证自动寻路等待 PathfindingSystem 返回的完整 controller
|
||||
it('awaits the pathfinding controller and returns false when no path exists', async () => {
|
||||
const state = createCoreState();
|
||||
const items = createReplayCommandItems(state);
|
||||
const deferred = Promise.withResolvers<void>();
|
||||
const moveTo = vi.spyOn(state.pathfinding, 'moveTo');
|
||||
moveTo.mockReturnValue({
|
||||
controller: controller(deferred.promise),
|
||||
path: []
|
||||
});
|
||||
const result = items[
|
||||
ReplayCommandCode.AutoPathfindToPoint
|
||||
].command.execute(step(ReplayCommandCode.AutoPathfindToPoint, [2, 3]));
|
||||
await Promise.resolve();
|
||||
expect(moveTo).toHaveBeenCalledWith({ x: 2, y: 3 });
|
||||
deferred.resolve();
|
||||
await expect(result).resolves.toBe(true);
|
||||
moveTo.mockReturnValue(null);
|
||||
await expect(
|
||||
items[ReplayCommandCode.AutoPathfindToPoint].command.execute(
|
||||
step(ReplayCommandCode.AutoPathfindToPoint, [2, 3])
|
||||
)
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
// 验证道具和装备 command 使用既有状态 API 并把失败结果返回给 replay
|
||||
it('returns the existing item and equipment action results', async () => {
|
||||
const state = createCoreState();
|
||||
const items = createReplayCommandItems(state);
|
||||
const useItem = vi
|
||||
.spyOn(state.hero.items, 'useItem')
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(false);
|
||||
await expect(
|
||||
items[ReplayCommandCode.UseItem].command.execute(
|
||||
step(ReplayCommandCode.UseItem, [12])
|
||||
)
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
items[ReplayCommandCode.UseItem].command.execute(
|
||||
step(ReplayCommandCode.UseItem, ['unknown'])
|
||||
)
|
||||
).resolves.toBe(false);
|
||||
expect(useItem).toHaveBeenNthCalledWith(1, 12);
|
||||
|
||||
const canEquipTo = vi
|
||||
.spyOn(state.hero.equip, 'canEquipTo')
|
||||
.mockReturnValue(EquipStatus.CanEquip);
|
||||
const getEquipped = vi
|
||||
.spyOn(state.hero.equip, 'getEquipped')
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(99)
|
||||
.mockReturnValueOnce(99)
|
||||
.mockReturnValueOnce(undefined);
|
||||
const equip = vi
|
||||
.spyOn(state.hero.equip, 'equip')
|
||||
.mockImplementation(() => undefined);
|
||||
await expect(
|
||||
items[ReplayCommandCode.Equip].command.execute(
|
||||
step(ReplayCommandCode.Equip, [99, 0])
|
||||
)
|
||||
).resolves.toBe(true);
|
||||
expect(canEquipTo).toHaveBeenCalledWith(99, 0);
|
||||
expect(equip).toHaveBeenCalledWith(99, 0, undefined);
|
||||
await expect(
|
||||
items[ReplayCommandCode.Unequip].command.execute(
|
||||
step(ReplayCommandCode.Unequip, [0])
|
||||
)
|
||||
).resolves.toBe(true);
|
||||
expect(getEquipped).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 验证所有 command 对无效参数都以 false 结束而不推进状态
|
||||
it('returns false for invalid command parameters', async () => {
|
||||
const state = createCoreState();
|
||||
const items = createReplayCommandItems(state);
|
||||
const invalid = [
|
||||
items[ReplayCommandCode.Up].command.execute(step(0, [1])),
|
||||
items[ReplayCommandCode.AutoPathfindToPoint].command.execute(
|
||||
step(4, ['x', 1])
|
||||
),
|
||||
items[ReplayCommandCode.UseItem].command.execute(step(5, [])),
|
||||
items[ReplayCommandCode.Equip].command.execute(step(6, [1])),
|
||||
items[ReplayCommandCode.Unequip].command.execute(step(7, ['slot']))
|
||||
];
|
||||
await expect(Promise.all(invalid)).resolves.toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replay safety decorators', () => {
|
||||
// 验证异步 decorator 在 Promise 兑现前保留嵌套 collection 上下文
|
||||
it('keeps collection context through deferred nested actions', async () => {
|
||||
interface DecoratedFixture {
|
||||
inner(): void;
|
||||
outer(gate: Promise<void>): Promise<void>;
|
||||
}
|
||||
const inner = shouldReplay('inner')(
|
||||
function (this: DecoratedFixture): void {},
|
||||
{ name: 'inner' } as ClassMethodDecoratorContext<
|
||||
DecoratedFixture,
|
||||
(this: DecoratedFixture) => void
|
||||
>
|
||||
);
|
||||
const outer = shouldReplay('outer')(
|
||||
async function (
|
||||
this: DecoratedFixture,
|
||||
gate: Promise<void>
|
||||
): Promise<void> {
|
||||
await gate;
|
||||
this.inner();
|
||||
},
|
||||
{ name: 'outer' } as ClassMethodDecoratorContext<
|
||||
DecoratedFixture,
|
||||
(this: DecoratedFixture, gate: Promise<void>) => Promise<void>
|
||||
>
|
||||
);
|
||||
const fixture: DecoratedFixture = {
|
||||
inner,
|
||||
outer
|
||||
};
|
||||
|
||||
const replay = new ReplaySystem();
|
||||
const gate = Promise.withResolvers<void>();
|
||||
const warning = vi.spyOn(logger, 'warn');
|
||||
const group = vi.spyOn(console, 'group').mockImplementation(() => {});
|
||||
beginReplaySafetyCollection(replay);
|
||||
const action = fixture.outer(gate.promise);
|
||||
gate.resolve();
|
||||
await action;
|
||||
endReplaySafetyCollection();
|
||||
|
||||
const detail = warning.mock.calls.find(call => call[0] === 161);
|
||||
expect(detail).toBeDefined();
|
||||
const command = String(detail![1]);
|
||||
const match = command.match(/\((\d+)\)/);
|
||||
expect(match).not.toBeNull();
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
logReplaySafetyDetail(Number(match![1]));
|
||||
expect(group).toHaveBeenCalled();
|
||||
expect(output.mock.calls.flat().join(' ')).toContain('inner');
|
||||
warning.mockRestore();
|
||||
group.mockRestore();
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
// 验证同步 collection 在结束后可重新开始且不会残留旧上下文
|
||||
it('resets the collection lifecycle after completion', () => {
|
||||
const replay = new ReplaySystem();
|
||||
const warning = vi.spyOn(logger, 'warn');
|
||||
beginReplaySafetyCollection(replay);
|
||||
endReplaySafetyCollection();
|
||||
beginReplaySafetyCollection(replay);
|
||||
endReplaySafetyCollection();
|
||||
expect(warning).not.toHaveBeenCalledWith(159);
|
||||
warning.mockRestore();
|
||||
});
|
||||
});
|
||||
204
packages-user/data-state/src/replay/commands.ts
Normal file
204
packages-user/data-state/src/replay/commands.ts
Normal file
@ -0,0 +1,204 @@
|
||||
import {
|
||||
FaceDirection,
|
||||
IReplayStepHandler,
|
||||
IReplaySystem
|
||||
} from '@user/data-common';
|
||||
import { EquipStatus } from '@user/data-base';
|
||||
import {
|
||||
IReplayCommandItem,
|
||||
IReplayCommandRegistry,
|
||||
IReplayCommandState,
|
||||
ReplayCommandCode,
|
||||
REPLAY_COMMAND_ORDER
|
||||
} from './types';
|
||||
|
||||
function isNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function isItem(value: unknown): value is number | string {
|
||||
return isNumber(value) || typeof value === 'string';
|
||||
}
|
||||
|
||||
function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
|
||||
function isSlot(value: unknown): value is number | string {
|
||||
return isNumber(value) || typeof value === 'string';
|
||||
}
|
||||
|
||||
async function moveHero(
|
||||
state: IReplayCommandState,
|
||||
direction: FaceDirection,
|
||||
step: IReplayStepHandler
|
||||
): Promise<boolean> {
|
||||
if (step.params.length !== 0) return false;
|
||||
const mover = state.hero.location.mover;
|
||||
if (mover.moving) return false;
|
||||
mover.step(direction);
|
||||
const controller = mover.start();
|
||||
if (!controller) return false;
|
||||
await controller.onEnd;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function moveToPoint(
|
||||
state: IReplayCommandState,
|
||||
step: IReplayStepHandler
|
||||
): Promise<boolean> {
|
||||
if (step.params.length !== 2) return false;
|
||||
const x = step.params[0];
|
||||
const y = step.params[1];
|
||||
if (!isNumber(x) || !isNumber(y)) return false;
|
||||
const result = state.pathfinding.moveTo({ x, y });
|
||||
if (!result) return false;
|
||||
await result.controller.onEnd;
|
||||
return true;
|
||||
}
|
||||
|
||||
function useItem(
|
||||
state: IReplayCommandState,
|
||||
step: IReplayStepHandler
|
||||
): Promise<boolean> {
|
||||
if (step.params.length !== 1) return Promise.resolve(false);
|
||||
const item = step.params[0];
|
||||
if (!isItem(item)) return Promise.resolve(false);
|
||||
return Promise.resolve(state.hero.items.useItem(item));
|
||||
}
|
||||
|
||||
function resolveSlot(
|
||||
state: IReplayCommandState,
|
||||
slot: number | string
|
||||
): number | null {
|
||||
if (typeof slot === 'number') {
|
||||
return Number.isInteger(slot) && slot >= 0 ? slot : null;
|
||||
}
|
||||
const index = state.hero.equip.slots.indexOf(slot);
|
||||
return index < 0 ? null : index;
|
||||
}
|
||||
|
||||
function equip(
|
||||
state: IReplayCommandState,
|
||||
step: IReplayStepHandler
|
||||
): Promise<boolean> {
|
||||
if (step.params.length < 2 || step.params.length > 3) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const uid = step.params[0];
|
||||
const slot = step.params[1];
|
||||
const autoUnload = step.params[2];
|
||||
if (!isNumber(uid) || !Number.isInteger(uid) || !isSlot(slot)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
if (autoUnload !== undefined && !isBoolean(autoUnload)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const slotIndex = resolveSlot(state, slot);
|
||||
if (slotIndex === null) return Promise.resolve(false);
|
||||
if (state.hero.equip.getEquipped(slotIndex) === uid) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (state.hero.equip.canEquipTo(uid, slot) === EquipStatus.CannotEquip) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
state.hero.equip.equip(uid, slot, autoUnload);
|
||||
return Promise.resolve(state.hero.equip.getEquipped(slotIndex) === uid);
|
||||
}
|
||||
|
||||
function unequip(
|
||||
state: IReplayCommandState,
|
||||
step: IReplayStepHandler
|
||||
): Promise<boolean> {
|
||||
if (step.params.length !== 1) return Promise.resolve(false);
|
||||
const slot = step.params[0];
|
||||
if (!isNumber(slot) || !Number.isInteger(slot) || slot < 0) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
if (state.hero.equip.getEquipped(slot) === undefined) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
state.hero.equip.unequip(slot);
|
||||
return Promise.resolve(state.hero.equip.getEquipped(slot) === undefined);
|
||||
}
|
||||
|
||||
function createMoveCommand(
|
||||
state: IReplayCommandState,
|
||||
direction: FaceDirection
|
||||
) {
|
||||
return {
|
||||
execute: (step: IReplayStepHandler): Promise<boolean> =>
|
||||
moveHero(state, direction, step)
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建按稳定 enum 顺序排列的默认 replay command items */
|
||||
export function createReplayCommandItems(
|
||||
state: IReplayCommandState
|
||||
): ReadonlyArray<IReplayCommandItem> {
|
||||
return [
|
||||
{
|
||||
code: ReplayCommandCode.Up,
|
||||
command: createMoveCommand(state, FaceDirection.Up)
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.Right,
|
||||
command: createMoveCommand(state, FaceDirection.Right)
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.Down,
|
||||
command: createMoveCommand(state, FaceDirection.Down)
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.Left,
|
||||
command: createMoveCommand(state, FaceDirection.Left)
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.AutoPathfindToPoint,
|
||||
command: { execute: step => moveToPoint(state, step) }
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.UseItem,
|
||||
command: { execute: step => useItem(state, step) }
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.Equip,
|
||||
command: { execute: step => equip(state, step) }
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.Unequip,
|
||||
command: { execute: step => unequip(state, step) }
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/** 按 top-level stable code 注册 command,并在注册前拒绝重复项 */
|
||||
export function registerReplayCommandItems(
|
||||
replay: IReplaySystem | IReplayCommandRegistry,
|
||||
items: ReadonlyArray<IReplayCommandItem>
|
||||
): void {
|
||||
if (items.length !== REPLAY_COMMAND_ORDER.length) {
|
||||
throw new Error(
|
||||
'Replay command registry must contain exactly eight items'
|
||||
);
|
||||
}
|
||||
const codes = new Set<number>();
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
const item = items[index];
|
||||
if (codes.has(item.code)) {
|
||||
throw new Error(`Duplicate replay command code: ${item.code}`);
|
||||
}
|
||||
if (item.code !== REPLAY_COMMAND_ORDER[index]) {
|
||||
throw new Error(`Replay command order mismatch at index ${index}`);
|
||||
}
|
||||
if (replay.getCommand(item.code)) {
|
||||
throw new Error(
|
||||
`Replay command code already registered: ${item.code}`
|
||||
);
|
||||
}
|
||||
codes.add(item.code);
|
||||
}
|
||||
for (const item of items) {
|
||||
replay.registerCommand(item.code, item.command);
|
||||
}
|
||||
}
|
||||
2
packages-user/data-state/src/replay/index.ts
Normal file
2
packages-user/data-state/src/replay/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './commands';
|
||||
export * from './types';
|
||||
107
packages-user/data-state/src/replay/types.ts
Normal file
107
packages-user/data-state/src/replay/types.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { IReplayCommand, ReplayParamValue } from '@user/data-common';
|
||||
import { EquipStatus, IHeroLocation, IHeroMover } from '@user/data-base';
|
||||
import { IPathfindingSystem } from '@user/data-system';
|
||||
|
||||
/** 顶层拥有的稳定录像指令码,数值属于录像格式的一部分 */
|
||||
export const enum ReplayCommandCode {
|
||||
/** 向上移动一步 */
|
||||
Up = 0,
|
||||
/** 向右移动一步 */
|
||||
Right = 1,
|
||||
/** 向下移动一步 */
|
||||
Down = 2,
|
||||
/** 向左移动一步 */
|
||||
Left = 3,
|
||||
/** 自动寻路至目标点 */
|
||||
AutoPathfindToPoint = 4,
|
||||
/** 使用道具 */
|
||||
UseItem = 5,
|
||||
/** 装备物品 */
|
||||
Equip = 6,
|
||||
/** 卸下装备 */
|
||||
Unequip = 7
|
||||
}
|
||||
|
||||
/** replay command 使用的勇士道具访问边界 */
|
||||
export interface IReplayHeroItems {
|
||||
/** 使用指定道具 */
|
||||
useItem(item: number | string): boolean;
|
||||
}
|
||||
|
||||
/** replay command 使用的勇士装备访问边界 */
|
||||
export interface IReplayHeroEquipment {
|
||||
/** 判断装备是否可以放入目标槽位 */
|
||||
canEquipTo(uid: number, slot: number | string): EquipStatus;
|
||||
|
||||
/** 将装备放入目标槽位 */
|
||||
equip(
|
||||
uid: number,
|
||||
slot: number | string,
|
||||
autoUnload?: boolean
|
||||
): number | undefined;
|
||||
|
||||
/** 卸下指定槽位的装备 */
|
||||
unequip(slot: number): number | undefined;
|
||||
|
||||
/** 获取槽位上的装备 uid */
|
||||
getEquipped(slot: number): number | undefined;
|
||||
|
||||
/** 当前装备槽名称 */
|
||||
readonly slots: readonly string[];
|
||||
}
|
||||
|
||||
/** replay command 使用的勇士移动访问边界 */
|
||||
export interface IReplayHeroLocation {
|
||||
/** 勇士移动器 */
|
||||
readonly mover: IHeroMover<IHeroLocation>;
|
||||
}
|
||||
|
||||
/** replay command 使用的勇士访问边界 */
|
||||
export interface IReplayHero {
|
||||
/** 勇士位置 */
|
||||
readonly location: IReplayHeroLocation;
|
||||
/** 勇士道具 */
|
||||
readonly items: IReplayHeroItems;
|
||||
/** 勇士装备 */
|
||||
readonly equip: IReplayHeroEquipment;
|
||||
}
|
||||
|
||||
/** replay command 实现可访问的 CoreState 内部边界 */
|
||||
export interface IReplayCommandState {
|
||||
/** 勇士状态 */
|
||||
readonly hero: IReplayHero;
|
||||
/** 已绑定勇士移动器的寻路系统 */
|
||||
readonly pathfinding: IPathfindingSystem;
|
||||
}
|
||||
|
||||
/** 模块提供给顶层注册器的 command item */
|
||||
export interface IReplayCommandItem {
|
||||
/** 顶层稳定指令码 */
|
||||
readonly code: ReplayCommandCode;
|
||||
/** 指令实现 */
|
||||
readonly command: IReplayCommand;
|
||||
}
|
||||
|
||||
/** 供测试和顶层装配读取的稳定指令码顺序 */
|
||||
export const REPLAY_COMMAND_ORDER: readonly ReplayCommandCode[] = [
|
||||
ReplayCommandCode.Up,
|
||||
ReplayCommandCode.Right,
|
||||
ReplayCommandCode.Down,
|
||||
ReplayCommandCode.Left,
|
||||
ReplayCommandCode.AutoPathfindToPoint,
|
||||
ReplayCommandCode.UseItem,
|
||||
ReplayCommandCode.Equip,
|
||||
ReplayCommandCode.Unequip
|
||||
];
|
||||
|
||||
/** 顶层注册器使用的重放系统最小边界 */
|
||||
export interface IReplayCommandRegistry {
|
||||
/** 注册录像指令 */
|
||||
registerCommand(code: number, command: IReplayCommand): void;
|
||||
|
||||
/** 查询录像指令 */
|
||||
getCommand(code: number): IReplayCommand | null;
|
||||
}
|
||||
|
||||
/** command 参数读取结果 */
|
||||
export type ReplayCommandParam = ReplayParamValue;
|
||||
Loading…
Reference in New Issue
Block a user