개요

반 발짝 옆에서는 되는데 여기서는 안 된다. 같은 바위, 같은 각도, 같은 클릭.

광질 게임을 만들다 만난 이 자리에서는 무엇이 달랐을까? 바위도 서버도 아니었다. 달라진 건 내 몸의 위치였다.

찍을 대상을 고르는 선이 바위보다 내 몸에 먼저 걸렸고, 2편에서 내가 세운 “자기 자신이면 무시” 필터가 그 클릭을 조용히 버렸다..

이번엔 채굴 방식에 재미 요소를 넣어줬다. Unity NGO(Netcode for GameObjects)처럼 서버가 판정을 쥐는 구조에서 찍는 순간의 소리와 흔들림, 바위에 금이 가거나 부서지는 결과가 화면에서 같아야 한다. 이 둘을 어떻게 나눴는지, 그리고 그 사이에 숨어 있던 Player 몸 버그를 적어보겠다!

손맛은 내 화면에서, 결과는 서버에서

서버 권한 게임에서 타격은 클라이언트 의도 → 서버 검증 → 반응 → 발행을 거친다. 연출까지 서버 확정을 기다리면 누를 때마다 핑만큼 늦게 소리가 난다. 그래서 연출을 “누가 반응 속도를 원하나”로 나눴다.

연출 누가 원하나 어디서
모터·그라인더 루프음, 불꽃, 카메라 흔들림 누르는 본인(반응 속도) owner 화면에서 입력 기준으로 즉시
금 가기, 파괴 모두(정확성) 서버가 확정한 상태만

즉시 연출은 판정에 쓰지 않는다. 서버가 타격을 거부해도 소리는 날 수 있는데, 이건 알고 고른 trade-off다.

내 화면의 즉시 연출과 서버 확정 뒤 모든 화면에 나오는 금 가기의 시간 순서 개념도

결과 쪽은 RPC를 따로 만들지 않고 이미 동기화되는 상태에 얹었다.

  • HP 변화 → NetworkVariable<int>.OnValueChanged에서 금 가는 단계 갱신
  • 파괴 → OnNetworkDespawn에서 네트워크 없는 파편 파티클을 Instantiate

여기서 두 군데 함정이 있었다.

  • OnValueChanged는 값이 바뀔 때만 불린다. 늦게 들어온 클라이언트는 이미 금 간 바위를 멀쩡하게 본다. OnNetworkSpawn에서 현재 값을 한 번 적용해야 한다.
  • OnNetworkDespawn은 세션 종료·씬 언로드 때도 불린다. NetworkManager.ShutdownInProgress와 gameObject.scene.isLoaded로 거르지 않으면 Play를 멈출 때마다 모든 바위가 터진다.

상태 변화 자체가 사건이 되는 경우도 있었다. 약점 위치는 NetworkVariable<Vector3>인데, 이 값이 바뀌었다는 건 누군가 약점을 맞혔다는 뜻이다. 그래서 명중 소리는 OnValueChanged에서 이전 위치에 낸다. RPC가 하나 줄었다.

찍기와 드릴이 같은 검증을 탄다

기본 도구는 드릴 킷이 업데이트되지 않아 찌르는 곡괭이처럼 쓰는 상태로 시작하고, 업데이트 칩을 얻으면 지속 타격 드릴이 된다. 찍기에는 약점 미니게임이 붙는다. 이 방향은 팀에서 아직 검토 중이다.

곡괭이 상태로 찍는 장면이다. 노란 구가 약점이고, 칠수록 바위가 어두워지는 게 금 가는 단계다. 마지막 파편은 서버가 바위를 despawn한 뒤에 나온다.

바닥에서 도는 작은 파란 네모가 업데이트 칩이다( 색 원판은 스폰 지점 ). 칩을 주운 뒤엔 누르고 있는 동안 계속 갈고, 튀는 불꽃은 내 화면의 즉시 연출이다. 두 영상 모두 소리가 있다.

입력 방식이 둘(이산 찍기, 지속 드릴)이 됐지만 서버 검증은 하나로 뒀다. 입력부는 “언제 HitRpc를 보낼지”만 다르고, 검증 코드는 건드리지 않는다.

둘 다 서버 쿨다운을 클라이언트 간격보다 조금 짧게 둔다( 찍기 0.5초 / 서버 0.45초, 드릴 0.2초 / 서버 0.15초 ). 지터로 요청이 당겨 도착해도 거부되지 않게 하려는 여유다. 서버는 조준점도 그대로 믿지 않고, 대상 표면 근처인지·손이 닿는지·동기화된 시선과 30° 안에서 맞는지 본다.

