Custom Spell Cards - brandonandzeus/Trainworks2 GitHub Wiki

Custom Spell Cards

If you haven't yet, it's highly recommended that you at least read through the Modifying an Existing Card tutorial to familiarize yourself with how Monster Train handles Cards, Traits, and Effects.

In this tutorial, we'll create several increasingly complex spell cards. For now, we'll add them to the Stygian Guard clan. Once we finish the custom clan tutorial, we'll move them into a new clan.

IMPORTANT Ensure that you are have the using statements for Trainworks.BuildersV2 and Trainworks.ConstantsV2 these are the newer Trainworks API and are incompatible with the older API

Not Horn Break

The first spell we're going to make is Not Horn Break. It's a card that deals 5 damage and has the Piercing trait. It will also use fully custom (non-animated) art.

If you don't have them already, make sure to add using statements for Trainworks.BuildersV2 and Trainworks.ConstantsV2. Ensure you use the Trainworks.BuildersV2 statement and not Trainworks.Builders (the old API).

Let's set up the art now. Navigate to your plugin's folder (the one your dll gets copied to when you build), and create an "assets" folder in it. Then we need art to put in there. Fortunately for you, I've already taken the liberty of preparing some for us. No need to thank me. Download this image and save it there. To save some trouble with downloading assets in the future, download them all from here

That's not the only thing I've prepared for us, though! Going through this line-by-line's a bit hard to follow, so I will drop the whole darn card on you and explain it afterward. Here:

new CardDataBuilder
{
    CardID = TestPlugin.GUID + "_NotHornBreak",
    Name = "Not Horn Break",
    Description = "Deal [effect0.power] damage",
    Cost = 1,
    Rarity = CollectableRarity.Common,
    TargetsRoom = true,
    Targetless = false,
    ClanID = VanillaClanIDs.Stygian,
    AssetPath = "assets/nothornbreak.png",
    CardPoolIDs = { VanillaCardPoolIDs.MegaPool },
    EffectBuilders =
    {
        new CardEffectDataBuilder
        {
            EffectStateType = typeof(CardEffectDamage),
            ParamInt = 5,
            TargetMode = TargetMode.DropTargetCharacter
        }
    },
    TraitBuilders =
    {
        new CardTraitDataBuilder
        {
            TraitStateType = typeof(CardTraitIgnoreArmor)
        }
    }
}.BuildAndRegister();

This is very long and intimidating, but it's actually extremely simple. Promise. Let's go through one field at a time:

  • CardID: The biggest gotcha here is that this must be unique. I recommend putting your mod GUID, or clan name with underscores at the start of the ID. If you recall, we made constants for the GUID, name, and version when we first set up the project. This is the reason why.
  • Name: Whatever you want. Note that it'll be the same in all languages. If you want to do localization, find details on custom localization in the custom localization tutorial.
  • Description: This is the card text, excluding traits like piercing (which add themselves). Note the [effect0.power]. This is how you get numbers to show up in your card description. Don't hardcode them; otherwise, you will break shop upgrades! Get them from your effects instead. In this case, it'll get the power of the effect at index 0 in the card's effects list.
  • Cost: The ember cost. Pretty self-explanatory.
  • Rarity: The valid rarities are common, uncommon, rare, starter, and champion.
  • TargetsRoom and Targetless: Values for these aren't always obvious. When in doubt, look up a card with a similar targeting mechanism in MonsterTrainGameData and copy it.
  • ClanID: You can either get a vanilla clan ID from the framework constants or use a custom one.
  • AssetPath: This is the path relative to the location of your plugin dll. For cards, it sets the card art.
  • CardPoolIDs: The card pools the card can appear in. For most spell cards, you'll want to put them into the MegaPool, which is the one used by the game for most things. There are a few CardPools for specific Cavern events. For a list of all CardPools available, see this.
  • EffectBuilders: Just like how there's a CardDataBuilder to help build CardData, there are also CardEffectDataBuilders to help build CardEffectData. Card effects all use their parameters differently; for example, some effects might not use ParamInt. The best way to figure out how to make a given card effect is to look up a card with it in MonsterTrainGameData. You will notice nearly all damaging spell cards use the CardEffectDamage EffectStateType. You can look up this class in your C# decompiler to see how the CardEffect works.
  • TraitBuilders: Same as above.

Once all the appropriate fields are set, we need to do two things:

  • Build the CardData.
  • Register the CardData with the CustomCardManager. If we don't do this, the card won't show up anywhere.

You could do these two steps separately, but since doing them together is so common, builders that need to register content provide a BuildAndRegister() method that saves you the trouble.

