手把手教学h5小游戏 - 贪吃蛇
简单的小游戏制作,代码量只有两三百行。游戏可自行扩展延申。
源码已发布至github,喜欢的点个小星星,源码入口:
游戏已发布,游戏入口:
第一步 - 制作想法
游戏如何实现是首要想的,这里我的想法如下:
- 利用canvas进行绘制地图(格子装)。
- 利用canvas绘制蛇,就是占用地图格子。让蛇移动,即:更新蛇坐标,重新绘制。
- 创建四个方向按钮,控制蛇接下来的方向。
- 随机在地图上绘制出果子,蛇移动时“吃”到果子,增加长度和“移速”。
- 开始键和结束键配置,分数显示、历史记录
第二步 - 框架选型
从第一步可知,我想实现这个游戏,只需要用到canvas绘制就可以了,没有物理引擎啥的,也没有高级的ui特效。可以选个简单点的,用来方便操作canvas绘制。精挑细选后选的是easeljs,比较轻量,用于绘制canvas,以及canvas的动态效果。
第三步 - 开发
准备
目录和文件准备:
| - index.html
| - js
| - | - main.js
| - css
| - | - stylesheet.css
index.html 导入相关的依赖,以及样式文件和脚本文件。设计是屏幕80%高度为canvas绘制区域,20%高度是操作栏以及展示分数区域.
<!doctype html> <html lang="zh"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="x-ua-compatible" content="ie=edge"> <title>贪吃蛇</title> <link rel="stylesheet" href="css/stylesheet.css"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, minimal-ui"> </head> <body> <div id="app"> <div class="content-canvas"> <canvas></canvas> </div> <div class="control"> </div> </div> <script src="https://cdn.bootcss.com/easeljs/1.0.2/easeljs.min.js"></script> <!-- 载入jquery 方便dom操作 --> <script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script> <!-- sweetalert 美化alert用的 --> <script src="https://cdn.bootcss.com/sweetalert/2.1.2/sweetalert.min.js"></script> <script src="js/main.js"></script> </body> </html>
stylesheet.css
* { padding: 0; margin: 0; } body { position: fixed; width: 100%; height: 100%; } #app { max-width: 768px; margin-left: auto; margin-right: auto; } /* canvas绘制区域 */ .content-canvas { width: 100%; max-width: 768px; height: 80%; position: fixed; overflow: hidden; } .content-canvas canvas { position: absolute; width: 100%; height: 100%; } /* 操作区域 */ .control { position: fixed; width: 100%; max-width: 768px; height: 20%; bottom: 0; background-color: #aeff5d; }
main.js
$(function() { // 主代码编写区域 })
1.绘制格子
注意的点(遇到的问题以及解决方案):
- canvas绘制的路线是无宽度的,但线条是有宽度的。比如:从(0, 0)到(0, 100)绘制一条宽度为10px的线,则线条一半是在区域外看不见的。处理方案是起点偏移,比如:从(0, 0)到(0, 100)绘制一条宽度为10px的线,改为从(5,0)到(5,100),偏移量为线条宽度的一半。
- 用样式定义canvas的宽高坐标会被拉伸,处理方案是给canvas元素设置宽高属性,值为它当前的实际宽高。
代码
main.js
$(function () { var line_width = 1 // 线条宽度 var line_max_num = 32 // 一行格子数量 var canvasheight = $('canvas').height() // 获取canvas的高度 var canvaswidth = $('canvas').width() // 获取canvas的宽度 var gridwidth = (canvaswidth - line_width) / line_max_num // 格子宽度,按一行32个格子计算 var num = { w: line_max_num, h: math.floor((canvasheight - line_width) / gridwidth) } // 计算横向和纵向多少个格子,即:横坐标的最大值和纵坐标的最大值 /** * 绘制格子地图 * @param graphics */ function drawgrid(graphics) { var wnum = num.w var hnum = num.h graphics.setstrokestyle(line_width).beginstroke('#ffac52') // 画横向的线条 for (var i = 0; i <= hnum; i++) { if (i === hnum || i === 0) graphics.setstrokestyle(line_width) if (i === 1) graphics.setstrokestyle(0.1) graphics.moveto(line_width / 2, i * gridwidth + line_width / 2) .lineto(gridwidth * wnum + line_width / 2, i * gridwidth + line_width / 2) } graphics.setstrokestyle(line_width) // 画纵向的线条 for (i = 0; i <= wnum; i++) { if (i === wnum || i === 0) graphics.setstrokestyle(line_width) if (i === 1) graphics.setstrokestyle(.1) graphics.moveto(i * gridwidth + line_width / 2, line_width / 2) .lineto(i * gridwidth + line_width / 2, gridwidth * hnum + line_width / 2) } } function init() { $('canvas').attr('width', canvaswidth) // 给canvas设置宽高属性赋值上当前canvas的宽度和高度(单用样式配置宽高会被拉伸) $('canvas').attr('height', canvasheight) var stage = new createjs.stage($('canvas')[0]) var grid = new createjs.shape() drawgrid(grid.graphics) stage.addchild(grid) stage.update() } init() })
效果图
浏览器打开index.html
,可以看到效果:
2.绘制蛇
蛇可以想象成一串坐标点(数组),“移动时”在数组头部添加新的坐标,去除尾部的坐标。类似队列,先进先出。
代码
main.js
$(function () { var line_width = 1 // 线条宽度 var line_max_num = 32 // 一行格子数量 var snake_start_point = [[0, 3], [1, 3], [2, 3], [3, 3]] // 初始蛇坐标 var dir_enum = { up: 1, down: -1, left: 2, right: -2 } // 移动的四个方向枚举值,两个对立方向相加等于0 var game_state_enum = { end: 1, ready: 2 } // 游戏状态枚举 var canvasheight = $('canvas').height() // 获取canvas的高度 var canvaswidth = $('canvas').width() // 获取canvas的宽度 var gridwidth = (canvaswidth - line_width) / line_max_num // 格子宽度,按一行32个格子计算 var num = { w: line_max_num, h: math.floor((canvasheight - line_width) / gridwidth) } // 计算横向和纵向多少个格子,即:横坐标的最大值和纵坐标的最大值 var directionnow = null // 当前移动移动方向 var directionnext = null // 下一步移动方向 var gamestate = null // 游戏状态 /** * 绘制格子地图 * @param graphics */ function drawgrid(graphics) { var wnum = num.w var hnum = num.h graphics.setstrokestyle(line_width).beginstroke('#ffac52') // 画横向的线条 for (var i = 0; i <= hnum; i++) { if (i === hnum || i === 0) graphics.setstrokestyle(line_width) if (i === 1) graphics.setstrokestyle(0.1) graphics.moveto(line_width / 2, i * gridwidth + line_width / 2) .lineto(gridwidth * wnum + line_width / 2, i * gridwidth + line_width / 2) } graphics.setstrokestyle(line_width) // 画纵向的线条 for (i = 0; i <= wnum; i++) { if (i === wnum || i === 0) graphics.setstrokestyle(line_width) if (i === 1) graphics.setstrokestyle(.1) graphics.moveto(i * gridwidth + line_width / 2, line_width / 2) .lineto(i * gridwidth + line_width / 2, gridwidth * hnum + line_width / 2) } } /** * 坐标类 */ function point(x, y) { this.x = x this.y = y } /** * 根据移动的方向,获取当前坐标的下一个坐标 * @param direction 移动的方向 */ point.prototype.nextpoint = function nextpoint(direction) { debugger var point = new point(this.x, this.y) switch (direction) { case dir_enum.up: point.y -= 1 break case dir_enum.down: point.y += 1 break case dir_enum.left: point.x -= 1 break case dir_enum.right: point.x += 1 break } return point } /** * 初始化蛇的坐标 * @returns {[point,point,point,point,point ...]} * @private */ function initsnake() { return snake_start_point.map(function (item) { return new point(item[0], item[1]) }) } /** * 绘制蛇 * @param graphics * @param snakes // 蛇坐标 */ function drawsnake(graphics, snakes) { graphics.clear() graphics.beginfill("#a088ff") var len = snakes.length for (var i = 0; i < len; i++) { if (i === len - 1) graphics.beginfill("#ff6ff9") graphics.drawrect( snakes[i].x * gridwidth + line_width / 2, snakes[i].y * gridwidth + line_width / 2, gridwidth, gridwidth) } } /** * 改变蛇身坐标 * @param snakes 蛇坐标集 * @param direction 方向 */ function updatesnake(snakes, direction) { var oldhead = snakes[snakes.length - 1] var newhead = oldhead.nextpoint(direction) // 超出边界 游戏结束 if (newhead.x < 0 || newhead.x >= num.w || newhead.y < 0 || newhead.y >= num.h) { gamestate = game_state_enum.end } else if (snakes.some(function (p) { // ‘吃’到自己 游戏结束 return newhead.x === p.x && newhead.y === p.y })) { gamestate = game_state_enum.end } else { snakes.push(newhead) snakes.shift() } } /** * 引擎 * @param graphics * @param snakes */ function move(graphics, snakes, stage) { cleartimeout(window._engine) // 重启时关停之前的引擎 run() function run() { directionnow = directionnext updatesnake(snakes, directionnow) // 更新蛇坐标 if (gamestate === game_state_enum.end) { end() } else { drawsnake(graphics, snakes) stage.update() window._engine = settimeout(run, 500) } } } /** * 游戏结束回调 */ function end() { console.log('游戏结束') } function init() { $('canvas').attr('width', canvaswidth) // 给canvas设置宽高属性赋值上当前canvas的宽度和高度(单用样式配置宽高会被拉伸) $('canvas').attr('height', canvasheight) directionnow = directionnext = dir_enum.down // 初始化蛇的移动方向 var snakes = initsnake() var stage = new createjs.stage($('canvas')[0]) var grid = new createjs.shape() var snake = new createjs.shape() drawgrid(grid.graphics) // 绘制格子 drawsnake(snake.graphics, snakes) stage.addchild(grid) stage.addchild(snake) stage.update() move(snake.graphics, snakes, stage) } init() })
效果图
效果图(gif):
3.移动蛇
制作4个按钮,控制移动方向
代码
index.html
... <div class="control"> <div class="row"> <div class="btn"> <button id="upbtn">上</button> </div> </div> <div class="row clearfix"> <div class="btn half-width left"> <button id="leftbtn">左</button> </div> <div class="btn half-width right"> <button id="rightbtn">右</button> </div> </div> <div class="row"> <div class="btn"> <button id="downbtn">下</button> </div> </div> </div> </div> ...
stylesheet.css
... .control .row { position: relative; height: 33%; text-align: center; } .control .btn { box-sizing: border-box; height: 100%; padding: 4px; } .control button { display: inline-block; height: 100%; background-color: white; border: none; padding: 3px 20px; border-radius: 3px; } .half-width { width: 50%; } .btn.left { padding-right: 20px; float: left; text-align: right; } .btn.right { padding-left: 20px; float: right; text-align: left; } .clearfix:after { content: ''; display: block; clear: both; }
mian.js
... /** * 改变蛇行进方向 * @param dir */ function changedirection(dir) { /* 逆向及同向则不改变 */ if (directionnow + dir === 0 || directionnow === dir) return directionnext = dir } /** * 绑定相关元素点击事件 */ function bindevent() { $('#upbtn').click(function () { changedirection(dir_enum.up) }) $('#leftbtn').click(function () { changedirection(dir_enum.left) }) $('#rightbtn').click(function () { changedirection(dir_enum.right) }) $('#downbtn').click(function () { changedirection(dir_enum.down) }) } function init() { bindevent() ... }
效果图
效果图(gif):
4. 绘制果子
随机取两个坐标点绘制果子,判定如果“吃到”,则不删除尾巴。缩短定时器的时间间隔增加难度。
注意的点(遇到的问题以及解决方案):新增一个果子不能占用蛇的坐标,一开始考虑的是随机生成一个坐标,如果坐标已被占用,那就继续生成随机坐标。然后发现这样做有个问题就是整个界面剩余两个坐标可用时(极端情况,蛇占了整个屏幕就差两个格子了),那这样的话,不停随机取坐标,要取到这最后两个坐标要耗不少时间。后面改了方法,先统计所有坐标,然后循环蛇身坐标,一一排除不可用坐标,然后再随机抽取可用坐标的其中一个。
代码
main.js
$(function () { var line_width = 1 // 线条宽度 var line_max_num = 32 // 一行格子数量 var snake_start_point = [[0, 3], [1, 3], [2, 3], [3, 3]] // 初始蛇坐标 var dir_enum = { up: 1, down: -1, left: 2, right: -2 } // 移动的四个方向枚举值,两个对立方向相加等于0 var game_state_enum = { end: 1, ready: 2 } // 游戏状态枚举 var canvasheight = $('canvas').height() // 获取canvas的高度 var canvaswidth = $('canvas').width() // 获取canvas的宽度 var gridwidth = (canvaswidth - line_width) / line_max_num // 格子宽度,按一行32个格子计算 var num = { w: line_max_num, h: math.floor((canvasheight - line_width) / gridwidth) } // 计算横向和纵向多少个格子,即:横坐标的最大值和纵坐标的最大值 var directionnow = null // 当前移动移动方向 var directionnext = null // 下一步移动方向 var gamestate = null // 游戏状态 var scope = 0 // 分数 /** * 绘制格子地图 * @param graphics */ function drawgrid(graphics) { var wnum = num.w var hnum = num.h graphics.setstrokestyle(line_width).beginstroke('#ffac52') // 画横向的线条 for (var i = 0; i <= hnum; i++) { if (i === hnum || i === 0) graphics.setstrokestyle(line_width) if (i === 1) graphics.setstrokestyle(0.1) graphics.moveto(line_width / 2, i * gridwidth + line_width / 2) .lineto(gridwidth * wnum + line_width / 2, i * gridwidth + line_width / 2) } graphics.setstrokestyle(line_width) // 画纵向的线条 for (i = 0; i <= wnum; i++) { if (i === wnum || i === 0) graphics.setstrokestyle(line_width) if (i === 1) graphics.setstrokestyle(.1) graphics.moveto(i * gridwidth + line_width / 2, line_width / 2) .lineto(i * gridwidth + line_width / 2, gridwidth * hnum + line_width / 2) } } /** * 坐标类 */ function point(x, y) { this.x = x this.y = y } /** * 根据移动的方向,获取当前坐标的下一个坐标 * @param direction 移动的方向 */ point.prototype.nextpoint = function nextpoint(direction) { var point = new point(this.x, this.y) switch (direction) { case dir_enum.up: point.y -= 1 break case dir_enum.down: point.y += 1 break case dir_enum.left: point.x -= 1 break case dir_enum.right: point.x += 1 break } return point } /** * 初始化蛇的坐标 * @returns {[point,point,point,point,point ...]} * @private */ function initsnake() { return snake_start_point.map(function (item) { return new point(item[0], item[1]) }) } /** * 绘制蛇 * @param graphics * @param snakes // 蛇坐标 */ function drawsnake(graphics, snakes) { graphics.clear() graphics.beginfill("#a088ff") var len = snakes.length for (var i = 0; i < len; i++) { if (i === len - 1) graphics.beginfill("#ff6ff9") graphics.drawrect( snakes[i].x * gridwidth + line_width / 2, snakes[i].y * gridwidth + line_width / 2, gridwidth, gridwidth) } } /** * 改变蛇身坐标 * @param snakes 蛇坐标集 * @param direction 方向 */ function updatesnake(snakes, fruits, direction, fruitgraphics) { var oldhead = snakes[snakes.length - 1] var newhead = oldhead.nextpoint(direction) // 超出边界 游戏结束 if (newhead.x < 0 || newhead.x >= num.w || newhead.y < 0 || newhead.y >= num.h) { gamestate = game_state_enum.end } else if (snakes.some(function (p) { // ‘吃’到自己 游戏结束 return newhead.x === p.x && newhead.y === p.y })) { gamestate = game_state_enum.end } else if (fruits.some(function (p) { // ‘吃’到水果 return newhead.x === p.x && newhead.y === p.y })) { scope++ snakes.push(newhead) var temp = 0 fruits.foreach(function (p, i) { if (newhead.x === p.x && newhead.y === p.y) { temp = i } }) fruits.splice(temp, 1) var newfruit = createfruit(snakes, fruits) if (newfruit) { fruits.push(newfruit) drawfruit(fruitgraphics, fruits) } } else { snakes.push(newhead) snakes.shift() } } /** * 引擎 * @param graphics * @param snakes */ function move(snakegraphics, fruitgraphics, snakes, fruits, stage) { cleartimeout(window._engine) // 重启时关停之前的引擎 run() function run() { directionnow = directionnext updatesnake(snakes, fruits, directionnow, fruitgraphics) // 更新蛇坐标 if (gamestate === game_state_enum.end) { end() } else { drawsnake(snakegraphics, snakes) stage.update() window._engine = settimeout(run, 500 * math.pow(0.9, scope)) } } } /** * 游戏结束回调 */ function end() { console.log('游戏结束') } /** * 改变蛇行进方向 * @param dir */ function changedirection(dir) { /* 逆向及同向则不改变 */ if (directionnow + dir === 0 || directionnow === dir) return directionnext = dir } /** * 绑定相关元素点击事件 */ function bindevent() { $('#upbtn').click(function () { changedirection(dir_enum.up) }) $('#leftbtn').click(function () { changedirection(dir_enum.left) }) $('#rightbtn').click(function () { changedirection(dir_enum.right) }) $('#downbtn').click(function () { changedirection(dir_enum.down) }) } /** * 创建水果坐标 * @returns point * @param snakes * @param fruits */ function createfruit(snakes, fruits) { var totals = {} for (var x = 0; x < num.w; x++) { for (var y = 0; y < num.h; y++) { totals[x + '-' + y] = true } } snakes.foreach(function (item) { delete totals[item.x + '-' + item.y] }) fruits.foreach(function (item) { delete totals[item.x + '-' + item.y] }) var keys = object.keys(totals) if (keys.length) { var temp = math.floor(keys.length * math.random()) var key = keys[temp].split('-') return new point(number(key[0]), number(key[1])) } else { return null } } /** * 绘制水果 * @param graphics * @param fruits 水果坐标集 */ function drawfruit(graphics, fruits) { graphics.clear() graphics.beginfill("#16ff16") for (var i = 0; i < fruits.length; i++) { graphics.drawrect( fruits[i].x * gridwidth + line_width / 2, fruits[i].y * gridwidth + line_width / 2, gridwidth, gridwidth) } } function init() { bindevent() $('canvas').attr('width', canvaswidth) // 给canvas设置宽高属性赋值上当前canvas的宽度和高度(单用样式配置宽高会被拉伸) $('canvas').attr('height', canvasheight) directionnow = directionnext = dir_enum.down // 初始化蛇的移动方向 var snakes = initsnake() var fruits = [] fruits.push(createfruit(snakes, fruits)) fruits.push(createfruit(snakes, fruits)) var stage = new createjs.stage($('canvas')[0]) var grid = new createjs.shape() var snake = new createjs.shape() var fruit = new createjs.shape() drawgrid(grid.graphics) // 绘制格子 drawsnake(snake.graphics, snakes) drawfruit(fruit.graphics, fruits) stage.addchild(grid) stage.addchild(snake) stage.addchild(fruit) stage.update() move(snake.graphics, fruit.graphics, snakes, fruits, stage) } init() })
效果图
效果图(gif):
5. 分数显示、游戏结束提示、排行榜
这一部分就比较简单了,处理下数据的展示即可。这部分代码就不展示出来了。
效果图
结语
界面比较粗糙,主要是学习逻辑操作。中间出现一些小问题,但都一一的解决了。createjs这个游戏引擎还是比较简单易学的,整体只用了绘制图形的api。