약점은 찍은 사람 쪽 면에만 생기게 했다. 바위를 돌아가며 약점을 찾는 게 피곤해서다. 약점이 반대편이라 칠 수 없었던 찍기는 빗나감으로 치지 않고 약점을 이쪽으로 옮겨 준다. 첫 영상의 노란 구도 맞을 때마다 내 쪽 면 안에서만 옮겨 다닌다.

동료의 드릴 소리는 바위에서 난다

내 드릴 소리는 내 화면에서 바로 낸다. 그럼 옆 동료가 드릴을 쓸 때 내 화면에서는 소리가 어디서 나야 할까?

소리 출처를 둘로 나눴다.

  • 모터음: 도구에서 난다 → 플레이어 위치
  • 갈리는 소리: 날과 광물이 닿는 곳에서 난다 → 광물 표면

갈리는 소리를 바위 표면에서 내려면 접촉 지점이 필요한데, 이걸 네트워크로 보내지 않았다. 보낸 건 원인(지금 무엇을 갈고 있나)뿐이다.

  • 서버가 HitRpc를 수락할 때 대상 id를 기록하고, 마지막 수락 뒤 0.3초(tick 0.2초 + 지터 여유)가 지나면 0으로 되돌린다. 클라이언트가 “나 드릴 중”이라고 쓰는 상태가 아니라 서버 권한 규칙과 부딪히지 않는다.
  • 원격 클라이언트는 대상 콜라이더.ClosestPoint(동료 눈 위치)로 접촉 지점을 직접 계산한다. 눈 위치(CameraPoint)는 이미 NetworkTransform으로 동기화되니 추가 전송이 없다.

원격 드릴 소리의 출처: 모터음은 플레이어 위치, 갈리는 소리는 ClosestPoint로 계산한 광물 표면 개념도

트래픽은 대상이 바뀔 때만 생긴다. 바위가 부서져 사라지면 id 조회가 실패하니 소리도 따로 끄지 않아도 멈춘다.

바위 쪽에서 3D 소리를 내는 방법도 있었지만, 그러면 드릴을 쥔 본인 화면에서는 내 2D 소리와 바위의 3D 소리가 겹쳐 두 번 난다. 그래서 본인은 2D, 원격 복제본만 3D(spatialBlend = 1)로 나눴다.

감쇠 거리는 근접 보이스(32m까지 들림, 8m 안은 대화 거리)와 맞춰 Linear, min 8m, max 32m로 뒀다. Logarithmic이 아닌 이유는 “32m 밖에선 안 들림”을 보장하고 싶어서다. Unity 문서 기준 Linear는 maxDistance에서 볼륨이 0이 되고, Logarithmic은 maxDistance를 무시하고 거리에 따라 끝없이 작아지기만 한다.

클릭이 씹히는 범인은 내가 넣은 필터였다

다시 개요의 그 클릭으로.

증상은 이랬다. 화면 중앙에 바위를 두고 우클릭해도 가끔 인식이 안 되고, 플레이어를 조금 움직이면 다시 된다. 쿨다운 문제는 아니었다.

처음엔 레이 사거리나 조준을 의심했다. 그래도 서버 로그부터 확인했다. 서버는 거리·각도·대상이 이상한 요청을 경고로 남기는데, 72회 수락 중 거부가 0건이었다. 씹힌 클릭은 서버까지 가지도 않았다.

이렇게 단정할 수 있던 건 거부 로그를 나눠 뒀기 때문이다. 지터로 생기는 쿨다운 거부는 정상이라 조용히 넘기고, 이상한 요청만 경고로 남겼다. 그래서 “경고가 없다”가 그대로 “클라이언트에서 막혔다”는 증거가 됐다.

클라이언트 쪽 후보(사거리 15m, 눈이 바위 안에 들어갈 가능성, 약점 표시 구의 콜라이더)는 수치로 하나씩 지웠다. 남은 건 레이 자체를 보는 것이었다. 클릭마다 레이 시작점, 첫 히트, RaycastAll 전체 목록, 시작점을 감싼 콜라이더(OverlapSphere)를 찍는 임시 진단 로그를 달고, 같은 자리에서 실패를 재현한 로그와 움직여서 성공한 로그를 나란히 놓았다.

