すごい勢いでモックを作るノウハウを共有しよう【Webゲーム開発】

この記事で伝えたいこと

Web上でゲーム作る際に、モックづくりで役立つ設定や注意点とかとかとか

つくったもの

f:id:s_gozaru:20171219060344g:plain

はじめに

皆さんゲーム作ってますか? クライアントワーク事業部のポロです。
最近はゲームっぽいWebサイトを作る日々を送っております。

ゲーム制作のような規模の大きい制作では、いきなり本制作に着手すると様々な要因で爆死する危険があります。
爆死の危険を回避するのに使えるのが、モック制作です。
本制作に着手する前に、確認したい機能やデザインを工数かけずにさくっと作ってしまいましょう。
(この記事では、モックとかプロトタイプとかの言葉を細かく使い分けません。ご了承を。)

モックづくりのノウハウはあまり言語化されていないことが多いので、
この記事を叩き台に、はてブなどでみなさんのノウハウやアドバイスやコメントなどもらえると励みになります。 このエントリーをはてなブックマークに追加

やったこと

  • package.json を設定して
  • 画面表示物つくって
  • ゲームの挙動を設定して
  • 新しく使うライブラリをAPI確認しながら導入して
  • 効果音探して
  • 適当にパラメタ調整して感想をもらう

作りはじめる際に注意していること

  • なにを確認するつもりで作るのか決めておく。
  • 割込みが起きないように周辺タスクを全て片付けておく。
  • 短い時間でつくる。
    • 1日に収まる規模で作るのが好きです(タイムアタック宣言すると楽しい)
  • 確認に不要な細かな部分に執着しないよう注意する。
  • いつ捨てるのか決めておく。
    • モックがそのまま本制作にならないように、事前にコードを捨てて書き直す旨を宣言しておきます。

package.json

タスクランナーのたぐいは仰々しいと思ってるみなさん、watchifyだけ使うとシンプルなのでオススメです。

{
  "name": "mock",
  "version": "1.0.0",
  "author": "",
  "license": "MIT",
  "scripts": {
    "start": "watchify ./src/app.js -o ./bundle.js -v"
  },
  "browserify": {
    "transform": [
      "babelify"
    ]
  },
  "babel": {
    "presets": [
      "es2015"
    ]
  },
  "devDependencies": {
    "babel-plugin-transform-runtime": "^6.23.0",
    "babel-preset-es2015": "^6.22.0",
    "babelify": "^7.3.0",
    "watchify": "^3.9.0"
  }
}

import export を使いたいだけなので、シンプルな構成です。
あとは Exponentiation operator を使いたかったら es2016 を、
Async Await 使いたかったら es2017 をといった具合に使いたい機能を随時追加します。

https://github.com/tc39/proposals/blob/master/finished-proposals.md

画面表示物をつくる

円形のゲージが、色を変えながら減っていくことだけ決まっていたので、工数書けずに作りました。

色については緑を128に固定しつつ、他の2つの平均を128にすると見ため綺麗でコスパが良いです。
色見本を付けておきます。 (iOS11っぽいですね)
f:id:s_gozaru:20171219061823p:plain

ゲームの挙動を設定

ここは手慣れているので、ごりごりっと作ります。
過去に作ったマウス or タッチ判定のラッパーなどを見直しつつ使いまわしていきます。

const eventList = {
  pointerstart: new Set(),
  pointermove: new Set(),
  pointerend: new Set(),
};

function initHandler(elm) {
  let isTouch = false;
  let didTouch = false;
  function onPointer(e, evtType, inputType) {
    if (didTouch && inputType === 'mouse') {
      return;
    }
    if (!didTouch && inputType === 'touch') {
      didTouch = true;
    }
    if (evtType === 'pointermove' && isTouch === false) {
      return;
    }
    const rect = e.target.getBoundingClientRect();
    const offsetX = (e.clientX - rect.left) * (elm.width / elm.clientWidth);
    const offsetY = (e.clientY - rect.top) * (elm.height / elm.clientHeight);
    if (evtType === 'pointerstart') { isTouch = true; }
    if (evtType === 'pointerend') { isTouch = false; }
    eventList[evtType].forEach((fn) => {
      fn(offsetX, offsetY);
    });
  }
  function touchHandler(evt) {
    evt.preventDefault();
    const evtType = (() => {
      switch (evt.type) {
        case 'touchstart':
          return 'pointerstart';
        case 'touchend':
          return 'pointerend';
        case 'touchmove':
          return 'pointermove';
        default:
          return null;
      }
    })();
    onPointer(evt.changedTouches[0], evtType, 'touch');
  }
  function mouseHandler(evt) {
    evt.preventDefault();
    const evtType = (() => {
      switch (evt.type) {
        case 'mousedown':
          return 'pointerstart';
        case 'mouseup':
          return 'pointerend';
        case 'mousemove':
          return 'pointermove';
        default:
          return null;
      }
    })();
    onPointer(evt, evtType, 'mouse');
  }
  elm.addEventListener('touchstart', touchHandler, { passive: false });
  elm.addEventListener('touchend', touchHandler, { passive: false });
  elm.addEventListener('touchmove', touchHandler, { passive: false });

  elm.addEventListener('mousedown', mouseHandler, { passive: false });
  elm.addEventListener('mouseup', mouseHandler, { passive: false });
  elm.addEventListener('mousemove', mouseHandler, { passive: false });
}
function addEventStart(evt) {
  eventList.pointerstart.add(evt);
}
function removeEventStart(evt) {
  eventList.pointerstart.delete(evt);
}

