2014年9月4日 星期四

Unity3D - Circular Scrolling List - Part 1 - 移動概念

實作影片:https://youtu.be/iZSN6CC--9Y

這個 Scrolling List 有幾個特色:
  • 使用固定數量的選單物件來代表無限數量的內容
  • 弧形移動效果
  • 慣性移動效果
將它分成幾的部分來教學:
  • 滑鼠動作
  • 慣性滑動
  • 多個物件同時移動
  • 邊界處理及環狀功能
  • 弧形移動

滑鼠動作


這個實作是以 y 軸的方向為主,每個選單物件的移動以滑鼠的 y 軸變化量為依據。所以一開始先讓選單物件有取得滑鼠位移的功能。
using UnityEngine;

public class ListBox : MonoBehaviour
{
    private Vector3 lastInputWorldPos;    // 上一個 frame 的滑鼠位置(world space)
    private Vector3 currentInputWorldPos; // 現在這個 frame 的滑鼠位置(world space)
    private Vector3 deltaInputWorldPos;   // 位移量(world space)

    void Update()
    {
        // Click start. Initialize the last world position
        // of the mouse position.
        if ( Input.GetMouseButtonDown(0) )
        {
            lastInputWorldPos
                = Camera.main.ScreenToWorldPoint( Input.mousePosition );
        }
        // Get the delta value of the mouse position and
        // move the ListBox with the same value.
        else if ( Input.GetMouseButton(0) )
        {
            currentInputWorldPos
                = Camera.main.ScreenToWorldPoint( Input.mousePosition );
            deltaInputWorldPos = new Vector3( 0.0f,
                             currentInputWorldPos.y - lastInputWorldPos.y,
                             0.0f );
            updatePosition( deltaInputWorldPos );
            lastInputWorldPos = currentInputWorldPos;
        }
    }

    void updatePosition( Vector3 deltaPosition )
    {
        transform.position += deltaPosition;
    }
}

Vector3 ScreenToWorldPoint( Vector3 position ) ; 

由於 transform.position 是使用 world 空間座標系,而 unity3D 回傳的滑鼠座標是 screen 座標系,所以使用 ScreenToWorldPoint 函式來幫助轉換。

想法

開始按下左鍵
        如圖,在滑鼠開始按下去時,要等到下一個 frame 才會有正確的位移值,利用 Input.GetMouseButtonDown(0) ,當滑鼠左鍵被開始按下時會回傳 true,藉此更新 lastInputWorldPos。如果沒有這一步,程式會使用上一次按住滑鼠左鍵的最後位置,來計算滑鼠按住時的位移量,得到的結果會是錯誤的。

持續按住左鍵
        Input.GetMouseButton(0) 在滑鼠左鍵按住時會回傳 true ( GetMouseButtonDown() 只有在滑鼠左鍵按下的瞬間才會回傳 true ),用來持續取得左鍵按下時的滑鼠位置,並計算滑鼠的 y 軸位移量,計算完畢後將 lastInputWorldPos 更新為 currentInputWorldPos。

移動物件
         最後將得到的位移量直接加到選單物件的 position 上,就可以達到依據滑鼠左鍵按下時的 y 軸位移量來移動物件的功能。

注意:在條件判斷中,GetMouseButtonDown(0) 一定要在 GetMouseButton(0) 前,是因為 GetMouseButton(0) 只要在左鍵按下時就會回傳 true,如果 GetMouseButtonDown(0) 放在其後,就永遠不會被判斷到。

慣性滑動


加入慣性滑動的效果就不會讓選單的移動上看起來死板板的,在視覺上有很好的感覺。
先加入兩個 global 變數來控制慣性滑動:
private bool keepSliding = false; // 要不要執行慣性滑動?
private int slidingFrames;        // 慣性滑動的 frame 數
再來在 Update() 中增加這個功能
void Update()
{
    .....
    else if ( Input.GetMouseButton(0) )
    {
        ......
        deltaInputWorldPos = new Vector3( 0.0f,
                         currentInputWorldPos.y - lastInputWorldPos.y,
                         0.0f );
        updatePosition( deltaInputWorldPos );

        keepSliding = true;
        slidingFrames = 20;

        lastInputWorldPos = currentInputWorldPos;
    }
    // Slide effect
    else if ( keepSliding )
    {
        --slidingFrames;
        if ( slidingFrames == 0 )
        {
            keepSliding = false;
            return;
        }
        updatePosition( deltaInputWorldPos );
        deltaInputWorldPos = deltaInputWorldPos / 1.2f;
    }
}
         在滑鼠左鍵按下時,判斷式只會判斷到 Input.GetMouseButton(0),當滑鼠左鍵放開時,由於 keepSliding 被設為 true,所以會執行 keepSliding 判斷式內的內容。
         慣性滑動的量依據上一次按住滑鼠左鍵的最後一次位移量,每個 frame 這個值都會被除以 1.2,所以每經過一個 frame,位移量就會減少,就可以達到慣性位移的效果。至於慣性位移要執行幾個 frame 以及每個 frame 要減少多少位移量(用減法也可以),都可以依照自己的喜好。

