Swift實現俄羅斯方塊詳細思路及原始碼

張小旭發表於2016-05-12

一、寫在開發前

俄羅斯方塊,是一款我們小時候都玩過的小遊戲,我自己也是看著書上的思路,學著用 Swift 來寫這個小遊戲,在寫這個遊戲的過程中,除了一些位置的計算,資料模型和理解 Swift 語言之外,最好知道UIKIt框架中的 Quartz2D 這個知識點。是我在簡書上面找的,是關於 Quartz2D 這個知識點的,看它我覺得也就夠學習。經過這兩天的整理,充分覺得在寫這些之前,一定要理清楚思路,你可能會花很多時間在它上面,你要知道了,怎麼寫就變的反而簡單了。

二、具體開發思路及主要程式碼

我在部落格的最下面附上了完整的程式碼,大家可以在Git上下載到它,你要也使用Git,就順便給我個小星星吧 O(∩_∩)O哈哈~。。

1、遊戲介面的佈局設計

這個裡面的Label 和 Button 就不多費口舌了,這不是我們的重點,看看這個效果我們也就一筆帶過了吧!重點是我們使用的上面說的利用 Quartz2D 這個知識畫出來表格。它單看就是一個 N * M 的表格,在它裡面就要執行我們的俄羅斯小方塊,在下面的程式碼裡面也會詳細的說明它的製作。

下面是我們繪製上面網格檢視的方法,下面所有程式碼方法裡面的有些引數是定義成全域性變數的,大家可以下載完整版的程式碼去看看。在程式碼中也加了許多的註釋,相信都能看的明白的。

// MARK: 繪製俄羅斯方庫網格的方法
   func creatcells(rows:Int,cols:Int,cellwidth:Int,cellHeight:Int) -> Void {

       // 開始建立路徑
       CGContextBeginPath(CTX)
       // 繪製橫向網格對應的路徑
       for  i  in 0...TETRIS_Row {

           CGContextMoveToPoint(CTX, 0, CGFloat(i  *  CELL_Size))
           CGContextAddLineToPoint(CTX, CGFloat(TETRIS_Cols * CELL_Size), CGFloat(i * CELL_Size))

       }
       // 繪製縱向的網格對應路徑
       for  i  in 0...TETRIS_Cols {

           CGContextMoveToPoint(CTX, CGFloat(i  *  CELL_Size),0)
           CGContextAddLineToPoint(CTX, CGFloat(i * CELL_Size), CGFloat(TETRIS_Row * CELL_Size))

       }
       // 關閉
       CGContextClosePath(CTX)

       // 設定筆觸顏色
       CGContextSetStrokeColorWithColor(CTX, UIColor(red: 0.9 , green: 0.9 , blue: 0.9,alpha: 1).CGColor)
       // 設定效線條粗細
       CGContextSetLineWidth(CTX, CGFloat(STROKE_Width))
       // 繪製線條
       CGContextStrokePath(CTX)

   }

2、小遊戲的資料模型

1: 遊戲的遊戲介面是一個 N * M 的網格,每一張網格顯示一張圖片,但對於我們來說,我門就得用一個二維陣列來定義,紀錄每一塊的行和列!來儲存遊戲的狀態。我們在最開始把每一個小塊的遊狀態都初始化為 0 ,看下面程式碼。

// 定義用於紀錄方塊遊戲狀態的二維陣列
var tetris_status = [[Int]]()

// MARK初始化遊戲狀態
func initTetrisStatus() -> Void {

    let tmpRow = Array.init(count: TETRIS_Cols, repeatedValue: NO_Block)
    tetris_status  = Array.init(count: TETRIS_Row, repeatedValue: tmpRow)

}

2: 遊戲的過程中有一隻處於“下落”狀態的四個方塊,這四個方塊我們也會是要紀錄,才可以做它的旋轉、向左、向右等等的處理。我們就用一個陣列包含著四個方塊,那具體到這四個方塊呢?我們就用一個結構體去體現你這四個方塊它的 X、Y值和顏色。

