第8章 用户界面

本章将介绍游戏中常用的用户界面分类及原理,以及love常用的UI库。

用户界面

说用户界面感觉有点生硬,莫不如说UI可能大家认识的多一些。用户界面可以说是游戏中很重要的一个组成部分,好的用户界面可以很赚眼球,方便操作,但用户界面绝对不是游戏的核心,更多是的美术功夫。广义的用户界面是除了游戏的核心绘图部分(一般由相机控制)外,其他所有的与用户交互的图形界面(当然,命令行界面也算)。狭义的用户界面仅仅指在一定框架下的用户交互图形界面,这个框架一般有其固定的绘图风格。

用户界面原理

标签

标签是最基本的用户界面,由于它是单向传递信息,而不具备交互能力(实际上也可以),它也是最为简单的。最简单的,相信你已经用过了。比如:

1
love.graphics.printf("hello world",0,300,800,"center")

之所以用printf是因为它更容易排版,当然这里仅仅给出了横向排版,实际纵向的可以通过取字体高度来得到排版位置。当然你也可以通过猴子补丁来让他支持纵向排版比如:

1
love.graphics.printf("hello world",0,0,800,600,"center","center")

让他上下都居中,至于内部实现很简单,可以看别的ui库如何实现,这里不说了。
标签的变体还有文字框,输入编辑框,多行输入编辑框等等。

框架

与其说是ui,还不如说是背景图片。实际上就是画一个方框,或者将图片缩放到方框的尺寸。还用举例么?0.0
框架的变体还有窗体,选项卡等。

按钮

按钮是最为常见的UI了,因为它具备的基本的交互属性。这些虚拟按键,实际上也可以说是可视性比较好的实体按键。放在屏幕上一目了然,具有美观性和动态性,也拓展了键盘的不足,比如触摸屏或者按键绑定复杂。
按钮的逻辑实际上跟按键是一样的,它是通过碰撞盒的点检测来实现的。比如下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
local button = {}
function button:testInQuad()
-- 此处省略若干字-。-
end
function button:update()
self.hover = self:testInQuad()
self.down = self.hover and love.mouse.isDown(1)
if not self.pressed and self.down then
self.pressed = true
if self.onPress then self:onPress() end
elseif self.pressed and not self.down then
self.pressed = false
if self.onRelease then self:onRelease() end
end
if self.down and self.onHolding then
self:onHolding()
end
end
function button:draw()
love.graphics.setColor(255,255,255,255)
love.graphics.rectangle(self.x,self.y,self.w,self.h)
love.graphics.setColor(0,0,0,255)
if self.text then
love.graphics.setFont(self.font or love.graphics.getFont())
love.graphics.printf("hello world",self.x,self.y,self.w,self.h,"center","center")
end
end

上面的代码应该是一个最基本的按键架构了,定义部分暂时不说了,响应部分主要是对鼠标悬停,按下,释放和按住几个状态进行判断,然后进入相应的回调中。这里突然想起来一个对于win编程很常用的一个词,钩子,实际上我们之前常说的猴子补丁其实跟钩子是同样的原理。就是一个代码转向的问题。比如我们在按下回调中下一个钩子是什么样的呢:

1
2
3
4
5
6
7
if button.onPress then
local onPress = button.onPress
button.onPress = function(self)
--你想要添加的代码
onPress(self)
end
end

按键的变体还有切换型按键,多选框,单选框。

进度条

进度条也很常用,因为它不仅仅是用于读取进度,还能够表示数据大小。最简单的比如我们之前用到的飞机血条的表示方法:

1
2
3
4
5
6
7
8
9
bar = {}
function bar:draw()
love.graphics.setColor(100, 255, 100, 50)
love.graphics.rectangle("fill", self.x,self.y,self.w,self.h)
love.graphics.setColor(100, 255, 100, 250)
love.graphics.rectangle("fill", self.x , self. y, self.w*self.value/self.valueMax,self.h)
love.graphics.setColor(200, 255, 200, 250)
love.graphics.rectangle("line", self.x,self.y,self.w,self.h)
end

