Modifying an Existing Card - brandonandzeus/Trainworks2 GitHub Wiki

Modifying an Existing Card

Surprisingly, modifying existing content is slightly more difficult than adding new content, but understanding how it works will make it easier to add your own cards later on.

A primer on cards.

Monster Train has two classes, CardData and CardState. Think of CardData as the template (a Class) for the card and CardState as the instance. There can be many CardStates (you can have five Frozen Lances in your deck), but each card will only ever have one CardData. We want to modify the Data for Frozen Lance so that we can apply only one change, and all Frozen Lance cards that are ever created will parrot it. Most types in the Monster Train codebase follow this Data/State pattern, so be sure to make note of this.

Starting off

Create a new method named ModifyFrozenLance(), and call it from your plugin's Initialize() method.

private void ModifyFrozenLance()
{
}

Note that if you are having trouble following along, the full code for this tutorial can be found here

Getting the CardData

The first thing we need to do is get the CardData for FrozenLance. We can do this easily with the framework. Add a new using statement for Trainworks.Managers and Trainworks.ConstantsV2.

To get the ID, The framework provides constants for every bit of GameData that exists in the game: string frozenLanceID = VanillaCardIDs.FrozenLance;

Then to get the card data, The framework provides managers to get the associated GameData if given the ID: var frozenLanceData = CustomCardManager.GetCardDataByID(frozenLanceID);

Now that we have Frozen Lance's CardData, let's make some adjustments.

The most annoying thing about modifying existing cards is how just about every field is private. Ordinarily, this means you'd need to use Reflection to access them, but Harmony provides two much more convenient ways: AccessTools and Traverse. AccessTools is (slightly) faster and Traverse has easier-to-use syntax, so use Traverse when speed isn't a concern. In this example, this code is run exactly once on startup, so we don't mind the (very) minor performance hit.

With that out of the way, let's talk about goals. We want to do three things:

  • Add Piercing to Frozen Lance.
  • Change its damage to 12.
  • Make it apply 327 Frostbite on hit.

Let's get started.

Adding Piercing

Piercing is what's known as a CardTraitState. We will refer to CardTraitState as simply a CardTrait. CardTraits modify the behavior of a Card. Some examples of CardTraits are Offering, Consume, Infused, Holdover, and Permafrost. Piercing, in particular, is a very easy CardTrait to add since the data for this particular CardTrait doesn't use any parameters. All we need to do is create a new CardTraitData, set its traitStateName field via a call to Setup and pass in a CardTraitState subclass, and lastly, add it to Frozen Lance's CardTraits list. The associated CardTraitState class for Piercing is the class CardTraitIgnoreArmor. You can look this class up in your C# decompiler, but it isn't interesting.

var piercingTrait = new CardTraitData();
piercingTrait.Setup("CardTraitIgnoreArmor");
frozenLanceData.GetTraits().Add(piercingTrait);

Piercing is nice because it doesn't require reflection to make. Other traits can be a bit tougher, but they will be plenty doable once we cover the basics of using Harmony to set private fields.

CardTraitData vs CardTraitState

To reiterate the starting section with CardData and CardState. There's also CardTraitData and CardTraitState. CardTraitData specifies the type of CardTrait and any parameters to pass to the CardTraitState. The actual implementation of the CardTrait is done in the CardTraitState subclass. Making your own CardTrait is explained much later, but note the distinction.

Modifying damage

Modifying damage is a bit more involved. A bit more on how cards work. Each CardData instance contains a list of CardEffectData objects that specify what effect the card has when played. CardEffects are specified as a CardEffectState subclass which does the actual work, it's exactly the same relationship as CardTraits. The Data class specifies what State class to use and any parameters to pass to the State class once it's instantiated.

Given that to modify a card's damage, we need to look up the Damage CardEffectData in Frozen lance, and then modify the amount of damage it specifies.

If you ever need to look up what a card does, I have made a repository with all of the GameData in an easy-to-view format. Here's Frozen Lance.

