tmlib.js+αでスマフォ向けエロゲUI風ゲーム的なやーつを作ったときの技術的な話

## はじめに

この記事は[tmlib.js Advent Calendar 2014](http://qiita.com/advent-calendar/2014/tmlib) 22日の記事です。

ちょっと前のイベント用に作ったtmlib.js+jQuery(+生JS)なアプリについて、技術面中心に振り返り的に書き残したいと思います。
企画等についてはチーム全体で反省を行いましたが、実装は私1人だったので個人的な反省を含めつつ。

![2014-10-18 09.03.53.png](https://qiita-image-store.s3.amazonaws.com/0/21888/8fc3bbec-6474-267c-35d4-41b16b3226e4.png)


## 企画の概要

* 学祭グループ展 [Media Design Works 2014](http://mdw2014.github.io/) にて行った、"ARG(代替現実ゲーム)"。
* プレイヤーがスマートフォンでQRコードを読み込みサイトへアクセス、ログインすると"人工知能N.E.O."が起動。彼女から「"MDW"のメンバーが危機に陥っている、力を貸してほしい」と依頼される。
* 目的は集客(会場が4階の教室なので人が来にくい！)、来場者への楽しみ+α
* チーム制作、プランナ・プログラマ担当

完成品、QRコードからアクセスするページは落としちゃってるのですが、ログイン(もちろんただの演出です)後リダイレクト先のページはまだ落としてないので貼っておきます。

[neo.neoneobeam.com](http://neo.neoneobeam.com)

ちなみに進行に必要な答えは以下

* 適性検査: 2014
* 座標情報: 114514


## 要件とか

そんな企画を踏まえて、要件はだいたいこんな感じで話が進みました。

* スマフォ
    * インストールとかさせたくない -> まあWebだよね
* エロゲUI
    * 真ん中に女の子
        * 表情差分あり
    * 下にテキスト
    * タップでシナリオ進行
    * ログ表示
    * 画像表示
* 数字入力
    * 正解でシナリオ進行


## 実装がんばった

さて、ここから本題。
1週間でやりました。

ちなみになぜCanvas使うと思い込んでたのか今ではわかりません。
途中DOMでいいじゃんという熱い意見も出ましたが、それなと答えるしかなかった…
この話にはあとからも触れます。

冒頭にも書きましたが、ライブラリは

* tmlib.js
* jQuery

を併用しています。

tmlib.jsを採用した理由は、以前に作った[THE SUGOI NINJA](http://sugoi-ninja.com)での経験があったことと、 @phi さんの思想にかねてから共感していたということから。
jQueryの方は、別に生のJSでも普通にできたんですが(実際途中まではそのつもりで書いてた)途中でめんどくさくなって甘えた感じです。他にもなんか理由あった気がするけど忘れました(笑)

というかんじでTipsになるかわかりませんが、一部コード抜粋しつつ少しだけ実装を紹介していきます。


### ローディング

Canvasは透過にして直接DOM操作してクロイガメン的なやつを動かしてます。

一番最初、こんなような待ち合わせを書いたりしたけど、他にやりようはないですよね…

```main.js
tm.main(function() {
  document.getElementById('tmlib').textContent = '[DONE]'; // クロイガメンの演出用
  onload();
});
  
$(function() {
  $('#jquery').text('[DONE]'); // クロイガメンの演出用
  onload();
});
  
var waitCount = 2;
function onload() {
  waitCount--;
      if (waitCount)
        return;
  /* 以下、起動処理など */
};
```

### テキスト表示

テキストをCanvas内で扱うのは、折り返しが大変という理由でやめました。
正直ここから実装が泥沼って感じだったと思います。

次エロゲUIなものを作るとしたら、そもそもCanvasを使わないかもなぁ…
画像は画像としてまとめやすかった感はあるけれども


### リサイズまわり

これが一番闇っぽい感じだった。

* 画面いっぱい、かつdevicePixelRatioによってcanvasのリサイズしていた
* 所謂 wrapper からはみ出す画像と、そうでない画像を混在させてしまった
* テキスト用のブロックが別にある

ために、リサイズ処理が大変なことになりました。あとタッチ判定も。
画面サイズによって縦横どちらに余りが出ても、上下の青くて丸い要素が続くようにしたかったのが一番の原因て感じです。が、もうちょっとスマートに実装できなかったか…パスとかにするというのも考えにはあったけど、うーん…っていう感じ。

ちなみに画面いっぱいはたぶんこれでいけると思います。

```main.js
var canvasSizeRatio = 1/window.devicePixelRatio;
var d = document.documentElement;
app.resize(d.clientWidth / canvasSizeRatio, d.clientHeight / canvasSizeRatio);
```


### 画面管理

今回スマートフォンメインということで、LOG画面と画像の拡大表示画面はAndroidのバックキー(ブラウザバック)でも閉じられるようにしたかったので、hashを書き換える方法を取りました。

```main.js
var _onPopState = [];
var isPopReady = false;

ns.page = {
  push: function(hash, f) {
    isPopReady = false;
    location.hash = hash;
    _onPopState.push(f);
    isPopReady = true;
  },
  pop: function(hash) {
    _onPopState.splice(-1, 1);
  }
}
window.addEventListener('popstate', function (event) {
  if(isPopReady)
    _onPopState[_onPopState.length-1]();
}, false);
```

```logScene.js
tm.define('LogScene', {
  superClass: 'MyScene',

  init: function() {
    this.superInit();
    /* 省略 */
  },
  popLog: function(i_e) {
    ns.page.pop('log');
    /* 閉じるアニメーションとか */
    e.app.popScene();
  },
  onenter: function() {
    ns.page.push('log', function() {this.popLog();}.bind(this));
    /* 開くアニメーションとか */
    // Logはさらに画像拡大へ行くこともできるので、そっちから戻った場合に備えて書き換え
    this.onenter = function() {
      /* 戻ってきたときの処理 */
    }
  },
});
```


### ｵﾚｵﾚノベル用スクリプト

勢いで自前ノベルゲーム用スクリプト的な奴も作りました。

シナリオはこんなふうにタグを埋めてて

```text.txt
――起動シークエンス確認、オールグリーン。ファーストインプレッション、開始します。

[neo serious]
[input num]
適正者診断。次の "????" に入る数字4桁を答えなさい。 「Media Design Works ????」

[case 2014]
お見事です。
[skipto endcase]

[default]
エラー。もう一度入力してください。
[backto input]

[endcase]

[neo smile]
こんにちは、先程は貴方を試すようなことをして申し訳ありません。
――あの難問を解き明かすとは、お見事です。私はこの時をずっと待ちわびていました。
貴方を適合者、いえ運命を変える存在と見込んでお願いがあります。

[neo serious]
私は仮想現実防御用エージェントシステム、高性能人工知能NEOです。
貴方のデバイスの中からサポートをします。よろしくお願いします。
[image show puritist]
今、この東京造形大学では、古典芸術を信仰し現代芸術を殲滅せんとする『純粋芸術派同盟』と
それに対抗し自由な芸術を推進するいくつかの新芸術派レジスタンスで抗争が起きています。
しかし、『純粋芸術派同盟』の勢力は日増しに強くなり、レジスタンスの殆どが機能を果たせなくなりました。
[image show mdw]
パパ――私の開発者が所属している、メディアデザインワークス、通称『MDW』も、厳しい戦いを強いられ
『CS祭』期間中は籠城せざるを得なくなっています。
しかし、貴方の協力があれば、この状況を打開できるかもしれません。
現在私には単独で行動を完了させることが不可能なのです。
[image del puritist]
[image del mdw]
お願いです。新芸術派の未来のために、力を貸してください。
```

いろいろ省いていますが、こんな感じでパースして、テキスト変えたり表情差分変えたりとかの関数を呼ぶように作りました。

```text.js

var _texts = null;
var _index = 0;
var canNextOnClick = true;
var canNext = true;

function _parseText(i_text) {
  return i_text.split(/\r\n|\r|\n|;/);
}
function _getCurrentText(i) {
  if (_index>=_texts.length)
    return;
  
  if(i)
    _index += i;
  
  var currentText = _texts[_index++];
  var pattern = /\[(.*?)\]/g;
  var script = currentText.match(pattern);
  var text = currentText.replace(pattern, '');
  return {'script':script, 'text':text};
}

var _toggleClickableTimer;
function _nextByClick(p) {
  _next(p);
  clearTimeout(_toggleClickableTimer);
  _toggleClickableTimer = setTimeout(function() {
      canNextOnClick = true;
    }, 500);
  canNextOnClick = false;
}

function _next(p) {
  if (!canNext || !canNextOnClick || _index>=_texts.length)
    return;
  var p = p || {};
  var ct;
  if (p && p.ct) {
    ct = p.ct;
  } else {
    ct = _getCurrentText();
  }

  if (ct.script) {
    _runScript(ct.script, p.val);
  } else if (ct.text) {
    _changeText(ct.text);
    _showClickableIcon();
  } else {
    _next({'val':p.val});
  }
}

function _runScript(i_script, i_val) {
  var s = _scriptReplace(i_script[0]);
  switch (s[0]) {
      case 'neo': // NEOの差分
        _changeNeo(s[1]);
        break;

      case 'image': // 画像
        switch (s[1]) {
            case 'show':
              _showImage(s[2]);
              break;

            case 'del':
              _hideImage(s[2]);
              break;
        }
        _next();
        break;

      case 'error': // 画像
        switch (s[1]) {
            case 'show':
              _showError();
              break;

            case 'del':
              _hideError();
              break;
        }
        _next();
        break;

      case 'input': // 入力画面を開く
        _pushInputArea(s[1]);
        break;

      case 'case': // 入力された値に一致すると発火
        if (s[1] == i_val) {
          _next();
        } else {
          _skipTextTo(/case|default|endcase/);
        }
        break;

      case 'default': // caseどれにも一致しなかった場合発火
          _next();
        break;

      case 'skipto':
        _skipTextTo(s[1]);
        break;

      case 'backto':
        _skipTextTo(s[1], 'reverse');
        break;

      case 'sleep': // スリープモードになっておでこタップされるまで待機
        _sleep();
        break;

      case 'eof':
        _end();
        break;

      default:
        _logScriptError(s[0]);
        _next();
        break;
  }
}

ns.text = {  
  next: function(p) {
    _nextByClick(p);
  },
  loadTextByTXT: function(i_path, callback) {
    var xmlHttp = new XMLHttpRequest();
    xmlHttp.onreadystatechange = checkReadyState;
    xmlHttp.open("GET", i_path, false);
    xmlHttp.send(null);
    function checkReadyState() {
      if (xmlHttp.readyState == 4 && xmlHttp.status == 200){
        _texts = _parseText(xmlHttp.responseText);
        callback();
      }
    };
  },
}
```

### セーブロード

いつプレイヤーがページを離れても大丈夫なように、進行というか各状態を```localStorage```に保存するようにしました。
離れようとしたときアラートを出して引き止めることもしていますが、```window.onbeforeunload```が呼ばれるとも限らないので、すべて1クリックごとに保存し直します。
クリアというか、すべて読み終わったときには```window.onbeforeunload```をクリアするとかそういう当たり前だけど細かい配慮もポイントです。

ハマりポイントとしては、Safariのプライベートブラウズ
こちらの記事を参考にしました。
http://qiita.com/hakobera/items/2ab35109bec109a06edd

確か、前日とかにメンバーにデバッグしてもらっていたら発覚して、かなり焦った覚えがあります。
iPhoneも持とうと決心した瞬間でありました。(まだ買えてない)


## まとめ

Webで既存のエンジンより自由度の高いエロゲUI風をやるなら、書き始める前にいろいろ検討した方がいいですね。今回はただのゲームでもなくかなり特殊でしたが。
tmlib.jsはとても便利なライブラリですが、今回はちょっと私の使い方が良くはなかった気がします。そもそもCanvasかっていう話も。
とは言え、たくさんの画像を管理して画面を作るのにはかなり恩恵を受けられた気もします。

いざ記事を書き始めてみたら、汎用性のあるコードも少なくて、大したこと書けませんでしたが…
以上、私の開発振り返りでした。