mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 21:01:37 +00:00
e58f1b6c18
Resolve configured and regional locales through a centralized English fallback for Lua and NUI consumers. Complete the German and English app translation keys, localize remaining picker and attachment copy, and add contract tests that detect missing locale entries.
523 lines
17 KiB
Lua
523 lines
17 KiB
Lua
local current_valet = nil
|
|
|
|
local vehicle_mods = {
|
|
modSpoilers = 0,
|
|
modFrontBumper = 1,
|
|
modRearBumper = 2,
|
|
modSideSkirt = 3,
|
|
modExhaust = 4,
|
|
modFrame = 5,
|
|
modGrille = 6,
|
|
modHood = 7,
|
|
modFender = 8,
|
|
modRightFender = 9,
|
|
modRoof = 10,
|
|
modEngine = 11,
|
|
modBrakes = 12,
|
|
modTransmission = 13,
|
|
modHorns = 14,
|
|
modSuspension = 15,
|
|
modArmor = 16,
|
|
modFrontWheels = 23,
|
|
modBackWheels = 24,
|
|
modPlateHolder = 25,
|
|
modVanityPlate = 26,
|
|
modTrimA = 27,
|
|
modOrnaments = 28,
|
|
modDashboard = 29,
|
|
modDial = 30,
|
|
modDoorSpeaker = 31,
|
|
modSeats = 32,
|
|
modSteeringWheel = 33,
|
|
modShifterLeavers = 34,
|
|
modAPlate = 35,
|
|
modSpeakers = 36,
|
|
modTrunk = 37,
|
|
modHydrolic = 38,
|
|
modEngineBlock = 39,
|
|
modAirFilter = 40,
|
|
modStruts = 41,
|
|
modArchCover = 42,
|
|
modAerials = 43,
|
|
modTrimB = 44,
|
|
modTank = 45,
|
|
modWindows = 46,
|
|
modLivery = 48,
|
|
}
|
|
|
|
local phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
|
local garage_locale = phone_locale.Nui.Apps.garage
|
|
|
|
local function normalized_plate(value)
|
|
return tostring(value or ""):match("^%s*(.-)%s*$")
|
|
end
|
|
|
|
local function request_model(model)
|
|
RequestModel(model)
|
|
local timeout = GetGameTimer() + 10000
|
|
while not HasModelLoaded(model) and GetGameTimer() < timeout do
|
|
Wait(50)
|
|
end
|
|
return HasModelLoaded(model)
|
|
end
|
|
|
|
local function set_vehicle_color(vehicle, primary, secondary)
|
|
if type(primary) == "table" then
|
|
SetVehicleCustomPrimaryColour(
|
|
vehicle,
|
|
tonumber(primary[1]) or 0,
|
|
tonumber(primary[2]) or 0,
|
|
tonumber(primary[3]) or 0
|
|
)
|
|
end
|
|
if type(secondary) == "table" then
|
|
SetVehicleCustomSecondaryColour(
|
|
vehicle,
|
|
tonumber(secondary[1]) or 0,
|
|
tonumber(secondary[2]) or 0,
|
|
tonumber(secondary[3]) or 0
|
|
)
|
|
end
|
|
if type(primary) == "number" or type(secondary) == "number" then
|
|
local current_primary, current_secondary = GetVehicleColours(vehicle)
|
|
SetVehicleColours(
|
|
vehicle,
|
|
tonumber(primary) or current_primary,
|
|
tonumber(secondary) or current_secondary
|
|
)
|
|
end
|
|
end
|
|
|
|
local function apply_vehicle_properties(vehicle, properties, fallback)
|
|
properties = type(properties) == "table" and properties or {}
|
|
SetVehicleModKit(vehicle, 0)
|
|
set_vehicle_color(vehicle, properties.color1, properties.color2)
|
|
if properties.pearlescentColor or properties.wheelColor then
|
|
SetVehicleExtraColours(
|
|
vehicle,
|
|
tonumber(properties.pearlescentColor) or 0,
|
|
tonumber(properties.wheelColor) or 0
|
|
)
|
|
end
|
|
if properties.wheels then
|
|
SetVehicleWheelType(vehicle, tonumber(properties.wheels) or 0)
|
|
end
|
|
if properties.windowTint then
|
|
SetVehicleWindowTint(vehicle, tonumber(properties.windowTint) or 0)
|
|
end
|
|
if properties.xenonColor then
|
|
SetVehicleXenonLightsColor(vehicle, tonumber(properties.xenonColor) or -1)
|
|
end
|
|
for property, mod_type in pairs(vehicle_mods) do
|
|
local value = tonumber(properties[property])
|
|
if value then
|
|
SetVehicleMod(vehicle, mod_type, value, false)
|
|
end
|
|
end
|
|
ToggleVehicleMod(vehicle, 18, properties.modTurbo == true)
|
|
ToggleVehicleMod(vehicle, 20, properties.modSmokeEnabled == true)
|
|
ToggleVehicleMod(vehicle, 22, properties.modXenon == true)
|
|
if type(properties.neonEnabled) == "table" then
|
|
for index = 0, 3 do
|
|
SetVehicleNeonLightEnabled(vehicle, index, properties.neonEnabled[index + 1] == true)
|
|
end
|
|
end
|
|
if type(properties.neonColor) == "table" then
|
|
SetVehicleNeonLightsColour(
|
|
vehicle,
|
|
tonumber(properties.neonColor[1]) or 0,
|
|
tonumber(properties.neonColor[2]) or 0,
|
|
tonumber(properties.neonColor[3]) or 0
|
|
)
|
|
end
|
|
if type(properties.tyreSmokeColor) == "table" then
|
|
SetVehicleTyreSmokeColor(
|
|
vehicle,
|
|
tonumber(properties.tyreSmokeColor[1]) or 0,
|
|
tonumber(properties.tyreSmokeColor[2]) or 0,
|
|
tonumber(properties.tyreSmokeColor[3]) or 0
|
|
)
|
|
end
|
|
if type(properties.extras) == "table" then
|
|
for extra_id, enabled in pairs(properties.extras) do
|
|
local numeric_id = tonumber(extra_id)
|
|
if numeric_id and DoesExtraExist(vehicle, numeric_id) then
|
|
SetVehicleExtra(vehicle, numeric_id, not enabled)
|
|
end
|
|
end
|
|
end
|
|
local fuel = tonumber(properties.fuelLevel or properties.fuel or fallback.fuel)
|
|
local engine = tonumber(properties.engineHealth or properties.engine or fallback.engine and fallback.engine * 10)
|
|
local body = tonumber(properties.bodyHealth or properties.body or fallback.body and fallback.body * 10)
|
|
if fuel then
|
|
SetVehicleFuelLevel(vehicle, math.max(0.0, math.min(100.0, fuel)))
|
|
end
|
|
if engine then
|
|
SetVehicleEngineHealth(vehicle, math.max(-4000.0, math.min(1000.0, engine)))
|
|
end
|
|
if body then
|
|
SetVehicleBodyHealth(vehicle, math.max(0.0, math.min(1000.0, body)))
|
|
end
|
|
if properties.dirtLevel then
|
|
SetVehicleDirtLevel(vehicle, math.max(0.0, math.min(15.0, tonumber(properties.dirtLevel) or 0.0)))
|
|
end
|
|
SetVehicleNumberPlateText(vehicle, fallback.plate)
|
|
end
|
|
|
|
local function existing_vehicle(plate)
|
|
local normalized = string.upper(normalized_plate(plate))
|
|
for _, vehicle in ipairs(GetGamePool("CVehicle")) do
|
|
if string.upper(normalized_plate(GetVehicleNumberPlateText(vehicle))) == normalized then
|
|
return vehicle
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function public_valet_state()
|
|
if not current_valet then
|
|
return nil
|
|
end
|
|
return {
|
|
canCancel = current_valet.can_cancel,
|
|
cost = current_valet.cost,
|
|
distance = current_valet.distance,
|
|
etaSeconds = current_valet.eta_seconds,
|
|
orderId = current_valet.order_id,
|
|
plate = current_valet.plate,
|
|
status = current_valet.status,
|
|
vehicleName = current_valet.vehicle_name,
|
|
}
|
|
end
|
|
|
|
local function send_valet_state()
|
|
SendNUIMessage({
|
|
type = "garage:valet-status",
|
|
data = public_valet_state(),
|
|
})
|
|
end
|
|
|
|
local function remove_valet_blip()
|
|
if current_valet and current_valet.blip and DoesBlipExist(current_valet.blip) then
|
|
RemoveBlip(current_valet.blip)
|
|
current_valet.blip = nil
|
|
end
|
|
end
|
|
|
|
local function delete_valet_entities()
|
|
if not current_valet then
|
|
return
|
|
end
|
|
remove_valet_blip()
|
|
if current_valet.driver and DoesEntityExist(current_valet.driver) then
|
|
DeleteEntity(current_valet.driver)
|
|
end
|
|
if current_valet.vehicle and DoesEntityExist(current_valet.vehicle) then
|
|
DeleteEntity(current_valet.vehicle)
|
|
end
|
|
end
|
|
|
|
local function fail_valet(error_code, server_cancel)
|
|
if not current_valet then
|
|
return
|
|
end
|
|
local order_id = current_valet.order_id
|
|
current_valet.cancelled = true
|
|
delete_valet_entities()
|
|
if server_cancel then
|
|
Bridge.Callbacks.Trigger("sky_phone:garage:valet-cancel", { orderId = order_id })
|
|
end
|
|
current_valet.status = "failed"
|
|
current_valet.can_cancel = false
|
|
current_valet.error = error_code
|
|
send_valet_state()
|
|
local locale = garage_locale
|
|
Bridge.Framework.Notify(locale.name, locale.errors[error_code] or locale.errors.default, "error", 5000)
|
|
SetTimeout(8000, function()
|
|
if current_valet and current_valet.order_id == order_id then
|
|
current_valet = nil
|
|
send_valet_state()
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function update_drive_target(driver, vehicle)
|
|
local coords = GetEntityCoords(PlayerPedId())
|
|
TaskVehicleDriveToCoordLongrange(
|
|
driver,
|
|
vehicle,
|
|
coords.x,
|
|
coords.y,
|
|
coords.z,
|
|
Config.Garage.Valet.DriveSpeed,
|
|
Config.Garage.Valet.DrivingStyle,
|
|
Config.Garage.Valet.ArrivalDistance
|
|
)
|
|
end
|
|
|
|
local function run_valet_delivery(order)
|
|
local model = tonumber(order.vehicle.model)
|
|
if not model and type(order.vehicle.model) == "string" then
|
|
model = joaat(order.vehicle.model)
|
|
end
|
|
local driver_model = joaat(order.driverModel)
|
|
if not model or not IsModelInCdimage(model) or not IsModelAVehicle(model) then
|
|
fail_valet("invalid_vehicle_model", true)
|
|
return
|
|
end
|
|
if existing_vehicle(order.vehicle.plate) then
|
|
fail_valet("vehicle_already_out", true)
|
|
return
|
|
end
|
|
current_valet.status = "preparing"
|
|
send_valet_state()
|
|
if not request_model(model) or not request_model(driver_model) then
|
|
SetModelAsNoLongerNeeded(model)
|
|
SetModelAsNoLongerNeeded(driver_model)
|
|
fail_valet("valet_spawn_failed", true)
|
|
return
|
|
end
|
|
|
|
local player_ped = PlayerPedId()
|
|
local player_coords = GetEntityCoords(player_ped)
|
|
local target = GetOffsetFromEntityInWorldCoords(player_ped, 0.0, -Config.Garage.Valet.SpawnDistance, 0.0)
|
|
local found, spawn_coords, spawn_heading = GetClosestVehicleNodeWithHeading(
|
|
target.x,
|
|
target.y,
|
|
target.z,
|
|
1,
|
|
3.0,
|
|
0
|
|
)
|
|
if not found then
|
|
SetModelAsNoLongerNeeded(model)
|
|
SetModelAsNoLongerNeeded(driver_model)
|
|
fail_valet("valet_no_road", true)
|
|
return
|
|
end
|
|
|
|
local vehicle = CreateVehicle(
|
|
model,
|
|
spawn_coords.x,
|
|
spawn_coords.y,
|
|
spawn_coords.z,
|
|
spawn_heading,
|
|
true,
|
|
true
|
|
)
|
|
if vehicle == 0 or not DoesEntityExist(vehicle) then
|
|
SetModelAsNoLongerNeeded(model)
|
|
SetModelAsNoLongerNeeded(driver_model)
|
|
fail_valet("valet_spawn_failed", true)
|
|
return
|
|
end
|
|
current_valet.vehicle = vehicle
|
|
SetEntityAsMissionEntity(vehicle, true, true)
|
|
SetVehicleOnGroundProperly(vehicle)
|
|
apply_vehicle_properties(vehicle, order.vehicle.properties, order.vehicle)
|
|
SetVehicleHasBeenOwnedByPlayer(vehicle, true)
|
|
SetVehicleDoorsLocked(vehicle, 2)
|
|
SetVehicleEngineOn(vehicle, true, true, false)
|
|
local network_id = NetworkGetNetworkIdFromEntity(vehicle)
|
|
SetNetworkIdCanMigrate(network_id, true)
|
|
|
|
local driver = CreatePedInsideVehicle(vehicle, 26, driver_model, -1, true, true)
|
|
if driver == 0 or not DoesEntityExist(driver) then
|
|
SetModelAsNoLongerNeeded(model)
|
|
SetModelAsNoLongerNeeded(driver_model)
|
|
fail_valet("valet_spawn_failed", true)
|
|
return
|
|
end
|
|
current_valet.driver = driver
|
|
SetEntityAsMissionEntity(driver, true, true)
|
|
SetBlockingOfNonTemporaryEvents(driver, true)
|
|
SetPedKeepTask(driver, true)
|
|
SetDriverAbility(driver, 1.0)
|
|
SetDriverAggressiveness(driver, 0.0)
|
|
SetModelAsNoLongerNeeded(model)
|
|
SetModelAsNoLongerNeeded(driver_model)
|
|
|
|
current_valet.blip = AddBlipForEntity(vehicle)
|
|
SetBlipSprite(current_valet.blip, 225)
|
|
SetBlipColour(current_valet.blip, 3)
|
|
SetBlipRoute(current_valet.blip, true)
|
|
current_valet.status = "en_route"
|
|
current_valet.can_cancel = true
|
|
update_drive_target(driver, vehicle)
|
|
send_valet_state()
|
|
|
|
local timeout = GetGameTimer() + (Config.Garage.Valet.TimeoutSeconds * 1000)
|
|
local next_retask = GetGameTimer() + 8000
|
|
while current_valet and not current_valet.cancelled and GetGameTimer() < timeout do
|
|
if not DoesEntityExist(vehicle)
|
|
or not DoesEntityExist(driver)
|
|
or IsEntityDead(driver)
|
|
or not IsVehicleDriveable(vehicle, false)
|
|
then
|
|
fail_valet("valet_interrupted", true)
|
|
return
|
|
end
|
|
player_coords = GetEntityCoords(PlayerPedId())
|
|
local vehicle_coords = GetEntityCoords(vehicle)
|
|
local distance = #(vehicle_coords - player_coords)
|
|
current_valet.distance = math.floor(distance + 0.5)
|
|
current_valet.eta_seconds = math.max(1, math.ceil(distance / math.max(1.0, Config.Garage.Valet.DriveSpeed)))
|
|
if distance <= Config.Garage.Valet.ArrivalDistance then
|
|
break
|
|
end
|
|
if GetGameTimer() >= next_retask then
|
|
update_drive_target(driver, vehicle)
|
|
next_retask = GetGameTimer() + 8000
|
|
end
|
|
send_valet_state()
|
|
Wait(750)
|
|
end
|
|
if not current_valet or current_valet.cancelled then
|
|
return
|
|
end
|
|
if GetGameTimer() >= timeout then
|
|
fail_valet("valet_timeout", true)
|
|
return
|
|
end
|
|
|
|
current_valet.status = "arriving"
|
|
current_valet.can_cancel = false
|
|
current_valet.distance = 0
|
|
current_valet.eta_seconds = 0
|
|
send_valet_state()
|
|
TaskVehicleTempAction(driver, vehicle, 27, 1800)
|
|
Wait(1800)
|
|
SetVehicleEngineOn(vehicle, false, true, true)
|
|
SetVehicleDoorsLocked(vehicle, 1)
|
|
TaskLeaveVehicle(driver, vehicle, 0)
|
|
|
|
local leave_timeout = GetGameTimer() + 5000
|
|
while IsPedInVehicle(driver, vehicle, false) and GetGameTimer() < leave_timeout do
|
|
Wait(100)
|
|
end
|
|
local driver_target = GetOffsetFromEntityInWorldCoords(vehicle, 4.0, 8.0, 0.0)
|
|
TaskGoStraightToCoord(
|
|
driver,
|
|
driver_target.x,
|
|
driver_target.y,
|
|
driver_target.z,
|
|
1.0,
|
|
10000,
|
|
GetEntityHeading(driver),
|
|
0.5
|
|
)
|
|
SetPedKeepTask(driver, true)
|
|
SetEntityAsNoLongerNeeded(driver)
|
|
current_valet.driver = nil
|
|
|
|
local completion = Bridge.Callbacks.Trigger(
|
|
"sky_phone:garage:valet-complete",
|
|
{
|
|
orderId = current_valet.order_id,
|
|
networkId = NetworkGetNetworkIdFromEntity(vehicle),
|
|
}
|
|
)
|
|
if not completion or not completion.success then
|
|
fail_valet("valet_completion_failed", false)
|
|
return
|
|
end
|
|
remove_valet_blip()
|
|
SetEntityAsNoLongerNeeded(vehicle)
|
|
current_valet.vehicle = nil
|
|
current_valet.status = "delivered"
|
|
current_valet.can_cancel = false
|
|
send_valet_state()
|
|
local locale = garage_locale
|
|
Bridge.Framework.Notify(locale.name, locale.valetDelivered, "success", 5000)
|
|
local completed_order_id = current_valet.order_id
|
|
SetTimeout(12000, function()
|
|
if current_valet and current_valet.order_id == completed_order_id then
|
|
current_valet = nil
|
|
send_valet_state()
|
|
end
|
|
end)
|
|
end
|
|
|
|
RegisterNUICallback("garage:valet-request", function(data, cb)
|
|
if type(data) ~= "table" then
|
|
cb({ success = false, error = "invalid_request" })
|
|
return
|
|
end
|
|
if current_valet then
|
|
cb({ success = false, error = "valet_active" })
|
|
return
|
|
end
|
|
local result = Bridge.Callbacks.Trigger("sky_phone:garage:valet-request", data)
|
|
if not result or not result.success or type(result.data) ~= "table" then
|
|
cb(result or { success = false, error = "request_failed" })
|
|
return
|
|
end
|
|
local vehicle = result.data.vehicle
|
|
local model_hash = tonumber(vehicle.model)
|
|
if not model_hash and type(vehicle.model) == "string" then
|
|
model_hash = joaat(vehicle.model)
|
|
end
|
|
local display_name = model_hash and GetDisplayNameFromVehicleModel(model_hash) or nil
|
|
local label = display_name and GetLabelText(display_name) or nil
|
|
current_valet = {
|
|
can_cancel = false,
|
|
cancelled = false,
|
|
cost = result.data.cost,
|
|
distance = nil,
|
|
eta_seconds = nil,
|
|
order_id = result.data.orderId,
|
|
plate = vehicle.plate,
|
|
status = "ordering",
|
|
vehicle_name = label and label ~= "NULL" and label ~= "CARNOTFOUND"
|
|
and label
|
|
or tostring(vehicle.model),
|
|
}
|
|
cb({ success = true, data = public_valet_state() })
|
|
send_valet_state()
|
|
CreateThread(function()
|
|
run_valet_delivery(result.data)
|
|
end)
|
|
end)
|
|
|
|
RegisterNUICallback("garage:valet-cancel", function(_, cb)
|
|
if not current_valet or not current_valet.can_cancel then
|
|
cb({ success = false, error = "valet_not_cancellable" })
|
|
return
|
|
end
|
|
local order_id = current_valet.order_id
|
|
local result = Bridge.Callbacks.Trigger("sky_phone:garage:valet-cancel", { orderId = order_id })
|
|
if not result or not result.success then
|
|
cb(result or { success = false, error = "request_failed" })
|
|
return
|
|
end
|
|
current_valet.cancelled = true
|
|
delete_valet_entities()
|
|
current_valet.status = "cancelled"
|
|
current_valet.can_cancel = false
|
|
send_valet_state()
|
|
cb({ success = true, data = public_valet_state() })
|
|
SetTimeout(5000, function()
|
|
if current_valet and current_valet.order_id == order_id then
|
|
current_valet = nil
|
|
send_valet_state()
|
|
end
|
|
end)
|
|
end)
|
|
|
|
RegisterNUICallback("garage:valet-state", function(_, cb)
|
|
cb({ success = true, data = public_valet_state() })
|
|
end)
|
|
|
|
RegisterNetEvent("sky_phone:garage:valet-aborted", function(error_code)
|
|
if current_valet then
|
|
fail_valet(error_code or "valet_timeout", false)
|
|
end
|
|
end)
|
|
|
|
AddEventHandler("onResourceStop", function(resource_name)
|
|
if resource_name ~= GetCurrentResourceName() or not current_valet then
|
|
return
|
|
end
|
|
delete_valet_entities()
|
|
end)
|