PaPeRo i をScratch3.0で制御する-その1-接続・発話・ボタン

 Scratch3.0にオリジナルブロックを追加してPaPeRo i を制御できる様にします。今回はScratch3.0とPaPeRo i との接続、発話、ボタンイベントのブロックを作成しました。今後、順次ブロックを追加して行く予定です。

Scratch 3.0の拡張機能について

 Scratch 3.0にオリジナルブロックを追加する方法については、こちらのQiita記事Japanese Scratch-Wikiの記事が参考になりました。
拡張機能でPaPeRo i 制御用のオリジナルブロックを追加するための情報を整理すると、

  • Scratch3.0には拡張機能という、ブロックを追加する仕組みがあり、2019/5現在公式サイトでは「音楽」「音声合成」「micro:bit」「LOGO MINDSTORMS EV3」といった拡張機能が利用可能
  • このScratch3.0の拡張機能はまだ固定的なもので、特定の拡張機能だけを切り出して公開したり、それをインストールする仕組みは検討中(2019年後半予定?)
  • Scratch3.0の開発環境をローカルに作り、ソースコードを直接追加修正する方法であれば、2019/5現在でもオリジナルブロックを追加して動かすことができる

となります。

Scratch 3.0開発環境を整える

 上記サイトの情報に従って開発環境を整えます。gitとNode.jsインストール済のWindows10 PCで実行しました。

$ mkdir c:\scratch
$ cd c:\scratch
$ git clone --depth 1 https://github.com/llk/scratch-vm.git
$ git clone --depth 1 https://github.com/llk/scratch-gui.git
$ cd scratch-vm
$ npm i
$ npm link
$ cd ..\scratch-gui
$ npm i
$ npm link scratch-vm

この環境でデバッグのためにScratch3.0を動かすには、

$ npm start

としてブラウザでlocalhost:8601にアクセスします。動作するローカルサーバはソースファイルが更新されると自動的に更新を反映してくれるので大変便利です。また、

$ npm run build

とすると、scratch-gui\buildディレクトリ以下にリリースファイルが生成されます。こちらであればNode.jsがインストールされていない、静的ページのみに対応したHTTPサーバの環境であってもScratch3.0が動きます。

絵を2枚描く

 拡張機能を選ぶ画面用に1枚(600x372PNG)、オリジナルブロックに表示されるアイコン用に1枚(SVG)絵を描きます。今回はひとまず動かすのが目的なのでざっくり適当な絵で済ましています。Japanese Scratch-Wikiには80×80のPNGも必要とありましたが、アイコン用のSVGをそのまま使える様に変わった様です。拡張機能を選ぶ画面用にPNGとSVGを画像ファイルとして置き、ブロックに表示されるアイコン用にはSVGをBase64でエンコードしてjsファイルに埋め込みます。

拡張機能選択側の修正

 scratch-gui\src\lib\libraries\extensions\paperoiディレクトリを作って画像ファイルを置き、scratch-gui\src\lib\libraries\extensions\index.jsxを修正しました。

...前略...
import paperoiIconURL from './paperoi/paperoi.png';
import paperoiInsetIconURL from './paperoi/paperoi-small.svc';

...中略...

    {
        name: (
            <FormattedMessage
                defaultMessage="PaPeRo i"
                description="Name for the 'PaPeRo i' extension"
                id="gui.extension.paperoi.name"
            />
        ),
        extensionId: 'paperoi',
        iconURL: paperoiIconURL,
        insetIconURL: paperoiInsetIconURL,
        description: (
            <FormattedMessage
                defaultMessage="PaPeRo iを制御する"
                description="Description for the 'PaPeRo i' extension"
                id="gui.extension.paperoi.description"
            />
        ),
        featured: true
    }
];

これで後で定義するPaPeRo i 制御用ブロックが拡張機能選択画面で選べるようになります。表示以外でこの選択動作で本質的に機能するのは識別子extensionId: ‘paperoi’だけの様です。

PaPeRo i 制御用ブロックの定義

 scratch-vm\src\extension-support\extension-manager.jsに以下を追記しました。

...前略...
const builtinExtensions = {
    ...中略...
    paperoi: () => require('../extensions/scratch3_paperoi')
};

これが識別子’paperoi’と実際のブロック定義jsファイルの位置を結びつけると思われます。そしてディレクトリ

scratch-vm\src\extensions\scratch3_paperoi

を作成し、新規作成したindex.jsを置きました。
(1) シミュレータとの接続ブロック
(2) PaPeRo i 実機との接続ブロック
(3) PaPeRo i と接続できたイベントブロック
(4) 発話ブロック
(5) ボタンイベントブロック
(6) 発話終了イベントブロック
の6ブロックを定義しています。

