모래게임 Sandrop TIL 1
같은 규칙이 두 곳에 있을 때
규칙을 두 번 구현하면 반드시 갈라진다
이 게임의 규칙은 두 군데에 있다. 게임 본체와 레벨을 자동으로 만드는 도구다.
나뉜 데는 이유가 있다. 게임은 Flutter로 만들어 폰에서 돌아가야 하니 Dart다. 레벨 생성기는 폰에 들어가지 않는다 — 개발 중에 컴퓨터에서 돌려 레벨 파일 500개를 만들어 두고, 앱은 그 결과만 읽는다.
레벨을 앱에서 그때그때 만들 이유가 없다. 만들려면 봇을 수만 번 돌려야 하는데 그 값을 사용자 폰이 치를 이유가 없고, 판이 매번 달라야 할 게임도 아니다. 미리 만들어 두면 품질을 확인하고 내보낼 수 있다. 그쪽은 시뮬레이션과 이미지 처리라 그런 도구가 잘 갖춰진 Python이 맞았다.
문제는 레벨 생성기도 “이 판을 깰 수 있는가”를 판정해야 한다는 것이다. 그러려면 게임과 같은 규칙을 알아야 한다. 결국 같은 규칙을 두 번 짠 셈이고, 한쪽만 고치면 게임과 생성기가 서로 다르게 동작한다.
사람이 눈으로 대조하는 것으로는 못 막는다고 봤다. 그래서 fixture — 입력과 기대 출력을 미리 적어 둔 테스트 데이터 — 를 하나 두고 양쪽이 각자 돌려 맞춰 보게 하기로 했다. 한쪽이 어긋나면 그쪽 테스트가 깨진다.
- 같은 규칙이 두 곳에 있으면 갈라지는 것은 시간 문제다. 사람이 아니라 기계가 대조하게 만든다
- 규칙을 바꿀 때 순서를 고정했다. 스펙 문서 → 양쪽 코드 → 공통 fixture. 문서가 먼저인 이유는 두 구현이 참고할 원본이 하나여야 하기 때문이다
통과해도 의미가 없는 테스트가 있다
fixture를 만들 때 함정이 하나 있었다. 벽 규칙을 검사하려고 만든 케이스인데, 벽을 아예 무시하고 짜도 우연히 같은 결과가 나오는 배치였다. 그러면 벽 규칙이 망가져도 테스트가 통과한다.
- 테스트는 틀렸을 때 틀렸다고 알려 줘야 값어치가 있다. “통과했다”는 것만으로는 아무것도 보장하지 않는다
- 케이스를 만들 때 “이게 깨지면 이 테스트가 정말 빨개지나”를 한 번 물어본다
난이도를 재는 법
재는 도구의 수준이 지표를 만든다
레벨 난이도를 내가 직접 매길 수는 없다. 500판을 다 해 볼 수도 없고, 해 본다 해도 그날 컨디션에 따라 기준이 흔들린다. 같은 판을 같은 방식으로 여러 번 재는 일은 사람이 못 한다.
그래서 봇에게 여러 번 풀게 하고 깬 비율로 난이도를 정하기로 했다. 그런데 봇을 어떻게 만드느냐에 따라 결과가 통째로 달라졌다.
늘 최선을 고르는 봇은 거의 모든 레벨을 100% 깬다. 쉬운 판과 어려운 판이 똑같이 100%라 구분이 안 된다. 반대로 아무렇게나 두는 봇은 어려운 판을 전부 0%로 만든다. 이것도 구분이 안 된다.
양쪽 끝은 다 쓸모가 없었다. 필요한 건 평균적인 사람 — 대체로 잘하다 가끔 실수하는 봇이다. 그래서 ε-greedy(엡실론 그리디 — 대부분은 최선을 고르되 작은 확률로 아무거나 고르는 방식)를 쓰고, 실수 확률을 30~35%로 뒀다.
- 측정 도구가 대상보다 너무 좋거나 너무 나쁘면 지표가 평평해진다. 재려는 범위 안에서 갈리는 도구를 골라야 한다
- 사람을 흉내 내는 지표는 “완벽한 사람”이 아니라 “보통 사람”을 기준으로 삼는다
어려워지지 않는 도안이 있다
측정하다 알게 된 것 둘. 클리어율을 가장 크게 좌우한 것은 벽 배치가 아니라 바스켓을 몇 조각으로 쪼갰는가였다. 벨트 자리가 붐빌수록 어려워진다.
그리고 색이 두 개뿐인 도안은 어떤 배치로도 잘 안 어려워졌다. 색이 층층이 교차하지 않아서 “부을 데가 없어 기다리는” 상황이 안 생긴다.
- 난이도를 만드는 손잡이가 여럿일 때, 어느 것이 실제로 크게 움직이는지는 재봐야 안다. 짐작한 것(벽)과 달랐다
- 소재 자체에 한계가 있으면 장치로 못 넘는다. 어려운 자리에 쓸 도안은 처음부터 골라 둬야 한다
비싼 측정 앞에 싼 필터를 둔다
클리어율을 재려면 봇을 수십 번 돌려야 해서 레벨 하나에 분 단위가 걸린다. 후보를 전부 이렇게 재면 생성이 끝나지 않는다.
그래서 시뮬레이션 없이 계산되는 값을 먼저 봤다. 보드가 빌 때까지 “지금 꺼낼 수 있는 것”을 한 겹씩 걷어내며 몇 번 반복해야 하는지 세는 것이다. 깊이 묻힌 배치일수록 이 숫자가 크다. 이걸로 먼저 거르고, 통과한 후보만 비싼 측정으로 넘기기로 했다.
해상도도 깎기로 했다. 난이도를 만드는 건 퍼즐 구조지 픽셀 개수가 아니다. 도안을 4분의 1로 줄인 축소판에서 재도 구간을 나누기에는 충분했고, 정확한 값이 필요할 때만 원래 크기로 한 번 더 봤다.
- 비싼 판정 앞에 싼 판정을 두면 전체가 몇 배 빨라진다. 싼 쪽이 완벽할 필요는 없고 명백한 탈락만 걸러 주면 된다
- 목적이 정확한 수치가 아니라 구간 분류라면 정밀도를 깎아도 된다. 무엇을 위해 재는지가 정밀도를 정한다
만드는 쪽과 검증하는 쪽을 나눈다
벽을 무작위로 깔다 보면 사방이 막혀 출구까지 길이 없는 칸이 생긴다. 거기 바스켓이 들어가면 영영 못 꺼내는, 깰 수 없는 레벨이 된다.
만들 때 조심하는 쪽이 아니라, 깔고 나서 확인하고 안 되면 버리는 쪽으로 갔다. 출구에서부터 번져 나가 모든 칸에 닿는지 보고, 하나라도 못 닿으면 그 배치는 버리고 다시 만든다.
- 생성과 검증을 나누면 생성 쪽이 자유로워진다. 만드는 쪽에서 규칙을 다 지키려 하면 패턴이 단조로워진다
- 버리고 다시 만드는 비용이 싸다면 그게 제일 단순한 답이다
총량이 맞아야 성립하는 게임에서는 반올림 하나가 판을 망친다
한 색의 모래를 여러 바스켓에 비율대로 나눠 담아야 한다. 100알을 셋으로 나누며 각자 반올림하면 99가 되어 한 알이 사라진다.
이 게임은 색별 총량이 캔버스와 정확히 맞아야 클리어된다. 한 알만 어긋나도 그 레벨은 깰 수 없다. 그래서 정수 부분을 먼저 나눠 주고 남은 것을 소수 부분이 큰 순서로 하나씩 얹는 방식(최대잉여법)으로 가기로 했다. 어떻게 나누든 합은 그대로다.
- 총량이 성립 조건인 시스템에서는 “거의 맞음”이 곧 실패다. 나누는 자리마다 합이 보존되는지 확인한다
조용히 죽는 것들
조건이 여러 층에 흩어지면 기능이 통째로 안 될 수 있다
되돌리기 버튼이 아무리 눌러도 반응하지 않았다. 오류도, 로그도 없었다.
조건이 두 군데 있었다. 화면 쪽은 “벨트가 비어 있어야 버튼이 켜진다”, 엔진 쪽은 “되돌릴 바스켓이 벨트에 살아 있어야 실행한다”. 각각 따로 보면 둘 다 말이 된다.
그런데 이 둘은 동시에 참일 수 없다. 바스켓이 벨트에 있으면 벨트가 안 비었고, 벨트가 비면 되돌릴 바스켓도 사라진 뒤다. 교집합이 없어서 버튼은 영원히 안 눌렸다.
- 조건이 화면과 엔진처럼 여러 층에 흩어져 있으면, “이 기능이 켜질 수 있는 상태가 존재하긴 하나”를 한 번은 겹쳐 봐야 한다
- 오류 없이 아무 일도 안 일어나는 것이 가장 늦게 발견된다. 예외가 나는 편이 차라리 낫다
되돌릴 수 없으면 버튼을 막는 편이 낫다
되돌리기가 실시간과 부딪혔다. 이 게임은 바스켓을 올린 뒤에도 벨트가 돌고 모래가 계속 부어진다. 되돌리기 직전 상태를 통째로 저장해 뒀다가 복원하면, 그 사이 부어진 모래가 저장본에 없어서 통째로 사라진다.
총량이 어긋나면 그 레벨은 깰 수 없게 된다. 되돌리기 한 번이 판을 못 쓰게 만드는 것이다.
그래서 복원해도 되는 조건을 분명히 정하고, 그 조건을 못 채우면 버튼을 비활성으로 두기로 했다.
- 되돌리기는 “그 사이 아무 일도 없었을 때만” 안전하다. 실시간에서는 그 조건이 대부분 안 맞는다
- 조건을 못 채우면 기능을 막는다. 애매하게 실행해서 판을 깨뜨리는 것보다 안 되는 편이 낫다
밝게 보인다고 눌리는 건 아니다
튜토리얼에서 화면을 어둡게 덮고 눌러야 할 버튼만 밝게 뚫었다. 그런데 그 밝은 자리를 눌러도 반응이 없었다.
그리는 것과 터치를 받는 것은 별개다. 구멍은 그림이지 통로가 아니다. 덮개가 터치를 전부 가로채고 있었다.
- 화면에서 보이는 것과 눌리는 것은 따로 논다. 뚫었으면 누를 수 있게 하는 처리를 따로 해 줘야 한다
남겨 두는 방식
값을 저장하지 말고 변화를 쌓는다
코인·하트 같은 재화를 지금 값으로 저장하지 않기로 했다. 대신 코인 +50·하트 -1 같은 변화 기록을 시간순으로 쌓아 두고, 전부 더해서 현재 값을 읽는다. 회계 장부와 같은 방식이고 append-only 원장이라고 부른다.
이렇게 두니 새로운 것이 생겨도 저장 구조를 안 바꿔도 됐다. 아이템 개수도, “이 레벨 보상을 이미 줬는가”도 기록 종류 하나만 늘리면 끝이었다.
- 저장 구조를 바꾸는 일은 나중에 비싸진다. 무엇이 늘어날지 모를 때는 값이 아니라 변화를 쌓는 쪽이 여지를 남긴다
- 대신 값을 읽을 때마다 전부 더해야 하므로, 기록이 무한정 늘어나는 종류에는 안 맞는다
확인할 수단이 없으면 대체 수단을 만든다
이 게임은 실시간으로 돌아가는데, 당시 환경에서는 기기에 올려 직접 해 볼 수가 없었다. 탭부터 완성까지 전체 흐름이 실제로 끝까지 가는지 확인할 방법이 없었다.
테스트 도구가 시간을 가짜로 흘려보낼 수 있다는 것을 쓰기로 했다. 실제로 기다리지 않고 “4초가 지났다고 치자”를 반복하면, 그 사이 게임 루프가 다 돌아 완성 화면까지 간다. 그게 뜨는지 보면 흐름 전체가 예외 없이 끝났다는 뜻이다.
- 직접 확인할 수단이 막혀 있어도 대체 수단이 있는 경우가 많다. 없다고 넘기기 전에 한 번 찾아본다
- 대체 수단은 진짜 확인만큼은 아니다. 흐름이 끝까지 가는지는 알려 주지만 눈으로 보이는 것이 맞는지는 못 알려 준다