canvas实现世界杯最佳接球手HTML5小游戏

最近全民热点事件就是世界杯了嘛,身为伪球迷为了蹭一波热度,于是在之前射击小游戏(戳这:canvas实现飞机打怪兽射击小游戏)基础上改了一个适配手机的接球手小游戏。
这个游戏其实比射击小游戏简化了,减少了场景、子弹类,主要区别的地方是把键盘事件改为了手指触摸事件,增加音效。

游戏演示

按照惯例,先甩 demo 地址:https://littleyljy.github.io/demo/soccergame/

游戏规则及场景

游戏规则简单粗暴,手指拖动守门员完成接球操作,然后根据接球数返回接球结果。场景也只有三个:

  • 开始游戏(.game-intro)
  • 游戏中(#canvas)
  • 游戏通关(.game-all-success)

面向对象

定义父类、子类的方法在射击小游戏(还是戳这:canvas实现飞机打怪兽射击小游戏)中已经阐述过了,这里就不详细讲了。这回把飞机对象当成守门员,怪兽对象当成足球(因为懒癌患者,类名没改代码直接复用了)。
怪兽部分可以照搬,飞机控制子弹的方法(如 hasHit() 、shoot()、drawBullets())都不需要,有区别的地方是:

  • 原来飞机只是左右移动,现在守门员需要全屏移动,即守门员 (x, y) 坐标值都会改变,direction() 改为 setPosition(x, y)
  • 还增加了一个判断守门员是否与足球接触的 hasCrash() 碰撞检测方法。
//方法:飞机方向
Plane.prototype.setPosition = function (x, y) {
    this.x = x;
    this.y = y;
    return this;//方便链式调用
};
//方法:碰撞检测
Plane.prototype.hasCrash = function(target){
    var crash = false;
    if(!(target.x + target.size < this.x) &&
    !(this.x + this.width < target.x) &&
    !(target.y + target.size < this.y) &&
    !(this.y + this.height < target.y)){
        crash = true;
    }
    return crash;
};

关于矩形碰撞检测的原理如下:
矩形2 和 矩形1 之间没有发生碰撞共有四种可能的情况:

  • 矩形2的右侧 离 矩形1的左侧有一段距离
  • 矩形2的左侧 离 矩形1的右侧有一段距离
  • 矩形2的底部 离 矩形1的顶部有一段距离
  • 矩形2的顶部 离 矩形1的底部有一段距离

当符合上面其中一种情况,则两个矩形没有发生碰撞。当上面四种情况都不满足的时候,则代表两个矩形碰撞了。


矩形物体碰撞检测,来源:腾讯课堂

我们把守门员当成一个矩形,通过判断四边是否有间距来确定是否发生了碰撞,碰撞返回 true。

游戏逻辑

游戏逻辑流程图

因为大部分逻辑与射击小游戏差不多,主要说下触摸事件、生成足球、返回的结果和游戏音效。

1、触摸事件

  • touchstart:当在屏幕上按下手指时触发。
  • touchmove:当在屏幕上移动手指时触发。
  • touchend:当在屏幕上抬起手指时触发
  • touchcancel:当一些更高级别的事件发生的时候(如电话接入或者弹出信息)会取消当前的touch操作,即触发 touchcancel。一般会在 touchcancel 时暂停游戏、存档等操作。(此段出处:移动端web开发---Touch事件详解

实现思路是(此处请脑补飞机就是守门员):我们需要记录手指刚触摸屏幕的坐标(startTouchX),手指移动到某点时候的坐标(newTouchX),然后相减就能得到手指滑动距离(newTouchX - startTouchX),滑动距离加飞机初始坐标(startPlaneX)就能得到飞机的新坐标(newPlaneX)。
我们把新坐标传入飞机的 setPosition(x, y) 方法,就能修改飞机的 (x, y) 属性了。当手指离开屏幕再次触摸时(即再次触发 touchstart ),就把飞机上一次的新坐标作为飞机初始坐标。
(关于 touches、targetTouches、changedTouches 三个触摸点列表可以戳这里了解:js中触摸相关变量touches,targetTouches和changedTouches的区别

bindTouchEvent: function () {
    var self = this;
    //飞机位置
    var newPlaneX = this.newPlaneX;
    var newPlaneY = this.newPlaneY;
    //手指初始位置坐标
    var startTouchX;
    var startTouchY;
    //飞机初始位置
    var startPlaneX;
    var startPlaneY;
    //首次触屏
    canvas.addEventListener('touchstart', function (e) {
      var plane = self.plane;
      //记录首次触摸位置
      startTouchX = e.touches[0].pageX;
      startTouchY = e.touches[0].pageY;
      //consol.log('touchstart', startTouchX, startTouchY);
      //记录飞机初始位置
      startPlaneX = plane.x;
      startPlaneY = plane.y;
    });
    //滑动触屏
    canvas.addEventListener('touchmove', function (e) {
      var newTouchX = e.touches[0].pageX;
      var newTouchY = e.touches[0].pageY;
      console.log('newTouch', newTouchX, newTouchY);
      //飞机新坐标=飞机起始坐标+手指滑动距离
      newPlaneX = startPlaneX + newTouchX - startTouchX;
      newPlaneY = startPlaneY + newTouchY - startTouchY;
      console.log('touchmove', newPlaneX, newPlaneY);
      if (newPlaneX < self.planeMinX) {
        newPlaneX = self.planeMinX;
      }
      if (newPlaneX > self.planeMaxX) {
        newPlaneX = self.planeMaxX;
      }
      if (newPlaneY < self.planeMinY) {
        newPlaneY = self.planeMinY;
      }
      if (newPlaneY > self.planeMaxY) {
        newPlaneY = self.planeMaxY;
      }
      self.plane.setPosition(newPlaneX, newPlaneY);
      //禁止默认事件,防止滚动屏幕
      e.preventDefault();
    });
  },

2、生成足球

为了制造随机出球的效果,下面这个公式不可少:

获取 [min, max] 范围的计算公式:Math.random() * (max - min + 1) + min

可以从三个参数控制:

  • (x, y) 坐标
  • 速度
  • 开始方向

足球横坐标在屏幕宽度范围内随机生成,纵坐标从[距离屏幕上边界一个屏高,0]范围内随机生成,这样下落的高度是随机的。
另外球速有慢有快,也是使用随机函数,取值范围可以自定义。

  //生成敌人
  createEnemy: function (enemyType) {
    var opts = this.opts;
    var level = opts.level;
    var enemies = this.enemies;
    var numPerLine = opts.numPerLine;
    var padding = opts.canvasPadding;
    var gap = opts.enemyGap;
    var size = opts.enemySize;
    var speed = opts.enemySpeed;

    //每升级一关敌人多numPerLine个
    for (var i = 0; i < level * numPerLine; i++) {
      var initOpt = {
        x: parseInt(Math.random() * (canvasWidth - size + 1) + padding, 10),
        y: -parseInt(Math.random() * (canvasHeight - size + 1) + padding, 10),
        size: size,
        speed: Math.round(Math.random() * 10 + 3, 10),
        status: enemyType,
        enemyDirection: randomDirection(),
        enemyIcon: opts.enemyIcon,
        enemyBoomIcon: opts.enemyBoomIcon
      };
      enemies.push(new Enemy(initOpt));
    }
    return enemies;
  },

足球一开始移动的方向随机左右,使用 randomDirection() 函数来实现,通过 Math.round(Math.random()) 随机生成 0 或 1 其中一个数,然后定义 0 代表左,1代表右,然后返回方向。

//随机生成方向
function randomDirection() {
  var direction = Math.round(Math.random());//随机赋值0或1
  direction = direction === 0 ? 'left' : 'right';
  return direction;
}

3、返回的结果

定义一个函数 famousMan(score) 不同档次得分时返回不同的背景图和文字,只要在 end() 中调用即可。

end: function (status) {
    var self = this;
    ctx.clearRect(0, 0, canvasWidth, canvasHeight);
    this.setStatus(status);
    totalScoreText.innerText = this.score;
    resultText.innerText = famousMan(self.score).goalkeeper;
    resultDescText.innerText = famousMan(self.score).resultdesc;
    effect.pause();
    return;
  },
//返回结果
function famousMan(score) {
  var goalkeeper = '';
  var resultdesc = '';
  var total = (CONFIG.numPerLine + CONFIG.totalLevel * CONFIG.numPerLine) * CONFIG.totalLevel / 2;
  if (0 <= score && score < total / 3) {
    goalkeeper = '中国队派来的卧底';
    resultdesc = 'emmmm...啥也不想说了';
    gameAllSuccess.style.backgroundImage = 'url(./img/bg-end-1.png)';
  } else if (total / 3 <= score && score < total / 2) {
    goalkeeper = '冰岛门将哈尔多松';
    resultdesc = '不好好接球就要回去当导演了';
    gameAllSuccess.style.backgroundImage = 'url(./img/bg-end-2.png)';
  } else if (total / 2 <= score && score < total * 2 / 3) {
    goalkeeper = '俄罗斯门将阿金费耶夫';
    resultdesc = '草原雕不发威当我是小鸡咕咕';
    gameAllSuccess.style.backgroundImage = 'url(./img/bg-end-6.png)';
  } else if (total * 2 / 3 <= score && score < total * 4 / 5) {
    goalkeeper = '墨西哥门将奥乔亚';
    resultdesc = '北美吴镇宇零封卫冕冠军出局';
    gameAllSuccess.style.backgroundImage = 'url(./img/bg-end-5.png)';
  }else if (total * 4 / 5 <= score && score < total *9 / 10) {
    goalkeeper = '英格兰门将皮克福德';
    resultdesc = '小个子保送三喵军团进四强';
    gameAllSuccess.style.backgroundImage = 'url(./img/bg-end-3.png)';
  } else {
    goalkeeper = '哇塞,获得金手套!';
    resultdesc = '你太牛了金手套非你莫属';
    gameAllSuccess.style.backgroundImage = 'url(./img/bg-end-4.png)';
  }
  console.log('goalkeeper', goalkeeper);
  return {
    goalkeeper: goalkeeper,
    resultdesc: resultdesc
  }
}

4、游戏音效

手机游戏为了体验效果,音效一般都是必不可少的。
首先在 HTML 中定义了两个 <audio> 标签,一个放背景音乐,一开始自动播放加循环(关于有些浏览器不允许页面一加载就自动播放的问题,我在canvas实现HTML5“正义联盟要造反”小动画这篇文章最后有提到一些解决方案),另一个是守门员接到球时触发的接球音效。

<audio id="bg-music" src="./audio/soccer.mp3" autoplay loop></audio>
<audio id="effect-music" src="./audio/biu.mp3"></audio>

在每次更新足球状态的时候,我之前尝试直接使用 effect.play(),但实际效果是当前音效播放完毕后才重新播放,这样会造成在很短时间内接中了好几个球,但是只播放了一次音效的问题。目前我的解决方案是 effect.cloneNode().play() ,每次接到一个球就复制并返回调用它的节点的副本。缺点是当生成的足球非常多的时候,会占用大量内存。

//更新敌人状态
  updateEnemeis: function () {
    var opts = this.opts;
    var plane = this.plane;
    var enemies = this.enemies;
    var i = enemies.length;

    //循环更新敌人
    while (i--) {
      var enemy = enemies[i];
      if (enemy.x < this.enemyMinX || enemy.x >= this.enemyMaxX) {
        enemy.enemyDirection = enemy.enemyDirection === 'right' ? 'left' : 'right';
      }
      enemy.down();
      enemy.direction(enemy.enemyDirection);
      switch (enemy.status) {
        case 'normal':
          if (plane.hasCrash(enemy)) {
            enemy.booming();
            effect.cloneNode().play();//会造成资源变大!
          }
          if (enemy.y >= canvasHeight) {
            enemies.splice(i, 1);//移出屏幕底部时从数组中删除
          }
          break;
        case 'booming':
          enemy.booming();
          break;
        case 'boomed':
          enemies.splice(i, 1);
          this.score += 1;
          break;
        default:
          break;
      }
    }
  },

另外 iOS 下微信浏览器中,只能听到背景音乐,听不到音效,而在手机QQ自带的浏览器中正常。

一个题外话

说个题外话,当时把这个 demo 链接发到微信朋友圈,第二天中午,微信居然提示该链接包含恶意欺诈内容,如下:

微信截图

很纳闷,游戏既没有分享提示,也没有获取用户信息,咋就恶意欺诈了呢?然后看了下微信规则(《微信外部链接内容管理规范》),发现微信现在对外链的管理超级严格,里面提到一条感觉勉强沾边的:

H5游戏、测试类内容
以游戏、测试等方式,吸引用户参与互动的,具体形式包括但不限于比手速、好友问答、性格测试,测试签、网页小游戏等;
若内容中包含以上情况,一经发现,立即停止链接内容在朋友圈继续传播、停止对相关域名或IP地址进行的访问;对于情节恶劣的情况,永久封禁帐号、域名、IP地址。

难道是因为这个 H5 是网页小游戏,吸引用户参与互动,接球形式又类似拼手速?
然后很奇怪的是,当天下午又莫名其妙解封了。(@_@;)
emmm......总之一脸懵。(°ー°〃)

因为本人才疏学浅,有些想法可能比较表面,有不足之处还请指出。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,634评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,951评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,427评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,770评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,835评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,799评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,768评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,544评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,979评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,271评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,427评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,121评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,756评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,375评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,579评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,410评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,315评论 2 352

推荐阅读更多精彩内容