Wyjaśniono samouczek JavaScript Snake

W tym artykule krok po kroku wyjaśnię, jak samodzielnie zbudować grę Snake, wykorzystując do tego celu HTML, CSS i JavaScript.

Zrezygnujemy z użycia jakichkolwiek zewnętrznych bibliotek, koncentrując się na działaniu gry bezpośrednio w przeglądarce internetowej. Proces tworzenia tej gry to doskonałe i angażujące ćwiczenie, które rozwija umiejętności logicznego myślenia oraz rozwiązywania problemów.

Koncepcja projektu

Gra Snake to klasyczna, nieskomplikowana produkcja, w której gracz kontroluje ruchy węża, dążąc do zdobycia pożywienia, jednocześnie unikając zderzeń. Po zjedzeniu pożywienia wąż powiększa swoją długość. W miarę trwania rozgrywki wąż staje się coraz dłuższy, co podnosi poziom trudności.

Ważnym elementem gry jest unikanie przez węża kolizji ze ścianami planszy oraz z własnym ciałem. Wraz z wydłużaniem się węża, gra staje się coraz bardziej wymagająca.

Celem tego przewodnika jest stworzenie działającej wersji gry Snake, opartej na języku JavaScript:

Kod źródłowy do tej gry jest dostępny w moim repozytorium na GitHub. Demo gry można znaleźć na stronie GitHub Pages.

Wymagania wstępne

Nasz projekt opiera się na technologiach HTML, CSS oraz JavaScript. W kodzie HTML i CSS skupimy się na podstawowych elementach, koncentrując się przede wszystkim na języku JavaScript. Dlatego też, aby w pełni skorzystać z tego poradnika, zalecana jest podstawowa znajomość tych technologii. Jeśli potrzebujesz odświeżenia wiedzy, polecamy nasz artykuł z listą najlepszych miejsc do nauki JavaScript.

Niezbędny będzie również edytor kodu, w którym napiszesz kod źródłowy, oraz przeglądarka internetowa, która zapewne jest już zainstalowana na Twoim komputerze.

Konfiguracja projektu

Zacznijmy od przygotowania plików projektu. W nowo utworzonym, pustym folderze utwórz plik o nazwie index.html i wklej do niego poniższy kod HTML:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="https://wilku.top/javascript-snake-tutorial-explained/./styles.css" />
    <title>Snake</title>
  </head>
  <body>
    <div id="game-over-screen">
      <h1>Game Over</h1>
    </div>
    <canvas id="canvas" width="420" height="420"> </canvas>
    <script src="./snake.js"></script>
  </body>
</html>

Powyższy kod generuje podstawowy ekran informujący o zakończeniu gry. Jego widoczność będziemy kontrolować za pomocą JavaScript. Dodatkowo definiuje element canvas (płótno), na którym rysowane będą elementy gry – labirynt, wąż i pożywienie. Kod łączy również plik arkusza stylów CSS oraz skrypt JavaScript.

Następnie utwórz plik styles.css, który będzie odpowiedzialny za style wizualne. Wklej do niego poniższy kod CSS:

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
    font-family: 'Courier New', Courier, monospace;
}

body {
    height: 100vh;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    background-color: #00FFFF;
}

#game-over-screen {
    background-color: #FF00FF;
    width: 500px;
    height: 200px;
    border: 5px solid black;
    position: absolute;
    align-items: center;
    justify-content: center;
    display: none;
}

Reguła „*” resetuje marginesy oraz dopełnienia dla wszystkich elementów. Dodatkowo ustawia rodzinę czcionek dla każdego elementu oraz wykorzystuje bardziej intuicyjny model określania rozmiarów elementów – border-box. Dla elementu body ustawiamy wysokość na 100% okna przeglądarki i wyśrodkowujemy wszystkie elementy na ekranie. Dodatkowo ustawiamy jasnoniebieskie tło.

Na końcu stylizujemy ekran „Game Over”, nadając mu wysokość 200 pikseli i szerokość 500 pikseli. Ustawiamy mu również kolor tła magenta oraz czarną ramkę. Ustawiamy pozycję elementu na absolutną, aby znajdował się poza normalnym przepływem dokumentu i był wyśrodkowany na ekranie. Następnie wyśrodkowujemy jego zawartość. Na końcu ustawiamy właściwość display na none, dzięki czemu ekran na starcie jest ukryty.

Ostatnim krokiem w konfiguracji projektu jest utworzenie pliku snake.js, który będziemy uzupełniać w kolejnych sekcjach.

Tworzenie zmiennych globalnych

