DEV NOTE · JJ

줄 하나로 협동을 강제하기: JJ의 탄성 줄 물리 설계

JJ는 두 코어를 줄 하나로 묶고 두 사람이 하나씩 조종하는 협동 퍼즐입니다. 이 게임에서 협동은 권장 사항이 아니고 물리 법칙이어야 했습니다. 그래서 줄의 장력 공식과 길이 규칙을 먼저 정하고, 그 수치가 만들어 내는 "혼자서는 못 넘는 거리"를 레벨 설계의 자로 삼았습니다. 처음 정한 수치 중 상당수는 헤드리스 테스트에서 깨졌고, 이 글은 그 수정 기록입니다.

혼자 넘을 수 있는 거리부터 계산했다

협동을 강제하려면 먼저 혼자서 할 수 있는 일의 한계를 정확히 알아야 합니다. 코어의 기본 수치는 중력 32칸/s², 점프 초속 11칸/s, 지면 가속 40칸/s², 최고 속도 7칸/s입니다. 여기서 점프 정점은 약 1.9칸이 나오고, 올라설 수 있는 턱은 1칸으로 정해집니다. 전력으로 달려서 뛰면 약 4.8칸을 날아가고 코요테 타임(가장자리를 지난 뒤 0.08초 동안 점프 허용)이 0.5칸쯤 보태집니다.

이 계산에서 레벨의 기하 규칙이 바로 나옵니다. 틈 3칸은 혼자 넘고, 4칸은 아슬아슬하며, 5칸부터는 새총이 필요합니다. 2칸 이상 높은 선반은 도르래로 끌어올려야 합니다. 이 규칙은 수제 레벨 팩 파일의 머리말에 그대로 적혀 있고, 레벨을 그릴 때마다 이 숫자를 기준으로 삼습니다. 처음 설계한 12개 스테이지 중 11개가 기하 오류로 실패한 뒤에야 규칙을 문서로 박아 두었다는 점은 고백해 둡니다.

장력은 하나의 숫자로 표현한다

줄은 16개 세그먼트로 된 Verlet 체인이고, 매 스텝 제약을 8번 반복해 풉니다. 양 끝점은 코어 위치에 고정됩니다. 줄의 모든 상태는 장력 T 하나로 요약됩니다. T는 두 코어의 실제 거리가 줄 길이를 얼마나 넘었는지를 탄성 구간 1.2칸으로 나눈 값이고, 0에서 1.3 사이로 자릅니다.

// jj/js/rope.js 발췌
function updateLength(r,d,pulling,dt){
  if(pulling>0) r.len=Math.max(ROPE_MIN, r.len-pulling*REEL*dt);
  else if(d>r.len) r.len=Math.min(ROPE_MAX,d);
  r.T=Math.max(0,Math.min(SNAP_T,(d-r.len)/TZ));
  r.state=r.T<=0?0:r.T<0.6?1:r.T<STRAIN_T?2:3;
  return r.T;
}

T가 0이면 SLACK, 0.6 미만이면 TAUT, 1.0 미만이면 STRAIN입니다. T가 1.3에 닿거나 1.0 이상이 0.5초 넘게 이어지면 줄이 끊어집니다. 화면의 줄 색, 줄이 내는 소리의 높이, 게이트 절단 판정(T 0.6 이상)이 모두 이 숫자 하나를 읽습니다. 시뮬레이션은 1/120초 고정 스텝이고 Math.random과 삼각함수를 쓰지 않습니다. 같은 입력이면 어느 기기에서든 같은 결과가 나와야 리플레이와 온라인 동기화가 성립하기 때문입니다.

콘셉트 프로토타입에서 팽팽해진 줄이 마젠타색으로 변한 장면
콘셉트 프로토타입의 STRAIN 상태. 줄 색이 장력을 그대로 보여 준다.

당기기 규칙을 처음부터 다시 짰다

첫 설계에서 당기기는 초당 3칸씩 줄을 감고, 손을 떼도 줄어든 길이를 유지하는 방식이었습니다. 그런데 콘셉트 프로토타입을 헤드리스로 돌려 보니 도르래 상황에서 장력이 전혀 걸리지 않았습니다. 로그를 보면 T가 끝까지 0이었습니다. 길이를 자동으로 되돌리는 보정 규칙이 감은 길이를 매번 원래대로 풀어 버리고 있었습니다.

그래서 줄자처럼 동작하도록 바꿨습니다. 누군가 당기기를 누르고 있는 동안에는 길이가 잠기고 초당 5칸씩 짧아지며(최소 1.2칸), 둘 다 누르면 속도가 더해집니다. 손을 떼면 잠금이 풀려 두 코어가 벌어지는 만큼 최대 길이까지 자유롭게 풀립니다. 이렇게 하면 장력은 "최대 길이를 넘었을 때"와 "누군가 감고 있을 때" 두 경우에만 생깁니다. 플레이어가 장력을 의도적으로 만들고 해제할 수 있게 된 것이 이 변경의 핵심입니다.

6칸 줄로는 새총이 성립하지 않았다

