Prologue
这一章正式开始之前给大家分享两个网站:
首先是菜鸟教程里关于LUA的内容,如果没有接触过LUA或者在之前都没有学习过编程,这里面的内容会很有帮助:Lua 教程 | 菜鸟教程
然后是Fandom中的PICO-8 Wiki,在里面我们可以找到关于PICO-8的一些内容,以及,最重要的——APIs,这个对我们以后的游戏开发有很大帮助:PICO-8 WIKI
在正式开始之前先介绍一下如何保存制作的PICO-8项目,在命令行中输入save [filename]指令,游戏就可以快速保存为.p8文件格式,不输入文件名的话就会保存以untitledn的文件名保存。
不过.p8并不是制作者们常用的文件分享格式,如果大家有试过从网上下载一些PICO-8游戏的话,就会发现保存的格式都是.p8.png,最后的形式就有点像一个游戏卡:
这就是PICO-8比较有特色的一个地方:虽然这些看上去就是一个个图片,但是所有的代码、美术、音乐,都包含在这一个小小的图片里面。
不过这上面的两种方式都需要有一个PICO-8才可以运行,如果我想把我做的游戏分享给我的朋友,而他并没有PICO-8,他还能在他的电脑上玩到我的游戏吗?
答案是可以的,输入export [filename].html,我们的游戏就可以保存为网页格式,打开之后会发现是一个镶嵌在网页内的PICO-8,里面就是我们的游戏:
1.4 Cave Diver游戏的开发
经过之前的两篇,我们已经认识了PICO-8的基本操作以及Game Loop的相关知识,这一篇我们就可以继续跟随Dylan来制作第一个正儿八经的的游戏:Cave Diver
Cave Diver对于大家来说应该不算陌生,这类型的游戏还是蛮多的,在2013年出现的现象级手机游戏Flappy Bird就是一个典型,当年我还没有自己的手机,所以只在别人的手机上玩过。后来手机游戏开始往MOBA、射击游戏的方面发展,这类小巧有趣的手机游戏就不再能见得到了。
回到我们的PICO-8,在书里面Dylan给出了完整的代码以及制作思路,不过对于程序苦手来说理解起来还是有些困难,更不用说原文还是英文了,所以在这里我还是会一行一行地来解析这个游戏,从而能够理清楚这个游戏的运作逻辑。
首先我们应该考虑的是这个游戏该做些什么:游戏开始后,我们控制一个小精灵让它在一个不断生成的“洞穴”中前行,为了保证游戏的可玩度,我们应该增加一些难度,比如让小精灵下坠,然后我们点击按钮让小精灵往上飞。
不过我们有必要让小精灵真的往前前进吗?答案是否定的,实际上我们只需要让摄像机内的洞穴不断左移,看起来就像小精灵在往前走一样,这个相对运动的原理在游戏中的运用可以说是非常的频繁,因为向前运动对于程序来说总归不是一件方便的事情。
清楚我们需要做的事情之后,就可以一步步来实施了。
老样子,在开始之前不要忘了绘制你的精灵:在这个例子里面,我们的精灵有三个状态:下坠、上升以及失败,所以我们需要绘制三个精灵来对应三个状态:
上图是Dylan给出的例子,当然我们也可以画三个属于自己的精灵:
然后就是代码部分了:
--0
function _init()
game_over=false
make_player()
end
function _update()
end
function _draw()
cls()
draw_player()
end
--1
function make_player()
player={}
player.x=24 --position
player.y=60
player.dy=0 --fall speed
player.rise=1 --sprites
player.fall=2
player.dead=3
player.speed=2 --fly speed
player.score=0
end
function draw_player()
if (game_over) then
spr(player.dead,player.x,player.y)
elseif (player.dy<0) then
spr(player.rise,player.x,player.y)
else
spr(player.fall,player.x,player.y)
end
end
Dylan在这个游戏的制作之中使用的代码分页来增加可读性,当然我们也可以把所有代码写进一页,不过我还是把Dylan原文中放置代码的页数备注在了最前面。
make_player()函数初始化了player,这里将player设置成了一个table,LUA中的table可以类比于Python中的字典,每一项有对应的key以及value。
关于table的内容菜鸟教程里面有比较详细的介绍,我就不说太多了,要补充一点是:在上面的代码里面是用“.key”来访问value,其实这是等价于["key"]的:
list={}
list.x=114
print(list.x)
print(list["x"])
--输出结果:
--114
--114
另提一句,类似于C语言,LUA中table名存储的是一个指针,所以我们运行print(list)并不能打印出来table的内容。
回到游戏,在初始化了player之后,draw_player()函数给三种状态绑定了各自的精灵,这样在游戏运行中精灵的外貌就可以发生变化。
现在我们的精灵只会呆在原地,我们需要编写一个运动函数让它动起来:
--1
function move_player()
gravity=0.2 --bigger means more gravity!
player.dy+=gravity --add gravity
--jump
if (btnp(2)) then
player.dy-=5
end
--move to new position
player.y+=player.dy
end
同时不要忘了修改你的_update()函数:
function _update()
move_player()
end
这里将精灵的下坠设计成了一个匀加速运动,而不是看起来很呆的匀速运动,实现方法其实也很简单,运用的就是最基础的运动公式:x = x0 + vt,由于代码是随时间运行,t 可以舍掉,v = v0 + at,同样舍掉 t,两个式子合并就是x = x0 + v0 + a,在代码中就是定义gravity,这个就是加速度a,速度的变化用player.dy+=gravity来实现,最后用player.y+=player.dy来实现位置的变化,最后就可以实现精灵的匀加速下坠。
而当我们按下“↑”时,又会给一个向上的瞬时加速度,这样就可以实现往上飞。
这个时候按下Ctrl+R,我们的小精灵就可以实现最基本的运动了,接下来就是cave的绘制:
--2
function make_cave()
cave={{["top"]=5,["btm"]=119}}
top=45 --how low can the ceiling go?
btm=85 --how high can the floor get?
end
function update_cave()
--remove the back of the cave
if (#cave>player.speed) then
for i=1,player.speed do
del(cave,cave[1])
end
end
--add more cave
while (#cave<128) do
local col={}
local up=flr(rnd(7)-3)
local dwn=flr(rnd(7)-3)
col.top=mid(3,cave[#cave].top+up,top)
col.btm=mid(btm,cave[#cave].btm+dwn,124)
add(cave,col)
end
end
function draw_cave()
top_color=5 --play with these!
btm_color=5 --choose your own colors!
for i=1,#cave do
line(i-1,0,i-1,cave[i].top,top_color)
line(i-1,127,i-1,cave[i].btm,btm_color)
end
end
老样子,不要忘记修改三个流程函数:
--0
function _init()
game_over=false
make_cave()
make_player()
end
function _update()
update_cave()
move_player()
end
function _draw()
cls()
draw_cave()
draw_player()
end
这里对于洞穴的处理使用了一个比较取巧的方法:将洞穴视作一个个连续的顶部直线与底部直线的组合,将这一个个组合以table的形式存放在数组里面,通过在右侧增加新的组合以及在左侧删除掉旧的组合,来实现洞穴的“运动”,我们的小精灵看上去就像在往前走了一样。
make_cave()实现了这样一个table数组的创建,同时还设定了两个值:top 和 btm,这两个值规定了顶部的直线和底部的直线最多能有多长,具体的用法在draw_cave()里面。
然后就是对cave数组的生成和删除,这一功能靠update_cave()来实现,这一个函数里面又分成了两小块,第一块 if 嵌套的 for 循环实现了cave的删除,if(#cave>player.speed)保证了cave数组不会被过早的删除,在数组名称前面加一个“#”可以获得这个数组的长度,比如这里#cave就是返回cave里面有多少个table,这个值在第一个Game Loop里面是1, 所以在第一个Game Loop里面del函数并不会实施,这样一来就可以确保cave的长度可以正确增加至128。
这里的del()函数相比其它语言的数组删除元素函数更加友好:在删除某个元素之后,它会自动让后面所有的元素往前挪一位。
for循环做的事情就是在每一个Game Loop里面从前往后删除掉cave中与player.speed等同数量的table元素,这样的话数组尾部会留出来同样数量的空白位置,这个位置会被随后的增加table元素的while代码块所填补,这个操作其实有点像C语言里面的队列,从尾部入队,从头部出队,这样的话就可以实现洞穴与player之间与player.speed值相同速度的相对运动——实际上speed并不是精灵的speed,而是洞穴刷新的速度。
不知道大家有没有疑惑过PICO-8代码里面循环的运作,因为Game Loop本身就是个循环,循环里面的循环该怎么运作?其实答案很简单,每个Game Loop内所有代码都是会完整地运行一遍,比如我的代码里面有一个30次的for循环,那样的话每一个Game Loop中 for 循环里面的语句都会一遍不少的运行30次,也就是说,在 1s 内,它运行了30*30 = 900次:完全不用担心会因为过多的循环导致游戏出现一些意料之外的事情,虽然PICO-8看上去很古老,但它毕竟是在我们的计算机上运行的。
回到游戏,接下来让我们看看while循环中如何给cave增加新的table元素:
while的循环条件是 (#cave<128),当 cave 数组内的元素小于128个时,while循环会一直运行,直到cave数组的元素数量达到128个。为什么是128个?上一篇我们提到过,游戏运行后摄像机的可视范围是一个128*128的矩形,128正好可以填满屏幕,不多也不少。
while函数的开始定义了三个local变量,这样可以保证循环内的数值不会受外界的干扰,同时各个循环也不会互相干扰。其中的col虽然按数组的方式进行定义,不过在后面我们也可以看到,实际上col是一个table,他有两个key,top和btm,这两个key是不是有点眼熟?它们就是在make_cave()里面出现过的,这也就是说,col就是要加到cave中的table元素,最后的add(cave,col)也实现了这一点,add()的用法也不言而喻了。
至于这里面出现的另外两个函数:flr(num)的功能是将num向下取整,rnd(num)的功能是生成一个大于等于0.0小于num的数,并不是整数,比如rnd(10)就是生成一个0.0 ~ 9.99999的随机数,因为不是整数,所以外面套了一个flr()来取整。另外,无论是up还是down,在后面我们都可以看见实际上是变化值,最后生成的洞穴肯定是起起伏伏的,所以数值的变化也是有增有减,这里的“-3”实现的就是这个功能。
最后再来看 col.top 和 col.btm 的赋值:mid(a, b, c)函数用于取中间值,在这里出现的3个数分别是3, cave[#cave].top+up, top,top 在 make_cave() 里面给定的值是45,cave[#cave].top+up就是新生成的top值,是对前一个cave元素加上up值,别忘了up有正有负。另外一提,LUA中的数组初始是从1开始计数的,所以这里cave[#cave]指的是队尾的元素,而不是队尾元素后面的那个空位,这是和其他语言不太一样的一点。最后,mid()函数保证了新生成的 col.top 是一个处于[3, 45]之间的值,不至于太大,也不至于太小。col.btm使用了同样的生成方法,这里就不再赘述了。
现在cave已经生成并且可以实现更新,接下来就是如何将它绘制出来了:
这里使用了line()函数来循环绘制直线,line()函数和EGE里面一样,五个值分别是起始点的X坐标、起始点的Y坐标、终点的X坐标、终点的Y坐标以及线条的颜色编号,这里的编号就是绘制界面的色盘从0 ~ 15的16种颜色对应的编号:
现在再来运行游戏:我们可以看到洞穴已经可以生成了,但是我们的小精灵就算撞到墙上也不会发生什么,而是直接穿了过去,那是因为我们还没有添加检测碰撞的函数:
--1
function check_hit()
for i=player.x,player.x+7 do
if (cave[i+1].top>player.y
or cave[i+1].btm<player.y+7) then
game_over=true
end
end
end
然后修改_update()函数:
--0
function _update()
if (not game_over) then
update_cave()
move_player()
check_hit()
end
end
这里的 check_hit() 原理其实很简单:就是从左到右依次检测小精灵的每个顶部像素格和底部像素格是否“碰”到了洞穴,player.x是精灵左上角的那个点,精灵的宽度是8,所以这里的循环条件是player.x, player.x+7。检测到“碰撞”的时候,给 game_over 赋值为 true,此时 _update()内的代码不再运行,但是游戏是没有结束的,只是画面不再发生变化了而已。
保存代码,Ctr+R运行,大功告成,小精灵可以在洞穴内自由向前,同时碰壁的时候游戏也会停止,我们的小精灵也会寄掉,接下来我们可以试着给游戏增加一个计分,同时在玩家游戏失败后会弹出Game Over的对话框,下面的内容相对简单,是对已有的两个函数添加一些新东西,就不再赘述了:
--0
function _draw()
cls()
draw_cave()
draw_player()
if (game_over) then
print("game over!",44,44,7)
print("your score:"..player.score,34,54,7)
else
print("score:"..player.score,2,2,7)
end
end
--1
function move_player()
--add gravity
player.dy+=0.2
--jump
if (btnp(2)) then
player.dy-=5
end
--move to new position
player.y+=player.dy
--update score
player.score+=player.speed
end
这里简单介绍一下print()函数:print(text, [x,] [y,] [color])需要四个参数,分别是文字内容、文本框左上角的X坐标、Y坐标以及颜色代码。
现在运行游戏,在碰壁之后就可以显示出来Game Over对话框,同时也可以打印出玩家所获得分数,但此时,Game Over之后我们只能退出再运行程序来再次开始游戏,貌似有点不合理,那么我们可以通过按下按钮来实现游戏重新开始:
--0
function _update()
if (not game_over) then
update_cave()
move_player()
check_hit()
else
if (btnp(5)) _init() --restart
end
end
function _draw()
cls()
draw_cave()
draw_player()
if (game_over) then
print("game over!",44,44,7)
print("your score:"..player.score,34,54,7)
print("press × to play again!",18,72,6)
else
print("score:"..player.score,2,2,7)
end
end
这里增加一个按键触发的事件:当 game_over == true 触发 else 语句,玩家按下X时,再次运行_init()函数,这样的话游戏就实现了初始化。
到这里,我们的游戏已经非常完善了,但是略显单调:不妨来给我们的游戏增加点音乐:
PICO-8的音乐编辑非常的简单,我们可以直接用鼠标在上面拖动,就可以绘制出一条音轨,或者说按下这些按键:
这个上面是键盘位置跟钢琴按键的对应关系,这样我们就可以更方便地编辑音乐。
播放音效的函数时sfx(),括号里面放音效的编号:
--1
function move_player()
--add gravity
player.dy+=0.2
--jump
if (btnp(2)) then
player.dy-=5
sfx(0)
end
--move to new position
player.y+=player.dy
--update score
player.score+=player.speed
end
function check_hit()
for i=player.x,player.x+7 do
if (cave[i+1].top>player.y
or cave[i+1].btm<player.y+7) then
game_over=true
sfx(1)
end
end
end
Ctrl+R运行:
恭喜!你在PICO-8上做出了第一个属于自己的游戏!