MCP&Forge的Mod教程 Extra编(4) – Gui

"知识的形成与权力的巩固总是相辅相成..."
-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个参数作用的图解.
E4-1
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便能查看它的所有派生类.

gui1
图为1.6.4的GUI派生类,在1.7.2中,多了个GuiLabel.

在MC1.6.4中,Gui类有8个直接的派生类,这8个类及其子类可分为如下三种:

  1. HUD
    这个名词有点扯...其实叫UI(用户界面)也是可以的,它是指玩家在游戏过程中看到的那些文字图标,比如下方的物品栏和生命值饥饿度,左上角的版本信息和除错信息(如果有的话).GuiIngame类及其子类GuiIngameForge类属于HUD.
  2. 窗口界面
    窗口界面是指一个玩家能够进行交互的界面,比如主菜单界面,多人模式下的服务器选择界面,或者游戏中玩家按下E键打开的物品栏窗口,打开箱子显示的库存窗口,窗口界面与HUD的不同之处在于窗口界面是可以打开关闭的,而HUD只要玩家在正常游戏便会始终显示.GuiScreen类及其派生类属于窗口界面.
  3. 控件
    控件通常无法独立存在,往往是依附在窗口界面中,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 在显示窗口时是否允许玩家等 先别激动,这个选项没法单独工作(看上去是个编程失误).表面上看当allowUserInput为真时,即使玩家开着窗口,也能继续进行移动等鼠标键盘操作(1.7.2的Minecraft类第1716行),但实际上这种情况默认是不存在的...因为所有的输入事件已经在之前(Minecraft类第1676行以及GuiScreen类的handleInput方法)被处理掉了,要想让这个选项生效,需要重写handleInput方法,在allowUserInput为真时跳过所有Mouse.next和Keyboard.next.或许它还有些其他用途,但我没发觉?
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时代制作的图...不过看上去和现在的流程依然相符.

E4-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() 不过这里我们在服务器代理器中没有操作,因此就省去了
		}
	}
}

E4-15

然后在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);

E4-16

测试一下,我们在主菜单最底部新增了一个按钮,同时移动了原来的"设置"和"退出"按钮的位置.不过新菜单什么都没有,显示后仅仅只有一片泥土背景(如果是游戏中显示则是一片透明的黑色背景).你可以通过ESC键来返回主菜单.

E4-3

E4-4

文字的绘制

我不知道有多少人会像我最开始接触图形编程时那样,认为绘制图形只需要每帧绘制变化的部分就好,事实上,早期的计算机图形确实是这样的...这种做法被称为脏矩形技术,用于在机能不足的环境上进行绘制,直至今日,仍有一些图形程序(比如一些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);

E4-6

按钮控件

按钮控件其实刚才我们已经使用了一次,不过这一次我们在界面类中编程可以更省事一些,不用费心思去写事件监听.接下来我们做一个退出到上一个界面的按钮.

按钮类最常使用的是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, "关闭"));

E4-7

文本框控件

文本框的使用比较麻烦,它涉及到两个问题,一个是控件焦点,如果界面中只有一个文本框控件的话还比较好办,如果有多个的话,你得判断当前是在哪个文本框中输入;另一个问题是长按按键输入问题,默认的界面是关闭键盘连续输入的,你需要在初始化界面时打开输入,还得在关闭界面时关闭输入.

文本框的构造函数有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();

E4-8

文本框可通过getText来获取内容.

纹理的绘制

之前我们提到过,我们通过Gui类的drawTexturedModalRect方法来绘制纹理,使用它绘制纹理需要先向渲染引擎中绑定纹理.

首先你要有一个纹理,先找一张256x256尺寸的图片,然后在你的mod的素材目录中找个地方复制过去,我习惯放在texture/gui中.这里我选了一张名称叫texture的png图片.

E4-10

然后需要添加纹理,在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的纹理

E4-9

如你所见...所有在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同理.

E4-11

瞬间我们的界面有了一种国产启动器的气势,不过看上去图片有些碍眼,但我们可以再渲染一个半透明的矩形.在func_146110_a下面加入:

drawRect((int)(width*0.1), (int)(height*0.1), (int)(width*0.9), (int)(height*0.7), 0x80FFFFFF);

E4-12

然后再启动看看.这次又多了一股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高度(即纹理中欲绘制区域的宽高),实际宽,实际高,纹理总宽,纹理总高.

E4-13

然后再看看效果,已经达到做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键就能打开新界面.

E4-14

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

进入游戏测试一下,你可以在按键设置中找到新设置的按键.

E4-17

如果要做到像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()));
}

E4-18

进入游戏观察一下效果,游戏的左上角会有一行欢迎文字(它还不会被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.htmlhttp://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)来取消纹理绑定,不过一个更有效率的方式是直接关闭掉纹理效果.

E4-19

于是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中不用进一步自己手动插值.