Cocos Creator 第二优雅的多语言组件实现

Cocos Creator 第二优雅的多语言组件实现

cocos creator

简介

基于 cocos creator 2.4.3 的一个手游项目模板, 提供一些自定义组件以及 Demo, 不定期维护中, 欢迎点赞收藏…
感兴趣一起交流的可以加我的微信: yanjfia2013, 备注 creator

项目地址: https://github.com/yanjifa/game-template

  • 本次着重介绍多语言组件, Demo在线查看
    目前的多语言组件如果想对已经上线的项目进行多语言支持, 普遍都要我对每个 Label 组件都操作一遍, 挂上组件脚本, 这波操作说实话, 小项目还好, 大项目简直让人崩溃。
    所以之前基于 1.10.3 版本搞了一个使用上更方便的多语言实现, 现在适配到 2.4.3 版本, 并添加了对 BMFONT 的支持。
    • 继承 cc.Label 内置组件实现, 使用上完全兼容 cc.Label。
    • 老项目方便接入, vscode 全局查找替换即可。
    // cocos creator 开发者工具, 控制台输入
    // uuid 为 LocalizedLabel.ts 脚本的 uuid
    Editor.Utils.UuidUtils.compressUuid("712432e0-72b6-4e45-90c5-42bf111e8964")
    // 得到压缩后的 uuid, 全局替换 prefab & fire 文件中的 cc.Label
    "71243LgcrZORZDFQr8RHolk"
    // 重新打开 prefab, 组件就以替换完毕
    

废话不多说, 该上图了。


replaceComp6fc9757e6e57132a.png


  • 支持编辑器预览
    LocalizedLabel0d9ef91a0f42be2a.gif

  • 修改语言设置立即生效
    setting907adb52785a093c.gif

怎么跑起来

克隆完项目后初始化并更新子模块, 子模块使用了论坛 Next 大佬的 ccc-detools 我比较喜欢这个工具, 堪称神器。

// 不更新无法使用浏览器预览
git submodule update --init --recursive

安装依赖, 项目根目录执行

// 必须
npm install

全局安装 ESlint

// 非必须
npm install -g eslint typescript @typescript-eslint/parser @typescript-eslint/eslint-plugin
// nvm 可能还需指定需要 NODE 环境变量
export NODE_PATH=$HOME/.nvm/versions/node/v12.19.0/lib/node_modules  // 根据自己使用的版本

ESLint 是一个开源的 JavaScript 代码检查工具,由 Nicholas C. Zakas 于2013年6月创建。代码检查是一种静态的分析,常用于寻找有问题的模式或者代码,并且不依赖于具体的编码风格。对大多数编程语言来说都会有代码检查,一般来说编译程序会内置检查工具。

JavaScript 是一个动态的弱类型语言,在开发中比较容易出错。因为没有编译程序,为了寻找 JavaScript 代码错误通常需要在执行过程中不断调试。像 ESLint 这样的可以让程序员在编码的过程中发现问题而不是在执行的过程中。

ESLint 的初衷是为了让程序员可以创建自己的检测规则。ESLint 的所有规则都被设计成可插入的。ESLint 的默认规则与其他的插件并没有什么区别,规则本身和测试可以依赖于同样的模式。为了便于人们使用,ESLint 内置了一些规则,当然,你可以在使用过程中自定义规则。

ESLint 使用 Node.js 编写,这样既可以有一个快速的运行环境的同时也便于安装。

:warning: TSLint已于2019年弃用.

Please see this issue for more details: Roadmap: TSLint → ESLint. now typescript-eslint is your best option for linting TypeScript.

  • 在我看来使用 ESlint 的意义
    • 统一代码风格, 项目组内不同人员写出风格基本一致的代码。
    • 提高代码可读性。

这是此项目使用到的规则:

展开查看 .eslintrc.json

