MicroWorks
Pas assez d'évaluations
Modding Pt.1: Custom Microgames
De noam 2000
MicroWorks' final 1.11 update introduces extended modding capabilities, including custom microgames, boss stages, and scenes.

In this tutorial series, we'll be learning how to utilize the scripting interface and create custom games that we can later publish to the workshop. Part 1 will focus on understanding the scripting interface and creating custom microgames.

1. Custom Microgames >> YOU ARE HERE <<
2. Custom Boss Stages
3. Custom Scenes

The guides are designed in a sequential order, and it is recommended to follow them in order, even if you are only interested in a specific one.

This is not a guide for beginners. You will be required to have some scripting knowledge, particularly with the Lua scripting language.

For any questions, ask us! Either in the Steam forums or on Discord.[discord.gg]
   
Récompenser
Ajouter aux favoris
Favoris
Retirer des favoris
What You’re Going to Need
1. Party Pass
Hosting a scripting enabled server requires the Party Pass DLC. If you don't already own it, you will have to get it.

https://steamproxy.net/steamstore/app/2355460/MicroWorks__Party_Pass/

2. Basic Understanding of Lua
Lua is the scripting language that powers custom scripts in MicroWorks.

Whether it's microgames, boss stages, or an AutoRun script, you'll fare far better if you're coming into this with a basic understanding of the Lua language. https://www.lua.org/

3. Code Editor of Choice
Y'know, something to write the Lua code in (that isn't notepad).

Some recmomendations: Visual Studio Code[code.visualstudio.com], Sublime Text[www.sublimetext.com], or even the lightweight Notepad++.[notepad-plus-plus.org]

4. Documentation
Not planning to walk in blindly, are you?

The MicroWorks Scripting Documentation details all exposed functions and events that you can make use of in your scripts. You'll want to have it opened in a tab while you're working: https://agiriko.digital/docs/

5. MicroKit
MicroKit is the official MicroWorks modding toolkit.

It will allow you to create custom levels (especially necessary later on for boss stages), convert assets to the MicroWorks format, pack and upload mods, and more.

You can find it in your Steam tools library, under MicroKit - MicroWorks Editor.



6. (OPTIONAL) Helper Scripts
In this Google Drive folder[drive.google.com], we've compiled some useful resources for your modding ventures, including helper scripts, mod examples, and the assets of the final results of this very guide you're reading.

If you're here mostly to learn, and less waste time on assets, you can always download the assets provided in that folder, "Tutorial Results". (for example, microgames require music, so you can grab the music provided in that folder. Music that, by the way, was specially created for this guide by my insanely talented friends Musearys and Tyra!)

I'd also like to bring attention to the Raycast Debugger script in Helper Scripts/Raycast Debugger. This script will display world coordinates at the position that your crosshair is aiming at, and can be very useful if you need to know the specific position of a point in the map, especially when you're making custom microgames and want to spawn something in the Nexus.
LUA Quickstart: AutoRun
Before we jump into the meat and juice of this guide, it is important to understand the scripting interface and documentation correctly.

First, let's navigate to Steam/steamapps/common/MicroWorks/MicroWorks_Data/StreamingAssets.

This is the root of where files will be read from, and where you'll be working from most of the time. When specifying paths to files in the code, you're gonna want to treat this as the root folder. So for example:

If I want to load a texture in:
"Steam/steamapps/common/MicroWorks/MicroWorks_Data/StreamingAssets/MyAssets/MyTexture.png"

I will specify the path in the code as:
"\MyAssets\MyTexture.png"

In StreamingAssets, let's create a folder called "Scripts", and inside that folder, create another folder called "AutoRun". Scripts located inside "StreamingAssets/Scripts/AutoRun" will automatically execute when a level loads, if the server has scripting enabled. This can be used to make global mods like the UT Announcer or the Kill Feed.

Inside the AutoRun folder, let's create a "TestScript.lua" file, and start editing it. We'll use this file to test out the numerous examples we're about to learn from the documentation. Speaking of the documentation, let's avert our attention there for a moment and start to analyze it...
LUA Quickstart: General
Let's have a look at the documentation.[agiriko.digital]

General is the category for generalized interfaces that don't always necessarily lend themselves to an asset or entity. Most notable entries from the general category are Global and WorldInfo.

* Global[agiriko.digital] is the global-scoped class that contains functions that are baked into the Lua sandbox, and can be freely called as-is.

For example, the following function will try to load a texture from a specified path:

local myTexture local myTexturePath = "\MyAssets\MyTexture.png" if FileExists(myTexturePath) then myTexture = LoadResource(myTexturePath, ResourceType.Texture) else error(myTexturePath .. " does not exist!") end

As you can see, FileExists and LoadResource are both functions from the Global sandbox.

* WorldInfo[agiriko.digital] is the class that facilitates the connection between Lua and the MicroWorks world. It usually contains functions pertaining to the game world or active server.

Functions from WorldInfo are called via: worldInfo:Function(). For example:

local message = "Hello world!" worldInfo:ShowMessageLocal(message)

Very often, you may want to create delayed execution, like calling a function and then waiting some time before calling another function. This is where RunAsync can help.

RunAsync is a wrapper for a function that allows inserting awaiters. For example:

RunAsync( -- Open Async function() -- Open function -- Wait 5 seconds Wait(Seconds(5)) -- Display message local message = "Hello world!" worldInfo:ShowMessageLocal(message) end -- Closes function ) -- Closes Async

This will make the script wait 5 seconds before displaying the message.

There are many awaiters that you can find in the Global[agiriko.digital] class. Some examples are:

* Yield() - Waits a single frame.
* Wait(Frames(5)) - Waits 5 frames.
* Wait(Seconds(5)) - Waits 5 seconds.
* Wait(Until(function() return worldInfo:GetSynchronizedTime() > 60 end)) - Waits until the provided function returns true. In this instance, it will wait until the time since the game started has passed a minute.
* Wait(Event("PlayerJoined")) - Waits until the "PlayerJoined" event fires. More about events later.

