docs(10-11): 用户修订循环 batch2(代码讲解/修正)

按用户领域视角修订循环 batch 2(U-12/U-13/U-16/U-17/U-18/U-19)修正战斗各篇:

- U-12/U-16:转换器 / 视图 / 效果 / 计算器 / 脚本 / 钩子代码块补逐行中文注释,并用 [模板] / [需改] 标出可照抄与必须修改的部分
- U-13:新增「注册在哪里」,给出 initializeEnemy 函数体(无关行省略)与注册调用位置
- U-17:每篇明确「改内置实现」vs「写自己的类」的区别与各自效果
- U-18:enemy-map-damage 的「受地形遮挡的激光」示例替换为更简单的「八方向激光」(FaceGroup.Dir8)
- U-19:enemy-calculator 补「计算器必须无副作用」;enemy-status-hooks 的「下次战斗伤害乘二」改为钩子挂状态 + 计算器只读 + 战斗脚本消费

docs 8 篇;10-11-READER-REVIEW.md 追加 batch 2 记录。
This commit is contained in:
unanmed 2026-10-18 14:27:56 +08:00
parent 8c5e8979e1
commit 5c77676bca
9 changed files with 444 additions and 80 deletions

View File

@ -299,3 +299,26 @@
- 构建门禁:`cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build"` → **green**,输出 `build complete`,dead link = 0。
- 备注(待用户确认):`combat/types.ts` 当前 `query: () => IEnemyContext` / `for(ctx: IEnemyContext)` 的签名仍为可写 `IEnemyContext`;U-15 已按用户口径把**文档**改为只读 `IReadonlyEnemyContext`。若需源码同步为只读,请另行派发。
---
## 用户循环 · batch 2 记录(代码讲解 / 修正)
**范围:** 用户修订循环第 1 轮 batch 2(U-12、U-13、U-16、U-17、U-18、U-19)。对战斗各篇的代码块补逐行中文注释、标注「模板 / 需改」,补注册上下文,明确「改内置 vs 自己写」,替换地图伤害示例,并修正计算器副作用问题。
### 逐条处置
| # | 处置 | 改动文件 |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| U-12/U-16 | 战斗各篇的转换器 / 视图 / 效果 / 计算器 / 脚本 / 钩子代码块全部补**逐行中文注释**,并用 `[模板]`(可原样照抄)与 `[需改]`(必须按需求修改)标注每行性质;覆盖 `enemy-aura`(两个示例)、`enemy-query`(抱团 / 同化 / 号角)、`enemy-final`(仿攻)、`enemy-calculator`(等差数列)、`enemy-map-damage`(八方向激光)、`enemy-combat-script`(战前 / 战后)、`enemy-status-hooks`(蓄力示例)。 | `enemy-aura.md`、`enemy-query.md`、`enemy-final.md`、`enemy-calculator.md`、`enemy-map-damage.md`、`enemy-combat-script.md`、`enemy-status-hooks.md` |
| U-13 | 在注册相关篇目各加「注册在哪里」,给出 `src/data/enemy/initialize.ts` 的 `initializeEnemy` 函数体(无关行以「...省略...」注释略去),标出注册调用(光环转换器 / 最终效果 / 地图伤害转换器 / 战斗脚本 / 查询效果)应放的位置。 | `enemy-aura.md`、`enemy-final.md`、`enemy-map-damage.md`、`enemy-combat-script.md`、`enemy-query.md` |
| U-17 | 每篇明确「改内置实现」与「写自己的类」的区别:`enemy-specials`(只改内置 `special.ts`,不建类)、`enemy-calculator`(改 `MainDamageCalculator` vs 自写 + `useCalculator` 替换)、`enemy-combat-script`(改 `MainCombatScript` vs 自写 + `addCombatScript` 追加)、`enemy-map-damage`(改内置 vs 自写 + `useConverter` 替换)、`enemy-final`(改 `MainEnemyFinalEffect` vs 自写 + `registerFinalEffect`)、`enemy-aura` / `enemy-query`(必须自写类,说明模板 vs 需改与各自效果)。 | 战斗各篇 |
| U-18 | `enemy-map-damage.md` 的「受地形遮挡的激光」示例替换为更简单的「八方向激光」:内置激光用 `FaceGroup.Dir4`,示例改用 `FaceGroup.Dir8`(八方向),其余逻辑与内置 `LaserDamageView` 一致;已核对 `FaceGroup`(`packages-user/data-common/src/common/faceManager.ts`)、`RayRange`(`packages/common/src/utils/range.ts`)与 `LaserDamageView`(`src/data/enemy/mapDamage.ts`)的方向 API。 | `enemy-map-damage.md` |
| U-19 | `enemy-calculator.md` 新增「计算器必须无副作用」warning,说明其在临界 / 显示 / 手册等场景被反复调用;`enemy-status-hooks.md` 的「下次战斗伤害乘二」示例改为正确做法:战斗钩子挂状态 → 计算器**只读** flag → 战斗脚本 `after`(每场只跑一次)消费 flag;并解释「在计算器里消费」为何失败。 | `enemy-calculator.md`、`enemy-status-hooks.md` |
### 校验
- 源码核对:`src/data/enemy/initialize.ts`(`registerAuraConverter` / `registerFinalEffect` / `addCombatScript` / `useConverter` / `useReducer` / `useCalculator` 的注册位置)、`src/data/enemy/mapDamage.ts`(`LaserDamageView` 用 `FaceGroup.Dir4`;`IMapDamageView` 需实现 `getRange` / `getRangeParam` / `getDamageAt` / `getDamageWithoutCheck`)、`packages-user/data-common/src/common/faceManager.ts`(`FaceGroup.Dir8`、`mapMovement()`)、`packages/common/src/utils/range.ts`(`RayRange` 跳过零偏移方向)、`packages-user/data-system/src/combat/combat.ts`(`combatFlow` 先算伤害、再跑脚本 `before` / `after`、最后 `onBeforeCombat` / `onAfterCombat`;脚本 `after` 每场只跑一次)、`packages-user/data-system/src/combat/damage.ts`(`calculate` 在临界二分中被反复调用)。
- 结构门禁:24 篇全部 CRLF、单 H1(`STRUCT-OK`)。
- 格式门禁:`pnpm exec prettier --write docs/intermediate/*.md`(D-45)已跑,被改 6 篇格式化通过。
- 构建门禁:`cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build"` → **green**,输出 `build complete`,dead link = 0。

View File

