버튼 하나에 네 가지 몸: 자동 변신과 1/24박 고정 스텝 물리
FOURSTEP의 주인공 핍은 들판에서는 점프하는 큐브, 땅굴에서는 땅을 파는 육각 프리즘, 바다에서는 떠오르는 유리 구체, 산에서는 활공하는 델타 기체가 됩니다. 입력은 버튼 하나뿐이고 변신은 지형이 정합니다. 이 글은 왜 변신을 플레이어 손에서 뺐는지, 그리고 네 형태의 물리를 어떻게 박자 단위로 정의해 게임과 코스 검증기가 같은 코드를 쓰게 했는지를 정리합니다.
변신 버튼을 만들지 않은 이유
처음 떠올린 구조는 "버튼 하나로 달리는 러너인데, 몸이 바뀐다"였습니다. 여기서 몸을 바꾸는 주체를 누구로 둘지가 첫 갈림길이었습니다. 플레이어가 고르게 하면 변신용 입력이 하나 더 필요하고, 원버튼이라는 전제가 깨집니다. 버튼 길이나 연타로 변신을 구분하는 방법도 생각했지만, 이미 탭과 홀드가 형태마다 다른 뜻을 가지는 구조라 의미가 겹쳐 버립니다.
그래서 설계 문서의 핵심 규칙에 "지형 = 변신"을 적었습니다. 지형 경계를 지나는 순간 형태가 바뀌고, 플레이어가 할 일은 바뀐 몸에서 버튼의 뜻을 다시 읽는 것뿐입니다. 이 결정은 몇 가지 장치를 같이 요구했습니다. 각 지형의 처음 4박자는 장애물을 두지 않는 안전 구간으로 비워 두었고, 지형에 처음 들어갈 때는 "길게 누르면 땅속으로! 놓으면 떠올라요" 같은 한 줄 안내 배너를 띄웁니다. 같은 지형을 세 번 지나면 안내 문장은 빠지고 지형 이름만 짧게 나옵니다. 처음 보는 사람에게는 설명을, 익숙한 사람에게는 방해가 안 되는 신호만 주려는 것입니다.
변신이 자동이라는 점은 음악 쪽에도 쓸모가 있었습니다. 지형 경계의 위치가 미리 정해져 있으니 음악 엔진이 경계 4박 전부터 라이저를 올리고 도착하는 박자에 임팩트를 칠 수 있습니다. 변신 순간이 곡의 전환점과 겹치도록 만든 것입니다.
단위는 칸과 박자
물리를 설계할 때 가장 먼저 정한 것은 단위입니다. 거리는 칸, 시간은 초가 아니라 박자입니다. 핍은 박자당 3칸을 전진하고, 중력·점프 속도·잠수 속도도 모두 "칸/박" 단위로 적었습니다. 이렇게 두면 곡의 BPM이 바뀌어도 코스의 모양과 필요한 입력 타이밍(몇 박째에 누르는가)은 그대로이고 실제 시간만 늘거나 줄어듭니다. 1.2.0에서 속도 배율(100~200%)을 넣을 때 물리 코드를 한 줄도 고치지 않은 이유가 이것입니다. 실효 BPM만 곱해 주면 끝났습니다.
RUNNER의 수치는 "점프 한 번이 정확히 1박"이 되도록 역산했습니다. 중력 12.8, 점프 초속도 6.4로 두면 체공 시간은 2×6.4/12.8 = 1박, 정점 높이는 6.4²/(2×12.8) = 1.6칸입니다. 1박 동안 3칸을 전진하니 점프 한 번에 3칸을 건넙니다. 구덩이 폭 상한을 3칸보다 작게 둔 것도 이 점프에 여유를 남기기 위해서입니다(처음 2.0칸, 1.2.1부터 솔버의 입력 격자까지 감안해 1.75칸). 드럼 패드는 초속도 9.6이라 정점이 3.6칸까지 올라갑니다.
천장 매달리기도 이 숫자에서 나왔습니다. 터널 천장 높이는 2.4칸이고, 몸 중심이 바닥에서 0.45칸 위에 있으니 점프 정점에서 몸 윗면은 0.45+1.6+0.45 = 2.5칸까지 갑니다. 즉 터널 안에서 점프하면 반드시 천장에 닿습니다. 설계 문서에 "터널에서 점프하면 항상 매달린다"고 적어 둔 것은 우연이 아니라 의도한 결과입니다. 플레이어는 터널을 보면 "점프 = 천장으로 옮겨 타기"라고 바로 읽을 수 있습니다. 다만 1.2.0까지는 이 계산이 코드에서 지켜지지 않았습니다. 1/24박 스텝마다 속도를 먼저 줄이고 그 속도로 위치를 옮기는 방식은 정점이 약 1.47칸으로 낮아져 몸 윗면이 2.37칸에 그쳤고, 실제로는 천장에 한 번도 닿지 않았습니다. 1.2.1에서 스텝 앞뒤 속도의 평균으로 위치를 옮기도록 바꿔 정점 1.6칸과 체공 1박을 정확히 맞췄습니다. 자세한 경위는 솔버 글에 적었습니다.
네 형태, 네 가지 독자 규칙
형태가 넷이어도 결국 위아래로 피하는 게임이라면 단조로워집니다. 그래서 형태마다 다른 형태에는 없는 규칙을 하나씩 주었습니다.
- RUNNER는 천장 매달리기입니다. 천장에서 탭하면 초속도 −1로 떨어지기 시작합니다.
- DIGGER는 두 레인입니다. 지표(y=0.45)와 지하(y=−0.75) 사이를 초당이 아닌 박당 6칸 속도로 오가므로 1.2칸을 내려가는 데 0.2박이 걸립니다. 지표에는 수정 가시, 지하에는 바위가 번갈아 나옵니다.
- JELLY는 저항과 해류입니다. 물속 중력은 2.8로 약하고, 매 스텝 속도에 e^(−1.9·dt)를 곱해 감쇠시킵니다. 탭 한 번은 초속도 3.4의 펄스이고, 해류 구간에서는 ±3의 힘이 더해집니다.
- GLIDER는 추진이 없다는 점입니다. 날개를 펴면 낙하 속도가 −1.5로 수렴하고, 상승기류 안에서만 박당 +8씩 올라갑니다(최대 4). 날개를 접으면 −7로 급강하합니다.
JELLY의 수치는 처음부터 이렇지 않았습니다. 1.0.0에서는 펄스가 4.5, 물속 중력 3.2, 저항 1.6, 해류 5였는데 탭 한 번에 1.27칸씩 솟구쳤습니다. 1.1.1에서 펄스 3.4, 저항 1.9, 해류 3으로 낮춰 탭 한 번의 상승을 약 0.81칸으로 줄였습니다. 바뀐 숫자보다 중요한 것은 이 조정이 코스 생성기의 검증 결과와 함께 이뤄졌다는 점인데, 이 이야기는 솔버 글에서 따로 다룹니다.

