Skip to content

Latest commit

 

History

History
287 lines (205 loc) · 12.1 KB

File metadata and controls

287 lines (205 loc) · 12.1 KB

초기 맵 생성

mapSeed 하나에서 Python 서버와 TypeScript 클라이언트가 비트 단위로 같은 초기 격자를 만드는 명세다. docs/netcode.md 의 matchInit.mapSeed 가 이 문서의 유일한 입력이다.

이 문서는 Phase 3의 결정론적 기준선이다. Phase 6에서 지형 종류를 늘릴 수 있지만, 규칙을 바꾸면 MAPGEN_VERSION과 골든 리플레이를 함께 갱신한다.


1. 공개 API

buildMap(mapSeed: uint32) -> MaterialGrid[960 * 540]
chooseSpawnCells(grid, playerCount: 2..6, spawnSeed: uint32 = 0) -> cellX[]
  • buildMap은 새 격자를 반환하고 전역 MaterialGrid를 수정하지 않는다.
  • 생성 직후 호출자가 격자를 sim에 적재하고, seed = mapSeed, step = 0, 전 행 활성으로 정착시킨다. 정착 후 연결성 재검사 루프까지 끝나야 초기 상태가 확정된다.
  • 스폰은 정착이 끝난 격자에서 선택한다. 생성 전 표면을 기준으로 잡으면 첫 정착 뒤 탱크가 공중에 남거나 서로 가까워질 수 있다.

2. 금지 사항

  • float, Math.random(), random.*, 런타임 노이즈 라이브러리 금지
  • 언어별 라이브러리 보간 함수 금지
  • set/dict 순회 순서에 의존하는 배치 금지
  • 생성 직후 전체 격자를 네트워크로 보내는 정상 경로 금지

난수는 전부 terrain.md §7.1의 hash32를 좌표 해시로 사용한다.


3. 지표면

상수:

이름 값
MAPGEN_VERSION 3
NOISE_SHIFT 7
SURFACE_BASE 262 cells
SURFACE_AMP 70 cells
BEDROCK_Y 522

열 x의 지표면은 128셀 간격 정수 값노이즈로 만든다.

i  = x >> 7
f  = x & 127
a  = hash32(mapSeed, i,     0x4D47, 0) & 0xFFFF
b  = hash32(mapSeed, i + 1, 0x4D47, 0) & 0xFFFF
n  = a + (((b - a) * f) >> 7)
y0 = 262 + (((n - 32768) * 70) >> 16) - mainPeak - secondaryPeak + valley

y0는 72..342로 clamp한다. 봉우리 단면은 max(0, width - abs(x - center)) * amplitude // width인 정수 삼각형이다. 주봉은 중심 240..720, 반폭 145..272, 높이 148..211이며, 반대쪽에 중심 약 190/770, 반폭 120..183, 높이 76..123의 보조 봉우리를 둔다. 두 중심 사이에는 반폭 110..173, 깊이 32..63의 골짜기를 더한다. 모두 hash32(mapSeed, index, 0x504B, 0)에서 파생한다. 모든 연산은 정수이며 지형 형상은 양 언어가 동일하게 생성한다.


4. 지층 — 지질 구역

각 열에서 y0부터 [SAND, SOIL, SAND, SCREE, SOIL] 순서로 채우고, 남는 깊이는 ROCK, 그 아래는 BEDROCK 이다. 두께는 열마다 다르다 — 맵을 좌우로 나눈 구역이 정한다.

왜 구역을 나누는가. Phase 3 기준선은 960열 전부가 같은 두께 (18/28/10/12/40)였다. 그래서 실측으로 갈라놓은 안식각 SAND 26.6° · SOIL 40.3° · SCREE 44.2° 가 전술적으로 아무 일도 하지 않았다 — 어디를 파도 같은 순서로 같은 것이 나오니 서 있는 자리에 의미가 없었다. CLAUDE.md 작업 원칙("무기를 늘리기 전에 지형을 완성한다")이 가리키는 자리다.

4.1 프로파일

# 이름 SAND SOIL SAND SCREE SOIL 기반암 깊이
0 모래 분지 34 14 6 10 30 —
1 점토 대지 0 48 4 8 40 —
2 자갈 사면 0 0 0 34 30 —
3 암반 선반 6 12 0 6 16 y0 + 132

기반암 깊이가 있는 구역은 BEDROCK_Y 대신 min(BEDROCK_Y, y0 + depth) 를 쓴다.

표면 재질이 구역마다 다르다. 앞 밴드를 0 으로 두어 아래 재질을 노출시킨다. 첫 판에서는 네 구역 모두 SAND 로 시작했는데, 그러면 화면상 전부 모래라 플레이어가 서 있는 땅을 구분할 수 없다 — 지질이 달라도 보이지 않으면 전술이 되지 않는다.

