mirror of
https://github.com/motajs/template.git
synced 2026-10-22 07:10:17 +08:00
docs(10-24): 编写高级 II 新增怪物属性
This commit is contained in:
parent
4f48ad5aa1
commit
ed7e174a87
395
docs/advanced/ii/adding-enemy-attribute.md
Normal file
395
docs/advanced/ii/adding-enemy-attribute.md
Normal file
@ -0,0 +1,395 @@
|
||||
# 新增怪物属性
|
||||
|
||||
怪物属性和勇士属性一样,不只是数据表里多一列。属性对象要在接口、默认模板、内容数据与伤害计算之间保持一致;只在接口里加字段,读取处会拿到 `undefined`,参与运算后变成 `NaN`。新增属性真正要补的,是这条链路上的每一处。
|
||||
|
||||
本篇以攻速(`atkSpeed`,怪物每回合的伤害按百分比缩放)与护甲(`armor`,怪物受到的伤害按百分比减免)为例,走完「改接口 → 补数据 → 读取与修改 → 接入伤害、光环、查询与最终效果」的数据端流程。它与[新增勇士属性](./adding-hero-attribute.md)构成对照:勇士属性偏重修饰器与装备,怪物属性偏重上下文构建时的计算顺序。编辑器新增属性项与怪物图鉴属于对应端的实现,本篇只留占位。
|
||||
|
||||
## 怪物属性在哪里
|
||||
|
||||
`IEnemyAttr` 是所有怪物共享的属性形状,位于 `packages-user/data-common/src/types.ts`。它当前包含名称、生命、攻防、金币、经验、点数与支援来源:
|
||||
|
||||
```ts
|
||||
export interface IEnemyAttr {
|
||||
/** 怪物名称 */
|
||||
name: string;
|
||||
/** 怪物生命值 */
|
||||
hp: number;
|
||||
/** 怪物攻击力 */
|
||||
atk: number;
|
||||
/** 怪物防御力 */
|
||||
def: number;
|
||||
/** 怪物金币 */
|
||||
money: number;
|
||||
/** 怪物经验值 */
|
||||
exp: number;
|
||||
/** 怪物拥有的点数 */
|
||||
point: number;
|
||||
/** 支援来源,即周围的支援怪物列表 */
|
||||
guard: Set<IEnemyView<IEnemyAttr>>;
|
||||
}
|
||||
```
|
||||
|
||||
具体数值来自 `IEnemyRawData.attribute`:怪物模板由 `num`(图块数字)、`attribute`(属性对象)与 `special`(特殊属性)组成。默认模板写在 `src/content/defaults/enemy.jsonc`,实际内容写在 `src/content/enemy.jsonc`。内容文件里每个怪物条目都有一份自己的 `attribute`,不会与默认模板合并。
|
||||
|
||||
`guard` 与其他字段不同:它是运行时由支援光环写入的集合(`GuardAura.apply` 会把来源怪物视图 `add` 进去),不是内容文件里手填的普通数值。改接口时不要把它当成内容字段,也不要在 `enemy.jsonc` 里补它。
|
||||
|
||||
## 改接口
|
||||
|
||||
要给攻速与护甲留位置,就在 `IEnemyAttr` 中增加两个数值字段:
|
||||
|
||||
```ts
|
||||
export interface IEnemyAttr {
|
||||
// ...省略:name / hp / atk / def / money / exp / point / guard 原样保留
|
||||
|
||||
/** 怪物攻速,百分比。100 表示正常速度,120 表示每回合伤害提高 20% */
|
||||
atkSpeed: number;
|
||||
/** 怪物护甲,百分比减伤。20 表示怪物受到的伤害降低 20% */
|
||||
armor: number;
|
||||
}
|
||||
```
|
||||
|
||||
字段名会被存档、内容数据、光环与查询引用,旧存档里也会以这个键保存数值。按项目的命名约定,新增公共字段要先确认名字再落笔;改名相当于改存档格式。
|
||||
|
||||
两个字段都参与伤害计算,因此都是 `number`。如果某个属性只用于展示、不参与数值运算,建议不要放进 `IEnemyAttr`,避免被存档与光环系统当成可计算属性。
|
||||
|
||||
## 补数据
|
||||
|
||||
接口增加字段后,默认模板与每一条怪物内容都要补齐。默认模板 `src/content/defaults/enemy.jsonc` 的 `attribute` 是单个对象:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"num": "@required",
|
||||
"attribute": {
|
||||
"name": "新怪物",
|
||||
"hp": 0,
|
||||
"atk": 0,
|
||||
"def": 0,
|
||||
"money": 0,
|
||||
"exp": 0,
|
||||
"point": 0,
|
||||
// 攻速以百分数存储:100 是正常速度
|
||||
"atkSpeed": 100,
|
||||
// 护甲同样以百分数存储:0 是不减伤
|
||||
"armor": 0
|
||||
},
|
||||
"special": {}
|
||||
}
|
||||
```
|
||||
|
||||
`src/content/enemy.jsonc` 是一个数组,每个怪物条目也要补同样的两个键:
|
||||
|
||||
```jsonc
|
||||
[
|
||||
{
|
||||
"num": 130,
|
||||
"attribute": {
|
||||
"name": "怪王",
|
||||
"hp": 100,
|
||||
"atk": 120,
|
||||
"def": 0,
|
||||
"money": 10,
|
||||
"exp": 0,
|
||||
"point": 0,
|
||||
"atkSpeed": 100,
|
||||
"armor": 0
|
||||
},
|
||||
"special": {}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
内容对象不会与默认模板合并,缺键就是缺键。读取到 `undefined` 后,`armor / 100` 与 `atkSpeed / 100` 都会变成 `NaN`,伤害与回合数随之失真。新增属性后,最好全局搜索一遍 `"attribute"`,确认每个模板入口都补齐。
|
||||
|
||||
## 读取与修改
|
||||
|
||||
怪物对象有两套写入口,取决于你拿到的是可写对象还是视图:
|
||||
|
||||
- `Enemy.getAttribute(key)` 读取属性,`setAttribute(key, value)` 直接赋值,`addAttribute(key, delta)` 在现有数值上增减;
|
||||
- `IReadonlyEnemy` 只暴露 `getAttribute` 与 `cloneAttributes`,用于只读场景;
|
||||
- 从 `IEnemyView` 取可写对象要用 `getModifiableEnemy()`;它返回的是**原始怪物**,不是计算后怪物。
|
||||
|
||||
下面的例子把某只怪物的护甲设为 20,然后通知上下文重算:
|
||||
|
||||
```ts
|
||||
import { IEnemyAttr } from '@user/data-common';
|
||||
import { IEnemyView } from '@user/data-system';
|
||||
|
||||
/** 把指定怪物的护甲设为 20,并让上下文重新计算它。 */
|
||||
function setArmor(view: IEnemyView<IEnemyAttr>): void {
|
||||
// getModifiableEnemy() 返回原始怪物;修改不会自动同步到计算后怪物
|
||||
const enemy = view.getModifiableEnemy();
|
||||
enemy.setAttribute('armor', 20);
|
||||
|
||||
// 标记为脏,下次读取 getComputedEnemy() 时才会重算
|
||||
view.markDirty();
|
||||
}
|
||||
```
|
||||
|
||||
直接改原始怪物后必须 `markDirty()`。少了这一步,`getComputedEnemy()` 仍然返回旧结果,伤害也不会变。更完整的刷新时机见[怪物上下文与战斗深入](./enemy-context-and-combat.md)。
|
||||
|
||||
## 接入伤害计算
|
||||
|
||||
伤害计算器通过 `handler.enemy` 读取**计算后怪物**,因此光环、查询与最终效果写进去的属性都会在这里生效。攻速与护甲的接入点不同:
|
||||
|
||||
- **攻速影响怪物每回合伤害**:先算 `monAtk - def`,再乘 `atkSpeed / 100`。
|
||||
- **护甲影响怪物受到的伤害**:先算勇士每回合伤害 `atk - monDef`,再乘 `1 - armor / 100`;护甲越高,回合数越多,总伤害随之上升。
|
||||
|
||||
顺序不能反:护甲要在算回合数之前生效,攻速要在累加总伤害之前生效。下面保留与这两处改动相关的分支,其余特殊属性逻辑按原版保留:
|
||||
|
||||
```ts
|
||||
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
|
||||
import {
|
||||
CriticalableHeroStatus,
|
||||
IDamageCalculator,
|
||||
IEnemyDamageInfoBase,
|
||||
IReadonlyEnemyHandler
|
||||
} from '@user/data-system';
|
||||
|
||||
/** 带攻速与护甲的怪物伤害计算器骨架。 */
|
||||
export class EnemyAttributeCalculator 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');
|
||||
|
||||
// [需改] 护甲是怪物受到的百分比减伤,先夹到 0 到 100
|
||||
// 夹紧可以避免负伤害,也避免超过 100 后变成「反向治疗」
|
||||
const armor = Math.min(Math.max(enemy.getAttribute('armor'), 0), 100);
|
||||
// 勇士每回合伤害:先减怪物防御,再乘护甲系数
|
||||
const heroPerDamage = (atk - monDef) * (1 - armor / 100);
|
||||
if (heroPerDamage <= 0) {
|
||||
// 打不动怪物,战斗无法结束
|
||||
return { damage: Infinity, turn: 0 };
|
||||
}
|
||||
|
||||
// 回合数随每回合伤害同步变化:护甲越高,需要的回合越多
|
||||
const turn = Math.ceil(monHp / heroPerDamage);
|
||||
|
||||
// [需改] 攻速缩放怪物每回合伤害;100 表示正常速度
|
||||
// 用 Math.max 防止负数攻速把伤害变成治疗
|
||||
const atkSpeed = Math.max(enemy.getAttribute('atkSpeed'), 0);
|
||||
let enemyPerDamage = Math.max(monAtk - def, 0) * (atkSpeed / 100);
|
||||
|
||||
// ...此处按原版保留无敌、吸血、连击、支援、先攻、破甲、反击、净化等分支...
|
||||
|
||||
// 总伤害:勇士先手,怪物实际出手 turn - 1 次
|
||||
let damage = (turn - 1) * enemyPerDamage;
|
||||
|
||||
// ...此处按原版保留魔防、负伤归零、固伤、仇恨、自爆等分支...
|
||||
|
||||
return { damage: Math.floor(damage), turn };
|
||||
}
|
||||
|
||||
getCriticalLimit(
|
||||
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>,
|
||||
attribute: CriticalableHeroStatus<IHeroAttr>
|
||||
): number {
|
||||
// 临界计算的上界;自定义属性参与伤害时,读取方式要与 calculate 一致
|
||||
return handler.hero.getFinalAttribute(attribute);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
内置公式的全部段落见[伤害计算](../../intermediate/enemy-calculator.md)。这段骨架只保留攻速与护甲相关的分支;真正替换内置计算器时,无敌、吸血、支援、先攻、破甲、反击、净化、固伤、仇恨、自爆等分支都要按原顺序保留,否则这些机制会一起失效。
|
||||
|
||||
## 接入光环
|
||||
|
||||
光环视图 `IEnemyAuraView` 继承自 `IAuraView`,它的 `apply(handler, baseEnemy)` 在基础属性阶段执行,适合给范围内的怪物加护甲或攻速。`handler.enemy` 是计算后怪物,`baseEnemy` 是未修改的原始怪物:读原始值用后者,写结果用前者。
|
||||
|
||||
下面是一条全场 +10 护甲的基础光环。转换器与视图的完整写法见[怪物光环](../../intermediate/enemy-aura.md),这里只保留视图本身:
|
||||
|
||||
```ts
|
||||
import { FullRange, IRange } from '@motajs/common';
|
||||
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
|
||||
import { IReadonlyEnemy } from '@user/data-base';
|
||||
import {
|
||||
IEnemyAuraView,
|
||||
IEnemyHandler,
|
||||
IEnemySpecialModifier
|
||||
} from '@user/data-system';
|
||||
|
||||
/** 给范围内所有怪物增加 10 点护甲的基础光环。 */
|
||||
export class ArmorAura implements IEnemyAuraView<IEnemyAttr, void, void> {
|
||||
readonly priority = 10;
|
||||
readonly couldApplyBase = true;
|
||||
readonly couldApplySpecial = false;
|
||||
readonly range: IRange<void> = new FullRange();
|
||||
|
||||
getRangeParam(): void {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
apply(
|
||||
handler: IEnemyHandler<IEnemyAttr, IHeroAttr>,
|
||||
baseEnemy: IReadonlyEnemy<IEnemyAttr>
|
||||
): void {
|
||||
// [需改] 修改的是计算后怪物;光环的加成会进入伤害计算
|
||||
handler.enemy.addAttribute('armor', 10);
|
||||
|
||||
// 若光环要按目标原始值算百分比,应读取 baseEnemy,而不是 handler.enemy
|
||||
// 例如:baseEnemy.getAttribute('armor') * 0.1
|
||||
}
|
||||
|
||||
applySpecial(): IEnemySpecialModifier<IEnemyAttr> | null {
|
||||
// 只改基础属性,不增删特殊属性
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
光环修改的是计算后怪物,因此不需要你自己标脏:`buildup()` 结束时会统一清空伤害缓存;局部刷新也会在 `refreshEnemy` 末尾处理。**不要在光环里改 `baseEnemy`**,那会污染原始模板,并让后续重建从被改过的值出发。
|
||||
|
||||
## 接入查询
|
||||
|
||||
查询用于「读取外部状态,再改自己」。常规查询实现 `IEnemyCommonQueryEffect`,在 `apply(handler, special, query)` 里通过 `query()` 取得上下文,统计周围怪物后修改自身属性:
|
||||
|
||||
```ts
|
||||
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
|
||||
import { ISpecial } from '@user/data-base';
|
||||
import {
|
||||
IEnemyCommonQueryEffect,
|
||||
IEnemyHandler,
|
||||
IReadonlyEnemyContext
|
||||
} from '@user/data-system';
|
||||
|
||||
/** 周围每有一只同类,自身攻速 +5 的常规查询。 */
|
||||
export class PackAtkSpeedQuery implements IEnemyCommonQueryEffect<
|
||||
IEnemyAttr,
|
||||
IHeroAttr
|
||||
> {
|
||||
readonly priority = 10;
|
||||
|
||||
apply(
|
||||
handler: IEnemyHandler<IEnemyAttr, IHeroAttr>,
|
||||
special: ISpecial<any>,
|
||||
query: () => IReadonlyEnemyContext<IEnemyAttr, IHeroAttr>
|
||||
): void {
|
||||
// [需改] 必须通过 query() 取上下文:
|
||||
// 它会把「这只怪物查询过外部状态」登记下来,局部刷新时才会重算它
|
||||
const ctx = query();
|
||||
let count = 0;
|
||||
|
||||
for (const [locator, view] of ctx.iterateEnemy()) {
|
||||
// 跳过自己,只统计其他怪物
|
||||
if (
|
||||
locator.x === handler.locator.x &&
|
||||
locator.y === handler.locator.y
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (view.getComputedEnemy().hasSpecial(special.code)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
// [需改] 每有一个同类,攻速提高 5
|
||||
handler.enemy.addAttribute('atkSpeed', count * 5);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注册时把效果绑定到一个特殊属性 `code` 上:
|
||||
|
||||
```ts
|
||||
// [需改] code 换成触发这条查询的特殊属性编号(示例自选 40)
|
||||
state.enemyContext.registerCommonQueryEffect(40, new PackAtkSpeedQuery());
|
||||
```
|
||||
|
||||
查询效果需要 `registerCommonQueryEffect` 注册;引擎的 `src/data/enemy/initialize.ts` **没有注册任何查询实现**,不注册就不会执行。如果要增删或改写目标拥有的特殊属性,应使用特殊查询 `IEnemySpecialQueryEffect`,写法见[怪物查询](../../intermediate/enemy-query.md),本篇不重复。
|
||||
|
||||
## 接入最终效果
|
||||
|
||||
最终效果 `IEnemyFinalEffect.apply(handler)` 只影响自身,不读取外部上下文,适合做「最后再补一点护甲」这类收尾。`priority` 数值越大越先执行:
|
||||
|
||||
```ts
|
||||
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
|
||||
import { IEnemyFinalEffect, IEnemyHandler } from '@user/data-system';
|
||||
|
||||
/** 给自己补 5 点护甲的最终效果。 */
|
||||
export class ArmorFinalEffect implements IEnemyFinalEffect<
|
||||
IEnemyAttr,
|
||||
IHeroAttr
|
||||
> {
|
||||
readonly priority = 0;
|
||||
|
||||
apply(handler: IEnemyHandler<IEnemyAttr, IHeroAttr>): void {
|
||||
// [需改] 最终阶段只改自身基础属性,不再查询上下文
|
||||
handler.enemy.addAttribute('armor', 5);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注册方式:
|
||||
|
||||
```ts
|
||||
state.enemyContext.registerFinalEffect(new ArmorFinalEffect());
|
||||
```
|
||||
|
||||
内置的 `MainEnemyFinalEffect`(`src/data/enemy/final.ts`)已经在 `initializeEnemy` 里注册;自写效果是追加执行,不是替换。最终效果与光环、查询的时序关系见[怪物上下文与战斗深入](./enemy-context-and-combat.md)。
|
||||
|
||||
## 编辑器与图鉴
|
||||
|
||||
编辑器需要在怪物属性面板增加攻速与护甲两个输入项;怪物图鉴需要显示它们的当前值。两处都属于对应端的实现,不在本篇范围内:
|
||||
|
||||
- `编辑器 · 新增怪物属性项`
|
||||
- `怪物图鉴`
|
||||
|
||||
<!-- TODO(advanced-II): 编辑器属性项与怪物图鉴 -->
|
||||
|
||||
数据端完成后,这两个入口只需要读取计算后怪物的 `atkSpeed` 与 `armor`。在它们实现之前,先用控制台或调试脚本确认数值正确。
|
||||
|
||||
## 怎么验证
|
||||
|
||||
固定一只怪物与勇士属性,避免光环、支援或随机因素干扰。先把怪物 `armor` 设为 `50`、`atkSpeed` 设为 `100`,打印伤害;再把 `armor` 改为 `0`,确认伤害接近翻倍:
|
||||
|
||||
```ts
|
||||
import { IEnemyAttr } from '@user/data-common';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
import { IEnemyView } from '@user/data-system';
|
||||
|
||||
/** 打印指定怪物的护甲、攻速与伤害信息。 */
|
||||
function verifyEnemyAttribute(
|
||||
state: ICoreState,
|
||||
view: IEnemyView<IEnemyAttr>
|
||||
): void {
|
||||
const enemy = view.getComputedEnemy();
|
||||
console.log('护甲', enemy.getAttribute('armor'));
|
||||
console.log('攻速', enemy.getAttribute('atkSpeed'));
|
||||
|
||||
const damageSystem = state.enemyContext.getDamageSystem();
|
||||
if (!damageSystem) return;
|
||||
|
||||
const info = damageSystem.getDamageInfo(view);
|
||||
if (!info) return;
|
||||
|
||||
console.log('伤害', info.damage, '回合', info.turn);
|
||||
}
|
||||
```
|
||||
|
||||
预期现象:
|
||||
|
||||
- `armor` 从 `50` 改为 `0` 后,勇士每回合伤害恢复,回合数减半,总伤害大致翻倍;
|
||||
- 给该怪物加一条 +10 护甲的光环后,`armor` 变为 `60`,伤害再次下降;
|
||||
- `atkSpeed` 从 `100` 提到 `120` 后,怪物每回合伤害提高 20%,总伤害随之上升。
|
||||
|
||||
若最终值是 `NaN`,先检查接口、默认模板与内容条目是否都补了键;若光环加了护甲但伤害不变,确认 `buildup()` 是否已经执行,以及是否误改了原始怪物而没有标脏。
|
||||
|
||||
## 边界与注意
|
||||
|
||||
- **接口、默认模板、内容条目都要补键**:三处缺一不可。内容对象不会与默认模板合并,缺键就是 `undefined`。
|
||||
- **`guard` 不要写进内容**:它是运行时由支援光环维护的集合,手填会与运行时行为冲突。
|
||||
- **百分数统一用整数存储**:`20` 表示 20%,计算时再除以 `100`;不要一处存 `0.2`、另一处存 `20`。
|
||||
- **新增字段会进入怪物存档**:读旧档时缺失的键不会自动补齐,需要在使用前给默认值。
|
||||
- **光环、查询、最终效果改的都是计算后怪物**:刷新前不要把它当原始数据;也不要在这三个阶段改 `getBaseEnemy()`。
|
||||
- **直接改原始怪物要标脏**:`getModifiableEnemy()` 的修改不会自动同步,必须 `markDirty()` 后重新读取。
|
||||
- **护甲要夹在有效区间**:大于 `100` 会让怪物无法被击败,负数会放大伤害;在计算器入口统一夹一次。
|
||||
- **攻速改动会影响回合数**:凡是依赖 `turn` 的逻辑(支援、先攻、状态持续时间)都要跟着复核,不能只改伤害数字。
|
||||
- **编辑器与图鉴尚未接入**:在对应端实现之前,不要假设属性会自动出现在界面上。
|
||||
Loading…
Reference in New Issue
Block a user