【JavaScript】200行で作る、enchant.jsを使った簡単ぷよぷよプログラミング

おっ立ち野郎が2015年1月6日15:53:17に投稿しました。

JavaScript

29

お気に入り

準備中

Views

このレシピで作るもの

今回のために簡易版ぷよぷよを作成しました。今回はこれを作っていきます。

Otatin Laboにてデモを公開しています。これが今回のぷよぷよの完成形です。

プレイしてわかるように、今から作るぷよぷよは、ぷよぷよとしての最低限の機能を持った「簡易版とことんぷよぷよ」です。演出はオプションということで、気に入らない部分は各自でコードを改変してみてください。

そして今回はenchant.jsを使って書いていきます。enchant.jsは簡単にゲームが作れる人気フレームワークです。

HTML5 + JavaScriptなので、作る時にもプレイする時にも特別なソフトウェアを必要としません。最低限テキストエディタとウェブブラウザがあれば大丈夫です。


目次

今回作るぷよぷよの動作の流れ

おおざっぱに書くと、今回作るぷよぷよってこんな感じです。

  1. 2つの操作ぷよを出現
  2. 連鎖処理
  3. フィールドの描画
  4. 1に戻る

もう少し詳しく書くと

  1. 2つの操作ぷよを上部に出現
  2. 操作ぷよの操作と落下
  3. 操作ぷよの着地地点と色をフィールド情報に追加
  4. 下が空いているぷよ達を自由落下
  5. 4つ以上つながっているぷよをフィールド情報から消す
  6. 下が空いているぷよ達を自由落下
  7. 自由落下するぷよが無くなるまで5,6を繰り返す
  8. フィールド情報からマップを更新
  9. 1に戻る

これでぷよぷよの最低限の機能がついたゲームが完成です。

では早速ゲームを作っていきましょう。

プログラミングの下準備

今回のぷよぷよに必要なファイルは以下の4つのみ。

  • enchant.js
  • index.html
  • main.js
  • puyos.png

今回はmain.jsをメインに書いていくので、最初にそれ以外の必要なファイルをちゃちゃっと用意しておきましょう。

  • enchant.jsを入手する

ここからダウンロードしましょう。

使うファイルは"enchant.js"だけです。取り出して今回使うディレクトリへ移しましょう。

  • puyos.pngを作る

作るの面倒くさいという方は今回こちらで用意した画像をDLして使ってください。

20130824112806.png

不満な方は80px * 16pxの画像を用意してください。
(16px*16pxの画像を並べたものです。順番は[ブロック, ぷよ1, ぷよ2, ぷよ3, ぷよ4]です。)

20130824073035.png

index.htmlを作る

index.htmlを以下のように記述してください。

<!DOCTYPE html>
<meta charset="utf-8">
<title>簡易版とことんぷよぷよ</title>
<script src="enchant.js"></script>
<script src="main.js"></script>
<style>body{margin:0;padding:0;}</style>

HTML5の宣言、文字コードの指定、タイトルとJavaScriptの読み込みだけです。
実はhtmlタグやhead、bodyタグなどは省略できます。これはGoogle HTML/CSS Style Guideで推奨されていることです。
最後にCSSで余白を消しています。

それでは下準備ができたので、早速main.jsを作ってそこにコードを書いていきましょう。すぐ終わりますよ。

main.jsを書いていく

完成したコードをGistにアップしておきました。

基本的には上のソースコードと照らしあわせてこの記事を見てください。

ゲームのひな形を作る

まずはenchant.jsのチュートリアルにしたがって書いてみましょう。

enchant();

window.onload = function () {
    var game = new Game(320, 320);
    game.onload() = function() {
        // 処理
    };
    game.start();
};

処理を書いてく

グローバル変数を宣言する

肝心な数値などを代入したグローバル変数を最初に宣言しておきましょう。

var FPS = 30; // フレームレート
var MAX_ROW = 14+1; // 縦のマス数
var MAX_COL = 6+2; // 横のマス数
var CELL_SIZE = 16; // マスのサイズ(ぷよのpxサイズ)
var PUYOS_IMG = "puyos.png"

基本的にはコメントの通りです。
MAX_ROWが+1, MAX_COLが+2されているのは壁(床)の分です。

