跳到主要内容
从这里开始

本页尚未翻译,当前显示英文版本。

快速入门

一个可玩的竞技场:地图、坦克、射击、掉落,从头到尾全靠声明(declaration)搭成。下文没有任何游戏循环,模拟在平台内部运行。

开始之前

你需要三样东西:一个带 dev 环境(environment)的项目(project),它在运营平面上创建,生命周期也由运营平面管理;一个已登录该项目的 playserv CLI;以及你所用绑定(binding)的 SDK 包。游戏里不需要再安装别的东西。

你要写的每个调用都在下面的示例里;调用之间的步骤由平台按声明去执行。下文用到的技能(ability)、属性(stat)和掉落表(drop table)都是实体预设:它们是写在实体上的声明,不是独立的模块。

声明世界

实体(entity)就是你的 schema 加上它运行时的 aspect。每种行为一个特性,紧挨着它描述的字段:

声明 Tank 实体:三种同步策略、三个玩法 aspect,每项一行
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;                     // synced every tick
  @Sync({ hz: 10 }) fuel = 0;                  // ~10 times a second
  @Sync({ to: Scope.Owner }) ammo = 0;         // owner's eyes only

  @Stat({ max: 100, atMin: 'death' }) hp: Stat;
  @Body({ shape: 'capsule', radius: 0.6 }) body: Body;
  @Motion({ model: 'tank', maxSpeed: 8, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()                      # synced every tick
    fuel: float = sync(hz=10)                    # ~10 times a second
    ammo: int = sync(to=Scope.OWNER)             # owner's eyes only

    hp = stat(max=100, at_min="death")
    body = collision.body(shape="capsule", radius=0.6)
    motion = locomotion.motion(model="tank", max_speed=8.0, turn_rate_deg=120.0)
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                        FVector3f Position;   // synced every tick
    UPROPERTY(PSSync = (Hz = 10))            float     Fuel;       // ~10 times a second
    UPROPERTY(PSSync = (To = "Owner"))       int32     Ammo;       // owner's eyes only

    UPROPERTY(PSStat = (Max = 100, AtMin = "death"))                                FPSStat Hp;
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6"))                         FPSBody Body;
    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", TurnRateDeg = 120))     FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — 同样的特性在 Unity 中也能编译,而且这里用不到 C# 12,2021.3 基线版本即可构建
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}

修改 [Sync] 字段,本身就是网络操作。不用组装快照,也不用调用发送。

这些类型都不需要你来定义,每个类型各有一个页面负责:

代码中的类型来源
Vector3、Stat你所用绑定的核心包
Body 及各种碰撞体形状Collision
Motion 及五种移动模型Locomotion
ObstacleSet、Drop、Flight、Ammo、Effect用到它们的实体预设
EntryRequest、Verdict、StatEventhook 的 payload,由你挂接的模块传入
SeatMatchmaking
Scope,即同步范围Visibility
Tick,即 tick 频率Rooms

这些枚举是封闭的。任何成员都覆盖不了的规则,要写成谓词,而不是新增成员:如果 Scope.Owner 不完全符合你想要的规则,就用 [Aspect("loadout", Visible = "owner == caller.player")] 表达按字段的可见性(见 Data & Subscriptions)。

声明房间

房间模板(room template)规定一局游戏是什么,并列出它引用的声明。没有要继承的房间类,也没有要填写的 tick 方法,因为房间内部归平台管:

battle 模板及其引用的三个声明:一张竞技场地图、一张掉落表、一种武器
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}
@RoomTemplate('battle', { map: 'arena' })
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
}
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
}
@DropTable('crate-loot')
export class CrateLoot {
  @Entry('ammo.shell', { weight: 60, count: [2, 4] }) ammoShell: Drop;
  @Entry('railgun', { weight: 1 }) railgun: Drop;              // the jackpot
}
@Projectile('shell', { cooldown: 1.5 })
export class Shell {
  @Ballistics({ speed: 24, gravity: 9.8 }) arc: Flight;
  @Ammo('ammo.shell', { perShot: 1 }) load: Ammo;
  @Effect({ damage: 35 }) onHit: Effect;
}
@room_template("battle", map="arena")
class Battle:
    capacity = 8
    tick = Tick.HZ30

