SDK 参考
以下内容均来自软件包的主入口。宿主方法调用均会返回异步结果。每次宿主调用都会先在你的框架内检查能力和输入;若检查失败,在发送任何内容前就会返回 FORBIDDEN 或 INVALID_INPUT。本地读取方法和订阅会同步完成。
连接
import {connect, applyTheme, SDKError} from '@zenclub/sdk'
connect(options: {
pluginId: string
version: string // the manifest version
allowedHostOrigins: string[] // exact origins, such as 'https://zenclub.app'
hostApi?: {major: number, minMinor: number} // default {major: 1, minMinor: 0}
timeoutMs?: number // default 10000
}): Promise<ZenClubClient>从 URL 片段读取宿主源站和实例 ID,根据 allowedHostOrigins 检查源站并完成握手。成功时返回客户端;失败时会以 NOT_EMBEDDED、HOST_DENIED、HANDSHAKE_REJECTED、INCOMPATIBLE_API 或 HANDSHAKE_TIMEOUT 拒绝(超时由 timeoutMs 决定,默认 10 秒)。
上下文
sdk.context: {
pluginId: string, instanceId: string, sessionId: string
protocol: 'zenclub.rpc/1'
hostApi: {major: 1, minor: 6} // undeclared legacy manifests receive 1.4 or 1.5
permissions: string[] // the granted capabilities
theme: ThemeSnapshot
sound?: {enabled: boolean} // only for 1.5 manifests, once a preference was chosen
progression?: {enabled: boolean, importExisting: boolean} // approved city source only
storageMode: 'postgres' | 'device' | 'memory' | 'development'
user?: {displayName: string} | null
locale?: string // a BCP 47 tag, such as 'ja-JP' or 'zh-TW'
timeZone?: string // such as 'Asia/Tokyo'
entitlements?: {tier: 'free' | 'basic' | 'pro'}
campaign?: {source?: string, medium?: string, campaign?: string} | null
}
sdk.supports(capability: string): booleansdk.context 描述当前会话。storageMode 表示存档位置:存于觉城服务器时为 postgres,加密后留在此浏览器时为 device;测试宿主使用 memory 或 development。旧版宿主可能缺少可选字段,读取时请提供备用值。
主题
sdk.theme.current(): ThemeSnapshot
sdk.theme.subscribe(listener: (theme: ThemeSnapshot) => void): () => void
applyTheme(theme: ThemeSnapshot, root?: HTMLElement): void
ThemeSnapshot: {
themeId: string
mode: 'light' | 'dark'
modePreference?: 'light' | 'dark' | 'system'
reducedMotion: boolean
tokens: {background, surface, ink, muted, accent, accentInk, border, soft,
radius, font, step1 … step12, onStepLow, onStepHigh}
}subscribe 会立即调用你,并在每次变化时再次调用。applyTheme 会将 24 个令牌写为 --zc-* CSS 变量,并在根元素上设置 data-mode、data-motion 和 color-scheme。请遵循 reducedMotion。
声音偏好
// Require hostApi: {major: 1, minMinor: 5} in your manifest and connect().
sdk.sound.current(): boolean | undefined
sdk.sound.subscribe(listener: (enabled: boolean) => void): () => void自开发套件 0.8 和宿主接口 1.5 起提供。只有要求版本为 1.5 或更高的插件才会收到可选的 context.sound 字段和 sound.changed 事件。两者只接受 {enabled: boolean}。访客尚未作出选择时,上下文中不会包含该字段。宿主未提供选择时,current() 会返回 undefined,请使用你自己的默认值。已知偏好后,subscribe 会立即收到一个布尔值,并在偏好变化时再次收到;它会返回取消订阅函数。
这是一项默认偏好,不是强制静音。启用时,先打开游戏内的声音开关,并且只在玩家于游戏内操作后开始播放音频。关闭时,请保持安静,直到访客在游戏中开启声音。请保留自己的声音控制,并在平台偏好变化时同步更新。无论偏好如何,游戏暂停或隐藏时都必须静音。
let soundOn = sdk.sound.current() ?? false
let paused = false, hidden = false
function applySound() {
setSoundSwitch(soundOn) // your own control, including a header action if used
if (!soundOn || paused || hidden) stopAudio()
}
applySound()
const unsubscribe = sdk.sound.subscribe(enabled => {
soundOn = enabled
applySound() // a host event is not an audio-unlocking gesture
})
sdk.onLifecycle(command => {
if (command.type === 'pause') paused = true
if (command.type === 'resume') paused = false
if (command.type === 'visibility') hidden = !command.visible
if (command.type === 'dispose') { paused = true; unsubscribe(); stopAudio(); return }
applySound()
})
gameSurface.addEventListener('click', () => {
if (soundOn && !paused && !hidden) startAudioFromGesture()
})
soundButton.addEventListener('click', () => {
soundOn = !soundOn
applySound()
if (soundOn && !paused && !hidden) startAudioFromGesture()
})
sdk.onDisconnect(() => { paused = true; unsubscribe(); stopAudio() })存储
sdk.storage.read(slot = 'primary'): Promise<SaveEnvelope | null>
sdk.storage.write(slot: string, data: Json, options: {
expectedRevision: number // 0 for a new slot
schemaVersion: string // your save format, such as 'garden-v2'
requestId?: string // the same id for every retry of one change
}): Promise<SaveEnvelope>
sdk.storage.createAutosave(save: () => Promise<unknown>, options?: {
intervalMs?: number // 10000 to 300000, default 10000
onError?: (error: SDKError) => void
}): {schedule(): void, flush(): Promise<void>, dispose(): void}
sdk.storage.flush(): Promise<void>
SaveEnvelope: {slot: string, schemaVersion: string, revision: number, data: Json, updatedAt: string}存储槽名称类似 ID,默认值为 primary。每份存档最多容纳 128 KiB 的纯 JSON。只有 expectedRevision 匹配时写入才会成功;否则会以 CONFLICT 失败,且不会覆盖任何内容。新建存储槽时使用 0。
私人城市里程碑
sdk.progression.current(): {enabled: boolean, importExisting: boolean}
sdk.progression.subscribe(listener: (preference: {
enabled: boolean, importExisting: boolean
}) => void): () => void
sdk.milestones.report(value: {
key: 'homestead' | 'pond' | 'clouds' | 'bamboo' | 'tea' | 'stream' | 'bell' | 'lotus'
tier: 1 | 2
ruleVersion: string // exactly '<key>.1' in this release
evidence: {saveRevision: number} // confirmed primary save revision
requestId: string // one id per immutable report, 1-80 ID characters
}): Promise<{status: 'recorded' | 'already-recorded', tier: 1 | 2}>自开发套件 0.9 和宿主接口 1.6 起提供。获批的来源会收到可选的 context.progression 和 progression.changed,内容恰为 {enabled: boolean, importExisting: boolean}。不支持时,本地读取方法会将两项都返回为假。订阅会立即收到一份副本,之后在有效变更时再次收到,并返回取消订阅函数。带入选择仅适用于当前选中的游戏。只有使用 postgres 或等效的 development 存储时,才提供此能力和上下文。
请先等待主存档成功保存,再报告其修订号。报告只包含固定键、等级 1 或 2、规则版本、一份已保存存档的修订号,以及必需的请求标识;不能包含身份、文本、图片、位置或其他属性。服务器会将自身的最小存档投影与获批规则核对。等级 2 包含等级 1。结果为 recorded 或 already-recorded,并附当前等级。确认结果表示装饰已可用;是否摆放由玩家决定。
// Call with the envelope returned by a successful primary storage.write.
// This rule counts completed cups, never play time or sound settings.
async function reportTea(confirmedSave) {
const serverStored = ['postgres', 'development'].includes(sdk.context.storageMode)
if (!sdk.supports('milestones.report') || !serverStored || !sdk.progression.current().enabled) return
const cups = confirmedSave.data.cups
const tier = cups >= 3 ? 2 : cups >= 1 ? 1 : 0
if (!tier) return
return sdk.milestones.report({
key: 'tea', tier, ruleVersion: 'tea.1',
evidence: {saveRevision: confirmedSave.revision},
requestId: crypto.randomUUID(),
})
}
const unsubscribe = sdk.progression.subscribe(({enabled}) => {
if (!enabled) clearInMemoryCityCandidates()
})
sdk.onLifecycle(command => { if (command.type === 'dispose') unsubscribe() })
sdk.onDisconnect(unsubscribe)
// A result confirms the decoration was recorded; placement is the player's choice.
// Keep no plugin-owned outbox and never block game saves or leaving.停止记录后,请清除内存中的候选项。插件不得保留自有队列、轮询服务器,也不得延迟保存或离开。重试次数由宿主限制。结果不确定时,保留完全相同的报告和请求标识。七天内,匹配的回执会返回原始结果,且不占用新报告额度;内容不同则会以 IDEMPOTENCY_CONFLICT 失败。遇到 STALE_EVIDENCE 后,请读取较新的存档并重新评估,再使用新的标识。权限缺失、记录关闭、规则未知或会话过期时,应停止当前报告。
这些装饰只用于选择私人外观。已确认的快照由客户端报告,明确带入的历史记录会标记为已带入。两者都不能证明真实的手动游玩或呼吸情况,也不能用于公开排名、交易或付款。插件不会得知所有者身份、城市布局、宿主代际或其他游戏的进度。停止或清除城市记录不会影响原有游戏存档。
加载与会话
sdk.loading.start(): Promise<{ok: true}>
sdk.loading.ready(): Promise<{ok: true}>
sdk.loading.fail(message: string): Promise<{ok: true}> // up to 240 characters
sdk.session.report(status: {
state: 'running' | 'paused' | 'ended'
mode: 'watch' | 'manual' | 'listen'
runId?: string
}): Promise<{ok: true}>访客可以开始时报告 ready。会话状态变化时都要报告;运行期间至少每 12 秒报告一次。平台根据这些报告计时。在 ready 前报告 running 会以 NOT_READY 失败。
运行记录、片刻与徽章
sdk.runs.finish(run: {
id: string, reason: 'finished' | 'restarted', control: 'auto' | 'manual' | 'mixed'
metrics: Record<string, number>, endedAt: string
}): Promise<{recorded: true}>
sdk.moments.create(moment: {title: string, image: string, metadata?: Json})
: Promise<{id: string, stored: true}>
sdk.achievements.request(id: string, evidence: Json)
: Promise<{status: 'recorded', verified: boolean, awarded?: string[]}>runs.finish 会先保存待处理进度,再记录一局,最多包含 20 项数值指标。片刻图片是 PNG 数据 URL,最长 350,000 个字符;平台可能会先征求访客同意再保存。徽章申请会附带你的证明。结果由服务器决定,设备模式下不会核验。
计数、事件、分享与离开
sdk.counters.add(key: string, delta: number, options?: {reason?: string, requestId?: string})
: Promise<{key: string, value: number, applied: boolean}>
sdk.events.report(name: string, props?: Record<string, string | number | boolean | null>)
: Promise<{recorded: boolean}>
sdk.shares.request(request: {kind: 'experience'} | {kind: 'badge', badgeCode: string})
: Promise<{presented: boolean}>
sdk.navigation.exit(reason?: string): Promise<{ok: true}>只有运营者定义了计数并将你列为写入方时,共享计数才会变化;请保留 requestId,确保重试只生效一次。事件名称须为小写,最多 64 个字符,最多带 20 个扁平属性。navigation.exit 会保存待处理进度并带访客前往你的详情页。
沉浸式显示
if (sdk.supports('display.immersive')) {
const {immersive, applied} = await sdk.display.immersive(true)
}从宿主 API 1.3 起提供。只有你已就绪且访客未关闭沉浸模式时,平台才会隐藏周围的框架。applied 会告知是否发生变化。退出沉浸模式始终可用。
页头操作
if (sdk.supports('header.setActions')) {
sdk.header.onAction(action => {
if (action.id === 'sound') toggleSound()
if (action.id === 'water') waterGarden()
})
await sdk.header.setActions([
{kind: 'standard', id: 'sound', intent: 'sound', pressed: soundOn},
{kind: 'custom', id: 'water', title: 'Water the garden',
icon: {paths: ['M12 3 C8 9 6 12 6 15 C6 18 9 21 12 21 C15 21 18 18 18 15 C18 12 16 9 12 3 Z']}},
])
}
sdk.header.clear()从宿主 API 1.4 起提供。最多六项操作:平台分享操作、标准意图(sound、restart、settings、help、undo、redo),或自定义操作。自定义标题最多 40 个字符,图标为 24 × 24,最多由四条 SVG 路径绘制。对于没有页头的宿主,请保留框架内的备用操作。
生命周期与断连
sdk.onLifecycle(handler: (command: {
type: 'pause' | 'resume' | 'visibility' | 'dispose'
visible?: boolean
}) => void | Promise<void>): () => void
sdk.onDisconnect(handler: (error: SDKError) => void): () => void
sdk.disconnect(): voidonLifecycle 会收到 pause、resume、visibility 和 dispose。处理程序运行后,SDK 会在暂停、隐藏和销毁时刷新所有自动存档。onDisconnect 会告知通道已永久关闭。不要在 dispose 处理程序中调用 disconnect(),否则宿主会等待一秒直至超时。
限制
| 项目 | 上限 |
|---|---|
| 单条消息 | 512 KiB |
| 存档数据 | 128 KiB |
| 里程碑报告 | 1 千字节;每位所有者在每款游戏内每 10 秒最多提交 2 条新报告,每款游戏每日(协调世界时)最多 40 条,每位所有者每日最多 160 条。 |
| JSON 结构 | 普通对象和数组、有限数值;最多 22 层深;每个对象最多 1,000 个键,每个数组最多 20,000 项 |
| 正在处理的请求 | 32 |
| 请求速率 | 可突发发送 40 个,之后每秒 20 个 |
| 服务器速率 | 片刻每分钟 15 次,徽章每分钟 30 次,计数和事件每分钟 120 次 |
| 超时时间 | 请求 8 秒、握手 10 秒、就绪检查 15 秒、保活 12 秒、销毁 1 秒 |
错误代码
每次拒绝都会返回带稳定 code 的 SDKError。错误消息用于日志,不面向访客。
| 代码 | 含义与处理方式 |
|---|---|
NOT_EMBEDDED · HOST_DENIED · HANDSHAKE_REJECTED · INCOMPATIBLE_API · HANDSHAKE_TIMEOUT | 页面在框架外打开、宿主未获允许,或宿主不支持你要求的宿主 API。请显示平和的提示。 |
FORBIDDEN · INVALID_INPUT · NO_METHOD · TOO_LARGE · NOT_READY | 此版本未获授该能力,或输入违反规则。请修正调用,不要重试。 |
CONFLICT · IDEMPOTENCY_CONFLICT · ID_REUSE | 你的存档已在其他位置更改,或不同数据重复使用了 requestId。请重新读取存档并让访客决定。切勿静默覆盖。 |
TIMEOUT · INTERNAL · SAVE_UNCONFIRMED | 结果未知,写入可能已经发生。请使用相同的 requestId 重试。 |
BUSY · RATE_LIMIT | 请求过多或发送过快。请稍等片刻再试。 |
GUEST_CAP · STORAGE_LIMIT | 访客已达到访客额度或存储上限。请将状态保存在内存中,并温和地告知对方。 |
DISCONNECTED · DISPOSED · UNAUTHORIZED | 会话已结束。请停止处理并停止存档。 |
UNAVAILABLE · STORAGE_UNAVAILABLE | 此宿主当前不提供该功能。请改用自己的实现。 |
PROGRESSION_DISABLED · SAVE_NOT_READY · STALE_EVIDENCE | 城市记录已关闭,请清除候选项。所引用的存档尚未到达,请等待保存确认。存在更新的快照,请读取并重新评估,再使用新的报告标识。 |
INVALID_RESPONSE | 宿主未确认里程碑结果。请保留游戏存档,不要声称装饰已记录。 |