feat: 触发器系统

This commit is contained in:
unanmed 2026-05-16 23:43:14 +08:00
parent 9ada0796b3
commit 501a598de0
11 changed files with 378 additions and 1 deletions

3
dev.md
View File

@ -75,6 +75,7 @@
- 注释合理换行:考虑中文字符较宽,建议每 4060 个字符在标点符号后换行,不要频繁换行,每行长度应差不多,不允许在句中换行;参数注释换行后保持对齐。
- 单行注释结尾不加句号;较长的多行注释结尾可加句号。
- 一般不建议给接口(`inteface` 本身)、类型别名或类本身写注释(不好看),特殊情况除外。
- 对于 jsDoc 注释,方法注释必须使用换行风格;对于成员 jsDoc 注释,除非注释较长需要换行,否则使用不换行的风格。
### 类型规范
@ -85,6 +86,7 @@
- 合理使用 `readonly`、`protected`、`private` 关键字。
- 可选参数过多时(大于两个),考虑改用对象参数。
- 尽量避免 `as` 类型断言,除非必要。
- 函数类型单独开一个 `type` 类型别名,除非函数类型本身较短(小于 20 字符,且不会因为此函数类型导致换行)
### 其他要求
@ -96,6 +98,7 @@
- 只进行必要的非空判断,非必要时直接使用非空断言 `!`
- 除非参数要求传入函数等情况,不建议在函数内定义局部函数。
- 语句尽量不换行,除非必要,尤其注意三元运算符与 `private readonly` 类成员。
- 任何时候都不应该写 `getter``setter`
## 双端分离

103
docs/dev/map/trigger.md Normal file
View File

