diff --git a/camping_data.sql b/camping_data.sql new file mode 100644 index 0000000..33357d3 --- /dev/null +++ b/camping_data.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS camping ( + id INT AUTO_INCREMENT PRIMARY KEY, + type VARCHAR(20) NOT NULL, -- 'tent' or 'campfire' + model VARCHAR(50) NOT NULL, + x FLOAT NOT NULL, + y FLOAT NOT NULL, + z FLOAT NOT NULL, + stashID VARCHAR(50), -- For tents only + heading FLOAT NOT NULL +); \ No newline at end of file diff --git a/client.lua b/client.lua new file mode 100644 index 0000000..c925c4b --- /dev/null +++ b/client.lua @@ -0,0 +1,398 @@ +local activeTents = {} +local enteredTent = false +local points = {} -- points[stashID] = lib.points.new(...) +local spawned = {} -- spawned[stashID] = true/false +local pointMeta = {} -- เก็บข้อมูลตำแหน่งของจุด (สำหรับหา coords ตอนเล่นเอฟเฟกต์) + +local TentModel = Config.TentModel +local CampfireModel= Config.CampfireModel + +-- เชื้อเพลิง/ไฟต่อกองไฟ (0..100 และ true/false) +local fuelById = {} +local litById = {} + +-- แฮนเดิลพาร์ติเคิลของแต่ละกองไฟ +local fireFxById = {} + +Citizen.CreateThread(function() + TriggerServerEvent('camping:LoadData') +end) + +RegisterNetEvent('camping:updateTentState', function(tentID, isOccupied) + if isOccupied then activeTents[tentID] = true else activeTents[tentID] = nil end +end) + +-- sync fuel จาก server +RegisterNetEvent('camping:updateFuel', function(campfireId, value) + fuelById[campfireId] = value or 0 +end) + +-- sync สถานะไฟ + เปิด/ปิดเอฟเฟกต์ +RegisterNetEvent('camping:updateLit', function(campfireId, lit) + litById[campfireId] = (lit and true) or false + local meta = pointMeta[campfireId] + if not meta then return end + + local base = vec3(meta.x, meta.y, meta.z) + local off = Config.CampfireFx and Config.CampfireFx.offset or {x=0.0,y=0.0,z=0.0} + local p = vec3(base.x + (off.x or 0.0), base.y + (off.y or 0.0), base.z + (off.z or 0.0)) + + if litById[campfireId] then + if not fireFxById[campfireId] then + lib.requestNamedPtfxAsset(Config.CampfireFx.asset) + UseParticleFxAsset(Config.CampfireFx.asset) + local scale = (Config.CampfireFx.scale or 1.0) + fireFxById[campfireId] = StartParticleFxLoopedAtCoord( + Config.CampfireFx.name, p.x, p.y, p.z, 0.0, 0.0, 0.0, scale, false, false, false, false + ) + end + else + if fireFxById[campfireId] and DoesParticleFxLoopedExist(fireFxById[campfireId]) then + StopParticleFxLooped(fireFxById[campfireId], 0) + fireFxById[campfireId] = nil + end + end +end) + +-- helper: ลงทะเบียน point (เข้าใกล้ค่อยสปอว์น, ออกค่อยลบ) +local function registerCampingPoint(data) + if points[data.stashID] then return end + pointMeta[data.stashID] = { type=data.type, x=data.x, y=data.y, z=data.z, heading=data.heading } + + local offsetZ = (data.type == 'tent') and -1.55 or -0.2 + local heading = data.heading + local id = data.stashID + local pos = vec3(data.x, data.y, data.z + offsetZ) + + points[id] = lib.points.new({ coords = vec3(data.x, data.y, data.z), distance = 80.0 }) + local p = points[id] -- <<<< ชี้ตาราง point ด้วยตัวแปรก่อน + + function p:onEnter() + if spawned[id] then return end + spawned[id] = true + + if data.type == 'tent' then + Renewed.addObject({ + id=id, coords=pos, object=TentModel, dist=75, heading=heading, + canClimb=false, freeze=true, snapGround=false, + target = { + { label="Go inside", icon='fa-solid fa-tent-arrows-down', iconColor='grey', + distance=Config.targetDistance, canInteract=function() return not enteredTent end, + onSelect=function(info) UseTent(info.entity, id) end }, + { label="Open tent storage", icon='fa-solid fa-box-open', iconColor='grey', + distance=Config.targetDistance, onSelect=function() exports.ox_inventory:openInventory('stash', id) end }, + { label="Pickup tent", icon='fa-solid fa-hand', iconColor='grey', + distance=Config.targetDistance, canInteract=function() return not enteredTent end, + onSelect=function() deleteTent(id) end }, + } + }) + else + Renewed.addObject({ + id=id, coords=pos, object=CampfireModel, dist=75, heading=heading, + canClimb=false, freeze=true, snapGround=true, + target = { + { label="Campfire Menu", icon='fas fa-fire', iconColor='orange', + distance=Config.targetDistance, onSelect=function() showCampfireMenu(id) end }, + + { label="Ignite Fire", icon='fa-solid fa-fire-flame-curved', iconColor='orange', + distance=Config.targetDistance, + canInteract=function() return (litById[id] ~= true) and ((fuelById[id] or 0) > 0) end, + onSelect=function() TriggerServerEvent('camping:ignite', id) end }, + + { label="Extinguish Fire", icon='fa-solid fa-hand', iconColor='grey', + distance=Config.targetDistance, + canInteract=function() return litById[id] == true end, + onSelect=function() TriggerServerEvent('camping:extinguish', id) end }, + + { label="Pickup Campfire", icon='fas fa-hand', iconColor='grey', + distance=Config.targetDistance, onSelect=function() deleteCampfire(id) end }, + } + }) + + -- ถ้าไฟติดอยู่แล้วตอนเราเข้าระยะ ให้เปิดเอฟเฟกต์ทันที + if litById[id] then + TriggerEvent('camping:updateLit', id, true) + end + end + end + + function p:onExit() + if not spawned[id] then return end + spawned[id] = false + -- ปิดเอฟเฟกต์ถ้ามี + if fireFxById[id] and DoesParticleFxLoopedExist(fireFxById[id]) then + StopParticleFxLooped(fireFxById[id], 0) + fireFxById[id] = nil + end + Renewed.removeObject(id) + end + + if data.type == 'tent' then + TriggerServerEvent('camping:createTentStash', id) + end +end + +RegisterNetEvent('camping:loadCampingData', function(data) + registerCampingPoint(data) +end) + +-- ===== Utility IDs ===== +local function generateRandomTentStashId() return "tent_" .. math.random(100000, 999999) end +local function generateRandomCampfireId() return "campfire_" .. math.random(100000, 999999) end + +-- ===== Spawn ===== +RegisterNetEvent('camping:client:spawnTent', function(x,y,z,h,randomModel,stashId) + registerCampingPoint({ type='tent', model=randomModel, x=x, y=y, z=z, heading=h, stashID=stashId }) +end) + +RegisterNetEvent('camping:client:spawnCampfire', function(x,y,z,h,fireModel,campfireID) + -- เริ่มต้นไฟดับ + litById[campfireID] = false + registerCampingPoint({ type='campfire', model=fireModel, x=x, y=y, z=z, heading=h, stashID=campfireID }) +end) + +-- ===== Item use ===== +AddEventHandler('ox_inventory:usedItem', function(name, slotId, metadata) + if name == Config.tentItem then + TriggerEvent('ox_inventory:closeInventory') + local tentCoords, tentHeading = Renewed.placeObject(TentModel, 25, false, { + '-- Place Object -- \n','[E] Place \n','[X] Cancel \n','[SCROLL UP] Change Heading \n','[SCROLL DOWN] Change Heading' + }, nil, vec3(0.0, 0.0, 0.65)) + if tentCoords and tentHeading then + local stashId = (metadata and metadata.stashID) and metadata.stashID or generateRandomTentStashId() + TriggerServerEvent('camping:server:spawnTent', tentCoords.x, tentCoords.y, tentCoords.z, tentHeading, TentModel, stashId, slotId) + TriggerServerEvent('camping:saveCampingData', 'tent', TentModel, tentCoords.x, tentCoords.y, tentCoords.z, stashId, tentHeading) + lib.notify({ title='Tent', description='Tent placed successfully.', type='success' }) + end + elseif name == Config.campfireItem then + TriggerEvent('ox_inventory:closeInventory') + local fireCoords, fireHeading = Renewed.placeObject(CampfireModel, 25, false, { + '-- Place Object -- \n','[E] Place \n','[X] Cancel \n','[SCROLL UP] Change Heading \n','[SCROLL DOWN] Change Heading' + }, nil, vec3(0.0, 0.0, 0.1)) + if fireCoords and fireHeading then + local campfireID = generateRandomCampfireId() + TriggerServerEvent('camping:server:spawnCampfire', fireCoords.x, fireCoords.y, fireCoords.z, fireHeading, CampfireModel, campfireID, slotId) + TriggerServerEvent('camping:saveCampingData', 'campfire', CampfireModel, fireCoords.x, fireCoords.y, fireCoords.z, campfireID, fireHeading) + lib.notify({ title='Campfire', description='Campfire placed successfully.', type='success' }) + end + end +end) + +-- ===== Tent use flow (คงเดิม) ===== +function UseTent(entity, tentID) + local playerPed = cache.ped + local PedCoord = GetEntityCoords(playerPed) + if not DoesEntityExist(entity) then + lib.notify({title='Tent', description='No tent found or tent is invalid!', type='error'}); return + end + if activeTents[tentID] then + lib.notify({ title='Tent', description='Someone is already inside this tent.', type='error' }); return + end + local dict, anim = "amb@medic@standing@kneel@base", "base" + RequestAnimDict(dict); local timeout, startTime = 5000, GetGameTimer() + while not HasAnimDictLoaded(dict) do Wait(10); if GetGameTimer()-startTime>timeout then lib.notify({title='Tent',description='Failed to load animation.',type='error'}); return end end + TaskTurnPedToFaceEntity(cache.ped, entity, 1000); Wait(1000) + TaskPlayAnim(PlayerPedId(), dict, anim, 8.0,-8.0,-1,1,0,false,false,false); Wait(1000) + local tentCoords = GetEntityCoords(entity) + SetEntityCoordsNoOffset(playerPed, tentCoords.x, tentCoords.y, tentCoords.z, true, true, true) + SetEntityHeading(cache.ped, (GetEntityHeading(entity)+45.0)) + dict = Config.TentAnimDict or "amb@world_human_sunbathe@male@back@base" + anim = Config.TentAnimName or "base" + RequestAnimDict(dict); timeout, startTime = 5000, GetGameTimer() + while not HasAnimDictLoaded(dict) do Wait(10); if GetGameTimer()-startTime>timeout then lib.notify({title='Tent',description='Failed to load animation.',type='error'}); return end end + if IsEntityPlayingAnim(playerPed, dict, anim, 3) then lib.notify({title='Tent', description='You are already resting.', type='info'}); return end + TaskPlayAnim(playerPed, dict, anim, 8.0,-8.0,-1,1,0,false,false,false) + + activeTents[tentID] = true + TriggerServerEvent('camping:tentEntered', tentID) + enteredTent = true + lib.showTextUI('[E] to leave the tent') + CreateThread(function() + while enteredTent do + Wait(0) + if IsControlJustReleased(0, 38) then + enteredTent = false + ClearPedTasksImmediately(playerPed) + SetEntityCoords(playerPed, PedCoord.x, PedCoord.y, PedCoord.z, true, false, false, false) + if activeTents[tentID] then + activeTents[tentID] = nil + TriggerServerEvent('camping:tentExited', tentID) + lib.notify({ title='Tent', description='You have exited the tent.', type='info' }) + else + lib.notify({ title='Tent', description='You are not inside this tent.', type='error' }) + end + lib.hideTextUI() + end + end + end) +end + +-- ===== Delete ===== +function deleteTent(tentId) + if not tentId then + lib.notify({ title='Error', description='No previous tent spawned, or your previous tent has already been deleted.', type='error' }) + else + TriggerServerEvent('camping:deleteCampingData', 'tent', tentId) + lib.notify({ title='Tent', description='Tent deleted successfully.', type='success' }) + TriggerServerEvent('camping:AI', 'tent', 1, {stashID = tentId}) + TriggerServerEvent('camping:server:removeTentItem', tentId) + if points[tentId] then points[tentId]:remove(); points[tentId] = nil end + if spawned[tentId] then Renewed.removeObject(tentId); spawned[tentId] = false end + end +end + +function deleteCampfire(fireId) + if not fireId then + lib.notify({ title='Error', description='No previous camping spawned, or your previous campfire has already been deleted.', type='error' }) + else + TriggerServerEvent('camping:deleteCampingData', 'campfire', fireId) + lib.notify({ title='Campfire', description='Campfire deleted successfully.', type='success' }) + TriggerServerEvent('camping:AI', 'campfire', 1) + TriggerServerEvent('camping:server:removeFireItem', fireId) + if points[fireId] then points[fireId]:remove(); points[fireId] = nil end + if spawned[fireId] then Renewed.removeObject(fireId); spawned[fireId] = false end + -- ปิดเอฟเฟกต์ของกองไฟนี้ด้วย + if fireFxById[fireId] and DoesParticleFxLoopedExist(fireFxById[fireId]) then + StopParticleFxLooped(fireFxById[fireId], 0) + fireFxById[fireId] = nil + end + litById[fireId] = nil + fuelById[fireId] = nil + pointMeta[fireId] = nil + end +end + +RegisterNetEvent('camping:client:removeTentItem', function(tentID) Renewed.removeObject(tentID) end) +RegisterNetEvent('camping:client:removeFireItem', function(fireId) Renewed.removeObject(fireId) end) + +-- ===== Fuel / Cooking (เหมือนเดิม) ===== +local function ingredientsText(ings) + local t = {} + for _, ing in ipairs(ings or {}) do t[#t+1] = (ing.name or "item") .. " x" .. tostring(ing.count or 1) end + return table.concat(t, ", ") +end + +function showCampfireMenu(campfireId) + local fuel = lib.callback.await('camping:getFuel', false, campfireId) or 0 + fuelById[campfireId] = fuel + + local cookOptions = {} + for key, data in pairs(Config.Recipes or {}) do + local cookSec = math.floor((data.cookTime or 0) / 1000) + cookOptions[#cookOptions+1] = { + icon = data.icon or 'fa-utensils', title = data.label or key, + description = ("Cook time: %d seconds. Recipe: %s"):format(cookSec, ingredientsText(data.ingredients)), + event='campfire_cooking', args={ recipe=data, campfireId=campfireId }, + } + end + table.sort(cookOptions, function(a,b) return (a.title or "") < (b.title or "") end) + + local fuelOptions = {} + for _, opt in ipairs(Config.FuelMenu or {}) do + fuelOptions[#fuelOptions+1] = { + icon=opt.icon, title=opt.title, description=opt.description, event='add_fuel_option', + args={ type=opt.args.type, duration=opt.args.duration, campfireId=campfireId }, + } + end + + lib.registerContext({ + id='campfire_menu', title='Campfire', + options = { + { icon='fa-fire', title=('Fuel Level: %d%%'):format(math.floor(fuelById[campfireId] or 0)), + description='Current fuel level of this campfire.', progress=fuelById[campfireId] or 0, colorScheme='orange', readOnly=true }, + { icon='fa-plus', title='Add Fuel', description='Add fuel to keep the fire burning.', menu='add_fuel_menu', arrow=true }, + { icon='fa-kitchen-set', title='Cooking', description='Prepare meals using the campfire.', menu='campfire_cooking_menu', arrow=true }, + { icon= (litById[campfireId] and 'fa-toggle-on' or 'fa-toggle-off'), + title= (litById[campfireId] and 'Fire: ON' or 'Fire: OFF'), + description= (litById[campfireId] and 'The fire is burning.' or 'The fire is out.'), + disabled = (not litById[campfireId] and (fuelById[campfireId] or 0) <= 0), + event = (litById[campfireId] and 'extinguish_fire_now' or 'ignite_fire_now'), + args = { campfireId = campfireId }, + }, + } + }) + lib.registerContext({ id='add_fuel_menu', title='Add Fuel', menu='campfire_menu', options=fuelOptions }) + lib.registerContext({ id='campfire_cooking_menu', title='Cooking Menu', menu='campfire_menu', options=cookOptions }) + lib.showContext('campfire_menu') +end + +RegisterNetEvent('ignite_fire_now', function(p) local id = p and p.campfireId; if id then TriggerServerEvent('camping:ignite', id) end end) +RegisterNetEvent('extinguish_fire_now',function(p) local id = p and p.campfireId; if id then TriggerServerEvent('camping:extinguish', id) end end) + +RegisterNetEvent('add_fuel_option', function(data) + local campfireId = data.campfireId + local itemtype = data.type + local duration = data.duration or 0 + + local minAmt, maxAmt, itemlabel + if itemtype == "garbage" then minAmt, maxAmt, itemlabel = 1, 20, "Garbage" + elseif itemtype == "wood" then minAmt, maxAmt, itemlabel = 1, 10, "Firewood" + elseif itemtype == "coal" then minAmt, maxAmt, itemlabel = 1, 5, "Coal" end + + local itemCount = exports.ox_inventory:Search('count', itemtype) + if itemCount <= 0 then return lib.notify({ title='Fuel', description='You do not have enough ' .. itemlabel .. '.', type='error' }) end + + local amount = lib.inputDialog("Add Fuel", { { type="number", label=("Amount (%d-%d)"):format(minAmt, maxAmt), min=minAmt, max=maxAmt, default=itemCount } }) + if not amount or tonumber(amount[1]) < 1 then return lib.notify({ title='Fuel', description='Invalid amount.', type='error' }) end + + local inputAmount = tonumber(amount[1]) + if inputAmount > itemCount then return lib.notify({ title='Fuel', description='You do not have enough ' .. itemtype .. '.', type='error' }) end + + local totalDuration = duration * inputAmount + local fuelPercent = (totalDuration / Config.maxFuel) * 100 + + local newv = lib.callback.await('camping:addFuel', false, campfireId, fuelPercent) + fuelById[campfireId] = newv or fuelById[campfireId] + + TriggerServerEvent('camping:RI', itemtype, inputAmount) + lib.notify({ title='Campfire', description='Fuel added successfully.', type='success' }) + + showCampfireMenu(campfireId) +end) + +RegisterNetEvent('campfire_cooking', function(payload) + local data = payload or {} + local campfireId = data.campfireId + local selectedRecipe = data.recipe + if type(selectedRecipe) ~= "table" then return lib.notify({ title='Campfire', description='Invalid recipe selected.', type='error' }) end + + local cookSec = (selectedRecipe.cookTime or 0) / 1000 + local requiredFuelPercent = (cookSec / Config.maxFuel) * 100 + + local curFuel = lib.callback.await('camping:getFuel', false, campfireId) or 0 + if curFuel < requiredFuelPercent then return lib.notify({ title='Campfire', description='Not enough fuel to start cooking.', type='error' }) end + + for _, ingredient in pairs(selectedRecipe.ingredients or {}) do + if exports.ox_inventory:Search('count', ingredient.name) < (ingredient.count or 1) then + return lib.notify({ title='Campfire', description='Not enough ' .. tostring(ingredient.name), type='error' }) + end + end + for _, ingredient in pairs(selectedRecipe.ingredients or {}) do + TriggerServerEvent('camping:RI', ingredient.name, ingredient.count or 1) + end + + local ok = lib.progressBar({ + duration = selectedRecipe.cookTime or 0, label = 'Cooking ' .. (selectedRecipe.label or 'Food'), + useWhileDead=false, canCancel=false, disable={ move=true, car=true, combat=true, mouse=false }, + anim={ dict='amb@prop_human_bbq@male@base', clip='base', flag=49 } + }) + if ok then + local result = lib.callback.await('camping:tryConsumeFuel', false, campfireId, requiredFuelPercent) or {} + if not result.ok then return lib.notify({ title='Campfire', description='Not enough fuel (changed during cooking).', type='error' }) end + fuelById[campfireId] = result.fuel or fuelById[campfireId] + if selectedRecipe.key then TriggerServerEvent('camping:AI', selectedRecipe.key, 1) end + lib.notify({ title='Campfire', description=(selectedRecipe.label or 'Food') .. ' cooked successfully!', type='success' }) + showCampfireMenu(campfireId) + end +end) + +RegisterNetEvent('camping:notify', function(data) if data then lib.notify(data) end end) + +AddEventHandler('onResourceStop', function(res) + if res ~= GetCurrentResourceName() then return end + for id, p in pairs(points) do p:remove() end + for id, fx in pairs(fireFxById) do + if DoesParticleFxLoopedExist(fx) then StopParticleFxLooped(fx, 0) end + end + if enteredTent then lib.hideTextUI() end +end) \ No newline at end of file diff --git a/client/client.lua b/client/client.lua index 319ef21..6f13f78 100644 --- a/client/client.lua +++ b/client/client.lua @@ -1,1063 +1,1082 @@ -local sec = 1000 -local minute = 60 * sec -local activeTents = {} -- Table to track tents by stashID -local activeFires = {} -local enteredTent = false -local Inventory = Config.Inventory -local TentModel = Config.TentModel -local CampfireModel = Config.CampfireModel -local FuelSystem = { - fuelLevel = Config.DefaultFuelLevel or 0, - maxFuelLevel = Config.maxFuel or 300, - isUIOpen = false, -} +local sec = 1000 +local minute = 60 * sec +local activeTents = {} -- Table to track tents by stashID +local activeFires = {} +local enteredTent = false +local Inventory = Config.Inventory +local TentModel = Config.TentModel +local CampfireModel = Config.CampfireModel +local FuelSystem = { + fuelLevel = Config.DefaultFuelLevel or 0, + maxFuelLevel = Config.maxFuel or 300, + isUIOpen = false, +} local currentWeather = "clear" -local cachedInventory = {} --- Relay temperature changes for GES-SurvCore and legacy feeds -RegisterNetEvent('ges:temperature:changed', function(data) - if type(data) == 'table' and data.weather then - currentWeather = data.weather +local TENT_STASH_SLOTS = 10 +local TENT_STASH_WEIGHT = 10000 + +local function closeInventoryUI() + if Inventory == 'ox' then + TriggerEvent('ox_inventory:closeInventory') + elseif Inventory == 'qb' or Inventory == 'qs' then + TriggerEvent('inventory:client:closeInventory') end -end) +end -RegisterNetEvent('weather-temperature:syncData', function(data) - TriggerEvent('ges:temperature:changed', data) -end) +local function getInventorySnapshot() + local inventory = {} + + if Inventory == 'ox' then + local items = exports.ox_inventory:GetPlayerItems() + if items then + for _, item in ipairs(items) do + inventory[item.name] = item.count + end + end + elseif Inventory == 'qb' then + if QBCore then + local PlayerData = QBCore.Functions.GetPlayerData() + if PlayerData and PlayerData.items then + for _, item in pairs(PlayerData.items) do + if item and item.name then + inventory[item.name] = (inventory[item.name] or 0) + (item.amount or 0) + end + end + end + end + elseif Inventory == 'qs' then + local items = exports['qs-inventory']:getUserInventory() + if items then + for itemName, itemData in pairs(items) do + if type(itemData) == 'table' then + inventory[itemName] = itemData.amount or itemData.count or 0 + else + inventory[itemName] = itemData + end + end + end + end --- Cache optional GES-Temperature resource state -local gesTemperatureAvailable = false -local function updateGESTemperatureState() - gesTemperatureAvailable = Config.useGESTemperature - and GetResourceState - and GetResourceState('GES-Temperature') == 'started' + return inventory end -updateGESTemperatureState() +local function getInventoryItemCount(itemName) + if not itemName then return 0 end -AddEventHandler('onResourceStart', function(res) - if res == 'GES-Temperature' then updateGESTemperatureState() end -end) + if Inventory == 'ox' then + local result = exports.ox_inventory:Search('count', itemName) + return tonumber(result) or 0 + elseif Inventory == 'qb' then + if not QBCore then return 0 end + local PlayerData = QBCore.Functions.GetPlayerData() + local amount = 0 + if PlayerData and PlayerData.items then + for _, item in pairs(PlayerData.items) do + if item and item.name == itemName then + amount = amount + (item.amount or 0) + end + end + end + return amount + elseif Inventory == 'qs' then + local result = exports['qs-inventory']:Search(itemName) + return tonumber(result) or 0 + end -AddEventHandler('onResourceStop', function(res) - if res == 'GES-Temperature' then gesTemperatureAvailable = false end -end) + return 0 +end + +local function openTentStash(stashId) + if not stashId then return end + + if Inventory == 'ox' then + exports.ox_inventory:openInventory('stash', stashId) + elseif Inventory == 'qb' or Inventory == 'qs' then + local other = { + maxweight = TENT_STASH_WEIGHT, + slots = TENT_STASH_SLOTS, + } + TriggerServerEvent('inventory:server:OpenInventory', 'stash', stashId, other) + TriggerEvent('inventory:client:SetCurrentStash', stashId) + end +end + +-- Skill system variables +local cookingSkill = { + level = 1, + xp = 0, + nextLevelXP = 100 +} + +-- Recipe discovery variables +local discoveredRecipes = {} + +-- Framework initialization +local Framework = Config.Framework or 'standalone' + +if Framework == 'esx' then + ESX = exports['es_extended']:getSharedObject() + if not ESX then + print("^1[ERROR] ESX not found. Falling back to standalone.^7") + Framework = 'standalone' + end +elseif Framework == 'qbox' or Framework == 'qb-core' then + QBCore = exports['qb-core']:GetCoreObject() + if not QBCore then + print("^1[ERROR] QBCore/QBox not found. Falling back to standalone.^7") + Framework = 'standalone' + end +end + +-- Animation loading helper function +local function LoadAnimDict(dict) + if not HasAnimDictLoaded(dict) then + RequestAnimDict(dict) + local timeout = 500 + while not HasAnimDictLoaded(dict) and timeout > 0 do + timeout = timeout - 1 + Wait(10) + end + end + return HasAnimDictLoaded(dict) +end + +-- Get current season based on in-game date +function GetCurrentSeason() + local month = GetClockMonth() + 1 -- GetClockMonth is 0-based + + if (month >= 3 and month <= 5) then + return "spring" + elseif (month >= 6 and month <= 8) then + return "summer" + elseif (month >= 9 and month <= 11) then + return "fall" + else + return "winter" + end +end + +-- Optimized object caching +local cachedModels = {} + +function GetCachedModel(model) + if not cachedModels[model] then + if not IsModelInCdimage(model) then return nil end + + RequestModel(model) + local timeout = 500 + while not HasModelLoaded(model) and timeout > 0 do + timeout = timeout - 1 + Wait(10) + end + + if HasModelLoaded(model) then + cachedModels[model] = true + else + return nil + end + end + return model +end --- Determine if the optional GES-Temperature resource is available. This --- prevents calling exports from a resource that isn't running. -local function isGESTemperatureAvailable() - return gesTemperatureAvailable +-- Cleanup function to remove unused models from cache +function CleanupModelCache() + for model in pairs(cachedModels) do + if not IsModelInCdimage(model) then + SetModelAsNoLongerNeeded(model) + cachedModels[model] = nil + end + end end - --- Skill system variables -local cookingSkill = { - level = 1, - xp = 0, - nextLevelXP = 100 -} - --- Recipe discovery variables -local discoveredRecipes = {} - --- Framework initialization -local Framework = Config.Framework or 'standalone' - -if Framework == 'esx' then - ESX = exports['es_extended']:getSharedObject() - if not ESX then - Framework = 'standalone' - end -elseif Framework == 'qbox' or Framework == 'qb-core' then - QBCore = exports['qb-core']:GetCoreObject() - if not QBCore then - Framework = 'standalone' - end -end - --- Animation loading helper function -local function LoadAnimDict(dict) - if not HasAnimDictLoaded(dict) then - RequestAnimDict(dict) - local timeout = 500 - while not HasAnimDictLoaded(dict) and timeout > 0 do - timeout = timeout - 1 - Wait(10) - end - end - return HasAnimDictLoaded(dict) -end - --- Get current season based on in-game date -function GetCurrentSeason() - local month = GetClockMonth() + 1 -- GetClockMonth is 0-based - - if (month >= 3 and month <= 5) then - return "spring" - elseif (month >= 6 and month <= 8) then - return "summer" - elseif (month >= 9 and month <= 11) then - return "fall" - else - return "winter" - end -end - --- Optimized object caching -local cachedModels = {} - -function GetCachedModel(model) - if not cachedModels[model] then - if not IsModelInCdimage(model) then return nil end - - RequestModel(model) - local timeout = 500 - while not HasModelLoaded(model) and timeout > 0 do - timeout = timeout - 1 - Wait(10) - end - - if HasModelLoaded(model) then - cachedModels[model] = true - else - return nil - end - end - return model -end - --- Cleanup function to remove unused models from cache -function CleanupModelCache() - for model in pairs(cachedModels) do - if not IsModelInCdimage(model) then - SetModelAsNoLongerNeeded(model) - cachedModels[model] = nil - end - end -end - -local function updateInventoryCache() - if Inventory ~= 'ox' then return end - cachedInventory = {} - local items = exports.ox_inventory:GetPlayerItems() - for _, item in ipairs(items) do - cachedInventory[item.name] = item.count - end -end - -if Inventory == 'ox' then - AddEventHandler('ox_inventory:updateInventory', function() - updateInventoryCache() - if FuelSystem.isUIOpen then - FuelSystem:refreshUI() - end - end) - - CreateThread(function() - updateInventoryCache() - end) -end - --- Refresh UI with current fuel level and inventory (if UI is open) -function FuelSystem:refreshUI() - if self.isUIOpen then - SendNUIMessage({ - action = 'updateFuel', - fuelLevel = self.fuelLevel, - inventory = cachedInventory - }) - end -end - --- Consume a given amount of fuel. Returns true if successful. -function FuelSystem:consume(amount) - if self.fuelLevel <= 0 then - lib.notify({ title = 'Campfire', description = 'The campfire is out of fuel.', type = 'error' }) - return false - end - self.fuelLevel = math.max(self.fuelLevel - amount, 0) - self:refreshUI() - return true -end - --- Add fuel using an item. Validates input, plays an animation, updates UI and notifies the server. -function FuelSystem:addFuel(itemtype, inputAmount, duration) - local itemRanges = { - garbage = { min = 1, max = 20, label = "Garbage" }, - firewood = { min = 1, max = 10, label = "Firewood" }, - coal = { min = 1, max = 5, label = "Coal" } - } - local range = itemRanges[itemtype] - if not range then - lib.notify({ title = 'Fuel', description = 'Invalid fuel type: ' .. tostring(itemtype), type = 'error' }) - return false - end - local availableCount = exports.ox_inventory:Search('count', itemtype) + +-- Refresh UI with current fuel level and inventory (if UI is open) +function FuelSystem:refreshUI() + if self.isUIOpen then + local inventory = getInventorySnapshot() + SendNUIMessage({ + action = 'updateFuel', + fuelLevel = self.fuelLevel, + inventory = inventory + }) + end +end + +-- Consume a given amount of fuel. Returns true if successful. +function FuelSystem:consume(amount) + if self.fuelLevel <= 0 then + lib.notify({ title = 'Campfire', description = 'The campfire is out of fuel.', type = 'error' }) + return false + end + self.fuelLevel = math.max(self.fuelLevel - amount, 0) + self:refreshUI() + return true +end + +-- Add fuel using an item. Validates input, plays an animation, updates UI and notifies the server. +function FuelSystem:addFuel(itemtype, inputAmount, duration) + local itemRanges = { + garbage = { min = 1, max = 20, label = "Garbage" }, + firewood = { min = 1, max = 10, label = "Firewood" }, + coal = { min = 1, max = 5, label = "Coal" } + } + local range = itemRanges[itemtype] + if not range then + lib.notify({ title = 'Fuel', description = 'Invalid fuel type: ' .. tostring(itemtype), type = 'error' }) + return false + end + local availableCount = getInventoryItemCount(itemtype) if availableCount < inputAmount then lib.notify({ title = 'Fuel', description = 'Not enough ' .. range.label .. '.', type = 'error' }) return false end if not inputAmount or tonumber(inputAmount) < range.min then + lib.notify({ title = 'Fuel', description = 'Invalid amount provided for ' .. range.label .. '.', type = 'error' }) + return false + end + local totalDuration = duration * inputAmount + local fuelAddition = (totalDuration / self.maxFuelLevel) * 100 + self.fuelLevel = math.min(self.fuelLevel + fuelAddition, self.maxFuelLevel) + + -- (Optional) Enhance immersion with a fueling animation. + self:refreshUI() + + lib.notify({ title = 'Campfire', description = 'Fuel added successfully.', type = 'success' }) + TriggerServerEvent('camping:RI', itemtype, inputAmount) + return true +end + +-- Expose FuelSystem for debugging if needed +_G.FuelSystem = FuelSystem + +-- Heat zone system +local activeHeatZones = {} + +-- Create heat zone around a prop (campfire) +function createHeatZoneAroundProp(coords, zoneId) + if Config.useGESTemperature then + exports['GES-Temperature']:createHeatZone(coords, zoneId) + + -- Store the heat zone in our tracking table for reference + if not activeFires[zoneId] then + activeFires[zoneId] = { + coords = coords, + active = true + } + end + + if Config.Debug then + print("^2[DEBUG] Created heat zone: " .. zoneId .. " at coords: " .. coords.x .. ", " .. coords.y .. ", " .. coords.z .. "^7") + end + end +end + +-- Delete heat zone +function deleteHeatZone(zoneId) + if Config.useGESTemperature then + exports['GES-Temperature']:deleteHeatZone(zoneId) + + -- Remove from our tracking table + if activeFires[zoneId] then + activeFires[zoneId] = nil + end + + if Config.Debug then + print("^2[DEBUG] Deleted heat zone: " .. zoneId .. "^7") + end + end +end + +-- Initialize data and cleanup thread +Citizen.CreateThread(function() + TriggerServerEvent('camping:LoadData') + + -- Periodically clean up model cache + while true do + Wait(60000) -- Clean up every minute + CleanupModelCache() + end +end) + +-- Event handlers for tent state +RegisterNetEvent('camping:updateTentState', function(tentID, isOccupied) + activeTents[tentID] = isOccupied or nil +end) + +-- Load camping data from server +RegisterNetEvent('camping:loadCampingData', function(data) + if data.type == 'tent' then + Renewed.addObject({ + id = data.stashID, + coords = vec3(data.x, data.y, data.z - 1.5), + object = data.model, + dist = 75, + heading = data.heading, + canClimb = false, + freeze = true, + snapGround = false, + target = { + { + label = "Go inside", + icon = 'fa-solid fa-tent-arrows-down', + iconColor = 'grey', + distance = Config.targetDistance, + canInteract = function() + return not enteredTent + end, + onSelect = function(info) + UseTent(info.entity, data.stashID) + end + }, + { + label = "Open tent storage", + icon = 'fa-solid fa-box-open', + iconColor = 'grey', + distance = Config.targetDistance, + onSelect = function() + openTentStash(data.stashID) + end + }, + { + label = "Pickup tent", + icon = 'fa-solid fa-hand', + iconColor = 'grey', + distance = Config.targetDistance, + canInteract = function() + return not enteredTent + end, + onSelect = function() + deleteTent(data.stashID) + end + }, + } + }) + TriggerServerEvent('camping:createTentStash', data.stashID) + else + Renewed.addObject({ + id = data.stashID, + coords = vec3(data.x, data.y, data.z - 0.7), + object = data.model, + dist = 75, + heading = data.heading, + canClimb = false, + freeze = true, + snapGround = false, + target = { + { + label = "Use Campfire", + icon = 'fas fa-fire', + iconColor = 'orange', + distance = Config.targetDistance, + onSelect = function() + OpenCampfireMenu(data.stashID) + end + }, + { + label = "Put Out Campfire", + icon = 'fas fa-hand', + iconColor = 'grey', + distance = Config.targetDistance, + onSelect = function() + deleteCampfire(data.stashID) + end + }, + } + }) + end +end) + + +function getCurrentWeather() + if Config.weatherResource == 'renewed-weathersync' then + return GlobalState.weather and GlobalState.weather.weather or "clear" + else + if Config.useGESTemperature then + local weatherData = exports['GES-Temperature']:getTemperatureData() + if weatherData and weatherData.weather then + return weatherData.weather + end + else + return "clear" + end + end +end + +-- Add a thread to update the current weather periodically +Citizen.CreateThread(function() + while true do + -- Update current weather + currentWeather = getCurrentWeather() + Wait(60000) -- Update every minute + end +end) + +-- Generate unique IDs +function generateRandomTentStashId() + return "tent_" .. math.random(100000, 999999) +end + +function generateRandomCampfireId() + return "campfire_" .. math.random(100000, 999999) +end + +-- Tent spawning event handler +RegisterNetEvent('camping:client:spawnTent', function(x, y, z, h, randomModel, stashId) + Renewed.addObject({ + id = stashId, + coords = vec3(x, y, z-1.5), + object = randomModel, + dist = 75, + heading = h, + canClimb = false, + freeze = true, + snapGround = false, + target = { + { + label = "Go inside", + icon = 'fa-solid fa-tent-arrows-down', + iconColor = 'grey', + distance = Config.targetDistance, + canInteract = function() + return not enteredTent + end, + onSelect = function(info) + UseTent(info.entity, stashId) + end + }, + { + label = "Open tent storage", + icon = 'fa-solid fa-box-open', + iconColor = 'grey', + distance = Config.targetDistance, + onSelect = function() + openTentStash(stashId) + end + }, + { + label = "Pickup tent", + icon = 'fa-solid fa-hand', + iconColor = 'grey', + distance = Config.targetDistance, + canInteract = function() + return not enteredTent + end, + onSelect = function() + deleteTent(stashId) + end + }, + } + }) +end) + +-- Campfire spawning event handler +RegisterNetEvent('camping:client:spawnCampfire', function(x, y, z, h, fireModel, campfireID) + Renewed.addObject({ + id = campfireID, + coords = vec3(x, y, z-0.7), + object = fireModel, + dist = 75, + heading = h, + canClimb = false, + freeze = true, + snapGround = false, + target = { + { + label = "Use Campfire", + icon = 'fas fa-fire', + iconColor = 'orange', + distance = Config.targetDistance, + onSelect = function() + OpenCampfireMenu(campfireID) + end + }, + { + label = "Put Out Campfire", + icon = 'fas fa-hand', + iconColor = 'grey', + distance = Config.targetDistance, + onSelect = function() + deleteCampfire(campfireID) + end + }, + } + }) +end) + +-- Item usage handler +local function handleInventoryItemUse(name, slotId, metadata) + if name == Config.tentItem then + closeInventoryUI() + + local tentCoords, tentHeading = Renewed.placeObject(TentModel, 25, false, { + '-- Place Object -- \n', + '[E] Place \n', + '[X] Cancel \n', + '[SCROLL UP] Change Heading \n', + '[SCROLL DOWN] Change Heading' + }, nil, vec3(0.0, 0.0, 0.65)) + + if tentCoords and tentHeading then + local stashId = metadata and metadata.stashID or generateRandomTentStashId() + TriggerServerEvent('camping:server:spawnTent', tentCoords.x, tentCoords.y, tentCoords.z, tentHeading, TentModel, stashId, slotId) + TriggerServerEvent('camping:saveCampingData', 'tent', TentModel, tentCoords.x, tentCoords.y, tentCoords.z, stashId, tentHeading) + lib.notify({ title = 'Tent', description = 'Tent placed successfully.', type = 'success' }) + end + elseif name == Config.campfireItem then + closeInventoryUI() + + local fireCoords, fireHeading = Renewed.placeObject(CampfireModel, 25, false, { + '-- Place Object -- \n', + '[E] Place \n', + '[X] Cancel \n', + '[SCROLL UP] Change Heading \n', + '[SCROLL DOWN] Change Heading' + }, nil, vec3(0.0, 0.0, 0.1)) + + if fireCoords and fireHeading then + local campfireID = generateRandomCampfireId() + TriggerServerEvent('camping:server:spawnCampfire', fireCoords.x, fireCoords.y, fireCoords.z, fireHeading, CampfireModel, campfireID, slotId) + TriggerServerEvent('camping:saveCampingData', 'campfire', CampfireModel, fireCoords.x, fireCoords.y, fireCoords.z, campfireID, fireHeading) + createHeatZoneAroundProp(fireCoords, campfireID) + lib.notify({ title = 'Campfire', description = 'Campfire placed successfully.', type = 'success' }) + end + end +end + +AddEventHandler('ox_inventory:usedItem', function(name, slotId, metadata) + handleInventoryItemUse(name, slotId, metadata) +end) + +RegisterNetEvent('camping:client:qsUsedItem', function(itemData) + if Inventory ~= 'qs' or not itemData then return end + handleInventoryItemUse(itemData.name, itemData.slot, itemData.metadata) +end) + +-- Tent usage function +function UseTent(entity, tentID) + local playerPed = cache.ped + local PedCoord = GetEntityCoords(playerPed) + if not DoesEntityExist(entity) then + lib.notify({title = 'Tent', description = 'No tent found or tent is invalid!', type = 'error'}) + return + end + if activeTents[tentID] then + lib.notify({ title = 'Tent', description = 'Someone is already inside this tent.', type = 'error' }) + return + end + if not LoadAnimDict("amb@medic@standing@kneel@base") then + lib.notify({title = 'Tent', description = 'Failed to load animation.', type = 'error'}) + return + end + TaskTurnPedToFaceEntity(cache.ped, entity, 1000) + Wait(1000) + TaskPlayAnim(PlayerPedId(), "amb@medic@standing@kneel@base", "base", 8.0, -8.0, -1, 1, 0, false, false, false) + Wait(1000) + local tentCoords = GetEntityCoords(entity) + SetEntityCoordsNoOffset(playerPed, tentCoords.x, tentCoords.y, tentCoords.z, true, true, true) + SetEntityHeading(cache.ped, (GetEntityHeading(entity)+45.0)) + local dict = Config.TentAnimDict or "amb@world_human_sunbathe@male@back@base" + local anim = Config.TentAnimName or "base" + if not LoadAnimDict(dict) then + lib.notify({title = 'Tent', description = 'Failed to load animation.', type = 'error'}) + return + end + if IsEntityPlayingAnim(playerPed, dict, anim, 3) then + lib.notify({title = 'Tent', description = 'You are already resting.', type = 'info'}) + return + end + TaskPlayAnim(playerPed, dict, anim, 8.0, -8.0, -1, 1, 0, false, false, false) + activeTents[tentID] = true + TriggerServerEvent('camping:tentEntered', tentID) + enteredTent = true + lib.showTextUI('[E] to leave the tent') + CreateThread(function() + while enteredTent do + Wait(0) + if IsControlJustReleased(0, 38) then + enteredTent = false + ClearPedTasksImmediately(playerPed) + SetEntityCoords(playerPed, PedCoord.x, PedCoord.y, PedCoord.z -1, true, false, false, false) + if activeTents[tentID] then + activeTents[tentID] = nil + TriggerServerEvent('camping:tentExited', tentID) + lib.notify({ title = 'Tent', description = 'You have exited the tent.', type = 'info' }) + else + lib.notify({ title = 'Tent', description = 'You are not inside this tent.', type = 'error' }) + end + lib.hideTextUI() + end + end + end) +end + +-- Delete tent function +function deleteTent(tentId) + if not tentId then lib.notify({ - title = 'Fuel', - description = 'Invalid amount provided for ' .. range.label .. '.', + title = 'Error', + description = 'No previous tent spawned, or your previous tent has already been deleted.', type = 'error' }) - return false + else + TriggerServerEvent('camping:deleteCampingData', 'tent', tentId) + lib.notify({ + title = 'Tent', + description = 'Tent deleted successfully.', + type = 'success' + }) + TriggerServerEvent('camping:AI', 'tent', 1, {stashID = tentId}) + TriggerServerEvent('camping:server:removeTentItem', tentId) end - if inputAmount > range.max then +end + +-- Delete campfire function +function deleteCampfire(fireId) + if not fireId then lib.notify({ - title = 'Fuel', - description = 'Cannot add more than ' .. range.max .. ' ' .. range.label .. '.', + title = 'Error', + description = 'No previous camping spawned, or your previous campfire has already been deleted.', type = 'error' }) - return false + else + TriggerServerEvent('camping:deleteCampingData', 'campfire', fireId) + lib.notify({ + title = 'Campfire', + description = 'Campfire deleted successfully.', + type = 'success' + }) + TriggerServerEvent('camping:AI', 'campfire', 1) + TriggerServerEvent('camping:server:removeFireItem', fireId) + deleteHeatZone(fireId) end - local totalDuration = duration * inputAmount - local fuelAddition = (totalDuration / self.maxFuelLevel) * 100 - self.fuelLevel = math.min(self.fuelLevel + fuelAddition, self.maxFuelLevel) - - -- (Optional) Enhance immersion with a fueling animation. - self:refreshUI() - - lib.notify({ title = 'Campfire', description = 'Fuel added successfully.', type = 'success' }) - TriggerServerEvent('camping:RI', itemtype, inputAmount) - return true -end - --- Expose FuelSystem for debugging if needed -_G.FuelSystem = FuelSystem - --- Heat zone system -local activeHeatZones = {} - --- Create heat zone around a prop (campfire) -function createHeatZoneAroundProp(coords, zoneId) - if isGESTemperatureAvailable() then - exports['GES-Temperature']:createHeatZone(coords, zoneId) - - -- Store the heat zone in our tracking table for reference - if not activeFires[zoneId] then - activeFires[zoneId] = { - coords = coords, - active = true - } - end - - if Config.Debug then - print("^2[DEBUG] Created heat zone: " .. zoneId .. " at coords: " .. coords.x .. ", " .. coords.y .. ", " .. coords.z .. "^7") - end - end -end - --- Delete heat zone -function deleteHeatZone(zoneId) - if isGESTemperatureAvailable() then - exports['GES-Temperature']:deleteHeatZone(zoneId) - - -- Remove from our tracking table - if activeFires[zoneId] then - activeFires[zoneId] = nil - end - - if Config.Debug then - print("^2[DEBUG] Deleted heat zone: " .. zoneId .. "^7") - end - end -end - --- Initialize data and cleanup thread -Citizen.CreateThread(function() - TriggerServerEvent('camping:LoadData') - - -- Periodically clean up model cache - while true do - Wait(60000) -- Clean up every minute - CleanupModelCache() - end -end) - --- Event handlers for tent state -RegisterNetEvent('camping:updateTentState', function(tentID, isOccupied) - activeTents[tentID] = isOccupied or nil -end) - --- Load camping data from server -RegisterNetEvent('camping:loadCampingData', function(data) - if data.type == 'tent' then - Renewed.addObject({ - id = data.stashID, - coords = vec3(data.x, data.y, data.z - 1.5), - object = data.model, - dist = 75, - heading = data.heading, - canClimb = false, - freeze = true, - snapGround = false, - target = { - { - label = "Go inside", - icon = 'fa-solid fa-tent-arrows-down', - iconColor = 'grey', - distance = Config.targetDistance, - canInteract = function() - return not enteredTent - end, - onSelect = function(info) - UseTent(info.entity, data.stashID) - end - }, - { - label = "Open tent storage", - icon = 'fa-solid fa-box-open', - iconColor = 'grey', - distance = Config.targetDistance, - onSelect = function() - exports.ox_inventory:openInventory('stash', data.stashID) - end - }, - { - label = "Pickup tent", - icon = 'fa-solid fa-hand', - iconColor = 'grey', - distance = Config.targetDistance, - canInteract = function() - return not enteredTent - end, - onSelect = function() - deleteTent(data.stashID) - end - }, - } - }) - TriggerServerEvent('camping:createTentStash', data.stashID) - else - Renewed.addObject({ - id = data.stashID, - coords = vec3(data.x, data.y, data.z - 0.7), - object = data.model, - dist = 75, - heading = data.heading, - canClimb = false, - freeze = true, - snapGround = false, - target = { - { - label = "Use Campfire", - icon = 'fas fa-fire', - iconColor = 'orange', - distance = Config.targetDistance, - onSelect = function() - OpenCampfireMenu(data.stashID) - end - }, - { - label = "Put Out Campfire", - icon = 'fas fa-hand', - iconColor = 'grey', - distance = Config.targetDistance, - onSelect = function() - deleteCampfire(data.stashID) - end - }, - } - }) - end -end) - - -function getCurrentWeather() - if Config.weatherResource == 'renewed-weathersync' then - return GlobalState.weather and GlobalState.weather.weather or "clear" - else - if isGESTemperatureAvailable() then - local weatherData = exports['GES-Temperature']:getTemperatureData() - if weatherData and weatherData.weather then - return weatherData.weather - end - else - return "clear" - end - end -end - --- Add a thread to update the current weather periodically -Citizen.CreateThread(function() - while true do - -- Update current weather - currentWeather = getCurrentWeather() - Wait(60000) -- Update every minute - end -end) - --- Generate unique IDs -function generateRandomTentStashId() - return "tent_" .. math.random(100000, 999999) -end - -function generateRandomCampfireId() - return "campfire_" .. math.random(100000, 999999) -end - --- Tent spawning event handler -RegisterNetEvent('camping:client:spawnTent', function(x, y, z, h, randomModel, stashId) - Renewed.addObject({ - id = stashId, - coords = vec3(x, y, z-1.5), - object = randomModel, - dist = 75, - heading = h, - canClimb = false, - freeze = true, - snapGround = false, - target = { - { - label = "Go inside", - icon = 'fa-solid fa-tent-arrows-down', - iconColor = 'grey', - distance = Config.targetDistance, - canInteract = function() - return not enteredTent - end, - onSelect = function(info) - UseTent(info.entity, stashId) - end - }, - { - label = "Open tent storage", - icon = 'fa-solid fa-box-open', - iconColor = 'grey', - distance = Config.targetDistance, - onSelect = function() - exports.ox_inventory:openInventory('stash', stashId) - end - }, - { - label = "Pickup tent", - icon = 'fa-solid fa-hand', - iconColor = 'grey', - distance = Config.targetDistance, - canInteract = function() - return not enteredTent - end, - onSelect = function() - deleteTent(stashId) - end - }, - } - }) -end) - --- Campfire spawning event handler -RegisterNetEvent('camping:client:spawnCampfire', function(x, y, z, h, fireModel, campfireID) - Renewed.addObject({ - id = campfireID, - coords = vec3(x, y, z-0.7), - object = fireModel, - dist = 75, - heading = h, - canClimb = false, - freeze = true, - snapGround = false, - target = { - { - label = "Use Campfire", - icon = 'fas fa-fire', - iconColor = 'orange', - distance = Config.targetDistance, - onSelect = function() - OpenCampfireMenu(campfireID) - end - }, - { - label = "Put Out Campfire", - icon = 'fas fa-hand', - iconColor = 'grey', - distance = Config.targetDistance, - onSelect = function() - deleteCampfire(campfireID) - end - }, - } - }) -end) - --- Item usage handler -AddEventHandler('ox_inventory:usedItem', function(name, slotId, metadata) - if name == Config.tentItem then - if Inventory == 'ox' then - TriggerEvent('ox_inventory:closeInventory') - elseif Inventory == 'qb' then - TriggerEvent('inventory:client:closeInventory') - end - local tentCoords, tentHeading = Renewed.placeObject(TentModel, 25, false, { - '-- Place Object -- \n', - '[E] Place \n', - '[X] Cancel \n', - '[SCROLL UP] Change Heading \n', - '[SCROLL DOWN] Change Heading' - }, nil, vec3(0.0, 0.0, 0.65)) - - if tentCoords and tentHeading then - local stashId = metadata and metadata.stashID or generateRandomTentStashId() - TriggerServerEvent('camping:server:spawnTent', tentCoords.x, tentCoords.y, tentCoords.z, tentHeading, TentModel, stashId, slotId) - TriggerServerEvent('camping:saveCampingData', 'tent', TentModel, tentCoords.x, tentCoords.y, tentCoords.z, stashId, tentHeading) - lib.notify({ title = 'Tent', description = 'Tent placed successfully.', type = 'success' }) - end - elseif name == Config.campfireItem then - if Inventory == 'ox' then - TriggerEvent('ox_inventory:closeInventory') - elseif Inventory == 'qb' then - TriggerEvent('inventory:client:closeInventory') - end - local fireCoords, fireHeading = Renewed.placeObject(CampfireModel, 25, false, { - '-- Place Object -- \n', - '[E] Place \n', - '[X] Cancel \n', - '[SCROLL UP] Change Heading \n', - '[SCROLL DOWN] Change Heading' - }, nil, vec3(0.0, 0.0, 0.1)) - - if fireCoords and fireHeading then - local campfireID = generateRandomCampfireId() - TriggerServerEvent('camping:server:spawnCampfire', fireCoords.x, fireCoords.y, fireCoords.z, fireHeading, CampfireModel, campfireID, slotId) - TriggerServerEvent('camping:saveCampingData', 'campfire', CampfireModel, fireCoords.x, fireCoords.y, fireCoords.z, campfireID, fireHeading) - createHeatZoneAroundProp(fireCoords, campfireID) - lib.notify({ title = 'Campfire', description = 'Campfire placed successfully.', type = 'success' }) - end - end -end) - --- Tent usage function -function UseTent(entity, tentID) - local playerPed = cache.ped - local PedCoord = GetEntityCoords(playerPed) - if not DoesEntityExist(entity) then - lib.notify({title = 'Tent', description = 'No tent found or tent is invalid!', type = 'error'}) - return - end - if activeTents[tentID] then - lib.notify({ title = 'Tent', description = 'Someone is already inside this tent.', type = 'error' }) - return - end - if not LoadAnimDict("amb@medic@standing@kneel@base") then - lib.notify({title = 'Tent', description = 'Failed to load animation.', type = 'error'}) - return - end - TaskTurnPedToFaceEntity(cache.ped, entity, 1000) - Wait(1000) - TaskPlayAnim(PlayerPedId(), "amb@medic@standing@kneel@base", "base", 8.0, -8.0, -1, 1, 0, false, false, false) - Wait(1000) - local tentCoords = GetEntityCoords(entity) - SetEntityCoordsNoOffset(playerPed, tentCoords.x, tentCoords.y, tentCoords.z, true, true, true) - SetEntityHeading(cache.ped, (GetEntityHeading(entity)+45.0)) - local dict = Config.TentAnimDict or "amb@world_human_sunbathe@male@back@base" - local anim = Config.TentAnimName or "base" - if not LoadAnimDict(dict) then - lib.notify({title = 'Tent', description = 'Failed to load animation.', type = 'error'}) - return - end - if IsEntityPlayingAnim(playerPed, dict, anim, 3) then - lib.notify({title = 'Tent', description = 'You are already resting.', type = 'info'}) - return - end - TaskPlayAnim(playerPed, dict, anim, 8.0, -8.0, -1, 1, 0, false, false, false) - activeTents[tentID] = true - TriggerServerEvent('camping:tentEntered', tentID) - enteredTent = true - lib.showTextUI('[E] to leave the tent') - CreateThread(function() - while enteredTent do - Wait(0) - if IsControlJustReleased(0, 38) then - enteredTent = false - ClearPedTasksImmediately(playerPed) - SetEntityCoords(playerPed, PedCoord.x, PedCoord.y, PedCoord.z -1, true, false, false, false) - if activeTents[tentID] then - activeTents[tentID] = nil - TriggerServerEvent('camping:tentExited', tentID) - lib.notify({ title = 'Tent', description = 'You have exited the tent.', type = 'info' }) - else - lib.notify({ title = 'Tent', description = 'You are not inside this tent.', type = 'error' }) - end - lib.hideTextUI() - end - end - end) -end - --- Delete tent function -function deleteTent(tentId) - if not tentId then - lib.notify({ - title = 'Error', - description = 'No previous tent spawned, or your previous tent has already been deleted.', - type = 'error' - }) - else - TriggerServerEvent('camping:deleteCampingData', 'tent', tentId) - lib.notify({ - title = 'Tent', - description = 'Tent deleted successfully.', - type = 'success' - }) - TriggerServerEvent('camping:AI', 'tent', 1, {stashID = tentId}) - TriggerServerEvent('camping:server:removeTentItem', tentId) - end -end - --- Delete campfire function -function deleteCampfire(fireId) - if not fireId then - lib.notify({ - title = 'Error', - description = 'No previous camping spawned, or your previous campfire has already been deleted.', - type = 'error' - }) - else - TriggerServerEvent('camping:deleteCampingData', 'campfire', fireId) - lib.notify({ - title = 'Campfire', - description = 'Campfire deleted successfully.', - type = 'success' - }) - TriggerServerEvent('camping:AI', 'campfire', 1) - TriggerServerEvent('camping:server:removeFireItem', fireId) - deleteHeatZone(fireId) - end -end - --- Remove tent/fire item events -RegisterNetEvent('camping:client:removeTentItem', function(tentID) - Renewed.removeObject(tentID) -end) - -RegisterNetEvent('camping:client:removeFireItem', function(fireId) - Renewed.removeObject(fireId) -end) - --- Fuel system -RegisterNetEvent('camping:syncFuel') -AddEventHandler('camping:syncFuel', function(fuelUsed) - FuelSystem:consume(fuelUsed) -end) -RegisterNUICallback('addFuel', function(data, cb) - local success = FuelSystem:addFuel(data.type, tonumber(data.amount), tonumber(data.duration)) - cb({ success = success }) -end) - -function OpenCookingMenu(campfireID) - if FuelSystem.isUIOpen then - return - end - - local inventory = {} - - if Inventory == 'ox' then - for name, count in pairs(cachedInventory) do - inventory[name] = count - end - elseif Inventory == 'qb' then - local PlayerData = QBCore.Functions.GetPlayerData() - for _, item in pairs(PlayerData.items) do - if item and item.name then - inventory[item.name] = item.amount - end - end - end - - local availableRecipes = GetAvailableRecipes() - local recipes = {} - for recipeName, recipeData in pairs(availableRecipes) do - local label = recipeData.label or recipeName:gsub("_", " "):gsub("^%l", string.upper) - local description = recipeData.description or "A delicious recipe" - table.insert(recipes, { - id = recipeName, - label = label, - description = description, - cookTime = recipeData.time * 1000, -- seconds to milliseconds - category = recipeData.category or "other", - ingredients = {}, - seasonal = recipeData.seasonal or false, - hidden = recipeData.hidden or false - }) - if Config.SkillSystem.Enabled and cookingSkill.level > 1 then - local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] - if benefit and benefit.cookTimeReduction > 0 then - local reduction = (benefit.cookTimeReduction / 100) - recipes[#recipes].cookTime = recipes[#recipes].cookTime * (1 - reduction) - end - end - if type(recipeData.ingredients) == "string" then - local amount = recipeData.amount or 1 - if Config.SkillSystem.Enabled and cookingSkill.level > 1 then - local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] - if benefit and benefit.ingredientReduction > 0 then - local saveChance = benefit.ingredientReduction / 100 - if saveChance > 0 and amount > 1 then - if math.random() < saveChance then - amount = amount - 1 - end - end - end - end - table.insert(recipes[#recipes].ingredients, { name = recipeData.ingredients, count = amount }) - elseif type(recipeData.ingredients) == "table" then - for i, ingredient in ipairs(recipeData.ingredients) do - local amount = 1 - if type(recipeData.amount) == "table" then - amount = recipeData.amount[i] or 1 - end - if Config.SkillSystem.Enabled and cookingSkill.level > 1 then - local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] - if benefit and benefit.ingredientReduction > 0 then - local saveChance = benefit.ingredientReduction / 100 - if saveChance > 0 and amount > 1 then - if math.random() < saveChance then - amount = amount - 1 - end - end - end - end - table.insert(recipes[#recipes].ingredients, { name = ingredient, count = amount }) - end - end - end - - FuelSystem.isUIOpen = true - SetNuiFocus(true, true) - - SendNUIMessage({ - action = 'openCookingMenu', - recipes = recipes, - inventory = inventory, - fuelLevel = FuelSystem.fuelLevel, - skill = cookingSkill - }) -end - --- Register NUI callbacks -RegisterNUICallback('closeCookingMenu', function(data, cb) - SetNuiFocus(false, false) - FuelSystem.isUIOpen = false - cb({}) -end) - -RegisterNUICallback('cookRecipe', function(data, cb) - local recipe = data.recipe - - -- Close UI - SetNuiFocus(false, false) - FuelSystem.isUIOpen = false - - -- Trigger the cooking process - TriggerEvent('campfire_cooking', recipe) - - cb({}) -end) - --- Update the existing OpenCampfireMenu function to use the new React UI -function OpenCampfireMenu(campfireID) - OpenCookingMenu(campfireID) -end - --- Fuel menu callback -RegisterNUICallback('openFuelMenu', function(data, cb) - SetNuiFocus(false, false) - lib.registerContext({ - id = 'add_fuel_menu', - title = 'Add Fuel', - options = { - Config.FuelMenu[1], - Config.FuelMenu[2], - Config.FuelMenu[3], - { - title = "Cancel", - icon = "fas fa-times", - onSelect = function() - if FuelSystem.isUIOpen then - SetNuiFocus(true, true) - end - end - } - }, - onExit = function() - TriggerEvent('camping:restoreNUIFocus') - end - }) - lib.showContext('add_fuel_menu') - cb({}) -end) - --- Restore NUI focus event - -RegisterNetEvent('camping:restoreNUIFocus') -AddEventHandler('camping:restoreNUIFocus', function() - if FuelSystem.isUIOpen then - SetNuiFocus(true, true) - end -end) - --- Cooking handler -RegisterNetEvent('campfire_cooking', function(recipe) - local availableRecipes = GetAvailableRecipes() - local selectedRecipe = availableRecipes[recipe] - if not selectedRecipe then - lib.notify({ title = 'Campfire', description = 'Invalid recipe selected.', type = 'error' }) - return - end - - local requiredFuel = ((selectedRecipe.time / 100)) -- Convert seconds to fuel units - local weatherMultiplier = 1.0 - if isGESTemperatureAvailable() then - local weatherConfig = exports["GES-Temperature"]:GetWeatherConfig() - weatherMultiplier = weatherConfig.WeatherEffects.FuelConsumption[currentWeather] or 1.0 - end - requiredFuel = requiredFuel * weatherMultiplier - - if FuelSystem.fuelLevel < requiredFuel then - lib.notify({ title = 'Campfire', description = 'Not enough fuel to start cooking.', type = 'error' }) - return - end - - -- Check player inventory for ingredients - local hasAllIngredients = true - local missingIngredients = {} - local ingredientsToRemove = {} - - -- Handle both string and table ingredients - if type(selectedRecipe.ingredients) == "string" then - -- Single ingredient - local ingredient = selectedRecipe.ingredients - local amount = selectedRecipe.amount or 1 - - -- Apply ingredient reduction from cooking skill - if Config.SkillSystem.Enabled and cookingSkill.level > 1 then - local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] - if benefit and benefit.ingredientReduction > 0 then - -- Calculate chance to save an ingredient - local saveChance = benefit.ingredientReduction / 100 - if saveChance > 0 and amount > 1 then - -- Potentially reduce amount by 1 based on skill level - if math.random() < saveChance then - amount = amount - 1 - end - end - end - end - - local playerAmount = exports.ox_inventory:Search('count', ingredient) - - if playerAmount < amount then - hasAllIngredients = false - table.insert(missingIngredients, ingredient) - else - table.insert(ingredientsToRemove, {name = ingredient, count = amount}) - end - elseif type(selectedRecipe.ingredients) == "table" then - -- Multiple ingredients - for i, ingredient in ipairs(selectedRecipe.ingredients) do - local amount = 1 - if type(selectedRecipe.amount) == "table" then - amount = selectedRecipe.amount[i] or 1 - end - - -- Apply ingredient reduction from cooking skill - if Config.SkillSystem.Enabled and cookingSkill.level > 1 then - local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] - if benefit and benefit.ingredientReduction > 0 then - -- Calculate chance to save an ingredient - local saveChance = benefit.ingredientReduction / 100 - if saveChance > 0 and amount > 1 then - -- Potentially reduce amount by 1 based on skill level - if math.random() < saveChance then - amount = amount - 1 - end - end - end - end - - local playerAmount = exports.ox_inventory:Search('count', ingredient) - if playerAmount < amount then - hasAllIngredients = false - table.insert(missingIngredients, ingredient) - else - table.insert(ingredientsToRemove, {name = ingredient, count = amount}) - end - end - end - - if not hasAllIngredients then - local missingText = table.concat(missingIngredients, ", ") - lib.notify({ title = 'Campfire', description = 'Not enough ingredients. Missing: ' .. missingText, type = 'error' }) - return - end - - -- Remove ingredients - for _, item in ipairs(ingredientsToRemove) do - TriggerServerEvent('camping:RI', item.name, item.count) - end - local cookTime = selectedRecipe.time - if Config.SkillSystem.Enabled and cookingSkill.level > 1 then - local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] - if benefit and benefit.cookTimeReduction > 0 then - local reduction = (benefit.cookTimeReduction / 100) - cookTime = cookTime * (1 - reduction) - end - end - - local progress = lib.progressBar({ - duration = cookTime * 1000, - label = 'Cooking ' .. (selectedRecipe.label or recipe), - useWhileDead = false, - canCancel = false, - disable = { - move = true, - car = true, - combat = true, - mouse = false - }, - anim = { - dict = 'amb@prop_human_bbq@male@base', - clip = 'base', - flag = 49 - } - }) - - if progress then - FuelSystem:consume(requiredFuel) - TriggerServerEvent('camping:updateFuel', requiredFuel) - local qualityBonus = 0 - if Config.SkillSystem.Enabled and cookingSkill.level > 1 then - local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] - if benefit and benefit.qualityBonus > 0 then - qualityBonus = benefit.qualityBonus - end - end - - if Config.SkillSystem.Enabled then - TriggerServerEvent('camping:addCookingXP', Config.SkillSystem.XPPerCook) - end - - TriggerServerEvent('camping:AI', recipe, 1, {quality = 100 + qualityBonus}) - if Config.RecipeDiscovery.Enabled then - TriggerServerEvent('camping:checkRecipeDiscovery', ingredientsToRemove) - end - - lib.notify({ title = 'Campfire', description = (selectedRecipe.label or recipe) .. ' cooked successfully!', type = 'success' }) - end -end) - --- Get available recipes based on season and discovered recipes -function GetAvailableRecipes() - local allRecipes = {} - local season = GetCurrentSeason() - - -- Add standard recipes - for k, v in pairs(Config.Recipes) do - allRecipes[k] = v - end - - -- Add seasonal recipes if available - if Config.SeasonalRecipes[season] then - for k, v in pairs(Config.SeasonalRecipes[season]) do - allRecipes[k] = v - end - end - - -- Add holiday recipes if they're in season - if Config.SeasonalRecipes.holiday then - for k, v in pairs(Config.SeasonalRecipes.holiday) do - if IsHolidayRecipeAvailable(v) then - allRecipes[k] = v - end - end - end - - -- Add discovered hidden recipes - for k, v in pairs(discoveredRecipes) do - if Config.HiddenRecipes[k] then - allRecipes[k] = Config.HiddenRecipes[k] - end - end - - return allRecipes -end - --- Check if a holiday recipe is available -function IsHolidayRecipeAvailable(recipe) - if not recipe.availableFrom or not recipe.availableTo then - return true -- No date restrictions - end - - local day = GetClockDayOfMonth() - local month = GetClockMonth() + 1 -- GetClockMonth is 0-based - - local fromMonth = recipe.availableFrom.month - local fromDay = recipe.availableFrom.day - local toMonth = recipe.availableTo.month - local toDay = recipe.availableTo.day - - -- Simple check for same month - if month == fromMonth and month == toMonth then - return day >= fromDay and day <= toDay - -- Check for period spanning multiple months - elseif month == fromMonth then - return day >= fromDay - elseif month == toMonth then - return day <= toDay - elseif month > fromMonth and month < toMonth then - return true - end - - return false -end - --- Load cooking skill data from server -RegisterNetEvent('camping:loadCookingSkill') -AddEventHandler('camping:loadCookingSkill', function(skillData) - cookingSkill = skillData - - -- Update UI if it's open - if FuelSystem.isUIOpen then - SendNUIMessage({ - action = 'updateSkill', - skill = cookingSkill - }) - end -end) - --- Load discovered recipes from server -RegisterNetEvent('camping:loadDiscoveredRecipes') -AddEventHandler('camping:loadDiscoveredRecipes', function(recipes) - discoveredRecipes = recipes -end) - --- Request skill and recipe data when player spawns -AddEventHandler('playerSpawned', function() - TriggerServerEvent('camping:requestCookingSkill') - TriggerServerEvent('camping:requestDiscoveredRecipes') -end) - --- Add emergency UI close command -RegisterCommand('closecampingui', function() - if FuelSystem.isUIOpen then - FuelSystem.isUIOpen = false - SetNuiFocus(false, false) - SendNUIMessage({ - action = 'hide' - }) - lib.notify({ - title = 'UI', - description = 'Forced UI to close', - type = 'inform' - }) - end -end) - --- Register a keybind for it (F10 key) -RegisterKeyMapping('closecampingui', 'Force close camping UI', 'keyboard', 'F10') - - - - - +end + +-- Remove tent/fire item events +RegisterNetEvent('camping:client:removeTentItem', function(tentID) + Renewed.removeObject(tentID) +end) + +RegisterNetEvent('camping:client:removeFireItem', function(fireId) + Renewed.removeObject(fireId) +end) + +-- Fuel system +RegisterNetEvent('camping:syncFuel') +AddEventHandler('camping:syncFuel', function(fuelUsed) + FuelSystem:consume(fuelUsed) +end) +RegisterNUICallback('addFuel', function(data, cb) + local success = FuelSystem:addFuel(data.type, tonumber(data.amount), tonumber(data.duration)) + cb({ success = success }) +end) + +function OpenCookingMenu(campfireID) + if FuelSystem.isUIOpen then + print("UI is already open, not opening again") + return + end + + print("Opening cooking menu for campfire: " .. tostring(campfireID)) + + local inventory = getInventorySnapshot() + + local availableRecipes = GetAvailableRecipes() + local recipes = {} + for recipeName, recipeData in pairs(availableRecipes) do + local label = recipeData.label or recipeName:gsub("_", " "):gsub("^%l", string.upper) + local description = recipeData.description or "A delicious recipe" + table.insert(recipes, { + id = recipeName, + label = label, + description = description, + cookTime = recipeData.time * 1000, -- seconds to milliseconds + category = recipeData.category or "other", + ingredients = {}, + seasonal = recipeData.seasonal or false, + hidden = recipeData.hidden or false + }) + if Config.SkillSystem.Enabled and cookingSkill.level > 1 then + local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] + if benefit and benefit.cookTimeReduction > 0 then + local reduction = (benefit.cookTimeReduction / 100) + recipes[#recipes].cookTime = recipes[#recipes].cookTime * (1 - reduction) + end + end + if type(recipeData.ingredients) == "string" then + local amount = recipeData.amount or 1 + if Config.SkillSystem.Enabled and cookingSkill.level > 1 then + local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] + if benefit and benefit.ingredientReduction > 0 then + local saveChance = benefit.ingredientReduction / 100 + if saveChance > 0 and amount > 1 then + if math.random() < saveChance then + amount = amount - 1 + end + end + end + end + table.insert(recipes[#recipes].ingredients, { name = recipeData.ingredients, count = amount }) + elseif type(recipeData.ingredients) == "table" then + for i, ingredient in ipairs(recipeData.ingredients) do + local amount = 1 + if type(recipeData.amount) == "table" then + amount = recipeData.amount[i] or 1 + end + if Config.SkillSystem.Enabled and cookingSkill.level > 1 then + local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] + if benefit and benefit.ingredientReduction > 0 then + local saveChance = benefit.ingredientReduction / 100 + if saveChance > 0 and amount > 1 then + if math.random() < saveChance then + amount = amount - 1 + end + end + end + end + table.insert(recipes[#recipes].ingredients, { name = ingredient, count = amount }) + end + end + end + + FuelSystem.isUIOpen = true + SetNuiFocus(true, true) + + print("Sending data to NUI: " .. json.encode({ + action = 'openCookingMenu', + recipes = #recipes, + inventory = "inventory data", + fuelLevel = FuelSystem.fuelLevel, + skill = "skill data" + })) + + SendNUIMessage({ + action = 'openCookingMenu', + recipes = recipes, + inventory = inventory, + fuelLevel = FuelSystem.fuelLevel, + skill = cookingSkill + }) +end + +-- Register NUI callbacks +RegisterNUICallback('closeCookingMenu', function(data, cb) + SetNuiFocus(false, false) + FuelSystem.isUIOpen = false + cb({}) +end) + +RegisterNUICallback('cookRecipe', function(data, cb) + local recipe = data.recipe + + -- Close UI + SetNuiFocus(false, false) + FuelSystem.isUIOpen = false + + -- Trigger the cooking process + TriggerEvent('campfire_cooking', recipe) + + cb({}) +end) + +-- Update the existing OpenCampfireMenu function to use the new React UI +function OpenCampfireMenu(campfireID) + OpenCookingMenu(campfireID) +end + +-- Fuel menu callback +RegisterNUICallback('openFuelMenu', function(data, cb) + SetNuiFocus(false, false) + lib.registerContext({ + id = 'add_fuel_menu', + title = 'Add Fuel', + options = { + Config.FuelMenu[1], + Config.FuelMenu[2], + Config.FuelMenu[3], + { + title = "Cancel", + icon = "fas fa-times", + onSelect = function() + if FuelSystem.isUIOpen then + SetNuiFocus(true, true) + end + end + } + }, + onExit = function() + TriggerEvent('camping:restoreNUIFocus') + end + }) + lib.showContext('add_fuel_menu') + cb({}) +end) + +-- Restore NUI focus event + +RegisterNetEvent('camping:restoreNUIFocus') +AddEventHandler('camping:restoreNUIFocus', function() + if FuelSystem.isUIOpen then + SetNuiFocus(true, true) + end +end) + +-- Cooking handler +RegisterNetEvent('campfire_cooking', function(recipe) + local availableRecipes = GetAvailableRecipes() + local selectedRecipe = availableRecipes[recipe] + if not selectedRecipe then + lib.notify({ title = 'Campfire', description = 'Invalid recipe selected.', type = 'error' }) + return + end + + local requiredFuel = ((selectedRecipe.time / 100)) -- Convert seconds to fuel units + local weatherMultiplier = 1.0 + if Config.useGESTemperature then + local weatherConfig = exports["GES-Temperature"]:GetWeatherConfig() + weatherMultiplier = weatherConfig.WeatherEffects.FuelConsumption[currentWeather] or 1.0 + end + requiredFuel = requiredFuel * weatherMultiplier + + if FuelSystem.fuelLevel < requiredFuel then + lib.notify({ title = 'Campfire', description = 'Not enough fuel to start cooking.', type = 'error' }) + return + end + + -- Check player inventory for ingredients + local hasAllIngredients = true + local missingIngredients = {} + local ingredientsToRemove = {} + + -- Handle both string and table ingredients + if type(selectedRecipe.ingredients) == "string" then + -- Single ingredient + local ingredient = selectedRecipe.ingredients + local amount = selectedRecipe.amount or 1 + + -- Apply ingredient reduction from cooking skill + if Config.SkillSystem.Enabled and cookingSkill.level > 1 then + local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] + if benefit and benefit.ingredientReduction > 0 then + -- Calculate chance to save an ingredient + local saveChance = benefit.ingredientReduction / 100 + if saveChance > 0 and amount > 1 then + -- Potentially reduce amount by 1 based on skill level + if math.random() < saveChance then + amount = amount - 1 + end + end + end + end + + local playerAmount = getInventoryItemCount(ingredient) + + if playerAmount < amount then + hasAllIngredients = false + table.insert(missingIngredients, ingredient) + else + table.insert(ingredientsToRemove, {name = ingredient, count = amount}) + end + elseif type(selectedRecipe.ingredients) == "table" then + -- Multiple ingredients + for i, ingredient in ipairs(selectedRecipe.ingredients) do + local amount = 1 + if type(selectedRecipe.amount) == "table" then + amount = selectedRecipe.amount[i] or 1 + end + + -- Apply ingredient reduction from cooking skill + if Config.SkillSystem.Enabled and cookingSkill.level > 1 then + local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] + if benefit and benefit.ingredientReduction > 0 then + -- Calculate chance to save an ingredient + local saveChance = benefit.ingredientReduction / 100 + if saveChance > 0 and amount > 1 then + -- Potentially reduce amount by 1 based on skill level + if math.random() < saveChance then + amount = amount - 1 + end + end + end + end + + local playerAmount = getInventoryItemCount(ingredient) + if playerAmount < amount then + hasAllIngredients = false + table.insert(missingIngredients, ingredient) + else + table.insert(ingredientsToRemove, {name = ingredient, count = amount}) + end + end + end + + if not hasAllIngredients then + local missingText = table.concat(missingIngredients, ", ") + lib.notify({ title = 'Campfire', description = 'Not enough ingredients. Missing: ' .. missingText, type = 'error' }) + return + end + + -- Remove ingredients + for _, item in ipairs(ingredientsToRemove) do + TriggerServerEvent('camping:RI', item.name, item.count) + end + local cookTime = selectedRecipe.time + if Config.SkillSystem.Enabled and cookingSkill.level > 1 then + local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] + if benefit and benefit.cookTimeReduction > 0 then + local reduction = (benefit.cookTimeReduction / 100) + cookTime = cookTime * (1 - reduction) + end + end + + local progress = lib.progressBar({ + duration = cookTime * 1000, + label = 'Cooking ' .. (selectedRecipe.label or recipe), + useWhileDead = false, + canCancel = false, + disable = { + move = true, + car = true, + combat = true, + mouse = false + }, + anim = { + dict = 'amb@prop_human_bbq@male@base', + clip = 'base', + flag = 49 + } + }) + + if progress then + FuelSystem:consume(requiredFuel) + TriggerServerEvent('camping:updateFuel', requiredFuel) + local qualityBonus = 0 + if Config.SkillSystem.Enabled and cookingSkill.level > 1 then + local benefit = Config.SkillSystem.LevelBenefits[cookingSkill.level] + if benefit and benefit.qualityBonus > 0 then + qualityBonus = benefit.qualityBonus + end + end + + if Config.SkillSystem.Enabled then + TriggerServerEvent('camping:addCookingXP', Config.SkillSystem.XPPerCook) + end + + TriggerServerEvent('camping:AI', recipe, 1, {quality = 100 + qualityBonus}) + if Config.RecipeDiscovery.Enabled then + TriggerServerEvent('camping:checkRecipeDiscovery', ingredientsToRemove) + end + + lib.notify({ title = 'Campfire', description = (selectedRecipe.label or recipe) .. ' cooked successfully!', type = 'success' }) + end +end) + +-- Get available recipes based on season and discovered recipes +function GetAvailableRecipes() + local allRecipes = {} + local season = GetCurrentSeason() + + -- Add standard recipes + for k, v in pairs(Config.Recipes) do + allRecipes[k] = v + end + + -- Add seasonal recipes if available + if Config.SeasonalRecipes[season] then + for k, v in pairs(Config.SeasonalRecipes[season]) do + allRecipes[k] = v + end + end + + -- Add holiday recipes if they're in season + if Config.SeasonalRecipes.holiday then + for k, v in pairs(Config.SeasonalRecipes.holiday) do + if IsHolidayRecipeAvailable(v) then + allRecipes[k] = v + end + end + end + + -- Add discovered hidden recipes + for k, v in pairs(discoveredRecipes) do + if Config.HiddenRecipes[k] then + allRecipes[k] = Config.HiddenRecipes[k] + end + end + + return allRecipes +end + +-- Check if a holiday recipe is available +function IsHolidayRecipeAvailable(recipe) + if not recipe.availableFrom or not recipe.availableTo then + return true -- No date restrictions + end + + local day = GetClockDayOfMonth() + local month = GetClockMonth() + 1 -- GetClockMonth is 0-based + + local fromMonth = recipe.availableFrom.month + local fromDay = recipe.availableFrom.day + local toMonth = recipe.availableTo.month + local toDay = recipe.availableTo.day + + -- Simple check for same month + if month == fromMonth and month == toMonth then + return day >= fromDay and day <= toDay + -- Check for period spanning multiple months + elseif month == fromMonth then + return day >= fromDay + elseif month == toMonth then + return day <= toDay + elseif month > fromMonth and month < toMonth then + return true + end + + return false +end + +-- Load cooking skill data from server +RegisterNetEvent('camping:loadCookingSkill') +AddEventHandler('camping:loadCookingSkill', function(skillData) + cookingSkill = skillData + + -- Update UI if it's open + if FuelSystem.isUIOpen then + SendNUIMessage({ + action = 'updateSkill', + skill = cookingSkill + }) + end +end) + +-- Load discovered recipes from server +RegisterNetEvent('camping:loadDiscoveredRecipes') +AddEventHandler('camping:loadDiscoveredRecipes', function(recipes) + discoveredRecipes = recipes +end) + +-- Request skill and recipe data when player spawns +AddEventHandler('playerSpawned', function() + TriggerServerEvent('camping:requestCookingSkill') + TriggerServerEvent('camping:requestDiscoveredRecipes') +end) + +-- Add emergency UI close command +RegisterCommand('closecampingui', function() + if FuelSystem.isUIOpen then + FuelSystem.isUIOpen = false + SetNuiFocus(false, false) + SendNUIMessage({ + action = 'hide' + }) + lib.notify({ + title = 'UI', + description = 'Forced UI to close', + type = 'inform' + }) + end +end) + +-- Register a keybind for it (F10 key) +RegisterKeyMapping('closecampingui', 'Force close camping UI', 'keyboard', 'F10') + diff --git a/config.lua b/config.lua new file mode 100644 index 0000000..98d4ade --- /dev/null +++ b/config.lua @@ -0,0 +1,52 @@ +Config = {} + +Config.targetDistance = 2.0 + +-- โมเดลเต็นท์ (ตามเดิม) +Config.TentModel = 'prop_skid_tent_01' + +-- เปลี่ยนเป็นพร็อพกองไฟของคุณ +Config.CampfireModel = 'log_campfire' -- เดิมเป็น 'prop_beach_fire' + +Config.tentItem = 'tent' +Config.campfireItem = 'campfire' + +-- ฐานเวลาเต็มถังเชื้อเพลิง (วินาที) ใช้แปลง % ตอนเติม/หัก +Config.maxFuel = 300 + +-- อัตราลดเชื้อเพลิงเมื่อไฟติด (% ต่อวินาที) +Config.fuelDrainPerSecond = 0.1 + +-- เอฟเฟกต์ไฟ (พาร์ติเคิล) เมื่อจุดไฟ +Config.CampfireFx = { + asset = 'core', + name = 'ent_amb_beach_campfire', + offset = { x = 0.0, y = -0.15, z = 0.0 }, + scale = 1.0 +} + +-- เมนูเชื้อเพลิง (คงไว้ตามเดิม) +Config.FuelMenu = { + { icon='fas fa-newspaper', title="Garbage (5 seconds)", description="Adds 5 seconds of fuel per unit.", event='add_fuel_option', args={ type="garbage", duration=5 } }, + { icon='fas fa-tree', title="Fire Wood (30 seconds)", description="Adds 30 seconds of fuel per unit.", event='add_fuel_option', args={ type="wood", duration=30 } }, + { icon='fas fa-fire', title="Coal (60 seconds)", description="Adds 60 seconds of fuel per unit.", event='add_fuel_option', args={ type="coal", duration=60 } }, +} + +-- สูตรทำอาหาร (ตามเดิม) +Config.Recipes = { + grilled_rainbow_trout = { + key='grilled_rainbow_trout', label="Grill Rainbow Trout", + icon='nui://ox_inventory/web/images/grilled_fish.png', cookTime=60000, + ingredients = { { name="rainbow-trout", count=1 } } + }, + meat_soup = { + key='meat_soup', label="Meat Soup", + icon='nui://ox_inventory/web/images/meat_stew.png', cookTime=120000, + ingredients = { { name="venison",count=1 }, { name="water",count=1 }, { name="potato_1",count=1 } } + }, + grilled_potato = { + key='grilled_potato', label="Grill Potato", + icon='nui://ox_inventory/web/images/potato.png', cookTime=30000, + ingredients = { { name="potato_1", count=1 } } + }, +} diff --git a/nui/index.html b/nui/index.html index 4ea9474..cd838be 100644 --- a/nui/index.html +++ b/nui/index.html @@ -7,6 +7,7 @@ +
diff --git a/nui/script.js b/nui/script.js index 632ca73..48174fd 100644 --- a/nui/script.js +++ b/nui/script.js @@ -1,3 +1,8 @@ +// Debug function +function debug(message) { + console.log(`[Camping UI] ${message}`) +} + // Global state const state = { isVisible: false, @@ -11,33 +16,37 @@ const state = { // Initialize when DOM is loaded document.addEventListener("DOMContentLoaded", () => { + debug("DOM loaded, initializing UI") + // Listen for messages from the game client - window.addEventListener("message", ({ data }) => { - switch (data.action) { - case "openCookingMenu": - state.isVisible = true - state.recipes = data.recipes || [] - state.inventory = data.inventory || {} - state.fuelLevel = data.fuelLevel || 0 - if (data.skill) state.skill = data.skill - break - case "hide": - state.isVisible = false - break - case "updateInventory": - state.inventory = data.inventory || {} - state.fuelLevel = data.fuelLevel || 0 - break - case "updateFuel": - state.fuelLevel = data.fuelLevel || 0 - break - case "updateSkill": - if (data.skill) state.skill = data.skill - break - default: - break + window.addEventListener("message", (event) => { + const data = event.data + debug(`Received message: ${JSON.stringify(data)}`) + + if (data.action === "openCookingMenu") { + debug("Opening cooking menu") + state.isVisible = true + state.recipes = data.recipes || [] + state.inventory = data.inventory || {} + state.fuelLevel = data.fuelLevel || 0 + if (data.skill) state.skill = data.skill + + renderUI() + } else if (data.action === "hide") { + debug("Hiding UI") + state.isVisible = false + renderUI() + } else if (data.action === "updateInventory") { + state.inventory = data.inventory || {} + state.fuelLevel = data.fuelLevel || 0 + renderUI() + } else if (data.action === "updateFuel") { + state.fuelLevel = data.fuelLevel || 0 + renderUI() + } else if (data.action === "updateSkill") { + if (data.skill) state.skill = data.skill + renderUI() } - renderUI() }) // Initial render @@ -53,22 +62,11 @@ function formatTime(ms) { return `${minutes}m ${remainingSeconds}s` } -// Escape HTML to prevent injection -function escapeHtml(text = "") { - const map = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - } - return String(text).replace(/[&<>"']/g, (m) => map[m]) -} - // Render the UI function renderUI() { const rootElement = document.getElementById("root") if (!rootElement) { + debug("ERROR: Root element not found!") return } @@ -77,21 +75,19 @@ function renderUI() { return } - // Filter recipes based on active tab - const filteredRecipes = state.recipes.filter((recipe) => { - if (state.activeTab === "all") return true - return recipe.category === state.activeTab - }) - - const fuelClass = state.fuelLevel < 20 ? "low" : "" + // Filter recipes based on active tab + const filteredRecipes = state.recipes.filter((recipe) => { + if (state.activeTab === "all") return true + return recipe.category === state.activeTab + }) - // Build the HTML - let html = ` -