docs(10-26): 修复高级 II 读者阻断问题(第1轮)

This commit is contained in:
unanmed 2026-10-21 20:09:28 +08:00
parent b04ff5d209
commit 2d25a32660
10 changed files with 610 additions and 125 deletions

View File

@ -0,0 +1,73 @@
# 高级 II 读者审阅
## 读者人格(D-68,逐字)
> 你已经理解了绝大多数常用语法;认真阅读本样板后,你能把一部分简单系统和中等复杂度的系统读明白。你正在读高级 II,想做更深入的定制,但对具体系统实现还不熟悉。
**明确未复用高级 I / 中级人格。**
## 第 1 轮 · 问题清单
### 阻断性(12)
| # | 问题 | 定位 |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |
| B-01 | 验证预期写成「只输出与勇士曼哈顿距离恰好为 2 的格子,最多 8 格」,但 `scanRange` 只返回范围内的怪物视图;照此无法复现,并会误解 `scanRange` 为枚举空格 | `custom-range.md`「怎么验证」 |
| B-02 | 「静态转动态」称无论是否保留、源静态图块的图块事件都会被清空;`keepEvent=false` 时实际不清空,之后 `setBlock` 可能继承旧事件 | `static-vs-dynamic-tiles.md`「双向转换」 |
| B-03 | 验证代码顺序是 equip → unequip → save → load,注释却写「存档 → 卸下 → 读档」并预期加成恢复;按代码读档后不会有加成 | `equipment-system.md`「怎么验证」 |
| B-04 | 建议用 `onChangeModifier` 做「穿上后触发」;它不是穿戴钩子(强化/改值也触发,无修饰器时穿上不触发) | `equipment-system.md`「装备事件」 |
| B-05 | 被动录像只说「调用 `next(status)`」,未说明它是 `getPassive()` 返回的 `IReplayPassiveHandler` 上的方法,也未给出被动步的记录/排入链路 | `custom-replay-command.md`「主动/被动」「边界」 |
| B-06 | 「新的寻路调用也可以接管当前移动」与「上一次移动未结束时返回 null」矛盾;实际需先等 `onEnd` 或 `interrupt()` | `pathfinding.md`「两种移动方式」「边界」 |
| B-07 | `Dir4FaceHandler` 从 `@motajs/common` 导入,实际应来自 `@user/data-common`;示例无法编译 | `pathfinding.md`「完整流程 → 绑定」 |
| B-08 | 护甲插入点未说清;片段放在 `damage = (turn-1)*enemyPerDamage` 之后,却称保留吸血/支援/先攻/破甲/魔防等分支;照抄会丢分支,顺序也不确定 | `adding-hero-attribute.md`「接入伤害计算」 |
| B-09 | 无来源全局光环写成 `implements IEnemyAuraView<...>`;`IEnemyAuraView` 还要求 `enemy`/`special`/`locator`,应实现 `IAuraView`(需核对源码) | `adding-enemy-attribute.md`「接入光环」、`enemy-context-and-combat.md`「完整收束示例」 |
| B-10 | 称 `setFloor` 会把上下文标记为需要更新;实际只改 `floor`、不置 `needUpdate`,切层后只调 `setFloor` 不会重建 | `enemy-context-and-combat.md`「上下文是什么」 |
| B-11 | 优先级语义不符:常规查询按特殊属性 code 逐个执行、优先级只在同一 code 内排序;删除同/更高优先级光环的实际表现也不是「被拒绝」 | `enemy-context-and-combat.md`「优先级的边界」 |
| B-12 | 旧存档缺新键只说「使用前给默认值」,未给补键位置或迁移入口;内容配置不会与默认值合并,照做可能读到 `undefined` | `adding-hero-attribute.md` / `adding-enemy-attribute.md`「边界与注意」 |
### 非阻断(19)
- `static-vs-dynamic-tiles`:`layer.getPointEvent` 返回只读 Map 而非可写视图;`toStatic` 移动中返回 `null` 需先判 `mover.moving`。
- `custom-replay-command`:`finalize?()` 返回值未写;`reseter.reset(save)` 的 `save` 来源;指令对象跨回放残留状态;指令码切 `Uint16` 的入口与兼容。
- `pathfinding`:`finder.find(...)` 与 `pathfinding.getPath(...)` 关系未说明;瞬移钩子的注册调用位置;`onAfterTeleportTo` 参数个数。
- `equipment-system`:`instancesOf`/`count` 参数是 num 还是 id;多件装备百分比是相加还是相乘。
- `adding-hero-attribute`:`HERO_DEFAULT_ATTRIBUTE` 注释与实际生效时机矛盾;`IItemEquipData.value/percentage` 的 `Record` 字面量可能类型不通过。
- `adding-enemy-attribute`:查询示例未说明如何让怪物拥有该 code;验证中「把 armor 设为 50」缺少 `setAttribute` + `markDirty/requestRefresh`。
- `enemy-context-and-combat`:`clear()` 会清空 `globalAuraList`;`buildupSpecials` 无光环时提前返回导致只注册查询也不执行;基础光环范围包含自身时会改自己。
- `index.md`:高级 III 占位表述;「优先插件,必要时改内置包」与本层多篇直接改 `packages-user` 的张力。
- `custom-range`:`radius` 需为整数,否则 `iterateLoc` 产生非整数索引。
## 迭代记录
### 第 1 轮修复(2026-10-21)
对照当前源码逐条修复,构建通过。
**阻断性**
- B-01 `custom-range.md`:明确 `scanRange` 只筛选上下文中已存在的怪物视图,不枚举空格;验证改为在中心、距离 `1`、距离 `2` 摆测试怪,并补充 `iterateLoc` 检查范围格子。
- B-02 `static-vs-dynamic-tiles.md`:改为只有 `keepEvent=true` 才清空源静态图块的图块事件;`false` 时源实例保留旧事件。补充 `layer.setBlock` 不重置事件、`StaticTile.set` 才重置。
- B-03 `equipment-system.md`:验证顺序改为 equip → save → unequip → load,注释与预期同步。
- B-04 同篇:说明 `onChangeModifier` 不是穿戴钩子,也不保证在 `equip` 时触发;给出在 `equip` / `unequip` 入口统一处理的写法。
- B-05 `custom-replay-command.md`:补充 `IReplayPassiveHandler.next(status)` 的归属、record → `getPassive()` → `next()` 链路,以及被动指令类。
- B-06 `pathfinding.md`:改为移动中 `moveTo` / `teleportTo` 返回 `null`,不会接管;需 `await controller.onEnd` 或 `state.pathfinding.interrupt()`。
- B-07 同篇:`Dir4FaceHandler` 改从 `@user/data-common` 导入。
- B-08 `adding-hero-attribute.md`:换成保留吸血、支援、先攻、破甲、反击、净化、魔防、固伤、仇恨、自爆的完整计算器;说明攻速插入点与护甲相对 mdef / 固伤 / 仇恨 / 自爆的顺序。
- B-09 `adding-enemy-attribute.md`、`enemy-context-and-combat.md`:无来源全局光环改为实现 `IAuraView`;说明 `IEnemyAuraView` 额外要求 `enemy` / `special` / `locator`。
- B-10 `enemy-context-and-combat.md`:改为 `setFloor` 只改 `floor`、不置 `needUpdate`;补充切层必须配合 `resize` 或 `bindHero` 等置脏动作。
- B-11 同篇:区分特殊光环 / 特殊查询的全局优先级与常规查询的同 code 排序;更正删除语义为「特殊属性先删,光环可能留到本轮」。
- B-12 `adding-hero-attribute.md`、`adding-enemy-attribute.md`:增加在 `state.loadState` 前迁移 `@system/hero` / `@system/enemy` 的入口;强调内容配置不与默认值合并。
**非阻断**
- `static-vs-dynamic-tiles.md`:`getPointEvent` 返回只读 Map 而非可写视图;`toStatic` 移动检查的现状与使用建议。
- `custom-replay-command.md`:`finalize` 返回类型、`reset(save)` 的 `save` 来源、指令对象跨回放残留状态、`setCommandWidth` 入口与兼容。
- `pathfinding.md`:`finder.find` 与 `getPath` 的区别、钩子注册位置、`onAfterTeleportTo` 第二参数。
- `equipment-system.md`:`instancesOf` / `count` 接受图块数字或 id;多件装备百分比为相加。
- `adding-hero-attribute.md`:`HERO_DEFAULT_ATTRIBUTE` 的实际语义;`Record` 字面量的类型限制。
- `adding-enemy-attribute.md`:怪物如何拥有查询 code;验证中的 `setAttribute` + `markDirty` + `requestRefresh`。
- `enemy-context-and-combat.md`:`clear()` 清空 `globalAuraList`;`buildupSpecials` 无光环时提前返回;光环可命中自身。
- `index.md`:高级 III 占位表述;优先插件与本层直接改 `packages-user` 的边界。
- `custom-range.md`:`radius` 必须为整数,示例统一截断。
**验证**:prettier 格式化通过;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 通过(仅 VitePress chunk 体积警告)。

View File

