中安拓也のブログ

プログラミングについて書くブログ

AngularでFlappy Birdを作った

f:id:l08084:20180311185520g:plain

はじめに

AngularでFlappy Birdを作りました

デモ

https://l08084.github.io/angular-redux-flappy-bird/

ソースコード

github.com

デモの遊び方

  • PC専用(IE以外なら動くはず)

  • クリックするとバードが上に移動します

  • 壁にぶつかるか、地面に落ちるとゲームオーバーとなります

  • ゲームオーバー時にスコアを表示します

実装

バージョン情報

  • Angular: 5.2.3

  • angular-redux/store: 7.1.0

  • TypeScript: 2.5.3

  • angular/cli: 1.6.5

  • OS: darwin x64

  • Node: 8.1.4

概要

angular-redux/storeを使ったReduxのアーキテクチャを採用しました。Flappy Birdについては、鳥・背景・壁のCSSにおけるpositionを一定間隔で変えることによって、右から左に動かすといった仕様にしました。壁については、一定間隔で新規にdiv要素を作成し、レンダリングするという仕様になっています。

f:id:l08084:20180313153420p:plain

AppComponent

Observable.intervalを使用することによって、一定間隔で鳥・壁・背景の移動処理、スコア採点などを呼び出しています。壁の新規作成とレンダリングには、Angular公式APIのRenderer2を使用しました。

src/app/app.component.ts:

import { Component, AfterViewInit, ElementRef, ViewChild, Renderer2 } from '@angular/core';
import { Subscription } from 'rxjs/Subscription';
import { select } from '@angular-redux/store';
import { Observable } from 'rxjs/Observable';
import { FlappyBirdActions } from '../state/action';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements AfterViewInit {

  subscription: Subscription;
  wallSubscription: Subscription;
  moveWallSubscription: Subscription;
  moveBackgroundSubscription: Subscription;
  scoreSubscription: Subscription;

  @ViewChild('bird') bird: ElementRef;

  @select() readonly maxY$: Observable<number>;
  @select() readonly birdPosition$: Observable<any>;
  @select() readonly isEnd$: Observable<boolean>;
  @select() readonly backgroundX$: Observable<number>;
  @select() readonly score$: Observable<number>;

  constructor(private action: FlappyBirdActions,
              private renderer2: Renderer2,
              private el: ElementRef,
            ) {}

  ngAfterViewInit() {
    // 初期処理
    this.init();

    // clickイベントを検知する
    Observable
      .fromEvent(document, 'click')
      .subscribe(_ => this.action.fly());

    // isEnd$が更新されると呼び出し
    this.isEnd$
      .filter(value => value)
      .subscribe(_ => this.end());

    // backgroundX$が更新されると、背景を動かす
    this.backgroundX$
      .subscribe(
        value => this.el.nativeElement.style.backgroundPosition  = `${value}px`);
  }

  /**
   * 初期化
   *
   * @memberof AppComponent
   */
  init = (): void => {
    const maxY: number
      = window.innerHeight - this.bird.nativeElement.height;
    this.action.setMaxY(maxY);
    this.action.setAy(0.4);
    this.action.setVy(0);
    this.action.setY(0);
    this.start();
  }

  /**
   * ゲームスタート
   * 各処理のインターバルを設定する
   *
   * @memberof AppComponent
   */
  start = (): void => {
    // 20msごとにbirdの落下処理と衝突判定を呼び出す
    this.subscription = Observable.interval(20)
      .subscribe(() => {
        this.action.moveBird();
        this.checkCollision();
      });

    // 2sごとに壁の設置処理を呼び出す
    this.wallSubscription = Observable.interval(2000)
      .subscribe(() => this.setWall());

    // 20msごとに壁の移動処理を呼び出す
    this.moveWallSubscription = Observable.interval(20)
      .subscribe(() => this.moveWall());

    // 20msごとに背景の移動処理を呼び出す
    this.moveBackgroundSubscription = Observable.interval(20)
      .subscribe(() => this.action.moveBackground());

    // 1sごとにスコアのカウントを行う
    this.scoreSubscription = Observable.interval(1000)
      .subscribe(() => this.action.scoreIncrement());
  }

  /**
   * 各処理のインターバルを停止する
   *
   * @memberof AppComponent
   */
  end = (): void => {
    if (this.subscription) {
      this.subscription.unsubscribe();
    }
    if (this.wallSubscription) {
      this.wallSubscription.unsubscribe();
    }
    if (this.moveWallSubscription) {
      this.moveWallSubscription.unsubscribe();
    }
    if (this.moveBackgroundSubscription) {
      this.moveBackgroundSubscription.unsubscribe();
    }
    if (this.scoreSubscription) {
      this.scoreSubscription.unsubscribe();
    }
  }

  /**
   * 壁を設置する
   *
   * @memberof AppComponent
   */
  setWall = (): void => {
    const maxX = window.innerWidth;
    const pos = 20 + Math.random() * 60;

    // 上下に配置する壁になるdiv要素を作成
    const wallTop = this.renderer2.createElement('div');
    const wallBottom = this.renderer2.createElement('div');

    this.renderer2.addClass(wallTop, 'wall');
    this.renderer2.addClass(wallBottom, 'wall');

    this.renderer2.setStyle(wallTop, 'bottom', `${pos + 15}%`);
    this.renderer2.setStyle(wallBottom, 'top', `${(100 - pos) + 15}%`);

    this.renderer2.setStyle(wallTop, 'left', `${maxX}px`);
    this.renderer2.setStyle(wallBottom, 'left', `${maxX}px`);

    // 壁(div)を設置する
    this.renderer2.appendChild(this.el.nativeElement, wallTop);
    this.renderer2.appendChild(this.el.nativeElement, wallBottom);
  }

  /**
   * 設置した壁を横に動かす
   *
   * @memberof AppComponent
   */
  moveWall = (): void => {
    const wallList = document.getElementsByClassName('wall') as HTMLCollectionOf<HTMLElement>;
    for (let i = 0; i < wallList.length; i++) {
      // tslint:disable-next-line:radix
      let left = parseInt(wallList[i].style.left.replace(/px/, ''));
      left -= 10;
      if (left < -wallList[i].getBoundingClientRect().width) {
        wallList[i].remove();
      } else {
        wallList[i].style.left = `${left}px`;
      }
    }
  }

  /**
   * 衝突判定
   *
   * @memberof AppComponent
   */
  checkCollision = () => {
    const wallList = document.getElementsByClassName('wall');
    const birdRect = this.bird.nativeElement.getBoundingClientRect();
    for (let i = 0; i < wallList.length; i++) {
      const wallRect = wallList[i].getBoundingClientRect();
      if (wallRect.left < birdRect.right && birdRect.left < wallRect.right) {
        if (wallRect.top < birdRect.bottom && birdRect.top < wallRect.bottom) {
          this.action.gameOver();
        }
      }
    }
  }
}

