【ぷよプロ】ぷよぷよプログラミングを始めよう(開発編⑤)- モードの処理を実装してゲームを完成させよう(前半)

みなさんこんにちは、チロルです!

本記事は次の記事の続きです。

【ぷよプロ】ぷよぷよプログラミングを始めよう(開発編④)- 無限ループを実装してゲームが進行できるようにしよう

※【ぷよプロ】シリーズが初めての方はこちらの記事から

【ぷよプロ】ぷよぷよでプログラミングを学習する方法

前回までにぷよプロを初期表示して、その後ゲーム進行ができるところまで完了しました。

今回は「ぷよを実際に動かして消すことができる」ようにしましょう。

はじめに

たしか、前回までにループ処理の実装まで行いましたね。

今回はどういったことを実装していくんでしょうか??

ともとも
ともとも

今回は前回実装したループ処理で具体的に処理している箇所を見ていくよ!

前回はステータス遷移を詳しく見ていたけれど、各ステータスごとの処理を説明していくよ!

ステータスのおさらい

まずは前回まで見てきたステータスを振り返ってみましょう。それぞれのステータス(mode)と処理の概要は以下の通りでした。

各ステータス(`mode`)を切り替えることでゲームが遷移していくんでしたね。前回見た通り、ループ処理は1/60秒に1回実行されるので、ステータスが更新されると遅延なく次の処理が実行されます。

これらのステータスの中でも今回は次のステータスについて説明していきます。(残りは後半編で説明します)

  • start
  • checkFall
  • checkErase
  • erasing

ステータス:”start”

まず、ゲーム開始時のステータスは”start”となります。

“start”の処理は前回見た通り、ステータスを”checkFall”に更新するだけです。

Javascript
case 'start':
// 最初は、もしかしたら空中にあるかもしれないぷよを自由落下させるところからスタート
mode = 'checkFall';
break;

ステータス:”checkFall”

ステータス:”checkFall”では、落下判定を実施します。

Stage.checkFall()で落下するかどうかを判定して、まだ落下するのであればtrue、そうでなければ(着地していてもう落下することがない)falseを返し、モードを”checkErase”に更新します。

Javascript
case 'checkFall':
// 落ちるかどうか判定する
if(Stage.checkFall()) {
	mode = 'fall';
} else {
// 落ちないならば、ぷよを消せるかどうか判定する
	mode = 'checkErase';
}
break;

Stage.checkFall()

Stage.checkFall()の処理を見ていきましょう。

Stage.javaはステージの状態を管理しているファイルで、変数:boardでどこにぷよが配置されているのかを把握しています。board上のぷよ全てに対して落下可能か判定して、落下可能な位置まで再配置しています。そして、落下する対象のぷよがある場合は、trueを返すようにしています。

この”checkFall”の実施イメージは以下です。

checkFallが実行されると青丸が「ぷよ」を表していて、赤の位置に移動させます。また、落下可能判定はboardの下から順に実施しています。

具体的にコードすると以下のようになっています。

Javascript
// 自由落下をチェックする
    static checkFall() {
        this.fallingPuyoList.length = 0;
        let isFalling = false;
        // 下の行から上の行を見ていく
        for(let y = Config.stageRows - 2; y >= 0; y--) { 
            const line = this.board[y];
            for(let x = 0; x < line.length; x++) {
                if(!this.board[y][x]) {
                    // このマスにぷよがなければ次
                    continue;
                }
                if(!this.board[y + 1][x]) {
                    // このぷよは落ちるので、取り除く
                    let cell = this.board[y][x];
                    this.board[y][x] = null;
                    let dst = y;
                    while(dst + 1 < Config.stageRows && this.board[dst + 1][x] == null) {
                        dst++;
                    }
                    // 最終目的地に置く
                    this.board[dst][x] = cell;
                    // 落ちるリストに入れる
                    this.fallingPuyoList.push({
                        element: cell.element,
                        position: y * Config.puyoImgHeight,
                        destination: dst * Config.puyoImgHeight,
                        falling: true
                    });
                    // 落ちるものがあったことを記録しておく
                    isFalling = true;
                }
            }
        }
        return isFalling;
    }
ステージの下から落下判定を行う理由

