Musoucrow' BLOG


  • Home

  • Categories

  • Archives

  • Tags

  • Search

《DFQ》开发随录——打击感

Posted on 2019-04-30 | In Development | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  对于动作游戏(本文所谓的动作游戏具体指由FTG衍生而来的「超人系ACT」,如鬼泣、猎天使魔女等)而言,打击感自然是重中之重,本篇就来讲讲DFQ的打击感实现思路吧。
  首先要明确打击感的定义,本人将之定义为:攻击命中时产生的反馈。就这点而言,打击感并非是动作游戏的专属,凡是涉及到攻击交互的游戏都有。而动作游戏与之不同在于会对在攻击中附加控制效果(击退、击飞等),使得动作游戏成为了围绕打击感展开操作的游戏。
  而DFQ的打击感基本源于DNF,在此基础上加入个人的理解,接下来便一一讲解其中构成。

判定

  在产生攻击命中之前,自然得讨论如何触发了。众所周知,动作游戏讲究「所见即所得」——必须是看上去击中了,才算是命中。所以自然要使用一些方式去近似地模拟素材的边界范围,以此进行碰撞判定。而DFQ的做法则相当粗暴——直接构建一些近似的矩形,当然这矩形却不一般:
collider
  如图所示,人物拥有两种颜色的矩形,其中白色矩形表示人物的X-Z轴矩形,红色矩形表示为X-Y轴矩形。与一般的无纵深横版游戏(冒险岛、胧村正等)不同,DFQ这类可进行上下移动且滞空的横版游戏(DNF、三国战纪等)会去构造一种「逻辑上的三维空间」:
coordinate
  当然实际上可以直接使用3D矩形(立方体)进行构造,只是2D图形下不便表示,于是分解为两个矩形进行。自然地,判定时也是红对红、白对白。至于判定算法,由于DFQ没有太精细的需求,矩形不会参与旋转变换,故直接使用AABB即可。

击退

  话不多说,直接上图:
stun
  击退是打击感中最基本的元素了,当然将之命名为击退只是个人行为,在业界它有着各种各样的称呼,如stun、硬直、僵直、気絶等,在此一提。
  由图可见击退有两种不同的姿势(为了表现的丰富度),敌人会保持被击姿势一段时间,且变速位移一段距离。以函数的形式表示便是stun(time, speed, acceleration),time表示保持姿势的时间,speed为位移的初始速度,acceleration为速度的衰减值(也可以是加速值),通过acceleration来每帧减少speed,以此实现简单的变速运动效果。当然speed与acceleration并非是必须的(不带位移的击退),但time则必须有(无time不stun)。

击飞

  同样,直接上图:
flight1
  与击退同样,击飞也属于动作游戏里最核心的控制效果之一,它的别名也很多,如flight、击倒、倒地、浮空等。
  击飞这种控制效果在最初只是作为一种动画表现手法而已,一般用于敌人死亡、某些想表达击飞的招式等。敌人处于击飞动画时一般无法或难以继续进行互动。将之发展的据说是CAPCOM开发《鬼武者》时触发的一个BUG——敌人处于击飞时被后续攻击而产生了滞空效应。从此一发不可收拾,铸就日后《鬼泣》皇牌空战之名。
  而DFQ身为2019年的游戏,自然不可能落后:
flight2
  以上两张图基本可以窥得击飞之全貌了:

  • 击飞在状态上可分为上升、下落、倒地,上升与下落都会进行类似击退的变速运动,且根据进度改变姿势。
  • 姿势内容为击退的两个为基础外加它们的90度旋转版本及倒地。
  • 在击飞时被攻击会切换姿势且保持滞空一小会,形成了浮空连击的效果。
  • 除此以外便是X轴的位移效果了,这点与击退一致。
  • 倒地会根据浮空高度结算出「再击飞」,画面表现上为落地弹起,若高度不足则直接倒地。

  与击退类似,击飞的基本函数形式则是flight(speed_z, speed_x, acceleration_z_up, acceleration_z_down, acceleration_x),参数含义与击退类似,不再阐述。一般而言acceleration_z_up与acceleration_z_down会选择默认值,speed_x与acceleration_x则为可选项,但speed_z必须有(无speed_z不flight)。

特效

  继续上图:
effect
  特效其实没什么好说的,如果说动作姿势是描线,那么特效便是上色了。需要注意的是特效出现的位置一般得是矩形碰撞的交点处,这样才有「打中这个位置」的感觉。
  特效的种类一般就是斩、打、突、气四类(利器、钝器、锐器、魔法),外加出血之类等等,多多益善。
  顺带一提的是,由于特效算是一种创建销毁十分频繁的对象,值得为之做对象池以减少创建销毁的开销。

