1.多语言简述
多语言,顾名思义就是我们开发的游戏需要支持多种语言,以便给各种其他国家的玩家游玩。
但开发过程中使用到的与语言相关的内容还挺多的:文本、图片、特效、动画等甚至使用的模型中都可能带有语言相关的文本信息。
2.多语言方案(文本)
1.传统方案
使用键值对存储需要多语言化的文本。例:
| Key值 | 中文 | 英文 |
|---|---|---|
| KeyA | A键 | KeyCode A |
| KeyB | B键 | KeyCode B |
然后在代码中需要使用文本时就要使用对应的Key值进行获取文本,策划的文本填写在表内,程序的文本可以定义一个常量文件进行存储。
优点:
- 所有文本集中在一张表内,策划使用的文本与程序使用的文本拆分。
- 没有一词多义问题
缺点:
- 策划配置时需要在表内填写对应的key,而不是中文
- 需要较为严格的制定规范,否则如果直接在代码或配置内使用文本会导致该部分内容多语言失效
2.奇怪的新方案
因为我们的项目已经上线运营很久了,在项目整个开发过程中没有考虑过多语言的问题。所以现在想做多语言的话就是把表复制一份,然后把里面的文本改成翻译后的结果。代码硬编码的文本也要全改。改了一遍下来发现太痛了,还是做一套通用的多语言方案吧。
1.中文做Key
首先我们的文本已经写在逻辑和配表内了,如果使用传统方案再提一遍感觉也很痛,而且这样会有大量的重复文本问题。但是多语言本质上还是一个字典集查找的过程,还是需要一些键值对来建立文本翻译索引。那么key既然可以是英文,那为什么不可以是中文呢?所以我们的目标就是把原本的翻译表改成:
| Key值 | 英文 |
|---|---|
| 中文 | English |
| 中文 | English |
2.如何生成这种翻译表
首先我们需要定位中文文本都会存在于哪些地方:代码、预制体、自定义的资源文件
接下来我们要想办法把这些地方的中文都提取出来:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
//读取文件 using (FileStream fs = new FileStream(tgtPath, FileMode.Open, FileAccess.Read)) { using (StreamReader sr = new StreamReader(fs)) { int index = 0; while (!sr.EndOfStream) { string result = sr.ReadLine().Trim(); //预制体中使用了带换行的空格时,保存到预制体内可能存在换行 while ((result.StartsWith("m_Text") || result.StartsWith("localizationKey"))&&result.Contains("\"") &&!result.EndsWith("\"")&&!sr.EndOfStream) result += sr.ReadLine().Trim(); index++; //检测这行文本是否为注释文本 if (!CheckGetTxtByExt(ext, result)) continue; //非贪婪匹配字符,匹配尽量短的""内包裹的内容 //这里有个问题,如果预制体内输入的时候输入了"。则会匹配到输入的引号,如果改成贪婪匹配则没有这个问题,但代码中文本匹配需要非贪婪匹配,考虑是否可以按目标文件类型决定正则类型。 Regex regex = new Regex("\"[^\"]*\""); MatchCollection matchs = regex.Matches(result); foreach (Match match in matchs) { foreach (Group group in match.Groups) { string value = group.Value; //匹配中文 Regex rx = new Regex("[\u4E00-\u9FA5]+"); value = ChangeTxtByExt(value, ext); if (rx.IsMatch(value)) { //纪录文本来源,从哪个预制体或者哪个代码里拿到的 string linePath = fileName + ext + "_" + index; if (localizationDic.ContainsKey(value)) localizationDic[value] += ";" + linePath; else localizationDic.Add(value, linePath); } } } } } } |
经过上面的代码处理,就可以最终获得一张项目内使用到的文本信息的总表。不过,有了翻译表我们的多语言工作就结束了吗?远远没有!
3.如何使用翻译表以及文本何时更新?
为了使用翻译表,我们可以提供一个多语言管理器,如LanguageManager。当代码逻辑需要设置文本时,通过LanguageManager查找应该使用的文本(当然LanguageManager需要纪录当前用的是什么语言啦)。
但是那些一开始就写在预制体上的静态文本怎么办?他们应该什么时候更新自己的值?当语言切换的时候文本该怎么知道自己需要更新值?
为了让静态文本初始化与语言切换时可以更新,我为对应的text组件制作了一个多语言组件,这个组件的主要功能就是初始化时更新自己的值,以及监听语言变化事件。为了在更新时组件自己就可以完成更新的操作,所以组件上会缓存下来当前的key(中文)。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
[Tooltip("本地化键")] public string localizationKey; protected virtual void Awake() { //有的时候有的组件不需要监听多语言事件,比如某组件是玩家自定义的名字 if (MultiLanguage) { OpenLanguageListener(); //更新组件内容,可以是调用LanguageManager获取文本 UpdateContent(); } } //可能有一开始不需要监听的组件,后来因为一些逻辑又需要监听了。 //但是防止监听事件注册多次,加了一层保护 protected void OpenLanguageListener() { if (!listenerOpen) { listenerOpen = true; LanguageManager.LocalizationChangeEvent += UpdateContent; } } |
4.字符串拼接问题怎么办?
我们游戏主逻辑使用lua,lua的字符串拼接有两种方式:一种是string.format,内部使用%s%d等占位符进行占位填坑。另一种是使用..连接两段字符串文本。
但是在进行翻译的时候,国内外的语序很大概率有所不同,比如日期的表达上中文是年月日,国外可能是日月年。在拼接时中文:%s年%s月%s日。如果直接替换为国外文本:%sday%smonth%syear时,就会发现语序不对拉!
所以我们需要使用C#风格的字符串拼接也就是用{0}{1}{2}这种挖坑的形式实现字符串拼接。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
--我们在lua内可以使用表结构进行存储,然后在C#端对表结构进行解析 local data = setmetatable({}, metatable) table.insert(data, "format") for i, v in ipairs({...})do if type(v) == "table" then data = data + v else table.insert(data, v) end end table.insert(data, "end") return data |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
foreach (var item in cmdArray) { if (item.ToString() != "end") { cmdStack.Push(item is string ? item.ToString() : item); } else { args.Clear(); while (cmdStack.Count >= 0) { var cmd = cmdStack.Pop(); if (cmd.ToString() == "format") { if (cmdStack.Count > 0) { args.Reverse(); args.Insert(0, "format"); args.Add("end"); //递归调用自己 cmdStack.Push(GetFormatResult(args, translate)); break; } else { args.Reverse(); string strBase = args[0].ToString(); args.RemoveAt(0); if (!translate) { return string.Format(strBase, args.ToArray()); } var strTranslate = Get(strBase); //处理format的参数翻译 for (int i = 0; i < args.Count; i++) { if (args[i] is string) args[i] = Get(args[i].ToString()); } return string.Format(strTranslate, args.ToArray()); } } else args.Add(cmd); } } } |
5.一词多义怎么办?
既然使用了中文做key,那么中文博大精深,相同词语含有不同语义就在所难免,所以我们还需要额外处理一词多义的问题。
但是我们一词多义的需求不多,暂时使用一张额外的双key表来缓存一词多义的问题。
如果是配表中使用一词多义,还可以在表中填写对应的表名,行列来定位要对哪个文本进行一词多义。然后对lua表建立元表来保证查询方式不变的前提下可以查找一词多义文本
| 主Key | 副Key | 英文 | sheet | row | col |
|---|---|---|---|---|---|
| 中文Key | Key2 | English |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
function deepGet(tb, list, index) index = index or 1 local key = list[index] if tb and tb[key] then if #list > index then return deepGet(tb[key], list, index + 1) else return tb, tb[key] end else error("错误,目标表没有指定行"..key) end end function replaceData() CfgReplace = { [1]= { id = 1, sheet = "CfgItemCollectWays", row = {1,2}, col = "Title", subKey = "A", }, [2]= { id = 1, sheet = "CfgItemCollectWays", row = {1,2}, col = "Desc", subKey = "A", } } for _, data in pairs(CfgReplace) do --找到对应的行 local cfgParent, cfg = deepGet(NCONFIG[data.sheet], data.row) if not cfg then error("查找表内容失败,表:"..data.sheet) return end --构建替换表 local replaceCfg = rawget(cfg, "_replaceCfg") --没构建过就新建立一个表 if not replaceCfg then replaceCfg = {} local replaceTB = { _oldCfg = cfg, _replaceCfg = replaceCfg, } local metaTable = { __index = function(t, key) local subKey = t._replaceCfg[key] local mainKey = t._oldCfg[key] if subKey and mainKey then print("去一词多义表中找主key为:"..mainKey..",副key为:"..subKey) elseif mainKey then --print("覆盖表无值,取配表值") return mainKey end end } setmetatable(replaceTB, metaTable) cfgParent[data.row[#data.row]] = replaceTB end --把要覆盖的字段写入表内 replaceCfg[data.col] = data.subKey end end |
6.额外的格式问题
此外,还有一些奇怪的问题,比如Unity在存储预制体内的回车时,使用的是\n(Unicode:\u000A)。而我们如果使用的是Windows平台,Excel内的文本回车在导出成资源文件时会变成\r\n(Unicode:“\u000D\u000A”
)。所以我们在写入文本与格式转换匹配时还需要额外小心这种格式问题。
3.多语言方案(图片)
图片的多语言处理与文本大体类似,只不过我们不需要额外的翻译表了,只需要确定好每种语言的后缀,如:
原本图片为:AAA.png
英文图片为:AAA_en.png
则我们就可以通过资源的后缀进行加载不同的图片了。此外,如果图片需要打图集的话要区分好图片的划分策略。比如是否打一个基础图集(包含非多语言项,与默认语言项),此外再打多语言图集(只包含对应语言图片)。还是将一个语言用到的所有图片都打进一个图集(每种语言对应一张图集,不同语言图集内包含了同一份基础图集)。
4.多语言方案(动画)
动画可以通过动态切换overrideController内的AnimatorClip来实现。