Bug fixes (verified against Cocos Creator 3.8.8): - diagnostics: shell:true for .cmd/.bat on Windows (T125/T126) - prefabs: duplicatePrefab target resolves under assets/ (T420) - cocos-project: add preview.open candidate for 3.8.8 (T444) - assets-advanced: detect directory assets in inspectAssetDependencies (T110) - scene: improve component-not-found errors with compilation hint (T429) Documentation improvements: - tool-registry: add enum and injected vars to execute_javascript, path format examples, asset ref limitation note - resources: add cocos://mcp/execute-context resource with variables, patterns, pitfalls New tools (core 37->38, full 101->110): - scene-management: create_scene, query_scene_state (core), copy_paste_node, rename_node, reparent_node - scripts: create_script with component/plain templates - prefabs: create_prefab - assets-advanced: batch_asset_ops, find_unused_assets
296 lines
9.3 KiB
JavaScript
296 lines
9.3 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { listAssets, queryAssetData, queryAssetInfo, queryAssetMeta } = require('./assets');
|
|
const { resolveProjectPath } = require('./path-safety');
|
|
|
|
function requestEditorMessage(channel, method, ...args) {
|
|
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
|
|
throw new Error('Editor.Message.request is unavailable in the Cocos extension host.');
|
|
}
|
|
return Editor.Message.request(channel, method, ...args);
|
|
}
|
|
|
|
function assetUrlToPath(projectPath, url) {
|
|
if (!url || !String(url).startsWith('db://assets/')) {
|
|
return '';
|
|
}
|
|
return path.join(projectPath, String(url).slice('db://'.length));
|
|
}
|
|
|
|
function assetFilePath(projectPath, info) {
|
|
const candidates = [
|
|
info && info.file,
|
|
info && info.path,
|
|
info && info.source,
|
|
info && info.url ? assetUrlToPath(projectPath, info.url) : '',
|
|
].filter(Boolean);
|
|
|
|
for (const candidate of candidates) {
|
|
const fullPath = path.isAbsolute(candidate)
|
|
? resolveProjectPath(projectPath, candidate)
|
|
: resolveProjectPath(projectPath, candidate);
|
|
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
|
|
return fullPath;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function collectUuidReferences(value, refs = [], pointer = '') {
|
|
if (!value || typeof value !== 'object') {
|
|
return refs;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
value.forEach((item, index) => collectUuidReferences(item, refs, `${pointer}/${index}`));
|
|
return refs;
|
|
}
|
|
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const childPointer = `${pointer}/${key}`;
|
|
if (
|
|
typeof child === 'string' &&
|
|
(key.toLowerCase().includes('uuid') || key === '__uuid__' || key === 'assetUuid' || key === 'prefabUuid')
|
|
) {
|
|
refs.push({ uuid: child, path: childPointer, key });
|
|
} else {
|
|
collectUuidReferences(child, refs, childPointer);
|
|
}
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
function getByJsonPath(target, jsonPath) {
|
|
const segments = String(jsonPath || '')
|
|
.replace(/^\//, '')
|
|
.split(/[/.]/)
|
|
.map((segment) => segment.trim())
|
|
.filter(Boolean);
|
|
let current = target;
|
|
for (const segment of segments) {
|
|
if (current == null) {
|
|
return undefined;
|
|
}
|
|
current = current[segment];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function setByJsonPath(target, jsonPath, value) {
|
|
const segments = String(jsonPath || '')
|
|
.replace(/^\//, '')
|
|
.split(/[/.]/)
|
|
.map((segment) => segment.trim())
|
|
.filter(Boolean);
|
|
if (!segments.length) {
|
|
throw new Error('jsonPath is required.');
|
|
}
|
|
let current = target;
|
|
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
const segment = segments[index];
|
|
if (current[segment] == null || typeof current[segment] !== 'object') {
|
|
current[segment] = {};
|
|
}
|
|
current = current[segment];
|
|
}
|
|
current[segments[segments.length - 1]] = value;
|
|
}
|
|
|
|
async function inspectPrefab(projectPath, target) {
|
|
const info = await queryAssetInfo(target);
|
|
const meta = await queryAssetMeta(target).catch(() => null);
|
|
const data = await queryAssetData(target).catch(() => null);
|
|
const filePath = assetFilePath(projectPath, info);
|
|
const content = filePath ? fs.readFileSync(filePath, 'utf8') : '';
|
|
const parsed = content ? JSON.parse(content) : data;
|
|
const references = collectUuidReferences(parsed).slice(0, 500);
|
|
|
|
return {
|
|
info,
|
|
meta,
|
|
filePath: filePath ? path.relative(projectPath, filePath).replace(/\\/g, '/') : '',
|
|
referenceCount: references.length,
|
|
references,
|
|
};
|
|
}
|
|
|
|
async function validatePrefabReferences(projectPath, options = {}) {
|
|
const targets = options.target
|
|
? [options.target]
|
|
: (await listAssets({ pattern: options.pattern || 'db://assets/**', ccType: 'cc.Prefab' }))
|
|
.slice(0, Number.isFinite(options.limit) ? Math.max(1, Math.min(200, options.limit)) : 50)
|
|
.map((asset) => asset.uuid || asset.url)
|
|
.filter(Boolean);
|
|
const prefabs = [];
|
|
|
|
for (const target of targets) {
|
|
const prefab = await inspectPrefab(projectPath, target);
|
|
const checked = [];
|
|
const missing = [];
|
|
for (const ref of prefab.references) {
|
|
try {
|
|
const info = await queryAssetInfo(ref.uuid);
|
|
checked.push({ ...ref, exists: true, asset: { uuid: info.uuid, url: info.url, type: info.type } });
|
|
} catch (error) {
|
|
missing.push({ ...ref, exists: false, error: error.message });
|
|
}
|
|
}
|
|
prefabs.push({
|
|
target,
|
|
filePath: prefab.filePath,
|
|
referenceCount: prefab.referenceCount,
|
|
checkedCount: checked.length + missing.length,
|
|
missingCount: missing.length,
|
|
missing,
|
|
});
|
|
}
|
|
|
|
const missingCount = prefabs.reduce((sum, prefab) => sum + prefab.missingCount, 0);
|
|
return {
|
|
ok: missingCount === 0,
|
|
prefabCount: prefabs.length,
|
|
missingCount,
|
|
prefabs,
|
|
};
|
|
}
|
|
|
|
async function duplicatePrefab(projectPath, options = {}) {
|
|
const source = String(options.source || '').trim();
|
|
const target = String(options.target || '').trim();
|
|
if (!source || !target) {
|
|
throw new Error('source and target are required.');
|
|
}
|
|
|
|
const info = await queryAssetInfo(source);
|
|
const sourcePath = assetFilePath(projectPath, info);
|
|
if (!sourcePath) {
|
|
throw new Error(`Prefab source file was not found: ${source}`);
|
|
}
|
|
|
|
const rawTarget = target.endsWith('.prefab') ? target : `${target}.prefab`;
|
|
const targetUnderAssets = rawTarget.replace(/^assets[\\/]/, '');
|
|
const targetPath = resolveProjectPath(projectPath, path.join('assets', targetUnderAssets));
|
|
const assetsRoot = path.join(projectPath, 'assets');
|
|
const relativeToAssets = path.relative(assetsRoot, targetPath);
|
|
if (relativeToAssets.startsWith('..') || path.isAbsolute(relativeToAssets)) {
|
|
throw new Error('target must be inside the Cocos assets directory.');
|
|
}
|
|
if (fs.existsSync(targetPath) && options.overwrite !== true) {
|
|
throw new Error(`Target prefab already exists: ${target}`);
|
|
}
|
|
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.copyFileSync(sourcePath, targetPath);
|
|
return {
|
|
duplicated: true,
|
|
source: path.relative(projectPath, sourcePath).replace(/\\/g, '/'),
|
|
target: path.relative(projectPath, targetPath).replace(/\\/g, '/'),
|
|
};
|
|
}
|
|
|
|
async function editPrefabJson(projectPath, options = {}) {
|
|
const target = String(options.target || '').trim();
|
|
if (!target) {
|
|
throw new Error('target is required.');
|
|
}
|
|
const info = await queryAssetInfo(target);
|
|
const filePath = assetFilePath(projectPath, info);
|
|
if (!filePath) {
|
|
throw new Error(`Prefab file was not found: ${target}`);
|
|
}
|
|
|
|
const original = fs.readFileSync(filePath, 'utf8');
|
|
let updated = original;
|
|
if (options.search !== undefined) {
|
|
const search = String(options.search);
|
|
if (!search) {
|
|
throw new Error('search must not be empty.');
|
|
}
|
|
if (!original.includes(search)) {
|
|
throw new Error('search text was not found in prefab file.');
|
|
}
|
|
updated = options.replaceAll
|
|
? original.split(search).join(String(options.replace || ''))
|
|
: original.replace(search, String(options.replace || ''));
|
|
} else {
|
|
const json = JSON.parse(original);
|
|
const value = JSON.parse(String(options.valueJson || 'null'));
|
|
setByJsonPath(json, options.jsonPath, value);
|
|
updated = JSON.stringify(json, null, 2) + '\n';
|
|
}
|
|
|
|
JSON.parse(updated);
|
|
if (options.createBackup) {
|
|
fs.writeFileSync(`${filePath}.bak`, original, 'utf8');
|
|
}
|
|
fs.writeFileSync(filePath, updated, 'utf8');
|
|
return {
|
|
edited: true,
|
|
path: path.relative(projectPath, filePath).replace(/\\/g, '/'),
|
|
oldValue: options.jsonPath ? getByJsonPath(JSON.parse(original), options.jsonPath) : undefined,
|
|
validation: await validatePrefabReferences(projectPath, { target }),
|
|
};
|
|
}
|
|
|
|
async function applyPrefabInstance(nodeUuid) {
|
|
const uuid = String(nodeUuid || '').trim();
|
|
if (!uuid) {
|
|
throw new Error('node uuid is required.');
|
|
}
|
|
const result = await requestEditorMessage('scene', 'apply-prefab', uuid);
|
|
return { applied: true, uuid, result };
|
|
}
|
|
|
|
async function revertPrefabInstance(nodeUuid) {
|
|
const uuid = String(nodeUuid || '').trim();
|
|
if (!uuid) {
|
|
throw new Error('node uuid is required.');
|
|
}
|
|
const candidates = ['revert-prefab', 'restore-prefab'];
|
|
let lastError = null;
|
|
for (const method of candidates) {
|
|
try {
|
|
const result = await requestEditorMessage('scene', method, uuid);
|
|
return { reverted: true, uuid, method, result };
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
throw lastError || new Error('No prefab revert editor message was available.');
|
|
}
|
|
|
|
async function createPrefab(options = {}) {
|
|
const nodeUuid = String(options.nodeUuid || '').trim();
|
|
const savePath = String(options.savePath || '').trim();
|
|
if (!nodeUuid) {
|
|
throw new Error('nodeUuid is required.');
|
|
}
|
|
if (!savePath) {
|
|
throw new Error('savePath is required.');
|
|
}
|
|
if (!savePath.endsWith('.prefab')) {
|
|
throw new Error('savePath must end with .prefab');
|
|
}
|
|
// Note: 'create-prefab' is not a public message in Cocos Creator 3.8.8
|
|
// (Public: No in editor-messages-3.8.8.md). It works but may change in future versions.
|
|
const result = await requestEditorMessage('scene', 'create-prefab', nodeUuid, savePath);
|
|
return {
|
|
created: true,
|
|
nodeUuid,
|
|
savePath,
|
|
result,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
applyPrefabInstance,
|
|
createPrefab,
|
|
duplicatePrefab,
|
|
editPrefabJson,
|
|
inspectPrefab,
|
|
revertPrefabInstance,
|
|
validatePrefabReferences,
|
|
};
|