18 KiB
怪物上下文与战斗深入
中级教程分别讲过光环、查询、最终效果与伤害计算,但它们并不是各算各的。所有结果都由同一个怪物上下文按固定顺序拼装出来;伤害系统再缓存这份结果,地图伤害也读同一份计算后怪物。理解这条装配线,才能回答「为什么我改了属性,伤害却没变」这类问题。
本篇是高级 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() 则把计算后怪物退回原始模板的状态,全量重建开始时会统一调用它:
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 为假时直接返回;没有绑定勇士时会告警并返回,因为光环与最终效果都可能读取勇士属性。真正开始时,它清空上一轮的排序表、转换表与脏标记,然后按下面的顺序执行:
- 重置视图:对每个视图调用
reset(),把计算后怪物退回原始模板。若不先重置,重复构建会在上一轮数值上继续累加。 buildupSpecials:把无来源的全局光环加入排序表;把每只怪物身上的特殊属性转换成光环视图;再按优先级从高到低,交替执行「修改特殊属性的光环」与「特殊查询」。这一阶段决定每只怪物最终拥有哪些特殊属性。buildupBase:按优先级从高到低执行所有基础光环的apply,修改计算后怪物的基础属性。buildupQuery:执行常规查询。它允许读取上下文(周围怪物、位置等),并修改怪物自身的基础属性。buildupFinal:执行最终效果。它不再查询上下文,只对自身基础属性做收尾。
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(),它就会被记为依赖上下文:周围怪物更新时,它也要更新。这正是怪物查询强调必须用 query() 而不是 handler.context 的原因——绕过 query() 不会被登记,周围变化后读到的仍是旧值。
伤害系统与缓存
IDamageSystem 在 DamageContext 的基础上加了一层缓存。getDamageInfo(view) 的行为是:
- 先查缓存,命中就直接返回;
- 未命中时调用
view.getComputedEnemy()取计算后怪物; - 把只读的怪物、位置与勇士属性交给
IDamageCalculator.calculate(handler); - 把
{ damage, turn }连同handler存回缓存。
markDirty(view) 只删除这一只怪物的缓存,markAllDirty() 清空全部缓存。getCriticalLimit 只用于临界计算的上界,不参与普通伤害结算;它不会被缓存,也不负责刷新。
「改了怪物属性,但伤害没变」通常有两个原因:要么改的是原始怪物而没标脏,查询仍在读旧的计算后副本;要么伤害缓存没有被清掉。按 markDirty → getDamageInfo 的顺序就能排除这两种情况。
光环、查询与最终效果在构建过程中改的是计算后怪物,buildup() 结束时已经统一清空缓存,所以它们不需要额外标脏。需要手动标脏的只有「绕过构建流程、直接改原始怪物」的场景。
与光环、查询、最终的交互
四个阶段的分工可以压成一张表:
| 阶段 | 改谁 | 是否读取外部环境 | 是否需要手动标脏 |
|---|---|---|---|
| 基础光环 | 其他怪物的基础属性 | 否,只按范围命中 | 否,buildup 结束时统一处理 |
| 特殊光环 | 其他怪物的特殊属性 | 否,只按范围命中 | 否,拓扑变化会触发全量重建 |
| 查询 | 自身的基础或特殊属性 | 是,必须通过 query() |
否,但会被登记进局部刷新 |
| 最终效果 | 自身的基础属性 | 否 | 否 |
| 伤害系统 | 只读计算后怪物 | 否 | 直接改原始怪物时需要 |
地图伤害同样按计算后怪物计算。因此光环新增的特殊属性会真实进入地图伤害判定,例如光环给怪物加上「领域」,这只怪物就确实拥有领域效果。这也是「光环改的是计算后怪物」的直接后果。
三条构建阶段的共同约束是:不要直接改 getBaseEnemy()。原始模板是所有重建的起点,改它等于改数据源;需要影响计算结果时,改 handler.enemy,让下一次重建从模板重新算出结果。
一个完整的收束示例
下面的例子注册一个最终效果(给所有怪物 +5 护甲)与一个全局基础光环(给范围内怪物 +10 攻速),执行 buildup() 后读取某只怪物的计算后属性与伤害;然后修改原始护甲并走一遍局部刷新,确认结果更新。注册只做一次,实际应放在初始化阶段:
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 根本不会被调用,光环看起来像没生效。
怎么验证
固定一只怪物与一套勇士属性,记录构建前后的数值:
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 导览。