GitLab CI/CDによるDevOpsとゲーム開発

WebGLやWebSocketの進歩により、Adobe Flashのようなプラグインを使用しなくても、ブラウザはゲーム開発プラットフォームとして非常に有効です。 さらに、GitLabとAWSを使用することで、一人のゲーム開発者やゲーム開発チームは、ブラウザベースのゲームをオンラインで簡単にホストすることができます。

このチュートリアルでは、GitLab CI/CDを使った継続的インテグレーション/デプロイメント手法によるゲームのテストとホスティング、DevOpsに焦点を当てます。 GitLab、JavaScript、ゲーム開発の基本に精通していることを前提としています。

ゲーム

私たちのデモゲームは、宇宙を旅するシンプルな宇宙船で構成されており、指定された方向にマウスをクリックして撮影します。

別のゲーム「Dark Nova」の開発開始時に強力なCI/CDパイプラインを作成することは、チームが速いペースで作業するために不可欠でした。 このチュートリアルでは、前回の紹介記事をベースに、以下のステップを説明します:

  1. 前の記事のコードを使用して、gulpファイルでビルドされた素のPhaserゲームから始めます。
  2. ユニットテストの追加と実行
  3. Bullet 、指定された方向にスポーンするトリガーとなるWeapon クラスの作成。
  4. この武器を使用し、画面内を動き回るPlayer クラスの追加。
  5. Player 、使用するスプライトを追加します。Weapon
  6. 継続的インテグレーションと継続的デプロイの手法によるテストとデプロイ

最終的には、master ブランチにプッシュするたびにテストされ、デプロイされる、プレイ可能なゲームのコアができあがります。また、以下のコンポーネントでブラウザベースのゲームを開始するための定型コードも提供されます:

  • TypeScriptと PhaserJsで書かれています。
  • Gulpを使ったビルド、実行、テスト
  • Chaiと Mochaによるユニットテスト
  • GitLabによるCI/CD
  • GitLab.com でのコードベースのホスティング
  • AWSでの試合開催
  • AWSへのデプロイ

要件とセットアップ

私の以前の記事DevOps and Game Devを参照して、基礎となる開発ツール、Hello World のようなゲームの実行、GitLab CI/CD を使ったこのゲームの構築について学んでください。このゲームのリポジトリのmaster ブランチには、すべての設定が完了したバージョンが含まれています。この記事に沿って作業を進めたい場合は、devops-article ブランチをクローンして作業することができます:

git clone git@gitlab.com:blitzgren/gitlab-game-demo.git
git checkout devops-article

次に、このWeapon クラスが通過すると予想される状態のほとんどを例証する、小さなテストのサブセットを作成します。手始めに、lib/testsというフォルダを作成し、新しいファイルweaponTests.tsに次のコードを追加します:

import { expect } from 'chai';
import { Weapon, BulletFactory } from '../lib/weapon';

describe('Weapon', () => {
    var subject: Weapon;
    var shotsFired: number = 0;
    // Mocked bullet factory
    var bulletFactory: BulletFactory = <BulletFactory>{
        generate: function(px, py, vx, vy, rot) {
            shotsFired++;
        }
    };
    var parent: any = { x: 0, y: 0 };

    beforeEach(() => {
        shotsFired = 0;
        subject = new Weapon(bulletFactory, parent, 0.25, 1);
    });

    it('should shoot if not in cooldown', () => {
        subject.trigger(true);
        subject.update(0.1);
        expect(shotsFired).to.equal(1);
    });

    it('should not shoot during cooldown', () => {
        subject.trigger(true);
        subject.update(0.1);
        subject.update(0.1);
        expect(shotsFired).to.equal(1);
    });

    it('should shoot after cooldown ends', () => {
        subject.trigger(true);
        subject.update(0.1);
        subject.update(0.3); // longer than timeout
        expect(shotsFired).to.equal(2);
    });

    it('should not shoot if not triggered', () => {
        subject.update(0.1);
        subject.update(0.1);
        expect(shotsFired).to.equal(0);
    });
});

