Detectar acertos é o processo de identificar quando os disparos colidem com 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 arma foi apontada para o alvo.
O tipo de detecção de acertos que você usa depende dos requisitos de gameplay da sua experiência. Por exemplo, uma verificação fisicamente simulada é apropriada para uma experiência 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 uma experiência de laser tag, onde os feixes devem ter uma velocidade quase infinita e ignorar fatores ambientais como gravidade e velocidade do vento.
Usando a experiência de laser tag de amostra 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:
- Obter a direção do disparo a partir dos valores atuais da câmera e do tipo de arma do jogador.
- Lançar raios em um caminho reto da arma enquanto dispara.
- Validar o disparo para evitar a exploração dos dados da arma.
- Reduzir a saúde do jogador de acordo com o dano do disparo de cada tipo de arma e quantos raios atingiram o jogador.
Após concluir 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 sua arma, ReplicatedStorage ⟩ Blaster ⟩ attemptBlastClient ⟩ blastClient ⟩ generateBlastData chama duas funções para iniciar o processo de detecção de acertos: rayDirections() e rayResults().
generateBlastDatalocal rayDirections = getDirectionsForBlast(currentCamera.CFrame, blasterConfig)local rayResults = castLaserRay(localPlayer, currentCamera.CFrame.Position, rayDirections)
As entradas para rayDirections são diretas: a posição atual e os valores de rotação da câmera, e o tipo de arma do jogador. Se a experiência de laser tag de amostra apenas fornecesse armas que produzem um único feixe laser, ReplicatedStorage ⟩ LaserRay ⟩ getDirectionsForBlast não seria necessário porque você poderia usar currentCamera.CFrame.LookVector para calcular a direção do disparo.
No entanto, como a amostra fornece um tipo de arma adicional que produz vários feixes laser com uma ampla dispersão horizontal, getDirectionsForBlast deve calcular a direção de cada feixe laser da dispersão de acordo com seus ângulos dentro da configuração da arma:
getDirectionsForBlastif numLasers == 1 then-- Para lasers únicos, eles miram retotable.insert(directions, originCFrame.LookVector)elseif numLasers > 1 then-- Para múltiplos lasers, distribua-os uniformemente na horizontal-- sobre um intervalo laserSpreadDegrees ao redor do centrolocal 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
Para demonstrar ainda mais este conceito, se você quisesse incluir um terceiro tipo de arma com uma ampla dispersão vertical, poderia criar um novo atributo de arma, como spreadDirection, e então ajustar o cálculo CFrame para usar um eixo diferente. Por exemplo, note a diferença nos cálculos de direction no seguinte script abaixo para esse hipotético terceiro tipo de arma.
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
No final, a função rayDirections() retorna uma tabela de Vectors que representam a direção de cada feixe laser. Se for útil, você pode adicionar algum registro para ter uma noção de como esses dados se parecem.
generateBlastDatalocal rayDirections = getDirectionsForBlast(currentCamera.CFrame, blasterConfig)for _, direction in rayDirections do -- nova linhaprint(direction) -- nova linhaend -- nova linhalocal 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, em seguida, verificar seu caminho para ver onde ele intersecta com outros objetos.
Essa informação é particularmente útil para experiências de tiro em primeira pessoa porque permite ver quando e onde os disparos intersectam com jogadores ou o ambiente. Por exemplo, a seguinte imagem 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 não atinge a parede e continua até encontrar 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 na workspace exceto o personagem que disparou. O script então lança um raio para cada direção na tabela directions. Se um raio atingir 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 o gameplay da experiência de laser tag de amostra porque comunica quando os raios colidem com outros jogadores. Para recuperar essas informações, a experiência 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.
castLaserRayif result then-- O disparo acertou algo, verifique se foi um jogador.destination = CFrame.lookAt(result.Position, result.Position + result.Normal)taggedPlayer = getPlayerFromDescendant(result.Instance)else-- O disparo não acertou nada, então seu destino é-- o ponto em sua distância máxima.local distantPosition = origin + rayDirection * MAX_DISTANCEdestination = CFrame.lookAt(distantPosition, distantPosition - rayDirection)taggedPlayer = nilend
Validar o disparo
Para prevenir trapaças, o capítulo anterior Implementando Armas 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 sua arma. Esse processo de validação de raios ocorre em ServerScriptService ⟩ LaserBlastHandler ⟩ getValidatedBlastData ⟩ getValidatedRayResults, e cada verificação está correlacionada a um módulo de script aninhado:
Primeiro, getValidatedRayResults chama validateRayResult para verificar se cada entrada na tabela rayResults do cliente é um CFrame e um Player (ou nil).
Em seguida, é chamada isRayAngleFromOriginValid para comparar os ângulos esperados da dispersão do laser com aqueles 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, em seguida, compará-lo com os dados do cliente.
Assim como a validação da arma 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 complicadas da matemática, então o resultado é uma função auxiliar curta e altamente reutilizável com aplicabilidade em uma variedade de experiências:
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 raio 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 mal-intencionado estivesse sistematicamente removendo todas as paredes da experiência 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 de anti-exploração é abrangente, mas é importante considerar como jogadores mal-intencionados podem abordar sua experiência para que você possa implementar verificações que o servidor possa executar para sinalizar comportamentos suspeitos.
Reduzir a saúde do jogador
Após verificar que um jogador acertou outro jogador, os passos finais para completar o loop principal de gameplay na experiência de laser tag de amostra são reduzir a saúde do jogador marcado, incrementar a pontuação e reanimar 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, a experiência de laser tag de amostra usa essa propriedade embutida para acompanhar quanto dano um jogador precisa antes de ser marcado.
A experiência armazena valores de dano no atributo damagePerHit de cada arma. Por exemplo, a arma que dispara um único feixe laser causa 10 pontos de dano, então são necessários dez disparos com essa arma para marcar outro jogador. Para iniciar o processo de marcar um jogador, LaserBlastHandler chama ServerScriptService ⟩ LaserBlastHandler ⟩ processTaggedPlayers, que verifica a tabela rayResults já validada em busca de jogadores e passa damagePerHit para onPlayerTagged.

