Files
template/docs/advanced/ii/custom-range.md
T
AncTe 7409a47037 docs(10-27): U7 修订批次E(范围/录像/寻路/编辑器占位)
- U7-02:删除范围对象调用处的模块级复用说明注释
- U7-05:指令码位宽收敛为一句,细节指向高级 III
- U7-06:选择框只作概念示例,代码示例恢复为技能示例
- U7-07:NoPath 改为正常结果,无需处理
- U7-08:移除编辑器过渡说法,jsonc 内容改为「需要编辑器」占位
2026-10-22 16:52:38 +08:00

261 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自定义范围对象
光环要影响「周围的怪物」,地图伤害要判定「勇士踩进的格子」,查询也常常要问「这一圈里有哪些目标」。这些需求表面不同,底层都是同一件事:**以某个坐标为中心,枚举或判断一片格子**。负责这件事的对象就是范围对象 `IRange`。
本篇说明 `IRange` 的职责、它和宿主的关系、内置范围各自的参数,最后给出一个可直接复用的自定义范围类。读完你应当能判断:一个需求该用哪个内置范围,以及内置范围不够时该怎么补一个。
## 范围对象解决什么问题
范围对象把「哪些格子算在范围内」从具体系统里抽出来。光环不需要知道范围是矩形还是菱形,只需要拿到一个 `IRange` 与一份参数;地图伤害也一样。于是同一个自定义范围可以同时被多个系统复用,而不必在每个系统里各写一遍坐标循环。
范围对象输出的是**坐标索引**,不是 `{ x, y }` 对象。约定是:
```ts
const index = y * width + x;
```
其中 `width` 是宿主的宽度。`iterateLoc`、`scan`、`iterate` 返回的都是这种索引;需要坐标时再拆回去:
```ts
const x = index % width;
const y = Math.floor(index / width);
```
`inRange(x, y, param)` 接收坐标,`inRangeIndex(index, param)` 接收索引,两者判断的是同一件事。
## 范围对象与宿主
范围对象本身不保存地图。它只保存宿主的**尺寸**,因为枚举时需要知道边界在哪里。宿主接口只有两个成员:
```ts
export interface IRangeHost {
/** 区域整体宽度 */
readonly width: number;
/** 区域整体高度 */
readonly height: number;
}
```
`bindHost(host)` 把宿主尺寸交给范围对象;`inBound(x, y)` 与 `inBoundIndex(index)` 用这份尺寸判断点是否落在宿主内。`BaseRange` 的宿主初始是 `{ width: 0, height: 0 }`,所以**没有绑定宿主之前,任何范围都是空的**。
地图上下文、图层对象都可以充当宿主,只要它们有 `width` 与 `height`。绑定发生在系统内部:光环构建时上下文会绑定自己,地图伤害刷新时也会绑定上下文。自定义范围不需要自己保存地图宽高,更不要在不同尺寸的地图之间共用同一个已绑定的范围实例。
## 接口职责
`IRange<T>` 的成员可以按「输入什么、输出什么」分成三组:
| 成员 | 作用 | 计算量 |
| ---------------------------- | -------------------------------------------------------- | -------------- |
| `iterateLoc(param)` | 枚举范围内全部索引,顺序由范围自己决定 | 与范围面积相关 |
| `inRange(x, y, param)` | 判断单个坐标是否在范围内 | 一次判定 |
| `inRangeIndex(index, param)` | 判断单个索引是否在范围内,内部会拆回坐标再调用 `inRange` | 一次判定 |
| `iterate(list, param)` | 遍历一个已有索引列表,只保留落在范围内的项 | 与列表长度相关 |
| `scan(list, param)` | 按范围顺序遍历范围,只输出同时存在于列表中的项 | 与范围面积相关 |
| `autoDetect(list, param)` | 在 `iterate` 与 `scan` 之间自动选择 | 取决于估算结果 |
`scan` 与 `iterate` 的差别只是「先遍历谁」:`scan` 先走范围、再查集合,适合范围小、列表大的情况;`iterate` 先走列表、再逐个判定,适合列表小、范围大的情况。`autoDetect` 用 `estimatePointCount(param)` 估算范围大小,再和 `list.size` 比较,决定走哪一条。这个估算不需要精确,它只影响性能,不影响结果。
`BaseRange` 已经实现了 `inRangeIndex`、`iterate`、`scan`、`autoDetect`。继承它时通常只需要实现三个方法:`estimatePointCount`、`iterateLoc`、`inRange`。
### estimatePointCount 的取舍
`autoDetect` 每次调用时都会先执行一遍 `estimatePointCount`,再拿结果和 `list.size` 比较。因此这个估算的耗时会计入每一次范围查询——`scanRange`、光环、地图伤害等所有走 `autoDetect` 的地方都受影响。
它只需要「尽量快地估出一个大致数量」,不需要精确:
- 估算结果只决定 `autoDetect` 走 `scan` 还是 `iterate`,不影响最终命中哪些格;
- 估大或估小最多让选型变差,不会让结果出错;
- 为了准确去遍历格子、读取地图或做复杂计算,反而会让每一次查询都变慢,代价远大于偶尔选错路径。
建议直接用参数算一个近似值,例如 `RectRange` 用 `w * h`、`ManhattanRange` 用与 `radius` 相关的二次式;不要为了追求精确而牺牲速度。内置范围都是常数时间的估算,可以参考它们的写法。
## 内置范围与参数
以下范围都从 `@motajs/common` 导出:
| 范围类 | 参数类型 | 参数含义 | 适用场景 |
| ---------------- | ---------------------- | --------------------------------------- | ---------------------------- |
| `FullRange` | `void` | 无 | 全图,例如全局光环 |
| `RectRange` | `IRectRangeParam` | `x`、`y` 为左上角,`w`、`h` 为宽高 | 矩形区域,例如十字以外的方块 |
| `ManhattanRange` | `IManhattanRangeParam` | `cx`、`cy` 为中心,`radius` 为半径 | 菱形范围,距离按曼哈顿距离算 |
| `RayRange` | `IRayRangeParam` | `cx`、`cy` 为中心,`dir` 为方向增量列表 | 从一点向若干方向发射的射线 |
| `NoneRange` | `void` | 无 | 永远为空,用于关闭范围效果 |
几个容易混淆的点:
- `RectRange` 的 `w`、`h` 可以为负,内部会按 `min` / `max` 归一化;枚举时会裁掉宿主外的部分。
- `ManhattanRange` 的 `radius` 会取绝对值;半径内包含中心,`radius: 0` 只命中中心。
- `RayRange` 的 `dir` 是 `IFaceDescriptor[]`,每一项形如 `{ x: 1, y: 0 }`。中心点只要在宿主内就会命中;同一格被多条射线覆盖时只输出一次。
- `FullRange` 与 `NoneRange` 不需要参数,泛型参数是 `void`。
## 自定义范围
内置范围覆盖不了时,继承 `BaseRange<T>` 写一个类即可。下面是一个**环形范围**:只保留曼哈顿距离**恰好等于** `radius` 的格子,也就是菱形边框,不含中心与内部。
```ts
import { BaseRange } from '@motajs/common';
// 环形范围的参数:以 (cx, cy) 为中心,只保留距离恰好为 radius 的格
export interface IRingRangeParam {
cx: number;
cy: number;
radius: number;
}
export class RingRange extends BaseRange<IRingRangeParam> {
// autoDetect 用这个估算决定走 iterate 还是 scan,不要求精确
protected estimatePointCount(param: Readonly<IRingRangeParam>): number {
// 半径必须是整数;这里统一截断,避免非整数半径产生非整数索引
const radius = Math.abs(Math.trunc(param.radius));
// 半径 0 退化为单点;否则曼哈顿距离恰为 radius 的格子有 4 * radius 个
return radius === 0 ? 1 : 4 * radius;
}
// 必须与下面的 inRange 判定完全一致,否则 scan 会漏格或多格
*iterateLoc(param: Readonly<IRingRangeParam>): Iterable<number> {
const { width, height } = this.host;
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;
// 每一行只保留左右两个端点:距离中心恰好为 radius 的位置
const span = radius - Math.abs(dy);
if (span === 0) {
if (this.inBound(param.cx, y)) yield y * width + param.cx;
continue;
}
const left = param.cx - span;
const right = param.cx + span;
if (this.inBound(left, y)) yield y * width + left;
if (this.inBound(right, y)) yield y * width + right;
}
}
inRange(x: number, y: number, param: Readonly<IRingRangeParam>): boolean {
const radius = Math.abs(Math.trunc(param.radius));
return (
this.inBound(x, y) &&
Math.abs(x - param.cx) + Math.abs(y - param.cy) === radius
);
}
}
```
三个实现各有分工:
- `estimatePointCount` 只服务 `autoDetect`,返回值偏大或偏小都只会让选型变差,不会改变命中结果;实现时要尽量快,不要为了精确去遍历格子;
- `iterateLoc` 决定「枚举时输出哪些格」,必须和 `inRange` 使用同一套判定;
- `inRange` 决定「单个点算不算命中」,越界必须先由 `inBound` 收口,不能只比较距离。
`radius` 统一取绝对值并截断为整数,因此 `-2` 与 `2` 等价,`2.7` 会按 `2` 处理。**不要依赖非整数半径**:不截断时 `iterateLoc` 会产出非整数索引,`index % width` 也会得到错误坐标。示例用 `Math.trunc` 兜底;如果你希望非整数直接报错而不是被截断,就在三个方法开头换成显式校验。`radius: 0` 时环形退化为中心单点,这与 `inRange` 的 `距离 === 0` 判定一致。
## 绑定到系统
系统会自己调用 `bindHost`,你只需要把范围对象与参数传进去。以怪物上下文为例:
```ts
// RingRange 即上一节定义的自定义范围类
const ringRange = new RingRange();
const loc = state.hero.getLocation();
const hits = state.enemyContext.scanRange(ringRange, {
cx: loc.x,
cy: loc.y,
radius: 2
});
for (const [hit, view] of hits) {
// hit 是命中坐标,view 是该位置的怪物视图
console.log('命中', hit.x, hit.y, view.getBaseEnemy().getAttribute('hp'));
}
```
`scanRange` 内部会先 `range.bindHost(this)`,再用 `autoDetect` 在「遍历怪物列表」与「遍历范围」之间选一条路径。自定义范围不需要知道怪物列表长什么样。
注意 `scanRange` 的语义是「筛选**当前上下文里已存在的怪物视图**」,不是「枚举范围内的空格」:范围里的空格不会出现在结果里,只有站在范围内的怪物才会。要检查范围本身覆盖了哪些坐标,直接跑 `range.iterateLoc(param)`(先绑定宿主),不要拿 `scanRange` 的结果当格子清单。
如果要在自己的函数里复用范围,也可以显式接收宿主:
```ts
import { IRange, IRangeHost } from '@motajs/common';
function collectRing(
range: IRange<IRingRangeParam>,
param: IRingRangeParam,
host: IRangeHost
): number[] {
// 宿主由调用方注入,范围对象只借用宽度与高度
range.bindHost(host);
return [...range.iterateLoc(param)];
}
```
地图伤害与光环视图也是同样的用法:地图伤害在刷新时调用 `range.bindHost(this.context)`,再遍历 `range.iterateLoc(param)`;光环则由上下文统一绑定。具体视图写法见[地图伤害](../../intermediate/enemy-map-damage.md)与[怪物光环](../../intermediate/enemy-aura.md)。
## 与视图和转换器的关系
光环视图通过 `range` 与 `getRangeParam()` 返回「范围对象 + 参数」,上下文拿这两个值去扫描;地图伤害视图通过 `getRange()` 与 `getRangeParam()` 做同样的事。范围对象只负责「哪些格」,不负责「命中后做什么」——后者由视图的 `apply`、`applySpecial` 或 `getDamageWithoutCheck` 决定。转换器如何构造视图、优先级如何排队,见[怪物光环](../../intermediate/enemy-aura.md),本篇不再重复。
## 怎么验证
先确认上下文已绑定勇士。正常初始化流程会调用 `state.enemyContext.bindHero(state.hero.getAttribute())`;如果你在最小环境里手动搭上下文,需要自己补这一步。
`scanRange` 只返回范围内的怪物,所以验证要摆好怪物,而不是只看格子。先以勇士为中心,在中心、曼哈顿距离 `1`、距离 `2` 的位置各放一只测试怪,再执行:
```ts
const ringRange = new RingRange();
state.enemyContext.bindHero(state.hero.getAttribute());
const loc = state.hero.getLocation();
const hits = [
...state.enemyContext.scanRange(ringRange, {
cx: loc.x,
cy: loc.y,
radius: 2
})
];
for (const [hit, view] of hits) {
// hit 是怪物所在坐标;view 是该位置的怪物视图
console.log(
`命中 (${hit.x}, ${hit.y})`,
view.getBaseEnemy().getAttribute('hp')
);
}
```
预期现象:
- 结果里只有距离恰好为 `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` 是否正确。
## 边界与注意
- **越界必须返回 `false`**:`inRange` 先调用 `inBound`,再比较距离。只比较距离会让地图外的坐标也命中。
- **`iterateLoc` 与 `inRange` 必须同源**:两者判定不一致时,`scan` 会按范围枚举出一批格,再用另一套规则判断,结果就会漏格或多格。写完自定义范围后,用同一组参数分别跑 `iterateLoc` 与逐点 `inRange` 对照一次。
- **`estimatePointCount` 只是估算**:它不参与命中判定,也不需要考虑地图裁剪。估得不准只会让 `autoDetect` 选错路径,不会改变结果;但它每次查询都会执行,必须保持廉价,不要为了准确去遍历格子。
- **负半径按绝对值处理**:这是本篇示例的约定;如果你希望负半径表示「无效」,要在 `inRange` 与 `iterateLoc` 里同时改,不能只改一处。
- **绑定后的范围实例不要跨地图共用**:`bindHost` 是可变状态,同一个实例先后绑定不同尺寸的宿主时,后一次会覆盖前一次。需要并行处理多张地图时,每张地图各用一个实例,或在每次使用前重新绑定。
- **`radius: 0` 是中心单点**:环形范围在半径为 0 时没有「边框」可枚举,按距离恰好为 0 处理,只命中中心。
返回[高级 II 导览](./index.md)。