声音

  这下上不了图了,毕竟声音的可视化形式一般人类都看不懂(笑。
  由于打击感是攻击命中时产生的反馈,而反馈的形式自然不局限于视觉上的,听觉也相当的重要。当然这里讨论的声音可不仅仅是攻击瞬间产生的部分,还包括了整个招式过程。
  一般而言,一个招式基本会包含以下元素:

  • voice:如人物发招时的叫声,播放时机不限。
  • swing:如人物挥剑的声音、特效产生的声音,一般于运动帧时播放。
  • hitting:如刀砍到身上的声音,于命中时播放。
  • damage:敌人被攻击的惨叫声,于命中时播放。

  声音这部分在业界不少垃圾游戏可谓是偷工减料的重灾区,实际上万万不可忽视,毕竟有时候效果好不好就靠听个响(代表:拳皇)。

Hitstop、闪烁、抖动

  这仨放一块讨论是因为他们相辅相成:
battle
  Hitstop这玩意我对其没有准确的中文词汇,业界一般称为硬直、僵直、卡肉等(可见多容易与击退混淆)。其定义如其名般:因hit而导致stop。表现形式为人物停止运动一段时间,这里的运动包括位移、动画之类。Hitstop是敌我双方皆有的,我称本体的Hitstop为Selfstop,敌方为Hitstop。由此可见,卡肉这个说法其实很恰当,感受起来就像是一下刀卡到肉里了。一般Hitstop的高低可以用于表示攻击的轻重,以及像内功拳法、一闪刀法等延迟杀伤效果也可以通过高Hitstop达成。
  至于闪烁就更直白了,就是敌人表面有一层纯色遮罩渐变消失。值得注意的是,闪烁的运作也会受Hitstop影响,故在Hitstop期间闪烁是保持初始状态的,看起来敌人就是蒙上了一层白色。这么做可以使得命中的效果更为明显,在业界中《王者荣耀》也采用了这样的表现手法。
  抖动在图中也许看不太出来,主要就是设定个时间值以及抖动范围(xa, xb, ya, yb),人物在时间内就会随机位移,形成抖动的效果。与闪烁同样,在Hitstop时间是不会流动的,所以抖动与闪烁类似,基本上便是用于加强Hitstop的表现力。在业界中CAPCOM的《吞食天地》也采用了这样的表现手法。

后记

  以上只是打击感的一些机械的构成,实际上要做好打击感得充分利用许多元素,如场景震动、运镜、符合节奏的连击等。实际上它是一门导演的学问,要想培养就只能多抄多想多做。限于素材与平台,未能表现更丰富的元素,只能有待日后进军3D再说了。

《DFQ》开发随录——随机掉落

Posted on 2019-03-31 | In Development | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  随机掉落可谓时下RPG的流行设定,DFQ自然也不例外。而掉落业务自然也有其值得细说之处,不然也就不会有本文了(笑)。接下来将一步步引申出随机掉落的实现演进。

粗劣的实现

  在以往的开发生涯中,对于掉落业务,我采取了很粗劣的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
local random = math.random() -- 0-1
local pool -- drop pool
if (random < 0.1) then
pool = pools.normal
elseif (random < 0.3) then
pool = pools.rare
else
pool = pools.other
end
local index = math.random(1, #pool) -- Choice one.
local item = pool[index] -- Get an item.

  这种实现的槽点可谓数不胜数:掉落池的选取可谓暴力代码,而池中的道具也只能通过塞入相同的多份来扩充概率,对于概率的控制度很生硬。哪怕是将掉落池采取与道具相同的做法(将pools做成list)以去除暴力代码,对于概率控制度的问题依旧没有解决。且进行了两次取随机数,从概率而言并不纯粹。实际效果而言也导致了经常重复掉落,并不可取。

Alias Method

  那么如果选择将多个掉落池合而为一,使之只有一个list呢?
  如此确实能让概率纯粹了,但是对于道具概率的控制度依然很差。这个问题可以通过构建道具概率表({a = 0.1, b = 0.5, ...})以生成掉落池({a, b, b, b,...})解决。但这样生成的掉落池未免也太大了(最后可能会达上千个元素),这太不环保了,那怎么办呢?
  长达廿二年的人生经验告诉我:我们做的绝大多数事情都是前人做过的,遇到不会的问题看看前人是怎么做的就对了。果不其然,这就遇上了个合适的算法:Alias Method。
  本文并不打算详解其中的奥妙,这是愚蠢的复读机行为。直接上代码:

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
-- items: A list of probability of item.
function Alias(items)
local len = #items
local alias = {}
local probs = {}
local small = {}
local large = {}
for n=1, len do
items[n] = items[n] * len
local tab = items[n] < 1 and small or large
table.insert(tab, n)
end
while (#small > 0 and #large > 0) do
local less = table.pop(small) -- Remove the first element of list and return it.
local more = table.pop(large)
probs[less] = items[less]
alias[less] = more
items[more] = items[more] - (1 - items[less])
local tab = items[more] < 1 and small or large
table.insert(tab, more)
end
while (#small > 0) do
probs[table.pop(small)] = 1
end
while (#large > 0) do
probs[table.pop(large)] = 1
end
return alias, probs
end

  算法的代码量并不多,也就三十多行,输入参数items为道具的的概率list({0.1, 0.1, 0.5, ...}),即代表需要配套的paths来表示对应的道具标识({"stone", "potion", "gold", ...})。至于返回值alias, probs,先来看看获取随机掉落的代码:

1
2
3
local index = math.random(1, #paths) --- 1-n
index = math.random() < probs[index] and index or alias[index]
local path = paths[index] --- Item's path.

  以上代码很好理解,首先随机获取一个道具的索引,根据索引获取到probs[index]的值,与随机数(0-1)比较,由此可见probs存放的是一种运算后的概率值。若是随机数大于概率值,索引则改为alias[index],由此可见alias存放的是一种与原索引相对应的新索引,而新的索引自然会有对应的道具。
  如此我们便可理解这套算法的做法了:为每个道具设置一个概率值以及相对应的另一个道具,随机到一个道具后,仍需二次随机进行二选一。这么做很好理解,就是将一些高概率的道具填充到一些低概率的道具里:
example

  如图所示的第二项紫色的占比(概率)为1,表示不需要进行二次随机了,如此即可保证整个掉落池的概率是可以平分干净的(多出的部分就作为1概率项)。不得不说这种做法十分绝妙,完美解决了先前做法中掉落池元素过大的问题,美中不足在于需要进行二次随机,相对破坏了概率的纯粹性,但由于只是二选一,实际上效果是可接受的。

掉落池的维护

  虽说Alias Method方案的掉落池配置变得相当容易,只需如此这般填写概率值即可,再分别生成items与paths:

1
2
3
4
5
6
return {
["equipment/weapon/sword"] = 0.3,
["equipment/weapon/knife"] = 0.3,
["equipment/weapon/katana"] = 0.3,
["skill/flash"] = 0.1
}

  然而实际上掉落项的种类与数量都相当的多,并且会时常更改。所以这般直接的配置是无法满足需求的,于是演进为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
return {
skill = {
prob = 0,
item = {
flash = 1
}
},
["equipment/weapon"] = {
prob = 0.9,
item = {
sword = 0,
knife = 0,
katana = 0
}
}
}

  新配置明显就方便了不少,若是概率填写为0则表示剩余总概率的平均值(sword=0 => 0.9/3 => 0.3),且填写的概率是相对于本层的(skill的总概率为0.1,故flash=1 => 0.1)。算是基于原配置进行了一波封装,可维护性大幅提升,如此便可面对变化频繁的需求了。

后记

  本文所展示的掉落业务只是基础,在业界会有复杂度远超于此的需求(与时间、职业等因素挂钩,掉落池数量等),但DFQ的需求也仅此而已,期待日后能接触到更主流的设计。

在macOS搭建LÖVE for iOS平台

Posted on 2019-03-01 | In Teach | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  继上回在macOS搭建LÖVE for Android平台后,这次买了新的iPhone,对iOS平台的发布自然也要开始了。如上回一般,也发现了不少实操中会遇到的问题,特此记录,以便后人。

实机调试

  LÖVE for iOS的编译可谓相当容易,前提是你必须拥有一台macOS以及iOS设备,并且安装了Xcode。如官方教程所言般进行便是,大致上就是下载LÖVE源码工程,并且用Xcode打开love/platform/xcode/love.xcodeproj,然后选择love-ios项目、连上iOS设备、设置签名、然后Build就完事了。
0
  如上图所示那便是签名的设置了,需要登录Apple账号作为Personal Team,并确保这是iOS设备所使用的账号。
1
  签名设置完成无错误提示后,那便如上图所示般选择、执行即可。请确保iOS设备与macOS设备是处于连接状态的,过会便可见到iOS设备已将LÖVE安装完毕。此时尚无法直接运行,需要执行设置→通用→设备管理→Apple账号→信任love。
  刚装好的LÖVE仍是空空如也,你可以选择打包好一个项目作为test.love,使用Apple的隔空投送(AirDrop)功能进行快速传输,如下图所示:
2
  投送完毕后,iOS会精确的识别到这是LÖVE所需要的文件,于是你可以在LÖVE里见到它了,如下图所示:
3

画面适配

  我所测试的项目的功能很简单:显示一张图片、该图片会拉伸至窗口大小。直接运行的效果如下图所示:
4
  很明显可以得出两个问题:画面并不是水平的,以及顶部的状态栏没去掉。解决它俩的方法很简单,想要画面是水平的,就得在编译设置里进行更改,如下图所示:
5
  如此即可,至于顶部的状态栏的去除,选择编译设置里的Hide status bar是无效的,因为LÖVE在游戏运行时又做了一次设置。而这个设置则是与引擎的love.window.setFullscreen(fullscreen)这个API有关,只需要在游戏运行时设置为全屏即可关闭状态栏。如此便没毛病了,如下图所示:
6

开发调试

  在开发过程中需要不断地进行实机调试时,每次都对项目进行打包那效率未免也太低了。然而鉴于iOS的沙盒机制,又做不到如同Android般使用Git来进行同步工程。好在*.love文件本质上就是*.zip文件,如此开发一个对压缩包进行增量更新的脚本即可。如以下代码所示,用到了zip命令,脚本版本为Python3:

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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import os
import datetime
import zipfile
from os.path import realpath, dirname
def listdir(path):
paths = []
lst = path.split('/')
tree = file_tree
for i in range(len(lst) - 1):
tree = tree[lst[i]]
for k in tree:
v = tree[k]
p = k if v is True else k + '/'
paths.append(p)
return paths
def zip(code):
os.system("zip %s %s" % (file_name, code))
def sync(path):
is_file_a = os.path.isfile(path)
is_dir_a = os.path.isdir(path)
info = path in name_set and zip_file.getinfo(path)
is_file_b = info and not info.is_dir()
is_dir_b = info and not is_file_b
if is_file_a:
if is_dir_b:
zip('-d %s' % path)
if is_file_b:
time_a = os.stat(path).st_mtime
time_b = datetime.datetime(*info.date_time).timestamp()
if abs(time_a - time_b) > 1:
zip(path)
else:
zip(path)
elif is_dir_a:
if is_file_b:
zip('-d %s' % path)
if is_dir_b:
list_a = os.listdir(path)
for i in range(len(list_a)):
if os.path.isdir(path + list_a[i]):
list_a[i] = list_a[i] + '/'
list_b = listdir(path)
list_merger = list(set(list_a + list_b))
for p in list_merger:
sync(path + p)
else:
zip(path)
zip('-r %s*' % path)
else:
if is_dir_b:
zip('-d %s*' % path)
elif is_file_b:
zip('-d %s' % path)
cwd = dirname(realpath(__file__))
os.chdir(cwd)
file_name = 'game.love'
zip_file = zipfile.ZipFile(file_name, 'a')
zip_file.close()
name_set = set(zip_file.namelist())
file_tree = {}
for p in name_set:
ls = p.split('/')
tree = file_tree
length = len(ls)
for i in range(length):
s = ls[i]
if len(s) > 0:
if s not in tree:
tree[s] = True if i == length - 1 else {}
tree = tree[s]
sync('asset/')
sync('source/')
# ...

IPA发布

  以上只能本机运行而已,若是想分享给他人,便要解决新的问题了。iOS的安装包为.ipa文件,你需要提供正式的开发者账号作为签名,方可生成之。开发者账号分为以下三种:

  • 个人账号
    • 只能提供单人使用
    • 其他人若想运行ipa文件,需要注册其UDID
    • 99美元/年
  • 公司账号
    • 允许多个开发者使用
    • 需要填写公司的邓百氏编码(D-U-N-S Number)
    • 其他人若想运行ipa文件,需要注册其UDID
    • 99美元/年
  • 企业账号
    • 允许多个开发者使用
    • 需要填写公司的邓百氏编码(D-U-N-S Number)
    • 该账号下的APP不能发布到App Store
    • 299美元/年

也就是说,除了企业账号以外,想轻松分享给他人是比较麻烦的。这方面可以考虑诸如蒲公英一般的第三方签名平台,会更方便。
  IPA的生成方式为Xcode下Product→Archive,然后根据指示进行即可。顺带一提,在编译设置中将game.love文件加入到APP资源里,变会默认直接运行该项目,以达到发布的效果。如下图所示:
7

LuaJIT

  还有一个需要注意的问题是:Lua代码若是需要转为LuaJIT字节码,所选择的版本得是LuaJIT 2.1.0-beta2 64bit(在LÖVE 0.10.2下)。重点在于这个64位,由于Apple现行规定APP必须得是64位,于是连LuaJIT的字节码也必须同步。而想要生成64位的字节码,则必须编译出64位的LuaJIT,而这需要在编译时填写参数:make CFLAGS=-DLUAJIT_ENABLE_GC64。

后记

  总的来说iOS平台较之Android在编译方面更为简单,毕竟是很稳定的平台与设施嘛。但在开发方面则有更多的繁文缛节,也算是福兮祸兮吧。

《DFQ》开发随录——随机地图

Posted on 2019-01-30 | In Development | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  虽然先前未曾严明,但《DFQ》的全称为《DungeonFighterQuest》,由字面上便可得出,这是一款《DNF》的同人游戏,那么《DFQ》的地图自然向《DNF》看齐了。而《DNF》的地图众所周知,具有一定的复杂度,在以往的作品开发过程中便是采取了手动制作的方式,可谓十分的费时费力。于是在《DFQ》便采用了生成随机地图的方式,与市面上许多独立游戏的做法不谋而合,毕竟手动做地图实在是太辛苦了(汗)。本文便记录其中心得。

地图结构

map

  如上图所示,这便是一张随机生成的地图,它拥有以下组成:

  • 远景层:地图最底的背景,图中表现为山水。
  • 近景层:地图较近的背景,图中表现为树林。
  • 边上层:地图的上边界,拥有若干地图物件。
  • 地表层:地图的地板,图中表现为草地。
  • 边下层:地图的下边界,拥有若干地图物件。
  • 活动层:地图的主体,拥有若干活动的地图物件。

  在配置中以这种形式组成:

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
return {
info = {
width = {1440, 1280, 1024}, -- 宽度随机选择
height = {600, 736}, -- 高度随机选择
theme = "lorien", -- 地图主体
type = "dungeon", -- 地图类型
bgm = "lorien", -- 背景音乐
bgs = "forest1", -- 背景音效
name = {
cn = "洛兰",
kr = "로리엔",
jp = "ロリエン",
en = "Lorien"
} -- 用于显示的地图名称,拥有中日韩英四语
},
floorHorizon = 327, -- 地表层起始Y坐标
scope = {
x = 16,
y = 368
}, -- 可行走区域起始坐标
far = "$A/far", -- 远景层
near = "$A/near", -- 近景层
floor = {
left = "$A/tile/0",
middle = "$A/tile/2",
right = "$A/tile/1",
bottom = "$A/tile/3"
}, -- 地表层
sprite = { -- 图片
up = {
"$A/flower/0"
...
}, -- 边上层
floor = {
"$A/grass/0",
...
} -- 地表层物件
},
actor = { -- 活动对象
down = {
"$A/tree/0",
...
}, -- 边下层
article = {
"$A/tree/1",
...
} -- 活动层
}
}

  接下来将对逐层进行分析。

远/近景层

  远景层与近景层的机制完全一致,所以可以拿来一起说明。当然之所以会分为两个层次而非合并,是因为远景与近景关于摄像机移动时的相对移动速度不一样,以此形成纵深感。但在地图生成这一块,它们的机制是一致的:

1
2
far = "$A/far", -- 远景层
near = "$A/near", -- 近景层

  它们都是加载一张图片,然后根据地图的宽度进行平铺操作即可。在最后阶段会渲染成一张成品长图,这是一种优化方法。

边上层

  边上层为地图的上边界,拥有若干地图物件。这里的地图物件与其他层的并不一样,在配置里它的划分是sprite,仅仅是单纯的图片罢了:

1
2
3
4
5
6
sprite = { -- 图片
up = {
"$A/flower/0"
...
}, -- 边上层
}

  因为这些物件不需要与角色产生什么互动,最后也会如远/近景层一般,渲染成大块的成图。
  关于物件的放置,会采取生成宽高为100的格子铺满整行,并随机在这些格子上放置物件,如下图所示:
up

边下层

  边下层与边上层类似,但是生成的地图物件为活动对象(actor):

1
2
3
4
5
6
actor = { -- 活动对象
down = {
"$A/tree/0",
...
}, -- 边下层
}

  边下层的地图物件需要作为活动对象主要是因为某些物件会遮挡人物,所以需要采取靠近后透明化的措施。于是不方便作为单纯的图片。
  物件放置方面与边上层一致,这里不再复述,如下图所示:
down

地表层

  地表层即地图的地板,远/近景层类似,也是采取平铺的方针。但是在元素上更为多样:

1
2
3
4
5
6
floor = {
left = "$A/tile/0",
middle = "$A/tile/2",
right = "$A/tile/1",
bottom = "$A/tile/3"
}, -- 地表层

  地表层的图片分为左中右下四种,左右两种为于地图边缘进行随机选择(左/右或中),中为默认选择,下为平铺Y方向。
  除此之外,地表层还会拥有一些类似边上层的地图物件:

1
2
3
4
5
6
sprite = { -- 图片
floor = {
"$A/grass/0",
...
} -- 地表层物件
},

  这些物件也是不会与人物有所交互,最终与整个地表层渲染成大图。与边上/边下层类似,地表层物件的放置会XY平铺宽高为64的格子,以此放置:
floor

活动层

  活动层即地图的主体,活动对象的放置层,诸如障碍、宝箱、怪物等皆置于此。放置的规则与地表层物件一致,与地表层物件的不同之处在于,活动层存在一些拥有障碍的物件:
obstacle

  如上图所示,《DFQ》采用的障碍方式为传统的格子流,这种形式便于配合类似A星的寻路算法。但如此存在障碍格子与物件素材的匹配问题,这方面都需要手动设置好。以及需要警惕因障碍范围过大且恰好四周都是障碍物件围住了人物的情况,好在实际上并不存在这样的物件(障碍并不会很大),并不需要为此做特殊措施。

随机问题

  在处理诸如物件放置的问题时,切忌采用遍历+随机数判断的形式。因为这是不符合概率论的(存在放置数量上限),如此便会导致地图左边的元素多于右边(右边存在轮不到的可能)。所以得采取将格子存储在一个list中,以list[math.random(1, #list)]的方式提取要放置的格子,如此即可保证几率均等了。

后记

  对于一些需要个性添加的元素(地图特效、通行门、BOSS),一般会采取编写专门的处理函数进行添加。对于一些需要固定化的地图,也可以采取生成后输出成文件以加载使用。目前这套很明显的缺点在于无法生成崎岖不一的地形,不过目前暂无需求,且日后再看吧。

2018年度总结

Posted on 2018-12-31 | In Talk | | Visitors

  不知不觉中2018年即将过去了,与去年相同,特作总结,以为归纳。
  今年的Blog总共写了14篇,较之去年的13篇算是相差不大。但内容方面则更为单纯了,简要说来便是:DFQ、网络同步、ECS、实践记录。期望明年能有新的花样。
  今年的主要成果为对网络同步的涉猎,在帧锁定同步方面有《Brick & Ball》,在FPS/TPS同步方面有TPSDemo。收获了不少同仁的赞可,但都算不上是正式项目的实践,只能有待机会了。
  项目方面,《DFQ》的开发也算是正式进行了,这将是明年的总旋律。与今年类似,如无特殊情况则保持月更相关Blog。待最终完工开源后(也许)将会是一个很好的学习素材。
  平常时刻也有与ChawDoe进行一些作业方面的研究,涉及到诸如强化学习、哈希表、寻路算法、词法分析、UNIX/POSIX API、OpenGL等方面,也是获益良多,作为巩固基础了。
  总的来说,愈发感觉到计算机图形学是自己的待恶补项,以及3D游戏开发中的不少方面也值得实践,这将是明年的发展方向,期望在这些方面能有所建树,作出一些心得分享。
  以上便是本人的2018年度总结,今年下半年以来的社会经济状况不是很好,祈祷明年能够回暖吧。
  无双草泥马
  2018.12.31

《DFQ》开发随录——ECS

Posted on 2018-12-27 | In Development | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  在阅读本文之前,你需要了解一下何为ECS框架,今年年初本人也对此进行了相关研究。到了实际开发时发现确实有此需求,遂应用之。本文便记录其中心得。

ECS的意义

  在讨论实现细节之前,首先要弄明白一个关键问题:为何要用ECS? 对于我而言,使用ECS的意义在于使用传统OOP方式构造一个高度复杂的对象集时异常困难。在我看来,高度复杂的对象集即「多衍生物、衍生物之间多多少少拥有些共性」的存在。游戏中于地图上活跃的对象便是如此,拥有多种形式(物体、特效、子弹、NPC、怪物……),而这些衍生物之间多多少少会拥有一些共性(怪物和NPC都要寻路),如何组织安排好这些功能是很麻烦的一件事。在以往的开发生涯中,这部分我重构过很多遍,尝试过各种形式(将通用的功能做成子对象之类的),最终发现:ECS便是解决此问题的绝佳利器。

实现要素

relationship

  上图便是本项目ECS框架的结构了,大致介绍一二:

  • Entity(实体): Entity是对象的主体,Component的容器,在数据结构的形式上就是个哈希表。
  • Component(组件): Component是数据的容器,与Data对接,提取相关数据。Component的形式多样,如Transform、Aspect、Input等。Component只有构造函数。
  • Data(数据): 来自配置文件,其中定义了各Component的配置所需。可由Manager将Data作为参数创建Entity。
  • Group(群组): Group以Component作为条件筛选出合适的Entity集合,如此便可使符合条件的Entity运作相应的业务。
  • System(系统): 业务运作的主体,以Group进行筛选出合适的Entity以执行相应的业务。分为Enter, Init, Exit, Update, LateUpdate, Draw六个业务函数。System的形式多样,如Drawing、Life、Battle等。
  • Lib(库): 存放通用业务函数之处,原则上以具体所需Component为参数,而非Entity,如Hitstop(attacker, identity, time)。如此是为明确函数调用条件,以及可以使Component分别来自不同Entity,实现一些特殊需求。Lib的形式多样,如AI、Battle、Effect等。
  • Manager(中枢): 负责Group与Entity的管理,如AddComponent, DelComponent, NewGroup, NewEntity等。
  • Executor(执行): 整套系统的执行者,负责导入System,定义System的执行顺序以及提供System的执行场所。

使用演示

  以上便是ECS框架的组成元素了,接下来展示一下使用场景:

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
-- attributes.lua
local _ATTRIBUTE = require("actor.lib.attribute") -- Lib
local _Timer = require("util.gear.timer")
local _Base = require("actor.system.base")
local _Attributes = require("core.class")(_Base) -- System
function _Attributes:Ctor(upperEvent)
-- Filter
_Base.Ctor(self, upperEvent, {
battle = true,
attributes = true
})
self._timer = _Timer.New(1000)
end
function _Attributes:Update(dt)
self._timer:Update(dt)
if (not self._timer.isRunning) then
-- List is from group.
for n=1, self._list:GetLength() do
local e = self._list:Get(n) -- Entity
-- Component of Battle
if ((e.battle and not e.battle.isDead) or not e.battle) then
local attributes = e.attributes -- Component of Attributes
_ATTRIBUTE.AddHp(attributes, attributes.hpRecovery)
_ATTRIBUTE.AddMp(attributes, attributes.mpRecovery)
end
end
self._timer:Enter()
end
end
return _Attributes

  可以看到,这是属性相关的System,它提供了每秒回复HP与MP的业务。拥有Battle与Attributes组件的Entity方可执行,并且了ATTRIBUTE这个Lib的函数。采用这种形式只需要将业务分割为一个个System,以不同的Component组成游戏对象即可达到极高的灵活度。对于高度复杂的对象集而言可谓绝佳的解决方案。

子对象问题

  在开发的过程中,总会遇到诸如状态、技能、BUFF之类需要以子对象形式存在的情况。为此应当如何实现是ECS框架绕不开的一个问题。我曾尝试为他们也纳入至ECS框架中,但是这样会使得System的数量膨胀,而且并没有带来什么明显的好处(它们的独立性很高)。也曾试过为它们弄二级ECS框架,但感觉很刻意死板。最终领悟到了一点:ECS框架对我而言的意义,只是降低构建对象的复杂度,若是对象本身的复杂度并不高,采用OOP的方式完全可以接受。

后记

  ECS框架我只用在了地图对象,其余部分(如UI)等都是采用传统的面向对象形式,因为他们的构成复杂度并不高,切忌犯了“为用而用”的错误。当然对于Unity那边而言,ECS的意义在于达到高性能(内存连续、非GC、高Cache命中率、多线程),这时候为了高性能是不得不用了。当然Unity的ECS框架我所涉猎并不多,有待后日挖掘。

TPS游戏网络同步总结

Posted on 2018-12-02 | In Development | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  因友人的项目要做TPS联机对战游戏,本人遂对此进行了一番研究,经过四回的辗转反侧,Demo总算是做出来了。本次Demo是C/S一体化的设计,即服务端也是Unity做的(可选择1P兼任服务器或者将Unity以命令行模式运行于服务器)。网络模块采用了UDP+KCP,即先前BNB的强化版,而之所以没用UNet是因为之前搞出了乌龙所以换了现在这套,但序列化部分还是用的UNet。以上只是背景交代,本文仅聚焦于网络同步方面的细节。

实现思想

  如果你对这方面有所涉猎,想必大致了解何为状态同步。市面上的大多文章将其与帧锁定同步对立而论,但本人认为两者并非是对立的存在,关于这点这篇文章讲的非常清楚,希望读者不要拘泥于形式。在阐述详细的实现思想之前,我们先来看看FPS/TPS游戏的需求:

  • 非常迅速的操作反馈(若采用服务器应答后方有反馈的设计,很难达到要求,尤其是操作镜头) → 本地先行
  • 个人体验第一(对于是否命中敌人与被命中不是很敏感) → 玩家之间看到画面情况不一致
  • ACT元素低(不存在ACT游戏的打击控制链,不需要帧判定) → 不需要精确到帧的同步
  • 服务器权威(命中判定由服务器决定) → 服务端模拟游戏世界、同步验证
  • 房间战斗(玩家人数不多) → 与MMORPG同步不同
  • 相对同步(玩家之间的时间差不可拉得太大) → 追赶进度

  Well done,由以上几点需求已经得出了TPS游戏同步的实现思想,下文将根据实现思想阐述具体实现细节。

快照

  在探究同步流程之前,首先要了解同步的核心:快照。换言之,也就是我们所同步的内容。快照(Snapshot)通俗来讲就是玩家的操作指令与相关数据的集合,由于需要做同步验证,所以将数据分为必要数据(Must)与验证数据(Check),先来看看移动的快照数据结构吧:

1
2
3
4
5
6
7
8
9
// Actor/Common.cs
public class Move {
public string fd; // Address:Port(Must)
public int frame; // Game Frame(Must)
public bool fromServer; // It is from server, or client?(Must)
public Vector3 velocity; // Moving Velocity(Must)
public Vector3 position; // Position before moving(Check)
}

  如上文所示,position为移动前的坐标,像这类数据客户端是不需要上传的,仅用于与服务端传来的快照作对比,以进行同步验证。

同步流程

  由于服务端模拟游戏世界,所以采用了C/S一体化的设计。在代码层面上则是分为ServerMgr与ClientMgr两个MonoBehaviour,ServerMgr负责收集客户端的快照并整合下发,而ClientMgr负责发送快照与模拟来自服务端的快照以驱动同步单位的运行。如下图所示:
flow
  图中所说的同步快照,是一种特殊的快照列表,它由服务端每帧打包,包括了多个客户端的一帧快照,客户端模拟它们即可驱动其他客户端代表的对象。采用这种同步流程只能保证在客户端是同一帧生成的快照,在服务端也会打包到同一个同步快照里。除此之外都不会保证(不会考虑到快照之间的帧间隔执行情况),即不需要精确到帧的同步。

追赶进度

  在正常的同步过程中情况总是理想的,但是一旦出现网络延迟或卡住的话,在恢复之时便会面临大量的快照,那么按照现有的做法便会导致与其他玩家的时间轴拉得太远(看到的画面是很久以前的了),这便需要设计追赶进度的机制。需要注意的是,追赶进度是服务端与客户端都需要的(服务器也有网络延迟和卡住的可能),客户端的追赶处理相当简单,同步快照超过一个数量则循环模拟:

1
2
3
4
5
6
7
8
9
10
// ClientMgr.cs
if (this.syncList.Count > 0) {
this.Simulate();
// SYNCMAX = 15
while(this.syncList.Count > SYNCMAX) {
this.Simulate();
}
}

  服务端方面则较为复杂,简而言之就是要知道每个客户端快照列表有多少帧(如4个快照,帧号分别为1, 2, 2, 3,则为3帧),当某个每个客户端快照的帧数过高,则循环打包到同步快照列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ServerMgr.cs
var list = new List<Snapshot>(); // sync-snapshot
// Foreach all clients.
foreach (var i in this.unitMap) {
int frame = -1;
var sl = i.Value.list;
// INTERVAL = 10, i.Value.count that is count of frame.
while (sl.Count > 0 && (i.Value.count > INTERVAL || (frame == -1 || sl[0].frame == frame))) {
var s = sl[0];
list.Add(s);
sl.RemoveAt(0);
if (frame != s.frame) {
frame = s.frame;
i.Value.count--;
}
}
}

本地先行

  本地先行可谓这类同步最玄学之处,不过只要了解其原理倒也无甚。需要本地先行的理由在上文已经阐述,由于是以服务端权威且不那么介意判定的问题,所以是可以允许玩家之间看到画面情况不一致这种情况的。况且在大多数场合下,玩家先行并不会造成什么问题(最终的结果趋于一致),但假设在这么一个场合下:玩家A一直行走,在玩家B的视角里对玩家A进行了眩晕。如此便会造成不同步了,所以需要进行同步验证以将问题修正。
  要实现同步验证的思路倒也朴素:就是用一个验证列表将快照保存,当收到同步快照列表时就进行逐个对照(对比它们的验证数据,见前文),一旦发现不一致之处,就以当前位置开始,循环模拟同步快照,然后再继续循环模拟验证列表里进度比目前快的快照,追上最新进度:

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
// ClientMgr.cs
// Compare sync list and check list.
for (int i = 0; i < list.Count; i++) {
if (!list[i].Equals(this.checkList[i])) {
index = i;
print(i);
break;
}
}
if (index == list.Count) { // Agreement
this.checkList.RemoveRange(0, list.Count);
}
else { // Need to fix.
var frame = list[list.Count - 1].frame;
// Remove useless snapshots.
for (int i = this.checkList.Count - 1; i >= 0; i--) {
if (this.checkList[i].frame <= frame) {
this.checkList.RemoveAt(i);
}
}
// Loop simulate.
ClientMgr.Resolve(this.fd, list, index);
ClientMgr.Resolve(this.fd, this.checkList, 0);
}

服务端权威

  从上文可以看出,本地先行会修正的范围只有本地玩家而已,回到之前的例子:在玩家B的视角里对玩家A进行了眩晕,假设这个行为在服务端上并没有达成(玩家A闪现走了),那么该如何修正呢?很显然可以选择搞个更大的修正系统,但我认为这样并不符合业界的常规做法,所以我给出的答案是: 眩晕行为需要在服务端触发了,然后由服务端将其作为快照,以正常同步的形式在诸客户端上展示。事实上在网络正常的情况下,这样的间隔最多也只是0.1x秒左右而已,完全可以接受。当然这么做对于玩家B而言肯定会发生修正(眩晕按理来说是之前的事了),所以我对此作了个措施: 为快照设计了fromServer属性,一旦是fromServer = true且属于本地玩家的快照,本地玩家会直接模拟而不会将其进行修正对比。这也可以看出这套同步的一个规则:会影响他人的操作,都需要由服务端发起。

后记

  很显然,目前这个demo仍很不成熟,不少地方在业界应该会有更好的处理,如CS的射击纠正(服务端根据客户端的射击时间回滚之前的场景进行判定)。如此只能算是一个雏形,还是缺少实战项目的淬炼,先根据接下来的项目看看效果吧。

《DFQ》开发随录——界面

Posted on 2018-10-29 | In Development | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  在游戏开发的领域里,界面(User Interface)是不可或缺的,在一些强大的游戏引擎会为其配备一套解决方案。和之前的一系列问题一样,LÖVE自然是不会提供的,所以又得自己折腾一套了,本文便记录其中心得。

层级

  许多年前,因我年少没经验,写UI都是逐个显示对象并填写参数的。捞的嘛就不谈了,所以也就深刻意识到面向对象以及建立层级体系的重要性。所谓层级体系,也就是将显示对象之间根据需求建立起上下级关系,下级的显示数据会基于上级(如上级移动了坐标,下级也会随之改变),这种玩意在Cocos我称之为Layer-Node体系(如下图所示),在Unity则是以Transform组件实现。欣慰的是,在LÖVE11.0也追加了Transform,同时不幸的是,我用的是旧版本,所以最终是自己造了一遍轮子:(
layer

  在设计上我首先实现了集显示与层级管理于一体的对象——Renderer,不过在实现上有点用力过猛,把Shader的层级管理也做了(下级的Shader会与上级的合并)。完成了这个核心之后,再分别设计基本的显示元素(Sprite、Animation、Particle、Label、Layer),不过我并没有另它们继承Renderer,而是设计了一个基类,并将Renderer作为成员对象存在,主要是Renderer的信息量过大,实是不宜直接继承了。结构如下图所示:
uml

焦点

  要说UI对象与一般的显示对象最大的不同之处,那便是会接收来自玩家的触控输入了,尤其是作为手机游戏,会同时受到多个触控。再涉及到图层等问题后,便有必要建立一个焦点管理体系了。首先UI对象需要提供判定触控,以及按下、持续、弹起的接口。然后在UIManager提供接收触控输入、焦点管理、对象运行的服务。流程如下图所示:
focus

  至于“判定触控所在坐标是否有符合条件的对象”这一需求的实现,便与上文提到的层级体系相得益彰了:显示顺序为从尾到头,而判定自然也是从最顶部的显示对象开始的,于是乎只要将Layer的成员从尾到头遍历判定即可。至于不想参与判定的对象会设置专门属性跳过。

MVC

  这个便是老生常谈的设计模式了,去年我也对此作了一篇文章。简要来说便是,UI对象只负责接收输入(Controller)以展示结果(View),UI对象所保存的数据为展示而服务,真正的数据保存在来源对象(Model)。示例如下图所示(这里的Event按照C#的Event去理解即可):
mvc

  当然以上只是个人的理解,在我看来Controller即Model与View的桥梁,只要符合这个性质的存在即为Controller,它不一定是个固定的形式。包括按钮的按下处理函数这样的存在只要是由外部传入的,那么它也算是Controller。

配置

  都8012年了,自然不可能以手写代码的形式创建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
return {
name = "mapName",
script = "advanced/mapName",
x = 1100,
y = -30,
subject = {
{
name = "bottom",
script = "sprite",
sprite = "mapName"
},
{
name = "label",
script = "label",
font = "normal/18",
color = {
red = 241,
green = 218,
blue = 157,
alpha = 255
},
x = 90,
y = 42
}
}
}

  通过这般类似HTML的方式进行编写配置文件,交给专门的创建函数处理即可,具体的数据处理方式则交由对应的类(script)处理。若是开发了相应的UI编辑器还可以直接制作生成配置,很显然这里也是契合了层级体系,可见其重要性。

后记

  目前这套界面体系还缺乏相应的编辑器以及没有自适应布局的功能,不过实际上我也不太需要这些。只能说面向内部与面向公众的要求级别是不一样的,所以这并不能代表通用UI库的设计思想,仅供参考而已。

在macOS搭建LÖVE for Android平台

Posted on 2018-08-24 | In Teach | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  近日在搭建macOS下的LÖVE for Android遇到了不少问题,虽然有官方Wiki的帮助,但却发现了不少实操中才会遇到的问题,特此记录,以便后人。

JDK

  由于安卓SDK只支持到了JDK8,所以只好装JDK8,新版将无法打开安卓SDK。可选择前往官网或者使用Homebrew下载。使用Homebrew如此输入便可:

1
2
$ brew tap caskroom/versions
$ brew cask install java8

SDK

  由于谷歌的尿性,安卓SDK的官网上已无合适的安卓SDK提供下载了,只剩下Android Studio和Android SDK命令行工具。此命令行工具实际上缺少了不少东西,所以也不推荐。本人推荐前往AndroidDevTools进行下载,认准SDK Tools项便是。
  下载完成后便需要安装各种工具了,使用命令行运行SDK/tools目录下的android即可打开SDK安装界面,然后按照官方Wiki所言般安装相关工具即可。sdk
  值得一提的是Android Support Library,在谷歌官方源似乎已经找不到了,可以考虑换源或者直接下载官方文件,下载完成后置于SDK/extras目录下即可。

NDK

  这里官方Wiki便开始坑人了:Once you have the SDK tools you can get the NDK version r9d from here (Download acording to your system).事实上r9d会因为某个部件版本过低而对接失败,必须是小于15大于9的版本。去AndroidDevTools下载即可。

Ant

  这个没什么坑点,照常下载即可。不过ant将会作为一个常用命令去使用,所以推荐在Homebrew进行安装:$ brew install ant即可。

环境变量

  把love-android-sdl2下载后(当然我使用的是0.10.2版本,所以下载的是这个),仍需要配置环境变量,参考官方Wiki即可。大致上是在~/.bash_profile文件添加SDK与NDK的路径:

1
2
export ANDROID_HOME=/Developer/SDKs/android-sdk-macosx
export ANDROID_NDK=/Developer/Tools/ndk

  然后是在/etc/paths.d/android-sdk文件添加SDK的tools与platform-tools以及ant的bin目录:

1
2
3
/Developer/SDKs/android-sdk-macosx/tools
/Developer/SDKs/android-sdk-macosx/platform-tools
/Developer/Tools/ant/bin

编译

  环境变量部署完毕后,便可以开始编译LÖVE for Android工程了(ndk-build是NDK文件夹下的一个工具):

1
2
$ cd ~/repos/love-android-sdl2
$ ndk-build

  若是一切平安无事的话,便接近大功告成了!

发布

  首先需要在LÖVE for Android目录下新建assets文件夹,然后将游戏打包命名为game.love并放置过去。然后在LÖVE for Android目录下执行$ ant debug即可,稍等便会于bin目录下生成apk文件。
  当然这样生成的apk文件与从官网直接下载的APK包外表无异,所以仍需要定制化,参考此篇即可,不再复述。

后续问题

  • 由于在大多数硬盘格式上是不区分大小写的,而到了.love文件下则会区分。这点需要仔细检查。
  • 由于LÖVE for Android使用的LuaJIT版本为2.1,而普世平台(Windows/macOS/Ubuntu)使用的版本还在2.0.4,而2.1与2.0的字节码无法兼容,所以需要使用2.1版本进行生成。
  • 在手机上对于GLSL的格式要求更为严格,详情参考此篇,总体来说便是:不要写整数,小数点结尾也不要带f。

后记

  其实当年也曾在Windows系统部署过,却未曾有现在这般多坑,可见流行系统也有流行系统的好处啊。这篇文章似乎都可以投稿到他们仓库了呢(笑)

《DFQ》开发随录——资源管理

Posted on 2018-07-27 | In Development | | Visitors

  欢迎参与讨论,转载请注明出处。

前言

  在游戏开发的领域里,游戏资源的管理可谓一个很重要的基础功能,在一些强大的游戏引擎会为其配备一套解决方案。而LÖVE很不幸的再次没有提供,好在即使没有也起码做好了内存管理的工作,那么即便自己动手做一套也不是什么困难的事了,本文便记录其中心得。
  资源管理模块本质上只做了两件事:

  • 生命周期管理:保证多次加载时资源的复用,在无需该资源时进行销毁。
  • 配置接口:提供加载资源的API,以及外部化的资源配置。

  接下来便围绕以上两点展开说明其中的要点。

生命周期管理

  如上文所言,生命周期管理要做的事即:保证多次加载时资源的复用,在无需该资源时进行销毁。资源复用的实现思路非常的简单,使用哈希表将资源以路径-对象为映射关系进行存储即可,然后每次加载资源时进行一次检查,若存在表内则直接获取,否则再进行读取。

1
2
3
4
5
6
7
8
9
10
11
12
13
local poor = {}
local function GetResource(path)
if (poor[path]) then
return poor[path]
end
local res = open(path)
--... load resource.
poor[path] = res
return res
end

  接下来是第二个问题:在无需该资源时进行销毁。说得具体点便是:当外部没有对象引用该资源时,将其从资源池里移除。如此只需要使用弱引用即可,在Lua里即是建立弱表(weak table)。

1
2
local poor = {}
setmetatable(poor, {__mode = 'v'})

  如此当poor内存在外部无引用的对象时,在垃圾回收时便会将其移除。如此资源的生命周期管理便算完成了。

配置接口

  资源文件按照性质可以划分为两种:数据文件(二进制为主,如图片、声音等),配置文件(可编辑、可序列化的变量对象)。对于配置文件,Lua可以很方便地直接使用本体:

1
2
3
4
return {
x = 1,
y = 2
}

  只要将其读取后使用loadstring(text)()函数便可将其序列化,在其他引擎也有自定义配置格式以编辑器加持的形式解决,如Unity。现实情况中,一般数据文件会通过配置文件进行加载,即在配置文件提供对应资源的路径,然后由代码进行加载处理。

1
2
3
4
5
6
7
8
9
10
11
return {
image = "glow",
ox = 5,
oy = 5,
color = {
r = 255,
g = 255,
b = 255,
a = 127
}
}

  如上配置所示,此配置的image项将会由代码根据配置提供的路径glow进行读取对应目录下的image/glow.png文件。如此便可看出,资源与资源之间存在很强的联动性,它们就像是一棵树,节节相扣。对于那些较上层的配置文件而言,往往会从上到下牵涉巨多资源。这么做是很棒的,一加载便将所有相关的资源都加载了,只要在恰当的场合进行资源加载(如切换地图),核心游戏过程中则几乎不会涉及到加载了。

配置的健壮性

  在没有编辑器加持的情况下,单纯的配置文件健壮性是有限的,最突出的两个需求便是:

  • 快捷定位路径:如位于sprite/test/1.cfg的配置文件想要读取位于同路径、不同分类下的image/test/1.png文件,如果没有一些辅助手段,那么只能傻傻的输入全路径,十分愚蠢。
  • 参数注入:倘若存在一些大体相似,少部分不同的配置需求,若没有参数注入,那么只好傻傻的批量复制修改,也是十分的愚蠢。

  当然以上两点若是存在编辑器,自然可以无视并通过自动化手段之类达到相同的效果。但目前项目暂无编辑器,于是采用了替换文本的方案。

1
2
3
4
5
6
---sprite/test/1.cfg
return {
image = "$A",
sx = $1,
sy = $2,
}

  如上配置所示,$A便是代表当前资源分类下的路径,即替换为test/1,如此便可快速定位至image/test/1.png,算是一种语法糖吧。至于$1 $2则代表第1、第2个注入的参数,在调用的API的时候会以{1.2, 1}的形式作为参数填入。如此便会将$1替换为1.2,同理$2替换为1。当然这种注入了参数的配置在资源池的key是不能使用路径的(不是标准的),会在其后加入参数值成为:test/1|1.2|1。

配置的只读性

  由于资源对象往往都是独一一份,到处引用,倘若哪处不小心对其进行了修改,那么便会引起连锁反应影响全局。所以有必要考虑将资源对象设置为只读的:

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
local function _PrivateTips()
assert(nil, "The table is const.")
end
function _TABLE.NewConst(tab)
local tabMt = getmetatable(tab)
if (not tabMt) then
tabMt = {}
setmetatable(tab, tabMt)
end
local const = tabMt.__const
if (not const) then
const = {}
tabMt.__const = const
local constMt = {
__index = tab,
__newindex = _PrivateTips,
__const = const
}
setmetatable(const, constMt)
end
for k, v in pairs(tab) do
if (type(v) == "table") then
tab[k] = _TABLE.NewConst(v)
end
end
return const
end

  只要将对象拿去处理后,试图修改该对象时将会报错。当然这样做是有代价的:pairs()和table.getn函数将会变得无法直接使用,需要取出其元表方可使用。所以需要配备专门函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function _TABLE.Len(tab)
local meta = getmetatable(tab)
if (meta and meta.__index) then
return #meta.__index
else
return #tab
end
end
function _TABLE.Pairs(tab)
local meta = getmetatable(tab)
if (meta and meta.__index) then
return pairs(meta.__index)
else
return pairs(tab)
end
end

  这样子使用起来虽然麻烦了点,不过的确将资源对象和一般对象作出了明显的区分。另外只读处理的时机也需要考量的,一般得在整个资源对象处理完毕后才进行。

配置的个性化

  对于一些普遍的资源文件(图片、声音、精灵、动画等),一般配备专属的处理函数即可。但是到了业务层面情况往往会繁杂许多,将会存在许多个性化的配置格式。这时候便需要将业务对象和资源对象进行绑定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
return {
script = "$A",
tagMap = {
attack = true,
attackRate = true,
autoPlay = true
},
stopTime = 200,
endTime = 300,
nextState = "stay",
frameaniPath = "$0attack1",
actor = "bullet/throwstone",
bulletPos = {
x = 20,
y = 0,
z = -60
}
}

  如上配置所示,这是一个哥布林的投掷状态,这里的配置便需要提供子弹资源以及发射坐标了。关于这些个性化的配置项,将会如此解决:

1
2
3
4
5
6
7
8
9
10
11
12
local function _NewStateData(path, keys)
local data, path = _RESOURCE.ReadConfig(path, "config/actor/state/%s.cfg", keys)
data.class = require("actor/state/" .. data.script)
data.script = nil
if (data.class.HandleData) then
data.class.HandleData(data)
end
return data
end

  可以看到,通过配置的script项找到对应的脚本业务对象,然后调用其对象的HandleData函数进行解析。如此便解决了个性化的问题。

后记

  还是如上篇一般,这个问题对于流行的大引擎而言已经提供了成熟的解决方案。上了贼船呀,只能走到黑了。不过造造轮子也是有益技术的提升的。

1234…6
Musoucrow

Musoucrow

53 posts
7 categories
37 tags
RSS
Coding Github
© 2022 Musoucrow
Powered by Hexo
Theme - NexT.Mist