参与编写者: innc11
本章外篇教程innc11制作,目的尽可能地简化插件开发过程中对多语言的处理。不得不说对多语言的支持是插件的一大加分项,特别是将插件发布到Nukkit的国际论坛上更是如此,如果有任何的疑问欢迎随时提issue。
所谓HashMap就是保存着一个个键值对的类,一个存储数据的类,形象的描述HashMap就相当于中药铺的药材柜子,柜子上有着数不清的小屉子,每个小屉子里都放着不同的药材,比如胖大海、金银花、荷叶等等(值),每个小屉子上都有一个标签,写着这个屉子里放着什么药材(键),这样就形成了一个关系,一个标签对应一个药材,这就是键值对的关系,一对一的关系,也是HashMap的存储结构,但也有一些限制,比如不能出现两个一模一样的标签,不然HashMap就无法判断到底需要哪一个小屉子的药材;但可以允许两个屉子里的东西一模一样;屉子可以是空的,但标签绝对不能为空。
HashMap能够处理一些数组无法处理的数据,比如一个统计玩家击杀数的插件,当某个玩家干掉10个怪物以后就给其发放奖励,这时候就需要对每个玩家进行记录,把玩家名作为标签,击杀数作为屉子里的东西,每次只需要根据玩家名把对应屉子打开,把里面的数据拿出来+1然后放回去关上屉子即可。这时候便出现了一个关系,每一个玩家名对应一个击杀数。
zhangsan = 5
lisi = 3
wangwu = 8
玩家名就是键,击杀数就是值,大致就是这个结构,可以随时根据玩家名获取到对应击杀数,这是HashMap的工作方式。
插件首先从配置文件加载所有语言到一个HashMap里,在需要时从这个HashMap里读出来,再进行相应变量替换后,显示给玩家。
- 首先创建一个项目,在这里我使用一个当玩家破坏方块时,给玩家发送一个消息,告诉玩家破坏的方块的ID,这里插件名字就叫 Tips,配置好依赖后,首先是
plugin.yml
name: Tips
main: exam.miner.TipsPlugin
version: "1.0"
api: ["1.0.0"]
- 首先我们声明一个语言类,这个类非常简单,仅仅包含一个HashMap、构造方法、获取语言的方法
lang
负责存储所有的语言文本,String getLang()
负责从lang
里面获取对应的文本并做参数替换,在构造方法里我们往lang
里面添加2个语言,其中BROKE_MESSAGE
和PLACE_MESSAGE
是关键字,我们通过传递给getLang()
一个关键字来获取对应的文本,getLang()
会在lang
里面用关键字去进行查找,并返回对应的文本
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
类,并注册监听器,当玩家在破坏或者放置一个方块时,去获取对应的文本,然后发送给玩家
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文件进行读取加载
- 我们使用language.yml文件用来保存语言文本
# language.yml
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
类型,则跳过。
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里
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()
重新加载即可,但在复杂的插件中,只有这些功能时远远不够的,语言文件不能一成不变,有时候需要将文字中的一部分字符替换成各种实际数据,比如商店插件在交易完成时会显示这笔交易花费了多少多少钱,玩家死亡时会显示被谁谁谁干掉了,其中的"钱"和"击杀者"就是实际的数据,需要根据实际情景来决定具体应该是什么。这就涉及到参数化的问题,将文本中一部分文字使用实际数据进行替换。
- 参数化必然会涉及到占位符这个概念,拿一个例子来说
PLACE_MESSAGE: "你放置了ID为 ${BLOCK_ID} 的方块"
其中的**${BLOCK_ID}**就是占位符,他只是给实际的数据占个位置而已,并不会被显示出来。当然风格可以自己定义,在这个例子中,我们使用${占位符名字}
这种风格。
- 我们修改我们的
MyLang
类的getLang()
方法,使其可以动态替换占位符,具体的调用方式为MyLang.getLang("PLACE_MESSAGE", "{BLOCK_ID}", String.valueOf(block.getId()));
- 多个参数的调用方式
MyLang.getLang("PLACE_MESSAGE",
"{BLOCK_ID}", String.valueOf(block.getId(),
"{PLAYER_NAME}", player.getName(),
));
- 无参数的调用方式
MyLang.getLang("PLACE_MESSAGE"));
- 后面的占位符和实际数据总是成双成对的出现,这可以大幅加快开发效率,当然这需要
MyLang.getLang()
具有对应的支持,具体看下面的示例代码。
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;
}
}
- 这样我们就可以随意组合占位符和参数了
- 我们修改
language.yml
# language.yml
PLACE_MESSAGE: "你放置了一个ID ${ID} 的方块"
BROKE_MESSAGE: "${PLAYER} 破坏了一个ID ${ID} 的方块"
- 再修改主类以添加支持
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);
}
}
- 到这里还没有结束,在一个大型插件项目中往往有着数量极多语言文本,经常上百行是很常见的问题,如果每次都手动去输入关键字,难免会出现差错,而且效率也会大打折扣,这个时候我们就需要引入一个新的内容,枚举,通过把关键字定义成枚举,可以由IDE快速补齐,也会大大减少因拼写错误的BUG。
- 我们需要在MyLang中额外定义一个enum
public enum L // L : Language
{
PLACE_MESSAGE,
BROKE_MESSAGE,
public String getDefaultLangText()
{
return name();
}
}
- 在L中有两个成员常量,和
getDefaultLangText()
方法,字如其意,用于获取默认的文本,这里直接返回字段名。也就是"PLACE_MESSAGE
"、"BROKE_MESSAGE
" - 由于enum的引入,
reload()
也要发生变化,lang
的泛型也发生了变化,getLang()
的参数也发生了变化,看代码!
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();
}
}
}
- 主类开始使用enum作为关键字
// 可以使用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);
}
}
- enum的优点特别明显,能避免拼写错误和支持IDE提示,当然enum也有缺点,必须要保持yml的关键字和enum成员字段名一致才行。
本篇只是一个最简单的解决方案,当然大家还可以按自己的需求添加更多的功能,如果感兴趣,可以参考我的另一个大量使用了此多语言方案的**插件项目**,添加了对config中多行文字写法的支持和彩色代码的支持。如果有任何问题欢迎提交issue,感谢阅读!