Napiszmy grę w JavaScript: Wprowadzamy podejście obiektowe

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

Gdzie wprowadzamy do kodu gry elementy programowania obiektowego, by uporządkować nasz kod.

Wraz z wprowadzaniem kolejnych funkcjonalności, rosła nam liczba zmiennych i funkcji. W tym momencie mamy: 8 zmiennych i 17 funkcji służących do zmiany stanu. Ponadto, większość z nich wskazuje w nazwie do czego, de facto, się odnoszą. Czy na przykład: do piłeczki, do paletki, czy też do gracza. Wnioski te sugerują nam, że właściwie brakuje jedynie podziału na obiekty reprezentujące piłeczkę, paletki i graczy. Uporządkujmy więc kod, zaczynając od piłeczki.

Porządkowanie kodu to bardzo istotna część pracy programisty. Kod musi nie tylko dobrze działać, ale liczy się także jego czytelność.

Piłeczka jako obiekt

Do określenia pozycji i ruchu piłeczki potrzebne są aż cztery zmienne zaczynające się od ball. Możemy je umieścić w obiekcie ball:

const ball = {
  x: BALL_START_X,
  y: BALL_START_Y,
  dx: BALL_START_DX,
  dy: BALL_START_DY
};

Aby kod działał dalej, wystarczy zamienić:

  • ballX na ball.x,
  • ballY na ball.y,
  • ballDX na ball.dx,
  • ballDY na ball.dy.

Następnym wymaganym krokiem jest przeniesienie, do tego obiektu funkcji, ewidentnie odnoszących się do piłeczki. Za przykład może nam posłużyć zamiana funkcji moveBallByStep na metodę moveByStep obiektu ball. Wówczas do piłeczki powinniśmy odnosić się przez słówko this. Metodę zaś powinniśmy wywoływać na obiekcie.

// Było
const ball = {
  x: BALL_START_X,
  y: BALL_START_Y,
  dx: BALL_START_DX,
  dy: BALL_START_DY
};

function moveBallByStep() {
    ball.x += ball.dx;
    ball.y += ball.dy;
}

// Użycie
moveBallByStep();

// Zamieniamy na
const ball = {
    x: BALL_START_X,
    y: BALL_START_Y,
    dx: BALL_START_DX,
    dy: BALL_START_DY,
    moveByStep: function () {
        this.x += this.dx;
        this.y += this.dy;
    }
}

// Użycie
ball.moveByStep();

Analogicznie zamienimy funkcje:

  • shouldBounceBallFromTopWall na shouldBounceFromTopWall;
  • shouldBounceBallFromBottomWall na shouldBounceFromBottomWall;
  • bounceBallFromWall na bounceFromWall;
  • bounceBallFromPaddle na bounceFromPaddle;
  • moveBallToStart na moveToStart;
  • ballIsOutsideOnLeft na isOutsideOnLeft;
  • ballIsOutsideOnRight na isOutsideOnRight;
  • isBallOnTheSameHeightAsPaddle na isOnTheSameHeightAsPaddle;
  • shouldBounceFromLeftPaddle na shouldBounceFromLeftPaddle;
  • shouldBounceFromRightPaddle na shouldBounceFromRightPaddle.

Zniknięcie słówka ball z nazw tych wszystkich funkcji nie jest przypadkowe. Od teraz wywołujemy je na obiekcie ball i nie musimy, o tym dodatkowo informować w nazwie.

