JavaScript
AmazonEcho
Alexa

Amazon Echoでりゅうおうと世界の半分をもらう交渉をする。

やりたいこと

  • Amazon Echoとドラゴンクエストごっこをして遊びたい。
  • スキル開発におけるセッションとステート管理の練習

下記の記事を参考にして開発を進めます。
Alexaスキル開発トレーニングシリーズ 第3回 音声ユーザーインターフェースの設計 : Alexa Blogs

1.目的とストーリーの明確化
2.台本の作成
3.フロー図の作成
4.対話モデルへの反映

1.目的とストーリーの明確化

目的は冒頭に書いた通りです。

2.台本の作成

台本は決まっています。
ドラゴンクエスト大辞典を作ろうぜ!!第三版 Wiki*を参照しました。(一部改変)

よくきた***よ。わしが王の中の王 竜王だ。
わしは待っておった。そなたのような若者が現れることを。
もしわしの味方になれば世界の半分をお前にやろう。
どうじゃ?わしの味方になるか?(はい/いいえ)
 
(はい)
本当だな?(はい/いいえ)
 
(はい)
では世界の半分、闇の世界を与えよう!
そなたに復活の呪文を教えよう!

これを書き留めておくのだぞ。
おまえの旅は終わった。
さあゆっくり休むがよい!わあっはっはっはっ

(いいえ)
では、どうしてもこのわしを倒すというのだな!
愚か者め!思い知るがよい!

3.フロー図の作成

クリップボード01.png

4.対話モデルへの反映

  • インテントの洗い出し

ShareTheWorldIntentという壮大なインテントを用意します。
名前を尋ねるのは起動時に行うので、LaunchRequestで行い、Yes/Noの質問については、標準インテントにあるAMAZON.YesIntentAMAZON.NoIntentを使います。

LaunchRequest //名前を尋ねる
ShareTheWorldIntent //半分をやろう
AMAZON.YesIntent //はい
AMAZON.NoIntent //いいえ
  • 発話の洗い出し

ドラクエの主人公は基本的に喋りませんので、発話パターンはかなり少なくなります。
以下の名前設定も主人公の発話というよりは、冒険を開始する際の設定入力という位置づけです。

ShareTheWorldIntent {firstName}
ShareTheWorldIntent 私は {firstName} です
ShareTheWorldIntent 名前は {firstName} です
ShareTheWorldIntent {firstName} だよ

名前は標準スロットタイプにあるAMAZON.FirstNameで受取ります。

インテントスキーマ
{
  "languageModel": {
    "intents": [
      {
        "name": "AMAZON.CancelIntent",
        "samples": []
      },
      {
        "name": "AMAZON.HelpIntent",
        "samples": []
      },
      {
        "name": "AMAZON.NoIntent",
        "samples": [
          "いいえ",
          "やだ",
          "だめ",
          "なし"
        ]
      },
      {
        "name": "AMAZON.StopIntent",
        "samples": []
      },
      {
        "name": "AMAZON.YesIntent",
        "samples": [
          "はい",
          "うん",
          "いいよ",
          "あり"
        ]
      },
      {
        "name": "ShareTheWorldIntent",
        "samples": [
          "{firstName}",
          "{firstName} と言います",
          "{firstName} だよ",
          "私は {firstName} です",
          "名前は {firstName} です"
        ],
        "slots": [
          {
            "name": "firstName",
            "type": "AMAZON.FirstName"
          }
        ]
      }
    ],
    "invocationName": "ドラクエ"
  }
}

完成したコード

index.js
"use strict";
const Alexa = require('alexa-sdk');
const askNameMsg = '名前を教えてください';
const shareTheWorldMsg = [ 
  'わしが王の中の王<break time="0.1s"/>竜王だ。<break time="0.5s"/>',
  'わし は 待っておった。そなたのような若者が現れることを。<break time="0.5s"/>',
  'もし、わしの味方になれば世界の半分をお前にやろう。<break time="0.5s"/>',
  'どうじゃ?わしの味方になるか?'
].join();
const combatMsg = [
  'では、どうしてもこのわしを倒すというのだな!<break time="0.5s"/>',
  '愚か者め!<break time="0.3s"/>思い知るがよい!'
].join();
const confirmMsg = '本当だな?';
const badEndMsg = [
  'では世界の半分、闇の世界を与えよう!<break time="0.5s"/>',
  'そなたに復活の呪文を教えよう!<break time="0.5s"/>',
  'これを書き留めておくのだぞ。<break time="0.5s"/>',
  'おまえの旅は終わった。<break time="0.5s"/>',
  'さあゆっくり休むがよい!<break time="0.5s"/><say-as interpret-as="interjection">わっはっは</say-as>'
].join();

