10
11

More than 3 years have passed since last update.

LINEボットでゲームブックを作ってみた

Last updated at Posted at 2020-09-29

最近LINEボットを扱っていなかったので、久しぶりに動かしてみました。
題材にしたのは「ゲームブック」です。

ゲームブックを知らない方もいるかもしれないので、補足すると、小説のような読み物ではあるのですが、途中で選択肢が出てきて、そのときの状況を読者が判断して適切な選択肢を繰り返し選択していくと、うまくいけばゴール、そうでない場合は途中で終了となります。面白いのが、選択肢の選択は、指定されたページに移動することであって、ページ番号は意味のない数字なので、ちゃんと順番に選択していかないと、ゴールにはたどり着きません。ゴールのページがどこにあるのかわからないからです。

今回は、これをLINEのチャットで実現します。
1チャット1ページで、チャットの内容から判断して適切な次の選択肢を選択すると、次のページの内容がチャットで返ってきます。これを繰り返すわけです。

チャットは、基本的に、「テキスト」と「次に進む選択肢」からなります。
で、せっかく、インタラクティブなLINEチャットを使うので、以下の機能を付けています。

  • チャットに画像ファイルを張り付けられます。さらに、複数の登場人物を指定することで、背景に人物を重ね合わせて表示させました。
  • 音声ファイルを付けられるようにしました。チャットの表示に合わせて、音声再生することで、臨場感が増します。
  • 複数のページにまたがって、フラグを保持するようにしました。特定のページにたどり着くことでフラグを得たり、フラグを失ったりします。本ゲームブック内では持ち物と言っています。
  • 持ち物の状態によって、次の選択肢を追加したり削除したり、画像に重ね合わせる登場人物を追加したり削除したりできるようにしました。

あとは、シナリオファイルにページの内容や次の選択肢を書いていけばできあがります。
シナリオファイルを、JSONファイルで記述します。後でフォーマットを示しておきます。

シナリオファイルには、複数のシーンを配列で記述します。このシーンが、1ページに相当します。
最初のシナリオから別のシナリオに移動することもできます。チャプターの切り替えのようなもので、新しいシーンになったら、持ち物はクリアされます。なので、もし行き詰まってやり直しする場合も、シナリオ単位で管理しているため、シナリオの最初に戻れるようにしています。

以下が、実際にシナリオを組んでみたときのものです。

image.png

image.png

image.png

GitHubにソースコード一式上げておきます。

pururuba/LinebotGamebook
 https://github.com/poruruba/LinebotGamebook

(2020/9/30 修正)
テンプレートメッセージは制約がきついので、Flex Messageに変えました。

(2020/10/4 修正)
・ページの移動にはポストバックを使うとソースコードがすっきりしました。
・シナリオ切り替え時に、シーン番号も指定できるようにしました。

(2020/10/16 追加)
・完全に自己満ですが、画像・音声に加えて、動画も表示できるようにしました。

(2021/1/3 補足)
・npmのmusic-metadataの最新バージョンでは不具合があるっぽい。7.4.0では大丈夫そうです。
 オプションで{duration:true}を指定しても、durationが返らない場合がありました。

シナリオファイルフォーマット

以下、例示とともに、説明を付記しておきます。