{
    "env": {
        "browser": true,
        "node": true
    },
    "globals": {
        "Editor": true,
        "Vue": true
    },
    "extends": [
        "eslint:recommended"
    ],
    "parserOptions": {
        "ecmaVersion": 12,
        "sourceType": "module"
    },
    "rules": {
        // 尤达表达式
        "yoda": "warn",
        // parseInt
        "radix": "error",
        // 禁止多个连续空格
        "no-multi-spaces": [
            "error",
            {
                "ignoreEOLComments": true,
                "exceptions": {
                    "Property": true,
                    "VariableDeclarator": true
                }
            }
        ],
        // 箭头表达式空格
        "arrow-spacing": [
            "error",
            {
                "before": true,
                "after": true
            }
        ],
        // 使用 === or !===
        "eqeqeq": "error",
        // for in 循环必须包含 if 语句
        "guard-for-in": "error",
        // 双引号
        "quotes": [
            "error",
            "double"
        ],
        // 行尾空格警告
        "no-trailing-spaces": "warn",
        // 一行最大字符
        "max-len": [
            "warn",
            {
                "code": 160
            }
        ],
        // 未定义
        "no-unused-vars": "warn",
        "no-undef": "error",
        // 分号
        "semi": [
            "error",
            "always",
            {
                "omitLastInOneLineBlock": true
            }
        ],
        // 禁止分号前后空格
        "semi-spacing": "error",
        // 禁止不必要的分号
        "no-extra-semi": "error",
        // 注释相关
        "comma-spacing": [
            "warn",
            {
                "before": false,
                "after": true
            }
        ],
        "comma-dangle": [
            "error",
            "always-multiline"
        ],
        "no-multiple-empty-lines": [
            "error",
            {
                "max": 2,
                "maxEOF": 1,
                "maxBOF": 1
            }
        ],
        "line-comment-position": [
            "warn",
            {
                "position": "above"
            }
        ],
        "spaced-comment": [
            "error",
            "always",
            {
                "line": {
                    "markers": ["/"],
                    "exceptions": ["-", "+"]
                },
                "block": {
                    "markers": ["!"],
                    "exceptions": ["*"],
                    "balanced": true
                }
            }
        ]
    },
    // typescript 独有规则
    "overrides": [
        {
            "files": [
                "*.ts"
            ],
            "plugins": [
                "@typescript-eslint"
            ],
            "parser": "@typescript-eslint/parser",
            "extends": [
                "plugin:@typescript-eslint/recommended"
            ],
            "rules": {
                "@typescript-eslint/no-duplicate-imports": "error",
                "@typescript-eslint/ban-ts-comment": "off",
                // 4 空格缩进
                "@typescript-eslint/indent": [
                    "warn",
                    4
                ],
                "@typescript-eslint/explicit-module-boundary-types": "off",
                "@typescript-eslint/no-unused-vars": "off",
                "@typescript-eslint/space-before-function-paren": [
                    "error",
                    {
                        "anonymous": "never",
                        "named": "never",
                        "asyncArrow": "always"
                    }
                ],
                "@typescript-eslint/naming-convention": [
                    "warn",
                    {
                        "selector": "typeParameter",
                        "format": [
                            "PascalCase"
                        ],
                        "prefix": ["T"]
                    },
                    {
                        "selector": "variable",
                        "format": [
                            "camelCase",
                            "UPPER_CASE"
                        ]
                    },
                    {
                        "selector": "interface",
                        "format": [
                            "PascalCase"
                        ],
                        "custom": {
                            "regex": "^I[A-Z]",
                            "match": true
                        }
                    }
                ]
            }
        },
        {
            "files": [
                "assets/scripts/Enum.ts"
            ],
            "rules": {
                "line-comment-position": [
                    "warn",
                    {
                        "position": "beside"
                    }
                ]
            }
        }
    ]
}

目录结构

.
├── README.md
├── assets
│   ├── resources
│   │   ├── language                  // 多语言根目录
│   │   │   ├── en                    // 英文
│   │   │   │   ├── ArialUnicodeMs.fnt
│   │   │   │   ├── ArialUnicodeMs.png
│   │   │   │   └── StringConfig.json   // 字符串配置表导出配置
│   │   │   └── zh                     // 中文
│   │   │       ├── ArialUnicodeMs.fnt
│   │   │       ├── ArialUnicodeMs.png
│   │   │       └── StringConfig.json
│   │   ├── listview
│   │   │   └── prefab
│   │   │       ├── ListViewDemo.prefab
│   │   │       └── ListViewDemoItem.prefab
│   │   └── setting
│   │       └── prefab
│   │           └── Setting.prefab
│   ├── scene
│   │   └── Main.fire
│   ├── scripts
│   │   ├── Enum.ts
│   │   ├── Game.ts
│   │   ├── Macro.ts
│   │   ├── Main.ts
│   │   ├── base
│   │   │   ├── BasePopView.ts        // 弹窗基类
│   │   │   ├── BaseScene.ts          // 场景基类
│   │   │   └── BaseSingeton.ts       // 单例基类
│   │   ├── component                 // 组件
│   │   │   ├── ListView.ts           // 支持复用, 和滚动条的 ListView 组件
│   │   │   ├── LocalizedLabel.ts     // 多语言 label 组件
│   │   │   └── LocalizedRichText.ts  // 多语言 RichText 组件
│   │   ├── manager
│   │   │   ├── AssetManager.ts       // 资源管理器
│   │   │   ├── AudioManager.ts       // 音频管理器(未实现)
│   │   │   ├── PopViewManager.ts     // 弹窗管理器
│   │   │   └── SceneManager.ts       // 场景管理器
│   │   ├── scene
│   │   │   └── Home.ts
│   │   ├── util
│   │   │   ├── GameUtil.ts          // 待实现
│   │   │   ├── LocalizedUtil.ts     // 多语言工具, 负责加载语言目录的资源, 获取对应 Id 文本
│   │   │   ├── NotifyUtil.ts        // 全局事件工具
│   │   │   └── StorageUtil.ts       // 存档工具, 相比 cc.sys.localStorage 多了一层缓存机制
│   │   └── view
│   │       ├── listviewdemo
│   │       │   ├── ListViewDemo.ts
│   │       │   └── ListViewDemoItem.ts
│   │       └── setting
│   │           └── Setting.ts
│   └── shader
│       ├── effects
│       │   └── avatar-mask.effect
│       └── materials
│           └── avatar-mask.mtl
├── creator.d.ts
├── game.d.ts                        // 为扩展的组件提供定义文件, 防止编辑器报错
├── package-lock.json
├── package.json
├── packages
│   └── game-helper                  // 项目插件
│       ├── component                // 插件提供的组件模板
│       │   └── prefab
│       │       ├── ListView.prefab
│       │       ├── LocalizedLabel.prefab
│       │       └── LocalizedRichText.prefab
│       ├── i18n
│       │   ├── en.js
│       │   └── zh.js
│       ├── inspectors               // 组件都是继承 creator 原生组件, 通过扩展 inspector 实现
│       │   ├── listview.js
│       │   ├── localizedlabel.js
│       │   └── localizedrichtext.js
│       ├── main.js                 // 编辑器模式下获取多语言文本方法
│       ├── package.json            // 里面设置了, 编辑器模式下, 返回的语言 zh || en
│       └── panel
│           └── index.js
├── project.json