Testing

If you haven't already, take a look at the Using Harmony tutorial. The last patch in it shows all cards in the logbook, which is convenient for testing.
If you boot up the game, go to the logbook, and search for Not Horn Break, you should see it doing exactly what we expect it to. If not, check the log for errors, and read over the previous steps again to make sure you've done everything properly. If you can't figure it out, ask in the modding Discord, and we can help.

This raises the question: How can I test to ensure my card works? The easiest way is with another Harmony patch.

[HarmonyPatch(typeof(SaveManager), nameof(SaveManager.SetupRun))]
class AddCardToStartingDeckPatch
{
    static void Postfix(ref SaveManager __instance)
    {
        var id = TestPlugin.GUID + "_NotHornBreak";
        __instance.AddCardToDeck(CustomCardManager.GetCardDataByID(id));
    }
}

This will add the card to the starting deck for easy testing.

Give Everyone Armor

Our next card will be named "Give Everyone Armor." This spell has the wholly unique effect of giving everyone on the current floor 2 Armor. It serves as an introduction to creating cards that apply status effects.

Download the fully custom card art if you haven't already from here.

If you read through the Modifying an Existing Card tutorial, you may remember how annoying it was to modify an existing card. That's because we had to do it from scratch, without many of the nice facilities the framework provides. This time it'll be much more pleasant. As before, we'll jump right into the finished product:

new CardDataBuilder
{
    CardID = TestPlugin.GUID + "_GiveEveryoneArmor",
    Name = "Give Everyone Armor",
    Description = "Give everyone <nobr>[armor] [effect0.status0.power]</nobr>.",
    Cost = 0,
    Rarity = CollectableRarity.Uncommon,
    TargetsRoom = true,
    Targetless = true,
    ClanID = VanillaClanIDs.Stygian,
    AssetPath = "assets/giveeveryonearmor.png",
    CardPoolIDs = { VanillaCardPoolIDs.MegaPool },
    EffectBuilders =
    {
        new CardEffectDataBuilder
        {
            EffectStateType = typeof(CardEffectAddStatusEffect),
            TargetMode = TargetMode.Room,
            TargetTeamType = Team.Type.Monsters | Team.Type.Heroes,
            ParamStatusEffects =
            {
                new StatusEffectStackData
                {
                    statusId = VanillaStatusEffectIDs.Armor,
                    count = 2
                }
            }
        }
    }
}.BuildAndRegister();

