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,9 @@
{
"ver": "4.0.23",
"importer": "typescript",
"imported": true,
"uuid": "8726de39-9138-4457-8098-f4b669c27882",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,14 @@
import l10n from './l10n-manager';
import { game, cclegacy } from 'cc';
// @ts-expect-error
import { EDITOR } from 'cc/env';
if (cclegacy.GAME_VIEW || EDITOR) { // for Editor
// @ts-expect-error we need top level await in Editor
await l10n.createIntl({});
} else { // for Runtime or Preview
game.onPostProjectInitDelegate.add(
() => l10n.createIntl({}),
);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.23",
"importer": "typescript",
"imported": true,
"uuid": "2f21bc78-8001-4d8f-a75c-b7da831c91c7",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,65 @@
import { FallbackLanguage, L10nValue } from './l10n-options';
export type FormattedValue = string;
export type TextInfoDirection = 'ltr' | 'rtl';
export interface StandardOption {
count?: number;
// 暂不开放
// context?: string
defaultValue?: L10nValue;
// returnObjects?: boolean;
language?: Intl.BCP47LanguageTag;
fallbackLanguage?: FallbackLanguage;
// 暂不开放
// joinArrays?: string
}
export interface Template {
[key: string]:
| string
| {
[key: string]: StandardOption;
};
}
export interface NumberFormatOptions extends Intl.NumberFormatOptions {
style?: 'decimal' | 'percent' | 'currency' | string;
/**
* 货币代码采用ISO 4217标准
* @see ISO4217Tag
*/
currency?: string;
currencySign?: 'standard' | 'accounting' | string;
currencyDisplay?: 'symbol' | 'code' | 'name' | string;
useGrouping?: boolean;
minimumIntegerDigits?: number;
minimumFractionDigits?: number;
maximumFractionDigits?: number;
minimumSignificantDigits?: number;
maximumSignificantDigits?: number;
}
export interface DateTimeFormatOptions {
localeMatcher?: 'best fit' | 'lookup' | undefined | string;
weekday?: 'long' | 'short' | 'narrow' | undefined | string;
era?: 'long' | 'short' | 'narrow' | undefined | string;
year?: 'numeric' | '2-digit' | undefined | string;
month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow' | undefined | string;
day?: 'numeric' | '2-digit' | undefined | string;
hour?: 'numeric' | '2-digit' | undefined | string;
minute?: 'numeric' | '2-digit' | undefined | string;
second?: 'numeric' | '2-digit' | undefined | string;
timeZoneName?: 'long' | 'short' | undefined | string;
formatMatcher?: 'best fit' | 'basic' | undefined | string;
hour12?: boolean | undefined;
timeZone?: string | undefined;
}
export type RelativeTimeFormatUnit = 'second' | 'minute' | 'hour' | 'day' | 'month' | 'year' | string;
export interface RelativeTimeFormatOptions {
localeMatcher?: 'lookup' | 'best fit' | string;
style?: 'narrow' | 'short' | 'long' | string;
numeric?: 'auto' | 'always' | string;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.23",
"importer": "typescript",
"imported": true,
"uuid": "dc153188-b6f7-4234-8025-20163cbd0f8c",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,11 @@
/**
* Intl formatting
*/
enum ICUType {
DateTime,
Number,
List,
RelativeTime,
}
export default ICUType;

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.23",
"importer": "typescript",
"imported": true,
"uuid": "e5dc4963-9221-45a4-8f4b-a8af210423e5",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,12 @@
enum L10nListenEvent {
languageChanged = 'languageChanged',
onMissingKey = 'missingKey',
/**
* store events
*/
// onAdded = 'added',
// onRemoved = 'removed',
}
export default L10nListenEvent;

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.23",
"importer": "typescript",
"imported": true,
"uuid": "3415306a-cc9f-4b47-8961-2437afa40e1f",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,255 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { createInstance, i18n, InitOptions as I18NextInitOptions } from 'i18next';
// @ts-ignore
import { EDITOR, BUILD, PREVIEW } from 'cc/env';
// @ts-ignore
import { game, assetManager } from 'cc';
import type { L10nOptions, ResourceData, L10nKey, L10nValue, ResourceItem } from './l10n-options';
import {
DateTimeFormatOptions,
FormattedValue,
NumberFormatOptions,
RelativeTimeFormatOptions,
RelativeTimeFormatUnit,
StandardOption,
Template,
TextInfoDirection,
} from './icu-options';
import L10nListenEvent from './l10n-listen-event';
import ResourceDataManager from './resource-data-manager';
import { mainName, pluginName } from './localization-global';
import { ResourceBundle, ResourceList } from './l10n-options';
export class L10nManager {
static LOCAL_STORAGE_LANGUAGE_KEY = `${mainName}/language`;
static readonly DEFAULT_NAMESPACE = 'translation' as const;
static readonly ASSET_NAMESPACE = 'asset' as const;
static readonly ALLOW_NAMESPACE = [L10nManager.DEFAULT_NAMESPACE, L10nManager.ASSET_NAMESPACE] as const;
static l10n: L10nManager = new L10nManager();
/**
* @zh
* i18n 实例
* @en
* i18next instance
*/
private _intl?: i18n = undefined;
private _options: L10nOptions = {};
private resourceList?: ResourceList;
private resourceBundle: ResourceBundle = {};
public resourceDataManager: ResourceDataManager;
private constructor() {
this.resourceDataManager = new ResourceDataManager();
}
public isInitialized(): boolean {
return this._intl?.isInitialized ?? false;
}
public async createIntl(options: L10nOptions) {
const reloadResult = await this.reloadResourceData();
if (!reloadResult) {
return;
}
this._options = options;
this._intl = createInstance();
let localStorageLanguage: string | undefined = undefined;
if (BUILD && !PREVIEW) {
localStorageLanguage = localStorage.getItem(
l10n['_options'].localStorageLanguageKey ?? L10nManager.LOCAL_STORAGE_LANGUAGE_KEY,
);
localStorageLanguage = this.checkLanguage(localStorageLanguage);
}
const defaultLanguage = localStorageLanguage ?? options.language ?? this.resourceList.defaultLanguage;
const fallbackLanguage = options.fallbackLanguage ?? this.resourceList.fallbackLanguage;
const resources = options.resources ?? this.resourceBundle;
const i18nextOptions: I18NextInitOptions = {
lng: defaultLanguage,
fallbackLng: fallbackLanguage,
resources: { ...resources },
ns: L10nManager.ALLOW_NAMESPACE,
defaultNS: L10nManager.DEFAULT_NAMESPACE,
initImmediate: false,
load: 'currentOnly',
};
await this._intl.init(i18nextOptions);
this.setAssetOverrideMap(resources[defaultLanguage][L10nManager.ASSET_NAMESPACE]);
}
public checkLanguage(language: string): string | undefined {
if (!language || language.length === 0 || language === 'null' || language === null || language === 'undefined' || language === undefined) {
return undefined;
}
if (this.resourceList && this.resourceList.languages.length > 0 && this.resourceList.languages.find(it => it === language)) {
return language;
}
return undefined;
}
public cloneIntl(options: L10nOptions) {
if (!this._intl) {
throw new Error('i18next not init, please use \'l10n.createIntl\'');
}
this._intl = this._intl.cloneInstance(options);
}
async reloadResourceData(): Promise<boolean> {
this.resourceList = await this.resourceDataManager.readResourceList();
if (!this.resourceList) {
console.log(`[${pluginName}] not found translate language list, skip init l10n`);
return false;
}
this.resourceBundle = await this.resourceDataManager.readResourceBundle(this.resourceList?.languages ?? []);
if (!this.resourceList?.defaultLanguage) {
console.log(`[${pluginName}] not found translate language data, skip init l10n`);
return false;
}
return true;
}
/** 初始化 i18next */
public config(options: L10nOptions) {
this.cloneIntl(options);
}
public async changeLanguage(language: Intl.BCP47LanguageTag) {
if (!language) {
console.warn(`[${pluginName}] invalid language tag`);
return;
}
console.log(`[${pluginName}] will change language to`, language);
if (this._intl) {
if (this.currentLanguage) {
this.releaseOverrideMap();
}
await this._intl.changeLanguage(language);
this.setAssetOverrideMap(this.resourceBundle[language][L10nManager.ASSET_NAMESPACE]);
if (!EDITOR) {
localStorage.setItem(L10nManager.LOCAL_STORAGE_LANGUAGE_KEY, language);
console.log(`[${pluginName}] game will restart`);
game.restart();
}
} else {
console.log(`[${pluginName}] language data not load, please confirm whether the language data is included in the build`);
}
}
public t(key: L10nKey, options?: StandardOption | Template): L10nValue {
if (!(this._intl?.isInitialized ?? false)) return key;
return this._intl!.t(key, options);
}
/**
* 实验性功能暂不开放
* 数字类ICU
*/
private tn(value: number, options?: NumberFormatOptions): FormattedValue {
if (!(this._intl?.isInitialized ?? false)) return value.toString();
const cloneOptions: NumberFormatOptions = {};
Object.assign(cloneOptions, options);
type NumberFormatOptionsKey = keyof NumberFormatOptions;
for (const key of Object.keys(cloneOptions) as NumberFormatOptionsKey[]) {
if (typeof cloneOptions[key] === 'string' && (cloneOptions[key] as string)!.length === 0) {
delete cloneOptions[key];
} else if (typeof cloneOptions[key] === 'number' && cloneOptions[key] === 0) {
delete cloneOptions[key];
}
}
return new Intl.NumberFormat(this._intl?.language, cloneOptions).format(value);
}
/**
* 实验性功能暂不开放
* 日期/时刻类ICU
*/
private td(date: Date, options?: DateTimeFormatOptions): FormattedValue {
if (!(this._intl?.isInitialized ?? false)) return date.toString();
const cloneOptions: DateTimeFormatOptions = {};
Object.assign(cloneOptions, options);
type DateTimeFormatOptionsKey = keyof DateTimeFormatOptions;
for (const key of Object.keys(cloneOptions) as DateTimeFormatOptionsKey[]) {
if (typeof cloneOptions[key] === 'string' && (cloneOptions[key] as string).length === 0) {
delete cloneOptions[key];
}
}
return new Intl.DateTimeFormat(this._intl?.language, cloneOptions as Intl.DateTimeFormatOptions).format(date);
}
/**
* 实验性功能暂不开放
* 时长类ICU
*/
private tt(value: number, unit: RelativeTimeFormatUnit, options?: RelativeTimeFormatOptions): FormattedValue {
if (!(this._intl?.isInitialized ?? false)) return value.toString();
const cloneOptions: RelativeTimeFormatOptions = {};
Object.assign(cloneOptions, options);
type RelativeTimeFormatOptionsKey = keyof RelativeTimeFormatOptions;
for (const key of Object.keys(cloneOptions) as RelativeTimeFormatOptionsKey[]) {
if (typeof cloneOptions[key] === 'string' && (cloneOptions[key] as string).length === 0) {
delete cloneOptions[key];
}
}
return new Intl.RelativeTimeFormat(this._intl?.language, cloneOptions as Intl.RelativeTimeFormatOptions).format(
value,
unit as Intl.RelativeTimeFormatUnit,
);
}
/**
* 实验性功能暂不开放
* 数组类ICU
*/
private tl(value: string[]): FormattedValue {
if (!(this._intl?.isInitialized ?? false)) return value.toString();
return new Intl.ListFormat(this._intl?.language).format(value);
}
public exists(key: L10nKey): boolean {
return this._intl?.exists(key) ?? false;
}
get currentLanguage(): Intl.BCP47LanguageTag {
return this._intl?.language ?? '';
}
get languages(): readonly Intl.BCP47LanguageTag[] {
return this.resourceList?.languages ?? [];
}
public direction(language?: Intl.BCP47LanguageTag): TextInfoDirection {
return (language ? new Intl.Locale(language) : new Intl.Locale(this._intl!.language)).textInfo()
.direction as TextInfoDirection;
}
public on(event: L10nListenEvent, callback: (...args: any[]) => void) {
this._intl?.on(event, callback);
}
public off(event: L10nListenEvent, callback: (...args: any[]) => void) {
this._intl?.off(event, callback);
}
public getResourceBundle(language: string, namespace: typeof L10nManager.ALLOW_NAMESPACE[number]): ResourceData | undefined {
return this._intl?.getResourceBundle(language, namespace);
}
protected setAssetOverrideMap(assetNamespace: Readonly<ResourceItem>) {
for (const key of Object.keys(assetNamespace)) {
assetManager.assetsOverrideMap.set(key, assetNamespace[key]);
}
}
protected releaseOverrideMap() {
assetManager.assetsOverrideMap.clear();
}
}
const l10n: L10nManager = L10nManager.l10n;
export default l10n;

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.23",
"importer": "typescript",
"imported": true,
"uuid": "2856aec6-495f-456d-be77-9ce8a6277dc9",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,116 @@
export type L10nKey = string;
export type L10nValue = string;
export interface ResourceList {
defaultLanguage?: Intl.BCP47LanguageTag;
fallbackLanguage?: Intl.BCP47LanguageTag;
languages: Intl.BCP47LanguageTag[];
}
export interface ResourceBundle {
[language: Intl.BCP47LanguageTag]: ResourceData;
}
export interface ResourceData {
[namespace: string]: ResourceItem;
}
export interface ResourceItem {
[key: string]: any;
}
export interface FallbackLanguageObjectList {
[language: string]: readonly string[];
}
export type FallbackLanguage =
| string
| readonly string[]
| FallbackLanguageObjectList
| ((language: Intl.BCP47LanguageTag) => string | readonly string[] | FallbackLanguageObjectList);
export interface L10nOptions {
/**
* Logs info level to console output. Helps finding issues with loading not working.
* @default false
*/
// debug?: boolean;
/**
* Resources to initialize with (if not using loading or not appending using addResourceBundle)
* @default undefined
*/
resources?: ResourceBundle;
/**
* Language to use (overrides language detection)
*/
language?: Intl.BCP47LanguageTag;
/**
* Language to use if translations in user language are not available.
* @default same as language
*/
fallbackLanguage?: false | FallbackLanguage;
/**
* @default IntlManager.LOCAL_STORAGE_LANGUAGE_KEY
*/
localStorageLanguageKey?: string;
/**
* @zh
* 可以对key进行前置处理返回值应该是处理后的key
*
* @en
* Preprocess the key
*
* @param key
* @return string
* onBeforeProcessHandler
*/
beforeTranslate?: (key: L10nKey) => L10nValue;
/**
* @zh
* 对value进行后置处理返回值应该是处理后的value
*
* @en
* Postprocess the value, return the processed value
*
* @param key
* @param value
* @return string
*/
afterTranslate?: (key: string, value: string) => string;
/**
* Allows null values as valid translation
* @default true
*/
returnNull?: boolean;
/**
* Allows empty string as valid translation
* @default true
*/
returnEmptyString?: boolean;
/**
* Allows objects as valid translation result
* @default false
*/
// returnObjects?: boolean;
/**
* Gets called if object was passed in as key but returnObjects was set to false
* @default noop
*/
// returnedObjectHandler?: (key: string, value: string, options: any) => void;
/**
* Char, eg. '\n' that arrays will be joined by
* @default false
*/
// joinArrays?: false | string;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.23",
"importer": "typescript",
"imported": true,
"uuid": "b461821b-b11b-4b2d-b469-d9d42b137108",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,5 @@
export const pluginName = 'Localization Editor';
export const mainName = 'localization-editor';
export const runtimeBundleName = 'l10n';
export const resourceListPath = 'resource-list';
export const resourceBundlePath = 'resource-bundle';

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.23",
"importer": "typescript",
"imported": true,
"uuid": "02d9eb4a-80d0-46a8-b3e9-bd4ed15f6f68",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,90 @@
// @ts-ignore
import { BUILD, EDITOR } from 'cc/env';
import { AssetManager, assetManager, JsonAsset, settings, Settings } from 'cc';
import { ResourceBundle, ResourceList } from './l10n-options';
import { mainName, pluginName, resourceBundlePath, resourceListPath, runtimeBundleName } from './localization-global';
export default class ResourceDataManager {
async readResourceList(): Promise<ResourceList> {
if (EDITOR) {
return Editor.Message.request(mainName, 'get-resource-list');
} else if (BUILD) {
console.log(`[${pluginName}] this is build env`);
return this.runtimeLoad(resourceListPath);
} else {
return this.previewLoad(resourceListPath);
}
}
async readResourceBundle(tags: Intl.BCP47LanguageTag[]): Promise<ResourceBundle> {
if (EDITOR) {
return this.editorLoad(tags);
} else if (BUILD) {
return this.runtimeLoad(resourceBundlePath);
} else {
return this.previewLoad(resourceBundlePath);
}
}
/**
* 编辑器模式下使用
* @param locales
*/
async editorLoad(locales: Intl.BCP47LanguageTag[]): Promise<ResourceBundle | undefined> {
return Editor.Message.request(mainName, 'get-resource-bundle', locales);
}
/**
* 构建后运行时使用
* @param fileName
*/
async runtimeLoad<T>(fileName: string): Promise<T | undefined> {
const bundle = await this.getBundle(runtimeBundleName);
if (!bundle) return undefined;
const jsonAsset = await this.getResource(bundle, fileName);
if (!jsonAsset || !jsonAsset.json) return undefined;
return jsonAsset.json as any as T;
}
/**
* 浏览器预览使用
* @param urlPath
*/
async previewLoad<T>(urlPath: string): Promise<T | undefined> {
try {
return await (await fetch(`${mainName}/${urlPath}`)).json() as T;
} catch (e) {
return undefined;
}
}
async checkBundle(bundleName: string): Promise<boolean> {
const queryResult: { bundle: string, version: string }[] | null = settings.querySettings<{ bundle: string, version: string }[]>(Settings.Category.ASSETS, 'preloadBundles');
const bundle = queryResult?.find((it) => it.bundle === bundleName);
return !!bundle;
}
async getBundle(bundleName: string): Promise<AssetManager.Bundle | undefined> {
return new Promise(resolve => {
assetManager.loadBundle(bundleName, (error, bundle: AssetManager.Bundle) => {
if (error) {
resolve(undefined);
} else {
resolve(bundle);
}
});
});
}
async getResource(bundle: AssetManager.Bundle, resourceName: string): Promise<JsonAsset | undefined> {
return new Promise(resolve => {
bundle.load(resourceName, (error, asset: JsonAsset) => {
if (error) {
resolve(undefined);
} else {
resolve(asset);
}
});
});
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.23",
"importer": "typescript",
"imported": true,
"uuid": "0a95ad24-8abb-499c-99cc-9d959245a167",
"files": [],
"subMetas": {},
"userData": {}
}