Thử Thách Code Game Bằng C++ trong 24h (phần 2)

Thử Thách Code Game Bằng C++ trong 24h (phần 2)

Xin chào các bạn, mình đã quay trở lại cùng hướng dẫn thực hành làm game với Cocos2dx tròng vòng 24 giờ. Như đã nói ở phần 1, trong series bài viết này mình sẽ hướng dẫn cách làm game Pikachu trên mobile.

1. Game Pikachu Onet Connect

Game Pikachu Onet Connect là một game có lẽ đã quá quen thuộc với đại đa số các bạn trẻ 8x, 9x Việt Nam. Đây là tựa game đã quá phổ biến, hầu như ai cũng đã từng chơi qua hoặc ít nhất là biết đến.

Luật chơi rất đơn giản: Cho một bảng gồm các ô vuông, trong mỗi ô vuông là một loại pokemon (như hình trên). Nối tất cả các cặp pokemon cùng loại trong thời gian quy định để chiến thắng. Nhưng hai pokemon chỉ có thể nối được với nhau nếu đường nối không có pokemon nào ngăn ở giữa và không được quá 2 lần gấp khúc.

2. Tạo bảng

2.1. Mô hình bảng

Đầu tiên, mình sẽ tạo ra class Board để tượng trưng cho bảng pokemon. Lớp này sẽ có các thuộc tính như số hàng, số cột, chỉ số của loại pokemon ở mỗi ô trong bảng (mỗi loại pokemon tương ứng với một số nguyên dương, nếu ô ko có pokemon thì chỉ số là -1).

Board.h:

#pragma once
#include <vector>

class Board
{
	int n_rows, n_columns;	// number of rows and columns

	std::vector<std::vector<int>> _pokemons;	// pokemons

public:
	
	Board(int n_rows, int n_columns, int n_types, std::vector<int> count);

	int getNRows();

	int getNColumns();

	void addPokemon(int x, int y, int type);

	int getPokemon(int x, int y);

	void removePokemon(int x, int y);

};
  • n_rows là số hàng của bảng.
  • n_columns là số cột của bảng.
  • _pokemons là một mảng 2 chiều n_rows x n_columns lưu chỉ số loại pokemon ở từng ô của bảng.

Trong file Board.cpp chứa định nghĩa các hàm:

  • Board(int n_rows, int n_columns, int n_types, std::vector<int> count) là hàm khởi tạo của class Board. Hàm này khởi tạo ngẫu nhiên một bảng pokemon gồm n_rows hàng, n_columns cột, n_types là số loại pokemon khác nhau, mảng count lưu số lượng pokemon mỗi loại. Sở dĩ mình có mảng count này vì nhờ nó mình có thể khiến cho số lượng pokemon mỗi loại là chẵn (vì nếu lẻ thì sẽ luôn thừa ít nhất 1 pokemon và game không thể kết thúc). Hơn nữa nếu không điều khiển được số lượng pokemon mỗi loại thì có thể dẫn đến có quá nhiều pokemon cũng loại và game sẽ quá dễ :D
  • Hàm getNRows()getNColumns() trả về số hàng và số cột của bảng.
  • Hàm addPokemon(int x, int y, int type) thêm pokemon thuộc loại type vào ô ở hàng x cột y.
  • Hàm getPokemon(int x, int y) trả về loại pokemon ở ô hàng x cột y.
  • Hàm removePokemon(int x, int y) xóa pokemon ở ô hàng x cột y (gán loại pokemon ở ô này = -1). 

Board.cpp:

#include "Board.h"
#include <map>

Board::Board(int n_rows, int n_columns, int n_types, std::vector<int> count):
	n_rows(n_rows), n_columns(n_columns),
	_pokemons(std::vector<std::vector<int>>(n_rows, std::vector<int>(n_columns, -1)))
{
	std::map<int, int> countType; // countType[x] counts number of type x
	for (int i = 0; i < n_rows; ++i) {
		for (int j = 0; j < n_columns; ++j) {
			int type;
			do {
				type = rand() % n_types;
			} while (countType[type] >= count[type]);
			countType[type] += 1;
			addPokemon(i, j, type + 1);
		}
	}
}

