使用ProtocolLib实现Spigot插件的国际化翻译(I18N)—— 聊天内容(第4章)

前情提要

上一篇教程,我们从Inventory的国际化翻译开始,围绕着扯出了一大堆新概念和内容,容量有点大,甚至有点烧脑

幸运的是,今天我们要聊的话题比较轻松,“聊天内容”

虽然小坑还是有的,但我可以保证,整体过程还是会相当轻松愉快的

不多说了,让我们开始吧

哪种聊天类型?

首先我们需要明确一点,我们要进行国际化翻译的,到底是哪种聊天类型?

是玩家发送给玩家的呢,还是服务端(插件)发送给玩家的呢?

很显然,由于我们针对的是插件的国际化,所以应当是后者

以下统称这类消息为System Chat,请务必记住这一点

接下来,我们就去wiki.vg上去找相应的数据包了

Let’s go packet-hunting!

哪个数据包?

当然,作为一篇教程,我就帮大家省去挨个看数据包名字并挨个分析的过程了,直接给出答案:

System Chat Message

诚然,在1.19.2版本中,我们有System Chat Message这个数据包,因此System Chat应该与这个数据包有关

但是我怎么可能这么好心呢?肯定是有坑的!

问题就在于,我前面特意强调了“在1.19.2版本中”,那么在1.19.2版本之前呢?

是的,如果你用wiki.vg查看各个版本的数据包定义,你会发现System Chat Message这个数据包是从1.19开始才有的

而在1.19之前的版本,System Chat是通过另一个叫Chat Message的数据包传递的

虽然本教程是基于1.19.2版本的,但是鉴于这个System Chat Message实在是太新,所以我也会说说Chat Message(即1.19之前的版本)这个数据包

注意:截至撰稿日期(2023/3/21),你必须使用dev版本的ProtocolLib才能监听System Chat Message数据包,最新的稳定版(4.8.0)是无法监听的

System Chat Message的监听及处理(适用于1.19及以上的版本)

由于这个数据包实在是太简单了,我们先挑软柿子捏

数据名称 数据类型 说明
JSON Data Chat 消息内容
Overlay Boolean 是ActionBar上的文字(true),还是聊天内容(false)

我们可以看到,这个数据包一箭双雕,既包含了聊天内容,又包含了ActionBar的文字

什么是ActionBar?这个就是ActionBar:

这下大家都知道了吧

虽然ActionBar不是我们的重点,不过多多了解一下总是没错的

接下来,我们开始走流程

第一步,看Packet ID –> 是十六进制的0x62,即十进制的98

第二步,到PacketType.Play.Server中找到currentId为98的数据包 –> 即SYSTEM_CHAT

知道了ProtocolLib中对应的数据包,初级问题也就迎刃而解了

read

老规矩,先上read的代码

注意:前方连续大坑预警

ProtocolLibrary.getProtocolManager().addPacketListener(
        new PacketAdapter(this, PacketType.Play.Server.SYSTEM_CHAT) {
            @Override
            public void onPacketSending(PacketEvent event) {
                Player player = event.getPlayer();
                player.sendMessage("JSON Data: " + event.getPacket().getChatComponents().read(0));
                player.sendMessage("Overlay: " + event.getPacket().getBooleans().read(0));
            }
        }
);

我敢肯定,如果仅凭之前的经验写,很多人肯定会写出上面这样的代码

然后一跑就报错

其实,这段代码有2处bug,一个是比较好发现的,另一个则需要留个心眼

好发现的bug

先说说那个比较好发现的:

很多人看到JSON Data是Chat类型的,自然而然就联想到了上一篇教程中Open Screen数据包的Window Title

当时,我们是用getChatComponents()读取的,所以现在也应该用getChatComponents(),不是很正常吗?

于是,你就掉进第一个坑了

如果你尝试输出event.getPacket().getChatComponents().size(),得到的结果竟然是0!说明这个数据包根本没有这个类型的数据!!

这到底是什么情况,明明都是Chat类型,为啥还读取不了了???

这时候,我们就需要回想一下之前第3章中提到的一个坑:

wiki.vg作为Minecraft通信协议的百科全书,它注重的是“过程”,所以它把Window Type的类型标注成VarInt,说明Window Type在网络传输过程中是以VarInt的形式存在的

而ProtocolLib作为服务端插件,它更关注于“结果”

鸡贼的是,这里的JSON Data正是属于上面这种情况

因此,JSON Data与Window Title在网络中都是以Chat的格式进行传输的,但是服务端(或者客户端吧)收到后,又把它们分别反序列化成了不同的类型

因此,作为“更关注‘结果’”的ProtocolLib,我们并不能直接用读Window Title的方法去读JSON Data

那到底应该用什么方法读取呢?

我也不难为大家了,应该用getStrings()方法

OKOK,看到这里,于是你把代码改成了下面这个样子:

// ...上接
player.sendMessage("JSON Data: " + event.getPacket().getStrings().read(0));
player.sendMessage("Overlay: " + event.getPacket().getBooleans().read(0));
// 下接...

