🐉

toio で「イライラ棒」的なものを作った

2021/12/19に公開約12,700字7件のコメント

概要

Unity は慣れていないので toio SDK for Unity ではなく toio.js を使っています。
来年は Unity をちゃんと使おうと思います🙃

ということで、
Node.js 上で PS4のコントローラー(DUALSHOCK4) の入力をハンドリングして toio.js と連携させて、toio の衝突を検知したら DUALSHOCK4 のバイブレーションを発動させることで、
イライラ棒的な遊びができるものを作りました。

まずは早速、完成動画から
(音量を出していただくと、最後にコントローラーがブルブルしているのがわかります)

開発環境

  • PC: Mac OSX 11.2.3
  • Node: v14.18.2
  • yarn: 1.22.17 (npm: 8.3.0)
  • npm package: toio.js, node-gamepad
  • その他: toio キューブ, DUALSHOCK4

システム構成図

システム構成図

完成したソースコード

先に完成コードを貼っておきますが、後段で順を追って説明を記述しています。

完成したソースコード
index.js
const { NearestScanner } = require('@toio/scanner');
const GamePad = require('node-gamepad');

const DEFAULT_BUFFER = [5, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0];
const INDEXES = {
  rumbleLeft: 4,
  rumbleRight: 5,
  red: 6,
  green: 7,
  blue: 8,
  flashOn: 9,
  flashOff: 10
};

// https://github.com/rdepena/node-dualshock-controller から拝借
const setExtra = (controller, data) => {
  const buff = DEFAULT_BUFFER.slice();
  Object.keys(data).forEach((k) => {
    buff[INDEXES[k]] = data[k];
  });
  controller._usb.write(buff);
};

let speedLeft = 0;
let speedRight = 0;

async function main() {
  const cube = await new NearestScanner().start();
  await cube.connect();

  // 第二世代(CUH-ZCT2)の DUALSHOCK4 なので、第二引数を自分で設定する必要があった
  // 第一世代(CUH-ZCT1)の場合は、第二引数は不要
  const controller = new GamePad('ps4/dualshock4', {
    vendorID: 0x054c,
    productID: 0x09cc
  });
  controller.connect();

  // 左スティックのイベントをハンドリング
  controller.on('left:move', (evt) => {
    // スティックの中央が 128 なので、中央が 0 になるように計算する
    // また、0〜100で表現できるように正規化する
    const x = ((evt.x - 128) / 128) * 100;
    const y = -1 * ((evt.y - 128) / 128) * 100;

    // スティックの小さな傾きは誤差として何もしない
    if (Math.abs(x) < 10 && Math.abs(y) < 10) {
      speedLeft = 0;
      speedRight = 0;
      return;
    }

    // なんか角度を計算して頑張る!
    // ベストな計算かはわからないけど、トライ&エラーで良い感じに動いてる!
    const angle = Math.atan2(y, x);
    speedLeft = Math.abs(y) * Math.sin(angle) + Math.abs(x) * Math.cos(angle);
    speedRight = Math.abs(y) * Math.sin(angle) - Math.abs(x) * Math.cos(angle);
  });

  // https://github.com/toio/toio.js/blob/master/packages/cube/src/cube.ts#L325 からコードを拝借
  const threshold = 1;
  cube.configurationCharacteristic.characteristic.write(Buffer.from([0x06, 0x00, threshold]), false);

  cube.on('sensor:collision', (evt) => {
    // isCollisionDetected が false に戻らない場合もある
    console.log(evt);
    if (evt.isCollisionDetected) {
      cube.playPresetSound(4);
      setExtra(controller, { rumbleLeft: 128, rumbleRight: 128 });
      setTimeout(() => {
        setExtra(controller, { rumbleLeft: 0, rumbleRight: 0 });
      }, 1000);
    }
  });

  // 走り続けさせるために定期的に0.1秒間動かす
  setInterval(() => {
    cube.move(speedLeft, speedRight, 100);
  }, 10);
}

main();

作り方

移動制御編

  1. toio.js で toio に命令を出す
  2. node-gamepad で DUALSHOCK4 の入力をハンドリングする
  3. [1] と [2] を合わせてゴニョゴニョする

1. toio.js で toio に命令を出す

移動のサンプル
const { NearestScanner } = require('@toio/scanner');

async function main() {
  const cube = await new NearestScanner().start();
  await cube.connect();

  const speedLeft = 70;
  const speedRight = 70;
  const durationMs = 1000;
  cube.move(speedLeft, speedRight, durationMs);
}

main();

2. node-gamepad で DUALSHOCK4 の入力をハンドリングする

左スティックの入力ハンドリングのサンプル
const GamePad = require('node-gamepad');

