• 8
    Like
  • 0
    Comment

/* これは岩手県立大学 Advent Calendar 2016の6日目の記事です。 */
/* 12/7 載せていなかったコードを追加しました。 */

高校時代。
あれほど狂ったようにコードを書いていた日々は無かっただろう。

自称進学校並の課題。部活(卓球)との両立。
限られた時間の中でこそ最大の集中を発揮するのだろうか。

課題は一切手付かず、夏休みの余暇全てを費やして作り上げた、ぼくのかんがえたさいきょうのノベルゲームエンジンについて話そう。

開発環境

  • Lenovo IdeaPad S103-s
  • ArchLinux
  • Sublime Text 3

実家のPCはすべて型落ちのWindows機。
自由に使えるのはメモリが512MBしか積んでいないXPマシンだった。
高校生だった自分にMacbookなどを買う財力も無く、辛うじてお年玉で買ったネットブックでひたすら開発をしていた。
Linuxに初めて触れたのは中学時代で、ArchLinuxは中2から使い続けている。

開発の経緯

初めて触れたプログラミング言語はHSPだった。
ちまちまとGUIアプリケーションを作ったり、ちょっとしたゲームを作って遊んでいた。

ゲーム機やゲームソフトを買うことを父親に取り締まられる家庭だったので、兄と自分はフリーゲームに染まっていた。
特に兄はノベルゲームが好きで、自分はそれを見て楽しむことが多かった。自分が操作しなくても勝手に物語が良いペースで進んでいくので楽である。

その影響もあり、ノベルゲームを作りたいと思う時があった。
無論シナリオなんてまともに書いたことは無いし、こうしてブログ的な感じで文章を書くのが精一杯だ。

だから、ゲームエンジンを作ることにしたというわけだ。
吉里吉里の存在は知っていたし、最低限文章と選択肢を出せればいいと思って開発がスタートした。
これが中学1年の時の話で、マルチバイト文字の処理や改行コードの扱い方を知り、最低限やろうと思ったことは出来た。
この時はそれで満足していた。

そして月日は流れ、高校2年。
HTML5が登場したばかりで、Canvas APIがもてはやされていた時代だ。
自分も時代の波に乗って、オライリーのHTML5 Canvas本を買って読んでいた。

ご存知の通り、技術本をただ読み流すというのは全く無意味な行為だ。
コードは書いてこそ身に付く。しかし本のサンプルコードを書き写すのは退屈だ
そこで、あの時HSPで書いたノベルゲームエンジンもどきを本物にしようと決意したのだった。

この頃、ティラノスクリプトのようにCanvasを駆使したノベルゲームエンジンは既にあって、二番煎じとなってしまうのは間違い無かった。
しかしここで立ち止まっていては何も進歩が無い。作りたいから作るのだと言い聞かせながら開発は進んだ。

17歳にして、エンジニアにとってモチベーションマネジメントは重要な技術であると悟ったのである。

開発物概要

Canvasでの2D描画をベースとした、ブラウザ上で動作するノベルゲームエンジン。
スクリプト構文は独自のものを採用する。以下にAPIを示す。

特殊構文
@名札ID 名札の表示
*ジャンプフラグ名 ジャンプ可能なフラグを設定

組み込み命令
[mbox p0, p1] メッセージボックス表示オプション
p0 = 表示するかどうか 0(hide) or 1(show)
p1 = 表示位置Y number or "top" | "center" | "bottom"

[sysnd p0, p1] システム効果音設定
p0 = "効果音名"
p1 = "音声ファイルパス"

[entry p0, rest, ...] 画像グループの登録
p0 = 画像グループID
rest = "画像ファイルパス"

[set p0, p1] 名札の登録
p0 = 名札ID
p1 = "表示名"

[bgm p0, p1, p2, p3, p4] BGMの再生
p0 = 再生チャンネル番号
p1 = "音声ファイルパス"
p2 = 繰り返すかどうか 0(loop) or 1(none)
p3 = フェードするかどうか 0(none) or 1(fade)
p4 = 音量(0.0~1.0)

[bgimage p0] 背景画像表示
p0 = "画像ファイルパス"

[seffect p0, p1, p2] 画面エフェクト
p0 = "fade-in" or "fade-out" or "ac-rotate"
p1 = 持続時間(ms)
p2 = "色"