설계 초안의 줄 최대 길이는 6칸이었습니다. 5칸 틈을 새총으로 넘는 스테이지를 만들면서 문제가 드러났습니다. 앵커가 가장자리에 있을 때 착지점은 앵커에서 약 5.9칸 떨어진 곳입니다. 그런데 6칸을 넘게 날아갈 만큼 빠르게 쏘면 줄이 한계에 닿아 공중에서 되감기고, 줄 한계 안에 머물 만큼 느리게 쏘면 틈을 못 넘습니다. 두 조건을 동시에 만족하는 발사 속도 구간이 0이었습니다.

줄을 8칸으로 늘리자 허용 구간이 초속 8.8~11칸으로 열렸습니다. 지금 새총은 앵커를 가장자리에서 0.45칸 안쪽에 두고, 발사하는 코어가 장력 약 0.72까지 줄을 늘렸다가 앵커 바로 앞에서 뛰면 초속 9~10칸이 나와 5칸 틈을 넘습니다. 착지점이 줄 한계 안에 있으니 공중에서 되감기지도 않습니다.

장력을 만드는 쪽에서도 수정이 있었습니다. 탄성 구간이 처음에는 0.6칸이었는데, 이 값으로는 걷기만 해서 줄을 늘리면 T가 0.33 근처에서 평형을 이뤄 새총에 필요한 장력까지 가지 못했습니다. 탄성 구간을 1.2칸으로 넓히고, 지면에서 파트너 반대쪽으로 줄을 늘릴 때 견인력을 2.5배로 올리자 걷기만으로 T 0.83까지 올라갑니다. 최고 속도 7칸/s 제한도 자력 가속에만 걸고 줄의 탄성 임펄스에는 걸지 않았습니다. 외부 힘까지 7칸/s로 묶었더니 발사 사거리가 232픽셀에 그쳐 틈을 넘을 수 없었기 때문입니다.

앵커한 코어를 축으로 다른 코어가 줄의 탄성으로 발사되는 새총 장면
새총. 앵커는 가장자리에 바짝, 발사는 앵커 바로 앞에서.

끊기지 않는 밧줄, 밀리지 않는 닻

탄성 스프링 모델에는 또 다른 문제가 있었습니다. 공중에서 줄이 갑자기 팽팽해지는 순간 T가 1.3을 넘어 바로 끊어졌습니다. 새총으로 건너간 직후 도르래로 넘어가는 구간에서 10번 연속으로 끊어진 적도 있습니다. 현실의 밧줄은 급제동을 흡수합니다. 그래서 줄이 팽팽할 때 두 코어가 벌어지는 상대 속도의 85%를 매 스텝 질량 비율로 흡수하도록 바꿨습니다. 이제 급제동으로는 끊어지지 않고, 앵커한 두 코어가 계속 반대로 당길 때처럼 의도적으로 장력을 유지할 때만 끊어집니다.

앵커도 손봤습니다. 처음에는 앵커 중 수평 속도를 매 스텝 0.6배로 줄이는 방식이라, 새총을 준비하는 동안 앵커가 0.5칸쯤 끌려갔습니다. 발사점이 틈에서 멀어지니 조합 스테이지에서 착지에 실패했습니다. 앵커 중에는 수평으로 완전히 고정하고 줄 장력의 수평 성분을 무시하도록 바꾸자 생성 코스의 첫 시도 통과율이 66%에서 90%로 올랐습니다.

도르래를 위해서는 맨틀 동작을 추가했습니다. 수평에 가까운 줄로는 매달린 파트너를 턱 위로 끌어올리는 데 T가 0.9 가까이 필요해 거의 끊어질 지경이었습니다. 그래서 공중에서 벽을 밀고 있고, 줄이 T 0.2 이상으로 당겨지고 있으며, 파트너가 0.4칸 이상 위에 있고, 턱 윗면이 코어 중심 위 0.95칸 안이면 기어오르게 했습니다. 가변 점프도 매 스텝 0.9배 감쇠에서 버튼을 뗄 때 한 번 0.45배로 바꿨습니다. 매 스텝 감쇠가 상승기류의 상승 속도를 초속 1.5칸으로 묶어 버렸기 때문입니다.

정리

줄 물리는 그럴듯하게 보이는 것보다 레벨 기하와 맞물리는 것이 중요했습니다. 6칸에서 8칸, 0.6칸에서 1.2칸, 감쇠 0.6배에서 완전 고정으로 바뀐 수치들은 모두 "어떤 틈을 어떤 동작으로 넘게 할 것인가"라는 질문에서 나왔고, 변경 근거는 설계 문서의 변경 이력에 남아 있습니다. 성능은 회귀 테스트 기준 스텝당 0.004~0.02ms로 설계 기준 0.4ms보다 충분히 빠릅니다. 아직 측정하지 못한 것은 사람입니다. 초심자 쌍이 스윙으로 6칸 심연을 세 번 안에 넘는 비율이 80%를 넘는지는 실제 플레이테스트로 확인해야 하고, 미달이면 복원력이나 앵커 질량을 한 번에 하나씩 조정할 계획입니다.