Health não aceita valores negativos, então onPlayerTagged possui alguma lógica para manter a saúde do jogador em zero ou acima. Após verificar que a saúde do jogador está acima de zero, ele 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 laser de 15 de dano, o laser causa apenas 10 pontos de dano.
Essa abordagem do problema pode parecer um pouco convoluta. Por exemplo, por que não apenas definir a saúde do jogador como zero se isso seria negativo? 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 estiverem ativos.
onPlayerTagged
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 campo de força estiver ativo
humanoid:TakeDamage(damage)
if humanoid.Health <= 0 then
-- Premiar playerBlasted com um ponto por marcar playerTagged
Scoring.incrementScore(playerBlasted, 1)
end
end
end
O próximo passo é incrementar a pontuação. Pode ter parecido desnecessário para LaserBlastHandler incluir o jogador que disparou junto com os dados do disparo, mas sem essas informações, a experiência não pode creditar o jogador por marcar alguém. Por fim, o jogador que foi marcado retorna à rodada, o que você pode revisar em Spawn e Respawn.
Os cinco capítulos deste currículo cobrem o loop principal de gameplay da experiência, mas ainda há muitas áreas a explorar, como:
- Visuais da arma: Veja ReplicatedStorage ⟩ FirstPersonBlasterVisuals e ServerScriptService ⟩ ThirdPersonBlasterVisuals.
- Áudio: Veja ReplicatedStorage ⟩ SoundHandler.
- Modos personalizados: Como você poderia modificar essa experiência para introduzir novos tipos de objetivos, como marcar mais pontos antes que o tempo acabe?
Para lógica de gameplay estendida para a experiência de laser tag, assim como ativos ambientais reutilizáveis e de alta qualidade, revise o Laser Tag template.