すべての移動を盤面の規則に従わせる

C++Beginner
オンラインで実践に進む

はじめに

C++ の端末で遊べる倉庫ゲームに、正しい歩行と 1 個の箱を押す処理を実装します。

固定地形と動くオブジェクトを分離した、小さな端末ゲームを扱います。中心となる C++ 関数を実装し、実際に遊んで動作を確認します。

壁を通り抜けずに歩く

このステップでは完成した例で遊んだ後、自分のプレイヤーの移動が盤面の規則に従うようにします。Terminal タブから始めてください。WebIDE エディターを使う場合は Terminal > New Terminal を開きます。以下のコマンドはすべてそのシェルで実行します。cd はそのシェルの作業ディレクトリを変更します。新しい端末セッションには引き継がれません。

cd /home/labex/project/sokoban

用意された sample は完成したデモであり、未完成の自分のプログラムとは別です。./ は現在のディレクトリにあるプログラムを実行します。

./sample

d を入力して Enter を押し、もう一度 d を入力して Enter を押します。この 2 手で箱に近づき、箱をゴールに押し込みます。Solved! が表示され、$ が * に変わります。u を入力して Enter を押すと、プレイヤーと箱の両方が直前のマスに戻り、表示が Not solved に変わります。q を入力して Enter を押すとシェルに戻ります。コマンドは 1 行に 1 文字です。空または不明なコマンドはヘルプを表示し、何も変更しません。この参考プログラムは完成形を示すもので、実行しても自分のエンジンを完成させたことにはなりません。

用意された Game クラスは engine.hpp で宣言されています。Point は整数座標 x と y を保持し、x は右向き、y は下向きに増加します。State はプレイヤーと、すべての箱を含む std::vector<Point> を保持します。terrain[y][x] に含まれるのは壁 #、空白、ゴール . だけです。const により構築後の地形は読み取り専用となるため、箱が動いてもゴールは消えません。描画時には 2 つの層を重ねます。* はゴール上の箱、+ はゴール上のプレイヤーです。

floor(point) は座標が盤面内にあり、壁ではないかを返します。boxAt(point) は箱の vector 内の添字を返し、そのマスに箱がなければ -1 を返します。これらの補助関数、読み込み、描画、コマンドの読み取りは準備済みです。engine.cpp で move、won、後で undo を実装します。engine.hpp、support.cpp、main.cpp、levels/、sample は保持してください。補助関数の追加や同等のアルゴリズムの使用は可能です。検証は動作を評価し、特定の書き方や手順は要求しません。

用意された宣言と初期コードを読みます。

cat engine.hpp
cat engine.cpp

初期プログラムはすべての移動を拒否します。そのため、この意図的な自己検証では FAIL walk changes only the player と表示され、ゼロ以外の終了状態になります。これは実装が残っているという意味で、環境が壊れているわけではありません。

bash /opt/labex-sokoban/check.sh walk

対応するマップは、壁で囲まれた、少なくとも 3 行 3 列の長方形 ASCII 格子です。プレイヤーはちょうど 1 人、箱は 1 個以上で、ゴール数と箱数が等しく、オブジェクトは重複しません。記号は壁 #、床の空白、ゴール .、プレイヤー @、箱 $、ゴール上のプレイヤー +、ゴール上の箱 * です。解けないマップや、最初から完成しているマップも認められます。準備済みのローダーはそれ以外の入力を拒否します。パーサーを書く必要はありません。

move(dx, dy) が受け付けるのは (1,0)、(-1,0)、(0,1)、(0,-1) のみです。それ以外の整数の組は、状態と履歴を変えずに false を返す必要があります。合法な操作は true を返し、不正な操作は false を返して何も変更しません。箱の vector 内の順序はマスの占有関係には影響しません。後で実装する取り消しは、その操作の前に実際に保存した完全な状態を、その時点の箱の順序も含めて復元します。

まず歩行だけを実装します。Point{...} は座標の値を構築します。&& は両方の条件が必要、|| はいずれかが満たされればよいことを表します。座標を加算する前に 4 方向を明示的に列挙することで、非常に大きい整数も安全に拒否できます。>= 0 は箱に占有されたマスを表します。この段階では箱は歩行を妨げます。押す処理は次に実装します。return false の分岐では状態を一切変えません。

端末からファイル全体を書くには、cat > engine.cpp でテキストをそのファイルにリダイレクトして古い内容を置き換えます。引用符付きのヒアドキュメントマーカー <<'CPP' は、CPP だけの行まで、以降の行をそのままシェルに読み取らせます。終了マーカーを含むコマンド全体をコピーしてください。

cat > engine.cpp <<'CPP'
#include "engine.hpp"

bool Game::move(int dx, int dy) {
    if (!((dx == -1 && dy == 0) || (dx == 1 && dy == 0) ||
          (dx == 0 && dy == -1) || (dx == 0 && dy == 1))) return false;
    Point next{state.player.x + dx, state.player.y + dy};
    if (!floor(next) || boxAt(next) >= 0) return false;
    state.player = next;
    return true;
}
bool Game::undo() { return false; }
bool Game::won() const { return false; }
CPP

