SPARKFUL

Blog

Insights from the SPARKFUL teams

在 Unity 打造簡易的動作系統

Ryan Wandev-notes
name

在產品的介面中,適當的提供有意義並精細的動畫,實用性上能直覺的讓使用者理解目前系統的狀態、取得回饋、幫助探索產品,更能在情感上提供愉悅的體驗。此篇將介紹如何在 Unity 環境中建構一個簡易的動作系統,來幫助快速開發介面的動態效果。

動作系統 vs 動畫系統

Unity 遊戲引擎中已經擁有一 動畫系統 提供了豐富的功能,能夠在物件、角色或是不同的屬性之中安排腳本及動畫,其中 動畫控制器 Animator Controller 可以記錄目前正在播放的動畫片段,當作狀態機 (state machine) 控制片段之間如何切換或混合,系統也支援許多對於角色人型骨架的操作,譬如目標匹配 (Target Matching)、反向動力學 (Inverse Kinematics)...等,任何複雜、非完全剛性的物體都能透過此動畫系統建構出栩栩如生的動態。

不過對於做動態介面而言,其實並不適合使用這樣的動畫系統,這類系統為了維持及優化整個複雜的架構,在功能及效能之間總會遇到一些取捨,譬如為了處理動畫片段間的混合,儘管只是播放單一個動畫片段,系統仍會緩存相關的資料;又或是在繪製元件時,不論動畫片段是否正在播放,動畫系統仍會在每一幀將畫布 (Canvas) 標示為 Dirty,一不注意就會使許多不需要變動的元件一直重新繪製 (Rebuild),導致效能降低或讓畫面延遲。

除了需要長時間高頻率變動的物件,在開發上會盡量避免使用 Unity 的動畫系統,尤其是在 Canvas 中的介面物件,這類物件通常都是接收到事件才需要做出反應,並不需要不斷地去檢查、繪製。因此我們希望有個簡單的動作系統,能透過改變物件的各種屬性來呈現動作,並能夠控制動作的時間、狀態,以及多個動作間的發生順序,就像是 cocos2dx 裡的 action 亦或是 iOS spritekit 框架的 SKAction。譬如要使物件在一秒內往上移動 100 px 可以寫成:

var move = new FDMoveByAction(1f, new vector(0f, 100f));
actionBehaviour.run(move);

另一方面,也能方便我們開發者直接在程式碼中就能快速的設定動作參數,使物件位移、形變、旋轉......等,並不需要再切換視窗回到 Unity 的動畫編輯器編輯。


實作

在開始之前,先劃分了一下這個架構的需求:

  1. 基本控制:動作的播放、停止
  2. 定義動作所需時間
  3. 組合動作:允許多個動作同時或依序的發生
  4. 支援緩動曲線參數 (ease in/out)

1. 基本控制:動作的播放、停止

一開始先建立兩個基本類別:

  • [csharp]FDAction[/csharp]:最基本的動作類別
  • [csharp]FDActionBehaviour[/csharp]:繼承[csharp]MonoBehaviour[/csharp]的控制器,來控制[csharp]FDAction[/csharp]的播放與停止狀態。

如下:

class FDAction {

  protected GameObject m_TargetObject;

  public FDAction () {
  }

  public virtual bool Update () {
    return true;
  }

  public virtual void Run (GameObject targetObject) {
    m_TargetObject = targetObject;
  }
}
class FDActionBehaviour: MonoBehaviour {

  FDAction m_Action;
  bool m_Finished = false;

  public bool Finished {
    get {
      return m_Finished;
    }
  }

  bool m_IsRunning = false;

  void Update() {

    if (m_Finished) {
      return;
    }

    if (m_IsRunning && (m_Action == null || m_Action.Update())) {
      m_Finished = true;
      StopAction();
    }
  }

  public void RunAction(FDAction action) {

    m_IsRunning = true;

    m_Action = action;
    m_Action.Run(this.gameObject);
  }

  public void StopAction() {

    m_IsRunning = false;

    GameObject.Destroy(this);
  }
}

