Napiszmy grę w JavaScript: W odpowiedzi na Twoje kliknięcie

Cześć! To jest fragment książki JavaScript od podstaw, która ma pomóc w nauce programowania od zera.

Gdzie oddajemy graczom kontrolę nad paletkami.

Wyobraź sobie, że jesteś programem, który powinien zareagować, gdy tylko użytkownik naciśnie przycisk na klawiaturze. Nie wiesz kiedy to nastąpi — może za chwilę, a może za godzinę. W takich przypadkach mówi się zwykle o dwóch typach nasłuchiwania:

  1. Nasłuchiwanie aktywne — czyli, sprawdzanie co chwilę, czy wydarzenie nie nastąpiło. Taka opcja nie wchodzi w grę w JavaScript, gdyż strona by nam się zawiesiła.
  2. Nasłuchiwanie pasywne — czyli "poinformujcie mnie, jak to się wydarzy". Najczęściej oznacza wskazanie, jaka funkcja powinna być wywołana, gdy zdarzenie będzie miało miejsce.

Informacji o naciśnięciu przycisku będziemy nasłuchiwali pasywnie. Przy użyciu funkcji window.addEventListener określimy co powinno się wydarzyć, gdy wystąpi interesujące nas zdarzenie. Przekazujemy tam jako argument: na jakie zdarzenie nasłuchujemy oraz funkcję do wywołania kiedy ono nastąpi. O całą resztę zadba już przeglądarka internetowa.

window.addEventListener("keydown", functionOnKeydown);

W tym przypadku interesujące nas zdarzenia to keydown, które następuje przy wciśnięciu przycisku, oraz keyup, który następuje, gdy zaprzestaniemy tej akcji. Jeśli chodzi o funkcję, to najczęściej używa się funkcji anonimowej albo strzałkowej. Możesz je przetestować przy użyciu poniższego kodu.

window.addEventListener('keydown', function (event) {
  console.log('keydown');
});

window.addEventListener('keyup', function (event) {
  console.log('keyup');
});

// Albo

window.addEventListener('keydown', event => {
  console.log('keydown');
});

window.addEventListener('keyup', event => {
  console.log('keyup');
});

Za każdym razem, gdy wciśniesz jakikolwiek przycisk na klawiaturze, w konsoli pojawi się "keydown". Gdy przestaniesz naciskać, zobaczysz komunikat "keyup".

No dobrze, ale nie interesuje nas dowolny przycisk, tylko konkretny. Aby ustalić, co zostało wciśnięte, możemy użyć obiektu event, który jest przekazywany do naszej funkcji nasłuchującej. Zawiera on informacje na temat zdarzenia, które nastąpiło. Zawiera między innymi właściwość code określającą, co zostało wciśnięte. Aby przetestować jak działa, wywołaj poniższy kod w konsoli albo na stronie, po czym zacznij wciskać dowolne przyciski i popatrz jaki uzyskasz wynik.

window.addEventListener('keydown', function (event) {
  console.log('keydown ' + event.code);
});

window.addEventListener('keyup', function (event) {
  console.log('keyup ' + event.code);
});

Spodziewany wynik pokazuje, że dla dowolnej litery kod zapisany w event.code powinien być "Key" oraz wciśnięta litera. Tak więc, po wciśnięciu litery k, powinieneś zobaczyć "keydown KeyK", a po podniesieniu palca powinno to być "keyup KeyK". Bez znaczenia czy używasz Shift, czy nie, kod litery zawsze powinien zawierać wielką literę symbolizującą wciśnięty klawisz (wciśnięcie lewego Shift powinno wyświetlić "keydown ShiftLeft").

Ruch paletki

Nowo poznaną funkcję możemy wykorzystać do sterowania naszymi paletkami. Załóżmy, że gracz pierwszy poruszać będzie paletką przy użyciu liter Q i A, a gracz drugi przy użyciu P i L (możesz śmiało wybrać inne litery). Poniżej znajduje się implementacja1, która dla każdego naciśnięcia wymienionych przycisków, poruszy odpowiednią paletką.

const PADDLE_STEP = 5;

window.addEventListener('keydown', function (event) {
  const code = event.code;
  if (code === 'KeyQ') {
    p1PaddleY -= PADDLE_STEP;
  } else if (code === 'KeyA') {
    p1PaddleY += PADDLE_STEP;
  } else if (code === 'KeyP') {
    p2PaddleY -= PADDLE_STEP;
  } else if (code === 'KeyL') {
    p2PaddleY += PADDLE_STEP;
  }
});

