BYTEPATH #15 - Final
Introduction
In this final article we'll talk about a few subjects that didn't fit into any of the previous ones but that are somewhat necessary for a complete game. In order, what we'll cover will be: saving and loading data, achievements, shaders and audio.
在最后这一篇里,我们会补上一些前面没太方便塞进去、但对于一款完整游戏来说又确实少不了的内容。按顺序来说,主要会讲四件事:数据的保存与读取、成就、着色器,以及音频。
Saving and Loading
Because this game doesn't require us to save level data of any kind, saving and loading becomes very very easy. We'll use a library called bitser to do it and two of its functions: dumpLoveFile and loadLoveFile. These functions will save and load whatever data we pass it to a file using love.filesystem. As the link states, the files are saved to different directories based on your operating system. If you're on Windows then the file will be saved in C:\Users\user\AppData\Roaming\LOVE. We can use love.filesystem.setIdentity to change the save location. If we set the identity to BYTEPATH instead, then the save file will be saved in C:\Users\user\AppData\Roaming\BYTEPATH.
因为这个游戏并不需要保存关卡本身的数据,所以存档和读档会变得非常简单。我们会用一个叫 bitser 的库,以及它的两个函数:dumpLoveFile 和 loadLoveFile。这两个函数会借助 love.filesystem 把你传进去的数据写进文件,或者再从文件里读出来。正如文档里说的那样,保存位置会因为操作系统不同而不同。如果你用的是 Windows,默认路径会是 C:\Users\user\AppData\Roaming\LOVE。我们也可以用 love.filesystem.setIdentity 改掉这个目录。比如把 identity 设成 BYTEPATH 之后,存档就会保存到 C:\Users\user\AppData\Roaming\BYTEPATH。
In any case, we'll only need two functions: save and load. They will be defined in main.lua. Let's start with the save function:
总之,这里我们只需要两个函数:save 和 load。它们都写在 main.lua 里。先来看保存函数:
function save()
local save_data = {}
-- Set all save data here
bitser.dumpLoveFile('save', save_data)
endThe save function is pretty straightforward. We'll create a new save_data table and in it we'll place all the data we want to save. For instance, if we want to save how many skill points the player has, then we'll just say save_data.skill_points = skill_points, which means that save_data.skill_points will contain the value that our skill_points global contains. The same goes for all other types of data. It's important though to keep ourselves to saving values and tables of values. Saving full objects, images, and other types of more complicated data likely won't work.
这个保存函数非常直接。我们先创建一个新的 save_data 表,把所有需要保存的数据都放进去。比如,如果你想保存玩家当前有多少技能点,就写 save_data.skill_points = skill_points,也就是说把全局变量 skill_points 的值复制到 save_data.skill_points 里。其他数据类型也同理。不过有一点要注意:尽量只保存普通值,或者由普通值构成的表。像完整对象、图片资源、以及更复杂的运行时数据,通常都不适合直接存。
In any case, after we add everything we want to save to save_data then we simply call bitser.dumpLoveFile and save all that data to the 'save' file. This will create a file called save in C:\Users\user\AppData\Roaming\BYTEPATH and once that file exists all the information we care about being saved is saved. We can call this function once the game is closed or whenever a round ends. It's really up to you. The only problem I can think of in calling it only when the game ends is that if the game crashes then the player's progress will likely not be saved, so that might be a problem.
等你把该存的东西都塞进 save_data 之后,直接调用 bitser.dumpLoveFile,把这些数据写到 'save' 文件里就行。这样会在 C:\Users\user\AppData\Roaming\BYTEPATH 下生成一个名为 save 的文件,只要这个文件存在,你关心的数据就已经被保存下来了。这个函数可以在游戏关闭时调用,也可以在每一局结束时调用,怎么安排都行。我唯一能想到的风险是,如果你只在游戏退出时才存档,那一旦游戏崩了,玩家这一局的进度很可能就丢了,这点你得自己权衡。
Now for the load function:
再来看读取函数:
function load()
if love.filesystem.exists('save') then
local save_data = bitser.loadLoveFile('save')
-- Load all saved data here
else
first_run_ever = true
end
endThe load function works very similarly except backwards. We call bitser.loadLoveFile using the name of our saved file (save) and then put all that data inside a local save_data table. Once we have all the saved data in this table then we can assign it to the appropriate variables. So, for instance, if now we want to load the player's skill points we'll do skill_points = save_data.skill_points, which means we're assigning the saved skill points to our global skill points variable.
读档函数和存档函数的思路几乎完全一样,只不过方向反过来了。我们通过存档文件名 save 去调用 bitser.loadLoveFile,把读出来的数据装进一个局部 save_data 表里。等这些数据都拿到手之后,再把它们分别写回对应的变量就行。比如要读取玩家的技能点,就写 skill_points = save_data.skill_points,意思就是把存档里的技能点重新赋给全局变量 skill_points。
Additionally, the load function needs a bit of additional logic to work properly. If it's the first time the player has run the game then the save file will not exist, which means that when try to load it we'll crash. To prevent this we check to see if it exists with love.filesystem.exists and only load it if it does. If it doesn't then we just set a global variable first_run_ever to true. This variable is useful because generally we want to do a few things differently if it's the first time the player has run the game, like maybe running a tutorial of some kind or showing some message of some kind that only first timers need. The load function will be called once in love.load whenever the game is loaded. It's important that this function is called after the globals.lua file is loaded, since we'll be overwriting global variables in it.
另外,读档函数还得补一点额外逻辑,才能真正跑得稳。因为玩家第一次运行游戏时,存档文件根本还不存在,这时候你要是直接去加载,程序就会崩。所以我们要先用 love.filesystem.exists 检查这个文件在不在,存在才去读;如果不存在,就把一个全局变量 first_run_ever 设成 true。这个变量很有用,因为很多游戏在“首次启动”时都需要做点特殊处理,比如播放一段教学,或者弹出只给新玩家看的提示。load 函数应该在游戏加载时,也就是 love.load 里调用一次。还要注意,它必须放在 globals.lua 载入之后执行,因为我们会在这里覆盖其中的一些全局变量。
And that's it for saving/loading. What actually needs to be saved and loaded will be left as an exercise since it depends on what you decided to implement or not. For instance, if you implement the skill tree exactly like in article 13, then you probably want to save and load the bought_node_indexes table, since it contains all the nodes that the player bought.
存档和读档本身就这些。至于到底有哪些数据应该被保存、哪些需要读回来,我就留给你自己决定了,因为这取决于你到底实现了哪些系统。比如,如果你完全照着第 13 篇把技能树做出来了,那你大概率就需要保存和读取 bought_node_indexes 这个表,因为它记录了玩家已经买过哪些节点。
Achievements
Because of the simplicity of the game achievements are also very easy to implement (at least compared to everything else xD). What we'll do is simply have a global table called achievements. And this table will be populated by keys that represent the achievement's name, and values that represent if that achievement is unlocked or not. So, for instance, if we have an achievement called '50K', which unlocks whenever the player reaches 50.000 score in a round, then achievements['50K'] will be true if this achievements has been unlocked and false otherwise.
由于这款游戏本身结构不复杂,成就系统做起来也相对轻松得多,至少和前面那些内容比起来是这样。我们的做法很简单:弄一个全局表 achievements。这个表的键是成就名称,值则表示该成就是不是已经解锁。比如说,如果有个叫 '50K' 的成就,条件是一局里打到 50000 分,那么当它解锁之后,achievements['50K'] 就会是 true;没解锁时就是 false。
To exemplify how this works let's create the 10K Fighter achievement, which unlocks whenever the player reaches 10.000 score using the Fighter ship. All we have to do to achieve this is set achievements['10K Fighter'] to true whenever we finish a round, the score is above 10K and the ship currently being used by the player is 'Fighter'. This looks like this:
为了更具体一点,我们来做一个叫 10K Fighter 的成就。它的解锁条件是:玩家使用 Fighter 飞船时,单局得分达到 10000。那实现起来也很简单,只要在一局结束时判断一下:分数是否超过 10K、当前飞船是不是 'Fighter',如果都满足,就把 achievements['10K Fighter'] 设成 true。代码像这样:
function Stage:finish()
timer:after(1, function()
gotoRoom('Stage')
if not achievements['10K Fighter'] and score >= 10000 and device = 'Fighter' then
achievements['10K Fighter'] = true
-- Do whatever else that should be done when an achievement is unlocked
end
end)
endAs you can see it's a very small amount of code. The only thing we have to make sure is that each achievement only gets triggered once, and we do that by checking to see if that achievement has already been unlocked or not first. If it hasn't then we proceed.
你可以看到,代码量其实非常小。唯一要注意的是,每个成就都只能触发一次,所以先要判断它是不是已经解锁过了。只有还没解锁,才继续往下执行。
I don't know how Steam's achievement system work yet but I'm assuming that we can call some function or set of functions to unlock an achievement for the player. If this is the case then we would call this function here as we set achievements['10K Fighter'] to true. One last thing to remember is that achievements need to be saved and loaded, so it's important to add the appropriate code back in the save and load functions.
我当时还不清楚 Steam 的成就系统具体是怎么接的,不过大概率会有某个函数,或者一组函数,专门用来给玩家解锁成就。如果确实如此,那就在这里把 achievements['10K Fighter'] 设为 true 的同时,顺手调用对应接口就可以了。最后别忘了,成就数据同样需要存档和读档,所以也得把它们补进 save 和 load 里。
Shaders
In the game so far I've been using about 3 shaders and we'll cover only one. However since the others use the same "framework" they can be applied to the screen in a similar way, even though the contents of each shader varies a lot. Also, I'm not a shaderlord so certainly I'm doing lots of very dumb things and there are better ways of doing all that I'm about to say. Learning shaders was probably the hardest part of game development for me and I'm still not comfortable enough with them to the extend that I am with the rest of my codebase.
到目前为止,这个游戏里我大概用了 3 个着色器,这里只会具体讲其中一个。不过另外两个虽然效果内容差别很大,整体套法其实是一样的,所以你学会这一套之后,别的也可以照着拓展。另外得先打个预防针:我并不是什么 shader 高手,所以接下来这部分做法里肯定有不少很笨的地方,也一定存在更好的写法。对我自己来说,学 shader 可能是整个游戏开发里最难啃的一块,到现在我对它的熟悉程度,还是远远不如自己代码库里其他部分。
With all that said, we'll implement a simple RGB shift shader and apply it only to a few select entities in the game. The basic way in which pixel shaders work is that we'll write some code and this code will be applied to all pixels in the texture passed into the shader. You can read more about the basics here.
说完这些,我们来实现一个简单的 RGB shift 着色器,并且只把它作用在游戏里少数几个指定对象上。像素着色器最基本的工作方式是:你写一段 shader 代码,这段代码会被应用到传进来的纹理上的每一个像素。更基础的说明可以看这里。
One of the problems that I found when trying to apply this pixel shader to different objects in the game is that you can't apply it directly in that object's code. For whatever reason (and someone who knows more would be able to give you the exact reason here), pixel shaders aren't applied properly whenever we use basic primitives like lines, rectangles and so on. And even if we were using sprites instead of basic shapes, the RGB shift shader wouldn't be applied in the way we want either because the effect requires us to go outside the sprite boundaries. But because the pixel shader is only applied to pixels in the texture, when we try to apply it it will only read pixels inside the sprite's boundary so our effect doesn't work.
我在把这个像素着色器往游戏对象上套的时候,遇到的一个问题是:你没法直接在对象自己的绘制代码里把它用好。具体原因我当时说不太清楚,懂图形渲染的人应该能解释得更准确一些,但结果就是:如果你画的是线段、矩形之类的基础图元,像素着色器并不会按我们想要的方式生效。就算你不用基础图元、改用精灵图,这个 RGB shift 也还是不太对,因为这个效果需要访问到精灵边界以外的像素。而像素着色器只能处理当前纹理本身的像素,所以它最终只能读到精灵边界之内的内容,效果自然就做不出来。
To solve this I've defaulted to drawing the objects that I want to apply effect X to to a new canvas, and then applying the pixel shader to that entire canvas. In a game like this where the order of drawing doesn't really matter this has almost no drawbacks. However in a game where the order of drawing matters more (like a 2.5D top-downish game) doing this gets a bit more complicated, so it's not a general solution for anything.
所以我的默认解法是:先把所有需要套某个效果的对象单独画到一个新的 canvas 上,然后再把像素着色器统一作用到这整张 canvas 上。在像这个游戏里,绘制顺序并没有特别敏感,所以这么做几乎没什么副作用。但如果你做的是那种更讲究前后层次的游戏,比如带点 2.5D 味道的俯视角游戏,这件事就会复杂不少。所以这不是一种放之四海而皆准的万能解法,只是这里刚好够用。
rgb_shift.frag
Before we get into coding all this let's get the actual pixel shader out of the way, since it's very simple:
在正式接这些代码之前,先把真正的像素着色器本体拿出来,因为它本身其实很简单:
extern vec2 amount;
vec4 effect(vec4 color, Image texture, vec2 tc, vec2 pc) {
return color*vec4(Texel(texture, tc - amount).r, Texel(texture, tc).g,
Texel(texture, tc + amount).b, Texel(texture, tc).a);
}I place this in a file called rgb_shift.frag in resources/shaders and loaded it in the Stage room using love.graphics.newShader. The entry point for all pixel shaders is the effect function. This function receives a color vector, which is the one set with love.graphics.setColor, except that instead of being in 0-255 range, it's in 0-1 range. So if the current color is set to 255, 255, 255, 255, then this vec4 will have values 1.0, 1.0, 1.0, 1.0. The second thing it receives is a texture to apply the shader to. This texture can be a canvas, a sprite, or essentially any object in LÖVE that is drawable. The pixel shader will automatically go over all pixels in this texture and apply the code inside the effect function to each pixel, substituting its pixel value for the value returned. Pixel values are always vec4 objects, for the 4 red, green, blue and alpha components.
我把它放在 resources/shaders/rgb_shift.frag 这个文件里,然后在 Stage 房间中通过 love.graphics.newShader 来加载。所有像素着色器的入口函数都是 effect。这个函数接收的第一个参数是 color 向量,也就是 love.graphics.setColor 当前设置的颜色,只不过在 shader 里它不是 0 到 255 的区间,而是 0 到 1 的区间。所以如果当前颜色是 255, 255, 255, 255,那么这里得到的 vec4 就会是 1.0, 1.0, 1.0, 1.0。第二个参数是要应用 shader 的 texture,它可以是 canvas、sprite,或者 LÖVE 里任何能画出来的对象。像素着色器会自动遍历这张纹理上的每一个像素,对每个像素执行 effect 函数里的逻辑,再用函数返回值替换原本的像素值。像素值本质上都是 vec4,也就是红、绿、蓝、透明度这四个分量。
The third argument tc represents the texture coordinate. Texture coordinates range from 0 to 1 and represent the position of the current pixel inside the pixel. The top-left corner is 0, 0 while the bottom-right corner is 1, 1. We'll use this along with the texture2D function (which in LÖVE is called Texel) to get the contents of the current pixel. The fourth argument pc represents the pixel coordinate in screen space. We won't use this for this shader.
第三个参数 tc 代表纹理坐标。纹理坐标的取值范围是 0 到 1,用来表示当前像素在整张纹理中的相对位置。左上角是 0, 0,右下角是 1, 1。我们会配合 texture2D 函数来使用它,在 LÖVE 里这个函数叫 Texel,用来读取当前位置像素的内容。第四个参数 pc 表示的是屏幕空间中的像素坐标,这个 shader 里暂时不会用到它。
Finally, the last thing we need to know before getting into the effect function is that we can pass values to the shader to manipulate it in some way. In this case we're passing a vec2 called amount which will control the size of the RGB shift effect. Values can be passed in with the send function.
最后,在真正解释 effect 函数之前,还要知道一点:我们可以从外面往 shader 里传值,来动态控制效果。这里我们传进去的是一个叫 amount 的 vec2,它用来控制 RGB 偏移的强度。传值时使用的是 send 函数。
Now, the single line that makes up the entire effect looks like this:
整个效果真正核心的,其实就是这一行:
return color*vec4(
Texel(texture, tc - amount).r,
Texel(texture, tc).g,
Texel(texture, tc + amount).b,
Texel(texture, tc).a);What we're doing here is using the Texel function to look up pixels. But we don't wanna look up the pixel in the current position only, we also want to look for pixels in neighboring positions so that we can actually to the RGB shifting. This effect works by shifting different channels (in this case red and blue) in different directions, which gives everything a glitchy look. So what we're doing is essentially looking up the pixel in position tc - amount and tc + amount, and then taking and red and blue value of that pixel, along with the green value of the original pixel and outputting it. We could have a slight optimization here since we're grabbing the same position twice (on the green and alpha components) but for something this simple it doesn't matter.
这里做的事情,就是用 Texel 去查像素。不过我们不是只查当前位置,而是还要去查它附近的位置,这样才能做出 RGB 偏移。这个效果的本质,就是把不同颜色通道朝不同方向挪开一点点,这里被挪开的主要是红色和蓝色,所以最终会有一种故障感、错位感。具体来说,就是去读 tc - amount 和 tc + amount 两个位置上的像素,再分别拿它们的红色、蓝色分量,搭配当前像素原本的绿色分量一起输出。严格说这里还能做一点小优化,因为绿色和 alpha 两次都取了同一个位置的值,不过对于这种简单效果来说,完全没必要较真。
Selective drawing
Since we want to apply this pixel shader only to a few specific entities, we need to figure out a way to only draw specific entities. The easiest way to do this is to mark each entity with a tag, and then create an alternate draw function in the Area object that will only draw objects with that tag. Defining a tag looks like this:
因为我们只想把这个 shader 用在少数特定对象上,所以得先想办法“只画出这些对象”。最简单的方案,就是给对象打标签,然后在 Area 里专门写一个 draw 变体,只绘制带指定标签的对象。打标签大概像这样:
function TrailParticle:new(area, x, y, opts)
TrailParticle.super.new(self, area, x, y, opts)
self.graphics_types = {'rgb_shift'}
...
endAnd then creating a new draw function that will only draw objects with certain tags in them looks like this:
然后写一个只绘制特定标签对象的新函数,大概会是这样:
function Area:drawOnly(types)
table.sort(self.game_objects, function(a, b)
if a.depth == b.depth then return a.creation_time < b.creation_time
else return a.depth < b.depth end
end)
for _, game_object in ipairs(self.game_objects) do
if game_object.graphics_types then
if #fn.intersection(types, game_object.graphics_types) > 0 then
game_object:draw()
end
end
end
endSo this is exactly like that the normal Area:draw function except with some additional logic. We're using the intersection to figure out if there are any common elements between the objects graphics_types table and the types table that we pass in. For instance, if we decide we only wanna draw rgb_shift type objects, then we'll call area:drawOnly({'rgb_shift'}), and so this table we passed in will be checked against each object's graphics_types. If they have any similar elements between them then #fn.intersection will be bigger than 0, which means we can draw the object.
这个函数本质上和普通的 Area:draw 一样,只是额外加了一层筛选逻辑。我们用 intersection 来判断:对象自身的 graphics_types 表,和调用者传进来的 types 表之间,有没有交集。比如如果我们只想绘制 rgb_shift 类型的对象,就调用 area:drawOnly({'rgb_shift'})。然后程序会把这个表和每个对象的 graphics_types 拿来比,只要交集长度大于 0,就说明这个对象该被画出来。
Similarly, we will want to implement an Area:drawExcept function, since whenever we draw an object to one canvas we don't wanna draw it again in another, which means we'll need to exclude certain types of objects from drawing at some point. That looks like this:
同样地,我们还需要一个 Area:drawExcept 函数。因为一个对象既然已经被画进某个专用 canvas 了,就不应该在另外一张 canvas 里再画一次。所以有时候我们需要按类型“排除绘制”。代码大概这样:
function Area:drawExcept(types)
table.sort(self.game_objects, function(a, b)
if a.depth == b.depth then return a.creation_time < b.creation_time
else return a.depth < b.depth end
end)
for _, game_object in ipairs(self.game_objects) do
if not game_object.graphics_types then game_object:draw()
else
if #fn.intersection(types, game_object.graphics_types) == 0 then
game_object:draw()
end
end
end
endSo here we draw the object if it doesn't have graphics_types defined, as well as if its intersection with the types table is 0, which means that its graphics type isn't one of the ones specified by the caller.
这里的逻辑是:如果对象根本没有定义 graphics_types,那就直接画;如果定义了,那就检查它和 types 的交集是不是 0。交集为 0,就说明它不属于要排除的那些图形类型,于是也可以照常绘制。
Canvases + shaders
With all this in mind now we can actually implement the effect. For now we'll just implement this on the TrailParticle object, which means that the trail that the player and projectiles creates will be RGB shifted. The main way in which we can apply the RGB shift only to objects like TrailParticle looks like this:
有了这些准备之后,就可以真的把效果接起来了。这里先只把它用在 TrailParticle 对象上,也就是让玩家和弹丸留下的尾迹带上 RGB 偏移。核心做法如下:
function Stage:draw()
...
love.graphics.setCanvas(self.rgb_shift_canvas)
love.graphics.clear()
camera:attach(0, 0, gw, gh)
self.area:drawOnly({'rgb_shift'})
camera:detach()
love.graphics.setCanvas()
...
endThis looks similar to how we draw things normally, except that now instead of drawing to main_canvas, we're drawing to the newly created rgb_shift_canvas. And more importantly we're only drawing objects that have the 'rgb_shift' tag. In this way this canvas will contain all the objects we need so that we can apply our pixel shaders to later. I use a similar idea for drawing Shockwave and Downwell effects.
这和我们平时的绘制流程很像,不同之处在于:现在不是往 main_canvas 上画,而是画到专门新建的 rgb_shift_canvas 上;更关键的是,这里只画带 'rgb_shift' 标签的对象。这样一来,这张 canvas 里就只会留下那些我们之后想拿去跑 shader 的对象。我做 Shockwave 和 Downwell 效果时,也用了同样的思路。
Once we're done with drawing to all our individual effect canvases, we can draw the main game to main_canvas with the exception of the things we already drew in other canvases. So that would look like this:
等所有单独效果用的 canvas 都画完之后,我们就可以开始画主画面了,只不过要把那些已经画进其他 canvas 的对象排除掉。代码像这样:
function Stage:draw()
...
love.graphics.setCanvas(self.main_canvas)
love.graphics.clear()
camera:attach(0, 0, gw, gh)
self.area:drawExcept({'rgb_shift'})
camera:detach()
love.graphics.setCanvas()
...
endAnd then finally we can apply the effects we want. We'll do this by drawing the rgb_shift_canvas to another canvas called final_canvas, but this time applying the RGB shift pixel shader. This looks like this:
最后一步,就是把真正的效果套上去。我们的做法是把 rgb_shift_canvas 再画到另一张叫 final_canvas 的 canvas 上,这次绘制时启用 RGB shift shader。代码如下:
function Stage:draw()
...
love.graphics.setCanvas(self.final_canvas)
love.graphics.clear()
love.graphics.setColor(255, 255, 255)
love.graphics.setBlendMode("alpha", "premultiplied")
self.rgb_shift:send('amount', {
random(-self.rgb_shift_mag, self.rgb_shift_mag)/gw,
random(-self.rgb_shift_mag, self.rgb_shift_mag)/gh})
love.graphics.setShader(self.rgb_shift)
love.graphics.draw(self.rgb_shift_canvas, 0, 0, 0, 1, 1)
love.graphics.setShader()
love.graphics.draw(self.main_canvas, 0, 0, 0, 1, 1)
love.graphics.setBlendMode("alpha")
love.graphics.setCanvas()
...
endUsing the send function we can change the value of the amount variable to correspond to the amount of shifting we want the shader to apply. Because the texture coordinates inside the pixel shader are between values 0 and 1, we want to divide the amounts we pass in by gw and gh. So, for instance, if we want a shift of 2 pixels then rgb_shift_mag will be 2, but the value passed in will be 2/gw and 2/gh, since inside the pixel shader, 2 pixels to the left/right is represented by that small value instead of actually 2. We also draw the main canvas to the final canvas, since the final canvas should contain everything that we want to draw.
通过 send,我们可以动态修改 shader 里 amount 的值,从而控制偏移幅度。因为像素着色器内部的纹理坐标是在 0 到 1 之间表示的,所以传进去的偏移量要先除以 gw 和 gh。比如你想让它左右偏移 2 个像素,那么 rgb_shift_mag 就是 2,但真正传进去的值会是 2/gw 和 2/gh。因为在 shader 看来,左右 2 个像素不是字面意义上的 2,而是纹理坐标里的一小段比例值。与此同时,我们还会把 main_canvas 也画到 final_canvas 上,因为 final_canvas 最终应该包含整张游戏画面。
Finally outside this we can draw this final canvas to the screen:
最后,在这一步之外,再把最终画好的 final_canvas 输出到屏幕上:
function Stage:draw()
...
love.graphics.setColor(255, 255, 255)
love.graphics.setBlendMode("alpha", "premultiplied")
love.graphics.draw(self.final_canvas, 0, 0, 0, sx, sy)
love.graphics.setBlendMode("alpha")
love.graphics.setShader()
endWe could have drawn everything directly to the screen instead of to the final_canvas first, but if we wanted to apply another screen-wide shader to the final screen, like for instance the distortion, then it's easier to do that if everything is contained in a canvas properly.
其实我们也可以不经过 final_canvas,而是直接把所有东西画到屏幕上。但如果你之后还想在整张最终画面上再套一层全屏 shader,比如 distortion 这种,那么先把所有内容规整地收进一张 canvas 里,会方便得多。
And so all that would end up looking like this:
整套效果最后看起来就是这样:

And as expected, the trail alone is being RGB shifted and looks kinda glitchly like we wanted.
正如预期的那样,只有尾迹部分被做了 RGB 偏移,看起来正好带着一点我们想要的那种故障感。
Audio
I'm not really big on audio so while there are lots of very interesting and complicated things one could do, I'm going to stick to what I know, which is just playing sounds whenever appropriate. We can do this by using ripple.
我自己其实不太擅长音频这块,所以虽然这里面可以玩出很多很复杂、也很有意思的东西,我还是只讲我比较熟的那部分,也就是在合适的时候把声音放出来。这里会用到 ripple。
This library has a pretty simple API and essentially it boils down to loading sounds using ripple.newSound and playing those sounds by calling :play on the returned object. For instance, if we want to play a shooting sound whenever the player shoots, we could do something like this:
这个库的 API 很简单,归根结底就是两步:先用 ripple.newSound 加载音效,再对返回的对象调用 :play 来播放它。比如,如果我们想让玩家每次开火时都响一下,大概可以这么写:
-- in globals.lua
shoot_sound = ripple.newSound('resources/sounds/shoot.ogg')function Player:shoot()
local d = 1.2*self.w
self.area:addGameObject('ShootEffect', ...
shoot_sound:play()
...
endAnd so in this very simple way we can just call :play whenever we want a sound to happen. The library also has additional goodies like changing the pitch of the sound, playing sounds in a loop, creating tags so that you can change properties of all sounds with a certain tag, and so on. In the actual game I ended up doing some additional stuff on top of this, but I'm not going to go over all that here. If you've bought the tutorial you can see all that in the sound.lua file.
所以思路就是这么朴素:想让它响的时候,直接 :play 就完了。这个库本身还提供了不少额外功能,比如调节音高、循环播放、给声音打标签然后批量改属性之类。我在实际游戏里也在这基础上又加了一些自己的封装,不过这里就不展开讲了。如果你买了完整教程,可以去 sound.lua 里直接看我最后是怎么写的。
END
And this is the end of this tutorial. By no means have we covered literally everything that we could have covered about this game but we went over the most important parts. If you followed along until now you should have a good grasp on the codebase so that you can understand most of it, and if you bought the tutorial then you should be able to read the full source code with a much better understanding of what's actually happening there.
到这里,这套教程就真的结束了。我们当然不可能把这个游戏相关的每一个细节都讲得面面俱到,但最核心、最关键的部分,基本都已经走过一遍了。如果你一路跟到这里,那你对这套代码库应该已经有了不错的把握,至少大部分内容都能看懂了;如果你买了完整教程,现在再去读完整源码,也应该会比一开始清楚得多,知道里面到底发生了什么。
Hopefully this tutorial has been helpful so that you can get some idea of what making a game actually entails and how to go from zero to the final result. Ideally now that you have all this done you should use what you learned from this to make your own game instead of just changing this one, since that's a much better exercise that will test your "starting from zero" abilities. Usually when I start a new project I pretty much copypaste a bunch of code that I know has been useful between multiple projects, generally that's a lot of the "engine" code that we went over in articles 1 through 5.
希望这套教程多少能帮你对“做一款游戏到底意味着什么”这件事有个更具体的认识,也知道该怎样从一无所有一步步走到最后成品。理想情况下,学完这些之后,你最好不是只在这个项目上小修小改,而是拿着这里学到的东西去做你自己的游戏。那才是更有效的练习,也更能检验你从零开始搭一套东西的能力。就我自己来说,每次开新项目时,通常都会直接复制一批已经在多个项目里证明过很好用的代码,而那里面很大一部分,其实就是我们在第 1 到第 5 篇里反复搭起来的那些“底层通用代码”。
Anyway, I don't know how to end this so... bye!
总之,我也不知道该怎么体面地收尾,那就这样吧……再见。