見出し画像

【技術解説】Running Game ~走って走って走りまくれ〜

インタラクションデザイナーのMIZUTANI KIRIN (@mizutanikirin)です!普段はデザイニウムでKinectを中心にいろいろなセンサーを使ったインタラクションアプリ、体験型ゲームを作っています。今回は「Running Game」の実装方法について話していきます。

1. Runningアプリとは?

Nintendo SwitchのJoyConを足に装着し、その場走りをすると足の動きに応じて画面上のキャラクタが走る体感型Running Gameです。2020に向けて、リオデジャネイロをスタートし、世界中の都市を走り抜けてゴールのTOKYOを目指して走る、大人から子供まで楽しめるゲームになっています。

オリジナル版の他に大阪マラソン用にカスタマイズしたバージョンも作っています。

「Running Game」はJoyCon制御アプリをMaoが、フロントアプリはMIZUTANIが担当して作りました。今回どちらのアプリもUnityを使って作成しています。

まずはMaoが担当したJoyCon制御アプリの方から説明していきます。

2. JoyConの接続方法

JoyConコンソールとの接続はJoyconLibを使って接続しています。(※マルチコンソール対応のためにペアリング用のスクリプトを修正しました。)

3. スピードの求め方

(1) JoyConから加速度センサの値を取得し、ノイズデータに対して簡単なスムースフィルタをかけています。
(↓はジョイコンの加速度センサの生データ(x:赤, y:緑, z:青)、オレンジ色はxの単純なスムースフィルタテストです。)

画像1

const float LowPassFilterFactor = 0.2f;

void JoyConUpdate() {
    ...
    // Accel values:  x, y, z axis values (in Gs)
    accel = joycon.GetAccel();
    Vector3 filteredAccelValue = FilterAccelValue(true, accel);
    current_accel_value = filteredAccelValue.magnitude;
    ...
}

Vector3 FilterAccelValue(bool smooth, Vector3 accVal)
{
    if (smooth)
        lowPassValue = Vector3.Lerp(lowPassValue, accVal, LowPassFilterFactor);
    else
        lowPassValue = accVal;

    return lowPassValue;
}

(2) 次にフィルタリングされた加速度センサのベクトル値の大きさをキー値としてステップ検出を行います。
基本的な考え方は、2つの波のピーク間の持続時間を検出することです。

画像2

peakOfWave, valleyOfWave, isPeakを返すピーク検出関数を作成しました。次2つの方法で波の山と谷を検出しています。
- 方向とcontinueUpCountを設定するためにnewValue≥olderValueかどうかをチェックしています。
- 波頭は方向が下がっているときに発生し、この波の間に上昇するために少なくとも2回あります。(ノイズを除去するため)

bool DetectorPeak(float newValue, float oldValue)
{
   lastStatus = bDirectionUp;

   // wave up
   if (newValue >= oldValue)                 
   {
       bDirectionUp = true;
       continueUpCount++;
   }
   // wave down
   else {                                                           
       continueUpFormerCount = continueUpCount;
       continueUpCount = 0;
       bDirectionUp = false;
   }

   // 山
   if (!bDirectionUp && lastStatus
           && (continueUpFormerCount >= 2 && (oldValue >= minValue && oldValue < maxValue)))
   {
       peakOfWave = oldValue;
       return true;
   }
   // 谷
   else if (!lastStatus && bDirectionUp)
   {
       valleyOfWave = oldValue;
       return false;
   }

   return false;
}

ピーク検出は 1/(timeOfNow-timeOfLastPeak)で速度を計算できます。

画像3

しかし、ユーザがステップを踏むと大きな波と小さな波が発生するので、ノイズの問題も解決しなければなりません。
- 波形が十分に大きいことを確認する必要があります。(peakOfWave - valleyOfWave >= threadThreshold)
- 速度変化が急すぎないように、波の持続時間を平均化しています。今回は4つの波を平均しています。

