42.20 and Towtrucks
This commit is contained in:
@@ -6,5 +6,3 @@ TowBarMod.Config.rigidTowbarDistance = 1.0
|
||||
TowBarMod.Config.towAttachmentExteriorPadding = 0.25
|
||||
TowBarMod.Config.towedVehicleRollingMass = 75
|
||||
TowBarMod.Config.devMode = false
|
||||
TowBarMod.Config.vanillaTowbarModelScaleMin = 1.5
|
||||
TowBarMod.Config.vanillaTowbarModelScaleMax = 2.0
|
||||
|
||||
@@ -4,6 +4,23 @@ if not TowBarMod then TowBarMod = {} end
|
||||
TowBarMod.Sync = TowBarMod.Sync or {}
|
||||
if TowBarMod.Sync._towSyncClientLoaded then return end
|
||||
TowBarMod.Sync._towSyncClientLoaded = true
|
||||
TowBarMod.Sync.appliedPairs = TowBarMod.Sync.appliedPairs or {}
|
||||
|
||||
local function pairKeyContainsVehicle(key, vehicleId)
|
||||
local id = tostring(vehicleId)
|
||||
return string.sub(key, 1, #id + 1) == id .. ":"
|
||||
or string.sub(key, -(#id + 1)) == ":" .. id
|
||||
end
|
||||
|
||||
local function clearAppliedPairForVehicle(vehicle)
|
||||
if not vehicle then return end
|
||||
local vehicleId = vehicle:getId()
|
||||
for key in pairs(TowBarMod.Sync.appliedPairs) do
|
||||
if pairKeyContainsVehicle(key, vehicleId) then
|
||||
TowBarMod.Sync.appliedPairs[key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function resolveVehicle(id)
|
||||
if not id then return nil end
|
||||
@@ -89,6 +106,8 @@ local function reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB)
|
||||
end
|
||||
end
|
||||
|
||||
local breakTowBarPair
|
||||
|
||||
local function applyAttachSync(args)
|
||||
if not args then return end
|
||||
|
||||
@@ -102,8 +121,25 @@ local function applyAttachSync(args)
|
||||
return
|
||||
end
|
||||
|
||||
if not isLinked(vehicleA, vehicleB) then
|
||||
vehicleA:addPointConstraint(nil, vehicleB, attachmentA, attachmentB)
|
||||
local key = tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())
|
||||
if TowBarMod.Sync.appliedPairs[key] and not isLinked(vehicleA, vehicleB) then
|
||||
-- Vehicle streaming can remove the native constraint while the Lua
|
||||
-- module remains loaded. Allow the authoritative recovery sync to
|
||||
-- rebuild local physics for the returning pair.
|
||||
TowBarMod.Sync.appliedPairs[key] = nil
|
||||
end
|
||||
if not TowBarMod.Sync.appliedPairs[key] then
|
||||
-- The server initially creates a normal Build 42 tow relation. It may
|
||||
-- also restore one from the save. Replace that native rope exactly
|
||||
-- once with this mod's rigid fake-trailer constraint.
|
||||
breakTowBarPair(vehicleA, vehicleB)
|
||||
if TowBarMod.Hook and TowBarMod.Hook.setVehicleScriptWithTowBarHidden then
|
||||
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(vehicleB, "notTowingA_Trailer")
|
||||
end
|
||||
-- The final true keeps this a local physics rebuild. The server owns
|
||||
-- the persisted logical relation and must not receive a detach/attach race.
|
||||
vehicleA:addPointConstraint(nil, vehicleB, attachmentA, attachmentB, true)
|
||||
TowBarMod.Sync.appliedPairs[key] = true
|
||||
end
|
||||
|
||||
reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB)
|
||||
@@ -130,18 +166,17 @@ local function hasConflictingTowLink(vehicle, expectedOther)
|
||||
and modData["towBarTowingVehicleId"] ~= expectedOtherId)
|
||||
end
|
||||
|
||||
local function breakTowBarPair(vehicleA, vehicleB)
|
||||
breakTowBarPair = function(vehicleA, vehicleB)
|
||||
if not vehicleA or not vehicleB then return end
|
||||
|
||||
local vehicleAReferencesB = vehicleA:getVehicleTowing() == vehicleB
|
||||
or vehicleA:getVehicleTowedBy() == vehicleB
|
||||
local vehicleBReferencesA = vehicleB:getVehicleTowing() == vehicleA
|
||||
or vehicleB:getVehicleTowedBy() == vehicleA
|
||||
if vehicleBReferencesA then
|
||||
vehicleB:breakConstraint(true, true)
|
||||
end
|
||||
if vehicleAReferencesB then
|
||||
vehicleA:breakConstraint(true, true)
|
||||
elseif vehicleBReferencesA then
|
||||
vehicleB:breakConstraint(true, true)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -154,6 +189,7 @@ local function applyDetachSync(args)
|
||||
return
|
||||
end
|
||||
breakTowBarPair(vehicleA, vehicleB)
|
||||
TowBarMod.Sync.appliedPairs[tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())] = nil
|
||||
|
||||
if TowBarMod.Hook and TowBarMod.Hook.cleanupDetachedTowBar then
|
||||
pcall(TowBarMod.Hook.cleanupDetachedTowBar, vehicleA, vehicleB)
|
||||
@@ -170,4 +206,10 @@ local function onServerCommand(module, command, args)
|
||||
end
|
||||
end
|
||||
|
||||
TowBarMod.Sync.applyAttachSync = applyAttachSync
|
||||
TowBarMod.Sync.applyDetachSync = applyDetachSync
|
||||
|
||||
Events.OnServerCommand.Add(onServerCommand)
|
||||
if Events.OnSpawnVehicleEnd then
|
||||
Events.OnSpawnVehicleEnd.Add(clearAppliedPairForVehicle)
|
||||
end
|
||||
|
||||
@@ -128,61 +128,54 @@ local function sendTowAttachCommand(playerObj, args)
|
||||
end
|
||||
|
||||
local TowbarVariantSize = 24
|
||||
local TowbarNormalStart = 0
|
||||
local TowbarLargeStart = 24
|
||||
local TowbarMaxIndex = TowbarVariantSize - 1
|
||||
local VanillaScaleMin = 1.5
|
||||
local VanillaScaleMax = 2.0
|
||||
local TowbarFirstZ = 1.0
|
||||
local TowbarSlotStep = 0.1
|
||||
-- Measured from Towbar.fbx at its original 0.01 script scale. The visual is
|
||||
-- enlarged uniformly, so its center must move outward by the scaled half-length
|
||||
-- to keep the inner end on the same vehicle-hitbox contact point.
|
||||
local TowbarVisualScale = 2.5
|
||||
local TowbarModelLength = 0.9714089036
|
||||
local TowbarScaledModelLength = TowbarModelLength * TowbarVisualScale
|
||||
local TowbarModelHalfLength = TowbarScaledModelLength / 2
|
||||
|
||||
local function getVehicleModelScale(script)
|
||||
local function getTowbarFrontEdgeZ(script)
|
||||
if not script then return nil end
|
||||
|
||||
local ok, result = pcall(function()
|
||||
return script:getModelScale()
|
||||
local ok, shape = pcall(function()
|
||||
return script:getPhysicsChassisShape()
|
||||
end)
|
||||
if ok and type(result) == "number" then
|
||||
return result
|
||||
end
|
||||
if not ok or not shape then return nil end
|
||||
|
||||
ok, result = pcall(function()
|
||||
local model = script:getModel()
|
||||
if model then
|
||||
return model:getScale()
|
||||
end
|
||||
local shapeOk, shapeZ = pcall(function()
|
||||
return shape:z()
|
||||
end)
|
||||
if not shapeOk or type(shapeZ) ~= "number" or shapeZ <= 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local centerOk, center = pcall(function()
|
||||
return script:getCenterOfMassOffset()
|
||||
end)
|
||||
if ok and type(result) == "number" then
|
||||
return result
|
||||
end
|
||||
if not centerOk or not center then return nil end
|
||||
|
||||
return nil
|
||||
end
|
||||
local zOk, centerZ = pcall(function()
|
||||
return center:z()
|
||||
end)
|
||||
if not zOk or type(centerZ) ~= "number" then return nil end
|
||||
|
||||
local function isVanillaScale(script)
|
||||
local modelScale = getVehicleModelScale(script)
|
||||
if modelScale == nil then
|
||||
return true
|
||||
end
|
||||
|
||||
local configuredMin = TowBarMod.Config and tonumber(TowBarMod.Config.vanillaTowbarModelScaleMin)
|
||||
local configuredMax = TowBarMod.Config and tonumber(TowBarMod.Config.vanillaTowbarModelScaleMax)
|
||||
local minScale = configuredMin or VanillaScaleMin
|
||||
local maxScale = configuredMax or VanillaScaleMax
|
||||
return modelScale >= minScale and modelScale <= maxScale
|
||||
end
|
||||
|
||||
local function getTowbarIndexVanilla(script)
|
||||
local z = script:getPhysicsChassisShape():z() / 2 - 0.1
|
||||
local index = math.floor((z * 2 / 3 - 1) * 10)
|
||||
return math.max(0, math.min(TowbarMaxIndex, index))
|
||||
return centerZ + shapeZ / 2
|
||||
end
|
||||
|
||||
local function getTowbarModelSlot(script)
|
||||
if not isVanillaScale(script) then
|
||||
-- KI5/small-scale vehicles use the normal towbar0 mesh at Z=1.
|
||||
return 0
|
||||
end
|
||||
return getTowbarIndexVanilla(script)
|
||||
local frontEdgeZ = getTowbarFrontEdgeZ(script)
|
||||
if frontEdgeZ == nil then return nil end
|
||||
|
||||
-- Model offsets position the mesh origin. Move its center outward so the
|
||||
-- towbar's inner end, rather than its center, meets the vehicle hitbox.
|
||||
local modelCenterZ = frontEdgeZ + TowbarModelHalfLength
|
||||
local index = math.floor(((modelCenterZ - TowbarFirstZ) / TowbarSlotStep) + 0.5)
|
||||
return math.max(0, math.min(TowbarMaxIndex, index))
|
||||
end
|
||||
|
||||
local function setTowBarModelVisible(vehicle, isVisible)
|
||||
@@ -210,7 +203,7 @@ local function setTowBarModelVisible(vehicle, isVisible)
|
||||
|
||||
local index = getTowbarModelSlot(script)
|
||||
local part = normalPart
|
||||
if part then
|
||||
if part and index ~= nil then
|
||||
part:setModelVisible("towbar" .. index, true)
|
||||
end
|
||||
|
||||
@@ -409,14 +402,10 @@ local function recoverTowBarVehicleAfterLoad(playerObj, vehicle, retriesLeft)
|
||||
|
||||
if towingVehicle then
|
||||
-- Apply rigid spacing as soon as the tow link exists to avoid a visible
|
||||
-- bumper-to-bumper snap while waiting for reattach recovery.
|
||||
-- bumper-to-bumper snap. Server/SP reconciliation owns recovery and
|
||||
-- never refunds or consumes an item while loading a save.
|
||||
TowBarMod.Hook.setVehiclePostAttach(nil, vehicle)
|
||||
end
|
||||
|
||||
if localPlayer and towingVehicle then
|
||||
if reattachTowBarPairAfterCleanDetach(localPlayer, towingVehicle, vehicle, false) then
|
||||
return
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if localPlayer and retries > 0 then
|
||||
@@ -425,9 +414,8 @@ local function recoverTowBarVehicleAfterLoad(playerObj, vehicle, retriesLeft)
|
||||
return
|
||||
end
|
||||
|
||||
-- Fallback: keep original post-attach restoration behavior.
|
||||
setTowBarModelVisible(vehicle, true)
|
||||
TowBarMod.Hook.setVehiclePostAttach(nil, vehicle)
|
||||
-- Leave reciprocal saved state untouched. The authoritative audit will
|
||||
-- reconnect it when both vehicle chunks are loaded.
|
||||
end
|
||||
|
||||
function TowBarMod.Hook.setVehiclePostAttach(playerObj, towedVehicle, retriesLeft)
|
||||
@@ -514,8 +502,6 @@ end
|
||||
function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle)
|
||||
if towingVehicle == nil or towedVehicle == nil then return end
|
||||
|
||||
TowBarMod.Utils.updateAttachmentsOnDefaultValues(towingVehicle, towedVehicle)
|
||||
|
||||
local towingModData = towingVehicle:getModData()
|
||||
local towedModData = towedVehicle:getModData()
|
||||
if not towingModData or not towedModData then return end
|
||||
@@ -523,17 +509,25 @@ function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle)
|
||||
if towedModData.towBarOriginalScriptName then
|
||||
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, towedModData.towBarOriginalScriptName)
|
||||
end
|
||||
-- A very fast break can happen while the temporary fake-trailer script is
|
||||
-- still active. Restore the real script first so the saved offsets are
|
||||
-- written back to the actual vehicle attachments, not the temporary script.
|
||||
TowBarMod.Utils.updateAttachmentsOnDefaultValues(towingVehicle, towedVehicle)
|
||||
restoreFreeRollingTowState(towedVehicle, towedModData)
|
||||
|
||||
towingModData["isTowingByTowBar"] = false
|
||||
towingModData["towed"] = false
|
||||
towingModData["towBarTowedVehicleId"] = nil
|
||||
towingModData["towBarTowedVehicleSqlId"] = nil
|
||||
towingModData["towBarTowingVehicleId"] = nil
|
||||
towingModData["towBarTowingVehicleSqlId"] = nil
|
||||
towingModData["towBarExpectedAttachment"] = nil
|
||||
towedModData["isTowingByTowBar"] = false
|
||||
towedModData["towed"] = false
|
||||
towedModData["towBarTowedVehicleId"] = nil
|
||||
towedModData["towBarTowedVehicleSqlId"] = nil
|
||||
towedModData["towBarTowingVehicleId"] = nil
|
||||
towedModData["towBarTowingVehicleSqlId"] = nil
|
||||
towedModData["towBarExpectedAttachment"] = nil
|
||||
towedModData.towBarOriginalScriptName = nil
|
||||
towedModData.towBarOriginalMass = nil
|
||||
@@ -762,7 +756,6 @@ function TowBarMod.Hook.devShowAllTowbarModels(playerObj, vehicle)
|
||||
local script = vehicle:getScript()
|
||||
local chassisZ = script and script:getPhysicsChassisShape():z() or 0
|
||||
local halfZ = chassisZ / 2
|
||||
local modelScale = script and getVehicleModelScale(script) or nil
|
||||
local index = 0
|
||||
if script then
|
||||
index = getTowbarModelSlot(script)
|
||||
@@ -770,7 +763,7 @@ function TowBarMod.Hook.devShowAllTowbarModels(playerObj, vehicle)
|
||||
local selectedPart = "towbar"
|
||||
print("[TowBar DEV] Vehicle: " .. tostring(vehicle:getScriptName()))
|
||||
print("[TowBar DEV] chassisShape.z = " .. tostring(chassisZ) .. ", half = " .. tostring(halfZ))
|
||||
print("[TowBar DEV] modelScale = " .. tostring(modelScale) .. ", part = " .. selectedPart)
|
||||
print("[TowBar DEV] frontEdgeZ = " .. tostring(script and getTowbarFrontEdgeZ(script) or nil) .. ", part = " .. selectedPart)
|
||||
print("[TowBar DEV] Formula picks index = " .. tostring(index) .. " (towbar" .. tostring(index) .. " at Z offset " .. tostring(1.0 + index * 0.1) .. ")")
|
||||
print("[TowBar DEV] Showing towbar0..towbar23 on both parts")
|
||||
for j = 0, TowbarVariantSize - 1 do
|
||||
|
||||
@@ -215,7 +215,13 @@ end
|
||||
function ISVehicleMenu.showRadialMenu(playerObj)
|
||||
TowBarMod.UI.defaultShowRadialMenu(playerObj)
|
||||
|
||||
if playerObj:getVehicle() then return end
|
||||
local currentVehicle = playerObj:getVehicle()
|
||||
if currentVehicle then
|
||||
if TowBarMod.WreckerUI then
|
||||
TowBarMod.WreckerUI.addOptionsToMenu(playerObj, currentVehicle)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local vehicle = ISVehicleMenu.getVehicleToInteractWith(playerObj)
|
||||
if vehicle == nil then return end
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
if isServer() then return end
|
||||
if not TowBarMod then TowBarMod = {} end
|
||||
TowBarMod.WreckerSync = TowBarMod.WreckerSync or {}
|
||||
|
||||
local Sync = TowBarMod.WreckerSync
|
||||
Sync.appliedLevels = Sync.appliedLevels or {}
|
||||
|
||||
local function pairKeyContainsVehicle(key, vehicleId)
|
||||
local id = tostring(vehicleId)
|
||||
return string.sub(key, 1, #id + 1) == id .. ":"
|
||||
or string.sub(key, -(#id + 1)) == ":" .. id
|
||||
end
|
||||
|
||||
local function clearAppliedLevelForVehicle(vehicle)
|
||||
if not vehicle then return end
|
||||
local vehicleId = vehicle:getId()
|
||||
for key in pairs(Sync.appliedLevels) do
|
||||
if pairKeyContainsVehicle(key, vehicleId) then
|
||||
Sync.appliedLevels[key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function pairKey(wrecker, target)
|
||||
return tostring(wrecker:getId()) .. ":" .. tostring(target:getId())
|
||||
end
|
||||
|
||||
local function callVehicle(vehicle, methodName, value)
|
||||
local method = vehicle and vehicle[methodName] or nil
|
||||
if not method then return false, nil end
|
||||
return pcall(function()
|
||||
if value ~= nil then return method(vehicle, value) end
|
||||
return method(vehicle)
|
||||
end)
|
||||
end
|
||||
|
||||
local function applyFreeRollingState(vehicle)
|
||||
local md = vehicle and vehicle:getModData() or nil
|
||||
if not md then return end
|
||||
local capturedOriginal = false
|
||||
if md.wreckerOriginalMass == nil then
|
||||
md.wreckerOriginalMass = vehicle:getMass()
|
||||
capturedOriginal = true
|
||||
end
|
||||
if md.wreckerOriginalBrakingForce == nil then
|
||||
md.wreckerOriginalBrakingForce = vehicle:getBrakingForce()
|
||||
capturedOriginal = true
|
||||
end
|
||||
if md.wreckerOriginalParkingBrakeOn == nil then
|
||||
local ok, value = callVehicle(vehicle, "isParkingBrakeOn")
|
||||
if ok then
|
||||
md.wreckerOriginalParkingBrakeOn = value
|
||||
capturedOriginal = true
|
||||
end
|
||||
end
|
||||
if md.wreckerOriginalParkingBrake == nil then
|
||||
local ok, value = callVehicle(vehicle, "getParkingBrake")
|
||||
if ok then
|
||||
md.wreckerOriginalParkingBrake = value
|
||||
capturedOriginal = true
|
||||
end
|
||||
end
|
||||
if md.wreckerOriginalHandbrake == nil then
|
||||
local ok, value = callVehicle(vehicle, "isHandbrake")
|
||||
if ok then
|
||||
md.wreckerOriginalHandbrake = value
|
||||
capturedOriginal = true
|
||||
end
|
||||
end
|
||||
vehicle:setMass(200)
|
||||
vehicle:setBrakingForce(0)
|
||||
if md.wreckerOriginalParkingBrakeOn ~= nil then callVehicle(vehicle, "setParkingBrakeOn", false) end
|
||||
if md.wreckerOriginalParkingBrake ~= nil then callVehicle(vehicle, "setParkingBrake", false) end
|
||||
if md.wreckerOriginalHandbrake ~= nil then callVehicle(vehicle, "setHandbrake", false) end
|
||||
if capturedOriginal then vehicle:transmitModData() end
|
||||
end
|
||||
|
||||
local function restoreFreeRollingState(vehicle)
|
||||
local md = vehicle and vehicle:getModData() or nil
|
||||
if not md then return end
|
||||
if md.wreckerOriginalMass ~= nil then vehicle:setMass(md.wreckerOriginalMass) end
|
||||
if md.wreckerOriginalBrakingForce ~= nil then vehicle:setBrakingForce(md.wreckerOriginalBrakingForce) end
|
||||
if md.wreckerOriginalParkingBrakeOn ~= nil then
|
||||
callVehicle(vehicle, "setParkingBrakeOn", md.wreckerOriginalParkingBrakeOn)
|
||||
end
|
||||
if md.wreckerOriginalParkingBrake ~= nil then
|
||||
callVehicle(vehicle, "setParkingBrake", md.wreckerOriginalParkingBrake)
|
||||
end
|
||||
if md.wreckerOriginalHandbrake ~= nil then
|
||||
callVehicle(vehicle, "setHandbrake", md.wreckerOriginalHandbrake)
|
||||
end
|
||||
md.wreckerOriginalMass = nil
|
||||
md.wreckerOriginalBrakingForce = nil
|
||||
md.wreckerOriginalParkingBrakeOn = nil
|
||||
md.wreckerOriginalParkingBrake = nil
|
||||
md.wreckerOriginalHandbrake = nil
|
||||
vehicle:transmitModData()
|
||||
end
|
||||
|
||||
local function isPairLinked(wrecker, target)
|
||||
return wrecker and target
|
||||
and wrecker:getVehicleTowing() == target
|
||||
and target:getVehicleTowedBy() == wrecker
|
||||
end
|
||||
|
||||
local function hasConflictingLink(vehicle, other)
|
||||
return vehicle and ((vehicle:getVehicleTowing() and vehicle:getVehicleTowing() ~= other)
|
||||
or (vehicle:getVehicleTowedBy() and vehicle:getVehicleTowedBy() ~= other))
|
||||
end
|
||||
|
||||
local function breakWreckerPair(wrecker, target)
|
||||
if not wrecker or not target then return end
|
||||
if isPairLinked(wrecker, target) then
|
||||
wrecker:breakConstraint(true, true)
|
||||
end
|
||||
end
|
||||
|
||||
local function setScriptSafely(vehicle, scriptName)
|
||||
if TowBarMod.Hook and TowBarMod.Hook.setVehicleScriptWithTowBarHidden then
|
||||
return TowBarMod.Hook.setVehicleScriptWithTowBarHidden(vehicle, scriptName)
|
||||
end
|
||||
vehicle:setScriptName(scriptName)
|
||||
return true
|
||||
end
|
||||
|
||||
local function applyAttachSync(args)
|
||||
if not args then return end
|
||||
local wrecker = getVehicleById(args.wrecker)
|
||||
local target = getVehicleById(args.target)
|
||||
if not wrecker or not target then return end
|
||||
if hasConflictingLink(wrecker, target) or hasConflictingLink(target, wrecker) then return end
|
||||
|
||||
local hookAttachment = TowBarMod.Wrecker.getHeightAttachmentId(args.heightLevel)
|
||||
local targetAttachment = args.targetAttachment
|
||||
if not hookAttachment or (targetAttachment ~= "trailer" and targetAttachment ~= "trailerfront") then return end
|
||||
if not wrecker:attachmentExist(hookAttachment) or not target:attachmentExist(targetAttachment) then return end
|
||||
|
||||
local key = pairKey(wrecker, target)
|
||||
local canonicalLevel = tonumber(args.heightLevel) or 0
|
||||
local appliedLevel = Sync.appliedLevels[key]
|
||||
if isPairLinked(wrecker, target) and appliedLevel == canonicalLevel then
|
||||
Sync.appliedLevels[key] = canonicalLevel
|
||||
applyFreeRollingState(target)
|
||||
return
|
||||
end
|
||||
|
||||
breakWreckerPair(wrecker, target)
|
||||
local targetMd = target:getModData()
|
||||
local originalScript = targetMd.wreckerOriginalScriptName or target:getScriptName()
|
||||
targetMd.wreckerOriginalScriptName = originalScript
|
||||
applyFreeRollingState(target)
|
||||
|
||||
setScriptSafely(target, "notTowingA_Trailer")
|
||||
wrecker:addPointConstraint(nil, target, hookAttachment, targetAttachment, true)
|
||||
setScriptSafely(target, originalScript)
|
||||
|
||||
local wreckerMd = wrecker:getModData()
|
||||
wreckerMd.wreckerTowActive = true
|
||||
wreckerMd.wreckerTowedVehicleId = target:getId()
|
||||
wreckerMd.wreckerTowedVehicleSqlId = tonumber(args.targetSqlId)
|
||||
wreckerMd.wreckerTargetAttachment = targetAttachment
|
||||
wreckerMd.wreckerHeightLevel = canonicalLevel
|
||||
targetMd.wreckerTowingVehicleId = wrecker:getId()
|
||||
targetMd.wreckerTowingVehicleSqlId = tonumber(args.wreckerSqlId)
|
||||
Sync.appliedLevels[key] = canonicalLevel
|
||||
end
|
||||
|
||||
local function applyDetachSync(args)
|
||||
if not args then return end
|
||||
local wrecker = getVehicleById(args.wrecker)
|
||||
local target = getVehicleById(args.target)
|
||||
if not target then return end
|
||||
local targetMd = target:getModData()
|
||||
local expectedWreckerId = tonumber(targetMd.wreckerTowingVehicleId)
|
||||
local expectedWreckerSqlId = tonumber(targetMd.wreckerTowingVehicleSqlId)
|
||||
local commandWreckerSqlId = tonumber(args.wreckerSqlId)
|
||||
if expectedWreckerSqlId and commandWreckerSqlId then
|
||||
if expectedWreckerSqlId ~= commandWreckerSqlId then return end
|
||||
elseif expectedWreckerId and expectedWreckerId ~= tonumber(args.wrecker) then
|
||||
return
|
||||
end
|
||||
if wrecker and (hasConflictingLink(wrecker, target) or hasConflictingLink(target, wrecker)) then return end
|
||||
if wrecker then breakWreckerPair(wrecker, target) end
|
||||
if targetMd.wreckerOriginalScriptName and target:getScriptName() ~= targetMd.wreckerOriginalScriptName then
|
||||
setScriptSafely(target, targetMd.wreckerOriginalScriptName)
|
||||
end
|
||||
restoreFreeRollingState(target)
|
||||
if wrecker then Sync.appliedLevels[pairKey(wrecker, target)] = nil end
|
||||
|
||||
if wrecker then
|
||||
local wreckerMd = wrecker:getModData()
|
||||
wreckerMd.wreckerTowActive = nil
|
||||
wreckerMd.wreckerTowedVehicleId = nil
|
||||
wreckerMd.wreckerTowedVehicleSqlId = nil
|
||||
wreckerMd.wreckerTargetAttachment = nil
|
||||
wreckerMd.wreckerHeightLevel = nil
|
||||
end
|
||||
targetMd.wreckerTowingVehicleId = nil
|
||||
targetMd.wreckerTowingVehicleSqlId = nil
|
||||
targetMd.wreckerOriginalScriptName = nil
|
||||
end
|
||||
|
||||
Sync.applyAttachSync = applyAttachSync
|
||||
Sync.applyDetachSync = applyDetachSync
|
||||
|
||||
Events.OnServerCommand.Add(function(module, command, args)
|
||||
if module ~= "towbar" then return end
|
||||
if command == "wreckerAttachSync" then
|
||||
applyAttachSync(args)
|
||||
elseif command == "wreckerDetachSync" then
|
||||
applyDetachSync(args)
|
||||
end
|
||||
end)
|
||||
if Events.OnSpawnVehicleEnd then
|
||||
Events.OnSpawnVehicleEnd.Add(clearAppliedLevelForVehicle)
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
require("TimedActions/ISBaseTimedAction")
|
||||
|
||||
WreckerTimedAction = ISBaseTimedAction:derive("WreckerTimedAction")
|
||||
|
||||
function WreckerTimedAction:isValid()
|
||||
return self.isValidFunc ~= nil
|
||||
and self.isValidFunc(self.character, self.arg1, self.arg2) == true
|
||||
end
|
||||
|
||||
function WreckerTimedAction:start()
|
||||
end
|
||||
|
||||
function WreckerTimedAction:perform()
|
||||
if self:isValid() and self.performFunc then
|
||||
self.performFunc(self.character, self.arg1, self.arg2)
|
||||
end
|
||||
ISBaseTimedAction.perform(self)
|
||||
end
|
||||
|
||||
function WreckerTimedAction:stop()
|
||||
ISBaseTimedAction.stop(self)
|
||||
end
|
||||
|
||||
function WreckerTimedAction:new(character, time, isValidFunc, performFunc, arg1, arg2)
|
||||
local action = ISBaseTimedAction.new(self, character)
|
||||
action.maxTime = time
|
||||
action.stopOnWalk = false
|
||||
action.stopOnRun = false
|
||||
action.useProgressBar = true
|
||||
action.isValidFunc = isValidFunc
|
||||
action.performFunc = performFunc
|
||||
action.arg1 = arg1
|
||||
action.arg2 = arg2
|
||||
return action
|
||||
end
|
||||
@@ -0,0 +1,109 @@
|
||||
if isServer() then return end
|
||||
require("TowBar/WreckerTimedAction")
|
||||
if not TowBarMod then TowBarMod = {} end
|
||||
TowBarMod.WreckerUI = TowBarMod.WreckerUI or {}
|
||||
|
||||
local UI = TowBarMod.WreckerUI
|
||||
local Wrecker = TowBarMod.Wrecker
|
||||
local AttachDuration = 300
|
||||
local DetachDuration = 200
|
||||
|
||||
local function sendAttachCommand(playerObj, wrecker, target)
|
||||
if not playerObj or not wrecker or not target then return end
|
||||
sendClientCommand(playerObj, "towbar", "attachWrecker", {
|
||||
wrecker = wrecker:getId(),
|
||||
target = target:getId()
|
||||
})
|
||||
end
|
||||
|
||||
local function sendDetachCommand(playerObj, wrecker, target)
|
||||
if not playerObj or not wrecker or not target then return end
|
||||
sendClientCommand(playerObj, "towbar", "detachWrecker", {
|
||||
wrecker = wrecker:getId(),
|
||||
target = target:getId()
|
||||
})
|
||||
end
|
||||
|
||||
local function isAttachStillValid(playerObj, wrecker, target)
|
||||
if not playerObj or not wrecker or not target then return false end
|
||||
if not wrecker:isDriver(playerObj) or not Wrecker.isSupportedWrecker(wrecker) then return false end
|
||||
if wrecker:getVehicleTowing() or wrecker:getVehicleTowedBy()
|
||||
or target:getVehicleTowing() or target:getVehicleTowedBy() then return false end
|
||||
local nearest = Wrecker.resolveNearestTarget(wrecker, Wrecker.getWorldVehicles())
|
||||
return nearest ~= nil and nearest.vehicle == target
|
||||
end
|
||||
|
||||
local function isDetachStillValid(playerObj, wrecker, target)
|
||||
if not playerObj or not wrecker or not target or not wrecker:isDriver(playerObj) then return false end
|
||||
local md = wrecker:getModData()
|
||||
return wrecker:getVehicleTowing() == target
|
||||
or (md and tonumber(md.wreckerTowedVehicleId) == target:getId())
|
||||
end
|
||||
|
||||
local function requestAttach(playerObj, wrecker, target)
|
||||
if not isAttachStillValid(playerObj, wrecker, target) then return end
|
||||
ISTimedActionQueue.add(WreckerTimedAction:new(
|
||||
playerObj, AttachDuration, isAttachStillValid, sendAttachCommand, wrecker, target
|
||||
))
|
||||
end
|
||||
|
||||
local function requestDetach(playerObj, wrecker, target)
|
||||
if not isDetachStillValid(playerObj, wrecker, target) then return end
|
||||
ISTimedActionQueue.add(WreckerTimedAction:new(
|
||||
playerObj, DetachDuration, isDetachStillValid, sendDetachCommand, wrecker, target
|
||||
))
|
||||
end
|
||||
|
||||
local function requestHeight(playerObj, wrecker, direction)
|
||||
if not playerObj or not wrecker then return end
|
||||
sendClientCommand(playerObj, "towbar", "adjustWreckerHeight", {
|
||||
wrecker = wrecker:getId(),
|
||||
direction = direction
|
||||
})
|
||||
end
|
||||
|
||||
function UI.addOptionsToMenu(playerObj, wrecker)
|
||||
if not playerObj or not wrecker or not Wrecker.isSupportedWrecker(wrecker) then return false end
|
||||
if not wrecker:isDriver(playerObj) then return false end
|
||||
|
||||
local menu = getPlayerRadialMenu(playerObj:getPlayerNum())
|
||||
if not menu then return false end
|
||||
|
||||
local md = wrecker:getModData()
|
||||
local targetId = md and tonumber(md.wreckerTowedVehicleId) or nil
|
||||
local target = targetId and getVehicleById(targetId) or wrecker:getVehicleTowing()
|
||||
if target and (target == wrecker:getVehicleTowing() or targetId == target:getId()) then
|
||||
TowBarMod.UI.removeDefaultDetachOption(playerObj)
|
||||
local level = tonumber(md.wreckerHeightLevel) or 0
|
||||
if level < Wrecker.MaxHeightLevel then
|
||||
menu:addSlice(
|
||||
getText("UI_Text_Towing_heightUp"),
|
||||
getTexture("media/textures/arrow_up.png"),
|
||||
requestHeight, playerObj, wrecker, 1
|
||||
)
|
||||
end
|
||||
if level > Wrecker.MinHeightLevel then
|
||||
menu:addSlice(
|
||||
getText("UI_Text_Towing_heightDown"),
|
||||
getTexture("media/textures/arrow_down.png"),
|
||||
requestHeight, playerObj, wrecker, -1
|
||||
)
|
||||
end
|
||||
menu:addSlice(
|
||||
getText("UI_Text_Towing_detachHook", ISVehicleMenu.getVehicleDisplayName(target)),
|
||||
getTexture("media/textures/untow_car_icon.png"),
|
||||
requestDetach, playerObj, wrecker, target
|
||||
)
|
||||
return true
|
||||
end
|
||||
|
||||
if wrecker:getVehicleTowing() or wrecker:getVehicleTowedBy() then return false end
|
||||
local nearest = Wrecker.resolveNearestTarget(wrecker, Wrecker.getWorldVehicles())
|
||||
if not nearest then return false end
|
||||
menu:addSlice(
|
||||
getText("UI_Text_Towing_attachHook", ISVehicleMenu.getVehicleDisplayName(nearest.vehicle)),
|
||||
getTexture("media/textures/tow_car_icon.png"),
|
||||
requestAttach, playerObj, wrecker, nearest.vehicle
|
||||
)
|
||||
return true
|
||||
end
|
||||
@@ -3,61 +3,54 @@ BTtow.Create = {}
|
||||
BTtow.Init = {}
|
||||
|
||||
local TowbarVariantSize = 24
|
||||
local TowbarNormalStart = 0
|
||||
local TowbarLargeStart = 24
|
||||
local TowbarMaxIndex = TowbarVariantSize - 1
|
||||
local VanillaScaleMin = 1.5
|
||||
local VanillaScaleMax = 2.0
|
||||
local TowbarFirstZ = 1.0
|
||||
local TowbarSlotStep = 0.1
|
||||
-- Measured from Towbar.fbx at its original 0.01 script scale. Keep this in
|
||||
-- lockstep with the client selector so the enlarged mesh's inner end remains
|
||||
-- on the same vehicle-hitbox contact point.
|
||||
local TowbarVisualScale = 2.5
|
||||
local TowbarModelLength = 0.9714089036
|
||||
local TowbarScaledModelLength = TowbarModelLength * TowbarVisualScale
|
||||
local TowbarModelHalfLength = TowbarScaledModelLength / 2
|
||||
|
||||
local function getVehicleModelScale(script)
|
||||
local function getTowbarFrontEdgeZ(script)
|
||||
if not script then return nil end
|
||||
|
||||
local ok, result = pcall(function()
|
||||
return script:getModelScale()
|
||||
local ok, shape = pcall(function()
|
||||
return script:getPhysicsChassisShape()
|
||||
end)
|
||||
if ok and type(result) == "number" then
|
||||
return result
|
||||
end
|
||||
if not ok or not shape then return nil end
|
||||
|
||||
ok, result = pcall(function()
|
||||
local model = script:getModel()
|
||||
if model then
|
||||
return model:getScale()
|
||||
end
|
||||
local shapeOk, shapeZ = pcall(function()
|
||||
return shape:z()
|
||||
end)
|
||||
if not shapeOk or type(shapeZ) ~= "number" or shapeZ <= 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local centerOk, center = pcall(function()
|
||||
return script:getCenterOfMassOffset()
|
||||
end)
|
||||
if ok and type(result) == "number" then
|
||||
return result
|
||||
end
|
||||
if not centerOk or not center then return nil end
|
||||
|
||||
return nil
|
||||
end
|
||||
local zOk, centerZ = pcall(function()
|
||||
return center:z()
|
||||
end)
|
||||
if not zOk or type(centerZ) ~= "number" then return nil end
|
||||
|
||||
local function isVanillaScale(script)
|
||||
local modelScale = getVehicleModelScale(script)
|
||||
if modelScale == nil then
|
||||
return true
|
||||
end
|
||||
|
||||
local configuredMin = TowBarMod and TowBarMod.Config and tonumber(TowBarMod.Config.vanillaTowbarModelScaleMin)
|
||||
local configuredMax = TowBarMod and TowBarMod.Config and tonumber(TowBarMod.Config.vanillaTowbarModelScaleMax)
|
||||
local minScale = configuredMin or VanillaScaleMin
|
||||
local maxScale = configuredMax or VanillaScaleMax
|
||||
return modelScale >= minScale and modelScale <= maxScale
|
||||
end
|
||||
|
||||
local function getTowbarIndexVanilla(script)
|
||||
local z = script:getPhysicsChassisShape():z() / 2 - 0.1
|
||||
local index = math.floor((z * 2 / 3 - 1) * 10)
|
||||
return math.max(0, math.min(TowbarMaxIndex, index))
|
||||
return centerZ + shapeZ / 2
|
||||
end
|
||||
|
||||
local function getTowbarModelSlot(script)
|
||||
if not isVanillaScale(script) then
|
||||
-- KI5/small-scale vehicles use the normal towbar0 mesh at Z=1.
|
||||
return 0
|
||||
end
|
||||
return getTowbarIndexVanilla(script)
|
||||
local frontEdgeZ = getTowbarFrontEdgeZ(script)
|
||||
if frontEdgeZ == nil then return nil end
|
||||
|
||||
-- Model offsets position the mesh origin. Move its center outward so the
|
||||
-- towbar's inner end, rather than its center, meets the vehicle hitbox.
|
||||
local modelCenterZ = frontEdgeZ + TowbarModelHalfLength
|
||||
local index = math.floor(((modelCenterZ - TowbarFirstZ) / TowbarSlotStep) + 0.5)
|
||||
return math.max(0, math.min(TowbarMaxIndex, index))
|
||||
end
|
||||
|
||||
function BTtow.Create.towbar(vehicle, part)
|
||||
@@ -81,7 +74,7 @@ function BTtow.Init.towbar(vehicle, part)
|
||||
if script then
|
||||
local index = getTowbarModelSlot(script)
|
||||
local shouldShowOnThisPart = part:getId() == "towbar"
|
||||
if shouldShowOnThisPart then
|
||||
if shouldShowOnThisPart and index ~= nil then
|
||||
part:setModelVisible("towbar" .. index, true)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
if isClient() then return end
|
||||
require("TowBar/Persistence")
|
||||
|
||||
local TowingCommands = {}
|
||||
local Commands = {}
|
||||
@@ -10,6 +11,9 @@ local pendingSync = {}
|
||||
local snapshotTickCounter = 0
|
||||
local brokenPairAuditTickCounter = 0
|
||||
local confirmedTowPairs = {}
|
||||
local towPairRuntime = {}
|
||||
local RestoreRetryMs = 2000
|
||||
local SustainedBreakMs = 5000
|
||||
local giveTowBar
|
||||
|
||||
TowingCommands.wantNoise = getDebug() or false
|
||||
@@ -88,6 +92,22 @@ local function isLinked(vehicleA, vehicleB)
|
||||
return vehicleA:getVehicleTowing() == vehicleB and vehicleB:getVehicleTowedBy() == vehicleA
|
||||
end
|
||||
|
||||
local function getPersistentVehicleId(vehicle)
|
||||
if not vehicle or not vehicle.getSqlId then return nil end
|
||||
local ok, sqlId = pcall(function() return vehicle:getSqlId() end)
|
||||
if ok and type(sqlId) == "number" and sqlId >= 0 then return sqlId end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function matchesSavedVehicle(runtimeId, sqlId, vehicle)
|
||||
if not vehicle then return false end
|
||||
local stableId = tonumber(sqlId)
|
||||
if stableId and stableId >= 0 then
|
||||
return getPersistentVehicleId(vehicle) == stableId
|
||||
end
|
||||
return tonumber(runtimeId) == vehicle:getId()
|
||||
end
|
||||
|
||||
local function hasAnyTowLink(vehicle)
|
||||
if not vehicle then return false end
|
||||
return vehicle:getVehicleTowing() ~= nil or vehicle:getVehicleTowedBy() ~= nil
|
||||
@@ -95,12 +115,17 @@ end
|
||||
|
||||
local function getTowBarPairKey(vehicleA, vehicleB)
|
||||
if not vehicleA or not vehicleB then return nil end
|
||||
return tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())
|
||||
local vehicleAId = getPersistentVehicleId(vehicleA) or vehicleA:getId()
|
||||
local vehicleBId = getPersistentVehicleId(vehicleB) or vehicleB:getId()
|
||||
return tostring(vehicleAId) .. ":" .. tostring(vehicleBId)
|
||||
end
|
||||
|
||||
local function markTowBarPairConfirmed(vehicleA, vehicleB)
|
||||
local key = getTowBarPairKey(vehicleA, vehicleB)
|
||||
if key then confirmedTowPairs[key] = true end
|
||||
if key then
|
||||
confirmedTowPairs[key] = true
|
||||
towPairRuntime[key] = { confirmed = true }
|
||||
end
|
||||
end
|
||||
|
||||
local function isTowBarPairConfirmed(vehicleA, vehicleB)
|
||||
@@ -110,7 +135,10 @@ end
|
||||
|
||||
local function forgetTowBarPair(vehicleA, vehicleB)
|
||||
local key = getTowBarPairKey(vehicleA, vehicleB)
|
||||
if key then confirmedTowPairs[key] = nil end
|
||||
if key then
|
||||
confirmedTowPairs[key] = nil
|
||||
towPairRuntime[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function hasTowBarState(vehicle)
|
||||
@@ -130,25 +158,35 @@ local function markExpectedTowBarPair(vehicleA, vehicleB, attachmentA, attachmen
|
||||
towingModData["isTowingByTowBar"] = true
|
||||
towingModData["towed"] = false
|
||||
towingModData["towBarTowedVehicleId"] = vehicleB:getId()
|
||||
towingModData["towBarTowedVehicleSqlId"] = getPersistentVehicleId(vehicleB)
|
||||
towingModData["towBarTowingVehicleId"] = nil
|
||||
towingModData["towBarTowingVehicleSqlId"] = nil
|
||||
towingModData["towBarExpectedAttachment"] = attachmentA
|
||||
towedModData["isTowingByTowBar"] = true
|
||||
towedModData["towed"] = true
|
||||
towedModData["towBarTowedVehicleId"] = nil
|
||||
towedModData["towBarTowedVehicleSqlId"] = nil
|
||||
towedModData["towBarTowingVehicleId"] = vehicleA:getId()
|
||||
towedModData["towBarTowingVehicleSqlId"] = getPersistentVehicleId(vehicleA)
|
||||
towedModData["towBarExpectedAttachment"] = attachmentB
|
||||
vehicleA:transmitModData()
|
||||
vehicleB:transmitModData()
|
||||
TowBarMod.Persistence.savePair("towbar", vehicleA, vehicleB, attachmentA, attachmentB)
|
||||
end
|
||||
|
||||
local function clearExpectedTowBarPair(vehicleA, vehicleB)
|
||||
if vehicleA and vehicleB then
|
||||
TowBarMod.Persistence.removePair("towbar", vehicleA, vehicleB)
|
||||
end
|
||||
if vehicleA then
|
||||
local towingModData = vehicleA:getModData()
|
||||
if towingModData then
|
||||
towingModData["isTowingByTowBar"] = false
|
||||
towingModData["towed"] = false
|
||||
towingModData["towBarTowedVehicleId"] = nil
|
||||
towingModData["towBarTowedVehicleSqlId"] = nil
|
||||
towingModData["towBarTowingVehicleId"] = nil
|
||||
towingModData["towBarTowingVehicleSqlId"] = nil
|
||||
towingModData["towBarExpectedAttachment"] = nil
|
||||
vehicleA:transmitModData()
|
||||
end
|
||||
@@ -160,7 +198,9 @@ local function clearExpectedTowBarPair(vehicleA, vehicleB)
|
||||
towedModData["isTowingByTowBar"] = false
|
||||
towedModData["towed"] = false
|
||||
towedModData["towBarTowedVehicleId"] = nil
|
||||
towedModData["towBarTowedVehicleSqlId"] = nil
|
||||
towedModData["towBarTowingVehicleId"] = nil
|
||||
towedModData["towBarTowingVehicleSqlId"] = nil
|
||||
towedModData["towBarExpectedAttachment"] = nil
|
||||
vehicleB:transmitModData()
|
||||
end
|
||||
@@ -174,8 +214,11 @@ local function isExpectedTowBarPair(vehicleA, vehicleB)
|
||||
local towedModData = vehicleB:getModData()
|
||||
if not towingModData or not towedModData then return false end
|
||||
|
||||
return towingModData["towBarTowedVehicleId"] == vehicleB:getId()
|
||||
and towedModData["towBarTowingVehicleId"] == vehicleA:getId()
|
||||
return matchesSavedVehicle(
|
||||
towingModData["towBarTowedVehicleId"], towingModData["towBarTowedVehicleSqlId"], vehicleB
|
||||
) and matchesSavedVehicle(
|
||||
towedModData["towBarTowingVehicleId"], towedModData["towBarTowingVehicleSqlId"], vehicleA
|
||||
)
|
||||
end
|
||||
|
||||
local function isLegacyTowBarPair(vehicleA, vehicleB)
|
||||
@@ -295,14 +338,34 @@ local function forEachCollectionItem(collection, callback)
|
||||
end
|
||||
end
|
||||
|
||||
local function findLoadedVehicle(runtimeId, sqlId)
|
||||
local stableId = tonumber(sqlId)
|
||||
if stableId and stableId >= 0 then
|
||||
local cell = getCell()
|
||||
local vehicles = cell and cell:getVehicles() or nil
|
||||
local match
|
||||
forEachCollectionItem(vehicles, function(vehicle)
|
||||
if not match and getPersistentVehicleId(vehicle) == stableId then match = vehicle end
|
||||
end)
|
||||
return match
|
||||
end
|
||||
return runtimeId and getVehicleById(tonumber(runtimeId)) or nil
|
||||
end
|
||||
|
||||
local function broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB)
|
||||
if not vehicleA or not vehicleB then return end
|
||||
sendServerCommand("towbar", "forceAttachSync", {
|
||||
local args = {
|
||||
vehicleA = vehicleA:getId(),
|
||||
vehicleB = vehicleB:getId(),
|
||||
attachmentA = attachmentA,
|
||||
attachmentB = attachmentB
|
||||
})
|
||||
}
|
||||
if isServer() then
|
||||
sendServerCommand("towbar", "forceAttachSync", args)
|
||||
elseif not isClient() and TowBarMod and TowBarMod.Sync
|
||||
and TowBarMod.Sync.applyAttachSync then
|
||||
TowBarMod.Sync.applyAttachSync(args)
|
||||
end
|
||||
end
|
||||
|
||||
local function broadcastDetach(vehicleAId, vehicleBId)
|
||||
@@ -320,6 +383,33 @@ local function broadcastSpontaneousDetach(vehicleA, vehicleB)
|
||||
})
|
||||
end
|
||||
|
||||
local function restorePersistedTowBarPair(towingVehicle, towedVehicle)
|
||||
if not isExpectedTowBarPair(towingVehicle, towedVehicle)
|
||||
or hasAnyTowLink(towingVehicle) or hasAnyTowLink(towedVehicle) then
|
||||
return false
|
||||
end
|
||||
|
||||
local towingModData = towingVehicle:getModData()
|
||||
local towedModData = towedVehicle:getModData()
|
||||
local attachmentA = towingModData["towBarExpectedAttachment"] or "trailer"
|
||||
local attachmentB = towedModData["towBarExpectedAttachment"] or "trailerfront"
|
||||
if not towingVehicle:attachmentExist(attachmentA)
|
||||
or not towedVehicle:attachmentExist(attachmentB) then
|
||||
return false
|
||||
end
|
||||
|
||||
markExpectedTowBarPair(towingVehicle, towedVehicle, attachmentA, attachmentB)
|
||||
if isServer() then
|
||||
towingVehicle:addPointConstraint(nil, towedVehicle, attachmentA, attachmentB)
|
||||
end
|
||||
broadcastAttach(towingVehicle, towedVehicle, attachmentA, attachmentB)
|
||||
if isLinked(towingVehicle, towedVehicle) then
|
||||
markTowBarPairConfirmed(towingVehicle, towedVehicle)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function breakTowBarConstraint(towingVehicle, towedVehicle)
|
||||
if not towingVehicle or not towedVehicle then return end
|
||||
|
||||
@@ -400,19 +490,80 @@ local function reconcileBrokenTowBarPairsServer()
|
||||
local vehicles = cell:getVehicles()
|
||||
if not vehicles then return end
|
||||
|
||||
local loadedBySqlId = {}
|
||||
forEachCollectionItem(vehicles, function(vehicle)
|
||||
local sqlId = getPersistentVehicleId(vehicle)
|
||||
if sqlId then loadedBySqlId[sqlId] = vehicle end
|
||||
end)
|
||||
|
||||
local processed = {}
|
||||
local function reconcilePair(towingVehicle, towedVehicle, attachmentA, attachmentB)
|
||||
if not towingVehicle or not towedVehicle then return end
|
||||
local key = getTowBarPairKey(towingVehicle, towedVehicle)
|
||||
if not key or processed[key] then return end
|
||||
processed[key] = true
|
||||
|
||||
if not isExpectedTowBarPair(towingVehicle, towedVehicle) then
|
||||
markExpectedTowBarPair(
|
||||
towingVehicle, towedVehicle,
|
||||
attachmentA or "trailer", attachmentB or "trailerfront"
|
||||
)
|
||||
end
|
||||
if not isExpectedTowBarPair(towingVehicle, towedVehicle) then return end
|
||||
|
||||
local now = getTimestampMs()
|
||||
local state = towPairRuntime[key] or {}
|
||||
local linked = isLinked(towingVehicle, towedVehicle)
|
||||
local occupied = hasAnyTowLink(towingVehicle) or hasAnyTowLink(towedVehicle)
|
||||
local action
|
||||
state, action = TowBarMod.Persistence.advanceRecoveryState(
|
||||
state, now, linked, occupied, RestoreRetryMs, SustainedBreakMs
|
||||
)
|
||||
towPairRuntime[key] = state
|
||||
if action == "adopt" then
|
||||
local towingMd = towingVehicle:getModData()
|
||||
local towedMd = towedVehicle:getModData()
|
||||
local savedAttachmentA = towingMd["towBarExpectedAttachment"] or attachmentA or "trailer"
|
||||
local savedAttachmentB = towedMd["towBarExpectedAttachment"] or attachmentB or "trailerfront"
|
||||
if tonumber(towingMd["towBarTowedVehicleId"]) ~= towedVehicle:getId()
|
||||
or tonumber(towedMd["towBarTowingVehicleId"]) ~= towingVehicle:getId() then
|
||||
markExpectedTowBarPair(
|
||||
towingVehicle, towedVehicle,
|
||||
savedAttachmentA, savedAttachmentB
|
||||
)
|
||||
end
|
||||
TowBarMod.Persistence.savePair(
|
||||
"towbar", towingVehicle, towedVehicle, savedAttachmentA, savedAttachmentB
|
||||
)
|
||||
confirmedTowPairs[key] = true
|
||||
elseif action == "break" then
|
||||
finalizeBrokenTowBarPair(towingVehicle, towedVehicle, "sustained-physical-link-audit")
|
||||
elseif action == "restore" then
|
||||
restorePersistedTowBarPair(towingVehicle, towedVehicle)
|
||||
end
|
||||
end
|
||||
|
||||
TowBarMod.Persistence.forEachPair("towbar", function(record)
|
||||
local towingVehicle = loadedBySqlId[tonumber(record.towingSqlId)]
|
||||
local towedVehicle = loadedBySqlId[tonumber(record.towedSqlId)]
|
||||
local key = tostring(record.towingSqlId) .. ":" .. tostring(record.towedSqlId)
|
||||
towPairRuntime[key] = TowBarMod.Persistence.notePeerAvailability(
|
||||
towPairRuntime[key], towingVehicle ~= nil and towedVehicle ~= nil
|
||||
)
|
||||
reconcilePair(
|
||||
towingVehicle,
|
||||
towedVehicle,
|
||||
record.attachmentA,
|
||||
record.attachmentB
|
||||
)
|
||||
end)
|
||||
|
||||
forEachCollectionItem(vehicles, function(towingVehicle)
|
||||
local towingModData = towingVehicle and towingVehicle:getModData() or nil
|
||||
local towedVehicleId = towingModData and towingModData["towBarTowedVehicleId"] or nil
|
||||
local towedVehicle = towedVehicleId and getVehicleById(towedVehicleId) or nil
|
||||
if towingVehicle and towedVehicle and isExpectedTowBarPair(towingVehicle, towedVehicle) then
|
||||
if isLinked(towingVehicle, towedVehicle) then
|
||||
markTowBarPairConfirmed(towingVehicle, towedVehicle)
|
||||
elseif isTowBarPairConfirmed(towingVehicle, towedVehicle)
|
||||
and not hasAnyTowLink(towingVehicle)
|
||||
and not hasAnyTowLink(towedVehicle) then
|
||||
finalizeBrokenTowBarPair(towingVehicle, towedVehicle, "physical-link-audit")
|
||||
end
|
||||
end
|
||||
local towedVehicleSqlId = towingModData and towingModData["towBarTowedVehicleSqlId"] or nil
|
||||
local towedVehicle = findLoadedVehicle(towedVehicleId, towedVehicleSqlId)
|
||||
if towingVehicle and towedVehicle then reconcilePair(towingVehicle, towedVehicle) end
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
if isClient() then return end
|
||||
require("TowBar/Persistence")
|
||||
if not TowBarMod or not TowBarMod.Wrecker then return end
|
||||
|
||||
local Wrecker = TowBarMod.Wrecker
|
||||
local Commands = {}
|
||||
local AuditInterval = 30
|
||||
local SnapshotInterval = 120
|
||||
local CommandCooldownMs = 150
|
||||
local auditTicks = 0
|
||||
local snapshotTicks = 0
|
||||
local commandTimes = {}
|
||||
local confirmedWreckerPairs = {}
|
||||
local wreckerPairRuntime = {}
|
||||
local RestoreRetryMs = 2000
|
||||
local SustainedBreakMs = 5000
|
||||
|
||||
local function isInteger(value)
|
||||
return type(value) == "number" and value == math.floor(value)
|
||||
end
|
||||
|
||||
local function resolveVehicleId(value)
|
||||
if not isInteger(value) or value < 0 then return nil end
|
||||
return getVehicleById(value)
|
||||
end
|
||||
|
||||
local function isLinked(wrecker, target)
|
||||
return wrecker and target
|
||||
and wrecker:getVehicleTowing() == target
|
||||
and target:getVehicleTowedBy() == wrecker
|
||||
end
|
||||
|
||||
local function getPersistentVehicleId(vehicle)
|
||||
if not vehicle or not vehicle.getSqlId then return nil end
|
||||
local ok, sqlId = pcall(function() return vehicle:getSqlId() end)
|
||||
if ok and type(sqlId) == "number" and sqlId >= 0 then return sqlId end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function findLoadedVehicle(runtimeId, sqlId)
|
||||
local stableId = tonumber(sqlId)
|
||||
if stableId and stableId >= 0 then
|
||||
local vehicles = Wrecker.getWorldVehicles()
|
||||
local iterator = vehicles and vehicles:iterator() or nil
|
||||
while iterator and iterator:hasNext() do
|
||||
local vehicle = iterator:next()
|
||||
if getPersistentVehicleId(vehicle) == stableId then return vehicle end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
return runtimeId and getVehicleById(tonumber(runtimeId)) or nil
|
||||
end
|
||||
|
||||
local function matchesSavedVehicle(runtimeId, sqlId, vehicle)
|
||||
if not vehicle then return false end
|
||||
local stableId = tonumber(sqlId)
|
||||
if stableId and stableId >= 0 then
|
||||
return getPersistentVehicleId(vehicle) == stableId
|
||||
end
|
||||
return tonumber(runtimeId) == vehicle:getId()
|
||||
end
|
||||
|
||||
local function pairKey(wrecker, target)
|
||||
local wreckerId = getPersistentVehicleId(wrecker) or wrecker:getId()
|
||||
local targetId = getPersistentVehicleId(target) or target:getId()
|
||||
return tostring(wreckerId) .. ":" .. tostring(targetId)
|
||||
end
|
||||
|
||||
|
||||
local function markWreckerPairConfirmed(wrecker, target)
|
||||
local key = pairKey(wrecker, target)
|
||||
confirmedWreckerPairs[key] = true
|
||||
wreckerPairRuntime[key] = { confirmed = true }
|
||||
end
|
||||
|
||||
local function isWreckerPairConfirmed(wrecker, target)
|
||||
return confirmedWreckerPairs[pairKey(wrecker, target)] == true
|
||||
end
|
||||
|
||||
local function forgetWreckerPair(wrecker, target)
|
||||
local key = pairKey(wrecker, target)
|
||||
confirmedWreckerPairs[key] = nil
|
||||
wreckerPairRuntime[key] = nil
|
||||
end
|
||||
|
||||
local function isExpectedPair(wrecker, target)
|
||||
if not wrecker or not target then return false end
|
||||
local wreckerMd = wrecker:getModData()
|
||||
local targetMd = target:getModData()
|
||||
return matchesSavedVehicle(
|
||||
wreckerMd.wreckerTowedVehicleId, wreckerMd.wreckerTowedVehicleSqlId, target
|
||||
) and matchesSavedVehicle(
|
||||
targetMd.wreckerTowingVehicleId, targetMd.wreckerTowingVehicleSqlId, wrecker
|
||||
)
|
||||
end
|
||||
|
||||
local function setPairState(wrecker, target, targetAttachment, heightLevel)
|
||||
local wreckerMd = wrecker:getModData()
|
||||
local targetMd = target:getModData()
|
||||
wreckerMd.wreckerTowActive = true
|
||||
wreckerMd.wreckerTowedVehicleId = target:getId()
|
||||
wreckerMd.wreckerTowedVehicleSqlId = getPersistentVehicleId(target)
|
||||
wreckerMd.wreckerTowingVehicleId = nil
|
||||
wreckerMd.wreckerTowingVehicleSqlId = nil
|
||||
wreckerMd.wreckerTargetAttachment = targetAttachment
|
||||
wreckerMd.wreckerHeightLevel = Wrecker.normalizeHeightLevel(heightLevel)
|
||||
targetMd.wreckerTowingVehicleId = wrecker:getId()
|
||||
targetMd.wreckerTowingVehicleSqlId = getPersistentVehicleId(wrecker)
|
||||
targetMd.wreckerTowedVehicleId = nil
|
||||
targetMd.wreckerTowedVehicleSqlId = nil
|
||||
wrecker:transmitModData()
|
||||
target:transmitModData()
|
||||
TowBarMod.Persistence.savePair(
|
||||
"wrecker", wrecker, target,
|
||||
Wrecker.getHeightAttachmentId(wreckerMd.wreckerHeightLevel),
|
||||
targetAttachment,
|
||||
wreckerMd.wreckerHeightLevel
|
||||
)
|
||||
end
|
||||
|
||||
local function clearPairState(wrecker, target)
|
||||
if not wrecker or not target then return end
|
||||
local wreckerMd = wrecker:getModData()
|
||||
local targetMd = target:getModData()
|
||||
TowBarMod.Persistence.removePair("wrecker", wrecker, target)
|
||||
wreckerMd.wreckerTowActive = nil
|
||||
wreckerMd.wreckerTowedVehicleId = nil
|
||||
wreckerMd.wreckerTowedVehicleSqlId = nil
|
||||
wreckerMd.wreckerTowingVehicleId = nil
|
||||
wreckerMd.wreckerTowingVehicleSqlId = nil
|
||||
wreckerMd.wreckerTargetAttachment = nil
|
||||
wreckerMd.wreckerHeightLevel = nil
|
||||
targetMd.wreckerTowingVehicleId = nil
|
||||
targetMd.wreckerTowingVehicleSqlId = nil
|
||||
targetMd.wreckerTowedVehicleId = nil
|
||||
targetMd.wreckerTowedVehicleSqlId = nil
|
||||
wrecker:transmitModData()
|
||||
target:transmitModData()
|
||||
end
|
||||
|
||||
local function broadcastWreckerSync(command, args)
|
||||
if isServer() then
|
||||
sendServerCommand("towbar", command, args)
|
||||
elseif TowBarMod.WreckerSync then
|
||||
if command == "wreckerAttachSync" then
|
||||
TowBarMod.WreckerSync.applyAttachSync(args)
|
||||
else
|
||||
TowBarMod.WreckerSync.applyDetachSync(args)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function syncPairState(wrecker, target, hookAttachment, targetAttachment, heightLevel, player)
|
||||
if isServer() then
|
||||
-- Dedicated servers own the logical relation; clients replace their
|
||||
-- local rope with the rigid height-indexed constraint on sync.
|
||||
wrecker:addPointConstraint(player, target, hookAttachment, targetAttachment)
|
||||
end
|
||||
broadcastWreckerSync("wreckerAttachSync", {
|
||||
wrecker = wrecker:getId(), target = target:getId(),
|
||||
wreckerSqlId = getPersistentVehicleId(wrecker),
|
||||
targetSqlId = getPersistentVehicleId(target),
|
||||
targetAttachment = targetAttachment, heightLevel = heightLevel
|
||||
})
|
||||
if isLinked(wrecker, target) then markWreckerPairConfirmed(wrecker, target) end
|
||||
end
|
||||
|
||||
local function breakPair(wrecker, target)
|
||||
if isLinked(wrecker, target) then
|
||||
wrecker:breakConstraint(true, false)
|
||||
end
|
||||
end
|
||||
|
||||
local function detachPair(wrecker, target)
|
||||
if not isExpectedPair(wrecker, target) then return false end
|
||||
breakPair(wrecker, target)
|
||||
forgetWreckerPair(wrecker, target)
|
||||
clearPairState(wrecker, target)
|
||||
broadcastWreckerSync("wreckerDetachSync", {
|
||||
wrecker = wrecker:getId(), target = target:getId(),
|
||||
wreckerSqlId = getPersistentVehicleId(wrecker)
|
||||
})
|
||||
return true
|
||||
end
|
||||
|
||||
function Commands.attachWrecker(player, args)
|
||||
if type(args) ~= "table" then return end
|
||||
local wrecker = resolveVehicleId(args.wrecker)
|
||||
local target = resolveVehicleId(args.target)
|
||||
if not wrecker or not target or wrecker == target then return end
|
||||
if not wrecker:isDriver(player) or not Wrecker.isSupportedWrecker(wrecker) then return end
|
||||
if wrecker:getVehicleTowing() or wrecker:getVehicleTowedBy()
|
||||
or target:getVehicleTowing() or target:getVehicleTowedBy() then return end
|
||||
|
||||
local nearest = Wrecker.resolveNearestTarget(wrecker, Wrecker.getWorldVehicles())
|
||||
if not nearest or nearest.vehicle ~= target then return end
|
||||
local targetAttachment = nearest.attachment
|
||||
local heightLevel = 0
|
||||
local hookAttachment = Wrecker.getHeightAttachmentId(heightLevel)
|
||||
if not hookAttachment or not wrecker:attachmentExist(hookAttachment) then return end
|
||||
|
||||
setPairState(wrecker, target, targetAttachment, heightLevel)
|
||||
syncPairState(wrecker, target, hookAttachment, targetAttachment, heightLevel, player)
|
||||
end
|
||||
|
||||
function Commands.detachWrecker(player, args)
|
||||
if type(args) ~= "table" then return end
|
||||
local wrecker = resolveVehicleId(args.wrecker)
|
||||
local target = resolveVehicleId(args.target)
|
||||
if not wrecker or not target or not wrecker:isDriver(player) then return end
|
||||
detachPair(wrecker, target)
|
||||
end
|
||||
|
||||
function Commands.adjustWreckerHeight(player, args)
|
||||
if type(args) ~= "table" then return end
|
||||
local wrecker = resolveVehicleId(args.wrecker)
|
||||
local direction = args.direction
|
||||
if not wrecker or not wrecker:isDriver(player) or not Wrecker.isSupportedWrecker(wrecker) then return end
|
||||
if direction ~= -1 and direction ~= 1 then return end
|
||||
|
||||
local md = wrecker:getModData()
|
||||
local target = findLoadedVehicle(md.wreckerTowedVehicleId, md.wreckerTowedVehicleSqlId)
|
||||
if not target or not isExpectedPair(wrecker, target) or not isLinked(wrecker, target) then return end
|
||||
local currentLevel = tonumber(md.wreckerHeightLevel) or 0
|
||||
local nextLevel = Wrecker.nextHeightLevel(currentLevel, direction)
|
||||
if not nextLevel or nextLevel == currentLevel then return end
|
||||
local hookAttachment = Wrecker.getHeightAttachmentId(nextLevel)
|
||||
local targetAttachment = md.wreckerTargetAttachment
|
||||
if not hookAttachment or not wrecker:attachmentExist(hookAttachment)
|
||||
or (targetAttachment ~= "trailer" and targetAttachment ~= "trailerfront") then return end
|
||||
|
||||
breakPair(wrecker, target)
|
||||
setPairState(wrecker, target, targetAttachment, nextLevel)
|
||||
syncPairState(wrecker, target, hookAttachment, targetAttachment, nextLevel, player)
|
||||
end
|
||||
|
||||
local function onClientCommand(module, command, player, args)
|
||||
if module == "towbar" and Commands[command] then
|
||||
if not player then return end
|
||||
local playerTimes = commandTimes[player]
|
||||
if not playerTimes then
|
||||
playerTimes = {}
|
||||
commandTimes[player] = playerTimes
|
||||
end
|
||||
local now = getTimestampMs()
|
||||
local previous = playerTimes[command]
|
||||
if previous and now - previous < CommandCooldownMs then return end
|
||||
playerTimes[command] = now
|
||||
Commands[command](player, args)
|
||||
return
|
||||
end
|
||||
if module == "vehicle" and command == "detachTrailerSpontaneous" then
|
||||
local vehicle = args and resolveVehicleId(args.vehicle) or nil
|
||||
if not vehicle then return end
|
||||
local md = vehicle:getModData()
|
||||
local wrecker = (md.wreckerTowedVehicleId or md.wreckerTowedVehicleSqlId) and vehicle or nil
|
||||
local target = wrecker and findLoadedVehicle(
|
||||
md.wreckerTowedVehicleId, md.wreckerTowedVehicleSqlId
|
||||
) or nil
|
||||
if not wrecker and (md.wreckerTowingVehicleId or md.wreckerTowingVehicleSqlId) then
|
||||
wrecker = findLoadedVehicle(md.wreckerTowingVehicleId, md.wreckerTowingVehicleSqlId)
|
||||
target = vehicle
|
||||
end
|
||||
if wrecker and target then detachPair(wrecker, target) end
|
||||
end
|
||||
end
|
||||
|
||||
local function auditWreckerPairs()
|
||||
auditTicks = auditTicks + 1
|
||||
if auditTicks < AuditInterval then return end
|
||||
auditTicks = 0
|
||||
snapshotTicks = snapshotTicks + AuditInterval
|
||||
local shouldSnapshot = snapshotTicks >= SnapshotInterval
|
||||
if shouldSnapshot then snapshotTicks = 0 end
|
||||
local vehicles = Wrecker.getWorldVehicles()
|
||||
if not vehicles then return end
|
||||
|
||||
local loadedBySqlId = {}
|
||||
local scan = vehicles:iterator()
|
||||
while scan:hasNext() do
|
||||
local vehicle = scan:next()
|
||||
local sqlId = getPersistentVehicleId(vehicle)
|
||||
if sqlId then loadedBySqlId[sqlId] = vehicle end
|
||||
end
|
||||
|
||||
local processed = {}
|
||||
local function reconcilePair(wrecker, target, targetAttachment, heightLevel)
|
||||
if not wrecker or not target or not Wrecker.isSupportedWrecker(wrecker) then return end
|
||||
local key = pairKey(wrecker, target)
|
||||
if processed[key] then return end
|
||||
processed[key] = true
|
||||
|
||||
local canonicalLevel = Wrecker.normalizeHeightLevel(heightLevel)
|
||||
local canonicalTargetAttachment = targetAttachment
|
||||
if canonicalTargetAttachment ~= "trailer" and canonicalTargetAttachment ~= "trailerfront" then return end
|
||||
local hookAttachment = Wrecker.getHeightAttachmentId(canonicalLevel)
|
||||
if not hookAttachment or not wrecker:attachmentExist(hookAttachment)
|
||||
or not target:attachmentExist(canonicalTargetAttachment) then return end
|
||||
|
||||
if not isExpectedPair(wrecker, target) then
|
||||
setPairState(wrecker, target, canonicalTargetAttachment, canonicalLevel)
|
||||
end
|
||||
if not isExpectedPair(wrecker, target) then return end
|
||||
|
||||
local now = getTimestampMs()
|
||||
local state = wreckerPairRuntime[key] or {}
|
||||
local linked = isLinked(wrecker, target)
|
||||
local occupied = wrecker:getVehicleTowing() or wrecker:getVehicleTowedBy()
|
||||
or target:getVehicleTowing() or target:getVehicleTowedBy()
|
||||
local action
|
||||
state, action = TowBarMod.Persistence.advanceRecoveryState(
|
||||
state, now, linked, occupied ~= nil, RestoreRetryMs, SustainedBreakMs
|
||||
)
|
||||
wreckerPairRuntime[key] = state
|
||||
if action == "adopt" then
|
||||
local wreckerMd = wrecker:getModData()
|
||||
local targetMd = target:getModData()
|
||||
if tonumber(wreckerMd.wreckerTowedVehicleId) ~= target:getId()
|
||||
or tonumber(targetMd.wreckerTowingVehicleId) ~= wrecker:getId()
|
||||
or tonumber(wreckerMd.wreckerTowedVehicleSqlId) ~= getPersistentVehicleId(target)
|
||||
or tonumber(targetMd.wreckerTowingVehicleSqlId) ~= getPersistentVehicleId(wrecker) then
|
||||
setPairState(wrecker, target, canonicalTargetAttachment, canonicalLevel)
|
||||
end
|
||||
TowBarMod.Persistence.savePair(
|
||||
"wrecker", wrecker, target, hookAttachment, canonicalTargetAttachment, canonicalLevel
|
||||
)
|
||||
confirmedWreckerPairs[key] = true
|
||||
if shouldSnapshot then
|
||||
broadcastWreckerSync("wreckerAttachSync", {
|
||||
wrecker = wrecker:getId(), target = target:getId(),
|
||||
wreckerSqlId = getPersistentVehicleId(wrecker),
|
||||
targetSqlId = getPersistentVehicleId(target),
|
||||
targetAttachment = canonicalTargetAttachment,
|
||||
heightLevel = canonicalLevel
|
||||
})
|
||||
end
|
||||
elseif action == "break" then
|
||||
detachPair(wrecker, target)
|
||||
elseif action == "restore" then
|
||||
setPairState(wrecker, target, canonicalTargetAttachment, canonicalLevel)
|
||||
syncPairState(wrecker, target, hookAttachment, canonicalTargetAttachment, canonicalLevel, nil)
|
||||
end
|
||||
end
|
||||
|
||||
TowBarMod.Persistence.forEachPair("wrecker", function(record)
|
||||
local wrecker = loadedBySqlId[tonumber(record.towingSqlId)]
|
||||
local target = loadedBySqlId[tonumber(record.towedSqlId)]
|
||||
local key = tostring(record.towingSqlId) .. ":" .. tostring(record.towedSqlId)
|
||||
wreckerPairRuntime[key] = TowBarMod.Persistence.notePeerAvailability(
|
||||
wreckerPairRuntime[key], wrecker ~= nil and target ~= nil
|
||||
)
|
||||
reconcilePair(
|
||||
wrecker,
|
||||
target,
|
||||
record.attachmentB,
|
||||
record.heightLevel
|
||||
)
|
||||
end)
|
||||
|
||||
local iterator = vehicles:iterator()
|
||||
while iterator:hasNext() do
|
||||
local vehicle = iterator:next()
|
||||
local vehicleMd = vehicle:getModData()
|
||||
local targetId = tonumber(vehicleMd.wreckerTowedVehicleId)
|
||||
local targetSqlId = tonumber(vehicleMd.wreckerTowedVehicleSqlId)
|
||||
if targetId or targetSqlId then
|
||||
local wrecker = vehicle
|
||||
local target = findLoadedVehicle(targetId, targetSqlId)
|
||||
if target then
|
||||
reconcilePair(
|
||||
wrecker, target,
|
||||
vehicleMd.wreckerTargetAttachment,
|
||||
vehicleMd.wreckerHeightLevel
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
local towingVehicleId = tonumber(vehicleMd.wreckerTowingVehicleId)
|
||||
local towingVehicleSqlId = tonumber(vehicleMd.wreckerTowingVehicleSqlId)
|
||||
if towingVehicleId or towingVehicleSqlId then
|
||||
local towingVehicle = findLoadedVehicle(towingVehicleId, towingVehicleSqlId)
|
||||
if towingVehicle and not isExpectedPair(towingVehicle, vehicle) then
|
||||
vehicleMd.wreckerTowingVehicleId = nil
|
||||
vehicleMd.wreckerTowingVehicleSqlId = nil
|
||||
vehicle:transmitModData()
|
||||
broadcastWreckerSync("wreckerDetachSync", {
|
||||
wrecker = towingVehicleId,
|
||||
wreckerSqlId = towingVehicleSqlId,
|
||||
target = vehicle:getId()
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Events.OnClientCommand.Add(onClientCommand)
|
||||
Events.OnTick.Add(auditWreckerPairs)
|
||||
@@ -0,0 +1,122 @@
|
||||
if not TowBarMod then TowBarMod = {} end
|
||||
TowBarMod.Persistence = TowBarMod.Persistence or {}
|
||||
|
||||
local Persistence = TowBarMod.Persistence
|
||||
local RegistryName = "TowBar.PersistentPairsV2"
|
||||
|
||||
local function getRegistry()
|
||||
if not ModData or not ModData.getOrCreate then return nil end
|
||||
local registry = ModData.getOrCreate(RegistryName)
|
||||
registry.pairs = registry.pairs or {}
|
||||
return registry
|
||||
end
|
||||
|
||||
local function getSqlId(vehicle)
|
||||
if not vehicle or not vehicle.getSqlId then return nil end
|
||||
local ok, sqlId = pcall(function() return vehicle:getSqlId() end)
|
||||
if ok and type(sqlId) == "number" and sqlId >= 0 then return sqlId end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function makeKey(kind, towingSqlId, towedSqlId)
|
||||
if type(kind) ~= "string" or type(towingSqlId) ~= "number" or type(towedSqlId) ~= "number" then
|
||||
return nil
|
||||
end
|
||||
return kind .. ":" .. tostring(towingSqlId) .. ":" .. tostring(towedSqlId)
|
||||
end
|
||||
|
||||
local function transmit()
|
||||
if isServer and isServer() and ModData.transmit then
|
||||
ModData.transmit(RegistryName)
|
||||
end
|
||||
end
|
||||
|
||||
function Persistence.savePair(kind, towingVehicle, towedVehicle, attachmentA, attachmentB, heightLevel)
|
||||
local towingSqlId = getSqlId(towingVehicle)
|
||||
local towedSqlId = getSqlId(towedVehicle)
|
||||
local key = makeKey(kind, towingSqlId, towedSqlId)
|
||||
local registry = getRegistry()
|
||||
if not key or not registry then return false end
|
||||
local previous = registry.pairs[key]
|
||||
if previous and previous.attachmentA == attachmentA and previous.attachmentB == attachmentB
|
||||
and previous.heightLevel == heightLevel then
|
||||
return true
|
||||
end
|
||||
registry.pairs[key] = {
|
||||
kind = kind,
|
||||
towingSqlId = towingSqlId,
|
||||
towedSqlId = towedSqlId,
|
||||
attachmentA = attachmentA,
|
||||
attachmentB = attachmentB,
|
||||
heightLevel = heightLevel
|
||||
}
|
||||
transmit()
|
||||
return true
|
||||
end
|
||||
|
||||
function Persistence.getPair(kind, towingVehicle, towedVehicle)
|
||||
local key = makeKey(kind, getSqlId(towingVehicle), getSqlId(towedVehicle))
|
||||
local registry = getRegistry()
|
||||
return key and registry and registry.pairs[key] or nil
|
||||
end
|
||||
|
||||
function Persistence.removePair(kind, towingVehicle, towedVehicle)
|
||||
local key = makeKey(kind, getSqlId(towingVehicle), getSqlId(towedVehicle))
|
||||
local registry = getRegistry()
|
||||
if not key or not registry or registry.pairs[key] == nil then return false end
|
||||
registry.pairs[key] = nil
|
||||
transmit()
|
||||
return true
|
||||
end
|
||||
|
||||
function Persistence.forEachPair(kind, callback)
|
||||
local registry = getRegistry()
|
||||
if not registry or not callback then return end
|
||||
for _, record in pairs(registry.pairs) do
|
||||
if type(record) == "table" and record.kind == kind then callback(record) end
|
||||
end
|
||||
end
|
||||
|
||||
function Persistence.notePeerAvailability(state, available)
|
||||
state = state or {}
|
||||
if not available then
|
||||
state.peerMissing = true
|
||||
return state
|
||||
end
|
||||
if state.peerMissing then
|
||||
-- A missing vehicle means its cell was not loaded. When both members
|
||||
-- return, treat the absent native constraint as load recovery rather
|
||||
-- than a runtime break of a previously confirmed pair.
|
||||
state.peerMissing = nil
|
||||
state.confirmed = false
|
||||
state.pendingUntil = nil
|
||||
state.unlinkedSince = nil
|
||||
end
|
||||
return state
|
||||
end
|
||||
|
||||
function Persistence.advanceRecoveryState(state, now, linked, occupied, retryMs, breakMs)
|
||||
state = state or {}
|
||||
if linked then
|
||||
state.confirmed = true
|
||||
state.pendingUntil = nil
|
||||
state.unlinkedSince = nil
|
||||
return state, "adopt"
|
||||
end
|
||||
if occupied then
|
||||
state.unlinkedSince = nil
|
||||
return state, "wait"
|
||||
end
|
||||
if state.confirmed then
|
||||
state.unlinkedSince = state.unlinkedSince or now
|
||||
if now - state.unlinkedSince >= breakMs then return state, "break" end
|
||||
return state, "wait"
|
||||
end
|
||||
if not state.pendingUntil or now >= state.pendingUntil then
|
||||
state.pendingUntil = now + retryMs
|
||||
return state, "restore"
|
||||
end
|
||||
return state, "wait"
|
||||
end
|
||||
|
||||
Persistence.getSqlId = getSqlId
|
||||
@@ -0,0 +1,163 @@
|
||||
if not TowBarMod then TowBarMod = {} end
|
||||
TowBarMod.Wrecker = TowBarMod.Wrecker or {}
|
||||
|
||||
local Wrecker = TowBarMod.Wrecker
|
||||
|
||||
Wrecker.MaxAttachDistance = 0.6
|
||||
Wrecker.HeightStep = 0.30
|
||||
Wrecker.MinHeightLevel = 0
|
||||
Wrecker.MaxHeightLevel = 2
|
||||
Wrecker.ScanRadius = 8
|
||||
|
||||
local SupportedWreckers = {
|
||||
-- KI5 '76 Chevrolet K Series (Workshop 3161951724)
|
||||
["Base.76chevyC30CCwrecker"] = true,
|
||||
["Base.76chevyC30SCwrecker"] = true,
|
||||
["Base.76chevyK30CCwrecker"] = true,
|
||||
["Base.76chevyK30SCwrecker"] = true,
|
||||
|
||||
-- KI5 '93 Chevrolet Suburban / Silverado (Workshop 3152529790).
|
||||
-- The mechanic variant inherits the K3500 wrecker template.
|
||||
["Base.93chevySilveradoK3500wrecker"] = true,
|
||||
["Base.93chevySilveradoK3500mechanic"] = true,
|
||||
|
||||
-- KI5 '78 AM General M35 Series Trucks (Workshop 2799152995).
|
||||
["Base.78amgeneralM62"] = true
|
||||
}
|
||||
|
||||
local HeightAttachmentIds = {
|
||||
[0] = "towbarWreckerHookLow",
|
||||
[1] = "towbarWreckerHookMid",
|
||||
[2] = "towbarWreckerHookHigh"
|
||||
}
|
||||
|
||||
local function getScriptFullName(script)
|
||||
if not script then return nil end
|
||||
local ok, fullName = pcall(function() return script:getFullName() end)
|
||||
if ok and fullName then return tostring(fullName) end
|
||||
local name = script:getName()
|
||||
return name and ("Base." .. tostring(name)) or nil
|
||||
end
|
||||
|
||||
function Wrecker.isSupportedWrecker(vehicle)
|
||||
if not vehicle then return false end
|
||||
local script = vehicle:getScript()
|
||||
local fullName = getScriptFullName(script)
|
||||
return SupportedWreckers[fullName] == true
|
||||
and vehicle:attachmentExist("hook")
|
||||
end
|
||||
|
||||
function Wrecker.getHeightAttachmentId(level)
|
||||
return HeightAttachmentIds[Wrecker.normalizeHeightLevel(level)]
|
||||
end
|
||||
|
||||
function Wrecker.normalizeHeightLevel(level)
|
||||
local numeric = tonumber(level) or Wrecker.MinHeightLevel
|
||||
numeric = math.floor(numeric + 0.5)
|
||||
return math.max(Wrecker.MinHeightLevel, math.min(Wrecker.MaxHeightLevel, numeric))
|
||||
end
|
||||
|
||||
function Wrecker.nextHeightLevel(currentLevel, direction)
|
||||
local current = Wrecker.normalizeHeightLevel(currentLevel)
|
||||
local delta = tonumber(direction)
|
||||
if delta ~= -1 and delta ~= 1 then return nil end
|
||||
return math.max(Wrecker.MinHeightLevel, math.min(Wrecker.MaxHeightLevel, current + delta))
|
||||
end
|
||||
|
||||
local function distanceSquared(pointA, pointB)
|
||||
if not pointA or not pointB then return nil end
|
||||
local dx = pointA:x() - pointB:x()
|
||||
local dy = pointA:y() - pointB:y()
|
||||
return dx * dx + dy * dy
|
||||
end
|
||||
|
||||
local function forEachVehicle(collection, callback)
|
||||
if not collection then return end
|
||||
if type(collection) == "table" then
|
||||
for _, vehicle in ipairs(collection) do callback(vehicle) end
|
||||
return
|
||||
end
|
||||
|
||||
local ok, iterator = pcall(function() return collection:iterator() end)
|
||||
if ok and iterator then
|
||||
while iterator:hasNext() do callback(iterator:next()) end
|
||||
return
|
||||
end
|
||||
|
||||
local sizeOk, size = pcall(function() return collection:size() end)
|
||||
if not sizeOk then return end
|
||||
for index = 0, size - 1 do callback(collection:get(index)) end
|
||||
end
|
||||
|
||||
local function isTargetEligible(wrecker, vehicle)
|
||||
if not vehicle or vehicle == wrecker then return false end
|
||||
if vehicle:getVehicleTowing() or vehicle:getVehicleTowedBy() then return false end
|
||||
if wrecker:getVehicleTowing() or wrecker:getVehicleTowedBy() then return false end
|
||||
local script = vehicle:getScript()
|
||||
if not script then return false end
|
||||
local name = string.lower(tostring(script:getName() or ""))
|
||||
return not string.find(name, "trailer", 1, true)
|
||||
end
|
||||
|
||||
function Wrecker.resolveNearestTarget(wrecker, candidates)
|
||||
if not Wrecker.isSupportedWrecker(wrecker) then return nil end
|
||||
local hookPoint = wrecker:getAttachmentWorldPos("hook", Vector3f.new())
|
||||
if not hookPoint then return nil end
|
||||
|
||||
local best
|
||||
local maxDistanceSquared = Wrecker.MaxAttachDistance * Wrecker.MaxAttachDistance
|
||||
forEachVehicle(candidates, function(vehicle)
|
||||
if not isTargetEligible(wrecker, vehicle) then return end
|
||||
for _, attachmentId in ipairs({ "trailerfront", "trailer" }) do
|
||||
if vehicle:attachmentExist(attachmentId) then
|
||||
local targetPoint = vehicle:getAttachmentWorldPos(attachmentId, Vector3f.new())
|
||||
local score = distanceSquared(hookPoint, targetPoint)
|
||||
if score and score <= maxDistanceSquared then
|
||||
local vehicleId = vehicle:getId()
|
||||
if not best or score < best.distanceSquared
|
||||
or (score == best.distanceSquared and vehicleId < best.vehicle:getId()) then
|
||||
best = {
|
||||
vehicle = vehicle,
|
||||
attachment = attachmentId,
|
||||
distanceSquared = score
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
return best
|
||||
end
|
||||
|
||||
function Wrecker.getWorldVehicles()
|
||||
local cell = getCell()
|
||||
return cell and cell:getVehicles() or nil
|
||||
end
|
||||
|
||||
local function registerHookAttachments()
|
||||
local manager = getScriptManager()
|
||||
if not manager then return end
|
||||
for fullName in pairs(SupportedWreckers) do
|
||||
local script = manager:getVehicle(fullName)
|
||||
local baseAttachment = script and script:getAttachmentById("hook") or nil
|
||||
local baseOffset = baseAttachment and baseAttachment:getOffset() or nil
|
||||
if baseOffset then
|
||||
for level = Wrecker.MinHeightLevel, Wrecker.MaxHeightLevel do
|
||||
local attachmentId = HeightAttachmentIds[level]
|
||||
if script:getAttachmentById(attachmentId) == nil then
|
||||
local attachment = ModelAttachment.new(attachmentId)
|
||||
attachment:getOffset():set(
|
||||
baseOffset:x(),
|
||||
baseOffset:y() + level * Wrecker.HeightStep,
|
||||
baseOffset:z()
|
||||
)
|
||||
attachment:setUpdateConstraint(false)
|
||||
script:addAttachment(attachment)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Wrecker.registerHookAttachments = registerHookAttachments
|
||||
Events.OnGameBoot.Add(registerHookAttachments)
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"ItemName_TowBar.TowBar": "Tow Bar"
|
||||
"TowBar.TowBar": "Tow Bar"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
"UI_Text_Towing_byRope": "by rope",
|
||||
"UI_Text_Towing_byTowBar": "by tow bar",
|
||||
"UI_Text_Towing_byHook": "by hook",
|
||||
"UI_Text_Towing_attachHook": "Tow %1 by hook",
|
||||
"UI_Text_Towing_detachHook": "Detach %1 from hook",
|
||||
"UI_Text_Towing_heightUp": "Raise tow hook",
|
||||
"UI_Text_Towing_heightDown": "Lower tow hook",
|
||||
"UI_Text_Towing_flipUpright": "Flip upright",
|
||||
"UI_Text_Towing_cannotDriveWhileTowed": "Cannot drive while being towed",
|
||||
"UI_Text_PushByHands": "Push vehicle",
|
||||
|
||||
@@ -4,7 +4,7 @@ module Base
|
||||
{
|
||||
mesh = vehicles/Towbar,
|
||||
texture = Vehicles/Towbar_Texture,
|
||||
scale = 0.01,
|
||||
scale = 0.025,
|
||||
}
|
||||
|
||||
model towbarModelLarge
|
||||
|
||||
Reference in New Issue
Block a user