dsh-unity-mcp 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  需要 Node.js 22+、DSH `>=0.2.1-rc.3 <0.3.0`。在 DSH 插件管理中安装:
18
18
 
19
19
  ```text
20
- dsh-unity-mcp
20
+ dsh-unity-mcp@0.0.10
21
21
  ```
22
22
 
23
23
  本地工程管理目前只支持 Windows。上游声明 Unity 2022.3+;本插件已在独立团结工程中验证,但不能保证所有 Unity / Tuanjie 版本都兼容。
@@ -32,7 +32,7 @@ Agent 加载工具后,先用 `unity_project` 的 `list` 查看打开的工程
32
32
 
33
33
  安装 CoderGamester 和保存 DSH 连接分别由独立工具处理,避免把打开工程、修改包和切换所有会话的连接混为一次操作。工程处于关闭状态时,也可以先离线准备安装,再打开编辑器。包依赖固定在已核验的上游 commit,已有不同的 CoderGamester 依赖时不会擅自替换;其他包会保留。
34
34
 
35
- **打开工程需要时间。** 工具会等待编辑器完成当前导入、编译并回应新鲜握手:返回 `editor_ready` / `editorReady: true` 才代表这次编辑器就绪检查通过。`list` 中的 `running_not_verified` 只表示发现了进程。`status` 返回 `readiness_unconfirmed` 或 `open` 返回 `editor_loading` 时,应继续检查或等待,不要重复启动或假设工程已经加载完成。运行或启动中的工程,安装工具也会在修改依赖前执行就绪检查;等待失败不会改写依赖。关闭工程的离线安装只代表文件准备完成,不代表工程已打开。
35
+ **打开工程需要时间。** 工具会等待编辑器完成当前导入、编译并回应新鲜握手:返回 `editor_ready` / `editorReady: true` 才代表这次编辑器就绪检查通过。`list` 中的 `running_not_verified` 只表示发现了进程。`status` 或 `open` 返回 `readiness_unconfirmed` 时,`running: true` 表示已发现该工程进程,`editorReady: null` 表示未能确认就绪,不能据此断定仍在加载。`process_unconfirmed` 表示部分进程的工程路径不可读,不能确认该工程是否运行。不要重复启动;可先截图查看编辑器。运行或启动中的工程,安装工具也会在修改依赖前执行就绪检查;等待失败不会改写依赖。关闭工程的离线安装只代表文件准备完成,不代表工程已打开。
36
36
 
37
37
  编辑器就绪仍不等于 MCP 连通。Package Manager 还需下载和编译引擎包,服务需要成功启动;Node 服务器缺少构建文件时,还需在其 `Server~` 目录执行 `npm install` 和 `npm run build`。安装工具不会代为下载或构建 Node 服务器,也不自动改变 DSH 连接。
38
38
 
@@ -91,7 +91,8 @@ Agent 加载工具后,先用 `unity_project` 的 `list` 查看打开的工程
91
91
 
92
92
  ## 排障
93
93
 
94
- - **工程一直是 `editor_loading`**:检查编辑器中的包解析、编译错误、模态窗口或自动刷新;修复后继续等待。不要因为超时就再次启动编辑器。
94
+ - **工程就绪检查一直未确认**:进程已发现时不会据此报为“仍在加载”。用 `unity_capture_window` 并指定 `projectPath` 查看编辑器面板,再检查包解析、编译错误、模态窗口或自动刷新;不要因为超时再次启动编辑器。
95
+ - **查看编辑器窗口**:懒加载后可调用 `unity_capture_window({projectPath: "C:/Projects/MyGame"})`,不依赖 MCP 或 Editor 桥接。仅支持 Windows,返回指定工程主窗口的图片附件,不抓桌面、不切换焦点、不还原最小化窗口。最小化、窗口不可用会报错;GPU 视图可能黑屏或未刷新,主窗口截图也不保证包含独立弹窗。截图可能包含工程中的敏感内容。工具仅为截图 PowerShell 子进程指定 `-ExecutionPolicy Bypass`,不修改系统或用户策略。
95
96
  - **安装等待失败**:运行中的工程尚未回应就绪检查,依赖未修改。先让工程完成加载;若修改过桥接源码,需人工处理冲突。
