数当てゲーム、マインスイーパーに続き、Vue.jsでこども向けゲームを作る。
もう少し動きのあるゲームが作ってみたいので、
今回はボールが跳ね回るブロック崩しゲームに挑戦する。
これまでに作ったゲームは初期処理で何か答えを生成しておいて、
あとはユーザーのイベントに応じて処理を実行するだけだったのだけど、
ブロック崩しの場合はゲームがスタートしたら終始ボールが
動いている必要があるので、この仕組みから考えてみよう。
完成イメージ
![f:id:piro_suke:20180826231844p:plain f:id:piro_suke:20180826231844p:plain]()
本当はユーザーが操作するバーを作って、
ちゃんとしたブロック崩しにしたかったのだけど、
ブロックを消せるようになった時点でちょっと飽きてしまったので、
ボールがブロックを消してくれるのをただ待つだけの
「自動」ブロック崩しになってしまった。
ちゃんとしたやつはまた気持ちが盛り上がってから作る。
ソースはこちら:
github.com
こちらで遊べます...というか見れます:
breakout
ボールが動き続ける仕組みを作る
上に書いたとおり、ボールを動かし続ける仕組みが必要なので、
processing.js や Quil にある draw/update メソッドのような
定期的に呼び出される描画メソッドを作る方法を試してみた。
具体的には、あらかじめvuexのactionにボールその他の動くものの
位置を更新する関数(update)を用意しておき、
それをsetIntervalで定期的に呼び出すようにする。
store.ts
import Vue from 'vue';
import Vuex from 'vuex';
import * as _ from 'lodash';
Vue.use(Vuex);
exportdefaultnew Vuex.Store({
state: {},
getters: {},
mutations: {
updateBallPositions(state, payload) {},
},
actions: {
...
update(context) {
context.commit('updateBallPositions');
},
},
});
Game.ts(updateアクションを呼び出し続けるクラス)
exportdefaultclass Game {private fps: number;
private interval: any;
private updateFunc: any;
constructor(updateFunc: any) {this.fps = 60;
this.interval = null;
this.updateFunc = updateFunc;
}public start() {this.interval = setInterval(() => {this.update();
}, 1000 / this.fps);
}public end() {
clearInterval(this.interval);
}public update() {this.updateFunc();
}}
App.vue
import{ Component, Vue } from 'vue-property-decorator';
import Game from './Game';
@Component({
components: {},
})
exportdefaultclass App extends Vue {public created() {const game = new Game(() => this.$store.dispatch('update'));
game.start();
}}
これで、ボールに限らず、動くものはstore.tsのupdateアクションに
位置更新処理を書いておけば動いてくれる。
試しにボールを3つ配置して動かす処理を追加すると、
下記のような感じになる。
store.ts
import Vue from 'vue';
import Vuex from 'vuex';
import * as _ from 'lodash';
Vue.use(Vuex);
function calcNextBallStates(ballStates: any[]) {return ballStates.map((ballState) => {return{
name: ballState.name,
minX: ballState.x + ballState.vx,
maxX: ballState.x + ballState.vx + ballState.r,
minY: ballState.y + ballState.vy,
maxY: ballState.y + ballState.vy + ballState.r,
movingRight: ballState.vx > 0,
movingDown: ballState.vy > 0,
};
});
}function calcNextBallDirectionsByWallHit(nextBallStates: any[], gameAreaWidth: number, gameAreaHeight: number) {const nextBallDirections: any = {};
for (const ballState of nextBallStates) {const newBallState: any = {
movingRight: ballState.movingRight,
movingDown: ballState.movingDown,
};
if (ballState.maxX > gameAreaWidth) {
newBallState.movingRight = false;
}elseif (ballState.minX < 0) {
newBallState.movingRight = true;
}if (ballState.maxY > gameAreaHeight) {
newBallState.movingDown = false;
}elseif (ballState.minY < 0) {
newBallState.movingDown = true;
}
nextBallDirections[ballState.name] = newBallState;
}return nextBallDirections;
}function isDirectionEqual(ballState1: any, ballState2: any) {return (ballState1.movingRight === ballState2.movingRight)
&& (ballState1.movingDown === ballState2.movingDown);
}exportdefaultnew Vuex.Store({
state: {
gameArea: {
width: 500,
height: 600,
blockListMarginTop: 50,
blockListMarginLeft: 50,
},
balls: [{
name: 'ball-1',
x: 400,
y: 400,
vx: 10,
vy: 10,
r: 5,
minSpeed: 5,
maxSpeed: 10,
fill: '#000',
},
{
name: 'ball-2',
x: 300,
y: 300,
vx: -5,
vy: 5,
r: 10,
minSpeed: 5,
maxSpeed: 10,
fill: '#c00',
},
{
name: 'ball-3',
x: 300,
y: 300,
vx: 5,
vy: 5,
r: 5,
minSpeed: 3,
maxSpeed: 5,
fill: '#00c',
},
],
},
getters: {
getGameArea: (state, getters) => () => {return state.gameArea;
},
getBalls: (state, getters) => () => {return state.balls;
},
},
mutations: {
updateBallPositions(state, payload) {const nextBallStates = calcNextBallStates(state.balls);
const nextBallDirectionsByWallHit = calcNextBallDirectionsByWallHit(nextBallStates,
state.gameArea.width, state.gameArea.height);
const newBallStateMap: any = {};
for (const nextBallState of nextBallStates) {const newBallState = {
movingRight: nextBallState.movingRight,
movingDown: nextBallState.movingDown,
};
const wallHitState = nextBallDirectionsByWallHit[nextBallState.name];
if (!isDirectionEqual(nextBallState, wallHitState)) {
newBallState.movingRight = wallHitState.movingRight;
newBallState.movingDown = wallHitState.movingDown;
}
newBallStateMap[nextBallState.name] = newBallState;
}for (const ballState of state.balls) {if (ballState.vx < 0 && newBallStateMap[ballState.name].movingRight) {
ballState.vx = _.random(ballState.minSpeed, ballState.maxSpeed);
}elseif (ballState.vx > 0 && !newBallStateMap[ballState.name].movingRight) {
ballState.vx = 0 - _.random(ballState.minSpeed, ballState.maxSpeed);
}
ballState.x = ballState.x + ballState.vx;
if (ballState.vy < 0 && newBallStateMap[ballState.name].movingDown) {
ballState.vy = _.random(ballState.minSpeed, ballState.maxSpeed);
}elseif (ballState.vy > 0 && !newBallStateMap[ballState.name].movingDown) {
ballState.vy = 0 - _.random(ballState.minSpeed, ballState.maxSpeed);
}
ballState.y = ballState.y + ballState.vy;
}},
},
actions: {
update(context) {
context.commit('updateBallPositions');
},
},
});
このあとのブロック崩し処理追加を想定して
必要以上に複雑な構成になっているけど、
内容としてはボールごとに現在位置(x, y)と
x方向、y方向への進行速度(vx, vy)を持っていて、
updateが呼ばれる度に現在位置に進行速度が追加されるようにしている。
壁にぶつかったら跳ね返るように座標チェックを入れていて、
跳ね返る時に少しランダムな角度と速度で跳ね返るようにしている。
Ball.vue
<template>
<g transform="translate(1, 1)">
<circle v-bind:r="ballR" v-bind:fill="ballFill" stroke="#fff" v-bind:cx="ballX" v-bind:cy="ballY" />
</g>
</template>
<script lang="ts">
import{ Component, Prop, Vue } from 'vue-property-decorator';
import * as _ from 'lodash';
@Component
exportdefaultclass Ball extends Vue {
@Prop({type: String})
public ballId!: string;
@Prop({type: String})
public ballFill!: string;
@Prop({type: Number})
public ballR!: number;
@Prop({type: Number})
public ballX!: number;
@Prop({type: Number})
public ballY!: number;
}</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>
これはただのボールの表示定義。
基本的に属性情報は親のApp.vueから受け取る。
App.vue
<template>
<svg id="app" v-bind:width="gameArea.width + 'px'" v-bind:height="gameArea.height + 'px'" style="border: 1px solid #000;">
<g transform="translate(1, 1)">
<Ball v-for="ball in balls" v-bind:key="ball.ballId" v-bind:ball-id="ball.ballId" v-bind:ball-r="ball.r" v-bind:ball-x="ball.x" v-bind:ball-y="ball.y" v-bind:ball-fill="ball.fill" />
</g>
</svg>
</template>
<script lang="ts">
import{ Component, Vue } from 'vue-property-decorator';
import Ball from './components/Ball.vue';
import Game from './Game';
@Component({
components: {
Ball,
},
})
exportdefaultclass App extends Vue {public created() {const game = new Game(() => this.$store.dispatch('update'));
game.start();
}
get balls() {returnthis.$store.getters.getBalls();
}
get gameArea() {returnthis.$store.getters.getGameArea();
}}</script>
<style>
</style>
store.tsでボールの位置を更新したら
あとはvuexが勝手に画面に反映してくれるので、
App.vueは現在の各ボールの位置を描画する処理を書いておくだけ。
この辺が vue + vuex の便利なところ。
ここまで書いて実行したら、ボールがずーっと跳ね回る画面ができるはず。
これを応用することで、ブロック崩し以外にもエアーホッケーとか
シューティングゲームとか色々作れるんじゃないかと思ってる。
ブロックを表示して当たり判定をつける
ブロック崩しなので、ボールが当たったら消えるブロックを配置する。
ボールはブロックに当たると跳ね返る。
この当たり判定と跳ね返る方向を実装するのがゲーム開発初心者には
結構大変だった。
最終的なソースは下記のような形になった。
store.ts
import Vue from 'vue';
import Vuex from 'vuex';
import * as _ from 'lodash';
Vue.use(Vuex);
function calcNextBallStates(ballStates: any[]) {return ballStates.map((ballState) => {return{
name: ballState.name,
minX: ballState.x + ballState.vx,
maxX: ballState.x + ballState.vx + ballState.r,
minY: ballState.y + ballState.vy,
maxY: ballState.y + ballState.vy + ballState.r,
movingRight: ballState.vx > 0,
movingDown: ballState.vy > 0,
};
});
}function calcNextBallDirectionsByWallHit(nextBallStates: any[], gameAreaWidth: number, gameAreaHeight: number) {const nextBallDirections: any = {};
for (const ballState of nextBallStates) {const newBallState: any = {
movingRight: ballState.movingRight,
movingDown: ballState.movingDown,
};
if (ballState.maxX > gameAreaWidth) {
newBallState.movingRight = false;
}elseif (ballState.minX < 0) {
newBallState.movingRight = true;
}if (ballState.maxY > gameAreaHeight) {
newBallState.movingDown = false;
}elseif (ballState.minY < 0) {
newBallState.movingDown = true;
}
nextBallDirections[ballState.name] = newBallState;
}return nextBallDirections;
}function calcHitDirection(prevX: number, prevY: number, ballR: number, nextX: number, nextY: number,
blockX: number, blockY: number, blockWidth: number, blockHeight: number) {const movingRight = prevX < nextX;
const movingDown = prevY < nextY;
if (movingRight && movingDown) {if ((nextX + ballR) - blockX >= (nextY + ballR) - blockY) {return'above';
}else{return'left';
}}if (movingRight && !movingDown) {if ((nextX + ballR) - blockX >= (blockY + blockHeight) - nextY) {return'below';
}else{return'left';
}}if (!movingRight && movingDown) {if ((blockX + blockWidth) - nextX >= (nextY + ballR) - blockY) {return'above';
}else{return'right';
}}if (!movingRight && !movingDown) {if ((blockX + blockWidth) - nextX >= (blockY + blockHeight) - nextY) {return'below';
}else{return'right';
}}}function isDirectionEqual(ballState1: any, ballState2: any) {return (ballState1.movingRight === ballState2.movingRight)
&& (ballState1.movingDown === ballState2.movingDown);
}function calcNextBallDirectionsByBlockHit(ballStates: any[], nextBallStates: any[], blocks: any[],
blockListMarginLeft: number, blockListMarginHeight: number) {const nextBallDirections: any = {};
const hitBlocks: string[] = [];
for (const ballState of nextBallStates) {const newBallState: any = {
movingRight: ballState.movingRight,
movingDown: ballState.movingDown,
};
for (const block of blocks) {const blockInfo = {
minX: block.x + blockListMarginLeft,
maxX: block.x + blockListMarginLeft + block.width,
minY: block.y + blockListMarginHeight,
maxY: block.y + blockListMarginHeight + block.height,
};
const isBallXInBlock =
(blockInfo.minX <= ballState.minX && ballState.minX <= blockInfo.maxX)
|| (blockInfo.minX <= ballState.maxX && ballState.maxX <= blockInfo.maxX);
const isBallYInBlock =
(blockInfo.minY <= ballState.minY && ballState.minY <= blockInfo.maxY)
|| (blockInfo.minY <= ballState.maxY && ballState.maxY <= blockInfo.maxY);
if (!block.isHit && isBallXInBlock && isBallYInBlock) {
hitBlocks.push(block.blockId);
const prevBallState = _.find(ballStates, {name: ballState.name});
const hitDirection = calcHitDirection(prevBallState.x, prevBallState.y, prevBallState.r,
ballState.minX, ballState.minY,
blockInfo.minX, blockInfo.minY, block.width, block.height);
switch (hitDirection) {case'above':
newBallState.movingDown = false;
break;
case'below':
newBallState.movingDown = true;
break;
case'left':
newBallState.movingRight = false;
break;
case'right':
newBallState.movingRight = true;
break;
}break;
}}
nextBallDirections[ballState.name] = newBallState;
}return[nextBallDirections, hitBlocks];
}exportdefaultnew Vuex.Store({
state: {
gameArea: {
width: 500,
height: 600,
blockListMarginTop: 50,
blockListMarginLeft: 50,
},
balls: [{
name: 'ball-1',
x: 400,
y: 400,
vx: 10,
vy: 10,
r: 5,
minSpeed: 5,
maxSpeed: 10,
fill: '#000',
},
{
name: 'ball-2',
x: 300,
y: 300,
vx: -5,
vy: 5,
r: 10,
minSpeed: 5,
maxSpeed: 10,
fill: '#c00',
},
{
name: 'ball-3',
x: 300,
y: 300,
vx: 5,
vy: 5,
r: 5,
minSpeed: 3,
maxSpeed: 5,
fill: '#00c',
},
],
blocks: [] as any[],
},
getters: {
getGameArea: (state, getters) => () => {return state.gameArea;
},
getBallState: (state, getters) => (name: string) => {return _.find(state.balls, {name});
},
getBalls: (state, getters) => () => {return state.balls;
},
getBlocks: (state, getters) => () => {return state.blocks;
},
},
mutations: {
updateBallPositions(state, payload) {const nextBallStates = calcNextBallStates(state.balls);
const nextBallDirectionsByWallHit = calcNextBallDirectionsByWallHit(nextBallStates,
state.gameArea.width, state.gameArea.height);
const[nextBallDirectionsByBlockHit, hitBlocks] = calcNextBallDirectionsByBlockHit(state.balls,
nextBallStates, state.blocks, state.gameArea.blockListMarginLeft, state.gameArea.blockListMarginTop);
state.blocks = state.blocks.map((block) => {if (_.includes(hitBlocks, block.blockId)) {
block.isHit = true;
}return block;
});
const newBallStateMap: any = {};
for (const nextBallState of nextBallStates) {const newBallState = {
movingRight: nextBallState.movingRight,
movingDown: nextBallState.movingDown,
};
const wallHitState = nextBallDirectionsByWallHit[nextBallState.name];
const blockHitState = nextBallDirectionsByBlockHit[nextBallState.name];
if (!isDirectionEqual(nextBallState, wallHitState)) {
newBallState.movingRight = wallHitState.movingRight;
newBallState.movingDown = wallHitState.movingDown;
}elseif (!isDirectionEqual(nextBallState, blockHitState)) {
newBallState.movingRight = blockHitState.movingRight;
newBallState.movingDown = blockHitState.movingDown;
}
newBallStateMap[nextBallState.name] = newBallState;
}for (const ballState of state.balls) {if (ballState.vx < 0 && newBallStateMap[ballState.name].movingRight) {
ballState.vx = _.random(ballState.minSpeed, ballState.maxSpeed);
}elseif (ballState.vx > 0 && !newBallStateMap[ballState.name].movingRight) {
ballState.vx = 0 - _.random(ballState.minSpeed, ballState.maxSpeed);
}
ballState.x = ballState.x + ballState.vx;
if (ballState.vy < 0 && newBallStateMap[ballState.name].movingDown) {
ballState.vy = _.random(ballState.minSpeed, ballState.maxSpeed);
}elseif (ballState.vy > 0 && !newBallStateMap[ballState.name].movingDown) {
ballState.vy = 0 - _.random(ballState.minSpeed, ballState.maxSpeed);
}
ballState.y = ballState.y + ballState.vy;
}},
generateBlocks(state, payload) {
state.blocks = [];
for (const y of _.range(10)) {for (const x of _.range(10)) {
state.blocks.push({
x: x * 40,
y: y * 20,
width: 40,
height: 20,
blockId: x + ':' + y,
isHit: false,
});
}}},
},
actions: {
resetGame(context) {
context.commit('generateBlocks');
},
update(context) {
context.commit('updateBallPositions');
},
},
});
これがベストのやり方かどうかは分からん。
BlockList.vue
<template>
<g v-bind:transform="'translate(' + gameArea.blockListMarginTop + ',' + gameArea.blockListMarginLeft + ')'">
<g v-for="item in blockList" v-bind:key="item.blockId">
<rect v-bind:width="item.width" v-bind:height="item.height" v-bind:stroke="(item.isHit ? '#fff' : '#ccc')" v-bind:fill="(item.isHit ? '#fff' : '#00c')" stroke-width="1" v-bind:x="item.x" v-bind:y="item.y" />
</g>
</g>
</template>
<script lang="ts">
import{ Component, Prop, Vue } from 'vue-property-decorator';
@Component
exportdefaultclass CellList extends Vue {
get blockList() {const blocks = this.$store.getters.getBlocks();
const blockViewList: any[] = [];
for (const block of blocks) {
blockViewList.push(Object.assign(block, {}));
}return blockViewList;
}
get gameArea() {returnthis.$store.getters.getGameArea();
}}</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>
ブロックリストを表示する。
App.vue
<template>
<svg id="app" v-bind:width="gameArea.width + 'px'" v-bind:height="gameArea.height + 'px'" style="border: 1px solid #000;">
<g transform="translate(1, 1)">
<BlockList />
<Ball v-for="ball in balls" v-bind:key="ball.ballId" v-bind:ball-id="ball.ballId" v-bind:ball-r="ball.r" v-bind:ball-x="ball.x" v-bind:ball-y="ball.y" v-bind:ball-fill="ball.fill" />
</g>
</svg>
</template>
<script lang="ts">
import{ Component, Vue } from 'vue-property-decorator';
import Ball from './components/Ball.vue';
import BlockList from './components/BlockList.vue';
import Game from './Game';
@Component({
components: {
Ball,
BlockList,
},
})
exportdefaultclass App extends Vue {public created() {this.$store.dispatch('resetGame');
const game = new Game(() => this.$store.dispatch('update'));
game.start();
}
get balls() {returnthis.$store.getters.getBalls();
}
get gameArea() {returnthis.$store.getters.getGameArea();
}}</script>
<style>
</style>
ブロックリスト表示処理を追加する。
これでひとまず完成。
おわり
とにかくブロックとボールの当たり判定とその後の跳ね返る方向を
算出する処理を作成するところで時間がかかった。
当たり判定がこんなに大変なものだとは...。
でも動きがあるゲームはやっぱり見てて楽しいので、
また他にも挑戦してみたい。