カンフーパペットをゲームコントローラーにする

モーターひとつで複雑な動きと音を出すことで
ものづくり界隈では有名な、こぐまのトンピー。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59911/569ddffd-0889-ab8c-1e07-febe23c13223.png)
————でおなじみイワヤさんから、
カンフーパペットとってもたのしいおもちゃが発売中です。

<iframe width="560" height="315" src="https://www.youtube.com/embed/Je8PsMT1H-o?si=N-TKeV2az9NBkbUz" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>


今回はこちらをゲームのコントローラーにしていきたいと思います。
なお、現在Amazon等で結構お安くなっております。
[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59911/56966af1-15e2-1a8d-259a-037f98e22de0.png)](https://www.amazon.co.jp/%E3%82%A4%E3%83%AF%E3%83%A4-%E3%82%AB%E3%83%B3%E3%83%95%E3%83%BC%E3%83%91%E3%83%9A%E3%83%83%E3%83%88-%E3%82%BF%E3%82%A4%E3%82%AC%E3%83%BC-2023%E5%B9%B4%E6%97%A5%E6%9C%AC%E3%81%8A%E3%82%82%E3%81%A1%E3%82%83%E5%A4%A7%E8%B3%9E%E3%82%A2%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3%E3%83%BB%E3%83%88%E3%82%A4%E9%83%A8%E9%96%80%E5%84%AA%E7%A7%80%E8%B3%9E%E5%8F%97%E8%B3%9E%E5%95%86%E5%93%81-3265-2/dp/B0C61GXQ75/ref=asc_df_B0C61BLP3Z/?tag=jpgo-22&linkCode=df0&hvadid=668655351569&hvpos=&hvnetw=g&hvrand=11600259028258040479&hvpone=&hvptwo=&hvqmt=&hvdev=c&hvdvcmdl=&hvlocint=&hvlocphy=1009309&hvtargid=pla-2391909132872&mcid=df209570d53c35b68c27e5fcf89facfe&th=1)

## カンフーパペットを分解する
個人的には今回の全行程の中で一番の難関でした。

ドライヤーをあてながら接着剤を溶かしつつ、ヒジの部分を肩のほうに移動させてくぐらせて服を脱がせていく、という作業を根気よくゆっくり行います。

うまく脱がせることができると頭ごときれいに取れるようなのですが、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59911/ff8e7f0d-6482-e229-910d-38f6fd353711.png)

自分はどうしても頭のぬいぐるみ部分が取れず、頭を残したまま胴体だけ取り出しました。![IMG_3537.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59911/9f37cefa-e30d-34ea-d54c-14283d6b8449.jpeg)


また、こぶしの先はぬいぐるみ部分と紐で結びつけてあるので、この紐も切ってしまいました。器用な人なら紐を残したまま作業できるかもしれません。

## 内部構造を確認する
内部構造を見てみると、その堅牢にして繊細な機構にあらためて感心します。さすがイワヤさん！
レバーを押すと腕が伸び、連動して口も動きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59911/a299d30a-0fda-bf52-412c-bb238ee763ab.png)

また、このおもちゃはマイコンを使ったギミックが搭載されていて、
手を伸ばすと画像赤丸部分のスイッチが入り、「アチョー！」と声を発ます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59911/408826c0-162a-34cc-0339-eface35970eb.png)

## 構成
今回はこの「左右の手を出す」機能をゲームのコントローラーとして利用するために、M5AtomLiteを使用します。
ぬいぐるみ側のギミックを残しつつコントローラー化するために、「アチョー」と音が出るスイッチ部分をそのまま利用し、M5AtomのIOに入力します。
入力された信号はWi-Fi AP化したM5AtomLiteを通じてPCに送られ、PC側からはHitなどアクションの結果をM5AtomLiteに返します。
M5AtomLiteは返ってきた結果をもとに振動モーターを動かします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59911/84473cf1-3088-971a-8a36-d17bb7c7ad20.png)