[StabDiag] f129 origin=(-10.62, 5.83, 21.66) dir=(0.07, -0.39, 0.92) first=Rock_North@7.3 netObj=True insideOrigin=[Multiplayer Controller(Clone),Multiplayer Controller(Clone)] all=[Rock_North@7.3 > Floor_North@14.8] playerPos=(-10.61, 3.27, 21.67)
[Event] OreHit attacker = 0 targetid = 5 at (-10.10, 2.94, 28.40)
[StabDiag] f183 origin=(-10.59, 5.83, 21.69) dir=(0.07, -0.37, 0.93) first=Multiplayer Controller(Clone)@0.0 netObj=True insideOrigin=[Multiplayer Controller(Clone),Multiplayer Controller(Clone)] all=[Multiplayer Controller(Clone)@0.0 > Rock_North@7.3] playerPos=(-10.59, 3.27, 21.69)
[StabDiag] f213 origin=(-10.59, 5.83, 21.69) dir=(0.07, -0.37, 0.93) first=Multiplayer Controller(Clone)@0.0 netObj=True insideOrigin=[Multiplayer Controller(Clone),Multiplayer Controller(Clone)] all=[Multiplayer Controller(Clone)@0.0 > Rock_North@7.3] playerPos=(-10.59, 3.27, 21.69)
[StabDiag] f274 origin=(-10.59, 5.83, 21.69) dir=(0.07, -0.37, 0.93) first=Multiplayer Controller(Clone)@0.0 netObj=True insideOrigin=[Multiplayer Controller(Clone),Multiplayer Controller(Clone)] all=[Multiplayer Controller(Clone)@0.0 > Rock_North@7.3] playerPos=(-10.59, 3.27, 21.69)
[StabDiag] f545 origin=(-9.93, 5.83, 21.64) dir=(0.07, -0.37, 0.93) first=Rock_North@7.3 netObj=True insideOrigin=[Multiplayer Controller(Clone),Multiplayer Controller(Clone)] all=[Rock_North@7.3] playerPos=(-9.93, 3.27, 21.64)
[Event] OreHit attacker = 0 targetid = 5 at (-9.42, 3.10, 28.40)
  • f129는 바위를 맞히고 [Event] OreHit까지 갔다.
  • 3cm 옆(f183, f213, f274)에서는 세 번 모두 first가 내 몸 @0.0이고, 뒤에 [Event]가 없다. 바위(Rock_North@7.3)는 목록 두 번째에 그대로 있었다.
  • 0.66m 옮긴 f545에서 다시 바위. 되고 안 되고를 가른 건 반 발짝도 아니고 3cm였다.
  • insideOrigin에는 성공한 줄에도 내 콜라이더 두 개가 잡혀 있다. 시작점이 내 몸 안에 있는 건 늘 그랬고, 거리 0 히트는 일부 위치에서만 나왔다.

보자마자 2편이 떠올랐다. 레이가 바위보다 먼저 자기 몸을 거리 0으로 맞혔고, 맞은 대상이 내 NetworkObject라 2편에서 넣은 “자기 자신이면 무시” 필터가 요청을 그냥 버렸다. 2편에서는 서버 거리 검증이 이상한 값을 잡아줬는데, 이번엔 필터가 앞에서 버리니 로그가 하나도 안 남았다. 기존 Raycast는 거리 0의 내 몸을 먼저 맞히고, RaycastInteract는 내 콜라이더를 건너뛰어 바위를 맞히는 비교 개념도

수정은 레이를 쏘는 쪽에 넣었다. 레이를 제공하는 PlayerMovement에 자기 콜라이더를 건너뛰는 RaycastInteract를 만들고, 같은 레이를 쓰던 세 곳(찍기, 드릴 연출, 아이템 줍기)이 모두 이걸 쓰게 했다. 구조만 요약한 예시다.

// 예시: 자기 자식 콜라이더를 건너뛴 첫 히트를 고른다
public bool RaycastInteract(out RaycastHit hit)
{
    var ray = new Ray(_cameraPoint.position, _cameraPoint.forward);       // 눈에서 시선 방향으로
    var hits = Physics.RaycastAll(ray, _interactRange);                   // 맞은 걸 전부 받는다
    System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance)); // RaycastAll은 순서 보장이 없다

    foreach (var h in hits)
    {
        if (h.collider.transform.IsChildOf(transform)) continue;          // 내 몸(자식 포함)은 건너뛴다
        hit = h;                                                          // 내 몸이 아닌 첫 대상
        return true;
    }
    hit = default;
    return false;
}

레이어 마스크로 거르지 않은 이유는 플레이어 본체가 Default 레이어라서다. 플레이어 레이어를 빼면 동료까지 빠지고, 나중에 만들 팀킬 타격이 막힌다. 그래서 기준을 “어느 레이어인가”가 아니라 “누구의 콜라이더인가”로 잡았다.

검증은 실패 위치 주변 1cm 간격 121개 지점에서 했다( 위 그림의 121 / 121 ).

하나 걸리는 건 남았다. Unity 문서는 시작점을 감싼 콜라이더를 레이캐스트가 감지하지 않는다고 적고 있다. 위 로그에서도 대부분의 위치에서는 그대로였다. 그런데 이 환경(플레이어에 CapsuleCollider와 CharacterController가 겹쳐 있음)의 특정 위치에서는 거리 0 히트가 돌아왔다. 3cm 차이로 갈리는 조건이 무엇인지는 아직 모른다.

손을 떼도 드릴이 안 멈출 때

