跳到主要内容
游戏周边服务

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

Files & UGC

文件按分块到达,边到边处理。 上传、资源及其派生变体,以及带审核流程的玩家生成内容。

何时使用

  • 玩家或服务要上传二进制数据:分块、可续传的上传会话(session),每个玩家的配额都可以读取。
  • 上传还没结束就要开始处理:把文件当作流(stream)逐块读取。
  • 玩家创作的内容需要审核流程:SubmitUgc、队列、裁决,两端都有 hook。
  • 一张母版图片要服务多个平台:派生出变体(缩放、转码),原图保持为规范版本。
  • 无需使用:如果只是小型结构化 payload。Data & Subscriptions 记录中的一个字段就能承载,不需要上传会话。

分工

主体在本页中的职责
player分块上传、读取流式文件、提交 UGC
moderator审阅队列,批准或驳回提交的内容
backend-service派生资源变体;为上传和审核挂 hook;设置配额

概览

分块上传 tank-07.png,上传途中读回,并作为贴花附加
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);
// upload, chunked, resumable
const session = await playserv.files.openUpload('skins/tank-07.png', { contentType: 'image/png' });
await session.write(chunk);
const file = await session.complete();

// consume a file as a stream — start processing before the upload finishes
const read = playserv.files.openRead(file);
for await (const chunk of read) ingest(chunk);

// attach to an entity
await tank.attach('decal', file);
# upload, chunked, resumable
session = await playserv.files.open_upload("skins/tank-07.png", content_type="image/png")
await session.write(chunk)
file = await session.complete()

# consume a file as a stream — start processing before the upload finishes
async with playserv.files.open_read(file) as read:
    async for chunk in read:
        ingest(chunk)

# attach to an entity
await tank.attach("decal", file)
即将推出 —— Go

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

// upload, chunked, resumable
Client->Files->Of<FSkin>()->Uploads->Create(FPSIdempotencyKey(UploadId),
    FPSUploadSpec{ .Path = TEXT("skins/tank-07.png"), .ContentType = TEXT("image/png") },
    TPSOnResult<FPSUpload*>::CreateWeakLambda(this, [this](const TPSResult<FPSUpload*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSUpload* Upload = Result.Value();
        Upload->Parts->Create(PartNumber, Chunk);
        Upload->Complete(TPSOnResult<FPSFileHandle*>::CreateWeakLambda(this, [this](const TPSResult<FPSFileHandle*>& Completed)
        {
            if (!Completed.HasValue()) { return; }
            OnSkinUploaded(Completed.Value());
        }));
    }));

// consume a file as a stream — start processing before the upload finishes
TPSSubscription SkinBytes = Client->Files->Of<FSkin>()->Contents->Subscribe(File,
    [this](const TArray<uint8>& Chunk) { Ingest(Chunk); });

// attach to an entity
Tank->Files->Attach(TEXT("decal"), File);
// 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 中基于生成的类型原样运行
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);

UGC,玩家这条路径:

从客户端调用 SubmitUgc:一次调用,所有绑定都一样
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl
const submission = await playserv.files.submitUgc(file, { kind: 'level' });   // cl
submission = await playserv.files.submit_ugc(file, kind="level")   # cl
即将推出 —— Go

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

// client — a submission is keyed; a retried submit returns the same submission
Client->Files->Ugc->Create(FPSIdempotencyKey(SubmitId), File,
    TPSOnResult<FPSSubmission*>::CreateWeakLambda(this, [this](const TPSResult<FPSSubmission*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Hud->ShowPending(Result.Value());
    }));
// 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 submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl

前后的把关都由 hook 完成,契约和其他地方一样:

hook 把关上传大小,并在提交时把内容加入审核队列
[Before(Files.Upload)]
public static Verdict CheckUpload(UploadIntent u) =>
    u.Size > 20.Mb() ? Hook.Reject("too large") : Hook.Continue(u);

[After(Files.SubmitUgc)]
public static Task Screen(UgcSubmission s) => PlayServ.Files.Moderation.Enqueue(s);
export const checkUpload = before(Files.upload, (u: UploadIntent) =>
  u.size > mb(20) ? Hook.reject('too large') : Hook.continue(u));

export const screen = after(Files.submitUgc,
  (s: UgcSubmission) => playserv.files.moderation.enqueue(s));