function main() {
  // 第二世代(CUH-ZCT2)の DUALSHOCK4 なので、第二引数を自分で設定する必要があった
  // 第一世代(CUH-ZCT1)の場合は、第二引数は不要
  const controller = new GamePad('ps4/dualshock4', {
    vendorID: 0x054c,
    productID: 0x09cc
  });
  controller.connect();

  // 左スティックのイベントをハンドリング
  controller.on('left:move', (evt) => {
    console.log(evt);
  });
}

main();

3. [1] と [2] を合わせてゴニョゴニョする

index.js
const { NearestScanner } = require('@toio/scanner');
const GamePad = require('node-gamepad');

let speedLeft = 0;
let speedRight = 0;

async function main() {
  const cube = await new NearestScanner().start();
  await cube.connect();

  // 第二世代(CUH-ZCT2)の DUALSHOCK4 なので、第二引数を自分で設定する必要があった
  // 第一世代(CUH-ZCT1)の場合は、第二引数は不要
  const controller = new GamePad('ps4/dualshock4', {
    vendorID: 0x054c,
    productID: 0x09cc
  });
  controller.connect();

  // 左スティックのイベントをハンドリング
  controller.on('left:move', (evt) => {
    // スティックの中央が 128 なので、中央が 0 になるように計算する
    // また、0〜100で表現できるように正規化する
    const x = ((evt.x - 128) / 128) * 100;
    const y = -1 * ((evt.y - 128) / 128) * 100;

    // スティックの小さな傾きは誤差として何もしない
    if (Math.abs(x) < 10 && Math.abs(y) < 10) {
      speedLeft = 0;
      speedRight = 0;
      return;
    }

    // なんか角度を計算して頑張る!
    // ベストな計算かはわからないけど、トライ&エラーで良い感じに動いてる!
    const angle = Math.atan2(y, x);
    speedLeft = Math.abs(y) * Math.sin(angle) + Math.abs(x) * Math.cos(angle);
    speedRight = Math.abs(y) * Math.sin(angle) - Math.abs(x) * Math.cos(angle);
  });

  // 走り続けさせるために定期的に0.1秒間動かす
  setInterval(() => {
    cube.move(speedLeft, speedRight, 100);
  }, 10);
}

main();

衝突検出編

  1. toio.js で衝突検出をする
  2. toio.js でサウンドを鳴らす (衝突検出時)
  3. DUALSHOCK4 にバイブレーション命令を出す
  4. [1] と [2] と [3] を合わせてゴニョゴニョする

1. toio.js で衝突検出をする

衝突検出のサンプル
const { NearestScanner } = require('@toio/scanner');

async function main() {
  const cube = await new NearestScanner().start();
  await cube.connect();

  cube.on('sensor:collision', (evt) => {
    if (evt.isCollisionDetected) {
      console.log('衝突判定');
    }
  });
}

main();

2. toio.js でサウンドを鳴らす (衝突検出時)

https://toio.github.io/toio-spec/docs/ble_sound#効果音の-id
衝突検出のサンプル
  const { NearestScanner } = require('@toio/scanner');

  async function main() {
    const cube = await new NearestScanner().start();
    await cube.connect();

    cube.on('sensor:collision', (evt) => {
      if (evt.isCollisionDetected) {
        console.log('衝突判定');
+       cube.playPresetSound(4);
      }
    });
  }

  main();

3. DUALSHOCK4 にバイブレーション命令を出す

バイブレーション発動のサンプル
const GamePad = require('node-gamepad');

// https://github.com/rdepena/node-dualshock-controller から拝借
// コントローラーは USB接続であること
const setExtra = (controller, data) => {
  const DEFAULT_BUFFER = [5, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0];
  const INDEXES = {
    rumbleLeft: 4,
    rumbleRight: 5,
    red: 6,
    green: 7,
    blue: 8,
    flashOn: 9,
    flashOff: 10
  };

  const buff = DEFAULT_BUFFER.slice();
  Object.keys(data).forEach((k) => {
    buff[INDEXES[k]] = data[k];
  });
  controller._usb.write(buff);
};

function main() {
  // 第二世代(CUH-ZCT2)の DUALSHOCK4 なので、第二引数を自分で設定する必要があった
  // 第一世代(CUH-ZCT1)の場合は、第二引数は不要
  const controller = new GamePad('ps4/dualshock4', {
    vendorID: 0x054c,
    productID: 0x09cc
  });
  controller.connect();

  // ×ボタンを押したときのイベントをハンドリング
  controller.on('x:press', (evt) => {
    setExtra(controller, { rumbleLeft: 128, rumbleRight: 128 });
    setTimeout(() => {
      setExtra(controller, { rumbleLeft: 0, rumbleRight: 0 });
    }, 1000);
  });
}

