检测击中是识别爆炸何时与玩家碰撞的过程,然后相应地减少他们的生命值。从高层次来看,您可以将这项工作视为:
- 物理模拟检查投射物是否击中目标。
- 瞬时检查爆炸器是否瞄准目标。
您使用的击中检测类型取决于游戏的玩法需求。例如,物理模拟检查适用于躲避球游戏,在该游戏中,球需要以一定的速度离开手,随着在空中移动而下落,或因天气条件而改变方向。然而,瞬时检查更适合激光标记游戏,在该游戏中,光束必须具有近乎无限的速度,并忽略重力和风速等环境因素。
以示例激光标记游戏为参考,本教程的这一部分教您关于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
-- 对于多个激光,均匀水平扩散
-- 在中心周围的激光扩散角度范围内
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 点以特定方向发送一条不可见光线,具有定义的长度,然后检查其路径以查看它与其他对象的交点。
此信息对于第一人称射击游戏特别有用,因为它允许您查看爆炸何时以及在哪里与玩家或环境相交。例如,以下图像演示了两条光线平行投射。根据它们的起点和方向,光线A错过了墙壁并继续直到达到其最大距离,而光线B与墙壁相撞。有关此过程的更多信息,请参见光线投射。

castLaserRay() 的参数指定 Raycast() 调用必须考虑工作区中的每个部分除了发射的角色。然后,脚本为 directions 表中的每个方向投射一条光线。如果光线击中某物,它会生成一个 RaycastResult,该结果具有五个属性:
- Distance – 光线起点与交点之间的距离。
- Material – 交点处的 Enum.Material。
Instance 值是这些属性中对示例激光标记游戏的游戏玩法最关键的,因为它传达了光线何时与其他玩家相撞。要检索此信息,游戏使用 ReplicatedStorage ⟩ LaserRay ⟩ castLaserRay ⟩ getPlayerFromDescendant 辅助函数。如果返回 nil,则实例不是玩家的一部分,意味着光线击中了环境中的无生命物体。
castLaserRay() 然后使用 Position 和 Normal 创建一个新的 CFrame,称为光线的 destination。每条光线都有一个目的地,它要么是光线在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 调用 validateRayResult 检查来自客户端的 rayResults 表中的每个条目是否为 CFrame 和 Player(或 nil)。
接下来,它调用 isRayAngleFromOriginValid 比较激光扩散的预期角度与客户端的角度。此代码特别显示了使用 ReplicatedStorage 的优势,因为服务器可以调用 getDirectionsForBlast 本身,将返回值存储为“预期”数据,然后与客户端的数据进行比较。
就像上一章的爆炸器验证一样,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点伤害,因此需要十次使用此爆炸器的爆炸才能标记出另一个玩家。为了开始将玩家标记出局的过程,LaserBlastHandler 调用 ServerScriptService ⟩ LaserBlastHandler ⟩ processTaggedPlayers,该函数检查现在已验证的 rayResults 表中的玩家,并将 damagePerHit 传递给 onPlayerTagged。

Health 不接受负值,因此 onPlayerTagged 有一些逻辑来保持玩家生命值在零或以上。在验证玩家生命值高于零后,它将生命值与 damagePerHit 进行比较,并使用两者中较小的值。例如,如果玩家有10点生命值并被15点伤害的激光束击中,则激光仅造成10点伤害。
这种处理问题的方法可能看起来有点复杂。例如,为什么不直接将玩家生命值设置为零,如果它会是负数?原因是设置生命值会规避力场。使用 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。
- 自定义模式:您如何修改此游戏以引入新的目标类型,例如在时间耗尽之前获得最多积分?
有关激光标记游戏的扩展游戏逻辑,以及可重用的高质量环境资产,请查看激光标记模板。