게임기에 조이스틱을 꽂았다 — 세 번째 게임, 세로 스크롤 슈팅

조이스틱으로 즐기는 세로 스크롤 슈팅 — 실제 플레이

세 번째 게임

3부에서 맥주 게임기에 블랙잭을 꽂으면서 “게임기가 플랫폼이 되는 순간”이라고 썼다. 그 말에 책임을 질 차례가 왔다. 세 번째 게임으로 세로 스크롤 슈팅을 넣었다.

이번에도 입력 장치가 게임을 정했다. 레버가 맥주 게임을 만들었고 물리 버튼이 블랙잭을 만들었듯, 이번엔 아날로그 조이스틱이 먼저 있었다. 상하좌우로 자유롭게 움직일 수 있는 입력이 생기니 자연스럽게 슈팅이었다. 레퍼런스는 국민 게임 〈드래곤 플라이트〉로 잡았다 — 세로 스크롤, 자동 발사, 아이템으로 강해지기, 죽을 때까지 계속.

만들고 싶은 그림은 분명했다. 밤하늘을 끝없이 올라가며, 쏟아지는 적을 조이스틱으로 피하고 총으로 쓸어담고, 아이템을 먹어 점점 강해지다, 위기엔 폭탄으로 화면을 정리하는 — 짧게 치고 빠지지만 “한 판만 더” 하게 되는 아케이드 슈팅. 다만 원작은 좌우로만 움직이는데, 조이스틱을 단 이상 상하도 쓰지 않을 이유가 없었다.

슈팅 게임 화면

밤하늘을 올라가며 적을 쏜다. 우측 패널에 점수·목숨·파워·폭탄. 조이스틱으로 비행기를 움직이고 총은 자동으로 나간다.

자리부터 없었다

만들기 전에 막힌 건 게임 로직이 아니라 메모리 주소였다.

이 게임기는 컨트롤러가 게임을 계산하고 화면이 그것을 그리는 구조다. 둘은 표준 통신으로 연결되는데, 화면이 읽을 수 있는 주소 범위가 정해져 있다. 그 범위가 이미 맥주와 블랙잭으로 차 있었다. 남은 빈칸이 두 덩어리로 쪼개져 있어서, 슈팅게임이 매 순간 보내야 하는 비행기·적·아이템 좌표를 한 번에 읽을 연속된 자리가 없었다.

읽기 영역과 쓰기 영역의 경계를 옮겨서 공간을 넓혔다. 대신 대가가 있었다 — 잘 돌고 있는 게임 두 개의 명령 주소를 전부 이사시켜야 했다. 시작·종료·베팅 같은 버튼이 전부 다른 번지로 옮겨간다는 뜻이다.

그래서 순서를 지켰다. 주소 이사만 먼저 하고, 슈팅은 한 줄도 건드리지 않은 채 맥주와 블랙잭을 각각 끝까지 한 판씩 돌렸다. 두 게임이 멀쩡한 걸 확인하고 나서야 새 게임 작업을 시작했다. 섞어서 했으면 문제가 생겼을 때 원인을 가릴 수 없었을 것이다.

총알을 아끼려다 인과가 깨졌다

좌표를 주고받는 자리가 빠듯하다 보니 아낄 곳을 찾았다. 눈에 띈 게 내 총알이었다.

총은 자동으로 나간다. 발사 주기가 정해져 있으니 화면 쪽에서 같은 규칙으로 궤적을 그릴 수 있다. 그러면 총알 좌표를 보낼 필요가 없다. 컨트롤러는 “몇 번째 발사인지”만 알려주고, 명중 판정은 자기 안에서 하면 된다. 자리를 스무 칸 넘게 아꼈다.

문제는 그 명중 판정을 발사하는 순간에 해버렸다는 것이다. 총알이 날아가는 시간이 0이니, 화면 꼭대기에 있는 적도 방아쇠를 당긴 즉시 터진다. 화면에는 총알이 이제 막 출발했는데 적은 벌써 사라진 뒤다. 플레이어 눈에는 “총알이 닿지도 않았는데 폭발했다” 로 보인다. 가장 먼 적은 0.4초나 먼저 죽었다.

고친 방법은 단순했다. 총알을 컨트롤러 내부에서만 실제로 날렸다. 좌표를 화면에 보내지 않으니 자리는 그대로 아끼면서, 명중은 총알이 도착했을 때 일어난다. 화면이 그리는 속도와 내부에서 나는 속도를 같은 값으로 맞춰서 시간축까지 일치시켰다.