上記コードの6行目の処理ですが、ここで下から処理をしているのは、処理対象の下に「ぷよ」があるかを利用してい処理判定しているからです。

for(let y = Config.stageRows - 2; y >= 0; y--) {

仮に上から処理をしていくと、次の場合に正しく処理が実施されないことになります。

1. 処理対象のぷよ(このぷよをAとする)の下に別のぷよ( このぷよをBとする )がある

2. Bが落下可能なぷよである

上記の場合、AがBの上で停止して、その後にBが落下するのでAとBの間に間ができてしまいます。

また、y = Config.stageRows - 2とするのは、次の2つの理由からです。

1. boardのy座標は 0 ~ Config.stageRows - 1 で、1を引く必要があるから

2. 一番下部のぷよは落下しない(判定する必要がない)ため下から2行目のぷよから判定するから

ステータス:”fall”

Stage.checkfall()で落下する対象の「ぷよ」がある場合、ステータスが”fall”になります。

ステータス:”fall”の処理は次の通りでした。

Stage.fall()で実際に落下する処理を実施します。先ほどのStage.checkFall()ではステージの座標の更新を実施しました。

このStage.fall()ではステージ上のぷよの座標の更新を行います。

Javascript
case 'fall':
if(!Stage.fall()) {
    // すべて落ちきったら、ぷよを消せるかどうか判定する
    mode = 'checkErase';
}
break;

Stage.fall()

Stage.fall()の処理を見ていきましょう。

Stage.fall()では以下2つの処理を実行しています。

  • 落下対象のぷよの座標をConfig.freeFallingSpeedだけ落下させる
  • 全てのぷよの落下が完了している場合、falseを返す。
  • 落下が完了していないぷよがある場合、trueを返す。

Config.freeFallingSpeed16pxなので、1/60秒ごとに16pxづつStage.checkFall()で算出した移動先のステージの座標まで落下することになります。

コードは次の通りです。

Javascript
// 自由落下させる
    static fall() {
        let isFalling = false;
        for(const fallingPuyo of this.fallingPuyoList) {
            if(!fallingPuyo.falling) {
                // すでに自由落下が終わっている
                continue;
            }
            let position = fallingPuyo.position;
            position += Config.freeFallingSpeed;
            if(position >= fallingPuyo.destination) {
                // 自由落下終了
                position = fallingPuyo.destination;
                fallingPuyo.falling = false;
            } else {
                // まだ落下しているぷよがあることを記録する
                isFalling = true;
            }
            // 新しい位置を保存する
            fallingPuyo.position = position;
            // ぷよを動かす
            fallingPuyo.element.style.top = position + 'px';
        }
        return isFalling;
    }
Stage.checkFall()とStage.fall()で処理を分けている理由

Stage.checkFall()でステージ上のぷよ配置を更新、Stage.fall()で実際のぷよの座標を更新していますが、どうして処理を分ける必要があるのでしょうか?

確かに Stage.checkFall() で各ぷよがどこまで移動するのか判明しているのですから、この中で各ぷよの座標も更新してしまうこともできます。

ただ、このような実装にすると、落下する処理が一瞬(正確には1/60秒)で完了してしまい、「早すぎる」のです。そのため、Stage.fall()で少しずつ落下させていくことで「落ちている感」を表現しているのです。

ステータス:”checkErase”

ぷよの落下処理が完了すると、ステータスが”checkErase”になります。

ステータスが”checkErase”では次の処理を行います。

  1. 消すことができる「ぷよ」(消すことができるぷよの個数・ぷよの色)を判定する。
  2. (消すことができる場合)連鎖(連続してぷよを消すこと)の数を把握する
  3. (消すことができる場合)スコアを算出する
  4. (消すことができない or 消す対象のぷよがなくなった場合)全消し(ステージ上のぷよがすべて消すこと)の判定をする
  5. (消すことができない or 消す対象のぷよがなくなった場合)新しいぷよをステージに追加する

実際のコードは次の通りです。

Javascript
case 'checkErase':
// 消せるかどうか判定する
const eraseInfo = Stage.checkErase(frame);
if(eraseInfo) {
    mode = 'erasing';
    combinationCount++;
    // 得点を計算する
    Score.calculateScore(combinationCount, eraseInfo.piece, eraseInfo.color);
    Stage.hideZenkeshi();
} else {
    if(Stage.puyoCount === 0 && combinationCount > 0) {
        // 全消しの処理をする
        Stage.showZenkeshi();
        Score.addScore(3600);
    }
    combinationCount = 0;
    // 消せなかったら、新しいぷよを登場させる
    mode = 'newPuyo'
}
break;

Stage.checkErase(frame)

Stage.checkErase() でステージ上に消すことができる「ぷよ」があるかどうかを判定します。

ここの処理はかなり複雑なように見えますがやっていることは要は次の処理です。

  • ステージ上の削除できる「ぷよ」をerasingPuyoInfoListに入れる

この erasingPuyoInfoList後続の処理で消すように実装していきます。

そして、erasingPuyoInfoList は大きく次の手順で算出しています。

  1. ステージの座標(x, y)を渡すと、その座標に含まれる「ぷよ」及び4方向(上下左右)に隣接する同じ色の「ぷよ」をsequencePuyoInfoListに配置し、ステージ上からは削除する関数を定義する。
  2. ステージ上の全座標に対して①の処理を実施する。(次の③~⑤の処理を各セルごとに実施)
  3. sequencePuyoInfoListに含まれる「ぷよ」が4つかどうか判定する。
  4. ③が4つの場合、erasingPuyoInfoListに追加する。
  5. ③が3追加の場合、existingPuyoInfoListに追加する。
  6. ⑤のexistingPuyoInfoListをステージに戻す。
  7. ④のerasingPuyoInfoListを返す。

コードは次の通りです。

Javascript
// 消せるかどうか判定する
static checkErase(startFrame) {
    this.eraseStartFrame = startFrame;
    this.erasingPuyoInfoList.length = 0;

    // 何色のぷよを消したかを記録する
    const erasedPuyoColor = {};

    // 隣接ぷよを確認する関数内関数を作成
    const sequencePuyoInfoList = [];
    const existingPuyoInfoList = [];
    const checkSequentialPuyo = (x, y) => {
        // ぷよがあるか確認する
        const orig = this.board[y][x];
        if(!orig) {
            // ないなら何もしない
            return;
        }
        // あるなら一旦退避して、メモリ上から消す
        const puyo = this.board[y][x].puyo;
        sequencePuyoInfoList.push({
            x: x,
            y: y,
            cell: this.board[y][x]
        });
        this.board[y][x] = null;

        // 四方向の周囲ぷよを確認する
        const direction = [[0, 1], [1, 0], [0, -1], [-1, 0]];
        for(let i = 0; i < direction.length; i++) {
            const dx = x + direction[i][0];
            const dy = y + direction[i][1];
            if(dx < 0 || dy < 0 || dx >= Config.stageCols || dy >= Config.stageRows) {
                // ステージの外にはみ出た
                continue;
            }
            const cell = this.board[dy][dx];
            if(!cell || cell.puyo !== puyo) {
                // ぷよの色が違う
                continue;
            }
            // そのぷよのまわりのぷよも消せるか確認する
            checkSequentialPuyo(dx, dy);
            
        };
    };
    
    // 実際に削除できるかの確認を行う
    for(let y = 0; y < Config.stageRows; y++) {
        for(let x = 0; x < Config.stageCols; x++) {
            sequencePuyoInfoList.length = 0;
            const puyoColor = this.board[y][x] && this.board[y][x].puyo;
            checkSequentialPuyo(x, y);
            if(sequencePuyoInfoList.length == 0 || sequencePuyoInfoList.length < Config.erasePuyoCount) {
                // 連続して並んでいる数が足りなかったので消さない
                if(sequencePuyoInfoList.length) {
                    // 退避していたぷよを消さないリストに追加する
                    existingPuyoInfoList.push(...sequencePuyoInfoList);
                }
            } else {
                // これらは消して良いので消すリストに追加する
                this.erasingPuyoInfoList.push(...sequencePuyoInfoList);
                erasedPuyoColor[puyoColor] = true;
            }
        }
    }
    this.puyoCount -= this.erasingPuyoInfoList.length;

    // 消さないリストに入っていたぷよをメモリに復帰させる
    for(const info of existingPuyoInfoList) {
        this.board[info.y][info.x] = info.cell;
    }

    if(this.erasingPuyoInfoList.length) {
        // もし消せるならば、消えるぷよの個数と色の情報をまとめて返す
        return {
            piece: this.erasingPuyoInfoList.length,
            color: Object.keys(erasedPuyoColor).length
        };
    }
    return null;
    }

Score.calculateScore ( combinationCount, eraseInfo.piece, eraseInfo.color )

実際に「ぷよ」の削除対象がある場合、削除対象の「ぷよ」の隣接数、色、および、連鎖数に応じてスコアを算出します。

実はこの計算式は少し謎(?)なのですが、次のように算出されます。

  • 追加するスコア = スケール(scale) × 消したぷよの数(piece) × 10

そしてスケールは次のように算出します。

  • スケール = 連鎖ボーナス + 消したぷよの数ボーナス + ぷよの色ボーナス

そうして上記ボーナスは連鎖数が24以上、消した「ぷよ」の数が12以上、ぷよの色が7種以上の場合ボーナスが発生するようになっています。(なので基本的にはボーナスが発生しないつくりとなってしまっています)

実際のコードは次の通りです。

javascript
static calculateScore (rensa, piece, color) {
        rensa = Math.min(rensa, Score.rensaBonus.length - 1);
        piece = Math.min(piece, Score.pieceBonus.length - 1);
        color = Math.min(color, Score.colorBonus.length - 1);
        let scale = Score.rensaBonus[rensa] + Score.pieceBonus[piece] + Score.colorBonus[color];
        if(scale === 0) {
            scale = 1;
        }
        this.addScore(scale * piece * 10);
    }

ステータス:”erasing”

Stage.checkErase()で削除対象の「ぷよ」がある場合、ステータスが”erasing”になります。

削除対象の「ぷよ」がある場合、すぐに削除されるわけではなく一定時間経過後に削除処理が始まります。

具体的にはConfig.eraseAnimationDurationのフレーム数経過後に削除が始まります。デフォルトでは30フレームとなっているので、30/60秒 = 0.5秒経過後に削除が開始することになります。

削除したら「ぷよ」がなくなるので、ステータスを”checkFall”に変更して再度落下する「ぷよ」がないかを確認します。

実際のコードは次の通りです。

Javascript
case 'erasing':
if(!Stage.erasing(frame)) {
    // 消し終わったら、再度落ちるかどうか判定する
    mode = 'checkFall';
}
break;

Stage.erasing(frame)

Stage.checkErase(frame)で渡したframe(削除対象決定時のフレーム)とStage.erasing(frame)で渡したframe(削除処理時のフレーム)を利用して削除するまでの経過時間を計測しています。

経過時間が0.5秒(30フレーム)となると、実際に削除処理を実施します。

※処理を見ると経過したフレーム数で分岐して処理をしていますが、特に意識する必要はないと思います。(もしこの箇所の意味が分かる方いたら教えてほしいです)

コードは次の通りです。

Javascript
// 消すアニメーションをする
static erasing(frame) {
const elapsedFrame = frame - this.eraseStartFrame;
const ratio = elapsedFrame / Config.eraseAnimationDuration;
if(ratio > 1) {
    // アニメーションを終了する
    for(const info of this.erasingPuyoInfoList) {
        var element = info.cell.element;
        this.stageElement.removeChild(element);
    }
    return false;
} else if(ratio > 0.75) {
    for(const info of this.erasingPuyoInfoList) {
        var element = info.cell.element;
        element.style.display = 'block';
    }
    return true;
} else if(ratio > 0.50) {
    for(const info of this.erasingPuyoInfoList) {
        var element = info.cell.element;
        element.style.display = 'none';
    }
    return true;
} else if(ratio > 0.25) {
    for(const info of this.erasingPuyoInfoList) {
        var element = info.cell.element;
        element.style.display = 'block';
    }
    return true;
} else {
    for(const info of this.erasingPuyoInfoList) {
        var element = info.cell.element;
        element.style.display = 'none';
    }
    return true;
}
    }

最後に

今回は各ステータスごとの細かい処理を見ていきました。

主に落下処理・削除処理・得点処理を中心に解説しましたが、次回はぷよの新規作成処理・ユーザ操作処理・終了判定処理を解説していきます。

何か追加で解説が欲しいところがあれば、コメントいただけると嬉しいです!では!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です