void DetectNewStep(float values)
{
   if (last_accel_value <= 0f)
   {
       last_accel_value = values;
   }
   else
   {
       if (DetectorPeak(values, last_accel_value))
       {
           timeOfLastPeak = timeOfThisPeak;
           timeOfNow = Time.time;

           if (timeOfNow - timeOfLastPeak >= 0.1f
                   && (peakOfWave - valleyOfWave >= threadThreshold))
           {
               timeOfThisPeak = timeOfNow;
               aveSpeed = 1f / Peak_Valley_Thread(timeOfNow - timeOfLastPeak);
           }
       }
   }
   last_accel_value = values;
}
const int valueNum = 4;
...
public float Peak_Valley_Thread(float value)
{
   float tempThread = 1f;
   if (tempCount < valueNum)
   { 
       tempValue[tempCount] = value;
       tempCount++;
   }
   else
   { 
       tempThread = averageValue(tempValue, valueNum);
       for (int i = 1; i < valueNum; i++)
       {
           tempValue[i - 1] = tempValue[i];
       }
       tempValue[valueNum - 1] = value;
   }
   return tempThread;
}

public float averageValue(float[] value, int n)
{
   float ave = 0;
   for (int i = 0; i < n; i++)
   {
       ave += value[i];
   }
   ave = ave / valueNum;
   return ave;
}

このようにスピードを算出したデータをフロントアプリ側にOSCで送信しています。

4. フロントアプリ

次にMIZUTANIが担当したフロントアプリ側で特筆すべき部分を書いていきます。

1. スタート時のカメラの移動方法
スタート時にランナーを順に映し出すカメラの動きはiTweenのiTweenPathを使用しました。いろいろとAssetなど検討しましたが、微調整が簡単なのと一番手っ取り早いという理由でiTweenPathかなということで採用しました。

スクリーンショット 2020-04-16 18.29.41

画像4

iTweenPathはアタッチしたGameObjectの移動を補助してくれます。カメラを動かすときのコードは以下のようになります。

iTween.MoveTo(startCamera,
   iTween.Hash(
       "path", iTweenPath.GetPath("StartPath"),
       "time", startCameraTime,
       "easeType", iTween.EaseType.easeInOutSine,
       "orienttopath", false
   )
);

そこまで複雑でない動き+コードで書くのは面倒+微調整もしたい」というようなときは便利です。

2. ランキングについて
このゲームはランナーIDのListと、タイムのListを別に管理しています。タイムListを速い順に並べたときにランナーID Listも同時に変わってくれるような関数があれば嬉しいなということで以下のような関数を作っています。

[Serializable]
public class OrderData {
    public string id;
    public float value;
}

public List<OrderData> GetOrderList(List<string> idList, List<float> valueList) {

   List<OrderData> userDataList = new List<OrderData>();
   for (int i = 0; i < idList.Count; i++) {
       OrderData thisData = new OrderData();
       thisData.id = idList[i];
       thisData.value = valueList[i];
       userDataList.Add(thisData);
   }

   if(direction == Direction.Up) userDataList.Sort((a, b) => Math.Sign(b.value - a.value));
   else userDataList.Sort((a, b) => Math.Sign(a.value - b.value));

   return userDataList;
}

上のコードでは一旦idとvalue(タイム)をOrderDataに代入してそのあとラムダ式で速い順に並べるようにしています。この関数はKirinUtil/UtilのGetOrderList()で実現できます。

3. 大阪マラソン用にした作業
基本的にモデル・デザインの変更などが多かったのですが、ただ1つコードではないのですが、気をつけないといけないなと思ったことがありました。

沿道の観客を追加した際、レースが終わると微妙に観客一人一人の位置が変わっているという事象がありました。よくよく調べてみるとゴール後は観客が拍手するアニメーションに変わるのですが、拍手を1ループするごとにほんの少しZ軸方向に動いてしまうことがわかりコードで強制的に元の位置に戻るようなコードを書いて解決させました。

拍手するだけのモーションだから位置なんて変わらないだろう思い込んでいたので、原因を絞り込むのに思ったよりも時間がかかった思い出があります。

編集後記

広報のマリコです!子供たちが楽しそうに走ってる姿を見ると仕事中でも癒やされますね。あー、この必死な感じ。かわいい。さて、このRunning Gameは東京マラソンとかで採用されないかなぁとSNSでもつぶやいていたのですが、まさかの大阪マラソンイベントで採用!と聞いて凄く驚いた記憶があります。オリンピックも延期になったことですし、次はオリンピック関連イベントで採用されないかなぁ、と一応言うだけ言っておきます(笑)

The Designium.inc
Interactive website
遊んで学んで
Twitter
Facebook

この記事が気に入ったらサポートをしてみませんか?