前言
今天给大家带来的游戏还是一款经典小游戏《吃豆人》,之前不知道在哪看到的这个游戏,就想着上班闲着时候摸摸鱼,看看能不能写出来。Pac-Man最早的艺名叫Pakkuman,源于“パクパク食べる”的发音paku-paku taberu,paku-paku表示嘴巴一张一合的动作和声音,形象描绘了“我吃,故我在”的生活态度,也希望大家每个人都是PacMan,能把生活中所有烦恼和麻烦都一起吃光光。
废话不多说,直接看代码。
一、游戏布局
游戏主旨就是在规定的地图内,布满金币和墙壁,玩家需要操控PacMan来进行上下左右移动,吃金币,击败怪物,来获取分数,进行通关。
关于布局里面一些细节,下面我都会一一说明
看到画面中右侧部分是游戏相关的一些说明,这里只说一点,那就是非常好看的一款字体,
可惜这款字体只能用在字母和数字上。
font-family: comic sans MS;
1. 绘制Pac-Man
关于用css画游戏的主角,我是用了一个div + ::before + @keyframes做成的效果。头部是一个圆,嘴巴就是一个伪元素用clip-path画成等腰三角形,然后利用动画改变伪元素的高度即可。运行出来就可以嘴巴一张一合了。
.spirit {
width: 24px;
height: 24px;
background-color: rgb(255,165,0);
border-radius: 50%;
position: absolute;
transition-property: left, top;
transition-timing-function: linear;
z-index: 2;
}
.spirit::before {
content: "";
position: absolute;
width: 80%;
height: 90%;
right: -1px;
top: 50%;
background-color: rgb(5, 31, 1);
transform: translateY(-50%);
clip-path: polygon(100% 0, 100% 100%, 20% 50%);
animation: zmlAniMouth .5s infinite linear;
}
@keyframes zmlAniMouth {
0% { height: 100%; }
50% { height: 0%; }
100% { height: 100%; }
}
2. 绘制地图map
(1)首先地图我是用一个二维数组生成。
其中 0=墙 1=路 2=金币 3=吃豆人 4=怪物 5=大金币
别问为什么2和5都是金币,为啥要离那么远,因为大金币是后来才加的,懒得返回去改了。
保存运行…!!!有点丑…接下来用js对地图进行优化处理
我们需要去除砖块之间多余的边框,最起码看起来要像一堵承重墙。
// 首先我们需要获取所有的砖块
// 还要获取所有的砖块元素
const wallList = []; // 获取砖块
const trList = document.querySelectorAll('.tr'); // 获取所有tr元素
for(let i = 0; i < this.list.length; i++) {
for(let j = 0; j < this.list[0].length; j++) {
if(this.list[i][j] === 0) {
wallList.push([i, j])
}
}
}
// 遍历所有墙
for(let i = 0; i < wallList.length; i++) {
let [x, y] = wallList[i];
if(this.list[x][y] === 0) {
// 把墙壁砖块与砖块之间的间隙去除
// 就是判断每块砖的上下左右有没有其他的砖了,如果有,则去掉对应的边框
if(x - 1 >= 0 && this.list[x - 1][y] === 0) {
trList[x].childNodes[y].childNodes[0].style.borderTop = 0;
}
if(x + 1 <= this.row - 1 && this.list[x + 1][y] === 0) {
trList[x].childNodes[y].childNodes[0].style.borderBottom = 0;
}
if(y - 1 >= 0 && this.list[x][y - 1] === 0) {
trList[x].childNodes[y].childNodes[0].style.borderLeft = 0;
}
if(y + 1 <= this.col - 1 && this.list[x][y + 1] === 0) {
trList[x].childNodes[y].childNodes[0].style.borderRight = 0;
}
// 把砖块的角磨平
// 用css圆角属性,我们只要判断每块砖的四角,每一个角的相邻两个地方有没有砖,如果两个地方都没有,则这个角需要变圆滑。
if(x-1 >= 0 && y-1 >= 0 && this.list[x-1][y] !== 0 && this.list[x][y-1] !== 0) {
trList[x].childNodes[y].childNodes[0].style.borderTopLeftRadius = '10px';
}
if(x-1 >= 0 && y+1 <= this.col-1 && this.list[x-1][y] !== 0 && this.list[x][y+1] !== 0) {
trList[x].childNodes[y].childNodes[0].style.borderTopRightRadius = '10px';
}
if(x+1 <= this.row-1 && y-1 >= 0 && this.list[x+1][y] !== 0 && this.list[x][y-1] !== 0) {
trList[x].childNodes[y].childNodes[0].style.borderBottomLeftRadius = '10px';
}
if(x+1 <= this.row-1 && y+1 <= this.col-1 && this.list[x+1][y] !== 0 && this.list[x][y+1] !== 0) {
trList[x].childNodes[y].childNodes[0].style.borderBottomRightRadius = '10px';
}
}
}
再次保存运行…完美!
3.绘制金币和幽灵
这个就不详细说明了,金币直接div渲染。闪光金币,利用动画+阴影。幽灵直接用图片代替。
二、游戏实现
1. 基本参数
allLevel: [level_1], // 全部关卡
list: [], // 0=墙 1=路 2=金币 3=吃豆人 4=怪物 5=大金币
list_detail: [], // 存放每个点的详细数据
row: 30, // 地图默认30行
col: 21, // 地图默认20列
location: [1, 1], // 吃豆人的坐标
dir: 'right', // 吃豆人的方向
left: 30, // 吃豆人左距离
top: 30, // 吃豆人上距离
speed: 100, // 速度
score: 0, // 分数 金币+1 大金币+5 怪物+10
timer: null, // 吃豆人定时器
isStart: false, // 是否开始
isOver: false, // 是否结束
isWin: null, // 是否 赢了
monster: [ // 怪物数组
{ location: [17, 8], left: 0, top: 0, dir: 'right', show: true },
{ location: [17, 9], left: 0, top: 0, dir: 'up', show: true },
{ location: [17, 10], left: 0, top: 0, dir: 'up', show: true },
{ location: [17, 11], left: 0, top: 0, dir: 'left', show: true },
],
timer_monster: null, // 怪物定时器
speed_monster: 300, // 怪物速度
isWeak: false, // 弱化怪物
weakTime: 5, // 弱化时间
timer_weak: null, // 弱化定时器
2. 游戏初始化
首先我们需要生成地图以及我们需要地图中每块的详细数据,比如每块左边、上边的距离。
后面我们需要根据left和top值进行吃豆人与怪物的定位。
默认每块的长宽是30
createMap() {
const level1 = this.allLevel[0];
const arr = [];
for(let i = 0; i < level1.length; i++) {
arr[i] = [];
for(let j = 0; j < level1[0].length; j++) {
arr[i].push({
left: j * 30,
top: i * 30
})
}
}
this.list_detail = JSON.parse(JSON.stringify(arr));
this.list = level1;
}
然后对地图进行打扮一番,上面已说过。
3. 监听键盘事件
这个地方我们按上下左右的时候,需要判断我们按得键导致移动的点,有没有墙,如果有墙,则改变方向无效
onKeyBoard() {
document.addEventListener('keydown', (e) => {
// 空格键开始
if(e.keyCode == 32 && !this.isStart) {
this.isStart = true;
this.spiritMove(); // 吃豆人移动
this.monsterMove(); // 幽灵移动
}
// 开始了 未结束,这时候我们可以操控
if(this.isStart && !this.isOver && [37,38,39,40].includes(e.keyCode)) {
let [x, y] = this.location;
let nexDir = '';
switch (e.keyCode){
case 37:
nexDir = 'left';
y -= 1;
break;
case 38:
nexDir = 'up';
x -= 1;
break;
case 39:
nexDir = 'right';
y+=1;
break;
case 40:
nexDir = 'down';
x+=1;
break;
}
if(this.list[x][y] == 0) {
return;
}
this.dir = nexDir;
}
})
}
4. 吃豆人走路
根据方位来进行location的调整,再根据当前的locaiton进行移动。如果碰到墙,则就会一直处于当前位置,不再进行移动,直到再次改变方位
这里我是用css的transition属性让吃豆人从一个点走到下一个点的,这里的移动时间数一定得跟定时器保持一致,这样才能移动完一格之后,才进行下一格的处理,让吃豆人走起路来更丝滑。
<!-- 精灵 -->
<div
class="spirit"
:style="{
'transform': 'rotate('+ spiritRotate +'deg)',
'left': left + 'px',
'top': top + 'px',
'transition-duration': speed + 'ms'
}"
></div>
spiritMove() {
if(this.timer) {
clearInterval(this.timer)
this.timer = null;
return;
}
this.timer = setInterval(() => {
let [x, y] = this.location;
switch (this.dir){
case 'left': y--; break;
case 'up': x--; break;
case 'right': y++; break;
case 'down': x++; break;
}
if(this.list[x][y] == 0) {
return;
}
this.location = [x, y];
this.drawSpirit();
}, this.speed)
}
5. 渲染吃豆人
每次改变完吃豆人location后,我们才要真正的进行移动操作。
详细请看代码注释
drawSpirit() {
// 根据当前的吃豆人位置,获取当前的left与top值
let [x, y] = this.location;
let targetLeft = this.list_detail[x][y].left + 3;
let targetTop = this.list_detail[x][y].top + 3;
[this.left, this.top] = [targetLeft, targetTop];
// 移动完之后,我们还要判断当前这个点有没有小金币或者大金币
if(this.isStart) {
// 吃小金币
if(this.list[x][y] == 2) {
this.score++;
}
// 吃大金币
// 吃完还要让所有的怪兽处于弱化状态,处于弱化状态的怪兽可以被吃
// 每个大金币弱化时间为自定义五秒,如果你吃的够快,则会重新刷新弱化时间
if(this.list[x][y] == 5) {
this.score += 5
clearInterval(this.timer_weak);
this.timer_weak = null;
this.isWeak = true;
this.onWeakTimer()
}
// 移动完之后,需要改变一下地图中吃豆人的位置(隐藏状态的吃豆人)
setTimeout(() => {
for(let i = 0; i < this.list.length; i++) {
for(let j = 0; j < this.list[0].length; j++) {
if(this.list[i][j] === 3) {
this.list[i][j] = 1;
}
}
}
this.list[x][y] = 3;
this.die();
this.win();
this.$forceUpdate();
}, this.speed/2); // 这里除2是因为,我想让吃豆人走到格子一半的时候,金币才消失,这样才像吃到嘴里的
}
}
样式中给它们加个类名就可以了
.monster.isWeak {
filter: grayscale(1);
}
6. 弱化定时器
onWeakTimer() {
let time = 0;
this.timer_weak = setInterval(() => {
time++;
if(time == this.weakTime) {
clearInterval(this.timer_weak);
this.isWeak = false;
}
}, 1000);
}
7. 幽灵走路
我们首先会给幽灵赋予方向感,然后让它自己顺着自己的方向进行移动。一个方向移动到底,碰到墙壁,则会随机取其他方位,再次进行移动。
monsterMove() {
this.timer_monster = setInterval(() => {
// 循环遍历所有幽灵
for (let i = 0; i < this.monster.length; i++) {
// 已经被吃的就不处理了
if(this.monster[i].show) {
let [x, y] = this.monster[i].location;
let dir = this.monster[i].dir;
// 首先需要拿到下一步的坐标,判断能不能走
switch (dir) {
case 'left': y--; break;
case 'up': x--; break;
case 'right': y++; break;
case 'down': x++; break;
}
// 判断下一步移动的地方是否有墙,
// 如果有墙,则随机从其他方位选一个继续移动
if(this.list[x][y] == 0) {
// 因为上面加过或者减过了,因为有墙不能走,则需要再加回来或者减回去,重新取其他方位走
switch (dir) {
case 'left': y++; break;
case 'up': x++; break;
case 'right': y--; break;
case 'down': x--; break;
}
// 判断其他范围有没有可以走的地,也就是判断上下左右有没有不是墙的
let arr = [];
if(x - 1 >= 0 && (this.list[x - 1][y] === 1 || this.list[x - 1][y] === 2)) {
arr.push({dir: 'up', location: [x-1, y]})
}
if(x + 1 <= this.row - 1 && (this.list[x + 1][y] === 1 || this.list[x + 1][y] === 2)) {
arr.push({dir: 'down', location: [x+1, y]})
}
if(y - 1 >= 0 && (this.list[x][y - 1] === 1 || this.list[x][y - 1] === 2)) {
arr.push({dir: 'left', location: [x, y-1]})
}
if(y + 1 <= this.col - 1 && (this.list[x][y + 1] === 1 || this.list[x][y + 1] === 2)) {
arr.push({dir: 'right', location: [x, y+1]})
}
// 随机取一个方位进行移动
let index = Math.floor(Math.random() * arr.length);
this.monster[i].dir = arr[index].dir;
this.monster[i].location = arr[index].location;
this.drawMonster();
return;
}
// 如果没有墙,则会一直顺着一个方位走下去
this.monster[i].location = [x, y];
this.drawMonster();
}
}
}, this.speed_monster);
}
7. 渲染幽灵
我们处理的过程中,不但要判断吃豆人能不能撞到幽灵,还要判断幽灵会不会撞到吃豆人。也就是说我站那不动,幽灵撞过来,我也会挂掉。
drawMonster() {
for (let i = 0; i < this.monster.length; i++) {
if(this.monster[i].show) {
let [x, y] = this.monster[i].location;
this.monster[i].left = this.list_detail[x][y].left + 3;
this.monster[i].top = this.list_detail[x][y].top + 3;
}
}
// 每次渲染完,就要判断有没有撞到吃豆人
this.die();
this.$forceUpdate();
}
8. 判断会不会挂掉
判断吃豆人的坐标是否与幽灵坐标重叠。
如果重叠了,还要判断当前幽灵是不是弱化状态的,如果是,就会变成“我”吃幽灵了
如果我们把所有幽灵都吃了,那就会直接获胜,直接获取所有金币奖励
die() {
// 吃豆人的坐标
const [xA, yA] = this.location;
// 遍历所有活的幽灵
for (let i = 0; i < this.monster.length; i++) {
if(this.monster[i].show) {
let [x, y] = this.monster[i].location;
// 幽灵没弱化,我直接挂掉
if(x == xA && y == yA && !this.isWeak) {
clearInterval(this.timer);
clearInterval(this.timer_monster);
this.isOver = true;
this.isWin = false;
break;
}
// 幽灵弱化了
if(x == xA && y == yA && this.isWeak) {
// 直接吃掉
this.monster[i].show = false;
this.score += 10;
// 每次吃怪物都要判断,是否把怪物吃完了,如果吃完为了,则直接获胜
const arr = this.monster.filter(item => item.show);
if(arr.length === 0) {
let arrBeansSmall = 0; // 剩余所有小金币分数
let arrBeansBig = 0; // 剩余素有大金币分数
for(let i = 0; i < this.list.length; i++) {
for(let j = 0; j < this.list[0].length; j++) {
if(this.list[i][j] === 2) {
this.list[i][j] = 1;
arrBeansBig++;
}
if(this.list[i][j] === 5) {
this.list[i][j] = 1;
arrBeansBig += 5;
}
}
}
this.score = this.score*1 + arrBeansSmall*1 + arrBeansBig*1;
this.win();
}
}
}
}
this.$forceUpdate();
}
9. 判断有没有赢
只要判断当前场上还有没有大金币或者小金币了
win() {
let arr = [];
for(let i = 0; i < this.list.length; i++) {
for(let j = 0; j < this.list[0].length; j++) {
if(this.list[i][j] === 2 || this.list[i][j] === 5) {
arr.push([i, j]);
}
}
}
if(arr.length === 0) {
clearInterval(this.timer);
clearInterval(this.timer_monster);
this.isOver = true;
this.isWin = true;
return;
}
this.$forceUpdate();
}
总结
游戏做出来还是比较喜欢的,用css+js做的确有些地方处理是不太好的。
准备这两天用canvas重新做一版出来,到时候利用碰撞检测处理这些效果,让游戏更丝滑,更具有游戏色彩。
谢谢观看!
完整代码:
zml-game-pacman: 《吃豆人》 (gitee.com)
喜欢h5游戏的可以看我其他文章,还希望看到的大兄弟可以点个赞,谢谢。