Skip to content

Multiplayer Duel

This is an advanced example that uses a lot of different features including Multiplayer Networking.

function OnTemplate()
    -- We'll register a listener to when the Game UI is starting to we can create our duel button.
    self.RegisterListener(Messager.StartGameUI,MakeUIOpenButton)
    -- We'll register to listen for network calls on this script so we can send calls to other players with the same mod.
    self.ListenForNetworkCalls()
end

function MakeUIOpenButton()
    -- Adds a button to start or end our duel and saves a reference to edit it later.
    DuelButton = UI.AddMenuButton("Start Duel",ToggleDuel)
    -- We'll set some default values here. At this point we're not dueling nor looking for a duel.
    IsDueling = false
    IsLookingForPlayers = false
end

-- This will be called when the duel button is pressed in the pause menu.
function ToggleDuel()
    -- First we'll unpause the game. Delayed calls don't work if the game is paused.
    Game.UnpauseGame()
    -- When we press the button on the pause we'll check if we're dueling or not and act accordingly. 
    if(IsDueling) then
        -- If we're dueling and the button was pressed let's stop dueling.
        self.CallFunctionOnEveryone("AfterDuelCleanup")
    else
        -- If we're not dueling let's attempt to start a duel.
        LookForPlayersToDuel()
    end
end

-- This is called if we pressed the duel button in the pause menu and we were not yet in a duel.
-- This function is only called locally on the player that started the duel.
function LookForPlayersToDuel()
    -- Here we'll initialize an empty list of players to duel.
    DuelPlayersList = {}
    -- We'll set some bool values to keep track of our current state.
    -- At this point we are looking for other players to duel.
    IsLookingForPlayers = true
    -- We just started so we haven't found any players.
    FoundPlayers = false

    -- Here we will update our duel button so we can't click again for now.
    -- We also change its text to say Looking for players.
    DuelButton.interactable = false
    DuelButton.SetText("Looking for players")

    -- This is a networked call.
    -- This will call the function Duel Request on others, but not on my own player.
    self.CallFunctionOnOthers("DuelRequest")

    -- We want to check in 3 seconds if we received any responses to our duel request.
    -- So we'll use a delayed call to call the function CheckIfFoundPlayersToDuel in 3 seconds.
    Game.DelayCall(CheckIfFoundPlayersToDuel,3)
end

-- This function will be called with Game.DelayCall 3 seconds after LookForPlayersToDuel()
function CheckIfFoundPlayersToDuel()
    -- If FoundPlayers is still false or our DuelPlayersList is nil...
    if FoundPlayers == false or DuelPlayersList == nil then
        -- Didn't find any players to duel. Show a message telling the player
        UI.ShowTopNotification("Did not find any players available to duel.")
        -- Let's do a cleanup to make sure we're back to our default state.
        AfterDuelCleanup()
    else
        -- Found Players to duel! Let's add our own player to the list of dueling players
        table.insert(DuelPlayersList,PlayerSyncer.GetLocalID())
        -- This is a networked function. This function will call StartDuel on everyone including us.
        -- We're passing the list of Duel Players we found as a parameter.
        self.CallFunctionOnEveryone("StartDuel",DuelPlayersList)
    end
end

-- This function will not be called locally if I started the duel.
-- It is only called on other players when a player attempts to start a duel.
-- It is called in the function LookForPlayersToDuel()
function DuelRequest()
    -- This is a networked call. It will call the function DuelConfirmation on other players
    -- but not my own. It will send our local player syncer ID as a parameter for that function.
    self.CallFunctionOnOthers("DuelConfirmation",PlayerSyncer.GetLocalID())
end

