diff --git a/.gitignore b/.gitignore index 9f9fd18d61..7122989a4d 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,4 @@ LogLoadingWarning.txt LogConsole.txt Console.dump.log Console.input.log +imgui.ini diff --git a/Data/Base.rte/Devices/Tools/GrappleGun/Grapple.lua b/Data/Base.rte/Devices/Tools/GrappleGun/Grapple.lua index 51a1edaa3b..2b927ca174 100644 --- a/Data/Base.rte/Devices/Tools/GrappleGun/Grapple.lua +++ b/Data/Base.rte/Devices/Tools/GrappleGun/Grapple.lua @@ -1,490 +1,958 @@ -function Create(self) - self.mapWrapsX = SceneMan.SceneWrapsX; - self.climbTimer = Timer(); - self.mouseClimbTimer = Timer(); - self.actionMode = 0; -- 0 = start, 1 = flying, 2 = grab terrain, 3 = grab MO - self.climb = 0; - self.canRelease = false; - - self.tapTimer = Timer(); - self.tapCounter = 0; - self.didTap = false; - self.canTap = false; - - self.fireVel = 40; -- This immediately overwrites the .ini FireVel - self.maxLineLength = 500; - self.setLineLength = 0; - self.lineStrength = 40; -- How much "force" the rope can take before breaking - - self.limitReached = false; - self.stretchMode = false; -- Alternative elastic pull mode a là Liero - self.stretchPullRatio = 0.1; - self.pieSelection = 0; -- 0 is nothing, 1 is full retract, 2 is partial retract, 3 is partial extend, 4 is full extend +---@diagnostic disable: undefined-global +-- filepath: /Cortex-Command-Community-Project/Data/Base.rte/Devices/Tools/GrappleGun/Grapple.lua +-- Main logic for the grapple claw MovableObject. - self.climbDelay = 10; -- MS time delay between "climbs" to keep the speed consistant - self.tapTime = 150; -- Maximum amount of time between tapping for claw to return - self.tapAmount = 2; -- How many times to tap to bring back rope - self.mouseClimbLength = 250; -- How long to climb per mouse wheel for mouse users - self.climbInterval = 3.5; -- How many pixels the rope retracts / extends at a time - self.autoClimbIntervalA = 4.0; -- How many pixels the rope retracts / extends at a time when auto-climbing (fast) - self.autoClimbIntervalB = 2.0; -- How many pixels the rope retracts / extends at a time when auto-climbing (slow) +-- Load Modules +local RopePhysics = require("Devices.Tools.GrappleGun.Scripts.RopePhysics") +local RopeRenderer = require("Devices.Tools.GrappleGun.Scripts.RopeRenderer") +local RopeInputController = require("Devices.Tools.GrappleGun.Scripts.RopeInputController") +local RopeStateManager = require("Devices.Tools.GrappleGun.Scripts.RopeStateManager") +local Logger = require("Scripts.Logger") - self.stickSound = CreateSoundContainer("Grapple Gun Claw Stick", "Base.rte"); - self.clickSound = CreateSoundContainer("Grapple Gun Click", "Base.rte"); - self.returnSound = CreateSoundContainer("Grapple Gun Return", "Base.rte"); - - --TODO: Rewrite this junk - for gun in MovableMan:GetMOsInRadius(self.Pos, 50) do - if gun and gun.ClassName == "HDFirearm" and gun.PresetName == "Grapple Gun" and SceneMan:ShortestDistance(self.Pos, ToHDFirearm(gun).MuzzlePos, self.mapWrapsX):MagnitudeIsLessThan(5) then - self.parentGun = ToHDFirearm(gun); - self.parent = MovableMan:GetMOFromID(gun.RootID); - if MovableMan:IsActor(self.parent) then - self.parent = ToActor(self.parent); - if IsAHuman(self.parent) then - self.parent = ToAHuman(self.parent); - elseif IsACrab(self.parent) then - self.parent = ToACrab(self.parent); - end +function Create(self) + Logger.info("Grapple Create() - Starting initialization") + + self.lastPos = self.Pos + + self.mapWrapsX = SceneMan.SceneWrapsX + self.climbTimer = Timer() + self.mouseClimbTimer = Timer() + self.tapTimer = Timer() -- Initialize tapTimer + + Logger.debug("Grapple Create() - Basic properties initialized") + + -- Initialize state using the state manager. This sets self.actionMode = 0. + RopeStateManager.initState(self) + Logger.debug("Grapple Create() - State initialized, actionMode = %d", self.actionMode) + + -- Core grapple properties + self.fireVel = 40 -- Initial velocity of the hook. Overwrites .ini FireVel. + self.hookRadius = 360 -- Reduced from 360 for more precise parent finding + + self.maxLineLength = 1000 -- Maximum allowed length of the rope. + self.maxShootDistance = self.maxLineLength * 0.95 -- Hook will detach if it travels further than this before sticking. + self.setLineLength = 0 -- Target length set by input/logic. + self.lineStrength = 10000 -- Force threshold for breaking (effectively unbreakable). + + self.limitReached = false -- True if the rope has reached its maxLineLength. + self.stretchMode = false -- Disabled for rigid rope behavior. + self.stretchPullRatio = 0.0 -- No stretching for rigid rope. + self.pieSelection = 0 -- Current pie menu selection (0: none, 1: full retract, etc.). + + Logger.debug("Grapple Create() - Core properties set (fireVel=%d, maxLineLength=%d)", self.fireVel, self.maxLineLength) + + -- Timing and interval properties for rope actions + self.climbDelay = 8 -- Delay between climb ticks. + self.tapTime = 150 -- Max time between taps for double-tap unhook. + self.tapAmount = 2 -- Number of taps required for unhook. + self.tapCounter = 0 -- Current tap count for multi-tap detection. + self.canTap = false -- Flag to register the first tap in a sequence. + self.mouseClimbLength = 200 -- Duration mouse scroll input is considered active. + self.climbInterval = 4.0 -- Amount rope length changes per climb tick. + self.autoClimbIntervalA = 5.0 -- Auto-retract speed (primary). + self.autoClimbIntervalB = 3.0 -- Auto-extend speed (secondary, e.g., from pie menu). + + -- Sound effects + self.stickSound = CreateSoundContainer("Grapple Gun Claw Stick", "Base.rte") + self.clickSound = CreateSoundContainer("Grapple Gun Click", "Base.rte") + self.returnSound = CreateSoundContainer("Grapple Gun Return", "Base.rte") + self.crankSoundInstance = nil + + Logger.debug("Grapple Create() - Sound containers created") + + -- Rope physics variables + self.currentLineLength = 0 + self.cablespring = 0.01 + + self.minSegments = 1 + self.maxSegments = 1000 + self.segmentLength = 6 + self.currentSegments = self.minSegments + + self.shiftScrollSpeed = 1.0 + + self.apx = {} + self.apy = {} + self.lastX = {} + self.lastY = {} + + local px = self.Pos.X + local py = self.Pos.Y + + Logger.debug("Grapple Create() - Initializing rope segments at position (%.1f, %.1f)", px, py) + + for i = 0, self.maxSegments do + self.apx[i] = px + self.apy[i] = py + self.lastX[i] = px + self.lastY[i] = py + end + + self.currentSegments = self.minSegments + Logger.debug("Grapple Create() - %d rope segments initialized", self.maxSegments + 1) + + -- Parent gun, parent actor, and related properties (Vel, anchor points, parentRadius) + -- will be determined and set in the first Update call. + -- No self.ToDelete = true will be set in Create. + + -- Add these new flags: + self.shouldUnhook = false -- Flag set by gun to signal unhook + + -- Keep only the tap detection variables: + self.tapCounter = 0 + self.canTap = false + self.tapTime = 150 + self.tapAmount = 2 + self.tapTimer = Timer() + + Logger.info("Grapple Create() - Initialization complete") +end - self.Vel = (self.parent.Vel * 0.5) + Vector(self.fireVel, 0):RadRotate(self.parent:GetAimAngle(true)); - self.parentGun:RemoveNumberValue("GrappleMode"); - for part in self.parent.Attachables do - local radcheck = SceneMan:ShortestDistance(self.parent.Pos, part.Pos, self.mapWrapsX).Magnitude + part.Radius; - if self.parentRadius == nil or radcheck > self.parentRadius then - self.parentRadius = radcheck; - end - end +function Update (self) + if self.ToDelete then + Logger.debug("Grapple Update() - ToDelete is true, exiting") + return + end + + Logger.debug("Grapple Update() - Starting update, actionMode = %d", self.actionMode) + + -- First-time setup: Find parent, initialize velocity, anchor points, etc. + if self.actionMode == 0 then + Logger.info("Grapple Update() - First-time setup, searching for parent gun") + local foundAndValidParent = false + + for gun_mo in MovableMan:GetMOsInRadius(self.Pos, self.hookRadius) do + if gun_mo and gun_mo.ClassName == "HDFirearm" and gun_mo.PresetName == "Grapple Gun" then + Logger.debug("Grapple Update() - Found potential parent gun: %s", gun_mo.PresetName) + local hdfGun = ToHDFirearm(gun_mo) + if hdfGun and SceneMan:ShortestDistance(self.Pos, hdfGun.MuzzlePos, self.mapWrapsX):MagnitudeIsLessThan(20) then + Logger.debug("Grapple Update() - Gun is within muzzle distance, validating") + self.parentGun = hdfGun + local rootParentMO = MovableMan:GetMOFromID(hdfGun.RootID) + if rootParentMO and MovableMan:IsActor(rootParentMO) then + self.parent = ToActor(rootParentMO) + Logger.info("Grapple Update() - Valid parent actor found: %s (ID: %d)", self.parent.PresetName, self.parent.ID) + + self.apx[0] = self.parent.Pos.X + self.apy[0] = self.parent.Pos.Y + self.lastX[0] = self.parent.Pos.X - (self.parent.Vel.X or 0) + self.lastY[0] = self.parent.Pos.Y - (self.parent.Vel.Y or 0) + + -- Set initial velocity of the hook based on parent's aim and velocity + local aimAngle = self.parent:GetAimAngle(true) + self.Vel = (self.parent.Vel or Vector(0,0)) + Vector(self.fireVel, 0):RadRotate(aimAngle) + Logger.debug("Grapple Update() - Initial velocity set: (%.1f, %.1f), aim angle: %.2f", self.Vel.X, self.Vel.Y, aimAngle) + + -- Initialize hook's lastX/Y for its initial trajectory + self.lastX[self.currentSegments] = self.Pos.X - self.Vel.X + self.lastY[self.currentSegments] = self.Pos.Y - self.Vel.Y + + if self.parentGun then -- Should be valid here + self.parentGun:RemoveNumberValue("GrappleMode") -- Clear any previous mode + Logger.debug("Grapple Update() - Cleared previous GrappleMode from gun") + end - self.actionMode = 1; - end - break; + -- Determine parent's effective radius for terrain checks + self.parentRadius = 5 -- Default radius + if self.parent.Attachables and type(self.parent.Attachables) == "table" then + Logger.debug("Grapple Update() - Calculating parent radius from %d attachables", #self.parent.Attachables) + for _, part in ipairs(self.parent.Attachables) do + if part and part.Pos and part.Radius then + local radcheck = SceneMan:ShortestDistance(self.parent.Pos, part.Pos, self.mapWrapsX).Magnitude + part.Radius + if self.parentRadius == nil or radcheck > self.parentRadius then + self.parentRadius = radcheck + end + end + end + end + Logger.debug("Grapple Update() - Parent radius calculated: %.1f", self.parentRadius) + + self.actionMode = 1 -- Set to flying, initialization successful + Logger.info("Grapple Update() - Initialization successful, switching to flying mode") + + -- Initialize rope segments for display during flight with proper physics + -- First segment is at the shooter's position, last segment is at hook position + -- Use more segments for better physics and visuals + self.currentSegments = 4 -- Start with more segments for better physics during flight + self.apx[0] = self.parent.Pos.X + self.apy[0] = self.parent.Pos.Y + self.lastX[0] = self.parent.Pos.X - (self.parent.Vel.X or 0) + self.lastY[0] = self.parent.Pos.Y - (self.parent.Vel.Y or 0) + + -- Initialize the hook segment + self.apx[self.currentSegments] = self.Pos.X + self.apy[self.currentSegments] = self.Pos.Y + self.lastX[self.currentSegments] = self.Pos.X - (self.Vel.X or 0) + self.lastY[self.currentSegments] = self.Pos.Y - (self.Vel.Y or 0) + + -- Initialize intermediate segments with a natural drape + for i = 1, self.currentSegments - 1 do + local t = i / self.currentSegments + self.apx[i] = self.parent.Pos.X + t * (self.Pos.X - self.parent.Pos.X) + self.apy[i] = self.parent.Pos.Y + t * (self.Pos.Y - self.parent.Pos.Y) + -- Add slight droop for natural look + self.apy[i] = self.apy[i] + math.sin(t * math.pi) * 2 + -- Initialize lastX/Y with small velocity matching the overall direction + self.lastX[i] = self.apx[i] - (self.Vel.X or 0) * 0.2 + self.lastY[i] = self.apy[i] - (self.Vel.Y or 0) * 0.2 + end + Logger.debug("Grapple Update() - Initialized %d rope segments for flight", self.currentSegments) + + foundAndValidParent = true + else + Logger.warn("Grapple Update() - Gun root is not a valid actor") + end -- if MovableMan:IsActor(rootParentMO) + else + Logger.debug("Grapple Update() - Gun too far from muzzle or invalid") + end -- if hdfGun and distance check + end -- if gun_mo is grapple gun + end -- for gun_mo + + if not foundAndValidParent then + Logger.error("Grapple Update() - Failed to find valid parent, marking for deletion") + self.ToDelete = true + return -- Exit Update if initialization failed end + -- If we reach here, initialization was successful, self.actionMode = 1 end - if self.parentGun == nil then -- Failed to find our gun, abort - self.ToDelete = true; + -- If ToDelete was set during initialization, or by other logic, exit. + if self.ToDelete then + Logger.debug("Grapple Update() - ToDelete flag set, exiting") + return end -end -function Update(self) - if self.parent and IsMOSRotating(self.parent) and self.parent:HasObject("Grapple Gun") then - local controller; - local startPos = self.parent.Pos; - - self.ToDelete = false; - self.ToSettle = false; - - self.lineVec = SceneMan:ShortestDistance(self.parent.Pos, self.Pos, self.mapWrapsX); - self.lineLength = self.lineVec.Magnitude; - - if self.parentGun and self.parentGun.ID ~= rte.NoMOID then - self.parent = ToMOSRotating(MovableMan:GetMOFromID(self.parentGun.RootID)); - if self.parentGun.Magazine then - self.parentGun.Magazine.Scale = 0; - end - startPos = self.parentGun.Pos; - local flipAng = self.parent.HFlipped and 3.14 or 0; - self.parentGun.RotAngle = self.lineVec.AbsRadAngle + flipAng; - - local mode = self.parentGun:GetNumberValue("GrappleMode"); - - if mode ~= 0 then - self.pieSelection = mode; - self.parentGun:RemoveNumberValue("GrappleMode"); - end - - if self.parentGun.FiredFrame then - if self.actionMode == 1 then - self.ToDelete = true; - else - self.canRelease = true; - end - end - - if self.parentGun.FiredFrame and self.canRelease and (Vector(self.parentGun.Vel.X, self.parentGun.Vel.Y) ~= Vector(0, -1) or self.parentGun:IsActivated()) then - self.ToDelete = true; + -- Continuous validation checks for parent and gun + -- self.parent should be an Actor if initialization succeeded and actionMode >= 1 + if not self.parent or self.parent.ID == rte.NoMOID then + Logger.warn("Grapple Update() - Parent actor lost or invalid, marking for deletion") + self.ToDelete = true + return + end + + local parentActor = self.parent -- self.parent is already an Actor type from the setup block + + -- Check if grapple gun still exists - either equipped or in inventory + -- Smart gun reference management with extensive logging + Logger.debug("Grapple Update() - Starting gun validation check") + + local needToSearchForGun = false + local gunValidationReason = "" + + -- Check if our current gun reference exists + if not self.parentGun then + needToSearchForGun = true + gunValidationReason = "No gun reference exists" + Logger.warn("Grapple Update() - %s", gunValidationReason) + else + Logger.debug("Grapple Update() - Gun reference exists, checking validity...") + + -- Test if the gun reference is actually valid by safely checking properties + local gunIsValid = false + local validationDetails = {} + + -- Check 1: Can we access the gun's ID and is the gun object still valid? + local success1, gunID = pcall(function() return self.parentGun.ID end) + if success1 then + validationDetails.id_accessible = true + validationDetails.gun_id = gunID + Logger.debug("Grapple Update() - Gun ID accessible: %d", gunID) + + -- Check if the gun object still exists in the game world by trying to get it from MovableMan + local gunFromMovableMan = MovableMan:GetMOFromID(gunID) + if gunFromMovableMan and gunFromMovableMan.ID == gunID then + validationDetails.id_valid = true + Logger.debug("Grapple Update() - Gun object exists in MovableMan") + else + needToSearchForGun = true + gunValidationReason = string.format("Gun object no longer exists in MovableMan (ID: %d)", gunID) + Logger.warn("Grapple Update() - %s", gunValidationReason) end + else + needToSearchForGun = true + gunValidationReason = "Cannot access gun ID (gun object invalid)" + Logger.warn("Grapple Update() - %s", gunValidationReason) + validationDetails.id_accessible = false end - - if IsAHuman(self.parent) then - self.parent = ToAHuman(self.parent); - -- We now have a user that controls this grapple - controller = self.parent:GetController(); - -- Point the gun towards the hook if our user is holding it - if (self.parentGun and self.parentGun.ID ~= rte.NoMOID) and (self.parentGun:GetRootParent().ID == self.parent.ID) then - if self.parent:IsPlayerControlled() then - if controller:IsState(Controller.WEAPON_RELOAD) then - self.ToDelete = true; - end - if self.parentGun.Magazine then - self.parentGun.Magazine.RoundCount = 0; - end - end - local offset = Vector(self.lineLength, 0):RadRotate(self.parent.FlipFactor * (self.lineVec.AbsRadAngle - self.parent:GetAimAngle(true))); - self.parentGun.StanceOffset = offset; - if self.parent.EquippedItem and self.parent.EquippedItem.ID == self.parentGun.ID and (self.parent.Vel:MagnitudeIsLessThan(5) and controller:IsState(Controller.AIM_SHARP)) then - self.parentGun.RotAngle = self.parent:GetAimAngle(false) * self.parentGun.FlipFactor; - startPos = self.parent.Pos; + + -- Check 2: Can we access the gun's PresetName? + if not needToSearchForGun then + local success2, presetName = pcall(function() return self.parentGun.PresetName end) + if success2 then + validationDetails.preset_accessible = true + validationDetails.preset_name = presetName + Logger.debug("Grapple Update() - Gun PresetName accessible: %s", presetName or "nil") + + if presetName == "Grapple Gun" then + validationDetails.preset_valid = true + gunIsValid = true + Logger.debug("Grapple Update() - Gun preset name is correct") else - self.parentGun.SharpStanceOffset = offset; + needToSearchForGun = true + gunValidationReason = string.format("Gun preset name incorrect: '%s' (expected 'Grapple Gun')", presetName or "nil") + Logger.warn("Grapple Update() - %s", gunValidationReason) end + else + needToSearchForGun = true + gunValidationReason = "Cannot access gun PresetName (gun object corrupted)" + Logger.warn("Grapple Update() - %s", gunValidationReason) + validationDetails.preset_accessible = false end - -- Prevent the user from spinning like crazy - if self.parent.Status > Actor.STABLE then - self.parent.AngularVel = self.parent.AngularVel/(1 + math.abs(self.parent.AngularVel) * 0.01); - end - else -- If the gun is by itself, hide the HUD - self.parentGun.HUDVisible = false; end - -- Add sound when extending / retracting - if MovableMan:IsParticle(self.crankSound) then - self.crankSound.PinStrength = 1000; - self.crankSound.ToDelete = false; - self.crankSound.ToSettle = false; - self.crankSound.Pos = startPos; - if self.lastSetLineLength ~= self.setLineLength then - self.crankSound:EnableEmission(true); + + -- Check 3: Can we access the gun's RootID? + if not needToSearchForGun then + local success3, rootID = pcall(function() return self.parentGun.RootID end) + if success3 then + validationDetails.rootid_accessible = true + validationDetails.root_id = rootID + Logger.debug("Grapple Update() - Gun RootID accessible: %d", rootID) else - self.crankSound:EnableEmission(false); + Logger.warn("Grapple Update() - Cannot access gun RootID (potential corruption)") + validationDetails.rootid_accessible = false + -- Don't mark for search yet, gun might still be valid + end + end + + -- Log detailed validation results + Logger.debug("Grapple Update() - Gun validation details: ID_OK=%s, Preset_OK=%s, RootID_OK=%s, Overall_Valid=%s", + tostring(validationDetails.id_valid), + tostring(validationDetails.preset_valid), + tostring(validationDetails.rootid_accessible), + tostring(gunIsValid)) + + if gunIsValid then + Logger.debug("Grapple Update() - Current gun reference is valid, no search needed") + end + end + + -- Only search for gun if we actually need to + local foundGun = false -- Initialize to false + if needToSearchForGun then + Logger.warn("Grapple Update() - Gun search triggered: %s", gunValidationReason) + Logger.info("Grapple Update() - Performing comprehensive gun search...") + + local searchResults = {} + + -- Search Method 1: Check equipped items + Logger.debug("Grapple Update() - Search Method 1: Checking equipped items") + if parentActor.EquippedItem then + Logger.debug("Grapple Update() - Main equipped item: %s (ID: %d)", + parentActor.EquippedItem.PresetName or "Unknown", parentActor.EquippedItem.ID) + if parentActor.EquippedItem.PresetName == "Grapple Gun" then + self.parentGun = ToHDFirearm(parentActor.EquippedItem) + foundGun = true + searchResults.method = "main_equipped" + searchResults.gun_id = self.parentGun.ID + Logger.info("Grapple Update() - SUCCESS: Found grapple gun equipped in main hand (ID: %d)", self.parentGun.ID) end else - self.crankSound = CreateAEmitter("Grapple Gun Sound Crank"); - self.crankSound.Pos = startPos; - MovableMan:AddParticle(self.crankSound); + Logger.debug("Grapple Update() - No main equipped item") end - - self.lastSetLineLength = self.setLineLength; - - if self.actionMode == 1 then -- Hook is in flight - self.rayVec = Vector(); - -- Stretch mode: gradually retract the hook for a return hit - if self.stretchMode then - self.Vel = self.Vel - Vector(self.lineVec.X, self.lineVec.Y):SetMagnitude(math.sqrt(self.lineLength) * self.stretchPullRatio/2); + + if not foundGun and parentActor.EquippedBGItem then + Logger.debug("Grapple Update() - BG equipped item: %s (ID: %d)", + parentActor.EquippedBGItem.PresetName or "Unknown", parentActor.EquippedBGItem.ID) + if parentActor.EquippedBGItem.PresetName == "Grapple Gun" then + self.parentGun = ToHDFirearm(parentActor.EquippedBGItem) + foundGun = true + searchResults.method = "bg_equipped" + searchResults.gun_id = self.parentGun.ID + Logger.info("Grapple Update() - SUCCESS: Found grapple gun equipped in BG hand (ID: %d)", self.parentGun.ID) + end + else + if not parentActor.EquippedBGItem then + Logger.debug("Grapple Update() - No BG equipped item") end - local length = math.sqrt(self.Diameter + self.Vel.Magnitude); - -- Detect terrain and stick if found - local ray = Vector(length, 0):RadRotate(self.Vel.AbsRadAngle); - if SceneMan:CastStrengthRay(self.Pos, ray, 0, self.rayVec, 0, rte.airID, self.mapWrapsX) then - self.actionMode = 2; - else -- Detect MOs and stick if found - local moRay = SceneMan:CastMORay(self.Pos, ray, self.parent.ID, -2, rte.airID, false, 0); - if moRay ~= rte.NoMOID then - self.target = MovableMan:GetMOFromID(moRay); - -- Treat pinned MOs as terrain - if self.target.PinStrength > 0 then - self.actionMode = 2; + end + + -- Search Method 2: Check inventory thoroughly + if not foundGun then + Logger.debug("Grapple Update() - Search Method 2: Checking inventory") + if parentActor.Inventory then + local inventoryCount = 0 + local grappleGunsFound = 0 + + for item in parentActor.Inventory do + inventoryCount = inventoryCount + 1 + if item then + Logger.debug("Grapple Update() - Inventory item %d: %s (ID: %d, RootID: %d)", + inventoryCount, item.PresetName or "Unknown", item.ID, item.RootID or -1) + if item.PresetName == "Grapple Gun" then + grappleGunsFound = grappleGunsFound + 1 + if not foundGun then -- Take the first one we find + self.parentGun = ToHDFirearm(item) + foundGun = true + searchResults.method = "inventory" + searchResults.gun_id = self.parentGun.ID + searchResults.inventory_position = inventoryCount + Logger.info("Grapple Update() - SUCCESS: Found grapple gun in inventory position %d (ID: %d)", inventoryCount, self.parentGun.ID) + else + Logger.warn("Grapple Update() - Additional grapple gun found in inventory (ID: %d) - this is unusual", item.ID) + end + end else - self.stickPosition = SceneMan:ShortestDistance(self.target.Pos, self.Pos, self.mapWrapsX); - self.stickRotation = self.target.RotAngle; - self.stickDirection = self.RotAngle; - self.actionMode = 3; + Logger.debug("Grapple Update() - Inventory item %d: nil", inventoryCount) end - -- Inflict damage - local part = CreateMOPixel("Grapple Gun Damage Particle"); - part.Pos = self.Pos; - part.Vel = SceneMan:ShortestDistance(self.Pos, self.target.Pos, self.mapWrapsX):SetMagnitude(self.Vel.Magnitude); - MovableMan:AddParticle(part); end + + Logger.debug("Grapple Update() - Inventory search complete: %d items total, %d grapple guns found", inventoryCount, grappleGunsFound) + else + Logger.warn("Grapple Update() - Actor has no inventory") end - - if self.actionMode > 1 then - self.stickSound:Play(self.Pos); - self.setLineLength = math.floor(self.lineLength); - self.Vel = Vector(); - self.PinStrength = 1000; - self.Frame = 1; - end - - if self.lineLength > self.maxLineLength then - if self.limitReached == false then - self.limitReached = true; - self.clickSound:Play(startPos); - end - local movetopos = self.parent.Pos + (self.lineVec):SetMagnitude(self.maxLineLength); - if self.mapWrapsX == true then - if movetopos.X > SceneMan.SceneWidth then - movetopos = Vector(movetopos.X - SceneMan.SceneWidth, movetopos.Y); - elseif movetopos.X < 0 then - movetopos = Vector(SceneMan.SceneWidth + movetopos.X, movetopos.Y); + end + + -- Search Method 3: Nearby area search + if not foundGun then + Logger.debug("Grapple Update() - Search Method 3: Nearby area search (radius: 150)") + local nearbyGunsFound = 0 + + for gun_mo in MovableMan:GetMOsInRadius(parentActor.Pos, 150) do + if gun_mo and gun_mo.ClassName == "HDFirearm" then + Logger.debug("Grapple Update() - Nearby HDFirearm: %s (ID: %d, RootID: %d, Distance: %.1f)", + gun_mo.PresetName or "Unknown", gun_mo.ID, gun_mo.RootID, + SceneMan:ShortestDistance(parentActor.Pos, gun_mo.Pos, self.mapWrapsX).Magnitude) + + if gun_mo.PresetName == "Grapple Gun" then + nearbyGunsFound = nearbyGunsFound + 1 + Logger.debug("Grapple Update() - Found nearby grapple gun %d", nearbyGunsFound) + + -- Check ownership/accessibility + local isAccessible = false + if gun_mo.RootID == parentActor.ID then + isAccessible = true + Logger.debug("Grapple Update() - Gun belongs to our parent") + elseif gun_mo.RootID == rte.NoMOID then + isAccessible = true + Logger.debug("Grapple Update() - Gun is unowned") + else + local currentOwner = MovableMan:GetMOFromID(gun_mo.RootID) + if currentOwner and IsActor(currentOwner) then + if ToActor(currentOwner).Team == parentActor.Team and parentActor.Team >= 0 then + isAccessible = true + Logger.debug("Grapple Update() - Gun belongs to teammate") + else + Logger.debug("Grapple Update() - Gun belongs to different team") + end + else + Logger.debug("Grapple Update() - Gun has invalid owner") + end + end + + if isAccessible and not foundGun then + self.parentGun = ToHDFirearm(gun_mo) + foundGun = true + searchResults.method = "nearby" + searchResults.gun_id = self.parentGun.ID + searchResults.distance = SceneMan:ShortestDistance(parentActor.Pos, gun_mo.Pos, self.mapWrapsX).Magnitude + Logger.info("Grapple Update() - SUCCESS: Found accessible nearby grapple gun (ID: %d, Distance: %.1f)", gun_mo.ID, searchResults.distance) + end end end - self.Pos = movetopos; - - local pullamountnumber = math.abs(-self.lineVec.AbsRadAngle + self.Vel.AbsRadAngle)/6.28; - self.Vel = self.Vel - self.lineVec:SetMagnitude(self.Vel.Magnitude * pullamountnumber); end - elseif self.actionMode > 1 then -- Hook has stuck - -- Actor mass and velocity affect pull strength negatively, rope length affects positively (diminishes the former) - local parentForces = 1 + (self.parent.Vel.Magnitude * 10 + self.parent.Mass)/(1 + self.lineLength); - local terrVector = Vector(); - -- Check if there is terrain between the hook and the user - if self.parentRadius ~= nil then - self.terrcheck = SceneMan:CastStrengthRay(self.parent.Pos, self.lineVec:SetMagnitude(self.parentRadius), 0, terrVector, 2, rte.airID, self.mapWrapsX); + + Logger.debug("Grapple Update() - Nearby search complete: %d grapple guns found", nearbyGunsFound) + end + + -- Report search results + if foundGun then + Logger.info("Grapple Update() - Gun search successful via method: %s", searchResults.method) + + -- Validate the newly found gun + local newGunValid = false + local success, newGunPreset = pcall(function() return self.parentGun.PresetName end) + if success and newGunPreset == "Grapple Gun" then + newGunValid = true + Logger.debug("Grapple Update() - Newly found gun validated successfully") else - self.terrcheck = false; + Logger.error("Grapple Update() - Newly found gun failed validation!") end - - -- Control automatic extension and retraction - if self.pieSelection ~= 0 and self.climbTimer:IsPastSimMS(self.climbDelay) then - self.climbTimer:Reset(); - - if self.pieSelection == 1 then - - if self.setLineLength > self.autoClimbIntervalA and self.terrcheck == false then - self.setLineLength = self.setLineLength - (self.autoClimbIntervalA/parentForces); - else - self.pieSelection = 0; - end - elseif self.pieSelection == 2 then - if self.setLineLength < (self.maxLineLength - self.autoClimbIntervalB) then - self.setLineLength = self.setLineLength + self.autoClimbIntervalB; - else - self.pieSelection = 0; - end + + if newGunValid then + -- Update magazine state for the found gun + if self.parentGun.Magazine and MovableMan:IsParticle(self.parentGun.Magazine) then + local mag = ToMOSParticle(self.parentGun.Magazine) + mag.RoundCount = 0 + mag.Scale = 0 + Logger.debug("Grapple Update() - Updated magazine state for found gun") end + + -- Log detailed gun state + local gunRootID = "unknown" + local gunPos = "unknown" + local success1, rootID = pcall(function() return self.parentGun.RootID end) + if success1 then gunRootID = tostring(rootID) end + local success2, pos = pcall(function() return self.parentGun.Pos end) + if success2 then gunPos = string.format("(%.1f, %.1f)", pos.X, pos.Y) end + + Logger.info("Grapple Update() - Gun recovery complete: ID=%d, RootID=%s, Position=%s, Method=%s", + searchResults.gun_id, gunRootID, gunPos, searchResults.method) end - - -- Control the rope if the user is holding the gun - if self.parentGun and self.parentGun.ID ~= rte.NoMOID and controller then - -- These forces are to help the user nudge across obstructing terrain - local nudge = math.sqrt(self.lineVec.Magnitude + self.parent.Radius)/(10 + self.parent.Vel.Magnitude); - -- Retract automatically by holding fire or control the rope through the pie menu - if self.parentGun:IsActivated() and self.climbTimer:IsPastSimMS(self.climbDelay) then - self.climbTimer:Reset(); - if self.pieSelection == 0 and self.parentGun:IsActivated() then - - if self.setLineLength > self.autoClimbIntervalA and self.terrcheck == false then - self.setLineLength = self.setLineLength - (self.autoClimbIntervalA/parentForces); - else - self.parentGun:RemoveNumberValue("GrappleMode"); - self.pieSelection = 0; - if self.terrcheck ~= false then - -- Try to nudge past terrain - local aimvec = Vector(self.lineVec.Magnitude, 0):SetMagnitude(nudge):RadRotate((self.lineVec.AbsRadAngle + self.parent:GetAimAngle(true))/2 + self.parent.FlipFactor * 0.7); - self.parent.Vel = self.parent.Vel + aimvec; - end - end - elseif self.pieSelection == 2 then - if self.setLineLength < (self.maxLineLength - self.autoClimbIntervalB) then - self.setLineLength = self.setLineLength + self.autoClimbIntervalB; - else - self.parentGun:RemoveNumberValue("GrappleMode"); - self.pieSelection = 0; - end + else + Logger.error("Grapple Update() - Gun search failed - no grapple gun found anywhere!") + Logger.error("Grapple Update() - Searched: equipped items, inventory items, nearby radius 150") + + -- Only delete if we're not already attached - if attached, enter gunless mode + if self.actionMode > 1 then + Logger.warn("Grapple Update() - Gun search failed but grapple is attached - entering gunless mode") + self.parentGun = nil + foundGun = false -- Explicitly set to false for gunless mode + else + Logger.error("Grapple Update() - Gun search failed and grapple not attached - marking for deletion") + self.ToDelete = true + return + end + end + else + -- We didn't need to search, so our existing gun reference is valid + foundGun = true + Logger.debug("Grapple Update() - No gun search needed, existing reference is valid") + end + + -- Comprehensive gun accessibility check with detailed logging + Logger.debug("Grapple Update() - Starting gun accessibility verification") + + local gunIsAccessible = false + local accessibilityMethod = "" + local accessibilityDetails = {} + + -- Get current gun state for logging + local currentGunID = "unknown" + local currentGunRootID = "unknown" + local success1, gunID = pcall(function() return self.parentGun and self.parentGun.ID or rte.NoMOID end) + if success1 then currentGunID = tostring(gunID) end + local success2, rootID = pcall(function() return self.parentGun and self.parentGun.RootID or rte.NoMOID end) + if success2 then currentGunRootID = tostring(rootID) end + + Logger.debug("Grapple Update() - Current gun state: ID=%s, RootID=%s, Parent ID=%d", + currentGunID, currentGunRootID, parentActor.ID) + + -- Special case: If no gun found but grapple is attached, allow gunless mode + if not foundGun and self.actionMode > 1 then + Logger.warn("Grapple Update() - No gun available but grapple is attached - entering gunless mode") + gunIsAccessible = true + accessibilityMethod = "attached_without_gun" + self.parentGun = nil -- Clear any invalid reference + Logger.info("Grapple Update() - Grapple remains active in attached mode without gun control") + elseif foundGun and self.parentGun then + -- Normal accessibility checks for cases when we have a gun reference + -- Accessibility Check 1: Is gun equipped? + Logger.debug("Grapple Update() - Accessibility Check 1: Equipment status") + if parentActor.EquippedItem and parentActor.EquippedItem.ID == tonumber(currentGunID) then + gunIsAccessible = true + accessibilityMethod = "equipped_main" + accessibilityDetails.equipped_slot = "main" + Logger.debug("Grapple Update() - Gun is equipped in main hand") + elseif parentActor.EquippedBGItem and parentActor.EquippedBGItem.ID == tonumber(currentGunID) then + gunIsAccessible = true + accessibilityMethod = "equipped_bg" + accessibilityDetails.equipped_slot = "background" + Logger.debug("Grapple Update() - Gun is equipped in background hand") + else + Logger.debug("Grapple Update() - Gun is not equipped") + end + + -- Accessibility Check 2: Is gun in inventory? + if not gunIsAccessible then + Logger.debug("Grapple Update() - Accessibility Check 2: Inventory status") + if parentActor.Inventory then + local inventoryCount = 0 + for item in parentActor.Inventory do + inventoryCount = inventoryCount + 1 + if item and item.ID == tonumber(currentGunID) then + gunIsAccessible = true + accessibilityMethod = "inventory" + accessibilityDetails.inventory_position = inventoryCount + Logger.debug("Grapple Update() - Gun found in inventory at position %d", inventoryCount) + break end end + if not gunIsAccessible then + Logger.debug("Grapple Update() - Gun not found in inventory (%d items checked)", inventoryCount) + end + else + Logger.debug("Grapple Update() - Actor has no inventory") + end + end + + -- Accessibility Check 3: Is gun nearby and owned by player? + if not gunIsAccessible and self.parentGun then + Logger.debug("Grapple Update() - Accessibility Check 3: Proximity and ownership") + local gunDistance = SceneMan:ShortestDistance(parentActor.Pos, self.parentGun.Pos, self.mapWrapsX).Magnitude + Logger.debug("Grapple Update() - Gun distance: %.1f units", gunDistance) + + if gunDistance < 100 then + if currentGunRootID == tostring(rte.NoMOID) then + gunIsAccessible = true + accessibilityMethod = "nearby_unowned" + accessibilityDetails.distance = gunDistance + Logger.debug("Grapple Update() - Gun is nearby and unowned") + elseif currentGunRootID == tostring(parentActor.ID) then + gunIsAccessible = true + accessibilityMethod = "nearby_owned" + accessibilityDetails.distance = gunDistance + Logger.debug("Grapple Update() - Gun is nearby and owned by parent") + else + Logger.debug("Grapple Update() - Gun is nearby but owned by someone else (RootID: %s)", currentGunRootID) + end + else + Logger.debug("Grapple Update() - Gun is too far away (%.1f > 100)", gunDistance) + end + end + + -- Special Check 4: If gun is owned by player but not equipped/in inventory, consider it accessible + -- This handles cases where the gun might be in a weird state but still belongs to the player + if not gunIsAccessible and currentGunRootID == tostring(parentActor.ID) then + Logger.debug("Grapple Update() - Accessibility Check 4: Player ownership fallback") + gunIsAccessible = true + accessibilityMethod = "owned_fallback" + Logger.debug("Grapple Update() - Gun is owned by parent (fallback access granted)") + end + else + -- No gun reference and either not attached or gun search explicitly failed + Logger.error("Grapple Update() - No gun available and not in valid attached state") + gunIsAccessible = false + end - -- Hold crouch to control rope manually - if controller:IsState(Controller.BODY_PRONE) then - if self.climb == 1 or self.climb == 2 then - if self.climbTimer:IsPastSimMS(self.climbDelay) then - self.climbTimer:Reset(); - if self.pieSelection == 0 then - if self.climb == 1 then - self.setLineLength = self.setLineLength - (self.climbInterval/parentForces); - elseif self.climb == 2 then - self.setLineLength = self.setLineLength + self.climbInterval; - end - end + -- Final accessibility determination + if gunIsAccessible then + Logger.info("Grapple Update() - Gun accessibility CONFIRMED via method: %s", accessibilityMethod) + if accessibilityDetails.equipped_slot then + Logger.debug("Grapple Update() - Equipment details: slot=%s", accessibilityDetails.equipped_slot) + elseif accessibilityDetails.inventory_position then + Logger.debug("Grapple Update() - Inventory details: position=%d", accessibilityDetails.inventory_position) + elseif accessibilityDetails.distance then + Logger.debug("Grapple Update() - Proximity details: distance=%.1f", accessibilityDetails.distance) + end + else + Logger.error("Grapple Update() - Gun accessibility FAILED - no valid access method found") + Logger.error("Grapple Update() - Gun state at failure: ID=%s, RootID=%s, Position=(%.1f, %.1f)", + currentGunID, currentGunRootID, self.parentGun.Pos.X, self.parentGun.Pos.Y) + Logger.error("Grapple Update() - Player state: ID=%d, Position=(%.1f, %.1f), Team=%d", + parentActor.ID, parentActor.Pos.X, parentActor.Pos.Y, parentActor.Team) + self.ToDelete = true + return + end - self.climb = 0; - end - elseif self.climb == 3 or self.climb == 4 then - if self.climbTimer:IsPastSimMS(self.mouseClimbLength) then - self.climbTimer:Reset(); - self.mouseClimbTimer:Reset(); - self.climb = 0; - else - if self.mouseClimbTimer:IsPastSimMS(self.climbDelay) then - self.mouseClimbTimer:Reset(); - if self.climb == 3 then - if (self.setLineLength-self.climbInterval) >= 0 and self.terrcheck == false then - self.setLineLength = self.setLineLength - (self.climbInterval/parentForces); + -- Standard update flags + self.ToSettle = false -- Grapple claw should not settle + + -- Update player anchor point (segment 0) + self.apx[0] = parentActor.Pos.X + self.apy[0] = parentActor.Pos.Y + self.lastX[0] = parentActor.Pos.X - (parentActor.Vel.X or 0) + self.lastY[0] = parentActor.Pos.Y - (parentActor.Vel.Y or 0) + + -- Update hook anchor point (segment self.currentSegments) + -- This depends on whether the hook is attached or flying + if self.actionMode == 1 then -- Flying + Logger.debug("Grapple Update() - Flying mode: updating hook position") + -- Hook position is determined by its own physics + self.apx[self.currentSegments] = self.Pos.X + self.apy[self.currentSegments] = self.Pos.Y + -- Initialize lastX/Y for the hook end if not set + if not self.lastX[self.currentSegments] then + self.lastX[self.currentSegments] = self.Pos.X - (self.Vel.X or 0) + self.lastY[self.currentSegments] = self.Pos.Y - (self.Vel.Y or 0) + end + + -- Use full Verlet physics during flight, not just simple line positioning + -- This ensures consistent rope behavior across all action modes + elseif self.actionMode == 2 then -- Grabbed terrain + Logger.debug("Grapple Update() - Terrain grab mode: fixing hook position") + -- Hook position is fixed where it grabbed + self.Pos.X = self.apx[self.currentSegments] -- Ensure self.Pos matches anchor + self.Pos.Y = self.apy[self.currentSegments] + -- Velocity of the terrain anchor is zero + self.lastX[self.currentSegments] = self.apx[self.currentSegments] + self.lastY[self.currentSegments] = self.apy[self.currentSegments] + elseif self.actionMode == 3 and self.target and self.target.ID ~= rte.NoMOID then -- Grabbed MO + Logger.debug("Grapple Update() - MO grab mode: tracking target") + local effective_target = RopeStateManager.getEffectiveTarget(self) + if effective_target and effective_target.ID ~= rte.NoMOID then + self.Pos = effective_target.Pos + self.apx[self.currentSegments] = effective_target.Pos.X + self.apy[self.currentSegments] = effective_target.Pos.Y + self.lastX[self.currentSegments] = effective_target.Pos.X - (effective_target.Vel.X or 0) + self.lastY[self.currentSegments] = effective_target.Pos.Y - (effective_target.Vel.Y or 0) + Logger.debug("Grapple Update() - Target position: (%.1f, %.1f)", effective_target.Pos.X, effective_target.Pos.Y) + else + -- Target lost or invalid, consider unhooking or reverting to terrain grab + Logger.warn("Grapple Update() - Target lost in MO grab mode, marking for deletion") + self.ToDelete = true -- Or change actionMode to 2 if it should stick to the last location + return + end + end + + -- Calculate current actual distance between player and hook + self.lineVec = SceneMan:ShortestDistance(parentActor.Pos, self.Pos, self.mapWrapsX) + self.lineLength = self.lineVec.Magnitude -- This is the visual length + Logger.debug("Grapple Update() - Line length: %.1f", self.lineLength) + + -- State-dependent logic for currentLineLength (the physics length) + if self.actionMode == 1 then -- Flying + if self.lineLength >= self.maxShootDistance then + if not self.limitReached then + Logger.info("Grapple Update() - Maximum shoot distance reached (%.1f)", self.maxShootDistance) + self.clickSound:Play(parentActor.Pos) + self.limitReached = true + end + self.currentLineLength = self.maxShootDistance -- Physics length capped + -- The RopePhysics.applyRopeConstraints will handle the "binding" + else + self.currentLineLength = self.lineLength -- Physics length matches visual + self.limitReached = false + end + self.setLineLength = self.currentLineLength -- Keep setLineLength synchronized during flight + else -- Attached (Terrain or MO) + -- currentLineLength is controlled by input or auto-climbing, clamped. + local oldLength = self.currentLineLength + self.currentLineLength = math.max(10, math.min(self.currentLineLength, self.maxLineLength)) + if oldLength ~= self.currentLineLength then + Logger.debug("Grapple Update() - Line length clamped from %.1f to %.1f", oldLength, self.currentLineLength) + end + self.setLineLength = self.currentLineLength -- Keep setLineLength synchronized + -- limitReached is true if currentLineLength is at maxLineLength, false otherwise + self.limitReached = (self.currentLineLength >= self.maxLineLength - 0.1) -- Small tolerance + end - elseif self.terrcheck ~= false then - -- Try to nudge past terrain - local aimvec = Vector(self.lineVec.Magnitude, 0):SetMagnitude(nudge):RadRotate((self.lineVec.AbsRadAngle + self.parent:GetAimAngle(true))/2 + self.parent.FlipFactor * 0.7); - self.parent.Vel = self.parent.Vel + aimvec; - end - elseif self.climb == 4 then - if (self.setLineLength+self.climbInterval) <= self.maxLineLength then - self.setLineLength = self.setLineLength + self.climbInterval; - end - end - end - end - end + -- Dynamic rope segment calculation + local desiredSegments = RopePhysics.calculateOptimalSegments(self, math.max(1, self.currentLineLength)) + + -- In flying mode, ensure we have enough intermediate segments for proper Verlet physics + if self.actionMode == 1 then + -- For short distances, use at least 6 segments + -- For longer distances, use enough segments for proper rope physics + -- This higher segment count is essential for proper Verlet physics simulation + local minSegmentsForFlight = math.max(6, math.floor(self.lineLength / 25)) + desiredSegments = math.max(minSegmentsForFlight, desiredSegments) + Logger.debug("Grapple Update() - Flying mode: desired segments = %d (min: %d)", desiredSegments, minSegmentsForFlight) + end + + -- Update segments if needed, with reduced hysteresis threshold for flight mode + -- This ensures smoother transitions as the rope extends + local segmentUpdateThreshold = self.actionMode == 1 and 1 or 2 + if desiredSegments ~= self.currentSegments and math.abs(desiredSegments - self.currentSegments) >= segmentUpdateThreshold then + Logger.info("Grapple Update() - Resizing rope segments from %d to %d", self.currentSegments, desiredSegments) + RopePhysics.resizeRopeSegments(self, desiredSegments) + end - if controller:IsMouseControlled() then - controller:SetState(Controller.WEAPON_CHANGE_NEXT, false); - controller:SetState(Controller.WEAPON_CHANGE_PREV, false); - if controller:IsState(Controller.SCROLL_UP) then - self.climbTimer:Reset(); - self.climb = 3; - end + -- Core rope physics simulation + Logger.debug("Grapple Update() - Running rope physics simulation") + RopePhysics.updateRopePhysics(self, parentActor.Pos, self.Pos, self.currentLineLength) + + -- Check for hook attachment collisions (only when flying) + if self.actionMode == 1 then + local stateChanged = RopeStateManager.checkAttachmentCollisions(self) + if stateChanged then + Logger.info("Grapple Update() - Hook attachment state changed, actionMode now: %d", self.actionMode) + -- Rope physics may need re-initialization after attachment + self.ropePhysicsInitialized = false + end + end + + -- Apply constraints and check for breaking + Logger.debug("Grapple Update() - Applying rope constraints") + local ropeBreaks = RopePhysics.applyRopeConstraints(self, self.currentLineLength) + if ropeBreaks or self.shouldBreak then -- self.shouldBreak can be set by other logic + Logger.warn("Grapple Update() - Rope breaks detected, marking for deletion") + self.ToDelete = true + if parentActor:IsPlayerControlled() then + FrameMan:SetScreenScrollSpeed(10.0) + if self.returnSound then self.returnSound:Play(parentActor.Pos) end + end + return -- Exit update if rope breaks + end - if controller:IsState(Controller.SCROLL_DOWN) then - self.climbTimer:Reset(); - self.climb = 4; - end - elseif controller:IsMouseControlled() == false then - if controller:IsState(Controller.HOLD_UP) then - if self.setLineLength > self.climbInterval and self.terrcheck == false then - self.climb = 1; - elseif self.terrcheck ~= false then - -- Try to nudge past terrain - local aimvec = Vector(self.lineVec.Magnitude, 0):SetMagnitude(nudge):RadRotate((self.lineVec.AbsRadAngle + self.parent:GetAimAngle(true))/2 + self.parent.FlipFactor * 0.7); - self.parent.Vel = self.parent.Vel + aimvec; - end - end + -- Update hook's own position if it's not attached to an MO + -- If attached to terrain (actionMode 2), its position is already fixed by its anchor point. + -- If flying (actionMode 1), its position is determined by its Verlet integration + constraints. + if self.actionMode == 1 then + -- The hook's self.Pos is updated by its own physics, but constraints might adjust segment end + self.Pos.X = self.apx[self.currentSegments] + self.Pos.Y = self.apy[self.currentSegments] + Logger.debug("Grapple Update() - Hook position updated to (%.1f, %.1f)", self.Pos.X, self.Pos.Y) + end - if controller:IsState(Controller.HOLD_DOWN) and self.setLineLength < (self.maxLineLength-self.climbInterval) then - self.climb = 2; - end + -- Aim the gun only if it's currently equipped AND we have a valid gun reference + if self.parentGun and self.parentGun.ID ~= rte.NoMOID then + local gunIsEquipped = (self.parentGun.RootID == parentActor.ID) + + if gunIsEquipped then + local flipAng = parentActor.HFlipped and math.pi or 0 + self.parentGun.RotAngle = self.lineVec.AbsRadAngle + flipAng + Logger.debug("Grapple Update() - Gun angle updated: %.2f", self.parentGun.RotAngle) + + -- Handle unhooking from firing the gun again - ONLY when gun is equipped + if self.parentGun.FiredFrame then + Logger.info("Grapple Update() - Gun fired while grapple active") + if self.actionMode == 1 then -- If flying, just delete + Logger.info("Grapple Update() - Flying mode: marking for deletion") + if self.returnSound then + self.returnSound:Play(parentActor.Pos) + Logger.debug("Grapple Update() - Return sound played for gun fire unhook (flying)") end - controller:SetState(Controller.AIM_UP, false); - controller:SetState(Controller.AIM_DOWN, false); + self.ToDelete = true + elseif self.actionMode > 1 then -- If attached, mark as ready to release + Logger.info("Grapple Update() - Attached mode: marking ready to release") + self.canRelease = true end end - - if self.actionMode == 2 then -- Stuck terrain - if self.stretchMode then - local pullVec = self.lineVec:SetMagnitude(0.15 * math.sqrt(self.lineLength)/parentForces); - self.parent.Vel = self.parent.Vel + pullVec; - elseif self.lineLength > self.setLineLength then - local hookVel = SceneMan:ShortestDistance(Vector(self.PrevPos.X, self.PrevPos.Y), Vector(self.Pos.X, self.Pos.Y), self.mapWrapsX); - - local pullAmountNumber = self.lineVec.AbsRadAngle - self.parent.Vel.AbsRadAngle; - if pullAmountNumber < 0 then - pullAmountNumber = pullAmountNumber * -1; + -- If marked ready and gun is fired again (or activated for some guns) + if self.canRelease and self.parentGun.FiredFrame and + (self.parentGun.Vel.Y ~= -1 or self.parentGun:IsActivated()) then + Logger.info("Grapple Update() - Release condition met, marking for deletion") + if self.returnSound then + self.returnSound:Play(parentActor.Pos) + Logger.debug("Grapple Update() - Return sound played for gun fire unhook (attached)") + end + self.ToDelete = true + end + end + + -- Always hide magazine when grapple is active, regardless of equipped status + if MovableMan:IsParticle(self.parentGun.Magazine) then -- Check if Magazine is a particle + ToMOSParticle(self.parentGun.Magazine).Scale = 0 -- Hide magazine when grapple is active + end + else + Logger.debug("Grapple Update() - No valid gun reference for aiming") + end + + -- Player-specific controls and unhooking mechanisms + if IsAHuman(parentActor) or IsACrab(parentActor) then + if parentActor:IsPlayerControlled() then + Logger.debug("Grapple Update() - Processing player controls") + + -- Only process gun-dependent controls if we have a valid gun reference + if self.parentGun and self.parentGun.ID ~= rte.NoMOID then + -- Refresh gun reference to ensure we have the latest gun instance + RopeInputController.refreshGunReference(self) + + local controller = self.parent:GetController() + local gunIsEquipped = self.parentGun and (self.parentGun.RootID == parentActor.ID) + + if controller and gunIsEquipped then + -- Only handle unhook inputs when gun is equipped + -- 1. Handle R key (reload) to unhook - use the module function + if RopeInputController.handleReloadKeyUnhook(self, controller) then + Logger.info("Grapple Update() - Reload key unhook triggered") + if self.returnSound then + self.returnSound:Play(parentActor.Pos) + Logger.debug("Grapple Update() - Return sound played for R key unhook") + end + self.ToDelete = true + return end - - pullAmountNumber = pullAmountNumber/6.28; - self.parent:AddAbsForce(self.lineVec:SetMagnitude(((self.lineLength - self.setLineLength)^3) * pullAmountNumber) + hookVel:SetMagnitude(math.pow(self.lineLength - self.setLineLength, 2) * 0.8), self.parent.Pos); - - local moveToPos = self.Pos + (self.lineVec * -1):SetMagnitude(self.setLineLength); - if self.mapWrapsX == true then - if moveToPos.X > SceneMan.SceneWidth then - moveToPos = Vector(moveToPos.X - SceneMan.SceneWidth, moveToPos.Y); - elseif moveToPos.X < 0 then - moveToPos = Vector(SceneMan.SceneWidth + moveToPos.X, moveToPos.Y); + + -- 2. Handle pie menu unhook commands + if RopeInputController.handlePieMenuSelection(self) then + Logger.info("Grapple Update() - Pie menu unhook triggered") + if self.returnSound then + self.returnSound:Play(parentActor.Pos) + Logger.debug("Grapple Update() - Return sound played for pie menu unhook") end + self.ToDelete = true + return end - - self.parent.Pos = moveToPos; - - local pullAmountNumber = math.abs(self.lineVec.AbsRadAngle - self.parent.Vel.AbsRadAngle)/6.28; - -- Break the rope if the forces are too high - if (self.parent.Vel - self.lineVec:SetMagnitude(self.parent.Vel.Magnitude * pullAmountNumber)):MagnitudeIsGreaterThan(self.lineStrength) then - self.ToDelete = true; + + -- Set magazine to empty when grapple is active + if self.parentGun.Magazine then + self.parentGun.Magazine.RoundCount = 0 + self.parentGun.Magazine.Scale = 0 -- Hide the magazine end - - self.parent.Vel = self.parent.Vel + self.lineVec; end - - elseif self.actionMode == 3 then -- Stuck MO - if self.target.ID ~= rte.NoMOID then - self.Pos = self.target.Pos + Vector(self.stickPosition.X, self.stickPosition.Y):RadRotate(self.target.RotAngle - self.stickRotation); - self.RotAngle = self.stickDirection + (self.target.RotAngle - self.stickRotation); - - local jointStiffness; - local target = self.target; - if target.ID ~= target.RootID then - local mo = target:GetRootParent(); - if mo.ID ~= rte.NoMOID and IsAttachable(target) then - -- It's best to apply all the forces to the parent instead of utilizing JointStiffness - target = mo; + + if controller and gunIsEquipped then + -- 3. Handle double-tap crouch to unhook - use the module function + if RopeInputController.handleTapDetection(self, controller) then + Logger.info("Grapple Update() - Tap detection unhook triggered") + if self.returnSound then + self.returnSound:Play(parentActor.Pos) + Logger.debug("Grapple Update() - Return sound played for tap unhook") end + self.ToDelete = true + return end - - if self.stretchMode then - local pullVec = self.lineVec:SetMagnitude(self.stretchPullRatio * math.sqrt(self.lineLength)/parentForces); - self.parent.Vel = self.parent.Vel + pullVec; - - local targetForces = 1 + (target.Vel.Magnitude * 10 + target.Mass)/(1 + self.lineLength); - target.Vel = target.Vel - (pullVec) * parentForces/targetForces; - elseif self.lineLength > self.setLineLength then - -- Take wrapping to account, treat all distances relative to hook - local parentPos = target.Pos + SceneMan:ShortestDistance(target.Pos, self.parent.Pos, self.mapWrapsX); - -- Add forces to both user and the target MO - local hookVel = SceneMan:ShortestDistance(Vector(self.PrevPos.X, self.PrevPos.Y), Vector(self.Pos.X, self.Pos.Y), self.mapWrapsX); - - local pullAmountNumber = self.lineVec.AbsRadAngle - self.parent.Vel.AbsRadAngle; - if pullAmountNumber < 0 then - pullAmountNumber = pullAmountNumber * -1; - end - pullAmountNumber = pullAmountNumber/6.28; - self.parent:AddAbsForce(self.lineVec:SetMagnitude(((self.lineLength - self.setLineLength)^3) * pullAmountNumber) + hookVel:SetMagnitude(math.pow(self.lineLength - self.setLineLength, 2) * 0.8), self.parent.Pos); - - pullAmountNumber = (self.lineVec * -1).AbsRadAngle - (hookVel).AbsRadAngle; - if pullAmountNumber < 0 then - pullAmountNumber = pullAmountNumber * -1; + + -- Always allow rope movement controls when gun is equipped + RopeInputController.handleRopePulling(self) + RopeInputController.handleAutoRetraction(self, false) + end + else + -- No valid gun reference, but grapple is attached - limited functionality + Logger.debug("Grapple Update() - No gun reference, limited functionality") + local controller = self.parent:GetController() + if controller then + -- Allow basic unhook via double-tap when no gun (emergency unhook) + if RopeInputController.handleTapDetection(self, controller) then + Logger.info("Grapple Update() - Emergency tap detection unhook (no gun)") + if self.returnSound then + self.returnSound:Play(parentActor.Pos) end - pullAmountNumber = pullAmountNumber/6.28; - local targetforce = ((self.lineVec * -1):SetMagnitude(((self.lineLength - self.setLineLength)^3) * pullAmountNumber) + (self.lineVec * -1):SetMagnitude(math.pow(self.lineLength - self.setLineLength, 2) * 0.8)); - - target:AddAbsForce(targetforce, self.Pos);--target.Pos + SceneMan:ShortestDistance(target.Pos, self.Pos, self.mapWrapsX)); - target.AngularVel = target.AngularVel * 0.99; + self.ToDelete = true + return end - else -- Our MO has been destroyed, return hook - self.ToDelete = true; end end end - - -- Double tapping crouch retrieves the hook - if controller and controller:IsState(Controller.BODY_PRONE) then - self.pieSelection = 0; - if self.canTap == true then - controller:SetState(Controller.BODY_PRONE, false); - self.climb = 0; - if self.parentGun ~= nil and self.parentGun.ID ~= rte.NoMOID then - self.parentGun:RemoveNumberValue("GrappleMode"); - end - - self.tapTimer:Reset(); - self.didTap = true; - self.canTap = false; - self.tapCounter = self.tapCounter + 1; - end - else - self.canTap = true; + + -- Gun stance offset when holding the gun (only if we have a valid gun reference) + if self.parentGun and self.parentGun.ID ~= rte.NoMOID and self.parentGun.RootID == parentActor.ID then + local offsetAngle = parentActor.FlipFactor * (self.lineVec.AbsRadAngle - parentActor:GetAimAngle(true)) + self.parentGun.StanceOffset = Vector(self.lineLength, 0):RadRotate(offsetAngle) + Logger.debug("Grapple Update() - Gun stance offset updated: angle=%.2f", offsetAngle) end + end - if self.tapTimer:IsPastSimMS(self.tapTime) then - self.tapCounter = 0; - else - if self.tapCounter >= self.tapAmount then - self.ToDelete = true; - end + -- Render the rope + Logger.debug("Grapple Update() - Rendering rope") + RopeRenderer.drawRope(self, player) + + -- Final deletion check and cleanup + if self.ToDelete then + Logger.info("Grapple Update() - Preparing for deletion, cleaning up") + if self.parentGun and self.parentGun.Magazine then + -- Show the magazine as if the hook is being retracted + local drawPos = parentActor.Pos + (self.lineVec * 0.5) + self.parentGun.Magazine.Pos = drawPos + self.parentGun.Magazine.Scale = 1 + self.parentGun.Magazine.Frame = 0 + Logger.debug("Grapple Update() - Magazine repositioned for retraction effect") end - - -- Fine tuning: take the seam into account when drawing the rope - local drawPos = self.parent.Pos + self.lineVec:SetMagnitude(self.lineLength); - if self.ToDelete == true then - drawPos = self.parent.Pos + (self.lineVec * 0.5); - if self.parentGun and self.parentGun.Magazine then - -- Show the magazine as if the hook is being retracted - self.parentGun.Magazine.Pos = drawPos; - self.parentGun.Magazine.Scale = 1; - self.parentGun.Magazine.Frame = 0; - end - self.returnSound:Play(drawPos); + if self.returnSound then + self.returnSound:Play(parentActor.Pos) + Logger.debug("Grapple Update() - Return sound played") end - - PrimitiveMan:DrawLinePrimitive(startPos, drawPos, 249); - elseif self.parentGun and IsHDFirearm(self.parentGun) then - self.parent = self.parentGun; - else - self.ToDelete = true; end + + Logger.debug("Grapple Update() - Update complete") end + function Destroy(self) - if MovableMan:IsParticle(self.crankSound) then - self.crankSound.ToDelete = true; + Logger.info("Grapple Destroy() - Starting cleanup") + + if self.crankSoundInstance and not self.crankSoundInstance.ToDelete then + self.crankSoundInstance.ToDelete = true + Logger.debug("Grapple Destroy() - Crank sound instance marked for deletion") end + + -- Try to restore magazine state via the input controller first + RopeInputController.restoreMagazineState(self) + + -- Clean up references on the parent gun if self.parentGun and self.parentGun.ID ~= rte.NoMOID then - self.parentGun.HUDVisible = true; - self.parentGun:RemoveNumberValue("GrappleMode"); + Logger.debug("Grapple Destroy() - Cleaning up parent gun references") + self.parentGun.HUDVisible = true + self.parentGun:RemoveNumberValue("GrappleMode") + self.parentGun.StanceOffset = Vector(0,0) + + -- Restore and show magazine when grapple is destroyed (fallback) + if self.parentGun.Magazine then + self.parentGun.Magazine.RoundCount = 1 -- Restore to 1 round when grapple returns + self.parentGun.Magazine.Scale = 1 -- Make magazine visible again + Logger.debug("Grapple Destroy() - Magazine restored and made visible (fallback)") + end end -end \ No newline at end of file + + Logger.info("Grapple Destroy() - Cleanup complete") +end diff --git a/Data/Base.rte/Devices/Tools/GrappleGun/GrappleGun.ini b/Data/Base.rte/Devices/Tools/GrappleGun/GrappleGun.ini index 5488859b6c..15f8f73885 100644 --- a/Data/Base.rte/Devices/Tools/GrappleGun/GrappleGun.ini +++ b/Data/Base.rte/Devices/Tools/GrappleGun/GrappleGun.ini @@ -256,6 +256,16 @@ AddDevice = HDFirearm CopyOf = Hand Open ScriptPath = Base.rte/Devices/Shared/Scripts/ToggleDualWield.lua FunctionName = ToggleDualWield + AddPieSlice = PieSlice + Description = Unhook + Direction = Right + Icon = Icon + PresetName = Grapple Gun Unhook + FrameCount = 2 + BitmapFile = ContentFile + FilePath = Base.rte/Devices/Tools/GrappleGun/PieIcons/Unhook.png + ScriptPath = Base.rte/Devices/Tools/GrappleGun/Pie.lua + FunctionName = GrapplePieUnhook AddGib = Gib GibParticle = MOPixel CopyOf = Spark Yellow 1 diff --git a/Data/Base.rte/Devices/Tools/GrappleGun/GrappleGun.lua b/Data/Base.rte/Devices/Tools/GrappleGun/GrappleGun.lua index 9c0bbb4a34..9f367c2cc2 100644 --- a/Data/Base.rte/Devices/Tools/GrappleGun/GrappleGun.lua +++ b/Data/Base.rte/Devices/Tools/GrappleGun/GrappleGun.lua @@ -1,89 +1,200 @@ +---@diagnostic disable: undefined-global +-- Localize Cortex Command globals +local Timer = Timer +local PresetMan = PresetMan +local CreateMOSRotating = CreateMOSRotating +local IsActor = IsActor +local Actor = Actor +local ToMOSParticle = ToMOSParticle +local ToMOSprite = ToMOSprite +local PrimitiveMan = PrimitiveMan +local ActivityMan = ActivityMan +local MovableMan = MovableMan +local Vector = Vector +local Controller = Controller -- For Controller.BODY_PRONE etc. +local rte = rte + function Create(self) - self.tapTimerAim = Timer(); - self.tapTimerJump = Timer(); - self.tapCounter = 0; - self.didTap = false; - self.canTap = false; + -- Timers and counters for tap-based controls (e.g., double-tap to retrieve hook) + self.tapTimerJump = Timer() -- Used for crouch-tap detection. + self.tapCounter = 0 + -- self.didTap = false -- Seems unused, consider removing. + self.canTap = false -- Flag to register the first tap in a sequence. - self.tapTime = 200; - self.tapAmount = 2; - self.guide = false; + self.tapTime = 200 -- Max milliseconds between taps for them to count as a sequence. + self.tapAmount = 2 -- Number of taps required. + + self.guide = false -- Whether to show the aiming guide arrow. - self.arrow = CreateMOSRotating("Grapple Gun Guide Arrow"); + -- Create the guide arrow MOSRotating. This is a visual aid. + -- Ensure "Grapple Gun Guide Arrow" preset exists and is a MOSRotating. + local arrowPreset = PresetMan:GetPreset("Grapple Gun Guide Arrow", "MOSRotating", "Grapple Gun Guide Arrow") + if arrowPreset and arrowPreset.ClassName == "MOSRotating" then + self.arrow = CreateMOSRotating("Grapple Gun Guide Arrow") + if self.arrow then + self.arrow.GlobalAccurateDelete = true -- Ensure it cleans up properly + end + else + self.arrow = nil -- Preset not found or incorrect type + -- Log an error or warning if preset is missing/incorrect + -- print("Warning: Grapple Gun Guide Arrow preset not found or incorrect type.") + end + + self.originalRoundCount = 1 + self.hasGrappleActive = false end function Update(self) - local parent = self:GetRootParent(); - if parent and IsActor(parent) then - if IsAHuman(parent) then - parent = ToAHuman(parent); - elseif IsACrab(parent) then - parent = ToACrab(parent); - else - parent = ToActor(parent); - end - if parent:IsPlayerControlled() and parent.Status < Actor.DYING then - local controller = parent:GetController(); - local mouse = controller:IsMouseControlled(); - -- Deactivate when equipped in BG arm to allow FG arm shooting - if parent.EquippedBGItem and parent.EquippedItem then - if parent.EquippedBGItem.ID == self.ID then - self:Deactivate(); - end - end + local parent = self:GetRootParent() + + -- Ensure the gun is held by a valid, player-controlled Actor. + if not parent or not IsActor(parent) then + self:Deactivate() -- If not held by an actor, deactivate. + return + end + + local parentActor = ToActor(parent) -- Cast to Actor base type + -- Specific casting to AHuman or ACrab can be done if needed for type-specific logic - if self.Magazine then - -- Double tapping crouch retrieves the hook - if self.Magazine.Scale == 1 then - self.StanceOffset = Vector(ToMOSprite(self:GetParent()):GetSpriteWidth(), 1); - self.SharpStanceOffset = Vector(ToMOSprite(self:GetParent()):GetSpriteWidth(), 1); - if controller and controller:IsState(Controller.BODY_PRONE) then - if self.canTap then - controller:SetState(Controller.BODY_PRONE, false); - self.tapTimerJump:Reset(); - self.didTap = true; - self.canTap = false; - self.tapCounter = self.tapCounter + 1; - end - else - self.canTap = true; - end - - if self.tapTimerJump:IsPastSimMS(self.tapTime) then - self.tapCounter = 0; - else - if self.tapCounter >= self.tapAmount then - self:Activate(); - self.tapCounter = 0; - end - end - end - - -- A guide arrow appears at higher speeds - if (self.Magazine.Scale == 0 and not controller:IsState(Controller.AIM_SHARP)) or parent.Vel:MagnitudeIsGreaterThan(6) then - self.guide = true; - else - self.guide = false; - end + if not parentActor:IsPlayerControlled() or parentActor.Status >= Actor.DYING then + self:Deactivate() -- Deactivate if not player controlled or if player is dying. + return + end + + local controller = parentActor:GetController() + if not controller then + self:Deactivate() -- Should not happen if IsPlayerControlled is true, but good check. + return + end + + -- REMOVE/COMMENT OUT this section that deactivates in background: + --[[ + if parentActor.EquippedBGItem and parentActor.EquippedBGItem.ID == self.ID and parentActor.EquippedItem then + self:Deactivate() + // Potentially return here if no further logic should run for a BG equipped grapple gun. + end + --]] + + -- Allow gun to stay active in background for rope functionality + + -- Magazine handling (visual representation of the hook's availability) + if self.Magazine and MovableMan:IsParticle(self.Magazine) then + local magazineParticle = ToMOSParticle(self.Magazine) + + -- Double tapping crouch retrieves the hook (if a grapple is active) + -- This logic seems to be for initiating a retrieve action from the gun itself. + -- The actual unhooking is handled by the Grapple.lua script's tap detection. + -- This section might be redundant if Grapple.lua's tap detection is comprehensive. + if magazineParticle.Scale == 1 then -- Assuming Scale 1 means hook is "loaded" / available to fire + -- The following stance offsets seem to be for when the hook is *not* fired yet. + -- Consider if this is the correct condition. + local parentSprite = ToMOSprite(self:GetParent()) -- Assuming self:GetParent() is the gun's sprite component + if parentSprite then + local spriteWidth = parentSprite:GetSpriteWidth() or 0 + self.StanceOffset = Vector(spriteWidth, 1) + self.SharpStanceOffset = Vector(spriteWidth, 1) end - if self.guide then - local frame = 0; - if parent.Vel:MagnitudeIsGreaterThan(12) then - frame = 1; - end - local startPos = (parent.Pos + parent.EyePos + self.Pos)/3; - local guidePos = startPos + Vector(parent.AimDistance + (parent.Vel.Magnitude), 0):RadRotate(parent:GetAimAngle(true)); - PrimitiveMan:DrawBitmapPrimitive(ActivityMan:GetActivity():ScreenOfPlayer(controller.Player), guidePos, self.arrow, parent:GetAimAngle(true), frame); + -- REMOVE the entire crouch-tap section from the gun - it should only be in the hook + -- The gun should NOT handle unhooking directly + + -- Only keep this for other gun functionality, NOT for unhooking: + if controller:IsState(Controller.WEAPON_RELOAD) then + -- Gun's own reload logic here (if any) + -- Do NOT send unhook signals from here end - else - self:Deactivate(); + + end + + -- Guide arrow visibility logic + -- Show if magazine scale is 0 (hook is fired) AND not sharp aiming, OR if parent is moving fast. + local shouldShowGuide = false + if magazineParticle.Scale == 0 and not controller:IsState(Controller.AIM_SHARP) then + shouldShowGuide = true + elseif parentActor.Vel and parentActor.Vel:MagnitudeIsGreaterThan(6) then + shouldShowGuide = true + end + self.guide = shouldShowGuide + else + self.guide = false -- No magazine or not a particle, so no guide based on it. + end + + -- Draw the guide arrow if enabled and valid + if self.guide and self.arrow and self.arrow.ID ~= rte.NoMOID then + local frame = 0 + if parentActor.Vel and parentActor.Vel:MagnitudeIsGreaterThan(12) then + frame = 1 -- Use a different arrow frame for higher speeds end - if self.Magazine then - self.Magazine.RoundCount = 1; - self.Magazine.Scale = 1; - self.Magazine.Frame = 0; + -- Calculate positions for drawing the arrow + -- EyePos might not exist on all Actor types, ensure parentActor has it or use a fallback. + local eyePos = parentActor.EyePos or Vector(0,0) + local startPos = (parentActor.Pos + eyePos + self.Pos)/3 -- Averaged position + local aimAngle = parentActor:GetAimAngle(true) + local aimDistance = parentActor.AimDistance or 50 -- Default AimDistance if not present + local guidePos = startPos + Vector(aimDistance + (parentActor.Vel and parentActor.Vel.Magnitude or 0), 0):RadRotate(aimAngle) + + -- Ensure the arrow MO still exists before trying to draw with it + if MovableMan:IsValid(self.arrow) then + PrimitiveMan:DrawBitmapPrimitive(ActivityMan:GetActivity():ScreenOfPlayer(controller.Player), guidePos, self.arrow, aimAngle, frame) + else + self.arrow = nil -- Arrow MO was deleted, nullify reference + end + end + + -- Check if we have an active grapple + local hasActiveGrapple = false + for mo in MovableMan.AddedActors do + if mo and mo.PresetName == "Grapple Gun Claw" and mo.parentGun and mo.parentGun.ID == self.ID then + hasActiveGrapple = true + break + end + end + + -- Update magazine based on grapple state + if self.Magazine and MovableMan:IsParticle(self.Magazine) then + local mag = ToMOSParticle(self.Magazine) + if hasActiveGrapple then + mag.RoundCount = 0 -- Empty when grapple is out + self.hasGrappleActive = true + elseif self.hasGrappleActive and not hasActiveGrapple then + -- Grapple just returned, restore ammo + mag.RoundCount = 1 + self.hasGrappleActive = false + end + end + + -- Ensure magazine is visually "full" and ready if no grapple is active. + -- This assumes the HDFirearm's standard magazine logic handles firing. + -- If a grapple claw MO (the projectile) is active, Grapple.lua will hide the magazine. + -- This section ensures it's visible when no grapple is out. + if self.Magazine and MovableMan:IsParticle(self.Magazine) then + local magParticle = ToMOSParticle(self.Magazine) + local isActiveGrapple = false + -- Check if there's an active grapple associated with this gun + for mo_instance in MovableMan:GetMOsByPreset("Grapple Gun Claw") do + if mo_instance and mo_instance.parentGun and mo_instance.parentGun.ID == self.ID then + isActiveGrapple = true + break + end + end + + if not isActiveGrapple then + magParticle.RoundCount = 1 -- Visually full + magParticle.Scale = 1 -- Visible + magParticle.Frame = 0 -- Standard frame + else + magParticle.Scale = 0 -- Hidden by active grapple (Grapple.lua also does this) + magParticle.RoundCount = 0 -- Visually empty + end end +end + +function Destroy(self) + -- Clean up the guide arrow if it exists + if self.arrow and self.arrow.ID ~= rte.NoMOID and MovableMan:IsValid(self.arrow) then + MovableMan:RemoveMO(self.arrow) + self.arrow = nil + end end \ No newline at end of file diff --git a/Data/Base.rte/Devices/Tools/GrappleGun/Pie.lua b/Data/Base.rte/Devices/Tools/GrappleGun/Pie.lua index 9c8633bdb2..1d27700f33 100644 --- a/Data/Base.rte/Devices/Tools/GrappleGun/Pie.lua +++ b/Data/Base.rte/Devices/Tools/GrappleGun/Pie.lua @@ -1,13 +1,53 @@ +-- Load required modules +-- RopeStateManager might not be directly needed here if we only set GrappleMode on the gun. +-- local RopeStateManager = require("Devices.Tools.GrappleGun.Scripts.RopeStateManager") + +-- Utility function to safely check if an object has a specific property (key) in its Lua script table. +-- This is useful for checking if a script-defined variable exists on an MO. +local function HasScriptProperty(obj, propName) + if type(obj) ~= "table" or type(propName) ~= "string" then + return false + end + -- pcall to safely access potentially non-existent script members. + -- This is more about checking Lua script-defined members rather than engine properties. + local status, result = pcall(function() return rawget(obj, propName) ~= nil end) + return status and result +end + +-- Helper function to validate grapple gun +local function ValidateGrappleGun(pieMenuOwner) + if not pieMenuOwner or not pieMenuOwner.EquippedItem then + return nil + end + + local gun = ToMOSRotating(pieMenuOwner.EquippedItem) + if gun and gun.PresetName == "Grapple Gun" then + return gun + end + + return nil +end + +-- Action for Retract slice in the pie menu. function GrapplePieRetract(pieMenuOwner, pieMenu, pieSlice) - local gun = pieMenuOwner.EquippedItem; + local gun = ValidateGrappleGun(pieMenuOwner) if gun then - ToMOSRotating(gun):SetNumberValue("GrappleMode", 1); + gun:SetNumberValue("GrappleMode", 1) -- 1 signifies Retract end end +-- Action for Extend slice in the pie menu. function GrapplePieExtend(pieMenuOwner, pieMenu, pieSlice) - local gun = pieMenuOwner.EquippedItem; + local gun = ValidateGrappleGun(pieMenuOwner) + if gun then + gun:SetNumberValue("GrappleMode", 2) -- 2 signifies Extend + end +end + +-- Action for Unhook slice in the pie menu. +function GrapplePieUnhook(pieMenuOwner, pieMenu, pieSlice) + local gun = ValidateGrappleGun(pieMenuOwner) if gun then - ToMOSRotating(gun):SetNumberValue("GrappleMode", 2); + gun:SetNumberValue("GrappleMode", 3) -- 3 signifies Unhook end end \ No newline at end of file diff --git a/Data/Base.rte/Devices/Tools/GrappleGun/PieIcons/Unhook.png b/Data/Base.rte/Devices/Tools/GrappleGun/PieIcons/Unhook.png new file mode 100644 index 0000000000..ddfc1d1307 Binary files /dev/null and b/Data/Base.rte/Devices/Tools/GrappleGun/PieIcons/Unhook.png differ diff --git a/Data/Base.rte/Devices/Tools/GrappleGun/PieIcons/Unhook000.png b/Data/Base.rte/Devices/Tools/GrappleGun/PieIcons/Unhook000.png new file mode 100644 index 0000000000..a120156b46 Binary files /dev/null and b/Data/Base.rte/Devices/Tools/GrappleGun/PieIcons/Unhook000.png differ diff --git a/Data/Base.rte/Devices/Tools/GrappleGun/PieIcons/Unhook001.png b/Data/Base.rte/Devices/Tools/GrappleGun/PieIcons/Unhook001.png new file mode 100644 index 0000000000..9ab7a0a0be Binary files /dev/null and b/Data/Base.rte/Devices/Tools/GrappleGun/PieIcons/Unhook001.png differ diff --git a/Data/Base.rte/Devices/Tools/GrappleGun/Scripts/RopeInputController.lua b/Data/Base.rte/Devices/Tools/GrappleGun/Scripts/RopeInputController.lua new file mode 100644 index 0000000000..b9430dba08 --- /dev/null +++ b/Data/Base.rte/Devices/Tools/GrappleGun/Scripts/RopeInputController.lua @@ -0,0 +1,616 @@ +-- Grapple Gun Input Controller Module +-- Handles user input for rope control. +-- Translates raw input into actions for the main grapple logic. + +local Logger = require("Scripts.Logger") +local RopeInputController = {} + +-- Check if player is currently holding the grapple gun (equipped in main or background hand) +local function isCurrentlyEquipped(grappleInstance) + if not grappleInstance.parent then + Logger.debug("RopeInputController.isCurrentlyEquipped() - No parent") + return false + end + + local parent = grappleInstance.parent + + -- Check main equipped item + local mainEquipped = false + if parent.EquippedItem and parent.EquippedItem.PresetName == "Grapple Gun" then + mainEquipped = true + -- Always update our reference when we find the gun equipped + grappleInstance.parentGun = ToHDFirearm(parent.EquippedItem) + Logger.debug("RopeInputController.isCurrentlyEquipped() - Updated parentGun reference from main hand (ID: %d)", grappleInstance.parentGun.ID) + end + + -- Check background equipped item + local bgEquipped = false + if parent.EquippedBGItem and parent.EquippedBGItem.PresetName == "Grapple Gun" then + bgEquipped = true + -- Always update our reference when we find the gun equipped + grappleInstance.parentGun = ToHDFirearm(parent.EquippedBGItem) + Logger.debug("RopeInputController.isCurrentlyEquipped() - Updated parentGun reference from BG hand (ID: %d)", grappleInstance.parentGun.ID) + end + + -- Additional check: see if gun's RootID matches parent (if we have a valid parentGun) + local rootEquipped = false + if grappleInstance.parentGun and grappleInstance.parentGun.ID ~= rte.NoMOID then + if grappleInstance.parentGun.RootID == parent.ID then + rootEquipped = true + Logger.debug("RopeInputController.isCurrentlyEquipped() - Gun root matches parent ID") + else + Logger.debug("RopeInputController.isCurrentlyEquipped() - Gun root mismatch: gun RootID=%d, parent ID=%d", + grappleInstance.parentGun.RootID, parent.ID) + end + end + + local isEquipped = mainEquipped or bgEquipped or rootEquipped + + Logger.debug("RopeInputController.isCurrentlyEquipped() - Equipment check: main=%s, bg=%s, root=%s, final=%s", + tostring(mainEquipped), tostring(bgEquipped), tostring(rootEquipped), tostring(isEquipped)) + + -- Debug additional info about current equipment state + if parent.EquippedItem then + Logger.debug("RopeInputController.isCurrentlyEquipped() - Main equipped: %s (ID: %d)", + parent.EquippedItem.PresetName or "Unknown", parent.EquippedItem.ID) + else + Logger.debug("RopeInputController.isCurrentlyEquipped() - No main equipped item") + end + + if parent.EquippedBGItem then + Logger.debug("RopeInputController.isCurrentlyEquipped() - BG equipped: %s (ID: %d)", + parent.EquippedBGItem.PresetName or "Unknown", parent.EquippedBGItem.ID) + else + Logger.debug("RopeInputController.isCurrentlyEquipped() - No BG equipped item") + end + + if grappleInstance.parentGun then + Logger.debug("RopeInputController.isCurrentlyEquipped() - Parent gun: %s (ID: %d, RootID: %d)", + grappleInstance.parentGun.PresetName or "Unknown", grappleInstance.parentGun.ID, grappleInstance.parentGun.RootID) + else + Logger.debug("RopeInputController.isCurrentlyEquipped() - No parent gun reference") + end + + return isEquipped +end + +-- Check if gun exists in player's inventory +local function isInInventory(grappleInstance) + if not grappleInstance.parent or not grappleInstance.parent.Inventory then + Logger.debug("RopeInputController.isInInventory() - Missing parent or inventory") + return false + end + + local inventoryCount = 0 + for item in grappleInstance.parent.Inventory do + inventoryCount = inventoryCount + 1 + if item then + Logger.debug("RopeInputController.isInInventory() - Inventory item %d: %s (ID: %d)", + inventoryCount, item.PresetName or "Unknown", item.ID) + if item.PresetName == "Grapple Gun" then + -- Always update our reference when we find the gun in inventory + grappleInstance.parentGun = ToHDFirearm(item) + Logger.debug("RopeInputController.isInInventory() - Updated parentGun reference from inventory (ID: %d)", grappleInstance.parentGun.ID) + return true + end + else + Logger.debug("RopeInputController.isInInventory() - Inventory item %d: nil", inventoryCount) + end + end + + Logger.debug("RopeInputController.isInInventory() - Gun not found in inventory (%d items checked)", inventoryCount) + return false +end + +-- Handle gun persistence - ensure grapple stays active even when gun changes hands/inventory +function RopeInputController.handleGunPersistence(grappleInstance) + if not grappleInstance.parent or not grappleInstance.parentGun then + Logger.warn("RopeInputController.handleGunPersistence() - Missing parent or parentGun") + return false + end + + Logger.debug("RopeInputController.handleGunPersistence() - Checking gun persistence") + + -- Check if gun still exists and is accessible to the player + local gunIsAccessible = isCurrentlyEquipped(grappleInstance) or + isInInventory(grappleInstance) or + (grappleInstance.parentGun.RootID == rte.NoMOID and + SceneMan:ShortestDistance(grappleInstance.parent.Pos, grappleInstance.parentGun.Pos, SceneMan.SceneWrapsX).Magnitude < 100) + + if not gunIsAccessible then + -- Gun was completely removed or taken by someone else + Logger.warn("RopeInputController.handleGunPersistence() - Gun no longer accessible, grapple will remain but controls limited") + return false + end + + Logger.debug("RopeInputController.handleGunPersistence() - Gun still accessible, updating magazine state") + + -- Gun still exists somewhere - keep grapple active + -- Update magazine state regardless of where gun is + if grappleInstance.parentGun.Magazine and MovableMan:IsParticle(grappleInstance.parentGun.Magazine) then + local mag = ToMOSParticle(grappleInstance.parentGun.Magazine) + mag.RoundCount = 0 -- Keep showing as "fired" + mag.Scale = 0 -- Keep hidden while grapple is active + Logger.debug("RopeInputController.handleGunPersistence() - Magazine state updated (hidden, empty)") + end + + return true +end + +-- Handle R key unhooking (only when gun is equipped) +function RopeInputController.handleReloadKeyUnhook(grappleInstance, controller) + if not controller then + Logger.debug("RopeInputController.handleReloadKeyUnhook() - No controller provided") + return false + end + + Logger.debug("RopeInputController.handleReloadKeyUnhook() - Checking reload key state") + + if isCurrentlyEquipped(grappleInstance) and controller:IsState(Controller.WEAPON_RELOAD) then + Logger.info("RopeInputController.handleReloadKeyUnhook() - R key pressed while holding grapple gun - unhooking!") + return true + end + + Logger.debug("RopeInputController.handleReloadKeyUnhook() - No unhook condition met") + return false +end + +-- Handle double-tap crouch unhooking (only when gun is NOT equipped but in inventory) +function RopeInputController.handleTapDetection(grappleInstance, controller) + if not controller or not grappleInstance.parent then + Logger.debug("RopeInputController.handleTapDetection() - No controller or parent") + return false + end + + Logger.debug("RopeInputController.handleTapDetection() - Processing tap detection, counter: %d", grappleInstance.tapCounter) + + -- Only allow tap unhooking when gun is NOT equipped but IS in inventory + if isCurrentlyEquipped(grappleInstance) then + -- Reset tap state when gun is equipped + if grappleInstance.tapCounter > 0 then + Logger.debug("RopeInputController.handleTapDetection() - Gun equipped, resetting tap counter") + end + grappleInstance.tapCounter = 0 + grappleInstance.canTap = true + return false + end + + if not isInInventory(grappleInstance) then + Logger.debug("RopeInputController.handleTapDetection() - Gun not in inventory") + return false -- Gun not in inventory at all + end + + -- Process tap detection + local proneState = controller:IsState(Controller.BODY_PRONE) + + if proneState then + if grappleInstance.canTap then + controller:SetState(Controller.BODY_PRONE, false) -- Clear prone state + + grappleInstance.tapCounter = grappleInstance.tapCounter + 1 + grappleInstance.canTap = false + grappleInstance.tapTimer:Reset() + + Logger.info("RopeInputController.handleTapDetection() - Crouch tap %d detected (gun not equipped)", grappleInstance.tapCounter) + else + Logger.debug("RopeInputController.handleTapDetection() - Crouch held but can't tap yet") + end + else + if not grappleInstance.canTap then + Logger.debug("RopeInputController.handleTapDetection() - Crouch released, can tap again") + end + grappleInstance.canTap = true + end + + -- Check for successful double-tap + if grappleInstance.tapTimer:IsPastSimMS(grappleInstance.tapTime) then + if grappleInstance.tapCounter > 0 then + Logger.debug("RopeInputController.handleTapDetection() - Tap timeout, resetting counter") + end + grappleInstance.tapCounter = 0 -- Reset if too much time passed + elseif grappleInstance.tapCounter >= grappleInstance.tapAmount then + grappleInstance.tapCounter = 0 + Logger.info("RopeInputController.handleTapDetection() - Double crouch-tap while gun not equipped - unhooking!") + return true + end + + return false +end + +-- Handle precise rope control with Shift+Mousewheel +function RopeInputController.handleShiftMousewheelControls(grappleInstance, controller) + if not controller or not grappleInstance.parent then + return false + end + + Logger.debug("RopeInputController.handleShiftMousewheelControls() - Starting shift mousewheel check") + + -- Only allow when gun is equipped and grapple is attached + if grappleInstance.actionMode <= 1 then + Logger.debug("RopeInputController.handleShiftMousewheelControls() - Action mode is %d (not attached)", grappleInstance.actionMode) + return false + end + + if not isCurrentlyEquipped(grappleInstance) then + Logger.debug("RopeInputController.handleShiftMousewheelControls() - Gun not currently equipped") + return false + end + + Logger.debug("RopeInputController.handleShiftMousewheelControls() - Equipment and attachment checks passed") + + -- Check for actual SHIFT key using UInputMan + local shiftHeld = UInputMan.FlagShiftState + Logger.debug("RopeInputController.handleShiftMousewheelControls() - SHIFT key held (UInputMan): %s", tostring(shiftHeld)) + + if not shiftHeld then + return false + end + + -- Check for mouse wheel input + local scrollUp = controller:IsState(Controller.SCROLL_UP) + local scrollDown = controller:IsState(Controller.SCROLL_DOWN) + + Logger.debug("RopeInputController.handleShiftMousewheelControls() - Scroll up: %s, Scroll down: %s", tostring(scrollUp), tostring(scrollDown)) + + if not scrollUp and not scrollDown then + return false + end + + Logger.info("RopeInputController.handleShiftMousewheelControls() - SHIFT + Mousewheel detected!") + + -- IMPORTANT: Clear the scroll states to prevent weapon switching + controller:SetState(Controller.SCROLL_UP, false) + controller:SetState(Controller.SCROLL_DOWN, false) + controller:SetState(Controller.WEAPON_CHANGE_NEXT, false) + controller:SetState(Controller.WEAPON_CHANGE_PREV, false) + + -- Apply precise rope length control + local preciseScrollSpeed = grappleInstance.shiftScrollSpeed or 1.0 + local lengthChange = 0 + + if scrollUp then + lengthChange = -preciseScrollSpeed -- Shorten rope + Logger.info("RopeInputController.handleShiftMousewheelControls() - Shortening rope by %.1f", preciseScrollSpeed) + elseif scrollDown then + lengthChange = preciseScrollSpeed -- Lengthen rope + Logger.info("RopeInputController.handleShiftMousewheelControls() - Lengthening rope by %.1f", preciseScrollSpeed) + end + + -- Update rope length + local oldLength = grappleInstance.currentLineLength + grappleInstance.currentLineLength = math.max(10, math.min(grappleInstance.currentLineLength + lengthChange, grappleInstance.maxLineLength)) + grappleInstance.setLineLength = grappleInstance.currentLineLength + + Logger.info("RopeInputController.handleShiftMousewheelControls() - Rope length changed from %.1f to %.1f", oldLength, grappleInstance.currentLineLength) + + -- Clear any automatic selections since user is manually controlling + grappleInstance.pieSelection = 0 + + return true +end + +-- Handle mouse wheel scrolling for rope control +function RopeInputController.handleMouseWheelControl(grappleInstance, controller) + if not controller or not controller:IsMouseControlled() then + Logger.debug("RopeInputController.handleMouseWheelControl() - No controller or not mouse controlled") + return + end + + -- Only allow rope controls if gun is equipped + if not isCurrentlyEquipped(grappleInstance) then + Logger.debug("RopeInputController.handleMouseWheelControl() - Gun not equipped") + return + end + + Logger.debug("RopeInputController.handleMouseWheelControl() - Processing mouse wheel input") + + -- Clear weapon change states + controller:SetState(Controller.WEAPON_CHANGE_NEXT, false) + controller:SetState(Controller.WEAPON_CHANGE_PREV, false) + + -- Check if shift is held for precise control + local shiftHeld = controller:IsState(Controller.BODY_JUMPSTART) or controller:IsState(Controller.BODY_CROUCH) + if shiftHeld then + Logger.debug("RopeInputController.handleMouseWheelControl() - Shift held, using precise controls") + RopeInputController.handleShiftMousewheelControls(grappleInstance, controller) + else + -- Normal mousewheel behavior + if controller:IsState(Controller.SCROLL_UP) then + grappleInstance.climbTimer:Reset() + grappleInstance.climb = 3 -- Mouse retract + Logger.info("RopeInputController.handleMouseWheelControl() - Mouse wheel up - retracting rope") + elseif controller:IsState(Controller.SCROLL_DOWN) then + grappleInstance.climbTimer:Reset() + grappleInstance.climb = 4 -- Mouse extend + Logger.info("RopeInputController.handleMouseWheelControl() - Mouse wheel down - extending rope") + end + end +end + +-- Handle directional key controls for climbing +function RopeInputController.handleDirectionalControl(grappleInstance, controller) + if not controller or controller:IsMouseControlled() or grappleInstance.actionMode <= 1 then + Logger.debug("RopeInputController.handleDirectionalControl() - Invalid state for directional control") + return + end + + -- Only allow rope controls if gun is equipped + if not isCurrentlyEquipped(grappleInstance) then + Logger.debug("RopeInputController.handleDirectionalControl() - Gun not equipped") + return + end + + Logger.debug("RopeInputController.handleDirectionalControl() - Checking directional input") + + if controller:IsState(Controller.HOLD_UP) then + if grappleInstance.currentLineLength > grappleInstance.climbInterval then + grappleInstance.climb = 1 -- Key retract + Logger.info("RopeInputController.handleDirectionalControl() - Up key held - retracting rope") + else + Logger.debug("RopeInputController.handleDirectionalControl() - Up key held but rope too short to retract") + end + elseif controller:IsState(Controller.HOLD_DOWN) then + if grappleInstance.currentLineLength < (grappleInstance.maxLineLength - grappleInstance.climbInterval) then + grappleInstance.climb = 2 -- Key extend + Logger.info("RopeInputController.handleDirectionalControl() - Down key held - extending rope") + else + Logger.debug("RopeInputController.handleDirectionalControl() - Down key held but rope at max length") + end + end + + -- Clear aim states if directional keys are used + if controller:IsState(Controller.HOLD_UP) or controller:IsState(Controller.HOLD_DOWN) then + controller:SetState(Controller.AIM_UP, false) + controller:SetState(Controller.AIM_DOWN, false) + Logger.debug("RopeInputController.handleDirectionalControl() - Cleared aim states") + end +end + +-- Main rope pulling handler +function RopeInputController.handleRopePulling(grappleInstance) + if not grappleInstance.parent then + return + end + + local controller = grappleInstance.parent:GetController() + if not controller then + return + end + + print("[ROPE PULLING DEBUG] Starting rope pulling handler") + + -- Handle SHIFT + Mousewheel for precise control first + if RopeInputController.handleShiftMousewheelControls(grappleInstance, controller) then + print("[ROPE PULLING DEBUG] SHIFT + Mousewheel handled, returning") + return -- If shift+mousewheel was handled, don't process other inputs + end + + -- Handle regular mouse wheel control + RopeInputController.handleMouseWheelControl(grappleInstance, controller) + + -- Handle directional key controls + RopeInputController.handleDirectionalControl(grappleInstance, controller) +end + +-- Handle pie menu selections +function RopeInputController.handlePieMenuSelection(grappleInstance) + if not grappleInstance.parentGun or grappleInstance.parentGun.ID == rte.NoMOID then + Logger.debug("RopeInputController.handlePieMenuSelection() - No parent gun") + return false + end + + -- Only allow pie menu controls if gun is equipped + if not isCurrentlyEquipped(grappleInstance) then + Logger.debug("RopeInputController.handlePieMenuSelection() - Gun not equipped") + return false + end + + Logger.debug("RopeInputController.handlePieMenuSelection() - Checking for pie menu commands") + + local mode = grappleInstance.parentGun:GetNumberValue("GrappleMode") + + if mode and mode ~= 0 then + grappleInstance.parentGun:RemoveNumberValue("GrappleMode") + Logger.info("RopeInputController.handlePieMenuSelection() - Pie menu mode %d selected", mode) + + if mode == 3 then + Logger.info("RopeInputController.handlePieMenuSelection() - Unhook command from pie menu") + return true -- Unhook via pie menu + elseif grappleInstance.actionMode > 1 then + grappleInstance.pieSelection = mode + grappleInstance.climb = 0 + Logger.info("RopeInputController.handlePieMenuSelection() - Pie selection set to %d", mode) + end + end + return false +end + +-- Handle automatic retraction +function RopeInputController.handleAutoRetraction(grappleInstance, terrCheck) + if not grappleInstance.parentGun or grappleInstance.parentGun.ID == rte.NoMOID or grappleInstance.actionMode <= 1 then + if grappleInstance.pieSelection ~= 0 then + Logger.debug("RopeInputController.handleAutoRetraction() - Clearing pie selection (invalid state)") + end + grappleInstance.pieSelection = 0 + return + end + + -- Only allow auto retraction if gun is equipped + if not isCurrentlyEquipped(grappleInstance) then + if grappleInstance.pieSelection ~= 0 then + Logger.debug("RopeInputController.handleAutoRetraction() - Clearing pie selection (gun not equipped)") + end + grappleInstance.pieSelection = 0 + return + end + + Logger.debug("RopeInputController.handleAutoRetraction() - Processing auto retraction, pieSelection: %d", grappleInstance.pieSelection) + + local parentForces = 1.0 + if grappleInstance.parent and grappleInstance.parent.Vel and grappleInstance.parent.Mass and grappleInstance.lineLength > 0 then + parentForces = 1 + (grappleInstance.parent.Vel.Magnitude * 10 + grappleInstance.parent.Mass) / (1 + grappleInstance.lineLength) + parentForces = math.max(0.1, parentForces) + Logger.debug("RopeInputController.handleAutoRetraction() - Parent forces calculated: %.2f", parentForces) + end + + -- Auto retraction when gun is activated + if grappleInstance.parentGun:IsActivated() and grappleInstance.pieSelection == 0 then + if grappleInstance.climbTimer:IsPastSimMS(grappleInstance.climbDelay) then + grappleInstance.climbTimer:Reset() + if grappleInstance.currentLineLength > grappleInstance.autoClimbIntervalA then + local oldLength = grappleInstance.currentLineLength + grappleInstance.currentLineLength = grappleInstance.currentLineLength - (grappleInstance.autoClimbIntervalA / parentForces) + grappleInstance.setLineLength = grappleInstance.currentLineLength + Logger.info("RopeInputController.handleAutoRetraction() - Gun activated: auto retract %.1f -> %.1f", oldLength, grappleInstance.currentLineLength) + else + Logger.debug("RopeInputController.handleAutoRetraction() - Gun activated but rope too short to retract") + end + end + end + + -- Pie menu controlled retraction/extension + if grappleInstance.pieSelection ~= 0 then + if grappleInstance.climbTimer:IsPastSimMS(grappleInstance.climbDelay) then + grappleInstance.climbTimer:Reset() + local actionTaken = false + local oldLength = grappleInstance.currentLineLength + + if grappleInstance.pieSelection == 1 then -- Retract + if grappleInstance.currentLineLength > grappleInstance.autoClimbIntervalA then + grappleInstance.currentLineLength = grappleInstance.currentLineLength - (grappleInstance.autoClimbIntervalA / parentForces) + actionTaken = true + Logger.info("RopeInputController.handleAutoRetraction() - Pie retract: %.1f -> %.1f", oldLength, grappleInstance.currentLineLength) + else + Logger.debug("RopeInputController.handleAutoRetraction() - Pie retract: rope too short") + end + elseif grappleInstance.pieSelection == 2 then -- Extend + if grappleInstance.currentLineLength < (grappleInstance.maxLineLength - grappleInstance.autoClimbIntervalB) then + grappleInstance.currentLineLength = grappleInstance.currentLineLength + grappleInstance.autoClimbIntervalB + actionTaken = true + Logger.info("RopeInputController.handleAutoRetraction() - Pie extend: %.1f -> %.1f", oldLength, grappleInstance.currentLineLength) + else + Logger.debug("RopeInputController.handleAutoRetraction() - Pie extend: rope at max length") + end + end + + grappleInstance.setLineLength = grappleInstance.currentLineLength + if not actionTaken then + Logger.info("RopeInputController.handleAutoRetraction() - Pie action complete, clearing selection") + grappleInstance.pieSelection = 0 + end + end + end + + local clampedLength = math.max(10, math.min(grappleInstance.currentLineLength, grappleInstance.maxLineLength)) + if clampedLength ~= grappleInstance.currentLineLength then + Logger.debug("RopeInputController.handleAutoRetraction() - Clamped rope length from %.1f to %.1f", grappleInstance.currentLineLength, clampedLength) + end + grappleInstance.currentLineLength = clampedLength +end + +-- Refresh gun reference - called when gun might have changed +function RopeInputController.refreshGunReference(grappleInstance) + -- Only refresh if we don't have a valid reference + if grappleInstance.parentGun then + local success, presetName = pcall(function() return grappleInstance.parentGun.PresetName end) + local idSuccess, gunID = pcall(function() return grappleInstance.parentGun.ID end) + if success and presetName == "Grapple Gun" and idSuccess and gunID and gunID ~= rte.NoMOID then + Logger.debug("RopeInputController.refreshGunReference() - Current gun reference is valid, no refresh needed") + return true -- Current reference is fine + end + end + + Logger.debug("RopeInputController.refreshGunReference() - Refreshing gun reference") + + if not grappleInstance.parent then + Logger.debug("RopeInputController.refreshGunReference() - No parent") + return false + end + + local parent = grappleInstance.parent + local foundGun = false + + -- Check equipped items first + if parent.EquippedItem and parent.EquippedItem.PresetName == "Grapple Gun" then + grappleInstance.parentGun = ToHDFirearm(parent.EquippedItem) + foundGun = true + Logger.info("RopeInputController.refreshGunReference() - Gun found equipped in main hand (ID: %d)", grappleInstance.parentGun.ID) + elseif parent.EquippedBGItem and parent.EquippedBGItem.PresetName == "Grapple Gun" then + grappleInstance.parentGun = ToHDFirearm(parent.EquippedBGItem) + foundGun = true + Logger.info("RopeInputController.refreshGunReference() - Gun found equipped in BG hand (ID: %d)", grappleInstance.parentGun.ID) + end + + -- If not equipped, check inventory + if not foundGun and parent.Inventory then + for item in parent.Inventory do + if item and item.PresetName == "Grapple Gun" then + grappleInstance.parentGun = ToHDFirearm(item) + foundGun = true + Logger.info("RopeInputController.refreshGunReference() - Gun found in inventory (ID: %d)", grappleInstance.parentGun.ID) + break + end + end + end + + if foundGun and grappleInstance.parentGun then + -- Test if we can actually access the gun's properties + local testSuccess, testID = pcall(function() return grappleInstance.parentGun.ID end) + if testSuccess and testID and testID ~= rte.NoMOID then + -- Update magazine state for the refreshed gun + local magSuccess, magazine = pcall(function() return grappleInstance.parentGun.Magazine end) + if magSuccess and magazine and MovableMan:IsParticle(magazine) then + local mag = ToMOSParticle(magazine) + mag.RoundCount = 0 -- Keep showing as "fired" + mag.Scale = 0 -- Keep hidden while grapple is active + Logger.debug("RopeInputController.refreshGunReference() - Updated magazine state for refreshed gun") + end + return true + else + Logger.warn("RopeInputController.refreshGunReference() - Found gun but cannot access its properties") + grappleInstance.parentGun = nil + return false + end + end + + Logger.warn("RopeInputController.refreshGunReference() - Could not find any grapple gun") + return false +end + +-- Restore magazine state when grapple is being destroyed +function RopeInputController.restoreMagazineState(grappleInstance) + if not grappleInstance.parentGun then + Logger.debug("RopeInputController.restoreMagazineState() - No parent gun to restore") + -- Try to find gun one more time for restoration + if RopeInputController.refreshGunReference(grappleInstance) then + Logger.debug("RopeInputController.restoreMagazineState() - Found gun during restoration attempt") + else + return false + end + end + + -- Don't call refreshGunReference again if we already have a gun reference + -- Test the gun reference directly + local success, gunID = pcall(function() return grappleInstance.parentGun.ID end) + if success and gunID and gunID ~= rte.NoMOID then + Logger.info("RopeInputController.restoreMagazineState() - Restoring magazine state for gun (ID: %d)", gunID) + + -- Restore magazine visibility and ammo count + local magSuccess, magazine = pcall(function() return grappleInstance.parentGun.Magazine end) + if magSuccess and magazine and MovableMan:IsParticle(magazine) then + local mag = ToMOSParticle(magazine) + mag.RoundCount = 1 -- Restore ammo + mag.Scale = 1 -- Make magazine visible again + Logger.info("RopeInputController.restoreMagazineState() - Magazine restored (visible, ammo: 1)") + return true + else + Logger.warn("RopeInputController.restoreMagazineState() - No magazine found to restore") + end + else + Logger.warn("RopeInputController.restoreMagazineState() - Gun ID invalid or inaccessible") + end + + return false +end + +return RopeInputController diff --git a/Data/Base.rte/Devices/Tools/GrappleGun/Scripts/RopePhysics.lua b/Data/Base.rte/Devices/Tools/GrappleGun/Scripts/RopePhysics.lua new file mode 100644 index 0000000000..144560614b --- /dev/null +++ b/Data/Base.rte/Devices/Tools/GrappleGun/Scripts/RopePhysics.lua @@ -0,0 +1,542 @@ +---@diagnostic disable: undefined-global +-- filepath: Cortex-Command-Community-Project/Data/Base.rte/Devices/Tools/GrappleGun/Scripts/RopePhysics.lua +--[[ + RopePhysics.lua - Advanced Rope Physics Module + + Implements Verlet integration for rope physics with position-based constraints. + Aims for a rigid rope behavior with high durability. +--]] + +local RopeStateManager = require("Scripts.Logger") + +local RopePhysics = {} + +-- Constants for physics behavior +local GRAVITY_Y = 0.1 -- Simulate normal gravity for the rope segments. +local NUDGE_DISTANCE = 1 -- Increased from 0.3 to help prevent phasing through terrain. +local BOUNCE_STRENGTH = 0.3 -- How much velocity is retained perpendicular to a collision surface. +local CONSTRAINT_STRENGTH = 1.0 -- Full strength for rigid rope constraints. +local DEFAULT_PHYSICS_ITERATIONS = 32 -- Default number of constraint iterations. User request. + +--[[ + Resolves collisions for a single rope segment using raycasting. + @param self The grapple instance. + @param segmentIdx The index of the segment point to process. + @param nextX The potential next X position (delta from current). + @param nextY The potential next Y position (delta from current). +]] +function RopePhysics.verletCollide(self, segmentIdx, nextX, nextY) + local currentPosX = self.apx[segmentIdx] + local currentPosY = self.apy[segmentIdx] + + local movementRay = Vector(nextX, nextY) + + -- Optimization: Skip collision check for very small movements. + if movementRay:MagnitudeIsLessThan(0.01) then -- Reduced threshold + self.apx[segmentIdx] = currentPosX + nextX + self.apy[segmentIdx] = currentPosY + nextY + return + end + + local collisionPoint = Vector() -- Stores the collision point if one occurs. + local surfaceNormal = Vector() -- Stores the normal of the collided surface. + + -- Cast a ray to detect obstacles (terrain and other MOs). + -- Uses parent's ID to avoid self-collision with the firing actor. + local collisionDist = SceneMan:CastObstacleRay(Vector(currentPosX, currentPosY), movementRay, + collisionPoint, surfaceNormal, + (self.parent and self.parent.ID or 0), + self.Team, rte.airID, 0) + + if type(collisionDist) == "number" and collisionDist >= 0 and collisionDist <= movementRay.Magnitude then + -- Collision detected. + if surfaceNormal:MagnitudeIsGreaterThan(0.001) then + surfaceNormal:SetMagnitude(1) -- Ensure normal is normalized. + else + surfaceNormal = Vector(0, -1) -- Default to an upward normal if it's invalid. + end + + -- Move the point to the collision surface and nudge it slightly along the normal. + self.apx[segmentIdx] = collisionPoint.X + surfaceNormal.X * NUDGE_DISTANCE + self.apy[segmentIdx] = collisionPoint.Y + surfaceNormal.Y * NUDGE_DISTANCE + + -- Update the 'last' position to simulate a bounce, reducing phasing. + self.lastX[segmentIdx] = self.apx[segmentIdx] - surfaceNormal.X * BOUNCE_STRENGTH + self.lastY[segmentIdx] = self.apy[segmentIdx] - surfaceNormal.Y * BOUNCE_STRENGTH + + -- If an anchor point (player or hook end) collides, it should ideally stop completely against the surface. + if segmentIdx == 0 or segmentIdx == self.currentSegments then + self.lastX[segmentIdx] = self.apx[segmentIdx] -- Effectively zero velocity for next frame at this point. + self.lastY[segmentIdx] = self.apy[segmentIdx] + end + else + -- No collision, apply the full displacement. + self.apx[segmentIdx] = currentPosX + nextX + self.apy[segmentIdx] = currentPosY + nextY + end +end + +--[[ + Calculates the optimal number of segments based on the current rope length. + Aims to balance visual fidelity with performance. + @param self The grapple instance. + @param ropeLength The current length of the rope. + @return The optimal number of segments. +]] +function RopePhysics.calculateOptimalSegments(self, ropeLength) + if ropeLength <= 0 then return self.minSegments end + + local baseSegments = math.ceil(ropeLength / self.segmentLength) + + -- Apply a scaling factor for very long ropes to use fewer segments per unit length. + local scalingFactor = 1.0 + if ropeLength > 200 then -- Example threshold for when scaling starts + -- Reduce segments more gradually for longer ropes. + scalingFactor = 1.0 - math.min(0.3, (ropeLength - 200) / 800) -- Adjusted scaling + end + + local desiredSegments = math.ceil(baseSegments * scalingFactor) + + return math.max(self.minSegments, math.min(desiredSegments, self.maxSegments)) +end + +--[[ + Determines the number of physics iterations. + Currently fixed as per user request in original comments. + @param self The grapple instance. + @return The number of physics iterations. +]] +function RopePhysics.optimizePhysicsIterations(self) + return DEFAULT_PHYSICS_ITERATIONS +end + +--[[ + Resizes the rope's segment arrays when the optimal number of segments changes. + Interpolates positions for new segments to maintain a smooth transition. + @param self The grapple instance. + @param newNumSegments The new total number of segments. +]] +function RopePhysics.resizeRopeSegments(self, newNumSegments) + if newNumSegments == self.currentSegments then return end + + local oldNumSegments = self.currentSegments + local tempOldAPX = {} + local tempOldAPY = {} + local tempOldLastX = {} + local tempOldLastY = {} + + -- Store current segment positions and velocities + for i = 0, oldNumSegments do + tempOldAPX[i] = self.apx[i] + tempOldAPY[i] = self.apy[i] + tempOldLastX[i] = self.lastX[i] + tempOldLastY[i] = self.lastY[i] + end + + -- Initialize new arrays (or re-initialize if maxSegments was pre-allocated) + -- self.apx, self.apy, self.lastX, self.lastY should already be tables up to maxSegments. + + -- Player anchor (segment 0) + if self.parent and self.parent.Pos then + self.apx[0] = self.parent.Pos.X + self.apy[0] = self.parent.Pos.Y + self.lastX[0] = self.parent.Pos.X - (self.parent.Vel.X or 0) + self.lastY[0] = self.parent.Pos.Y - (self.parent.Vel.Y or 0) + elseif tempOldAPX[0] then -- Fallback to old anchor if parent is briefly invalid + self.apx[0] = tempOldAPX[0] + self.apy[0] = tempOldAPY[0] + self.lastX[0] = tempOldLastX[0] + self.lastY[0] = tempOldLastY[0] + end + + -- Hook anchor (segment newNumSegments) + -- The hook's current position (self.Pos) is the primary source for the end anchor. + self.apx[newNumSegments] = self.Pos.X + self.apy[newNumSegments] = self.Pos.Y + -- Estimate velocity for the hook end based on its last movement or current self.Vel + local hookVelX = self.Vel and self.Vel.X or (tempOldAPX[oldNumSegments] and (tempOldAPX[oldNumSegments] - tempOldLastX[oldNumSegments])) or 0 + local hookVelY = self.Vel and self.Vel.Y or (tempOldAPY[oldNumSegments] and (tempOldAPY[oldNumSegments] - tempOldLastY[oldNumSegments])) or 0 + self.lastX[newNumSegments] = self.Pos.X - hookVelX + self.lastY[newNumSegments] = self.Pos.Y - hookVelY + + -- Interpolate intermediate segments + if newNumSegments > 1 then + for i = 1, newNumSegments - 1 do + local t = i / newNumSegments -- Ratio along the new rope length + + -- Find corresponding point(s) on the old rope structure for interpolation + local old_t = t * oldNumSegments + local old_idx_prev = math.floor(old_t) + local old_idx_next = math.ceil(old_t) + local interp_factor = old_t - old_idx_prev + + old_idx_prev = math.max(0, math.min(old_idx_prev, oldNumSegments)) + old_idx_next = math.max(0, math.min(old_idx_next, oldNumSegments)) + + if tempOldAPX[old_idx_prev] and tempOldAPX[old_idx_next] then -- Ensure old indices are valid + self.apx[i] = tempOldAPX[old_idx_prev] * (1 - interp_factor) + tempOldAPX[old_idx_next] * interp_factor + self.apy[i] = tempOldAPY[old_idx_prev] * (1 - interp_factor) + tempOldAPY[old_idx_next] * interp_factor + self.lastX[i] = tempOldLastX[old_idx_prev] * (1 - interp_factor) + tempOldLastX[old_idx_next] * interp_factor + self.lastY[i] = tempOldLastY[old_idx_prev] * (1 - interp_factor) + tempOldLastY[old_idx_next] * interp_factor + else + -- Fallback: linear interpolation between new start and end if old points are problematic + local overall_t = i / newNumSegments + self.apx[i] = self.apx[0] * (1 - overall_t) + self.apx[newNumSegments] * overall_t + self.apy[i] = self.apy[0] * (1 - overall_t) + self.apy[newNumSegments] * overall_t + self.lastX[i] = self.apx[i] -- Initialize with no velocity + self.lastY[i] = self.apy[i] + end + end + end + + self.currentSegments = newNumSegments +end + + +--[[ + Updates the rope physics using Verlet integration. + @param grappleInstance The grapple instance. + @param startPos Position vector of the start anchor (player/gun). + @param endPos Position vector of the end anchor (hook). + @param cableLength Current maximum allowed length of the cable (physics length). +]] +function RopePhysics.updateRopePhysics(grappleInstance, startPos, endPos, cableLength) + local segments = grappleInstance.currentSegments + if segments < 1 or not grappleInstance.apx then return end -- Ensure segments and arrays are valid. + + -- Initialize lastX/Y for any new segments if not already done (e.g., after resize). + for i = 0, segments do + if grappleInstance.lastX[i] == nil then -- Check specifically for nil + grappleInstance.lastX[i] = grappleInstance.apx[i] or startPos.X -- Fallback if apx[i] is also nil + grappleInstance.lastY[i] = grappleInstance.apy[i] or startPos.Y + end + end + + -- Verlet integration for interior points (not the main anchors). + -- Anchors (0 and segments) are handled separately. + for i = 1, segments - 1 do + if grappleInstance.apx[i] and grappleInstance.lastX[i] then -- Ensure points are valid + local current_x = grappleInstance.apx[i] + local current_y = grappleInstance.apy[i] + local prev_x = grappleInstance.lastX[i] + local prev_y = grappleInstance.lastY[i] + + local vel_x = current_x - prev_x + local vel_y = current_y - prev_y + + grappleInstance.lastX[i] = current_x + grappleInstance.lastY[i] = current_y + + -- Apply Verlet integration with gravity. No explicit dampening here for "rigid" feel. + local next_integrated_x = current_x + vel_x + local next_integrated_y = current_y + vel_y + GRAVITY_Y + + -- Perform collision detection for this segment's new position + RopePhysics.verletCollide(grappleInstance, i, next_integrated_x - current_x, next_integrated_y - current_y) + end + end + + -- Update anchor positions (player and hook ends). + -- Player anchor (segment 0) + if startPos then + grappleInstance.apx[0] = startPos.X + grappleInstance.apy[0] = startPos.Y + -- lastX/Y for player anchor are updated in Grapple.lua based on parent's velocity. + end + + -- Hook anchor (segment 'segments') + if endPos then + if grappleInstance.actionMode == 1 then -- Flying hook + -- Save the current hook position for the final segment + -- The hook itself still follows its natural physics trajectory + grappleInstance.apx[segments] = grappleInstance.Pos.X + grappleInstance.apy[segments] = grappleInstance.Pos.Y + grappleInstance.lastX[segments] = grappleInstance.Pos.X - (grappleInstance.Vel.X or 0) + grappleInstance.lastY[segments] = grappleInstance.Pos.Y - (grappleInstance.Vel.Y or 0) + + -- Now apply Verlet physics to all intermediate rope segments + -- This makes the rope behave like it has actual physics during flight + if segments > 2 then -- Only if we have intermediate segments + for i = 1, segments - 1 do + -- Calculate how far along the rope this segment is - for natural draping effect + local t = i / segments + + -- Apply a slight gravity influence based on segment position + -- Middle segments should droop more than those near anchors + local gravity_factor = t * (1 - t) * 4 -- Parabolic function, max at t=0.5 + + -- Calculate position if the rope was straight between player and hook + local straight_x = grappleInstance.apx[0] + t * (grappleInstance.apx[segments] - grappleInstance.apx[0]) + local straight_y = grappleInstance.apy[0] + t * (grappleInstance.apy[segments] - grappleInstance.apy[0]) + + -- Apply gravity influence only to existing positions, don't override completely + if not grappleInstance.lastX[i] then + -- First initialization for this segment + grappleInstance.lastX[i] = straight_x + grappleInstance.lastY[i] = straight_y + grappleInstance.apx[i] = straight_x + grappleInstance.apy[i] = straight_y + gravity_factor * 0.5 -- Slight initial droop + else + -- Preserve momentum from previous frame + local vel_x = grappleInstance.apx[i] - grappleInstance.lastX[i] + local vel_y = grappleInstance.apy[i] - grappleInstance.lastY[i] + + grappleInstance.lastX[i] = grappleInstance.apx[i] + grappleInstance.lastY[i] = grappleInstance.apy[i] + + -- Apply Verlet integration with gravity influence + local next_x = grappleInstance.apx[i] + vel_x * 0.98 -- Slight damping + local next_y = grappleInstance.apy[i] + vel_y * 0.98 + GRAVITY_Y * gravity_factor + + -- Perform collision detection for this segment's new position + RopePhysics.verletCollide(grappleInstance, i, next_x - grappleInstance.apx[i], next_y - grappleInstance.apy[i]) + end + end + end + elseif grappleInstance.actionMode == 2 then -- Hook stuck in terrain + -- Position is fixed. Velocity is zero. + grappleInstance.apx[segments] = grappleInstance.apx[segments] -- Should already be set + grappleInstance.apy[segments] = grappleInstance.apy[segments] + grappleInstance.lastX[segments] = grappleInstance.apx[segments] + grappleInstance.lastY[segments] = grappleInstance.apy[segments] + elseif grappleInstance.actionMode == 3 and grappleInstance.target and grappleInstance.target.ID ~= rte.NoMOID then -- Hook on MO + local effective_target = RopeStateManager.getEffectiveTarget(grappleInstance) + if effective_target and effective_target.Pos and effective_target.Vel then + grappleInstance.apx[segments] = effective_target.Pos.X + grappleInstance.apy[segments] = effective_target.Pos.Y + grappleInstance.lastX[segments] = effective_target.Pos.X - (effective_target.Vel.X or 0) + grappleInstance.lastY[segments] = effective_target.Pos.Y - (effective_target.Vel.Y or 0) + else + -- Fallback if target becomes invalid, keep last known position + grappleInstance.lastX[segments] = grappleInstance.apx[segments] + grappleInstance.lastY[segments] = grappleInstance.apy[segments] + end + else -- Default or unknown state, try to hold position + if grappleInstance.apx[segments] then + grappleInstance.lastX[segments] = grappleInstance.apx[segments] + grappleInstance.lastY[segments] = grappleInstance.apy[segments] + end + end + end +end + + +--[[ + Applies constraints to the rope segments to maintain their lengths and overall rope length. + This is the core of the rigid rope behavior. + @param grappleInstance The grapple instance. + @param currentPhysicsLength The target physics length of the rope. + @return True if the rope should break due to extreme stretch, false otherwise. +]] +function RopePhysics.applyRopeConstraints(grappleInstance, currentPhysicsLength) + local segments = grappleInstance.currentSegments + if segments == 0 or not grappleInstance.apx or not grappleInstance.parent then return false end + + local maxAllowedRopeLength = currentPhysicsLength -- This is the length the rope tries to adhere to. + + -- Ensure anchor points are up-to-date before constraint solving. + -- Player anchor: + grappleInstance.apx[0] = grappleInstance.parent.Pos.X + grappleInstance.apy[0] = grappleInstance.parent.Pos.Y + -- Hook anchor is updated based on its state (flying, terrain, MO) in updateRopePhysics or Grapple.lua + + -- Store current tension as a ratio for feedback/other systems. + -- This will be updated after constraints. + grappleInstance.currentTension = 0 + + -- Iteratively satisfy segment length constraints. + local targetSegmentLength = maxAllowedRopeLength / math.max(1, segments) + local iterations = RopePhysics.optimizePhysicsIterations(grappleInstance) + + for iter = 1, iterations do + -- First, constrain the overall length between the two main anchors (player and hook). + -- This helps prevent the whole rope from overstretching significantly. + local p_start_x, p_start_y = grappleInstance.apx[0], grappleInstance.apy[0] + local p_end_x, p_end_y = grappleInstance.apx[segments], grappleInstance.apy[segments] + + local dx_total = p_end_x - p_start_x + local dy_total = p_end_y - p_start_y + local dist_total = math.sqrt(dx_total*dx_total + dy_total*dy_total) + + if dist_total > maxAllowedRopeLength and dist_total > 0.001 then + local diff_total = maxAllowedRopeLength - dist_total + local percent_total = (diff_total / dist_total) * CONSTRAINT_STRENGTH * 0.5 -- Apply half to each end's controller + + -- Determine how to apply correction based on actionMode + if grappleInstance.actionMode == 2 then -- Hook on terrain, player swings + -- Correct player position (only when exceeding max length) + local vec_from_hook_to_player = Vector(p_start_x - p_end_x, p_start_y - p_end_y) + local correctedPlayerPos = Vector(p_end_x, p_end_y) + vec_from_hook_to_player:SetMagnitude(maxAllowedRopeLength) + + grappleInstance.parent.Pos = correctedPlayerPos + grappleInstance.apx[0] = correctedPlayerPos.X + grappleInstance.apy[0] = correctedPlayerPos.Y + + -- Correct player velocity - BUT ONLY remove the OUTWARD component + local ropeDirFromPlayerToHook = (Vector(p_end_x, p_end_y) - correctedPlayerPos):SetMagnitude(1) + local radialVelScalar = grappleInstance.parent.Vel:Dot(ropeDirFromPlayerToHook) + + -- Only remove velocity component if it's moving AWAY from hook (radialVelScalar < 0) + -- Allow all inward movement (towards hook) to preserve free movement within the radius + if radialVelScalar < 0 then -- Only cancel outward velocity + grappleInstance.parent.Vel = grappleInstance.parent.Vel - (ropeDirFromPlayerToHook * radialVelScalar) + end + + -- Store tension feedback + if -radialVelScalar > 0.01 then + grappleInstance.ropeTensionForce = -radialVelScalar * 0.5 + grappleInstance.ropeTensionDirection = ropeDirFromPlayerToHook + else + grappleInstance.ropeTensionForce = nil + end + + elseif grappleInstance.actionMode == 1 or grappleInstance.actionMode == 3 then -- Hook flying or on MO, player is "fixed" anchor + -- Correct hook position + grappleInstance.apx[segments] = p_end_x + dx_total * percent_total + grappleInstance.apy[segments] = p_end_y + dy_total * percent_total + -- Also update the grapple MO's actual position if it's the one being moved + if grappleInstance.actionMode == 1 then -- Flying hook's position is its anchor + grappleInstance.Pos.X = grappleInstance.apx[segments] + grappleInstance.Pos.Y = grappleInstance.apy[segments] + end + grappleInstance.ropeTensionForce = nil -- No direct tension feedback to player in this case from this global constraint + end + else + grappleInstance.ropeTensionForce = nil -- No global overstretch + end + + + -- Then, iterate through individual segments. + for i = 0, segments - 1 do + local p1_idx, p2_idx = i, i + 1 + local x1, y1 = grappleInstance.apx[p1_idx], grappleInstance.apy[p1_idx] + local x2, y2 = grappleInstance.apx[p2_idx], grappleInstance.apy[p2_idx] + + local dx_seg = x2 - x1 + local dy_seg = y2 - y1 + local dist_seg = math.sqrt(dx_seg*dx_seg + dy_seg*dy_seg) + + if dist_seg > targetSegmentLength and dist_seg > 0.001 then -- Only correct if overstretched + local diff_seg = targetSegmentLength - dist_seg + local percent_seg = (diff_seg / dist_seg) * CONSTRAINT_STRENGTH * 0.5 -- 0.5 because applied to two points + + local offsetX = dx_seg * percent_seg + local offsetY = dy_seg * percent_seg + + local p1_is_player_anchor = (p1_idx == 0) + local p2_is_hook_anchor = (p2_idx == segments) + + if not p1_is_player_anchor then + grappleInstance.apx[p1_idx] = x1 - offsetX + grappleInstance.apy[p1_idx] = y1 - offsetY + end + if not p2_is_hook_anchor then + grappleInstance.apx[p2_idx] = x2 + offsetX + grappleInstance.apy[p2_idx] = y2 + offsetY + end + + -- If one end is an anchor, the other point takes full correction. + if p1_is_player_anchor and not p2_is_hook_anchor then + grappleInstance.apx[p2_idx] = grappleInstance.apx[p2_idx] + offsetX -- Additional correction for p2 + grappleInstance.apy[p2_idx] = grappleInstance.apy[p2_idx] + offsetY + elseif p2_is_hook_anchor and not p1_is_player_anchor then + grappleInstance.apx[p1_idx] = grappleInstance.apx[p1_idx] - offsetX -- Additional correction for p1 + grappleInstance.apy[p1_idx] = grappleInstance.apy[p1_idx] - offsetY + end + end + end + end + + -- Calculate final actual rope length and check for breaking condition. + local finalRopeVisualLength = 0 + for i = 0, segments - 1 do + local dx = grappleInstance.apx[i+1] - grappleInstance.apx[i] + local dy = grappleInstance.apy[i+1] - grappleInstance.apy[i] + finalRopeVisualLength = finalRopeVisualLength + math.sqrt(dx*dx + dy*dy) + end + grappleInstance.actualRopeLength = finalRopeVisualLength -- For debug/renderer + + -- Update tension based on final visual length vs physics target length + if maxAllowedRopeLength > 0 then + grappleInstance.currentTension = math.max(0, (finalRopeVisualLength - maxAllowedRopeLength) / maxAllowedRopeLength) + else + grappleInstance.currentTension = 0 + end + + -- Rope breaking condition: Extremely high stretch (e.g., 5x target length). + if maxAllowedRopeLength > 0 and finalRopeVisualLength > maxAllowedRopeLength * 5.0 then + grappleInstance.shouldBreak = true -- Signal to Grapple.lua + return true + end + + return false -- Rope did not break. +end + +--[[ + Smooths the rope visually using weighted averaging. + Applied sparingly to avoid significantly altering physics. + @param grappleInstance The grapple instance. +]] +function RopePhysics.smoothRope(grappleInstance) + local segments = grappleInstance.currentSegments + if segments < 3 or not grappleInstance.apx then return end -- Need at least 3 points (2 segments) to smooth. + + local smoothing_strength = 0.05 -- Very light smoothing. + + local smoothedX, smoothedY = {}, {} + for i = 0, segments do -- Copy current points. + smoothedX[i] = grappleInstance.apx[i] + smoothedY[i] = grappleInstance.apy[i] + end + + -- Apply smoothing to intermediate points only. + for i = 1, segments - 1 do + local avgX = (grappleInstance.apx[i-1] + grappleInstance.apx[i] + grappleInstance.apx[i+1]) / 3 + local avgY = (grappleInstance.apy[i-1] + grappleInstance.apy[i] + grappleInstance.apy[i+1]) / 3 + + smoothedX[i] = grappleInstance.apx[i] * (1 - smoothing_strength) + avgX * smoothing_strength + smoothedY[i] = grappleInstance.apy[i] * (1 - smoothing_strength) + avgY * smoothing_strength + end + + -- Apply smoothed positions back (excluding anchors, which are controlled). + for i = 1, segments - 1 do + grappleInstance.apx[i] = smoothedX[i] + grappleInstance.apy[i] = smoothedY[i] + end +end + +-- Placeholder for actor protection logic if direct forces were to be applied. +-- In a pure constraint system, this is less critical as positions are directly managed. +function RopePhysics.calculateActorProtection(grappleInstance, force_magnitude, force_direction) + -- This function would limit forces if the system used AddForce extensively. + -- For now, it's a conceptual placeholder. + local safe_force_magnitude = math.min(force_magnitude, 5.0) -- Example hard cap. + return safe_force_magnitude, force_direction * safe_force_magnitude +end + + +-- The following functions (handleRopePull, handleRopeExtend, checkRopeBreak) seem +-- to be remnants of a previous force-based system or conceptual helpers. +-- In the current Verlet + constraint model, their roles are largely superseded +-- by the input controller (for desired length changes) and applyRopeConstraints. +-- They are kept here for context or if parts of their logic are to be repurposed. + +function RopePhysics.handleRopePull(grappleInstance, controller, terrCheck) + -- Logic for player pulling on the rope would typically adjust 'currentLineLength' + -- which is then enforced by applyRopeConstraints. + -- Direct force application here would conflict with the constraint system. +end + +function RopePhysics.handleRopeExtend(grappleInstance) + -- Similar to handleRopePull, extending the rope involves changing 'currentLineLength'. +end + +function RopePhysics.checkRopeBreak(grappleInstance) + -- The primary rope breaking logic is now within applyRopeConstraints, + -- based on excessive stretch beyond a high threshold. + -- This function could be used for alternative breaking conditions if needed. + -- Example: if grappleInstance.lineStrength is exceeded by some calculated tension. + -- However, current breaking is purely stretch-based. +end + +return RopePhysics diff --git a/Data/Base.rte/Devices/Tools/GrappleGun/Scripts/RopeRenderer.lua b/Data/Base.rte/Devices/Tools/GrappleGun/Scripts/RopeRenderer.lua new file mode 100644 index 0000000000..dced3838f5 --- /dev/null +++ b/Data/Base.rte/Devices/Tools/GrappleGun/Scripts/RopeRenderer.lua @@ -0,0 +1,135 @@ +---@diagnostic disable: undefined-global +-- Grapple Gun Rope Renderer Module +-- Handles the visual rendering of the rope and optional debug information. + +-- Localize Cortex Command globals +local PrimitiveMan = PrimitiveMan +local SceneMan = SceneMan +local FrameMan = FrameMan +local Vector = Vector + +local RopeRenderer = {} + +-- Configuration for rendering +local ROPE_COLOR = 97 -- Dark brown color, consistent with original. +local DEBUG_TEXT_COLOR = 1000 -- Standard white for debug text. +local DEBUG_LINE_HEIGHT = 12 +local MAX_DEBUG_SEGMENTS_TO_SHOW = 10 -- Limit displayed segment lengths to avoid clutter. + +--[[ + Draws a single segment of the rope. + @param grappleInstance The grapple instance. + @param segmentStartIdx Index of the starting point of the segment. + @param segmentEndIdx Index of the ending point of the segment. + @param player The player index for the screen context. +]] +function RopeRenderer.drawSegment(grappleInstance, segmentStartIdx, segmentEndIdx, player) + -- Validate that the segment indices and corresponding points exist. + if not grappleInstance.apx or + not grappleInstance.apx[segmentStartIdx] or not grappleInstance.apy[segmentStartIdx] or + not grappleInstance.apx[segmentEndIdx] or not grappleInstance.apy[segmentEndIdx] then + -- print("RopeRenderer: Invalid segment indices or points for drawing.") + return + end + + local point1 = Vector(grappleInstance.apx[segmentStartIdx], grappleInstance.apy[segmentStartIdx]) + local point2 = Vector(grappleInstance.apx[segmentEndIdx], grappleInstance.apy[segmentEndIdx]) + + -- Safety check for zero vectors, which might indicate uninitialized points. + if (point1.X == 0 and point1.Y == 0) or (point2.X == 0 and point2.Y == 0) then + -- print("RopeRenderer: Segment point is zero vector, skipping draw.") + return + end + + -- Calculate visual segment vector and length for sanity checking. + local visualSegmentVec = SceneMan:ShortestDistance(point1, point2, grappleInstance.mapWrapsX) + local visualSegmentLength = visualSegmentVec.Magnitude + + -- Safety check for excessively long visual segments, which could be an error or cause rendering issues. + if visualSegmentLength > (grappleInstance.maxLineLength or 600) * 1.5 then -- Allow some slack over maxLineLength + -- print("RopeRenderer: Visual segment length (" .. visualSegmentLength .. ") is excessively long, skipping draw.") + return + end + + -- Fix the DrawLinePrimitive call - remove player parameter if it's nil + PrimitiveMan:DrawLinePrimitive(point1, point2, ROPE_COLOR) +end + +--[[ + Draws the complete rope, iterating through its segments. + Also triggers debug information drawing if conditions are met. + @param grappleInstance The grapple instance. + @param player The player index for the screen context. +]]-- +function RopeRenderer.drawRope(grappleInstance, player) + if not grappleInstance or grappleInstance.currentSegments == nil or grappleInstance.currentSegments < 1 then + return -- Nothing to draw if no segments. + end + + -- Draw each segment of the rope. + for i = 0, grappleInstance.currentSegments - 1 do + RopeRenderer.drawSegment(grappleInstance, i, i + 1, player) + end + + -- Optionally draw debug information. + -- Condition: Parent exists, is player controlled, and a global debug flag could be added here. + if grappleInstance.parent and grappleInstance.parent:IsPlayerControlled() then -- Add 'and GlobalDebugFlags.Grapple' + RopeRenderer.drawDebugInfo(grappleInstance, player) + end +end + +--[[ + Draws debug information on screen regarding the rope's state. + @param grappleInstance The grapple instance. + @param player The player index for the screen context. +]] +function RopeRenderer.drawDebugInfo(grappleInstance, player) + -- Ensure parent is valid before trying to position debug text relative to it. + if not grappleInstance.parent or not grappleInstance.parent.Pos then + return + end + + local screenPos = grappleInstance.parent.Pos + Vector(-120, -180) -- Adjusted for better visibility + local currentLine = 0 + + local function drawDebugText(text) + local textPos = screenPos + Vector(0, currentLine * DEBUG_LINE_HEIGHT) + FrameMan:SetScreenText(text, textPos.X, textPos.Y, DEBUG_TEXT_COLOR, false) + currentLine = currentLine + 1 + end + + drawDebugText("=== GRAPPLE DEBUG ===") + drawDebugText("Mode: " .. (grappleInstance.actionMode or "N/A")) + drawDebugText(string.format("Target Length: %.1f", grappleInstance.currentLineLength or 0)) + drawDebugText(string.format("Visual Length: %.1f", grappleInstance.lineLength or 0)) -- Actual distance player-hook + drawDebugText(string.format("Physics Length (Verlet): %.1f", grappleInstance.actualRopeLength or 0)) -- Sum of segment lengths + drawDebugText("Max Length: " .. (grappleInstance.maxLineLength or "N/A")) + drawDebugText("Segments: " .. (grappleInstance.currentSegments or 0)) + + if grappleInstance.currentTension then + drawDebugText(string.format("Tension (Stretch): %.2f%%", grappleInstance.currentTension * 100)) + end + drawDebugText("Limit Reached: " .. tostring(grappleInstance.limitReached or false)) + + -- Display individual segment lengths (limited count). + if grappleInstance.apx and grappleInstance.currentSegments and grappleInstance.currentSegments > 0 then + drawDebugText("--- SEGMENT LENGTHS ---") + local segmentsToShow = math.min(MAX_DEBUG_SEGMENTS_TO_SHOW, grappleInstance.currentSegments) + for i = 0, segmentsToShow - 1 do + if grappleInstance.apx[i+1] and grappleInstance.apx[i] then + local p1 = Vector(grappleInstance.apx[i], grappleInstance.apy[i]) + local p2 = Vector(grappleInstance.apx[i+1], grappleInstance.apy[i+1]) + local len = SceneMan:ShortestDistance(p1, p2, grappleInstance.mapWrapsX).Magnitude + drawDebugText(string.format("Seg %d: %.1f", i, len)) + else + drawDebugText(string.format("Seg %d: Invalid", i)) + end + end + + if grappleInstance.currentSegments > MAX_DEBUG_SEGMENTS_TO_SHOW then + drawDebugText("... (" .. (grappleInstance.currentSegments - MAX_DEBUG_SEGMENTS_TO_SHOW) .. " more)") + end + end +end + +return RopeRenderer diff --git a/Data/Base.rte/Devices/Tools/GrappleGun/Scripts/RopeStateManager.lua b/Data/Base.rte/Devices/Tools/GrappleGun/Scripts/RopeStateManager.lua new file mode 100644 index 0000000000..9d7738bfe4 --- /dev/null +++ b/Data/Base.rte/Devices/Tools/GrappleGun/Scripts/RopeStateManager.lua @@ -0,0 +1,622 @@ +---@diagnostic disable: undefined-global +-- Grapple Gun State Manager Module +-- Handles grapple state transitions, collision checks for attachment, +-- and effects related to the grapple's state. + +-- Load Logger module +local Logger = require("Scripts.Logger") + +-- Localize Cortex Command globals +local CreateMOPixel = CreateMOPixel +local SceneMan = SceneMan +local MovableMan = MovableMan +local Vector = Vector +local rte = rte + +local RopeStateManager = {} + +--[[ + Initializes the core state variables for the grapple instance. + Called from Grapple.lua's Create function. + @param grappleInstance The grapple instance. +]] +function RopeStateManager.initState(grappleInstance) + Logger.info("RopeStateManager.initState() - Initializing grapple state") + + grappleInstance.actionMode = 0 -- 0: Start/Inactive, 1: Flying, 2: Grabbed Terrain, 3: Grabbed MO + grappleInstance.limitReached = false -- True if rope is at max extension. + grappleInstance.canRelease = false -- True if the grapple is in a state where it can be released by player action. + grappleInstance.currentLineLength = 0 -- The current physics target length of the rope. + -- grappleInstance.longestLineLength = 0 -- Seems unused, consider removing. + grappleInstance.setLineLength = 0 -- The length explicitly set by input or logic. + + grappleInstance.target = nil -- Stores the MO if actionMode is 3. + grappleInstance.stickPosition = nil -- Offset from target MO's origin. + grappleInstance.stickRotation = nil -- Initial rotation of target MO. + grappleInstance.stickDirection = nil -- Initial rotation of the grapple claw itself. + + grappleInstance.shouldBreak = false -- Flag to indicate rope should break. + grappleInstance.ropePhysicsInitialized = false -- Flag for one-time physics setups if needed. + + Logger.debug("RopeStateManager.initState() - State initialized: actionMode=%d, currentLineLength=%.1f", + grappleInstance.actionMode, grappleInstance.currentLineLength) +end + +--[[ + Checks for collisions when the grapple is flying, to transition to an attached state. + @param grappleInstance The grapple instance. + @return True if the state changed (grapple attached), false otherwise. +]] +function RopeStateManager.checkAttachmentCollisions(grappleInstance) + if grappleInstance.actionMode ~= 1 then + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Not in flying state (actionMode=%d), skipping", grappleInstance.actionMode) + return false + end -- Only process in flying state. + + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Starting collision detection") + + local stateChanged = false + + -- Much stricter collision detection with minimal ranges + local baseRayLength = math.max(1, (grappleInstance.Diameter or 4) * 0.2) -- Reduced from 0.5 to 0.2 + local velocityComponent = math.min(1, (grappleInstance.Vel and grappleInstance.Vel.Magnitude or 0) * 0.1) -- Reduced from 0.2 to 0.1 + local rayLength = baseRayLength + velocityComponent + rayLength = math.max(1, rayLength) -- Reduced minimum from 2 to 1 + + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Ray parameters: baseLength=%.2f, velocityComponent=%.2f, finalLength=%.2f", + baseRayLength, velocityComponent, rayLength) + + local rayDirection = Vector(1,0) -- Default direction + -- Require higher velocity threshold for directional casting + if grappleInstance.Vel and grappleInstance.Vel.Magnitude and grappleInstance.Vel.Magnitude > 0.1 then -- Increased from 0.005 to 0.1 + local mag = grappleInstance.Vel.Magnitude + if mag ~= 0 then + rayDirection = Vector(grappleInstance.Vel.X / mag, grappleInstance.Vel.Y / mag) + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Using velocity-based ray direction: (%.2f, %.2f), magnitude=%.2f", + rayDirection.X, rayDirection.Y, mag) + end + else + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Using default ray direction (low velocity)") + end + + -- Primary ray (much shorter and more precise) + local collisionRay = rayDirection * rayLength + local hitPoint = Vector() + + -- Secondary ray (extremely short) + local secondaryRayLength = math.max(0.5, baseRayLength * 0.1) -- Reduced from 0.3 to 0.1 + local secondaryHitPoint = Vector() + + -- Close-range radius (extremely minimal) + local closeRangeRadius = math.max(0.5, (grappleInstance.Diameter or 4) * 0.1) -- Reduced from 0.3 to 0.1 + local terrainHit = false + + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Secondary ray length: %.2f, close range radius: %.2f", + secondaryRayLength, closeRangeRadius) + + -- 1. Check for Terrain Collision (primary ray) - require much higher strength + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Performing primary terrain ray cast") + local terrainHit = SceneMan:CastStrengthRay(grappleInstance.Pos, collisionRay, 15, hitPoint, 0, rte.airID, grappleInstance.mapWrapsX) -- Increased from 5 to 15 + + if terrainHit then + Logger.info("RopeStateManager.checkAttachmentCollisions() - Primary terrain hit detected at (%.1f, %.1f)", hitPoint.X, hitPoint.Y) + else + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Primary terrain ray cast missed") + end + + -- 2. Secondary terrain check - even higher strength requirement + if not terrainHit and grappleInstance.Vel and grappleInstance.Vel.Magnitude < 0.5 then -- Reduced from 1 to 0.5 + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Performing secondary terrain ray cast (low velocity)") + terrainHit = SceneMan:CastStrengthRay(grappleInstance.Pos, rayDirection * secondaryRayLength, 20, secondaryHitPoint, 0, rte.airID, grappleInstance.mapWrapsX) -- Increased from 8 to 20 + if terrainHit then + hitPoint = secondaryHitPoint + Logger.info("RopeStateManager.checkAttachmentCollisions() - Secondary terrain hit detected at (%.1f, %.1f)", hitPoint.X, hitPoint.Y) + else + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Secondary terrain ray cast missed") + end + end + + -- 3. Close-range terrain collision - extremely high strength requirement + if not terrainHit and (not grappleInstance.Vel or grappleInstance.Vel.Magnitude < 0.1) then -- Reduced from 0.5 to 0.1 + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Performing close-range terrain check (very low velocity)") + -- Only check 1 direction instead of 2 - just forward + local checkDir = rayDirection * closeRangeRadius + local closeRangeHit = Vector() + -- Require very high terrain strength for close-range detection + if SceneMan:CastStrengthRay(grappleInstance.Pos, checkDir, 25, closeRangeHit, 0, rte.airID, grappleInstance.mapWrapsX) then -- Increased from 10 to 25 + hitPoint = closeRangeHit + terrainHit = true + Logger.info("RopeStateManager.checkAttachmentCollisions() - Close-range terrain hit detected at (%.1f, %.1f)", hitPoint.X, hitPoint.Y) + else + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Close-range terrain check missed") + end + end + + -- Additional validation: Ensure hit point is actually close to grapple position + if terrainHit then + local distanceToHit = SceneMan:ShortestDistance(grappleInstance.Pos, hitPoint, grappleInstance.mapWrapsX).Magnitude + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Validating terrain hit distance: %.2f (max: %.2f)", + distanceToHit, rayLength * 1.1) + if distanceToHit > rayLength * 1.1 then -- Allow only 10% tolerance + terrainHit = false -- Reject if hit point is too far + Logger.warn("RopeStateManager.checkAttachmentCollisions() - Terrain hit rejected: too far from grapple position") + else + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Terrain hit validated") + end + end + + if terrainHit then + Logger.info("RopeStateManager.checkAttachmentCollisions() - TERRAIN ATTACHMENT: Transitioning to grabbed terrain mode") + grappleInstance.actionMode = 2 -- Transition to "Grabbed Terrain" + grappleInstance.Pos = hitPoint -- Snap grapple to the hit point. + grappleInstance.apx[grappleInstance.currentSegments] = hitPoint.X -- Update anchor point + grappleInstance.apy[grappleInstance.currentSegments] = hitPoint.Y -- Update anchor point + grappleInstance.lastX[grappleInstance.currentSegments] = hitPoint.X -- Ensure lastPos is also updated for stability + grappleInstance.lastY[grappleInstance.currentSegments] = hitPoint.Y + stateChanged = true + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Terrain attachment complete, anchor updated") + if grappleInstance.stickSound then + grappleInstance.stickSound:Play(grappleInstance.Pos) + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Stick sound played for terrain attachment") + end + else + Logger.debug("RopeStateManager.checkAttachmentCollisions() - No terrain collision, checking for MO collision") + -- MO collision detection - also made stricter + local hitMORayInfo = SceneMan:CastMORay(grappleInstance.Pos, collisionRay, + (grappleInstance.parent and grappleInstance.parent.ID or 0), + -2, rte.airID, false, 0) + + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Primary MO ray cast completed") + + -- Only try secondary MO ray if moving very slowly and primary failed + if not (hitMORayInfo and type(hitMORayInfo) == "table" and hitMORayInfo.MOSPtr and hitMORayInfo.MOSPtr.ID ~= rte.NoMOID) then + if grappleInstance.Vel and grappleInstance.Vel.Magnitude < 1 then -- Stricter velocity requirement + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Performing secondary MO ray cast (low velocity)") + hitMORayInfo = SceneMan:CastMORay(grappleInstance.Pos, rayDirection * secondaryRayLength, + (grappleInstance.parent and grappleInstance.parent.ID or 0), + -2, rte.airID, false, 0) + else + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Skipping secondary MO ray (velocity too high)") + end + else + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Primary MO ray hit detected") + end + + if hitMORayInfo and type(hitMORayInfo) == "table" and hitMORayInfo.MOSPtr and hitMORayInfo.MOSPtr.ID ~= rte.NoMOID then + local hitMO = hitMORayInfo.MOSPtr + Logger.info("RopeStateManager.checkAttachmentCollisions() - MO hit detected: %s (ID: %d, Diameter: %.1f)", + hitMO.PresetName or "Unknown", hitMO.ID, hitMO.Diameter or 0) + + -- Much stricter size filtering + local minGrappableSize = 8 -- Increased from 3 to 8 + if hitMO.Diameter and hitMO.Diameter < minGrappableSize then + Logger.warn("RopeStateManager.checkAttachmentCollisions() - MO rejected: too small (%.1f < %.1f)", + hitMO.Diameter, minGrappableSize) + hitMO = nil + hitMORayInfo = nil + end + + -- Additional validation: Ensure MO hit point is close enough + if hitMO and hitMORayInfo.HitPos then + local distanceToMOHit = SceneMan:ShortestDistance(grappleInstance.Pos, hitMORayInfo.HitPos, grappleInstance.mapWrapsX).Magnitude + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Validating MO hit distance: %.2f (max: %.2f)", + distanceToMOHit, rayLength * 1.1) + if distanceToMOHit > rayLength * 1.1 then -- Same 10% tolerance + Logger.warn("RopeStateManager.checkAttachmentCollisions() - MO hit rejected: too far from grapple position") + hitMO = nil + hitMORayInfo = nil + else + Logger.debug("RopeStateManager.checkAttachmentCollisions() - MO hit validated") + end + end + + if hitMO and hitMORayInfo then + grappleInstance.target = hitMO + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Target set, analyzing MO type") + + local isPinnedActor = MovableMan:IsActor(hitMO) and ToActor(hitMO):IsPinned() + Logger.debug("RopeStateManager.checkAttachmentCollisions() - MO analysis: IsActor=%s, IsPinned=%s, Mass=%.1f", + tostring(MovableMan:IsActor(hitMO)), tostring(isPinnedActor), hitMO.Mass or 0) + + if isPinnedActor or (not MovableMan:IsActor(hitMO) and hitMO.Material and hitMO.Material.Mass == 0) then + Logger.info("RopeStateManager.checkAttachmentCollisions() - MO ATTACHMENT (TERRAIN MODE): Pinned actor or zero-mass object") + grappleInstance.actionMode = 2 + grappleInstance.Pos = hitMORayInfo.HitPos + grappleInstance.apx[grappleInstance.currentSegments] = hitMORayInfo.HitPos.X + grappleInstance.apy[grappleInstance.currentSegments] = hitMORayInfo.HitPos.Y + grappleInstance.lastX[grappleInstance.currentSegments] = hitMORayInfo.HitPos.X + grappleInstance.lastY[grappleInstance.currentSegments] = hitMORayInfo.HitPos.Y + grappleInstance.stickDirection = (grappleInstance.Pos - (grappleInstance.parent and grappleInstance.parent.Pos or grappleInstance.Pos)):Normalized() + stateChanged = true + elseif MovableMan:IsActor(hitMO) and ToActor(hitMO):IsPhysical() then + -- Additional validation for actor grappling - require minimum mass + local minGrappableActorMass = 15 -- Minimum mass for grappable actors + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Checking actor mass: %.1f (min: %.1f)", + hitMO.Mass or 0, minGrappableActorMass) + if hitMO.Mass and hitMO.Mass >= minGrappableActorMass then + Logger.info("RopeStateManager.checkAttachmentCollisions() - MO ATTACHMENT (ACTOR MODE): Physical actor with sufficient mass") + grappleInstance.actionMode = 3 + grappleInstance.Pos = hitMORayInfo.HitPos + grappleInstance.apx[grappleInstance.currentSegments] = hitMORayInfo.HitPos.X + grappleInstance.apy[grappleInstance.currentSegments] = hitMORayInfo.HitPos.Y + grappleInstance.lastX[grappleInstance.currentSegments] = hitMORayInfo.HitPos.X + grappleInstance.lastY[grappleInstance.currentSegments] = hitMORayInfo.HitPos.Y + + grappleInstance.stickOffset = grappleInstance.Pos - hitMO.Pos + grappleInstance.stickAngle = hitMO.RotAngle + grappleInstance.stickDirection = (grappleInstance.Pos - (grappleInstance.parent and grappleInstance.parent.Pos or grappleInstance.Pos)):Normalized() + stateChanged = true + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Actor attachment data recorded: offset=(%.1f, %.1f), angle=%.2f", + grappleInstance.stickOffset.X, grappleInstance.stickOffset.Y, grappleInstance.stickAngle or 0) + else + Logger.warn("RopeStateManager.checkAttachmentCollisions() - Actor rejected: insufficient mass") + end + else + Logger.debug("RopeStateManager.checkAttachmentCollisions() - MO rejected: not a physical actor or pinned actor") + end + end + else + Logger.debug("RopeStateManager.checkAttachmentCollisions() - No valid MO collision detected") + end + end + + -- Actions to take if the state changed to an attached state. + if stateChanged then + Logger.info("RopeStateManager.checkAttachmentCollisions() - STATE CHANGE CONFIRMED: actionMode = %d", grappleInstance.actionMode) + + -- Play sound before potential errors if parent.Pos is nil, though parent should be valid. + if grappleInstance.stickSound then + grappleInstance.stickSound:Play(grappleInstance.Pos) + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Stick sound played for attachment") + end + + -- Update line length to current distance upon sticking. + if grappleInstance.parent and grappleInstance.parent.Pos then + local distVec = grappleInstance.Pos - grappleInstance.parent.Pos + grappleInstance.currentLineLength = math.floor(distVec.Magnitude) + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Line length calculated from distance: %.1f", grappleInstance.currentLineLength) + else + -- Fallback if parent or parent.Pos is nil. This indicates a deeper issue elsewhere. + -- Setting to a large portion of maxLineLength as a temporary measure. + grappleInstance.currentLineLength = grappleInstance.maxLineLength * 0.9 + Logger.error("RopeStateManager.checkAttachmentCollisions() - Parent position unavailable, using fallback line length: %.1f", grappleInstance.currentLineLength) + end + -- Ensure currentLineLength is within valid bounds immediately after calculating. + local oldLength = grappleInstance.currentLineLength + grappleInstance.currentLineLength = math.max(10, math.min(grappleInstance.currentLineLength, grappleInstance.maxLineLength)) + if oldLength ~= grappleInstance.currentLineLength then + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Line length clamped from %.1f to %.1f", oldLength, grappleInstance.currentLineLength) + end + + grappleInstance.setLineLength = grappleInstance.currentLineLength + grappleInstance.Vel = Vector(0,0) -- Stop the hook's independent movement. + grappleInstance.PinStrength = 1000 -- Make it "stick" firmly. + grappleInstance.Frame = 1 -- Change sprite frame to "stuck" appearance if applicable. + + grappleInstance.canRelease = true -- Now that it's stuck, player can choose to release it. + grappleInstance.limitReached = (grappleInstance.currentLineLength >= grappleInstance.maxLineLength - 0.1) + grappleInstance.ropePhysicsInitialized = false -- May need re-init for rope physics with new anchor. + + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Post-attachment state: canRelease=%s, limitReached=%s, PinStrength=%.1f", + tostring(grappleInstance.canRelease), tostring(grappleInstance.limitReached), grappleInstance.PinStrength) + else + Logger.debug("RopeStateManager.checkAttachmentCollisions() - No state change occurred") + end + + Logger.debug("RopeStateManager.checkAttachmentCollisions() - Collision detection complete, stateChanged=%s", tostring(stateChanged)) + return stateChanged +end + +--[[ + Handles logic when the rope reaches its maximum allowed length. + This is mostly for effects like sound, as the actual length constraint is handled by RopePhysics. + @param grappleInstance The grapple instance. + @return True if the limit was newly reached this frame, false otherwise. +]] +function RopeStateManager.checkLengthLimit(grappleInstance) + Logger.debug("RopeStateManager.checkLengthLimit() - Checking length limit for actionMode %d", grappleInstance.actionMode) + + -- This function's primary role is now for triggering effects when the length limit is hit. + -- The actual physics of stopping at max length is handled in Grapple.lua (for flight) + -- and RopePhysics.applyRopeConstraints (for attached states). + + local effectivelyAtMax = false + if grappleInstance.actionMode == 1 then -- Flying + effectivelyAtMax = (grappleInstance.lineLength >= grappleInstance.maxShootDistance - 0.1) + Logger.debug("RopeStateManager.checkLengthLimit() - Flying mode: lineLength=%.1f, maxShootDistance=%.1f, atMax=%s", + grappleInstance.lineLength or 0, grappleInstance.maxShootDistance, tostring(effectivelyAtMax)) + else -- Attached + effectivelyAtMax = (grappleInstance.currentLineLength >= grappleInstance.maxLineLength - 0.1) + Logger.debug("RopeStateManager.checkLengthLimit() - Attached mode: currentLineLength=%.1f, maxLineLength=%.1f, atMax=%s", + grappleInstance.currentLineLength, grappleInstance.maxLineLength, tostring(effectivelyAtMax)) + end + + if effectivelyAtMax then + if not grappleInstance.limitReached then -- If it wasn't at limit last frame + Logger.info("RopeStateManager.checkLengthLimit() - Length limit newly reached") + grappleInstance.limitReached = true + if grappleInstance.clickSound and grappleInstance.parent and grappleInstance.parent.Pos then + grappleInstance.clickSound:Play(grappleInstance.parent.Pos) + Logger.debug("RopeStateManager.checkLengthLimit() - Click sound played for length limit") + end + return true -- Newly reached limit + else + Logger.debug("RopeStateManager.checkLengthLimit() - Length limit already reached (continuing)") + end + else + if grappleInstance.limitReached then + Logger.debug("RopeStateManager.checkLengthLimit() - Length limit no longer reached") + end + grappleInstance.limitReached = false + end + return false -- Not newly at limit, or not at limit. +end + +--[[ + Applies effects for "stretch mode" (currently disabled by default in Grapple.lua). + If enabled, this would typically retract the hook. + @param grappleInstance The grapple instance. +]] +function RopeStateManager.applyStretchMode(grappleInstance) + if not grappleInstance.stretchMode then + Logger.debug("RopeStateManager.applyStretchMode() - Stretch mode disabled, skipping") + return + end + + if not grappleInstance.parent or not grappleInstance.parent.Pos then + Logger.warn("RopeStateManager.applyStretchMode() - No valid parent position, skipping stretch mode") + return + end + + Logger.debug("RopeStateManager.applyStretchMode() - Applying stretch mode effects") + + if grappleInstance.actionMode == 1 and grappleInstance.lineVec then -- Flying + Logger.debug("RopeStateManager.applyStretchMode() - Flying mode stretch: lineLength=%.1f", grappleInstance.lineLength or 0) + -- Example: Gradually retract the hook. + local pullForceFactor = (grappleInstance.stretchPullRatio or 0.05) * 0.5 + local pullMagnitude = math.sqrt(grappleInstance.lineLength or 0) * pullForceFactor + + local oldVel = Vector(grappleInstance.Vel.X, grappleInstance.Vel.Y) + grappleInstance.Vel = grappleInstance.Vel - grappleInstance.lineVec:SetMagnitude(pullMagnitude) + + Logger.debug("RopeStateManager.applyStretchMode() - Velocity adjusted: (%.2f, %.2f) -> (%.2f, %.2f), pullMagnitude=%.2f", + oldVel.X, oldVel.Y, grappleInstance.Vel.X, grappleInstance.Vel.Y, pullMagnitude) + end +end + + +--[[ + Helper function to get the effective target MO, considering root parents. + @param grappleInstance The grapple instance. + @return The effective target MO, or nil. +]] +function RopeStateManager.getEffectiveTarget(grappleInstance) + Logger.debug("RopeStateManager.getEffectiveTarget() - Getting effective target") + + if not grappleInstance or not grappleInstance.target or grappleInstance.target.ID == rte.NoMOID then + Logger.debug("RopeStateManager.getEffectiveTarget() - No valid target available") + return nil + end + + local currentTarget = grappleInstance.target + Logger.debug("RopeStateManager.getEffectiveTarget() - Current target: %s (ID: %d, RootID: %d)", + currentTarget.PresetName or "Unknown", currentTarget.ID, currentTarget.RootID or -1) + + -- If the direct hit target is part of a larger entity (e.g., a limb of an actor), + -- try to use its root parent as the effective target, IF the root is "attachable" (conceptual). + -- For now, we just get the root parent if it's different. + if currentTarget.RootID and currentTarget.ID ~= currentTarget.RootID then + Logger.debug("RopeStateManager.getEffectiveTarget() - Target has different root, checking root parent") + local rootParent = MovableMan:GetMOFromID(currentTarget.RootID) + if rootParent and rootParent.ID ~= rte.NoMOID then + Logger.info("RopeStateManager.getEffectiveTarget() - Using root parent: %s (ID: %d)", + rootParent.PresetName or "Unknown", rootParent.ID) + -- Add a check here if certain MO types shouldn't be "grabbed" by their root + -- e.g., if IsAttachable(rootParent) then effective_target = rootParent end + -- For now, always use root if available. + return rootParent + else + Logger.warn("RopeStateManager.getEffectiveTarget() - Root parent not found or invalid") + end + else + Logger.debug("RopeStateManager.getEffectiveTarget() - Target is its own root or has same ID as root") + end + + Logger.debug("RopeStateManager.getEffectiveTarget() - Returning original target") + return currentTarget -- Return the original target if no valid root parent or same as root. +end + + +-- The following physics application functions (applyTerrainPullPhysics, applyMOPullPhysics) +-- are complex and were part of a system that applied direct forces. +-- In a pure Verlet constraint system (as aimed for in RopePhysics.lua), +-- these direct force applications can conflict or become redundant if the constraints +-- are correctly managing positions and by extension, velocities. +-- They are kept for reference or if a hybrid model is intended, but their direct usage +-- should be carefully considered alongside the constraint-based physics. +-- If RopePhysics.applyRopeConstraints correctly handles player/MO movement due to rope tension, +-- these functions might only be needed for secondary effects or very specific scenarios. + +--[[ + Applies physics forces when the grapple is attached to terrain. + (Primarily for a force-based system, review if needed with Verlet constraints) + @param grappleInstance The grapple instance. + @return True if the rope should break from this interaction, false otherwise. +]] +function RopeStateManager.applyTerrainPullPhysics(grappleInstance) + Logger.debug("RopeStateManager.applyTerrainPullPhysics() - Starting terrain pull physics") + + if grappleInstance.actionMode ~= 2 then + Logger.debug("RopeStateManager.applyTerrainPullPhysics() - Not in terrain grab mode (actionMode=%d), skipping", grappleInstance.actionMode) + return false + end + + if not grappleInstance.parent then + Logger.warn("RopeStateManager.applyTerrainPullPhysics() - No parent available, skipping") + return false + end + + -- If RopePhysics.applyRopeConstraints provides tension force/direction, use that. + if grappleInstance.ropeTensionForce and grappleInstance.ropeTensionDirection and grappleInstance.parent.AddForce then + Logger.debug("RopeStateManager.applyTerrainPullPhysics() - Using constraint-based tension force") + local actor = grappleInstance.parent + local raw_force_magnitude = grappleInstance.ropeTensionForce + local force_direction = grappleInstance.ropeTensionDirection -- Should be towards the hook point + + Logger.debug("RopeStateManager.applyTerrainPullPhysics() - Raw tension: magnitude=%.2f, direction=(%.2f, %.2f)", + raw_force_magnitude, force_direction.X, force_direction.Y) + + -- Apply actor protection/scaling to this force + -- This is a simplified protection; a more detailed one would consider mass, velocity, health. + local safe_force_magnitude = math.min(raw_force_magnitude, (actor.Mass or 10) * 0.5) -- Cap force based on mass + + Logger.debug("RopeStateManager.applyTerrainPullPhysics() - Force clamping: raw=%.2f, safe=%.2f, actorMass=%.1f", + raw_force_magnitude, safe_force_magnitude, actor.Mass or 10) + + local final_force_vector = force_direction * safe_force_magnitude + actor:AddForce(final_force_vector) -- AddForce at center of mass + + Logger.debug("RopeStateManager.applyTerrainPullPhysics() - Applied force: (%.2f, %.2f)", + final_force_vector.X, final_force_vector.Y) + + -- No breaking logic here, as RopePhysics handles breaking by stretch. + return false + else + Logger.debug("RopeStateManager.applyTerrainPullPhysics() - No constraint-based tension available") + end + + -- Fallback or alternative spring logic (if not using tension from constraints directly for forces) + -- This section would be active if grappleInstance.ropeTensionForce is nil. + -- ... (original complex spring logic could be here) ... + -- However, this is likely to conflict with a pure constraint system. + + Logger.debug("RopeStateManager.applyTerrainPullPhysics() - Completed (no breaking)") + return false -- Default: no break from this function. +end + +--[[ + Applies physics forces when the grapple is attached to a Movable Object. + (Primarily for a force-based system, review if needed with Verlet constraints) + @param grappleInstance The grapple instance. + @return True if the rope should break, false otherwise. +]] +function RopeStateManager.applyMOPullPhysics(grappleInstance) + Logger.debug("RopeStateManager.applyMOPullPhysics() - Starting MO pull physics") + + if grappleInstance.actionMode ~= 3 then + Logger.debug("RopeStateManager.applyMOPullPhysics() - Not in MO grab mode (actionMode=%d), skipping", grappleInstance.actionMode) + return false + end + + if not grappleInstance.target or grappleInstance.target.ID == rte.NoMOID then + Logger.warn("RopeStateManager.applyMOPullPhysics() - No valid target, should unhook") + return true -- Or true if target is lost, to signal unhook. + end + + if not grappleInstance.parent then + Logger.warn("RopeStateManager.applyMOPullPhysics() - No parent available, should unhook") + return true + end + + Logger.debug("RopeStateManager.applyMOPullPhysics() - Target: %s (ID: %d)", + grappleInstance.target.PresetName or "Unknown", grappleInstance.target.ID) + + local effective_target = RopeStateManager.getEffectiveTarget(grappleInstance) + if not effective_target or effective_target.ID == rte.NoMOID then + Logger.warn("RopeStateManager.applyMOPullPhysics() - No effective target, signaling unhook") + return true -- Signal unhook. + end + + Logger.debug("RopeStateManager.applyMOPullPhysics() - Effective target: %s (ID: %d)", + effective_target.PresetName or "Unknown", effective_target.ID) + + -- Update hook's visual position to stick to the target MO. + if effective_target.Pos and grappleInstance.stickPosition then + Logger.debug("RopeStateManager.applyMOPullPhysics() - Updating hook position to track target") + local rotatedStickPos = Vector(grappleInstance.stickPosition.X, grappleInstance.stickPosition.Y) + if effective_target.RotAngle and grappleInstance.stickRotation then + rotatedStickPos:RadRotate(effective_target.RotAngle - grappleInstance.stickRotation) + Logger.debug("RopeStateManager.applyMOPullPhysics() - Applied rotation: target=%.2f, stick=%.2f", + effective_target.RotAngle, grappleInstance.stickRotation) + end + local oldPos = Vector(grappleInstance.Pos.X, grappleInstance.Pos.Y) + grappleInstance.Pos = effective_target.Pos + rotatedStickPos + Logger.debug("RopeStateManager.applyMOPullPhysics() - Position updated: (%.1f, %.1f) -> (%.1f, %.1f)", + oldPos.X, oldPos.Y, grappleInstance.Pos.X, grappleInstance.Pos.Y) + + if effective_target.RotAngle and grappleInstance.stickRotation and grappleInstance.stickDirection then + local oldRotAngle = grappleInstance.RotAngle or 0 + grappleInstance.RotAngle = grappleInstance.stickDirection + (effective_target.RotAngle - grappleInstance.stickRotation) + Logger.debug("RopeStateManager.applyMOPullPhysics() - Rotation updated: %.2f -> %.2f", oldRotAngle, grappleInstance.RotAngle) + end + end + + -- If RopePhysics.applyRopeConstraints provides tension, apply forces to player and target. + if grappleInstance.ropeTensionForce and grappleInstance.ropeTensionDirection then + Logger.debug("RopeStateManager.applyMOPullPhysics() - Applying constraint-based forces to actor and target") + local actor = grappleInstance.parent + local raw_force_magnitude = grappleInstance.ropeTensionForce + local force_direction_on_actor = grappleInstance.ropeTensionDirection -- Towards hook + + Logger.debug("RopeStateManager.applyMOPullPhysics() - Tension data: magnitude=%.2f, direction=(%.2f, %.2f)", + raw_force_magnitude, force_direction_on_actor.X, force_direction_on_actor.Y) + + local total_mass = (actor.Mass or 10) + (effective_target.Mass or 10) + local actor_force_share = (effective_target.Mass or 10) / total_mass + local target_force_share = (actor.Mass or 10) / total_mass + + Logger.debug("RopeStateManager.applyMOPullPhysics() - Mass distribution: actor=%.1f, target=%.1f, actor_share=%.2f, target_share=%.2f", + actor.Mass or 10, effective_target.Mass or 10, actor_force_share, target_force_share) + + -- Simplified protection and force application + local actor_pull_force = math.min(raw_force_magnitude * actor_force_share, (actor.Mass or 10) * 0.5) + local target_pull_force = math.min(raw_force_magnitude * target_force_share, (effective_target.Mass or 10) * 0.8) + + Logger.debug("RopeStateManager.applyMOPullPhysics() - Final forces: actor=%.2f, target=%.2f", + actor_pull_force, target_pull_force) + + if actor.AddForce then + actor:AddForce(force_direction_on_actor * actor_pull_force) + Logger.debug("RopeStateManager.applyMOPullPhysics() - Force applied to actor: (%.2f, %.2f)", + (force_direction_on_actor * actor_pull_force).X, (force_direction_on_actor * actor_pull_force).Y) + end + if effective_target.AddForce then + effective_target:AddForce(-force_direction_on_actor * target_pull_force) + Logger.debug("RopeStateManager.applyMOPullPhysics() - Force applied to target: (%.2f, %.2f)", + (-force_direction_on_actor * target_pull_force).X, (-force_direction_on_actor * target_pull_force).Y) + end + + return false -- No breaking from this function. + else + Logger.debug("RopeStateManager.applyMOPullPhysics() - No constraint-based tension available") + end + + -- Fallback or alternative spring logic for MOs... + -- ... (original complex MO spring logic) ... + -- Again, likely to conflict with pure constraint system. + + -- Check if target MO is destroyed or invalid. + if not MovableMan:IsValid(effective_target) or effective_target.ToDelete then + Logger.warn("RopeStateManager.applyMOPullPhysics() - Target is invalid or marked for deletion, signaling unhook") + return true -- Signal to delete the hook. + end + + Logger.debug("RopeStateManager.applyMOPullPhysics() - Completed (no breaking)") + return false -- Default: no break. +end + + +--[[ + Determines if the grapple can be released by the player. + @param grappleInstance The grapple instance. + @return True if releasable, false otherwise. +]] +function RopeStateManager.canReleaseGrapple(grappleInstance) + local canRelease = grappleInstance.canRelease or false + Logger.debug("RopeStateManager.canReleaseGrapple() - Can release: %s", tostring(canRelease)) + -- The 'canRelease' flag is set to true in checkAttachmentCollisions when the hook sticks. + -- It can be set to false if, for example, the hook is mid-flight or during a special animation. + return canRelease -- Default to false if nil. +end + +return RopeStateManager diff --git a/Data/Base.rte/GUIs/SettingsGUI.ini b/Data/Base.rte/GUIs/SettingsGUI.ini index 3d941e417a..2acd6c6685 100644 --- a/Data/Base.rte/GUIs/SettingsGUI.ini +++ b/Data/Base.rte/GUIs/SettingsGUI.ini @@ -1776,7 +1776,7 @@ Parent = CollectionBoxScrollingMappingClipBox X = 0 Y = 0 Width = 440 -Height = 540 +Height = 585 Visible = True Enabled = True Name = CollectionBoxScrollingMappingBox diff --git a/Data/Base.rte/Scripts/Logger.lua b/Data/Base.rte/Scripts/Logger.lua new file mode 100644 index 0000000000..4f27e0f98b --- /dev/null +++ b/Data/Base.rte/Scripts/Logger.lua @@ -0,0 +1,64 @@ +-- Logger.lua - Conditional logging system for debugging + +local Logger = {} + +-- Global debug flag - set this to true/false to enable/disable all logging +Logger.debugEnabled = false -- Change to false to disable all print statements + +-- Different log levels +Logger.LOG_LEVELS = { + DEBUG = 1, + INFO = 2, + WARN = 3, + ERROR = 4 +} + +-- Current log level (only logs at or above this level will be printed) +Logger.currentLogLevel = Logger.LOG_LEVELS.DEBUG + +-- Main logging function +function Logger.log(level, message, ...) + if not Logger.debugEnabled then + return + end + + if level < Logger.currentLogLevel then + return + end + + local levelNames = {"DEBUG", "INFO", "WARN", "ERROR"} + local levelName = levelNames[level] or "UNKNOWN" + + -- Format the message with any additional arguments + local formattedMessage = string.format(message, ...) + + -- Print with level prefix + print("[" .. levelName .. "] " .. formattedMessage) +end + +-- Convenience functions for different log levels +function Logger.debug(message, ...) + Logger.log(Logger.LOG_LEVELS.DEBUG, message, ...) +end + +function Logger.info(message, ...) + Logger.log(Logger.LOG_LEVELS.INFO, message, ...) +end + +function Logger.warn(message, ...) + Logger.log(Logger.LOG_LEVELS.WARN, message, ...) +end + +function Logger.error(message, ...) + Logger.log(Logger.LOG_LEVELS.ERROR, message, ...) +end + +-- Simple boolean check function (like your original request) +function Logger.conditionalPrint(condition, message, ...) + if condition then + local formattedMessage = string.format(message, ...) + print(formattedMessage) + end +end + +return Logger \ No newline at end of file diff --git a/Source/Entities/HDFirearm.cpp b/Source/Entities/HDFirearm.cpp index 28e3a6a4e7..eb98970f39 100644 --- a/Source/Entities/HDFirearm.cpp +++ b/Source/Entities/HDFirearm.cpp @@ -725,6 +725,10 @@ void HDFirearm::Update() { m_RoundsFired++; pRound = m_pMagazine->PopNextRound(); + if (!pRound) { + // Handle the case where no round is available + continue; // or break, depending on desired behavior + } shake = (m_ShakeRange - ((m_ShakeRange - m_SharpShakeRange) * m_SharpAim)) * (m_Supported ? 1.0F : m_NoSupportFactor) * RandomNormalNum(); tempNozzle = m_MuzzleOff.GetYFlipped(m_HFlipped); diff --git a/Source/Menus/SettingsInputMappingGUI.cpp b/Source/Menus/SettingsInputMappingGUI.cpp index 683a3492b2..e11062f210 100644 --- a/Source/Menus/SettingsInputMappingGUI.cpp +++ b/Source/Menus/SettingsInputMappingGUI.cpp @@ -13,7 +13,7 @@ using namespace RTE; std::array SettingsInputMappingGUI::m_InputElementsUsedByMouse = {InputElements::INPUT_FIRE, InputElements::INPUT_PIEMENU_ANALOG, InputElements::INPUT_AIM, InputElements::INPUT_AIM_UP, InputElements::INPUT_AIM_DOWN, InputElements::INPUT_AIM_LEFT, InputElements::INPUT_AIM_RIGHT}; SettingsInputMappingGUI::SettingsInputMappingGUI(GUIControlManager* parentControlManager) : - m_GUIControlManager(parentControlManager) { + m_GUIControlManager(parentControlManager) { m_InputMappingSettingsBox = dynamic_cast(m_GUIControlManager->GetControl("CollectionBoxPlayerInputMapping")); m_InputMappingSettingsBox->SetVisible(false); @@ -29,9 +29,19 @@ SettingsInputMappingGUI::SettingsInputMappingGUI(GUIControlManager* parentContro for (int i = 0; i < InputElements::INPUT_COUNT; ++i) { m_InputMapLabel[i] = dynamic_cast(m_GUIControlManager->GetControl("LabelInputName" + std::to_string(i + 1))); - m_InputMapLabel[i]->SetText(c_InputElementNames[i]); + + // Add null check to prevent crash + if (m_InputMapLabel[i]) { + m_InputMapLabel[i]->SetText(c_InputElementNames[i]); + } + m_InputMapButton[i] = dynamic_cast(m_GUIControlManager->GetControl("ButtonInputKey" + std::to_string(i + 1))); + // Add null check to prevent crash + if (m_InputMapButton[i]) { + // Optionally, initialize button text or state here if needed + } } + m_InputMappingCaptureBox = dynamic_cast(m_GUIControlManager->GetControl("CollectionBoxInputCapture")); m_InputMappingCaptureBox->SetVisible(false); diff --git a/Source/System/Constants.h b/Source/System/Constants.h index ce04992d31..4e7dc714b2 100644 --- a/Source/System/Constants.h +++ b/Source/System/Constants.h @@ -138,10 +138,10 @@ namespace RTE { #define c_PlayerSlotColorHovered makecol(203, 130, 56) #define c_PlayerSlotColorDisabled makecol(104, 67, 15) static constexpr std::array c_Quad{ - 1.0f, 1.0f, 1.0f, 0.0f, - 1.0f, -1.0f, 1.0f, 1.0f, - -1.0f, 1.0f, 0.0f, 0.0f, - -1.0f, -1.0f, 0.0f, 1.0f}; + 1.0f, 1.0f, 1.0f, 0.0f, + 1.0f, -1.0f, 1.0f, 1.0f, + -1.0f, 1.0f, 0.0f, 0.0f, + -1.0f, -1.0f, 0.0f, 1.0f}; static constexpr float c_GuiDepth = -100.0f; static constexpr float c_DefaultDrawDepth = 0.0f; @@ -241,40 +241,40 @@ namespace RTE { }; static const std::array c_InputElementNames = { - "Move Up", // INPUT_L_UP - "Move Down", // INPUT_L_DOWN - "Move Left", // INPUT_L_LEFT - "Move Right", // INPUT_L_RIGHT - "Run", // INPUT_MOVE_FAST - "Run (Toggle)", // INPUT_MOVE_FAST_TOGGLE - "Aim Up", // INPUT_AIM_UP - "Aim Down", // INPUT_AIM_DOWN - "Aim Left", // INPUT_AIM_LEFT - "Aim Right", // INPUT_AIM_RIGHT - "Fire/Activate", // INPUT_FIRE - "Sharp Aim", // INPUT_AIM - "Pie Menu (Analog)", // INPUT_PIEMENU_ANALOG - "Pie Menu (Digital)", // INPUT_PIEMENU_DIGITAL - "Jump", // INPUT_JUMP - "Crouch", // INPUT_CROUCH - "Prone", // INPUT_PRONE - "Next Body", // INPUT_NEXT - "Prev. Body", // INPUT_PREV - "Next Device", // INPUT_WEAPON_CHANGE_NEXT - "Prev. Device", // INPUT_WEAPON_CHANGE_PREV - "Pick Up Device", // INPUT_WEAPON_PICKUP - "Drop Device", // INPUT_WEAPON_DROP - "Reload Weapon", // INPUT_WEAPON_RELOAD - "Primary Weapon Hotkey", // INPUT_WEAPON_PRIMARY_HOTKEY - "Auxiliary Weapon Hotkey", // INPUT_WEAPON_AUXILIARY_HOTKEY + "Move Up", // INPUT_L_UP + "Move Down", // INPUT_L_DOWN + "Move Left", // INPUT_L_LEFT + "Move Right", // INPUT_L_RIGHT + "Run", // INPUT_MOVE_FAST + "Run (Toggle)", // INPUT_MOVE_FAST_TOGGLE + "Aim Up", // INPUT_AIM_UP + "Aim Down", // INPUT_AIM_DOWN + "Aim Left", // INPUT_AIM_LEFT + "Aim Right", // INPUT_AIM_RIGHT + "Fire/Activate", // INPUT_FIRE + "Sharp Aim", // INPUT_AIM + "Pie Menu (Analog)", // INPUT_PIEMENU_ANALOG + "Pie Menu (Digital)", // INPUT_PIEMENU_DIGITAL + "Jump", // INPUT_JUMP + "Crouch", // INPUT_CROUCH + "Prone", // INPUT_PRONE + "Next Body", // INPUT_NEXT + "Prev. Body", // INPUT_PREV + "Next Device", // INPUT_WEAPON_CHANGE_NEXT + "Prev. Device", // INPUT_WEAPON_CHANGE_PREV + "Pick Up Device", // INPUT_WEAPON_PICKUP + "Drop Device", // INPUT_WEAPON_DROP + "Reload Weapon", // INPUT_WEAPON_RELOAD + "Primary Weapon Hotkey", // INPUT_WEAPON_PRIMARY_HOTKEY + "Auxiliary Weapon Hotkey", // INPUT_WEAPON_AUXILIARY_HOTKEY "Primary Actor Hotkey", // INPUT_ACTOR_PRIMARY_HOTKEY "Auxiliary Actor Hotkey", // INPUT_ACTOR_AUXILIARY_HOTKEY - "Start", // INPUT_START - "Back", // INPUT_BACK - "Analog Aim Up", // INPUT_R_UP - "Analog Aim Down", // INPUT_R_DOWN - "Analog Aim Left", // INPUT_R_LEFT - "Analog Aim Right" // INPUT_R_RIGHT + "Start", // INPUT_START + "Back", // INPUT_BACK + "Analog Aim Up", // INPUT_R_UP + "Analog Aim Down", // INPUT_R_DOWN + "Analog Aim Left", // INPUT_R_LEFT + "Analog Aim Right" // INPUT_R_RIGHT }; /// Enumeration for mouse button types. @@ -350,18 +350,18 @@ namespace RTE { }; static const std::unordered_map c_DirectionNameToDirectionsMap = { - {"None", Directions::None}, - {"Up", Directions::Up}, - {"Down", Directions::Down}, - {"Left", Directions::Left}, - {"Right", Directions::Right}, - {"Any", Directions::Any}}; + {"None", Directions::None}, + {"Up", Directions::Up}, + {"Down", Directions::Down}, + {"Left", Directions::Left}, + {"Right", Directions::Right}, + {"Any", Directions::Any}}; static const std::unordered_map c_DirectionsToRadiansMap = { - {Directions::Up, c_HalfPI}, - {Directions::Down, c_OneAndAHalfPI}, - {Directions::Left, c_PI}, - {Directions::Right, 0.0F}}; + {Directions::Up, c_HalfPI}, + {Directions::Down, c_OneAndAHalfPI}, + {Directions::Left, c_PI}, + {Directions::Right, 0.0F}}; #pragma endregion #pragma region Un - Definitions diff --git a/Source/System/Controller.cpp b/Source/System/Controller.cpp index bf7b92a5fa..32d82510e9 100644 --- a/Source/System/Controller.cpp +++ b/Source/System/Controller.cpp @@ -226,7 +226,7 @@ void Controller::UpdatePlayerInput(std::array