Skip to content

Tainted unlock

Author(s): benevolusgoat
Tags:

Introduction⚓︎

Tainted characters are alternate, twisted versions of their regular variants that are unlocked by finding them inside the Home floor's secret closet while playing as their regular variant. This article will cover setting up this unlock method for your own custommkdo tainted character.

Home Closet

Requirements⚓︎

For this tutorial, you will need:

  1. A regular custom character.
  2. A tainted variant of the character.
  3. Save data to to remember the state of the unlock.

A boolean value should be saved to remember the state of the unlock, for whether the tainted character is unlocked or not. With REPENTOGON, you can create an achievement and attach it to your tainted character directily in the players.xml file. Without REPENTOGON, you will need to learn how to manually handle save data.

Locking access to the character⚓︎

Before the tainted character can be unlocked, it must be locked and unable to be played.

As mentioned previously, REPENTOGON makes this process simple by allowing you to attach an achievement to the character, which will stop your character from being selected on the character selection menu.

Without REPENTOGON, there are no capabilities to lock the character inside the main menu. Instead, the character will need to be changed to a different character when being initialized.

EntityPlayer:ChangePlayerType will be utilized in order to change from your tainted character to your regular character. Using this function within MC_POST_PLAYER_INIT right as the player spawns will also grant the character any items that they may have on them as defined in their players.xml file. Depending on how your character is setup, you may need to make some additional adjustments to ensure this alternate way of starting as your regular character isn't any different from starting as them from the character selection menu.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
local mod = RegisterMod("My Mod", 1)

--PlayerTypes of the regular and tainted version of your character
local MY_CHAR = Isaac.GetPlayerTypeByName("My Character", false)
local MY_CHAR_TAINTED = Isaac.GetPlayerTypeByName("My Character", true)
local PLAYER_VARIANT_NORMAL = 0
--This is a stand-in for however you have your save data structured and where you keep your variable for tracking the tainted unlock.
local isUnlocked = false

--MC_POST_PLAYER_INIT passes the player being initialized.
function mod:LockTaintedOnInit(player)
    --Our tainted isn't unlocked yet!
    if player:GetPlayerType() == MY_CHAR_TAINTED and not isUnlocked then
        player:ChangePlayerType(MY_CHAR)
        --Insert other necessary adjustments here
    end
end

--MC_POST_PLAYER_INIT triggers whenever a player is initialized at run start, co-op spawn, run continue, or Genesis.
--PLAYER_VARIANT_NORMAL is inserted as the optional argument to ensure it runs for regular players and not co-op babies.
mod:AddCallback(ModCallback.MC_POST_PLAYER_INIT, mod.LockTaintedOnInit, PLAYER_VARIANT_NORMAL)

Spawn the player body⚓︎

The traditional method of unlocking a tainted character is by locating and touching their shaking body within the closet room of the Home floor. To start, check that you're entering the correct room with your character. The tainted character unlock method only looks at the first player using Isaac.GetPlayer().

You will be checking the first player's character multiple times throughout this tutorial. For convenience, create a function for it and return if the player is your character and has their tainted character locked.

1
2
3
4
5
6
7
local game = Game()

local function isFirstPlayerTaintedLocked()
    local player = Isaac.GetPlayer()
    local playerType = player:GetPlayerType()
    return playerType == MY_CHAR and not isUnlocked
end

If using REPENTOGON, the isFirstPlayerTaintedLocked check should specifically involve the achievement attached to your character. As such, the function should be adjusted like so:

1
2
3
4
5
6
7
8
9
--This is the achievement defined in achievements.xml and attached to your tainted character in players.xml
local TAINTED_ACHIEVEMENT = Isaac.GetAchievementIdByName("Tainted MyChar")

local function isFirstPlayerTaintedLocked()
    local persistGameData = Isaac.GetPersistentGameData()
    local player = Isaac.GetPlayer()
    local playerType = player:GetPlayerType()
    return playerType == MY_CHAR and not persistGameData:Unlocked(TAINTED_ACHIEVEMENT)
end

When entering the closet in Home, the tainted character's body is spawned as part of its room layout if not unlocked. If unlocked, the game will morph it into either Inner Child if that item is unlocked, or a Shopkeeper if not.

