직전 상태를 완전히 복원하기

C++Beginner
지금 연습하기

소개

C++ 소코반 게임에 전체 값 스냅샷과 안전한 여러 단계 실행 취소를 추가합니다.

고정 지형과 움직이는 객체를 분리한 작은 터미널 게임을 다룹니다. 핵심 C++ 함수를 구현한 뒤 실제로 플레이하며 동작을 확인합니다.

올바른 이동 전에 상태 저장하기

이번 단계에서는 완전한 직전 상태를 기록해, 나중의 실행 취소가 걷기와 상자 밀기를 모두 되돌리도록 합니다. 이 실습은 자체 파일을 초기화하며 이전 실습 파일이 필요하지 않습니다. Terminal 탭이나 WebIDE의 Terminal > New Terminal에서 시작하세요.

cd /home/labex/project/sokoban

완성된 sample은 학습자 엔진과 별개입니다. 실행하여 d, d, 이어서 u를 각각 Enter를 눌러 입력하고, 완료 상태와 플레이어 및 상자가 승리를 만든 밀기 이전 값으로 돌아가는 모습을 살펴보세요. q로 종료합니다.

./sample

자신의 engine.cpp에는 올바른 이동과 완료 판단이 이미 있지만 이력을 기록하지 않으며 undo()는 항상 거부합니다. 내용을 확인하세요.

cat engine.cpp

준비된 Game 클래스는 engine.hpp에 선언되어 있습니다. Point는 정수 좌표 x와 y를 보관합니다. x는 오른쪽으로, y는 아래쪽으로 증가합니다. State는 플레이어와 모든 상자를 담은 std::vector<Point>를 보관합니다. terrain[y][x]에는 벽 #, 공백, 목표 .만 있습니다. const는 생성 이후 지형을 읽기 전용으로 만들어 움직이는 상자가 목표를 지우지 못하게 합니다. 화면을 그릴 때 두 층을 합칩니다. *는 목표 위의 상자, +는 목표 위의 플레이어입니다.

floor(point)는 좌표가 보드 안에 있고 벽이 아닌지 알려 줍니다. boxAt(point)는 상자의 vector 인덱스를 반환하며, 해당 칸에 상자가 없으면 -1을 반환합니다. 이 보조 함수와 로딩, 화면 출력, 명령 읽기는 준비되어 있습니다. engine.cpp에서 move, won, 이후에 undo를 구현합니다. engine.hpp, support.cpp, main.cpp, levels/, sample은 보존하세요. 보조 함수를 추가하고 동등한 알고리즘을 사용해도 됩니다. 검사는 특정 표기나 이동 경로가 아니라 동작을 평가합니다.

지원하는 맵은 벽으로 둘러싸인 직사각형 ASCII 격자이며, 최소 3행 3열입니다. 플레이어는 정확히 하나, 상자는 하나 이상이고 목표 수는 상자 수와 같으며 객체가 겹치지 않습니다. 기호는 벽 #, 바닥인 공백, 목표 ., 플레이어 @, 상자 $, 목표 위의 플레이어 +, 목표 위의 상자 *입니다. 풀 수 없는 맵이나 시작부터 완성된 맵도 허용됩니다. 준비된 로더는 다른 입력을 거부하므로 파서를 작성할 필요는 없습니다.

move(dx, dy)는 (1,0), (-1,0), (0,1), (0,-1)만 받습니다. 다른 모든 정수 쌍은 상태나 이력을 바꾸지 않고 false를 반환해야 합니다. 올바른 동작은 true를 반환하고, 허용되지 않는 동작은 false를 반환하며 아무것도 바꾸지 않습니다. 상자 vector의 순서는 칸의 점유 관계에는 중요하지 않습니다. 이후 실행 취소는 그 동작 전에 실제로 저장한 전체 상태를 당시 상자 순서까지 포함하여 복원합니다.

std::vector<State> history는 전체 상태를 순서대로 담는 모음입니다. history.push_back(state)는 값 복사본을 저장합니다. 나중에 state.player나 state.boxes의 요소를 바꾸더라도 앞서 저장한 복사본은 바뀌지 않습니다. 플레이어만 저장하면 밀었던 상자의 위치를 잃습니다. 올바른 플레이로 고정 지형을 바꿀 수 없으므로 지형은 복사하지 않습니다. 완료 여부는 별도의 플래그로 저장하지 않고 지형과 상자에서 계산합니다.

거부한 동작이 실행 취소 자리를 차지해서는 안 됩니다. 스냅샷은 모든 거부 분기 뒤이면서 첫 변경 전에 저장합니다. 방향, 벽, 두 번째 상자나 막힌 도착 칸 때문에 이동을 거부한다면 유용한 스냅샷이 없습니다. 시작 이력은 비어 있습니다.

다음 검사는 시작 코드에서 의도적으로 실패하며 walk records its entire before state를 표시합니다.

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