[bgscroll p0, p1, p2, p3, p4, p5, (p6)] 背景スクロール設定
p0 = 倍率(0.0~)
p1 = 初期座標X
p2 = 初期座標Y
p3 = 移動量X
p4 = 移動量Y
p5 = 更新間隔ms
p6 = スクロール繰り返し回数

[snd p0, p1, p2] 効果音再生
p0 = 再生チャンネル番号
p1 = "音声ファイルパス"
p2 = ?

[astop (p0)] 音声のストップ(引数無し=全停止)
p0 = 再生チャンネル番号

[p] クリック待ち

[show p0, p1, p2, p3] スプライトを表示
p0 = スプライト番号
p1 = 画像グループID
p2 = グループ内画像番号
p3 = 画像位置X  |  p3 = "画像位置"
p4 = 画像位置Y  |  p4 = 透明度
p5 = 透明度     |

[hide p0] スプライトを隠す
p0 = スプライト番号

[br] 改行

[cm] メッセージ消去(次のページ)

[jump p0] フラグジャンプ
p0 = フラグ名

[choice p0, p1] 選択肢作成
p0 = "選択肢ラベル"
p1 = ジャンプ先フラグ名

[select] 選択肢実行

[puttext p0, p1, p2, p3, p4] テキストスプライトの作成 表示-> [show p0, _TEXT_IMAGE_ 0]
p0 = テキストスプライト番号
p1 = "テキスト"
p2 = フォントサイズ
p3 = フォント名
p4 = "文字色"

[bgcolor p0] 背景色設定
p0 = "背景色"

ソースコードにろくにコメントを記していなかったため、全ては掬いきれていない。
またリファクタリング途中であったため、ここに書いていないが変数なども扱えたようである。

ちょっとしたサンプルはここに公開してある。
結構古いため、もしかしたら一部のブラウザでは動かなくなっているかもしれない。
Android4.0以降ならスマホとかでも動くはず。

内部実装

base.js

スクリプトのパースやインタープリタの動作はこのファイルにまとめてある。
パーサーに関しては当時ステートマシンなどという知識は全く無かったため、完全に試行錯誤の賜物である。
命令部と引数部を分けて考えて、ステートマシンのような動作をさせているのが見て取れる。
パース関数だけ抜き出してみる。

function parser(text) {
  var num = /(-?(0?[.]\d+$|[1-9]+\d*(([.]\d+)|$)))|^0$/;
  var str = /^\".*\"$/;
  var par = /^[a-z]+[0-z]*$/;

  var all = text.split(/\n/);
  var line;
  var out = [];
  var aidx = 0;
  var lidx = 0;
  var s = '';
  var arg = [];
  var depth = 0;
  var t = '';
  var sa = false;

  function addarg() {
    if (sa) { //string
      arg[arg.length] = s;
    } else {
      if (num.test(s)) {
        arg[arg.length] = Number(s);
      } else if (par.test(s)) {
        arg[arg.length] = { name: s };
      } else {
        alert("error; line:" + aidx + " index:" + lidx);
      }
    }
  }

  for (; aidx < all.length; aidx++) {
    line = all[aidx];
    if (line[0] == '*' || line[0] == '@') {
      out[out.length] = { type: line[0], args: [line.substring(1, line.length)] };
    } else {

      for (lidx = 0; lidx < line.length; lidx++) {
        switch (depth) {
          case 0: //<top level>
            if (line[lidx] == '[') {
              if (s.length > 0) {
                out[out.length] = { type: 'message', args: [s] };
                s = '';
              }
              sa = false;
              depth = 1;
            } else {
              s = s + line[lidx];
            }
            break;
          case 1: //[# ]
            if (line[lidx] == ' ') {
              if (s.length > 0) {
                t = s;
                s = '';
                sa = false;
                depth = 2;
              } //else -> through
            } else if (line[lidx] == ']') {
              if (s.length > 0) {
                out[out.length] = { type: s, args: [] };
                s = '';
                sa = false;
                depth = 0;
              } else {
                depth = 0;
              }
            } else {
              s = s + line[lidx];
            }
            break;
          case 2: //[ #]
            switch (line[lidx]) {
              case ']':
                addarg();
                out[out.length] = { type: t, args: arg };
                depth = 0;
                t = '';
                s = '';
                sa = false;
                arg = [];
                break;
              case '\"':
                s = '';
                sa = true;
                depth = 3;
                break;
              case ',':
                addarg();
                s = '';
                sa = false;
                break;
              case ' ':
                //through
                break;
              default:
                s = s + line[lidx];
                break;
            }
            break;
          case 3: //[ "#"]
            if (line[lidx] == '\"') {
              depth = 2;
            } else {
              s = s + line[lidx];
            }
            break;
        }
      }
      if (s.length > 0) out[out.length] = { type: 'message', args: [s] };
    }
  }
  return out;
}

