-- Init legacy global variables for other mods to hand over Descriptions if not __eidItemDescriptions then __eidItemDescriptions = {} end setmetatable(__eidItemDescriptions, { __newindex = function(_, k, v) EID:addCollectible(k, v) end }) if not __eidTrinketDescriptions then __eidTrinketDescriptions = {} end setmetatable(__eidTrinketDescriptions, { __newindex = function(_, k, v) EID:addTrinket(k, v) end }) if not __eidCardDescriptions then __eidCardDescriptions = {} end setmetatable(__eidCardDescriptions, { __newindex = function(_, k, v) EID:addCard(k, v) end }) if not __eidPillDescriptions then __eidPillDescriptions = {} end setmetatable(__eidPillDescriptions, { __newindex = function(_, k, v) EID:addPill(k, v) end }) if not __eidItemTransformations then __eidItemTransformations = {} end setmetatable(__eidItemTransformations, { __newindex = function(_, k, v) EID:assignTransformation("collectible", k, v) end }) if not __eidEntityDescriptions then __eidEntityDescriptions = {} end setmetatable(__eidEntityDescriptions, { __newindex = function(_, k, v) local entityID = {} for id in string.gmatch(k, "([^.]+)") do table.insert(entityID, id) end EID:addEntity(entityID[1], entityID[2], entityID[3], v[1], v[2]) end }) --------------------------------------------------------------------------- -------------------------Handle API Functions ----------------------------- EID.Pathfinder = require("features.pathfinder.luafinding") local nullVector = Vector(0,0) local game = Game() local maxCardID = Card.NUM_CARDS - 1 local maxPillID = PillColor.NUM_PILLS - 1 local dynamicSpriteCache = {} -- used to store sprite objects of collectible icons etc. --#region Luadoc definitions ---@alias EID_PillClass "3-"|"2-"|"1-"|"0"|"1+"|"2+"|"3+" ---@alias EID_TypeVariantAlias ---| "collectible" @ "5.100" ---| "collectibles" @ "5.100" ---| "trinket" @ "5.350" ---| "trinkets" @ "5.350" ---| "card" @ "5.300" ---| "cards" @ "5.300" ---| "pill" @ "5.70" ---| "pills" @ "5.70" ---| "horsepills" @ "5.70" ---| "horsepill" @ "5.70" ---| "dice" @ "1000.76" ---| "entity" @ Indicates that a full entity identifier is used ---@alias EID_Dimension ---| 0 @Normal dimension ---| 1 @Secondary dimension, used by Downpour mirror dimension and Mines escape sequence ---| 2 @Death Certificate dimension ---@class EID_GoldenTrinketData ---@field t number[]? @The numbers inside the text that should be multiplied ---@field mult number? @Max multiplier applied. assumed to be 3. ---@field mults number[]? @Custom multipliers. A Missing Page's damage goes from 80 to 120 to 160; so its multipliers are 1.5 and 2, instead of 2 and 3 ---@field append boolean? @If true, text is added to the description ---@field findReplace boolean? @If true, the text is replaced ---@field fullReplace boolean? @If true, description is fully replaced ---@field goldenOnly boolean? @If true, the description is modified only when the trinket is golden ---@field additions number? @Add a value to the number in the text, similar to multiplier. Expects a table of 3 values, 1: double, 2: triple, 3: quadruple effect ---@class EID_Icon ---@field [1] string @Animation name ---@field [2] integer @Animation frame ---@field [3] integer @Width ---@field [4] integer @Height ---@field [5] integer? @Left offset ---@field [6] integer? @Top offset ---@field [7] Sprite @Sprite object ---@class EID_DescObj ---@field ObjType integer ---@field ObjVariant integer ---@field ObjSubType integer ---@field fullItemString string @String in `Type.Variant.SubType` format ---@field Name string ---@field Description string ---@field Transformation string ---@field ModName string ---@field Quality integer ---@field Icon EID_Icon ---@field Entity Entity? ---@field ShowWhenUnidentified boolean? ---@field PermanentTextEnglish string? ---@field IgnoreBulletPointIconConfig boolean? ---@field ItemType integer? ---@field ChargeType integer? ---@field Charges integer? @Max charges ---@alias EID_Anchor "TOP"|"BOTTOM"|"LEFT"|"RIGHT" ---@class EID_HudElement ---@field x number ---@field y number ---@field width number ---@field height number ---@field anchors EID_Anchor[] ---@field descriptionObj fun(): EID_DescObj --#endregion ---Adds a description for a collectible. ---@param id CollectibleType ---@param description string ---@param itemName string? ---@param language EID_LanguageCode? @Default: "en_us" function EID:addCollectible(id, description, itemName, language) itemName = itemName or nil language = language or EID.DefaultLanguageCode if id == -1 then EID:WriteErrorMsg("Trying to add collectible description to id = -1, which is not allowed! (Name: "..tostring(itemName).."; Description: "..tostring(description)..")") return end local modName = EID._currentMod -- Glitched Items exception so they don't have a mod name if id > 4294960000 then modName = nil end local fullID = "5.100." .. id if EID.FullyAutomatedDescriptions[fullID] then EID.FullyAutomatedDescriptions[fullID] = nil -- remove vanilla item id from the modular desc list, since it got overriden by this function. end EID:CreateDescriptionTableIfMissing("custom", language) EID.descriptions[language].custom[fullID] = {id, itemName, description, modName} if itemName then EID.ItemNames[language][fullID] = itemName end end ---Adds a description for a trinket. ---@param id TrinketType ---@param description string ---@param itemName? string ---@param language? EID_LanguageCode @Default: "en_us" function EID:addTrinket(id, description, itemName, language) itemName = itemName or nil language = language or EID.DefaultLanguageCode if id == -1 then EID:WriteErrorMsg("Trying to add trinket description to id = -1, which is not allowed! (Name: "..tostring(itemName).."; Description: "..tostring(description)..")") return end local fullID = "5.350." .. id if EID.FullyAutomatedDescriptions[fullID] then EID.FullyAutomatedDescriptions[fullID] = nil -- remove vanilla item id from the modular desc list, since it got overriden by this function. end EID:CreateDescriptionTableIfMissing("custom", language) EID.descriptions[language].custom[fullID] = {id, itemName, description, EID._currentMod} if itemName then EID.ItemNames[language][fullID] = itemName end end ---Adds character specific information, which can be viewed in the Item Reminder ---@param characterId PlayerType ---@param description string ---@param playerName? string @Default: "Modded Character" ---@param language? EID_LanguageCode @Default: "en_us" function EID:addCharacterInfo(characterId, description, playerName, language) playerName = playerName or "Modded Character" language = language or EID.DefaultLanguageCode if characterId == -1 then EID:WriteErrorMsg("Trying to add character description to id = -1, which is not allowed! (Name: "..tostring(playerName).."; Description: "..tostring(description)..")") return end EID:CreateDescriptionTableIfMissing("CharacterInfo", language) EID.descriptions[language].CharacterInfo[characterId] = {playerName, description} end ---Adds information about appending text and multiplying numbers in a modded trinket's Golden/Mom's Box description. All three variables are optional, set to ""/0 or nil to not include them ---@param appendText? string | string[] @Text to be appended onto the description. Can be one string, or a table of two strings; one for doubling and one for tripling ---@param numbersToMultiply? number | number[] @The number inside the text that should be multiplied. can be one number, or a table of numbers ---@param maxMultiplier? number @Is what tripling (Golden+Mom's Box) should multiply the numbers by, normally 3. If it's less than 2, it also applies to doubling ---@param language? EID_LanguageCode @Default: "en_us" -- Example: My modded trinket gives +0.5 range and when tripled, adds homing instead of tripling the range boost: --- ```lua --- EID:addGoldenTrinketMetadata(Isaac.GetTrinketIdByName("Cool Trinket"), {"", "Homing tears"}, 0.5, 2) --- ``` function EID:addGoldenTrinketMetadata(id, appendText, numbersToMultiply, maxMultiplier, language) maxMultiplier = maxMultiplier or 3 language = language or EID.DefaultLanguageCode if appendText == "" then appendText = nil elseif type(appendText) == "string" then appendText = {appendText} end if numbersToMultiply == 0 then numbersToMultiply = nil elseif type(numbersToMultiply) == "number" then numbersToMultiply = {numbersToMultiply} end EID.GoldenTrinketData[id] = {t = numbersToMultiply, mult = maxMultiplier, append = appendText and true} if appendText then EID:CreateDescriptionTableIfMissing("goldenTrinketEffects", language) EID.descriptions[language].goldenTrinketEffects[id] = { appendText[1], appendText[1], appendText[2] or appendText[1] } end end ---Adds information about appending text and adding to numbers in a modded trinket's Golden/Mom's Box description. All three variables are optional, set to ""/0 or nil to not include them ---@param id TrinketType ---@param appendText? string | string[] @Text to be appended onto the description. Can be one string, or a table of two strings; one for doubling and one for tripling ---@param numbersToChange? number | number[] @The number inside the text that gets added towards. can be one number, or a table of numbers ---@param additiveValues? number @Table of values that should be added to the number, if its doubled, tripled, or quadrupled ---@param language? EID_LanguageCode @Default: "en_us" -- Example: My modded trinket gives +5 range and when tripled, adds homing instead of tripling the range boost: --- ```lua --- EID:addGoldenTrinketMetadataAdditive(Isaac.GetTrinketIdByName("Cool Trinket"), {"", "Homing tears"}, 5, {1,2,3}) --- ``` function EID:addGoldenTrinketMetadataAdditive(id, appendText, numbersToChange, additiveValues, language) language = language or EID.DefaultLanguageCode if appendText == "" then appendText = nil elseif type(appendText) == "string" then appendText = {appendText} end if numbersToChange == 0 then numbersToChange = nil elseif type(numbersToChange) == "number" then numbersToChange = {numbersToChange} end additiveValues = type(additiveValues) == "number" and {additiveValues, additiveValues, additiveValues} or additiveValues or {1, 2, 3} EID.GoldenTrinketData[id] = {t = numbersToChange, additions = additiveValues, mults = {1,1,1}, append = appendText and true} if appendText then EID:CreateDescriptionTableIfMissing("goldenTrinketEffects", language) EID.descriptions[language].goldenTrinketEffects[id] = { appendText[1], appendText[1], appendText[2] or appendText[1] } end end ---@see EID_GoldenTrinketData ---Add a fully custom data table to the table of Golden Trinket effects. ---Check GoldenTrinketCallback in [eid_modifiers.lua](eid_modifiers.lua) to see the specifics of how it works. ---You may also want to add text entries into `EID.descriptions[languageCode].goldenTrinketEffects` ---@param id TrinketType ---@param dataTable EID_GoldenTrinketData function EID:addGoldenTrinketTable(id, dataTable) EID.GoldenTrinketData[id] = dataTable end ---Adds a description for a card/rune ---@param id Card ---@param description string ---@param itemName? string ---@param language? EID_LanguageCode @Default: "en_us" function EID:addCard(id, description, itemName, language) itemName = itemName or nil language = language or EID.DefaultLanguageCode if id == -1 then EID:WriteErrorMsg("Trying to add card description to id = -1, which is not allowed! (Name: "..tostring(itemName).."; Description: "..tostring(description)..")") return end local fullID = "5.300." .. id if EID.FullyAutomatedDescriptions[fullID] then EID.FullyAutomatedDescriptions[fullID] = nil -- remove vanilla item id from the modular desc list, since it got overriden by this function. end EID:CreateDescriptionTableIfMissing("custom", language) EID.descriptions[language].custom[fullID] = {id, itemName, description, EID._currentMod} if itemName then EID.ItemNames[language][fullID] = itemName end end -- DEPRECATED! Does nothing! Don't use! ---@deprecated ---@diagnostic disable-next-line: unused-local function EID:addCardMetadata(id, mimicCharge, isRune) end ---Adds a description for a PillEffect id ---@param id PillEffect ---@param description string ---@param itemName? string ---@param language? EID_LanguageCode @Default: "en_us" function EID:addPill(id, description, itemName, language) itemName = itemName or nil language = language or EID.DefaultLanguageCode if id == -1 then EID:WriteErrorMsg("Trying to add pill description to id = -1, which is not allowed! (Name: "..tostring(itemName).."; Description: "..tostring(description)..")") return end local fullID = "5.70." .. id if EID.FullyAutomatedDescriptions[fullID] then EID.FullyAutomatedDescriptions[fullID] = nil -- remove vanilla item id from the modular desc list, since it got overriden by this function. end EID:CreateDescriptionTableIfMissing("pills", language) EID:CreateDescriptionTableIfMissing("horsepills", language) EID.descriptions[language].pills[id + 1] = {id, itemName, description, EID._currentMod} -- Default the horsepill to the same description as the base pill, for mods that only define the base pill if EID.isRepentance and EID.descriptions[language].horsepills[id + 1] == nil then EID.descriptions[language].horsepills[id + 1] = {id, itemName, description, EID._currentMod} end if itemName then EID.ItemNames[language][fullID] = itemName end end ---Adds a horsepill-specific description for a PillEffect ---@param id PillEffect ---@param description string ---@param itemName? string ---@param language? EID_LanguageCode @Default: "en_us" function EID:addHorsePill(id, description, itemName, language) if not EID.isRepentance then return end itemName = itemName or nil language = language or EID.DefaultLanguageCode if id == -1 then EID:WriteErrorMsg("Trying to add horsepill description to id = -1, which is not allowed! (Name: "..tostring(itemName).."; Description: "..tostring(description)..")") return end local fullID = "5.70." .. id if EID.FullyAutomatedDescriptions[fullID] then EID.FullyAutomatedDescriptions[fullID] = nil -- remove vanilla item id from the modular desc list, since it got overriden by this function. end EID:CreateDescriptionTableIfMissing("horsepills", language) EID.descriptions[language].horsepills[id + 1] = {id, itemName, description, EID._currentMod} if itemName then EID.ItemNames[language][fullID] = itemName end end ---Adds a metadata for a pilleffect. Used for Placebo/False PHD ---@param id PillEffect ---@param mimicCharge integer @DEPRECATED, does nothing ---@param class? EID_PillClass @Default: "0". With False PHD, "3-" gives +0.6 Damage, "2-" and "1-" spawn a Black Heart ---@diagnostic disable-next-line: unused-local function EID:addPillMetadata(id, mimicCharge, class) EID.pillMetadata[id] = { class = class or "0", } end ---Adds a character specific description for the item "Birthright" ---@param characterId PlayerType ---@param description string ---@param playerName? string ---@param language? EID_LanguageCode @Default: "en_us" function EID:addBirthright(characterId, description, playerName, language) playerName = playerName or nil language = language or EID.DefaultLanguageCode if characterId == -1 then EID:WriteErrorMsg("Trying to add Birthright description to id = -1, which is not allowed! (Name: "..tostring(characterId).."; Description: "..tostring(description)..")") return end EID:CreateDescriptionTableIfMissing("birthright", language) EID.descriptions[language].birthright[characterId + 1] = {playerName, "", description} end ---Creates a new transformation with a given unique name and a display name ---@param uniqueName string ---@param displayName string ---@param language? EID_LanguageCode @Default: "en_us" function EID:createTransformation(uniqueName, displayName, language) language = language or EID.DefaultLanguageCode if EID.CustomTransformations[uniqueName] == nil then EID.CustomTransformations[uniqueName] = {} end EID.CustomTransformations[uniqueName][language] = displayName end ---Assigns transformations to an entity (Adds to existing transformations) ---Target entity identifier is in `Type.Variant.SubType` format and is formed based on `targetType` and `targetIdentifier`
---Example: adding "My Transformation" to Sad Onion: --- ```lua --- EID:assignTransformation("collectible", 1, "My Transformation") --- ``` ---@param targetType EID_TypeVariantAlias @Alias for a `Type.Variant` of entity ---@param targetIdentifier integer | string @If valid `targetType` is specified, `SubType` of the entity. Otherwise, the full entity identifier ---@param transformationString string @Transformation name function EID:assignTransformation(targetType, targetIdentifier, transformationString) local entryID = EID:getIDVariantString(targetType) if entryID ~= nil then entryID = entryID.."."..targetIdentifier else ---@cast targetIdentifier string entryID = targetIdentifier end EID:removeEntryFromString(EID.CustomTransformAssignments, entryID, transformationString) if EID.CustomTransformAssignments[entryID] == nil then EID.CustomTransformAssignments[entryID] = transformationString else EID.CustomTransformAssignments[entryID] = EID.CustomTransformAssignments[entryID]..","..transformationString end EID:removeEntryFromString(EID.CustomTransformRemovals, entryID, transformationString) end ---Try to automatically assign vanilla transformations to the entity ---@param collectibleID CollectibleType function EID:tryAutodetectTransformationsCollectible(collectibleID) if not EID.isRepentance then return end local config = EID.itemConfig:GetCollectible(collectibleID) -- evaulate all transformations that have an ItemTag field for transformIDString, data in pairs(EID.TransformationData) do if data.ItemTag ~= nil and config:HasTags(data.ItemTag) then EID:assignTransformation("collectible", collectibleID, transformIDString) end end end ---Removes a transformation of an entity ---Target entity identifier is in `Type.Variant.SubType` format and is formed based on `targetType` and `targetIdentifier`
---Example: removing "My Transformation" from Sad Onion: ---```lua ---EID:removeTransformation("collectible", 1, "My Transformation") ---``` ---@param targetType EID_TypeVariantAlias @Alias for a `Type.Variant` of entity ---@param targetIdentifier integer | string @If valid `targetType` is specified, `SubType` of the entity. Otherwise, the full entity identifier ---@param transformationString string @Transformation name function EID:removeTransformation(targetType, targetIdentifier, transformationString) local entryID = EID:getIDVariantString(targetType) if entryID ~= nil then entryID = entryID.."."..targetIdentifier else ---@cast targetIdentifier string entryID = targetIdentifier end EID:removeEntryFromString(EID.CustomTransformRemovals, entryID, transformationString) if EID.CustomTransformRemovals[entryID] == nil then EID.CustomTransformRemovals[entryID] = transformationString else EID.CustomTransformRemovals[entryID] = EID.CustomTransformRemovals[entryID]..","..transformationString end EID:removeEntryFromString(EID.CustomTransformAssignments, entryID, transformationString) end ---Removes a given value from the string inside a table. Example: "1,2,3", removing 2 will return "1,3" ---@param sourceTable table ---@param entryKey any ---@param entryValue string function EID:removeEntryFromString(sourceTable, entryKey, entryValue) if sourceTable[entryKey] == nil then return end local newEntry = "" ---@type string|nil for str in string.gmatch(sourceTable[entryKey], "([^,]+)") do local addToList = true for removeStr in string.gmatch(entryValue, "([^,]+)") do if removeStr == str then addToList = false end end if addToList then newEntry = newEntry..","..str end end ---@cast newEntry string newEntry = newEntry:sub(2) if newEntry == "" then newEntry = nil end sourceTable[entryKey] = newEntry end ---Adds a description for a an arbitrary Entity ---@param id EntityType ---@param variant integer ---@param subtype? integer @If `nil` or `-1`, it will affect all subtypes of that entity ---@param entityName string ---@param description string ---@param language? EID_LanguageCode @Default: "en_us" function EID:addEntity(id, variant, subtype, entityName, description, language) subtype = subtype or -1 language = language or EID.DefaultLanguageCode if id == EntityType.ENTITY_EFFECT then EID.effectList[variant] = true end EID:CreateDescriptionTableIfMissing("custom", language) EID.descriptions[language].custom[id .. "." .. variant .. "." .. subtype] = { subtype, entityName, description } end ---Adds a new icon object with the shortcut defined in the "shortcut" variable (e.g. "{{shortcut}}" = your icon). ---Shortcuts are case sensitive! Shortcuts can be overriden with this function to allow for full control over everything ---@param shortcut string ---@param animationName string ---@param animationFrame integer @-1 to play the animation ---@param width integer ---@param height integer ---@param leftOffset? integer @Default: -1 ---@param topOffset? integer @Default: 0 function EID:addIcon(shortcut, animationName, animationFrame, width, height, leftOffset, topOffset, spriteObject) leftOffset = leftOffset or -1 topOffset = topOffset or 0 EID.InlineIcons[shortcut] = {animationName, animationFrame, width, height, leftOffset, topOffset, spriteObject} end ---Adds a custom poop spell to T???'s poop descriptions. ---This spell should be added with the "Custom Poop API" library to actually appear in-game. ---Example: ---```lua ---EID:addCustomPoopSpell("MYPOOP", "I Made This Poop", "{{PoopSpell1}}", "Can be throwed to deal damage", "en_us") ---``` ---@param token string @The name of the spell in the Custom Poop API code ---@param name string @The name of the spell as it should appear in the description ---@param icon string @Markup icon of the poop ---@param description string ---@param language? EID_LanguageCode @Default: "en_us" function EID:addCustomPoopSpell(token, name, icon, description, language) EID.descriptions[language]["poopSpells"][token] = {icon, name, description, EID._currentMod} end ---Adds a new color object with the shortcut defined in the "shortcut" variable (e.g. "{{shortcut}}" = your color) ---Shortcuts are case sensitive! Shortcuts can be overriden with this function to allow for full control over everything ---Either `kColor` or `callback` must be provided ---@param shortcut string ---@param kColor? KColor @Constant color ---@param callback? fun(color:KColor):KColor @Called whenever the color markup is interpreted function EID:addColor(shortcut, kColor, callback) if callback ~= nil then EID.InlineColors[shortcut] = callback else EID.InlineColors[shortcut] = kColor end end ---Overrides all potentially displayed texts and permanently displays the given texts ---@see EID.hidePermanentText @Hides permanently displayed text object. ---@param descriptionObject EID_DescObj @Description object to display ---@param permName1 string ---@param permName2 string? function EID:displayPermanentText(descriptionObject, permName1, permName2) descriptionObject.PermanentTextEnglish = EID:getDescriptionEntryEnglish(permName1, permName2) EID.permanentDisplayTextObj = descriptionObject EID.isDisplayingPermanent = true end ---Hides permanently displayed text objects if they exist. function EID:hidePermanentText() EID.permanentDisplayTextObj = nil EID.isDisplayingPermanent = false end ---Turns entity type names into actual ingame ID.Variant pairs ---@param typeName EID_TypeVariantAlias function EID:getIDVariantString(typeName) if typeName == "collectible" or typeName == "collectibles" then return "5.100" elseif typeName == "trinket" or typeName == "trinkets" then return "5.350" elseif typeName == "card" or typeName == "cards" then return "5.300" elseif typeName == "pill" or typeName == "pills" or typeName == "horsepills" or typeName == "horsepill" then return "5.70" elseif typeName == "dice" then return "1000.76" end return nil end ---Turns entity type and variant into their EID table-name ---@param Type EntityType ---@param Variant integer ---@param SubType integer ---@return string function EID:getTableName(Type, Variant, SubType) local idString = Type.."."..Variant if idString == "5.100" then return "collectibles" elseif idString == "5.350" then return "trinkets" elseif idString == "5.300" then return "cards" elseif idString == "5.70" then if SubType <2049 then return "pills" else return "horsepills" end elseif idString == "1000.76" then return "dice" elseif idString == "1.0" then return "players" else return "custom" end end ---Loads a given font from a given file path and use it to render text ---@param fontFileName string ---@return boolean @True if the font was loaded successfully function EID:loadFont(fontFileName) ---@diagnostic disable EID.font:Load(fontFileName, "") -- GoG Version of game somehow wants a string as the second argument EID.font:SetMissingCharacter(2) ---@diagnostic enable if not EID.font:IsLoaded() then EID:WriteErrorMsg("Could not load font from '" .. EID.modPath .. "resources/font/default.fnt" .. "'") return false end return true end ---Returns if EID is displaying text right now ---@return boolean function EID:isDisplayingText() return EID.isDisplaying end ---Returns true, if curse of blind is active ---@return boolean function EID:hasCurseBlind() return game:GetLevel():GetCurses() & LevelCurse.CURSE_OF_BLIND > 0 end ---Returns the current text position ---@return Vector function EID:getTextPosition() local posVector = Vector(EID.UsedPosition.X, EID.UsedPosition.Y) -- Only apply position modifiers when not in Local Mode if EID.CurrentScaleType == "Size" then for _, modifier in pairs(EID.PositionModifiers) do posVector = posVector + modifier end end return posVector end ---Adds a text position modifier Vector, which will be applied to the text position variable. ---Useful to add small offsets. For example: for schoolbag HUD ---@param identifier string ---@param modifierVector Vector function EID:addTextPosModifier(identifier, modifierVector) EID.PositionModifiers[identifier] = modifierVector end ---Removes a text position modifier Vector ---@param identifier string function EID:removeTextPosModifier(identifier) EID.PositionModifiers[identifier] = nil end ---Changes the initial position of all eid descriptions ---Useful to totally alter and override the current initial Overlay position ---@param newPosVector Vector function EID:alterTextPos(newPosVector) EID.UsedPosition = newPosVector end ---Returns the entity that is currently described. Returns last described entity if currently not displaying text ---@return Entity? function EID:getLastDescribedEntity() return EID.lastDescriptionEntity end ---Appends a given string to the description of a given Description object ---@param descObj EID_DescObj ---@param appendString string function EID:appendToDescription(descObj, appendString) descObj.Description = descObj.Description..appendString end ---Returns the description object of a specific entity. ---Falls back to english if the objID isnt available ---@param entity Entity function EID:getDescriptionObjByEntity(entity) return EID:getDescriptionObj(entity.Type, entity.Variant, entity.SubType, entity) end ---Returns the description object of the specified entity. ---Falls back to english if the objID isnt available ---@param Type EntityType | integer ---@param Variant integer ---@param SubType integer ---@param entity? Entity ---@param checkModifiers? boolean @Default: true ---@return EID_DescObj function EID:getDescriptionObj(Type, Variant, SubType, entity, checkModifiers) local description = {} description.ObjType = Type description.ObjVariant = Variant description.ObjSubType = SubType local adjustedID = EID:getAdjustedSubtype(Type, Variant, SubType) description.fullItemString = Type.."."..Variant.."."..adjustedID description.Name = EID:getObjectName(Type, Variant, SubType) description.Entity = entity or nil local generatedModularDesc = false local hasIgnoreModular = EID.descriptions[EID:getLanguage()].IgnoreFullyAutomatedDescriptions and EID.descriptions[EID:getLanguage()].IgnoreFullyAutomatedDescriptions[description.fullItemString] if EID.FullyAutomatedDescriptions[description.fullItemString] and not hasIgnoreModular then local success, modularDescription = pcall(EID.GenerateDescription, nil, description) if success and modularDescription and modularDescription ~= "" then description.Description = modularDescription generatedModularDesc = true end end local tableEntry = EID:getDescriptionData(Type, Variant, SubType) if not generatedModularDesc then description.Description = tableEntry and tableEntry[3] or EID:getXMLDescription(Type, Variant, SubType) end description.Transformation = EID:getTransformation(Type, Variant, SubType) description.ModName = tableEntry and tableEntry[4] description.Quality = EID:getObjectQuality(description) description.Icon = EID:getObjectIcon(description) description.ItemPoolType = EID:getObjectItemPool(description) EID:getObjectItemTypeAndCharge(description) EID.DifferentEffectPlayers = {} if checkModifiers ~= false then description = EID:applyConditionals(description) description = EID:applyDescriptionModifier(description, SubType) end return description end ---Returns description Object from the legacy mod descriptions if they exist ---@param Type EntityType ---@param Variant integer ---@param SubType integer ---@return [string, string, string]? function EID:getLegacyModDescription(Type, Variant, SubType) local tableName = EID:getTableName(Type, Variant, SubType) local customDesc = __eidEntityDescriptions[Type.."."..Variant.."."..SubType] if tableName == "collectibles" and __eidItemDescriptions[SubType] then return {"","",__eidItemDescriptions[SubType]} elseif tableName == "trinkets" and __eidTrinketDescriptions[SubType] then return{"","", __eidTrinketDescriptions[SubType]} elseif tableName == "cards" and __eidCardDescriptions[SubType] then return {"","",__eidCardDescriptions[SubType]} elseif tableName == "pills" and __eidPillDescriptions[SubType-1] then return {"","",__eidPillDescriptions[SubType-1]} elseif customDesc~=nil then return {"", customDesc[1], customDesc[2]} end return nil end ---Apply Description Modifier to a given description object ---@param description EID_DescObj ---@param SubType integer ---@return EID_DescObj function EID:applyDescriptionModifier(description, SubType) for _,modifier in ipairs(EID.DescModifiers) do local result = modifier.condition(description) if type(result) == "table" then for _,callback in ipairs(result) do -- If the modifier loads a different description obj (which also goes through the modifier checks), we should stop our checks so text doesn't get printed twice if description.ObjSubType ~= SubType then break end description = callback(description) end elseif result then description = modifier.callback(description) end end return description end ---Returns the icon and mod name of a given EID description object as a preformatted description string ---@param descObj EID_DescObj ---@return string function EID:getModNameString(descObj) local modString = "" if EID.Config["ModIndicatorDisplay"] == "Both" or EID.Config["ModIndicatorDisplay"] == "Name only" then local modName = EID.ModIndicator[descObj.ModName] and EID.ModIndicator[descObj.ModName].Name or descObj.ModName modString = modString .. " {{"..EID.Config["ModIndicatorTextColor"].."}}" .. modName end local modIcon = EID.ModIndicator[descObj.ModName] and EID.ModIndicator[descObj.ModName].Icon if (EID.Config["ModIndicatorDisplay"] == "Both" or EID.Config["ModIndicatorDisplay"] == "Icon only") and modIcon then modString = modString .. "{{".. modIcon .."}}" end return modString end ---Attempts to merge two given Description objects into one ---@param oldDescObj EID_DescObj ---@param newDescObj EID_DescObj ---@return EID_DescObj function EID:mergeDescriptionObjects(oldDescObj, newDescObj) for k,v in pairs(oldDescObj) do if not newDescObj[k] then newDescObj[k] = v end end return newDescObj end ---Writes charge-related information to a given description object ---@param descObj EID_DescObj function EID:getObjectItemTypeAndCharge(descObj) if not (descObj.ObjType == 5 and descObj.ObjVariant == 100 and descObj.ObjSubType ~= nil) then return end local itemConfig = EID.itemConfig:GetCollectible(descObj.ObjSubType) if not itemConfig then return end descObj.ItemType = itemConfig.Type or -1 if descObj.ItemType == ItemType.ITEM_ACTIVE then descObj.ChargeType = itemConfig.ChargeType or 0 descObj.Charges = itemConfig.MaxCharges or 0 -- Special handling for dynamic charge items if EID.isRepentance and (descObj.ObjSubType == CollectibleType.COLLECTIBLE_BLANK_CARD or descObj.ObjSubType == CollectibleType.COLLECTIBLE_PLACEBO or descObj.ObjSubType == CollectibleType.COLLECTIBLE_CLEAR_RUNE or descObj.ObjSubType == CollectibleType.COLLECTIBLE_D_INFINITY) then descObj.ChargeType = ItemConfig.CHARGE_SPECIAL end end end ---Returns the specified object string in the current language. ---Falls back to english if it doesnt exist, unless `noFallback` is true ---@param objTable string ---@param objIdentifier? integer | string ---@param noFallback? boolean ---@return string function EID:getDescriptionEntry(objTable, objIdentifier, noFallback) if not objIdentifier then if noFallback then return EID.descriptions[EID:getLanguage()][objTable] else return EID.descriptions[EID:getLanguage()][objTable] or EID.descriptions[EID.DefaultLanguageCode][objTable] end else local translatedTable = EID.descriptions[EID:getLanguage()][objTable] local description if noFallback then description = translatedTable and translatedTable[objIdentifier] else description = (translatedTable and translatedTable[objIdentifier]) or (EID.descriptions[EID.DefaultLanguageCode][objTable] and EID.descriptions[EID.DefaultLanguageCode][objTable][objIdentifier]) end --Try looking for a -1 that would encompass all subtypes of the variant -- Safety check for if the identifier contains "."(dot) 1-2 times and only contains numbers local strNoDots, numDots = string.gsub(objIdentifier, "%.","") if not description and tonumber(strNoDots) and (numDots == 1 or numDots == 2) then local subtype for i = string.len(objIdentifier), 1, -1 do if string.sub(objIdentifier, i, i) == "." then subtype = string.sub(objIdentifier, i + 1, -1) objIdentifier = string.gsub(objIdentifier, "." .. subtype, "") .. ".-1" if subtype ~= "-1" then return EID:getDescriptionEntry(objTable, objIdentifier, noFallback) end break end end end return description end end ---Returns the specified object string in english. ---@param objTable string ---@param objID? any ---@return string function EID:getDescriptionEntryEnglish(objTable, objID) if not objID then return EID.descriptions[EID.DefaultLanguageCode][objTable] else return EID.descriptions[EID.DefaultLanguageCode][objTable] and EID.descriptions[EID.DefaultLanguageCode][objTable][objID] end end ---Returns the description data table related to a given id, variant and subtype. ---Falls back to english if it doesnt exist ---@param Type EntityType ---@param Variant integer ---@param SubType integer ---@return string function EID:getDescriptionData(Type, Variant, SubType) local fullString = Type.."."..Variant local adjustedID = EID:getAdjustedSubtype(Type, Variant, SubType) local moddedDesc = EID:getDescriptionEntry("custom", fullString.."."..adjustedID) local fallbackDesc = EID:getDescriptionEntry("custom", fullString..".-1") local tableName = EID:getTableName(Type, Variant, SubType) local legacyModdedDescription = EID:getLegacyModDescription(Type, Variant, adjustedID) local defaultDesc = EID:getDescriptionEntry(tableName, adjustedID) return moddedDesc or fallbackDesc or legacyModdedDescription or defaultDesc end ---Returns an adjusted SubType for special cases like Horse Pills and Golden Trinkets ---@param Type EntityType ---@param Variant integer ---@param SubType integer ---@return integer function EID:getAdjustedSubtype(Type, Variant, SubType) local tableName = EID:getTableName(Type, Variant, SubType) if tableName == "trinkets" then if EID.isRepentance then return (SubType & TrinketType.TRINKET_ID_MASK) end elseif tableName == "pills" or tableName == "horsepills" then -- The effect of a pill varies depending on what player is looking at it in co-op -- EID.pillPlayer is a way to recheck a pill for what different players will turn it into local player = EID.pillPlayer or EID.player if EID.isRepentance and SubType % PillColor.PILL_GIANT_FLAG == PillColor.PILL_GOLD then return 9999 end local pool = game:GetItemPool() if EID.isRepentance and player:GetPlayerType() == PlayerType.PLAYER_THESOUL_B then SubType = pool:GetPillEffect(SubType, player:GetOtherTwin() or player) + 1 else SubType = pool:GetPillEffect(SubType, player) + 1 end end return SubType end ---Get the transformation uniqueName / ID of a given entity ---Example: ---```lua ---EID:getTransformation(5,100,34) --Returns "12" which is the id for Bookworm ---``` ---@param id EntityType ---@param variant integer ---@param subType integer ---@return string function EID:getTransformation(id, variant, subType) local adjustedSubtype = EID:getAdjustedSubtype(id, variant, subType) local entityString = id.."."..variant.."."..adjustedSubtype local listToTest = "" local default = EID.EntityTransformations[entityString] if default~= nil then listToTest = default end if id == 5 and variant == 100 then local customLegacy = __eidItemTransformations[adjustedSubtype] if customLegacy~= nil then listToTest = listToTest..","..customLegacy end end local custom = EID.CustomTransformAssignments[entityString] if custom~= nil then listToTest = listToTest..","..custom end local transformationList = "" local removedList = EID.CustomTransformRemovals[entityString] for transform in string.gmatch(listToTest, "([^,]+)") do local isRemoved = false if removedList ~= nil then for removedTransform in string.gmatch(removedList, "([^,]+)") do if transform == removedTransform then isRemoved = true end end end if not isRemoved then transformationList = transformationList..","..transform end end return transformationList end ---Get the name of the given transformation by its uniqueName / ID ---@param id string ---@return string function EID:getTransformationName(id) local str = "Custom" if tonumber(id) == nil then -- get translated custom name local customTransform = EID.CustomTransformations[id] if customTransform ~= nil then return customTransform[EID:getLanguage()] or customTransform[EID.DefaultLanguageCode] or id end return id end return EID:getDescriptionEntry("transformations")[tonumber(id) + 1] or str end ---Tries to get the ingame name of an item based on its ID ---@param Type EntityType ---@param Variant integer ---@param SubType integer ---@return string function EID:getObjectName(Type, Variant, SubType) local fallbackName = Type.."."..Variant.."."..SubType local tableName = EID:getTableName(Type, Variant, SubType) -- try to get name from EID.ItemNames table local translatedName = EID.ItemNames[EID:getLanguage()][fallbackName] local itemNameTableEntry = translatedName ~= "" and translatedName or EID.ItemNames[EID.DefaultLanguageCode][fallbackName] -- return itemName table entry if found and not pills/horsepills -- 'EID.ItemNames[EID.DefaultLanguageCode][fallbackName]' can also be empty check if it is empty again, to check ItemConfig stuff propery if itemNameTableEntry and itemNameTableEntry ~= "" and tableName ~= "pills" and tableName ~= "horsepills" then return itemNameTableEntry end local tableEntry = EID:getDescriptionData(Type, Variant, SubType) local name = nil if tableEntry ~= nil then if tableEntry[2] ~= nil and tableEntry[2] ~= "" and tableEntry[2] ~= EID.descriptions[EID.DefaultLanguageCode][tableName][SubType] then name = tableEntry[2] end end if tableName == "collectibles" then if EID.itemConfig:GetCollectible(SubType) == nil then return fallbackName end local vanillaName = EID.itemConfig:GetCollectible(SubType).Name local englishName = EID.descriptions[EID.DefaultLanguageCode][tableName][SubType] and EID.descriptions[EID.DefaultLanguageCode][tableName][SubType][2] return name or (not string.find(vanillaName, "^#") and vanillaName) or englishName or vanillaName elseif tableName == "trinkets" then local adjustedSubtype = EID:getAdjustedSubtype(Type, Variant, SubType) if EID.itemConfig:GetTrinket(adjustedSubtype) == nil then return fallbackName end local vanillaName = EID.itemConfig:GetTrinket(adjustedSubtype).Name local englishName = EID.descriptions[EID.DefaultLanguageCode][tableName][adjustedSubtype] and EID.descriptions[EID.DefaultLanguageCode][tableName][adjustedSubtype][2] return name or (not string.find(vanillaName, "^#") and vanillaName) or englishName or vanillaName elseif tableName == "cards" then if EID.itemConfig:GetCard(SubType) == nil then return fallbackName end local vanillaName = EID.itemConfig:GetCard(SubType).Name local englishName = EID.descriptions[EID.DefaultLanguageCode][tableName][SubType] and EID.descriptions[EID.DefaultLanguageCode][tableName][SubType][2] return name or (not string.find(vanillaName, "^#") and vanillaName) or englishName or vanillaName elseif tableName == "pills" or tableName == "horsepills" then local adjustedSubtype = EID:getAdjustedSubtype(Type, Variant, SubType) return EID:getPillName(adjustedSubtype, tableName == "horsepills") elseif tableName == "dice" then return EID:getDescriptionEntry("diceHeader").." ("..SubType..")" elseif tableName == "players" then return EID:getPlayerName(SubType) elseif tableName == "custom" then local xmlName = EID:GetEntityXMLName(Type, Variant, SubType) return name or xmlName or fallbackName end return fallbackName end ---Returns the name of a player based on their ID ---@param id PlayerType ---@param altFallback? string ---@return string function EID:getPlayerName(id, altFallback) local playerInfo = EID:getDescriptionEntry("CharacterInfo")[id] local birthrightInfo = EID.isRepentance and EID:getDescriptionEntry("birthright")[id+1] return (playerInfo and playerInfo[1]) or (birthrightInfo and birthrightInfo[1]) or altFallback or EID:findPlayerName(id) or "???" end ---Get the name of a given player ID by checking for a matching EntityPlayer. ---This is for modded characters, whose name is best found by doing EntityPlayer:GetName() ---@param id PlayerType ---@return string? function EID:findPlayerName(id) local _, entityPlayer = EID:PlayersHaveCharacter(id, false) if entityPlayer then return entityPlayer:GetName() end end ---Returns the name of a pill based on the pilleffect id ---@param pillID PillEffect | integer ---@param isHorsepill boolean ---@return string function EID:getPillName(pillID, isHorsepill) local moddedDesc = EID:getDescriptionEntry("custom", "5.70."..pillID) local legacyModdedDescription = EID:getLegacyModDescription(5, 70, pillID) local tableName = isHorsepill and "horsepills" or "pills" local defaultDesc = EID:getDescriptionEntry(tableName, pillID) local name = moddedDesc or legacyModdedDescription or defaultDesc local vanillaName = "" if pillID == 9999 then vanillaName = "Golden Pill" -- only used for languages that haven't defined a Golden Pill name else if EID.itemConfig:GetPillEffect(pillID - 1) == nil then return "" else vanillaName = EID.itemConfig:GetPillEffect(pillID - 1).Name end end local translatedName = EID.ItemNames[EID:getLanguage()]["5.70."..pillID-1] name = name and name[2] or translatedName or (not string.find(vanillaName, "^#") and vanillaName) or EID.descriptions[EID.DefaultLanguageCode][tableName][pillID][2] or vanillaName name = string.gsub(name,"I'm Excited!!!","I'm Excited!!") -- prevent markup trigger return name end ---Tries to get the ingame description of an object, based on their description in the XML files ---@param Type EntityType ---@param Variant integer ---@param SubType integer ---@return string function EID:getXMLDescription(Type, Variant, SubType) local tableName = EID:getTableName(Type, Variant, SubType) if SubType == 0 then return "(no description available)" end local itemConfig = nil if tableName == "collectibles" then itemConfig = EID.itemConfig:GetCollectible(SubType) elseif tableName == "trinkets" then itemConfig = EID.itemConfig:GetTrinket(SubType) elseif tableName == "cards" and EID.itemConfig:GetCard(SubType) then itemConfig = EID.itemConfig:GetCard(SubType) end return itemConfig and itemConfig.Description or "(no description available)" end ---Check if an entity is part of the describable entities ---@param entity Entity | GridEntity ---@return boolean ---@diagnostic disable: param-type-mismatch ---@diagnostic disable-next-line: duplicate-set-field function EID:hasDescription(entity) if not EID:EntitySanityCheck(entity) then return false end if EID:IsGridEntity(entity) then return EID:CheckGridEntityHasDescription(entity) end local isAllowed = false local entityString = entity.Type .. "." .. entity.Variant .. "." .. entity.SubType -- dont describe entity when EID_Hide data is set if EID:getEntityData(entity, "EID_Hide") then return false end if EID.IgnoredEntities[entity.Type .. "." .. entity.Variant] or EID.IgnoredEntities[entityString] then return false end if EID.Config["EnableEntityDescriptions"] and EID:getTableName(entity.Type, entity.Variant, entity.SubType) == "custom" then isAllowed = isAllowed or __eidEntityDescriptions[entityString] ~= nil isAllowed = isAllowed or EID:getDescriptionData(entity.Type, entity.Variant, entity.SubType) ~= nil isAllowed = isAllowed or EID:getEntityData(entity, "EID_Description") ~= nil end if entity.Type == EntityType.ENTITY_PICKUP then if entity.SubType == 0 and EID:getDescriptionData(entity.Type, entity.Variant, entity.SubType) ~= nil then -- allow pickups with subtype 0 to have a description, if its explictly defined return true end isAllowed = isAllowed or (entity.Variant == PickupVariant.PICKUP_COLLECTIBLE and EID.Config["DisplayItemInfo"]) isAllowed = isAllowed or (entity.Variant == 110 and entity:GetSprite():IsPlaying("Idle") and EID.Config["DisplayItemInfo"]) -- Broken Shovel isAllowed = isAllowed or (entity.Variant == PickupVariant.PICKUP_TRINKET and EID.Config["DisplayTrinketInfo"]) isAllowed = isAllowed or (entity.Variant == PickupVariant.PICKUP_TAROTCARD and EID.Config["DisplayCardInfo"]) isAllowed = isAllowed or (entity.Variant == PickupVariant.PICKUP_PILL and EID.Config["DisplayPillInfo"]) ---@diagnostic disable-next-line: return-type-mismatch return isAllowed and (entity.SubType > 0 or -- For Flip descriptions, allow 5.100.0 pedestals to have descriptions under VERY specific criteria! (EID.isRepentance and EID:getEntityData(entity, "EID_FlipItemID") and EID:PlayersHaveCollectible(CollectibleType.COLLECTIBLE_FLIP))) end if entity.Type == 6 and entity.Variant == 16 and EID.Config["DisplayCraneInfo"] and EID.isRepentance then if REPENTOGON then isAllowed = not entity:GetSprite():IsPlaying("Broken") and not entity:GetSprite():IsPlaying("OutOfPrizes") and entity:ToSlot():GetPrizeCollectible() ~= -1 else isAllowed = not entity:GetSprite():IsPlaying("Broken") and not entity:GetSprite():IsPlaying("OutOfPrizes") and (EID.CraneItemType[entity.InitSeed.."Drop"..entity.DropSeed] or EID.CraneItemType[tostring(entity.InitSeed)]) end end if entity.Type == 1000 then -- Handle Card Reading Portals if entity.Variant == 161 then if entity.SubType <= 2 or (EID.isRepentancePlus and entity.SubType == 3) then isAllowed = true end end -- Dice floor information if entity.Variant == EffectVariant.DICE_FLOOR and EID.Config["DisplayDiceInfo"] then isAllowed = true end end return isAllowed end ---Replaces shorthand-representations of a character with the internal reference ---@param text string ---@return string function EID:replaceShortMarkupStrings(text) for _, pair in ipairs(EID.TextReplacementPairs) do text = string.gsub(text, pair[1], pair[2]) end return text end local VariantToColorText = { [100] = "{{ColorYellow}}", [350] = "{{ColorYellow}}", [300] = "{{ColorCard}}", [70] = "{{ColorPill}}" } ---Replaces name markup objects with the actual name ---@param text string ---@return string function EID:replaceNameMarkupStrings(text) for word in string.gmatch(text, "{{Name.-}}") do local strTrimmed = string.gsub(word, "{{Name(.-)}}", function(a) return a end) local showIcon = true -- If the markup is {{NameOnlyX###}}, do not print an icon before the name if string.find(text, "NameOnly") then strTrimmed = string.gsub(word, "{{NameOnly(.-)}}", function(a) return a end) showIcon = false end local indicator = string.sub(strTrimmed, 1, 1) local id = tonumber(string.sub(strTrimmed, 2, -1)) ---@cast id integer local name = "" if tonumber(indicator) then local entityID = {} for e in string.gmatch(strTrimmed, "([^.]*)") do table.insert(entityID, tonumber(e)) end local iconText = "" local colorText = "" if entityID[1] == 1 then if showIcon then iconText = EID:GetPlayerIcon(entityID[3]) .. " " end colorText = "{{ColorIsaac}}" else if showIcon then iconText = "{{" .. EID:GetIconNameByVariant(entityID[2]) .. entityID[3] .. "}} " end colorText = VariantToColorText[entityID[2]] or "" end if entityID[2] == 70 then name = iconText .. colorText .. EID:getPillName(entityID[3], false) .. "{{CR}}" else name = iconText .. colorText .. EID:getObjectName(entityID[1], entityID[2], entityID[3]) .. "{{CR}}" end elseif indicator == "C" then -- Collectible name = (showIcon and "{{Collectible"..id.."}} " or "") .. "{{ColorYellow}}" .. EID:getObjectName(5, 100, id) .. "{{CR}}" elseif indicator == "T" then -- Trinket name = (showIcon and "{{Trinket"..id.."}} " or "") .. "{{ColorYellow}}" .. EID:getObjectName(5, 350, id) .. "{{CR}}" elseif indicator == "P" then -- Pills name = (showIcon and "{{Pill"..id.."}} " or "") .. "{{ColorPill}}" .. EID:getPillName(id, false) .. "{{CR}}" elseif indicator == "K" then -- Card name = (showIcon and "{{Card"..id.."}} " or "") .. "{{ColorCard}}" .. EID:getObjectName(5, 300, id) .. "{{CR}}" elseif indicator == "I" then -- Player (I for Isaac) name = (showIcon and EID:GetPlayerIcon(id) .. " " or "") .. "{{ColorIsaac}}" .. EID:getPlayerName(id) .. "{{CR}}" end text = string.gsub(text, word, name, 1) end return text end ---Generates a string with the defined pixel-length using a custom 1px wide character. ---This will only work for this specific custom font. ---@param length integer ---@return string function EID:generatePlaceholderString(length) return string.rep("ยค", length) end ---Returns the inlineIcon object of a given Iconstring. ---Can be used to validate an iconstring ---@param str string ---@return EID_Icon function EID:getIcon(str) if str == nil then return EID.InlineIcons["ERROR"] end local strTrimmed = string.gsub(str,"{{(.-)}}",function(a) return a end ) if #strTrimmed <= #str then local itemIconObj = EID:createItemIconObject(strTrimmed) ---@diagnostic disable-next-line: return-type-mismatch if itemIconObj then return itemIconObj end if type(EID.InlineIcons[strTrimmed]) == "function" then return EID.InlineIcons[strTrimmed](str) or EID.InlineIcons["ERROR"] end ---@diagnostic disable-next-line: return-type-mismatch return EID.InlineIcons[strTrimmed] or EID.InlineIcons["ERROR"] else return EID.InlineIcons["ERROR"] end end ---Tries to read special markup used to generate icons for all Collectibles/Trinkets and the default Cards/Pills ---Returns an inlineIcon Object or nil if no parsing was possible ---@param str string ---@return EID_Icon? function EID:createItemIconObject(str) local item = nil local backgroundImage = "" local subTypeIdentifier = 0 -- Check for {{Item#.##.###}} markup; easiest way to handle it is to just call this function again local itemID,numReplaceGeneric = string.gsub(str, "Item", "") if numReplaceGeneric > 0 and itemID ~= "" then local Type, Var, Sub = EID:SplitTVS(itemID) if Type == 1 then return EID:getIcon("Player" .. Sub) elseif Type == 5 then local iconName = EID:GetIconNameByVariant(Var) if iconName then return EID:createItemIconObject(iconName .. Sub) end end end -- Add background image to Icon, if the keyword can be found in the markup string for keyword, imagePath in pairs(EID.MarkupBackgroundKeywords) do if string.find(str, keyword) then str = string.gsub(str, keyword, "") backgroundImage = imagePath break end end ---@diagnostic disable local collID,numReplace = string.gsub(str, "Collectible", "") if numReplace > 0 and collID ~= "" and tonumber(collID) ~= nil then item = EID.itemConfig:GetCollectible(tonumber(collID)) subTypeIdentifier = tonumber(collID) end local trinketID,numReplace2 = string.gsub(str, "Trinket", "") if numReplace2 > 0 and trinketID ~= "" and tonumber(trinketID) ~= nil then item = EID.itemConfig:GetTrinket(tonumber(trinketID)) subTypeIdentifier = tonumber(trinketID) end local cardID,numReplace3 = string.gsub(str, "Card", "") if numReplace3 > 0 and cardID ~= "" and tonumber(cardID) ~= nil then if tonumber(cardID) > maxCardID then return EID.InlineIcons[str] or EID.InlineIcons["Card"] end return {"Cards", tonumber(cardID)-1, 8, 8, 0, 1, EID.CardPillSprite} end local pillID,numReplace4 = string.gsub(str, "Pill", "") if numReplace4 > 0 and pillID ~= "" and tonumber(pillID) ~= nil then if tonumber(pillID % 2048) > maxPillID then return EID.InlineIcons[str] or EID.InlineIcons["Pill"] end return {"Pills", tonumber(pillID % 2048)-1, 9, 8, 0, 1, EID.CardPillSprite} end ---@diagnostic enable if item == nil then return nil end if dynamicSpriteCache[backgroundImage .. str] then return dynamicSpriteCache[backgroundImage .. str] else local spriteDummy = Sprite() spriteDummy:Load("gfx/eid_inline_icons.anm2", true) spriteDummy:ReplaceSpritesheet(0, backgroundImage) spriteDummy:ReplaceSpritesheet(1, item.GfxFileName) spriteDummy:LoadGraphics() local newDynamicSprite = { "ItemIcon", subTypeIdentifier, 11, 8, -2, -2, spriteDummy } dynamicSpriteCache[backgroundImage .. str] = newDynamicSprite return newDynamicSprite end end ---Returns the icon for a given transformation name or ID ---@param str string ---@return EID_Icon function EID:getTransformationIcon(str) if str == nil then return EID.InlineIcons["ERROR"] end if tonumber(str) ~= nil then str = EID.descriptions[EID.DefaultLanguageCode].transformations[tonumber(str + 1)] end local transformSprite = EID:getIcon(str:gsub(" ", "")) if transformSprite[1] == "ERROR" then transformSprite = EID:getIcon("CustomTransformation") end return transformSprite end ---Returns the width of a given string in Pixels ---@param str string ---@return integer function EID:getStrWidth(str) return EID.font:GetStringWidthUTF8(str) end ---Searches through the given string and replaces Icon placeholders with icons. ---Returns 2 values. the string without the placeholders but with an accurate space between lines. And a table of all Inline Sprites ---@param text string ---@param renderBulletPointIcon boolean? ---@return string, [EID_Icon, integer, function?][] function EID:filterIconMarkup(text, renderBulletPointIcon) local spriteTable = {} for word in string.gmatch(text, "{{.-}}") do local textposition = string.find(text, word) or 1 local callback = EID._NextIconModifier EID._NextIconModifier = nil local lookup = EID:getIcon(word) local preceedingTextWidth = EID:getStrWidth(string.sub(text, 0, textposition - 1)) * EID.Scale -- Center the small bullet point icons by adding an extra left offset to them. -- If the icon has a positive left offset, that means the offset is already included in the icon itself and we should not add it again if renderBulletPointIcon and lookup[3] < 11 and (#lookup < 5 or lookup[5] <= 0) then preceedingTextWidth = preceedingTextWidth + math.floor((12 - lookup[3]) / 2) * EID.Scale end table.insert(spriteTable, {lookup, preceedingTextWidth, callback}) text = string.gsub(text, word, EID:generatePlaceholderString(lookup[3]), 1) end return text, spriteTable end ---Renders a list of given inline sprite objects returned by the "EID:filterIconMarkup()" function ---@param spriteTable [EID_Icon, integer?, fun(spriteObj:Sprite)?][] ---@param posX integer ---@param posY integer function EID:renderInlineIcons(spriteTable, posX, posY) for _, sprite in ipairs(spriteTable) do local Xoffset = sprite[1][5] or -1 local Yoffset = sprite[1][6] or 0 local additionalXOffset = sprite[2] or 0 local spriteObj = (type(sprite[1][7]) == "function" and sprite[1][7]()) or sprite[1][7] or EID.InlineIconSprite if sprite[1][2] >= 0 then spriteObj:SetFrame(sprite[1][1], sprite[1][2]) elseif not spriteObj:IsPlaying(sprite[1][1]) or spriteObj:IsFinished(sprite[1][1]) then spriteObj:Play(sprite[1][1],true) else spriteObj:Update() end EID:renderIcon(spriteObj, posX + additionalXOffset + Xoffset * EID.Scale, posY + Yoffset * EID.Scale, sprite[3], sprite[1][1], sprite[1][2]) end end ---Helper function to render Icons in specific EID settings ---@param spriteObj Sprite ---@param posX integer ---@param posY integer ---@param callback fun(spriteObj: Sprite) | nil ---@param animName string | nil ---@param animFrame integer | nil function EID:renderIcon(spriteObj, posX, posY, callback, animName, animFrame) spriteObj.Scale = Vector(EID.Scale, EID.Scale) spriteObj.Color = Color(1, 1, 1, EID.Config["Transparency"], 0, 0, 0) if callback then callback(spriteObj) end if EID.CachingDescription then table.insert(EID.CachedIcons[#EID.CachedIcons], {spriteObj, posX, posY, callback, animName, animFrame}) end spriteObj:Render(Vector(posX, posY), nullVector, nullVector) end ---Returns the icon used for the bulletpoint. It will look at the first word in the given string. ---Also returns the first word if it was rejected (so it can be removed from the line) ---@param text string ---@return string, string? function EID:handleBulletpointIcon(text, ignoreBPConfig) -- Find the position where '}}' is followed by a space or letter local firstMarkupPos, _ = string.find(text, "{{.-}}") local startPos, endPos = string.find(text, "}}%s") -- check if string starts with markup and has if firstMarkupPos and startPos and firstMarkupPos == 1 then -- The split should be right after '}}', so adjust the position local firstWord = string.sub(text, 1, endPos - 1) firstWord = EID:removeColorMarkup(firstWord) if EID:getIcon(firstWord) ~= EID.InlineIcons["ERROR"] and string.find(firstWord, "{{.-}}")~=nil then if ignoreBPConfig and not EID.Config["StatAndPickupBulletpoints"] and EID.StatPickupBulletpointBlacklist[firstWord] then return "\007", firstWord end return firstWord end end return "\007" end -- Find indentation markup, remove it and return the modified string and the number of indentations -- position of the markup in the text is irrelevant. it only affects the indentation of the bullet point ---@param text string ---@return string, integer function EID:handleTextIndentation(text) return string.gsub(text, "{{Indent}}", "") end local colorFunc = nil ---Gets a KColor from a Markup-string (example Input: "{{ColorText}}"). ---Returns the KColor object and a boolean value indicating if the given string was a color markup or not ---@param str string ---@param baseKColor KColor ---@return KColor, boolean function EID:getColor(str, baseKColor) local color = baseKColor ---@type KColor | function local isColorMarkup = false if str ~= nil then local strTrimmed = string.gsub(str,"{{(.-)}}",function(a) return a end, 1) if #strTrimmed <= #str then if type(EID.InlineColors[strTrimmed]) == "function" then colorFunc = EID.InlineColors[strTrimmed] color = EID.InlineColors[strTrimmed](color) else if EID.InlineColors[strTrimmed] then colorFunc = nil end color = EID.InlineColors[strTrimmed] or color if EID.Config["ColorblindMode"] > 0 then color = EID.ColorBlindColors[EID.Config["ColorblindMode"]][strTrimmed] or color end end isColorMarkup = type(EID.InlineColors[strTrimmed]) ~= type(nil) end end ---@cast color KColor color = EID:copyKColor(color) color.Alpha = math.min(color.Alpha, EID.Config["Transparency"]) return color, isColorMarkup end ---Filters a given string and looks for Colormarkup. Splits the text into subsections limited by them. ---Returns: Table of subsections of the text, their respective KColor, and the width of the subsection ---@param text string ---@param baseKColor KColor ---@return [string, KColor, integer][] function EID:filterColorMarkup(text, baseKColor) local textPartsTable = {} local lastColor = baseKColor local lastFunc = colorFunc local lastPosition = 0 for word in string.gmatch(text, "{{.-}}") do local textposition = string.find(text, word) ---@cast textposition integer local lookup, isColor = EID:getColor(word, lastColor) if isColor then local preceedingText = string.sub(text, lastPosition, textposition - 1) local preceedingTextWidth = EID:getStrWidth(preceedingText) * EID.Scale lastPosition = textposition table.insert(textPartsTable, {preceedingText, lastColor, preceedingTextWidth, lastFunc}) lastColor = lookup lastFunc = colorFunc text = string.gsub(text, word, "", 1) end end table.insert(textPartsTable, {string.sub(text, lastPosition), lastColor, 0, lastFunc}) return textPartsTable end ---Removes color markup, preserves bulletpoint icons after start-of-line color markup ---@param text string ---@return string function EID:removeColorMarkup(text) for word in string.gmatch(text, "{{Color.-}}") do text = string.gsub(text, word, "", 1) end return text end ---Replaces all markup {{ }} with placeholder strings, to use in fitTextToWidth ---@param text string ---@param checkBulletpoint? boolean ---@return string, integer? function EID:replaceAllMarkupWithSpaces(text, checkBulletpoint) if checkBulletpoint then -- Check for the text to just be a bulletpoint icon, which should be considered as zero width -- (fixTextToWidth uses this function one word at a time) if EID:getIcon(text:gsub(" ", "")) ~= EID.InlineIcons["ERROR"] and string.find(text, "{{.-}}")~=nil then return "" end end -- iconsFound is used to make the next space after a markup icon be immune to line breaks, but only if it's just one icon with no other text local iconsFound = 0; if text:gsub(" ", ""):gsub("{{.-}}","") ~= "" then iconsFound = math.mininteger end for word in string.gmatch(text, "{{.-}}") do local lookup = EID:getIcon(word) if lookup[1] ~= "ERROR" then text = string.gsub(text, word, EID:generatePlaceholderString(lookup[3]), 1) iconsFound = iconsFound + 1 else text = string.gsub(text, word, "", 1) end end return text, iconsFound end ---Fits a given string to a specific width ---@param str string ---@param textboxWidth integer ---@param breakUtf8Chars boolean ---@return string[] @Input string split into lines function EID:fitTextToWidth(str, textboxWidth, breakUtf8Chars) local formattedLines = {} -- Lines with a {{NoLineBreak}} tag should be left in one continuous line if str:find("{{NoLineBreak}}") or str:find("{{NoLB}}") then str = str:gsub("{{NoLineBreak}}",""):gsub("{{NoLB}}","") table.insert(formattedLines,str) return formattedLines end local curLength = 0 local text = {} -- the first word we run into might actually be a bulletpoint icon, which should be zero width local isBulletpoint = true -- if we find an icon by itself, we should skip the following space local previousWordWasIcon = false local cursor = 1 local word_begin_index = 1 local byte = string.byte -- for speed up local sub = string.sub local byte_space = string.byte(' ') while cursor <= #str do -- ascii word: 0x0xxxxxxx -- utf8 word (sequence): 0x11xxxxxx 0x10xxxxxx 0x10xxxxxx ... 0x10xxxxxx -- see https://en.wikipedia.org/wiki/UTF-8 -- we can only break after space, or before 0x11xxxxxx local cur, next = byte(str,cursor), byte(str,cursor+1) if -- cond#1: we can break at the end of string cursor == #str or -- cond#2: we can break after space cur == byte_space or -- handle utf8 characters (breakUtf8Chars and( -- cond#3: we can break if the next character is 0x11xxxxxx ((next & 0xC0) == 0xC0) or -- cond#4: we can also break if the current is 0x10xxxxxx while the next is ascii but not space (((cur & 0xC0) == 0x80) and (next~= byte_space and (next & 0x80) == 0x00)) )) then --[[ -------------------ascii only--------------- word will be separated by spaces. wordA |wordB |wordC |^%@&Q%#^&#@!! |aksldj โ†‘ cond#2 we may break after every space. spaces | | | | | |woops โ†‘ cond#2 -------------------UTF8 only---------------- word is 0x11xxxxxx followed by several 0x10xxxxxx until the next 0x11xxxxxx (not included) ไฝ |ๅฅฝ|๏ผŒ|ไธ–|็•Œ|๏ผ|โ†‘|โ†‘|โ†‘|โ†‘ โ†‘ cond#3 byte data of a word: [0x11xxxxxx,0x10xxxxxx,0x10xxxxxx, (next is 0x11xxxxxx, cond#3)] --------------------ascii->utf8------------ utf8 word must start with 0x11xxxxxx, so cond#3 split before it. word|ไธ–|็•Œ | โ†‘ โ†‘ cond#3 cond#3 world |means |ไธ–|็•Œ โ†‘ cond#2 -------------------utf8->ascii-------------- ไธ–|็•Œ|ๆ˜ฏ|world | โ†‘ โ†‘ cond#4 cond#3 ไธ–|็•Œ|ๆ˜ฏ |world | โ†‘ โ†‘ cond#2 cond#3 ]] -- we can break after str[cursor] local word = sub(str, word_begin_index, cursor) local wordFiltered, iconsFound = EID:replaceAllMarkupWithSpaces(word, isBulletpoint) isBulletpoint = false local wordLength = EID:getStrWidth(wordFiltered) if curLength + wordLength <= textboxWidth or curLength < 17 or previousWordWasIcon then table.insert(text, word) curLength = curLength + wordLength else table.insert(formattedLines, table.concat(text)) text = { word } curLength = wordLength end previousWordWasIcon = (iconsFound == 1) -- next word starts here word_begin_index = cursor + 1 end cursor = cursor + 1 end -- fix this function applying crafting icon green tint to the start of the printed line EID._NextIconModifier = nil table.insert(formattedLines, table.concat(text)) return formattedLines end ---Renders a given string using the EID Custom font. This will also apply any markup and render icons ---Should be called in a render callback ---@param str string ---@param position Vector ---@param scale Vector ---@param kcolor KColor ---@param renderBulletPointIcon boolean? ---@return KColor function EID:renderString(str, position, scale, kcolor, renderBulletPointIcon) str = EID:replaceShortMarkupStrings(str) local textPartsTable = EID:filterColorMarkup(str, kcolor) local offsetX = 0 for _, textPart in ipairs(textPartsTable) do local strFiltered, spriteTable = EID:filterIconMarkup(textPart[1], renderBulletPointIcon) EID:renderInlineIcons(spriteTable, position.X + offsetX, position.Y) if strFiltered then -- prevent possible crash when strFiltered is nil EID.font:DrawStringScaledUTF8(strFiltered, position.X + offsetX, position.Y, scale.X, scale.Y, textPart[2], 0, false) if EID.CachingDescription then table.insert(EID.CachedStrings[#EID.CachedStrings], {strFiltered, position.X + offsetX, position.Y, textPart[2], textPart[4], EID.Config["Transparency"]}) end offsetX = offsetX + EID:getStrWidth(strFiltered) * scale.X end end return textPartsTable[#textPartsTable][2] end ---Adds Description object modifiers such as Spindown dice or Tarot Cloth ---@param modifierName string ---@param condition fun(descObj: EID_DescObj): boolean ---@param callback fun(descObj: EID_DescObj): EID_DescObj ---@param position? integer function EID:addDescriptionModifier(modifierName, condition, callback, position) position = position or #EID.DescModifiers + 1 for _,v in ipairs(EID.DescModifiers) do if v["name"] == modifierName then v["condition"] = condition v["callback"] = callback return end end table.insert(EID.DescModifiers, position, { name = modifierName, condition = condition, callback = callback }) end ---Removes a Description object modifier ---@see EID.addDescriptionModifier ---@param modifierName string function EID:removeDescriptionModifier(modifierName) for i,v in ipairs(EID.DescModifiers) do if v["name"] == modifierName then table.remove(EID.DescModifiers,i) return end end end ---Adds Modular data modifier ---@param modifierName string ---@param condition fun(descObj: EID_DescObj): boolean ---@param callback fun(descObj: EID_DescObj): EID_DescObj ---@param position? integer function EID:addModularDataModifier(modifierName, condition, callback, position) position = position or #EID.ModularDataModifiers + 1 for _, v in ipairs(EID.ModularDataModifiers) do if v["name"] == modifierName then v["condition"] = condition v["callback"] = callback return end end table.insert(EID.ModularDataModifiers, position, { name = modifierName, condition = condition, callback = callback }) end ---Removes a Modular data modifier ---@see EID.addModularDataModifier ---@param modifierName string function EID:removeModularDataModifier(modifierName) for i, v in ipairs(EID.ModularDataModifiers) do if v["name"] == modifierName then table.remove(EID.ModularDataModifiers, i) return end end end ---Interpolates between 2 KColors with a given fraction. ---@param kColor1 KColor ---@param kColor2 KColor ---@param fraction number @Range: [0,1] ---@return KColor function EID:interpolateColors(kColor1, kColor2, fraction) local t = KColor( (kColor2.Red - kColor1.Red) * fraction + kColor1.Red, (kColor2.Green - kColor1.Green) * fraction + kColor1.Green, (kColor2.Blue - kColor1.Blue) * fraction + kColor1.Blue, (kColor2.Alpha - kColor1.Alpha) * fraction + kColor1.Alpha ) return t end ---Replaces all values in `changeTable` with the values in `tableToUpdate`. ---If a value is an empty string, it will be removed. ---@param changeTable table ---@param tableToUpdate table function EID:updateDescriptionsViaTable(changeTable, tableToUpdate) for k,v in pairs(changeTable) do if v == "" then tableToUpdate[k] = nil else tableToUpdate[k] = v end end end ---Converts e.g. "5.100.69" format strings into `5, 100, 69`. Returns 0 for any not included ---@param tvsString string ---@return integer, integer, integer function EID:SplitTVS(tvsString) local Type, Var, Sub = 0, 0, 0 local tvsTable = {} for i in string.gmatch(tvsString, "[^.]+") do table.insert(tvsTable, tonumber(i)) end Type = tvsTable[1] or Type; Var = tvsTable[2] or Var; Sub = tvsTable[3] or Sub; return Type, Var, Sub end ---Checks if any player has a given item ID (or anyone is a given player ID) ---@param Type string | integer | EntityType ---@param Var integer | nil ---@param Sub integer | nil ---@return boolean, EntityPlayer?, integer? function EID:PlayersHaveItem(Type, Var, Sub) -- convert "5.100.69" format strings into type, var, sub if type(Type) == "string" then Type, Var, Sub = EID:SplitTVS(Type) -- assume collectible if only Type was given elseif Var == nil then Sub = Type; Type = 5; Var = 100 end if Type == 1 and Sub then return EID:PlayersHaveCharacter(Sub) elseif Type == 5 and Sub then if Var == 100 then return EID:PlayersHaveCollectible(Sub) elseif Var == 350 then return EID:PlayersHaveTrinket(Sub) elseif Var == 300 then return EID:PlayersHaveCard(Sub) elseif Var == 70 then return EID:PlayersHavePill(Sub) end end return false end ---Checks if the given player has the given item ID (or is the given player ID) ---@param player EntityPlayer ---@param Type EntityType | string ---@param Var integer? ---@param Sub integer? ---@return boolean function EID:PlayerHasItem(player, Type, Var, Sub) -- convert "5.100.69" format strings into type, var, sub if type(Type) == "string" then Type, Var, Sub = EID:SplitTVS(Type) -- assume collectible if only Type was given elseif Var == nil then Sub = Type; Type = 5; Var = 100 end if Type == 1 and Sub then return player:GetPlayerType() == Sub elseif Type == 5 and Sub then if Var == 100 then return player:HasCollectible(Sub) elseif Var == 350 then return player:HasTrinket(Sub) elseif Var == 300 then return EID:PlayerHasCard(player, Sub) elseif Var == 70 then return EID:PlayerHasPill(player, Sub) end end return false end ---Checks if any player has a given collectible ID, for modifiers ---@param collectibleID CollectibleType ---@return boolean, EntityPlayer?, integer? function EID:PlayersHaveCollectible(collectibleID) for i = 0, game:GetNumPlayers() - 1 do local player = Isaac.GetPlayer(i) if player:HasCollectible(collectibleID) then return true, player, i end end return false end ---Returns true, if any player has a given voided collectible ---@param collectibleID CollectibleType ---@return boolean, EntityPlayer? function EID:PlayersVoidedCollectible(collectibleID) for i = 0, game:GetNumPlayers() - 1 do local player = Isaac.GetPlayer(i) return EID:PlayerVoidedCollectible(player, collectibleID) end return false end ---Returns true, if the player has a given voided collectible ---@param player EntityPlayer ---@param collectibleID CollectibleType ---@return boolean, EntityPlayer? function EID:PlayerVoidedCollectible(player, collectibleID) local playerNum = EID:getPlayerID(player, true) local isCollectibleAbsorbed = EID.absorbedItems[tostring(playerNum)] and EID.absorbedItems[tostring(playerNum)][tostring(collectibleID)] if player:HasCollectible(477) and isCollectibleAbsorbed then return true, player end return false end ---Checks if any player has a given trinket ID, for modifiers ---@param trinketID TrinketType ---@return boolean, EntityPlayer?, integer? function EID:PlayersHaveTrinket(trinketID) for i = 0, game:GetNumPlayers() - 1 do local player = Isaac.GetPlayer(i) if player:HasTrinket(trinketID) then return true, player, i end end return false end ---Returns true, if any player has a given card ---@param cardID Card ---@return boolean, EntityPlayer? function EID:PlayersHaveCard(cardID) for i = 0, game:GetNumPlayers() - 1 do local player = Isaac.GetPlayer(i) return EID:PlayerHasCard(player, cardID) end return false end ---Returns true, if the player has a given card ---@param player EntityPlayer ---@param cardID Card ---@return boolean, EntityPlayer?, integer? function EID:PlayerHasCard(player, cardID) local playerNum = EID:getPlayerID(player, true) for j = 0, (EID.isRepentance and 3 or 1) do if player:GetCard(j) == cardID then return true, player, playerNum end end return false end ---Returns true, if any player has a given pill color ---@param pillID PillColor ---@return boolean, EntityPlayer? function EID:PlayersHavePill(pillID) for i = 0, game:GetNumPlayers() - 1 do local player = Isaac.GetPlayer(i) return EID:PlayerHasPill(player, pillID) end return false end ---Returns true, if the player has a given pill color ---@param player EntityPlayer ---@param pillID PillColor ---@return boolean, EntityPlayer?, integer? function EID:PlayerHasPill(player, pillID) local playerNum = EID:getPlayerID(player, true) for j = 0, (EID.isRepentance and 3 or 1) do if player:GetPill(j) == pillID then return true, player, playerNum end end return false end ---Checks if someone is playing as a certain character, for modifiers ---@param playerType PlayerType ---@param includeTainted? boolean @If true, doesn't care if the player is tainted or not ---@return boolean, EntityPlayer?, integer? function EID:PlayersHaveCharacter(playerType, includeTainted) for i = 0, game:GetNumPlayers() - 1 do local player = Isaac.GetPlayer(i) if player:GetPlayerType() == playerType then return true, player, i end if includeTainted and EID.isRepentance and EID.TaintedToRegularID[player:GetPlayerType()] == playerType then return true, player, i end end return false end ---Converts a given CollectibleID into the respective Spindown dice result ---@param collectibleID CollectibleType ---@return CollectibleType function EID:getSpindownResult(collectibleID) if collectibleID <= 0 or collectibleID > 4294960000 then return 0 end local newID = collectibleID local attempts = 0 repeat newID = newID - 1 attempts = attempts + 1 --note: the order of the SkipLocked check statement is important so that the item is checked for being in a pool either way (to display a ? if it isn't) until (EID.itemConfig:GetCollectible(newID) and (EID:isCollectibleUnlocked(newID) or not EID.Config["SpindownDiceSkipLocked"]) and not EID.itemConfig:GetCollectible(newID).Hidden and EID:isCollectibleAllowed(newID)) or newID == CollectibleType.COLLECTIBLE_NULL or attempts > 10 return newID end local maxCollectibleID = nil -- cache after first use. this number will not change mid game ---Returns the maximum collectible id, including modded items ---@return CollectibleType function EID:GetMaxCollectibleID() if maxCollectibleID then return maxCollectibleID end local id = CollectibleType.NUM_COLLECTIBLES-1 local step = 16 while step > 0 do if EID.itemConfig:GetCollectible(id+step) ~= nil then id = id + step else step = step // 2 end end maxCollectibleID = id return id end local maxTrinketID = nil ---Returns the maximum trinket id, including modded trinkets ---@return TrinketType function EID:GetMaxTrinketID() if maxTrinketID then return maxTrinketID end local id = TrinketType.NUM_TRINKETS-1 local step = 16 while step > 0 do if EID.itemConfig:GetTrinket(id+step) ~= nil then id = id + step else step = step // 2 end end maxTrinketID = id return id end ---@return boolean function EID:DetectModdedItems() if EID:GetMaxCollectibleID() > EID.XMLMaxItemID then return true end if EID.itemConfig:GetCollectible(EID.XMLMaxItemID) == nil then return true end return false end ---REPENTANCE ONLY! Return whether the collectible is considered available. ---Bag of Crafting rerolls unavailable items; this function is kept brief to help BoC speed ---@param collectibleID CollectibleType ---@return boolean function EID:isCollectibleAvailable(collectibleID) if EID.itemAvailableStates[collectibleID] == nil then EID.itemAvailableStates[collectibleID] = EID.itemConfig:GetCollectible(collectibleID):IsAvailable() end return EID.itemAvailableStates[collectibleID] end ---REPENTANCE ONLY! Return our best guess on whether an achievement-locked collectible is unlocked ---(Things like Tainted Lost and Sacred Orb give false negatives) ---Spindown Dice skips over locked items, but not unavailable items ---@param collectibleID CollectibleType ---@return boolean function EID:isCollectibleUnlocked(collectibleID) local item = EID.itemConfig:GetCollectible(collectibleID) if item == nil then return false end if EID.itemUnlockStates[collectibleID] == nil then --whitelist all quest items and items with no associated achievement if item.AchievementID == -1 or (item.Tags and item.Tags & ItemConfig.TAG_QUEST == ItemConfig.TAG_QUEST) then EID.itemUnlockStates[collectibleID] = true return true end --blacklist all hidden items if item.Hidden then EID.itemUnlockStates[collectibleID] = false return false end --check the item's IsAvailable status if the function exists if item.IsAvailable then EID.itemUnlockStates[collectibleID] = item:IsAvailable() else EID.itemUnlockStates[collectibleID] = true end return EID.itemUnlockStates[collectibleID] else return EID.itemUnlockStates[collectibleID] end end EID.isCollectibleUnlockedAnyPool = EID.isCollectibleUnlocked -- old name before ItemConfig:IsAvailable was added ---REPENTANCE ONLY! Return whether the collectible is completely disallowed by the current game mode. ---Bag of Crafting and Spindown Dice skip over disallowed items entirely ---@param collectibleID CollectibleType ---@return boolean function EID:isCollectibleAllowed(collectibleID) local item = EID.itemConfig:GetCollectible(collectibleID) if item == nil then return false elseif item.Tags == nil then return true else if game:IsGreedMode() and item:HasTags(ItemConfig.TAG_NO_GREED) then return false elseif game.Challenge > 0 and item:HasTags(ItemConfig.TAG_NO_CHALLENGE) then return false else return true end -- no need to check TAG_NO_DAILY because mods are disabled for Dailies end end ---Achievements Locked Check (do we have Cube of Meat or Book of Revelations unlocked?) ---@return boolean ---@diagnostic disable-next-line: duplicate-set-field function EID:AreAchievementsAllowed() -- Tainted characters have definitely beaten Mom! -- (Fixes Tainted Lost's item pools, and potentially modded character's mechanics, ruining this check) if EID.player:GetPlayerType() < 21 then -- Challenge runs and TMTrainer might break the pool, so ignore them. if not game:GetSeeds():IsCustomRun() and not EID:PlayersHaveCollectible(CollectibleType.COLLECTIBLE_TMTRAINER) then ---@diagnostic disable-next-line: undefined-field local hasBookOfRevelationsUnlocked = EID:isCollectibleUnlocked(CollectibleType.COLLECTIBLE_BOOK_OF_REVELATIONS or CollectibleType.COLLECTIBLE_BOOK_REVELATIONS) if not hasBookOfRevelationsUnlocked then local hasCubeOfMeatUnlocked = EID:isCollectibleUnlocked(CollectibleType.COLLECTIBLE_CUBE_OF_MEAT) if not hasCubeOfMeatUnlocked then return false end end end end return true end ---Returns the dimension ID the player is currently in. ---@param level Level ---@return EID_Dimension? function EID:GetDimension(level) local roomIndex = level:GetCurrentRoomIndex() for i = 0, 2 do if GetPtrHash(level:GetRoomByIdx(roomIndex, i)) == GetPtrHash(level:GetRoomByIdx(roomIndex, -1)) then return i end end return nil end local emptyPickupTable = {} for i=1, 29 do emptyPickupTable[i] = 0 end ---Converts a given table into a string containing the crafting icons of the table ---Example input: {1,2,3,4,5,6,7,8} ---Result: "{{Crafting1}}{{Crafting2}}{{Crafting3}}{{Crafting4}}{{Crafting5}}{{Crafting6}}{{Crafting7}}{{Crafting8}}" ---@param craftTable integer[] ---@param indicateCompleteContent boolean ---@return string function EID:tableToCraftingIconsFull(craftTable, indicateCompleteContent) local sortedList = {table.unpack(craftTable)} table.sort(sortedList, function(a, b) return a < b end) local visitedItemCount = {table.unpack(emptyPickupTable)} local iconString = "" for _, nr in ipairs(sortedList) do visitedItemCount[nr] = visitedItemCount[nr] + 1 local containsItemResult = EID:bagContainsItem(nr, visitedItemCount[nr]) local completedColoring = indicateCompleteContent and containsItemResult and containsItemResult >= 1 and "{{IconGreenTint}}" or "" iconString = iconString..completedColoring.."{{Crafting"..nr.."}}" end return iconString end ---Converts a given table into a string containing the crafting icons of the table, which are also grouped to reduce render lag ---@param craftTable integer[] @Example: {1,1,1,2,2,3,3,3} ---@param indicateCompleteContent boolean? ---@return string @Example: "3{{Crafting1}}2{{Crafting2}}3{{Crafting3}}" function EID:tableToCraftingIconsMerged(craftTable, indicateCompleteContent) local sortedList = {table.unpack(craftTable)} local filteredList = {table.unpack(emptyPickupTable)} for _, nr in ipairs(sortedList) do filteredList[nr] = filteredList[nr] + 1 end local iconString = "" for nr, count in ipairs(filteredList) do if (count > 0) then local coloring = "" if indicateCompleteContent then local bagContainsItem = EID:bagContainsItem(nr, count) if bagContainsItem == 1 then coloring = "{{ColorBagComplete}}" elseif bagContainsItem == 2 then coloring = "{{ColorBagOverfill}}" end end iconString = iconString..coloring..count.."{{Crafting"..nr.."}}{{CR}}" end end return iconString end ---Checks how many of an item there are in the bag ---@param itemID integer ---@param itemCount integer ---@return ---|false @Item is not in the bag ---| 0 @There are fewer items than the target amount ---| 1 @There are exactly the target amount ---| 2 @There are more items than the target amount function EID:bagContainsItem(itemID, itemCount) local foundCount = 0 local bagItems = EID.BoC.BagItemsOverride or EID.BoC.BagItems for _, bagItem in ipairs(bagItems) do if bagItem == itemID then foundCount = foundCount + 1 end end if foundCount == 0 then return false elseif foundCount < itemCount then return 0 elseif foundCount == itemCount then return 1 else return 2 end end ---Get the number of pickups in the given recipe table that are inside our bag ---Is for checking if a recipe is possible to create if you need to use every item in your bag ---@param craftTable integer[] ---@return integer function EID:bagContainsCount(craftTable) local count = 0 local ingredCount = {table.unpack(emptyPickupTable)} for _, id in ipairs(craftTable) do ingredCount[id] = ingredCount[id] + 1 end local bagItems = EID.BoC.BagItemsOverride or EID.BoC.BagItems for _, bagItem in ipairs(bagItems) do if ingredCount[bagItem] > 0 then count = count + 1 ingredCount[bagItem] = ingredCount[bagItem] - 1 end end return count end ---@param hudElement EID_HudElement ---@return EID_HudElement function EID:handleHUDElement(hudElement) local alteredHudElement = {} for k,v in pairs(hudElement) do alteredHudElement[k] = v end local screenSize = EID:getScreenSize() local hudOffset = EID.Config["HUDOffset"] if EID.isRepentance then hudOffset = (Options.HUDOffset * 10) end for _,v in ipairs(hudElement.anchors) do if v == "TOP" then alteredHudElement.y = hudElement.y + hudOffset * 2 elseif v == "LEFT" then alteredHudElement.x = hudElement.x + hudOffset * 2 elseif v == "BOTTOM" then alteredHudElement.y = screenSize.Y*2 - alteredHudElement.y - alteredHudElement.height alteredHudElement.y = alteredHudElement.y - hudOffset * 2 elseif v == "RIGHT" then alteredHudElement.x = screenSize.X*2 - alteredHudElement.x - alteredHudElement.width alteredHudElement.x = alteredHudElement.x - hudOffset * 2 end end return alteredHudElement end ---@return Vector function EID:getScreenSize() local room = game:GetRoom() local pos = room:WorldToScreenPosition(Vector(0,0)) - room:GetRenderScrollOffset() - game.ScreenShakeOffset local rx = pos.X + 60 * 26 / 40 local ry = pos.Y + 140 * (26 / 40) return Vector(rx*2 + 13*26, ry*2 + 7*26) end ---@param entity Entity ---@return any? function EID:getEntityData(entity, str) if EID:EntitySanityCheck(entity) and not EID:IsGridEntity(entity) and entity:GetData() ~= nil and type(entity:GetData()) == "table" then return entity:GetData()[str] end return nil end ---@param entity Entity ---@param str string ---@param value any function EID:setEntityData(entity, str, value) if EID:EntitySanityCheck(entity) and not EID:IsGridEntity(entity) and entity:GetData() ~= nil and type(entity:GetData()) == "table" then entity:GetData()[str] = value end end ---Function to fix font compatibility. Resets config font to a value compatible with your current language ---@param forceRefresh boolean | nil ---@return boolean @True if the font was changed function EID:fixDefinedFont(forceRefresh) local curLang = EID:getLanguage() local curFont = EID.Config["FontType"] -- If the textbox width is set to the default, make it match the selected language's default -- Fixes language selection not respecting the language's textbox width, although it also prevents 130 from being used as the width with those languages if EID.Config["TextboxWidth"] == EID.DefaultConfig["TextboxWidth"] then EID.Config["TextboxWidth"] = EID.descriptions[curLang].fonts[1].textboxWidth or EID.DefaultConfig["TextboxWidth"] end -- If our currently loaded font is still valid, we don't need to reset values for _, v in ipairs(EID.descriptions[curLang].fonts) do if curFont == v.name then -- Load the new language's line height / textbox width if (forceRefresh) then EID.Config["LineHeight"] = EID.descriptions[curLang].fonts[1].lineHeight or EID.DefaultConfig["LineHeight"] EID.Config["TextboxWidth"] = EID.descriptions[curLang].fonts[1].textboxWidth or EID.DefaultConfig["TextboxWidth"] end return false end end EID.Config["FontType"] = EID.descriptions[curLang].fonts[1].name -- On new font load, reset line height / textbox width to the default values if there's no defined height/width EID.Config["LineHeight"] = EID.descriptions[curLang].fonts[1].lineHeight or EID.DefaultConfig["LineHeight"] EID.Config["TextboxWidth"] = EID.descriptions[curLang].fonts[1].textboxWidth or EID.DefaultConfig["TextboxWidth"] return true end ---Check if a given font name is valid for the currently selected language ---@param fontType string ---@return boolean function EID:canUseFontType(fontType) local curLang = EID:getLanguage() for _, v in ipairs(EID.descriptions[curLang].fonts) do if fontType == v.name then return true end end return false end ---Creates a copy of a KColor object. This prevents overwriting existing KColor objects ---@param colorObj KColor ---@return KColor function EID:copyKColor(colorObj) return KColor(colorObj.Red, colorObj.Green, colorObj.Blue, colorObj.Alpha) end ---Compares two KColors. Returns true if they are equal ---@param c1 KColor ---@param c2 KColor ---@return boolean function EID:areColorsEqual(c1, c2) return c1.Red == c2.Red and c1.Green == c2.Green and c1.Blue == c2.Blue and c1.Alpha == c2.Alpha end ---Get KColor object of "Entity Name" texts ---@return KColor, boolean function EID:getNameColor() return EID:getColor(EID.Config["ItemNameColor"], EID.InlineColors["ColorEIDObjName"]) end ---Get KColor object of "Description" texts ---@return KColor, boolean function EID:getTextColor() return EID:getColor(EID.Config["TextColor"], EID.InlineColors["ColorEIDText"]) end ---Get KColor object of "Transformation" texts ---@return KColor, boolean function EID:getTransformationColor() return EID:getColor(EID.Config["TransformationColor"], EID.InlineColors["ColorEIDTransform"]) end ---Get KColor object of "Error" texts ---@return KColor, boolean function EID:getErrorColor() return EID:getColor(EID.Config["ErrorColor"], EID.InlineColors["ColorEIDError"]) end ---Specify the name of the mod which will be displayed next to the item name ---By default EID takes the mod name ---@param newName string function EID:setModIndicatorName(newName) EID.ModIndicator[EID._currentMod].Name = newName end ---Set an icon for the mod which will be displayed next to the item name ---@param iconMarkup string ---@param override? boolean @Default: true function EID:setModIndicatorIcon(iconMarkup, override) if override == nil then override = true end -- overide previous value if not specified if EID.ModIndicator[EID._currentMod].Icon ~= nil and override == false then return end EID.ModIndicator[EID._currentMod].Icon = iconMarkup end EID.Coroutines = {} ---Add a coroutine to be ran 60 times a second ---@param name string ---@param func fun() ---@param overwrite? boolean @Default: false function EID:addCoroutine(name, func, overwrite) if overwrite or EID.Coroutines[name] == nil then EID.Coroutines[name] = coroutine.create(func) end end ---Remove a coroutine from the coroutine list ---@param name string function EID:removeCoroutine(name) EID.Coroutines[name] = nil end ---Resumes all coroutines. ---Ran 60 times a second in main game render function EID:resumeCoroutines() for k,v in pairs(EID.Coroutines) do if coroutine.resume(v) == false then EID:removeCoroutine(k) end end end ---Returns true if an item needs to be collected for the collection page ---@param itemID CollectibleType ---@return boolean ---@diagnostic disable-next-line: duplicate-set-field function EID:requiredForCollectionPage(itemID) if not EID.SaveGame or EID.Config["SaveGameNumber"] == 0 or itemID >= CollectibleType.NUM_COLLECTIBLES or itemID <= 0 or game:GetVictoryLap() > 0 or game:GetSeeds():IsCustomRun() then return false end return EID.SaveGame[EID.Config["SaveGameNumber"]].ItemNeedsPickup[itemID] end ---Updates the item collection state of the players, based on the QueuedItem value. ---TODO: also check for D100 / MissingNo Item collections ---@diagnostic disable-next-line: duplicate-set-field function EID:checkPlayersForMissingItems() if not EID.SaveGame or EID.Config["SaveGameNumber"] == 0 or game:GetVictoryLap() > 0 or game:GetSeeds():IsCustomRun() then return end if EID.GameUpdateCount % 5 ~= 0 then return end for i = 0, game:GetNumPlayers() - 1 do local player = Isaac.GetPlayer(i) if player.QueuedItem.Item and EID.SaveGame[EID.Config["SaveGameNumber"]].ItemNeedsPickup[player.QueuedItem.Item.ID] then table.insert(EID.CollectedItems, player.QueuedItem.Item.ID) EID.SaveGame[EID.Config["SaveGameNumber"]].ItemNeedsPickup[player.QueuedItem.Item.ID] = nil end end end ---@param entityPlayer EntityPlayer ---@param lazarusAdjust? boolean ---@return integer function EID:getPlayerID(entityPlayer, lazarusAdjust) if not EID:EntitySanityCheck(entityPlayer) then return 0 end for i = 0, game:GetNumPlayers() - 1 do local player = Isaac.GetPlayer(i) if GetPtrHash(player) == GetPtrHash(entityPlayer) then -- Dead Tainted Lazarus exceptions if lazarusAdjust and EID.isRepentance then if player:GetPlayerType() == 38 then -- Check for Birthright to make the ghostly form use the same base ID as the main form if player:GetMainTwin() then if GetPtrHash(player:GetMainTwin()) ~= GetPtrHash(player) then i = EID:getPlayerID(player:GetMainTwin()) end end -- Increase the ID by 666 if Dead Tainted Lazarus i = i + 666 end end return i end end return 0 end ---@param itemID CollectibleType function EID:AddToCollectiblesToCheckList(itemID) EID.collectiblesToCheck[itemID] = true end ---Add a specific entity to be ignored by EID. ---Set entitySubType to -1 in order to ignore all entities with this type+variant combination. ---@param entityType EntityType ---@param entityVariant integer ---@param entitySubType integer function EID:addIgnoredEntity(entityType, entityVariant, entitySubType) if entitySubType == -1 then EID.IgnoredEntities[entityType.."."..entityVariant] = true else EID.IgnoredEntities[entityType.."."..entityVariant.."."..entitySubType] = true end end ---Remove a specific entity from the ignored List of EID. ---@param entityType EntityType ---@param entityVariant integer ---@param entitySubType integer function EID:removeIgnoredEntity(entityType, entityVariant, entitySubType) if entitySubType == -1 then EID.IgnoredEntities[entityType.."."..entityVariant] = nil else EID.IgnoredEntities[entityType.."."..entityVariant.."."..entitySubType] = nil end end ---Returns if this is a frame we should refresh our descriptions ---@return boolean function EID:RefreshThisFrame() if EID.GameRenderCount % (60 / EID.Config["RefreshRate"]) == 0 then return true end return false end ---Returns true if any player is pressing the given button (you can also specify any of the input functions) ---@generic BTN ---@param button BTN ---@param inputFunc nil | fun(button: BTN, playerIndex: integer): boolean ---@return boolean, integer? function EID:PlayersActionPressed(button, inputFunc) inputFunc = inputFunc or Input.IsActionPressed for k,v in pairs(EID.controllerIndexes) do if inputFunc(button, k) then return true, v end end return false end ---Replaces markup icons with ones of a different size based on the current config ---@param description string ---@return string function EID:replaceMarkupSize(description) if EID.Config["StatChangeIcons"] == false then description = string.gsub(description, "{{ArrowUp}} ({{[.-}}]+)", "{{ArrowUp}} ") description = string.gsub(description, "{{ArrowDown}} ({{[.-}}]+)", "{{ArrowDown}} ") -- Remove double spaces after markup removal description = string.gsub(description, " ", " ") description = string.gsub(description, "# ", "#") end if EID.Config["MarkupSize"] == "small" then for normal, small in pairs(EID.MarkupSizeMap) do description = string.gsub(description, normal, small) end elseif EID.Config["MarkupSize"] == "big" then for normal, small in pairs(EID.MarkupSizeMap) do description = string.gsub(description, small, normal) end end return description end EID.TransformationLookup = {} -- Creates a table that contains all objects a transformation is associated with. function EID:buildTransformationTables() EID.TransformationLookup = {} for entityString, transformationData in pairs(EID.EntityTransformations) do EID:alterTransformationLookup(entityString, transformationData, true) end -- legacy for subType, transformationData in pairs(__eidItemTransformations) do EID:alterTransformationLookup("5.100."..subType, transformationData, true) end -- add custom for entityString, transformationData in pairs(EID.CustomTransformAssignments) do EID:alterTransformationLookup(entityString, transformationData, true) end -- custom remove for entityString, transformationData in pairs(EID.CustomTransformRemovals) do EID:alterTransformationLookup(entityString, transformationData, nil) end end ---@param entityString string ---@param transformString string ---@param addToList? boolean function EID:alterTransformationLookup(entityString, transformString, addToList) for transformation in string.gmatch(transformString, '([^,]+)') do if EID.TransformationLookup[transformation] == nil then EID.TransformationLookup[transformation] = {} end EID.TransformationLookup[transformation][entityString] = addToList end end EID.TransformationProgress = {} ---Given a transformation identifier, iterates over every player and count the number of items they have which count towards that transformation ---@param transformation string function EID:evaluateTransformationProgress(transformation) for i = 0, game:GetNumPlayers() - 1 do local player = Isaac.GetPlayer(i) local id = EID:getPlayerID(player, true) -- Dead Tainted Lazarus exception EID.TransformationProgress[id] = {} EID.TransformationProgress[id][transformation] = 0 local transformData = EID.TransformationData[transformation] if not EID.TransformationLookup[transformation] then return end if REPENTOGON and transformData and transformData.VanillaForm then -- REPENTOGON lets us ignore everything else for vanilla transformation progress EID.TransformationProgress[id][transformation] = player:GetPlayerFormCounter(transformData.VanillaForm) elseif transformData and transformData.VanillaForm and player:HasPlayerForm(transformData.VanillaForm) then EID.TransformationProgress[id][transformation] = transformData.NumNeeded or 3 else local pickupHistory = EID.PlayerItemInteractions[id].pickupHistory local pillsTable = {} if pickupHistory then for j = 1, #pickupHistory do if pickupHistory[j][1] == "pill" then local pillSubType = tostring(pickupHistory[j][3]) if not pillsTable[pillSubType] then pillsTable[pillSubType] = 0 end pillsTable[pillSubType] = pillsTable[pillSubType] + 1 -- collect pill SubType end end end local activesTable = EID.PlayerItemInteractions[id].actives for entityString, _ in pairs(EID.TransformationLookup[transformation]) do local eType, eVariant, eSubType = entityString:match("([^.]+).([^.]+).([^.]+)") if tonumber(eType) == EntityType.ENTITY_PICKUP then if tonumber(eVariant) == PickupVariant.PICKUP_COLLECTIBLE then -- Rep+ Exclusive: Only count a Transformation progress for Necromancer, if you own the Necronomicon local isNecromancer = EID.isRepentancePlus and transformation == tostring(EID.TRANSFORMATION.NECROMANCER) if not isNecromancer and activesTable[tostring(eSubType)] then EID.TransformationProgress[id][transformation] = EID.TransformationProgress[id][transformation] + activesTable[tostring(eSubType)] else local collCount = player:GetCollectibleNum(eSubType, true) if EID.PlayerItemInteractions[id].rerollItems then collCount = collCount - (EID.PlayerItemInteractions[id].rerollItems[tostring(eSubType)] or 0) end EID.TransformationProgress[id][transformation] = EID.TransformationProgress[id][transformation] + collCount -- Undo the Book of Virtues active item getting counted here if EID.isRepentance and tonumber(eSubType) == 584 and player:GetActiveItem() == 584 then EID.TransformationProgress[id][transformation] = EID.TransformationProgress[id][transformation] - 1 end end -- In AB+, only one copy of a given collectible is counted for trans if not EID.isRepentance and EID.TransformationProgress[id][transformation] > EID.TransformationProgress[id][transformation] + 1 then EID.TransformationProgress[id][transformation] = EID.TransformationProgress[id][transformation] + 1 end elseif tonumber(eVariant) == PickupVariant.PICKUP_TRINKET and player:HasTrinket(eSubType) then EID.TransformationProgress[id][transformation] = EID.TransformationProgress[id][transformation] + player:GetTrinketMultiplier(eSubType) elseif tonumber(eVariant) == PickupVariant.PICKUP_PILL then if pillsTable[tostring(eSubType)] then EID.TransformationProgress[id][transformation] = EID.TransformationProgress[id][transformation] + pillsTable[tostring(eSubType)] end end end end end end end ---Create a list of all grid entities in the room that have an EID description function EID:CheckCurrentRoomGridEntities() EID.CurrentRoomGridEntities = {} local room = game:GetRoom() for i = 1, room:GetGridSize(), 1 do local gridEntity = room:GetGridEntity(i) if gridEntity and EID:hasDescription(gridEntity) then EID.CurrentRoomGridEntities[i] = gridEntity end end end EID.PlayerHeldPill = {} ---Workaround function to get the currently held pill of the players. Used to map Pill ID to pill color and vise versa function EID:evaluateHeldPill() EID.PlayerHeldPill = {} for i = 0, game:GetNumPlayers() - 1 do local player = Isaac.GetPlayer(i) EID.PlayerHeldPill[i] = player:GetPill(0) -- Magdalene starts with an identified pill. We need to account for that if player:GetPlayerType() == PlayerType.PLAYER_MAGDALENE and player.FrameCount <= 1 then EID.UsedPillColors[tostring(EID.PlayerHeldPill[i])] = true end end end EID.PlayerItemInteractions = {} EID.RecentlyTouchedItems = {} local hadQueuedItem = {} ---Watch for a player's queued item (holding an item over their head) to track active item touches ---Used for Transformation Progress and for tracking Recently Touched Items function EID:evaluateQueuedItems() for i = 0, game:GetNumPlayers() - 1 do local player = Isaac.GetPlayer(i) local id = EID:getPlayerID(player, true) EID:InitItemInteractionIfAbsent(id) if player.QueuedItem then -- Refresh our descriptions and grid entity list upon a queued passive item being added to a player if not player.QueuedItem.Item and hadQueuedItem[id] then EID.ForceRefreshCache = true EID:CheckCurrentRoomGridEntities() end hadQueuedItem[id] = player.QueuedItem.Item ~= nil if EID.PlayerItemInteractions[id].LastTouch + 45 >= game:GetFrameCount() and player.QueuedItem.Item then return else EID.PlayerItemInteractions[id].LastTouch = 0 end if not player.QueuedItem.Touched and player.QueuedItem.Item then EID.PlayerItemInteractions[id].LastTouch = game:GetFrameCount() local itemIDStr = tostring(player.QueuedItem.Item.ID) -- Add touched active items to our transformation progress table if player.QueuedItem.Item.Type == ItemType.ITEM_ACTIVE then -- A new active item was touched; initiate its touch count to 0 for all players -- (Fixes co-op bugs, compared to only initiating it for the toucher) EID:InitActiveItemInteraction(itemIDStr) local activesTable = EID.PlayerItemInteractions[id].actives EID.ForceRefreshCache = true activesTable[itemIDStr] = activesTable[itemIDStr] + 1 elseif player.QueuedItem.Item.Type ~= ItemType.ITEM_TRINKET then -- In AB+, Halo of Flies is counted as an active item due to inaccuracy with GetCollectibleNum if (not EID.isRepentance and itemIDStr == "10") then EID.PlayerItemInteractions[id].actives[itemIDStr] = EID.PlayerItemInteractions[id].actives[itemIDStr] + 1 end -- Put non-active item pickups into the recent item list, for printing in the Item Reminder local playerID = i if EID.isRepentance and player:GetPlayerType() == 38 then playerID = playerID + 666 end table.insert(EID.RecentlyTouchedItems[playerID], player.QueuedItem.Item.ID) EID:ResetItemReminderSelectedItems("Passives") end end end end end ---If the player ItemInteraction table doesnt exist, create it with its init values ---@param playerID integer function EID:InitItemInteractionIfAbsent(playerID) playerID = playerID % 666 -- dead tainted lazarus exception if not EID.PlayerItemInteractions[playerID] then EID.PlayerItemInteractions[playerID] = { LastTouch = 0, actives = {}, pickupHistory = {}, rerollItems = {} } -- in AB+, initiate Halo of Flies as an active item (collectible ID 10 is used for ALL Pretty Flies) if not EID.isRepentance then EID.PlayerItemInteractions[playerID].actives["10"] = 0 end end if not EID.PlayerItemInteractions[playerID+666] then EID.PlayerItemInteractions[playerID+666] = { LastTouch = 0, actives = {}, pickupHistory = {}, rerollItems = {} } end EID.RecentlyTouchedItems[playerID] = EID.RecentlyTouchedItems[playerID] or {} EID.RecentlyTouchedItems[playerID+666] = EID.RecentlyTouchedItems[playerID+666] or {} end ---Initialize a touched active item to 0 count for all players ---(Fixes co-op bugs, compared to only initiating it for the toucher) ---@param itemIDStr string function EID:InitActiveItemInteraction(itemIDStr) for playerID = 0, game:GetNumPlayers() - 1 do EID:InitItemInteractionIfAbsent(playerID) EID.PlayerItemInteractions[playerID].actives[itemIDStr] = EID.PlayerItemInteractions[playerID].actives[itemIDStr] or 0 EID.PlayerItemInteractions[playerID+666].actives[itemIDStr] = EID.PlayerItemInteractions[playerID+666].actives[itemIDStr] or 0 end end ---Returns the quality of the described entity ---@param descObj EID_DescObj ---@return integer? function EID:getObjectQuality(descObj) local subType = tonumber(descObj.ObjSubType) ---@cast subType number if EID.isRepentance and descObj.ObjType == 5 and descObj.ObjVariant == 100 and EID.itemConfig:GetCollectible(subType) then return tonumber(EID.itemConfig:GetCollectible(subType).Quality) end end ---Returns the item pool of the described entity ---@param descObj EID_DescObj ---@return integer? function EID:getObjectItemPool(descObj) if EID.isRepentance and descObj.ObjType == 5 and descObj.ObjVariant == 100 then if EID.isRepentancePlus then -- TODO (maybe will require REPENTOGON?) -- Since D4 exploit is completely fixed in 1.9.7.11, this is no longer viable for full-reroll effect -- (still useful for initial item pool from rooms though) return game:GetItemPool():GetLastPool() -- remove this if item pool function should not appear on 1.9.7.11 patch else return game:GetItemPool():GetLastPool() end end end EID.ObjectIcon = {} ---Returns the Inline Icon definition object for a given object. ---@param descObj EID_DescObj ---@return EID_Icon? function EID:getObjectIcon(descObj) -- custom object icon if EID.ObjectIcon[descObj.ObjType.."."..descObj.ObjVariant.."."..descObj.ObjSubType] then return EID.ObjectIcon[descObj.ObjType.."."..descObj.ObjVariant.."."..descObj.ObjSubType] end if descObj.ObjType == 5 then local iconName = EID:GetIconNameByVariant(descObj.ObjVariant) if iconName then return EID:createItemIconObject(iconName .. descObj.ObjSubType) end -- Handle Dice Room Floor elseif descObj.ObjType == 1000 and descObj.ObjVariant == 76 then ---@diagnostic disable-next-line: return-type-mismatch return EID.InlineIcons["DiceFace" .. descObj.ObjSubType] end end ---Adds an EID Icon to an Object ---@param eType EntityType ---@param eVariant integer ---@param eSubType integer ---@param iconName string function EID:AddIconToObject(eType, eVariant, eSubType, iconName) EID.ObjectIcon[eType.."."..eVariant.."."..eSubType] = EID.InlineIcons[iconName] end ---Set a pilleffect to be permanently unidentifyable by EID ---@param pillEffectID PillEffect ---@param isUnidentifyable boolean function EID:SetPillEffectUnidentifyable(pillEffectID, isUnidentifyable) EID.UnidentifyablePillEffects[pillEffectID + 1] = isUnidentifyable or nil end ---Add pickup usage to history of pickups used by the player ---@param pickupType "pill" | "card" ---@param effectID PillEffect | Card ---@param player EntityPlayer ---@param useFlags UseFlag ---@param pillColorID? PillColor @Only used for pills function EID:AddPickupToHistory(pickupType, effectID, player, useFlags, pillColorID) -- don't add mimiced or noannouncer cards/pills to Echo Chamber history local allowEchoChamber = true if EID.isRepentance and (useFlags & UseFlag.USE_MIMIC == UseFlag.USE_MIMIC or useFlags & UseFlag.USE_NOANNOUNCER == UseFlag.USE_NOANNOUNCER) then allowEchoChamber = false end local playerID = EID:getPlayerID(player, true) EID:InitItemInteractionIfAbsent(playerID) local historyTable = EID.PlayerItemInteractions[playerID].pickupHistory -- pickupType = ["pill","card"], pillColorID, effectID, hadEchoChamberWhenUsed table.insert(historyTable, 1, {pickupType, pillColorID, effectID, EID.isRepentance and player:HasCollectible(700) and allowEchoChamber}) end EID.WildCardEffects = {} EID.WildCardPillColor = {} EID.TemporaryWildCardEffects = {} -- resets every frame ---@param effectID string @Identifier in `Type.Variant.SubType` format ---@param player EntityPlayer ---@param pillColor? PillColor function EID:TrackWildCardEffects(effectID, player, pillColor) local playerID = EID:getPlayerID(player, true) -- ? Card exception; both it and the active it used are ignored by Wild Card if effectID == "5.300.48" then -- Blank Card using ? Card exception; Blank Card being used is still seen if EID.TemporaryWildCardEffects[playerID] ~= "5.100.286" then EID.TemporaryWildCardEffects[playerID] = nil end else EID.TemporaryWildCardEffects[playerID] = effectID EID.WildCardPillColor[playerID] = pillColor -- doesn't matter that much so just set it here end end function EID:UpdateWildCardEffects() for k,v in pairs(EID.TemporaryWildCardEffects) do EID.WildCardEffects[k] = v end EID.TemporaryWildCardEffects = {} end ---Render a sprite of an entity ---@param entity Entity ---@param sprite Sprite ---@param position Vector function EID:RenderEntity(entity, sprite, position) if entity.Type == 5 and entity.Variant == 100 then sprite:RenderLayer(1, position, nullVector, nullVector) elseif entity.Type == 6 and entity.Variant == 16 then -- Crane Game sprite:RenderLayer(2, position, nullVector, nullVector) else sprite:Render(position, nullVector, nullVector) end end ---Tries to get the Vanilla transformations of modded items based on Tags function EID:GetTransformationsOfModdedItems() if not EID.isRepentance then return end local numCollectibles = EID:GetMaxCollectibleID() for i = 733, numCollectibles, 1 do EID:tryAutodetectTransformationsCollectible(i) end end ---Collects items that the player got after using D4 item ---@param player EntityPlayer function EID:CollectRerolledItemsOfPlayer(player) if maxCollectibleID == nil then maxCollectibleID = EID:GetMaxCollectibleID() end local playerID = EID:getPlayerID(player, true) EID.PlayerItemInteractions[playerID].rerollItems = {} for i = 1, maxCollectibleID do local count = player:GetCollectibleNum(i, true) if count > 0 then EID.PlayerItemInteractions[playerID].rerollItems[tostring(i)] = count end end end ---Returns true if a given entity is a grid entity ---@param entity Entity | GridEntity function EID:IsGridEntity(entity) return entity.Type == nil end ---Returns true, if the given entity is a valid game object and actually is something useful to process. ---Sometimes the game sends weird shit so this function is needed to catch bullshit ---@return boolean function EID:EntitySanityCheck(entity) return entity ~= nil and type(entity) == "userdata" end ---Returns true if the given pill color was used at least once in this game ---@param pillColor PillColor ---@return boolean function EID:WasPillUsed(pillColor) return EID.UsedPillColors[tostring(pillColor)] ~= nil end ---Returns the name of the given entity ---@param Type EntityType ---@param Variant integer ---@param SubType integer ---@return string ---@diagnostic disable-next-line: duplicate-set-field function EID:GetEntityXMLName(Type, Variant, SubType) return EID.XMLEntityNames[Type.."."..Variant] or EID.XMLEntityNames[Type.."."..Variant.."."..SubType] end ---@param tvsString string @Entity identifier in `Type.Variant.SubType` format ---@return string ---@diagnostic disable-next-line: duplicate-set-field function EID:GetEntityXMLNameByString(tvsString) local Type, Var, Sub = EID:SplitTVS(tvsString) return EID:GetEntityXMLName(Type, Var, Sub) end ---Get an item's RNG seed. ---We have no use for the RNG object itself because every other function it can do will advance the item's RNG, altering the game state ---@param player EntityPlayer ---@param id CollectibleType | TrinketType | Card | PillEffect ---@param variant? integer @Default: 100 (Collectible) ---@return integer? function EID:GetItemSeed(player, id, variant) if player == nil then return game:GetSeeds():GetStartSeed() elseif variant == nil or variant == 100 then return player:GetCollectibleRNG(id):GetSeed() elseif variant == 350 then return player:GetTrinketRNG(id):GetSeed() elseif variant == 300 then return player:GetCardRNG(id):GetSeed() elseif variant == 70 then return player:GetPillRNG(id):GetSeed() end end local variantToName = { [70] = "Pill", [100] = "Collectible", [300] = "Card", [350] = "Trinket" } ---@param variant ---| 70 @Pill ---| 100 @Collectible ---| 300 @Card ---| 350 @Trinket function EID:GetIconNameByVariant(variant) return variantToName[variant] end ---@param descObj EID_DescObj ---@return string function EID:GetIconStringByDescriptionObject(descObj) if descObj and descObj.Icon then -- icon object assigned to Icon attribute if type(descObj.Icon) == "table" then local subType = descObj.ObjSubType if descObj.ObjType == 1 then return "{{Player" .. (subType or "") .. "}}" elseif descObj.ObjType == 5 then local iconName = EID:GetIconNameByVariant(descObj.ObjVariant) if iconName then return "{{" .. iconName .. subType .. "}}" end elseif descObj.ObjType == 1000 and descObj.ObjVariant == 76 then return "{{DiceFace" .. subType .. "}}" end -- String assigned to Icon attribute elseif type(descObj.Icon) == "string" then ---@diagnostic disable-next-line: return-type-mismatch return descObj.Icon end end return "{{Blank}}" end ---Returns the markup for a given player ID or type. ---If no icon was found, returns the customTransformation icon markup ---@param playerID integer ---@param altFallback? string function EID:GetPlayerIcon(playerID, altFallback) local fallback = altFallback or "{{CustomTransformation}}" return EID:getIcon("Player" .. playerID) ~= EID.InlineIcons["ERROR"] and "{{Player" .. playerID .. "}}" or fallback end local passiveItems = nil -- cache of all passive item ids ---Returns a list of all passive item ids ---@return integer[] function EID:GetAllPassiveItems() if passiveItems then return passiveItems end passiveItems = {} for i = 1, EID:GetMaxCollectibleID() do local config = EID.itemConfig:GetCollectible(i) if config ~= nil and (config.Type == ItemType.ITEM_PASSIVE or config.Type == ItemType.ITEM_FAMILIAR) then table.insert(passiveItems, i) end end return passiveItems end local activeItems = nil -- cache of all active item ids ---Returns a list of all active item ids ---@return integer[] function EID:GetAllActiveItems() if activeItems then return activeItems end activeItems = {} for i = 1, EID:GetMaxCollectibleID() do local config = EID.itemConfig:GetCollectible(i) if config ~= nil and (config.Type == ItemType.ITEM_ACTIVE) then table.insert(activeItems, i) end end return activeItems end ---Updates the EID.RecentlyTouchedItems table to include the players currently held passive items ---@return table function EID:UpdateAllPlayerPassiveItems() local passives = EID:GetAllPassiveItems() local listUpdatedForPlayers = {} -- check if id is smaller max id, because numbers bigger a certain value can crash the game when calling HasCollectible() local maxCollID = EID:GetMaxCollectibleID() for i = 1, #EID.coopAllPlayers do local player = EID.coopAllPlayers[i] if player == nil then return listUpdatedForPlayers -- dont evaluate when bad data is present end ---@cast player EntityPlayer local playerNum = EID:getPlayerID(player, true) -- remove items the player no longer has. reverse iteration to make deletion easier for index = #EID.RecentlyTouchedItems[playerNum], 1, -1 do local itemID = EID.RecentlyTouchedItems[playerNum][index] if itemID > maxCollID or not player:HasCollectible(itemID, true) then table.remove(EID.RecentlyTouchedItems[playerNum], index) listUpdatedForPlayers[i] = true -- If an item earlier than our oldest item is removed (e.g. Eve sacrificial altaring her Dead Bird), reduce it if index < EID.OldestItemIndex[playerNum] then EID.OldestItemIndex[playerNum] = EID.OldestItemIndex[playerNum] - 1 end end end -- add items the player did get with non-standard methods (Bag of Crafting, console command, item effects, etc...) for _, itemID in ipairs(passives) do if itemID <= maxCollID and player:HasCollectible(itemID, true) then local alreadyInList = false for _, heldItemID in ipairs(EID.RecentlyTouchedItems[playerNum]) do if itemID == heldItemID then alreadyInList = true break end end if not alreadyInList then table.insert(EID.RecentlyTouchedItems[playerNum], itemID) listUpdatedForPlayers[i] = true end end end -- super hacky Tainted Cain active items check, mostly for transformation progress -- Only adds up to one copy of any active, I can't think of a good way to recognize a craft of the same active twice. better than nothing if EID.isRepentance and player:GetPlayerType() == 23 then for j = 0, 1 do local itemID = tostring(player:GetActiveItem(j)) if itemID ~= "0" then EID:InitActiveItemInteraction(itemID) if EID.PlayerItemInteractions[playerNum].actives[itemID] == 0 then EID.PlayerItemInteractions[playerNum].actives[itemID] = 1 end end end end end return listUpdatedForPlayers end EID.OldestItemIndex = {} function EID:SetOldestItemIndex() for i = 0, game:GetNumPlayers() - 1 do EID.RecentlyTouchedItems[i] = EID.RecentlyTouchedItems[i] or {} if EID.OldestItemIndex[i] == nil then EID.OldestItemIndex[i] = #EID.RecentlyTouchedItems[i] + 1 end -- set up Dead Tainted Lazarus to be oldest slot 1 if EID.OldestItemIndex[i+666] == nil then EID.OldestItemIndex[i+666] = 1 end end end EID.GulpedTrinkets = {} ---Check for gulped trinkets that have been removed (e.g. perfection, walnut) function EID:UpdateAllPlayerTrinkets() for i = 1, #EID.coopAllPlayers do local player = EID.coopAllPlayers[i] if player == nil then return end ---@cast player EntityPlayer local playerNum = EID:getPlayerID(player, true) if EID.GulpedTrinkets[playerNum] == nil then return end -- remove items the player no longer has. reverse iteration to make deletion easier for index = #EID.GulpedTrinkets[playerNum], 1, -1 do if not player:HasTrinket(EID.GulpedTrinkets[playerNum][index]) then table.remove(EID.GulpedTrinkets[playerNum], index) end end end end EID.WispsPerPlayer = {} ---This is automatically called shortly after main.lua sees a Lemegeton get used, and when the Item Reminder is opened function EID:UpdateAllPlayerLemegetonWisps() EID.WispsPerPlayer = {} for _, wisp in ipairs(Isaac.FindByType(3, 237, -1, true, false)) do local wispPlayer = wisp:ToFamiliar() and wisp:ToFamiliar().Player if wispPlayer then local playerNum = EID:getPlayerID(wispPlayer, true) EID.WispsPerPlayer[playerNum] = EID.WispsPerPlayer[playerNum] or {} table.insert(EID.WispsPerPlayer[playerNum], wisp) end end -- Sort wisps by age (newest first), and leave just the IDs for playerNum, wisps in pairs(EID.WispsPerPlayer) do table.sort(wisps, function(a, b) return a.FrameCount < b.FrameCount end) for i,v in ipairs(wisps) do wisps[i] = v.SubType end end end ---This table holds, for each pedestal in the room, a table of item IDs that have been on that pedestal, and timestamps of when they were first and last seen ---The Glitched Crown callback when describing the pedestal will sort by first timestamp, and delete entries with too old of a last timestamp (like after a reroll) EID.GlitchedCrownCheck = {} ---Watch pedestals for being a Glitched Crown style pedestal that flips between items too quickly to display descriptions for function EID:WatchForGlitchedCrown() if REPENTOGON then -- In REPENTOGON, always check even without Glitched Crown, allowing to check 5+ Soul of Isaac usage, or Everything Jar local curRoomIndex = game:GetLevel():GetCurrentRoomIndex() EID.GlitchedCrownCheck[curRoomIndex] = EID.GlitchedCrownCheck[curRoomIndex] or {} for _, entity in ipairs(Isaac.FindByType(5, 100, -1, true, false)) do -- Use InitSeed and Index to prevent any Diplopia weirdness EID.GlitchedCrownCheck[curRoomIndex][entity.InitSeed..entity.Index] = EID.GlitchedCrownCheck[curRoomIndex][entity.InitSeed..entity.Index] or {} local pickup = entity:ToPickup() ---@cast pickup EntityPickup local cycle = pickup:GetCollectibleCycle() if #cycle > 1 then for i, subType in ipairs(cycle) do EID.GlitchedCrownCheck[curRoomIndex][entity.InitSeed..entity.Index][subType] = EID.GlitchedCrownCheck[curRoomIndex][entity.InitSeed..entity.Index][subType] or {EID.GameUpdateCount + i, EID.GameUpdateCount + i} -- update the last frame seen for the pedestal's current collectible ID EID.GlitchedCrownCheck[curRoomIndex][entity.InitSeed..entity.Index][subType][2] = EID.GameUpdateCount + i end end end else if not EID.collectiblesOwned[689] then return end local curRoomIndex = game:GetLevel():GetCurrentRoomIndex() EID.GlitchedCrownCheck[curRoomIndex] = EID.GlitchedCrownCheck[curRoomIndex] or {} for _, entity in ipairs(Isaac.FindByType(5, 100, -1, true, false)) do local index = entity.InitSeed..entity.Index -- Use InitSeed and Index to prevent any Diplopia weirdness EID.GlitchedCrownCheck[curRoomIndex][index] = EID.GlitchedCrownCheck[curRoomIndex][index] or {} -- Initialize the data about this pedestal showing its current item ID, if necessary -- in order to sort the items displayed, while also trashing items that haven't shown up in a while, keep both "initial frame seen" and "last frame seen" EID.GlitchedCrownCheck[curRoomIndex][index][entity.SubType] = EID.GlitchedCrownCheck[curRoomIndex][index][entity.SubType] or {EID.GameUpdateCount, EID.GameUpdateCount} -- update the last frame seen for the pedestal's current collectible ID EID.GlitchedCrownCheck[curRoomIndex][index][entity.SubType][2] = EID.GameUpdateCount end end end --- Cached table for item pool infos EID.CachedCollectibleItemPools = {} ---Check Item Pools that contain certain collectible, updates EID.CachedCollectibleItemPools to returned value ---@param collectibleType CollectibleType ---@return table function EID:GetPoolsForCollectible(collectibleType) if not EID.isRepentance then return end local cached = EID.CachedCollectibleItemPools[collectibleType] if cached then return cached end local poolTable = {} if not REPENTOGON then -- non-rgon : catch from xmldata local CraftingItemPools = EID.XMLItemPools local ItemPoolTypeToXMLPool = { -- did this in case for excluding greed pools [0] = 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 20, 21, 19, 22, -- greed pools, enum does not match with xmldata 23, 24, 25, 26, 27, 28, 29, 30 } for i = 0, ItemPoolType.NUM_ITEMPOOLS - 1 do local xmlPool = ItemPoolTypeToXMLPool[i] local t = CraftingItemPools[xmlPool + 1] for _, element in ipairs(t) do if element[1] == collectibleType and element[2] > 0 and not Isaac.GetItemConfig():GetCollectible(collectibleType).Hidden then table.insert(poolTable, i) break end end end else -- rgon : catch from pool directly local pool = game:GetItemPool() local numPools = pool:GetNumItemPools() for i = ItemPoolType.POOL_TREASURE, numPools - 1 do local t = pool:GetCollectiblesFromPool(i) for _, element in ipairs(t) do if element.itemID == collectibleType and element.initialWeight > 0 and not Isaac.GetItemConfig():GetCollectible(collectibleType).Hidden then table.insert(poolTable, i) break end end end end EID.CachedCollectibleItemPools[collectibleType] = poolTable return poolTable end ---Assign Item Pool Markup for Custom item pools ---Normally this does nothing if REPENTOGON is not active ---@param itemPoolType ItemPoolType ---@param str String function EID:assignItemPoolMarkup(itemPoolType, str) if str ~= nil and type(str) == "string" then EID.ItemPoolTypeToMarkup[itemPoolType] = str end end ---Adds name for a item pool. ---Normally this does nothing if REPENTOGON is not active ---@param id ItemPoolType ---@param poolName string ---@param language EID_LanguageCode? @Default: "en_us" function EID:addItemPoolName(id, poolName, language) poolName = poolName or nil language = language or EID.DefaultLanguageCode if id == -1 then EID:WriteErrorMsg("Trying to add item pool name to id = -1, which is not allowed! (Name: "..tostring(poolName)..")") return end EID:CreateDescriptionTableIfMissing("itemPoolNames", language) EID.descriptions[language].itemPoolNames[id] = poolName end ---Replaces Variable placeholders in string with a given value ---Example: "My {1} message" --> "My test message" ---varID can be omitted to replace {1} (or pass in a string table, to replace {1}, {2}, etc.) ---@param str string ---@param varID integer | integer[] | string | string[] ---@param newString? string | string[] | integer function EID:ReplaceVariableStr(str, varID, newString) if newString == nil then newString = varID ---@diagnostic disable-line varID = 1 end if type(str) ~= "string" or newString == nil then return str end if type(newString) == "table" then for i = 1, #newString do str = str:gsub("{"..i.."}", newString[i]) end return str else return str:gsub("{"..varID.."}", newString) end end ---deep table copy, copied from http://lua-users.org/wiki/CopyTable ---@generic T ---@param orig T ---@return T function EID:CopyTable(orig) local orig_type = type(orig) local copy if orig_type == 'table' then copy = {} for orig_key, orig_value in next, orig, nil do copy[EID:CopyTable(orig_key)] = EID:CopyTable(orig_value) end setmetatable(copy, EID:CopyTable(getmetatable(orig))) else -- number, string, boolean, etc copy = orig end return copy end ---Super simple table concatenation: https://www.tutorialspoint.com/concatenation-of-tables-in-lua-programming ---@generic T ---@param table1 T[] ---@param table2 T[] ---@return T[] function EID:ConcatTables(table1, table2) if table1 == nil then table1 = {} end if table2 == nil then table2 = {} end for _, value in ipairs(table2) do table.insert(table1, value) end return table1 end ---Thing to fix find/replace pairs with hyphens (like "1-2") or pluses (like +1 Health) breaking because of special characters ---https://stackoverflow.com/questions/29072601/lua-string-gsub-with-a-hyphen ---@param str string ---@param what string ---@param with string ---@param count integer ---@return string, integer function EID:SimpleReplace(str, what, with, count) what = string.gsub(what, "[%(%)%.%+%-%*%?%[%]%^%$%%]", "%%%1") -- escape pattern with = string.gsub(with, "[%%]", "%%%%") -- escape replacement return string.gsub(str, what, with, count) end ---Simple decimal rounding, instead of just floor or ceil ---@param num number ---@param dp integer? ---@return number function EID:SimpleRound(num, dp) dp = dp or 2 local mult = 10^dp return math.floor(num * mult + 0.5)/mult end ---@param t any[] ---@param value any ---@return boolean function EID:ArrayContains(t, value) for _,v in ipairs(t) do if v == value then return true end end return false end ---Find the closest player to the given entity ---@param entity Entity ---@return EntityPlayer function EID:ClosestPlayerTo(entity) local closestDist = 9999999 local closestPlayer = EID.player or Isaac.GetPlayer() local numPlayers = game:GetNumPlayers() if EID.InsideItemReminder then return EID.ItemReminderPlayerEntity end if entity == nil or numPlayers == 1 then return closestPlayer end for i = 1, #EID.coopAllPlayers do local player = EID.coopAllPlayers[i] ---@cast player EntityPlayer local dist = player.Position:Distance(entity.Position) if dist < closestDist and player then closestDist = dist closestPlayer = player end end return closestPlayer end ---Creates a description table for a given language to prevent outdated languages from breaking EID API functions for mods ---@param tableName string ---@param language EID_LanguageCode function EID:CreateDescriptionTableIfMissing(tableName, language) if language and not EID.descriptions[language] then EID.descriptions[language] = {} end if tableName and not EID.descriptions[language][tableName] then EID.descriptions[language][tableName] = {} end end ---Returns true if the given pedestal-entity is hidden (questionmark sprite) ---@param entity Entity ---@return boolean function EID:IsItemHidden(entity) if EID:getEntityData(entity, "EID_DontHide") == true then return false end -- Repentogon check (does not account for curse of blind), -- with expection for alt path item descriptions when config option is enabled if REPENTOGON and entity:ToPickup():IsBlind() and not (not EID.Config["DisableOnAltPath"] and EID:IsAltChoice(entity)) then return true end if (EID.Config["DisableOnCurse"] and EID:hasCurseBlind() and not entity:ToPickup().Touched and not EID.isDeathCertRoom) or (EID.Config["HideUncollectedItemDescriptions"] and EID:requiredForCollectionPage(entity.SubType)) or (EID.Config["DisableOnAltPath"] and not entity:ToPickup().Touched and EID:IsAltChoice(entity)) or (EID.Config["DisableOnAprilFoolsChallenge"] and game.Challenge == Challenge.CHALLENGE_APRILS_FOOL) then return true end return false end ---Returns true if the position at the given grid location is valid for pathfinding. ---gridPosition is a table with an x and y entry. Its derived from the FindVector class of "LuaFindings" module (pathfinder) ---@param gridPosition table ---@return boolean function EID:EvaluateLocation(gridPosition) local room = game:GetRoom() local width = room:GetGridWidth() local height = room:GetGridHeight() if gridPosition.x < 1 or gridPosition.y < 1 or gridPosition.x >= height-1 or gridPosition.y >= width - 1 then return false end local gridIndex = gridPosition.x * room:GetGridWidth() + gridPosition.y -- GridPath contains a value that defines if a poisiton is walkable by the ingame pathfinder -- numbers bigger 900 are considered not walkable (spikes, rocks, pits, grimaces, etc.) -- numbers smaller 900 are walkable but less preferable. 0 is most preferable local collision = room:GetGridPath(gridIndex) return collision <= 900 end ---Returns true if an unobstructed path between the start and end position exists. ---Obstructions can be something like Rocks, Pits, Walls, etc. ---@param startPos Vector ---@param endPos Vector ---@return boolean function EID:HasPathToPosition(startPos, endPos) -- divide by 40 to convert from world to grid coords local room = game:GetRoom() local width = room:GetGridWidth() local startGI = room:GetGridIndex(startPos) local endGI = room:GetGridIndex(endPos) -- convert GridIndex into Grid X,Y coordinates and calculate path local pathfinderObj = EID.Pathfinder(Vector(startGI%width, math.floor(startGI/width)), Vector(endGI%width, math.floor(endGI/width)), EID.EvaluateLocation) -- return true if it has a path return pathfinderObj:GetPath() ~= nil end ---Applies language specific modifiers that aim to pluralize a text ---@param text string ---@param amount integer function EID:TryPluralizeString(text, amount) local func = EID:getDescriptionEntry("PluralizeFunction") if type(func) == "function" then return func(text,amount) end return text end ---Prints a message in both the console and the Log file, to make important messages from EID stand out everywhere ---@param str string function EID:WriteDebugMsg(str) print(str) Isaac.DebugString(str) end ---Prints an error message in both the console and the Log file, to make important messages from EID stand out everywhere ---@param str string function EID:WriteErrorMsg(str) EID:WriteDebugMsg("EID ERROR: " .. str) end