{
  "nameKey": "CardData_nameKey-cf98d38af789ae23-337a4227e5d4cce41aafe3304937a72f-v2",
  "cost": 1,
  "overrideDescriptionKey": "CardData_overrideDescriptionKey-d8e87d13cee59cc9-337a4227e5d4cce41aafe3304937a72f-v2",
  "effects": [
    {
      "effectStateName": "CardEffectDamage",
      "targetMode": 2,
      "paramInt": 6
    }
  ],
  "linkedClass": "ClassStygian",
  "cardLoreTooltipKeys": [
    "CardData_data-fb9bb1cbabc7a7d0-337a4227e5d4cce41aafe3304937a72f-v2"
  ],
  "name": "Frozen Lance"
}

How to read this data

This is in JSON format and should be easy enough to read. The interesting portion for now is the "effect": [] as that lists the CardEffectData objects the card has. In this case, there's only 1 CardEffectData. Just note that each key should line up with the fields of the class in your C# decompiler.

    {
      "effectStateName": "CardEffectDamage",
      "targetMode": 2,
      "paramInt": 6
    }

This specifies that the CardEffectState type is CardEffectDamage. targetMode is uninteresting, but this specifies the target; of course, the magic number 2 here corresponds to FrontInRoom a member of the TargetMode enum (to see all of the numerical values lookup TargetMode in your C# decompiler). The last interesting piece of data here is "paramInt" being set to 6.

So from this information, here are the steps we can take.

  1. Get the only CardEffectData instance from Frozen lance.
  2. Modify the paramInt and change it to 12.

First, get the CardEffect:

CardEffectData frozenLanceDamageEffect = frozenLanceData.GetEffects()[0];

Unfortunately, paramInt is a private field, like just about everything in Monster Train. It has a getter but no setter, so we need to use Reflection to change it. As discussed before, we'll be using Harmony's Traverse.

var t = Traverse.Create(frozenLanceDamageEffect);
t.Field("paramInt").SetValue(12);

Note that if you wanted to use AccessTools instead, it would be

AccessTools.Field(typeof(CardEffectData), "paramInt").SetValue(frozenLanceDamageEffect, 12);

Now Frozen Lance deals 12 damage. You'll note that the card text has already been updated to match, as it did with Piercing.

Adding Frostbite

Adding status effects to existing cards is a bit more challenging, so this is going to get quite involved. Buckle in.

First, we need to create the CardEffectData that'll add the Frostbite. You can find out how it's done by searching another Frostbite card in MonsterTrainGameData. Let's take a look at Forgone Power.

{
  "nameKey": "CardData_nameKey-7e6e3e77df782709-0b3e3cb4f647cdb46939903e53c95fd3-v2",
  "overrideDescriptionKey": "CardData_overrideDescriptionKey-8710e064626091ef-0b3e3cb4f647cdb46939903e53c95fd3-v2",
  "effects": [
    {
      "effectStateName": "CardEffectAddStatusEffect",
      "targetMode": 2,
      "paramStatusEffects": [
        {
          "statusId": "poison",
          "count": 6
        }
      ]
    },
    {
      "effectStateName": "CardEffectRandomDiscard",
      "targetMode": 18,
      "targetTeamType": 3,
      "targetCardType": 4,
      "shouldTest": false,
      "paramInt": 1
    }
  ],
  "linkedClass": "ClassStygian",
  "cardLoreTooltipKeys": [
    "CardData_data-4a850150572d2fda-0b3e3cb4f647cdb46939903e53c95fd3-v2"
  ],
  "name": "Forgone Power"
}

There's a bit more data this time, but let's pick out the only part we are interested in.

    {
      "effectStateName": "CardEffectAddStatusEffect",
      "targetMode": 2,
      "paramStatusEffects": [
        {
          "statusId": "poison",
          "count": 6
        }
      ]
    },

On Status Effects

Status Effects are handled via the CardEffect subclass, CardEffectAddStatusEffect. This CardEffect class requires us to specify the status effect to add via paramStatusEffects You may notice that the word frostbite doesn't appear anywhere in the above blob of text. Status effects are specified via a statusId. Unfortunately, the statusId doesn't always match what's presented in-game. The statusId for Frostbite is called poison. To alleviate this issue, the framework provides constants with the statusId for each status effect. Lastly, CardEffectAddStatusEffect requires us to specify how much of that status effect to apply to the target. That is done by specifying a count along with the statusId to apply.

Builder classes

To save some trouble with using reflection to create the data we want, the framework has various Builder classes for each GameData object present in the game. CardEffectData can be created with a CardEffectDataBuilder. These Builder classes are designed to easily create the GameData object and ensure all required fields are set to prevent errors.

Note that the above example with adding the Piercing CardTraitData to Frozen Lance could have also been done with a Builder, specifically CardTraitDataBuilder.

So here's the frostbite CardEffectData, using a CardEffectDataBuilder.

CardEffectData frostbiteEffect = new CardEffectDataBuilder
{
    EffectStateType = typeof(CardEffectAddStatusEffect),
    TargetMode = TargetMode.LastTargetedCharacters,
    TargetTeamType = Team.Type.Heroes,
    ParamStatusEffects =
    {
        new StatusEffectStackData {statusId = VanillaStatusEffectIDs.Frostbite, count = 327 },
    }
}.Build();

This is a lot to take in, so an explanation of what's going on.

  1. EffectStateType specifies which CardEffectState type that gets instantiated when the CardState is created. In this case, the effect we want is CardEffectAddStatusEffect. We use the typeof operator to get the Type object corresponding to the class for EffectSyateType.
  2. TargetMode specifies what the CardEffect is going to target, Since we are appending this CardEffect to the card, we can use the LastTargetedCharacters TargetMode.
  3. TargetTeamType specifies which team to target. Heroes, Monsters, or Both. We want to target Heroes. This, combined with TargetMode will specify the last targeted hero. Note that Heroes here means the Enemies, and Monsters mean our units. This parameter if not specified, will default to Heroes.
  4. ParamStatusEffects specifies status effect parameters to the card effect. In this case, CardEffectAddStatusEffect determines what status effects to add through ParamStatusEffects. ParamStatusEffects wants a list of StatusEffectStackData, so we create a list with one item specifying Frostbite and 327 stacks of it.

Last but not least, we need to add this entire effect to the Frozen Lance CardData's effects list:

frozenLanceData.GetEffects().Add(frostbiteEffect);

Finally (on how Cards generate their descriptions)

If you boot up the game, you'll notice that Frozen Lance pierces, deals 12 damage, and inflicts 327 frostbite. But, strangely enough, the frostbite isn't in the description. Descriptions are explained more in the custom localization tutorial, but the short version:

  • Traits add themselves to card text.
  • Effects do not; they are displayed as part of the card's description, which is stored in the game assets.
  • To modify the card's description, we would have to either write a Harmony patch or replace it entirely. In the custom localization tutorial, we will replace it entirely.

Wrapping up

Now you should have all of the information needed to modify existing cards and if you wish to only modify cards in your mod, this should be sufficient. However, it is still recommended to continue with the next few tutorials; we will cover other parts of cards, clans, units, and relics and how the framework can help build those for you without Reflection.

A few on-your-own exercises.

  1. How would you add Permafrost instead of Piercing to Frozen Lance instead? Hint: There are a few cards in the Stygian Guard clan with this Trait.
  2. How would you modify Frozen Lance to Attack the front-friendly (Monster) unit instead? Or How would you change the card to attack the Back Enemy (Hero) unit instead?
  3. Harder! How would you add Holdover to the Card Spreading Spores?
  4. Harder! How would you modify Frozen Lance to also heal the front-friendly Monster unit for 20 health?

Next: Custom Spell Cards