Tutorial: Making Unlockables - rspforhp/WildfrostModdingDocumentation GitHub Wiki

By : @Hopeless_phan ( Fury )


Warning

🚧 🚧 Tutorial under heavy construction 🚧 🚧 🚧 🚧 Tutorial under heavy construction 🚧 🚧 🚧 🚧 Tutorial under heavy construction 🚧 🚧

0 - Why add Unlockables?

Hello reader! As you add more and more content to your mod, you might wonder if its worth adding challenges and unlockables like the base game does. This can be for any number of reasons common to roguelikes;

  • You want to slowly introduce more and more mechanics to the player
  • You have some kind of "story" or progression you want to give the player
  • ... or you want the player to spend more time (and make more videos) on your mod

1 - Prior Setup

Note: In this example, I will be making a pet Ice Forge because its funny.

1.1 UnlockData

UnlockDatas really just hold the instructions for how to display the reward when some ChallengeData is completed. These are as easy to get going as just copying a template and changing the databuilder's name. These are incredibly boring and it's honestly worth making a shortcut for these.

For most UnlockData types, these are their templates.

For pets
new UnlockDataBuilder(this)
.Create("Unlock Pet IceForge")
.WithType(UnlockData.Type.Pet)
.WithUnlockTitle(Extensions.GetLocalizedString("UI Text", "unlock_pet"))
.WithUnlockDescription(Extensions.GetLocalizedString("UI Text", "unlock_pet_desc"))
.SubscribeToAfterAllBuildEvent(data =>
{
    data.relatedBuilding = GetAsset<BuildingType>("Buildings/PetHut.asset");
    if (!data.relatedBuilding.unlocks.ToArrayOfNames().Contains(data.name))
        data.relatedBuilding.unlocks = data.relatedBuilding.unlocks.AddToArray(data);
    data.requires = new UnlockData[]
    {
        Get<UnlockData>("PetHutFinished"),
    };
});
For items
new UnlockDataBuilder(this)
.Create("Unlock Item IceForge")
.WithType(UnlockData.Type.Item)
.WithUnlockTitle(Extensions.GetLocalizedString("UI Text", "unlock_item"))
.WithUnlockDescription(Extensions.GetLocalizedString("UI Text", "unlock_item_desc"))
.SubscribeToAfterAllBuildEvent(data =>
{
    data.relatedBuilding = GetAsset<BuildingType>("Buildings/InventorHut.asset");
    if (!data.relatedBuilding.unlocks.ToArrayOfNames().Contains(data.name))
        data.relatedBuilding.unlocks = data.relatedBuilding.unlocks.AddToArray(data);
    data.requires = new UnlockData[]
    {
        Get<UnlockData>("InventorHutFinished"),
    };
});
For companions
new UnlockDataBuilder(this)
.Create("Unlock Companion IceForge")
.WithType(UnlockData.Type.Companion)
.WithUnlockTitle(Extensions.GetLocalizedString("UI Text", "unlock_companion"))
.WithUnlockDescription(Extensions.GetLocalizedString("UI Text", "unlock_companion_desc"))
.SubscribeToAfterAllBuildEvent(data =>
{
    data.relatedBuilding = GetAsset<BuildingType>("Buildings/HotSpring.asset");
    if (!data.relatedBuilding.unlocks.ToArrayOfNames().Contains(data.name))
        data.relatedBuilding.unlocks = data.relatedBuilding.unlocks.AddToArray(data);
    data.requires = new UnlockData[]
    {
        Get<UnlockData>("HotSpringFinished"),
    };
});
For tribes
new UnlockDataBuilder(this)
.Create("Unlock Tribe IceForge")
.WithType(UnlockData.Type.Tribe)
.WithUnlockTitle(Extensions.GetLocalizedString("UI Text", "unlock_tribe"))
.WithUnlockDescription(Extensions.GetLocalizedString("UI Text", "unlock_tribe_desc"))
.SubscribeToAfterAllBuildEvent(data =>
{
    data.relatedBuilding = GetAsset<BuildingType>("Buildings/TribeHut.asset");
    if (!data.relatedBuilding.unlocks.ToArrayOfNames().Contains(data.name))
        data.relatedBuilding.unlocks = data.relatedBuilding.unlocks.AddToArray(data);
    data.requires = new UnlockData[]
    {
        Get<UnlockData>("TribeHutFinished"),
    };
});
For charms
new UnlockDataBuilder(this)
.Create("Unlock Charm IceForge")
.WithType(UnlockData.Type.Charm)
.WithUnlockTitle(Extensions.GetLocalizedString("UI Text", "unlock_charm"))
.WithUnlockDescription(Extensions.GetLocalizedString("UI Text", "unlock_charm_desc"))
.SubscribeToAfterAllBuildEvent(data =>
{
    data.relatedBuilding = GetAsset<BuildingType>("Buildings/ChallengeShrine.asset");
    if (!data.relatedBuilding.unlocks.ToArrayOfNames().Contains(data.name))
        data.relatedBuilding.unlocks = data.relatedBuilding.unlocks.AddToArray(data);
    data.requires = new UnlockData[]
    {
    };
});