const ball = {
  x: BALL_START_X,
  y: BALL_START_Y,
  dx: BALL_START_DX,
  dy: BALL_START_DY,
  moveByStep: function () {
    this.x += this.dx;
    this.y += this.dy;
  },
  shouldBounceFromTopWall: function () {
    return this.y < BALL_R && this.dy < 0;
  },
  shouldBounceFromBottomWall: function () {
    return this.y + BALL_R > CANVAS_HEIGHT &&
      this.dy > 0;
  },
  bounceFromWall: function () {
    this.dy = -this.dy;
  },
  bounceFromPaddle: function () {
    this.dx = -this.dx;
  },
  moveToStart: function () {
    this.x = BALL_START_X;
    this.y = BALL_START_Y;
  },
  isOutsideOnLeft: function () {
    return this.x + BALL_R < 0;
  },
  isOutsideOnRight: function () {
    return this.x - BALL_R > CANVAS_WIDTH;
  },
  isOnTheSameHeightAsPaddle: function (paddleY) {
    return isInBetween(
      this.y,
      paddleY,
      paddleY + PADDLE_HEIGHT
    );
  },
  shouldBounceFromLeftPaddle: function () {
    return this.dx < 0 &&
      isInBetween(
        this.x - BALL_R,
        PADDLE_P1_X,
        PADDLE_P1_X + PADDLE_WIDTH
      ) &&
      this.isOnTheSameHeightAsPaddle(p1PaddleY);
  },
  shouldBounceFromRightPaddle: function () {
    return this.dx > 0 &&
      isInBetween(
        this.x + BALL_R,
        PADDLE_P2_X,
        PADDLE_P2_X + PADDLE_WIDTH
      ) &&
      this.isBallOnTheSameHeightAsPaddle(p2PaddleY);
  }
}

Być może zastanawiasz się, co z funkcją moveBall. Teoretycznie można by ją również przenieść do ball, bo w końcu jest z nim silnie powiązana. Modyfikuje ona jednak liczbę punktów i wyraża szerszą logikę zmiany stanu. Z uwagi na to zdecydowałem, że zostawię ją tak jak jest.

function moveBall() {
  if (ball.shouldBounceFromTopWall() ||
      ball.shouldBounceFromBottomWall()) {
    ball.bounceFromWall();
  }
  if (ball.shouldBounceFromLeftPaddle() ||
      ball.shouldBounceFromRightPaddle()) {
    ball.bounceFromPaddle();
  }

  if (ball.isOutsideOnLeft()) {
    ball.moveToStart();
    p2Points++;
  } else if (ball.isOutsideOnRight()) {
    ball.moveToStart();
    p1Points++;
  }

  ball.moveByStep();
}

Gracze i paletki jako obiekty

Zmienne obu graczy to pozycje ich paletek i stan ich punków. Analogicznie do piłeczki, również tutaj moglibyśmy wydzielić dwa obiekty odpowiadające za każdego z graczy.

const p1 = {
  points: 0,
  paddleY: PADDLE_START_Y
};
const p2 = {
  points: 0,
  paddleY: PADDLE_START_Y
};

Jakby się jednak zastanowić to odkryjemy, że obiekty, które są do siebie bardzo podobne, będą miały odpowiadające im wydzielone funkcje dla obu graczy. Dla uproszczenia powinniśmy więc tworzyć te obiekty we wspólnej funkcji. Na razie uniknę użycia klas czy operatora new. Przećwiczmy użycie czystych obiektów w funkcji. Jednak już teraz nazwę ją wielką literą tak jak konstruktor.

function Player() {
  return {
    points: 0,
    paddleY: PADDLE_START_Y
  }
}

const p1 = Player();
const p2 = Player();

Dla porządku wydzielimy również obiekt reprezentujący paletkę.

function Player() {
  return {
    points: 0,
    paddle: {
      y: PADDLE_START_Y
    }
  };
}

const p1 = Player();
const p2 = Player();

Pozycja paletki

Kiedy modyfikowaliśmy pozycję y paletki, upewnialiśmy się, że jest ona poprawna przy użyciu funkcji coerceIn. W programowaniu obiektowym możemy zrobić to jednak łatwiejszym sposobem, poprzez ustawianie tej wartości przy pomocy metody setY, a już ona zajmie się zapewnieniem, czy dana wartość jest poprawna1.

function Player() {
  return {
    points: 0,
    paddle: {
      y: PADDLE_START_Y,
      setY: function (newY) {
        const minPaddleY = 0;
        const maxPaddleY = CANVAS_HEIGHT-PADDLE_HEIGHT;
        this.y = coerceIn(newY, minPaddleY, maxPaddleY);
      }
    }
  }
}

// Użycie