@ -0,0 +1,103 @@
# 需求综述
当前地图系统已完成图层管理与动态图块的构建,但地图上每个格点可能存在的**事件逻辑**(如战斗、对话、商店等)尚无统一抽象。本需求旨在建立一套触发器系统,将地图事件的注册、收集与触发解耦为三个独立接口层,最终可通过统一的方式触发地图上某一点所有匹配的事件。
触发器本身不与地图绑定,可独立于地图存在。由于触发的执行依赖 Layer 2 的上下文(`IStateBase` 等),整个触发器系统均归属 Layer 2`@user/data-system`),地图在 Layer 1 中只存储可序列化的图块数据,不直接持有 `ITrigger`
---
# 接口设计与预期
## ITrigger
- `ITrigger.type`:预期频率**低频**,触发器的类型标识符,以数字表示,用于区分不同种类的触发器(如战斗、对话等);一般仅在实例化时通过构造参数传入一次,运行期基本不读写,故为低频。
- `ITrigger.priority`:预期频率**低频**,仅在创建触发器时设置一次,用于控制同格点多触发器的执行顺序;绝大多数情况下一个格点只会有一个触发器,多触发器仅为少量拓展场景预留,在 `collect` 场景下,同一次收集中的 `priority` 重复会被视为配置冲突,故为低频。
- `ITrigger.trigger(handler)`:预期频率**中频**,用户在脚本中有时会直接持有某个触发器引用并手动触发,但大多数事件触发都通过 `ITriggerCollection.trigger` 完成,直接调用此方法的场合相对有限,故为中频。接受 `ITriggerHandler` 上下文对象(必选 `state: IStateBase`,可选 `layer?: ILayerState`、`mapLayer?: IMapLayer`、`locator?: ITileLocator`),返回 `Promise<void>`。典型使用场景:脚本中已持有某个战斗触发器引用,希望直接触发而不经过格点收集流程。
- `ITrigger.collection()`:预期频率**中频**,当用户希望将单个触发器包装为集合并走统一触发流程时使用,场景较为固定但有一定出现概率,故为中频。典型使用场景:脚本中手动构造只含单个触发器的集合,再调用 `collection.trigger(...)` 统一触发。
## ITriggerRegistry
以触发器**类型**`type: number`)为核心提供注册与查询;`registerString` / `getString` 是触发器自身 id 的字符串别名,与地图图块绑定无关,不参与 `collect` 的自动收集流程。共四个方法,不拆分为子接口。
- `ITriggerRegistry.register(type, factory)`:预期频率**低频**,按触发器类型注册触发器工厂函数(`(type: number) => ITrigger`),由注册表在实例化时将当前 `type` 透传给工厂,使 `ITrigger.type` 跟随注册表,`collect` 时每次调用工厂创建独立实例,一种触发器类型只注册一次,故为低频。
- `ITriggerRegistry.get(type)`:预期频率**低频**,主要供 `ITriggerCollector` 内部调用,用户代码几乎不会直接使用,故为低频。
- `ITriggerRegistry.registerString(id, factory)`:预期频率**低频**,为触发器注册字符串别名(`() => ITrigger`),仅用于手动按字符串 id 查询,与地图图块收集无关,故为低频。
- `ITriggerRegistry.getString(id)`:预期频率**低频**,按字符串 id 查询触发器工厂,调用方手动获取后实例化,主要供内部调用,故为低频。
## ITriggerCollection
- `ITriggerCollection.count`:预期频率**低频**,在"当格点无任何触发器时跳过处理"的场景下可快速判断,避免额外迭代,故为低频。
- `ITriggerCollection.trigger(handler)`:预期频率**高频**,这是触发器系统对用户暴露的最主要入口,凡是需要触发某格事件的地方都会出现此调用,故为高频。接受 `ITriggerHandler` 上下文对象,顺序异步执行所有触发器,返回 `Promise<void>`。典型使用场景:玩家移动到某格后,移动系统调用 `collection.trigger(handler)` 依次执行该格所有已排序的触发器。
- `ITriggerCollection.triggerIter(handler)`:预期频率**低频**,返回 `AsyncGenerator<ITrigger, void, ITriggerHandler | null>`,允许调用方逐个手动推进触发器执行,每次 `next(handler)` 可传入新的上下文(传 `null` 则沿用初始 `handler`),故为低频。典型使用场景:需要在两个触发器之间插入额外效果(如战斗结束后立刻播放特效)的进阶场景。
- `ITriggerCollection.iterate()`:预期频率**低频**,仅在需要检查当前集合包含哪些触发器时使用(如 UI 显示交互提示),多数情况下直接触发而无需遍历,故为低频。
- `ITriggerCollection.push(trigger)`:预期频率**低频**,在脚本中偶尔需要向已有集合末尾追加一个触发器(如特殊演出追加额外效果),直接插入末尾不重新排序,故为低频。
- `ITriggerCollection.unshift(trigger)`:预期频率**低频**,与 `push` 对称,向集合头部强制插入触发器,直接插入头部不重新排序,场景更为罕见,故为低频。
- `ITriggerCollection.concat(...others)`:预期频率**低频**,将当前集合与一个或多个其他集合按自身在前、传入参数依序在后的顺序合并为新集合,不重新排序。此接口需求存疑,先行提供,故为低频。
## ITriggerCollector
`ITriggerCollector` 独立存在于 Layer 2`@user/data-system`),不附属于 `ILayerState`。调用方持有 collector 引用,调用 `collect(x, y, layer)` 时显式传入目标图层(`IMapLayer`)。收集时会依赖后续由 `IMapLayer.getTileInfo` 暴露的图块信息,其中包含触发器类型。引擎层会利用 `ILayerState.eventLayer` 自动调用 `collect` 实现默认收集行为,该行为不在本接口设计范围内;若需对多个图层收集,调用方自行多次调用并合并结果,合并顺序、跨层排序结果与跨层冲突处理均由调用方自行决定。
- `ITriggerCollector.collect(x, y, layer)`:预期频率**中频**,在移动系统或交互系统中需要确认某格事件时调用,使用场景固定且有一定频率,故为中频。`layer` 为必选,调用方显式指定要收集的目标图层(`IMapLayer`)。典型使用场景:玩家向某格移动时,移动系统持有 collector 引用并调用 `collector.collect(x, y, layer)` 获取目标格的 `ITriggerCollection`,再决定是否阻断移动或直接触发。
- `ITriggerCollector.attachRegistry(registry)`:预期频率**低频**,仅在初始化或切换注册表时调用,故为低频。
---
# 实现思路
## 1. 触发器对象 ITrigger
`ITrigger` 代表一类事件逻辑(如"战斗触发器"、"对话触发器"),是整个系统的原子单元。`type` 成员以数字标识触发器种类,在常规注册流程下由注册表在实例化时通过构造参数注入;`priority` 控制同格点的执行顺序,并在 `collect` 场景下充当唯一执行位。触发器本身是极其宽泛的东西,一般只有几种类型,比如战斗、触发系统事件、触发自定义事件等,不会每种战斗和每种对话都开一种触发器。触发时接受外部传入的 `ITriggerHandler` 上下文参数,执行对应逻辑。
`ITrigger` 提供 `collection()` 方法,将单个触发器包装为 `ITriggerCollection`,供不需要收集步骤的场景直接使用。
## 2. 触发器注册接口 ITriggerRegistry
触发器以类型为单位注册。为保证每个收集场景下的 `ITrigger` 实例相互独立(触发器可在内部保存状态而不互相影响),注册时传入工厂函数 `(type: number) => ITrigger`,由注册表在创建实例时将当前类型透传给工厂,使实例上的 `ITrigger.type` 跟随注册表;`collect` 时按需调用工厂创建新实例。字符串别名注册仍用于手动查询场景,不参与 `collect` 自动收集。`ITriggerRegistry` 直接包含以下四个方法:
- `register(type: number, factory: (type: number) => ITrigger)`:按触发器类型注册触发器工厂
- `get(type: number)`:按触发器类型查询触发器工厂
- `registerString(id: string, factory: () => ITrigger)`:为触发器注册字符串别名(仅用于手动查询,不参与 `collect` 自动收集)
- `getString(id: string)`:按字符串 id 查询触发器工厂
每个 key 只对应一个工厂。重复注册同一 key 时,发出警告并以新工厂覆盖旧的。
## 3. 触发收集 ITriggerCollector
`ITriggerCollector` 属于 Layer 2`@user/data-system`),独立于 `ILayerState` 存在,不挂载到任何 Layer 1 接口上。地图在 Layer 1 中只存储可序列化的图块数据(如图块编号),不持有 `ITrigger` 实例。收集时,调用方显式传入目标图层 `layer``IMapLayer``ITriggerCollector` 读取该图层中该格点的图块数据——包括静态图块(通过 `IMapLayer.getBlock`)以及动态图块(通过 `IMapLayer.dynamicLayer`)——并结合后续由 `IMapLayer.getTileInfo` 暴露的图块信息读取其中记录的触发器类型,再通过 `ITriggerRegistry` 获取工厂并构造对应的 `ITrigger`,收集后排序,返回 `ITriggerCollection`。注册表通过 `attachRegistry(registry)` 设置。
排序规则:按触发器自身 `priority` 降序排列。这里的 `priority` 不仅表示执行顺序,也表示单次收集内的唯一执行位;正常情况下同一坐标只会有一个触发器,多触发器支持仅用于极少数拓展场景。收集阶段若发现两个或以上触发器的 `priority` 相同,则视为配置冲突,发出警告,并将本次结果中所有该优先级的触发器全部剔除,不进入 `ITriggerCollection`
## 4. 触发器集合 ITriggerCollection
`ITriggerCollection` 是一组已排序的 `ITrigger` 的载体,提供统一的触发入口 `trigger(...)` 与迭代方法 `iterate()`、数量属性 `count`。`push` / `unshift` / `concat` 均不对集合重新排序,语义为"强制指定执行位置",与 `priority` 排序体系完全分离(`ITriggerCollection` 本身与地图无关,不应自行排序)。可由:
- `ITriggerCollector.collect` 返回;
- `ITrigger.collection()` 方法直接构造(单触发器集合)。
触发执行策略:`trigger(handler)` 顺序异步执行(返回 `Promise<void>`),触发器之间串行等待;`triggerIter(handler)` 返回异步生成器,调用方可逐个推进执行并为每一步单独传入上下文,适用于需要在触发器间插入额外逻辑的进阶场景。
---
# 涉及文件
## 需要引用的文件
- `@user/data-base`(跨包引用):`@user/data-system/trigger/types.ts` 中的 `ITriggerHandler` 需要引用 `IStateBase`(全局状态)、`ILayerState`(楼层状态)、`IMapLayer`(图层)与 `ITileLocator``ITriggerCollector.collect` 同样需要引用 `IMapLayer`,并依赖其后续提供的 `getTileInfo` 读取图块信息中的触发器类型
## 需要修改的文件
### `@user/data-base` 中的 `ILayerState`
- [x] 新增 `readonly eventLayer: IMapLayer | null` 属性:表示该楼层的默认事件图层,供引擎默认收集行为使用(引擎默认收集逻辑不在本设计范围内)
- [x] 新增 `setEventLayer(layer: IMapLayer | null): void` 方法:设置默认事件图层
## 需要新建的文件
### `@user/data-system/trigger/types.ts`
- [ ] 新增 `ITriggerHandler` 接口:触发时传入的上下文对象,包含必选的 `state: IStateBase` 以及可选的 `layer?: ILayerState`、`mapLayer?: IMapLayer`、`locator?: ITileLocator`
- [ ] 新增 `ITrigger` 接口:触发器原子对象,代表一类事件逻辑;包含 `type: number` 类型标识、`priority` 优先级成员、`trigger(handler: ITriggerHandler): Promise<void>` 触发方法与 `collection()` 方法以便包装为集合
- [ ] 新增 `ITriggerRegistry` 接口:包含 `register(type, factory: (type: number) => ITrigger)` `get(type)` `registerString(id, factory: () => ITrigger)` `getString(id)` 四个方法;以触发器类型 `type` 为核心管理注册与查询,字符串 id 为触发器自身别名,不参与 `collect` 自动收集;数字注册路径下由注册表透传 `type` 给工厂,保证实例的 `ITrigger.type` 与注册项一致
- [ ] 新增 `ITriggerCollection` 接口:包含已排序的触发器集合,提供 `trigger(handler)` 顺序异步触发入口(`Promise<void>`)与 `triggerIter(handler)` 异步迭代触发入口(`AsyncGenerator<ITrigger, void, ITriggerHandler | null>`)、`iterate()` 迭代方法、`count` 数量属性、`push` / `unshift` 向集合末尾或头部插入(不重排序)的方法,以及 `concat(...others)` 按自身在前的顺序合并多个集合(不重排序)的方法
- [ ] 新增 `ITriggerCollector` 接口:包含 `collect(x: number, y: number, layer: IMapLayer)` 收集方法与 `attachRegistry(registry: ITriggerRegistry | null)` 注册表设置方法