struct Block {

    var X:Int
    var Y:Int
    var Color:Int
    var description:String {

        return "Block[X=\(X),Y=\(Y),Color=\(Color)]"
    }
}

3:在俄羅斯方塊這個遊戲中,你也肯定得知道有哪些方塊的組合可以下落,這也是一個資料來源!你也得定義好,在每次要下落的時候你就隨機取出這個而資料來源裡面的資料,讓它隨機的出現下落。這些工作也就是你要在初始化上面要紀錄的四個正在下落的方塊陣列的時候做的事了,下面是這些個組合的資料來源。

// 幾種可能的組合方塊
self.blockArr = [

    // 第一種可能出現的組合 Z
    [
        Block(X:TETRIS_Cols/2 - 1,Y:0,Color:1),
        Block(X:TETRIS_Cols/2,Y:0,Color:1),
        Block(X:TETRIS_Cols/2,Y:1,Color:1),
        Block(X:TETRIS_Cols/2 + 1,Y:1,Color:1)

    ],
    // 第二種可能出現的組合 反Z
    [
        Block(X:TETRIS_Cols/2 + 1,Y:0,Color:2),
        Block(X:TETRIS_Cols/2,Y:0,Color:2),
        Block(X:TETRIS_Cols/2,Y:1,Color:2),
        Block(X:TETRIS_Cols/2 - 1,Y:1,Color:2)

    ],
    // 第三種可能出現的組合 田
    [
        Block(X:TETRIS_Cols/2 - 1,Y:0,Color:3),
        Block(X:TETRIS_Cols/2,Y:0,Color:3),
        Block(X:TETRIS_Cols/2 - 1,Y:1,Color:3),
        Block(X:TETRIS_Cols/2 ,Y:1,Color:3)

    ],
    // 第四種可能出現的組合 L
    [
        Block(X:TETRIS_Cols/2 - 1,Y:0,Color:4),
        Block(X:TETRIS_Cols/2 - 1,Y:1,Color:4),
        Block(X:TETRIS_Cols/2 - 1,Y:2,Color:4),
        Block(X:TETRIS_Cols/2 ,Y:2,Color:4)

    ],
    // 第五種可能出現的組合 J
    [
        Block(X:TETRIS_Cols/2,Y:0,Color:5),
        Block(X:TETRIS_Cols/2,Y:1,Color:5),
        Block(X:TETRIS_Cols/2,Y:2,Color:5),
        Block(X:TETRIS_Cols/2 - 1,Y:2,Color:5)

    ],
    // 第六種可能出現的組合 ——
    [
        Block(X:TETRIS_Cols/2,Y:0,Color:6),
        Block(X:TETRIS_Cols/2,Y:1,Color:6),
        Block(X:TETRIS_Cols/2,Y:2,Color:6),
        Block(X:TETRIS_Cols/2,Y:3,Color:6)

    ],
    // 第七種可能出現的組合 土缺一
    [
        Block(X:TETRIS_Cols/2,Y:0,Color:7),
        Block(X:TETRIS_Cols/2-1,Y:1,Color:7),
        Block(X:TETRIS_Cols/2,Y:1,Color:7),
        Block(X:TETRIS_Cols/2 + 1,Y:1,Color:7)

    ],
]

隨機取出下落

// 定義紀錄 “正在下掉的四個方塊” 位置
 var currentFall = [Block]()
 func initBlock() -> Void {

     // 生成一個在 0 - blockArr.count  之間的隨機數
     let rand =  Int(arc4random()) % blockArr.count
     // 隨機取出 blockArr 陣列中的某個元素為正在下掉的方塊組合
     currentFall = blockArr[rand]

 }

3、 遊戲邏輯處理

1:下落

前面我們提到過有用陣列紀錄正在下落的四個方塊的狀態,我們梳理一下“下落”狀態的邏輯關係。如果在下落的狀態,你只需要把這四個正在下落的方塊的 Y 值加 1 即可! 但是得注意什麼情況下它不能再下落了。。

