はじめに
AngularでFlappy Birdを作りました
デモ
https://l08084.github.io/angular-redux-flappy-bird/
ソースコード
デモの遊び方
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要素を作成し、レンダリングするという仕様になっています。
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/store
のdispatch
デコレーターを使うことでディスパッチを行っています。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, };