mirror of
https://github.com/motajs/template.git
synced 2026-10-21 14:50:26 +08:00
refactor: replay system
This commit is contained in:
parent
9247a8886c
commit
2198734f81
@ -27,7 +27,7 @@ function createCommand(
|
||||
execute: (step: IReplayStepHandler) => Promise<boolean>,
|
||||
notExecuted?: () => Promise<boolean>
|
||||
): IReplayCommand {
|
||||
return notExecuted ? { execute, notExecuted } : { execute };
|
||||
return notExecuted ? { execute, finalize: notExecuted } : { execute };
|
||||
}
|
||||
|
||||
// 用录像系统构造一个可手动驱动的录像沙箱
|
||||
|
||||
@ -6,11 +6,17 @@ import {
|
||||
} from '@motajs/common';
|
||||
import {
|
||||
IReplayArray,
|
||||
IReplayCommand,
|
||||
IReplayPassiveHandler,
|
||||
IReplayReadStream,
|
||||
IReplaySandbox,
|
||||
IReplaySandboxHooks,
|
||||
IReplaySystem
|
||||
IReplayStepHandler,
|
||||
IReplaySystem,
|
||||
ReplayCommandResult,
|
||||
ReplayCommandType
|
||||
} from './types';
|
||||
import { isNil } from 'lodash-es';
|
||||
|
||||
export class ReplaySandbox
|
||||
extends Hookable<IReplaySandboxHooks>
|
||||
@ -25,9 +31,15 @@ export class ReplaySandbox
|
||||
private needPause: boolean = false;
|
||||
/** 当前录像是否播放完毕 */
|
||||
private ending: boolean = false;
|
||||
|
||||
/** 暂停 `Promise` 的 `resolve` 函数 */
|
||||
private pauseResolve: () => void = () => {};
|
||||
|
||||
/** 被动录像步兑现函数 */
|
||||
private passiveResolve: (status: ReplayCommandResult) => void = () => {};
|
||||
/** 下一步被动录像步的 `StepHandler`,注意不是 `PassiveHandler` */
|
||||
private passiveHandler: IReplayStepHandler | null = null;
|
||||
|
||||
/** 录像的流式读取器 */
|
||||
private reader: Readonly<IReplayReadStream>;
|
||||
|
||||
@ -49,6 +61,20 @@ export class ReplaySandbox
|
||||
return new HookController(this, hook);
|
||||
}
|
||||
|
||||
getPassive(): IReplayPassiveHandler | null {
|
||||
if (!this.passiveHandler) {
|
||||
logger.error(73);
|
||||
return null;
|
||||
}
|
||||
const handler: IReplayPassiveHandler = {
|
||||
step: this.passiveHandler,
|
||||
next: status => {
|
||||
this.passiveResolve(status);
|
||||
}
|
||||
};
|
||||
return handler;
|
||||
}
|
||||
|
||||
setSpeed(speed: number): void {
|
||||
this.speed = speed;
|
||||
this.forEachHook(hook => hook.onSpeedSet?.(speed));
|
||||
@ -121,19 +147,63 @@ export class ReplaySandbox
|
||||
/**
|
||||
* 触发上一步指令的连续步骤后处理
|
||||
*/
|
||||
private async finalizeLast(): Promise<boolean> {
|
||||
if (this.last === -1) return true;
|
||||
private async finalizeLast(): Promise<ReplayCommandResult> {
|
||||
if (this.last === -1) return ReplayCommandResult.Success;
|
||||
const last = this.system.getCommand(this.last);
|
||||
if (!last) {
|
||||
logger.warn(157, this.last.toString());
|
||||
return false;
|
||||
return ReplayCommandResult.Failed;
|
||||
}
|
||||
const success = (await last.notExecuted?.()) ?? true;
|
||||
if (!success) {
|
||||
logger.warn(175, this.last.toString());
|
||||
return false;
|
||||
return last.finalize?.() ?? ReplayCommandResult.Success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行下一个录像步
|
||||
* @param command 录像步对象
|
||||
* @param next 录像步参数
|
||||
*/
|
||||
private async executeCommand(
|
||||
command: IReplayCommand,
|
||||
next: IReplayStepHandler
|
||||
): Promise<ReplayCommandResult> {
|
||||
if (command.type === ReplayCommandType.Passive) {
|
||||
// 被动录像步
|
||||
const { promise, resolve } =
|
||||
Promise.withResolvers<ReplayCommandResult>();
|
||||
this.passiveResolve = resolve;
|
||||
const status = await promise;
|
||||
if (isNil(status)) {
|
||||
logger.error(71);
|
||||
return ReplayCommandResult.Failed;
|
||||
} else {
|
||||
return status;
|
||||
}
|
||||
} else {
|
||||
// 主动录像步
|
||||
const status = command.execute(next);
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查录像执行状态,进行适当的控制台输出
|
||||
* @param status 录像执行状态
|
||||
* @param method 录像步执行方法,可填 `active` `passive` `finalize`
|
||||
*/
|
||||
private checkReplayStatus(
|
||||
status: ReplayCommandResult,
|
||||
method: string
|
||||
): boolean {
|
||||
if (status === ReplayCommandResult.Success) return true;
|
||||
else if (status === ReplayCommandResult.Failed) {
|
||||
logger.error(72, method);
|
||||
return false;
|
||||
} else {
|
||||
logger.log(
|
||||
`Ignored replay error with a Ignored status returned by ${method} command.`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async step(): Promise<boolean> {
|
||||
@ -143,44 +213,40 @@ export class ReplaySandbox
|
||||
return false;
|
||||
}
|
||||
const next = this.reader.read();
|
||||
if (!next) {
|
||||
// notExecuted
|
||||
const ne = await this.finalizeLast();
|
||||
if (ne) {
|
||||
this.last = -1;
|
||||
this.ending = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// notExecuted
|
||||
if (next.command !== this.last) {
|
||||
// finalize 执行
|
||||
if (!next || next.command !== this.last) {
|
||||
const ne = await this.finalizeLast();
|
||||
if (!ne) {
|
||||
const success = this.checkReplayStatus(ne, 'finalize');
|
||||
if (success) {
|
||||
if (!next) {
|
||||
this.last = -1;
|
||||
this.ending = true;
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.last = next.command;
|
||||
|
||||
// execute
|
||||
// 获取指令本身
|
||||
const command = this.system.getCommand(next.command);
|
||||
if (!command) {
|
||||
logger.warn(157, next.command.toString());
|
||||
return false;
|
||||
}
|
||||
const success = await command.execute(next);
|
||||
if (!success) {
|
||||
logger.warn(
|
||||
158,
|
||||
next.command.toString(),
|
||||
JSON.stringify(next.params)
|
||||
);
|
||||
|
||||
const status = await this.executeCommand(command, next);
|
||||
const success = this.checkReplayStatus(
|
||||
status,
|
||||
command.type === ReplayCommandType.Active ? 'execute' : 'passive'
|
||||
);
|
||||
if (success) {
|
||||
await Promise.all(this.forEachHook(hook => hook.onStep?.(next)));
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
// hook
|
||||
await Promise.all(this.forEachHook(hook => hook.onStep?.(next)));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,22 @@ export const enum ReplayCode {
|
||||
Unequip
|
||||
}
|
||||
|
||||
export const enum ReplayCommandType {
|
||||
/** 主动录像,即录像指令本身会执行某些操作 */
|
||||
Active,
|
||||
/** 被动录像,即外部通过 `IReplaySandbox.getPassive` 获取本步录像的参数,由外部进行后续操作 */
|
||||
Passive
|
||||
}
|
||||
|
||||
export const enum ReplayCommandResult {
|
||||
/** 录像步执行成功 */
|
||||
Success,
|
||||
/** 录像步执行失败 */
|
||||
Failed,
|
||||
/** 录像步执行失败,但可以被忽略,继续执行后续录像。会在控制台输出信息。 */
|
||||
Ignored
|
||||
}
|
||||
|
||||
export type ReplayParamValue = number | string | boolean | bigint;
|
||||
|
||||
export interface IReplayStepHandler {
|
||||
@ -32,19 +48,33 @@ export interface IReplayStepHandler {
|
||||
readonly index: number;
|
||||
}
|
||||
|
||||
export interface IReplayPassiveHandler {
|
||||
/** 录像步信息 */
|
||||
readonly step: IReplayStepHandler;
|
||||
|
||||
/**
|
||||
* 本被动录像步执行完毕,进入下一个录像步
|
||||
* @param status 被动录像步执行情况
|
||||
*/
|
||||
next(status: ReplayCommandResult): void;
|
||||
}
|
||||
|
||||
export interface IReplayCommand {
|
||||
/** 录像类型,是主动录像还是被动录像 */
|
||||
readonly type: ReplayCommandType;
|
||||
|
||||
/**
|
||||
* 执行当前录像步对应的操作逻辑
|
||||
* @param step 当前录像步信息
|
||||
* @returns 当此录像步执行完毕时兑现的 `Promise`,兑现值表示此录像步是否播放成功
|
||||
* @returns 当此录像步执行完毕时兑现的 `Promise`,兑现值表示此录像步的执行情况
|
||||
*/
|
||||
execute(step: IReplayStepHandler): Promise<boolean>;
|
||||
execute(step: IReplayStepHandler): Promise<ReplayCommandResult>;
|
||||
|
||||
/**
|
||||
* 若上一步是当前录像指令,而下一步不是,触发此函数,一般用于连续步骤的后处理
|
||||
* @returns 当此后处理执行完毕时兑现的 `Promise`,兑现值表示是否执行成功
|
||||
*/
|
||||
notExecuted?(): Promise<boolean>;
|
||||
finalize?(): Promise<ReplayCommandResult>;
|
||||
}
|
||||
|
||||
export interface IReplaySandboxHooks extends IHookBase {
|
||||
@ -91,6 +121,11 @@ export interface IReplaySandbox extends IHookable<IReplaySandboxHooks> {
|
||||
/** 当前是否正在播放,当调用 `play` 后,调用 `stop` 前,此值会是 `true` */
|
||||
readonly playing: boolean;
|
||||
|
||||
/**
|
||||
* 获取被动录像参数。如果下一步是被动录像,那么获取其参数,如果不是,返回 `null`
|
||||
*/
|
||||
getPassive(): IReplayPassiveHandler | null;
|
||||
|
||||
/**
|
||||
* 设置播放倍率
|
||||
* @param speed 播放倍率
|
||||
|
||||
@ -122,7 +122,7 @@ describe('replay commands', () => {
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
|
||||
let result: boolean | undefined;
|
||||
const pending = command.notExecuted().then(value => {
|
||||
const pending = command.finalize().then(value => {
|
||||
result = value;
|
||||
});
|
||||
await Promise.resolve();
|
||||
@ -147,7 +147,7 @@ describe('replay commands', () => {
|
||||
|
||||
(mover as unknown as { moving: boolean }).moving = false;
|
||||
vi.spyOn(mover, 'start').mockReturnValueOnce(null);
|
||||
await expect(command.notExecuted()).resolves.toBe(false);
|
||||
await expect(command.finalize()).resolves.toBe(false);
|
||||
expect(error).toHaveBeenCalledWith(2004);
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
@ -2,7 +2,9 @@ import {
|
||||
FaceDirection,
|
||||
IReplayStepHandler,
|
||||
IReplayCommand,
|
||||
ReplayParamValue
|
||||
ReplayParamValue,
|
||||
ReplayCommandResult,
|
||||
ReplayCommandType
|
||||
} from '@user/data-common';
|
||||
import { IStateSystem } from '@user/data-system';
|
||||
import { logger } from '@motajs/common';
|
||||
@ -18,6 +20,8 @@ export abstract class BaseReplayCommand implements IReplayCommand {
|
||||
/** 预期的参数类型列表 */
|
||||
protected abstract readonly paramTypes: readonly string[];
|
||||
|
||||
abstract readonly type: ReplayCommandType;
|
||||
|
||||
constructor(state: IStateSystem) {
|
||||
this.state = state;
|
||||
}
|
||||
@ -37,16 +41,16 @@ export abstract class BaseReplayCommand implements IReplayCommand {
|
||||
const e = expect.length.toString();
|
||||
const p = parameter.length.toString();
|
||||
logger.error(2001, command, e, p);
|
||||
return false;
|
||||
return ReplayCommandResult.Failed;
|
||||
}
|
||||
|
||||
return parameter.every((v, i) => {
|
||||
const type = typeof v;
|
||||
if (type === expect[i]) {
|
||||
return true;
|
||||
return ReplayCommandResult.Success;
|
||||
} else {
|
||||
logger.error(2002, command, i.toString(), expect[i], type);
|
||||
return false;
|
||||
return ReplayCommandResult.Failed;
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -55,11 +59,13 @@ export abstract class BaseReplayCommand implements IReplayCommand {
|
||||
* 执行录像步,已进行必要的参数校验,内部仅包含录像逻辑,不必包含参数校验
|
||||
* @param step 当前录像步信息
|
||||
*/
|
||||
abstract wrappedExecute(step: IReplayStepHandler): Promise<boolean>;
|
||||
abstract wrappedExecute(
|
||||
step: IReplayStepHandler
|
||||
): Promise<ReplayCommandResult>;
|
||||
|
||||
execute(step: IReplayStepHandler): Promise<boolean> {
|
||||
execute(step: IReplayStepHandler): Promise<ReplayCommandResult> {
|
||||
if (!this.assertParameter(this.name, step.params, this.paramTypes)) {
|
||||
return Promise.resolve(false);
|
||||
return Promise.resolve(ReplayCommandResult.Failed);
|
||||
} else {
|
||||
return this.wrappedExecute(step);
|
||||
}
|
||||
@ -71,6 +77,8 @@ export abstract class BaseReplayCommand implements IReplayCommand {
|
||||
//#region 移动指令
|
||||
|
||||
export class ReplayMove extends BaseReplayCommand implements IReplayCommand {
|
||||
readonly type: ReplayCommandType = ReplayCommandType.Active;
|
||||
|
||||
protected readonly name: string = 'move';
|
||||
protected readonly paramTypes: readonly string[] = [];
|
||||
|
||||
@ -81,27 +89,27 @@ export class ReplayMove extends BaseReplayCommand implements IReplayCommand {
|
||||
super(state);
|
||||
}
|
||||
|
||||
async wrappedExecute(): Promise<boolean> {
|
||||
async wrappedExecute(): Promise<ReplayCommandResult> {
|
||||
// Parameter: []
|
||||
const mover = this.state.hero.location.mover;
|
||||
if (mover.moving) {
|
||||
logger.error(2003);
|
||||
return false;
|
||||
return ReplayCommandResult.Failed;
|
||||
}
|
||||
mover.step(this.direction);
|
||||
|
||||
return true;
|
||||
return ReplayCommandResult.Success;
|
||||
}
|
||||
|
||||
async notExecuted(): Promise<boolean> {
|
||||
async finalize(): Promise<ReplayCommandResult> {
|
||||
const mover = this.state.hero.location.mover;
|
||||
const controller = mover.start();
|
||||
if (!controller) {
|
||||
logger.error(2004);
|
||||
return false;
|
||||
return ReplayCommandResult.Failed;
|
||||
}
|
||||
await controller.onEnd;
|
||||
return true;
|
||||
return ReplayCommandResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@ -113,19 +121,23 @@ export class ReplayTeleport
|
||||
extends BaseReplayCommand
|
||||
implements IReplayCommand
|
||||
{
|
||||
readonly type: ReplayCommandType = ReplayCommandType.Active;
|
||||
|
||||
protected readonly name: string = 'teleport';
|
||||
protected readonly paramTypes: readonly string[] = ['number', 'number'];
|
||||
|
||||
async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
|
||||
async wrappedExecute(
|
||||
step: IReplayStepHandler
|
||||
): Promise<ReplayCommandResult> {
|
||||
// Parameter: [int16 x, int16 y]
|
||||
const [x, y] = step.params as [number, number];
|
||||
const result = this.state.pathfinding.teleportTo({ x, y });
|
||||
if (!result) {
|
||||
logger.error(2005, x.toString(), y.toString());
|
||||
return false;
|
||||
return ReplayCommandResult.Failed;
|
||||
}
|
||||
await result.controller.onEnd;
|
||||
return true;
|
||||
return ReplayCommandResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@ -134,17 +146,21 @@ export class ReplayTeleport
|
||||
//#region 使用物品指令
|
||||
|
||||
export class ReplayUseItem extends BaseReplayCommand implements IReplayCommand {
|
||||
readonly type: ReplayCommandType = ReplayCommandType.Active;
|
||||
|
||||
protected readonly name: string = 'use-item';
|
||||
protected readonly paramTypes: readonly string[] = ['number'];
|
||||
|
||||
async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
|
||||
async wrappedExecute(
|
||||
step: IReplayStepHandler
|
||||
): Promise<ReplayCommandResult> {
|
||||
// Parameter: [int16 item]
|
||||
const item = step.params[0] as number;
|
||||
if (!this.state.hero.items.useItem(item)) {
|
||||
logger.error(2006, item.toString());
|
||||
return false;
|
||||
return ReplayCommandResult.Failed;
|
||||
}
|
||||
return true;
|
||||
return ReplayCommandResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@ -153,14 +169,18 @@ export class ReplayUseItem extends BaseReplayCommand implements IReplayCommand {
|
||||
//#region 装备指令
|
||||
|
||||
export class ReplayEquip extends BaseReplayCommand implements IReplayCommand {
|
||||
readonly type: ReplayCommandType = ReplayCommandType.Active;
|
||||
|
||||
protected readonly name: string = 'equip';
|
||||
protected readonly paramTypes: readonly string[] = [
|
||||
'number',
|
||||
'number',
|
||||
'boolean'
|
||||
'ReplayCommandResult'
|
||||
];
|
||||
|
||||
async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
|
||||
async wrappedExecute(
|
||||
step: IReplayStepHandler
|
||||
): Promise<ReplayCommandResult> {
|
||||
// Parameter: [int16 uid, int8 slot, bool autoUnload]
|
||||
const [uid, slot, autoUnload] = step.params as [
|
||||
number,
|
||||
@ -171,9 +191,9 @@ export class ReplayEquip extends BaseReplayCommand implements IReplayCommand {
|
||||
equipment.equip(uid, slot, autoUnload);
|
||||
if (equipment.getEquipped(slot) !== uid) {
|
||||
logger.error(2007, uid.toString(), slot.toString());
|
||||
return false;
|
||||
return ReplayCommandResult.Failed;
|
||||
}
|
||||
return true;
|
||||
return ReplayCommandResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,19 +202,23 @@ export class ReplayEquip extends BaseReplayCommand implements IReplayCommand {
|
||||
//#region 卸下装备指令
|
||||
|
||||
export class ReplayUnequip extends BaseReplayCommand implements IReplayCommand {
|
||||
readonly type: ReplayCommandType = ReplayCommandType.Active;
|
||||
|
||||
protected readonly name: string = 'unequip';
|
||||
protected readonly paramTypes: readonly string[] = ['number'];
|
||||
|
||||
async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
|
||||
async wrappedExecute(
|
||||
step: IReplayStepHandler
|
||||
): Promise<ReplayCommandResult> {
|
||||
// Parameter: [int8 slot]
|
||||
const slot = step.params[0] as number;
|
||||
const equipment = this.state.hero.equip;
|
||||
equipment.unequip(slot);
|
||||
if (equipment.getEquipped(slot) !== void 0) {
|
||||
logger.error(2008, slot.toString());
|
||||
return false;
|
||||
return ReplayCommandResult.Failed;
|
||||
}
|
||||
return true;
|
||||
return ReplayCommandResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -417,7 +417,7 @@ describe('replay playback error codes 2001-2008', () => {
|
||||
|
||||
(mover as unknown as { moving: boolean }).moving = false;
|
||||
vi.spyOn(mover, 'start').mockReturnValueOnce(null);
|
||||
await expect(move.notExecuted()).resolves.toBe(false);
|
||||
await expect(move.finalize()).resolves.toBe(false);
|
||||
expect(error).toHaveBeenCalledWith(2004);
|
||||
});
|
||||
|
||||
|
||||
@ -70,6 +70,9 @@
|
||||
"68": "Extra config identifier '$1' has conflicts. More than one extra config use this identifier, and the final load result is unpredictable since network decides which one to be loaded fast.",
|
||||
"69": "$1 core config not loaded for the next loading phase.",
|
||||
"70": "Cannot $1 replay param type $2 since they are not defined in this engine version.",
|
||||
"71": "Passive replay command needs a ReplayCommandResult status transfering by IReplayPassiveHandler.next, but system got a undefined.",
|
||||
"72": "Replay error when replaying $1 replay step, detailed error message will show together with this error info.",
|
||||
"73": "No passive replay needed currently, but a 'getPassive' call detected, check if there's an issue on replay array or your replay logic.",
|
||||
"2001": "Replay($1): Replay parameter count mismatch: expected $2, got $3.",
|
||||
"2002": "Replay($1): Replay parameter type mismatch: index $2, expected $3, got $4.",
|
||||
"2003": "Replay(move): Expected hero to be stopped before execute moving step.",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user