359 lines
12 KiB
Lua
359 lines
12 KiB
Lua
-- Prevent running if menu is disabled
|
|
if not TX_MENU_ENABLED then return end
|
|
|
|
-- =============================================
|
|
-- Contains all spectate related logic
|
|
-- =============================================
|
|
|
|
-- Control keys config
|
|
local CONTROLS
|
|
if IS_FIVEM then
|
|
CONTROLS = {
|
|
next = 187, --INPUT_FRONTEND_DOWN
|
|
prev = 188, --INPUT_FRONTEND_UP
|
|
exit = 194, --INPUT_FRONTEND_RRIGHT
|
|
}
|
|
else
|
|
CONTROLS = {
|
|
next = 0x05CA7C52, --INPUT_FRONTEND_DOWN
|
|
prev = 0x6319DB71, --INPUT_FRONTEND_UP
|
|
exit = 0x156F7119, --INPUT_FRONTEND_CANCEL
|
|
}
|
|
end
|
|
|
|
|
|
-- Last spectate location stored in a vec3
|
|
local spectatorReturnCoords
|
|
-- Spectate mode
|
|
local isSpectateEnabled = false
|
|
-- Whether should we lock the camera to the target ped
|
|
local isInTransitionState = false
|
|
-- Spectated ped
|
|
local storedTargetPed
|
|
-- Spectated player's client ID
|
|
local storedTargetPlayerId
|
|
-- Spectated players associated server id
|
|
local storedTargetServerId
|
|
|
|
|
|
--- Helper function to get coords under target
|
|
local function calculateSpectatorCoords(coords)
|
|
return vec3(coords.x, coords.y, coords.z - 15.0)
|
|
end
|
|
|
|
--- Will freeze the player and set the entity to invisible
|
|
--- @param enabled boolean - Whether we should prepare or cleanup
|
|
local function prepareSpectatorPed(enabled)
|
|
local playerPed = PlayerPedId()
|
|
FreezeEntityPosition(playerPed, enabled)
|
|
SetEntityVisible(playerPed, not enabled, 0)
|
|
|
|
if enabled then
|
|
TaskLeaveAnyVehicle(playerPed, 0, 16)
|
|
end
|
|
end
|
|
|
|
--- Will load collisions, tp the player, and fade screen
|
|
--- The player should be frozen when calling this function
|
|
--- @param coords table <string, number>
|
|
--- @return nil
|
|
local function collisionTpCoordTransition(coords)
|
|
debugPrint('Starting full collision teleport')
|
|
|
|
-- Fade screen to black
|
|
if not IsScreenFadedOut() then DoScreenFadeOut(500) end
|
|
while not IsScreenFadedOut() do Wait(5) end
|
|
|
|
-- Teleport player back
|
|
local playerPed = PlayerPedId()
|
|
RequestCollisionAtCoord(coords.x, coords.y, coords.z)
|
|
SetEntityCoords(playerPed, coords.x, coords.y, coords.z)
|
|
local attempts = 0
|
|
while not HasCollisionLoadedAroundEntity(playerPed) do
|
|
Wait(5)
|
|
attempts = attempts + 1
|
|
if attempts > 1000 then
|
|
debugPrint('Failed to load collisions')
|
|
error()
|
|
end
|
|
end
|
|
|
|
debugPrint('Collisions loaded, player teleported')
|
|
end
|
|
|
|
--- Stops spectating
|
|
local function stopSpectating()
|
|
debugPrint('Stopping spectate process init')
|
|
isSpectateEnabled = false
|
|
isInTransitionState = true
|
|
|
|
-- blackout screen
|
|
DoScreenFadeOut(500)
|
|
while not IsScreenFadedOut() do Wait(5) end
|
|
|
|
-- reset spectator
|
|
NetworkSetInSpectatorMode(false, nil)
|
|
if IS_FIVEM then
|
|
SetMinimapInSpectatorMode(false, nil)
|
|
end
|
|
if spectatorReturnCoords then
|
|
debugPrint('Returning spectator to original coords')
|
|
if not pcall(collisionTpCoordTransition, spectatorReturnCoords) then
|
|
debugPrint('collisionTpCoordTransition failed!')
|
|
end
|
|
else
|
|
debugPrint('No spectator return coords saved')
|
|
end
|
|
prepareSpectatorPed(false)
|
|
toggleShowPlayerIDs(false, false)
|
|
|
|
-- resetting cache + threads
|
|
storedTargetPed = nil
|
|
storedTargetPlayerId = nil
|
|
storedTargetServerId = nil
|
|
spectatorReturnCoords = nil
|
|
|
|
-- fading screen back & marking as done
|
|
DoScreenFadeIn(500)
|
|
while IsScreenFadingIn() do Wait(5) end
|
|
isInTransitionState = false
|
|
|
|
--logging that we stopped
|
|
TriggerServerEvent('txsv:req:spectate:end')
|
|
end
|
|
|
|
--- Starts the thread that continuously teleport the spectator under the target
|
|
--- This is being done this way to make sure we are compatible with all VOIP resources
|
|
--- In the future check if it's possible to migrate to using `SetFocusPosAndVel` instead.
|
|
local function createSpectatorTeleportThread()
|
|
debugPrint('Starting teleport follower thread')
|
|
CreateThread(function()
|
|
local initialTargetServerid = storedTargetServerId
|
|
while isSpectateEnabled and storedTargetServerId == initialTargetServerid do
|
|
-- If ped doesn't exist anymore try to resolve it again
|
|
if not DoesEntityExist(storedTargetPed) then
|
|
local newPed = GetPlayerPed(storedTargetPlayerId)
|
|
if newPed > 0 then
|
|
if newPed ~= storedTargetPed then
|
|
debugPrint(("Spectated target ped (%s) updated to %s"):format(storedTargetPlayerId, newPed))
|
|
end
|
|
storedTargetPed = newPed
|
|
else
|
|
sendSnackbarMessage('error', 'nui_menu.player_modal.actions.interaction.notifications.spectate_failed', true)
|
|
debugPrint(("Spectated player (%s) no longer exists, ending spectate..."):format(storedTargetPlayerId))
|
|
stopSpectating()
|
|
break
|
|
end
|
|
end
|
|
|
|
-- Update Teleport
|
|
local newSpectateCoords = calculateSpectatorCoords(GetEntityCoords(storedTargetPed))
|
|
SetEntityCoords(PlayerPedId(), newSpectateCoords.x, newSpectateCoords.y, newSpectateCoords.z, 0, 0, 0, false)
|
|
|
|
Wait(500)
|
|
end
|
|
end)
|
|
end
|
|
|
|
|
|
--- Cycles the spectate to next or previous player
|
|
--- @param isNext boolean - If true, will spectate the next player in the list
|
|
local function handleSpecCycle(isNext)
|
|
-- We don't want to cycle if the player is moving down the menu using arrow keys
|
|
-- or if pause is open, or if spectate isn't enabled
|
|
if isMenuVisible or IsPauseMenuActive() or not isSpectateEnabled then
|
|
return
|
|
end
|
|
if isInTransitionState then
|
|
return debugPrint('Currently in transition moment, cannot change target')
|
|
end
|
|
if storedTargetServerId == nil then
|
|
return debugPrint('Cannot cycle prev/next player because current one is not saved')
|
|
end
|
|
debugPrint(('Cycling spectate from target: %s, isNext: %s'):format(
|
|
tostring(storedTargetServerId),
|
|
tostring(isNext)
|
|
))
|
|
TriggerServerEvent('txsv:req:spectate:cycle', storedTargetServerId, isNext)
|
|
end
|
|
|
|
-- Instructional stuff
|
|
local keysTable = {
|
|
{'Exit Spectate', CONTROLS.exit},
|
|
{'Previous Player', CONTROLS.prev},
|
|
{'Next Player', CONTROLS.next},
|
|
}
|
|
local redmInstructionGroup, redmPromptTitle
|
|
|
|
--- Key press checking (fivem)
|
|
local function fivemCheckControls()
|
|
if IsControlJustPressed(0, CONTROLS.next) then
|
|
handleSpecCycle(true)
|
|
end
|
|
if IsControlJustPressed(0, CONTROLS.prev) then
|
|
handleSpecCycle(false)
|
|
end
|
|
if IsControlJustPressed(0, CONTROLS.exit) then
|
|
stopSpectating()
|
|
end
|
|
end
|
|
|
|
--- Key press checking (redm)
|
|
local function redmCheckControls()
|
|
if PromptIsJustPressed(redmInstructionGroup.prompts['Next Player']) then
|
|
handleSpecCycle(true)
|
|
end
|
|
if PromptIsJustPressed(redmInstructionGroup.prompts['Previous Player']) then
|
|
handleSpecCycle(false)
|
|
end
|
|
if PromptIsJustPressed(redmInstructionGroup.prompts['Exit Spectate']) then
|
|
debugPrint('exit spectate button pressed')
|
|
stopSpectating()
|
|
end
|
|
end
|
|
local checkControlsFunc = IS_FIVEM and fivemCheckControls or redmCheckControls
|
|
|
|
|
|
--- Creates and draws the instructional scaleform
|
|
local function createInstructionalThreads()
|
|
debugPrint('Starting instructional buttons thread')
|
|
--drawing thread
|
|
CreateThread(function()
|
|
local fivemScaleform = IS_FIVEM and makeFivemInstructionalScaleform(keysTable)
|
|
while isSpectateEnabled do
|
|
if IS_FIVEM then
|
|
DrawScaleformMovieFullscreen(fivemScaleform, 255, 255, 255, 255, 0)
|
|
else
|
|
PromptSetActiveGroupThisFrame(redmInstructionGroup.groupId, redmPromptTitle, 1, 0, 0, 0)
|
|
end
|
|
Wait(0)
|
|
end
|
|
|
|
--cleanup of the scaleform movie
|
|
if IS_FIVEM then
|
|
SetScaleformMovieAsNoLongerNeeded()
|
|
end
|
|
debugPrint('Finished drawer thread')
|
|
end)
|
|
|
|
--controls thread for redm - disabled when menu is visible
|
|
CreateThread(function()
|
|
while isSpectateEnabled do
|
|
if not isMenuVisible then
|
|
checkControlsFunc()
|
|
end
|
|
Wait(5)
|
|
end
|
|
|
|
debugPrint('Finished buttons checker thread')
|
|
end)
|
|
end
|
|
|
|
|
|
-- Register NUI callback
|
|
RegisterSecureNuiCallback('spectatePlayer', function(data, cb)
|
|
TriggerServerEvent('txsv:req:spectate:start', tonumber(data.id))
|
|
cb({})
|
|
end)
|
|
|
|
|
|
-- Client-side event handler for failed cype (no next player or whatever)
|
|
RegisterNetEvent('txcl:spectate:cycleFailed', function()
|
|
sendSnackbarMessage('error', 'nui_menu.player_modal.actions.interaction.notifications.spectate_cycle_failed', true)
|
|
end)
|
|
|
|
-- Client-side event handler for an authorized spectate request
|
|
RegisterNetEvent('txcl:spectate:start', function(targetServerId, targetCoords)
|
|
if IS_REDM then
|
|
redmPromptTitle = CreateVarString(10, 'LITERAL_STRING', 'Spectate')
|
|
redmInstructionGroup = makeRedmInstructionalGroup(keysTable)
|
|
end
|
|
|
|
if isInTransitionState then
|
|
stopSpectating()
|
|
error('Spectate request received while in transition state')
|
|
end
|
|
|
|
-- check if self-spectate
|
|
if targetServerId == GetPlayerServerId(PlayerId()) then
|
|
return sendSnackbarMessage('error', 'nui_menu.player_modal.actions.interaction.notifications.spectate_yourself', true)
|
|
end
|
|
|
|
-- mark transitory state - locking the init of another spectate
|
|
isInTransitionState = true
|
|
|
|
-- wiping any previous spectate cache
|
|
-- maybe not needed, but just to make sure
|
|
storedTargetPed = nil
|
|
storedTargetPlayerId = nil
|
|
storedTargetServerId = nil
|
|
|
|
-- saving current player coords and preparing ped
|
|
if spectatorReturnCoords == nil then
|
|
local spectatorPed = PlayerPedId()
|
|
spectatorReturnCoords = GetEntityCoords(spectatorPed)
|
|
end
|
|
prepareSpectatorPed(true)
|
|
|
|
-- teleport player under target and fade to black
|
|
debugPrint(('Targets coords = x: %f, y: %f, z: %f'):format(targetCoords.x, targetCoords.y, targetCoords.z))
|
|
local coordsUnderTarget = calculateSpectatorCoords(targetCoords)
|
|
if not pcall(collisionTpCoordTransition, coordsUnderTarget) then
|
|
debugPrint('collisionTpCoordTransition failed!')
|
|
stopSpectating()
|
|
return
|
|
end
|
|
|
|
-- resolving target and saving in cache
|
|
-- this will try for up to 15 seconds (redm is slow af)
|
|
local targetResolveAttempts = 0
|
|
local resolvedPlayerId = -1
|
|
local resolvedPed = 0
|
|
while (resolvedPlayerId <= 0 or resolvedPed <= 0) and targetResolveAttempts < 300 do
|
|
targetResolveAttempts = targetResolveAttempts + 1
|
|
resolvedPlayerId = GetPlayerFromServerId(targetServerId)
|
|
resolvedPed = GetPlayerPed(resolvedPlayerId)
|
|
Wait(50)
|
|
end
|
|
|
|
--If failed to resolve the target
|
|
if (resolvedPlayerId <= 0 or resolvedPed <= 0) then
|
|
debugPrint('Failed to resolve target PlayerId or Ped')
|
|
-- reset spectator
|
|
if not pcall(collisionTpCoordTransition, spectatorReturnCoords) then
|
|
debugPrint('collisionTpCoordTransition failed!')
|
|
end
|
|
prepareSpectatorPed(false)
|
|
-- Fade screen back
|
|
DoScreenFadeIn(500)
|
|
while IsScreenFadedOut() do Wait(5) end
|
|
-- mark as finished
|
|
isInTransitionState = false
|
|
spectatorReturnCoords = nil
|
|
return sendSnackbarMessage('error', 'nui_menu.player_modal.actions.interaction.notifications.spectate_failed', true)
|
|
end
|
|
|
|
-- if player resolved
|
|
debugPrint('Resolved target player ped: ' .. tostring(resolvedPed))
|
|
storedTargetPed = resolvedPed
|
|
storedTargetPlayerId = resolvedPlayerId
|
|
storedTargetServerId = targetServerId
|
|
|
|
-- start spectating
|
|
NetworkSetInSpectatorMode(true, resolvedPed)
|
|
if IS_FIVEM then
|
|
SetMinimapInSpectatorMode(true, resolvedPed)
|
|
end
|
|
debugPrint(('Set spectate to true for resolvedPed (%s)'):format(resolvedPed))
|
|
|
|
isSpectateEnabled = true
|
|
isInTransitionState = false
|
|
toggleShowPlayerIDs(true, false)
|
|
createSpectatorTeleportThread()
|
|
createInstructionalThreads()
|
|
|
|
-- Fade screen back
|
|
DoScreenFadeIn(500)
|
|
while IsScreenFadedOut() do Wait(5) end
|
|
end)
|