最近LINEボットを扱っていなかったので、久しぶりに動かしてみました。
題材にしたのは「ゲームブック」です。
ゲームブックを知らない方もいるかもしれないので、補足すると、小説のような読み物ではあるのですが、途中で選択肢が出てきて、そのときの状況を読者が判断して適切な選択肢を繰り返し選択していくと、うまくいけばゴール、そうでない場合は途中で終了となります。面白いのが、選択肢の選択は、指定されたページに移動することであって、ページ番号は意味のない数字なので、ちゃんと順番に選択していかないと、ゴールにはたどり着きません。ゴールのページがどこにあるのかわからないからです。
今回は、これをLINEのチャットで実現します。
1チャット1ページで、チャットの内容から判断して適切な次の選択肢を選択すると、次のページの内容がチャットで返ってきます。これを繰り返すわけです。
チャットは、基本的に、「テキスト」と「次に進む選択肢」からなります。
で、せっかく、インタラクティブなLINEチャットを使うので、以下の機能を付けています。
- チャットに画像ファイルを張り付けられます。さらに、複数の登場人物を指定することで、背景に人物を重ね合わせて表示させました。
- 音声ファイルを付けられるようにしました。チャットの表示に合わせて、音声再生することで、臨場感が増します。
- 複数のページにまたがって、フラグを保持するようにしました。特定のページにたどり着くことでフラグを得たり、フラグを失ったりします。本ゲームブック内では持ち物と言っています。
- 持ち物の状態によって、次の選択肢を追加したり削除したり、画像に重ね合わせる登場人物を追加したり削除したりできるようにしました。
あとは、シナリオファイルにページの内容や次の選択肢を書いていけばできあがります。
シナリオファイルを、JSONファイルで記述します。後でフォーマットを示しておきます。
シナリオファイルには、複数のシーンを配列で記述します。このシーンが、1ページに相当します。
最初のシナリオから別のシナリオに移動することもできます。チャプターの切り替えのようなもので、新しいシーンになったら、持ち物はクリアされます。なので、もし行き詰まってやり直しする場合も、シナリオ単位で管理しているため、シナリオの最初に戻れるようにしています。
以下が、実際にシナリオを組んでみたときのものです。
GitHubにソースコード一式上げておきます。
pururuba/LinebotGamebook
https://github.com/poruruba/LinebotGamebook
シナリオファイルフォーマット
以下、例示とともに、説明を付記しておきます。
{
"title": "王様に会いに行く", // (任意) シナリオの名前
"scene":[
{
"id": "0", // (必須) シーンの識別子。シナリオの最初は必ず "0" から始まる
"title": "序章:始まりの街", // (任意) シーンのタイトル。チャットに太字で表示される。
"text": "目覚めると、見知らぬ城下街に立っていた。ポケットに金貨5枚。こういう時は、まずは王様に会いに行くべきだろう。", // (必須) チャットに表示されるテキスト。
"image": { // (任意) 表示する画像ファイル
"background": "公園", // (必須) 背景となる画像名。png形式
"composite": [ // (任意) 重ね合わせる画像。出現順で重ね合わせる。
{
"name": "跳ねてる子", // (必須) 重ね合わせる画像名。png形式。
"have": ["アイス"], // (任意) いずれの持ち物も所持していないと画像が重ね合わされない
"nothave": ["金貨5枚"] // (任意) いずれか持ち物を所持しいると画像が重ね合わされない
}
]
},
"audio": { // (任意) 再生する音声ファイル
"name": "街の中心", // (必須) 再生する音声。m4a形式
"have": ["アイス"], // (任意) いずれの持ち物も所持していないと音声が再生されない
"nothave": ["金貨5枚"] // (任意) いずれか持ち物を所持しいると音声が再生されない
},
"selection": [ // (任意) 次の選択肢
{
"type": "scene", // 次の選択肢の区別。"scene"か"scenario"。未指定のばあいは、"scene"
"id": "1", // 次の選択肢の識別子。type="scene"の場合はシーンの識別子、type="scenario"の場合はシナリオファイル名。json形式。
"title": "街の中心に行く", // (必須)次の選択肢の表示文字
"have": ["アイス"], // (任意) いずれの持ち物も所持していないと選択肢が表示されない
"nothave": ["金貨5枚"] // (任意) いずれか持ち物を所持しいると選択肢が表示されない
}
],
"acquire": ["アイス"], // (任意) 本シーンにたどり着いたときに獲得する持ち物
"lost": ["金貨5枚"] // (任意) 本シーンにたどり着いたときに失う持ち物
}
]
}
ちょっと補足です。
- idが、ページ番号に相当します。
- textが各ページの表示文です。
- selectionが、次に進むべきページの選択肢です。
- haveやnothaveがありますが、そのときの持ち物の状態によって、有効化したり無効化したりするためのものです。
- acquireやlostが、そのページにたどり着いたときの持ち物を獲得したり失ったりするためのものです。
シナリオファイル中に出てくる画像や音声名に対応するファイル名は、以下に従う必要があります。
シナリオファイル
ファイル名:シナリオ名.json
形式:JSON形式
画像ファイル
ファイル名:画像名.png
形式:PNG
音声ファイル
音声名.m4a
形式:AAC
格納場所は、AWSを想定した場合と、ローカル実行を想定した場合の2種類に対応できるようにしました。
AWSを想定した場合
シナリオファイル、画像ファイル、音声ファイル:S3
ロジック(Node.js):Lambda+API Gateway
ユーザごとの状態:DynamoDB
ローカル実行を想定した場合
シナリオファイル、画像ファイル、音声ファイル:ファイル
ロジック(Node.js):express
ユーザごとの状態:ファイル(JSON)
LINEチャットのメッセージの形式
LINEチャットの返信には、内部では、LINEのテンプレートメッセージの機能を使っています。いくつかのタイプがありますが、そのうちの「ボタン」を使っています。
ボタンテンプレート
https://developers.line.biz/ja/reference/messaging-api/#buttons
ロジック
入り口として2つのエンドポイントがあります。
・linebotエンドポイント
LINEでユーザがチャットするとそれを受け取ってシナリオに沿ったページのチャットを返します。
・linebot_image
画像の重ね合わせを処理し、重ね合わせた結果をmimetype=image/png のバイナリ形式で返します。
linebotエンドポイント
'use strict';constIMAGE_URL_BASE="【linebot_imageのエンドポイントURL】";constAUDIO_URL_BASE="【音声ファイルの公開URL】";constline=require('@line/bot-sdk');constmm=require('music-metadata');constHELPER_BASE=process.env.HELPER_BASE||'../../helpers/';constconfig={channelAccessToken:process.env.LINE_CHANNEL_ACCESS_TOKEN,// LINEのチャネルアクセストークン(長期)channelSecret:process.env.LINE_CHANNEL_SECRET,// LINEのチャネルシークレット};constLineUtils=require(HELPER_BASE+'line-utils');constapp=newLineUtils(line,config);constDEFAULT_SCENARIO=process.env.DEFAULT_SCENARIO||'scenario0';constDEFAULT_SCENE=process.env.DEFAULT_SCENE||'0';/* S3用 */constSCENARIO_BUCKET=process.env.SCENARIO_BUCKET||'gamebook';constSCENARIO_OBJECT_BASE='scenario/';constAUDIO_BUCKET=process.env.AUDIO_BUCKET||'gamebook';constAUDIO_OBJECT_BASE='audio/';/* ファイル用 */constSCENARIO_FILE_BASE='./public/gamebook/scenario/';constAUDIO_FILE_BASE='./public/gamebook/audio/';constSTATE_FILE_BASE='./data/gamebook/';constfs=require('fs').promises;constTABLE_NAME="gamebook";constAWS=require("aws-sdk");AWS.config.update({region:"ap-northeast-1",});constdocClient=newAWS.DynamoDB.DocumentClient({// 必要に応じて});vars3=newAWS.S3({// 必要に応じて});asyncfunctionload_scenario(name){/*
// S3用
var param_get = {
Bucket: SCENARIO_BUCKET,
Key: SCENARIO_OBJECT_BASE + name + ".json"
};
var image = await s3.getObject(param_get).promise();
return JSON.parse(image.Body.toString());
*/// ファイル用varbuffer=awaitfs.readFile(SCENARIO_FILE_BASE+name+'.json',"utf-8");returnJSON.parse(buffer.toString());}asyncfunctionload_audio(name){/*
// S3用
var param_get = {
Bucket: AUDIO_BUCKET,
Key: AUDIO_OBJECT_BASE + name + ".m4a"
};
var image = await s3.getObject(param_get).promise();
return image.Body;
*/// ファイル用returnawaitfs.readFile(AUDIO_FILE_BASE+name+'.m4a');}asyncfunctionload_status(userid){/*
// S3用
var params_get = {
TableName: TABLE_NAME,
Key: {
userid: userid,
}
};
var result = await docClient.get(params_get).promise();
return result.Item;
*/// ファイル用try{varstatus=awaitfs.readFile(STATE_FILE_BASE+userid+'.json','utf8');returnJSON.parse(status);}catch(error){returnundefined;}}asyncfunctioncreate_status(userid,scenario,scene){return{userid:userid,scenario:scenario,scene:scene,turn:0,items:[],};}asyncfunctioninsert_status(status){/*
// S3用
var params_put = {
TableName: TABLE_NAME,
Item: status
};
return await docClient.put(params_put).promise();
*/// ファイル用returnfs.writeFile(STATE_FILE_BASE+status.userid+'.json',JSON.stringify(status),'utf8');}asyncfunctionupdate_status(status){/*
// S3用
var params_update = {
TableName: TABLE_NAME,
Key: {
userid: status.userid
},
ExpressionAttributeNames: {
'#attr1': 'scenario',
'#attr2': 'scene',
'#attr3': 'items',
'#attr4': 'turn',
},
ExpressionAttributeValues: {
':attrValue1': status.scenario,
':attrValue2': status.scene,
':attrValue3': status.items,
':attrValue4': status.turn,
},
UpdateExpression: 'SET #attr1 = :attrValue1, #attr2 = :attrValue2, #attr3 = :attrValue3 , #attr4 = :attrValue4',
ConditionExpression: "attribute_exists(userid)",
ReturnValues:"ALL_NEW"
};
return await docClient.update(params_update).promise();
*/// ファイル用returninsert_status(status);}functionadd_item(item_list,item){if(item_list.indexOf(item)>=0)returnfalse;item_list.push(item);returntrue;}functionremove_item(item_list,item){varindex=item_list.indexOf(item);if(index<0)returnfalse;item_list.splice(index,1);returntrue;}functionhas_item(item_list,item){return(item_list.indexOf(item)>=0);}functioncheck_condition(items,have,nothave){varcondition=true;// haveのアイテムの所持確認if(have){have.forEach(item=>{if(!has_item(items,item)){condition=false;return;}});}// nothaveのアイテムの非所持確認if(nothave){nothave.forEach(item=>{if(has_item(items,item)){condition=false;return;}});}returncondition;}app.message(async(event,client)=>{try{console.log(event);varcmd_processed=false;// userIdのステータスをDBから取得varstatus=awaitload_status(event.source.userId);if(!status){// 初めてのuserIdの場合はステータスを生成status=awaitcreate_status(event.source.userId,DEFAULT_SCENARIO,DEFAULT_SCENE);// DBに同期awaitinsert_status(status);}if(event.message.text=='ヘルプ'){// コマンド:ヘルプvarmessage={type:"text",text:"コマンドリスト\nリタイア:最初からやり直し\nリセット:今のシナリオの最初に戻る\nリロード:再表示\n持ち物:持ち物の確認\nステータス:シナリオ名の確認",quickReply:{items:[{type:'action',action:{type:"message",label:"リセット",text:"リセット"}},{type:'action',action:{type:"message",label:"リロード",text:"リロード"}},{type:'action',action:{type:"message",label:"持ち物",text:"持ち物"}},{type:'action',action:{type:"message",label:"ステータス",text:"ステータス"}},]}};returnclient.replyMessage(event.replyToken,message);}varcom_list=event.message.text.split('');switch(com_list[0]){case'リタイア':{// コマンド:リタイアstatus=awaitcreate_status(event.source.userId,DEFAULT_SCENARIO,DEFAULT_SCENE);cmd_processed=true;break;}case'リセット':{// コマンド:リセットstatus.scene=DEFAULT_SCENE;status.items=[];cmd_processed=true;break;}case'リロード':{// コマンド:リロードcmd_processed=true;break;}case'scenario':{// シナリオの変更if(parseInt(com_list[2])!=(status.turn+1))throw"turn is invalid";status.scenario=com_list[1];status.scene=DEFAULT_SCENE;cmd_processed=true;break;}case'scene':{// シーンの変更if(parseInt(com_list[2])!=(status.turn+1))throw"turn is invalid";status.scene=com_list[1];cmd_processed=true;break;}}// ターン番号をインクリメントstatus.turn++;// 現在のシナリオを取得constscenario=awaitload_scenario(status.scenario);if(!scenario)throw"scenario not found";// 現在のシーンを取得varscene=scenario.scene.filter(item=>item.id==status.scene);if(scene.length<=0)throw"scene not found";if(event.message.text=='持ち物'||event.message.text=='もちもの'){// コマンド:持ち物varmessage={type:"text",text:"現在の持ち物"};if(status.items.length>0){status.items.forEach(item=>{message.text+="\n・"+item;});}else{message.text+="はありません";}cmd_processed=true;returnclient.replyMessage(event.replyToken,message);}elseif(event.message.text=='ステータス'){// コマンド:ステータスvarmessage={type:"text",text:"シナリオ名:"+scenario.title+"\nシーン番号:"+status.scene,};cmd_processed=true;returnclient.replyMessage(event.replyToken,message);}varmessages=[];if(!cmd_processed){// 不明なコマンドvarmessage={type:"text",text:"不明なコマンド",};messages.push(message);}// 獲得アイテムの処理if(scene[0].acquire){scene[0].acquire.forEach(item=>{varacquire=add_item(status.items,item);varmessage={type:"text",text:acquire?(item+"を手に入れた"):("すでに"+item+"を持っている")}messages.push(message);});}// ロストアイテムの処理if(scene[0].lost){scene[0].lost.forEach(item=>{varlost=remove_item(status.items,item);if(lost){varmessage={type:"text",text:item+"を失った"}messages.push(message);}});}// メインダイアログvartemplate={type:"template",altText:scene[0].text,template:{type:"buttons",text:scene[0].text,actions:[]}}if(scene[0].image){// 画像が指定されていた場合varimage_url=IMAGE_URL_BASE+scene[0].image.background;// 画像合成が指定されていた場合if(scene[0].image.composite){scene[0].image.composite.forEach(select=>{// アイテムの所持・非所持確認varcondition=check_condition(status.items,select.have,select.nothave);if(!condition)return;// 合成画像の指定追加image_url+='-'+select.name;if(select.position!=undefined)image_url+='_'+select.position;});}template.template.thumbnailImageUrl=image_url;template.template.imageSize='contain';}if(scene[0].title){// タイトルが指定されていた場合template.template.title=scene[0].title;}// 次の選択肢if(scene[0].selection){scene[0].selection.forEach(select=>{// アイテムの所持・非所持確認varcondition=check_condition(status.items,select.have,select.nothave);if(!condition)return;// 選択肢の追加vartype="scene";if(select.type)type=select.type;template.template.actions.push({type:"message",label:select.title+'('+select.id+')',text:type+""+select.id+""+(status.turn+1),});});}if(template.template.actions.length==0){template.template.actions.push({type:"message",label:"シナリオの最初に戻る",text:"リセット",});template.template.actions.push({type:"message",label:"最初から始める",text:"リタイア",});}messages.push(template);if(scene[0].audio){// 音声ファイルが指定されていた場合// アイテムの所持・非所持確認varcondition=check_condition(status.items,scene[0].audio.have,scene[0].audio.nothave);if(condition){varaudio_buffer=awaitload_audio(scene[0].audio.name);varmetadata=awaitmm.parseBuffer(audio_buffer,"audio/aac")varmessage={type:"audio",originalContentUrl:encodeURI(AUDIO_URL_BASE+scene[0].audio.name+'.m4a'),duration:Math.floor(metadata.format.duration*1000)};messages.push(message);}}//userIdのステータスをDBに更新console.log(status);update_status(status);// メッセージの一括送信console.log(messages);console.log(JSON.stringify(messages));returnclient.replyMessage(event.replyToken,messages);}catch(error){console.error(error);varmessage={type:"text",text:error.toString()};returnclient.replyMessage(event.replyToken,message);}});exports.fulfillment=app.lambda();linebot_imageエンドポイント
'use strict';constsharp=require('sharp');constHELPER_BASE=process.env.HELPER_BASE||'../../helpers/';constBinResponse=require(HELPER_BASE+'binresponse');// S3用constIMAGE_BUCKET=process.env.IMAGE_BUCKET||'gamebook';constIMAGE_OBJECT_BASE='images/'varAWS=require('aws-sdk');AWS.config.update({region:"ap-northeast-1",});vars3=newAWS.S3({// 必要に応じて});// ファイル用constIMAGE_FILE_BASE='./public/gamebook/images/';constfs=require('fs').promises;asyncfunctionload_image(name){/*
// S3用
var param_get = {
Bucket: IMAGE_BUCKET,
Key: IMAGE_OBJECT_BASE + name + ".png"
};
var image = await s3.getObject(param_get).promise();
return image.Body;
*/// ファイル用returnfs.readFile(IMAGE_FILE_BASE+name+".png");}exports.handler=async(event,context,callback)=>{console.log(event);varpaths=decodeURIComponent(event.path).split('/');varwords=paths[2].split('-');constimage_buffer=awaitload_image(words[0]);constimage=sharp(image_buffer);constimage_meta=awaitimage.metadata();varwidth=image_meta.width;varunit=width/12.0;varlist=[];for(vari=1;i<words.length;i++){varparams=words[i].split('_');constadd_buffer=awaitload_image(params[0]);constadd=sharp(add_buffer);constadd_meta=awaitadd.metadata();varposition=(params.length>1)?parseInt(params[1]):6;varleft=Math.floor(position*unit-unit/2-add_meta.width/2);list.push({input:add_buffer,left:(left<0)?0:left,top:(add_meta.height<image_meta.height)?(image_meta.height-add_meta.height):0,})}image.composite(list);returnimage.toBuffer().then(buffer=>{returnnewBinResponse('image/png',buffer);});};サンプルシナリオ
public/scenarioにサンプルシナリオを置いておきました。
とりあえず、著作権の関係で、画像ファイルや音声ファイルの除いた形で、scenario0.jsonとscenario1.jsonを用意しました。
画像が用意できるようであれば、「画像有」フォルダにあるシナリオを参考にしてみてください。「template」フォルダにあるのは、シナリオファイルのテンプレートです。
LINEボットのセットアップ
GitHubからダウンロードして、expressを起動しておきます。
> unzip LinebotGamebook_master.zip
> cd LinebotGamebook_master
> npm install
> mkdir data
> mkdir data/gamebook
> node app.js
LINEボットとして動かすためには、LINEから払いだされる各種シークレットが必要です。
LINE Developers
https://developers.line.biz/ja/
チャネルの種類は、Messaging APIでチャネルを作成し、チャネル基本情報にある「チャネルシークレット」、Messaging API設定にある「チャネルアクセストークン(長期)」を生成し、メモっておきます。
また、Messaging APIにあるWebhook設定において、立ち上げるexpressサーバのURLまたはAWS API GatewayのURLを指定し、Webhookの利用 をOnにします。ついでに、「応答メッセージ」は無効にしておきます。
補足
LINEチャットのテンプレートメッセージの機能を使っているのですが、ちょっと1文字間違えただけでエラーとなり、どこが間違っているのか教えてくれないので、エラー発生時には苦労しました。。。
お礼
今回は著作権の関係で、GitHubにはおけませんでしたが、以下の画像ファイルや音声ファイルを利用させていただきました。ありがとうございました。
<画像ファイル>
- http://iwayu2.blog.fc2.com/blog-entry-9.html
- https://publicdomainq.net/castle-bridge-0007545/
- http://creativefreaks.net/?page_id=4392
<音声ファイル>
シナリオを作る上での注意
textに指定する文字列の長さに制限があります。
画像またはタイトルがあると60文字となりちょっと短いです。これにはまりました。
・画像もタイトルも指定しない場合の最大文字数:160
・画像またはタイトルを指定する場合の最大文字数:60
以上


