Skip to content

Typical usage example and explanation

MarC0 edited this page Mar 13, 2020 · 13 revisions

Here's a structure of a typical plugin using KKAPI to add a new functionality to a character. We will use BecomeTrap as an example, you can see all source code from this page here.

This is just an example of what worked well for me. You can use a different setup, but you will need to at least split out the controller.

The goal of the BecomeTrap plugin is to load different character animations based on a setting that is saved to the character card. The setting is configured in character maker.

Project structure of BecomeTrap

These are all of the classes that the plugin uses. This structure allows separation of "business" code from interface code and "glue" code. This makes the code easier to understand and maintain.

  • BecomeTrap.cs
  • BecomeTrap.Hooks.cs
  • BecomeTrapController.cs
  • BecomeTrapGui.cs

BecomeTrap.cs

This is the "glue" logic. It acts as an entry point of the plugin and takes care of registering the plugin and checking of dependencies or incompatibilities.

// Specify this as a plugin that gets loaded by BepInEx
[BepInPlugin(GUID, "Koikatsu: Become Trap", Version)]
// Tell BepInEx that we need KKAPI to run, and that we need the latest version of it. Check documentation of KoikatuAPI.VersionConst for more info.
[BepInDependency(KKAPI.KoikatuAPI.GUID, KKAPI.KoikatuAPI.VersionConst)]
public partial class BecomeTrap : BaseUnityPlugin
{
    // Expose both your GUID and current version to allow other plugins to easily check for your presence and version, for example by using the BepInDependency attribute.
    // Be careful with public const fields! Read more: https://stackoverflow.com/questions/55984
    // Avoid changing GUID unless absolutely necessary. Plugins that rely on your plugin will no longer recognize it, and if you use it in function controllers you will lose all data saved to cards before the change!
    public const string GUID = "marco.becometrap";
    public const string Version = "2.0";

    internal static BecomeTrap Instance;
    internal static new ManualLogSource Logger;

    private void Awake()
    {
        Instance = this;
        Logger = base.Logger;

        if (StudioAPI.InsideStudio) return;

        // Register your logic that depends on a character.
        // A new instance of this component will be added to ALL characters in the game.
        // The GUID will be used as the ID of the extended data saved to character
        // cards, scenes and game saves, so make sure it's unique and do not change it!
        CharacterApi.RegisterExtraBehaviour<BecomeTrapController>(GUID);

        BecomeTrapGui.Initialize();

        // Register your hooks (in this example contained in BecomeTrap.Hooks.cs)
        HarmonyWrapper.PatchAll(typeof(Hooks));
    }
}

BecomeTrap.hooks.cs

This part is highly dependant on your plugin. In this case the hooks check the BecomeTrapController.IsTrap property and if true, load different assets. You can see the actual hooks here.

Usually you will want to make a method that can figure out what character the fired hook is working on, and get that character's controller. For example:

private static BecomeTrapController GetController(Player player)
{
    // Always do a full null check instead of using ? when dealing with GameObjects and Components/MonoBehaviors
    // https://forum.unity.com/threads/elvis-operator-null-coalescing-operator.433767/
    if(player == null || player.chaCtrl == null) return null;
    // Controllers are added to the character's root gameobject.
    return player.chaCtrl.gameObject
        // It's safe to assume that if a ChaCtrl exists then all controllers have been added already.
        .GetComponent<BecomeTrapController>();
}

BecomeTrapController.cs

This class is a controller of your custom function. This controller is a MonoBehaviour that is added to ALL characters spawned into the game. It provides many useful methods that abstract away the nasty hooks needed to figure out when a character is changed or how to save and load your custom data to the character card.

It's recommended to not use constructors, Awake or Start in controllers. Use OnReload instead. OnReload is guaranteed to be fired after a character is created but before your Update method runs.

public class BecomeTrapController : CharaCustomFunctionController
{
    internal bool IsTrap { get; set; }
    internal string IdleAnimation { get; set; }

    // OnCardBeingSaved is fired when the character information is being saved
    // It handles all types of saving (to character card, to a scene etc.)
    protected override void OnCardBeingSaved(GameMode currentGameMode)
    {
        // Only work for male characters (male is 0, female is 1)
        if (ChaControl.sex == 0 && IsTrap)
        {
            // Write your state to a new PluginData and save it with the SetExtendedData method
            // Avoid reusing old PluginData since we might no longer be pointed to the same character.
            var data = new PluginData();
            data.data.Add("IsTrap", IsTrap);
            data.data.Add("IdleAnimation", IdleAnimation);
            data.version = 1;

            SetExtendedData(data);
        }
        else
        {
            // If unnecessary, set your extended data to null so it doesn't get added to the card at all
            SetExtendedData(null);
        }
    }