你想:这下总好了吧,这么简单的代码,还能有什么bug

抱着十足的自信,你又重新跑了一遍

woc,控制台怎么被异常信息刷屏了???

于是乎,你又碰到了第二个bug,那个需要留个心眼的bug

需要留个心眼的bug

让我们把自己提升一个维度,打开格局,从上帝视角再仔细审视一遍我们的代码

诚然,在1.19.2版本中,我们有System Chat Message这个数据包,因此System Chat应该与这个数据包有关

是玩家发送给玩家的呢,还是服务端(插件)发送给玩家的呢?

很显然,由于我们针对的是插件的国际化,所以应当是后者

以下统称这类消息为System Chat,请务必记住这一点

看到这里,我们就要想了:既然SYSTEM_CHAT传递的是System Chat,那么我们在处理该数据包的过程中再次向玩家发送System Chat,是否又会再次触发我们所监听的SYSTEM_CHAT?

这不就是俄罗斯套娃吗???怪不得会反复报错!

因此,吸取教训:不要在一个数据包的处理过程中使用到任何会导致该数据包再次被触发(发送)的方法

那么,player.sendMessage()自然是不能用了

最简单的替代方法,莫过于从Hello World开始就陪伴我们的System.out.println()了

ProtocolLibrary.getProtocolManager().addPacketListener(
        new PacketAdapter(this, PacketType.Play.Server.SYSTEM_CHAT) {
            @Override
            public void onPacketSending(PacketEvent event) {
                System.out.println("JSON Data: " + event.getPacket().getStrings().read(0));
                System.out.println("Overlay: " + event.getPacket().getBooleans().read(0));
            }
        }
);

重新跑一遍,没有报错

接下来,我们在其他地方调用player.sendMessage()(比如自定义的命令中),看看我们能否正确捕获System Chat

内容是Hello, world!,位于聊天框中,因此Overlay是false

一切正常

接下来试试ActionBar的消息

使用如下的代码向玩家发送ActionBar消息:player.spigot().sendMessage(ChatMessageType.ACTION_BAR, TextComponent.fromLegacyText("Action bar text"));

内容是Action bar text,位于ActionBar上,因此Overlay是true

简直完美

write

read部分结束了,write就简单多了

ProtocolLibrary.getProtocolManager().addPacketListener(
        new PacketAdapter(this, PacketType.Play.Server.SYSTEM_CHAT) {
            @Override
            public void onPacketSending(PacketEvent event) {
                event.getPacket().getStrings().write(
                    0,
                    WrappedChatComponent.fromText("Message overrode").getJson()
                );
            }
        }
);

需要注意的是,在写入时不能只写入一个随意的字符串,比如:

event.getPacket().getStrings().write(
    0,
    "I'm only a string"
);

否则,客户端将会收到如下的异常,并断开连接

乍一看,好像是JSON的语法错误导致的解析失败

这时候,我们就需要联想到同样是上一篇教程中提到的“原始JSON文本”了

之所以我们之前在Window Title时不需要太关注它,是因为Window Title接收的是getChatComponents(),我们只需要传递一个WrappedChatComponent就好了,ProtocolLib帮我们擦了屁股

但是,这里的JSON Data我们是用getStrings()来处理的,所以这转换成“原始JSON文本”的脏活得我们自己干,否则客户端还不认,直接不和你玩了

所以,不管你是用fromLegacyText()还是fromText(),最后都记得使用getJson()将其转换成标准的“原始JSON文本”格式的字符串

如果你不想使用ProtocolLib提供的WrappedChatComponent,Spigot自带的ChatComponent API也可以实现将普通的字符串转换成“原始JSON文本”的功能。代码如下:

ComponentSerializer.toString(TextComponent.fromLegacyText("I'm only a string"))

Chat Message的监听及处理(适用于1.19以下的版本)

之前没给大家看过这个数据包长啥样,现在给大家看看(我以1.17.1版本为例)

看仔细了,我们需要的是Clientbound的Chat Message,还有一个Serverbound的Chat Message和我们无关

归纳一下其结构,无非就是:

数据名称 数据类型 说明
JSON Data Chat 文本内容
Position Byte 文本的位置
0是聊天框中的普通聊天
1是聊天框中的System Chat(我们想要的)
2是ActionBar(原文是game info (above hotbar),指的就是ActionBar)
Sender UUID 虽然和我们的目的无关,但我还是简单翻译一下:
被原版的客户端(the Notchian client)用于聊天内容的屏蔽处理(哪些人的聊天显示,哪些人的不显示),如果UUID是00000000-0000-0000-0000-000000000000(Setting both longs to 0),则将会始终显示该消息(无视客户端的屏蔽设置)
个人理解:这个应该就是发送者的UUID

我们要关注的,只有前两项

幸运的是,这里的JSON Data,我们可以直接用getChatComponents()

那么Position呢?

既然它是Byte类型的,我们能否直接用getBytes()呢?

遗憾的是,这里的Byte表示的又是Position在网络传输中的格式。正确的打开方式是getChatTypes()