@ -100,6 +100,38 @@ export interface IEnemyAttr {
内容对象不会与默认模板合并,缺键就是缺键。读取到 `undefined` 后,`armor / 100` 与 `atkSpeed / 100` 都会变成 `NaN`,伤害与回合数随之失真。新增属性后,最好全局搜索一遍 `"attribute"`,确认每个模板入口都补齐。
### 旧存档迁移
新游戏补 `enemy.jsonc` 与默认模板即可;旧存档不会自动补键。`state.loadState(save, compression)` 读取 `@system/enemy` 时,会把每个脏模板的 `attrs` 原样交给怪物模板,缺失的键不会从内容配置或默认模板回填。迁移点在调用 `loadState` 之前:
```ts
import { IEnemyAttr, SaveCompression } from '@user/data-common';
import { ICoreState } from '@user/data-state';
/** 在 state.loadState 之前补齐旧档里怪物模板缺失的属性键。 */
function migrateEnemySave(save: Map<string, unknown>): void {
const enemy = save.get('@system/enemy') as
| { modified?: Map<number, { attrs?: Partial<IEnemyAttr> }> }
| undefined;
if (!enemy?.modified) return;
// modified 的键是怪物 code,值是该模板的属性与特殊属性存档
for (const enemyState of enemy.modified.values()) {
enemyState.attrs ??= {};
enemyState.attrs.atkSpeed ??= 100;
enemyState.attrs.armor ??= 0;
}
}
/** 读档入口:先迁移,再交给数据端统一读档。 */
function loadEnemySave(state: ICoreState, save: Map<string, unknown>): void {
migrateEnemySave(save);
state.loadState(save, SaveCompression.NoCompression);
}
```
只补 `@system/enemy` 还不够:勇士属性也可能从旧档读入,两个存档键都要按各自的新字段做一次迁移。勇士侧的写法见[新增勇士属性](./adding-hero-attribute.md)的「旧存档迁移」。
## 读取与修改
怪物对象有两套写入口,取决于你拿到的是可写对象还是视图:
@ -202,22 +234,22 @@ export class EnemyAttributeCalculator implements IDamageCalculator<
## 接入光环
光环视图 `IEnemyAuraView` 继承自 `IAuraView`,它的 `apply(handler, baseEnemy)` 在基础属性阶段执行,适合给范围内的怪物加护甲或攻速。`handler.enemy` 是计算后怪物,`baseEnemy` 是未修改的原始怪物:读原始值用后者,写结果用前者。
光环的公共接口是 `IAuraView<TEnemy, TRange>`,它的 `apply(handler, baseEnemy)` 在基础属性阶段执行,适合给范围内的怪物加护甲或攻速。`handler.enemy` 是计算后怪物,`baseEnemy` 是未修改的原始怪物:读原始值用后者,写结果用前者。
下面是一条全场 +10 护甲的基础光环。转换器与视图的完整写法见[怪物光环](../../intermediate/enemy-aura.md),这里只保留视图本身:
`IEnemyAuraView` 是在 `IAuraView` 之上多出 `enemy` / `special` / `locator` 的派生接口,专门表示「由某只怪物的某个特殊属性转换出来的光环」。**无来源的全局光环不是 `IEnemyAuraView`**:它没有来源怪物,只实现 `IAuraView`,再用 `state.enemyContext.addAura(...)` 注册。转换器与特殊光环的完整写法见[怪物光环](../../intermediate/enemy-aura.md),这里只保留无来源全局光环的视图本身:
```ts
import { FullRange, IRange } from '@motajs/common';
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import { IReadonlyEnemy } from '@user/data-base';
import {
IEnemyAuraView,
IAuraView,
IEnemyHandler,
IEnemySpecialModifier
} from '@user/data-system';
/** 给范围内所有怪物增加 10 点护甲的基础光环。 */
export class ArmorAura implements IEnemyAuraView<IEnemyAttr, void, void> {
/** 给范围内所有怪物增加 10 点护甲的无来源全局光环。 */
export class ArmorAura implements IAuraView<IEnemyAttr, void> {
readonly priority = 10;
readonly couldApplyBase = true;
readonly couldApplySpecial = false;
@ -243,8 +275,13 @@ export class ArmorAura implements IEnemyAuraView<IEnemyAttr, void, void> {
return null;
}
}
// 初始化阶段注册一次;同一个实例会一直留在 globalAuraList 里
state.enemyContext.addAura(new ArmorAura());
```
`addAura` 接受的就是 `IAuraView`。如果你写成 `implements IEnemyAuraView`,编译期会要求补 `enemy`、`special`、`locator` 三个字段,而无来源光环根本拿不出这些值。
光环修改的是计算后怪物,因此不需要你自己标脏:`buildup()` 结束时会统一清空伤害缓存;局部刷新也会在 `refreshEnemy` 末尾处理。**不要在光环里改 `baseEnemy`**,那会污染原始模板,并让后续重建从被改过的值出发。
## 接入查询
@ -303,6 +340,8 @@ export class PackAtkSpeedQuery implements IEnemyCommonQueryEffect<
state.enemyContext.registerCommonQueryEffect(40, new PackAtkSpeedQuery());
```
注册只决定「拥有 code 40 的怪物会执行哪条查询」,**不会让任何怪物自动拥有 40**。常规查询的执行条件是怪物身上有对应特殊属性:在 `src/content/enemy.jsonc` 的 `special` 里写 `"40": ...`,或在运行时用 `enemy.addSpecial(...)` 添加。只注册效果、不给怪物 code,查询一次也不会触发。
查询效果需要 `registerCommonQueryEffect` 注册;引擎的 `src/data/enemy/initialize.ts` **没有注册任何查询实现**,不注册就不会执行。如果要增删或改写目标拥有的特殊属性,应使用特殊查询 `IEnemySpecialQueryEffect`,写法见[怪物查询](../../intermediate/enemy-query.md),本篇不重复。
## 接入最终效果
@ -348,38 +387,66 @@ state.enemyContext.registerFinalEffect(new ArmorFinalEffect());
## 怎么验证
固定一只怪物与勇士属性,避免光环、支援或随机因素干扰。先把怪物 `armor` 设为 `50`、`atkSpeed` 设为 `100`,打印伤害;再把 `armor` 改为 `0`,确认伤害接近翻倍:
固定一只怪物与勇士属性,避免光环、支援或随机因素干扰。模板护甲不能直接写在 `getComputedEnemy()` 上;要先改可修改怪物,再 `markDirty()` + `requestRefresh()`,最后重新取伤害:
```ts
import { IEnemyAttr } from '@user/data-common';
import { ICoreState } from '@user/data-state';
import { IEnemyView } from '@user/data-system';
/** 打印指定怪物的护甲、攻速与伤害信息。 */
/** 修改模板属性并立即刷新这只怪物。 */
function setEnemyAttribute(
state: ICoreState,
view: IEnemyView<IEnemyAttr>,
name: 'armor' | 'atkSpeed',
value: number
): void {
// getModifiableEnemy() 返回模板;改完不会自动同步
view.getModifiableEnemy().setAttribute(name, value);
// 标脏后申请刷新,伤害缓存也会随之失效
view.markDirty();
state.enemyContext.requestRefresh(view);
}
/** 对比 50 护甲与 0 护甲下的伤害。 */
function verifyEnemyAttribute(
state: ICoreState,
view: IEnemyView<IEnemyAttr>
): void {
const enemy = view.getComputedEnemy();
console.log('护甲', enemy.getAttribute('armor'));
console.log('攻速', enemy.getAttribute('atkSpeed'));
const damageSystem = state.enemyContext.getDamageSystem();
if (!damageSystem) return;
const info = damageSystem.getDamageInfo(view);
if (!info) return;
setEnemyAttribute(state, view, 'armor', 50);
const highArmor = view.getComputedEnemy();
const high = damageSystem.getDamageInfo(view);
console.log(
'计算后护甲',
highArmor.getAttribute('armor'),
'伤害',
high?.damage,
'回合',
high?.turn
);
console.log('伤害', info.damage, '回合', info.turn);
setEnemyAttribute(state, view, 'armor', 0);
const low = damageSystem.getDamageInfo(view);
console.log('0 护甲伤害', low?.damage, '回合', low?.turn);
setEnemyAttribute(state, view, 'atkSpeed', 120);
const fast = damageSystem.getDamageInfo(view);
console.log('120 攻速伤害', fast?.damage, '回合', fast?.turn);
}
```
预期现象:
- `armor` 从 `50` 改为 `0` 后,勇士每回合伤害恢复,回合数减半,总伤害大致翻倍;
- 给该怪物加一条 +10 护甲的光环后,`armor` 变为 `60`,伤害再次下降;
- 计算后护甲可能高于模板值(光环或最终效果会继续叠加),所以打印的是 `getComputedEnemy()` 的结果;
- `atkSpeed` 从 `100` 提到 `120` 后,怪物每回合伤害提高 20%,总伤害随之上升。
如果第二次读取仍是旧伤害,检查是否漏了 `markDirty()` 或 `requestRefresh()`:只改模板不会让伤害缓存失效。
若最终值是 `NaN`,先检查接口、默认模板与内容条目是否都补了键;若光环加了护甲但伤害不变,确认 `buildup()` 是否已经执行,以及是否误改了原始怪物而没有标脏。
## 边界与注意
@ -387,9 +454,9 @@ function verifyEnemyAttribute(
- **接口、默认模板、内容条目都要补键**:三处缺一不可。内容对象不会与默认模板合并,缺键就是 `undefined`。
- **`guard` 不要写进内容**:它是运行时由支援光环维护的集合,手填会与运行时行为冲突。
- **百分数统一用整数存储**:`20` 表示 20%,计算时再除以 `100`;不要一处存 `0.2`、另一处存 `20`。
- **新增字段会进入怪物存档**:读旧档时缺失的键不会自动补齐,需要在使用前给默认值。
- **新增字段会进入怪物存档**:读旧档时缺失的键不会自动补齐,需要按上文「旧存档迁移」在 `state.loadState` 之前给默认值。
- **光环、查询、最终效果改的都是计算后怪物**:刷新前不要把它当原始数据;也不要在这三个阶段改 `getBaseEnemy()`。
- **直接改原始怪物要标脏**:`getModifiableEnemy()` 的修改不会自动同步,必须 `markDirty()` 后重新读取。
- **直接改原始怪物要标脏**:`getModifiableEnemy()` 的修改不会自动同步,必须 `markDirty()` 后调用 `state.enemyContext.requestRefresh(view)`,再重新读取计算后怪物与伤害。
- **护甲要夹在有效区间**:大于 `100` 会让怪物无法被击败,负数会放大伤害;在计算器入口统一夹一次。
- **攻速改动会影响回合数**:凡是依赖 `turn` 的逻辑(支援、先攻、状态持续时间)都要跟着复核,不能只改伤害数字。
- **编辑器与图鉴尚未接入**:在对应端实现之前,不要假设属性会自动出现在界面上。

View File

@ -59,10 +59,14 @@ export interface IHeroAttr {
## 补默认值与初始值
`HERO_DEFAULT_ATTRIBUTE` 是属性对象的完整形状。接口增加字段后,这里必须同步补齐:
`HERO_DEFAULT_ATTRIBUTE` 是属性对象的完整形状。接口增加字段后,这里必须同步补齐,否则 `IHeroAttr` 的类型检查会报缺键:
```ts
/** 勇士的初始属性,数值填多少目前都无所谓,因为最终会从旧样板读取,但是必须得填 */
/**
* 勇士属性的完整默认形状。
* 注释里的「最终会从旧样板读取」是历史说法:当前运行时不会读取这个常量,
* 它只用于保证接口字段在类型层面齐全;真正的新游戏初始值来自 core.jsonc。
*/
export const HERO_DEFAULT_ATTRIBUTE: IHeroAttr = {
name: '',
level: 0,
@ -82,7 +86,7 @@ export const HERO_DEFAULT_ATTRIBUTE: IHeroAttr = {
};
```
实际进入游戏的初始值来自内容配置 `src/content/core.jsonc` 的 `init.hero.attribute`,由 `state.hero.createAttribute(...)` 直接交给 `HeroAttribute` 构造。它不会与默认值合并,缺键就是缺键:
实际进入游戏的初始值来自内容配置 `src/content/core.jsonc` 的 `init.hero.attribute`,由 `src/data/initialize.ts` 的 `afterLoad` 调用 `state.hero.createAttribute(coreConfig.init.hero.attribute)`,直接交给 `HeroAttribute` 构造。它不会与默认值合并,缺键就是缺键;`HERO_DEFAULT_ATTRIBUTE` 不会在运行时兜底:
```jsonc
"attribute": {
@ -105,6 +109,39 @@ export const HERO_DEFAULT_ATTRIBUTE: IHeroAttr = {
`core.jsonc` 允许注释,但这两项不是可选项。若只在接口与默认值里加、忘了内容配置,`getBaseAttribute('atkSpeed')` 会返回 `undefined`,`atkSpeed / 100` 随之变成 `NaN`,伤害与回合数都会失真。
### 旧存档迁移
新游戏只需要改 `core.jsonc`;旧存档不会和它合并。`state.loadState(save, compression)` 会把 `@system/hero` 里的 `attribute.values` 原样交给 `HeroAttribute.loadState`,缺的键不会被补。迁移点在调用 `loadState` 之前,直接改存档映射:
```ts
import { IHeroAttr, SaveCompression } from '@user/data-common';
import { ICoreState } from '@user/data-state';
/** 在 state.loadState 之前补齐旧档缺失的勇士属性键。 */
function migrateHeroSave(save: Map<string, unknown>): void {
const hero = save.get('@system/hero') as
| { attribute?: { values?: Partial<IHeroAttr> } }
| undefined;
const values = hero?.attribute?.values;
if (!values) return;
// 旧档没有这两个键;补成与新游戏一致的默认值
values.atkSpeed ??= 100;
values.armor ??= 0;
}
/** 读档入口:先迁移,再交给数据端统一读档。 */
function loadWithMigration(
state: ICoreState,
save: Map<string, unknown>
): void {
migrateHeroSave(save);
state.loadState(save, SaveCompression.NoCompression);
}
```
也可以给 `'@system/hero'` 绑定一个 `ISaveableExecutor`,在 `afterLoad` 里用 `state.hero.getModifiableAttribute().set('armor', 0)` 补值。但 `afterLoad` 发生在属性读档之后,已经算过一轮最终值,补完还要依赖 `set` 触发重算;直接在 `loadState` 前改映射更干净。
## 读取与修饰
属性对象有两套读取方式:
@ -150,7 +187,7 @@ function addTemporaryAtkSpeed(state: ICoreState): void {
- **攻速影响「每回合伤害」**。先算 `atk - monDef`,再乘 `atkSpeed / 100`,最后用缩放后的每回合伤害计算回合数。
- **护甲影响「最终伤害」**。所有回合伤害汇总后,再乘 `1 - armor / 100`,并把护甲夹在 `0` 到 `100` 之间,避免负伤害或反向治疗。
顺序不能反:攻速要在算回合数之前生效,护甲要在最终伤害上生效。下面保留与这两处改动相关的分支,其余特殊属性逻辑按原版保留:
顺序不能反:攻速要在算回合数之前生效,护甲要在最终伤害上生效。下面给出的是**完整**的 `MainDamageCalculator`,吸血、支援、先攻、破甲、反击、净化、魔防、负伤归零、固伤、仇恨、自爆全部保留,只插入攻速与护甲两处:
```ts
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
@ -160,46 +197,164 @@ import {
IEnemyDamageInfoBase,
IReadonlyEnemyHandler
} from '@user/data-system';
import { ISpecial9Value } from './special';
/** 带攻速与护甲的勇士伤害计算器。 */
export class MainDamageCalculator implements IDamageCalculator<
IEnemyAttr,
IHeroAttr
> {
/** 当前是否正在计算支援怪的伤害 */
private inGuard: boolean = false;
calculate(
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>
): IEnemyDamageInfoBase {
const { enemy, hero } = handler;
const { enemy, locator, hero } = handler;
const hp = hero.getBaseAttribute('hp');
const atk = hero.getFinalAttribute('atk');
const def = hero.getFinalAttribute('def');
// 支援中魔防只会被计算一次
const mdef = this.inGuard ? 0 : hero.getFinalAttribute('mdef');
const monAtk = enemy.getAttribute('atk');
const monDef = enemy.getAttribute('def');
const monHp = enemy.getAttribute('hp');
let monHp = enemy.getAttribute('hp');
// [需改] 攻速缩放勇士的每回合伤害,必须发生在计算回合数之前
// 无敌
if (
enemy.hasSpecial(18) &&
handler.state.hero.items.itemCount('cross') < 1
) {
return { damage: Infinity, turn: 0 };
}
let damage = 0;
let heroPerDamage = 0;
let enemyPerDamage = 0;
// [需改] 攻速缩放勇士每回合伤害。
// 插入点就是原来的 `heroPerDamage += atk - monDef`:
// 必须在 `turn = Math.ceil(monHp / heroPerDamage)` 之前,
// 也要在下面的 `<= 0` 早退之前,否则攻速为 0 时会除出 Infinity。
const atkSpeed = hero.getFinalAttribute('atkSpeed');
const heroPerDamage = ((atk - monDef) * atkSpeed) / 100;
heroPerDamage += ((atk - monDef) * atkSpeed) / 100;
if (heroPerDamage <= 0) {
return { damage: Infinity, turn: 0 };
}
// 回合数随每回合伤害同步变化
const turn = Math.ceil(monHp / heroPerDamage);
// 吸血
const vampire = enemy.getSpecial<ISpecial9Value>(9);
if (vampire) {
const value = (vampire.value.vampire / 100) * hp;
damage += value;
// 如果吸血加到自身
if (vampire.value.add) {
monHp += value;
}
}
let enemyPerDamage = monAtk - def;
// 魔攻
if (enemy.hasSpecial(2)) {
enemyPerDamage = monAtk;
} else {
enemyPerDamage = monAtk - def;
}
// 连击
const multiHit = enemy.getSpecial<number>(4);
if (multiHit) {
enemyPerDamage *= multiHit.value;
}
if (enemyPerDamage < 0) enemyPerDamage = 0;
// 此处保留原版的吸血、支援、先攻、破甲、反击、魔防等分支
let damage = (turn - 1) * enemyPerDamage;
// 回合数必须使用缩放后的每回合伤害
let turn = Math.ceil(monHp / heroPerDamage);
// [需改] 护甲是最终减伤,先夹到 0 到 100,再换算成 0 到 1 的系数
// 支援
const guards = enemy.getAttribute('guard');
if (guards.size > 0 && !this.inGuard) {
this.inGuard = true;
// 打支援怪时当前怪物也会打你,因此回合数与伤害都要累加
for (const view of guards) {
const extraInfo = this.calculate({
enemy: view.getComputedEnemy(),
context: handler.context,
locator,
hero,
state: handler.state
});
turn += extraInfo.turn;
damage += extraInfo.damage;
}
this.inGuard = false;
}
// 先攻
if (enemy.hasSpecial(1)) {
damage += enemyPerDamage;
}
// 破甲
const breakArmor = enemy.getSpecial<number>(5);
if (breakArmor) {
damage += (breakArmor.value / 100) * def;
}
// 反击
const counterAttack = enemy.getSpecial<number>(6);
if (counterAttack) {
// 反击是每回合生效,因此加到 enemyPerDamage 上
enemyPerDamage += (counterAttack.value / 100) * atk;
}
// 净化
const purify = enemy.getSpecial<number>(7);
if (purify) {
damage += purify.value * mdef;
}
damage += (turn - 1) * enemyPerDamage;
// 魔防
damage -= mdef;
// 未开启负伤时,如果伤害为负,则设为 0
if (
!handler.state.flags.getFieldValueDefaults(
'enableNegativeDamage',
false
) &&
damage < 0
) {
damage = 0;
}
// 固伤,无法被魔防减伤
const fixedDamage = enemy.getSpecial<number>(20);
if (fixedDamage) {
damage += fixedDamage.value;
}
// 仇恨,无法被魔防减伤
if (enemy.hasSpecial(15)) {
damage += handler.state.flags.getFieldValueDefaults('hatred', 0);
}
// [需改] 护甲按最终伤害百分比减伤。
// 放在魔防、负伤归零、固伤、仇恨之后,因此这些部分都会被护甲缩放;
// 放在自爆之前,自爆仍然把伤害抬到 hp - 1,保持「必定重创」的语义。
// 如果你希望固伤/仇恨绕过护甲,把这段移到固伤之前。
const armor = Math.min(
Math.max(hero.getFinalAttribute('armor'), 0),
100
);
damage *= 1 - armor / 100;
// 此处保留固伤、仇恨、自爆等分支,它们按原版顺序排在护甲减伤之后
// 自爆
if (enemy.hasSpecial(17)) {
damage = Math.max(damage, hp - 1);
}
return { damage: Math.floor(damage), turn };
}
@ -207,37 +362,57 @@ export class MainDamageCalculator implements IDamageCalculator<
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>,
attribute: CriticalableHeroStatus<IHeroAttr>
): number {
switch (attribute) {
case 'atk': {
if (handler.enemy.hasSpecial(3)) {
return Infinity;
}
return (
handler.enemy.getAttribute('def') +
handler.enemy.getAttribute('hp')
);
}
}
return handler.hero.getFinalAttribute(attribute);
}
}
```
原来的 `MainDamageCalculator` 还有支援、先攻、破甲、反击、净化、固伤、自爆等分支;它们的相对位置不变,只是每回合伤害与最终伤害各多了一次缩放。改动时不要把护甲乘进 `enemyPerDamage`:那样只减免每回合伤害,先攻、破甲等一次性伤害会漏掉护甲。
两处插入点的含义:
- **攻速**:替换的是「每回合伤害」的初值,位置在吸血、连击、支援之前,但仍在 `turn` 计算与 `<= 0` 早退之前。不能等 `turn` 算完再乘——那样回合数不会变,只是把总伤害整体缩小。
- **护甲**:放在所有伤害分量汇总之后,缩放的是最终伤害。魔防已经先减掉,固伤与仇恨随后加上,所以它们也会被护甲缩放;自爆在其后把下限抬到 `hp - 1`,因此自爆不受护甲影响。不要把护甲乘进 `enemyPerDamage`:那样只减免每回合伤害,先攻、破甲、净化、固伤等一次性伤害会漏掉护甲。
`getCriticalLimit` 也要跟着新属性考虑。它决定临界计算的搜索上界;自定义属性若参与伤害,至少要让读取该属性的方式与计算器一致,避免界面显示一个值、战斗按另一个值结算。
## 与装备和道具的关系
装备的 `value` 与 `percentage` 是按属性名索引的记录,新属性不需要额外注册。只要 `IHeroAttr` 里有这个数值字段,装备定义就能直接写:
装备的 `value` 与 `percentage` 是按属性名索引的记录,新属性不需要额外注册。只要 `IHeroAttr` 里有这个数值字段,内容层的装备定义就能直接写(`category: 4` 即 `ItemCategory.Equipment`):
```ts
import { IHeroAttr, IItemEquipData } from '@user/data-common';
/** 一件提供 +15% 攻速的武器。 */
export const attackSpeedEquip: IItemEquipData<IHeroAttr> = {
slots: ['weapon'],
// 动画 id 由内容层定义,这里换成你自己的装备动画
animate: 'equip',
// 数值加成:攻速只走百分比,不额外加固定值
value: { atkSpeed: 0 },
// 百分比加成:沿用装备系统的约定,0.15 表示 15%
percentage: { atkSpeed: 0.15 },
// 这两个字段目前只是数据,引擎不会自动执行
loadEvent: null,
unloadEvent: null
};
```jsonc
{
"num": 120,
"category": 4,
"name": "疾风剑",
"text": "攻击速度 +15%",
"hideInToolbox": false,
"equip": {
"slots": ["weapon"],
// 动画 id 由内容层定义,这里换成你自己的装备动画
"animate": "equip",
// 数值加成:攻速只走百分比,不额外加固定值
"value": {},
// 百分比加成:沿用装备系统的约定,0.15 表示 15%
"percentage": { "atkSpeed": 0.15 },
// 这两个字段目前只是数据,引擎不会自动执行
"loadEvent": null,
"unloadEvent": null
}
}
```
内容层是 JSONC,不会经过 TypeScript 的 `Record` 检查。如果你要在 TS 里手写 `IItemEquipData<IHeroAttr>` 对象字面量,注意 `value` 与 `percentage` 的类型是 `Record<SelectKey<IHeroAttr, number>, number>`,它会要求补齐**全部**数值属性键;只写 `{ atkSpeed: 0.15 }` 无法通过类型检查。最省事的做法仍然是写在 `src/content/item.jsonc` 里。
装备实例会把这些记录转成 `ValueModifier` 与 `PercentageModifier`,穿戴时挂到勇士属性,卸下时移除。基础攻速 `100` 配上 `percentage.atkSpeed = 0.15`,最终攻速就是 `115`。注意属性本身按百分数存储(`15` 表示 15%),而装备的 `percentage` 是倍率(`0.15` 表示 15%),两者写法不同。
道具效果若要直接改攻速,也可以调用 `state.hero.getModifiableAttribute().add('atkSpeed', value)` 或挂修饰器;道具效果属于内容层,这里不额外展开。
@ -295,7 +470,7 @@ function verifyHeroAttribute(
- **内容配置不会与默认值合并**:`createAttribute` 直接使用传入对象,缺键就是 `undefined`;不要指望默认值兜底。
- **百分数属性统一用百分数存储**:`15` 表示 15%,计算时再除以 `100`。装备的 `percentage` 则是倍率,`0.15` 表示 15%。
- **不要把新属性塞进既有汇总字段**:例如不要把攻速加进 `money` 或 `exp` 的统计逻辑,读这些字段的系统并不知道新属性的语义。
- **新增字段会影响存档结构**:读旧档时缺失的键不会自动补齐,需要在使用前给默认值;写入新档后,旧版本读到多出的键通常可忽略,但不能假设旧版本认识它。
- **新增字段会影响存档结构**:读旧档时缺失的键不会自动补齐。迁移入口是在调用 `state.loadState(save, compression)` 之前修改 `@system/hero` 的 `attribute.values`(见上文「旧存档迁移」),不要指望 `core.jsonc` 或 `HERO_DEFAULT_ATTRIBUTE` 兜底;写入新档后,旧版本读到多出的键通常可忽略,但不能假设旧版本认识它。
- **`save = true` 的修饰器要注册类型**:读档按 `type` 查构造器,未注册的修饰器会被静默跳过,数值看起来「读档后消失了」。
- **护甲要夹在有效区间**:大于 `100` 会变成治疗,负数会放大伤害;在计算器入口统一夹一次。
- **攻速改动会影响回合数**:凡是依赖 `turn` 的逻辑(支援、先攻、状态持续时间)都要跟着复核,不能只改伤害数字。

View File

@ -93,7 +93,8 @@ export interface IRingRangeParam {
export class RingRange extends BaseRange<IRingRangeParam> {
// autoDetect 用这个估算决定走 iterate 还是 scan,不要求精确
protected estimatePointCount(param: Readonly<IRingRangeParam>): number {
const radius = Math.abs(param.radius);
// 半径必须是整数;这里统一截断,避免非整数半径产生非整数索引
const radius = Math.abs(Math.trunc(param.radius));
// 半径 0 退化为单点;否则曼哈顿距离恰为 radius 的格子有 4 * radius 个
return radius === 0 ? 1 : 4 * radius;
}
@ -101,7 +102,7 @@ export class RingRange extends BaseRange<IRingRangeParam> {
// 必须与下面的 inRange 判定完全一致,否则 scan 会漏格或多格
*iterateLoc(param: Readonly<IRingRangeParam>): Iterable<number> {
const { width, height } = this.host;
const radius = Math.abs(param.radius);
const radius = Math.abs(Math.trunc(param.radius));
for (let dy = -radius; dy <= radius; dy++) {
const y = param.cy + dy;
if (y < 0 || y >= height) continue;
@ -121,7 +122,7 @@ export class RingRange extends BaseRange<IRingRangeParam> {
}
inRange(x: number, y: number, param: Readonly<IRingRangeParam>): boolean {
const radius = Math.abs(param.radius);
const radius = Math.abs(Math.trunc(param.radius));
return (
this.inBound(x, y) &&
Math.abs(x - param.cx) + Math.abs(y - param.cy) === radius
@ -136,7 +137,7 @@ export class RingRange extends BaseRange<IRingRangeParam> {
- `iterateLoc` 决定「枚举时输出哪些格」,必须和 `inRange` 使用同一套判定;
- `inRange` 决定「单个点算不算命中」,越界必须先由 `inBound` 收口,不能只比较距离。
`radius` 统一取绝对值,因此 `-2` 与 `2` 等价。`radius: 0` 时环形退化为中心单点,这与 `inRange` 的 `距离 === 0` 判定一致。
`radius` 统一取绝对值并截断为整数,因此 `-2` 与 `2` 等价,`2.7` 会按 `2` 处理。**不要依赖非整数半径**:不截断时 `iterateLoc` 会产出非整数索引,`index % width` 也会得到错误坐标。示例用 `Math.trunc` 兜底;如果你希望非整数直接报错而不是被截断,就在三个方法开头换成显式校验。`radius: 0` 时环形退化为中心单点,这与 `inRange` 的 `距离 === 0` 判定一致。
## 绑定到系统
@ -158,6 +159,8 @@ for (const [hit, view] of hits) {
`scanRange` 内部会先 `range.bindHost(this)`,再用 `autoDetect` 在「遍历怪物列表」与「遍历范围」之间选一条路径。自定义范围不需要知道怪物列表长什么样。
注意 `scanRange` 的语义是「筛选**当前上下文里已存在的怪物视图**」,不是「枚举范围内的空格」:范围里的空格不会出现在结果里,只有站在范围内的怪物才会。要检查范围本身覆盖了哪些坐标,直接跑 `range.iterateLoc(param)`(先绑定宿主),不要拿 `scanRange` 的结果当格子清单。
如果要在自己的函数里复用范围,也可以显式接收宿主:
```ts
@ -184,7 +187,7 @@ function collectRing(
先确认上下文已绑定勇士。正常初始化流程会调用 `state.enemyContext.bindHero(state.hero.getAttribute())`;如果你在最小环境里手动搭上下文,需要自己补这一步。
把勇士放在一只怪物旁边,执行:
`scanRange` 只返回范围内的怪物,所以验证要摆好怪物,而不是只看格子。先以勇士为中心,在中心、曼哈顿距离 `1`、距离 `2` 的位置各放一只测试怪,再执行:
```ts
state.enemyContext.bindHero(state.hero.getAttribute());
@ -198,12 +201,35 @@ const hits = [
})
];
for (const [hit] of hits) {
console.log(`命中 (${hit.x}, ${hit.y})`);
for (const [hit, view] of hits) {
// hit 是怪物所在坐标;view 是该位置的怪物视图
console.log(
`命中 (${hit.x}, ${hit.y})`,
view.getBaseEnemy().getAttribute('hp')
);
}
```
预期现象:只输出与勇士曼哈顿距离恰好为 `2` 的格子,最多 8 格;中心与距离为 `1` 的格子不会出现。如果勇士靠近地图边缘,超出地图的格会被裁掉,数量相应减少。场景里没有怪物时,可以先放两只测试怪,或临时用 `collectRing` 直接检查索引数量。
预期现象:
- 结果里只有距离恰好为 `2` 的那只怪物;中心与距离 `1` 的怪物不会出现;
- 结果的数量取决于你放在环上的怪物数量,而不是环上的空格数量——环本身最多有 8 格,但 `scanRange` 不会为空格产出条目;
- 如果勇士靠近地图边缘,超出地图的环上格子会被裁掉;如果那里也没有怪物,结果数量相应减少。
要单独确认范围覆盖了哪些格子,绕过怪物列表,直接检查 `iterateLoc`:
```ts
const ring = new RingRange();
// 上下文有 width / height,可以直接作为宿主
ring.bindHost(state.enemyContext);
const loc = state.hero.getLocation();
const cells = [...ring.iterateLoc({ cx: loc.x, cy: loc.y, radius: 2 })];
console.log('环上格子数', cells.length); // 未被地图边缘裁剪时最多 8
```
场景里没有怪物时,`scanRange` 返回空结果是正常的;这时用上面的 `iterateLoc` 或 `collectRing` 检查范围本身。不要用「输出最多 8 格」来判断 `scanRange` 是否正确。
## 边界与注意

View File

@ -43,17 +43,29 @@ export const enum ReplayCode {
新增一项意味着修改 `packages-user` 的引擎内容,而不是只改 `src`。`registerCommand` 收的是普通 `number`,跳过枚举直接写数字也能注册;但引擎之后分配新码时可能撞号,所以正式做法仍然是在枚举末尾追加。
指令码的取值还受位宽限制。默认的 `ReplayCommandWidth.Uint8` 只支持 `0–255`,超出后写入会被截断。需要更多指令码时,要切到 `ReplayCommandWidth.Uint16`(`0–65535`);这是录像格式的变更,已保存的录像会带上当时的位宽,不能假设新旧录像可以混用。
指令码的取值还受位宽限制。默认的 `ReplayCommandWidth.Uint8` 只支持 `0–255`,超出后写入会被截断。需要更多指令码时,在开始录制前切到 `ReplayCommandWidth.Uint16`(`0–65535`):
```ts
import { ReplayCommandWidth } from '@user/data-common';
import { ICoreState } from '@user/data-state';
/** 在注册/录制超过 255 的指令码之前调用。 */
function useUint16ReplayCodes(state: ICoreState): void {
state.replaySystem.array.setCommandWidth(ReplayCommandWidth.Uint16);
}
```
`setCommandWidth` 会重新编码现有指令;`ReplaySystem.saveState` 会把当前 `commandWidth` 一并存入录像,`loadState` 再按存档里的宽度恢复,所以本系统自己的存读档不需要额外转换。但这是录像格式变更:外部工具或旧版本若固定按 Uint8 解析,就读不了 Uint16 录像;从 Uint16 降回 Uint8 时,大于 255 的指令码会告警 code 154 并被截断。确定要扩容后就不要来回切换。
## 指令对象
一条指令实现 `IReplayCommand`:
| 成员 | 作用 |
| --------------- | ----------------------------------------------- |
| `type` | `ReplayCommandType.Active` 或 `Passive` |
| `execute(step)` | 执行这一步,返回 `Promise<ReplayCommandResult>` |
| `finalize?()` | 连续多步同一指令结束时统一收尾;可选 |
| 成员 | 作用 |
| --------------- | ------------------------------------------------------------------------- |
| `type` | `ReplayCommandType.Active` 或 `Passive` |
| `execute(step)` | 执行这一步,返回 `Promise<ReplayCommandResult>` |
| `finalize?()` | 连续多步同一指令结束时统一收尾,返回 `Promise<ReplayCommandResult>`;可选 |
`step` 提供 `command`(指令码)、`params`(参数列表)与 `index`(读取流给出的步索引)。返回值 `ReplayCommandResult` 有三种:
@ -61,15 +73,71 @@ export const enum ReplayCode {
- `Failed`:这一步失败,播放中止;
- `Ignored`:失败但可以忽略,控制台输出信息后继续。
`finalize` 在两种时机被调用:下一步换了另一条指令,或整条录像已经播完。它没有参数,因此需要跨步保存的数据要放在指令对象的成员里。典型用途是「连续多步移动后,统一启动一次动画」:每步只更新目标,最后一步结束时再提交。
`finalize` 在两种时机被调用:下一步换了另一条指令,或整条录像已经播完。它没有参数,但**有返回值**:`Promise<ReplayCommandResult>`。返回 `Success` 继续,返回 `Ignored` 记录后继续,返回 `Failed` 会中止播放。需要跨步保存的数据要放在指令对象的成员里。典型用途是「连续多步移动后,统一启动一次动画」:每步只更新目标,最后一步结束时再提交。
## 主动录像与被动录像
`ReplayCommandType.Active` 的指令自己执行逻辑,是最常见的形式。内置的移动、瞬移、使用道具、装备都是主动录像。
`ReplayCommandType.Passive` 的指令不执行任何逻辑。沙盒在主动步执行期间把下一步暴露给 `IReplaySandbox.getPassive()`,由外部取走参数,处理完后调用 `next(status)` 才会推进。适合「需要渲染端配合、耗时不可预测」的行为:数据端只负责给出参数,什么时候处理完由外部决定。
`ReplayCommandType.Passive` 的指令不执行任何逻辑。沙盒在主动步执行期间把下一步暴露给 `IReplaySandbox.getPassive()`,由主动步取走参数,处理完后调用 `next(status)` 才会推进。适合「需要渲染端配合、耗时不可预测」的行为:数据端只负责给出参数,什么时候处理完由外部决定。
被动步必须紧跟在某个主动步之后,并在那个主动步结束前被取走。没有及时消费的被动步会被沙盒跳过,并输出告警。被动步对应的指令对象仍然要注册,沙盒要靠它判断 `type`;但它的 `execute` 不会被调用。
`getPassive()` 返回的是 `IReplayPassiveHandler | null`,结构为:
```ts
export interface IReplayPassiveHandler {
/** 被动步本身,params 与 command 都在这里 */
readonly step: IReplayStepHandler;
/** 处理完后调用;Success 或 Ignored 会进入下一步,Failed 不推进 */
next(status: ReplayCommandResult): Promise<void>;
}
```
**`next(status)` 在这个 handler 上,不在 `IReplayCommand` 上,也不在 `getPassive()` 的返回值之外**。完整链路是:
1. 玩家入口先 `record` 主动指令,再紧接着 `record` 被动指令——两个录像步必须相邻:
```ts
this.state.replaySystem.record(ReplayCode.UseSkill, skillId, x, y);
// 紧随其后的被动步,给渲染端播放技能特效
this.state.replaySystem.record(ReplayCode.SkillEffect, skillId);
```
2. 回放时,主动指令的 `execute` 在自己的执行期间取被动 handler:
```ts
const passive = this.state.replaySystem.sandbox?.getPassive();
if (passive) {
// 参数在 passive.step.params;先确认它确实是预期的那条被动指令
const [effectSkill] = passive.step.params as [number];
console.log('播放技能特效', effectSkill);
// 渲染端处理完后推进;不调用 next 就不会进入下一步
await passive.next(ReplayCommandResult.Success);
}
```
3. `next` 推进读取流并触发 `onStep`;重复调用 `getPassive()` 在推进前会拿到同一个 handler / 同一步。
被动指令本身只需要声明类型并注册,`execute` 不会被沙盒调用:
```ts
import { ReplayCommandResult, ReplayCommandType } from '@user/data-common';
import { BaseReplayCommand } from './commands';
/** 技能特效的被动步:参数由主动步通过 getPassive 取走。 */
export class ReplaySkillEffect extends BaseReplayCommand {
readonly type: ReplayCommandType = ReplayCommandType.Passive;
protected readonly name: string = 'skill-effect';
protected readonly paramTypes: readonly string[] = ['number'];
async wrappedExecute(): Promise<ReplayCommandResult> {
// 被动步不会进入这里;保留实现只是为了满足基类契约
return ReplayCommandResult.Success;
}
}
```
被动步必须紧跟在某个主动步之后,并在那个主动步的 `execute` 返回前被取走。没有及时消费的被动步不会让播放永远卡住:下一次 `replayStep` 发现它是 `Passive` 时会告警 code 194 并跳过,但这一步对应的效果就丢了。被动步对应的指令对象仍然要注册,沙盒要靠它判断 `type`;它的 `execute` 不会被调用。一个主动步只对应一个紧随其后的被动步,不要连续排两个。
渲染端的播放表现按渲染端教程处理,本篇只写数据端。 <!-- TODO(renderer): 被动录像的渲染端消费 -->
@ -278,20 +346,22 @@ async function playReplay(
`reseter` 负责把游戏状态重置到录像的起点;不重置的话,回放会叠加在当前状态上,结果与原始游玩对不上。`createReplaySandbox` 在创建时就会调用一次 `reset`,不是在 `play` 时。
`reset` 收到的 `save` 来自沙盒配置的 `save` 字段(`IReplaySandboxConfig.save`,可选)。`ReplaySystem.createReplaySandbox` 实际执行的是 `config.reseter.reset(config.save)`;不传 `save` 就是 `reset(undefined)`。如果录像要从某个存档点开始回放,把该存档读出的 `Map<string, unknown>` 传给 `save`,`reset` 就用它恢复起点;`ReplayReseter` 里注入的 `restore` 只是项目自己的「新游戏 / 读档」实现。
播放控制由 `IReplaySandbox` 提供:
| 成员 | 作用 |
| --------------- | ----------------------------------------------- |
| `play()` | 连续播放 |
| `step()` | 单步播放,返回该步是否成功 |
| `pause()` | 暂停,返回的 Promise 在真正暂停后兑现 |
| `resume()` | 从暂停恢复 |
| `stop()` | 停止播放 |
| `getReplayed()` | 已读取的录像步数量,不含当前正在播放的一步 |
| `pausing` | 是否处于暂停状态 |
| `ended` | 是否已经播放完毕 |
| `playing` | 是否处于播放状态 |
| `getPassive()` | 取下一步的被动录像参数,不是被动步时返回 `null` |
| 成员 | 作用 |
| --------------- | ----------------------------------------------------------- |
| `play()` | 连续播放 |
| `step()` | 单步播放,返回该步是否成功 |
| `pause()` | 暂停,返回的 Promise 在真正暂停后兑现 |
| `resume()` | 从暂停恢复 |
| `stop()` | 停止播放 |
| `getReplayed()` | 已读取的录像步数量,不含当前正在播放的一步 |
| `pausing` | 是否处于暂停状态 |
| `ended` | 是否已经播放完毕 |
| `playing` | 是否处于播放状态 |
| `getPassive()` | 取下一步的 `IReplayPassiveHandler`,不是被动步时返回 `null` |
`ended` 由连续播放的循环在结束时置为 `true`。只用 `step()` 一步步走到末尾时,沙盒会完成 `finalize` 并停止推进,但不会因此设置 `ended`;需要观察结束状态时,用 `play()` 或 `resume()` 跑完循环。
@ -364,8 +434,9 @@ async function verifyUseSkill(
- **不要复用已删除的旧指令码**:数值属于录像格式,旧录像会按旧值解码。新语义一律追加新值。
- **`Uint8` 下不要超过 255**:写入会截断,产生无法回放的录像。需要更多码时切 `Uint16`,并把它当成格式变更处理。
- **参数类型必须与 `paramTypes` 一致**:只支持 `number`、`string`、`boolean`、`bigint`。传对象等无法编码的值会被丢弃,参数个数随之变化,基类会判为失败。
- **被动步忘记 `next` 会卡住**:沙盒不会自己推进;重复调用 `getPassive()` 会得到同一步。被动步没有在主动步期间取走时,会被跳过并告警。
- **`finalize` 返回 `Failed` 会中止播放**:它和 `execute` 一样会中断后续步骤,不要在里面吞掉错误。
- **被动步忘记 `next` 不会推进**:沙盒不会自己前进;重复调用 `getPassive()` 会得到同一个 handler / 同一步。被动步没有在主动步期间取走时,下一次 `replayStep` 会告警 code 194 并跳过它——播放不会永远卡住,但这一步的效果丢了。
- **`finalize` 返回 `Failed` 会中止播放**:它返回的是 `Promise<ReplayCommandResult>`,和 `execute` 一样会中断后续步骤,不要在里面吞掉错误。
- **指令对象跨回放复用**:指令对象在注册时创建一次,之后每次回放都用同一个实例。成员变量不会自动清空;如果 `execute` 里累积了跨步状态,要在每次 `execute` 开头重置,或在 `finalize` 末尾清理,否则第二次回放会带着上一次的残留。
- **重复注册不会覆盖**:同一指令码重复注册会告警(code 163)并保留先注册的对象。确认注册只发生一次。
- **未知指令码会中止播放**:沙盒查不到指令时告警并停止,不会跳过继续。
- **播放期间必须禁用录制**:否则回放执行的行为会写回同一条录像,并让读取流过期。用 `disable()` 与 `revert()` 包住播放,且保证配对。

View File

@ -15,9 +15,14 @@
- `getEnemyByLoc(x, y)` / `getEnemyByLocator(locator)` / `getEnemyLocatorByView(view)`:按位置或视图互查;
- `getDamageSystem()` / `getMapDamage()`:取绑定到上下文的伤害系统与地图伤害。
上下文本身不进入存档。切楼层时由外部调用 `setFloor`;地图尺寸变化时调用 `resize`,它会清空当前上下文中的所有怪物与运行状态。`clear()` 只清空怪物与运行状态,`destroy()` 还会解绑伤害系统、地图伤害并清空所有注册项。
上下文本身不进入存档。切楼层时由外部调用 `setFloor`;地图尺寸变化时调用 `resize`,它会清空当前上下文中的所有怪物与运行状态,并把 `needUpdate` 置为真。`clear()` 只清空怪物与运行状态(以及无来源全局光环列表),保留转换器、查询、最终效果等注册项;`destroy()` 还会解绑伤害系统、地图伤害并清空所有注册项。
`resize` 与 `setFloor` 都会把上下文标记为需要更新,下一次读取计算后怪物时会重新构建。不要把它们当成普通的字段赋值。
**`setFloor` 只改 `floor` 字段,不会把 `needUpdate` 置为真。** 如果只调用 `setFloor`,下一次 `buildup()` 会直接返回,计算后怪物与伤害都不会重建。切楼层时必须同时做一次会置脏的动作:
- 正常换图流程调用 `resize(新地图宽, 新地图高)`,它会清空旧怪物并置 `needUpdate`,随后重新放入新楼层的怪物;
- 如果尺寸没变、只想让已有视图重算,可以重新 `bindHero(state.hero.getAttribute())`(它也会置 `needUpdate`),或触发任一注册/增删接口,再调用 `buildup()`。
不要只写 `setFloor` 就期待上下文刷新。
## 三种怪物对象
@ -76,12 +81,17 @@ graph TD
## 优先级的边界
光环、特殊查询与常规查询共用同一套优先级:数值越大越先执行。高优先级留下的结果,低优先级能看到;反过来不行。
这里其实有两套顺序,不要混为一谈。
**特殊光环与特殊查询**按优先级全局排队:`buildupSpecials` 每轮取当前未处理的最大优先级,先执行该优先级下的 `couldApplySpecial` 光环,再执行同优先级的特殊查询,然后才进入下一个优先级。高优先级留下的特殊属性,低优先级能看到;反过来不行。
**常规查询**不参加这个全局队列。`buildupQuery` 是逐只怪物、逐个特殊属性 code 执行的:对怪物身上的每个 `special`,取出 `commonQueryMap.get(special.code)` 上注册的效果,按这些效果自己的 `priority` 从高到低执行。优先级只在**同一个 code 内**排序;不同 code 之间没有全局先后,执行顺序取决于 `enemy.iterateSpecials()` 的遍历顺序。
由效果新增的特殊属性会立刻被转换成光环,但这条新光环只能影响之后的阶段:
- 新光环的优先级**高于**当前阶段时,开发环境会告警并跳过这条新增属性;
- 当前阶段不能删除同优先级或更高优先级的已生效光环,删除会被拒绝并告警;
- 新光环的优先级**高于**当前阶段时,开发环境会告警并跳过这条新增属性(特殊属性也不会被加上);
- 删除特殊属性时,`enemy.deleteSpecial(...)` **总是先执行**;如果对应光环的优先级不低于当前阶段,开发环境会告警并跳过「从排序表移除光环」这一步,于是出现「特殊属性已经删了,但这一轮光环还在」的状态。生产环境没有这个优先级拦截,会直接把光环移除。因此不要把它理解成「删除被拒绝」;
- `modify` 命中同优先级或更高优先级的光环时只会告警,不会回滚已经写入的修改;
- 同一优先级内部不保证先后顺序,不要把有依赖关系的两条效果放在同一优先级。
举个例子:优先级 20 的特殊光环给周围怪物加上一条优先级 10 的普通光环,这是成立的——20 先执行,10 在之后的基础光环阶段生效。反过来,优先级 10 的光环想给目标加上优先级 20 的光环则不成立:20 比当前阶段高,开发环境会告警并跳过。设计效果时,先想清楚「它应该在谁之后生效」,再决定优先级。
@ -166,7 +176,7 @@ import { FullRange, IRange } from '@motajs/common';
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import { ICoreState } from '@user/data-state';
import {
IEnemyAuraView,
IAuraView,
IEnemyFinalEffect,
IEnemyHandler,
IEnemySpecialModifier,
@ -182,8 +192,8 @@ class GlobalArmorFinal implements IEnemyFinalEffect<IEnemyAttr, IHeroAttr> {
}
}
/** 全局基础光环:全场怪物 +10 攻速。 */
class SpeedAura implements IEnemyAuraView<IEnemyAttr, void, void> {
/** 无来源的全局基础光环:全场怪物 +10 攻速。 */
class SpeedAura implements IAuraView<IEnemyAttr, void> {
readonly priority = 10;
readonly couldApplyBase = true;
readonly couldApplySpecial = false;
@ -236,7 +246,7 @@ function inspectAfterBuildup(
}
```
`addAura` 添加的是无来源全局光环,不需要转换器。实际游戏里 `initializeEnemy` 已经注册了三个光环转换器,因此基础光环阶段一定会执行;如果你在测试里手建了一个空上下文,记得先注册至少一个转换器或特殊查询,否则这一段会被跳过。
`addAura` 添加的是无来源全局光环,不需要转换器。但要注意 `buildup()` 的外层开关:它判断的是 `auraConverter.size > 0 || specialQueryEffects.size > 0`,并不看 `globalAuraList`。实际游戏里 `initializeEnemy` 已经注册了三个光环转换器,所以全局光环会正常进入构建;如果你在测试里手建了一个空上下文,只调用 `addAura` 而不注册任何转换器或特殊查询,`buildupSpecials` / `buildupBase` 根本不会被调用,光环看起来像没生效。
## 怎么验证
@ -288,7 +298,9 @@ function verifyBuildup(state: ICoreState, view: IEnemyView<IEnemyAttr>): void {
- **不要在高频循环里反复全量 `buildup()`**:全量构建会重置并重算所有怪物,开销随怪物数量增长;单只变化优先用 `markDirty` + `requestRefresh`。
- **`getComputingEnemy()` 是内部接口**:它暴露正在构建中的可写怪物,外部调用会绕过刷新与脏标记。
- **查询效果不是内置内容**:`initializeEnemy` 只注册光环转换器、最终效果、战斗脚本与钩子,没有注册任何查询。查询需要自己注册后才会参与构建。
- **`buildupSpecials` 在没有光环时提前返回**:它先加入全局光环、转换特殊属性光环,然后执行 `if (sortedAura.size === 0) return;`。如果只注册了特殊查询而场上没有任何光环,这个提前返回会让特殊查询也不执行。需要特殊查询时,至少保证存在一条光环(全局或转换出的都算)。
- **光环可以命中自己**:范围包含中心时(`ManhattanRange` 的 `radius: 0`、`FullRange` 等),来源怪物自己也在扫描结果里,`apply` / `applySpecial` 会作用到它自己。设计要求「不影响自身」时,在 `apply` 里跳过 `handler.locator` 与来源位置相同的目标(带来源的 `IEnemyAuraView` 可比较 `aura.locator`),或改用不含中心的范围。
- **开发环境的优先级告警不是致命错误**:它会跳过不合法的增删,但通常说明阶段依赖设计有问题,应该调整优先级而不是忽略告警。
- **`clear()` 与 `destroy()` 的时机不同**:`clear()` 清空怪物与运行状态,保留注册项;`destroy()` 会连伤害系统、地图伤害与所有注册项一起清掉。销毁后不应继续使用该上下文。
- **`clear()` 与 `destroy()` 的时机不同**:`clear()` 清空怪物、运行状态与无来源全局光环列表(`globalAuraList`),但保留光环转换器、查询、最终效果等注册项;`destroy()` 会连伤害系统、地图伤害与所有注册项一起清掉。销毁后不应继续使用该上下文。
返回[高级 II 导览](./index.md)。

View File

@ -33,15 +33,15 @@ export interface IItemEquipData<THero> {
`state.hero.items.equipment` 是 `IHeroEquipsStore<IHeroAttr>`,管理所有已获得的装备实例:
| 成员 | 作用 |
| -------------------- | ----------------------------------------------------------- |
| `add(item)` | 按图块数字或字符串 id 创建实例,返回新 `uid`;失败返回 `-1` |
| `delete(uid)` | 移除实例 |
| `get(uid)` | 取实例,未找到返回 `null` |
| `instancesOf(equip)` | 取某件装备的全部实例,按排序器输出 |
| `instances()` | 取全部实例,按排序器输出 |
| `count(item)` | 数某件装备的实例数量 |
| `useSorter(sorter)` | 设置排序器,传 `null` 恢复按 `uid` 升序 |
| 成员 | 作用 |
| -------------------- | ------------------------------------------------------------- |
| `add(item)` | 按图块数字或字符串 id 创建实例,返回新 `uid`;失败返回 `-1` |
| `delete(uid)` | 移除实例 |
| `get(uid)` | 取实例,未找到返回 `null` |
| `instancesOf(equip)` | 取某件装备的全部实例,参数为图块数字或字符串 id,按排序器输出 |
| `instances()` | 取全部实例,按排序器输出 |
| `count(item)` | 数某件装备的实例数量,参数为图块数字或字符串 id |
| `useSorter(sorter)` | 设置排序器,传 `null` 恢复按 `uid` 升序 |
`add` 的参数可以传图块数字或字符串 id,内部先经 `tileStore.num` 解析,再取 `itemStore` 里的定义。因此调用前道具定义必须已经注册。
@ -62,7 +62,7 @@ function grantEquipment(state: ICoreState, id: string): number {
}
```
拿到 `uid` 后,既可以交给装备槽穿戴,也可以直接改实例的加成。`instancesOf` 与 `count` 都按「同一件装备」聚合,与 `uid` 无关。
拿到 `uid` 后,既可以交给装备槽穿戴,也可以直接改实例的加成。`instancesOf` 与 `count` 都按「同一件装备」聚合,与 `uid` 无关;两者都接受图块数字或字符串 id,内部同样先经 `tileStore.num` 解析。
## 装备实例的修饰器
@ -106,6 +106,8 @@ function strengthenEquipment(state: ICoreState, uid: number): void {
修饰器按 `priority` 从大到小应用,所以百分比先算、数值后加。若基础攻击为 `10`,装备提供 `atk +5` 与 `def +10%`,则攻击为 `15`,防御为 `baseDef * 1.1`。百分比不会把其它装备的数值加成也乘进去。
多件装备的百分比是**相加**,不是相乘:每件装备的 `PercentageModifier` 都独立按基础值计算 `baseValue * value`,再依次加到当前值上。两件各 `+10%` 的装备得到 `base * 1.2`,不是 `base * 1.21`;数值修饰器则在百分比之后再直接相加。
实例的修饰器是活的:穿戴后修改实例数值,`setValue` 会让已挂到属性上的修饰器标脏并立即重算,不需要重新穿戴。
## 装备槽与装备行为
@ -238,7 +240,7 @@ function useAtkSort(state: ICoreState): void {
## 装备事件
`EquipmentState` 提供 `IEquipmentStateHooks.onChangeModifier`,在修饰器增加、删除、数值修改时触发。它适合做「装备强化后重算某个系统」这类联动:
`EquipmentState` 提供 `IEquipmentStateHooks.onChangeModifier`,但它监听的是**修饰器变化**,不是穿戴。`setValue`、`setPercentage`、`buildModifiers` 以及删除条目都会触发它;`equip` 与 `unequip` 本身不会触发。它适合做「装备强化后重算某个系统」这类联动:
```ts
import { ICoreState } from '@user/data-state';
@ -250,7 +252,7 @@ function watchEquipment(state: ICoreState, uid: number): void {
const controller = equip.addHook({
onChangeModifier(modifiers) {
// 每次增加、删除或修改修饰器后都会触发
// 改变 setValue / setPercentage / buildModifiers 时触发
// modifiers 是实时迭代器,不要保存到回调之外
for (const [name, modifier] of modifiers) {
console.log('装备属性变化', name, modifier.value);
@ -263,7 +265,29 @@ function watchEquipment(state: ICoreState, uid: number): void {
}
```
`IItemRawData.equip.loadEvent` 与 `unloadEvent` 不是自动钩子。引擎不会在穿戴或卸下时执行它们,目前它们只是数据字段。需要「穿上后触发」的行为,用 `onChangeModifier`,或在调用 `equip` 与 `unequip` 的地方自行处理。
`IItemRawData.equip.loadEvent` 与 `unloadEvent` 不是自动钩子。引擎不会在穿戴或卸下时执行它们,目前它们只是数据字段。**引擎也没有内置「装备穿上/卸下」钩子**;`onChangeModifier` 不能替代它:
- 如果装备实例在 `add` 时已经 `buildModifiers()`,之后 `equip` 只是把已有修饰器挂到属性上,不会触发 `onChangeModifier`;
- 如果实例是在别的时机才首次 `getModifiers()`,`equip` 可能顺带触发一次懒构建,但那是构建副作用,不是稳定的穿戴信号;
- 强化或改值同样会触发它,即使没有穿戴。
需要「穿上后触发」的行为,就在调用 `equip` / `unequip` 的统一入口里处理,或者包装一个自己的装备服务:
```ts
import { ICoreState } from '@user/data-state';
/** 在穿戴入口统一处理穿上后的逻辑。 */
function equipWithCallback(
state: ICoreState,
uid: number,
slot: string | number
): number | undefined {
const replaced = state.hero.equip.equip(uid, slot);
// 只有这里能确定「刚完成一次穿戴」;不要依赖 loadEvent 或 onChangeModifier
console.log('穿上', uid, '替换下', replaced);
return replaced;
}
```
`onChangeModifier` 在 `buildModifiers()` 时也会触发一次。实例在 `add` 时就已经构建过修饰器,所以之后才 `addHook` 的监听者收不到这次初始构建,只会收到后续变化。
@ -302,11 +326,14 @@ function verifyEquipment(state: ICoreState): void {
console.log('穿戴后攻击', attribute.getFinalAttribute('atk'));
console.log('穿戴后防御', attribute.getFinalAttribute('def'));
// 先存档,此时装备仍穿在身上,装备槽与实例仓都会写进快照
const snapshot = state.hero.saveState(SaveCompression.NoCompression);
// 再卸下,确认修饰器被移除
equipment.unequip(equipment.slots.indexOf('weapon'));
console.log('卸下后攻击', attribute.getFinalAttribute('atk'));
// 存档 -> 卸下 -> 读档,确认加成由重新穿戴恢复
const snapshot = state.hero.saveState(SaveCompression.NoCompression);
// 最后读档:属性先回到基础值,HeroEquipment.loadState 再按快照重新穿戴
state.hero.loadState(snapshot, SaveCompression.NoCompression);
console.log('读档后攻击', attribute.getFinalAttribute('atk'));
@ -317,7 +344,7 @@ function verifyEquipment(state: ICoreState): void {
}
```
穿戴后攻击应比基础值多 `5`,防御应为 `baseDef * 1.1`;卸下后两者回到基础值。读档后这两个差值应再次出现。
穿戴后攻击应比基础值多 `5`,防御应为 `baseDef * 1.1`;卸下后两者回到基础值。因为存档发生在卸下之前,读档后 `HeroEquipment.loadState` 会按快照重新穿戴,这两个差值应再次出现。若把存档放在卸下之后,快照里没有已穿戴的装备,读档后自然不会有加成——这不是读档失败。
## 边界与注意

View File

@ -26,7 +26,7 @@
同一层的篇目可能分散在数据端的不同模块;层与层之间也可能调整主题。判断依据是难度与改动深度,不是模块归属。
高级 III 目前只有代码 span `高级 III · 原理与架构` 这样的占位标记,没有页面入口。本篇不展开原理与架构,只用占位指出归属。
高级 III 尚未编写。本层正文只在需要时用 `高级 III · <topic>` 这样的代码 span 或 `<!-- TODO(advanced-III) -->` 标出归属,不会写成真实链接;等该层建立后再替换。
## 前置
@ -79,18 +79,26 @@
- **高级 III 的原理与架构**:双端分离约束、数据端分层、编辑器识别协议、录像深层原理与性能调优留给高级 III。
- **API 全量参考**:本层只讲改动链路与关键接口,不做签名清单。
## 优先插件
## 优先插件与改内置的边界
新增系统优先通过插件承载;只有不得不修改内置系统时,才去改 `packages` 与 `packages-user`。原因是升级成本:`src` 基本不会被上游改动,而内置包会。把改动留在插件里,升级时需要对照的地方少得多。
| 需求类型 | 优先做法 | 什么情况下才改内置 |
| ------------------------------ | ------------------------------ | -------------------------------------- |
| 新增一套有状态与生命周期的系统 | 写成插件,在插件里装配对象 | 现有插件只差一个接口或默认值时 |
| 给已有系统补一条范围或行为 | 注册自己的范围对象、视图或钩子 | 需要替换整套策略、或改动会破坏内置行为 |
| 新增一个勇士或怪物属性 | 先确认能否用现有修饰器表达 | 属性要参与伤害、光环、查询与存档时 |
| 新增一条可回放的行为 | 先给已有方法补录像标记 | 行为不在引擎里、录像没有对应指令码时 |
本层有几处**只能**改 `packages-user`,因为它们是接口或录像格式本身,插件没有扩展点:
判断标准不是改动多少行,而是改动落在谁的代码里。能由插件或注册项完成的,就留在 `src`;必须动内置包时,尽量把差异收敛在一个方法或一条分支上。
- 给 `IHeroAttr` / `IEnemyAttr` 增加字段(类型定义在 `packages-user/data-common/src/types.ts`);
- 追加 `ReplayCode`(录像格式的一部分);
- 补 `HERO_DEFAULT_ATTRIBUTE`,以及需要随接口一起更新的默认值常量。
这些改动应在满足需求的前提下尽量小,只加字段、只追加枚举值,不要顺手改内置逻辑。除此之外,本层反复出现的范围、光环、查询、最终效果、录像指令注册与钩子都优先走注册或插件,不直接改内置实现。
| 需求类型 | 优先做法 | 什么情况下才改内置 |
| ------------------------------ | ------------------------------ | ----------------------------------------- |
| 新增一套有状态与生命周期的系统 | 写成插件,在插件里装配对象 | 现有插件只差一个接口或默认值时 |
| 给已有系统补一条范围或行为 | 注册自己的范围对象、视图或钩子 | 需要替换整套策略、或改动会破坏内置行为 |
| 新增一个勇士或怪物属性 | 先确认能否用现有修饰器表达 | 接口字段只能加在 `packages-user` 的类型里 |
| 新增一条可回放的行为 | 先给已有方法补录像标记 | 行为不在引擎里、录像没有对应指令码时 |
判断标准不是改动多少行,而是改动落在谁的代码里。能由插件或注册项完成的,就留在 `src`;必须动内置包时,尽量把差异收敛在一个字段、一个枚举值或一条分支上。
## 怎么验证你读懂了

View File

@ -36,7 +36,8 @@ flowchart LR
绑定发生在初始化与换楼层两处。`src/data/initialize.ts` 已经注入通行谓词并把移动器指向勇士,图层先置为 `null`;真正进入楼层后,再把当前楼层的事件层与方向处理器绑上:
```ts
import { Dir4FaceHandler } from '@motajs/common';
// Dir4FaceHandler / Dir8FaceHandler 都在 @user/data-common,不在 @motajs/common
import { Dir4FaceHandler } from '@user/data-common';
import { ICoreState } from '@user/data-state';
/**
@ -73,6 +74,13 @@ function bindPathfindingLayer(state: ICoreState): void {
每一步是 `IPathfindingStep`,包含 `dir`(移动方向)、`from`(出发位置)与 `to`(到达位置)。`from` 与 `to` 都是只读的坐标对象;路径按起点到终点的顺序排列。
`finder.find(start, target)` 和 `state.pathfinding.getPath(target)` 的区别只在起点:
- `finder.find` 要求显式传入起始坐标,不会读取移动器,适合「从任意起点试算」;
- `getPath` 是便捷包装,取 `state.pathfinding.mover.tile` 的当前坐标作为起点,再把结果交给 `finder.find`。没有绑定移动器时它直接返回 `InvalidInput`。
两者都依赖同一套图层、通行谓词与方向处理器;换用哪个都不会绕过这些绑定。
## 两种移动方式
`moveTo` 总是逐步移动。它先取路径,状态不是 `Success` 时直接返回 `null`;否则把每一步的 `dir` 依次交给移动器,再启动移动器。
@ -97,7 +105,7 @@ async function walkTo(
}
```
两个方法都返回 `IPathfindingController`:`controller` 是底层移动控制器(可读 `done`、可 `await onEnd`),`path` 是本次使用的寻路结果。需要中止当前移动时调用 `interrupt()`,它会停止底层移动器并等待其结束;新的方向输入或新的寻路调用也可以接管当前移动。
成功启动时,两个方法都返回 `IPathfindingController`:`controller` 是底层移动控制器(可读 `done`、可 `await onEnd`),`path` 是本次使用的寻路结果。**上一次移动没有结束时,新的 `moveTo` / `teleportTo` 会直接返回 `null`,不会接管当前移动**;底层 `mover.start()` 在移动中同样返回 `null`。想换目标,必须先等 `controller.onEnd`,或调用 `await state.pathfinding.interrupt()` 停止当前移动,再发起新的寻路。
## 初始化在哪里
@ -236,14 +244,26 @@ export class MainPathfindingSystemHooks implements IPathfindingSystemHooks {
);
}
onAfterTeleportTo(path: IPathfindingResult): void {
onAfterTeleportTo(
path: IPathfindingResult,
system: IPathfindingSystem
): void {
// 第二个参数同样是寻路系统,可用来判断这次移动绑定的移动器
void system;
// 这个钩子挂在所有移动的结束回调上,逐步移动也会走到这里
console.log('寻路移动结束,共', path.path.length, '步');
}
}
```
钩子实例在 `src/data/hook/initialize.ts` 中注册。需要区分「这次是瞬移还是逐步」时,在 `onBeforeTeleportTo` 里记录标记,再在 `onAfterTeleportTo` 里读取并清除;不要只看结束钩子。
这段代码直接补进 `src/data/hook/path.ts` 的 `MainPathfindingSystemHooks` 即可(类已存在,方法体是空的)。注册发生在 `src/data/hook/initialize.ts` 的 `initializeHooks`:
```ts
// initializeHooks 内部
state.pathfinding.addHook(new MainPathfindingSystemHooks(state));
```
需要区分「这次是瞬移还是逐步」时,在 `onBeforeTeleportTo` 里记录标记,再在 `onAfterTeleportTo` 里读取并清除;不要只看结束钩子。
## 怎么验证

View File

@ -73,7 +73,12 @@ function inspectLayer(layer: IMapLayer): void {
3. 用这个数字创建一个动态图块;
4. 按 `keepEvent` 决定是否把原静态图块的**图块事件**复制到动态图块。
`keepEvent` 默认是 `true`。保留时,原静态图块当前的图块事件会复制到动态实例,然后原位置的事件被清空;不保留时,动态实例保留它按图块定义重建的默认事件。无论是否保留,静态图块那一格都不再持有原来的图块事件。
`keepEvent` 默认是 `true`。两种取值对源静态图块的影响并不对称:
- `true`:先把源静态图块的图块事件复制到动态实例,再调用 `staticTile.tileEvent().clear()` 清空源实例的事件;
- `false`:动态实例按图块定义重建默认事件,但**源静态图块实例完全不被触碰**,它缓存里原来的图块事件仍然保留。
因此「不保留」不等于「源事件被清空」。之后如果又往这一格写静态图块,`layer.setBlock(num, x, y)` 不会重置该坐标缓存实例的事件(只有 `StaticTile.set(num)` 会调用 `restoreDefaultEvents()`),旧的图块事件就可能被新图块继承。想确保干净,转换后用 `source.tileEvent().clear()` 或 `source.restoreDefaultEvents()` 显式清一次,之后再用 `tile.set(num)` 换图块。
```ts
import { IMapLayer } from '@user/data-base';
@ -137,8 +142,8 @@ async function shiftRight(
### 转换的失败条件
- `transferToDynamic` 在坐标不在地图上时返回 `null`,并告警 code 128;目标格为空时仍会创建一个数字为 `0` 的动态图块,并告警 code 127。
- `toStatic()` 按接口约定,在动态图块坐标不是整数或正在移动时返回 `null`;目标格被占用时会告警 code 129 并覆盖。
- `toStaticIfSafe()` 在 `toStatic()` 的基础上额外要求目标格为空;目标格被占用时返回 `null`。
- `toStatic()` 在目标坐标不在地图内时返回 `null`;目标格被占用时会告警 code 129 并覆盖。接口注释还约定「坐标不是整数或正在移动」时应失败,但当前实现没有在 `transferToStatic` 里检查 `mover.moving`,所以不要依赖它替你拦截移动中的转换。需要转换时先 `await controller.onEnd`,或至少判断 `dynamic.mover.moving` 为 `false`。
- `toStaticIfSafe()` 在 `toStatic()` 的基础上额外要求目标格为空;目标格被占用时返回 `null`。它同样不会检查移动状态。
- 动态图块被转回静态,或被 `delete()` 删除时,会触发图层的 `onDeleteDynamic` 钩子。`delete()` 返回 `Promise<void>`,会等待钩子完成;`toStatic` 只触发钩子,不等待。
- `createDynamic` 与 `transferToDynamic` 都会触发 `onCreateDynamic`;`updateDynamicTile` 会触发 `onUpdateDynamicPosition`,移动器在每步落点更新位置时会调用它。
@ -160,7 +165,7 @@ async function shiftRight(
## 事件视图接口
点事件与图块事件都用同一套事件视图。只读接口是 `IReadonlyEventView`:
点事件与图块事件都用同一套事件视图。取可写视图用 `layer.event(x, y)`(返回 `ILayerEventView | null`)或 `tile.tileEvent()`;`layer.getPointEvent(x, y)` 不是视图,它只是 `event(x, y)?.get()` 的简写,返回 `ReadonlyMap<number, string> | null`,不能用来修改点事件。只读接口是 `IReadonlyEventView`:
| 成员 | 作用 |
| --------- | -------------------------------------------------------------- |
@ -293,6 +298,7 @@ async function verifyTileConversion(
- **静态图块的 `set()` 会重置默认事件**:`set(num)` 把图块事件恢复为该图块定义的默认事件。想保留自定义事件,应在 `set` 之后再挂,或使用 `syncTileEvent` 从别的实例复制。
- **动态图块不写回图层数组**:图层数组只保存静态图块数字。判断某一格有没有动态图块要用 `getDynamicTilesAt` / `iterateDynamicTiles`,不要只读 `getBlock`。
- **`keepEvent` 为 `false` 时事件不是「丢了」**:目标图块会回到该图块定义的默认事件,这是设计行为,不是转换失败。需要保留自定义事件就显式传 `true`,并在转换前确认源实例的事件已经设置好。
- **`layer.setBlock` 不会重置图块事件**:它只改图块数字;同一坐标缓存着的静态图块实例会继续持有旧事件。要重置事件,用 `tile.set(num)` 或 `tile.restoreDefaultEvents()`,不要以为换了数字事件就自动干净。
- **同一格多个动态图块的顺序不保证**:`getDynamicTilesAt` 返回的是集合语义的迭代器;依赖先后顺序时,请先取出数组并自行排序。
- **`syncTileEvent` 会覆盖目标**:它先清空目标再复制,目标原有的图块事件不会保留。只想追加一条事件时,用 `tileEvent().set(priority, eventId)`,不要用 `syncTileEvent`。
- **点事件与图块事件共用优先级概念但不是同一张表**:同一格可以同时有优先级 `5` 的点事件与优先级 `5` 的图块事件,两者互不冲突;冲突只发生在同一个事件视图内部。