int Board::getNRows()
{
	return n_rows;
}

int Board::getNColumns()
{
	return n_columns;
}

void Board::addPokemon(int x, int y, int type)
{
	_pokemons[x][y] = type;
}

int Board::getPokemon(int x, int y)
{
	return _pokemons[x][y];
}

void Board::removePokemon(int x, int y)
{
	_pokemons[x][y] = -1;
}

2.2. Vẽ bảng

Như ở phần 1 mình đã nói qua, bản chất cửa sổ màn hình game là 1 Scene, và những đối tượng nằm trong Scene là các Node. Nên để vẽ được bảng ra màn hình, mình sẽ phải tạo ra các Node thể hiện bảng.

Mình sẽ tạo lớp BoardView là một Layer hình chữ nhật biểu diễn hình ảnh của bảng. Sau đó sẽ thêm các Sprite biểu diễn hình ảnh của từng ô pokemon vào BoardView.

Giống như Scene, Sprite, Menu hay Label, Layer cũng là một class thừa kế từ class Node. Nó có đầy đủ các thuộc tính và phương thức của Node, chỉ khác là có thêm một số phương thức để xử lý sự kiện.

Lớp BoardView của mình như sau:

BoardView.h:

#pragma once
#include <cocos2d.h>
#include <Board.h>

USING_NS_CC;

class BoardView : public Layer
{
	Board* board;

	float squareSize, width, height;

	std::vector<std::vector<Sprite*>> pokemons;

public:

	static Layer* createBoardView(Board* board);

	void showBoard();

	Sprite* addPokemon(int row, int column, int type);

	Vec2 positionOf(int row, int column);

	std::pair<int, int> findRowAndColumnOfSprite(Node* node);

	bool removePokemon(int row, int column);

	CREATE_FUNC(BoardView);
};

Các thuộc tính của BoardView gồm:

  • board: mô hình của bảng (class Board)
  • squareSize: độ dài cạnh một ô vuông trong bảng (đo bằng pixel).
  • width: độ rộng của bảng (đo bằng pixel).
  • height: độ cao của bảng (đo bằng pixel).
  • pokemons: mảng hai chiều lưu ảnh của các ô pokemon, mỗi ô là một Sprite (ảnh động)

Các hàm, phương thức của BoardView gồm:

  • createBoardView(Board& board) là hàm khởi tạo và trả về một đối tượng mới của class BoardView
  • Sprite* addPokemon(int row, int column, int type) là hàm trợ giúp cho hàm khởi tạo, hàm này tạo ra một Sprite thể hiện ảnh pokemon loại type ở vị trí hàng row, cột column. Hàm trả về con trỏ tới Sprite vừa tạo đó. (Link ảnh mình sử dụng mình sẽ để ở cuối bài viết)
  • Vec2 positionOf(int row, int column) là hàm trợ giúp cho hàm addPokemon, hàm này tính tọa độ vị trí đặt pokemon hàng row, cột column trên màn hình. Hàm trả về một Vector 2D là tọa độ của của điểm chính giữa Sprite, gốc Vector tính từ điểm tận cùng trái dưới của BoardView.
  • std::pair<int, int> findRowAndColumnOfSprite(Node* node) là hàm ngược của hàm positionOf, nhận vào một Sprite* (ép kiểu Node*), trả về cặp chỉ số hàng và cột của Sprite* đó.

BoardView.cpp:

#include "BoardView.h"
#include "algorithm"

Layer* BoardView::createBoardView(Board* board)
{
	auto boardView = BoardView::create();
	boardView->board = board;
	boardView->showBoard();
	return boardView;
}

