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
@@ -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)