NukkitLearn章外篇之二-插件中的多语言解决方案

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. 创建项目

  1. 首先创建一个项目,在这里我使用一个当玩家破坏方块时,给玩家发送一个消息,告诉玩家破坏的方块的ID,这里插件名字就叫 Tips,配置好依赖后,首先是plugin.yml
1
2
3
4
name: Tips
main: exam.miner.TipsPlugin
version: "1.0"
api: ["1.0.0"]

3. 使用语言类

  1. 首先我们声明一个语言类,这个类非常简单,仅仅包含一个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配置文件加载语言

  1. 我们使用language.yml文件用来保存语言文本
1
2
3
# language.yml
PLACE_MESSAGE: "你放置了一个方块"
BROKE_MESSAGE: "你破坏了一个方块"
  1. 接着我们需要修改我们的语言文件,使其从配置文件进行加载,首先需要在MyLang类里额外添加一个Config config变量,和一个void reload()方法,我们手动调用reload()方法来从配置文件加载语言文本。

  2. 构造方法我们需要添加一个参数,用来告诉MyLang类应该读取哪一个yml文件,不建议在构造方法中立即调用reload(),因为当对象构造的时候language.yml可能根本就不存在。非常建议在插件主类中保存默认配置文件后手动调用reload()

  3. 在新添加的void reload()方法中,首先是命令config(重新)加载一下,然后把lang中已经存在的数据全部删除掉,接着就是使用getKeys(false)来获取config中所有的key,就是上面yml中的PLACE_MESSAGEBROKE_MESSAGE,这个方法会以Set<String>的形式返回,我们使用foreach进行遍历即可,需要说明的是参数中的falseboolean child,我们只需要根节点上的key不需要子节点上的key,传false即可

  4. 在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);
}
}
  1. 主类需要在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);
}

}
  1. 在实际使用中,我们只需要修改language.yml中文字,然后使用指令调用MyLang.reload()重新加载即可,但在复杂的插件中,只有这些功能时远远不够的,语言文件不能一成不变,有时候需要将文字中的一部分字符替换成各种实际数据,比如商店插件在交易完成时会显示这笔交易花费了多少多少钱,玩家死亡时会显示被谁谁谁干掉了,其中的"钱"和"击杀者"就是实际的数据,需要根据实际情景来决定具体应该是什么。这就涉及到参数化的问题,将文本中一部分文字使用实际数据进行替换。

4. 参数化

  1. 参数化必然会涉及到占位符这个概念,拿一个例子来说
1
PLACE_MESSAGE: "你放置了ID为 ${BLOCK_ID} 的方块"

其中的**${BLOCK_ID}**就是占位符,他只是给实际的数据占个位置而已,并不会被显示出来。当然风格可以自己定义,在这个例子中,我们使用${占位符名字}这种风格。

  1. 我们修改我们的MyLang类的getLang()方法,使其可以动态替换占位符,具体的调用方式为MyLang.getLang("PLACE_MESSAGE", "{BLOCK_ID}", String.valueOf(block.getId()));
  2. 多个参数的调用方式
1
2
3
4
MyLang.getLang("PLACE_MESSAGE", 
"{BLOCK_ID}", String.valueOf(block.getId(),
"{PLAYER_NAME}", player.getName(),
));
  1. 无参数的调用方式
1
MyLang.getLang("PLACE_MESSAGE"));
  1. 后面的占位符和实际数据总是成双成对的出现,这可以大幅加快开发效率,当然这需要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); // 执行替换
// 最终reg 等于 \$\{占位符名字\} 并在rawStr中执行替换
}

}

return rawStr;
}
}
  1. 这样我们就可以随意组合占位符和参数了
  2. 我们修改language.yml
1
2
3
# language.yml
PLACE_MESSAGE: "你放置了一个ID ${ID} 的方块"
BROKE_MESSAGE: "${PLAYER} 破坏了一个ID ${ID} 的方块"
  1. 再修改主类以添加支持
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(); // 这里把int 转换为 String 类型
String id = String.valueOf(e.getBlock().getId());

String message = lang.getLang("BROKE_MESSAGE", "{PLAYER}", playerName, "{ID}", id);

e.getPlayer().sendMessage(message);// xxx破坏了一个ID 137 的方块
}

@EventHandler
public void onPlayerPlaceBlock(BlockPlaceEvent e)
{
String id = String.valueOf(e.getBlock().getId());
String message = lang.getLang("PLACE_MESSAGE", "{ID}", id);

// 你放置了一个ID 137 的方块
e.getPlayer().sendMessage(message);
}

}
  1. 到这里还没有结束,在一个大型插件项目中往往有着数量极多语言文本,经常上百行是很常见的问题,如果每次都手动去输入关键字,难免会出现差错,而且效率也会大打折扣,这个时候我们就需要引入一个新的内容,枚举,通过把关键字定义成枚举,可以由IDE快速补齐,也会大大减少因拼写错误的BUG。

5. 枚举常量

  1. 我们需要在MyLang中额外定义一个enum
1
2
3
4
5
6
7
8
9
10
public enum L // L : Language
{
PLACE_MESSAGE,
BROKE_MESSAGE,

public String getDefaultLangText()
{
return name();
}
}
  1. 在L中有两个成员常量,和getDefaultLangText()方法,字如其意,用于获取默认的文本,这里直接返回字段名。也就是"PLACE_MESSAGE"、“BROKE_MESSAGE
  2. 由于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> 将MyLang.L作为键(key)以提高效率
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();

// 一个标志,如果有缺少的关键字,会被补全,然后保存config,以便调试
boolean supplement = false;

// 现在是以Lang.values()进行遍历,而不是config.getKeys(),注意
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);
}
}

// 如果有补齐,则需要保存这个config,以便用户可以在config内查看到以定位问题
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); // 执行替换
// 最终reg 等于 \$\{占位符名字\} 并在rawStr中执行替换
}

}

return rawStr;
}

public enum L // L : Language
{
PLACE_MESSAGE,
BROKE_MESSAGE,

// 当config中找不到时的默认文本,这里直接返回字段名
public String getDefaultLangText()
{
return name();
}

}

}
  1. 主类开始使用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
// 可以使用static import 来简化代码
//static import MyLang.L.*;

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(); // 这里把int 转换为 String 类型
String id = String.valueOf(e.getBlock().getId());

// 这里使用常量作为关键字
String message = lang.getLang(MyLang.L.BROKE_MESSAGE, "{PLAYER}", playerName, "{ID}", id);

e.getPlayer().sendMessage(message);// xxx破坏了一个ID 137 的方块
}

@EventHandler
public void onPlayerPlaceBlock(BlockPlaceEvent e)
{
String id = String.valueOf(e.getBlock().getId());

// 这里使用常量作为关键字
String message = lang.getLang(MyLang.L.PLACE_MESSAGE, "{ID}", id);

// 你放置了一个ID 137 的方块
e.getPlayer().sendMessage(message);
}

}
  1. enum的优点特别明显,能避免拼写错误和支持IDE提示,当然enum也有缺点,必须要保持yml的关键字和enum成员字段名一致才行。

6. 总结

本篇只是一个最简单的解决方案,当然大家还可以按自己的需求添加更多的功能,如果感兴趣,可以参考我的另一个大量使用了此多语言方案的**插件项目**,添加了对config中多行文字写法的支持和彩色代码的支持。如果有任何问题欢迎提交issue,感谢阅读!