Files
template/docs/intermediate/enemy-map-damage.md
T
AncTe e64167b7c5 docs(10-11): 按 D-51 微调特殊属性落点与讲解方式
- 特殊属性实现写进已有文件(aura.ts 等),仅查询属性新建 query.ts
- aura.ts / query.ts 建议用 //#region {code}-{name} 包裹
- 通俗解释「副作用」;地图伤害按计算后怪物计算,光环加的特殊属性会真实生效
- 依赖并修改外部环境应拆成查询 + 光环、用新怪物属性桥接(指向高级教程)
- getCriticalLimit 提一句并指向高级教程;接口讲解改为「为了实现 X,改 Y 接口」
- 追加 batch 10 读者审阅记录
2026-10-19 19:28:18 +08:00

381 lines
20 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.
# 地图伤害
地图伤害不是通过战斗结算的伤害,而是勇士在地图上移动时,根据怪物与位置自动扣减的生命值。常见形式有领域、夹击、阻击、激光与捕捉。整体流程见[战斗总览](./combat-overview.md)。
## 三部分
地图伤害系统由三部分组成:
- **伤害视图** `IMapDamageView`:描述一个来源的影响范围,以及某个位置会受到多少伤害;
- **转换器** `IMapDamageConverter`:把一个怪物的特殊属性转换成一组伤害视图;
- **合并器** `IMapDamageReducer`:当一个位置同时受到多个来源影响时,决定最终取什么伤害。
伤害视图的核心方法:
- `getRange()` / `getRangeParam()`:返回范围对象与范围参数;
- `getDamageAt(locator)`:先判断坐标是否在范围内,再取伤害;
- `getDamageWithoutCheck(locator)`:不判断范围,直接取该位置的伤害。
伤害信息 `IMapDamageInfo` 包含 `damage`(伤害值)、`type`(伤害类型)与 `extra`(额外信息)。`extra.catch` 记录需要触发强制战斗的怪物位置,`extra.repulse` 记录需要后退的阻击怪物位置。
## 按「计算后怪物」计算
转换器拿到的 `handler.enemy` 是**计算后怪物**(`getComputedEnemy()`),也就是光环、查询、最终效果都算完后的结果。因此光环通过 `applySpecial` 给怪物新增的特殊属性会真实生效:例如某个光环给一只怪物加了「领域」(code 13),这只怪物就确实拥有领域地图伤害,勇士踩进范围会掉血。
旧样板做不到动态给怪物添加特殊属性,这一点是现版本才有的能力。反过来说,如果一种属性既要读外部环境、又要修改外部环境,不要硬塞进地图伤害或单个光环里,而应拆成「查询效果 + 光环效果」,再用一个新怪物属性桥接(见[怪物光环](./enemy-aura.md)的「光环、计算后怪物与『读写外部』」)。
## 注册
地图伤害管理器由 `state.enemyContext.getMapDamage()` 取得。注册方式:
```ts
const mapDamage = state.enemyContext.getMapDamage();
mapDamage?.useReducer(new MainMapDamageReducer());
mapDamage?.useConverter(new MainMapDamageConverter());
```
`initializeEnemy` 已经按上面的顺序注册了 `MainMapDamageReducer` 与 `MainMapDamageConverter`。`useConverter` 会替换当前转换器,`useReducer` 会替换当前合并器。
## 内置五类
`MapDamageType` 定义了 `Unknown`、`Zone`、`Layer`、`Repulse`、`Between` 五种类型。内置视图与转换器在 `src/data/enemy/mapDamage.ts`:
| 类型 | 视图类 | 内置特殊属性 code | 说明 |
| ---- | ------------------- | ----------------------- | ---------------------------- |
| 领域 | `ZoneDamageView` | 13(`ISpecial13Value`) | 范围内自动减血 |
| 夹击 | `BetweenDamageView` | 14 | 两只怪物夹住勇士时扣半血 |
| 阻击 | `RepulseDamageView` | 16 | 经过时怪物后退并造成伤害 |
| 激光 | `LaserDamageView` | 22 | 同行或同列时造成伤害 |
| 捕捉 | `AmbushDamageView` | 25 | 触发强制战斗,本身不造成伤害 |
表中的 code 就是 `src/data/enemy/special.ts` 里同名内置属性注册的编号,转换器读取的也是同一组编号。要启用某类地图伤害,给怪物挂上对应的内置特殊属性即可,不需要再换算编号或修改转换器。
另外两点以当前代码为准:
- 「捕捉」的视图类名沿用 `AmbushDamageView`(伏击),它读取的是 code 25「捕捉」。它返回 `MapDamageType.Unknown`,因为 `MapDamageType` 没有「捕捉」这一项;伤害恒为 0,作用是产生 `extra.catch`,由移动钩子触发强制战斗。捕捉本身不造成伤害是设计如此,不是未补齐的功能。
- `BetweenDamageView` 读取全局 flag `betweenAttackMax`,决定夹击伤害是否取「半血、当前怪物伤害、对侧怪物伤害」三者的较小值;对侧怪物通过 `hasSpecial(14)` 识别,与夹击自身使用的是同一个编号。
## 改内置:调整内置视图与转换器
内置五类地图伤害的视图与转换器都写在 `src/data/enemy/mapDamage.ts`,而且已经在 `initializeEnemy` 里注册好了。所以**改内置就是改这些类的现有方法、或往 `MainMapDamageConverter.convert` 里加一个分支**——不新建类,也不用再调用 `useConverter`。这是本篇的主线做法。
先按「想改什么」找落点:
| 想改的东西 | 改哪里 |
| ---------------------- | --------------------------------------------------------------------- |
| 某类伤害的范围 | 对应视图的 `getRange` / `getRangeParam` |
| 某类伤害的数值或类型 | 对应视图的 `getDamageWithoutCheck` |
| 某类伤害读哪个特殊属性 | `MainMapDamageConverter.convert` 里对应的 `getSpecial` / `hasSpecial` |
| 多来源叠加的取舍规则 | `MainMapDamageReducer.reduce` |
| 某类伤害的触发条件 | 对应视图的 `getDamageWithoutCheck`(返回 `null` 即该点不受伤) |
五个视图都继承自 `BaseMapDamageView`,它已经实现了 `getDamageAt`(先判范围再取伤害)并提供 `createInfo(damage, type, extra?)` 帮你拼伤害信息。所以改一个视图时通常只需要碰 `getRangeParam` 与 `getDamageWithoutCheck` 两个方法。
### 示例:让激光也打斜向(八方向激光)
内置激光(code 22)用 `FaceGroup.Dir4`(上下左右四个方向)沿射线发射。要让同一条内置激光多覆盖四个斜向,最省事的做法是改内置:把转换器里取的朝向组换成 `FaceGroup.Dir8`,`LaserDamageView` 本身一行都不用改——它只是把传入的朝向处理器映射成方向列表,方向多了自然就多打四个斜向。
**目标**:内置激光(code 22)沿上下左右加四个斜向共八个方向发射,其余行为不变。
**改哪里**:`src/data/enemy/mapDamage.ts` 的 `MainMapDamageConverter.convert`(内置文件,改完不需要注册)。
**复制什么**:把取朝向处理器那一行换个方向组——
```ts
convert(
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>,
context: IEnemyContext<IEnemyAttr, IHeroAttr>
): IMapDamageView<any>[] {
const views: IMapDamageView<any>[] = [];
const { enemy, locator, hero } = handler;
// ...省略:领域 13、夹击 14、阻击 16 的分支,原样保留...
const laser = enemy.getSpecial<number>(22);
if (laser) {
// [需改] 内置是 FaceGroup.Dir4(四方向),改成 Dir8 即八方向
const face = handler.state.faceManager.get(FaceGroup.Dir8)!;
views.push(new LaserDamageView(context, locator, laser, face));
}
// ...省略:捕捉 25 的分支,原样保留...
return views;
}
```
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
**改什么**:只需要把 `FaceGroup.Dir4` 换成 `FaceGroup.Dir8`。`LaserDamageView` 的构造函数把朝向处理器经 `dir.mapMovement()` 映射成方向列表,八方向组自然给出八项;其中「原地不动」的未知方向偏移为零,`RayRange` 会自动跳过它,不必额外处理。
**怎么验证**:给带激光(code 22)的怪物挂上后,站在它八个方向任一格的勇士都应受到伤害;斜向也要覆盖到(这正是与原本四方向激光的区别)。
**注意**:这次改的是**内置激光**,所有挂 code 22 的怪物都会变成八方向。如果只想让某一种新怪物打八方向、其余仍是四方向,就得给它一条新的特殊属性并另写转换器分支——那属于下一节的「自己写」。
### 另外几处常见的改法
- **领域(13)**:`ZoneDamageView.getRangeParam` 按 `square` 在矩形与曼哈顿之间切换。想换算法(例如改成全场),在模块顶部建一个 `const FULL_RANGE = new FullRange();`,让 `getRange` 返回它,并让 `getRangeParam` 返回 `undefined` 即可。
- **阻击(16)**:`RepulseDamageView.getRangeParam` 里的 `radius: 1` 决定触发距离,`getDamageWithoutCheck` 里的 `extra.repulse` 决定哪个怪物后退。想改触发距离,改 `getRangeParam` 的 `radius`。
- **捕捉(25)**:`AmbushDamageView` 的伤害恒为 0、只产出 `extra.catch`。要让捕捉附带伤害,改 `createInfo` 的第一个参数。
- **合并规则**:`MainMapDamageReducer.reduce` 现在是「伤害相加、类型取最大来源」。要改成取最大值、或优先某类型,改这个方法。
## 自己写地图伤害类
只有「内置五类都表达不了、往内置方法体里也塞不进去」时才需要自己写类。典型情况是:一条全新的特殊属性要有自己的范围形态与伤害算法,而它既不是射线、也不是矩形 / 曼哈顿。
自己写类要同时处理视图与转换器两个接口,还要用 `useConverter` 替换内置转换器,改动面比改内置方法体大得多。
**如果你不怎么懂代码,建议直接改内置的视图与转换器(`src/data/enemy/mapDamage.ts`),而不是自己写新的类。** 上面「改内置」一节已经覆盖了调范围、调数值、调方向、调合并规则这些需求;自写转换器意味着你要自己把内置五类视图也重新收集进去,否则它们会全部失效。
`useConverter` 是**替换**当前转换器:选了自己的就把内置的整个换掉,若要同时保留内置五类,需要在自己的转换器里把它们的视图也收集进去,或直接改内置转换器。
### 模板:复制什么、改什么
自己写地图伤害永远是「一个视图 + 一个转换器」两个类。下面是机制本身的骨架,不含任何具体伤害;复制到 `src/data/enemy/mapDamage.ts` 末尾后按 `[需改]` 修改(不要另建文件,地图伤害的实现集中在这个文件里)。
先写视图——它负责「范围、某点的伤害与类型」:
```ts
// [需改] 类名:换成你的视图名
export class XxxDamageView implements IMapDamageView<TRangeParam> {
// [模板] 构造函数接收引擎传进来的上下文、位置与特殊属性
constructor(
private readonly context: IEnemyContext<IEnemyAttr, IHeroAttr>,
private readonly locator: ITileLocator,
private readonly special: ISpecial<IXxxValue>
) {}
// [需改] 范围对象:RectRange / ManhattanRange / RayRange / FullRange 之一
getRange(): IRange<TRangeParam> {
// [需改] RANGE 换成你在模块顶部建好的范围对象
return RANGE;
}
// [需改] 与范围配套的参数(矩形 { x, y, w, h };曼哈顿 { cx, cy, radius };
// 射线 { cx, cy, dir };全场返回 undefined)
getRangeParam(): TRangeParam {
return undefined as TRangeParam;
}
// [模板] 先判范围,再取伤害;通常不需要改
getDamageAt(locator: ITileLocator): Readonly<IMapDamageInfo> | null {
const range = this.getRange();
range.bindHost(this.context);
if (!range.inRange(locator.x, locator.y, this.getRangeParam())) {
return null;
}
return this.getDamageWithoutCheck(locator);
}
// [需改] 真正决定某点的伤害与类型;返回 null 表示该点不受伤
getDamageWithoutCheck(
locator: ITileLocator
): Readonly<IMapDamageInfo> | null {
return {
damage: 0,
type: MapDamageType.Unknown,
extra: { catch: new Set(), repulse: new Set() }
};
}
}
```
再写转换器——它负责「从怪物身上读出属性,造出视图」:
```ts
// [需改] 类名:换成你的转换器名
export class XxxConverter implements IMapDamageConverter<
IEnemyAttr,
IHeroAttr
> {
// [模板] convert 签名固定:把特殊属性翻译成一组视图
convert(
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>,
context: IEnemyContext<IEnemyAttr, IHeroAttr>
): IMapDamageView<any>[] {
// [需改] 读取要转换的特殊属性
const special = handler.enemy.getSpecial<IXxxValue>(XX);
if (!special) return [];
// [需改] 造视图;一条属性也可以返回多个视图
return [new XxxDamageView(context, handler.locator, special)];
}
}
```
注册(**替换**当前转换器):
```ts
const mapDamage = state.enemyContext.getMapDamage();
mapDamage?.useConverter(new XxxConverter());
```
复制后需要改的只有:两个类名、范围对象与参数、`getDamageWithoutCheck` 的伤害 / 类型、转换器读取的 code。
### 示例:八方向激光
下面是一个具体效果,演示怎么把模板填满。它只是填空示范,不是模板本身。
**目标**:新增一个「八方向激光」特殊属性(示例 code 43),沿上下左右加四个斜向共八个方向发射,且不影响内置的四方向激光。
**改哪里**:`src/data/enemy/special.ts` 登记属性;伤害视图与转换器写进 `src/data/enemy/mapDamage.ts`;`initializeEnemy` 里用 `useConverter` 替换(见下方「注册在哪里」)。
**复制什么**:先在 `src/data/enemy/special.ts` 登记这条属性——
```ts
/** 43 - 八方向激光 */
export interface ISpecial43Value {
damage: number; // [需改] 参数:激光造成的伤害
}
// 43 - 八方向激光
map.set(
43, // [需改] 编号:示例自选的未占用号
defineCommonSerializableSpecial<ISpecial43Value>(
43,
{ damage: 50 }, // [需改] 默认参数:默认 50 点伤害
{
name: '八方向激光', // [需改] 显示名称
desc: special =>
`同行、同列或同斜线时造成${special.value.damage}点伤害。`, // [需改] 悬停说明
color: '#D0A0DD' // [需改] 名称颜色
}
)
);
```
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
再写伤害视图与转换器——
```ts
// [模板] 范围对象可以复用,在模块顶层建一次即可
const RAY_RANGE = new RayRange();
// [需改] 类名:换成你的视图名
export class EightDirLaserDamageView implements IMapDamageView<IRayRangeParam> {
// [模板] 存下八个方向的偏移量,供 getRangeParam 使用
private readonly dirs: IFaceDescriptor[];
// [模板] 构造函数签名固定:引擎会把上下文、位置、特殊属性与朝向处理器传进来
constructor(
private readonly context: IEnemyContext<IEnemyAttr, IHeroAttr>,
private readonly locator: ITileLocator,
private readonly special: ISpecial<ISpecial43Value>,
dir: IFaceHandler<number>
) {
// [模板] mapMovement() 给出「方向 → 偏移」,这里只取偏移(每项的 [1])
this.dirs = [...dir.mapMovement()].map(v => v[1]);
}
// [模板] 返回射线范围对象
getRange(): IRange<IRayRangeParam> {
return RAY_RANGE;
}
// [模板] 范围参数:中心点是怪物位置,方向列表是八个方向
getRangeParam(): IRayRangeParam {
return {
cx: this.locator.x,
cy: this.locator.y,
dir: this.dirs
};
}
// [模板] 先判断目标点是否落在范围内(RayRange 会跳过零偏移的未知方向)
getDamageAt(locator: ITileLocator): Readonly<IMapDamageInfo> | null {
const range = this.getRange();
range.bindHost(this.context);
if (!range.inRange(locator.x, locator.y, this.getRangeParam())) {
return null;
}
return this.getDamageWithoutCheck(locator);
}
// [需改] 真正决定某点的伤害:这里固定返回本属性登记的伤害与激光类型
getDamageWithoutCheck(
locator: ITileLocator
): Readonly<IMapDamageInfo> | null {
return {
damage: this.special.value.damage,
type: MapDamageType.Layer,
extra: {
catch: new Set(), // [模板] 无捕捉来源
repulse: new Set() // [模板] 无阻击来源
}
};
}
}
// [需改] 类名:换成你的转换器名
export class EightDirLaserConverter implements IMapDamageConverter<
IEnemyAttr,
IHeroAttr
> {
// [模板] convert 签名固定:把怪物身上的特殊属性翻译成一组视图
convert(
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>,
context: IEnemyContext<IEnemyAttr, IHeroAttr>
): IMapDamageView<any>[] {
// [需改] 读取触发本转换器的特殊属性(示例 code 43)
const special = handler.enemy.getSpecial<ISpecial43Value>(43);
// [模板] 没有这个属性就不产生视图
if (!special) return [];
// [需改] 取八方向朝向处理器(内置激光这里用的是 FaceGroup.Dir4)
const face = handler.state.faceManager.get(FaceGroup.Dir8)!;
// [模板] 返回视图数组
return [
new EightDirLaserDamageView(context, handler.locator, special, face)
];
}
}
// [需改] 注册这行要挪进 initializeEnemy(见下方「注册在哪里」)
const mapDamage = state.enemyContext.getMapDamage();
mapDamage?.useConverter(new EightDirLaserConverter());
```
**改什么**:编号(`43`)、类名、方向组(本示例 `FaceGroup.Dir8`)与 `getDamageWithoutCheck` 返回的伤害 / 类型换成你自己的。
**怎么验证**:给带该属性的怪物挂上后,站在它八个方向任一格的勇士都应受到伤害;斜向也要覆盖到。同时确认原本带激光(code 22)的怪物仍按四方向工作——如果你没有在自己的转换器里收集内置视图,它不会再生效。
**注意**:`useConverter` 是**替换**当前转换器——选了自己的就把内置的整个换掉;要同时保留内置五类,需要在自己的转换器里把它们的视图也收集进去,或直接改内置转换器。
### 注册在哪里
`useConverter` 要写进 `initializeEnemy`。下面是 `src/data/enemy/initialize.ts` 中该函数的样子(省略了无关的行):
```ts
export function initializeEnemy(
state: ICoreState
): IEnemyInitializationReturns {
// ...省略:怪物管理器、特殊属性、上下文的初始化...
const ctx = state.enemyContext;
const damageSystem = new DamageSystem(ctx);
const mapDamage = new MapDamage(ctx);
// ...省略:damageSystem.useCalculator(...)...
mapDamage.useReducer(new MainMapDamageReducer());
mapDamage.useConverter(new MainMapDamageConverter());
// [需改] 你自己的转换器在这里替换/追加:
// mapDamage.useConverter(new EightDirLaserConverter());
ctx.attachDamageSystem(damageSystem);
ctx.attachMapDamage(mapDamage);
// ...省略:绑定勇士、光环、最终效果、脚本、钩子...
}
```
## 合并器
`MainMapDamageReducer.reduce` 的规则很直接:
- 把所有来源的 `damage` 相加;
- 取伤害最大的那条来源的 `type` 作为合并后的类型;
- 把所有来源的 `extra.catch` 与 `extra.repulse` 合并。
多个地图伤害落在同一格时,勇士只会受到合并后的一份伤害。需要不同的取舍规则时,改内置的 `reduce` 方法即可;只有在整条合并逻辑都要换掉时才实现 `IMapDamageReducer` 并用 `useReducer` 替换。
## 实际结算
移动时由 `EnemyMoveHooks.onStepEnd` 调用 `handleMapDamage`:先用 `mapDamage.getReducedDamage(location)` 取合并伤害,再扣减勇士生命、发出提示,并处理阻击后退与捕捉强制战斗。这套结算在当前 `src/data/enemy/hook.ts` 中已经接通:踩到地图伤害格会立即掉血,阻击会推动怪物后退,捕捉会触发强制战斗。