Detectar acertos é o processo de identificar quando os disparos colidem com os jogadores, reduzindo sua saúde de acordo. Em um nível alto, você pode pensar nesse trabalho como:
- Uma verificação fisicamente simulada de se um projétil atingiu o alvo.
- Uma verificação instantânea de se a blaster estava apontada para o alvo.
O tipo de detecção de acertos que você usa depende dos requisitos de jogabilidade do seu jogo. Por exemplo, uma verificação fisicamente simulada é apropriada para um jogo de queimada onde as bolas precisam sair da mão a uma certa velocidade, cair enquanto se movem pelo ar ou mudar de direção devido a condições climáticas. No entanto, uma verificação instantânea é uma combinação melhor para um jogo de laser tag onde os feixes devem ter uma velocidade quase infinita e ignorar fatores ambientais como gravidade e velocidade do vento.
Usando o jogo de laser tag de exemplo como referência, esta seção do tutorial ensina sobre os scripts por trás da detecção de acertos no espaço 3D, incluindo orientações sobre:
- Obtendo a direção do disparo a partir dos valores atuais da câmera e do tipo de blaster do jogador.
- Lançando raios em um caminho reto a partir do blaster enquanto ele dispara.
- Validando o disparo para evitar a exploração dos dados do blaster.
- Reduzindo a saúde do jogador de acordo com o dano do disparo de cada tipo de blaster e quantos raios atingiram o jogador.
Depois de completar esta seção, você pode explorar tópicos adicionais de desenvolvimento para aprimorar sua jogabilidade, como áudio, iluminação e efeitos especiais.
Obter direção do disparo
Depois que um jogador dispara seu blaster, ReplicatedStorage ⟩ Blaster ⟩ attemptBlastClient ⟩ blastClient ⟩ generateBlastData chama duas funções para iniciar o processo de detecção de acertos: rayDirections() e rayResults().
local rayDirections = getDirectionsForBlast(currentCamera.CFrame, blasterConfig)
local rayResults = castLaserRay(localPlayer, currentCamera.CFrame.Position, rayDirections)As entradas para rayDirections são diretas: a posição atual da câmera e os valores de rotação, e o tipo de blaster do jogador. Se o jogo de laser tag de exemplo apenas desse aos jogadores blasters que produzem um único feixe de laser, ReplicatedStorage ⟩ LaserRay ⟩ getDirectionsForBlast seria desnecessário porque você poderia usar currentCamera.CFrame.LookVector para calcular a direção do disparo.
No entanto, como o exemplo fornece um tipo de blaster adicional que produz vários feixes de laser com uma ampla dispersão horizontal, getDirectionsForBlast deve calcular a direção de cada feixe de laser da dispersão de acordo com seus ângulos dentro da configuração do blaster:
if numLasers == 1 then
-- Para lasers únicos, eles miram reto
table.insert(directions, originCFrame.LookVector)
elseif numLasers > 1 then
-- Para múltiplos lasers, espalhe-os uniformemente horizontalmente
-- sobre um intervalo laserSpreadDegrees ao redor do centro
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
endPara demonstrar ainda mais esse conceito, se você fosse incluir um terceiro tipo de blaster com uma ampla dispersão vertical, poderia criar um novo atributo de blaster, como spreadDirection, e então ajustar o cálculo de CFrame para usar um eixo diferente. Por exemplo, note a diferença nos cálculos de direction no seguinte script abaixo para este hipotético terceiro tipo de blaster.
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 directionsNo final, a função rayDirections() retorna uma tabela de Vectors que representam a direção de cada feixe de laser. Se for útil, você pode adicionar alguns logs para ter uma noção de como esses dados se parecem.
local rayDirections = getDirectionsForBlast(currentCamera.CFrame, blasterConfig)
for _, direction in rayDirections do -- nova linha
print(direction) -- nova linha
end -- nova linha
local rayResults = castLaserRay(localPlayer, currentCamera.CFrame.Position, rayDirections)Lançar raios
castLaserRay(), a segunda função em ReplicatedStorage ⟩ Blaster ⟩ attemptBlastClient ⟩ blastClient ⟩ generateBlastData, realiza as operações mais complexas dentro do script. Começa especificando parâmetros para que possa fazer chamadas Workspace:Raycast() para fins de raycasting. Raycasting é o processo de enviar um raio invisível de um ponto Vector3 em uma direção específica com um comprimento definido, e então verificar seu caminho para ver onde ele intersecta com outros objetos.
Essa informação é particularmente útil para jogos de tiro em primeira pessoa porque permite que você veja quando e onde os disparos intersectam com jogadores ou o ambiente. Por exemplo, a imagem a seguir demonstra dois raios que estão lançando paralelamente um ao outro. De acordo com seu ponto de origem e direção, o Raio A erra a parede e continua até atingir sua distância máxima, enquanto o Raio B colide com a parede. Para mais informações sobre esse processo, veja Raycasting.

