扯淡前言

今天在 codepen 上发现一个 pong (也就是乒乓)的 Canvas 游戏,做的挺不错的,于是解读了它的源码,从中总结了一种 Canvas 游戏设计的思路和框架。

运用这份框架,我写了一个 贪吃蛇黑桃王 (一种扑克游戏),简单但效果还不错。

所以说框架果然很重要,有了体系和规则,剩下的就是完形填空一般的填写代码的快感。

游戏的设计模式

pong 这个 canvas 游戏由四部分组成:定模型,算模型,改模型,画模型

  • 定模型:定义游戏所有数据。如位置,速度,方向,角度,先手,颜色,得分,失分判定等
  • 算模型:就是数据更新,重算被修改后的数据。比如方向修改后,要根据方向与速度重算位置,位置修改后,要对可能引起的分数变化修改得分数据。
  • 改模型:就是监听器,监听用户动作更改游戏数据
  • 画模型:数据到画面的映射

定模型

  • 可把所有数据分为两类:常量与变量
  • 常量(如拍子的长度和宽度,移动速度,乒乓球的半径等),可以包装成外部对象引入
  • 变量又可分为两类:一类是能改变其他数据的数据,另一类是被改变的数据

比如说:方向数据可以通过影响 dx, dy,从而改变位置数据 x, y

算模型

  • 专注于用能改变其他数据的数据改变其他数据

改模型

  • 专注于改变能改变数据的数据

代码框架

game loop is the heart of the game, it continuously refresh the frame and data model

// 定模型
init() {
    ...
    this.loop()
    this.listen()
}
// 算模型 + 画模型
loop() {
    this.update()
    this.draw()
}
// 改模型
listen() {
}

题外话:游戏其实就是控制我们所看到的画面。由于画面是由数据决定,所以游戏本质上就是控制我们能看的画面所依赖的数据


其他细节

Tips

1 如何播放音效?

new Audio + play

  1. 第一步 新建 Audio 对象 new Audio(音频文件地址或流字符串)
  2. 第二步 执行 .play( ) 方法
var snd2 = new Audio("data:audio/mpeg;base64,音频文件的编码");  
snd2.play()

2 canvas 清理画布,填充背景,写入文本的代码

  • 清理画布的代码
// Clear the Canvas
this.context.clearRect(
    0,
    0,
    this.canvas.width,
    this.canvas.height
);
  • 填充画布背景的代码
// Set the fill style of back
  this.context.fillStyle = this.color;
  
// Draw the background
this.context.fillRect(
  0,
  0,
  this.canvas.width,
  this.canvas.height
);
  • 写入文本的代码
// Set the default canvas font and align it to the center
this.context.font = '100px Courier New';
this.context.textAlign = 'center';

// Change the font size for the center score text
this.context.font = '30px Courier New';

// Draw the winning score (center)
this.context.fillText(
  'Round ' + (Pong.round + 1),
  (this.canvas.width / 2),
  35
);

3 按下移动,不按不动怎么做

同时监听按下和松开就行了

  • 监听keydown,修改移动方向数据
  • 监听keyup,还原移动方向数据
var PRESSING = {
  'ArrowUp': false,
  'ArrowRight': false,
  'ArrowDown': false,
  'ArrowLeft': false
}

listen: function() {
    document.addEventListener('keydown', e => {
      this.direction = DIRECTION[e.code] || DIRECTION.Default
      if(PRESSING[e.code] !== undefined) {
        PRESSING[e.code] = true
      }
    })
    document.addEventListener('keyup', e => {
      if(PRESSING[e.code] !== undefined) {
        PRESSING[e.code] = false
      }
      for(key in PRESSING) {
        if(PRESSING[key]) {
          return
        }
      }
      this.direction = DIRECTION.Default
    })
  }

如何添加菜单?

很简单,它只在 init() 中调用,每次 endGame() 通过 init() 间接唤醒。

menu: function() {
  //画菜单
}

源码结构

实体模型A + 实体模型B + ... + Game 模型 + Game.init

  • Ball 对象
  • Paddle 对象
  • Game 对象
  • 实例化 Game 对象并调用 initialize 方法
Ball {
  new: function (incrementedSpeed)
  //返回球的基础数据
}

Paddle {
  new: function (side)
  //side指定拍子在左还是在右,返回拍子的基础数据
}

Game {
  initialize: function ()
  //定义各项属性,并调用menu(), listen()

  endGameMenu: function (text)
  //过关或结束画面,text是显示文本

  menu : function ()
  //绘制开始界面菜单,调用draw()

  update: function ()
  //更新所有物品的位置参数,包括位置计算,碰撞检测,AI,胜负检测,调endGameMenu()
  //通关调_generateRoundColor(),在球接不到时调用_resetTurn()

  draw: function ()
  //画球,画板子,画网,画信息。画球时调用_turnDelayIsOver(victor, loser)

  loop: function ()
  //调用update(),draw()

  listen: function ()
  //监听键盘事件,如果还没开始,调用loop(),否则更改模型数据

  _resetTurn: function (victor, loser)
  //把球摆好,发球开始新的一回合

  _turnDelayIsOver: function ()
  //工具:延时函数

  _generateRoundColor: function ()
  //工具:更新桌布
}

实体模型的分离绑定

第一步

var Paddle = {
      new : function(param) {
          return {}
      }
  }

第二步

init: function() {
      var player = Paddle.new.call(this, xx) // 用call绑定的目的是使Game对象直接拥有数据
                                             // Paddle.abcd -> Game.abcd
                                             // 这样Game的this可以直接操作abcd
    }

loop 方法

在 requestAnimationFrame 中实现 update 与 draw 的循环

loop: function() {
    Pong.update()  //注意这个函数的三句话全是以实例Pong为对象而不是this
    Pong.draw()    //Pong = Object.assign({}, Game)
    if(!Pong.over) requestAnimationFrame(Pong.loop)
  }

update 方法

准备一个this.over变量,true代表游戏结束,false进行。

  if(!this.over){ ↔ } //游戏还没结束,常规计算
  if (胜利数据判断) {
    this.over = true
  }
  else if (失败数据判断) {
    this.over = true
  }
  if (下一回合数据判断) {} //如果表明进入下一回合,Pong._resetRound()
  if (下一球数据判断) {} //如果判断该球接不到(进入下一球),Pong._resetTurn()

draw 方法

清画布,填背景,然后各种 draw

// Clear the Canvas
// Draw the background
// Draw A
// Draw B
// Draw C
// Draw D
// ...