View File

@ -31,8 +31,8 @@ export class LayerState
/** 图层钩子映射 */
private layerHookMap: Map<IMapLayer, IMapLayerHookController> = new Map();
/** 楼层是否处于激活状态 */
active: boolean = false;
eventLayer: IMapLayer | null = null;
/** 楼层级脏标记 */
private dirty: boolean = false;
@ -130,6 +130,17 @@ export class LayerState
this.active = active;
}
setEventLayer(layer: IMapLayer | null): void {
if (!layer) {
this.eventLayer = null;
} else {
if (!this.layerList.has(layer)) {
return;
}
this.eventLayer = layer;
}
}
isDirty(): boolean {
return this.dirty;
}

View File

@ -231,6 +231,8 @@ export interface ILayerState extends IHookable<ILayerStateHooks> {
readonly width: number;
/** 此楼层的地图高度 */
readonly height: number;
/** 当前楼层的默认事件层 */
readonly eventLayer: IMapLayer | null;
/**
* 使
@ -293,6 +295,12 @@ export interface ILayerState extends IHookable<ILayerStateHooks> {
*/
setActiveStatus(active: boolean): void;
/**
*
* @param layer
*/
setEventLayer(layer: IMapLayer | null): void;
/**
*
*/

View File

@ -1 +1,2 @@
export * from './combat';
export * from './trigger';

View File

@ -0,0 +1,57 @@
import { ITrigger, ITriggerCollection, ITriggerHandler } from './types';
export class TriggerCollection implements ITriggerCollection {
/** 当前集合内部维护的触发器列表 */
private readonly triggerList: ITrigger[];
constructor(triggers: Iterable<ITrigger>) {
this.triggerList = Array.from(triggers);
}
count(): number {
return this.triggerList.length;
}
async trigger<TEnemy = unknown, THero = unknown>(
handler: ITriggerHandler<TEnemy, THero>
): Promise<void> {
for (const trigger of this.triggerList) {
await trigger.trigger(handler);
}
}
async *triggerIter<TEnemy = unknown, THero = unknown>(
handler: ITriggerHandler<TEnemy, THero>
): AsyncGenerator<ITrigger, void, ITriggerHandler<TEnemy, THero> | null> {
let currentHandler = handler;
for (const trigger of this.triggerList) {
await trigger.trigger(currentHandler);
const nextHandler = yield trigger;
if (nextHandler) {
currentHandler = nextHandler;
} else {
currentHandler = handler;
}
}
}
iterate(): Iterable<ITrigger> {
return this.triggerList.values();
}
push(trigger: ITrigger): void {
this.triggerList.push(trigger);
}
unshift(trigger: ITrigger): void {
this.triggerList.unshift(trigger);
}
concat(...others: ITriggerCollection[]): ITriggerCollection {
const merged = [...this.triggerList];
for (const other of others) {
merged.push(...other.iterate());
}
return new TriggerCollection(merged);
}
}

View File

@ -0,0 +1,3 @@
export * from './types';
export * from './collection';
export * from './registry';

View File

@ -0,0 +1,49 @@
import { logger } from '@motajs/common';
import {
ITrigger,
ITriggerRegistry,
TriggerFactory,
TriggerStringFactory
} from './types';
export class TriggerRegistry implements ITriggerRegistry {
/** 数字类型到触发器工厂的映射 */
private readonly typeMap: Map<number, TriggerFactory> = new Map();
/** 字符串 id 到触发器工厂的映射 */
private readonly stringMap: Map<string, TriggerStringFactory> = new Map();
register(type: number, factory: TriggerFactory): void {
if (this.typeMap.has(type)) {
logger.warn(132, 'type', type.toString());
}
this.typeMap.set(type, factory);
}
get(type: number): TriggerFactory | null {
return this.typeMap.get(type) ?? null;
}
create(num: number): ITrigger | null {
const factory = this.get(num);
if (!factory) return null;
return factory(num);
}
registerString(id: string, factory: TriggerStringFactory): void {
if (this.stringMap.has(id)) {
logger.warn(132, 'id', id);
}
this.stringMap.set(id, factory);
}
getString(id: string): TriggerStringFactory | null {
return this.stringMap.get(id) ?? null;
}
createByString(id: string): ITrigger | null {
const factory = this.getString(id);
if (!factory) return null;
return factory();
}
}

View File

@ -0,0 +1,139 @@
import { ITileLocator } from '@motajs/common';
import { ILayerState, IMapLayer, IStateBase } from '@user/data-base';
export interface ITriggerHandler<TEnemy = unknown, THero = unknown> {
/** 当前全局状态对象 */
readonly state: IStateBase<TEnemy, THero>;
/** 当前楼层状态对象 */
readonly layer?: ILayerState;
/** 当前参与触发的图层对象 */
readonly mapLayer?: IMapLayer;
/** 当前触发点定位符 */
readonly locator?: ITileLocator;
}
export type TriggerFactory = (type: number) => ITrigger;
export type TriggerStringFactory = () => ITrigger;
export interface ITrigger {
/** 触发器类型标识 */
readonly type: number;
/** 触发器优先级 */
readonly priority: number;
/**
* 使
* @param handler
*/
trigger<TEnemy = unknown, THero = unknown>(
handler: ITriggerHandler<TEnemy, THero>
): Promise<void>;
/**
*
*/
collection(): ITriggerCollection;
}
export interface ITriggerRegistry {
/**
*
* @param type
* @param factory
*/
register(type: number, factory: TriggerFactory): void;
/**
*
* @param type
*/
get(type: number): TriggerFactory | null;
/**
* `null`
* @param num
*/
create(num: number): ITrigger | null;
/**
* id
* @param id id
* @param factory
*/
registerString(id: string, factory: TriggerStringFactory): void;
/**
* id
* @param id id
*/
getString(id: string): TriggerStringFactory | null;
/**
* id `null`
* @param id id
*/
createByString(id: string): ITrigger | null;
}
export interface ITriggerCollection {
/**
*
*/
count(): number;
/**
*
* @param handler
*/
trigger<TEnemy = unknown, THero = unknown>(
handler: ITriggerHandler<TEnemy, THero>
): Promise<void>;
/**
*
* @param handler
*/
triggerIter<TEnemy = unknown, THero = unknown>(
handler: ITriggerHandler<TEnemy, THero>
): AsyncGenerator<ITrigger, void, ITriggerHandler<TEnemy, THero> | null>;
/**
*
*/
iterate(): Iterable<ITrigger>;
/**
*
* @param trigger
*/
push(trigger: ITrigger): void;
/**
*
* @param trigger
*/
unshift(trigger: ITrigger): void;
/**
*
* @param others
*/
concat(...others: ITriggerCollection[]): ITriggerCollection;
}
export interface ITriggerCollector {
/**
*
* @param x
* @param y
* @param layer
*/
collect(x: number, y: number, layer: IMapLayer): ITriggerCollection;
/**
* collector 使
* @param registry
*/
attachRegistry(registry: ITriggerRegistry | null): void;
}

View File

@ -188,6 +188,8 @@
"128": "Position $1,$2 is out of map bounds, operation cancelled.",
"129": "Position $1,$2 is not empty, existing block will be overwritten.",
"130": "The given tile is not managed by this dynamic layer.",
"131": "Event layer to set is not belong to current LayerState.",
"132": "Trigger registry entry of $1 '$2' already exists, new factory will override old factory.",
"1001": "Item-detail extension needs 'floor-binder' and 'floor-damage' extension as dependency."
}
}

View File

@ -21,6 +21,7 @@
3. **发现接口问题时提问**:若认为类型标注中的接口设计有问题,或在实现中发现缺少某些接口,应向我提问是否添加,经我同意后方可添加。
4. **接口设计兼顾合理性与便捷性**:设计接口时不仅要考虑合理性,还要考虑使用便捷性。罕见场景应当被支持,但不应与常见场景共用同一接口——这只会增加常见场景的使用难度。
5. **避免多余的非空判断与类型守卫**:若某个类型已满足目标的类型约束,不应再对其添加任何判断或过滤操作。典型例子:`Promise.all` 对数组元素类型没有任何限制,传入 `(Promise<unknown> | void)[]` 完全合法,无需多此一举地写成 `Promise.all(arr.filter(v => !!v))`
6. **关于整个仓库的类型检查**:一般情况下**不需要**跑全仓库的类型检查,每个需求都是独立的,不会影响外部内容。只有在进行重构的时候才有此需求。
**时刻谨记上述要求,避免一个需求反复修改仍无法满足预期。**