// ステートの定義
const states = {
  DIALOGUEMODE: '_DIALOGUEMODE',
  FINALQUESTIONMODE: '_FINALQUESTIONMODE'
};

exports.handler = function(event, context, callback) {
  var alexa = Alexa.handler(event, context);
  // alexa.appId = process.env.APP_ID;
  alexa.registerHandlers(handlers, dialogueHandlers,finalquestionHandlers); // 既存のハンドラに加えてステートハンドラ(後半で定義)も登録
  alexa.execute();
};
var handlers = {
  'LaunchRequest': function () {
    this.emit(':ask',askNameMsg);
  },
  'ShareTheWorldIntent': function () {
    var firstName = this.event.request.intent.slots.firstName.value;
    this.handler.state = states.DIALOGUEMODE; // ステートをセット
    this.attributes['firstName'] = firstName; // 名前をセッションアトリビュートにセット
    var message = 'よく来た' + firstName + 'よ。' + shareTheWorldMsg;
    this.emit(':ask', message); 
    console.log(message);
  }
};
// ステートハンドラの定義
var dialogueHandlers = Alexa.CreateStateHandler(states.DIALOGUEMODE, {
  'AMAZON.NoIntent':function() {
    this.handler.state = '';
    this.attributes['STATE'] = undefined;
    this.emit(':tell', combatMsg);
  },
  'AMAZON.YesIntent':function() {
    this.handler.state = states.FINALQUESTIONMODE; // ステートをセット
    this.emit(':ask', confirmMsg);
  },
  'Unhandled': function() {
    var reprompt = 'どうじゃ?わしの味方になるか?';
    this.emit(':ask', reprompt, reprompt);
}
});
var finalquestionHandlers = Alexa.CreateStateHandler(states.FINALQUESTIONMODE, {
  'AMAZON.NoIntent':function() {
    this.handler.state = '';
    this.attributes['STATE'] = undefined;
    this.emit(':tell', combatMsg);
  },
  'AMAZON.YesIntent':function() {
    this.handler.state = '';
    this.attributes['STATE'] = undefined;
    this.handler.state = states.FINALQUESTIONMODE; // ステートをセット
    this.emit(':tell', badEndMsg);
  },
  'Unhandled': function() {
    var reprompt = 'どうじゃ?わしの味方になるか?';
    this.emit(':ask', reprompt, reprompt);
}
});

コードの説明

index.js
"use strict";
const Alexa = require('alexa-sdk');
const askNameMsg = '名前を教えてください';
const shareTheWorldMsg = [ 
  'わしが王の中の王<break time="0.1s"/>竜王だ。<break time="0.5s"/>',
  'わし は 待っておった。そなたのような若者が現れることを。<break time="0.5s"/>',
  'もし、わしの味方になれば世界の半分をお前にやろう。<break time="0.5s"/>',
  'どうじゃ?わしの味方になるか?'
].join();
const combatMsg = [
  'では、どうしてもこのわしを倒すというのだな!<break time="0.5s"/>',
  '愚か者め!<break time="0.3s"/>思い知るがよい!'
].join();
const confirmMsg = '本当だな?';
const badEndMsg = [
  'では世界の半分、闇の世界を与えよう!<break time="0.5s"/>',
  'そなたに復活の呪文を教えよう!<break time="0.5s"/>',
  'これを書き留めておくのだぞ。<break time="0.5s"/>',
  'おまえの旅は終わった。<break time="0.5s"/>',
  'さあゆっくり休むがよい!<break time="0.5s"/><say-as interpret-as="interjection">わっはっは</say-as>'
].join();

