Complete Cocos Creator port with level bundles, themes, and tooling.

Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 15:30:58 +08:00
parent cba5105908
commit d393302388
6248 changed files with 17322729 additions and 11036 deletions

View File

@@ -0,0 +1,50 @@
'use strict';
const fs = require('fs');
const path = require('path');
const REL = 'temp/.lme-bake-ignore.json';
const DEFAULT_MS = 3500;
function ignoreFilePath(projectPath) {
return path.join(projectPath || Editor.Project.path, REL);
}
function readAll(projectPath) {
const fp = ignoreFilePath(projectPath);
try {
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
return data && typeof data === 'object' ? data : {};
} catch {
return {};
}
}
function writeAll(data, projectPath) {
const fp = ignoreFilePath(projectPath);
fs.mkdirSync(path.dirname(fp), { recursive: true });
fs.writeFileSync(fp, JSON.stringify(data), 'utf8');
}
function markBakePending(levelId, durationMs, projectPath) {
const ms = Number(durationMs) > 0 ? Number(durationMs) : DEFAULT_MS;
const data = readAll(projectPath);
data[String(levelId)] = Date.now() + ms;
writeAll(data, projectPath);
}
function shouldIgnoreImport(levelId, projectPath) {
const key = String(levelId);
const data = readAll(projectPath);
const until = data[key];
if (!until) return false;
if (until > Date.now()) return true;
delete data[key];
writeAll(data, projectPath);
return false;
}
module.exports = {
markBakePending,
shouldIgnoreImport,
};

View File

@@ -0,0 +1,153 @@
'use strict';
const TOOLS = ['paint', 'box', 'picker', 'eraser', 'fill'];
const TOOL_LABELS = {
paint: '画笔:在网格上点击或拖拽绘制(需先在右侧选瓦片)',
box: '框选:拖拽矩形区域,松开后用当前瓦片填满',
picker: '吸管:点击已有格子,吸取瓦片为当前画笔',
eraser: '橡皮擦:点击或拖拽删除当前层的格子',
fill: '油漆桶:填充相连的同类型格子(需先选瓦片)',
};
const TOOL_CURSORS = {
paint: 'crosshair',
box: 'crosshair',
picker: 'copy',
eraser: 'not-allowed',
fill: 'cell',
};
function layerMap(state) {
return state.editLayer === 'ground' ? state.config.ground : state.config.border;
}
function getCell(state, key) {
const map = layerMap(state);
return map[key];
}
function hasCell(state, key) {
return getCell(state, key) !== undefined;
}
function removeCell(state, key) {
if (state.editLayer === 'ground') delete state.config.ground[key];
else delete state.config.border[key];
}
function setCell(state, key, tileKey) {
if (state.editLayer === 'ground') state.config.ground[key] = tileKey;
else state.config.border[key] = tileKey;
}
function canPaintBrush(state) {
return state.brush && state.brush.layer === state.editLayer;
}
function fillRectangle(state, x0, y0, x1, y1, tileKey) {
const minX = Math.min(x0, x1);
const maxX = Math.max(x0, x1);
const minY = Math.min(y0, y1);
const maxY = Math.max(y0, y1);
let n = 0;
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
setCell(state, `${x},${y}`, tileKey);
n++;
}
}
return n;
}
function eraseRectangle(state, x0, y0, x1, y1) {
const minX = Math.min(x0, x1);
const maxX = Math.max(x0, x1);
const minY = Math.min(y0, y1);
const maxY = Math.max(y0, y1);
let n = 0;
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
const key = `${x},${y}`;
if (hasCell(state, key)) {
removeCell(state, key);
n++;
}
}
}
return n;
}
/** 四连通洪水填充(仅当前编辑层) */
function floodFill(state, startKey, fillTileKey) {
const map = layerMap(state);
const target = map[startKey];
if (target === fillTileKey) return 0;
const q = [startKey];
const seen = new Set([startKey]);
let n = 0;
while (q.length) {
const key = q.shift();
map[key] = fillTileKey;
n++;
const [xs, ys] = key.split(',');
const x = parseInt(xs, 10);
const y = parseInt(ys, 10);
for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
const nk = `${nx},${ny}`;
if (seen.has(nk)) continue;
const v = map[nk];
const same = target === undefined ? v === undefined : v === target;
if (!same) continue;
seen.add(nk);
q.push(nk);
}
}
return n;
}
/** 洪水清除(橡皮擦油漆桶:删相连同值区域) */
function floodErase(state, startKey) {
const map = layerMap(state);
if (map[startKey] === undefined) return 0;
const target = map[startKey];
const q = [startKey];
const seen = new Set([startKey]);
let n = 0;
while (q.length) {
const key = q.shift();
delete map[key];
n++;
const [xs, ys] = key.split(',');
const x = parseInt(xs, 10);
const y = parseInt(ys, 10);
for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
const nk = `${nx},${ny}`;
if (seen.has(nk) || map[nk] !== target) continue;
seen.add(nk);
q.push(nk);
}
}
return n;
}
function findPaletteTile(state, tileKey, layer) {
return state.tiles.find((t) => t.tileKey === tileKey && t.layer === layer) || null;
}
module.exports = {
TOOLS,
TOOL_LABELS,
TOOL_CURSORS,
layerMap,
getCell,
hasCell,
removeCell,
setCell,
canPaintBrush,
fillRectangle,
eraseRectangle,
floodFill,
floodErase,
findPaletteTile,
};

View File

