LoginSignup
15
10

More than 3 years have passed since last update.

クリスマスはIonicでゲームを作ろうぜ!

Last updated at Posted at 2019-12-22

この記事はIonic Advent Calendar 2019 の23日目の記事です。

やあみんな!!年末をエンジョイしているかい!? 
え?クリスマスが近いのに予定がなくて寂しい?
HAHAHA!そうだな( ;∀;)
そんな時はゲームでも作ってクリスマスを盛り上げチャオーぜ!!

ということでゲーム作りです。
Ionicは、AngularやReactをサポートしたクロスプラットフォームな開発環境を提供しています。すなわち、Ionicを用いることで、クロスプラットフォームなゲームを簡単に作ることが出来ます!
Ionicについて興味のある方、学びたい方にはこちらの本をお勧めします。

Ionicで作る モバイルアプリ制作入門[Angular版]<Web/iPhone/Android対応>

今回は、IonicによるPWAなゲーム作りに挑戦したいと思います。
作成するゲームはブロック崩しです。
ゲームの完成イメージは次の通りです。
image.png

背景画像は、こちらのサイトから拝借しました。

ゲームの内容は、Pythonでつくる ゲーム開発 入門講座を参考にしています1

完成版のソースコードはこちらのGitHubリポジトリにあります。
また、完成したゲームはこちらで公開しています。

Ionic start

では早速作って行きましょう!!
まずはIonicのプロジェクトionic-breakoutを作成します。
Ionicのインストールがまだの方は、こちらの記事を参考にしてください2

任意のディレクトリで次のコマンドを実行します。

$ ionic start ionic-breakout blank --type=angular

無事プロジェクトが出来ましたか?
ではここで一度画面を起動してみましょう

ionic-breakoutディレクトリに移動し、次のコマンドを実行してください。

$ cd ionic-breakout
$ npm start

下記URLにブラウザでアクセスし、次の画面が表示されれば正常です。

http://localhost:4200

これ以降は、ionic-breakoutディレクトリでコマンドを実行してください。

参考までに、僕の環境を次に示します。
Ionicのバージョンが異なる場合、以降正常に動作しない可能性があるのでご注意ください。

$ ionic info

Ionic:

   Ionic CLI                     : 5.4.13 (/Users/s_kozake/.nvm/versions/node/v12.13.1/lib/node_modules/ionic)
   Ionic Framework               : @ionic/angular 4.11.7
   @angular-devkit/build-angular : 0.801.3
   @angular-devkit/schematics    : 8.1.3
   @angular/cli                  : 8.1.3
   @ionic/angular-toolkit        : 2.1.1
      :

PixiJSのインストール

次にPixiJSをインストールします。
PixiJSはWebGLを用いた2Dレンダリングエンジンです。
PixiJSを用いることで、Web上でサクサク動作する2Dゲームを作れます!
ご存知の方もいるかも知れませんが、RPGツクールMVという製品にもこのライブラリが用いられています。

次のコマンドを実行してPixiJSをインストールしてください。

npm install pixi.js

ブロック崩し画面の作成

次にIonic CLIを用いてゲーム画面であるBreakoutページを作成します。
次のコマンドを実行してください。

ionic generate page Breakout

最初に表示される画面をゲーム画面とするよう、次の修正を加えます。