function addEventEnd(evt) {
  eventList.pointerend.add(evt);
}
function removeEventEnd(evt) {
  eventList.pointerend.delete(evt);
}

function addEventMove(evt) {
  eventList.pointermove.add(evt);
}
function removeEventMove(evt) {
  eventList.pointermove.delete(evt);
}

export default {
  initHandler,
  addEventStart,
  removeEventStart,
  addEventEnd,
  removeEventEnd,
  addEventMove,
  removeEventMove,
};

新しく使うライブラリの導入

今回は効果音の再生に howler.js を使うので、 その仕様確認と動作確認を行いました。
howler.js はドキュメントが見やすく過保護すぎない素直な挙動をします。みなさんもガンガン使いましょう。

f:id:s_gozaru:20171219065123p:plain

効果音探し

Googleで「適当なクエリ + 効果音」とかで探します。
同人制作クラスタ出身だと、サイト名を見ただけで商用利用やクレジットの可否が判定できるらしいですね。

利用した素材は利用元をファイルにまとめておくと便利です。
今回の場合は、seフォルダ内に以下のように記載しています。

drum02.mp3 ドラム系02 http://www.kurage-kosho.info/others.html
small-bell02.mp3 鈴02 http://www.kurage-kosho.info/others.html
cicada01.mp3 セミ01 http://www.kurage-kosho.info/nature.html

これをしとかないと、あとで権利関係を確認するときに大きな工数がかかります。

スワイプ操作音に蝉の鳴き声を選定できたのが個人的には最高にCoolだったと思っています。
くらげ工匠さん。素晴らしい効果音を自由に使わせていただき、ありがとうございます。)

パラメタ調整 & フィードバック

収穫フェイズです。
うごくものがあるので、 色んな人に見せてフィードバックをもらいます。
(以下の内容は記憶を頼りに書いてるので、正確な内容じゃないです。ご注意を)

  • 「押したと思ったのにMissが出るとストレスなんで、判定広めにしたほうがいいですよ」(音ゲーマー①)
  • 「スワイプのやつ。始点と終点を同時タップで取れました」(音ゲーマー①)
  • 「メータの色いいかんじ」(デザイナー)
  • 「シンクロニカかosuか」(音ゲーマー②)
  • 「いい感じ、あとはちゃんと譜面を作れば面白くなりそう」(ディレクター)

参考文献の情報や、おおまかな方針としてはブレがなさそうなことを確認できました。
また本実装の際に、当たり判定や譜面を柔軟に変更できるよう警戒できました。

個人的に重要な確認事項

あと個人的に重要な確認事項として以下2点があります。

  • 設計に無理がないか

    • 新規導入するライブラリはすんなり設計に収まってくれそうです。
    • updateの返り値で成功失敗消滅など返してみましたが、弱点が多かったので本実装では方針を変えました。
    • 本実装では非同期演出によるフェイズ進行が厄介そうなので、もう少し防御的にコードを組み直しました。
  • パラメタ調整次第で、誰にもクリア不可能と誰でもクリア可能に設定できる

    • 「調整次第でクリアできそうでできないラインに設定できる」ことを経験して確信しておくことで、ゲームの仕組み自体の面白さは疑わずにゲーム開発を進めることができます。

おわりに

いかがだったでしょうか。

使い捨て型プロトタイピングは、迅速さを失わない限り、利点が沢山あるのでどんどん導入していきましょう。
みなさんのモックづくりのノウハウもお待ちしております。
何かコメントなどありましたら、はてブなどから叩きつけてやってください。 このエントリーをはてなブックマークに追加
Web開発者なのでフィードバックをこよなく愛しています。

また、面白法人カヤックではブラウザゲーム開発が好きな方や、生JSを愛してやまない方を募集しております。 www.kayac.com www.kayac.com

明日は手がけたサイトは毎度素晴らしいクオリティを叩き出すふかぽんさんによる
「【WebGL】スプライトアニメーションさせるシェーダー」です。乞うご期待!