{
  "title": "王様に会いに行く",  // (任意) シナリオの名前
  "scene":[
    {
      "id": "0", // (必須) シーンの識別子。シナリオの最初は "0" から始まる
      "title": "序章:始まりの街", // (任意) シーンのタイトル。チャットに太字で表示される。
      "text": "目覚めると、見知らぬ城下街に立っていた。ポケットに金貨5枚。こういう時は、まずは王様に会いに行くべきだろう。", // (必須) チャットに表示されるテキスト。
      "image": { // (任意) 表示する画像ファイル
        "background": "公園", // (必須) 背景となる画像名。png形式。videoがある場合はプレビュー画像となる
        "video": "そよ風", // (任意) 動画名。mp4形式
        "composite": [ // (任意) 重ね合わせる画像。出現順で重ね合わせる。
          {
            "name": "跳ねてる子", // (必須) 重ね合わせる画像名。png形式。
            "position": 6, // (任意) 重ね合わせる位置(1~12)。未指定または0の場合は6(真ん中)
            "have": ["アイス"], // (任意) いずれの持ち物も所持していないと画像が重ね合わされない
            "nothave": ["金貨5枚"] // (任意) いずれか持ち物を所持しいると画像が重ね合わされない
          }
        ]
      },
      "audio": { // (任意) 再生する音声ファイル
        "name": "街の中心", // (必須) 再生する音声。m4a形式
        "have": ["アイス"], // (任意) いずれの持ち物も所持していないと音声が再生されない
        "nothave": ["金貨5枚"] // (任意) いずれか持ち物を所持しいると音声が再生されない
      },
      "selection": [ // (任意) 次の選択肢
        {
          "type": "scene", // (任意)次の選択肢の区別。"scene"か"scenario"。未指定の場合は、"scene"
          "id": "1", // (必須)次の選択肢の識別子。type="scene"の場合はシーンの識別子、type="scenario"の場合はシナリオ名
          "scene": "0", // (任意) type="scenario"の場合の飛び先のシーンの識別子。未指定の場合"0" 
          "title": "街の中心に行く", // (必須)次の選択肢の表示文字
          "have": ["アイス"], // (任意) いずれの持ち物も所持していないと選択肢が表示されない
          "nothave": ["金貨5枚"] // (任意) いずれか持ち物を所持しいると選択肢が表示されない
        }
      ],
      "acquire": ["アイス"], // (任意) 本シーンにたどり着いたときに獲得する持ち物
      "lost": ["金貨5枚"] // (任意) 本シーンにたどり着いたときに失う持ち物
    }
  ]
}

ちょっと補足です。

  • idが、ページ番号に相当します。
  • textが各ページの表示文です。
  • selectionが、次に進むべきページの選択肢です。
  • haveやnothaveがありますが、そのときの持ち物の状態によって、有効化したり無効化したりするためのものです。
  • acquireやlostが、そのページにたどり着いたときの持ち物を獲得したり失ったりするためのものです。

シナリオファイル中に出てくる画像や音声名に対応するファイル名は、以下に従う必要があります。

シナリオファイル
 ファイル名:シナリオ名.json
 形式:JSON形式

画像ファイル
 ファイル名:画像名.png
 形式:PNG

音声ファイル
 ファイル名:音声名.m4a
 形式:AAC

動画ファイル
 ファイル名:動画名.mp4
 形式:MP4

格納場所は、AWSを想定した場合と、ローカル実行を想定した場合の2種類に対応できるようにしました。

AWSを想定した場合
 シナリオファイル、画像ファイル、音声ファイル:S3
 ロジック(Node.js):Lambda+API Gateway
 ユーザごとの状態:DynamoDB

ローカル実行を想定した場合
 シナリオファイル、画像ファイル、音声ファイル:ファイル
 ロジック(Node.js):express
 ユーザごとの状態:ファイル(JSON)

LINEチャットのメッセージの形式

LINEチャットの返信には、内部では、LINEのFlex Messageの機能を使っています。Flex Message Simulatorのおかげで、GUIでテンプレートを作れます。

Flex Message
 https://developers.line.biz/ja/reference/messaging-api/#flex-message
Flex Message Simulator
 https://developers.line.biz/flex-simulator/

ロジック

入り口として2つのエンドポイントがあります。

・linebotエンドポイント
  LINEでユーザがチャットするとそれを受け取ってシナリオに沿ったページのチャットを返します。
・linebot-image
  画像の重ね合わせを処理し、重ね合わせた結果をmimetype=image/png のバイナリ形式で返します。

linebotエンドポイント

