mcpField
M
migrate-sdk6-to-sdk7

migrate-sdk6-to-sdk7

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

关于 migrate-sdk6-to-sdk7

供编码 Agent 将现有 Decentraland SDK6 场景迁移到 SDK7 的技能。它先核实项目版本,并指导 API 转换,同时保留原有场景入口路径。

主要功能

  1. 01核实 SDK6 项目版本
  2. 02SDK6 到 SDK7 API 映射
  3. 03按序执行迁移流程
  4. 04实体与系统转换指引
  5. 05可选迁移静态场景内容

适用场景

  1. 01迁移现有 SDK6 场景
  2. 02检查实体与组件转换
  3. 03安全更新场景配置

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/decentraland/sdk-skills --skill migrate-sdk6-to-sdk7

安装后在 Claude Code 中输入/migrate-sdk6-to-sdk7确认已加载。

附带文件references/api-mapping.mdreferences/migration-example.md

详细介绍

将 Decentraland SDK6 场景迁移到 SDK7

在 Creator Hub Agent 中已禁用。 Creator Hub 随附此技能时不包含其 SKILL.md,因此它不会触发应用内嵌的 AI 助手——Creator Hub 通过自有界面管理项目设置,并且仅支持 SDK7。技能的 references/ 文件仍会随附,因此其他技能仍可解析指向这些文件的相对路径。如果你是 Creator Hub 助手,且用户提出此需求,请引导他们使用应用自身的流程。在 Creator Hub 之外(Claude Code、Cursor、SDK Agent),此技能可正常使用。

这是一项移植技能,而非脚手架技能。 它假设磁盘上已有 SDK6 项目。要创建全新的 SDK7 场景,请使用 [[create-scene]]。

规则:开始操作前,先确认项目确实是 SDK6

按以下顺序检查这些文件。只有所有 SDK6 特征都存在时,才能将项目视为 SDK6——部分匹配通常意味着项目只完成了一半迁移,需要采用不同的方法。

检查项SDK6 特征SDK7 特征
package.json dependenciesdecentraland-ecs@dcl/sdk
package.json scriptsbuild-ecs、dcl startsdk-commands start、sdk-commands build
scene.json没有 runtimeVersion,或缺少 ecs7"runtimeVersion": "7" 和/或 "ecs7": true
源代码导入没有显式导入 @dcl/sdk/ecs——符号(Entity、Transform、Vector3、engine)为全局变量大多数内容从 @dcl/sdk/ecs 和 @dcl/sdk/math 导入
源代码模式new Entity()、entity.addComponent(...)、@Component('name')、class X implements ISystem、OnPointerDown、GLTFShape、Input.instanceengine.addEntity()、Transform.create(entity, ...)、engine.defineComponent(...)、pointerEventsSystem.onPointerDown、GltfContainer

规则:不要更改 scene.json 的 main 字段

保留 SDK6 项目原有的 main 值(通常为 "bin/game.js")。虽然新场景的 SDK7 默认值是 bin/index.js,但 sdk-commands build 会将构建包写入 scene.json 的 main 指定的路径(已验证:sdk-commands 的 bundle.ts 根据 sceneJson.main 设置输出文件),因此现有的 bin/game.js 无需更改即可继续使用。除非用户明确要求,否则不要将其重命名为 bin/index.js。

你应该在 scene.json 中添加 "runtimeVersion": "7"(并移除任何 ECS6 专属内容)。完整的 scene.json 架构请参阅 [[create-scene]]。

规则:迁移同样适用组合文件优先原则

迁移后,所有静态场景内容(模型、灯光、基元、场景加载时放置的文本)都应放在 assets/scene/main.composite 中,而不是 TypeScript 中。SDK6 场景没有组合文件——每个实体都是通过代码创建的。将这些实体迁移到组合文件中是一个单独的可选步骤;_最小可行_迁移会将它们保留为 TypeScript 中的 engine.addEntity(),以便场景先运行起来。

操作顺序:

  1. 先让 SDK7 TypeScript 移植版运行起来(实体仍通过代码创建)。
  2. 然后,可选地将静态实体移至 assets/scene/main.composite。有关组合文件优先原则,请参阅 [[create-scene]];有关格式,请参阅 [[composites]]。

概念转变——编写任何代码前请先阅读

SDK6 将实体建模为附加了组件的对象。SDK7 是真正的 ECS,实体只是不透明 ID,组件则是纯数据。