实现方式

  • 组件脚本
import { ENotifyType } from "../Enum";
import Game from "../Game";

const {ccclass, property, executeInEditMode, menu, inspector} = cc._decorator;

@ccclass
@executeInEditMode()
@menu(`${CC_EDITOR && Editor.T("game-helper.projectcomponent")}/LocalizedLabel`)
@inspector("packages://game-helper/inspectors/localizedlabel.js")
export default class LocalizedLabel extends cc.Label {
    @property()
    private _tid = "";
    @property({
        multiline: true,
        tooltip: "多语言 text id",
    })
    set tid(value: string) {
        this._tid = value;
        this.updateString();
    }
    get tid() {
        return this._tid;
    }
    @property()
    private _bmfontUrl = "";
    @property({
        tooltip: "动态加载 bmfonturl",
    })
    set bmfontUrl(value: string) {
        this._bmfontUrl = value;
        this.updateString();
    }
    get bmfontUrl() {
        return this._bmfontUrl;
    }

    protected onLoad() {
        super.onLoad();
        Game.NotifyUtil.on(ENotifyType.LANGUAGE_CHANGED, this.onLanguageChanged, this);
        this.updateString();
    }

    protected onDestroy() {
        Game.NotifyUtil.off(ENotifyType.LANGUAGE_CHANGED, this.onLanguageChanged, this);
        super.onDestroy();
    }

    /**
     * 收到语言变更通知
     *
     * @private
     * @memberof LocalizedLabel
     */
    private onLanguageChanged() {
        this.updateString();
    }

    /**
     * 更新文本
     *
     * @private
     * @returns {*}
     * @memberof LocalizedLabel
     */
    private updateString(): void {
        if (!this._tid) {
            return;
        }
        if (CC_EDITOR) {
            // 编辑器模式下, 从插件中获取文本
            Editor.Ipc.sendToMain("game-helper:getLangStr", this._tid, (e: Error, str: string) => {
                if (e) {
                    return;
                }
                this.string = "" + str;
            });
        } else {
            // 获取多语言文本
            this.string = "" + Game.LocalizeUtil.getLangStr(this._tid);
            // 如果使用了 bmfont, 切换对应语言的 bmfont
            // _bmfontUrl 为自动生成
            if (this._bmfontUrl) {
                const lang = Game.LocalizeUtil.language;
                this.font = cc.resources.get<cc.BitmapFont>(this._bmfontUrl.replace("${lang}", lang), cc.BitmapFont);
            }
        }
    }
}

  • 插件脚本
"use strict";

const ipcMain = require("electron").ipcMain;
const fs = require("fs");

