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:
50
extensions/level-map-editor/dist/bake-ignore.js
vendored
Normal file
50
extensions/level-map-editor/dist/bake-ignore.js
vendored
Normal 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,
|
||||
};
|
||||
153
extensions/level-map-editor/dist/editor-tools.js
vendored
Normal file
153
extensions/level-map-editor/dist/editor-tools.js
vendored
Normal 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,
|
||||
};
|
||||
46
extensions/level-map-editor/dist/entity-spawn-defaults.js
vendored
Normal file
46
extensions/level-map-editor/dist/entity-spawn-defaults.js
vendored
Normal 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,
|
||||
};
|
||||
171
extensions/level-map-editor/dist/entity-texture-presets.js
vendored
Normal file
171
extensions/level-map-editor/dist/entity-texture-presets.js
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
'use strict';
|
||||
|
||||
/** 各主题推荐贴图路径(resources 相对路径,无 .png),供编辑器「从主题填充」 */
|
||||
|
||||
/** Prop_kuai1 → nProp_kuai1;Prop → 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,
|
||||
};
|
||||
87
extensions/level-map-editor/dist/grid-math.js
vendored
Normal file
87
extensions/level-map-editor/dist/grid-math.js
vendored
Normal 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}`;
|
||||
}
|
||||
|
||||
/** 世界坐标 → Canvas(Y 向下,与 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,
|
||||
};
|
||||
109
extensions/level-map-editor/dist/level-id.js
vendored
Normal file
109
extensions/level-map-editor/dist/level-id.js
vendored
Normal 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
164
extensions/level-map-editor/dist/main.js
vendored
Normal 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');
|
||||
};
|
||||
1969
extensions/level-map-editor/dist/panels/level-map-editor.js
vendored
Normal file
1969
extensions/level-map-editor/dist/panels/level-map-editor.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
271
extensions/level-map-editor/dist/prefab-sync.js
vendored
Normal file
271
extensions/level-map-editor/dist/prefab-sync.js
vendored
Normal 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;
|
||||
},
|
||||
};
|
||||
86
extensions/level-map-editor/dist/scene.js
vendored
Normal file
86
extensions/level-map-editor/dist/scene.js
vendored
Normal 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 () {};
|
||||
252
extensions/level-map-editor/dist/spawn-tools.js
vendored
Normal file
252
extensions/level-map-editor/dist/spawn-tools.js
vendored
Normal 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,
|
||||
};
|
||||
73
extensions/level-map-editor/dist/tile-meta.js
vendored
Normal file
73
extensions/level-map-editor/dist/tile-meta.js
vendored
Normal 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 };
|
||||
Reference in New Issue
Block a user