本页尚未翻译,当前显示英文版本。
Events
**事件就是“某件事发生了”这一事实,会送达每个应当知道它的人。**只发生一次、无法从当前值补看的事情,就用事件:一次射击、一笔购买、一次进入房间。
何时使用
- 某件事发生了,其他人必须做出反应:开了一枪、锁了一扇门、一局比赛结束。
- 受众不固定。同一次发送可以送达一个小队、一个房间(room)或一个主体(actor),取决于类型声明的目标。
- 你想要带自动补全的类型化处理器。声明过的事件(event)会在所属接口上变成
send.*和on.*,各自有自己的契约。 - 一小时后仍要能读到这个事实:把类型声明为保留(retention),再按时间段读回。
分工
| 主体 | 在本页中的职责 |
|---|---|
schema-author | 用 [Event] 声明事件,推送 schema |
任意主体 | 通过 send.* 发送,通过 on.* 订阅 |
概览
RallyCall 只声明一次;用 send.* 发送,用 on.* 响应[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public record RallyCall(Vector3 Position);
// emitting: the declaration generated the method — and its contract
squad.Send.RallyCall(position);
// subscribing: typed handler, autocompleted beside every other declared event
squad.On.RallyCall(call => ShowRallyMarker(call.Position));@Event('rally_call', { clock: Clock.SimTime, retention: Retention.Transient })
export class RallyCall { constructor(public position: Vector3) {} }
// emitting: the declaration generated the method — and its contract
squad.send.rallyCall(position);
// subscribing: typed handler, autocompleted beside every other declared event
squad.on.rallyCall((call) => showRallyMarker(call.position));@event("rally_call", clock=Clock.SIM_TIME, retention=Retention.TRANSIENT)
class RallyCall:
position: Vector3
# emitting: the declaration generated the method — and its contract
squad.send.rally_call(position)
# subscribing: typed handler, autocompleted beside every other declared event
squad.on.rally_call(lambda call: show_rally_marker(call.position))即将推出 —— Go
Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。
// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSEvent = (Name = "rally_call", Clock = "SimTime", Retention = "Transient"))
struct FRallyCall
{
GENERATED_BODY()
UPROPERTY() FVector Position;
};
// emitting and subscribing — generated, typed
Squad->Publish->RallyCall({ Position });
TPSSubscription RallyMarkers = Squad->Subscribe->RallyCall(
[this](const FRallyCall& Call) { ShowRallyMarker(Call.Position); });
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.// the calls are the C# ones; the payload is not — Unity's floor is C# 9 and the generated
// source may carry no records, so a declared payload is a plain serializable type
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public sealed class RallyCall
{
public Vector3 Position; // converts to and from UnityEngine.Vector3
}
squad.Send.RallyCall(new RallyCall { Position = position });
squad.On.RallyCall(call => ShowRallyMarker(call.Position.ToUnity()));在模块或群组(group)内部声明的事件只在那里出现:squad.send.rallyCall 之所以存在,是因为 rally_call 是为小队声明的,发送也只送达小队成员。模块向外发送的事件属于它声明的契约,调用方永远看不到未声明的事件。
模型
事件类型声明哪些内容。
| 声明项 | 含义 |
|---|---|
name | 显式的传输名称,由声明给出,而不是从符号推导 |
payload | 每次发送所携带内容的 schema |
target | 发送的 去向:实体(entity)实例、群组、房间或全局上下文。以群组为目标时,一个信号送达多个接收者。会变化的收件方要表达为群组,而不是在发送时传入地址 |
clock | sim_time 或 timestamp,二者只选其一。sim_time 用于模拟内部的事实,这些事实参与预测、延迟补偿和 rollback;timestamp 用于模拟之外的事实,比如购买或登录 |
retention | transient 或 retained,见下文 |
term | 用于 retained 类型:保留多久、到期后怎样。不存在“永久” |
delivery | 至多一次、至少一次或恰好一次,声明在类型上,订阅方无需再问。“恰好一次”会写明它在什么范围内成立 |
context | 全局或局部。全局声明的名称在局部上下文中可见,局部声明的名称在上层不可见 |
每次发送携带什么。
| 字段 | 含义 |
|---|---|
type | 声明的事件。发送永远不会合并:两次射击就是两个事件。这正是事件与 Data & Subscriptions 中 [Sync] 字段的区别 |
payload | 符合该类型的 schema |
source | 发送方主体;由实体发送时还包括该实体实例 |
stamp | 按类型声明的时钟记录 |
去重键 | 每种投递模式下都有 |
原因键 | 平台因另一个事件而发送的事件带有此项,值为那个事件的 id,这样可以按键重建因果链,而不必比较时间戳 |
客户端发来的事件只是一种声称
在任何逻辑依赖客户端发送的事件之前,权威的一方都要先检查它。不要把 source 当作证明。
处理重复投递
每种投递模式都可能重复投递:传输层的重复包,或对保留事件的再次读取。按 去重键 去重。
订阅包含什么。
| 包含项 | 含义 |
|---|---|
event | 订阅绑定的声明类型 |
surface | 订阅所在的节点,位于类型声明的 target 之内 |
handler | 按 payload 类型化的处理器 |
position | 重连后从哪里继续,免得悄悄从“现在”重新开始。断开期间错过的事件不会重放:瞬时事件就此丢失,只有 retained 事件可以读回 |
所有事件都满足的几点。
| 始终成立 | 含义 |
|---|---|
受众 | 从不由发送方列出:它就是声明的 target,先收窄到已订阅的人,再经过 Access & Roles 把关。即使类型可见,某个流(stream)也可能被谓词关闭 |
阶段 | 先发送,后投递。事件没有状态机:它只发生一次 |
顺序 | 只在一个流内保证,一个流就是一个发送实例:同一实例的两个事件按发送顺序到达 |
缺口检测 | 投递模式允许丢失时,订阅方会得知出现了缺口,而不是悄悄跳过 |
跨流没有顺序保证
两个实例之间不保证顺序,同一变更的 delta 和事件之间也不保证。需要跨流有序时,把消息放进同一个流,或在 payload 里携带因果时间戳。
保留是类型的一部分
事件投递后是否保留,声明在类型上,而不是在发送时选择,因此同一类型的保留方式始终相同。保留的事件按类型和时间段读回,不走 Data & Subscriptions 的查询接口。
| 瞬时 | 保留 | |
|---|---|---|
| 送达 | 当时已订阅的人 | 同左,外加之后才订阅的人 |
| 之后 | 消失 | 在声明的期限内保留 |
| 可读回 | 否 | 是,在期限内 |
| 超过期限 | — | 选择集直接拒绝,而不是返回空结果 |
保留事件:声明时带上期限,按时间段读回
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public record ObjectiveTaken(string Objective, PlayerId By);
// a member who joined late reads what it missed — by type and period, nothing wider
var taken = await squad.Retained.ObjectiveTaken(since: matchStart);@Event('objective_taken', { clock: Clock.SimTime, retention: Retention.Retained, keep: '7d' })
export class ObjectiveTaken { constructor(public objective: string, public by: PlayerId) {} }
// a member who joined late reads what it missed — by type and period, nothing wider
const taken = await squad.retained.objectiveTaken({ since: matchStart });@event("objective_taken", clock=Clock.SIM_TIME, retention=Retention.RETAINED, keep="7d")
class ObjectiveTaken:
objective: str
by: PlayerId
# a member who joined late reads what it missed — by type and period, nothing wider
taken = await squad.retained.objective_taken(since=match_start)即将推出 —— Go
Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。
USTRUCT(PSEvent = (Name = "objective_taken", Clock = "SimTime", Retention = "Retained", Keep = "7d"))
struct FObjectiveTaken
{
GENERATED_BODY()
UPROPERTY() FString Objective;
UPROPERTY() FPSPlayerId By;
};
// a member who joined late reads what it missed — by type and period, nothing wider
Squad->Retained->ObjectiveTaken->Select(FPSTimeWindow{ .From = MatchStart })
.Then(TPSOnResult<TArray<FObjectiveTaken>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FObjectiveTaken>>& Result)
{
if (!Result.HasValue()) { return; }
for (const FObjectiveTaken& Taken : Result.Value()) { Timeline->Add(Taken); }
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.// same attribute, same read — the payload is a plain serializable type on the C# 9 floor
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public sealed class ObjectiveTaken
{
public string Objective;
public PlayerId By;
}
var taken = await squad.Retained.ObjectiveTaken(since: matchStart);错误
- 发布和订阅是两项独立的权限,拥有其中一项不代表拥有另一项。没有权限的订阅返回 forbidden,而不是 not-found:该类型本来就在模块声明的契约里,没什么可隐藏的。
- 订阅模块未声明的类型