I’m trying to make a fighting game system by using a ModuleScript that’s parented to each player’s character each time they spawn, which handles what abilities you can or can’t use at the time. It sets up a bunch of tables (attackDisableReasons
, dashDisableReasons
, etc.) that tracks the ongoing reasons why you CAN’T perform a specific action. Ideally, the ModuleScript allows you to both add and remove specific reasons from each table, and if a corresponding table is empty when a “getAbility
” function is called, then you’re allowed to perform that ability as long as you’re otherwise allowed to act.
However, for some reason whenever a disable reason is added and then subsequently removed by another script, the entire reasoning table is seemingly destroyed as it unexpectedly returns nil
the next time it is called, rather than returning an empty list. Because the table is destroyed, all of the get functions fail because they’re incorrectly looking to see if there are zero items inside of nil
. I have no clue as to what’s causing the table to become nil
, since nowhere in the ModuleScript is it coded to directly change what the tables themselves are, and other scripts don’t even have direct access to the tables.
-- ModuleScript "PlayerStateManager", cloned to Workspace > (Your Character) > ModuleScripts
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local storage = game.ReplicatedStorage
local player
if RunService:IsClient() then player = Players.LocalPlayer
elseif RunService:IsServer() then player = Players:GetPlayerFromCharacter(script.Parent.Parent)
if not player then error("ModuleScript hierarchy was changed; couldn't find the corresponding player") end
end
local autoReplicate = storage.RemoteEvents.AutoReplication.PlayerStateChanged
local main = require(game.ReplicatedStorage.ModuleScripts.PlayerModule)
local PlayerStateManager = {}
local actDisableReasons = {} -- if any reason exists that the player can't act, then no other abilities may be used
local attackDisableReasons = {}
local dashDisableReasons = {}
local grabDisableReasons = {}
local blockDisableReasons = {}
local anchoredReasons = {} -- currently unused
local jumpDisableReasons = {}
-- Player statistics
local playerWalkSpeed = 18
local playerJumpPower = 50
local defaultShieldHealth = 80
local shieldHealth = 80
function PlayerStateManager.getCanAct()
if player.Character.Humanoid.Health <= 0
or player.Character:GetAttribute("IsGrabbed")
or player.Character:GetAttribute("IsGrabbing")
or player.Character:GetAttribute("IsCarried")
or player.Character:GetAttribute("IsCarrying")
or player.Character:GetAttribute("IsRagdolled")
or player.Character:GetAttribute("IsStunned") then
return false end
return (#actDisableReasons == 0), actDisableReasons
end
function PlayerStateManager.getCanAttack()
if PlayerStateManager.getCanAct() then
return (#attackDisableReasons == 0)
else
return false, attackDisableReasons, actDisableReasons
end
end
function PlayerStateManager.getCanDash()
if PlayerStateManager.getCanAct() then
return (#dashDisableReasons == 0)
else
return false, dashDisableReasons, actDisableReasons
end
end
function PlayerStateManager.getCanGrab()
if PlayerStateManager.getCanAct() then
return (#grabDisableReasons == 0)
else
return false, grabDisableReasons, actDisableReasons
end
end
function PlayerStateManager.getCanBlock()
if PlayerStateManager.getCanAct() then
return (#blockDisableReasons == 0)
else
return false, blockDisableReasons, actDisableReasons
end
end
function PlayerStateManager.getPlayerDefaultWalkSpeed()
return playerWalkSpeed
end
function PlayerStateManager.getPlayerDefaultJumpPower()
return playerJumpPower
end
function PlayerStateManager.getCanJump() -- JumpPower must be manually disabled per script; this is to ensure jumping is not reenabled improperly
return (#jumpDisableReasons == 0), jumpDisableReasons
end
function PlayerStateManager.getShieldHealth()
return shieldHealth
end
--[[
Adjusts the health of a player's bubble shield. Their shield health will be incremented by the number provided.
"health" must be negative in order to damage a player's shield.
Using trueSet will forcefully set the shield health to the given health, rather than incrementing by it.
]]
function PlayerStateManager.setShieldHealth(health: number, trueSet: boolean?)
if trueSet == true then
shieldHealth = health
else
shieldHealth += health
end
if RunService:IsServer() then autoReplicate:FireClient(player, "ShieldHealth", shieldHealth) end
end
function PlayerStateManager.getDefaultShieldHealth()
return defaultShieldHealth
end
function PlayerStateManager.setDefaultShieldHealth(health)
defaultShieldHealth = health
if RunService:IsServer() then autoReplicate:FireClient(player, "DefaultShieldHealth", health) end
end
local abilityMapping = {
canact = {
disableTable = actDisableReasons,
replicate = function()
if RunService:IsServer() then
autoReplicate:FireClient(player, "CanAct", PlayerStateManager.getCanAct())
end
end,
getter = PlayerStateManager.getCanAct
},
canattack = {
disableTable = attackDisableReasons,
replicate = function()
if RunService:IsServer() then
autoReplicate:FireClient(player, "CanAttack", PlayerStateManager.getCanAttack())
end
end,
getter = PlayerStateManager.getCanAttack
},
candash = {
disableTable = dashDisableReasons,
replicate = function()
storage.RemoteEvents.PlayerActions.Abilities.CanDashChanged:Fire(PlayerStateManager.getCanDash()) -- this and similar RemoteEvents are for handling UI on the client
if RunService:IsServer() then
autoReplicate:FireClient(player, "CanDash", PlayerStateManager.getCanDash())
end
end,
getter = PlayerStateManager.getCanDash
},
cangrab = {
disableTable = grabDisableReasons,
replicate = function()
storage.RemoteEvents.PlayerActions.Grabs.CanGrabChanged:Fire(PlayerStateManager.getCanGrab())
if RunService:IsServer() then
autoReplicate:FireClient(player, "CanGrab", PlayerStateManager.getCanGrab())
end
end,
getter = PlayerStateManager.getCanGrab
},
canblock = {
disableTable = blockDisableReasons,
replicate = function()
if RunService:IsServer() then
autoReplicate:FireClient(player, "CanBlock", PlayerStateManager.getCanBlock())
end
end,
getter = PlayerStateManager.getCanBlock
},
canjump = {
disableTable = jumpDisableReasons,
replicate = function()
if RunService:IsServer() then
autoReplicate:FireClient(player, "CanJump", PlayerStateManager.getCanJump())
end
end,
getter = PlayerStateManager.getCanJump
},
}
--[[
Adds a reason for a player's ability to be disabled.
Once a reason is added, it must be removed before that ability can be used again.
]]
function PlayerStateManager.setAbilityDisabled(ability: string, disableReason: string, state: boolean)
local mapping = abilityMapping[string.lower(ability)]
if not mapping then warn("Couldn't set", ability, "because", ability, "does not exist.") return end
local disableTable = mapping.disableTable
if state == true then
if not table.find(disableTable, disableReason) then
table.insert(disableTable, disableReason)
end
else
for i, r in ipairs(disableTable) do
if r == disableReason then
table.remove(disableTable, i)
break
end
end
end
mapping.replicate()
end
-- Whenever the server sets a reason why you can't perform an action, a dummy reason is set on the client until the server says the list is empty again
if RunService:IsClient() and script.Parent.Parent ~= game.StarterPlayer.StarterCharacterScripts then
autoReplicate.OnClientEvent:Connect(function(state: string, value: boolean|number)
if state == "ShieldHealth" then
PlayerStateManager.setShieldHealth(value, true)
elseif state == "DefaultShieldHealth" then
PlayerStateManager.setDefaultShieldHealth(value)
elseif type(value) == "boolean" then
PlayerStateManager.setAbilityDisabled(state, "ClientReplicatedDisable", value)
end
end)
end
return PlayerStateManager
I discovered this issue while manipulating a disable reason for “actDisableReasons
”, which is set like this within a grab handler script:
aggressorChrStates.setAbilityDisabled("CanAct", "IsGrabInitiator", true)
-- later...
if aggressorChrStates then aggressorChrStates.setAbilityDisabled("CanAct", "IsGrabInitiator", false) end
I set up the following debugger to run near the beginning and the end of a grab:
_, grabreasons, actreasons = aggressorChrStates.getCanGrab()
print("GrabDisableReasons:", grabreasons)
print("ActDisableReasons", actreasons)
At the beginning of the grab, it outputs this:
…which is what I want. But at the end of the grab, it outputs this:
The first set of reasons are fetched right before attempting to remove the IsGrabInitiator
reason from the actDisableReasons
table. They’re fine.
The second set of reasons, the ones that return nil
, are fetched one second after IsGrabInitiator
is removed from the actDisableReasons
table.
My intuition tells me that the part of the PlayerStateManager.setAbilityDisabled()
function that removes disable reasons from the tables is the issue, but I can’t figure out why it’s not only nuking the table, but also nuking the grabDisableReasons
table as well, when that table was never even touched by the script that managed the IsGrabInitiator
reason.