前言
《Pac-Man》上一次已经写过了,但是用vue写的,整体下来能玩,但是自己感觉不算流畅,而且游戏计算方面也有点欠缺,感兴趣的可看我上一篇文章。这次我又卷土重来,在三四天内用空闲时间,用canvas重构了一个完整且自认为完美的Pac-Man,无论是流畅度还是音乐,自测都感觉比较…perfect…
又因为此次中的代码还是比较繁琐的,我就不一一贴代码了,我就把整个游戏的做的过程走一遍,以及其中要注意的部分都会讲解到位。
此次游戏做的过程中,其实真的写起来还是有很多不明白的,自己也是慢慢的查阅、调试、询问等方式去慢慢啃下来。
废话不多说,直接走流程。
一、游戏代码分析
游戏展示用canvas绘制
<canvas id="myCanvas"></canvas>
游戏的核心代码就是用class类来分解游戏中各个元素
- Direction.js —— 方向
- Game.js —— 游戏入口
- Ghost.js —— 幽灵
- Pacman.js —— 吃豆人
- TileMap.js —— 地图
在核心代码class中,为了好区分,又把方法分为公共方法(不加#)和私有方法(加#)
例如:
draw() { ... }
#move() { ... }
二、游戏所需文件
(从上往下)
- 音乐文件(背景、死亡、吃金币、吃幽灵、吃闪光金币、获胜,全部用的是超级玛丽的音乐)
- 图片文件(墙壁砖、吃豆人的整个动效图片、幽灵本体、幽灵闪烁图片、金币图片、闪光金币图片)
- 核心文件(上面说的5个js文件)
- 样式文件(index.css)
- 入口文件(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();
}
}
}
(7)判断游戏是否赢了
找二维数组地图数据中只要没有金币即可
function checkGameWin() {
if(!gameWin) {
gameWin = tileMap.isWin();
if(gameWin) {
bgAudio.pause();
gameWinAudio.play();
}
}
}
(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需要引入
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;
}
}
(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的题,解题过程的思考对写小游戏还是有很大帮助的。
最后,
学习中娱乐,娱乐中学习。
谢谢观看!