它真的很简单仅仅需要一个value/maxvalue就足够了,如果想显示数值可以仿造上面标签的形式。它可以不用矩形而用弧形,就用画arc那一套就行了。其他形状可以用图像剪切来做,以后再说。所有显示数值的都可以用它,因为比较直观。

滚动条

这里就不说了,就是一个移动的按键配合两个固定的按键,在按键的holding状态绑定其y轴的移动位置,再根据maxValue,currentLenth,currentValue来决定实际的位置。

按照个人的看法,有了上面几个,实际上对于游戏来讲就基本足够了,不过有一些人比较较真,喜欢全套的ui体系,就像我们之前vb里各种控件那样。实际上,是可以的,只是,除非你用于十分个别的情形,不然都是没有什么用处的。

布局

说到UI就要说UI的布局了,他的作用类似于把ui附着在一个虚拟的框架中,让他们的排列更加有序,也方便你控制。当设计分辨率更改的时候,ui能够对应的分布。布局能够有多种形式,有列表式的,直接像excel那样画出单元格,然后往里面填入相应的内容,你还可以控制单元格合并。还有一种是继承式的,首先你规定下一个位置的大小,然后每当新建一个新的ui时,按照你上一次规定的继承下来。当然,只有在你UI组织很有规律的情况下才用得到布局,一般而言手动的修改就够了。

9patch 九宫格贴图

9宫格贴图是为了方便图片拉伸,避免拉伸时产生变形的一种方法,它通过规定不能拉伸的部分和可以拉伸的部分来划分拉伸。之所以称为9九宫格,是因为要把图片切为#字型,其中四个角是不能拉伸的,左右边只能y轴拉伸,上下只能x轴拉伸,中间是任意拉伸。至于如何切分九宫格,有两种方法,一种是图片外的数据,就是在设计时,把能切分的部分用另外的文件标记出来,随着图片一起,以便读取。另一种是图片内标记,把图片最外一个框的一个像素作为标记,有像素标记的作为九宫格的切割起止点。(一般而言任何UI贴图都不会满格显示图片的,所以最外面的像素可以直接利用。)

常用的UI库

下面罗列一些常用的UI库,方便大家选择。

loveframes

笔者最早接触的love的UI库之一,记得作者是从某个js的UI库转码过来的,这个库最大的特点是比较全面,包含了几乎所有的桌面系统的控件,基本的不说了,复杂的比如选项卡,下拉菜单,右键菜单子菜单,提示框等都有集成。而且,而且也可以高度自由的定制主题(皮肤)表现。不过,对于一般游戏而言,用不上这么全面的UI系统啦。而且从内部实现上也比较复杂,存在一些已知bug,当然,它是纯lua的,你可以改成你想要的样子。作者已经明确表示不再更新了,所以现在能够看到的是别人的fork版本。

GOOi

个人比较喜欢的一套UI,虽然是安卓向的,但是桌面用起来也挺舒服的。而且它的文本具有多语言支持,代码支持连续调用,等等。文档也比较全,该有的也都有。所以比较推荐。

SUIT

个人另一个很喜欢的UI,它是立即式的,或者叫组件式的,他不会返回任何所谓对象,而是数据间的交换。基于这一点,他UI组件的注册和删除就很方便,只要你不加入到update回调中,它就不存在。没有什么烦人的表的加入,删除控制。虽然它真的很好,但是估计很多人用不太习惯它。

对于UI的注意事项

