--[[ Dynamic Gameplay Camera v1.0.34 - Stable Chase + Idle + Progressive V Distance Modes CLIENT-SIDE - Multi Theft Auto: San Andreas Unificação dos dois sistemas em UM controller: - Gameplay camera dinâmica enquanto dirige. - Idle cinematic automática quando o veículo fica parado. - Transição chase -> idle e idle -> chase sem setCameraTarget, evitando corte seco. - Idle cinematic agora usa 2 loops baixos/laterais contínuos, sem pontos altos de entrada. - Tecla V alterna 4 distâncias progressivas: Perto, Normal, Longe e Muito longe. - setCameraTarget(localPlayer) só é usado ao sair do veículo/morrer/parar resource/desativar. ]] local Config = { enabled = true, debug = false, activation = { autoStartWhenDriver = true, minSpeedForEffects = 5.0, disableOnFirstPerson = false, }, camera = { forwardCamOffset = { x = 0.0, y = -7.35, z = 2.28 }, forwardLookOffset = { x = 0.0, y = 2.25, z = 0.86 }, reverseCamOffset = { x = 0.0, y = 6.35, z = 2.18 }, reverseLookOffset = { x = 0.0, y = -2.65, z = 0.82 }, entryBlendMs = 950, positionSmooth = 9.5, lookSmooth = 11.0, reverseSmooth = 5.8, accelPullMax = 0.34, brakePushMax = 0.28, accelSmooth = 7.0, }, fov = { enabled = true, default = 70, max = 84, speedForMax = 220, smooth = 6.5, }, cameraView = { enabled = true, switchKey = "v", notify = true, -- A tecla V alterna estes 4 modos externos em ordem progressiva. -- Perto -> Normal -> Longe -> Muito longe. -- A idle cinematográfica ignora esses modos para não quebrar as animações paradas. modes = { { name = "Perto", distanceScale = 0.78, heightAdd = -0.08, lookYAdd = -0.06, fovAdd = -1, }, { name = "Normal", distanceScale = 1.00, heightAdd = 0.00, lookYAdd = 0.00, fovAdd = 0, }, { name = "Longe", distanceScale = 1.20, heightAdd = 0.16, lookYAdd = 0.15, fovAdd = 2, }, { name = "Muito longe", distanceScale = 1.42, heightAdd = 0.28, lookYAdd = 0.26, fovAdd = 4, }, } }, turnVision = { enabled = true, minSpeed = 12.0, fullSpeed = 95.0, inputStrength = 0.30, yawRateStrength = 0.24, cameraSideShift = 0.18, lookSideShift = 0.46, frontLookBoost = 0.68, maxCameraSide = 0.28, maxLookSide = 0.62, maxFrontBoost = 0.85, smooth = 9.5, invert = false, }, cornerShake = { enabled = true, maxPosition = 0.0045, maxLook = 0.0025, frequency = 4.8, curvePower = 2.35, smooth = 10.0, minSpeed = 35.0, fullSpeed = 125.0, }, manualViews = { enabled = true, sideSmooth = 10.5, rearSmooth = 9.5, sideCam = { x = 4.55, y = -4.65, z = 2.12 }, sideLook = { x = 0.85, y = 1.65, z = 0.88 }, rearReleaseMs = 180, rearCamOffset = { x = 0.0, y = 6.15, z = 2.12 }, rearLookOffset = { x = 0.0, y = -3.15, z = 0.84 }, useAlternativeControls = true, }, safety = { maxTiltForEffects = 47.0, resetFovOnStopResource = true, }, } local IdleConfig = { enabled = true, idleTimeToStart = 5000, minSpeedToCancel = 1.5, closeCooldown = 1800, -- menor porque agora não devolve para câmera nativa transitionTime = 2600, -- v1.0.25: trava global de altura da idle para eliminar o efeito de "nascer de cima". maxIdleHeight = 1.58, minIdleHeight = 0.46, heightPenaltyInSearch = 4.5, -- penaliza ponto alto ao escolher onde entrar no loop cameraDistanceScale = 1.08, orbitDistanceScale = 1.08, detailDistanceScale = 1.22, progressiveFlow = true, sameSideChance = 45, balancedVehicleCoverage = true, sideBalanceTolerance = 1, smoothTransitionMin = 2800, smoothTransitionMax = 4200, seamlessProfileChanges = true, -- nova animação nasce da última posição real da câmera profileStartSearchSamples = 16, -- procura dentro da próxima animação o ponto mais próximo da câmera atual reverseProfileIfCloserToEnd = true, -- se o ponto mais próximo for o final, inverte o caminho para continuar fluido minRemainingProfileProgress = 0.42, -- usado apenas em perfis não-loop megaLoopLocked = true, -- mantém o mesmo mega loop rodando continuamente durante a sessão idle megaLoopStartSearchSamples = 48, -- acha o ponto mais próximo do loop ao entrar na idle cancelWhenCursorVisible = true, forceVehicleStreamable = true, enableTestCommands = true, -- v1.0.25: 2 loops gigantes, porém somente baixos/laterais. -- Não existe mais ponto alto/drone/crane no começo, fim ou meio do loop. -- O loop inteiro fica limitado por maxIdleHeight para não começar de cima e descer. profiles = { { name = "Low Loop 01 - Lateral Showcase Around Car", mode = "mega_loop", weight = 1, durationMin = 145000, durationMax = 145000, loopStyle = "low_showcase", shake = 0.0010, lookOffset = { x = 0.0, y = 0.0, z = 0.72 } }, { name = "Low Loop 02 - Detail Flow Around Car", mode = "mega_loop", weight = 1, durationMin = 155000, durationMax = 155000, loopStyle = "low_detail_flow", shake = 0.0012, lookOffset = { x = 0.0, y = 0.0, z = 0.68 } } } } local state = { active = false, vehicle = nil, lastTick = getTickCount(), currentCam = nil, currentLook = nil, entryFrom = nil, entryStartTick = 0, currentFov = Config.fov.default, viewModeIndex = 1, reverseBlend = 0, manualSideBlend = 0, manualSide = 0, manualRearBlend = 0, rearPeekUntil = 0, mouse3Held = false, accelEffect = 0, turnAmount = 0, shakeAmount = 0, lastSpeed = 0, lastYaw = nil, yawRate = 0, } local idle = { active = false, idleStartTick = false, lastCloseTick = 0, currentProfile = nil, profileStartTick = 0, profileDuration = 6000, transitionStartTick = 0, transitionDuration = IdleConfig.transitionTime, fromCam = nil, fromLook = nil, lastCam = nil, lastLook = nil, preferredSide = nil, sideStats = { left = 0, right = 0 }, lastProfileName = nil, } local function clamp(v, a, b) if v < a then return a end if v > b then return b end return v end local function lerp(a, b, t) return a + (b - a) * t end local function smoothAlpha(speed, dt) return 1 - math.exp(-(speed or 8) * dt) end local function randomFloat(a, b) return a + math.random() * (b - a) end local function angleDiff(a, b) return (a - b + 180) % 360 - 180 end local function deepCopy(obj) if type(obj) ~= "table" then return obj end local result = {} for k, v in pairs(obj) do result[k] = deepCopy(v) end return result end local function safeGetEasingValue(progress, easingType) progress = clamp(progress or 0, 0, 1) local value = getEasingValue(progress, easingType or "InOutQuad") if type(value) ~= "number" then value = getEasingValue(progress, "InOutQuad") end if type(value) ~= "number" then value = progress end return value end local function getSpeedKMH(vehicle) if not isElement(vehicle) then return 0 end local vx, vy, vz = getElementVelocity(vehicle) return math.sqrt(vx * vx + vy * vy + vz * vz) * 180 end local function getVehicleYaw(vehicle) local _, _, rz = getElementRotation(vehicle) return rz or 0 end local function getForwardVectorFromYaw(yawDeg) local r = math.rad(yawDeg) return -math.sin(r), math.cos(r), 0 end local function getSignedForwardSpeedKMH(vehicle, yawDeg) if not isElement(vehicle) then return 0 end local vx, vy = getElementVelocity(vehicle) local fx, fy = getForwardVectorFromYaw(yawDeg) return (vx * fx + vy * fy) * 180 end local function localToWorldYaw(vehicle, ox, oy, oz, yawDeg) if not isElement(vehicle) then return nil end local px, py, pz = getElementPosition(vehicle) local r = math.rad(yawDeg) local rightX, rightY = math.cos(r), math.sin(r) local forwardX, forwardY = -math.sin(r), math.cos(r) return px + rightX * ox + forwardX * oy, py + rightY * ox + forwardY * oy, pz + oz end local function getDriverVehicle() local vehicle = getPedOccupiedVehicle(localPlayer) if not vehicle or not isElement(vehicle) then return nil end if getVehicleOccupant(vehicle, 0) ~= localPlayer then return nil end if isPedDead(localPlayer) then return nil end return vehicle end local function getControlStateSafe(controlName) if type(getControlState) ~= "function" then return false end local ok, result = pcall(getControlState, controlName) return ok and result == true end local function getKeyStateSafe(keyName) if type(getKeyState) ~= "function" then return false end local ok, result = pcall(getKeyState, keyName) return ok and result == true end local function setFovSafe(value) value = clamp(value, 1, 179) state.currentFov = value if type(setCameraFieldOfView) == "function" then pcall(setCameraFieldOfView, "vehicle", value) pcall(setCameraFieldOfView, "vehicle_max", value) pcall(setCameraFieldOfView, "player", value) end end local function resetCameraSystem() setFovSafe(Config.fov.default) setCameraTarget(localPlayer) end local function stopDynamicCamera(reason) state.active = false state.vehicle = nil state.currentCam = nil state.currentLook = nil state.entryFrom = nil state.reverseBlend = 0 state.manualSideBlend = 0 state.manualSide = 0 state.manualRearBlend = 0 state.rearPeekUntil = 0 state.mouse3Held = false state.accelEffect = 0 state.turnAmount = 0 state.shakeAmount = 0 state.lastYaw = nil state.yawRate = 0 idle.active = false idle.idleStartTick = false idle.currentProfile = nil idle.fromCam = nil idle.fromLook = nil resetCameraSystem() if Config.debug then outputDebugString("[DynamicCamera] stopped: " .. tostring(reason)) end end local function startDynamicCamera(vehicle) if state.active then return end if not isElement(vehicle) then return end state.active = true state.vehicle = vehicle state.lastTick = getTickCount() state.entryStartTick = getTickCount() local cx, cy, cz, lx, ly, lz = getCameraMatrix() if cx then state.entryFrom = { cam = { cx, cy, cz }, look = { lx, ly, lz } } else state.entryFrom = nil end state.currentCam = nil state.currentLook = nil state.lastSpeed = getSpeedKMH(vehicle) state.lastYaw = getVehicleYaw(vehicle) state.manualSideBlend = 0 state.manualSide = 0 state.manualRearBlend = 0 state.rearPeekUntil = 0 idle.active = false idle.idleStartTick = false idle.preferredSide = nil idle.sideStats = { left = 0, right = 0 } if Config.debug then outputDebugString("[DynamicCamera] started") end end local function getTurnInput() local left = getControlStateSafe("vehicle_left") or getControlStateSafe("steer_left") local right = getControlStateSafe("vehicle_right") or getControlStateSafe("steer_right") local value = 0 if left then value = value - 1 end if right then value = value + 1 end return value end local function getLookLeftState() if getControlStateSafe("vehicle_look_left") or getControlStateSafe("look_left") then return true end if Config.manualViews.useAlternativeControls then return getKeyStateSafe("q") or false end return false end local function getLookRightState() if getControlStateSafe("vehicle_look_right") or getControlStateSafe("look_right") then return true end if Config.manualViews.useAlternativeControls then return getKeyStateSafe("e") or false end return false end local function getLookBehindState() if getControlStateSafe("vehicle_look_behind") or getControlStateSafe("look_behind") then return true end return getLookLeftState() and getLookRightState() end local function getManualViewInput() if not Config.manualViews.enabled then return 0, false end local left = getLookLeftState() local right = getLookRightState() local behind = getLookBehindState() or state.mouse3Held or getTickCount() < (state.rearPeekUntil or 0) if behind then return 0, true end if left and not right then return -1, false elseif right and not left then return 1, false end return 0, false end local function getCameraViewModeConfig() local cfg = Config.cameraView if not cfg or not cfg.enabled or not cfg.modes or #cfg.modes == 0 then return { name = "Normal", distanceScale = 1.0, heightAdd = 0.0, lookYAdd = 0.0, fovAdd = 0 } end state.viewModeIndex = clamp(state.viewModeIndex or 1, 1, #cfg.modes) return cfg.modes[state.viewModeIndex] or cfg.modes[1] end local function cycleCameraViewMode() local cfg = Config.cameraView if not cfg or not cfg.enabled or not cfg.modes or #cfg.modes == 0 then return end state.viewModeIndex = (state.viewModeIndex or 1) + 1 if state.viewModeIndex > #cfg.modes then state.viewModeIndex = 1 end -- Evita tranco ao trocar: o novo modo nasce da câmera atual suavizada. state.entryFrom = nil local mode = getCameraViewModeConfig() if cfg.notify then outputChatBox("#ff0048[DynamicCamera] #ffffffModo de câmera: #ff0048" .. tostring(mode.name or state.viewModeIndex), 255,255,255,true) end end local function getVehicleTilt(vehicle) local rx, ry = getElementRotation(vehicle) rx = math.abs(((rx or 0) + 180) % 360 - 180) ry = math.abs(((ry or 0) + 180) % 360 - 180) return math.max(rx, ry) end local function getVehicleRelativePosition(vehicle, offsetX, offsetY, offsetZ) if not isElement(vehicle) then return nil end local matrix = getElementMatrix(vehicle) if not matrix then return nil end local right, forward, up, pos = matrix[1], matrix[2], matrix[3], matrix[4] return pos[1] + right[1] * offsetX + forward[1] * offsetY + up[1] * offsetZ, pos[2] + right[2] * offsetX + forward[2] * offsetY + up[2] * offsetZ, pos[3] + right[3] * offsetX + forward[3] * offsetY + up[3] * offsetZ end local function getVehicleLookAt(vehicle, offset) offset = offset or { x = 0, y = 0, z = 0.75 } return getVehicleRelativePosition(vehicle, offset.x or 0, offset.y or 0, offset.z or 0.75) end -- ========================================================= -- IDLE CINEMATIC INTEGRADA -- ========================================================= local function idleMaybeMirrorProfile(profile) if profile.__mirrorApplied or not profile.mirrorSide then return end profile.__mirrorApplied = true local side if IdleConfig.balancedVehicleCoverage then local leftCount, rightCount = idle.sideStats.left or 0, idle.sideStats.right or 0 local tolerance = IdleConfig.sideBalanceTolerance or 1 if leftCount > rightCount + tolerance then side = 1 elseif rightCount > leftCount + tolerance then side = -1 elseif idle.preferredSide and math.random(1,100) <= (IdleConfig.sameSideChance or 45) then side = idle.preferredSide elseif idle.preferredSide then side = -idle.preferredSide else side = math.random(0,1) == 1 and 1 or -1 end elseif IdleConfig.progressiveFlow and idle.preferredSide and math.random(1,100) <= (IdleConfig.sameSideChance or 45) then side = idle.preferredSide else side = math.random(0,1) == 1 and 1 or -1 end idle.preferredSide = side if side == -1 then idle.sideStats.left = (idle.sideStats.left or 0) + 1 else idle.sideStats.right = (idle.sideStats.right or 0) + 1 end local function applySide(offset) if offset and offset.x then offset.x = side * math.abs(offset.x) end end applySide(profile.startOffset); applySide(profile.endOffset); applySide(profile.camOffset) if profile.lookOffset and profile.mirrorLookX then profile.lookOffset.x = side * math.abs(profile.lookOffset.x) end end local function idlePrepareProfile(profile) local copy = deepCopy(profile) idleMaybeMirrorProfile(copy) copy.__baseAngle = randomFloat(0, math.pi * 2) if copy.baseAngleMin and copy.baseAngleMax then copy.__baseAngle = math.rad(randomFloat(copy.baseAngleMin, copy.baseAngleMax)) elseif copy.sideBias then if math.random(0, 1) == 1 then copy.__baseAngle = math.rad(randomFloat(70, 115)) else copy.__baseAngle = math.rad(randomFloat(245, 290)) end end copy.__radius = randomFloat(copy.radiusMin or 3.5, copy.radiusMax or 5.0) copy.__height = randomFloat(copy.heightMin or 0.8, copy.heightMax or 1.3) copy.__arc = math.rad(randomFloat(copy.arcMin or 35, copy.arcMax or 75)) if math.random(0, 1) == 1 then copy.__arc = -copy.__arc end copy.__phase = randomFloat(0, math.pi * 2) return copy end local function idleChooseWeightedProfile() local totalWeight = 0 for _, p in ipairs(IdleConfig.profiles) do totalWeight = totalWeight + (p.weight or 1) end local rw, current = randomFloat(0, totalWeight), 0 for _, p in ipairs(IdleConfig.profiles) do current = current + (p.weight or 1); if rw <= current then return p end end return IdleConfig.profiles[math.random(1, #IdleConfig.profiles)] end local function idleBuildCameraForProfile(vehicle, profile, progress) if not isElement(vehicle) then return nil end progress = clamp(progress or 0, 0, 1) local eased = safeGetEasingValue(progress, "InOutQuad") local tick, t = getTickCount(), getTickCount() / 1000 local camX, camY, camZ, lookX, lookY, lookZ if profile.mode == "mega_loop" then -- Mega loops fechados: progress 0 e 1 são o mesmo ponto. -- Isso permite loop infinito sem corte seco nem ponto zero entre animações. local u = (progress % 1) * math.pi * 2 local style = profile.loopStyle or "showcase" local ox, oy, oz, lx, ly, lz if style == "low_detail_flow" then -- Loop baixo e próximo, mas sem câmera colada e sem ponto alto. -- Caminho fechado: traseira -> lateral -> frente -> lateral oposta -> traseira. ox = math.sin(u) * 4.15 + math.sin(u * 3.0) * 0.28 oy = math.cos(u) * 3.95 + math.sin(u * 2.0) * 0.32 oz = 0.74 + math.sin(u + 0.65) * 0.12 + math.sin(u * 2.0) * 0.035 lx = math.sin(u + 0.55) * 0.28 ly = math.cos(u - 0.35) * 0.36 lz = 0.66 + math.sin(u * 2.0 + 0.2) * 0.035 else -- Loop showcase lateral mais aberto, sempre baixo. -- Não há crane/drone. O máximo fica abaixo de IdleConfig.maxIdleHeight. ox = math.sin(u) * 5.55 + math.sin(u * 2.0) * 0.42 oy = math.cos(u) * 4.85 + math.cos(u * 2.0) * 0.38 oz = 1.02 + math.sin(u + 0.8) * 0.13 + math.sin(u * 2.0) * 0.04 lx = math.sin(u + 0.35) * 0.20 ly = math.cos(u - 0.25) * 0.28 lz = 0.73 + math.sin(u * 2.0) * 0.035 end -- Micro parallax temporal quase imperceptível para não parecer trilho duro. ox = ox + math.sin(t * 0.38 + profile.__phase) * 0.025 oy = oy + math.cos(t * 0.34 + profile.__phase) * 0.025 oz = oz + math.sin(t * 0.31 + profile.__phase) * 0.010 -- Trava global de altura. Isso é a correção principal da v1.0.25: -- nenhum ponto do loop pode nascer alto e depois descer. oz = clamp(oz, IdleConfig.minIdleHeight or 0.45, IdleConfig.maxIdleHeight or 1.6) lz = clamp(lz, 0.52, (IdleConfig.maxIdleHeight or 1.6) - 0.18) camX, camY, camZ = getVehicleRelativePosition(vehicle, ox, oy, oz) lookX, lookY, lookZ = getVehicleRelativePosition(vehicle, lx, ly, lz) elseif profile.mode == "orbit" then local angle = profile.__baseAngle + profile.__arc * eased local radius = profile.__radius * (profile.distanceScale or IdleConfig.orbitDistanceScale or 1) local height = profile.__height local micro = math.sin(t * 0.45 + profile.__phase) * 0.08 local ox = math.cos(angle) * (radius + micro) local oy = math.sin(angle) * (radius + micro) local oz = height + math.sin(t * 0.35 + profile.__phase) * 0.04 camX, camY, camZ = getVehicleRelativePosition(vehicle, ox, oy, oz) lookX, lookY, lookZ = getVehicleLookAt(vehicle, profile.lookOffset) elseif profile.mode == "slider" or profile.mode == "push" then local s, e = profile.startOffset, profile.endOffset local ox = s.x + (e.x - s.x) * eased local oy = s.y + (e.y - s.y) * eased local oz = s.z + (e.z - s.z) * eased local distanceScale = profile.distanceScale or IdleConfig.cameraDistanceScale or 1 ox, oy = ox * distanceScale, oy * distanceScale ox = ox + math.sin(t * 0.55 + profile.__phase) * 0.035 oy = oy + math.cos(t * 0.45 + profile.__phase) * 0.035 camX, camY, camZ = getVehicleRelativePosition(vehicle, ox, oy, oz) lookX, lookY, lookZ = getVehicleLookAt(vehicle, profile.lookOffset) elseif profile.mode == "detail" then local o = profile.camOffset local ox = o.x + math.sin(t * 0.9 + profile.__phase) * (profile.swayX or 0.04) local oy = o.y + math.cos(t * 0.75 + profile.__phase) * (profile.swayY or 0.04) local oz = o.z + math.sin(t * 1.15 + profile.__phase) * (profile.swayZ or 0.02) local distanceScale = profile.distanceScale or IdleConfig.detailDistanceScale or 1 ox, oy = ox * distanceScale, oy * distanceScale camX, camY, camZ = getVehicleRelativePosition(vehicle, ox, oy, oz) lookX, lookY, lookZ = getVehicleLookAt(vehicle, profile.lookOffset) end if not camX or not lookX then return nil end local shake = profile.shake or 0 if shake > 0 then camX = camX + math.sin(t * 6.2 + profile.__phase) * shake camY = camY + math.cos(t * 5.7 + profile.__phase) * shake camZ = camZ + math.sin(t * 7.1 + profile.__phase) * shake * 0.45 lookX = lookX + math.sin(t * 2.2 + profile.__phase) * shake * 0.25 lookY = lookY + math.cos(t * 2.0 + profile.__phase) * shake * 0.25 lookZ = lookZ + math.sin(t * 2.6 + profile.__phase) * shake * 0.15 end return { cam = { camX, camY, camZ }, look = { lookX, lookY, lookZ } } end local function idleDistanceBetweenCameraPoints(a, b) if not a or not b or not a.cam or not b.cam then return 999999 end local dx = (a.cam[1] or 0) - (b.cam[1] or 0) local dy = (a.cam[2] or 0) - (b.cam[2] or 0) local dz = ((a.cam[3] or 0) - (b.cam[3] or 0)) * (IdleConfig.heightPenaltyInSearch or 1) local lx, ly, lz = 0, 0, 0 if a.look and b.look then lx = ((a.look[1] or 0) - (b.look[1] or 0)) * 0.35 ly = ((a.look[2] or 0) - (b.look[2] or 0)) * 0.35 lz = ((a.look[3] or 0) - (b.look[3] or 0)) * 0.35 end return dx * dx + dy * dy + dz * dz + lx * lx + ly * ly + lz * lz end local function idleReversePreparedProfile(profile) if not profile then return end if (profile.mode == "slider" or profile.mode == "push") and profile.startOffset and profile.endOffset then local oldStart = profile.startOffset profile.startOffset = profile.endOffset profile.endOffset = oldStart elseif profile.mode == "orbit" then profile.__baseAngle = (profile.__baseAngle or 0) + (profile.__arc or 0) profile.__arc = -(profile.__arc or 0) end end local function idleFindClosestProgress(vehicle, profile, fromCam, fromLook) if not isElement(vehicle) or not fromCam or not fromLook then return 0 end -- Detail é praticamente uma câmera parada com leve sway; não compensa buscar ponto interno. if profile.mode == "detail" then return 0 end local reference = { cam = { fromCam[1], fromCam[2], fromCam[3] }, look = { fromLook[1], fromLook[2], fromLook[3] } } local samples = math.max(6, profile.mode == "mega_loop" and (IdleConfig.megaLoopStartSearchSamples or 48) or (IdleConfig.profileStartSearchSamples or 16)) local bestP, bestDistance = 0, 999999 for i = 0, samples do local p = i / samples local candidate = idleBuildCameraForProfile(vehicle, profile, p) local d = idleDistanceBetweenCameraPoints(reference, candidate) if d < bestDistance then bestDistance = d bestP = p end end -- Se o ponto mais próximo está no final da animação, inverte o caminho. -- Assim a próxima animação não precisa "voltar para o começo" nem nascer de cima. if IdleConfig.reverseProfileIfCloserToEnd and bestP > 0.62 and profile.mode ~= "detail" and profile.mode ~= "mega_loop" then idleReversePreparedProfile(profile) bestP = 1 - bestP end -- Em mega_loop podemos entrar em qualquer ponto, porque o caminho é fechado e infinito. if profile.mode ~= "mega_loop" then -- Evita entrar no último pedaço do perfil, que causaria troca muito rápida. local maxStart = 1 - (IdleConfig.minRemainingProfileProgress or 0.42) bestP = clamp(bestP, 0, maxStart) end return bestP end local function idleChooseNewProfile(fromCam, fromLook) if not idle.active then return false end local vehicle = state.vehicle if not isElement(vehicle) then stopDynamicCamera("idle_vehicle_destroyed"); return false end -- Ponto mais importante da v1.0.22: -- a próxima animação sempre nasce da última câmera REAL renderizada. -- Isso evita o bug de voltar para a chase camera / ponto zero por 1 frame -- quando troca de perfil cinematográfico. if fromCam and fromLook then idle.fromCam = { fromCam[1], fromCam[2], fromCam[3] } idle.fromLook = { fromLook[1], fromLook[2], fromLook[3] } elseif idle.lastCam and idle.lastLook then idle.fromCam = { idle.lastCam[1], idle.lastCam[2], idle.lastCam[3] } idle.fromLook = { idle.lastLook[1], idle.lastLook[2], idle.lastLook[3] } elseif state.currentCam and state.currentLook then idle.fromCam = { state.currentCam[1], state.currentCam[2], state.currentCam[3] } idle.fromLook = { state.currentLook[1], state.currentLook[2], state.currentLook[3] } else local cx, cy, cz, lx, ly, lz = getCameraMatrix() idle.fromCam = { cx, cy, cz } idle.fromLook = { lx, ly, lz } end local selected = idleChooseWeightedProfile() local profile = idlePrepareProfile(selected) -- v1.0.23: continuidade real. -- A nova animação não começa obrigatoriamente no progress 0. -- Ela procura, dentro do próprio caminho, o ponto mais próximo da câmera atual -- e passa a tocar a partir dali. Isso elimina a sensação de nascer de cima/ponto zero. profile.__startProgress = idleFindClosestProgress(vehicle, profile, idle.fromCam, idle.fromLook) idle.currentProfile = profile idle.lastProfileName = profile.name idle.profileStartTick = getTickCount() idle.profileDuration = math.random(profile.durationMin or 5000, profile.durationMax or 7500) idle.transitionStartTick = getTickCount() idle.transitionDuration = math.random(IdleConfig.smoothTransitionMin or IdleConfig.transitionTime, IdleConfig.smoothTransitionMax or IdleConfig.transitionTime) return true end local function idleStart() if idle.active then return end local vehicle = state.vehicle if not IdleConfig.enabled or not isElement(vehicle) then return end idle.active = true idle.currentProfile = nil idle.lastCam = state.currentCam and { state.currentCam[1], state.currentCam[2], state.currentCam[3] } or nil idle.lastLook = state.currentLook and { state.currentLook[1], state.currentLook[2], state.currentLook[3] } or nil idle.preferredSide = nil idle.sideStats = { left = 0, right = 0 } if IdleConfig.forceVehicleStreamable and setElementStreamable then setElementStreamable(vehicle, true) end idleChooseNewProfile() -- FOV volta para padrão durante idle de forma suave pelo render principal. end local function idleExit(reason) if not idle.active then return end idle.active = false idle.lastCloseTick = getTickCount() idle.idleStartTick = false idle.currentProfile = nil idle.fromCam = nil idle.fromLook = nil idle.lastCam = nil idle.lastLook = nil -- Não chama setCameraTarget aqui. A câmera chase assume suavemente a posição atual. if Config.debug then outputDebugString("[DynamicCamera] idle exit: " .. tostring(reason)) end end local function idleUpdateTrigger(vehicle, speed) if not IdleConfig.enabled then return end if idle.active then return end if getTickCount() - (idle.lastCloseTick or 0) < IdleConfig.closeCooldown then idle.idleStartTick = false; return end if IdleConfig.cancelWhenCursorVisible and isCursorShowing() then idle.idleStartTick = false; return end if state.mouse3Held or getLookLeftState() or getLookRightState() then idle.idleStartTick = false; return end if speed <= IdleConfig.minSpeedToCancel then if not idle.idleStartTick then idle.idleStartTick = getTickCount(); return end if getTickCount() - idle.idleStartTick >= IdleConfig.idleTimeToStart then idleStart() end else idle.idleStartTick = false end end local function idleRender(vehicle, dt) if not idle.active then return nil end if not isElement(vehicle) then stopDynamicCamera("idle_invalid_vehicle"); return nil end local speed = getSpeedKMH(vehicle) if speed > IdleConfig.minSpeedToCancel or state.mouse3Held or getLookLeftState() or getLookRightState() then idleExit("input_or_moved") return nil end if not idle.currentProfile then if not idleChooseNewProfile() then return nil end end local now = getTickCount() local elapsed = now - idle.profileStartTick -- Não retorna nil na troca de animação. -- Antes isso fazia o render principal cair na chase camera por 1 frame, -- parecendo que a câmera voltava para o ponto zero a cada corte. if elapsed >= idle.profileDuration then if idle.currentProfile and idle.currentProfile.mode == "mega_loop" and IdleConfig.megaLoopLocked then -- Não troca de animação: mantém o loop matemático rodando. -- Isso elimina o corte ou nascimento em ponto alto/ponto zero. elapsed = elapsed % idle.profileDuration idle.profileStartTick = now - elapsed else local bridgeCam = idle.lastCam or state.currentCam local bridgeLook = idle.lastLook or state.currentLook if not idleChooseNewProfile(bridgeCam, bridgeLook) then return nil end now = getTickCount() elapsed = 0 end end local progress = clamp(elapsed / idle.profileDuration, 0, 1) local startProgress = idle.currentProfile.__startProgress or 0 local effectiveProgress if idle.currentProfile.mode == "mega_loop" then -- Loop fechado: começa no ponto mais próximo da câmera atual e segue circularmente. effectiveProgress = (startProgress + progress) % 1 else effectiveProgress = startProgress + (1 - startProgress) * progress effectiveProgress = clamp(effectiveProgress, 0, 1) end local target = idleBuildCameraForProfile(vehicle, idle.currentProfile, effectiveProgress) if not target then idleExit("invalid_idle_target"); return nil end local transitionProgress = clamp((now - idle.transitionStartTick) / idle.transitionDuration, 0, 1) local e = safeGetEasingValue(transitionProgress, "InOutQuad") local result if transitionProgress < 1 and idle.fromCam and idle.fromLook then local camX, camY, camZ = interpolateBetween(idle.fromCam[1], idle.fromCam[2], idle.fromCam[3], target.cam[1], target.cam[2], target.cam[3], e, "InOutQuad") local lookX, lookY, lookZ = interpolateBetween(idle.fromLook[1], idle.fromLook[2], idle.fromLook[3], target.look[1], target.look[2], target.look[3], e, "InOutQuad") result = { cam = { camX, camY, camZ }, look = { lookX, lookY, lookZ } } else result = target end idle.lastCam = { result.cam[1], result.cam[2], result.cam[3] } idle.lastLook = { result.look[1], result.look[2], result.look[3] } return result end -- ========================================================= -- CHASE CAMERA -- ========================================================= local function buildTargetCamera(vehicle, dt) if not isElement(vehicle) then return nil end local speed = getSpeedKMH(vehicle) local yaw = getVehicleYaw(vehicle) local signedSpeed = getSignedForwardSpeedKMH(vehicle, yaw) local tilt = getVehicleTilt(vehicle) local effectsAllowed = tilt <= Config.safety.maxTiltForEffects if state.lastYaw then local diff = angleDiff(yaw, state.lastYaw) local rawRate = diff / math.max(dt, 0.001) state.yawRate = lerp(state.yawRate or 0, rawRate, smoothAlpha(7.0, dt)) end state.lastYaw = yaw local reverseTarget = signedSpeed < -2.5 and 1 or 0 state.reverseBlend = lerp(state.reverseBlend, reverseTarget, smoothAlpha(Config.camera.reverseSmooth, dt)) local manualSide, manualRear = getManualViewInput() local sideTarget = manualRear and 0 or manualSide local rearTarget = manualRear and 1 or 0 state.manualSide = lerp(state.manualSide or 0, sideTarget, smoothAlpha(Config.manualViews.sideSmooth, dt)) state.manualSideBlend = lerp(state.manualSideBlend or 0, math.abs(sideTarget), smoothAlpha(Config.manualViews.sideSmooth, dt)) state.manualRearBlend = lerp(state.manualRearBlend or 0, rearTarget, smoothAlpha(Config.manualViews.rearSmooth, dt)) local accel = dt > 0 and (speed - (state.lastSpeed or speed)) / dt or 0 state.lastSpeed = speed local accelTarget = 0 if effectsAllowed and speed > Config.activation.minSpeedForEffects then if accel > 12 then accelTarget = -clamp(accel / 90, 0, 1) * Config.camera.accelPullMax elseif accel < -18 then accelTarget = clamp((-accel) / 120, 0, 1) * Config.camera.brakePushMax end end state.accelEffect = lerp(state.accelEffect, accelTarget, smoothAlpha(Config.camera.accelSmooth, dt)) local turnTarget = 0 if Config.turnVision.enabled and effectsAllowed and speed >= Config.turnVision.minSpeed then local sf = clamp((speed - Config.turnVision.minSpeed) / math.max(1, Config.turnVision.fullSpeed - Config.turnVision.minSpeed), 0, 1) local input = getTurnInput() * Config.turnVision.inputStrength local yawPart = clamp((state.yawRate or 0) / 55, -1, 1) * Config.turnVision.yawRateStrength turnTarget = clamp((input + yawPart) * sf, -1, 1) if Config.turnVision.invert then turnTarget = -turnTarget end end state.turnAmount = lerp(state.turnAmount, turnTarget, smoothAlpha(Config.turnVision.smooth, dt)) local shakeTarget = 0 if Config.cornerShake.enabled and effectsAllowed and speed >= Config.cornerShake.minSpeed then local sf = clamp((speed - Config.cornerShake.minSpeed) / math.max(1, Config.cornerShake.fullSpeed - Config.cornerShake.minSpeed), 0, 1) shakeTarget = (math.abs(state.turnAmount) ^ Config.cornerShake.curvePower) * sf end state.shakeAmount = lerp(state.shakeAmount, shakeTarget, smoothAlpha(Config.cornerShake.smooth, dt)) local viewMode = getCameraViewModeConfig() local fovDefault = Config.fov.default + ((viewMode and viewMode.fovAdd) or 0) local fovMax = Config.fov.max + ((viewMode and viewMode.fovAdd) or 0) local targetFov = fovDefault if Config.fov.enabled and effectsAllowed and not idle.active then targetFov = lerp(fovDefault, fovMax, clamp(speed / Config.fov.speedForMax, 0, 1)) end setFovSafe(lerp(state.currentFov or fovDefault, targetFov, smoothAlpha(Config.fov.smooth, dt))) local t, reverse = state.turnAmount or 0, state.reverseBlend or 0 local forwardCam, forwardLook = Config.camera.forwardCamOffset, Config.camera.forwardLookOffset local reverseCam, reverseLook = Config.camera.reverseCamOffset, Config.camera.reverseLookOffset local camSide = clamp(-t * Config.turnVision.cameraSideShift, -Config.turnVision.maxCameraSide, Config.turnVision.maxCameraSide) local lookSide = clamp(t * Config.turnVision.lookSideShift, -Config.turnVision.maxLookSide, Config.turnVision.maxLookSide) local frontBoost = clamp(math.abs(t) * Config.turnVision.frontLookBoost, 0, Config.turnVision.maxFrontBoost) local camForward = { x = forwardCam.x + camSide, y = forwardCam.y + state.accelEffect, z = forwardCam.z } local lookForward = { x = forwardLook.x + lookSide, y = forwardLook.y + frontBoost, z = forwardLook.z } local reverseT = t * 0.35 local camReverse = { x = reverseCam.x + clamp(reverseT * 0.20, -0.22, 0.22), y = reverseCam.y, z = reverseCam.z } local lookReverse = { x = reverseLook.x + clamp(reverseT * 0.30, -0.32, 0.32), y = reverseLook.y, z = reverseLook.z } if viewMode then local ds = viewMode.distanceScale or 1.0 local h = viewMode.heightAdd or 0.0 local lyAdd = viewMode.lookYAdd or 0.0 camForward.y = camForward.y * ds camReverse.y = camReverse.y * ds camForward.z = camForward.z + h camReverse.z = camReverse.z + h lookForward.y = lookForward.y + lyAdd lookReverse.y = lookReverse.y - lyAdd end local ox, oy, oz = lerp(camForward.x, camReverse.x, reverse), lerp(camForward.y, camReverse.y, reverse), lerp(camForward.z, camReverse.z, reverse) local lx, ly, lz = lerp(lookForward.x, lookReverse.x, reverse), lerp(lookForward.y, lookReverse.y, reverse), lerp(lookForward.z, lookReverse.z, reverse) local sideBlend = state.manualSideBlend or 0 if sideBlend > 0.001 then local sideSign = (state.manualSide or 0) >= 0 and 1 or -1 local sideCam, sideLook = Config.manualViews.sideCam, Config.manualViews.sideLook local ds = (viewMode and viewMode.distanceScale) or 1.0 local h = (viewMode and viewMode.heightAdd) or 0.0 ox = lerp(ox, sideCam.x * sideSign * ds, sideBlend); oy = lerp(oy, sideCam.y * ds, sideBlend); oz = lerp(oz, sideCam.z + h, sideBlend) lx = lerp(lx, sideLook.x * sideSign, sideBlend); ly = lerp(ly, sideLook.y, sideBlend); lz = lerp(lz, sideLook.z, sideBlend) end local rearBlend = state.manualRearBlend or 0 if rearBlend > 0.001 then local rearCam, rearLook = Config.manualViews.rearCamOffset, Config.manualViews.rearLookOffset local ds = (viewMode and viewMode.distanceScale) or 1.0 local h = (viewMode and viewMode.heightAdd) or 0.0 ox = lerp(ox, rearCam.x, rearBlend); oy = lerp(oy, rearCam.y * ds, rearBlend); oz = lerp(oz, rearCam.z + h, rearBlend) lx = lerp(lx, rearLook.x, rearBlend); ly = lerp(ly, rearLook.y, rearBlend); lz = lerp(lz, rearLook.z, rearBlend) end local camX, camY, camZ = localToWorldYaw(vehicle, ox, oy, oz, yaw) local lookX, lookY, lookZ = localToWorldYaw(vehicle, lx, ly, lz, yaw) if not camX or not lookX then return nil end if state.shakeAmount > 0.001 and not idle.active then local now, s, freq = getTickCount() / 1000, state.shakeAmount, Config.cornerShake.frequency local posAmp, lookAmp = Config.cornerShake.maxPosition * s, Config.cornerShake.maxLook * s camX = camX + math.sin(now * freq * 1.31) * posAmp camY = camY + math.cos(now * freq * 1.07) * posAmp camZ = camZ + math.sin(now * freq * 1.63) * posAmp * 0.45 lookX = lookX + math.sin(now * freq * 0.91) * lookAmp lookY = lookY + math.cos(now * freq * 0.87) * lookAmp end return { cam = { camX, camY, camZ }, look = { lookX, lookY, lookZ } } end local function renderDynamicCamera(timeSlice) if not Config.enabled then if state.active then stopDynamicCamera("disabled") end return end local vehicle = getDriverVehicle() if not vehicle then if state.active then stopDynamicCamera("no_driver_vehicle") end return end if not state.active then if Config.activation.autoStartWhenDriver then startDynamicCamera(vehicle) else return end end if state.vehicle ~= vehicle then stopDynamicCamera("vehicle_changed"); startDynamicCamera(vehicle); return end if Config.activation.disableOnFirstPerson and getCameraViewMode and getCameraViewMode() == 0 then stopDynamicCamera("first_person"); return end local now = getTickCount() local dt = (timeSlice or (now - state.lastTick)) / 1000 dt = clamp(dt, 0.001, 0.08) state.lastTick = now local speed = getSpeedKMH(vehicle) idleUpdateTrigger(vehicle, speed) local target = nil if idle.active then target = idleRender(vehicle, dt) end if not target then target = buildTargetCamera(vehicle, dt) end if not target then stopDynamicCamera("invalid_target"); return end local entryP = 1 if state.entryFrom then entryP = clamp((now - state.entryStartTick) / Config.camera.entryBlendMs, 0, 1) entryP = entryP * entryP * (3 - 2 * entryP) end if not state.currentCam then if state.entryFrom then state.currentCam = { state.entryFrom.cam[1], state.entryFrom.cam[2], state.entryFrom.cam[3] } state.currentLook = { state.entryFrom.look[1], state.entryFrom.look[2], state.entryFrom.look[3] } else state.currentCam = { target.cam[1], target.cam[2], target.cam[3] } state.currentLook = { target.look[1], target.look[2], target.look[3] } end end local posSmooth = idle.active and 999 or Config.camera.positionSmooth local lookSmooth = idle.active and 999 or Config.camera.lookSmooth local posA = smoothAlpha(posSmooth, dt) local lookA = smoothAlpha(lookSmooth, dt) state.currentCam[1] = lerp(state.currentCam[1], target.cam[1], posA) state.currentCam[2] = lerp(state.currentCam[2], target.cam[2], posA) state.currentCam[3] = lerp(state.currentCam[3], target.cam[3], posA) state.currentLook[1] = lerp(state.currentLook[1], target.look[1], lookA) state.currentLook[2] = lerp(state.currentLook[2], target.look[2], lookA) state.currentLook[3] = lerp(state.currentLook[3], target.look[3], lookA) local camX, camY, camZ = state.currentCam[1], state.currentCam[2], state.currentCam[3] local lookX, lookY, lookZ = state.currentLook[1], state.currentLook[2], state.currentLook[3] if state.entryFrom and entryP < 1 then camX = lerp(state.entryFrom.cam[1], camX, entryP); camY = lerp(state.entryFrom.cam[2], camY, entryP); camZ = lerp(state.entryFrom.cam[3], camZ, entryP) lookX = lerp(state.entryFrom.look[1], lookX, entryP); lookY = lerp(state.entryFrom.look[2], lookY, entryP); lookZ = lerp(state.entryFrom.look[3], lookZ, entryP) end if not isElement(vehicle) then stopDynamicCamera("vehicle_destroyed"); return end setCameraMatrix(camX, camY, camZ, lookX, lookY, lookZ, 0, state.currentFov or Config.fov.default) end addEventHandler("onClientPreRender", root, renderDynamicCamera) addEventHandler("onClientVehicleExit", root, function(player) if player == localPlayer then stopDynamicCamera("vehicle_exit") end end) addEventHandler("onClientPlayerWasted", localPlayer, function() stopDynamicCamera("player_wasted") end) addEventHandler("onClientElementDestroy", root, function() if state.active and source == state.vehicle then stopDynamicCamera("vehicle_destroyed_event") end end) addEventHandler("onClientResourceStop", resourceRoot, function() stopDynamicCamera("resource_stop") end) addEventHandler("onClientKey", root, function(button, press) if not press then return end if idle.active then idleExit("key_pressed") end if state.active then idle.idleStartTick = false end end) addEventHandler("onClientCursorMove", root, function() if idle.active then idleExit("cursor_moved") end idle.idleStartTick = false end) bindKey(Config.cameraView.switchKey or "v", "down", function() if Config.cameraView and Config.cameraView.enabled then -- Se estiver na idle, sair dela suavemente antes de trocar modo de gameplay. if idle.active then idleExit("view_mode_switch") end idle.idleStartTick = false cycleCameraViewMode() end end) bindKey("mouse3", "down", function() if Config.manualViews.enabled then if idle.active then idleExit("mouse3") end state.mouse3Held = true state.rearPeekUntil = 0 if state.active then state.manualRearBlend = math.max(state.manualRearBlend or 0, 0.12) end end end) bindKey("mouse3", "up", function() if Config.manualViews.enabled then state.mouse3Held = false if state.active then state.rearPeekUntil = getTickCount() + (Config.manualViews.rearReleaseMs or 160) else state.rearPeekUntil = 0 end end end) addCommandHandler("dyncam", function() Config.enabled = not Config.enabled if not Config.enabled then stopDynamicCamera("command_disabled"); outputChatBox("#ff0048[DynamicCamera] #ffffffDesativada.", 255,255,255,true) else outputChatBox("#ff0048[DynamicCamera] #ffffffAtivada.", 255,255,255,true) end end) addCommandHandler("dyncamreset", function() state.currentCam = nil; state.currentLook = nil; state.reverseBlend = 0; state.manualSideBlend = 0; state.manualSide = 0; state.manualRearBlend = 0; state.rearPeekUntil = 0; state.mouse3Held = false; state.accelEffect = 0; state.turnAmount = 0; state.shakeAmount = 0; state.lastYaw = nil; state.yawRate = 0 idle.active = false; idle.idleStartTick = false; idle.currentProfile = nil setFovSafe(Config.fov.default) outputChatBox("#ff0048[DynamicCamera] #ffffffSuavizações resetadas.", 255,255,255,true) end) addCommandHandler("dyncamview", function() cycleCameraViewMode() end) addCommandHandler("dyncaminfo", function() local vehicle = getDriverVehicle() local speed = vehicle and getSpeedKMH(vehicle) or 0 local mode = getCameraViewModeConfig() outputChatBox("#ff0048[DynamicCamera] #ffffffv1.0.34 | ativo: " .. tostring(state.active) .. " | idle: " .. tostring(idle.active) .. " | ligado: " .. tostring(Config.enabled), 255,255,255,true) outputChatBox("#ff0048[DynamicCamera] #ffffffvelocidade: " .. string.format("%.1f", speed) .. " km/h | turn: " .. string.format("%.2f", state.turnAmount or 0) .. " | ré: " .. string.format("%.2f", state.reverseBlend or 0), 255,255,255,true) outputChatBox("#ff0048[DynamicCamera] #ffffffmodo V: " .. tostring(mode.name or state.viewModeIndex) .. " | idle loop: " .. tostring(idle.lastProfileName), 255,255,255,true) end) if IdleConfig.enableTestCommands then addCommandHandler("testcine", function() local vehicle = getDriverVehicle() if not vehicle then outputChatBox("#ff0048[CineCam] #ffffffVocê precisa estar dirigindo.",255,255,255,true); return end state.active = state.active or false if not state.active then startDynamicCamera(vehicle) end idleStart() outputChatBox("#ff0048[CineCam] #ffffffIdle cinematográfica integrada ativada.",255,255,255,true) end) addCommandHandler("stopcine", function() if idle.active then idleExit("manual_command"); outputChatBox("#ff0048[CineCam] #ffffffIdle desligada, chase assumindo suave.",255,255,255,true) else outputChatBox("#ff0048[CineCam] #ffffffIdle já está desligada.",255,255,255,true) end end) end