gulpを使ってこれらのテストをビルドして実行するために、以下のgulp関数も既存のgulpfile.js

gulp.task('build-test', function () {
    return gulp.src('src/tests/**/*.ts', { read: false })
    .pipe(tap(function (file) {
      // replace file contents with browserify's bundle stream
      file.contents = browserify(file.path, { debug: true })
        .plugin(tsify, { project: "./tsconfig.test.json" })
        .bundle();
    }))
    .pipe(buffer())
    .pipe(sourcemaps.init({loadMaps: true}) )
    .pipe(gulp.dest('built/tests'));
});

gulp.task('run-test', function() {
    gulp.src(['./built/tests/**/*.ts']).pipe(mocha());
});

ゲームの最初の部分の実装を開始し、これらのWeapon テストに合格するようにします。この Weaponクラスは、指定された方向と速度で弾丸の生成をトリガーするメソッドを公開します。後で、武器をトリガーするためのユーザー入力を結びつけるPlayer クラスを実装します。src/lib フォルダに、weapon.ts ファイルを作成します。WeaponBulletFactory の 2 つのクラスを追加し、Phaser のスプライトとグループオブジェクト、およびゲーム固有のロジックをカプセル化します。

export class Weapon {
    private isTriggered: boolean = false;
    private currentTimer: number = 0;

    constructor(private bulletFactory: BulletFactory, private parent: Phaser.Sprite, private cooldown: number, private bulletSpeed: number) {
    }

    public trigger(on: boolean): void {
        this.isTriggered = on;
    }

    public update(delta: number): void {
        this.currentTimer -= delta;

        if (this.isTriggered && this.currentTimer <= 0) {
            this.shoot();
        }
    }

    private shoot(): void {
        // Reset timer
        this.currentTimer = this.cooldown;

        // Get velocity direction from player rotation
        var parentRotation = this.parent.rotation + Math.PI / 2;
        var velx = Math.cos(parentRotation);
        var vely = Math.sin(parentRotation);

        // Apply a small forward offset so bullet shoots from head of ship instead of the middle
        var posx = this.parent.x - velx * 10
        var posy = this.parent.y - vely * 10;

        this.bulletFactory.generate(posx, posy, -velx * this.bulletSpeed, -vely * this.bulletSpeed, this.parent.rotation);
    }
}

export class BulletFactory {

    constructor(private bullets: Phaser.Group, private poolSize: number) {
        // Set all the defaults for this BulletFactory's bullet object
        this.bullets.enableBody = true;
        this.bullets.physicsBodyType = Phaser.Physics.ARCADE;
        this.bullets.createMultiple(30, 'bullet');
        this.bullets.setAll('anchor.x', 0.5);
        this.bullets.setAll('anchor.y', 0.5);
        this.bullets.setAll('outOfBoundsKill', true);
        this.bullets.setAll('checkWorldBounds', true);
    }

    public generate(posx: number, posy: number, velx: number, vely: number, rot: number): Phaser.Sprite {
        // Pull a bullet from Phaser's Group pool
        var bullet = this.bullets.getFirstExists(false);

        // Set the few unique properties about this bullet: rotation, position, and velocity
        if (bullet) {
            bullet.reset(posx, posy);
            bullet.rotation = rot;
            bullet.body.velocity.x = velx;
            bullet.body.velocity.y = vely;
        }

        return bullet;
    }
}

最後に、エントリーポイントであるgame.tsを作り直し、PlayerWeapon の両オブジェクトを結びつけ、更新ループに追加します。更新されたgame.ts ファイルは以下のようになります:

import { Player } from "./player";
import { Weapon, BulletFactory } from "./weapon";

