'use strict'; const fs = require('fs'); const path = require('path'); const THEMES_DB_REL = 'assets/resources/theme/themes-database.json'; const PALETTES_INDEX_REL = 'assets/resources/map-tiles/palettes/_index.json'; const LEVELS_DB_REL = 'assets/level-data/levels-database.json'; const ENTITY_FIELDS = [ { key: 'playerFront', label: '角色正面' }, { key: 'playerBack', label: '角色背面' }, { key: 'vehicleNorth', label: '载具北 (上)' }, { key: 'vehicleEast', label: '载具东 (右)' }, { key: 'vehicleSouth', label: '载具南 (下)' }, { key: 'vehicleWest', label: '载具西 (左)' }, { key: 'prop', label: '可拾取物' }, { key: 'propGround', label: '可拾取物(空地 nProp)' }, ]; const TILE_FIELDS = [ { key: 'Baseblock', label: 'Baseblock(地面)' }, { key: 'JumpBlock', label: 'JumpBlock(跳跃块)' }, { key: 'WallBlock', label: 'WallBlock(墙)' }, { key: 'borderDecor', label: '装饰墙砖(第4块)' }, ]; /** Unity GameManager.changeIcon / UIMain 右侧 HUD 按钮 */ const HUD_FIELDS = [ { key: 'navigation', label: '导航' }, { key: 'revert', label: '重置' }, { key: 'speed1', label: '1倍速' }, { key: 'speed2', label: '2倍速' }, { key: 'speed4', label: '4倍速' }, { key: 'zoomIn', label: '放大' }, { key: 'zoomOut', label: '缩小' }, { key: 'audioOn', label: '声音开' }, { key: 'audioOff', label: '声音关' }, ]; const CELL_PIXEL = 100; const HALF_H = CELL_PIXEL * 0.25; const DEFAULT_PROP_BLOCK_Y_OFFSET = 14; const DEFAULT_PROP_GROUND_Y_OFFSET = -11; const DEFAULT_MOVER_EMPTY_CELL_Y_OFFSET = HALF_H; const DEFAULT_PLAYER_RIDE_Y_OFFSET = HALF_H * 0.88; const DEFAULT_PLAYER_STAND_Y_OFFSET = 0; const ENTITY_DISPLAY_BASE = { player: { w: 0.68, h: 0.9 }, vehicle: { w: 0.96, h: 0.88 }, prop: { w: 0.52, h: 0.69 }, propGround: { w: 0.52, h: 0.69 }, }; const DEFAULT_ENTITY_DISPLAY = { player: { scale: 1 }, vehicle: { scale: 1 }, prop: { scale: 1 }, propGround: { scale: 1 }, propBlockYOffset: DEFAULT_PROP_BLOCK_Y_OFFSET, propGroundYOffset: DEFAULT_PROP_GROUND_Y_OFFSET, moverEmptyCellYOffset: DEFAULT_MOVER_EMPTY_CELL_Y_OFFSET, playerRideYOffset: DEFAULT_PLAYER_RIDE_Y_OFFSET, playerStandYOffset: DEFAULT_PLAYER_STAND_Y_OFFSET, }; const ENTITY_DISPLAY_FIELDS = [ { key: 'player', label: '角色' }, { key: 'vehicle', label: '载具' }, { key: 'prop', label: '可拾取物' }, { key: 'propGround', label: '可拾取物(空地)' }, ]; const ENTITY_DISPLAY_OFFSET_FIELDS = [ { key: 'propBlockYOffset', label: '砖块可拾取物 Y 偏移 (px)', fallback: DEFAULT_PROP_BLOCK_Y_OFFSET }, { key: 'propGroundYOffset', label: '空地可拾取物 Y 偏移 (px)', fallback: DEFAULT_PROP_GROUND_Y_OFFSET }, { key: 'moverEmptyCellYOffset', label: '空地/载具格 角色 Y 补偿 (px)', fallback: DEFAULT_MOVER_EMPTY_CELL_Y_OFFSET }, { key: 'playerRideYOffset', label: '骑乘载具 角色 Y 偏移 (px)', fallback: DEFAULT_PLAYER_RIDE_Y_OFFSET }, { key: 'playerStandYOffset', label: '角色站立 Y 偏移 (px)', fallback: DEFAULT_PLAYER_STAND_Y_OFFSET }, ]; function clampScale(v, fallback = 1) { const n = typeof v === 'number' ? v : parseFloat(String(v)); if (Number.isNaN(n)) return fallback; return Math.max(0.1, Math.min(2, n)); } function readScale(raw, base) { if (raw && raw.scale != null) return clampScale(raw.scale); if (raw?.w != null && raw?.h != null) { return clampScale(Math.min(raw.w / base.w, raw.h / base.h)); } if (raw?.w != null) return clampScale(raw.w / base.w); if (raw?.h != null) return clampScale(raw.h / base.h); return 1; } function readYOffset(raw, fallback) { const n = typeof raw === 'number' ? raw : parseFloat(String(raw)); if (Number.isNaN(n)) return fallback; return n; } function mergeEntityDisplay(raw) { const propScale = readScale(raw?.prop, ENTITY_DISPLAY_BASE.prop); const out = { player: { scale: readScale(raw?.player, ENTITY_DISPLAY_BASE.player) }, vehicle: { scale: readScale(raw?.vehicle, ENTITY_DISPLAY_BASE.vehicle) }, prop: { scale: propScale }, propGround: { scale: readScale(raw?.propGround ?? raw?.prop, ENTITY_DISPLAY_BASE.propGround) }, propBlockYOffset: readYOffset(raw?.propBlockYOffset, DEFAULT_PROP_BLOCK_Y_OFFSET), propGroundYOffset: readYOffset(raw?.propGroundYOffset, DEFAULT_PROP_GROUND_Y_OFFSET), moverEmptyCellYOffset: readYOffset(raw?.moverEmptyCellYOffset, DEFAULT_MOVER_EMPTY_CELL_Y_OFFSET), playerRideYOffset: readYOffset(raw?.playerRideYOffset, DEFAULT_PLAYER_RIDE_Y_OFFSET), playerStandYOffset: readYOffset(raw?.playerStandYOffset, DEFAULT_PLAYER_STAND_Y_OFFSET), }; return out; } function entityDisplayCellBoxes(scales) { const out = {}; for (const f of ENTITY_DISPLAY_FIELDS) { const s = scales[f.key].scale; const base = ENTITY_DISPLAY_BASE[f.key]; out[f.key] = { w: base.w * s, h: base.h * s }; } return out; } function readEntityDisplayFromDb(db) { return mergeEntityDisplay(db?.entityDisplay); } function readEntityDisplayFromForm(root) { if (!root) return mergeEntityDisplay(undefined); const get = (id) => { const el = root.querySelector(`#${id}`); if (!el) return NaN; return parseFloat(String(el.value ?? '')); }; const raw = {}; for (const f of ENTITY_DISPLAY_FIELDS) { raw[f.key] = { scale: get(`tc-ed-${f.key}-scale`) }; } for (const f of ENTITY_DISPLAY_OFFSET_FIELDS) { raw[f.key] = get(`tc-ed-${f.key}`); } return mergeEntityDisplay(raw); } function writeEntityDisplayToForm(root, entityDisplay) { const scales = mergeEntityDisplay(entityDisplay); const boxes = entityDisplayCellBoxes(scales); const set = (id, val) => { const el = root.querySelector(`#${id}`); if (el) el.value = String(val); }; const setHint = (id, base, box) => { const el = root.querySelector(`#${id}`); if (!el) return; const bw = Math.round(base.w * CELL_PIXEL); const bh = Math.round(base.h * CELL_PIXEL); const w = Math.round(box.w * CELL_PIXEL); const h = Math.round(box.h * CELL_PIXEL); el.textContent = `默认 ${bw}×${bh} → 现 ≈ ${w}×${h} px`; }; for (const f of ENTITY_DISPLAY_FIELDS) { const scale = scales[f.key].scale; set(`tc-ed-${f.key}-scale`, scale); setHint(`tc-ed-${f.key}-px`, ENTITY_DISPLAY_BASE[f.key], boxes[f.key]); } for (const f of ENTITY_DISPLAY_OFFSET_FIELDS) { set(`tc-ed-${f.key}`, scales[f.key]); } } function bindEntityDisplayInputs(root, onChange) { for (const f of ENTITY_DISPLAY_FIELDS) { const el = root.querySelector(`#tc-ed-${f.key}-scale`); if (el) el.addEventListener('change', () => onChange?.()); } for (const f of ENTITY_DISPLAY_OFFSET_FIELDS) { const el = root.querySelector(`#tc-ed-${f.key}`); if (el) el.addEventListener('change', () => onChange?.()); } } function themesDbPath(projectPath) { return path.join(projectPath, THEMES_DB_REL); } function palettesIndexPath(projectPath) { return path.join(projectPath, PALETTES_INDEX_REL); } function levelsDbPath(projectPath) { return path.join(projectPath, LEVELS_DB_REL); } function normalizePath(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 clampRatio(v, fallback) { const n = typeof v === 'number' ? v : parseFloat(String(v)); if (Number.isNaN(n)) return fallback; return Math.max(0.1, Math.min(2, n)); } function readThemesDb(projectPath) { const fp = themesDbPath(projectPath); if (!fs.existsSync(fp)) { return { version: 1, themes: {} }; } return JSON.parse(fs.readFileSync(fp, 'utf8')); } function writeThemesDb(projectPath, data) { data.updatedAt = new Date().toISOString(); if (data.entityDisplay && data.themes) { for (const id of Object.keys(data.themes)) { if (!data.themes[id].entityDisplay) { data.themes[id].entityDisplay = mergeEntityDisplay(data.entityDisplay); } } delete data.entityDisplay; } const fp = themesDbPath(projectPath); fs.mkdirSync(path.dirname(fp), { recursive: true }); fs.writeFileSync(fp, JSON.stringify(data, null, 2), 'utf8'); } function themeToPaletteEntry(themeId, theme) { const tiles = []; const t = theme.tiles || {}; const decorKey = theme.borderDecorKey || 'kuai11'; const entries = [ ['Baseblock', t.Baseblock, 'ground'], ['JumpBlock', t.JumpBlock, 'ground'], ['WallBlock', t.WallBlock, 'border'], [decorKey, t.borderDecor, 'border'], ]; let idx = 0; for (const [tileKey, texPath, layer] of entries) { if (!texPath) continue; tiles.push({ display: tileKey, layer, tileKey, texture: texPath, unityIndex: idx++, }); } return { displayName: theme.displayName || themeId, tiles, }; } function themesToPalettesMap(db) { const out = {}; for (const [id, theme] of Object.entries(db.themes || {})) { out[id] = themeToPaletteEntry(id, theme); } return out; } function buildThemeLabels(db) { const labels = {}; for (const [id, theme] of Object.entries(db.themes || {})) { labels[id] = theme.displayName || id; } return labels; } function syncPalettesIndex(projectPath, db) { const payload = { themes: themesToPalettesMap(db) }; const fp = palettesIndexPath(projectPath); fs.mkdirSync(path.dirname(fp), { recursive: true }); fs.writeFileSync(fp, JSON.stringify(payload, null, 2), 'utf8'); } function defaultHudForFolder(folder) { return { navigation: `textures/${folder}/anniu_03`, revert: `textures/${folder}/anniu_06`, speed1: `textures/${folder}/anniu_08`, speed2: `textures/${folder}/anniu_10`, speed4: `textures/${folder}/anniu_12`, zoomIn: `textures/${folder}/anniu_17`, zoomOut: `textures/${folder}/anniu_19`, audioOn: `textures/${folder}/anniu_22`, audioOff: `textures/${folder}/anniu_21`, }; } function createEmptyTheme(themeId, textureFolder) { const folder = textureFolder || themeId; return { displayName: themeId, textureFolder: folder, background: '', hud: defaultHudForFolder(folder), entities: { playerFront: `textures/${folder}/player_F`, playerBack: `textures/${folder}/player_B`, vehicleFront: `textures/${folder}/ship_F`, vehicleBack: `textures/${folder}/ship_B`, prop: `textures/${folder}/Prop`, propGround: `textures/${folder}/nProp`, }, tiles: { Baseblock: `textures/${folder}/Baseblock`, JumpBlock: `textures/${folder}/JumpBlock`, WallBlock: `textures/${folder}/WallBlock`, borderDecor: `textures/${folder}/kuai11`, }, borderDecorKey: 'kuai11', entityDisplay: { ...DEFAULT_ENTITY_DISPLAY }, }; } function countLevelsUsingTheme(projectPath, themeId) { const fp = levelsDbPath(projectPath); if (!fs.existsSync(fp)) return 0; const db = JSON.parse(fs.readFileSync(fp, 'utf8')); let n = 0; for (const cfg of Object.values(db.levels || {})) { if (cfg && cfg.theme === themeId) n += 1; } return n; } function readHudFromForm(root) { const get = (id) => root.querySelector(`#${id}`)?.value ?? ''; const hud = {}; for (const f of HUD_FIELDS) { const v = normalizePath(get(`tc-hud-${f.key}`)); if (v) hud[f.key] = v; } const sx = parseFloat(String(get('tc-hud-iconScaleX'))); const sy = parseFloat(String(get('tc-hud-iconScaleY'))); if (!Number.isNaN(sx)) hud.iconScaleX = sx; if (!Number.isNaN(sy)) hud.iconScaleY = sy; return hud; } function writeHudToForm(root, hud) { const set = (id, val) => { const el = root.querySelector(`#${id}`); if (el) el.value = val ?? ''; }; for (const f of HUD_FIELDS) { set(`tc-hud-${f.key}`, hud?.[f.key] || ''); } set('tc-hud-iconScaleX', hud?.iconScaleX ?? 1); set('tc-hud-iconScaleY', hud?.iconScaleY ?? 1); } function readThemeFromForm(root) { const get = (id) => root.querySelector(`#${id}`)?.value ?? ''; const theme = { displayName: get('tc-display-name'), textureFolder: get('tc-texture-folder'), background: normalizePath(get('tc-background')), borderDecorKey: get('tc-border-key') || 'kuai11', entities: {}, tiles: {}, hud: readHudFromForm(root), entityDisplay: readEntityDisplayFromForm(root.querySelector('#tc-entity-display')), }; for (const f of ENTITY_FIELDS) { const v = normalizePath(get(`tc-ent-${f.key}`)); if (v) theme.entities[f.key] = v; } for (const f of TILE_FIELDS) { const v = normalizePath(get(`tc-tile-${f.key}`)); if (v) theme.tiles[f.key] = v; } if (!theme.background) delete theme.background; return theme; } function writeThemeToForm(root, themeId, theme) { const set = (id, val) => { const el = root.querySelector(`#${id}`); if (el) el.value = val ?? ''; }; set('tc-theme-id', themeId || ''); set('tc-display-name', theme?.displayName || ''); set('tc-texture-folder', theme?.textureFolder || themeId || ''); set('tc-background', theme?.background || ''); set('tc-border-key', theme?.borderDecorKey || 'kuai11'); for (const f of ENTITY_FIELDS) { set(`tc-ent-${f.key}`, theme?.entities?.[f.key] || ''); } for (const f of TILE_FIELDS) { set(`tc-tile-${f.key}`, theme?.tiles?.[f.key] || ''); } writeHudToForm(root, theme?.hud); const edWrap = root.querySelector('#tc-entity-display'); if (edWrap) writeEntityDisplayToForm(edWrap, theme?.entityDisplay); } module.exports = { THEMES_DB_REL, PALETTES_INDEX_REL, ENTITY_FIELDS, TILE_FIELDS, HUD_FIELDS, ENTITY_DISPLAY_FIELDS, ENTITY_DISPLAY_OFFSET_FIELDS, ENTITY_DISPLAY_BASE, DEFAULT_ENTITY_DISPLAY, DEFAULT_PROP_BLOCK_Y_OFFSET, DEFAULT_PROP_GROUND_Y_OFFSET, CELL_PIXEL, themesDbPath, readThemesDb, writeThemesDb, themesToPalettesMap, buildThemeLabels, syncPalettesIndex, createEmptyTheme, countLevelsUsingTheme, readThemeFromForm, writeThemeToForm, normalizePath, mergeEntityDisplay, entityDisplayCellBoxes, readEntityDisplayFromDb, readEntityDisplayFromForm, writeEntityDisplayToForm, bindEntityDisplayInputs, };