Skip to content

Tutorial: Writing a simple module

Emik edited this page Oct 9, 2021 · 4 revisions

Part 1: Class Inheritance

To understand the main focus of the library, we first need to think about Class Inheritance.

Normally, a modded module's class definition would look like this...

public class Foo : MonoBehaviour

In this case, Foo inherits from MonoBehaviour. This means that everything inside MonoBehaviour gets carried over to Foo.

To get the main functionalities of the library, we will be inheriting from a different class from this library.

public class Foo : ModuleScript

Now, everything from ModuleScript gets pulled into Foo!

But wait, are we not inheriting from MonoBehaviour anymore? How can we attach this into Unity?

In this case it's fine, because if we look at ModuleScript's definition...

public abstract class ModuleScript : CacheableBehaviour, IAwake, IDump, ILog

...then look at CacheableBehaviour's definition...

public abstract class CacheableBehaviour : MonoBehaviour

...it eventually inherits MonoBehaviour!

There are 2 such classes you inherit from in this library...

  • ModuleScript: The class for creating either a regular or needy modded module in Keep Talking and Nobody Explodes. This one is required.
  • TPScript<TModule>: The class for implementing Twitch Plays support into any existing module. This one is optional.

Both scripts are attached to the module prefab.


Part 2: Getting Components

To demonstrate the essentials, let us recreate the modded module egg by Red Penguin and Danny7007 with some additional production value.

If we simplify the rules of the module, we have:

"Press the egg when the last digit of the timer is equal to the last digit of the serial number."

The first thing to understand is getting a component.

There are normally 2 main methods in doing so.

// Method 1: Assign this from Unity.
[SerializeField]
private KMBombModule _module1;

// Method 2: Use GetComponent<KMBombModule> and cache the result.
private KMBombModule _module2;

private void Start()
{
    _module2 = GetComponent<KMBombModule>();
}

KeepCoding offers a better solution for Method 2, which doesn't require declaring a field, and uses a method from CacheableBehaviour.

Get<KMBombModule>();

Wouldn't calling this repeatedly be inefficient? Not in this case, as the method stores the result after its been called for the first time.

There are also other methods that cache Unity API's component calls.

  • Get<T>(): Caches Component.GetComponents<T>() and returns the first result.
  • Gets<T>(): Caches Component.GetComponents<T>().
  • GetChild<T>(): Caches Component.GetComponentsInChildren<T>() and returns the first result.
  • GetChildren<T>(): Caches Component.GetComponentsInChildren<T>().
  • GetParent<T>(): Caches Component.GetComponentsInParent<T>() and returns the first result.
  • GetParents<T>(): Caches Component.GetComponentsInParent<T>().
  • Find<T>(): Caches Object.FindObjectsOfType<T>() and returns the first result.
  • Finds<T>(): Caches Object.FindObjectsOfType<T>().

You can also specify your own expensive API call by using Cache<T>().

Cache<Object>(() => new[] { this });

With every single method here, you can also use the optional parameter allowNull to specify whether it should throw an error if the result is null. By default, it is false.

GetChild<KMSelectable>(allowNull: true);
Cache<Object>(() => new[] { this }, allowNull: true);

Keep in mind that once a type has been cached, it will always return the same array.

Get<Component>();
Finds<Component>(); // This returns the same array of components from Get<T>(), because the type Component already been cached by Get<T>()!

Part 3: Assigning Events

Now that we have access to the KMSelectable, a natural next step is to subscribe to one of its events.

Normally this is done by the following...

private void Start()
{
    // Anonymous method
    GetComponent<KMSelectable>.OnInteract += delegate
    {
        return false;
    };

    // Dedicated method
    GetComponent<KMSelectable>.OnInteract += Bar;
}

private bool Bar()
{
    return false;
}

KeepCoding has an extension method for every KMFramework-type with events, in the form of Assign().

Consider the following example...

private void Start()
{
    // Anonymous method
    Get<KMSelectable>().Assign(onInteract: () => 
    {
    });

    // Dedicated method
    Get<KMSelectable>().Assign(onInteract: Foo);
}

private void Foo()
{
}