3 つのソースファイルをコンパイルします。-std=c++17 は C++17 を選び、-Wall -Wextra は有用な警告を有効にし、-o sokoban は実行ファイル名を指定します。コンパイルエラーには行番号が表示されます。engine.cpp の該当行を修正するか、ファイル全体の置き換えをやり直し、同じビルドを再実行してください。

g++ -std=c++17 -Wall -Wextra engine.cpp support.cpp main.cpp -o sokoban
./sokoban

a を入力して Enter を押すと、左の壁によって Blocked と表示され、盤面は変わりません。d を入力して Enter を押すと、プレイヤーが右に歩きます。2 回目の d はまだ箱に阻まれます。このステップでは想定どおりです。u は No earlier move と表示します。取り消しはまだ実装していません。q で終了します。

bash /opt/labex-sokoban/check.sh walk

All walk checks passed は 4 方向の歩行と安全な拒否が正しいことを確認します。検証では一時ディレクトリに実際のエンジンをコンパイルし、既存の sokoban 実行ファイルも隔離したゲームで実行します。編集後は sokoban を再ビルドしてください。実行ファイルがない、または古い場合は未完成です。検証はプレイ中のゲームを動かしたり、ソースを変更したりしません。

箱を 1 個押してゴールを保持する

このステップでは、箱を 1 個押し、その実際の位置から完成を判断できるようエンジンを拡張します。Terminal の /home/labex/project/sokoban で続けてください。実行するたびにメモリ内で新しいゲームが始まります。プログラムの編集や再ビルドは、プレイ中のゲームを保存しません。

押す前に、箱のさらに先のマスを確認します。そこは床かゴールで、ほかの箱がない必要があります。まず拒否条件をすべて調べ、その後で箱とプレイヤーを一緒に更新します。これにより、壁を通して押したり、同じ操作で隣接する 2 個の箱をまとめて押したりすることを防ぎます。-1 が箱の不在を表すため、箱の添字は int です。box >= 0 を確認してから vector の添字として使ってください。

won() const はゲームを変更せずに観察します。範囲 for ループ for (Point box : state.boxes) はすべての箱の座標を値として取り出します。すべての箱が固定地形の . 上にある必要があり、2 箱のマップで 1 個だけを置いても不十分です。本課の契約では、箱の vector が空なら完成したゲームにはなりません。勝利は毎回計算するため、箱がゴールを離れると再び偽になります。このラボの undo はまだ意図的に拒否します。履歴は次のラボの新しい課題です。

engine.cpp を移動と完成判定の完全な実装に置き換えます。

cat > engine.cpp <<'CPP'
#include "engine.hpp"

bool Game::move(int dx, int dy) {
    if (!((dx == -1 && dy == 0) || (dx == 1 && dy == 0) ||
          (dx == 0 && dy == -1) || (dx == 0 && dy == 1))) return false;
    Point next{state.player.x + dx, state.player.y + dy};
    if (!floor(next)) return false;
    int box = boxAt(next);
    Point beyond{next.x + dx, next.y + dy};
    if (box >= 0 && (!floor(beyond) || boxAt(beyond) >= 0)) return false;
    if (box >= 0) state.boxes[box] = beyond;
    state.player = next;
    return true;
}
bool Game::undo() {
    return false;
}
bool Game::won() const {
    if (state.boxes.empty()) return false;
    for (Point box : state.boxes) {
        if (terrain[box.y][box.x] != '.') return false;
    }
    return true;
}
CPP
g++ -std=c++17 -Wall -Wextra engine.cpp support.cpp main.cpp -o sokoban
./sokoban

d、d をそれぞれ Enter で入力します。盤面に * と Solved! が表示されます。さらに d を入力すると、箱がゴールを離れ、プレイヤーがその上に + として立ち、表示は Not solved に戻ります。もう一度 d を入力しても、箱が壁に入るため拒否されます。固定ゴールはプレイヤーの下に残っています。q を入力します。

ファイルパスをプログラムの省略可能な引数として渡し、用意された 2 箱の倉庫を試します。

./sokoban levels/warehouse.txt

解ける手順の一例は w、s、d、d、w で、それぞれ Enter を押します。ほかの合法な手順でも構いません。最短解は要求されません。両方の箱が * になって初めて Solved! が表示されます。q を入力します。

レベルのパスを間違えると、Cannot open level: ... を表示して終了し、レベルファイルは変更されません。この通常のエラーを意図的に確認するため、次を実行します。

./sokoban levels/missing.txt

パスを levels/warehouse.txt にして先ほどのコマンドを繰り返すと復帰できます。ファイル名を直すだけで、環境をリセットする必要はありません。レベルを再開すると、メモリ内で新しいゲームが始まります。

bash /opt/labex-sokoban/check.sh move

All move checks passed は合法な歩行と押し、不正な操作、ゴールの保持、初期状態で完成しているマップ、異なる 2 箱の解法を検証します。エンジンと準備済みファイルを保持してください。実際に遊べる移動システムが完成しました。

まとめ

固定地形と動くオブジェクトを分離し、無効な操作を副作用なしで拒否して、2 つの小さな倉庫を完成させました。次の独立したラボでは履歴と取り消しを追加します。