refactor: Hero equipment system

This commit is contained in:
unanmed 2026-09-23 13:09:19 +08:00
parent e78309b326
commit 7743795948
6 changed files with 225 additions and 120 deletions

View File

@ -1,53 +1,14 @@
import { logger } from '@motajs/common';
import { SaveCompression } from '@user/data-common';
import { SaveCompression, shouldReplay } from '@user/data-common';
import {
IHeroAttribute,
IHeroAttributeCloneOption,
IHeroAttributeSave,
IHeroModifier,
IHeroModifierOwner,
IModifierStateSave
} from './types';
import { isNil } from 'lodash-es';
export abstract class BaseHeroModifier<T, V> implements IHeroModifier<T, V, V> {
abstract readonly type: string;
abstract readonly priority: number;
owner: IHeroModifierOwner | null = null;
constructor(private currentValue: V) {}
get value(): V {
return this.currentValue;
}
setValue(value: V): void {
this.currentValue = value;
this.owner?.markModifierDirty(this);
}
getValue(): V {
return this.currentValue;
}
bindAttribute(attribute: IHeroModifierOwner | null): void {
this.owner = attribute;
}
saveState(_compression: SaveCompression): V {
return this.currentValue;
}
loadState(state: V, _compression: SaveCompression): void {
this.setValue(state);
}
abstract modify(value: T, baseValue: T, name: string): T;
abstract clone(): IHeroModifier<T, V>;
}
export class HeroAttribute<THero> implements IHeroAttribute<THero> {
/** 当前勇士属性修饰器 */
private readonly modifier: Map<keyof THero, IHeroModifier[]> = new Map();
@ -142,21 +103,25 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
//#region 属性操作
@shouldReplay('Hero attribute change should be replayed.')
set<K extends keyof THero>(name: K, value: THero[K]): void {
this.attribute[name] = value;
this.markDirty(name);
}
@shouldReplay('Hero attribute change should be replayed.')
add(name: SelectKey<THero, number>, value: number): void {
(this.attribute[name] as number) += value;
this.markDirty(name);
}
@shouldReplay('Hero attribute change should be replayed.')
mul(name: SelectKey<THero, number>, value: number): void {
(this.attribute[name] as number) *= value;
this.markDirty(name);
}
@shouldReplay('Hero attribute change should be replayed.')
div(name: SelectKey<THero, number>, value: number): void {
(this.attribute[name] as number) /= value;
this.markDirty(name);
@ -187,6 +152,7 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
return arr.indexOf(modifier);
}
@shouldReplay('Changing hero attrubute modifier should be replayed.')
addModifier<K extends keyof THero>(
name: K,
modifier: IHeroModifier<THero[K]>,
@ -210,6 +176,7 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
this.markDirty(name);
}
@shouldReplay('Changing hero attrubute modifier should be replayed.')
deleteModifier<K extends keyof THero>(
name: K,
modifier: IHeroModifier<THero[K], unknown>
@ -227,6 +194,7 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
this.markDirty(name);
}
@shouldReplay('Changing hero attrubute modifier should be replayed.')
deleteModifierByIndex<K extends keyof THero>(
name: K,
index: number

View File

@ -1,4 +1,9 @@
import { IItemRawData, SaveCompression, IDataCommon } from '@user/data-common';
import {
IItemRawData,
SaveCompression,
IDataCommon,
shouldReplay
} from '@user/data-common';
import { isNil, maxBy } from 'lodash-es';
import { ValueModifier, PercentageModifier } from './modifier';
import {
@ -8,63 +13,131 @@ import {
IHeroEquipsStore,
IEquipmentSorter,
IEquipmentSortHandler,
IHeroEquipsStoreSave
IHeroEquipsStoreSave,
IEquipmentStateHooks
} from './types';
import { logger } from '@motajs/common';
import {
Hookable,
HookController,
IHookController,
logger
} from '@motajs/common';
export class EquipmentState<THero> implements IEquipmentState<THero> {
type Modifiers<THero> = Map<SelectKey<THero, number>, IHeroModifier<number>>;
export class EquipmentState<THero>
extends Hookable<IEquipmentStateHooks<THero>>
implements IEquipmentState<THero>
{
readonly item: IItemRawData<THero>;
/** 装备产生的所有修饰器,每个元素为 [属性名, 修饰器] */
readonly modifiers: [SelectKey<THero, number>, IHeroModifier<number>][];
/** 数值修饰器列表 */
readonly valueModifiers: Modifiers<THero> = new Map();
/** 百分比修饰器列表 */
readonly perModifiers: Modifiers<THero> = new Map();
/** 装备的数值属性 */
private readonly value: Map<SelectKey<THero, number>, number>;
private readonly value: Map<SelectKey<THero, number>, number> = new Map();
/** 装备的百分比属性 */
private readonly percentage: Map<SelectKey<THero, number>, number>;
private readonly per: Map<SelectKey<THero, number>, number> = new Map();
/** 是否已经构建过修饰器 */
private built: boolean = false;
constructor(
readonly uid: number,
item: IItemRawData<THero>,
buildModifier: boolean = true
item: IItemRawData<THero>
) {
super();
this.item = item;
this.modifiers = [];
const equip = item.equip;
this.value = new Map(
Object.entries(equip.value) as [SelectKey<THero, number>, number][]
);
this.percentage = new Map(
Object.entries(equip.percentage) as [
SelectKey<THero, number>,
number
][]
);
if (buildModifier) {
this.rebuildModifiers();
for (const [key, value] of Object.entries(equip.value)) {
this.value.set(key as SelectKey<THero, number>, value as number);
}
for (const [key, value] of Object.entries(equip.percentage)) {
this.per.set(key as SelectKey<THero, number>, value as number);
}
}
protected createController(
hook: Partial<IEquipmentStateHooks<THero>>
): IHookController<IEquipmentStateHooks<THero>> {
return new HookController(this, hook);
}
/**
* 从装备属性重新生成装备修饰器
* 触发修饰器变动钩子
*/
private rebuildModifiers() {
this.modifiers.length = 0;
for (const [name, baseVal] of this.value) {
this.modifiers.push([name, new ValueModifier(baseVal)]);
}
for (const [name, basePct] of this.percentage) {
this.modifiers.push([name, new PercentageModifier(basePct)]);
}
private notifyModifierChange() {
this.forEachHook(hook => hook.onChangeModifier?.(this.getModifiers()));
}
getModifiers(): Iterable<
[keyof SelectType<THero, number>, IHeroModifier<number>]
@shouldReplay('Setting equipment value should be replayed.')
setValue(name: keyof SelectType<THero, number>, value: number): void {
if (value === 0) {
this.value.delete(name);
this.valueModifiers.delete(name);
} else {
this.value.set(name, value);
const modifier = this.valueModifiers.getOrInsertComputed(
name,
() => new ValueModifier(value, 0)
);
modifier.setValue(value);
}
this.notifyModifierChange();
}
@shouldReplay('Setting equipment percentage value should be replayed.')
setPercentage(name: keyof SelectType<THero, number>, value: number): void {
if (value === 0) {
this.per.delete(name);
this.perModifiers.delete(name);
} else {
this.per.set(name, value);
const modifier = this.perModifiers.getOrInsertComputed(
name,
() => new PercentageModifier(value, 10)
);
modifier.setValue(value);
}
this.notifyModifierChange();
}
getValue(name: keyof SelectType<THero, number>): number {
return this.value.get(name) ?? 0;
}
getPercentage(name: keyof SelectType<THero, number>): number {
return this.per.get(name) ?? 0;
}
buildModifiers() {
if (this.built) return;
this.built = true;
this.valueModifiers.clear();
this.perModifiers.clear();
for (const [name, baseVal] of this.value) {
this.valueModifiers.set(name, new ValueModifier(baseVal, 0));
}
for (const [name, basePct] of this.per) {
this.perModifiers.set(name, new PercentageModifier(basePct, 10));
}
this.notifyModifierChange();
}
*getModifiers(): Iterable<
[SelectKey<THero, number>, IHeroModifier<number>]
> {
return this.modifiers;
if (!this.built) {
this.buildModifiers();
}
yield* this.valueModifiers;
yield* this.perModifiers;
}
/**
@ -76,7 +149,7 @@ export class EquipmentState<THero> implements IEquipmentState<THero> {
uid: this.uid,
num: this.item.num,
value: new Map(this.value),
percentage: new Map(this.percentage)
percentage: new Map(this.per)
};
}
@ -94,7 +167,7 @@ export class EquipmentState<THero> implements IEquipmentState<THero> {
}
}
const perDiff = new Map<SelectKey<THero, number>, number>();
for (const [name, equipPer] of this.percentage) {
for (const [name, equipPer] of this.per) {
const base = percentage[name];
if (base !== equipPer) {
perDiff.set(name, equipPer);
@ -122,14 +195,14 @@ export class EquipmentState<THero> implements IEquipmentState<THero> {
*/
private loadNoCompression(state: IEquipmentStateSave<THero>): void {
this.value.clear();
this.percentage.clear();
this.per.clear();
for (const [name, value] of state.value) {
this.value.set(name, value);
}
for (const [name, value] of state.percentage) {
this.percentage.set(name, value);
this.per.set(name, value);
}
this.rebuildModifiers();
this.buildModifiers();
}
/**
@ -138,7 +211,7 @@ export class EquipmentState<THero> implements IEquipmentState<THero> {
*/
private loadDiff(state: IEquipmentStateSave<THero>): void {
this.value.clear();
this.percentage.clear();
this.per.clear();
// 基准为装备原始定义,再叠加存档中的差异条目
for (const [name, value] of Object.entries<number>(
@ -149,7 +222,7 @@ export class EquipmentState<THero> implements IEquipmentState<THero> {
for (const [name, value] of Object.entries<number>(
this.item.equip.percentage
)) {
this.percentage.set(name as SelectKey<THero, number>, value);
this.per.set(name as SelectKey<THero, number>, value);
}
// 差异内容
@ -157,10 +230,10 @@ export class EquipmentState<THero> implements IEquipmentState<THero> {
this.value.set(name, value);
}
for (const [name, value] of state.percentage) {
this.percentage.set(name, value);
this.per.set(name, value);
}
this.rebuildModifiers();
this.buildModifiers();
}
loadState(
@ -192,6 +265,7 @@ export class HeroEquipsStore<THero> implements IHeroEquipsStore<THero> {
this.state = state;
}
@shouldReplay('Adding equipment to equip store should be replayed.')
add(item: number | string): number {
const num = this.state.tileStore.num(item);
if (isNil(num)) return -1;
@ -202,9 +276,11 @@ export class HeroEquipsStore<THero> implements IHeroEquipsStore<THero> {
const uid = this.nextUid++;
const state = new EquipmentState<THero>(uid, raw);
this.instanceMap.set(uid, state);
state.buildModifiers();
return uid;
}
@shouldReplay('Deleting equipment from equip store should be replayed.')
delete(uid: number): void {
this.instanceMap.delete(uid);
}
@ -279,34 +355,16 @@ export class HeroEquipsStore<THero> implements IHeroEquipsStore<THero> {
state: IHeroEquipsStoreSave<THero>,
compression: SaveCompression
): void {
// 按 uid 复用现有实例原地读档,使外部持有的引用跨读档仍然有效;
// 仅当 uid 相同但装备图块数字不同时才替换实例
const savedUids = new Set<number>();
this.instanceMap.clear();
for (const save of state.equipments) {
const raw = this.state.itemStore.getData(save.num);
if (!raw) {
logger.error(59, save.num.toString());
continue;
}
savedUids.add(save.uid);
const existing = this.instanceMap.get(save.uid);
if (existing && existing.item.num === save.num) {
existing.loadState(save, compression);
} else {
const instance = new EquipmentState<THero>(
save.uid,
raw,
false
);
instance.loadState(save, compression);
this.instanceMap.set(save.uid, instance);
}
}
// 以存档为准:存档中不存在的装备实例一律删除
for (const uid of this.instanceMap.keys()) {
if (!savedUids.has(uid)) {
this.instanceMap.delete(uid);
}
const ins = new EquipmentState<THero>(save.uid, raw);
ins.loadState(save, compression);
this.instanceMap.set(save.uid, ins);
}
if (state.equipments.length === 0) {
this.nextUid = 0;
@ -314,9 +372,10 @@ export class HeroEquipsStore<THero> implements IHeroEquipsStore<THero> {
const maxUid = maxBy(state.equipments, 'uid');
if (!maxUid) {
logger.error(58);
return;
this.nextUid = 0;
} else {
this.nextUid = maxUid.uid + 1;
}
this.nextUid = maxUid.uid + 1;
}
}
}

View File

@ -1,5 +1,5 @@
import { isNil } from 'lodash-es';
import { IDataCommon, ReplayCode } from '@user/data-common';
import { IDataCommon, ReplayCode, shouldReplay } from '@user/data-common';
import {
EquipStatus,
IEquipmentState,
@ -150,6 +150,7 @@ export class HeroEquipment<THero> implements IHeroEquipment<THero> {
}
}
@shouldReplay('Equiping equipment should be replayed.')
equip(
uid: number,
slot: number | string,
@ -197,6 +198,7 @@ export class HeroEquipment<THero> implements IHeroEquipment<THero> {
return curr;
}
@shouldReplay('Unequiping equipment should be replayed.')
unequip(slot: number): number | undefined {
const uid = this.equips.get(slot);
if (isNil(uid)) return void 0;

View File

@ -1,5 +1,43 @@
import { BaseHeroModifier } from './attribute';
import { IHeroModifier } from './types';
import { SaveCompression } from '@user/data-common';
import { IHeroModifier, IHeroModifierOwner } from './types';
export abstract class BaseHeroModifier<T, V> implements IHeroModifier<T, V, V> {
abstract readonly type: string;
abstract readonly priority: number;
owner: IHeroModifierOwner | null = null;
constructor(private currentValue: V) {}
get value(): V {
return this.currentValue;
}
setValue(value: V): void {
this.currentValue = value;
this.owner?.markModifierDirty(this);
}
getValue(): V {
return this.currentValue;
}
bindAttribute(attribute: IHeroModifierOwner | null): void {
this.owner = attribute;
}
saveState(_compression: SaveCompression): V {
return this.currentValue;
}
loadState(state: V, _compression: SaveCompression): void {
this.setValue(state);
}
abstract modify(value: T, baseValue: T, name: string): T;
abstract clone(): IHeroModifier<T, V>;
}
export class ValueModifier extends BaseHeroModifier<number, number> {
readonly type = '@system/value';

View File

@ -662,18 +662,56 @@ export interface IHeroEquipsStoreSave<THero> {
readonly equipments: readonly IEquipmentStateSave<THero>[];
}
/**
* 装备实例。其修饰器由装备自身加成重建而来,是派生视图:
* 跨读档不保留旧的修饰器对象引用属预期行为,读档后它们会被重新创建
*/
export interface IEquipmentState<THero> extends ISaveableContent<
IEquipmentStateSave<THero>
> {
export interface IEquipmentStateHooks<THero> extends IHookBase {
/**
* 当装备的修饰器发生变化时执行,当发生修饰器增加、减少、数值修改时都会触发
* @param modifiers 当前装备所拥有的所有修饰器
*/
onChangeModifier?(
modifiers: Iterable<[SelectKey<THero, number>, IHeroModifier<number>]>
): void;
}
export interface IEquipmentState<THero>
extends
ISaveableContent<IEquipmentStateSave<THero>>,
IHookable<IEquipmentStateHooks<THero>> {
/** 装备实例 uid */
readonly uid: number;
/** 装备的定义数据引用 */
readonly item: IItemRawData<THero>;
/**
* 设置此装备对指定勇士属性值的增加量
* @param name 勇士属性名
* @param value 要将装备的此属性增量设置为的值
*/
setValue(name: SelectKey<THero, number>, value: number): void;
/**
* 设置此装备对指定勇士属性值的百分比增加量
* @param name 勇士属性名
* @param value 要将装备的此属性百分比增量设置为的值
*/
setPercentage(name: SelectKey<THero, number>, value: number): void;
/**
* 获取装备对指定勇士属性值的增加量,若装备不增加对应属性,则返回 `0`
* @param name 勇士属性名
*/
getValue(name: SelectKey<THero, number>): number;
/**
* 获取装备对指定勇士属性值的百分比增加量,若装备不增加对应属性,则返回 `0`
* @param name 勇士属性名
*/
getPercentage(name: SelectKey<THero, number>): number;
/**
* 构建所有的修饰器,每个装备实例只需要进行一次构建
*/
buildModifiers(): void;
/**
* 获取装备产生的所有修饰器,每个元素为 [属性名, 修饰器]
*/

View File

@ -18,7 +18,7 @@ interface IReplaySafetyDetailQueue {
type ReplayMethod<This, Args extends unknown[], Return> = (
this: This,
...args: Args
) => Return;
) => Return | any;
type ReplayDecorator = <This, Args extends unknown[], Return>(
method: ReplayMethod<This, Args, Return>,