NukkitLearn章外篇之二-插件中的多语言解决方案
参与编写者: innc11
知识点: 配置文件、HashMap、枚举、文本替换、可变参数、正则表达式
本章外篇教程innc11制作,目的尽可能地简化插件开发过程中对多语言的处理。不得不说对多语言的支持是插件的一大加分项,特别是将插件发布到Nukkit的国际论坛上更是如此,如果有任何的疑问欢迎随时提issue。
0.HashMap
所谓HashMap就是保存着一个个键值对的类,一个存储数据的类,形象的描述HashMap就相当于中药铺的药材柜子,柜子上有着数不清的小屉子,每个小屉子里都放着不同的药材,比如胖大海、金银花、荷叶等等(值),每个小屉子上都有一个标签,写着这个屉子里放着什么药材(键),这样就形成了一个关系,一个标签对应一个药材,这就是键值对的关系,一对一的关系,也是HashMap的存储结构,但也有一些限制,比如不能出现两个一模一样的标签,不然HashMap就无法判断到底需要哪一个小屉子的药材;但可以允许两个屉子里的东西一模一样;屉子可以是空的,但标签绝对不能为空。
HashMap能够处理一些数组无法处理的数据,比如一个统计玩家击杀数的插件,当某个玩家干掉10个怪物以后就给其发放奖励,这时候就需要对每个玩家进行记录,把玩家名作为标签,击杀数作为屉子里的东西,每次只需要根据玩家名把对应屉子打开,把里面的数据拿出来+1然后放回去关上屉子即可。这时候便出现了一个关系,每一个玩家名对应一个击杀数。
1 2 3 zhangsan = 5 lisi = 3 wangwu = 8
玩家名就是键,击杀数就是值,大致就是这个结构,可以随时根据玩家名获取到对应击杀数,这是HashMap的工作方式。
1. 原理
插件首先从配置文件加载所有语言到一个HashMap里,在需要时从这个HashMap里读出来,再进行相应变量替换后,显示给玩家。
2. 创建项目
首先创建一个项目,在这里我使用一个当玩家破坏方块时,给玩家发送一个消息,告诉玩家破坏的方块的ID,这里插件名字就叫 Tips,配置好依赖后,首先是plugin.yml
1 2 3 4 name: Tips main: exam.miner.TipsPlugin version: "1.0" api: ["1.0.0" ]
3. 使用语言类
首先我们声明一个语言类,这个类非常简单,仅仅包含一个HashMap、构造方法、获取语言的方法
getLang()```负责从```lang```里面获取对应的文本并做参数替换,在构造方法里我们往```lang```里面添加2个语言,其中```BROKE_MESSAGE```和```PLACE_MESSAGE```是关键字,我们通过传递给```getLang()```一个关键字来获取对应的文本,```getLang()```会在```lang```里面用关键字去进行查找,并返回对应的文本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 ```java public class MyLang { HashMap<String, String> lang = new HashMap<String, String>(); public MyLang() { lang.put("BROKE_MESSAGE", "你破坏了一个方块"); lang.put("PLACE_MESSAGE", "你放置了一个方块"); } public String getLang(String key) { return lang.get(key); } }
接下来是主类:
在主类中使用刚才的MyLang
类,并注册监听器,当玩家在破坏或者放置一个方块时,去获取对应的文本,然后发送给玩家
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 public class TipsPlugin extends PluginBase implements Listener { MyLang lang; @Override public void onEnable () { lang = new MyLang (); getServer().getPluginManager().registerEvents(this , this ); } @EventHandler public void onPlayerBrokeBlock (BlockBreakEvent e) { String message = lang.getLang("BROKE_MESSAGE" ); e.getPlayer().sendMessage(message); } @EventHandler public void onPlayerPlaceBlock (BlockPlaceEvent e) { String message = lang.getLang("PLACE_MESSAGE" ); e.getPlayer().sendMessage(message); } }
这就是最简单的方式,但实际开发中语言往往是从配置文件进行加载的,而不是写死在代码里,接下来就是如何从yml文件进行读取加载
3. 从yaml配置文件加载语言
我们使用language.yml文件用来保存语言文本
1 2 3 PLACE_MESSAGE: "你放置了一个方块" BROKE_MESSAGE: "你破坏了一个方块"
接着我们需要修改我们的语言文件,使其从配置文件进行加载,首先需要在MyLang
类里额外添加一个Config config
变量,和一个void reload()
方法,我们手动调用reload()
方法来从配置文件加载语言文本。
构造方法我们需要添加一个参数,用来告诉MyLang
类应该读取哪一个yml文件,不建议在构造方法中立即调用reload()
,因为当对象构造的时候language.yml
可能根本就不存在。非常建议在插件主类中保存默认配置文件后手动调用reload()
在新添加的void reload()
方法中,首先是命令config
(重新)加载一下,然后把lang
中已经存在的数据全部删除掉,接着就是使用getKeys(false)
来获取config
中所有的key,就是上面yml中的PLACE_MESSAGE
和BROKE_MESSAGE
,这个方法会以Set<String>
的形式返回,我们使用foreach进行遍历即可,需要说明的是参数中的false
指boolean child
,我们只需要根节点上的key不需要子节点上的key,传false
即可
在foreach中我们定义一个变量value来放置获取到的"key对应的值"也就是你放置了一个方块
和你破坏了一个方块
接下来我们需要进行一个判断,如果这个值是String
类型的,我们就把它添加到lang
里面,如果不是,比如int
,bool
,或者list
类型,则跳过。
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 public class MyLang { Config config; HashMap<String, String> lang = new HashMap <String, String>(); public MyLang (String languageFileName) { config = new Config (new File (getDataFolder(), languageFileName), Config.YAML); } public void reload () { config.reload(); lang.clear(); for (String key : config.getKeys(false )) { Object value = config.get(key.name()); if (value instanceof String) { lang.put(key, (String) value); } } } public String getLang (String key) { return lang.get(key); } }
主类需要在new MyLang()
时传递文件名。当然也要把language.yml
以前打包进插件里。在onEnable()里要调用saveResource("language.yml", false)
把language.yml
写入到插件DataFolder 里
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 public class TipsPlugin extends PluginBase implements Listener { MyLang lang; @Override public void onEnable () { saveResource("language.yml" , false ); lang = new MyLang ("language.yml" ); lang.reload(); getServer().getPluginManager().registerEvents(this , this ); } @EventHandler public void onPlayerBrokeBlock (BlockBreakEvent e) { String message = lang.getLang("BROKE_MESSAGE" ); e.getPlayer().sendMessage(message); } @EventHandler public void onPlayerPlaceBlock (BlockPlaceEvent e) { String message = lang.getLang("PLACE_MESSAGE" ); e.getPlayer().sendMessage(message); } }
在实际使用中,我们只需要修改language.yml
中文字,然后使用指令调用MyLang.reload()
重新加载即可,但在复杂的插件中,只有这些功能时远远不够的,语言文件不能一成不变,有时候需要将文字中的一部分字符替换成各种实际数据,比如商店插件在交易完成时会显示这笔交易花费了多少多少钱,玩家死亡时会显示被谁谁谁干掉了,其中的"钱"和"击杀者"就是实际的数据,需要根据实际情景来决定具体应该是什么。这就涉及到参数化的问题,将文本中一部分文字使用实际数据进行替换。
4. 参数化
参数化必然会涉及到占位符 这个概念,拿一个例子来说
1 PLACE_MESSAGE: "你放置了ID为 ${BLOCK_ID} 的方块"
其中的**${BLOCK_ID}**就是占位符,他只是给实际的数据占个位置而已,并不会被显示出来。当然风格可以自己定义,在这个例子中,我们使用${占位符名字}
这种风格。
我们修改我们的MyLang
类的getLang()
方法,使其可以动态替换占位符,具体的调用方式为MyLang.getLang("PLACE_MESSAGE", "{BLOCK_ID}", String.valueOf(block.getId()));
多个参数的调用方式
1 2 3 4 MyLang.getLang("PLACE_MESSAGE", "{BLOCK_ID}", String.valueOf(block.getId(), "{PLAYER_NAME}", player.getName(), ));
无参数的调用方式
1 MyLang.getLang("PLACE_MESSAGE"));
后面的占位符和实际数据总是成双成对的出现,这可以大幅加快开发效率,当然这需要MyLang.getLang()
具有对应的支持,具体看下面的示例代码。
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 public class MyLang { Config config; HashMap<String, String> lang = new HashMap <String, String>(); public MyLang (String languageFileName) { config = new Config (new File (getDataFolder(), languageFileName), Config.YAML); } public void reload () { config.reload(); lang.clear(); for (String key : config.getKeys(false )) { Object value = config.get(key.name()); if (value instanceof String) { lang.put(key, (String) value); } } } public String getLang (String key, String... argsPair) { String rawStr = lang.get(key); int argCount = argsPair.length / 2 ; for (int i=0 ;i<argCount;i++) { String reg = argsPair[i*2 ]; String replacement = argsPair[i*2 +1 ]; if (reg.startsWith("{" ) && reg.endsWith("}" )) { reg = reg.replaceAll("\\{" , "\\\\{" ); reg = reg.replaceAll("\\}" , "\\\\}" ); rawStr = rawStr.replaceAll("\\$" +reg, replacement); } } return rawStr; } }
这样我们就可以随意组合占位符和参数了
我们修改language.yml
1 2 3 PLACE_MESSAGE: "你放置了一个ID ${ID} 的方块" BROKE_MESSAGE: "${PLAYER} 破坏了一个ID ${ID} 的方块"
再修改主类以添加支持
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 public class TipsPlugin extends PluginBase implements Listener { MyLang lang; @Override public void onEnable () { saveResource("language.yml" , false ); lang = new MyLang ("language.yml" ); lang.reload(); getServer().getPluginManager().registerEvents(this , this ); } @EventHandler public void onPlayerBrokeBlock (BlockBreakEvent e) { String playerName = e.getPlayer().getName(); String id = String.valueOf(e.getBlock().getId()); String message = lang.getLang("BROKE_MESSAGE" , "{PLAYER}" , playerName, "{ID}" , id); e.getPlayer().sendMessage(message); } @EventHandler public void onPlayerPlaceBlock (BlockPlaceEvent e) { String id = String.valueOf(e.getBlock().getId()); String message = lang.getLang("PLACE_MESSAGE" , "{ID}" , id); e.getPlayer().sendMessage(message); } }
到这里还没有结束,在一个大型插件项目中往往有着数量极多语言文本,经常上百行是很常见的问题,如果每次都手动去输入关键字,难免会出现差错,而且效率也会大打折扣,这个时候我们就需要引入一个新的内容,枚举 ,通过把关键字定义成枚举,可以由IDE快速补齐,也会大大减少因拼写错误的BUG。
5. 枚举常量
我们需要在MyLang中额外定义一个enum
1 2 3 4 5 6 7 8 9 10 public enum L { PLACE_MESSAGE, BROKE_MESSAGE, public String getDefaultLangText () { return name(); } }
在L中有两个成员常量,和getDefaultLangText()
方法,字如其意,用于获取默认的文本,这里直接返回字段名。也就是"PLACE_MESSAGE
"、“BROKE_MESSAGE
”
由于enum的引入,reload()
也要发生变化,lang
的泛型也发生了变化,getLang()
的参数也发生了变化,看代码!
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 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 public class MyLang { Config config; HashMap<L, String> lang = new HashMap <L, String>(); public MyLang (String languageFileName) { config = new Config (new File (getDataFolder(), languageFileName), Config.YAML); } public void reload () { config.reload(); lang.clear(); boolean supplement = false ; for (L key : L.values()) { Object value = config.get(key.name()); if (v==null ) { config.set(key.name(), key.getDefaultLangText()); supplement = true ; lang.put(key, key.getDefaultLangText()); } if (value instanceof String) { lang.put(key, (String) value); } } if (supplement) { config.save(); } } public String getLang (L key, String... argsPair) { String rawStr = lang.get(key); int argCount = argsPair.length / 2 ; for (int i=0 ;i<argCount;i++) { String reg = argsPair[i*2 ]; String replacement = argsPair[i*2 +1 ]; if (reg.startsWith("{" ) && reg.endsWith("}" )) { reg = reg.replaceAll("\\{" , "\\\\{" ); reg = reg.replaceAll("\\}" , "\\\\}" ); rawStr = rawStr.replaceAll("\\$" +reg, replacement); } } return rawStr; } public enum L { PLACE_MESSAGE, BROKE_MESSAGE, public String getDefaultLangText () { return name(); } } }
主类开始使用enum作为关键字
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 public class TipsPlugin extends PluginBase implements Listener { MyLang lang; @Override public void onEnable () { saveResource("language.yml" , false ); lang = new MyLang ("language.yml" ); lang.reload(); getServer().getPluginManager().registerEvents(this , this ); } @EventHandler public void onPlayerBrokeBlock (BlockBreakEvent e) { String playerName = e.getPlayer().getName(); String id = String.valueOf(e.getBlock().getId()); String message = lang.getLang(MyLang.L.BROKE_MESSAGE, "{PLAYER}" , playerName, "{ID}" , id); e.getPlayer().sendMessage(message); } @EventHandler public void onPlayerPlaceBlock (BlockPlaceEvent e) { String id = String.valueOf(e.getBlock().getId()); String message = lang.getLang(MyLang.L.PLACE_MESSAGE, "{ID}" , id); e.getPlayer().sendMessage(message); } }
enum的优点特别明显,能避免拼写错误和支持IDE提示,当然enum也有缺点,必须要保持yml的关键字和enum成员字段名一致才行。
6. 总结
本篇只是一个最简单的解决方案,当然大家还可以按自己的需求添加更多的功能,如果感兴趣,可以参考我的另一个大量使用了此多语言方案的**插件项目 **,添加了对config中多行文字写法的支持和彩色代码的支持。如果有任何问题欢迎提交issue,感谢阅读!