4.2 배치

count = 4 + (hash32(mapSeed, 0, 0x9E07, 0) & 1)     // 4 또는 5 구역
width = floorDiv(960, count)
index = floorDiv(x, width)          // count 를 넘으면 count-1

구역 index 의 프로파일은 순열 provinceOrder(mapSeed)[index % 4] 다. 순열은 정수 Fisher-Yates 이고 hash32(mapSeed, i, 0x9E07, 1) 를 쓴다.

왜 순열인가. 처음에는 구역마다 hash32(...) % 4 로 독립 추첨했는데, 그러면 시드에 따라 맵 전체가 한 지질로 덮인다 (실측: 모래 809열 / 960). 다양성이 이 변경의 전부라 우연에 맡기지 않는다. 순열은 한 맵 안에 네 지질이 전부 나오는 것을 보장한다.

4.3 경계 혼합

구역 경계에서 지층이 수직으로 잘리지 않게 PROVINCE_BLEND = 48 셀에 걸쳐 섞는다. 구역 안 localX < 48 인 열은 이전 구역과 정수 가중 평균한다.

blend(a, b, t) = a + floorDiv((b - a) * t, 48)      // t = localX

index == 0 인 첫 구역은 혼합하지 않는다.

4.4 산악 사면과 초기 정착 비용

v3에서는 각 열의 좌우 6셀 지표와의 최대 고도차 relief를 계산한다. 급경사가 한 지질 구역 전체를 덮으면 그 구역의 표면은 암반이 될 수 있다. 검증은 암반과 최소 두 종류의 느슨한 지표 재질이 함께 나타나는지를 확인하며, 네 지질 프로파일의 배치 순열은 그대로 유지한다. relief <= 2이면 기존 지층 두께를 유지하고, relief == 3이면 각 두께를 1/4로 줄인다. relief > 3인 급경사는 퇴적층 대신 파괴 가능한 ROCK을 노출한다. 높은 봉우리를 느슨한 흙으로만 만들면 시작 정착에 예산 대부분을 쓰므로 암반 코어가 형상을 지탱한다. 6개 교차 골든 시드에서 초기 정착은 4~17스텝이다. 자동자·안식각·정착 상한 자체는 바꾸지 않는다.

아래는 v2 지질 구역 도입 당시의 실측이다. 당시에는 지표 아래 두께만 바뀌었으므로 새로 노출되는 경사가 없다. 노출된 SOIL(40.3°)·SCREE(44.2°)도 지표 경사(최대 25°)보다 안식각이 급해서 그대로 선다.

초기 정착 초기 연결성 변환
Phase 3 기준선 (균일 지층) 최악 9 스텝 0 셀
지질 구역 최악 9 스텝 0 셀

12 시드 실측이다. 상한은 MAX_SETTLE_STEPS = 12000 이므로 여유가 11,991 스텝 남는다. createMatch 는 초기 정착이 상한을 넘으면 예외를 던져 매치가 아예 안 만들어지므로 이 여유를 확인하지 않고 지층을 바꾸면 안 된다.

파헤쳤을 때는 확실히 다르다. 같은 반경 30 크레이터의 붕괴 규모:

정착 스텝 이동 셀 붕괴 폭
가장 약한 자리 408 5,988 82 셀
가장 무른 자리 2,076 32,244 152 셀

5배 차이다. 이게 "어디에 서 있느냐"가 의미를 갖는다는 뜻이다.


5. 기반암 앵커

후반에 맵 전체가 평평해지는 것을 막기 위해 기반암 기둥 4개를 묻는다.

baseX  = [160, 320, 640, 800][i]                 // i = 0..3
jitter = (hash32(mapSeed, i, 0xA11C, 0) & 31) - 15
x      = clamp(baseX + jitter, 24, 935)
top    = clamp(surface[x] + 96, 220, 500)

x-3 .. x+2, top .. 539를 BEDROCK으로 채운다. 최하단 기반암층과 연결되므로 연결성 검사의 영구 앵커가 된다.

기준점과 지터 범위는 아래 암반 아치의 최대 범위 x=345..615와 겹치지 않게 고정한다. 생성 순서는 지층 → 기반암 앵커 → 암반 아치 → 좌우 봉인이다.


6. 암반 아치

모래 자동자의 구조 붕괴를 첫 맵부터 검증할 수 있도록 암반 아치 하나를 넣는다.

center = 480 + ((hash32(mapSeed, 0, 0xA2C4, 0) & 127) - 63)
left   = center - 72
right  = center + 72
roofY  = max(surface[left..right]) + 24
  • 지붕: x = left..right, y = roofY..roofY+7을 ROCK
  • 빈 공간: x = left+8..right-8, y = roofY+8..roofY+56을 EMPTY
  • 다리: 좌우 8열을 roofY..legBottom까지 ROCK
  • legBottom = max(roofY+64, surface[left]+112, surface[right]+112), 상한 510