## コントローラー部分の作成
コントローラー部分は両腕のスイッチおよび振動モーターをM5AtoLiteの21,22,25の各ピンに接続し、小型ブレッドボードに固定してぬいぐるみフレームのおなかの部分に貼り付けました。
ある程度雑に扱われても配線が抜けないように、輪ゴムやテープで保護してあります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59911/6df679ef-ad84-d13d-b8a2-e8d806d265a8.png)

<details><summary>controller.cpp</summary>

```
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <Adafruit_NeoPixel.h>
#include <TmDeltaTime.hpp>

//#define TMDEBUG true
#define HOST_NAME   "192.168.4.2"
#define HOST_PORT   (7001)
#define RECV_PORT   (7002)
#ifdef INI_ATOM
#define BUTTONL_PIN (22)
#define BUTTONR_PIN (21)
#define BUTTONZ_PIN (39)
#define MOTOR_PIN   (25)
#define NEO_PWR (26)
#define NEOPIX (27)
#endif
#ifdef INI_ATOMS3
#define BUTTONL_PIN (G5)
#define BUTTONR_PIN (G6)
#define BUTTONZ_PIN (G7)
#define MOTOR_PIN   (G8)
#define NEO_PWR (16)
#define NEOPIX (35)
#endif

#define CHECK_INTERVAL 20
#define CALC_NUM 5
#define HEARTBEAT_MILLIS 1000 // 0 なら送らない
#define MOTOR_ON_TIME 200

enum Btn{ L=0,R,Z };
const char ssid[] = "KungFu_Atom";       // SSID
const char password[] = [your password];     // password
const IPAddress ip(192, 168, 4, 1);       // ServerのIPアドレス
const IPAddress gateway(192, 168, 4, 1);  // gatewayのIPアドレス
const IPAddress subnet(255, 255, 255, 0); // サブネットマスク

WiFiUDP udp;
TmDeltaTime tdt(4);
int val[3]={0,0,0}; // l,r,z
int pre[3]={0,0,0}; // l,r,z
int onCnt[3]={0,0,0}; // l,r,z
int calcTotal=0;
uint32_t heartbeatTimer=0;
int32_t motorTimer=0;
bool isMotorOn=false;

#define NUMPIXELS 1
Adafruit_NeoPixel pixels(NUMPIXELS, NEOPIX, NEO_GRB + NEO_KHZ800);

#define UDP_TX_PACKET_MAX_SIZE 1024
char packetBuffer[UDP_TX_PACKET_MAX_SIZE]; //buffer to hold incoming packet,

void calcTrig(uint32_t _delta);
void updateMotor(uint32_t _delta);

void setup() {
  delay(100);
  Serial.begin(115200);
  delay(100);
  pinMode(BUTTONL_PIN, INPUT_PULLUP);
  pinMode(BUTTONR_PIN, INPUT_PULLUP);
  pinMode(BUTTONZ_PIN, INPUT_PULLUP);
  pinMode(MOTOR_PIN, OUTPUT);

  pinMode(NEO_PWR,OUTPUT);  digitalWrite(NEO_PWR, HIGH);
  pixels.begin();
  pixels.setBrightness(20);
  pixels.setPixelColor(0, pixels.Color(55, 0, 0));
  pixels.show();

  WiFi.softAP(ssid, password);
  delay(100);
  WiFi.softAPConfig(ip, gateway, subnet);
  delay(100);
  while (WiFi.status() != WL_CONNECTED) {
    delay(100);
  }
  udp.begin(RECV_PORT);
  delay(100);
  String sendStr = "SSID:" + String(ssid) + "\npass:" + String(password);
  Serial.println(sendStr);

  digitalWrite(MOTOR_PIN, HIGH);
  delay(500);
  digitalWrite(MOTOR_PIN, LOW);
  pixels.setPixelColor(0, pixels.Color(0, 55, 0));
  pixels.show();

  //tdt.Setup();
  tdt.AddTrig(calcTrig,10);
  tdt.AddTrig(updateMotor,10);
  //tdt.AddTrig([](uint32_t _delta){Serial.println(_delta);},2000);
}

void loop() {
  tdt.Update();

  int packetSize = udp.parsePacket();
  if (packetSize>0 && packetSize<UDP_TX_PACKET_MAX_SIZE){
    udp.read(packetBuffer, UDP_TX_PACKET_MAX_SIZE);
    Serial.println("Received");
    motorTimer = MOTOR_ON_TIME;
  }

  delay(2);
}

void calcTrig(uint32_t _delta){
  heartbeatTimer+=_delta;    
  onCnt[Btn::L] += digitalRead(BUTTONL_PIN) ? 0 : 1;
  onCnt[Btn::R] += digitalRead(BUTTONR_PIN) ? 0 : 1;
  onCnt[Btn::Z] += digitalRead(BUTTONZ_PIN) ? 0 : 1;
  calcTotal++;
  if(calcTotal>=CALC_NUM){
    calcTotal=0;
    val[Btn::L] = (onCnt[Btn::L]>CALC_NUM/2)?1:0;
    val[Btn::R] = (onCnt[Btn::R]>CALC_NUM/2)?1:0;
    val[Btn::Z] = (onCnt[Btn::Z]>CALC_NUM/2)?1:0;
    onCnt[Btn::L]=onCnt[Btn::R]=onCnt[Btn::Z]=0;
    if((pre[Btn::L]!=val[Btn::L]) || (pre[Btn::R]!=val[Btn::R]) || (pre[Btn::Z]!=val[Btn::Z]) || ((HEARTBEAT_MILLIS>0)&& (heartbeatTimer>HEARTBEAT_MILLIS))){
      heartbeatTimer=0;
      String sendStr = "l:" + String(val[Btn::L])+",r:" + String(val[Btn::R])+",z:" + String(val[Btn::Z]);
      udp.beginPacket(HOST_NAME, HOST_PORT);
      udp.write((const uint8_t*)(sendStr.c_str()), strlen(sendStr.c_str()));
      udp.endPacket();
      Serial.println(sendStr);
    }
    pre[Btn::L]=val[Btn::L];
    pre[Btn::R]=val[Btn::R];
    pre[Btn::Z]=val[Btn::Z];
  }
}

void updateMotor(uint32_t _delta){
  if(motorTimer>0){
    if(!isMotorOn){
      digitalWrite(MOTOR_PIN, HIGH);
      isMotorOn = true;
      pixels.setPixelColor(0, pixels.Color(55, 55, 0));
      pixels.show();
    }
    motorTimer-=_delta;
    if(motorTimer<=0){
      digitalWrite(MOTOR_PIN, LOW);
      isMotorOn=false;
      pixels.setPixelColor(0, pixels.Color(0, 55, 0));
      pixels.show();
    }
  }
}

```