@Map("arena", seed=42, bounds="160x160")
class Arena:
    rocks = scatter("rock", count=40, min_spacing=6)

@drop_table("crate-loot")
class CrateLoot:
    ammo_shell = entry("ammo.shell", weight=60, count=(2, 4))
    railgun = entry("railgun", weight=1)                       # the jackpot

@projectile("shell", cooldown=1.5)
class Shell:
    arc = ballistics(speed=24, gravity=9.8)
    load = ammo("ammo.shell", per_shot=1)
    on_hit = effect(damage=35)
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

USTRUCT(PSRoomTemplate = (Name = "battle", Map = "arena", Capacity = 8, Tick = 30))
struct FBattle { GENERATED_BODY() };

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6)) FPSObstacles Rocks;
};

USTRUCT(PSDropTable = "crate-loot")
struct FCrateLoot
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Item = "ammo.shell", Weight = 60, Count = "2..4")) FPSDrop AmmoShell;
    UPROPERTY(PSEntry = (Item = "railgun", Weight = 1))                     FPSDrop Railgun;  // the jackpot
};

USTRUCT(PSProjectile = (Name = "shell", Cooldown = "1.5"))
struct FShell
{
    GENERATED_BODY()

    UPROPERTY(PSBallistics = (Speed = "24.0", Gravity = "9.8")) FPSFlight Arc;
    UPROPERTY(PSAmmo = (Item = "ammo.shell", PerShot = 1))      FPSAmmo Load;
    UPROPERTY(PSEffect = (Damage = 35))                         FPSEffect OnHit;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — 同样的四个声明在 Unity 中也能编译;Unity 构建读取推送后的模板,并据此加入房间
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}

引号里的 id 是内容键,不是随便写的文字:

键指代什么
rock地图障碍物集合里的一种道具(Map)
ammo.shell、railgun商品目录(catalog)中的物品(Catalog & Commerce),射击扣除弹药、拾取的物品进入背包,靠的也是这一点
battle、arena、crate-loot、shell这四个声明自己注册的键

如果声明引用的键在目标环境中不存在,playserv push 会拒绝它,所以拼错的键在部署时就会失败,而不是等到第一次施放技能才暴露。无论模板写在哪里,都可以随时调整,无需重新部署引擎:live-ops 在面板里编辑的就是推送上去的模型。

用 hook 编写规则

hook 是平台在具名步骤上调用的云函数(cloud function)。输入输出都有类型,签名里没有上下文包,也没有 logger。

三条规则写成 hook:在入口拒绝被封禁的玩家,给新玩家发 20 发炮弹,死亡时掷出掉落
[Before(Rooms.Entry, room: "battle")]
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? entry.Reject(Problem.Banned, "banned from this project")
        : entry.Accept();

[After(Auth.SignIn, created: true)]
public static async Task GrantStarterPack(Player player)
{
    await player.Inventory.Grant("ammo.shell", count: 20);
}

[After(Stats.Depleted, stat: "hp")]
public static void OnDeath(StatEvent e) => CrateLoot.RollAt(e.Entity.Position);
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? entry.reject(Problem.banned, 'banned from this project')
      : entry.accept());

export const grantStarterPack = after(Auth.signIn, { created: true },
  async (player: Player) => {
    await player.inventory.grant('ammo.shell', { count: 20 });
  });

export const onDeath = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) => {
  CrateLoot.rollAt(e.entity.position);
});
@before(rooms.entry, room="battle")
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()

@after(auth.sign_in, created=True)
async def grant_starter_pack(player: Player):
    await player.inventory.grant("ammo.shell", count=20)

@after(stats.depleted, stat="hp")
def on_death(e: StatEvent):
    CrateLoot.roll_at(e.entity.position)
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

在引擎之外运行

hook 是云函数:它在平台上执行,而不是在引擎里。用 C#、TypeScript 或 Python 编写,Unreal 订阅它产生的事件。在引擎中编写函数的能力已在计划中:Blueprint 图表,以及 C++(转译为经平台校验的脚本目标)。两者都会像其他声明一样经过分析并推送,执行仍留在平台上。

在引擎之外运行

hook 是云函数:它在平台上执行,而不是在引擎里。用 C#、TypeScript 或 Python 编写,Unity 订阅它产生的事件。

