本页尚未翻译,当前显示英文版本。
快速入门
一个可玩的竞技场:地图、坦克、射击、掉落,从头到尾全靠声明(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 中基于特性的声明方式尚未确定(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[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、StatEvent | hook 的 payload,由你挂接的模块传入 |
Seat | Matchmaking |
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 中基于特性的声明方式尚未确定(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[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。
[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 中基于特性的声明方式尚未确定(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 中基于特性的声明方式尚未确定(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.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 panelplayserv 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)。
后续阅读
- 示例,紧接本节之后 :Tanks 中的排行榜、Tanks 中的医疗箱,以及面向局外循环的每日锦标赛。每个示例实现一个真实功能,每一步都链接到负责相应内容的模块页面。
- SDK 如何工作:当 SDK 的整体结构比下一个功能更重要时再读。核心概念是词典,另有四篇文章分别回答谁在调用(由谁决策)、授权怎么写(Access & Roles)、SDK 由什么构成(SDK 的构成)以及它如何执行(线程、生命周期与测试)。
- 然后是各个模块。所有模块页面结构相同,所以越往后读越快。其他一切都依赖其中两个: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 → 主机失联后还剩什么 |