20130824073535.png

gameのプロパティをいじる

gameのセッティングです。
FPSをセットして、画像を読み込んでいます。
keybindの32という数字はキーコードです。キーコード?って方はAdobeのページなどでどんなもんかみましょう。

game.fps = FPS; // フレームレートのセット
game.preload(PUYOS_IMG); // ぷよ画像の読み込み
game.keybind(32, 'a'); // スペースバーにAボタンを割り当て

ゲームの開始時の処理

ゲームが開始されるとgame.onload指定された関数が呼び出されます。

game.onload = function() {
// 処理
};

なのでこの{}の中に処理を書いていくわけです。

今回は「操作するぷよ2つ」と「配置が確定されたぷよ」を分けて描画していきます。

20130824074310.png

配置が確定されたぷよの位置と情報はfieldという変数に格納していきます。
これは二次元配列でfield[row][col]で-1から4の数値が取り出せます。
なにもない場合は-1、ブロックは0、各色ぷよは1から4です。

なぜこのような数値の割り当てかというと、fieldをmapに読み込ませるためです。
fieldをmapに読み込ませることによって、操作ぷよの当たり判定と置かれたぷよの描画を行うことができます。

var scene = game.rootScene;     // game.rootSceneは長いのでsceneに参照を割り当て
var map = new Map(16, 16); // フィールド描画用&当たり判定用マップ
var field = new Array(MAX_ROW); // フィールドの色のデータ
for (var i=0; i<field.length; i++) {
    var temp_array = [];
    for (var j=0; j<MAX_COL; j++) {
        if (j==0 || j==MAX_COL-1 || i==MAX_ROW-1) temp_array[j] = 0; // ブロック(壁)を配置
        else temp_array[j] = -1; // 空
    }
  field[i] = temp_array;
}
map.image = game.assets[PUYOS_IMG];     // mapにぷよ画像を読みこませる
map.loadData(field);    // mapにフィールドを読みこませる
scene.addChild(map);    // マップをシーンに追加

20130824074833.png

操作用ぷよを返す関数

操作用ぷよを返す関数を作りましょう。
Spriteは画像表示機能を持ったクラスです。

座標は0,0で初期化しています。

読み込んだ画像の中でぷよぷよがあるのは0pxではなく16px、つまり2つめから始まっているので一つずれて
Math.floor(Math.random()4)ではなく
Math.floor(Math.random()
4+1)なのです。

20130824075301.png

/**
* 一つのぷよスプライトを返します。
* 操作ぷよに使われます。
* 色は4色からランダムに決定されます。
*
* @game {Game}
* @return {Sprite} (0, 0)のランダムな色のぷよぷよ
*/
function createPuyo (game){
    var puyo = new Sprite(CELL_SIZE, CELL_SIZE);
    puyo.image = game.assets[PUYOS_IMG];
    puyo.frame = Math.floor(Math.random()*4+1); // ランダムに色を選択
    puyo.moveTo(0, 0);
    return puyo;
}

2つの操作ぷよで構成されるグループを返す関数

2つの操作ぷよを持ったpairグループを返す関数を作りましょう。
200行中一番行数をとっている関数ですのでじっくり見て行きましょう。(あまりぷよぷよと関係ない処理が多いですが)

まず操作ぷよをp0, p1として生成。
ここでp0、p1のxy座標はグループの中での座標だということに注意しましょう。
つまり、p0.xが10でpair.xが10ならグループの外から見た時のp0のx座標は20だということです。

20130824081506.png

formNumは回る側のぷよ(p0)が上下左右喉の位置にいるかを示す整数で、0:上, 1:右, 2:下, 3: 左 となっています。
この割り当てはfomrsにも対応していて、formsはグループの中の座標で回る側のぷよがどの位置にいるかを示しています。
そして操作ぷよが出現する際は回る側のぷよは上に配置されているので、formNumは0で、p0.yは-CELL_SIZEなのです。

20130824082431.png

isFall変数は落下中かどうかを示しています。落下中であればtrue, 着地したのであればfalseとなります。

20130824082849.png

最後にグループの座標を指定します。本家のぷよぷよを見ると左から3番目の場所から出現するのでCELL*(2+1)となっています。この+1は壁の分です。