概念SDK6SDK7
创建实体const e = new Entity() then engine.addEntity(e)const e = engine.addEntity()(返回 ID)
实体类型IEntity / Entity 类,带有方法(addComponent、getComponent、setParent、alive)Entity = 不透明整数 ID,无方法
添加组件entity.addComponent(new Transform({...})) — 入口是实体Transform.create(entity, {...}) — 入口是组件类型
定义组件@Component('name') class GemData { val: number; constructor(...) {...} }export const GemData = engine.defineComponent('gemData', { val: Schemas.Number }, { val: 2 }) — 架构 + 默认值,无方法
组件方法(reset() 等)允许(类方法)禁止 — 组件仅存储数据。将方法移到普通函数或系统中
读取组件数据entity.getComponent(GemData).val(始终可变)GemData.get(entity).val(不可变,速度快),或 GemData.getMutable(entity).val = X(仅在写入时使用)
修改组件数据修改返回的引用必须使用 getMutable() — .get() 返回只读引用。使用 .get() 并修改它会静默失败
设置父级child.setParent(parent)设置子实体 Transform 的 parent 字段:Transform.create(child, { parent: parentEntity }) 或 Transform.getMutable(child).parent = parentEntity
定义系统class MoveGems implements ISystem { update(dt) {...} } then engine.addSystem(new MoveGems())export function moveGemsSystem(dt: number) { ... } then engine.addSystem(moveGemsSystem)
查询实体const group = engine.getComponentGroup(Transform, GemData) then group.entitiesfor (const [entity, gemData, transform] of engine.getEntitiesWith(GemData, Transform)) { ... }
移除实体engine.removeEntity(entity)engine.removeEntity(entity)(相同 API)
组件上的事件回调entity.addComponent(new OnPointerDown((e) => {...}))pointerEventsSystem.onPointerDown({entity, opts}, () => {...}) — 与组件分离
静态“场景级”对象Camera.instance、Input.instance(单例)engine.PlayerEntity、engine.CameraEntity、engine.RootEntity + 读取组件(Transform.get(engine.PlayerEntity))
可变性是一项性能约定,而不只是代码风格。始终优先使用 .get()(读取),而不是 .getMutable()(写入)。如果只读取却使用 .getMutable(),每一帧都会发送一个 CRDT 增量。

API 映射 — 快速参考

完整表格请参阅 {baseDir}/references/api-mapping.md。最常见的转换如下:

SDK6SDK7
import { ... } from 'decentraland-ecs'(通常为隐式导入)import { engine, Transform, GltfContainer, Vector3, Quaternion, Color4 } from '@dcl/sdk/ecs' + '@dcl/sdk/math'
new Entity() + engine.addEntity(entity)const entity = engine.addEntity()
entity.addComponent(new Transform({position, rotation, scale}))Transform.create(entity, { position, rotation, scale })
entity.getComponent(Transform)(读取/写入)Transform.get(entity)(读取)/ Transform.getMutable(entity)(写入)
entity.getComponentOrCreate(Transform)Transform.getOrCreateMutable(entity)
entity.addComponentOrReplace(...)Component.createOrReplace(entity, {...})
entity.hasComponent(Transform)Transform.has(entity)
entity.removeComponent(Transform)Transform.deleteFrom(entity)
child.setParent(parent)Transform.getMutable(child).parent = parent(或在 create 中传入 parent)
new GLTFShape('models/x.glb') + entity.addComponent(shape)GltfContainer.create(entity, { src: 'models/x.glb' })
new BoxShape() / new PlaneShape() / new SphereShape() / new CylinderShape()MeshRenderer.setBox(entity) / setPlane(entity) / setSphere(entity) / setCylinder(entity)(如需碰撞检测,请添加 MeshCollider.setBox(entity) 等)
new Material() + m.albedoColor = Color3.Blue() + entity.addComponent(m)Material.setPbrMaterial(entity, { albedoColor: Color4.Blue() })(注意:SDK7 中使用 Color4)
new BasicMaterial() + m.texture = new Texture(...)Material.setBasicMaterial(entity, { texture: Material.Texture.Common({ src }) })
new TextShape(text) + t.fontSize = 1TextShape.create(entity, { text, fontSize: 1 })
new Animator() + new AnimationState('Clip') + animator.addClip(state)Animator.create(entity, { states: [{ clip: 'Clip', playing: false, loop: false }] });使用 Animator.playSingleAnimation(entity, 'Clip') 播放;使用 Animator.stopAllAnimations(entity) 停止
在实体上使用 new OnPointerDown(handler, { button, hoverText })pointerEventsSystem.onPointerDown({ entity, opts: { button: InputAction.IA_POINTER, hoverText } }, handler)
Input.instance.subscribe('BUTTON_DOWN', ActionButton.POINTER, false, handler)在系统中使用 inputSystem.isTriggered(InputAction.IA_POINTER, PointerEventType.PET_DOWN),或对特定实体使用 pointerEventsSystem.onPointerDown
ActionButton.POINTER / .PRIMARY / .SECONDARYInputAction.IA_POINTER / .IA_PRIMARY / .IA_SECONDARY
Camera.instance.position / .rotationTransform.get(engine.CameraEntity).position / .rotation
new Vector3(x, y, z)Vector3.create(x, y, z)
Quaternion.Euler(x, y, z)Quaternion.fromEulerDegrees(x, y, z)
Color3.Blue()Color4.Blue()(SDK7 的大多数组件字段使用 Color4)
Vector3.Lerp(a, b, t)Vector3.lerp(a, b, t)(小写 l)
Scalar.Lerp(a, b, t)Scalar.lerp(a, b, t)
vector.setAll(0.5)Vector3.create(0.5, 0.5, 0.5)(没有可变更对象的辅助方法——Vector3 被视为普通数据)
class X implements ISystem { update(dt) {...} } + engine.addSystem(new X())function xSystem(dt: number) { ... } + engine.addSystem(xSystem)
engine.getComponentGroup(A, B) 返回 .entitiesengine.getEntitiesWith(A, B) 返回由 [entity, a, b] 元组组成的可迭代对象
log(...)console.log(...)
entity.addComponent(new utils.Delay(ms, cb))(来自 decentraland-ecs-utils / @dcl-sdk/utils)timers.setTimeout(cb, ms)(import { timers } from '@dcl/sdk/ecs',参数顺序为 (callback, ms))
entity.addComponent(new utils.Interval(ms, cb))timers.setInterval(cb, ms)(import { timers } from '@dcl/sdk/ecs')
自定义逐帧 timerSystem 累积 dt删除它——改用 timers.setTimeout / timers.setInterval

