Files
template/docs/advanced/ii/enemy-context-and-combat.md
T

270 lines
18 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.
# 怪物上下文与战斗深入
中级教程分别讲过光环、查询、最终效果与伤害计算,但它们并不是各算各的。所有结果都由同一个怪物上下文按固定顺序拼装出来;伤害系统再缓存这份结果,地图伤害也读同一份计算后怪物。理解这条装配线,才能回答「为什么我改了属性,伤害却没变」这类问题。
本篇是高级 II 数据端的收束:先看上下文与三种怪物对象,再走一遍 `buildup()` 的顺序、优先级边界与局部刷新,最后把伤害缓存和光环、查询、最终的交互串起来。各机制的接口细节不在这里重复,只讲时序与边界。
## 上下文是什么
`IEnemyContext` 是当前楼层所有怪物视图的容器。它持有宽高、楼层、坐标索引,以及绑定到它上面的伤害系统与地图伤害:
- `width` / `height`:上下文尺寸,由 `resize` 维护;
- `floor`:当前楼层,由 `setFloor` 设置;
- `indexer`:坐标与索引的换算工具;
- `scanRange(range, param)` / `iterateEnemy()`:按范围扫描或遍历全部怪物;
- `getEnemyByLoc(x, y)` / `getEnemyByLocator(locator)` / `getEnemyLocatorByView(view)`:按位置或视图互查;
- `getDamageSystem()` / `getMapDamage()`:取绑定到上下文的伤害系统与地图伤害。
上下文本身不进入存档。切楼层与地图尺寸变化由外部维护:`setFloor` 只改 `floor` 字段,`resize` 会清空当前怪物与运行状态,并让下一次 `buildup()` 重建。正常换图流程已经处理好这些,不需要自己手动刷新。`clear()` 只清空怪物与运行状态(以及无来源全局光环列表),保留转换器、查询、最终效果等注册项;`destroy()` 还会解绑伤害系统、地图伤害并清空所有注册项。
## 三种怪物对象
每个怪物视图都同时持有两份怪物对象,并对外暴露三种取法:
- `getBaseEnemy()`:原始模板,只读。它来自怪物数据,是所有计算的起点。
- `getComputedEnemy()`:计算后怪物,只读。光环、查询、最终效果的结果都写在这份副本上;伤害与地图伤害读的也是它。
- `getModifiableEnemy()`:可修改的原始怪物。它返回的其实是 `getBaseEnemy()` 的可写版本,修改后不会自动同步;这是少数需要手动 `markDirty()` 的情况。
视图内部还有一份「正在计算中的怪物」(`getComputingEnemy()`),只供上下文在构建过程中使用,外部不应调用。
`getComputedEnemy()` 并不是单纯返回一个字段:读取它本身就会按需刷新这只怪物,然后返回计算后对象。`reset()` 则把计算后怪物退回原始模板的状态,全量重建开始时会统一调用它:
```ts
import { IEnemyAttr } from '@user/data-common';
import { IEnemyView } from '@user/data-system';
/** 对比同一只怪物的原始属性与计算后属性。 */
function compareEnemy(view: IEnemyView<IEnemyAttr>): void {
// 原始模板:不会因为光环或最终效果而改变
const base = view.getBaseEnemy();
// 计算后怪物:读取时会按需触发局部刷新
const computed = view.getComputedEnemy();
console.log('模板攻击', base.getAttribute('atk'));
console.log('计算后攻击', computed.getAttribute('atk'));
// 把计算后怪物退回模板状态;下一次读取会重新构建
view.reset();
}
```
## buildup 的五段
`buildup()` 是全量重建入口。它先检查是否需要更新:`needUpdate` 为假时直接返回;没有绑定勇士时会告警并返回,因为光环与最终效果都可能读取勇士属性。真正开始时,它清空上一轮的排序表、转换表与脏标记,然后按下面的顺序执行:
1. **重置视图**:对每个视图调用 `reset()`,把计算后怪物退回原始模板。若不先重置,重复构建会在上一轮数值上继续累加。
2. **`buildupSpecials`**:把无来源的全局光环加入排序表;把每只怪物身上的特殊属性转换成光环视图;再按优先级从高到低,交替执行「修改特殊属性的光环」与「特殊查询」。这一阶段决定每只怪物最终拥有哪些特殊属性。
3. **`buildupBase`**:按优先级从高到低执行所有基础光环的 `apply`,修改计算后怪物的基础属性。
4. **`buildupQuery`**:执行常规查询。它允许读取上下文(周围怪物、位置等),并修改怪物自身的基础属性。
5. **`buildupFinal`**:执行最终效果。它不再查询上下文,只对自身基础属性做收尾。
```mermaid
graph TD
A[重置视图] --> B[buildupSpecials]
B --> C[buildupBase]
C --> D[buildupQuery]
D --> E[buildupFinal]
E --> F[伤害系统全部标脏]
F --> G[地图伤害全部刷新]
```
五个阶段结束后,`buildup()` 会调用 `damageSystem.markAllDirty()` 清空伤害缓存,并调用 `mapDamage.refreshAll()` 重建地图伤害。也就是说,构建结果变了,缓存一定跟着失效;你不需要在光环或最终效果里自己标脏。
实现上,第二、三段只在注册过光环转换器或特殊查询时执行;第四段只在注册过常规查询时执行;第五段只在注册过最终效果时执行。空阶段会被跳过,不会产生额外开销。
## 优先级的边界
这里其实有两套顺序,不要混为一谈。
**特殊光环与特殊查询**按优先级全局排队:`buildupSpecials` 每轮取当前未处理的最大优先级,先执行该优先级下的 `couldApplySpecial` 光环,再执行同优先级的特殊查询,然后才进入下一个优先级。高优先级留下的特殊属性,低优先级能看到;反过来不行。
**常规查询**不参加这个全局队列。`buildupQuery` 是逐只怪物、逐个特殊属性 code 执行的:对怪物身上的每个 `special`,取出 `commonQueryMap.get(special.code)` 上注册的效果,按这些效果自己的 `priority` 从高到低执行。优先级只在**同一个 code 内**排序;不同 code 之间没有全局先后,执行顺序取决于 `enemy.iterateSpecials()` 的遍历顺序。
由效果新增的特殊属性会立刻被转换成光环,但这条新光环只能影响之后的阶段:
- 新光环的优先级**高于**当前阶段时,开发环境会告警并跳过这条新增属性(特殊属性也不会被加上);
- 删除特殊属性时,`enemy.deleteSpecial(...)` **总是先执行**;如果对应光环的优先级不低于当前阶段,开发环境会告警并跳过「从排序表移除光环」这一步,于是出现「特殊属性已经删了,但这一轮光环还在」的状态。生产环境没有这个优先级拦截,会直接把光环移除。因此不要把它理解成「删除被拒绝」;
- `modify` 命中同优先级或更高优先级的光环时只会告警,不会回滚已经写入的修改;
- 同一优先级内部不保证先后顺序,不要把有依赖关系的两条效果放在同一优先级。
举个例子:优先级 20 的特殊光环给周围怪物加上一条优先级 10 的普通光环,这是成立的——20 先执行,10 在之后的基础光环阶段生效。反过来,优先级 10 的光环想给目标加上优先级 20 的光环则不成立:20 比当前阶段高,开发环境会告警并跳过。设计效果时,先想清楚「它应该在谁之后生效」,再决定优先级。
开发环境的优先级告警不是致命错误,但通常说明效果之间存在循环或倒置的依赖,值得回头调整设计。
## 局部刷新与全量重建
全量 `buildup()` 会重算所有怪物。日常不需要手动更新:怪物增删、注册效果、绑定勇士等操作都会自己置脏,读取 `getComputedEnemy()` 或伤害时会自动完成局部刷新。
只有绕过构建流程、直接改原始模板时,才需要 `view.markDirty()`。它把这只怪物加入待刷新集合,同时清掉伤害缓存;之后再读计算后怪物或伤害就会更新。这是少数情况,不要把它当成常态流程。
局部刷新不会重建光环拓扑:新产生的特殊属性即使能转成光环,也只在开发环境告警(code 101),不会加入排序表。要让新光环参与构建,必须走一次全量 `buildup()`。
若某只怪物在常规查询里调用了 `query()`,它就会被记为依赖上下文:周围怪物更新时,它也要更新。这正是[怪物查询](../../intermediate/enemy-query.md)强调必须用 `query()` 而不是 `handler.context` 的原因——绕过 `query()` 不会被登记,周围变化后读到的仍是旧值。
## 伤害系统与缓存
`IDamageSystem` 在 `DamageContext` 的基础上加了一层缓存。`getDamageInfo(view)` 的行为是:
1. 先查缓存,命中就直接返回;
2. 未命中时调用 `view.getComputedEnemy()` 取计算后怪物;
3. 把只读的怪物、位置与勇士属性交给 `IDamageCalculator.calculate(handler)`;
4. 把 `{ damage, turn }` 连同 `handler` 存回缓存。
`markDirty(view)` 只删除这一只怪物的缓存,`markAllDirty()` 清空全部缓存。`getCriticalLimit` 只用于临界计算的上界,不参与普通伤害结算;它不会被缓存,也不负责刷新。
「改了怪物属性,但伤害没变」通常有两个原因:要么改的是原始怪物而没标脏,查询仍在读旧的计算后副本;要么伤害缓存没有被清掉。按 `markDirty` → `getDamageInfo` 的顺序就能排除这两种情况。
光环、查询与最终效果在构建过程中改的是计算后怪物,`buildup()` 结束时已经统一清空缓存,所以它们不需要额外标脏。需要手动标脏的只有「绕过构建流程、直接改原始怪物」的场景。
## 与光环、查询、最终的交互
四个阶段的分工可以压成一张表:
| 阶段 | 改谁 | 是否读取外部环境 | 是否需要手动标脏 |
| -------- | -------------------- | ---------------------- | ---------------------------- |
| 基础光环 | 其他怪物的基础属性 | 否,只按范围命中 | 否,`buildup` 结束时统一处理 |
| 特殊光环 | 其他怪物的特殊属性 | 否,只按范围命中 | 否,拓扑变化会触发全量重建 |
| 查询 | 自身的基础或特殊属性 | 是,必须通过 `query()` | 否,但会被登记进局部刷新 |
| 最终效果 | 自身的基础属性 | 否 | 否 |
| 伤害系统 | 只读计算后怪物 | 否 | 直接改原始怪物时需要 |
地图伤害同样按计算后怪物计算。因此光环新增的特殊属性会真实进入地图伤害判定,例如光环给怪物加上「领域」,这只怪物就确实拥有领域效果。这也是「光环改的是计算后怪物」的直接后果。
三条构建阶段的共同约束是:**不要直接改 `getBaseEnemy()`**。原始模板是所有重建的起点,改它等于改数据源;需要影响计算结果时,改 `handler.enemy`,让下一次重建从模板重新算出结果。
## 一个完整的收束示例
下面的例子注册一个最终效果(给所有怪物 +5 护甲)与一个全局基础光环(给范围内怪物 +10 攻速),执行 `buildup()` 后读取某只怪物的计算后属性与伤害;然后修改原始护甲并走一遍局部刷新,确认结果更新。注册只做一次,实际应放在初始化阶段:
```ts
import { FullRange, IRange } from '@motajs/common';
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import { ICoreState } from '@user/data-state';
import {
IAuraView,
IEnemyFinalEffect,
IEnemyHandler,
IEnemySpecialModifier,
IEnemyView
} from '@user/data-system';
/** 收尾:所有怪物 +5 护甲。 */
class GlobalArmorFinal implements IEnemyFinalEffect<IEnemyAttr, IHeroAttr> {
readonly priority = 0;
apply(handler: IEnemyHandler<IEnemyAttr, IHeroAttr>): void {
handler.enemy.addAttribute('armor', 5);
}
}
/** 无来源的全局基础光环:全场怪物 +10 攻速。 */
class SpeedAura implements IAuraView<IEnemyAttr, 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>): void {
handler.enemy.addAttribute('atkSpeed', 10);
}
applySpecial(): IEnemySpecialModifier<IEnemyAttr> | null {
return null;
}
}
// 初始化阶段执行一次:最终效果与无来源全局光环
state.enemyContext.registerFinalEffect(new GlobalArmorFinal());
state.enemyContext.addAura(new SpeedAura());
/** 构建后对比模板与计算后结果,再验证局部刷新。 */
function inspectAfterBuildup(
state: ICoreState,
view: IEnemyView<IEnemyAttr>
): void {
const ctx = state.enemyContext;
// 全量构建:光环与最终效果写入计算后怪物
ctx.buildup();
const computed = view.getComputedEnemy();
console.log('计算后护甲', computed.getAttribute('armor')); // 模板护甲 + 5
console.log('计算后攻速', computed.getAttribute('atkSpeed')); // 模板攻速 + 10
// 模板本身不应被光环或最终效果改写
console.log('模板护甲', view.getBaseEnemy().getAttribute('armor'));
const before = ctx.getDamageSystem()?.getDamageInfo(view);
console.log('构建后伤害', before?.damage, '回合', before?.turn);
// 直接改模板后标脏;再次读取时会自动完成局部刷新
view.getModifiableEnemy().setAttribute('armor', 20);
view.markDirty();
const after = ctx.getDamageSystem()?.getDamageInfo(view);
console.log('刷新后伤害', after?.damage, '回合', after?.turn);
}
```
`addAura` 添加的是无来源全局光环,不需要转换器。但要注意 `buildup()` 的外层开关:它判断的是 `auraConverter.size > 0 || specialQueryEffects.size > 0`,并不看 `globalAuraList`。实际游戏里 `initializeEnemy` 已经注册了三个光环转换器,所以全局光环会正常进入构建;如果你在测试里手建了一个空上下文,只调用 `addAura` 而不注册任何转换器或特殊查询,`buildupSpecials` / `buildupBase` 根本不会被调用,光环看起来像没生效。
## 怎么验证
固定一只怪物与一套勇士属性,记录构建前后的数值:
```ts
import { IEnemyAttr } from '@user/data-common';
import { ICoreState } from '@user/data-state';
import { IEnemyView } from '@user/data-system';
/** 检查构建只改计算后怪物,并且伤害缓存会随标脏刷新。 */
function verifyBuildup(state: ICoreState, view: IEnemyView<IEnemyAttr>): void {
const ctx = state.enemyContext;
const baseArmor = view.getBaseEnemy().getAttribute('armor');
ctx.buildup();
const computedArmor = view.getComputedEnemy().getAttribute('armor');
// 期望:模板护甲不变;计算后护甲 = 模板 + 最终效果(+5)
console.log({ baseArmor, computedArmor });
const damageSystem = ctx.getDamageSystem();
const first = damageSystem?.getDamageInfo(view);
console.log('第一次伤害', first?.damage);
// 修改模板护甲并标脏,缓存应失效
view.getModifiableEnemy().setAttribute('armor', 50);
view.markDirty();
const second = damageSystem?.getDamageInfo(view);
console.log('修改后伤害', second?.damage);
}
```
预期现象:
- `baseArmor` 始终等于内容里的原始值,光环与最终效果不会写回去;
- `computedArmor` 比模板多 5(最终效果),若还挂了加护甲的光环则会继续增加;
- 第一次伤害与修改后的伤害不同;若不变,先确认 `markDirty` 是否在 `getDamageInfo` 之前调用;
- 只改模板、不标脏时,第二次读到的仍是第一次的缓存值。
## 边界与注意
- **未绑定勇士时 `buildup()` 会告警并返回**:光环与最终效果可能读取勇士属性,构建前必须 `bindHero`。`initializeEnemy` 已经绑定过。
- **`needUpdate` 为假时 `buildup()` 直接返回**:没有变化就不会重算。注册效果、放置怪物、绑定勇士都会把它置为真。
- **`getModifiableEnemy()` 的修改不会自动同步**:它返回原始模板,改完必须 `markDirty()`。
- **新光环需要全量重建**:特殊属性增删会改变光环集合,局部刷新处理不了,下一次读取会回到全量 `buildup()`。
- **不要在高频循环里反复全量 `buildup()`**:全量构建会重置并重算所有怪物,开销随怪物数量增长;单只变化只需 `markDirty()`,读取时会按需局部刷新。
- **`getComputingEnemy()` 是内部接口**:它暴露正在构建中的可写怪物,外部调用会绕过刷新与脏标记。
- **查询效果不是内置内容**:`initializeEnemy` 只注册光环转换器、最终效果、战斗脚本与钩子,没有注册任何查询。查询需要自己注册后才会参与构建。
- **`buildupSpecials` 在没有光环时提前返回**:它先加入全局光环、转换特殊属性光环,然后执行 `if (sortedAura.size === 0) return;`。如果只注册了特殊查询而场上没有任何光环,这个提前返回会让特殊查询也不执行。需要特殊查询时,至少保证存在一条光环(全局或转换出的都算)。
- **光环可以命中自己**:范围包含中心时(`ManhattanRange` 的 `radius: 0`、`FullRange` 等),来源怪物自己也在扫描结果里,`apply` / `applySpecial` 会作用到它自己。设计要求「不影响自身」时,在 `apply` 里跳过 `handler.locator` 与来源位置相同的目标(带来源的 `IEnemyAuraView` 可比较 `aura.locator`),或改用不含中心的范围。
- **开发环境的优先级告警不是致命错误**:它会跳过不合法的增删,但通常说明阶段依赖设计有问题,应该调整优先级而不是忽略告警。
- **`clear()` 与 `destroy()` 的时机不同**:`clear()` 清空怪物、运行状态与无来源全局光环列表(`globalAuraList`),但保留光环转换器、查询、最终效果等注册项;`destroy()` 会连伤害系统、地图伤害与所有注册项一起清掉。销毁后不应继续使用该上下文。
返回[高级 II 导览](./index.md)。