この記事はIonic Advent Calendar 2019 の23日目の記事です。
やあみんな!!年末をエンジョイしているかい!?
え?クリスマスが近いのに予定がなくて寂しい?
HAHAHA!そうだな( ;∀;)
そんな時はゲームでも作ってクリスマスを盛り上げチャオーぜ!!
ということでゲーム作りです。
Ionicは、AngularやReactをサポートしたクロスプラットフォームな開発環境を提供しています。すなわち、Ionicを用いることで、クロスプラットフォームなゲームを簡単に作ることが出来ます!
Ionicについて興味のある方、学びたい方にはこちらの本をお勧めします。
Ionicで作る モバイルアプリ制作入門[Angular版]<Web/iPhone/Android対応>
今回は、IonicによるPWAなゲーム作りに挑戦したいと思います。
作成するゲームはブロック崩しです。
ゲームの完成イメージは次の通りです。
背景画像は、こちらのサイトから拝借しました。
ゲームの内容は、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
最初に表示される画面をゲーム画面とするよう、次の修正を加えます。
const routes: Routes = [
{ path: '', redirectTo: 'breakout', pathMatch: 'full' },
:
ブロック崩しの画面を準備していきます。breakout.page.html
を次のように編集してください。#screen
がゲーム画面のスクリーンとなります。
<ion-content>
<div class="main">
<div class="screen" #screen></div>
</div>
</ion-content>
スタイルシートを修正します。breakout.page.scss
に次の記述を追加してください。ディスプレイ全体をゲーム画面とし、中央にスクリーンを配置して背景色を黒色とします。
.main {
text-align: center;
width: 100%;
height: 100%;
background-color: black;
}
.screen {
width: 100%;
height: 100%;
}
画面の雛形作成
以降の変更はbreakout.page.ts
に対して実施していきます。
まずは画面サイズ。
横幅600ピクセル、縦幅1000ピクセルとして定義します。
// スクリーン横幅
const SCREEN_WIDTH = 600;
// スクリーン縦幅
const SCREEN_HEIGHT = 1000;
ngOnInit()
メソッドで、PIXI.Application
を生成します。その際に注意することは、Angular Zoneの外でPIXI.Application
を生成することです。PixiJS内部では、window.requestAnimationFrame()
メソッドを用いてアニメーションフレーム処理を実現します。Angular Zone内で実行してしまうと、フレーム処理が行われるたびに変更検知が動作してしまい、レスポンス低下を招く恐れがあります。
@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
のどちらか小さい方をスケールサイズとして、スクリーンサイズを再計算します。
// 画面のスケール率
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
ディレクトリに、こちらのサイトから拝借した画像を配置します。画像はお好みで変更いただいても構いません。
/**
* 背景作成
*/
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の長方形で表現します。
// ラケット
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の円で表現します。
// ボール
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()
メソッドを用いて作成します。
// ブロック
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
オブジェクトを作成し、そこに配置していきます。こうする事で、後々のゲームクリアやゲームオーバー後の再初期処理が楽になります。
// ゲームステージ
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座標に到達する仕様とします。
// タッチ回数
touchDownCount: number;
// 最後にタッチしたX座標
lastTouchDownX: number;
// ラケットの目指すX座標
racketTargetX: number;
次に、画面タッチに対するイベント処理を定義します。
その際、gameStage.interactive
を忘れずtrue
に設定してください。これにより、タッチ、ポインター、およびマウスイベントが有効となります。
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座標として設定します。
/**
* タッチ開始
* @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クリアしています。
/**
* タッチ終了
* @param event イベント
*/
private onTouchEnd(event: PIXI.interaction.InteractionEvent) {
this.touchDownCount--;
if (this.touchDownCount <= 0) {
this.touchDownCount = 0;
}
}
スワイプ時の動作は次のとおりです。
2本指の操作はサポートしないこととし、タッチ回数が1以外の場合は処理しません。
現在のX座標と前回のX座標の差を距離(distance)として求めます。タッチイベントのX座標はディスプレイの実座標となるため、スケール値で割り算した結果をスクリーン上の距離とします。
ラケットの目標X座標に距離を足し、画面からはみ出ないよう調整します。
/**
* スワイプ
* @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フレームで進みます。こうすることで、ラケットの動作が滑らかなものとなります。
また、ボールはラケットに追随する形にしておきます。
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
ballDx
とballDx
は、ボールの移動方向と距離を格納する変数です。
isGameStart
は、ゲームが開始したかどうかのフラグです。最初はもちろんfalse
を設定しておきます。
// ボールのX方向距離
ballDx: number;
// ボールのY方向距離
ballDy: number;
// ゲームが開始したかどうか
isGameStart: boolean;
:
onInit() {
:
this.ballDx = 5;
this.ballDy = -5;
this.isGameStart = false;
:
}
では、実際にゲームをスタートさせましょう!
最初のタッチで指が離れたタイミングをゲームスタートとします。こうすることで、ラケットの初期位置を操作できます。
/**
* タッチ終了
* @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;
}
:
簡単にするために、今回はボールを四角と見立てた衝突判定を行います。
衝突判定のソースコードはこちらとなります。
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
の距離だけ進みます。ボールを動かした後に、ブロックやラケット、壁との衝突判定を行い、衝突した場合は進む方向を反転させます。ブロックにぶつかった場合はそのブロックを消去します。ラケットとの衝突判定もブロックと同様です。
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
全体のソースコードがこちらとなります。
では、動作確認しましょう!
ボールでブロックを消せましたか?
では、いよいよ最後です。
ゲームクリアとゲームオーバー処理を組み込みましょう!
ゲームクリアとゲームオーバー
ゲームクリアとゲームオーバー、そしてリスタートを管理するフラグを用意します。
// ゲームをクリアしたかどうか
isGameClear: boolean;
// ゲームをゲームオーバーしたかどうか
isGameOver: boolean;
// リスタートフラグ
restart: boolean;
onInit() {
:
this.isGameClear = false;
this.isGameOver = false;
this.restart = false;
:
}
ゲームクリア時とゲームオーバー時の判定処理をフレーム処理に追加します。
ブロックが全てなくなるとゲームクリア、ボールが下に落ちるとゲームオーバーとなります。
ゲームクリア時は「GAME CLEAR!!」、ゲームオーバー時は「GAME OVER」というテキストを画面中央に表示し、ゲームを停止します。
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()
メソッドを呼び出します。
こうすることで、ゲームが初期状態に戻ります。
/**
* タッチ開始
* @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
全体のソースコードがこちらとなります。
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を用いると、優れた開発環境でマルチプラットフォームなゲーム作りが簡単にできます。
興味があれば、是非色々なゲーム作りに挑戦してみてください。
では、最後にゲームクリアの文字を少し変えて..
private gameClear() {
this.isGameClear = true;
const gameclearText = this.createText('Merry Christmas!!');
this.gameStage.addChild(gameclearText);
}
-
こちらの本は、Googleエンジニアが厳選した10冊にも選ばれた良書です。 ↩
-
Ionicでチャットアプリを作るための事前準備を記したものですが、今回のゲーム作りも同様の手順で問題ありません。 ↩