(1):如果方塊組合中任意一個方塊已經到達了最底下就不能再下落了。

(2) :如果方庫組合中任意一個方塊的下面有了方塊就不能再下落了。

下落的實現思路就是,如果有方塊可以下落,那麼就把方塊組合原來所在位置的顏色清楚,然後把組合中的每一個方塊的 Y 屬性加1 ,最後把當前方塊的所在位置加上相應的顏色,下面是思路實現的程式碼。

// MARK:控制方塊組合向下移動
 func movedown () -> Void {

     // 定義能否向下掉落的 標籤
     var canDown = true

     // 遍歷每一塊方塊,判斷它是否能向下掉落
     for i in 0..<currentFall.count {

         // 第一種情況,如果位置到行數最底下了,不能再下落
         if currentFall[i].Y >= TETRIS_Row - 1 {

             canDown = false
             break
         }
         // 第二種情況,如果他的下面有了方塊,不能再下落
         if tetris_status[currentFall[i].Y + 1][currentFall[i].X] != NO_Block {

             canDown = false
             break
         }
     }
     // 如果能向下掉落
     if canDown {

         self.drawBlock()//

         for i in 0..<currentFall.count {

             let cur = currentFall[i]
             // 設定填充顏色
             CGContextSetFillColorWithColor(CTX, UIColor.whiteColor().CGColor)

             CGContextFillRect(CTX, CGRectMake(CGFloat(cur.X * CELL_Size + STROKE_Width), CGFloat(cur.Y * CELL_Size + STROKE_Width), CGFloat(CELL_Size - STROKE_Width * 2),CGFloat(CELL_Size - STROKE_Width * 2)))

         }
         //  遍歷每一個方塊。控制每一個方塊的 有座標都 加 1
         for i in 0..<currentFall.count {

             currentFall[i].Y += 1

         }
         //  將下移後的每一個方塊的背景塗色稱該方塊的顏色
         for i in 0..<currentFall.count {

             let cur = currentFall[i]
             // print(cur.X   ,   cur.Y)
             CGContextSetFillColorWithColor(CTX, colors[cur.Color])
             CGContextFillRect(CTX, CGRectMake(CGFloat(cur.X * CELL_Size + STROKE_Width), CGFloat(cur.Y * CELL_Size + STROKE_Width), CGFloat(CELL_Size - STROKE_Width * 2),CGFloat(CELL_Size - STROKE_Width * 2)))

         }
     }
     // 不能向下掉落
     else
     {
         // 遍歷每個方塊,把每個方塊的值紀錄到
         for i in 0..<currentFall.count {

             let cur = currentFall[i]
             // 小於2表示已經到最上面,遊戲要結束了
             if cur.Y < 2 {

                 // 計時器失效
                 curTimer?.invalidate()
                 // 提示遊戲結束
                 self.delegate.UpdateGameState()

             }

             // 把每個方塊當前所在的位置賦值為當前方塊的顏色值
             tetris_status[cur.Y][cur.X] = cur .Color

     }
         // 判斷是否有可消除的行
         lineFull()
         // 開始一組新的方塊
         initBlock()
 }

 // 獲取快取區的圖片
 image = UIGraphicsGetImageFromCurrentImageContext()
 // 通知重繪
 self.setNeedsDisplay()
}

裡面的代理更新UI(及分數和速度)我們就不多說了,說說 drawBlock() 這個方法,它是來繪製了我們在所有的方塊,相當於把我們的互資料模型給全都視覺化;