Takie sterowanie byłoby jednak wysoce niewygodne, gdyż wymagałoby bardzo szybkiego naciskania przycisków przez obu graczy. Mogłoby też być zabójcze dla klawiatury. Lepszym rozwiązaniem byłoby jednokrotne wciśnięcie przez gracza dedykowanego przycisku, mówiącego w którą stronę paletka ma się poruszać, a ona poruszałaby się ze stałą prędkością. Wprawienie w ruch ze stałą prędkością uzyskamy poprzez modyfikację stanu w updateState, która powinna być wywoływana w stałych odstępach czasu. Potrzebujemy tylko zmiennej przechowującej, w jakim kierunku dana paletka powinna się poruszać. Dla poprawienia czytelności tego rozwiązania określmy możliwe akcje jako następujące stringi:

  • "stop" oznacza, że paletka powinna stać w miejscu;
  • "up" oznacza, że paletka powinna poruszać się w górę;
  • "down" oznacza, że paletka powinna poruszać się w dół;
const PADDLE_STEP = 3;

let p1Action = "stop";
let p2Action = "stop";

window.addEventListener('keydown', function (event) {
  const code = event.code;
  if (code === 'KeyQ') {
    p1Action = "up";
  } else if (code === 'KeyA') {
    p1Action = "down";
  } else if (code === 'KeyP') {
    p2Action = "up";
  } else if (code === 'KeyL') {
    p2Action = "down";
  }
});

function updateState() {
  if (p1Action === "up") {
    p1PaddleY -= PADDLE_STEP;
  } else if (p1Action === "down") {
    p1PaddleY += PADDLE_STEP;
  }
  if (p2Action === "up") {
    p2PaddleY -= PADDLE_STEP;
  } else if (p2Action === "down") {
    p2PaddleY += PADDLE_STEP;
  }

  ballX += ballDX;
  ballY += ballDY;
};

Powyższa implementacja jest krokiem do przodu, wymaga jednak pewnego uporządkowania. Wielokrotnie powtarzające się stringi określające akcje powinny być wydzielone jako stałe — w przeciwieństwie do powtarzania się w kółko. Poruszanie paletką również wyciągniemy jako osobną funkcję, by podkreślić, że jest to tylko część procesu zmiany stanu.

const P1_UP_BUTTON = 'KeyQ';
const P1_DOWN_BUTTON = 'KeyA';
const P2_UP_BUTTON = 'KeyP';
const P2_DOWN_BUTTON = 'KeyL';

const UP_ACTION = "up";
const DOWN_ACTION = "down";
const STOP_ACTION = "stop";

let p1Action = STOP_ACTION;
let p2Action = STOP_ACTION;

window.addEventListener('keydown', function (event) {
  const code = event.code;
  if (code === P1_UP_BUTTON) {
    p1Action = UP_ACTION;
  } else if (code === P1_DOWN_BUTTON) {
    p1Action = DOWN_ACTION;
  } else if (code === P2_UP_BUTTON) {
    p2Action = UP_ACTION;
  } else if (code === P2_DOWN_BUTTON) {
    p2Action = DOWN_ACTION;
  }
});

function movePaddles() {
  if (p1Action === UP_ACTION) {
    p1PaddleY -= PADDLE_STEP;
  } else if (p1Action === DOWN_ACTION) {
    p1PaddleY += PADDLE_STEP;
  }
  if (p2Action === UP_ACTION) {
    p2PaddleY -= PADDLE_STEP;
  } else if (p2Action === DOWN_ACTION) {
    p2PaddleY += PADDLE_STEP;
  }
}

function updateState() {
  movePaddles();

  ballX += ballDX;
  ballY += ballDY;
}

Zatrzymanie paletki

Aktualnie paletka raz wprawiona w ruch, cały czas będzie się poruszała. Możemy jedynie zmieniać jej kierunek w górę lub w dół. Takie podejście spotyka się w pewnych grach komputerowych, a więc jeśli Ci się to podoba, możesz tak zostawić i zupełnie pominąć ten krok. Standardowym jednak rozwiązaniem jest, by paletka przestała się poruszać w daną stronę, gdy przestaniemy naciskać przycisk ruchu w tę stronę. Do tego wykorzystamy nasłuchiwanie na "keyup".

