小游戏之欢乐吃豆人canvas重制版

作者用canvas重构了Pac-Man游戏,包括地图、吃豆人、幽灵等元素的实现,详细介绍了游戏的核心代码结构和逻辑,如方向控制、碰撞检测、游戏胜利和失败条件。文章强调了canvas在游戏开发中的应用,并分享了整个开发过程中的学习体验。
摘要由CSDN通过智能技术生成

前言

《Pac-Man》上一次已经写过了,但是用vue写的,整体下来能玩,但是自己感觉不算流畅,而且游戏计算方面也有点欠缺,感兴趣的可看我上一篇文章。这次我又卷土重来,在三四天内用空闲时间,用canvas重构了一个完整且自认为完美的Pac-Man,无论是流畅度还是音乐,自测都感觉比较…perfect…

又因为此次中的代码还是比较繁琐的,我就不一一贴代码了,我就把整个游戏的做的过程走一遍,以及其中要注意的部分都会讲解到位。

此次游戏做的过程中,其实真的写起来还是有很多不明白的,自己也是慢慢的查阅、调试、询问等方式去慢慢啃下来。

废话不多说,直接走流程。

1e2ab47e351a7e3a47236da6cbad674.png

一、游戏代码分析

游戏展示用canvas绘制

<canvas id="myCanvas"></canvas>

游戏的核心代码就是用class类来分解游戏中各个元素

  • Direction.js —— 方向
  • Game.js —— 游戏入口
  • Ghost.js —— 幽灵
  • Pacman.js —— 吃豆人
  • TileMap.js —— 地图

在核心代码class中,为了好区分,又把方法分为公共方法(不加#)和私有方法(加#)
例如:

draw() { ... }
#move() { ... }

二、游戏所需文件

image.png
(从上往下)

  1. 音乐文件(背景、死亡、吃金币、吃幽灵、吃闪光金币、获胜,全部用的是超级玛丽的音乐)
  2. 图片文件(墙壁砖、吃豆人的整个动效图片、幽灵本体、幽灵闪烁图片、金币图片、闪光金币图片)
  3. 核心文件(上面说的5个js文件)
  4. 样式文件(index.css)
  5. 入口文件(index.html)

三、游戏基本布局

1. 页面布局
<div id="app">
  <!-- 游戏名 -->
  <h1 class="title">Pac-Man</h1>
  <!-- 游戏区 -->
  <div class="game">
    <canvas id="myCanvas"></canvas>
  </div>
</div>
<script src="js/Game.js" type="module"></script>
2. 样式
* { padding: 0; margin: 0; }
#app {
  width: 100%;
  height: 100vh;
  padding-top: 100px;
  position: fixed;
  display: flex;
  flex-direction: column;
  align-items: center;
  font-family: comic sans MS;
  background: linear-gradient(0deg, rgb(17, 51,161) 0%, rgb(136,34,195) 100%);
}
.title {
  color: lightgray;
  margin-bottom: 30px;
  font-size: 50px;
  user-select: none;
}

.game {
  background-color: #000;
}

#myCanvas {
  display: block;
  box-shadow: 10px 10px 20px black;
}

四、聊聊游戏核心代码

1. 入口类 Game.js

这个类的作用,主要就是存放游戏的基本配置信息,比如获取canvas,游戏定时器,游戏胜利、失败等

(1)在游戏开始,我们需要获取canvas

const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');

(2)还要配置需要的基本参数

const tileSize = 30; // 砖块尺寸(每个格子的长宽)                   
const speed = 2;     // 速度    
const gameOverAudio = new Audio('../audio/die.wav');  // 游戏结束音效
const gameWinAudio = new Audio('../audio/win.wav');   // 游戏胜利音效
const bgAudio = new Audio('../audio/bg.mp3');         // 游戏背景音乐
let gameOver = false; // 游戏是否结束
let gameWin = false;  // 游戏是否胜利

(3)又因为我们的游戏是高灵活性的,可以随意搭建自己想玩的地图,所以我们的canvas也要根据地图来生成大小