ページ遷移すると、以下のところが呼ばれます。

 app.postback(async (event, client) =>{

そこらへんのLINEの細かな処理は、line-utils.js にまとめておきました。

linebot/index.js
'use strict';

const IMAGE_URL_BASE = "【サーバのURL】/linebot-image/";
const AUDIO_URL_BASE = "【音声ファイルのURL】";

const line = require('@line/bot-sdk');
const mm = require('music-metadata');
const sharp = require('sharp');

const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const BinResponse = require(HELPER_BASE + 'binresponse');

const AWS_ENABLE = false;
const FILE_ENABLE = true;

const config = {
  channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN,
  channelSecret: process.env.LINE_CHANNEL_SECRET,
};
const LineUtils = require(HELPER_BASE + 'line-utils');
const app = new LineUtils(line, config);

const DEFAULT_SCENARIO = process.env.DEFAULT_SCENARIO || 'scenario0';
const DEFAULT_SCENE = process.env.DEFAULT_SCENE || '0';

/* S3用 */
const CONTENTS_BUCKET = process.env.CONTENTS_BUCKET || 'gamebook';
const SCENARIO_OBJECT_BASE = 'scenario/';
const AUDIO_OBJECT_BASE = 'audio/';

/* DynamoDB用 */
const TABLE_NAME = process.env.TABLE_NAME || "gamebook";

/* ファイル用 */
const SCENARIO_FILE_BASE = './data/gamebook/scenario/';
const STATE_FILE_BASE = './data/gamebook/users/';
const AUDIO_FILE_BASE = './public/gamebook/audio/';
const IMAGE_FILE_BASE = './public/gamebook/images/';
const fs = require('fs').promises;

const AWS = require("aws-sdk");
AWS.config.update({
  region: "ap-northeast-1",
});
const docClient = new AWS.DynamoDB.DocumentClient({
});
var s3  = new AWS.S3({
});

async function load_scenario(fname){
  if( AWS_ENABLE ){
    // S3用
    var param_get = {
      Bucket: CONTENTS_BUCKET,
      Key: SCENARIO_OBJECT_BASE + fname
    };
    var obj = await s3.getObject(param_get).promise();
    return obj.Body;
  }
  if( FILE_ENABLE ){
    // ファイル用
    return await fs.readFile(SCENARIO_FILE_BASE + fname, "utf-8");
  }
}

async function load_audio(fname){
  if( AWS_ENABLE ){
    // S3用
    var param_get = {
      Bucket: CONTENTS_BUCKET,
      Key: AUDIO_OBJECT_BASE + fname
    };
    var image = await s3.getObject(param_get).promise();
    return image.Body;
  }
  if( FILE_ENABLE ){
    // ファイル用
    return await fs.readFile(AUDIO_FILE_BASE + fname);
  }
}

async function load_status(userid){
  if( AWS_ENABLE ){
    // DynamoDB用
    var params_get = {
      TableName: TABLE_NAME,
      Key: {
        userid: userid,
      }
    };
    var result = await docClient.get(params_get).promise();
    return result.Item;
  }
  if( FILE_ENABLE ){
    // ファイル用
    try{
      var status = await fs.readFile(STATE_FILE_BASE + userid + '.json', 'utf8');
      return JSON.parse(status);
    }catch(error){
      return undefined;
    }
  }
}

async function create_status(userid, scenario, scene){
  return {
    userid: userid,
    scenario: scenario,
    scene: scene,
    turn: 0,
    items: [],
  };
}

async function insert_status(status){
  if( AWS_ENABLE ){
    // DynamoDB用
    var params_put = {
      TableName: TABLE_NAME,
      Item: status
    };
    return await docClient.put(params_put).promise();
  }
  if( FILE_ENABLE ){
    // ファイル用
    return fs.writeFile(STATE_FILE_BASE + status.userid + '.json', JSON.stringify(status), 'utf8');
  }
}

async function update_status(status){
  if( AWS_ENABLE ){
    // DynamoDB用
    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();
  }
  if( FILE_ENABLE ){
    // ファイル用
    return insert_status(status);
  }
}

function add_item(item_list, item){
  if( item_list.indexOf(item) >= 0 )
    return false;

  item_list.push(item);
  return true;
}

function remove_item(item_list, item){
  var index = item_list.indexOf(item);
  if( index < 0 )
    return false;

  item_list.splice(index, 1);
  return true;
}

function has_item(item_list, item){
  return ( item_list.indexOf(item) >= 0 );
}

function check_condition(items, have, nothave){
  var condition = 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;
      }
    });
  }

  return condition;
}

