TL;DR
リングフィットアドベンチャーの称号をコンプするための進捗管理を、以下の技術要素を詰め込んで自動で行えるようにしたお話です。
- serverless framework
- AWS Lambda
- Amazon S3
- Amazon DynamoDB
- Amazon API Gateway
- Google Cloud Vision
- Twitter API
- Googleスプレッドシート
- Glide
ご注意
- エモ多め、技術の詳細少なめ
- 画像が多いです(ここを読んでる時点で手遅れだと思いますが)
- 掲載しているコードは実運用しているものとは異なり、参考程度の内容に編集しています
- 記事内のリングフィットアドベンチャーに関する画像は、任天堂公式より転載、またはゲーム画面のスクリーンショットを用いています
リングフィットアドベンチャー is 何
慢性的な運動不足に陥るか、筋肉だけは裏切らないと狂信しているかの二極化しがちなITエンジニアの方々はご存知だと思いますが、リングフィットアドベンチャー
は任天堂が生み出したSwitch向けの家庭用フィットネスゲームです。
ゲームの雰囲気についてはガッキーの公式動画を見ていただければ伝わると思うのでここでは割愛しますが以下のようなゲームだと思ってください。
- ゆるい運動を
- 長期間に渡って
- 手軽に楽しく行える
(※ただしゆるいかどうかは人と遊び方による)
スキルについて
リングフィットアドベンチャーでは、戦闘パートにてフィットネススキル(以下スキル)
という、特定の運動を一定回数行うことで敵にダメージを与える(あるいは自身のダメージを回復する)ことができる、本ゲームのメインとなる要素が存在します。
(引用元)
スキル
には、特定条件下のみで発動するもの(ラッシュスキル)などもありますが、本記事では便宜上、「戦闘中に使用でき、称号(後述)のコンプリートに必要な43種類」のことを「スキル」と呼びます
対象となる43種類のスキル
が以下になります。
アームツイスト アゲサゲコンボ アシパカパカ ウシロプッシュ カタニプッシュ グルグルアーム サゲテプッシュ スクワット ステップアップ スワイショウ チョウツガイのポーズ トライセプス ニートゥーチェスト ねじり体側のポーズ ハサミレッグ バタバタレッグ バンザイコシフリ バンザイサイドベンド バンザイスクワット バンザイツイスト バンザイプッシュ バンザイモーニング ヒップリフト プランク ベントオーバー マウンテンクライマー マエニプッシュ モモアゲアゲ モモアゲコンボ モモデプッシュ リングアゲサゲ リングアロー レッグレイズ ロシアンツイスト ワイドスクワット 椅子のポーズ 英雄1のポーズ 英雄2のポーズ 英雄3のポーズ 舟のポーズ 折りたたむポーズ 扇のポーズ 立木のポーズ
皆さんのお気に入りのスキルも入っているでしょうか?ちなみに私のお気に入りはステップアップやロシアンツイスト、舟のポーズあたりです。
称号とは
本記事でメインとして取り上げる 称号
ですが、これはトロフィー
や実績
などと表記すると、他ハードのゲームをされる方にはピンと来ると思います。
リングフィットアドベンチャーにおける称号
もそれらと同様で、「ゲーム内で何らかの基準を達成した場合に取得できるが、ゲーム本編には影響を与えない要素」のことです。
例えば上記画像では、折りたたむポーズ
というスキルを一定回数ゲーム中で実施すると、回数に応じて 折りたたむポーズに目覚めし者
折りたたむポーズに愛されし者
折りたたむポーズを極めしもの
といった称号が取得できます。2000回まで達すると、折りたたむポーズ関係の最後の称号が解禁できそうですが、まだそこまで達していないという状況です。
この称号は、それをプロフィール上で名乗れるというだけで、ゲームを有利にすすめるような要素は一切ありません。
こんなん、埋めたくならないわけがないですよね??埋めましょうよ。
称号コンプ上の問題点
※ リングフィットアドベンチャーの称号は全部で250種類
ぐらいありますが、そのほとんどが 〇〇(スキル名)を行った回数が累計N回以上となったが解禁要件になるので、本記事ではそれについてのみ着目します。
それぞれのスキルを、あとどれぐらいやれば該当の称号が手に入るのか。それを確認するには以下のような手順を取る必要があります。
2. プロフィールメニューから、該当のスキルの累計回数を確認する
ほうほう、いまは820回ね。
3. 差分から残りの回数を算出して絶望する
以上の手順を、確認したいスキルの数だけ行わなければならず、スキルを累計N回使った / 称号取得まであとN回やるの全体進捗が把握しづらいという大きな問題があります。
しかし私達は筋肉を愛するものである前に、ITエンジニアだ。テックの力で解決しようじゃないか。
進捗をスプレッドーシートで管理する
「テックの力」とか言いながら、まずはGoogleスプレッドシートで進捗を管理するための基本となるシートを手作業で作成します。
- スキルの基本情報
- 実施回数
- 目標回数
- 実施回数と目標回数から決定する進捗関係
以上の列を用意し、実施回数
を自動で更新できるような仕組みを目指します。
リザルト画面をOCRする
リングフィットアドベンチャーでは、一日の活動を終了すると、本日の運動結果
という形で、その日行ったスキル及びこれまでの累計回数が一覧で表示されます。
本記事では、この画面のスクリーンショットを撮影し、OCRにかけることで累計回数を抽出しようと試みます。
今回はGCPのCloud Vision API
を使ってみることにします。
(引用元)
Cloud Vision API
は、上記のように画像の中からテキスト情報とメタデータ取得できる上、その精度の高さと、無料枠でも充分収まるリーズナブルさを兼ね備えていたので採用しました。
では実際にCloud Vision API
に対して、リングフィットアドベンチャーのスクリーンショットを安直に投げてみましょう。
惜しい!!
テキスト自体は認識できていますが、その区切りがガバガバで、どのスキルが何回なのかの関連付けができそうにはありません。また、「本日の運動結果」
や、画面を撮影する
といった、本来の目的には不要なノイズまで混じってしまっています。
- 必要な部分だけOCRさせるようにする
- レイアウトの区切りがわかりやすいようにする
までは、こちら側でお膳立てしてからCloud Vision API
にお願いして上げる必要がありそうです。
では以下のように、スクリーンショットから不要な部分を削ぎ落とし、3分割して、それぞれをOCRにかけるのはどうでしょうか。
分割後のスクリーンショットを投げてみると...
素晴らしい!!
スキル名、回数(累計回数)の順に解析され、多少のノイズはアレど充分に活用できることがわかりました。
Switchからスクリーンショットをツイートする
さて、OCRからは一度離れて、まずSwitchからスクリーンショットを投稿し、それをAWS Lamdaで処理する下準備をしていきます。
Switchでスクリーンショットを手っ取り早く共有するには、TwitterやFacebookと言った、SNS連携を使うのが一番です。Switchで撮影したスクショをTwitterで共有すると、以下のようにハッシュタグ付きのツイートが投稿されます。
これをTwitter API
を用いて取得しましょう。ご丁寧にハッシュタグがついているので容易に絞り込めそうです。
constTwitter=require('twitter')asyncfunctionfetchImageUrls(){constclient=newTwitter({consumer_key:process.env.TWITTER_CONSUMER_KEY,consumer_secret:process.env.TWITTER_CONSUMER_SECRET,access_token_key:process.env.TWITTER_ACCESS_TOKEN,access_token_secret:process.env.TWITTER_ACCESS_SECRET})consttweets=awaitclient.get('statuses/user_timeline',{count:200})// 自身の直近のツイートのうち、対象となるハッシュタグが付いているものを絞り込み、// そこから添付画像のURLを抽出するreturntweets.filter((tweet)=>tweet.text.includes('#RingFitAdventure')).map((tweet)=>tweet.entities.media[0].media_url_https+'?format=jpg&name=large')}
上記コードでは、直近200件のツイートを対象としているため、取得したツイートが既に解析済みかどうかを把握する必要があります。コードは省略しますが、ここではDynamoDB
を用いて、解析済みの画像URLにはチェックを入れ、初出の画像URLの場合は次の処理に進むという構成になっています。
抽出された画像ファイルの3分割処理は、次のLambdaに委譲されます。
画像ファイルの分割処理
画像の3分割は、nodeを用いて、jimpという画像編集ライブラリを用いて、以下のように行いました。
constJimp=require('jimp')constAWS=require('aws-sdk')constS3=newAWS.S3()// 元のスクショから必要な部分だけ3分割するための座標情報// スクショが3カラム構成になっていることを前提とするconstWIDTH=321constHEIGHT=462constY=132constX1=150constX2=502constX3=857asyncfunctionuploadToS3(image,key){constimageBuffer=awaitimage.getBufferAsync(Jimp.MIME_JPEG)returnS3.putObject({ACL:"public-read",Body:imageBuffer,Bucket:"bucket-name",ContentType:"image/jpeg",Key:key}).promise()}module.exports.index=asyncevent=>{constimageUrl='hoge'constdirName='fuga'constorigin=awaitJimp.read(imageUrl)awaituploadToS3(origin.clone().crop(X1,Y,WIDTH,HEIGHT),`${dirName}/1.jpeg`)awaituploadToS3(origin.clone().crop(X2,Y,WIDTH,HEIGHT),`${dirName}/2.jpeg`)awaituploadToS3(origin.clone().crop(X3,Y,WIDTH,HEIGHT),`${dirName}/3.jpeg`)};
上記コードでは、3分割した画像それぞれをS3
にアップロードして、このLambdaのお仕事は終了です。
OCRを実行する
S3
にファイルがアップロードされたことをトリガに次のLambdaを実行します。このLambdaは、S3から受け取ったイベントに含まれている、分割後の画像をCloud Vision API
に投げ、その結果をさらに次のLambdaに委譲します。
constaxios=require('axios')module.exports.index=asyncevent=>{constTOKEN=process.env.TOKENconstENDPOINT=`https://vision.googleapis.com/v1/images:annotate?key=${TOKEN}`constIMAGE_URL=event.imageUrlconstimageResponse=awaitaxios.get(IMAGE_URL,{responseType:'arraybuffer'})constimageBase64=Buffer.from(imageResponse.data,'binary').toString('base64')constpostData={requests:[{image:{content:imageBase64},features:[{type:'DOCUMENT_TEXT_DETECTION',// これがOCRをやるぞっていう指令maxResults:1}]}]}constresponse=awaitaxios.post(ENDPOINT,postData).catch((e)=>{console.log(e)})// OCRのレスポンス内容見て成否を戻す
(正確にはCloud Vision APIのレスポンスをハンドリングして、エラー処理したり次のLambdaを呼んだりするLambdaが別にいるけど割愛)
OCR結果を整形して永続化する
Cloud Vision API
の精度が高いとはいえ、Switchのスクリーンショットの画質が低かったり、レイアウトがギリギリを攻めすぎてることもあって、ノイズが入ることが多々あります。
まずノイズを正規表現で殴ります。
text=text.split(/\n/)text=text.filter((line)=>line)text=text.map((line)=>{returnline.replace('、','').replace('。','').replace('」','').replace('','').replace('バタバタレック','バタバタレッグ').replace('ラッシュバンザイコシフリー','ラッシュバンザイコシフリ').replace('ラッシュモモデプッシュ|','ラッシュモモデプッシュ').replace(/(\d+)(\D)(\d+)(\D)\)$/,'$1$2($3$4)')// 171回1990回) → 171回(1990回).replace(/(\d+)(\D)(\d+)(\D)$/,'$1$2($3$4)')// 1042m17253m → 1042m(17253m).replace(/(\D+)(\d+)(\D)(\d+)\D?/,'$1$2$3($4$3)')// 引っぱりバンザイサイドベンド1081秒117999) → 引っ張りバンザイサイドベンド1081秒(117999秒).replace(/^(\d{7,}).+$/,'0回(0回)')// 22139142650円) → 0回(0回) 不正データのため.replace(/^(\d+)(\D)\((\d+)(\D)$/,'$1$2($3$4)')// 602m(136893m → 602m(136893m).replace(/^(\d+)(\D)\/(\d+)(\D)\)$/,'$1$2($3$4)')// 1694m/19577m) → 1694m(19577m).replace(/^(\d+)(\D)\/(\d+)(\D)】$/,'$1$2($3$4)')// 1回(21回】→ 1回(21回)})
ノイズを除外すると、概ね スキル名\n回数(累計回数)\n
の繰り返しパターンが出来上がっているので、これを良い感じにオブジェクトに変換します。
その辺りのコードは闇が深いので割愛しますが、最終的には以下のような、スキル別の累計回数と最終実行日だけが永続化されるように、DynamoDBを更新します。
"results":{"舟のポーズ":{"updatedAt":"2020-04-21T17:20:24.362Z","value":1722},"英雄1のポーズ":{"updatedAt":"2020-04-25T16:20:24.095Z","value":1357},"英雄2のポーズ":{"updatedAt":"2020-04-25T16:20:24.197Z","value":1213},"英雄3のポーズ":{"updatedAt":"2020-04-25T16:20:24.197Z","value":1555}}
updatedAt
は本処理実行時点の時刻で、DynamoDB
が持っている現在の記録を元に、OCR結果をマージした新しい記録で更新します。
constaws=require('aws-sdk')constdynamoClient=newaws.DynamoDB.DocumentClient({region:'ap-northeast-1'})// これまでの累計記録と、今回OCRされた結果をマージするfunctionmergeResults({currentResults,newResults}){constmergedResults={}Object.keys(currentResults).forEach((key)=>{// まれにここでもノイズが入るので、最新記録のほうが高い数値の場合のみ更新するif(newResults[key]&&newResults[key].value>=currentResults[key].value){mergedResults[key]=newResults[key]}})returnmergedResults}// 現在の記録をDynamoから取得asyncfunctionfetchCurrentResult({userName}){constparams={TableName:'rfa-logs',Key:{'hogehoge'}}constcurrentDoc=awaitdynamoClient.get(params).promise()if(currentDoc.hasOwnProperty('Item')){returncurrentDoc.Item.results}}// これまでの累計記録と、今回OCRされた結果をマージして更新するasyncfunctionupdateResult(newResults){constcurrentResults=awaitfetchCurrentResult()constmergedResults=mergeResults({currentResults,newResults})constnewDocawaitdynamoClient.update({TableName:'rfa-logs',Key:{'hogehoge'},UpdateExpression:'set results = :r',ExpressionAttributeValues:{':r':{...currentResults,...mergedResults}},ReturnValues:'UPDATED_NEW'}).promise()returnnewDoc}
ここまでで、常に最新の進捗がDynamoDBに反映される状態に!
進捗を取得できるAPIを用意する
データが出来上がっちまえばこっちのもんです。API Gateway
と、DynamoDB
を参照するだけのLambdaを用意して、最新のデータを取得するWebAPIを提供します。
constaws=require('aws-sdk')constdynamoClient=newaws.DynamoDB.DocumentClient({region:'ap-northeast-1'})constDYNAMO_TABLE_NAME='rfa-logs'asyncfunctionfetchCurrentResult(){constparams={TableName:DYNAMO_TABLE_NAME,Key:{'hogehoge'}}constcurrentDoc=awaitdynamoClient.get(params).promise()if(currentDoc.hasOwnProperty('Item')){returncurrentDoc.Item.results}}module.exports.index=async(event,context,callback)=>{constresults=awaitfetchCurrentResult()callback(null,{statusCode:200,body:JSON.stringify({results})})}
API Gateway
は新しい公開エンドポイントを生成し、上記のLambdaに接続することで、特定URLを叩くだけで以下のようなJSONを取得できるようになります。
"results":{"舟のポーズ":{"updatedAt":"2020-04-21T17:20:24.362Z","value":1722},"英雄1のポーズ":{"updatedAt":"2020-04-25T16:20:24.095Z","value":1357},"英雄2のポーズ":{"updatedAt":"2020-04-25T16:20:24.197Z","value":1213},"英雄3のポーズ":{"updatedAt":"2020-04-25T16:20:24.197Z","value":1555}}
スプレッドシートでAPIを叩いて更新する
さぁ、いよいよスプレッドシートに戻ってきました。
以下のスプレッドシートがあるとき、今度はGAS
の力で前述のAPIを叩いて、レスポンスの内容に応じてシートを更新します。
GASは非常に簡単にAPIを叩けます。レスポンスのJSON文字列をパースしてシートの更新に使いましょう。
functionfetchCurrentResults(){varurl='https://hogehogehoge.execute-api.ap-northeast-1.amazonaws.com/dev'varresponse=UrlFetchApp.fetch(url);varcontent=response.getContentText("UTF-8")returnJSON.parse(content).results;}
functionupdateResults(){varresults=fetchCurrentResults()// APIから最新の進捗を取得Object.keys(results).forEach(function(key){varname=key;vartotal=results[key].value;varupdatedAt=newDate(results[key].updatedAt);varrow=searchRow(name)// スキル名から、該当行を特定するif(row){sheet.getRange(row,COL_TOTAL).setValue(total);// 累計回数列を更新sheet.getRange(row,COL_UPDATED).setValue(updatedAt);// 最終実施日を更新}})}
上記のようなコードを実行すると、シュバババっとシートが自動で埋められていきます。実施回数
カラムが埋まれば他のカラムも遷移的に決まっていくので、これで進捗が管理できるようになりました。
さらに、Google Apps Script で毎日決まった時刻にスクリプトを実行するトリガー設定を参考に、このスクリプトを毎時実行することによって、こちらは何もしなくても(正確にはTwitterにスクショをアップロードするだけで) 進捗が管理できるようになります。
Glideでスマホアプリ化
以上でスプレッドシート上でリングフィットアドベンチャーの進捗を自動管理できるようになりましたが、スプレッドシートはパソコンで開かないと使いづらいし、そもそも現状だと情報量が多くて視認性が少し悪い!!
ということで、本当に知りたい最低限の進捗のみを、スマホアプリで確認できるようにします。
といっても、整形されたスプレッドシートが用意されてる時点で、あとはGlideという、スプレッドシートを元にPWAなアプリを自動生成できるサービスを活用します。
これを用いることで、まるでネイティブアプリかのようにホーム画面にアイコンを配置し、ネイティブアプリ化のようなUI/UXで進捗を確認できるようになります。
まとめ
以上で全てのシステムが完成し、最終的には以下のような構成になりました。
技術的な好奇心も強かったため、必要以上に冗長な構成になってしまったようにも思えますが、AWS関係のリソースはserverless frameworkを使って構成管理したため、デプロイはもちろん、IAMの付与やAPIGatewayの設置なども非常に簡単に出来、管理も容易に行うことができました。
ちなみに実運用しているスプレッドシートは特に意味もなく公開しています。見ての通り、現在(2020/04/26時点)でも、まだまだコンプが遠い状態なので毎日頑張ってます。
長々とお付き合いいただきありがとうございました。良いリングフィットアドベンチャーライフを!