上面的代码要注意到UI对中文的兼容问题。有的ui并没有对语言进行检测,所以可能会出现排版问题。
中文的输入框和选字框,跟love的窗体不太兼容,导致看不到输入框的存在,而且不同版本的window这个问题的表现也不一样。所以对于有中文输入需求的同学,有几种方案,用FFI去获取选字框和输入框的列表,然后在游戏内部窗体重建。这种做法类似于很多大型游戏,比如魔兽,lol等,还有一种是内建输入法,并且屏蔽掉中文输入法。已知这些问题存在win平台的各个版本,目前尚未知mac平台的表现,android和ios由于使用虚拟键盘,也无此问题。
另外,还有个别中文字体显示乱码的,是因为目前版本的FreeType库存在一些问题,如果无法避免,可以用0.9.2版本love。(目前兼容性最好的版本)
对于某些游戏UI,与其使用第三方库,再动一次手术才能很好使用的,还不如单独的实现一些简化版本,必须血条,人物头像,人物状态栏,装备栏等等。
由于一般的UI会绘制在最顶层,有些时候还要考虑到点击穿透的问题,一定要注意。
另外,文字的画面输出一定要注意,由于文字是矢量图,它会产生小于1个像素的问题,如果小雨一个像素,显示起来就是按数值的百分比进行亮度减小,这样就意味着如果输出在诸如0.5像素这种清醒,字会很虚。因此,一定要输出前对位置进行取整。

编程时间

实现一个虚拟摇杆。虚拟摇杆在移动端是一个十分常见的虚拟设备,通过触摸来进行控制。其实原理也很简单。

设计阶段

  1. 虚拟摇杆仅在屏幕左下角的触碰有反馈。
  2. 虚拟摇杆可以任意指定起始位置,而对偏离位置进行反馈。(滑动)
  3. 虚拟摇杆会移动到允许范围内最大的位置。(为了更好的反馈转向)
  4. 虚拟摇杆放开时隐藏虚拟摇杆。
    这个虚拟摇杆比一般固定式的有一个好处是,玩家不需要用眼睛来寻找虚拟要改的位置,它只检测你手指的相对滑动来判断摇杆的偏移程度。另外,摇杆可以跟随手指的位置并达到最大的响应位置,这样,在玩家变换摇杆方向时会基于你手指位置进行,而不是最初的接触位置,更加灵敏。

代码阶段

摇杆的初始化

1
2
3
4
5
function joystick:init()
self.working=false
self.limit=50
self.stickSize=30
end

摇杆的working状态是为了标记摇杆的状态数据是否有效,因为摇杆在不接触时数据也是0,0。
limit是摇杆的最大偏移限制。size是高干中心球的大小。
一些有用的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function joystick:inBound()
if love.mouse.getX()< love.graphics.getWidth()/2 and
love.mouse.getY()> love.graphics.getHeight()/2 then
return true
end
end
function joystick:limitToRound()
local dist=math.getDistance(self.cx,self.cy,self.sx,self.sy)
if dist>self.limit then
local dx = (self.sx-self.cx)* self.limit/dist
local dy = (self.sy-self.cy)* self.limit/dist
self.sx= self.cx+(self.sx-self.cx)* self.limit/dist
self.sy= self.cy+(self.sy-self.cy)* self.limit/dist
self.cx=self.cx+(self.sx-self.cx)* self.limit/dist/10
self.cy=self.cy+(self.sy-self.cy)* self.limit/dist/10
end
end
function joystick:getValue()
if self.working then
self.vx= (self.sx-self.cx)/self.limit
self.vy= -(self.sy-self.cy)/self.limit
else
self.vx= 0
self.vy= 0
end
end

inBound方法是标记接触位置是否在左下角。如果不在这不响应摇杆。
limitToRound是让摇杆停留在里圆心最大的偏移上,如果大于偏移,则让摇杆缓缓移动向偏移位置。
getValue很简单了,获取当前摇杆的偏移值,用的是摇杆的位置和中心位置之差与最大位置的比值。所以结果是一个-1,1的小数。而放开摇杆,则返回0,0
更新原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function joystick:update()
if self.working then
if not love.mouse.isDown(1) then
self.cx=nil
self.working=false
return
end
self.sx,self.sy = love.mouse.getPosition()
self:limitToRound()
else
self.working=love.mouse.isDown(1) and self:inBound()
if self.working then
self.cx,self.cy = love.mouse.getPosition()
self.sx,self.sy = self.cx, self.cy
end
end
self:getValue()
end