window.addEventListener('keyup', function (event) {
  // Wywołane, gdy przestaniemy naciskać przycisk
});

Dla każdego przycisku musimy osobno zaimplementować logikę. Jeśli puścimy przycisk ruchu w górę i poruszymy się w górę, to powinniśmy przestać. Analogicznie stanie się, gdy puścimy przycisk ruchu w dół i poruszymy się w dół.

window.addEventListener('keyup', function (event) {
  const code = event.code;
  if (code === P1_UP_BUTTON && p1Action === UP_ACTION) {
    p1Action = STOP_ACTION;
  } else if (code === P1_DOWN_BUTTON && 
             p1Action === DOWN_ACTION) {
    p1Action = STOP_ACTION;
  } else if (code === P2_UP_BUTTON && 
             p2Action === UP_ACTION) {
    p2Action = STOP_ACTION;
  } else if (code === P2_DOWN_BUTTON && 
             p2Action === DOWN_ACTION) {
    p2Action = STOP_ACTION;
  }
});

Zastanów się, co w sytuacji, gdybyśmy nie dodali dodatkowego warunku sprawdzającego, w którą stronę porusza się paletka? Wyobraź sobie, że: naciskasz przycisk P, nie przestając naciskasz także L i dopiero wtedy podnosisz palec znad P. Logicznie wciąż wciskasz L, więc paletka powinna poruszać się w dół. Jednak właśnie przestałeś naciskać przycisk ruchu w górę, co w konsekwencji sprawi, że bez dodatkowego sprawdzenia, paletka by się zatrzymała.

Efekt powinien być taki sam, zarówno gdy powinniśmy zaprzestać poruszania się w górę, jak i w dół: ustawiamy akcję zatrzymania paletki. Dla przejrzystości zapisu możemy połączyć te dwa warunki w jeden, przy użyciu alternatywy ||.

window.addEventListener('keyup', function (event) {
  let code = event.code;
  if (
  (code === P1_UP_BUTTON && p1Action === UP_ACTION) ||
  (code === P1_DOWN_BUTTON && p1Action === DOWN_ACTION)
  ) {
    p1Action = STOP_ACTION;
  } else if (
  (code === P2_UP_BUTTON && p2Action === UP_ACTION) ||
  (code === P2_DOWN_BUTTON && p2Action === DOWN_ACTION)
  ) {
    p2Action = STOP_ACTION;
  }
  });

To wystarczy, aby nasza paletka zatrzymała się, gdy przestajemy wciskać przycisk odpowiedzialny za jej ruch.

Blokowanie na skrajach

Możemy już sterować naszą paletką, ale łatwo możemy ją... zgubić. Nic nie stoi na przeszkodzie, by wyjechać nią poza planszę, tym samym tracąc ją z pola widzenia. Nie chcemy takiego efektu — wręcz przeciwnie, paletka powinna zatrzymywać się na skraju.

Najprostszym sposobem będzie dodanie warunku w funkcji updateState.

  • Powinniśmy zabronić ruchu w górę (p1Action === UP_ACTION), gdy paletka znajduje się na samym szczycie, czyli gdy jej górna krawędź (p1PaddleY) jest równa lub większa od 0 (p1PaddleY >= 0).
  • Powinniśmy zabronić ruchu w dół (p1Action === DOWN_ACTION), gdy paletka jest na samym dole, czyli gdy jej dolna krawędź (p1PaddleY + PADDLE_HEIGHT) znajdzie się poniżej dolnego skraju (p1PaddleY + PADDLE_HEIGHT <= CANVAS_HEIGHT).
function movePaddles() {
  if (p1Action === UP_ACTION && p1PaddleY >= 0) {
    p1PaddleY -= PADDLE_STEP;
  } else if (p1Action === DOWN_ACTION &&
      p1PaddleY + PADDLE_HEIGHT <= CANVAS_HEIGHT) {
    p1PaddleY += PADDLE_STEP;
  }
  if (p2Action === UP_ACTION && p2PaddleY >= 0) {
    p2PaddleY -= PADDLE_STEP;
  } else if (p2Action === DOWN_ACTION &&
      p2PaddleY + PADDLE_HEIGHT <= CANVAS_HEIGHT) {
    p2PaddleY += PADDLE_STEP;
  }
}

