42.20 and Towtrucks

This commit is contained in:
2026-08-13 18:59:54 -04:00
parent a6ba4877d0
commit e403323017
33 changed files with 2333 additions and 142 deletions
-2
View File
@@ -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
+50 -57
View File
@@ -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
+7 -1
View File
@@ -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
+109
View File
@@ -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
+36 -43
View File
@@ -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
+168 -17
View File
@@ -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
+397
View File
@@ -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