我ながら糞みたいなコードだ。
カプセル化してるだけでグローバル変数多用し過ぎだし、可読性のカの字も無い。
あと関数名は"parser"じゃなくて"parse"だろ!!

一応ざっくり解説すると、まず受け取った文字列を改行で区切って配列とする。
その各要素に対してパースを行う。つまりは行指向だ。
行の先頭に'@'か'*'があれば特殊構文として扱い、'['で始まる場合は命令とみなす。
状態は'[' -> 命令名 -> 空白 -> 引数 <-> ',' -> ']'の順に遷移し、さらに引数に関しては正規表現によって文字列・数値・シンボルと区別を付けている。
どちらにも合致しない場合は単なる文章(文字列出力命令)と解釈される。

他にInterpreterクラス、Sceneクラス、Taskクラスがある。
この頃はまだES6なんて無かったのでclass構文は使えず、なかなか気持ち悪いコードになっている。

Interpreterクラスはparser関数で得た命令配列を受け取り、逐次解釈実行する。
事前に、これをインスタンス化してaddメソッドで命令名と関数を紐付けてやる必要がある。
それはmain1.jsで行っている。何で数字が付いてるのかは察してほしい。

function Interpreter(coms) {
  this.script = coms;
  this.command = [];
  this.com = {
    type: null,
    args: null,
    isEnd: false,
    timer: null
  };
  this.idx = 0;

  this.add = function(n, f, c) {
    this.command[this.command.length] = {
      name: n,
      start: f,
      stop: c
    };
  }
  this.comEnd = function() {
    this.com.isEnd = true;
    this.idx++;
  }
  this.before = function() {}
  this.run = function() {
    var chk = false;
    if (this.idx >= this.script.length) {
      return false;
    }
    this.com = this.script[this.idx];
    for (k = 0; k < this.command.length; k++) {
      if (this.com.type == this.command[k].name) {
        this.com.timer = new Date();
        this.com.isEnd = false;
        this.before();
        this.command[k].start();
        if (this.command[k].stop == false) this.comEnd();
        chk = true;
        break;
      }
    }
    if (chk == false) {
      this.comEnd();
    }
  }
}

Taskクラスはゲームエンジンの処理の最小単位で、interfaceな感じで作った。
main1.jsを見てもらうと分かると思うが、背景とか文章の表示とかはこれで分けている。
内部でvar own=this;としているのはJSの闇の名残で、ここ以外には出てこないので安心して欲しい。
なんで消して無いんだろう…。

function Task() {
  var own = this;
  this.isRunning = false;
  this.start = function() {
    this.isRunning = true;
  }
  this.end = function() {
    this.isRunning = false;
  }
  this.update = function() {}
  this.draw = function() {}
}

Sceneクラスはその名の通りシーンの管理のためのクラスだ。
実際はフィールドのTaskクラス配列をforでぶん回してるだけ。
最初に全部のタスクについてupdateして、その後全部配列の順番通りにdrawしている。
何故か二重ループになっているが、たしかタスクの処理順が問題になって、そのまま戻すのを忘れていた気がする。

function Scene(rfunc) {
  this.update = function() {
    for (i = 0; i < this.task.length; i++) {
      for (j = 0; j < this.task[i].length; j++) {
        if (this.task[i][j].isRunning) {
          if (this.task[i][j].update() == false) {
            this.task[i][j].isRunning = false;
          }
        }
      }
    }
  }
  this.draw = function() {
    for (i = 0; i < this.task.length; i++) {
      for (j = 0; j < this.task[i].length; j++) {
        if (this.task[i][j].isRunning) {
          this.task[i][j].draw();
        }
      }
    }
  }
  this.run = rfunc;
}