void BoardView::showBoard()
{
	auto visibleSize = Director::getInstance()->getVisibleSize();
	squareSize = visibleSize.width / (board->getNColumns() + 2);
	width = squareSize * board->getNColumns();
	height = squareSize * board->getNRows();
	setContentSize({ width, height });

	pokemons.resize(board->getNRows());
	for (int i = 0; i < board->getNRows(); ++i) {
		pokemons[i].resize(board->getNColumns());
		for (int j = 0; j < board->getNColumns(); ++j) {
			pokemons[i][j] = addPokemon(i, j, board->getPokemon(i, j));
			addChild(pokemons[i][j]);
		}
	}
}

Sprite* BoardView::addPokemon(int row, int column, int type)
{
	auto pokemon = Sprite::create("pokemons/" + std::to_string(type) + ".png");
	pokemon->setScaleX(squareSize / pokemon->getContentSize().width);
	pokemon->setScaleY(squareSize / pokemon->getContentSize().height);
	Vec2 position = positionOf(row, column);
	pokemon->setPosition(position);
	return pokemon;
}

Vec2 BoardView::positionOf(int row, int column)
{
	return Vec2(column * squareSize + squareSize / 2, height - row * squareSize - squareSize / 2);
}

std::pair<int, int> BoardView::findRowAndColumnOfSprite(Node* node)
{
	for (int i = 0; i < board->getNRows(); ++i) {
		for (int j = 0; j < board->getNColumns(); ++j) {
			if (pokemons[i][j] == node) {
				return { i, j };
			}
		}
	}
	return { -1, -1 };
}

bool BoardView::removePokemon(int row, int column)
{
	if (pokemons[row][column] == nullptr) return false;
	board->removePokemon(row, column);
	pokemons[row][column] = nullptr;
	return true;
}

Design bảng như nào là tùy ý các bạn, nhưng cho dù làm thế nào thì có lẽ các bạn vẫn sẽ cần sử dụng một số hàm cơ bản của sau đây:

  • Director::getInstance()->getVisibleSize() : Lấy kích thước màn hình Scene hiện tại. Trả về giá trị kiểu Size {width, height}
  • node->getContentSize(): Lấy kích thước node. Trả về giá trị kiểu Size
  • node->setContentSize(width, height): Đặt kích thước cho node.
  • pokemon->setPosition(x, y): Đặt Sprite pokemon ở vị trí x, y trong hệ tọa độ của node cha - BoardView.
    • Hệ tọa độ của BoardView (hay bất kì một Node nào, kể cả Scene) có gốc là tính từ điểm tận cùng góc trái dưới của nó. 
    • Lưu ý là hệ tọa độ của BoardView khác hệ tọa độ của GameScene.
    • Vị trí của pokemon được tính ở điểm AnchorPoint.
    • AnchorPoint của Menu, Label hoặc Sprite là điểm chính giữa, AnchorPoint của Scene, Layer là điểm góc trái dưới.
    • Tức là điểm chính giữa của pokemon sẽ nằm ở (x,y) trên hệ tọa độ tính từ gốc ở góc trái dưới BoardView
      • Có thể thay đổi điểm AnchorPoint bằng hàm node->setAnchorPoint()
  • pokemon->getPosition(): Lấy tọa độ của pokemon trong hệ tọa độ của BoardView.
  • pokemon->setScaleX(c), setScaleY(c) hoặc setScale(c): Nhân chiều rộng, chiều cao, hoặc cả 2 chiều của pokemon với một số thực c.

Khi đã xây dựng xong BoardView rồi thì mình sẽ thêm nó vào GameScene:

GameScene.cpp:

...

bool GameScene::init()
{
        ...

	//Show Board
	showBoard();

	return true;
}

Layer* GameScene::showBoard()
{
	std::vector<int> count(16, 4);
	Board* board = new Board(8, 8, 16, count);
	auto boardView = BoardView::createBoardView(board);
	this->addChild(boardView, 1);
	float x = (Director::getInstance()->getVisibleSize().width - boardView->getContentSize().width) / 2;
	float y = (Director::getInstance()->getVisibleSize().height - boardView->getContentSize().height) / 2;
	boardView->setPosition({x, y});
	return boardView;
}

