휘는 선을 계산하는 법: ◎ 중력 별 중계의 기하 처리
STARLINK에서 다른 퍼즐과 가장 다른 부분은 ◎ 중력 별이다. 서로 가려진 두 별을 중력 별에서 휘어 이어 주는 규칙인데, 화면에서는 부드러운 곡선으로 보이고 판정은 두 개의 직선 구간으로 한다. 보이는 모양과 판정하는 모양이 다르면 보통 버그의 온상이 되는데, 이를 어떻게 일치시켰는지 정리한다.
왜 중력 별이 필요했나
시야 규칙을 넣고 나니 "가까이 있지만 사이에 별이 끼어 이을 수 없는 쌍"이 판마다 여럿 생겼다. 이것은 추론 재료로는 좋지만, 그대로 두면 이 쌍들은 퍼즐에서 그냥 버려지는 정보다. 가려진 쌍을 이을 수단을 하나 주면 "직접은 안 되지만 우회하면 된다"는 두 번째 층의 판단이 생긴다. 중력 별은 이 우회 수단을 하나의 별로 형상화한 것이다. 중력 별 자체는 선의 끝점이 될 수 없고, 숫자는 그곳을 지나가는 선의 개수를 뜻한다.
이 규칙을 어떤 쌍에 허용할지가 첫 번째 결정이었다. 직접 이을 수 있는 쌍까지 중계를 허용하면 같은 두 별 사이를 잇는 방법이 둘이 되어 해가 늘어나고, 플레이어도 어느 쪽을 택해야 하는지 근거가 없다. 그래서 중계는 직선 후보가 아닌 쌍에만 허용한다. 입력 쪽에서도 서로 보이는 두 별을 중력 별로 이으려 하면 "서로 보이는 별은 직접 이어야 해요"라고 알려 준다.
후보 조건: 보이고, 닿고, 너무 꺾이지 않을 것
중력 별 G를 거치는 A→G→B가 후보가 되려면 세 조건을 만족해야 한다. A와 B가 각각 G에서 3.2칸 이내이며 가려지지 않아야 하고, A와 B가 직선 후보 쌍이 아니어야 하며, 꺾이는 각이 너무 날카롭지 않아야 한다.
세 번째 조건이 없으면 A와 B가 G의 같은 쪽에 몰려 있을 때 선이 G까지 갔다가 거의 되돌아오는 바늘 모양이 된다. 이 모양은 화면에서 두 구간이 포개져 한 줄처럼 보이고, 곡선으로 그리면 G 근처에서 작은 고리가 생긴다. 그래서 G에서 A로 향하는 방향과 B로 향하는 방향 사이의 각이 60°보다 작으면, 다시 말해 선이 120°보다 급하게 꺾이면 후보에서 뺀다. 각도는 구하지 않고 내적으로 코사인만 계산해 0.5와 비교한다. 초월 함수를 피하는 이유는 같은 시드가 모든 기기에서 같은 퍼즐을 내야 하기 때문이다.
// js/puzzle.js 발췌 (중계 후보)
if(direct.has(i*N+j)) continue; // relays only join stars that cannot see each other
const ux=stars[i].x-stars[g].x, uy=stars[i].y-stars[g].y,
vx=stars[j].x-stars[g].x, vy=stars[j].y-stars[g].y;
const cosv=(ux*vx+uy*vy)/Math.sqrt((ux*ux+uy*uy)*(vx*vx+vy*vy));
if(cosv>0.5) continue; // bend sharper than 120° not allowed
edges.push({a:i,b:j,via:g,segs:[[i,g],[g,j]]});
튜토리얼 퍼즐을 예로 들면 왼쪽 별 A(1, 1)와 오른쪽 별 B(3.3, 1) 사이에 가운데 별(2.2, 1)이 있어 직접 이을 수 없다. 중력 별 G는 (2.2, 2.6)에 있다. G→A 벡터는 (−1.2, −1.6), G→B는 (1.1, −1.6)이고 코사인은 약 0.32다. 0.5보다 작으므로 두 방향 사이가 약 71°로 벌어져 있고 후보가 된다.
판정은 꺾은선 두 구간으로
휘는 선은 내부적으로 구간 목록 segs를 가진다. 직선은 구간 하나, 중계선은 A–G와 G–B 두 구간이다. 충돌 검사는 두 선의 구간끼리 모든 조합을 비교한다. 이렇게 하면 직선과 휘는 선, 휘는 선끼리를 같은 코드로 처리할 수 있다.
여기서 경계 사례가 둘 생긴다. 하나는 같은 중력 별을 지나는 두 휘는 선이다. 둘은 G에서 만나지만, 끝점을 공유하는 접촉은 교차로 보지 않는다는 원칙에 따라 허용된다. 중력 별 숫자가 2일 수 있는 것은 이 덕분이다. 다른 하나는 두 휘는 선이 같은 구간을 공유하는 경우다. A→G→B와 A→G→C는 A–G 구간이 완전히 겹친다. 끝점만 보면 "공유 끝점 예외"에 걸려 통과해 버리므로, 구간 두 끝이 모두 같으면 먼저 충돌로 처리하는 검사를 따로 넣었다. 화면에서 한 줄로 포개진 두 선은 구분할 수 없기 때문이다.
생성기는 중력 별 하나가 받는 휘는 선을 최대 2개로 제한한다. 중력 별마다 1개를 기본으로, 40% 확률로 2개를 붙인다. 3개 이상이면 G 주변이 곡선으로 뒤엉켜 읽기 어려워진다.
화면은 곡선, 판정은 직선인데 어긋나지 않는 이유
렌더러는 휘는 선을 A에서 G 직전까지 직선으로 그리고, G 근처에서 G를 제어점으로 하는 2차 베지어 곡선으로 방향을 튼 뒤, 다시 B까지 직선으로 그린다. 곡선이 시작하는 지점은 G에서 0.28칸 떨어진 곳이다. 따라서 화면의 곡선이 판정용 꺾은선과 다른 부분은 G를 중심으로 반지름 0.28칸 안에 갇힌다.
한편 가림 규칙은 다른 선이 별 중심에서 0.42칸 이내를 지나가지 못하게 막는다. 중력 별도 별이므로 이 규칙을 그대로 받는다. 즉 G를 거치지 않는 선은 G 반경 0.42칸 안에 들어올 수 없고, 곡선이 직선에서 벗어나는 영역(0.28칸)은 그보다 안쪽이다. 결과적으로 "화면에서는 곡선끼리 닿아 보이는데 판정은 통과" 같은 불일치가 생기지 않는다. 두 수치를 따로 정했다면 쉽게 놓칠 수 있는 관계라서, 렌더링 반경을 바꿀 때는 가림 반경보다 작게 유지해야 한다.