아래 전체 파일은 올바른 동작마다 스냅샷 하나를 추가하며, 실행 취소는 여전히 미완성입니다. cat >는 파일을 교체하고, 따옴표로 감싼 here-document는 끝의 CPP 줄까지 C++를 그대로 기록합니다.

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;
    history.push_back(state);
    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

C++17과 컴파일러 경고를 사용해 빌드하세요. 잘못 입력한 줄이 있으면 그 줄을 고치거나 전체 교체를 반복한 다음 다시 빌드합니다.

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

d, d, d를 각각 Enter를 눌러 입력하세요. 이력은 걷기, 목표로 밀기, 목표에서 밀어내기에 따라 1, 2, 3이 됩니다. 또 다른 d는 Blocked를 표시하고 History: 3을 유지합니다. u를 입력하세요. 다음 단계에서 실행 취소를 구현하기 전까지는 No earlier move가 정상이며 보드는 바뀌지 않습니다. q를 입력합니다.

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

All history checks passed는 올바른 동작이 실제의 완전한 직전 값을 저장하고, 거부한 동작은 항목을 추가하지 않았다는 뜻입니다. 실행 취소가 작동한다는 뜻은 아직 아닙니다.

최신 스냅샷 복원 후 제거하기

이번 단계에서는 실행 취소를 구현하고 여러 번 되돌리면서 플레이합니다. Terminal의 /home/labex/project/sokoban에서 계속하세요.

history.empty()는 이전의 올바른 동작이 없음을 확인합니다. 그렇다면 아무것도 바꾸지 않고 false를 반환합니다. 그렇지 않으면 history.back()이 최신 스냅샷을 가리킵니다. 이를 state에 대입하면 값 복사를 통해 플레이어와 모든 상자를 복원합니다. 복사한 뒤에만 pop_back()이 사용한 스냅샷을 지웁니다. 빈 vector의 back()에 접근하는 것은 잘못된 동작이므로 보호 조건이 먼저 필요합니다. 실행 취소는 새 이력 항목을 추가하지 않습니다.

나머지 이동 처리는 같습니다. 파일 전체를 바꾸세요.

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;
    history.push_back(state);
    if (box >= 0) state.boxes[box] = beyond;
    state.player = next;
    return true;
}
bool Game::undo() {
    if (history.empty()) return false;
    state = history.back();
    history.pop_back();
    return true;
}
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

u를 입력하면 No earlier move가 표시되며 시작 보드는 변하지 않습니다. d, d로 게임을 완성하세요. 다시 d를 입력하면 상자가 목표를 떠나고 플레이어 아래의 목표가 +로 표시되며 승리 상태가 사라집니다. 또 다른 d를 시도하면 막히고 이력은 3으로 유지됩니다.

이제 u를 입력하면 상자를 포함해 승리한 보드로 돌아갑니다. 두 번째 u는 승리를 만든 밀기 이전 위치를 복원하며 Not solved가 표시됩니다. 세 번째 u는 플레이어의 시작 위치로 돌아가 History: 0이 됩니다. 다시 u를 입력해도 안전하게 No earlier move를 표시합니다. q를 입력하세요.

./sokoban levels/warehouse.txt

w, s, d, d, w를 각각 Enter를 눌러 시도합니다. 두 상자가 목표에 놓입니다. u를 다섯 번 입력하면 매번 완전한 직전 상태를 복원하여 마침내 두 상자와 플레이어가 시작 위치로 돌아갑니다. 추가 실행 취소는 안전하게 거부합니다. 다른 올바른 경로는 d, d, w, s, a, a, w입니다. 엔진은 고정 순서만이 아니라 어느 경로든 지원해야 합니다. q를 입력하세요.

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

All undo checks passed는 서로 다른 경로에서 실제 직전 상태 복원, 빈 이력, 잘못된 동작에서 이력 보존, 목표 떠나기, 승리 취소를 확인합니다. 검사기는 격리된 게임에서 원래 함수와 이미 빌드된 sokoban 실행 파일을 사용합니다. 편집 후에는 다시 빌드하여 플레이할 프로그램을 소스와 일치시키세요. 검사기는 진행 중인 세션에 이동을 보내거나 진행 상황을 덮어쓰지 않습니다.

일반적인 오류에서 복구할 때, ./sokoban levels/missing.txt 같은 오타는 Cannot open level: ...를 출력하고 종료합니다. levels/warehouse.txt로 명령을 다시 실행하세요. 실행 중인 게임 안에서 명령을 잘못 입력하면 Use one command per line...를 출력하고 상태를 바꾸지 않습니다. 올바른 한 글자 명령을 입력하면 됩니다. 언제든 q로 나가 필요하면 소스를 고치고 다시 빌드한 뒤, 원래 레벨 파일을 바꾸지 않고 메모리에 새 게임을 시작할 수 있습니다.

요약

올바른 변경 전에 값 스냅샷을 저장하고 모든 움직이는 객체를 복원했으며, 실행 취소 후 승리 여부를 다시 판단했습니다. 잘못된 동작은 게임 상태와 이력을 모두 그대로 둡니다.