Landtrain!!!!
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
.tools/
|
||||||
|
|
||||||
|
# Logs and local patch leftovers
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
*.orig
|
||||||
|
*.rej
|
||||||
|
|
||||||
|
# Editor/IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# OS metadata
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
483
42.13/media/lua/client/Landtrain/UnlimitedTowbarChains.lua
Normal file
483
42.13/media/lua/client/Landtrain/UnlimitedTowbarChains.lua
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
if not TowBarMod then TowBarMod = {} end
|
||||||
|
if not TowBarMod.Utils then TowBarMod.Utils = {} end
|
||||||
|
if not TowBarMod.UI then TowBarMod.UI = {} end
|
||||||
|
|
||||||
|
local LANDTRAIN_DEBUG = false
|
||||||
|
local LANDTRAIN_ATTACH_LABEL = "Landtrain attach"
|
||||||
|
local LANDTRAIN_FALLBACK_MAX_DIST_SQ = 3.25 -- ~1.8 tiles
|
||||||
|
local LANDTRAIN_FALLBACK_MAX_DZ = 0.9
|
||||||
|
|
||||||
|
local function ltLog(msg)
|
||||||
|
if not LANDTRAIN_DEBUG then return end
|
||||||
|
print("[Landtrain] " .. tostring(msg))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function vehLabel(vehicle)
|
||||||
|
if vehicle == nil then return "nil" end
|
||||||
|
local name = vehicle:getScriptName() or "unknown"
|
||||||
|
return tostring(vehicle:getId()) .. ":" .. tostring(name)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function dumpTowState(prefix, vehicle)
|
||||||
|
if not LANDTRAIN_DEBUG then return end
|
||||||
|
if vehicle == nil then
|
||||||
|
ltLog(prefix .. " vehicle=nil")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local modData = vehicle:getModData() or {}
|
||||||
|
local front = vehicle:getVehicleTowedBy()
|
||||||
|
local rear = vehicle:getVehicleTowing()
|
||||||
|
ltLog(prefix
|
||||||
|
.. " v=" .. vehLabel(vehicle)
|
||||||
|
.. " front=" .. vehLabel(front)
|
||||||
|
.. " rear=" .. vehLabel(rear)
|
||||||
|
.. " md.isTowingByTowBar=" .. tostring(modData["isTowingByTowBar"])
|
||||||
|
.. " md.towed=" .. tostring(modData["towed"]))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function refreshTowBarState(vehicle)
|
||||||
|
if vehicle == nil then return end
|
||||||
|
|
||||||
|
local modData = vehicle:getModData()
|
||||||
|
if modData == nil then return end
|
||||||
|
|
||||||
|
local frontVehicle = vehicle:getVehicleTowedBy()
|
||||||
|
local rearVehicle = vehicle:getVehicleTowing()
|
||||||
|
|
||||||
|
local isTowedByTowBar = false
|
||||||
|
if frontVehicle ~= nil then
|
||||||
|
local frontModData = frontVehicle:getModData()
|
||||||
|
if modData["towed"] == true or (frontModData and frontModData["isTowingByTowBar"] == true) then
|
||||||
|
isTowedByTowBar = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local isTowingByTowBar = false
|
||||||
|
if rearVehicle ~= nil then
|
||||||
|
local rearModData = rearVehicle:getModData()
|
||||||
|
if rearModData and rearModData["towed"] == true and rearModData["isTowingByTowBar"] == true then
|
||||||
|
isTowingByTowBar = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
modData["towed"] = isTowedByTowBar
|
||||||
|
modData["isTowingByTowBar"] = (isTowedByTowBar or isTowingByTowBar)
|
||||||
|
vehicle:transmitModData()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setTowBarModelVisibleForVehicle(vehicle, visible)
|
||||||
|
if vehicle == nil then return end
|
||||||
|
local part = vehicle:getPartById("towbar")
|
||||||
|
if part == nil then return end
|
||||||
|
|
||||||
|
for j = 0, 23 do
|
||||||
|
part:setModelVisible("towbar" .. j, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
if visible then
|
||||||
|
local z = vehicle:getScript():getPhysicsChassisShape():z() / 2 - 0.1
|
||||||
|
local index = math.floor((z * 2 / 3 - 1) * 10)
|
||||||
|
if index < 0 then index = 0 end
|
||||||
|
if index > 23 then index = 23 end
|
||||||
|
part:setModelVisible("towbar" .. index, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
vehicle:doDamageOverlay()
|
||||||
|
end
|
||||||
|
|
||||||
|
local _landtrainHookPosA = Vector3f.new()
|
||||||
|
local _landtrainHookPosB = Vector3f.new()
|
||||||
|
|
||||||
|
local function hasTowAttachment(vehicle, attachmentId)
|
||||||
|
if vehicle == nil or attachmentId == nil then return false end
|
||||||
|
local script = vehicle:getScript()
|
||||||
|
if script == nil then return false end
|
||||||
|
return script:getAttachmentById(attachmentId) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isAttachmentSideFree(vehicle, attachmentId)
|
||||||
|
if vehicle == nil then return false end
|
||||||
|
if attachmentId == "trailer" then
|
||||||
|
return vehicle:getVehicleTowing() == nil
|
||||||
|
end
|
||||||
|
if attachmentId == "trailerfront" then
|
||||||
|
return vehicle:getVehicleTowedBy() == nil
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function canTowByLandtrain(vehicleA, vehicleB, attachmentA, attachmentB)
|
||||||
|
if vehicleA == nil or vehicleB == nil then return false end
|
||||||
|
if vehicleA == vehicleB then return false end
|
||||||
|
if not hasTowAttachment(vehicleA, attachmentA) then return false end
|
||||||
|
if not hasTowAttachment(vehicleB, attachmentB) then return false end
|
||||||
|
if not isAttachmentSideFree(vehicleA, attachmentA) then return false end
|
||||||
|
if not isAttachmentSideFree(vehicleB, attachmentB) then return false end
|
||||||
|
if vehicleA:getVehicleTowing() == vehicleB or vehicleA:getVehicleTowedBy() == vehicleB then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Keep vanilla behavior when possible.
|
||||||
|
if vehicleA:canAttachTrailer(vehicleB, attachmentA, attachmentB) then
|
||||||
|
ltLog("canTowByLandtrain vanilla=true A=" .. vehLabel(vehicleA) .. " B=" .. vehLabel(vehicleB) .. " attA=" .. tostring(attachmentA) .. " attB=" .. tostring(attachmentB))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Vanilla blocks chained towing here; allow only near-identical close-range hookups.
|
||||||
|
local posA = vehicleA:getAttachmentWorldPos(attachmentA, _landtrainHookPosA)
|
||||||
|
local posB = vehicleB:getAttachmentWorldPos(attachmentB, _landtrainHookPosB)
|
||||||
|
if posA == nil or posB == nil then return false end
|
||||||
|
|
||||||
|
local dx = posA:x() - posB:x()
|
||||||
|
local dy = posA:y() - posB:y()
|
||||||
|
local dz = posA:z() - posB:z()
|
||||||
|
local distSq = dx * dx + dy * dy + dz * dz
|
||||||
|
local allow = distSq <= LANDTRAIN_FALLBACK_MAX_DIST_SQ and math.abs(dz) <= LANDTRAIN_FALLBACK_MAX_DZ
|
||||||
|
ltLog("canTowByLandtrain fallback=" .. tostring(allow)
|
||||||
|
.. " A=" .. vehLabel(vehicleA)
|
||||||
|
.. " B=" .. vehLabel(vehicleB)
|
||||||
|
.. " attA=" .. tostring(attachmentA)
|
||||||
|
.. " attB=" .. tostring(attachmentB)
|
||||||
|
.. " distSq=" .. string.format("%.3f", distSq)
|
||||||
|
.. " dz=" .. string.format("%.3f", dz))
|
||||||
|
return allow
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getAttachLabel(vehicleA, vehicleB)
|
||||||
|
local isLandtrainLink = (vehicleA and (vehicleA:getVehicleTowing() or vehicleA:getVehicleTowedBy()))
|
||||||
|
or (vehicleB and (vehicleB:getVehicleTowing() or vehicleB:getVehicleTowedBy()))
|
||||||
|
if isLandtrainLink then
|
||||||
|
return LANDTRAIN_ATTACH_LABEL
|
||||||
|
end
|
||||||
|
return getText("UI_Text_Towing_byTowBar")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function captureTowbarFrontLink(towingVehicle)
|
||||||
|
if towingVehicle == nil then
|
||||||
|
ltLog("captureTowbarFrontLink towingVehicle=nil")
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local frontVehicle = towingVehicle:getVehicleTowedBy()
|
||||||
|
if frontVehicle == nil then
|
||||||
|
ltLog("captureTowbarFrontLink no front link for " .. vehLabel(towingVehicle))
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local link = {
|
||||||
|
frontVehicle = frontVehicle,
|
||||||
|
towingVehicle = towingVehicle,
|
||||||
|
attachmentA = frontVehicle:getTowAttachmentSelf() or "trailer",
|
||||||
|
attachmentB = towingVehicle:getTowAttachmentSelf() or "trailerfront"
|
||||||
|
}
|
||||||
|
ltLog("captureTowbarFrontLink captured front=" .. vehLabel(frontVehicle) .. " middle=" .. vehLabel(towingVehicle)
|
||||||
|
.. " attA=" .. tostring(link.attachmentA) .. " attB=" .. tostring(link.attachmentB))
|
||||||
|
return link
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restoreTowbarFrontLink(playerObj, link)
|
||||||
|
if link == nil then
|
||||||
|
ltLog("restoreTowbarFrontLink skipped (no captured link)")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local frontVehicle = link.frontVehicle
|
||||||
|
local towingVehicle = link.towingVehicle
|
||||||
|
if frontVehicle == nil or towingVehicle == nil then
|
||||||
|
ltLog("restoreTowbarFrontLink invalid captured refs")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if towingVehicle:getVehicleTowedBy() ~= nil then
|
||||||
|
ltLog("restoreTowbarFrontLink not needed; front still connected for middle=" .. vehLabel(towingVehicle))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
ltLog("restoreTowbarFrontLink restoring front=" .. vehLabel(frontVehicle) .. " middle=" .. vehLabel(towingVehicle))
|
||||||
|
TowBarMod.Utils.updateAttachmentsForRigidTow(frontVehicle, towingVehicle, link.attachmentA, link.attachmentB)
|
||||||
|
towingVehicle:setScriptName("notTowingA_Trailer")
|
||||||
|
|
||||||
|
local args = {
|
||||||
|
vehicleA = frontVehicle:getId(),
|
||||||
|
vehicleB = towingVehicle:getId(),
|
||||||
|
attachmentA = link.attachmentA,
|
||||||
|
attachmentB = link.attachmentB
|
||||||
|
}
|
||||||
|
sendClientCommand(playerObj, "vehicle", "attachTrailer", args)
|
||||||
|
ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, 10, TowBarMod.Hook.setVehiclePostAttach, towingVehicle))
|
||||||
|
|
||||||
|
local frontModData = frontVehicle:getModData()
|
||||||
|
local towingModData = towingVehicle:getModData()
|
||||||
|
frontModData["isTowingByTowBar"] = true
|
||||||
|
towingModData["isTowingByTowBar"] = true
|
||||||
|
towingModData["towed"] = true
|
||||||
|
frontVehicle:transmitModData()
|
||||||
|
towingVehicle:transmitModData()
|
||||||
|
dumpTowState("restoreTowbarFrontLink after front", frontVehicle)
|
||||||
|
dumpTowState("restoreTowbarFrontLink after middle", towingVehicle)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function queueTowbarFrontLinkRestore(playerObj, link, delayTicks)
|
||||||
|
if link == nil or playerObj == nil then return end
|
||||||
|
local delay = delayTicks or 15
|
||||||
|
ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, delay, function(character, linkArg)
|
||||||
|
ltLog("queueTowbarFrontLinkRestore fire delay=" .. tostring(delay) .. " middle=" .. vehLabel(linkArg and linkArg.towingVehicle or nil))
|
||||||
|
restoreTowbarFrontLink(character, linkArg)
|
||||||
|
end, link))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensureTowAttachmentsForTrailers()
|
||||||
|
local scriptManager = getScriptManager()
|
||||||
|
if scriptManager == nil then return end
|
||||||
|
|
||||||
|
local vehicleScripts = scriptManager:getAllVehicleScripts()
|
||||||
|
if vehicleScripts == nil then return end
|
||||||
|
|
||||||
|
for i = 0, vehicleScripts:size() - 1 do
|
||||||
|
local script = vehicleScripts:get(i)
|
||||||
|
local scriptName = script and script:getName() or nil
|
||||||
|
if script and scriptName and string.match(string.lower(scriptName), "trailer") then
|
||||||
|
local wheelCount = script:getWheelCount()
|
||||||
|
local attachHeightOffset = -0.5
|
||||||
|
if wheelCount > 0 then
|
||||||
|
attachHeightOffset = script:getWheel(0):getOffset():y() + 0.1
|
||||||
|
end
|
||||||
|
|
||||||
|
local rearTow = script:getAttachmentById("trailer")
|
||||||
|
if rearTow == nil then
|
||||||
|
local attach = ModelAttachment.new("trailer")
|
||||||
|
attach:getOffset():set(0, attachHeightOffset, -script:getPhysicsChassisShape():z() / 2 - 0.1)
|
||||||
|
attach:setZOffset(-1)
|
||||||
|
script:addAttachment(attach)
|
||||||
|
end
|
||||||
|
|
||||||
|
local frontTow = script:getAttachmentById("trailerfront")
|
||||||
|
if frontTow == nil then
|
||||||
|
local attach = ModelAttachment.new("trailerfront")
|
||||||
|
attach:getOffset():set(0, attachHeightOffset, script:getPhysicsChassisShape():z() / 2 + 0.1)
|
||||||
|
attach:setZOffset(1)
|
||||||
|
script:addAttachment(attach)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function menuHasTowbarAttachSlice(menu)
|
||||||
|
if menu == nil or menu.slices == nil then return false end
|
||||||
|
for _, slice in ipairs(menu.slices) do
|
||||||
|
local command = slice.command and slice.command[1]
|
||||||
|
if command == TowBarMod.Hook.attachByTowBarAction or command == TowBarMod.UI.showChooseVehicleMenu then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getLandtrainHookTypeVariants(vehicleA, vehicleB)
|
||||||
|
local hookTypeVariants = {}
|
||||||
|
if vehicleA == nil or vehicleB == nil or vehicleA == vehicleB then
|
||||||
|
return hookTypeVariants
|
||||||
|
end
|
||||||
|
|
||||||
|
if canTowByLandtrain(vehicleA, vehicleB, "trailerfront", "trailer") then
|
||||||
|
local hookType = {}
|
||||||
|
hookType.name = getText("UI_Text_Towing_attach") .. "\n" .. ISVehicleMenu.getVehicleDisplayName(vehicleB) .. "\n" .. LANDTRAIN_ATTACH_LABEL
|
||||||
|
hookType.func = TowBarMod.Hook.attachByTowBarAction
|
||||||
|
hookType.towingVehicle = vehicleB
|
||||||
|
hookType.towedVehicle = vehicleA
|
||||||
|
hookType.textureName = "tow_bar_icon"
|
||||||
|
table.insert(hookTypeVariants, hookType)
|
||||||
|
elseif canTowByLandtrain(vehicleA, vehicleB, "trailer", "trailerfront") then
|
||||||
|
local hookType = {}
|
||||||
|
hookType.name = getText("UI_Text_Towing_attach") .. "\n" .. ISVehicleMenu.getVehicleDisplayName(vehicleB) .. "\n" .. LANDTRAIN_ATTACH_LABEL
|
||||||
|
hookType.func = TowBarMod.Hook.attachByTowBarAction
|
||||||
|
hookType.towingVehicle = vehicleA
|
||||||
|
hookType.towedVehicle = vehicleB
|
||||||
|
hookType.textureName = "tow_bar_icon"
|
||||||
|
table.insert(hookTypeVariants, hookType)
|
||||||
|
end
|
||||||
|
|
||||||
|
return hookTypeVariants
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getNearbyLandtrainTargets(mainVehicle)
|
||||||
|
local vehicles = {}
|
||||||
|
local square = mainVehicle and mainVehicle:getSquare() or nil
|
||||||
|
if square == nil then return vehicles end
|
||||||
|
|
||||||
|
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 then
|
||||||
|
local variants = getLandtrainHookTypeVariants(mainVehicle, obj)
|
||||||
|
if #variants > 0 then
|
||||||
|
table.insert(vehicles, { vehicle = obj, variants = variants })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return vehicles
|
||||||
|
end
|
||||||
|
|
||||||
|
local function addLandtrainHookOptionToMenu(playerObj, vehicle)
|
||||||
|
if playerObj == nil or vehicle == nil then return end
|
||||||
|
if not playerObj:getInventory():getItemFromTypeRecurse("TowBar.TowBar") then return end
|
||||||
|
|
||||||
|
local menu = getPlayerRadialMenu(playerObj:getPlayerNum())
|
||||||
|
if menu == nil then return end
|
||||||
|
|
||||||
|
local targets = getNearbyLandtrainTargets(vehicle)
|
||||||
|
if #targets == 0 then
|
||||||
|
ltLog("addLandtrainHookOptionToMenu no nearby valid targets for " .. vehLabel(vehicle))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if #targets == 1 then
|
||||||
|
local hookType = targets[1].variants[1]
|
||||||
|
menu:addSlice(
|
||||||
|
hookType.name,
|
||||||
|
getTexture("media/textures/tow_bar_attach.png"),
|
||||||
|
hookType.func,
|
||||||
|
playerObj,
|
||||||
|
hookType.towingVehicle,
|
||||||
|
hookType.towedVehicle,
|
||||||
|
hookType.towingPoint,
|
||||||
|
hookType.towedPoint
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Reuse Towbar's chooser UI by passing only the candidate vehicles.
|
||||||
|
local vehicleList = {}
|
||||||
|
for _, entry in ipairs(targets) do
|
||||||
|
table.insert(vehicleList, entry.vehicle)
|
||||||
|
end
|
||||||
|
menu:addSlice(
|
||||||
|
LANDTRAIN_ATTACH_LABEL .. "...",
|
||||||
|
getTexture("media/textures/tow_bar_attach.png"),
|
||||||
|
TowBarMod.UI.showChooseVehicleMenu,
|
||||||
|
playerObj,
|
||||||
|
vehicle,
|
||||||
|
vehicleList,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function installLandtrainTowbarPatch()
|
||||||
|
if not TowBarMod or not TowBarMod.Utils then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if TowBarMod.Utils._landtrainUnlimitedChainsInstalled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Override Towbar's single-link limitation so a vehicle can be part of a chain.
|
||||||
|
function TowBarMod.Utils.getHookTypeVariants(vehicleA, vehicleB, hasTowBar)
|
||||||
|
local hookTypeVariants = {}
|
||||||
|
if not hasTowBar then return hookTypeVariants end
|
||||||
|
if vehicleA == nil or vehicleB == nil then return hookTypeVariants end
|
||||||
|
if vehicleA == vehicleB then return hookTypeVariants end
|
||||||
|
|
||||||
|
-- Allow trailer <-> vehicle and trailer <-> trailer links for landtrains.
|
||||||
|
if canTowByLandtrain(vehicleA, vehicleB, "trailerfront", "trailer") then
|
||||||
|
local hookType = {}
|
||||||
|
hookType.name = getText("UI_Text_Towing_attach") .. "\n" .. ISVehicleMenu.getVehicleDisplayName(vehicleB) .. "\n" .. getAttachLabel(vehicleA, vehicleB)
|
||||||
|
hookType.func = TowBarMod.Hook.attachByTowBarAction
|
||||||
|
hookType.towingVehicle = vehicleB
|
||||||
|
hookType.towedVehicle = vehicleA
|
||||||
|
hookType.textureName = "tow_bar_icon"
|
||||||
|
table.insert(hookTypeVariants, hookType)
|
||||||
|
elseif canTowByLandtrain(vehicleA, vehicleB, "trailer", "trailerfront") then
|
||||||
|
local hookType = {}
|
||||||
|
hookType.name = getText("UI_Text_Towing_attach") .. "\n" .. ISVehicleMenu.getVehicleDisplayName(vehicleB) .. "\n" .. getAttachLabel(vehicleA, vehicleB)
|
||||||
|
hookType.func = TowBarMod.Hook.attachByTowBarAction
|
||||||
|
hookType.towingVehicle = vehicleA
|
||||||
|
hookType.towedVehicle = vehicleB
|
||||||
|
hookType.textureName = "tow_bar_icon"
|
||||||
|
table.insert(hookTypeVariants, hookType)
|
||||||
|
end
|
||||||
|
|
||||||
|
return hookTypeVariants
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Keep towbar state valid for middle links in a chain after detach/attach.
|
||||||
|
local originalPerformAttach = TowBarMod.Hook and TowBarMod.Hook.performAttachTowBar
|
||||||
|
if originalPerformAttach then
|
||||||
|
TowBarMod.Hook.performAttachTowBar = function(playerObj, towingVehicle, towedVehicle, attachmentA, attachmentB)
|
||||||
|
ltLog("performAttachTowBar begin towing=" .. vehLabel(towingVehicle) .. " towed=" .. vehLabel(towedVehicle)
|
||||||
|
.. " attA=" .. tostring(attachmentA) .. " attB=" .. tostring(attachmentB))
|
||||||
|
dumpTowState("performAttachTowBar pre towing", towingVehicle)
|
||||||
|
dumpTowState("performAttachTowBar pre towed", towedVehicle)
|
||||||
|
local frontLink = captureTowbarFrontLink(towingVehicle)
|
||||||
|
originalPerformAttach(playerObj, towingVehicle, towedVehicle, attachmentA, attachmentB)
|
||||||
|
dumpTowState("performAttachTowBar post-original towing", towingVehicle)
|
||||||
|
dumpTowState("performAttachTowBar post-original towed", towedVehicle)
|
||||||
|
restoreTowbarFrontLink(playerObj, frontLink)
|
||||||
|
queueTowbarFrontLinkRestore(playerObj, frontLink, 12)
|
||||||
|
queueTowbarFrontLinkRestore(playerObj, frontLink, 30)
|
||||||
|
dumpTowState("performAttachTowBar post-restore towing", towingVehicle)
|
||||||
|
dumpTowState("performAttachTowBar post-restore towed", towedVehicle)
|
||||||
|
|
||||||
|
setTowBarModelVisibleForVehicle(towedVehicle, true)
|
||||||
|
refreshTowBarState(towingVehicle)
|
||||||
|
refreshTowBarState(towedVehicle)
|
||||||
|
if towingVehicle then
|
||||||
|
refreshTowBarState(towingVehicle:getVehicleTowedBy())
|
||||||
|
end
|
||||||
|
if towedVehicle then
|
||||||
|
refreshTowBarState(towedVehicle:getVehicleTowing())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local originalPerformDetach = TowBarMod.Hook and TowBarMod.Hook.performDeattachTowBar
|
||||||
|
if originalPerformDetach then
|
||||||
|
TowBarMod.Hook.performDeattachTowBar = function(playerObj, towingVehicle, towedVehicle)
|
||||||
|
originalPerformDetach(playerObj, towingVehicle, towedVehicle)
|
||||||
|
|
||||||
|
setTowBarModelVisibleForVehicle(towedVehicle, false)
|
||||||
|
refreshTowBarState(towingVehicle)
|
||||||
|
refreshTowBarState(towedVehicle)
|
||||||
|
if towingVehicle then
|
||||||
|
refreshTowBarState(towingVehicle:getVehicleTowedBy())
|
||||||
|
refreshTowBarState(towingVehicle:getVehicleTowing())
|
||||||
|
end
|
||||||
|
if towedVehicle then
|
||||||
|
refreshTowBarState(towedVehicle:getVehicleTowedBy())
|
||||||
|
refreshTowBarState(towedVehicle:getVehicleTowing())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Towbar UI only adds attach when fully unlinked; add attach option for linked vehicles too.
|
||||||
|
if ISVehicleMenu and ISVehicleMenu.showRadialMenu and not TowBarMod.UI._landtrainShowRadialPatched then
|
||||||
|
local originalShowRadialMenu = ISVehicleMenu.showRadialMenu
|
||||||
|
ISVehicleMenu.showRadialMenu = function(playerObj)
|
||||||
|
originalShowRadialMenu(playerObj)
|
||||||
|
|
||||||
|
if playerObj == nil or playerObj:getVehicle() then return end
|
||||||
|
local vehicle = ISVehicleMenu.getVehicleToInteractWith(playerObj)
|
||||||
|
if vehicle == nil then return end
|
||||||
|
|
||||||
|
local menu = getPlayerRadialMenu(playerObj:getPlayerNum())
|
||||||
|
if menuHasTowbarAttachSlice(menu) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if (vehicle:getVehicleTowing() or vehicle:getVehicleTowedBy()) then
|
||||||
|
addLandtrainHookOptionToMenu(playerObj, vehicle)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
TowBarMod.UI._landtrainShowRadialPatched = true
|
||||||
|
end
|
||||||
|
|
||||||
|
TowBarMod.Utils._landtrainUnlimitedChainsInstalled = true
|
||||||
|
end
|
||||||
|
|
||||||
|
Events.OnGameBoot.Add(ensureTowAttachmentsForTrailers)
|
||||||
|
Events.OnGameBoot.Add(installLandtrainTowbarPatch)
|
||||||
|
Events.OnGameStart.Add(installLandtrainTowbarPatch)
|
||||||
9
42.13/mod.info
Normal file
9
42.13/mod.info
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
name=Landtrain
|
||||||
|
id=hrsys_landtrain
|
||||||
|
require=\hrsys_towbars
|
||||||
|
description=Extends Towbars to allow chained vehicle towing.
|
||||||
|
author=Riggs0
|
||||||
|
category=vehicle
|
||||||
|
versionMin=42.13.0
|
||||||
|
url=https://hudsonriggs.systems
|
||||||
|
modversion=1.0.0
|
||||||
29
README.md
29
README.md
@@ -1,3 +1,30 @@
|
|||||||
# Landtrain
|
# Landtrain
|
||||||
|
|
||||||
Yargh I be a land pirate, or g'day chap wanna vb with that land train
|
Landtrain extends Towbars to support chained towing.
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
Project Zomboid base `BaseVehicle.addPointConstraint()` force-breaks existing constraints.
|
||||||
|
To keep `1 -> 2` while attaching `2 -> 3`, Landtrain includes a Java class override:
|
||||||
|
|
||||||
|
- `zombie/vehicles/BaseVehicle.class`
|
||||||
|
|
||||||
|
This is the same override pattern used by mods like Realistic Car Physics (manual `zombie` folder copy).
|
||||||
|
|
||||||
|
## Apply patch to game
|
||||||
|
|
||||||
|
1. Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\tools\patch-game-basevehicle.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Ensure both mods are enabled:
|
||||||
|
- `hrsys_towbars`
|
||||||
|
- `hrsys_landtrain`
|
||||||
|
|
||||||
|
## Restore vanilla class
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\tools\restore-game-basevehicle.ps1
|
||||||
|
```
|
||||||
|
|||||||
9
mod.info
Normal file
9
mod.info
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
name=Landtrain
|
||||||
|
id=hrsys_landtrain
|
||||||
|
require=\hrsys_towbars
|
||||||
|
description=Extends Towbars to allow chained vehicle towing.
|
||||||
|
author=Riggs0
|
||||||
|
category=vehicle
|
||||||
|
versionMin=42.13.0
|
||||||
|
url=https://hudsonriggs.systems
|
||||||
|
modversion=1.0.0
|
||||||
105
tools/java/BaseVehicleConstraintPatch.java
Normal file
105
tools/java/BaseVehicleConstraintPatch.java
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import org.objectweb.asm.ClassReader;
|
||||||
|
import org.objectweb.asm.ClassWriter;
|
||||||
|
import org.objectweb.asm.tree.AbstractInsnNode;
|
||||||
|
import org.objectweb.asm.tree.ClassNode;
|
||||||
|
import org.objectweb.asm.tree.InsnNode;
|
||||||
|
import org.objectweb.asm.tree.InsnList;
|
||||||
|
import org.objectweb.asm.tree.MethodInsnNode;
|
||||||
|
import org.objectweb.asm.tree.MethodNode;
|
||||||
|
import org.objectweb.asm.Opcodes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patches zombie.vehicles.BaseVehicle so addPointConstraint() no longer force-breaks
|
||||||
|
* both vehicles before creating a new constraint.
|
||||||
|
*/
|
||||||
|
public final class BaseVehicleConstraintPatch {
|
||||||
|
private static final String TARGET_NAME = "addPointConstraint";
|
||||||
|
private static final String BREAK_DESC_OBJECT_BOOL = "(ZLjava/lang/Boolean;)V";
|
||||||
|
private static final String BREAK_DESC_PRIMITIVE_BOOL = "(ZZ)V";
|
||||||
|
private static final String BASE_VEHICLE_OWNER = "zombie/vehicles/BaseVehicle";
|
||||||
|
|
||||||
|
private BaseVehicleConstraintPatch() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
if (args.length != 2) {
|
||||||
|
System.err.println("Usage: BaseVehicleConstraintPatch <input BaseVehicle.class> <output BaseVehicle.class>");
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path input = Paths.get(args[0]);
|
||||||
|
Path output = Paths.get(args[1]);
|
||||||
|
byte[] original = Files.readAllBytes(input);
|
||||||
|
|
||||||
|
ClassNode classNode = new ClassNode();
|
||||||
|
new ClassReader(original).accept(classNode, 0);
|
||||||
|
|
||||||
|
int removedCalls = 0;
|
||||||
|
int inspectedAddPointMethods = 0;
|
||||||
|
for (MethodNode method : classNode.methods) {
|
||||||
|
if (!TARGET_NAME.equals(method.name) || !isTargetAddPointConstraint(method.desc)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inspectedAddPointMethods++;
|
||||||
|
removedCalls += patchAddPointConstraint(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedCalls < 2) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Expected to remove 2 breakConstraint calls, removed "
|
||||||
|
+ removedCalls
|
||||||
|
+ " (inspected addPoint methods: "
|
||||||
|
+ inspectedAddPointMethods
|
||||||
|
+ ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
ClassWriter writer = new ClassWriter(0);
|
||||||
|
classNode.accept(writer);
|
||||||
|
|
||||||
|
Files.createDirectories(output.getParent());
|
||||||
|
Files.write(output, writer.toByteArray());
|
||||||
|
System.out.println("Patched BaseVehicle.class; removed breakConstraint calls: " + removedCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isTargetAddPointConstraint(String methodDesc) {
|
||||||
|
// We only want the 5-arg overload:
|
||||||
|
// (IsoPlayer, BaseVehicle, String, String, boolean|Boolean) -> void
|
||||||
|
return "(Lzombie/characters/IsoPlayer;Lzombie/vehicles/BaseVehicle;Ljava/lang/String;Ljava/lang/String;Z)V"
|
||||||
|
.equals(methodDesc)
|
||||||
|
|| "(Lzombie/characters/IsoPlayer;Lzombie/vehicles/BaseVehicle;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;)V"
|
||||||
|
.equals(methodDesc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int patchAddPointConstraint(MethodNode method) {
|
||||||
|
int patched = 0;
|
||||||
|
InsnList insns = method.instructions;
|
||||||
|
|
||||||
|
for (AbstractInsnNode node = insns.getFirst(); node != null; ) {
|
||||||
|
AbstractInsnNode next = node.getNext();
|
||||||
|
if (node instanceof MethodInsnNode call
|
||||||
|
&& BASE_VEHICLE_OWNER.equals(call.owner)
|
||||||
|
&& "breakConstraint".equals(call.name)) {
|
||||||
|
if (!(BREAK_DESC_OBJECT_BOOL.equals(call.desc)
|
||||||
|
|| BREAK_DESC_PRIMITIVE_BOOL.equals(call.desc))) {
|
||||||
|
node = next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Keep stack-map frames valid by preserving stack effect:
|
||||||
|
// breakConstraint(...) consumes objectref + 2 args and returns void.
|
||||||
|
// Replace invoke with POP2 + POP (consume 3 category-1 stack slots).
|
||||||
|
InsnList replacement = new InsnList();
|
||||||
|
replacement.add(new InsnNode(Opcodes.POP2));
|
||||||
|
replacement.add(new InsnNode(Opcodes.POP));
|
||||||
|
insns.insert(node, replacement);
|
||||||
|
insns.remove(node);
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
|
node = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return patched;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
tools/patch-game-basevehicle.ps1
Normal file
80
tools/patch-game-basevehicle.ps1
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
param(
|
||||||
|
[string]$GameRoot = "D:\SteamLibrary\steamapps\common\ProjectZomboid"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$toolsDir = Join-Path $repoRoot ".tools"
|
||||||
|
$classPatchDir = Join-Path $toolsDir "classpatch"
|
||||||
|
$buildDir = Join-Path $toolsDir "patcher-build"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path $classPatchDir | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path $buildDir | Out-Null
|
||||||
|
|
||||||
|
$javaExe = Join-Path $GameRoot "jre64\bin\java.exe"
|
||||||
|
$gameJar = Join-Path $GameRoot "projectzomboid.jar"
|
||||||
|
if (-not (Test-Path $javaExe)) { throw "java.exe not found at $javaExe" }
|
||||||
|
if (-not (Test-Path $gameJar)) { throw "projectzomboid.jar not found at $gameJar" }
|
||||||
|
|
||||||
|
$ecjJar = Join-Path $toolsDir "ecj.jar"
|
||||||
|
$asmJar = Join-Path $toolsDir "asm.jar"
|
||||||
|
$asmTreeJar = Join-Path $toolsDir "asm-tree.jar"
|
||||||
|
|
||||||
|
if (-not (Test-Path $ecjJar)) {
|
||||||
|
Invoke-WebRequest -Uri "https://repo1.maven.org/maven2/org/eclipse/jdt/ecj/3.38.0/ecj-3.38.0.jar" -OutFile $ecjJar
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $asmJar)) {
|
||||||
|
Invoke-WebRequest -Uri "https://repo1.maven.org/maven2/org/ow2/asm/asm/9.9/asm-9.9.jar" -OutFile $asmJar
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $asmTreeJar)) {
|
||||||
|
Invoke-WebRequest -Uri "https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.9/asm-tree-9.9.jar" -OutFile $asmTreeJar
|
||||||
|
}
|
||||||
|
|
||||||
|
$patcherSource = Join-Path $PSScriptRoot "java\BaseVehicleConstraintPatch.java"
|
||||||
|
if (-not (Test-Path $patcherSource)) { throw "Missing patcher source: $patcherSource" }
|
||||||
|
|
||||||
|
& $javaExe -jar $ecjJar -17 -cp "$asmJar;$asmTreeJar" -d $buildDir $patcherSource
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Failed to compile BaseVehicleConstraintPatch.java" }
|
||||||
|
|
||||||
|
$inputClass = Join-Path $classPatchDir "BaseVehicle.original.class"
|
||||||
|
$patchedClass = Join-Path $classPatchDir "BaseVehicle.patched.class"
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
$zip = [System.IO.Compression.ZipFile]::OpenRead($gameJar)
|
||||||
|
try {
|
||||||
|
$entry = $zip.GetEntry("zombie/vehicles/BaseVehicle.class")
|
||||||
|
if ($null -eq $entry) { throw "zombie/vehicles/BaseVehicle.class not found in $gameJar" }
|
||||||
|
|
||||||
|
$entryStream = $entry.Open()
|
||||||
|
$fileStream = [System.IO.File]::Create($inputClass)
|
||||||
|
try {
|
||||||
|
$entryStream.CopyTo($fileStream)
|
||||||
|
} finally {
|
||||||
|
$fileStream.Close()
|
||||||
|
$entryStream.Close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$zip.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
& $javaExe -cp "$buildDir;$asmJar;$asmTreeJar" BaseVehicleConstraintPatch $inputClass $patchedClass
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "BaseVehicle class patch failed" }
|
||||||
|
|
||||||
|
$targetDir = Join-Path $GameRoot "zombie\vehicles"
|
||||||
|
$targetClass = Join-Path $targetDir "BaseVehicle.class"
|
||||||
|
$backupClass = "$targetClass.landtrain.original"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $targetDir | Out-Null
|
||||||
|
if (-not (Test-Path $backupClass)) {
|
||||||
|
if (Test-Path $targetClass) {
|
||||||
|
Copy-Item $targetClass $backupClass -Force
|
||||||
|
} else {
|
||||||
|
Copy-Item $inputClass $backupClass -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item $patchedClass $targetClass -Force
|
||||||
|
Write-Output "Patched BaseVehicle.class deployed to $targetClass"
|
||||||
|
Write-Output "Backup stored at $backupClass"
|
||||||
18
tools/restore-game-basevehicle.ps1
Normal file
18
tools/restore-game-basevehicle.ps1
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
param(
|
||||||
|
[string]$GameRoot = "D:\SteamLibrary\steamapps\common\ProjectZomboid"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$targetClass = Join-Path $GameRoot "zombie\vehicles\BaseVehicle.class"
|
||||||
|
$backupClass = "$targetClass.landtrain.original"
|
||||||
|
|
||||||
|
if (Test-Path $backupClass) {
|
||||||
|
Copy-Item $backupClass $targetClass -Force
|
||||||
|
Write-Output "Restored BaseVehicle.class from $backupClass"
|
||||||
|
} elseif (Test-Path $targetClass) {
|
||||||
|
Remove-Item $targetClass -Force
|
||||||
|
Write-Output "Removed override class at $targetClass (game will use class from projectzomboid.jar)"
|
||||||
|
} else {
|
||||||
|
Write-Output "No override or backup found. Nothing to restore."
|
||||||
|
}
|
||||||
BIN
zombie/vehicles/BaseVehicle.class
Normal file
BIN
zombie/vehicles/BaseVehicle.class
Normal file
Binary file not shown.
Reference in New Issue
Block a user