Bump mod version to 1.0.5

This commit is contained in:
2026-08-12 13:36:01 -04:00
parent 40f0296080
commit a6ba4877d0
23 changed files with 6650 additions and 1 deletions
+10
View File
@@ -0,0 +1,10 @@
if not TowBarMod then TowBarMod = {} end
if not TowBarMod.Config then TowBarMod.Config = {} end
TowBarMod.Config.lowLevelAnimation = "RemoveGrass"
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
@@ -0,0 +1,72 @@
require "TimedActions/ISBaseTimedAction"
TowBarCustomPathFind = ISBaseTimedAction:derive("TowBarCustomPathFind")
function TowBarCustomPathFind:isValid()
return true
end
function TowBarCustomPathFind:update()
if instanceof(self.character, "IsoPlayer") and
(self.character:pressedMovement(false) or self.character:pressedCancelAction()) then
self:forceStop()
return
end
local result = self.character:getPathFindBehavior2():update()
if result == BehaviorResult.Succeeded then
self:forceComplete()
end
local x = self.character:getX()
local y = self.character:getY()
if x == self.lastX and y == self.lastY then
self.currentTimeInOnePosition = self.currentTimeInOnePosition + 1
else
self.currentTimeInOnePosition = 0
self.lastX = x
self.lastY = y
end
if self.currentTimeInOnePosition > self.maxTimeInOnePosition then
self:forceComplete()
end
end
function TowBarCustomPathFind:start()
self.character:facePosition(self.goal[2], self.goal[3])
self.character:getPathFindBehavior2():pathToLocationF(self.goal[2], self.goal[3], self.goal[4])
end
function TowBarCustomPathFind:stop()
ISBaseTimedAction.stop(self)
self.character:getPathFindBehavior2():cancel()
self.character:setPath2(nil)
end
function TowBarCustomPathFind:perform()
self.character:getPathFindBehavior2():cancel()
self.character:setPath2(nil)
ISBaseTimedAction.perform(self)
end
function TowBarCustomPathFind:pathToLocationF(character, targetX, targetY, targetZ)
local o = {}
setmetatable(o, self)
self.__index = self
o.character = character
o.stopOnWalk = false
o.stopOnRun = false
o.maxTime = -1
o.maxTimeInOnePosition = 15
o.currentTimeInOnePosition = 0
o.lastX = -1
o.lastY = -1
o.goal = { 'LocationF', targetX, targetY, targetZ }
return o
end
@@ -0,0 +1,56 @@
require('TimedActions/ISBaseTimedAction')
TowBarHookVehicle = ISBaseTimedAction:derive("TowBarHookVehicle")
-- The condition which tells the timed action if it is still valid
function TowBarHookVehicle:isValid()
return true;
end
-- Starts the Timed Action
function TowBarHookVehicle:start()
self:setActionAnim(self.animation)
self.sound = getSoundManager():PlayWorldSound("towbar_hookingSound", false, self.character:getSquare(), 0, 5, 1, true)
end
-- Is called when the time has passed
function TowBarHookVehicle:perform()
self.sound:stop();
if self.performFunc ~= nil then
self.performFunc(self.character, self.arg1, self.arg2, self.arg3, self.arg4)
end
ISBaseTimedAction.perform(self);
end
function TowBarHookVehicle:stop()
if self.sound then
self.sound:stop()
end
ISBaseTimedAction.stop(self)
end
function TowBarHookVehicle:new(character, time, animation, performFunc, arg1, arg2, arg3, arg4)
local o = {};
setmetatable(o, self)
self.__index = self
o.stopOnWalk = true
o.stopOnRun = true
o.maxTime = time
o.character = character;
o.animation = animation
o.performFunc = performFunc
o.arg1 = arg1
o.arg2 = arg2
o.arg3 = arg3
o.arg4 = arg4
return o;
end
@@ -0,0 +1,42 @@
require("TimedActions/ISBaseTimedAction")
TowBarScheduleAction = ISBaseTimedAction:derive("TowBarScheduleAction")
function TowBarScheduleAction:isValid()
return true
end
function TowBarScheduleAction:start()
end
function TowBarScheduleAction:perform()
if self.performFunc ~= nil then
self.performFunc(self.character, self.arg1, self.arg2, self.arg3, self.arg4)
end
ISBaseTimedAction.perform(self)
end
function TowBarScheduleAction:stop()
ISBaseTimedAction.stop(self)
end
function TowBarScheduleAction:new(character, time, performFunc, arg1, arg2, arg3, arg4)
local o = ISBaseTimedAction.new(self, character)
o.useProgressBar = false
o.stopOnWalk = false
o.stopOnRun = false
o.maxTime = time
o.character = character
o.performFunc = performFunc
o.arg1 = arg1
o.arg2 = arg2
o.arg3 = arg3
o.arg4 = arg4
return o
end
@@ -0,0 +1,173 @@
if isServer() then return end
if not TowBarMod then TowBarMod = {} end
TowBarMod.Sync = TowBarMod.Sync or {}
if TowBarMod.Sync._towSyncClientLoaded then return end
TowBarMod.Sync._towSyncClientLoaded = true
local function resolveVehicle(id)
if not id then return nil end
return getVehicleById(id)
end
local function ensureAttachment(vehicle, attachmentId)
if not vehicle or not attachmentId then return false end
local script = vehicle:getScript()
if not script then return false end
if script:getAttachmentById(attachmentId) ~= nil then return true end
local wheelCount = script:getWheelCount()
local yOffset = -0.5
if wheelCount > 0 then
local wheel = script:getWheel(0)
if wheel and wheel:getOffset() then
yOffset = wheel:getOffset():y() + 0.1
end
end
local chassis = script:getPhysicsChassisShape()
if not chassis then return false end
local attach = ModelAttachment.new(attachmentId)
if attachmentId == "trailer" then
attach:getOffset():set(0, yOffset, -chassis:z() / 2 - 0.1)
attach:setZOffset(-1)
else
attach:getOffset():set(0, yOffset, chassis:z() / 2 + 0.1)
attach:setZOffset(1)
end
script:addAttachment(attach)
return true
end
local function isLinked(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return false end
return vehicleA:getVehicleTowing() == vehicleB and vehicleB:getVehicleTowedBy() == vehicleA
end
local function reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB)
if TowBarMod.Utils and TowBarMod.Utils.updateAttachmentsForRigidTow then
TowBarMod.Utils.updateAttachmentsForRigidTow(vehicleA, vehicleB, attachmentA, attachmentB)
end
local towingMd = vehicleA:getModData()
local towedMd = vehicleB:getModData()
local currentScript = vehicleB:getScriptName()
if towingMd then
towingMd["isTowingByTowBar"] = true
towingMd["towed"] = false
towingMd["towBarTowedVehicleId"] = vehicleB:getId()
towingMd["towBarTowingVehicleId"] = nil
towingMd["towBarExpectedAttachment"] = attachmentA
vehicleA:transmitModData()
end
if towedMd then
if towedMd.towBarOriginalScriptName == nil and currentScript ~= "notTowingA_Trailer" then
towedMd.towBarOriginalScriptName = currentScript
end
if towedMd.towBarOriginalMass == nil then
towedMd.towBarOriginalMass = vehicleB:getMass()
end
if towedMd.towBarOriginalBrakingForce == nil then
towedMd.towBarOriginalBrakingForce = vehicleB:getBrakingForce()
end
towedMd["isTowingByTowBar"] = true
towedMd["towed"] = true
towedMd["towBarTowedVehicleId"] = nil
towedMd["towBarTowingVehicleId"] = vehicleA:getId()
towedMd["towBarExpectedAttachment"] = attachmentB
vehicleB:transmitModData()
end
if TowBarMod.Hook and TowBarMod.Hook.setVehicleScriptWithTowBarHidden then
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(vehicleB, "notTowingA_Trailer")
end
if TowBarMod.Hook and TowBarMod.Hook.setVehiclePostAttach then
pcall(TowBarMod.Hook.setVehiclePostAttach, nil, vehicleB)
end
end
local function applyAttachSync(args)
if not args then return end
local vehicleA = resolveVehicle(args.vehicleA)
local vehicleB = resolveVehicle(args.vehicleB)
if not vehicleA or not vehicleB then return end
local attachmentA = args.attachmentA or "trailer"
local attachmentB = args.attachmentB or "trailerfront"
if not ensureAttachment(vehicleA, attachmentA) or not ensureAttachment(vehicleB, attachmentB) then
return
end
if not isLinked(vehicleA, vehicleB) then
vehicleA:addPointConstraint(nil, vehicleB, attachmentA, attachmentB)
end
reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB)
end
local function hasConflictingTowLink(vehicle, expectedOther)
if not vehicle or not expectedOther then return false end
local towing = vehicle:getVehicleTowing()
local towedBy = vehicle:getVehicleTowedBy()
if (towing ~= nil and towing ~= expectedOther)
or (towedBy ~= nil and towedBy ~= expectedOther) then
return true
end
-- Also reject a delayed detach while a new tow is being established but
-- has not created its physical constraint yet.
local modData = vehicle:getModData()
if not modData then return false end
local expectedOtherId = expectedOther:getId()
return (modData["towBarTowedVehicleId"] ~= nil
and modData["towBarTowedVehicleId"] ~= expectedOtherId)
or (modData["towBarTowingVehicleId"] ~= nil
and modData["towBarTowingVehicleId"] ~= expectedOtherId)
end
local function breakTowBarPair(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)
end
end
local function applyDetachSync(args)
if not args then return end
local vehicleA = resolveVehicle(args.vehicleA)
local vehicleB = resolveVehicle(args.vehicleB)
if not vehicleA or not vehicleB then return end
if hasConflictingTowLink(vehicleA, vehicleB) or hasConflictingTowLink(vehicleB, vehicleA) then
return
end
breakTowBarPair(vehicleA, vehicleB)
if TowBarMod.Hook and TowBarMod.Hook.cleanupDetachedTowBar then
pcall(TowBarMod.Hook.cleanupDetachedTowBar, vehicleA, vehicleB)
end
end
local function onServerCommand(module, command, args)
if module ~= "towbar" then return end
if command == "forceAttachSync" then
applyAttachSync(args)
elseif command == "forceDetachSync" or command == "spontaneousDetachSync" then
applyDetachSync(args)
end
end
Events.OnServerCommand.Add(onServerCommand)
@@ -0,0 +1,833 @@
if not TowBarMod then TowBarMod = {} end
if not TowBarMod.Hook then TowBarMod.Hook = {} end
local DefaultTowBarTowMass = 200
local AutoReattachCooldownHours = 1 / 7200 -- 0.5 seconds
TowBarMod.Hook.lastAutoReattachAtByVehicle = TowBarMod.Hook.lastAutoReattachAtByVehicle or {}
local AutoReattachPlayerCooldownHours = 1 / 14400 -- 0.25 seconds
TowBarMod.Hook.lastAutoReattachAtByPlayer = TowBarMod.Hook.lastAutoReattachAtByPlayer or {}
local FreeRollTickInterval = 15
local freeRollTickCounter = 0
local function tryVehicleCall(vehicle, methodName, arg)
if not vehicle or not methodName then return false, nil end
local method = vehicle[methodName]
if method == nil then return false, nil end
return pcall(function()
if arg ~= nil then
return method(vehicle, arg)
end
return method(vehicle)
end)
end
local function storeOriginalVehicleCall(vehicle, modData, key, getterName)
if not vehicle or not modData or modData[key] ~= nil then return end
local ok, value = tryVehicleCall(vehicle, getterName)
if ok and value ~= nil then
modData[key] = value
end
end
local function applyFreeRollingTowState(vehicle)
if not vehicle then return end
local modData = vehicle:getModData()
if not modData then return end
if modData.towBarOriginalMass == nil then
modData.towBarOriginalMass = vehicle:getMass()
end
if modData.towBarOriginalBrakingForce == nil then
modData.towBarOriginalBrakingForce = vehicle:getBrakingForce()
end
storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrakeOn", "isParkingBrakeOn")
storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrake", "isParkingBrake")
storeOriginalVehicleCall(vehicle, modData, "towBarOriginalHandbrake", "isHandbrake")
local configuredTowMass = TowBarMod.Config and tonumber(TowBarMod.Config.towedVehicleRollingMass)
vehicle:setMass(configuredTowMass or DefaultTowBarTowMass)
vehicle:setBrakingForce(0)
if modData.towBarOriginalParkingBrakeOn ~= nil then
tryVehicleCall(vehicle, "setParkingBrakeOn", false)
end
if modData.towBarOriginalParkingBrake ~= nil then
tryVehicleCall(vehicle, "setParkingBrake", false)
end
if modData.towBarOriginalHandbrake ~= nil then
tryVehicleCall(vehicle, "setHandbrake", false)
end
vehicle:constraintChanged()
vehicle:updateTotalMass()
end
local function restoreFreeRollingTowState(vehicle, modData)
if not vehicle or not modData then return end
if modData.towBarOriginalMass ~= nil then
vehicle:setMass(modData.towBarOriginalMass)
end
if modData.towBarOriginalBrakingForce ~= nil then
vehicle:setBrakingForce(modData.towBarOriginalBrakingForce)
end
if modData.towBarOriginalParkingBrakeOn ~= nil then
tryVehicleCall(vehicle, "setParkingBrakeOn", modData.towBarOriginalParkingBrakeOn)
end
if modData.towBarOriginalParkingBrake ~= nil then
tryVehicleCall(vehicle, "setParkingBrake", modData.towBarOriginalParkingBrake)
end
if modData.towBarOriginalHandbrake ~= nil then
tryVehicleCall(vehicle, "setHandbrake", modData.towBarOriginalHandbrake)
end
vehicle:constraintChanged()
vehicle:updateTotalMass()
end
local function isTowBarTowPair(towingVehicle, towedVehicle)
if not towingVehicle or not towedVehicle then return false end
local towingModData = towingVehicle:getModData()
local towedModData = towedVehicle:getModData()
if not towingModData or not towedModData then return false end
if towingModData["isTowingByTowBar"] and towedModData["isTowingByTowBar"] and towedModData["towed"] then
return true
end
-- Rejoin fallback: original towbar state on the towed vehicle is enough to reapply rigid spacing.
if towedModData.towBarOriginalScriptName ~= nil then
return true
end
return false
end
local function getTowBarItem(playerObj)
if not playerObj then return nil end
local inventory = playerObj:getInventory()
if not inventory then return nil end
return inventory:getItemFromTypeRecurse("TowBar.TowBar")
end
local function sendTowAttachCommand(playerObj, args)
if not playerObj or not args then return end
-- MP-safe/server-authoritative attach path (Landtrain style).
if isClient() and isMultiplayer() then
sendClientCommand(playerObj, "towbar", "attachTowBar", args)
return
end
-- Keep vanilla attach path for SP/local behavior.
sendClientCommand(playerObj, "vehicle", "attachTrailer", args)
end
local TowbarVariantSize = 24
local TowbarNormalStart = 0
local TowbarLargeStart = 24
local TowbarMaxIndex = TowbarVariantSize - 1
local VanillaScaleMin = 1.5
local VanillaScaleMax = 2.0
local function getVehicleModelScale(script)
if not script then return nil end
local ok, result = pcall(function()
return script:getModelScale()
end)
if ok and type(result) == "number" then
return result
end
ok, result = pcall(function()
local model = script:getModel()
if model then
return model:getScale()
end
return nil
end)
if ok and type(result) == "number" then
return result
end
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))
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)
end
local function setTowBarModelVisible(vehicle, isVisible)
if not vehicle then return end
local normalPart = vehicle:getPartById("towbar")
local largePart = vehicle:getPartById("towbarLarge")
if normalPart == nil and largePart == nil then return end
for j = 0, TowbarVariantSize - 1 do
if normalPart then normalPart:setModelVisible("towbar" .. j, false) end
if largePart then largePart:setModelVisible("towbar" .. j, false) end
end
if not isVisible then
vehicle:doDamageOverlay()
return
end
local script = vehicle:getScript()
if not script then
vehicle:doDamageOverlay()
return
end
local index = getTowbarModelSlot(script)
local part = normalPart
if part then
part:setModelVisible("towbar" .. index, true)
end
vehicle:doDamageOverlay()
end
function TowBarMod.Hook.setVehicleScriptWithTowBarHidden(vehicle, scriptName)
if not vehicle or not scriptName then return false end
local modData = vehicle:getModData()
setTowBarModelVisible(vehicle, false)
if modData then
modData.towBarModelSwapInProgress = true
end
local ok, err = pcall(function()
vehicle:setScriptName(scriptName)
end)
if modData then
modData.towBarModelSwapInProgress = nil
end
-- Script initialization can reset part visibility, so hide it again before
-- allowing the intended model to be shown by the caller.
setTowBarModelVisible(vehicle, false)
if not ok then
error(err)
end
return true
end
local function resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedModData)
if not towingVehicle or not towedVehicle then
return nil, nil
end
local attachmentA = towingVehicle:getTowAttachmentSelf() or "trailer"
local attachmentB = towingVehicle:getTowAttachmentOther()
or (towedModData and towedModData["towBarChangedAttachmentId"])
or "trailerfront"
if not towingVehicle:canAttachTrailer(towedVehicle, attachmentA, attachmentB) then
if towingVehicle:canAttachTrailer(towedVehicle, "trailer", "trailerfront") then
attachmentA = "trailer"
attachmentB = "trailerfront"
elseif towingVehicle:canAttachTrailer(towedVehicle, "trailerfront", "trailer") then
attachmentA = "trailerfront"
attachmentB = "trailer"
end
end
return attachmentA, attachmentB
end
local function hasTowBarTowState(modData)
if not modData then
return false
end
if modData["isTowingByTowBar"] and modData["towed"] then
return true
end
-- Rejoin fallback: legacy saves may only have the original-script marker.
if modData.towBarOriginalScriptName ~= nil then
return true
end
return false
end
local function isActiveTowBarTowedVehicle(vehicle, modData)
if not vehicle or not modData then
return false
end
if modData["isTowingByTowBar"] and modData["towed"] then
return true
end
-- Rejoin fallback: if the tow link exists, original-script marker is enough.
if vehicle:getVehicleTowedBy() and modData.towBarOriginalScriptName ~= nil then
return true
end
return false
end
local function reattachTowBarPair(playerObj, towingVehicle, towedVehicle, requireDriver)
if not playerObj or not towingVehicle or not towedVehicle then
return false
end
if requireDriver and not towingVehicle:isDriver(playerObj) then
return false
end
local towingModData = towingVehicle:getModData()
local towedModData = towedVehicle:getModData()
if not towingModData or not towedModData then
return false
end
if requireDriver then
if not isTowBarTowPair(towingVehicle, towedVehicle) then
return false
end
else
if not isActiveTowBarTowedVehicle(towedVehicle, towedModData) then
return false
end
end
local attachmentA, attachmentB = resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedModData)
if not attachmentA or not attachmentB then
return false
end
local towingScript = towingVehicle:getScript()
local towedScript = towedVehicle:getScript()
if not towingScript or not towedScript then
return false
end
if not towingScript:getAttachmentById(attachmentA) or not towedScript:getAttachmentById(attachmentB) then
return false
end
TowBarMod.Utils.updateAttachmentsForRigidTow(towingVehicle, towedVehicle, attachmentA, attachmentB)
towedModData.towBarOriginalScriptName = towedModData.towBarOriginalScriptName or towedVehicle:getScriptName()
applyFreeRollingTowState(towedVehicle)
towingModData["isTowingByTowBar"] = true
towingModData["towed"] = false
towingModData["towBarTowedVehicleId"] = towedVehicle:getId()
towingModData["towBarTowingVehicleId"] = nil
towingModData["towBarExpectedAttachment"] = attachmentA
towedModData["isTowingByTowBar"] = true
towedModData["towed"] = true
towedModData["towBarTowedVehicleId"] = nil
towedModData["towBarTowingVehicleId"] = towingVehicle:getId()
towedModData["towBarExpectedAttachment"] = attachmentB
towingVehicle:transmitModData()
towedVehicle:transmitModData()
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, "notTowingA_Trailer")
local args = {
vehicleA = towingVehicle:getId(),
vehicleB = towedVehicle:getId(),
attachmentA = attachmentA,
attachmentB = attachmentB
}
sendTowAttachCommand(playerObj, args)
ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, 10, TowBarMod.Hook.setVehiclePostAttach, towedVehicle))
return true
end
local function reattachTowBarPairAfterCleanDetach(playerObj, towingVehicle, towedVehicle, requireDriver)
if not playerObj or not towingVehicle or not towedVehicle then
return false
end
if requireDriver and not towingVehicle:isDriver(playerObj) then
return false
end
local detachArgs = {
towingVehicle = towingVehicle:getId(),
vehicle = towedVehicle:getId()
}
sendClientCommand(playerObj, "towbar", "detachTowBar", detachArgs)
-- World load/spawn can restore constraints in a bad state. Reattach one
-- short tick later so the detach is fully applied first.
ISTimedActionQueue.add(TowBarScheduleAction:new(
playerObj,
1,
reattachTowBarPair,
towingVehicle,
towedVehicle,
requireDriver
))
return true
end
local function recoverTowBarVehicleAfterLoad(playerObj, vehicle, retriesLeft)
if not vehicle then return end
local modData = vehicle:getModData()
if not hasTowBarTowState(modData) then
return
end
local retries = tonumber(retriesLeft) or 0
local localPlayer = playerObj or getPlayer()
local towingVehicle = vehicle:getVehicleTowedBy()
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.
TowBarMod.Hook.setVehiclePostAttach(nil, vehicle)
end
if localPlayer and towingVehicle then
if reattachTowBarPairAfterCleanDetach(localPlayer, towingVehicle, vehicle, false) then
return
end
end
if localPlayer and retries > 0 then
-- During world load, tow links can become available a few ticks later.
ISTimedActionQueue.add(TowBarScheduleAction:new(localPlayer, 10, recoverTowBarVehicleAfterLoad, vehicle, retries - 1))
return
end
-- Fallback: keep original post-attach restoration behavior.
setTowBarModelVisible(vehicle, true)
TowBarMod.Hook.setVehiclePostAttach(nil, vehicle)
end
function TowBarMod.Hook.setVehiclePostAttach(playerObj, towedVehicle, retriesLeft)
if not towedVehicle then return end
local towedModData = towedVehicle:getModData()
if not isActiveTowBarTowedVehicle(towedVehicle, towedModData) then return end
if towedModData and towedModData.towBarOriginalScriptName then
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, towedModData.towBarOriginalScriptName)
end
local towingVehicle = towedVehicle:getVehicleTowedBy()
if towingVehicle then
local attachmentA, attachmentB = resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedModData)
if attachmentA and attachmentB then
local towingModData = towingVehicle:getModData()
if towingModData then
towingModData["towBarTowedVehicleId"] = towedVehicle:getId()
towingModData["towBarTowingVehicleId"] = nil
towingModData["towBarExpectedAttachment"] = attachmentA
towedModData["towBarTowingVehicleId"] = towingVehicle:getId()
towedModData["towBarTowedVehicleId"] = nil
towedModData["towBarExpectedAttachment"] = attachmentB
towingVehicle:transmitModData()
towedVehicle:transmitModData()
end
TowBarMod.Utils.updateAttachmentsForRigidTow(towingVehicle, towedVehicle, attachmentA, attachmentB)
end
end
applyFreeRollingTowState(towedVehicle)
-- Re-show the towbar model after the script name has been restored.
-- setScriptName() resets model visibility, so we must set it again here.
setTowBarModelVisible(towedVehicle, true)
end
function TowBarMod.Hook.performAttachTowBar(playerObj, towingVehicle, towedVehicle, attachmentA, attachmentB)
if playerObj == nil or towingVehicle == nil or towedVehicle == nil then return end
if #(TowBarMod.Utils.getHookTypeVariants(towingVehicle, towedVehicle, true)) == 0 then return end
local towBarItem = getTowBarItem(playerObj)
if towBarItem ~= nil and not (isClient() and isMultiplayer()) then
sendClientCommand(playerObj, "towbar", "consumeTowBar", { itemId = towBarItem:getID() })
end
playerObj:setPrimaryHandItem(nil)
TowBarMod.Utils.updateAttachmentsForRigidTow(towingVehicle, towedVehicle, attachmentA, attachmentB)
local towingModData = towingVehicle:getModData()
local towedModData = towedVehicle:getModData()
towedModData.towBarOriginalScriptName = towedVehicle:getScriptName()
applyFreeRollingTowState(towedVehicle)
towingModData["isTowingByTowBar"] = true
towingModData["towed"] = false
towingModData["towBarTowedVehicleId"] = towedVehicle:getId()
towingModData["towBarTowingVehicleId"] = nil
towingModData["towBarExpectedAttachment"] = attachmentA
towedModData["isTowingByTowBar"] = true
towedModData["towed"] = true
towedModData["towBarTowedVehicleId"] = nil
towedModData["towBarTowingVehicleId"] = towingVehicle:getId()
towedModData["towBarExpectedAttachment"] = attachmentB
towingVehicle:transmitModData()
towedVehicle:transmitModData()
-- Match the known-good rigid tow path: fake trailer + vanilla attach command.
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, "notTowingA_Trailer")
local args = {
vehicleA = towingVehicle:getId(),
vehicleB = towedVehicle:getId(),
attachmentA = attachmentA,
attachmentB = attachmentB,
itemId = towBarItem and towBarItem:getID() or nil
}
sendTowAttachCommand(playerObj, args)
ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, 10, TowBarMod.Hook.setVehiclePostAttach, towedVehicle))
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
if towedModData.towBarOriginalScriptName then
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, towedModData.towBarOriginalScriptName)
end
restoreFreeRollingTowState(towedVehicle, towedModData)
towingModData["isTowingByTowBar"] = false
towingModData["towed"] = false
towingModData["towBarTowedVehicleId"] = nil
towingModData["towBarTowingVehicleId"] = nil
towingModData["towBarExpectedAttachment"] = nil
towedModData["isTowingByTowBar"] = false
towedModData["towed"] = false
towedModData["towBarTowedVehicleId"] = nil
towedModData["towBarTowingVehicleId"] = nil
towedModData["towBarExpectedAttachment"] = nil
towedModData.towBarOriginalScriptName = nil
towedModData.towBarOriginalMass = nil
towedModData.towBarOriginalBrakingForce = nil
towedModData.towBarOriginalParkingBrakeOn = nil
towedModData.towBarOriginalParkingBrake = nil
towedModData.towBarOriginalHandbrake = nil
towingVehicle:transmitModData()
towedVehicle:transmitModData()
TowBarMod.Hook.lastAutoReattachAtByVehicle[towingVehicle:getId()] = nil
setTowBarModelVisible(towedVehicle, false)
end
function TowBarMod.Hook.performDetachTowBar(playerObj, towingVehicle, towedVehicle)
if playerObj == nil or towingVehicle == nil or towedVehicle == nil then return end
local args = { towingVehicle = towingVehicle:getId(), vehicle = towedVehicle:getId() }
sendClientCommand(playerObj, "towbar", "detachTowBar", args)
TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle)
end
function TowBarMod.Hook.reattachTowBarFromDriverSeat(playerObj, towingVehicle)
if not playerObj or not towingVehicle then return end
local towedVehicle = towingVehicle:getVehicleTowing()
if not towedVehicle then return end
reattachTowBarPair(playerObj, towingVehicle, towedVehicle, true)
end
local function tryAutoReattachFromCharacter(character)
if not character or not instanceof(character, "IsoPlayer") or not character:isLocalPlayer() then return end
local playerObj = character
local nowHours = getGameTime() and getGameTime():getWorldAgeHours() or 0
local playerNum = playerObj:getPlayerNum()
local lastPlayerHours = TowBarMod.Hook.lastAutoReattachAtByPlayer[playerNum]
if lastPlayerHours and (nowHours - lastPlayerHours) < AutoReattachPlayerCooldownHours then
return
end
local towingVehicle = playerObj:getVehicle()
if not towingVehicle then return end
if not towingVehicle:isDriver(playerObj) then return end
local towedVehicle = towingVehicle:getVehicleTowing()
if not towedVehicle then return end
if not isTowBarTowPair(towingVehicle, towedVehicle) then return end
local vehicleId = towingVehicle:getId()
local lastHours = TowBarMod.Hook.lastAutoReattachAtByVehicle[vehicleId]
if lastHours and (nowHours - lastHours) < AutoReattachCooldownHours then
return
end
TowBarMod.Hook.lastAutoReattachAtByPlayer[playerNum] = nowHours
TowBarMod.Hook.lastAutoReattachAtByVehicle[vehicleId] = nowHours
TowBarMod.Hook.reattachTowBarFromDriverSeat(playerObj, towingVehicle)
end
local function forEachCollectionItem(collection, callback)
if not collection then 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 size
ok, size = pcall(function()
return collection:size()
end)
if not ok or type(size) ~= "number" then return end
for i = 0, size - 1 do
callback(collection:get(i))
end
end
local function keepTowBarVehiclesFreeRolling()
freeRollTickCounter = freeRollTickCounter + 1
if freeRollTickCounter < FreeRollTickInterval then
return
end
freeRollTickCounter = 0
local cell = getCell()
if not cell then return end
local vehicles = cell:getVehicles()
if not vehicles then return end
forEachCollectionItem(vehicles, function(vehicle)
local modData = vehicle and vehicle:getModData() or nil
if isActiveTowBarTowedVehicle(vehicle, modData) then
applyFreeRollingTowState(vehicle)
end
end)
end
function TowBarMod.Hook.OnEnterVehicle(character)
tryAutoReattachFromCharacter(character)
end
function TowBarMod.Hook.OnSwitchVehicleSeat(character)
tryAutoReattachFromCharacter(character)
end
function TowBarMod.Hook.attachByTowBarAction(playerObj, towingVehicle, towedVehicle)
if playerObj == nil or towingVehicle == nil or towedVehicle == nil then return end
local item = getTowBarItem(playerObj)
if item == nil then return end
if #(TowBarMod.Utils.getHookTypeVariants(towingVehicle, towedVehicle, true)) == 0 then return end
local hookPoint = towedVehicle:getAttachmentWorldPos("trailerfront", TowBarMod.Utils.tempVector1)
if hookPoint == nil then return end
ISTimedActionQueue.add(TowBarCustomPathFind:pathToLocationF(playerObj, hookPoint:x(), hookPoint:y(), hookPoint:z()))
if not playerObj:getInventory():contains("TowBar.TowBar") then
ISTimedActionQueue.add(ISInventoryTransferAction:new(playerObj, item, item:getContainer(), playerObj:getInventory(), nil))
end
local storePrim = playerObj:getPrimaryHandItem()
if storePrim == nil or storePrim ~= item then
ISTimedActionQueue.add(ISEquipWeaponAction:new(playerObj, item, 12, true))
end
ISTimedActionQueue.add(TowBarHookVehicle:new(playerObj, 300, TowBarMod.Config.lowLevelAnimation))
hookPoint = towingVehicle:getAttachmentWorldPos("trailer", TowBarMod.Utils.tempVector1)
if hookPoint == nil then return end
ISTimedActionQueue.add(TowBarCustomPathFind:pathToLocationF(playerObj, hookPoint:x(), hookPoint:y(), hookPoint:z()))
ISTimedActionQueue.add(TowBarHookVehicle:new(
playerObj,
100,
TowBarMod.Config.lowLevelAnimation,
TowBarMod.Hook.performAttachTowBar,
towingVehicle,
towedVehicle,
"trailer",
"trailerfront"
))
end
function TowBarMod.Hook.deattachTowBarAction(playerObj, vehicle)
local towingVehicle = vehicle
local towedVehicle = vehicle and vehicle:getVehicleTowing() or nil
if vehicle and vehicle:getVehicleTowedBy() then
towingVehicle = vehicle:getVehicleTowedBy()
towedVehicle = vehicle
end
if towingVehicle == nil or towedVehicle == nil then return end
local localPoint = towingVehicle:getAttachmentLocalPos(towingVehicle:getTowAttachmentSelf(), TowBarMod.Utils.tempVector1)
local shift = 0
if towingVehicle:getModData()["isChangedTowedAttachment"] then
shift = localPoint:z() > 0 and -1 or 1
end
local hookPoint = towingVehicle:getWorldPos(localPoint:x(), localPoint:y(), localPoint:z() + shift, TowBarMod.Utils.tempVector2)
if hookPoint == nil then return end
ISTimedActionQueue.add(TowBarCustomPathFind:pathToLocationF(playerObj, hookPoint:x(), hookPoint:y(), hookPoint:z()))
local storePrim = playerObj:getPrimaryHandItem()
if storePrim ~= nil then
ISTimedActionQueue.add(ISUnequipAction:new(playerObj, storePrim, 12))
end
ISTimedActionQueue.add(TowBarHookVehicle:new(playerObj, 100, TowBarMod.Config.lowLevelAnimation))
localPoint = towedVehicle:getAttachmentLocalPos(towedVehicle:getTowAttachmentSelf(), TowBarMod.Utils.tempVector1)
shift = 0
if towedVehicle:getModData()["isChangedTowedAttachment"] then
shift = localPoint:z() > 0 and -1 or 1
end
hookPoint = towedVehicle:getWorldPos(localPoint:x(), localPoint:y(), localPoint:z() + shift, TowBarMod.Utils.tempVector2)
if hookPoint == nil then return end
ISTimedActionQueue.add(TowBarCustomPathFind:pathToLocationF(playerObj, hookPoint:x(), hookPoint:y(), hookPoint:z()))
ISTimedActionQueue.add(TowBarHookVehicle:new(
playerObj,
300,
TowBarMod.Config.lowLevelAnimation,
TowBarMod.Hook.performDetachTowBar,
towingVehicle,
towedVehicle
))
end
function TowBarMod.Hook.OnSpawnVehicle(vehicle)
recoverTowBarVehicleAfterLoad(nil, vehicle, 6)
end
function TowBarMod.Hook.OnGameStart()
local cell = getCell()
if not cell then return end
local vehicles = cell:getVehicles()
if not vehicles then return end
local playerObj = getPlayer()
forEachCollectionItem(vehicles, function(vehicle)
recoverTowBarVehicleAfterLoad(playerObj, vehicle, 6)
end)
end
---------------------------------------------------------------------------
--- Dev / debug helpers
---------------------------------------------------------------------------
function TowBarMod.Hook.devShowAllTowbarModels(playerObj, vehicle)
if not vehicle then return end
local normalPart = vehicle:getPartById("towbar")
local largePart = vehicle:getPartById("towbarLarge")
if normalPart == nil and largePart == nil then
print("[TowBar DEV] No 'towbar' or 'towbarLarge' part found on vehicle " .. tostring(vehicle:getScriptName()))
return
end
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)
end
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] 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
if normalPart then normalPart:setModelVisible("towbar" .. j, true) end
if largePart then largePart:setModelVisible("towbar" .. j, true) end
end
vehicle:doDamageOverlay()
end
function TowBarMod.Hook.devHideAllTowbarModels(playerObj, vehicle)
if not vehicle then return end
local normalPart = vehicle:getPartById("towbar")
local largePart = vehicle:getPartById("towbarLarge")
if normalPart == nil and largePart == nil then
print("[TowBar DEV] No 'towbar' or 'towbarLarge' part found on vehicle " .. tostring(vehicle:getScriptName()))
return
end
print("[TowBar DEV] Hiding ALL towbar models on " .. tostring(vehicle:getScriptName()))
for j = 0, TowbarVariantSize - 1 do
if normalPart then normalPart:setModelVisible("towbar" .. j, false) end
if largePart then largePart:setModelVisible("towbar" .. j, false) end
end
vehicle:doDamageOverlay()
end
function TowBarMod.Hook.devShowSingleTowbar(playerObj, vehicle, index)
if not vehicle then return end
local normalPart = vehicle:getPartById("towbar")
local largePart = vehicle:getPartById("towbarLarge")
if normalPart == nil and largePart == nil then
print("[TowBar DEV] No 'towbar' or 'towbarLarge' part found on vehicle " .. tostring(vehicle:getScriptName()))
return
end
local localIndex = math.max(0, math.min(TowbarMaxIndex, index % TowbarVariantSize))
local useLargePart = index >= TowbarVariantSize
for j = 0, TowbarVariantSize - 1 do
if normalPart then normalPart:setModelVisible("towbar" .. j, false) end
if largePart then largePart:setModelVisible("towbar" .. j, false) end
end
local part = useLargePart and largePart or normalPart
if part == nil then
part = normalPart or largePart
end
print("[TowBar DEV] Showing only towbar" .. tostring(localIndex) .. " on part " .. tostring(useLargePart and "towbarLarge" or "towbar") .. " (Z offset " .. tostring(1.0 + localIndex * 0.1) .. ") on " .. tostring(vehicle:getScriptName()))
if part then
part:setModelVisible("towbar" .. localIndex, true)
end
vehicle:doDamageOverlay()
end
Events.OnSpawnVehicleEnd.Add(TowBarMod.Hook.OnSpawnVehicle)
if Events.OnGameStart then
Events.OnGameStart.Add(TowBarMod.Hook.OnGameStart)
end
Events.OnEnterVehicle.Add(TowBarMod.Hook.OnEnterVehicle)
Events.OnSwitchVehicleSeat.Add(TowBarMod.Hook.OnSwitchVehicleSeat)
Events.OnTick.Add(keepTowBarVehiclesFreeRolling)
+231
View File
@@ -0,0 +1,231 @@
if not TowBarMod then TowBarMod = {} end
if not TowBarMod.UI then TowBarMod.UI = {} end
---------------------------------------------------------------------------
--- UI functions
---------------------------------------------------------------------------
function TowBarMod.UI.removeDefaultDetachOption(playerObj)
local menu = getPlayerRadialMenu(playerObj:getPlayerNum())
if menu == nil then return end
local tmpSlices = {}
for i, slice in ipairs(menu.slices or {}) do
tmpSlices[i] = slice
end
menu:clear()
for _, slice in ipairs(tmpSlices) do
local command = slice.command and slice.command[1]
local args = slice.command or {}
if command ~= ISVehicleMenu.onDetachTrailer then
menu:addSlice(
slice.text,
slice.texture,
args[1],
args[2],
args[3],
args[4],
args[5],
args[6],
args[7]
)
end
end
end
--- Show menu with available vehicles for tow bar hook.
function TowBarMod.UI.showChooseVehicleMenu(playerObj, vehicle, vehicles, hasTowBar)
local playerIndex = playerObj:getPlayerNum()
local menu = getPlayerRadialMenu(playerIndex)
menu:clear()
local added = 0
for _, veh in ipairs(vehicles) do
local hookTypeVariants = TowBarMod.Utils.getHookTypeVariants(vehicle, veh, hasTowBar)
if #hookTypeVariants > 0 then
local hookType = hookTypeVariants[1]
menu:addSlice(
hookType.name,
getTexture("media/textures/tow_bar_attach.png"),
hookType.func,
playerObj,
hookType.towingVehicle,
hookType.towedVehicle,
hookType.towingPoint,
hookType.towedPoint
)
added = added + 1
end
end
if added == 0 then return end
menu:setX(getPlayerScreenLeft(playerIndex) + getPlayerScreenWidth(playerIndex) / 2 - menu:getWidth() / 2)
menu:setY(getPlayerScreenTop(playerIndex) + getPlayerScreenHeight(playerIndex) / 2 - menu:getHeight() / 2)
menu:addToUIManager()
if JoypadState.players[playerObj:getPlayerNum()+1] then
menu:setHideWhenButtonReleased(Joypad.DPadUp)
setJoypadFocus(playerObj:getPlayerNum(), menu)
playerObj:setJoypadIgnoreAimUntilCentered(true)
end
end
function TowBarMod.UI.addHookOptionToMenu(playerObj, vehicle)
local menu = getPlayerRadialMenu(playerObj:getPlayerNum())
if menu == nil then return end
local hasTowBar = playerObj:getInventory():getItemFromTypeRecurse("TowBar.TowBar") ~= nil
if not hasTowBar then return end
local vehicles = TowBarMod.Utils.getAviableVehicles(vehicle, hasTowBar)
if #vehicles == 0 then
return
elseif #vehicles == 1 then
local hookTypeVariants = TowBarMod.Utils.getHookTypeVariants(vehicle, vehicles[1], hasTowBar)
if #hookTypeVariants > 0 then
local hookType = hookTypeVariants[1]
menu:addSlice(
hookType.name,
getTexture("media/textures/tow_bar_attach.png"),
hookType.func,
playerObj,
hookType.towingVehicle,
hookType.towedVehicle,
hookType.towingPoint,
hookType.towedPoint
)
end
else
menu:addSlice(
getText("UI_Text_Towing_attach") .. "...",
getTexture("media/textures/tow_bar_attach.png"),
TowBarMod.UI.showChooseVehicleMenu,
playerObj,
vehicle,
vehicles,
hasTowBar
)
end
end
function TowBarMod.UI.addUnhookOptionToMenu(playerObj, vehicle)
local menu = getPlayerRadialMenu(playerObj:getPlayerNum())
if menu == nil then return end
if not vehicle:getModData()["isTowingByTowBar"] then return end
if not vehicle:getVehicleTowing() and not vehicle:getVehicleTowedBy() then return end
local towedVehicle = vehicle
if vehicle:getVehicleTowing() then
towedVehicle = vehicle:getVehicleTowing()
end
menu:addSlice(
getText("ContextMenu_Vehicle_DetachTrailer", ISVehicleMenu.getVehicleDisplayName(towedVehicle)),
getTexture("media/textures/tow_bar_detach.png"),
TowBarMod.Hook.deattachTowBarAction,
playerObj,
towedVehicle
)
end
---------------------------------------------------------------------------
--- Dev menu
---------------------------------------------------------------------------
function TowBarMod.UI.showDevSingleTowbarMenu(playerObj, vehicle)
local playerIndex = playerObj:getPlayerNum()
local menu = getPlayerRadialMenu(playerIndex)
menu:clear()
for j = 0, 47 do
local zIndex = j % 24
local modelType = (j >= 24) and "large" or "normal"
menu:addSlice(
"towbar" .. j .. " [" .. modelType .. "] (Z=" .. tostring(1.0 + zIndex * 0.1) .. ")",
getTexture("media/textures/tow_bar_icon.png"),
TowBarMod.Hook.devShowSingleTowbar,
playerObj,
vehicle,
j
)
end
menu:setX(getPlayerScreenLeft(playerIndex) + getPlayerScreenWidth(playerIndex) / 2 - menu:getWidth() / 2)
menu:setY(getPlayerScreenTop(playerIndex) + getPlayerScreenHeight(playerIndex) / 2 - menu:getHeight() / 2)
menu:addToUIManager()
if JoypadState.players[playerObj:getPlayerNum()+1] then
menu:setHideWhenButtonReleased(Joypad.DPadUp)
setJoypadFocus(playerObj:getPlayerNum(), menu)
playerObj:setJoypadIgnoreAimUntilCentered(true)
end
end
function TowBarMod.UI.addDevOptionsToMenu(playerObj, vehicle)
local devModeEnabled = (TowBarMod.Config and TowBarMod.Config.devMode) or getDebug()
if not devModeEnabled then return end
if not vehicle then return end
local menu = getPlayerRadialMenu(playerObj:getPlayerNum())
if menu == nil then return end
menu:addSlice(
"[DEV] Show ALL Towbars",
getTexture("media/textures/tow_bar_icon.png"),
TowBarMod.Hook.devShowAllTowbarModels,
playerObj,
vehicle
)
menu:addSlice(
"[DEV] Hide ALL Towbars",
getTexture("media/textures/tow_bar_icon.png"),
TowBarMod.Hook.devHideAllTowbarModels,
playerObj,
vehicle
)
menu:addSlice(
"[DEV] Pick Single Towbar...",
getTexture("media/textures/tow_bar_icon.png"),
TowBarMod.UI.showDevSingleTowbarMenu,
playerObj,
vehicle
)
end
---------------------------------------------------------------------------
--- Mod compability
---------------------------------------------------------------------------
if getActivatedMods():contains("vehicle_additions") then
require("Vehicles/ISUI/Oven_Mattress_RadialMenu")
require("Vehicles/ISUI/FuelTruckTank_ISVehicleMenu_FillPartMenu")
end
---------------------------------------------------------------------------
--- Attach to default menu method
---------------------------------------------------------------------------
if TowBarMod.UI.defaultShowRadialMenu == nil then
TowBarMod.UI.defaultShowRadialMenu = ISVehicleMenu.showRadialMenu
end
function ISVehicleMenu.showRadialMenu(playerObj)
TowBarMod.UI.defaultShowRadialMenu(playerObj)
if playerObj:getVehicle() then return end
local vehicle = ISVehicleMenu.getVehicleToInteractWith(playerObj)
if vehicle == nil then return end
if vehicle:getModData()["isTowingByTowBar"] then
TowBarMod.UI.removeDefaultDetachOption(playerObj)
TowBarMod.UI.addUnhookOptionToMenu(playerObj, vehicle)
elseif not vehicle:getVehicleTowing() and not vehicle:getVehicleTowedBy() then
TowBarMod.UI.addHookOptionToMenu(playerObj, vehicle)
end
TowBarMod.UI.addDevOptionsToMenu(playerObj, vehicle)
end
@@ -0,0 +1,317 @@
if not TowBarMod then TowBarMod = {} end
if not TowBarMod.Utils then TowBarMod.Utils = {} end
TowBarMod.Utils.tempVector1 = Vector3f.new()
TowBarMod.Utils.tempVector2 = Vector3f.new()
---------------------------------------------------------------------------
--- Util functions
---------------------------------------------------------------------------
--- Compute the attachment Y offset for a vehicle so the towbar sits just
--- above the wheels (i.e. a fixed distance off the ground) regardless of
--- how the vehicle model is configured.
local function computeAttachmentHeight(vehicle)
local script = vehicle:getScript()
if not script then return -0.5 end
local wheelCount = script:getWheelCount()
if wheelCount > 0 then
return script:getWheel(0):getOffset():y() + 0.1
end
return -0.5
end
local function getVehicleHalfLength(script)
if not script then return nil end
local ok, shape = pcall(function()
return script:getPhysicsChassisShape()
end)
if ok and shape then
local zOk, z = pcall(function()
return shape:z()
end)
z = zOk and tonumber(z) or nil
if z and z > 0 then return z / 2 end
end
ok, shape = pcall(function()
return script:getExtents()
end)
if ok and shape then
local zOk, z = pcall(function()
return shape:z()
end)
z = zOk and tonumber(z) or nil
if z and z > 0 then return z / 2 end
end
return nil
end
local function getExteriorAttachmentZ(script, attachmentId, originalZ, extraDistance)
local halfLength = getVehicleHalfLength(script)
if halfLength == nil then return originalZ end
local configuredPadding = TowBarMod.Config and tonumber(TowBarMod.Config.towAttachmentExteriorPadding)
local padding = configuredPadding or 0.25
local direction = originalZ >= 0 and 1 or -1
if originalZ == 0 and attachmentId == "trailer" then
direction = -1
end
local exteriorZ = direction * (halfLength + padding + (extraDistance or 0))
if math.abs(originalZ) > math.abs(exteriorZ) then
return originalZ
end
return exteriorZ
end
function TowBarMod.Utils.isTrailer(vehicle)
return string.match(string.lower(vehicle:getScript():getName()), "trailer")
end
--- Return vehicles from sector that player can tow by tow bar.
function TowBarMod.Utils.getAviableVehicles(mainVehicle, hasTowBar)
local vehicles = {}
if not hasTowBar then return vehicles end
local square = mainVehicle:getSquare()
if square == nil then return vehicles end
-- Match vanilla towing search radius.
for y=square:getY() - 6, square:getY() + 6 do
for x=square:getX() - 6, square:getX() + 6 do
local square2 = getCell():getGridSquare(x, y, square:getZ())
if square2 then
for i=1, square2:getMovingObjects():size() do
local obj = square2:getMovingObjects():get(i-1)
if obj ~= nil
and instanceof(obj, "BaseVehicle")
and obj ~= mainVehicle
and #(TowBarMod.Utils.getHookTypeVariants(mainVehicle, obj, hasTowBar)) ~= 0 then
table.insert(vehicles, obj)
end
end
end
end
end
return vehicles
end
--- Return a table with towbar-only hook options for vehicles.
function TowBarMod.Utils.getHookTypeVariants(vehicleA, vehicleB, hasTowBar)
local hookTypeVariants = {}
if not hasTowBar then return hookTypeVariants end
if vehicleA:getVehicleTowing() or vehicleA:getVehicleTowedBy()
or vehicleB:getVehicleTowing() or vehicleB:getVehicleTowedBy() then
return hookTypeVariants
end
-- Keep tow bars for vehicle-to-vehicle towing only.
if TowBarMod.Utils.isTrailer(vehicleA) or TowBarMod.Utils.isTrailer(vehicleB) then
return hookTypeVariants
end
if vehicleA:canAttachTrailer(vehicleB, "trailerfront", "trailer") then
local hookType = {}
hookType.name = getText("UI_Text_Towing_attach") .. "\n" .. ISVehicleMenu.getVehicleDisplayName(vehicleB) .. "\n" .. getText("UI_Text_Towing_byTowBar")
hookType.func = TowBarMod.Hook.attachByTowBarAction
hookType.towingVehicle = vehicleB
hookType.towedVehicle = vehicleA
hookType.textureName = "tow_bar_icon"
table.insert(hookTypeVariants, hookType)
elseif vehicleA:canAttachTrailer(vehicleB, "trailer", "trailerfront") then
local hookType = {}
hookType.name = getText("UI_Text_Towing_attach") .. "\n" .. ISVehicleMenu.getVehicleDisplayName(vehicleB) .. "\n" .. getText("UI_Text_Towing_byTowBar")
hookType.func = TowBarMod.Hook.attachByTowBarAction
hookType.towingVehicle = vehicleA
hookType.towedVehicle = vehicleB
hookType.textureName = "tow_bar_icon"
table.insert(hookTypeVariants, hookType)
end
return hookTypeVariants
end
function TowBarMod.Utils.updateAttachmentsForRigidTow(towingVehicle, towedVehicle, attachmentA, attachmentB)
local towingScript = towingVehicle:getScript()
local towedScript = towedVehicle:getScript()
if towingScript == nil or towedScript == nil then return end
local towingAttachment = towingScript:getAttachmentById(attachmentA)
local towedAttachment = towedScript:getAttachmentById(attachmentB)
if towingAttachment == nil or towedAttachment == nil then return end
towingAttachment:setUpdateConstraint(false)
towingAttachment:setZOffset(0)
towedAttachment:setUpdateConstraint(false)
towedAttachment:setZOffset(0)
-- Dynamic height: compute Y from wheel offset so the towbar never clips the floor.
local towingHeight = computeAttachmentHeight(towingVehicle)
local towedHeight = computeAttachmentHeight(towedVehicle)
-- Store and update the towing vehicle's attachment Y.
local towingModData = towingVehicle:getModData()
local towingOffset = towingAttachment:getOffset()
if towingModData["towBarOriginalTowingAttachmentId"] ~= attachmentA
or towingModData["towBarOriginalTowingOffsetX"] == nil
or towingModData["towBarOriginalTowingOffsetY"] == nil
or towingModData["towBarOriginalTowingOffsetZ"] == nil then
towingModData["towBarOriginalTowingOffsetX"] = towingOffset:x()
towingModData["towBarOriginalTowingOffsetY"] = towingOffset:y()
towingModData["towBarOriginalTowingOffsetZ"] = towingOffset:z()
towingModData["towBarOriginalTowingAttachmentId"] = attachmentA
end
local towedModData = towedVehicle:getModData()
local spacingDistance = 1.0
if TowBarMod.Config and tonumber(TowBarMod.Config.rigidTowbarDistance) ~= nil then
spacingDistance = tonumber(TowBarMod.Config.rigidTowbarDistance)
end
local towingBaseX = tonumber(towingModData["towBarOriginalTowingOffsetX"]) or towingOffset:x()
local towingBaseZ = tonumber(towingModData["towBarOriginalTowingOffsetZ"]) or towingOffset:z()
local towingExteriorZ = getExteriorAttachmentZ(towingScript, attachmentA, towingBaseZ, 0)
towingAttachment:getOffset():set(towingBaseX, towingHeight, towingExteriorZ)
local offset = towedAttachment:getOffset()
local storedBaseX = tonumber(towedModData["towBarBaseAttachmentOffsetX"])
local storedBaseY = tonumber(towedModData["towBarBaseAttachmentOffsetY"])
local storedBaseZ = tonumber(towedModData["towBarBaseAttachmentOffsetZ"])
local hasStoredBase = towedModData["towBarBaseAttachmentId"] == attachmentB
and storedBaseX ~= nil and storedBaseY ~= nil and storedBaseZ ~= nil
local baseX = hasStoredBase and storedBaseX or offset:x()
local baseY = hasStoredBase and storedBaseY or offset:y()
local baseZ = hasStoredBase and storedBaseZ or offset:z()
if not hasStoredBase then
towedModData["towBarBaseAttachmentId"] = attachmentB
towedModData["towBarBaseAttachmentOffsetX"] = baseX
towedModData["towBarBaseAttachmentOffsetY"] = baseY
towedModData["towBarBaseAttachmentOffsetZ"] = baseZ
end
local towedExteriorZ = getExteriorAttachmentZ(towedScript, attachmentB, baseZ, spacingDistance)
local zShift = towedExteriorZ - baseZ
towedAttachment:getOffset():set(baseX, towedHeight, towedExteriorZ)
towedModData["isChangedTowedAttachment"] = true
towedModData["towBarChangedAttachmentId"] = attachmentB
towedModData["towBarChangedOffsetZShift"] = zShift
towedVehicle:transmitModData()
towingVehicle:transmitModData()
end
function TowBarMod.Utils.updateAttachmentsOnDefaultValues(towingVehicle, towedVehicle)
local towingModData = towingVehicle:getModData()
local towingAttachmentId = towingModData["towBarOriginalTowingAttachmentId"]
or towingVehicle:getTowAttachmentSelf()
local towingAttachment = towingVehicle:getScript():getAttachmentById(towingAttachmentId)
if towingAttachment ~= nil then
towingAttachment:setUpdateConstraint(true)
local zOffset = (towingAttachmentId == "trailer") and -1 or 1
towingAttachment:setZOffset(zOffset)
-- Restore the original offset that was overridden for rigid tow spacing.
local originalX = tonumber(towingModData["towBarOriginalTowingOffsetX"])
local originalY = tonumber(towingModData["towBarOriginalTowingOffsetY"])
local originalZ = tonumber(towingModData["towBarOriginalTowingOffsetZ"])
if originalX ~= nil and originalY ~= nil and originalZ ~= nil then
towingAttachment:getOffset():set(originalX, originalY, originalZ)
elseif originalY ~= nil then
local off = towingAttachment:getOffset()
towingAttachment:getOffset():set(off:x(), originalY, off:z())
end
end
towingModData["towBarOriginalTowingOffsetX"] = nil
towingModData["towBarOriginalTowingOffsetY"] = nil
towingModData["towBarOriginalTowingOffsetZ"] = nil
towingModData["towBarOriginalTowingAttachmentId"] = nil
towingVehicle:transmitModData()
local towedModData = towedVehicle:getModData()
local changedAttachmentId = towedModData["towBarChangedAttachmentId"] or towedVehicle:getTowAttachmentSelf()
local towedAttachment = towedVehicle:getScript():getAttachmentById(changedAttachmentId)
if towedAttachment ~= nil then
towedAttachment:setUpdateConstraint(true)
local zOffset = (changedAttachmentId == "trailer") and -1 or 1
towedAttachment:setZOffset(zOffset)
if towedModData["isChangedTowedAttachment"] then
local storedBaseX = tonumber(towedModData["towBarBaseAttachmentOffsetX"])
local storedBaseY = tonumber(towedModData["towBarBaseAttachmentOffsetY"])
local storedBaseZ = tonumber(towedModData["towBarBaseAttachmentOffsetZ"])
local hasStoredBase = towedModData["towBarBaseAttachmentId"] == changedAttachmentId
and storedBaseX ~= nil and storedBaseY ~= nil and storedBaseZ ~= nil
if hasStoredBase then
towedAttachment:getOffset():set(storedBaseX, storedBaseY, storedBaseZ)
else
local offset = towedAttachment:getOffset()
local storedShift = tonumber(towedModData["towBarChangedOffsetZShift"])
if storedShift ~= nil then
towedAttachment:getOffset():set(offset:x(), offset:y(), offset:z() - storedShift)
else
local zShift = offset:z() > 0 and -1 or 1
towedAttachment:getOffset():set(offset:x(), offset:y(), offset:z() + zShift)
end
end
end
end
towedModData["isChangedTowedAttachment"] = false
towedModData["towBarChangedAttachmentId"] = nil
towedModData["towBarChangedOffsetZShift"] = nil
towedModData["towBarBaseAttachmentId"] = nil
towedModData["towBarBaseAttachmentOffsetX"] = nil
towedModData["towBarBaseAttachmentOffsetY"] = nil
towedModData["towBarBaseAttachmentOffsetZ"] = nil
towedVehicle:transmitModData()
end
-----------------------------------------------------------
--- Fix mods that add vehicles without tow attachments
local function fixTowAttachmentsForOtherVehicleMods()
local scriptManager = getScriptManager()
local vehicleScripts = scriptManager:getAllVehicleScripts()
for i = 0, vehicleScripts:size()-1 do
local script = vehicleScripts:get(i)
local wheelCount = script:getWheelCount()
local attachHeigtOffset = -0.5
if wheelCount > 0 then
attachHeigtOffset = script:getWheel(0):getOffset():y() + 0.1
end
if not string.match(string.lower(script:getName()), "trailer") then
local trailerAttachment = script:getAttachmentById("trailer")
if trailerAttachment == nil then
local attach = ModelAttachment.new("trailer")
attach:getOffset():set(0, attachHeigtOffset, -script:getPhysicsChassisShape():z()/2 - 0.1)
attach:setZOffset(-1)
script:addAttachment(attach)
end
local trailerFrontAttachment = script:getAttachmentById("trailerfront")
if trailerFrontAttachment == nil then
local attach = ModelAttachment.new("trailerfront")
attach:getOffset():set(0, attachHeigtOffset, script:getPhysicsChassisShape():z()/2 + 0.1)
attach:setZOffset(1)
script:addAttachment(attach)
end
end
end
end
Events.OnGameBoot.Add(fixTowAttachmentsForOtherVehicleMods)
+89
View File
@@ -0,0 +1,89 @@
BTtow = {}
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 function getVehicleModelScale(script)
if not script then return nil end
local ok, result = pcall(function()
return script:getModelScale()
end)
if ok and type(result) == "number" then
return result
end
ok, result = pcall(function()
local model = script:getModel()
if model then
return model:getScale()
end
return nil
end)
if ok and type(result) == "number" then
return result
end
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))
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)
end
function BTtow.Create.towbar(vehicle, part)
if part == nil then return end
for j=0, TowbarVariantSize - 1 do
part:setModelVisible("towbar" .. j, false)
end
end
function BTtow.Init.towbar(vehicle, part)
if part == nil then return end
for j=0, TowbarVariantSize - 1 do
part:setModelVisible("towbar" .. j, false)
end
local modData = vehicle:getModData()
if modData and modData.towBarModelSwapInProgress then
return
end
if modData and modData["isTowingByTowBar"] and modData["towed"] then
local script = vehicle:getScript()
if script then
local index = getTowbarModelSlot(script)
local shouldShowOnThisPart = part:getId() == "towbar"
if shouldShowOnThisPart then
part:setModelVisible("towbar" .. index, true)
end
end
end
end
@@ -0,0 +1,71 @@
require 'Items/ProceduralDistributions'
require 'Items/SuburbsDistributions'
require 'Items/Distributions'
require 'Items/Distribution_BinJunk'
require 'Items/Distribution_ClosetJunk'
require 'Items/Distribution_DeskJunk'
require 'Items/Distribution_ShelfJunk'
require 'Items/Distribution_CounterJunk'
require 'Items/Distribution_SideTableJunk'
require 'Vehicles/VehicleDistributions'
require 'Vehicles/VehicleDistribution_GloveBoxJunk'
require 'Vehicles/VehicleDistribution_SeatJunk'
require 'Vehicles/VehicleDistribution_TrunkJunk'
----------------- TOW BAR -----------------------
-- Mirror Jack spawn chance into TowBar in container distributions (world + vehicle containers).
-- Intentionally excludes story-clutter floor placement tables (RandomizedWorldContent/StoryClutter).
local TOWBAR_ITEM_TYPE = "TowBar.TowBar"
local JACK_ITEM_TYPES = {
["Jack"] = true,
["Base.Jack"] = true,
}
local function addMissingTowBarsForJack(items)
if type(items) ~= "table" then return end
local jackCountByChance = {}
local towBarCountByChance = {}
for i = 1, #items, 2 do
local itemType = items[i]
local chance = tonumber(items[i + 1])
if type(itemType) == "string" and chance ~= nil then
if JACK_ITEM_TYPES[itemType] then
jackCountByChance[chance] = (jackCountByChance[chance] or 0) + 1
elseif itemType == TOWBAR_ITEM_TYPE then
towBarCountByChance[chance] = (towBarCountByChance[chance] or 0) + 1
end
end
end
for chance, jackCount in pairs(jackCountByChance) do
local missing = jackCount - (towBarCountByChance[chance] or 0)
for _ = 1, missing do
table.insert(items, TOWBAR_ITEM_TYPE)
table.insert(items, chance)
end
end
end
local function walkContainerDistributions(root, seen)
if type(root) ~= "table" or seen[root] then return end
seen[root] = true
for key, value in pairs(root) do
if key == "items" and type(value) == "table" then
addMissingTowBarsForJack(value)
elseif type(value) == "table" then
walkContainerDistributions(value, seen)
end
end
end
local seen = {}
walkContainerDistributions(ProceduralDistributions, seen)
walkContainerDistributions(SuburbsDistributions, seen)
walkContainerDistributions(Distributions, seen)
walkContainerDistributions(VehicleDistributions, seen)
walkContainerDistributions(ClutterTables, seen)
+669
View File
@@ -0,0 +1,669 @@
if isClient() then return end
local TowingCommands = {}
local Commands = {}
local TowBarItemType = "TowBar.TowBar"
local SyncDelayTicks = 2
local SnapshotIntervalTicks = 120
local BrokenPairAuditIntervalTicks = 5
local pendingSync = {}
local snapshotTickCounter = 0
local brokenPairAuditTickCounter = 0
local confirmedTowPairs = {}
local giveTowBar
TowingCommands.wantNoise = getDebug() or false
local noise = function(msg)
if TowingCommands.wantNoise then
print("TowBarCommands: " .. msg)
end
end
local function queueSync(kind, player, args, reservedTowBar)
if not args then return end
table.insert(pendingSync, {
kind = kind,
ticks = SyncDelayTicks,
attempts = 0,
reservedTowBar = reservedTowBar == true,
player = player,
args = args
})
end
local function cancelPendingAttach(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return end
local vehicleAId = vehicleA:getId()
local vehicleBId = vehicleB:getId()
local remaining = {}
for i = 1, #pendingSync do
local item = pendingSync[i]
local itemArgs = item.args or {}
local isThisAttach = item.kind == "attach"
and itemArgs.vehicleA == vehicleAId
and itemArgs.vehicleB == vehicleBId
if isThisAttach then
-- The reserved item is about to become the single ground drop.
item.reservedTowBar = false
else
table.insert(remaining, item)
end
end
pendingSync = remaining
end
local function hasPendingAttach(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return false end
local vehicleAId = vehicleA:getId()
local vehicleBId = vehicleB:getId()
for i = 1, #pendingSync do
local item = pendingSync[i]
local itemArgs = item.args or {}
if item.kind == "attach"
and itemArgs.vehicleA == vehicleAId
and itemArgs.vehicleB == vehicleBId then
return true
end
end
return false
end
local function resolveAttachmentA(args, vehicleA)
if args and args.attachmentA then return args.attachmentA end
if vehicleA and vehicleA:getTowAttachmentSelf() then return vehicleA:getTowAttachmentSelf() end
return "trailer"
end
local function resolveAttachmentB(args, vehicleB)
if args and args.attachmentB then return args.attachmentB end
if vehicleB and vehicleB:getTowAttachmentSelf() then return vehicleB:getTowAttachmentSelf() end
return "trailerfront"
end
local function isLinked(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return false end
return vehicleA:getVehicleTowing() == vehicleB and vehicleB:getVehicleTowedBy() == vehicleA
end
local function hasAnyTowLink(vehicle)
if not vehicle then return false end
return vehicle:getVehicleTowing() ~= nil or vehicle:getVehicleTowedBy() ~= nil
end
local function getTowBarPairKey(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return nil end
return tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())
end
local function markTowBarPairConfirmed(vehicleA, vehicleB)
local key = getTowBarPairKey(vehicleA, vehicleB)
if key then confirmedTowPairs[key] = true end
end
local function isTowBarPairConfirmed(vehicleA, vehicleB)
local key = getTowBarPairKey(vehicleA, vehicleB)
return key ~= nil and confirmedTowPairs[key] == true
end
local function forgetTowBarPair(vehicleA, vehicleB)
local key = getTowBarPairKey(vehicleA, vehicleB)
if key then confirmedTowPairs[key] = nil end
end
local function hasTowBarState(vehicle)
if not vehicle then return false end
local md = vehicle:getModData()
if not md then return false end
return md["isTowingByTowBar"] == true
end
local function markExpectedTowBarPair(vehicleA, vehicleB, attachmentA, attachmentB)
if not vehicleA or not vehicleB then return end
local towingModData = vehicleA:getModData()
local towedModData = vehicleB:getModData()
if not towingModData or not towedModData then return end
towingModData["isTowingByTowBar"] = true
towingModData["towed"] = false
towingModData["towBarTowedVehicleId"] = vehicleB:getId()
towingModData["towBarTowingVehicleId"] = nil
towingModData["towBarExpectedAttachment"] = attachmentA
towedModData["isTowingByTowBar"] = true
towedModData["towed"] = true
towedModData["towBarTowedVehicleId"] = nil
towedModData["towBarTowingVehicleId"] = vehicleA:getId()
towedModData["towBarExpectedAttachment"] = attachmentB
vehicleA:transmitModData()
vehicleB:transmitModData()
end
local function clearExpectedTowBarPair(vehicleA, vehicleB)
if vehicleA then
local towingModData = vehicleA:getModData()
if towingModData then
towingModData["isTowingByTowBar"] = false
towingModData["towed"] = false
towingModData["towBarTowedVehicleId"] = nil
towingModData["towBarTowingVehicleId"] = nil
towingModData["towBarExpectedAttachment"] = nil
vehicleA:transmitModData()
end
end
if vehicleB then
local towedModData = vehicleB:getModData()
if towedModData then
towedModData["isTowingByTowBar"] = false
towedModData["towed"] = false
towedModData["towBarTowedVehicleId"] = nil
towedModData["towBarTowingVehicleId"] = nil
towedModData["towBarExpectedAttachment"] = nil
vehicleB:transmitModData()
end
end
end
local function isExpectedTowBarPair(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return false end
local towingModData = vehicleA:getModData()
local towedModData = vehicleB:getModData()
if not towingModData or not towedModData then return false end
return towingModData["towBarTowedVehicleId"] == vehicleB:getId()
and towedModData["towBarTowingVehicleId"] == vehicleA:getId()
end
local function isLegacyTowBarPair(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return false end
local towingModData = vehicleA:getModData()
local towedModData = vehicleB:getModData()
if not towingModData or not towedModData then return false end
return hasTowBarState(vehicleA) and hasTowBarState(vehicleB)
and towedModData["towed"] == true
and towingModData["towBarTowedVehicleId"] == nil
and towingModData["towBarTowingVehicleId"] == nil
and towedModData["towBarTowedVehicleId"] == nil
and towedModData["towBarTowingVehicleId"] == nil
end
local function resolveExpectedTowBarPair(vehicle)
if not vehicle then return nil, nil end
local towingVehicle = vehicle:getVehicleTowing() and vehicle or vehicle:getVehicleTowedBy()
local towedVehicle = vehicle:getVehicleTowing() or (vehicle:getVehicleTowedBy() and vehicle or nil)
if towingVehicle and towedVehicle
and (isExpectedTowBarPair(towingVehicle, towedVehicle)
or isLegacyTowBarPair(towingVehicle, towedVehicle)) then
return towingVehicle, towedVehicle
end
local modData = vehicle:getModData()
if not modData then return nil, nil end
if modData["towBarTowedVehicleId"] ~= nil then
local towedVehicle = getVehicleById(modData["towBarTowedVehicleId"])
if isExpectedTowBarPair(vehicle, towedVehicle) then
return vehicle, towedVehicle
end
end
if modData["towBarTowingVehicleId"] ~= nil then
local towingVehicle = getVehicleById(modData["towBarTowingVehicleId"])
if isExpectedTowBarPair(towingVehicle, vehicle) then
return towingVehicle, vehicle
end
end
return nil, nil
end
local function isPlayerAuthorizedForPair(player, vehicleA, vehicleB)
if not player or not vehicleA or not vehicleB then return false end
local playerVehicle = player:getVehicle()
if playerVehicle == vehicleA or playerVehicle == vehicleB then
return true
end
local playerX = player:getX()
local playerY = player:getY()
local function isNear(vehicle)
local deltaX = playerX - vehicle:getX()
local deltaY = playerY - vehicle:getY()
return deltaX * deltaX + deltaY * deltaY <= 64
end
return isNear(vehicleA) or isNear(vehicleB)
end
local function findTowBarItem(player, itemId)
if not player then return nil end
local inventory = player:getInventory()
if not inventory then return nil end
local towBarItem = itemId and inventory:getItemWithID(itemId) or nil
if towBarItem and towBarItem:getFullType() ~= TowBarItemType then
towBarItem = nil
end
return towBarItem or inventory:getFirstTypeRecurse(TowBarItemType)
end
local function removeTowBarItem(player, towBarItem)
if not player or not towBarItem then return false end
local inventory = player:getInventory()
if not inventory or not inventory:contains(towBarItem, true) then return false end
local wasPrimary = player:isPrimaryHandItem(towBarItem)
local wasSecondary = player:isSecondaryHandItem(towBarItem)
player:removeFromHands(towBarItem)
inventory:Remove(towBarItem)
sendRemoveItemFromContainer(inventory, towBarItem)
if wasPrimary or wasSecondary then
sendEquip(player)
end
return true
end
local function forEachCollectionItem(collection, callback)
if not collection then 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 size
ok, size = pcall(function()
return collection:size()
end)
if not ok or type(size) ~= "number" then return end
for i = 0, size - 1 do
callback(collection:get(i))
end
end
local function broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB)
if not vehicleA or not vehicleB then return end
sendServerCommand("towbar", "forceAttachSync", {
vehicleA = vehicleA:getId(),
vehicleB = vehicleB:getId(),
attachmentA = attachmentA,
attachmentB = attachmentB
})
end
local function broadcastDetach(vehicleAId, vehicleBId)
sendServerCommand("towbar", "forceDetachSync", {
vehicleA = vehicleAId,
vehicleB = vehicleBId
})
end
local function broadcastSpontaneousDetach(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return end
sendServerCommand("towbar", "spontaneousDetachSync", {
vehicleA = vehicleA:getId(),
vehicleB = vehicleB:getId()
})
end
local function breakTowBarConstraint(towingVehicle, towedVehicle)
if not towingVehicle or not towedVehicle then return end
-- Only break a relation between this exact pair. Stale towbar metadata must
-- never disconnect either vehicle from a different trailer.
local towingReferencesTowed = towingVehicle:getVehicleTowing() == towedVehicle
or towingVehicle:getVehicleTowedBy() == towedVehicle
local towedReferencesTowing = towedVehicle:getVehicleTowing() == towingVehicle
or towedVehicle:getVehicleTowedBy() == towingVehicle
if towedReferencesTowing then
towedVehicle:breakConstraint(true, false)
end
if towingReferencesTowed then
towingVehicle:breakConstraint(true, false)
end
end
local function dropTowBarOnGround(towingVehicle, towedVehicle)
local square = towedVehicle and towedVehicle:getSquare() or nil
if not square and towingVehicle then
square = towingVehicle:getSquare()
end
if not square then
noise("could not drop broken towbar because neither vehicle has a square")
return false
end
local item = square:AddWorldInventoryItem(TowBarItemType, 0.5, 0.5, 0)
if not item then
noise("failed to drop broken towbar on the ground")
return false
end
return true
end
local function finalizeBrokenTowBarPair(towingVehicle, towedVehicle, reason)
if not towingVehicle or not towedVehicle then return false end
if not isExpectedTowBarPair(towingVehicle, towedVehicle)
and not isLegacyTowBarPair(towingVehicle, towedVehicle) then
return false
end
local shouldDrop = hasTowBarState(towingVehicle) or hasTowBarState(towedVehicle)
if shouldDrop and not dropTowBarOnGround(towingVehicle, towedVehicle) then
-- Keep the reciprocal IDs intact so the audit can retry when a square is available.
return false
end
cancelPendingAttach(towingVehicle, towedVehicle)
forgetTowBarPair(towingVehicle, towedVehicle)
breakTowBarConstraint(towingVehicle, towedVehicle)
clearExpectedTowBarPair(towingVehicle, towedVehicle)
if isServer() then
broadcastSpontaneousDetach(towingVehicle, towedVehicle)
elseif not isClient() and TowBarMod and TowBarMod.Hook
and TowBarMod.Hook.cleanupDetachedTowBar then
local ok, err = pcall(TowBarMod.Hook.cleanupDetachedTowBar, towingVehicle, towedVehicle)
if not ok then
noise("single-player broken towbar cleanup failed: " .. tostring(err))
return false
end
end
noise("finalized broken towbar pair " .. tostring(towingVehicle:getId())
.. ":" .. tostring(towedVehicle:getId()) .. " reason=" .. tostring(reason)
.. " dropped=" .. tostring(shouldDrop))
return true
end
local function reconcileBrokenTowBarPairsServer()
brokenPairAuditTickCounter = brokenPairAuditTickCounter + 1
if brokenPairAuditTickCounter < BrokenPairAuditIntervalTicks then return end
brokenPairAuditTickCounter = 0
local cell = getCell()
if not cell then return end
local vehicles = cell:getVehicles()
if not vehicles then return 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
end)
end
local function processAttachSync(item)
local args = item.args or {}
local vehicleA = args.vehicleA and getVehicleById(args.vehicleA) or nil
local vehicleB = args.vehicleB and getVehicleById(args.vehicleB) or nil
if not vehicleA or not vehicleB then
noise("attach sync skipped missing vehicles A=" .. tostring(args.vehicleA) .. " B=" .. tostring(args.vehicleB))
return "failed"
end
local attachmentA = resolveAttachmentA(args, vehicleA)
local attachmentB = resolveAttachmentB(args, vehicleB)
if not isLinked(vehicleA, vehicleB) then
if isTowBarPairConfirmed(vehicleA, vehicleB) then return "broken" end
if item.attempts >= 3 then return "failed" end
vehicleA:addPointConstraint(item.player, vehicleB, attachmentA, attachmentB)
return "retry"
end
markExpectedTowBarPair(vehicleA, vehicleB, attachmentA, attachmentB)
markTowBarPairConfirmed(vehicleA, vehicleB)
broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB)
return "complete"
end
local function failAttachSync(item)
local args = item.args or {}
local vehicleA = args.vehicleA and getVehicleById(args.vehicleA) or nil
local vehicleB = args.vehicleB and getVehicleById(args.vehicleB) or nil
if isLinked(vehicleA, vehicleB) then
vehicleA:breakConstraint(true, false)
end
clearExpectedTowBarPair(vehicleA, vehicleB)
broadcastDetach(args.vehicleA, args.vehicleB)
if item.reservedTowBar then
giveTowBar(item.player, true)
item.reservedTowBar = false
end
end
local function processDetachSync(item)
local args = item.args or {}
local vehicleAId = args.towingVehicle or args.vehicleA or args.vehicle
local vehicleBId = args.vehicleB or args.vehicle
broadcastDetach(vehicleAId, vehicleBId)
end
local function snapshotActiveTowbarLinksServer()
local cell = getCell()
if not cell then return end
local list = cell:getVehicles()
if not list then return end
forEachCollectionItem(list, function(towingVehicle)
local towedVehicle = towingVehicle and towingVehicle:getVehicleTowing() or nil
if towingVehicle and towedVehicle and towedVehicle:getVehicleTowedBy() == towingVehicle then
-- Preserve reciprocal modern pairs and clean legacy pairs only.
-- Stale modern IDs must not promote an unrelated current trailer.
if isExpectedTowBarPair(towingVehicle, towedVehicle)
or isLegacyTowBarPair(towingVehicle, towedVehicle) then
local attachmentA = resolveAttachmentA(nil, towingVehicle)
local towedMd = towedVehicle:getModData()
local attachmentB = (towedMd and towedMd["towBarChangedAttachmentId"]) or resolveAttachmentB(nil, towedVehicle)
if attachmentA == attachmentB then
attachmentA = "trailer"
attachmentB = "trailerfront"
end
markExpectedTowBarPair(towingVehicle, towedVehicle, attachmentA, attachmentB)
markTowBarPairConfirmed(towingVehicle, towedVehicle)
broadcastAttach(towingVehicle, towedVehicle, attachmentA, attachmentB)
end
end
end)
end
local function processPendingSync()
reconcileBrokenTowBarPairsServer()
snapshotTickCounter = snapshotTickCounter + 1
if snapshotTickCounter >= SnapshotIntervalTicks then
snapshotTickCounter = 0
snapshotActiveTowbarLinksServer()
end
if #pendingSync == 0 then return end
local remaining = {}
for i = 1, #pendingSync do
local item = pendingSync[i]
item.ticks = item.ticks - 1
if item.ticks <= 0 then
if item.kind == "attach" then
local status = processAttachSync(item)
if status == "retry" and item.attempts < 3 then
item.attempts = item.attempts + 1
item.ticks = SyncDelayTicks
table.insert(remaining, item)
elseif status == "broken" then
finalizeBrokenTowBarPair(
item.args.vehicleA and getVehicleById(item.args.vehicleA) or nil,
item.args.vehicleB and getVehicleById(item.args.vehicleB) or nil,
"attach-confirmation-break"
)
elseif status ~= "complete" then
failAttachSync(item)
end
elseif item.kind == "detach" then
processDetachSync(item)
end
else
table.insert(remaining, item)
end
end
pendingSync = remaining
end
function Commands.attachTowBar(player, args)
if not player or type(args) ~= "table" then return end
if type(args.attachmentA) ~= "string" or type(args.attachmentB) ~= "string" then return end
local vehicleA = getVehicleById(args.vehicleA)
local vehicleB = getVehicleById(args.vehicleB)
if not vehicleA then
noise("no such vehicle (A) id=" .. tostring(args.vehicleA))
return
end
if not vehicleB then
noise("no such vehicle (B) id=" .. tostring(args.vehicleB))
return
end
if vehicleA == vehicleB or not isPlayerAuthorizedForPair(player, vehicleA, vehicleB) then
noise("rejected invalid or remote towbar attach")
return
end
if hasAnyTowLink(vehicleA) or hasAnyTowLink(vehicleB) then
noise("rejected conflicting towbar attach")
return
end
if isExpectedTowBarPair(vehicleA, vehicleB)
or isExpectedTowBarPair(vehicleB, vehicleA)
or hasPendingAttach(vehicleA, vehicleB)
or hasPendingAttach(vehicleB, vehicleA) then
noise("rejected duplicate pending towbar attach")
return
end
local towBarItem = findTowBarItem(player, args.itemId)
if not towBarItem then
noise("rejected towbar attach without item")
return
end
if not removeTowBarItem(player, towBarItem) then
noise("rejected towbar attach because item reservation failed")
return
end
-- Constraint creation may complete on a later server tick in multiplayer.
-- Record and queue the reserved item first so an immediate Build 42.20
-- spontaneous break can resolve the pair and consume the reservation once.
markExpectedTowBarPair(vehicleA, vehicleB, args.attachmentA, args.attachmentB)
queueSync("attach", player, args, true)
vehicleA:addPointConstraint(player, vehicleB, args.attachmentA, args.attachmentB)
if isLinked(vehicleA, vehicleB) then
markTowBarPairConfirmed(vehicleA, vehicleB)
end
end
function Commands.detachTowBar(player, args)
local towingVehicle = args.towingVehicle and getVehicleById(args.towingVehicle) or nil
local towedVehicle = args.vehicle and getVehicleById(args.vehicle) or nil
if not towingVehicle and towedVehicle then
towingVehicle = towedVehicle:getVehicleTowedBy()
end
if not towedVehicle and towingVehicle then
towedVehicle = towingVehicle:getVehicleTowing()
end
if not isPlayerAuthorizedForPair(player, towingVehicle, towedVehicle) then
noise("rejected unauthorized towbar detach")
return
end
if not isLinked(towingVehicle, towedVehicle) then
noise("rejected mismatched or stale towbar detach")
return
end
if not isExpectedTowBarPair(towingVehicle, towedVehicle)
and not isLegacyTowBarPair(towingVehicle, towedVehicle) then
noise("rejected detach for a non-towbar or stale linked pair")
return
end
local shouldRefund = hasTowBarState(towingVehicle) or hasTowBarState(towedVehicle)
breakTowBarConstraint(towingVehicle, towedVehicle)
forgetTowBarPair(towingVehicle, towedVehicle)
clearExpectedTowBarPair(towingVehicle, towedVehicle)
queueSync("detach", player, args)
if shouldRefund then
giveTowBar(player, true)
end
end
function Commands.consumeTowBar(player, args)
local itemId = args and args.itemId
removeTowBarItem(player, findTowBarItem(player, itemId))
end
giveTowBar = function(player, equipPrimary)
if not player then return end
local inventory = player:getInventory()
if not inventory then return end
local towBarItem = inventory:AddItem(TowBarItemType)
if not towBarItem then return end
sendAddItemToContainer(inventory, towBarItem)
if equipPrimary then
player:setPrimaryHandItem(towBarItem)
sendEquip(player)
end
end
-- Compatibility aliases for older command names.
Commands.attachConstraint = Commands.attachTowBar
Commands.detachConstraint = Commands.detachTowBar
TowingCommands.OnClientCommand = function(module, command, player, args)
if module == "vehicle" and command == "detachTrailerSpontaneous" then
args = args or {}
local vehicle = args.vehicle and getVehicleById(args.vehicle) or nil
local towingVehicle, towedVehicle = resolveExpectedTowBarPair(vehicle)
if towingVehicle and towedVehicle then
finalizeBrokenTowBarPair(towingVehicle, towedVehicle, "detachTrailerSpontaneous")
end
return
end
if module == "towbar" and Commands[command] then
local argStr = ""
args = args or {}
for k, v in pairs(args) do
argStr = argStr .. " " .. tostring(k) .. "=" .. tostring(v)
end
noise("received " .. module .. " " .. command .. " " .. tostring(player) .. argStr)
Commands[command](player, args)
end
end
Events.OnClientCommand.Add(TowingCommands.OnClientCommand)
Events.OnTick.Add(processPendingSync)
@@ -0,0 +1,3 @@
{
"IGUI_VehicleParttowbar": "Towbar Attachment"
}
@@ -0,0 +1,3 @@
{
"ItemName_TowBar.TowBar": "Tow Bar"
}
@@ -0,0 +1,3 @@
{
"Tooltip_TowBar": "A tow bar can be used<br>to tow vehicles like a trailer"
}
@@ -0,0 +1,17 @@
{
"UI_Text_Towing_turnOffParkingBrake": "Turn off parking brake",
"UI_Text_Towing_turnOnParkingBrake": "Turn on parking brake",
"UI_Text_Towing_noAviableVehicles": "No vehicles for attach<br>or can't attach",
"UI_Text_Towing_attach": "Attach",
"UI_Text_Towing_deattach": "Deattach",
"UI_Text_Towing_byRope": "by rope",
"UI_Text_Towing_byTowBar": "by tow bar",
"UI_Text_Towing_byHook": "by hook",
"UI_Text_Towing_flipUpright": "Flip upright",
"UI_Text_Towing_cannotDriveWhileTowed": "Cannot drive while being towed",
"UI_Text_PushByHands": "Push vehicle",
"UI_Text_PushByHands_Left": "Left",
"UI_Text_PushByHands_Right": "Right",
"UI_Text_PushByHands_Front": "Front",
"UI_Text_PushByHands_Behind": "Behind"
}
+3
View File
@@ -0,0 +1,3 @@
-- Build 42 registry file.
-- This mod currently uses only base registries (for example ItemType = base:normal),
-- so no custom identifier registrations are required here.
+15
View File
@@ -0,0 +1,15 @@
module TowBar
{
/*******************Towing Car*******************/
item TowBar
{
DisplayCategory = Tool,
Weight = 8.0,
ItemType = base:normal,
Icon = TowBar,
Tooltip = Tooltip_TowBar,
StaticModel = towbarModel,
WorldStaticModel = towbarModel,
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,315 @@
module Base
{
template vehicle Battery
{
part towbar
{
model towbar0
{
file = towbarModel,
offset = 0 -0.3 1.0,
}
model towbar1
{
file = towbarModel,
offset = 0 -0.3 1.1,
}
model towbar2
{
file = towbarModel,
offset = 0 -0.3 1.2,
}
model towbar3
{
file = towbarModel,
offset = 0 -0.3 1.3,
}
model towbar4
{
file = towbarModel,
offset = 0 -0.3 1.4,
}
model towbar5
{
file = towbarModel,
offset = 0 -0.3 1.5,
}
model towbar6
{
file = towbarModel,
offset = 0 -0.3 1.6,
}
model towbar7
{
file = towbarModel,
offset = 0 -0.3 1.7,
}
model towbar8
{
file = towbarModel,
offset = 0 -0.3 1.8,
}
model towbar9
{
file = towbarModel,
offset = 0 -0.3 1.9,
}
model towbar10
{
file = towbarModel,
offset = 0 -0.3 2.0,
}
model towbar11
{
file = towbarModel,
offset = 0 -0.3 2.1,
}
model towbar12
{
file = towbarModel,
offset = 0 -0.3 2.2,
}
model towbar13
{
file = towbarModel,
offset = 0 -0.3 2.3,
}
model towbar14
{
file = towbarModel,
offset = 0 -0.3 2.4,
}
model towbar15
{
file = towbarModel,
offset = 0 -0.3 2.5,
}
model towbar16
{
file = towbarModel,
offset = 0 -0.3 2.6,
}
model towbar17
{
file = towbarModel,
offset = 0 -0.3 2.7,
}
model towbar18
{
file = towbarModel,
offset = 0 -0.3 2.8,
}
model towbar19
{
file = towbarModel,
offset = 0 -0.3 2.9,
}
model towbar20
{
file = towbarModel,
offset = 0 -0.3 3.0,
}
model towbar21
{
file = towbarModel,
offset = 0 -0.3 3.1,
}
model towbar22
{
file = towbarModel,
offset = 0 -0.3 3.2,
}
model towbar23
{
file = towbarModel,
offset = 0 -0.3 3.3,
}
area = Engine,
mechanicRequireKey = false,
lua
{
create = BTtow.Create.towbar,
init = BTtow.Init.towbar,
}
}
part towbarLarge
{
model towbar0
{
file = towbarModelLarge,
offset = 0 -0.3 1.0,
}
model towbar1
{
file = towbarModelLarge,
offset = 0 -0.3 1.1,
}
model towbar2
{
file = towbarModelLarge,
offset = 0 -0.3 1.2,
}
model towbar3
{
file = towbarModelLarge,
offset = 0 -0.3 1.3,
}
model towbar4
{
file = towbarModelLarge,
offset = 0 -0.3 1.4,
}
model towbar5
{
file = towbarModelLarge,
offset = 0 -0.3 1.5,
}
model towbar6
{
file = towbarModelLarge,
offset = 0 -0.3 1.6,
}
model towbar7
{
file = towbarModelLarge,
offset = 0 -0.3 1.7,
}
model towbar8
{
file = towbarModelLarge,
offset = 0 -0.3 1.8,
}
model towbar9
{
file = towbarModelLarge,
offset = 0 -0.3 1.9,
}
model towbar10
{
file = towbarModelLarge,
offset = 0 -0.3 2.0,
}
model towbar11
{
file = towbarModelLarge,
offset = 0 -0.3 2.1,
}
model towbar12
{
file = towbarModelLarge,
offset = 0 -0.3 2.2,
}
model towbar13
{
file = towbarModelLarge,
offset = 0 -0.3 2.3,
}
model towbar14
{
file = towbarModelLarge,
offset = 0 -0.3 2.4,
}
model towbar15
{
file = towbarModelLarge,
offset = 0 -0.3 2.5,
}
model towbar16
{
file = towbarModelLarge,
offset = 0 -0.3 2.6,
}
model towbar17
{
file = towbarModelLarge,
offset = 0 -0.3 2.7,
}
model towbar18
{
file = towbarModelLarge,
offset = 0 -0.3 2.8,
}
model towbar19
{
file = towbarModelLarge,
offset = 0 -0.3 2.9,
}
model towbar20
{
file = towbarModelLarge,
offset = 0 -0.3 3.0,
}
model towbar21
{
file = towbarModelLarge,
offset = 0 -0.3 3.1,
}
model towbar22
{
file = towbarModelLarge,
offset = 0 -0.3 3.2,
}
model towbar23
{
file = towbarModelLarge,
offset = 0 -0.3 3.3,
}
area = Engine,
mechanicRequireKey = false,
lua
{
create = BTtow.Create.towbar,
init = BTtow.Init.towbar,
}
}
part Battery
{
area = Engine,
itemType = Base.CarBattery,
mechanicRequireKey = true,
category = engine,
table install
{
items
{
1
{
type = Base.Screwdriver,
count = 1,
keep = true,
equip = primary,
}
}
time = 100,
professions = ,
skills = ,
traits = ,
recipes = ,
test = Vehicles.InstallTest.Default,
door = EngineDoor,
}
table uninstall
{
items
{
1
{
type = Base.Screwdriver,
count = 1,
keep = true,
equip = primary,
}
}
time = 100,
test = Vehicles.UninstallTest.Battery,
}
lua
{
create = Vehicles.Create.Battery,
update = Vehicles.Update.Battery,
}
}
}
}
@@ -0,0 +1,284 @@
module Base
{
model towbarModel
{
mesh = vehicles/Towbar,
texture = Vehicles/Towbar_Texture,
scale = 0.01,
}
model towbarModelLarge
{
mesh = vehicles/Towbar,
texture = Vehicles/Towbar_Texture,
scale = 0.02022,
}
template vehicle Towbar
{
part towbar
{
model towbar0
{
file = towbarModel,
offset = 0 -0.3 1.0,
}
model towbar1
{
file = towbarModel,
offset = 0 -0.3 1.1,
}
model towbar2
{
file = towbarModel,
offset = 0 -0.3 1.2,
}
model towbar3
{
file = towbarModel,
offset = 0 -0.3 1.3,
}
model towbar4
{
file = towbarModel,
offset = 0 -0.3 1.4,
}
model towbar5
{
file = towbarModel,
offset = 0 -0.3 1.5,
}
model towbar6
{
file = towbarModel,
offset = 0 -0.3 1.6,
}
model towbar7
{
file = towbarModel,
offset = 0 -0.3 1.7,
}
model towbar8
{
file = towbarModel,
offset = 0 -0.3 1.8,
}
model towbar9
{
file = towbarModel,
offset = 0 -0.3 1.9,
}
model towbar10
{
file = towbarModel,
offset = 0 -0.3 2.0,
}
model towbar11
{
file = towbarModel,
offset = 0 -0.3 2.1,
}
model towbar12
{
file = towbarModel,
offset = 0 -0.3 2.2,
}
model towbar13
{
file = towbarModel,
offset = 0 -0.3 2.3,
}
model towbar14
{
file = towbarModel,
offset = 0 -0.3 2.4,
}
model towbar15
{
file = towbarModel,
offset = 0 -0.3 2.5,
}
model towbar16
{
file = towbarModel,
offset = 0 -0.3 2.6,
}
model towbar17
{
file = towbarModel,
offset = 0 -0.3 2.7,
}
model towbar18
{
file = towbarModel,
offset = 0 -0.3 2.8,
}
model towbar19
{
file = towbarModel,
offset = 0 -0.3 2.9,
}
model towbar20
{
file = towbarModel,
offset = 0 -0.3 3.0,
}
model towbar21
{
file = towbarModel,
offset = 0 -0.3 3.1,
}
model towbar22
{
file = towbarModel,
offset = 0 -0.3 3.2,
}
model towbar23
{
file = towbarModel,
offset = 0 -0.3 3.3,
}
area = Engine,
mechanicRequireKey = false,
lua
{
create = BTtow.Create.towbar,
init = BTtow.Init.towbar,
}
}
part towbarLarge
{
model towbar0
{
file = towbarModelLarge,
offset = 0 -0.3 1.0,
}
model towbar1
{
file = towbarModelLarge,
offset = 0 -0.3 1.1,
}
model towbar2
{
file = towbarModelLarge,
offset = 0 -0.3 1.2,
}
model towbar3
{
file = towbarModelLarge,
offset = 0 -0.3 1.3,
}
model towbar4
{
file = towbarModelLarge,
offset = 0 -0.3 1.4,
}
model towbar5
{
file = towbarModelLarge,
offset = 0 -0.3 1.5,
}
model towbar6
{
file = towbarModelLarge,
offset = 0 -0.3 1.6,
}
model towbar7
{
file = towbarModelLarge,
offset = 0 -0.3 1.7,
}
model towbar8
{
file = towbarModelLarge,
offset = 0 -0.3 1.8,
}
model towbar9
{
file = towbarModelLarge,
offset = 0 -0.3 1.9,
}
model towbar10
{
file = towbarModelLarge,
offset = 0 -0.3 2.0,
}
model towbar11
{
file = towbarModelLarge,
offset = 0 -0.3 2.1,
}
model towbar12
{
file = towbarModelLarge,
offset = 0 -0.3 2.2,
}
model towbar13
{
file = towbarModelLarge,
offset = 0 -0.3 2.3,
}
model towbar14
{
file = towbarModelLarge,
offset = 0 -0.3 2.4,
}
model towbar15
{
file = towbarModelLarge,
offset = 0 -0.3 2.5,
}
model towbar16
{
file = towbarModelLarge,
offset = 0 -0.3 2.6,
}
model towbar17
{
file = towbarModelLarge,
offset = 0 -0.3 2.7,
}
model towbar18
{
file = towbarModelLarge,
offset = 0 -0.3 2.8,
}
model towbar19
{
file = towbarModelLarge,
offset = 0 -0.3 2.9,
}
model towbar20
{
file = towbarModelLarge,
offset = 0 -0.3 3.0,
}
model towbar21
{
file = towbarModelLarge,
offset = 0 -0.3 3.1,
}
model towbar22
{
file = towbarModelLarge,
offset = 0 -0.3 3.2,
}
model towbar23
{
file = towbarModelLarge,
offset = 0 -0.3 3.3,
}
area = Engine,
mechanicRequireKey = false,
lua
{
create = BTtow.Create.towbar,
init = BTtow.Init.towbar,
}
}
}
}
+10
View File
@@ -0,0 +1,10 @@
name=Towbars
id=hrsys_towbars
poster=../common/media/textures/preview.png
description=Tow bars for vehicle-to-vehicle towing.
author=Riggs0
category=vehicle
icon=../common/media/textures/tow_bar_icon.png
url=https://hudsonriggs.systems
modversion=1.0.5
versionMin=42.20.0
+1 -1
View File
@@ -6,5 +6,5 @@ author=Riggs0
category=vehicle category=vehicle
versionMin=42.13.0 versionMin=42.13.0
url=https://hudsonriggs.systems url=https://hudsonriggs.systems
modversion=1.0.2 modversion=1.0.5
icon=common/media/textures/tow_bar_icon.png icon=common/media/textures/tow_bar_icon.png
+168
View File
@@ -0,0 +1,168 @@
$ErrorActionPreference = "Stop"
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$releaseRoot = Join-Path $repositoryRoot "42.20"
$baselineRoot = Join-Path $repositoryRoot "42.18"
$gameRoot = "D:\SteamLibrary\steamapps\common\ProjectZomboid"
$failures = [Collections.Generic.List[string]]::new()
function Assert-True {
param(
[bool]$Condition,
[string]$Message
)
if (-not $Condition) {
$script:failures.Add($Message)
}
}
function Get-RelativeFileNames {
param([string]$Root)
return Get-ChildItem -LiteralPath $Root -Recurse -File |
ForEach-Object { $_.FullName.Substring($Root.Length + 1).Replace("\", "/") } |
Sort-Object
}
Assert-True (Test-Path -LiteralPath $releaseRoot) "Missing 42.20 release folder."
$releaseInfo = Get-Content -LiteralPath (Join-Path $releaseRoot "mod.info")
$rootInfo = Get-Content -LiteralPath (Join-Path $repositoryRoot "mod.info")
Assert-True ($releaseInfo -contains "id=hrsys_towbars") "42.20 must use the stable mod id."
Assert-True ($releaseInfo -contains "versionMin=42.20.0") "42.20 must require Build 42.20."
Assert-True ($releaseInfo -contains "modversion=1.0.5") "42.20 must declare mod version 1.0.5."
Assert-True ($rootInfo -contains "modversion=1.0.5") "Root and release mod versions must match."
$baselineFiles = Get-RelativeFileNames $baselineRoot
$releaseFiles = Get-RelativeFileNames $releaseRoot
Assert-True (-not (Compare-Object $baselineFiles $releaseFiles)) "42.20 file layout must match the latest baseline."
foreach ($jsonFile in Get-ChildItem -LiteralPath $releaseRoot -Recurse -Filter "*.json") {
try {
$null = Get-Content -LiteralPath $jsonFile.FullName -Raw | ConvertFrom-Json
}
catch {
$failures.Add("Invalid JSON: $($jsonFile.FullName)")
}
}
$serverCommandsPath = Join-Path $releaseRoot "media/lua/server/TowingCommands.lua"
$btTowPath = Join-Path $releaseRoot "media/lua/server/BTTow.lua"
$clientSyncPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowSyncClient.lua"
$hookingPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowingHooking.lua"
$towbarTemplatePath = Join-Path $releaseRoot "media/scripts/vehicles/template_towbar.txt"
$serverCommands = Get-Content -LiteralPath $serverCommandsPath -Raw
$btTow = Get-Content -LiteralPath $btTowPath -Raw
$clientSync = Get-Content -LiteralPath $clientSyncPath -Raw
$hooking = Get-Content -LiteralPath $hookingPath -Raw
$towbarTemplate = Get-Content -LiteralPath $towbarTemplatePath -Raw
Assert-True ($serverCommands -match 'detachTrailerSpontaneous') "Server must handle Build 42.20 spontaneous towing detach."
Assert-True ($serverCommands -match 'towBarTowedVehicleId') "Server must persist the expected towbar pair."
Assert-True ($serverCommands -match '(?s)local function markExpectedTowBarPair.*?towingModData\["towBarTowingVehicleId"\] = nil.*?towedModData\["towBarTowedVehicleId"\] = nil') "Pair marking must clear stale opposite-role IDs."
Assert-True ($serverCommands -match '(?s)local function clearExpectedTowBarPair.*?towingModData\["towBarTowedVehicleId"\] = nil.*?towingModData\["towBarTowingVehicleId"\] = nil.*?towedModData\["towBarTowedVehicleId"\] = nil.*?towedModData\["towBarTowingVehicleId"\] = nil') "Pair cleanup must clear both role IDs from both vehicles."
Assert-True ($serverCommands -match 'local function isExpectedTowBarPair') "Server must validate reciprocal expected towbar IDs."
Assert-True ($serverCommands -match '(?s)isExpectedTowBarPair.*?towBarTowedVehicleId.*?vehicleB:getId\(\).*?towBarTowingVehicleId.*?vehicleA:getId\(\)') "Expected towbar pair validation must be reciprocal."
Assert-True ($serverCommands -match 'local function isLegacyTowBarPair') "Server must isolate legacy pair recovery from stale modern pair IDs."
Assert-True ($serverCommands -match '(?s)isLegacyTowBarPair.*?hasTowBarState\(vehicleA\) and hasTowBarState\(vehicleB\).*?towed.*?== true.*?towingModData\["towBarTowedVehicleId"\].*?== nil.*?towingModData\["towBarTowingVehicleId"\].*?== nil.*?towedModData\["towBarTowedVehicleId"\].*?== nil.*?towedModData\["towBarTowingVehicleId"\].*?== nil') "Legacy recovery must require both role markers and no modern pair IDs on either vehicle."
Assert-True ($serverCommands -match '(?s)local function resolveExpectedTowBarPair.*?towingVehicle and towedVehicle.*?isExpectedTowBarPair\(towingVehicle, towedVehicle\)') "Current-link spontaneous resolution must still require reciprocal towbar identity."
Assert-True ($serverCommands -match '(?s)local function snapshotActiveTowbarLinksServer.*?isExpectedTowBarPair\(towingVehicle, towedVehicle\).*?isLegacyTowBarPair\(towingVehicle, towedVehicle\)') "Server snapshots must only preserve reciprocal or clean legacy towbar pairs."
Assert-True ($serverCommands -match 'spontaneousDetachSync') "Server must broadcast spontaneous towbar cleanup."
Assert-True ($serverCommands -match 'local vehicleBId = args\.vehicleB or args\.vehicle') "Manual detach sync must include the towed vehicle id."
Assert-True ($serverCommands -notmatch 'function Commands\.giveTowBar') "Clients must not be able to invoke the towbar refund helper."
Assert-True ($serverCommands -match 'isPlayerAuthorizedForPair') "Server refunds must validate the requesting player."
Assert-True ($serverCommands -match 'rejected towbar attach without item') "Multiplayer attach must require server-side towbar ownership."
Assert-True ($serverCommands -match '(?s)function Commands\.attachTowBar.*?if hasAnyTowLink\(vehicleA\) or hasAnyTowLink\(vehicleB\) then.*?return.*?if isExpectedTowBarPair\(vehicleA, vehicleB\).*?or isExpectedTowBarPair\(vehicleB, vehicleA\).*?or hasPendingAttach\(vehicleA, vehicleB\).*?or hasPendingAttach\(vehicleB, vehicleA\).*?then.*?return.*?local towBarItem = findTowBarItem') "Existing or reversed pending tow pairs must be rejected before consuming another towbar."
Assert-True ($serverCommands -match 'queueSync\("attach", player, args, true\)') "Multiplayer attach must reserve the towbar for delayed confirmation."
Assert-True ($serverCommands -match '(?s)function Commands\.attachTowBar.*?markExpectedTowBarPair\(vehicleA, vehicleB, args\.attachmentA, args\.attachmentB\).*?queueSync\("attach", player, args, true\).*?vehicleA:addPointConstraint') "Attach must record and queue the pair before constraint creation can break spontaneously."
Assert-True ($serverCommands -match 'local function failAttachSync') "Failed multiplayer attach must have a cleanup/refund path."
Assert-True ($serverCommands -match '(?s)local function processAttachSync.*?if isTowBarPairConfirmed\(vehicleA, vehicleB\) then return "broken" end') "A confirmed pair that breaks during attach confirmation must not be reattached."
Assert-True ($serverCommands -match '(?s)local function processPendingSync.*?status == "broken".*?finalizeBrokenTowBarPair') "A break during attach confirmation must use the shared ground-drop finalizer."
Assert-True ($serverCommands -match 'if item\.reservedTowBar then') "Failed multiplayer attach must refund the reserved towbar exactly once."
Assert-True ($serverCommands -match 'Constraint creation may complete on a later server tick') "Multiplayer attach must preserve delayed constraint confirmation."
Assert-True ($serverCommands -match 'local function finalizeBrokenTowBarPair') "Broken towbar cleanup must use one idempotent finalizer."
Assert-True ($serverCommands -match 'local function reconcileBrokenTowBarPairsServer') "Server and single-player must audit confirmed towbar constraints for silent breaks."
Assert-True ($serverCommands -match '(?s)local function reconcileBrokenTowBarPairsServer.*?towBarTowedVehicleId.*?getVehicleById.*?isExpectedTowBarPair.*?isLinked.*?markTowBarPairConfirmed.*?isTowBarPairConfirmed.*?finalizeBrokenTowBarPair') "The physical-link audit must confirm reciprocal pairs and finalize a missing constraint."
Assert-True ($serverCommands -match '(?s)local function processPendingSync.*?reconcileBrokenTowBarPairsServer\(\)') "The physical-link audit must run from the server/SP tick handler."
Assert-True ($serverCommands -match '(?s)local function finalizeBrokenTowBarPair.*?dropTowBarOnGround.*?cancelPendingAttach.*?breakTowBarConstraint.*?clearExpectedTowBarPair.*?broadcastSpontaneousDetach') "Broken-pair finalization must drop, disconnect, clear state, and synchronize cleanup."
Assert-True ($serverCommands -match '(?s)local function finalizeBrokenTowBarPair.*?if isServer\(\) then.*?elseif not isClient\(\).*?TowBarMod\.Hook\.cleanupDetachedTowBar') "Single-player breaks must directly restore the vehicle without relying on a server command."
Assert-True ($clientSync -match 'spontaneousDetachSync') "Client sync must receive spontaneous towbar cleanup."
Assert-True ($clientSync -match 'local function hasConflictingTowLink') "Client detach sync must detect links to unrelated vehicles."
Assert-True ($clientSync -match 'local function breakTowBarPair') "Client detach sync must break only the requested pair."
Assert-True ($clientSync -notmatch 'local function safeBreak') "Client detach sync must not use an unrestricted per-vehicle constraint break."
Assert-True ($clientSync -match '(?s)local function applyDetachSync.*?hasConflictingTowLink\(vehicleA, vehicleB\).*?return.*?breakTowBarPair\(vehicleA, vehicleB\).*?cleanupDetachedTowBar') "Client detach sync must reject conflicting links before cleanup."
Assert-True ($hooking -match 'function TowBarMod\.Hook\.cleanupDetachedTowBar') "Client must expose idempotent towbar cleanup."
Assert-True ($hooking -match '(?s)if modData\.towBarOriginalParkingBrakeOn ~= nil then.*?setParkingBrakeOn.*?end.*?if modData\.towBarOriginalParkingBrake ~= nil then.*?setParkingBrake.*?end.*?if modData\.towBarOriginalHandbrake ~= nil then.*?setHandbrake.*?end') "Free-rolling state may only change brake controls whose original values were captured."
Assert-True ($hooking -notmatch 'tryVehicleCall\(vehicle, "setBrake"') "Free-rolling state must not change an unrestorable brake control."
Assert-True ($hooking -notmatch 'tryVehicleCall\(vehicle, "setBraking"') "Free-rolling state must not change an unrestorable braking control."
Assert-True ($hooking -match '(?s)local function getTowbarModelSlot\(script\).*?if not isVanillaScale\(script\) then.*?return 0\s+end') "KI5 client rendering must select towbar slot 0."
Assert-True ($btTow -match '(?s)local function getTowbarModelSlot\(script\).*?if not isVanillaScale\(script\) then.*?return 0\s+end') "KI5 part initialization must select towbar slot 0."
Assert-True ($hooking -match '(?s)local function setTowBarModelVisible.*?local part = normalPart.*?part:setModelVisible\("towbar" \.\. index, true\)') "Automatic rendering must use the normal towbar part."
Assert-True ($btTow -match '(?s)function BTtow\.Init\.towbar.*?local shouldShowOnThisPart = part:getId\(\) == "towbar"') "Automatic part initialization must only show the normal towbar part."
Assert-True ($hooking -notmatch 'local part = isVanilla and normalPart or largePart') "KI5 automatic rendering must not select the large towbar part."
$normalTowbarPart = $towbarTemplate.Substring($towbarTemplate.IndexOf('part towbar'), $towbarTemplate.IndexOf('part towbarLarge') - $towbarTemplate.IndexOf('part towbar'))
Assert-True ($normalTowbarPart -match '(?s)model towbar0\s*\{\s*file = towbarModel,\s*offset = 0 -0\.3 1\.0,') "Normal towbar0 must retain the radial-menu Z=1.0 geometry."
Assert-True ($hooking -match 'function TowBarMod\.Hook\.setVehicleScriptWithTowBarHidden') "Vehicle script swaps must use the anti-flicker helper."
Assert-True ($hooking -match '(?s)setVehicleScriptWithTowBarHidden.*?setTowBarModelVisible\(vehicle, false\).*?towBarModelSwapInProgress.*?vehicle:setScriptName\(scriptName\).*?setTowBarModelVisible\(vehicle, false\)') "The anti-flicker helper must hide towbar models for the full script swap."
Assert-True ($btTow -match '(?s)function BTtow\.Init\.towbar.*?setModelVisible\("towbar" \.\. j, false\).*?towBarModelSwapInProgress.*?return') "Part initialization must not show a towbar during a script swap."
Assert-True ($hooking -notmatch 'towedVehicle:setScriptName\(') "All hook script swaps must use the anti-flicker helper."
Assert-True ($clientSync -notmatch 'vehicleB:setScriptName\(') "Client reconciliation must not bypass the anti-flicker helper."
Assert-True ($clientSync -match 'TowBarMod\.Hook\.setVehicleScriptWithTowBarHidden\(vehicleB, "notTowingA_Trailer"\)') "Client reconciliation must hide towbar models while changing scripts."
$spontaneousStart = $serverCommands.IndexOf('if module == "vehicle" and command == "detachTrailerSpontaneous" then')
$spontaneousEnd = $serverCommands.IndexOf('if module == "towbar" and Commands[command] then', $spontaneousStart)
Assert-True ($spontaneousStart -ge 0 -and $spontaneousEnd -gt $spontaneousStart) "Could not isolate the spontaneous detach handler."
if ($spontaneousStart -ge 0 -and $spontaneousEnd -gt $spontaneousStart) {
$spontaneousBranch = $serverCommands.Substring($spontaneousStart, $spontaneousEnd - $spontaneousStart)
Assert-True ([regex]::Matches($spontaneousBranch, 'finalizeBrokenTowBarPair\(towingVehicle, towedVehicle').Count -eq 1) "The multiplayer spontaneous event must use the shared broken-pair finalizer."
Assert-True ($spontaneousBranch -notmatch 'dropTowBarOnGround\(|breakTowBarConstraint\(|clearExpectedTowBarPair\(') "The spontaneous event must not bypass exactly-once finalization."
Assert-True ($spontaneousBranch -notmatch 'giveTowBar\(|inventory:AddItem|sendAddItemToContainer') "A spontaneously broken towbar must not be refunded to inventory."
}
Assert-True ($serverCommands -match '(?s)local function dropTowBarOnGround\(towingVehicle, towedVehicle\).*?getSquare\(\).*?AddWorldInventoryItem\(TowBarItemType, 0\.5, 0\.5, 0\)') "A broken towbar must be placed on the ground beside the tow pair."
Assert-True ([regex]::Matches($serverCommands, 'dropTowBarOnGround\(').Count -eq 2) "Only the shared finalizer may call the one towbar ground-drop helper."
$cleanupStart = $hooking.IndexOf('function TowBarMod.Hook.cleanupDetachedTowBar')
$cleanupEnd = $hooking.IndexOf('function TowBarMod.Hook.performDetachTowBar', $cleanupStart)
Assert-True ($cleanupStart -ge 0 -and $cleanupEnd -gt $cleanupStart) "Could not isolate client towbar cleanup."
if ($cleanupStart -ge 0 -and $cleanupEnd -gt $cleanupStart) {
$cleanupBranch = $hooking.Substring($cleanupStart, $cleanupEnd - $cleanupStart)
Assert-True ($cleanupBranch -match 'updateAttachmentsOnDefaultValues') "Break cleanup must restore attachment offsets."
Assert-True ($cleanupBranch -match 'setVehicleScriptWithTowBarHidden.*?towBarOriginalScriptName') "Break cleanup must restore the original vehicle script."
Assert-True ($cleanupBranch -match 'restoreFreeRollingTowState') "Break cleanup must restore mass, brakes, and parking-brake state."
Assert-True ($cleanupBranch -match 'setTowBarModelVisible\(towedVehicle, false\)') "Break cleanup must hide the towbar model."
Assert-True ($cleanupBranch -match '(?s)towingModData\["towBarTowedVehicleId"\] = nil.*?towingModData\["towBarTowingVehicleId"\] = nil.*?towedModData\["towBarTowedVehicleId"\] = nil.*?towedModData\["towBarTowingVehicleId"\] = nil') "Client cleanup must remove stale IDs for either tow role."
}
$manualDetachStart = $serverCommands.IndexOf('function Commands.detachTowBar')
$manualDetachEnd = $serverCommands.IndexOf('function Commands.consumeTowBar', $manualDetachStart)
Assert-True ($manualDetachStart -ge 0 -and $manualDetachEnd -gt $manualDetachStart) "Could not isolate the manual detach handler."
if ($manualDetachStart -ge 0 -and $manualDetachEnd -gt $manualDetachStart) {
$manualDetachBranch = $serverCommands.Substring($manualDetachStart, $manualDetachEnd - $manualDetachStart)
Assert-True ($manualDetachBranch -match 'if not isLinked\(towingVehicle, towedVehicle\) then') "Manual detach must reject mismatched or stale vehicle pairs."
Assert-True ($manualDetachBranch -match '(?s)not isExpectedTowBarPair\(towingVehicle, towedVehicle\).*?not isLegacyTowBarPair\(towingVehicle, towedVehicle\)') "Manual detach must reject non-towbar or stale linked pairs."
Assert-True ($manualDetachBranch -match 'breakTowBarConstraint\(towingVehicle, towedVehicle\)') "Manual detach must only break the validated tow pair."
Assert-True ($manualDetachBranch -match 'giveTowBar\(player, true\)') "Manual detach must continue returning the towbar to inventory."
Assert-True ($manualDetachBranch -notmatch 'dropTowBarOnGround') "Manual detach must not drop the towbar on the ground."
}
$vanillaVehicleCommandsPath = Join-Path $gameRoot "media/lua/server/Vehicles/VehicleCommands.lua"
if (Test-Path -LiteralPath $vanillaVehicleCommandsPath) {
$vanillaVehicleCommands = Get-Content -LiteralPath $vanillaVehicleCommandsPath -Raw
Assert-True ($vanillaVehicleCommands -match 'function Commands\.attachTrailer') "Installed game lacks the expected attach command."
Assert-True ($vanillaVehicleCommands -match 'function Commands\.detachTrailerSpontaneous') "Installed game lacks the Build 42.20 spontaneous detach command."
Assert-True ($vanillaVehicleCommands -match 'addPointConstraint') "Installed game lacks the expected towing constraint API."
}
$luaFiles = Get-ChildItem -LiteralPath (Join-Path $releaseRoot "media/lua") -Recurse -Filter "*.lua" |
ForEach-Object FullName
& npx --yes luaparse --quiet @luaFiles
Assert-True ($LASTEXITCODE -eq 0) "One or more 42.20 Lua files failed to parse."
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Error $_ }
exit 1
}
Write-Output "PASS: Build 42.20 metadata, layout, JSON, Lua, and towing compatibility checks."