Napiszmy grę w JavaScript: Lot kulki

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

Gdzie sprawiamy, by piłeczka poprawnie odbijała się od ścianek i od paletek, a gdy wypadnie poza planszę, by wracała na środek i punkty się zmieniały.

Jak to mawiał znany polski trener Kazimierz Górski: "piłka jest okrągła, a bramki są dwie". To właśnie piłka (a w naszym przypadku piłeczka) jest sednem gry. Więc to na niej się teraz skupimy. U nas piłeczka opisywana jest przez swoją pozycję (ballXballY) oraz kierunek ruchu (ballDXballDY). Do tej pory sprawiliśmy już, by piłeczka leciała w kierunku ruchu. Robimy to poprzez dodawanie kierunku ruchu do pozycji co określony czas.


function updateState() {
  //...
  ballX += ballDX; // lub ballX = ballX + ballDX
  ballY += ballDY; // lub ballY = ballY + ballDY
}

Zauważ, że może to oznaczać zarówno zwiększanie, jak i zmniejszanie ballXballY, w zależności od tego czy ballDXballDY przyjmuje wartości dodatnie, czy ujemne.

Sam ruch to za mało, ponieważ piłeczka musi się odbijać od ścian i paletek. Dopiero wtedy ich obecność będzie miała sens. Trzeba też przygotować się na sytuację, gdy jeden z graczy przepuści piłeczkę. W takim wypadku powinna ona wrócić na środek, a przeciwnik powinien otrzymać punkt.

Jak widać, wciąż mamy dużo do zrobienia. Aby lepiej zapanować nad naszymi zmianami, wydzielmy odpowiednie funkcje odpowiedzialne za to, co będziemy robić. Z funkcji updateState wywołamy moveBall odpowiedzialną ogólnie za ruch piłeczki. Ta natomiast będzie wołała moveBallByStep, odpowiedzialną konkretnie za przesunięcie o krok w kierunku ruchu.

function moveBallByStep() {
  ballX += ballDX;
  ballY += ballDY;
}

function moveBall() {
  moveBallByStep();
}

function updateState() {
  moveBall();
  movePaddles();
}

Z takim planem, jesteśmy gotowi do działania. Odbijanie piłeczki jest nieco bardziej skomplikowane, więc zacznijmy od prostszego zadania: powrotu na środek.

Piłeczka wraca na środek

Pomyślmy o sytuacji, gdy piłeczka wylatuje poza planszę z lewej lub prawej strony:

  • Jeśli piłeczka wyleciała po lewej stronie, to powinna wrócić na środek i gracz drugi powinien dostać punkt.
  • Jeśli wyleciała po prawej stronie, to powinna wrócić na środek i gracz pierwszy powinien dostać punkt.

Tę logikę można wyrazić następująco:

if (ballIsOutsideOnLeft()) {
  moveBallToStart();
  p2Points++;
} else if (ballIsOutsideOnRight()) {
  moveBallToStart();
  p1Points++;
}

Samo przeniesienie piłeczki na środek to ustawienie jej współrzędnych na początkowe.

function moveBallToStart() {
  ballX = BALL_START_X;
  ballY = BALL_START_Y;
}

Skąd mamy wiedzieć, że piłeczka wyleciała poza planszę po lewej stronie? Zapewne powinniśmy bazować na współrzędnych jej środka. Współrzędna ballY w zasadzie nas nie interesuje, bo zakładamy, że ścianki utrzymują piłeczkę na dozwolonej wysokości. Bardziej interesuje nas ballX. Kiedy jest równe 0, to wiemy, że środek piłeczki dotarł do lewego końca planszy - a to już stanowczo za późno, by ją odbić. Możemy zdefiniować funkcję ballIsOutsideOnLeft jak poniżej.

function ballIsOutsideOnLeft() {
  return ballX <= 0;
}

W zasadzie jednak już wcześniej jest za późno na jej odbicie. Nie da się uratować kolejki, gdy piłeczka dotknie lewego krańca planszy. W takim przypadku powinniśmy wziąć poprawkę na wielkość piłeczki.

function ballIsOutsideOnLeft() {
  return ballX - BALL_R <= 0;
}

Ktoś inny mógłby argumentować, że powinna ona zostać uznana za straconą, dopiero po całkowitym zniknięciu z planszy. W takim przypadku wielkość piłeczki powinniśmy dodać, zamiast odejmować.

function ballIsOutsideOnLeft() {
  return ballX + BALL_R <= 0;
}

To kiedy właściwie piłeczka wyjechała poza planszę? To niemal filozoficzne pytanie. Z technicznego punktu widzenia wszystkie powyższe odpowiedzi są poprawne. Zostawiam tę decyzję Tobie. Na potrzeby następnych części założę przypadek, w którym piłeczka musi wyjść całkowicie za planszę.

function ballIsOutsideOnLeft() {
  return ballX + BALL_R <= 0;
}

Analogicznie z drugiej strony, środek piłeczki sięga prawego końca, gdy ballX jest równy szerokości planszy CANVAS_WIDTH. Zakładając poprawkę na wielkość piłeczki, w poniższy sposób możemy sprawdzić, czy opuściła ona już w całości planszę:

function ballIsOutsideOnRight() {
  return ballX - BALL_R >= CANVAS_WIDTH;
}

Piłeczka odbija się od ścianek

Udało się na chwilę ominąć ten temat, ale czas wreszcie zabrać się za odbicie piłeczki. Odbicie to zmiana jej kierunku ruchu, czyli ballDXballDY. Może pamiętacie ze szkoły skomplikowane wzory na zmianę kierunku ruchu przy odbiciu — spokojnie, tutaj nie będą potrzebne. Życie bardzo ułatwia nam fakt, że ścianki są idealnie poziome, a paletki idealnie pionowe.

Wyobraź sobie, że piłeczka leci ukośnie w górę i w prawo, po czym odbija się od górnej ścianki. Czy po odbiciu powinna poruszać się w prawo szybciej lub wolniej? Ani jedno, ani drugie. Powinna poruszać się dokładnie z taką samą prędkością. Zmieniła się tylko składowa ruchu pionowego (góra-dół). Czy zatem będzie w tej płaszczyźnie poruszała się szybciej niż poprzednio? Znów nie. Jeśli poruszała się szybko w górę, to teraz tak samo szybko będzie poruszała się w dół. Jeśli zaś wolno w górę, to wciąż wolno w dół. Szybkość pozostanie taka sama, zmieni się jedynie kierunek (a dokładnie jego zwrot w płaszczyźnie pionowej).

Wizualizacja, jak piłeczka odbija się od górnej ścianki.

Matematycznie rzecz ujmując, musimy zmienić znak tej zmiennej na przeciwny — jeśli była ujemna to na dodatni, a jeśli dodatnia to na ujemy. Dokładnie tak samo stanie się przy odbiciu się piłeczki od dolnej ścianki, dlatego możemy stworzyć wspólną funkcję bounceBallFromWall.

function bounceBallFromWall() {
  ballDY = -ballDY;
}

Analogiczna sytuacja ma miejsce wtedy, gdy piłeczka odbija się od idealnie pionowej paletki. Tutaj prędkość i kierunek w płaszczyźnie pionowej (góra-dół) pozostaje taki sam. A w płaszczyźnie poziomej (prawo-lewo) prędkość zostaje taka sama, ale kierunek się zmienia.

function bounceBallFromPaddle() {
  ballDX = -ballDX;
}

Kiedy dojdzie do odbicia? Wtedy, gdy piłeczka dotknie ścianki. Po poprzedniej sekcji wiemy już jak to zrobić — funkcje te będą analogiczne do sprawdzających, czy nie opuściła planszy w płaszczyźnie poziomej (prawo-lewo). Powinny porównywać ballY z poprawką na BALL_R do 0 oraz do CANVAS_HEIGHT.

function shouldBounceBallFromTopWall() {
  return ballY - BALL_R <= 0;
}

function shouldBounceBallFromBottomWall() {
  return ballY + BALL_R >= CANVAS_HEIGHT;
}

Powinniśmy dodać jeszcze jeden warunek. Piłeczka powinna odbić się od górnej ścianki tylko wtedy, jeśli leci w górę, czyli ballDY < 0. Analogicznie od dolnej tylko wtedy, jak leci w dół, czyli ballDY > 0. W zasadzie nie powinno to być konieczne, ale ten warunek stanowi dla nas dodatkowe zabezpieczenie przed niepożądaną sytuacją, gdy piłeczka znajdzie się przypadkiem za wysoko, po czym ugrzęźnie, odbijając się na zmianę w górę i w dół.

function shouldBounceBallFromTopWall() {
  return ballY <= BALL_R && ballDY < 0;
}

function shouldBounceBallFromBottomWall() {
  return ballY + BALL_R >= CANVAS_HEIGHT && ballDY > 0;
}

Ponieważ dla obydwu przypadków odbicie jest takie samo, wystarczy pojedyncza instrukcja if. W jej warunku powinniśmy uwzględnić możliwość odbicia się zarówno od dolnej, jak i od górnej ściany.

if (shouldBounceBallFromTopWall() ||
    shouldBounceBallFromBottomWall()) {
  bounceBallFromWall();
}

Piłeczka odbija się od paletek

Z bardzo podobną sytuacją mamy do czynienia przy odbijaniu od paletek, ale ponieważ nie pokrywają one całej powierzchni prawej i lewej ściany, musimy dodać funkcję sprawdzającą, czy paletka znajduje się w miejscu pozwalającym na odbicie.

W tym miejscu mamy wachlarz nietypowych sytuacji, które mogłyby się pojawić. Na przykład odbicie od kantu albo odbicie górną ścianką paletki. Dla uproszczenia nie będziemy się nimi zajmować (będzie to opisane jako zadanie dodatkowe).

Założymy, że interesuje nas tylko proste odbicie poziome (prawo-lewo). Aby takie nastąpiło, piłeczka musi dotknąć paletki prawym lub lewym skrajem. To zaś następuje, gdy jej środek znajdzie się na wysokości paletki.

