히트 감지는 발사체가 플레이어와 충돌할 때 이를 식별하고, 그에 따라 플레이어의 체력을 줄이는 과정입니다. 높은 수준에서 이 작업은 다음과 같이 생각할 수 있습니다:
- 발사체가 목표에 충돌했는지에 대한 물리적으로 시뮬레이션된 검사.
- 발사기가 목표를 겨냥했는지에 대한 즉각적인 검사.
사용하는 히트 감지 유형은 게임의 게임플레이 요구 사항에 따라 다릅니다. 예를 들어, 공이 특정 속도로 손에서 나가야 하고, 공중에서 떨어지거나 날씨 조건에 따라 방향을 바꿔야 하는 피구 게임에는 물리적으로 시뮬레이션된 검사가 적합합니다. 그러나 레이저 태그 게임에서는 빔이 거의 무한한 속도를 가져야 하고 중력 및 바람 속도와 같은 환경 요인을 무시해야 하므로 즉각적인 검사가 더 적합합니다.
샘플 레이저 태그 게임을 참조하여, 이 튜토리얼의 이 섹션에서는 3D 공간에서 히트 감지의 스크립트에 대해 배우고 다음과 같은 내용을 안내합니다:
- 현재 카메라 값과 플레이어의 발사기 유형에서 발사 방향 가져오기.
- 발사기가 발사할 때 발사 방향으로 직선으로 레이를 쏘기.
- 발사기 데이터를 악용하지 않도록 발사를 검증하기.
- 각 유형의 발사기에서 플레이어에게 히트한 레이 수에 따라 플레이어의 체력을 줄이기.
이 섹션을 완료한 후에는 오디오, 조명 및 특수 효과와 같은 게임플레이를 향상시키기 위한 추가 개발 주제를 탐색할 수 있습니다.
발사 방향 가져오기
플레이어가 발사기를 발사하면, ReplicatedStorage ⟩ Blaster ⟩ attemptBlastClient ⟩ blastClient ⟩ generateBlastData가 히트 감지 프로세스를 시작하기 위해 두 개의 함수를 호출합니다: rayDirections() 및 rayResults().
local rayDirections = getDirectionsForBlast(currentCamera.CFrame, blasterConfig)
local rayResults = castLaserRay(localPlayer, currentCamera.CFrame.Position, rayDirections)rayDirections의 입력은 간단합니다: 현재 카메라의 위치 및 회전 값과 플레이어의 발사기 유형입니다. 샘플 레이저 태그 게임이 단일 레이저 빔을 생성하는 발사기만 제공했다면, ReplicatedStorage ⟩ LaserRay ⟩ getDirectionsForBlast는 필요하지 않았을 것입니다. 왜냐하면 currentCamera.CFrame.LookVector를 사용하여 발사 방향을 계산할 수 있었기 때문입니다.
그러나 샘플이 넓고 수평으로 퍼진 여러 레이저 빔을 생성하는 추가 발사기 유형을 제공하기 때문에, getDirectionsForBlast는 발사기 구성 내의 각 레이저 빔의 각도에 따라 퍼짐의 방향을 계산해야 합니다:
if numLasers == 1 then
-- 단일 레이저의 경우, 직진합니다.
table.insert(directions, originCFrame.LookVector)
elseif numLasers > 1 then
-- 여러 레이저의 경우, 수평으로 고르게 퍼집니다.
-- 중심 주위의 간격 laserSpreadDegrees에 걸쳐
local leftAngleBound = laserSpreadDegrees / 2
local rightAngleBound = -leftAngleBound
local degreeInterval = laserSpreadDegrees / (numLasers - 1)
for angle = rightAngleBound, leftAngleBound, degreeInterval do
local direction = (originCFrame * CFrame.Angles(0, math.rad(angle), 0)).LookVector
table.insert(directions, direction)
end
end이 개념을 더 잘 설명하기 위해, 만약 넓은 수직 퍼짐을 가진 세 번째 발사기 유형을 포함한다면, spreadDirection과 같은 새로운 발사기 속성을 만들고, CFrame 계산을 다른 축을 사용하도록 조정할 수 있습니다. 예를 들어, 아래의 스크립트에서 이 가상의 세 번째 발사기 유형에 대한 direction 계산의 차이를 주목하십시오.
if numLasers == 1 then
table.insert(directions, originCFrame.LookVector)
elseif numLasers > 1 then
local leftAngleBound = laserSpreadDegrees / 2
local rightAngleBound = -leftAngleBound
local degreeInterval = laserSpreadDegrees / (numLasers - 1)
for angle = rightAngleBound, leftAngleBound, degreeInterval do
local direction
if spreadDirection == "vertical" then
direction = (originCFrame * CFrame.Angles(math.rad(angle), 0, 0)).LookVector
else
direction = (originCFrame * CFrame.Angles(0, math.rad(angle), 0)).LookVector
end
table.insert(directions, direction)
end
end
return directions궁극적으로, rayDirections() 함수는 각 레이저 빔의 방향을 나타내는 Vectors 테이블을 반환합니다. 도움이 된다면, 이 데이터가 어떻게 생겼는지 알기 위해 로깅을 추가할 수 있습니다.
local rayDirections = getDirectionsForBlast(currentCamera.CFrame, blasterConfig)
for _, direction in rayDirections do -- new line
print(direction) -- new line
end -- new line
local rayResults = castLaserRay(localPlayer, currentCamera.CFrame.Position, rayDirections)레이 쏘기
castLaserRay()는 ReplicatedStorage ⟩ Blaster ⟩ attemptBlastClient ⟩ blastClient ⟩ generateBlastData의 두 번째 함수로, 스크립트 내에서 더 복잡한 작업을 수행합니다. 이 함수는 Workspace:Raycast() 호출을 위해 매개변수를 지정하여 레이캐스팅을 수행할 수 있도록 합니다. 레이캐스팅은 특정 방향으로 정의된 길이의 Vector3 지점에서 보이지 않는 레이를 발사하고, 그 경로가 다른 객체와 교차하는지 확인하는 과정입니다.
이 정보는 1인칭 슈팅 게임에 특히 유용합니다. 왜냐하면 발사체가 플레이어나 환경과 교차하는 시점과 위치를 확인할 수 있기 때문입니다. 예를 들어, 다음 이미지는 서로 평행하게 캐스팅된 두 개의 레이를 보여줍니다. 원점과 방향에 따라 레이 A는 벽을 놓치고 최대 거리까지 계속 진행하는 반면, 레이 B는 벽과 충돌합니다. 이 과정에 대한 자세한 내용은 레이캐스팅을 참조하십시오.