Os parâmetros de castLaserRay() especificam que as chamadas Raycast() devem considerar todas as partes no espaço de trabalho exceto o personagem que disparou. O script então lança um raio para cada direção na tabela directions. Se um raio atinge algo, ele gera um RaycastResult, que possui cinco propriedades:
- Distance – A distância entre a origem do raio e o ponto de interseção.
- Material – O Enum.Material no ponto de interseção.
O valor Instance é o mais crítico dessas propriedades para a jogabilidade do jogo de laser tag de exemplo porque comunica quando os raios colidem com outros jogadores. Para recuperar essa informação, o jogo usa a função auxiliar ReplicatedStorage ⟩ LaserRay ⟩ castLaserRay ⟩ getPlayerFromDescendant. Se retornar nil, a instância não faz parte de um jogador, significando que o raio atingiu um objeto inanimado dentro do ambiente.
castLaserRay() então usa Position e Normal para criar um novo CFrame que chama de destination do raio. Cada raio tem um destino, e é onde o raio atingiu no espaço 3D, ou o ponto no final de sua distância máxima. Dependendo de quão bem seus jogadores miram, muitos ou a maioria dos valores taggedPlayer são nil.
if result then
-- O disparo atingiu algo, verifique se foi um jogador.
destination = CFrame.lookAt(result.Position, result.Position + result.Normal)
taggedPlayer = getPlayerFromDescendant(result.Instance)
else
-- O disparo não atingiu nada, então seu destino é
-- o ponto em sua distância máxima.
local distantPosition = origin + rayDirection * MAX_DISTANCE
destination = CFrame.lookAt(distantPosition, distantPosition - rayDirection)
taggedPlayer = nil
endValidar o disparo
Para evitar trapaças, o capítulo anterior Implementando Blasters explica como blastClient notifica o servidor do disparo usando um RemoteEvent para que ele possa verificar todos os dados que cada cliente envia, como se realmente marcaram outro jogador com seu blaster. Esse processo de validação de raios ocorre em ServerScriptService ⟩ LaserBlastHandler ⟩ getValidatedBlastData ⟩ getValidatedRayResults, e cada verificação se correlaciona a um script de módulo aninhado:
Primeiro, getValidatedRayResults chama validateRayResult para verificar se cada entrada na tabela rayResults do cliente é um CFrame e um Player (ou nil).
Em seguida, chama isRayAngleFromOriginValid para comparar os ângulos esperados da dispersão do laser com os do cliente. Este código em particular mostra a vantagem de usar ReplicatedStorage porque o servidor pode chamar getDirectionsForBlast ele mesmo, armazenar o retorno como os dados "esperados" e então compará-los com os dados do cliente.
Assim como a validação do blaster do capítulo anterior, isRayAngleFromOriginValid depende de um valor de tolerância para determinar o que constitui uma diferença "excessiva" em ângulos:
isRayAngleFromOriginValidlocal claimedDirection = (rayResult.destination.Position - originCFrame.Position).Unitlocal directionErrorDegrees = getAngleBetweenDirections(claimedDirection, expectedDirection)return directionErrorDegrees <= ToleranceValues.BLAST_ANGLE_SANITY_CHECK_TOLERANCE_DEGREESRoblox abstrai as partes mais envolvidas da matemática, então o resultado é uma função auxiliar curta e altamente reutilizável com aplicabilidade em uma variedade de jogos:
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)endA próxima verificação é a mais intuitiva. Enquanto getValidatedBlastData usa DISTANCE_SANITY_CHECK_TOLERANCE_STUDS para verificar se o jogador que disparou estava perto do ponto de origem do feixe, isPlayerNearPosition usa lógica idêntica para verificar se o jogador marcado estava perto do destino do feixe:
isPlayerNearPositionlocal distanceFromCharacterToPosition = position - character:GetPivot().Positionif distanceFromCharacterToPosition.Magnitude > ToleranceValues.DISTANCE_SANITY_CHECK_TOLERANCE_STUDS thenreturn falseendA verificação final isRayPathObstructed usa uma variação da operação de lançamento de raios para verificar se o destino do raio está atrás de uma parede ou outra obstrução a partir da posição do cliente. Por exemplo, se um jogador malicioso estivesse sistematicamente removendo todas as paredes do jogo para marcar outros jogadores, o servidor verificaria e confirmaria que os raios são inválidos porque conhece a posição de cada objeto dentro do ambiente.
isRayPathObstructedlocal scaledDirection = (rayResult.destination.Position - blastData.originCFrame.Position)scaledDirection *= (scaledDirection.Magnitude - 1) / scaledDirection.Magnitude
Nenhuma estratégia anti-exploração é abrangente, mas é importante considerar como jogadores maliciosos podem abordar seu jogo para que você possa implementar verificações que o servidor pode executar para sinalizar comportamentos suspeitos.
Reduzir a saúde do jogador
Depois de verificar que um jogador marcou outro jogador, os passos finais para completar o loop principal de jogabilidade no jogo de laser tag de exemplo são reduzir a saúde do jogador marcado, incrementar a tabela de pontuação e respawnar o jogador de volta na rodada.
Começando com a redução da saúde do jogador marcado, Spawn e respawn cobre a distinção entre Player e Player.Character, especificamente que um personagem é um modelo Humanoid. Modelos Humanoid têm uma propriedade Health com um valor padrão de 100. Em vez de implementar seu próprio sistema, o jogo de laser tag de exemplo usa essa propriedade embutida para acompanhar quanto dano um jogador precisa antes de ser marcado para fora da rodada.
O jogo armazena valores de dano no atributo damagePerHit de cada blaster. Por exemplo, o blaster que dispara um único feixe de laser inflige 10 pontos de dano, então leva dez disparos com esse blaster para marcar outro jogador. Para iniciar o processo de marcar um jogador para fora, LaserBlastHandler chama ServerScriptService ⟩ LaserBlastHandler ⟩ processTaggedPlayers, que verifica a tabela rayResults agora validada em busca de jogadores e passa damagePerHit para onPlayerTagged.

