LoginSignup
6
2

More than 5 years have passed since last update.

Grimoire.jsでゲーム作ったから技術的な内容を解説する

Last updated at Posted at 2017-01-23

GGJでゲーム作ったった

2017年のGGJ(1/22-1/20)でGrimoire.jsを使ったゲームを作成した。
ちなみにチーム構成は、Grimoire.jsの開発メンバー3名、Unity Technologies Japanの簗瀬さん、当日参加したプランナーの方2名、3Dモデラーの方が1名。

今年のテーマ「Wave」

今年のGGJのテーマは「Wave」、つまりである。とりあえず、発表を受けてその場で感じた感想は「波か...リアルな表現はWebGLでは無理だな。」ということだった。

まず、今回はGrimoire.jsを使うということで、Unityで表現するのは難しい内容を行いたかった。そこで、今回は全員で出たアイデアをいくつかまとめて、スクロールというWebで当たり前に行う行為自体が結果的にゲームになっているような表現を行うゲームを作成した。

実際のゲームはこんな感じだ。

実際のプレイ画像

概要

プレイヤーはカモメになって、海の表面を漂うアイテムを回収して雛に与える。
この際、減点アイテム(亀)をとったり、障害物(ヨット)にぶつかったりすると減点される。
また、海に入水してしまったり、ヨットにぶつかるとスクロールが自動で跳ね上がってしばらくユーザーの操作がブロックされる。

つまり、減点アイテムをできる限り取らず、水面ギリギリで飛行して、ポイントを稼いで雛をカモメに育て上げるというゲームだ。

HTMLでスクロールするための背景ができていて、スクロールの操作などもjQueryで行なっている。あえて、ゲームの中に通常のフロントエンドで使うようなものを取り込んだ形だ。

実際のプレイはこちら

http://ggj.grimoire.gl/home

  • モバイルで、レイアウトが崩れてしまう場合は、一度ピンチで縮小してから適度に拡大するといい感じになります。
  • IE/Edgeでは現在Grimoire.js自体のバグで動作しません
  • マウスのスクロール量によっては1スクロールが大きく、かくつくことがあります。

Grimoire.jsをゲームで使う

Grimoire.jsはもともとWebサービス用を目指して設計されている。必ずしもゲーム目的に設計されているライブラリではない。
しかし、実際にはそれでもゲームを作成することは十分に可能だ。

波っぽく動かす

波っぽく動かすために以下のようなWaveコンポーネントを作成した。

components.js
gr.registerComponent("Wave", {
    attributes: {
        yOffset: {
            converter: "Number",
            default: 0
        },
        smallWave: {
            converter: "Number",
            default: 1.0
        }
    },
    $mount: function () {
        this.transform = this.node.getComponent("Transform");
        this.initialY = this.transform.getAttribute("position").Y;
        this.getAttributeRaw("yOffset").boundTo("yOffset");
        this.getAttributeRaw("smallWave").boundTo("smallWave");
        this.random = Math.random() * 1000;
    },
    $update: function () {
        const p = this.transform.getAttribute("position");
        p.Y = waveMain(p.Z) + this.yOffset + this.smallWave * Math.sin(Date.now() / 1000. + this.random);
        this.transform.setAttribute("position", [p.X, p.Y, p.Z]);
    },
    $resetPosition: function () {
        var count = WAVES.length;
        var d = 1;
        var p = this.node.getAttribute("position");
        this.node.setAttribute("position", [p.X, p.Y, p.Z - count * d]);
    }
});

updateでは、waveMain関数で計算された波の高さにyOffsetを足して、smallWaveとtimeから計算したsin波をかけたものを座標として更新している。
smallWaveはトビウオの動きでは大きくして、リンゴなどは小さい値を指定することで波からの高さも時間に応じて少しランダムに動くようになっている。
ちなみにwaveMainはz座標からその点の波の高さを返す関数で、以下のように定義されている。

components.js
function waveMain(o) {
    var bigWaveParam = o / 1000 * Math.PI * 2;
    var bigWave = Math.sin(bigWaveParam);
    bigWave = bigWave * bigWave;
    bigWave = bigWave * bigWave;
    bigWave = bigWave * bigWave;
    bigWave = bigWave * bigWave;
    bigWave = bigWave * bigWave;
    bigWave = bigWave * bigWave * bigWave * bigWave * C.bigAmpl;
    // return bigWave

    var w1 = Math.sin(o / 57 * Math.PI);
    var w2 = Math.sin(o / 31 * Math.PI);
    var w3 = Math.sin(o / 17 * Math.PI);
    return (w1 + 0.6 * w2 + 0.8 * w3 + bigWave) * C.ampl;
}