@@ -0,0 +1,46 @@
'use strict';
/** 与 assets/scripts/level/EntitySpawnDefaults.ts 保持一致 */
const ENTITY_BASE_HEIGHT = {
player: 90,
vehicle: 80,
};
const ENTITY_BASE_SCALE = {
player: 1,
vehicle: 1,
prop: 0.85,
prop_decor: 0.6,
enemy: 1,
};
const DEFAULT_SPAWN_SCALE = {
player: 1,
vehicle: 1,
prop: 1,
};
function clampScale(v) {
const n = typeof v === 'number' ? v : parseFloat(String(v));
if (Number.isNaN(n)) return 1;
return Math.max(0.1, Math.min(4, n));
}
function normalizeSpawnScale(scale) {
const s = clampScale(scale);
return Math.abs(s - 1) < 0.001 ? undefined : s;
}
function resolveEntityScaleMul(kind, spawnScale) {
return clampScale(spawnScale !== undefined && spawnScale !== null ? spawnScale : 1);
}
module.exports = {
ENTITY_BASE_HEIGHT,
ENTITY_BASE_SCALE,
DEFAULT_SPAWN_SCALE,
clampScale,
normalizeSpawnScale,
resolveEntityScaleMul,
};

View File

@@ -0,0 +1,171 @@
'use strict';
/** 各主题推荐贴图路径resources 相对路径,无 .png供编辑器「从主题填充」 */
/** Prop_kuai1 → nProp_kuai1Prop → nProp */
function propToGroundPath(blockPath) {
if (!blockPath) return '';
const norm = String(blockPath).trim().replace(/\\/g, '/');
const slash = norm.lastIndexOf('/');
const dir = slash >= 0 ? norm.slice(0, slash + 1) : '';
const file = slash >= 0 ? norm.slice(slash + 1) : norm;
if (file.startsWith('Prop_')) return `${dir}n${file}`;
if (file === 'Prop') return `${dir}nProp`;
if (file.startsWith('nProp')) return norm;
return norm.replace(/\/Prop([^/]*)$/, '/nProp$1');
}
function withPropGround(preset) {
return {
...preset,
propGround: propToGroundPath(preset.prop),
};
}
function vehicleFourWay(folder, prefix) {
const base = `textures/${folder}/${prefix}`;
return {
vehicleNorth: `${base}_N`,
vehicleEast: `${base}_E`,
vehicleSouth: `${base}_S`,
vehicleWest: `${base}_W`,
};
}
const THEME_ENTITY_PRESETS = {
default: withPropGround({
playerFront: 'textures/default/player_F',
playerBack: 'textures/default/player_B',
...vehicleFourWay('default', 'ship'),
prop: 'textures/default/Prop',
}),
silu: withPropGround({
playerFront: 'textures/silu/skin/待机正面/1',
playerBack: 'textures/silu/skin/待机背面/1',
...vehicleFourWay('silu', 'siluShip'),
prop: 'textures/silu/Prop_kuai1',
}),
chinese: withPropGround({
playerFront: 'textures/chinese/chineseShip_F',
playerBack: 'textures/chinese/chineseShip_B',
...vehicleFourWay('chinese', 'chineseShip'),
prop: 'textures/chinese/Prop_kuai1',
}),
sanxing: withPropGround({
playerFront: 'textures/sanxing/skin/待机正面/1',
playerBack: 'textures/sanxing/skin/待机背面/1',
...vehicleFourWay('sanxing', 'sanxingShip'),
prop: 'textures/sanxing/Prop_kuai1',
}),
snow: withPropGround({
playerFront: 'textures/snow/skin/待机正面/1',
playerBack: 'textures/snow/skin/待机背面/1',
...vehicleFourWay('snow', 'snowShip'),
prop: 'textures/snow/Prop_kuai1',
}),
numMan: withPropGround({
playerFront: 'textures/numMan/skin/待机正面/1',
playerBack: 'textures/numMan/skin/待机背面/1',
...vehicleFourWay('numMan', 'numManShip'),
prop: 'textures/numMan/Prop_kuai11',
}),
redArmy: withPropGround({
playerFront: 'textures/redArmy/skin/待机正面/1',
playerBack: 'textures/redArmy/skin/待机背面/1',
...vehicleFourWay('redArmy', 'redArmyShip'),
prop: 'textures/redArmy/Prop_kuai1',
}),
redarmy: withPropGround({
playerFront: 'textures/redArmy/skin/待机正面/1',
playerBack: 'textures/redArmy/skin/待机背面/1',
...vehicleFourWay('redArmy', 'redArmyShip'),
prop: 'textures/redArmy/Prop_kuai1',
}),
};
const ENTITY_TEXTURE_FIELDS = [
{ key: 'playerFront', label: '角色正面' },
{ key: 'playerBack', label: '角色背面' },
{ key: 'vehicleNorth', label: '载具北' },
{ key: 'vehicleEast', label: '载具东' },
{ key: 'vehicleSouth', label: '载具南' },
{ key: 'vehicleWest', label: '载具西' },
{ key: 'prop', label: '可拾取物(砖块 Prop)' },
{ key: 'propGround', label: '可拾取物(空地 nProp)' },
];
function normalizeTexturePath(raw) {
if (!raw) return '';
let p = String(raw).trim().replace(/\\/g, '/');
if (p.startsWith('assets/resources/')) p = p.slice('assets/resources/'.length);
if (p.startsWith('resources/')) p = p.slice('resources/'.length);
if (p.startsWith('/')) p = p.slice(1);
if (p.endsWith('.png')) p = p.slice(0, -4);
return p;
}
function ensureEntityTextures(state) {
if (!state.config) return null;
if (!state.config.entityTextures || typeof state.config.entityTextures !== 'object') {
state.config.entityTextures = {};
}
return state.config.entityTextures;
}
function presetForTheme(theme) {
return THEME_ENTITY_PRESETS[theme] || THEME_ENTITY_PRESETS.silu;
}
function applyThemePreset(state, theme) {
const et = ensureEntityTextures(state);
if (!et) return false;
const preset = presetForTheme(theme || state.theme || 'silu');
Object.assign(et, { ...preset });
return true;
}
function readEntityTexturesFromPanel(listEl) {
const out = {};
for (const f of ENTITY_TEXTURE_FIELDS) {
const el = listEl?.querySelector(`#entity-tex-${f.key}`);
const v = normalizeTexturePath(el?.value || '');
if (v) out[f.key] = v;
}
return out;
}
function writeEntityTexturesToPanel(listEl, entityTextures) {
const et = entityTextures || {};
for (const f of ENTITY_TEXTURE_FIELDS) {
const el = listEl?.querySelector(`#entity-tex-${f.key}`);
if (el) el.value = et[f.key] || '';
}
}
function pruneEmptyEntityTextures(state) {
const et = state.config?.entityTextures;
if (!et) return;
let hasAny = false;
for (const f of ENTITY_TEXTURE_FIELDS) {
const v = normalizeTexturePath(et[f.key]);
if (v) {
et[f.key] = v;
hasAny = true;
} else {
delete et[f.key];
}
}
if (!hasAny) delete state.config.entityTextures;
}
module.exports = {
THEME_ENTITY_PRESETS,
ENTITY_TEXTURE_FIELDS,
normalizeTexturePath,
ensureEntityTextures,
presetForTheme,
applyThemePreset,
readEntityTexturesFromPanel,
writeEntityTexturesToPanel,
pruneEmptyEntityTextures,
};