The callback MC_PRE_ROOM_ENTITY_SPAWN will trigger when attempting to spawn entities as part of the room layout. Choosing to override it will stop the game from proceeding with any morphs/replacements that would happen otherwise, such as the Inner Child or Shopkeeper.

For the player body, it is internally a slot machine (EntityType.ENTITY_SLOT) with a variant of 14. With REPENTOGON, there is a SlotVariant enum with 14 being assigned to SlotVariant.HOME_CLOSET_PLAYER.

Inside MC_PRE_ROOM_ENTITY_SPAWN, check the following:

  1. You're on the Home floor.
  2. You're in the closet room.
  3. The entity attempting to be spawned is a "home closet player" slot.
  4. Our isFirstPlayerTaintedLocked check from earlier.

After going through these checks, you can return the exact same entity type, variant, and subtype that was passed by the callback into a table so that the game doesn't attempt to morph it into anything else.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
local game = Game()
--The Home floor has a static layout, so the room index of the closet should remain unchanged.
local CLOSET_ROOM_INDEX = 94
local SLOT_HOME_CLOSET_PLAYER = 14

function mod:AllowHomeClosetPlayer(entType, variant, subtype, grid, seed)
    local level = game:GetLevel()
    if level:GetStage() == LevelStage.STAGE8 --Home.
        and level:GetCurrentRoomIndex() == CLOSET_ROOM_INDEX --Closet.
        and entType == EntityType.ENTITY_SLOT
        and variant == SLOT_HOME_CLOSET_PLAYER
        and isFirstPlayerTaintedLocked()
    then
        --Return its own entity type, variant, and subtype as to not turn into Inner Child or a shopkeeper by the game.
        return {entType, variant, subtype}
    end
end

Mod:AddCallback(ModCallbacks.MC_PRE_ROOM_ENTITY_SPAWN, mod.AllowHomeClosetPlayer)

Update the player body sprite⚓︎

The player body will attempt to take on the tainted appearance of the first player's current character. If no tainted is available, or when playing a modded character, it will spawn with the first player's own spritesheet. As such, the spritesheet must be manually updated to display the tainted's spritesheet.

Non-REPENTOGON method⚓︎

Without REPENTOGON, there are no callbacks for slot machines, so they must be manually searched for upon entering the room containing them. Use MC_POST_NEW_ROOM for entering the room, and Isaac.FindByType to search for all "home closet player" slot machines in the room to update their spritesheet.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
local myCharTaintedSpritePath = "gfx/characters/costumes/character_mychar_b.png"

--We want to update the body's sprite no matter what room it's located in, so this will activate on every MC_POST_NEW_ROOM.
function mod:TryUpdateClosetPlayer()
    --We store this check before the FindByType loop as we only need to check it once.
    local firstPlayerTaintedLocked = isFirstPlayerTaintedLocked()
    --Search for all slot machines with the desired variant.
    for _, ent in ipairs(Isaac.FindByType(EntityType.ENTITY_SLOT, SLOT_HOME_CLOSET_PLAYER)) do
        --Check that it just spawned and we should update it for our character.
        if firstPlayerTaintedLocked then
            local sprite = ent:GetSprite()
            sprite:ReplaceSpritesheet(0, myCharTaintedSpritePath)
            sprite:LoadGraphics()
        end
    end
end

mod:AddCallback(ModCallbacks.MC_POST_NEW_ROOM, mod.TryUpdateClosetPlayer)

REPENTOGON method⚓︎

If you have REPENTOGON, you can update the spritesheet whenever the slot machine initializes on MC_POST_SLOT_INIT. For additional convenience, you can automatically fetch your tainted counterpart's spritesheet using EntityConfigPlayer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
--Fetches the tainted spritesheet of the passed player.
local function getTaintedSpritesheet(player)
    --Current player's config.
    local playerConfig = player:GetEntityConfigPlayer()
    --Get their tainted's config. Keep in mind that on a tainted, it will return the regular counterpart.
    local taintedConfig = playerConfig:GetTaintedCounterpart()
    --If already a tainted character or has no tainted, returns the current player's spritesheet.
    if playerConfig:IsTainted() or not taintedConfig then
        --Returning early stops later code in this function from running.
        return playerConfig:GetSkinPath()
    end
    --Return the tainted character's spritesheet path.
    return taintedConfig:GetSkinPath()
end