그림은 다시 AI에게

4부에서 바텐더를 만들 때 쓴 방법을 그대로 썼다. 브라우저를 열어 생성 AI에게 스프라이트 시트를 주문하는 것이다. 한 대화에서 이어 요청하면 그림체가 유지된다.

기체 스프라이트 시트

한 번에 다섯 개를 요청해서 받은 시트. 플레이어기, 소형·중형·대형 적, 파괴 불가 암석. 따로따로 주문하면 그림체가 제각각이 된다.

아이템과 폭발 시트

파워업 캡슐, 폭탄, 그리고 폭발 4단계. 배경을 연한 회색 단색으로 지정해두면 나중에 투명하게 벗겨내기가 깔끔하다.

이번엔 두 가지가 걸렸다.

첫째, 첫 요청이 거부당했다. “저는 언어 모델로서 그것을 도와주도록 설계되지 않았습니다.” 프롬프트가 문제인 줄 알고 표현을 바꿔볼 뻔했는데, 화면을 자세히 보니 원인이 따로 있었다 — “현재 Pro에 대한 수요가 높습니다. 이 대답에는 다른 모델이 사용되었으며”. 이미지를 만들 수 있는 모델이 붐벼서 글만 쓰는 모델로 대체됐고, 그 모델이 “나는 그림을 못 그린다”고 답한 것이었다. 모델을 다시 지정하니 바로 나왔다. 거부 문구만 보고 프롬프트를 고쳤으면 엉뚱한 데서 헤맸을 것이다.

둘째, 적기가 전부 위를 향해 그려졌다. “아래를 향한 탑뷰”라고 분명히 적었는데도 플레이어기와 같은 방향이었다. 내려오는 적이 위를 보고 있으면 게임이 성립하지 않는다. 다시 주문하는 대신 후처리에서 적기만 180도 돌렸다. AI에게 다시 설명하는 것보다 한 줄 코드가 빨랐다.

너무 빨랐다

돌려보니 적이 무섭게 빨랐다. 재보니 직진형 적이 화면을 2.2초에 통과했다. 피할지 쏠지 판단할 시간이 없다. 가만히 서 있으면 10초 만에 목숨 세 개가 다 날아갔다.

속도를 절반으로 낮추려는데 사소한 벽이 있었다. 속도가 “한 틱에 몇 픽셀”인 정수라 3의 절반인 1.5를 표현할 수가 없었다. 비행기 이동에 이미 쓰고 있던 소수점 누적 방식을 적에게도 적용했다 — 매 틱 0.1픽셀 단위로 쌓아두고 1픽셀이 넘을 때만 실제로 움직인다.

부드럽지 않은 이유는 따로 있었다

속도를 고치고 나니 이번엔 비행기가 뚝뚝 끊겨 보인다는 게 눈에 들어왔다.

컨트롤러는 20밀리초마다, 그러니까 초당 50번 비행기를 움직인다. 그런데 화면이 그 좌표를 받아오는 건 훨씬 뜸했다. 화면은 초당 60번 그리는데 위치는 초당 20번쯤만 갱신되니, 같은 자리에 세 번 그리고 갑자기 점프하는 식이었다.

두 갈래로 손봤다. 통신 주기를 당겨서 좌표를 더 자주 받아오게 하고, 그래도 남는 간격은 화면 쪽에서 부드럽게 이어 그리도록 했다. 재보니 좌표가 32밀리초마다 7픽셀씩 점프하는데, 그 7픽셀을 프레임 사이에 나눠 그리는 것이다.

여기서 함정을 하나 미리 막았다. 적이 차지하는 자리는 재사용된다. 이어 그리기를 그대로 적용하면 새로 나타난 적이 방금 죽은 적의 자리에서 날아오는 것처럼 보인다. 자리의 주인이 바뀌면 이어 그리지 않고 즉시 제자리에 찍도록 예외를 뒀다.

난이도

속도를 정하고 나니 “이건 사람마다 다르겠다” 싶었다. 설정 화면에는 이미 맥주 게임용 난이도(쉬움·보통·어려움)가 있었다. 새로 만들 것 없이 그걸 슈팅 속도에 연결했다. 아케이드 기계에 난이도 스위치가 하나인 게 자연스럽기도 하고.

난이도별 낙하 속도

