import { JsonAsset, resources } from 'cc'; import { CELL_PIXEL } from '../core/GridConstants'; import type { ThemeConfig, ThemeDatabaseFile, ThemeEntityConfig, ThemeTileConfig, ThemeHudConfig, ThemeHudIconKey, EntityDisplayGlobalConfig, EntityDisplayCellBox, EntityDisplayKind, EntityDisplayScaleConfig, } from './ThemeTypes'; const DB_PATH = 'theme/themes-database'; /** scale=1 时的包围盒(相对 100px 格子),宽高比固定 */ export const ENTITY_DISPLAY_BASE: Record = { player: { w: 0.68, h: 0.9 }, vehicle: { w: 0.96, h: 0.88 }, /** 与 Unity Prop_kuai* 竖长比例一致(约 49×69) */ prop: { w: 0.52, h: 0.69 }, propGround: { w: 0.52, h: 0.69 }, }; const HALF_H = CELL_PIXEL * 0.25; /** Unity Prop_sanxing checkPoint collider ≈ +0.144 world (PPU 100) */ export const DEFAULT_PROP_BLOCK_Y_OFFSET = 14; /** Unity nProp_sanxing checkPoint collider ≈ -0.115 world */ export const DEFAULT_PROP_GROUND_Y_OFFSET = -11; /** 空地/载具格角色与载具:补齐无 Baseblock 时的视觉高度(约半格) */ export const DEFAULT_MOVER_EMPTY_CELL_Y_OFFSET = HALF_H; /** JumpBlock 自动补偿之上的主题微调(px) */ export const DEFAULT_MOVER_JUMP_CELL_Y_OFFSET = 0; /** 骑乘时角色相对载具节点抬高,对齐载具甲板(约 vehicle 包围盒 40%) */ export const DEFAULT_PLAYER_RIDE_Y_OFFSET = HALF_H * 0.88; export const DEFAULT_PLAYER_STAND_Y_OFFSET = 0; export const DEFAULT_ENTITY_DISPLAY: EntityDisplayGlobalConfig = { 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, moverJumpCellYOffset: DEFAULT_MOVER_JUMP_CELL_Y_OFFSET, playerRideYOffset: DEFAULT_PLAYER_RIDE_Y_OFFSET, playerStandYOffset: DEFAULT_PLAYER_STAND_Y_OFFSET, }; const DISPLAY_KINDS: EntityDisplayKind[] = ['player', 'vehicle', 'prop', 'propGround']; const HUD_ICON_KEYS: ThemeHudIconKey[] = [ 'navigation', 'revert', 'speed1', 'speed2', 'speed4', 'zoomIn', 'zoomOut', 'audioOn', 'audioOff', ]; /** Unity 导入的内置 HUD 回退(themes-database 未配置 hud 时使用) */ const FALLBACK_HUD: Record = { silu: { navigation: 'textures/silu/anniu_03', revert: 'textures/silu/anniu_06', speed1: 'textures/silu/anniu_08', speed2: 'textures/silu/anniu_10', speed4: 'textures/silu/anniu_12', zoomIn: 'textures/silu/anniu_17', zoomOut: 'textures/silu/anniu_19', audioOn: 'textures/silu/anniu_22', audioOff: 'textures/silu/anniu_21', }, }; const THEME_ID_ALIASES: Record = { default: 'silu', SILU: 'silu', redArmy: 'redarmy', }; const FOLDER_TO_THEME_ID: Record = { redArmy: 'redarmy', }; function asTrimmedString(v: unknown): string | undefined { if (v == null) return undefined; if (typeof v === 'string') { const s = v.trim(); return s || undefined; } if (typeof v === 'number' || typeof v === 'boolean') { return String(v); } return undefined; } export function resolveThemeId(themeId: string | undefined): string { const trimmed = asTrimmedString(themeId); if (!trimmed) return 'silu'; const raw = trimmed; if (themesMap[raw]) return raw; const alias = THEME_ID_ALIASES[raw]; if (alias && themesMap[alias]) return alias; const fromFolder = FOLDER_TO_THEME_ID[raw]; if (fromFolder && themesMap[fromFolder]) return fromFolder; const lower = raw.toLowerCase(); if (themesMap[lower]) return lower; return raw; } function mergeHudConfig(raw: ThemeHudConfig | undefined, themeId: string): ThemeHudConfig { const layers: ThemeHudConfig[] = [FALLBACK_HUD.silu]; if (FALLBACK_HUD[themeId]) layers.push(FALLBACK_HUD[themeId]!); if (raw) layers.push(raw); return Object.assign({}, ...layers); } export function getThemeHudConfig(themeId: string | undefined): ThemeHudConfig { const id = resolveThemeId(themeId); const cfg = getThemeConfig(id); return mergeHudConfig(cfg?.hud, themesMap[id] ? id : 'silu'); } export function getThemeHudIconPath(themeId: string | undefined, icon: ThemeHudIconKey): string | undefined { const hud = getThemeHudConfig(themeId); const path = asTrimmedString(hud[icon]); if (path) return path; return FALLBACK_HUD.silu[icon]; } export function getThemeHudIconScale(themeId: string | undefined): { x: number; y: number } { const hud = getThemeHudConfig(themeId); return { x: hud.iconScaleX ?? 1, y: hud.iconScaleY ?? 1, }; } /** 左上角肖像贴图(优先 entities.portrait) */ export function getThemePortraitPath(themeId: string | undefined): string | undefined { const id = resolveThemeId(themeId); const ent = getThemeConfig(id)?.entities; const portrait = asTrimmedString(ent?.portrait); if (portrait) return portrait; return asTrimmedString(ent?.playerFront); } /** 左上角肖像水平翻转(默认朝右) */ export function getThemePortraitFlipX(themeId: string | undefined): boolean { const hud = getThemeHudConfig(themeId); return hud.portraitFlipX ?? true; } const DEFAULT_PORTRAIT_SCALE = 1.35; export function getThemePortraitScale(themeId: string | undefined): number { const hud = getThemeHudConfig(themeId); const raw = hud.portraitScale; if (raw != null && Number.isFinite(raw)) { return Math.max(0.5, Math.min(2.5, raw)); } return DEFAULT_PORTRAIT_SCALE; } export function getThemeHudIconCandidates(themeId: string | undefined, icon: ThemeHudIconKey): string[] { const primary = getThemeHudIconPath(themeId, icon); if (!primary) return []; // 不回退到 silu 碎图,避免非丝路主题下倍速/音量变成黄圆按钮 return [primary]; } function clampScale(v: number, fallback = 1): number { if (!Number.isFinite(v)) return fallback; return Math.max(0.1, Math.min(2, v)); } function readScale(raw: EntityDisplayScaleConfig | EntityDisplayCellBox | undefined, base: EntityDisplayCellBox): number { if (raw && 'scale' in raw && raw.scale != null) { return clampScale(raw.scale); } const legacy = raw as EntityDisplayCellBox | undefined; if (legacy?.w != null && legacy?.h != null) { return clampScale(Math.min(legacy.w / base.w, legacy.h / base.h)); } if (legacy?.w != null) return clampScale(legacy.w / base.w); if (legacy?.h != null) return clampScale(legacy.h / base.h); return 1; } function mergeScale(raw: EntityDisplayScaleConfig | EntityDisplayCellBox | undefined, kind: EntityDisplayKind): EntityDisplayScaleConfig { return { scale: readScale(raw, ENTITY_DISPLAY_BASE[kind]) }; } function readYOffset(raw: number | undefined, fallback: number): number { if (raw == null || !Number.isFinite(raw)) return fallback; return raw; } /** 读取 JSON 中的等比缩放配置(兼容旧版 w/h) */ export function mergeEntityDisplay( raw: (Partial> & Pick) | undefined, ): EntityDisplayGlobalConfig { const prop = mergeScale(raw?.prop, 'prop'); const blockY = readYOffset(raw?.propBlockYOffset, DEFAULT_PROP_BLOCK_Y_OFFSET); return { player: mergeScale(raw?.player, 'player'), vehicle: mergeScale(raw?.vehicle, 'vehicle'), prop, propGround: mergeScale(raw?.propGround ?? raw?.prop, 'propGround'), propBlockYOffset: blockY, propGroundYOffset: readYOffset(raw?.propGroundYOffset, DEFAULT_PROP_GROUND_Y_OFFSET), moverEmptyCellYOffset: readYOffset(raw?.moverEmptyCellYOffset, DEFAULT_MOVER_EMPTY_CELL_Y_OFFSET), moverJumpCellYOffset: readYOffset(raw?.moverJumpCellYOffset, DEFAULT_MOVER_JUMP_CELL_Y_OFFSET), playerRideYOffset: readYOffset(raw?.playerRideYOffset, DEFAULT_PLAYER_RIDE_Y_OFFSET), playerStandYOffset: readYOffset(raw?.playerStandYOffset, DEFAULT_PLAYER_STAND_Y_OFFSET), }; } /** 骑乘载具时角色视觉 Y 抬高(逻辑格点仍与载具一致) */ export function getThemePlayerRideYOffset(themeId: string | undefined): number { const cfg = getThemeEntityDisplayScales(themeId); return cfg.playerRideYOffset ?? DEFAULT_PLAYER_RIDE_Y_OFFSET; } /** 角色站立 Y 偏移(负值降低) */ export function getThemePlayerStandYOffset(themeId: string | undefined): number { const cfg = getThemeEntityDisplayScales(themeId); return cfg.playerStandYOffset ?? DEFAULT_PLAYER_STAND_Y_OFFSET; } /** 空地/载具格角色与载具 Y 补偿(有砖块格为 0) */ export function getThemeMoverEmptyCellYOffset(themeId: string | undefined): number { const cfg = getThemeEntityDisplayScales(themeId); return cfg.moverEmptyCellYOffset ?? cfg.propBlockYOffset ?? DEFAULT_MOVER_EMPTY_CELL_Y_OFFSET; } /** JumpBlock 额外 Y 抬高(在自动 pivot 差值之上) */ export function getThemeMoverJumpCellYOffset(themeId: string | undefined): number { const cfg = getThemeEntityDisplayScales(themeId); return cfg.moverJumpCellYOffset ?? DEFAULT_MOVER_JUMP_CELL_Y_OFFSET; } /** 可拾取物砖块/空地 Y 偏移(来自主题 entityDisplay,缺省为 Unity Prop/nProp 换算值) */ export function getThemePropPlacementOffsets(themeId: string | undefined): { block: number; ground: number } { const cfg = getThemeEntityDisplayScales(themeId); return { block: cfg.propBlockYOffset ?? DEFAULT_PROP_BLOCK_Y_OFFSET, ground: cfg.propGroundYOffset ?? DEFAULT_PROP_GROUND_Y_OFFSET, }; } /** 由缩放算出运行时包围盒(保持默认宽高比) */ export function entityDisplayCellBoxes(scales: EntityDisplayGlobalConfig): Record { const out = {} as Record; for (const kind of DISPLAY_KINDS) { const s = scales[kind].scale; const base = ENTITY_DISPLAY_BASE[kind]; out[kind] = { w: base.w * s, h: base.h * s }; } return out; } export function getGlobalEntityDisplay(): Record { return entityDisplayCellBoxes(getGlobalEntityDisplayScales()); } /** @deprecated 使用 getThemeEntityDisplayScales(themeId) */ export function getGlobalEntityDisplayScales(): EntityDisplayGlobalConfig { return mergeEntityDisplay(fileCache?.entityDisplay); } export function getThemeEntityDisplayScales(themeId: string | undefined): EntityDisplayGlobalConfig { const cfg = getThemeConfig(resolveThemeId(themeId)); if (cfg?.entityDisplay) { return mergeEntityDisplay(cfg.entityDisplay); } if (fileCache?.entityDisplay) { return mergeEntityDisplay(fileCache.entityDisplay); } return mergeEntityDisplay(undefined); } export function getThemeEntityDisplayCellBoxes(themeId: string | undefined): Record { return entityDisplayCellBoxes(getThemeEntityDisplayScales(themeId)); } let fileCache: ThemeDatabaseFile | null = null; let themesMap: Record = {}; let sortedIds: string[] = []; let loadPromise: Promise | null = null; function rebuildIndex() { sortedIds = Object.keys(themesMap).sort((a, b) => a.localeCompare(b)); } function ingestFile(data: ThemeDatabaseFile) { migrateLegacyEntityDisplay(data); fileCache = data; themesMap = { ...(data.themes ?? {}) }; rebuildIndex(); } /** 将旧版根级 entityDisplay 迁移到各主题(仅补缺失项) */ function migrateLegacyEntityDisplay(data: ThemeDatabaseFile) { const legacy = data.entityDisplay; if (!legacy || !data.themes) return; for (const id of Object.keys(data.themes)) { if (!data.themes[id].entityDisplay) { data.themes[id].entityDisplay = mergeEntityDisplay(legacy); } } } export function loadThemeDatabase(): Promise { if (loadPromise) return loadPromise; loadPromise = new Promise((resolve, reject) => { resources.load(DB_PATH, JsonAsset, (err, asset) => { if (err || !asset?.json) { console.warn('[ThemeDatabase] 未找到 themes-database.json,将使用内置回退', err); ingestFile({ version: 1, themes: {} }); resolve(); return; } ingestFile(asset.json as ThemeDatabaseFile); console.log(`[ThemeDatabase] 已加载 ${sortedIds.length} 个主题: ${sortedIds.join(', ')}`); resolve(); }); }); return loadPromise; } /** 重新读取 themes-database.json(主题控制器保存全局尺寸后生效) */ export function reloadThemeDatabase(): Promise { fileCache = null; loadPromise = null; themesMap = {}; sortedIds = []; return new Promise((resolve) => { resources.release(DB_PATH); loadThemeDatabase().then(resolve); }); } export function isThemeDatabaseReady(): boolean { return fileCache !== null; } export function getThemeIds(): string[] { return [...sortedIds]; } export function getThemeConfig(themeId: string | undefined): ThemeConfig | null { if (!themeId) return null; return themesMap[themeId] ?? null; } export function hasTheme(themeId: string): boolean { return themeId in themesMap; } export function getThemeTextureFolder(themeId: string | undefined): string { const cfg = getThemeConfig(themeId); if (cfg?.textureFolder) return cfg.textureFolder; if (themeId === 'redarmy') return 'redArmy'; return themeId ?? 'silu'; } export function getThemeEntities(themeId: string | undefined): ThemeEntityConfig | null { const cfg = getThemeConfig(themeId); return cfg?.entities ?? null; } export function getThemeBackground(themeId: string | undefined): string | undefined { const cfg = getThemeConfig(themeId); return asTrimmedString(cfg?.background); } export function getThemeTiles(themeId: string | undefined): ThemeTileConfig | null { const cfg = getThemeConfig(themeId); return cfg?.tiles ?? null; } export function getThemeBorderDecorKey(themeId: string | undefined): string | undefined { const cfg = getThemeConfig(resolveThemeId(themeId)); return asTrimmedString(cfg?.borderDecorKey); } /** 关卡 JSON / Unity 预制体里常见的装饰砖 tileKey,统一映射到 themes-database 的 borderDecor */ const BORDER_DECOR_ALIASES = new Set([ 'kuai11', 'Decor23', 'borderDecor', '素材切图-23', '素材切图2-23', '小游戏素材红色_03', ]); /** 按 tileKey 解析贴图路径(含 borderDecorKey 与跨主题别名) */ export function getThemeTilePath(themeId: string | undefined, tileKey: unknown): string | undefined { const id = resolveThemeId(themeId); const tiles = getThemeTiles(id); if (!tiles) return undefined; const key = asTrimmedString(tileKey); if (!key) return undefined; if (key === 'Baseblock' && tiles.Baseblock) return tiles.Baseblock; if (key === 'JumpBlock' && tiles.JumpBlock) return tiles.JumpBlock; if (key === 'WallBlock' && tiles.WallBlock) return tiles.WallBlock; const decorKey = getThemeBorderDecorKey(id); if (tiles.borderDecor) { if (key === decorKey || BORDER_DECOR_ALIASES.has(key)) { return tiles.borderDecor; } } if (tiles.borderDecor && key === 'borderDecor') return tiles.borderDecor; return undefined; } export function setTheme(themeId: string, config: ThemeConfig): void { themesMap[themeId] = { ...config }; if (fileCache) fileCache.themes[themeId] = themesMap[themeId]; rebuildIndex(); } export function removeTheme(themeId: string): boolean { if (!(themeId in themesMap)) return false; delete themesMap[themeId]; if (fileCache?.themes) delete fileCache.themes[themeId]; rebuildIndex(); return true; } export function exportDatabaseJson(): string { const payload: ThemeDatabaseFile = fileCache ?? { version: 1, themes: {} }; payload.updatedAt = new Date().toISOString(); payload.themes = { ...themesMap }; return JSON.stringify(payload, null, 2); }