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

この記事は[Ionic Advent Calendar 2019](https://qiita.com/advent-calendar/2019/ionicframework "Ionic Advent Calendar 2019") の23日目の記事です。

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

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

<a target="_blank" href="https://www.amazon.co.jp/gp/product/4863542925/ref=as_li_tl?ie=UTF8&camp=247&creative=1211&creativeASIN=4863542925&linkCode=as2&tag=kozake0a-22&linkId=c2f5c151c7ae91c142e1ee610e3af7ac">Ionicで作る モバイルアプリ制作入門[Angular版]&lt;Web/iPhone/Android対応&gt;</a><img src="//ir-jp.amazon-adsystem.com/e/ir?t=kozake0a-22&l=am2&o=9&a=4863542925" width="1" height="1" border="0" alt="" style="border:none !important; margin:0px !important;" />

<a target="_blank"  href="https://www.amazon.co.jp/gp/product/4863542925/ref=as_li_tl?ie=UTF8&camp=247&creative=1211&creativeASIN=4863542925&linkCode=as2&tag=kozake0a-22&linkId=6ab43f6eb9c53e5f9500c0f21e439da8"><img border="0" src="//ws-fe.amazon-adsystem.com/widgets/q?_encoding=UTF8&MarketPlace=JP&ASIN=4863542925&ServiceVersion=20070822&ID=AsinImage&WS=1&Format=_SL250_&tag=kozake0a-22" ></a><img src="//ir-jp.amazon-adsystem.com/e/ir?t=kozake0a-22&l=am2&o=9&a=4863542925" width="1" height="1" border="0" alt="" style="border:none !important; margin:0px !important;" />

今回は、IonicによるPWAなゲーム作りに挑戦したいと思います。
作成するゲームはブロック崩しです。
ゲームの完成イメージは次の通りです。
<img width="50%" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101517/ea328607-8b74-1a49-5842-82188d6e5e4b.png">

背景画像は、[こちらのサイト](https://family.gr.jp/christmas/spwall/index.html "スマートフォン（スマホ）用クリスマス壁紙")から拝借しました。

ゲームの内容は、[Pythonでつくる ゲーム開発 入門講座]( https://www.amazon.co.jp/dp/4800712394/ref=cm_sw_em_r_mt_dp_U_9xC.Db8RHQYTJ "Pythonでつくる ゲーム開発 入門講座")を参考にしています[^1]。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101517/358b6243-7e84-a76d-9b68-00a62354580f.png" width=50%>
[^1]: こちらの本は、[Googleエンジニアが厳選した10冊](https://japan.googleblog.com/2019/12/books-for-students.html "エンジニアが厳選した 10 冊を、次世代のプログラミングを担う皆さんに")にも選ばれた良書です。


完成版のソースコードはこちらの[GitHubリポジトリ](https://github.com/kozake/ionic-breakout "ionic-breakout")にあります。
また、完成したゲームは[こちら](https://awesome-kepler-fb485c.netlify.com/)で公開しています。

## Ionic start

では早速作って行きましょう！！
まずはIonicのプロジェクト`ionic-breakout`を作成します。
Ionicのインストールがまだの方は、[こちら](https://qiita.com/s_kozake/items/6a8bf2084cf92b49d7b9 "Ionic 4とFirebaseでチャットアプリ ~事前準備~")の記事を参考にしてください[^2]。

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

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

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

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

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

```
$ cd ionic-breakout
$ npm start
```

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

http://localhost:4200
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101517/001b4b2a-5197-02e5-447b-fe0933d44045.png" width=50%>
これ以降は、`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](https://tkool.jp/mv/ "RPGツクールMV")という製品にもこのライブラリが用いられています。

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

```
npm install pixi.js
```

## ブロック崩し画面の作成

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

```
ionic generate page Breakout
```

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

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

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

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

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

```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ピクセルとして定義します。

```typescript: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内で実行してしまうと、フレーム処理が行われるたびに変更検知が動作してしまい、レスポンス低下を招く恐れがあります。

```typescript: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

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

```typescript: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`のソースコードは[こちら](https://github.com/kozake/ionic-breakout/blob/7f4b1afcfb9b9f1240b350742deed35ad3c4c822/src/app/breakout/breakout.page.ts)となります。

では、アプリを起動して動作を確認しましょう！
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101517/a4db8593-e89d-1ef9-1be7-852c9f2d07b4.png" width=50%>
青色の部分がゲーム画面のスクリーンです。ブラウザの縮小・拡大に応じてスクリーンサイズが変われば正常に動作しています。

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

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

まずは背景画像の作成。背景画像のサイズはスクリーンサイズに合わせます。
`assets/image`ディレクトリに、[こちらのサイト](https://family.gr.jp/christmas/spwall/index.html "スマートフォン（スマホ）用クリスマス壁紙")から拝借した画像を配置します。画像はお好みで変更いただいても構いません。

```typescript: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の長方形で表現します。

```typescript: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の円で表現します。

```typescript: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()`メソッドを用いて作成します。

```typescript: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`オブジェクトを作成し、そこに配置していきます。こうする事で、後々のゲームクリアやゲームオーバー後の再初期処理が楽になります。

```typescript: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`全体のソースコードが[こちら](https://github.com/kozake/ionic-breakout/blob/e06752b62f172010ee80909a9adaf61a10611a02/src/app/breakout/breakout.page.ts)となります。

では、アプリを起動して動作を確認しましょう！
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101517/7affa729-4f9d-ac09-77a6-896d3fac47f9.png" width=50%>
無事ラケットやボール、ブロックがスクリーンに表示されましたか？まだ動きませんが、完成画面に近づいた気がしますね！

## ラケットの操作

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

- `touchDownCount`
- `lastTouchDownX`
- `racketTargetX`

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

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

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

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

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

```typescript: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座標として設定します。

```typescript: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クリアしています。

```typescript: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座標に距離を足し、画面からはみ出ないよう調整します。

```typescript: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フレームで進みます。こうすることで、ラケットの動作が滑らかなものとなります。
また、ボールはラケットに追随する形にしておきます。

```typescript: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`全体のソースコードが[こちら](https://github.com/kozake/ionic-breakout/blob/315b90a2dd5dd2a315fbc0a8c9f94251c08adf45/src/app/breakout/breakout.page.ts)となります。

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

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

## ボールでブロック崩し

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

- `ballDx`
- `ballDx`
- `isGameStart`

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

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

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

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

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

```typescript: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;
    }
       :
```

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

```typescript: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フレームで`ballDx`、`ballDy`の距離だけ進みます。ボールを動かした後に、ブロックやラケット、壁との衝突判定を行い、衝突した場合は進む方向を反転させます。ブロックにぶつかった場合はそのブロックを消去します。ラケットとの衝突判定もブロックと同様です。

```typescript: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`全体のソースコードが[こちら](https://github.com/kozake/ionic-breakout/blob/4e62b55eb042c047c87c3585b3612269d3875b29/src/app/breakout/breakout.page.ts)となります。

では、動作確認しましょう！
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101517/6f887b87-0ef4-0a3e-6605-1ff7e3456bd5.png" width=50%>
ボールでブロックを消せましたか？
では、いよいよ最後です。
ゲームクリアとゲームオーバー処理を組み込みましょう！

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

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

```typescript: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」というテキストを画面中央に表示し、ゲームを停止します。

```typescript: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()`メソッドを呼び出します。
こうすることで、ゲームが初期状態に戻ります。

```typescript: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`全体のソースコードがこちらとなります。

```typescript: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);
  }
}
```

では、アプリを起動して動作を確認しましょう！
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101517/aec9b1d4-88f8-ddba-2b6c-aa276b31078a.png" width=50%>
うまくゲームが動きましたか！！？
これでゲームは完成です。
おめでとうございます！！

##PWA

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

```
$ npx ng add @angular/pwa
```

これでゲームがPWAになりました！
詳しくは知りたい方は、[こちら](https://ionicframework.com/jp/docs/angular/pwa "Progressive Web Apps in Angular")を参考にしてください。

## リリース

Netlifyでゲームを公開します。
公開方法は[こちらの記事](https://qiita.com/s_kozake/items/d590896e5303b75c0c6c#github%E3%82%82%E3%81%97%E3%81%8F%E3%81%AFbitbucket%E3%81%ABpush)を参考にしてください。

## 最後に

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

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

```typescript:src/app/breakout/breakout.page.ts
  private gameClear() {
    this.isGameClear = true;
    const gameclearText = this.createText('Merry Christmas!!');
    this.gameStage.addChild(gameclearText);
  }
```
<img width="50%" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101517/c3b52237-7345-a0a2-e81f-c54e95124d04.png">
メリークリスマス！！
素敵な年末をお過ごしください。