View File

@@ -0,0 +1,87 @@
'use strict';
/** 与 Unity PPU=100、Grid CellSize (1,0.5,1) 一致(世界坐标 Y 向上) */
const CELL_PIXEL = 100;
const HALF_W = CELL_PIXEL * 0.5;
const HALF_H = CELL_PIXEL * 0.25;
const PANEL_HALF_W = HALF_W;
const PANEL_HALF_H = HALF_H;
function worldToCellXY(wx, wy) {
const cx = (wy / HALF_H + wx / HALF_W) * 0.5;
const cy = (wy / HALF_H - wx / HALF_W) * 0.5;
return { x: Math.round(cx), y: Math.round(cy) };
}
/** 世界坐标为格子中心时 → 格子索引(与 cellCenterCanvas 互逆) */
function worldCenterToCellXY(wx, wy) {
return worldToCellXY(wx, wy - HALF_H);
}
function cellToWorldXY(cx, cy) {
return {
x: (cx - cy) * HALF_W,
y: (cx + cy) * HALF_H,
};
}
function cellCenterWorldXY(cx, cy) {
const w = cellToWorldXY(cx, cy);
return { x: w.x, y: w.y + HALF_H };
}
function cellKey(x, y) {
return `${x},${y}`;
}
/** 世界坐标 → CanvasY 向下,与 Unity/Cocos 预览一致) */
function worldToCanvasXY(wx, wy, offsetX, offsetY) {
return {
x: offsetX + wx,
y: offsetY - wy,
};
}
/** 格子底顶点Grid.CellToWorld */
function cellAnchorCanvas(cx, cy, offsetX, offsetY) {
const w = cellToWorldXY(cx, cy);
return worldToCanvasXY(w.x, w.y, offsetX, offsetY);
}
/** 格子中心Unity Tilemap m_TileAnchor 0.5,0.5 → 精灵 pivot 对齐点) */
function cellCenterCanvas(cx, cy, offsetX, offsetY) {
const w = cellToWorldXY(cx, cy);
return worldToCanvasXY(w.x, w.y + HALF_H, offsetX, offsetY);
}
/** 鼠标拾取:与贴图 pivot 相同使用格子中心worldCenterToCell */
function cellFromCanvas(mx, my, offsetX, offsetY) {
const wx = mx - offsetX;
const wyCenter = offsetY - my;
const c = worldCenterToCellXY(wx, wyCenter);
return { x: c.x, y: c.y, key: cellKey(c.x, c.y) };
}
function cellToCanvas(cx, cy, offsetX, offsetY) {
const a = cellAnchorCanvas(cx, cy, offsetX, offsetY);
return { px: a.x, py: a.y };
}
module.exports = {
CELL_PIXEL,
HALF_W,
HALF_H,
PANEL_HALF_W,
PANEL_HALF_H,
worldToCellXY,
worldCenterToCellXY,
cellToWorldXY,
cellCenterWorldXY,
cellKey,
worldToCanvasXY,
cellAnchorCanvas,
cellCenterCanvas,
cellFromCanvas,
cellToCanvas,
};

View File

@@ -0,0 +1,109 @@
'use strict';
/** 与 Unity / 主站 config.js BEGINNING_REAL_LVID、LevelRegistry.LEVEL_ID_BASE 一致 */
const LEVEL_ID_BASE = 91601;
const PREFAB_DIR = 'level-prefabs';
/** internalIndex 0 → 91601首关 */
function externalLevelId(internalIndex) {
return LEVEL_ID_BASE + internalIndex;
}
function internalLevelIndex(levelId) {
return levelId >= LEVEL_ID_BASE ? levelId - LEVEL_ID_BASE : levelId;
}
function isGameLevelId(levelId) {
return levelId >= LEVEL_ID_BASE;
}
function isExternalLevelId(levelId) {
return isGameLevelId(levelId);
}
function minLevelId() {
return LEVEL_ID_BASE;
}
function prefabResourcePath(levelId) {
return `${PREFAB_DIR}/Level${levelId}`;
}
function normalizeDb(db) {
if (!db.levels) db.levels = {};
db.levelIdBase = LEVEL_ID_BASE;
return db;
}
function nextAvailableLevelId(db) {
const keys = Object.keys(db.levels || {});
if (!keys.length) return LEVEL_ID_BASE;
const ids = keys.map((k) => parseInt(k, 10)).filter((n) => !Number.isNaN(n));
return Math.max(...ids) + 1;
}
function syncLevelEntry(cfg, levelId) {
const out = { ...cfg };
out.levelID = levelId;
out.cocosPrefab = prefabResourcePath(levelId);
if (typeof out.unityPrefab === 'string' && out.unityPrefab) {
out.unityPrefab = out.unityPrefab.replace(/Level\d+\.prefab$/i, `Level${levelId}.prefab`);
}
return out;
}
function updateDbStats(db) {
const total = Object.keys(db.levels || {}).length;
db.stats = { ...(db.stats || {}), total, withPrefabTilemap: total };
}
function touchDatabase(db, levelId) {
normalizeDb(db);
const key = String(levelId);
if (db.levels[key]) {
db.levels[key] = syncLevelEntry(db.levels[key], levelId);
}
updateDbStats(db);
db.generatedAt = new Date().toISOString();
}
function sortedLevelIds(db) {
return Object.keys(db.levels || {})
.map((k) => parseInt(k, 10))
.filter((n) => !Number.isNaN(n))
.sort((a, b) => a - b);
}
function prevLevelIdInDb(db, cur) {
const ids = sortedLevelIds(db);
if (!ids.length) return cur;
const i = ids.indexOf(cur);
if (i < 0) return ids[0];
return ids[(i - 1 + ids.length) % ids.length];
}
function nextLevelIdInDb(db, cur) {
const ids = sortedLevelIds(db);
if (!ids.length) return cur;
const i = ids.indexOf(cur);
if (i < 0) return ids[0];
return ids[(i + 1) % ids.length];
}
module.exports = {
LEVEL_ID_BASE,
PREFAB_DIR,
externalLevelId,
internalLevelIndex,
isExternalLevelId,
minLevelId,
prefabResourcePath,
normalizeDb,
nextAvailableLevelId,
syncLevelEntry,
updateDbStats,
touchDatabase,
sortedLevelIds,
prevLevelIdInDb,
nextLevelIdInDb,
};

