diff --git a/42.13/media/lua/client/TowBar/Config.lua b/42.13/media/lua/client/TowBar/Config.lua index 77f509f..f956d1f 100644 --- a/42.13/media/lua/client/TowBar/Config.lua +++ b/42.13/media/lua/client/TowBar/Config.lua @@ -3,7 +3,7 @@ if not TowBarMod.Config then TowBarMod.Config = {} end TowBarMod.Config.lowLevelAnimation = "RemoveGrass" TowBarMod.Config.rigidTowbarDistance = 1.0 -TowBarMod.Config.devMode = true +TowBarMod.Config.devMode = false TowBarMod.Config.vanillaTowbarModelScaleMin = 1.5 TowBarMod.Config.vanillaTowbarModelScaleMax = 2.0 TowBarMod.Config.smallScaleTowbarIndexOffset = 2 diff --git a/42.13/media/lua/client/TowBar/TowSyncClient.lua b/42.13/media/lua/client/TowBar/TowSyncClient.lua new file mode 100644 index 0000000..f3f7771 --- /dev/null +++ b/42.13/media/lua/client/TowBar/TowSyncClient.lua @@ -0,0 +1,125 @@ +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 + 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 + vehicleB:transmitModData() + end + + vehicleB:setScriptName("notTowingA_Trailer") + 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 safeBreak(vehicle) + if not vehicle then return end + if vehicle:getVehicleTowing() == nil and vehicle:getVehicleTowedBy() == nil then return end + vehicle:breakConstraint(true, true) +end + +local function applyDetachSync(args) + if not args then return end + safeBreak(resolveVehicle(args.vehicleA)) + safeBreak(resolveVehicle(args.vehicleB)) +end + +local function onServerCommand(module, command, args) + if module ~= "towbar" then return end + + if command == "forceAttachSync" then + applyAttachSync(args) + elseif command == "forceDetachSync" then + applyDetachSync(args) + end +end + +Events.OnServerCommand.Add(onServerCommand) diff --git a/42.13/media/lua/client/TowBar/TowingHooking.lua b/42.13/media/lua/client/TowBar/TowingHooking.lua index 28d7f7a..44a008d 100644 --- a/42.13/media/lua/client/TowBar/TowingHooking.lua +++ b/42.13/media/lua/client/TowBar/TowingHooking.lua @@ -33,6 +33,19 @@ local function getTowBarItem(playerObj) 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 @@ -278,11 +291,38 @@ local function reattachTowBarPair(playerObj, towingVehicle, towedVehicle, requir attachmentA = attachmentA, attachmentB = attachmentB } - sendClientCommand(playerObj, "vehicle", "attachTrailer", args) + 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 @@ -295,8 +335,14 @@ local function recoverTowBarVehicleAfterLoad(playerObj, vehicle, retriesLeft) 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 reattachTowBarPair(localPlayer, towingVehicle, vehicle, false) then + if reattachTowBarPairAfterCleanDetach(localPlayer, towingVehicle, vehicle, false) then return end end @@ -376,7 +422,7 @@ function TowBarMod.Hook.performAttachTowBar(playerObj, towingVehicle, towedVehic attachmentA = attachmentA, attachmentB = attachmentB } - sendClientCommand(playerObj, "vehicle", "attachTrailer", args) + sendTowAttachCommand(playerObj, args) ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, 10, TowBarMod.Hook.setVehiclePostAttach, towedVehicle)) end diff --git a/42.13/media/lua/server/TowingCommands.lua b/42.13/media/lua/server/TowingCommands.lua index dbbe5c1..383e969 100644 --- a/42.13/media/lua/server/TowingCommands.lua +++ b/42.13/media/lua/server/TowingCommands.lua @@ -3,6 +3,10 @@ if isClient() then return end local TowingCommands = {} local Commands = {} local TowBarItemType = "TowBar.TowBar" +local SyncDelayTicks = 2 +local SnapshotIntervalTicks = 120 +local pendingSync = {} +local snapshotTickCounter = 0 TowingCommands.wantNoise = getDebug() or false @@ -12,6 +16,133 @@ local noise = function(msg) end end +local function queueSync(kind, player, args) + if not args then return end + table.insert(pendingSync, { + kind = kind, + ticks = SyncDelayTicks, + player = player, + args = args + }) +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 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 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 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 + end + + local attachmentA = resolveAttachmentA(args, vehicleA) + local attachmentB = resolveAttachmentB(args, vehicleB) + if not isLinked(vehicleA, vehicleB) then + vehicleA:addPointConstraint(item.player, vehicleB, attachmentA, attachmentB) + end + if isLinked(vehicleA, vehicleB) then + broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB) + 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 + 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 + + for i = 0, list:size() - 1 do + local towingVehicle = list:get(i) + local towedVehicle = towingVehicle and towingVehicle:getVehicleTowing() or nil + if towingVehicle and towedVehicle and towedVehicle:getVehicleTowedBy() == towingVehicle then + if hasTowBarState(towingVehicle) or hasTowBarState(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 + broadcastAttach(towingVehicle, towedVehicle, attachmentA, attachmentB) + end + end + end +end + +local function processPendingSync() + 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 + processAttachSync(item) + elseif item.kind == "detach" then + processDetachSync(item) + end + else + table.insert(remaining, item) + end + end + pendingSync = remaining +end + function Commands.attachTowBar(player, args) local vehicleA = getVehicleById(args.vehicleA) local vehicleB = getVehicleById(args.vehicleB) @@ -92,6 +223,16 @@ Commands.attachConstraint = Commands.attachTowBar Commands.detachConstraint = Commands.detachTowBar TowingCommands.OnClientCommand = function(module, command, player, args) + if module == "vehicle" and command == "attachTrailer" then + queueSync("attach", player, args) + elseif module == "vehicle" and command == "detachTrailer" then + queueSync("detach", player, args) + elseif module == "towbar" and command == "attachTowBar" then + queueSync("attach", player, args) + elseif module == "towbar" and command == "detachTowBar" then + queueSync("detach", player, args) + end + if module == "towbar" and Commands[command] then local argStr = "" args = args or {} @@ -104,3 +245,4 @@ TowingCommands.OnClientCommand = function(module, command, player, args) end Events.OnClientCommand.Add(TowingCommands.OnClientCommand) +Events.OnTick.Add(processPendingSync)