# 新增勇士属性 勇士属性不只是数据表里多一列。一个属性会同时被默认值、内容配置、修饰器、伤害计算、装备与界面读取;只在接口里加字段,读取处会拿到 `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` 等读取处 | 让属性真正影响伤害或其他运算 | | 显示 | 编辑器与渲染端 | 编辑器属性项、状态栏显示(本篇不实现) | 前四处缺一不可。接口决定编译期可见性,默认值与内容配置决定运行期取值,计算接入决定属性是否真的起作用。显示可以后补,但数据端必须先跑通。 中级教程的[勇士属性](../../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` 是属性对象的完整形状。接口增加字段后,这里必须同步补齐: ```ts /** 勇士的初始属性,数值填多少目前都无所谓,因为最终会从旧样板读取,但是必须得填 */ 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`,由 `state.hero.createAttribute(...)` 直接交给 `HeroAttribute` 构造。它不会与默认值合并,缺键就是缺键: ```jsonc "attribute": { "name": "勇士", "level": 0, "hp": 10000, "atk": 100, "def": 100, "mdef": 0, "mana": 0, "manamax": 0, "money": 0, "exp": 0, // 攻速以百分数存储:100 是正常速度 "atkSpeed": 100, // 护甲同样以百分数存储:0 是不减伤 "armor": 0 } ``` `core.jsonc` 允许注释,但这两项不是可选项。若只在接口与默认值里加、忘了内容配置,`getBaseAttribute('atkSpeed')` 会返回 `undefined`,`atkSpeed / 100` 随之变成 `NaN`,伤害与回合数都会失真。 ## 读取与修饰 属性对象有两套读取方式: - `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` 之间,避免负伤害或反向治疗。 顺序不能反:攻速要在算回合数之前生效,护甲要在最终伤害上生效。下面保留与这两处改动相关的分支,其余特殊属性逻辑按原版保留: ```ts import { IEnemyAttr, IHeroAttr } from '@user/data-common'; import { CriticalableHeroStatus, IDamageCalculator, IEnemyDamageInfoBase, IReadonlyEnemyHandler } from '@user/data-system'; /** 带攻速与护甲的勇士伤害计算器。 */ export class MainDamageCalculator implements IDamageCalculator< IEnemyAttr, IHeroAttr > { calculate( handler: IReadonlyEnemyHandler ): IEnemyDamageInfoBase { const { enemy, hero } = handler; const atk = hero.getFinalAttribute('atk'); const def = hero.getFinalAttribute('def'); const monAtk = enemy.getAttribute('atk'); const monDef = enemy.getAttribute('def'); const monHp = enemy.getAttribute('hp'); // [需改] 攻速缩放勇士的每回合伤害,必须发生在计算回合数之前 const atkSpeed = hero.getFinalAttribute('atkSpeed'); const heroPerDamage = ((atk - monDef) * atkSpeed) / 100; if (heroPerDamage <= 0) { return { damage: Infinity, turn: 0 }; } // 回合数随每回合伤害同步变化 const turn = Math.ceil(monHp / heroPerDamage); let enemyPerDamage = monAtk - def; if (enemyPerDamage < 0) enemyPerDamage = 0; // 此处保留原版的吸血、支援、先攻、破甲、反击、魔防等分支 let damage = (turn - 1) * enemyPerDamage; // [需改] 护甲是最终减伤,先夹到 0 到 100,再换算成 0 到 1 的系数 const armor = Math.min( Math.max(hero.getFinalAttribute('armor'), 0), 100 ); damage *= 1 - armor / 100; // 此处保留固伤、仇恨、自爆等分支,它们按原版顺序排在护甲减伤之后 return { damage: Math.floor(damage), turn }; } getCriticalLimit( handler: IReadonlyEnemyHandler, attribute: CriticalableHeroStatus ): number { return handler.hero.getFinalAttribute(attribute); } } ``` 原来的 `MainDamageCalculator` 还有支援、先攻、破甲、反击、净化、固伤、自爆等分支;它们的相对位置不变,只是每回合伤害与最终伤害各多了一次缩放。改动时不要把护甲乘进 `enemyPerDamage`:那样只减免每回合伤害,先攻、破甲等一次性伤害会漏掉护甲。 `getCriticalLimit` 也要跟着新属性考虑。它决定临界计算的搜索上界;自定义属性若参与伤害,至少要让读取该属性的方式与计算器一致,避免界面显示一个值、战斗按另一个值结算。 ## 与装备和道具的关系 装备的 `value` 与 `percentage` 是按属性名索引的记录,新属性不需要额外注册。只要 `IHeroAttr` 里有这个数值字段,装备定义就能直接写: ```ts import { IHeroAttr, IItemEquipData } from '@user/data-common'; /** 一件提供 +15% 攻速的武器。 */ export const attackSpeedEquip: IItemEquipData = { slots: ['weapon'], // 动画 id 由内容层定义,这里换成你自己的装备动画 animate: 'equip', // 数值加成:攻速只走百分比,不额外加固定值 value: { atkSpeed: 0 }, // 百分比加成:沿用装备系统的约定,0.15 表示 15% percentage: { atkSpeed: 0.15 }, // 这两个字段目前只是数据,引擎不会自动执行 loadEvent: null, unloadEvent: null }; ``` 装备实例会把这些记录转成 `ValueModifier` 与 `PercentageModifier`,穿戴时挂到勇士属性,卸下时移除。基础攻速 `100` 配上 `percentage.atkSpeed = 0.15`,最终攻速就是 `115`。注意属性本身按百分数存储(`15` 表示 15%),而装备的 `percentage` 是倍率(`0.15` 表示 15%),两者写法不同。 道具效果若要直接改攻速,也可以调用 `state.hero.getModifiableAttribute().add('atkSpeed', value)` 或挂修饰器;道具效果属于内容层,这里不额外展开。 ## 编辑器与状态栏 编辑器需要在英雄属性面板增加攻速与护甲两个输入项;状态栏需要在合适的位置显示它们的当前值。两处都属于对应端的实现,不在本篇范围内: - `编辑器 · 新增英雄属性项` - `渲染端 · 状态栏` 数据端完成后,这两个入口只需要读取 `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` 与 `HERO_DEFAULT_ATTRIBUTE` 是否都补了键;若装备穿上后数值不变,检查装备定义的键名是否与接口字段完全一致。 ## 边界与注意 - **所有创建英雄属性的位置都要补键**:默认值、内容配置、测试夹具各一处。`Partial` 不会报缺键,但运行时仍会读到 `undefined`。 - **内容配置不会与默认值合并**:`createAttribute` 直接使用传入对象,缺键就是 `undefined`;不要指望默认值兜底。 - **百分数属性统一用百分数存储**:`15` 表示 15%,计算时再除以 `100`。装备的 `percentage` 则是倍率,`0.15` 表示 15%。 - **不要把新属性塞进既有汇总字段**:例如不要把攻速加进 `money` 或 `exp` 的统计逻辑,读这些字段的系统并不知道新属性的语义。 - **新增字段会影响存档结构**:读旧档时缺失的键不会自动补齐,需要在使用前给默认值;写入新档后,旧版本读到多出的键通常可忽略,但不能假设旧版本认识它。 - **`save = true` 的修饰器要注册类型**:读档按 `type` 查构造器,未注册的修饰器会被静默跳过,数值看起来「读档后消失了」。 - **护甲要夹在有效区间**:大于 `100` 会变成治疗,负数会放大伤害;在计算器入口统一夹一次。 - **攻速改动会影响回合数**:凡是依赖 `turn` 的逻辑(支援、先攻、状态持续时间)都要跟着复核,不能只改伤害数字。 - **编辑器与状态栏尚未接入**:在对应端实现之前,不要假设属性会自动出现在界面上。 渲染端如何展示这两个属性不在本篇范围内。