想要在外層類別呼叫動作播放時,只要呼叫 [csharp]FDActionBehaviour[/csharp] 的 [csharp]RunAction()[/csharp] 方法,並帶入要播放的 [csharp]FDAction[/csharp] ,[csharp]FDActionBehaviour[/csharp] 即會在每一個 frame 裡去檢查 [csharp]FDAction[/csharp] 的 [csharp]Update()[/csharp] 方法是否已回傳 true ,當確認播放完畢(收到 true)之後,即呼叫 [csharp]StopAction()[/csharp] 直接將此控制器回收。

例如:

void Play() {
  FDActionBehaviour actionBehaviour = this.gameObject.AddComponent ();
  actionBehaviour.RunAction(new FDAction());
}

如果有情況是播到一半想要直接強制停止,也可以直接對 [csharp]FDActionBehaviour[/csharp] 呼叫 [csharp]StopAction()[/csharp] ,即會停止所有的動作。

void ForceStop() {
  FDActionBehaviour actionBehaviour = this.gameObject.GetComponent ();

  if(actionBehaviour!=null && !actionBehaviour.Finished)
    actionBehaviour.StopAction();
}

2. 定義動作所需時間

接著定義完成一個動作所需要的時間,同樣的動作所需時間越長,動作就會越慢。

實作上,在每一幀更新的時候去確認動作目前經過的時間是否已經超過了設定的時間 (duration)。下面繼承了 [csharp]FDAction[/csharp] 來建立具判斷已過多久時間的類別 [csharp]FDFiniteAction[/csharp] :

class FDFiniteAction:FDAction {

  protected float m_Duration;
  protected float m_StartTime;

  public FDFiniteAction (float duration) {
    m_Duration = duration;
  }

  public override bool Update () {

    float elapsedTime = Time.time - m_StartTime;

    if (elapsedTime >= m_Duration) {

      UpdateAction (m_Duration);
      return true;

    } else {

      UpdateAction (elapsedTime);
      return false;
    }
  }

  public override void Run (GameObject targetObject) {

    base.Run (targetObject);
    m_StartTime = Time.time;
  }

  protected virtual void UpdateAction (float elapsedTime) {

  }
}

記下被呼叫[csharp]Run()[/csharp]的時間點,在每次被呼叫[csharp]Update()[/csharp]時去確認 elapsedime 是否超過了設定的時間長度,如果超過便回傳 true 代表動作已完成,還沒完成即連同目前已過的時間帶進 [csharp]UpdateAction[/csharp] 方法進行相對應的參數變化。

小試身手

有了這些基本類別,我們試試看在 [csharp]UpdateAction[/csharp] 中塞入不同的參數來創造各式不同的動作吧!

下面用三個平常最常用到的動作當作範例:

  • 位移
  • 縮放
  • 顏色淡入淡出

位移

藉由更改物件的 localPosition 來移動:

public class FDMoveToAction : FDFiniteAction {

  Vector3 m_StartPos;
  Vector3 m_Difference;
  Vector3 m_Destination;
  Transform m_TargetTrans;

  public FDMoveToAction (float duration, Vector3 destination) : base (duration) {
    m_Destination = destination;
  }

  public override void Run (GameObject targetObject) {
    base.Run (targetObject);

    if (m_TargetTrans == null) {
      m_TargetTrans = m_TargetObject.transform;
    }
    m_StartPos = new Vector3 (m_TargetTrans.localPosition.x, m_TargetTrans.localPosition.y, m_TargetTrans.localPosition.z);
    m_Difference = m_Destination - m_StartPos;
  }

  protected override void UpdateAction (float elapsedTime) {
    float progress = elapsedTime / m_Duration;
    m_TargetTrans.localPosition = m_StartPos + m_Difference * progress;
  }
}

縮放

藉由更改物件的 localScale 來做縮放效果:

public class FDScaleAction : FDFiniteAction {

  Vector3 m_FromScale;
  Vector3 m_ToScale;
  Vector3 m_Difference;
  Transform m_Target;

  public FDScaleAction (Transform target, float duration, Vector3 fromScale, Vector3 toScale) : base (duration) {

    m_Target = target;
    m_FromScale = fromScale;
    m_ToScale = toScale;
    m_Difference = m_ToScale - m_FromScale;

  }

  protected override void UpdateAction (float elapsedTime) {
    base.UpdateAction (elapsedTime);
    m_Target.transform.localScale = m_FromScale + m_Difference * elapsedTime / m_Duration;
  }
}

顏色淡入淡出