Taka implementacja działa, choć ma pewne wady. Przede wszystkim nie zatrzymuje paletki dokładnie na skraju planszy. Wyobraźmy sobie, że p1PaddleY wynosi 1 i zadany jest ruch w górę. Warunek jest spełniony, więc paletka zostanie przesunięta w górę o krok PADDLE_STEP, który ustawiliśmy na 3. Tak więc, wartość p1PaddleY wyniesie 1 — 3 = -2. Dopiero w następnym kroku się zatrzyma. Moglibyśmy poprawić nasz warunek, by zatrzymywał paletkę wcześniej, ale wtedy powstałby problem paletki, która nie może dojść do ściany, a zamiast tego zostaje o niepełny krok od niej.

Jak poprawnie rozwiązać ten problem? Idealnie będzie założyć, że jeśli paletka miałaby wyjechać poza planszę u góry, to powinna się zatrzymać najwyżej, jak to tylko możliwe, a jeśli u dołu to najniżej.

Ograniczenie zakresu wartości liczbowych to znany wzorzec w programowaniu, standardowo określany przez funkcję coerceIn. Przyjmuje ona 3 argumenty:

  • value — wartość, którą mamy utrzymać w zakresie;
  • min — minimum, na jakie pozwalamy;
  • max — maksimum, na jakie pozwalamy.

Implementacja może wyglądać następująco:

function coerceIn(value, min, max) {
  if (value <= min) {
    return min;
  } else if(value >= max) {
    return max;
  } else {
    return value;
  }
}

// Wartości w zakresie
console.log(coerceIn(50, 0, 100)); // 50
console.log(coerceIn(60, 0, 100)); // 60
console.log(coerceIn(100, 0, 100)); // 100

// Wartość poniżej minimum
console.log(coerceIn(-0.01, 0, 100)); // 0
console.log(coerceIn(-10, 0, 100)); // 0

// Wartość powyżej maksimum
console.log(coerceIn(100.1, 0, 100)); // 100
console.log(coerceIn(150, 0, 100)); // 100

Warto dodać jeszcze jeden sposób w jaki tę funkcję można zaimplementować. Wykorzystujemy wówczas funkcję Math.min oraz Math.max. Powiedzenie, że jeśli wartość value jest mniejsza od wartości min to powinna być równa min jest równoznaczne z wyrażeniem Math.max(value, min). Dzieje się tak dlatego, że funkcja Math.max zawsze wybiera większą z wartości.

console.log(Math.max(10, 0)); // 10
console.log(Math.max(0.1, 0)); // 0.1
console.log(Math.max(0, 0)); // 0
console.log(Math.max(-0.1, 0)); // 0
console.log(Math.max(-10, 0)); // 0

Analogicznie, powiedzenie, że jeśli wartość jest większa od max to powinniśmy zwrócić max, można zaimplementować poprzez Math.min(value, max)

console.log(Math.min(10, 100)); // 10
console.log(Math.min(99.9, 100)); // 99.9
console.log(Math.min(100, 100)); // 100
console.log(Math.min(100.1, 100)); // 100
console.log(Math.min(110, 100)); // 100

Łącząc te dwa fakty, możemy zmienić ciało funkcji coerceIn na poniższe, a jej działanie zostanie takie jak wcześniej.

function coerceIn(value, min, max) {
  return Math.max(Math.min(value, max), min);
}

Wykorzystując naszą nową funkcję, możemy spokojne modyfikować pozycję paletki. Dodamy tylko ograniczenie, które zapewni, że nigdy nie wyjedzie ona poza dozwolony zakres. Jaki on powinien być? By nie wyjechać na górze – pozycja nie powinna być mniejsza od 0. By nie wyjechać na dole – dół paletki nie powinien być poniżej dolnego krańca płótna. Uwzględniając więc wysokość paletki, pozycja góry paletki nie powinna być większa niż CANVAS_HEIGHT — PADDLE_HEIGHT.

function movePaddles() {
  const yMin = 0;
  const yMax = CANVAS_HEIGHT - PADDLE_HEIGHT;
  if (p1Action === UP_ACTION) {
    p1PaddleY =
      coerceIn(p1PaddleY - PADDLE_STEP, yMin, yMax);
  } else if (p1Action === DOWN_ACTION) {
    p1PaddleY =
      coerceIn(p1PaddleY + PADDLE_STEP, yMin, yMax);
  }
  if (p2Action === UP_ACTION) {
    p2PaddleY =
      coerceIn(p2PaddleY - PADDLE_STEP, yMin, yMax);
  } else if (p2Action === DOWN_ACTION) {
    p2PaddleY =
      coerceIn(p2PaddleY + PADDLE_STEP, yMin, yMax);
  }
}