window.onload = function() {
    var game = new Phaser.Game(800, 600, Phaser.AUTO, 'gameCanvas', { preload: preload, create: create, update: update });
    var player: Player;
    var weapon: Weapon;

    // Import all assets prior to loading the game
    function preload () {
        game.load.image('player', 'assets/player.png');
        game.load.image('bullet', 'assets/bullet.png');
    }

    // Create all entities in the game, after Phaser loads
    function create () {
        // Create and position the player
        var playerSprite = game.add.sprite(400, 550, 'player');
        playerSprite.anchor.setTo(0.5);
        player = new Player(game.input, playerSprite, 150);

        var bulletFactory = new BulletFactory(game.add.group(), 30);
        weapon = new Weapon(bulletFactory, player.sprite, 0.25, 1000);

        player.loadWeapon(weapon);
    }

    // This function is called once every tick, default is 60fps
    function update() {
        var deltaSeconds = game.time.elapsedMS / 1000; // convert to seconds
        player.update(deltaSeconds);
        weapon.update(deltaSeconds);
    }
}

gulp serve を実行すれば、走り回って撮影することができます。 素晴らしい! CIパイプラインを更新して、既存のビルドジョブと一緒にテストを実行するようにしましょう。

継続的インテグレーション

私たちの変更がビルドを壊さず、すべてのテストがパスすることを確認するために、私たちは継続的インテグレーション(CI) を利用して、プッシュするたびにこれらのチェックを自動的に実行します。 この記事を読んで、継続的インテグレーション、継続的デリバリー、継続的デプロイ、そしてこれらの方法が GitLab によってどのように活用されるのかを理解しましょう。前回のチュートリアルで、私たちはすでにプッシュするたびにアプリをビルドするための.gitlab-ci.yml ファイルをセットアップしています。テスト用の新しい CI ジョブをセットアップする必要があり、GitLab CI/CD は gulp から生成されたアーティファクトを使ってビルドジョブの後に実行します。

CI/CD設定ファイルのドキュメントを読んで、その内容を調べ、あなたのニーズに合わせて調整してください。

GitLab CI/CDでゲームを構築しましょう。

テストも実行されるようにビルドジョブを更新する必要があります。既存のbuild ジョブのscript 配列の末尾にgulp build-testを追加します。 これらのコマンドが実行されると、GitLab CI/CD のartifactsによって指定されたbuilt フォルダ内のすべてにアクセスする必要があることがわかります。これらの依存関係を完全に再プルする必要がないように、node_modules もキャッシュします。build ジョブは以下のようになります:

build:
  stage: build
  script:
    - npm i gulp -g
    - npm i
    - gulp
    - gulp build-test
  cache:
    policy: push
    paths:
      - node_modules
  artifacts:
    paths:
      - built

GitLab CI/CDでゲームをテストしましょう

ローカルでテストするために、gulp run-testsを実行します。build のジョブのように gulp がグローバルにインストールされている必要があります。node_modules をキャッシュから引っ張ってくるので、npm iコマンドはあまり実行する必要がありません。デプロイの準備として、アーティファクトにbuilt フォルダが必要なことが分かっています。これは前のジョブからデフォルトの動作として引き継がれます。最後に、慣習として、build ジョブの後に実行する必要があることを GitLab CI/CD にtest ステージをtest与えることで知らせますtest 。YAML 構造に従うと、ジョブは test次のようになります:

test:
  stage: test
  script:
    - npm i gulp -g
    - npm i
    - gulp run-test
  cache:
    policy: push
    paths:
      - node_modules/
  artifacts:
    paths:
      - built/

指定した間隔でシュートを行うWeapon クラスのユニットテストを追加しました。Player クラスはWeapon を実装しており、動き回りながらシュートを行うことができます。また、GitLab の CI/CD パイプラインにテストアーティファクトとテストステージを追加しました。.gitlab-ci.yml, を使うことで、プッシュするたびにテストを実行できるようになります。 .gitlab-ci.ymlファイル.gitlab-ci.yml全体は .gitlab-ci.ymlこのようになります:

image: node:10

build:
  stage: build
  script:
    - npm i gulp -g
    - npm i
    - gulp
    - gulp build-test
  cache:
    policy: push
    paths:
      - node_modules/
  artifacts:
    paths:
      - built/