src/app/app-routing.module.ts
const routes: Routes = [
  { path: '', redirectTo: 'breakout', pathMatch: 'full' },
       :

ブロック崩しの画面を準備していきます。breakout.page.htmlを次のように編集してください。#screenがゲーム画面のスクリーンとなります。

src/app/breakout/breakout.page.html
<ion-content>
  <div class="main">
    <div class="screen" #screen></div>
  </div>
</ion-content>

スタイルシートを修正します。breakout.page.scssに次の記述を追加してください。ディスプレイ全体をゲーム画面とし、中央にスクリーンを配置して背景色を黒色とします。

src/app/breakout/breakout.page.scss
.main {
    text-align: center;
    width: 100%;
    height: 100%;
    background-color: black;
}

.screen {
    width: 100%;
    height: 100%;
}

画面の雛形作成

以降の変更はbreakout.page.tsに対して実施していきます。

まずは画面サイズ。
横幅600ピクセル、縦幅1000ピクセルとして定義します。

src/app/breakout/breakout.page.ts
// スクリーン横幅
const SCREEN_WIDTH = 600;
// スクリーン縦幅
const SCREEN_HEIGHT = 1000;

ngOnInit()メソッドで、PIXI.Applicationを生成します。その際に注意することは、Angular Zoneの外でPIXI.Applicationを生成することです。PixiJS内部では、window.requestAnimationFrame()メソッドを用いてアニメーションフレーム処理を実現します。Angular Zone内で実行してしまうと、フレーム処理が行われるたびに変更検知が動作してしまい、レスポンス低下を招く恐れがあります。

src/app/breakout/breakout.page.ts
  @ViewChild('screen', { static: true })
  screen: ElementRef;

  app: PIXI.Application = null;

  constructor(private zone: NgZone) {}

  ngOnInit() {
    // フレーム処理ごとに変更検知を起こさないよう、Pixi.jsはAngularのZone処理外で実施
    this.zone.runOutsideAngular(() => {
      this.app = new PIXI.Application({
        backgroundColor: 0x1099bb,
        width: SCREEN_WIDTH,
        height: SCREEN_HEIGHT
      });
      this.screen.nativeElement.appendChild(this.app.view);
      this.app.ticker.add(delta => {
        this.onFrame(delta);
      });
    });
  }

  onFrame(delta: number) {}

ブラウザのサイズに応じたリサイズ処理を組み込みます。ここでは、変更検知のタイミングでリサイズすることとします。リザイズ処理では、

  • 実画面の幅 ÷ SCREEN_WIDTH
  • 実画面の高さ ÷ SCREEN_HEIGHT

のどちらか小さい方をスケールサイズとして、スクリーンサイズを再計算します。

src/app/breakout/breakout.page.ts
  // 画面のスケール率
  scale: number;
       :
  ngAfterViewChecked() {
    if (this.app != null) {
      this.resize();
    }
  }

  /**
   * 変更検知の際に画面のスケール率を再計算する.
   */
  resize() {
    const ratio = Math.min(
      this.screen.nativeElement.clientWidth / SCREEN_WIDTH,
      this.screen.nativeElement.clientHeight / SCREEN_HEIGHT
    );
    if (ratio > 0) {
      this.scale = ratio;
      this.app.renderer.resize(
        SCREEN_WIDTH * this.scale,
        SCREEN_HEIGHT * this.scale
      );
      this.app.stage.scale.x = this.app.stage.scale.y = this.scale;
    }
  }

変更後のbreakout.page.tsのソースコードはこちらとなります。

では、アプリを起動して動作を確認しましょう!

青色の部分がゲーム画面のスクリーンです。ブラウザの縮小・拡大に応じてスクリーンサイズが変われば正常に動作しています。

背景画像・ラケット・ボール・ブロックの作成

では、背景画像にラケットやボール、ブロックを作成して配置していきましょう!
これらはSpriteと呼ばれるオブジェクトで扱います。

まずは背景画像の作成。背景画像のサイズはスクリーンサイズに合わせます。
assets/imageディレクトリに、こちらのサイトから拝借した画像を配置します。画像はお好みで変更いただいても構いません。

src/app/breakout/breakout.page.ts
  /**
   * 背景作成
   */
  private createBackground(): PIXI.Sprite {
    const t = PIXI.Texture.from('assets/image/ch-tree.jpg');
    const background = new PIXI.Sprite(t);
    background.width = SCREEN_WIDTH;
    background.height = SCREEN_HEIGHT;
    return background;
  }

次にラケット。ラケットはdrawRect()メソッドを用いて横幅100px、縦幅20pxの長方形で表現します。

src/app/breakout/breakout.page.ts
  // ラケット
  racket: PIXI.Sprite;
       :
  /**
   * ラケット作成
   */
  private createRacket(): PIXI.Sprite {
    const g = new PIXI.Graphics();
    // beginFill(塗りつぶしの色, 塗りつぶしのアルファ値)
    g.beginFill(0xffd700, 1);
    // drawRect(左上のX座標, 左上のY座標, 幅, 高さ)
    g.drawRect(0, 0, 100, 20);
    g.endFill();

    const t = PIXI.RenderTexture.create({ width: g.width, height: g.height });

    this.app.renderer.render(g, t);
    return new PIXI.Sprite(t);
  }

ボールの作成。ボールはdrawCircle()メソッドを用いて半径10pxの円で表現します。

src/app/breakout/breakout.page.ts
  // ボール
  ball: PIXI.Sprite;
       :
  /**
   * ボール作成
   */
  private createBall(): PIXI.Sprite {
    const g = new PIXI.Graphics();
    // beginFill(塗りつぶしの色, 塗りつぶしのアルファ値)
    g.beginFill(0xffffff, 1);
    // drawCircle(円の中心のX座標, 円の中心のY座標, 円の半径)
    g.drawCircle(10, 10, 10);
    g.endFill();

    const t = PIXI.RenderTexture.create({ width: g.width, height: g.height });

    this.app.renderer.render(g, t);
    return new PIXI.Sprite(t);
  }

最後にブロック。ラケットと同じくdrawRect()メソッドを用いて作成します。

src/app/breakout/breakout.page.ts
  // ブロック
  blocks: PIXI.Sprite[];
       :
  /**
   * ブロック作成
   */
  private createBlock(row: number, col: number): PIXI.Sprite {
    const g = new PIXI.Graphics();
    // beginFill(塗りつぶしの色, 塗りつぶしのアルファ値)
    g.beginFill(0xffffff, 1);
    // drawRect(左上のX座標, 左上のY座標, 幅, 高さ)
    g.drawRect(0, 0, 85, 25);
    g.endFill();

    const t = PIXI.RenderTexture.create({ width: g.width, height: g.height });

    this.app.renderer.render(g, t);
    return new PIXI.Sprite(t);
  }

では、作成したSpriteを配置していきます。
画面スクリーンにはSpriteを直接配置せず、ゲームのステージとなるContainerオブジェクトを作成し、そこに配置していきます。こうする事で、後々のゲームクリアやゲームオーバー後の再初期処理が楽になります。

src/app/breakout/breakout.page.ts
  // ゲームステージ
  gameStage: PIXI.Container;
       :
  ngOnInit() {
       :
      this.screen.nativeElement.appendChild(this.app.view);
      this.onInit();
       :
  }

  onInit() {
    this.gameStage = new PIXI.Container();

    // 背景作成
    this.gameStage.addChild(this.createBackground());

    // ラケット作成
    this.racket = this.createRacket();
    this.racket.x = SCREEN_WIDTH / 2 - this.racket.width / 2;
    this.racket.y = 850;
    this.gameStage.addChild(this.racket);

    // ボール作成
    this.ball = this.createBall();
    this.ball.x = SCREEN_WIDTH / 2 - this.ball.width / 2;
    this.ball.y = this.racket.y - this.ball.height;
    this.gameStage.addChild(this.ball);

    // ブロック作成
    this.blocks = [];
    for (let row = 0; row < 15; row++) {
      for (let col = 0; col < 6; col++) {
        const block = this.createBlock(row, col);
        block.x = col * (block.width + 5) + 30;
        block.y = row * (block.height + 5) + 100;
        this.blocks.push(block);
        this.gameStage.addChild(block);
      }
    }

    this.app.stage.addChild(this.gameStage);
  }

ここまでのbreakout.page.ts全体のソースコードがこちらとなります。

では、アプリを起動して動作を確認しましょう!

無事ラケットやボール、ブロックがスクリーンに表示されましたか?まだ動きませんが、完成画面に近づいた気がしますね!

ラケットの操作

では、画面タッチでラケットが動くよう処理を追加していきましょう!
まずは3つの変数を用意します。

  • touchDownCount
  • lastTouchDownX
  • racketTargetX

touchDownCountはタッチされた回数です。複数の指で画面タッチされた場合でも正常に動作するよう、タッチ回数をカウントしています。
lastTouchDownXは最後にタッチしたX座標を保持します。
racketTargetXは、ラケットの目標X座標となります。後述しますが、ラケットはすぐにX座標まで移動せず、アニメーションしながら目標となるX座標に到達する仕様とします。

src/app/breakout/breakout.page.ts
  // タッチ回数
  touchDownCount: number;

  // 最後にタッチしたX座標
  lastTouchDownX: number;

  // ラケットの目指すX座標
  racketTargetX: number;

次に、画面タッチに対するイベント処理を定義します。
その際、gameStage.interactiveを忘れずtrueに設定してください。これにより、タッチ、ポインター、およびマウスイベントが有効となります。

src/app/breakout/breakout.page.ts
  onInit() {
       :
    this.touchDownCount = 0;
    this.lastTouchDownX = 0;
    this.racketTargetX = this.racket.x;

    // タッチ、ポインター、およびマウスイベントの有効化
    this.gameStage.interactive = true;
    this.gameStage
      // タッチダウン
      .on('pointerdown', (event: PIXI.interaction.InteractionEvent) =>
        this.onTouchStart(event)
      )
      // タッチ終了
      .on('pointerup', (event: PIXI.interaction.InteractionEvent) =>
        this.onTouchEnd(event)
      )
      .on('pointerupoutside', (event: PIXI.interaction.InteractionEvent) =>
        this.onTouchEnd(event)
      )
      // スワイプ
      .on('pointermove', (event: PIXI.interaction.InteractionEvent) =>
        this.onTouchMove(event)
      );
       :
  }

タッチ開始時は、タッチ回数のカウント、初回タッチ時のX座標の保存、そしてラケットの現在位置をラケットの目標X座標として設定します。

src/app/breakout/breakout.page.ts
  /**
   * タッチ開始
   * @param event イベント
   */
  private onTouchStart(event: PIXI.interaction.InteractionEvent) {
    this.touchDownCount++;
    if (this.touchDownCount === 1) {
      this.lastTouchDownX = event.data.global.x;
      this.racketTargetX = this.racket.x;
    }
  }

タッチ終了時は、タッチ回数をデクリメントします。
念の為、タッチ回数が0以下の場合に0クリアしています。

src/app/breakout/breakout.page.ts
  /**
   * タッチ終了
   * @param event イベント
   */
  private onTouchEnd(event: PIXI.interaction.InteractionEvent) {
    this.touchDownCount--;
    if (this.touchDownCount <= 0) {
      this.touchDownCount = 0;
    }
  }

スワイプ時の動作は次のとおりです。
2本指の操作はサポートしないこととし、タッチ回数が1以外の場合は処理しません。
現在のX座標と前回のX座標の差を距離(distance)として求めます。タッチイベントのX座標はディスプレイの実座標となるため、スケール値で割り算した結果をスクリーン上の距離とします。
ラケットの目標X座標に距離を足し、画面からはみ出ないよう調整します。

src/app/breakout/breakout.page.ts
  /**
   * スワイプ
   * @param event イベント
   */
  private onTouchMove(event: PIXI.interaction.InteractionEvent) {
    if (this.touchDownCount !== 1) {
      return;
    }
    const x = event.data.global.x;
    const distance = x - this.lastTouchDownX;

    this.racketTargetX += distance / this.scale;
    this.racketTargetX = Math.max(this.racketTargetX, 0);
    this.racketTargetX = Math.min(
      this.racketTargetX,
      SCREEN_WIDTH - this.racket.width
    );

    this.lastTouchDownX = x;
  }

最後にフレーム処理でラケットを動かします!
ラケットは、目標X座標までの距離を10で割った分だけ1フレームで進みます。こうすることで、ラケットの動作が滑らかなものとなります。
また、ボールはラケットに追随する形にしておきます。

src/app/breakout/breakout.page.ts
  onFrame(delta: number) {
    this.moveRacket();
    this.ball.x = this.racket.x + this.racket.width / 2 - this.ball.width / 2;
  }

  private moveRacket() {
    const add = (this.racketTargetX - this.racket.x) / 10;
    if (Math.abs(add) < 0.01) {
      this.racket.x = this.racketTargetX;
    } else {
      this.racket.x += add;
    }
  }

ここまでのbreakout.page.ts全体のソースコードがこちらとなります。

では、アプリを起動して動作を確認しましょう!

画面タッチでラケットがスムーズに動きましたか?
無事に動けば正常です。

ボールでブロック崩し

では、いよいよボールを動かしてブロックを崩しましょう!
ボールを動かすためとゲーム開始のための変数を追加します。

  • ballDx
  • ballDx
  • isGameStart

ballDxballDxは、ボールの移動方向と距離を格納する変数です。
isGameStartは、ゲームが開始したかどうかのフラグです。最初はもちろんfalseを設定しておきます。

src/app/breakout/breakout.page.ts
  // ボールのX方向距離
  ballDx: number;

  // ボールのY方向距離
  ballDy: number;

  // ゲームが開始したかどうか
  isGameStart: boolean;
       :
  onInit() {
       :
    this.ballDx = 5;
    this.ballDy = -5;
    this.isGameStart = false;
       :
  }

では、実際にゲームをスタートさせましょう!
最初のタッチで指が離れたタイミングをゲームスタートとします。こうすることで、ラケットの初期位置を操作できます。

src/app/breakout/breakout.page.ts
  /**
   * タッチ終了
   * @param event イベント
   */
  private onTouchEnd(event: PIXI.interaction.InteractionEvent) {
    this.touchDownCount--;
    if (this.touchDownCount <= 0) {
      this.isGameStart = true;
      this.touchDownCount = 0;
    }
  }

  onFrame(delta: number) {
    this.moveRacket();
    if (!this.isGameStart) {
      this.ball.x = this.racket.x + this.racket.width / 2 - this.ball.width / 2;
      return;
    }
       :

簡単にするために、今回はボールを四角と見立てた衝突判定を行います。
衝突判定のソースコードはこちらとなります。

src/app/breakout/breakout.page.ts
  private isHitBlock(block: PIXI.Sprite) {
    return (
      this.ball.x <= block.x + block.width &&
      this.ball.x + this.ball.width >= block.x &&
      this.ball.y <= block.y + block.height &&
      this.ball.y + this.ball.height >= block.y
    );
  }

では、フレーム処理内でボールを動かしましょう!
ボールは1フレームでballDxballDyの距離だけ進みます。ボールを動かした後に、ブロックやラケット、壁との衝突判定を行い、衝突した場合は進む方向を反転させます。ブロックにぶつかった場合はそのブロックを消去します。ラケットとの衝突判定もブロックと同様です。

src/app/breakout/breakout.page.ts
  onFrame(delta: number) {
    this.moveRacket();
    if (!this.isGameStart) {
      this.ball.x = this.racket.x + this.racket.width / 2 - this.ball.width / 2;
      return;
    }
    this.ball.y += this.ballDy;

    for (let i = 0; i < this.blocks.length; i++) {
      const block = this.blocks[i];
      // ブロックと衝突した?
      if (this.isHitBlock(block)) {
        this.gameStage.removeChild(block);
        this.blocks.splice(i, 1);
        // ブロックとの衝突位置にずらす
        this.ball.y =
          this.ballDy > 0
            ? block.y - this.ball.height - 1
            : block.y + block.height + 1;
        this.ballDy *= -1;
        break;
      }
    }

    // ラケットと衝突した?
    if (this.isHitBlock(this.racket)) {
      // ラケットとの衝突位置にずらす
      this.ball.y =
        this.ballDy > 0
          ? this.racket.y - this.ball.height - 1
          : this.racket.y + this.racket.height + 1;
      this.ballDy *= -1;
    }

    this.ball.x += this.ballDx;

    for (let i = 0; i < this.blocks.length; i++) {
      const block = this.blocks[i];
      // ブロックと衝突した?
      if (this.isHitBlock(block)) {
        this.gameStage.removeChild(block);
        this.blocks.splice(i, 1);
        // ブロックとの衝突位置にずらす
        this.ball.x =
          this.ballDx > 0
            ? block.x - this.ball.width - 1
            : block.x + block.width + 1;
        this.ballDx *= -1;
        break;
      }
    }

    // ラケットと衝突した?
    if (this.isHitBlock(this.racket)) {
      this.ball.x =
        // ラケットとの衝突位置にずらす
        this.ballDx > 0
          ? this.racket.x - this.ball.width - 1
          : this.racket.x + this.racket.width + 1;
      this.ballDx *= -1;
    }

    // 左の壁に衝突した?
    if (this.ball.x <= 0) {
      this.ball.x = 0;
      this.ballDx *= -1;
    }
    // 右の壁に衝突した?
    if (this.ball.x >= SCREEN_WIDTH - this.ball.width) {
      this.ball.x = SCREEN_WIDTH - this.ball.width;
      this.ballDx *= -1;
    }
    // 上の壁に衝突した?
    if (this.ball.y <= 0) {
      this.ball.y = 0;
      this.ballDy *= -1;
    }
  }

ここまでのbreakout.page.ts全体のソースコードがこちらとなります。

では、動作確認しましょう!

ボールでブロックを消せましたか?
では、いよいよ最後です。
ゲームクリアとゲームオーバー処理を組み込みましょう!

ゲームクリアとゲームオーバー

ゲームクリアとゲームオーバー、そしてリスタートを管理するフラグを用意します。

src/app/breakout/breakout.page.ts
  // ゲームをクリアしたかどうか
  isGameClear: boolean;

  // ゲームをゲームオーバーしたかどうか
  isGameOver: boolean;

  // リスタートフラグ
  restart: boolean;

  onInit() {
       :
    this.isGameClear = false;
    this.isGameOver = false;
    this.restart = false;
       :
  }

ゲームクリア時とゲームオーバー時の判定処理をフレーム処理に追加します。
ブロックが全てなくなるとゲームクリア、ボールが下に落ちるとゲームオーバーとなります。
ゲームクリア時は「GAME CLEAR!!」、ゲームオーバー時は「GAME OVER」というテキストを画面中央に表示し、ゲームを停止します。

src/app/breakout/breakout.page.ts
  onFrame(delta: number) {
    if (this.isGameOver || this.isGameClear) {
      return;
    }
       :
    // 下に落ちた?
    if (this.ball.y > SCREEN_HEIGHT) {
      this.gameOver();
      return;
    }

    // 全てのブロックを消した?
    if (this.blocks.length === 0) {
      this.gameClear();
      return;
    }
  }

  private gameClear() {
    this.isGameClear = true;
    const gameclearText = this.createText('GAME CLEAR!!');
    this.gameStage.addChild(gameclearText);
  }

  private gameOver() {
    this.isGameOver = true;

    const gameOverText = this.createText('GAME OVER');
    this.gameStage.addChild(gameOverText);
  }

  private createText(text: string): PIXI.Text {
    const gameClearText = new PIXI.Text(
      text,
      new PIXI.TextStyle({
        fontFamily: 'Arial',
        fontSize: 72,
        fill: ['#00ffff']
      })
    );
    gameClearText.x = SCREEN_WIDTH / 2 - gameClearText.width / 2;
    gameClearText.y = (SCREEN_HEIGHT / 10) * 4;

    return gameClearText;
  }

最後にリスタート時の処理です。
ゲームクリア、ゲームオーバー後の最初のタッチでリスタートフラグを立て、タッチ終了時にgameStageを削除した後に初期処理であるonInit()メソッドを呼び出します。
こうすることで、ゲームが初期状態に戻ります。

src/app/breakout/breakout.page.ts
  /**
   * タッチ開始
   * @param event イベント
   */
  private onTouchStart(event: PIXI.interaction.InteractionEvent) {
    this.touchDownCount++;
    if (this.touchDownCount === 1) {
      this.lastTouchDownX = event.data.global.x;
      this.racketTargetX = this.racket.x;
      if (this.isGameClear || this.isGameOver) {
        this.restart = true;
      }
    }
  }

  /**
   * タッチ終了
   * @param event イベント
   */
  private onTouchEnd(event: PIXI.interaction.InteractionEvent) {
    this.touchDownCount--;
    if (this.touchDownCount <= 0) {
      this.isGameStart = true;
      if (this.restart) {
        this.app.stage.removeChild(this.gameStage);
        this.onInit();
      }
      this.touchDownCount = 0;
    }
  }

これでゲーム完成です!!
breakout.page.ts全体のソースコードがこちらとなります。

src/app/breakout/breakout.page.ts
import {
  Component,
  OnInit,
  ViewChild,
  NgZone,
  ElementRef,
  AfterViewChecked
} from '@angular/core';
import * as PIXI from 'pixi.js';

// スクリーン横幅
const SCREEN_WIDTH = 600;
// スクリーン縦幅
const SCREEN_HEIGHT = 1000;

@Component({
  selector: 'app-breakout',
  templateUrl: './breakout.page.html',
  styleUrls: ['./breakout.page.scss']
})
export class BreakoutPage implements OnInit, AfterViewChecked {
  @ViewChild('screen', { static: true })
  screen: ElementRef;

  app: PIXI.Application = null;

  // 画面のスケール率
  scale: number;

  // ラケット
  racket: PIXI.Sprite;

  // ボール
  ball: PIXI.Sprite;

  // ブロック
  blocks: PIXI.Sprite[];

  // ゲームステージ
  gameStage: PIXI.Container;

  // タッチ回数
  touchDownCount: number;

  // 最後にタッチしたX座標
  lastTouchDownX: number;

  // ラケットの目指すX座標
  racketTargetX: number;

  // ボールのX方向距離
  ballDx: number;

  // ボールのY方向距離
  ballDy: number;

  // ゲームが開始したかどうか
  isGameStart: boolean;

  // ゲームをクリアしたかどうか
  isGameClear: boolean;

  // ゲームをゲームオーバーしたかどうか
  isGameOver: boolean;

  // リスタートフラグ
  restart: boolean;

  constructor(private zone: NgZone) {}

  ngOnInit() {
    // フレーム処理ごとに変更検知を起こさないよう、Pixi.jsはAngularのZone処理外で実施
    this.zone.runOutsideAngular(() => {
      this.app = new PIXI.Application({
        backgroundColor: 0x1099bb,
        width: SCREEN_WIDTH,
        height: SCREEN_HEIGHT
      });
      this.screen.nativeElement.appendChild(this.app.view);
      this.onInit();
      this.app.ticker.add(delta => {
        this.onFrame(delta);
      });
    });
  }

  onInit() {
    this.gameStage = new PIXI.Container();

    // 背景作成
    this.gameStage.addChild(this.createBackground());

    // ラケット作成
    this.racket = this.createRacket();
    this.racket.x = SCREEN_WIDTH / 2 - this.racket.width / 2;
    this.racket.y = 850;
    this.gameStage.addChild(this.racket);

    // ボール作成
    this.ball = this.createBall();
    this.ball.x = SCREEN_WIDTH / 2 - this.ball.width / 2;
    this.ball.y = this.racket.y - this.ball.height;
    this.gameStage.addChild(this.ball);

    // ブロック作成
    this.blocks = [];
    for (let row = 0; row < 15; row++) {
      for (let col = 0; col < 6; col++) {
        const block = this.createBlock(row, col);
        block.x = col * (block.width + 5) + 30;
        block.y = row * (block.height + 5) + 100;
        this.blocks.push(block);
        this.gameStage.addChild(block);
      }
    }

    this.touchDownCount = 0;
    this.lastTouchDownX = 0;
    this.racketTargetX = this.racket.x;
    this.ballDx = 5;
    this.ballDy = -5;
    this.isGameStart = false;
    this.isGameClear = false;
    this.isGameOver = false;
    this.restart = false;

    // タッチ、ポインター、およびマウスイベントの有効化
    this.gameStage.interactive = true;
    this.gameStage
      // タッチダウン
      .on('pointerdown', (event: PIXI.interaction.InteractionEvent) =>
        this.onTouchStart(event)
      )
      // タッチ終了
      .on('pointerup', (event: PIXI.interaction.InteractionEvent) =>
        this.onTouchEnd(event)
      )
      .on('pointerupoutside', (event: PIXI.interaction.InteractionEvent) =>
        this.onTouchEnd(event)
      )
      // スワイプ
      .on('pointermove', (event: PIXI.interaction.InteractionEvent) =>
        this.onTouchMove(event)
      );

    this.app.stage.addChild(this.gameStage);
  }

  ngAfterViewChecked() {
    if (this.app != null) {
      this.resize();
    }
  }

  /**
   * 変更検知の際に画面のスケール率を再計算する.
   */
  resize() {
    const ratio = Math.min(
      this.screen.nativeElement.clientWidth / SCREEN_WIDTH,
      this.screen.nativeElement.clientHeight / SCREEN_HEIGHT
    );
    if (ratio > 0) {
      this.scale = ratio;
      this.app.renderer.resize(
        SCREEN_WIDTH * this.scale,
        SCREEN_HEIGHT * this.scale
      );
      this.app.stage.scale.x = this.app.stage.scale.y = this.scale;
    }
  }

  onFrame(delta: number) {
    if (this.isGameOver || this.isGameClear) {
      return;
    }
    this.moveRacket();
    if (!this.isGameStart) {
      this.ball.x = this.racket.x + this.racket.width / 2 - this.ball.width / 2;
      return;
    }
    this.ball.y += this.ballDy;

    for (let i = 0; i < this.blocks.length; i++) {
      const block = this.blocks[i];
      // ブロックと衝突した?
      if (this.isHitBlock(block)) {
        this.gameStage.removeChild(block);
        this.blocks.splice(i, 1);
        // ブロックとの衝突位置にずらす
        this.ball.y =
          this.ballDy > 0
            ? block.y - this.ball.height - 1
            : block.y + block.height + 1;
        this.ballDy *= -1;
        break;
      }
    }

    // ラケットと衝突した?
    if (this.isHitBlock(this.racket)) {
      // ラケットとの衝突位置にずらす
      this.ball.y =
        this.ballDy > 0
          ? this.racket.y - this.ball.height - 1
          : this.racket.y + this.racket.height + 1;
      this.ballDy *= -1;
    }

    this.ball.x += this.ballDx;

    for (let i = 0; i < this.blocks.length; i++) {
      const block = this.blocks[i];
      // ブロックと衝突した?
      if (this.isHitBlock(block)) {
        this.gameStage.removeChild(block);
        this.blocks.splice(i, 1);
        // ブロックとの衝突位置にずらす
        this.ball.x =
          this.ballDx > 0
            ? block.x - this.ball.width - 1
            : block.x + block.width + 1;
        this.ballDx *= -1;
        break;
      }
    }

    // ラケットと衝突した?
    if (this.isHitBlock(this.racket)) {
      this.ball.x =
        // ラケットとの衝突位置にずらす
        this.ballDx > 0
          ? this.racket.x - this.ball.width - 1
          : this.racket.x + this.racket.width + 1;
      this.ballDx *= -1;
    }

    // 左の壁に衝突した?
    if (this.ball.x <= 0) {
      this.ball.x = 0;
      this.ballDx *= -1;
    }
    // 右の壁に衝突した?
    if (this.ball.x >= SCREEN_WIDTH - this.ball.width) {
      this.ball.x = SCREEN_WIDTH - this.ball.width;
      this.ballDx *= -1;
    }
    // 上の壁に衝突した?
    if (this.ball.y <= 0) {
      this.ball.y = 0;
      this.ballDy *= -1;
    }
    // 下に落ちた?
    if (this.ball.y > SCREEN_HEIGHT) {
      this.gameOver();
      return;
    }

    // 全てのブロックを消した?
    if (this.blocks.length === 0) {
      this.gameClear();
      return;
    }
  }

  private gameClear() {
    this.isGameClear = true;
    const gameclearText = this.createText('GAME CLEAR!!');
    this.gameStage.addChild(gameclearText);
  }

  private gameOver() {
    this.isGameOver = true;

    const gameOverText = this.createText('GAME OVER');
    this.gameStage.addChild(gameOverText);
  }

  private createText(text: string): PIXI.Text {
    const gameClearText = new PIXI.Text(
      text,
      new PIXI.TextStyle({
        fontFamily: 'Arial',
        fontSize: 72,
        fill: ['#00ffff']
      })
    );
    gameClearText.x = SCREEN_WIDTH / 2 - gameClearText.width / 2;
    gameClearText.y = (SCREEN_HEIGHT / 10) * 4;

    return gameClearText;
  }

  private moveRacket() {
    const add = (this.racketTargetX - this.racket.x) / 10;
    if (Math.abs(add) < 0.01) {
      this.racket.x = this.racketTargetX;
    } else {
      this.racket.x += add;
    }
  }

  private isHitBlock(block: PIXI.Sprite) {
    return (
      this.ball.x <= block.x + block.width &&
      this.ball.x + this.ball.width >= block.x &&
      this.ball.y <= block.y + block.height &&
      this.ball.y + this.ball.height >= block.y
    );
  }

  /**
   * タッチ開始
   * @param event イベント
   */
  private onTouchStart(event: PIXI.interaction.InteractionEvent) {
    this.touchDownCount++;
    if (this.touchDownCount === 1) {
      this.lastTouchDownX = event.data.global.x;
      this.racketTargetX = this.racket.x;
      if (this.isGameClear || this.isGameOver) {
        this.restart = true;
      }
    }
  }

  /**
   * タッチ終了
   * @param event イベント
   */
  private onTouchEnd(event: PIXI.interaction.InteractionEvent) {
    this.touchDownCount--;
    if (this.touchDownCount <= 0) {
      this.isGameStart = true;
      if (this.restart) {
        this.app.stage.removeChild(this.gameStage);
        this.onInit();
      }
      this.touchDownCount = 0;
    }
  }

  /**
   * スワイプ
   * @param event イベント
   */
  private onTouchMove(event: PIXI.interaction.InteractionEvent) {
    if (this.touchDownCount !== 1) {
      return;
    }
    const x = event.data.global.x;
    const distance = x - this.lastTouchDownX;

    this.racketTargetX += distance / this.scale;
    this.racketTargetX = Math.max(this.racketTargetX, 0);
    this.racketTargetX = Math.min(
      this.racketTargetX,
      SCREEN_WIDTH - this.racket.width
    );

    this.lastTouchDownX = x;
  }

  /**
   * 背景作成
   */
  private createBackground(): PIXI.Sprite {
    const t = PIXI.Texture.from('assets/image/ch-tree.jpg');
    const background = new PIXI.Sprite(t);
    background.width = SCREEN_WIDTH;
    background.height = SCREEN_HEIGHT;
    return background;
  }

  /**
   * ラケット作成
   */
  private createRacket(): PIXI.Sprite {
    const g = new PIXI.Graphics();
    // beginFill(塗りつぶしの色, 塗りつぶしのアルファ値)
    g.beginFill(0xffd700, 1);
    // drawRect(左上のX座標, 左上のY座標, 幅, 高さ)
    g.drawRect(0, 0, 100, 20);
    g.endFill();

    const t = PIXI.RenderTexture.create({ width: g.width, height: g.height });

    this.app.renderer.render(g, t);
    return new PIXI.Sprite(t);
  }

  /**
   * ボール作成
   */
  private createBall(): PIXI.Sprite {
    const g = new PIXI.Graphics();
    // beginFill(塗りつぶしの色, 塗りつぶしのアルファ値)
    g.beginFill(0xffffff, 1);
    // drawCircle(円の中心のX座標, 円の中心のY座標, 円の半径)
    g.drawCircle(10, 10, 10);
    g.endFill();

    const t = PIXI.RenderTexture.create({ width: g.width, height: g.height });

    this.app.renderer.render(g, t);
    return new PIXI.Sprite(t);
  }

  /**
   * ブロック作成
   */
  private createBlock(row: number, col: number): PIXI.Sprite {
    const g = new PIXI.Graphics();
    // beginFill(塗りつぶしの色, 塗りつぶしのアルファ値)
    g.beginFill(0xffffff, 1);
    // drawRect(左上のX座標, 左上のY座標, 幅, 高さ)
    g.drawRect(0, 0, 85, 25);
    g.endFill();

    const t = PIXI.RenderTexture.create({ width: g.width, height: g.height });

    this.app.renderer.render(g, t);
    return new PIXI.Sprite(t);
  }
}

では、アプリを起動して動作を確認しましょう!

うまくゲームが動きましたか!!?
これでゲームは完成です。
おめでとうございます!!

PWA

では、いよいよPWAとしてゲームを世界に公開しましょう!
PWAにするのは簡単です。次のコマンドを実行しましょう!

$ npx ng add @angular/pwa

これでゲームがPWAになりました!
詳しくは知りたい方は、こちらを参考にしてください。

リリース

Netlifyでゲームを公開します。
公開方法はこちらの記事を参考にしてください。

最後に

如何だったでしょうか?
Ionicを用いると、優れた開発環境でマルチプラットフォームなゲーム作りが簡単にできます。
興味があれば、是非色々なゲーム作りに挑戦してみてください。

では、最後にゲームクリアの文字を少し変えて..

src/app/breakout/breakout.page.ts
  private gameClear() {
    this.isGameClear = true;
    const gameclearText = this.createText('Merry Christmas!!');
    this.gameStage.addChild(gameclearText);
  }

image.png
メリークリスマス!!
素敵な年末をお過ごしください。


  1. こちらの本は、Googleエンジニアが厳選した10冊にも選ばれた良書です。 

  2. Ionicでチャットアプリを作るための事前準備を記したものですが、今回のゲーム作りも同様の手順で問題ありません。 

15
10
2

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
15
10