鳥画像のスタイル(位置)を指定しているbirdPosition$とゲームオーバー判定を行うisEnd$、合計点を表すscore$は、Observable型のため、asyncパイプを使用しています。

src/app/app.component.html:

<img #bird [ngStyle]="birdPosition$ | async"
 class="bird" src="assets/images/bird.jpg">

 <div class="score">{{score$ | async}}</div>

 <div *ngIf="isEnd$ | async" class="score-board">
     <div class="score-text">Score:{{score$ | async}}</div>
</div>

user-select: none;を指定することによって、クリック時に選択状態を表すエフェクトがかからないようにしています。

src/app/app.component.scss:

.bird {
    height: 10%;
    position: absolute;
    left: 10%;
    user-select: none;
}

.wall {
    background-image: url(/assets/images/white-wood.jpg);
    width: 10%;
    height: 100%;
    position: absolute;
    user-select: none;
}

.score {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    margin: auto;
    width: 10%;
    height: 10%;
    z-index: 10;
    color: white;
    font-size: 5vw;
    user-select: none;
}

.score-board {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    margin: auto;
    width: 40%;
    height: 30%;
    z-index: 100;
    background-color: #2274A5;
    display: flex;
    align-items: center;
    justify-content: center;
    user-select: none;
    .score-text {
        color: #DD7373;
        align-self: center;
        font: bold;
        font-size: 7vw;
        user-select: none;
    }
}

Action

angular-redux/storedispatchデコレーターを使うことでディスパッチを行っています。ActionのtypeプロパティはReducerで判別するのに使い、Reducerに渡したい引数がある場合は、payloadプロパティに渡します。

src/state/action.ts:

import { Injectable } from '@angular/core';
import { FluxStandardAction } from 'flux-standard-action';
import { dispatch } from '@angular-redux/store';

export type NumberAction = FluxStandardAction<number, void>;
export type VoidAction = FluxStandardAction<void, void>;

@Injectable()
export class FlappyBirdActions {
  static SET_MAX_Y = 'SET_MAX_Y';
  static SET_AY = 'SET_AY';
  static SET_VY = 'SET_VY';
  static SET_Y = 'SET_Y';
  static MOVE_BIRD = 'MOVE_BIRD';
  static FLY = 'FLY';
  static MOVE_BACKGROUND = 'MOVE_BACKGROUND';
  static SCORE_INCREMENT = 'SCORE_INCREMENT';
  static GAME_OVER = 'GAME_OVER';

  @dispatch() setMaxY = (maxY: number): NumberAction => ({
    type: FlappyBirdActions.SET_MAX_Y,
    payload: maxY,
    meta: undefined
  })

  @dispatch() setAy = (ay: number): NumberAction => ({
    type: FlappyBirdActions.SET_AY,
    payload: ay,
    meta: undefined
  })

  @dispatch() setVy = (vy: number): NumberAction => ({
    type: FlappyBirdActions.SET_VY,
    payload: vy,
    meta: undefined
  })

  @dispatch() setY = (y: number): NumberAction => ({
    type: FlappyBirdActions.SET_Y,
    payload: y,
    meta: undefined
  })

