最近 slither.io 貌似特別火,中午的時候,同事們都在玩,包括我本身也是玩的不亦樂乎。javascript
很久很久沒折騰過canvas相關的我也是以爲是時候再折騰一番啦,因此就試着仿造一下吧。樓主也沒寫過網絡遊戲,因此實現邏輯徹底靠本身YY。java
並且樓主內心也有點發虛,由於有些邏輯仍是不知道怎麼實現呀,因此不立flag,實話實說:不必定會更新下去,若是寫到不會寫了,就不必定寫了哈~git
爲啥取名叫先畫條蛇,畢竟是作個遊戲,功能仍是蠻多蠻複雜的,一口氣是確定搞不完的,因此得一步一步來,第一步就是先造條蛇!!github
當前項目最新效果:http://whxaxes.github.io/slither/ (因爲代碼一直在更新,效果會比本文所述的更多)web
在這個遊戲裏,須要一個基類,也就是地圖上的全部元素都會繼承這個基類:Base
canvas
export default class Base { constructor(options) { this.x = options.x; this.y = options.y; this.width = options.size || options.width; this.height = options.size || options.height; } /** * 繪製時的x座標, 要根據視窗來計算位置 * @returns {number} */ get paintX() { return this.x - frame.x; } /** * 繪製時的y座標, 要根據視窗來計算位置 * @returns {number} */ get paintY() { return this.y - frame.y; } /** * 在視窗內是否可見 * @returns {boolean} */ get visible() { const paintX = this.paintX; const paintY = this.paintY; const halfWidth = this.width / 2; const halfHeight = this.height / 2; return (paintX + halfWidth > 0) && (paintX - halfWidth < frame.width) && (paintY + halfHeight > 0) && (paintY - halfHeight < frame.height); } }
也就是地圖上的元素,都會有幾個基本屬性:水平座標x,垂直座標y,寬度width,高度height,水平繪製座標paintX,垂直繪製座標paintY,在視窗內是否可見visible。網絡
其中繪製座標和視窗相關參數這一篇先不用管,這兩個是涉及到地圖的,會在下一篇文章再做解釋。app
不像常見的那種以方格爲運動單位的貪吃蛇,slither裏的蛇動的動的更自由,先不說怎麼動,先說一下蛇體的構成。dom
這構造很顯然易見,其實就是由一個又一個的圓構成的,能夠分爲構成身體的圓,以及構成頭部的圓。因此,實現蛇這個類的時候,能夠進行拆分,拆分紅蛇的基類SnakeBase
,繼承蛇基類的蛇頭類SnakeHeader
,以及繼承蛇基類的蛇身類SnakeBody
,還有一個蛇類Snake
用於組合蛇頭和蛇身。函數
爲何要實現一個蛇基類,由於蛇頭和蛇身實際上是有不少類似的地方,也會有不少相同屬性,因此實現一個蛇基類會方便方法的複用的。
蛇基類我命名爲SnakeBase
,繼承基類Base
:
// 蛇頭和蛇身的基類 class SnakeBase extends Base { constructor(options) { super(options); // 皮膚顏色 this.color = options.color; // 描邊顏色 this.color_2 = '#000'; // 垂直和水平速度 this.vx = 0; this.vy = 0; // 生成元素圖片鏡像 this.createImage(); } // 設置基類的速度 set speed(val) { this._speed = val; // 從新計算水平垂直速度 this.velocity(); } get speed() { return this._speed ? this._speed : (this._speed = this.tracer ? this.tracer.speed : SPEED); } /** * 設置寬度和高度 * @param width * @param height */ setSize(width, height) { this.width = width; this.height = height || width; this.createImage(); } /** * 生成圖片鏡像 */ createImage() { this.img = this.img || document.createElement('canvas'); this.img.width = this.width + 10; this.img.height = this.height + 10; this.imgctx = this.img.getContext('2d'); this.imgctx.lineWidth = 2; this.imgctx.save(); this.imgctx.beginPath(); this.imgctx.arc(this.img.width / 2, this.img.height / 2, this.width / 2, 0, Math.PI * 2); this.imgctx.fillStyle = this.color; this.imgctx.strokeStyle = this.color_2; this.imgctx.stroke(); this.imgctx.fill(); this.imgctx.restore(); } /** * 更新位置 */ update() { this.x += this.vx; this.y += this.vy; } /** * 渲染鏡像圖片 */ render() { this.update(); // 若是該元素在視窗內不可見, 則不進行繪製 if (!this.visible) return; // 若是該對象有角度屬性, 則使用translate來繪製, 由於要旋轉 if (this.hasOwnProperty('angle')) { map.ctx.save(); map.ctx.translate(this.paintX, this.paintY); map.ctx.rotate(this.angle - BASE_ANGLE - Math.PI / 2); map.ctx.drawImage(this.img, -this.img.width / 2, -this.img.height / 2); map.ctx.restore(); } else { map.ctx.drawImage( this.img, this.paintX - this.img.width / 2, this.paintY - this.img.height / 2 ); } } }
簡單說明一下各個屬性的意義:
x,y
基類的座標r
爲基類的半徑,由於這個蛇是由圓組成的,因此r就是圓的半徑color、color_2
用於着色vx,vy
爲基類的水平方向的速度,以及垂直方向的速度再說明一下幾個方法:
createImage
方法:用於建立基類的鏡像,雖然基類只是畫個圓,可是繪製操做仍是很多,因此最好仍是先建立鏡像,以後每次繪製的時候就只須要調用一次drawImage
便可,對提高性能仍是有效的update
方法:每次的動畫循環都會調用的方法,根據基類的速度來更新其位置render
方法:基類的繪製自身的方法,裏面就只有一個繪製鏡像的操做,不過會判斷一下當前這個實例有無angle屬性,若是有angle則須要用canvas的rotate方法進行轉向後再繪製。再接下來就是蛇頭SnakeHeader
類,蛇頭類會繼承蛇基類,並且,因爲蛇的運動就是蛇頭的運動,因此蛇頭是運動的核心,而蛇身是跟着蛇頭動而動。
蛇頭怎麼動呢,我代碼裏寫的是,蛇會朝着鼠標移動,可是蛇的運動是不會停的,因此不以鼠標位置爲終點來計算蛇的運動,而是以鼠標相對於蛇頭的角度來計算蛇的運動方向,而後讓蛇持續的往那個方向運動便可。
因此在蛇頭類裏,會新增兩個屬性:angle
以及toAngle
,angle是蛇頭角度,toAngle是蛇頭要轉向的角度,請看蛇頭的構造函數代碼:
constructor(options) { super(options); this.angle = BASE_ANGLE + Math.PI / 2; this.toAngle = this.angle; }
初始角度爲一個基礎角度加上90度,由於畫布的rotate是從x軸正向開始的,而我想把y軸正向做爲0度,那麼就得加上90度,而基礎角度BASE_ANGLE是一個很大的數值,可是都是360度的倍數:
const BASE_ANGLE = Math.PI * 200; // 用於保證蛇的角度一直都是正數
目的是保證蛇的運動角度一直是正數。
其次,蛇頭須要眼睛,因此在蛇頭的繪製鏡像方法中,加入了繪製眼睛的方法:
/** * 添加畫眼睛的功能 */ createImage() { super.createImage(); const self = this; const eyeRadius = this.width * 0.2; function drawEye(eyeX, eyeY) { self.imgctx.beginPath(); self.imgctx.fillStyle = '#fff'; self.imgctx.strokeStyle = self.color_2; self.imgctx.arc(eyeX, eyeY, eyeRadius, 0, Math.PI * 2); self.imgctx.fill(); self.imgctx.stroke(); self.imgctx.beginPath(); self.imgctx.fillStyle = '#000'; self.imgctx.arc(eyeX + eyeRadius / 2, eyeY, 3, 0, Math.PI * 2); self.imgctx.fill(); } // 畫左眼 drawEye( this.img.width / 2 + this.width / 2 - eyeRadius, this.img.height / 2 - this.height / 2 + eyeRadius ); // 畫右眼 drawEye( this.img.width / 2 + this.width / 2 - eyeRadius, this.img.height / 2 + this.height / 2 - eyeRadius ); }
再者就是蛇頭的運動,蛇頭會根據鼠標與蛇頭的角度來運動,因此須要一個derectTo方法來調整蛇頭角度:
/** * 轉向某個角度 */ directTo(angle) { // 老的目標角度, 可是是小於360度的, 由於每次計算出來的目標角度也是0 - 360度 const oldAngle = Math.abs(this.toAngle % (Math.PI * 2)); // 轉了多少圈 let rounds = ~~(this.toAngle / (Math.PI * 2)); this.toAngle = angle; if (oldAngle >= Math.PI * 3 / 2 && this.toAngle <= Math.PI / 2) { // 角度從第四象限左劃至第一象限, 增長圈數 rounds++; } else if (oldAngle <= Math.PI / 2 && this.toAngle >= Math.PI * 3 / 2) { // 角度從第一象限劃至第四象限, 減小圈數 rounds--; } // 計算真實要轉到的角度 this.toAngle += rounds * Math.PI * 2; }
若是單純根據鼠標與蛇頭的角度,來給予蛇頭運動方向,會有問題,由於計算出來的目標角度都是0-360的,也就是,當個人鼠標從340度,右劃挪到10度。會出現蛇頭變成左轉彎,由於目標度數比蛇頭度數小。
因此就引入了圈數rounds
來計算蛇真正要去到的角度。仍是當個人鼠標從340度右劃到10度的時候,通過計算,我會認爲蛇頭的目標度數就是 360度 + 10度
。就能保證蛇頭的轉向是符合常識的。
計算出目標角度,就根據目標角度來算出蛇頭的水平速度vx,以及垂直速度vy:
// 根據蛇頭角度計算水平速度和垂直速度 velocity() { const angle = this.angle % (Math.PI * 2); const vx = Math.abs(this.speed * Math.sin(angle)); const vy = Math.abs(this.speed * Math.cos(angle)); if (angle < Math.PI / 2) { this.vx = vx; this.vy = -vy; } else if (angle < Math.PI) { this.vx = vx; this.vy = vy; } else if (angle < Math.PI * 3 / 2) { this.vx = -vx; this.vy = vy; } else { this.vx = -vx; this.vy = -vy; } }
以後再在每一次的重繪中進行轉向的計算,以及移動的計算便可:
/** * 蛇頭轉頭 */ turnAround() { const angleDistance = this.toAngle - this.angle; // 與目標角度之間的角度差 const turnSpeed = 0.045; // 轉頭速度 // 當轉到目標角度, 重置蛇頭角度 if (Math.abs(angleDistance) <= turnSpeed) { this.toAngle = this.angle = BASE_ANGLE + this.toAngle % (Math.PI * 2); } else { this.angle += Math.sign(angleDistance) * turnSpeed; } } /** * 增長蛇頭的逐幀邏輯 */ update() { this.turnAround(); this.velocity(); super.update(); }
蛇頭類寫好了,就能夠寫蛇身類SnakeBody
了,蛇身須要跟着前面一截的蛇身或者蛇頭運動,因此又新增了幾個屬性,先看部分代碼:
constructor(options) { super(options); // 設置跟蹤者 this.tracer = options.tracer; this.tracerDis = this.distance; this.savex = this.tox = this.tracer.x - this.distance; this.savey = this.toy = this.tracer.y; } get distance() { return this.tracer.width * 0.2; }
新增了一個tracer
跟蹤者屬性,也就是前一截的蛇頭或者蛇身實例,蛇身和前一截實例會有一些位置差距,因此有個distance屬性是用於此,還有就是計算蛇身的目標位置,也就是前一截蛇身的運動方向日後平移distance距離的點。讓蛇身朝着這個方向移動,就能夠有跟着動的效果了。
還有tracerDis是用於計算tracer的移動長度,this.savex和this.savey是用於保存tracer的運動軌跡座標
再來就是計算水平速度,以及垂直速度,還有每一幀的更新邏輯了:
/** * 根據目標點, 計算速度 * @param x * @param y */ velocity(x, y) { this.tox = x || this.tox; this.toy = y || this.toy; const disX = this.tox - this.x; const disY = this.toy - this.y; const dis = Math.hypot(disX, disY); this.vx = this.speed * disX / dis || 0; this.vy = this.speed * disY / dis || 0; } update() { if (this.tracerDis >= this.distance) { const tracer = this.tracer; // 計算位置的偏移量 this.tox = this.savex + ((this.tracerDis - this.distance) * tracer.vx / tracer.speed); this.toy = this.savey + ((this.tracerDis - this.distance) * tracer.vy / tracer.speed); this.velocity(this.tox, this.toy); this.tracerDis = 0; // 保存tracer位置 this.savex = this.tracer.x; this.savey = this.tracer.y; } this.tracerDis += this.tracer.speed; if (Math.abs(this.tox - this.x) <= Math.abs(this.vx)) { this.x = this.tox; } else { this.x += this.vx; } if (Math.abs(this.toy - this.y) <= Math.abs(this.vy)) { this.y = this.toy; } else { this.y += this.vy; } }
上面代碼中,update方法,會計算tracer移動距離,當超過distance的時候,就讓蛇身根據此前保存的運動軌跡,計算相應的速度,而後進行移動。這樣就能夠實現蛇身會跟着tracer的移動軌跡行動。
蛇頭、蛇身都寫完了,是時候把二者組合起來了,因此再建立一個蛇類Snake
。
先看構造函數,在建立實例的時候,實例化一個蛇頭,再根據入參的長度,來增長蛇身的實例,而且把蛇身的tracer指向前一截蛇身或者蛇頭實例。
constructor(options) { this.bodys = []; // 建立腦殼 this.header = new SnakeHeader(options); // 建立身軀, 給予各個身軀跟蹤目標 options.tracer = this.header; for (let i = 0; i < options.length; i++) { this.bodys.push(options.tracer = new SnakeBody(options)); } this.binding(); }
還有就是鼠標事件綁定,包括根據鼠標位置,來調整蛇的運動方向,還有按下鼠標的時候,蛇會進行加速,鬆開鼠標則不加速的邏輯:
/** * 蛇與鼠標的交互事件 */ binding() { const header = this.header; const bodys = this.bodys; // 蛇頭跟隨鼠標的移動而變動移動方向 window.addEventListener('mousemove', (e = window.event) => { const x = e.clientX - header.paintX; const y = header.paintY - e.clientY; let angle = Math.atan(Math.abs(x / y)); // 計算角度, 角度值爲 0-360 if (x > 0 && y < 0) { angle = Math.PI - angle; } else if (x < 0 && y < 0) { angle = Math.PI + angle; } else if (x < 0 && y > 0) { angle = Math.PI * 2 - angle; } header.directTo(angle); }); // 鼠標按下讓蛇加速 window.addEventListener('mousedown', () => { header.speed = 5; bodys.forEach(body => { body.speed = 5; }); }); // 鼠標擡起中止加速 window.addEventListener('mouseup', () => { header.speed = SPEED; bodys.forEach(body => { body.speed = SPEED; }); }); }
固然,最終還須要一個渲染方法,逐個渲染便可:
// 渲染蛇頭蛇身 render() { for (let i = this.bodys.length - 1; i >= 0; i--) { this.bodys[i].render(); } this.header.render(); }
至此,整個蛇類都寫完了,再寫一下動畫循環邏輯便可:
import Snake from './snake'; import frame from './lib/frame'; import Stats from './third/stats.min'; const sprites = []; const RAF = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame || function(callback) { window.setTimeout(callback, 1000 / 60) }; const canvas = document.getElementById('cas'); const ctx = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = window.innerHeight; const stats = new Stats(); stats.setMode(0); stats.domElement.style.position = 'absolute'; stats.domElement.style.right = '0px'; stats.domElement.style.top = '0px'; document.body.appendChild( stats.domElement ); function init() { const snake = new Snake({ x: frame.x + frame.width / 2, y: frame.y + frame.height / 2, size: 40, length: 10, color: '#fff' }); sprites.push(snake); animate(); } let time = new Date(); let timeout = 0; function animate() { const ntime = new Date(); if(ntime - time > timeout) { ctx.clearRect(0, 0, canvas.width, canvas.height); sprites.forEach(function(sprite) { sprite.render(); }); time = ntime; } stats.update(); RAF(animate); } init();
這一塊的代碼就很簡單了,生成蛇的實例,經過requestAnimationFrame
方法進行動畫循環,而且在每次循環中進行畫布的重繪便可。裏面有個叫timeout的參數,用於下降遊戲fps,用來debug的。
這個項目目前仍是單機的,因此我放在了github,以後加上網絡功能的話,估計就沒法預覽了。
github地址:https://github.com/whxaxes/slither