长大,是一个不断失去的过程。
笔者是一位90后。
小时候大家家里条件都差,娱乐手段十分贫乏。不像现在的小孩子可以玩到手机,平板和电脑,那时,一叠小浣熊水浒卡就能玩上一整天,要是有一张稀有卡,比如什么宋江,卢俊义,呼延灼啦,那屁股后面肯定会跟上一长串迷弟争相瞻仰。
而要说这世上还有比这更让人羡慕的事情,那绝对是家里能有一台小霸王学习机啦。虽然打着学五笔的旗号哄骗着爸妈买,但不会真的有人买这个是用来学习的吧?狗头。
一台学习机,一盘卡带,一个手柄,一盘卡带(《魂斗罗》,《沙罗慢蛇》,《坦克大战》,《忍者神龟》,《双截龙》,《超级玛丽》等等等等),如何快速冷却那坨烧得发烫的黑色电源适配器,构成了无数快乐的童年回忆。反观现在,游戏画面越发华丽,机制越发复杂,玩的时候却也很难体验到那些简单的像素游戏带来的纯粹快乐了。
长大,真的是一个不断失去的过程。
好像说的有点远啦,回到正题。那么,笔者将带大家一起,手把手做一台小霸王学(you)习(xi)机,并在上面玩游戏,追忆一下小时候的快乐时光,滴滴。
工欲善其事,必先利其器。——《论语·魏灵公》
单片机 × 1。
喏,长这样。
单片机是什么?
简单来说,单片机就是一台计算机,只是少了很多外设。
怎么选单片机?
对新手来说,笔者推荐虽然被业内人士戏称为玩具,但上手简单,且社区活跃的Arduino开发板。笔者购买的型号是UNO R3,阿猫阿狗都有卖,意呆利版一百多,祖国版几十块。
什么是开发板?
开发板适合学习和实验,提供了更多的引脚:方便实现功能,和配套部件:如通信串口,程序烧录口,复位按钮等等。开发板功能齐全,但尺寸也较大。而在实际生产中,可能并不需要所有这些东西。
单片机如何工作?
你要和我唠嗑这个,我可不困了啊。这里只简单介绍个大概,因为笔者也只是个业余爱好者。
先介绍一下引脚。引脚好比人体感官,用来接收反馈信息。引脚可以是输入,也可以是输出。引脚状态,分为高电平和低电平(是否联想到了什么?),引脚电平状态会影响与其对接的外部器件,下面的Hello World会做演示。
本质上讲,烧入开发板的程序就是控制各个引脚的电平高低,再加上各种逻辑门来组成更复杂的状态,小到灯的亮灭,大到控制飞机,都能由01来表示。
不由让人感叹先人的智慧,颇有一种:道生一,一生二,二生三,三生万物的感觉。
开发板一旦通电,就会根据程序不断重复【输入->运算】。这个过程学过计算机的小伙伴就很熟悉了:读取指令,到寄存器取数据,CPU计算结果,最后将结果存回寄存器。
FBI Warning,以上笔者愚见,如有纰漏,欢迎指正。
四脚按键开关 × 3。
给你戴一顶可爱的小绿帽。
按下会“嘀嗒”响的那种,小霸王手柄同款。
公对公杜邦线 若干。
那有心的小伙伴会问了,既然有公的,那肯定有母的咯?笔者就放一张图,你们自行体会。
220Ω电阻 × 3。
怎么看电阻是不是220Ω的?
对于没学过电工知识的小伙伴只需要了解,电阻上色圈的顺序是:红色,红色,棕色,黄色就是啦。
面包板 × 1。
相当于乐高积木底座。具体的后面会讲到。
Hello World!——Brian Kernighan
硬件离开了软件,那它什么都不是。所以先简单了解一下Arduino的语法吧。
到官网(https://www.arduino.cc/en/software)免费下载Arduino IDE(有web版的,为了方便上传程序,还是下载客户端吧)。这个IDE已经很简单了,且支持中文,故不再介绍细节。
一个简单的LED灯实验。
硬件接法:开发板引脚N -> 220Ω电阻黄色一端 -> LED正极(脚长的那头);LED负极 -> 开发板GND(可以理解为电源负极)。加电阻是为了减弱流入LED的电流,防止烧坏器件。考虑到可能有小伙伴看不来原理图,所以直接上照片。
开始编写程序(Arduino是类C语言):
// 这个函数只会执行一次
void setup() {// 设置6号引脚为输出,是上面的NpinMode(6, OUTPUT);
}// 这家伙会循环执行
void loop() {digitalWrite(6, HIGH); // 高电平亮delay(1000);digitalWrite(6, LOW); // 低电平灭delay(1000);
}
点击下图红框框里的按钮上传程序到开发板。
IF上传失败,点击工具 -> 端口,检查是否选择了正确的COM(Windows)或设备(Linux)。IF是正确的,则需要安装驱动(https://www.arduino.cn/thread-1008-1-1.html)。
一切正常的话,可以看到开发板的小黄灯在不停闪烁,意味着有数据传输。上传完成后,你的小灯泡就重复亮一秒,熄一秒的过程,撒花。
就让游戏开始吧。——麦迪文
先来组装这个。不是笔者矫情啊,字不是笔者P的,原图就有!
由于笔者没有足够的四角按键,所以只接了3个,每个除连接板子的引脚不一样外,其余接法均一致。把按钮翻面,可以看到脚针旁边会有1234的标号,需要注意的是,12可以通电,34可以通电(方向不重要),但13,24,14,23不能连在一起哦。
接法:引脚N -> 按键脚1,按键脚2 -> 220Ω电阻黄色一端, 220Ω电阻红色一端 -> 开发板5V;按键脚3 -> 开发板GND。
接完可以发现按键有一个脚没用到,是正常的。其余的如法炮制。
细心的小伙伴会发现开发板的GND只有2个,所以一个按键接一个GND肯定不够。
这时就需要把杜邦线插在面包板的特殊区域啦。如图,面包板两侧,红线和蓝线之间的部分,是横着连通的,所有这一排的脚都视为连接到了5V或GND上。而面包中间的区域是竖着连通的,这点要注意。
接下来写程序读取按键信号,不多说了,一切都在代码里:
// 这里是引脚编号
const byte left = 7;
const byte right = 8;
const byte act = 9;// 这里是引脚状态
byte leftHit;
byte rightHit;
byte actHit;void setup() {pinMode(7, INPUT);pinMode(8, INPUT);pinMode(9, INPUT);// 新来的家伙,这里在串口初始化,用来接收,发送数据Serial.begin(9600);
}void loop() {// 读取四脚按键的电平leftHit = digitalRead(left);rightHit = digitalRead(right);actHit = digitalRead(act);// 被按下了,电平就是LOWif (leftHit == LOW) {sendSerial(0);}if (rightHit == LOW) {sendSerial(1);}if (actHit == LOW) {sendSerial(2);}// 因为按下,松开这一段时间,对于程序来说还是很漫长的// 这里延迟一会儿,用来降低发送数据的频度// 对于游戏而言,就是你不能一直按着来"连打"delay(100);
}void sendSerial(byte data) {// 发送数据时,中断一下,保证时序delayMicroseconds(2);Serial.print(data);delayMicroseconds(2);
}
So far,so good,赶紧来测试一下。借此介绍一个技巧:上传程序后,打开IDE的工具 -> 串口监视器(上传程序时会占用串口,所以得等程序上传完),在右下角把波特率调整为程序中的9600,IF硬件连接正确,每按下一个按钮,监视器上会显示对应的值。
好了,手柄有了,接下来该来编写游戏啦。
Hello World!Again!——Brian Kernighan
由于笔者对游戏开发是门外汉,所以选择了比较简单的Processing语言进行开发。Processing原本是用来进行图像设计的,这里也算是用它来做了一件比较偏门的事。考虑到大部分小伙伴应该从没听说过这家伙,还是从一个例子来看看Processing怎么玩吧。
老样子,到官网(https://processing.org/download/)免费下载Processing3 IDE。它的界面和Arduino很相似,所以也不过多介绍了。
Demo:
// 哈哈,和Arduino一样,一个setup函数,一个循环函数
void setup() {// 设置画布大小size(200, 200);
}// 和Arduino一样,不停的循环,可以对标游戏的帧数
void draw() {// 背景设置为黑色,具体参数可以参考官网background(0);// 将接下来的元素填充为白色fill(255);// 以鼠标坐标为中心,画一个半径80像素的圆ellipse(mouseX, mouseY, 80, 80);
}
点击左上角的运行,IF代码正确的话,会弹出一个黑色框框,鼠标进去后,会有一个白色圆形一直跟随鼠标移动。
稍微修改一下,在setup前定义一个int x = 0,然后在draw的尾巴自增它x++,将ellipse参数改为ellipse(x, 40, 80, 80),圆形就会从左向右移动啦,这样就可以通过变量值控制元素移动咯。
那么同理可证,只要是Processing提供的函数,就都可以用变量去控制元素的属性或行为。
IF注释掉background的话,所有圆形移动过的地方就都会留下一个圆。
为什么要单独强调这个点呢?当然是后面会利用这个特性,保留或移除一些元素啦。
至此,你已经具备了用Processing写游戏的基础知识啦(误)。
万事俱备,只欠东风。——诸葛亮
前面诸多铺垫,终于迎来终焉时刻。现在就开始写打飞机的游戏吧。一般来说,正常的程序设计,实际编码的时间比为8比2。所以不要急,先理一理。
笔者参考的是经典游戏小蜜蜂。
游戏类型:飞行射击
胜利条件:消灭所有敌机
失败条件:玩家中弹
人机交互接口(User Interface,换一种说法,是不是瞬间高大上了起来,狗头):
-
屏幕上方为3排,每排6架敌机。敌人行为有:左右移动,射击。
-
屏幕下方为玩家,1架飞机。指令有:左右移动,射击。
-
游戏流程:按任意键开始游戏;玩家中弹或敌机全灭,屏幕中间提示对应文字,游戏中断;中断时,按任意键开始新游戏。
以上是玩家看到的部分,下面是程序内部:
移动:很简单,即改变玩家和敌机的x轴,y轴位置。
射击:产生一枚子弹,敌人的子弹向下运动,固定x轴,改变y轴,玩家子弹方向相反。
碰撞判定:最困难的部分,判断子弹是否撞到了物体。由于每帧,每个子弹都要计算,当屏幕中有大量子弹时,计算量会几何增长,不适当优化,暴力循环的话,很可能会卡顿,导致玩家体验不佳。
差不多了,整活。Processing提供多种语言模式,笔者选择的是Java模式(IDE右上角)。
// 先整主角
class Ship {// 当前位置int sx;int sy;// 移动速度int speed = 6;Ship(int initX, int initY) { sx = initX;sy = initY;}void display() {// 玩家的飞机是40x26的图片,可以换成自己喜欢的image(shipShape, sx, sy, 40, 26);}void drive(int direct) {// 向左移动if (0 == direct) {sx = sx - speed;// 不能飞出屏幕if (sx <= 0) {sx = 0;}return;}sx += speed;int right = width - 40;if (sx >= right) {sx = right;}}
}PImage shipShape;
Ship ship;void setup() {size(600, 360);// 文件路径和Process文件同一级shipShape = loadImage("resource/ship.png");ship = new Ship(280, 324);
}void draw() {// 向→飞// ship.drive(1);ship.display();
}
至此,通过调用ship的drive方法,就可以控制飞机啦。敌机的类是差不多的,这里就不重复贴代码啦。
等等,那怎么响应Arduino的按键呢?其实之前已经回答了这个问题,串口通信!
import processing.serial.*;Serial port;void setup() {...// 破特率要和Arduino那边一致哦port = new Serial(this, "{你的串口/设备}", 9600);
}void draw() {if (port.available() <= 0) {return;}// Arduino输出的数据,需要改成自己的定义int coming = port.read();switch(coming) {case 48:ship.drive(0);break;case 49:ship.drive(1);break;case 50:// 飞机的攻击方法还没定义呢ship.attack();break;}
}
按常理,现在应该写攻击逻辑,不过有子弹才打得出去嘛,所以先写子弹对象相关的代码。
class Bullet {int bx = 0;int by = 0;int speed = 5;// 公用子弹类,调用不同方法来向上飞,还是向下飞boolean up() {by -= speed;image(bulletUp, bx, by, 2, 14);}boolean down() {by += speed;image(bulletDown, bx, by, 2, 14);}
}// 用不同的图片区分敌我子弹
PImage bulletUp;
PImage bulletDown;
// 为了演示,所以在这new一个子弹
Bullet bullet = new Bullet();void setup() {...bulletUp = loadImage("resource/bullet_up.png");bulletDown = loadImage("resource/bullet_down.png");
}void draw() {...bullet.up();
}
接下来,就是攻击啦。
// 为子弹类加上属性,方法
// 是否要显示
boolean alive = false;// 击发
void trigger(int initX, int initY, int initSpeed) {alive = true;bx = initX;by = initY;speed = initSpeed;
}// 飞出屏幕后,子弹就无需显示了
void clean() {alive = false;bx = 0;by = 0;
}// 修改up down方法
boolean up() {by -= speed;// 这里之所以是负数,是因为要等子弹完全飞出屏幕再移除if (by <= -14) {return false;}image(bulletUp, bx, by, 2, 14);return true;
}boolean down() {by += speed;if (by >= height + 14) {return false;}image(bulletDown, bx, by, 2, 14);return true;
}// 为飞机加上攻击方法
void attack() {// 对不起,我们只招休眠的子弹if (!bullet.alive) {// 子弹初始x轴的位置,5是子弹飞行速度bullet.trigger(sx + 20, sy, 5);break;}
}void draw() {...// 只显示活着的子弹if (bullet.alive) {if (!bullet.up()) {// 再见了,飞出屏幕的子弹bullet.clean();}}
}
为了控制屏幕中子弹数量,笔者并没有按一下攻击键就new一个子弹。而是创建了一个容器,预先放进去一些。当容器的子弹用光,就不再响应攻击了,当子弹飞出屏幕,再把子弹唤醒备用。
最后,也是最难的部分,碰撞测试。
笔者的思路是最简单暴力的方式,fake代码如下:
int len = 剩余敌人数量
for (int i = 0; i < len; i++) {// 算出敌人的上下左右边界,敌人是28 x 38的方块// int top = enemy.pool[i].ey;int bottom = enemy.pool[i].ey + 28;// int left = enemy.pool[i].ex;int right = enemy.pool[i].ex + 38;int len2 = 存活子弹数量for (int j = 0; j < len2; j++) {Bullet tmp = bullet[j];// 虽然子弹有宽度,但笔者偷懒把它看成一个点if (tmp.bx > enemy.pool[i].ex && tmp.bx < right && tmp.by < bottom && tmp.by > enemy.pool[i].ey) {// 如果这个点和敌人的块重合了,那么判定敌人被命中了enemy[i].alive = false;// 子弹击中物体,也消失了tmp.clean();}}}
}
这样做是可以实现功能的,但还是太粗犷了。所以笔者小小的优化了一下,献丑啦。
因为玩家和敌人的y轴是固定的,所以笔者创建了2个容器。每次子弹刷新时,把进入敌人y轴移动区域,和玩家y轴移动区域的子弹加载进去,然后修改上面的代码,只遍历这2个容器里的子弹。这样就大大减少了遍历次数。当下次刷新画面时,再把容器清空。当然,更成熟的做法是利用算法来加速计算,不过笔者能力有限,没有实践。
剩下的工作就很简单啦。
循环实例化敌人和玩家。敌人设置成每次移动就随机一个数,大于这个数就攻击。draw循环时,判断玩家是否被命中,或者敌人是否全灭来判断游戏要不要继续。笔者完成时,代码也就不到400行。最终效果如下:
完整代码见仓库(https://gitee.com/kyzx/mutalisk/tree/master/ctlTest),喜欢的话可以点个Star。
有了以上骨架,小伙伴们就可以疯狂实现自己的脑洞啦。比如将飞机移动改为可以上下左右的,为玩家和敌人加上血条,新增子弹弹道,子弹可以抵消子弹,分数机制等等等等。
不知不觉已经写了这么多了。
回忆起来,这个过程困难重重,身边也没有高人指点,但靠着互联网这个强大的工具,笔者还是磕磕绊绊的完成啦,其中的成就感不言而喻。
这个游戏玩起来真的非常简单,甚至还比不上那些黄色卡带里的游戏。但从0到1,这个摸索的过程,和当年小时候玩游戏时,是一样的,痛并快乐着。
愿,你永远是少年。