  @dispatch() moveBird = (): VoidAction => ({
    type: FlappyBirdActions.MOVE_BIRD,
    payload: undefined,
    meta: undefined
  })

  @dispatch() fly = (): VoidAction => ({
    type: FlappyBirdActions.FLY,
    payload: undefined,
    meta: undefined
  })

  @dispatch() moveBackground = (): VoidAction => ({
    type: FlappyBirdActions.MOVE_BACKGROUND,
    payload: undefined,
    meta: undefined
  })

  @dispatch() scoreIncrement = (): VoidAction => ({
    type: FlappyBirdActions.SCORE_INCREMENT,
    payload: undefined,
    meta: undefined
  })

  @dispatch() gameOver = (): VoidAction => ({
    type: FlappyBirdActions.GAME_OVER,
    payload: undefined,
    meta: undefined
  })
}

Reducer

Actionから引数を受け取るときは、(action as NumberAction).payloadみたいに記述します。

src/state/reducer.ts:

import { IAppState } from './store';
import { Action } from 'redux';
import { FlappyBirdActions, NumberAction } from './action';

export function rootReducer(
    lastState: IAppState,
    action: Action
): IAppState {
  switch (action.type) {
      case FlappyBirdActions.SET_MAX_Y:
      return {
          maxY: (action as NumberAction).payload,
          ay: lastState.ay,
          vy: lastState.vy,
          y: lastState.y,
          birdPosition: lastState.birdPosition,
          isEnd: false,
          backgroundX: lastState.backgroundX,
          score: lastState.score,
        };
      case FlappyBirdActions.SET_AY:
      return {
          maxY: lastState.maxY,
          ay: (action as NumberAction).payload,
          vy: lastState.vy,
          y: lastState.y,
          birdPosition: lastState.birdPosition,
          isEnd: lastState.isEnd,
          backgroundX: lastState.backgroundX,
          score: lastState.score,
        };
      case FlappyBirdActions.SET_VY:
      return {
          maxY: lastState.maxY,
          ay: lastState.ay,
          vy: (action as NumberAction).payload,
          y: lastState.y,
          birdPosition: lastState.birdPosition,
          isEnd: lastState.isEnd,
          backgroundX: lastState.backgroundX,
          score: lastState.score,
        };
      case FlappyBirdActions.SET_Y:
      return {
          maxY: lastState.maxY,
          ay: lastState.ay,
          vy: lastState.vy,
          y: (action as NumberAction).payload,
          birdPosition: {top: `${(action as NumberAction).payload}px`},
          isEnd: lastState.isEnd,
          backgroundX: lastState.backgroundX,
          score: lastState.score,
        };
      case FlappyBirdActions.MOVE_BIRD:
      const newVy = lastState.vy + lastState.ay;
      let newY = lastState.y + newVy;
      let newIsEnd = lastState.isEnd;
      if (newY < 0) {
        newY = 0;
      } else if (newY > lastState.maxY) {
        // 地面に衝突したらゲーム終了
        newIsEnd = true;
      }
      return {
          maxY: lastState.maxY,
          ay: lastState.ay,
          vy: newVy,
          y: newY,
          birdPosition:  {top: `${newY}px`},
          isEnd: newIsEnd,
          backgroundX: lastState.backgroundX,
          score: lastState.score,
        };
      case FlappyBirdActions.FLY:
      const minusVy = lastState.vy - 10;
      return {
          maxY: lastState.maxY,
          ay: lastState.ay,
          vy: minusVy,
          y: lastState.y,
          birdPosition: lastState.birdPosition,
          isEnd: lastState.isEnd,
          backgroundX: lastState.backgroundX,
          score: lastState.score,
        };
      case FlappyBirdActions.SCORE_INCREMENT:
      return {
          maxY: lastState.maxY,
          ay: lastState.ay,
          vy: lastState.vy,
          y: lastState.y,
          birdPosition: lastState.birdPosition,
          isEnd: lastState.isEnd,
          backgroundX: lastState.backgroundX,
          score: lastState.score += 1,
        };
      case FlappyBirdActions.GAME_OVER:
      return {
          maxY: lastState.maxY,
          ay: lastState.ay,
          vy: lastState.vy,
          y: lastState.y,
          birdPosition: lastState.birdPosition,
          isEnd: true,
          backgroundX: lastState.backgroundX,
          score: lastState.score,
        };
    default:
      return lastState;
  }
}

Store

INITIAL_STATEは、Stateの初期値を表しています。

src/state/store.ts:

export interface IAppState {
    maxY: number;
    ay: number;
    vy: number;
    y: number;
    birdPosition: {};
    isEnd: boolean;
    backgroundX: number;
    score: number;
}

export const INITIAL_STATE: IAppState = {
    maxY: 0,
    ay: 0,
    vy: 0,
    y: 0,
    birdPosition: {},
    isEnd: false,
    backgroundX: 0,
    score: 0,
};

参考サイト

【Web開発初心者向け】codepenで作るFlappy Bird - Qiita