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:
438
assets/scripts/theme/ThemeDatabase.ts
Normal file
438
assets/scripts/theme/ThemeDatabase.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user