test:
  stage: test
  script:
    - npm i gulp -g
    - npm i
    - gulp run-test
  cache:
    policy: pull
    paths:
      - node_modules/
  artifacts:
    paths:
      - built/

CI/CDパイプラインの実行

これで完了です!新しいファイルをすべて追加してコミットし、プッシュします。 この時点でリポジトリがどのようになっているかは、この記事に関連する最終コミットを私のサンプルリポジトリで参照してください。 ビルドとテストの両方のステージを適用することで、GitLab はリポジトリへのプッシュごとにそれらを順次実行します。 すべてがうまくいけば、パイプラインの各ジョブに緑色のチェックマークが付きます:

Passing Pipeline

test のジョブをクリックしてビルドログを表示させれば、テストがパスしたことを確認できます。一番下までスクロールして、テストがパスしたことを確認してください:

$ gulp run-test
[18:37:24] Using gulpfile /builds/blitzgren/gitlab-game-demo/gulpfile.js
[18:37:24] Starting 'run-test'...
[18:37:24] Finished 'run-test' after 21 ms


  Weapon
    ✓ should shoot if not in cooldown
    ✓ should not shoot during cooldown
    ✓ should shoot after cooldown ends
    ✓ should not shoot if not triggered


  4 passing (18ms)

Uploading artifacts...
built/: found 17 matching files
Uploading artifacts to coordinator... ok            id=17095874 responseStatus=201 Created token=aaaaaaaa Job succeeded

継続的デプロイ

継続的デプロイメントで完全なパイプラインを完成させるために、AWS S3 を使って無料のウェブホスティングと、ビルドのアーティファクトをデプロイするジョブをセットアップしましょう。 GitLab にもGitLab Pagesという無料の静的サイトホスティングサービスがありますが、Dark Nova は特に他の AWS ツールを使うため、AWS S3を使う必要があります。S3 と GitLab Pages の両方へのデプロイについて説明し、この記事で説明したよりも GitLab CI/CD の原則についてさらに掘り下げたこの記事を読んでください。

S3バケットのセットアップ

  1. AWSアカウントにログインし、S3に移動します。
  2. 上部にある「バケットを作成」リンクをクリックします。
  3. 任意の名前を入力し、次へをクリックします。
  4. デフォルトのプロパティのまま、次へをクリックします。
  5. グループの権限を管理]をクリックし、[Everyone]グループの[読み取り]を許可し、[次へ]をクリックします。
  6. バケットを作成し、S3のバケットリストから選択します。
  7. 右側の「プロパティ」をクリックし、「静的ウェブサイトホスティング」カテゴリを有効にします。
  8. ラジオボタンをUse this bucket to host a websiteの選択に更新します。index.htmlerror.html をそれぞれ記入します。

AWSシークレットの設定

AWSアカウントの認証情報を使ってAWSにデプロイできるようにする必要がありますが、ソースコードにシークレットを入れたくないのは確かです。 幸いなことに、GitLabは変数を使ってこの解決策を提供しています。 これはIAM管理のために複雑になる可能性があります。 ベストプラクティスとして、rootのセキュリティ認証情報を使うべきではありません。 適切なIAM認証情報の管理はこの記事の範囲外ですが、AWSはroot認証情報を使うことは推奨されず、彼らのベストプラクティスに反することを思い出させてくれます。 ベストプラクティスに従って、同じ2つの認証情報(Key IDとSecret)になるカスタムIAMユーザーの認証情報を自由に使ってください。AWSのIAMベストプラクティスを完全に理解するのは良いアイデアです。 これらの認証情報をGitLabに追加する必要があります:

  1. AWSアカウントにログインし、セキュリティ資格情報のページに移動します。
  2. Access Keys」セクションをクリックし、「Create New Access Key」をクリックします。 キーを作成し、IDとシークレットを保管してください。

    AWS Access Key Configuration

  3. GitLabプロジェクトに移動し、左サイドバーのSettings >CI/CDをクリックします。
  4. 変数セクションの展開

    GitLab Secret Config

  5. AWS_KEY_ID という名前のキーを追加し、ステップ2のキーIDをValueフィールドにコピーします。
  6. AWS_KEY_SECRET という名前のキーを追加し、ステップ2のキーシークレットをフィールドにコピーします。