@ -72,30 +72,40 @@
下方示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」。
### 改内置,还是自己写
光环与前面几篇不同:**它必须写一个「转换器 + 视图」的类**,没有「只改内置一处」的省事办法。原因是「怎么认出这条光环、怎么影响目标」在光环里是拆成两个对象表达的,引擎没有提供更简单的注册入口。(若你的需求恰好与内置三条光环同构,也可以直接改 `src/data/enemy/aura.ts` 里的内置实现。)
- **转换器**:只有 `shouldConvert` 与 `convert` 两个方法,负责「认领」特殊属性并构造视图。`shouldConvert` 通常照抄、只换一个 code;`convert` 里构造视图那一行要按你的视图构造函数改。
- **视图**:真正描述效果的地方(范围、优先级、`apply` / `applySpecial`),这部分必须按你的需求写。
下面两个示例用 `[模板]` 标出可原样照抄的行(多为依赖导入、接口签名、`shouldConvert`),用 `[需改]` 标出必须按需求改写的行(code、范围、优先级,以及 `apply` / `applySpecial` 里做的事)。
## 示例:基础光环(减少周围怪物的金币与经验)
先从一个只改基础属性的例子开始:让范围内的怪物金币与经验下降。第一步是定义一条带参数的特殊属性(code 取一个未占用的值,这里用 30):
```ts
// [需改] 在 src/data/enemy/special.ts 里新增这条属性定义(编号换成你没用过的)
/** 30 - 削弱光环 */
export interface ISpecial30Value {
range: number;
square: boolean;
moneyBuff: number;
expBuff: number;
range: number; // [需改] 光环范围值
square: boolean; // [需改] true 矩形 / false 十字
moneyBuff: number; // [需改] 金币变化百分比(负数表示下降)
expBuff: number; // [需改] 经验变化百分比
}
// 30 - 削弱光环
map.set(
30,
30, // [需改] 编号:示例自选的未占用号
defineCommonSerializableSpecial<ISpecial30Value>(
30,
{ range: 1, square: false, moneyBuff: -20, expBuff: -20 },
{ range: 1, square: false, moneyBuff: -20, expBuff: -20 }, // [需改] 默认参数
{
name: '削弱光环',
name: '削弱光环', // [需改] 显示名称
desc: special =>
`周围怪物金币与经验下降${Math.abs(special.value.moneyBuff)}%。`,
color: '#9AC'
`周围怪物金币与经验下降${Math.abs(special.value.moneyBuff)}%。`, // [需改] 悬停说明
color: '#9AC' // [需改] 名称颜色
}
)
);
@ -104,6 +114,7 @@ map.set(
然后写转换器与视图:
```ts
// [模板] 依赖导入:@motajs/common 提供范围对象,其余来自用户包
import {
IManhattanRangeParam,
IRange,
@ -123,43 +134,55 @@ import { IReadonlyEnemy, ISpecial } from '@user/data-base';
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import { ISpecial30Value } from './special';
// [模板] 两个范围对象各建一次复用
const MANHATTAN_RANGE = new ManhattanRange();
const RECT_RANGE = new RectRange();
// [需改] 类名:换成你的转换器名
export class WeakAuraConverter implements IAuraConverter<
IEnemyAttr,
IHeroAttr
> {
// [模板] shouldConvert:认领本转换器负责的 code,通常只需换掉这个判断
shouldConvert(special: ISpecial<any>): boolean {
return special.code === 30;
return special.code === 30; // [需改] code
}
// [模板] convert 签名固定:把认领到的特殊属性翻译成视图
convert(
special: ISpecial<ISpecial30Value>,
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>
): WeakAura {
// [需改] 构造视图,并决定把哪些信息传给它(这里传了来源怪物、属性、位置)
return new WeakAura(handler.enemy, special, handler.locator);
}
}
// [需改] 类名:换成你的视图名
export class WeakAura implements IEnemyAuraView<
IEnemyAttr,
IRectRangeParam | IManhattanRangeParam,
ISpecial30Value
> {
// [需改] priority:越大越先执行;高优先级的结果能被低优先级看到
readonly priority = 8;
// [需改] 本光环改基础属性(走 apply)还是改特殊属性(走 applySpecial)
readonly couldApplyBase = true;
readonly couldApplySpecial = false;
// [模板] 范围对象在构造函数里挑选,getRangeParam 负责给参数
readonly range: IRange<IRectRangeParam | IManhattanRangeParam>;
// [模板] 构造函数把来源怪物、特殊属性、位置存下来
constructor(
readonly enemy: IReadonlyEnemy<IEnemyAttr>,
readonly special: ISpecial<ISpecial30Value>,
readonly locator: ITileLocator
) {
// [模板] 按参数里的 square 选矩形或曼哈顿范围
this.range = special.value.square ? RECT_RANGE : MANHATTAN_RANGE;
}
// [模板] 生成范围参数:矩形用左上角与宽高,曼哈顿用中心点与半径
getRangeParam(): IRectRangeParam | IManhattanRangeParam {
const { range, square } = this.special.value;
if (square) {
@ -177,13 +200,17 @@ export class WeakAura implements IEnemyAuraView<
};
}
// [需改] apply:真正修改目标基础属性;baseEnemy 是未修改的原始怪物
apply(
handler: IEnemyHandler<IEnemyAttr, IHeroAttr>,
baseEnemy: IReadonlyEnemy<IEnemyAttr>
): void {
// [需改] 从参数里取出要用的字段
const { moneyBuff, expBuff } = this.special.value;
// [模板] handler.enemy 是被光环命中的目标怪物
const enemy = handler.enemy;
if (moneyBuff !== 0) {
// [需改] 以 baseEnemy 的原始值算百分比,避免多个光环互相叠乘
const money = (baseEnemy.getAttribute('money') * moneyBuff) / 100;
enemy.addAttribute('money', Math.floor(money));
}
@ -193,6 +220,7 @@ export class WeakAura implements IEnemyAuraView<
}
}
// [模板] 本光环不改特殊属性,返回 null
applySpecial(): IEnemySpecialModifier<IEnemyAttr> | null {
return null;
}
@ -212,18 +240,20 @@ state.enemyContext.registerAuraConverter(new WeakAuraConverter());
如果光环要给目标「加一条特殊属性」,而不是直接改基础属性,就把逻辑放进 `applySpecial`。下面的例子给范围内没有普通光环的怪物加上 code 23 的普通光环:
```ts
// [需改] 这条无参属性定义同样加进 src/data/enemy/special.ts
// special.ts:31 - 传染光环
map.set(
31,
31, // [需改] 编号
defineNonePropertySpecial(31, {
name: '传染光环',
desc: '同楼层没有光环的怪物获得普通光环。',
color: '#9F9'
name: '传染光环', // [需改] 显示名称
desc: '同楼层没有光环的怪物获得普通光环。', // [需改] 悬停说明
color: '#9F9' // [需改] 名称颜色
})
);
```
```ts
// [模板] 依赖导入
import { FullRange, ITileLocator } from '@motajs/common';
import {
IAuraConverter,
@ -234,13 +264,19 @@ import {
import { IReadonlyEnemy, ISpecial } from '@user/data-base';
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
// [需改] 类名:换成你的修饰器名;它负责「给目标增删改特殊属性」
export class ChainAuraModifier implements IEnemySpecialModifier<IEnemyAttr> {
// [模板] 构造函数存下来源属性
constructor(readonly special: ISpecial<void>) {}
// [需改] add:返回要添加给目标的特殊属性数组
add(handler: IReadonlyEnemyHandler<IEnemyAttr, any>): ISpecial<any>[] {
// [需改] 已经有普通光环(code 23)就不再添加
if (handler.enemy.hasSpecial(23)) return [];
// [模板] 从怪物管理器取一条新的属性实例
const sp = handler.state.enemyManager.getSpecial(23)?.();
if (!sp) return [];
// [需改] 设置这条属性的参数(这里是普通光环的字段)
sp.setValue({
range: 1,
square: false,
@ -248,52 +284,67 @@ export class ChainAuraModifier implements IEnemySpecialModifier<IEnemyAttr> {
atkBuff: 0,
defBuff: 0
});
// [模板] 返回要添加的属性
return [sp];
}
// [模板] 本修饰器不删除属性,返回空数组
delete(): ISpecial<any>[] {
return [];
}
// [模板] 本修饰器不修改已有属性,返回 false
modify(): boolean {
return false;
}
}
// [需改] 类名:换成你的转换器名
export class ChainAuraConverter implements IAuraConverter<
IEnemyAttr,
IHeroAttr
> {
// [模板] shouldConvert:认领 code,通常只换这个判断
shouldConvert(special: ISpecial<any>): boolean {
return special.code === 31;
return special.code === 31; // [需改] code
}
// [模板] convert 签名固定
convert(
special: ISpecial<void>,
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>
): ChainAura {
// [需改] 构造视图
return new ChainAura(handler.enemy, special, handler.locator);
}
}
// [需改] 类名:换成你的视图名
export class ChainAura implements IEnemyAuraView<IEnemyAttr, void, void> {
// [需改] priority = 25 高于内置特殊光环的 20,故先执行
readonly priority = 25;
// [需改] 本光环只改特殊属性:couldApplyBase 为 false,couldApplySpecial 为 true
readonly couldApplyBase = false;
readonly couldApplySpecial = true;
// [需改] 范围:这里用 FullRange 作用于全场
readonly range = new FullRange();
// [模板] 构造函数保存来源信息
constructor(
readonly enemy: IReadonlyEnemy<IEnemyAttr>,
readonly special: ISpecial<void>,
readonly locator: ITileLocator
) {}
// [模板] 范围参数:FullRange 不需要参数
getRangeParam(): void {
return undefined;
}
// [模板] 本光环不改基础属性,apply 留空
apply(): void {}
// [需改] applySpecial:返回修饰器,由上下文按优先级应用到目标
applySpecial(): IEnemySpecialModifier<IEnemyAttr> | null {
return new ChainAuraModifier(this.special);
}
@ -310,3 +361,28 @@ export class ChainAura implements IEnemyAuraView<IEnemyAttr, void, void> {
- 没有来源怪物、始终生效的光环:实现 `IAuraView` 并 `state.enemyContext.addAura(view)`。
`initializeEnemy` 已经注册了 `CommonAuraConverter`、`GuardAuraConverter` 与 `SpecialAuraConverter`。新增转换器时照同样方式追加即可。
### 注册在哪里
`registerAuraConverter` 要写进 `initializeEnemy`。下面是 `src/data/enemy/initialize.ts` 中该函数的样子(省略了无关的行):
```ts
export function initializeEnemy(
state: ICoreState
): IEnemyInitializationReturns {
// ...省略:怪物管理器、特殊属性、上下文、伤害系统、地图伤害的初始化...
const ctx = state.enemyContext;
// 注册光环转换器 —— 内置光环转换器在这里注册
ctx.registerAuraConverter(new CommonAuraConverter());
ctx.registerAuraConverter(new GuardAuraConverter());
ctx.registerAuraConverter(new SpecialAuraConverter());
// [需改] 你自己的光环转换器紧接着追加即可:
// ctx.registerAuraConverter(new WeakAuraConverter());
// ...省略:最终效果、脚本与钩子的注册...
}
```
把 `registerAuraConverter(new WeakAuraConverter())` 放在这几行之后,你的光环就会随 `initializeEnemy` 一起生效。

View File

@ -27,6 +27,17 @@ state.enemyContext.getDamageSystem()?.useCalculator(new MainDamageCalculator());
`initializeEnemy` 在创建 `DamageSystem` 后就调用了 `useCalculator(new MainDamageCalculator())`,因此默认使用内置公式。
:::warning 计算器必须无副作用
计算器在一次战斗里会被**反复调用**:算临界值要二分十几次,渲染伤害数值、图鉴与提示等也可能在真正开打前各调用一次。因此它**必须只读输入、算出结果,不能改动任何外部状态**——不能写 flag、不能删 flag,也不能改怪物或勇士对象。只要存在副作用,同一只怪物在不同时刻就会算出不同结果,临界与显示都会错乱。需要「一次性」生效的状态,请放到别处去改(战斗脚本、钩子等),计算器只负责读取。
:::
### 改内置,还是自己写
- **改内置公式**:直接在 `src/data/enemy/calculator.ts` 的 `MainDamageCalculator.calculate` 里改公式或加分支。多数「调参类」属性(先攻、连击、破甲等)都实现在这里。
- **写自己的计算器**:实现 `IDamageCalculator` 接口,再用 `useCalculator` 替换。适合整条公式都要换、或不想改动内置文件时。
两者只能二选一:`useCalculator` 是**替换**当前计算器,选了自己的就会把内置的整个换掉,内置的那些分支需要你在自己的 `calculate` 里重新保留。
## 内置公式逐段导读
与其一次背下整条公式,不如按顺序看每一段在算什么。`MainDamageCalculator.calculate`(`src/data/enemy/calculator.ts`)按以下顺序计算:
@ -59,9 +70,10 @@ state.enemyContext.getDamageSystem()?.useCalculator(new MainDamageCalculator());
「等差数列属性」指怪物攻击每回合按固定值递增。属性本身在一次计算中是常量,所以不能靠给 `atk` 加值实现;要在 `calculate` 里把「每回合伤害」按等差数列求和:
示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」。
示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」。下面用 `[模板]` 标出可原样照抄的行,用 `[需改]` 标出必须按需求修改的行。
```ts
// [模板] 依赖导入:类型分别来自 @user/data-common 与 @user/data-system
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import {
CriticalableHeroStatus,
@ -70,32 +82,42 @@ import {
IReadonlyEnemyHandler
} from '@user/data-system';
// [需改] 类名:换成你自己的计算器名
export class ArithmeticCalculator implements IDamageCalculator<
IEnemyAttr,
IHeroAttr
> {
// [模板] calculate 签名固定:读 handler 里的 enemy 与 hero,返回 { damage, turn }
calculate(
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>
): IEnemyDamageInfoBase {
// [模板] 取出只读的怪物与勇士对象(只读,供读取,不可改写)
const { enemy, hero } = handler;
// [模板] 勇士每回合对怪物造成的伤害 = 勇士攻击 - 怪物防御
const heroPerDamage =
hero.getFinalAttribute('atk') - enemy.getAttribute('def');
// [模板] 打不动(≤ 0)时返回 Infinity,表示无法击败
if (heroPerDamage <= 0) {
return { damage: Infinity, turn: 0 };
}
// [模板] 回合数 = 怪物血量 / 每回合伤害,向上取整
const turn = Math.ceil(enemy.getAttribute('hp') / heroPerDamage);
// [需改] 怪物每回合的基础伤害:这里按「怪物攻击 - 勇士防御」并最低取 0
const base = Math.max(
0,
enemy.getAttribute('atk') - hero.getFinalAttribute('def')
);
// [需改] 公差:每回合递增的伤害(本示例固定为 5)
const step = 5;
// 首项 base,公差 step,共 turn 项
// [需改] 等差数列求和:首项 base,公差 step,共 turn 项
const damage = turn * base + (step * turn * (turn - 1)) / 2;
// [模板] 返回结果,damage 向下取整
return { damage: Math.floor(damage), turn };
}
// [模板] getCriticalLimit 的签名同样是固定的:这里直接返回勇士当前最终值
getCriticalLimit(
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>,
attribute: CriticalableHeroStatus<IHeroAttr>

View File

@ -36,6 +36,13 @@ state.combat.addCombatScript(new MainCombatScript());
- **战前**:非强制战斗时,如果勇士当前生命小于本次伤害,返回 `false`,阻止战斗;
- **战后**:把怪物与支援者的经验、金币加到勇士身上;`curse` flag 为 `true` 时不加;最后发出一次全局提示。
### 改内置,还是自己写
- **改内置脚本**:直接改 `src/data/enemy/script.ts` 的 `MainCombatScript`(内置的战前拦截与战后结算就写在里面)。
- **写自己的脚本**:实现 `ICombatScript` 接口,再用 `addCombatScript` 追加。适合额外规则,或不想改动内置文件时。
`addCombatScript` 是**追加**而不是替换:内置脚本仍会执行,新脚本按 `priority` 与它一起排序;但同优先级的脚本不能重复注册。
## 脚本 vs 钩子
战斗前后能做事的除了脚本,还有钩子。两者的区别主要在「能不能改变战斗的走向」和「多个监听者如何共存」:
@ -52,9 +59,10 @@ state.combat.addCombatScript(new MainCombatScript());
## 战前示例:拥有某特殊属性时无法战斗
下面的脚本检查怪物是否拥有 code 18 的特殊属性(无敌,编号见[特殊属性](./enemy-specials.md)的「内置属性编号表」),有则直接放弃战斗(示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」):
下面的脚本检查怪物是否拥有 code 18 的特殊属性(无敌,编号见[特殊属性](./enemy-specials.md)的「内置属性编号表」),有则直接放弃战斗(示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」)。代码中用 `[模板]` 标出可原样照抄的行,用 `[需改]` 标出必须按需求修改的行。
```ts
// [模板] 依赖导入:类型分别来自 @user/data-common 与 @user/data-system
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import {
ICombatFlowHandler,
@ -62,23 +70,32 @@ import {
IEnemyDamageInfo
} from '@user/data-system';
// [需改] 类名:换成你的脚本名
export class SealCombatScript implements ICombatScript<IEnemyAttr, IHeroAttr> {
// [需改] priority:越大越先执行;内置脚本是 0
readonly priority = 100;
// [模板] before 签名固定:战前执行,返回 false 即放弃战斗
async before(
info: IEnemyDamageInfo<IEnemyAttr, IHeroAttr>,
handler: ICombatFlowHandler<IEnemyAttr, IHeroAttr>
): Promise<boolean> {
// [需改] 判断条件:怪物带「无敌」(code 18)就拦下
if (handler.enemy.hasSpecial(18)) {
// [需改] 向玩家提示(可换成别的处理)
handler.state.emitTip('这个怪物无法被击败。');
// [模板] 返回 false:放弃本次战斗
return false;
}
// [模板] 返回 true:允许继续
return true;
}
// [模板] after 也必须实现;本脚本战后不做任何事,留空即可
async after(): Promise<void> {}
}
// [需改] 注册这行要挪进 initializeEnemy(见文末「注册在哪里」)
state.combat.addCombatScript(new SealCombatScript());
```
@ -91,6 +108,7 @@ state.combat.addCombatScript(new SealCombatScript());
下面的脚本在每场战斗后给勇士回复 20 点生命:
```ts
// [模板] 依赖导入
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import {
ICombatFlowHandler,
@ -98,27 +116,58 @@ import {
IEnemyDamageInfo
} from '@user/data-system';
// [需改] 类名:换成你的脚本名
export class HealAfterCombatScript implements ICombatScript<
IEnemyAttr,
IHeroAttr
> {
// [需改] priority:设成负值,排在内置脚本之后执行
readonly priority = -10;
// [模板] before 也要实现;本脚本不拦截战斗,直接返回 true
async before(): Promise<boolean> {
return true;
}
// [模板] after 签名固定:战后执行
async after(
info: IEnemyDamageInfo<IEnemyAttr, IHeroAttr>,
handler: ICombatFlowHandler<IEnemyAttr, IHeroAttr>
): Promise<void> {
// [需改] 真正做的事:给勇士回 20 点血
handler.hero.add('hp', 20);
}
}
// [需改] 注册
state.combat.addCombatScript(new HealAfterCombatScript());
```
`priority = -10` 让它在内置脚本之后执行,避免在奖励结算之前改动勇士状态。`handler.hero` 是可修改对象,直接 `add` 即可。
`priority` 只需要记住一句:数值越大越先执行;战前与战后各自按这个顺序遍历脚本列表。
## 注册在哪里
`addCombatScript` 要写进 `initializeEnemy`。下面是 `src/data/enemy/initialize.ts` 中该函数的样子(省略了无关的行):
```ts
export function initializeEnemy(
state: ICoreState
): IEnemyInitializationReturns {
// ...省略:怪物管理器、特殊属性、上下文、伤害系统、地图伤害的初始化...
const ctx = state.enemyContext;
// ...省略:光环转换器与最终效果的注册...
// 添加默认战前战后脚本 —— 内置脚本在这里注册
state.combat.addCombatScript(new MainCombatScript());
// [需改] 你自己的脚本紧接着追加即可:
// state.combat.addCombatScript(new SealCombatScript());
// ...省略:钩子的注册...
}
```
把 `addCombatScript(...)` 放在内置脚本那一行之后,你的脚本就会随 `initializeEnemy` 一起生效。

View File

@ -17,7 +17,16 @@
state.enemyContext.registerFinalEffect(effect);
```
`initializeEnemy` 已经注册了内置的 `MainEnemyFinalEffect`(`src/data/enemy/final.ts`)。它处理两个属性:
### 改内置,还是自己写
最终效果有两种做法,按需要选一种:
- **改内置实现**:直接在 `src/data/enemy/final.ts` 的 `MainEnemyFinalEffect.apply` 里加一个分支。内置的坚固、模仿就是这么写的,适合与现有逻辑同类的机制。
- **写自己的类**:实现 `IEnemyFinalEffect` 接口,再用 `registerFinalEffect` 注册。适合独立机制,或不想改动内置文件时。
两种做法进入的是同一条流水线,会按各自的 `priority` 与内置效果一起执行。下面示例用「写自己的类」演示。
`initializeEnemy`(`src/data/enemy/initialize.ts`)已经注册了内置的 `MainEnemyFinalEffect`(`src/data/enemy/final.ts`)。它处理两个属性:
- **坚固(code 3)**:把怪物防御提升到不低于「勇士攻击 - 1」;
- **模仿(code 8)**:把怪物攻击与防御设置为勇士的最终攻击与最终防御。
@ -48,30 +57,41 @@ state.enemyContext.registerFinalEffect(effect);
示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」。
下面代码中用 `[模板]` 标出可以原样照抄的部分,用 `[需改]` 标出必须按你的需求修改的部分。特殊属性定义写在 `src/data/enemy/special.ts`:
```ts
// special.ts:32 - 仿攻
// [需改] 照 special.ts 里其他属性的写法,新增这样一条定义,编号与名称换成你自己的
map.set(
32,
32, // [需改] 编号:示例自选的未占用号
defineNonePropertySpecial(32, {
name: '仿攻',
desc: '怪物攻击等于勇士攻击。',
color: '#B0C0DD'
name: '仿攻', // [需改] 显示名称
desc: '怪物攻击等于勇士攻击。', // [需改] 悬停时显示的说明
color: '#B0C0DD' // [需改] 名称的显示颜色
})
);
```
然后写最终效果本身:
```ts
// [模板] 依赖导入:类型分别来自 @user/data-common 与 @user/data-system
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import { IEnemyFinalEffect, IEnemyHandler } from '@user/data-system';
// [需改] 类名:换成你自己的效果名
export class AttackCopyFinal implements IEnemyFinalEffect<
IEnemyAttr,
IHeroAttr
> {
// [需改] priority:数值越大越先执行;内置最终效果是 0
readonly priority = 0;
// [模板] apply 的签名是固定的,引擎在最终阶段调用它并传入 handler
apply(handler: IEnemyHandler<IEnemyAttr, IHeroAttr>): void {
// [需改] 触发条件:只有带本效果(示例 code 32)的怪物才处理
if (!handler.enemy.hasSpecial(32)) return;
// [需改] 真正做的事:把怪物攻击设为勇士的最终攻击
handler.enemy.setAttribute(
'atk',
handler.hero.getFinalAttribute('atk')
@ -79,6 +99,7 @@ export class AttackCopyFinal implements IEnemyFinalEffect<
}
}
// [需改] 注册这行要挪进 initializeEnemy(见下方「注册在哪里」)
state.enemyContext.registerFinalEffect(new AttackCopyFinal());
```
@ -86,6 +107,36 @@ state.enemyContext.registerFinalEffect(new AttackCopyFinal());
`priority` 只需要记住一句:数值越大越先执行;内置最终效果的 `priority` 是 `0`。
### 注册在哪里
`registerFinalEffect` 要写进 `initializeEnemy`。下面是 `src/data/enemy/initialize.ts` 中该函数的样子(省略了无关的行):
```ts
export function initializeEnemy(
state: ICoreState
): IEnemyInitializationReturns {
// ...省略:怪物管理器、特殊属性、上下文的初始化...
const ctx = state.enemyContext;
// ...省略:伤害系统、地图伤害的初始化与注册...
// 注册光环转换器
ctx.registerAuraConverter(new CommonAuraConverter());
ctx.registerAuraConverter(new GuardAuraConverter());
ctx.registerAuraConverter(new SpecialAuraConverter());
// 注册怪物最终效果 —— 内置效果就在这里注册
ctx.registerFinalEffect(new MainEnemyFinalEffect());
// [需改] 你自己的最终效果紧接着追加即可:
// ctx.registerFinalEffect(new AttackCopyFinal());
// ...省略:脚本与钩子的注册...
}
```
把注册调用放在内置效果那一行之后,你的效果就会随 `initializeEnemy` 一起生效。
## 与伤害计算的分工
最终效果改的是怪物的基础属性,伤害计算器负责决定「这些属性如何变成伤害与回合数」。如果某个效果只是让怪物攻击更高,优先考虑写成光环、查询或最终效果;只有需要改变公式本身时,才去改[伤害计算](./enemy-calculator.md)。

View File

@ -49,37 +49,49 @@ mapDamage?.useConverter(new MainMapDamageConverter());
- 「捕捉」的视图类名沿用 `AmbushDamageView`(伏击),它读取的是 code 25「捕捉」。它返回 `MapDamageType.Unknown`,因为 `MapDamageType` 没有「捕捉」这一项;伤害恒为 0,作用是产生 `extra.catch`,由移动钩子触发强制战斗。捕捉本身不造成伤害是设计如此,不是未补齐的功能。
- `BetweenDamageView` 读取全局 flag `betweenAttackMax`,决定夹击伤害是否取「半血、当前怪物伤害、对侧怪物伤害」三者的较小值;对侧怪物通过 `hasSpecial(14)` 识别,与夹击自身使用的是同一个编号。
## 示例:受地形遮挡的激光
## 示例:八方向激光
内置激光使用 `RayRange` 沿四个方向发射,不判断中间是否被地形挡住。下面的示例新增一个「遮挡激光」特殊属性(code 43),沿射线检查路径,遇到非空图块就停止:
内置激光(code 22)用 `FaceGroup.Dir4`(上下左右四个方向)沿射线发射。下面的示例新增一个「八方向激光」特殊属性(code 43),改用 `FaceGroup.Dir8`(再加上四个斜向,共八个方向)发射,其余逻辑与内置激光一致。
示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」;`map.set(...)` 的 `map` 是 `special.ts` 中 `createSpecials(state)` 建的属性表,见[特殊属性](./enemy-specials.md)。
示例里出现的几个名字先简单说明:`FaceGroup.Dir4` 是「四方向」朝向组,`faceManager` 按朝向组管理朝向;`dir.mapMovement()` 把朝向处理器映射成各方向对应的移动量,示例据此取出四个方向;`IFaceHandler` / `IFaceDescriptor` 分别是「朝向处理器」与「单个朝向」的类型;`MapDamageType.Layer` 是伤害类型枚举里的「激光伤害」(定义在 `src/data/enemy/types.ts`)。
示例里出现的几个名字先简单说明:`FaceGroup.Dir8` 是「八方向」朝向组,`faceManager` 按朝向组管理朝向;`dir.mapMovement()` 把朝向处理器映射成各方向对应的移动量,示例据此取出八个方向(其中「原地不动」的未知方向偏移为零,`RayRange` 会自动跳过它);`IFaceHandler` / `IFaceDescriptor` 分别是「朝向处理器」与「单个朝向」的类型;`MapDamageType.Layer` 是伤害类型枚举里的「激光伤害」(定义在 `src/data/enemy/types.ts`)。
### 改内置,还是自己写
- **改内置**:直接改 `src/data/enemy/mapDamage.ts` 里的 `LaserDamageView` / `MainMapDamageConverter`,或往转换器里加分支。内置五类地图伤害都实现在那里。
- **写自己的**:实现 `IMapDamageView`(描述一个来源的影响范围与某点的伤害)与 `IMapDamageConverter`(把特殊属性翻译成视图),再用 `useConverter` 注册。下面的示例就是这种写法。
`useConverter` 是**替换**当前转换器:选了自己的就把内置的整个换掉,若要同时保留内置五类,需要在自己的转换器里把它们的视图也收集进去,或直接改内置转换器。
下面代码用 `[模板]` 标出可原样照抄的行,用 `[需改]` 标出必须按需求修改的行。先在 `src/data/enemy/special.ts` 登记这条属性:
```ts
/** 43 - 遮挡激光 */
/** 43 - 八方向激光 */
export interface ISpecial43Value {
damage: number;
damage: number; // [需改] 参数:激光造成的伤害
}
// 43 - 遮挡激光
// 43 - 八方向激光
map.set(
43,
43, // [需改] 编号:示例自选的未占用号
defineCommonSerializableSpecial<ISpecial43Value>(
43,
{ damage: 50 },
{ damage: 50 }, // [需改] 默认参数:默认 50 点伤害
{
name: '遮挡激光',
name: '八方向激光', // [需改] 显示名称
desc: special =>
`同行或同列时造成${special.value.damage}点伤害,被地形遮挡则无效。`,
color: '#D0A0DD'
`同行、同列或同斜线时造成${special.value.damage}点伤害。`, // [需改] 悬停说明
color: '#D0A0DD' // [需改] 名称颜色
}
)
);
```
再写伤害视图与转换器:
```ts
// [模板] 依赖导入:类型分别来自 @motajs/common、@user/data-system、@user/data-base 与 @user/data-common
import {
IFaceDescriptor,
IFaceHandler,
@ -100,24 +112,31 @@ 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> {
// [需改] 类名:换成你的视图名
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,
@ -126,6 +145,7 @@ export class BlockedLaserDamageView implements IMapDamageView<IRayRangeParam> {
};
}
// [模板] 先判断目标点是否落在范围内(RayRange 会跳过零偏移的未知方向)
getDamageAt(locator: ITileLocator): Readonly<IMapDamageInfo> | null {
const range = this.getRange();
range.bindHost(this.context);
@ -135,65 +155,75 @@ export class BlockedLaserDamageView implements IMapDamageView<IRayRangeParam> {
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()
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<
// [需改] 类名:换成你的转换器名
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 [];
const face = handler.state.faceManager.get(FaceGroup.Dir4)!;
// [需改] 取八方向朝向处理器(内置激光这里用的是 FaceGroup.Dir4)
const face = handler.state.faceManager.get(FaceGroup.Dir8)!;
// [模板] 返回视图数组
return [
new BlockedLaserDamageView(context, handler.locator, special, face)
new EightDirLaserDamageView(context, handler.locator, special, face)
];
}
}
// [需改] 注册这行要挪进 initializeEnemy(见下方「注册在哪里」)
const mapDamage = state.enemyContext.getMapDamage();
mapDamage?.useConverter(new BlockedLaserConverter());
mapDamage?.useConverter(new EightDirLaserConverter());
```
示例用 `eventLayer.getBlock(x, y)` 判断遮挡;如果你的项目把地形放在别的图层,替换成对应图层的 `getBlock` 即可。`useConverter` 会替换掉内置转换器,若要保留内置五类,需要在转换器里同时收集内置视图,或修改 `MainMapDamageConverter`。
### 注册在哪里
`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);
// ...省略:绑定勇士、光环、最终效果、脚本、钩子...
}
```
示例只用 `FaceGroup.Dir8` 替换内置的 `FaceGroup.Dir4`,其余与内置激光相同,就得到了八方向激光。
## 合并器

View File

@ -63,44 +63,65 @@ state.enemyContext.registerSpecialQueryEffect(effect);
这里的**怪物视图**是 `IEnemyView`:用 `view.getComputedEnemy()` 取到该怪物的计算后对象,就能读它的特殊属性。上下文还有其他成员(如按坐标取怪物、取绑定的勇士与地图伤害管理器),需要时再查 API 参考。
## 改内置,还是自己写
查询是**引擎未内置、必须自己写并注册的新增内容**(见上方 warning),没有「只改内置一处」的捷径:`initialize.ts` 里目前没有注册任何查询效果。
- **常规查询**:写一个 `IEnemyCommonQueryEffect` 对象,用 `registerCommonQueryEffect(code, effect)` 注册;
- **特殊查询**:写一个 `IEnemySpecialQueryEffect` 对象,用 `registerSpecialQueryEffect(effect)` 注册。
下面代码用 `[模板]` 标出可原样照抄的行(接口签名、遍历骨架),用 `[需改]` 标出必须按需求改写的行(code、优先级、统计与修改逻辑)。
## 示例:抱团
「抱团」的规则是:范围内每有一个带同种特殊属性的怪物,自身攻击力就增加固定值。它只需要读周围、改自己的攻击,所以用常规查询实现(示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」):
```ts
// [模板] 依赖导入
import { ManhattanRange } from '@motajs/common';
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import { IEnemyCommonQueryEffect } from '@user/data-system';
// [需改] 变量名:这是一个常规查询效果对象
const effect: IEnemyCommonQueryEffect<IEnemyAttr, IHeroAttr> = {
// [需改] priority:与光环共用一套优先级,越大越先执行
priority: 10,
// [模板] apply 签名固定:handler 是当前怪物,special 是触发查询的属性,query() 取上下文
apply(handler, special, query) {
// [需改] 读出本属性登记的参数(示例:半径与每只同类提供的攻击加成)
const { radius, atk } = special.value as {
radius: number;
atk: number;
};
// [模板] 必须用 query() 取上下文(直接读 handler.context 不会被登记为「查询过」,见上文)
const ctx = query();
// [模板] 范围对象建一次即可
const range = new ManhattanRange();
let count = 0;
// [模板] 扫出范围内的怪物,逐项拿到 [位置, 视图]
for (const [locator, view] of ctx.scanRange(range, {
cx: handler.locator.x,
cy: handler.locator.y,
radius
})) {
// [需改] 跳过自己:只统计周围的其他怪物
if (
locator.x === handler.locator.x &&
locator.y === handler.locator.y
) {
continue;
}
// [需改] 统计拥有同种特殊属性的怪物
if (view.getComputedEnemy().hasSpecial(special.code)) {
count++;
}
}
// [需改] 修改自身:每有一个同类,攻击加 atk 点
handler.enemy.addAttribute('atk', count * atk);
}
};
// [需改] 注册:code 是绑定本效果的属性编号(示例自选 40)
state.enemyContext.registerCommonQueryEffect(40, effect);
```
@ -111,17 +132,23 @@ state.enemyContext.registerCommonQueryEffect(40, effect);
「同化」的规则是:把场上其他怪物的特殊属性搬到自己身上——自己还没有的属性直接照抄(先按位置索引从小到大排序,靠前位置的属性先被抄过来);自己已经有的数值型属性则与场上其他怪物的同一属性加算叠加。它要增删、改写自己拥有的特殊属性,所以用特殊查询(示例用 `ctx.iterateEnemy()` 遍历全场;若要限定范围,把遍历换成 `ctx.scanRange`):
```ts
// [模板] 依赖导入
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import { IEnemySpecialQueryEffect } from '@user/data-system';
import { ISpecial } from '@user/data-base';
// [需改] 变量名:特殊查询效果对象
const effect: IEnemySpecialQueryEffect<IEnemyAttr, IHeroAttr> = {
priority: 15,
priority: 15, // [需改] 优先级
// [模板] for(ctx) 签名固定:返回一个修饰器;ctx 是只读查询上下文
for(ctx) {
return {
// [需改] shouldQuery:本效果是否作用于这只怪物(示例:带 code 41 的怪物)
shouldQuery: handler => handler.enemy.hasSpecial(41),
// [需改] add:返回要添加给怪物的特殊属性
add(handler) {
const result: ISpecial<any>[] = [];
// [模板] 取全场其他怪物,并按位置索引从小到大排序
const others = [...ctx.iterateEnemy()]
.filter(
([, view]) => view.getComputedEnemy() !== handler.enemy
@ -131,6 +158,7 @@ const effect: IEnemySpecialQueryEffect<IEnemyAttr, IHeroAttr> = {
ctx.indexer.index(a.x, a.y) -
ctx.indexer.index(b.x, b.y)
);
// [需改] 把别人有、自己没有的属性照抄过来(跳过同化属性自身)
for (const [, view] of others) {
for (const special of view
.getComputedEnemy()
@ -142,13 +170,17 @@ const effect: IEnemySpecialQueryEffect<IEnemyAttr, IHeroAttr> = {
}
return result;
},
// [模板] 本效果不删除属性,返回空数组
delete() {
return [];
},
// [需改] modify:自己已有、需要叠加数值的属性在这里处理
modify(handler, special) {
// [模板] 只处理数值型属性
if (typeof special.value !== 'number') return false;
let sum = special.value;
let found = false;
// [模板] 遍历全场,把同 code 的数值加算进来
for (const [, view] of ctx.iterateEnemy()) {
const other = view
.getComputedEnemy()
@ -158,6 +190,7 @@ const effect: IEnemySpecialQueryEffect<IEnemyAttr, IHeroAttr> = {
found = true;
}
if (!found) return false;
// [模板] 写回并返回 true,表示确实改动了
special.setValue(sum);
return true;
}
@ -165,6 +198,7 @@ const effect: IEnemySpecialQueryEffect<IEnemyAttr, IHeroAttr> = {
}
};
// [需改] 注册(特殊查询不绑定单个 code,只传 effect)
state.enemyContext.registerSpecialQueryEffect(effect);
```
@ -175,22 +209,53 @@ state.enemyContext.registerSpecialQueryEffect(effect);
「号角」的规则是:场上每有一只怪物,自身就获得一份加成。它不关心范围与同类,用常规查询遍历全场即可:
```ts
// [模板] 依赖导入
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import { IEnemyCommonQueryEffect } from '@user/data-system';
// [需改] 变量名:同样是常规查询效果对象
const effect: IEnemyCommonQueryEffect<IEnemyAttr, IHeroAttr> = {
priority: 5,
priority: 5, // [需改] 优先级
// [模板] apply 签名固定
apply(handler, special, query) {
// [模板] 必须用 query() 取上下文
const ctx = query();
let count = 0;
// [模板] 遍历全场怪物逐一计数
for (const _ of ctx.iterateEnemy()) {
count++;
}
// [需改] 修改自身:每有一只怪物,攻击加 special.value 点
handler.enemy.addAttribute('atk', count * (special.value as number));
}
};
// [需改] 注册:示例自选 code 42
state.enemyContext.registerCommonQueryEffect(42, effect);
```
三个示例分别覆盖了「按范围统计同类」「合并特殊属性」「遍历全场计数」三种典型用法。把注册调用放进 `initializeEnemy`,查询就会参与构建计算后怪物。
三个示例分别覆盖了「按范围统计同类」「合并特殊属性」「遍历全场计数」三种典型用法。
## 注册在哪里
`registerCommonQueryEffect` 与 `registerSpecialQueryEffect` 要写进 `initializeEnemy`。下面是 `src/data/enemy/initialize.ts` 中该函数的样子(省略了无关的行):
```ts
export function initializeEnemy(
state: ICoreState
): IEnemyInitializationReturns {
// ...省略:怪物管理器、特殊属性、上下文、伤害系统、地图伤害的初始化...
const ctx = state.enemyContext;
// ...省略:光环转换器与最终效果的注册...
// [需改] 查询效果目前没有内置,需要自己在这里注册:
// ctx.registerCommonQueryEffect(40, effect);
// ctx.registerSpecialQueryEffect(effect);
// ...省略:脚本与钩子的注册...
}
```
把注册调用放在这里,查询就会参与构建计算后怪物。

View File

@ -148,6 +148,8 @@ map.set(
## 自定义一个新特殊属性
特殊属性没有「自己写一个类」的做法:这类数据本就登记在内置文件 `src/data/enemy/special.ts` 里,新增一条属性的方式就是**往这个文件里加一段 `map.set(...)`**(即下面步骤)。真正让它生效的代码,则按类型写在各内置实现文件里(见下方「属性实现位置」表)——这也是本篇与光环 / 查询那两篇最大的不同:那两边需要新建类,这里只需要改内置文件。
当你需要引擎里没有的机制时,就新增一条特殊属性。以「每回合给勇士造成固定伤害」为例(假设使用 code 50):
1. **选一个未使用的 code**。先搜一遍 `special.ts` 中已有的 `map.set(...)`,确认没有冲突。

View File

@ -60,57 +60,103 @@ state.hero.location.addHook(new EnemyChangeFloorHooks(state));
## 示例:下次战斗伤害乘二
钩子能改状态,但「伤害乘二」最终要由伤害公式来兑现。这个例子正好展示一个状态如何跨两个系统:战斗钩子负责「挂上状态」,伤害计算器负责「消费状态」。先定义一个无参数特殊属性(code 45):
钩子能改状态,但「伤害乘二」最终要由伤害公式来兑现。这个例子展示一个状态如何跨多个系统:战斗钩子负责「挂上状态」,伤害计算器负责「读状态」,战斗脚本负责「一次性消费状态」。
:::warning 计算器不能消费状态
不要在伤害计算器里 `deleteField` 之类地去「消费」这个 flag。计算器在一次战斗里会被反复调用(算临界、显示伤害、图鉴等),谁先调用谁就把 flag 用掉了,真正开打时反而不翻倍;而且这属于改动外部状态,违反「计算器必须无副作用」的规则(见[伤害计算](./enemy-calculator.md))。正确做法是:**计算器只读 flag**,由「一场战斗只跑一次」的**战斗脚本**去消费它。
:::
先定义一个无参数特殊属性(code 45)。下面代码中用 `[模板]` 标出可原样照抄的行,用 `[需改]` 标出必须按需求修改的行:
```ts
// special.ts:45 - 蓄力
// [需改] 照常加进 src/data/enemy/special.ts
map.set(
45,
45, // [需改] 编号
defineNonePropertySpecial(45, {
name: '蓄力',
desc: '战斗后,下一次战斗伤害翻倍。',
color: '#F99'
name: '蓄力', // [需改] 名称
desc: '战斗后,下一次战斗伤害翻倍。', // [需改] 说明
color: '#F99' // [需改] 颜色
})
);
```
再写一个战斗钩子。它不修改内置的 `EnemyCombatHooks`,而是作为另一个钩子并存(示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」):
**第一步:用战斗钩子「挂上状态」。** 它不修改内置的 `EnemyCombatHooks`,而是作为另一个钩子并存(示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」):
```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) {}
constructor(readonly state: ICoreState) {} // [模板] 存下数据端状态,供读写 flag
// [模板] onAfterCombat 签名固定:一场战斗结束后调用一次
async onAfterCombat(
info: IEnemyDamageInfo<IEnemyAttr, IHeroAttr>
): Promise<void> {
// [需改] 只有带「蓄力」(code 45)的怪物战斗后,才设置这个 flag
if (info.handler.enemy.hasSpecial(45)) {
// [需改] 在全局 flag 里记下「下次战斗伤害翻倍」
this.state.flags.setFieldValue('nextBattleDouble', true);
}
}
}
// [需改] 注册:挂到战斗流程上
state.combat.addHook(new ChargedHooks(state));
```
最后在伤害计算器里消费这个 flag:
**第二步:用战斗脚本「消费状态」。** 脚本的 `after` 一场战斗只执行一次,正好用来把 flag 清掉:
```ts
// [模板] 依赖导入
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import { ICoreState } from '@user/data-state';
import { ICombatScript } from '@user/data-system';
// [需改] 类名
export class ConsumeDoubleScript implements ICombatScript<
IEnemyAttr,
IHeroAttr
> {
constructor(readonly state: ICoreState) {} // [模板] 存下状态以清 flag
readonly priority = 0; // [需改] 优先级按需要设置
// [模板] 本脚本不拦截战斗,直接放行
async before(): Promise<boolean> {
return true;
}
// [模板] after 一场战斗只跑一次,是消费 flag 的正确位置
async after(): Promise<void> {
// [需改] 战斗结束后清掉 flag,保证只翻倍一次
this.state.flags.deleteField('nextBattleDouble');
}
}
// [需改] 注册
state.combat.addCombatScript(new ConsumeDoubleScript(state));
```
**第三步:让伤害计算器「只读」这个 flag**(不改、不删):
```ts
// [模板] 放在 calculate 返回之前:只读,不修改任何外部状态
const double = handler.state.flags.getFieldValueDefaults(
'nextBattleDouble',
false
);
if (double) {
damage *= 2;
handler.state.flags.deleteField('nextBattleDouble');
damage *= 2; // [需改] 翻倍逻辑;注意不要在这里 deleteField
}
```
把这段逻辑放在 `calculate` 返回之前即可。伤害公式的其余部分见[伤害计算](./enemy-calculator.md)。
把这段读 flag 的逻辑放在 `calculate` 返回之前即可;伤害公式的其余部分见[伤害计算](./enemy-calculator.md)。
这个例子体现了「一个状态要用多个钩子/系统」:`onAfterCombat` 只负责记录状态,真正影响伤害的是计算器。如果只在钩子里改 flag 而不改计算器,状态不会有任何效果。
这个例子体现了「一个状态要用多个系统」:`onAfterCombat` 钩子负责记录状态,计算器负责读取并翻倍,战斗脚本负责在真正的战斗里消费掉它。若把消费放进计算器,就会因计算器被反复调用而提前把 flag 用掉——只有放进「每次战斗只跑一次」的脚本里才可靠。
状态里用到的临时加减或百分比,优先用修饰器实现,概念与 `save` 参数见[勇士属性](./hero-attribute.md)。