96
97
  - **Built CoderGamester server not found**:确认引擎包已经安装且 Node 服务器已构建,或手工设置 `serverPath`。
97
98
  - **Multiple cached CoderGamester servers found**:明确选择 `serverPath`。
@@ -109,3 +110,9 @@ npm run pack:check
109
110
  测试覆盖服务器发现、配置边界、就绪等待、安装保护、保存关闭和 Agent 隔离。上下文大小测试比较 UTF-8 schema 字节数,不把字节数当作模型 token 数。
110
111
 
111
112
  本适配插件采用 MIT 许可证。上游 [CoderGamester/mcp-unity](https://github.com/CoderGamester/mcp-unity) 也采用 MIT 许可证;本包不分发上游引擎或服务器源码。图标由 Krea 2 Turbo 生成,不使用 Unity 官方标志。许可与素材说明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
113
+
114
+ ## 按需技能目录
115
+
116
+ - [unity-safe-verification](skills/unity-safe-verification/SKILL.md):安全操作、回读和 Play 验收。
117
+
118
+ 这些技能随 npm 包分发,由插件启用时注册,禁用或卸载时撤销;正文仍按需加载,资源路径指向包内目录。无需另建用户级副本或目录联接。旧版已有的同名本地入口应核对来源后移到技能扫描范围之外备份,不删除技能正文。独立用户技能不会被插件改写或删除;同名条目遵循宿主优先级,禁用插件后用户条目仍可使用。跨插件技能引用需要启用对应插件。
package/index.mjs CHANGED
@@ -4,9 +4,11 @@ import { connectionConfig } from './lib/connection.mjs';
4
4
  import { installLazyTools } from './lib/lazy-tools.mjs';
5
5
  import { projectTool } from './lib/project.mjs';
6
6
  import { installationTool } from './lib/install-codergamester.mjs';
7
+ import { screenshotTool } from './lib/capture-window.mjs';
7
8
  import { configurationTool } from './lib/configure-project.mjs';
9
+ import { installPackagedSkills } from './lib/packaged-skills.mjs';
8
10
  export const name = 'dsh-unity-mcp';
9
- export const inject = ['tools', 'agents', 'configEditor'];
11
+ export const inject = ['tools', 'agents', 'configEditor', 'attachments', 'skills'];
10
12
  export const Config = z.object({
11
13
  projectPath: z.string().default('').description('Unity / Tuanjie 项目绝对路径 / Absolute project directory'),
12
14
  serverPath: z.string().default('').description('CoderGamester Server~/build/index.js 绝对路径;留空从项目发现 / Built server path; blank discovers in project'),
@@ -17,8 +19,9 @@ export const Config = z.object({
17
19
  toolCallTimeoutMs: z.number().min(1000).max(600000).step(1).default(120000).description('工具超时(毫秒) / Tool timeout (ms)')
18
20
  });
19
21
  export async function apply(ctx, config) {
22
+ installPackagedSkills(ctx, new URL('./skills/', import.meta.url));
20
23
  // Install the loader even before configuration, so discovery explains setup.
21
- installLazyTools(ctx, config.lazyTools, [projectTool(ctx), installationTool(), configurationTool(ctx)]);
24
+ installLazyTools(ctx, config.lazyTools, [projectTool(ctx), screenshotTool(ctx), installationTool(), configurationTool(ctx)]);
22
25
  if (!config.projectPath && !config.serverPath) {
23
26
  ctx.logger.info('Unity MCP: configure projectPath or serverPath to connect CoderGamester/mcp-unity.');
24
27
  return;
@@ -0,0 +1,35 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { matchingProcess, projectRoot } from './project-lifecycle.mjs';
5
+ const exec = promisify(execFile);
6
+ export async function captureWindow(pid) {
7
+ const { stdout } = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', fileURLToPath(new URL('./capture-window.ps1', import.meta.url)), '-EditorPid', String(pid)], { windowsHide: true, timeout: 20000, maxBuffer: 12 * 1024 * 1024 });
8
+ return JSON.parse(stdout.replace(/^\uFEFF/, '').trim());
9
+ }
10
+ export async function captureProject(input, deps = {}) {
11
+ if ((deps.platform ?? process.platform) !== 'win32') throw Error('Editor window capture currently supports Windows only.');
12
+ if (!input || Object.keys(input).some(key => key !== 'projectPath')) throw Error('Capture accepts only projectPath.');
13
+ const root = await projectRoot(input.projectPath);
14
+ const editor = await matchingProcess(root, deps.processes);
15
+ if (!editor) throw Error('No matching editor process. Use unity_project action=list to inspect running editors.');
16
+ const image = await (deps.capture ?? captureWindow)(editor.ProcessId);
17
+ if (image.pid !== editor.ProcessId) throw Error('Screenshot process identity mismatch.');
18
+ const after = await matchingProcess(root, deps.processes);
19
+ if (after?.ProcessId !== editor.ProcessId) throw Error('Editor changed during capture; screenshot discarded.');
20
+ const data = Buffer.from(image.data ?? '', 'base64');
21
+ if (data.length < 24 || data.length > 8 * 1024 * 1024 || data.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' || data.toString('base64') !== image.data) throw Error('Invalid PNG window capture.');
22
+ if (data.readUInt32BE(16) !== image.width || data.readUInt32BE(20) !== image.height) throw Error('Screenshot dimensions mismatch.');
23
+ return { projectPath: root, pid: editor.ProcessId, title: image.title, width: image.width, height: image.height, method: image.method, data };
24
+ }
25
+ export function screenshotTool(ctx, deps = {}) {
26
+ return { name: 'unity_capture_window', description: 'Capture the main Windows Unity/Tuanjie editor window for an explicit project, including editor panels, without MCP or an Editor bridge. Returns an image. Does not focus/restore windows or capture the desktop. Minimized or unavailable windows fail; GPU-rendered views may be black or stale.',
27
+ parameters: { type: 'object', properties: { projectPath: { type: 'string', description: 'Absolute directory of the open project. Use unity_project action=list to select it.' } }, required: ['projectPath'], additionalProperties: false },
28
+ output: { schema: { type: 'object' }, render: (_, value) => [{ type: 'text', text: JSON.stringify({ ...value, attachment: undefined }) }, { type: 'image', attachment: value.attachment }] },
29
+ execute: async input => {
30
+ const { data, ...metadata } = await captureProject(input, deps);
31
+ const attachment = await ctx.attachments.saveImage({ data, mediaType: 'image/png', name: 'unity-editor.png' });
32
+ return { ...metadata, attachment };
33
+ }
34
+ };
35
+ }
@@ -0,0 +1,40 @@
1
+ param([int]$EditorPid)
2
+ $ErrorActionPreference = 'Stop'
3
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
4
+ Add-Type -AssemblyName System.Drawing
5
+ Add-Type @'
6
+ using System;
7
+ using System.Runtime.InteropServices;
8
+ public static class DshEditorCapture {
9
+ [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; }
10
+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out Rect r);
11
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
12
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
13
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
14
+ [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags);
15
+ [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
16
+ }
17
+ '@
18
+ [void][DshEditorCapture]::SetProcessDPIAware()
19
+ $p = Get-Process -Id $EditorPid -ErrorAction Stop
20
+ $h = $p.MainWindowHandle
21
+ if ($h -eq [IntPtr]::Zero -or -not [DshEditorCapture]::IsWindowVisible($h)) { throw 'Editor has no visible main window yet.' }
22
+ if ([DshEditorCapture]::IsIconic($h)) { throw 'Editor window is minimized. Restore it before capture; no desktop fallback was used.' }
23
+ [uint32]$ownerId = 0
24
+ [void][DshEditorCapture]::GetWindowThreadProcessId($h, [ref]$ownerId)
25
+ if ($ownerId -ne $EditorPid) { throw 'Window process identity changed.' }
26
+ $r = New-Object DshEditorCapture+Rect
27
+ if (-not [DshEditorCapture]::GetWindowRect($h, [ref]$r)) { throw 'Cannot read editor window bounds.' }
28
+ $w = $r.Right - $r.Left; $height = $r.Bottom - $r.Top
29
+ if ($w -lt 1 -or $height -lt 1 -or $w -gt 8192 -or $height -gt 8192 -or [long]$w * $height -gt 24000000) { throw 'Editor window dimensions exceed capture limits.' }
30
+ $bitmap = New-Object System.Drawing.Bitmap($w, $height)
31
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
32
+ $stream = New-Object System.IO.MemoryStream
33
+ try {
34
+ $dc = $graphics.GetHdc()
35
+ try { $ok = [DshEditorCapture]::PrintWindow($h, $dc, 2) } finally { $graphics.ReleaseHdc($dc) }
36
+ if (-not $ok) { throw 'Window capture failed; no desktop fallback was used.' }
37
+ $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
38
+ if ($stream.Length -gt 8388608) { throw 'Screenshot exceeds 8 MiB.' }
39
+ @{ pid=$EditorPid; title=$p.MainWindowTitle; width=$w; height=$height; data=[Convert]::ToBase64String($stream.ToArray()); method='PrintWindow' } | ConvertTo-Json -Compress
40
+ } finally { $graphics.Dispose(); $bitmap.Dispose(); $stream.Dispose() }
@@ -21,11 +21,12 @@ async function finishOpen(result, input, deps) {
21
21
  await (deps.waitReady ?? waitForEditor)(result.projectPath, input.timeoutMs ?? 120000, deps);
22
22
  return { ...result, status: 'editor_ready', editorReady: true, message: 'Editor responded after import/compilation settled. MCP connectivity is not verified.' };
23
23
  } catch (error) {
24
- return { ...result, status: 'editor_loading', editorReady: false, message: 'Not ready: ' + error.message + ' Do not assume loading finished. Retry unity_project action=status or open to wait again; do not relaunch or configure MCP yet.' };
24
+ return { ...result, status: 'readiness_unconfirmed', running: true, editorReady: null, message: 'Editor process was found or launched, but readiness is unconfirmed: ' + error.message + ' This does not prove it is loading or stopped. Inspect the editor window or retry unity_project action=status; do not relaunch.' };
25
25
  }
26
26
  }
27
- async function powershell(script) {
28
- const { stdout } = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { windowsHide: true, timeout: 15000, maxBuffer: 1024 * 1024 });
27
+ export async function powershell(script) {
28
+ const prefix = '[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $ErrorActionPreference = "Stop"; ';
29
+ const { stdout } = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(prefix + script, 'utf16le').toString('base64')], { windowsHide: true, timeout: 15000, maxBuffer: 12 * 1024 * 1024 });
29
30
  return JSON.parse(stdout.replace(/^\uFEFF/, '').trim() || 'null');
30
31
  }
31
32
  export function projectFromCommandLine(command = '') {
@@ -66,6 +67,7 @@ export async function openProject(input, deps = {}) {
66
67
  const target = projectFromCommandLine(row.CommandLine);
67
68
  if (target && await realpath(target).then(p => p.toLowerCase() === key, () => false)) return await finishOpen({ status: 'already_open', projectPath, editorPath: row.ExecutablePath || '', version, pid: row.ProcessId }, input, deps);
68
69
  }
70
+ if ((Array.isArray(rows) ? rows : [rows]).some(row => row && !projectFromCommandLine(row.CommandLine))) throw Error('An editor has inaccessible project arguments; cannot safely rule out a duplicate launch. Inspect running editor windows.');
69
71
  if (Date.now() - (recentLaunches.get(key) ?? 0) < 60000) throw Error('A launch was requested within the last 60 seconds; wait for the editor before retrying.');
70
72
  const lock = await stat(path.join(projectPath, 'Temp/UnityLockfile')).catch(e => { if (e.code !== 'ENOENT') throw e; });
71
73
  if (lock) throw Error('UnityLockfile exists but no matching editor could be confirmed. Refusing duplicate launch; check the editor/lock manually.');
@@ -0,0 +1,27 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ export function readPackagedSkills(directory) {
5
+ return readdirSync(directory, { withFileTypes: true })
6
+ .filter(entry => entry.isDirectory()).map(entry => entry.name).sort().map(name => {
7
+ const base = new URL(`${name}/`, directory);
8
+ const path = new URL('SKILL.md', base);
9
+ const source = readFileSync(path, 'utf8');
10
+ const match = /^---\r?\nname: ([^\r\n]+)\r?\ndescription: ([^\r\n]+)\r?\n---\r?\n([\s\S]*)$/u.exec(source);
11
+ if (!match || match[1] !== name || !match[2].trim()) {
12
+ throw new Error(`Invalid packaged skill frontmatter: ${name}`);
13
+ }
14
+ return {
15
+ name, description: match[2], content: match[3], source: 'runtime',
16
+ path: fileURLToPath(path),
17
+ resourceBase: { kind: 'directory', path: fileURLToPath(base) },
18
+ invocation: { modelInvocable: true, userInvocable: true },
19
+ };
20
+ });
21
+ }
22
+
23
+ export function installPackagedSkills(ctx, directory) {
24
+ // Validate all plain name/description frontmatter before registering any entry.
25
+ const skills = readPackagedSkills(directory);
26
+ for (const skill of skills) ctx.effect(() => ctx.skills.register(skill));
27
+ }
package/lib/project.mjs CHANGED
@@ -36,13 +36,16 @@ export async function manageProject(input, target = {}, deps = {}) {
36
36
  const inventory = await listProjects(target, deps);
37
37
  if (inventory.truncated) throw Error('Editor inventory exceeds 100 processes; status cannot safely select a project.');
38
38
  const matches = inventory.projects.filter(p => p.projectPath.toLowerCase() === root.toLowerCase());
39
- if (!matches.length) return { status: 'not_running', projectPath: root, editorReady: false, connection: target, message: 'No matching editor process found; MCP connectivity is not verified.' };
39
+ if (!matches.length) {
40
+ const unknown = inventory.projects.some(p => p.state === 'project_unknown');
41
+ return { status: unknown ? 'process_unconfirmed' : 'not_running', projectPath: root, running: unknown ? null : false, editorReady: unknown ? null : false, connection: target, message: unknown ? 'Some editor process arguments are unavailable. Cannot confirm whether this project is running; do not launch a duplicate.' : 'No matching editor process found; MCP connectivity is not verified.' };
42
+ }
40
43
  if (matches.length !== 1) throw Error('Multiple editor processes match this project. Resolve duplicates before acting.');
41
44
  try {
42
45
  await (deps.request ?? requestProject)({ ...input, timeoutMs: input.timeoutMs ?? 10000 }, 'ready', deps);
43
46
  return { ...matches[0], state: 'editor_ready', status: 'editor_ready', editorReady: true, connection: target, message: 'Editor readiness confirmed; MCP connectivity is not verified.' };
44
47
  } catch (error) {
45
- return { ...matches[0], status: 'readiness_unconfirmed', editorReady: false, connection: target, message: error.message };
48
+ return { ...matches[0], status: 'readiness_unconfirmed', running: true, editorReady: null, connection: target, message: 'Editor process is running; readiness is unknown, not proof of loading. ' + error.message };
46
49
  }
47
50
  }
48
51
  throw Error('Unknown project action.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-unity-mcp",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Unity and Tuanjie editor tools for DSH, powered by CoderGamester/mcp-unity with per-agent lazy loading.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@
23
23
  "./locale/*.json": "./locale/*.json"
24
24
  },
25
25
  "files": [
26
+ "skills",
26
27
  "index.mjs",
27
28
  "lib",
28
29
  "engine",
@@ -39,7 +40,7 @@
39
40
  }
40
41
  },
41
42
  "scripts": {
42
- "check": "node --check index.mjs && node --check lib/project.mjs && node --check lib/connection.mjs && node --check lib/engine-tools.mjs && node --check lib/lazy-tools.mjs && node --check lib/configure-project.mjs && node --check lib/open-project.mjs && node --check lib/project-lifecycle.mjs && node --check lib/install-codergamester.mjs",
43
+ "check": "node --check index.mjs && node --check lib/capture-window.mjs && node --check lib/project.mjs && node --check lib/connection.mjs && node --check lib/engine-tools.mjs && node --check lib/lazy-tools.mjs && node --check lib/configure-project.mjs && node --check lib/open-project.mjs && node --check lib/project-lifecycle.mjs && node --check lib/install-codergamester.mjs && node --check lib/packaged-skills.mjs",
43
44
  "test": "node --test test/*.test.mjs",
44
45
  "build": "npm run check",
45
46
  "pack:check": "npm pack --dry-run --json"
@@ -0,0 +1,32 @@
1
+ ---
2
+ name: unity-safe-verification
3
+ description: 操作 Unity 或团结工程、修改场景资产或交付可玩版本时使用;确认工程就绪、回读目标并在 Play 中验收。
4
+ ---
5
+
6
+ # Unity/团结安全操作与实机验收
7
+
8
+ ## 确认目标
9
+
10
+ 加载当前工具目录,枚举工程后固定绝对路径。分别确认编辑器就绪、MCP 连接、场景和 Play/Prefab 状态。进程、目录、握手只证明各自一层;只读场景查询需与工程一致。超时不重复启动或切换全局连接。
11
+
12
+ ## 修改前
13
+
14
+ 读取目标、受影响场景、Prefab、共享材质和引用,记录定位与原值,不按脚本类名猜对象名。明确世界/本地坐标、共享/实例资源、活动场景/另存目标。参数与枚举按当前 schema 核验。
15
+
16
+ 保存用户修改后再复制工程,磁盘副本不包含未保存状态。安装包、升级编辑器、切换连接、打开不可信项目均在授权范围内处理。
17
+
18
+ ## 写后回读
19
+
20
+ 小范围写入后查询对象实际值、组件、引用并检查控制台。回执不保证最终值;部分修改可能已发生,批量回滚按实际契约判断。修改共享资源前核对其他引用。超时先查状态,特别是播放切换、删除与父级变更。
21
+
22
+ ## 分层验收
23
+
24
+ 1. 文件:diff、资产与 meta 配对及引用;哈希只证明字节。
25
+ 2. 导入编译:等待完成,读取相关堆栈,不清日志掩盖错误。
26
+ 3. 场景:加载明确场景,核对层级和画面;默认空场景不证明工程损坏。
27
+ 4. Play:按需求走主路径及失败分支,检查输入、状态、真实画面和运行错误。静态测试不代替游玩。
28
+ 5. 持久化:退出 Play,确认编辑态结果并保存具名场景及资源,核对磁盘;运行态修改可能被丢弃。
29
+
30
+ 测试使用隔离对象或夹具,只清理自己的对象。报告各层验证范围;不能进入 Play 就列出未验证功能。
31
+
32
+ 例:位置修改后读取 Transform,再在目标场景检查落脚点;编译通过不证明角色没有悬空。