/**
* 操作ぷよ2つが含まれたグループを返します。
* このグループは自動でY座標を更新し、
* キー入力を受付け、それに応じて挙動を取ります。
* 着地するとfieldに操作ぷよの情報を追加します。
*
* @game {Game}
* @map {Map} フィールドを読み込むMapオブジェクト。
* 操作ぷよとフィールドとの当たり判定用。
* @field {Array} フィールドの色情報が含まれる二次元配列。
* 操作ぷよが着地した時に操作ぷよをフィールドに追加する。
* @return {Group} 操作ぷよ2つが含まれるグループ
*/
function createPair (game, map, field) {
    var pair = new Group();
    var p0 = createPuyo(game);  // 回る側の操作ぷよ
    var p1 = createPuyo(game);  // 軸側の操作ぷよ
    var forms = [[0, -CELL_SIZE], [CELL_SIZE, 0], [0, CELL_SIZE], [-CELL_SIZE, 0]]; // 操作ぷよの形
    var formNum = 0;                    // 操作ぷよの形の番号。フォームナンバ
    /* キー押下カウント */
    var inputRightCount = 0;    
    var inputLeftCount = 0;
    var inputAcount = 0;
    pair.isFall = true;            // 落下中、つまり操作出来る状態かどうか
    pair.addChild(p0);             // 操作ぷよをシーンに追加
    pair.addChild(p1);
    p0.y = -CELL_SIZE;     // 回る側のぷよの初期位置を軸ぷよの一つ上へ
    pair.moveTo(CELL_SIZE*3, CELL_SIZE); // グループの初期位置を操作ぷよ出現場所へ
    pair.addEventListener("enterframe", function() {
        // フレーム毎の処理
    });
    return pair;
}

ではpairグループのフレーム毎の処理を書いていきましょう。

/* キー連続押下カウントの更新 */
inputRightCount = game.input.right ? inputRightCount+1 : 0;
inputLeftCount = game.input.left ? inputLeftCount+1 : 0;
inputACount = game.input.a ? inputACount+1 : 0;
/* 回転 */
if (inputACount == 1) {
    var newFormNum = (formNum+1) % 4; // 回転した場合のフォームナンバ
    var newX = forms[newFormNum][0];  // 回転先のx
    var newY = forms[newFormNum][1];  // 回転先のy
    if (!map.hitTest(this.x+newX, this.y+newY)) { // 回転可能判定
        formNum = newFormNum;
        p0.moveTo(newX, newY);
    }
}
/* 横移動 */
var newX = 0;                   // 横移動先のx
if (inputRightCount == 1) {
    newX = formNum==1 ? p0.x+CELL_SIZE : p1.x+CELL_SIZE;
}
if (inputLeftCount == 1) {
    newX = formNum==3 ? p0.x-CELL_SIZE : p1.x-CELL_SIZE;
}
if (!map.hitTest(this.x+newX, this.y+p0.y) && !map.hitTest(this.x+newX, this.y+p1.y)) { // 移動可能判定
    this.x = this.x + (newX?newX>=0?1:-1:0)*CELL_SIZE;
}
/* 落下 */
newY = formNum==2 ? p0.y+CELL_SIZE : p1.y+CELL_SIZE;
var vy = Math.floor(game.input.down ? game.fps/10 : game.fps/1); // 落下速度の設定 (10や1などの数値は何マス毎秒か
if (game.frame%vy == 0) {
    if (!map.hitTest(this.x+p0.x, this.y+newY) && !map.hitTest(this.x+p1.x, this.y+newY)) { // 移動可能判定
        this.y += CELL_SIZE;
    } else {                    // 着地した場合
        /* フィールドに操作ぷよを追加 */
        field[(this.y+p0.y)/CELL_SIZE][(this.x+p0.x)/CELL_SIZE] = p0.frame;
        field[(this.y+p1.y)/CELL_SIZE][(this.x+p1.x)/CELL_SIZE] = p1.frame;
        pair.isFall = false; // 着地したので落下中フラグをfalseに
    }
}

まずはキー連続押下カウントを更新します。これは三項演算子を使っていて、カウントをキーが押されていたらインクリメント、そうでなかったら0にします。