Google Test ことはじめ

こんにちは。技術部の小池です。

これは Tech KAYAC Advent Calendar 2017 17日目の記事です。

今回はGoogle製のC++テストフレームワークの Google Test を案件に導入した話をします。

経緯

私の所属している新規ゲーム案件ではサーバサイドをPerlとC++で開発しており、Perlは社内に知見がありメソッド単位でテストをがっつり書いており品質が保たれていましたがC++はメンバーの知見がなくテストも書かれていない状態が続いていました。

C++はバトルのパケットを中継するリレーサーバ的な役割を担っており、バトル部分が複雑化するに伴いC++サーバに徐々にバグが潜んでいる感じで大変困っておりました。その状況を打破するためにテストをちゃんとやって品質を上げていくぞ!ということで導入したのがGoogle Testです。

テストの書き方はJUnitライクな感じなのでxUnit系のテストフレームワークを使ったことある方ならとっつきやすいかな〜と思います。

Google Testのビルド

Google TestはLinux、Mac、Windowsどの環境でも使用できるテストフレームワークです。それぞれの環境でビルドできるよう各環境用にビルドファイルが用意されているので案件ごとの環境に適したやり方でビルドできるかと思います。

私の案件では導入時点で最新バージョンのv.1.8.0をCentOS 7上で CMake を使ってビルドしました。以下のコマンドでビルドできます。

$ curl -OL https://github.com/google/googletest/archive/release-1.8.0.tar.gz
$ tar xzf release-1.8.0.tar.gz
$ cd googletest-release-1.8.0
$ cmake .
$ make

ビルドが成功すると googlemock/gtest 以下に libgtest.alibgtest_main.a の2つのライブラリファイルが生成されます。ヘッダファイル群は googletest/include にあります。ライブラリとヘッダの場所が分かれていて分かりにくい感じはありますね〜。

テストを書いてみる

早速テストを書いてみます。ディレクトリ構成は以下のようにしました。

├── googletest
│   ├── include
│   │   └── gtest (中身はヘッダファイル群)
│   ├── libgtest.a
│   └── libgtest_main.a
├── src
│   └── example.cpp
└── test
    └── example_test.cpp

2つの引数の合計を返すプログラムとそのテストを書いてみます。

src/example.cpp

int sum(int x, int y) {
    return x + y;
}

test/example_test.cpp

#include <gtest/gtest.h>

#include "src/example.cpp"

TEST(example_test, func_sum) {
    ASSERT_EQ(3, sum(1, 2));
}

テストケースごとにTESTマクロを呼び出します。第一引数はテストケースの名前、第二引数は個々のテストの名前です。第一引数はフィクスチャクラスの名前を兼ねており、複数のテストで共通で使用するオブジェクトの生成などの処理がある場合にTESTマクロではなくTEST_Fマクロを呼ぶことでフィクスチャクラスに共通処理をまとめることができます。

テストはアサーションで行います。ここではsum関数の実行結果が期待した値になっていることをテストしています。アサーションの一覧は こちら を見ると良いでしょう。Google Test v.1.6のものですが、日本語版のページ もあります。

実行してみます。