多個物件同時移動


一開始必須決定每個物件的起始位置,個人採用以螢幕高度 ( height ) 為基礎,可以避免因為平台視窗大小不一,而造成的一些問題。需要新增幾個 global 變數:
public int listBoxID;            // 這個 ListBox 的編號
public int numOfListBox;         // 全部的 ListBox 數量,奇數為佳

private Vector2 maxWorldPos;     // World 座標系的最大座標值
private Vector2 unitWorldPosY;   // 將 World 座標分成數份,每一份的 Y 值
在 Start() 函式中初始化 maxWorldPos 以及 unitWorldPosY,並計算出每一個物件的初始位置。
void Start()
{
    ......

    // Get the maximum of the world position.
    maxWorldPos = (Vector2) Camera.main.ScreenToWorldPoint(
        new Vector2( Camera.main.pixelWidth, Camera.main.pixelHeight) );

    // Equally divide the range Y
    unitWorldPosY = new Vector2( 0.0f, maxWorldPos.y / 3.0f );

    // Set the initial position accroding to ListBoxID
    updatePosition( new Vector3( 0.0f,
       unitWorldPosY.y * (float)( listBoxID * -1 + (int)numOfListBox / 2 ) ) );
}

Screen 座標系 與 World 座標系

 
        如圖,藍色的 Screen 座標系以 Camera 的左下角為 ( 0,0 ),右上角為最大值。透過 pixelWidth 及 pixelHeight 取得 Camera 座標的最大值。注意,Camera 的位置必須是設定為 ( 0, 0 ) 才會有正確的結果。如此一來,紅色的 World 座標系原點會在 Camera 的正中央。
        再利用 ScreenToWorldPoint() 函式來取得 MaxWorldPos,至於 MinWorldPos,因為在 Camera view 中 world 座標系是對稱的,所以 MinWorldPos = -MaxWorldPos。

分配物件的位置


        由於要去除平台螢幕大小不同的問題,所以以 MaxWorldPos 的 y 值為基礎等分,並存入 UnitWorldPos 裡。在這裡是將 0 到 MaxWorldPos.y 做三等分,不過可以依照選單物件的大小來調整這個值,例如:3.2或2.5。
        選單物件的 pivot point 皆在物件的正中央,以圖為例:每個選單物件皆有編號 ( listBoxID ),而且有 7 個 ( numOfListBox ) 選單物件,所以 ID 0 的 y 位置為 +3 個 UnitWorldPosY.y,ID 1 的 y 位置為 +2 個 UnitWorldPosY.y,而 ID 3 在正中央,至於 ID 6 則在 -3 個 UnitWorldPosY.y。依照此規律,可以得出單位位置對應 listBoxID 為:listBoxID * -1 +(int) numOfListBox / 2。(註:利用 int 除 int 會得到結果的整數部份的特性)
        計算出對應的位置後,在 Start() 中呼叫 initialPosition() 來設置初始位置。
        因為總有一個選單物件需要置中來標記使用者想要選取的項目,所以建議選單物件為奇數個。
/* Initialize the position of the list box accroding to its ID. */
void initialPosition( int listBoxID )
{
    transform.position = new Vector3( 0.0f,
        unitWorldPosY * (float)( listBoxID * -1 + numOfListBox / 2 ),
        0.0f );
}

邊界處理及環狀功能


再來就是這個 Scrolling list 的核心功能,每個物件到達邊界時,要會從另一端出現,也就是說當物件超出自定的上界時,要從下方出現,反之亦然。

環狀功能


       以 7 個選單物件為例,由左到右,模擬選單物件上移時的情況,將上界定為 +4 UnitWorldPosY.y (如圖左),當 ListBox0 的 pivot point 超過上界時 (如圖中) ,應該要出現再 -3 UnitWorldPosY.y 的位置 (如圖右)。比較圖左及圖右,可以發現選單物件的順序做了循環。同理,物件下移時,必須將下界定在 -4 UnitWorldPosY.y 的位置。

邊界處理(重要!)

邊界處理是影響這個 Scrolling List 可不可以如預期一樣的運作的關鍵。當初就是邊界處理沒有弄好,造成選單物件分崩離析 Orz。
       由於程式是在每個 frame,依照滑鼠左鍵按住的位移量直接更新物件位置,所以當選單物件到達邊界時,其 pivot point 並不一定會剛好在邊界上,大多時候都是超出邊界。


       所以當選單物件超出邊界時,必須去計算它超出了多少距離,並在另一端補上同樣的距離。右圖的貪食蛇幫助理解概念,當蛇頭超出上界兩單位時,就在另一端補上兩單位。