Kolejnym krokiem w tworzeniu gry Snake za pomocą JavaScript jest zdefiniowanie zmiennych globalnych, które będą używane w grze. W pliku snake.js, na samej górze, dodaj następujące definicje zmiennych:

// Odniesienia do elementów HTML
let gameOverScreen = document.getElementById("game-over-screen");
let canvas = document.getElementById("canvas");

// Kontekst renderowania na płótnie
let ctx = canvas.getContext("2d");

Te zmienne będą przechowywać odwołania do ekranu „Game Over” oraz elementu canvas. Dodatkowo tworzymy kontekst, który będzie używany do rysowania na płótnie.

Następnie, poniżej powyższego fragmentu kodu, dodaj te definicje zmiennych:

// Definicje planszy
let gridSize = 400;
let unitLength = 10;

Pierwsza zmienna definiuje rozmiar planszy w pikselach. Druga zaś określa długość jednostki w grze. Długość jednostki będzie używana w wielu miejscach, takich jak grubość ścian labiryntu, grubość węża, rozmiar pożywienia, oraz wielkość przesunięcia węża.

Teraz dodajmy zmienne, które są odpowiedzialne za śledzenie stanu rozgrywki:

// Zmienne gry
let snake = [];
let foodPosition = { x: 0, y: 0 };
let direction = "right";
let collided = false;

Zmienna snake przechowuje informacje o pozycjach, jakie aktualnie zajmuje wąż. Wąż składa się z jednostek, gdzie każda z nich zajmuje określoną pozycję na płótnie. Pozycje jednostek są przechowywane w tablicy snake, zawierającej współrzędne x i y. Pierwszy element tablicy reprezentuje ogon węża, a ostatni – jego głowę.

Podczas ruchu węża będziemy dodawać elementy na końcu tablicy, przesuwając w ten sposób głowę. Jednocześnie usuwamy pierwszy element (ogon), aby utrzymać stałą długość węża.

Zmienna foodPosition przechowuje aktualne położenie pożywienia za pomocą współrzędnych x i y. Zmienna direction określa kierunek ruchu węża, natomiast zmienna collided to zmienna typu boolean, która przyjmuje wartość true, gdy wykryjemy kolizję.

Deklarowanie funkcji

Cała logika gry jest podzielona na mniejsze funkcje, co ułatwia pisanie i zarządzanie kodem. W tej sekcji zadeklarujemy te funkcje i omówimy ich przeznaczenie. W kolejnych sekcjach dokładnie zdefiniujemy poszczególne funkcje oraz wyjaśnimy ich algorytmy.

function setUp() {}
function doesSnakeOccupyPosition(x, y) {}
function checkForCollision() {}
function generateFood() {}
function move() {}
function turn(newDirection) {}
function onKeyDown(e) {}
function gameLoop() {}

Funkcja setUp inicjuje grę. Funkcja checkForCollision sprawdza, czy wąż zderzył się ze ścianą lub z własnym ciałem. Funkcja doesSnakeOccupyPosition sprawdza, czy dana pozycja (określona współrzędnymi x i y) jest zajęta przez jakąkolwiek część ciała węża. Będzie to przydatne przy szukaniu miejsca na umieszczenie pożywienia.

Funkcja move przesuwa węża w danym kierunku, natomiast funkcja turn zmienia ten kierunek. Funkcja onKeyDown obsługuje wciśnięcia klawiszy, które służą do zmiany kierunku. Funkcja gameLoop odpowiada za ruch węża i sprawdzanie kolizji w pętli gry.

Definiowanie funkcji

W tej sekcji zdefiniujemy wcześniej zadeklarowane funkcje. Dodatkowo omówimy szczegółowo jak działają poszczególne funkcje. Przed kodem znajdziesz krótki opis funkcji i komentarze wyjaśniające poszczególne linie kodu, jeśli będzie to potrzebne.

Funkcja setUp