Note that the ChallengeShrine starts unlocked

For events
new UnlockDataBuilder(this)
.Create("Unlock Event IceForge")
.WithType(UnlockData.Type.Event)
.WithUnlockTitle(Extensions.GetLocalizedString("UI Text", "unlock_event"))
.WithUnlockDescription(Extensions.GetLocalizedString("UI Text", "unlock_event_desc"))
.SubscribeToAfterAllBuildEvent(data =>
{
    data.relatedBuilding = GetAsset<BuildingType>("Buildings/Icebreakers.asset");
    if (!data.relatedBuilding.unlocks.ToArrayOfNames().Contains(data.name))
        data.relatedBuilding.unlocks = data.relatedBuilding.unlocks.AddToArray(data);
    data.requires = new UnlockData[]
    {
        Get<UnlockData>("IcebreakerHutFinished"),
    };
});

Note

In these templates, we finally use GetAsset. These can only be used to get addressable assets, either made through Tutorial 6: Using Addressables or listed in the mod references sheet: https://docs.google.com/spreadsheets/d/1YGf_4x-XcwxzF31fXmWUjwjb-sCV7G5s_ues02ruWo0/edit?gid=51799025#gid=51799025

1.2 - ChallengeData and ChallengeListener

...

2 - Patches

I might just bundle these with VFX mod again, since most people would depend on it anyway? NOTE: It is assumed that the mod class itself has the attribute of [HarmonyPatch]

For challenges to show up on the unlockable card:

[HarmonyPatch(typeof(PetHutSequence), nameof(PetHutSequence.Start))]
static void Postfix(PetHutSequence __instance)
{
    List<ChallengeData> petChallenges = [];
    foreach (var kvp in MetaprogressionSystem.GetPetDict().Skip(7)) // Ignore the 7 vanilla pets? Hoping that the order is retained
    {
        if (AddressableLoader.Get<CardData>(nameof(CardData), kvp.Key) == null)
            continue;

        ChallengeData challenge = AddressableLoader.GetGroup<ChallengeData>(nameof(ChallengeData)).FirstOrDefault(c => c.reward?.name == kvp.Value);
        Debug.LogWarning("PETHUT: " + (kvp, challenge));
        
        if (challenge != null)
            petChallenges.Add(challenge);
    }

    // ISSUE: only the challenges for pets indexed >= __instance.challenges.Length will be shown!
    if (petChallenges.Any())
        __instance.challenges = __instance.challenges.AddRangeToArray(petChallenges.ToArray());
}

Note that this doesn't yet fix how, when unlocking the pet, it will shove itself into where the next unlockable vanilla pet would go (if any)

For custom challenge listener systems:

[HarmonyPatch(typeof(GameObjectExt), nameof(GameObjectExt.AddComponentByName))]
public static bool Prefix(ref Component __result, GameObject gameObject, string componentName)
{
    Type componentType = Type.GetType(componentName) ?? instance.GetType().Assembly.GetType(componentName);
    if (componentType != null)
    {
        __result = gameObject.AddComponent(componentType);
        return false;
    }

    return true;
}

For unload cleanup:

public void FixUnlocks()
{
    Resources.FindObjectsOfTypeAll<BuildingType>().Do(type =>
    type.unlocks = type.unlocks.Where(unlock => unlock != null && unlock.ModAdded != this).ToArray()
    );
}

3 - Testing

Using the Another Console mod, you can access the commands:

  • progress set: Set the current value of an active challenge, but not automatically completing it.
  • progress complete: Immediately complete an active challenge isn't complete.
  • progress uncomplete: The opposite of completing
  • progress notification: Runs the unlock sequence for the town building's challenges, except tribes
  • progress sequence: Runs the unlock sequence for any UnlockData. Similar to progress notification but for UnlockData that aren't rewards of ChallengeData, like journal pages and tribes (kind of)
  • progress gain: Currently broken...
⚠️ **GitHub.com Fallback** ⚠️