关于 scene-runtime
面向 Decentraland SDK7 场景开发者的运行时 API 指南,介绍异步任务、网络请求、场景信息和引擎系统等跨领域能力。还记录了受限操作、执行顺序及场景可见状态等注意事项。
主要功能
- 01运行异步任务与 HTTP 请求
- 02使用 WebSocket 通信
- 03读取场景、Realm 与世界时间
- 04读取部署文件及引擎状态
- 05配置系统执行优先级
适用场景
- 01向外部 API 获取数据
- 02按场景可见状态启动演出
- 03控制系统更新顺序
- 04调用需要玩家交互的操作
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ decentraland/ sdk-skills --skill scene-runtime安装后在 Claude Code 中输入/scene-runtime确认已加载。
附带文件references/runtime-apis.md
详细介绍
场景运行时 API
Decentraland SDK7 场景中均可使用的跨领域运行时 API。
异步任务
场景运行时是单线程的。请将所有异步工作包装在 executeTask()(或异步函数)中——裸 Promise 会被静默丢弃:
import { executeTask } from "@dcl/sdk/ecs";
executeTask(async () => {
const res = await fetch("https://api.example.com/data");
const data = await res.json();
console.log(data);
});
HTTP:fetch 和 signedFetch
普通 fetch 可用于公共 API:
const res = await fetch("https://api.example.com/data");
signedFetch 可向你的后端证明玩家的身份。使用 getHeaders() 仅获取签名标头(适用于由库管理自身 fetch 的情况):
import { signedFetch, getHeaders } from "~system/SignedFetch";
// Full signed request
const res = await signedFetch({
url: "https://your-server.com/api",
init: { method: "POST", body: JSON.stringify(payload) },
});
// Get signed headers only (for custom fetch calls)
const { headers } = await getHeaders({ url: "https://your-server.com/api" });
signedFetch 返回 { ok, status, statusText, headers, body },其中 body 是字符串(请自行调用 JSON.parse(response.body)——没有 .json())。系统会自动添加已签名的身份标头;你的后端会根据 ADR-44 验证这些标头。
权限:外部 HTTP 的名义权限是
"USE_FETCH"(套接字则为"USE_WEBSOCKET"),需在scene.json的requiredPermissions中声明。实际上,即使未声明该权限,在预览版/Worlds 中,普通或签名fetch请求非媒体主机也不会被硬性阻止(66,6-signed-fetch测试场景在requiredPermissions为空时调用了signedFetch)。为确保正确性和向前兼容,仍应声明USE_FETCH。signedFetch无需玩家事先交互——受限操作需要交互,但fetch/signedFetch不需要。
WebSocket
const ws = new WebSocket("wss://your-server.com/ws");
ws.onopen = () => ws.send("hello");
ws.onmessage = (event) => console.log(event.data);
ws.onclose = () => console.log("disconnected");
场景和 Realm 信息
import { getSceneInformation, getRealm, getExplorerInformation } from "~system/Runtime";
executeTask(async () => {
// Scene info: URN, content mappings, metadata JSON, baseUrl
const scene = await getSceneInformation({});
const metadata = JSON.parse(scene.metadataJson);
// metadata.scene?.parcels (parcel list), metadata.display?.title (scene title)
console.log(scene.urn, scene.baseUrl, metadata);
// Realm info: baseUrl, realmName, isPreview, networkId (1 = mainnet, 5 = goerli), commsAdapter
const realm = await getRealm({});
console.log(realm.realmInfo?.realmName, realm.realmInfo?.isPreview); // realmName e.g. "peer-us-1"
// Explorer info: agent string, platform, configurations
const explorer = await getExplorerInformation({});
console.log(explorer.agent, explorer.platform);
});
检查 realm.realmInfo?.isPreview 以检测预览模式并控制调试功能。
世界时间
import { getWorldTime } from "~system/Runtime";
executeTask(async () => {
const { seconds } = await getWorldTime({});
// seconds = coordinated world time (cycles 0-86400 for day/night)
});
读取已部署文件
在运行时读取随场景部署的文件——可用于 JSON 配置或关卡数据等数据文件:
import { readFile } from "~system/Runtime";
executeTask(async () => {
const result = await readFile({ fileName: "data/config.json" });
const text = new TextDecoder().decode(result.content);
const config = JSON.parse(text);
});
EngineInfo 组件
帧级计时以及场景可见性,位于 engine.RootEntity 上:
import { EngineInfo, engine } from "@dcl/sdk/ecs";
engine.addSystem(() => {
const info = EngineInfo.getOrNull(engine.RootEntity);
if (info) {
console.log(info.frameNumber, info.tickNumber, info.totalRuntime);
// info.sceneHidden: true when a fullscreen Explorer UI (map, backpack,
// settings, loading screen, etc.) covers the scene viewport.
if (info.sceneHidden) {
// Pause expensive work, audio, animations — the player cannot see or
// interact with the scene while it is hidden.
}
}
});
EngineInfo 字段
| 字段 | 类型 | 描述 |
|---|---|---|
frameNumber | number | 引擎帧计数器 |
tickNumber | number | 场景 tick 计数器(依据 ADR-148) |
totalRuntime | number | 此场景的总运行时间,单位为秒 |
sceneHidden | boolean | 当场景被全屏 Explorer UI(地图、背包、设置、相机胶片、加载屏幕)遮挡时为 true。在“physics”阶段与 frameNumber/tickNumber 一同写入。场景不可见时,可用它暂停游戏、音频、动画和开销较大的系统。默认为 false。 |
已根据 protocol commit 0b3d285(PBEngineInfo 中的字段 4 bool scene_hidden,组件 ID 为 1048)以及 js-sdk-toolchain commit ffb26183(在 PBEngineInfo 上公开为 sceneHidden: boolean)进行验证。
检测加载屏幕何时淡出
Explorer 的加载屏幕遮挡场景期间,sceneHidden 为 true;加载屏幕淡出的瞬间,它会变为 false。**这是场景获知玩家实际看到场景的第一刻的唯一信号。**基于 totalRuntime、帧计数器或 utils.timers 延迟的 onSceneReady 式计时都只是猜测——加载屏幕显示多久就会持续多久,而且不同玩家和设备的时长各不相同。
可用它来延后所有玩家应该亲眼见证的内容:开场电影、欢迎音效/配音、开场补间动画、标题 UI,或不应在玩家仍盯着加载屏幕时触发的分析事件。
import { EngineInfo, engine } from "@dcl/sdk/ecs";
engine.addSystem(function waitForSceneRevealed() {
const info = EngineInfo.getOrNull(engine.RootEntity);
if (!info || info.sceneHidden) return;
engine.removeSystem(waitForSceneRevealed); // one-shot
// the player is now looking at the scene — start the intro here
});
- 当
sceneHidden为true时,场景仍会正常持续运行 tick——它并未暂停,只是不显示。不要用此标志控制场景逻辑,只用于安排展示时机。 - 始终使用
getOrNull进行保护——组件在最初几帧可能不存在。 - 触发后移除该系统(或设置
done标志);否则它会在每一帧重复运行。 - 在早于此字段的 Explorer 版本中,
sceneHidden会保持其原型默认值false,因此此模式会退化为“尽快触发”,而不会一直卡住。
系统执行顺序与优先级
engine.addSystem(fn, priority?, name?) 每帧都会运行 fn(dt)。priority 参数控制它在帧中的运行时机,以及它相对于其他系统的顺序。
优先级数字越大 = 在帧中越早运行。 系统按优先级降序排序(@dcl/ecs 中的 sort((a, b) => b.priority - a.priority))。SDK 自身的 JSDoc 指出:“优先级是一个数字,数字越大,调用越早。”
WARNING — 违反直觉: 这与 Unity/Godot/许多引擎的规则恰好相反,在那些引擎中,数字越小越先运行。在 Decentraland SDK7 中,“让它先运行”意味着赋予它一个较大的优先级数字,而不是
1。优先级为1的系统几乎是最后运行的。
关键数字:
- 默认优先级为
100000(SYSTEMS_REGULAR_PRIORITY = 100e3)。调用engine.addSystem(fn)而不传入优先级时,就会使用此默认值。 @dcl/react-ecs会以100000注册 UI 渲染器系统(以及一个优先级为100001的 UI 缩放系统)。因此,UI 会与默认优先级的系统同时运行,或略早于它们运行。- 若要在所有常规系统之前运行,请传入一个高于
100000的优先级(例如engine.addSystem(fn, 1000000))。若要在它们之后运行,请传入一个低于100000的优先级(例如10,或脚本使用的默认值0)。
engine.addSystem(earlySystem, 1000000); // runs before regular systems
engine.addSystem(regularSystem); // priority 100000 (default)
engine.addSystem(lateSystem, 10); // runs after regular systems
- 在系统的
update(dt)中执行步进的物理库(例如 cannon.js)遵循同样的规则——不存在单独的物理循环。如果其他系统必须在同一帧读取步进后的状态,请为执行物理步进的系统设置较高的优先级。 - 当前,优先级相同的系统会按插入顺序运行,但引擎不保证平局时的顺序稳定(源码中将此标记为 TODO)。不要依赖优先级相同的系统之间的顺序。
受限操作
这些操作需要玩家先进行交互(例如点击)才能执行。请从 ~system/RestrictedActions 导入:
import {
movePlayerTo,
teleportTo,
triggerEmote,
changeRealm,
openExternalUrl,
openNftDialog,
triggerSceneEmote,
copyToClipboard,
setCommunicationsAdapter,
openExplorerUi,
} from "~system/RestrictedActions";
// Move player within scene bounds. Optional: cameraTarget (where the
// CAMERA looks), avatarTarget (where the AVATAR faces — for rotating in
// place), duration (seconds, for a smooth glide instead of a snap).
movePlayerTo({
newRelativePosition: { x: 8, y: 0, z: 8 },
cameraTarget: { x: 8, y: 1, z: 12 },
});
// Teleport to coordinates in Genesis City
teleportTo({ worldCoordinates: { x: 50, y: 70 } });
// Teleport into another realm / World. `realm` takes a World name
// (`foo.dcl.eth`) or a realm URL; the client does a FULL RECONNECT to
// that realm, then lands on the parcel. Requires @dcl/sdk 7.28.0+.
// The player is asked to confirm first ("Are you sure you want to enter
// this World?"); the switch only happens if they accept.
teleportTo({ realm: "foo.dcl.eth", worldCoordinates: { x: 0, y: 0 } });
// Omit worldCoordinates to land on that realm's default spawn.
// This replaces changeRealm.
teleportTo({ realm: "foo.dcl.eth" });
// Play a built-in emote
triggerEmote({ predefinedEmote: "wave" });
// Open URL in browser (prompts user)
openExternalUrl({ url: "https://decentraland.org" });
// Open NFT detail dialog
openNftDialog({
urn: "urn:decentraland:ethereum:erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d:558536",
});
// Copy text to clipboard
copyToClipboard({ text: "Hello from Decentraland!" });
// [DEPRECATED] changeRealm — use teleportTo({ realm }) instead.
// The player always gets a confirmation dialog. `message` is OPTIONAL:
// omit it to show the default text, include it to show custom text.
changeRealm({ realm: "https://peer.decentraland.org" }); // default prompt text
changeRealm({ realm: "other-realm.dcl.eth", message: "Join this realm?" }); // custom prompt text
changeRealm 已标记为 [DEPRECATED]——建议改用 teleportTo({ realm })
协议 e89d7fa,SDK 固定版本 6b7c3586;realm 字段在 @dcl/js-runtime 7.28.0 中有类型定义。
TeleportToRequest现在为{ worldCoordinates?: Vector2, realm?: string }——两者都是可选项。realm接受 World 名称(foo.dcl.eth)或 realm URL。设置后,客户端会先显示确认提示,默认文本为“确定要进入此 World 吗?”。如果玩家接受,客户端会完整重连到该 realm(即使玩家已经身处该 realm),然后将玩家传送到地块。- 设置了
realm时,省略worldCoordinates会将玩家传送到该 realm 的默认出生点——与changeRealm的行为完全相同。 - 省略
realm会在玩家当前所在的 realm 内传送(原有行为)。
为何用它取代 changeRealm: changeRealm 会在请求被接受时返回,而不是等到新 realm 生效后才返回。因此,先调用 changeRealm(...) 再调用 teleportTo(coords),会将玩家传送到旧 realm 中的地块。一次 teleportTo({ realm, worldCoordinates }) 调用则不会出现这种竞态。
changeRealm 仍可使用,并会保留供现有场景使用。changeRealm 和 teleportTo({ realm }) 都会在切换 realm 前要求玩家确认;只有 changeRealm 允许场景通过 message 设置自定义提示文本。
Unity 浏览器支持 realm 字段。
openExplorerUi -- 打开 Explorer 面板
从场景代码中打开全屏 Explorer 面板(地图、背包、设置等)。需要玩家事先进行交互。
import { openExplorerUi } from "~system/RestrictedActions";
import { ExplorerUi } from "@dcl/sdk/ecs";
// Open the map panel
openExplorerUi({ ui: ExplorerUi.EU_MAP });
调用会解析为 { openResult },这是一个 OpenExplorerUiResult(同样来自 ~system/RestrictedActions):OPENED、WAS_ALREADY_OPEN(该面板使用的槽位已被占用;共用槽位的面板会互相排斥,因此当前显示的可能是另一个面板或分区)、REJECTED_NOT_CURRENT_SCENE(玩家不在此场景中)、REJECTED_FEATURE_DISABLED(客户端隐藏或不支持该面板)、REJECTED_NO_USER_GESTURE(调用并非来自玩家交互),以及默认值 UNSPECIFIED。请从输入回调中调用:如果在顶层调用,始终会返回 REJECTED_NO_USER_GESTURE。只有 OPENED 表示成功:其他情况下,请通过其他方式显示信息,或告诉玩家要打开什么。
import { openExplorerUi, OpenExplorerUiResult } from "~system/RestrictedActions";
import { ExplorerUi, InputAction, pointerEventsSystem } from "@dcl/sdk/ecs";
pointerEventsSystem.onPointerDown(
{ entity: placesSign, opts: { button: InputAction.IA_POINTER, hoverText: "Places" } },
async () => {
const { openResult } = await openExplorerUi({ ui: ExplorerUi.EU_PLACES });
if (openResult !== OpenExplorerUiResult.OPENED) {
showHint("Open Places from the menu to find more scenes"); // your own UI fallback
}
},
);
ExplorerUi 枚举值(从 @dcl/sdk/ecs 导入):EU_SETTINGS(0)、EU_MAP(1)、EU_BACKPACK(2)、EU_CAMERA_REEL(3)、EU_COMMUNITIES(4)、EU_PLACES(5)、EU_EVENTS(6)。openExplorerUi 和 ExplorerUiEventsResult 共用此枚举——比较时无需进行类型转换。
ExplorerUiEventsResult -- 监听面板打开/关闭
场景可以监听 Explorer 面板的打开或关闭。ExplorerUiEventsResult 是 engine.RootEntity 上一个只增值集合(APPEND 语义,最多 100 个条目)。每个条目会报告面板及其打开或关闭状态。
import { engine, ExplorerUiEventsResult, ExplorerUi } from "@dcl/sdk/ecs";
engine.addSystem(() => {
const results = ExplorerUiEventsResult.get(engine.RootEntity);
for (const entry of results.values()) {
if (entry.ui === ExplorerUi.EU_MAP) {
if (entry.event?.$case === "opened") {
console.log("Map was opened");
} else if (entry.event?.$case === "closed") {
console.log("Map was closed");
}
}
}
});
每个条目包含:ui(ExplorerUi 枚举)、timestamp(场景 tick)、event(oneof:{ $case: 'opened' } 或 { $case: 'closed' })。组件 ID 为 1220。已根据协议提交 86c4613 和 js-sdk-toolchain 提交 fc8cfc65 验证。
定时器
始终使用来自 @dcl/sdk/ecs、与引擎绑定的 timers 对象。 不要使用原生 JS setTimeout / setInterval 全局函数。尽管 QuickJS 运行时将符合 JS 标准的 setTimeout / clearTimeout / setInterval / clearInterval 作为全局函数提供(在 @dcl/js-runtime/index.d.ts 中声明),但在 Decentraland 场景中调用它们可能看似有效,却会引发难以察觉的问题——它们并未绑定到场景的引擎。请改用 timers.setTimeout。
import { timers } from "@dcl/sdk/ecs";
const timeoutId = timers.setTimeout(() => console.log("delayed"), 2000);
timers.clearTimeout(timeoutId);
const intervalId = timers.setInterval(() => console.log("tick"), 1000);
timers.clearInterval(intervalId);
函数签名与符合 JS 标准的定时器一致:
timers.setTimeout(callback: () => void, ms: number): number
timers.clearTimeout(timerId: number): void
timers.setInterval(callback: () => void, ms: number): number
timers.clearInterval(timerId: number): void
参数顺序为 (callback, ms),而不是 (ms, callback)。不要编写颠倒参数顺序的自定义辅助函数。
**定时器错误处理:**如果定时器回调抛出异常,后续定时器仍会正确计时。SDK 会通过 try/finally 清除内部计时上下文,因此一个回调中抛出的异常不会破坏后续定时器的已用时间跟踪。已根据 js-sdk-toolchain 提交 a2ccd0b1 验证。
不要编写自定义的逐帧定时器系统,通过累积 dt 来触发延迟回调。SDK 已内置 timers。自定义系统会重复实现已有功能、与引擎自身的调度产生偏差;对于单次延迟来说,这也是错误的抽象方式。
对于自定义引擎实例,请使用来自 @dcl/sdk/ecs 的 createTimers(engineInstance),获取作用域限定于该引擎的 Timers 对象。
基于系统的定时器(推荐用于游戏逻辑——与帧循环同步):
let elapsed = 0;
engine.addSystem((dt: number) => {
elapsed += dt;
if (elapsed >= 3) {
elapsed = 0;
// Do something every 3 seconds
}
});
Component.onChange() 监听器
对任意实体上的组件变更作出响应:
Transform.onChange(engine.PlayerEntity, (newValue) => {
if (newValue) {
console.log("Player moved to", newValue.position);
}
});
实体移除
engine.removeEntity(entity): boolean
移除实体上的所有组件,并释放其 ID 以供重用。返回 boolean:
true— 实体已接受移除;组件已清除,ID 已释放以供回收。false— 实体拒绝移除;组件保持不变,ID 仍被占用。这适用于渲染器保留范围内的实体 ID(Avatar 实体,任何版本中编号均为 3 至reservedStaticEntities - 1)。三个命名的静态实体(engine.RootEntity、engine.PlayerEntity、engine.CameraEntity)也属于保留实体,永远不会释放,但其组件仍会被清除(渲染器接受对这三个实体的场景删除操作)。
从 js-sdk-toolchain 提交 e712ef71 起,返回类型从 void 改为 boolean。忽略返回值的现有代码不受影响。
注意 — Avatar 实体冲突: 修复此问题之前,engine.removeEntity 可能会悄悄清除在线玩家 Avatar 实体的组件。现在引擎会拒绝移除渲染器保留的 ID,从而避免这种情况。除非你确实要清除命名的静态实体,否则切勿对遍历 PlayerIdentityData 时返回的实体调用 removeEntity。
removeEntityWithChildren
递归移除一个实体及其所有子实体 — 清理复杂实体层级时可使用此方法:
import { removeEntityWithChildren } from "@dcl/sdk/ecs";
removeEntityWithChildren(engine, parentEntity);
注意 — 可能只完成部分操作: removeEntityWithChildren 可能只完成部分操作,且不会报告这一情况。如果 Transform 树中的任何位置出现渲染器保留节点(例如 Avatar 实体),removeEntity 会拒绝移除该节点,但仍会移除其后代,导致该保留节点的 Transform.parent 指向一个已移除实体。只有当场景将保留实体设为某个场景实体的子实体时,才可能出现这种情况。该函数返回 void — 不会返回每个节点的处理结果。已根据 js-sdk-toolchain 提交 e712ef71 验证。
Portable Experiences
可在世界导航期间持续存在的场景。从 ~system/PortableExperiences 导入。
import {
spawn,
kill,
exit,
getPortableExperiencesLoaded,
} from "~system/PortableExperiences";
// Spawn by ENS name (a deployed World) OR by pid. NOT by "urn".
const result = await spawn({ ens: "boedo.dcl.eth" });
// result: { pid, parentCid, name, ens }
// Kill a running one by its pid (from the spawn response). NOT by urn.
if (result.pid) await kill({ pid: result.pid });
// List currently loaded portable experiences
const { loaded } = await getPortableExperiencesLoaded({});
// Exit self (only if THIS scene IS a portable experience)
await exit({});
spawn({ ens?, pid? })→SpawnResponse { pid, parentCid, name, ens? }。字段是ens/pid,不是urn。kill({ pid })返回{ status: boolean };kill({ pid })和getPortableExperiencesLoaded({})都以pid为键,绝不使用urn。- 如需构建在穿戴物品装备期间运行的 Portable Experience,请参阅 smart-wearables 技能。
- 宿主场景必须在
scene.json中启用它们:"featureToggles": { "portableExperiences": "enabled" }。可选值:"enabled"|"disabled"|"hideUi"(启动 PX,但隐藏其 UI)。设为"disabled"时,spawn()不执行任何操作或会被拒绝。
Testing Framework
场景可以使用 @dcl/sdk/testing 附带单元测试。测试采用生成器形式 — 让出执行会暂停,直到下一帧,以便观察各个 tick 之间的引擎状态。
import { test } from "@dcl/sdk/testing";
import {
assertComponentValue,
assertEquals,
} from "@dcl/sdk/testing/assert";
import { engine, Transform, MeshRenderer } from "@dcl/sdk/ecs";
import { Vector3, Quaternion } from "@dcl/sdk/math";
test("transform is applied after one frame", function* () {
const entity = engine.addEntity();
Transform.create(entity, { position: Vector3.One() });
// Let the engine run for a frame before asserting
yield;
assertComponentValue(entity, Transform, {
position: Vector3.One(),
scale: Vector3.One(),
rotation: Quaternion.Identity(),
parent: 0 as any,
});
});
test("five meshes are present", function* () {
yield;
assertEquals(1 + 1, 2, "basic math");
// No count assertion exists — count via getEntitiesWith + Array.from
assertEquals(
Array.from(engine.getEntitiesWith(MeshRenderer)).length,
5,
"should have 5 meshes"
);
});
可用断言(@dcl/sdk/testing/assert)— 仅有以下四种:
assertEquals(actual, expected, message?)— 深度相等检查assert(condition, message?)— 真值检查assertComponentValue(entity, Component, expected)— 完整组件值比较deepCloseTo(actual, expected, options?)— 带容差的深度数值比较(适用于浮点数)
没有数量断言 — 使用 assertEquals(Array.from(engine.getEntitiesWith(Comp)).length, n) 统计实体数量。
运行测试:没有 CLI 测试命令(不存在 npx @dcl/sdk-commands test)。只有当宿主运行时提供 ~system/Testing 模块时才会执行测试 — 例如 CI 测试运行器或启用了测试功能的 Explorer。在普通预览中,测试运行器只是记录日志,不执行任何操作;在生产构建中,它受 DEBUG 保护。测试在与场景相同的 QuickJS 运行时中执行,因此受到相同限制(不能使用 Node.js API,应使用 SDK 计时器等)。
Logging
运行时仅声明了 console.log() 和 console.error() — 不提供 console.warn()、.info()、.debug()、.trace()。
示例场景
引擎团队测试场景,针对真实运行时测试这些 API:
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/66,6-signed-fetch — 点击时调用
signedFetch;读取response.ok/.status/.body,检查自动添加的签名标头。 - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/2,0-skybox-scene-json — 使用
executeTask+getSceneInformation({}),在运行时读取场景自己的scene.json:JSON.parse(sceneInfo.metadataJson),然后遍历worldConfiguration.skyboxConfig,并在必要时回退到顶层skyboxConfig。同级场景 https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/3,0-skybox-world-json 使用相同代码,针对的是worldConfiguration部署。 - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/80,-4-restricted-actions — 通过 UI 按钮测试每个 RestrictedAction:
movePlayerTo(使用cameraTarget和avatarTarget)、teleportTo、triggerEmote、triggerSceneEmote、openExternalUrl、openNftDialog、changeRealm(有和没有message)。 - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/8,8-portable-experience — 根据生成响应调用
spawn({ ens })/kill({ pid });宿主scene.json中的portableExperiences: "enabled"。 - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/8,9-portable-experience-disabled — 相同,但宿主
scene.json将portableExperiences设为"disabled"(抑制生成)。 - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/8,7-portable-experience-hide-ui — 宿主
scene.json将portableExperiences设为"hideUi"(PX 运行,其 UI 隐藏)。
完整的 RestrictedActions 参考——triggerSceneEmote(需要 _emote.glb)、setCommunicationsAdapter、movePlayerTo 原地旋转、预定义表情名称——以及其他 executeTask 变体(错误处理、顺序执行):{baseDir}/references/runtime-apis.md。