
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ềun_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 classBoard
. Hàm này khởi tạo ngẫu nhiên một bảng pokemon gồmn_rows
hàng,n_columns
cột,n_types
là số loại pokemon khác nhau, mảngcount
lưu số lượng pokemon mỗi loại. Sở dĩ mình có mảngcount
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ễ 😀- Hàm
getNRows()
và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ạitype
vào ô ở hàngx
cộty
. - Hàm
getPokemon(int x, int y)
trả về loại pokemon ở ô hàngx
cộty
. - Hàm
removePokemon(int x, int y)
xóa pokemon ở ô hàngx
cộty
(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ộtSprite
(ả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ủaclass 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ộtSprite
thể hiện ảnh pokemon loạitype
ở vị trí hàngrow
, cộtcolumn
. Hàm trả về con trỏ tớiSprite
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àmaddPokemon
, hàm này tính tọa độ vị trí đặt pokemon hàngrow
, cộtcolumn
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ữaSprite
, gốc Vector tính từ điểm tận cùng trái dưới củaBoardView
.std::pair<int, int> findRowAndColumnOfSprite(Node* node)
là hàm ngược của hàmpositionOf
, nhận vào mộtSprite*
(ép kiểuNode*
), trả về cặp chỉ số hàng và cột củaSprite*
đó.
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ểuSize {width, height}
node->getContentSize()
: Lấy kích thướcnode
. Trả về giá trị kiểuSize
node->setContentSize(width, height)
: Đặt kích thước chonode
.pokemon->setPosition(x, y)
: ĐặtSprite 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ủaGameScene
. - Vị trí của
pokemon
được tính ở điểmAnchorPoint
. AnchorPoint
củaMenu
,Label
hoặcSprite
là điểm chính giữa,AnchorPoint
củaScene
,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ướiBoardView
- Có thể thay đổi điểm
AnchorPoint
bằng hàmnode->setAnchorPoint()
- Có thể thay đổi điểm
- Hệ tọa độ của
pokemon->getPosition()
: Lấy tọa độ củapokemon
trong hệ tọa độ củaBoardView
.pokemon->setScaleX(c)
,setScaleY(c)
hoặcsetScale(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ựcc
.
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 8×8, 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 😀
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àmonTouchBegan
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.
- Khi
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ạiTouch*
vàEvent*
. Hàm này trả về giá trị kiểubool
thể hiện có nuốt sự kiện hay không (nếusetSwallowTouches(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ớinode pokemon
, độ ưu tiên củalistener
phụ thuộc vào vị trí củapokemon
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ủaGameScene
.touchLocation
là tọa độ của điểm chạm trong hệ tọa độ củaBoardView
. (nhắc lại, 2 hệ tọa độ này khác nhau)
auto target = event->getCurrentTarget();
target
là mục tiêu củalistener
này, hay nói cách khác làSprite pokemon
mà được gắn vớilistener
.
if (target->getBoundingBox().containsPoint(touchLocation))
target->getBoundingBox()
trả về một hình chữ nhật bao quanhtarget
(chính là viền củaSprite
).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ứatouchLocation
, 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 xemx, 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àmselectPokemon
, 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 chocanConnect
, 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àmcanConnect
làpath.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
Post Comment