Health não aceita valores negativos, então onPlayerTagged tem alguma lógica para manter a saúde do jogador em ou acima de zero. Depois de verificar que a saúde do jogador está acima de zero, compara a saúde com damagePerHit e usa o menor dos dois valores. Por exemplo, se um jogador tem 10 de saúde e é atingido por um feixe de laser de 15 de dano, o laser só inflige 10 pontos de dano.
Essa abordagem para o problema pode parecer um pouco convoluta. Por exemplo, por que não simplesmente definir a saúde do jogador como zero se ela fosse negativa? A razão é que definir valores de saúde contorna o campo de força. Usar o método Humanoid:TakeDamage() garante que os jogadores não sofram dano enquanto seus campos de força estão ativos.
local function onPlayerTagged(playerBlasted: Player, playerTagged: Player, damageAmount: number)
local character = playerTagged.Character
local isFriendly = playerBlasted.Team == playerTagged.Team
-- Proibir fogo amigo
if isFriendly then
return
end
local humanoid = character and character:FindFirstChild("Humanoid")
if humanoid and humanoid.Health > 0 then
-- Evitar saúde negativa
local damage = math.min(damageAmount, humanoid.Health)
-- TakeDamage garante que a saúde não seja reduzida se o ForceField estiver ativo
humanoid:TakeDamage(damage)
if humanoid.Health <= 0 then
-- Premiar playerBlasted com um ponto por marcar playerTagged
Scoring.incrementScore(playerBlasted, 1)
end
end
endO próximo passo é incrementar a tabela de pontuação. Pode ter parecido desnecessário para LaserBlastHandler incluir o jogador que disparou junto com os dados do disparo, mas sem essa informação, o jogo não pode creditar o jogador por marcar alguém para fora. Finalmente, o jogador marcado para fora respawna de volta na rodada, o que você pode revisar em Spawn e Respawn.
Os cinco capítulos deste currículo cobrem o loop principal de jogabilidade do jogo, mas ainda há muitas áreas a explorar, como:
- Visuais do blaster: Veja ReplicatedStorage ⟩ FirstPersonBlasterVisuals e ServerScriptService ⟩ ThirdPersonBlasterVisuals.
- Áudio: Veja ReplicatedStorage ⟩ SoundHandler.
- Modos personalizados: Como você poderia modificar este jogo para introduzir novos tipos de objetivos, como marcar mais pontos antes que o tempo acabe?
Para lógica de jogabilidade estendida para o jogo de laser tag, bem como ativos ambientais reutilizáveis e de alta qualidade, revise o Laser Tag template.