입력: 별 → ◎ → 별
휘는 선은 탭 세 번으로 긋는다. 별을 고르고, 중력 별을 고르고, 목표 별을 고른다. 드래그 중에 손가락을 중력 별 위에서 떼면 그 중력 별이 경유지로 지정되고, 다음 탭으로 목표 별을 고른다. 경유지가 지정되면 출발 별에서 G까지 보라색 점선이 그려져 지금 어떤 상태인지 보여 준다.
중력 별에서 시작하려 하면 "중력 별에서는 시작할 수 없어요. 별 → ◎ → 별 순서예요"라는 안내와 함께 거부한다. 휘는 선이 놓이지 않을 때는 이유를 넷으로 나눠 알려 준다. 서로 보이는 쌍이면 직접 이으라고, A나 B가 G에서 3.2칸보다 멀면 "중력 별까지 너무 멀어요", 가려져 있으면 "중력 별에서 보이지 않는 별이에요", 꺾임 제한에 걸렸을 때만 "중력 별에서 너무 급하게 꺾여요"라고 표시한다. 검사 순서는 후보를 만드는 코드와 같다.
1.0.0에는 거리 검사가 없어서, 거리 초과가 가림 문구나 꺾임 문구로 나왔다. 난이도별 시드 100개 퍼즐에서 중계 후보가 아닌 (A, G, B) 조합을 모두 넣어 보니, 실제 원인이 거리 초과인 34,076건 가운데 20,705건이 "보이지 않는 별", 13,371건이 "급하게 꺾여요"로 안내됐다. 먼 선분은 지나가는 길에 다른 별을 스치기 쉬워 가림으로 잡힌 것이다. 1.0.1에서 거리 검사를 가림·꺾임보다 먼저 두어 네 원인 모두 실제 원인과 같은 문구가 나오게 했다(직접 연결 11,155건, 거리 34,076건, 가림 618건, 꺾임 30건). 놓인 휘는 선을 지울 때는 두 구간 중 어느 쪽을 탭해도 된다.
풀이기 입장에서 중력 별은 숫자를 가진 또 하나의 별이다. 휘는 선 하나는 A, B, G 세 별의 남은 필요 수를 동시에 하나씩 줄인다. 다만 연결 검사에서는 중력 별이 빠진다. 휘는 선은 A와 B를 잇는 선이지 G를 별자리에 포함시키는 선이 아니기 때문이다.
정리
중력 별은 시야 규칙이 만든 "이을 수 없는 쌍"을 다시 퍼즐 재료로 돌려놓는 장치다. 구현의 핵심은 판정과 표현을 분리하되 둘의 차이가 다른 규칙이 막아 둔 영역 안에서만 일어나게 한 것이다. 판정은 꺾은선 두 구간과 내적 기반 120° 제한, 구간 공유 금지로 하고, 표현은 반지름 0.28칸의 곡선으로 한다. 앞으로 중력 별을 하나의 판에 셋 이상 넣는다면 휘는 선끼리 G 주변에서 몰리는 문제를 다시 봐야 한다.