</details>

M5AtomLiteの電源を入れるとKungFu_AtomというWi-Fiアクセスポイントができるので、
PC側からはここに接続します。

## PC側送受信部分の作成
PC側はUnityで作成しています。
コントローラー（カンフーパペット）側のM5AtomLiteの電源を入れると

<details><summary>送受信部分</summary>

```
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Net.Sockets;
using System.Net;
using System;
using System.Threading;
using System.Text;
using UnityEditor.PackageManager;
using UnityEditor.Experimental.GraphView;
using UnityEngine.Events;

public class UDPApp : MonoBehaviour
{
    public int HOST_PORT = 7001;
    public int SEND_PORT = 7002;
    public byte[] SEND_IP = new byte[] { 192, 168, 4, 1 };
    public UnityEvent<String> OnReceive;

    public static UDPApp m_instance = null;
    UdpClient udpClient=null;
    int tickRate = 10;
    Thread receiveThread;
    IPEndPoint receiveEP;
    IPEndPoint sendEP;

    String currentMsg = "";
    String lastMsg = "";


    void Awake()
    {
        if (m_instance != null)
        {
            Destroy(this);
            return;
        }
        m_instance = this;
        OnReceive = new UnityEvent<string>();
    }

    private void Start()
    {
        DontDestroyOnLoad(this);
        receiveEP = new IPEndPoint(IPAddress.Any, HOST_PORT);
        udpClient = new UdpClient(receiveEP);
        udpClient.Client.ReceiveTimeout = 5000;
        IPAddress sendIP = new IPAddress(SEND_IP);
        sendEP = new IPEndPoint(sendIP, SEND_PORT);
        udpClient.Connect(sendEP);

        receiveThread = new Thread(new ThreadStart(ThreadReceive));
        receiveThread.Start();

        StartCoroutine(SendMessage());
    }

    private void Update()
    {
        if (currentMsg != lastMsg)
        {
            OnReceive.Invoke(currentMsg);
            lastMsg = currentMsg;
        }
    }

    void OnApplicationQuit()
    {
        udpStop();
    }

    void ThreadReceive()
    {
        Debug.Log("RecvStt");
        while (true)
        {
            IPEndPoint senderEP = null;
            byte[] receivedBytes = udpClient.Receive(ref senderEP);
            if(receivedBytes == null) continue;
            Parse(senderEP, receivedBytes);
        }
    }

    void Parse(IPEndPoint senderEP, byte[] data)
    {
        //受信処理
        currentMsg = Encoding.UTF8.GetString(data);
        Debug.Log(currentMsg + " " + senderEP.ToString());
    }

    public void OnSend(string _msg)
    {
        if(udpClient == null) return;
        Debug.Log(_msg + " Send");
        byte[] dgram = Encoding.UTF8.GetBytes(_msg);
        udpClient.Send(dgram, dgram.Length);

    }

    IEnumerator SendMessage()
    {
        //送信処理
        yield return new WaitForSeconds(1f / tickRate);
        while (true)
        {
            if (Input.GetMouseButtonDown(0))
            {
                OnSend("hello!");
            }
            yield return null;
        }
    }

    private void udpStop()
    {
        if (receiveThread != null)
        {
            receiveThread.Abort();
            receiveThread = null;
        }
        if (udpClient != null)
        {
            udpClient.Close();
            udpClient = null;
        }
    }
}

```