Notice how there is no boolean return. This is because KeepCoding implictly returns true/false depending on the state of the KMSelectable.

  • If the KMSelectable has no children, it returns false, and the KMSelectable acts as a button.
  • If the KMSelectable has any children, it returns true, and the KMSelectable acts as a module.

Normally this is correct in most cases, but if you do need to specify, you can use the optional parameter overrideReturn to override the return.

// Regardless of children, this KMSelectable acts as a button.
Get<KMSelectable>.Assign(overrideReturn: true, onInteract: Foo);

With this information, we can start writing the first pieces of logic in our module. The internal keyword is for later when we implement Twitch Plays support.

using KeepCoding;
using System;
using UnityEngine;

public class Foo : ModuleScript
{
    [SerializeField]
    internal KMSelectable egg;

    private void Start()
    {
        egg.Assign(onInteract: HandleInteract);
    }

    private void HandleInteract()
    {
        throw new NotImplementedException("Todo: Add something that will happen when a user presses the egg!");
    }
}

Part 4: Solve and Strike logic

"Press the egg when the last digit of the timer is equal to the last digit of the serial number."

Looking back on the rule, we see that we need to obtain the bomb's time, and the last digit of the serial number.

Getting the serial number is pretty simple, as it is part of KMGameInfo and is part of the same game object as this script, Get<T>() can be used...

// Obtain the KMGameInfo.
var gameInfo = Get<KMGameInfo>();

// Obtain the serial number digits.
var numbers = gameInfo.GetSerialNumberDigits();

// Obtain the last digit of the serial number, using System.Linq.
var lastDigit = numbers.Last();

To get the bomb timer, it is a lot simpler. ModuleScript already has a property named TimeLeft which describes the amount of seconds remaining, rounded down.

Therefore, to see if both the last digit of the serial number and the last digit of the bomb's timer are the same, we can use this comparison...

bool isCorrect = TimeLeft % 10 != Get<KMBombInfo>().GetSerialNumberDigits().Last();

Next, we have Log(), which allows you to log something...

Log("Test"); // Logs "[Foo #1] Test"

Log("Test {0}", 0); // Logs "[Foo #1] Test 0"

Log(new[] { 1, 2, 3, 4, 5 }); // Logs "[Foo #1] 1, 2, 3, 4, 5"

Logging is common as it allows both the user to understand their mistakes, and give additional information to you for debugging.

You can even use the extension method Call<T>() as a quick-and-dirty way of viewing the value of something.

new int[][] { new[] { 1 }, new[] { 2 } }.Call(); // Logs "1, 2"

Then the most important part, Solve() and Strike().

You use Solve() when a module is declared as finished, and Strike() to punish a user for a mistake.

You can pass in a string to log something as well.

Strike("Incorrect input!");

Solve("Correct input!");

Now our class looks like this. The internal keyword will be useful to get TP to access this variable.

using KeepCoding;
using KModkit;
using System.Linq;
using UnityEngine;

public class Foo : ModuleScript
{
    internal bool IsCorrect { get { return TimeLeft % 10 == Get<KMBombInfo>().GetSerialNumberDigits().Last(); } }

    [SerializeField]
    internal KMSelectable egg;

    private void Start()
    {
        egg.Assign(onInteract: HandleInteract);
    }

    private void HandleInteract()
    {
        if (!IsCorrect)
        {
            Strike("egg was pressed wrong :(");
            return;
        }

        Solve("egg was pressed right :)");
    }
}

Part 5: Production value

Next, let's add a little but more production value.

If we want to play a sound, we can do so with PlaySound(). This allows for both custom sounds, and in-game sounds...

// Plays both "YourCustomSound" and the in-game sound that plays from the The Button when it is pressed down.
PlaySound("YourCustomSound", Sound.BigButtonPress);

PlaySound() returns KMAudioRef, meaning you also have the ability to stop the sounds if you want.

Keep in mind that if the Master Audio channel is receiving a lot of sounds, some sounds may be skipped, so it does not guarantee a populated array...

var sound = PlaySound("YourCustomSound");

if (sound.Length == 1)
    sound[0].StopSound();

A very common thing to do is to play a sound and shake the bomb from a selectable. This already has a dedicated method called ButtonEffect()...