// 设置地图默认大小
tileMap.setCanvasSize(canvas);   

(4)页面加载时,游戏需要一个定时器,来实时监听页面的变化

// 游戏定时器
setInterval(gameLoop, 1000 / 75);

// 游戏运行
function gameLoop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);   // 每次先清空画布
  
  tileMap.draw(ctx);                                  // 画地图
  pacman.draw(ctx, pause(), ghost);                   // 画吃豆人
  ghost.forEach(e => e.draw(ctx, pause(), pacman));   // 画幽灵
  
  checkGameOver();  // 检查是否结束
  checkGameWin();   // 检查是否获胜
  drawGameDraw();   // 画游戏结束的提示框
  
  // 只有吃豆人动了,背景音乐才播放,游戏结束则停止播放
  if(pacman.madeFirstMove && !gameOver && !gameWin) {
    bgAudio.play();
  }
}

上面的个别代码比如传参啊,比如方法的内容,后面我们都会一一说明,因为这几个类基本上都会互相调用,要是放一起就会显得很乱,只能单拎出来说,最后再放一起整合就好了。

(5)定义暂停函数

// 是否暂停
// 如果 吃豆人没动,或者 游戏输了或者赢了,游戏都会处于暂停状态
function pause() {
  return !pacman.madeFirstMove || gameOver || gameWin;
}

(6)判断游戏是否结束

其实就是根据2D碰撞检测,检测幽灵是否碰到吃豆人了

下面是MDN官网的链接,感兴趣可以瞅瞅。

[MDN-web-docs](2D 碰撞检测 - 游戏开发环境 | MDN (mozilla.org))

function checkGameOver() {
  if(!gameOver) {
    // powerCoinActive:吃豆人是否吃到闪光豆豆
    // collidePacMan: 幽灵里面有没有哪个幽灵与吃豆人发生碰撞
    gameOver = ghost.some(item => !pacman.powerCoinActive && item.collidePacMan(pacman));
    if(gameOver) {
      bgAudio.pause();
      gameOverAudio.play();
    }
  }
}

image.png

(7)判断游戏是否赢了

找二维数组地图数据中只要没有金币即可

function checkGameWin() {
  if(!gameWin) {
    gameWin = tileMap.isWin();
    if(gameWin) {
      bgAudio.pause();
      gameWinAudio.play();
    }
  }
}

image.png

(8)游戏结束提示框

function drawGameDraw() {
  if(gameOver || gameWin) {
    let txt = 'You Win!'
    if(gameOver) {
      txt = 'Game Over!'
    }
    ctx.fillStyle = 'black';
    ctx.fillRect(0, canvas.height / 2.5, canvas.width, 100);
    ctx.font = '80px comic sans';
    
    // 渐变  官网有示例  直接拿来用
    const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
    gradient.addColorStop('0', 'magenta');
    gradient.addColorStop('0.5', 'blue');
    gradient.addColorStop('1.0', 'red');

    ctx.fillStyle = gradient;
    ctx.textAlign = 'center';
    ctx.fillText(txt, canvas.width / 2, canvas.height / 1.75);
  }
}
2. 地图类 TileMap.js

TileMap主要存放关于地图的一切,墙壁、豆豆、闪光豆豆、判断撞墙、是否吃到豆豆、还有上面说的是否胜利等,为了能实时获取吃豆人和幽灵的位置信息,他们也是在这个里面获取的

(1)定义基本参数

constructor(tileSize) {
    this.tileSize = tileSize;

    // 金币
    this.coinDot = new Image();
    this.coinDot.src = "../img/coin1.png";

    // 闪光金币
    this.pinkCoinDot = new Image();
    this.pinkCoinDot.src = "../img/coinPink.png";

    // 墙
    this.wall = new Image();
    this.wall.src = "../img/wall.png";

    // 闪光金币定时器,说白一点,就是频繁切换金币图片,有闪烁的效果
    this.powerCoinTimerDefault = 40;
    this.powerCoinTimer = this.powerCoinTimerDefault;
    this.powerCoinDot = this.coinDot;
  }

