Tutorial 7: Making a Map Node - rspforhp/WildfrostModdingDocumentation GitHub Wiki
By Michael Coopman
Click here to toggle
This tutorial covers how to use the CampaignNodeTypeBuilder to make a map node. It will showcase three different ways of adding the map node into a run, highlighting the difference use-cases for each. Then, this tutorial will delve into the code necessary to make a custom CampaignNodeType class.
Finally, some words will spoken on how to continue on to make an EventRoutine. If you want the code and images in their entirety, they can be found here.
This tutorial will assume the reader has read Tutorial 0 and performed assembly publicizing in Advanced Project Setup. The reader is also assumed to have familiarity with the common conventions and helper methods established in some of the other tutorials ( 1 2, 3, 5 ), but a recap on what's needed will be done in the next section. There will be no patching done, so you have safely avoided the use of the Patching Tutorials.
The Addressables Tutorial can be applied here for the map sprites, but we will not do that at the moment. The reader is free (as they always are) to deviate from the code outlined here in order to be more efficient.
The map node we aim to create is a portal that seems to leads somewhere beyond the current snow-filled world. The portal can be entered multiple times, and each time could bring unimaginable riches or unparalleled danger. But once the doors to the portal closes, there is no way to re-open them. Let's get started!

Image dimensions are 360x274px, but will be scaled down to 180x137px.
Most of the past setup will originate from past tutorials. This section is split up based on how new the setup code is for someone who read the basic project setup and the past tutorials.
We will make a copy of the MapNode component and its associated GameObject.
As such, we need to follow the sprite specifications that the game expects from us. The ScaledSprite method will automate this.
//Place this method in your main mod class.
internal Sprite ScaledSprite(string fileName, int pixelsPerUnit = 100)
{
Texture2D tex = ImagePath(fileName).ToTex();
return Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, (20f*pixelsPerUnit)/(tex.height*100f)), pixelsPerUnit);
}The target dimensions for map nodes is somewhere in the ballpark of 125-175px wide and 70-170px tall. The base game does not scale these sprites, but the pixelsPerUnit allows you to scale them manually.
Once we copy another node's MapNode, we need a place to store it.
So, the main mod class will have a GameObject called PrefabHolder that will hold all of our reference game object and keep them inactive (thus giving an option to clone them as active).
//Define this outside of any methods, similar to assets.
internal static GameObject PrefabHolder;
//The PrefabHolder will be created in the Load method.
public override void Load()
{
PrefabHolder = new GameObject(GUID); //To hold prefabs and eventually UI elements
UnityEngine.Object.DontDestroyOnLoad(PrefabHolder); //Makes sure it doesn't get destroyed when moving scenes
PrefabHolder.SetActive(false); //Set it (and its children) to inactive in the hierarchy.
//The rest of the code in Load()
}
//On Unload, we can destroy the holder and everything it has will go with it.
public override void Unload()
{
PrefabHolder.Destroy();
//The rest of the code in Unload()
}SStack is a small helper method whose purpose is combination of shorthand, clarity, and debugging. In some past tutorials, SStack was defined to be a private method. Make sure this is not the case; it must be internal or public because we will call it outside of the main mod class.
//TryGet is defined in the "Old Code" section.
//Place this like you would any method in the main mod class.
internal CardData.StatusEffectStacks SStack(string name, int amount) => new CardData.StatusEffectStacks(TryGet<StatusEffectData>(name), amount);In Tutorial 5, it was erroneously declared in Load. This revision is not necessary, but it is good practice to declare the instance in the constructor.
//The constructor was from Basic Project Setup.
public Tutorial7(string baseDirectory) : base(baseDirectory)
{
instance = this; //This is the new line. It was moved from Load to here.
}Click each drop-down for more information.
Click here
When the game starts to load your mod, it'll ask for all of your `CardDataBuilder`'s so that they can build `CardData`. It'll do the same thing with `CardUpgradeDataBuilder`, `CampaignNodeTypeBuilder`, and so on. The list `assets` will record all of our builders while the method `AddAssets` will effectively organize and give the proper builders when the need arises.// Both of these pieces of code should be in the class but in another method.
public static List<object> assets = new List<object>();
//Credits to Hopeful for this method
public override List<T> AddAssets<T, Y>()
{
if (assets.OfType<T>().Any())
Debug.LogWarning($"[{Title}] adding {typeof(Y).Name}s: {assets.OfType<T>().Select(a => a._data.name).Join()}");
return assets.OfType<T>().ToList();
}Click here
The `CreateModAssets` method is where we create all of the builders to build the cards, status effects, charms, and campaign node types that we desire. The method needs to be run once on the first load, but not on subsequent loads. This is why we track whether `CreateModAssets` has run already with the `preLoaded` variable. Each tutorial starts with a clean slate for `CreateModAssets` and this one is no exception.//Define this where you define your other class variables.
public bool preLoaded = false;
//Place this somewhere in your class
private void CreateModAssets()
{
//Put builder code here
//Signals that this method has run
preLoaded = true;
}Click here
In order to support multiple languages, localized strings are used in place of normal strings. To help the translation process, these localized strings should be defined as early as possible. The method `CreateLocalizedStrings` will be used for that purpose. Note that localized strings can (and will) be defined in other places, such as the `SubscribeToAfterAllEvent` of `CreateModAssets`. Just like `CreateModAssets`, this method only needs to be called once per application load.//Should be called in Load (see "Load and Unload")
private void CreateLocalizedStrings()
{
StringTable uiText = LocalizationHelper.GetCollection("UI Text", SystemLanguage.English);
//Once we start using it, expect a lot of uiText.SetString() and uiText.GetString().
}Click here
The `Load` and `Unload` method are called whenever we click the bell associated with your mod. Typically, we would expect some to hook methods to `Events` onto `Load` and unhook them on the `Unload`. There are three things that `Load` needs to do right now:(1) Instantiate the PrefabHolder from the "New Setup Code" section,
(2) Call CreateModAssets and CreateLoacalizedStrings if they have not been called already.
(3) Perform important tasks in the background in base.Load
All Unload has to do is its own version of (1) and (3).
public override void Load()
{
PrefabHolder = new GameObject("mhcdc9.wildfrost.tutorial"); //See an above section for more details
UnityEngine.Object.DontDestroyOnLoad(PrefabHolder); //Makes sure it doesn't get destroyed when moving scenes
PrefabHolder.SetActive(false); //Set it (and its children) to inactive in the hierarchy.
if (!preLoaded)
{
CreateModAssets();
CreateLocalizedStrings();
}
base.Load(); //The WildfrostMod class does a lot of stuff in the background through this line.
//Some event hooking can be done here
}
public override void Unload()
{
PrefabHolder.Destroy();
base.Unload();
}Click here
The helper method `TryGet` is a modified version of `Get`. It performs `StatusEffectData` casts automatically so you can avoid cumbersome syntax. It also throws an error if it cannot find the data file of the name you provided. This makes debugging progress more smoothly, and helps catch tiny misspellings more quickly. As shown above, `SStack` automatically uses it.internal T TryGet<T>(string name) where T : DataFile
{
T data;
if (typeof(StatusEffectData).IsAssignableFrom(typeof(T)))
data = base.Get<StatusEffectData>(name) as T;
else
data = base.Get<T>(name);
if (data == null)
throw new Exception($"TryGet Error: Could not find a [{typeof(T).Name}] with the name [{name}] or [{Extensions.PrefixGUID(name, this)}]");
return data;
}If you are used to the builders, most of this stuff will be second nature by now.
An importance difference about the CampaignNodeType and other data files classes seen so far is that each map node uses a different CampaignNodeType class.
So, making a custom CampaignNodeType class here is unavoidable.
Additionally, like ClassDataBuilder, much of the builder code will be done in a SubscribeToAfterAllEvent.
In a sense, CampaignNodeType's have a less rigid structure, so the builders have trouble capturing that.
It also gives more freedom to those that can understand it.
Note
Map Nodes in Detail
The actual map node is divided into three classes: the MapNode, the CampaignNode, and the CampaignNodeType classes. Each focus on a different aspect of the map node
The MapNode class handles all of the visual elements of node and all of the Unity side of things. We theoretically could create our own cluster of Unity Objects and place a custom MapNode on the parent object, but that is a lot of work. Every MapNode holds a variable called the campaignNode, which is of the CampaignNode class.
The CampaignNode holds the data for the entire node. It stores the item data and is the only part of the map node that needs to be saved when exiting. They are created automatically during campaign generation with no easy way to call a custom CampaignNode class instead. Every CampaignNode has a reference to the master instance of the CampaignNodeType class.
CampaignNodeTypes define the rules, procedures, and logic for how the map node interacts with the player and the data. It is easily editable, and there is an associated builder for it. Each node type has a prefab of a MapNode.
For now, we will be focused on getting the map node to appear in-game first before we dive into the details of implementing the portal logic.
This is the bare minimum that you can do to make a custom CampaignNodeType class, so treat it as a template.
public class CampaignNodeTypePortal : CampaignNodeType
{
public override IEnumerator SetUp(CampaignNode node)
{
node.data = new Dictionary<string, object>(); //Avoiding random null exceptions
//More code here later
return base.SetUp(node); //Placeholder. Does nothing.
}
public override IEnumerator Run(CampaignNode node)
{
//More code here later
node.SetCleared(); //Closes the node
References.Map.Continue; //Allows player to move on to adjacent nodes
return base.Run(node); //Placeholder. Does nothing.
}
//Called on the continue run screen. Returning true makes the run unable to continue.
public override bool HasMissingData(CampaignNode node)
{
//More code here later
return base.HasMissingData(node); //always returns false
}
}Most of the useful methods for this builder are the ones that take bool arguments. They fall along the lines
- Is this map node interactable? (if yes, set
canEnterandinteractableto true) - If interactable, can it be skipped? (if yes, set
canSkipto true and keepmustClearto false) - If non-skippable, is it a battle/boss (if yes, set
isBattleandisBossaccordingly)
For the portal, it is definitely interactable and skippable (that is, the player can skip remaining uses of the portal). Barring some less important details for later, the code will look like:
//Inside CreateModAssets
//Put builder code here
assets.Add(new CampaignNodeTypeBuilder(this)
.Create<CampaignNodeTypePortal>("PortalNode")
.WithZoneName("Portal") //The name of the CampaignNode associated to the map node. Used for special event replacement.
.WithCanEnter(true) //Needs to be true to be interactable
.WithInteractable(true) //Needs to be true to be interactable
.WithCanSkip(true) //If you want this node unskippable, replace this line with .WithMustClear(true)
.WithCanLink(true) //See below.
.WithLetter("p") //See below.
.SubscribeToAfterAllBuildEvent(
(data) =>
{
//MapNode stuff
})
);Linking occurs when there are two map nodes of the same type and on different paths. if the node type allows linking, then one map node will clone the data of the other. This is presumably used to make seeded (ie, daily voyages) runs feel more similar between different path choices. For a single run in a seed, the choice is meaningless until someone mods in wing boots or something.
Each CampaignNodeType is uniquely identified by a letter. If your node type wants to be a mandatory event (see the Preset section), make sure this letter is one character long. If you do not care, then make it two or more characters long to ensure any conflicts are meaningless.
The visual elements of our map node are still missing, which is governed by the MapNode.
Making one from scratch is quite an ordeal, but most of the work can be done by cloning an existing MapNodePrefab.
Keep in mind that there are slight differences between the prefabs of the base game.
For the one that we will clone (Blingsnail Cave), the only things we need to change are
- The ribbon title (handled through a LocalizeStringEvent)
- The initial sprite(s) (handled by the
spriteOptionsarray) - The cleared sprite(s) (handled by the
clearedSpriteOptionsarray) There is the issue of where to store this prefab, but we made thePrefabHolderprecisely for this reason. Since thePrefabHolderwas placed it the "dontdestroyonload" scene, it and its children are safe from spontaneous destruction.
Due to the amount of lines all of these modifications will take, we will opt to do it in a SubscribeToAfterAllBuildEvent instead of the WithMapNodePrefab.
//Inside the SubscribeToAfterAllBuildEvent
//Some MapNode stuff
MapNode mapNode = TryGet<CampaignNodeType>("CampaignNodeGold").mapNodePrefab.InstantiateKeepName(); //There's a lot of things in one of these prefabs
mapNode.name = GUID + ".Portal"; //Changing the name
data.mapNodePrefab = mapNode; //And assign it to our node type before we forget.
StringTable uiText = LocalizationHelper.GetCollection("UI Text", SystemLanguage.English);
string key = mapNode.name + "Ribbon";
uiText.SetString(key, "Mysterious Portal"); //Define the Localized string for our ribbon title.
mapNode.label.GetComponentInChildren<LocalizeStringEvent>().StringReference = uiText.GetString(key);
//Find the LocalizeStringEvent and set it to our own.
//The game will randomly pick between the options available. They will also pick the same index for both sprites and cleared sprites, if possible.
mapNode.spriteOptions = new Sprite[2] { ScaledSprite("MapPortalOpen1.png",200), ScaledSprite("MapPortalOpen2.png",200) };
mapNode.clearedSpriteOptions = new Sprite[2] { ScaledSprite("MapPortalClosed1.png", 200), ScaledSprite("MapPortalClosed2.png", 200) };
//I am using 360x274px images, but setting pixelsPerUnit to 200 scales it down to 180x137px.
GameObject nodeObject = mapNode.gameObject; //MapNode is a MonoBehaviour, so there is an underlying GameObject.
nodeObject.transform.SetParent(PrefabHolder.transform); //Ensures your reference doesn't poof out of existence.With that, all the builder code for this tutorial is complete! Time to turn our attention on how to insert these nodes into a run.
The base game generates all of the map nodes once you start a run via the Start method of the Campaign class. An outline of the generation is as follow:
-
[Mandatory Nodes] The campaign grabs one of the two possible presets. These presets are the general blueprint of the run: the battles, the start and end of each area/tier, some fixed shop locations, and where path split and converge. Presets are stored as an array of four strings, and the letter "r" is used to denote placeholders to be replaced later.
-
[Rare Nodes] The
SpecialEventSystemclass looks through all of the r's and picks a few to convert into special events: charm merchant, shade sculptor, and gnome traveler. Each special event has their own criteria and frequency on which r's to replace. -
[Common Nodes] The remaining r's are replaced with the more common map nodes: companions, items, shops, charms, caves. The frequency of each map node is determined by the tier (the region between each battle is a tier. There are effectively 7 tiers).
Each step places a unique variety of map nodes with a different purpose. We will discuss how to do all three in order of decreasing frequency. These three approaches are different ways of solving the same problem. They are not (usually) meant to be used together.
If we are able to access the preset before it is read, then we could insert our own nodes directly on the blueprint. Fortunately, the Events.OnCampaignLoadPreset exists. Not only is this called right before the preset is used, but it also passes the preset as a ref argument too. This event makes sense because both the injured companion event and the journal pages both insert themselves in an otherwise complete preset.
The presets have a fixed structure. Each character in the first line represent a map node along the path. If the path splits, then the second line is used to store the other path of nodes. The 3rd (2nd bottommost) line represents what tier the node belongs. This determines the battles as well as the nodes to pull. The final line represents the area, which changes the background, music, and affects some are-related storm bells. See the References for the exact presets.
For the initial test, we will place the portal right after the Snowdwell node. The position could be hardcoded, but the code will be written in a way so that it can generalized to after specific battles instead. In the Load method, add the line Events.OnCampaignLoadPreset += InsetPortalViaPrefix;. In the Unload, add a similar line but with -= instead.
//Be sure to hook and unhook to Events.OnCampaignLoadPreset in the Load and Unload methods respectively
private void InsertPortalViaPreset(ref string[] preset)
{
//See References for the two possible presets.
//Lines 0 + 1: Node types
//Line 2: Battle Tier (fight 1, fight 2, etc)
//Line 3: Zone (Snow Tundra, Ice Caves, Frostlands)
char letter = 'S'; //S is for Snowdwell, b is for non-boss, B is for boss.
int targetAmount = 1; //Stop after the 1st S.
for(int i=0; i < preset[0].Length; i++)
{
if (preset[0][i] == letter)
{
targetAmount--;
if (targetAmount == 0)
{
preset[0] = preset[0].Insert(i + 1, "p");//"p" for portal
for(int j=1; j < preset.Length; j++)
{
preset[j] = preset[j].Insert(i + 1, preset[j][i].ToString()); //Whatever the ref node used
}
break; //Once the portal is placed, no need for other portals.
}
}
}
}After all of this, we are ready to test. Build the solution, move the dll to the right place if you have not automated that already, and run the game. You should see the portal immediately. In fact, you might even see the injured companion event before the first fight (this is because the injured companion event does hardcode its insertion position). All the node should do is immediately close and allow you to move on to the first fight.
Showcase of the alternate portal sprites before the first fight.
Caution
This method requires that the letter of the CampaignNodeType be unique, single letter. This condition may cause incompatibly with other mods that also add new events. You have been warned.
Note
Troubleshooting and Possible Errors
A new run was started, but the map node was not there. No errors/crashes. This could be because the InsertPortalViaPreset was not hooked onto Events.OnCampaignLoadPreset. Make sure it is. In addition, make sure the mod is turned on, and that the dll was put in the right place.The process of replacing the remaining CampaignNodeRewards (marked as 'r' in the preset) with common nodes is done in the campaignPopulator of the gameMode, usually "GameModeNormal". Whenever the populator sees a CampaignNodeReward, it select an node type from a set of choices to replace it. The selection is done without replacement until the populator runs out. Then the populator would reset its choices and continue. Map nodes after the same fight are in the same tier, and every tier has its own set of node type choices, see the References for these tiers. In general, the bulk of the reward nodes seen in a run will be made with the populator.
Adding our portal nodes to one or more tiers is as easy as TryGet-ting the standard game mode and modifying its campaignPopulator. In particular, the tiers variable is the array of the possible tiers, and each one holds an array of node types named rewardPool (not to be confused with the RewardPool class). Remember to undo the changes on Unload.
The code below will method to insert two of these node types into every tier in the before the second boss. This will be done by calling the AddToPopulator method on Load. On Unload, we will define a sister method RemoveFromPopulator that removes them. The actual arguments of these methods will float around as class variables for the main mod class so that making a change to the "input" of one method will change the "input" of the other.
//Floating class fields. Place them inside the main mod class but outside of any methods.
public int[] addToTiers = new int[] { 0, 1, 2, 3, 4 };//First two Acts
public int amountToAdd = 2;
//Load should call this method. If you did the preset manipulation already, feel free to comment out the event hook for that.
public void AddToPopulator()
{
CampaignPopulator populator = TryGet<GameMode>("GameModeNormal").populator; //Find the populator
foreach(int i in addToTiers) //Iterate through the desired tiers
{
CampaignTier tier = populator.tiers[i];
List<CampaignNodeType> list = tier.rewardPool.ToList(); //Convert the array to a list to easier adding
for (int j=0; j<amountToAdd; j++)
{
list.Add(TryGet<CampaignNodeType>("PortalNode")); //Add as much times as desired
}
tier.rewardPool = list.ToArray(); //Replace the old array
}
}
//Unoad should call this method. If you did the preset manipulation already, feel free to comment out the event unhook for that.
public void RemoveFromPopulator()
{
CampaignPopulator populator = TryGet<GameMode>("GameModeNormal").populator; //Find the populator
foreach (int i in addToTiers) //Iterate through the desired tiers
{
CampaignTier tier = populator.tiers[i];
List<CampaignNodeType> list = tier.rewardPool.ToList();
list.RemoveAll(x => x == null || x.ModAdded == this); //Remove everything that needs to be removed
tier.rewardPool = list.ToArray();
}
}Update your mod and test it. You do need to start a new run again to see the changes. Note that although two portals were put in the desired tiers, those tiers may have less since they choose from a pool larger than the CampaignNodeRewards available.
Two portals (with different sprites) in the same tier. This picture took two runs to generate.
Note
Troubleshooting and Possible Errors
Error: Immediately after starting a run, there was a null error involving a clone methodThis could be caused by two portals linking their data; however, we did not give it any data. In the CampaignNodeTypePortal class, there should be a line in the Setup method that declares data as an empty dictionary. Make sure you have this line.
All of the unlockable map nodes are considered "special nodes". They do not show up in any of the pools of the campaign populator. Instead, the "Systems" object in the Campaign scene comes equipped with the SpecialEventSystem component. Before the populator runs, that system component replaces a few of the CampaignNodeRewards with the unlockable map nodes. Special events are usually rarer than the normal nodes, and the SpeicalEventSystem is tuned for that.
Inserting your own node types into the SpecialEventSystem is not obvious from Visual Studios alone, but this is where exploring the game comes in handy (at worst, we could have also patched it in). The fact that we know the location of the system (in the Campaign scene) means that we can hook a method onto Events.OnSceneLoaded to find and modify it. Thankfully, this is done before SpecialEventSystem runs. As for how the system handles the node types, it wraps the class in an aptly named Events class that holds some extra info to determine where to place the node.
Tip
If a class ends with the word System, there is a good chance it is on the "Systems" in the Campaign scene. One exception is if you acquire a modifier from a boss reward. That will be attached to a different object in the same scene.
The code below works by hooking the method InsertPortalViaSpecialEvent is meant to hook onto the Events.OnSceneLoaded event somewhere in Load. As before, unhook this method during Unload. All it does is wrap the node type in an Event class and provide it reasonable conditions. More specifically, we will make it appear between 2 to 4 times, and at most twice per tier. The portal will only be available after the first boss.
//Hook this method onto Events.OnSceneLoaded somewhere in your Load method. Also, remember to unhook in Unload.
//Remember to comment out the other two approaches.
public void InsertPortalViaSpecialEvent(Scene scene)//The Scene class is from the UnityEngine.SceneManagement namespace
{
if (scene.name == "Campaign")
{
SpecialEventsSystem specialEvents = GameObject.FindObjectOfType<SpecialEventsSystem>(); //Only 1 of these exists
SpecialEventsSystem.Event eve = new SpecialEventsSystem.Event()
{
requiresUnlock = null, //Unnecessary as this is default, but really just showing that it exists
nodeType = TryGet<CampaignNodeType>("PortalNode"), //Our portal
replaceNodeTypes = new string[] { "CampaignNodeReward" }, //If you spell this string wrong, the game will loop endlessly
minTier = 2, //After the first boss
perTier = new Vector2Int(0,2), //Maximum of 2 per tier
perRun = new Vector2Int(2,4) //Between 2 and 4 portals per run
};
specialEvents.events = specialEvents.events.AddItem(eve).ToArray();
}
}This test will be like the others; remember to start a new run. The only meaningful difference is that no portal spawns before Infernoko/Bamboozle (unless the other approaches were not commented out).
Note
Troubleshooting and Possible Errors
After starting a new run, the game gets stuck on a white screenThe SpeicalEventSystem has no safeguards from an infinite loop. So, check your spelling of "CampaignNodeRewards" for replaceNodeTypes, and make sure that the other conditions provided are not too severe. Remember that other special events are replacing these reward nodes just like yours.
Now that we are finally experts on map node insertion, let's return to the CampaignNodeTypePortal class and make it function as intended. First, we need to discuss the specifics of the portal node. Here is the outline:
- Each portal can be accessed at most 7 times: 3 bling events, 3 injury events, and 1 charm event.
- bling: gain some amount of bling, say 25.
- injury: a random companion gets injured.
- charm: the player obtain a random charm.
- The first access will always be bling; the charm will be in the latter half of accesses.
- If the player gets the injury event with no eligible companions to injure, the portal will close.
With this outline in mind, we need to focus on how to realize this event using the foundation that CampaignNodeType provides. There are three main methods to override: Setup, Run, and HasMissingData. The Setup method grabs all the necessary data for the node to run properly. Typically, all the randomness is called here as well to avoid save-scumming. The Run method occurs when the player clicks on the node. Since Run is an IEnumerator, we can create an elaborate sequence of animations if we desire. Finally, the HasMissingData method occurs when returning to the game. It makes sure that the game (or at the very least, that particular node) can still be played properly.
As Run will be the longest in terms of code, we will talk about the other two first before getting to that method.
As said before, we want all of the data and randomness to occur here. The data used must be save-able. That is, any primitive data type (int, float, string, bool, char) or any data structure marked as [Serializable]. Any call to randomness should use Dead.Random so that if someone plays the same seed, the result of the roll is the same. If the node needs to pull something from the tribe's reward pools, it should use the Pull method from the tribe's CharacterRewards (this is a component attached to References.Player).
For out portal, we want to save
- what access we are on.
- The ordering of the bling, injury, and charm events.
- What the charm is.
The first one can be stored as an int. The second one looks like a list at first glance, but lists are not serializable (arrays could work though, but I like add/remove methods even if I'm not using them). Instead we will use a SaveCollection, which effectively a list that can be saved. Whether this is a list of ints, strings, or enums is up to you. The code below will use an enum for this. Finally, the charm name is enough to recover the charm, so a string can be used for that.
[Serializable]
public enum Results //Results is saveable because it is serializable
{
Gold = 0,
Injury = 1,
Charm = 2
}
int minCharmEvent = 4; //The charm will spawn at this index or after.
//Called during campaign population, determines the data to give to node.
public override IEnumerator SetUp(CampaignNode node)
{
node.data = new Dictionary<string, object>();
List<Results> results = new List<Results> //It's missing a bling event and a charm event currently. It's a list, but can be converted later.
{
Results.Gold,
Results.Gold,
Results.Injury,
Results.Injury,
Results.Injury
};
results = results.InRandomOrder().ToList(); //Randomize the results
results.Insert(0, Results.Gold); //Insert a bling event at the very beginning
results.Insert(Dead.Random.Range(minCharmEvent, results.Count), Results.Charm); //Add a charm somewhere at the end
CharacterRewards component = References.Player.GetComponent<CharacterRewards>();
CardUpgradeData cardUpgradeData = component.Pull<CardUpgradeData>(node, "Charms"); //Pulling a charm from the pool
node.data.Add("clicks", -1); //-1 means that the first event has not been accessed yet
node.data.Add("results", new SaveCollection<Results>(results)); //Convert the list into a save collection
node.data.Add("charm", cardUpgradeData.name); //Convert the charm into a string
}Making HasMissingData should be rather painless. The only data that is expected to be missing is the charm. This can happen if the player unloads the mod with the corresponding charm. Checking whether this charm exists involves using Get (this is the rare case that TryGet should not be used) on the charm name.
The code will be a bit extra, and make sure that the other data is at least there. To be extra cautious, we could also check the data type of each data, but that seems excessive.
//Called on the continue run screen. Returning true makes the run unable to continue.
public override bool HasMissingData(CampaignNode node)
{
if (!node.data.ContainsKey("clicks") || !node.data.ContainsKey("results")) //Bonus check: probably not necessary
{
return true;
}
if (node.data.TryGetValue("charm", out object value) && value is string upgradeName) //Necessary check
{
return (Tutorial7.instance.Get<CardUpgradeData>(upgradeName) == null); //Do not use TryGet here
}
return true; //If the code reaches here, it would not have the "charm" data
}Update your dll and run the game. If your prior run has a portal in it, the game should say the run is missing data and you must restart (which means HasMissingData is working as intended). Start a new run, find the portal, and use the Console Command inspect this to look at the MapNode, head to its campaignNode, and observe the data. Do not click on the portal yet; save that for after we code the Run method.
Warning
If you do not use the saveable/serializable data types as your data, you will not be able to continue a run. Even worse, your run will be deleted if you click on the gate in the town. If this happens, check the data types of what you are trying to save.
HasMissingData working as intended.
Unity Explorer view of the data of a caampaignNode of the portal node.
Note
Troubleshooting and Possible Errors
We have finally reached the Run method, the place where the player interacts with the node. For many nodes, this method leads to a transition to the "Event" scene for an EventRoutine (who has an even more complicated Run method). But we do not need anything fancy for our portal; curiosity will suffice. Important methods for Run will be Sequences.Wait to time animations, node.SetCleared to mark the end of the node's interaction, and References.Map.Continue to give the player control again. Sometimes Campaign.PromptSave is useful if you want to save the progress of the node without clearing it.
The outline for our Run is to update clicks, determine which event to perform, and then perform the event. The most important thing for nodes/events is feedback for the player. The player should know what event they received without needing to open their deckpack to check. To accomplish this, we will borrow some code from the NoTargetTextSystem to display a text popup whose text changes depending on the event.
This section is organized starting with the smallest intermediate method and moving up until we finally write Run.
In Tutorial 4, we saw that the NoTargetTextSystem can be manipulated in-battle. Since the class ends with System, it likely lives in the "Campaign" scene, so the system should be usable even on the map. We have four different outcomes: bling, injury, injury without valid targets, and charm, so we need to write four different strings for this. In order to make this easily localized, we will make four localized strings and define them in the DefineLocalizedStrings from our main mod class. The rest of the code involves finding the NoTargetTextSystem, changing its text, and then playing it.
//The LocalizedString to be used, to be declared in the main mod class's DefineLocalizedStrings
public static LocalizedString GoldString;
public static LocalizedString InjuryString;
public static LocalizedString AllInjuryString;
public static LocalizedString CharmString;
public IEnumerator TextPopUp(CampaignNode node, LocalizedString key, string textInsert = "")
{
string s = key.GetLocalizedString();
s = s.Format(textInsert);
NoTargetTextSystem system = UnityEngine.Object.FindObjectOfType<NoTargetTextSystem>(); //Hijacking the NoTargetTextSystem to display text
if (system == null)
{
yield break;
}
TMP_Text textElement = system.textElement;
textElement.text = s;
system.PopText(References.Map.FindNode(Campaign.FindCharacterNode(References.Player)).transform.position);
yield return new WaitForSeconds(0.5f); //Wait until text clears
}//Call this method in Load()
private void CreateLocalizedStrings()
{
//Keys to turn into LocalizedStrings in the main mod class
string GoldKey = "mhcdc9.wildfrost.tutorial.PortalNode.GoldKey";
string InjuryKey = "mhcdc9.wildfrost.tutorial.PortalNode.InjuryKey";
string AllInjuryKey = "mhcdc9.wildfrost.tutorial.PortalNode.AllInjuryKey";
string CharmKey = "mhcdc9.wildfrost.tutorial.PortalNode.CharmKey";
StringTable uiText = LocalizationHelper.GetCollection("UI Text", SystemLanguage.English);
//For each key, SetString then GetString.
uiText.SetString(GoldKey, "Found riches!");
CampaignNodeTypePortal.GoldString = uiText.GetString(GoldKey);
uiText.SetString(InjuryKey, "{0} injured"); //Where {0} is the injured companion
CampaignNodeTypePortal.InjuryString = uiText.GetString(InjuryKey);
uiText.SetString(AllInjuryKey, "Too injured to continue");
CampaignNodeTypePortal.AllInjuryString = uiText.GetString(AllInjuryKey);
uiText.SetString(CharmKey, "Found {0}!"); //Where {0} is the charm name (title)
CampaignNodeTypePortal.CharmString = uiText.GetString(CharmKey);
}For the bling event, the blingsnail cave essentially does what we want it to do. Namely, it visually drops bling for the player to auto-collect. We will modify it slightly by decreasing the ending delay and adding the text popup.
The code itself uses the Events.InvokeDropGold to show the dropped gold (the true method used is hooked onto the event). All we need is to give it the character who initiated it (References.Player) and a place to drop it (the node's position), along with a couple safety checks.
public IEnumerator ObtainGold(CampaignNode node)
{
Character player = References.Player; //Found the player
Vector3 position = Vector3.zero;
MapNew mapNew = UnityEngine.Object.FindObjectOfType<MapNew>();
if ((object)mapNew != null)
{
MapNode mapNode = mapNew.FindNode(node); //Finds the node
if ((object)mapNode != null)
{
position = mapNode.transform.position; //Gets its position
}
}
if ((bool)player && (bool)player.data?.inventory)
{
Events.InvokeDropGold(25, "Portal", player, position); //Drops the bling
}
Campaign.PromptSave(); //Save the game
yield return TextPopUp(node, GoldString); //Pop the text
yield return new WaitForSeconds(0.5f); //After 0.5 from TextPopUp, we wait a total of 1 second (blingsnail sets this to 1.5)
}For coding the injury event, you could look at either InjurySystem or EventRoutineInjuredCompanion classes to see how to injure a companion through code. This is done by adding the Injury effect to their injuries list. Interestingly, this means that the system has implementation for both different types of injurie and multiple injuries. Its full potential is not realized due to some checks, but the potential is still there.
Unlike the injury classes listed, we need to select which companion to injure as well. If we cannot find an injured companion, we need to show a different message. The bool flag tracks this.
public IEnumerator AddInjury(CampaignNode node)
{
bool flag = false;
string s = ""; //s will replace {0}
foreach(CardData data in References.PlayerData.inventory.deck.InRandomOrder()) //Iterate through the deck in a random order
{
if (data.cardType.name == "Friendly" && data.injuries.Count == 0) //Uninjured companion
{
data.injuries.Add(Tutorial7.instance.SStack("Injury",1)); //Injury
s = data.title; //Save the name
flag = true;
break;
}
}
if (!flag)
{
node.data["clicks"] = 999; //999 should be out of the range of the list, so the portal will close
}
Campaign.PromptSave();
yield return TextPopUp(node, flag ? InjuryString : AllInjuryString, s); //Display either injured or the everyone injured key
}Obtaining a charm is as easy as calling References.PlayerData.inventory.upgrades.Add. So we do that with a clone of the charm, and we display the unique message as before.
public IEnumerator ObtainCharm(CampaignNode node)
{
CardUpgradeData upgrade = Tutorial7.instance.TryGet<CardUpgradeData>((string)node.data["charm"]);
References.PlayerData.inventory.upgrades.Add(upgrade.Clone());
Campaign.PromptSave();
yield return TextPopUp(node, CharmString, upgrade.title);
}The Run method itself will tick up the number of clicks, find the right method to yield to via a switch statement, and then close if there is no events left to do.
//Called when the map node is clicked on.
public override IEnumerator Run(CampaignNode node)
{
SaveCollection<Results> results = (SaveCollection<Results>)node.data["results"];
int click = (int)node.data["clicks"];
click++; //Count up clicks
node.data["clicks"] = click; //Update the data
if (click < results.Count) //Safty check
{
switch (results[click]) //Navigate to the right method
{
case Results.Gold:
yield return ObtainGold(node);
break;
case Results.Injury:
yield return AddInjury(node);
break;
case Results.Charm:
yield return ObtainCharm(node);
break;
}
}
if ((int)node.data["clicks"] >= results.Count-1) //Check if the portal closes (note the injury event could change clicks stored in data)
{
node.SetCleared(); //portal closes
}
References.Map.Continue(); //Give control back to the player
}Update the mod and run the game again. No need to start a new run; you may continue the run from the end of HasMissingData. Test it a few times to see if the right messages appear for the events. And with that, the map node is complete!
Note: the "Too injured to continue" is not shown but should be tested.
Note
Troubleshooting and Possible Errors
The next question is how much more work would it be to make this map node a full-fledged event? The answer depends on your tolerance of tedium.
Making the map node send you to the "Events" scene is easy: it involves the line Transition.To("Event") and all the event invokes and asset creation that is written in CampaignNodeTypeEvent.Run. If you have a firm grasp of the Addressables tutorial, you can have your custom node type class derive from CampaignNodeTypeEvent and make the prefab out-of-game. If not, you can make an alternate version of CampaignNodeTypeEvent that replaces mention of prefabReference with a copy of the GameObject you keep in your PrefabHolder.
Each prefab in the main game is made specifically for that event, so there might not be a perfect candidate to clone for your event. This means your prefab will probably be made from scratch. Well, that is not entirely true. You can study how other events are organized, and what components its game objects use. In addition, you can still take parts from others that seem useful and clone them. Even still, there is the matter of placing them in a nice way. This is the tedious part.
Once you get past this, the code will be similar to Run just with extra buttons. Button code is relatively simple, but making sure all possibilities are considered requires some planning.
All of this sounds difficult, but in truth, it's just a lot of Unity Explorer and UI more than anything.









