본문 바로가기

로블록스 개발 중급

Blaster 제작 : 서버/클라이언트 이벤트 처리(3/3)

상대 플레이어에게 데미지 주기

서버-클라이언트 모델인 로블록스에서 플레이어끼리 데미지를 주기 위해서는 서버의 개입이 필요하다. 즉 블래스터를 발사한 플레이어는 명중되었음을 서버에게 알리고 서버는 명중된 플레이어에게 데미지를 줘야한다.

클라이언트와 서버와의 통신을 위해서 로블록스에서는 RemoteEvent를 사용할 수 있다. 로블록스에서 제공되고 있는 ReplicatedStorage 서비스를 사용하면 서버와 클라이언트 모두에서 접근이 된다. 따라서 ReplicatedStorage 서비스 밑에 RemoteEvent를 만들고 사용한다.

 

ReplicatedStorage 밑에 폴더를 만들고 Events라고 네이밍한다.

DamageCharacter 라는 이름으로 RemoteEvent를 만들어서 추가한다.

로컬스크립트인 ToolController 스크립트안에서 ReplicatedStorage서비스의 events 폴더에 접근한다. 

local UserInputService = game:GetService("UserInputService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
 
local LaserRenderer = require(Players.LocalPlayer.PlayerScripts.LaserRenderer)
 
local tool = script.Parent
local eventsFolder = ReplicatedStorage.Events
 
local MAX_MOUSE_DISTANCE = 1000
local MAX_LASER_DISTANCE = 500

RemoteEvent를 처리하는 방법은 간단하다. events폴더의 DamageCharacter에 "."으로 접근하여 FireServer()함수를 인자와 함께 호출하면 된다. 여기서는 명중된 상대 캐릭터를 인자로 보낸다.

    local characterModel = weaponRaycastResult.Instance:FindFirstAncestorOfClass("Model")
    if characterModel then
        local humanoid = characterModel:FindFirstChild("Humanoid")
        if humanoid then
            -- 휴머노이드가 있으니 명중. 서버에게 RemoteEvent인 DamageCharacter를 보낸다.
            eventsFolder.DamageCharacter:FireServer(characterModel)
        end
    end
else
    hitPosition = tool.Handle.Position + directionVector
end

리모트 이벤트를 받은 서버는 이제 명중된 플레이어에게 피격받은 사실을 알리고 데미지를 줘야한다. 

ServerScriptService에 ServerLaserManager라는 서버스크립트를 추가하자. 

레이저빔의 데미지인 LASER_DAMAGE 값을 설정하고(여기서는 10) damageCharacter()를 정의하자. 이 함수는 리모트이벤트 DamageCharacter 에 반응할 함수이다. 리모트이벤트를 받을 때 나오는 OnServerEvent이벤트와 damageCharacter()함수를 Connet()함수로 연결한다.

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local eventsFolder = ReplicatedStorage.Events
local LASER_DAMAGE = 10
 
function damageCharacter(playerFired, characterToDamage)

end
 
-- 함수와 이벤트를 연결
eventsFolder.DamageCharacter.OnServerEvent:Connect(damageCharacter)

damageCharacter 함수는 playerFired 인자와 characterToDamage 인자를 받는다. 

  • playerFired : 레이저빔을 발사한 플레이어
  • characterToDamage : 데미지를 입을 플레이어의 캐릭터

eventsFolder.DamageCharacter:FireServer(characterModel) 의 호출에 의해 characterModel 인자는 피격 캐릭터가 보내지고, playerFired 인자는 내부적으로 자동을 보내진다.

function damageCharacter(playerFired, characterToDamage)
    local humanoid = characterToDamage:FindFirstChild("Humanoid")
    if humanoid then
        -- 받은 캐릭터에서 휴머노이드를 찾아서 Health를 줄인다.
        humanoid.Health -= LASER_DAMAGE
    end
end

로컬 서버를 사용해서 2개의 클라이언트로 테스트해보자. 명중되면 Health가 10만큼 감소될 것이다.

상대 플레이어의 레이저빔 렌더링하기

지금까지의 레이저빔 렌더링은 레이저빔을 발사한 클라이언트에게만 보이는 렌더링이었다. 상대편이 발사하는 레이저빔에 대한 렌더링에 대해 살펴보자.

 

ToolController 자체가 로컬 스크립트이므로 자신에게만 해당되는 렌더링이었다는 것을 조그만 생각해보면 알 수 있을 것이다. 어쨋든 레이저 빔은 내가 발사하든 남이 발사하든 보여야 하기 때문에 상대편의 레이저빔도 렌더링해야 한다. 상대의 레이저빔의 대한 정보만 얻어 올 수 있다면 내 것을 렌더링하듯이 하면 될 것이다. 상대의 렌더링 정보는 어떻게 얻어 올까?? 물론 서버를 통해서 얻어온다.

 

정리하자.

  1. 레이저빔을 발사한 클라이언트는 그냥 자신의 레이저빔을 바로 렌더링한다. 여기에는 딜레이가 없다.
  2. 서버는 레이저빔을 발사한 클라이언트에게 발사한 정보를 받고 다른 모든 클라이언트에게 보내준다.
  3. 다른 플레이어는 누군가의 레이저빔의 발사에 대한 이벤트를 받고 다른 플레이어의 레이저빔을 렌더링한다. 여기에는 딜레이가 존재한다.

마지막 3번의 다른 모든 플레이어는 레이저빔을 발사한 플레이어와는 다르게 레이저빔의 렌더링에 대한 약간의 딜레이가 있다. 이 시나리오보다 더 빠르게(딜레이없는) 렌더링을 할 수 있다면 좋겠지만, 아직까지는 이게 최선의 방법이다. 다른 플레이어의 레이저빔이므로 약간의 지연은 기술상 어쩔수없는 부분이다. 인터넷의 발전으로 레이턴시가 점점 짧아지고 있기 때문에 이 지연은 이미 인간은 눈치챌 수 없을 정도의 상태까지 왔고 앞으로 미래에는 점점 더 알아차리기 힘들게 될 것이다. (그러니 무시하자.ㅠㅠ)

레이저빔을 발사한 클라이언트의 구현

먼저 레이저빔을 발사한 클라이언트는 자신의 발사한 사실과 레이저빔의 끝 위치를 서버에 제공한다.

서버에 발사를 알리기 위한 리모트 이벤트로 LaserFired 를 만들어서 ReplicatedStorage에 추가.

fireWeapon()함수에 자신의 레이져빔을 렌더링하는 createLaser() 함수위에서 서버에 리모트 이벤트를 보낸다.

서버의 구현

서버는 클라이언트가 보낸 LaserFired 이벤트를 수신하고 모든 클라이언트에게 레이저빔의 렌더링을 위해 시작과 끝 위치를 알려준다. 

ServerLaserManager 스크립트에서 playerFiredLaser ()함수를 만들어서 레이저빔의 렌더링에 필요한 정보를 넘겨주고 모든 클라이언트에게 알려주는 역할을 하게 한다.

-- 모든 클라이언트에게 레이져빔의 렌더링을 위해 정보를 날려주는 함수
local function playerFiredLaser(playerFired, endPosition)
 
end

-- 리모트 이벤트와 함수를 연결
eventsFolder.DamageCharacter.OnServerEvent:Connect(damageCharacter)
eventsFolder.LaserFired.OnServerEvent:Connect(playerFiredLaser)

서버는 레이저의 끝 위치 뿐 아니라 시작 위치도 필요하다. 시작 위치는 클라이언트의 캐릭터의 Tool의 손잡이로 부터 얻을 수 있다. 알아 낼수 있는 정보는 보안상의 문제로 가급적 클라이언트를 신뢰하지 않는게 좋다.

getPlayerToolHandle() 함수를 정의하여 캐릭터를 인자로 넘겨주고 weapon의 핸들을 반환하는 역할을 만들자.

-- player 캐릭터로 부터 블래스터의 핸들을 반환한다.
local function getPlayerToolHandle(player)
    local weapon = player.Character:FindFirstChildOfClass("Tool")
    if weapon then
        return weapon:FindFirstChild("Handle")
    end
end

이제 서버는 LaserFired 리모트 이벤트에서 FireAllClients를 호출하여 모든 클라이언트가 레이저를 렌더링하는 데 필요한 정보를 보낼 수 있게 되었다. 여기에는 레이저를 발사한 플레이어(이 클라이언트는 이미 레이저빔을 렌더링함), 블래스터의 핸들 및 레이저의 끝 위치등, 모두 세가지 정보가 보내진다.

playerFiredLaser() 함수에서 playerFired(발사한 플레이어)를 인자로 사용하여 getPlayerToolHandle() 함수를 호출하고 해당 플레이어의 툴의 Handle을 toolHandle이라는 변수에 할당한다. toolHandle을 조건문으로 사용하여, 값이 존재하면 playerFired, toolHandle 및 endPosition을 인자로 사용하여 모든 클라이언트에 대해 LaserFired 이벤트를 보낸다.

-- 모든 클라이언트에게 레이저빔을 렌더링할 수 있는 정보를 보냄
local function playerFiredLaser(playerFired, endPosition)
    local toolHandle = getPlayerToolHandle(playerFired)
    if toolHandle then
        eventsFolder.LaserFired:FireAllClients(playerFired, toolHandle, endPosition)
    end
end

다른 모든 클라이언트에서 레이저빔을 렌더링

모든 클라이언트에게 FireAllClients가 호출되었으니, 각각 클라이언트는 서버에서 받은 이벤트를 통해 레이저 빔을 렌더링한다. 각 클라이언트는 이전의 LaserRenderer 모듈을 재사용하여 도구의 핸들 위치와 끝 위치 값을 사용하여 레이저 빔을 렌더링할 수 있다. 레이저 빔을 발사한 플레이어는 이미 렌더링을 했으므로 이 이벤트를 무시한다. 

로컬스크립트 ClientLaserManager를 추가한다.

LaserRenderer 모듈을 재사용하고, 다른 플레이어의 레이저를 렌더링하는 함수, createPlayerLaser 를 정의한다. 이 함수는 LaserFired 리모트 이벤트와 연결하여 이벤트가 오면 바로 호출되게 한다. 이벤트의 정보로 오는 첫번째 인자인 발사한 플레이어를 보고 LocalPlayer인지 아닌지 확인하여, 같은 플레이어이면 무시하고 다른 플레이어면 렌더링을 시작한다.

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
 
local LaserRenderer = require(script.Parent:WaitForChild("LaserRenderer"))
 
local eventsFolder = ReplicatedStorage.Events
 
-- 다른 플레이어의 레이저빔을 렌더링
local function createPlayerLaser(playerWhoShot, toolHandle, endPosition)
    if playerWhoShot ~= Players.LocalPlayer then
        LaserRenderer.createLaser(toolHandle, endPosition)
    end
end
 
eventsFolder.LaserFired.OnClientEvent:Connect(createPlayerLaser)

테스트를 해보자. 로컬서버를 통해 2개의 클라이언트를 동시에 확인할 수 있도록 열어놓자. 한 클라이언트에서 레이저빔을 발사하면 2개의 클라이언트 모두에서 레이저빔을 볼 수 있어야 한다. 

사운드 이펙트

레이저빔의 발사의 사운드 이펙트는 한 컴퓨터에서 로컬 서버로 2개의 클라이언트를 열어놔도 테스트하기가 힘들다. 테스트가 힘들기 때문에 현재 상태를 그냥 얘기하자면, 발사한 클라이언트에게서만 사운드 이펙트가 출력되고 있다. 즉, 상대가 레이저빔을 쏴도 사운드는 들리지 않고, 내가 쏜 레이저빔의 발사 사운드만 들린다.

현재는 사운드 출력의 tool이 활성화 될 때(레이저빔이 발사될때) 출력되게 해놓은 상태이다.

 local function toolActivated()
     if canShootWeapon() then
         -- 사운드가 출력
         local shootingSound = toolHandle:FindFirstChild("Activate")
         if shootingSound then
             shootingSound:Play()
         end
         fireWeapon()
     end
 end

LaserRenderer 모듈 스크립트의 createLaser()함수에 사운드 출력 스크립트를 옮겨 놓자. 레이저빔 렌더링과 함께 사운드도 같이 출력되게 할 것이다. 

    laserPart.Parent = workspace
 
    game.Debris:AddItem(laserPart, SHOT_DURATION)
 
    -- 레이저빔 발사 사운드 출력
    -- toolHandler는 모듈의 createLaser()함수의 인자로 넘어온다. 
    local shootingSound = toolHandle:FindFirstChild("Activate")
    if shootingSound then
        shootingSound:Play()
    end
end

피격 루틴에 대한 고찰

블래스트의 레이저빔의 피격 여부를 클라이언트에서 서버로 전송한다라는 시스템은 대단히 불안하다. 해킹의 위험도 있을 것이고... 또한 여러가지 이상한 경험이 재현될 수도 있을 것이다. 예를 들어 레이턴시가 꽤 느린 클라이언트라고 가정해보면, 이 클라이언트에게 상대 플레이어는 매우 끊기듯이 움직일 것이고 그렇다면 레이저빔을 명중시킬 가능성이 높아질 것이다. FPS가 낮을수록 명중 확률이 높아지다니... 이건 좀 이상하다. 하지만, (물론) 이런 일은 일어나지 않는다. 어느 서버에서든 유효성 검증을 하기 때문이다. 다음 장에서 자세히 알아보자. 

서버/클라이언트 보안 검증

서버/클라이언트 모델에서 서버는 기본적으로 클라이언트가 건네는 정보를 믿지 않는게 정상이다. 클라이언트는 해킹에 취약할 수 밖에 없다. 그래서 해킹된 정보가 서버가 받아들이기 시작하면 게임은 엉망진창이 되어버릴 것이다. 그렇다고 클라이언트의 정보를 아예 안 받을 수는 없다. 어찌 됐건 키입력같은 사용자의 조작은 클라이언트의 정보이기 때문이다. 게다가 이 포스팅의 구현과 같이 상대방이 레이저포에 맞았는지 아닌지에 정보까지 클라이언트에서 받아 오는 경우라면 더더욱 조심해야 할 것이다. 즉, 서버는 클라이언트의 데이터가 가짜인지 아닌지 확인해야 한다. 이러한 확인은 그 정보가 상식적인 데이터인가?를 확인하는 단계에서부터 시작한다. 

현재 구현 코드로써는 DamageCharacter 리모트 이벤트에 대한 유효성 검증이 없다. 해커라면 이런 리모트 이벤트를 가지고 마구잡이로 쏘는 구현도 할 수 있을 것이다. 이 DamageCharacter 리모트 이벤트에 대한 유효성 검증을 추가해 보자.

유효성 검증은 각자 상황에 따라 여러가지를 생각해 볼 수 있다. 단순한 검증이라도 예외 상황이 없는지 잘 생각해보고 판단해야 할 것이다. 여기서는 2가지의 유효성 검증을 한다.

  • 레이저에 맞은 플레이어와 레이저에 맞은 부위의 위치 사이의 거리를 확인한다.
  • 블래스터와 레이저에 맞은 부위의 위치사이에 레이캐스팅을 해보고 벽과 같은 장애물이 있지는 않은지를 확인한다. 

클라이언트측 구현

클라이언트가 해줘야 할 일은 피격된 플레이어의 적중한 위치를 DamageCharacter 리모트이벤트와 같이 보내는 것이다.

-- ToolController의 fireWeapon() 함수의 DamageCharacter리모트이벤트 Fire부분을 수정.
    if characterModel then
    local humanoid = characterModel:FindFirstChild("Humanoid")
    if humanoid then
        eventsFolder.DamageCharacter:FireServer(characterModel, hitPosition)
    end
end

서버측 구현

DamageCharacter 리모트이벤트로 전송하는 매개변수가 하나 추가되었으니 서버측 ServerLaserManager를 수정해야 한다. ServerLaserManager 스크립트에서 hitPosition 매개변수를 damageCharacter() 함수에 추가

function damageCharacter(playerFired, characterToDamage, hitPosition)
    local humanoid = characterToDamage:FindFirstChild("Humanoid")
    if humanoid then
        humanoid.Health -= LASER_DAMAGE
    end
end

이제 isHitValid()함수라는 유효성 검증 함수를 만들자. 세가지 매개변수가 필요하다.

  • playerFired : 레이저를 발사한 플레이어
  • characterToDamage : 피격된 플레이어의 캐릭터
  • hitPosition : 피격된 개체의 위치
local function isHitValid(playerFired, characterToDamage, hitPosition)
 
end

피격 위치와 피격 캐릭터와의 거리 확인

피격 위치와 피격 캐릭터와의 허용 거리는 10으로 한다. 클라이언트가 DamageCharacter 리모트 이벤트를 날린 후에 캐릭터가 움직일 수 있으므로 오차는 피할 수 없다. 적당하게 10으로 정했다.

local MAX_HIT_PROXIMITY = 10

isHitValid()함수안에서 피켝 위치와 피격 캐릭터 사이의 거리를 MAX_HIT_PROXIMITY와 비교한다. 허용오차를 벗어나면 false를 반환한다.

local function isHitValid(playerFired, characterToDamage, hitPosition)
    -- 피격 캐릭터 위치와 피격된 위치와의 거리는 MAX_HIT_PROXIMITY보다 커지면 검증 실패(false)
    local characterHitProximity = (characterToDamage.HumanoidRootPart.Position - hitPosition).Magnitude
    if characterHitProximity > MAX_HIT_PROXIMITY then
        return false
    end
end

장애물 체크

블래스터 총구 위치와 레이저에 맞은 부위의 위치 사이에 장애물이 있지는 않은지 레이캐스팅을 해본다. 레이캐스팅을 해보고 그 결과, 피격 캐릭터가 아닌 오브젝트를 반환하면 무엇인가 레이저샷 사이에 있다는 말이다. 그렇다는 건 사이에 장애물이 있음에도 피격이 됐다는 말이 되므로 믿을 수 없는 피격 정보라고 단정할 수 있다.

블래스터 총구 위치와 레이저에 맞은 부위의 위치 사이에 레이캐스팅을 해보자.

local function isHitValid(playerFired, characterToDamage, hitPosition)
    local characterHitProximity = (characterToDamage.HumanoidRootPart.Position - hitPosition).Magnitude
    if characterHitProximity > 10 then
        return false
    end
 
    -- 총구 위치와 피격 위치간의 레이캐스팅
    local toolHandle = getPlayerToolHandle(playerFired)
    if toolHandle then
        local rayLength = (hitPosition - toolHandle.Position).Magnitude
        local rayDirection = (hitPosition - toolHandle.Position).Unit
        local raycastParams = RaycastParams.new()
        raycastParams.FilterDescendantsInstances = {playerFired.Character}
        local rayResult = workspace:Raycast(toolHandle.Position, rayDirection * rayLength, raycastParams)
        
        -- 결과물인 인스턴스가 피격 캐릭터의 하위노드가 아니면 검증 실패
        if rayResult and not rayResult.Instance:IsDescendantOf(characterToDamage) then
            return false
        end
    end
     
    return true
end

이제, 서버 코드에서 데미지를 처리하는 damageCharacter() 함수안에서, 위의 isHitValid()함수의 결과를 보고 실행하도록 변경한다.

function damageCharacter(playerFired, characterToDamage, hitPosition)
    local humanoid = characterToDamage:FindFirstChild("Humanoid")
    local validShot = isHitValid(playerFired, characterToDamage, hitPosition)
    if humanoid and validShot then
        humanoid.Health -= LASER_DAMAGE
    end
end

이제 2가지의 유효성 검증을 통해 DamageCharacter 리모트 이벤트가 더 안전해 졌다. 대부분의 플레이어의 해킹행위는 막을 수 있을 것이다. 다만, 좀더 능숙한 해커라면 어떤 유효성 검증을 하는지 찾아내고 피할 방법도 찾아 낼 수 있을 것이다. 좀더 안전하게 리모트 이벤트를 처리할 수 있도록 유효성 검증 시스템을 유지하고 발전시켜야 할 것이다.