function movePaddles() {
  if (p1Action === UP_ACTION) {
    p1.paddle.setY(p1PaddleY - PADDLE_STEP);
  } else if (p1Action === DOWN_ACTION) {
    p1.paddle.setY(p1PaddleY + PADDLE_STEP);
  }
  if (p2Action === UP_ACTION && p2PaddleY >= 0) {
    p2.paddle.setY(p1PaddleY - PADDLE_STEP);
  } else if (p2Action === DOWN_ACTION) {
    p2.paddle.setY(p1PaddleY + PADDLE_STEP);
  }
}

Zasłonięcie pobierania i ustawiania wartości właściwości poprzez metody kryje się w programowaniu pod nazwą enkapsulacja. Dzięki tej technice możemy kontrolować, jak zmienia się obiekt, poprzez modyfikację ciała jednej funkcji (zamiast wielu zmian wartości). Dla przykładu: gdybyśmy chcieli zmodyfikować zakres dozwolonych wartości y, zmienilibyśmy tylko ciało metody setY.

W tym przypadku istnieją tylko dwie możliwe zmiany wartości: krok w górę lub w dół. Możemy więc je ująć jako osobne metody.

function Player() {
  return {
    points: 0,
    paddle: {
      y: PADDLE_START_Y,
      setY: function (newY) {
        const minPaddleY = 0;
        const maxPaddleY =
          CANVAS_HEIGHT - PADDLE_HEIGHT;
        this.y = coerceIn(newY, minPaddleY, maxPaddleY);
      },
      stepDown: function () {
        this.setY(this.y + PADDLE_STEP);
      },
      stepUp: function () {
        this.setY(this.y - PADDLE_STEP);
      }
    }
  }
}

// Użycie

function movePaddles() {
  if (p1Action === UP_ACTION) {
    p1.paddle.stepUp();
  } else if (p1Action === DOWN_ACTION) {
    p1.paddle.stepDown();
  }
  if (p2Action === UP_ACTION && p2PaddleY >= 0) {
    p2.paddle.stepUp();
  } else if (p2Action === DOWN_ACTION) {
    p2.paddle.stepDown();
  }
}

W powyższym kodzie możesz zauważyć, że sposób, w jaki obsługujemy ruch obu graczy jest niemal identyczny — co również można wydzielić do metody i uwspólnić. Uznam taki zabieg jednak za bardziej kontrowersyjną zmianę, gdyż wymagałoby to umieszczenia akcji gracza w jego obiekcie. Do tej pory organizowaliśmy nasz kod tak, aby oddzielić modyfikację stanu i reagowanie na akcje gracza. W tym momencie łącząc zmienną określającą akcję oraz stan gracza w jednym obiekcie złamalibyśmy tę zasadę. W tym przypadku mamy do czynienia z typowym problemem wśród programistów: gdy kilka ogólnych dobrych praktyk stoi w sprzeczności, wtedy sami musimy wybrać, co cenimy bardziej. Jeśli zdecydujesz się na tę zmianę, oto jak teraz wyglądać będzie: addEventListener, addEventListener, PlayermovePaddles.

// Input
let paused = false;

window.addEventListener('keydown', function (event) {
  let code = event.code;
  if (code === P1_UP_BUTTON) {
    p1.action = UP_ACTION;
  } else if (code === P1_DOWN_BUTTON) {
    p1.action = DOWN_ACTION;
  } else if (code === P2_UP_BUTTON) {
    p2.action = UP_ACTION;
  } else if (code === P2_DOWN_BUTTON) {
    p2.action = DOWN_ACTION;
  } else if (code === PAUSE_BUTTON) {
    paused = !paused;
  }
});

window.addEventListener('keyup', function (event) {
  let code = event.code;
  if ((code === P1_UP_BUTTON &&
        p1.action === UP_ACTION) ||
      (code === P1_DOWN_BUTTON &&
        p1.action === DOWN_ACTION)) {
    p1.action = STOP_ACTION;
  } else if ((code === P2_UP_BUTTON &&
        p2.action === UP_ACTION) ||
      (code === P2_DOWN_BUTTON &&
        p2.action === DOWN_ACTION)) {
    p2.action = STOP_ACTION;
  }
  });
  
