"知识的形成与权力的巩固总是相辅相成..."
-Michel Foucault
经过了人生最长的一个假期后,大学生活终于开始了= =不过时间比高中时要充裕是肯定的,所以就抽空写一些东西吧...
这句话是我13年9月写的,你可以知道我的拖延症有多可怕了吧!
何为GUI?
GUI即图形用户界面,基本上说,你在游戏屏幕中看到的2D图像,比如血条、物品栏、菜单什么的,都属于GUI.
如何绘制GUI?
Minecraft使用OpenGL作为绘图库,由于其特性,所有的2D图像在本质上其实都是投影在3D视角上的2D纹理,因此net.minecraft.client.gui包下的Gui类提供了一系列2D绘图函数,用以辅助开发者方便地绘制2D图形.一切和界面有关的类都直接或间接继承自GUI类,GUI类提供的方法包括:
drawHorizontalLine(int x1, int x2, int y, int c)
绘制水平线段,极少被使用...x1和y是起点的XY坐标,(注:2D绘图坐标系以屏幕左上角为远点,X轴向右为正,Y轴向下为正.)x2是终点的X坐标,如果小于x1的话系统会自动纠正.c是一组8位的16进制数字,代表一个ARGB颜色.
(ARGB即Alpha(不透明度)Red(红色)Green(绿色)Blue(蓝色).用这3种颜色和1种属性能绘制出世界上绝大多数颜色.每一种颜色/属性对应一个2位的十六进制数字,即十进制的0~255.一组ARGB颜色的格式形如0xOOOOOOOO,如聊天框的颜色0x80000000即一个不透明度为128的纯白框.)
drawVerticalLine(int x, int y1, int y2, int c)
绘制垂直线段,同上,只不过x和y1为起点的XY坐标,y2为终点的Y坐标.
drawRect(int x1, int y1, int x2, int y2, int c)
绘制一个矩形,x1和y1为起点的XY坐标,x2和y2为终点的XY坐标,两个坐标写反了也没关系,系统会自动纠正,c为8位的16进制数字,代表ARGB颜色.
drawGradientRect(int x1, int y1, int x2, int y2, int c1, int c2)
同样是绘制一个矩形,只不过颜色是渐变的!
drawCenteredString(FontRenderer,str,x,y,c)
以点(x,y)为中心绘制文本str.使用文字渲染器FontRenderer,使用RGB颜色c.(RGB颜色和ARGB相比没有透明度信息,即你无法绘制透明文字)FontRenderer实例可以通过RenderManager.instance.getFontRenderer()来获取.
(事实上它不是绘制在中心,我的语文水平很难表述出它的位置...所以我作了个图,红点是点(x,y),黑框是文本)
顺便一提,drawCenteredString和下面的drawString支持Minecraft的字体样式,字体样式中的颜色设定会覆盖方法中指定的默认颜色.不过有时候需要注意使用顺序,比如同时使用颜色样式(如白色§f)和字型样式(如下划线§n)时,你可能必须把§f放在§n前面才能同时起效.
drawString(FontRenderer,str,x,y,c)
以点(x,y)为左上角绘制文本.
drawTexturedModalRect(x,y,u,v,w,h)
截取渲染引擎中绑定的纹理,并渲染到屏幕上.这个是最常用的绘图函数.
它的使用很特殊,你需要先向渲染引擎绑定纹理,首先你需要使用渲染引擎的bindTexture方法将一个ResourceLocation所指向的纹理绑定入渲染引擎.绑定完毕后你才可以使用drawTexturedModalRect来绘制纹理.这里有6个参数作用的图解.
x,y : 将要在用户屏幕上绘制的纹理的左上角坐标.
u,v : 被绑定纹理中,需要绘制部分的左上角坐标.
w,h : 需要绘制的部分的宽和高.
绘制的流程可以这样理解:
渲染引擎会截取被绑定的纹理,将纹理中一个以(u,v)为左上角,(u+w,v+h)为右下角的矩形区域截下来,并将其绘制到用户屏幕上,其左上角位于点(x,y).
需要注意的是,drawTexturedModalRect只能绘制纹理大小为256x256的纹理!这是因为它内部隐含了两个比例系数,用来将参数调整成适合OpenGL的形式,这两个系数是在假定纹理的长宽都是256的情况下制定的,若不为256的话,则无法正确地截取纹理,具体的解决方法是使用drawModalRectWithCustomSizedTexture,或func_146110_a.
drawTexturedModelRectFromIcon(int x, int y, Icon icon, int w, int h)
另一个版本的drawTexturedModalRect,用来绘制Icon.
drawModalRectWithCustomSizedTexture(int x, int y, float u, float v, int width, int height, float textureWidth, float textureHeight)
在MC1.7中它叫func_146110_a,跟drawTexturedModalRect相比,它可以正确绘制任意尺寸的纹理.不过你需要把纹理的宽高手动代进去.
顺便一提,Gui类提供的方法使用的都是屏幕坐标,即原点为窗口左上角,X轴向右为正,Y轴向下为正,这与我们大部分人的排版习惯(自上而下,从左到右)相符,目前看起来还挺好用,至少是目前...
Gui类虽然"强大",但它只是个工具包,Gui只是支画笔,我们还需要一张画布,在认识"画布"前,我们先来看一下Gui类的派生类.
过去我绘制了一张Gui派生类树状图,不过那已经是很早之前的版本了...(大概是MC1.2.5左右),如果你想检索所有的Gui派生类的话,直接找到位于net.minecraft.client.gui包下的Gui类,选择Open Type Hierarchey便能查看它的所有派生类.
图为1.6.4的GUI派生类,在1.7.2中,多了个GuiLabel.
在MC1.6.4中,Gui类有8个直接的派生类,这8个类及其子类可分为如下三种:
- HUD
这个名词有点扯...其实叫UI(用户界面)也是可以的,它是指玩家在游戏过程中看到的那些文字图标,比如下方的物品栏和生命值饥饿度,左上角的版本信息和除错信息(如果有的话).GuiIngame类及其子类GuiIngameForge类属于HUD. - 窗口界面
窗口界面是指一个玩家能够进行交互的界面,比如主菜单界面,多人模式下的服务器选择界面,或者游戏中玩家按下E键打开的物品栏窗口,打开箱子显示的库存窗口,窗口界面与HUD的不同之处在于窗口界面是可以打开关闭的,而HUD只要玩家在正常游戏便会始终显示.GuiScreen类及其派生类属于窗口界面. - 控件
控件通常无法独立存在,往往是依附在窗口界面中,GuiAchievement,GuiButton,GuiLabel,GuiNewChat和GuiTextField都是控件.
那么我们依次说起这三大类.由于控件无法独立存在,因此控件的介绍就放在另外两类中穿插介绍.下面首先介绍的是窗口界面.
窗口界面
正如刚才所说,窗口界面就是能够打开关闭的可交互界面,从石炉的操作窗口到游戏中的ESC菜单,甚至主菜单和游戏报错的窗口都是窗口界面.由于游戏中全部的窗口界面都是GuiScreen类及其派生类,因此只要研究明白GuiScreen类就能了解游戏中的窗口界面了.
GuiScreen类虽然不是个抽象类,但在游戏中直接创建并显示一个GuiScreen类没有什么意义,我们主要通过创建GuiScreen的派生类来定制我们自己的窗口界面.GuiScreen类的字段和方法包括如下:
字段:
字段名 | 类型 | 访问级 | 用途 | 备注 |
---|---|---|---|---|
itemRender | ItemRender | protected | 物品渲染器 | |
mc | Minecraft | public | 直接获取Minecraft类的实例 | 应视为只读变量来操作 |
width | int | public | 当前mc窗口的宽(即分辨率的x) | 应视为只读变量来操作 |
height | int | public | 当前mc窗口的高(即分辨率的y) | 应视为只读变量来操作 |
buttonList | ArrayList | protected | 记录该窗口界面内现有的按钮控件(GuiButton) | |
labelList | ArrayList | protected | 记录该窗口界面内现有的文字标签控件(GuiLabel) | 1.7.X下无用,因为GuiLabel在1.7.X下是未完成的... |
allowUserInput | boolean | public | 在显示窗口时是否允许玩家等 | |
fontRendererObj | FontRenderer | protected | 字体渲染器的实例 | 应视为只读变量来操作. btw,原来这个字段的名字叫fontRenderer,从1.7开始莫名其妙后面加了个Obj,是Forge工作组里哪位小天才在秀智商? |
selectedButton | GuiButton | private | 指示玩家按下的按钮(GuiButton) | private字段...不用细研究了 |
eventButton | int | private | 指示玩家按下的鼠标按键 | private字段...不用细研究了 |
lastMouseEvent | long | private | 上一次鼠标按键事件触发时的时间 | private字段...不用细研究了. lastMouseEvent = Minecraft.getSystemTime() |
field_146298_h(1.6.4) | int | private | 跟触摸屏相关... | private字段...不用细研究了 |
方法:
方法名 | 返回值/参数 | 访问级 | 用途 | 重写 (Override) |
备注 |
---|---|---|---|---|---|
构造函数 | 窗口界面不要求必须有一个非默认的构造函数 | 可 | 如果一定要用构造函数的话,只能在构造函数中初始化与绘图及控件无关的东西(比如分析输入来的数据,但决不能涉及到屏幕位置等换算) | ||
drawScreen | 鼠标的X坐标(int) 鼠标的Y坐标(int) delta(解释见附录)(float) 无返回值 |
public | 每一帧绘制窗口界面的方法,由系统调用 | 可, 建议调用基类的原方法 |
GuiScreen类的drawScreen负责完成按钮控件(GuiButton)的绘制工作,先于调用原方法的绘制将被按钮控件覆盖住,反之,后于调用原方法的绘制将覆盖在按钮上. 对于"距离上一次渲染的时间间隔"的解释将放在附录A1 |
keyTyped | 输入的字符(char) 按下的按键号(int) 无返回值 |
protected | 对键盘输入事件的处理 | 可, 建议调用基类的原方法 |
GuiScreen类的keyTyped方法用于实现按下ESC键后关闭窗口. |
getClipboardString | 无参数值 返回剪切板内容(String) |
static public |
返回剪切板内的文字内容 | 否 | 如果失败了,会返回""(非null空字符串) |
setClipboardString | 文字(String) 无返回值 |
static public |
设置剪切板的内容 | 否 | 吐槽一下,如果写入失败了的话,不会有任何提示或反馈,异常会被忽视. |
renderToolTip | 物品栈(ItemStack) X坐标(int) Y坐标(int) 无返回值 |
protected | 绘制工具提示 | 可 | 最终是通过调用drawHoveringText来实现 |
drawHoveringText | 字符串列表(List) X坐标(int) Y坐标(int) 字体渲染器(FontRenderer) 无返回值 |
protected | 绘制带有悬浮框的文本. | 可 | |
func_146283_a | 字符串列表(List) X坐标(int) Y坐标(int) 无返回值 |
protected | 对drawHoveringText的简单封装. | 可 | |
drawCreativeTabHoveringText | 字符串(String) X坐标(int) Y坐标(int) 无返回值 |
protected | 对只有一行文字的drawHoveringText的简单封装. | 可 | |
mouseClicked | 鼠标的X坐标(int) 鼠标的Y坐标(int) 按下的键(int) 无返回值 |
protected | 响应鼠标按下事件,由HandleMouseInput调用.0是左键,1是右键. | 可, 建议调用基类的原方法 |
GuiScreen类的mouseClicked方法用于监视按键控件(GuiScreen)的点击,如果不调用原方法的话就会造成按键点击不能. |
mouseMovedOrUp | 鼠标的X坐标(int) 鼠标的Y坐标(int) 标示(int) 无返回值 |
protected | 响应鼠标移动和松开按键事件,标志为-1代表为鼠标移动事件,0代表左键松开事件,1代表右键松开事件. | 可, 建议调用基类的原方法 |
GuiScreen类的mouseMovedOrUp方法用于监视按键控件(GuiScreen)的松开. |
mouseClickMove | 鼠标的X坐标(int) 鼠标的Y坐标(int) 标示(int) 无返回值 |
protected | 响应鼠标移动和松开按键事件,标志为-1代表为鼠标移动事件,0代表左键松开事件,1代表右键松开事件. | 可 | GuiScreen类的mouseMovedOrUp方法用于监视按键控件(GuiScreen)的松开. |
actionPerformed | 按下的按钮(GuiButton) 无返回值 |
protected | 当玩家选中一个按钮时引发. | 可 | 只有GuiScreen类的mouseClicked被执行才有机会触发这个方法. |
setWorldAndResolution | Minecraft实例(Minecraft) 宽(int) 高(int) 无返回值 |
public | 设置游戏屏幕的宽和高. | 可,但不建议 | 由Minecraft来调用. |
initGui | 无参数 无返回值 |
public | 初始化窗口 | 可 | 将初始化内容写在这里. |
updateScreen | 无参数 无返回值 |
public | 每tick都会被系统调用一次的更新窗口界面的方法 | 可 | 将逻辑更新写在这里. |
handleInput | 无参数 无返回值 |
public | 轮询所有鼠标和键盘操作 | 可 | 会调用handleMouseInput和handleKeyboardInput方法 |
handleMouseInput | 无参数 无返回值 |
public | 处理鼠标操作 | 可,建议调用基类的原方法 | |
handleKeyboardInput | 无参数 无返回值 |
public | 处理键盘操作 | 可,建议调用基类的原方法 | |
onGuiClosed | 无参数 无返回值 |
public | 当窗口界面被关闭时引发 | 可 | 值得一提的是,onGuiClosed被调用不代表这个界面就永远不会显示了,有可能是游戏打开了一个它的子界面,比如添加服务器界面(GuiScreenAddServer)在被关闭时是直接返回打开它的多人游戏界面(GuiMultiplayer)的实例,而不是创建一个新实例. |
drawDefaultBackground | V修正值(int) 无返回值 |
public | 用于绘制背景,通常在drawScreen的一开头便调用,不在游戏中时,它是绘制泥土背景,在游戏中时,它是绘制半透明的黑色背景,参数通常设为0 | 可,但不建议 | V修正值是泥土背景纹理的UV中的V的修正值,似乎没有什么用呢... |
doesGuiPauseGame | 无参数 返回是否要求暂停(boolean) |
public | 指示在单人游戏下打开此窗口界面时是否暂停游戏. | 可 | |
confirmClicked | 自定义参数A(boolean) 自定义参数B(int) 无返回值 |
public | 没有固定作用,通常是供子界面通过回调confirmClicked来将结果反馈给父界面,因此参数值的作用完全是由开发者来约定 | 可 | 比如,父界面打开了一个询问用户(如GuiYesNo)的子界面,子界面通过它来将用户的选择反馈给父界面 |
isCtrlKeyDown | 无参数值 返回结果(boolean) |
static public |
返回用户是否按下了Ctrl键 | 否 | |
isShiftKeyDown | 无参数值 返回结果(boolean) |
static public |
返回用户是否按下了Shift键 | 否 |
打开一个界面
打开界面通过调用Minecraft类的displayGuiScreen方法来完成.如果传入的参数是一个GuiScreen类或其子类的实例,那么就关闭(调用onGuiClosed)当前界面(如果有的话)然后打开(调用initGui)新界面;如果是null的话,在游戏中则关闭一切界面,未在游戏中则是显示主菜单.
顺便再附一张displayGuiScreen方法显示界面的流程,这是在1.2.5时代制作的图...不过看上去和现在的流程依然相符.
创建一个新界面
一个界面即GuiScreen类的派生类,它至少包含如下要素:
构造函数 - 用于初始化部分数据
initGui - 部署控件
drawScreen - 绘制界面,文字,纹理,别忘记调用基类.
换句话说,一个最简单的窗口类是这样:
public class YourGui extends GuiScreen{ private GuiScreen parentScreen; public YourGui(GuiScreen parent) { parentScreen = parent; //记下是哪个界面打开了它,以便以后返回那个界面 //在这里初始化与界面无关的数据,或者是只需初始化一次的数据. } public void initGui() { //每当界面被打开时调用 //这里部署控件 } public void drawScreen(int par1, int par2, float par3) { drawDefaultBackground(); //在这里绘制文本或纹理等非控件内容,这里绘制的东西会被控件(即按键)盖住. super.drawScreen(par1,par2,par3); //在这里绘制文本或纹理等非控件内容,这里绘制的东西会盖在控件(即按键)之上. } }
不过还有个问题是我们该如何显示这个界面,displayGuiScreen确实可以显示,但该在哪调用呢...过去我在测试GUI界面时喜欢随便改一个主菜单的按钮,但现在不能随便改了(虽然可以用FGOW...咳咳),不过Forge倒是提供了三个很好的事件来供我们魔改任意界面:InitGuiEvent.Pre,InitGuiEvent.Post和GuiOpenEvent.
InitGuiEvent.Pre事件是在调用界面的initGui之前引发,如果它被取消了(关于事件的取消参考Extra1),则initGui根本不会被调用,这个特性允许你可以自由地修改一个界面.(但是这里有个设计隐患,前文提到过一个界面实例可能会被反复打开,所以这个事件传给你的buttonList可能已经有内容了.似乎设计者希望开发者每次都手动clear一遍?)
InitGuiEvent.Post事件则是在ininGui之后引发,你可以在此进一步修改界面.
如果简单的修改无法满足你的需要的话,一位开源贡献者jk-5给我们提供了一个"一劳永逸"的银弹:GuiOpenEvent,这个事件的厉害之处在于它不但能中止一个打开界面的行为(简单地取消掉事件即可),还能偷梁换柱,替换掉将要打开的界面.
此外,Forge还提供了ActionPerformedEvent系列事件来监听按钮的点击,ActionPerformedEvent也分Pre和Post两个实际事件,不过它们的差别不大,仅仅是一个在actionPerformed调用前,一个在调用后,这里我们使用InitGuiEvent.Post和ActionPerformedEvent.Post来为主菜单添加一个新按钮.不过首先,我们要先做一个代理器(Proxy),用来执行客户端操作,老读者应该很熟悉代理器,因为原先(MC1.6之前)需要用代理器来注册纹理,因此教程第二章里就讲了这个,但后来因为没什么用就移除了...这里为新读者简单介绍一下,代理器是个用来区分执行客户端与服务器操作的东西,它的原理是FML会为客户端和服务器分别实例化两个类,其中客户端的类必须是服务器的类的子类,因为客户端的操作一般是"服务器操作+客户端Only的操作",其实"客户端Only"一般就是Gui之类的...因此接下来我们要创建一个代理器.
首先创建一个代理器类,我这里服务器代理器叫CommonProxy,客户端代理器叫ClientProxy,为了图省事直接把它弄成CommonProxy的内部静态类.同时为它加上init方法.
public class CommonProxy { public void init() {} public static class ClientProxy extends CommonProxy { @Override public void init() { //通常这里还会有super.init() 不过这里我们在服务器代理器中没有操作,因此就省去了 } } }
然后在mod主类中加上:
@SidedProxy(modId="diracon", //此处为你的modid serverSide="net.hakugyokurou.diracon.CommonProxy", clientSide="net.hakugyokurou.diracon.CommonProxy$ClientProxy") public static CommonProxy proxy;
之后别忘了在mod主类的init或preInit中加入代理器的init调用...
(上次不知为何我忘了写上这点...于是坑了不少人...)
@EventHandler public void preInit(FMLPreInitializationEvent event) { proxy.init(); }
然后在你的客户端代理器中加入:
//不知道有没有人能发现这个梗,能看出这个的出处的话那一定是相当厉害了 private GuiButton btnShowNewGui = new GuiButton(223, 0, 0, "To open the door, use the code: 0012"); @SubscribeEvent public void guiScreenShow(InitGuiEvent.Post event) { if(event.gui instanceof GuiMainMenu) { GuiScreen screen = event.gui; for(Object o : event.buttonList) { GuiButton btn = (GuiButton)o; if(btn.id == 4) //退出游戏按钮的id btn.xPosition = (int)(screen.width * 0.75); if(btn.id == 0) //选项按钮的id { btn.yPosition = (int)(screen.height * 0.7); btn.xPosition = (int)(screen.width * 0.75); } } btnShowNewGui.xPosition = (int)(screen.width * 0.5) - 100; btnShowNewGui.yPosition = (int)(screen.height * 0.8); btnShowNewGui.width = 200; event.buttonList.add(btnShowNewGui); } } @SubscribeEvent public void guiClickButton(ActionPerformedEvent.Post event) { if(event.button == btnShowNewGui) { Minecraft mc = Minecraft.getMinecraft(); mc.displayGuiScreen(new YourGui(mc.currentScreen)); } }
然后在你的客户端代理器init方法中加入:
MinecraftForge.EVENT_BUS.register(this);
测试一下,我们在主菜单最底部新增了一个按钮,同时移动了原来的"设置"和"退出"按钮的位置.不过新菜单什么都没有,显示后仅仅只有一片泥土背景(如果是游戏中显示则是一片透明的黑色背景).你可以通过ESC键来返回主菜单.
文字的绘制
我不知道有多少人会像我最开始接触图形编程时那样,认为绘制图形只需要每帧绘制变化的部分就好,事实上,早期的计算机图形确实是这样的...这种做法被称为脏矩形技术,用于在机能不足的环境上进行绘制,直至今日,仍有一些图形程序(比如一些2D的手机游戏或html5游戏,以及大部分客户端程序)使用这种"局部重绘"的方式.相比之下,现代的电脑游戏则奢侈得多,几乎所有的游戏都是每次绘制时清空整个图形缓冲,然后重新绘制一遍.
文字的绘制通过在drawScreen中调用drawString和drawCenteredString来完成,这里我们在屏幕上添加一个欢迎文本以及显示当前鼠标坐标的文本.
在drawScreen中的super.drawScreen下面加入:
drawCenteredString(fontRendererObj, "Your §f§nPROUD§r first screen", width/2, (int)(height*0.2), 0xFFFF00); drawString(fontRendererObj, String.format("You are pointing to: (§o%d§r,§o%d§r)", par1, par2), (int)(width*0.05), (int)(height*0.9), 0xFFFFFF);
按钮控件
按钮控件其实刚才我们已经使用了一次,不过这一次我们在界面类中编程可以更省事一些,不用费心思去写事件监听.接下来我们做一个退出到上一个界面的按钮.
按钮类最常使用的是GuiButton类,它也是其他专用按钮类的父类,它有两个构造函数,6参数版本是GuiButton(int id, int x, int y, int width, int height, String text),参数含义分别是"按钮ID","左上角X坐标","左上角Y坐标","宽","高","文字内容",4参数版本省略了宽和高,使用的默认值是200,20.
在YourGui类中加入:
private GuiButton btnClose; @Override protected void actionPerformed(GuiButton button) { if(button == btnClose) //改成button.id==0也行 mc.displayGuiScreen(parentScreen); }
在initGui方法中加入:
buttonList.add(btnClose = new GuiButton(0, (int)(width*0.75), (int)(height*0.85), 80, 20, "关闭"));
文本框控件
文本框的使用比较麻烦,它涉及到两个问题,一个是控件焦点,如果界面中只有一个文本框控件的话还比较好办,如果有多个的话,你得判断当前是在哪个文本框中输入;另一个问题是长按按键输入问题,默认的界面是关闭键盘连续输入的,你需要在初始化界面时打开输入,还得在关闭界面时关闭输入.
文本框的构造函数有5个参数,第一个是字体渲染器,一般直接使用Gui类自带的fontRendererObj,另外四个参数是"X坐标","Y坐标","宽","高".
在YourGui类中加入:
private GuiTextField tfInput; @Override protected void keyTyped(char par1, int par2) { if(tfInput.textboxKeyTyped(par1, par2)) //向文本框传入输入的内容 return; super.keyTyped(par1, par2); } @Override protected void mouseClicked(int par1, int par2, int par3) { tfInput.mouseClicked(par1, par2, par3); //调用文本框的鼠标点击检查 super.mouseClicked(par1, par2, par3); } @Override public void onGuiClosed() { Keyboard.enableRepeatEvents(false); //关闭键盘连续输入 }
在initGui方法中加入:
Keyboard.enableRepeatEvents(true); //打开键盘连续输入 tfInput = new GuiTextField(fontRendererObj, (int)(width*0.5)-150, (int)(height*0.85), 300, 20); tfInput.setMaxStringLength(64); //设置最大长度,可省略 tfInput.setFocused(false); //设置是否为焦点 tfInput.setCanLoseFocus(true); //设置为可以被取消焦点
在drawScreen方法的super.drawScreen下面加入:
tfInput.drawTextBox();
文本框可通过getText来获取内容.
纹理的绘制
之前我们提到过,我们通过Gui类的drawTexturedModalRect方法来绘制纹理,使用它绘制纹理需要先向渲染引擎中绑定纹理.
首先你要有一个纹理,先找一张256x256尺寸的图片,然后在你的mod的素材目录中找个地方复制过去,我习惯放在texture/gui中.这里我选了一张名称叫texture的png图片.
然后需要添加纹理,在YourGui类中添加:
private ResourceLocation texture = new ResourceLocation("diracon", "textures/gui/texture.png"); //第一个参数是modid
然后在drawScreen方法的super.drawScreen上面加入:
mc.renderEngine.bindTexture(texture); //绑定纹理 drawTexturedModalRect((int)(width*0.5)-128, 0,0,0,256,256); //绘制一个256x256的纹理
如你所见...所有在super.drawScreen之前渲染的内容都会被按钮控件遮盖.此外你也可能注意到纹理的最下部分超出屏幕边界了,换句话说MC在默认屏幕大小和默认GUI尺寸下,窗口高度在内部逻辑上是小于256的...大约只有239左右.
另外,drawTexturedModalRect方法有个缺陷,就是它只能"正确"绘制256x256尺寸的纹理,这是因为它的一个系数是假定纹理都是256x256尺寸的,为了绘制任意尺寸的图片,我们可以使用func_146110_a(MC1.7),或者叫drawModalRectWithCustomSizedTexture(MC1.8)...
弄一个新的纹理,这里我找了个尺寸为1440x900的jpg图片.改好ResourceLocation,然后将drawTexturedModalRect替换为:
func_146110_a(0, 0, 0, 300, width, height, 1440, 900); //中间的0,300是UV偏移,根据你的纹理随意定吧.1440,900同理.
瞬间我们的界面有了一种国产启动器的气势,不过看上去图片有些碍眼,但我们可以再渲染一个半透明的矩形.在func_146110_a下面加入:
drawRect((int)(width*0.1), (int)(height*0.1), (int)(width*0.9), (int)(height*0.7), 0x80FFFFFF);
然后再启动看看.这次又多了一股Metro风,纳德拉先生会自豪的,快拿去给巨软安利一下,说不定MC1.9就变成Metro风格了...我先去撸一管压压惊
顺便一提,drawModalRectWithCustomSizedTexture不能缩放绘制出的纹理,对此我们可以使用drawScaledCustomSizeModalRect,或func_152125_a,不过有个小问题是这个方法到1.7.10时才出现,如果要在1.7.2下用的话,可以自己把代码复制到你的界面类中:
public static void drawScaledCustomSizeModalRect(int x, int y, float u, float v, int uWidth, int vHeight, int width, int height, float tileWidth, float tileHeight) { float f4 = 1.0F / tileWidth; float f5 = 1.0F / tileHeight; Tessellator tessellator = Tessellator.instance; tessellator.startDrawingQuads(); tessellator.addVertexWithUV((double)x, (double)(y + height), 0.0D, (double)(u * f4), (double)((v + (float)vHeight) * f5)); tessellator.addVertexWithUV((double)(x + width), (double)(y + height), 0.0D, (double)((u + (float)uWidth) * f4), (double)((v + (float)vHeight) * f5)); tessellator.addVertexWithUV((double)(x + width), (double)y, 0.0D, (double)((u + (float)uWidth) * f4), (double)(v * f5)); tessellator.addVertexWithUV((double)x, (double)y, 0.0D, (double)(u * f4), (double)(v * f5)); tessellator.draw(); }
将func_146110_a替换为:
drawScaledCustomSizeModalRect(0, 0, 0, 0, 1440, (int)(1440*(height/(float)width)), width, height, 1440, 900); //参数分别为x,y,u,v,u宽度,v高度(即纹理中欲绘制区域的宽高),实际宽,实际高,纹理总宽,纹理总高.
然后再看看效果,已经达到做GalGame的水平了.
在游戏中打开界面
目前我们是在主菜单中打开界面,如果我们希望在游戏中通过按键打开界面的话,可以通过监听按键事件来实现.
(如果你想修改游戏中的ESC界面的话,按刚才魔改主菜单的方式魔改GuiIngameMenu就行了)
过去FML提供了KeyBindingRegistry和KeyHandler来监听键盘事件,后来从MC1.7开始,FML改为使用更轻量级的KeyInputEvent事件来监听键盘操作.不过有一点需要注意,就是在MC1.7中,KeyInputEvent使用的是FML的游戏事件总线,而不是Forge的.
在客户端代理器的init方法中加入:
FMLCommonHandler.instance().bus().register(this);
然后在客户端代理器中加入:
@SubscribeEvent public void keyListener(KeyInputEvent event) { if (Keyboard.getEventKey() == Keyboard.KEY_K) //获取按下的按键并判断 { Minecraft mc = Minecraft.getMinecraft(); mc.displayGuiScreen(new YourGui(mc.currentScreen)); } }
然后在测试前,先将YourGui类的drawScreen中的绘制纹理(drawScaledCustomSizeModalRect)注释掉,不过待会它会挡住游戏背景.
之后进入游戏测试,在游戏中按K键就能打开新界面.
GuiScreen默认在单人游戏时会暂停游戏,你可以在YourGui类中重写doesGuiPauseGame来阻止暂停游戏.
@Override public boolean doesGuiPauseGame() { return false; }
不过,使用刚才那种监听按键的方式没法让玩家更改按键,因此我们可以使用一个更高大上的东西:KeyBinding.
KeyBinding即游戏中的按键绑定,它可以在游戏设置中更改,KeyBinding的构造函数有3个参数,分别是"文本Key","默认按键的Keycode"和"分类Key","文本Key"是这个按键的文字描述在语言文件中的键,"默认按键的Keycode"就是默认按键的键盘值,比如上文的Keyboard.KEY_K,"分类Key"就是按键分类在语言文件中的键.
KeyBinding在实例化后需要通过ClientRegistry.registerKeyBinding来注册,之后通过调用实例的isPressed来获取是否按下,监听位置可以选择监听KeyInputEvent,也可以选择监听TickEvent,甚至是在任何地方执行都行,因为KeyBinding能累计按键的状态.由于这个特性,有时我们在调用isPressed时会采用这种写法:
while(keyBinding.isPressed()) { ... }
这样,如果玩家在一个tick内按下多次按键的话,这种写法能够全部捕捉到.需要注意的是,isPressed的实现原理是在KeyBinding内部有一个计数器来累计按键的按下次数,调用isPressed其实就是判断计数器是否大于0(如果大于0则还要递减),如果你想规避这个特性,可以使用getIsKeyPressed.
那么接下来我们就把原来的系统替换为使用KeyBinding来实现,在客户端代理器中加入:
public static final KeyBinding kbShowNewGui = new KeyBinding("diracon.key.shownewgui", Keyboard.KEY_K, "diracon.keytitle");
在客户端代理器的init中加入:
ClientRegistry.registerKeyBinding(kbShowNewGui);
然后将keyListener中的Keyboard.getEventKey() == Keyboard.KEY_K替换为:
kbShowNewGui.isPressed()
然后还有添加按键文本,按照基础篇的方法添加语言文件,然后加入:
diracon.keytitle=Mod Keys diracon.key.shownewgui=Show screen
进入游戏测试一下,你可以在按键设置中找到新设置的按键.
如果要做到像E键打开的物品栏界面那样,在界面中按相同的键就可以关闭菜单,可以通过重写GuiScreen类的keyTyped.
在YourGui类的keyTyped的super.keyTyped(par1, par2)的上面加入:
if(mc.theWorld != null && par2 == ClientProxy.kbShowNewGui.getKeyCode()) //mc.theWorld!=null是判断当前是否在游戏中 mc.displayGuiScreen(parentScreen);
然后从initGui中删掉Keyboard.enableRepeatEvents(true),在mouseClicked中的tfInput.mouseClicked(par1, par2, par3)的下面加入:
if(tfInput.isFocused()) Keyboard.enableRepeatEvents(true); //只有当输入框成为焦点时才打开键盘连续输入,这是为了防止玩家打开菜单时按住按键不放,从而导致菜单打开后又立刻关闭的情况. else Keyboard.enableRepeatEvents(false);
之后再测试一下,你现在已经可以像物品栏界面那样,按K键直接关闭新界面了.
在游戏中绘制
之前我们提到Minecraft的HUD类是GuiIngame,实际上安装了Forge的MC使用的HUD是GuiIngameForge,Forge的这层"暴力封装"为我们修改HUD提供了很大的便利.
所有与HUD绘制相关的事件都位于RenderGameOverlayEvent类中,具体的有如下四种:
RenderGameOverlayEvent.Pre:绘制一个元素前发布,取消的话则不会绘制此元素
RenderGameOverlayEvent.Post:绘制完一个元素后发布
RenderGameOverlayEvent.Text:继承自Pre,主要用来绘制左右两边的文字信息,比如Debug信息,还有Demo版信息.
RenderGameOverlayEvent.Chat:继承自Pre,用来绘制聊天信息
HUD元素有如下几种:
元素名 | 说明 | 备注 |
---|---|---|
ALL | 代表绘制整个HUD之前和之后 | 如果在pre中取消的话,那么整个HUD都不会被绘制了... |
HELMET | 南瓜的头盔效果或物品的renderHelmetOverlay | |
PORTAL | 站在下界传送门时屏幕的紫色效果 | 不包括视角模糊的效果... |
CROSSHAIRS | 准星 | |
BOSSHEALTH | Boss的血条 | |
ARMOR | 装甲值 | |
HEALTH | 生命值 | |
FOOD | 饱腹度 | |
AIR | 氧气值 | |
HOTBAR | 下方的物品栏 | |
EXPERIENCE | 经验值 | |
TEXT | 显示在屏幕左侧和右侧的文本,比如Debug内容 | 没有Pre |
HEALTHMOUNT | 坐骑的生命值 | |
JUMPBAR | 马的跳跃蓄力 | |
CHAT | 聊天信息 | 没有Pre |
PLAYER_LIST | Tab键的玩家列表 | |
DEBUG | Debug信息 |
这里我们演示在游戏中以文字形式显示玩家生命值,同时加入一行新的文字,在客户端代理器中添加:
@SubscribeEvent public void playerHealth(RenderGameOverlayEvent.Pre event) { if(event.type == ElementType.HEALTH) { event.setCanceled(true); //取消掉事件来阻止原图标的绘制 int width = event.resolution.getScaledWidth(); int height = event.resolution.getScaledHeight(); Minecraft mc = Minecraft.getMinecraft(); String hp = String.format("Health: %d/%d", MathHelper.ceiling_float_int(mc.thePlayer.getHealth()), MathHelper.ceiling_double_int(mc.thePlayer.getEntityAttribute(SharedMonsterAttributes.maxHealth).getAttributeValue())); FontRenderer fontRenderer = mc.fontRenderer; fontRenderer.drawStringWithShadow(hp, width / 2 - 91, height - GuiIngameForge.left_height, 0xFFFFFF); //字体渲染器在渲染时会重新绑定到字型纹理上,由于一些"编程失误",HUD在下一步绘制时不会重新绑定纹理,因此需要我们在此手动绑定. mc.renderEngine.bindTexture(Gui.icons); } } @SubscribeEvent public void playerName(RenderGameOverlayEvent.Text event) { event.left.add(0, String.format("Welcome, %s!", Minecraft.getMinecraft().thePlayer.getCommandSenderName())); }
进入游戏观察一下效果,游戏的左上角会有一行欢迎文字(它还不会被Debug信息遮盖住),原来图标形状的生命条被替换成了文字形式.
绘图器的使用
之前我们一直在使用Minecraft的上层API,现在我们要使用MC中最底层的API之一,与OpenGL只有一线之隔的绘图器(Tessellator).
想从名字上了解Tessellator的作用几乎是不可能的,因为它的直译细分器与图形学上的细分技术八竿子打不着,我开始以为这是MCP中某个人随便起的名字,后来发现Tessellator的一个异常信息中有一句"Not tesselating",可见"Tessellator"就算不是它真正的名字,也八九不离十.
它的真正作用是对OpenGL的立即模式的一个高效率封装,立即模式是OpenGL最早的绘图模式,通过CPU逐顶点地发送数据给显卡(按OpenGL的专业术语是客户端向服务器发送,不过既然我们还没说到OpenGL,就先用通俗的画来解释)来绘制,在那个"巫毒和炸药"横飞的年代(老前辈们应该熟悉那时候猎奇的显卡名字),通过立即模式来绘制寥寥几百个多边形组成的场景并不显慢,无论是对那些刚刚被从软件渲染中解放出来的玩家,还是对不用再自己徒手写光栅器的程序员来说,都足够了.而且即使再不济,OpenGL还提供了显示列表(Display List),它可以将一组固定的OpenGL指令和它们的参数进行化简.
然而随着硬件机能和业界门槛的提升,逐顶点地发送指令成为了水桶中最短的那块板,比如这段C代码是绘制一个平面:
glBegin(GL_QUADS); //开始 glNormal3f(-1,0,0); //绘制 glTexCoord2f(1,1);glVertex3f(1,1,0); glTexCoord2f(1,0);glVertex3f(1,0,0); glTexCoord2f(0,0);glVertex3f(0,0,0); glTexCoord2f(0,1);glVertex3f(0,1,0); glEnd(); //结束
你应该可以想象到它是有多么累赘,对一个由有纹理有法线的三角形组成的多边形来说,每顶点平均需要2.3条指令.显示列表虽然可以高效绘制,但它的数据是固定的,对于一个动态的模型,比如一个正在行走的人,就无法使用显示列表了.
于是OpenGL从1.5开始提供了顶点数组(Vertex Array,简称VA),它引入了顶点索引的概念,然后允许直接把顶点数据存储在内存(不是显存)中,当需要绘制多边形时,只要传入各个顶点数组的指针就能完整绘制整个多边形,省去了函数调用的开销,再配合显示列表(那些指针是固定的,因此可以作为参数储存在显示列表中),可以实现更高效的绘制.
另外OpenGL从3.0开始,有了更魔性的绘制方式:VertexBufferObject(简称VBO)和VertexArrayObject,它们是直接把数据存储在显存中...
Tessellator就是一个通过VA来提高效率,操作方式模仿OpenGL立即模式的工具,其实早期的Tessellator还会在显卡支持VBO的时候启用VBO进行绘制,然而MC从1.7开始却放弃VBO,只使用VA了,似乎Mojang认为MC的渲染效率瓶颈不在渲染方式上.
Tessellator可以直接使用它的静态实例,也可以自己创建一个实例,正如上面立即模式下的OpenGL绘图代码一样,Tessellator也分为开始,绘制,结束这三个阶段.
开始阶段的方法是startDrawing和startDrawingQuads. startDrawing的参数可以是:
GL_POINTS
GL_LINE_STRIP
GL_LINE_LOOP
GL_LINES
GL_TRIANGLE_STRIP
GL_TRIANGLE_FAN
GL_TRIANGLES
GL_QUAD_STRIP
GL_QUADS
GL_POLYGON
(注:它们位于GL11类中.)
关于它们的定义可以参考http://www.cnblogs.com/minggoddess/archive/2010/12/15/1907175.html和http://www.opentk.com/doc/chapter/2/opengl/geometry/primitives.其中最常用的是GL_TRIANGLES和GL_QUADS. startDrawingQuads就相当于startDrawing(GL11.QL_QUADS).
在绘制阶段,有这些方法可用:
addVertex:绘制一个顶点,相当于glVertex3d. (给不了解OpenGL的人科普一下,这里的3d不代表三维,而是参数为3个double...下面的2d同理)
addVertexWithUV:绘制一个顶点并指定它的UV,相当于glTexCoord2d+glVertex3d(但参数顺序是先坐标,后UV).
setTextureUV:设置接下来绘制的顶点的UV,相当于glTexCoord2d.需要注意的是,这里的UV的范围是0.0~1.0,0.0相当于0(原点),1.0相当于最边沿.
setColorOpaque_F:设置接下来绘制顶点的RGB颜色,相当于glColor3f.需要注意的是,颜色的范围在0.0~1.0,1.0相当于255(纯色)
setColorRGBA_F:设置接下来绘制顶点的RGBA颜色,相当于glColor4f.
setColorOpaque:setColorOpaque_F的整数版,范围在0~255.
setColorRGBA:setColorRGBA_F的整数版.
setColorOpaque_I:类似setColorOpaque,但把RGB按0x00RRGGBB的格式压缩在一个整数中作为参数.
setColorRGBA_I:类似setColorOpaque_I,但多了个Alpha.
disableColor:调用此方法后,以上的跟颜色相关的方法将不再生效.感觉是很不常用的东西...
setNormal:设置接下来绘制的顶点的法线,相当于glNormal3f.
setTranslation:设置接下来绘制的顶点的原点位置,相当于在模型视角矩阵中先glLoadIdentity再glTranslated.
addTranslation:偏移接下来绘制的顶点的原点位置,相当于在模型视角矩阵中glTranslatef.
setBrightness:设置自身光照亮度,这是个新鲜玩意,不过要留到以后再说,毕竟2D绘制几乎用不到它,而且这里篇幅有限嘛...它的参数的计算公式是:([环境光照强度] << 20 | [自身发光强度] << 4),光强度范围是0~15.因此,如果参数是15728880的话, It's gonna be a sunny day at Area 51...
此外还有个setVertexState和getVertexState,用于直接设置或读取Tessellator的状态,正常情况下Tessellator的状态是可以自动推导的,因此这两个主要是用来debug的...
最后,我们在结束阶段通过调用draw来结束绘制.
那么接下来我们就用绘制器来画一个静态的透明2D图片本章的结尾.还记得YourGui类中被我们注释掉的背景图片吗?现在我们要重做它,让它在游戏中以半透明的形式渲染.
在YourGui类中的drawScreen,将那行被注释掉的绘制纹理命令替换成:
final int textureWidth = 1440; //纹理的宽 final int textureHeight = 900; //纹理的高 float vHeight = 1440*(height/(float)width); float factorX = 1.0F / textureWidth; //将UV从整数归一化到实数的系数. float factorY = 1.0F / textureHeight; GL11.glEnable(GL11.GL_BLEND); //打开OpenGL混合模式 Tessellator tessellator = Tessellator.instance; tessellator.startDrawingQuads(); if(mc.theWorld != null) //如果是在游戏中,则修改颜色,增加透明效果. tessellator.setColorRGBA(255, 255, 255, 64); tessellator.addVertexWithUV(0 , height, 0, 0 , vHeight*factorY); tessellator.addVertexWithUV(width, height, 0, textureWidth*factorX, vHeight*factorY); tessellator.addVertexWithUV(width, 0 , 0, textureWidth*factorX, 0 ); tessellator.addVertexWithUV(0 , 0 , 0, 0 , 0 ); tessellator.draw(); GL11.glDisable(GL11.GL_BLEND); //用完了别忘了关掉
你会注意到这里面乱入了个glEnable和glDisable,这两个是用来打开和关闭OpenGL状态的,你以前可能也听说过OpenGL是一个状态机,它的状态就是通过这两个指令来开关,OpenGL的介绍要到以后才有,但考虑到有些人可能读完这一章就足够了,而且有没有下一章还是一回事...因此这里还列出了在绘制MC2D界面时可能会用到的状态:
GL_BLEND 混合,计算机实现透明的原理是将透明物体的颜色与背景底物按比例混合,混合有多种算法,不过最常用的是glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA)(标准的混合效果,透明物体的Alpha越大越不透明)和glBlendFunc(GL11.GL_ONE, GL11.GL_ONE)(简单地将物体和背景的颜色相加,用来实现某些特殊效果).
GL_DEPTH_TEST 深度测试,深度测试用于判断物体的遮掩关系,在2D绘图中用不到深度测试,因为设计者总是希望后绘制的覆盖原来绘制的.如果你发现你按顺序绘制的东西莫名其妙地被遮住的话,尝试关掉深度测试.
GL_ALPHA_TEST Alpha测试,Alpha测试用来筛选具有特定Alpha值的像素,通常我们也用不到...如果你发现你正确设置了混合,而绘制出的透明纹理却不见了的话,尝试关掉Alpha测试.
GL_CULL_FACE 背面消隐,OpenGL区分正反面,而正反面由图元顶点的绘制顺序来决定,其中背面可以被简单理解为"不会被渲染",如果你不喜欢这个特性,就关掉它.
GL_TEXTURE_2D 使用纹理,有时候当我们想绘制一个纯色时,可以用glBindTexture(GL_TEXTURE_2D, 0)来取消纹理绑定,不过一个更有效率的方式是直接关闭掉纹理效果.
于是Gui教程就此结束了,拖了一年半的坑终于填上了!当然,以后还有新的坑要填...
附录 - 关于Delta
众所周知,Minecraft的Tick是每秒20次,这意味着每秒只有20次逻辑更新,然后渲染却是每秒60次或120次,怎么才能保证运动物体能够平滑移动?这便是Delta的存在意义,Delta表示"本次渲染距离上一个Tick相比经过了多少个Tick间隔的时间",比如在Minecraft的每秒20次Tick时间隔为50毫秒,那么Delta为0.2时,代表本次渲染是发生在上一次Tick之后的第50*0.2=10毫秒.
有了Delta后,游戏就可以对运动物体进行插值渲染了,比如如果在Tick1时物体在点P1以速度V进行运动,那么毫无疑问Tick2时物体在P1+V的位置,于是当一次渲染在Tick1和Tick2之间时,物体的位置就是在P1+V*delta.
不过,Minecraft使用的差值方式却大多采用P0+(P1-P0)*delta,因此实际上,MC渲染的始终是上一Tick的状态...或许他们认为这样插值移动更平滑一些.
游戏中,插值已经在中间层进行了,因此通常来说在Renderer中不用进一步自己手动插值.
运行环境 1.7.10
博主你好 = = MinecraftForge.EVENT_BUS.register(this); 这一句一旦执行就出错哎
我在guiScreenShow中加了输出 还有ClientProxy 的 init 里面加了输出 这两个函数都正常的走完的
但是这一句的输出却没有
public void guiClickButton(ActionPerformedEvent.Post event) {
if (event.button == btnShowNewGui) {
System.out.println("新的按钮!");
Minecraft mc = Minecraft.getMinecraft();
System.out.println("获取属性!");
mc.displayGuiScreen(new mygui(mc.currentScreen));
System.out.println("设置新的gui!");
}
}
}
然后就出错崩溃了= =
sz大大你好~这里有两个问题想要请教一下orz
1.如果我要绘制一张图,现在有IntBuffer的pixelBuffer(其实是从ScreenShotHelper里改过来的……)。我想要实现的效果是按下某个按键后将这张图片绘制在屏幕(特定的GuiScreen)上,但使用与图片相符的GL11.glDrawPixels参数以后得到的跟没画没什么区别……不大懂OpenGL……
2.如果我要重写某一个特定的键盘事件(比如F2截屏我想把它直接替换掉)有什么方法吗……
没用过glDrawPixels...我一般遇到这种情况是直接制成纹理再绘制 _(:з」∠)_ 想魔改键盘事件也挺麻烦,截屏事件是在Minecraft类中的dispatchKeypresses里,不用ASM的话也许只能魔改keyBindScreenshot,不过会影响到MC的键位绑定,如果玩家一打开设置就露馅了 233
为什么通过热键打开GUI在客户端能够运行,一放到服务器(我用的KCauldron-1.7.10-1614.188开服端)里怎么按都没反应?貌似ClientProxy里的内容就没加载。还有,如果要通过玩家名字获得对应的玩家实体,应该怎么做?