(2)定义地图数据

依旧是一个二维数组

// 0 = 金币   
// 1 = 墙   
// 4 = 吃豆人  
// 5 = 空地
// 6 = 幽灵
// 7 = 闪光金币
map = [
    [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
    [1,6,0,0,0,0,0,0,7,0,0,0,0,0,0,0,0,0,4,1],
    [1,0,1,0,1,1,1,1,0,1,1,1,1,0,1,0,0,1,0,1],
    [1,0,1,0,0,0,0,1,0,0,0,0,1,0,1,0,0,1,0,1],
    [1,0,1,0,0,0,0,1,0,0,0,0,1,0,1,0,0,1,0,1],
    [1,0,1,0,0,0,0,1,0,0,0,0,1,0,1,0,0,1,0,1],
    [1,0,1,0,0,0,6,1,0,0,0,0,1,0,1,0,0,1,0,1],
    [1,7,1,0,1,1,1,1,0,1,1,1,1,0,1,1,1,1,7,1],
    [1,0,1,0,1,0,0,0,0,0,0,0,1,0,0,0,7,1,0,1],
    [1,0,1,0,1,0,0,0,0,0,0,0,1,0,0,0,0,1,0,1],
    [1,0,1,0,1,0,0,0,0,0,0,0,1,0,0,0,0,1,0,1],
    [1,0,1,0,1,0,0,0,0,0,0,6,1,0,0,0,0,1,0,1],
    [1,0,1,0,1,1,1,1,0,1,1,1,1,0,0,0,0,1,0,1],
    [1,6,0,0,0,0,0,0,7,0,0,0,0,0,0,0,0,0,6,1],
    [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
  ]

都定义好了,接下来就是画基本游戏界面了

(3)确定canvas的长宽

setCanvasSize(canvas) {
    canvas.width = this.map[0].length * this.tileSize;
    canvas.height = this.map.length * this.tileSize;
}

(4)绘制地图

draw(ctx) {
    for(let i = 0; i < this.map.length; i++) {
      for(let j = 0; j < this.map[0].length; j++) {
        let tile = this.map[i][j];
        if(tile === 1) {
          this.#drawWall(ctx, i, j, this.tileSize);
        } else if(tile === 0) {
          this.#drawCoin(ctx, i, j, this.tileSize);
        } else if(tile === 7) {
          this.#drawSuperCoin(ctx, i, j, this.tileSize)
        } else {
          this.#drawBlank(ctx, i, j, this.tileSize);
        }
      }
    }
}

// 绘制墙
#drawWall(ctx, row, col, size) {
    ctx.drawImage(this.wall, col * size, row * size, size, size);
}

// 绘制金币
#drawCoin(ctx, row, col, size) {
    ctx.drawImage(this.coinDot, col * size, row * size, size, size);
}

// 绘制超级金币(buffer: 使怪物弱化)
#drawSuperCoin(ctx, row, col, size) {
    this.powerCoinTimer--;
    if(this.powerCoinTimer == 0) {
      this.powerCoinTimer = this.powerCoinTimerDefault;
      if(this.powerCoinDot == this.pinkCoinDot) {
        this.powerCoinDot = this.coinDot;
      } else {
        this.powerCoinDot = this.pinkCoinDot;
      }
    }
    ctx.drawImage(this.powerCoinDot, col * size, row * size, size, size);
}

// 绘制空地
#drawBlank(ctx, row, col, size) {
    ctx.fillStyle = 'black';
    ctx.fillRect(col * size, row * size, size, size);
}

(5)获取吃豆人的信息

注意这里需要引入吃豆人类,也就是吃豆人的一些基本信息

  getPacman(speed) {
    for(let i = 0; i < this.map.length; i++) {
      for(let j = 0; j < this.map[0].length; j++) {
        let tile = this.map[i][j];
        if(tile == 4) {
          this.map[i][j] = 0; // 无论吃豆人还是幽灵是不需要占位的,所以需要把它们那一块变成豆豆
          return new Pacman( j * this.tileSize, i * this.tileSize, this.tileSize, speed, this);
        }
      }
    }
  }