GitLab CI/CDでゲームをデプロイしましょう。

ビルド成果物をデプロイするために、Shared RunnerにAWS CLIをインストールする必要があります。 Shared Runnerは、成果物をデプロイするためにAWSアカウントで認証できる必要もあります。慣例では、AWS CLIはAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYを探します。GitLabのCIは、deploy ジョブのvariables の部分を使用して、前のセクションで設定した変数を渡す方法を提供してくれます。最後に、masterへのプッシュ時にデプロイonly が発生するようにディレクティブを追加します。この方法では、すべてのブランチがCIを通して実行され、masterへのマージ(または直接コミット)のみがパイプラインのdeploy ジョブをトリガーします。これらをまとめると、次のようになります:

deploy:
  stage: deploy
  variables:
    AWS_ACCESS_KEY_ID: "$AWS_KEY_ID"
    AWS_SECRET_ACCESS_KEY: "$AWS_KEY_SECRET"
  script:
    - apt-get update
    - apt-get install -y python3-dev python3-pip
    - easy_install3 -U pip
    - pip3 install --upgrade awscli
    - aws s3 sync ./built s3://gitlab-game-demo --region "us-east-1" --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers --cache-control "no-cache, no-store, must-revalidate" --delete
  only:
    - master

最後のスクリプトコマンドのリージョンとS3のURLは、あなたのセットアップに合わせて更新してください。 最終的な設定ファイル.gitlab-ci.yml は以下のようになります:

image: node:10

build:
  stage: build
  script:
    - npm i gulp -g
    - npm i
    - gulp
    - gulp build-test
  cache:
    policy: push
    paths:
      - node_modules/
  artifacts:
    paths:
      - built/

test:
  stage: test
  script:
    - npm i gulp -g
    - gulp run-test
  cache:
    policy: pull
    paths:
      - node_modules/
  artifacts:
    paths:
      - built/

deploy:
  stage: deploy
  variables:
    AWS_ACCESS_KEY_ID: "$AWS_KEY_ID"
    AWS_SECRET_ACCESS_KEY: "$AWS_KEY_SECRET"
  script:
    - apt-get update
    - apt-get install -y python3-dev python3-pip
    - easy_install3 -U pip
    - pip3 install --upgrade awscli
    - aws s3 sync ./built s3://gitlab-game-demo --region "us-east-1" --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers --cache-control "no-cache, no-store, must-revalidate" --delete
  only:
    - master

結論

デモリポジトリ内には、TypeScriptMochaGulpPhaserをGitLab CI/CDとうまく連携させるためのボイラープレートコードもあります。 フリーのオープンソースソフトウェアを組み合わせて使用することで、CI/CDパイプライン、ゲーム基盤、ユニットテストがすべて稼働し、驚くほど少ないコードでマスターへのプッシュごとにデプロイされます。 エラーはGitLabのビルドログで簡単にデバッグでき、コミットが成功すると数分以内にゲーム上で変更を確認できます。

Dark Novaで最初から継続的インテグレーションと継続的デプロイを設定することで、迅速かつ安定した開発が可能になります。 必要であれば、別の環境、または複数の環境で変更を簡単にテストすることができます。 マルチプレイヤーゲームのバランス調整とアップデートは継続的で退屈なものですが、GitLab CI/CDで安定したデプロイを信頼することで、変更を素早くプレイヤーに届けることができます。

その他の設定

ここでは、パイプラインのスピードアップや改善につながるアイデアをご紹介します:

  • npm の代わりにYarn
  • 依存関係やツール(AWS CLIなど)を事前にロードできるカスタムDockerイメージのセットアップ
  • カスタムドメインをゲームのS3静的ウェブサイトに転送します。
  • 小規模プロジェクトで不要と判断した場合は、ジョブを組み合わせてください。
  • キューを回避し、独自のGitLab CI/CDランナーを設定します。