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

castLaserRay()의 파라미터는 Raycast() 호출이 발사를 한 캐릭터를 제외하고 작업 공간의 모든 파트를 고려해야 한다고 명시합니다. 스크립트는 directions 테이블의 각 방향에 대해 레이를 캐스팅합니다. 만약 레이가 어떤 것에 맞는다면, 이는 다섯 개의 속성이 있는 RaycastResult를 생성합니다:
- Distance – 레이 기원과 교차 지점 사이의 거리입니다.
- Material – 교차 지점의 Enum.Material입니다.
Instance 값은 샘플 레이저 태그 경험의 게임 플레이에 가장 중요한 속성으로, 레이가 다른 플레이어와 충돌했음을 전달합니다. 이 정보를 검색하기 위해 경험은 ReplicatedStorage ⟩ LaserRay ⟩ castLaserRay ⟩ getPlayerFromDescendant 헬퍼 함수를 사용합니다. 만약 이 함수가 nil을 반환한다면, 인스턴스는 플레이어의 일부가 아니므로, 레이가 환경 내의 무생물체에 맞았음을 의미합니다.
그 후, castLaserRay()는 Position과 Normal을 사용하여 레이의 destination이라 부르는 새로운 CFrame을 생성합니다. 모든 레이는 목적지가 있으며, 이는 3D 공간에서 레이가 맞은 지점이거나 최대 거리의 끝점입니다. 플레이어의 조준 능력에 따라 taggedPlayer 값은 많이 또는 대부분 nil입니다.
castLaserRayif result then-- 발사가 무언가에 맞음, 플레이어인지 확인합니다.destination = CFrame.lookAt(result.Position, result.Position + result.Normal)taggedPlayer = getPlayerFromDescendant(result.Instance)else-- 발사가 아무것에도 맞지 않았으므로, 목적지는-- 최대 거리의 지점입니다.local distantPosition = origin + rayDirection * MAX_DISTANCEdestination = CFrame.lookAt(distantPosition, distantPosition - rayDirection)taggedPlayer = nilend
발사 유효화
치팅을 방지하기 위해, 이전 챕터 발사기 구현에서는 blastClient가 발사를 서버에 알리고 각 클라이언트가 보낸 모든 데이터, 즉 그들이 진정으로 자신들의 발사기로 다른 플레이어를 태그했는지의 여부를 확인할 수 있습니다. 이 레이 검증 과정은 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() 메서드를 사용하면 플레이어가 힘의 장벽이 활성화되어 있을 때 피해를 입지 않도록 보장할 수 있습니다.
onPlayerTagged
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를 참조하십시오.
- 커스텀 모드: 새로운 목표 유형을 도입하기 위해 이 경험을 어떻게 수정할 수 있을까요? 예를 들어, 시간 제한 전까지 가장 많은 점수를 얻는 것과 같은 목표를 설정할 수 있습니다.
레이저 태그 경험을 위한 확장된 게임 플레이 논리와 재사용 가능한 고품질 환경 자산은 레이저 태그 템플릿을 검토하십시오.