-- This function is called by other players that received a DuelRequest.
-- It is possible that I'm receiving this as another player in the room that hasn't started
-- a duel, so we'll first check if we are looking for players, if we are then we can be sure
-- that this confirmation was intended for us.
function DuelConfirmation(duelistID)
    -- Here we check if we are looking for players. If we aren't then we can ignore this confirmation.
    if IsLookingForPlayers == true then
        -- Here we try to get a player syncer reference using the ID that was sent to us.
        duelistSyncer = PlayerSyncer.GetWithID(duelistID)

        -- If we find a player syncer then we have a player to duel!
        if (duelistSyncer ~= nil) then
            -- We'll insert this id on our duel list
            table.insert(DuelPlayersList,duelistID)
            -- And we'll set FoundPlayers to true since we found a player.
            FoundPlayers = true
        end
    end
end

-- This function will be called over the network for everyone with a list of the player ids
-- that are confirmed to have this mod. We're calling it with CheckIfFoundPlayersToDuel()
-- which is called 3 seconds after the original player that started the duel clicked the 
-- button in the pause menu.
function StartDuel(duelPlayers)
    -- We'll update our duel button making it interactable again and setting the text to "End Duel"
    DuelButton.SetText("End Duel")
    DuelButton.interactable = true

    -- We are no longer looking for players at this point.
    IsLookingForPlayers = false
    -- We are now dueling
    IsDueling = true
    -- We'll use this new variable to keep track if we are still alive.
    IsAlive = true

    -- We'll use Hittable objects to check for wand hits.
    -- Let's initialize a table to place our hittable objects inside.
    availableHittables = {}

    -- Here we will go over all the player syncers currently in the room.
    for i, player in ipairs(PlayerSyncer.GetAll()) do
        -- If the current player we're checking is not nul
        -- and this player's ID is different than our own player ID (I shouldn't be able to hit myself right?)
        -- and the list of duel players we received contains this player's ID (Using another function to check that) 
        if(player ~= nil and player.ID ~= PlayerSyncer.GetLocalID() and hasValue(duelPlayers,player.ID)) then
            -- This player is in the duel, so let's create a new Hittable object for it.
            newHittable = Hittable.MakeHittable()
            -- We need to add a collision to the hittable to it can be hit by wand hits.
            newHittable.CreateSphereCollisions(0.65)
            -- We will set this new hittable's transform to be a child of the player's transform.
            -- This means it will move with the player.
            newHittable.transform.SetParent(player.transform)
            -- Since it is now a child object of the player we will set the new hittable's
            -- local position to be 0,1,0 so it's just slightly higher than the player's position.
            newHittable.transform.localPosition = Vector3.up

            -- Here we will add a callback to the new hittable object we created.
            -- This way when the hittable receives a hit it will call the method HitObject
            -- and will pass the player's ID. We'll also pass our own player id as the one that did the hit.

            -- We're also passing a value of false to be able to differentiate between this
            -- call to HitObject being made locally by us hitting a hittable and the call
            -- being made remotely because we're syncing it to other players.
            newHittable.RegisterCallback(HitObject,player.ID,PlayerSyncer.GetLocalID(),false)

            -- With that our new Hittable is all set up and ready to go.
            -- Then let's register it in our availableHittables table so we keep a reference to it.
            availableHittables[player.ID] = newHittable
        end
    end

    -- Shows a top notification so players know a duel has started!
    UI.ShowTopNotification("<color=#4bf0ff>The duel has started!</color>")
end