素数による周期を持つ小さい波を掛け合わせた小さい周期の波と、時々局所的に大きな波が来るような調整がされた関数だ。

波で回転する

波を受けてりんごが同じ向きを浮いてるのは不自然なので回転するようなコンポーネントを作った。

components.js

gr.registerComponent("Rotate", {
    attributes: {
        axis: {
            default: '0, 1, 0',
            converter: 'Vector3'
        },
        speed: {
            default: 0.03,
            converter: 'Number'
        }
    },
    $mount: function () {
        this._transform = this.node.getComponent('Transform');
        this.getAttributeRaw("axis").boundTo("axis");
        this.getAttributeRaw("speed").boundTo("speed");
    },
    $update: function () {
        this._transform.localRotation = Quaternion.multiply(
            Quaternion.angleAxis(this.speed, this.axis), this._transform.localRotation);
    }
});

他にも、当たり判定とスコアの加算処理を行うItemコンポーネントカメラのスクロールによるマウスをコントロールするMouseControlコンポーネントなどを製作して追加した。

詳細はこちらのソースコードを参照してほしい。

ノードを定義して使う

このように、Unityに近い形でコンポーネントを作った後は以下のようにノードを定義して用いる。(ノードを定義しなくてもGOMLだけでもコンポーネントを用いることはできるが今回の場合はあまりお勧めではない。)

components.js
gr.registerNode("carrot", ["Wave", "Item", "Rotate"], {
    src: "./models/carrot.gltf",
    yOffset: 1,
    score: 10,
    sounds: "piyopiyo",
    axis: [0, -1, 0],
    hitY: 5
}, "model");

gr.registerNode("fish", ["Wave", "Item"], {
    src: "./models/fish.gltf",
    yOffset: 1.7,
    smallWave: 10,
    score: 50,
    sounds: "piyopiyo"
}, "model");

上記は人参とトビウオの例で、それぞれ<carrot><fish>で表示されるようにしている。

このregisterNodeの第二引数がコンポーネントのリストで、ここに先ほど定義したコンポーネントであるWaveがあるので波打つことになる。
第3引数は初期値だ。ノードであるので、タグからさらに値は上書きできるが、ここが指定されている場合はそのタグ固有の初期値を指定することができる。これがない場合はコンポーネント側で定義した属性の初期値が用いられる。

第3引数は継承元のノードである。<model>タグはgltfモデルファイルの読み込み用のプラグインにより定義されるタグで(実際には他のモデルデータも同じタグで読み込まれるようになる予定)、初期値やコンポーネントのリストを引き継いで定義することができる。これによってsrcにモデルファイルを指定すればモデルが表示できるのだ。

波を作る(擬似インスタンシング)

上記の例を見ていただければわかるかと思うが、波のためにそれなりの数の直方体を生成している。
一つの少しハイポリなモデルを何個か表示するのと、ものすごくローポリなモデルを大量に表示するのでは、実は少しハイポリなモデル何個かの方がいいことが多い。
GPU側へのメモリの転送処理は非常に負荷が高くなりがちであって、できる限り1つの描画命令で済ますべきなのだ。

そこで、今回は直方体をリサイズして大量に生成して波を作るということはやっていない。
横一列分のジオメトリをあらかじめ生成して、奥の方向へ100列強ほど描画している。

ジオメトリを生成して登録する

つまり、波のモデルを動的にプログラムで生成して登録する必要がある。
それぞれの頂点は以下の情報を持つ。(座標(vec3)法線(vec3)乱数のためのシード値(float))

頂点シェーダーで時系列に合わせて、それぞれの波が上下に少しずつランダムに動くようにしたいのでこのためのシード値を受け取るようにした。なんにしてもコードは以下の通りだ。

