前言
上一篇博文講了如何造一條蛇,現在蛇有了,要讓它自由的活動起來,就得有個地圖啊,而且只能走也不行呀,還得有點吃的,所以還得加點食物,這一篇博文就來講講如何添加地圖和食物。
預覽效果
當前項目最新效果:http://whxaxes.github.io/slither/ (由於代碼一直在更新,效果可能會比本文所述的更多)
功能分析
slither.io的地圖是類似於rpg游戲的大地圖,所以,我們需要兩個新的類,一個是地圖類:Map,一個是視窗類:Frame,地圖類就是整個大地圖的抽象,視窗類就是可視界面的抽象。
而怎么做成蛇動的時候,繪制位置不動,而是地圖動呢。其實原理也很簡單,如果看過上一篇文章的讀者,應該還記得Base類里有兩個參數:paintX
以及paintY
,這兩個是繪制坐標,跟蛇的坐標不同的就是,繪制坐標是蛇的實際坐標減去視窗的坐標。
get paintX() {
return this.x - frame.x;
}
get paintY() {
return this.y - frame.y;
}
每次render的時候,繪制的坐標就是用的這兩個參數,同時適當的調整一下視窗的坐標,就可以做成相對於視窗中蛇沒移動,但是看上去蛇移動了的效果。
Base類里還有一個參數叫visible:
/**
* 在視窗內是否可見
* @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);
}
用於判斷實例在視窗frame中是否可見,如果不可見,就不需要調用繪制接口了,從而提升游戲性能。
而食物類就比較簡單了,繼承Base類后,在地圖中隨機出一定數量,再進行一下蛇頭與食物的碰撞檢測即可。
接着再細講一下各個類的實現。
視窗類
因為地圖類也依賴視窗類,所以先看視窗類。代碼量相當少,所以直接全部貼出:
// 視窗類
class Frame {
init(options) {
this.x = options.x;
this.y = options.y;
this.width = options.width;
this.height = options.height;
}
/**
* 跟蹤某個對象
*/
track(obj) {
this.translate(
obj.x - this.x - this.width / 2,
obj.y - this.y - this.height / 2
);
}
/**
* 移動視窗
* @param x
* @param y
*/
translate(x, y) {
this.x += x;
this.y += y;
}
}
export default new Frame();
由於視窗在整個游戲中只有一個,所以做成了單例的。視窗類就只有幾個屬性,x坐標,y坐標,寬度和高度。x坐標和y坐標是相對於地圖左上角的值,width和height一般就是canvas的大小。
track方法是跟蹤某個對象,也就是視窗跟着對象的移動而移動。在main.js中調用跟蹤蛇類:
// 讓視窗跟隨蛇的位置更改而更改
frame.track(snake);
地圖類
地圖類跟視窗類一樣也是整個游戲里只有一個,所以也做成單例的,而且由於,整個游戲的元素,都是基於地圖上的,所以我也把canvas的2d繪圖對象掛載到了地圖類上。先看地圖類的部分代碼:
constructor() {
// 背景塊的大小
this.block_w = 150;
this.block_h = 150;
}
/**
* 初始化map對象
* @param options
*/
init(options) {
this.canvas = options.canvas;
this.ctx = this.canvas.getContext('2d');
// 地圖大小
this.width = options.width;
this.height = options.height;
}
/**
* 清空地圖上的內容
*/
clear() {
this.ctx.clearRect(0, 0, frame.width, frame.height);
}
構造函數中,定義一下地圖背景的方格塊的大小,然后就是init方法,給外部初始化用的,因為地圖的位置是固定的,所以不需要坐標值,只需要寬度和高度即可。clear是給外部調用用來清除畫布。
再看地圖類的渲染方法:
/**
* 渲染地圖
*/
render() {
const beginX = (frame.x < 0) ? -frame.x : (-frame.x % this.block_w);
const beginY = (frame.y < 0) ? -frame.y : (-frame.y % this.block_h);
const endX = (frame.x + frame.width > this.width)
? (this.width - frame.x)
: (beginX + frame.width + this.block_w);
const endY = (frame.y + frame.height > this.height)
? (this.height - frame.y)
: (beginY + frame.height + this.block_h);
// 鋪底色
this.ctx.fillStyle = '#999';
this.ctx.fillRect(beginX, beginY, endX - beginX, endY - beginY);
// 畫方格磚
this.ctx.strokeStyle = '#fff';
for (let x = beginX; x <= endX; x += this.block_w) {
for (let y = beginY; y <= endY; y += this.block_w) {
const cx = endX - x;
const cy = endY - y;
const w = cx < this.block_w ? cx : this.block_w;
const h = cy < this.block_h ? cy : this.block_h;
this.ctx.strokeRect(x, y, w, h);
}
}
}
其實就是根據視窗的位置,來進行局部繪制,如果進行整個地圖的繪制,會超級消耗性能。所以只繪制需要展示的那一塊。
按照slither.io的功能,大地圖有了,還得畫個小地圖:
/**
* 畫小地圖
*/
renderSmallMap() {
// 小地圖外殼, 圓圈
const margin = 30;
const smapr = 50;
const smapx = frame.width - smapr - margin;
const smapy = frame.height - smapr - margin;
// 地圖在小地圖中的位置和大小
const smrect = 50;
const smrectw = this.width > this.height ? smrect : (this.width * smrect / this.height);
const smrecth = this.width > this.height ? (this.height * smrect / this.width) : smrect;
const smrectx = smapx - smrectw / 2;
const smrecty = smapy - smrecth / 2;
// 相對比例
const radio = smrectw / this.width;
// 視窗在小地圖中的位置和大小
const smframex = frame.x * radio + smrectx;
const smframey = frame.y * radio + smrecty;
const smframew = frame.width * radio;
const smframeh = frame.height * radio;
this.ctx.save();
this.ctx.globalAlpha = 0.8;
// 畫個圈先
this.ctx.beginPath();
this.ctx.arc(smapx, smapy, smapr, 0, Math.PI * 2);
this.ctx.fillStyle = '#000';
this.ctx.fill();
this.ctx.stroke();
// 畫縮小版地圖
this.ctx.fillStyle = '#999';
this.ctx.fillRect(smrectx, smrecty, smrectw, smrecth);
// 畫視窗
this.ctx.strokeRect(smframex, smframey, smframew, smframeh);
// 畫蛇蛇位置
this.ctx.fillStyle = '#f00';
this.ctx.fillRect(smframex + smframew / 2 - 1, smframey + smframeh / 2 - 1, 2, 2);
this.ctx.restore();
}
這個也沒什么難度,就是疊圖層而已。不再解釋
最后再export出去:export default new Map();
即可。
組合
在main.js中,直接初始化一下:
// 初始化地圖對象
map.init({
canvas,
width: 5000,
height: 5000
});
// 初始化視窗對象
frame.init({
x: 1000,
y: 1000,
width: canvas.width,
height: canvas.height
});
然后在動畫循環中,讓視窗跟隨蛇的實例snake
,然后再進行相應的render即可,render的順序關系到元素的層級,所以小地圖是最后才render :
// 讓視窗跟隨蛇的位置更改而更改
frame.track(snake);
map.render();
snake.render();
map.renderSmallMap();
食物類
再講一下食物類,也是非常的簡單,直接繼承Base類,然后做個簡單的發光動畫效果即可,代碼量不多,也全部貼出:
export default class Food extends Base {
constructor(options) {
super(options);
this.point = options.point;
this.r = this.width / 2; // 食物的半徑, 發光半徑
this.cr = this.width / 2; // 食物實體半徑
this.lightDirection = true; // 發光動畫方向
}
update() {
const lightSpeed = 1;
this.r += this.lightDirection ? lightSpeed : -lightSpeed;
// 當發光圈到達一定值再縮小
if (this.r > this.cr * 2 || this.r < this.cr) {
this.lightDirection = !this.lightDirection;
}
}
render() {
this.update();
if (!this.visible) {
return;
}
map.ctx.fillStyle = '#fff';
// 繪制光圈
map.ctx.globalAlpha = 0.2;
map.ctx.beginPath();
map.ctx.arc(this.paintX, this.paintY, this.r, 0, Math.PI * 2);
map.ctx.fill();
// 繪制實體
map.ctx.globalAlpha = 1;
map.ctx.beginPath();
map.ctx.arc(this.paintX, this.paintY, this.cr, 0, Math.PI * 2);
map.ctx.fill();
}
}
然后在main.js中,進行食物生成:
// 食物生成方法
const foodsNum = 100;
const foods = [];
function createFood(num) {
for (let i = 0; i < num; i++) {
const point = ~~(Math.random() * 30 + 50);
const size = ~~(point / 3);
foods.push(new Food({
x: ~~(Math.random() * (map.width + size) - 2 * size),
y: ~~(Math.random() * (map.height + size) - 2 * size),
size, point
}));
}
}
然后在動畫循環中進行循環並且渲染即可:
// 渲染食物, 以及檢測食物與蛇頭的碰撞
foods.slice(0).forEach(food => {
food.render();
if (food.visible && collision(snake.header, food)) {
foods.splice(foods.indexOf(food), 1);
snake.eat(food);
createFood(1);
}
});
渲染的同時,也跟蛇頭進行一下碰撞檢測,如果產生了碰撞,則從食物列表中刪掉吃掉的實物,並且調用蛇類的eat方法,然后再隨機生成一個食物補充。
因為食物是圓,蛇頭也是圓,所以碰撞檢測就很簡單了:
/**
* 碰撞檢測
* @param dom
* @param dom2
* @param isRect 是否為矩形
*/
function collision(dom, dom2, isRect) {
const disX = dom.x - dom2.x;
const disY = dom.y - dom2.y;
if (isRect) {
return Math.abs(disX) < (dom.width + dom2.width)
&& Math.abs(disY) < (dom.height + dom2.height);
}
return Math.hypot(disX, disY) < (dom.width + dom2.width) / 2;
}
然后再看一下蛇的eat方法:
/**
* 吃掉食物
* @param food
*/
eat(food) {
this.point += food.point;
// 增加分數引起蟲子體積增大
const newSize = this.header.width + food.point / 50;
this.header.setSize(newSize);
this.bodys.forEach(body => {
body.setSize(newSize);
});
// 同時每吃一個食物, 都增加身軀
const lastBody = this.bodys[this.bodys.length - 1];
this.bodys.push(new SnakeBody({
x: lastBody.x,
y: lastBody.y,
size: lastBody.width,
color: lastBody.color,
tracer: lastBody
}));
}
調用該方法后,會使蛇的分數增加,同時增加體積,以及身軀長度。
至此,地圖以及食物都做好了。
照例貼出github地址:https://github.com/whxaxes/slither