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:
2026-06-16 15:30:58 +08:00
parent cba5105908
commit d393302388
6248 changed files with 17322729 additions and 11036 deletions

View 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);
}