    // OnReload is fired whenever the character's state needs to be updated.
    // This might be beacuse the character was just loaded into the game, 
    // was replaced with a different character, etc.
    // Use this method instead of Awake and Start. It will always get called
    // before other methods, but after the character is in a usable state.
    // WARNING: Make sure to completely reset your state in this method!
    //          Assume that all of your variables are no longer valid!
    protected override void OnReload(GameMode currentGameMode)
    {
        // ALWAYS reset your properties/fields to their defaults - they might have
        // unexpected values in them for example because the character was swapped!
        IsTrap = false;
        IdleAnimation = null;

        // Again, only work for males
        if (ChaControl.sex == 0)
        {
            // Use GetExtendedData to grab the state that you've saved above with SetExtendedData
            var data = GetExtendedData();

            // Validate that the data exists and is correct
            // If everything looks correct, load the state
            if (data != null)
            {
                IsTrap = data.data.TryGetValue("IsTrap", out var val) && val is bool isTrap && isTrap;
                IdleAnimation = data.data.TryGetValue("IdleAnimation", out var val2) && val2 is string anim ? anim : null;
            }
        }
    }
    
    // The above two methods are mandatory to implement.
    // Next, there are two optional functions that are used for
    // coordinate (outfit) cards (they are not used in BecomeTrap):
    
    // OnCoordinateBeingLoaded is fired whenever the player loads a
    // coordinate on top of the current character.
    protected override void OnCoordinateBeingLoaded(ChaFileCoordinate coordinate)
    {
        // Get the extended data that you've saved to the card
        var pluginData = GetCoordinateExtendedData(coordinate);
        
        /* Load the plugin data into your state. 
         * You can use the CurrentCoordinate property to figure out what
         * clothes set your character is wearing right now.
        */
    }
    
    // OnCoordinateBeingSaved is fired whenever the player saves your
    // current outfit to a coordinate card. 
    protected override void OnCoordinateBeingSaved(ChaFileCoordinate coordinate)
    {
        var pluginData = new PluginData();
        
        /* Write your state into the plugin data object. */
        
        // Save your plugin data into the card
        SetCoordinateExtendedData(coordinate, pluginData);
    }
}

BecomeTrapGui.cs

This file deals exclusively with the character maker interface. It configures the necessary custom interface elements and forwards their values to the controller that's attached to the character loaded inside character maker.

public class BecomeTrapGui
{
    private static readonly List<KeyValuePair<string, string>> _idleAnimations = new List<KeyValuePair<string, string>>
    {
        new KeyValuePair<string, string>("Stand_17_01", "Hands behind"),
        // ...
    };

    internal static string DefaultIdleAnimation => _idleAnimations[0].Key;

    internal static void Initialize()
    {
        // This event is fired every time the character maker is being loaded. It never fires in Studio.
        // If user quits maker all of your registered controls will be destroyed.
        // This event will be fired to register the controls again next time maker opens.
        MakerAPI.RegisterCustomSubCategories += RegisterCustomSubCategories;

        // Here are some additional events that you might need:

        // MakerAPI.ReloadCustomInterface
        // This event is fired every time a character card is loaded inside maker.
        // In this case it's used to update the interface with new values.
        // This event is only fired when inside the character maker.

        // MakerAPI.MakerExiting
        // Clean up after leaving maker. You want to return to the state you were in before maker was loaded.
    }

    private static void RegisterCustomSubCategories(object sender, RegisterSubCategoriesEvent e)
    {
        // Only work in male maker
        if (MakerAPI.GetMakerSex() != 0) return;

        // Category to add our controls under. 
        //If you want to make a custom setting category/tab, use e.AddSubCategory
        var category = MakerConstants.Parameter.Character;

        // Add a toggle control at the bottom of the Parameter/Character screen.
        var isTrap = e.AddControl(new MakerToggle(category, "Character is a trap or a futa (changes gameplay)", BecomeTrap.Instance));
        // Connect the toggle to our custom function controller.
        isTrap.BindToFunctionController<BecomeTrapController, bool>(
            // This gets the value from the controller. This value is then set to our toggle. 
            // This runs every time a character is reloaded.
            (controller) => controller.IsTrap,
            // This sets the value of your toggle to the controller. 
            // This runs every time the value of our toggle changes, usually by user clicking it.
            (controller, value) => controller.IsTrap = value);

        // Add a drop down list.
        var animType = e.AddControl(new MakerDropdown("Idle trap animation", _idleAnimations.Select(x => x.Value).ToArray(), category, 0, BecomeTrap.Instance));
        animType.BindToFunctionController<BecomeTrapController, int>(
            // Here we need to do some processing because animations are stored in the controller as strings, while the dropdown uses int index.
            (controller) => Mathf.Max(0, _idleAnimations.FindIndex(pair => pair.Key == controller.IdleAnimation)),
            (controller, value) => controller.IdleAnimation = _idleAnimations[value].Key);
    }
}