Piłeczka dotyka poziomej ściany dokładnie na wysokości swojego środka.

function isBallOnTheSameHeightAsPaddle(paddleY) {
  return ballY >= paddleY &&
    ballY <= paddleY + PADDLE_HEIGHT;
}

Wielokrotnie sprawdzamy, czy wartość zawiera się pomiędzy dwiema innymi. Możemy więc wydzielić funkcję pomocniczą isInBetween.

function isInBetween(value, min, max) {
  return value >= min && value <= max;
}

function isBallOnTheSameHeightAsPaddle(paddleY) {
  return isInBetween(ballY, paddleY,
    paddleY + PADDLE_HEIGHT);
}

Poniżej lista warunków piłeczki koniecznych do odbicia jej od paletki:

  • Jest na wysokości paletki.
  • Dotarła do paletki.
  • Porusza się odpowiednim kierunku (znów, by uniknąć sytuacji, w której piłeczka ugrzęźnie, odbijając się to w tę, to w tę).

Pomyślmy o prawej paletce. Punkt, w którym piłeczka powinna się odbić, to ten, w którym jej prawy skraj (współrzędna środka ballX plus promień piłeczki BALL_R) jest równy współrzędnej poziomej paletki (PADDLE_P2_X), czyli ballX + BALL_R === PADDLE_P2_X. Ponieważ jednak w naszej grze wszystko odbywa się w kolejnych krokach, istnieje spora szansa, że gdybyśmy użyli takiego warunku, to piłeczka przeleciałaby przez paletkę. Załóżmy, że ballX = 179, BALL_R = 20, PADDLE_P2_X = 200, ballDX = 3. Warunek nie jest spełniony, bo 199 nie jest równe 200. Nawet jeśli zrobimy krok (czyli dodamy ballDX do ballX), to warunek nadal nie będzie spełniony, bo 202 nie jest równe 200. Rozwiązaniem jest założenie marginesu błędu. My użyjemy do tego szerokości paletki. Tak więc odbijemy piłeczkę, gdy jej skraj znajdzie się w obszarze paletki, a więc między PADDLE_P2_XPADDLE_P2_X + PADDLE_WIDTH. Poniżej cały kod, zarówno dla jednej, jak i drugiej paletki.

function shouldBounceFromLeftPaddle() {
  return ballDX < 0 &&
    isInBetween(ballX - BALL_R, PADDLE_P1_X,
      PADDLE_P1_X + PADDLE_WIDTH) &&
    isBallOnTheSameHeightAsPaddle(p1PaddleY);
}

function shouldBounceFromRightPaddle() {
  return ballDX > 0 &&
    isInBetween(ballX + BALL_R, PADDLE_P2_X,
      PADDLE_P2_X + PADDLE_WIDTH) &&
    isBallOnTheSameHeightAsPaddle(p2PaddleY);
}

Z obu stron odbicie wygląda tak samo. Powinno więc nastąpić, gdy dowolny z tych dwóch warunków jest spełniony.

if (shouldBounceFromLeftPaddle() ||
    shouldBounceFromRightPaddle()) {
  bounceBallFromPaddle();
}

Po tych zmianach nasza gra jest już gotowa, by zacząć w nią grać.

Kod, który powinien zostać po tym rozdziale, znajdziesz pod linkiem:

https://github.com/MarcinMoskala/pong/blob/master/4_ball.html

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

Aktualny stan gry w CodePen.

Następny rozdział wymaga wiedzy z części drugiej, Głębsze wody. Warto się z nią zapoznać przed przystąpieniem do niego. Możesz też przeskoczyć zarówno jedno jak i drugie, by przejść do części czwartej.

Zadania dodatkowe

Aby lepiej skorzystać z tego rozdziału, polecam pobawić się tym kodem i spróbować go zmodyfikować po swojemu. Pamiętaj tylko, żeby zapisywać różne wersje - by móc zawsze do nich wrócić1. Oto kilka pomysłów jak można uczynić tę grę ciekawszą:

  • Po powrocie na środek piłeczka może mieć losowy kierunek (wspomnij funkcję Math.random()).
  • Przy odbiciu albo po powrocie na środek, piłeczka może nieznacznie przyspieszać (pamiętaj uwzględnić to przy marginesie odbicia od paletki, by krok nie był większy niż sam margines).
  • Przy odbiciu możesz zmieniać kierunek piłeczki w zależności od ruchu paletki lub od punktu odbicia (jakby paletka była półokrągła).
  • Możesz sprawić, aby odbicie paletką było możliwe od jej kantu, co zmieniłoby kierunek piłeczki w poprawny matematycznie sposób.
  • Możesz też umożliwić odbicie górną lub dolną krawędzią paletki. Nie będzie to miało wpływu na grywalność, bo nie uratuje to piłeczki, ale uzyskasz za to ciekawy efekt wizualny.

Wybierz i wprowadź przynajmniej jedną z tych modyfikacji.

1:

Ponoć ludzie dzielą się na tych, co nauczyli się zapisywać wersje robocze i na tych, którzy się dopiero tego nauczą.