loadtext.js

XMLHttpRequestでスクリプトを読み込む処理を書いたつもりだった。
上手く行かなかったので使ってない。多分ローカルでテストしてたからだと思う。
Chromeを--allow-file-access-from-filesオプション付きで起動すれば良いということを知ったのはもう少し後だ。

fps.js

FPSを表示するのに利用。
どこかのコードをそのままコピーしてきた記憶がある。

main1.js

ゴミコード。僕の青春はここに詰まっている。
ゲームエンジンの根幹部分。大体1000行ぐらいで、長めのコードを書いたのはこれが初めて。
3回ぐらいリファクタリングした気がする。

さすがにこれを全部解説する気は無い。
よく訓練されたソフティなら読めないこともないと思う。

基本的にはさっき述べたTaskクラスの実装をずらずらと列挙してるだけで、変数渡しの流れを読めれば多分理解出来る。
一応、メッセージ表示命令の登録部分とその表示タスクだけ解説する。

メッセージ表示命令登録
advInterpreter.add('message',
  function(){
    mlRndr=new Date();
    mesIdx=0;
    text=ip.com.args[0];
    var pfs=fontSize;
    fontSize=_fontSize;
    fontName=_fontName;
    mesColor=_mesColor;
    if(ip.com.args.length>1){
      fontSize=ip.com.args[1];
      if(ip.com.args.length>2){
        fontName=ip.com.args[2];
        if(ip.com.args.length>3){
          mesColor=ip.com.args[3];
          if(ip.com.args.length>4){
            mesSpd=ip.com.args[4];
          }
        }
      }
    }
    lineHeight=Math.round(fontSize*1.2);
    if(pfs>fontSize){
      cY+=Math.round((pfs-fontSize)*1.2);
    }
    mes.font=fontSize+"px "+fontName;
    mes.fillStyle=mesColor;
    mes.textBaseline="top";
    MessageBox.start();
    Message.start();
  }, true);

登録部分ではip.com.args配列に格納された引数を取り出して、設定などを反映させる。
メッセージの場合はフォントや文字サイズ・色、表示速度を引数として受け取る。
Interpreterクラスのaddメソッドの第3引数では、その命令を同期的に実行するかどうかを決定している。
この例のようにtrueとなっている場合は、この命令(タスク)が実行されている間は次の命令の評価をしない。

メッセージ表示タスク
var Message = new Task();
  Message.update = function() {
    if (mesIdx >= text.length && ip.com.type == 'message') {
      ip.comEnd();
    } else {
      function chkline() {
        if (cX > cmes.width - mes.measureText(text[mesIdx]).width) { //改行処理
          cX = 0;
          cY += lineHeight;
        }
        if (cY > cmes.height - lineHeight) { //改ページ処理
          mes.clearRect(0, 0, cmes.width, cmes.height);
          cX = 0;
          cY = 0;
        }
      }

      function drawCharactor() {
        mes.clearRect(cX, cY, mes.measureText(text[mesIdx]).width, lineHeight);
        mes.fillText(text[mesIdx], cX, cY);
        cX += mes.measureText(text[mesIdx]).width;
        mesIdx++;
      }
      if (mouse.isClick && new Date() - ip.com.timer > skipGap) { //スキップ
        while (mesIdx < text.length) {
          chkline();
          drawCharactor();
        }
      } else {
        cpms += new Date() - mlRndr;
        var alpha = cpms % mesSpd;
        var char = (cpms - alpha) / mesSpd;
        if (char >= text.length - mesIdx) {
          alpha = 0;
          char = text.length - mesIdx;
        }
        while (char > 0) {
          drawCharactor();

          char -= 1;
        }
        cpms = alpha;
        if (alpha > 0) {
          chkline();
          mes.clearRect(cX, cY, mes.measureText(text[mesIdx]).width, lineHeight);
          mes.save();
          mes.globalAlpha = alpha / mesSpd;
          mes.fillText(text[mesIdx], cX, cY);
          mes.restore();
        }
      }
      mlRndr = new Date();
    }
  }
  Message.draw = function() {
    buf.drawImage(cmes, mesX, mY + 24);
  }

