
Làm Game Tetris Với C++ Siêu Đơn Giản (Phần 2)
Chào mừng các bạn đã quay trở lại với Series “Làm Game Tetris Với C++ Siêu Đơn Giản”. Ở bài viết trước, mình đã sơ lược về game Tetris và hướng dẫn các bạn xây dựng giao diện của game. Bài viết này, mình sẽ cùng các bạn xây dựng gameplay.
Các bạn có thể xem source code bài trước tại đây.
Còn bây giờ, “Bắt đầu thôi !”
1. Tạo Tetromino
Mỗi khối Tetromino nằm trong một ma trận 4 * 4
.
Nói là 4 * 4
, nhưng vì một ô ký tự trong Console của chiều cao gấp đôi chiều rộng, nên cần kích thước chính xác là 8 * 4
.
Sử dụng wstring
để lưu “phác thảo” của khối Tetromino:
L"....XX.."
L"....XX.."
L"..XXXX.."
L"........";
4 dòng trên, toàn bộ ký tự chỉ lưu vào một 1 wstring
duy nhất, chứ không phải 4. Các bạn hoàn toàn có thể viết thành:
L"....XX......XX....XXXX..........";
Tuy hơi khó để hình dung hình dạng của khối, nhưng nó ngắn ngọn.
Tất cả có 7 loại Tetromino. Mỗi loại khi xoay 90, 180, 270 độ theo chiều kim đồng hồ sẽ tạo ra thêm 3 khối nữa. Vậy ta cần 1 vector<vector<wstring>>
để lưu trữ toàn bộ các khối:
const vector<vector<wstring>> tetromino = {
// I
{
L"....XX......XX......XX......XX..", // 0 deg
L"................XXXXXXXX........", // 90 deg
L"..XX......XX......XX......XX....", // 180 deg
L"........XXXXXXXX................" // 270 deg
},
// J
{
L"....XX......XX....XXXX..........",
L"..........XX......XXXXXX........",
L"..........XXXX....XX......XX....",
L"........XXXXXX......XX.........."
},
// L
{
L"..XX......XX......XXXX..........",
L"..........XXXXXX..XX............",
L"..........XXXX......XX......XX..",
L"............XX..XXXXXX.........."
},
// O
{
L"..........XXXX....XXXX..........",
L"..........XXXX....XXXX..........",
L"..........XXXX....XXXX..........",
L"..........XXXX....XXXX.........."
},
// S
{
L"..XX......XXXX......XX..........",
L"............XXXX..XXXX..........",
L"..........XX......XXXX......XX..",
L"..........XXXX..XXXX............"
},
// T
{
L"..XX......XXXX....XX............",
L"..........XXXXXX....XX..........",
L"............XX....XXXX......XX..",
L"..........XX....XXXXXX.........."
},
// Z
{
L"....XX....XXXX....XX............",
L"..........XXXX......XXXX........",
L"............XX....XXXX....XX....",
L"........XXXX......XXXX.........."
}
};
2. Hiển thị Tetromino
Khối Tetromino hiển thị ra màn hình sẽ có hình dạng và màu sắc như hình bên trên phần 1.
Kiểm tra trong “bản phác thảo” của khối Tetromino, nếu không phải là '.'
thì gán vào pBuffer
tại vị trí tương ứng ký tự '▓'
. Còn về màu sắc, các bạn có thể thấy Game Board của chúng ra sử dụng 2 màu nền, nên set màu cho khối sẽ hơi dài dòng, công thức là background_color * 16 + character_color
.
int nCurrentPiece = 4;
int nCurrentRotation = 0;
int nCurrentX = nBoardWidth / 2 - 4;
int nCurrentY = 0;
for (int i = 0; i < 8; i++)
{
for (int j = 0; j < 4; j++)
{
if (tetromino.at(nCurrentPiece).at(nCurrentRotation).at(j * 8 + i) != L'.' && nCurrentY + j >= 0)
{
if ((nCurrentY + j) % 2 == 1)
{
if ((nCurrentX + i) % 4 == 1 || (nCurrentX + i) % 4 == 2)
{
pColor[(nCurrentY + j) * nScreenWidth + (nCurrentX + i)] = 8 * 16 + nCurrentPiece;
}
else
{
pColor[(nCurrentY + j) * nScreenWidth + (nCurrentX + i)] = 7 * 16 + nCurrentPiece;
}
}
else
{
if ((nCurrentX + i) % 4 == 1 || (nCurrentX + i) % 4 == 2)
{
pColor[(nCurrentY + j) * nScreenWidth + (nCurrentX + i)] = 7 * 16 + nCurrentPiece;
}
else
{
pColor[(nCurrentY + j) * nScreenWidth + (nCurrentX + i)] = 8 * 16 + nCurrentPiece;
}
}
pBuffer[(nCurrentY + j) * nScreenWidth + (nCurrentX + i)] = L'▓';
}
}
}
Sau đó 2 hàm WriteConsoleOutputCharacter()
và WriteConsoleOutputAttribute()
sẽ hiển thị ra màn hình. Xem nào:
3. Gameloop
Mỗi game đều là một vòng lặp, lặp đi lặp lại sau một khoảng thời gian nhất định. Gameloop của Tetris nói riêng và mọi game nói chung bao gồm Game Timing, Input, Game Logic và Display.
while (bGameOver != 1)
{
// Game Timing
// Input
// Game Logic
// Display
}
Bây giờ chúng ta sẽ code các phần trong vòng lặp này. Tuy nhiên mình không đi theo trình tự của vòng lặp này đâu nhá.
4. Input và xử lý Input
Input
Game Tetris chỉ có 4 hành động là di chuyển sang phải, di chuyển sang trái, xoay khối và thả xuống. Ta sẽ sử dụng 4 phím để điều khiển các hành động này. Mình sử dụng 4 phím là W, A, S, D:
const vector<char> key = { 'W', 'A', 'S', 'D' };
Để nhận tín hiệu từ các phím ta sử dụng hàm GetAsyncKeyState()
. Hàm này xác định xem một phím lên hay xuống tại thời điểm hàm được gọi và liệu phím đó có được nhấn sau khi gọi GetAsyncKeyState()
lần trước đó hay không.
Cách làm như sau, khởi tạo một mảng bool bKey
gồm 4
phần tử (ứng với 4 phím) bên ngoài Game Loop:
bool bKey[key.size()];
Lặp i
từ 0
đến 3
, nếu GetAsyncKeyState(key.at(i))
trả về 1
(true
), thì gán bKey
tương ứng giá trị 1
, ngược lại gán 0
.
for (int i = 0; i < key.size(); i++)
{
bKey[i] = (GetAsyncKeyState(key.at(i))) != 0;
}
Hàm GetAsyncKeyState()
không đợi người dùng nhập phím. Vòng lặp vẫn sẽ chạy hết trong trường hợp người chơi không ấn phím gì. Cuối cùng, hoặc không có phím nào, hoặc 1 trong 4 phím được ấn.
Bây giờ dữ liệu điều khiển input đã được lưu trong mảng bKey.
Xử lý input nào!
Xử lý input thuộc Game Logic.
Người chơi chỉ có thể điều khiển khối Tetromino, khi nó đã xuất hiện hoàn toàn, tức là khi ta đã nhìn thấy đầy đủ hình dạng của khối. Để kiểm tra nó ta cần một giá trị để so sánh:
int nLimit = 0;
Quay lại với ảnh ở phần 1 trên:
Ta thấy rằng, các khối sẽ xuất hiện đầy đủ khi nCurrentY >= 0
(Hệ tọa độ trong Console chiều dương trục Y hướng xuống dưới). Nên giới hạn nhìn thấy của các khối này là 0. Còn riêng khối O là -1. Mình đã khởi tạo nLimit = 0
, bây giờ chỉ cần thay đổi với trường hợp nCurrentPiece == 3
(khối O).
if (nCurrentPiece == 3)
{
nLimit = -1;
}
Thêm nữa, ta cần kiểm tra Tetromino nếu di chuyển, hoặc xoay có bị vướng hay không. Xây dựng hàm:
bool CheckPiece(int*& pMatrix, int nTetromino, int nRotation, int nPosX, int nPosY)
{
for (int i = 0; i < 8; i++)
{
for (int j = 0; j < 4; j++)
{
if (nPosX + i >= 0 && nPosX + i < nBoardWidth)
{
if (nPosY + j >= 0 && nPosY + j < nBoardHeight)
{
if (tetromino.at(nTetromino).at(nRotation).at(j * 8 + i) != L'.' && pMatrix[(nPosY + j) * nBoardWidth + (nPosX + i)] != 0)
{
return 0;
}
}
}
}
}
return 1;
}
Di chuyển phải – trái
// Move Right
if (bKey[3] == 1 && nCurrentY >= nLimit)
{
if (CheckPiece(pMatrix, nCurrentPiece, nCurrentRotation, nCurrentX + 2, nCurrentY) == 1)
{
nCurrentX += 2;
}
}
// Move Left
if (bKey[1] == 1 && nCurrentY >= nLimit)
{
if (CheckPiece(pMatrix, nCurrentPiece, nCurrentRotation, nCurrentX - 2, nCurrentY) == 1)
{
nCurrentX -= 2;
}
}
Xoay khối
Khởi tạo biến giúp giữ khối Tetromino tại góc xoay hiện tại. Thực ra bản chất nó giúp người chơi mỗi lần ấn W sẽ xoay đúng 1 lần. Tuy nhiên vẫn còn phải phụ thuộc vào phần Game Timing. Khởi tạo bên ngoài Game Loop:
bool bRotateHold = 1;
Sử dụng hàm CheckPiece()
đã xây dựng bên trên để kiểm tra.
if (bKey[0] == 1 && nCurrentY >= nLimit && bRotateHold == 1 && CheckPiece(pMatrix, nCurrentPiece, (nCurrentRotation + 1) % 4, nCurrentX, nCurrentY) == 1)
{
nCurrentRotation++;
nCurrentRotation %= 4;
bRotateHold = 0;
}
else
{
bRotateHold = 1;
}
Đố bạn nào biêt phải chia lấy dư cho 4 😀
Thả khối xuống
Tìm vị trí thấp nhất mà vật có thể rơi xuống và thả.
if (bKey[2] == 1 && nCurrentY >= nLimit)
{
int i{};
while (CheckPiece(pMatrix, nCurrentPiece, nCurrentRotation, nCurrentX, nCurrentY + i) == 1)
{
i++;
}
nCurrentY += i - 1;
}
5. Game Timing
Thời gian để khối Tetromino rơi xuống thêm 1 ô là một Game Tick. Mình chọn 1 Game Tick ban đầu bằng 1 giây. Về sau thời gian này sẽ giảm dần.
Khai báo bên ngoài vòng lặp game:
bool bForceDown = 0;
int nFrame = 20;
int nFrameCount = 0;
Sleep(50);
nFrameCount++;
if (nFrameCount == nFrame)
{
bForceDown = 1;
}
else
{
bForceDown = 0;
}
Mỗi lần “nghỉ” 50 mili giây, đủ 20 lần tức 50 * 20 = 1000 mili giây (1 giây), thì khối sẽ di chuyển xuống thêm 1 ô. Nếu Sleep()
với thời gian quá ngắn thì phần input sẽ không hoạt động tốt.
Sao không Sleep(1000)
luôn cho khỏe? Đoán xem nào.
6. Làm khối rơi xuống sau mỗi Game tick
Nếu bForceDown == 1
, CheckPiece(...)
xem nếu còn có thể di chuyển xuống thì tăng tọa độ Y của khối thêm 1 đơn vị.
Ngược lại, nếu CheckPiece(...) == 0
, Khối Tetromino không thể rơi xuống thêm. Lúc này hoặc nó đã hạ cánh thành công hoặc Game Over.
if (bForceDown)
{
nFrameCount = 0;
if (CheckPiece(pMatrix, nCurrentPiece, nCurrentRotation, nCurrentX, nCurrentY + 1))
{
nCurrentY++; // It can, so do it!
}
else
{
if (/* Condition */)
{
// Game Over
}
else
{
// Fix the Tetromino
}
}
}
7. Cố định khối Tetromino
Các khối đang rơi mình dùng ký tự '▓'
. Bây giờ, để phân biệt các khối đã hạ cánh mình sẽ dùng '█'
.
Thay đổi giá trị bên trong pMatrix
và pColor
nào:
for (int i = 0; i < 8; i++)
{
for (int j = 0; j < 4; j++)
{
if (nCurrentY >= 0 && tetromino.at(nCurrentPiece).at(nCurrentRotation).at(j * 8 + i) != L'.')
{
pMatrix[(nCurrentY + j) * nBoardWidth + (nCurrentX + i)] = 2;
if ((nCurrentY + j) % 2 == 1)
{
if ((nCurrentX + i) % 4 == 1 || (nCurrentX + i) % 4 == 2)
{
pColor[(nCurrentY + j) * nScreenWidth + (nCurrentX + i)] = 8 * 16 + nCurrentPiece;
}
else
{
pColor[(nCurrentY + j) * nScreenWidth + (nCurrentX + i)] = 7 * 16 + nCurrentPiece;
}
}
else
{
if ((nCurrentX + i) % 4 == 3 || (nCurrentX + i) % 4 == 0)
{
pColor[(nCurrentY + j) * nScreenWidth + (nCurrentX + i)] = 8 * 16 + nCurrentPiece;
}
else
{
pColor[(nCurrentY + j) * nScreenWidth + (nCurrentX + i)] = 7 * 16 + nCurrentPiece;
}
}
}
}
}
* pMatrix[(nCurrentY + j) * nBoardWidth + (nCurrentX + i)] = 2
, xem phần hiển thị của bài viết trước để hiểu hơn.
8. Kiểm tra sự tạo thành 1 hàng – xóa hàng
Kiểm tra
Sau khi khối đã hạ cánh. Ta tiến hành kiểm tra số hàng đã tạo thành.
Người chơi có thể hoàn thành cùng lúc tối đa 4 hàng. Do đó, ta chỉ cần kiểm tra từ tọa độ Y hiện tại của khối xuống bên dưới 4 hàng. Đương nhiên ta sẽ không kiểm tra nếu hàng đó “nằm ngoài” Game Board.
Với mỗi hàng, khởi tạo một biến bool bLine = 1
. Nếu trong hàng xuất hiện một ô còn trống thì bLine
sẽ nhận giá trị 0.
Nếu bLine == 1
, thay đổi các ô trong hàng thành ký tự '░'
. Bước này giúp ta khi xóa hàng sẽ tạo ra một chút hiệu ứng “tan vỡ”.
for (int j = 0; j < 4; j++)
{
if (nCurrentY + j < nBoardHeight - 1)
{
bool bLine = 1;
for (int i = 1; i < nBoardWidth - 1; i++)
{
if (pMatrix[(nCurrentY + j) * nBoardWidth + i] == 0)
{
bLine = 0;
break;
}
}
if (bLine == 1)
{
for (int i = 1; i < nBoardWidth - 1; i++)
{
pMatrix[(nCurrentY + j) * nBoardWidth + i] = 3;
}
vLines.push_back(nCurrentY + j);
}
}
}
* vector<int> vLines
được khởi tạo bên ngoài Game Loop.
Xóa
Hiển thị để thấy “hiệu ứng tan vỡ” nói trên. Sau đó tiến hành xóa bằng cách đè các ô hàng trên lên các ô của hàng bị xóa.
if (!vLines.empty())
{
WriteConsoleOutputCharacter(hConsole, pBuffer, nScreenWidth * nScreenHeight, { 0,0 }, &dwBytesWritten);
Sleep(400);
for (int l = 0; l < vLines.size(); l++)
{
for (int i = 1; i < nBoardWidth - 1; i++)
{
for (int j = vLines.at(l); j > 0; j--)
{
if (j % 2 == 0)
{
if (i % 4 == 1 || i % 4 == 2)
{
pColor[j * nScreenWidth + i] = pColor[(j - 1) * nScreenWidth + i] - 16;
}
else
{
pColor[j * nScreenWidth + i] = pColor[(j - 1) * nScreenWidth + i] + 16;
}
}
else
{
if (i % 4 == 1 || i % 4 == 2)
{
pColor[j * nScreenWidth + i] = pColor[(j - 1) * nScreenWidth + i] + 16;
}
else
{
pColor[j * nScreenWidth + i] = pColor[(j - 1) * nScreenWidth + i] - 16;
}
}
pMatrix[j * nBoardWidth + i] = pMatrix[(j - 1) * nBoardWidth + i];
}
pMatrix[i] = 0;
}
}
vLines.clear();
}
9. Tính điểm – tính số hàng
Điểm
Mình thiết lập 1 khối hạ cách thành công người chơi sẽ có 25 điểm; 1 hàng 200 điểm, 2 hàng 400 điểm, 3 hàng 800 điểm và 4 hàng 1600 điểm.
nScore += 25;
if (!vLines.empty())
{
nScore += (1 << vLines.size()) * 100;
}
* 1 << vLines.size()
là phép toán dịch trái bit. với 1 <= vLines.size()
<= 4, giá trị trả về lần lượt là 2, 4, 8 và 16.
Số hàng
Khởi tao thêm 1 biến int nLine = 0
vẫn bên ngoài Game Loop. Thêm vào đoạn code trong phần 8, if (bLine == 1) {...}
:
nLine++;
10. Tạo khối mới
nCurrentX = nBoardWidth / 2 - 4;
nCurrentY = -4;
nCurrentRotation = 0;
nCurrentPiece = nNextPiece;
nNextPiece = random(0, 6);
* nNextPiece
cũng là một biến khai báo bên ngoài vòng lặp game. Biến này giúp ta có thể cho người chơi biết khối tiếp theo là gì.
* Hàm random()
:
int random(int nMin, int nMax)
{
random_device rd;
mt19937 rng(rd());
uniform_int_distribution<int> uni(nMin, nMax);
auto num = uni(rng);
return num;
}
11. Hiển thị Score – Line – Next (Tetromino)
Xây dựng hàm để hiển thị Text và Block:
void Text(wchar_t*& pBuffer, wstring content, int nPosX, int nPosY)
{
for (int i = 0; i < content.length(); i++, nPosX++)
{
pBuffer[nPosY * nScreenWidth + nPosX] = content.at(i);
}
}
void Block(wchar_t*& pBuffer, WORD*& pColor, int nTetromino, int nPosX, int nPosY)
{
for (int j = 0; j < 4; j++)
{
for (int i = 0; i < 8; i++)
{
if (tetromino.at(nTetromino).at(0).at(j * 8 + i) != L'.')
{
pBuffer[(nPosY + j) * nScreenWidth + (nPosX + i)] = tetromino.at(nTetromino).at(0).at(j * 8 + i);
}
else
{
pBuffer[(nPosY + j) * nScreenWidth + (nPosX + i)] = L' ';
}
pColor[(nPosY + j) * nScreenWidth + (nPosX + i)] = 8 * 16 + nTetromino;
}
}
}
Score
Tìm vị trí thích hợp và hiển thị.
// Outside the Game loop
int nScorePosX = 37;
int nScoreComp = 10;
if (nScore >= nScoreComp)
{
nScorePosX--;
nScoreComp *= 10;
}
Text(pBuffer, to_wstring(nScore), nScorePosX, 2);
Line
// Outside the Game loop
int nLinePosX = 37;
int nLineComp = 10;
if (nLine >= nLineComp)
{
nLinePosX--;
nLineComp *= 10;
}
Text(pBuffer, to_wstring(nLine), nLinePosX, 5);
Next
Block(pBuffer, pColor, nNextPiece, 26, 8);
12. Game Over
Nếu khối chưa hiện đầy đủ và không thể rơi xuống thêm, bGameOver = 1
, break
khỏi Game Loop. Sử dụng nLimit
bên trên để so sánh.
if (nCurrentY < nLimit)
{
bGameOver = 1;
break;
}
Thông báo Game Over và Điểm. Trước đó, cần CloseHandle()
.
CloseHandle(hConsole);
cout << "Game Over !" << "\n";
cout << "Score: " << nScore << "\n";
Chúng ta có gì
Souce code: Pastebin.com
Tạm kết
Như vậy, qua bài viết này, các đã có thể làm ra một con game Console khá hoàn chỉnh rồi. Bài viết sau, chúng ta sẽ tiếp tục hoàn thiện nó.
Hay rate 5*, share và để lại ý kiến của các bạn bên dưới phần bình luận nhé. Cảm ơn các bạn!
Post Comment