다리는 아래 ROCK층에 닿고, 한쪽 다리만 끊으면 버티며 두 다리가 모두 끊기면 지붕이 SCREE로 변환되어 무너진다. BEDROCK으로 만들지 않는 이유는 파괴 가능해야 하기 때문이다.


7. 좌우 봉인

terrain.md §1.1대로 최외곽 2열을 봉인한다.

  • 왼쪽은 x=2, 오른쪽은 x=957의 최초 비-EMPTY 행을 기준으로 한다.
  • x=0,1,958,959를 그 행부터 바닥까지 BEDROCK으로 채운다.
  • 인덱스 래핑으로 이웃 열을 읽지 않는다.

8. 초기 정착

호출 순서는 고정한다.

grid = buildMap(mapSeed)
terrain.seed = mapSeed
terrain.resetGateCache()
terrain.connectivity()
terrain.markAll()
terrain.step = 0
정착 → 연결성 재검사 → 필요하면 정착 재개
  • 자동자 스텝 예산은 MAX_SETTLE_STEPS를 공유한다.
  • 연결성 재개 상한은 CONNECTIVITY_MAX_ROUNDS다.
  • 초기 정착이 강제 종료에 걸리면 그 맵은 생성 실패다. 정상 매치에 사용하지 않는다.

9. 스폰 선택

플레이어 수 n은 2~6이다. spawnSeed는 정상 매치에서 mapSeed다. 정착된 격자의 20..939열에서 실제 지면이 있고 이미 고른 위치와 36셀 이상 떨어진 후보를 고른다. 양끝(x±6)과 중앙의 높이 차 합이 12셀 이하인 후보만 먼저 검사하고, 없을 때만 경사 제한을 푼다. 서로 겹치지 않는 실제 지면이 부족하면 실패하며 임의의 공중 좌표를 반환하지 않는다.

hash32(spawnSeed, n, 0x5A17, 0) % 3으로 다음 배치 중 하나를 선택한다.

  • 근접 군집: 시드로 정한 중심(80..879)의 반경 48 + n*24 안을 선호한다.
  • 장거리 분산: 32 + slot*(960-64)//(n-1)의 ±64셀 안을 선호한다.
  • 전역 무작위: 위치에 대한 선호 구역이 없다.

구역 바깥 거리 penalty에 대해 점수는 penalty*65536 + (hash32(spawnSeed, x, slot, 0x5A17) & 65535)다. 최소 점수, 동점이면 작은 열을 고른다. 마지막에 시드 기반 Fisher–Yates로 슬롯 순서를 섞는다. 같은 격자와 시드는 같은 결과를 내지만 시드가 바뀌면 간격·순서 모두 달라진다.

스폰의 y는 ballistics.makeTank()가 정착된 지표면에서 계산한다.

9.1 슬롯 배정은 라운드마다 돌린다

v2는 x 오름차순 배치라 슬롯 0 이 왼쪽 끝을 받았다. v3는 첫 매치부터 순서를 섞되, 라운드 간에는 기존 회전 규칙도 유지한다. 지질 구역이 생기기 전에는 고정 배치여도 됐지만(960열이 같은 지층이었다) 이제는 자리마다 발밑이 다르다. 실측한 스폰 발밑 60셀 구성:

자리 구성 뜻
암반 선반 BEDROCK 52 / SOIL 8 사실상 파괴 불가
모래 분지 SAND 40 / SOIL 14 통째로 쓸려나간다

회전이 없으면 슬롯 번호가 5라운드 내내 고정 유불리가 된다. 그래서 match 의 beginRound(players, spawnCells, rotation) 이 rotation = roundNo - 1 로 슬롯과 자리를 어긋나게 돌린다.

player[i].x = spawnCells[(i + rotation mod n) mod n]

4인 · 5라운드에서 슬롯별 "단단한 땅" 누적 격차 실측:

최대 격차
회전 없음 4,580‰
회전 있음 916‰

잔여 916‰ 은 5라운드를 4인으로 나누면 한 슬롯이 첫 라운드 자리를 두 번 받기 때문이다.

지질을 평탄화해서 공정하게 만드는 방법도 있다. 그러면 이 변경의 목적이 사라진다. 자리를 돌리는 쪽이 차이를 남기면서 매치 단위로 균등하게 만든다.


10. 검증

test_cross_sim_mapgen은 여러 mapSeed와 플레이어 수에 대해 아래를 대조한다.

  1. 정착 전 격자 체크섬
  2. 초기 정착 스텝 수와 정착 후 체크섬
  3. 질량
  4. 스폰 열 배열
  5. 좌우 2열 봉인과 기반암 불변식

초기 맵 골든은 mapSeed만 담고 .grid.gz 사이드카를 사용하지 않는다.