게임과 솔버가 같은 stepHero를 쓴다
FOURSTEP의 코스는 시드로 자동 생성되고, 생성된 코스는 솔버가 실제로 통과해 본 것만 채택합니다. 이때 솔버가 쓰는 물리와 게임이 쓰는 물리가 조금이라도 다르면 "솔버는 통과했는데 사람은 절대 못 지나가는" 코스가 나옵니다. 이를 막으려고 핍의 물리를 js/level.js의 stepHero(L, h, press, dt) 함수 하나에 모았습니다. 입력은 "지금 눌려 있는가" 하나뿐이고, 탭은 함수 안에서 이전 스텝의 눌림 상태와 비교해 판별합니다.
시간 간격도 고정했습니다. 게임 루프는 화면 갱신 주기와 상관없이 1/24박씩 끊어서 시뮬레이션을 진행합니다. 실제 게임 루프의 핵심 부분은 다음과 같습니다.
const target=beatNow(); let n=0;
while(RENDER.hitStop<=0 && h.t+WORLD.DT<=target && n<60 && h.alive && h.x<L.xEnd){
const p=autoplay ? (L.solution[Math.floor(stepIdx/WORLD.TICK)]===1) : press;
WORLD.stepHero(L,h,p,WORLD.DT); stepIdx++; n++; handleEvents();
}
beatNow()는 오디오 시계에서 읽은 현재 박자입니다. 핍의 시간이 그 박자를 따라잡을 때까지 1/24박 스텝을 반복합니다. 60Hz 화면이든 144Hz 화면이든 같은 박자에 같은 입력을 넣으면 같은 결과가 나옵니다. 솔버는 1/8박마다 누를지 말지를 결정하고(1/8박 = 1/24박 스텝 3번), 그 결정을 그대로 재생하면 타이틀 화면의 자동 플레이가 됩니다. 위 코드의 autoplay 분기가 그것입니다. 같은 함수를 쓰기 때문에 솔버의 해답을 게임에 그대로 흘려 넣어도 핍이 똑같이 결승선까지 달립니다. 이것이 저희가 가진 가장 손쉬운 회귀 테스트이기도 합니다.
경계에서 일어나는 일
자동 변신에서 의외로 손이 많이 간 부분은 "변신하는 순간 핍이 어떤 상태인가"였습니다. 들판에서 점프한 채로 바다에 들어갈 수도 있고, 산에서 활공하다가 들판에 내려설 수도 있습니다. morph() 함수는 형태별로 이어받는 상태를 정리합니다. RUNNER나 GLIDER로 바뀔 때 땅 위에 있으면 착지 상태로, 공중이면 위로 솟구치는 속도를 0으로 깎아 낙하 상태로 둡니다. DIGGER로 바뀌는데 공중에 있으면 먼저 떨어져 지표에 닿은 뒤 파고들 수 있습니다. JELLY로 바뀔 때는 낙하 속도를 −4로 제한해 물에 들어가자마자 바닥에 처박히지 않게 했습니다.
이 처리가 없으면 변신 직후에 플레이어가 아무것도 못 한 채 죽는 경우가 생깁니다. 다행히 안전 구간 4박과 솔버가 함께 이 경계를 검증합니다. 솔버는 지형 하나를 풀 때 앞 지형 해답의 마지막 상태에서 출발하므로, 경계를 넘는 상태 전이까지 통과 가능성 검사에 포함됩니다.
정리
FOURSTEP의 변신은 연출처럼 보이지만 사실은 설계의 제약에서 출발했습니다. 원버튼을 지키려고 변신을 지형에 맡겼고, 지형마다 버튼의 뜻이 달라지는 부담은 안전 구간과 안내 배너로 덜었습니다. 물리는 칸과 박자로 정의해 속도 배율과 음악 동기를 공짜로 얻었고, 1/24박 고정 스텝의 stepHero 하나를 게임과 솔버가 함께 써서 "생성된 코스는 반드시 통과할 수 있다"는 약속을 코드 수준에서 지킵니다. 형태가 넷인 만큼 형태별 체감 난이도의 균형은 앞으로도 계속 지켜볼 부분입니다.