From 9f62d8768e4477a471bffadb14723b8702ab1a6f Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Sun, 14 Jun 2026 00:31:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=88=98=E6=96=97=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/enemy/enemy-combat-flow.md | 458 ++++++------------ .../data-system/src/combat/combat.ts | 242 +++++++++ .../data-system/src/combat/damage.ts | 14 + packages-user/data-system/src/combat/types.ts | 12 +- packages/common/src/logger.json | 6 +- prompt.md | 1 + 6 files changed, 408 insertions(+), 325 deletions(-) create mode 100644 packages-user/data-system/src/combat/combat.ts diff --git a/docs/dev/enemy/enemy-combat-flow.md b/docs/dev/enemy/enemy-combat-flow.md index 2307846..3e5784c 100644 --- a/docs/dev/enemy/enemy-combat-flow.md +++ b/docs/dev/enemy/enemy-combat-flow.md @@ -1,395 +1,209 @@ # 需求综述 -战斗上下文系统已经完成,下一步需要在 `packages-user/data-system/src/combat` 中补齐真正的战斗流程,使其可以驱动“与某只怪物实际交战”这一行为,而不再只是做战前预估。 +`packages-user/data-system/src/combat/types.ts` 中的战斗流程接口已经是本次实现的权威设计,文档只负责解释这套接口的目的、运行语义与落地方式,不再额外改造公共接口。 -本次设计将战斗拆为三个阶段: +本次需要补齐的是 `packages-user/data-system/src/combat/combat.ts` 中真正的战斗流程实现,使 `ICombatFlow` 可以驱动一次完整的实际战斗,而不是只停留在伤害预估层。 -1. 战前准备 -2. 战斗过程 -3. 战后处理 +按照当前 `types.ts` 的设计,战斗流程分为三个阶段: -其中: +1. 战前阶段:按优先级执行 `before` 脚本。 +2. 战斗阶段:取得本次战斗的 `IEnemyDamageInfo`,并执行战前 hook。 +3. 战后阶段:执行战后脚本,再执行战后 hook。 -- 战前准备与战后处理都属于流程阶段,需要支持两类扩展点: - - 外部注入钩子:面向系统层或外部模块的扩展,适合做动画、日志、兼容桥接等观察型逻辑 - - 自定义脚本:面向战斗流程对象本身的注册式扩展,通过 `addCombatScript` 注入 -- 战斗过程本身只保留一个实际结算方法,输出 `IEnemyDamageInfo`;这个方法是纯计算、无副作用的方法,直接写在流程对象内部,不额外设计成可注入接口 -- 创建战斗流程对象时,必须一次性绑定勇士与怪物上下文,后续不可更改;流程对象本身始终绑定一个上下文,但在真正执行战斗时,传入战斗专用 handler 的 `context` 与 `locator` 允许为 `null`,以支持与不在地图上的怪物战斗 -- 流程对象对外暴露统一的 `battle(...)` 异步方法,由它串起三个阶段;`battle` 接收的是计算后的怪物对象,而不是 `IEnemyView` +当前设计已经明确区分了两种入口: -本次设计的重点不是把所有战后行为硬编码进一个类里,而是先把“可编排、可扩展、与现有 combat 风格一致”的骨架搭起来。删怪、发奖励、旧系统桥接等默认行为,全部由外部脚本或钩子注入,这个系统本身只提供流程壳。 +1. `battle(enemy: IEnemyView)`:面向怪物视图对象。 +2. `battleComputed(enemy: IReadonlyEnemy)`:面向计算后怪物对象。 + +是否“在地图上”不能仅由参数类型决定,而应以当前绑定上下文能否反查出有效坐标为准。对于不在地图上的怪物,当前设计已经明确: + +1. 它会被视为“战斗时隶属于当前绑定的 `IEnemyContext`”。 +2. 它不会被真正放入上下文。 +3. `handler.context` 使用当前绑定的上下文。 +4. `handler.locator` 使用原点 `(0, 0)`。 +5. `handler.onMap` 为 `false`。 + +另外,当前流程对象的失败语义也已经确定:`hero`、`context`、`damage` 任意一个未绑定,则本次战斗直接返回 `null`。 # 接口设计与预期 ## IEnemyDamageInfo -本次不新增 `IDamageInfo` 结构,直接复用现有的 `IEnemyDamageInfo` 作为战斗过程的返回值。当前阶段只需要真实伤害值与回合数,现有结构已经足够。 +`IEnemyDamageInfo` 直接作为战斗结果对象使用,不新增额外结果结构。 -- `IEnemyDamageInfo.damage`:预期频率**高频**。对于真正消费战斗结果的代码来说,最常读取的一定是伤害值;无论是战后脚本、展示层还是日志统计,都会优先关心这一项。典型使用场景:战后脚本根据本次伤害值决定是否触发濒死保护、成就统计或额外效果。 -- `IEnemyDamageInfo.turn`:预期频率**低频**。回合数虽然重要,但大多数战斗逻辑只关心能否战斗和最终伤害,真正直接读取回合数的场景相对少得多,因此归为低频。 +- `IEnemyDamageInfo.damage`:预期频率**高频**。大多数消费战斗结果的代码首先关心伤害值。典型使用场景:战后脚本根据伤害值扣除勇士生命或触发保命逻辑。 +- `IEnemyDamageInfo.turn`:预期频率**低频**。回合数重要,但直接消费它的逻辑明显少于伤害值。 +- `IEnemyDamageInfo.handler`:预期频率**中频**。脚本或日志逻辑经常会顺手读取本次战斗关联的怪物、勇士与定位信息。典型使用场景:战后脚本通过 `handler.enemy` 读取掉落,或通过 `handler.locator` 定位当前怪物。 -若后续确实出现额外结算字段需求,再单独讨论;本次设计不预留额外成员。 +## ICombatFlowHandler -## IEnemyHandlerBase 与 IReadonlyEnemyHandlerBase +`ICombatFlowHandler` 是脚本阶段使用的可写信息对象,职责是把战斗执行阶段真正需要改动的数据集中到一个 handler 中。 -本次保留四种 handler 语义,但不再把它们平铺写成四份完整定义,而是先抽出两个 base: +- `ICombatFlowHandler.onMap`:预期频率**低频**。它只在脚本需要区分“地图内战斗”和“离图战斗”时才会被使用。 +- `ICombatFlowHandler.hero`:预期频率**高频**。几乎所有真正产生副作用的战斗脚本都会碰到勇士对象。典型使用场景:战后脚本扣除生命、增加金币或写入状态。 +- `ICombatFlowHandler.enemy`:预期频率**中频**。不少脚本会读取或修改怪物对象,但频率仍低于直接操作勇士对象。典型使用场景:战后脚本读取怪物奖励,或修改怪物的战后状态。 +- `ICombatFlowHandler.context`:预期频率**低频**。只有需要反查地图对象或联动上下文的脚本才会使用它。 +- `ICombatFlowHandler.locator`:预期频率**低频**。只有与地图位置强相关的逻辑才会读取坐标。 +- `ICombatFlowHandler.state`:预期频率**低频**。它主要服务于跨系统联动,不是常规战斗脚本的主输入。 -- `IEnemyHandlerBase`:可写怪物版本,只保留 `enemy`、`hero`、`data` -- `IReadonlyEnemyHandlerBase`:只读怪物版本,只保留 `enemy`、`hero`、`data` +`handler` 的组装语义需要分成两条路径: -然后再由四个具体接口各自补上 `context` 与 `locator`: +1. `battle(enemyView)`: + - 先尝试从绑定上下文反查该视图的真实坐标 + - 若能反查到坐标,则 `onMap = true` + - 若不能反查到坐标,则按离图战斗处理,`onMap = false` + - `enemy` 使用该视图对应的可写怪物对象 + - `context`、`state` 使用绑定上下文中的真实运行信息 +2. `battleComputed(enemy)`: + - 若能从绑定上下文反查到视图,则按在图战斗处理,`onMap = true` + - 若无法反查到视图,则按离图战斗处理,`onMap = false` + - 离图时 `context` 仍使用绑定上下文,`locator` 固定使用原点 `(0, 0)` + - 离图时怪物不真正加入上下文,但本次战斗仍要提供一个可写的怪物对象作为 `handler.enemy` -- `IRequiredEHandler` -- `IReadonlyRequiredEHandler` -- `INullableEHandler` -- `IReadonlyNullableEHandler` +## ICombatFlowHook -这样拆分后,真正共享的只有三项公共成员,`context` / `locator` 是否可空则留给具体接口自己表达,不再把“可空”硬塞进 base 的泛型参数里。 +`ICombatFlowHook` 是实例级观察型扩展点,它只接收 `IEnemyDamageInfo`,不直接接触可写 handler。 -建议定义如下: +- `ICombatFlowHook.onBeforeCombat()`:预期频率**低频**。更适合动画、日志、录像等系统层观察逻辑。典型使用场景:战前记录一次战斗开始事件。 +- `ICombatFlowHook.onAfterCombat()`:预期频率**低频**。同样偏观察与收尾,不负责流程控制。典型使用场景:战后统一记录本次战斗结果。 -```ts -interface IEnemyHandlerBase { - readonly enemy: IEnemy; - readonly hero: IReadonlyHeroAttribute; - readonly data: IStateBase; -} +当前设计下,hook 的定位很明确: -interface IReadonlyEnemyHandlerBase { - readonly enemy: IReadonlyEnemy; - readonly hero: IReadonlyHeroAttribute; - readonly data: IStateBase; -} - -interface IRequiredEHandler extends IEnemyHandlerBase< - TEnemy, - THero -> { - readonly context: IEnemyContext; - readonly locator: ITileLocator; -} - -interface IReadonlyRequiredEHandler< - TEnemy, - THero -> extends IReadonlyEnemyHandlerBase { - readonly context: IReadonlyEnemyContext; - readonly locator: ITileLocator; -} - -interface INullableEHandler extends IEnemyHandlerBase< - TEnemy, - THero -> { - readonly context: IEnemyContext | null; - readonly locator: ITileLocator | null; -} - -interface IReadonlyNullableEHandler< - TEnemy, - THero -> extends IReadonlyEnemyHandlerBase { - readonly context: IReadonlyEnemyContext | null; - readonly locator: ITileLocator | null; -} -``` - -这里有三个关键点: - -- 旧的 required 语义和 nullable 语义都保留,但命名改为 `RequiredEHandler` / `NullableEHandler` -- `Base` 只承载真正共享的三项成员,不再把 `context` / `locator` 抽象过度 -- 内部纯计算阶段继续使用 required 版本;战斗流程钩子与脚本使用 nullable 版本 - -### 风险说明 - -如果直接把 required 版本放宽成可空,至少会带来下面这些风险: - -- 现有 `damage calculator`、`aura converter`、上下文构建逻辑会被迫接收可空 `context` / `locator`,需要成片补判断或非空断言 -- 一些本来应当被视为错误调用的场景,会因为接口被放宽而静默溜过去,后续更难排查 -- 战斗流程只是一个新需求,却会把可空语义扩散成 combat 旧模块的公共契约变化,影响面明显过大 - -`IEnemyHandlerBase` 与 `IReadonlyEnemyHandlerBase` 的成员频率可以一并分析,因为二者仅在 `enemy` 的只读性上不同: - -- `enemy`:预期频率**中频**。无论是实际结算、战前判定还是战后奖励脚本,核心输入始终是当前这只怪物本身,但真正手写这类读取的位置并不会太多,因此定为中频。典型使用场景:内部结算方法读取怪物攻击、防御、特殊属性并计算战斗结果。 -- `hero`:预期频率**中频**。战斗过程一定会读它,但对外部脚本来说,不是每一个脚本都会直接碰勇士属性,因此不宜定为高频。典型使用场景:战前脚本读取勇士当前生命值或某个关键属性,决定是否允许本次战斗继续执行。 -- `data`:预期频率**低频**。虽然它能访问全局状态,但真正直接走到这一级的脚本相对少,通常只有做跨系统联动时才会用到。 - -四个具体接口额外补上的成员频率如下: - -- `context`:预期频率**低频**。只有需要反查地图对象、刷新上下文或处理周边状态时才会用到,不属于大多数战斗脚本的常规输入。 -- `locator`:预期频率**低频**。只有与地图强绑定的脚本才会真正关心坐标;很多离图战斗或纯数值逻辑根本不会访问它。 - -其中: - -- 钩子接收 `IReadonlyNullableEHandler` -- 脚本接收 `INullableEHandler` -- 内部纯计算方法额外组装 `IReadonlyRequiredEHandler` - -这样既能隔离战斗场景的可空语义,也不会再把 `IEnemyView` 这种可写入口直接暴露给战斗阶段。 - -## ICombatFlowHooks - -战斗流程对象应支持实例级钩子注入,风格上与仓库中现有的 `IHookable/IHookController` 体系保持一致。这里的钩子只负责观察与通知,不参与流程控制,因此返回值统一为 `Promise`。 - -建议形态如下: - -```ts -interface ICombatFlowHooks extends IHookBase { - onBeforeCombat?( - handler: IReadonlyNullableEHandler, - info: IEnemyDamageInfo - ): Promise; - - onAfterCombat?( - handler: IReadonlyNullableEHandler, - info: IEnemyDamageInfo - ): Promise; -} -``` - -- `ICombatFlowHooks.onBeforeCombat()`:预期频率**低频**。只有少数系统级扩展会专门监听战前阶段,例如动画准备、录像标记或调试日志,因此它属于明确存在但并不常写的扩展点。典型使用场景:渲染层在战前收到 `info` 后决定是否播放某段过渡动画。 -- `ICombatFlowHooks.onAfterCombat()`:预期频率**低频**。它主要服务于系统层通知与收尾,常见业务逻辑更适合写在脚本里,而不是写成钩子。典型使用场景:录像或日志系统在战后统一记录本次战斗结果。 - -因此战斗流程对象本身建议实现为: - -```ts -interface ICombatFlow extends IHookable< - ICombatFlowHooks -> {} -``` - -这里钩子与脚本的分工很明确: - -- 钩子偏系统扩展与观察 -- 脚本偏流程配置与流程控制 +1. 它们只拿到 `info`。 +2. 它们不直接承担状态修改职责。 +3. 它们位于脚本阶段之后,只负责观察已经成立的流程结果。 ## ICombatScript -战前与战后脚本统一收敛为一个注册入口,不再拆成 `addBeforeCombatScript` / `addAfterCombatScript` 两套 API。脚本本身就是一个对象,同时提供战前与战后两个方法。 +`ICombatScript` 是战斗流程对象上的注册式扩展点,用于承载真正参与流程的逻辑。 -建议形态如下: +- `ICombatScript.priority`:预期频率**中频**。所有脚本都要声明优先级,但它主要出现在定义脚本和排查顺序问题时。典型使用场景:让扣血脚本先于奖励脚本,或让删怪脚本压到最后执行。 +- `ICombatScript.before()`:预期频率**中频**。只有需要拦截战斗或做战前准备的脚本才会实现它。典型使用场景:根据 `info.damage` 判断本次战斗是否允许继续进行。 +- `ICombatScript.after()`:预期频率**中频**。绝大多数默认行为都更适合放在战后脚本里。典型使用场景:扣血、删怪、发奖励。 -```ts -interface ICombatScript { - readonly priority: number; +脚本层的规则已经确定: - before( - handler: INullableEHandler, - info: IEnemyDamageInfo - ): Promise; - - after( - handler: INullableEHandler, - info: IEnemyDamageInfo - ): Promise; -} -``` - -`ICombatScript` 的成员频率如下: - -- `ICombatScript.priority`:预期频率**中频**。每个脚本对象都必须声明优先级,但它只在定义脚本与注册冲突判断时出现,不属于到处都会写到的成员。典型使用场景:删怪脚本需要排在奖励脚本之前或之后时,通过 `priority` 控制顺序。 -- `ICombatScript.before()`:预期频率**中频**。战前脚本有明确使用场景,例如读取预先算出的伤害信息、拦截非法战斗或执行战前演出,但并不是每一个脚本都会真正写战前逻辑。典型使用场景:某个脚本在战前根据 `info.damage` 判断本次战斗是否允许继续执行。 -- `ICombatScript.after()`:预期频率**中频**。大多数默认行为更偏向战后,但它同样只会出现在定义脚本的代码里,不应误判成高频。典型使用场景:战后脚本删除怪物、发放奖励、扣除勇士生命或触发额外状态修改。 - -如果某个脚本只关心战前或战后,则另一侧直接返回默认值即可: - -- 不关心战前时,`before` 返回 `Promise.resolve(true)` -- 不关心战后时,`after` 返回 `Promise.resolve()` - -对应流程对象提供: - -- `addCombatScript(script)`:注册一个脚本对象 -- `deleteCombatScript(script)`:删除一个脚本对象 - -优先级规则建议与现有系统保持一致: - -- 数值越大越先执行 -- 若已有脚本占用同一优先级,则在注册时使用 `logger.warn(...)` 抛出警告,并直接放弃这次新增,不保留重复项 - -只有 `before` 允许通过返回 `false` 取消战斗;钩子和 `after` 都不参与流程控制。 - -## 战斗过程 - -战斗过程不额外抽成公开接口、类型别名或可注入配置,而是直接实现为 `CombatFlow` 内部的一个同步方法。它只负责一件事: - -- 根据传入的怪物与勇士状态结算 `IEnemyDamageInfo` - -这个同步方法必须是纯计算、无副作用的方法: - -- 不修改勇士状态 -- 不删除怪物 -- 不发放奖励 -- 不做任何桥接逻辑 - -异步需求全部放在战前与战后阶段处理;真正的勇士状态修改也放在战后脚本中执行,而不是放在这个内部结算方法里。 +1. `before()` 可以返回 `false` 来取消本次战斗。 +2. `after()` 只负责战后逻辑,不再影响战斗是否成立。 +3. 同一 `priority` 的脚本不允许并存;注册时需要警告并拒绝新增。 +4. 脚本按 `priority` 从高到低执行。 ## ICombatFlow -`ICombatFlow` 是对外的战斗流程对象。它在构造时绑定勇士与上下文,后续不可更改,负责串起“战前 → 战斗 → 战后”三段流程,并统一返回 `Promise`。 +`ICombatFlow` 是对外暴露的流程对象,本体负责绑定依赖、组织阶段顺序与执行脚本。 -建议成员如下: +- `ICombatFlow.hero`:预期频率**低频**。它主要用于只读检查或调试。 +- `ICombatFlow.context`:预期频率**低频**。通常只在调试或初始化检查中使用。 +- `ICombatFlow.damage`:预期频率**低频**。它更偏运行时绑定状态的公开暴露,而不是常规调用点。 +- `ICombatFlow.bindHero()`:预期频率**中频**。初始化流程对象时必须绑定勇士。典型使用场景:状态系统创建完勇士属性对象后注入给战斗流程。 +- `ICombatFlow.bindContext()`:预期频率**中频**。初始化或切换上下文时需要重绑战斗目标来源。典型使用场景:进入新楼层后,将新的怪物上下文绑定给战斗流程。 +- `ICombatFlow.bindDamage()`:预期频率**中频**。战斗流程本身不做伤害计算,必须依赖外部提供的伤害上下文。典型使用场景:伤害系统初始化完成后绑定给战斗流程。 +- `ICombatFlow.battle()`:预期频率**低频**。它面向怪物视图对象,适合最常规的战斗入口。典型使用场景:交互逻辑在玩家尝试攻击某个怪物视图时直接传入 `IEnemyView`。 +- `ICombatFlow.battleComputed()`:预期频率**低频**。它主要服务于“只有计算后怪物对象,没有视图对象”的调用点。典型使用场景:脚本逻辑对一个临时怪物对象发起离图战斗。 +- `ICombatFlow.addCombatScript()`:预期频率**中频**。所有默认行为和扩展模块都要通过它接入流程。典型使用场景:初始化阶段注册扣血、删怪、奖励等脚本。 -- `readonly hero: IReadonlyHeroAttribute`:当前绑定的勇士属性对象 -- `readonly context: IEnemyContext`:当前绑定的怪物上下文 -- `addCombatScript(script)`:注册脚本对象 -- `deleteCombatScript(script)`:删除脚本对象 -- `battle(enemy, locator?)`:执行一次完整战斗流程 - -这些成员与方法的频率分析如下: - -- `ICombatFlow.hero`:预期频率**低频**。它主要用于初始化后的少量检查、调试或对外只读暴露,不会成为常规调用点。 -- `ICombatFlow.context`:预期频率**低频**。和 `hero` 类似,它更多是一个绑定关系的公开暴露,而不是经常被外部消费的核心接口。 -- `ICombatFlow.addCombatScript()`:预期频率**中频**。虽然运行时通常只在初始化阶段调用,但从“用户会不会写这个调用”的角度看,所有默认行为和扩展模块都要通过它注册脚本,因此它会出现在若干模块初始化代码中。典型使用场景:在战斗系统初始化时注册删怪、奖励、战前拦截等脚本。 -- `ICombatFlow.deleteCombatScript()`:预期频率**低频**。只有模块卸载、临时逻辑失效或测试场景才会显式移除脚本,常规游戏流程很少写到它。 -- `ICombatFlow.battle()`:预期频率**低频**。这里的频率是指“会有多少地方真的去写这个调用”,而不是运行时调用次数;通常只有触发器入口、强制战斗指令和少量特殊逻辑会直接调用它,因此归为低频。 - -建议形态如下: - -```ts -interface ICombatFlow extends IHookable< - ICombatFlowHooks -> { - readonly hero: IReadonlyHeroAttribute; - readonly context: IEnemyContext; - - addCombatScript(script: ICombatScript): void; - - deleteCombatScript(script: ICombatScript): void; - - battle( - enemy: IReadonlyEnemy, - locator?: ITileLocator | null - ): Promise; -} -``` - -这里不再接收 `IEnemyView`。调用方只要能提供计算后的怪物对象,就可以发起战斗;若该怪物本来就在地图中,再额外提供 `locator` 即可。 +结合这些成员可以看出,`ICombatFlow` 的设计目的不是把所有逻辑都塞进一个类里,而是提供一个可被上层系统组装的公共流程壳。 ## 流程顺序 -建议的执行顺序如下: +当前流程顺序已经确定为: -1. 调用 `battle(enemy, locator?)`,传入计算后的怪物对象,以及可选的地图定位符 -2. 组装 `INullableEHandler` 与 `IReadonlyNullableEHandler`:`data` 统一来自绑定上下文上的 `dataState`;`context` 与 `locator` 是否为 `null` 取决于这只怪物是否真的属于当前地图上下文 -3. 先额外组装一个内部使用的 `IReadonlyRequiredEHandler`,调用流程对象内部的纯计算方法,得到 `IEnemyDamageInfo` -4. 执行 `onBeforeCombat(handler, info)` 钩子;钩子只做观察与准备,不影响流程 -5. 按优先级执行全部战前脚本;战前脚本同样接收 `info`,若任一脚本明确返回 `false`,则直接取消战斗并返回 `null` -6. 按优先级执行全部战后脚本;真正的勇士扣血等副作用在这里完成 -7. 执行 `onAfterCombat(handler, info)` 钩子 -8. 兑现 `battle(...)` 返回的 `Promise` +1. 先计算本次战斗的 `IEnemyDamageInfo`。 +2. 按优先级执行全部 `before()` 脚本。 +3. 若战前脚本未取消战斗,则执行 `onBeforeCombat(info)` hook。 +4. 执行全部 `after()` 脚本。 +5. 执行 `onAfterCombat(info)` hook。 +6. 返回本次战斗信息。 -这样安排后,流程既能处理地图内怪物,也能处理离图怪物,同时不会把观察逻辑和真正参与控制的逻辑混在一起。 +也就是说,hook 并不是包裹整段脚本流程的最外层,而是位于脚本之后的观察阶段:`before scripts -> before hook -> after scripts -> after hook`。 # 预期体量 -预期代码体量为 120-180 行。分析如下: +预期代码体量为 140-220 行。分析如下: -- `combat/types.ts` 的改动主要是补齐 `IEnemyHandlerBase`、`IReadonlyEnemyHandlerBase`、`IRequiredEHandler`、`IReadonlyRequiredEHandler`、`INullableEHandler`、`IReadonlyNullableEHandler`,以及战斗流程相关接口 -- `combat/combat.ts` 中的 `CombatFlow` 类主要包含脚本存储、优先级校验、handler 组装、纯伤害计算与统一的 `battle(...)` 编排逻辑 -- `combat/index.ts` 的导出改动体量很小,基本只是一两行 +1. `combat/combat.ts` 需要新建 `CombatFlow`,实现 hook 能力、绑定逻辑、脚本存储、重复优先级拒绝以及 `battle(...)` / `battleComputed(...)` 两条入口,这部分是主要体量来源。 +2. `combat/combat.ts` 还需要补齐离图 `battleComputed(...)` 的虚拟上下文语义,包括 `onMap = false`、原点定位与不真正加入上下文的处理。 +3. `combat/index.ts` 只需要增加 `export * from './combat'`,改动很小。 +4. `combat/types.ts` 作为已确认的设计源头,本次文档不计划修改其公共接口。 -由于本次不接入全局 legacy hook,也不硬编码任何默认行为,因此整体体量会比先前方案更小。 +# 可能风险 + +本次文档将 `combat/types.ts` 视为已确认设计,不计划改动其中的公共接口,因此这里不额外展开实现风险。 # 实现思路 -## 1. 在 `combat/types.ts` 中补齐战斗流程接口 +## 1. 新增 `combat/combat.ts` -新增战斗流程所需的类型,包括: +新增 `CombatFlow` 类,使用 `@motajs/common` 的 `Hookable` / `HookController` 体系实现 `ICombatFlow`。 -- 复用现有 `IEnemyDamageInfo` 作为战斗结果类型 -- 新增 `IEnemyHandlerBase` 与 `IReadonlyEnemyHandlerBase` -- 新增 `IRequiredEHandler` 与 `IReadonlyRequiredEHandler` -- 新增 `INullableEHandler` 与 `IReadonlyNullableEHandler` -- `ICombatFlowHooks` -- `ICombatScript` -- `ICombatFlow` +## 2. 完成绑定与脚本存储 -这里不新增 `IDamageInfo`,但会新增战斗专用 handler;旧 handler 保持原状,不再直接改公共契约。 +类内部保存三个绑定对象与脚本列表: -## 2. 在 `combat/combat.ts` 中实现 `CombatFlow` +1. `hero`:保存可写勇士对象,同时对外按接口暴露只读视图。 +2. `context`:保存当前战斗所属的怪物上下文。 +3. `damage`:保存当前使用的伤害上下文。 +4. `scriptList`:保存已注册脚本,并在注册时检查重复优先级。 -实现一个真正的流程编排类: +## 3. 完成两条战斗入口的数据组装 -- 构造器中绑定 `hero` 与 `context` -- 通过私有成员保存脚本列表 -- 提供 `addCombatScript` / `deleteCombatScript` -- 提供统一的 `battle(enemy, locator?)` -- 在类内部写一个同步的纯伤害计算方法 +1. `battle(enemyView)`: + - 检查绑定是否完整,不完整直接返回 `null` + - 通过 `damage.getDamageInfo(enemyView)` 获取伤害信息 + - 通过 `context.getEnemyLocatorByView(enemyView)` 判断当前是否在图 + - 若能拿到坐标,则组装 `onMap = true` 的 `ICombatFlowHandler` + - 若拿不到坐标,则按离图语义组装 `onMap = false`、`locator = (0, 0)` 的 `ICombatFlowHandler` +2. `battleComputed(enemy)`: + - 检查绑定是否完整,不完整直接返回 `null` + - 先尝试从 `context.getViewByComputed(enemy)` 反查视图 + - 若反查成功,则复用在图路径处理 + - 若反查失败,则按离图语义处理:`onMap = false`、`locator = (0, 0)`、怪物不加入上下文 + - 离图时的伤害计算直接使用绑定 `context` 上当前 `IDamageSystem` 的 calculator,基于绑定上下文、原点坐标与目标怪物组装只读 handler 进行纯计算 -此类本身不做具体战斗结算,只负责把三阶段按顺序跑通。 +## 4. 完成 `battle(...)` 主流程 -## 3. 抽出统一的脚本注册逻辑 +主流程按固定顺序执行: -全部脚本共用同一套行为: +1. 先取得 `IEnemyDamageInfo` 与 `ICombatFlowHandler`。 +2. 按优先级执行全部 `before()` 脚本,若任一脚本返回 `false`,则立刻结束并返回 `null`。 +3. 执行 `onBeforeCombat(info)` hook。 +4. 执行全部 `after()` 脚本,处理真正的副作用。 +5. 执行 `onAfterCombat(info)` hook。 +6. 返回本次战斗信息。 -- 注册 -- 注销 -- 按优先级从高到低排序 -- 同优先级警告并直接放弃新增项 +## 5. 更新 `combat/index.ts` -因此 `combat.ts` 中应优先抽出一个局部的脚本登记结构或辅助方法,避免重复写优先级判断与删除逻辑。 - -## 4. 将伤害计算与状态修改拆开 - -流程类内部的结算方法只负责计算 `IEnemyDamageInfo`,不产生任何副作用。真正的勇士扣血、删怪、发奖励等行为全部放在战后脚本中处理。是否复用现有 `IDamageSystem` 的预估结果,可以在实现时再决定,但这部分仍然不设计成一个可替换的公开配置点。 - -## 5. 所有默认行为都通过外部脚本定义 - -默认的战后行为,如: - -- 删除怪物 -- 发放金币与经验 -- 战后额外状态修改 -- 旧系统桥接 - -都统一通过外部 `ICombatScript` 或实例级钩子完成。`CombatFlow` 本身只提供壳,不内置任何默认战前或战后逻辑。 - -## 6. 在 `combat/index.ts` 中导出新模块 - -当前 `combat/index.ts` 只导出了 `context`、`damage`、`enemy`、`mapDamage`、`types`,实现后需要补上对 `combat.ts` 的导出,保证 `@user/data-system` 的上层调用点可以直接使用战斗流程对象。 +在当前导出列表基础上补上 `export * from './combat'`,让上层可以直接拿到流程实现。 # 涉及文件 ## 需要引用的文件 -- `@motajs/common`:需要 `IHookBase`、`IHookable`、`IHookController`、`Hookable`、`HookController`、`logger`,分别用于实例级钩子能力与重复优先级警告 -- `@user/data-base`:需要 `IStateBase`、`IReadonlyHeroAttribute`、`IReadonlyEnemy` -- `combat/types.ts`:已有 `IEnemyContext`、`IReadonlyEnemyContext`、`IEnemyDamageInfo` 等接口,是本次接口扩展的核心落点 -- `combat/damage.ts`:虽然本次不直接修改,但内部的纯伤害计算方法大概率会参考或复用这里的预估逻辑 -- `trigger/collector.ts`:可参考其优先级冲突时的警告写法,但本次脚本注册的冲突处理规则与它不同 +1. `@motajs/common`:用于 `IHookBase`、`IHookable`、`IHookController`、`Hookable`、`HookController`、`logger`。 +2. `@user/data-base`:用于 `IEnemy`、`IReadonlyEnemy`、`IHeroAttribute`、`IReadonlyHeroAttribute`、`IStateBase`。 +3. `combat/types.ts`:本次实现的权威接口来源,重点使用 `ICombatFlow`、`ICombatFlowHandler`、`ICombatFlowHook`、`ICombatScript`、`IEnemyDamageInfo`、`IEnemyView`。 +4. `combat/context.ts`:需要使用 `getViewByComputed(...)`、`getEnemyLocatorByView(...)`、`getDamageSystem()` 等能力。 +5. `combat/damage.ts`:需要复用 `IDamageContext.getDamageInfo(...)`、`IDamageContext.getDamageInfoByComputed(...)` 与 `IDamageSystem.getCalculator()` 的现有语义。 +6. `combat/enemy.ts`:需要依赖怪物视图提供的可写怪物入口。 ## 需要修改的文件 -### `packages-user/data-system/src/combat/types.ts` - -- [ ] 新增 `IEnemyHandlerBase` 接口:抽出 `enemy`、`hero`、`data` 三个共享成员 -- [ ] 新增 `IReadonlyEnemyHandlerBase` 接口:抽出只读 handler 的共享成员 -- [ ] 新增 `IRequiredEHandler` 接口:承接 required 语义的 `context` 与 `locator` -- [ ] 新增 `IReadonlyRequiredEHandler` 接口:承接只读 required 语义 -- [ ] 新增 `INullableEHandler` 接口:承接 nullable 语义的 `context` 与 `locator` -- [ ] 新增 `IReadonlyNullableEHandler` 接口:承接只读 nullable 语义 -- [ ] 新增 `ICombatFlowHooks` 接口:实例级战斗钩子定义 -- [ ] 新增 `ICombatScript` 接口:定义统一的脚本对象模型 -- [ ] 新增 `ICombatFlow` 接口:定义流程对象的公开 API - ### `packages-user/data-system/src/combat/combat.ts` -- [ ] 新增 `CombatFlow` 类:实现战斗流程编排 -- [ ] 新增脚本存储成员:保存脚本列表 -- [ ] 新增优先级注册/注销辅助逻辑:统一处理重复优先级警告与放弃注册 -- [ ] 实现 `addCombatScript` / `deleteCombatScript` -- [ ] 实现内部同步结算方法:纯计算 `IEnemyDamageInfo` -- [ ] 实现 `battle(enemy, locator?)`:按“战前 → 战斗 → 战后”的顺序执行 +- [ ] 新增 `CombatFlow` 类:实现 `ICombatFlow` 的完整流程壳。 +- [ ] 新增绑定成员:保存 `hero`、`context`、`damage` 三个外部注入对象。 +- [ ] 新增脚本存储成员:保存已注册的战斗脚本,并在注册时拒绝重复优先级。 +- [ ] 新增私有组装方法:分别处理 `battle(IEnemyView)` 与 `battleComputed(IReadonlyEnemy)` 的 handler 组装。 +- [ ] 编写 `CombatFlow.battle(...)` 与 `CombatFlow.battleComputed(...)`:按接口设计执行战前、战斗、战后三阶段。 ### `packages-user/data-system/src/combat/index.ts` -- [ ] 新增 `export * from './combat'`:将战斗流程对象导出给上层使用 +- [ ] 新增 `export * from './combat'`:导出战斗流程实现。 -# 已确认事项 +# 问题 -1. 直接复用 `IEnemyDamageInfo`,本次不新增新的结算结果结构 -2. 全局 `hook` 属于 legacy 内容,不纳入本次设计 -3. 这个系统只是流程壳,默认行为全部由外部脚本或钩子定义,不做任何硬编码 -4. 战斗阶段不上传 `IEnemyView`,统一处理 computed enemy;真正执行战斗时,战斗专用 handler 上的 `context` 与 `locator` 允许为 `null` -5. 新增战斗专用 handler,但不修改旧 handler,也不新增可注入的战斗过程接口;脚本统一为单对象模型 -6. 内部伤害计算方法是纯计算、无副作用的方法;真正的勇士状态修改放在战后脚本中执行 +1. `battle(enemy: IEnemyView)` 传入怪物视图时,是否应当与 `battleComputed(...)` 保持完全一致的离图判定,即只要 `context.getEnemyLocatorByView(enemy)` 返回空,就按 `onMap = false`、原点坐标处理? + +> 使用所有的可能查询方式,包括 `getEnemyLocator` `getEnemyLocatorByView` `getViewByComputed` 等都查一遍,如果还是没有才按不在地图上处理。还有为啥要写成离图在图?看着不别扭吗,直接写成在地图上和不在地图上多清晰。 + +2. 离图战斗时提供给 `handler.enemy` 的可写怪物对象,是否直接使用传入视图上的可写对象 / 传入 computed 怪物的克隆对象即可,还是还有额外约束? diff --git a/packages-user/data-system/src/combat/combat.ts b/packages-user/data-system/src/combat/combat.ts new file mode 100644 index 0000000..79f6af1 --- /dev/null +++ b/packages-user/data-system/src/combat/combat.ts @@ -0,0 +1,242 @@ +import { + Hookable, + HookController, + IHookController, + ITileLocator, + logger +} from '@motajs/common'; +import { + ICombatFlow, + ICombatFlowHandler, + ICombatFlowHook, + ICombatScript, + IDamageContext, + IEnemyContext, + IEnemyDamageInfo, + IEnemyView, + IReadonlyEnemyHandler +} from './types'; +import { + Enemy, + IEnemy, + IHeroAttribute, + IReadonlyEnemy, + IStateBase +} from '@user/data-base'; + +export class CombatFlow + extends Hookable> + implements ICombatFlow +{ + hero: IHeroAttribute | null = null; + context: IEnemyContext | null = null; + damage: IDamageContext | null = null; + + /** 战前战后脚本列表 */ + private readonly scriptList: ICombatScript[] = []; + + constructor(readonly state: IStateBase) { + super(); + } + + //#region 对象控制 + + protected createController( + hook: Partial> + ): IHookController> { + return new HookController(this, hook); + } + + bindHero(hero: IHeroAttribute | null): void { + this.hero = hero; + } + + bindContext(context: IEnemyContext | null): void { + if (!context) { + this.context = null; + } else { + // 传入对象的 state 必须与当前对象一致 + if (context.state === this.state) { + this.context = context; + } else { + logger.warn(138, 'an enemy context object'); + } + } + } + + bindDamage(damage: IDamageContext | null): void { + if (!damage) { + this.damage = null; + } else { + // 传入对象的 state 必须与当前对象一致 + if (damage.state === this.state) { + this.damage = damage; + } else { + logger.warn(138, 'a damage context object'); + } + } + } + + private createHandler( + enemy: IEnemy, + locator: ITileLocator | null + ): ICombatFlowHandler { + return { + onMap: locator !== null, + hero: this.hero!, + enemy, + context: this.context!, + locator: locator ?? { x: -1, y: -1 }, + state: this.state + }; + } + + private createEnemyHandler( + enemy: IReadonlyEnemy, + locator: ITileLocator | null + ): IReadonlyEnemyHandler { + return { + enemy, + context: this.context!, + locator: locator ?? { x: -1, y: -1 }, + hero: this.hero!, + state: this.state + }; + } + + addCombatScript(script: ICombatScript): void { + if (this.scriptList.some(v => v.priority === script.priority)) { + logger.warn(140); + return; + } + this.scriptList.push(script); + this.scriptList.sort((a, b) => b.priority - a.priority); + } + + //#endregion + + //#region 战斗流程 + + /** + * 尝试根据怪物信息从怪物上下文中查找坐标 + * @param view 怪物视图 + * @param computed 计算后怪物 + * @param origin 可修改的原始怪物对象 + */ + private tryGetEnemyLocator( + view: IEnemyView | null, + computed: IReadonlyEnemy | null, + origin: IEnemy | null + ): ITileLocator | null { + if (!this.context) return null; + // 尝试视图 + if (view) { + const locator = this.context.getEnemyLocatorByView(view); + if (locator) return locator; + } + // 尝试计算后怪物 + if (computed) { + const view = this.context.getViewByComputed(computed); + if (view) { + const locator = this.context.getEnemyLocatorByView(view); + if (locator) return locator; + } + } + // 尝试原始怪物 + if (origin) { + const locator = this.context.getEnemyLocator(origin); + if (locator) return locator; + // 传入的原始怪物可能是计算后怪物,所以也判断一下 + const view = this.context.getViewByComputed(origin); + if (view) { + const locator = this.context.getEnemyLocatorByView(view); + if (locator) return locator; + } + } + return null; + } + + /** + * 执行战斗流程 + * @param handler 战斗流程信息对象 + * @param eHandler 怪物信息对象 + */ + private async combatFlow( + handler: ICombatFlowHandler, + eHandler: IReadonlyEnemyHandler + ): Promise | null> { + if (!this.damage) { + logger.warn(139, 'a damage context object'); + return null; + } + const damage = this.damage.getDamageInfoByHandler(eHandler); + if (!damage) { + logger.warn(141); + return null; + } + + for (const script of this.scriptList) { + await script.before(damage, handler); + } + await Promise.all( + this.forEachHook(hook => hook.onBeforeCombat?.(damage)) + ); + for (const script of this.scriptList) { + await script.after(damage, handler); + } + await Promise.all( + this.forEachHook(hook => hook.onAfterCombat?.(damage)) + ); + + return damage; + } + + battle( + enemy: IEnemyView + ): Promise | null> { + if (!this.context) { + logger.warn(139, 'an enemy context object'); + return Promise.resolve(null); + } + if (!this.hero) { + logger.warn(139, 'a hero attribute object'); + return Promise.resolve(null); + } + + const computed = enemy.getComputedEnemy(); + const origin = enemy.getModifiableEnemy(); + const locator = this.tryGetEnemyLocator(enemy, computed, origin); + const handler = this.createHandler(origin, locator); + const eHandler = this.createEnemyHandler(computed, locator); + + return this.combatFlow(handler, eHandler); + } + + battleComputed( + enemy: IReadonlyEnemy + ): Promise | null> { + if (!this.context) { + logger.warn(139, 'an enemy context object'); + return Promise.resolve(null); + } + if (!this.hero) { + logger.warn(139, 'a hero attribute object'); + return Promise.resolve(null); + } + + const view = this.context.getViewByComputed(enemy); + // 如果能查询到怪物视图,直接走 battle 流程 + if (view) return this.battle(view); + else { + // 否则走单独的流程 + const locator = { x: -1, y: -1 }; + const attr = enemy.cloneAttributes(); + const writableEnemy = new Enemy(enemy.id, enemy.code, attr); + const handler = this.createHandler(writableEnemy, locator); + const eHandler = this.createEnemyHandler(enemy, locator); + return this.combatFlow(handler, eHandler); + } + } + + //#endregion +} diff --git a/packages-user/data-system/src/combat/damage.ts b/packages-user/data-system/src/combat/damage.ts index 9b4fea7..da168c1 100644 --- a/packages-user/data-system/src/combat/damage.ts +++ b/packages-user/data-system/src/combat/damage.ts @@ -117,6 +117,20 @@ export class DamageContext implements IDamageContext< }; } + getDamageInfoByHandler( + handler: IReadonlyEnemyHandler + ): IEnemyDamageInfo | null { + if (!this.calculator) { + logger.warn(106); + return null; + } + + return { + handler, + ...this.calculator.calculate(handler) + }; + } + *calculateCritical( view: IEnemyView, attribute: CriticalableHeroStatus, diff --git a/packages-user/data-system/src/combat/types.ts b/packages-user/data-system/src/combat/types.ts index 51671b2..6ac65aa 100644 --- a/packages-user/data-system/src/combat/types.ts +++ b/packages-user/data-system/src/combat/types.ts @@ -423,6 +423,14 @@ export interface IDamageContext extends IStateBaseExtended { enemy: IReadonlyEnemy ): IEnemyDamageInfo | null; + /** + * 根据指定怪物信息对象获取战斗伤害信息 + * @param handler 怪物信息对象 + */ + getDamageInfoByHandler( + handler: IReadonlyEnemyHandler + ): IEnemyDamageInfo | null; + /** * 计算怪物在指定勇士属性下的临界 * @param enemy 怪物视图 @@ -804,7 +812,7 @@ export interface ICombatFlow * 绑定怪物上下文 * @param context 怪物上下文 */ - bindContext(context: IReadonlyEnemyContext | null): void; + bindContext(context: IEnemyContext | null): void; /** * 绑定伤害上下文 @@ -822,7 +830,7 @@ export interface ICombatFlow /** * 与指定怪物战斗 - * @param enemy 怪物对象 + * @param enemy 计算后怪物对象 */ battleComputed( enemy: IReadonlyEnemy diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index 4bffa98..fe90781 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -194,6 +194,10 @@ "134": "TileStore.addTile: tile id '$1' already maps to number $2, old tile data will be overridden.", "135": "Expected a trigger registry attched before collect triggers.", "136": "Unexpected duplicate trigger priority $1, which may cause trigger executed in an unexpected order.", - "137": "Unknown guard enemy at locator '$1,$2'." + "137": "Unknown guard enemy at locator '$1,$2'.", + "138": "Expected $1 with a same state with the CombatFlow object, but got a difference one.", + "139": "Expected $1 binding before start battle flow, but got a null object.", + "140": "Different priority of combat script is expected, but got a same one.", + "141": "Some issue may occur in binded damage system on combat flow, please check the console." } } diff --git a/prompt.md b/prompt.md index 30330d9..e1f2cfa 100644 --- a/prompt.md +++ b/prompt.md @@ -11,6 +11,7 @@ - **彻底性重构**(新旧接口完全没有重合):按正常方式全新实现,旧代码仅作逻辑与思路上的参考。 - **结构性重构**(新旧接口基本一致,细节有差距):将旧代码搬移到新接口上后进行微调。**不要**擅自新增任何参数、方法或接口,**不要**仅通过新增兼容层的方式应对重构。 7. **不要有任何“顺手”的想法**:任何时候,都不要出现顺手的想法,包括但不限于发现了一个 bug,然后**顺手**修复、发现一处类型错误,然后**顺手**修复等,这种情况下应当遵循规则 1。 +8. **写完代码后说明修改内容**:写完代码后,必须在对话中说明自己修改了哪些内容,包括所有的文件及修改了每个文件的哪些东西。 # 建议规则