# 新增勇士属性 勇士属性不只是数据表里多一列。一个属性会同时被类型常量、内容配置、修饰器、伤害计算、装备与界面读取;只在接口里加字段,读取处会拿到 `undefined`,参与运算后变成 `NaN`。新增属性真正要补的,是这条链路上的每一处。 本篇以攻速(`atkSpeed`,每回合造成的伤害按百分比缩放)与护甲(`armor`,按百分比减免最终伤害)为例,走完「改接口 → 补默认值与初始值 → 读取与修饰 → 接入伤害计算」的数据端流程。编辑器负责新增属性项与内容配置,状态栏负责显示;这些都属于对应端的实现,需要编辑器的部分只留占位。 ## 新增属性要改哪五处 | 位置 | 文件 | 要做什么 | | -------- | ---------------------------------------- | -------------------------------------- | | 类型 | `packages-user/data-common/src/types.ts` | 在 `IHeroAttr` 中增加字段 | | 默认值 | `packages-user/data-state/src/shared.ts` | 补齐 `HERO_DEFAULT_ATTRIBUTE` | | 初始值 | `src/content/core.jsonc` | 由编辑器补齐 `init.hero.attribute` | | 计算接入 | `src/data/enemy/calculator.ts` 等读取处 | 让属性真正影响伤害或其他运算 | | 显示 | 编辑器与渲染端 | 编辑器属性项、状态栏显示(本篇不实现) | 前四处缺一不可。接口决定编译期可见性,`core.jsonc` 决定运行期取值,计算接入决定属性是否真的起作用;`HERO_DEFAULT_ATTRIBUTE` 只保证类型形状完整,不是运行时兜底。显示可以后补,但数据端必须先跑通。 中级教程的[勇士属性](../../intermediate/hero-attribute.md)已经讲过基础属性、最终属性与修饰器的常用调用,这里补齐「新增一个属性」的完整改动面。 ## 改接口 `IHeroAttr` 位于 `packages-user/data-common/src/types.ts`。要给攻速与护甲留位置,就在接口中增加两个数值字段: ```ts export interface IHeroAttr { /** 勇士名称 */ name: string; /** 勇士等级 */ level: number; /** 勇士生命值 */ hp: number; /** 勇士生命值上限 */ hpmax: number; /** 勇士攻击力 */ atk: number; /** 勇士防御力 */ def: number; /** 勇士护盾 */ mdef: number; /** 勇士魔法值 */ mana: number; /** 勇士魔法上限 */ manamax: number; /** 勇士拥有的金币 */ money: number; /** 勇士拥有的经验 */ exp: number; /** 勇士攻速,百分比。100 表示正常速度,115 表示每回合造成 115% 伤害 */ atkSpeed: number; /** 勇士护甲,百分比减伤。15 表示受到的最终伤害降低 15% */ armor: number; } ``` 字段名一旦定下,就会被存档、内容配置、装备与插件引用,旧存档里也会以这个键保存数值。按项目的命名约定,新增公共字段要先确认名字再落笔;改名相当于改存档格式。 两个字段都参与伤害计算,因此都是 `number`。如果某个属性只用于展示、不参与数值运算,也仍然建议放在 `IHeroAttr` 之外,避免被存档与装备系统当成可修饰属性。 ## 补默认值与初始值 `HERO_DEFAULT_ATTRIBUTE` 是属性对象的完整形状,只用于类型完整与示例,不会进入运行时。接口增加字段后,这里必须同步补齐,否则 `IHeroAttr` 的类型检查会报缺键: ```ts /** * 勇士属性的完整默认形状。 * 注释里的「最终会从旧样板读取」是历史说法:当前运行时不会读取这个常量, * 它只用于保证接口字段在类型层面齐全;真正的新游戏初始值来自 core.jsonc。 */ export const HERO_DEFAULT_ATTRIBUTE: IHeroAttr = { name: '', level: 0, hp: 1, hpmax: 0, atk: 0, def: 0, mdef: 0, mana: 0, manamax: 0, money: 0, exp: 0, // 100 表示正常攻速,低于 100 变慢,高于 100 变快 atkSpeed: 100, // 0 表示没有减伤;15 表示减伤 15% armor: 0 }; ``` 实际进入游戏的初始值来自内容配置 `src/content/core.jsonc` 的 `init.hero.attribute`,由 `src/data/initialize.ts` 的 `afterLoad` 调用 `state.hero.createAttribute(coreConfig.init.hero.attribute)`,直接交给 `HeroAttribute` 构造。它不会与默认值合并,缺键就是缺键;`HERO_DEFAULT_ATTRIBUTE` 不会在运行时兜底。 > **需要编辑器**:在 `core.jsonc` 的 `init.hero.attribute` 中补齐 `atkSpeed` 与 `armor`。 这两项不是可选项。`core.jsonc` 的 `init.hero.attribute` 由编辑器维护,必须补齐这两个键。 若只在接口与默认值里加、忘了内容配置,`getBaseAttribute('atkSpeed')` 会返回 `undefined`,`atkSpeed / 100` 随之变成 `NaN`,伤害与回合数都会失真。 ### 旧存档迁移 新游戏只需要改内容配置;旧存档不会和它合并。`state.loadState(save, compression)` 会把 `@system/hero` 里的 `attribute.values` 原样交给 `HeroAttribute.loadState`,缺的键不会被补。需要兼容旧档时,**建议实现一个 `ISaveableExecutor` 并绑定到 `@system/hero`**:它在读档完成后执行,正是为这类迁移准备的。 ```ts import { IHeroAttr } from '@user/data-common'; import { IHeroState } from '@user/data-base'; import { ISaveableExecutor } from '@user/data-state'; /** * 读档后补齐旧档缺失的勇士属性键。 * Executor 在对应可存档对象读档完成后执行,不包在录像禁用范围内。 */ export class HeroAttributeSaveExecutor implements ISaveableExecutor< IHeroState > { afterLoad(data: IHeroState): void { const attribute = data.getModifiableAttribute(); const values = attribute.toStructured(); // 只在旧档确实缺键时补默认值;set 会顺带重算最终值 if (!Object.hasOwn(values, 'atkSpeed')) attribute.set('atkSpeed', 100); if (!Object.hasOwn(values, 'armor')) attribute.set('armor', 0); } } ``` 把执行器绑定在可存档对象注册之后(例如 `initialize` 的 `awake` 里): ```ts state.addSaveableContent('@system/hero', state.hero); state.bindSaveableExecuter('@system/hero', new HeroAttributeSaveExecutor()); ``` 读档后 `set` 会立刻重算最终值,不需要再手动刷新。新增属性需要存档迁移时,优先用 Executor,而不是在 `loadState` 前后手工改存档映射。 ## 读取与修饰 属性对象有两套读取方式: - `getBaseAttribute(name)` 读基础值,也就是存档里保存的数值; - `getFinalAttribute(name)` 读最终值,也就是基础值依次经过全部修饰器后的结果。 写基础值要用 `state.hero.getModifiableAttribute()`;只读场景用 `state.hero.getAttribute()`。直接改基础值会触发重算,最终值随之更新。 修饰器通过 `addModifier(name, modifier, save?)` 挂到某个属性上。内置的 `ValueModifier` 做数值加减,`PercentageModifier` 按基础值计算百分比: ```ts import { ICoreState } from '@user/data-state'; import { PercentageModifier } from '@user/data-base'; /** 给勇士挂一个临时的 +15% 攻速。 */ function addTemporaryAtkSpeed(state: ICoreState): void { const attribute = state.hero.getModifiableAttribute(); const modifier = new PercentageModifier(0.15); // 第三个参数为 false:这个修饰器不进属性存档,由拥有它的系统负责恢复 attribute.addModifier('atkSpeed', modifier, false); // 需要结束时用同一个修饰器对象移除 // attribute.deleteModifier('atkSpeed', modifier); } ``` `PercentageModifier` 的参数是倍率,`0.15` 表示基础值的 15%。基础攻速为 `100` 时,最终攻速是 `100 + 100 * 0.15 = 115`;它乘的是基础值,不会把其他修饰器的结果再乘一遍。 `save` 的取舍决定了读档后由谁恢复这个修饰器: - 装备产生的加成用 `false`。装备槽在读档时会重新穿戴,实例修饰器随之重挂;属性存档再存一份会重复计算。详见[勇士的装备系统](./equipment-system.md)。 - 由某个系统长期维护的加成,也建议用 `false`,让该系统在自己的读档流程里恢复,避免两套来源各恢复一次。 - 确实需要属性自己保存的修饰器用 `true`,但它的 `type` 必须已经通过 `registerModifier` 注册;读档时找不到构造器会静默跳过该修饰器。 ## 接入伤害计算 `IDamageCalculator.calculate(handler)` 接收只读的怪物与勇士信息,返回 `{ damage, turn }`:`damage` 是这场战斗勇士会损失的总生命,`turn` 是打完怪物需要的回合数。内置实现在 `src/data/enemy/calculator.ts`,由 `src/data/enemy/initialize.ts` 通过 `damageSystem.useCalculator(new MainDamageCalculator())` 注册。 攻速与护甲的接入点不同: - **攻速影响「每回合伤害」**。先算 `atk - monDef`,再乘 `atkSpeed / 100`,最后用缩放后的每回合伤害计算回合数。 - **护甲影响「最终伤害」**。所有回合伤害汇总后,再乘 `1 - armor / 100`,并把护甲夹在 `0` 到 `100` 之间,避免负伤害或反向治疗。 顺序不能反:攻速要在算回合数之前生效,护甲要在最终伤害上生效。下面给出的是**完整**的 `MainDamageCalculator`,吸血、支援、先攻、破甲、反击、净化、魔防、负伤归零、固伤、仇恨、自爆全部保留,只插入攻速与护甲两处: ```ts import { IEnemyAttr, IHeroAttr } from '@user/data-common'; import { CriticalableHeroStatus, IDamageCalculator, IEnemyDamageInfoBase, IReadonlyEnemyHandler } from '@user/data-system'; import { ISpecial9Value } from './special'; /** 带攻速与护甲的勇士伤害计算器。 */ export class MainDamageCalculator implements IDamageCalculator< IEnemyAttr, IHeroAttr > { /** 当前是否正在计算支援怪的伤害 */ private inGuard: boolean = false; calculate( handler: IReadonlyEnemyHandler ): IEnemyDamageInfoBase { const { enemy, locator, hero } = handler; const hp = hero.getBaseAttribute('hp'); const atk = hero.getFinalAttribute('atk'); const def = hero.getFinalAttribute('def'); // 支援中魔防只会被计算一次 const mdef = this.inGuard ? 0 : hero.getFinalAttribute('mdef'); const monAtk = enemy.getAttribute('atk'); const monDef = enemy.getAttribute('def'); let monHp = enemy.getAttribute('hp'); // 无敌 if ( enemy.hasSpecial(18) && handler.state.hero.items.itemCount('cross') < 1 ) { return { damage: Infinity, turn: 0 }; } let damage = 0; let heroPerDamage = 0; let enemyPerDamage = 0; // 攻速缩放勇士每回合伤害。 // 插入点就是原来的 `heroPerDamage += atk - monDef`: // 必须在 `turn = Math.ceil(monHp / heroPerDamage)` 之前, // 也要在下面的 `<= 0` 早退之前,否则攻速为 0 时会除出 Infinity。 const atkSpeed = hero.getFinalAttribute('atkSpeed'); heroPerDamage += ((atk - monDef) * atkSpeed) / 100; if (heroPerDamage <= 0) { return { damage: Infinity, turn: 0 }; } // 吸血 const vampire = enemy.getSpecial(9); if (vampire) { const value = (vampire.value.vampire / 100) * hp; damage += value; // 如果吸血加到自身 if (vampire.value.add) { monHp += value; } } // 魔攻 if (enemy.hasSpecial(2)) { enemyPerDamage = monAtk; } else { enemyPerDamage = monAtk - def; } // 连击 const multiHit = enemy.getSpecial(4); if (multiHit) { enemyPerDamage *= multiHit.value; } if (enemyPerDamage < 0) enemyPerDamage = 0; // 回合数必须使用缩放后的每回合伤害 let turn = Math.ceil(monHp / heroPerDamage); // 支援 const guards = enemy.getAttribute('guard'); if (guards.size > 0 && !this.inGuard) { this.inGuard = true; // 打支援怪时当前怪物也会打你,因此回合数与伤害都要累加 for (const view of guards) { const extraInfo = this.calculate({ enemy: view.getComputedEnemy(), context: handler.context, locator, hero, state: handler.state }); turn += extraInfo.turn; damage += extraInfo.damage; } this.inGuard = false; } // 先攻 if (enemy.hasSpecial(1)) { damage += enemyPerDamage; } // 破甲 const breakArmor = enemy.getSpecial(5); if (breakArmor) { damage += (breakArmor.value / 100) * def; } // 反击 const counterAttack = enemy.getSpecial(6); if (counterAttack) { // 反击是每回合生效,因此加到 enemyPerDamage 上 enemyPerDamage += (counterAttack.value / 100) * atk; } // 净化 const purify = enemy.getSpecial(7); if (purify) { damage += purify.value * mdef; } damage += (turn - 1) * enemyPerDamage; // 魔防 damage -= mdef; // 未开启负伤时,如果伤害为负,则设为 0 if ( !handler.state.flags.getFieldValueDefaults( 'enableNegativeDamage', false ) && damage < 0 ) { damage = 0; } // 固伤,无法被魔防减伤 const fixedDamage = enemy.getSpecial(20); if (fixedDamage) { damage += fixedDamage.value; } // 仇恨,无法被魔防减伤 if (enemy.hasSpecial(15)) { damage += handler.state.flags.getFieldValueDefaults('hatred', 0); } // 护甲按最终伤害百分比减伤。 // 放在魔防、负伤归零、固伤、仇恨之后,因此这些部分都会被护甲缩放; // 放在自爆之前:自爆只把低于 hp - 1 的伤害抬到下限,下限本身不缩放。 // 如果你希望固伤/仇恨绕过护甲,把这段移到固伤之前。 const armor = Math.min( Math.max(hero.getFinalAttribute('armor'), 0), 100 ); damage *= 1 - armor / 100; // 自爆 if (enemy.hasSpecial(17)) { damage = Math.max(damage, hp - 1); } return { damage: Math.floor(damage), turn }; } getCriticalLimit( handler: IReadonlyEnemyHandler, attribute: CriticalableHeroStatus ): number { switch (attribute) { case 'atk': { if (handler.enemy.hasSpecial(3)) { return Infinity; } return ( handler.enemy.getAttribute('def') + handler.enemy.getAttribute('hp') ); } } return handler.hero.getFinalAttribute(attribute); } } ``` 两处插入点的含义: - **攻速**:替换的是「每回合伤害」的初值,位置在吸血、连击、支援之前,但仍在 `turn` 计算与 `<= 0` 早退之前。不能等 `turn` 算完再乘——那样回合数不会变,只是把总伤害整体缩小。 - **护甲**:放在所有伤害分量汇总之后,缩放的是最终伤害。魔防已经先减掉,固伤与仇恨随后加上,所以它们也会被护甲缩放;自爆在其后执行 `damage = max(damage, hp - 1)`。注意自爆只保证 `hp - 1` 这个**下限**不被护甲缩放:护甲仍会先缩放之前的伤害,只有缩放后低于下限时才被抬回;缩放后仍高于下限时不会恢复。因此不能说「自爆完全不受护甲影响」。不要把护甲乘进 `enemyPerDamage`:那样只减免每回合伤害,先攻、破甲、净化、固伤等一次性伤害会漏掉护甲。 `getCriticalLimit` 也要跟着新属性考虑。它决定临界计算的搜索上界;自定义属性若参与伤害,至少要让读取该属性的方式与计算器一致,避免界面显示一个值、战斗按另一个值结算。 ## 与装备和道具的关系 装备的 `value` 与 `percentage` 是按属性名索引的记录,新属性不需要额外注册。只要 `IHeroAttr` 里有这个数值字段,装备定义就能直接配置(`category: 4` 即 `ItemCategory.Equipment`)。 > **需要编辑器**:在装备内容里把 `percentage.atkSpeed` 设为 `0.15`。 装备实例会把这些记录转成 `ValueModifier` 与 `PercentageModifier`,穿戴时挂到勇士属性,卸下时移除。基础攻速 `100` 配上 `percentage.atkSpeed = 0.15`,最终攻速就是 `115`。注意属性本身按百分数存储(`15` 表示 15%),而装备的 `percentage` 是倍率(`0.15` 表示 15%),两者写法不同。 道具效果若要直接改攻速,也可以调用 `state.hero.getModifiableAttribute().add('atkSpeed', value)` 或挂修饰器;道具效果属于内容层,这里不额外展开。 ## 编辑器与状态栏 编辑器需要在英雄属性面板增加攻速与护甲两个输入项,并负责维护 `core.jsonc` 里的内容配置;状态栏需要在合适的位置显示它们的当前值。这些都属于对应端的实现,不在本篇范围内: - `需要编辑器 · 新增英雄属性项` - `需要编辑器 · 内容配置` - `渲染端 · 状态栏` 数据端完成后,编辑器与状态栏只需要读取 `getFinalAttribute('atkSpeed')` 与 `getFinalAttribute('armor')`。 ## 怎么验证 先准备一只属性固定的怪物,避免光环、支援或随机因素干扰。在编辑器里把内容配置的 `atkSpeed` 设为 `100`、`armor` 设为 `0`,读取最终属性,再穿上一件 +15% 攻速的装备,最后观察伤害变化: ```ts import { IEnemyAttr } from '@user/data-common'; import { ICoreState } from '@user/data-state'; import { IEnemyView } from '@user/data-system'; /** 打印攻速、护甲与对指定怪物的伤害。 */ function verifyHeroAttribute( state: ICoreState, enemy: IEnemyView ): void { const attribute = state.hero.getAttribute(); console.log('攻速', attribute.getFinalAttribute('atkSpeed')); console.log('护甲', attribute.getFinalAttribute('armor')); const damageSystem = state.enemyContext.getDamageSystem(); if (!damageSystem) return; const info = damageSystem.getDamageInfo(enemy); if (!info) return; console.log('伤害', info.damage, '回合', info.turn); } ``` 预期现象: - 初始攻速为 `100`、护甲为 `0`; - 穿上 +15% 攻速的装备后,最终攻速变为 `115`,同一只怪物的战斗回合数下降; - 把护甲调高后,`damage` 随护甲上升而下降,`turn` 不变。 若最终攻速是 `NaN`,先检查编辑器里 `core.jsonc` 的 `init.hero.attribute` 是否补了键;`HERO_DEFAULT_ATTRIBUTE` 只影响类型检查,补了它也不会在运行时兜底。若装备穿上后数值不变,检查装备定义的键名是否与接口字段完全一致。 ## 边界与注意 - **所有创建英雄属性的位置都要补键**:类型常量(`HERO_DEFAULT_ATTRIBUTE`)、内容配置、测试夹具各一处。`Partial` 不会报缺键,但运行时仍会读到 `undefined`。 - **内容配置不会与默认值合并**:`createAttribute` 直接使用传入对象,缺键就是 `undefined`;不要指望默认值兜底。 - **百分数属性统一用百分数存储**:`15` 表示 15%,计算时再除以 `100`。装备的 `percentage` 则是倍率,`0.15` 表示 15%。 - **不要把新属性塞进既有汇总字段**:例如不要把攻速加进 `money` 或 `exp` 的统计逻辑,读这些字段的系统并不知道新属性的语义。 - **新增字段会影响存档结构**:读旧档时缺失的键不会自动补齐。迁移入口是在调用 `state.loadState(save, compression)` 之前修改 `@system/hero` 的 `attribute.values`(见上文「旧存档迁移」),不要指望 `core.jsonc` 或 `HERO_DEFAULT_ATTRIBUTE` 兜底;写入新档后,旧版本读到多出的键通常可忽略,但不能假设旧版本认识它。 - **`save = true` 的修饰器要注册类型**:读档按 `type` 查构造器,未注册的修饰器会被静默跳过,数值看起来「读档后消失了」。 - **护甲要夹在有效区间**:计算器先把护甲夹到 `0`–`100`;夹紧后 `≥ 100` 时最终伤害为 `0`,不是治疗,负值会被夹到 `0`,不会放大伤害。 - **攻速改动会影响回合数**:凡是依赖 `turn` 的逻辑(支援、先攻、状态持续时间)都要跟着复核,不能只改伤害数字。 - **编辑器与状态栏读取最终属性**:它们不属于本篇的数据端流程。 渲染端如何展示这两个属性不在本篇范围内。 返回[高级 II 导览](./index.md)。