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
naball.x
,ballY
naball.y
,ballDX
naball.dx
,ballDY
naball.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
nashouldBounceFromTopWall
;shouldBounceBallFromBottomWall
nashouldBounceFromBottomWall
;bounceBallFromWall
nabounceFromWall
;bounceBallFromPaddle
nabounceFromPaddle
;moveBallToStart
namoveToStart
;ballIsOutsideOnLeft
naisOutsideOnLeft
;ballIsOutsideOnRight
naisOutsideOnRight
;isBallOnTheSameHeightAsPaddle
naisOnTheSameHeightAsPaddle
;shouldBounceFromLeftPaddle
nashouldBounceFromLeftPaddle
;shouldBounceFromRightPaddle
nashouldBounceFromRightPaddle
.
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
, Player
i movePaddles
.
// 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:
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:
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ć.