(6)获取幽灵

注意这里需要引入幽灵类,获取全部的幽灵,同上

  getGhost(speed) {
    const ghosts = [];
    for(let i = 0; i < this.map.length; i++) {
      for(let j = 0; j < this.map[0].length; j++) {
        const tile = this.map[i][j];
        if(tile === 6) {
          this.map[i][j] = 0;
          ghosts.push(
            new Ghost( j * this.tileSize, i * this.tileSize, this.tileSize, speed, this)
          );
        }
      }
    }
    return ghosts;
  }

(7)判断撞墙

我们可以根据坐标以及方向,判断下一块是否是墙

Direction需要引入

image.png

isCollideWall(x, y, direction) {
    if(direction == null) {
      return;
    }
    // 必须是整数才可以,因为游戏里,都是一块一块的
    if(Number.isInteger(x / this.tileSize) && Number.isInteger(y / this.tileSize)) {
      let col = 0;    // 纵坐标
      let row = 0;    // 横坐标
      let nextCol = 0;  // 下一块的左距离
      let nextRow = 0;  // 下一块的上距离

      switch(direction) {
        case Direction.up:
          nextRow = y - this.tileSize;
          row = nextRow / this.tileSize;
          col = x / this.tileSize;
          break;
        case Direction.down:
          nextRow = y + this.tileSize;
          row = nextRow / this.tileSize;
          col = x / this.tileSize;
          break;
        case Direction.left:
          nextCol = x - this.tileSize;
          col = nextCol / this.tileSize;
          row = y / this.tileSize;
          break;
        case Direction.right:
          nextCol = x + this.tileSize;
          col = nextCol / this.tileSize;
          row = y / this.tileSize;
          break;
      }
      
      const tile = this.map[row][col];
      if(tile === 1) {
        return true;
      }
    }
    return false;
 }

(8) 吃豆豆

我们可以根据坐标来判断当前所在格子中,是否有豆豆

isEatDot(x, y) {
    const row = y / this.tileSize;
    const col = x / this.tileSize;
    if(Number.isInteger(row) && Number.isInteger(col)) {
      if(this.map[row][col] === 0) {
        this.map[row][col] = 5;
        return true;
      }
    }
    return false;
}

(9) 吃闪光豆豆(同上)

isEatPowerDot(x, y) {
    const row = y / this.tileSize;
    const col = x / this.tileSize;
    if(Number.isInteger(row) && Number.isInteger(col)) {
      if(this.map[row][col] === 7) {
        this.map[row][col] = 5;
        return true;
      }
    }
    return false;
}

(10)判断是否赢了

// flat() 方法方法会按照一个可指定的深度递归遍历数组,并将所有元素与遍历到的子数组中的元素合并为一个新数组返回
isWin() {
    const len = this.map.flat().filter(item => item === 0).length;
    return len === 0;
}
3. 吃豆人 Pacman.js

Pacman主要存放吃豆人信息

(1)定义吃豆人基础参数

