Files
cocos/assets/scripts/theme/ThemeDatabase.ts
刘宇飞 d393302388 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>
2026-06-16 15:30:58 +08:00

439 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<EntityDisplayKind, EntityDisplayCellBox> = {
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<string, ThemeHudConfig> = {
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<string, string> = {
default: 'silu',
SILU: 'silu',
redArmy: 'redarmy',
};
const FOLDER_TO_THEME_ID: Record<string, string> = {
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<Record<EntityDisplayKind, EntityDisplayScaleConfig | EntityDisplayCellBox>> & Pick<EntityDisplayGlobalConfig, 'propBlockYOffset' | 'propGroundYOffset' | 'moverEmptyCellYOffset' | 'playerRideYOffset' | 'playerStandYOffset'>) | 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<EntityDisplayKind, EntityDisplayCellBox> {
const out = {} as Record<EntityDisplayKind, EntityDisplayCellBox>;
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<EntityDisplayKind, EntityDisplayCellBox> {
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<EntityDisplayKind, EntityDisplayCellBox> {
return entityDisplayCellBoxes(getThemeEntityDisplayScales(themeId));
}
let fileCache: ThemeDatabaseFile | null = null;
let themesMap: Record<string, ThemeConfig> = {};
let sortedIds: string[] = [];
let loadPromise: Promise<void> | 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<void> {
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<void> {
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);
}