1. 程式人生 > >一步一步用Canvas寫一個貪吃蛇

一步一步用Canvas寫一個貪吃蛇

之前在慕課網看了幾集Canvas的視訊,一直想著寫點東西練練手。感覺貪吃蛇算是比較簡單的了,當年大學的時候還寫過C語言字元版的,沒想到還是遇到了很多問題。

最終效果如下(圖太大的話 時間太長 錄製gif的軟體有時限…)

首先定義遊戲區域。貪吃蛇的螢幕上只有蛇身和蘋果兩種元素,而這兩個都可以用正方形格子構成。正方形之間新增縫隙。為什麼要新增縫隙?你可以想象當你成功填滿所有格子的時候,如果沒有縫隙,就是一個實心的大正方形……你根本不知道蛇身什麼樣。

畫了一個圖。

格子是左上角的座標是(0, 0),向右是橫座標增加,向下是縱座標增加。這個方向和Canvas相同。

每次畫一個格子的時候,要從左上角開始,我們直知道Canvas的左上角座標是(0, 0),假設格子的邊長是 GRID_WIDTH

 縫隙的寬度是 GAP_WIDTH ,可以得到第(i, j)個格子的左上角座標 (i*(GRID_WIDTH+GAP_WIDTH)+GAP_WIDTH, j*(GRID_WIDTH+GAP_WIDTH)+GAP_WIDTH) 。

假設現在蛇身是由三個藍色的格子組成的,我們不能只繪製三個格子,兩個紫色的空隙也一定要繪製,否則,還是之前說的,你根本不知道蛇身什麼樣。如下圖,不畫縫隙雖然也能玩,但是體驗肯定不一樣。

繪製相鄰格子之間間隙 不繪製間隙

現在我們可以嘗試著畫一條蛇了。蛇身其實就是一個格子的集合,每個格子用包含兩個位置資訊的陣列表示,整條蛇可以用二維陣列表示。

<!DOCTYPE html
> <html lang="en"> <head> <meta charset="UTF-8"> <title>blog_snack</title> <style> #canvas { background-color: #000; } </style> </head> <body> <canvas id="canvas"></canvas> <
script> const GRID_WIDTH = 10; // 格子的邊長 const GAP_WIDTH = 2; // 空隙的邊長 const ROW = 10; // 一共有多少行格子&每行有多少個格子 let canvas = document.getElementById('canvas'); canvas.height = GRID_WIDTH * ROW + GAP_WIDTH * (ROW + 1); canvas.width = GRID_WIDTH * ROW + GAP_WIDTH * (ROW + 1); let ctx = canvas.getContext('2d'); let snack = [ [2, 3], [2, 4], [2, 5], [3, 5], [4, 5], [4, 4], [5, 4], [5, 5] ]; // 初始化一條? drawSnack(ctx, snack, '#fff'); function drawSnack(ctx, snack, color) { ctx.fillStyle = color; for (let i = 0; i < snack.length; i++) { ctx.fillRect(...getGridULCoordinate(snack[i]), GRID_WIDTH, GRID_WIDTH); if (i) { ctx.fillRect(...getBetweenTwoGridGap(snack[i], snack[i - 1])); } } } // 傳入一個格子 返回左上角座標 function getGridULCoordinate(g) { return [g[0] * (GRID_WIDTH + GAP_WIDTH) + GAP_WIDTH, g[1] * (GRID_WIDTH + GAP_WIDTH) + GAP_WIDTH]; } // 傳入兩個格子 返回兩個格子之間的矩形縫隙 // 這裡傳入的兩個格子必須是相鄰的 // 返回一個數組 分別是這個矩形縫隙的 左上角橫座標 左上角縱座標 寬 高 function getBetweenTwoGridGap(g1, g2) { let width = GRID_WIDTH + GAP_WIDTH; if (g1[0] === g2[0]) { // 橫座標相同 是縱向相鄰的兩個格子 let x = g1[0] * width + GAP_WIDTH; let y = Math.min(g1[1], g2[1]) * width + width; return [x, y, GRID_WIDTH, GAP_WIDTH]; } else { // 縱座標相同 是橫向相鄰的兩個格子 let x = Math.min(g1[0], g2[0]) * width + width; let y = g1[1] * width + GAP_WIDTH; return [x, y, GAP_WIDTH, GRID_WIDTH]; } } </script> </body> </html>