constructor(x, y, size, speed, tileMap) {
    this.x = x;
    this.y = y;
    this.size = size;
    this.speed = speed;
    this.tileMap = tileMap;

    this.currentDirection = null;                             // 当前运动的方向
    this.requestDirection = null;                             // 期望的方向(我想让它改变的方向)

    this.pacmanAnimateTimerDefault = 10;                      // 吃豆人动画  默认值
    this.pacmanAnimateTimer = null;                           // 吃豆人多久换下一张图片

    this.pacmanRotate = this.rotates.left;                    // 吃豆人旋转

    this.eatAudio = new Audio('../audio/eat.wav');            // 吃豆豆音效
    this.eatPowerAudio = new Audio('../audio/eatPower.mp3');  // 吃闪光豆豆音效
    this.powerCoinActive = false;                             // 是否吃到了闪光豆豆
    this.powerCoinAboutToExpire = false;                      // 闪光豆豆的效果是否快失效了,如果一个闪光豆豆的效果是6s,那当第3秒的时候,就让幽灵闪烁起来,提示用户快失效了
    this.timer = [];                                          // 存放闪光豆豆效果的计时器,和失效的计时器。

    this.eatGhostAudio = new Audio('../audio/eatGhost.wav');  // 吃幽灵音效

    this.madeFirstMove = false;                               // 吃豆人是否已经开始移动了,true移动了,这时候幽灵也可以移动了

    this.#loadPacmanImages();

    document.addEventListener('keydown', this.#keyDown);      // 监听键盘上下左右
  }
  
  //  旋转角度
  rotates = {
    left: 0,
    up: 1,
    right: 2,
    down: 3
  }

(2)绘制吃豆人

游戏过程中,需要对吃豆人进行暂停,所以我们在Game里定义一个pause方法。

对于吃豆人,如果游戏结束,或者未开始,它是不能动的。

还要在绘制的时候,传入幽灵数组,是为了吃豆人行动过程中,判断是否吃到幽灵

需要注意的是,在canvas里面,不能直接去对元素进行旋转,所以我们需要根据上下左右,进行旋转。这里我是从网上学到的,那就是对图像进行和整体canvas进行移动和旋转,比如可以先旋转图像,保存吃豆人的图像,然后再把canavs旋转回去,这样吃豆人就完成了旋转,这点就是比较麻烦的。

draw(ctx, pause, ghosts) {
    if(!pause) {
      this.#move();
      this.#pacmanAnimate();  // 吃豆人本身动画
    }
    this.#eatDot();         // 吃豆豆
    this.#eatPowerDot();    // 吃闪光豆豆
    this.#eatGhost(ghosts); // 吃幽灵

    // 绘制吃豆人,需要注意的重点
    const size = this.size / 2;
    ctx.save();
    ctx.translate(this.x + size, this.y + size);
    ctx.rotate((this.pacmanRotate * 90 * Math.PI) / 180);
    ctx.drawImage(this.pacmanImgs[this.pacmanIndex], -size, -size, this.size, this.size);
    ctx.restore();
}

(3)监听键盘上下左右

这里需要注意的是,我们在移动的过程中,是需要定义两个方向值,一个是当前移动方向,还有一个就是玩家希望吃豆人移动的方向,主要就是为了判断会不会撞到墙,其中相反方向不用判断,因为比如你往左走,突然又往右走,那说明刚才右边一定不会有墙。

还有一个需要注意,一个参数madeFirstMove 这个其实就是开始游戏一样,当我们按下上下左右任意一个键,游戏才会正常开始,吃豆人和幽灵也开始移动

#keyDown = (e) => {
    // up
    if(e.keyCode == 38) {
      if(this.currentDirection == moveDirection.down) {
        this.currentDirection = moveDirection.up;
      }
      this.requestDirection = moveDirection.up;
      this.madeFirstMove = true;
    }
    // down
    if(e.keyCode == 40) {
      if(this.currentDirection == moveDirection.up) {
        this.currentDirection = moveDirection.down;
      }
      this.requestDirection = moveDirection.down;
      this.madeFirstMove = true;
    }
    // left
    if(e.keyCode == 37) {
      if(this.currentDirection == moveDirection.right) {
        this.currentDirection = moveDirection.left;
      }
      this.requestDirection = moveDirection.left;
      this.madeFirstMove = true;
    }
    // right
    if(e.keyCode == 39) {
      if(this.currentDirection == moveDirection.left) {
        this.currentDirection = moveDirection.right;
      }
      this.requestDirection = moveDirection.right;
      this.madeFirstMove = true;
    }
  }

(4)吃豆人移动

根据移动方向进行+ -操作,以及移动过程中的注意事项,下面注释中都已标出

