MichuMod: Programming Custom Milestones | Part 2
This is a continuation of Part 1.
Today I’m going to explain how the game handles the progress of each player towards a Milestone or Award.
Milestone and Award data basics
There is a “base” ScriptableObject called TM_GameDataSet
that is essentially a collection of all the data that needed for a TM game, such as data related to the boards (BoardDataSets
), the cards (CardDataSet
), the initial Terraforming Rating (InitialTerraformingRating
), and more. Milestones and Awards are part of the board data. When a new TM_BoardDataSet
(which is itself a ScriptableObject) is created at the beginning of the game, there are two related objects passed to it:
TM_AchievementDataInfo
andTM_AchievementDataSet_UI
TM_AchievementDataInfo
stores the cost of Milestones (8, 8, 8) and Awards (8, 14, 20), the points won each time a player buys one, the MilestoneType
of each Milestone and its RequirementValue
. The MilestoneType
is an int and refers to the value each Milestone has in the EMilestoneType
enum. The RequirementValue
defines how much of something you have to have to be able to buy the Milestone. Yes, it’s that abstract.
TM_AchievementDataSet_UI
stores the connection between Milestone/Award values and their visuals, that is the icon/sprite that appear for its one in the game.
Since both of these objects, as well as their parent object, are serializable, it’s straightforward to load the kind of data needed from JSON files. This is what the mod does: the board/grid data, Milestone and Award data, and even the type and cost of Standard Projects are loaded from external JSON files, easily editable in a text editor. When it comes to board/grid data, I’ve made an external tool that helps with the creation of these JSON files. I’ve already talked about it in another post.
And the actual logic?
As we can see, the logic behind Milestones and Awards is not included in the board data. Sure, we understand that the Mayor Milestone requires 3 of something by looking at its RequirementValue
, but 3 of what?.
The logic is stored in the AchievementHandler
class and is handled by the GetPlayerMilestoneProgressValue
(Milestones) and GetPlayerAwardProgressValue
(Awards) methods. These are triggered for every Milestone and Award in the current game and return an int that indicates the player’s progress. For example, the Researcher Milestone requires the player to have 4 Science Tags in play. GetPlayerMilestoneProgressValue
will go through each player’s tags, count them, and return the value. That number is then compared to the RequirementValue
that we saw earlier (which is 4 in this case). If the player meets the requirements, the Button for Researcher will activate allowing him/her to buy the achievement.
Having the original Milestone and Award logic as reference, I wrote a HarmonyPatch
that hooks onto these methods and calculates the progress on the Milestones of Utopia, Cimmeria, Amazonis, and Vastitas. These are some examples:
Milestones
Trader
case "Trader":
HashSet<EResourceType> uniqueResources = new HashSet<EResourceType>();
foreach (KeyValuePair<int, TupleLH<EResourceType, int>> keyValuePair in playerBoardData.ResourcesOnCards)
{
uniqueResources.Add(keyValuePair.Value.Item1);
}
__result = uniqueResources.Count;
return;
Metallurgist
case "Metallurgist":
__result = playerBoardData.ResourceBank[EResourceType.Steel].ProductionQuantity +
playerBoardData.ResourceBank[EResourceType.Titanium].ProductionQuantity;
return;
Geologist
case "Geologist":
HashSet<int> nearOrOnVolcanicTiles = new HashSet<int>();
List<EGridTileType> allTileTypesGeologist = ((EGridTileType[])Enum.GetValues(typeof(EGridTileType))).ToList<EGridTileType>();
foreach (TM_GridSlotTileData volcanicAreaSlot in boardData.GetNamedSlots(aPlayerLocalId, EGridTileLocationName.VolcanicArea))
{
int volcanicTileID = volcanicAreaSlot.Infos.TileID;
List<TM_GridSlotTileData> playerPlacedTiles = boardData.GetPlacedTiles(aPlayerLocalId);
if (playerPlacedTiles.Any(tile => tile.Infos.TileID == volcanicTileID))
{
nearOrOnVolcanicTiles.Add(volcanicTileID);
}
foreach (TM_GridSlotTileData adjacentTile in boardData.GetOwnedTilesInRange(volcanicTileID, allTileTypesGeologist, aPlayerLocalId, 1, true))
{
nearOrOnVolcanicTiles.Add(adjacentTile.Infos.TileID);
}
}
__result = nearOrOnVolcanicTiles.Count;
return;
Awards
Zoologist
case "Zoologist":
__result = playerBoardData.CountResourceOnAllCard(EResourceType.Microbe) +
playerBoardData.CountResourceOnAllCard(EResourceType.Animal);
return;
Manufacturer
case "Manufacturer":
__result = playerBoardData.ResourceBank[EResourceType.Steel].ProductionQuantity +
playerBoardData.ResourceBank[EResourceType.Heat].ProductionQuantity;
return;
Landscaper
This was one of the trickiest ones.
case "Landscaper":
List<TM_GridSlotTileData> playerOwnedTiles = boardData.GetPlacedTiles(aPlayerLocalId);
HashSet<int> visitedTiles = new HashSet<int>();
int largestPatchSize = 0;
foreach (TM_GridSlotTileData tile in playerOwnedTiles)
{
int tileID = tile.Infos.TileID;
if (visitedTiles.Contains(tileID))
continue;
// Perform Breadth-First Search to find the size of this connected component
int patchSize = MichuBoardUtils.GetConnectedTileGroupSize(tileID, playerOwnedTiles, visitedTiles, boardData);
largestPatchSize = Mathf.Max(largestPatchSize, patchSize);
}
__result = largestPatchSize;
return;
At this point I had fully functioning Milestones and Awards within the game. After implementing the “register/unregister” system that I described in Part 1, there was no need to patch any of the methods that deal with buying and limiting the total amount of achievements that can be bought.
Moving on
What was missing now was purely visual: I had to design some icons and fix how the custom Milestones appear in the player’s log. More on this in a future post.