function mod:OnClosetIsaacInit(slot)
    if isFirstPlayerTaintedLocked() then
        local sprite = slot:GetSprite()
        local player = Isaac.GetPlayer()
        local spritesheet = getTaintedSpritesheet(player)
        --Update the spritesheet. The last `true` here introduced by REPENTOGON will trigger `sprite:LoadGraphics()`.
        sprite:ReplaceSpritesheet(0, spritesheet, true)
    end
end

mod:AddCallback(ModCallbacks.MC_POST_SLOT_INIT, mod.OnClosetIsaacInit, SlotVariant.HOME_CLOSET_PLAYER)

Triggering the unlock⚓︎

When the player touches the player body, it will start playing the "PayPrize" animation. The unlock can be triggered when that animation finishes.

Non-REPENTOGON method⚓︎

The same method of finding the player body last time will be used here once more. This time MC_POST_UPDATE is used, as the animation must be constantly checked for when it finishes. If you wish to show an achievement paper, that must be handled manually and won't be covered in this tutorial. Otherwise, you can use something such as HUD:ShowItemText to communicate to the player that the character was unlocked.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function mod:UnlockTaintedOnPayPrize()
    local firstPlayerTaintedLocked = isFirstPlayerTaintedLocked()
    for _, ent in ipairs(Isaac.FindByType(EntityType.ENTITY_SLOT, SLOT_HOME_CLOSET_PLAYER)) do
        if firstPlayerTaintedLocked then
            local sprite = ent:GetSprite()
            if sprite:IsFinished("PayPrize") then
                isUnlocked = true
                game:GetHUD():ShowItemText("Tainted MyChar Unlocked!")
            end
        end
    end
end

mod:AddCallback(ModCallbacks.MC_POST_UPDATE, mod.UnlockTaintedOnPayPrize)

REPENTOGON method⚓︎

REPENTOGON's MC_POST_SLOT_UPDATE will pass slot machines being updated, skipping the need for Isaac.FindByType from the non-REPENTOGON method. You can unlock your registered achievement when the PayPrize animation finishes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function mod:UnlockTaintedOnPayPrize(slot)
    if isFirstPlayerTaintedLocked() then
        local sprite = slot:GetSprite()
        if sprite:IsFinished("PayPrize") then
            local persistGameData = Isaac.GetPersistentGameData()
            persistGameData:TryUnlock(TAINTED_ACHIEVEMENT)
        end
    end
end

mod:AddCallback(ModCallbacks.MC_POST_SLOT_UPDATE, mod.UnlockTaintedOnPayPrize, SlotVariant.HOME_CLOSET_PLAYER)

Final code snippet⚓︎

With that, your unlock is complete! For your convenience, the combined code for both non-REPENTOGON and REPENTOGON methods are available below:

Non-REPENTOGON method⚓︎

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
local mod = RegisterMod("My Mod", 1)

local MY_CHAR = Isaac.GetPlayerTypeByName("My Character", false)
local MY_CHAR_TAINTED = Isaac.GetPlayerTypeByName("My Character", true)
local PLAYER_VARIANT_NORMAL = 0
local CLOSET_ROOM_INDEX = 94
local SLOT_HOME_CLOSET_PLAYER = 14
local isUnlocked = false
local myCharTaintedSpritePath = "gfx/characters/costumes/character_mychar_b.png"
local game = Game()

local function isFirstPlayerTaintedLocked()
    local player = Isaac.GetPlayer()
    local playerType = player:GetPlayerType()
    return playerType == MY_CHAR and not isUnlocked
end

function mod:LockTaintedOnInit(player)
    if player:GetPlayerType() == MY_CHAR_TAINTED and not isUnlocked then
        player:ChangePlayerType(MY_CHAR)
    end
end

mod:AddCallback(ModCallback.MC_POST_PLAYER_INIT, mod.LockTaintedOnInit, PLAYER_VARIANT_NORMAL)

function mod:AllowHomeClosetPlayer(entType, variant, subtype, grid, seed)
    local level = game:GetLevel()
    if level:GetStage() == LevelStage.STAGE8 --Home.
        and level:GetCurrentRoomIndex() == CLOSET_ROOM_INDEX --Closet.
        and entType == EntityType.ENTITY_SLOT
        and variant == SLOT_HOME_CLOSET_PLAYER
        and isFirstPlayerTaintedLocked()
    then
        --Return its own entity type, variant, and subtype as to not turn into Inner Child or a shopkeeper by the game.
        return {entType, variant, subtype}
    end