#move() {
    // 如果当前移动方向与期望的移动方向不一样,说明你想转弯了
    // 但是你还不能随便转弯,需要当吃豆人的上距离 并且左距离 正好够方块size的整数
    // 比如总不能走到某个墙的一半的时候转弯吧,这是不可行的
    if(this.currentDirection !== this.requestDirection) {
      // isInteger() 函数用于检测指定参数是否为无整数,如果是整数返回 true,否则返回 false
      if(Number.isInteger(this.x / this.size) && Number.isInteger(this.y / this.size)) {
        // 判断是否撞到了墙, 当期望方向上的第一块不是墙,才可以转弯
        // 比如当前方向是向右的,我突然按了下,这时候就需要判断下面那一块是否有墙,有则不能转弯,没有则可以直接转弯
        if(!this.tileMap.isCollideWall(this.x, this.y, this.requestDirection)) {
          this.currentDirection = this.requestDirection;
        }
      }
    }

    // 一直朝一个方向移动,如果撞墙了,则停止,并且吃豆人动画暂停
    if(this.tileMap.isCollideWall(this.x, this.y, this.currentDirection)) {
      this.pacmanAnimateTimer = null;
      this.pacmanIndex = 1;
      return;
    } else if(this.currentDirection != null && this.pacmanAnimateTimer == null) {
      this.pacmanAnimateTimer = this.pacmanAnimateTimerDefault;
    }
    
    // 移动过程中,还要保证旋转角度的改变
    switch (this.currentDirection) {
      case moveDirection.up:
        this.y -= this.speed;
        this.pacmanRotate = this.rotates.up;
        break;
      case moveDirection.down:
        this.y += this.speed;
        this.pacmanRotate = this.rotates.down;
        break;
      case moveDirection.left:
        this.x -= this.speed;
        this.pacmanRotate = this.rotates.left;
        break;
      case moveDirection.right:
        this.x += this.speed;
        this.pacmanRotate = this.rotates.right;
        break;
    }
}

(5)吃豆人的张嘴动效

这个其实就是频繁的切换图片,来达到张嘴闭嘴的动画效果

#loadPacmanImages() {
    const pacmanImg1 = new Image();
    pacmanImg1.src = '../img/pacman1.png';
    const pacmanImg2 = new Image();
    pacmanImg2.src = '../img/pacman2.png';
    const pacmanImg3 = new Image();
    pacmanImg3.src = '../img/pacman3.png';
    const pacmanImg4 = new Image();
    pacmanImg4.src = '../img/pacman2.png';

    this.pacmanImgs = [pacmanImg1, pacmanImg2, pacmanImg3, pacmanImg4]; // 吃豆人图片数组
    this.pacmanIndex = 1;   // 默认
}


#pacmanAnimate() {
    if(this.pacmanAnimateTimer == null) {
      return;
    }
    this.pacmanAnimateTimer--;
    if(this.pacmanAnimateTimer == 0) {
      this.pacmanAnimateTimer = this.pacmanAnimateTimerDefault;
      this.pacmanIndex++;
      if(this.pacmanIndex == this.pacmanImgs.length) {
        this.pacmanIndex = 0;
      }
    }
  }

(6)吃豆豆

在地图类里面,我们定义了是否吃到了豆豆,在这里就可以用了,每吃一个豆豆就会播放音效

#eatDot() {
    if(this.tileMap.isEatDot(this.x, this.y) && this.madeFirstMove) {
      // play() failed because the user didn't interact with the document first.
      // 这个错误信息,其实就是最新的浏览器告诉你,现在不能上来就播放音乐了,得需要跟播放做交互动作
      // 这时候只要加上我们定义的 madeFirstMove 即可,
      // this.eatAudio.play();
    }
  }

(6)吃闪光豆豆

吃闪光豆豆比吃豆豆需要多一个效果,就是可以弱化幽灵。在这里我们需要用定时器来对每只幽灵的效果时间进行计算,每吃一个闪光豆豆,就有6秒的弱化时间,并且当时间过去一半的时候,幽灵是需要闪烁来提示玩家,效果快要过期了