// Shakes the bomb with intensity 2 with the source being the selectable, and plays "Test" with the source being from the selectable.
ButtonEffect(Get<KMSelectable>(), 2, "Test");

Now for the egg, we declare a property that returns a random number...

private float Next { get { return Random.Range(0, 1f); } }

...and make the egg go in that direction...

egg.GetComponent<RigidBody>().velocity = new Vector3(Next, Next, Next);

After inserting all of that, we now have a finished module.

using KeepCoding;
using KModkit;
using System.Linq;
using UnityEngine;

public class Foo : ModuleScript
{
    internal bool IsCorrect { get { return TimeLeft % 10 == Get<KMBombInfo>().GetSerialNumberDigits().Last(); } }

    private float Next { get { return Random.Range(0, 1f); } }

    [SerializeField]
    internal KMSelectable egg;

    private void Start()
    {
        egg.Assign(onInteract: HandleInteract);
    }

    private void HandleInteract()
    {
        ButtonEffect(egg, 1, Sound.ButtonPress);

        if (!IsCorrect)
        {
            Strike("egg was pressed wrong :(");
            return;
        }

        _egg.GetComponent<RigidBody>.velocity = new Vector3(Next, Next, Next);

        PlaySound(Sound.CorrectChime);
        Solve("egg was pressed right :)");
    }
}

You can alternatively merge the HandleInteract() into Start()...

using KeepCoding;
using KModkit;
using System.Linq;
using UnityEngine;

public class Foo : ModuleScript
{
    internal bool IsCorrect { get { return TimeLeft % 10 == Get<KMBombInfo>().GetSerialNumberDigits().Last(); } }

    [SerializeField]
    internal KMSelectable egg;

    private float Next { get { return Random.Range(0, 1f); } }

    private void Start()
    {
        egg.Assign(onInteract: () =>
        {
            ButtonEffect(egg, 1, Sound.ButtonPress);

            if (!IsCorrect)
            {
                Strike("egg was pressed wrong :(");
                return;
            }

            egg.GetComponent<RigidBody>.velocity = new Vector3(Next, Next, Next);

            PlaySound(Sound.CorrectChime);
            Solve("egg was pressed right :)");
        });
    }
}

Part 6: Twitch Plays

For more information about Twitch Plays, refer to the External Mod Module Support.

To implement Twitch Plays support, you need to create another script.

The script TPScript has a generic type. Make the type your original script type that you are implementing Twitch Plays support for...

public class TPFoo : TPScript<Foo>

This means "TPFoo is implementing Twitch Plays support for Foo.".

When added to the module prefab, a text field and 2 buttons appear.

  • The text field Twitch Help Message is responsible for what gets sent as the help command. Remember that {0} gets replaced with the Twitch ID of the module. Fill this textbox with the help message.
  • The buttons Force Solve and Force Solve All are editor-only features that run the autosolver. The button checks if the autosolver coroutine ends before the module is solved and throws an error message in the event that it does, since it is considered a mistake and Twitch Plays automatically solves the module early.

An error occurs when you inherit from this class, this is because there are a few abstract methods that need to be overriden. Hover over the error and press ALT+ENTER or CTRL+., and implement the abstract class.

Your class should look like this...

using KeepCoding;

public class TPFoo : TPScript<Foo>
{
    public override IEnumerator ForceSolve()
    {
        throw new NotImplementedException("Todo: Implement the autosolver!");
    }

    public override IEnumerator Process(string command)
    {
        throw new NotImplementedException("Todo: Implement the command processor!");
    }
}

Now IEnumerator is a special type to return. Consider the following code...

public override IEnumerator Process(string command)
{
    yield return null;
    yield return null;
}

Whenever a command is run on your module, Process runs. The method runs until a yield return happens, where the value gets passed into Twitch Plays, then waits a frame.

In this case, Twitch Plays sees 2 nulls, and that's it. The only thing this achieves is the fact that since we returned anything, we are telling Twitch Plays that the command was valid.

Let's split the command by space, and add a check to see if the command is formatted correctly, and then ignore the command otherwise...

public override IEnumerator Process(string command)
{
    // Split the command by spaces.
    string[] split = command.Split();

    // Check if the first part of the command isn't 'press'.
    if (!IsMatch(split[0], "press"))
        yield break;

    // Return something to indicate that the command is valid.
    yield return null;
}

