2019年7月10日 星期三

[筆記] 矩形的碰撞偵測 (中篇) - 反彈

  處理完碰撞後,接著就要來處理碰撞後的反彈。因為做的是打磚塊這類 pixel game,所以呈現的是簡單反彈,也就是反彈物體速度的 xy 分量的正負號變換(呈現出來是入射角 = 反射角,反彈後速率維持不變)。
  這個功能困擾的我很久,困難點在於如何判斷是哪個面發生碰撞。期間一直無法做出理想中的反彈效果。後來拋出問題向朋友求救,討論後終於得到解答,雖然想出來的演算法不盡完美,但大部分的情況下都能有預想中的反彈行為。


判斷碰撞發生面


  當碰撞發生時,如果可以知道兩的矩形是從哪個面碰撞的,就可以決定反彈方向。但是卻不能夠以碰撞發生當下的狀態來判斷碰撞發生面。如下圖:


左圖中間的藍色矩形與白色矩形是碰撞發生時的狀態,藍色矩形是會反彈的物體。直覺上會想要利用藍色矩形與白色矩形重疊的邊,來判斷藍色矩形應該是往下反彈。然而事情沒有那麼美好,如右圖所示:藍色矩形也有可能從上方移動過來,如果做了往下反彈,就會出現神奇現象了。

要是其中一個矩形太大,連哪個邊有碰到都無法判定。

  跟朋友討論出來的解法是,要是知道碰撞前一刻兩個矩形的相對位置,就可以判定碰撞發生在哪一個面。接下來解說設定碰撞時的兩個矩形:會反彈的矩形稱為球(像是打磚塊遊戲的球)、不會反彈的矩形稱為平台(像是打磚塊遊戲的平台)。

決定相對位置


  延續前一篇,矩形由四個值 top、bottom、left、right 訂出它在場景中的位置,藉由這四個值可以很容易地決定相對位置:
  • 球在平台的上方:ball.bottom < platform.top
  • 球在平台的下方:ball.top > platform.bottom
  • 球在平台的左方:ball.right < platform.left
  • 球在平台的右方:ball.left > platform.right
如果碰撞前一刻球在平台的上方,可以推得球應該要往上方彈;如果球在下方,則球要往下反彈。可以知道如果球在平台的上方或下方時,是球的 y 方向速度要反轉,同理可推得 x 方向的速度變化。
  由於整個演算法觸發的時機為碰撞發生時,所以會取得碰撞當下的資訊。而這一刻的位置為這一刻的速度加上前一刻的位置,則碰撞前一刻的位置為:$$Pos_{t-1} = Pos_t - V_t$$以此撰寫對應函式,要注意球是可以同時在平台的左方與上方的(即左上方):
def ball_bounce_off(ball_rect, ball_speed, platform_rect, platform_speed):
    """
    計算並回傳球撞擊平台後球的新速度

    @param ball_rect 撞擊當下球的位置。為 pygame.Rect
    @param ball_speed 撞擊當下球的速度。為一個 list [x, y]
    @param platform_rect 撞擊當下平台的位置。為 pygame.Rect
    @param platform_speed 撞擊當下平台的速度。為一個 list [x, y]
    """
    # 計算前一刻的位置
    ball_rect_last = ball_rect.move(-ball_speed[0], -ball_speed[1])
    platform_rect_last = platform_rect.move(-platform_speed[0], -platform_speed[1])

    ball_speed_new = ball_speed.copy()
    # 判定相對位置
    if ball_rect_last.bottom < platform_rect_last.top or \
       ball_rect_last.top > platform_rect_last.bottom:
        ball_speed_new[1] *= -1

    if ball_rect_last.right < platform_rect_last.left or \
       ball_rect_last.left > platform_rect_last.right:
        ball_speed_new[0] *= -1

    return ball_speed_new

位置修正


  在碰撞發生時,除了決定反彈的方向之外,還有位置需要修正。由於球與平台在碰撞時不會嵌入其中,但是碰撞發生時得到的是兩個互相交疊的矩形,因此必須要把球從平台「排出」。
排出的方向一樣要由碰撞前一刻的相對位置來決定。如果撞擊前一刻球在平台的上方,則球排出後的位置一定會在平台的上方(因為球是往下撞擊平台的上平面,反彈後的位置一定在此之上)。排出的方法有兩種,一是簡單的將球移到平台的邊緣與其相切,二是考慮球嵌入平台的距離。後者在物理上比較正確。

差異


  兩種演算法在物件移動比較快的時候會有明顯的視覺差異。下圖是單純的移到與平台相切,看起來像球被平台拖住了。
再與考慮嵌入平台的距離的算法比較,球看起來反彈的比較乾脆:
兩者除了視覺上的差異之外,因為位置修正算法的不同,反彈後球的路徑也會有所不同。

移到與平台相切


  這個做法就像是把球剛好擠到平台外,前一刻球的相對位置在哪裡,就直接往那個方向擠,例如:如果碰撞前一刻球在平台的上方,則要往上移動,讓球的下方與平台的上方相切。下圖橘色表示上一刻球的位置,圖左表示移動的過程,圖右則是看到的移動結果:
依照不同的相對位置,有不同的移動方向:
def ball_bounce_off(ball_rect, ball_speed, platform_rect, platform_speed):
    """
    計算並回傳球撞擊平台後球的新位置與速度

    @param ball_rect 撞擊當下球的位置。為 pygame.Rect
    @param ball_speed 撞擊當下球的速度。為一個 list [x, y]
    @param platform_rect 撞擊當下平台的位置。為 pygame.Rect
    @param platform_speed 撞擊當下平台的速度。為一個 list [x, y]
    """
    # 計算前一刻的位置
    ball_rect_last = ball_rect.move(-ball_speed[0], -ball_speed[1])
    platform_rect_last = platform_rect.move(-platform_speed[0], -platform_speed[1])

    ball_rect_new = ball_rect.copy()
    ball_speed_new = ball_speed.copy()
    # 判定相對位置,將球移動到剛好與平台相切
    if ball_rect_last.bottom < platform_rect_last.top:
        # 球在平台的上方
        ball_rect_new.bottom = platform_rect.top
        ball_speed_new[1] *= -1
    elif ball_rect_last.top > platform_rect_last.bottom:
        # 球在平台的下方
        ball_rect_new.top = platform_rect.bottom
        ball_speed_new[1] *= -1

    if ball_rect_last.right < platform_rect_last.left:
        # 球在平台的左方
        ball_rect_new.right = platform_rect.left
        ball_speed_new[0] *= -1
    elif ball_rect_last.left > platform_rect_last.right:
        # 球在平台的右方
        ball_rect_new.left = plarform_rect.right
        ball_speed_new[0] *= -1

    return ball_rect_new, ball_speed_new

考慮嵌入的距離


  以連續的移動過程來看,照理來說球一碰到平台就要立刻反彈,但是在遊戲中,每一幀之間都是一下子移動一段距離,當球意識到自己碰撞到平台了(也就是檢查碰撞的時候),球早已嵌入平台之中,所以必須要把多跑的距離補還給球,才會符合常理。
  如下圖,假設球移動的速度是 7 單位 / 幀,圖左是連續的移動過程,深橘色代表球碰撞到平台要開始反彈的位置,最後實線框的矩形是球最後的位置。右圖則是遊戲中實際移動的情況,每一幀會直接移動 7 單位,實線藍色框是球移動後的位置,可以看到球比應該要反彈的位置(深藍色)晚了一段距離,而要補償的距離與嵌入的距離有關。

  要計算嵌入距離會與碰撞發生面有關,例如:球碰到平台的上平面,球理應往上彈(y 方向速度反轉),但球會繼續往下跑,所以球往下跑多少距離,就從碰撞發生面往上補多少距離。
所以相比於上一個方法,除了「排出」之外,還要再加上嵌入的距離:
def ball_bounce_off(ball_rect, ball_speed, platform_rect, platform_speed):
    """
    計算並回傳球撞擊平台後球的新位置與速度

    @param ball_rect 撞擊當下球的位置。為 pygame.Rect
    @param ball_speed 撞擊當下球的速度。為一個 list [x, y]
    @param platform_rect 撞擊當下平台的位置。為 pygame.Rect
    @param platform_speed 撞擊當下平台的速度。為一個 list [x, y]
    """
    # 計算前一刻的位置
    ball_rect_last = ball_rect.move(-ball_speed[0], -ball_speed[1])
    platform_rect_last = platform_rect.move(-platform_speed[0], -platform_speed[1])

    ball_rect_new = ball_rect.copy()
    ball_speed_new = ball_speed.copy()
    # 判定相對位置,並考慮嵌入的距離
    if ball_rect_last.bottom < platform_rect_last.top:
        # 球在平台的上方
        ball_rect_new.bottom = platform_rect.top \
            - (ball_rect.bottom - platform_rect.top)
        ball_speed_new[1] *= -1
    elif ball_rect_last.top > platform_rect_last.bottom:
        # 球在平台的下方
        ball_rect_new.top = platform_rect.bottom \
            + (platform_rect.bottom - ball_rect.top)
        ball_speed_new[1] *= -1

    if ball_rect_last.right < platform_rect_last.left:
        # 球在平台的左方
        ball_rect_new.right = platform_rect.left \
            - (ball_rect.right - platform_rect.left)
        ball_speed_new[0] *= -1
    elif ball_rect_last.left > platform_rect_last.right:
        # 球在平台的右方
        ball_rect_new.left = plarform_rect.right \
            + (platform_rect.right - ball_rect.left)
        ball_speed_new[0] *= -1

    return ball_rect_new, ball_speed_new

結語


  上述介紹的方法其實還可以更好,就是當球前一刻的位置判定是同時在平台的左方與上方時,到底是先碰到左平面還是上平面,這會影響到球反彈的方向。但是原本的演算法在遊戲運作中看起來沒有明顯的錯誤,就沒有繼續把它改得更好了。

沒有留言:

張貼留言