ほんじゃーねっと

おっさんがやせたがったり食べたがったりする日常エッセイ

Vue.js + SVG でブロック崩しゲーム(自動版)を作る

数当てゲーム、マインスイーパーに続き、Vue.jsでこども向けゲームを作る。

もう少し動きのあるゲームが作ってみたいので、

今回はボールが跳ね回るブロック崩しゲームに挑戦する。

これまでに作ったゲームは初期処理で何か答えを生成しておいて、

あとはユーザーのイベントに応じて処理を実行するだけだったのだけど、

ブロック崩しの場合はゲームがスタートしたら終始ボールが

動いている必要があるので、この仕組みから考えてみよう。

完成イメージ

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);

export default new Vuex.Store({
  state: {
  },
  getters: {
  },
  mutations: {
    updateBallPositions(state, payload) {
    },
  },
  actions: {
    ...
    update(context) {
      // ここに動くものの位置を更新する処理を書く
      context.commit('updateBallPositions');
    },
  },
});

Game.ts(updateアクションを呼び出し続けるクラス)

export default class 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: {
  },
})
export default class 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;
    } else if (ballState.minX < 0) {
      newBallState.movingRight = true;
    }

    if (ballState.maxY > gameAreaHeight) {
      newBallState.movingDown = false;
    } else if (ballState.minY < 0) {
      newBallState.movingDown = true;
    }
    nextBallDirections[ballState.name] = newBallState;
  }

  return nextBallDirections;
}

// 2つの進行方向が同じかどうかを判定する
function isDirectionEqual(ballState1: any, ballState2: any) {
  return (ballState1.movingRight === ballState2.movingRight)
    && (ballState1.movingDown === ballState2.movingDown);
}

export default new 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);
        } else if (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);
        } else if (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
export default class 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,
  },
})
export default class App extends Vue {
  public created() {
    const game = new Game(() => this.$store.dispatch('update'));
    game.start();
  }

  get balls() {
    return this.$store.getters.getBalls();
  }

  get gameArea() {
    return this.$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;
    } else if (ballState.minX < 0) {
      newBallState.movingRight = true;
    }

    if (ballState.maxY > gameAreaHeight) {
      newBallState.movingDown = false;
    } else if (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';
    }
  }
}

// 2つの進行方向が同じか判定する
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];
}

export default new 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;
        } else if (!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);
        } else if (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);
        } else if (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
export default class 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() {
        return this.$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,
  },
})
export default class App extends Vue {
  public created() {
    this.$store.dispatch('resetGame');
    const game = new Game(() => this.$store.dispatch('update'));
    game.start();
  }

  get balls() {
    return this.$store.getters.getBalls();
  }

  get gameArea() {
    return this.$store.getters.getGameArea();
  }
}
</script>

<style>
</style>

ブロックリスト表示処理を追加する。

これでひとまず完成。

おわり

とにかくブロックとボールの当たり判定とその後の跳ね返る方向を

算出する処理を作成するところで時間がかかった。

当たり判定がこんなに大変なものだとは...。

でも動きがあるゲームはやっぱり見てて楽しいので、

また他にも挑戦してみたい。

Game Programming Patterns ソフトウェア開発の問題解決メニュー (impress top gear)

Game Programming Patterns ソフトウェア開発の問題解決メニュー (impress top gear)