@before(files.upload)
def check_upload(u: UploadIntent) -> Verdict:
    return hook.reject("too large") if u.size > mb(20) else hook.continue_(u)

@after(files.submit_ugc)
async def screen(s: UgcSubmission):
    await playserv.files.moderation.enqueue(s)
即将推出 —— Go

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

在引擎之外运行

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

在引擎之外运行

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

两个 hook 包裹的都是操作步骤,而不是事件:Before(Files.Upload) 决定上传是否开始,After(Files.SubmitUgc) 在提交生成后运行,并通过模块自带的 Moderation.Enqueue 把它放进审核队列。事件(上传完成、变体就绪、UGC 已提交、审核裁决)发给订阅者,而订阅者什么也否决不了。

模型

文件是不透明的字节加上声明的元数据(来源、内容类型、大小),它不用来存放决策所依据的状态。它与游戏模型的关联方向相反:你的类型上有一个字段保存引用;文件本身对游戏一无所知。

文件种类声明什么。

声明项含义
origin创作内容、游戏生成或用户生成;大小限制和策略都由它决定
可接受的内容类型以声明的列表给出,从不根据字节嗅探
大小限制在打开会话时按声明的大小检查,而不是等到最后一块
分块大小与顺序上传通过会话完成:声明的分块大小、分块顺序、续传点
derivatives可选,由处理器生成的具名变体。每个声明的变体是否就绪都可观测,所以客户端从不用猜缩略图有没有生成
存储前缀位于保存引用的 schema 字段上:只说明字节存在哪里,别无其他。它不是目录:不能重命名、不能移动、前缀上没有权限,也没有递归操作。文件仍按 id 或 key 寻址,前缀不参与寻址

每个文件始终成立的事实。

始终成立含义
完成按会话幂等:重复完成返回同一个文件,而不是第二个
校验和必填,不匹配就拒绝,绝不悄悄接受损坏的字节
已发布的文件不可变:编辑即产生新版本,指向某个版本的引用始终指向原来的内容
创作内容按 key 加版本寻址,而且是 managed:不在管理控制台中编辑,因为它归代码所有
未完成的会话消亡过程可观测:超过截止时间后,平台终止它并发出事件,同时释放它的分块
所有权遵循所有者谓词:用户生成和游戏生成的文件和任何有主的行一样有所有者,所有者的文件遵守玩家删除策略:级联删除、拒绝或匿名化,需要声明,而不是默认假定
读取权限可以依赖权益(entitlement):付费资源由 Catalog & Commerce 的权益把关,而不是靠第二套权限系统

每个扩展点都指明它交给 hook 的类型:上传前是上传意图,提交后是提交本身,所以 hook 从不接收无类型的数据包。

错误

  • 没有权益时返回 not found,而不是 forbidden,这样拒绝列表不会暴露有哪些附加内容存在。已下架的文件也这样答复。
  • 会话已过期属于冲突:打开一个新会话。
  • 分块超出声明的顺序或大小、未声明的内容类型以及超出限制的大小都属于校验拒绝;其中大小问题在打开会话时就会报出,而不是等字节传完。
  • 校验和不匹配属于值得重试的校验拒绝:重新发送该分块。
  • 配额已用完属于冲突,释放空间后可以重试。
  • 读取授权(grant)已过期返回 not authenticated:申请新的授权,而不要把它当作权限问题。
  • 审核驳回是裁决,不是拒绝:提交已经过审议,答复是否定的,并附有声明的原因,所以下一步怎么做取决于原因。
  • 超出上传频率时返回限流类别的错误,并附截止时间。

限制

具体数值会随平台限制一章给出;触及每个上限时的行为现在就已确定。

  • 按来源划分的文件大小:平台在接受任何分块之前就拒绝上传,而不是等到最后一块。
  • 分块大小:以校验失败拒绝该分块。
  • 会话的有效期:转为 expired 并发出事件,释放分块。
  • 每个项目和每个玩家的存储配额:新会话以冲突拒绝,而且平台绝不为腾空间悄悄删除已发布的内容。
  • 保留的创作内容版本数:最旧的版本下架,但生效环境引用的版本绝不下架。
  • 每个主体的上传频率:限流,并给出截止时间。
  • 游戏生成内容的保留期:超过期限后下架,并发出事件。