迁移工作流

请按顺序执行。跳过步骤会导致场景损坏,且难以调试。

1. 审查 SDK6 项目

阅读每个源文件。列出以下内容:

  • 每个自定义 @Component 类——这些类将转换为具有扁平架构的 engine.defineComponent(...)。类方法(reset() 等)必须移到自由函数或系统中。
  • 每个 class X implements ISystem——这些类将转换为自由函数 (dt: number) => void。
  • 每个 OnPointerDown / OnPointerUp / Input.instance.subscribe——这些将转换为 pointerEventsSystem 调用,或系统中的 inputSystem.isTriggered()。
  • 每个 GLTFShape、BoxShape 等——请参阅映射表。
  • 每个 Camera.instance、Input.instance——这些将转换为静态引擎实体和组件读取。
  • 所有 setParent 调用——这些将转换为 Transform.parent 字段赋值。

此列表是步骤 5–10 的完成检查清单——只有当所属类别中的每一项都已迁移,且源代码中不再出现 SDK6 符号时,对应步骤才算完成。

2. 替换 package.json、tsconfig.json 并更新 scene.json
  • 将 decentraland-ecs 替换为 @dcl/sdk(最新版本)。
  • 将 build-ecs / dcl start 脚本替换为 sdk-commands start / sdk-commands build。
  • 将旧版 tsconfig.json 替换为标准 SDK7 配置(target: ES2020、module: ESNext、jsx: react-jsx、strict: true)。
  • 在 scene.json 中添加 "runtimeVersion": "7"。除非用户提出要求,否则不要修改 main。
3. 将资源文件夹整理到 assets/ 下

SDK6 场景通常会将资源放在项目根目录下的顶层文件夹中:models/、images/、audio/(或 sounds/)、textures/、videos/。SDK7 和 Creator Hub 要求资源放在顶层的 assets/ 文件夹下,以便可视化编辑器为其编制索引。请将此操作作为代码迁移前的独立步骤完成——这样后续步骤重写路径时就能直接指向最终位置。

  1. 识别 SDK6 项目中的所有顶层资源文件夹。常见的 SDK6 文件夹名称包括:models/、images/、audio/、sounds/、textures/、videos/。
  2. 创建 assets/ 下的新目录结构,并采用其他技能中使用的类别名称首字母大写的惯例(请参阅 [[create-scene]]、[[add-3d-models]]、[[audio-video]]):
    • models/ → assets/Models/
    • images/ → assets/Images/
    • audio/ 或 sounds/ → assets/Audio/
    • videos/ → assets/Videos/
    • textures/ → assets/Images/(纹理属于图片;除非用户想要单独的文件夹,否则请合并)
  3. 移动文件到新目录结构中。不要保留重复副本——移动完成后删除旧的顶层文件夹。双份文件会导致 Creator Hub 为过期路径编制索引,并使部署时的场景体积大约翻倍(两个副本都会被打包)。
  4. 重写所有代码引用,使其指向移动后的路径。在整个源代码树中搜索旧文件夹名称,并更新每个匹配项:
    • GltfContainer.create(e, { src: 'models/chair.glb' }) → ... { src: 'assets/Models/chair.glb' }
    • AudioSource.create(e, { audioClipUrl: 'sounds/click.mp3' }) → ... 'assets/Audio/click.mp3'
    • Material.Texture.Common({ src: 'textures/logo.png' }) → ... 'assets/Images/logo.png'
    • React-ECS <UiEntity uiBackground={{ texture: { src: 'images/icon.png' } }} /> → ... 'assets/Images/icon.png'
    • 代码、JSON 或 .composite 文件中任何提及旧文件夹名称的字符串字面量。
  5. **完成条件:**在项目中搜索旧文件夹名称时,不再有任何匹配项;在 Creator Hub 中打开场景时,资源树会从 assets/ 中载入资源。

例外——如果已有目录布局,则沿用现有布局。 如果项目中已包含 assets/scene/Models/(旧版 Creator Hub 布局)或 assets/asset-packs/ / assets/custom/(Creator Hub 会为资源包项目和自定义项目添加这些目录),请沿用这些路径,而不要另建平行的 assets/Models/。原则是:每种资源只使用一个规范位置,不要拆分存放。

