mirror of
https://github.com/motajs/template.git
synced 2026-10-19 13:20:18 +08:00
docs(10-04): write map damage, combat script, and status hook docs
- document map damage views, converters, reducers, and code mapping caveat - add combat script selection table and before/after examples - explain multi-hook status handling and calculator integration
This commit is contained in:
parent
e13ebf71ed
commit
0868650847
118
docs/intermediate/enemy-combat-script.md
Normal file
118
docs/intermediate/enemy-combat-script.md
Normal file
@ -0,0 +1,118 @@
|
||||
# 战斗脚本
|
||||
|
||||
战斗脚本(combat script)挂在战斗流程上,在战前或战后执行一段逻辑。战前脚本可以决定「这场战斗是否发生」,战后脚本负责结算奖励或修改状态。脚本与钩子的分工见下方选型表,整体流程见[战斗总览](./combat-overview.md)。
|
||||
|
||||
## 接口
|
||||
|
||||
`ICombatScript` 有三个成员:
|
||||
|
||||
- `priority`:优先级,越高越先执行;
|
||||
- `before(info, handler)`:战前执行,返回 `Promise<boolean>`。返回 `false` 会立刻停止后续战前脚本,并放弃此次战斗;
|
||||
- `after(info, handler)`:战后执行,返回 `Promise<void>`。
|
||||
|
||||
`info` 是 `IEnemyDamageInfo`,包含 `damage`、`turn` 与 `handler`;`handler` 是 `ICombatFlowHandler`,包含:
|
||||
|
||||
- `onMap`:怪物是否在地图上;
|
||||
- `force`:是否为强制战斗;
|
||||
- `hero`:可修改的勇士属性对象;
|
||||
- `enemy`:怪物对象;
|
||||
- `context`:怪物上下文;
|
||||
- `locator`:怪物位置。
|
||||
|
||||
注册方式:
|
||||
|
||||
```ts
|
||||
state.combat.addCombatScript(new MainCombatScript());
|
||||
```
|
||||
|
||||
同优先级的脚本不能重复注册:`addCombatScript` 遇到相同 `priority` 会告警并忽略后一个脚本。内置的 `MainCombatScript` 优先级为 `0`。
|
||||
|
||||
## 内置脚本
|
||||
|
||||
`MainCombatScript`(`src/data/enemy/script.ts`)做两件事:
|
||||
|
||||
- **战前**:非强制战斗时,如果勇士当前生命小于本次伤害,返回 `false`,阻止战斗;
|
||||
- **战后**:把怪物与支援者的经验、金币加到勇士身上;`curse` flag 为 `true` 时不加;最后发出一次全局提示。
|
||||
|
||||
## 脚本 vs 钩子
|
||||
|
||||
| 需求 | 用脚本 | 用钩子 |
|
||||
|------|--------|--------|
|
||||
| 决定这场战斗是否发生 | 是,`before` 返回 `false` | 否,`onBeforeCombat` 不能阻止战斗 |
|
||||
| 按固定顺序结算奖励或回血 | 是,用 `priority` 排序 | 顺序不由优先级控制 |
|
||||
| 只响应战斗结果做副作用 | 可以,但不是必须 | 是,`onAfterCombat` 更直接 |
|
||||
| 多个互不影响的监听者 | 不适合,同优先级会冲突 | 适合,多个钩子可以并存 |
|
||||
| 需要和内置脚本一样参与战前拦截 | 是 | 否 |
|
||||
|
||||
简单记:**要拦截或按顺序结算,用脚本;只想在战斗前后做附加动作,用钩子。**
|
||||
|
||||
## 战前示例:拥有某特殊属性时无法战斗
|
||||
|
||||
下面的脚本检查怪物是否拥有 code 18 的特殊属性(无敌),有则直接放弃战斗:
|
||||
|
||||
```ts
|
||||
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
|
||||
import {
|
||||
ICombatFlowHandler,
|
||||
ICombatScript,
|
||||
IEnemyDamageInfo
|
||||
} from '@user/data-system';
|
||||
|
||||
export class SealCombatScript implements ICombatScript<IEnemyAttr, IHeroAttr> {
|
||||
readonly priority = 100;
|
||||
|
||||
async before(
|
||||
info: IEnemyDamageInfo<IEnemyAttr, IHeroAttr>,
|
||||
handler: ICombatFlowHandler<IEnemyAttr, IHeroAttr>
|
||||
): Promise<boolean> {
|
||||
if (handler.enemy.hasSpecial(18)) {
|
||||
handler.state.emitTip('这个怪物无法被击败。');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async after(): Promise<void> {}
|
||||
}
|
||||
|
||||
state.combat.addCombatScript(new SealCombatScript());
|
||||
```
|
||||
|
||||
`priority = 100` 让它先于内置脚本执行;返回 `false` 后,后续战前脚本、战后脚本与 `onAfterCombat` 钩子都不会执行。
|
||||
|
||||
## 战后示例:战后玩家回复固定量生命值
|
||||
|
||||
下面的脚本在每场战斗后给勇士回复 20 点生命:
|
||||
|
||||
```ts
|
||||
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
|
||||
import {
|
||||
ICombatFlowHandler,
|
||||
ICombatScript,
|
||||
IEnemyDamageInfo
|
||||
} from '@user/data-system';
|
||||
|
||||
export class HealAfterCombatScript implements ICombatScript<
|
||||
IEnemyAttr,
|
||||
IHeroAttr
|
||||
> {
|
||||
readonly priority = -10;
|
||||
|
||||
async before(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async after(
|
||||
info: IEnemyDamageInfo<IEnemyAttr, IHeroAttr>,
|
||||
handler: ICombatFlowHandler<IEnemyAttr, IHeroAttr>
|
||||
): Promise<void> {
|
||||
handler.hero.add('hp', 20);
|
||||
}
|
||||
}
|
||||
|
||||
state.combat.addCombatScript(new HealAfterCombatScript());
|
||||
```
|
||||
|
||||
`priority = -10` 让它在内置脚本之后执行,避免在奖励结算之前改动勇士状态。`handler.hero` 是可修改对象,直接 `add` 即可。
|
||||
|
||||
`priority` 只需要记住一句:数值越大越先执行;战前与战后各自按这个顺序遍历脚本列表。
|
||||
205
docs/intermediate/enemy-map-damage.md
Normal file
205
docs/intermediate/enemy-map-damage.md
Normal file
@ -0,0 +1,205 @@
|
||||
# 地图伤害
|
||||
|
||||
地图伤害不是通过战斗结算的伤害,而是勇士在地图上移动时,根据怪物与位置自动扣减的生命值。常见形式有领域、夹击、阻击、激光与伏击。整体流程见[战斗总览](./combat-overview.md)。
|
||||
|
||||
## 三部分
|
||||
|
||||
地图伤害系统由三部分组成:
|
||||
|
||||
- **伤害视图** `IMapDamageView`:描述一个来源的影响范围,以及某个位置会受到多少伤害;
|
||||
- **转换器** `IMapDamageConverter`:把一个怪物的特殊属性转换成一组伤害视图;
|
||||
- **合并器** `IMapDamageReducer`:当一个位置同时受到多个来源影响时,决定最终取什么伤害。
|
||||
|
||||
伤害视图的核心方法:
|
||||
|
||||
- `getRange()` / `getRangeParam()`:返回范围对象与范围参数;
|
||||
- `getDamageAt(locator)`:先判断坐标是否在范围内,再取伤害;
|
||||
- `getDamageWithoutCheck(locator)`:不判断范围,直接取该位置的伤害。
|
||||
|
||||
伤害信息 `IMapDamageInfo` 包含 `damage`(伤害值)、`type`(伤害类型)与 `extra`(额外信息)。`extra.catch` 记录需要触发强制战斗的怪物位置,`extra.repulse` 记录需要后退的阻击怪物位置。
|
||||
|
||||
## 注册
|
||||
|
||||
地图伤害管理器由 `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 | `special.ts` 中同名属性的 code | 说明 |
|
||||
|------|--------|-------------------|-------------------------------|------|
|
||||
| 领域 | `ZoneDamageView` | `getSpecial(15)` | 13 | 范围内自动减血 |
|
||||
| 夹击 | `BetweenDamageView` | `hasSpecial(16)` | 14 | 两只怪物夹住勇士时扣半血 |
|
||||
| 阻击 | `RepulseDamageView` | `getSpecial(18)` | 16 | 经过时怪物后退并造成伤害 |
|
||||
| 激光 | `LaserDamageView` | `getSpecial(24)` | 22 | 同行或同列时造成伤害 |
|
||||
| 伏击 | `AmbushDamageView` | `hasSpecial(27)` | 未注册 | 返回 0 伤害并附带捕捉标记 |
|
||||
|
||||
:::warning
|
||||
写作时核对源码发现,上表两组 code 并不一致:转换器实际读取的是 15、16、18、24、27,而 `special.ts` 注册的是 13、14、16、22、24,且没有 27。也就是说,按当前 `src/` 代码,内置五类地图伤害与特殊属性的编号尚未对齐。自定义时请以你所用版本的 `mapDamage.ts` 为准:要么让特殊属性使用转换器实际读取的 code,要么同步修改转换器,不要把上表当成已经对齐的最终编号。
|
||||
:::
|
||||
|
||||
另外两点以当前代码为准:
|
||||
|
||||
- `AmbushDamageView` 返回 `MapDamageType.Unknown`,因为 `MapDamageType` 没有「伏击」这一项;它的作用是产生 `extra.catch`,由移动钩子触发强制战斗。
|
||||
- `BetweenDamageView` 读取全局 `core.flags.betweenAttackMax`,决定夹击伤害是否取「半血、当前怪物伤害、对侧怪物伤害」三者的较小值;对侧怪物通过 `hasSpecial(16)` 识别。
|
||||
|
||||
## 示例:受地形遮挡的激光
|
||||
|
||||
内置激光使用 `RayRange` 沿四个方向发射,不判断中间是否被地形挡住。下面的示例新增一个「遮挡激光」特殊属性(code 43),沿射线检查路径,遇到非空图块就停止:
|
||||
|
||||
```ts
|
||||
/** 43 - 遮挡激光 */
|
||||
export interface ISpecial43Value {
|
||||
damage: number;
|
||||
}
|
||||
|
||||
// 43 - 遮挡激光
|
||||
map.set(
|
||||
43,
|
||||
defineCommonSerializableSpecial<ISpecial43Value>(
|
||||
43,
|
||||
{ damage: 50 },
|
||||
{
|
||||
name: '遮挡激光',
|
||||
desc: special => `同行或同列时造成${special.value.damage}点伤害,被地形遮挡则无效。`,
|
||||
color: '#D0A0DD'
|
||||
}
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
```ts
|
||||
import {
|
||||
IFaceDescriptor,
|
||||
IFaceHandler,
|
||||
IRange,
|
||||
IRayRangeParam,
|
||||
ITileLocator,
|
||||
RayRange
|
||||
} from '@motajs/common';
|
||||
import {
|
||||
IEnemyContext,
|
||||
IMapDamageConverter,
|
||||
IMapDamageInfo,
|
||||
IMapDamageView,
|
||||
IReadonlyEnemyHandler
|
||||
} from '@user/data-system';
|
||||
import { ISpecial } from '@user/data-base';
|
||||
import { IEnemyAttr, IHeroAttr, FaceGroup } from '@user/data-common';
|
||||
import { MapDamageType } from './types';
|
||||
import { ISpecial43Value } from './special';
|
||||
|
||||
const RAY_RANGE = new RayRange();
|
||||
|
||||
export class BlockedLaserDamageView implements IMapDamageView<IRayRangeParam> {
|
||||
private readonly dirs: IFaceDescriptor[];
|
||||
|
||||
constructor(
|
||||
private readonly context: IEnemyContext<IEnemyAttr, IHeroAttr>,
|
||||
private readonly locator: ITileLocator,
|
||||
private readonly special: ISpecial<ISpecial43Value>,
|
||||
dir: IFaceHandler<number>
|
||||
) {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
if (this.isBlocked(locator)) return null;
|
||||
return {
|
||||
damage: this.special.value.damage,
|
||||
type: MapDamageType.Layer,
|
||||
extra: {
|
||||
catch: new Set(),
|
||||
repulse: new Set()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断从怪物到目标点的路径上是否有非空图块
|
||||
*/
|
||||
private isBlocked(target: ITileLocator): boolean {
|
||||
const floor = this.context.floor;
|
||||
if (!floor) return false;
|
||||
const map = this.context.state.maps.getMap(floor);
|
||||
const eventLayer = map?.eventLayer;
|
||||
if (!eventLayer) return false;
|
||||
|
||||
const dx = Math.sign(target.x - this.locator.x);
|
||||
const dy = Math.sign(target.y - this.locator.y);
|
||||
let x = this.locator.x + dx;
|
||||
let y = this.locator.y + dy;
|
||||
while (x !== target.x || y !== target.y) {
|
||||
if (eventLayer.getBlock(x, y) !== 0) return true;
|
||||
x += dx;
|
||||
y += dy;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class BlockedLaserConverter implements IMapDamageConverter<
|
||||
IEnemyAttr,
|
||||
IHeroAttr
|
||||
> {
|
||||
convert(
|
||||
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>,
|
||||
context: IEnemyContext<IEnemyAttr, IHeroAttr>
|
||||
): IMapDamageView<any>[] {
|
||||
const special = handler.enemy.getSpecial<ISpecial43Value>(43);
|
||||
if (!special) return [];
|
||||
const face = handler.state.faceManager.get(FaceGroup.Dir4)!;
|
||||
return [new BlockedLaserDamageView(context, handler.locator, special, face)];
|
||||
}
|
||||
}
|
||||
|
||||
const mapDamage = state.enemyContext.getMapDamage();
|
||||
mapDamage?.useConverter(new BlockedLaserConverter());
|
||||
```
|
||||
|
||||
示例用 `eventLayer.getBlock(x, y)` 判断遮挡;如果你的项目把地形放在别的图层,替换成对应图层的 `getBlock` 即可。`useConverter` 会替换掉内置转换器,若要保留内置五类,需要在转换器里同时收集内置视图,或修改 `MainMapDamageConverter`。
|
||||
|
||||
## 合并器
|
||||
|
||||
`MainMapDamageReducer.reduce` 的规则很直接:
|
||||
|
||||
- 把所有来源的 `damage` 相加;
|
||||
- 取伤害最大的那条来源的 `type` 作为合并后的类型;
|
||||
- 把所有来源的 `extra.catch` 与 `extra.repulse` 合并。
|
||||
|
||||
多个地图伤害落在同一格时,勇士只会受到合并后的一份伤害。需要不同的取舍规则时,实现 `IMapDamageReducer` 并 `useReducer` 替换即可。
|
||||
|
||||
## 实际结算
|
||||
|
||||
移动时由 `EnemyMoveHooks.onStepEnd` 调用 `handleMapDamage`:先用 `mapDamage.getReducedDamage(location)` 取合并伤害,再扣减勇士生命、发出提示,并处理阻击后退与捕捉强制战斗。当前 `src/` 中地图伤害的实际结算仍在补齐;以上是移动钩子当前的处理方式,若你使用的版本结算逻辑不同,以当时代码为准。
|
||||
114
docs/intermediate/enemy-status-hooks.md
Normal file
114
docs/intermediate/enemy-status-hooks.md
Normal file
@ -0,0 +1,114 @@
|
||||
# 状态钩子
|
||||
|
||||
状态类特殊属性(中毒、衰弱、重生等)不会在一次计算里结束:它们要在移动、战斗、切换楼层等时机反复生效。这类属性必须借助钩子实现,而且一个状态往往需要**多个钩子**协同。钩子本身的用法见[钩子](./hooks.md),整体流程见[战斗总览](./combat-overview.md)。
|
||||
|
||||
## 为什么一个状态要用多个钩子
|
||||
|
||||
以中毒为例,它会从两个时机扣血:
|
||||
|
||||
- 勇士正常走一步时,在移动钩子的 `onStepEnd` 里扣血;
|
||||
- 勇士通过寻路瞬移到目标点时,在寻路钩子的 `onAfterTeleportTo` 里按路径长度扣血。
|
||||
|
||||
只挂其中一个钩子,另一种移动方式就会漏掉中毒。重生也一样:战后要先记录位置,切层时再把怪物放回去,两个时机分别属于战斗钩子与楼层钩子。
|
||||
|
||||
## 三类内置状态
|
||||
|
||||
| 状态 | 使用的钩子 | 实现位置 | 行为 |
|
||||
|------|------------|----------|------|
|
||||
| 中毒 | `onStepEnd` + `onAfterTeleportTo` | `EnemyMoveHooks` / `EnemyPathfindingHooks` | 读取 `poison` flag,按移动扣血;瞬移时按路径长度处理 |
|
||||
| 衰弱 | `onAfterCombat` + 修饰器 | `EnemyCombatHooks` | 战后取最高虚弱值,用 `PercentageModifier` 降低攻防 |
|
||||
| 重生 | `onAfterCombat` + `onSetFloor` | `EnemyCombatHooks` / `EnemyChangeFloorHooks` | 战后记录 `rebirth` flag;切层时把记录的事件块放回 |
|
||||
|
||||
补充说明:
|
||||
|
||||
- 中毒的每步扣血由 `EnemyMoveHooks.handlePoison` 处理,移动钩子还会顺带调用 `handleMapDamage`;
|
||||
- 衰弱在 `EnemyCombatHooks` 构造时向勇士的 `atk` 与 `def` 挂两个 `PercentageModifier`,战后通过 `setWeakValue` 设置负百分比;
|
||||
- 重生的战后记录以楼层为键存入 `rebirth` flag,切换楼层时由 `EnemyChangeFloorHooks.onSetFloor` 读取并放回。
|
||||
|
||||
同一个 `onAfterCombat` 还处理诅咒、仇恨、退化等状态,因此新增状态时要先检查现有钩子是否已经覆盖了你的时机。
|
||||
|
||||
## 注册
|
||||
|
||||
这些钩子在 `initializeEnemy` 中注册:
|
||||
|
||||
```ts
|
||||
state.hero.location.mover.addHook(new EnemyMoveHooks(state));
|
||||
state.pathfinding.addHook(new EnemyPathfindingHooks(state));
|
||||
state.combat.addHook(new EnemyCombatHooks(state));
|
||||
state.hero.location.addHook(new EnemyChangeFloorHooks(state));
|
||||
```
|
||||
|
||||
四个注册点分别对应:
|
||||
|
||||
- 勇士移动器:处理正常移动;
|
||||
- 寻路系统:处理瞬移;
|
||||
- 战斗流程:处理战前/战后;
|
||||
- 勇士位置:处理切换楼层。
|
||||
|
||||
## 战斗流程中的时序
|
||||
|
||||
战斗钩子的执行顺序是:
|
||||
|
||||
1. 战前脚本 `before`;
|
||||
2. `onBeforeCombat` 钩子;
|
||||
3. 战后脚本 `after`;
|
||||
4. `onAfterCombat` 钩子。
|
||||
|
||||
因此,想在「奖励结算之后」处理状态,应该用 `onAfterCombat`;想在战斗开始前拦截,应该用脚本的 `before`,见[战斗脚本](./enemy-combat-script.md)。
|
||||
|
||||
## 示例:下次战斗伤害乘二
|
||||
|
||||
这个状态需要两处配合:战斗钩子负责「挂上状态」,伤害计算器负责「消费状态」。先定义一个无参数特殊属性(code 45):
|
||||
|
||||
```ts
|
||||
// special.ts:45 - 蓄力
|
||||
map.set(
|
||||
45,
|
||||
defineNonePropertySpecial(45, {
|
||||
name: '蓄力',
|
||||
desc: '战斗后,下一次战斗伤害翻倍。',
|
||||
color: '#F99'
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
再写一个战斗钩子。它不修改内置的 `EnemyCombatHooks`,而是作为另一个钩子并存:
|
||||
|
||||
```ts
|
||||
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
import { ICombatFlowHooks, IEnemyDamageInfo } from '@user/data-system';
|
||||
|
||||
export class ChargedHooks implements ICombatFlowHooks<IEnemyAttr, IHeroAttr> {
|
||||
constructor(readonly state: ICoreState) {}
|
||||
|
||||
async onAfterCombat(
|
||||
info: IEnemyDamageInfo<IEnemyAttr, IHeroAttr>
|
||||
): Promise<void> {
|
||||
if (info.handler.enemy.hasSpecial(45)) {
|
||||
this.state.flags.setFieldValue('nextBattleDouble', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.combat.addHook(new ChargedHooks(state));
|
||||
```
|
||||
|
||||
最后在伤害计算器里消费这个 flag:
|
||||
|
||||
```ts
|
||||
const double = handler.state.flags.getFieldValueDefaults(
|
||||
'nextBattleDouble',
|
||||
false
|
||||
);
|
||||
if (double) {
|
||||
damage *= 2;
|
||||
handler.state.flags.deleteField('nextBattleDouble');
|
||||
}
|
||||
```
|
||||
|
||||
把这段逻辑放在 `calculate` 返回之前即可。伤害公式的其余部分见[伤害计算](./enemy-calculator.md)。
|
||||
|
||||
这个例子体现了「一个状态要用多个钩子/系统」:`onAfterCombat` 只负责记录状态,真正影响伤害的是计算器。如果只在钩子里改 flag 而不改计算器,状态不会有任何效果。
|
||||
|
||||
状态里用到的临时加减或百分比,优先用修饰器实现,概念与 `save` 参数见[勇士属性](./hero-attribute.md)。
|
||||
Loading…
Reference in New Issue
Block a user