feat: 录像记录 & CoreState 存储接口

This commit is contained in:
unanmed 2026-09-14 18:09:45 +08:00
parent 5470f7a90e
commit cee843981f
16 changed files with 156 additions and 51 deletions

View File

@ -1 +1 @@
{"isolation":"harness-worktree","harness_flag":"isolation=\"worktree\"","phase":"03","plan":null,"written_at":1789138005150}
{"isolation":"none","harness_flag":null,"phase":"06","plan":null,"written_at":1789346124877}

View File

@ -1,5 +1,5 @@
import { isNil } from 'lodash-es';
import { IDataCommon } from '@user/data-common';
import { IDataCommon, ReplayCommandCode } from '@user/data-common';
import {
EquipStatus,
IEquipmentState,
@ -127,6 +127,11 @@ export class HeroEquipment<THero> implements IHeroEquipment<THero> {
return void 0;
}
// 记录录像
// TODO: 可以考虑把禁用录像记录放到录像系统里面,避免每个可能由代码触发的行动都写一个 noRoute 参数
const replay = this.state.replaySystem;
replay.route.add(ReplayCommandCode.Equip, [uid]);
if (typeof slot === 'number') {
// 数字槽位,直接进行指定替换
const curr = this.equips.get(slot);
@ -176,6 +181,10 @@ export class HeroEquipment<THero> implements IHeroEquipment<THero> {
return void 0;
}
// 记录录像
const replay = this.state.replaySystem;
replay.route.add(ReplayCommandCode.Unequip, [slot]);
this.unloadEquipEffect(state);
this.equips.delete(slot);

View File

@ -1,5 +1,10 @@
import { isNil } from 'lodash-es';
import { IDataCommon, ItemCategory, SaveCompression } from '@user/data-common';
import {
IDataCommon,
ItemCategory,
ReplayCommandCode,
SaveCompression
} from '@user/data-common';
import { HeroEquipsStore } from './equipStore';
import {
IHeroItems,
@ -107,7 +112,7 @@ export class HeroItems<THero> implements IHeroItems<THero> {
this.addItem(item, 1);
}
useItem(item: number | string): boolean {
useItem(item: number | string, noRoute: boolean = false): boolean {
const state = this.internalGetItemState(item);
if (!state) return false;
@ -121,6 +126,11 @@ export class HeroItems<THero> implements IHeroItems<THero> {
if (!raw.effect.canUse(raw)) return false;
if (!noRoute) {
const replay = this.state.replaySystem;
replay.route.add(ReplayCommandCode.UseItem, [raw.num]);
}
raw.effect.useEffect(raw);
if (raw.category === ItemCategory.Consumable) {

View File

@ -6,7 +6,8 @@ import {
ObjectMoveStep,
ObjectMover,
ObjectMoveType,
IDataCommon
IDataCommon,
ReplayCommandCode
} from '@user/data-common';
import {
HeroMoveCode,
@ -177,6 +178,29 @@ export class HeroMover<T extends IHeroLocation>
type === ObjectMoveType.DirFace ||
type === ObjectMoveType.Special
) {
// 录像记录
if (!this.noRoute) {
const replay = this.state.replaySystem;
switch (handler.direction) {
case FaceDirection.Up:
replay.route.add(ReplayCommandCode.Up, []);
break;
case FaceDirection.Right:
replay.route.add(ReplayCommandCode.Right, []);
break;
case FaceDirection.Left:
replay.route.add(ReplayCommandCode.Left, []);
break;
case FaceDirection.Down:
replay.route.add(ReplayCommandCode.Down, []);
break;
default:
logger.warn(176);
break;
}
}
// 移动判断
if (this.ignoreTerrain) {
return HeroMoveCode.Step;
} else {

View File

@ -562,9 +562,10 @@ export interface IHeroItems<THero>
/**
* 使用道具,仅对 Constant 与 Consumable 类型生效。Consumable 类型使用后数量减一。
* @param item 道具图块数字或字符串 id
* @param noRoute 是否不计入录像
* @returns 道具是否使用成功
*/
useItem(item: number | string): boolean;
useItem(item: number | string, noRoute?: boolean): boolean;
/**
* 获取指定道具的持有数量

View File

@ -1,5 +1,25 @@
import { IHookable, IHookBase } from '@motajs/common';
/** 录像指令码,数值属于录像格式的一部分 */
export const enum ReplayCommandCode {
/** 向上移动一步 */
Up,
/** 向右移动一步 */
Right,
/** 向下移动一步 */
Down,
/** 向左移动一步 */
Left,
/** 自动寻路瞬移至目标点 */
Teleport,
/** 使用道具 */
UseItem,
/** 装备物品 */
Equip,
/** 卸下装备 */
Unequip
}
export type ReplayParamValue = number | string | boolean | bigint;
export interface IReplayStepHandler {

View File

@ -2,6 +2,7 @@ import { IDirectionMapper, ITileLocator } from '@motajs/common';
import { IFaceManager, IRoleFaceBinder } from './common';
import { IGameEventStore, IItemStore, IMapStore, ITileStore } from './store';
import { ISaveSystem } from './save';
import { IReplaySystem } from './replay';
export interface IEnemyAttr {
/** 怪物生命值 */
@ -60,6 +61,8 @@ export interface IDataCommon {
readonly directionMapper: IDirectionMapper;
/** 存档系统 */
readonly saveSystem: ISaveSystem;
/** 录像系统 */
readonly replaySystem: IReplaySystem;
}
export interface IDataCommonExtended {

View File

@ -24,7 +24,8 @@ import {
IMapStore,
MapStore,
IReplaySystem,
ReplaySystem
ReplaySystem,
ReplayCommandCode
} from '@user/data-common';
import {
EnemyManager,
@ -83,7 +84,6 @@ import { isNil } from 'lodash-es';
import { DefaultHeroMoveTopImpl } from './hero';
import { createEventRegistrations } from './event/registrations';
import {
ReplayCommandCode,
ReplayEquipCommand,
ReplayMoveCommand,
ReplayTeleportCommand,
@ -101,6 +101,7 @@ export class CoreState implements ICoreState {
readonly mapStore: IMapStore;
readonly eventStore: IGameEventStore;
readonly directionMapper: IDirectionMapper;
readonly replaySystem: IReplaySystem;
// Layer 1 数据层,所有可存档内容都在这,一般用于数据存储
readonly maps: IMapState;
@ -112,7 +113,6 @@ export class CoreState implements ICoreState {
readonly enemyContext: IEnemyContext<IEnemyAttr, IHeroAttr>;
readonly eventSystem: IGameEventSystem;
readonly pathfinding: IPathfindingSystem;
readonly replaySystem: IReplaySystem;
// Layer 3 用户层,也就是最顶层的内容,一般仅用于初始化以及仅供渲染端调用的顶层模块
readonly loadProgress: ILoadProgressTotal;
@ -505,6 +505,37 @@ export class CoreState implements ICoreState {
}
}
saveState(compression: SaveCompression): ReadonlyMap<string, unknown> {
const result = new Map<string, unknown>();
for (const [key, value] of this.saveables) {
const content = value.saveState(compression);
result.set(key, content);
}
return result;
}
loadState(
state: ReadonlyMap<string, unknown>,
compression: SaveCompression
): void {
for (const [key, value] of this.saveables) {
// 使用 has 判断是否在映射中,而非值的非空判断,因为空值也有可能是存档的一部分
if (!state.has(key)) {
logger.warn(177, key);
continue;
}
const data = state.get(key);
value.loadState(data, compression);
}
const loaded = new Set<string>(state.keys());
const total = new Set(this.saveables.keys());
const remain = total.difference(loaded);
if (remain.size > 0) {
const ids = [...remain].join(' | ');
logger.warn(178, ids);
}
}
//#endregion
}

View File

@ -7,7 +7,8 @@ import {
beginReplaySafetyCollection,
endReplaySafetyCollection,
logReplaySafetyDetail,
shouldReplay
shouldReplay,
ReplayCommandCode
} from '@user/data-common';
import { readFileSync } from 'node:fs';
import { describe, expect, it, vi } from 'vitest';
@ -20,7 +21,18 @@ import {
ReplayUnequipCommand,
ReplayUseItemCommand
} from './commands';
import { ReplayCommandCode, REPLAY_COMMAND_ORDER } from './types';
/** 供测试读取的稳定指令码顺序 */
export const REPLAY_COMMAND_ORDER: readonly ReplayCommandCode[] = [
ReplayCommandCode.Up,
ReplayCommandCode.Right,
ReplayCommandCode.Down,
ReplayCommandCode.Left,
ReplayCommandCode.Teleport,
ReplayCommandCode.UseItem,
ReplayCommandCode.Equip,
ReplayCommandCode.Unequip
];
function step(
command: number,

View File

@ -1,2 +1 @@
export * from './commands';
export * from './types';

View File

@ -1,31 +0,0 @@
/** 顶层拥有的稳定录像指令码,数值属于录像格式的一部分 */
export const enum ReplayCommandCode {
/** 向上移动一步 */
Up = 0,
/** 向右移动一步 */
Right = 1,
/** 向下移动一步 */
Down = 2,
/** 向左移动一步 */
Left = 3,
/** 自动寻路至目标点 */
Teleport = 4,
/** 使用道具 */
UseItem = 5,
/** 装备物品 */
Equip = 6,
/** 卸下装备 */
Unequip = 7
}
/** 供测试读取的稳定指令码顺序 */
export const REPLAY_COMMAND_ORDER: readonly ReplayCommandCode[] = [
ReplayCommandCode.Up,
ReplayCommandCode.Right,
ReplayCommandCode.Down,
ReplayCommandCode.Left,
ReplayCommandCode.Teleport,
ReplayCommandCode.UseItem,
ReplayCommandCode.Equip,
ReplayCommandCode.Unequip
];

View File

@ -12,7 +12,8 @@ export interface ISaveableExecutor<T> {
afterLoad(data: T, state: IStateBase): void;
}
export interface ICoreState extends IStateSystem {
export interface ICoreState
extends IStateSystem, ISaveableContent<ReadonlyMap<string, unknown>> {
/** 加载进度对象 */
readonly loadProgress: ILoadProgressTotal;
/** 数据端加载对象 */

View File

@ -4,15 +4,27 @@ import {
IReplaySandbox,
IHeroAttr,
ReplaySystem,
SaveCompression
SaveCompression,
ReplayCommandCode
} from '@user/data-common';
import { IEnemy, IReadonlyHeroAttribute } from '@user/data-base';
import { IReadonlyEnemyHandler } from '@user/data-system';
import { createCoreState, CoreState } from '../src/core';
import { MainDamageCalculator } from '../src/enemy/calculator';
import { REPLAY_COMMAND_ORDER, ReplayCommandCode } from '../src/replay/types';
import { createClosedLoopFixture } from './fixtures/closed-loop';
/** 供测试读取的稳定指令码顺序 */
export const REPLAY_COMMAND_ORDER: readonly ReplayCommandCode[] = [
ReplayCommandCode.Up,
ReplayCommandCode.Right,
ReplayCommandCode.Down,
ReplayCommandCode.Left,
ReplayCommandCode.Teleport,
ReplayCommandCode.UseItem,
ReplayCommandCode.Equip,
ReplayCommandCode.Unequip
];
vi.hoisted(() => {
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,

View File

@ -1,6 +1,10 @@
import { ITileLocator, logger } from '@motajs/common';
import { IObjectMovable, IObjectMover } from '@user/data-common';
import { IStateBase } from '@user/data-base';
import {
IObjectMovable,
IObjectMover,
ReplayCommandCode
} from '@user/data-common';
import { HeroMover, IStateBase } from '@user/data-base';
import { isNil } from 'lodash-es';
import { PathfindingFinder } from './finder';
import {
@ -95,6 +99,15 @@ export class PathfindingSystem implements IPathfindingSystem {
if (!this.policy || this.policy(path)) {
return this.startMove(path, false);
} else {
// 录像记录
// TODO:后续需要调整设计方式,最好不用 instanceof
if (this.mover instanceof HeroMover) {
const replay = this.state.replaySystem;
replay.route.add(ReplayCommandCode.Teleport, [
target.x,
target.y
]);
}
return this.startMove(path, true);
}
}

View File

@ -1,6 +1,6 @@
import { IStateBase } from '@user/data-base';
import { IEnemyContext } from './combat';
import { IEnemyAttr, IHeroAttr, IReplaySystem } from '@user/data-common';
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import { IGameEventSystem } from './event';
import { IPathfindingSystem } from './path';
@ -11,8 +11,6 @@ export interface IStateSystem extends IStateBase {
readonly eventSystem: IGameEventSystem;
/** 已绑定勇士移动器的寻路系统 */
readonly pathfinding: IPathfindingSystem;
/** 当前 CoreState 独立拥有的录像系统 */
readonly replaySystem: IReplaySystem;
}
export interface IStateSystemExtended {

View File

@ -250,6 +250,9 @@
"173": "Pathfinding input is invalid or a required binding (map state, map layer) is missing. An empty result will be returned.",
"174": "Pathfinding cost function returned a negative or NaN value. Default cost 1 will be used instead.",
"175": "Replay error: Replay not executed execution failed. Command code: $1.",
"176": "Cannot add move step to replay system since only 'up,right,down,left' directions are acceptable.",
"177": "Save data for saveable content $1 is needed, but there's no data in save data. Maybe a version issue for it? System will try to load other contents to make game run normally.",
"178": "Save data with keys of '$1' are saved but not be loaded, is there some issue for it?",
"1001": "Event(setBlock): Unknown tile '$1' when setting block."
}
}