main();

3. [1] と [2] と [3] を合わせてゴニョゴニョする

index.js
const { NearestScanner } = require('@toio/scanner');
const GamePad = require('node-gamepad');

// https://github.com/rdepena/node-dualshock-controller から拝借
const setExtra = (controller, data) => {
  const DEFAULT_BUFFER = [5, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0];
  const INDEXES = {
    rumbleLeft: 4,
    rumbleRight: 5,
    red: 6,
    green: 7,
    blue: 8,
    flashOn: 9,
    flashOff: 10
  };

  const buff = DEFAULT_BUFFER.slice();
  Object.keys(data).forEach((k) => {
    buff[INDEXES[k]] = data[k];
  });
  controller._usb.write(buff);
};

async function main() {
  const cube = await new NearestScanner().start();
  await cube.connect();

  // 第二世代(CUH-ZCT2)の DUALSHOCK4 なので、第二引数を自分で設定する必要があった
  // 第一世代(CUH-ZCT1)の場合は、第二引数は不要
  const controller = new GamePad('ps4/dualshock4', {
    vendorID: 0x054c,
    productID: 0x09cc
  });
  controller.connect();

  cube.on('sensor:collision', (evt) => {
    if (evt.isCollisionDetected) {
      cube.playPresetSound(4);
      setExtra(controller, { rumbleLeft: 128, rumbleRight: 128 });
      setTimeout(() => {
        setExtra(controller, { rumbleLeft: 0, rumbleRight: 0 });
      }, 1000);
    }
  });
}

main();

衝突検出の閾値編

https://toio.github.io/toio-spec/docs/ble_configuration#衝突検出のしきい値設定

上記を見ると10段階のレベルで閾値を設定でき、デフォルトは7になっているとのこと。

https://github.com/toio/toio.js/blob/master/packages/cube/src/cube.ts#L323-L327
衝突検出の閾値設定のサンプル
const { NearestScanner } = require('@toio/scanner');

async function main() {
  const cube = await new NearestScanner().start();
  await cube.connect();

  // https://github.com/toio/toio.js/blob/master/packages/cube/src/cube.ts#L325 からコードを拝借
  const threshold = 1;
  cube.configurationCharacteristic.characteristic.write(Buffer.from([0x06, 0x00, threshold]), false);
}

main();

完成したソースコード(再掲)

index.js
const { NearestScanner } = require('@toio/scanner');
const GamePad = require('node-gamepad');

const DEFAULT_BUFFER = [5, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0];
const INDEXES = {
  rumbleLeft: 4,
  rumbleRight: 5,
  red: 6,
  green: 7,
  blue: 8,
  flashOn: 9,
  flashOff: 10
};

// https://github.com/rdepena/node-dualshock-controller から拝借
const setExtra = (controller, data) => {
  const buff = DEFAULT_BUFFER.slice();
  Object.keys(data).forEach((k) => {
    buff[INDEXES[k]] = data[k];
  });
  controller._usb.write(buff);
};

let speedLeft = 0;
let speedRight = 0;

async function main() {
  const cube = await new NearestScanner().start();
  await cube.connect();

  // 第二世代(CUH-ZCT2)の DUALSHOCK4 なので、第二引数を自分で設定する必要があった
  // 第一世代(CUH-ZCT1)の場合は、第二引数は不要
  const controller = new GamePad('ps4/dualshock4', {
    vendorID: 0x054c,
    productID: 0x09cc
  });
  controller.connect();

  // 左スティックのイベントをハンドリング
  controller.on('left:move', (evt) => {
    // スティックの中央が 128 なので、中央が 0 になるように計算する
    // また、0〜100で表現できるように正規化する
    const x = ((evt.x - 128) / 128) * 100;
    const y = -1 * ((evt.y - 128) / 128) * 100;

    // スティックの小さな傾きは誤差として何もしない
    if (Math.abs(x) < 10 && Math.abs(y) < 10) {
      speedLeft = 0;
      speedRight = 0;
      return;
    }

    // なんか角度を計算して頑張る!
    // ベストな計算かはわからないけど、トライ&エラーで良い感じに動いてる!
    const angle = Math.atan2(y, x);
    speedLeft = Math.abs(y) * Math.sin(angle) + Math.abs(x) * Math.cos(angle);
    speedRight = Math.abs(y) * Math.sin(angle) - Math.abs(x) * Math.cos(angle);
  });

  // https://github.com/toio/toio.js/blob/master/packages/cube/src/cube.ts#L325 からコードを拝借
  const threshold = 1;
  cube.configurationCharacteristic.characteristic.write(Buffer.from([0x06, 0x00, threshold]), false);

  cube.on('sensor:collision', (evt) => {
    // isCollisionDetected が false に戻らない場合もある
    console.log(evt);
    if (evt.isCollisionDetected) {
      cube.playPresetSound(4);
      setExtra(controller, { rumbleLeft: 128, rumbleRight: 128 });
      setTimeout(() => {
        setExtra(controller, { rumbleLeft: 0, rumbleRight: 0 });
      }, 1000);
    }
  });

  // 走り続けさせるために定期的に0.1秒間動かす
  setInterval(() => {
    cube.move(speedLeft, speedRight, 100);
  }, 10);
}