비슷한 결의 버그가 하나 더 있었다. 가끔 손을 뗀 뒤에도 드릴 소리가 계속 났다.

지금 와서 추측하면, 에디터 Game View는 포커스를 잃으면 입력 이벤트를 받지 않는다. 누른 채 포커스를 잃고 손을 떼면 release가 사라져 IsPressed()가 true로 남는다. 재현으로 확인하진 못했다.

“커서 잠김 + 창 포커스”일 때만 눌림으로 인정하는 판정을 PickaxeController.IsDrillHeld 한 곳에 두고, 연출과 타격 요청이 같은 판정을 쓰게 했다. 증상은 소리에서 보였지만, 같은 원인이 맞다면 타격 요청도 계속 나가고 있었다. 판정과 연출이 같은 입력 정의를 쓰면 한쪽에서 보인 버그가 다른 쪽에 숨어 있을 수가 없다.

금 가는 단계는 나눗셈을 마지막에

첫 절의 결과 연출, 금 가는 단계는 서버 HP에서 계산한다. 이 식이 틀리면 모든 화면에서 똑같이 틀린다. 계산식은 두 가지로 쓸 수 있다.

// float로 먼저 나누는 식
int cracks = Mathf.FloorToInt((1f - (float)hp / maxHp) * stages);

// 정수로 곱하고 마지막에 나누는 식
int cracks = (maxHp - hp) * stages / maxHp;   // 양수 int 나눗셈은 소수점을 버린다

수학적으로 같은 식인데 결과가 다르다. hp / maxHp를 float로 먼저 나누면 2/3 같은 값이 근사값으로 저장되고, 거기에 단계 수를 곱한 값이 경계 바로 아래(1 대신 0.9999999)로 떨어져 Floor가 한 단계를 깎는다.

  • MaxHp 3, 단계 3, HP 2 → float 식 0, 정수 식 1

MaxHp 1~60, 단계 1~6의 모든 조합을 두 식으로 비교하면 수십 건이 어긋난다. 그런데 MaxHp 12 / 4단계처럼 1/2·1/4·3/4만 거치는 값에서는 우연히 차이가 안 드러난다. 지금 Rock이 쓰는 값이 그 조합이다.

수치가 나중에 ScriptableObject 같은 데이터로 바뀌면 누가 어떤 MaxHp를 넣을지 모른다. 이런 함수는 가능한 입력 전체를 다른 계산 방식과 비교하는 검사가 잘 맞는다. 정수 식의 한계는 오버플로우 하나로, (maxHp - hp) * stages가 int 범위를 넘지 않아야 한다. 이런 전수 비교가 쉬우려면 함수가 인자만 보고 답해야 해서 static으로 뒀다.

HP의 단위는 기본 타격 1회

지금 기본 피해 1은 광물(OreBody)에 숫자로 박혀 있고, 약점(OreWeakSpot)은 공격자의 도구 모드를 직접 조회한다. 새 도구가 생기면 광물 코드가 바뀌는 구조다. “얼마나 세게 치나는 도구, 얼마나 버티나는 광물”로 나눠 도구가 HitInfo를 채워 넘기는 안은 IHittable 계약을 바꾸는 일이라 팀 결정으로 넘겼다.

당장은 “HP의 단위 = 기본 타격 1회”로 정의하고 BaseHitDamage = 1을 단위 상수로 뒀다. Rock MaxHp 12는 기본 타격 12번, 드릴로 약 2.4초다. 매직넘버를 없앤 건 상수를 만들어서가 아니라 1이라는 값의 뜻이 정의되는 자리를 정해서다. 곧 바뀔 약점 보너스는 코드 대신 인스펙터 배열 _bonusByCombo에 뒀다.

아직 확인하지 못한 것

  • 시작점을 감싼 콜라이더가 이 환경에서 레이캐스트에 잡힌 조건(CapsuleCollider + CharacterController 중복, 스케일 3.19 등)
  • 손을 뗀 뒤 드릴이 안 멈추던 원인이 포커스 유실 때 release 누락인지
  • _bonusByCombo = {1, 2, 3}이면 숙련된 찍기(약 1.5초)가 업데이트 드릴(약 2.4초)보다 빠르다. 업그레이드 보상과 맞는지 체감 점검이 필요하다.
  • 2인 접속 검증: 지연 상황의 즉시 연출, 금 간 단계 동기화, 원격 드릴 3D 소리, 약점 위치 동기화
  • 업데이트 드릴이 약점을 무시할지, 타격 정보와 피해량의 주인을 어디에 둘지 팀 결정

소감

2편에서 내 몸을 치는 버그를 막겠다고 넣은 필터가, 이번엔 내 클릭을 아무 말 없이 삼키고 있었다.. 고친 게 다음 버그를 숨기고 있었다는 게 좀 허탈했다..