手把手教學h5小遊戲 - 貪吃蛇
簡單的小遊戲製作,程式碼量只有兩三百行。遊戲可自行擴充套件延申。
原始碼已釋出至github,喜歡的點個小星星,原始碼入口:game-snake
遊戲已釋出,遊戲入口:http://snake.game.yanjd.top
第一步 - 製作想法
遊戲如何實現是首要想的,這裡我的想法如下:
- 利用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