玩家账号
给你的游戏玩家一个能在每一款用 Aukimi 制作的游戏里通用的账号,存档能从一台设备跟到另一台设备,可以用 Aukimi 的服务,也可以用你自己的服务器。
一句话概括#
一名玩家在 play.aukimi.com 登录一次,就能在每一款用 Aukimi 制作的游戏里找到这个账号,存档也在内。你的游戏只需要索要这个账号;它永远不会经手密码。
为什么你的游戏永远看不到密码#
任何人都可以发布一款游戏。一款索要 Aukimi 密码的游戏,会是一个非常有说服力的钓鱼页面,无论你自己抱着多好的意图,都改变不了另一个开发者能拿它做什么。
所以密码只在 play.aukimi.com 上输入,别处都不行。你的游戏调用
PlayerLoginAsync(),一个使用 Aukimi 自己配色方案的浏览器窗口会打开,玩家在里面确认,然后这个窗口把一个令牌交给你的游戏。
那个令牌只对你的游戏有效。把游戏 A 的令牌拿去游戏 B 那里用,会被拒绝,不是被忽略,是被拒绝。这正是关键所在:账号是在各个游戏之间共享的,访问权限不是。没有这一条,任何一款恶意游戏都能读取一名玩家在其他所有游戏里的存档。
关于一名玩家,你能拿到什么#
只有两样,不会更多:
| 你能拿到的 | 你永远拿不到的 |
|---|---|
一个显示名称(PlayerName()) | 邮箱地址 |
一个不透明的 id,在每款游戏里都不同(PlayerID()) | 任何能在别处识别出他们身份的信息 |
这个 id 会在不同游戏之间刻意变化。两个开发者拿各自的玩家名单相互比对,也看不出他们其实在看同一个人。这也让你只是这份数据的处理者,而不是控制者,这是一个法律上的区别,而不是装饰性的说法。
没有什么会阻塞游戏循环#
每一条与网络对话的命令返回的都是一个任务编号,从不返回结果本身。当请求还在飞行途中时,你的循环会继续以每秒六十帧的速度运行,这正是一款游戏无法简单地"等待服务器"的全部原因。
job = PlayerLoginAsync()
然后,每一帧都做一次:
etat$ = PlatformJobState(job)
if etat$ = "done"
resultat$ = PlatformJobResult(job)
PlatformJobRelease(job)
endif
if etat$ = "error"
Print(PlatformJobError(job))
PlatformJobRelease(job)
endif
读取结果之后,记得释放这个任务。 一个从未被释放的任务,会在整个会话剩余的时间里把它的结果一直留在内存中。没有什么会崩溃,也没有什么会报警,这个泄漏只会在一次很长的游玩会话里才显现出来。
不登录也能玩#
不是每个人都想在尝试一款游戏之前先注册。PlayerGuestAsync(name$) 会创建一个没有邮箱的账号,保存在这个浏览器里。
这是一个真实的账号:它可以保存,可以读取,之后也可以被认领,玩家添加一个邮箱和一个密码,已有的存档不需要迁移就能跟着走。
一个诚实的代价是:清除浏览器的站点数据会丢失这个账号,而且无法找回。这是"无需注册"要付出的代价,你的游戏应该主动说明,而不是让玩家自己去发现。PlayerIsGuest() 就是为了让你能说出这句话而存在的。
保存#
job = PlayerSaveAsync(donnees$)
job = PlayerLoadAsync()
存档是你自己定义结构的 JSON,按(这名玩家,这款游戏)这一对来存放。它会跟着玩家去到另一个浏览器、另一台机器、另一个平台。
对少量数值来说,SetCloudDataVariable() 和 CloudDataVariable() 更简单,不需要任务,不需要 JSON。先检查 CloudDataAllowed():没有玩家登录时它会返回 0,这时候硬写只会悄悄地什么都不发生。
绘制屏幕#
Aukimi不会绘制任何登录表单。一个由引擎拼出来的表单,看起来总会像一个硬塞进像素风游戏里的网页,而且会是你的游戏里唯一一个你无法重新设计外观的部分。
由你自己来画。唯一必须来自浏览器的部分是文字输入,所以
CreateEditBox() 会给你一个真实的输入框,由你自己来定位:
CreateEditBox(1)
EditBoxPosition(1, 20, 42)
EditBoxSize(1, 60, 8)
nom$ = EditBoxText(1)
Engine 示例列表里的 Player Account 演示,是一个大约一百行就能完成的完整可用界面。打开它,读一读,然后把每一个 Print() 换成你自己的美术素材。
在这一切生效之前#
这款游戏必须先在你的 Aukimi 账号下声明,在我的游戏里(aukimi.com/app/games):一个标题,可以游玩的地址,以及你的游戏被允许跳转回去的地址。在此之前 PlayerAvailable() 会返回 0,一款好的游戏应该说"账号功能当前不可用",而不是表现得像卡住了一样。
同一个界面也是你能看到谁在游玩的地方。要按数字本来的含义去理解它:它统计的是登录过的玩家和开始过的会话,永远不是被游玩过的次数。一款从不要求任何人登录的游戏,在那里什么都不会显示,而且界面会说明这一点,而不是显示一个沉默的零。
使用你自己的账号服务器#
以上一切都是在和 Aukimi 自己的账号服务对话。如果你把游戏托管在自己的服务器上,同样这十二个命令可以改为和你自己的服务对话,而且你的脚本一行都不需要改。
在 Engine 里:Multiplayer 面板 → Player accounts 分区 → 选择 My own server → 填一个字段,也就是你的服务器地址。这个地址是场景的一个设置项,所以两款游戏可以使用两个不同的服务。
让一名玩家登录的两种方式#
选择在你的脚本里做出,而不是在某个设置项里。不管你指向的是哪个服务器,两种形式都存在。
弹窗(你已经了解的那种)#
job = PlayerLoginAsync()
一个窗口会打开,玩家在里面登录,然后它把代表玩家行事的权利交还给你的游戏。使用 Aukimi 的服务时,那个窗口就是
play.aukimi.com。换成你自己的服务器时,那个页面就由你自己来搭建。
直接用用户名和密码#
job = PlayerLoginAsync(username$, password$)
没有弹窗。你的游戏读取玩家在你自己界面上输入的内容,并把它发送给你的服务器。写起来更简单,也能让你的游戏从头到尾保持自己的视觉风格。
警告: 在这种形式下,密码会原样从你的游戏传输到你的服务器。这带来两个坚定不移的后果:你的服务器地址必须以
https://开头(只有在你自己机器上做的测试可以例外),而且这种形式只应该用于你自己拥有的服务器。
play.aukimi.com会拒绝这种形式,这是刻意的。Aukimi 永远不想处于能看到玩家密码的位置,你没有编写的另一款游戏,同样不应该处于那个位置。
这两种选择与设置项本身是相互独立的:一个自建服务器完全可以只提供弹窗方式,永远不直接接受密码。
一个具体的例子#
一款拥有自己"登录"界面的游戏:两个由游戏自己绘制的文本框,一个按钮,以及按钮背后的这段逻辑。
job = PlayerLoginAsync(typedName$, typedPassword$)
DO
etat$ = PlatformJobState(job)
if etat$ = "done"
PlatformJobRelease(job)
Print("Welcome " + PlayerName())
endif
if etat$ = "error"
PlatformJobRelease(job)
Print(PlayerLastError())
endif
Sync()
LOOP
登录完成之后的一切,都和这页其他内容完全一样:
PlayerSaveAsync、PlayerLoadAsync、PlayerName 以及其他命令都不知道、也不关心是哪个服务器给出的应答。
你的服务器必须回答什么#
不管你提供哪种登录方式,都需要四个地址:
| 用途 | 游戏期待什么 |
|---|---|
| 以访客身份游玩 | 交还一个没有邮箱的账号 |
| 这名玩家是谁 | 显示名称、id,以及是否是访客 |
| 读取存档 | 已保存的数据 |
| 写入存档 | 存储游戏发来的内容 |
然后,视你选择提供哪种方式而定:
- 弹窗方式需要你服务器上有一个登录页面,完成后告诉游戏是谁登录了。
- 直接方式需要再多一个地址,接收一个用户名和一个密码,并回答是或否。
具体的地址、每个应答的结构,以及错误码,都在代码仓库的 docs/engine-player-accounts.md 里。那份文件是契约本身;这一页是地图。
注意: 这一切在原生导出中都不生效。
Player*系列命令需要一个可以对话的服务器和一个可以传递信息的浏览器,而一次原生构建两者都没有。
命令一览#
| 命令 | 作用 |
|---|---|
PlayerAvailable() | 这款游戏是否配置了账号服务 |
PlayerLoginAsync() | 打开登录窗口,返回一个任务 |
PlayerLoginAsync(user, pass) | 直接登录,仅限你自己的服务器,返回一个任务 |
PlayerGuestAsync() | 创建一个没有邮箱的账号,返回一个任务 |
PlayerLoggedIn() | 此刻是否有人登录着 |
PlayerName() | 他们的显示名称 |
PlayerID() | 他们在这款游戏里专属的不透明 id |
PlayerIsGuest() | 这是否是一个没有邮箱的账号 |
PlayerSaveAsync() | 写入存档,返回一个任务 |
PlayerLoadAsync() | 读回存档 |
PlayerRefreshAsync() | 在令牌过期前刷新它 |
PlayerLogout() | 在这台设备上登出 |
PlayerLastError() | 上一次调用失败的原因 |