タスクの内部で関数を宣言しているため面食らうかもしれないが、やっていることはシンプルだ。
嘘だ、結構複雑。

グローバル変数がいくつかあるのでまずはそれを説明する。

  • cpms: 現在描画中の文字の描画にかけている総時間(ms)
  • ip.com.timer: 最後にこのタスクが実行されたタイムスタンプ(ms)
  • skipGap: スキップを行う間隔(ms)
  • cmes: メッセージ表示用Canvas
  • mes: cmesのコンテキスト
  • cX : 次に文字を表示する位置(cmes内X座標)
  • cY : 次に文字を表示する位置(cmes内Y座標)
  • lineHeight: 行の高さ(px)
  • text: 表示したい文字列
  • mesIdx: text中のインデックス(次に表示する文字の位置)

chkline関数を見てみる。
改行やら改ページやらコメントが付いているので察せると思うが、これは行末処理だ。
現在の表示位置(cX)に表示したい文字を描画したらはみ出る場合は改行。
現在の表示位置(cY)に表示したい文字を描画したらはみ出る場合は改ページといった具合。

そしてdrawCharacter関数。
これは単純なので簡単。描画領域を一旦クリアしてからそこに文字を描画する。
関数内で特に処理はしていないためglobalAlpha(透明度)は文脈に依存する形になるが、実は不透明文字を描画するのに利用している。

言い忘れていたが、文字の表示にはいちいちフェードをかけている。
その処理を省けばもっとシンプルに書けるというのに自分は何をやっていたんだ。

読むのが辛いので、フェードのために透明度をいじっている部分の解説は割愛したい。
興味がある人だけ頑張ってコードを追っていただきたい。

スキップ処理はコメントが付いている条件式だけで行っている。
マウスがクリックされていて、描画タスク実行から一定時間が経過している場合はtextに入っている文字全てを一気に描画する。
実は、ここがこのゲームエンジンで一番こだわった部分である。
サンプルを動かすと分かるように、かなりストレスなくスキップ出来るはずだ(文章が短いから分からないかも)。

以上、非常にざっくりとした解説だったが、まあ頑張って作ったってことが分かってもらえれば。
総開発期間は1ヵ月強で毎日3時間は書いた。部活が休みの日(週に1回)は9時間ぐらい書いたと思う。
今考えると信じられない集中力だ。

机上の空論

このゲームエンジンを作っている最中に考えたことがある。
ここでは命令を中心に動作を管理しているが、もしその命令が各タスクに動作を指示するものだとしたら?

これはある意味、プロセス指向設計とかオブジェクト指向設計とかいう概念に関わってくる。
例えば、[cm]命令の動作は文字列描画タスクに従属するものだ。
ここではInterpreterクラスが命令を解釈して実行しているが、以下のようにしたいと考えた。

// 1つの命令を以下の形式で持つ
command = {target: Message, instruction: "cm", argv: []};

// 実行する場合は以下のような形
command.target.execute(command.instruction, command.argv);

なんかこっちの方が良い感じに見える(語彙力)。
実際、命令を全てInterpreterに持っておくよりも、タスクごとに分散させた方がコードリーディング的にも捗るだろう。

これは全て机上の空論ではあるが、何かしらのヒントにはなり得ると思うので書いた。

現状

このゲームエンジンの開発は完全にストップしている。
本当はこの記事に合わせて再開しようかと思ったのだが、他のカレンダーで立て込んでいて実現には至らなかった。
あと、あの時ほどゲームエンジン開発に対する情熱が燃えないというのもある。

もともとゲームエンジンの開発を仕事にしたいと思っていたのだが、今はゲーム開発から手を引いた感じになってしまっている。
趣味でやっているうちが一番楽しいような気がするし。

今後ゲームエンジンを作るようなことがあれば、それはきっとOSSプロジェクトだろう。
あるいは人生の転機となる出来事が起こるか。

いずれにせよ、今はゲーム開発に対するモチベーションはあまり無いのが現状。
まだ大学2年なので今後どう気持ちが変わるかは分からない。
けどもう3年になってしまうのか…。

最後に一言。
皆さんLispを書きましょう。

This post is the No.6 article of 岩手県立大学 Advent Calendar 2016