竜王のセリフを定義しています。
やたらと喋るので、長いセリフについては可読性の観点から配列で記載して、join()で結合しています。
竜王の発話に威厳を持たせるため、SSML(Speech Synthesis Markup Language : 音声合成マークアップ言語)形式で間を調整しています。
SSMLについては、下記の記事を参考にしてください。

index.js
// ステートの定義
const states = {
  DIALOGUEMODE: '_DIALOGUEMODE',
  FINALQUESTIONMODE: '_FINALQUESTIONMODE'
};

exports.handler = function(event, context, callback) {
  var alexa = Alexa.handler(event, context);
  // alexa.appId = process.env.APP_ID;
  alexa.registerHandlers(handlers, dialogueHandlers,finalquestionHandlers); // 既存のハンドラに加えてステートハンドラ(後半で定義)も登録
  alexa.execute();
};

ステートは2つ定義しました。
名前を聞いて、最初の竜王の問いかけを聞いた時点で、DIALOGUEMODEに入ります。
「いいえ」と答えたらその時点で戦闘に突入して終了します。
「はい」と答えたら、FINALQUESTIONMODEに入って、更に次の質問へと移ります。

index.js
var handlers = {
  'LaunchRequest': function () {
    this.emit(':ask',askNameMsg);
  },
  'ShareTheWorldIntent': function () {
    var firstName = this.event.request.intent.slots.firstName.value;
    this.handler.state = states.DIALOGUEMODE; // ステートをセット
    this.attributes['firstName'] = firstName; // 名前をセッションアトリビュートにセット
    var message = 'よく来た' + firstName + 'よ。' + shareTheWorldMsg;
    this.emit(':ask', message); 
    console.log(message);
  }
};

まず、LaunchRequestに入って、名前を受け取ります。
そしてShareTheWorldIntentに入って、ステートDIALOGUEMODEをセットしてから、受け取った名前で呼びかけてあげます。

index.js
// ステートハンドラの定義
var dialogueHandlers = Alexa.CreateStateHandler(states.DIALOGUEMODE, {
  'AMAZON.NoIntent':function() {
    this.handler.state = '';
    this.attributes['STATE'] = undefined;
    this.emit(':tell', combatMsg);
  },
  'AMAZON.YesIntent':function() {
    this.handler.state = states.FINALQUESTIONMODE; // ステートをセット
    this.emit(':ask', confirmMsg);
  },
  'Unhandled': function() {
    var reprompt = 'どうじゃ?わしの味方になるか?';
    this.emit(':ask', reprompt, reprompt);
}
});

ステートがDIALOGUEMODEの状態で、dialogueHandlersにやって来ます。

答えが「いいえ」の場合、AMAZON.NoIntentに入り、this.emitの引数が':tell'になっているため、戦闘に突入して終了します。ステートのリセットが必要かよくわかりませんでしたが、書いてみました。

「はい」の場合、AMAZON.YesIntentに入り、ステートをFINALQUESTIONMODEに更新して、追加の質問をします。

「はい/いいえ」どちらにも当てはまらなかった場合は、Unhandledに入って、もう一回同じ質問を繰り返します。竜王は我慢強いのです。

index.js
var finalquestionHandlers = Alexa.CreateStateHandler(states.FINALQUESTIONMODE, {
  'AMAZON.NoIntent':function() {
    this.handler.state = '';
    this.attributes['STATE'] = undefined;
    this.emit(':tell', combatMsg);
  },
  'AMAZON.YesIntent':function() {
    this.handler.state = '';
    this.attributes['STATE'] = undefined;
    this.handler.state = states.FINALQUESTIONMODE; // ステートをセット
    this.emit(':tell', badEndMsg);
  },
  'Unhandled': function() {
    var reprompt = 'どうじゃ?わしの味方になるか?';
    this.emit(':ask', reprompt, reprompt);
}
});

前の質問で「はい」と答えると、ステートがFINALQUESTIONMODEであるため、最後の質問に入ります。
「はい/いいえ」どちらにしても、`:tell'で応答してセッションが終了します。

おわりに

あとは竜王を倒すのみです。検討を祈ります。

参考リンク