4. 创建带有导出 main() 的 src/index.ts

SDK7 场景从顶层导出的 main() 函数开始。通常会将大部分代码放在另一个文件中(例如 src/game.ts),而 index.ts 只负责调用它:

import { initializeGame } from "./game";
export function main() {
  initializeGame();
}
5. 优先迁移组件

将每个 @Component 类转换为 engine.defineComponent(name, schema, defaults)。扁平化嵌套类型——Schemas 不支持任意类,但支持 Schemas.Vector3、Schemas.Quaternion、基本类型、数组和嵌套的 Schemas.Map(...)。SDK6 中的 Vector2 字段在 SDK7 中通常会变成两个标量字段(posX、posY)——请参阅 {baseDir}/references/migration-example.md 中的 2048 示例。

完成标准:grep -rn "@Component(" src/ 返回零条匹配结果,并且第 1 步审查清单上的每个组件都有对应的 engine.defineComponent 调用。

6. 迁移系统

每个 class X implements ISystem { update(dt) {...} } 都要转换为独立函数。将 engine.getComponentGroup(A, B).entities 迭代替换为 for (const [entity, a, b] of engine.getEntitiesWith(A, B))。在循环中,仅在需要写入时使用 .getMutable(entity)。

完成标准:grep -rnE "implements ISystem|getComponentGroup" src/ 返回零条匹配结果,并且第 1 步审查清单上的每个系统都通过 engine.addSystem 注册。

7. 迁移实体/组件设置(game.ts 的主要部分)

对于每个 new Entity() 代码块,都要将其替换为 engine.addEntity() 和一系列 Component.create(entity, ...) 调用。将 setParent 转换为 Transform 上的 parent 字段。对每个 src/audioClipUrl/texture.src,使用第 3 步中确定的新 assets/Models/...、assets/Audio/...、assets/Images/... 路径。

完成标准:grep -rnE "new Entity\(|\.addComponent\(|\.setParent\(" src/ 返回零条匹配结果,并且没有任何 src/audioClipUrl/纹理路径仍引用第 3 步之前的文件夹。

8. 迁移输入/指针处理程序
  • 每个实体的点击处理程序(new OnPointerDown(...))→ pointerEventsSystem.onPointerDown({ entity, opts }, handler)。如果实体还没有碰撞体,请添加一个(MeshCollider.setBox(entity) 或在 GltfContainer 上设置 visibleMeshesCollisionMask: 1)。请参阅 [[add-interactivity]]。
  • 全局按键订阅(Input.instance.subscribe)→ 在系统中使用 inputSystem.isTriggered(InputAction.IA_X, PointerEventType.PET_DOWN)。请参阅 [[advanced-input]]。

完成标准:grep -rnE "OnPointerDown|OnPointerUp|Input\.instance|ActionButton\." src/ 返回零条匹配结果,并且第 1 步审查清单上的每个处理程序都有对应的 pointerEventsSystem 或 inputSystem 实现。

9. 迁移动画

new Animator() + new AnimationState('clip') + animator.addClip(...) 将变成单个 Animator.create(entity, { states: [{ clip, playing, loop }] })。使用 Animator.playSingleAnimation(entity, 'clip') 播放。使用 Animator.stopAllAnimations(entity) 停止所有动画。请参阅 [[animations-tweens]]。

完成标准:grep -rnE "new Animator|AnimationState|addClip|getClip" src/ 返回零条匹配结果,并且 SDK6 的 playAnimation/getClip 调用中出现的每个片段名称都列在某个 states[] 数组中——未列入 states[] 的片段会静默失败(请参阅 Common Pitfalls 中有关 playSingleAnimation 的问题)。

10. 迁移声音

new AudioClip(url) + entity.addComponent(new AudioSource(clip)) 将变成 AudioSource.create(entity, { audioClipUrl: url, playing: false, ... })。将 AudioSource.getMutable(entity).playing = true 设为 true 即可播放。请参阅 [[audio-video]]。

完成标准:grep -rnE "new AudioClip|new AudioSource\(" src/ 返回零条匹配结果。

11. 验证和测试

运行 npm install 获取新的 SDK,然后运行 sdk-commands start(或 npm start)。只有满足以下所有条件,迁移才算完成:

  1. npm run build 的退出码为 0。
  2. 最后执行一次 grep -rnE "decentraland-ecs|@Component\(|implements ISystem|Input\.instance|Camera\.instance" src/ 检查,结果为零条命中——工作中断并恢复时容易漏掉逐步执行的 grep;这是端到端检查。
  3. 预览加载时,控制台没有错误。
  4. 可见布局(位置/旋转)与 SDK6 场景一致。

如果任一检查未通过,请返回负责该符号类别的步骤——不要声称迁移已完成。

