Alexaには、APL(Alexa Presentation Language)という機能があり、Echo ShowやFireタブレットによるShowモードで、画面付きのスマートスピーカのための画面表示をするための仕組みがあります。
手元に、Echo Showはないですが、ブラウザ上で試せるシミュレータがあるので、試しながらゲームブック実行環境を作ってみようと思います。
もろもろのソースコードは以下のGitHubに置いてあります。
poruruba/AlexaAplGamebook
参考ページ
・Alexa Presentation Language(APL)
https://developer.amazon.com/ja-JP/docs/alexa/alexa-presentation-language/understand-apl.html
スキルを新規作成
以下のページからまずはAlexaスキルを作成します。
alexa developer console
https://developer.amazon.com/alexa/console/ask
「スキルを作成」ボタンを押下します。
スキル名は適当にたとえば「テストゲームブック」とし、カスタムとalexa-hosted(Node.js)を選択します。
最後に、右上の「スキルを作成」ボタンを押下します。
スクラッチで作成を選択して、「テンプレートで続ける」ボタンを押下します。
作成が完了すると、以下のように表示されます。
インテントを追加
以下のインテントを作ります。
・GameStartIntet
ゲームブックを最初から始めるときの言葉です。
サンプル発話は、「始める」です。
・ChoiceNextIntent
ゲームブックのシーンを進めるときの言葉です。
サンプル発話は、「次」と「次へ」です。
・ChoiceSelectIntent
ゲームブックでの選択肢を選択するときの言葉です。
サンプル発話は、「{select}」と「{select}番」です。{select}は、インテントスロットに追加するもので、スロットタイプは、「AMAZON.NUMBER」にします。
HelloWorldIntentは使わないので削除します。
次に、左側のナビゲーションメニューから、インターフェースを選択します。
するとそこに、「Alexa Presentation Language」がありますのでそれをOnにして有効にします。
最後に、「インターフェースを保存」ボタンを押下します。
次に、同様に左側のナビゲーションメニューから、エンドポイントを選択します。
サービスのエンドポイントの種類として、HTTPSを選択します。Lambdaでもよいですが、まずはのちほど自分でLambda実行環境を立ち上げます。
デフォルトの地域のところに、これから立ち上げるLambda実行環境のURLを指定します。
https://【立ち上げるLambda実行環境のホスト名】/apl-gamebook
「開発用のエンドポイントには、信頼された証明機関が発行した証明書があります。」
立ち上げるホストのポート番号は443である必要があります。
最後に、「エンドポイントを保存」ボタンを押下します。
左側のナビゲーションメニューからカスタムを選択して最初の画面に戻ったら、3.モデルをビルド> をクリックします。ビルドが始まります。少し時間がかかりますが、完了すると、画面右下にポップアップで表示してくれます。
画面をデザインする
左側のナビゲーションメニューから、マルチモーダルをクリックします。
そして、「Visual」タブを選択し、「Create Visual Response」ボタンを押下します。
「空のドキュメント」ボタンを押下します。
以下のようなページが表示されます。
APLオーサリングツールというそうです。
このページで、表示された状態を見ながら画面を作ることができます。
ですが、すでに作ってありますので、改めての作成不要です。
Lambdaサーバを立ち上げる
以下のGitHubサイトから丸ごとZIPダウンロードします。
https://github.com/poruruba/AlexaAplGamebook
> unzip AlexaAplGamebook-master.zip
> cd AplGamebook-master
> mkdir cert
> npm install
> touch .env
HTTPSで立ち上げる必要があるため、certフォルダに、SSL証明書を置く必要があります。
ポート番号は.envファイルに以下を記載します。
SPORT=443
あとは、以下で立ち上がります。これで、インテントが飛んでくるので、ソースコードにブレイクを入れたりして手元でデバッグできるようになります。
> node app.js
ゲームブックのシナリオファイルの作成
ゲームブックのシナリオファイルを作成します。APLとは関係ないです。
以下にサンプルを記載しておきました。お好みで編集してください。
/api/controllers/apl-gamebook/scenario.json
下記にある通り、ビデオファイルや画像ファイルは、いずれかからとってきて、以下のフォルダに格納しておきます。
/public/media/
{
"scenes": [
{
"id": "id0",
"type": "normal",
"title": "昔話",
"backgroundImage": "https://【立ち上げたサーバのホスト名】/media/bg_yozora_night_sky.jpg",
"sentences": [
"昔々あるところに、おじいさんとおばあさんがいました。"
],
"players": [
{
"image_src": "https:// 【立ち上げたサーバのホスト名】/media/ojiisan.png",
"height": 80,
"position": 4
},
{
"image_src": "https:// 【立ち上げたサーバのホスト名】/media/obaasan.png",
"height": 60,
"position": 8
}
],
"choices": {
"text": "選択してください",
"choices": [
{
"text": "おじいさんと話す",
"choice_id": "id1"
},
{
"text": "おばあさんと話す",
"choice_id": "id2"
}
]
}
},
{
"id": "id1",
"type": "normal",
"headerTitle": "昔話",
"backgroundImage": "https:// 【立ち上げたサーバのホスト名】/media/bg_yozora_night_sky.jpg",
"sentences": [
"こんにちは。",
"わしはおじいさんです。"
],
"players": [
{
"image_src": "https:// 【立ち上げたサーバのホスト名】 /media/ojiisan.png",
"height": 80,
"position": 6
}
],
"choices": {
"text": "選択してください",
"choices": [
{
"text": "戻る",
"choice_id": "id0"
}
]
}
},
{
"id": "id2",
"type": "normal",
"headerTitle": "昔話",
"backgroundImage": "https:// 【立ち上げたサーバのホスト名】/media/bg_yozora_night_sky.jpg",
"sentences": [
"こんにちは",
"わたしはおばあさんです。"
],
"players": [
{
"image_src": "https:// 【立ち上げたサーバのホスト名】/media/obaasan.png",
"height": 60,
"position": 6
}
],
"choices": {
"text": "選択してください",
"choices": [
{
"text": "戻る",
"choice_id": "id0"
}
]
}
},
{
"id": "0",
"type": "video",
"video_src": "https:// 【立ち上げたサーバのホスト名】/media/Pexels Videos 4703.mp4",
"title": "昔話",
"choice_id": "id0",
"sentences": [
"ビデオが流れます。"
]
}
]
}
上記で利用しているフォーマットは以下の通りです。
{
"scenes": [
{
"id": "【シーンのID(0がスタートシーン)】",
"type": "【normalまたはvideo。以降はnormalの場合】",
"title": "【タイトル】",
"headerTitle": "【ヘッダーサブタイトル】",
"backgroundImage": "【背景画像ファイルのURL】",
"sentences": [
"【表示・発話したい文書】"
],
"players": [
{
"image_src": "【登場人物画像ファイルのURL】",
"height": 【登場人物の高さ(%)】,
"position": 【登場人物の位置(0~12、6が中央)】
}
],
"choices": {
"text": "【選択肢のページの文章】",
"choices": [
{
"text": "【選択肢の文章】",
"choice_id": "【選択後のシーンID】"
}
]
}
{
"id": "【シーンのID(0がスタートシーン)】",
"type": "【normalまたはvideo。以降はvideoの場合】",
"video_src": "【ビデオファイルのURL】",
"title": "【タイトル】",
"choice_id": "【ビデオ表示後のシーンID】",
"sentences": [
"【ビデオが表示できない端末用の文章】"
]
}
]
}
ソースコード解説
'use strict';
const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const AskUtils = require(HELPER_BASE + 'alexa-utils');
const Alexa = require('ask-sdk-core');
const app = new AskUtils(Alexa);
const gamebookDocumentBase = require('./gamebookDocumentBase.json');
const gamebookVideoDocumentBase = require('./gamebookVideoDocumentBase.json');
const scenario = require('./scenario.json');
const styleResource = require('./styleResource.json');
const HELLO_WORLD_TOKEN = 'helloworldToken';
const CHOICE_INTENT = 'choice';
const PARAMETER_KEY = "gamebookStyles";
const SCENE_START = "0";
app.intent("ChoiceSelectIntent", async (handlerInput) => {
console.log(handlerInput);
var builder = handlerInput.responseBuilder;
var slots = app.getSlots(handlerInput);
var select = parseInt(slots.select.value);
var attributes = app.getAttributes(handlerInput);
var choice_id = findChoiceId(scenario, attributes.current_id, select - 1);
attributes.current_id = choice_id;
app.setAttributes(handlerInput, attributes);
var scene = scenario.scenes.find(item => item.id == choice_id);
appendSceneDocument(handlerInput, builder, scene);
return builder.getResponse();
});
app.intent("ChoiceNextIntent", async (handlerInput) => {
console.log(handlerInput);
var builder = handlerInput.responseBuilder;
var select = 1;
var attributes = app.getAttributes(handlerInput);
var choice_id = findChoiceId(scenario, attributes.current_id, select - 1);
attributes.current_id = choice_id;
app.setAttributes(handlerInput, attributes);
var scene = scenario.scenes.find(item => item.id == choice_id);
appendSceneDocument(handlerInput, builder, scene);
return builder.getResponse();
});
app.intent('LaunchRequest', async (handlerInput) => {
console.log(handlerInput);
var builder = handlerInput.responseBuilder;
builder.speak('始める、と言ってください。');
builder.reprompt('始める、と言ってください。');
return builder.getResponse();
});
app.intent('StopIntent', async (handlerInput) => {
console.log(handlerInput);
var builder = handlerInput.responseBuilder;
builder.speak('さようなら');
builder.withShouldEndSession(true);
return builder.getResponse();
});
app.intent('GameStartIntent', async (handlerInput) => {
var builder = handlerInput.responseBuilder;
var choice_id = SCENE_START;
var attributes = app.getAttributes(handlerInput);
attributes.current_id = choice_id;
app.setAttributes(handlerInput, attributes);
var scene = scenario.scenes.find(item => item.id == choice_id);
appendSceneDocument(handlerInput, builder, scene);
return builder.getResponse();
});
app.userEvent(undefined, async (handlerInput) => {
var builder = handlerInput.responseBuilder;
var request = app.getUserEventRequest(handlerInput);
var choice_id = request.arguments[0].choice_id;
var attributes = app.getAttributes(handlerInput);
attributes.current_id = choice_id;
app.setAttributes(handlerInput, attributes);
var scene = scenario.scenes.find(item => item.id == choice_id);
appendSceneDocument(handlerInput, builder, scene);
return builder.getResponse();
});
app.userEvent(CHOICE_INTENT, async (handlerInput) => {
var builder = handlerInput.responseBuilder;
var request = app.getUserEventRequest(handlerInput);
var choice_id = request.arguments[0].choice_id;
var attributes = app.getAttributes(handlerInput);
attributes.current_id = choice_id;
app.setAttributes(handlerInput, attributes);
var scene = scenario.scenes.find(item => item.id == choice_id);
appendSceneDocument(handlerInput, builder, scene);
return builder.getResponse();
});
exports.handler = app.lambda();
function appendSceneDocument(handlerInput, builder, scene) {
if (Alexa.getSupportedInterfaces(handlerInput.requestEnvelope)['Alexa.Presentation.APL']) {
var gamebookbase = makeDocument(scene, PARAMETER_KEY);
builder.addDirective(app.buildRenderDocumentDirective(HELLO_WORLD_TOKEN, gamebookbase, styleResource));
if (scene.type != 'video') {
var sentence = makeSentence(scene);
builder.speak(sentence);
builder.reprompt('次と言ってください');
}
} else {
var sentence = makeSentence(scene);
builder.speak(sentence);
builder.reprompt('番号を言ってください');
}
}
function findChoiceId(scenario, current_id, select) {
var scene = scenario.scenes.find(item => item.id == current_id);
if (!scene)
return null;
if( scene.type == 'video')
return scene.choice_id;
var choice = scene.choices.choices[select];
if (!choice)
return null;
return choice.choice_id;
}
function makeDocument(scene, param_key) {
if (!scene)
return null;
if (scene.type == 'normal') {
var gamebookbase = JSON.parse(JSON.stringify(gamebookDocumentBase));
setParameterKey(gamebookbase, param_key);
setBackgroundImage(gamebookbase, scene.backgroundImage);
setTitle(gamebookbase, scene.title);
setHeaderTitle(gamebookbase, scene.headerTitle);
if (scene.players && scene.players.length > 0) {
for (var i = 0; i < scene.players.length; i++)
pushPlayer(gamebookbase, scene.players[i].image_src, scene.players[i].height, scene.players[i].position);
}
if (scene.sentences && scene.sentences.length > 0) {
appendSentencePage(gamebookbase);
for (var i = 0; i < scene.sentences.length; i++)
appendText(gamebookbase, scene.sentences[i]);
}
if (scene.choices) {
appendChoicePage(gamebookbase, scene.choices.text);
for (var i = 0; i < scene.choices.choices.length; i++)
appendChoice(gamebookbase, CHOICE_INTENT, scene.choices.choices[i].text, { choice_id: scene.choices.choices[i].choice_id });
}
console.log(JSON.stringify(gamebookbase));
return gamebookbase;
} else if (scene.type == "video") {
var gamebookbase = JSON.parse(JSON.stringify(gamebookVideoDocumentBase));
setParameterKey(gamebookbase, param_key);
setVideoTitle(gamebookbase, scene.title);
setVideo(gamebookbase, CHOICE_INTENT, scene.video_src, { choice_id: scene.choice_id });
console.log(JSON.stringify(gamebookbase));
return gamebookbase;
}
}
function makeSentence(scene) {
if (!scene)
return null;
if (scene.type == 'normal') {
var sentence = '';
if (scene.sentences && scene.sentences.length > 0) {
for (var i = 0; i < scene.sentences.length; i++)
sentence += scene.sentences[i] + '。';
}
if (scene.choices) {
sentence += scene.choices.text + '。';
for (var i = 0; i < scene.choices.choices.length; i++)
sentence += String(i + 1) + '、' + scene.choices.choices[i].text + '。';
}
console.log(sentence);
return sentence;
} else if (scene.type == "video") {
var sentence = '';
if (scene.sentences && scene.sentences.length > 0) {
for (var i = 0; i < scene.sentences.length; i++)
sentence += scene.sentences[i] + '。';
}
return sentence;
}
}
function getPlayerItems(document) {
return document.mainTemplate.items[0].items[2].items;
}
function getContentItems(document) {
return document.mainTemplate.items[0].items[3].items;
}
function getPagerItems(document) {
var content = getContentItems(document);
return content[1].item[0].items;
}
function getSentencePage(document) {
var pager = getPagerItems(document);
var page = pager[0];
if (page == null || page.type != 'Sequence')
return null;
else
return page;
}
function getChoicePage(document) {
var pager = getPagerItems(document);
var page = pager[pager.length - 1];
if (page == null || page.type != 'Container')
return null;
else
return page;
}
function setParameterKey(document, param_key){
document.mainTemplate.parameters.push(param_key);
}
function setBackgroundImage(document, image){
document.mainTemplate.items[0].items[0].backgroundImageSource = image;
}
function setHeaderTitle(document, title) {
document.mainTemplate.items[0].items[1].text = title;
}
function setTitle(document, title) {
var content = getContentItems(document);
content[0].text = title;
}
function pushPlayer(document, image, height, position){
var pos = Math.floor((position - 6) * (50 / 6));
var obj = {
"type": "Image",
"height": height + "%",
"width": "100%",
"scale": "best-fit",
"source": image,
"position": "absolute",
"align": "bottom",
"left": pos + "%"
};
var items = getPlayerItems(document);
items.push(obj);
}
function appendText(document, text){
var page = getSentencePage(document);
if( page == null )
return;
var index = page.items.length;
var obj = {
"type": "Text",
"id": "para_" + index,
"text": text,
"speech": text,
"color": "${gamebookStyles.gb_font_color}",
"paddingBottom": "${gamebookStyles.gb_para_padding}",
"fontSize": "${gamebookStyles.gb_font_size}"
};
page.items.push(obj);
}
function appendSentencePage(document) {
var obj = {
"type": "Sequence",
"padding": "${gamebookStyles.gb_padding}",
"items": [
]
};
var content = getContentItems(document);
content[1].item[0].items.push(obj);
}
function appendChoicePage(document, text){
var obj = {
"type": "Container",
"padding": "${gamebookStyles.gb_padding}",
"items": [
{
"type": "Text",
"id": "para_choice",
"text": text,
"speech": text,
"color": "${gamebookStyles.gb_font_color}",
"paddingBottom": "${gamebookStyles.gb_para_padding}",
"fontSize": "${gamebookStyles.gb_font_size}"
}
]
};
var content = getContentItems(document);
content[1].item[0].items.push(obj);
}
function appendChoice(document, id, text, argument){
const number_text = [ '①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨' ];
var page = getChoicePage(document);
if( page == null )
return;
var index = page.items.length - 1;
var obj = {
"type": "TouchWrapper",
"id": id,
"item": {
"type": "Text",
"text": number_text[index] + " " + text,
"speech": String(index + 1) + "、" + text,
"color": "${gamebookStyles.gb_font_color}",
"fontSize": "${gamebookStyles.gb_font_size}"
},
"onPress": [
{
"type": "SendEvent",
"arguments": [
argument
]
}
]
};
page.items.push(obj);
}
function setVideo(document, intent, video, argument){
document.mainTemplate.items[0].items[0].id = intent;
document.mainTemplate.items[0].items[0].source = video;
document.mainTemplate.items[0].items[0].onEnd[0].arguments.push(argument);
}
function setVideoTitle(document, title) {
document.mainTemplate.items[0].items[1].text = title;
}
・app.intent("ChoiceSelectIntent", async (handlerInput) => {
ユーザが選択肢(1とか2とか)を言ったときに呼ばれます。
・app.intent("ChoiceNextIntent", async (handlerInput) => {
ユーザが「次へ」と言ったときに呼ばれます。
・app.intent('LaunchRequest', async (handlerInput) => {
このスキル「テストゲームブック」が起動したときに呼ばれます。
・app.intent('StopIntent', async (handlerInput) => {
このスキルが終了したときに呼ばれます。
・app.intent('GameStartIntent', async (handlerInput) => {
ユーザが「始める」と言ったときに呼ばれます。
・app.userEvent(undefined, async (handlerInput) => {
名前なしのコンポーネントでSendEventが呼ばれたときに呼ばれます。
通常は、名前付きのコンポーネントなのですが、onMountなどの名前なしの場所から呼ばれる場合に使われますが、今回は使っていません。
・app.userEvent(CHOICE_INTENT, async (handlerInput) => {
名前付きのコンポーネントでSendEventが呼ばれたときに呼ばれます。
その他細かなAlexaに関連する処理は以下で実装しています。
/api/helpers/alexa-utils.js
ご参考
SwaggerでLambdaのデバッグ環境を作る(4):Alexaをデバッグする
画面は、以下にテンプレートとして作成しておいたので、シナリオファイルの内容に従って、シーンごとに書き換えています。これが先ほどのAPLオーサリングツールで作成したものです。
/api/controllers/apl-gamebook/gamebookDocumentBase.json
/api/controllers/apl-gamebook/gamebookVideoDocumentBase.json
もし、フォントの大きさや文字色などのスタイルを変えたい場合は以下にまとめておきましたので編集してください。
/api/controllers/apl-gamebook/styleResource.json
Alexaシミュレータで動作を確認する
alexa developer consoleに戻って、「テスト」タブを選択します。
そして、「スキルテストが有効になっているステージ」を「開発中」に選択しなおします。
あとはこんな感じで進みます。
ビデオ再生が終わると、
下側のダイアログの領域をスワイプすると、選択肢が現れます。
選択肢をクリックすれば、そのシーンに遷移します。
または、「1番」とか「2番」と言っても遷移できます。
最後にLambdaにアップロードする
以下のフォルダをLambdaにアップしてください。
/api/controllers/apl-gamebook/
ただし、npmモジュールである「ask-sdk-core」も使っていますので、このフォルダにインストールしてZIPで固めてからアップしてください。
> cd api/controllers/api-gamebook/
> npm init -y
> npm install ask-sdk ask-sdk-core ask-sdk-model
Lambdaへの切り替えは、alexa developer consoleのエンドポイントのところです。
使わせていただいたコンテンツ
いらすとや
https://www.irasutoya.com/
Pexels
https://www.pexels.com/ja-jp/videos/
以上