waveGeometry.js
const GeometryFactory = gr.lib.fundamental.Geometry.GeometryFactory;
const Geometry = gr.lib.fundamental.Geometry.Geometry;
const GeometryUtility = gr.lib.fundamental.Geometry.GeometryUtility;
GeometryFactory.addType("wave",{
  count:{
    converter:"Number",
    default:10
  },
  margin:{
    converter:"Number",
    default:1
  }
},(gl,attr)=>{
  const geo = new Geometry(gl);
  const verticies = new Float32Array(attr.count * 7 * 24);
  const indicies = new Uint16Array(attr.count * 36);
  let sizeSum = 0;
  for(let j =0; j < attr.count; j++){
    sizeSum += Math.abs(attr.count / 2.0 - j) + 1;
  }
//普通のCubeのジオメトリ1つを配列に格納する処理
  const wof = -((attr.count - 1) * attr.margin + sizeSum)/2;
  const bCube = [].concat.apply([], [
  GeometryUtility.plane([0, 0, 1], [0, 0, 1], [0, 1, 0], [1, 0, 0], 1, 1),
  GeometryUtility.plane([0, 0, -1], [0, 0, -1], [0, 1, 0], [-1, 0, 0], 1, 1),
  GeometryUtility.plane([0, 1, 0], [0, 1, 0], [0, 0, -1], [1, 0, 0], 1, 1),
  GeometryUtility.plane([0, -1, 0], [0, -1, 0], [0, 0, -1], [-1, 0, 0], 1, 1),
  GeometryUtility.plane([1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, -1], 1, 1),
  GeometryUtility.plane([-1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, 0, 1], 1, 1)]);
// 普通のCubeの頂点インデックスを一つの配列に格納する処理
  const bIndicies = [].concat.apply([], [
    GeometryUtility.planeIndex(0, 1, 1),
    GeometryUtility.planeIndex(4, 1, 1),
    GeometryUtility.planeIndex(2 * 4, 1, 1),
    GeometryUtility.planeIndex(3 * 4, 1, 1),
    GeometryUtility.planeIndex(4 * 4, 1, 1),
    GeometryUtility.planeIndex(5 * 4, 1, 1)]);
  for(let i = 0; i < attr.count; i++){
    const seed = Math.random();
    const size = Math.abs(attr.count / 2.0 - i) + 1;
    let sizeSum = 0;
    for(let j =0; j < i; j++){
      sizeSum += Math.abs(attr.count / 2.0 - j) + 1;
    }
    for(let j = 0; j < 24; j++){
        verticies[7 * 24 * i + j * 7 + 0] = bCube[8 * j + 0] * size  + attr.margin * i + sizeSum + wof; // 中心から離れているほど、少しずつXの長さは伸ばしておく
        verticies[7 * 24 * i + j * 7 + 1] = bCube[8 * j + 1]; // 座標Y
        verticies[7 * 24 * i + j * 7 + 2] = bCube[8 * j + 2]; // 座標Z
        verticies[7 * 24 * i + j * 7 + 3] = bCube[8 * j + 3]; // 法線X
        verticies[7 * 24 * i + j * 7 + 4] = bCube[8 * j + 4]; // 法線Y
        verticies[7 * 24 * i + j * 7 + 5] = bCube[8 * j + 5]; // 法線Z
        verticies[7 * 24 * i + j * 7 + 6] = seed; // 乱数のためのシード値(一つのCubeごと)
    }
    for(let j = 0; j < 36; j++){
      indicies[36 * i + j] = bIndicies[j] + 24 * i;
    }
}
  geo.addAttributes(verticies,{
    POSITION:{
      size:3,
      stride:28
    },
    NORMAL:{
      size:3,
      stride:28,
      offset:12
    },
    SEED:{
      size:1,
      stride:28,
      offset:24
    }
  }); // 頂点レイアウトの指定
  geo.addIndex("default",indicies);
  return geo;
});

こうすると、このジオメトリをGOML内で以下のようにして生成することができる。

index.goml
    <geometry type="wave" name="waveGeometry1" margin="0.1" count="30"/>

ここでのmargincountはwaveGeometry.jsの上の方で指定したジオメトリの生成時に受け取れる変数だ。
このようにしておくことでジオメトリの簡単なパラメーターによる調整はgomlファイルをいじるだけで可能になる。

こうしてしまえば、以下のようなmeshを書くことでジオメトリを描画することができるだろう。

index.goml
   <mesh geometry="waveGeometry1" color="red"/>

波用のマテリアルを作る

先ほど指定した乱数用のシードを使ってランダムになびくような頂点シェーダーを作って見栄えを良くすることにしよう。
Grimoire.jsのマテリアル記法であるSORTを利用して以下のように書く。(SORTについてはSORTマテリアル仕様を参考にしてほしい)

