mcpField
S
scene-runtime

scene-runtime

github.com/decentraland开发4287 次安装今天更新
代码仓库

关于 scene-runtime

面向 Decentraland SDK7 场景开发者的运行时 API 指南,介绍异步任务、网络请求、场景信息和引擎系统等跨领域能力。还记录了受限操作、执行顺序及场景可见状态等注意事项。

主要功能

  1. 01运行异步任务与 HTTP 请求
  2. 02使用 WebSocket 通信
  3. 03读取场景、Realm 与世界时间
  4. 04读取部署文件及引擎状态
  5. 05配置系统执行优先级

适用场景

  1. 01向外部 API 获取数据
  2. 02按场景可见状态启动演出
  3. 03控制系统更新顺序
  4. 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 字段
字段类型描述
frameNumbernumber引擎帧计数器
tickNumbernumber场景 tick 计数器(依据 ADR-148)
totalRuntimenumber此场景的总运行时间,单位为秒
sceneHiddenboolean当场景被全屏 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:

完整的 RestrictedActions 参考——triggerSceneEmote(需要 _emote.glb)、setCommunicationsAdapter、movePlayerTo 原地旋转、预定义表情名称——以及其他 executeTask 变体(错误处理、顺序执行):{baseDir}/references/runtime-apis.md。

评论

支持 Markdown