組員: 411214208 柯采彤 411214202屠玟綺
上學期我們成功利用Arduino完成了貪吃蛇小遊戲,這學期我們想挑戰利用差不多的材料復刻出俄羅斯方塊😾✨
俄羅斯方塊是一款 1984 年由蘇聯設計的經典益智遊戲。玩家要在狹長的遊戲區中,操控由 4 個小方格組成的各種形狀方塊,透過旋轉與左右移動,把它們排成完整的一橫排。只要一行被填滿就會消除並得分,方塊堆到螢幕頂端則遊戲結束。規則非常簡單容易上手!
所需材料:
矩陣模組
(MAX 7219)
雙軸按鍵搖桿
LCD面板
(LCD1602A I2C)
Arduino UNO板
杜邦線 (公對公、公對母)
麵包板
設計電路圖:
程式碼(可展開):
Code
# # #include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <LedControl.h>
// ==========================================
// [硬體設定區]
// ==========================================
// 矩陣方向修正 (若圖案顛倒請改這裡)
const bool ROTATE_MATRIX = false;
const bool INVERT_X = false;
// 接腳定義 (DIN=12, CLK=11, CS=10)
LedControl lc = LedControl(12, 11, 10, 1);
LiquidCrystal_I2C lcd(0x27, 16, 2);
const int PIN_X = A0; // 搖桿 X
const int PIN_Y = A2; // 搖桿 Y
const int PIN_POT = A1; // 可變電阻 (調速)
// ==========================================
// [遊戲變數]
// ==========================================
byte board[8][8] = {0}; // 0=空, 1=有方塊
int curX = 3, curY = -2;
int curPiece, curRot;
int gamePhase = 0; // 0:待機, 1:倒數, 2:遊戲中
unsigned long lastFallTime = 0;
unsigned long startTime = 0;
unsigned long lastLcdUpdate = 0; // 新增:控制螢幕刷新頻率
int dropSpeed = 500;
// 7 種方塊定義
const byte blocks[7][4][4][4] = {
{ { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, { {0,0,1,0}, {0,0,1,0}, {0,0,1,0}, {0,0,1,0} }, { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, { {0,0,1,0}, {0,0,1,0}, {0,0,1,0}, {0,0,1,0} } }, // I
{ { {1,0,0,0}, {1,1,1,0}, {0,0,0,0}, {0,0,0,0} }, { {0,1,1,0}, {0,1,0,0}, {0,1,0,0}, {0,0,0,0} }, { {0,0,0,0}, {1,1,1,0}, {0,0,1,0}, {0,0,0,0} }, { {0,1,0,0}, {0,1,0,0}, {1,1,0,0}, {0,0,0,0} } }, // J
{ { {0,0,1,0}, {1,1,1,0}, {0,0,0,0}, {0,0,0,0} }, { {0,1,0,0}, {0,1,0,0}, {0,1,1,0}, {0,0,0,0} }, { {0,0,0,0}, {1,1,1,0}, {1,0,0,0}, {0,0,0,0} }, { {1,1,0,0}, {0,1,0,0}, {0,1,0,0}, {0,0,0,0} } }, // L
{ { {0,1,1,0}, {0,1,1,0}, {0,0,0,0}, {0,0,0,0} }, { {0,1,1,0}, {0,1,1,0}, {0,0,0,0}, {0,0,0,0} }, { {0,1,1,0}, {0,1,1,0}, {0,0,0,0}, {0,0,0,0} }, { {0,1,1,0}, {0,1,1,0}, {0,0,0,0}, {0,0,0,0} } }, // O
{ { {0,1,1,0}, {1,1,0,0}, {0,0,0,0}, {0,0,0,0} }, { {0,1,0,0}, {0,1,1,0}, {0,0,1,0}, {0,0,0,0} }, { {0,1,1,0}, {1,1,0,0}, {0,0,0,0}, {0,0,0,0} }, { {0,1,0,0}, {0,1,1,0}, {0,0,1,0}, {0,0,0,0} } }, // S
{ { {0,1,0,0}, {1,1,1,0}, {0,0,0,0}, {0,0,0,0} }, { {0,1,0,0}, {0,1,1,0}, {0,1,0,0}, {0,0,0,0} }, { {0,0,0,0}, {1,1,1,0}, {0,1,0,0}, {0,0,0,0} }, { {0,1,0,0}, {1,1,0,0}, {0,1,0,0}, {0,0,0,0} } }, // T
{ { {1,1,0,0}, {0,1,1,0}, {0,0,0,0}, {0,0,0,0} }, { {0,0,1,0}, {0,1,1,0}, {0,1,0,0}, {0,0,0,0} }, { {1,1,0,0}, {0,1,1,0}, {0,0,0,0}, {0,0,0,0} }, { {0,0,1,0}, {0,1,1,0}, {0,1,0,0}, {0,0,0,0} } } // Z
};
void setup() {
lcd.init();
lcd.backlight();
lc.shutdown(0, false);
lc.setIntensity(0, 5);
lc.clearDisplay(0);
// 硬體開機檢測
for(int i=0; i<2; i++){
for(int r=0; r<8; r++) lc.setRow(0, r, 255);
delay(100);
lc.clearDisplay(0);
delay(100);
}
resetToStandby();
randomSeed(analogRead(A5));
}
void loop() {
// 隨時讀取速度
// 地圖映射:0~1023 對應到 1000ms(慢) ~ 50ms(極快)
int potVal = analogRead(PIN_POT);
dropSpeed = map(potVal, 0, 1023, 1000, 50);
switch (gamePhase) {
case 0: // 待機
lcd.setCursor(0, 0); lcd.print(" TETRIS READY ");
lcd.setCursor(0, 1); lcd.print("Move to start ");
// 搖桿觸發
if (abs(analogRead(PIN_X) - 512) > 200 || abs(analogRead(PIN_Y) - 512) > 200) {
startCountdown();
}
break;
case 2: // 遊戲中
runGameLoop();
break;
// case 3 (死亡狀態) 邏輯在 doGameOver 函式
}
}
// 倒數計時
void startCountdown() {
gamePhase = 1;
for(int i=3; i>0; i--) {
lcd.clear();
lcd.setCursor(7, 0); lcd.print(i);
delay(1000);
}
lcd.clear();
lcd.setCursor(6, 0); lcd.print("GO!");
delay(500);
// 遊戲開始
gamePhase = 2;
memset(board, 0, sizeof(board));
startTime = millis();
lcd.clear();
spawnBlock();
}
// 遊戲主迴圈
void runGameLoop() {
unsigned long now = millis();
// === 效能優化關鍵:每 200ms 才更新一次螢幕 ===
// 這樣 Arduino 才有時間去處理極速下落
if (now - lastLcdUpdate > 200) {
lcd.setCursor(0, 0);
lcd.print("Spd:"); lcd.print(dropSpeed); lcd.print("ms ");
lcd.setCursor(0, 1);
lcd.print("Time:"); lcd.print((now - startTime) / 1000); lcd.print("s");
lastLcdUpdate = now;
}
// 搖桿操作
static unsigned long lastMove = 0;
if (now - lastMove > 150) {
int xVal = analogRead(PIN_X);
int yVal = analogRead(PIN_Y);
if (xVal < 200) moveBlock(-1, 0);
if (xVal > 800) moveBlock(1, 0);
if (yVal < 200) rotateBlock();
if (yVal > 800) { // 下推加速
if (!checkCollision(curX, curY + 1, curRot)) curY++;
}
lastMove = now;
}
// 自動下落 (這裡現在不會被 LCD 拖慢了)
if (now - lastFallTime > dropSpeed) {
if (!checkCollision(curX, curY + 1, curRot)) {
curY++;
} else {
// 到底固定
if (!lockBlock()) {
doGameOver(); // 溢出 -> 死亡
return;
}
clearLines();
if (!spawnBlock()) {
doGameOver(); // 無法生成 -> 死亡
return;
}
}
lastFallTime = now;
}
drawGame();
}
// 產生新方塊
bool spawnBlock() {
curPiece = random(0, 7);
curRot = 0;
curX = 2; curY = -3;
return !checkCollision(curX, curY, curRot);
}
void moveBlock(int dx, int dy) {
if (!checkCollision(curX + dx, curY + dy, curRot)) { curX += dx; curY += dy; }
}
void rotateBlock() {
int nextRot = (curRot + 1) % 4;
if (!checkCollision(curX, curY, nextRot)) { curRot = nextRot; }
}
bool checkCollision(int x, int y, int rot) {
for (int r = 0; r < 4; r++) {
for (int c = 0; c < 4; c++) {
if (blocks[curPiece][rot][r][c]) {
int realX = x + c;
int realY = y + r;
if (realX < 0 || realX > 7) return true;
if (realY > 7) return true;
if (realY >= 0 && board[realY][realX]) return true;
}
}
}
return false;
}
bool lockBlock() {
bool overflow = false;
for (int r = 0; r < 4; r++) {
for (int c = 0; c < 4; c++) {
if (blocks[curPiece][curRot][r][c]) {
int realX = curX + c;
int realY = curY + r;
if (realY < 0) overflow = true;
else if (realY <= 7 && realX >= 0 && realX <= 7) board[realY][realX] = 1;
}
}
}
return !overflow;
}
void clearLines() {
for (int y = 7; y >= 0; y--) {
bool full = true;
for (int x = 0; x < 8; x++) if (!board[y][x]) full = false;
if (full) {
for (int k = y; k > 0; k--) {
for (int x = 0; x < 8; x++) board[k][x] = board[k-1][x];
}
for (int x = 0; x < 8; x++) board[0][x] = 0;
y++;
}
}
}
void doGameOver() {
gamePhase = 3; // 停止遊戲迴圈 (LCD 時間此刻凍結)
// 1. 矩陣模組閃爍三次
for(int i=0; i<3; i++) {
for(int r=0; r<8; r++) lc.setRow(0, r, 255);
delay(200);
lc.clearDisplay(0);
delay(200);
}
lc.clearDisplay(0); // 歸零
// 2. 停頓一秒 (LCD 仍顯示時間)
delay(1000);
// 3. 顯示 GAME OVER
lcd.clear();
lcd.setCursor(3, 0);
lcd.print("GAME OVER");
// 4. 停留 3 秒
delay(3000);
// 5. 回待機
resetToStandby();
}
void resetToStandby() {
gamePhase = 0;
lc.clearDisplay(0);
}
// 繪圖
void drawGame() {
lc.clearDisplay(0);
for (int y = 0; y < 8; y++) {
for (int x = 0; x < 8; x++) {
if (board[y][x]) safeSetLed(y, x, true);
}
}
for (int r = 0; r < 4; r++) {
for (int c = 0; c < 4; c++) {
if (blocks[curPiece][curRot][r][c]) {
int realY = curY + r;
int realX = curX + c;
if (realY >= 0 && realY <= 7 && realX >= 0 && realX <= 7) safeSetLed(realY, realX, true);
}
}
}
}
void safeSetLed(int row, int col, bool state) {
int finalRow = row;
int finalCol = col;
if (ROTATE_MATRIX) { finalRow = col; finalCol = 7 - row; }
if (INVERT_X) { finalCol = 7 - finalCol; }
lc.setLed(0, finalRow, finalCol, state);
}
最終成果:
我們另外再新增一塊wio terminal顯示板上去,使得遊戲看起來更有趣。
心得感想:
這次做俄羅斯方塊真的讓我們學到很多debug的經驗!
原本以為只要把線接好,程式上傳進去就能玩,結果一開始矩陣模組根本不亮,後來又是方塊不會落下,連轉動可變電阻速度都沒反應,還以為要做不出來了。
後來才發現原來是 LCD 更新太快導致可變電阻調了也沒反應,還有死亡判定的邏輯原本有錯。
經過一步步修改,把 Game Over 的閃爍特效、遊戲時間、速度跟待機畫面串連起來後,整個遊戲流程變得很順暢當看到方塊終於能隨著旋鈕加速落下,成就感滿滿!