164
extensions/level-map-editor/dist/main.js vendored Normal file
View File

@@ -0,0 +1,164 @@
'use strict';
const fs = require('fs');
const path = require('path');
const prefabSync = require('./prefab-sync');
const bakeIgnore = require('./bake-ignore');
const IMPORT_DEBOUNCE_MS = 900;
/** levelId → debounce timer */
const importTimers = new Map();
let prefabWatcher = null;
let broadcastUnsubs = [];
function addBroadcastListener(message, handler) {
const fn = Editor.Message.__protected__?.addBroadcastListener || Editor.Message.addBroadcastListener;
if (typeof fn !== 'function') {
console.warn(`[level-map-editor] addBroadcastListener unavailable for ${message}`);
return null;
}
fn.call(Editor.Message, message, handler);
return () => {
const rm = Editor.Message.__protected__?.removeBroadcastListener || Editor.Message.removeBroadcastListener;
if (typeof rm === 'function') rm.call(Editor.Message, message, handler);
};
}
function scheduleImportFromPrefab(levelId, source) {
if (!levelId || bakeIgnore.shouldIgnoreImport(levelId)) return;
if (importTimers.has(levelId)) clearTimeout(importTimers.get(levelId));
importTimers.set(
levelId,
setTimeout(() => {
importTimers.delete(levelId);
runImportFromPrefab(levelId, source);
}, IMPORT_DEBOUNCE_MS),
);
}
function runImportFromPrefab(levelId, source) {
if (bakeIgnore.shouldIgnoreImport(levelId)) return null;
try {
const result = prefabSync.importLevelFromPrefab(levelId);
if (!result.ok) {
console.warn(`[level-map-editor] prefab import skipped Level${levelId}: ${result.reason}`);
return result;
}
console.log(
`[level-map-editor] prefab → JSON Level${levelId} (ground ${result.ground}, border ${result.border}, theme ${result.theme || '-'}) [${source}]`,
);
void prefabSync.refreshDatabaseAsset();
Editor.Message.broadcast('level-map-editor:prefab-synced', {
levelId,
source,
ground: result.ground,
border: result.border,
theme: result.theme,
});
return result;
} catch (e) {
console.error(`[level-map-editor] prefab import failed Level${levelId}`, e);
return { ok: false, reason: e.message, levelId };
}
}
async function handleAssetDbEvent(...args) {
let url = '';
for (const arg of args) {
if (typeof arg === 'string') {
if (arg.includes('level-prefabs/Level') && arg.endsWith('.prefab')) url = arg;
else if (arg.startsWith('db://') && arg.includes('Level') && arg.endsWith('.prefab')) url = arg;
} else if (arg && typeof arg === 'object') {
if (typeof arg.url === 'string') url = arg.url;
else if (typeof arg.path === 'string') url = arg.path;
}
}
if (!url && typeof args[0] === 'string' && !args[0].includes('/')) {
try {
url = await Editor.Message.request('asset-db', 'query-url', args[0]);
} catch {
/* ignore */
}
}
const levelId = prefabSync.extractLevelIdFromUrl(url);
if (levelId) scheduleImportFromPrefab(levelId, 'asset-db');
}
function watchPrefabDirectory() {
const dir = path.join(Editor.Project.path, prefabSync.PREFAB_DIR_REL);
if (!fs.existsSync(dir)) return;
try {
prefabWatcher = fs.watch(dir, (_event, filename) => {
const levelId = prefabSync.extractLevelIdFromFilename(filename);
if (levelId) scheduleImportFromPrefab(levelId, 'fs-watch');
});
} catch (e) {
console.warn('[level-map-editor] prefab fs.watch failed', e);
}
}
function registerAssetListeners() {
const events = [
'asset-db:asset-change',
'asset-db:asset-changed',
'asset-db:assets-changed',
'asset-db:asset-add',
'asset-db:assets-created',
];
for (const message of events) {
const unsub = addBroadcastListener(message, (...args) => {
void handleAssetDbEvent(...args);
});
if (unsub) broadcastUnsubs.push(unsub);
}
}
exports.methods = {
openPanel() {
const id = 'level-map-editor';
try {
Editor.Panel.open(id);
console.log(`[${id}] Panel.open('${id}')`);
} catch (e) {
console.error(`[${id}] open failed:`, e);
}
},
markBakePending(levelId, durationMs) {
bakeIgnore.markBakePending(levelId, durationMs);
},
importLevelFromPrefab(levelId) {
return runImportFromPrefab(Number(levelId), 'manual');
},
};
exports.load = function () {
prefabSync.clearSpriteUuidCache();
registerAssetListeners();
watchPrefabDirectory();
console.log('[level-map-editor] extension loaded (v1.7.1, prefab→JSON sync enabled)');
};
exports.unload = function () {
for (const unsub of broadcastUnsubs) {
try {
unsub();
} catch {
/* ignore */
}
}
broadcastUnsubs = [];
if (prefabWatcher) {
try {
prefabWatcher.close();
} catch {
/* ignore */
}
prefabWatcher = null;
}
for (const timer of importTimers.values()) clearTimeout(timer);
importTimers.clear();
console.log('[level-map-editor] extension unloaded');
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,271 @@
'use strict';
const fs = require('fs');
const path = require('path');
const levelIdUtil = require('./level-id');
const LEVEL_MAP_TYPE = 'd4e5fanuMlNDh8qO0xdbn+K';
const DB_REL = 'assets/level-data/levels-database.json';
const PREFAB_DIR_REL = 'assets/resources/level-prefabs';
const PREFAB_NAME_RE = /^Level(\d+)\.prefab$/i;
const TILE_NODE_RE = /^([gb])_(-?\d+)_(-?\d+)$/;
let spriteUuidCache = null;
function projectRoot(projectPath) {
return projectPath || Editor.Project.path;
}
function dbPath(projectPath) {
return path.join(projectRoot(projectPath), DB_REL);
}
function prefabDir(projectPath) {
return path.join(projectRoot(projectPath), PREFAB_DIR_REL);
}
function prefabPath(levelId, projectPath) {
return path.join(prefabDir(projectPath), `Level${levelId}.prefab`);
}
function readJsonFile(fp, fallback) {
try {
return JSON.parse(fs.readFileSync(fp, 'utf8'));
} catch {
return fallback;
}
}
function writeJsonFile(fp, data) {
fs.mkdirSync(path.dirname(fp), { recursive: true });
fs.writeFileSync(fp, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
}
function buildSpriteUuidCache(projectPath) {
if (spriteUuidCache) return spriteUuidCache;
const cache = new Map();
const texRoot = path.join(projectRoot(projectPath), 'assets/resources/textures');
const walk = (dir) => {
if (!fs.existsSync(dir)) return;
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, ent.name);
if (ent.isDirectory()) {
walk(full);
continue;
}
if (!/\.(png|jpg)\.meta$/i.test(ent.name)) continue;
const tileKey = ent.name.replace(/\.(png|jpg)\.meta$/i, '');
try {
const meta = JSON.parse(fs.readFileSync(full, 'utf8'));
if (meta.subMetas) {
for (const sub of Object.values(meta.subMetas)) {
if (sub && sub.uuid) cache.set(sub.uuid, tileKey);
}
}
if (meta.uuid) {
cache.set(meta.uuid, tileKey);
cache.set(`${meta.uuid}@f9941`, tileKey);
}
} catch {
/* ignore broken meta */
}
}
};
walk(texRoot);
spriteUuidCache = cache;
return cache;
}
function tileKeyFromSpriteUuid(uuid, projectPath) {
if (!uuid) return null;
const cache = buildSpriteUuidCache(projectPath);
const direct = cache.get(uuid);
if (direct) return direct;
const base = String(uuid).split('@')[0];
for (const [key, val] of cache.entries()) {
if (key.startsWith(base)) return val;
}
return null;
}
function parseJsonRecord(raw) {
try {
const parsed = JSON.parse(raw || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function parseLevelMapData(objs) {
for (const obj of objs) {
if (!obj || typeof obj !== 'object') continue;
if (obj.__type__ !== LEVEL_MAP_TYPE && obj.groundJson === undefined) continue;
if (obj.groundJson === undefined && obj.levelID === undefined) continue;
return {
levelID: parseInt(obj.levelID, 10) || 0,
ground: parseJsonRecord(obj.groundJson),
border: parseJsonRecord(obj.borderJson),
theme: String(obj.theme || '').trim() || null,
};
}
return null;
}
function spriteUuidOnNode(objs, node) {
if (!node || !Array.isArray(node._components)) return null;
for (const ref of node._components) {
const comp = objs[(ref && ref.__id__) - 1];
if (!comp || comp.__type__ !== 'cc.Sprite') continue;
const frame = comp._spriteFrame;
if (frame && frame.__uuid__) return frame.__uuid__;
}
return null;
}
function parseTilesFromNodes(objs, projectPath) {
const ground = {};
const border = {};
for (const obj of objs) {
if (!obj || obj.__type__ !== 'cc.Node' || typeof obj._name !== 'string') continue;
const m = obj._name.match(TILE_NODE_RE);
if (!m) continue;
const x = parseInt(m[2], 10);
const y = parseInt(m[3], 10);
if (Number.isNaN(x) || Number.isNaN(y)) continue;
const key = `${x},${y}`;
const layer = m[1] === 'g' ? 'ground' : 'border';
const tileKey = tileKeyFromSpriteUuid(spriteUuidOnNode(objs, obj), projectPath);
if (layer === 'ground') {
ground[key] = tileKey || 'Baseblock';
} else {
border[key] = tileKey || 'WallBlock';
}
}
return { ground, border };
}
function mergeMapSources(mapData, fromNodes) {
const md = mapData || { ground: {}, border: {}, theme: null, levelID: 0 };
const nodes = fromNodes || { ground: {}, border: {} };
const ground = Object.keys(nodes.ground).length ? nodes.ground : md.ground || {};
const border = Object.keys(nodes.border).length ? nodes.border : md.border || {};
return {
levelID: md.levelID || 0,
ground,
border,
theme: md.theme || null,
};
}
function mergeEntry(levelId, mapData, prev) {
const extId = levelIdUtil.isExternalLevelId(levelId) ? levelId : levelIdUtil.externalLevelId(levelId);
const out = {
levelID: extId,
boundary: (prev && prev.boundary) || { x: 20, y: 20 },
spawns: Array.isArray(prev && prev.spawns) ? [...prev.spawns] : [],
cocosPrefab: levelIdUtil.prefabResourcePath(extId),
};
if (prev && prev.unityPrefab) out.unityPrefab = prev.unityPrefab;
if (mapData) {
if (mapData.ground && Object.keys(mapData.ground).length) out.ground = mapData.ground;
if (mapData.border && Object.keys(mapData.border).length) out.border = mapData.border;
if (mapData.theme) out.theme = mapData.theme;
if (mapData.levelID > 0) out.levelID = levelIdUtil.isExternalLevelId(mapData.levelID) ? mapData.levelID : levelIdUtil.externalLevelId(mapData.levelID);
} else if (prev) {
for (const k of ['ground', 'border', 'theme', 'entityTextures']) {
if (prev[k] !== undefined) out[k] = prev[k];
}
}
return out;
}
function parsePrefabFile(prefabFile, projectPath) {
const objs = readJsonFile(prefabFile, null);
if (!Array.isArray(objs)) return null;
const mapData = parseLevelMapData(objs);
const fromNodes = parseTilesFromNodes(objs, projectPath);
return mergeMapSources(mapData, fromNodes);
}
function loadDatabase(projectPath) {
const fp = dbPath(projectPath);
if (!fs.existsSync(fp)) {
return levelIdUtil.normalizeDb({ levels: {} });
}
return levelIdUtil.normalizeDb(readJsonFile(fp, { levels: {} }));
}
function saveDatabase(db, projectPath) {
levelIdUtil.normalizeDb(db);
levelIdUtil.updateDbStats(db);
db.generatedAt = new Date().toISOString();
if (!db.version) db.version = 2;
if (!db.source) db.source = 'Cocos level-prefabs LevelMapData + levels-database spawns';
writeJsonFile(dbPath(projectPath), db);
}
async function refreshDatabaseAsset() {
const assetUrl = `db://${DB_REL}`;
try {
await Editor.Message.request('asset-db', 'refresh-asset', assetUrl);
const uuid = await Editor.Message.request('asset-db', 'query-uuid', assetUrl);
if (uuid) await Editor.Message.request('asset-db', 'refresh-asset', uuid);
} catch (e) {
console.warn('[level-map-editor] levels-database refresh failed', e);
}
}
function importLevelFromPrefab(levelId, options = {}) {
const projectPath = projectRoot(options.projectPath);
const fp = prefabPath(levelId, projectPath);
if (!fs.existsSync(fp)) {
return { ok: false, reason: 'prefab missing', levelId };
}
const mapData = parsePrefabFile(fp, projectPath);
if (!mapData) {
return { ok: false, reason: 'parse failed', levelId };
}
const db = loadDatabase(projectPath);
const key = String(levelId);
const prev = db.levels[key];
db.levels[key] = mergeEntry(levelId, mapData, prev);
levelIdUtil.touchDatabase(db, levelId);
saveDatabase(db, projectPath);
return {
ok: true,
levelId,
ground: Object.keys(mapData.ground || {}).length,
border: Object.keys(mapData.border || {}).length,
theme: mapData.theme,
};
}
function extractLevelIdFromUrl(url) {
if (!url || typeof url !== 'string') return null;
const m = url.match(/level-prefabs\/Level(\d+)\.prefab/i);
return m ? parseInt(m[1], 10) : null;
}
function extractLevelIdFromFilename(name) {
if (!name || typeof name !== 'string') return null;
const m = name.match(PREFAB_NAME_RE);
return m ? parseInt(m[1], 10) : null;
}
module.exports = {
DB_REL,
PREFAB_DIR_REL,
prefabPath,
dbPath,
parsePrefabFile,
importLevelFromPrefab,
refreshDatabaseAsset,
extractLevelIdFromUrl,
extractLevelIdFromFilename,
clearSpriteUuidCache() {
spriteUuidCache = null;
},
};

View File

@@ -0,0 +1,86 @@
'use strict';
const grid = require('./grid-math');
function findNodeByUuid(root, uuid) {
if (!root || !uuid) return null;
if (root.uuid === uuid) return root;
for (const ch of root.children) {
const hit = findNodeByUuid(ch, uuid);
if (hit) return hit;
}
return null;
}
function resolveLayer(node) {
let cur = node;
while (cur) {
if (cur.name === 'Ground') return 'ground';
if (cur.name === 'Border') return 'border';
cur = cur.parent;
}
const m = node.name && node.name.match(/^([gb])_(-?\d+)_(-?\d+)$/);
if (m) return m[1] === 'g' ? 'ground' : 'border';
return null;
}
exports.methods = {
/**
* 将指定 uuid 节点吸附到等距格子中心(场景内拖动后调用)
*/
snapNodeByUuid(uuid) {
const { director, Vec3 } = require('cc');
const scene = director.getScene();
if (!scene) return { ok: false, reason: 'no scene' };
const node = findNodeByUuid(scene, uuid);
if (!node) return { ok: false, reason: 'node not found' };
const layer = resolveLayer(node);
if (!layer) return { ok: false, reason: 'not under Ground/Border' };
const p = node.position;
const c = grid.worldCenterToCellXY(p.x, p.y);
const w = grid.cellCenterWorldXY(c.x, c.y);
node.setPosition(new Vec3(w.x, w.y, p.z));
const prefix = layer === 'ground' ? 'g' : 'b';
const expected = `${prefix}_${c.x}_${c.y}`;
if (/^[gb]_-?\d+_-?\d+$/.test(node.name) || node.name.startsWith(prefix)) {
node.name = expected;
}
return { ok: true, cell: c, key: grid.cellKey(c.x, c.y), layer };
},
/** 批量吸附(参数为 uuid 数组) */
snapNodes(uuids) {
const list = Array.isArray(uuids) ? uuids : [uuids];
const results = [];
for (const id of list) {
results.push(exports.methods.snapNodeByUuid(id));
}
return results;
},
/** 在 Level 根节点挂载 GridSnapHelper编辑器场景内持续吸附 */
attachHelperOnSelection(uuid) {
const { director } = require('cc');
const scene = director.getScene();
if (!scene) return { ok: false, reason: 'no scene' };
let node = findNodeByUuid(scene, uuid);
if (!node) return { ok: false, reason: 'node not found' };
let root = node;
while (root.parent && !/^Level\d+$/.test(root.name)) {
root = root.parent;
}
if (!/^Level\d+$/.test(root.name)) {
return { ok: false, reason: 'not a Level prefab node' };
}
let comp = root.getComponent('GridSnapHelper');
if (!comp) {
comp = root.addComponent('GridSnapHelper');
}
return { ok: true, level: root.name, hasHelper: !!comp };
},
};
exports.load = function () {};
exports.unload = function () {};

View File

@@ -0,0 +1,252 @@
'use strict';
/** 与 Unity Levels*.cs / levels-database.json spawns 一致 */
const { normalizeSpawnScale, clampScale, DEFAULT_SPAWN_SCALE } = require('./entity-spawn-defaults');
const { normalizeTexturePath } = require('./entity-texture-presets');
const SPAWN_KIND_LABELS = {
player: '玩家',
prop: '可拾取物',
vehicle: '载具',
};
const DIRECTIONS = [
'Direction.North',
'Direction.East',
'Direction.South',
'Direction.West',
];
function ensureSpawns(state) {
if (!state.config) return [];
if (!Array.isArray(state.config.spawns)) state.config.spawns = [];
return state.config.spawns;
}
function spawnsAt(state, x, y) {
return ensureSpawns(state).filter((s) => s.x === x && s.y === y);
}
function findSpawnAt(state, x, y) {
return spawnsAt(state, x, y)[0] || null;
}
function countByKind(state, kind) {
return ensureSpawns(state).filter((s) => s.kind === kind).length;
}
function getPlayerSpawn(state) {
return ensureSpawns(state).find((s) => s.kind === 'player') || null;
}
function getVehicleSpawn(state) {
return ensureSpawns(state).find((s) => s.kind === 'vehicle') || null;
}
function applyScaleField(entry, scale) {
const s = normalizeSpawnScale(scale);
if (s !== undefined) entry.scale = s;
else delete entry.scale;
}
function setPlayerSpawn(state, x, y, direction, scale) {
const prev = getPlayerSpawn(state);
const rest = ensureSpawns(state).filter((s) => s.kind !== 'player');
const entry = {
x,
y,
kind: 'player',
playerDirection: direction || 'Direction.South',
};
const sc = scale !== undefined ? scale : prev?.scale ?? DEFAULT_SPAWN_SCALE.player;
applyScaleField(entry, sc);
rest.push(entry);
state.config.spawns = rest;
return entry;
}
function setVehicleSpawn(state, x, y, direction, scale) {
const prev = getVehicleSpawn(state);
const rest = ensureSpawns(state).filter((s) => s.kind !== 'vehicle');
const entry = {
x,
y,
kind: 'vehicle',
vehicleDirection: direction || 'Direction.North',
};
const sc = scale !== undefined ? scale : prev?.scale ?? DEFAULT_SPAWN_SCALE.vehicle;
applyScaleField(entry, sc);
rest.push(entry);
state.config.spawns = rest;
return entry;
}
function clearVehicleSpawn(state) {
const before = countByKind(state, 'vehicle');
state.config.spawns = ensureSpawns(state).filter((s) => s.kind !== 'vehicle');
return before > 0;
}
/** 载具 0 或 1再次点击已有载具格则清除 */
function vehicleToggleInternal(state, x, y, direction, scale) {
const existing = getVehicleSpawn(state);
if (existing && existing.x === x && existing.y === y) {
clearVehicleSpawn(state);
return { changed: false, spawn: null, removed: true };
}
const spawn = setVehicleSpawn(state, x, y, direction, scale);
return { changed: true, spawn, removed: false };
}
function toggleVehicleSpawn(state, x, y, direction, scale) {
const r = vehicleToggleInternal(state, x, y, direction, scale);
return r.changed;
}
function toggleVehicleSpawnDetailed(state, x, y, direction, scale) {
return vehicleToggleInternal(state, x, y, direction, scale);
}
function togglePropSpawn(state, x, y, scale, propPlacement) {
const spawns = ensureSpawns(state);
const idx = spawns.findIndex((s) => s.x === x && s.y === y && s.kind === 'prop');
if (idx >= 0) {
spawns.splice(idx, 1);
return { changed: false, spawn: null, removed: true };
}
const entry = { x, y, kind: 'prop' };
if (propPlacement === 'ground') entry.propPlacement = 'ground';
const sc = scale !== undefined ? scale : DEFAULT_SPAWN_SCALE.prop;
applyScaleField(entry, sc);
spawns.push(entry);
return { changed: true, spawn: entry, removed: false };
}
function removeSpawnAt(state, x, y) {
const spawns = ensureSpawns(state);
const before = spawns.length;
state.config.spawns = spawns.filter((s) => !(s.x === x && s.y === y));
return state.config.spawns.length < before;
}
function removeSpawnRef(state, spawnRef) {
if (!spawnRef) return false;
const spawns = ensureSpawns(state);
const idx = spawns.indexOf(spawnRef);
if (idx < 0) return false;
spawns.splice(idx, 1);
return true;
}
function pickSpawnAtCell(state, x, y, preferKind) {
const hits = spawnsAt(state, x, y);
if (!hits.length) return null;
if (preferKind) {
const m = hits.find((s) => s.kind === preferKind);
if (m) return m;
}
return hits[0];
}
function spawnStillExists(state, spawnRef) {
if (!spawnRef) return false;
return ensureSpawns(state).includes(spawnRef);
}
function updateSpawnEntry(state, spawnRef, patch) {
const spawns = ensureSpawns(state);
const idx = spawns.indexOf(spawnRef);
if (idx < 0) return false;
const cur = { ...spawns[idx] };
if (patch.x !== undefined) cur.x = parseInt(patch.x, 10);
if (patch.y !== undefined) cur.y = parseInt(patch.y, 10);
if (patch.playerDirection !== undefined) cur.playerDirection = patch.playerDirection;
if (patch.vehicleDirection !== undefined) cur.vehicleDirection = patch.vehicleDirection;
if (patch.scale !== undefined) applyScaleField(cur, patch.scale);
if (patch.texture !== undefined) {
const t = normalizeTexturePath(patch.texture);
if (t) cur.texture = t;
else delete cur.texture;
}
spawns[idx] = cur;
return true;
}
function formatSpawnScale(spawn) {
if (spawn?.scale !== undefined && spawn.scale !== null) return spawn.scale;
return 1;
}
function validateSpawns(state) {
const spawns = ensureSpawns(state);
const players = spawns.filter((s) => s.kind === 'player');
const vehicles = spawns.filter((s) => s.kind === 'vehicle');
const props = spawns.filter((s) => s.kind === 'prop');
const errors = [];
if (players.length !== 1) {
errors.push(`玩家必须恰好 1 个(当前 ${players.length}`);
}
if (vehicles.length > 1) {
errors.push(`载具最多 1 个(当前 ${vehicles.length}`);
}
if (props.length < 1) {
errors.push(`可拾取物至少 1 个(当前 ${props.length}`);
}
return { ok: errors.length === 0, errors, players, vehicles, props };
}
function spawnSummary(state) {
const v = validateSpawns(state);
const player = v.players[0];
const vehicle = v.vehicles[0];
const parts = [];
if (player) {
const sc = formatSpawnScale(player);
parts.push(`玩家 (${player.x},${player.y}) ${player.playerDirection || ''}${sc !== 1 ? ` ×${sc}` : ''}`);
} else {
parts.push('玩家 未设置');
}
parts.push(`载具 ${v.vehicles.length}/1`);
if (vehicle) {
const sc = formatSpawnScale(vehicle);
parts.push(`@(${vehicle.x},${vehicle.y}) ${vehicle.vehicleDirection || ''}${sc !== 1 ? ` ×${sc}` : ''}`);
}
parts.push(`可拾取物 ${v.props.length}`);
if (!v.ok) parts.push('[待完善]');
return parts.join(' · ');
}
function validationHint(state) {
const v = validateSpawns(state);
if (v.ok) return '';
return v.errors.join('');
}
module.exports = {
SPAWN_KIND_LABELS,
DIRECTIONS,
DEFAULT_SPAWN_SCALE,
clampScale,
ensureSpawns,
spawnsAt,
findSpawnAt,
countByKind,
getPlayerSpawn,
getVehicleSpawn,
setPlayerSpawn,
setVehicleSpawn,
clearVehicleSpawn,
toggleVehicleSpawn,
toggleVehicleSpawnDetailed,
togglePropSpawn,
removeSpawnAt,
removeSpawnRef,
pickSpawnAtCell,
spawnStillExists,
updateSpawnEntry,
formatSpawnScale,
validateSpawns,
spawnSummary,
validationHint,
};

View File

@@ -0,0 +1,73 @@
'use strict';
const fs = require('fs');
const path = require('path');
/** Unity spritePivot + 贴图尺寸PPU=100— 无主题数据时的回退 */
const TILE_META = {
Baseblock: { width: 101, height: 80, pivotX: 0.5, pivotY: 0.92 },
JumpBlock: { width: 101, height: 99, pivotX: 0.5, pivotY: 0.77 },
WallBlock: { width: 101, height: 115, pivotX: 0.5, pivotY: 0.67 },
kuai11: { width: 101, height: 74, pivotX: 0.5, pivotY: 1.01 },
};
let themeTileMeta = null;
function loadThemeTileMeta() {
if (themeTileMeta) return themeTileMeta;
try {
const fp = path.join(Editor.Project.path, 'assets/resources/theme/tile-display-meta.json');
if (fs.existsSync(fp)) {
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
themeTileMeta = data.themes || {};
return themeTileMeta;
}
} catch (e) {
console.warn('[tile-meta] 读取 tile-display-meta.json 失败', e);
}
themeTileMeta = {};
return themeTileMeta;
}
function normalizeTheme(theme) {
if (!theme) return undefined;
if (theme === 'redArmy') return 'redarmy';
return theme;
}
function getTileMeta(tileName, theme) {
const key = normalizeTheme(theme);
if (key) {
const themes = loadThemeTileMeta();
const entry = themes[key]?.[tileName];
if (entry) {
return {
width: entry.width,
height: entry.height,
pivotX: entry.pivotX,
pivotY: entry.pivotY,
};
}
}
return TILE_META[tileName] || TILE_META.Baseblock;
}
/**
* 格子中心 + Unity spritePivot宽度贴满菱形格保留高度供等距遮挡。
*/
function spriteDrawRect(anchorX, anchorY, imgW, imgH, meta, halfW) {
const hw = halfW || 50;
const srcW = imgW || meta.width;
const srcH = imgH || meta.height;
const scale = (2 * hw) / srcW;
const w = srcW * scale;
const h = srcH * scale;
return {
x: anchorX - w * meta.pivotX,
y: anchorY - (1 - meta.pivotY) * h,
w,
h,
};
}
module.exports = { TILE_META, getTileMeta, spriteDrawRect, loadThemeTileMeta };