Эта страница ещё не переведена — показана английская версия.
Первые шаги
Соберём играбельную арену — карту, танки, стрельбу и выпадающие предметы — целиком из объявлен ий. Игрового цикла ниже не будет: симуляция работает внутри платформы.
Что понадобится
Проект (project) с окружением dev — его создают на операторском уровне, который отвечает за жизненный цикл окружений (environment). CLI playserv, авторизованный в этом проекте. Пакет SDK для вашего биндинга. Больше в игру ничего устанавливать не нужно.
Все вызовы, которые делаете вы сами, показаны в примерах ниже. Всё, что происходит между ними, делает платформа — по тому, что написано в объявлениях. Способность, характеристика и таблица выпадения из этих примеров — пресеты сущностей: это объявления на сущностях, а не отдельные модули.
Объявите мир
Сущность (entity) — это часть вашей схемы вместе с её живыми аспектами. Каждое поведение задаётся одним атрибутом рядом с полем (field), которое оно описывает:
Tank: три правила синхронизации и три игровых аспекта, по строке на каждый[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 | аргументы хуков: их передаёт модуль, в который вы встраиваетесь |
Seat | Matchmaking |
Scope и области синхронизации | Visibility |
Tick и частоты тика | Rooms |
Перечисления закрыты. Если нужного значения в них нет, правило записывают предикатом, а не новым значением. Например, если Scope.Owner — не совсем то, что вам нужно, видимость отдельного поля задаётся так: [Aspect("loadout", Visible = "owner == caller.player")] (подробнее — на странице Data & Subscriptions).
Объявите комнату
Шаблон комнаты (room template) описывает, какой будет сессия, и перечисляет объявления, на которые она опирается. Наследоваться от класса комнаты и писать метод тика не нужно: всё, что происходит внутри комнаты, делает платформа.
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;
}Строки в кавычках — это ключи контента, а не произвольный текст:
| Ключ | Что обозначает |
|---|---|
rock | объект из набора препятствий карты (Map) |
ammo.shell, railgun | предметы каталога (Catalog & Commerce); благодаря этому выстрел списывает снаряд, а подобранное попадает в инвентарь |
battle, arena, crate-loot, shell | ключи, под которыми регистрируются сами четыре объявления |
Если ключа нет в целевом окружении, playserv push не примет объявление. Опечатка в ключе обнаружится при деплое, а не при первом выстреле. Где бы ни лежал шаблон, его можно перенастроить без повторной сборки игры: live-ops правит в панели именно загруженную модель.
Запишите правила в хуках
Хуки — это облачные функции, которые платформа вызывает на именованных шагах. На входе и на выходе у них конкретные типы, а в сигнатуре нет ни универсального контекста, ни логгера.
[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). Примеры появятся, когда выберут способ записи.
Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а Unreal подпишется на события, которые он порождает. В планах — функции, написанные прямо в движке: графы Blueprint и C++, который будет транслироваться в скрипт, проверяемый платформой. Их будут анализировать и загружать так же, как любые объявления, а выполняться они по-прежнему будут на платформе.
Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а Unity подпишется на события, которые он порождает.
Хук Before может отклонить шаг. Хук After срабатывает, когда шаг уже завершён, и отменить его не может. Поэтому если стартовый набор не выдался, игрок (player) потеряет 20 снарядов, но войдёт в игру. Остальное — на странице Extensibility.
Что на самом деле стоит за каждой строкой этого примера:
| Строка | Что за ней стоит |
|---|---|
срабатывает Stats.Depleted | характеристика (stat) опустилась до минимума: для Hp это 0, потому что в объявлении задан только Max |
| переход в состояние смерти | AtMin = "death" из первого шага; хук добавляет только последствие, а не сам переход |
| урон | [Effect(Damage = 35)] на снаряде; при попадании его применяет платформа |
RollAt | генерируется для объявления [DropTable], поэтому класс помечен partial, а во вкладке Go вызов называется drops.RollAtCrateLoot |
| подбор | когда танк проезжает по добыче, предметы атомарно переходят в инвентарь игрока; этот переход и есть событие (event) changed, которое отрисовывает HUD |
Типы для движка
playserv schema codegen # Unreal C++ → Plugins/PlayServ/Generated · Unity C# → Packages/com.playserv.sdk/GeneratedЗапускайте генерацию после каждой загрузки схемы (schema) — вручную или в CI. Типы всегда генерирую тся заново и никогда не правятся руками, поэтому сгенерированный Tank в точности совпадает с загруженным. playserv push читает проект движка так же, как серверный: спецификаторы UHT и атрибуты C# сами по себе и есть объявление. Чтобы экспортировать модель, достаточно указать CLI на проект UE или Unity. В каком потоке придёт колбэк и когда закончится подписка (subscription), определяет модель выполнения — см. Потоки, время жизни и тесты.
Подключите клиент
Клиентский API симметричен серверному: модули те же, а что может вызывать сборка, определяет ключ, с которым она запущена. В сборку игры входит ключ игрока (player key) — здесь это projectKey. Он открывает доступ к одному проекту и одному окружению, выдаётся в панели и поставляется вместе со сборкой. Ролей (role) в самом ключе нет: сервер определяет их при каждом запросе, а игрок, от имени которого идёт запрос, появляется после SignIn. Основные здесь — биндинги для движков, но серверные биндинги могут работать с тем же API без интерфейса: так устроены мозг бота, нагрузочный тест или служебный инструмент.
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 | создаёт один тикет и резервирует место (seat) в комнате. Резерв держится столько, сколько указано в шаблоне; если время вышло, игрок теряет место, но не право играть |
Join | завершается, когда пришло текущее состояние комнаты. Танк, которого шаблон создаёт для нового участника, входит в это состояние, поэтому My<Tank>() работает уже на следующей строке, а всё, что идёт после Join, — это живой трафик |
Cast | выстрел — это применение способности (ability), а не отдельный API. Объявление [Projectile] — та же способность, только с баллистикой, поэтому Cast проверяет перезарядку, боезапас и цель так же, как для рывка или лечения (Entity Presets) |
Abilities | генерируется: кодогенерация собирает объявленные способности и снаряды в один тип для каждого биндинга |
Отказ приходит как типизированный Problem: код ошибки и понятное человеку объяснение. В C#, TypeScript, Python и Unreal он выбрасывается как исключение, а в Go возвращается как значение ошибки — поэтому во вкладке Go проверяется каждый вызов. Выделенный сервер Unreal или мастер-клиент запускает тот же бинарник, но с ключом хоста (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 находит в проекте хуки и объявления на атрибутах и деплоит их в выбранное окружение (по умолчанию --env dev). playserv schema push загружает только модель, а playserv schema diff показывает, с чем будет сравниваться загрузка (см. Схема в коде).
Загрузка применяется целиком или не применяется вовсе. Если развёрнутая схема успела измениться после вашего diff, загрузка будет отклонена, а не слита. Перенос модели из dev в prod делается на операторском уровне, а не вызовом SDK (см. The Operator Plane).
Несовместимые изменения становятся миграциями
Изменение, которое сломало бы существующие данные, не применяется при загрузке. Из него получается миграция: вы сначала её просматриваете, а потом запускаете или отменяете (см. Схема в коде).
playserv open battle создаёт комнату по загруженному шаблону battle и открывает её в панели. Там можно смотреть состояние комнаты и её участников прямо во время игры. Теперь в панели видны шаблон, карта (map), таблица выпадения (drop table) и хуки — та же модель, что вы написали в коде, и редактировать её можно и там.
Ограничения, которые задали не вы
Capacity = 8, Hz30, Hz = 10 и Cooldown = 1.5f — это настройки конкретной игры, а не предельные значения. У платформы свои лимиты, они выше, и для каждого описано, что увидит вызывающий код, когда в него упрётся:
| Ситуация | Что получает вызывающий код |
|---|---|
| вход в заполненную или закрытую комнату | conflict — можно повторить, когда освободится место |
| создание комнаты сверх лимита на проект или на актора | отказ; уже созданные комнаты не удаляются |
| слишком частое создание комнат или вход | отказ по частоте запросов с указанием, сколько подождать |
| событие с данными больше лимита комнаты | отказ ещё до отправки; данные не обрезаются |
| чтение большего числа строк, чем разрешено роли | столько строк, сколько разрешено, и отметка, что результат обрезан |
Конкретные числа задаются для каждого окружения и будут опубликованы вместе с лимитами платформы, а поведение на границе уже работает (см. Rooms, Access & Roles, Auth & Players).
Что дальше
- Примеры — следующий раздел: таблица лидеров в Tanks, аптечки в Tanks или рецепт ежедневного турнира для мета-игры. Каждый пример — одна настоящая фича, и каждый шаг ведёт на страницу модуля, которым вы только что воспользовались.
- Как работает SDK — когда устройство SDK станет важнее следующей фичи. Основные понятия — это словарь, а ещё четыре статьи отвечают на вопросы: кто вызывает (Кто принимает решения), как записываются права (Access & Roles), из чего состоит SDK (Как устроен SDK) и как он выполняется (Потоки, время жизни и тесты).
- Затем модули. Страницы всех модулей устроены одинаково, так что каждая следующая читается быстрее. Всё остальное опирается на две из них: Entity и Data & Subscriptions.
Что читать в зависимости от задачи
Кем бы вы ни были, начните со страницы Кто принимает решения: идея одного SDK, где у каждого актора свои права, нужна всем. Держите рядом открытыми Основные понятия.
| Вы | Что читать, по порядку |
|---|---|
| Разработчик клиента игры (Unity · клиент Unreal · TS) | Auth & Players → Matchmaking → Rooms → Entity → Data & Subscriptions, затем по нужным фичам: Inventory · Leaderboards · Messaging · Profile |
| Серверный разработчик (C# · TS · Python · Go) | Схема в коде → строительные блоки → Entity → Extensibility → Access & Roles, затем модули, объявления которых вы ведёте: Rooms · Matchmaking · Leaderboards · Catalog & Commerce |
| Разработчик выделенного сервера на Unreal | Rooms (раздел «Хостинг комнаты») → Bots → Locomotion · объекты мира → Map → Что остаётся после потери хоста |