// Objects
function Player(paddleX) {
  return {
    points: 0,
    action: STOP_ACTION,
    paddle: {
      y: PADDLE_START_Y,
      setY: function (newY) {
        const maxPaddleY = 0;
        const minPaddleY =
          CANVAS_HEIGHT - PADDLE_HEIGHT;
        this.y = coerceIn(newY, maxPaddleY, minPaddleY);
      },
      stepDown: function () {
        this.setY(this.y + PADDLE_STEP);
      },
      stepUp: function () {
        this.setY(this.y - PADDLE_STEP);
      }
    },
    makeAction: function () {
      if (this.action === UP_ACTION) {
        this.paddle.stepUp();
      } else if (this.action === DOWN_ACTION) {
        this.paddle.stepDown();
      }
    }
  }
}

// State
function movePaddles() {
    p1.makeAction();
    p2.makeAction();
}

Obiektowe rysowanie piłeczki i paletek

Idąc za ciosem, również metody do rysowania możemy przenieść do obiektów. Dzięki temu nie będziemy musieli przekazywać obiektów jako argumenty. Zacznijmy od rysowania piłeczki — aktualnie używamy do tego funkcji drawBall.

function drawBall(x, y) {
  drawCircle(x, y, BALL_R);
}

// Użycie
drawBall(ball.x, ball.y);

Możemy ją wciągnąć do obiektu Ball.

function Ball() {
  return {
    ...
    draw: function () {
      drawCircle(this.x, this.y, BALL_R);
    }
  }
}

// Użycie
ball.draw();

Zauważ, że dzięki temu zabiegowi nowa funkcja nie ma żadnych argumentów. Nie wymaga też słówka "ball" w nazwie.

Analogicznie, możemy przenieść rysowanie punktów i paletki do obiektów reprezentujących odpowiednio graczy i paletki. Zarówno jednak paletka, jak i punkty są wyświetlane w różnych miejscach dla różnych graczy. Informacje o nich przekażemy przy tworzeniu obiektów.

function Player(paddleX, boardX) {
  return {
    points: 0,
    boardX: boardX,
    action: STOP_ACTION,
    paddle: {
      x: paddleX,
      y: PADDLE_START_Y,
      ...
    },
  ...
  }
}

const p1 = Player(PADDLE_P1_X, BOARD_P1_X);
const p2 = Player(PADDLE_P2_X, BOARD_P2_X);

Dzięki tym wartościom możemy bezpośrednio napisać funkcje do rysowania punktów i paletek.

function Player(paddleX, boardX) {
  return {
    points: 0,
    boardX: boardX,
    action: STOP_ACTION,
    paddle: {
      x: paddleX,
      y: PADDLE_START_Y,
      ...,
      draw: function () {
        ctx.fillRect(this.x, this.y,
          PADDLE_WIDTH, PADDLE_HEIGHT);
      }
    },
    ...,
    drawPoints: function () {
      drawPoints(this.points.toString(), this.boardX);
    }
  }
}

function drawState() {
  clearCanvas();
  p1.drawPoints();
  p2.drawPoints();
  ball.draw();
  p1.paddle.draw();
  p2.paddle.draw();
}

Moglibyśmy pójść nawet o krok dalej i stworzyć pojedynczą funkcję draw do rysowania wszystkiego w obiektach reprezentujących graczy.

function Player(paddleX, boardX) {
  return {
    ...,
    draw: function () {
      this.drawPoints();
      this.paddle.draw();
    }
  }
}

function drawState() {
  clearCanvas();
  ball.draw();
  p1.draw();
  p2.draw();
}

W tym momencie ukończyliśmy podstawowe porządki w kodzie. Kod na tym etapie znajdziesz pod linkiem:

https://github.com/MarcinMoskala/pong/blob/master/5_oop.html

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

Aktualny stan gry w CodePen.

Zanim jednak skończymy, nadajmy mu jeszcze trochę... klasy.

Użycie klas

Zamiast funkcji do tworzenia obiektów, możemy wykorzystać klasy. Jest to relatywnie niewielka zmiana, właściwie porządkowa — zamieniamy funkcje tworzące obiekty na klasy oraz tworzymy je przy użyciu operatora new.