Widzimy bardzo powtarzalne użycie funkcji coerceIn z takimi samymi ograniczeniami. Możemy więc to zachowanie wydzielić do osobnej funkcji coercePaddle.

function coercePaddle(paddleY) {
  const minPaddleY = 0;
  const maxPaddleY = CANVAS_HEIGHT - PADDLE_HEIGHT;
  return coerceIn(paddleY, minPaddleY, maxPaddleY);
}

function movePaddles() {
  if (p1Action === UP_ACTION) {
    p1PaddleY = coercePaddle(p1PaddleY - PADDLE_STEP);
  } else if (p1Action === DOWN_ACTION) {
    p1PaddleY = coercePaddle(p1PaddleY + PADDLE_STEP);
  }
  if (p2Action === UP_ACTION) {
    p2PaddleY = coercePaddle(p2PaddleY - PADDLE_STEP);
  } else if (p2Action === DOWN_ACTION) {
    p2PaddleY = coercePaddle(p2PaddleY + PADDLE_STEP);
  }
}

W tym momencie może Cię zastanawiać sensowność zostawiania coerceIn jako osobnej funkcji. Przecież moglibyśmy wszczepić jej ciało do funkcji coercePaddle. Pamiętajmy jednak, że funkcja coerceIn implementuje pewien uniwersalny wzorzec, który wciąż może się przydać w innych okolicznościach. Także zostawienie jej osobno nadaje temu wzorcowi uniwersalną nazwę, a tym samym zwiększa czytelność naszego kodu.

Pauzowanie

Każdą grę kiedyś trzeba przerwać. Czasem wzywają obowiązki domowe, a czasem fizjologia. Dlatego większość gier ma zaimplementowaną funkcjonalność pauzy. Dodanie jej nie powinno stanowić problemu. Wystarczy, że wprowadzimy zmienną paused o wartości logicznej (true lub false), po czym dodamy warunek, że wykonamy kolejny krok gry tylko, gdy jest ona równa false.

let paused = false;

function updateAndDrawState() {
  if (paused === false) {
    updateState();
    drawState();
  }
}

Ten warunek można uprościć. paused === false w tym przypadku jest równoznaczna z !paused.

function updateAndDrawState() {
  if (!paused) {
    updateState();
    drawState();
  }
}

Zamiast owijać całe ciało funkcji updateAndDrawState w warunek, możemy powiedzieć na samym początku, że chcemy, by funkcja została natychmiast zakończona, jeśli gra jest zatrzymana. Natychmiastowe zakończenie funkcji można zrobić przez użycie return, które momentalnie kończy funkcję.

function updateAndDrawState() {
  if (paused) return;
  updateState();
  drawState();
}

Aby nasze pauzowanie działało, powinniśmy nasłuchiwać również na wciśnięcie przycisku odpowiadającego za zatrzymanie. Ponieważ literę P już zajęliśmy, zdecydowałem się użyć położonej na samym środku litery B. Wobec tego przyjrzyjmy się, co powinno się stać, gdy wciśnięty zostanie przycisk pauzy. Jeśli gra się toczy, to powinna zostać zatrzymana. Gdy jest zatrzymana, to powinna wystartować. Innymi słowy, zmieniamy wartość zmiennej paused na przeciwną, a więc paused = !paused.

const PAUSE_BUTTON = 'KeyB';

let paused = false;

window.addEventListener('keydown', function (event) {
  //...
  if (code === PAUSE_BUTTON) {
    paused = !paused;
  }
});

Na koniec powinniśmy być w stanie w pełni kontrolować paletki oraz pauzować grę. Kod, który powinien zostać pod koniec tego rozdziału, znajdziesz pod linkiem:

https://github.com/MarcinMoskala/pong/blob/master/3_input.html

Zamiast linku, możesz użyć tego kodu QR.

Aktualny stan gry w CodePen.

Byśmy mogli pograć w naszą grę, do zaimplementowania pozostał nam już tylko ruch piłeczki.

1:

Patrz Słowniczek na samym końcu książki.