弊社ライブラリのPaPeRo i 顔認証サービス(NeoFace)対応とPython版デモアプリとGolang版ライブラリのPaPeRo i 顔認証サービス(NeoFace)対応で作成したデモアプリはNeoFace認証を利用するアプリではなくNeoFaceのサブグループ管理アプリだったので、ちゃんと顔認証を利用するデモアプリも作ってみました。
基本方針
基本方針としては、
(1) 従来通りPaPeRo i 制御用WebSocket通信アドオンシナリオとのWebSocket通信によりパペロを制御する
(2) パペロ本体とラズパイ3のどちらでも動作するGolangアプリ
(3) HTTPサーバとして動作しブラウザにUI画面を表示する
(4) WebSocketサーバとしても動作しブラウザのJavaScriptプログラムと通信して座布団ボタンなどパペロのイベントで画面更新可能とする
(5) Vue.jsを使ったシングルページアプリケーション(ページ遷移なしで画面を変化させる)とする
(6) 顔認証によって「ログイン」できる様にする
としました。サーバ・クラウドへのログインではなくローカル(パペロ本体またはラズパイ3)で動作するアプリに対するログイン動作になっています。また、画面情報はすべて送り、JavaScriptで必要な部分だけを表示しているだけなので、実際に本人以外に使われては困る様なアプリにこのままの形で流用することはできませんのでご注意ください。
動作
具体的な動作は、
(1) 初期状態でログイン画面を表示する
(2) ログイン画面表示中はパペロがランダムに頭を動かして人の顔を探す
(3) 顔が見つかった場合、顔の距離が遠ければ「もうちょっと寄って」と発話し、近ければ「ちょっと止まって」と発話して撮影する
(4) 撮影した画像でNeoFace認証実行
(5) 認証の結果ユーザID・ユーザ名が返された場合、「こんにちは、~さん」と発話しメニュー画面を表示
(6) 認証失敗の場合「こんにちは」だけ発話し顔探しを続ける
(7) メニュー画面ではパペロの座布団左右ボタンでメニューのフォーカスを移動、中ボタンでメニューを開く=個別画面を開く
(8) 個別画面は例としてiframeで外部サイトを表示
(9) 個別画面でパペロの座布団ボタンを押すとメニュー画面に戻る
(10) メニュー画面でログアウトボタンを選ぶとログイン画面に戻る
としました。
Golang側プログラム
かいつまんでプログラムの説明をします。
(1) アプリのメイン処理はApp構造体に実装しています。パペロの制御には従来通りyapapero.goを使用しており、ボタンなどパペロからのイベントはチャネルで送られる様になっています(app.papero.Ch)。ブラウザのJavaScriptからのイベントもチャネルに送られる様にし(app.webCh)、メインのイベントループではこれら二つのチャネルからイベントを待ちます。
import yp "local/yapapero"
...
func (app *App) start(url, simID, simName string) {
...
yp.NewPapero(url, simID, simName)
app.papero = papero
papero.Start()
for {
if !app.appHandler() {
break
}
}
}
func (app *App) appHandler() bool {
isPaperoMsg := false
isWebMsg := false
var msg yp.MsgIf
// イベント受信
select {
case msg = <-app.papero.Ch:
isPaperoMsg = true
case msg = <-app.webCh:
isWebMsg = true
}
// イベント処理
app.eventHook(msg, isPaperoMsg, isWebMsg)
res := true
if (*app.state).handleEvent == nil {
logger.Error().Str("state", (*app.state).String()).Msg("handleEvent is not defined")
} else {
res = (*app.state).handleEvent(app, msg, isPaperoMsg, isWebMsg)
}
return res
}
(2) サブグループIDの設定・保存用に以前から使用しているwebconfを流用しています。下記のGolangの定義で設定画面が生成され、JSONファイルへの保存・読込みができます。
import wc "local/webconf"
func paperoConfDef(param *wc.ConfDefPrm, app *App) *wc.ConfDefSt {
// セレクトボックスの選択肢
sellst := []wc.OptionSt{
wc.OptionSt{Disp: "Low", Val: yp.PP_NEOFACE_AUTH_THRESHOLD_LOW},
wc.OptionSt{Disp: "Medium", Val: yp.PP_NEOFACE_AUTH_THRESHOLD_MEDIUM},
wc.OptionSt{Disp: "High", Val: yp.PP_NEOFACE_AUTH_THRESHOLD_HIGH},
}
// 画面/データ定義
genitems := func() []wc.ItemPtr {
return []wc.ItemPtr{
wc.Text(&wc.ItemPrm{ID: idSubGroupIdTxt, Title: "サブグループID", Val: "", Eot: " "}),
wc.Selectbox(&wc.ItemPrm{ID: idThresholdSel, Title: "auth threshold", Lst: sellst, Val: yp.PP_NEOFACE_AUTH_THRESHOLD_LOW, Eot: " "}),
wc.Button(&wc.ItemPrm{ID: idSaveBtn, Title: "保存", Eol: " ", Handler: func(s *wc.ConfDefSt, item *wc.ItemSt, val string, dic map[string]string) bool {
s.Dump("")
return true
}}),
wc.Button(&wc.ItemPrm{ID: idReloadBtn, Title: "ファイルから読み直す", Eol: " ", Handler: func(s *wc.ConfDefSt, item *wc.ItemSt, val string, dic map[string]string) bool {
s.Load("")
return true
}}),
wc.Button(&wc.ItemPrm{ID: idDefaultBtn, Title: "デフォルトに戻す", Handler: func(s *wc.ConfDefSt, item *wc.ItemSt, val string, dic map[string]string) bool {
s.SetDefault()
return true
}}),
wc.Span(&wc.ItemPrm{ID: idVersion, Val: "Ver." + Version, Nosave: true}),
}
}
handler := &wc.ConfDefHandler{
OnEvent: func(s *wc.ConfDefSt, idstr string, typ string, val string, dic map[string]string, handled bool) {
},
}
return wc.NewConfDefSt(param, handler, genitems)
}
設定画面:
(3) JavaScriptと通信するWebSocketサーバはwebconfのWsSvrStを流用しており、受信したデータをチャネルapp.webChでAppに送っています。
// wsAppSvr __control websocket handler
type wsAppSvr struct {
wc.WsSvrSt
app *App
}
func (svr *wsAppSvr) Init(app *App) {
svr.app = app
svr.WsSvrSt.OnOpen = func(ws *websocket.Conn) {
name := MID_DUMP_REQ
dat := &DumpReq{
name: name,
path: "",
ws: ws,
}
app.webCh <- yp.NewMsg(name, dat)
}
svr.WsSvrSt.OnClose = func(ws *websocket.Conn) {
app.webCh <- yp.NewMsg(MID_CLOSE, nil)
}
svr.WsSvrSt.DataRecv = func(ws *websocket.Conn, messageType int, msg wc.WsMessage) bool {
svr.InvokeBroadcast(msg, ws, true)
dic := map[string]string{}
err := json.Unmarshal(msg, &dic)
if err != nil {
logger.Error().Err(err).Msg("json.Unmarshal error.")
return true
}
idstr := dic["Name"]
app.webCh <- yp.NewMsg(idstr, dic)
return true
}
svr.WsSvrSt.SingleMode = true
svr.WsSvrSt.Init()
}
(4) 全体は状態により処理を振り分ける状態遷移プログラムとなっており、StateCode型の状態変数app.stateの値自体にイベント処理関数(handleEvent)を登録しています。
type stateCodeSt struct {
name string
faceTrackEn bool
handleEvent func(app *App, msg yp.MsgIf, isPaperoMsg, isWebMsg bool) bool
enter func(app *App, msg yp.MsgIf, oldState StateCode)
leave func(app *App, msg yp.MsgIf, newState StateCode)
}
type StateCode *stateCodeSt
var (
appInit = &stateCodeSt{name: "appInit"}
appSceVersion = &stateCodeSt{name: "appSceVersion"}
appNeofaceInit = &stateCodeSt{name: "appNeofaceInit"}
appIdle = &stateCodeSt{name: "appIdle"}
appSearchPause = &stateCodeSt{name: "appSearchPause", faceTrackEn: true}
appSearchMove = &stateCodeSt{name: "appSearchMove", faceTrackEn: true}
appDetectFace = &stateCodeSt{name: "appDetectFace", faceTrackEn: true}
appTakePicture = &stateCodeSt{name: "appTakePicture"}
appNeofaceAuth = &stateCodeSt{name: "appNeofaceAuth"}
appUserWork = &stateCodeSt{name: "appUserWork"}
)
func (app *App) setHandlers() {
// 初期状態イベント処理
appInit.handleEvent = func(app *App, msg yp.MsgIf, isPaperoMsg, isWebMsg bool) bool {
res := true
switch {
case msg.IsReady():
logger.Info().Msg("PaperoReady")
app.papero.SendGetSceVersion(nil)
// 状態遷移
app.transState(msg, appSceVersion)
}
return res
}
...
(5) 顔の撮影は左右の目の距離(簡単のためx座標のみの比較)が定数(90)より大きいときのみ行うようにし、それより小さい場合「もうちょっとそばに寄って?」と発話させています。また、その発話が立て続けに行われないよう、タイマーで一定時間しゃべらない様にしています。
func (app *App) onDetectFace(msg yp.MsgIf, pm *yp.PaperoMessage) {
if pm != nil && pm.LeftEyePos != "" && pm.RightEyePos != "" {
lpos, _ := TextToSliceInt(pm.LeftEyePos, ",")
rpos, _ := TextToSliceInt(pm.RightEyePos, ",")
if len(lpos) == 2 && len(rpos) == 2 {
d := lpos[0] - rpos[0]
logger.Info().Int("eyes distance", d).Msg("detect face.")
if d < 90 {
if !app.inShutUp {
app.speech("もうちょっとそばに寄って?", false, nil)
} else {
logger.Info().Msg("in shut up time.")
}
app.transState(msg, appDetectFace)
} else {
app.speech("ちょっと止まって?", false, nil)
app.transState(msg, appTakePicture)
}
}
}
}
(6) 顔認証はAPIを呼び、ユーザID・ユーザ名が返ってくれば成功としています。プログラム側でそのユーザIDの正当性のチェックは行っていません。「こんにちは、~さん」と発話し、発話終了のタイミングでユーザIDと名前をWebSocketでJavaScript側に送っています。
func (app *App) startAuth() {
sgid := app.subGroupId
queryImage := IMAGE_FILE_DIR + "/" + AUTH_IMAGE_NAME
if app.authImageExists() && 0 < len(sgid) {
th, err := app.confdef.GetVal(idThresholdSel)
if err != nil {
logger.Error().Err(err).Msg("bad ThresholdSel conf.")
} else {
app.clearError()
app.papero.SendNeofaceAuthenticate(sgid, th, queryImage, nil)
}
}
}
func (app *App) authUser(msg yp.MsgIf, userId string, userName string, imageIdx int) {
if 0 == len(userName) {
app.speech("こんにちは", true, nil)
app.transState(msg, appIdle)
} else {
app.userId = userId
app.userName = userName
app.speech(fmt.Sprintf("こんにちは、%sさん", userName), true, nil)
app.transState(msg, appUserWork)
}
}
...
appNeofaceAuth.handleEvent = func(app *App, msg yp.MsgIf, isPaperoMsg, isWebMsg bool) bool {
res := true
name := msg.GetName()
pm := msg.GetPaperoMessage()
ret := app.getRet(pm)
logger.Info().Msg(msg.GetName())
switch {
case msg.IsNeofaceAuthenticateRes():
if ret != 0 {
logger.Error().Str("name", name).Int("code", ret).
Str("type", yp.NeofaceRes(ret).String()).Msg("Error")
app.authUser(msg, "", "", -1)
} else {
logger.Info().Msg(name)
ok := false
lst := pm.Outvec
if lst != nil && 0 < len(lst) {
top := lst[0]
uid := top.UserId
uname := top.UserName
imageIdx := top.FaceIndex
if 0 < len(uid) {
logger.Info().Str("userId", uid).Str("userName", uname).Interface("outvec", lst).Msg("authenticate OK.")
idx, err := strconv.Atoi(imageIdx)
if err == nil {
ok = true
app.authUser(msg, uid, uname, idx)
}
}
}
if !ok {
logger.Info().Interface("outvec", lst).Msg("authenticate fail.")
app.authUser(msg, "", "", -1)
}
}
}
return res
}
appUserWork.handleEvent = func(app *App, msg yp.MsgIf, isPaperoMsg, isWebMsg bool) bool {
res := true
name := msg.GetName()
pm := msg.GetPaperoMessage()
switch {
case msg.IsEndOfSpeech():
app.guist = guiSelect
dic := map[string]string{
yp.PP_NAME: MID_LOGIN,
KEY_USER_ID: app.userId,
KEY_USER_NAME: app.userName,
KEY_GUI_STATE: app.guist.String(),
}
app.broadcastDic(dic)
app.stopFaceTracking()
app.papero.MoveHeadTo([2]int{0, 0}, "", "")
case msg.IsDetectButton(""):
logger.Info().Msg(name)
val := pm.Status
dic := map[string]string{
yp.PP_NAME: MID_BUTTON,
KEY_VALUE: val,
}
app.broadcastDic(dic)
}
return res
}
HTML/JavaScript側
ほとんどのロジックはGolang側に実装されるのでhtml/jsはシンプルです。こちらは省略せず記載します。
(1) neofaceauth.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>
NeoFaceログインデモ
</title>
<link rel="stylesheet" href="css/pure-min.css">
<link rel="stylesheet" href="css/neofaceauth.css">
<style>
[v-cloak] {display: none;}
</style>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="js/paperoapp.js"></script>
<script src="js/neofaceauth.js"></script>
<div id="app" v-cloak>
<div v-show="no_javascript">
JavaScriptを有効にして下さい
</div>
<div v-if="state=='login'">
<div class="header">
<h2>PaPeRo i NeoFaceログインデモ</h2>
</div>
<div class="content">
<form class="pure-form pure-form-aligned">
<fieldset>
<div class="pure-control-group">
<label for="user_login">ユーザー名</label>
<input type="text" id="user_login" v-bind:value="user_name" size="20">
</div>
<div class="pure-control-group">
<label for="user_password">パスワード</label>
<input type="password" id="user_password" value="" size="20">
</div>
<div class="pure-controls">
<input type="button" id="login_btn" value="ログイン" v-on:click="doLogin">
</div>
</fieldset>
</form>
</div>
</div>
<transition
v-on:enter="selsetEnter"
v-on:leave="selsetLeave"
>
<div v-if="state=='select'">
<div class="header">
<h2>PaPeRo i NeoFaceログインデモ</h2>
</div>
<form class="pure-form pure-form-aligned">
<fieldset>
<div class="pure-control-group">
<label for="user_login">ユーザー名</label>
<input type="text" v-bind:value="user_name" size="20" readonly>
<input type="button" id="login_btn" value="logout" ref="elm0" v-on:click="doLogout">
</div>
</fieldset>
<div class="content">
<h4>メニュー</h4>
<div class="pure-controls">
<a href="" ref="elm1">Zabbix</a>
</div>
<div class="pure-controls">
<a href="" ref="elm2">Munin</a>
</div>
<div class="pure-controls">
<a href="" ref="elm3">Nagios</a>
</div>
<div class="pure-controls">
<a href="" ref="elm4">Youtube</a>
</div>
</div>
</div>
</transition>
<div v-if="state=='disp1'">
<div>
<label for="user_login">ユーザー名
<input type="text" v-bind:value="user_name" size="20" readonly>
</label>
<input type="button" id="login_btn" value="logout" v-on:click="doLogout">
</div>
<div>
<iframe width="800" height="600" src="http://demo.zabbix.jp"></iframe>
</div>
</div>
<div v-else-if="state=='disp2'">
<div>
<label for="user_login">ユーザー名
<input type="text" v-bind:value="user_name" size="20" readonly>
</label>
<input type="button" id="login_btn" value="logout" v-on:click="doLogout">
</div>
<div>
<iframe width="800" height="600" src="http://demo.munin-monitoring.org/"></iframe>
</div>
</div>
<div v-else-if="state=='disp3'">
<div>
<label for="user_login">ユーザー名
<input type="text" v-bind:value="user_name" size="20" readonly>
</label>
<input type="button" id="login_btn" value="logout" v-on:click="doLogout">
</div>
<div>
<iframe width="800" height="600" src="http://demos.nagios.com/"></iframe>
</div>
</div>
<div v-else-if="state=='disp4'">
<div>
<label for="user_login">ユーザー名
<input type="text" v-bind:value="user_name" size="20" readonly>
</label>
<input type="button" id="login_btn" value="logout" v-on:click="doLogout">
</div>
<div>
<iframe width="560" height="315" src="https://www.youtube.com/embed/OQAvtAvN390" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
</div>
</div>
</div>
</body>
</html>
(2) neofaceauth.js
/**
* PaPeRo i NeoFaceログインデモ
* paperoapp.jsを使用しGolang側アプリと通信して動作する
* (c) 2019 Sophia Planning Inc.
* LICENSE: MIT
*/
'use strict';
(function() {
// message id
var TEXT_PFX = '#';
var MID_DUMP_DAT = '$dump_dat';
var MID_ERROR = '$error';
var MID_LOGIN = '$login';
var MID_LOGOUT = '$logout';
var MID_BUTTON = '$button';
// message key
var KEY_NAME = 'Name';
var KEY_VALUE = 'Value';
var KEY_USER_NAME = 'userName';
var KEY_GUI_STATE = 'guiState';
// gui state
var GUI_STATE_LOGIN = 'login';
var GUI_STATE_SELECT = 'select';
var GUI_STATE_DISP_PFX = 'disp';
// other def
var CLS_FOCUS = 'neofaceauth-sel';
var loaded = false;
var NeofaceAuth = (function() {
function NeofaceAuth() {
var self = this;
// ブラウザとPython?アプリ間でWebSocket通信するGUIアプリ用通信クラスオブジェクト
this.papero = new paperoapp.PaperoApp();
this.vueapp = null;
// イベントハンドラを入れ替え
this.papero.handleEvent =function(mid, dat) {
this.handleEvent(mid, dat);
}.bind(this);
// パペロ接続開始
this.papero.start();
}
NeofaceAuth.prototype.clearSel = function() {
var refs = this.vueapp.$refs;
var cnt = Object.keys(refs).length;
for (var j = 0; j < cnt; j++) {
refs['elm' + j].classList.remove(CLS_FOCUS);
}
};
NeofaceAuth.prototype.buttonInSelect = function(val) {
var idx = this.vueapp.focus_idx;
var refs = this.vueapp.$refs;
if (0 == this.vueapp.elm_cnt) {
this.vueapp.elm_cnt = Object.keys(refs).length;
}
var elm_cnt = this.vueapp.elm_cnt;
if ('R' == val) {
idx -= 1;
if (idx < 0) {
idx = elm_cnt - 1;
}
this.clearSel();
refs['elm' + idx].classList.add(CLS_FOCUS);
}
else if ('C' == val) {
if (0 == idx) {
// logout
this.vueapp.doLogout();
}
else {
this.vueapp.state = GUI_STATE_DISP_PFX + idx;
}
}
else if ('L' == val) {
idx += 1;
if (idx >= elm_cnt) {
idx = 0;
}
this.clearSel();
refs['elm' + idx].classList.add(CLS_FOCUS);
}
this.vueapp.focus_idx = idx;
}
NeofaceAuth.prototype.handleEvent = function(mid, dat){
console.log('NeofaceAuth.handleEvent. name:' + mid);
if (mid == MID_ERROR) {
var txt = dat[KEY_VALUE];
if (!txt) {
txt = '未知のエラー';
}
alert(txt);
}
if (MID_DUMP_DAT == mid) {
if (this.vueapp) {
if (KEY_GUI_STATE in dat) {
var guist = dat[KEY_GUI_STATE];
this.vueapp.state = guist;
}
if (KEY_USER_NAME in dat) {
this.vueapp.user_name = dat[KEY_USER_NAME];
}
}
}
else if (MID_LOGIN == mid) {
if (this.vueapp) {
if (KEY_GUI_STATE in dat) {
var guist = dat[KEY_GUI_STATE];
this.vueapp.state = guist;
}
if (KEY_USER_NAME in dat) {
this.vueapp.user_name = dat[KEY_USER_NAME];
}
}
}
else if (MID_BUTTON == mid) {
if (this.vueapp) {
if (KEY_VALUE in dat) {
var val = dat[KEY_VALUE];
var state = this.vueapp.state;
if (GUI_STATE_SELECT == state) {
this.buttonInSelect(val);
}
else if (0 === state.indexOf(GUI_STATE_DISP_PFX)) {
this.vueapp.state = GUI_STATE_SELECT;
}
}
}
}
else if (mid.substring(0, 1) == TEXT_PFX) {
var eid = mid.substring(1);
var val = dat[KEY_VALUE];
//setText(eid, val);
}
};
NeofaceAuth.prototype.setVue = function(app){
this.vueapp = app;
}
return NeofaceAuth;
})();
// 初期化
var init = function() {
var none = 'none';
// アプリクラス生成(グローバル変数へ)
var app = new NeofaceAuth();
var vueapp = new Vue({
el: '#app',
data: {
no_javascript: false,
state: GUI_STATE_LOGIN,
user_name: '',
focus_idx: 1,
elm_cnt: 0,
},
methods: {
doLogin: function (evnt) {
// 未
},
doLogout:function (evnt) {
var dic = {};
dic['Name'] = MID_LOGOUT;
app.papero.wssend(dic);
this.state = GUI_STATE_LOGIN;
},
selsetEnter: function (el, done) {
var refs = this.$refs;
var idx = this.focus_idx;
if (0 <= idx) {
refs['elm' + idx].classList.add(CLS_FOCUS);
}
done();
},
selsetLeave: function (el, done) {
done();
},
}
});
app.setVue(vueapp);
loaded = true;
}
window.addEventListener('load', init, false);
})();
表示の切替えにVue.jsの条件付きレンダリング機能を利用しています。これはHTMLのタグで例えばv-if=”state==’select'”と記述してあるdivは、jsのstate変数(new Vue()のdata{}で定義)が’select’の時だけ表示される、というものです。また、画面切り替え時にメニュー画面のフォーカス表示を再現するのに、v-on:enter/v-on:leaveを利用しています。
ログイン画面:
メニュー画面:
※パペロのボタンのみ操作可能、マウス・キーボードでの操作はできるようにしていません
個別画面:
例としてZabbixデモサーバをiframeで表示しているのでアカウント: guest-ja、パスワード: zabbixと入力して下さい。
使用手順
(1) PaPeRo i のバージョン確認・更新
PaPeRo i 顔認証サービスを使用するためにはFW=2.1.37_papero-09以降、robot_platform=std_1_0_18.rom以降(/Extension/robot_platform/bin/sys_mgrのタイムスタンプがMar 16 2017以降)が必要です。
FWはブラウザで 192.168.1.1/index.cgi/fw_main から、
robot_platformは 192.168.1.1/robo/fwup.html から更新できます。
(2) アドオンシナリオ用ライセンスファイル入手
FW=2.1.37_papero-09/robot_platform=std_1_0_18.rom以降ではアドオンシナリオを動作させるためのライセンスファイルが必要になります。
(/Extension/License/sce_????????_????????????.lic)
(3) PaPeRo i 顔認証サービス申し込み、使用するPaPeRo iの申請
(4) PaPeRo i 顔認証サービス管理ポータルでサブグループの登録、端末(PaPeRo i)へのサブグループ登録
(5) PaPeRo i へのNeoFaceライセンスファイル等のインストール
/Extension/Licenseにneoface_admin_key、neoface_auth_key、neoface_groupidを置いてください。
(6) 初回起動
※ラズパイ3(192.168.1.5)で実行してパペロ(192.168.1.1)と接続する例です。アドレスは適宜読み換えてください。パペロ本体で実行する場合アドレスは全て192.168.1.1になります。
デモアプリバイナリをダウンロードしてラズパイ3の任意の場所に展開し、
# cd target_dir
# ./neofaceauth -wssvr ws://192.168.1.1:8088/papero
(7) サブグループIDの設定
ブラウザで192.168.1.5:8866/configを開き、サブグループIDに管理ポータルで設定したIDを入力して保存してください。
(8) アプリ再起動
一旦終了し、パペロからインターネット接続可能なことを確認した上で、再度実行してください。
# ./neofaceauth -wssvr ws://192.168.1.1:8088/papero
(9) 操作
ブラウザで 192.168.1.5:8866/web/neofaceauth.html を開いてください。
ダウンロードリンク:
アドオンシナリオ
デモアプリバイナリとソース