모든 홀을 직접 쏴 보고 내놓는 코스 생성기: 144회에서 384회 발사로
SLINGSTAR의 홀은 사람이 배치하지 않는다. 시드에서 행성을 뿌린 뒤, 게임과 똑같은 물리 코드로 시작점에서 수백 번 시험 발사를 해 보고, 들어가는 발사가 적당히 드문 배치만 채택한다. 기준 샷도 그 발사 결과에서 계산한다. 1.0.0에서는 홀마다 144회였던 시험 발사가 1.1.0에서 384회로 늘었다. 이 글은 그 생성기의 구조와 바뀐 이유, 그리고 30일치 DAILY 코스를 실제로 돌려 본 결과를 다룬다.
왜 "쏴 보고" 판단하는가
중력이 여럿 겹친 공간에서 궤적은 해석적으로 풀리지 않는다. 행성 세 개가 있는 홀에서 "어느 방향으로 얼마나 세게 쏘면 들어가는가"를 공식으로 구할 방법이 없다. 그렇다고 행성 배치만 보고 난이도를 추정하면, 보기엔 막혀 있는데 실제로는 아무 방향으로나 쏴도 들어가는 홀이나, 반대로 어떻게 쏴도 안 들어가는 홀이 섞인다. 둘 다 플레이어가 바로 알아챈다.
그래서 판단을 시뮬레이션에 맡겼다. 생성기는 게임이 쓰는 PHYS.simulate를 그대로 불러 실제 샷을 끝까지 돌린다. 마찰, 착지, 필드 이탈, 블랙홀의 약한 인력까지 전부 같다. 생성기가 "들어간다"고 판정한 발사는 플레이어가 같은 방향과 세기로 쏘면 실제로 들어간다.
1.0.0: 48방향 × 3세기 = 144회
첫 버전의 규칙은 단순했다. 시작점은 필드 아래쪽, 블랙홀은 위쪽에 무작위로 두고, 홀 번호가 클수록 행성을 많이(1~4개) 뿌린다. 행성끼리 겹치거나 시작점·블랙홀에 너무 붙으면 다시 뽑는다. 그다음 48방향마다 세기 55·85·120으로 쏴서 들어가는 횟수를 센다. 들어가는 발사가 1회 이상 24회 이하이고, 시작점과 블랙홀을 잇는 직선 근처(행성 반지름 + 20 이내)에 행성이 하나라도 있으면 채택했다. 기준 샷은 들어가는 발사가 4회 이상이면 2, 그보다 적으면 3이었다. 설계 문서 초안에는 이 문턱이 6으로 적혀 있는데, 실제 1.0.0 코드에 들어간 값은 4다.
이 방식은 "풀 수 있는 홀"은 확실히 보장했지만 쉬운 홀을 걸러 내는 장치가 약했다. 직선 근처에 행성이 "있기만" 하면 통과였기 때문에, 행성이 경로 옆에 살짝 비켜 있는 홀에서는 블랙홀을 향해 거의 똑바로 쏘면 들어갔다. 중력을 이용하는 게임에서 중력을 무시해도 되는 홀이 섞일 수 있는 구조였고, 1.1.0 변경 이력의 난이도 항목이 바로 이 부분을 고친 것이다.
1.1.0: 96방향 × 4세기, 그리고 직선 금지
1.1.0에서 검증을 두 방향으로 바꿨다. 첫째, 발사를 촘촘히 했다. 방향을 96개로 늘리고 세기를 50·75·100·125 네 단계로 나눠 홀마다 384회를 쏜다. 둘째, 2번 홀부터는 "직선으로 못 넣는 홀"만 받는다. 각 발사의 방향이 시작점에서 블랙홀로 향하는 방향과 이루는 코사인이 0.966(약 15°)보다 크면 직선 발사로 분류하고, 직선 발사 중 하나라도 들어가면 그 홀을 버린다. 채택 조건은 실제 코드로 보면 이렇다.
const ok=idx===0
?(ev.sinks>=4&&ev.sinks<=64&&near)
:(ev.sinks>=3&&ev.sinks<=40&&ev.direct===0&&(a<40?blocked:near));
const parFor=s=>s<=4?4:s<=10?3:2;
1번 홀은 연습 홀이라 384회 중 4~64회가 들어가면 되고 직선 금지도 없다. 2번 홀부터는 3~40회여야 하고, 처음 40번의 시도까지는 행성이 직선 경로를 실제로 가로막아야(blocked) 한다. 40번을 넘기면 조건을 "직선 근처에 행성이 있음"(near)으로 완화해서 어떻게든 80번 안에 홀을 찾게 했다. 80번을 다 써도 못 찾으면 그동안 본 것 중 들어가는 발사가 있는 홀을 쓴다. 기준 샷 계산도 바뀌어서, 들어가는 발사가 4회 이하면 기준 4샷, 10회 이하면 3샷, 그보다 많으면 2샷이 된다.
들어가는 발사의 수를 난이도 지표로 쓰는 근거는 이렇다. 384개 발사는 조준 가능한 공간을 고르게 덮는 표본이다. 그중 들어가는 비율이 낮다는 것은 플레이어가 한 번에 맞혀야 할 창이 좁다는 뜻이고, 그만큼 착지를 끼워 여러 번에 나눠 가야 할 가능성이 커진다. 완벽한 지표는 아니지만, 사람이 손으로 하나씩 평가하지 않고도 9홀마다 일관된 기준을 붙일 수 있다.
삼각함수 없이 방향 만들기
방향 96개를 만드는 가장 쉬운 방법은 각도를 3.75°씩 늘리며 코사인과 사인을 구하는 것이다. 그런데 ECMAScript 명세는 Math.sin, Math.cos의 결과를 구현에 맡긴다(implementation-approximated). 브라우저 엔진이나 CPU에 따라 마지막 비트가 다를 수 있다. 보통은 상관없지만 SLINGSTAR의 생성기는 그 방향으로 최대 1,800단계 남짓 혜성을 날려 보고 결과를 센다. 궤도가 행성 근처를 스칠 때 마지막 비트 차이는 크게 불어나고, 어떤 기기에선 들어가는 발사가 다른 기기에선 빗나가면 채택되는 홀 자체가 달라진다. 같은 날 같은 DAILY 코스를 보장할 수 없게 된다.
그래서 정사각형 둘레를 같은 간격으로 96등분한 점을 길이 1로 정규화해서 방향으로 쓴다. 필요한 연산은 사칙연산과 Math.sqrt뿐이고, 제곱근은 IEEE 754에서 정확한 반올림이 정해진 연산이라 어느 기기에서나 같은 값이 나온다. 물리 코드 역시 같은 이유로 삼각함수를 쓰지 않는다. 시드 난수기도 Math.imul 기반의 정수 연산(문자열 해시 + mulberry32)이다.
대가는 방향 간격이 고르지 않다는 점이다. 정사각형 한 변 위의 점을 같은 간격으로 찍으면 변 가운데(상하좌우 축 방향)에서는 각도 간격이 약 4.8°, 모서리(대각선 방향) 근처에서는 약 2.4°가 된다. 평균은 3.75°지만 축 방향 표본이 대각선보다 성기다. 1.0.0의 48방향에서는 이 간격이 축 근처에서 9.5°까지 벌어졌으니, 96방향으로 늘린 것은 이 빈틈을 줄이는 효과도 있었다. 엄밀히 말하면 행성 배치 단계의 거리 비교에 쓰인 ** 연산자도 명세상 근사가 허용되는 연산이다. 비교 경계에 정확히 걸리는 경우에만 영향이 있겠지만, 곱셈으로 바꿔 둘 항목으로 적어 두었다.
30일치를 실제로 돌려 보았다
이 글을 쓰면서 1.1.1 코드를 Node에서 그대로 불러 DAILY 시드 30일치(2026년 10월 7일부터 11월 5일까지, 270홀)를 생성해 보았다. 결과는 다음과 같다.
| 항목 | 값 |
|---|---|
| 기준 4샷 / 3샷 / 2샷 홀 | 184 / 86 / 0 |
| 홀당 들어가는 발사 수(384회 중) | 최소 3, 중앙값 4, 최대 10 |
| 코스 기준 샷 합계 | 30~36 |
| 홀당 생성 시도 횟수 | 중앙값 7, 최대 77 (80번 제한) |
| 80번 안에 조건을 못 맞춰 대체 홀을 쓴 경우 | 0 |
| 홀당 평균 행성 수 / 반발 행성 비율 | 2.6개 / 6.7% |
| 코스 1개 생성 시간(개발 PC, Node) | 평균 약 2.1초 |
눈에 띄는 점이 두 가지 있다. 첫째, 기준 2샷 홀이 한 번도 나오지 않았다. 코드상으로는 들어가는 발사가 11회 이상이면 기준 2샷인데, 직선 금지와 경로 차단 조건을 통과한 홀은 들어가는 발사가 3~5회인 경우가 232홀(86%)이었고 최대도 10회였다. 채택 하한인 딱 3회짜리가 117홀(43%)로 가장 많았다는 점에서, 조건을 통과하는 배치 자체가 이미 어려운 쪽으로 쏠려 있다는 것을 알 수 있다. 기준 샷이 사실상 3과 4 두 단계로만 쓰이고 있다는 뜻이다. 1번 홀마저 들어가는 발사가 4~8회에 머물러 기준 4샷이 자주 나온다. 연습 홀이 오히려 기준 샷이 큰 홀이 되는 셈이라, 1번 홀 조건과 기준 샷 문턱을 함께 다시 볼 생각이다.
둘째, 생성 비용이다. 표본으로 고른 한 홀에서 384회 발사를 평가하는 데 물리 단계가 약 4만 번 돈다. 홀마다 평균 몇 번씩 배치를 다시 뽑으니 9홀이면 수백만 단계가 되고, 개발 PC에서도 코스 하나에 2초 남짓 걸린다. 지금은 시작 버튼을 누를 때 메인 스레드에서 동기적으로 생성하므로 느린 휴대폰에서는 버튼 반응이 늦게 느껴질 수 있다. 시도 횟수 최대값이 77로 80번 제한에 거의 붙은 홀이 있었다는 것도, 조건이 이미 꽤 빡빡하다는 신호로 읽었다.
1.1.2에서 고친 것: 문턱 재조정과 Web Worker
원인부터 다시 쟀다. 조건을 전혀 걸지 않고 무작위 배치 2,400개를 평가해 봐도 384회 중 들어가는 발사는 0~12회였고 11회 이상은 몇 개뿐이었다. 블랙홀의 크기(반지름 4.8)와 방향 간격(평균 3.75°)으로 보면, 한 방향으로 쏴서 들어가는 세기는 네 단계 중 몇 개에 불과하고 들어가는 방향도 한두 개라 10회 안팎이 사실상 천장이다. 기준 2샷 문턱 11회는 이 척도에서 닿을 수 없는 값이었고, 직선 금지·경로 차단을 통과한 홀은 대부분 1~4회에 몰려 있었다.
그래서 문턱을 실측 척도에 맞췄다. 들어가는 발사가 4회 이상이면 기준 2샷, 2~3회면 3샷, 딱 1회면 4샷이다. 채택 하한은 2번 홀부터 1회(1.0.0 설계와 같은 "1회 이상"), 1번 홀은 3회다. 목표 분포는 2샷 약 25%, 3샷 약 45%, 4샷 약 30%로 잡았다. 1.0.0의 파 2·3 골격에 1.1.0이 더한 4샷을 붙이되, 1번 홀은 연습 홀답게 2샷이 많고 행성이 늘어나는 뒤쪽 홀일수록 3·4샷이 많아지는 모양이다. 조건 자체(직선 금지, 경로 차단)는 그대로 두었다.
생성 시간은 두 단계로 줄였다. 먼저 결과가 같은 최적화다. 기하 조건(blocked/near)을 384회 발사보다 먼저 확인하고, 직선 방향 발사를 먼저 쏴서 직선으로 들어가는 순간 평가를 멈춘다. 들어가는 횟수는 쏘는 순서와 무관하고, 80번을 다 쓰는 드문 경우에만 건너뛴 배치를 다시 평가하므로, 1.1.1 코드로 같은 시드 30일치를 만들어 비교했을 때 코스가 한 홀도 다르지 않았다. 이것만으로 코스당 평균 2.3초가 0.87초가 됐다(같은 PC, Node). 그다음 생성을 홀 단위 제너레이터로 나누고 Web Worker에서 돌린다. 화면은 "코스 만드는 중 n/9" 로딩을 띄우고 1번 홀이 나오는 즉시(중앙값 15ms) 게임을 시작하며, 나머지 홀은 플레이하는 동안 워커가 만든다. 워커를 쓸 수 없는 환경에서는 메인 스레드에서 홀 하나씩 끊어 만든다. 어느 쪽이든 같은 RNG 흐름을 같은 순서로 쓰므로 COURSE.generate(seed)와 결과가 같다.
| DAILY 30일(270홀) | 1.1.1 | 1.1.2 |
|---|---|---|
| 기준 2샷 / 3샷 / 4샷 홀 | 0 / 86 / 184 | 81 / 119 / 70 |
| 홀당 들어가는 발사 수 | 최소 3, 중앙값 4, 최대 10 | 최소 1, 중앙값 3, 최대 10 |
| 코스 기준 샷 합계 | 30~36 | 23~31 (중앙값 27) |
| 대체 홀 사용 | 0 | 0 |
| 코스 생성 시간(Node) | 평균 약 2.3초, 메인 스레드 | 평균 약 0.35초, 워커 · 1번 홀까지 약 15ms |
365일치로 넓혀도 2·3·4샷이 30% / 47% / 23%였고 대체 홀은 한 번도 쓰이지 않았다. 1번 홀은 2샷이 80%, 7~9번 홀은 2샷이 10% 안팎으로 홀 번호를 따라 기준 샷이 올라간다. 이 변경으로 1.1.2부터 DAILY 코스가 1.1.1과 달라졌고, 변경 이력에도 그렇게 적었다.
정리
"쏴 보고 판단한다"는 방식 덕분에 SLINGSTAR의 모든 홀은 적어도 하나의 정확한 해가 있는 상태로 나온다. 플레이어가 그 방향과 세기를 찾지 못할 수는 있어도 풀 수 없는 홀은 없다. 1.1.0에서 직선 금지를 넣은 뒤로는 중력을 쓰지 않고 넣을 수 있는 홀도 걸러진다. 실측에서 드러난 두 과제, 사라진 기준 2샷과 시작 지연은 1.1.2에서 문턱을 실측 척도에 맞추고 생성을 Web Worker로 옮겨 정리했다. 남은 한계는 지표 자체다. 들어가는 발사 수는 "한 번에 넣을 창의 넓이"만 재고, 행성에 착지해 두 번에 나눠 가는 길이 얼마나 쉬운지는 보지 않는다. 그 길까지 재려면 착지 지점마다 다시 384회를 쏴야 하니, 워커로 옮겨 생긴 여유를 어디에 쓸지 정할 때 다시 볼 항목이다.