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