GameEvent
GameEvent 描述"已经发生了什么"。与 Action("我要做什么")相对,Event 由 ActionHandler 在执行成功后产出,经 EventBus 广播给所有 EventHandler。引擎自动填充 id、timestamp 与 stateVersion,业务代码只需提供 type、payload 与可选 playerId。
概述
设计要点:
- Event 描述"已经发生了什么",由 ActionHandler 产出
- 引擎自动填充
id/timestamp/stateVersion - 通过
EventInit({ type, payload, playerId? })描述待发事件,eventFromInit补全元数据 - 内置 11 个基础事件类型常量,覆盖对局生命周期、回合、卡牌移动与玩家进出
GameEvent 接口
ts
export interface GameEvent<TType extends string = string, TPayload = unknown> {
id: string
type: TType
payload: TPayload
timestamp: number
stateVersion: number
playerId?: string
}| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | 是 | 事件唯一标识,由引擎自动生成 |
type | TType | 是 | 事件类型,通常引用本文件导出的常量以避免拼写错误 |
payload | TPayload | 是 | 事件负载,结构由事件类型决定 |
timestamp | number | 是 | 事件产生时间戳(毫秒),由引擎自动填充 |
stateVersion | number | 是 | 产出该事件时的 GameState 版本号,用于回放与一致性校验 |
playerId | string | 否 | 触发该事件的玩家 id,用于事件归因(系统事件可省略) |
泛型参数
| 参数 | 默认值 | 约束 | 说明 |
|---|---|---|---|
TType | string | extends string | 事件类型 |
TPayload | unknown | — | 负载类型 |
基础事件类型常量
引擎内置的基础事件类型常量:
ts
export const GAME_CREATED = 'GAME_CREATED' as const
export const GAME_STARTED = 'GAME_STARTED' as const
export const TURN_STARTED = 'TURN_STARTED' as const
export const TURN_ENDED = 'TURN_ENDED' as const
export const CARD_DRAWN = 'CARD_DRAWN' as const
export const CARD_PLAYED = 'CARD_PLAYED' as const
export const CARD_MOVED = 'CARD_MOVED' as const
export const PLAYER_JOINED = 'PLAYER_JOINED' as const
export const PLAYER_LEFT = 'PLAYER_LEFT' as const
export const GAME_WON = 'GAME_WON' as const
export const GAME_FINISHED = 'GAME_FINISHED' as const| 常量 | 值 | 说明 |
|---|---|---|
GAME_CREATED | 'GAME_CREATED' | 对局已创建(由 createGame 派发) |
GAME_STARTED | 'GAME_STARTED' | 对局已开始(由 start 派发) |
TURN_STARTED | 'TURN_STARTED' | 回合开始 |
TURN_ENDED | 'TURN_ENDED' | 回合结束 |
CARD_DRAWN | 'CARD_DRAWN' | 抽牌 |
CARD_PLAYED | 'CARD_PLAYED' | 出牌 |
CARD_MOVED | 'CARD_MOVED' | 卡牌移动 |
PLAYER_JOINED | 'PLAYER_JOINED' | 玩家加入 |
PLAYER_LEFT | 'PLAYER_LEFT' | 玩家离开 |
GAME_WON | 'GAME_WON' | 胜负已判定:先于 GAME_FINISHED 派发,此时整局可能仍未彻底结束 |
GAME_FINISHED | 'GAME_FINISHED' | 整局彻底结束:不再有后续回合,通常在 GAME_WON 之后派发 |
BasicEventType 类型
ts
export type BasicEventType =
| typeof GAME_CREATED
| typeof GAME_STARTED
| typeof TURN_STARTED
| typeof TURN_ENDED
| typeof CARD_DRAWN
| typeof CARD_PLAYED
| typeof CARD_MOVED
| typeof PLAYER_JOINED
| typeof PLAYER_LEFT
| typeof GAME_WON
| typeof GAME_FINISHED所有内置基础事件类型的联合,便于对事件类型做收窄与穷举校验。
Payload 类型
GameCreatedPayload
ts
export interface GameCreatedPayload {
gameId: string
}| 字段 | 类型 | 说明 |
|---|---|---|
gameId | string | 创建出的对局 id |
GameStartedPayload
ts
export interface GameStartedPayload {
gameId: string
}| 字段 | 类型 | 说明 |
|---|---|---|
gameId | string | 开始的对局 id |
TurnStartedPayload
ts
export interface TurnStartedPayload {
playerId: string
turn: number
}| 字段 | 类型 | 说明 |
|---|---|---|
playerId | string | 进入回合的玩家 id |
turn | number | 回合序号(从 1 开始递增) |
TurnEndedPayload
ts
export interface TurnEndedPayload {
playerId: string
turn: number
}| 字段 | 类型 | 说明 |
|---|---|---|
playerId | string | 结束回合的玩家 id |
turn | number | 结束的回合序号 |
CardDrawnPayload
ts
export interface CardDrawnPayload {
playerId: string
cardIds: string[]
count: number
}| 字段 | 类型 | 说明 |
|---|---|---|
playerId | string | 抽牌玩家 id |
cardIds | string[] | 抽到的卡牌 id 列表 |
count | number | 抽牌数量(等于 cardIds.length,冗余便于消费方快速读取) |
CardPlayedPayload
ts
export interface CardPlayedPayload {
playerId: string
cardIds: string[]
}| 字段 | 类型 | 说明 |
|---|---|---|
playerId | string | 出牌玩家 id |
cardIds | string[] | 打出的卡牌 id 列表 |
CardMovedPayload
ts
export interface CardMovedPayload {
cardId: string
fromZoneId: string
toZoneId: string
playerId?: string
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
cardId | string | 是 | 被移动的卡牌 id |
fromZoneId | string | 是 | 源区域 id |
toZoneId | string | 是 | 目标区域 id |
playerId | string | 否 | 卡牌归属玩家 id(区域与玩家绑定时填写) |
PlayerJoinedPayload
ts
export interface PlayerJoinedPayload {
playerId: string
seat: number
}| 字段 | 类型 | 说明 |
|---|---|---|
playerId | string | 加入的玩家 id |
seat | number | 分配的座位号 |
PlayerLeftPayload
ts
export interface PlayerLeftPayload {
playerId: string
}| 字段 | 类型 | 说明 |
|---|---|---|
playerId | string | 离开的玩家 id |
GameWonPayload
ts
export interface GameWonPayload {
winnerIds: string[]
}| 字段 | 类型 | 说明 |
|---|---|---|
winnerIds | string[] | 胜利者 id 列表(支持多赢家) |
GameFinishedPayload
ts
export interface GameFinishedPayload {
winnerIds: string[]
}| 字段 | 类型 | 说明 |
|---|---|---|
winnerIds | string[] | 最终赢家 id 列表 |
GameEventInit 接口
ts
export interface GameEventInit<TType extends string = string, TPayload = unknown> {
type: TType
payload: TPayload
playerId?: string
stateVersion: number
id?: string
timestamp?: number
}创建 GameEvent 所需的初始化数据。
业务侧只需提供 type、payload、stateVersion 与可选 playerId;id 与 timestamp 可省略,由工厂函数自动填充。
createGameEvent()
根据初始化数据构造一个完整的 GameEvent。
ts
export function createGameEvent<TType extends string, TPayload = unknown>(
init: GameEventInit<TType, TPayload>
): GameEvent<TType, TPayload>参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
init.type | TType | 是 | — | 事件类型 |
init.payload | TPayload | 是 | — | 事件负载 |
init.stateVersion | number | 是 | — | 当前的 state version |
init.playerId | string | 否 | undefined | 触发玩家 |
init.id | string | 否 | generateId('event') | 事件 id |
init.timestamp | number | 否 | Date.now() | 时间戳 |
返回值
GameEvent<TType, TPayload> —— 填充完整元数据后的 GameEvent。
行为
- 缺省时自动生成
id(event-前缀)与timestamp(当前时间) - 显式传入则覆盖原值,便于回放时复现原事件
- 仅当传入
playerId时才挂载该字段
ts
function createGameEvent<TType extends string, TPayload = unknown>(
init: GameEventInit<TType, TPayload>
): GameEvent<TType, TPayload> {
const event: GameEvent<TType, TPayload> = {
id: init.id ?? generateId('event'),
type: init.type,
payload: init.payload,
timestamp: init.timestamp ?? Date.now(),
stateVersion: init.stateVersion
}
if (init.playerId !== undefined) {
event.playerId = init.playerId
}
return event
}示例
ts
import { createGameEvent, GAME_WON } from 'decklet'
const event = createGameEvent({
type: GAME_WON,
payload: { winnerIds: ['p1'] },
stateVersion: 42,
playerId: 'p1'
})
console.log(event.id) // 'event-xxxxxxxx-xxxxxxxx-N'
console.log(event.type) // 'GAME_WON'
console.log(event.payload) // { winnerIds: ['p1'] }
console.log(event.stateVersion) // 42
console.log(event.playerId) // 'p1'eventFromInit()
从 ActionHandler 产出的 EventInit 构造 GameEvent。
ts
export function eventFromInit(init: EventInit, stateVersion: number): GameEvent参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
init.type | string | 是 | 事件类型 |
init.payload | unknown | 是 | 事件负载 |
init.playerId | string | 否 | 触发玩家 |
stateVersion | number | 是 | 当前 GameState 版本号 |
返回值
GameEvent —— 带完整元数据的 GameEvent。
行为
ActionHandler 只产出 (type, payload, playerId),引擎在派发前调用本函数补齐 id、timestamp 与当前 stateVersion,使 handler 免于元数据记账。
id通过generateId('event')自动生成timestamp取Date.now()stateVersion由引擎传入- 仅当
init.playerId !== undefined时才挂载该字段
ts
function eventFromInit(init: EventInit, stateVersion: number): GameEvent {
const event: GameEvent = {
id: generateId('event'),
type: init.type,
payload: init.payload,
timestamp: Date.now(),
stateVersion
}
if (init.playerId !== undefined) {
event.playerId = init.playerId
}
return event
}调用方
主要由 GameEngine 在 processAction 内调用:
ts
for (const init of handlerEvents) {
const event = eventFromInit(init, this.state.version)
emittedEvents.push(event)
this.tickEvents.push(event)
this.eventBus.emit(event)
}以及在 emitEvent(GameContext.emit 内部使用):
ts
emitEvent(init: EventInit): void {
const event = eventFromInit(init, this.state.version)
this.tickEvents.push(event)
this.eventBus.emit(event)
}示例
在 ActionHandler 中产出事件
ts
import { type ActionHandler, PLAY_CARD_ACTION, type PlayCardAction } from 'decklet'
import { moveCard } from 'decklet/core/stateUtils.js'
const handler: ActionHandler<PlayCardAction> = {
type: PLAY_CARD_ACTION,
execute(action, ctx) {
const { cardIds } = action.payload
const playerId = action.playerId!
let next = ctx.state
for (const cardId of cardIds) {
next = moveCard(next, cardId, `player:${playerId}:hand`, 'discard')
}
// 产出 EventInit(无需 id/timestamp/stateVersion,引擎会补全)
return {
state: next,
events: [
{
type: 'CARD_PLAYED',
payload: { playerId, cardIds: [...cardIds] },
playerId
}
]
}
}
}在 EventHandler 中产出事件(通过 ctx.emit)
ts
events: [
{
type: 'CARD_PLAYED',
handle(event, ctx) {
const { playerId } = event.payload as { playerId: string }
const handZone = ctx.state.zones[`player:${playerId}:hand`]
if (handZone && handZone.cards.length === 0) {
ctx.addWinner(playerId)
ctx.setStatus('finished')
// ctx.emit 接收 EventInit,引擎补全元数据
ctx.emit({
type: 'GAME_WON',
payload: { winnerIds: [playerId] },
playerId
})
ctx.emit({
type: 'GAME_FINISHED',
payload: { winnerIds: [playerId] }
})
} else {
ctx.dispatch(createEndTurnAction(playerId))
}
}
}
]订阅事件
ts
import { GameEngine, CARD_PLAYED, GAME_WON } from 'decklet'
const engine = new GameEngine()
engine.subscribe(CARD_PLAYED, (event) => {
const { playerId, cardIds } = event.payload as { playerId: string; cardIds: string[] }
console.log(`${playerId} played ${cardIds.length} card(s)`)
})
engine.subscribe(GAME_WON, (event) => {
const { winnerIds } = event.payload as { winnerIds: string[] }
console.log(`winners: ${winnerIds.join(', ')}`)
})注意事项
- 引擎内置事件常量是
as const字面量;插件可派发自定义事件类型 eventFromInit不接受id/timestamp/stateVersion之外的字段覆盖(与createGameEvent不同)stateVersion是回放与一致性校验的关键 —— 引擎会自动填充,业务侧无需关心GAME_WON与GAME_FINISHED通常先后派发:前者表示胜负已判定,后者表示整局彻底结束- 自定义事件类型应使用字符串字面量(如
'MY_EVENT' as const),避免类型放宽 EventInit与GameEvent的差异:前者只有type/payload/playerId,后者多了id/timestamp/stateVersion