index.sort
@Technique default{
  @Pass{
    @BlendFunc(SRC_ALPHA,ONE_MINUS_SRC_ALPHA)
    FS_PREC(mediump,float)

    varying vec3 vNormal;

    varying float vDepth;

    #ifdef VS
      attribute vec3 position;
      attribute vec3 normal;
      @SEED
      attribute float seed;

      uniform mat4 _matPVM;

      uniform mat4 _matVM;

      uniform mat4 _matL;

      uniform float _time;

      float rand(vec2 i){
        float a = fract(dot(i, vec2(2.067390879775102, 12.451168662908249))) - 0.5;
        float s = a * (6.182785114200511 + a*a * (-38.026512460676566 + a*a * 53.392573080032137));
        float t = fract(s * 43758.5453);
        return t;
      }

      void main(){
        vec3 p = position;
        float z = (_matL * vec4(p,1)).z;
        p.y += rand(vec2(seed,z)) + sin(_time/100. + seed * 100. + z) * 0.2;
        gl_Position = _matPVM * vec4(p,1);
        vNormal = normalize((_matVM * vec4(normal,0)).xyz);
        vDepth = -(_matVM * vec4(position,1)).z;
      }
    #endif

    #ifdef FS
    @{type:"color",default:"#224483"}
    uniform vec3 color;

    @{default:"n(0.5,0.5,1)"}
    uniform vec3 light;

    @{default:2.0}
    uniform float brightness;

    @{default:100.0}
    uniform float depthMax;
      void main(){
        gl_FragColor.xyz = brightness * pow(max(dot(vNormal,light),0.) * color,vec3(1.3));
        gl_FragColor.w = 0.95 * pow(1.0-vDepth/depthMax,0.1);
      }
    #endif
  }
}

今回は半透明に描画したいので、@BlendFunc(SRC_ALPHA,ONE_MINUS_SRC_ALPHA)を記述してこのマテリアルを使用した時にブレンディングが有効になるようにする。

        vec3 p = position;
        float z = (_matL * vec4(p,1)).z;
        p.y += rand(vec2(seed,z)) + sin(_time/100. + seed * 100. + z) * 0.2;
        gl_Position = _matPVM * vec4(p,1);

ここでやってるのは、y座標を、シードとz座標から乱数を生成してそれを元にY座標を上下に動かして、結果として格納しているということだ。他にも、フラグメントシェーダーで深度フォグとランバートシェーディングをしたかったので以下のようにvarying変数に計算結果を格納した。

        vNormal = normalize((_matVM * vec4(normal,0)).xyz);
        vDepth = -(_matVM * vec4(position,1)).z;

フラグメントシェーダーで行なっているのは一般的なランバートシェーディングを少し改変したものと、FOGである。
明るさの調整のため、色は1.3乗したものを用いた。


        gl_FragColor.xyz = brightness * pow(max(dot(vNormal,light),0.) * color,vec3(1.3));
        gl_FragColor.w = 0.95 * pow(1.0-vDepth/depthMax,0.1);

これで、ランダムっぽい波がいい感じにリアルタイムに波打っている感じになっている。

ステージを生成する

タグはgomlに記述するだけでなく、動的に追加することができる。
jQueryのように、特定のタグの下に以下のようにappendすることもできる。

gr("キャンバスへのセレクタ")("タグへのセレクタ").append('<apple scale="2" position="100,0,0"/>');

しかし、上記の書き方は基本的な考え方に忠実でわかりやすいが、文字列に変換する処理が面倒でもあるので、代わりにaddChildByName関数を使った方が良いだろう。

gr("キャンバスへのセレクタ")("タグへのセレクタ").addChildByName("apple,{
   //適用する初期値の連想配列
   position:[x,y,z],
   id:"hello"
});

これを以下のようにループ中で作ってあげればその通りに作成することができる。

     const $$ = gr("#sea");
     const waveContainer = $$(".wave-container").get(0);
    //init waves
    for (let i = 0; i < 110; i++) {
       waveContainer.addChildByName("wave-cube", {
            position: `${Math.random()*3},0,-${i}`,
            color: "#0084cf",
            offset: i,
            id: "wave-" + i
        });
    }

まとめ

Grimoire.jsを使って、案外まともなゲームを作成することは十分にできた。CSS力の足りている人が使えばもっと効果的な扱い方が色々できそうだ。Webでの開発に最適な形とは何か、常に問いかけつつ行なっていきたいところだ。

特に、まだまだマイナーなフレームワークを使ってゲームを作ることにご協力いただいた当日にチームに加わっていただいた、様々な意見、貴重な時間を割いて庶務をしていただいたプランナーの方々、すばらしくて可愛いローポリモデルを作成していただいた3Dモデラーの方には厚く感謝させていただきたい。

気になった方は是非公式サイトへ!

6
2
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
6
2