【簡単nodejsアプリ開発】NexeでCLI版スネークゲームを作ってみる・後編


※ 当ページには【広告/PR】を含む場合があります。
2021/12/07
【簡単nodejsアプリ開発】NexeでCLI版スネークゲームを作ってみる・前編
【nodejsシェルアプリ開発】vercel/pkgでCLI版スネークゲームを作ってみる
蛸壺の技術ブログ|NexeでCLI版スネークゲームを作ってみる・後編

前編の記事では、
「nexe」というCLIアプリケーションのビルドツールの導入と使用方法を説明していきました。

合同会社タコスキングダム|蛸壺の技術ブログ
【簡単nodejsアプリ開発】NexeでCLI版スネークゲームを作ってみる・前編

nodejsのユーティリティ・nexeを使って、簡単なCLIコンソールゲームのスネークゲームを作ってみます。

今回はその続きとして、nodejs版CLIスネークゲームを実装してプレーしてみましょう。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】nodejsをこれから学びたい人のためのオススメ書籍&教材特集

スネークゲームの実装

nexeのビルド方法などは前回説明した通りです。

ここでは完成品のコードを一気にお見せします。

            
            //👇プロセス終了処理(コールバックで)
process.on("exit", e => {
    //👇カーソルを表示状態に戻す
    process.stdout.write("\x1b[?25h");

    //👇画面を全てリセットしたい場合に利用
    //process.stdout.write("\x1b[2J");

    //👇ゲーム終了時にスコアだけ残したい場合に利用
    process.stdout.write("\x1b[1E");
    process.stdout.write("\x1b[J");
});
//Ctrl+Cでの終了処理
process.on("SIGINT", () => process.exit(0));

//👇標準出力の文字エンコード
process.stdin.setEncoding('utf8');
//👇一文字ずつ入力
process.stdin.setRawMode(true);
//👇キー入力待ち状態にする
process.stdin.resume();

//👇標準出力の1文字を延々と受け続ける関数オブジェクト(非同期)
const inputKey = (prompt) => new Promise(resolve => {
    const isRow = process.stdin.isRaw;
    const callBack = key => {
        process.stdin.off('data', callBack);
        process.stdin.pause();
        process.stdin.setRawMode(isRow);
        resolve(key);
    };
    process.stdin.setRawMode(true);
    process.stdin.resume();
    process.stdin.on('data', callBack);
});

//👇ゲーム画面を描画処理する関数オブジェクト
const refreshGame = () => {
    return (currentState, height, timerIdRef) => {
        //👇描画開始時にカーソルを一番上の左側に移動
        process.stdout.write(`\x1b[${height+1}F`);
        if (currentState) {
            //👇更新する内容があった場合に描画する
            process.stdout.write(`${currentState}`);
        } else {
            //👇更新する内容が無い場合にはタイマーを止め、プログラム終了
            clearInterval(timerIdRef);
            process.exit(2);
        }
    };
};

//👇ゲーム状態を組み立てる関数オブジェクト
// widthとheightで画面のサイズ調整可能
const makeState = (width, height) => {
    //👇画面の文字色・背景色
    const defaultStyle = '\x1b[42;30m';

    //👇ヘビやエサなどのシンボルを文字に置き換え
    const spaceChar = ' ';
    const snakeHead = '@';
    const snakeBody = 'o';
    const appleChar = '\x1b[31m' + 'b' + defaultStyle;

    //👇ゲームのスコア
    let score = 0;
    //👇ゲームの終了フラグ
    let isGameover = false;

    //👇ヘビの初期位置
    let snakeX = [Math.floor(width/2)];
    let snakeY = [Math.floor(height/2)];

    //👇エサをランダムに配置させる
    let appleX, appleY;
    const genApplePosition = () => {
        appleX = Math.floor(Math.random() * width);
        appleY = Math.floor(Math.random() * height);
    }
    genApplePosition();

    //👇ゲーム画面(文字列)の組立
    const composeGame = () => {
        let result = '\n' + defaultStyle;
        for (let i=0;i<height;i++) {
            for (let j=0;j<width;j++) {
                let tile = spaceChar;
                if (i == appleY && j == appleX) tile = appleChar;
                if (snakeX.length > 1) {
                    for (let k=1;k<snakeX.length;k++) {
                        if (i == snakeY[k] && j == snakeX[k]) {
                            tile = snakeBody;
                        }
                    }
                }
                if (i == snakeY[0] && j == snakeX[0]) {
                    tile = snakeHead;
                    if (i == appleY && j == appleX) {
                        score++;
                        snakeX.push(appleX);
                        snakeY.push(appleY);
                        genApplePosition();
                    }
                }
                result += tile;
            }
            result += '\n';
        }
        return result + '\x1b[m';
    };

    //👇ゲーム描画内容を更新
    let gameContent = composeGame();

    //👇ヘビのキーボード操作を処理する関数オブジェクト
    //u | UPキー(%1B%5BA)
    //d | DOWNキー(%1B%5BB)
    //r | RIGHTキー(%1B%5BC)
    //l | LEFTキー(%1B%5BD)
    //初回は上方向に移動
    let snakeDirection = 'u';
    const selectDirection = (key) => {
        if (/%1B%5BA/.test(`${key}`)) {
            if (snakeX.length > 1 && snakeDirection == 'd') {
                isGameover = true;
            }
            snakeDirection = 'u';
            return 'u'
        } else if (/%1B%5BB/.test(`${key}`)) {
            if (snakeX.length > 1 && snakeDirection == 'u') {
                isGameover = true;
            }
            snakeDirection = 'd';
            return 'd'
        } else if (/%1B%5BC/.test(`${key}`)) {
            if (snakeX.length > 1 && snakeDirection == 'l') {
                isGameover = true;
            }
            snakeDirection = 'r';
            return 'r'
        } else if (/%1B%5BD/.test(`${key}`)) {
            if (snakeX.length > 1 && snakeDirection == 'r') {
                isGameover = true;
            }
            snakeDirection = 'l';
            return 'l'
        } else {
            return ''
        }
    };

    //👇ヘビの座標値を更新する関数オブジェクト
    const updateXY = () => {
        for (let i=snakeX.length-1;i>=0;i--) {
            switch(snakeDirection) {
                case 'u':
                    if (i>0) {
                        snakeX[i] = snakeX[i-1];
                        snakeY[i] = snakeY[i-1];
                    } else {
                        snakeY[0]--;
                        if (snakeY[0] < 0) {
                            snakeY[0]++;
                            isGameover = true;
                        }
                    }
                    break;
                case 'd':
                    if (i>0) {
                        snakeX[i] = snakeX[i-1];
                        snakeY[i] = snakeY[i-1];
                    } else {
                        snakeY[0]++;
                        if (snakeY[0] >= height) {
                            snakeY[0]--;
                            isGameover = true;
                        }
                    }
                    break
                case 'r':
                    if (i>0) {
                        snakeX[i] = snakeX[i-1];
                        snakeY[i] = snakeY[i-1];
                    } else {
                        snakeX[0]++;
                        if (snakeX[0] >= width) {
                            snakeX[0]--;
                            isGameover = true;
                        }
                    }
                    break;
                case 'l':
                    if (i>0) {
                        snakeX[i] = snakeX[i-1];
                        snakeY[i] = snakeY[i-1];
                    } else {
                        snakeX[0]--;
                        if (snakeX[0] < 0) {
                            snakeX[0]++;
                            isGameover = true;
                        }
                    }
                    break;
                default:
                    break;
            }
        }
    };

    //👇ヘビが自分自身への衝突判定をする関数オブジェクト
    const checkCollision = () => {
        if (snakeX.length > 1) {
            for (let i=1;i<snakeX.length;i++) {
                if (snakeX[0] == snakeX[i] && snakeY[0] == snakeY[i]) {
                    isGameover = true;
                }
            }
        }
    };

    return (currentKey) => {
        if (currentKey) {
            //👇キーが何かしら押された場合にヘビの進行方向を更新
            selectDirection(escape(currentKey));
        } else {
            //キー入力が空の場合、ゲーム状態の更新
            updateXY();
            checkCollision();
            gameContent = `SCORE >> ${score}` + composeGame();
        }
        return isGameover ? null : gameContent;
    }
};

