
九保すこひです(フリーランスのITコンサルタント、エンジニア)
さてさて、皆さんも季節が変わると印象深いことを思い出すこともあるんじゃないでしょうか。ちなみに私の場合、暑い夏は、
「夏休みの宿題でやった自由研究が楽しかった」
ことをよく思い出します。(他は全くですけどね)
そして、そんなことを考えていると「勝手に自由研究しよっかな」という気分になったので、いろいろと考えてみたところ「どうせなら小学生の自分に見せたら驚くだろうな」というものをつくることにしました。
それはズバリ・・・・・・
「テ●リス(パズルゲーム)」
です。(なぜ伏せ字なのかは、後ほど説明します)
そうです。
小学生の頃は自分でゲームなんてつくれるとは思ってなかったので、もしタイムマシーンがあったら「すげえ!」と言ってくれると思ったんですね。
そこで
今回はVue 3
を使ってこの「有名落ちものパズルゲーム」をつくってみたいと思います。
ぜひ何かの参考になりましたら嬉しいです。
「ホワイトニング歯磨き粉、
徐々に効果あり」
開発環境: Vue 3
目次 [非表示]
前提として
こちらのページ によると、「テ●リス」という名前や、内容を全く同じにするのは権利関係上問題があるようなので、マス目やブロックの種類、またブロックを構成する数など全て本家とは違ったものにし、さらにブロック自体も以下の私の顔写真にしています。
(若かりし…
)
また、デモページも用意していますが、プログラムの動作確認が主目的です。
なお、正直なところ今回のパズルゲームは「配列を用意して中身を変更するだけでしょ〜」と軽く考えていたのですが、そんなに甘くなく、とても複雑になってしまいました。(特に回転部分が難しかったです… もしバグがあっても、もう触りたくない
)
そのため、初学者向けのプログラムとしては適していないと思います。(こんな教材が始めに出てきたら、嫌になってプログラムやめてたかもです…)
つまり、冒頭でも書いたとおり今回は私の「夏休みの工作(自己満足)」発表ぐらいで考えてくださいね(「よくできました」と言ってください!)
では、実際にみていきましょう
デモページはこちら
コードを見る前にデモページを体験できます。
Vue で落ちものパズルゲームをつくる
では、メインのコードになります。
block_game.html
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-image: linear-gradient(180deg, #aff, #ddd);
border: 8px solid #7cc;
}
#block-game-table {
margin: 40px auto;
border-collapse: collapse;
background-color: #f9f9f9;
}
#block-game-table td {
border: 1px solid #ccc;
width: 30px;
height: 30px;
text-align: center;
}
#block-game-table td img {
height: 26px;
border-radius: 15px;
}
.disable-dbl-tap-zoom {
touch-action: manipulation;
}
</style>
</head>
<body>
<div id="app" class="pt-4">
<div class="h3 text-center" style="text-shadow: 2px 2px #eee;">🎮 Vue 3 で「あの」落ちものゲームをつくる</div>
<table id="block-game-table" class="shadow">
<tbody>
<tr v-for="rowBlocks in allBlocks">
<td v-for="block in rowBlocks" :style="getBlockStyles(block)">
<img src="/images/profile_face.png" v-if="! isEmpty(block)">
</td>
</tr>
</tbody>
</table>
<div class="text-center">
<button class="btn btn-primary btn-lg me-3 disable-dbl-tap-zoom" @click="moveBlock('left')">←</button>
<button class="btn btn-primary btn-lg me-3 disable-dbl-tap-zoom" @click="moveBlock('right')">→</button>
<button class="btn btn-primary btn-lg me-3 disable-dbl-tap-zoom" @click="rotateBlock">回転</button>
</div>
<div class="text-center pt-4">
消したブロック: <span v-text="removedLines"></span> 行
</div>
<p class="p-3 bg-info rounded m-4 border" style="font-size:0.7rem;">
このページは Vue 3 の学習目的のデモです。元記事は <a href="https://blog.capilano-fw.com/?p=10784">こちら</a>。<br>
なお、<a href="https://hotnews8.net/society/tetris-copyright">こちらのページ</a> を参考にし類似性を低くしたものを公開していますが、もし権利関係に問題があるようでしたら、<a href="https://docs.google.com/forms/d/e/1FAIpQLSdqMmc1sGcm4BaHTInlMTn-8tFf4pV1k4JV0GovSfTqUddnrg/viewform">こちら</a> からご連絡ください。
</p>
</div>
<!-- Modal -->
<div class="modal fade" id="game_over_modal" tabindex="-1" aria-labelledby="game_over_modal_label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="game_over_modal_label">ゲームオーバー...😹</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
再スタートしますか?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">いいえ</button>
<a href="" class="btn btn-primary">はい</a>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/js/bootstrap.min.js"></script>
<script src="https://unpkg.com/vue@3.2.31/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
<script src="/js/block_game_config.js"></script>
<script>
const { createApp, ref, computed, nextTick } = Vue;
createApp({
setup() {
// 各種設定
const rowLength = 13;
const columnLength = 8;
// ブロック
const getInitialBlocks = (status = STATUS_EMPTY) => {
const blocks = [];
for(let y = 0; y < rowLength; y++) {
let rowBlocks = [];
for(let x = 0; x < columnLength; x++) {
const center = false;
rowBlocks.push({ x, y, status, center });
}
blocks.push(rowBlocks);
}
return blocks;
};
const allBlocks = ref(getInitialBlocks());
const setBlock = (x, y, status, center) => {
if(status !== null) {
allBlocks.value[y][x].status = status;
}
if(center !== null) {
allBlocks.value[y][x].center = center;
}
};
const getBlock = (x, y) => {
if(x >= 0 && y >= 0) {
return allBlocks.value[y][x];
} else if(y >= 0) {
return allBlocks.value[y];
}
return allBlocks
};
const getRowBlocks = y => {
return getBlock(-1, y);
};
const resetBlock = (x, y) => {
setBlock(x, y, STATUS_EMPTY, false);
};
const getBlockStyles = block => {
const backgroundColor = STATUS_COLORS[block.status];
return { backgroundColor };
};
// リアルタイム部分
const movingBlocks = computed(() => {
let blocks = [];
for(let y = 0; y < rowLength; y++) {
for(let x = 0; x < columnLength; x++) {
const block = getBlock(x, y);
if(block.status === STATUS_MOVING) {
blocks.push(block);
}
}
}
return blocks;
});
const movingCenterBlock = computed(() => {
return movingBlocks.value.find(block => {
return block.center === true;
});
});
const isEmpty = block => {
return block.status === STATUS_EMPTY;
};
const isGameOver = () => {
const firstRowBlocks = getRowBlocks(0);
return firstRowBlocks.some(block => {
return block.status !== STATUS_EMPTY;
});
};
const shouldFix = () => {
for(let block of movingBlocks.value) {
const { x, y } = block;
const nextY = y + 1;
if(y === rowLength - 1) { // 底まで来た
return true;
}
const downBlock = getBlock(x, nextY);
if(downBlock.status === STATUS_FIXED) { // 下のブロックが固定されている
return true;
}
}
return false;
};
const dropMovingBlocks = () => {
let newMovingBlocks = [];
for(let block of movingBlocks.value) {
const { x, y, center } = block;
const nextY = y + 1;
newMovingBlocks.push({
x: x,
y: nextY,
status: STATUS_MOVING,
center: center,
});
resetBlock(x, y);
}
for(let block of newMovingBlocks) {
const { x, y, status, center } = block;
setBlock(x, y, status, center);
}
};
let currentMovingBlockType = null;
let currentMovingRotation = ROTATION_UP;
const getBlockMaps = (centerX, centerY, blockType, rotation) => {
const metadata = BLOCKS_METADATA[blockType];
const maps = metadata.maps[rotation];
let newMaps = [];
if(centerX === null) {
centerX = metadata.center.x;
}
if(centerY === null) {
centerY = metadata.center.y;
}
maps.forEach(map => {
const { x, y, center } = map;
const newX = centerX + x;
const newY = centerY + y;
newMaps.push({
x: newX,
y: newY,
center: center
});
});
return newMaps;
};
const addNewBlock = () => {
const types = Object.keys(BLOCKS_METADATA);
currentMovingBlockType = _.shuffle(types)[0]; // ランダムでブロック取得
currentMovingRotation = ROTATION_UP;
const maps = getBlockMaps(null, null, currentMovingBlockType, currentMovingRotation);
maps.forEach(map => {
const { x, y, center } = map;
setBlock(x, y, STATUS_MOVING, center);
});
};
const removedLines = ref(0);
const removeFullRowBlocks = () => { // 揃っていれば消す
let fullRowIndexes = [];
for(let y = 0; y < rowLength; y++) {
const rowBlocks = getRowBlocks(y);
const isFull = rowBlocks.every(block => {
return block.status === STATUS_FIXED;
});
if(isFull === true) { // 揃っているラインのインデックスを取得
fullRowIndexes.push(y);
}
}
fullRowIndexes.forEach(fullIndex => {
for(let y = fullIndex; y > 0; y--) { // 消すラインより上にあるブロックを一つずつ下げる
for(let x = 0; x < columnLength; x++) {
const prevY = y - 1;
const prevBlock = getBlock(x, prevY);
const status = prevBlock.status;
setBlock(x, y, status, null);
}
}
});
removedLines.value += fullRowIndexes.length;
};
let gameOverModal = new bootstrap.Modal(document.getElementById('game_over_modal'));
const timer = setInterval(() => {
if(movingBlocks.value.length === 0) {
addNewBlock();
} else {
if(shouldFix()) {
movingBlocks.value.forEach(movingBlock => {
const { x, y } = movingBlock;
setBlock(x, y, STATUS_FIXED, false);
});
} else {
dropMovingBlocks();
}
removeFullRowBlocks();
if(isGameOver()) {
allBlocks.value = getInitialBlocks(STATUS_END);
clearInterval(timer);
setTimeout(() => {
gameOverModal.show();
}, 1000);
}
}
}, 300);
// 操作
const canMoveBlock = direction => {
for(let block of movingBlocks.value) {
const { x, y } = block;
const nextX = (direction === 'right')
? x + 1
: x - 1;
const nextBlock = allBlocks.value[y][nextX] || undefined;
if(!nextBlock || nextBlock.status === STATUS_FIXED) {
return false;
}
}
return true;
};
const moveBlock = direction => {
let newMovingBlocks = [];
if(canMoveBlock(direction)) {
let centerLocation = {
x: -1,
y: -1
}
for(let block of movingBlocks.value) {
const { x, y, center } = block;
const nextX = (direction === 'right')
? x + 1
: x - 1;
newMovingBlocks.push(allBlocks.value[y][nextX]);
if(center === true) {
centerLocation = {
x: nextX,
y: y
}
}
resetBlock(x, y);
}
for(let block of newMovingBlocks) {
const { x, y } = block;
const center = (centerLocation.x === x && centerLocation.y === y);
setBlock(x, y, STATUS_MOVING, center);
}
}
};
const canRotateBlock = newMaps => {
for(let map of newMaps) {
const { x, y } = map;
if(x < 0 || x >= columnLength || y < 0 || y >= rowLength) {
return false;
}
const block = getBlock(x, y);
if(! block || block.status === STATUS_FIXED) {
return false;
}
}
return true;
};
const rotateBlock = () => {
currentMovingRotation = (currentMovingRotation + 1) % 2;
const block = movingCenterBlock.value;
if(block) {
const newMaps = getBlockMaps(block.x, block.y, currentMovingBlockType, currentMovingRotation);
if(canRotateBlock(newMaps)) {
movingBlocks.value.forEach(block => {
const { x, y } = block;
resetBlock(x, y);
});
newMaps.forEach(map => {
const { x, y, center } = map;
setBlock(x, y, STATUS_MOVING, center);
});
}
}
};
return {
allBlocks,
getBlockStyles,
moveBlock,
rotateBlock,
isEmpty,
removedLines
}
}
}).mount('#app');
</script>
</body>
</html>
また、ゲームの定数やブロックの構成は以下のファイルにまとめています。
/js/block_game_config.js
// 定数
const STATUS_FIXED = 1;
const STATUS_MOVING = 2;
const STATUS_END = 3;
const STATUS_EMPTY = 0;
const STATUS_COLORS = {
0: '#fff',
1: '#99f',
2: '#f99',
3: '#ccc',
};
const ROTATION_UP = 0;
const ROTATION_RIGHT = 1;
const ROTATION_DOWN = 2;
const ROTATION_LEFT = 3;
// ブロックの属性情報
const BLOCKS_METADATA = {
i: {
center: { x: 3, y: 0 },
maps: [
[
{ x: 0, y: 0, center: false },
{ x: 0, y: 1, center: true },
{ x: 0, y: 2, center: false },
],
[
{ x: 0, y: 0, center: false },
{ x: 1, y: 0, center: true },
{ x: 2, y: 0, center: false },
],
]
},
l: {
center: { x: 3, y: 0 },
maps: [
[
{ x: 0, y: 0, center: false },
{ x: 0, y: 1, center: true },
{ x: 1, y: 1, center: false },
],
[
{ x: 0, y: 0, center: false },
{ x: 1, y: 0, center: true },
{ x: 1, y: 1, center: false },
],
]
}
};
では、ここからはメインのコードの中で重要な部分をピックアップしてご紹介していきます。
ブロック部分
ブロック部分は先ほど言及しましたように、「配列をつくって、中身のデータを変更する」ことで管理するようにしています。
つまり、シンプルにする以下のようになります。
0 | 1 | 2 |
0 | 1 | 2 |
0 | 1 | 2 |
なお、各マス目のデータは以下のデータを保管しています。
- x: 横の位置
- y: 縦の位置
- status: 状態(0 = 空、1 = 固定している、2 = 動いている、3 = 終了)
- center: 回転する中心
基本的にはその他の部分でも、この配列を変更するものとなっています。
リアルタイム部分
ここではブロックが落ちていく部分を管理しています。
基本的には繰り返し同じコードを実行することになるので、setInterval()
内で(今回は1秒ごとに)クルクル回っている状態です。
そして、実行する順序は次のとおりです。
- 新しいブロックを追加
- ブロックを落下させる(配列の状態を変更する)
- 底まで行ったり、下に固定済みのブロックが存在していたら、そこでストップし、状態を「動いている → 固定」へ変更する
- そして、ブロックが一番上まで来てしまったら「ゲームオーバー」になる
なお、この中で一番難しかったのが、ブロックが横一列に揃った時(=消す時)の挙動です。消した後は(重力にしたがって)その上に存在しているブロックを順番に下へ移動しないといけないからです。
例えば、以下のような場合だと、
□□□
■■■■■■■■■ ← 揃った
■■■■□□■■■
■■■■■■■■■ ← 揃った
2行目と4行目が削除されて、以下のようにならないといけません。
□□□
■■■■□□■■■
そこで、揃った行を取得して1つずつ行を移動(配列の状態を変更)しています。
操作
ここでは「落下しているブロック」の左右、回転させる部分です。
左右の移動もそうなのですが、先ほども書いたように回転する部分が手こずりました…
というのも「どこが基準になって回転するのか?」というデータを常に持たせておかないといけなかったからです。(それが、各配列内のcenter
になります)
当初はブロックの形状から回転できるだろうと簡単に考えていたのですが、「回転する=移動する方向が毎回変わる」ということなので、そもそも無理がありました。
そのため、回転した状態のブロックをblock_game_config.js
の中に全て準備し、さらに回転状態のインデックス番号も保持することで次々と回転させるようにしています。(本家はどうやって回転させてるんでしょうね )
Safari のダブルタップを無効にする
iPhone
(iOS 15.5
)で試してみたところ、ダブルタップするごとに画面フォーカスが実行されてしまいゲームどころではなくなってしまったので、以下のCSS
をボタンにセットして無効にしています。
.disable-dbl-tap-zoom {
touch-action: manipulation;
}
<button class="btn btn-primary btn-lg me-3 disable-dbl-tap-zoom" @click="moveBlock('left')">←</button>
配列内の値をずらしながら取得する省コード
今回いろいろと調べ物をしていて「なるほど!」と思ったのが「配列からずらしながら値を取得する」省コードです。
例えば、以下の配列があったとします。
const names = [
'太郎',
'次郎',
'三郎',
'四郎'
];
そして、今から10回ループする中で、「太郎」→「次郎」→「三郎」→「四郎」→「(また)太郎」….
という風に無限に次のデータをずらしながら取得するという場合です。
最初に思いつくのは、以下のようにif
文を使ってインデックスを初期化する方法です。
let nameIndex = 0;
for(let i = 0 ; i < 10 ; i++) {
if(names[nameIndex] === undefined) {
nameIndex = 0; //
ここでインデックスを初期化
}
console.log(names[nameIndex]);
nameIndex++;
}
もちろんこれでも問題ありませんが、以下のようにたった4行で書くことができます。
for(let i = 0 ; i < 10 ; i++) {
const nameIndex = i % names.length; //
ここ
console.log(names[nameIndex]);
}
つまり、「あふれた分のあまり=インデックス」にすることでシンプルにしているわけですね。しかも可読性もこちらの方がよくないでしょうか。
やはり賢い人たちはすごいですね
企業様へのご提案
Vue
を使うと、通常のシステムから今回のようなゲームまで幅広く実装することができます。
もし何か「こんなことができないだろうか?」というアイデアがございましたら、いつでもお気軽のご相談ください。
お待ちしております。
おわりに
ということで、今回はVue 3
で「あの」落ちものパズルゲームをつくってみました。
私が初めて本家のゲームをやったのは小学生の頃で、「こんなに単純なのになんて面白いゲームなんだ!」と感銘を受けたことを覚えています。
(年がバレますが)当時は「BPS」という聞いたこともない会社から発売されていましたが、なんとこのソフトでは「ちょっとずつ落下スピードをあげる」ことができず、いきなり一番下までドカンと移動してしまい、「うわー、1個ずれてたじゃん」となったのはいい想い出です(笑)
今回は落下スピードの部分はつくっていませんが、これも実装するとなるとsetInterval()
まわりを改変しないといけないので、なかなか難しいんじゃないでしょうか。
そう考えると、その昔「このクソゲーが!」とよく言っていましたが、開発者目線でいくと、すばらしい作品ばかりだったのかもしれません。
ぜひ、みなさんも童心に帰って何かつくってみてくださいね。
ではでは〜
「Apple さん、もう Safari も
Chrome ベースにしませんか…」