ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
# 你的第一个GUI **难度分级:☆☆☆☆** 图形用户界面(Graphical User Interface)是指采用图形方式显示的计算机操作用户界面。在现在的交互系统中,GUI无处不在。同样的,在Minecraft游戏中,我们也无时无刻不在与GUI打交道。本章我们就来探索一下如何在Minecraft中开发一个可交互的GUI。 ## LWJGL **轻量级Java游戏工具库(LightWight Java Game Library)** 是一个跨平台的JAVA图形API,它为游戏的 **视频(基于OpenGL或Vulkan),音频(基于OpenAL)和并行计算(基于OpenCL)** 提供了原生高效的硬件加速支持。Minecraft的图形界面就是基于LWJGL开发的,因此如果你希望进行**深度的Minecraft图形界面开发**,你就需要~~直接与LWJGL的API打交道~~&nbsp;**请使用Minecraft提供的GLStateManager/Tessellator,而不是直接调用LWJGL API。直接调用LWJGL API可能会导致游戏渲染发生异常,包括颜色不正确和各种匪夷所思的Bug**。本章中我们对该部分不做过多的阐述,你可以在[这里](https://github.com/LWJGL/lwjgl3-wiki/wiki)找到LWJGL的完整官方教程。 ## 单一GUI窗口类 &nbsp;&nbsp;难度分级:☆☆ Minecraft自身为GUI封装了一个名为`GuiScreen`的基本窗口类,几乎所有你在游戏中看到的GUI界面都是基于`GuiScreen`绘制的。`GuiScreen`提供了几乎所有必须的**GUI元素**,并接管了绝大部分用于**基于GUI的游戏交互事件**。`GuiScreen`本身~~并不具备与服务端交互的能力~~ **某些情况下也可以与服务端交互,只是需要使用其他通讯方式(下一章会讲到)**,因此如果你需要渲染一个**独立的GUI窗口**,你可以直接继承`net.minecraft.client.gui.GuiScreen`类。 ## Container与GuiContainer &nbsp;&nbsp;难度分级:☆☆☆ **Container**和**GuiContainer**是Forge中一套对于**需要使GUI与游戏对象进行数据交互**的同步解决方案。`Container`和`GuiContainer`在游戏中是一个**独立的游戏对象**,因此你可以将它附着在任何**物品,方块,实体,方块实体等**游戏对象上。`Container`与`GuiContainer`的交互逻辑可以用下图阐释:![](https://img.kancloud.cn/68/71/687126e32573f38e7b8749c1c8009c1e_512x384.png) 其中**实线部分是由Minecraft/Forge进行托管同步的**,而**虚线部分则需要玩家使用其他通讯方式手动进行同步**(将在下一章介绍)。 ### **自定义`Container`类** 首先我们新建一个负责处理服务端逻辑的**自定义`Container`类**。所有的自定义`Container`类需继承`net.minecraft.inventory.Container`类,该类为一个**抽象类(abstract类)**,因此我们需要新建一个构造函数(如果想让你的GUI中显示玩家物品栏则此处可以传入一个**EntityPlayer类**的参数)。同时,我们还需要覆写 **`canInteractWith()`方法** 来设置该Container的交互权限。**对于一个与TileEntity绑定的`Container`来说,该方法一般只需要返回`true`即可;而对于与手持物品绑定的`Container`,这里还需要检测手持物品是否为指定物品。** 例如EOK中**基础研究台**的自定义`Container`类的基本结构如下: ~~~ public class ContainerElementaryResearchTable extends Container ...{ ... public ContainerElementaryResearchTable(EntityPlayer player) { super(); ... } @Override public boolean canInteractWith(EntityPlayer playerIn) { return true; } ... } ~~~ 再如EOK中**折射望远镜**的自定义`Container`类中**对`canInteractWith()`方法的覆写**如下: ~~~ public class ContainerRefractingTelescope extends Container ...{ ... @Override public boolean canInteractWith(EntityPlayer playerIn) { return new ItemStack(ItemHandler.refractingTelescope).isItemEqual(playerIn.getHeldItemMainhand()); } ... } ~~~ ### **自定义`GuiContainer`类** 随后我们来创建负责处理客户端逻辑的**自定义**`GuiContainer`**类**。`GuiContainer`是一个`GuiScreen`的一个**子类**,因此`GuiScreen`中能够被覆写的函数在`GuiContainer`中同样可以被覆写。所有的自定义`GuiContainer`类需继承`net.minecraft.client.gui.inventory.GuiContainer`类。和`Container`类似,`GuiContainer`同样需要我们新建**构造函数**。为了能够**实时获取对应`Container`中的数据**,**我们需要在构造函数中传入一个**`Container`**型参数**来存放**对应的`Container`对象**。同时,我们还需要**覆写**`drawGuiContainerBackgroundLayer()`**方法**(该方法的作用将在后文具体阐述)。 例如EOK中**基础研究台**的自定义`GuiContainer`类的基本结构如下: ~~~ public class GUIElementaryResearchTable extends GuiContainer { ... public GUIElementaryResearchTable(Container inventorySlotsIn) { super(inventorySlotsIn); ... } ... @Override protected void drawGuiContainerBackgroundLayer(float partialTicks, int mouseX, int mouseY) { ... } } ~~~ ### **注册Container与GuiContainer** 和方块实体类似,我们需要通过调用 `NetworkRegistry.INSTANCE.registerGuiHandler()` 方法来单独注册`Container`与`GuiContainer`。Forge提供了一个名为`IGuiHandler`的接口来方便我们**将`Container`与`GuiContainer`关联**。这里我们直接使用EOK中的相关代码来进行解释: ~~~ public class GUIHandler implements IGuiHandler { //对每个自定义GUI进行序号编码 public static final int GUIRefractingTelescope = 1; public static final int GUIElementaryResearchTable = 2; //在服务端中运行的逻辑 @Override public Object getServerGuiElement(int ID, EntityPlayer player, World world, int x, int y, int z) { //通过编码创建服务端的Container switch (ID){ case GUIRefractingTelescope: return new ContainerRefractingTelescope(player); case GUIElementaryResearchTable: return new ContainerElementaryResearchTable(player); default: return null; } } //在客户端中运行的逻辑 @Override public Object getClientGuiElement(int ID, EntityPlayer player, World world, int x, int y, int z) { //通过编码创建客户端的Container与GuiContainer(Forge会自动托管服务端到客户端的Container同步) switch (ID){ case GUIRefractingTelescope: return new GUIRefractingTelescope(new ContainerRefractingTelescope(player)); case GUIElementaryResearchTable: return new GUIElementaryResearchTable(new ContainerElementaryResearchTable(player)); default: return null; } } } ~~~ 随后我们就可以将该GUI封装类注册到**主类或`CommonProxy`代理类**中。例如EOK中的相关注册代码: ~~~ public class CommonProxy { ... public void init(FMLInitializationEvent event) { NetworkRegistry.INSTANCE.registerGuiHandler(EOK.instance, new GUIHandler()); } ... } ~~~ ### **将GUI与游戏对象关联** 通常情况下,我们希望在**右键一个方块/物品/实体时打开对应的自定义GUI**。我们可以直接通过覆写对应游戏对象的**右键触发函数**来实现这一点。 例如EOK中打开 **基础研究台(方块)** GUI的触发代码如下: ~~~ public class BlockElementaryResearchTable extends BlockContainer implements IHasModel { ... @Override public boolean onBlockActivated(World worldIn, BlockPos pos, IBlockState state, EntityPlayer playerIn, EnumHand hand, EnumFacing facing, float hitX, float hitY, float hitZ) { // 判断所在游戏端是否为服务端,world.isRemote=true为客户端,false为服务端 if(!worldIn.isRemote){ / /获取初级探究台的GUI编码 int id = GUIHandler.GUIElementaryResearchTable; // 打开对应的GuiContainer(在服务端中即为Container) // 注意:openGui方法后面的三个参数x, y, z可以是任意值,不会影响游戏逻辑 // 你可以通过更改这三个int值来向GUIHandler传输简单的数据 playerIn.openGui(EOK.instance, id, worldIn, 0, 0, 0); } return true; } } ~~~ 再如EOK中打开 **折射望远镜(物品)** GUI的触发代码如下: ~~~ public class ItemRefractingTelescope extends Item implements IHasModel { ... @Override public ActionResult<ItemStack> onItemRightClick(World worldIn, EntityPlayer playerIn, EnumHand handIn){ if(!worldIn.isRemote){ //判断玩家是否处于潜行状态(默认为shift键) if(playerIn.isSneaking()){ //获取折射望远镜的GUI编码 int id = GUIHandler.GUIRefractingTelescope; //打开对应的GuiContainer(在服务端中即为Container) playerIn.openGui(EOK.instance, id, worldIn, 0, 0, 0); } } return new ActionResult<ItemStack>(EnumActionResult.PASS, playerIn.getHeldItem(handIn)); } } ~~~ ## GUI中的基本元素 ### **GuiContainer类的逻辑执行顺序** 对于一个`GuiContainer`对象来说,其更新逻辑会按照以下顺序进行: 1. **执行构造函数代码创建对象** 2. **执行**`initGui()`**方法初始化GUI元素** 3. **在每一帧更新时执行以下操作:** * 执行 `drawGuiContainerBackgroundLayer()` 方法绘制背景 * 绘制所有`GuiButton`等GUI元素(`drawScreen()`) * 绘制物品槽及物品 * 执行 `drawGuiContainerForegroundLayer()` 方法绘制前景 ### **背景贴图的绘制** 在`GuiContainer`中,`drawGuiContainerBackgroundLayer()` 方法被用来在**每帧刷新时**绘制**GUI背景**上的元素。Gui类提供了一个名为 `drawTexturedModalRect()` 的方法用于绘制自定义贴图。**警告:贴图图片大小必须是256*256,否则无法正常绘制!**在绘制自定义贴图之前,我们还需要进行一些前置的准备工作。 这里我们直接使用EOK中**基础研究台**GUI中的相关代码进行解释: ~~~ public class GUIElementaryResearchTable extends GuiContainer { //指定自定义背景贴图位置 private static final String TEXTURE_BACK = EOK.MODID + ":" + "textures/gui/container/elementary_research_table.png"; //创建自定义贴图的ResourceLocation标识 private static final ResourceLocation TEXTUREBACK = new ResourceLocation(TEXTURE_BACK); ... public GUIElementaryResearchTable(Container inventorySlotsIn) { super(inventorySlotsIn); //设置GUI背景贴图的大小(方便后续计算GUI元素与背景贴图的相对位置) this.xSize = 256; this.ySize = 192; } ... @Override protected void drawGuiContainerBackgroundLayer(float partialTicks, int mouseX, int mouseY) { //设置渲染混合模式及颜色模式(该处代码解释请查看lwjgl及OpenGL相关文档) GLStateManager.pushMatrix(); GLStateManager.enableBlend(); GLStateManager.blendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); GLStateManager.color(1.0F, 1.0F, 1.0F, 1.0F); //将自定义背景贴图与Minecraft材质管理器绑定 this.mc.getTextureManager().bindTexture(TEXTUREBACK); //计算相对位置(以背景贴图左上角为(0,0)点) int offsetX = (this.width - this.xSize) / 2, offsetY = (this.height - this.ySize) / 2; //绘制背景贴图(参数说明:在游戏中的XY位置;贴图在贴图文件中的XY位置;贴图的大小) this.drawTexturedModalRect(offsetX, offsetY, 0, 0, this.xSize, this.ySize); ... //用完了别忘了关掉 //警告:一定要关!!不然可能会出问题!! GLStateManager.disableBlend(); //结束渲染 GLStateManager.popMatrix(); } } ~~~ ### **按钮的绘制** Minecraft提供了一个名为`GuiButton`的类来封装GUI中的按钮元素,因此你可以直接通过新建该类型的对象来创建GUI按钮。该按钮拥有一个默认的样式,但你也可以通过覆写 `drawButton() `方法来自定义按钮的样式。该方法的作用是**在~~每个游戏刻~~每帧刷新时控制按钮的渲染**。 在`GuiContainer`中(准确来说是在其父类`GuiScreen`中),所有的GUI按钮都被放在了一个名为`buttonList`的 **列表(List)** 里进行维护,因此你也需要将你的自定义按钮 **加入(add)** 该列表中使得其能够在GUI中显示。 通常情况下,我们需要在`initGui()` 方法中完成对按钮的初始化。这里我们直接使用EOK中**基础研究台**GUI中的相关代码进行解释: ~~~ public class GUIElementaryResearchTable extends GuiContainer { ... //指定组件贴图的位置 private static final String TEXTURE_COMP = EOK.MODID + ":" + "textures/gui/container/elementary_research_table_components.png"; //创建组件贴图的ResourceLocation标识 private static final ResourceLocation TEXTURECOMP = new ResourceLocation(TEXTURE_COMP); ... @Override public void initGui() { super.initGui(); //计算相对位置(以背景贴图左上角为(0,0)点) int offsetX = (this.width - this.xSize) / 2, offsetY = (this.height - this.ySize) / 2; //创建一个新的GuiButton对象并加入buttonList中(参数说明:按钮ID;在游戏中XY位置;按钮大小;按钮上文字(如果为自定义样式则该参数无效)) this.buttonList.add(new GuiButton(0, offsetX + 219, offsetY + 129, 32, 32, ""){ //覆写drawButton()内部方法(当然你也可以创建一个新的类使其继承GuiButton类来进行更多的自定义操作) @Override public void drawButton(Minecraft mc, int mouseX, int mouseY, float partialTicks){ //判断按钮是否可见(默认可见) if(this.visible){ //设置渲染混合模式及颜色模式(该处代码解释请查看lwjgl及OpenGL相关文档) GLStateManager.pushMatrix(); GLStateManager.enableBlend(); GLStateManager.blendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); GLStateManager.color(1.0F, 1.0F, 1.0F, 1.0F); //绑定组件贴图至贴图管理器 mc.getTextureManager().bindTexture(TEXTURECOMP); //绘制按钮贴图 this.drawTexturedModalRect(this.x, this.y, 0, 0, this.width, this.height); GLStateManager.disableBlend(); //结束渲染 GLStateManager.popMatrix(); } } }); } } ~~~ ### **文字的绘制** 在GUI中,你可以通过`FontRenderer`提供的 `drawString()` 方法在任何你想要的地方绘制文字。该方法的使用方法如下: ~~~ FontRenderer实例.drawString(需要绘制的字符串, 绘制字符串的左上角X坐标, 绘制字符串的左上角Y坐标, 文字的颜色代码(16进制)); ~~~ **警告:由于Mojang的某些“编程失误”,这里指定的字体颜色可能不会正常应用到文字上。这种问题通常是在你错误地使用GL11 API直接更改颜色后出现的** **建议通过**`GLStateManager.color(float, float, float, float)`**指定字体颜色。** 在很多时候,我们会希望绘制的字符串**根据语言自动改变**。此时我们可以使用 `I18n.format(String)` 方法来对语言文件进行适配。该方法用法如下: ~~~ String 字符串变量 = I18n.format(在lang文件中的键名); //lang语言文件中 键名=本地化字符串 ~~~ **警告:千万不要让本地化键与原版/其他Mod的本地化键重复,不然可能会覆盖其他mod的本地化键**<br> 通常情况下,如果我们想要在GUI背景上绘制文字,我们可以在`drawGuiContainerForegroundLayer()` 方法中完成绘制,该方法被用来在~~每个游戏刻(Tick)刷新时~~**每帧**绘制**GUI前景**上的元素。一个简单的例子如下(此段代码不为EOK中代码): ~~~ //覆写前景绘制函数(注意在该函数中的坐标均以背景贴图的左上角为(0,0)点) @Override protected void drawGuiContainerForegroundLayer(int mouseX, int mouseY) { //从语言文件中获取键名为"mymod.abcdefg"的字符串 String name = I18n.format("mymod.abcdefg"); //绘制字符串 mc.fontRenderer.drawString(name, 20, 20, 0x404040); } ~~~ 在按钮的 `drawButton()` 方法中你同样也可以进行文字的绘制。一般情况下我们会希望当鼠标移动到按钮上时显示该按钮的悬浮文字信息,这在EOK **基础研究台** GUI的按钮封装类中有一个很好的例子: ~~~ public class ButtonElementaryResearchTable extends GuiButton { ... @Override public void drawButton(Minecraft mc, int mouseX, int mouseY, float partialTicks) { ... if (this.visible) { ... //计算光标和按钮边框的位置关系 int relx = mouseX - this.x, rely = mouseY - this.y; //判断光标是否在按钮内 if(relx >= 0 && rely >= 0 && relx < this.width && rely < this.height){ //从语言文件获取相应字符串 String name = I18n.format("research.gui.pre") + I18n.format("research." + this.researchNode.getUnlocalizedName() + ".name"); 在光标旁绘制文字 mc.fontRenderer.drawString(name, mouseX + 5, mouseY + 5, 0x404040); } ... } } } ~~~ ## 物品槽的交互与绘制 在GUI中,我们通常需要**和该游戏对象的物品槽进行交互**,这就牵涉到了**服务端/客户端的同步问题**。幸运的是,Forge为我们接管了**从`Container`到`GuiContainer`的同步操作**(见本章开头图),因此我们只需要在Container中完成物品槽的添加即可。 这里我们直接使用EOK中**基础研究台**的Container代码进行说明: ~~~ public class ContainerElementaryResearchTable extends Container ...{ //新建用于存放“纸张”的物品槽 protected Slot paperSlot; //新建用于存放“笔”的物品槽 protected Slot penSlot; public ContainerElementaryResearchTable(EntityPlayer player) { super(); //将“纸”物品槽加入容器(SlotItemHandler中参数分别为 物品栈容器,物品槽标识ID, 在GUI中左上角的X,Y坐标) this.addSlotToContainer(this.paperSlot = new SlotItemHandler(tileEntity.getCapability(CapabilityItemHandler.ITEM_HANDLER_CAPABILITY, EnumFacing.DOWN), 0, 229, 12){ //限制物品槽中最多只能放置1个物品 @Override public int getItemStackLimit(ItemStack stack){ return 1; } }); //将“笔”物品槽加入容器 this.addSlotToContainer(this.penSlot = new SlotItemHandler(tileEntity.getCapability(CapabilityItemHandler.ITEM_HANDLER_CAPABILITY, EnumFacing.UP), 0, 229, 36){ @Override public int getItemStackLimit(ItemStack stack){ return 1; } }); //将玩家物品槽第一行(快捷栏)加入容器 for(int i = 0; i < 9; ++i){ this.addSlotToContainer(new Slot(player.inventory, i, 47 + i * 18, 174)); } } ... } ~~~ 这里我们还需要覆写`transferStackInSlot()`方法并将其返回值设置为`ItemStack.EMPTY`以避免一些不必要的游戏特性 ~~(Notch的阴谋)~~ **警告:在Minecraft 1.12.2中,这里不能返回`null`,否则会导致游戏崩溃** ~~~ @Override public ItemStack transferStackInSlot(EntityPlayer playerIn, int index){ return ItemStack.EMPTY; } ~~~ 有时候,我们希望在GUI关闭时物品能够自动掉出,这时我们可以通过覆写`onContainerClosed()`方法来实现。例如在EOK**折射望远镜**这一`Container`中的相关代码如下: ~~~ public class ContainerRefractingTelescope extends Container ...{ ... @Override public void onContainerClosed(EntityPlayer playerIn){ super.onContainerClosed(playerIn); if(playerIn.isServerWorld()){ ItemStack paperStack = this.paperSlot.getStack(); if(paperStack != ItemStack.EMPTY){ playerIn.dropItem(paperStack, false); } } } } ~~~ ***** ## 附录 ### `GLStateManager`是啥?为什么要`pushMatrix()`, `popMatrix()`? `enableBlend()`和`blendFunc()`又是啥? 答:`GLStateManager`是Minecraft对`GL11`类的~~套娃~~封装,**它自己也会保存一套GL状态(如颜色,渲染模式等)**,Minecraft内部所有的渲染都用了这个类的方法。**因为它保存了所有GL状态,且不会主动获取GL11类存储的状态,直接调用GL11的API会导致原版渲染出现问题。**`pushMatrix`和`popMatrix`的作用是**使当前渲染时进行的图形变换不会影响到其他的渲染代码**。`pushMatrix()`和`popMatrix()`必须**成对使用**,否则会导致栈上溢或下溢。`enableBlend()`的作用是**开启颜色混合**,用于正确处理半透明像素的绘制;`blendFunc()`用来指定**如何混合颜色**,例如`blendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA)`代表“正常混合颜色”。**混色功能用完了一定要关上,不然可能会导致原版的渲染发生问题。** ***** ### `Gui`类的构造函数必须是固定的吗? 答:不,它是可以自定义的,你可以传入用户输入的数据等与实际渲染无关的参数,但构造函数里**绝对不能涉及到关于屏幕大小,界面偏移位置等换算**,**`initGui()`方法的代码也不能涉及到这种换算**。不能这么写的原因是**屏幕大小是游戏每帧在`drawScreen()`方法中获取的,在它被第一次调用之前,屏幕的宽和高都是0。** ***** ### `Container`类的`transferStackInSlot()`方法究竟是何方神圣? `transferStackInSlot()`是在玩家按住Shift点击物品槽的时候调用的,它用来决定按Shift点击物品槽后这个物品会被放到哪里。如果你不想写这个方法,必须要让它返回`ItemStack.EMPTY` > 想象一下一个背包中每个格子里都有63块木炭,现在你在熔炉中烧好了半组木块,也就是生成了32块木炭,你想要按住Shift直接放入物品槽中,那么最后的结果是要有足足32个物品槽受到影响。 设计一种一劳永逸的方式去解决这个问题显然不够现实,不过更重要的是因为Mojang独特的设计方式——Minecraft采取的办法是先进行一步,也就是试着把一个`ItemStack`的部分放入第一个可用的物品槽,如果**没有**可放入的物品槽则**返回`ItemStack.EMPTY`**,如果**仍然可能有**可放入的物品槽,则返回**旧的`ItemStack`**。 ——引自ustc-zzzz的教程 具体的使用方法请看[高级篇 - 3.更复杂的GUI](高级篇-3.更复杂的GUI.md)。 ***** ### `Gui`类的字段`width`和`height`是指什么的宽和高? 答:整个游戏窗口。 ***** ### `Gui`必须要有背景吗? 答:可以没有背景。 ***** ### 所有绘图操作必须在我的“界面”里进行吗? 答:绘图操作可以在整个游戏窗口的任何地方进行。 ***** ### 为什么我画出来的纯色图形被加上了贴图? 答:尝试`GLStateManager.disableTexture2D()`?**注意:用完了还要再`GLStateManager.enableTexture2D()`。** ***** ### 为什么不能绘制大于256*256的贴图?用什么方法可以绘制出这种贴图?如何缩放贴图?`Tessellator`又是啥?绘制出来的字体如何缩放? 答:见[高级篇 - 3.更复杂的GUI](高级篇-3.更复杂的GUI.md)