我初始化了一條蛇,看起來是符合預期的。

接下來要做的是讓蛇動起來。蛇動起來這事很簡單,蛇向著當前運動的方向前進一格,刪掉蛇尾,也就是最後一個格子就可以了。之前說的二維陣列表示一條蛇,現在規定其中snack[0]表示蛇尾,snack[snack.length-1]表示蛇頭。動畫就簡單的用setInterval實現了。

const GRID_WIDTH = 10;  // 格子的邊長
const GAP_WIDTH = 2;    // 空隙的邊長
const ROW = 10;         // 一共有多少行格子&每行有多少個格子
const COLOR = '#fff';   // 蛇的顏色
const BG_COLOR = '#000';// 背景顏色

const UP = 0, LEFT = 1, RIGHT = 2, DOWN = 3;    // 定義蛇前進的方向
const CHANGE = [ [0, -1], [-1, 0], [1, 0], [0, 1] ]; // 每個方向前進時格子座標的變化

let canvas = document.getElementById('canvas');
canvas.height = GRID_WIDTH * ROW + GAP_WIDTH * (ROW + 1);
canvas.width = GRID_WIDTH * ROW + GAP_WIDTH * (ROW + 1);
let ctx = canvas.getContext('2d');

let snack = [ [2, 3], [2, 4], [2, 5], [3, 5], [4, 5], [4, 4], [5, 4], [5, 5] ]; // 初始化一條?
let dir = RIGHT; // 初始化一個方向

drawSnack(ctx, snack, COLOR);

let timer = setInterval(() => {
    // 每隔一段時間就重新整理一次
    let head = snack[snack.length - 1]; // 蛇頭
    let change = CHANGE[dir];           // 下一個格子前進位置
    let newGrid = [head[0] + change[0], head[1] + change[1]]; // 新格子的位置
    snack.push(newGrid);    // 新格子加入蛇身的陣列中
    ctx.fillStyle = COLOR;
    ctx.fillRect(...getGridULCoordinate(newGrid), GRID_WIDTH, GRID_WIDTH); // 畫新格子
    ctx.fillRect(...getBetweenTwoGridGap(head, newGrid)); // 新蛇頭和舊蛇頭之間的縫隙
    ctx.fillStyle = BG_COLOR;
    let delGrid = snack.shift();    // 刪除蛇尾-最後一個元素
    ctx.fillRect(...getGridULCoordinate(delGrid), GRID_WIDTH, GRID_WIDTH); // 擦除刪除元素
    ctx.fillRect(...getBetweenTwoGridGap(delGrid, snack[0])); // 擦除刪除元素和當前最後一個元素之間的縫隙
}, 1000);

..... // 和之前相同

現在蛇已經可以動起來了。

但這肯定不是我想要的效果——它的移動是一頓一頓的,而我想要順滑的。

現在每一次變化都是直接移動一個格子邊長的距離,保證蛇移動速度不變的情況下,動畫是不可能變得順滑的。所以想要移動變得順滑,一種可行的方法是,移動一個格子的距離的過程分多次繪製。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>blog_snack</title>
    <style>
        #canvas {
             background-color: #000;
        }
    </style>