操作ぷよグループ(pair)はそれぞれのキーカウントによって挙動を変えています。「キーカウントが1の時のみなんたら」にすることで、キーリピートを防止しています。(本当は今回の方針により導入しないつもりでしたが、これがないとあまりにも操作が困難になってしまうため導入しました。導入しないとどうなるかは inputRightCount >= 1などとすれば確認できるでしょう!)

壁や配置されたぷよと操作ぷよグループとの当たり判定はmap.hitTestで調べています。
いずれもあらかじめ移動先、回転先の座標をnewX、newYとし、その座標に何もないなら移動、回転処理を行っています。

ここでも注意したいのはnewX、newYはグループの中の座標でのx, yだということです。

そしてもしp0かp1のy方向の移動先にブロックか配置されたぷよがあれば着地したとみなし、
フィールドを表す配列に操作ぷよを追加し、isFallをfalseにします。

実際に操作ぷよグループをシーンに追加してみよう

せっかく操作ぷよグループを生成する関数を作ったのでそれを使ってグループを生成し、シーン(game.rootScene)に追加しましょう。

さっき書いたgame.onloadの中の

scene.addChild(map); // マップをシーンに追加

の直後に以下を記述しましょう。

var pair = createPair(game, map, field); // 操作するぷよ2つを作成
scene.addChild(pair);   // 操作ぷよをシーンに追加

ちゃんとぷよが上のほうに表示されたでしょうか。

同じ色のぷよが幾つ繋がっているかを返す関数

それではぷよぷよが幾つ繋がっているかを返す関数を書いていきましょう。
これはぷよぷよプログラミングの中で一番大事な部分で、この関数がないとぷよぷよとして成り立ちません。

今回は再帰処理を採用しました。

/**
* 指定された場所から色がいくつ繋がっているかを返します。
* 同じ色のぷよが隣接されていた場合は再帰呼び出しし、
* 隣接されたぷよからの色の数を足していきます。
*
* @row {Number} 調べ始めるぷよの行
* @col {Number} 調べ始めるぷよの列
* @field {Array} フィールドの色情報が格納された二次元配列
* @return {Number} 指定された場所からぷよがいくつ繋がっているか
*/
function countPuyos (row, col, field) {
    var c = field[row][col];    // ぷよの色
    var n = 1;                  // 1で初期化しているのは自分もカウントするため。
    field[row][col] = -1; // この場所をチェックした証として一時的に空白に
    if (row-1>=2 && field[row-1][col]==c) n += countPuyos(row-1, col, field);   
    if (row+1<=MAX_ROW-2 && field[row+1][col]==c) n += countPuyos(row+1, col, field);
    if (col-1>=1 && field[row][col-1]==c) n += countPuyos(row, col-1, field);
    if (col+1<=MAX_COL-2 && field[row][col+1]==c) n += countPuyos(row, col+1, field);
    field[row][col] = c;                // 色を戻す
    return n;
}

20130824091630.png

20130824084528.png

ぷよを消す関数

ぷよを消す関数を作りましょう。
これも再帰処理を使います。指定された場所を空(-1)にし、隣接された同じ色のぷよにも関数を当てていきます。

/**
* 指定された場所のぷよを消します。
* 隣接されたぷよが同じ色だった場合は再帰呼び出しし、
* 消していきます。
*/
function deletePuyos (row, col, field) {
    var c = field[row][col];    // ぷよの色
    field[row][col] = -1;               // ぷよを空に
    if (row-1>=2 && field[row-1][col]==c) deletePuyos(row-1, col, field);
    if (row+1<=MAX_ROW-2 && field[row+1][col]==c) deletePuyos(row+1, col, field);
    if (col-1>=1 && field[row][col-1]==c) deletePuyos(row, col-1, field);
    if (col+1<=MAX_COL-2 && field[row][col+1]==c) deletePuyos(row, col+1, field);
}

自由落下処理関数

次に自由落下処理関数を作りましょう。
ひとつひとつのぷよを見ていき、下が空の分だけ下へ移動させます。

20130824084903.png

この関数は操作ぷよが着地してフィールドに追加された直後、ぷよを消した直後に呼び出されます。

落としたぷよの数の連鎖を返すことにより、次に作る連鎖の関数で連鎖を続けるか続けないかを判断することができます。