$ g++ -std=c++11 test/example_test.cpp -I. -Isrc -Igoogletest/include -Lgoogletest -lgtest -lgtest_main -lpthread
$ ./a.out
Running main() from gtest_main.cc
[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from example_test
[ RUN      ] example_test.func_sum
[       OK ] example_test.func_sum (0 ms)
[----------] 1 test from example_test (1 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (2 ms total)
[  PASSED  ] 1 test.

無事にテストが通りました。

テストファイル内に自前でmain関数を書くこともできますが gtest_main にmain関数の実装があるので通常こちらを使う形で良いでしょう。私の案件ではビルド用のシェルスクリプトを書いてテスト時は gtest_main をリンクするようにしています。

また、Google Testはスレッドを使っているため pthread をリンクする必要があります。

Google Mockも使ってみる

通信や状態を含む処理や時間が関連する処理をテストする際は処理をモック化してテストをすることもあると思いますが、Google MockというGoogle Testに付属しているモックライブラリを使用することでモックオブジェクトを作ることができます。Google Testがビルドできていれば googlemock 以下に libgmock.alibgmock_main.a があるはずなのでこちらを使用します。ヘッダファイル群は googlemock/include 以下にあります。

先ほどの構成にGoogle Mockを追加して以下のようにしました。

├── googletest
│   ├── include
│   │   ├── gmock (中身はヘッダファイル群)
│   │   └── gtest (中身はヘッダファイル群)
│   ├── libgmock.a
│   ├── libgmock_main.a
│   ├── libgtest.a
│   └── libgtest_main.a
├── src
│   └── example.cpp
└── test
    └── example_test.cpp

Google Mockを使う際はまずモック化したいオブジェクトのインターフェースを作る必要があります。ここではゲームのルームにいるプレイヤー全員にハートビートを送るサンプルを考えてみます。この際playerのsession_idが0のときはplayerがルームから離脱している状態なので送信しないものとします。

#include <memory>
#include <vector>
#include <stdint.h>
#include <iostream>

struct Player {
    uint32_t player_id;
    uint32_t session_id;
};

class MyNetworkClientInterface {
public:
    virtual void heartbeat(uint32_t session_id) = 0;
};

class MyNetworkClient : public MyNetworkClientInterface {
public:
    void heartbeat(uint32_t session_id);
};

class Room {
public:
    std::vector<Player> players;
    MyNetworkClientInterface *client;

    void update() {
        for (auto &player : players) {
            if (player.session_id != 0) {
                client->heartbeat(player.session_id);
            }
        }
    }
};

MyNetworkClientInterface がアプリケーションクラスとモッククラスの共通のインターフェースで、実際に処理を行インターフェースを継承したアプリケーションのクラスが MyNetworkClient ですね。このハートビートを送るRoomクラスのupdateメソッドをテストする際は以下のようになります。

#include <gtest/gtest.h>
#include <gmock/gmock.h>

#include <memory>
#include <stdint.h>

#include "src/example.cpp"

class MockMyNetworkClient : public MyNetworkClientInterface {
public:
    MOCK_METHOD1(heartbeat, void(uint32_t session_id));
};

TEST(example_test, func_update) {
    MockMyNetworkClient client;

    auto room = std::unique_ptr<Room>(new Room());
    room->players.push_back(Player{1, 1234});
    room->players.push_back(Player{2, 0});
    room->client = &client;

    EXPECT_CALL(client, heartbeat(1234)).Times(1);
    EXPECT_CALL(client, heartbeat(0)).Times(0); // session_idが 0 なので呼ばれない

    room->update();
}

モック用の MockNetworkClient クラスを定義して使っています。このモッククラスはジェネレータで自動生成できます。モックのテストでは呼び出しの回数の判定や振る舞いの定義など一通りのことが可能で、ここでは呼び出し回数のテストをしています。

gmock と gmock_main を更にリンクさせテストを実行すると無事にテストが通ることが確認できます。

$ g++ -std=c++11 test/example_test.cpp -I. -Isrc -Igoogletest/include -Lgoogletest -lgtest -lgtest_main -lgmock -lgmock_main -lpthread
$ ./a.out
Running main() from gtest_main.cc
[==========] Running 2 tests from 1 test case.
[----------] Global test environment set-up.
[----------] 2 tests from example_test
[ RUN      ] example_test.func_sum
[       OK ] example_test.func_sum (0 ms)
[ RUN      ] example_test.func_update
[       OK ] example_test.func_update (1 ms)
[----------] 2 tests from example_test (1 ms total)

[----------] Global test environment tear-down
[==========] 2 tests from 1 test case ran. (1 ms total)
[  PASSED  ] 2 tests.

導入してどうだったか

すべてのクラスに対するテストが書かれているわけではないのですが、主要なクラスにはテストが書かれている状態となっています。C++サーバはゲームのルーム管理やパケット中継などを行うため状態管理が複雑でしたが、それぞれのテストケースごとにテストを書くことで品質は安定してきました。我々エンジニアの精神の均衡も保たれるようになり改めてテストは大事だな〜と思いました。

しかし通信処理の本体は静的ライブラリになっており、ライブラリから呼び出されるcallbackに処理を実装するというアーキテクチャになっているため、callback部分をテストするためにはcallbackクラスに全部インターフェースを噛ませてDI的なことをする必要がありさすがに大規模工事になるので静的ライブラリに関連するクラスのテストは諦めました。

それとモックはハマリポイントがちょくちょくあります。

  • モックオブジェクトがデストラクタで破棄される際にテストが評価されるためモックオブジェクトが破棄されないような作りになっているとエラーになる
  • 内部でコピーコンストラクタを使っているので unique_ptr を使っているとそのままモックできず一枚噛ませないといけない

などなど癖があり本格的に使う場合は根気強く向き合っていくことになりそうです。テスト時にカジュアルにスタブに置き換えるような仕組みがあればいいなーと思いました。

一方でメソッドレベルのテストでは見つからない、ゲームの連戦時のみ発生するような不具合もあり単体テストだけでなくバトルのマッチングからバトル終了までの一連のシナリオをテストするシナリオテストの仕組みが必要そうと感じてます。

さいごに

面白法人カヤックではC++でもPerlでもGoでもRubyでもなんでもばっちこいなエンジニアも募集しております!

明日18日目は s_gozaru さんで がおー です!お楽しみに!