藉由更改物件的 CanvasGroup 更改 alpha 值:

public class FDCanvasFadeAction : FDFiniteAction {

  float m_FromAlpha;
  float m_ToAlpha;
  float m_Difference;

  CanvasGroup m_CanvasGroup;

  public FDCanvasFadeAction (float fromAlpha, float toAlpha, float duration) : base (duration) {

    m_FromAlpha = fromAlpha;
    m_ToAlpha = toAlpha;
    m_Difference = m_ToAlpha - m_FromAlpha;
  }

  public override void Run (GameObject targetObject) {
    base.Run (targetObject);
    m_CanvasGroup = targetObject.GetComponent ();
  }

  protected override void UpdateAction (float elapsedTime) {
    base.UpdateAction (elapsedTime);
    m_CanvasGroup.alpha = m_FromAlpha + m_Difference * elapsedTime / m_Duration;
  }
}


3. 組合動作:允許多個動作同時或依序的發生

現在能夠依據時間改變不同屬性的參數來建立出許多的動作了!然而在實際應用中,往往需要結合這些動作來呈現出細緻、有說服力的畫面,因此必須規範動作之間所發生的先後順序,根據動作之間發生的時間性,這裡分成了 序列動作 (sequence action)同步動作 (group action)

序列動作 (sequence action): 擁有多個子動作,每一個在清單裡的子動作會在前一個子動作完成後才播放。 同步動作 (group action): 擁有多個子動作,所有子動作會在同一時間一起播放。

實作上,為了保持外層在呼叫這些組合動作與單一動作一樣簡單,像是:

actionBehaviour.RunAction(sequenceActions);

這裏可以使用 組合模式 (composite pattern) 來實作。

组合模式的特點即是讓外層類別 (客戶端) 可以像處理單一對象一樣的來處理複雜對象,譬如說,數個單一動作可以變成一組合動作(也可以將組合動作想成是一個動作清單),組合動作也可以包含其他組合動作,甚至在之中加入單一的動作。以下面的例子而言,這個序列動作裡面包含了 move up 及一個 block action,也加入了同時觸發淡出及移動的同步動作。

public class FDGroupAction : FDAction {

  List m_Actions;

  public FDGroupAction (List actions) : base () {
    m_Actions = actions;
  }

  public override void Run (GameObject targetObject) {
    base.Run (targetObject);

    foreach (var action in m_Actions) {
      action.Run (targetObject);
    }
  }