</details>

カンフーパペットの腕が伸びてスイッチが入ると、コントローラーからUDPでメッセージが届くので、
PC側はこれを受けてゲームに反映します。
また、ゲーム側で振動などのフィードバックをM5AtomLite側に返すには

```
    void Start()
    {
        UDPApp udp = FindObjectOfType<UDPApp>();
        if (udp != null)
        {
            OnSend = new UnityEvent<string>();
            OnSend.AddListener((_str)=>udp.OnSend(_str));
        }
    }
```
のようにイベントを追加してM5AtomLite側に送り返します。
もちろんM5AtomLite側はこれを受けて振動モーターをONにする必要があります。

## ゲーム部分の作成
ゲーム部分はシンプルながら直感的に遊べるものにしました。
<iframe width="560" height="315" src="https://www.youtube.com/embed/hn14b3fnGKs?si=yJxg-Ms9iNgdfVq6&amp;start=8" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

## まとめ
イワヤさんのカンフーパペットをゲームコントローラーにしてゲームを作成しました。
普通のコントローラーではオフにされがちな振動フィードバックですが、今回のようなフィジカルな反応を楽しむコントローラーではとてもここちよいものになりました。
今後もこういったコントローラーを作っていきたいなと思います。

<blockquote class="twitter-tweet" data-media-max-width="560"><p lang="ja" dir="ltr">LODGE XR Talkで、 <a href="https://twitter.com/hashtag/%E3%82%A4%E3%83%AF%E3%83%A4?src=hash&amp;ref_src=twsrc%5Etfw">#イワヤ</a> さんの <a href="https://twitter.com/hashtag/%E3%82%AB%E3%83%B3%E3%83%95%E3%83%BC%E3%83%91%E3%83%9A%E3%83%83%E3%83%88?src=hash&amp;ref_src=twsrc%5Etfw">#カンフーパペット</a> を使用したコンテンツを展示してきました<a href="https://t.co/1Y1bwIEqzN">https://t.co/1Y1bwIEqzN</a><a href="https://twitter.com/hashtag/LODGE?src=hash&amp;ref_src=twsrc%5Etfw">#LODGE</a></p>&mdash; misawa (@emesiw_misawa) <a href="https://twitter.com/emesiw_misawa/status/1733428684961501497?ref_src=twsrc%5Etfw">December 9, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>