yield break; will stop the IEnumerator and does not count as returning.

Now let's extract the value of what comes after press.

Rememeber: Do not trust user input by default, always assume the worst case!

public override IEnumerator Process(string command)
{
    string[] split = command.Split();

    if (!IsMatch(split[0], "press"))
        yield break;

    // Return something to indicate that the command is valid.
    yield return null;

    // We need to see if there are a correct number of parameters.
    if (split.Length != 2)
    {
        yield return SendToChatError(split.Length == 1 ? "You must specify an argument!" : "Too many arguments!");
        yield break;
    }

    // The parameter can only be a character long.
    if (split[1].Length != 1)
    {
        yield return SendToChatError("The parameter is too long!");
        yield break;
    }

    // The parameter can only be a character long.
    int i;

    if (!int.TryParse(split[1][0].ToString(), int i))
    {
        yield return SendToChatError("The parameter is not a number!");
        yield break;
    }
}

SendToChatError is one of the many commands you can yield return to get Twitch Plays to do something.

Now i can be trusted. Let's use Unity's WaitUntil method to repeatedly return null until the condition is satisfied.

For the condition, we can access the module with Module, and use the IsCorrect property we created from Step 4.

public override IEnumerator Process(string command)
{
    string[] split = command.Split();

    if (!IsMatch(split[0], "press"))
        yield break;

    // Return something to indicate that the command is valid.
    yield return null;

    // We need to see if there are a correct number of parameters.
    if (split.Length != 2)
    {
        yield return SendToChatError(split.Length == 1 ? "You must specify an argument!" : "Too many arguments!");
        yield break;
    }

    // The parameter can only be a character long.
    if (split[1].Length != 1)
    {
        yield return SendToChatError("The parameter is too long!");
        yield break;
    }

    // The parameter can only be a character long.
    int i;

    if (!int.TryParse(split[1][0].ToString(), int i))
    {
        yield return SendToChatError("The parameter is not a number!");
        yield break;
    }

    // This waits until the user has their digit as the last timer.
    yield return new WaitUntil(() => i == Module.TimeLeft);
    
    // Press the egg.
    yield return new[] { Module.egg };
}

For the autosolver, it's really easy. We just need to wait until the timer rolls around to the correct time, luckily we have already incorporated IsCorrect, so let's use that.

public override IEnumerator ForceSolve()
{
    yield return YieldUntil(true, () => Module.IsCorrect);
    Module.egg.OnInteract();
}

Keep in mind that the autosolver ignores KMSelectables or strings and will not process them, therefore we will use OnInteract directly.

Another useful feature about KeepCoding is that when you return an IEnumerator, it will iterate over it and return its items before sending it to Twitch Plays. This means that we can split the method like so and it will work the same...

using KeepCoding;
using UnityEngine;

public class TPFoo : TPScript<Foo>
{
    public override IEnumerator ForceSolve()
    {
        yield return YieldUntil(true, () => Module.IsCorrect);
        Module.egg.OnInteract();
    }

    public override IEnumerator Process(string command)
    {
        string[] split = command.Split();

        if (!IsMatch(split[0], "press"))
            yield break;

        yield return PressCommand(command);
    }

    private IEnumerator PressCommand(string command)
    {
        // Return something to indicate that the command is valid.
        yield return null;

        // We need to see if there are a correct number of parameters.
        if (split.Length != 2)
        {
            yield return SendToChatError(split.Length == 1 ? "You must specify an argument!" : "Too many arguments!");
            yield break;
        }

        // The parameter can only be a character long.
        if (split[1].Length != 1)
        {
            yield return SendToChatError("The parameter is too long!");
            yield break;
        }

        yield return ParseInput(command);
    }
    
    private IEnumerator ParseInput(string command)
    {
        // The parameter can only be a character long.
        int i;

        if (!int.TryParse(split[1][0].ToString(), int i))
        {
            yield return SendToChatError("The parameter is not a number!");
            yield break;
        }

        // This waits until the user has their digit as the last timer.
        yield return new WaitUntil(() => i == Module.TimeLeft);
    
        // Press the egg.
        yield return new[] { Module.egg };
    }
}