fix(01-07): dispatch events with source-aware trigger matching

- Carry each event id with its real point or tile environment.
- Filter mismatched triggers before execution and include every dynamic tile in priority order.
This commit is contained in:
unanmed 2026-09-09 00:18:00 +08:00
parent 87f2252ccc
commit 22056140bd
5 changed files with 729 additions and 41 deletions

View File

@ -54,6 +54,13 @@ export interface IBlockEventEnv extends IDataCommonExtended {
readonly map: IGameMap | null;
}
export interface IGameEventInvocation {
/** 事件在 `IGameEventStore` 中的 id */
readonly id: string;
/** 此次调用对应的真实来源环境 */
readonly env: IBlockEventEnv;
}
export interface IReadonlyBlockEvent<R = void> extends IReadonlyGameEvent<
IBlockEventParam,
IBlockEventEnv,

View File

@ -1,26 +1,34 @@
import { ITileLocator } from '@motajs/common';
import {
BlockEventType,
IBlockEventEnv,
IBlockEventParam,
IGameEventInvocation,
IHeroMoveTopHandler,
IHeroMoveTopImpl,
IMapState
IMapState,
IReadonlyTileBase
} from '@user/data-base';
import { FaceDirection, PassBit } from '@user/data-common';
import {
IStateSystem,
ITriggerCollector,
ITriggerHandler,
TriggerType
} from '@user/data-system';
import { EventTrigger, FaceDirection, PassBit } from '@user/data-common';
import { IGameEventExecutor, IStateSystem } from '@user/data-system';
import { isNil } from 'lodash-es';
interface IEventSource {
readonly priority: number;
readonly id: string;
readonly type: BlockEventType;
readonly tile: IReadonlyTileBase | null;
}
export class DefaultHeroMoveTopImpl implements IHeroMoveTopImpl {
/** 地图存储对象 */
private readonly maps: IMapState;
/** 触发器收集器对象 */
private readonly collector: ITriggerCollector;
/** 游戏事件执行器 */
private readonly executor: IGameEventExecutor;
constructor(private readonly state: IStateSystem) {
constructor(state: IStateSystem) {
this.maps = state.maps;
this.collector = state.triggerCollector;
this.executor = state.eventSystem.executor;
}
//#region 通行性判断
@ -84,11 +92,13 @@ export class DefaultHeroMoveTopImpl implements IHeroMoveTopImpl {
// 判断事件层
const curr = event.getLocationData(x, y);
const next = event.getLocationData(nx, ny);
if (curr && curr.raw) {
canLeave = !!(leaveMask & curr.raw.pass.outPass);
const currRaw = curr?.static.raw();
const nextRaw = next?.static.raw();
if (currRaw) {
canLeave = !!(leaveMask & currRaw.pass.outPass);
}
if (next && next.raw) {
canEnter = !!(enterMask & next.raw.pass.inPass);
if (nextRaw) {
canEnter = !!(enterMask & nextRaw.pass.inPass);
}
if (!canLeave || !canEnter) return false;
@ -100,11 +110,13 @@ export class DefaultHeroMoveTopImpl implements IHeroMoveTopImpl {
const next = layer.getLocationData(nx, ny);
let canLeave = true;
let canEnter = true;
if (curr && curr.raw && curr.raw.pass.onlyEvents) {
canLeave = !!(leaveMask & curr.raw.pass.outPass);
const currRaw = curr?.static.raw();
const nextRaw = next?.static.raw();
if (currRaw?.pass.onlyEvents) {
canLeave = !!(leaveMask & currRaw.pass.outPass);
}
if (next && next.raw && next.raw.pass.onlyEvents) {
canEnter = !!(enterMask & next.raw.pass.inPass);
if (nextRaw?.pass.onlyEvents) {
canEnter = !!(enterMask & nextRaw.pass.inPass);
}
if (!canLeave || !canEnter) return false;
}
@ -123,24 +135,27 @@ export class DefaultHeroMoveTopImpl implements IHeroMoveTopImpl {
const { x: nx, y: ny } = nextLoc;
const next = eventLayer.getLocationData(nx, ny);
if (!next || !next.raw) return false;
return !next.raw.eventPass;
const nextRaw = next?.static.raw();
if (!nextRaw) return false;
return !nextRaw.eventPass;
}
//#endregion
//#region 触发行为
//#region 事件触发行为
/**
*
* @param type
*
* @param trigger
* @param handler
* @param heroLoc
* @param x
* @param y
*/
private commonTrigger(
type: TriggerType,
private async commonTrigger(
trigger: EventTrigger,
handler: IHeroMoveTopHandler,
heroLoc: ITileLocator,
x: number,
y: number
): Promise<void> {
@ -151,36 +166,100 @@ export class DefaultHeroMoveTopImpl implements IHeroMoveTopImpl {
const event = map.eventLayer;
if (!event) return Promise.resolve();
const triggers = this.collector.collect(x, y, event);
const point = event.getPointEvent(x, y);
const loc = event.getLocationData(x, y);
const pointSources: IEventSource[] = [];
const tileSources: IEventSource[] = [];
if (point) {
for (const [priority, id] of point) {
pointSources.push({
priority,
id,
type: BlockEventType.PointEvent,
tile: null
});
}
}
if (loc) {
for (const [priority, id] of loc.static.tileEvent().get()) {
tileSources.push({
priority,
id,
type: BlockEventType.TileEvent,
tile: loc.static
});
}
for (const tile of loc.dynamics) {
for (const [priority, id] of tile.tileEvent().get()) {
tileSources.push({
priority,
id,
type: BlockEventType.TileEvent,
tile
});
}
}
}
pointSources.sort((a, b) => b.priority - a.priority);
tileSources.sort((a, b) => b.priority - a.priority);
const triggerHandler: ITriggerHandler = {
state: this.state,
layer: map,
mapLayer: event,
locator: { x, y }
};
const param: IBlockEventParam = { custom: {} };
const invocations: IGameEventInvocation[] = [];
for (const source of [...pointSources, ...tileSources]) {
const env: IBlockEventEnv = {
state: handler.state,
type: source.type,
trigger,
heroLocator: heroLoc,
triggerLocator: { x, y },
tile: source.tile,
layer: event,
map
};
invocations.push({ id: source.id, env });
}
return triggers.trigger(type, triggerHandler);
await this.executor.execute<void>(invocations, param);
}
async enter(handler: IHeroMoveTopHandler): Promise<void> {
const { x, y } = handler.nextLoc;
return this.commonTrigger(TriggerType.Enter, handler, x, y);
return this.commonTrigger(
EventTrigger.OnEnter,
handler,
handler.nextLoc,
x,
y
);
}
async leave(handler: IHeroMoveTopHandler): Promise<void> {
const { x, y } = handler.currLoc;
return this.commonTrigger(TriggerType.Leave, handler, x, y);
return this.commonTrigger(
EventTrigger.OnLeave,
handler,
handler.currLoc,
x,
y
);
}
async hit(handler: IHeroMoveTopHandler): Promise<void> {
const { x, y } = handler.nextLoc;
return this.commonTrigger(TriggerType.Hit, handler, x, y);
return this.commonTrigger(
EventTrigger.OnTouch,
handler,
handler.currLoc,
x,
y
);
}
async cannotEnter(handler: IHeroMoveTopHandler): Promise<void> {
const { x, y } = handler.nextLoc;
return this.commonTrigger(TriggerType.CannotEnter, handler, x, y);
/**
*
*/
async cannotEnter(): Promise<void> {
return Promise.resolve();
}
//#endregion

View File

@ -0,0 +1,436 @@
import { beforeAll, describe, expect, it, vi } from 'vitest';
interface TestModules {
EventExecutor: typeof import('./executor').EventExecutor;
DefaultHeroMoveTopImpl: typeof import('@user/data-state/hero/moverImpl').DefaultHeroMoveTopImpl;
MapState: typeof import('@user/data-base').MapState;
TileStore: typeof import('@user/data-common').TileStore;
RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder;
FaceManager: typeof import('@user/data-common').FaceManager;
Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler;
EventTrigger: typeof import('@user/data-common').EventTrigger;
BlockEventType: typeof import('@user/data-base').BlockEventType;
EventExecuteMode: typeof import('./types').EventExecuteMode;
EventReduceMode: typeof import('./types').EventReduceMode;
}
interface EventCall {
readonly id: string;
readonly trigger: number;
readonly type: number;
readonly tile: object | null;
readonly hero: Readonly<{ x: number; y: number }>;
readonly triggerLocator: Readonly<{ x: number; y: number }> | null;
}
let modules: TestModules;
beforeAll(async () => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
const executorModule = await import('./executor');
const moverModule = await import('@user/data-state/hero/moverImpl');
const baseModule = await import('@user/data-base');
const commonModule = await import('@user/data-common');
const eventModule = await import('./types');
modules = {
EventExecutor: executorModule.EventExecutor,
DefaultHeroMoveTopImpl: moverModule.DefaultHeroMoveTopImpl,
MapState: baseModule.MapState,
TileStore: commonModule.TileStore,
RoleFaceBinder: commonModule.RoleFaceBinder,
FaceManager: commonModule.FaceManager,
Dir8FaceHandler: commonModule.Dir8FaceHandler,
EventTrigger: commonModule.EventTrigger,
BlockEventType: baseModule.BlockEventType,
EventExecuteMode: eventModule.EventExecuteMode,
EventReduceMode: eventModule.EventReduceMode
};
});
function createFixture(
pointEvents: Record<number, Record<number, string>> = {},
staticEvent: string = 'static-enter',
dynamicEvent: string = 'dynamic-enter'
) {
const tileStore = new modules.TileStore();
tileStore.addTile({
num: 1,
id: 'static',
events: { 30: staticEvent },
type: 0,
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
eventPass: true
});
tileStore.addTile({
num: 2,
id: 'dynamic',
events: { 40: dynamicEvent },
type: 0,
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
eventPass: true
});
const faceManager = new modules.FaceManager();
faceManager.register(1, new modules.Dir8FaceHandler());
const commonState = {
tileStore,
itemStore: {},
mapStore: {},
eventStore: {},
roleFace: new modules.RoleFaceBinder(),
faceManager,
saveSystem: {}
};
const maps = new modules.MapState(tileStore, commonState);
const map = maps.fromRaw({
floorId: 'F1',
width: 3,
map: { 0: [1, 1, 1] },
layerAlias: { 0: 'event' },
events: { 0: pointEvents }
});
const layer = map!.getLayerByAlias('event')!;
const dynamic = layer.createDynamic(2, 1, 0);
dynamic.set(2);
const events = new Map<string, object>();
const store = {
getEvent(id: string) {
return events.get(id) ?? null;
}
};
const executor = new modules.EventExecutor({}, () => store);
const state = {
maps,
eventSystem: { executor }
};
const mover = new modules.DefaultHeroMoveTopImpl(state);
return { events, executor, layer, dynamic, mover, state };
}
function addEvent(
events: Map<string, object>,
id: string,
trigger: number,
result: unknown,
calls: EventCall[]
): void {
events.set(id, {
trigger,
execute: async (
_param: unknown,
env: {
trigger: number;
type: number;
tile: object | null;
heroLocator: Readonly<{ x: number; y: number }>;
triggerLocator: Readonly<{ x: number; y: number }> | null;
}
) => {
calls.push({
id,
trigger: env.trigger,
type: env.type,
tile: env.tile,
hero: env.heroLocator,
triggerLocator: env.triggerLocator
});
return result;
}
});
}
function invocation(id: string, trigger: number) {
return { id, env: { trigger } };
}
describe('source-aware matching dispatch', () => {
it('source-aware matching dispatch', async () => {
const calls: EventCall[] = [];
const fixture = createFixture({
1: { 50: 'point-enter', 60: 'leave-only' }
});
addEvent(
fixture.events,
'point-enter',
modules.EventTrigger.OnEnter,
true,
calls
);
addEvent(
fixture.events,
'leave-only',
modules.EventTrigger.OnLeave,
true,
calls
);
addEvent(
fixture.events,
'static-enter',
modules.EventTrigger.OnEnter,
true,
calls
);
addEvent(
fixture.events,
'dynamic-enter',
modules.EventTrigger.OnEnter,
true,
calls
);
await fixture.mover.enter({
state: fixture.state,
currLoc: { x: 0, y: 0 },
nextLoc: { x: 1, y: 0 },
direction: 0,
floorId: 'F1',
face: new modules.Dir8FaceHandler()
});
expect(calls.map(call => call.id)).toEqual([
'point-enter',
'dynamic-enter',
'static-enter'
]);
expect(calls.map(call => call.type)).toEqual([
modules.BlockEventType.PointEvent,
modules.BlockEventType.TileEvent,
modules.BlockEventType.TileEvent
]);
expect(calls[0].tile).toBeNull();
expect(calls[1].tile).toBe(fixture.dynamic);
expect(calls[2].tile).toBe(fixture.layer.getLocationData(1, 0)!.static);
});
it('awaits each source before continuing to the next one', async () => {
const calls: EventCall[] = [];
let release: () => void = () => {};
const pending = new Promise<void>(resolve => {
release = resolve;
});
const fixture = createFixture({ 1: { 50: 'slow-point' } });
fixture.events.set('slow-point', {
trigger: modules.EventTrigger.OnEnter,
execute: async (
_param: unknown,
env: {
trigger: number;
type: number;
tile: object | null;
heroLocator: Readonly<{ x: number; y: number }>;
triggerLocator: Readonly<{ x: number; y: number }> | null;
}
) => {
calls.push({
id: 'slow-point',
trigger: env.trigger,
type: env.type,
tile: env.tile,
hero: env.heroLocator,
triggerLocator: env.triggerLocator
});
await pending;
return true;
}
});
addEvent(
fixture.events,
'static-enter',
modules.EventTrigger.OnEnter,
true,
calls
);
addEvent(
fixture.events,
'dynamic-enter',
modules.EventTrigger.OnEnter,
true,
calls
);
const running = fixture.mover.enter({
state: fixture.state,
currLoc: { x: 0, y: 0 },
nextLoc: { x: 1, y: 0 },
direction: 0,
floorId: 'F1',
face: new modules.Dir8FaceHandler()
});
await Promise.resolve();
expect(calls.map(call => call.id)).toEqual(['slow-point']);
release();
await running;
expect(calls.map(call => call.id)).toEqual([
'slow-point',
'dynamic-enter',
'static-enter'
]);
});
});
describe('event execute modes and reductions', () => {
it('applies cut and reduce only to matching events', async () => {
const calls: string[] = [];
const fixture = createFixture();
fixture.events.set('wrong-trigger', {
trigger: modules.EventTrigger.OnLeave,
execute: async () => {
calls.push('wrong-trigger');
return true;
}
});
fixture.events.set('false', {
trigger: modules.EventTrigger.OnEnter,
execute: async () => {
calls.push('false');
return false;
}
});
fixture.events.set('true', {
trigger: modules.EventTrigger.OnEnter,
execute: async () => {
calls.push('true');
return true;
}
});
const enter = modules.EventTrigger.OnEnter;
fixture.executor.setMode(modules.EventExecuteMode.Normal);
fixture.executor.setReduce(modules.EventReduceMode.NoReduce);
await expect(
fixture.executor.execute(
[
invocation('wrong-trigger', enter),
invocation('false', enter)
],
{ custom: {} }
)
).resolves.toEqual([false]);
expect(calls).toEqual(['false']);
calls.length = 0;
fixture.executor.setMode(modules.EventExecuteMode.CutIfFalsy);
await fixture.executor.execute(
[
invocation('wrong-trigger', enter),
invocation('false', enter),
invocation('true', enter)
],
{ custom: {} }
);
expect(calls).toEqual(['false']);
calls.length = 0;
fixture.executor.setMode(modules.EventExecuteMode.CutIfTruthy);
await fixture.executor.execute(
[
invocation('wrong-trigger', enter),
invocation('true', enter),
invocation('false', enter)
],
{ custom: {} }
);
expect(calls).toEqual(['true']);
fixture.executor.setMode(modules.EventExecuteMode.Normal);
fixture.executor.setReduce(modules.EventReduceMode.OrReduce);
await expect(
fixture.executor.execute(
[invocation('false', enter), invocation('true', enter)],
{ custom: {} }
)
).resolves.toBe(true);
fixture.executor.setReduce(modules.EventReduceMode.AndReduce);
await expect(
fixture.executor.execute(
[invocation('true', enter), invocation('false', enter)],
{ custom: {} }
)
).resolves.toBe(false);
});
it('warns for unknown ids and continues with valid events', async () => {
const calls: string[] = [];
const fixture = createFixture();
fixture.events.set('valid', {
trigger: modules.EventTrigger.OnEnter,
execute: async () => {
calls.push('valid');
return true;
}
});
fixture.executor.setReduce(modules.EventReduceMode.NoReduce);
await expect(
fixture.executor.execute(
[
invocation('missing', modules.EventTrigger.OnEnter),
invocation('valid', modules.EventTrigger.OnEnter)
],
{ custom: {} }
)
).resolves.toEqual([true]);
expect(calls).toEqual(['valid']);
});
});
describe('enter leave hit trigger hooks', () => {
it('maps enter leave and hit to their approved triggers and coordinates', async () => {
const calls: EventCall[] = [];
const fixture = createFixture(
{
0: { 10: 'leave-event' },
1: { 10: 'enter-event' }
},
'touch-event',
'unused'
);
addEvent(
fixture.events,
'leave-event',
modules.EventTrigger.OnLeave,
true,
calls
);
addEvent(
fixture.events,
'enter-event',
modules.EventTrigger.OnEnter,
true,
calls
);
addEvent(
fixture.events,
'touch-event',
modules.EventTrigger.OnTouch,
true,
calls
);
const handler = {
state: fixture.state,
currLoc: { x: 0, y: 0 },
nextLoc: { x: 1, y: 0 },
direction: 0,
floorId: 'F1',
face: new modules.Dir8FaceHandler()
};
await fixture.mover.enter(handler);
await fixture.mover.leave(handler);
await fixture.mover.hit(handler);
expect(calls.map(call => call.id)).toEqual([
'enter-event',
'leave-event',
'touch-event'
]);
expect(calls.map(call => call.trigger)).toEqual([
modules.EventTrigger.OnEnter,
modules.EventTrigger.OnLeave,
modules.EventTrigger.OnTouch
]);
expect(calls[0].hero).toEqual({ x: 1, y: 0 });
expect(calls[1].hero).toEqual({ x: 0, y: 0 });
expect(calls[2].hero).toEqual({ x: 0, y: 0 });
expect(calls[0].triggerLocator).toEqual({ x: 1, y: 0 });
expect(calls[1].triggerLocator).toEqual({ x: 0, y: 0 });
expect(calls[2].triggerLocator).toEqual({ x: 1, y: 0 });
});
});

View File

@ -0,0 +1,76 @@
import { logger } from '@motajs/common';
import { IBlockEventParam, IGameEventInvocation } from '@user/data-base';
import { IGameEventStore } from '@user/data-common';
import { AnonTokyoInterpreter } from 'anon-tokyo';
import { EventExecuteMode, EventReduceMode, IGameEventExecutor } from './types';
export class EventExecutor implements IGameEventExecutor {
mode: EventExecuteMode = EventExecuteMode.Normal;
reduce: EventReduceMode = EventReduceMode.NoReduce;
constructor(
readonly interpreter: AnonTokyoInterpreter,
private readonly storeRef: () => IGameEventStore | null
) {}
setMode(mode: EventExecuteMode): void {
this.mode = mode;
}
setReduce(reduce: EventReduceMode): void {
this.reduce = reduce;
}
async execute<R = void>(
events: IGameEventInvocation[],
param: IBlockEventParam
): Promise<R> {
const results: any[] = [];
for (const invocation of events) {
const id = invocation.id;
const store = this.storeRef();
if (!store) {
logger.warn(171, id);
continue;
}
const event = store.getEvent<IBlockEventParam, IBlockEventEnv, any>(
id
);
if (!event) {
logger.warn(171, id);
continue;
}
if (event.trigger !== invocation.env.trigger) continue;
const result = await event.execute(param, invocation.env);
if (
this.reduce !== EventReduceMode.NoReduce &&
typeof result !== 'boolean'
) {
logger.warn(172, String(result));
}
results.push(result);
if (this.mode === EventExecuteMode.CutIfFalsy && !result) {
break;
} else if (this.mode === EventExecuteMode.CutIfTruthy && result) {
break;
}
}
let reduced: any = results;
if (this.reduce === EventReduceMode.OrReduce) {
reduced = false;
for (const result of results) {
reduced ||= result;
if (reduced) break;
}
} else if (this.reduce === EventReduceMode.AndReduce) {
reduced = true;
for (const result of results) {
reduced &&= result;
if (!reduced) break;
}
}
return reduced;
}
}

View File

@ -0,0 +1,90 @@
import {
IBlockEventEnv,
IBlockEventParam,
IDataBaseExtended,
IGameEventInvocation
} from '@user/data-base';
import { IGameEventStore } from '@user/data-common';
import { AnonTokyoInterpreter } from 'anon-tokyo';
export const enum EventExecuteMode {
/** 正常顺序执行,执行完前一个后执行后一个 */
Normal,
/** 若事件执行过程中有任意事件返回了 `falsy` 值,立刻结束事件的运行 */
CutIfFalsy,
/** 若事件执行过程中有任意事件返回了 `truthy` 值,立刻结束事件的运行 */
CutIfTruthy
}
export const enum EventReduceMode {
/** 不对事件的返回值做折叠处理,按照执行顺序将事件返回值组成一个列表返回 */
NoReduce,
/**
*
*
*/
OrReduce,
/**
*
*
*/
AndReduce
}
export type GameEventBuiltinFunction = (
param: IBlockEventParam,
env: IBlockEventEnv
) => any;
export interface IGameEventInit {
/**
*
* @param name
* @param func
*/
addBuiltinFunction(name: string, func: GameEventBuiltinFunction): void;
}
export interface IGameEventExecutor {
/** 当前的执行器执行模式 */
readonly mode: EventExecuteMode;
/** 当前的执行器返回值折叠方式 */
readonly reduce: EventReduceMode;
/** 事件解释器 */
readonly interpreter: AnonTokyoInterpreter;
/**
*
* @param mode
*/
setMode(mode: EventExecuteMode): void;
/**
*
* @param reduce
*/
setReduce(reduce: EventReduceMode): void;
/**
*
* @param events
* @param param
*/
execute<R = void>(
events: IGameEventInvocation[],
param: IBlockEventParam
): Promise<R>;
}
export interface IGameEventSystem extends IDataBaseExtended {
/** 游戏事件执行器 */
readonly executor: IGameEventExecutor;
/** 事件系统使用的存储器 */
readonly store: IGameEventStore | null;
/**
* 使
* @param store
*/
useStore(store: IGameEventStore | null): void;
}