首先要类似按钮一样,判断是不是not working到working。也就是按下状态。
如果是按下状态,那么定一个中心位置,并暂时的把摇杆位置定为与中心位置重合。
如果是按住状态,则实时的更新摇杆位置,并把中心位置移向摇杆位置的最大偏移。
如果是松开状态,则删除中心位置。标记not working.
绘制阶段

1
2
3
4
5
6
7
8
9
function joystick:draw()
if self.working then
love.graphics.setColor(255, 255, 255, 255)
love.graphics.circle("line", self.cx, self.cy, self.limit)
love.graphics.setColor(200, 200,200, 255)
love.graphics.circle("fill", self.sx, self.sy, self.stickSize)
end
--love.graphics.print(string.format("axis x: %0.2f; axis y: %0.2f",self.vx,self.vy),100,100)
end

如果处于按下或者按住状态(working)则绘制,对于中心使用线框,对于摇杆,使用填充圆。颜色自订。你也可以打印一下debug信息。string.format虽然是效率不是很高的函数,但很有用,请多加使用。

作业

  1. 根据自己的喜好,选择几种UI库,对着教程案例进行尝试,找到一个你比较喜欢的类型。
  2. 查看你喜欢的UI的源码,理解它的基本原理。并根据自己的需求做出合理的修改。
  3. 为之前我们做的飞机游戏做一点UI内容吧,比如飞机血量,可以把飞机和血条固定在左上角。分数固定在右下角,时间固定在右上角。你可以给飞机几条生命,固定在左下角。
  4. 把虚拟摇杆放到游戏中,并把飞机的控制方式改为摇杆控制。
  5. 把开炮放在虚拟按键上,然后弄一个炸弹。效果为全屏的敌机全部销毁,全屏的子弹全部销毁。做一个原型的进度条放在这个按钮上。cd为30秒。
  6. 给你的虚拟摇杆座和摇杆佩一个贴图吧。

本章代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
local joystick=class("joystick")
function joystick:init()
self.working=false
self.limit=50
self.stickSize=30
end
function joystick:inBound()
if love.mouse.getX()< love.graphics.getWidth()/2 and
love.mouse.getY()> love.graphics.getHeight()/2 then
return true
end
end
function joystick:limitToRound()
local dist=math.getDistance(self.cx,self.cy,self.sx,self.sy)
if dist>self.limit then
local dx = (self.sx-self.cx)* self.limit/dist
local dy = (self.sy-self.cy)* self.limit/dist
self.sx= self.cx+(self.sx-self.cx)* self.limit/dist
self.sy= self.cy+(self.sy-self.cy)* self.limit/dist
self.cx=self.cx+(self.sx-self.cx)* self.limit/dist/10
self.cy=self.cy+(self.sy-self.cy)* self.limit/dist/10
end
end
function joystick:getValue()
if self.working then
self.vx= (self.sx-self.cx)/self.limit
self.vy= -(self.sy-self.cy)/self.limit
else
self.vx= 0
self.vy= 0
end
end
function joystick:update()
if self.working then
if not love.mouse.isDown(1) then
self.cx=nil
self.working=false
return
end
self.sx,self.sy = love.mouse.getPosition()
self:limitToRound()
else
self.working=love.mouse.isDown(1) and self:inBound()
if self.working then
self.cx,self.cy = love.mouse.getPosition()
self.sx,self.sy = self.cx, self.cy
end
end
self:getValue()
end
function joystick:draw()
if self.working then
love.graphics.setColor(255, 255, 255, 255)
love.graphics.circle("line", self.cx, self.cy, self.limit)
love.graphics.setColor(200, 200,200, 255)
love.graphics.circle("fill", self.sx, self.sy, self.stickSize)
end
--love.graphics.print(string.format("axis x: %0.2f; axis y: %0.2f",self.vx,self.vy),100,100)
end
return joystick