feat: 录像系统

This commit is contained in:
AncTe committed 2026-07-16 20:34:28 +08:00
1 parent f63da7dc3d
commit bf6702be54
9 files changed
+529 -254

No files matched your search

+34 -1
View File
@@ -2,11 +2,13 @@ import { logger } from '@motajs/common';
import {
IReplayArray,
IReplayArrayConfig,
IReplayArraySave,
IReplayReadStream,
IReplayStepHandler,
ReplayCommandWidth,
ReplayParamValue
} from './types';
import { ISaveableContent } from '../save';
interface INormalizedParam {
/**
@@ -44,7 +46,9 @@ interface IDecodedCommand {
readonly paramCount: number;
}
export class ReplayArray implements IReplayArray {
export class ReplayArray
implements IReplayArray, ISaveableContent<IReplayArraySave>
{
length: number = 0;
commandWidth: ReplayCommandWidth = ReplayCommandWidth.Uint8;
@@ -705,6 +709,9 @@ export class ReplayArray implements IReplayArray {
expired: false,
read: () => {
if (stream.expired) {
logger.warn(155);
}
if (stream.index >= this.length) return null;
const { command, paramCount } = this.decodeCommand(currCommand);
const params = this.decodeParamList(currParam, paramCount);
@@ -787,4 +794,30 @@ export class ReplayArray implements IReplayArray {
getParamArray(): ArrayBuffer {
return this.paramBuffer;
}
//#region 存读档
saveState(): IReplayArraySave {
return {
commands: this.commandBuffer,
params: this.paramBuffer,
metadata: {
commandWidth: this.commandWidth
}
};
}
loadState(state: IReplayArraySave): void {
this.commandBuffer = state.commands;
this.paramBuffer = state.params;
this.commandWidth = state.metadata.commandWidth;
this.commandView = new DataView(this.commandBuffer);
this.paramView = new DataView(this.paramBuffer);
this.commandArray = new Uint8Array(this.commandBuffer);
this.paramArray = new Uint8Array(this.paramBuffer);
this.indexArray = new Uint32Array(this.indexBuffer);
this.expireStreams();
}
}
@@ -0,0 +1,183 @@
import { logger } from '@motajs/common';
import { IReplaySystem } from './types';
interface IReplaySafetyCollection {
/** 这个安全对象(由 `@shouldReplay()` 触发)的名称,即传入给 `@shouldReplay` 的参数 */
readonly name: string;
/** 这个安全对象所包含的所有字对象 */
readonly messages: IReplaySafetyCollection[];
}
interface IReplaySafetyDetailQueue {
/** 详细信息代码 */
readonly code: number;
/** 此详细信息对应的安全对象 */
readonly collection: IReplaySafetyCollection;
}
type ReplayDecorator = <
Return,
This,
Func extends (this: This, ...args: any[]) => Return
>(
method: Func,
context: ClassMethodDecoratorContext
) => (this: This, ...args: any[]) => Return;
/** 本次收集使用的录像系统 */
let replaySystem: IReplaySystem | null = null;
/** 当前是否正在进行录像收集 */
let collecting: boolean = false;
/** 收集之前录像系统的录像长度 */
let beforeLength: number = 0;
/** 收集时是否有 `@ignoreReplay` 调用 */
let shouldIgnore: boolean = false;
/** 录像安全收集根对象 */
const collection: IReplaySafetyCollection = {
name: 'root',
messages: []
};
/** 当前录像安全收集对象 */
let currentCollection: IReplaySafetyCollection | null = null;
/** 详细信息 code */
let detailCode = 0;
/** 录像收集详细信息队列,保留 50 个以确保可以在控制台重复输出 */
const detailQueue: IReplaySafetyDetailQueue[] = [];
/**
* 开始录像安全性检查收集,一般情况下不需要手动调用此接口
* @param system 录像系统对象
*/
export function beginReplaySafetyCollection(system: IReplaySystem): void {
if (collecting || replaySystem) {
logger.warn(159);
return;
}
replaySystem = system;
collecting = true;
beforeLength = system.route.length;
collection.messages.length = 0;
currentCollection = collection;
}
/**
* 结束录像安全性检查收集,并输出收集结果
*/
export function endReplaySafetyCollection(): void {
if (!collecting || !replaySystem) {
logger.warn(160);
return;
}
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);
while (detailQueue.length > 50) {
detailQueue.shift();
}
detailQueue.push({
code,
collection: col
});
}
/**
* 输出指定的录像安全对象的所有详细信息
* @param collection 录像安全对象
*/
function logCollection(collection: IReplaySafetyCollection): void {
console.group(collection.name);
const sorted = collection.messages.toSorted(
(a, b) => b.messages.length - a.messages.length
);
for (const msg of sorted) {
if (msg.messages.length === 0) {
console.log(msg.name);
} else {
logCollection(msg);
}
}
console.groupEnd();
}
/**
* 输出某次录像安全检查的详细信息
* @param code 录像安全检查对象的代码
*/
export function logReplaySafetyDetail(code: number): void {
const root = detailQueue.find(v => v.code === code);
if (!root) {
logger.warn(162, code.toString());
return;
}
logCollection(root.collection);
}
/**
* 将一个类的方法标记为需要计入录像
* @param message 如果录像安全检查出现问题,那么会使用此作为信息输出
*/
export function shouldReplay(message: string): ReplayDecorator {
return function <
Return,
This,
Args extends any[],
Func extends (this: This, ...args: Args) => Return
>(
this: This,
method: Func,
context: ClassMethodDecoratorContext
): (this: This, ...args: Args) => Return {
return function (this: This, ...args: Args): Return {
const before = currentCollection;
if (!before) return method.apply(this, args);
const exist = before.messages.find(v => v.name === message);
const newCollection: IReplaySafetyCollection = exist ?? {
name: `${String(context.name)}: ${message}`,
messages: []
};
if (!exist) {
before.messages.push(newCollection);
}
currentCollection = newCollection;
const result = method.apply(this, args);
currentCollection = before;
return result;
};
};
}
/**
* 将一个类的方法标记为忽略录像安全检查,一般用于异步内容等不是立刻会触发状态修改的方法,
* 不要使用此方法来规避控制台的录像错误警告,否则很可能导致录像出错。
*/
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: This, ...args: Args): Return {
shouldIgnore = true;
return method.apply(this, args);
};
};
}
@@ -1,2 +1,3 @@
export * from './array';
export * from './sandbox';
export * from './types';
@@ -0,0 +1,146 @@
import {
Hookable,
HookController,
IHookController,
logger
} from '@motajs/common';
import {
IReplayArray,
IReplayReadStream,
IReplaySandbox,
IReplaySandboxHook,
IReplaySystem
} from './types';
export class ReplaySandbox
extends Hookable<IReplaySandboxHook>
implements IReplaySandbox
{
pausing: boolean = true;
speed: number = 1;
ended: boolean = false;
playing: boolean = false;
/** 下一步是否需要暂停播放 */
private needPause: boolean = false;
/** 当前录像是否播放完毕 */
private ending: boolean = false;
/** 暂停 `Promise` 的 `resolve` 函数 */
private pauseResolve: () => void = () => {};
/** 录像的流式读取器 */
private reader: Readonly<IReplayReadStream>;
constructor(
readonly route: IReplayArray,
readonly system: IReplaySystem,
startIndex: number
) {
super();
this.reader = route.createReadStream(startIndex);
}
protected createController(
hook: Partial<IReplaySandboxHook>
): IHookController<IReplaySandboxHook> {
return new HookController(this, hook);
}
setSpeed(speed: number): void {
this.speed = speed;
this.forEachHook(hook => hook.onSpeedSet?.(speed));
}
getReplayed(): number {
return this.reader.index;
}
/**
* 开始录像重播循环
*/
private async startReplayLoop() {
if (this.ended) return;
if (this.reader.expired) {
logger.warn(156);
return;
}
while (true) {
if (this.needPause && !this.pausing) {
this.pausing = true;
this.needPause = false;
this.pauseResolve();
this.forEachHook(hook => hook.onPauseReplay?.());
break;
}
if (this.reader.expired) {
logger.warn(156);
break;
}
const success = await this.step();
if (!success) break;
}
if (this.ending) {
this.ended = true;
}
}
play(): void {
if (this.playing || this.ended) return;
this.pausing = false;
this.playing = true;
this.forEachHook(hook => hook.onStartReplay?.());
this.startReplayLoop();
}
pause(): Promise<void> {
if (this.pausing || this.ended || !this.playing) {
return Promise.resolve();
}
const { promise, resolve } = Promise.withResolvers<void>();
this.pauseResolve = resolve;
this.needPause = true;
return promise;
}
resume(): void {
if (!this.pausing || !this.playing || this.ended) return;
this.pausing = false;
this.forEachHook(hook => hook.onResumeReplay?.());
this.startReplayLoop();
}
async stop(): Promise<void> {
if (this.pausing || this.ended || !this.playing) return;
await this.pause();
this.forEachHook(hook => hook.onStopReplay?.());
}
async step(): Promise<boolean> {
if (!this.pausing || !this.playing || this.ended) return false;
if (this.reader.expired) {
logger.warn(156);
return false;
}
const next = this.reader.read();
if (!next) {
this.ending = true;
return false;
}
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)
);
return false;
}
await Promise.all(this.forEachHook(hook => hook.onStep?.(next)));
return true;
}
}
@@ -0,0 +1,87 @@
import {
Hookable,
HookController,
IHookController,
logger
} from '@motajs/common';
import {
IReplayArray,
IReplayCommand,
IReplaySandbox,
IReplaySandboxConfig,
IReplaySystem,
IReplaySystemHook,
ReplayCommandWidth,
ReplayParamValue
} from './types';
import { ReplayArray } from './array';
import { ReplaySandbox } from './sandbox';
export class ReplaySystem
extends Hookable<IReplaySystemHook>
implements IReplaySystem
{
replaying: boolean = false;
sandbox: IReplaySandbox | null = null;
route: IReplayArray;
/** 所有注册的指令 */
private readonly commands: Map<number, IReplayCommand> = new Map();
constructor() {
super();
this.route = new ReplayArray({
initCommandLength: 1000,
initParamLength: 10000,
commandExpandMultiplier: 1.2,
paramExpandMultiplier: 1.2,
commandWidth: ReplayCommandWidth.Uint8,
commandMaxLength: 1_000_000,
paramMaxLength: 10_000_000
});
}
protected createController(
hook: Partial<IReplaySystemHook>
): IHookController<IReplaySystemHook> {
return new HookController(this, hook);
}
registerCommand(code: number, command: IReplayCommand): void {
if (this.commands.has(code)) {
logger.warn(163);
return;
}
this.commands.set(code, command);
}
getCommand(code: number): IReplayCommand | null {
return this.commands.get(code) ?? null;
}
record(code: number, ...params: ReplayParamValue[]): void {
this.route.add(code, params);
this.forEachHook(hook =>
hook.onRecordCommand?.(code, this.route.length, params)
);
}
createReplaySandbox(
config: Readonly<IReplaySandboxConfig>
): IReplaySandbox {
config.reseter.reset(config.save);
const sandbox = new ReplaySandbox(
config.route,
this,
config.startIndex ?? 0
);
this.sandbox = sandbox;
this.forEachHook(hook => hook.onCreateSandbox?.(sandbox));
return sandbox;
}
releaseSandbox(): void {
this.sandbox?.stop();
this.sandbox = null;
}
}
+68 -46
View File
@@ -1,5 +1,4 @@
import { IHookable, IHookBase } from '@motajs/common';
import { ISaveableContent } from '../save';
export type ReplayParamValue = number | string | boolean | bigint;
@@ -16,8 +15,9 @@ export interface IReplayCommand {
/**
* 执行当前录像步对应的操作逻辑
* @param step 当前录像步信息
* @returns 当此录像步执行完毕时兑现的 `Promise`,兑现值表示此录像步是否播放成功
*/
execute(step: IReplayStepHandler): Promise<void>;
execute(step: IReplayStepHandler): Promise<boolean>;
}
export interface IReplaySandboxHook extends IHookBase {
@@ -57,8 +57,12 @@ export interface IReplaySandboxHook extends IHookBase {
export interface IReplaySandbox extends IHookable<IReplaySandboxHook> {
/** 是否处于暂停状态 */
readonly pausing: boolean;
/** 当前录像是否已经播放完毕 */
readonly ended: boolean;
/** 播放倍率,1 为正常速度 */
readonly speed: number;
/** 当前是否正在播放,当调用 `play` 后,调用 `stop` 前,此值会是 `true` */
readonly playing: boolean;
/**
* 设置播放倍率
@@ -66,6 +70,11 @@ export interface IReplaySandbox extends IHookable<IReplaySandboxHook> {
*/
setSpeed(speed: number): void;
/**
* 获取已经播放过的录像步数量,不包括当前正在播放的录像步
*/
getReplayed(): number;
/**
* 开始连续播放
*/
@@ -88,8 +97,9 @@ export interface IReplaySandbox extends IHookable<IReplaySandboxHook> {
/**
* 单步播放一步,返回的 Promise 在该步渲染完成后兑现
* @returns 当前步是否正常播放完成
*/
step(): Promise<void>;
step(): Promise<boolean>;
}
export interface IStateReseter {
@@ -108,7 +118,7 @@ export const enum ReplayCommandWidth {
}
export interface IReplayReadStream {
/** 当前读取位置的步索引 */
/** 已经读取过的录像步数量,即上一次调用 `read` 时读取的录像步索引 */
index: number;
/** 录像总步数 */
length: number;
@@ -144,6 +154,38 @@ export interface IReplayArrayConfig {
paramMaxLength: number;
}
export interface IReplayMetadataSave {
/** 当前录像使用的指令码宽度 */
readonly commandWidth: ReplayCommandWidth;
}
export interface IReplayArraySave {
/**
* 指令数组,在 Uint8 位宽下,两个字节为一组,第一个字节为参数数量,第二个字节为指令标识。
* 在 Uint16 位宽下,三个字节为一组,第一个字节为参数数量,后两个字节组成的 uint16 为指令标识。
*/
readonly commands: ArrayBuffer;
/**
* 参数数组,由参数类型和参数值组成。参数类型占据一个字节,参数值根据类型不同占据不同的字节。
* 参数类型列表(包含参数类型字节):
*
* - 0: boolean --- 2 Byte
* - 1: int8 --- 2 Byte
* - 2: int16 --- 3 Byte
* - 3: int32 --- 5 Byte
* - 4: int64 --- 9 Byte
* - 5: float --- 9 Byte
* - 6: bigint --- n + 1 Byte, 其中 n 是 bigint 的字节数
* - 7: string --- n + 1 Byte, 其中 n 是字符串编码后的字节数
* - 8 ~ 255: n - 7 长度的字符串 --- n + 1 Byte, 其中 n 是字符串编码后的字节数
*/
readonly params: ArrayBuffer;
/** 录像元数据 */
readonly metadata: IReplayMetadataSave;
}
export interface IReplayArray {
/** 录像中的总步数 */
readonly length: number;
@@ -260,40 +302,18 @@ export interface IReplaySystemHook extends IHookBase {
): void;
}
export interface IReplayMetadataSave {
/** 当前录像使用的指令码宽度 */
readonly commandWidth: ReplayCommandWidth;
export interface IReplaySandboxConfig {
/** 录像播放沙箱使用的录像数组 */
route: IReplayArray;
/** 录像播放前进行的状态重置使用的状态重置对象 */
reseter: IStateReseter;
/** 录像播放的起始索引,默认为 0 */
startIndex?: number;
/** 初始化使用的存档对象 */
save?: Map<string, unknown>;
}
export interface IReplaySystemSave {
/**
* 指令数组,在 Uint8 位宽下,两个字节为一组,第一个字节为参数数量,第二个字节为指令标识。
* 在 Uint16 位宽下,三个字节为一组,第一个字节为参数数量,后两个字节组成的 uint16 为指令标识。
*/
readonly commands: ArrayBuffer;
/**
* 参数数组,由参数类型和参数值组成。参数类型占据一个字节,参数值根据类型不同占据不同的字节。
* 参数类型列表:
*
* - 0: boolean --- 2 Byte
* - 1: int8 --- 2 Byte
* - 2: int16 --- 3 Byte
* - 3: int32 --- 5 Byte
* - 4: int64 --- 9 Byte
* - 5: float --- 9 Byte
* - 6: bigint --- n + 1 Byte, 其中 n 是 bigint 的字节数
* - 7: string --- n + 1 Byte, 其中 n 是字符串编码后的字节数
* - 8 ~ 255: n - 7 长度的字符串 --- n + 1 Byte, 其中 n 是字符串编码后的字节数
*/
readonly params: ArrayBuffer;
/** 录像元数据 */
readonly metadata: IReplayMetadataSave;
}
export interface IReplaySystem
extends ISaveableContent<IReplaySystemSave>, IHookable<IReplaySystemHook> {
export interface IReplaySystem extends IHookable<IReplaySystemHook> {
/** 当前是否处在录像播放状态 */
readonly replaying: boolean;
/** 当前正在播放的录像沙箱实例 */
@@ -303,27 +323,29 @@ export interface IReplaySystem
/**
* 注册一个录像命令
* @param id 命令的唯一标识
* @param code 命令的唯一标识
* @param command 命令对应的执行对象
*/
registerCommand(id: number, command: IReplayCommand): void;
registerCommand(code: number, command: IReplayCommand): void;
/**
* 根据录像命令标识符获取命令对象
* @param code 命令标识符
*/
getCommand(code: number): IReplayCommand | null;
/**
* 向录像末尾追加一条录像步
* @param id 命令标识
* @param code 命令标识
* @param params 可变数量的录像参数
*/
record(id: number, ...params: ReplayParamValue[]): void;
record(code: number, ...params: ReplayParamValue[]): void;
/**
* 创建一个录像播放沙箱
* @param reseter 状态重置器,用于在播放前重置游戏状态
* @param save 可选,已加载的存档数据,用于从存档状态继续播放
* @param config 录像播放沙箱的配置对象
*/
createReplaySandbox(
reseter: IStateReseter,
save?: Map<string, unknown>
): IReplaySandbox;
createReplaySandbox(config: Readonly<IReplaySandboxConfig>): IReplaySandbox;
/**
* 释放当前活跃的沙箱