실기에서 잰 값. 손으로 정한 “보통”을 기준선에 두고 위아래로 벌렸다.

점수도 난이도에 비례하게 했다. 어려운 쪽에서 얻은 기록이 순위표에서 정당하게 높아야 하니까. 여기서 정수 나눗셈에 한 번 걸렸다. 생존 점수가 1초에 1점이었는데, 쉬움 난이도의 70%를 곱하면 소수점이 잘려 0점이 된다. 생존 점수가 통째로 사라지는 것이다. 어려움에서도 1점은 배율을 곱해봐야 그대로였다. 단위를 1초에 10점으로 올려서 세 난이도 모두 배율이 제대로 걸리게 했다.

재밌는 건 실측 결과다. 22초 동안 벌어들인 점수가 쉬움에서 오히려 더 높게 나왔다. 배율은 분명히 낮은데도. 이유는 간단하다 — 적이 느리면 화면에 오래 머무니까 격추 기회가 더 많다. 속도를 낮추는 것 자체가 점수를 올리는 방향으로 작용한다. 순위표를 난이도와 무관하게 공정하게 만들려면 배율을 훨씬 더 벌리거나 아예 표를 나눠야 한다는 뜻인데, 이건 아직 안 고쳤다.

빌드는 통과하는데 돌려봐야 드러나는 것들

이번 편에서 유독 많았던 게, 컴파일도 빌드도 멀쩡히 통과하는데 실제로 돌려봐야만 드러나는 버그들이었다. 몇 개를 추린다.

증상원인
등장하자마자 사라지는 적등장 y좌표를 음수로 잡아, 음수를 못 담는 메모리가 즉시 지워버림
22분쯤 놀면 게임이 얼어붙음누적 타이머가 넘칠 때를 고려하지 않음
지그재그 적이 직진형과 똑같음좌우 주기를 너무 짧게 잡아 이동이 아니라 떨림이 됨
비행기 하단이 화면 밖으로 잘림플레이 영역 높이를 24px 크게 잡음
통신이 예상만큼 안 빨라짐통신 속도를 두 배로 낙관했는데 라이브러리 구조상 불가능한 값이었음
종료 버튼을 눌러도 안 나가짐게임 상태 읽기를 빠뜨려 슈팅 화면에서 못 빠져나옴

공통점은 정적으로는 안 잡힌다는 것이다. 코드를 아무리 다시 읽어도, 실제로 만들어 돌려보거나 라이브러리 소스를 파고들어야 드러난다. 특히 마지막 종료 버그는 배포하고 나서야 “어? 왜 안 나가지” 하고 만났을 문제였다. 그래서 이 프로젝트는 매 단계 — 빌드가 아니라 실행을 완료의 기준으로 삼는다.

아직 남은 것

폭탄 버튼이 안 된다. 조이스틱에 달린 스위치를 눌러 화면의 적을 쓸어버리는 기능인데, 배선에서 신호가 안 들어온다. 로직은 멀쩡하다 — 폭탄 개수가 줄어드는 것까지는 확인했다.

검증하려다 한 번 더 배운 게 있다. 스위치가 눌린 상태를 소프트웨어로 흉내내려고 값을 강제로 고정했는데, 하필 그 값은 프로그램이 매 순간 다시 계산하는 값이었다. 강제값과 계산이 서로 덮어쓰면서 값이 진동했고, “눌렀다 뗐다”가 초당 수십 번 일어난 셈이 되어 폭탄 두 개가 한 번에 터졌다. 흉내를 내려면 계산된 결과가 아니라 그 재료를 건드려야 했다.

이번 편의 교훈

다음 편

캐비닛 정리 차례라고 생각했는데, 그 전에 같은 부품으로 게임을 하나 더 만들었다. 네 번째 게임 — 7부 · 낚시로 “재미없다”를 숫자로 번역하기. 캐비닛 이야기는 그 다음이다.

🐉 레퍼런스로 삼은 〈드래곤 플라이트〉는 넥스트플로어(현 라인게임즈)가 2012년에 낸 국민 종스크롤 슈팅이다. 이 글의 기체·아이템 그림과 코드는 전부 오리지널이며, 게임 방식만 참고했다.

🍺 이전 편: 1부 · 2부 · 3부 · 4부 · 5부

댓글

로그인 없이 이름만 적고 남기실 수 있어요. 남긴 댓글은 바로 게시됩니다.

댓글을 불러오는 중…