From 5528edc70dec6dec284b3f1eea0a674c6282d667 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Sat, 17 Oct 2026 18:43:06 +0800 Subject: [PATCH] docs(10-04): write combat overview, specials, and aura docs - add combat flow overview with mermaid and doc map - document special attribute definitions and implementation locations - document aura priority, built-in auras, and custom examples --- docs/intermediate/combat-overview.md | 61 ++++++ docs/intermediate/enemy-aura.md | 280 +++++++++++++++++++++++++++ docs/intermediate/enemy-specials.md | 141 ++++++++++++++ 3 files changed, 482 insertions(+) create mode 100644 docs/intermediate/combat-overview.md create mode 100644 docs/intermediate/enemy-aura.md create mode 100644 docs/intermediate/enemy-specials.md diff --git a/docs/intermediate/combat-overview.md b/docs/intermediate/combat-overview.md new file mode 100644 index 0000000..1cd7321 --- /dev/null +++ b/docs/intermediate/combat-overview.md @@ -0,0 +1,61 @@ +# 战斗总览 + +战斗在数据端完成:怪物先被计算成「计算后怪物」,再由伤害计算器根据怪物与勇士的属性算出伤害与回合数,最后经过战前脚本、战后脚本与状态钩子结算。本篇串起这条主线,各环节的细节见对应文档。 + +## 伤害大致怎么算 + +一次战斗的伤害可以写成 `f(enemy, hero)`:`enemy` 是计算后的怪物对象,`hero` 是勇士属性对象。计算器读取双方的 `hp`、`atk`、`def`、`mdef` 等属性,返回: + +- `damage`:本次战斗勇士会损失的生命值; +- `turn`:战斗持续多少回合。 + +数据端不逐帧模拟战斗,只根据公式给出这两个值。公式的实现与替换见[伤害计算](./enemy-calculator.md)。 + +## 计算后怪物:buildup 的四个阶段 + +怪物在数据里保存的是基础属性与特殊属性。进入战斗前,怪物上下文会把每个怪物重建为「计算后怪物」(`enemyContext.buildup()`),分四步: + +1. **光环与特殊查询**:把特殊属性转换成光环视图,按优先级执行会修改特殊属性的效果,确定每个怪物最终拥有哪些特殊属性; +2. **普通光环**:执行修改基础属性的光环效果; +3. **常规查询**:允许读取上下文(周围怪物、位置等),并修改自身基础属性; +4. **最终效果**:不再查询上下文,只对自身基础属性做收尾调整。 + +前三步都可能受优先级影响;光环与查询共用同一套优先级,见[怪物光环](./enemy-aura.md)与[怪物查询](./enemy-query.md)。最终效果见[最终属性效果](./enemy-final.md)。 + +## 战斗流程 + +```mermaid +graph TD + A[怪物基础数据] --> B[光环 / 查询 / 最终] + B --> C[伤害计算 f(enemy, hero)] + C --> D[战前脚本 before] + D -->|返回 false| X[放弃战斗] + D -->|返回 true| E[战斗结算 damage / turn] + E --> F[战后脚本 after] + F --> G[战后钩子与状态钩子] +``` + +- **基础数据**:怪物模板的属性与特殊属性,来自怪物数据; +- **光环 / 查询 / 最终**:由 `enemyContext.buildup()` 生成计算后怪物; +- **伤害计算**:`IDamageCalculator.calculate` 返回 `{ damage, turn }`; +- **战前脚本**:`ICombatScript.before` 返回 `false` 时放弃战斗; +- **战斗结算**:按 `damage` 扣血,按 `turn` 记录回合数; +- **战后脚本**:`ICombatScript.after` 结算经验、金币等内容; +- **战后钩子与状态钩子**:处理中毒、衰弱、重生等持续状态。 + +战斗流程本身由 `state.combat`(`ICombatFlow`)驱动,`battle(view)` 返回本次战斗的伤害信息。脚本见[战斗脚本](./enemy-combat-script.md),状态钩子见[状态钩子](./enemy-status-hooks.md)。 + +## 流程步骤与对应文档 + +| 步骤 | 做什么 | 对应文档 | +|------|--------|----------| +| 特殊属性 | 定义怪物的机制与参数 | [特殊属性](./enemy-specials.md) | +| 光环 | 影响周围怪物的基础属性或特殊属性 | [怪物光环](./enemy-aura.md) | +| 查询 | 读取上下文后调整自身 | [怪物查询](./enemy-query.md) | +| 最终效果 | 只调整自身基础属性 | [最终属性效果](./enemy-final.md) | +| 伤害计算 | 实现 `f(enemy, hero)` | [伤害计算](./enemy-calculator.md) | +| 地图伤害 | 勇士移动时按位置结算伤害 | [地图伤害](./enemy-map-damage.md) | +| 战前 / 战后脚本 | 拦截战斗、结算奖励 | [战斗脚本](./enemy-combat-script.md) | +| 状态钩子 | 处理中毒、衰弱、重生等状态 | [状态钩子](./enemy-status-hooks.md) | + +勇士一侧的属性读写见[勇士属性](./hero-attribute.md)。 diff --git a/docs/intermediate/enemy-aura.md b/docs/intermediate/enemy-aura.md new file mode 100644 index 0000000..a3fce2d --- /dev/null +++ b/docs/intermediate/enemy-aura.md @@ -0,0 +1,280 @@ +# 怪物光环 + +光环(aura)是一类特殊属性的执行方式:带有光环的怪物不直接改自己,而是把效果施加到范围内的其他怪物(也可以包括自己)。本篇先讲优先级,再讲内置光环与自定义写法。基础概念见[战斗总览](./combat-overview.md)。 + +## 优先级 + +每个光环视图都有 `priority`,每个查询效果也有 `priority`,两者共用同一套优先级: + +- 构建时从高优先级开始处理,再处理较低优先级; +- 高优先级产生的特殊属性变化,低优先级能看到;低优先级产生变化时,高优先级已经执行完,不能回头影响它; +- 由效果新增的光环只能影响之后的阶段。若新光环的优先级高于当前阶段,开发环境会告警并跳过它。 + +因此,想让一个光环「先于普通光环生效」,把它的 `priority` 设得更高即可。 + +## 内置光环 + +| 光环 | code | 视图类 | priority | 行为 | +|------|------|--------|----------|------| +| 普通光环 | 23 | `CommonAura` | 10 | 按范围给怪物加 `hp` / `atk` / `def` 百分比 | +| 支援 | 24 | `GuardAura` | 5 | 把范围内的怪物登记为支援者,战斗时一起结算 | +| 特殊光环 | 26 | `SpecialAura` | 20 | 通过 `SpecialAuraModifier` 给目标添加或修改特殊属性 | + +这三类都在 `src/data/enemy/aura.ts` 中实现,并在 `initializeEnemy` 里通过 `ctx.registerAuraConverter(...)` 注册。 + +## 运作方式 + +一个光环由两部分组成: + +- **转换器** `IAuraConverter`:`shouldConvert(special, handler)` 判断某个特殊属性是否归自己处理;`convert(special, handler, context)` 返回光环视图; +- **视图** `IEnemyAuraView`:描述优先级、范围、影响方式。 + +视图的核心成员: + +- `priority`:优先级; +- `range`:影响范围对象; +- `couldApplyBase` / `couldApplySpecial`:描述此光环是否会修改基础属性 / 特殊属性; +- `getRangeParam()`:返回范围参数; +- `apply(handler, baseEnemy)`:修改目标怪物的基础属性; +- `applySpecial(handler, baseEnemy)`:返回 `IEnemySpecialModifier`,用于添加、删除或修改目标怪物的特殊属性。 + +`apply` 与 `applySpecial` 的差别: + +- `apply` 在「普通光环」阶段执行,目标是基础属性(`hp`、`atk`、`def` 等),拿到的 `baseEnemy` 是未修改的原始怪物; +- `applySpecial` 在「光环与特殊查询」阶段执行,返回一个修饰器,由上下文按当前优先级应用到目标的特殊属性上。只有 `couldApplySpecial` 为 `true` 的光环才会被调用; +- 只改基础属性就实现 `apply`;要给目标「上光环」或增删特殊属性,才实现 `applySpecial`。 + +范围对象由 `@motajs/common` 提供,常用的有 `FullRange`(全场)、`RectRange`(矩形)、`ManhattanRange`(曼哈顿距离/菱形)与 `RayRange`(射线)。 + +## 示例:基础光环(减少周围怪物的金币与经验) + +先定义一个带参数的特殊属性(code 取一个未占用的值,这里用 30): + +```ts +/** 30 - 削弱光环 */ +export interface ISpecial30Value { + range: number; + square: boolean; + moneyBuff: number; + expBuff: number; +} + +// 30 - 削弱光环 +map.set( + 30, + defineCommonSerializableSpecial( + 30, + { range: 1, square: false, moneyBuff: -20, expBuff: -20 }, + { + name: '削弱光环', + desc: special => + `周围怪物金币与经验下降${Math.abs(special.value.moneyBuff)}%。`, + color: '#9AC' + } + ) +); +``` + +然后写转换器与视图: + +```ts +import { + IManhattanRangeParam, + IRange, + IRectRangeParam, + ITileLocator, + ManhattanRange, + RectRange +} from '@motajs/common'; +import { + IAuraConverter, + IEnemyAuraView, + IEnemyHandler, + IEnemySpecialModifier, + IReadonlyEnemyHandler +} from '@user/data-system'; +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 { + shouldConvert(special: ISpecial): boolean { + return special.code === 30; + } + + convert( + special: ISpecial, + handler: IReadonlyEnemyHandler + ): WeakAura { + return new WeakAura(handler.enemy, special, handler.locator); + } +} + +export class WeakAura implements IEnemyAuraView< + IEnemyAttr, + IRectRangeParam | IManhattanRangeParam, + ISpecial30Value +> { + readonly priority = 8; + readonly couldApplyBase = true; + readonly couldApplySpecial = false; + readonly range: IRange; + + constructor( + readonly enemy: IReadonlyEnemy, + readonly special: ISpecial, + readonly locator: ITileLocator + ) { + this.range = special.value.square ? RECT_RANGE : MANHATTAN_RANGE; + } + + getRangeParam(): IRectRangeParam | IManhattanRangeParam { + const { range, square } = this.special.value; + if (square) { + return { + x: this.locator.x - range, + y: this.locator.y - range, + w: range * 2 + 1, + h: range * 2 + 1 + }; + } + return { + cx: this.locator.x, + cy: this.locator.y, + radius: range + }; + } + + apply( + handler: IEnemyHandler, + baseEnemy: IReadonlyEnemy + ): void { + const { moneyBuff, expBuff } = this.special.value; + const enemy = handler.enemy; + if (moneyBuff !== 0) { + const money = (baseEnemy.getAttribute('money') * moneyBuff) / 100; + enemy.addAttribute('money', Math.floor(money)); + } + if (expBuff !== 0) { + const exp = (baseEnemy.getAttribute('exp') * expBuff) / 100; + enemy.addAttribute('exp', Math.floor(exp)); + } + } + + applySpecial(): IEnemySpecialModifier | null { + return null; + } +} +``` + +注册: + +```ts +state.enemyContext.registerAuraConverter(new WeakAuraConverter()); +``` + +`apply` 里用 `baseEnemy` 的原始值做百分比,因此多个光环不会把彼此的结果继续乘进去。 + +## 示例:特殊光环(给周围怪物上光环) + +如果要给周围怪物「上」一个特殊属性,而不是直接改基础属性,就在 `applySpecial` 中返回一个 `IEnemySpecialModifier`。下面的例子给范围内没有普通光环的怪物加上 code 23 的普通光环: + +```ts +// special.ts:31 - 传染光环 +map.set( + 31, + defineNonePropertySpecial(31, { + name: '传染光环', + desc: '同楼层没有光环的怪物获得普通光环。', + color: '#9F9' + }) +); +``` + +```ts +import { FullRange, ITileLocator } from '@motajs/common'; +import { + IAuraConverter, + IEnemyAuraView, + IEnemySpecialModifier, + IReadonlyEnemyHandler +} from '@user/data-system'; +import { IReadonlyEnemy, ISpecial } from '@user/data-base'; +import { IEnemyAttr, IHeroAttr } from '@user/data-common'; + +export class ChainAuraModifier implements IEnemySpecialModifier { + constructor(readonly special: ISpecial) {} + + add(handler: IReadonlyEnemyHandler): ISpecial[] { + if (handler.enemy.hasSpecial(23)) return []; + const sp = handler.state.enemyManager.getSpecial(23)?.(); + if (!sp) return []; + sp.setValue({ + range: 1, + square: false, + hpBuff: 20, + atkBuff: 0, + defBuff: 0 + }); + return [sp]; + } + + delete(): ISpecial[] { + return []; + } + + modify(): boolean { + return false; + } +} + +export class ChainAuraConverter implements IAuraConverter { + shouldConvert(special: ISpecial): boolean { + return special.code === 31; + } + + convert( + special: ISpecial, + handler: IReadonlyEnemyHandler + ): ChainAura { + return new ChainAura(handler.enemy, special, handler.locator); + } +} + +export class ChainAura implements IEnemyAuraView { + readonly priority = 25; + readonly couldApplyBase = false; + readonly couldApplySpecial = true; + readonly range = new FullRange(); + + constructor( + readonly enemy: IReadonlyEnemy, + readonly special: ISpecial, + readonly locator: ITileLocator + ) {} + + getRangeParam(): void { + return undefined; + } + + apply(): void {} + + applySpecial(): IEnemySpecialModifier | null { + return new ChainAuraModifier(this.special); + } +} +``` + +这里 `priority = 25` 高于内置特殊光环的 20,所以它在特殊光环之前执行;它新加上的普通光环(priority 10)会在之后的阶段生效。 + +不要把 `couldApplySpecial` 设为 `false` 后又指望 `applySpecial` 被调用:上下文只会对 `couldApplySpecial === true` 的光环调用它。 + +## 注册与全局光环 + +- 由特殊属性产生的光环:实现 `IAuraConverter` 并 `state.enemyContext.registerAuraConverter(...)`; +- 没有来源怪物、始终生效的光环:实现 `IAuraView` 并 `state.enemyContext.addAura(view)`。 + +`initializeEnemy` 已经注册了 `CommonAuraConverter`、`GuardAuraConverter` 与 `SpecialAuraConverter`。新增转换器时照同样方式追加即可。 diff --git a/docs/intermediate/enemy-specials.md b/docs/intermediate/enemy-specials.md new file mode 100644 index 0000000..39cf5e2 --- /dev/null +++ b/docs/intermediate/enemy-specials.md @@ -0,0 +1,141 @@ +# 特殊属性 + +特殊属性是挂在怪物身上的机制:一个数字 `code` 加一份可选参数 `value`。怪物的基础属性决定「有多少血、多少攻防」,特殊属性决定「它还会做什么」,例如先攻、连击、坚固、光环、中毒。访问怪物特殊属性的入口见[战斗总览](./combat-overview.md)。 + +## 在数据里的样子 + +数据端的怪物对象(`IReadonlyEnemy`)提供三个读取接口: + +- `getSpecial(code)`:取指定 code 的特殊属性对象,没有则返回 `null`; +- `hasSpecial(code)`:判断是否拥有指定特殊属性; +- `iterateSpecials()`:迭代全部特殊属性。 + +每个特殊属性对象实现 `ISpecial`,常用成员是 `code`、`value` 与 `getDescription()`: + +```ts +const multiHit = enemy.getSpecial(4); +if (multiHit) { + // 连击的 value 是每回合攻击次数 + console.log(multiHit.value); +} + +if (enemy.hasSpecial(3)) { + // 坚固:只看有没有,不读参数 +} +``` + +`ISpecial` 还提供 `setValue` / `getValue`、`getSpecialName` / `getNameColor`、`clone` 与 `deepEqualsTo`。需要修改怪物时用 `IEnemy` 的 `addSpecial` / `deleteSpecial`,不要直接改 `value` 后忘记刷新上下文。 + +## 两类定义 + +内置特殊属性在 `src/data/enemy/special.ts` 的 `createSpecials(state)` 中定义,使用两个工厂函数: + +- `defineNonePropertySpecial(code, config)`:无参数,`value` 为 `void`,适合先攻、魔攻、坚固、模仿这类纯机制; +- `defineCommonSerializableSpecial(code, defaultValue, config)`:有参数,参数会进入存档,适合连击、破甲、光环这类需要数值描述的效果。 + +`config` 的 `name`、`desc`、`color` 都可以写成字符串,也可以写成 `(special) => string` 的函数: + +```ts +// 无参:坚固 +map.set( + 3, + defineNonePropertySpecial(3, { + name: '坚固', + desc: '怪物防御不小于勇士攻击-1。', + color: '#C0B088' + }) +); + +// 有参:n连击,默认 2 次 +map.set( + 4, + defineCommonSerializableSpecial(4, 2, { + name: special => `${special.value}连击`, + desc: special => `怪物每回合攻击${special.value}次。`, + color: '#FE7' + }) +); +``` + +参数可以不只是数字。比如吸血用对象保存比例与是否加到自身: + +```ts +/** 9 - 吸血 */ +export interface ISpecial9Value { + /** 吸血比例 */ + vampire: number; + /** 是否加到自身生命值上 */ + add: boolean; +} + +map.set( + 9, + defineCommonSerializableSpecial( + 9, + { vampire: 0, add: false }, + { + name: '吸血', + desc: special => `战斗前吸取勇士${special.value.vampire}%生命。`, + color: '#DD4448' + } + ) +); +``` + +## 自定义一个新特殊属性 + +以新增一个「每回合给勇士造成固定伤害」的属性为例(假设使用 code 50): + +1. **选一个未使用的 code**。先搜一遍 `special.ts` 中已有的 `map.set(...)`,确认没有冲突。 +2. **有参数时定义参数接口**。在文件顶部 `//#region 复合属性值类型` 内声明 `ISpecial50Value`,并在接口前写 `/** 50 - 属性名 */`。 +3. **在 `//#region 属性定义` 内注册**。每条定义前写 `// 50 - 属性名`,再用 `map.set(50, define...)` 添加。 +4. **实现效果**。按下方「属性实现位置」表,把效果写进对应的计算器、光环、地图伤害或钩子;如果不属于现有分类,再考虑新增对应的转换器 / 效果 / 钩子。 +5. **刷新上下文**。修改怪物模板或新增属性后,调用 `state.enemyContext.markDirty(view)` 标记脏数据,必要时调用 `state.enemyContext.buildup()` 全量重建计算后怪物。 + +```ts +// 50 - 灼烧 +map.set( + 50, + defineCommonSerializableSpecial(50, 0, { + name: '灼烧', + desc: special => `每回合对勇士造成${special.value}点伤害。`, + color: '#F80' + }) +); +``` + +`createSpecials(state)` 返回 `Map>`。`initializeEnemy` 会遍历这个 Map,逐个调用 `state.enemyManager.registerSpecial(code, cons)`,所以新增属性只要放进这个 Map 就会完成注册。 + +## 属性实现位置 + +`special.ts` 顶部的注释给出了内置属性的实现分工。新增属性时按这张表找落点: + +| 属性类型 | 实现位置 | 说明 | +|----------|----------|------| +| 调参类(先攻、魔攻、连击、破甲、反击、净化、固伤、仇恨、自爆等) | `src/data/enemy/calculator.ts` 的 `MainDamageCalculator.calculate` | 只改变伤害公式的结果 | +| 坚固、模仿 | `src/data/enemy/final.ts` 的 `MainEnemyFinalEffect.apply` | 只修改怪物自身基础属性 | +| 领域、夹击、阻击、激光、伏击 | `src/data/enemy/mapDamage.ts` | 伤害视图 + 转换器 | +| 光环、支援、特殊光环 | `src/data/enemy/aura.ts` | 光环视图 + 转换器 | +| 中毒的每步效果 | `src/data/enemy/hook.ts` 的 `EnemyMoveHooks.handlePoison` | 移动钩子 | +| 中毒的瞬移效果 | `src/data/enemy/hook.ts` 的 `EnemyPathfindingHooks.handlePoison` | 寻路钩子 | +| 衰弱 | `src/data/enemy/hook.ts` 的 `EnemyCombatHooks.onAfterCombat` | 战后钩子 + 修饰器 | +| 重生 | `src/data/enemy/hook.ts` 的 `EnemyCombatHooks.onAfterCombat` 与 `EnemyChangeFloorHooks.onSetFloor` | 两个钩子协同 | + +具体实现方式分别见[怪物光环](./enemy-aura.md)、[伤害计算](./enemy-calculator.md)、[地图伤害](./enemy-map-damage.md)与[状态钩子](./enemy-status-hooks.md)。 + +## 编辑器识别约定 + +`special.ts` 里的部分注释是编辑器的识别模式,删除后编辑器可能无法识别属性。新增属性时保持以下写法: + +- 属性定义前写 `// {code} - {name}`,例如 `// 50 - 灼烧`; +- 参数接口前写 `/** {code} - {name} */`,例如 `/** 50 - 灼烧 */`; +- 参数接口名必须是 `ISpecial{code}Value`,例如 `ISpecial50Value`; +- `//#region 复合属性值类型` 与 `//#region 属性定义` 这两行标记不能删除。 + +:::warning +`special.ts` 顶部的注释明确说明这些注释会被编辑器读取。修改属性定义时不要顺手删掉 `#region` 标记或 `// {code} - {name}` 注释。 +::: + +## 怪物比较 + +`MainEnemyComparer.compare` 负责判断两个怪物是否相同:先比较 `hp`、`atk`、`def`、`money`、`exp`、`point`,再逐个比较特殊属性的 `deepEqualsTo`。它用于怪物模板的脏数据判断。一般不需要改;只有当你希望「两个怪物是否视为同一模板」的判定标准发生变化时,才替换比较器。