Ở đây mình tạo một bảng 8x8, tổng cộng là 64 ô pokemon, có 16 loại pokemon khác nhau, mỗi loại xuất hiện 4 lần trong bảng. Bạn hãy thử thay đổi các thông số để tạo ra bảng khác xem sao.

Nhắc lại là các công thức tính toán kích thước, tọa độ hoàn toàn dựa trên thiết kế của mình. Bạn hoàn toàn có thể thiết kế bảng một cách hoàn toàn khác chỉ cần sử dụng những hàm mình kể ở phần trên :D

Kết quả bạn sẽ thu được khi bấm vào nút PLAY:

Bạn có thể thấy là 2 lần bấm PLAY tạo ra 2 bảng random khác nhau.

(Nếu bạn muốn bỏ trạng thái GL verts/calls ở góc trái dưới: Trong file AppDelegate.cpp đổi director->setDisplayStats(true); thành director->setDisplayStats(false);)

3. Xử lý sự kiện

3.1. Event Dispatch

Trong cocos2d-x, việc xử lý các sự kiện như click chuột, bấm điện thoại, gõ phím, ... được thực hiện xoay quanh cơ chế Event Dispatch - dịch nôm na là truyền tải sự kiện.

Khi một sự kiện xảy ra, sự kiện này sẽ được lưu trữ, biểu diễn dưới dạng Event, đây là một đối tượng chứa thông tin của sự kiện. Sau đó, Event sẽ được Event Dispatcher truyền đi đến từng Event Listener theo một thứ tự nhất định và thực hiện nhiệm vụ của Event Listener đó. Tại mỗi thời điểm, một Event Listener có thể quyết định tiếp tục để Event Dispatcher truyền Event cho những listener tiếp theo, hoặc nuốt Event (swallow) và kết thúc hành trình của Event tại đó.

Thứ tự của các listener có thể được quy định bằng 2 cách:

  • Fixed Priority: Mỗi listener được gán một giá trị ưu tiên là một số nguyên. Listener giá trị ưu tiên thấp hơn sẽ nhận được Event trước.
  • Scene Graph Priority: Mỗi listener được gắn với một node trong Scene Graph (phần 1 mình đã nói qua về Scene Graph). Node nào có thứ tự được vẽ sau (z-order cao) trong Scene Graph sẽ nhận được Event trước.

Trong cocos2d-x có nhiều loại EventListener như TouchEvent, KeyboardEvent, AccelerometerEvent, MouseEvent và cả CustomEvent. Trong phần này mình sẽ sử dụng TouchEventOneByOne và SceneGraphPriority để xử lý sự kiện.

3.2. Cài đặt xử lý sự kiện

TouchEventOneByOne là sự kiện chạm vào màn hình. Mình sẽ cài đặt một listener thuộc loại này với mỗi ô pokemon trong bảng.

BoardView.cpp:

...

Sprite* BoardView::addPokemon(int row, int column, int type)
{
	auto pokemon = Sprite::create("pokemons/" + std::to_string(type) + ".png");
	pokemon->setScaleX(squareSize / pokemon->getContentSize().width);
	pokemon->setScaleY(squareSize / pokemon->getContentSize().height);
	Vec2 position = positionOf(row, column);
	pokemon->setPosition(position);

	//EventListener
	auto listener = EventListenerTouchOneByOne::create();
	listener->setSwallowTouches(true);
	listener->onTouchBegan = CC_CALLBACK_2(BoardView::onTouchPokemon, this);
	_eventDispatcher->addEventListenerWithSceneGraphPriority(listener, pokemon);

	return pokemon;
}