获取聊天类型,这么看起来还挺直白的

大体上都说明白了,这里就直接上write的代码了

ProtocolLibrary.getProtocolManager().addPacketListener(
        new PacketAdapter(this, PacketType.Play.Server.CHAT) {
            @Override
            public void onPacketSending(PacketEvent event) {
                PacketContainer packetContainer = event.getPacket();
                if (packetContainer.getChatTypes().read(0) != EnumWrappers.ChatType.SYSTEM) {
                    return;
                }
                packetContainer.getChatComponents().write(
                        0,
                        WrappedChatComponent.fromText("Message overrode")
                );
            }
        }

在上面的代码中,我们只针对System Chat进行处理,将其内容一律修改为Message overrode

整体而言,除了getChatTypes()的小坑之外,相当的简单

我就不放效果图了,大家自己试一试就好

温馨提示:

EnumWrappers.ChatType.SYSTEM指的是System Chat

EnumWrappers.ChatType.CHAT指的是玩家的普通聊天消息

EnumWrappers.ChatType.GAME_INFO指的是ActionBar消息

版本兼容

既然我们知道,以1.19版本为分界线,前后用于传递System Chat的数据包并不相同

这时候,我们就要做一些版本兼容的工作了

说起获取当前的服务端版本,大家第一时间想到的可能是Spigot自带的Bukkit.getVersion()

确实,这个方法能用来获取服务端版本

但用过的都知道,它返回的字符串不仅包含版本号,还包含了其他很多杂七杂八的至少对于现在的我们来说没用的信息

下面是一个Bukkit.getVersion()的返回样例:3610-Spigot-6198b5a-19df23a (MC: 1.19.2)

emm,看来最后括号里的major, minor, build三个数字才是我们目前想要的

如果我们自己来写,说难其实也不难;但说简单的话,还是要花点时间的

你的运气不错,由于我们的项目大量依赖ProtocolLib,而刚好ProtocolLib就提供了一个用于版本判断的小工具MinecraftVersion

下面我们就浅浅地上手一下:

if (MinecraftVersion.getCurrentVersion().isAtLeast(new MinecraftVersion(1, 19, 0))) {
    ProtocolLibrary.getProtocolManager().addPacketListener(
            new PacketAdapter(this, PacketType.Play.Server.SYSTEM_CHAT) {
                @Override
                public void onPacketSending(PacketEvent event) {
                    event.getPacket().getStrings().write(
                            0,
                            WrappedChatComponent.fromText("Message overrode at least 1.19.0").getJson()
                    );
                }
            }
    );
} else {
    ProtocolLibrary.getProtocolManager().addPacketListener(
            new PacketAdapter(this, PacketType.Play.Server.CHAT) {
                @Override
                public void onPacketSending(PacketEvent event) {
                    PacketContainer packetContainer = event.getPacket();
                    if (packetContainer.getChatTypes().read(0) != EnumWrappers.ChatType.SYSTEM) {
                        return;
                    }
                    packetContainer.getChatComponents().write(
                            0,
                            WrappedChatComponent.fromText("Message overrode below 1.19.0")
                    );
                }
            }
    );
}

其实仔细看,我们真正用到了MinecraftVersion的地方,只有if判断的地方,其余处只是整合了之前的代码缝合怪

逻辑也很简单,如果当前服务端的版本大于等于(至少为)1.19.0,则监听PacketType.Play.Server.SYSTEM_CHAT;反之(小于1.19.0),则监听PacketType.Play.Server.CHAT

如果版本大于等于1.19.0,则玩家会收到“Message overrode at least 1.19.0”;反之,则收到“Message overrode below 1.19.0”

建议的是在添加监听器的时候进行版本判断,而不是在read/write数据包的时候

另外,MinecraftVersion还有一些其他的方法,大家有兴趣可以自己去探索探索

结语

在这篇教程中,我们深入了解了System Chat Message(1.19及以上)和Chat Message(1.19以下)这两个数据包,用于对System Chat进行read/write

总结一下几个坑:

  • JSON Data在System Chat Message中应当使用getStrings(),并配合使用WrappedChatComponent的getJson()方法;而在Chat Message中应当使用getChatComponents()
  • Chat Message中的Position应当使用getChatTypes()而不是getBytes()
  • 不要在一个数据包的处理过程中使用到任何会导致该数据包再次被触发(发送)的方法

总体来说,这次的探索还是相当愉快的,至少比之前几次愉快很多

下一次,我们来讲讲ItemStack的国际化翻译问题

评论

  1. 2 月前
    2024-9-28 17:39:19

    Great read, thanks for sharing your thoughts and expertise.

  2. 2 月前
    2024-10-12 19:27:37

    Thank you for this insightful post! I really appreciate the effort you put into creating such valuable content.

  3. 2 月前
    2024-10-18 17:01:26

    helloI like your writing very so much proportion we keep up a correspondence extra approximately your post on AOL I need an expert in this space to unravel my problem May be that is you Taking a look forward to see you

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