由於上下界的值會被頻繁使用到,所以選擇使用變數來儲存,亦可使用 static 變數,避免每次都要再計算一次。
private float lowerBoundWorldPosY;     // 下界位置
private float upperBoundWorldPosY;     // 上界位置
private float rangeBoundWorldPosY;     // 從邊界到另一端出現點的距離
在 Start() 中初始化:
void Start()
{
    ......
    unitWorldPosY = new Vector2( 0.0f, maxWorldPos.y / 3.0f );

    // Calulate the boundary position
    lowerBoundWorldPosY = unitWorldPosY * (float)( -1 * numOfListBox / 2 - 1 );
    upperBoundWorldPosY = unitWorldPosY * (float)( numOfListBox / 2 + 1 );
    rangeBoundWorldPosY = unitWorldPosY * (float)numOfListBox;

    ......
}
再來新增 checkBoundary() 函式,並在 initialPosition() 及 updatePosition() 中呼叫:
/* Check if the world position of the GameObject is out of the boundary.
 * If does, make the GameObject appear at the other slide.
 */
void checkBoundary()
{
    float beyondWorldPosY = 0.0f;

    if ( spriteRenderer.transform.position.y < lowerBoundPosY.y )
    {
        beyondWorldPosY =
            ( lowerBoundWorldPosY - transform.position.y ) % rangeBoundWorldPosY;
        transform.position = new Vector3(
            transform.position.x,
            upperBoundWorldPosY - unitWorldPosY - beyondWorldPosY,
            transform.position.z );
    }
    else if ( spriteRenderer.transform.position.y > upperBoundPosY.y )
    {
        beyondWorldPosY =
            ( transform.position.y - upperBoundWorldPosY ) % rangeBoundWorldPosY;
        transform.position = new Vector3(
            transform.position.x,
            lowerBoundWorldPosY + unitWorldPosY + beyondWorldPosY,
            transform.position.z );
    }

    updateXPosition();
}
       以超出上界為例:這裡是 7 個選單物件,當選單物件超過上界時,要讓它在 -3 UnitWorldPosY.y 的地方出現,而下界的值是 -4 UnitWorldPosY.y,所以必須再加上一個 UnitWorldPosY.y,最後再補償超出的距離。至於再計算超出距離要取餘數,理由是如果超出上界的距離比上界到出現位置的距離還大時,應該還要再做一次邊界檢查,取餘數的話,可以幫助這個動作。

弧狀移動


弧狀移動的話就是以物件的 y 位置來決定 x 位置,並加上三角函數的幫助:
/* Calculate the x position accroding to the y position.
 */
void updateXPosition()
{
    transform.position = new Vector3(
        maxWorldPos.x * 0.5 - maxWorldPos.x * 0.2f * Mathf.Cos( transform.position.y / upperBoundWorldPosY * Mathf.PI / 2.0f ),
        transform.position.y,
        transform.position.z );;
}
       在更新 y 位置後的程式碼呼叫 updateXPositiion(),一是 updatePosition(),二是 checkBoundary() 的兩個條件判斷的 statement 中。


  從圖中可以知道,當 ListBox 越往中間,與最右 x 位置的差距越大。再來是無論與正負,對於相同的 y 位置(取絕對值,其差距都是一樣的。這個特性與 Cos 函數在 +90度 到 -90度 的變化是一樣的。所以可以導出這樣的式子:

                                                                                      物件的 y 位置
x 位置 = 最右 x 位置 - (|最左到最右的 x 距離|) X cos( -------------------- X Pi/2 )
                                                                                  ListBox上界 y 位置

       如此一來選單物件的 x 位置只會在 0.3 maxWorldPos.x 到 0.5 maxWorldPos.x 之間。如果將式中的負改為正,則整個選單就會向右彎曲。


2014/09/04 9:16 pm 更新
原本筆者是使用 localPosition 來控制選單物件的位置,但是後來查 API,應該使用 position 來控制位置。由於 localPosition 是相對於 parent GameObject 的位置,而 position 才是物件在 world 座標系的絕對位置。筆者在測試時,因為這些選單物件並沒有任何 parent GameObject,所以 localPosition 與 position 的效果是一樣的。

最後先附上到目前為止的程式碼:Github

教學還沒結束((躺,能看到這裡真得很有耐心(感動)。接下來談談如何顯示內容:Part 2

如果有任何問題或不足的地方,歡迎留言提問,感謝~

沒有留言:

張貼留言