Raspberry PI + Node.js + Nuxt.jsで30秒の間に光っているボタンが何回押せるかを競うゲームをDIYした

# 作ったもの
まずはこの[動画](http://www.youtube.com/watch?v=jR0ta-itRY0)を見てください。

[![IMAGE ALT TEXT HERE](http://img.youtube.com/vi/jR0ta-itRY0/0.jpg)](http://www.youtube.com/watch?v=jR0ta-itRY0)

-----

このようなゲームを子供の頃体験しませんでしたか？30秒の間に光っているボタンが何回押せるかを競うゲームです。なかなか大きいものですし、市販されていません。なければDIYで作る。ということで[作りました](https://wallbuttons.jimdofree.com/)。

-----

# アーキテクチャ概要
![ダウンロード.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122922/dc2bb8d0-0b0e-7ed9-a0c3-dda3692cc45a.png)
全体図は上図のようになります。

-----
## まずはボタンの機構を確認してみる
ボタンを開けて確認してみると、ちゃんと回路的には別になっています。
これなら好きな場所を光らせたり、押されたところを判別することができますね。
![IMG_0486.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122922/d8023431-ef83-f52b-2abb-9e0fc82059e2.jpeg)

-----
これをボタンに入れるとこんな感じ。
![名称未設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122922/c1a9d004-42a3-b773-c102-4b90a0f6cc41.png)

-----

## LEDとRaspberry PIとの通信
LEDに関してはGPIOという仕組みを利用してピンのモードを出力（OUTPUT）にし、回路の電圧をHIGHにしたりLOWにすることでLEDを光らせたり、消したりすることができます。

|出力|LEDの状態|
|---|---|
|HIGH|点灯|
|LOW|消灯|

-----

## スイッチとRaspberry PIとの通信
スイッチに関してもGPIOという仕組みを利用してピンのモードを入力（INPUT、PULL_UP）にし、値を読むことでボタンを押されている間はピンの入力値が0になります。逆に押されていないときは1になります。PULL_UPにしないと、スイッチがOFFのときに値が不定となってしまうため、必ずPULL_UPしておきます。

|ボタンの状態|GPIOの値|
|---|---|
|押されている|0|
|押されていない|1|

-----

## Node.jsでGPIOを制御する
まずはともあれ、ライブラリを使いましょう。Raspberry PIのライブラリをいくつか試してみたのですが、使い勝手の良かったライブラリは[pigpio](https://www.npmjs.com/package/pigpio)でした。

使い方は簡単です。LEDを制御するには下記のようにします。

```javascript
const Gpio = require('pigpio').Gpio;
 
const led = new Gpio(17, {mode: Gpio.OUTPUT});
led.digitalWrite(1); // ON
led.digitalWrite(0); // OFF
```

-----

スイッチの変化を検知するには下記のようにします。

```javascript
const Gpio = require('pigpio').Gpio;
const button = new Gpio(pin, {
  mode: Gpio.INPUT,
  pullUpDown: Gpio.PUD_UP,           // プルアップにしておく
  alert: true
});
button.glitchFilter(20000);          // チャタリングを防ぐためのフィルタ
button.on("alert", onChange(index)); // 変化があったときにイベントの登録
```

-----

なお、チャタリングとは

> 可動接点などが接触状態になる際に、微細な非常に速い機械的振動を起こす現象のことである。
[チャタリング - Wikipedia](https://ja.wikipedia.org/wiki/%E3%83%81%E3%83%A3%E3%82%BF%E3%83%AA%E3%83%B3%E3%82%B0)

-----

## USBオーディオで音を鳴らす
スピーカーにはUSBオーディオの下記のものを用意しました。
<a href="https://www.amazon.co.jp/gp/product/B073TXYLDB/ref=as_li_ss_il?ie=UTF8&psc=1&linkCode=li2&tag=sukesamblog-22&linkId=292c96df4442b033dc852331be17ebf8&language=ja_JP" target="_blank"><img border="0" src="//ws-fe.amazon-adsystem.com/widgets/q?_encoding=UTF8&ASIN=B073TXYLDB&Format=_SL160_&ID=AsinImage&MarketPlace=JP&ServiceVersion=20070822&WS=1&tag=sukesamblog-22&language=ja_JP" ><br>エレコム PCスピーカー コンパクト 4W USB接続 ブラック MS-P08USBBK</a><img src="https://ir-jp.amazon-adsystem.com/e/ir?t=sukesamblog-22&language=ja_JP&l=li2&o=9&a=B073TXYLDB" width="1" height="1" border="0" alt="" style="border:none !important; margin:0px !important;" />

-----

最初ハマったのですが、同時に複数の音を出すことができませんでした。これはALSAの設定の問題。~/.asoundrcを次のように書き換え、mixerを使用するように設定すれば解決します。mixerを使用しないと一つのプロセスがALSAを専有してしまって複数の音を出せないんですね。

-----

```
pcm.!default {
  type hw
  card 1
}

ctl.!default {
  type hw
  card 1
}

pcm.!default {
  type plug
  slave.pcm "dmixer"
}

pcm.dmixer {
  type dmix
  ipc_key 1024
  slave {
    pcm "hw:1,0"
    period_time 0
    period_size 1024
    buffer_size 4096
    rate 44100
  }
  bindings {
    0 0
    1 1
  }
}
```

-----

## タブレットとRaspberry PIとの通信
ブラウザを使って画面を表示しますが、今回のコントローラはあくまでWoodBoardのボタン。したがって、タブレットには表示しますが、全てサーバサイドからのプッシュ通知で実現します。サーバサイドはNode.jsを使っているので、素直に[Socket.io](https://socket.io/)を利用します。

使い方は本家のサイトの[Get Started](https://socket.io/get-started/chat/)を参考にしました。

-----

## サーバサイド側のゲームプログラム
「30秒で何回光ってるボタンを押せるか」のゲームのソースコードのサンプルとして掲載します。ベースとなるクラスがあるので、これだけでは動かないですが、100行もないコードでゲームが動いています。しかも殆どは音を再生するための処理のみ。簡単ですね。

-----

```javascript
const Game = require('./Game.js');
const { wait } = require('./util.js');
const GAME_TIME = 30; // seconds

class TimeAttack extends Game {
  /** コンストラクタ */
  constructor(io, buttons, leds) {
    super(io, buttons, leds);
    // stateはコンストラクタで設定する
    this.state = {
      remainSeconds: GAME_TIME,
      score: 0,
      mode: 'PLAYING',
      nextIndex: this.getNextIndex()
    };
  }


  /** 初期化処理 */
  async init() {
    await wait(1000);
    await this.play('assets/sound/info-girl1-youi2.mp3');
    await wait(1000);
    this.play('assets/sound/info-girl1-don1.mp3');
    this.playBgm('assets/bgm/tw045_volume.mp3');
    this.getLedPin(this.state.nextIndex).digitalWrite(1);
    this.startTime = new Date().getTime();
    this.seconds = 0;
    this.timer = setInterval(this.loop.bind(this), 100);
  }
  /** 終了時処理 */
  destroy() {
    super.destroy();
    clearInterval(this.timer);
  }
  /** 100ms毎に呼ばれる */
  async loop() {
    const now = new Date().getTime();
    const seconds = parseInt((now - this.startTime) / 1000);
    const remainSeconds = GAME_TIME - seconds;
    if (this.seconds !== seconds) {
      // 残り時間が0になった、または時刻がntpのせいでずれ残り時間がマイナスあるいはゲーム時間よりも多くなった場合は終了する
      if (remainSeconds === 0 || remainSeconds < 0 || remainSeconds > GAME_TIME) {
        this.clearLeds();
        this.state.remainSeconds = remainSeconds;
        this.state.mode = 'SHOW_SCORE';
        this.stopBgm();
        this.emitState();
        clearInterval(this.timer);
        await this.play('assets/sound/info-girl1-zero1.mp3');
        await this.play('assets/sound/info-girl1-sokomade1.mp3');
        await wait(500);
        await this.play('assets/sound/jingle-jazz.mp3');
        this.playBgm('assets/bgm/何を作っているのかな？_volume.mp3');
        await this.waitForAnyButtonPushed();
        this.end();
      }
      if (remainSeconds === 10) this.play('assets/sound/info-girl1-zyuu1.mp3');
      if (remainSeconds === 9) this.play('assets/sound/info-girl1-kyuu1.mp3');
      if (remainSeconds === 8) this.play('assets/sound/info-girl1-hachi1.mp3');
      if (remainSeconds === 7) this.play('assets/sound/info-girl1-nana1.mp3');
      if (remainSeconds === 6) this.play('assets/sound/info-girl1-roku1.mp3');
      if (remainSeconds === 5) this.play('assets/sound/info-girl1-go1.mp3');
      if (remainSeconds === 4) this.play('assets/sound/info-girl1-yon1.mp3');
      if (remainSeconds === 3) this.play('assets/sound/info-girl1-san1.mp3');
      if (remainSeconds === 2) this.play('assets/sound/info-girl1-ni1.mp3');
      if (remainSeconds === 1) this.play('assets/sound/info-girl1-ichi1.mp3');
      this.seconds = seconds;
      this.state.remainSeconds = GAME_TIME - seconds;
      this.emitState();
    }
  }

  /** ボタンが押されたときの処理 */
  onPushed(pin) {
    if (this.state.mode !== 'PLAYING') return;
    if (pin !== this.state.nextIndex) {
      this.play('assets/sound/button62.mp3');
      return;
    }
    this.play('assets/sound/button25.wav');
    this.getLedPin(this.state.nextIndex).digitalWrite(0);
    this.state.nextIndex = this.getNextIndex();
    this.getLedPin(this.state.nextIndex).digitalWrite(1);
    this.state.score++;
    this.emitState();
  }

  /** 次のボタンのインデックスを取得する。連続で同じインデックスは採番しない */
  getNextIndex() {
    while (true) {
      const nextIndex = Math.floor(Math.random() * 10);
      if (!this.state || (this.state.nextIndex !== nextIndex)) return nextIndex;
    }
  }
}

module.exports = TimeAttack;
```

-----

## クライアント側のゲームプログラム Nuxt.js
クライアントサイドはNuxt.jsで実現します。Nuxt.jsは簡単にSPAを作れるので楽ですね。

画面表示だけで割り切っているのでかなり簡単に実現できます。ベースとなるvueファイルはこんなかんじ。

-----

```vue
<template>
  <div>
    <title-menu v-if="game === 'TitleMenu'" :socket="socket" />
    <time-attack v-else-if="game === 'TimeAttack'" :socket="socket" />
    <debug v-else-if="game === 'Debug'" :socket="socket" />
    <audio-setting v-else-if="game === 'AudioSetting'" :socket="socket" />
    <exit v-else-if="game === 'Exit'" :socket="socket" />
  </div>
</template>

<script>
import TitleMenu from '~/components/TitleMenu.vue'
import TimeAttack from '~/components/TimeAttack.vue'
import Debug from '~/components/Debug.vue'
import AudioSetting from '~/components/AudioSetting.vue'
import Exit from '~/components/Exit.vue'
import io from 'socket.io-client'

export default {
  components: {
    TitleMenu,
    TimeAttack,
    Debug,
    AudioSetting,
    Exit
  },
  mounted() {
    this.socket = io('http://192.168.1.111:3000')
    this.socket.on('game', this.onGameChange)
  },
  beforeDestroy() {
    this.socket.off('game', this.onGameChange)
  },

  data() {
    return {
      socket: null,
      game: null
    }
  },
  methods: {
    onGameChange(game) {
      this.game = game
    }
  }
}
</script>
```

-----

# 終わりに
6月の初旬に思い立ってから完成までで3週間くらいかかりました。ボタンの選定、設計したことのない電子回路、ArduinoでやってみたりRaspberry PIにしたりで方針もぶれたし、慣れないドリルドライバやネジの取り付けなどもあったので、説明書や部材が予め用意されていれば全然時間はかからなそうです。

どうでしょう。一家に一台作ってみませんか？