#eatPowerDot() {
    if(this.tileMap.isEatPowerDot(this.x, this.y)) {
      // 播放音效
      this.eatPowerAudio.play();

      // 弱化效果
      this.powerCoinActive = true;          // 效果开启
      this.powerCoinAboutToExpire = false;  // 这时候还不用提示快失效了
      
      // 默认先清空,防止吃完第一颗闪光豆豆后,时间还没到,又吃第二颗
      this.timer.forEach(timer => clearTimeout(timer));
      this.timer = [];

      //  效果一共持续时间
      let powerCoinActiveTimer = setTimeout(() => {
        this.powerCoinActive = false;
        this.powerCoinAboutToExpire = false;
      }, 1000 * 6);

      this.timer.push(powerCoinActiveTimer);

      // 时间到达一半的时候,需要提示快失效了
      let powerCoinAboutToExpireTimer = setTimeout(() => {    
        this.powerCoinAboutToExpire = true;
      }, 1000 * 3);

      this.timer.push(powerCoinAboutToExpireTimer);
    }
}

这里我就不贴图了,我不会做gif图片。

(7)吃幽灵

这个就只需要判断,我们吃闪光豆豆的效果期间,幽灵有没有碰到我(我有没有碰到幽灵)。碰到则就把当前幽灵从幽灵数组中删除

collidePacMan方法 在幽灵类中定义,就是用来判断,幽灵有没有碰到玩家的吃豆人,用的是2D碰撞检测方法

#eatGhost(ghosts) {
    if(this.powerCoinActive) {
      const collideGhost = ghosts.filter(item => item.collidePacMan(this));
      collideGhost.forEach(item => {
        ghosts.splice(ghosts.indexOf(item), 1);
        this.eatGhostAudio.play();
      });
    }
  }
4. 关于方向 Direction.js

这个不多说,其实就相当于定义了一个全局方向枚举

const Direction = {
  up: 0,
  down: 1,
  left: 2,
  right: 3
}

export default Direction;
5. 幽灵类 Ghost.js

存放关于幽灵的一切

先分析一下幽灵,幽灵在定义好的地图中产生,然后刚开始默认是不能动的,当玩家点击上下左右的时候,这时候幽灵才可以移动,并且幽灵移动的方向是随机的,下来我们定义一个变量,来根据这个值决定,幽灵多长时间想要转变方向。幽灵的移动是随机的,幽灵碰到吃豆人,则游戏结束(没吃闪光豆豆的前提下)。 最后幽灵会改变自身的颜色(其实就是换图片),也就是被弱化到一半时间的时候,上面都说过,会有弱化时间。

(1)定义幽灵所需要的参数

constructor(x, y, size, speed, tileMap) {
    this.x = x;
    this.y = y;
    this.size = size;
    this.speed = speed;
    this.tileMap = tileMap;

    this.#laodImages();  // 加载幽灵图片

    this.randomMoveDir = Math.floor(
      Math.random() * Object.keys(Direction).length
    );              // 随机获取一个方向

    this.directionTimerDefault = this.#random(10, 30);  // 默认多长时间切换一次方向, 值越小,幽灵换方向越频繁
    this.directionTimer = this.directionTimerDefault;   // 实际切换时间递减,到0时,表示要切换了

    this.blinkAboutToExpireTimerDefault = 10;           // 常量 默认闪烁的间隔(也就是频繁的切换图片)
    this.blinkAboutToExpireTimer = this.blinkAboutToExpireTimerDefault; // 闪烁间隔递减,到0时,切换图片
  }
  
// 拿到幽灵所需要的所有图片
#laodImages() {
    this.ghostImageDefault = new Image();
    this.ghostImageDefault.src = '../img/ghost1.png';

    this.ghostImageBlink1 = new Image();
    this.ghostImageBlink1.src = '../img/ghost2.png';

    this.ghostImageBlink2 = new Image();
    this.ghostImageBlink2.src = '../img/ghost3.png';

    this.ghostImageActive = this.ghostImageDefault; // 当前图片
}

// 获取一个区间的随机数
#random(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

(2) 绘制幽灵

游戏暂停,幽灵不能走动

draw(ctx, pause, pacman) {
    if(!pause) {
      this.#move();
      this.#changeDirection();
    }
    this.#setImage(ctx, pacman);
}