Before 拦截型 hook 可以拒绝这一步;After 观察型 hook 在这一步提交后才运行,无法拒绝。所以新手礼包没发成功,损失的是 20 发炮弹,而不是这次登录。其余内容见 Extensibility。

上面每一行实际由谁执行:

代码行实际由谁执行
Stats.Depleted 触发属性降到下限:Hp 的下限是 0,因为声明只设置了 Max
转入死亡状态第 1 步中的 AtMin = "death";hook 只添加后果,不负责状态转换
伤害炮弹上的 [Effect(Damage = 35)],命中时由平台结算
RollAt生成到 [DropTable] 声明上,所以这个类是 partial,Go 标签页里写作 drops.RollAtCrateLoot
拾取坦克开过掉落物时,物品以原子方式转入玩家(player)的背包;这次转移就是 HUD 渲染的 changed 事件(event)

为引擎生成类型

playserv schema codegen   # Unreal C++ → Plugins/PlayServ/Generated · Unity C# → Packages/com.playserv.sdk/Generated

每次推送 schema 后都运行它(或交给 CI 运行)。类型只重新生成,从不手工修改,生成的 Tank 就是推送上去的 Tank。playserv push 读取引擎项目的方式和读取服务器项目完全一样:UHT 说明符和 C# 特性本身就是声明,所以把 CLI 指向 UE 或 Unity 项目,导出就完成了。回调落在哪个线程、订阅何时结束,由运行时模型决定,见线程、生命周期与测试。

连接客户端

客户端 API 是对称的:模块相同,一个构建能调用什么,由它运行时使用的密钥决定。引擎构建携带玩家密钥(player key),这里就是 projectKey:它是一个项目、一个环境的凭据,在面板中签发,随构建一起发布。它不指定任何角色(role):每次请求时角色都在服务器端解析,背后的玩家则通过 SignIn 确定。引擎绑定在这里是一等公民;服务器绑定以无界面方式驱动同一套接口(机器人大脑、压力测试、运维工具):

客户端会话:登录、找比赛、加入、响应变化、驾驶和射击
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);
const playserv = await PlayServ.connect(projectKey);
const session = await playserv.auth.signIn(Provider.Device, { create: true });

const seat = await playserv.matchmaking.find('battle');
const room = await playserv.rooms.join(seat);

room.entities<Tank>().onChange((tank) => render(tank));

const aim: Vector3 = { x: 24, y: 0, z: 12 };      // the world point under the crosshair
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });
await room.my<Tank>().cast(Shell, aim);
playserv = await PlayServ.connect(project_key)
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)

seat = await playserv.matchmaking.find("battle")
room = await playserv.rooms.join(seat)

room.entities(Tank).on_change(lambda tank: render(tank))

aim = Vector3(24, 0, 12)                          # the world point under the crosshair
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)
await room.my(Tank).cast(Shell, aim)
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        FPlayServClient* Client = ConnectResult.Value();
        Client->Auth->SignInAnonymous(FPSIdempotencyKey(DeviceId),
            TPSOnResult<FPSSession>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSSession>& SignedIn)
            {
                if (!SignedIn.HasValue()) { return; }
                FindBattle(Client);
            }));
    }));

// in FindBattle(FPlayServClient* Client): a ticket, the seat it wins, the room it opens
Client->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this, Client](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterBattle(JoinResult.Value());
                }));
        });
    }));

// in EnterBattle(FPSRoom* Room): render what you see, drive what is yours
TPSSubscription TankView = Room->Entities->Of<UTank>()->Select()
    .Subscribe([this](const TArray<UTank*>& Tanks) { Render(Tanks); });

const FVector3f Aim(24.f, 0.f, 12.f);            // the world point under the crosshair
Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this, Aim](const TPSResult<UTank*>& MineResult)
    {
        if (!MineResult.HasValue()) { return; }
        UTank* MyTank = MineResult.Value();
        MyTank->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
        MyTank->Call->Cast(PSKeys::Ability::Shell, Aim);
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — 在 Unity 中基于生成的类型直接运行
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);

四个调用,各自返回不同的东西:

调用返回什么
Find提交一张匹配票据(ticket),并在某个房间(room)里预留一个席位(seat)。预留保持多久由模板声明;过期失去的是席位,而不是参与游戏的资格
Join房间当前状态到达后返回。模板为新进入的成员生成的坦克就包含在这份状态里,所以下一行的 My<Tank>() 马上有结果,Join 之后的一切都是实时流量
Cast开火就是施放技能,而不是另一套接口:[Projectile] 声明本身就是加上弹道的技能,所以 Cast 检查冷却、弹药和目标的方式,与冲刺或治疗完全相同(Entity Presets)
Abilities自动生成:代码生成把声明的技能和投射物收集到每个绑定的一个类型里

拒绝以平台的类型化 Problem 返回:一个错误码,加上给人看的原因。C#、TypeScript、Python 和 Unreal 会抛出它;Go 把它作为 error 值返回,所以 Go 标签页里的每个调用都做了检查。Unreal 专用服务器或主客户端(master-client)运行同一个二进制文件,但改用主机密钥(host key),它的角色带有 mc 各行:Rooms → 托管房间完整介绍了这套接口,背后的密钥和角色在 Access & Roles 中声明。

推送并开玩

playserv push          # schema + declarations + hooks, one deploy
playserv open battle   # a dev-env room, live in the panel

playserv push 扫描所在项目中基于特性的 hook 和声明,并部署到你指定的环境(默认 --env dev)。单独运行 playserv schema push 只推送模型,playserv schema diff 则给出推送所对比的基准(见 Schema 即代码)。

一次推送要么完整生效,要么完全不生效;如果在你 diff 之后已部署的 schema 有了变化,推送会被拒绝,而不是合并。把模型从 dev 迁到 prod 是运营平面上的操作,不是 SDK 调用(见运营平面)。

破坏性变更会变成迁移

会破坏现有数据的变更不会随推送生效,而是变成一次迁移(migration):你先审阅,再决定执行还是取消(见 Schema 即代码)。

playserv open battle 用推送上去的 battle 模板创建一个房间,并在面板中打开它;你在房间里对战时,可以在面板上查看房间状态和成员。面板里现在能看到模板、地图、掉落表和 hook:和你在代码里写的是同一个模型,在面板里也能编辑。

不由你决定的数值

Capacity = 8、Hz30、Hz = 10 和 Cooldown = 1.5f 是这款游戏自己的调参,不是上限。平台自身的限制在它们之上,每条限制都规定了调用方在边界处看到什么:

边界情况调用方得到什么
房间已满或已关闭时加入conflict,有席位空出时值得重试
创建房间超出单项目或单主体上限被拒绝,已创建的房间不受影响
创建房间或登录过于频繁限流拒绝,并附带需要等待的时间
事件 payload 超过房间上限发送前即被拒绝,绝不截断
读取行数超过角色上限返回上限数量的行,并附带已截断的标记

具体数值按环境设定,会随平台限制一起公布;边界处的行为现在就已生效(见 Rooms、Access & Roles、Auth & Players)。

后续阅读

  1. 示例,紧接本节之后:Tanks 中的排行榜、Tanks 中的医疗箱,以及面向局外循环的每日锦标赛。每个示例实现一个真实功能,每一步都链接到负责相应内容的模块页面。
  2. SDK 如何工作:当 SDK 的整体结构比下一个功能更重要时再读。核心概念是词典,另有四篇文章分别回答谁在调用(由谁决策)、授权怎么写(Access & Roles)、SDK 由什么构成(SDK 的构成)以及它如何执行(线程、生命周期与测试)。
  3. 然后是各个模块。所有模块页面结构相同,所以越往后读越快。其他一切都依赖其中两个:Entity 和 Data & Subscriptions。

按角色选择阅读路线

无论你负责什么,都先读由谁决策:一个 SDK、每个主体(actor)各有授权(grant),这是所有人共同的前提。同时把核心概念放在手边。

你的角色按顺序阅读
游戏客户端开发者(Unity · Unreal 客户端 · TS)Auth & Players → Matchmaking → Rooms → Entity → Data & Subscriptions,然后按功能阅读:Inventory · Leaderboards · Messaging · Profile
服务器开发者(C# · TS · Python · Go)Schema 即代码 → 基础构件 → Entity → Extensibility → Access & Roles,然后是由你负责声明的模块:Rooms · Matchmaking · Leaderboards · Catalog & Commerce
Unreal 专用服务器开发者Rooms(托管房间) → Bots → Locomotion · 世界物件 → Map → 主机失联后还剩什么