Custom Room Modifiers - brandonandzeus/Trainworks2 GitHub Wiki
Just like CardTraits and CardEffects, you can make your own RoomModifiers. A RoomModifier is a subclass of RoomStateModifierBase
. If you were to look up this class in your C# decompiler, it doesn't do anything interesting. The only method you can override is Initialize
. So what gives? This is because to determine what your RoomModifier does, you will need to implement one or more of the interfaces inheriting from the IRoomStateModifier
interface.
RoomModifiers may be harder to create and get right, as the Monster Train codebase may not have implemented the parts to handle a particular case. As you will see in this tutorial.
Remember Shard Channeler
from the Awoken clan? Why does Awoken get all the shiny stuff? Here we are going to make a similar RoomModifier, but instead, it modifies Frostbite damage. If you were to look up Shard Channeler
, you will find it has RoomStateSpikesDamageModifier
to summarize that class. The interesting portions in this implementation is the following inheriting classes and interfaces RoomStateModifierBase
, IRoomStateDamageModifier
, IRoomStateModifier
, ILocalizationParamInt
, ILocalizationParameterContext
. The first two are the most important, though.
IRoomStateDamageModifier
can make a room modifier adjust damage from status effects (Shard Channeler
and spikes damage) or buff a character's stats if the character has the status effect (Deranged Brute
and rage).
It only has one method we need to implement
int GetModifiedDamage(Damage.Type damageType, CharacterState attacker, bool requestingForCharacterStats)
- Damage.Type is an enum. Long story short, frostbite damage has Damage.Type Poison makes sense since frostbite's internal statusId is poison.
- CharacterState attacker is a misnomer. This will eventually be the unit with the frostbite taking damage.
- The last is for buffing attack damage, so we can ignore it.
With that, here's the class.
public class RoomStateFrostbiteDamageModifier : RoomStateModifierBase, IRoomStateDamageModifier, IRoomStateModifier, ILocalizationParamInt, ILocalizationParameterContext
{
private int modifiedDamage;
public override void Initialize(RoomModifierData roomModifierData, RoomManager roomManager)
{
base.Initialize(roomModifierData, roomManager);
modifiedDamage = roomModifierData.GetParamInt();
}
public int GetModifiedDamage(Damage.Type damageType, CharacterState attacker, bool requestingForCharacterStats)
{
if (requestingForCharacterStats)
return 0;
// Note Frostbite's StatusID is actually called Poison.
if (damageType != Damage.Type.Poison)
{
return 0;
}
// Note that "attacker" in this context is the target with Frostbite.
// Additionally the stacks of frostbite will have already decremented by the time this is called so add 1).
// However if the user has Cuttlebeard it will have not been decremented so don't in that case.
int stacks = attacker.GetStatusEffectStacks(VanillaStatusEffectIDs.Frostbite);
if (!ProviderManager.SaveManager.GetHasRelic(CustomCollectableRelicManager.GetRelicDataByID(VanillaRelicIDs.Cuttlebeard)))
{
stacks += 1;
}
return modifiedDamage * stacks;
}
}
}
There's some weirdness here. Stacks of frostbite will be removed when it's triggered (as that is how the status effect was set up), meaning by the time we are calculating the extra damage for it, it will have already been decremented... However, a relic named Cuttlebeard exists, which prevents frostbite from being depleted, so we must handle these cases in our RoomModifier.
There's one more little thing we need to do. For a RoomModifier, we need to provide a localization text for the name of it. Fortunately, the localization key IS the class name itself, so toss this in your Initialize method in your plugin class.
BuilderUtils.ImportStandardLocalization(nameof(RoomStateFrostbiteDamageModifier), "Bitter Cold");
Here's the associated card with this, thanks to AI image generators. It is called Frostfang Ferox and gives 3 frostbite on hit, but when they take frostbite damage, the effect is multiplied 5x.
class FrostFangFerox
{
public static readonly string ID = TestPlugin.CLANID + "_FrostFangFeroxCard";
public static readonly string CharID = TestPlugin.CLANID + "_FrostFangFeroxCharacter";
public static readonly string RoomModifierID = TestPlugin.CLANID + "_FrostFangFeroxRoomModifier";
public static readonly string TriggerID = TestPlugin.CLANID + "_FrostFangFeroxAttacking";
public static void BuildAndRegister()
{
var character = new CharacterDataBuilder
{
CharacterID = CharID,
Name = "Frostfang Ferox",
Size = 3,
AttackDamage = 10,
Health = 30,
AssetPath = "assets/FrostfangFerox_Character.png",
PriorityDraw = true,
TriggerBuilders =
{
new CharacterTriggerDataBuilder
{
TriggerID = TriggerID,
Trigger = CharacterTriggerData.Trigger.OnAttacking,
Description = "Apply <nobr>[frostbite] [effect0.status0.power]</nobr> to the attacked unit.",
EffectBuilders =
{
new CardEffectDataBuilder
{
EffectStateType = typeof(CardEffectAddStatusEffect),
TargetTeamType = Team.Type.Heroes,
TargetMode = TargetMode.LastAttackedCharacter,
ParamStatusEffects =
{
new StatusEffectStackData {statusId = VanillaStatusEffectIDs.Frostbite, count = 3}
}
}
}
}
},
RoomModifierBuilders =
{
new RoomModifierDataBuilder
{
RoomModifierID = RoomModifierID,
// Note tooltipTitle in-game is tied to the RoomStateModifier class.
RoomModifierClassType = typeof(RoomStateFrostbiteDamageModifier),
// Note the <br> as it's used as part of the Card's Description to make it look nicer as
//The trigger text appears on the same line otherwise.
Description = "<br>[frostbite] deals [paramint] more damage per stack.",
DescriptionInPlay = "[frostbite] deals [paramint] more damage per stack.",
ParamInt = 4,
}
}
};
new CardDataBuilder
{
CardID = ID,
Name = "Frostfang Ferox",
Cost = 3,
CardType = CardType.Monster,
Rarity = CollectableRarity.Rare,
TargetsRoom = true,
Targetless = false,
AssetPath = "assets/FrostfangFerox.png",
ClanID = Clan.ID,
CardPoolIDs = { VanillaCardPoolIDs.StygianBanner, VanillaCardPoolIDs.UnitsAllBanner },
EffectBuilders =
{
new CardEffectDataBuilder
{
EffectStateType = typeof(CardEffectSpawnMonster),
TargetMode = TargetMode.DropTargetCharacter,
ParamCharacterDataBuilder = character
}
}
}.BuildAndRegister();
}
}
You can test it now, but read the below section and let's continue.
Unfortunately, this code isn't enough. If you test that card, you will find that the effect doesn't work, and the enemy took just 3 damage from frostbite. Nothing happens, so what gives?
This is, unfortunately, due to how Frostbite and RoomModifiers were implemented. Ideally, in this case, when the frostbite damage is done, it should query all units on the floor if they have a RoomModifier that interacts with the status. However, this does not happen. I have created a patch to fix this, but the explanation would require discussing details of StatusEffectPoisonState
, CombatManager
, and CharacterState
. The way I made this was by tracing the method calls to do damage to a target. Long story short, because frostbite damage doesn't have an attacker set, that type of damage can't be modified by a RoomModifier. The following patch sets the attacker to the unit with frostbite itself. This shouldn't affect any other aspects of frostbite, so this should be safe.
[HarmonyPatch(typeof(CombatManager), nameof(CombatManager.ApplyDamageToTarget))]
class FrostbiteDamagePatchSetAttacker
{
public static void Prefix(CharacterState target, ref ApplyDamageToTargetParameters parameters)
{
// Hack to set selfTarget for Frosbite Damage. This is required later when the damage is calculated to consider what RoomModifiers are in play.
// Damage.Type.Posion is only used by StatusEffectPoisonState so this should be safe to do.
// Essentially when computing the damage, selfTarget is used as the attacker which is why its being set here.
if (parameters.damageType == Damage.Type.Poison)
{
parameters.selfTarget = target;
}
}
}
// This patch runs through all IRoomStateDamageModifier for all units on the floor and modifies the Frostbite Damage.
[HarmonyPatch(typeof(RoomState), nameof(RoomState.GetRoomStateModifiedDamage))]
class FrostbiteDamagePatchActivate
{
private static List<CharacterState> toProcessCharacters = new List<CharacterState>();
private static TargetHelper.CollectTargetsData collectTargetsData;
// Here since its a simple enough, rather than using Reflection to Call the same method from RoomState.
private static void ResetToCollectTargetsLists(TargetMode targetMode, HeroManager heroManager, MonsterManager monsterManager, RoomManager roomManager)
{
toProcessCharacters.Clear();
collectTargetsData.Reset(targetMode);
collectTargetsData.heroManager = heroManager;
collectTargetsData.monsterManager = monsterManager;
collectTargetsData.roomManager = roomManager;
if (collectTargetsData.targetModeStatusEffectsFilter == null)
{
collectTargetsData.targetModeStatusEffectsFilter = new List<string>();
}
collectTargetsData.targetModeStatusEffectsFilter.Clear();
collectTargetsData.targetModeHealthFilter = CardEffectData.HealthFilter.Both;
}
public static void Postfix(RoomState __instance, ref int __result, CharacterState attacker /* unit with frostbite*/, Damage.Type damageType, HeroManager heroManager, MonsterManager monsterManager, RoomManager roomManager)
{
if (damageType != Damage.Type.Poison)
{
return;
}
// Code to grab every unit on the floor
ResetToCollectTargetsLists(TargetMode.Room, heroManager, monsterManager, roomManager);
collectTargetsData.targetTeamType = Team.Type.Heroes | Team.Type.Monsters;
collectTargetsData.roomIndex = __instance.GetRoomIndex();
TargetHelper.CollectTargets(collectTargetsData, ref toProcessCharacters);
// Modify the damage by calling each interested RoomModifier.
foreach (CharacterState toProcessCharacter in toProcessCharacters)
{
foreach (IRoomStateModifier roomStateModifier in toProcessCharacter.GetRoomStateModifiers())
{
if (roomStateModifier is IRoomStateDamageModifier roomStateDamageModifier)
{
__result += roomStateDamageModifier.GetModifiedDamage(damageType, attacker, false);
}
}
}
}
}
Now with these two patches, the effect should work as intended.