castLaserRay()의 매개변수는 Raycast() 호출이 발사한 캐릭터를 제외한 모든 파트를 고려해야 함을 지정합니다. 그런 다음 스크립트는 directions 테이블의 각 방향에 대해 레이를 캐스팅합니다. 레이가 무언가에 닿으면, 다섯 가지 속성을 가진 RaycastResult를 생성합니다:
- Distance – 레이 원점과 교차점 사이의 거리.
- Material – 교차점의 Enum.Material.
Instance 값은 샘플 레이저 태그 게임의 게임플레이에 가장 중요한 속성입니다. 왜냐하면 레이가 다른 플레이어와 충돌할 때 이를 전달하기 때문입니다. 이 정보를 검색하기 위해 게임은 ReplicatedStorage ⟩ LaserRay ⟩ castLaserRay ⟩ getPlayerFromDescendant 헬퍼 함수를 사용합니다. 만약 nil을 반환하면, 인스턴스는 플레이어의 일부가 아니며, 레이가 환경 내의 무생물 객체에 닿았음을 의미합니다.
castLaserRay()는 그런 다음 Position 및 Normal을 사용하여 레이의 destination이라고 부르는 새로운 CFrame을 생성합니다. 모든 레이는 목적지가 있으며, 이는 레이가 3D 공간에서 충돌한 지점이거나 최대 거리의 끝점입니다. 플레이어의 조준이 얼마나 잘 이루어지는지에 따라, 많은 또는 대부분의 taggedPlayer 값은 nil입니다.
if result then
-- 발사가 무언가에 닿았으므로, 플레이어인지 확인합니다.
destination = CFrame.lookAt(result.Position, result.Position + result.Normal)
taggedPlayer = getPlayerFromDescendant(result.Instance)
else
-- 발사가 아무것도 맞추지 않았으므로, 목적지는
-- 최대 거리의 지점입니다.
local distantPosition = origin + rayDirection * MAX_DISTANCE
destination = CFrame.lookAt(distantPosition, distantPosition - rayDirection)
taggedPlayer = nil
end발사 검증
치팅을 방지하기 위해, 이전 장 발사기 구현에서는 blastClient가 RemoteEvent를 사용하여 서버에 발사를 알리고, 각 클라이언트가 보내는 모든 데이터를 검증할 수 있도록 설명합니다. 이 레이 검증 프로세스는 ServerScriptService ⟩ LaserBlastHandler ⟩ getValidatedBlastData ⟩ getValidatedRayResults에서 발생하며, 각 검사는 중첩된 모듈 스크립트와 관련이 있습니다:
먼저, getValidatedRayResults는 클라이언트의 rayResults 테이블의 각 항목이 CFrame 및 Player(또는 nil)인지 확인하기 위해 validateRayResult를 호출합니다.
다음으로, 클라이언트의 레이저 퍼짐의 예상 각도를 비교하기 위해 isRayAngleFromOriginValid를 호출합니다. 이 코드는 서버가 getDirectionsForBlast를 직접 호출하고 반환된 값을 "예상" 데이터로 저장한 다음 클라이언트의 데이터와 비교할 수 있기 때문에 ReplicatedStorage를 사용하는 장점을 보여줍니다.
이전 장의 발사기 검증과 마찬가지로, isRayAngleFromOriginValid는 각도에서 "과도한" 차이를 결정하기 위해 허용 오차 값을 사용합니다:
isRayAngleFromOriginValidlocal claimedDirection = (rayResult.destination.Position - originCFrame.Position).Unitlocal directionErrorDegrees = getAngleBetweenDirections(claimedDirection, expectedDirection)return directionErrorDegrees <= ToleranceValues.BLAST_ANGLE_SANITY_CHECK_TOLERANCE_DEGREESRoblox는 가장 복잡한 수학 부분을 추상화하므로, 결과는 다양한 게임에서 적용할 수 있는 짧고 재사용 가능한 헬퍼 함수입니다:
getAngleBetweenDirectionslocal function getAngleBetweenDirections(directionA: Vector3, directionB: Vector3)local dotProduct = directionA:Dot(directionB)local cosAngle = math.clamp(dotProduct, -1, 1)local angle = math.acos(cosAngle)return math.deg(angle)end다음 검사는 가장 직관적입니다. getValidatedBlastData가 발사한 플레이어가 빔의 원점 근처에 있었는지 확인하기 위해 DISTANCE_SANITY_CHECK_TOLERANCE_STUDS를 사용하는 반면, isPlayerNearPosition은 태그된 플레이어가 빔의 목적지 근처에 있었는지 확인하기 위해 동일한 논리를 사용합니다:
isPlayerNearPositionlocal distanceFromCharacterToPosition = position - character:GetPivot().Positionif distanceFromCharacterToPosition.Magnitude > ToleranceValues.DISTANCE_SANITY_CHECK_TOLERANCE_STUDS thenreturn falseend마지막 검사는 isRayPathObstructed로, 클라이언트의 위치에서 레이의 목적지가 벽이나 다른 장애물 뒤에 있는지 확인하기 위해 레이 캐스트 작업의 변형을 사용합니다. 예를 들어, 악의적인 플레이어가 다른 플레이어를 태그하기 위해 게임에서 모든 벽을 체계적으로 제거하려고 한다면, 서버는 레이가 유효하지 않다는 것을 확인할 수 있습니다. 왜냐하면 환경 내의 모든 객체 위치를 알고 있기 때문입니다.
isRayPathObstructedlocal scaledDirection = (rayResult.destination.Position - blastData.originCFrame.Position)scaledDirection *= (scaledDirection.Magnitude - 1) / scaledDirection.Magnitude
어떤 안티-익스플로잇 전략도 포괄적이지 않지만, 악의적인 플레이어가 게임에 접근하는 방식을 고려하여 서버가 의심스러운 행동을 플래그할 수 있는 검사를 설정하는 것이 중요합니다.
플레이어 체력 줄이기
플레이어가 다른 플레이어를 태그했음을 확인한 후, 샘플 레이저 태그 게임의 주요 게임플레이 루프를 완료하는 마지막 단계는 태그된 플레이어의 체력을 줄이고, 리더보드를 증가시키며, 플레이어를 라운드로 다시 스폰하는 것입니다.
태그된 플레이어의 체력을 줄이는 것부터 시작하겠습니다. 스폰 및 리스폰에서는 Player와 Player.Character의 구분을 다루며, 특히 캐릭터는 Humanoid 모델이라는 점을 강조합니다. Humanoid 모델은 기본값이 100인 Health 속성을 가지고 있습니다. 샘플 레이저 태그 게임은 자체 시스템을 구현하는 대신 이 내장 속성을 사용하여 플레이어가 라운드에서 태그 아웃되기 전에 얼마나 많은 피해를 입어야 하는지를 추적합니다.
게임은 각 발사기의 damagePerHit 속성에 피해 값을 저장합니다. 예를 들어, 단일 레이저 빔을 발사하는 발사기는 10 포인트의 피해를 입히므로, 이 발사기로 다른 플레이어를 태그 아웃하려면 10번의 발사가 필요합니다. 플레이어를 태그 아웃하는 프로세스를 시작하기 위해, LaserBlastHandler는 ServerScriptService ⟩ LaserBlastHandler ⟩ processTaggedPlayers를 호출하여 이제 검증된 rayResults 테이블에서 플레이어를 확인하고 damagePerHit를 onPlayerTagged에 전달합니다.