</head>
<body>
    <canvas id="canvas"></canvas>
    <script>
        const GRID_WIDTH = 10;  // 格子的邊長
        const GAP_WIDTH = 2;    // 空隙的邊長
        const ROW = 10;         // 一共有多少行格子&每行有多少個格子
        const COLOR = '#fff';   // 蛇的顏色
        const BG_COLOR = '#000';// 背景顏色
        const INTERVAL = 1000;

        const UP = 0, LEFT = 1, RIGHT = 2, DOWN = 3;    // 定義蛇前進的方向
        const CHANGE = [ [0, -1], [-1, 0], [1, 0], [0, 1] ]; // 每個方向前進時格子座標的變化

        let canvas = document.getElementById('canvas');
        canvas.height = GRID_WIDTH * ROW + GAP_WIDTH * (ROW + 1);
        canvas.width = GRID_WIDTH * ROW + GAP_WIDTH * (ROW + 1);
        let ctx = canvas.getContext('2d');

        let snack = [ [2, 3], [2, 4], [2, 5], [3, 5], [4, 5], [4, 4], [5, 4], [5, 5] ]; // 初始化一條?
        let dir = RIGHT; // 初始化一個方向

        drawSnack(ctx, snack, COLOR);

        let timer = setInterval(() => {
            // 每隔一段時間就重新整理一次
            let head = snack[snack.length - 1]; // 蛇頭
            let change = CHANGE[dir];           // 下一個格子前進位置
            let newGrid = [head[0] + change[0], head[1] + change[1]]; // 新格子的位置
            snack.push(newGrid);    // 新格子加入蛇身的陣列中
            gradientRect(ctx, ...getUniteRect(newGrid, getBetweenTwoGridGap(head, newGrid)), dir, COLOR, INTERVAL);
            let delGrid = snack.shift();    // 刪除蛇尾-最後一個元素
            gradientRect(ctx, ...getUniteRect(delGrid, getBetweenTwoGridGap(delGrid, snack[0])), 
                getDirection(delGrid, snack[0]), BG_COLOR, INTERVAL);
        }, INTERVAL);

        // 給定一個格子的座標和一個格子間隙的矩形(左上角,寬,高) 返回兩個合併的矩形 的左上角、右下角 座標
        function getUniteRect(g, rect) {
            let p = getGridULCoordinate(g);
            if (p[0] === rect[0] && p[1] < rect[1] ||   // 矩形是在格子正下方
                p[1] === rect[1] && p[0] < rect[0]) {   // 矩形在格子的正右方
                return [p[0], p[1], rect[0] + rect[2], rect[1] + rect[3]];
            } else if (p[0] === rect[0] && p[1] > rect[1] || // 矩形是在格子正上方
                p[1] === rect[1] && p[0] > rect[0]) { // 矩形在格子的正左方
                return [rect[0], rect[1], p[0] + GRID_WIDTH, p[1] + GRID_WIDTH];
            }
        }
        // 從格子1 移動到格子2 的方向
        function getDirection(g1, g2) {
            if (g1[0] === g2[0] && g1[1] < g2[1]) return DOWN;
            if (g1[0] === g2[0] && g1[1] > g2[1]) return UP;
            if (g1[1] === g2[1] && g1[0] < g2[0]) return RIGHT;
            if (g1[1] === g2[1] && g1[0] > g2[0]) return LEFT;
        }

        // 慢慢的填充一個矩形 (真的不知道則怎麼寫 瞎寫...動畫的執行時間可能不等於duration 但一定要保證<=duration
        // 傳入的是矩形左上角和右下角的座標 以及漸變的方向
        function gradientRect(ctx, x1, y1, x2, y2, dir, color, duration) {
            let dur = 20;
            let times = Math.floor(duration / dur); // 更新次數
            let nowX1 = x1, nowY1 = y1, nowX2 = x2, nowY2 = y2;
            let dx1 = 0, dy1 = 0, dx2 = 0, dy2 = 0;
            if (dir === UP) { dy1 = (y1 - y2) / times; nowY1 = y2; }
            if (dir === DOWN) { dy2 = (y2 - y1) / times; nowY2 = y1; }
            if (dir === LEFT) { dx1 = (x1 - x2) / times; nowX1 = x2; }
            if (dir === RIGHT) { dx2 = (x2 - x1) / times; nowX2 = x1; }
            let startTime = Date.now();
            let timer = setInterval(() => {
                nowX1 += dx1, nowX2 += dx2, nowY1 += dy1, nowY2 += dy2; // 更新
                let runTime = Date.now() - startTime;
                if (nowX1 < x1 || nowX2 > x2 || nowY1 < y1 || nowY2 > y2 || runTime >= duration - dur) {
                    nowX1 = x1, nowX2 = x2, nowY1 = y1, nowY2