module.exports = {

    localizeCfgs: null,

    load() {
        ipcMain.on("editor:ready", this.onEditorReady.bind(this));
        //
        this.profiles.load();
        this.loadLangConfig();
    },

    unload() {
        // execute when package unloaded
    },

    onEditorReady() {
        //
    },
    // 加载多语言文本配置, 和项目中使用的是相同的
    loadLangConfig() {
        const configPath = this.profiles.get("path");
        const lang = this.profiles.get("lang");
        const fileName = this.profiles.get("fileName");
        try {
            this.localizeCfgs = JSON.parse(fs.readFileSync(`${Editor.Project.path}/${configPath}/${lang}/${fileName}`, "utf-8"));
            Editor.success("localized config load success:", lang);
        } catch (e) {
            Editor.warn("localized config load fail:", e);
        }
    },

    messages: {
        open() {
            Editor.Panel.open("game-helper");
        },
        // reload lang config
        reload() {
            this.loadLangConfig();
        },
        // 获取多语言配置字符串
        getLangStr(event, param) {
            if (this.localizeCfgs === null) {
                event.reply(new Error("config not load"), null);
            }
            const [tid, ...args] = param.split(",");
            let str = this.localizeCfgs[tid];
            if (str) {
                args.forEach((arg, index) => {
                    str = str.replace("${p" + (index + 1) + "}", arg);
                });
                event.reply(null, str);
            } else {
                event.reply(null, tid);
            }
        },
    },

    profiles: {
        config: null,
        path: "",
        load() {
            this.path = Editor.url("packages://game-helper/package.json");
            this.config = JSON.parse(fs.readFileSync(this.path, "utf8"));
        },
        get(key) {
            return this.config.profiles.local[key];
        },
    },
};

更多的东西就不展开讲了, 感兴趣的 clone 下来看看吧。

21赞

扩展 inspector 的方式可以看我以前的帖子

3赞

:+1:t2:,,,

谢谢分享,看了下,你们也是监听onLanguageChanged来切换多语言的,不过感觉还是有点问题:有些文本是在不同的函数里赋值的,因为它需要那个函数里的计算,局部变量之类的,这时候如果都在onLanguageChanged里处理总感觉不是很好,可能有些变量你还得从局部变量提到"类变量"里去,很蛋疼,

大佬的类名取得优雅简洁啊 赞:+1:t2:

我虽然没完全明白你的意思,但是感觉你说的这种情况应该不是问题

同感,onLanguageChanged只适合静态,有明确字符串id或者key的时候。
如果是动态的根据不同状态下拼接而成的string就不适合使用了。

1赞

对的 我说的就是指: 动态拼接的文本,类似: 等级提升 lv 级, 变量lv需要动态计算,并且lv只在特定的某个函数内部存在,目前我这里是重新跑一遍界面逻辑,因为文本太多了,不适合放在onLanguageChanged里,另外还有文字图片,

tid 支持加参数, 把动态变化的部分,配成参数,应该就能支持的说的情况比如

使用的时候

// 配置
“TID_LABEL_LV”: "Lv: ${p1}"
“TID_LABEL_LV”: "等级: ${p1}"

const level = 10;
label.tid = `TID_LABEL_LV,${level}`;
// "Lv: 10" | “等级: 10”

类似上面这样, onLanguageChanged 里是可以处理的

id支持参数,能和具体的变量绑定么? 变量可能是函数局部变量哦

发一段我们目前的代码哈:
skillMeta和skillLv都是函数内的局部变量,且需要动态赋值的
this.skillNameTxt = (LangID.SKILL_NAME_LEVEL).format(skillMeta.name, skillLv);


这样是不是就行了, 看你这情况是把格式化好的文本赋值过去的, 我赋值的是要格式化的 id, 所以 onLanguageChanged 重新按新的语言格式化,应该就行了

TID 这个词好熟悉,你是猪厂毕业生吗

并不是,可能是行业普遍这么命名吧

你可以看下标题,[ 怎么跑起来 ] 那里,子模块,和依赖的第三方库需要安装

你好,安装过依赖没有报错了,可是我想做的是多个Scene切换,还需要显示loding进度,您这个是单场景用多个预制体切换这样的吗?

是这样没错, 不过这样也是可以做多个 scene 切换, 并显示 loading 进度的, 只不过我现在还没有写到那, 比如有个预制体,切换场景时专门盖到 sceneRoot 上面,用来显示进度

我是做的3D的,会有多个美术场景,预制体是不行的,不过还是谢谢,我关注下,主要在加载的过程中显示进度(初始化场景内容,动态加载人物等等),然后资源加载和释放这块,看您这边后续有更新了我继续来学习一下,谢谢

最开始我们也是使用多个 fire 文件的, 后来因为 fire 文件切换场景太慢(有些 UI 还要重新创建),所以放弃了多个 fire 的方式, 改用 prefab

对的,就是我已经调用了preloadScene预加载了取的那个onProgress都100%了,最后调用那个loadScene的时候还会卡一段时间,所以想找个成熟的场景管理框架学习一下。