-- This function can be called two ways. One way is local, by a callback from our Hittable Objects.
-- The second way is remotely because we want to let other players in the network also know that
-- we hit this hittable. So we'll use the remote parameter to differentiate between those two.
function HitObject(defeatedID, hitterID, remote)
    -- If this call is not remote then we want to call this function on the other players
    -- but we will call it with remote set to true otherwise they would call it back on me
    -- and we would be stuck on a loop of calling this function back and forth over the network.
    if remote == false then
        -- This will call this same function on other players, but with the remote flag set to true
        -- so we don't get into a loop of sending this call back and forth forever.
        self.CallFunctionOnOthers("HitObject",defeatedID,hitterID, true)
    end

    -- We'll check our availableHittables table and see if it has a reference to our hittable
    if(availableHittables ~= nil and availableHittables[defeatedID] ~= nil) then
        -- If it does destroy it, we don't need it anymore, then remove it from our table.
        availableHittables[defeatedID].transform.Destroy()
        availableHittables[defeatedID] = nil
    end

    -- Here we call another function that counts how many available hittable objects we have left.
    playersLeft = availableHittablesCount()

    -- We will then retrieve the Player Syncer that caused the hit
    hitterPlayer = PlayerSyncer.GetWithID(hitterID)
    -- And the player syncer that was hit.
    defeatedPlayer = PlayerSyncer.GetWithID(defeatedID)

    -- If we were able to find the defeated player and the hitter player 
    if defeatedPlayer ~= nil and hitterPlayer ~= nil then
        -- We'll display a message on the screen letting everyone know what happened.
        UI.ShowTopNotification(UI.FormatText("<color=#4bf0ff>{0}</color> was hit by <color=#4bf0ff>{1}</color>! Players Left <color=#ff9c3c>{2}</color>",defeatedPlayer.name,hitterPlayer.name, playersLeft))

        -- Then we'll check if this hit was on us.
        if (defeatedID == PlayerSyncer.GetLocalID()) then
            -- If it was on us then we'll set IsAlive to false, our player has been defeated.
            IsAlive = false
            -- We'll show a message the player know they've been defeated.
            UI.ShowTopNotification(UI.FormatText("You were defeated by <color=#4bf0ff>{0}</color>!",hitterPlayer.name))
        end
    end

    -- If there are no other players available to hit and I'm still alive it means I'm the winner
    if (playersLeft == 0 and IsAlive) then
        -- This is a networked call.
        -- We'll call DuelIsOver on everyone including us and pass our own ID as the Winner ID.
        self.CallFunctionOnEveryone("DuelIsOver",PlayerSyncer.GetLocalID())
    end
end

-- This is called on everyone on the network. It is called by HitObject if it finds
-- that there are no other players left and a player is still alive. It passes that
-- player's id as a parameter.
function DuelIsOver(winner)
    -- We will attempt to find the winning player using the ID we received.
    winnerSyncer = PlayerSyncer.GetWithID(winner)
    -- If we were able to locate the winning player syncer
    if winnerSyncer ~= nil then
        -- We will display a notification so everyone can know who won the duel.
        UI.ShowTopNotification(UI.FormatText("<color=#4bf0ff>{0}</color> won the duel!",winnerSyncer.name))
    end

    -- We will then call our cleanup function to end the duel and reset our values back to default.
    AfterDuelCleanup()
end

-- This function can be called by DuelIsOver, by CheckIfFoundPlayersToDuel (in case no players
-- are available) or by cancelling the duel using the UI Button.
-- It's purpose is to cleanup all duel related things we created and reset this script back to its
-- default values.
function AfterDuelCleanup()
    -- If we have any available hittables left then go over each of them and destroy them.
    if availableHittables ~= nil then
        for i, hittable in ipairs(availableHittables) do
            if(hittable ~= nil) then
                hittable.transform.Destroy()
            end
        end
    end

    -- Then set it back to nil.
    availableHittables = nil

    -- At this point we are no longer dueling nor looking for players.
    IsDueling = false
    IsLookingForPlayers=false

    -- We will reset the duel button back to say Start Duel and be interactable.
    DuelButton.SetText("Start Duel")
    DuelButton.interactable = true
end

-- This function is used to count the amount of hittables available in our availableHittables table.
function availableHittablesCount()
    -- If the table is nil then we have 0 available hittables left.
    if availableHittables == nil then return 0 end

    -- Start a count value at 0
    local count = 0
    for i, hittable in ipairs(availableHittables) do
        -- First check if this hittable is not nil
        if(hittable ~= nil) then
            -- We found another hittable, let's update our count by adding 1 to it.
            count = count +1
        end
    end
    -- Returns our count, if none are found it will return the default value of 0.
    return count
end

-- This utility function takes a table and checks if a certain value is contained by the table.
function hasValue (tab, val)
    for index, value in ipairs(tab) do
        if value == val then
            return true
        end
    end
    return false
end

Comments