class Ball {
  constructor() {
    this.x = BALL_START_X;
    this.y = BALL_START_Y;
    this.dx = BALL_START_DX;
    this.dy = BALL_START_DY;
  }

  moveByStep() {
    this.x += this.dx;
    this.y += this.dy;
  }

  shouldBounceFromTopWall() {
    return this.y < BALL_R && this.dy < 0;
  }

  shouldBounceFromBottomWall() {
    return this.y + BALL_R > CANVAS_HEIGHT &&
      this.dy > 0;
  }

  bounceFromWall() {
    this.dy = -this.dy;
  }

  bounceFromPaddle() {
    this.dx = -this.dx;
  }

  moveToStart() {
    this.x = BALL_START_X;
    this.y = BALL_START_Y;
  }

  isOutsideOnLeft() {
    return this.x + BALL_R < 0;
  }

  isOutsideOnRight() {
    return this.x - BALL_R > CANVAS_WIDTH;
  }

  isOnTheSameHeightAsPaddle(paddleY) {
    return isInBetween(this.y, paddleY,
      paddleY + PADDLE_HEIGHT);
  }

  shouldBounceFromLeftPaddle(paddle) {
    return this.dx < 0 &&
      isInBetween(
        this.x - BALL_R,
        PADDLE_P1_X,
        PADDLE_P1_X + PADDLE_WIDTH
      ) &&
      this.isOnTheSameHeightAsPaddle(paddle.y);
  }

  shouldBounceFromRightPaddle(paddle) {
    return this.dx > 0 &&
      isInBetween(
        this.x + BALL_R,
        PADDLE_P2_X,
        PADDLE_P2_X + PADDLE_WIDTH
      ) &&
      this.isOnTheSameHeightAsPaddle(paddle.y);
  }

  draw() {
    drawCircle(this.x, this.y, BALL_R);
  }
}

class Paddle {
  constructor(paddleX) {
    this.x = paddleX;
    this.y = PADDLE_START_Y;
  }

  setY(newY) {
    const maxPaddleY = 0;
    const minPaddleY = CANVAS_HEIGHT - PADDLE_HEIGHT;
    this.y = coerceIn(newY, maxPaddleY, minPaddleY);
  }

  stepDown() {
    this.setY(this.y + PADDLE_STEP);
  }

  stepUp() {
    this.setY(this.y - PADDLE_STEP);
  }

  draw() {
    ctx.fillRect(this.x, this.y,
      PADDLE_WIDTH, PADDLE_HEIGHT);
  }
}

class Player {
  constructor(paddleX, boardX) {
    this.points = 0;
    this.boardX = boardX;
    this.action = STOP_ACTION;
    this.paddle = new Paddle(paddleX);
  }

  makeAction() {
    if (this.action === UP_ACTION) {
      this.paddle.stepUp();
    } else if (this.action === DOWN_ACTION) {
      this.paddle.stepDown();
    }
  }

  drawPoints() {
    drawPoints(this.points.toString(), this.boardX);
  }

  draw() {
    this.drawPoints();
    this.paddle.draw();
  }
}

// State
const ball = new Ball();
const p1 = new Player(PADDLE_P1_X, BOARD_P1_X);
const p2 = new Player(PADDLE_P2_X, BOARD_P2_X);

Kod na tym etapie znajdziesz pod linkiem:

https://github.com/MarcinMoskala/pong/blob/master/6_class.html

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

Zakończenie

Gratuluję. Właśnie ukończyliśmy ostatnią lekcję tej książki. Było naprawdę dużo wiedzy, przykładów, a później cały projekt napisany od początku do końca. Mam nadzieję, że była to dla Ciebie dobra zabawa. Jeśli tak, to pocieszę Cię, że to dopiero początek na ścieżce nauki programowania. W dalszej części książki odpowiem na pytanie, jak tę drogę kontynuować.

1:

We współczesnym JavaScript, zasłonięcia właściwości przez metody można dokonać przy użyciu składni set oraz get.