  public override bool Update () {

    int index = 0;
    while (index  m_Actions;
  FDAction m_CurrentAction;

  public FDSequenceAction (List actions) : base () {

    m_Actions = actions;

    m_CurrentAction = m_Actions [0];
    m_Actions.RemoveAt (0);
  }

  public override void Run (GameObject targetObject) {
    base.Run (targetObject);

    m_CurrentAction.Run (targetObject);
  }

  public override bool Update () {

    if (m_CurrentAction.Update ()) {

      if (m_Actions.Count == 0) {

        m_Finished = true;
        return true;

      } else {

        m_CurrentAction = m_Actions [0];
        m_Actions.RemoveAt (0);

        m_CurrentAction.Run (m_TargetObject);
      }
    }

    return false;
  }
}

小試身手

以下對話框跳出的展開及淡出效果即能用上述的組合出來囉!


4. 支援緩動曲線參數

在實作動態介面時加入緩動曲線,這不僅是將現實中的物理特性帶入虛擬螢幕中讓畫面更真實,它往往也是引導使用者視線、提供 affordance 的最佳利器;一些簡單的的原則,像是會在重要或是需要強調的介面元件顯示時放慢速度,這能夠有效地抓住使用者視線;對話框跳出或是新頁面刷入時,會加快速度讓使用者不覺得延遲地看到內容,而離場時的加快也能讓當下已經不重要的畫面加速消失,減輕認知負擔。相關的原則及經驗可以參考 google material design 整理出來的 guideline,能幫助未來在開發上注意到更多的小細節哦!

在實作動態介面時加入緩動曲線,這不僅是將現實中的物理特性帶入虛擬螢幕中讓畫面更真實,它往往也是引導使用者視線、提供 affordance 的最佳利器。

針對不同的專案及效果,我們需要許多不同的函數,這邊就先列舉幾個最常用的當做例子:

  • Ease In
  • Ease Out
  • Ease InOut
  • EaseInBack
  • EaseOutBack
public static float EaseIn (float progress) {

  return progress * progress;
}

public static float EaseOut (float progress) {

  return progress * (2 - progress);
}

public static float EaseInEaseOut (float progress) {

  progress = progress * 2f;

  if (progress = m_Duration) {

    UpdateAction (m_Duration);
    m_Finished = true;

    return true;

  } else {

    UpdateAction (m_Duration * m_TimingModifier.Modify (elapsedTime / m_Duration));

  }

  return false;
}
public class FDTimingModifier {

  public virtual float Modify (float progress) {

    return progress;
  }

  public static FDTimingModifier Create (FDActionTiming timing) {

    switch (timing) {
      default:
        return new FDTimingModifier ();
      case FDActionTiming.EaseIn:
        return new FDTimingEaseInModifier ();
      case FDActionTiming.EaseOut:
        return new FDTimingEaseOutModifier ();
      case FDActionTiming.EaseInEaseOut:
        return new FDTimingEaseInEaseOutModifier ();
      case FDActionTiming.EaseInBack:
        return new FDTimingEaseInBackModifier ();
      case FDActionTiming.EaseOutBack:
        return new FDTimingEaseOutBackModifier ();
    }
  }
}

public class FDTimingEaseInModifier : FDTimingModifier {

  public override float Modify (float progress) {

    return FDMath.EaseIn (progress);
  }
}

public class FDTimingEaseOutModifier : FDTimingModifier {

  public override float Modify (float progress) {

    return FDMath.EaseOut (progress);
  }
}

public class FDTimingEaseInEaseOutModifier : FDTimingModifier {

  public override float Modify (float progress) {

    return FDMath.EaseInEaseOut (progress);
  }
}

public class FDTimingEaseInBackModifier : FDTimingModifier {

  public override float Modify (float progress) {

    return FDMath.EaseInBack (progress);
  }
}

public class FDTimingEaseOutBackModifier : FDTimingModifier {

  public override float Modify (float progress) {

    return FDMath.EaseOutBack (progress);
  }
}

這樣就可以做出具有緩動曲線的動態囉!


結語

一般的動作系統還包括了許多功能,例如:

  • 呼叫 block
  • 重複播放動作
  • 反向播放動作
  • 更換序列圖片 ...等

這邊就先不提供範例了,大家可以嘗試實作玩玩看,也可以跟設計師們一起發想好玩的效果,挑戰細緻並有意義的動態介面設計:

隨著專案跟不同的設計,將這些做過的 action 整理成函式庫,不僅能快速打造 prototype 進行討論,也有利於在不同專案中實作具一致性的效果;最後如果有想到什麼很棒的介面設計,歡迎跟我們分享!


參考文獻

  1. Cocos2d-x: Actions
  2. SKAction - SpriteKit | Apple Developer Documentation
  3. Image via ZhipengYa, CC Licensed.
Ryan Wan
Ryan Wan
Developer @ Fourdesire. Focus on creating appealing and meaningful digital context. Love Swiss Design and mind bending films :D
divider
Related Posts
name

用 Particle System 實作模糊效果的圓形進度條

實現圓形進度條的方式有很多種,最簡易的方式就是調整 Image 的參數: Image Type - Filled 、Fill Method - Radial 360、Fill Origin - Top,這篇文章 裡有更詳細的介紹。但這個方式無法符合所有設計稿的需求,例如模糊效果、圓角、流動效果...Read more
name

在 Unity 裡做 TDD -- FDMonoBehav Framework 的誕生

這篇文章主要是在說明我們如何在 Unity 專案中導入 TDD 開發方法。開發環境主要使用 C# 語言在 Unity 2017.4.4f1 + Visual Studio for Mac Community 7.5.1,並使用 NUnit 和 NSubstitute 來作為開發單元測試專案...Read more
name

Unity - iOS TableView 完美移植

TableView 在 app 的開發中可以說是最常使用到的元件了,不論是純遊戲或是應用工具類型都缺少不了它,甚至不少 app 開發的第一課就是嘗試製作一個簡易的 TableView。在 Unity 中雖然有提供 UGUI,但使用起來總是覺得不順手,既然如...Read more