const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
const Cast = require('../../util/cast');
const formatMessage = require('format-message');
const log = require('../../util/log');
const scratch3papero = require('./scratch3papero')

const blockIconURI = 
'(略)';

class Scratch3PaperoiBlocks {
    /**
     * @return {array} - text and values for each buttons menu element
     */
    get BUTTONS_MENU () {
        return [
            {
                text: formatMessage({
                    id: 'paperoi.buttonsMenu.L',
                    default: '左',
                    description: 'PaPeRo iの左ボタン'
                }),
                value: 'R'
            },
            {
                text: formatMessage({
                    id: 'paperoi.buttonsMenu.C',
                    default: '中',
                    description: 'PaPeRo iの中ボタン'
                }),
                value: 'C'
            },
            {
                text: formatMessage({
                    id: 'paperoi.buttonsMenu.R',
                    default: '右',
                    description: 'PaPeRo iの右ボタン'
                }),
                value: 'L'
            },
            {
                text: formatMessage({
                    id: 'paperoi.buttonsMenu.any',
                    default: 'どれか',
                    description: 'PaPeRo iのボタンどれか'
                }),
                value: 'any'
            }
        ];
    }

    constructor (runtime) {
        this.runtime = runtime;
        this._paperoDic = {};
        log.log('new papero');
    }

    getInfo () {
        return {
            id: 'paperoiblocks',
            name: 'PaPeRo i',
            blockIconURI: blockIconURI,
            blocks: [
                {
                    opcode: 'connectId',
                    blockType: BlockType.COMMAND,
                    text: formatMessage({
                        id: 'paperoi.connectId',
                        default: 'パペロ [ID] URL: [URL] シミュレータID: [SIMID] 名前: [SIMNAME] に接続する',
                        description: 'パペロにURL、シミュレータID、シミュレータ名を指定して接続し、IDを付ける',
                    }),
                    arguments: {
                        ID: {
                            type: ArgumentType.STRING,
                            defaultValue: "1"
                        },
                        URL: {
                            type: ArgumentType.STRING,
                            defaultValue: "wss://smilerobo.com:8000/papero"
                        },
                        SIMID: {
                            type: ArgumentType.STRING,
                            defaultValue: ""
                        },
                        SIMNAME: {
                            type: ArgumentType.STRING,
                            defaultValue: ""
                        }
                    }
                },
                {
                    opcode: 'connectIdReal',
                    blockType: BlockType.COMMAND,
                    text: formatMessage({
                        id: 'paperoi.connectIdReal',
                        default: 'パペロ [ID] URL: [URL] に接続する',
                        description: 'パペロにURLを指定して接続し、IDを付ける',
                    }),
                    arguments: {
                        ID: {
                            type: ArgumentType.STRING,
                            defaultValue: "1"
                        },
                        URL: {
                            type: ArgumentType.STRING,
                            //defaultValue: "ws://192.168.1.189:8088/papero"  // debug
                            defaultValue: "ws://192.168.1.1:8088/papero"
                        }
                    }
                },
                {
                    opcode: 'whenReady',
                    text: formatMessage({
                        id: 'paperoi.whenReady',
                        default: 'パペロ [ID] と接続できたとき',
                        description: '指定パペロとの接続完了イベント'
                    }),
                    blockType: BlockType.HAT,
                    arguments: {
                        ID: {
                            type: ArgumentType.STRING,
                            defaultValue: "1"
                        },
                    }
                },
                {
                    opcode: 'speech',
                    blockType: BlockType.COMMAND,
                    text: formatMessage({
                        id: 'paperoi.speech',
                        default : 'パペロ [ID] で [TEXT] としゃべる',
                        description: '指定パペロに発話させる'
                    }),
                    arguments: {
                        ID: {
                            type: ArgumentType.STRING,
                            defaultValue: "1"
                        },
                        TEXT: {
                            type: ArgumentType.STRING,
                            defaultValue: "こんにちは"
                        }
                    }
                },
                {
                    opcode: 'whenButton',
                    text: formatMessage({
                        id: 'paperoi.whenButton',
                        default: 'パペロ [ID] のボタン [BTN] が押されたとき',
                        description: '指定パペロのボタンイベント'
                    }),
                    blockType: BlockType.HAT,
                    arguments: {
                        ID: {
                            type: ArgumentType.STRING,
                            defaultValue: "1"
                        },
                        BTN: {
                            type: ArgumentType.STRING,
                            menu: 'buttons',
                            defaultValue: 'R'
                        }
                    }
                },
                {
                    opcode: 'whenEndOfSpeech',
                    text: formatMessage({
                        id: 'paperoi.whenEndOfSpeech',
                        default: 'パペロ [ID] がしゃべり終わったとき',
                        description: '指定パペロの発話終了イベント'
                    }),
                    blockType: BlockType.HAT,
                    arguments: {
                        ID: {
                            type: ArgumentType.STRING,
                            defaultValue: "1"
                        },
                    }
                }
            ],
            menus: {
                buttons: this.BUTTONS_MENU,
            }
        };
    }