Funkcja setUp realizuje trzy zadania:

  • Rysuje obramowanie labiryntu na płótnie.
  • Inicjuje węża, dodając jego pozycje do zmiennej snake i rysując go na płótnie.
  • Generuje początkową pozycję pożywienia.
  • Kod funkcji będzie wyglądać następująco:

      // Rysowanie granic na płótnie
      // Rozmiar płótna jest równy rozmiarowi siatki plus podwójna grubość obramowania
      canvasSideLength = gridSize + unitLength * 2;
    
      // Rysujemy czarny kwadrat, który obejmuje całe płótno
      ctx.fillRect(0, 0, canvasSideLength, canvasSideLength);
    
      // Czyścimy środek czarnego kwadratu, aby utworzyć obszar do gry.
      // Pozostawiamy czarną obwódkę, która stanowi obramowanie.
      ctx.clearRect(unitLength, unitLength, gridSize, gridSize);
    
      // Następnie, definiujemy początkowe pozycje głowy i ogona węża.
      // Długość początkowa węża wynosi 60 pikseli, czyli 6 jednostek.
    
      // Głowa węża będzie znajdować się 30 pikseli (3 jednostki) przed środkiem.
      const headPosition = Math.floor(gridSize / 2) + 30;
    
      // Ogon węża będzie znajdować się 30 pikseli (3 jednostki) za środkiem.
      const tailPosition = Math.floor(gridSize / 2) - 30;
    
      // Pętla od ogona do głowy, z przyrostem długości jednostki
      for (let i = tailPosition; i <= headPosition; i += unitLength) {
    
        // Zapisz pozycję każdej części węża i narysuj ją na płótnie
        snake.push({ x: i, y: Math.floor(gridSize / 2) });
    
        // Rysuj prostokąt o wymiarach unitLength * unitLength
        ctx.fillRect(x, y, unitLength, unitLength);
      }
    
      // Generuj jedzenie
      generateFood();

    doesSnakeOccupyPosition

    Ta funkcja przyjmuje współrzędne x i y jako pozycję. Następnie sprawdza, czy dana pozycja jest zajęta przez jakąkolwiek część węża. Wykorzystuje metodę find tablicy JavaScript do wyszukania pozycji o pasujących współrzędnych.

    function doesSnakeOccupyPosition(x, y) {
      return !!snake.find((position) => {
        return position.x == x && y == foodPosition.y;
      });
    }

    checkForCollision

    Ta funkcja sprawdza, czy doszło do kolizji węża i w przypadku wykrycia kolizji ustawia zmienną collided na true. Najpierw sprawdzamy kolizje z lewą i prawą ścianą, następnie z górną i dolną ścianą, a na końcu z samym wężem.

    Aby sprawdzić kolizje z lewą i prawą ścianą, weryfikujemy, czy współrzędna x głowy węża jest mniejsza od 0 lub większa lub równa gridSize. Analogicznie sprawdzamy kolizję z górną i dolną ścianą, ale w tym przypadku weryfikujemy współrzędną y.

    Na końcu sprawdzamy, czy głowa węża nie wchodzi w kolizję z jego własnym ciałem. W tym celu sprawdzamy, czy jakakolwiek inna część ciała węża zajmuje pozycję, w której znajduje się głowa węża. Biorąc pod uwagę te warunki, funkcja checkForCollision powinna wyglądać w sposób następujący:

     function checkForCollision() {
      const headPosition = snake.slice(-1)[0];
      // Sprawdź kolizję z lewą i prawą ścianą
      if (headPosition.x < 0 || headPosition.x >= gridSize - 1) {
        collided = true;
      }
    
      // Sprawdź kolizję z górną i dolną ścianą
      if (headPosition.y < 0 || headPosition.y >= gridSize - 1) {
        collided = true;
      }
    
      // Sprawdź kolizję z samym wężem
      const body = snake.slice(0, -2);
      if (
        body.find(
          (position) => position.x == headPosition.x && position.y == headPosition.y
        )
      ) {
        collided = true;
      }
    }

    generateFood

    Funkcja generateFood wykorzystuje pętlę do-while do znalezienia miejsca na planszy, które nie jest zajęte przez węża, i w którym można umieścić pożywienie. Po znalezieniu odpowiedniej pozycji, współrzędne są zapisywane i jedzenie jest rysowane na płótnie. Kod funkcji generateFood powinien wyglądać w ten sposób:

    function generateFood() {
      let x = 0,
        y = 0;
      do {
        x = Math.floor((Math.random() * gridSize) / 10) * 10;
        y = Math.floor((Math.random() * gridSize) / 10) * 10;
      } while (doesSnakeOccupyPosition(x, y));
    
      foodPosition = { x, y };
      ctx.fillRect(x, y, unitLength, unitLength);
    }

    move

    Funkcja move rozpoczyna się od stworzenia kopii pozycji głowy węża. Następnie, w oparciu o aktualny kierunek ruchu, zwiększa lub zmniejsza wartość współrzędnej x lub y węża. Na przykład zwiększenie wartości współrzędnej x jest równoznaczne z ruchem w prawo.

    Po dokonaniu przesunięcia, nowa pozycja głowy jest dodawana do tablicy snake. Również nowa pozycja głowy jest rysowana na płótnie.

    Następnie sprawdzamy, czy wąż w wyniku przesunięcia zjadł jedzenie. Aby to zrobić, porównujemy pozycję głowy z pozycją jedzenia. Jeśli wąż zjadł jedzenie, to wywołujemy funkcję generateFood w celu wygenerowania nowego pożywienia.

    Jeśli wąż nie zjadł jedzenia, to usuwamy pierwszy element z tablicy snake. Element ten reprezentuje ogon, a jego usunięcie pozwala zachować stałą długość węża, jednocześnie dając iluzję ruchu.

    function move() {
      // Tworzymy kopię obiektu reprezentującego pozycję głowy
      const headPosition = Object.assign({}, snake.slice(-1)[0]);
    
      switch (direction) {
        case "left":
          headPosition.x -= unitLength;
          break;
        case "right":
          headPosition.x += unitLength;
          break;
        case "up":
          headPosition.y -= unitLength;
          break;
        case "down":
          headPosition.y += unitLength;
      }
    
      // Dodajemy nową pozycję głowy do tablicy
      snake.push(headPosition);
    
      ctx.fillRect(headPosition.x, headPosition.y, unitLength, unitLength);
    
      // Sprawdź, czy wąż zjadł pożywienie
      const isEating =
        foodPosition.x == headPosition.x && foodPosition.y == headPosition.y;
    
      if (isEating) {
        // Wygeneruj nową pozycję jedzenia
        generateFood();
      } else {
        // Usuń ogon, jeśli wąż nie zjadł jedzenia
        tailPosition = snake.shift();
    
        // Usuń ogon z planszy
        ctx.clearRect(tailPosition.x, tailPosition.y, unitLength, unitLength);
      }
    }

    turn

    Ostatnią ważną funkcją, którą omówimy, jest funkcja turn. Ta funkcja przyjmuje nowy kierunek ruchu i zmienia wartość zmiennej direction na ten nowy kierunek. Ważne jest, aby pamiętać, że wąż może skręcić tylko pod kątem prostym do swojego aktualnego kierunku.

    Z tego powodu wąż może skręcić w lewo lub w prawo tylko wtedy, gdy aktualnie porusza się w górę lub w dół i odwrotnie. Mając na uwadze to ograniczenie, funkcja turn wygląda w sposób następujący:

    function turn(newDirection) {
      switch (newDirection) {
        case "left":
        case "right":
          // Zezwól na skręcanie w lewo lub w prawo tylko gdy wąż pierwotnie poruszał się w górę lub w dół
          if (direction == "up" || direction == "down") {
            direction = newDirection;
          }
          break;
        case "up":
        case "down":
          // Zezwól na skręcanie w górę lub w dół tylko gdy wąż pierwotnie poruszał się w lewo lub w prawo
          if (direction == "left" || direction == "right") {
            direction = newDirection;
          }
          break;
      }
    }

    onKeyDown

    Funkcja onKeyDown jest funkcją obsługi zdarzeń, która wywołuje funkcję turn z kierunkiem odpowiadającym naciśniętemu klawiszowi strzałki. Funkcja wygląda więc następująco:

    function onKeyDown(e) {
      switch (e.key) {
        case "ArrowDown":
          turn("down");
          break;
        case "ArrowUp":
          turn("up");
          break;
        case "ArrowLeft":
          turn("left");
          break;
        case "ArrowRight":
          turn("right");
          break;
      }
    }

    gameLoop

    Funkcja gameLoop będzie wywoływana regularnie, aby gra działała. Ta funkcja wywoła funkcję move oraz funkcję checkForCollision. Sprawdza również, czy zmienna collided ma wartość true. W takim przypadku zatrzymuje zegar, którego używamy do uruchomienia gry i wyświetla ekran zakończenia gry. Funkcja gameLoop będzie wyglądać następująco:

    function gameLoop() {
      move();
      checkForCollision();
    
      if (collided) {
        clearInterval(timer);
        gameOverScreen.style.display = "flex";
      }
    }

    Uruchomienie gry

    Aby uruchomić grę, dodaj następujące linijki kodu:

    setUp();
    document.addEventListener("keydown", onKeyDown);
    let timer = setInterval(gameLoop, 200);

    Najpierw wywołujemy funkcję setUp. Następnie dodajemy detektor zdarzeń „keydown”. Na koniec, za pomocą funkcji setInterval, uruchamiamy licznik czasu.

    Podsumowanie

    W tym momencie plik JavaScript powinien być identyczny z tym w moim repozytorium na GitHub. Jeśli coś nie działa, to sprawdź kod w repozytorium. Możesz również zapoznać się z naszym innym przewodnikiem dotyczącym tworzenia suwaka obrazów w JavaScrip.