bool BoardView::onTouchPokemon(Touch* touch, Event* event) {
	auto touchLocation = touch->getLocation() - this->getPosition();
	auto target = event->getCurrentTarget();
	if (target->getBoundingBox().containsPoint(touchLocation)) {
		auto p = findRowAndColumnOfSprite(target);
		if (board->selectPokemon(p.first, p.second)) {
			removePokemon(board->_x, board->_y);
			removePokemon(p.first, p.second);
			board->_x = board->_y = -1;
			CCLOG("CURRENTLY SELECTED: row = %d , column = %d", -1, -1);
		}
		else {
			board->_x = p.first;
			board->_y = p.second;
			CCLOG("CURRENTLY SELECTED: row = %d , column = %d", p.first, p.second);
		}
		return true;
	}
	return false;
}

...
  • auto listener = EventListenerTouchOneByOne::create();
    • Khởi tạo listener
  • listener->setSwallowTouches(true);
    • Khi SwallowTouches = true, sự kiện Touch sẽ được nuốt khi hàm onTouchBegan trả về true.
    • Default SwallowTouches = false nên nếu ko gọi hàm này sự kiện sẽ không bao giờ bị nuốt.
  • listener->onTouchBegan = CC_CALLBACK_2(BoardView::onTouchPokemon, this);
    • onTouchBegan, onTouchCancelled, onTouchMoved, onTouchEnded là các hàm xử lý sự kiện.
    • onTouchBegan được thực thi khi sự kiện Touch vừa xảy ra. Đây là một hàm callback có 2 tham số, thuộc loại Touch*Event*. Hàm này trả về giá trị kiểu bool thể hiện có nuốt sự kiện hay không (nếu setSwallowTouches(true)).
  • _eventDispatcher->addEventListenerWithSceneGraphPriority(listener, pokemon);
    • _eventDispatcher là đối tượng có nhiệm vụ vận chuyển thông tin sự kiện.
    • Trong trường hợp này listener được gắn với node pokemon, độ ưu tiên của listener phụ thuộc vào vị trí của pokemon trong scene graph.

Giải thích hàm bool BoardView::onTouchPokemon(Touch* touch, Event* event):

  • Mỗi khi người dùng chạm vào màn hình ở bất cứ vị trí nào (chạm vào một pokemon, chạm vào background), event này sẽ được vận chuyển qua tất cả các listener theo thứ tự Scene Graph (nếu không bị nuốt).
  • Thế nên, vì ở mỗi ô trong bảng đều được gắn listener, nên ta sẽ phải kiểm tra xem người dùng đã chạm vào đâu, chạm vào pokemon ở ô nào.
  • auto touchLocation = touch->getLocation() - this->getPosition();
    • touch->getLocation() là tọa độ của điểm chạm trong hệ tọa độ của GameScene.
    • touchLocation là tọa độ của điểm chạm trong hệ tọa độ của BoardView. (nhắc lại, 2 hệ tọa độ này khác nhau)
  • auto target = event->getCurrentTarget();
    • target là mục tiêu của listener này, hay nói cách khác là Sprite pokemon mà được gắn với listener.
  • if (target->getBoundingBox().containsPoint(touchLocation))
    • target->getBoundingBox() trả về một hình chữ nhật bao quanh target (chính là viền của Sprite)
    • .containsPoint(touchLocation) là hàm kiểm tra xem hình chữ nhật có chứa một điểm không
    • Nếu Sprite chứa touchLocation, tức là điểm được chạm nằm trong một pokemon
  • Nếu chứa thì thực thi lệnh và nuốt sự kiện (return true).
  • Nếu không chứa thì return false để chuyển sự kiện cho pokemon tiếp theo.
  • Hàm board->selectPokemon(p.first, p.second) kiểm tra xem pokemon được chọn có phải là nước đi hợp lệ để xóa pokemon hay không.

Cụ thể, class Board được thay đổi như sau:

Board.h:

#pragma once
#include <vector>

class Board
{
	...

public:

	int _x = -1, _y = -1;	// selected pokemon row and column
	
        ...

	bool selectPokemon(int x, int y);

	bool canConnect(int _x, int _y, int x, int y);

	std::vector<std::pair<int, int>> findPath(int _x, int _y, int x, int y);
};