Keep in mind that just like the name implies, RunAsync runs asynchronously, meaning that the code written inside will run in its own separate routine, and the code written after the RunAsync will continue to run normally.

-- "Test 1" and "Test 2" will print on the same frame -- "Test 3" will print a second later print("Test 1") RunAsync(function() Wait(Seconds(1)) print("Test 3") end) print("Test 2")
LUA Quickstart: Types & Resources
Types are additional value types to the ones you may already be familiar with (booleans, floats, strings, etc).

For example, LuaVector3[agiriko.digital] is a vector holding 3 float values, that can be created via Vector3(x,y,z).

Usage example:

local teleportPosition = Vector3(10, 5, 10) -- Creates our own vector local teleportFacing = Vector3Zero() -- Shorthand for creating Vector3(0, 0, 0) local player = worldInfo:GetLocalPlayer() player:Teleport(teleportPosition, teleportFacing)

Other notable types are:

* LuaColor[agiriko.digital] - Color(x, y, z, w)
Constructs a Color with 4 provided floats for RGBA values, of 0-1 range.

* LuaQuaternion[agiriko.digital] - Quaternion(x, y, z, w)
Constructs a Quaternion with 4 provided floats. Please do EulerToQuaternion(Vector3(x, y, z)) instead to get a rotation from euler angles. It is not recommended to construct quaternions yourself, unless you understand them inside-out.

Resources are assets that you load from the disk via LoadResource(Path, ResourceType), where ResourceType can be:

* ResourceType.Texture[agiriko.digital] (.png, .jpg)
* ResourceType.Audio[agiriko.digital] (.ogg)
* ResourceType.MemoryAudio[agiriko.digital] (same as ResourceType.Audio, but allows the sound to be reused across multiple sources)
* ResourceType.Model[agiriko.digital] (.nmd, requires MicroKit to create)
* ResourceType.Skybox[agiriko.digital] (.png, .jpg, will create a cubemap out of the provided image)
* ResourceType.Text (Any file, will read and return its contents as a string)
* ResourceType.Bytes (Any file, will read and return its contents as an array of bytes)

Example usage can be:

local myModel = LoadResource("\MyAssets\MyModel.nmd", ResourceType.Model) myModel:Spawn(Vector3(23.4, 4.2, 9.5), QuaternionIdentity())
LUA Quickstart: Wrappers & Entities
Wrappers are interfaces for the many entities and classes MicroWorks has.

For example, if you get the local player via worldInfo:GetLocalPlayer(), we can see in the documentation that it returns PlayerControllerWrapper[agiriko.digital], and we can use the functions available for us in the documentation for that wrapper.

It's very important to note that wrappers are, as their name implies, just a wrapper for the entity, allowing you to call functions for that entity, but are not a direct reference to the entity. That means, for example, if you want to check whether the entity has been destroyed, doing "wrapper ~= nil" is wrong, because the wrapper will persist even after the object's death. Instead, you want to use "ObjectExists(wrapper)", like so:

-- Get all players in the server local allPlayers = worldInfo:GetAllPlayers() -- Get a random player from the server local player = allPlayers[math.random(#allPlayers)] -- Wait 60 seconds and check if this player still exists RunAsync(function() Wait(Seconds(60)) print(tostring(player ~= nil)) -- Will always return true, because we are checking the wrapper and not the underlying entity (player). print(tostring(ObjectExists(player))) -- Will return true if the player controller still exists in the game, and false if it was destroyed (which would happen if the player left). end)

As an exercise, let's make a function that, when called, will award the host with a special weapon, and everyone else with a different weapon.

local function GiveWeapons() -- Start of function -- Execute this function only if we're the host if not worldInfo:IsServer() then return end -- Get all active players in the server local allPlayers = worldInfo:GetAllActivePlayers() -- Iterate over every player -- allPlayers is a table of PlayerControllerWrapper -- i is the index of the iteration, and v is the value for i, v in ipairs(allPlayers) do -- If this player is the server (host), give them a special weapon if v:IsServer() then v:AwardWeapon("Loserbuster") -- Otherwise, award them another weapon else v:AwardWeapon("BatWood") end end end -- End of function -- Execute right away GiveWeapons()

Now, let's say I want to spawn an entity, like an audio source. WorldInfo contains the "CreateEntity(EntityType)" function, where EntityType can be:

* Entity.UIImage[agiriko.digital] - creates a UI image.
* Entity.UIText[agiriko.digital] - creates a UI text.
* Entity.Decal[agiriko.digital] - creates a decal.
* Entity.PointLight[agiriko.digital] - creates a light.
* Entity.ReflectionProbe[agiriko.digital] - creates a reflection probe.
* Entity.AudioSource[agiriko.digital] - creates an audio source.
* Entity.Trigger[agiriko.digital] - creates a trigger field.
* Entity.Killer[agiriko.digital] - creates a killer field.
* Entity.Bouncer[agiriko.digital] - creates a bouncer.
* Entity.Teleporter[agiriko.digital] - creates a teleporter.

After creating an audio source, I want to move it to a desired location. Moving objects around requires them to have a Transform[agiriko.digital], and so long as they inherit from Component[agiriko.digital], you should be able to call :GetTransform() to get their transform and manipulate it.

You can check what each wrapper inherits from in the documentation. For example, in the AudioSourceWrapper, we see the following header:

"public class AudioSourceWrapper : MonoBehaviourWrapper".

This means that it inherits from MonoBehaviourWrapper. MonoBehaviourWrapper, in turn, inherits from ComponentWrapper, which contains the "GetTransform()" function we need. Great!

So, in code we'd do:

-- Load audio file and create audio source local mySound = LoadResource("\MyAssets\MySound.ogg", ResourceType.Audio) local audioSource = worldInfo:CreateEntity(Entity.AudioSource) -- Set audio source parameters audioSource:SetAudioClip(mySound) audioSource:SetSoundType(SoundType.Effect) audioSource:SetVolume(0.75) audioSource:Set3D(true) audioSource:SetMinDistance(25) audioSource:SetMaxDistance(100) audioSource:SetLoop(true) -- Set the audio source position audioSource:GetTransform():SetPosition(Vector3(0, 50, 0)) -- Play it! audioSource:Play()

In addition to the transform, objects derived from ComponentWrapper will also give you access to :GetGameObject() (GameObjectWrapper).[agiriko.digital] The game object is an object in the world that can have any component attached to it. For example, the audio source we created is actually just a component attached to a game object. So, every component has a GameObject, and every component has a Transform.

The game object contains many useful functions that you can use on all entities, such as SetActive(), or Destroy(), or even MakeUsable() and ListenToCollisions() (given this gameobject has collision, most notably for models).

* When spawning a UI object such as a UIImage or UIText, use :GetRectTransform() instead of :GetTransform(). More about UI in Part 2.
LUA Quickstart: Events
Events are notifications that fire when a specific event happens. For example, when a player joins the server, a "PlayerJoinedEvent" will fire.

We can listen to these events, attach code to execute when they happen, and even get information about them, like the player that joined in "PlayerJoinedEvent".

ListenFor( -- Starts ListenFor "PlayerJoined", -- Name of event, provide it as described in the documentation description function(pay) -- Starts function, add "pay" argument to retrieve information from the event worldInfo:ShowMessageLocal(pay:GetPlayer():GetSteamName() .. " has joined the game!") end -- Closes function ) -- Closes ListenFor

The functions that we can use on the payload (pay) vary on each event, and are detailed in the documentation.

Another example could be:

-- Listen for PlayerPushed event ListenFor("PlayerPushed", function(pay) -- Do not execute if we are not the host if not worldInfo:IsServer() then return end -- Kill the pusher because THIS IS A NO PUSHING SERVER ! pay:GetPusherPlayer():Kill() end)
LUA Quickstart: Client/Server
MicroWorks is a multiplayer game, and so you need to understand client-server relations and how each function you call will affect the online state of the game.

In the documentation, you will see that the description for each function starts with the following:

* (Server)
* (Client)
* (Client/Server)

(Server) indicates that the function is already synced by the game, and should only be called from the host. Trying to call it from a client could lead to an error at best and stuff breaking at worst.

If the function returns information (is not void), it likely means only the server is updated on this information and clients may not be up-to-date. In this case, it may be up to you to manually sync it.

(Client) indicates the function is not synced, and although it can safely be called from anyone, it will only execute locally. If you need to sync it, you will have to do so manually.

If the function returns information (is not void), it likely means all clients are already up to date on this information and no additional syncing is required (given that no local changes were made to it from script).

(Client/Server) indicates that the function is already synced by the game, but can still be safely called from either server or client (given that the client has authority to perform this action).

For example, PlayerController:Kill() is marked as (Client/Server). This means if we call it on ourselves, we will update the server and the server will sync it. If the server calls it on us, it is also valid. If we try to call it on another player, that will not work.

If the function returns information (is not void), it likely means all clients are already up to date on this information and no additional syncing is required (given that no local changes were made to it from script).
LUA Quickstart: Remote Procedure Calls
Now that we understand what's synced and what's not, we need to understand how to sync stuff ourselves.

That's where Remote Procedure Calls (RPCs) come into play. They are essentially just messages to a server or a client that say "Hey, please execute this function!".

RPCs are set up like so:

RPC( -- Starts RPC definition "SendMessageRPC", -- Name of the RPC SendTo.Multicast, -- Where should this RPC get sent? Delivery.Reliable, -- What protocol to send it over? function() -- Starts function worldInfo:ShowMessageLocal("This message travelled over the wire!") end -- Closes function ) -- Closes RPC definition if worldInfo:IsServer() then worldGlobals.SendMessageRPC() -- Executes the RPC end

This will create the RPC and store it into the worldGlobals global table that's shared across all scripts. Once created, you can call worldGlobals.RPCName() to execute it.

There are 4 parameters that you supply to the RPC:

1. Name - The name as a string.

2. SendTo - From where to where should this RPC get sent? Valid options are:

* SendTo.Multicast - Sent from the host to everyone in the server (including the host).
* SendTo.Server - Sent from the client to the host.
* SendTo.TargetClient - Sent from host to a specific client.

When an RPC is set as TargetClient, the first parameter you supply to worldGlobals.RPCName() should be the PlayerController you wish to send this message to.

It is important to verify that the RPC is being called from where it's supposed to. For example, you don't want to call a multicast RPC from a client.

3. Delivery - What protocol to send this message over? Valid options are:

* Delivery.Reliable - Will ensure the message gets delivered and in order, but could take slightly longer. Use this if you can't afford the message to get lost or arrive out of order.
* Delivery.Unreliable - Will fire and forget, meaning that if the message didn't make it to the other side, it is lost forever. Use this if you absolutely need speed and can afford to lose a few packets.

4. Function - the code to execute.

-- Server -> Client RPC RPC("ServerToClientRPC", SendTo.Multicast, Delivery.Reliable, function() end ) -- Client -> Server RPC RPC("ClientToServerRPC", SendTo.Server, Delivery.Reliable, function() end ) -- Server -> Target Client RPC RPC("ServerToTargetClientRPC", SendTo.TargetClient, Delivery.Reliable, function() end )

Here's an example of how we can use RPCs to sync information from the host to the clients:

-- Displays a number local function DisplayNumber(num) worldInfo:ShowMessageLocal(tostring(num)) end -- Server -> Client RPC RPC("DisplayNumberToClients", SendTo.Multicast, Delivery.Reliable, function(num) -- The host will already be aware of the random number, because they generated it. -- We will execute the function immediately for the host, and make them skip this delayed one. if worldInfo:IsServer() then return end DisplayNumber(num) end) local function GetAndSendRandomNumber() -- This should only execute on the host if not worldInfo:IsServer() then return end -- Get a random number from 1 to 1000 local num = math.random(1, 1000) -- We are already aware of the number, let's display it immediately for us DisplayNumber(num) -- And now we will update the rest of the clients with the number we have picked. worldGlobals.DisplayNumberToClients(num) end

Or, in a more advanced example of Target Client RPC, where we'll update every joining player with a random number we picked at the beginning of the level:

-- Initialize num local num = 0 -- Function for setting the num local function SetNum(newNum) -- Set our num to the new provided num. Mmm, num num. num = newNum worldInfo:ShowMessageLocal(tostring(num)) end -- Server -> Client RPC -- Update all clients of new num RPC("UpdateNumAllClients", SendTo.Multicast, Delivery.Reliable, function(newNum) -- Host knows num, return if worldInfo:IsServer() then return end SetNum(newNum) end) -- Server -> Target Client RPC -- Updates specific client of new num RPC("UpdateNumTargetClient", SendTo.TargetClient, Delivery.Reliable, function(newNum) -- Host knows num, return if worldInfo:IsServer() then return end SetNum(newNum) end) -- Listen for new joining players and update them on the num ListenFor("PlayerJoined", function(pay) -- Only execute for the host if worldInfo:IsServer() then -- We should not send anything until the num has been set. if num <= 0 then return end -- Update the joining player of the new num worldGlobals.UpdateNumTargetClient(pay:GetPlayer(), num) end end) -- Wait a second and get random number as the host RunAsync(function() if not worldInfo:IsServer() then return end Wait(Seconds(1)) -- Set num locally and then update all clients SetNum(math.random(1, 1000)) worldGlobals.UpdateNumAllClients(num) end)
Microgame: Preperations
Now that we understand how to script in MicroWorks, we can do what we all came here for: microgames!

For this tutorial, we're gonna make a microgame called "Pop the Duck". In this microgame, players have to work together to inflate a rubber duck until it pops. If they succeed, everyone wins.

The host will keep track of the duck state and sync it to clients (where to spawn the duck, how many inflations it will require, etc etc). When clients inflate the duck, they will inform the server who will decide on the current size of the duck and pop it when it is big enough.

Let's return to StreamingAssets and delete the AutoRun scripts we've created. Instead, we're going to make a folder called "Microgames".

This is the folder where custom microgames are loaded from (StreamingAssets/Microgames). To create a microgame, we need to add a .mcg file to the folder.

Despite the extension, .mcg files are just .json files, and they contain information about the microgame we want to make. That information being:

* Title - The internal microgame name. This will be used for localization.
* Author - Name of the microgame author.
* Launch - Path to the lua file to execute when this microgame is enabled.
* Difficulty - Difficulty level of the microgame. 1 for Easy, 2 for Medium, 3 for Hard.
* MinPlayers - Amount of players required to run this microgame.
* I18n - Paths to localization files. Key is the locale code, and value is the locale path.

You can either generate this file with MicroKit (File -> New -> Microgame), or you can create it yourself:

{ "Microgames": [ { "Title": "PopTheDuck", "Author": "noam 2000", "Launch": "\\Microgames\\PopTheDuck\\PopTheDuck.lua", "Difficulty": 2, "MinPlayers": 1, "I18n": { "en": "\\Microgames\\PopTheDuck\\Locale\\en.json" } } ] }

As you can see, you can even define multiple microgames in one file. But for now, we'll keep it at just one.

Let's create the files we've specified.
First, the .lua script (Microgames/PopTheDuck/PopTheDuck.lua),
And then the .json file (Microgames/PopTheDuck/Locale/en.json).

Before we begin work on our code, we have to prepare all the assets for this microgame, starting with the localization.

Localization, too, is provided in .json format. You can create it manually, or in MicroKit (File -> New -> Locale). Full localization guide available here.

You can add any localization strings your microgame may require, but it must contain two important entries: the display name and the instruction name.

The display name is the name that will appear in menus and other UI elements, like the mutators or game details. The key of it in the localization must be "Microgame_NAME_BaseName", where "NAME" is the internal name you specified.

The instruction name is the name that will appear in the actual in-game UI, when you receive the instruction. The instruction name can have multiple localization entries for different variations, which you will also define in the lua script, but it must always have the "default" variation as, well, the default.

The key of it is "NAME_VARIATION", where "NAME" is the microgame name you will define in the lua script, and "VARIATION" is the name of the variation that you will also define in the lua script.

Here's the localization file I've created for Pop the Duck:

{ "values": { "Microgame_PopTheDuck_BaseName": "Pop the Duck", "PopTheDuck_default": "Pop the Duck", "Hint_Microgame_PopTheDuck": "Work together to <color=#FFE964>inflate and pop</color> the rubber duck!", "Usable_Duck": "Inflate" } }
Microgame: Assets
Before we head into the code, we have to prepare all the assets we will need for this microgame.

The first and most important thing that every microgame needs is the music. You will have to prepare 6 tracks in .ogg format, with each track corresponding to each speed level. (did you know that with mutators, you can turn up the speed to x6?)

The length of the microgame is determined by the music track. If your music track is 5.5 seconds long, the microgame will last 5.5 seconds. In MicroWorks, we speed up the track on each level by 11%.



* _100 for x1
* _111 for x2
* _122 for x3
* _133 for x4
* _144 for x5
* _155 for x6

After we've gotten our music ready, we can start preparing the assets specific to the microgame we're making. Let's prepare feedback sounds for when we inflate the duck and when the duck pops.

I created the "DuckInflate.ogg" and "DuckPop.ogg" sounds, and placed them inside "Microgames/PopTheDuck/Sounds".

If you're not particularly great with audio, you can grab the sounds from the finished results folder in the google drive link provided at the beginning of this guide.

Remember that sounds must be of type .ogg. MicroWorks currently does not support other sound types.

Now let's focus on the star of the show: The rubber duck 3D model. I got this cute rubber duck model from Ikki_3d on Sketchfab[sketchfab.com], and imported it into a 3D software like Blender, so I can adjust the position and scaling, and later export to .obj - the file format we will need.

A few more touches here and there, and Jason the Duck is ready to go!



Let's export him to .obj into a temporary folder, and launch MicroKit - so we can convert him into a model format that MicroWorks accepts (.nmd).

Inside MicroKit, head over to the menu bar at the top and navigate to File -> New -> Level. We'll be learning more about level creation in the next part, but for now, we are only interested in exporting Jason.

At the asset browser tab at the bottom, press "Load Asset" and navigate to your model.



Let's preview him inside the level by pressing "Add Entity" at the entity list on the left, and selecting "Model". In the inspector tab on the right, you will see the "Model" category and a "Model" parameter. Simply drag the loaded asset icon into that parameter, and the model will be assigned to the entity!



This will help us visualize the pivot or the scale of the model next to the player character. Unfortunately, MicroKit can't edit the source model - if you need to adjust the scaling, you will have to go back into Blender or your 3D editor of choice, adjust the scaling, and re-import the model.

Fortunately, you can use the transform properties to scale the model in the editor to see how much you'll need to scale it in the 3D editor! But don't be confused - editing the transform values will not reflect on the exported model. They are only reflected on world entities.



Now the last part is just editing the materials - select your imported asset in the asset browser, and head over to the inspector on the right again. You can set whether this model should have collision enabled (for our case, we want to keep it on), and then press on "Edit Materials".

A new window will open with all the materials of your model. From here, you can upload textures and mess with parameters until you're satisfied with your model's look.



Jason looks great! Let's export him.

Go back once more to the asset browser, and this time right click on your imported asset. Click on "Export Asset", and select the folder to export to. In my case, I'll already export it directly to the microgame folder, inside:

StreamingAssets/Microgames/PopTheDuck/Models/Duck

This will generate 3 files:

* .nmd - The model descriptor. This is the file you pass when you want to load a model in code.
* .nmf - The mesh information.
* .nmt - The material information.

In addition, a "Textures" folder will also generate with all your model's textures. Make sure that folder is included in the same place as the .nmd file, and that if you decide to rename it for some reason, you reflect the change in the .nmd file.

Microgame: Script Definitions
With all assets at hand, we can proceed to the microgame code. Let's open up the .lua file we've created earlier and understand how to register a microgame.

To register a microgame, all you essentially have to do is call two functions from the Global scope:

* CreateMicrogameVariation
* CreateMicrogame

We will write the variations first, and create the microgame at the very end.

Variations can be multiple, and are basically different spins on the same microgame. For example, "Jump X Times" and "Crouch X Times" are variations of the same microgame.

Every time you call CreateMicrogameVariation, you're going to want to save the return value into a local variable. This is because when we call CreateMicrogame, we will need to pass a table that includes all the variations we've created.

Likewise, it might also be a good idea to save the return value from CreateMicrogame, as that will return LuaMicrogameWrapper[agiriko.digital], and you could potentially make use of the functions provided within it.

CreateMicrogameVariation takes 5 parameters:

* name - The name of the variation. This is used in the localization file you provided (for example, if I name my variation "Super", the localization key for it would be "PopTheDuck_Super").

If this is the default variation, and there has to be a default variation, you must name it "default", with a non-capital d.

* onBeginMicrogame - A function that will execute once when this variation starts.
* onMicrogameTick - A function that will execute on every frame that this variation is running.
* onPostMicrogame - A function that will execute once when this variation ends.

These 3 functions are always called on everyone (server and clients).

* rarity - (Optional) An integer number that determines how rare this variation is. The higher the value is, the more likely it is to appear. The default value is 8.

So, creating a microgame variation would look something like:

local defaultVariation = CreateMicrogameVariation( -- Name, used for I18n -- The default variation should always be named default "default", -- OnBeginMicrogame -- Doesn't take any parameters -- Called before the microgame happens function() print("Microgame has begun!") end, -- OnMicrogameTick -- Takes in a floating-point value that represents -- how much time of the microgame is left. -- Called every frame during the microgame. function(fTimeLeft) print("Microgame has ticked! There are " .. tostring(fTimeLeft) .. " seconds left!") end, -- OnPostMicrogame -- Doesn't take any parameters -- Called after the microgame function() print("Microgame has ended!") end )

After you create all variations, you can create the microgame itself, with CreateMicrogame. It takes 4 parameters:

* name - The name of the microgame. This will be used in localization for the instruction display.
* musicArray - A table that holds the music for this microgame. It has to have 6 "AudioResource" elements.
* variationsArray - A table that holds all the variations for this microgame. Has to have at least 1 variation called "default".
* params - Extra data about this microgame, provided in a table. The elements are "Difficulty", "Rarity", "MinPlayers", "Type", "PlayEffect". Any of these elements can be left empty, as the function will auto fill them.

"Difficulty" is the overall difficulty of this microgame. Its values are: "Difficulty.Easy", "Difficulty.Medium", "Difficulty.Hard". Defaults to Easy.

"Rarity" is the rarity of the microgame. Same as with CreateMicrogameVariation.

"MinPlayers" is the minimum amount of players required for this microgame. Defaults to 1.

"Type" is the internal type of this microgame. This isn't used much, other than for a special round. Values are "MicrogameType.WinAtEnd", "MicrogameType.WinBeforeEnd", "MicrogameType.Versus", "MicrogameType.Coop". Defaults to WinBeforeEnd.

"PlayEffect" toggles whether we play the win effect right as the player is declared the winner by the coordinator. Defaults to false.

* If you're asking yourself "Didn't I already set these values in the microgame descriptor earlier?", the microgame descriptor's data is used for displaying in menus like the mutators or game details. The data you provide here will be used by the microgame coordinator. Nevertheless, the data between the two should match.

So, usage of this function would look like:

-- Creates a microgame and automatically appends it to the coordinator -- "microgame" variable should be declared at start, and set here, at the end. microgame = CreateMicrogame( -- Name of the microgame, used for I18n "MyMicrogame", -- List of the music microgameMusic, -- All the variations { defaultVariation }, -- Extra params { Difficulty = Difficulty.Easy, Rarity = 8, MinPlayers = 1, Type = MicrogameType.WinBeforeEnd, PlayEffect = true, } )
Microgame: Logic Pt. 1
Now we understand how microgames work like, and with an understanding of scripting, we can focus on the logic.

Let's start by creating all the required variables and parameters for this microgame:

-- The path to the root of our assets local contentRoot = "\\Microgames\\PopTheDuck" -- Access to coordinator local coordinator = worldInfo:GetCoordinator() -- Access to this microgame (we will set this at the end) local microgame -- A table containing the microgame music we've created local microgameMusic = { LoadResource(contentRoot .. "\\Music\\PopTheDuck_100.ogg", ResourceType.Audio), LoadResource(contentRoot .. "\\Music\\PopTheDuck_111.ogg", ResourceType.Audio), LoadResource(contentRoot .. "\\Music\\PopTheDuck_122.ogg", ResourceType.Audio), LoadResource(contentRoot .. "\\Music\\PopTheDuck_133.ogg", ResourceType.Audio), LoadResource(contentRoot .. "\\Music\\PopTheDuck_144.ogg", ResourceType.Audio), LoadResource(contentRoot .. "\\Music\\PopTheDuck_155.ogg", ResourceType.Audio), } -- Our assets local duckModelResource = LoadResource(contentRoot .. "\\Models\\Duck\\Duck.nmd", ResourceType.Model) local inflateSoundResource = LoadResource(contentRoot .. "\\Sounds\\DuckInflate.ogg", ResourceType.Audio) local popSoundResource = LoadResource(contentRoot .. "\\Sounds\\DuckPop.ogg", ResourceType.Audio) -- Our entities -- We will store the created entities into these local duckModel local inflateAudioSource local popAudioSource -- Settings -- The min and max scale of the duck local duckSizeMin = 1 local duckSizeMax = 10 -- The min and max values of the inflation sound's pitch local inflatePitchMin = 0.25 local inflatePitchMax = 2.5 -- The min and max amount of base inflations required local inflationMin = 5 local inflationMax = 25 -- The min and max amount of bonus inflations required per player local inflationBonusMin = 2 local inflationBonusMax = 6 -- Duck state values local currentDuckValue = 0 local requiredDuckValue = 0

Next, we're going to create 3 functions that we will later pass to OnCreateMicrogameVariation.

local function OnBeginMicrogame() end local function OnMicrogameTick(fTimeLeft) end local function OnPostMicrogame() end

Let's start filling it up! The bulk of our operations will happen inside OnBeginMicrogame(), so let's start there.

A worthy little side note and reminder - remember that lua is an interpreted language, meaning it is read from top to bottom, and trying to call a function before it was declared will result in an error. Be diligent and aware of how you structure and where you position your functions.

-- Good! local function MyFunction() print("Hello!") end MyFunction()

-- Bad! MyFunction() local function MyFunction() print("Hello!") end

Right, where were we? Oh yeah, ducks. First order of business is to spawn the duck inside of OnBeginMicrogame():

--- Spawn the duck duckModel = duckModelResource:Spawn(coordinator:GetRandomPointInNexusSynced(0.75), QuaternionIdentity()) -- Get the duck's transform local duckTransform = duckModel:GetTransform() -- Rotate duck towards Nexus center duckTransform:LookAt(coordinator:GetNexusCenter())

Notice how I used the "GetRandomPointInNexusSynced" (that's a mouthful) function for the position. This function will return a random point in the nexus, but the beautiful thing about it is that everyone who calls it will get the same random position, without any syncing required. This is thanks to our Pseudo Random Number Generator (PRNG), that is re-seeded every time a new microgame starts, and that seed is automatically synced, meaning the random values returned can be pre-determined. Or, well, for your purposes, it's just an easy way to get a random value without having to do networking.

Anyway, on to the audio sources:

-- Spawn the audio sources inflateAudioSource = worldInfo:CreateEntity(Entity.AudioSource) inflateAudioSource:SetAudioClip(inflateSoundResource) inflateAudioSource:SetPitch(inflatePitchMin) inflateAudioSource:SetMinDistance(40) inflateAudioSource:SetMaxDistance(200) inflateAudioSource:GetTransform():SetPosition(duckTransform:GetPosition()) popAudioSource = worldInfo:CreateEntity(Entity.AudioSource) popAudioSource:SetAudioClip(popSoundResource) popAudioSource:SetMinDistance(100) popAudioSource:SetMaxDistance(300) popAudioSource:GetTransform():SetPosition(duckTransform:GetPosition())

And now to set the duck's stats:

-- Set duck default stats duckTransform:SetScale(Vector3One() * duckSizeMin) currentDuckValue = 0 local playerCount = worldInfo:GetActivePlayersCount() local initialRequired = coordinator:GetRandomFloatSynced(inflationMin, inflationMax) local bonusRequired = coordinator:GetRandomFloatSynced(inflationBonusMin, inflationBonusMax) * playerCount requiredDuckValue = initialRequired + bonusRequired

Finally, we will make the duck usable:

-- Make the duck usable duckModel:MakeUsable(GetI18n("Usable_Duck"), false) -- Listen to usage from this duck ListenFor("EntityUsed", function() OnDuckUsed() end, duckModel)

"GetI18n" is a function that fetches a value from the localization file, and you just have to supply the key to it. Remember how "Usable_Duck" was defined in the localization file we provided in the microgame descriptor?

We also add a listener to the "EntityUsed" event, and you'll notice we also provide "duckModel" after the function declaration. This means we are only listening to the event that has been fired from this object, rather than listening to *any* EntityUsed event that fires.

"OnDuckUsed()" is a function we have yet to declare and will do so soon. But let's just wrap up our beginning logic with:

-- Finally, add a target indicator and a hint message coordinator:AddTargetIndicator(duckTransform, IndicatorType.Objective) coordinator:ShowHintWithGroup("PopTheDuck", "Hint_Microgame_PopTheDuck")

As you can guess, "AddTargetIndicator" will add an on-screen indicator for the duck. Available IndicatorTypes are:

* IndicatorType.Objective
* IndicatorType.Warning

Indicators are automatically cleaned at the end of a microgame, so you don't need to worry about removing them. The same, however, cannot be said about hints.

"ShowHintWithGroup" will display a hint with the provided localization key. The "group" part is the name of a group that the hint belongs to. Hints will stop displaying if their group count has been marked as "seen" for more than the provided value in the function (by default 3).

When the microgame ends, it is your responsibility to hide the hint with "HideHintWithGroup", where you also have the ability to decide whether to mark this hint group as "seen". Usually in MicroWorks, we mark it as seen if the player has won the microgame, so that after winning 3 times, we stop displaying the hint.
Microgame: Logic Pt. 2
Let's create that OnDuckUsed() function. When the duck is used, we want to do a very simple thing:

1. Check if we are the host. If we are, we will register an inflation right away.
2. If we are not the host, we will send an RPC informing the server that we triggered an inflation.

-- When the duck is used, we'll check if we're the server or a client -- If we're the server, we'll call RegisterInflation right away -- Otherwise, we'll call an RPC to inform the server to register an inflation local function OnDuckUsed() if worldInfo:IsServer() then HostRegisterInflation() else worldGlobals.RegisterInflationRPC() end end

The Client -> Server RPC, will actually just call back to HostRegisterInflation().

-- Client -> Server RPC -- Tells server to register an inflation. RPC("RegisterInflationRPC", SendTo.Server, Delivery.Reliable, function() HostRegisterInflation() end)

Inside HostRegisterInflation(), we're going to want increment the current duck value, and then check if we've passed the required amount. If we have the passed the amount, we will pop the duck and mark everyone as winners. Otherwise, we will update the state of the duck.

-- Function to run on the host when an inflation is registered local function HostRegisterInflation() -- Return if not the host if not worldInfo:IsServer() then return end -- Return if the duck doesn't exist if not ObjectExists(duckModel) then return end -- Increment duck value currentDuckValue = currentDuckValue + 1 -- If the current duck value is above the requirement, we won -- Pop the duck and mark all players as winners -- Then return from the function, don't execute further if currentDuckValue >= requiredDuckValue then local allPlayers = worldInfo:GetAllActivePlayers() for i, v in ipairs(allPlayers) do coordinator:MarkWinner(v:GetNetworkID(), false) end PopDuck() worldGlobals.PopDuckRPC() return end -- Else, let's update the state of the duck local ratio = currentDuckValue / requiredDuckValue SetDuckState(ratio) worldGlobals.SetDuckStateRPC(ratio) end

Now we need to define the functions for "SetDuckState" and "PopDuck".

In PopDuck, we will shake the player's screen the closer they are to the duck, before deleting the duck. We will also play the duck pop sound.

-- Pops the duck local function PopDuck() -- Destroy the duck model and shake the screen if ObjectExists(duckModel) then worldInfo:GetLocalPlayer():ShakeCameraAtPosition( duckModel:GetTransform():GetPosition(), 150, 50, Vector3(2.5, 2.5, 2.5), Vector3(15, 15, 15), 1 ) duckModel:Destroy() duckModel = nil end -- Play the pop sound if ObjectExists(popAudioSource) then popAudioSource:Play() end end

And the RPC that the host will call for clients:

-- Server -> Client RPC -- Tells clients to pop the duck. RPC("PopDuckRPC", SendTo.Multicast, Delivery.Reliable, function() if worldInfo:IsServer() then return end PopDuck() end)

Now, for duck state, we rely on the ratio to interpolate how big the duck should be between the min and max values we set up. For this, we're going to use "lerp" from lua's math library. (Well, technically, lua's math library doesn't contain a lerp definition, but it's something we added to our lua <3)

-- Sets the duck state by provided ratio local function SetDuckState(ratio) -- Set the scale if ObjectExists(duckModel) then duckModel:GetTransform():SetScale(Vector3One() * math.lerp(duckSizeMin, duckSizeMax, ratio)) end if ObjectExists(inflateAudioSource) then inflateAudioSource:SetPitch(math.lerp(inflatePitchMin, inflatePitchMax, ratio)) end inflateAudioSource:Play() end

And the RPC for syncing it to clients:

-- Server -> Client RPC -- Tells clients to update the state of the duck. RPC("SetDuckStateRPC", SendTo.Multicast, Delivery.Reliable, function(ratio) if worldInfo:IsServer() then return end SetDuckState(ratio) end)

That's covers the beginning functions! Thankfully, the bulk of operations happened here, and the other two should be a cakewalk from here.
Microgame: Logic Pt. 3
After setting up OnBeginMicrogame(), we have to set up OnMicrogameTick() and OnPostMicrogame().

We don't need to run any code inside OnMicrogameTick(), so we can leave it empty. Good work, team.

in OnPostMicrogame(), we have to clean up our mess. The entities we create don't get removed by themselves, and it is our responsibility to clean them up.

We will also hide the hint we displayed at the beginning, and reset the duck stats back to 0, for the next time the microgame runs.

-- At the end of the microgame, we have to clean up the entities we've created. It is our responsibility! -- When destroying entities, make sure you destroy the gameobject they are a part of -- Otherwise, you will only destroy the component, and that can have unexpected results local function OnPostMicrogame() if ObjectExists(duckModel) then duckModel:Destroy() duckModel = nil end if ObjectExists(inflateAudioSource) then inflateAudioSource:GetGameObject():Destroy() inflateAudioSource = nil end if ObjectExists(popAudioSource) then popAudioSource:GetGameObject():Destroy() popAudioSource = nil end -- Although target indicators automatically clean themselves when a microgame ends, -- Hints do not. We have to manually hide them. coordinator:HideHintWithGroup("PopTheDuck", coordinator:IsMarkedAWinner(worldInfo:GetLocalPlayer():GetNetworkID())) -- Reset the duck stats currentDuckValue = 0 requiredDuckValue = 0 end

And that's it! It took a few functions but our logic is now complete, and that's pretty much how you code for microgames.

With all our logic code ready, all that's left is to register the microgame, just like we learned earlier.

First, we create a variation. Remember to save the return value into a variable!

-- Create a variation local defaultVariation = CreateMicrogameVariation( -- Name, used for I18n "default", -- OnBeginMicrogame OnBeginMicrogame, -- OnMicrogameTick OnMicrogameTick, -- OnPostMicrogame OnPostMicrogame )

Then, at the end of it all, we create the microgame:

-- Creates a microgame and automatically appends it to the coordinator microgame = CreateMicrogame( -- Name of the microgame, used for I18n "PopTheDuck", -- List of the music microgameMusic, -- All the variations { defaultVariation }, -- Extra params { Difficulty = Difficulty.Medium, Type = MicrogameType.Coop, Rarity = 8, MinPlayers = 1, PlayEffect = true, } )

Congratulations! Your microgame is now ready to be played.
Microgame: Testing & Debugging
In order to play your newly created microgame, you will launch MicroWorks and create a new server with mutators enabled. In the mutators menu, you will tick "Enable Scripting" on, and if done correctly, you should see your microgame appear at the very top category of "Custom Games".

Unless you're some kind of scripting god, most chances are you'll never get it right the first time, and you better get ready for a bunch of testing and debugging. So here's a few tips:

1. Enable Developer Console. If your microgame is broken, the developer console will point out the issue and the line where the issue occurred.

You can enable the developer console from Settings -> Preferences -> Advanced -> Enable Developer Console, and you invoke it with the tilde key (~). It is strongly recommended to look out for any issues the console might tell you about, specifically after the "Parsing MyScript.lua..." line.

2. Create an empty mutators config. Disabling every single microgame every time so you can specifically test yours is tedious, so make sure to save that configuration so you can restore it next time.

3. You don't have to restart your game. Scripts are reloaded when a level reloads, so if you need to make adjustments to your script, quitting to the main menu and then hosting a new game will work just fine.

Adding new files, however, will require a game restart.

4. Private your server when testing. You don't want people joining when you're trying to test stuff, right?

5. Ask for help. If you're stuck, the community is here to your aid.

Steam Forums
Discord[discord.gg] (faster response)
Microgame: Packaging & Uploading
You've worked hard, you've tested your microgame, and it's finally ready to see the light of day. It's time to upload it.

In order to upload to the workshop, you will have to package your contents into a .alf file. Launch MicroKit once more, and at the top bar, navigate to Tools -> ALF Browser.

Remember how we said "StreamingAssets" is the root folder? That will be our reference point to the root folder in the alf browser. We're going to package all the required files for the microgame.

Thankfully, because we were nice and tidy, we've kept all our content inside of the "Microgames" folder, and so that's the only folder we'll need to import.

When we select a directory in the ALF browser, files or folders we import will import into that selected directory. Therefore, let's select the root directory "\", and click on "Add Folder". (PROTIP: Right click on a directory to select it without folding/unfolding it)

We'll navigate to our StreamingAssets folder, and select "Microgames". This will import the folder and all of its contents.

If done correctly, your ALF browser should look like so:



Now all that's left is to hit "Save Package", and our .alf should be generated. If you want to double check that you've packaged it correctly, you can create a new folder in StreamingAssets called "Mods", and put your .alf file in there. If packaged correctly, the game will read and load it.

Remember to clean up the local source files in your StreamingAssets folder before starting work on a new mod!

Now, for the uploading part: in MicroKit again, we will go to Tools -> Workshop Manager, where you'll be prompted to fill in a few details about your item:

* Item - Allows you to select a previously uploaded item for updating. Keep at "New Item" to upload a new workshop item.

* Tags - Select all that apply to your workshop item:

- Microgame
- Boss Stage
- Gamemode
- Has Custom Level

* Visibility - The privacy status of your mod.

- Public
- Friends Only
- Private
- Hidden (public and shareable, but won't show up in the workshop)

* Title - The title of your item.
* Path - Path to the .alf file to upload. Click the triple dots ("...") to open a file browser to navigate with.
* Description - A description of your item.
* Thumbnail - A thumbnail to your mod.



When all looks right to you, hit the publish button and sit back in accomplishment. Your custom microgame is now live!
Recap & Endword
So, to summarize, in order to make a microgame, we:

  1. Add a microgame descriptor (.mcg) into StreamingAssets/Microgames

  2. We specify the microgame data and the path to the .lua script

  3. In the script, we call CreateMicrogameVariation() with the name of the variation and 3 functions for when the microgame begins, for when the microgame ticks, and for when the microgame ends

  4. At the end, we call CreateMicrogame() with the name of the microgame, the music table, the variation table, and additional parameters about the microgame

Congratulations on making it to the end... of Part 1! In this part, we learned how to script and how to create custom microgames.

In the next part, we will learn how to create custom levels and custom boss stages.

Modding Pt. 2: Custom Boss Stages

Once again, remember that the sources for the mods we create here are available in this Google Drive folder.[drive.google.com].

For any questions, please reach out to us: https://agiriko.digital/contact/

See you in the next part!