async function process_scenario(event, client, scenario, status){
  // 現在のシーンを取得
  var scene = scenario.scene.find(item => item.id == status.scene );
  if( !scene )
    throw "scene not found";

  // ターン番号をインクリメント
  status.turn++;

  var messages = [];

  // 獲得アイテムの処理
  if(scene.acquire && scene.acquire.length > 0){
    scene.acquire.forEach(item => add_item(status.items, item));
    var message = {
      type: "text",
      text: scene.acquire.join('') + "を手に入れた"
    }
    messages.push(message);
  }

  // ロストアイテムの処理
  if( scene.lost && scene.lost.length > 0){
    scene.lost.forEach(item => remove_item(status.items, item));
    var message = {
      type: "text",
      text: scene.lost.join('') + "を失った"
    };
    messages.push(message);
  }

  // メインダイアログ
  var flex = {
    type: "flex",
    altText: scene.text,
    contents: {
      type: "bubble",
      size: "kilo",
      body: {
        type: "box",
        layout: "vertical",
        contents: [],
      },
      footer:{
        type: "box",
        layout: "vertical",
        contents:[],
        flex: 0
      }
    }
  };

  if( scene.image && scene.image.background ){
    // 画像が指定されていた場合
    var image_url = IMAGE_URL_BASE + scene.image.background;
    // 画像合成が指定されていた場合
    if(scene.image.composite ){
      scene.image.composite.forEach( select => {
        if( !select.name )
          return;

        // アイテムの所持・非所持確認
        var condition = check_condition(status.items, select.have, select.nothave );
        if( !condition )
          return;

        // 合成画像の指定追加
        image_url += '-' + select.name;
        if( select.position != undefined )
          image_url += '_' + select.position;
      });
    }
    flex.contents.hero = {
      type: "image",
      url: encodeURI(image_url),
      size: "full",
      aspectRatio: "20:13",
      aspectMode: "fit"
    };
  }

  if( scene.title ){
    // タイトルが指定されていた場合
    flex.contents.body.contents.push({
      type: "text",
      wrap: true,
      text: scene.title,
      weight: "bold",
      size: "md"
    });
  }

  // テキストの設定
  flex.contents.body.contents.push({
    type: "text",
    wrap: true,
    size: "sm",
    text: scene.text
  });

  // 次の選択肢
  if( scene.selection ){
    scene.selection.forEach( select =>{
      if( !select.id )
        return;

      // アイテムの所持・非所持確認
      var condition = check_condition(status.items, select.have, select.nothave );
      if( !condition )
        return;

      // 選択肢の追加
      var type = "scene";
      if( select.type )
        type = select.type;

        flex.contents.footer.contents.push({
          type: "button",
          style: "link",
          height: "sm",
          action:{
            type: "postback",
            label: select.title,
            data: (select.type == 'scenario' && select.scene ) ?
              String(status.turn + 1) + " " + type + " " + select.id + " " + select.scene :
              String(status.turn + 1) + " " + type + " " + select.id,
            displayText: select.title
          }
        });
    });
  }
  if( flex.contents.footer.contents.length == 0 ){
    flex.contents.footer.contents.push({
      type: "button",
      style: "link",
      height: "sm",
      action:{
        type: "message",
        label: "シナリオの最初に戻る",
        text: "リセット"
      }
    });
    flex.contents.footer.contents.push({
      type: "button",
      style: "link",
      height: "sm",
      action:{
        type: "message",
        label: "最初から始める",
        text: "リタイア"
      }
    });
  }
  messages.push(flex);

  if( scene.audio && scene.audio.name ){
    // 音声ファイルが指定されていた場合
    // アイテムの所持・非所持確認
    var condition = check_condition(status.items, scene.audio.have, scene.audio.nothave );
    if( condition ){
      var audio_buffer = await load_audio(scene.audio.name + '.m4a');
      var metadata = await mm.parseBuffer(audio_buffer, "audio/aac")
      var message = {
        type: "audio",
        originalContentUrl: encodeURI(AUDIO_URL_BASE + scene.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));
  return client.replyMessage(event.replyToken, messages);
}

// ポストバック受信処理
app.postback(async (event, client) =>{
  console.log(event);

  try{
    // userIdのステータスをDBから取得
    var status = await load_status(event.source.userId);
    if( !status ){
      throw "unknown user";
    }

    var com_list = event.postback.data.split(' ');

    switch(com_list[1]){
      case 'scenario':{
        // シナリオの変更
        if( parseInt(com_list[0]) != (status.turn + 1) )
          throw "turn is invalid";
        status.scenario = com_list[2];
        status.items = [];
        if( com_list.length > 3 )
          status.scene = com_list[3];
        else
          status.scene = DEFAULT_SCENE;
        break;
      }
      case 'scene':{
        // シーンの変更
        if( parseInt(com_list[0]) != (status.turn + 1))
          throw "turn is invalid";
        status.scene = com_list[2];
        break;
      }
      default:{
        // 不明なコマンド
        var message = {
          type: "text",
          text: "不明なコマンド",
        };
        return client.replyMessage(event.replyToken, message);
      }
    }

    // 現在のシナリオを取得
    let scenario = await load_scenario(status.scenario + '.json');
    if( !scenario )
      throw "scenario not found";    
    scenario = JSON.parse(scenario);

    return await process_scenario(event, client, scenario, status);
  }catch(error){
    console.error(error);
    var message = {
      type: "text",
      text: error.toString()
    };
    return client.replyMessage(event.replyToken, message);
  }
});

・・・

exports.fulfillment = app.lambda();

linebot-imageエンドポイント

背景画像と人物画像を動的に重ね合わせます。
sharpというnpmモジュールを使いました。

linebot/index.js
async function load_image(name){
  if( AWS_ENABLE ){
    // S3用
    var param_get = {
      Bucket: CONTENTS_BUCKET,
      Key: IMAGE_OBJECT_BASE + name + ".png"
    };
    var image = await s3.getObject(param_get).promise();
    return image.Body;
  }
  if( FILE_ENABLE ){
    // ファイル用
    return fs.readFile(IMAGE_FILE_BASE + name + ".png");
  }
}

exports.handler = async (event, context, callback) => {
  console.log(event);
  if( event.path.startsWith('/linebot-image/') ){
    var paths = decodeURIComponent(event.path).split('/');
    var words = paths[2].split('-');

    const image_buffer = await load_image(words[0]);
    const image = sharp(image_buffer);
    const image_meta = await image.metadata();
    var width = image_meta.width;
    var unit = width / 12.0;

    var list = [];
    for( var i = 1 ; i < words.length ; i++ ){
      var params = words[i].split('_');
      const add_buffer = await load_image(params[0]);
      const add = sharp(add_buffer);
      const add_meta = await add.metadata();

      var position = ( params.length > 1 ) ? parseInt(params[1]) : 6;
      var left = 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);

    return image.toBuffer()
    .then(buffer =>{
      return new BinResponse('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にします。ついでに、「応答メッセージ」は無効にしておきます。
※残念ながらというか当然ながらというか、立ち上げるサーバはHTTPSである必要があります。

Welcomeメッセージ

ユーザが最初にボットとして友達追加されたときに表示するメッセージは、以下の2つのいづれかまたは両方で指定できます。

・LINE Developerページにおいて、あいさつメッセージに指定
・/linebot/index.js に以下を追加

linebot/index.js
app.follow(async (event, client) =>{
 // ここでメッセージ等を送信
});

補足

LINEチャットのテンプレートメッセージの機能を使っているのですが、ちょっと1文字間違えただけでエラーとなり、どこが間違っているのか教えてくれないので、エラー発生時には苦労しました。。。

おわりに

シナリオエディタもあるとよさそうです。。。

シナリオエディタ作りました。本投稿の続編です。
 LINEボットでゲームブックを作った、ついでにシナリオエディタ作ったので完成
 LINEボットでゲームブック、回想シーンを追加

ついでに

以下の投稿で、LINEボットにリッチメニューを追加できるようにしました。
 LINEBotのリッチメニューエディタがないので自作した

こちらの画像と、
image.png
こちらのJSONを指定すれば、リッチメニューが追加されて使いやすくなります。
【LIFF_ID】の部分は、ご自身の環境に合わせてください。

richmenu.json
{
    "richMenuId": "richmenu-XXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
    "name": "メニュー",
    "size": {
        "width": 837,
        "height": 270
    },
    "chatBarText": "選択してください。",
    "selected": false,
    "areas": [
        {
            "bounds": {
                "x": 74,
                "y": 20,
                "width": 212,
                "height": 106
            },
            "action": {
                "type": "uri",
                "uri": "https://liff.line.me/【LIFF_ID】"
            }
        },
        {
            "bounds": {
                "x": 313,
                "y": 20,
                "width": 212,
                "height": 106
            },
            "action": {
                "type": "message",
                "text": "リセット"
            }
        },
        {
            "bounds": {
                "x": 74,
                "y": 143,
                "width": 212,
                "height": 106
            },
            "action": {
                "type": "message",
                "text": "持ち物"
            }
        },
        {
            "bounds": {
                "x": 313,
                "y": 143,
                "width": 212,
                "height": 106
            },
            "action": {
                "type": "message",
                "text": "記憶"
            }
        },
        {
            "bounds": {
                "x": 623,
                "y": 143,
                "width": 138,
                "height": 106
            },
            "action": {
                "type": "message",
                "text": "リタイア"
            }
        },
        {
            "bounds": {
                "x": 552,
                "y": 20,
                "width": 212,
                "height": 106
            },
            "action": {
                "type": "message",
                "text": "リロード"
            }
        }
    ]
}

以上

10
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
11