//👇メイン処理
(async () => {
    //👇画面の大きさと描画更新間隔時間を設定
    const width = 25, height = 18, speed = 100;

    const rg = refreshGame();
    const ms = makeState(width, height);
    const timerId = setInterval(() => rg(ms(), height, timerId), speed);

    //👇カーソルを隠す
    process.stdout.write( "\x1b[?25l" );
    //👇ゲームを連続で遊ぶ場合打ち込んだコマンド分一つ上にカーソルを戻す
    process.stdout.write(`\x1b[1F`);
    //👇カーソル以下の行を消去
    process.stdout.write("\x1b[K");

    let key;
    //👇無限ループ処理で、qキーを押せば終了
    while (['q'].indexOf(key) < 0) {
        key = await inputKey('');
        ms(key);
        //Ctrl+C(\u0003)がキー入力されたらタイマーを止め、ループを抜ける
        if (key === "\u0003") {
            clearInterval(timerId);
            process.exit(1);
        };
    };

    //👇タイマーを止める
    clearInterval(timerId);
    //👇プロセス終了
    process.exit(0);
})();
        

コードとともに実装のポイントをコメントで付けておきましたので、自分で作ってみたいときの参考にしていただけると良いと思います。

今回のnodejsでのCLIアプリケーションを作る際に、最大のポイントとなるのは
「ANSIエスケープシークエンス」の扱い方です。

合同会社タコスキングダム|蛸壺の技術ブログ
【シェルスクリプト実践講座】ANSIエスケープシークエンスを使おう①〜基本的な使い方

シェルアプリを作成する上で欠かせない「ANSIエスケープシークエンス」のテクニックを整理していきます。

ほとんどのOS標準搭載のターミナルが「エスケープシークエンス」の操作に対応していますが、OSによっては一部の機能は使えないこともしばしばですので、ちゃんと動くかどうかは実機での確認が必要です。

今回分かりにくいポイントかなと思うのは、ヘビの座標を配列として使っていることくらいかと思います。

合同会社タコスキングダム|蛸壺の技術ブログ

ここでは、上の図のように2つの配列
snakeXsnakeYをヘビの位置を表すXY座標のセットとみなしながら利用しています。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】nodejsをこれから学びたい人のためのオススメ書籍&教材特集

完成したゲームを試す

折角ですので、個人的にあまりゲームをする方は得意ではないですが、ちょっとだけ遊んでみましょう。

今回、nexeプロジェクトのエンドポイント名を
snake_nexeという名前でビルドすると、snake_nexeという名前の実行ファイルとして以下のように使えます。

            
            $ ./snake_nexe
        
ビルドターゲットに、手元の環境では64bit版のLinuxOSとして、以下のように動けば完成です。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】nodejsをこれから学びたい人のためのオススメ書籍&教材特集

まとめ

今回でnexeを使ったCLI版スネークゲームは完成しました。

細かいところはまだ改善する余地がありますが、基礎的なnodejsを使ったCLIアプリケーションの作成の流れは掴んでいただけたかと思います。

また今後、nexeで自作コマンドを作ってみる機会がありましたら、その都度ブログ記事でまとめていく予定です。
記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。

合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】nodejsをこれから学びたい人のためのオススメ書籍&教材特集