/**
* 下が空いているぷよを落とした状態にするよう
* フィールドを更新し、落ちたぷよの数を返します。
*
* @field {Array} フィールドの色情報が格納された二次元配列
* @return {Number} 落ちたぷよの数
*/
function freeFall (field) {
    var c = 0;                                  // おちたぷよの数
    for (var i=0; i<MAX_COL; i++) {
        var spaces = 0;
        for (var j=MAX_ROW-1; j>=0; j--) {
            if (field[j][i] == -1) spaces ++;
            else if (spaces >= 1) {     // 落ちるべきぷよがあった場合
                field[j+spaces][i] = field[j][i];
                field[j][i] = -1;
                c ++;
            }
        }
    }
    return c;
}

連鎖処理関数

連鎖処理関数を作りましょう。
この関数はcountPuyos関数、deletePuyos関数、freeFall関数を繰り返し行い連鎖を実行します。
一つひとつぷよを見ていき、まずcountPuyos関数で繋がっているぷよが4つ以上かを数え、
そうであるならdeletePuyos関数を実行していきます。

全てのぷよを回った時freeFall関数を実行します。もしfreeFall関数で返ってきた値が
0だった場合は連鎖を終了、1以上であった場合は連鎖を続行します。

/**
* 連鎖処理を行います。
* 消去と自動落下を繰り返して連鎖を終了させます。
* 自動落下が発生しなかった場合は再帰呼び出しをせずに終了します。
*
* @field {Array} フィールドの色情報が格納された二次元配列
*/
function chain (field) {
    for (var i=0; i<MAX_ROW; i++) {
        for (var j=0; j<MAX_COL; j++) {
            var n = 0; // つながっているぷよをカウントする変数を初期化
            if (field[i][j]>=1 && countPuyos(i, j, field)>=4){ // 同じ色のぷよが4つながっていた場合
                deletePuyos(i, j, field); // ぷよを消去
            };
        }
    }
    if (freeFall(field) >= 1) chain(field); // 自由落下したぷよがあった場合は再帰
}

仕上げ

全ての関数が出来上がったのでそれを呼び出して仕上げましょう。
仕上げにrootSceneにenterframe処理を追加します。

以下をpairを宣言した直後の場所にに記述してください。

操作中のぷよグループが着地した時にグループをシーンから削除し、自由落下処理をします。
そして連鎖処理をした後にmap.loadData(field)で更新されたfieldで配置されたぷよを描画し直します。

もし指定された位置にぷよがあった場合はゲームオーバー処理をし、なかった場合は操作ぷよグループを作り直し、シーンに再追加します。

20130824085418.png

以下をgame.onloadの操作用ぷよグループを宣言した後に記述しましょう。

scene.addEventListener("enterframe", function() { // 1フレームごとに呼び出される関数を登録
    if (!pair.isFall) {                  // 操作ぷよの着地判定
        scene.removeChild(pair); // 操作ぷよをシーンから削除
        freeFall(field);                 // 自由落下
        chain(field);                    // 連鎖処理
        map.loadData(field);     // マップの再読み込み
        if (field[2][3] != -1) { // ゲームオーバー判定
            game.stop();
            console.log("Game Over");
        } else {
            /* 操作ぷよを更新、シーンに追加 */
            pair = createPair(game, map, field);
            scene.addChild(pair);
        }
    }
});

簡易版とことんぷよぷよ完成!!

どうでしたか?
一応これでぷよぷよのルールに沿ったゲームは完成しました。
しかし色々不満な部分も沢山あると思います。

  • 左回転できない
  • 右(左)をおしっぱで移動できない
  • 地面についてからの操作出来る時間が全くない
  • 連鎖が一瞬で終わってしまう
  • 壁際で回転できない
  • 挟まれた時に回転できない
  • 13、14段目が表示されている
  • などなどなど…

今回の記事のメインは最低限のぷよぷよの機能を持ったゲームを作ること、でした。ですので納得行かない部分は各自でプログラミングしてみてみてください。

このレシピを書いたシェフ

おっ立ち野郎

@ottatiyarou

コードレシピの管理人です。
「for文とか関数とかの基礎はやったけど、実際アプリとかどうやって作るのか分からない」を解決するようなサービスにしていきたいです。


このシェフが書いたレシピ