Trong đó:

  • _x, _y là chỉ số hàng, cột của ô đã được chọn trước đó (nếu không có ô nào được chọn thì _x = _y = -1)
  • bool selectPokemon(int x, int y): kiểm tra xem x, y có phải là một cặp pokemon hợp lệ với _x, _y không.
bool Board::selectPokemon(int x, int y)
{
	if (_x == -1 && _y == -1 || _pokemons[x][y] != _pokemons[_x][_y] || !canConnect(_x, _y, x, y)) {
		return false;
	}
	return true;
}
  • bool canConnect(int _x, int _y, int x, int y): Hàm trợ giúp cho hàm selectPokemon, kiểm tra xem có đường nối giữa 2 pokemon mà không quá 3 đoạn hay không.
bool Board::canConnect(int _x, int _y, int x, int y)
{
	auto path = findPath(_x, _y, x, y);
	return path.size() >= 2 && path.size() <= 4;
}
  • std::vector<std::pair<int, int>> findPath(int _x, int _y, int x, int y): Hàm trợ giúp cho canConnect, trả về các ô trên đường ngắn nhất từ ô (_x, _y) đến ô (x, y) (tính cả 2 đầu mút nên điều kiện ở hàm canConnectpath.size() >= 2 && path.size() <= 4).
    • Hàm tìm đường này mình cài đặt bằng thuật toán tìm đường Breadth-first Search.
std::vector<std::pair<int, int>> Board::findPath(int _x, int _y, int x, int y)
{
	//INIT Graph
	std::vector<std::vector<int>> e(n_rows + 2, std::vector<int>(n_columns + 2, 0));
	for (int i = 0; i < n_rows; ++i)
	{
		for (int j = 0; j < n_columns; ++j)
		{
			e[i + 1][j + 1] = _pokemons[i][j] != -1;
		}
	}
	std::pair<int, int> s = { _x + 1, _y + 1 };
	std::pair<int, int> t = { x + 1, y + 1 };

	//BFS
	const int dx[4] = { -1, 0, 1, 0 };
	const int dy[4] = { 0, 1, 0, -1 };
	std::deque<std::pair<int, int>> q;
	std::vector<std::vector<std::pair<int, int>>> trace(e.size(), std::vector<std::pair<int, int>>(e[0].size(), std::make_pair(-1, -1)));
	q.push_back(t);
	trace[t.first][t.second] = std::make_pair(-2, -2);
	e[s.first][s.second] = 0;
	e[t.first][t.second] = 0;
	while (!q.empty()) {
		auto u = q.front();
		q.pop_front();
		if (u == s) break;
		for (int i = 0; i < 4; ++i) {
			int x = u.first + dx[i];
			int y = u.second + dy[i];
			while (x >= 0 && x < e.size() && y >= 0 && y < e[0].size() && e[x][y] == 0) {
				if (trace[x][y].first == -1) {
					trace[x][y] = u;
					q.push_back({ x, y });
				}
				x += dx[i];
				y += dy[i];
			}
		}
	}

	//trace back
	std::vector<std::pair<int, int>> res;
	if (trace[s.first][s.second].first != -1) {
		while (s.first != -2) {
			res.push_back({ s.first - 1, s.second - 1 });
			s = trace[s.first][s.second];
		}
	}
	return res;
}

Và đây là kết quả:

4. Kết luận

Vậy là về cơ bản game chúng ta đã xong phần chính. Sau bài này mình nghĩ các bạn đã có thể customize bảng pokemon của riêng mình (không phải hình chữ nhật nữa chẳng hạn :D).

Ở phần tiếp theo, mình sẽ tiếp tục hoàn thiện game, thêm thanh thời gian, thêm các hiệu ứng hình ảnh, âm thanh, ... Các bạn cùng đón đọc nhé.

Tham khảo:

- Phần 1: https://codelearn.io/blog/view/huong-dan-lam-game-bang-cocos2d-x-phan-1
- Code của mình và hình ảnh mình sử dụng: https://github.com/s34vv1nd/Cocos2dx-Tutorials