【硬核】梦回小霸王

长大,是一个不断失去的过程。

在这里插入图片描述

笔者是一位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号引脚为输出,是上面的N
   pinMode(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);
  // 被按下了,电平就是LOW
  if (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,这个摸索的过程,和当年小时候玩游戏时,是一样的,痛并快乐着。

愿,你永远是少年。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值