正在读取容器状态
选择模式
是否关注:读取中
我的称号
排行榜
看看你这次跳到了哪里
开发示例
从接入到完整源码,拆解这款 Toy 怎样使用 SDK。
不需要安装 npm 包。引入官方脚本后,页面会得到一个 window.toy 对象,再调用它提供的方法。
第 1 步:引入 SDK
把下面这行放进 HTML。这里使用 defer,表示 HTML 解析完成后再执行脚本。
<script defer
src="https://s1.hdslb.com/bfs/seed/toy/app/sdk/toy-sdk.js">
</script>
第 2 步:先判断能力是否存在
本地直接打开 HTML、网络异常或旧版本环境里,方法可能暂时不可用。调用前先检查,页面就不会因为一个 SDK 错误整体卡住。
if (
window.toy &&
typeof window.toy.getCloudStorage === 'function'
) {
const data = await window.toy.getCloudStorage()
}
第 3 步:选对能力
- 云存档
- 保存当前登录用户自己的记录。适合最高分、称号、设置、测试结果和游戏进度。
- 排行榜
- 把用户成绩放进公共榜单比较。适合分数、关卡、竞速和限时挑战。
- 关系与视频
- 读取用户与作者的关系、作者视频数据和当前用户的视频互动,把实时数据转成皮肤、道具和关卡条件。
云存档按“登录用户 + 当前 Toy”隔离,不能读取其他用户的数据,也不能用来统计总参与人数。
这款 Toy 实际用了什么
- 启动时:一次读取最高分、称号、道具次数和三个存档槽位。
- 游戏中:刷新最高分、解锁称号;暂停后可把整局状态写进指定槽位。
- 结算时:按游戏模式提交成绩,再读取自己的总榜名次。
- 排行榜页:同时读取前 50 名和我的名次,支持总榜、日榜、周榜、月榜。
- 粉丝关系与视频数据:完整讲解充电解锁模式、播放量解锁关卡、点赞送火箭和关注送皮肤;当前游戏只实际启用关注送皮肤。
常见错误怎么处理
- 云存档和提交成绩要求用户登录;失败时不要阻塞游戏本身。
- 所有 SDK 方法都返回 Promise,要使用
await或.then()。 - 云存档的 value 必须是字符串;数字用
String(),对象用JSON.stringify()。 - 不要把排行榜当数据库。它只保留每个用户在该榜单中的最高 score。
把云存档想成“每位用户独有的一小张键值表”:key 是字段名,value 是字符串。
从零实现最高分
1给最高分取一个固定 key
要做什么:先决定云端用什么名字保存最高分。本例使用 best_score。以后读取和写入必须使用同一个名字。
// 云端字段名:相当于给抽屉贴标签
const CLOUD_KEYS = {
best: 'best_score'
}
// 页面刚打开时还不知道云端成绩,先用 0
const state = {
best: 0,
pendingBestTimer: 0
}
2确认 SDK 可以使用
要做什么:在调用前检查 window.toy 和具体方法。本地试玩或 SDK 暂时不可用时,游戏不会因此停止。
function canUseCloudStorage() {
// window.toy 是 SDK 提供的全局对象
return Boolean(
window.toy &&
typeof window.toy.getCloudStorage === 'function' &&
typeof window.toy.setCloudStorage === 'function'
)
}
3页面启动时读取旧最高分
要做什么:读取 best_score。云存档返回的 value 是字符串,所以要用 Number() 转成数字;新用户没有记录时使用 0。
async function loadBestScore() {
// SDK 不可用时保留默认值 0
if (!canUseCloudStorage()) return
try {
// 返回示例:{ best_score: '268' }
const data = await window.toy.getCloudStorage([
CLOUD_KEYS.best
])
// 没有这个 key 时使用字符串 '0'
const savedBest = data[CLOUD_KEYS.best] || '0'
// 云存档是字符串,游戏里需要数字
state.best = Number(savedBest) || 0
// 把结果显示到首页和游戏 HUD
updateHud()
} catch (error) {
// 读取失败仍允许继续玩
console.warn('最高分读取失败', error)
}
}
4每次分数增加时比较
要做什么:当前分数没有超过旧纪录时什么都不做;只有破纪录才更新页面并准备写入云端。
function noteScore(currentScore) {
// 没破纪录,不更新,也不请求云端
if (currentScore <= state.best) return
// 先立即更新页面里的最高分
state.best = currentScore
updateHud()
// 再把新纪录保存到云端
queueBestSave(currentScore)
}
5把新纪录写成字符串
要做什么:连续向上跳时分数变化很快。等待 700 毫秒再写;期间出现更高分就取消上一次等待,只保存最新值。
function queueBestSave(newBest) {
// 取消上一次还没执行的保存
clearTimeout(state.pendingBestTimer)
state.pendingBestTimer = setTimeout(async () => {
try {
await window.toy.setCloudStorage({
// value 必须是字符串,所以使用 String()
[CLOUD_KEYS.best]: String(newBest)
})
} catch (error) {
console.warn('最高分保存失败', error)
}
}, 700)
}
最高分完成:进入 Toy 时读取旧纪录,游戏中破纪录时覆盖保存。每个人读取和写入的都是自己的 best_score。
完整项目使用的全部 key
key 应该稳定、易懂,发布后尽量不要改名。本例用了下面这些 key。
const CLOUD_KEYS = {
best: 'best_score',
title: 'title',
titles: 'titles',
rocketUses: 'rocket_uses',
brokenBlocks: 'broken_blocks',
saveSlot1: 'save_slot_1',
saveSlot2: 'save_slot_2',
saveSlot3: 'save_slot_3'
}
完整项目一次读取所有数据
一次传入多个 key,比逐个读取更简单。没有命中的 key 不会出现在结果里,所以读取时要准备默认值。
const data = await window.toy.getCloudStorage([
CLOUD_KEYS.best,
CLOUD_KEYS.titles,
CLOUD_KEYS.rocketUses,
CLOUD_KEYS.brokenBlocks,
CLOUD_KEYS.saveSlot1,
CLOUD_KEYS.saveSlot2,
CLOUD_KEYS.saveSlot3
])
state.best = Number(data[CLOUD_KEYS.best] || '0') || 0
state.titles = JSON.parse(data[CLOUD_KEYS.titles] || '[]')
真实项目的防抖写入
先和内存里的最高分比较。没有超过就不写,超过后延迟 700 毫秒合并频繁更新,减少请求。
function noteScore(score) {
if (score <= state.best) return
state.best = score
clearTimeout(state.pendingBestTimer)
state.pendingBestTimer = setTimeout(() => {
window.toy.setCloudStorage({
[CLOUD_KEYS.best]: String(score)
})
}, 700)
}
用云存档保存游戏进程
1列出恢复游戏需要的数据
要做什么:不是保存整个网页,而是保存“重新搭出这一局”所需的最小状态。本例需要分数、跳跃次数、镜头位置、玩家、平台和障碍物。
function createSaveSnapshot() {
return {
version: 1, // 存档格式版本
savedAt: Date.now(), // 保存时间
width: state.width, // 保存时的屏幕宽度
height: state.height, // 保存时的屏幕高度
score: state.score, // 当前分数
jumps: state.jumps, // 已跳次数
cameraY: state.cameraY, // 镜头滚动位置
startY: state.startY, // 本局起点
minPlatformY: state.minPlatformY,
player: serializePlayer(state.player),
platforms: state.platforms.map(serializePlatform),
hazards: state.hazards.map(serializeHazard)
}
}
真实游戏只保留玩家附近最多 18 个平台和 6 个障碍物,远处内容可以在恢复后继续生成。
2给三个槽位分配三个 key
要做什么:每个槽位对应一个独立 key。写槽位 2 时只覆盖 save_slot_2,不会影响另外两个槽位。
const SAVE_SLOT_KEYS = [
'save_slot_1', // 下标 0:槽位 1
'save_slot_2', // 下标 1:槽位 2
'save_slot_3' // 下标 2:槽位 3
]
3把对象编码成短字符串
要做什么:云存档只能写字符串,而且单个 value 有容量限制。真实代码把数字取整、把字段名换成固定顺序,并逐步减少远处平台,直到不超过 1024 字节。
function encodeSaveSlot(snapshot) {
// 先尝试保存 18 个平台,再逐步减少
for (let platformLimit = 18; platformLimit >= 8; platformLimit -= 2) {
const encoded = encodeCompactSave(
snapshot,
platformLimit,
6 // 最多保存 6 个障碍物
)
// 计算字符串实际占用的 UTF-8 字节数
const bytes = new TextEncoder().encode(encoded).length
if (bytes <= 1024) return encoded
}
// 极端情况下只保留最近的 6 个平台
return encodeCompactSave(snapshot, 6, 0)
}
Base64 不是压缩,通常会让内容变大。本例使用紧凑字符串编码;完整的 encodeCompactSave() 在“完整源码”里。
4点击保存时写入对应槽位
要做什么:暂停游戏,生成快照,编码,然后把结果写进用户选择的 key。
async function saveCurrentSlot(slotIndex) {
// slotIndex 只能是 0、1、2
const key = SAVE_SLOT_KEYS[slotIndex]
if (!key || !state.player) return
// 先暂停,避免生成快照时状态继续变化
if (state.mode === 'playing') pauseGame()
const snapshot = createSaveSnapshot()
const encoded = encodeSaveSlot(snapshot)
await window.toy.setCloudStorage({
// key 的值会成为真正的云存档字段名
[key]: encoded
})
showToast(`槽位 ${slotIndex + 1} 已保存`)
}
5启动时读取三个槽位
要做什么:把三个 key 一次读回,再将字符串解析成对象,放进内存里的 state.saveSlots。
async function loadSaveSlots() {
const data = await window.toy.getCloudStorage(
SAVE_SLOT_KEYS
)
state.saveSlots = SAVE_SLOT_KEYS.map((key) => {
const encoded = data[key] || ''
// 空字符串表示这个槽位还没有存档
if (!encoded) return null
// 把紧凑字符串还原成游戏对象
return parseSaveSlot(encoded)
})
updateSaveSlotsUI()
}
6点击读取时恢复游戏
要做什么:先检查存档,再把保存的值逐项放回游戏状态。恢复后重新进入游戏循环,玩家就能从保存处继续。
function loadSaveSlot(slotIndex) {
const save = state.saveSlots[slotIndex]
if (!save) {
showToast(`槽位 ${slotIndex + 1} 为空`)
return
}
// 检查存档,并适配当前屏幕宽度
const restored = normalizeSaveForScreen(save)
if (!restored) {
showToast('存档读取失败')
return
}
// 把存档内容放回当前游戏状态
state.score = restored.score
state.jumps = restored.jumps
state.cameraY = restored.cameraY
state.player = restored.player
state.platforms = restored.platforms
state.hazards = restored.hazards
// 回到游戏循环继续玩
setMode('playing')
state.lastTime = performance.now()
}
游戏进程存储完成:保存按钮负责“状态变快照,再变字符串”;读取按钮负责“字符串变对象,再放回游戏状态”。三个 key 就是三个互不覆盖的存档槽位。
真实项目还做了哪些保护
- 云端请求失败会重试三次,再回退到当前浏览器的
localStorage。 - 存档带
version,格式变化后可以识别旧存档。 - 读取时检查数字范围、平台类型和屏幕宽度,避免坏数据破坏游戏。
- 限时挑战读取后按经典模式继续,避免恢复旧计时器产生不公平成绩。
补充:用 JSON 保存多个称号
云存档不能直接写数组,因此保存时转成 JSON 字符串,读取时再还原。解锁过的称号先去重,避免重复写入。
function unlockTitle(title) {
if (state.titles.includes(title)) return
state.titles.push(title)
window.toy.setCloudStorage({
title,
titles: JSON.stringify(state.titles)
})
}
// 第 10 次跳跃时解锁
if (state.jumps >= 10) {
unlockTitle('第一次跳')
}
补充:槽位入口的简化代码
先把玩家、平台、障碍物、分数等状态做成快照,再编码成短字符串。每个槽位使用独立 key,所以互不覆盖。
const SAVE_SLOT_KEYS = [
'save_slot_1',
'save_slot_2',
'save_slot_3'
]
async function saveCurrentSlot(slotIndex) {
const snapshot = createSaveSnapshot()
const encoded = encodeSaveSlot(snapshot)
await window.toy.setCloudStorage({
[SAVE_SLOT_KEYS[slotIndex]]: encoded
})
}
function loadSaveSlot(slotIndex) {
const save = state.saveSlots[slotIndex]
if (save) restoreSaveSlot(save)
}
为什么存档没有直接用 Base64
Base64 只是换一种字符表示,通常还会让内容变大,并不会自动压缩。本例使用短字段、整数取整和分隔符,把完整快照压到单个 value 的 1024 字节限制内;读取时再解码回对象。
云端失败时如何不影响试玩
真实源码里的 callCloud() 最多重试三次;仍失败时回退到 localStorage。本地数据只能保存在当前浏览器,云端数据才能跟随登录账号同步。完整实现可在“完整源码”中查看。
排行榜固定按 score 从大到小排列。同一用户重复提交时,只保留更大的 score。
做出第一个排行榜
1决定榜位 1 的含义
要做什么:Toy 已经提供 board: 1、2、3 三个榜位,不需要额外创建。我们约定榜位 1 是“经典分数榜”。名称由页面展示,SDK 只接收数字 1。
// 第一个榜位:经典分数榜
const SCORE_BOARD = 1
// 这是页面上的名称,不需要传给 SDK
const SCORE_BOARD_NAME = '经典分数榜'
2确定什么数字算成绩
要做什么:本游戏用玩家跳到的最高高度作为 score。提交值必须是整数,所以结算时取本局最高高度的整数值。
function getResultScore() {
// 当前高度和本局峰值中取更大的一个
const highest = Math.max(
state.score,
state.runPeakScore
)
// 排行榜成绩使用整数
return Math.floor(highest)
}
3提交前检查 SDK 和成绩
要做什么:提交成绩需要用户登录。代码还要检查方法是否存在、成绩是否是正整数;不满足时只跳过排行榜,不能让游戏结算失败。
function canSubmitScore(score) {
// 检查 SDK 对象和提交方法
const sdkReady = Boolean(
window.toy &&
typeof window.toy.submitScore === 'function'
)
// 本例只提交大于 0 的整数
const scoreReady = Number.isInteger(score) && score > 0
return sdkReady && scoreReady
}
4游戏结束后提交成绩
要做什么:把榜位和成绩一起传给 submitScore()。SDK 会写入当前登录用户的成绩;如果这个用户以前的成绩更高,旧成绩继续保留。
async function submitClassicScore(score) {
if (!canSubmitScore(score)) return
try {
await window.toy.submitScore({
board: SCORE_BOARD, // 这里等于 1
score: score // 例如 268
})
} catch (error) {
// 排行榜失败不影响重新开始游戏
console.warn('成绩提交失败', error)
}
}
5提交后查询我的总榜名次
要做什么:submitScore()只负责提交。要告诉用户“本次第几名”,还要调用 getMyRank()。判断是否上榜必须看 ranked。
async function showMyResultRank(score) {
const mine = await window.toy.getMyRank({
board: SCORE_BOARD,
period: 'all' // all 表示总榜
})
if (!mine.ranked) {
showToast('暂未进入排行榜')
return
}
if (mine.score === score) {
// 榜上成绩等于本局成绩:本局刷新了纪录
showToast(`本次排名第 ${mine.rank}!`)
} else {
// 榜上成绩更高:历史纪录仍然更好
showToast(`历史最好排名第 ${mine.rank}`)
}
}
6读取前 50 名和我的名次
要做什么:用户打开排行榜页面时,同时请求公共榜单和个人名次。两个请求互不依赖,所以使用 Promise.all() 并行完成。
async function loadScoreBoard(period = 'all') {
const [list, mine] = await Promise.all([
// 游客也能读取公共榜单
window.toy.getRankList({
board: SCORE_BOARD,
period: period,
limit: 50
}),
// 我的名次需要用户登录
window.toy.getMyRank({
board: SCORE_BOARD,
period: period
})
])
renderRankList(list)
renderMyRank(mine)
}
7把返回结果显示到页面
要做什么:每条数据包含名次、成绩、昵称和头像。使用 textContent 写昵称,避免把用户文字当成 HTML 执行。
function renderRankList(list) {
// 先清空上一次显示的榜单
rankList.replaceChildren()
list.forEach((entry) => {
const row = document.createElement('li')
const name = document.createElement('span')
const score = document.createElement('strong')
// entry.rank 是名次
row.dataset.rank = String(entry.rank)
// textContent 可以安全显示用户昵称
name.textContent = entry.nickname || 'B站用户'
score.textContent = `${entry.score} 分`
row.append(name, score)
rankList.appendChild(row)
})
}
8把整个流程接到游戏结算
要做什么:游戏结束后先展示“正在计算排名”,再提交成绩并查询名次。异步请求不会阻塞结算首页出现。
async function finishClassicGame() {
const resultScore = getResultScore()
// 先立即告诉用户本局成绩
menuCopy.textContent =
`本次高度 ${resultScore},正在计算排名...`
if (!canSubmitScore(resultScore)) return
try {
// 第一步:提交成绩
await window.toy.submitScore({
board: SCORE_BOARD,
score: resultScore
})
// 第二步:查询并展示名次
await showMyResultRank(resultScore)
} catch (error) {
menuCopy.textContent =
`本次高度 ${resultScore},排名读取失败`
}
}
第一个排行榜完成:游戏结束会提交经典分数;首页提示本次或历史最好名次;排行榜页面可以读取前 50 名以及我的排名。
同一个榜单展示四个 period
下面用四个页签展示同一个 board: 1 的总榜、月榜、周榜和日榜。四个 period 共用一套列表,点击页签时再读取对应周期。
1准备四个周期和页面容器
要做什么:先把 SDK 使用的 period 和用户看到的中文名称放在一起。页面只需要一个页签容器、一行“我的名次”和一个公共榜单列表。
<nav id="periodTabs" aria-label="排行榜周期"></nav>
<p id="periodMyRank">我的排名加载中</p>
<ol id="periodRankList"></ol>
<script>
const SCORE_BOARD = 1
const PERIODS = [
{ value: 'all', label: '总榜' },
{ value: 'month', label: '月榜' },
{ value: 'week', label: '周榜' },
{ value: 'day', label: '日榜' }
]
</script>
2生成四个 period 页签
要做什么:根据配置生成按钮,不要手写四套榜单。按钮只保存 period,点击后统一交给 selectPeriod() 读取。
const periodTabs = document.getElementById('periodTabs')
PERIODS.forEach(({ value, label }) => {
const button = document.createElement('button')
button.type = 'button'
button.dataset.period = value
button.textContent = label
button.addEventListener('click', () => {
selectPeriod(value)
})
periodTabs.appendChild(button)
})
3用同一个 board 读取所选 period
要做什么:board 始终是 1,只替换 period。公共榜单和我的名次可以并行读取;读取完成后更新选中态和页面内容。
async function selectPeriod(period) {
periodTabs.querySelectorAll('button').forEach((button) => {
const active = button.dataset.period === period
button.classList.toggle('is-active', active)
button.setAttribute('aria-selected', String(active))
})
const [entries, mine] = await Promise.all([
window.toy.getRankList({
board: SCORE_BOARD,
period,
limit: 50
}),
window.toy.getMyRank({
board: SCORE_BOARD,
period
})
])
renderPeriodList(entries)
renderPeriodMyRank(mine)
}
4渲染列表并默认打开总榜
要做什么:切换周期时先清空旧列表,再显示新结果。页面首次打开调用 selectPeriod('all'),默认展示总榜。
const periodRankList =
document.getElementById('periodRankList')
const periodMyRank =
document.getElementById('periodMyRank')
function renderPeriodList(entries) {
periodRankList.replaceChildren()
entries.forEach((entry) => {
const row = document.createElement('li')
row.textContent =
`第 ${entry.rank} 名 · ${entry.nickname || 'B站用户'} · ${entry.score} 分`
periodRankList.appendChild(row)
})
}
function renderPeriodMyRank(mine) {
periodMyRank.textContent = mine.ranked
? `我的排名 ${mine.rank} · ${mine.score} 分`
: '当前周期暂未上榜'
}
// 页面首次打开时默认展示总榜
selectPeriod('all')
四个周期展示完成:四个页签始终读取同一个 board;切换页签只改变 period,公共榜单和我的名次会同步更新。
从第一个榜单扩展到三个
三个榜位的对应关系
- board 1
- 经典分数榜:跳得越高,score 越大。
- board 2
- 300 分竞速榜:提交负毫秒数,让用时短的人靠前。
- board 3
- 1 分钟挑战榜:60 秒内跳到的最高分。
board 只是 1、2、3 三个固定槽位。榜单名称和玩法含义由 Toy 自己决定。
最小提交和查名次代码
游戏结束后提交整数成绩。提交成功表示后端已经接收,但页面如果要展示名次,还要继续调用 getMyRank()。
await window.toy.submitScore({
board: 1,
score: resultScore
})
const mine = await window.toy.getMyRank({
board: 1,
period: 'all'
})
切换周期时的读取代码
公共榜单游客也能读取;“我的名次”需要登录。两项互不依赖,可以并行请求,减少等待时间。
const [list, mine] = await Promise.all([
window.toy.getRankList({
board: 1,
period: 'week',
limit: 50
}),
window.toy.getMyRank({
board: 1,
period: 'week'
})
])
if (mine.ranked) {
console.log(`我的排名:${mine.rank}`)
}
判断是否上榜要看 ranked,不能用 score 是否为 0,因为榜单允许 0 和负数。
4. 竞速榜为什么提交负数
12.40 秒等于 12400 毫秒。排行榜只能从大到小,所以提交 -12400。10 秒提交 -10000,而 -10000 大于 -12400,于是更快的人自然排在前面。
const elapsedMs = 12400
await window.toy.submitScore({
board: 2,
score: -elapsedMs
})
function formatRaceTime(score) {
const milliseconds = Math.abs(score)
return `${(milliseconds / 1000).toFixed(2)} s`
}
真实项目怎样判断本次是否刷新纪录
提交后读取总榜,并比较榜上成绩和本次成绩。如果相等,本次刷新了纪录;如果榜上成绩更大,说明以前的最好成绩仍然更高。
await window.toy.submitScore({ board, score })
const mine = await window.toy.getMyRank({
board,
period: 'all'
})
if (mine.ranked && mine.score === score) {
showToast(`本次排名第 ${mine.rank}!`)
} else if (mine.ranked) {
showToast(`历史最好排名第 ${mine.rank}`)
}
四种 period 分别是什么
all:总榜,永久累计。month:月榜。week:周榜。day:日榜。
排行榜不能用来做什么
它不是全局数据库,不能准确统计总参与人数、答题次数或题目正确率;读取榜单最多返回前 100 名,同一用户也只保留最高成绩。
这款 Toy 用容器控制 SDK 演示完整闭环:首页恢复竖屏普通模式,开始游戏时进入沉浸,游戏中再由按钮切换横竖屏。
先监听实际状态,再发起切换
1订阅容器状态
端侧会回传实际可用尺寸、方向、沉浸状态和安全区。布局始终以回调结果为准,不假定切换请求一定生效。
const off = toy.onContainerChange((state) => {
game.dataset.orientation = state.orientation
game.dataset.immersive = String(state.immersive)
relayout(state.viewport, state.safeArea)
})
2进入游戏时打开沉浸
首页初始化为竖屏普通模式。选择游戏模式后只打开沉浸,方向继续保持竖屏。
await toy.setContainerMode({ immersive: true })
3游戏内切换横竖屏
手机横屏必须同时保持沉浸,所以横屏和竖屏按钮都把两个字段一次下发,避免中间状态闪烁。
await toy.setContainerMode({
orientation: 'landscape',
immersive: true
})
await toy.setContainerMode({
orientation: 'portrait',
immersive: true
})
4回到首页时一次恢复
await toy.setContainerMode({
orientation: 'portrait',
immersive: false
})
这三项能力目前仅 B站 App 支持:onContainerChange、getContainerState、setContainerMode。Web 环境会保留游戏玩法,但不执行容器切换。
这里完整讲解粉丝关系、作者视频数据和当前用户视频互动怎样转成玩法条件。当前游戏只实际启用“关注作者送皮肤”,其他能力作为可复制的接入示例保留。
4 个功能分别怎么做
充电解锁模式
getAuthorRelation()判断:isCharging 是否为真。
玩法:已充电可进入专属模式;未充电保持锁定,并提供清楚的解锁提示。
视频播放量破百
getAuthorVideos()判断:指定视频的 stat.view 是否达到目标。
玩法:播放量达到 100 后开放挑战,可把目标值替换成适合自己内容的里程碑。
点赞视频送火箭
getVideoUserActions()判断:指定视频的 liked 是否为真。
玩法:首次满足时保存奖励,以后进入经典模式自带一次火箭。
关注作者送皮肤
getAuthorRelation()判断:isFollowing 是否为真。
玩法:首次满足时弹出奖励提示并保存,后续自动使用“追更披风”。
读取、判断、保存
1进入玩法页时并行读取
关系、视频和互动状态互不依赖,使用 Promise.allSettled 并行读取。某一项失败时只关闭对应玩法,不阻塞基础体验。
const VIDEO_AID = 116951305164019
const [relation, videos, actions] = await Promise.allSettled([
toy.getAuthorRelation(),
toy.getAuthorVideos({ videos: [{ aid: VIDEO_AID }] }),
toy.getVideoUserActions({ aids: [VIDEO_AID] })
])
// 分别处理三项结果,不让单项失败阻塞基础玩法
2实时数据直接控制玩法
充电状态、关注状态和播放量每次进入相关页面时重新读取;实时条件只控制界面状态,不要把“读取失败”当成“未满足”。
const relationData = relation.value?.data
const videoData = videos.value?.items?.find(
item => item.status === 'ok'
)?.data
chargingModeButton.disabled = !relationData?.isCharging
videoModeButton.disabled =
(videoData?.stat?.view || 0) < 100
followStatus.textContent = relationData?.isFollowing
? '是否关注:已关注'
: '是否关注:未关注'
3一次性奖励写入云存储
点赞火箭和关注皮肤可以设计成永久奖励。SDK 负责判断当前状态,云存储负责记住奖励已经领取。
const relationData = relation.value?.data
const action = actions.value?.items?.find(
item => item.aid === VIDEO_AID && item.status === 'ok'
)
if (action?.liked && !likeRocketUnlocked) {
await toy.setCloudStorage({ like_rocket_unlocked_v1: '1' })
openToyModal({ icon: '🚀', title: '开局火箭已到账' })
}
if (relationData?.isFollowing && !followSkinUnlocked) {
await toy.setCloudStorage({ follow_skin_unlocked_v1: '1' })
openToyModal({ icon: '🎨', title: '追更披风已到账' })
}
4跳转必须由用户点击
关注入口跳作者主页,互动入口跳目标视频;用户返回后再重新读取对应状态。
await toy.navigate({
type: 'space',
id: '1211412540'
})
await toy.navigate({
type: 'video',
id: 'BV1kNKU6REBg'
})
这里有两个动作。用户点“分享这个页面”以后,toy.share() 会叫出 B站 App 的分享面板。页面上的二维码直接通过 toy.getQrCode() 生成,不需要再安装二维码库。
分享当前 Toy 内的详情页
1告诉 App 要分享哪个页面
toy.share() 只收当前 Toy 里的相对路径。这里传入排行详情页和榜单参数。完整地址会由 Toy 宿主页补好,作者不用自己拼域名。
const path = 'rank-detail.html?board=1&period=all'
if (await toy.isSupport('share')) {
await toy.share({ path })
}
2先看看当前环境能不能分享
分享面板目前只在支持这项能力的 B站 App 内可用。调用前先用 isSupport('share') 检查。手机浏览器和电脑浏览器不支持时,页面直接告诉用户去 B站 App 里打开。
try {
const supported = await toy.isSupport('share')
if (!supported) throw new Error('unsupported')
await toy.share({ path })
} catch (error) {
shareStatus.textContent =
'当前环境不支持分享,请在 B站 App 内打开'
}
让另一台手机扫码进入详情页
1先留一个显示二维码的位置
toy.getQrCode() 返回一张 PNG 图片,可以直接放进 img。页面不需要加载 qrcode 包。
<img id="qrImage" width="184" height="184"
alt="排行详情页二维码" />
<script defer src="./rank-detail.js"></script>
2把 Toy 内的页面交给 SDK
只传当前 Toy 内的相对路径和图片尺寸,完整访问地址与二维码都由平台生成。base64 可以直接赋给 img.src,url 是二维码实际编码的页面地址。
try {
const { base64, url } = await toy.getQrCode({
path: 'rank-detail.html?board=1&period=all',
size: 184
})
document.getElementById('qrImage').src = base64
console.log('扫码会打开', url)
} catch (error) {
shareStatus.textContent = '二维码生成失败,请稍后重试'
}
扫码后会看到什么
另一台手机会进入排行详情页。页面随后按扫码者当前登录的 B站账号调用 getMyRank()。对方看到的是自己的名次和成绩,不会看到生成二维码的人当时的成绩。
这里展示的文件就是本次发布包里的文件。游戏页面、排行详情和二维码 SDK 调用都能直接打开查看,不再包含第三方二维码库。
打开本页签后读取源码
当前运行代码只启用关注奖励;本页同时保留关系与视频数据的完整接入示例。SDK 引入位置在 index.html,运行逻辑在 game.js。