#setImage(ctx, pacman) {
    // 如果吃到了闪光豆豆,则需要让幽灵变色
    if(pacman.powerCoinActive) {
      this.#setImageWhenEatPowerCoin(pacman);
    } else {
      this.ghostImageActive = this.ghostImageDefault;
    }
    ctx.drawImage(this.ghostImageActive, this.x, this.y, this.size, this.size);
}

// 吃豆人吃了闪光豆豆,幽灵会立马变颜色
// 并且当时间过去一半的时候,幽灵需要频繁切换图片,达到闪烁的效果
#setImageWhenEatPowerCoin(pacman) {
    if(pacman.powerCoinAboutToExpire) {
      this.blinkAboutToExpireTimer--;
      if(this.blinkAboutToExpireTimer == 0) {
        this.blinkAboutToExpireTimer = this.blinkAboutToExpireTimerDefault;
        if(this.ghostImageActive == this.ghostImageBlink1) {
          this.ghostImageActive = this.ghostImageBlink2;
        } else {
          this.ghostImageActive = this.ghostImageBlink1;
        }
      }
    } else {
      this.ghostImageActive = this.ghostImageBlink1;
    }
  }

image.png

(3)幽灵移动

分析幽灵移动的特点,幽灵如果没到切换方向的时候,会一直往一个方向移动,直到撞到墙为止。另一个就是当切换方向时间到了,幽灵走到一半,突然换方向了。

#move() {
    // 如果没撞墙,则会一直走
    if(!this.tileMap.isCollideWall(this.x, this.y, this.randomMoveDir)) {
      switch (this.randomMoveDir) {
        case Direction.up:
          this.y -= this.speed;
          break;
        case Direction.down:
          this.y += this.speed;
          break;
        case Direction.left:
          this.x -= this.speed;
          break;
        case Direction.right:
          this.x += this.speed;
          break;
      }
    }
  }

(4)幽灵随机改变方向

每个幽灵会根据改变方向定义的时间,来定期的随机改变方向

#changeDirection() {
    this.directionTimer--;
    let newMoveDirection = null;
    if(this.directionTimer == 0) {
      this.directionTimer = this.directionTimerDefault;
      // 时间一到,重新随机获取一个方向
      newMoveDirection = Math.floor(
        Math.random() * Object.keys(Direction).length
      );
    }

    // 这里用了多重判断
    // 新的移动方向 不能跟随机产生的一样,没意义
    // 而且幽灵走的位置,刚刚好处于拐弯处
    // 并且新的方向上的那一块,不是墙壁
    if(newMoveDirection != null && this.randomMoveDir != newMoveDirection) {
      if(Number.isInteger(this.x / this.size) && Number.isInteger(this.y / this.size)) {
        if(!this.tileMap.isCollideWall(this.x, this.y, newMoveDirection)) {
          this.randomMoveDir = newMoveDirection;
        }
      }
    }
}

(5)2D碰撞检测 - 主要用于判断是否碰到吃豆人

collidePacMan(pacman) {
    const size = this.size / 1.5;
    if(
      this.x < pacman.x + size && 
      this.x + size > pacman.x &&
      this.y < pacman.y + size &&
      this.y + size > pacman.y
    ) {
      return true;
    }
    return false;
}
6. 其他

后续可以添加分数、时间、生命等模块

总结

游戏整体做下来,很过瘾,学到不少东西,而且成就感满满。这也是第一次用canvas写游戏,以后再写小游戏,首先考虑使用canvas。感觉很棒!现在也不知道为啥开始喜欢写一些小游戏之类的东西了,就感觉比平常业务代码更让人上瘾,尤其把一个游戏亲自做完的时候。以后还会慢慢接触更多的小游戏制作,以及小游戏过程中的不断学习。主要的还是得多看看原生的东西,比如js、canvas之类。也还得多刷刷leetcode的题,解题过程的思考对写小游戏还是有很大帮助的。

最后,

学习中娱乐,娱乐中学习。

谢谢观看!

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

张_大_炮

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值