//MARK: 繪製俄羅斯方塊的狀態
   func drawBlock() -> Void {

       for i in 0..<TETRIS_Row {

           for j in 0..<TETRIS_Cols {

               if tetris_status[i][j] != NO_Block {

                   // 設定填充顏色
                   CGContextSetFillColorWithColor(CTX, colors[tetris_status[i][j]])
                   CGContextFillRect(CTX, CGRectMake(CGFloat(j * CELL_Size + STROKE_Width),CGFloat(i * CELL_Size + STROKE_Width) , CGFloat(CELL_Size - STROKE_Width * 2), CGFloat(CELL_Size - STROKE_Width * 2)))

               }
               else
               {

                   // 設定填充顏色
                   CGContextSetFillColorWithColor(CTX, UIColor.whiteColor().CGColor)
                   CGContextFillRect(CTX, CGRectMake(CGFloat(j * CELL_Size + STROKE_Width),CGFloat(i * CELL_Size + STROKE_Width) , CGFloat(CELL_Size - STROKE_Width * 2), CGFloat(CELL_Size - STROKE_Width * 2)))

               }
           }
       }
   }

2:判斷這行是否已滿

上面是讓它下落了,裡面有呼叫判斷一行是否已滿,其實這裡的邏輯就是遍歷每一行每一個方塊,給你的每一行都加一個狀態,這裡是 true ,判斷你該行的每一個方塊的狀態是不是初始化時候的 0  ,要是,那說明是缺方塊的,這行沒有滿,跳出。。要是都不是,那就說明這行都滿了。。就可以進行消除這行的後續操作了。增加積分,消除相應的行等,下面是它的程式碼。