    // パペロ実機またはシミュレータに接続し指定のIDを付ける
    connectId (args) {
        const id = Cast.toString(args.ID);
        const url = args.URL;
        const simid = args.SIMID;
        const simname = args.SIMNAME;
        var papero = new scratch3papero.Scratch3Papero(url, simid, simname);
        this._paperoDic[id] = papero;
        if (papero) {
            papero.open();
        }
    }

    // パペロ実機に接続し指定のIDを付ける
    connectIdReal (args) {
        const id = Cast.toString(args.ID);
        const url = args.URL;
        var papero = new scratch3papero.Scratch3Papero(url, "", "");
        this._paperoDic[id] = papero;
        if (papero) {
            papero.open();
        }
    }

    whenReady (args) {
        const id = Cast.toString(args.ID);
        var papero = this._paperoDic[id]
        if (papero) {
            return papero.whenReady();
        }
        return false;
    }

    speech (args) {
        const id = Cast.toString(args.ID);
        const text = args.TEXT;
        var papero = this._paperoDic[id]
        if (papero) {
            papero.speech(text);
        }
    }

    whenButton (args) {
        const id = Cast.toString(args.ID);
        var papero = this._paperoDic[id]
        if (papero) {
            return papero.whenButton(args.BTN);
        }
        return false;
    }

    whenEndOfSpeech (args) {
        const id = Cast.toString(args.ID);
        var papero = this._paperoDic[id]
        if (papero) {
            return papero.whenEndOfSpeech();
        }
        return false;
    }
}

module.exports = Scratch3PaperoiBlocks;

これで拡張機能を選択すると以下の様にブロックが表示される様になりました。

 「パペロ1 で こんにちは としゃべる」の様に、全てのブロックの先頭でパペロのIDを指定する様にし、複数のパペロを制御できる様にしています。実際には一台のパペロを制御できれば良い場合がほとんどだと思いますので、最終的にはID指定のないブロックを作り、ID指定版のブロックはデフォルトでは表示しない様にすることになると思いますが、現時点ではまずこの複数パペロ対応のブロックを作って行くことにしようと思います。
 ブロックの定義については今回は、文字列入力とボタンの選択入力、および処理ブロック(BlockType.COMMAND)とイベントブロック(BlockType.HAT)のみでごく基本的なものだけです。実処理はすべて、papero.wenButton()の様に、別ファイルscratch3papero.jsのメソッドを呼び出しているだけになっています。
 なお「しゃべり終わったとき」など引数がないイベントブロックは今後一つにまとめて選択式に変更する予定です。

実際の制御処理

 PaPeRo i を制御するにはWebSocketで接続してコマンド送信、レスポンス・イベント受信を行う必要がありますが、これには弊社ぱぺろっくりー用に作成したyapapero.jsというファイルをそのまま使用しています。ただし、yapapero.jsはブラウザとNode.jsの両方に対応していたのですが、Scratch3.0の開発環境(webpack)ではNode.jsと誤判定してしまい正常に動作しないことと、yapapero.jsはイベントドリブン動作であるのに対しscratchはポーリング動作(例えば「ボタンが押されたとき」というブロックを画面に置くと、一秒間に何度も=フレームごとだと思いますが、その判定関数が呼ばれる)なのでその橋渡しが必要なことから、yapapero.jsのプロトコル動作のみ定義した中間クラスを継承したクラスを定義して使用しています。(scratch3papero.js)

'use strict';