main();

ステージを作る

ステージ

苦労したところ

  • toio.js の npm package が GitHub に比べて古かったこと
  • 衝突状態が true になったあと false に戻らないことがあること
    • 2回目の衝突が上手く反応しなくて困っています
    • 上手く反応しなくなったら node index.js で再起動!
  • node-dualshock-controller が yarn (or npm install) で失敗したこと
    • そのため、一部のコードだけ拝借させてもらいました
  • DUALSHOCK4 のバイブレーション発動が USB 接続で行う必要があった
    • ボタンやスティックは Bluetooth 接続でハンドリングできました
  • 撮影と操作を1人でやってるので難しかった!
  • 衝突風景を撮影したいのに何度かゴールしちゃった!

最後に

https://github.com/chibi929/toio_de_irairabou

GitHub のリポジトリに置きました。
記事の内容に加えて、以下の機能を追加しました。

  • 閾値レベルを変更する機能
  • 操作パターンの切り替え機能
    • 「左右のスティックで操作」する機能が増え、左スティックで左タイヤを、右スティックで右タイヤを操作するような挙動にしています。

Discussion

こんにちは。衝突検出をやる場合は、evt.isCollisionDetectedを参照しないようにするとうまくいくと思います!

   cube.on('sensor:collision', () => {
       console.log('衝突判定');
       cube.playPresetSound(4);
   });

コメントいただきありがとうございます!
isCollisionDetected が true でコールバックされたあと、すぐに false がコールバックされることもあるので、
そのパターンの場合は isCollisionDetected をハンドリングしないと、処理が2回走ってしまうのが若干気になりますね。。。

かと言って false がコールバックされない場合もあるので、そのときに2回目以降の衝突判定が上手いこと発生しなくなるんですよね。。。

そうなんですね。isCollisionDetectedはキューブから最後に通知された衝突検出フラグとイコールなため、
推測ですが、衝突検出が来た後すぐに、ダブルタップ検出が来ると、falseに上書かれてしまっている気がします。

こんなイメージです。

  1. 衝突検出が発生し、isCollisionDetectedがtrueになる
  2. 'sensor:collision'のイベントが投げられる
  3. すぐにダブルタップ検出が発生し、isCollisionDetectedがfalseになる
  4. index.js側で'sensor:collision'の処理がなされる

isCollisionDetectedは参照するとちょっと怪しいので、

そのパターンの場合は isCollisionDetected をハンドリングしないと、処理が2回走ってしまうのが若干気になりますね。。。

こちらをやるのであれば、'sensor:collision'のイベントを処理した後、一定時間マスクするような処理を書いた方がいい気がします。

推測ですので、あくまでもご参考までですがお役に立てますと幸いです。

ご返信いただきありがとうございます。

isCollisionDetectedはキューブから最後に通知された衝突検出フラグとイコールなため、
推測ですが、衝突検出が来た後すぐに、ダブルタップ検出が来ると、falseに上書かれてしまっている気がします。
isCollisionDetectedは参照するとちょっと怪しいので、

なるほどです!
それで、何度 こちら で取得してみても、false に戻らなかったわけですね。そして ダブルタップ検出 も同じセンサーで動くのでそのときに false に戻っている可能性があるってことですねー。

すごくしっくり来た感じがあります。
たくさんご説明いただき本当にありがとうございます!

ご参考になれましたら良かったです!

順番前後しちゃいましたが、いらいら棒とても楽しそうですね!次回作も楽しみにしております!

大事な情報をお伝えし忘れていました。toio.jsのコードを見ると
'sensor:collision'のイベントはisCollisionDetectedがtrueの時しか通知されない仕様になっています。

補足情報まで。

sensor-characteristic.ts
      if (parsedData.data.isCollisionDetected) {
        this.eventEmitter.emit('sensor:collision', { isCollisionDetected: parsedData.data.isCollisionDetected })
      }

おお。。。これは見逃しておりました。
参考情報ありがとうございます。

ログインするとコメントできます