使用ProtocolLib实现Spigot插件的国际化翻译(I18N)—— Inventory(第3章)

前情提要

在上一篇教程中,我们了解了如何从零开始分析一个数据包

至此,一些必要的铺垫都已经准备完毕了

今天,我们正式开始实战——对Inventory的标题栏进行国际化

不过在这之前,我们还要先解决一下上次教程留下的一点小尾巴

作业答案

在wiki.vg上找到与ProtocolLib中PacketType.Play.Server.OPEN_WINDOW对应的数据包的名字,我们在下一篇教程中会用到它

这就是上次作业的内容

对于作业的答案,我相信一些读者应该是胸有成竹了

不过请容许我再啰嗦一下过程(我以构建版本#606为例,使用5.0.0-SNAPSHOT的可以进行类比参照,结果是一样的):

  1. PacketType.class中找到PacketType.Play.Server.OPEN_WINDOW的定义

    OPEN_WINDOW = new PacketType(PacketType.Play.PROTOCOL, SENDER, 45, new String[]{"OpenWindow", "SPacketOpenWindow"});

    可以看到其Packet ID为45,或者说0x2D

  2. 在wiki.vg中的Clientbound部分找到Packet ID为0x2D的数据包

没错,在wiki.vg上与ProtocolLib中PacketType.Play.Server.OPEN_WINDOW对应的数据包就是Open Screen

分析Open Screen数据包

这个数据包的结构比较简单,有且只包含三部分数据

数据名称 数据类型 说明
Window ID VarInt 窗体的ID(和上一篇教程中提到的Entity ID类似,都像一个“句柄”)
Window Type VarInt 窗体的类型
Window Title Chat 窗体的标题

但是我们最关心的是最后一个Window Title,因为它正好包含了我们需要的Inventory标题

但是我们如何在ProtocolLib中获取这一项的值呢?

上代码!

读取数据包

又到了我们久违的代码时间了

ProtocolLibrary.getProtocolManager().addPacketListener(
        new PacketAdapter(this, PacketType.Play.Server.OPEN_WINDOW) {
            @Override
            public void onPacketSending(PacketEvent event) {
                event.getPlayer().sendMessage("OPEN_WINDOW");
            }
        }
);

上面展示的,是我们在第一篇教程中使用过的代码。我们将在它的基础上进行修改

ProtocolLibrary.getProtocolManager().addPacketListener(
        new PacketAdapter(this, PacketType.Play.Server.OPEN_WINDOW) {
            @Override
            public void onPacketSending(PacketEvent event) {
                event.getPlayer().sendMessage(
                    String.valueOf(event.getPacket().getChatComponents().read(0))
                );
            }
        }
);

改动的地方不多,就是把"OPEN_WINDOW"换成了String.valueOf(event.getPacket().getChatComponents().read(0))

接下来我们来仔细看看:

其实,核心部分就是event.getPacket().getChatComponents().read(0)

evevt.getPacket()就是获取我们的数据包

getChatComponents()方法返回的是StructureModifier<WrappedChatComponent>,它包含了这个数据包中所有类型为WrappedChatComponent(或者说ChatComponent类型,不在此深究,大家明白就好)的数据

read(0)表示我们要读取第1个(索引为0)数据

那么在这个数据包中,第1个类型为WrappedChatComponent的数据自然就是我们关心的Window Title了

让我们来看看实战的效果

可以看到,随着我们依次打开了工作台、箱子、投掷器三个Inventory,我们也依次收到了三条消息:

WrappedChatComponent[json={"translate":"container.crafting"}]

WrappedChatComponent[json={"translate":"container.chest"}]

WrappedChatComponent[json={"translate":"container.dropper"}]

虽然第一眼看上去可能一头雾水,并不是我们想要的“合成”、“箱子”、“投掷器”

但细细一琢磨,这"container.crafting", "container.chest", "container.dropper",结合前面的"translate",好像还是有点关系的

究竟是什么关系呢?

String.valueOf()迷思

我们前面实际上调用的是String.valueOf(),或者说toString()方法,返回的与其说是我们想要的标题栏文本,更不如说是这个对象的文字描述

好奇宝宝们或许已经发现了,在event.getPacket().getChatComponents().read(0)后面多打一个".",触发IDE的自动补全,就能看到一个getJson()的补全项

看起来好像有点东西,不如运行试试

ProtocolLibrary.getProtocolManager().addPacketListener(
        new PacketAdapter(this, PacketType.Play.Server.OPEN_WINDOW) {
            @Override
            public void onPacketSending(PacketEvent event) {
                event.getPlayer().sendMessage(
                    event.getPacket().getChatComponents().read(0).getJson()
                );
            }
        }
);

不出意外,我们收到了三个标准的JSON格式的消息:

"translate":"container.crafting"

"translate":"container.chest"

"translate":"container.dropper"

至此,我估计大家都知道我们要从这里入手了

有些老鸟这时坐不住了:“我看到WrappedChatComponent就有话要说了。”

没错,有经验的开发者看到JSON,又看到WrappedChatComponent和"translate",很难不联想到Spigot的ChatComponent API,以及“原始JSON文本”

WrappedChatComponent, ChatComponent API与“原始JSON文本”

要理清这三者,我还得倒过来说

先说说“原始JSON文本”吧

从某种意义上来说,这算是个专业术语了。如果要详细了解,可以到Fandom Minecraft Wiki上看看

我简单说一下,所谓“原始JSON文本”,就是用JSON描述的一段带格式的文本

相信大家在玩一些服务器时都见过一些花里胡哨的文字

这样的效果就是通过“原始JSON文本”来描述的

而ChatComponent API是Spigot提供的一套API,用以辅助我们实现如上的效果。不过与我们这篇教程无关,所以不涉及

WrappedChatComponent和ChatComponent API作用类似,只不过是ProtocolLib提供的,我们下面会稍加涉猎

我们前面看到的"translate":"container.crafting"中的translate,就是属于“翻译标识符”

它告诉客户端“去到语言文件里找到与container.crafting对应的值,然后拿过来放到这里”

正是通过这样的方法,Minecraft实现了国际化翻译

我们找找Minecraft的简体中文语言文件zh_cn.json(以前的版本是.lang格式的),看看里面与container.crafting对应的值是什么

没错,就是我们想要的“合成”

那么剩下的"translate":"container.chest""translate":"container.dropper"也就不言而喻了

谜团解开

修改数据包

既然我们知道了类似"translate":"container.crafting"这样的字符串的意思,接下来就要偷天换日了

ProtocolLibrary.getProtocolManager().addPacketListener(
        new PacketAdapter(this, PacketType.Play.Server.OPEN_WINDOW) {
            @Override
            public void onPacketSending(PacketEvent event) {
                event.getPacket().getChatComponents().write(
                    0,
                    WrappedChatComponent.fromLegacyText("This is the title of the container")
                );
            }
        }
);

和读取数据包类似,只不过把read()换成了write()。读到的是WrappedChatComponent,写入的自然也是WrappedChatComponent

用人话来说,就是往类型为WrappedChatComponent的第1个(索引为0)数据处写入WrappedChatComponent.fromLegacyText("This is the title of the container")

废话不多说,直接看效果

由于我们没有加入任何的条件判断,因此所有Inventory的标题栏都被修改为了"This is the title of the container"

虽然看起来有点笨笨的,不过这是我们成功的第一步,不是吗?

我怀疑你在搞颜色

原本深灰色的文字颜色未免显得有些单调了,我们不妨也来点花里胡哨的

这时候,我们就要请出格式化代码来助我们一臂之力了

当然,看到上面的超链接,就知道我又甩了一篇Wiki。按照惯例,想要详细了解的,可以去Wiki上看看

下面我来简单地说一说

所谓的格式化代码,就是由“§”(分节符)作为前缀,加上一个字符,共两个字符所组成的,用于表示特定格式的代码

用“§”开头来表示颜色的场合,相信大家多多少少也都听过或见过

我就把各个格式化代码的简要信息列出来,这个概念就过了

格式化代码 说明
§0 黑色
§1 深蓝色
§2 深绿色
§3 湖蓝色
§4 深红色
§5 紫色
§6 金色
§7 灰色
§8 深灰色
§9 蓝色
§a 绿色
§b 天蓝色
§c 红色
§d 粉红色
§e 黄色
§f 白色
§g 硬币金(仅限基岩版)
§k 随机字符(与原有字符的宽度相同)
§l 粗体
§m 删除线(仅限Java版)
§n 下划线(仅限Java版)
§o 斜体
§r 重置文字样式

OK,接下来,从理论到实践

我想要显示“xxx is saying hello”的效果。其中,"xxx"代表3个蓝色的随机字符,"is saying"是正常颜色的,"hello"是深绿色的

就应当如下书写:§9§kxxx §ris saying §2hello

稍微要注意一下的就是,当前后的格式冲突时,用§r重置一下样式,然后再继续书写新的格式

把上面这段套到之前的fromLegacyText()方法中试一试

ProtocolLibrary.getProtocolManager().addPacketListener(
        new PacketAdapter(this, PacketType.Play.Server.OPEN_WINDOW) {
            @Override
            public void onPacketSending(PacketEvent event) {
                event.getPacket().getChatComponents().write(
                    0,
                    WrappedChatComponent.fromLegacyText("§9§kxxx §ris saying §2hello")
                );
            }
        }
);

看看效果

简直完美

琐事三两件:Window Type

上面是Open Screen这个数据包的结构图,大家在之前都已经看过了

对于Window Title,我们已经门清了。那么我们现在要聊的是什么呢,还有什么问题吗?

那就是Window Type

有些人不以为意:不就是个VarInt吗,有啥了不起的,我用event.getPacket().getIntegers().read(1)读一下,然后到对照表中查一下,不就就搞定了?

有经验的读者都会心一笑,既然我把它拎出来单独说说,当然是有坑的

抱着不撞南墙不回头的精神,我们就用event.getPacket().getIntegers().read(1)来试一试

我擦,怎么有异常。你这ProtocolLib不讲武德啊,明明wiki.vg上写着有2个VarInt,我怎么一read(1)你就告诉我总共只有1个Integer,良心属实大大的坏了

难道是ProtocolLib的问题?

当然也不是

不知道大家有没有听说过序列化和反序列化的概念

简单来说,要是我有一个Student类,包含了一个学生的所有相关信息。现在,我要把这个存到数据库里

我总不可能直接让数据库真的保存Student的对象,可能数据库根本不知道那是啥玩意儿

所以,我们就需要制定一种数据交换方式,然后通过这个交换方式来存入、读取数据,且这个数据交换方式必须双方都认识

最典型的就是JSON,大家都是认识的

结合上面的例子,如果使用JSON作为中介,那么存入数据的时候我就需要把Student类“序列化”为JSON格式的字符串,然后送到数据库中;相反的,读取数据的时候,需要把JSON格式的字符串“反序列化”为Student

但是,对于Minecraft这样客户端和服务端的通信过程相对封闭的体系来说,就没必要用JSON了。因为JSON的易读性使其丧失了部分的传输效率

并且对于游戏来说,效率也是很重要的事。反正都是自己人,又不是给别人看的,怎么快怎么来

但是作为开发人员,直接拿0,1,2这样的“魔法值”去判断Inventory类型,效率高是高,简单是简单,但实在是太不优雅了,严重点说是犯忌的。既然Inventory类型就那几个,比较理想的就是用Enum来表示(当然这是理想情况,用public final class也不是不行)

既要保证代码的可读性、容错性高,又要保证传输的效率。大家自然而然想到的方案就是:传递的时候把Enum转成int,接收的时候把int转成Enum

于是问题就产生了

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

而ProtocolLib作为服务端插件,它更关注于“结果”。所以我们用ProtocolLib读取数据包的时候就不应当使用getIntegers()方法了

那么我们应该用什么呢

这里就要提到另一个操蛋之处了。不像ChatTypes这种可以直接使用getChatTypes()的ProtocolLib已经定义好的方法,我们需要自己手动去读取

这时候,我们需要用到getModifier()方法

这个方法就像一个大熔炉,或者说像是泛型里的?,任何类型的东西都可以用它来读取到

用了这个方法,我们就不用考虑它是什么类型的第几个数据了,直接看它在整个数据包中的顺序就行了

比如说,我们之前的Window Title,在整个数据包中它是第3条数据,所以我们使用getModifier().read(2)可以读取到

当然,我并不是说大家一股脑儿都去用getModifier()算了,因为getModifier().read()返回的是Object,到头来你还是得把它转换成目标类型才能做一些操作

使用getModifier()是一种下下策,或者说是fallback,是迫不得已的。如果ProtocolLib有提供相应的更方便的方法,请务必别用getModifier()

言归正传,回到Window Type。由于它在整个数据包中是第2条数据,所以我们使用getModifier().read(1)进行读取

先直接套个String.valueOf()看看结果:

看来是类名+地址,没重写toString()

不过这个信息够我们判断一些东西了

我们可以看到,我们读取到的是net.minecraft.world.inventory.Containers的一个对象

不妨去看看这个类

可是,怎么找不到呢?

没错,头疼的事情又来了,因为我们需要用到NMS

没办法,为了科学献身一把,到pom.xml里面把<artifactId>spigot-api</artifactId>改成<artifactId>spigot</artifactId>,刷新

然后进到net.minecraft.world.inventory.Containers看看有啥东西

public class Containers<T extends Container> {
    public static final Containers<ContainerChest> a = a("generic_9x1", ContainerChest::a);
    public static final Containers<ContainerChest> b = a("generic_9x2", ContainerChest::b);
    public static final Containers<ContainerChest> c = a("generic_9x3", ContainerChest::c);
    public static final Containers<ContainerChest> d = a("generic_9x4", ContainerChest::d);
    public static final Containers<ContainerChest> e = a("generic_9x5", ContainerChest::e);
    public static final Containers<ContainerChest> f = a("generic_9x6", ContainerChest::f);
    public static final Containers<ContainerDispenser> g = a("generic_3x3", ContainerDispenser::new);
    public static final Containers<ContainerAnvil> h = a("anvil", ContainerAnvil::new);
    public static final Containers<ContainerBeacon> i = a("beacon", ContainerBeacon::new);
    public static final Containers<ContainerBlastFurnace> j = a("blast_furnace", ContainerBlastFurnace::new);
    public static final Containers<ContainerBrewingStand> k = a("brewing_stand", ContainerBrewingStand::new);
    public static final Containers<ContainerWorkbench> l = a("crafting", ContainerWorkbench::new);
    public static final Containers<ContainerEnchantTable> m = a("enchantment", ContainerEnchantTable::new);
    public static final Containers<ContainerFurnaceFurnace> n = a("furnace", ContainerFurnaceFurnace::new);
    public static final Containers<ContainerGrindstone> o = a("grindstone", ContainerGrindstone::new);
    public static final Containers<ContainerHopper> p = a("hopper", ContainerHopper::new);
    public static final Containers<ContainerLectern> q = a("lectern", (i, playerinventory) -> {
        return new ContainerLectern(i, playerinventory);
    });
    public static final Containers<ContainerLoom> r = a("loom", ContainerLoom::new);
    public static final Containers<ContainerMerchant> s = a("merchant", ContainerMerchant::new);
    public static final Containers<ContainerShulkerBox> t = a("shulker_box", ContainerShulkerBox::new);
    public static final Containers<ContainerSmithing> u = a("smithing", ContainerSmithing::new);
    public static final Containers<ContainerSmoker> v = a("smoker", ContainerSmoker::new);
    public static final Containers<ContainerCartography> w = a("cartography_table", ContainerCartography::new);
    public static final Containers<ContainerStonecutter> x = a("stonecutter", ContainerStonecutter::new);
    private final Supplier<T> y;

    private static <T extends Container> Containers<T> a(String s, Supplier<T> containers_supplier) {
        return (Containers)IRegistry.a(IRegistry.ah, s, new Containers(containers_supplier));
    }

    private Containers(Supplier<T> containers_supplier) {
        this.y = containers_supplier;
    }

    public T a(int i, PlayerInventory playerinventory) {
        return this.y.create(i, playerinventory);
    }

    private interface Supplier<T extends Container> {
        T create(int var1, PlayerInventory var2);
    }
}

简直是难以忍受,不过我们还是看到了点有用的东西

    public static final Containers<ContainerChest> a = a("generic_9x1", ContainerChest::a);
    public static final Containers<ContainerChest> b = a("generic_9x2", ContainerChest::b);
    public static final Containers<ContainerChest> c = a("generic_9x3", ContainerChest::c);
    public static final Containers<ContainerChest> d = a("generic_9x4", ContainerChest::d);
    public static final Containers<ContainerChest> e = a("generic_9x5", ContainerChest::e);
    public static final Containers<ContainerChest> f = a("generic_9x6", ContainerChest::f);
    public static final Containers<ContainerDispenser> g = a("generic_3x3", ContainerDispenser::new);
    public static final Containers<ContainerAnvil> h = a("anvil", ContainerAnvil::new);
    public static final Containers<ContainerBeacon> i = a("beacon", ContainerBeacon::new);
    public static final Containers<ContainerBlastFurnace> j = a("blast_furnace", ContainerBlastFurnace::new);
    public static final Containers<ContainerBrewingStand> k = a("brewing_stand", ContainerBrewingStand::new);
    public static final Containers<ContainerWorkbench> l = a("crafting", ContainerWorkbench::new);
    public static final Containers<ContainerEnchantTable> m = a("enchantment", ContainerEnchantTable::new);
    public static final Containers<ContainerFurnaceFurnace> n = a("furnace", ContainerFurnaceFurnace::new);
    public static final Containers<ContainerGrindstone> o = a("grindstone", ContainerGrindstone::new);
    public static final Containers<ContainerHopper> p = a("hopper", ContainerHopper::new);
    public static final Containers<ContainerLectern> q = a("lectern", (i, playerinventory) -> {
        return new ContainerLectern(i, playerinventory);
    });
    public static final Containers<ContainerLoom> r = a("loom", ContainerLoom::new);
    public static final Containers<ContainerMerchant> s = a("merchant", ContainerMerchant::new);
    public static final Containers<ContainerShulkerBox> t = a("shulker_box", ContainerShulkerBox::new);
    public static final Containers<ContainerSmithing> u = a("smithing", ContainerSmithing::new);
    public static final Containers<ContainerSmoker> v = a("smoker", ContainerSmoker::new);
    public static final Containers<ContainerCartography> w = a("cartography_table", ContainerCartography::new);
    public static final Containers<ContainerStonecutter> x = a("stonecutter", ContainerStonecutter::new);

这些全部用public static final声明的常量,好像有点搞头

不妨拿来试一试

ProtocolLibrary.getProtocolManager().addPacketListener(
        new PacketAdapter(this, PacketType.Play.Server.OPEN_WINDOW) {
            @Override
            public void onPacketSending(PacketEvent event) {
                event.getPlayer().sendMessage(
                    String.valueOf(event.getPacket().getModifier().read(1).equals(Containers.l))
                );
            }
        }
);

我们测试的目标是工作台

Containers类中,我们可以看到工作台对对应的应该是l

public static final Containers<ContainerWorkbench> l = a("crafting", ContainerWorkbench::new);

所以我们在getModifier().read(1)后直接用equals()比较一下

理想状态下,这段代码的效果是:只有当玩家打开工作台才会收到true,反之都是false

抱着忐忑的心情,我们来实战一下

努力没有白费,但只能算是惨胜

由于使用了NMS,算是破了戒,因此这种方法不推荐使用

但有没有稍微好一点的办法呢?

你可能会想到,既然工作台的Window Title是"translate":"container.crafting",箱子的Window Title是"translate":"container.chest",投掷器的Window Title是"translate":"container.dropper",那我们难道不能用Window Title来判断吗?

当然可以,这也确实比用NMS更优雅,但还是有一个小小的问题:

如果其他的插件也用了ProtocolLib,也监听了PacketType.Play.Server.OPEN_WINDOW,也修改了Window Title,那岂不是有大问题?

虽然这个情况挺极端的,但我们既然考虑到了,不想办法克服,心里总是不好过的

于是,有请第三种方法

我们想,既然我们能用event.getPlayer()获取到玩家,我们能不能从此着手呢?

Player类下面有一个getOpenInventory()方法,它返回的是InventoryView(Inventory视图),说白了就是玩家的Inventory视野

由于Inventory是上下分层的,并且容器的Inventory是在上方的(比如说箱子,上方的Inventory是箱子的,下方的Inventory是玩家的),所以我们应该使用InventoryView下面的getTopInventory()方法获取容器的Inventory

既然这是Spigot API下的内容,相信大家都应该很熟悉了

如果要判断类型,直接getType()就好了,甚至你还可以做一些其他的操作

比如说判断工作台,就是event.getPlayer().getOpenInventory().getTopInventory().getType() == InventoryType.CRAFTING

千算万算没想到,最后还是Spigot API救了我们一命,这也是我能想到的最优雅的方法

美中不足的是,如果你要通过ProtocolLib发PacketType.Play.Server.OPEN_WINDOW包,还是得用到NMS

不过由于我们系列的教程用不到发包,我就偷个小懒。如果大家有兴趣,自己可以去研究研究

琐事三两件:WrappedChatComponent

接下来我们要聊的没有上面那么沉重,WrappedChatComponent

WrappedChatComponent下面有好几个方法,最常用三个的应该就是fromLegacyText(), fromJson(), fromText()

主要说说哥仨的区别

fromLegacyText()

顾名思义,是为了向前兼容的产物

只支持使用“§”(分节符)设置文字颜色、文字样式的基本功能

由于这次我们只需要修改文字的颜色和样式,所以fromLegacyText()就够用了

fromJson()

运用了“原始JSON文本”

除了支持LegacyText使用“§”(分节符)设置文字颜色、文字样式的方法,还支持使用JSON格式设置文字颜色、文字样式,以及translate, clickEvent, hoverEvent, keybind等诸多高级玩法,是现在Minecraft普遍使用的格式

fromText()

底层调用的还是fromJson()方法

如果只涉及到文本,不涉及“原始JSON文本”的高级玩法,可以用这个

结语

文本详细介绍了Open Screen数据包,读取、修改数据包的方法,WrappedChatComponent的用法,以及从数据包和WrappedChatComponent所延申出来的诸多概念和内容

这一篇教程涉及的知识不仅广,而且杂。事后可以对教程所涉及的概念多加了解,并且多加运用,熟能生巧

至此,你应该掌握了使用ProtocolLib读取、修改数据包的方法

所以接下来的教程中,对于读取、修改数据包部分,我将不再赘述。也正因此,将会容易很多

下一篇教程,我们来看一看“聊天内容”的国际化

相关链接

Fandom Wiki中对于“原始JSON文本”的介绍:https://minecraft.fandom.com/zh/wiki/%E5%8E%9F%E5%A7%8BJSON%E6%96%87%E6%9C%AC%E6%A0%BC%E5%BC%8F?variant=zh

Fandom Wiki中对于“格式化代码”的介绍:https://minecraft.fandom.com/zh/wiki/%E6%A0%BC%E5%BC%8F%E5%8C%96%E4%BB%A3%E7%A0%81

wiki.vg上的 Inventory类型<->数值 对照表:https://wiki.vg/Inventory

评论

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

    This was exactly what I was looking for, thank you!

  2. 1 月前
    2024-10-18 16:59:33

    Magnificent beat I would like to apprentice while you amend your site how can i subscribe for a blog web site The account helped me a acceptable deal I had been a little bit acquainted of this your broadcast offered bright clear idea

  3. 1 月前
    2024-10-18 17:01:07

    hiI like your writing so much share we be in contact more approximately your article on AOL I need a specialist in this area to resolve my problem Maybe that is you Looking ahead to see you

发送评论 编辑评论


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