// MARK: 判斷是否有一行已滿
    func lineFull() -> Void{
      // 遍歷每一行
        for i in 0..<TETRIS_Row {

            var flag = true
            // 遍歷每一行的每一個單元
            for j in 0..<TETRIS_Cols {

                if tetris_status[i][j] == NO_Block {

                    flag = false
                    break
                }
            }
            // 如果當前行已經全部有了方塊
            if flag {

                // 當前積分增加 100
                curScore += 100
                // 代理更新當前積分
                self.delegate.UpdateScore(curScore)

                if curScore >= curSpeed * curSpeed * 500{

                    curSpeed += 1
                    // 代理更新當前速度
                    self.delegate.UpdateSpeed(curSpeed)
                    curTimer?.invalidate()
                    curTimer = NSTimer.scheduledTimerWithTimeInterval(BASE_Speed/Double(curSpeed), target: self, selector: #selector(self.movedown), userInfo: nil, repeats: true)
                }

            }
            // 把所有的整體下移一行
            for var j = i; j < 0 ; j -= 1 {

                for k in 0..<TETRIS_Cols {

                    tetris_status[j][k] = tetris_status[j-1][k]

                }

            }
            // 播放消除的音樂
//            if !disBackGroundMusicPlayer.play() {
//               
//                disBackGroundMusicPlayer.play()
//            }
        }
    }

3.左移處理

它的處理方式和上面的下落的邏輯是一樣的,也就是兩點,到了最左邊和左邊有了兩型別的情況,程式碼如下。

//MARK: 定義左邊移動的方法
   func moveLeft () -> Void {

       // 定義左邊移動的標籤
       var canLeft = true
       for i in 0..<currentFall.count {

           if currentFall[i].X <= 0 {

               canLeft = false
               break
           }
           // 左變位置的前邊一塊
           if tetris_status[currentFall[i].Y][currentFall[i].X - 1] != NO_Block  {

               canLeft = false
               break

           }
       }
       // 如果可以左移
       if canLeft {

           self.drawBlock()
           // 將左移前的的每一個方塊背景塗成白底
           for i in 0..<currentFall.count {

               let  cur = currentFall[i]
               CGContextSetFillColorWithColor(CTX, UIColor.whiteColor()
               .CGColor)
               CGContextFillRect(CTX, CGRectMake(CGFloat(cur.X * CELL_Size + STROKE_Width), CGFloat(cur.Y * CELL_Size + STROKE_Width), CGFloat(CELL_Size - STROKE_Width * 2), CGFloat(CELL_Size - STROKE_Width * 2)))

           }

           // 左移正字啊下掉的方塊
           for i in 0..<currentFall.count {

               currentFall[i].X -= 1

           }

           // 將左移後的的每一個方塊背景塗成對應的顏色
           for i in 0..<currentFall.count {

               let  cur = currentFall[i]
               CGContextSetFillColorWithColor(CTX,colors[cur.Color])
               CGContextFillRect(CTX, CGRectMake(CGFloat(cur.X * CELL_Size + STROKE_Width), CGFloat(cur.Y * CELL_Size + STROKE_Width), CGFloat(CELL_Size - STROKE_Width * 2), CGFloat(CELL_Size - STROKE_Width * 2)))

           }
           // 獲取緩衝區的圖片
           image = UIGraphicsGetImageFromCurrentImageContext()

           // 通知重新繪製
           self.setNeedsDisplay()

       }
   }

4.右移處理

右邊移動的處理情況幾乎就和左邊的完全相同了,見程式碼

// MARK: 定義右邊移動的方法
   func moveRight () -> Void {

       // 能否右移動的標籤
       var canRight = true
       for i in 0..<currentFall.count {

           // 如果已經到最右邊就不能再移動
           if currentFall[i].X >= TETRIS_Cols - 1 {

               canRight = false
               break
           }
           // 如果右邊有方塊,就不能再移動
           if tetris_status[currentFall[i].Y][currentFall[i].X + 1] != NO_Block {

               canRight = false
               break
           }
       }
       // 如果能右邊移動
       if canRight {

           self.drawBlock()
           // 將香油移動的每個方塊塗白色
           for i in 0..<currentFall.count {

               let cur = currentFall[i]
               CGContextSetFillColorWithColor(CTX, UIColor.whiteColor().CGColor)
               CGContextFillRect(CTX, CGRectMake(CGFloat(cur.X * CELL_Size + STROKE_Width), CGFloat(cur.Y * CELL_Size + STROKE_Width), CGFloat(CELL_Size - STROKE_Width * 2), CGFloat(CELL_Size - STROKE_Width * 2)))

           }
       }
       // 右邊移動正在下落的所有的方塊
       for i in 0..<currentFall.count {

           currentFall[i].X += 1

       }
       // 有以後將每個方塊的顏色背景圖成各自方塊對應的顏色
       for i in 0..<currentFall.count {

           let  cur = currentFall[i]
           // 設定填充顏色
           CGContextSetFillColorWithColor(CTX, colors[cur.Color])
           // 繪製矩形
           CGContextFillRect(CTX, CGRectMake(CGFloat(cur.X * CELL_Size + STROKE_Width), CGFloat(cur.Y * CELL_Size + STROKE_Width), CGFloat(CELL_Size - STROKE_Width * 2), CGFloat(CELL_Size - STROKE_Width * 2)))

           image = UIGraphicsGetImageFromCurrentImageContext()
           // 通知重新繪製
           self.setNeedsDisplay()

       }
   }

5.旋轉處理

旋轉處理,就得用點數學知識了,你畫一個座標軸,試著把一個點順時針或者逆時針旋轉九十度,你再寫出旋轉後的座標。其實清楚了這點也就OK了,我們是按逆時針旋轉處理的,四個方塊,就按照第三個作為它的旋轉軸心。

// MARK: 定義旋轉的方法
   func rotate () -> Void {

      // 定義是否能旋轉的標籤
       var canRotate = true
       for i in 0..<currentFall.count
       {

           let preX = currentFall[i].X
           let preY = currentFall[i].Y
           // 始終以第三塊作為旋轉的中心
           // 當 i == 2的時候,說明是旋轉的中心
           if i != 2
           {

               // 計算方塊旋轉後的X,Y座標
               let afterRotateX  =  currentFall[2].X + preY - currentFall[2].Y
               let afterRotateY  =  currentFall[2].Y + currentFall[2].X - preX

               // 如果旋轉後的x,y座標越界,或者旋轉後的位置已有別的方塊,表示不能旋轉
               if afterRotateX < 0 || afterRotateX > TETRIS_Cols - 1 || afterRotateY < 0 || afterRotateY > TETRIS_Row - 1 || tetris_status[afterRotateY][afterRotateX] != NO_Block
               {

                   canRotate = false
                   break

               }
           }
       }

       // 如果能旋轉
       if canRotate
       {

               self.drawBlock()

               for i in 0..<currentFall.count
               {

                   let  cur = currentFall[i]
                   // 設定填充顏色
                   CGContextSetFillColorWithColor(CTX, UIColor.whiteColor().CGColor)
                   // 繪製矩形
                   CGContextFillRect(CTX, CGRectMake(CGFloat(cur.X * CELL_Size + STROKE_Width), CGFloat(cur.Y * CELL_Size + STROKE_Width), CGFloat(CELL_Size - STROKE_Width * 2), CGFloat(CELL_Size - STROKE_Width * 2)))

               }

               for i in 0..<currentFall.count
               {

                   let preX = currentFall[i].X
                   let preY = currentFall[i].Y

                   // 始終第三個作為旋轉中心
                   if i != 2
                   {                       
                       currentFall[i].X = currentFall[2].X + preY - currentFall[2].Y
                       currentFall[i].Y = currentFall[2].Y + currentFall[2].X - preX
                   }
               }

               for i in 0..<currentFall.count
               {

                   let cur = currentFall[i]
                   CGContextSetFillColorWithColor(CTX, colors[cur.Color])
                   // 繪製矩形
                   CGContextFillRect(CTX, CGRectMake(CGFloat(cur.X * CELL_Size + STROKE_Width), CGFloat(cur.Y * CELL_Size + STROKE_Width), CGFloat(CELL_Size - STROKE_Width * 2), CGFloat(CELL_Size - STROKE_Width * 2)))

               }

               // 獲取快取區的圖片
               image = UIGraphicsGetImageFromCurrentImageContext()
               // 通知重新繪製
               self.setNeedsDisplay()

           }
   }

三、啟動遊戲

做完了上面的工作,你就可以啟動你的遊戲了,你的做的工作就有下面這些;

重置遊戲積分,將積分設定為 0

重置下落的速度,也將它設定為0

初始化俄羅斯方塊的狀態,將它們的值全都初始化為 0

生成一組在下落的方塊組

啟動計時器,控制下落的方塊

// MARK:開始遊戲
   func startGame()
   {

       self.curSpeed = 1
       self.delegate.UpdateSpeed(self.curSpeed)

       self.curScore = 0
       self.delegate.UpdateScore(self.curScore)

       // 初始化遊戲狀態
       self.initTetrisStatus()

       // 初始化四個正在下落的方塊
       self.initBlock()

       // 定時器控制下落
       curTimer = NSTimer.scheduledTimerWithTimeInterval(BASE_Speed/Double(curSpeed), target: self, selector: #selector(self.movedown), userInfo: nil, repeats: true)

   }

PS:一張遊戲執行圖片

四、寫在開發後

差不多到這裡也就結束了,但裡面有一個BUG,有些時候會發生一個陣列的越界導致的崩潰,這個問題有時間在好好看一下,自己寫的裡面可能還有我不知道的問題,也沒做大量的測試,感興趣的朋友可以自己好好完善一下,比如試試暫停,重新開始這些功能的。。反正肯定還有寫的不好的地方,有問題大家可以發訊息隨時交流!!

寫完了,說點無聊的,說說自己,其實在大學的時候,我打死也不可能相信自己將來會走上程式設計這條路,一個大一連C語言都掛科不懂得人。現在想想真的就是著實蛋疼。要是那時候上帝給我說一句,你將來要會是一個開發軟體的,我一定覺得是上帝瘋了。可轉眼工作也一年多了,慢慢的,我喜歡上了自己做的事,至少我自己覺得挺好的。工作第一,但也得給自己充充電,每天敲著程式碼 PS:還有加著班,但心裡踏實。沒有為碌碌無為,荒廢一天又一天感到不安!難道有什麼比你心裡踏實更重要的麼,當然你要是有鴻鵠之志,額~~~你還是得充電呀,,O(∩_∩)O哈哈~

最後就是完整程式碼。Git地址給大家。點選下載 Swift俄羅斯方塊完整程式碼

相關文章