You'll note that not much is different from Not Horn Break. Let's discuss the following things that are different:

  • Description: How did I figure out what to put here? I used an existing Armor card as a base. I looked up Alloy's overrideDescriptionKey in AssetStudio and printed it to the console: Debug.Log(I2.Loc.LocalizationManager.GetTranslation("CardData_overrideDescriptionKey-26e01e44a11eff46-42c94d44efd339b4ba1b341db632ece6-v2")); At that point, all I had to do was lightly rearrange it to fit the new card. If you're ever stumped as to how the base game created a given description, that's a good way to figure it out.
  • Targetless: This card does not target anything specific, so it is targetless. If you're unsure what you should've put here (I wasn't), again, lookup an existing card. I looked up Inferno.
  • EffectStateType: This is no different from when we put CardEffectDamage, but I'm calling attention to it regardless because I'd like to point out the obvious in how general this effect type is. All status effects use this same type, CardEffectAddStatusEffect. Again these are classes you can look up in your C# decompiler to see how they work.
  • TargetMode: It targets the entire room, so it uses TargetMode.Room. Very sensible. To lookup all of the possible TargetModes, see the TargetMode enum in your C# decompiler.
  • TargetTeamType: Minor gotcha here. This card can target both monsters and heroes, so you might assume that you want to use the AND operator here, but not so! Think of it this way: if a unit is either a monster or a hero, this card will target it. Using AND would tell the game to only target units that are both monsters AND heroes, which is equivalent to "none" (an actual TargetTeamType that sees use in-game).
  • ParamStatusEffects: Like ParamInt from Not Horn Break, except for status effects. EffectDataBuilders have tons and tons of param fields. Different effects make use of different ones. In this case, we have no use for ParamInt, but we do want to tell CardEffectAddStatusEffect which status effect to add, so we do that here.

Add it to your starter deck to make sure it works, then move on to the next card of this tutorial!

Play Other Cards

For this tutorial's next card, we shall create the ominously-named-but-otherwise-innocent "Play Other Cards." For the low cost of 2 energy, you can give one of your monsters +2 attack per card played in this battle. It serves as an introduction to scaling effects.

As usual, download the art from here, then gaze upon the code below.

new CardDataBuilder
{
    CardID = TestPlugin.GUID + "_PlayOtherCards",
    Name = "Play Other Cards",
    Description = "Give a friendly unit +<nobr>[trait0.power][attack]</nobr> for each card played this battle.",
    Cost = 2,
    Rarity = CollectableRarity.Rare,
    TargetsRoom = true,
    Targetless = false,
    ClanID = VanillaClanIDs.Stygian,
    AssetPath = "assets/playothercards.png",
    CardPoolIDs = { VanillaCardPoolIDs.MegaPool },
    TraitBuilders =
    {
        new CardTraitDataBuilder
        {
            TraitStateType = typeof(CardTraitScalingBuffDamage),
            ParamTrackedValue = CardStatistics.TrackedValueType.AnyCardPlayed,
            ParamEntryDuration = CardStatistics.EntryDuration.ThisBattle,
            ParamInt = 1,
            ParamTeamType = Team.Type.Monsters
        }
    },
    EffectBuilders =
    {
        new CardEffectDataBuilder
        {
            EffectStateName = typeof(CardEffectBuffDamage),
            TargetMode = TargetMode.DropTargetCharacter,
            TargetTeamType = Team.Type.Monsters
        }
    }
}.BuildAndRegister();

This sort of scheme is generally how the game handles variable effects. Let's go over the things that changed:

  • Description: You know how some cards have sprites in their descriptions, like for, say, the attack symbol? This is how it's done.
  • TraitBuilders: Now's a good time to point out that any builders nested inside of another builder will build automatically when the outermost one is built. That's why we didn't have to worry about calling Build() on the EffectBuilder from Give Everyone Armor and also why we don't have to do it here.
  • ParamTrackedValue: This is the variable the card will use to scale its effect. If you want to use one that doesn't already exist in vanilla, you'll have to implement it yourself (which is not straightforward and will be covered in a future tutorial). In this case, we use AnyCardPlayed, which you may be shocked to hear increments any time a card is played.
  • ParamEntryDuration: How long the value should be tracked for. This is where you put whether you want it to count cards played this turn or this battle. We want this battle.
  • ParamInt: The purpose of this field, as with effects, varies by trait type. In this case, it's how much damage the card gains when its trigger is triggered.
  • EffectStateType: You'll note that the effect's type, CardEffectBuffDamage, is very similar to the trait's type, CardTraitScalingBuffDamage. This is important.

You may have noticed we omitted ParamInt from CardEffectDataBuilder; not to worry, ParamInt defaults to 0. The scaling amount given by the CardTrait will be added to the value given in the CardEffect. So if you wanted to add a flat amount to buff in addition to 2 * for each card played, you can easily do that as well by setting ParamInt inside the CardEffect to a value other than 0.

Add it to your starting deck, hop into a battle, play other cards, and notice how the card gradually ramps up in damage.

Pyre Sharpener

The next card touches on cards with triggers. So far, all the cards have only had effects that play when you play the card in battle. You can add triggers to a card to have different effects play when the trigger's condition is met. Triggers on cards are represented with a CardTriggerEffectData instance. A CardTriggerEffectData has a Trigger type and a list of CardEffectData for the CardEffects to play when the Trigger is triggered.

With that, let's make a card that has a Reserve trigger that gives your pyre +3 attack. If you recall, Reserve triggers at the end of the turn if the card is in your hand. The card is also unplayable, meaning you can't play this card like other cards. This card's name is called Pyre Sharpener.

var sharpenPyre = new CardEffectDataBuilder
{
    EffectStateType = typeof(CardEffectBuffDamage),
    TargetMode = TargetMode.Pyre,
    TargetTeamType = Team.Type.Monsters,
    ParamInt = 3,
};

var trigger = new CardTriggerEffectDataBuilder
{
    TriggerID = TestPlugin.GUID + "_PyreSharpenerReserve",
    Trigger = CardTriggerType.OnUnplayed,
    Description = "Apply +[effect0.power][attack] to your Pyre.",
    CardEffectBuilders = { sharpenPyre },
};

new CardDataBuilder
{
    CardID = TestPlugin.GUID + "_PyreSharpener",
    Name = "Pyre Sharpener",
    CostType = CardData.CostType.NonPlayable,
    Cost = 0,
    Rarity = CollectableRarity.Common,
    TargetsRoom = true,
    Targetless = true,
    ClanID = VanillaClanIDs.Stygian,
    AssetPath = "assets/pyresharpener.png",
    CardPoolIDs = { VanillaCardPoolIDs.MegaPool },
    TriggerBuilders = { trigger },
    TraitBuilders =
    {
        new CardTraitDataBuilder
        {
            TraitStateType = typeof(CardTraitUnplayable),
        }
    }
}.BuildAndRegister();

Some interesting things to go over.

  • sharpenPyre: Just noting that you don't have to shove everything into new CardDataBuilder if you find that the nesting is too deep, feel free to split your cards into multiple variables and then use them later. Here we see another use of CardEffectBuffDamage and is an example of how to target the Pyre itself!
  • trigger: Here's something new. A CardTriggerEffectDataBuilder this class will build the CardTriggerEffectData for us when built.
  • trigger.TriggerID: We give the CardTriggerEffect a unique ID. Just like cards, triggers have an id, which the framework uses to generate a unique localization key for the trigger description.
  • trigger.Trigger: This is the in-game trigger type. Reserve corresponds to CardTriggerType.OnUnplayed for a full list of CardTriggerTypes available lookup the CardTriggerType enum in your C# decompiler.
  • trigger.Description: You may notice that triggers can have descriptions! Triggers will add themselves to the Card text, so there's no need to set Description in CardEffectDataBuilder this time. It's better to set the description within the trigger as you can properly use the substitution text [effect0.power] here.
  • CostType: You can set a cost type for a card. This can either be Default, ConsumeRemainingEnergy (X cost card), or NonPlayable. Note that setting NonPlayable is not enough to make the card non-playable weirdly.
  • TraitBuilders: If you were wondering how to make a card actually non-playable, it's done through a CardTrait. Here we use CardTraitUnplayable to make the card actually non-playable.

So add this card to your initial deck and give it a whirl. You may notice the card looks different than other cards. CardTraits can also change how a card looks, and CardTriggerEffects can as well!

Icy Boost.

The final card in this tutorial will touch on another aspect of cards, applying buffs to other cards. In previous cards, we applied buffs to units and a buff to the pyre. Some examples of cards that upgrade other cards are Channelsong, Gifts for a Guard, and Bounding Echos. These cards change some aspects of other cards in some way. Internally, these cards apply a CardUpgrade to another card. A CardUpgrade can change just about anything about a card, such as adding CardTriggers, changing the amount of damage a card does, reducing the cost of a card, and applying new CardTraits to a card. The sky's the limit!

CardUpgrades can sometimes be temporary (lasting a single battle) or permanent (applied for the rest of the run). We can also restrict cards from receiving the CardUpgrade through a CardUpgradeMask. A CardUpgradeMask can allow you to restrict upgrades to a specific set of cards or cards fulfilling certain conditions. You can also blacklist and whitelist cards through these masks.

So with that, let's make a card that utilizes these two concepts CardUpgrade and CardUpgradeMask.

This card is called Icy Boost. For just 2 ember, all Spell Cards in your hand gain 30 magic power, cost 2 ember less, and became Frozen. For such a deal, we can't allow the player to abuse immediately, so let's also make this card Consume.

Let's dive into the code. There's a lot of it, but we will cover it piece by piece.

var bannedCardPool = new CardPoolBuilder
{
    CardPoolID = TestPlugin.GUID + "_IcyBoostCardUpgradeMaskBannedPool",
    CardIDs =
    {
        VanillaCardIDs.UnleashtheWildwood,
        VanillaCardIDs.AdaptiveMutation,
    }
}.Build();

var onlyDamagingHealingSpells = new CardUpgradeMaskDataBuilder
{
    CardUpgradeMaskID = TestPlugin.GUID + "_IcyBoostCardUpgradeMask",
    CardType = CardType.Spell,
    RequiredCardEffectsOperator = CardUpgradeMaskDataBuilder.CompareOperator.Or,
    RequiredCardEffects =
    {
        "CardEffectDamage",
        "CardEffectHeal",
        "CardEffectHealAndDamageRelative"
    },
    DisallowedCardPools = { bannedCardPool },
};

var cheapen = new CardUpgradeDataBuilder
{
    UpgradeID = TestPlugin.GUID + "_IcyBoostCardUpgrade",
    BonusDamage = 30,
    BonusHeal = 30,
    CostReduction = 2,
    XCostReduction = 2,
    TraitDataUpgradeBuilders =
    {
        new CardTraitDataBuilder
        {
            TraitStateType = typeof(CardTraitFreeze),
        }
    },
    FiltersBuilders =
    {
        onlyDamagingHealingSpells,
    }
};

new CardDataBuilder
{
    CardID = TestPlugin.GUID + "_IcyBoost",
    Name = "Icy Boost",
    Description = "Apply +[effect0.upgrade.bonusdamage] [magicpower], -[effect0.upgrade.costreduction][ember], and [permafrost] to spells in hand.",
    Cost = 2,
    Rarity = CollectableRarity.Common,
    TargetsRoom = true,
    Targetless = true,
    ClanID = VanillaClanIDs.Stygian,
    AssetPath = "assets/icyboost.png",
    CardPoolIDs = { VanillaCardPoolIDs.MegaPool },
    EffectBuilders =
    {
        new CardEffectDataBuilder
        {
            EffectStateType = typeof(CardEffectAddTempCardUpgradeToCardsInHand),
            TargetMode = TargetMode.Hand,
            TargetTeamType = Team.Type.Monsters,
            ParamCardUpgradeDataBuilder = cheapen,
        }
    },
    TraitBuilders =
    {
        new CardTraitDataBuilder
        {
            TraitStateType = typeof(CardTraitExhaustState),
        }
    }
}.BuildAndRegister();

Wow, that is a mouthful. Let's start the explanation variable by variable.

  • bannedCardPool: This is a CardPool; much like in the other cards where we added them to the MegaPpool, we can create our own specialized CardPools containing a specific set of cards. Here we don't want to apply this upgrade to cards that do a full heal.

  • onlyDamagingHealingSpells: Here's our CardUpgradeMaskData. It's an appropriately named variable that will define a CardUpgradeMaskData to target only Damaging and Healing Spell Cards

  • onlyDamagingHealingSpells.CardType: Important to set the Card Type. Otherwise, cards like Self-Mutilation, which is CardType.Blight can be upgraded... Yikes!

  • onlyDamagingHealingSpells.RequiredCardEffectsOperator: We want to filter out any cards that do not have any of these CardEffects. This, combined with RequiredCardEffects, will specify any Card with CardEffectDamage, CardEffectHeal, or CardEffectHealAndDamageRelative. Do not forget to set the Operator; otherwise, you may get unintended behavior.

  • onlyDamagingHealingSpells.DisallowedCardPools: Here's where we use the CardPool we just defined. We now restrict UnleashtheWildwood and AdaptiveMutation from receiving this upgrade.

  • cheapen: Here's a new Builder for you, a CardUpgradeDataBuilder. Again CardUpgrades can do many things, so this Builder is pretty big. Let's go over the fields set.

  • cheapen.UpgradeID: It is important to set this; it is a unique ID for the upgrade so the game can find it.

  • cheapen.BonusDamage, cheapen.BonusHeal: This is usually the amount of damage and healing to add to the Card. These are set together. If you wanted to lower the damage, these values can be negative.

  • cheapen.CostReduction, cheapen.XCostReduction: This is the amount of ember cost reduction to apply. It can be negative to increase the cost of the card. Again these two values are set to the same value usually.

  • cheapen.TraitDataUpgradeBuilders: Here is the field to add Traits to the cards the CardUpdate is applied to. Here we use CardTraitFreeze to make the cards frozen afterward.

  • cheapen.FiltersBuilders: Lastly, we add our CardUpgradeMask to restrict our upgrade to being applied to only the cards we want.

  • Description: You are able to reference data from the CardUpdate to make your card's description. CardUpgrades do not add themselves to the description.

  • EffectBuilders: Here, we use a new CardEffect that specifically deals with CardUpgrades. CardEffectAddTempCardUpgradeToCardsInHand does exactly what it states. This CardEffect takes a CardUpgrade and applies it to cards matching the CardUpdateMask. We set the TargetMode to target the hand and give it the CardUpdate to apply.

  • TraitBuilders: Last but not least, we want to give the card Consume. If you guess that Consume is handled through a CardTrait, congrats! The CardTrait class for consume is named CardTraitExhaustState.

Congrats now. Add it to your deck and try it out.

Pat yourself on the back as you've made 5 cards. Most cards in Monster Train follow these Effects and Triggers, so you can make just about any card in the game now. Not that this doesn't handle all of the bases, but now you have the tools to figure out the rest. With AssetStudio and some research, you can now do just about anything you want with cards!

To summarize, you should now know the following aspects of cards:

  1. CardTraits and how to design a card with a few specific Card Traits Frozen, Exhaust, and Piercing.
  2. How to make a damaging spell card.
  3. How to make a card that adds status effects.
  4. How to make a card with a scaling effect.
  5. How to buff a unit or your Pyre with a Card
  6. How to make a card with a trigger.
  7. How to make a card upgrade with a mask.
  8. How to apply a card upgrade to cards in hand.

Next: Custom Monster Cards

⚠️ **GitHub.com Fallback** ⚠️