end

Mod:AddCallback(ModCallbacks.MC_PRE_ROOM_ENTITY_SPAWN, mod.AllowHomeClosetPlayer)

function mod:TryUpdateClosetPlayer()
    local firstPlayerTaintedLocked = isFirstPlayerTaintedLocked()
    for _, ent in ipairs(Isaac.FindByType(EntityType.ENTITY_SLOT, SLOT_HOME_CLOSET_PLAYER)) do
        if ent.FrameCount == 0 and firstPlayerTaintedLocked then
            local sprite = ent:GetSprite()
            sprite:ReplaceSpritesheet(0, myCharTaintedSpritePath)
            sprite:LoadGraphics()
        end
    end
end

mod:AddCallback(ModCallbacks.MC_POST_NEW_ROOM, mod.TryUpdateClosetPlayer)

function mod:UnlockTaintedOnPayPrize()
    local firstPlayerTaintedLocked = isFirstPlayerTaintedLocked()
    for _, ent in ipairs(Isaac.FindByType(EntityType.ENTITY_SLOT, SLOT_HOME_CLOSET_PLAYER)) do
        if firstPlayerTaintedLocked then
            local sprite = ent:GetSprite()
            if sprite:IsFinished("PayPrize") then
                isUnlocked = true
                game:GetHUD():ShowItemText("Tainted MyChar Unlocked!")
            end
        end
    end
end

mod:AddCallback(ModCallbacks.MC_POST_UPDATE, mod.UnlockTaintedOnPayPrize)

REPENTOGON method⚓︎

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
local mod = RegisterMod("My Mod", 1)
local MY_CHAR = Isaac.GetPlayerTypeByName("My Character", false)
local TAINTED_ACHIEVEMENT = Isaac.GetAchievementIdByName("Tainted MyChar")
local CLOSET_ROOM_INDEX = 94
local game = Game()

local function isFirstPlayerTaintedLocked()
    local persistGameData = Isaac.GetPersistentGameData()
    local player = Isaac.GetPlayer()
    local playerType = player:GetPlayerType()
    return playerType == MY_CHAR and not persistGameData:Unlocked(TAINTED_ACHIEVEMENT)
end

local function getTaintedSpritesheet(player)
    local playerConfig = player:GetEntityConfigPlayer()
    local taintedConfig = playerConfig:GetTaintedCounterpart()
    if playerConfig:IsTainted() or not taintedConfig then
        return playerConfig:GetSkinPath()
    end
    return taintedConfig:GetSkinPath()
end

function mod:AllowHomeClosetPlayer(entType, variant, subtype, grid, seed)
    local level = game:GetLevel()
    if level:GetStage() == LevelStage.STAGE8
        and level:GetCurrentRoomIndex() == CLOSET_ROOM_INDEX
        and entType == EntityType.ENTITY_SLOT
        and variant == SlotVariant.HOME_CLOSET_PLAYER
        and isFirstPlayerTaintedLocked()
    then
        return {entType, variant, subtype}
    end
end

Mod:AddCallback(ModCallbacks.MC_PRE_ROOM_ENTITY_SPAWN, mod.AllowHomeClosetPlayer)

function mod:OnClosetIsaacInit(slot)
    if isFirstPlayerTaintedLocked() then
        local sprite = slot:GetSprite()
        local player = Isaac.GetPlayer()
        local spritesheet = getTaintedSpritesheet(player)
        sprite:ReplaceSpritesheet(0, spritesheet, true)
    end
end

mod:AddCallback(ModCallbacks.MC_POST_SLOT_INIT, mod.OnClosetIsaacInit, SlotVariant.HOME_CLOSET_PLAYER)

function mod:UnlockTaintedOnPayPrize(slot)
    if isFirstPlayerTaintedLocked() then
        local sprite = slot:GetSprite()
        if sprite:IsFinished("PayPrize") then
            local persistGameData = Isaac.GetPersistentGameData()
            persistGameData:TryUnlock(TAINTED_ACHIEVEMENT)
        end
    end
end

mod:AddCallback(ModCallbacks.MC_POST_SLOT_UPDATE, mod.UnlockTaintedOnPayPrize, SlotVariant.HOME_CLOSET_PLAYER)