Health는 음수 값을 허용하지 않으므로, onPlayerTagged는 플레이어의 체력을 0 이상으로 유지하기 위한 로직을 가지고 있습니다. 플레이어의 체력이 0 이상인지 확인한 후, 체력을 damagePerHit와 비교하고 두 값 중 더 작은 값을 사용합니다. 예를 들어, 플레이어의 체력이 10이고 15 피해를 입히는 레이저 빔에 맞으면, 레이저는 10 포인트의 피해만 입힙니다.
이 문제에 접근하는 방식은 다소 복잡해 보일 수 있습니다. 예를 들어, 왜 음수일 경우 플레이어의 체력을 0으로 설정하지 않나요? 그 이유는 체력 값을 설정하는 것이 힘의 장벽을 우회하기 때문입니다. Humanoid:TakeDamage() 메서드를 사용하면 플레이어가 힘의 장벽이 활성화된 동안 피해를 입지 않도록 보장합니다.
local function onPlayerTagged(playerBlasted: Player, playerTagged: Player, damageAmount: number)
local character = playerTagged.Character
local isFriendly = playerBlasted.Team == playerTagged.Team
-- 아군의 공격을 금지합니다.
if isFriendly then
return
end
local humanoid = character and character:FindFirstChild("Humanoid")
if humanoid and humanoid.Health > 0 then
-- 음수 체력을 방지합니다.
local damage = math.min(damageAmount, humanoid.Health)
-- TakeDamage는 힘의 장벽이 활성화된 경우 체력이 낮아지지 않도록 보장합니다.
humanoid:TakeDamage(damage)
if humanoid.Health <= 0 then
-- playerBlasted에게 playerTagged를 태그한 점수를 부여합니다.
Scoring.incrementScore(playerBlasted, 1)
end
end
end다음 단계는 리더보드를 증가시키는 것입니다. LaserBlastHandler가 발사 데이터와 함께 발사한 플레이어를 포함하는 것이 불필요해 보일 수 있지만, 그 정보 없이는 게임이 누군가를 태그 아웃한 플레이어에게 크레딧을 줄 수 없습니다. 마지막으로, 태그 아웃된 플레이어는 라운드로 다시 스폰됩니다. 이 과정은 스폰 및 리스폰에서 검토할 수 있습니다.
이 커리큘럼의 다섯 개 장에서는 게임의 핵심 게임플레이 루프를 다루지만, 여전히 탐색할 수 있는 많은 영역이 있습니다. 예를 들어:
- 발사기 시각 효과: ReplicatedStorage ⟩ FirstPersonBlasterVisuals 및 ServerScriptService ⟩ ThirdPersonBlasterVisuals를 참조하십시오.
- 오디오: ReplicatedStorage ⟩ SoundHandler를 참조하십시오.
- 커스텀 모드: 이 게임을 어떻게 수정하여 시간 내에 가장 많은 점수를 얻는 새로운 목표를 도입할 수 있을까요?
레이저 태그 게임을 위한 확장된 게임플레이 논리와 재사용 가능한 고품질 환경 자산에 대해서는 레이저 태그 템플릿을 검토하십시오.