常见陷阱

  • 修改 .get() 的结果:Transform.get(entity).position.x = 5 会静默地不执行任何操作。请使用 Transform.getMutable(entity).position.x = 5。
  • 向 Transform.create 显式传入 rotation: undefined / scale: undefined 会导致序列化器崩溃:表现为序列化时出现 "Cannot read properties of undefined (reading 'x')"。原因(已验证 — @dcl/ecs/dist/components/manual/Transform.js):Transform.create 会通过 TransformSchema.extend 处理该值,而该方法在默认值之后展开 ...value,因此显式传入的 undefined 字段会覆盖默认的单位旋转 / 单位缩放,并以 undefined 存储;随后二进制 serialize 读取 value.rotation.x 时就会抛出异常。如果迁移代码根据可能包含 undefined 字段的部分对象创建 Transform(例如 Transform.create(e, { position, rotation: maybeRot, scale: maybeScale })),就会触发此问题。修复方法 — 完全省略该字段,不要传入 undefined,这样 schema 才会应用默认值:
    const out: Partial<TransformType> = { position }
    if (rotation) out.rotation = rotation
    if (scale) out.scale = scale
    if (parent !== undefined) out.parent = parent
    Transform.create(entity, out)
    
  • 忘记 schema:engine.defineComponent 必须提供 schema。SDK6 中没有类型的类字段无法自动推断。
  • 组件方法:SDK6 组件通常带有 reset() 或构造函数逻辑。在 SDK7 中,必须将这些逻辑移到自由函数中 — 组件是纯数据。
  • Vector2 字段:Schemas 没有 Schemas.Vector2。[尚未验证 — 请通过读取 Schemas 对象的导出项确认。] 将字段拆成两个 Schemas.Number,或使用 Schemas.Map({ x: Schemas.Number, y: Schemas.Number })。
  • 在实体对象上调用 setParent:SDK7 中的实体是整数 — 没有此方法。请在子级的 Transform 上设置 parent 字段。
  • Color3 与 Color4:SDK7 中大多数组件字段都使用 Color4(带 alpha)。除非字段文档明确说明是 Color3,否则请将 Color3.X() 调用替换为 Color4.X()。
  • 材质不是实体或对象:不存在需要保留引用的 new Material() 实例。设置材质只能使用 Material.setPbrMaterial(entity, props) — 材质属于单个实体,而不是可共享对象。
  • 形状实体(BoxShape、PlaneShape 等)需要 MeshRenderer 和 MeshCollider:SDK6 中,单个 BoxShape 同时负责渲染和碰撞(通过标志控制)。SDK7 中这两个组件彼此独立 — 如果需要原始形状支持点击 / 物理效果,请同时添加二者。
  • GLTFShape 默认允许点击其可见网格(withCollisions: true):SDK7 中,GltfContainer 默认只在不可见的 _collider 网格上设置碰撞。有些模型可能不适合这种方式。如果实体因缺少碰撞体而无法点击,可能需要显式将 visibleMeshesCollisionMask 设置为包含点击所需的 CL_POINTER(1)、玩家碰撞所需的 CL_PHYSICS(2),或两者都需要时设置为 3。请参阅 [[add-3d-models]]。
  • 在组件组上使用 forEach 时,OnPointerDown 回调不再触发:SDK6 中它们由事件驱动;SDK7 中应使用 pointerEventsSystem.onPointerDown(...) 注册一次处理程序 — 不要在系统的更新循环中调用,否则每帧都会注册一个新处理程序。
  • 对不在 Animator.states 中的动画片段调用 Animator.playSingleAnimation 会静默地不执行任何操作(适用于在代码中构建 Animator 的迁移者):这具体适用于 SDK6 迁移路径:你在 TypeScript 中自行编写 Animator,并以编程方式调用 Animator.playSingleAnimation(entity, clipName)。如果 clipName 不在实体的 states 数组中,playSingleAnimation 就会返回 false 并且不执行任何操作(已验证 — @dcl/ecs/dist-cjs/components/extended/Animator.js 第 35-46 行)。不会发出警告。SDK6 隐藏了这个问题 — Animator.getClip(name) 会在首次使用时自动创建动画片段,因此 SDK6 场景通常会在没有预先声明 'Appear' 的情况下调用 playAnimation('Appear')。如果直接将 entity.getComponent(Animator).getClip(name).play() 映射为 Animator.playSingleAnimation(entity, name),而迁移者从未将这些动画片段名称添加到 states[],这些调用就会静默失败。修复方法:遍历 SDK6 源码中的每个 playAnimation / getClip 调用,收集每个实体的全部动画片段名称,并在调用 Animator.create 时将它们全部列入 states 数组。若想快速添加适合迁移的垫片,通过惰性执行 states.push 恢复 SDK6“无需设置即可播放任意动画片段”的便捷方式,请参阅 [[animations-tweens]] 中的包装器(PITFALL 部分)。此规则不适用于 Animator 由 Creator Hub Inspector 编写的场景(它会将 GLB 中的每个动画片段预先填入 states[]),也不适用于自带已填充 states 的资源包 / 智能物品,或完全没有 Animator 的模型 — 后者依赖下一条中的自动播放机制。
  • 将 anim.stop(); anim.play() 迁移为每帧调用的手写 SDK7 辅助函数会产生不必要的序列化开销:当你用自定义 SDK7 辅助函数替代 SDK6 的 anim.stop(); anim.play(),直接修改 Animator.states(s.playing、s.shouldReset),且该辅助函数由每帧执行的调用方触发时,就会遇到此问题 — 例如系统更新,或每个输入保持的时刻都会触发的输入 / 射线检测回调。动画不会冻结(@dcl/ecs 中的 CRDT 未变化可变值抑制层会检测相同写入并将其静默丢弃),但每个时刻都调用 getMutableOrNull() 会带来不必要的序列化和逐字节比较开销。修复方法:跟踪每个实体上最后应用的动画片段,并在其未变化时提前返回;对于使用同一动画片段再次调用 noLoop + revertToIdle 的情况,只刷新恢复计时器,不要再次修改状态;在恢复回调中重新应用 idle 动画片段前,清除跟踪的动画片段。以下情况不适用:辅助函数仅在状态转换时调用(一次性的 onPointerDown、边沿触发的状态机),或通过状态转换而非每帧调用方使用 Animator.playSingleAnimation。有关完整辅助函数模式,请参阅 [[animations-tweens]] 中“缩短动画片段切换辅助函数执行路径”的最佳实践。
  • 未附加 Animator 时,GltfContainer 会自动播放多动画 .glb 中的一个动画片段:SDK6 模型默认保持绑定姿势;SDK7 会从 .glb 中选取一个动画片段并播放。这与使没有动画片段注册的 Inspector 场景也能正常工作的机制相同 — 它是上一条的补充,而不是矛盾。实体没有 Animator 时,渲染器会自动播放;实体带有 Animator 且你调用 playSingleAnimation 时,动画片段必须在 states[] 中。渲染器自动播放哪个动画片段因模型而异,无法保证稳定 — 已观察到的情况包括一个万圣节场景中没有 Animator 的小幽灵直接从 GLB 自动播放 die。若要使用不同的默认动画,修复方法是:附加 Animator.create(entity, { states: [{ clip: '<intended-default>', playing: true, loop: true }] }) 以接管控制。若还要在运行时通过 playSingleAnimation 切换动画片段,修复方法是:将所有要切换到的动画片段列入 states[](参阅上一条)。
  • 使用 BUTTON_DOWN / BUTTON_UP 调用 Input.instance.subscribe:没有可直接对应的回调。请在系统中使用 inputSystem.isTriggered() 检查“本帧刚按下”。
  • @dcl/ecs-scene-utils 触发区域(“Utils 库”)以及手写的逐帧 AABB 检查:不要迁移自定义的逐帧位置检查系统,也不要在 SDK7 中从头重建。SDK6 没有原生触发区域,因此社区 Utils 库(以及许多场景中的自定义代码)会在每帧轮询 Camera.instance.position,检查其是否位于盒形 / 球形区域内,并触发 onCameraEnter / onCameraExit。SDK7 提供了原生 TriggerArea 组件(@dcl/sdk/ecs)— 请将整套模式替换为 TriggerArea.setBox(entity)(或 .setSphere)以及 triggerAreaEventsSystem.onTriggerEnter(entity, cb) / .onTriggerExit / .onTriggerStay。尺寸取自 Transform.scale,位置取自 Transform.position,并且会考虑父级链 — 因此,将触发区域设为已旋转 / 偏移的场景根节点的子级后,无需手动转换坐标即可正常工作。行为一致性:Utils 库只会对本地玩家触发;原生 TriggerArea 默认使用 ColliderLayer.CL_PLAYER,会对该层上的任何玩家触发。若要仅针对本地玩家,正确的检查条件是 if (result.trigger?.entity !== engine.PlayerEntity) return(而不是 result.triggeredEntity — 尽管名称容易造成混淆,triggeredEntity 实际上是触发区域自身的实体,因此将它与玩家比较始终为真,检查条件永远不会触发)。完整组件参考请参阅 {baseDir}/references/api-mapping.md 的“触发区域”部分和 [[add-interactivity]]。
  • decentraland-ecs-utils 中的 utils.Delay / utils.ExpireIn(后来改为 @dcl-sdk/utils)以及手写的逐帧计时器系统:不要迁移社区的 Delay 组件,不要在 SDK7 中重建它,也不要编写由累加 dt 的 timerSystem 支持的自定义 setSceneTimeout 辅助函数。请使用 @dcl/sdk/ecs 的具名导出 timers(import { timers } from '@dcl/sdk/ecs')— 它提供绑定到场景引擎的 setTimeout / clearTimeout / setInterval / clearInterval。将 entity.addComponent(new utils.Delay(2000, cb)) 替换为 timers.setTimeout(cb, 2000),并删除 SDK6 场景中仅用于承载 Delay 组件而创建的“计时器实体”。参数顺序为 (callback, ms),而不是 (ms, callback) — SDK6 的 Delay(ms, cb) 顺序与 JS 标准相反;按 SDK6 顺序编写的自定义辅助函数是一个已知的陷阱,只会让一开始不该编写辅助函数的问题更加严重。不要使用原生 JS 的全局 setTimeout / setInterval — 尽管 QuickJS 提供了这些函数,它们没有绑定到场景引擎,可能导致难以察觉的问题;请始终使用 timers。完整参考请参阅 [[scene-runtime]](计时器部分)。
  • SDK6 的“拦截工具”智能物品(钥匙、磁铁、库存物品等通过 Input.instance.subscribe('BUTTON_DOWN', ActionButton.POINTER, true, …) 和遍历实体层级,对点击的目标执行操作的物品)在 SDK7 中没有直接对应的实现。pointerEventsSystem.onPointerDown 仅针对单个实体 — 没有全局点击结果订阅器。迁移模式是建立一个以目标实体名称为键的小型注册表;目标自身的 pointerEventsSystem.onPointerDown 会先查询该注册表,再回退到默认行为。请参阅 {baseDir}/references/api-mapping.md#click-interception-across-entities-keytool-acting-on-a-target。
  • Vector3.GetAngleBetweenVectors、Vector3.Up() 等:SDK6 中有许多 Vector3 静态辅助方法。SDK7 仍在 @dcl/sdk/math 中提供大部分方法,但大小写已更改(例如 Vector3.Lerp → Vector3.lerp)。迁移后请搜索 Vector3.,并对照 math 模块核实每个调用。
  • SDK7 不导出 log():请替换为 console.log()。
  • 持握物品(枪、瞄准标、手电筒、固定位置工具)上的 Attachable.FIRST_PERSON_CAMERA / Attachable.AVATAR:SDK6 只提供两种粗略的跟随模式 — Attachable.FIRST_PERSON_CAMERA(跟随相机)和 Attachable.AVATAR(跟随玩家根节点)。SDK7 提供了此前没有的新选择:使用 anchorPointId(例如 AAPT_RIGHT_HAND)的骨骼级 AvatarAttach。骨骼锚点看起来像是 SDK7 中“将物品放到玩家手中”的自然对应方式,但骨骼锚点会继承玩家动画(待机摆动、行走循环、手势)— 附加到 AAPT_RIGHT_HAND 的枪会每帧抖动,无法瞄准。持握类游戏物品在 SDK7 中正确的映射方式是通过 Transform.parent 将实体设为子级,而不是使用 AvatarAttach。对于任何需要瞄准的物品(枪、瞄准标、手电筒,以及任何玩家应能通过环视来指向的物品),SDK7 中正确的默认值是 Transform.parent = engine.CameraEntity — 这对应 SDK6 的 FIRST_PERSON_CAMERA,同时跟踪水平和垂直视角,因此枪口会指向相机所看的方向。Transform.parent = engine.PlayerEntity 是另一种选择,也是一种常见的隐蔽故障模式:它跟随玩家根节点(脚部位置和仅水平旋转,不跟随垂直视角或动画),因此当玩家抬头瞄准时,附加到 PlayerEntity 的枪仍保持水平 — 物品不会跟随视线方向。只有对于不应跟随瞄准的身体固定物品(手持盾牌、静态火炬、挂在腰间且不用于瞄准的库存物品),才使用 PlayerEntity。AvatarAttach 仅用于希望随动画运动的装饰物品(帽子、光环、背包、姓名牌)。请根据用途选择:engine.CameraEntity(需要瞄准,持握武器的推荐默认值)、engine.PlayerEntity(仅水平旋转 / 不跟随垂直视角,用于身体固定物品)、AvatarAttach(boneId)(仅用于装饰,随骨骼动画)。对比表和完整的枪支示例,请参阅 [[player-avatar]] 中的“持握物品与装饰物品”部分。
  • 迁移后,带 Billboard 的模型可能会背对相机:在一些 SDK6 → SDK7 迁移案例中观察到此现象 — 在 SDK6 的 new Billboard(false, true, false)(Y 轴 Billboard)下正面朝外的模型,在 SDK7 的 Billboard.create(e, { billboardMode: BillboardMode.BM_Y }) 下,即使父子级设置相同,也会背对相机。这两个 SDK 似乎对 Billboard 朝向相机的模型面(+Z 或 -Z)有不同约定。SDK7 的 PBBillboard 接口没有 oppositeDirection 标志 — 唯一字段是 billboardMode(已根据 @dcl/ecs 的 billboard.gen.d.ts 验证),因此应修改模型的 Transform,而不是 Billboard 组件。将显示模型绕 Y 轴旋转 180°(Quaternion.fromEulerDegrees(0, 180, 0))。如果实体结构是一个设有 Billboard 的父级和一个子级模型,请将 180° 旋转应用到子级的 Transform.rotation — 父实体的旋转由 Billboard 组件控制,在带 Billboard 的实体上覆盖该旋转不会产生效果。[尚未验证这是有变更日志记录的 SDK 显式行为变更,还是模型导入器中另一项约定变更所导致 — 面向用户解释时,请表述为“两个 SDK 对 Billboard 的朝向约定不同”,不要声称是某个特定版本引入了此变化。] SDK7 组件参考请参阅 [[advanced-rendering]](Billboard)。
  • UI 像素尺寸不会按 1:1 沿用:SDK6 UI(UICanvas / UIImage / UIText)是以固定屏幕尺寸为基准,使用原始像素编写的,因此像 width = 200 或 positionX = -350 这样的尺寸和位置都是绝对值。SDK7 React-ECS 会根据虚拟画布缩放像素值;在 SDK 7.26.0 及更高版本中,如果不指定虚拟画布,它会按平台应用默认值(1920x1080,移动端为 1600x720)— 这很可能与编写 SDK6 UI 时使用的网格不同。在低于 7.26.0 的版本中没有默认值,不指定虚拟画布就完全不会缩放,因此布局会随显示设备变化而偏移。请在 ReactEcsRenderer.setUiRenderer 中显式设置虚拟画布,让引擎将像素值视为编写 UI 时所用空间中的坐标。迁移 UI 前,请查看 SDK6 源码(旧版 ECS 位于 https://github.com/decentraland/ecs),确认原始 UI 的像素网格,并将其作为虚拟尺寸传入:
    ReactEcsRenderer.setUiRenderer(uiRoot, { virtualWidth: 1920, virtualHeight: 1080 })
    
    1920x1080 是一个合理的默认展示值,但正确的 virtualWidth/virtualHeight 取决于编写 SDK6 UI 时所依据的分辨率 — 如果 SDK6 场景使用了不同的参考分辨率(例如 1280x720),请改用对应数值,确保现有像素坐标仍落在相同位置。在 7.26.0 及更高版本中,有两种行为可能会导致网格偏移:场景在移动端运行时,16:9 尺寸会被覆盖为 1600x720;任何 <= 0 的值都会禁用缩放。完整的 React-ECS 重写说明及 SDK 版本限制,请参阅 [[build-ui]]。

难以真正迁移的内容

这些内容无法直接一一对应,需要重新设计:

  • 包含会执行操作的方法或构造函数的组件(例如 GemData.reset(val, x, y)):将数据拆分到新组件中,把逻辑移至自由函数,该函数接收 (entity: Entity, val: number, ...),并调用 Component.getMutable(entity)。
  • 用户会持有引用并多次迭代的组件组:SDK7 每次调用都会重新计算 engine.getEntitiesWith(...)。对于针对同一集合的热点循环,可以在系统中缓存结果,或接受这项开销。
  • 依赖 entity.alive 的对象池:SDK7 的实体没有 alive 标志。对象池必须使用自定义组件跟踪实体是否存活,或通过检查 Component.has(entity) 来判断。Migrated-2048 示例将其简化为“始终添加新实体”——对于低吞吐量场景,这样做可以接受,但真正的对象池需要显式状态。
  • 使用 SDK6 UICanvas / UIImage / UIText / UIClickable 构建的 UI:与 SDK7 API 不兼容。SDK7 UI 使用 React-ECS(JSX)。请参阅 [[build-ui]]——这通常需要从头重写。移植时,还要在 ReactEcsRenderer.setUiRenderer 上设置虚拟画布大小,这样 SDK6 的像素坐标才能在不同屏幕上按预期缩放——请参阅上文 常见陷阱 中的 UI 像素尺寸问题。
  • @Component('name') 装饰器元数据:SDK7 组件 ID 是传递给 engine.defineComponent('name', ...) 的字符串。如果其他工具(composites、多人游戏同步)按名称引用组件,请使用与 SDK6 装饰器相同的名称。否则,请选择一致的命名方案。

交叉引用

  • [[create-scene]] — scene.json 架构、优先使用 composite 的规则,以及如何为全新的 SDK7 项目搭建脚手架
  • [[composites]] — .composite 文件格式,用于第二个可选阶段,将静态实体从 TypeScript 移出
  • [[add-3d-models]] — GltfContainer、用于实现可点击性的碰撞体掩码
  • [[add-interactivity]] — pointerEventsSystem(替代 SDK6 OnPointerDown)
  • [[advanced-input]] — inputSystem 轮询(替代 SDK6 Input.instance.subscribe)
  • [[scene-runtime]] — @dcl/sdk/ecs 中的 timers.setTimeout / timers.setInterval(替代社区库 SDK6 decentraland-ecs-utils 的 Delay / Interval)、executeTask、fetch、realm/场景信息
  • [[animations-tweens]] — Animator 和 Tween(替代 SDK6 Animator/AnimationState)
  • [[audio-video]] — AudioSource 和 VideoPlayer
  • [[build-ui]] — React-ECS UI(替代 SDK6 UICanvas UI 系统)
  • [[advanced-rendering]] — PBR 材质、TextShape、Billboard
  • [[multiplayer-sync]] — 用于多人游戏的 syncEntity(替代 SDK6 MessageBus / 同步库)

参考资料

  • {baseDir}/references/api-mapping.md — SDK6 → SDK7 符号转换完整表格
  • {baseDir}/references/migration-example.md — 使用 2048 游戏场景标注的迁移前后对照(实体设置、组件、系统、指针事件、动画)

评论

支持 Markdown