Files
template/docs/advanced/ii/adding-hero-attribute.md
T

305 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 新增勇士属性
勇士属性不只是数据表里多一列。一个属性会同时被默认值、内容配置、修饰器、伤害计算、装备与界面读取;只在接口里加字段,读取处会拿到 `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<IEnemyAttr, IHeroAttr>
): 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<IEnemyAttr, IHeroAttr>,
attribute: CriticalableHeroStatus<IHeroAttr>
): 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<IHeroAttr> = {
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)` 或挂修饰器;道具效果属于内容层,这里不额外展开。
## 编辑器与状态栏
编辑器需要在英雄属性面板增加攻速与护甲两个输入项;状态栏需要在合适的位置显示它们的当前值。两处都属于对应端的实现,不在本篇范围内:
- `编辑器 · 新增英雄属性项`
- `渲染端 · 状态栏`
<!-- TODO(advanced-II): 编辑器属性项与状态栏显示 -->
数据端完成后,这两个入口只需要读取 `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<IEnemyAttr>
): 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<IHeroAttr>` 不会报缺键,但运行时仍会读到 `undefined`。
- **内容配置不会与默认值合并**:`createAttribute` 直接使用传入对象,缺键就是 `undefined`;不要指望默认值兜底。
- **百分数属性统一用百分数存储**:`15` 表示 15%,计算时再除以 `100`。装备的 `percentage` 则是倍率,`0.15` 表示 15%。
- **不要把新属性塞进既有汇总字段**:例如不要把攻速加进 `money` 或 `exp` 的统计逻辑,读这些字段的系统并不知道新属性的语义。
- **新增字段会影响存档结构**:读旧档时缺失的键不会自动补齐,需要在使用前给默认值;写入新档后,旧版本读到多出的键通常可忽略,但不能假设旧版本认识它。
- **`save = true` 的修饰器要注册类型**:读档按 `type` 查构造器,未注册的修饰器会被静默跳过,数值看起来「读档后消失了」。
- **护甲要夹在有效区间**:大于 `100` 会变成治疗,负数会放大伤害;在计算器入口统一夹一次。
- **攻速改动会影响回合数**:凡是依赖 `turn` 的逻辑(支援、先攻、状态持续时间)都要跟着复核,不能只改伤害数字。
- **编辑器与状态栏尚未接入**:在对应端实现之前,不要假设属性会自动出现在界面上。
渲染端如何展示这两个属性不在本篇范围内。 <!-- TODO(renderer): 攻速与护甲的状态栏显示 -->