var Scratch3Papero = (function() {
  var isNode = true;
  var yapapero = yapapero || require('./yapapero');
  var getLogger = yapapero.getLogger;

  var inherits = function(child, base) {
    child.super_ = base;
    child.prototype = Object.create(base.prototype, {
      constructor: {
        value: child,
        enumerable: false,
        writable: true,
        configrable: true
      }
    });
  };

  function Scratch3Papero(url, simId, simName, logLevel = getLogger.INFO,
      eosPollSec = 0.1) {
    yapapero.PaperoMid.call(this, url, simId, simName, eosPollSec, logLevel);
    this.deleted = false;
    var papero = this;
    this.valOpen = false;
    this.valClose = false;
    this.valError = false;
    this.valWsError = false;
    this.valPaperoReady = false;
    this.valButton = {'L': false, 'C': false, 'R': false};
    this.valHeadButton = false;
    this.valEndOfSpeech = false;
    var eventHandler = function(event) {
      papero.logger.trace("get message. dat = " + JSON.stringify(event.dat));
      if (event.isOpen()) {
        papero.valOpen = true;
      }
      else if (event.isClose()) {
        papero.valClose = true;
      }
      else if (event.isError()) {
        papero.valError = true;
      }
      else if (event.isWsError()) {
        papero.valWsError = true;
      }
      else if (event.isPaperoReady()) {
        papero.valPaperoReady = true;
      }
      else if (event.isDetectButton('L')) {
        papero.valButton['L'] = true;
      }
      else if (event.isDetectButton('C')) {
        papero.valButton['C'] = true;
      }
      else if (event.isDetectButton('R')) {
        papero.valButton['R'] = true;
      }
      else if (event.isDetectHeadButton()) {
        papero.valHeadButton = true;
      }
      else if (event.isEndOfSpeech()) {
        this.valEndOfSpeech = true;
      }
    }
    this.addEventHandler(eventHandler);
  }
  inherits(Scratch3Papero, yapapero.PaperoMid);

  /**
  * open websocket connection
   * @method open
   */
  Scratch3Papero.prototype.open = function () {
    var papero = this;
    if (true) {
      // browser environment
      var ws = new WebSocket(this.url);
      // on open
      ws.onopen = function (e) {
        papero.innerHandler(new yapapero.PaperoBase.prototype.WsEvent(papero.WS_OPEN));
      };
      // on close
      ws.onclose = function (e) {
        if (papero.deleted) {
          return;
        }
        papero.innerHandler(new yapapero.PaperoBase.prototype.WsEvent(papero.WS_CLOSE));
      };
      // on error
      ws.onerror = function (error) {
        if (papero.deleted) {
          return;
        }
        papero.innerHandler(new yapapero.PaperoBase.prototype.WsEvent(papero.WS_ERROR, error));
      };
      // on messages from papero
      ws.onmessage = function (e) {
        //papero.logger.debug("onmessage:" + e.data);
        if (papero.deleted) {
          return;
        }
        var dic = JSON.parse(e.data);
        papero.innerHandler(new yapapero.PaperoBase.prototype.RobotMsg(dic));
      }
      this.ws = ws;
    }
  }
  Scratch3Papero.prototype.close = function () {
    if (this.ws) {
      this.ws.close();
    }
    this.deleted = true;
  }
  Scratch3Papero.prototype.reopen = function () {
    this.close();
    this.open();
  }
  Scratch3Papero.prototype.whenReady = function () {
    var res = this.valPaperoReady;
    this.valPaperoReady = false;
    return res;
  }
  Scratch3Papero.prototype.whenButton = function (btn) {
    if ('any' == btn) {
      var res = this.valButton['L'] || this.valButton['C'] || this.valButton['R'];
      this.valButton['L'] = false;
      this.valButton['C'] = false;
      this.valButton['R'] = false;
      return res;
    }
    var res = this.valButton[btn];
    this.valButton[btn] = false;
    return res;
  }
  Scratch3Papero.prototype.whenHeadButton = function () {
    var res = this.valHeadButton;
    this.valHeadButton = false;
    return res;
  }
  Scratch3Papero.prototype.whenEndOfSpeech = function () {
    var res = this.valEndOfSpeech;
    this.valEndOfSpeech = false;
    return res;
  }

  if (isNode) {
    module.exports.Scratch3Papero = Scratch3Papero;
  }
  return Scratch3Papero;
})();

クラウドで動かす

 IBM Cloudに載せてみましたのでお試しください。
順次機能追加を行う予定なので、もし記事内容と異なる場合にはご容赦ください。
なおIBMクラウドではScratch3.0のbuildディレクトリの他、
manufest.yml

---
applications:
 - buildpack: staticfile_buildpack
   host: paperoi-scratch3
   name: paperoi-scratch3
   memory: 64M

Staticfile

root: build

の2ファイルを用意し、以下のコマンドでpush/実行できました。

$ ibmcloud api cloud.ibm.com
$ ibmcloud login
$ ibmcloud target --cf
$ ibmcloud cf push

なお現状では残念ながら保存したブロックがLoadできない様です。不具合修正しました(2019/6/5)。


0