MicroWorks

MicroWorks

Not enough ratings
Modding Pt.2: Custom Boss Stages
By noam 2000
MicroWorks' final 1.11 update introduces extended modding capabilities, including custom microgames, boss stages, and scenes.

In part 2 of this tutorial series, we'll be learning how to create a custom level and how to script a custom boss stage.

1. Custom Microgames
2. Custom Boss Stages >> YOU ARE HERE <<
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]
   
Award
Favorite
Favorited
Unfavorite
Before We Begin
This guide is a direct continuation to Modding Pt.1: Custom Microgames. It is strongly recommended that you read it first, as we will not be repeating what we learned in the previous part.

In this part, we will learn how to make a custom boss stage. Every custom boss stage requires a custom level, so we will also learn how to make a custom level with MicroKit.

As an example, the boss stage we'll be making will be called "Epic Duck Fight". In this boss stage, players will battle a giant duck together that will hurdle projectiles at them. Players will earn points for hitting the duck, and lose points for getting hit by the duck. When the duck's HP bar is at 0, the boss will end early.

The sources and assets for this boss stage can be found in this Google Drive folder.[drive.google.com]

Let's get to it!
MicroKit: Introduction & Preperations
MicroKit is the official modding toolkit for MicroWorks. Its main function, aside from providing a bunch of tools and helpers, is making custom levels.

Now, temper your expectations. This editor does not provide functionality for brushwork, or terrain creation, or any of that fancy stuff, so if you were expecting to build up your level design portfolio here, sorry to disappoint!

What MicroKit's level editor essentially is, is an entity editor. You construct levels by creating, placing, and editing entities, for MicroWorks to load later. One of these entities is of course a Model entity, which you can assign an imported model to. Your level at its core will be a collection of models you imported and placed around.

This is why you must prepare all your level assets in advance, in external 3D editors such as Blender.[www.blender.org] MicroKit accepts .obj and .nmd files as models.

For this example, I'll create a very simple hexagonal arena. If you don't want to create your own, you can grab the final .nmd for this level from the Google Drive folder, and import that instead (just make sure you also get the associated .nmf, .nmt, and textures!)



There are actually 2 models in here - the platforms themselves, and the grass scattered on them. The platforms will have collision enabled, while the grass will have collision disabled.
MicroKit: Getting Started
Let's launch MicroKit and head over to File on the top menu bar, and click New -> Level to create a new level.

The level editing interface will open. Let's understand what each window does:



Scene View

The scene view displays your level. With your cursor hovered on the window, hold Right Mouse Button to start moving around.

* W (or Arrow Up) to move forward
* A (or Arrow Left) to move left
* S (or Arrow Down) to move backwards
* D (or Arrow Right) to move right
* Space to move up
* Left Ctrl to move down

Let go of Right Mouse Button to stop moving and regain control of your cursor.

With your cursor hovered above an entity in the scene view, Left Mouse Button to select it. Selecting an empty space will remove your selection.

When an entity is selected, gizmos will appear at the center of your selection, that will allow you to move, rotate or scale the selected entity by holding and dragging each handle. With your cursor on the window, press any of the following keys on your keyboard to change gizmos:

* T for Position
* R for Rotation
* S for Scaling

Ctrl + D will duplicate your currently selected object.

Asset Browser

The asset browser is your library of imported assets. You can assign them to entities that take them as input, like a model that requires a model asset, or an audio source that requires a sound asset.

Simply click on "Load Asset", and navigate to the asset you want to import.

Entity List

The entity list displays all the current entities in the level. Select them with Left Mouse Button, or remove them with Right Mouse Button (or Delete key on your keyboard while they are selected).

There are some default entities that you can't remove, as they are integral to the level. Those entities are: Directional Light, Post Processing, and Environment Volume.

We will detail all the entities in full in a bit, but for now, let's move on to the other windows:

Entity Properties

When you select an entity, their properties will appear on this window. From here you can adjust their properties.

Usually entities will have a "Transform" category by default, that controls their position, rotation and scale in the world.

They will also have a "Lua Properties" category, that will allow you to set whether this entity is exposed to lua. When an entity is exposed, you can get a reference to it from script, using CustomLevelManager[agiriko.digital]:GetExposedEntityByName(name), where "name" is the name of the entity in the editor. Names must be unique for exposed entities, or otherwise they will override each other. (i.e. if I have two exposed entities called "MyEntity", trying to get "MyEntity" will only return the last one.)

After that, you will have a category for the entity's specific properties.

To edit a field, you can either double click on it to set a new value, or you can hold and drag to adjust the value.

Level Properties

There are actually two tabs in this window: Level Properties and View Properties.

Level Properties contains properties related to your level.

* Level Scale will scale all model entities in the level, but not other entities. Usually you don't want to adjust the level scale after you've started filling in other entities.

* Kill Height determines the height level at which the game will kill the player if they are below it. This is useful for if you make a level where you can fall out of bounds, and naturally, don't want the player to keep falling forever.

* Spawn Void Plane determines whether to spawn a collision plane at the kill height level. You usually don't need to worry over this if your level is properly sealed, or if you don't use any weapons with projectiles that don't die out (like the Dodgeball). The void plane exists because of an issue where if a physics object falls indefinitely and reaches a certain level, it will break the physics system. For such circumstance, the void plane can act as a fishnet for these projectiles.

* Respawn Time determines how long does it take a player to respawn after dying. Set to 0 to disable respawns.

* Background Music - drag an audio asset into this parameter to set music for this level that will play automatically. Keep this empty for boss stages, as they have their own music definition.

View Properties don't actually affect your level, but are rather editor-only settings to adjust your scene camera. They are mostly self-explanatory.

Entities

Let's briefly touch up on each entity:

* Directional Light: Controls the overall lighting of the level, like a sunlight. You can adjust the strength, color and rotation (for shadows).

* Post Processing: A container for post processing effects. There are 4 different effects available:

- Fog: It's fog. You can set it to be volumetric, which will make lights scatter inside the fog to create a volumetrics effect. The volumetrics are tinted with the "Albedo" color property.

- Color Adjustments: Some basic image post processing, like saturation, contrast, or color filter.

- Bloom: Controls the strength and tint of bloom in the level.

- Sky: Controls the sky of the level. Drag a cubemap asset to set the skybox. You can use "Tools -> Skybox Creator" to create a cubemap asset.

* Environment Volume: In the rendering pipeline, global illumination is affected by the sky. This means, for example, if your skybox texture is colored deep blue, your environment's shading will also be deep blue.

In MicroWorks, we've decoupled it from the actual rendered sky, into an internal but invisible "sky". This allows us better control over how our environment looks like, regardless of what the sky looks like.

This is what the Environment Volume is responsible for. It is essentially another sky that only contributes to the global illumination.



* Model: A 3D model. Drag a model asset to the model parameter.
* Audio Source: An audio player. Drag a sound asset as the audio file.
* Light: An entity that emits light. Each dynamic light entity has a performance cost, so be smart with how you use them, and try not to go over 10 at most.
* Reflection Probe: An entity that captures the scene around it and affects shiny surfaces that reflect light. Remember to hit "Bake" to see the result. Maximum of 8 reflection probes allowed.
* Spawn Point: A point at which players will be spawned.
* Sample Kai: An accurately sized Kai, to test level scale. They will not be serialized to your level.
* Trigger: A trigger that sends the TriggerEnterEvent[agiriko.digital] event when a player enters it, and TriggerLeftEvent[agiriko.digital] when a player leaves it. Expose it to lua and add a ListenFor to attach code to it.
* Killer: A trigger that will kill players who touch it.
* Marker: An entity for marking specific locations. Expose it to lua to get the position and rotation of it.
* Bouncer: Bounces players to the provided target point. Requires a "Target" entity to be assigned to it.
* Teleporter: Teleports players to the provided target point. Requires a "Target" entity to be assigned to it.
* Target: A target point. Assign these to bouncers or teleporters.
* Weapon Spawner: Spawns selected weapon at set intervals. Set cooldown to 0 to make it not respawn.
MicroKit: Making a Level
MicroKit's pretty cool! And now that we understand how to work it, it's our free domain to make any level we want. So let's start making that boss stage level.

I've imported the models we've created earlier, and edited their materials inside MicroKit (in case you forgot, you select the asset in the asset browser, and from the entity properties, you click on "Edit Materials").

I've also made sure to tick off "Collision Enabled" on the grass model.

Create two model entities from the entity list, assign the stage and the grass models, and there's our geometry!



Now let's pretty the level up a bit. I've created a custom skybox using the skybox creator in the top menu bar's "Tools" section.

Skyboxes are constructed in this order, so make sure everything aligns when supplying the textures:



After creating the skybox, supply it to the Sky Post Processing to set it.



Now we mess with all the other post processing effects and the lighting, until we get a result we like.



Looking good! All that's left now is to scatter some spawn points, and we'll also add a couple of weapon spawners with a laser pistol that players can pick up and fight the duck with.



Badabing badaboom, our custom level is ready! That wasn't too bad, was it?
MicroKit: Saving a Level
Saving your level is as simple as navigating to File -> Save (or Save As, if you've yet to save your level). You can also use the Ctrl + S shortcut.

When you save your level, it will generate a .lua file and an assets folder. The lua file contains all the information about your level that it will pass to MicroWorks, and the assets folder is of course all the assets you've imported and used.

For it to register as a custom level in MicroWorks, we must place this lua file (and the assets folder) into a folder inside StreamingAssets/CustomMaps that matches the same name as your lua file. For example:

StreamingAssets/CustomMaps/EpicDuckFight/EpicDuckFight.lua

That's it! Your custom level is ready. You can load it by typing "load NAME" into the developer console, where "NAME" is the name of your lua file. For example, "load EpicDuckFight". But make sure you're in an active level first before calling it, like the Sandbox.

You can also create a scene (Extras -> Scenes in the main menu) that would load into your custom level, but that is something we will learn in the next part.
Boss Stage: Preperations
Preparing a boss stage is a lot similar to preparing a microgame. Let's navigate to StreamingAssets and create a "BossStages" folder.

Inside this folder, we will create a ".bos" file. Just like .mcg, .bos is a json file in disguise that informs the game that this is a boss stage descriptor.

Unlike the .mcg though, we can't define multiple boss stages in one file, and we have a lot more settings to fill out:

{ "BossName": "EpicDuckFight", "Author": "noam 2000", "LevelName": "EpicDuckFight", "Script": "\\BossStages\\EpicDuckFight\\EpicDuckFight.lua", "Theme": "\\BossStages\\EpicDuckFight\\Music\\EpicDuckFight.ogg", "StartAfterDelay": 1, "MinPlayers": 1, "Length": 90, "Icon": "\\BossStages\\EpicDuckFight\\Textures\\Icon.png", "Background": "\\BossStages\\EpicDuckFight\\Textures\\Background.png", "I18n": { "en": "\\BossStages\\EpicDuckFight\\Locale\\en.json" } }
PROTIP: This descriptor can also be generated from MicroKit, by navigating to File -> New -> Boss Stage.

* BossName: The internal name of the boss. This will be used for localization.
* Author: The name of the author.

* LevelName: The name of the custom level.
* Script: The path to the script file to execute.
* Theme: The path to the music file.
* StartAfterDelay: Determines how much time to wait after loading the level to start the boss.
* MinPlayers: How many players are required to start this boss?
* Length: How long should this boss stage last? (in seconds)
* Icon: The path to the boss icon texture.
* Background: The path to the boss loading screen background texture.

* I18n: Table of paths to localization files.

Oof, quite a lot more things to prepare this time around! Let's start with the localization file.

There are four mandatory entries you localization file must have: The boss name, and the boss description, one for each gamemode (LPS inherits from Survival description, and Boss Rush inherits from Scorematch). The keys are:

* BossStage_NAME
* BossStage_NAME_Description_Pointmatch
* BossStage_NAME_Description_Scorematch
* BossStage_NAME_Description_Survival

Where "NAME" is the name you provided in the .bos file. So, for example:

{ "values": { "BossStage_EpicDuckFight": "Epic Duck Fight", "BossStage_EpicDuckFight_Description_Pointmatch": "Land as many hits as you can on the duck, and avoid dying!\n\n<color=#FFDE62>* 1 points for every 25 hits\n* 2 points lost at death\n* Bonus point for delivering the final blow", "BossStage_EpicDuckFight_Description_Scorematch": "Land as many hits as you can on the duck, and avoid dying!\n\n<color=#FFDE62>* 30 points for every hit\n* 1,000 points lost at death\n* 500 points bonus for delivering the final blow", "BossStage_EpicDuckFight_Description_Survival": "Land as many hits as you can on the duck, and avoid dying!\n\n<color=#FFDE62>* 1 life for every 50 hits\n* 1 life lost at death" } }
PROTIP: An easier way of writing with rich text tags is with our online rich text formatter: https://nanoshinono.me/richtext.html

Full list of rich text tags is available here: http://digitalnativestudios.com/textmeshpro/docs/rich-text/

Now let's prepare the textures.

For the icon, a 1:1 aspect ratio texture is required, preferably of 256x256 or 512x512 resolution.

For the background, the aspect ratio gets a little funky - 2.874:1. This is because it has to accommodate for widescreen monitors, but keep in mind that most of the time, the sides won't be visible. Recommended resolutions are 1600x556, 2048x713 or 4096x1426 if you REALLY need the extra resolution. Boss stage backgrounds are usually blurred out in MicroWorks, so it's a good idea to blur the image beforehand.



Last but not least, the music. Prepare whichever track you like in .ogg format, and set it at the specified path. Once again, special thanks to Musearys and Tyra who helped me prepare a track for this boss stage. You can grab it from the google drive folder.

Also remember to prepare the .lua script. We'll start working on it as soon as we have all the assets at hand.
Boss Stage: Assets
Now we need to prepare all the assets for the boss stage. We're going to start with sounds, because we'll be baking some of them inside the level.

We're going to need a sound for every one of these occasions:

* Duck Rising (When the duck spawns)
* Duck Hit (When the duck is hit. I'll make 5 different variations of this sound, and switch them at random every time the duck is hit.)
* Duck Killed (When the duck has been defeated)
* Cannon Fire (When the duck fires)
* Projectile Explode (When the duck projectile explodes)
* Hit Register (When our own projectile lands on the duck, just a nice extra feedback kind of thing)



As usual, you can grab these sounds from the Google Drive folder.

Now to the models. There's two models we're going to need: The actual duck boss, and the projectile of the duck boss.

We are going to place the duck boss inside the level, and expose it to lua, so we can grab a reference to it from script. The duck projectiles are dynamically spawned however, so we will have to import them into MicroKit and export them, to later dynamically spawn them.

Let's launch MicroKit again and load our level (navigate to the .lua file we've generated earlier). I'll use the same duck model we've used in the previous guide for the microgame, but attach arm cannons to shoot projectiles from.



I'll name the model "DuckBoss" and make sure it is exposed to lua. Now just scale it and position it where you want it to be:



Looks great! But, actually, when the boss starts, I want the duck to rise up from below for that extra dramatic effect. So I'm going to note down the start and end position of the duck, so I can later tween it from code:

Start Position: 0, -45.849, 62.732
End Position: 0, 5.849, 62.732

Noted! Now one last thing - we're going to add a couple of audio sources in the level. Two for each cannon, and a main one for the duck itself. We will expose them to lua, so we can grab a reference to them and play them when necessary. We will also use the position of the cannon sounds to spawn projectiles.

Create the audio sources, place them in the relevant positions, give them an appropriate name and expose them to lua. For the cannon audio sources, since their sound won't change, we can already import the sound we created for them and set it in the entity to save us some time in the code.



Good to go! Before we save the level, let's just move the duck and the audio sources to the start position we noted earlier. When we load into the level, the duck will be at the bottom, and when the boss starts, the duck will rise up. We will parent the audio sources to the duck from code, to make sure they move together with him.

After saving the level, let's quickly make the duck projectiles, which will just be red ducks. After I'll import and adjust the materials, I'll set the collision to be disabled. We're going to add a player trigger to these ducks from code, and we don't require them to have collision for this (even more recommended to disable collision in this instance, so it doesn't interfere with the trigger).



The projectile is set up. We'll right click the asset and export it to:

"StreamingAssets/BossStages/EpicDuckFight/Models/DuckProjectile/DuckProjectile.nmd".

Our assets are ready, and our level is filled with the entities we need. Let's move on to the code!
Boss Stage: Script Definitions
Let's open up the .lua script we've specified in the boss descriptor, and start filling out the definitions.

Setting up a boss stage in the code is a lot simpler than setting up a microgame. We don't need to call multiple special functions, or provide a whole bunch of new parameters. It actually all boils down to:

return { OnBossBegin = function() end, OnBossTick = function() end, OnBossEnd = function() end }

Yup! We just return a table from the script, with the table containing the following 3 key-value definitions:

* OnBossBegin - A function that will execute once when the boss stage starts.
* OnBossTick - A function that will execute every frame while the boss stage is running.
* OnBossEnd - A function that will execute once when the boss stage ends.

Like the microgame counterpart, these functions will run on everyone, host and clients.

Let's create these 3 functions outside of the return scope to make the code a little easier to write in, and then pass these functions to the return.

-- Called when the boss starts local function OnBossBegin() end -- Called when the boss ticks local function OnBossTick() end -- Called when the boss ends local function OnBossEnd() end return { OnBossBegin = OnBossBegin, OnBossTick = OnBossTick, OnBossEnd = OnBossEnd, }

Great! We can begin working on logic.
Boss Stage: Logic Pt. 1
Strap in, it's gonna be a big one! We've got much new things to learn.

For the most part, we're gonna approach things pretty simplistically and and primitively. In fact, the only things we are going to sync from server to clients is when the duck shoots, and when the duck is killed.

Let's start with variables and parameters. Let's get access to classes we're going to be calling often, and also load the assets we've created:

-- The path to the root of our assets local contentRoot = "\\BossStages\\EpicDuckFight" -- Access to coordinator local coordinator = worldInfo:GetCoordinator() -- Access to custom level manager local customLevelManager = worldInfo:GetCustomLevelManager() -- Our assets local duckProjectile = LoadResource(contentRoot .. "\\Models\\DuckProjectile\\DuckProjectile.nmd", ResourceType.Model) local projectileExplodeSound = LoadResource(contentRoot .. "\\Sounds\\ProjectileExplode.ogg", ResourceType.Audio) local hitRegisterSound = LoadResource(contentRoot .. "\\Sounds\\HitRegister.ogg", ResourceType.Audio) local duckStartSound = LoadResource(contentRoot .. "\\Sounds\\DuckRising.ogg", ResourceType.Audio) local duckEndSound = LoadResource(contentRoot .. "\\Sounds\\DuckKilled.ogg", ResourceType.Audio) local duckHitSound = { LoadResource(contentRoot .. "\\Sounds\\DuckHit_01.ogg", ResourceType.Audio), LoadResource(contentRoot .. "\\Sounds\\DuckHit_02.ogg", ResourceType.Audio), LoadResource(contentRoot .. "\\Sounds\\DuckHit_03.ogg", ResourceType.Audio), LoadResource(contentRoot .. "\\Sounds\\DuckHit_04.ogg", ResourceType.Audio), LoadResource(contentRoot .. "\\Sounds\\DuckHit_05.ogg", ResourceType.Audio), } local bossIcon = LoadResource(contentRoot .. "\\Textures\\Icon.png", ResourceType.Texture)

Let's get the entities we previously exposed from MicroKit:

-- Our entities local duckBoss = customLevelManager:GetExposedEntityByName("DuckBoss") local duckAudioSource = customLevelManager:GetExposedEntityByName("DuckMainSound") local duckCannonSoundR = customLevelManager:GetExposedEntityByName("DuckCannonSoundR") local duckCannonSoundL = customLevelManager:GetExposedEntityByName("DuckCannonSoundL")

For this boss stage, we are going to learn how to make UI, by creating a health bar for the duck boss. Let's save a space for the image entity that will be the health bar, and the colors for good, mid and bad health:

-- UI entities local healthBar local goodHealthColor = Color(0.333, 0.921, 0.203, 1) local midHealthColor = Color(0.921, 0.752, 0.203, 1) local badHealthColor = Color(0.921, 0.203, 0.203, 1)

Internal data: Let's create a table called "playerData". This table will contain information about each player, and we will key it with the player's network ID. So for example, if we want to keep track of how many times a certain player shot the duck, we will save that info into "playerData[player:GetNetworkID()].hits".

In addition, let's create a PRNG that can give us random values within a provided range. Although we don't need to sync these values which is what PRNG could be good for, it is preseeded with a random value, and is generally more reliable than lua's math.random. It also contains other goodies that help with randomness, such as the "Chance()" function.

-- Internal -- A table that will hold information about players -- The key to it will be the player network ID (obtainable via :GetNetworkID()) local playerData = {} -- PRNG local PRNG = MakePRNG()

Finally, the stats:

-- Boss stats local pointsAward = 1 local pointsBonus = 1 local pointsRemove = 2 local hitsToPoint = 25 local scoreAward = 30 local scoreBonus = 500 local scoreRemove = 1000 local hitsToScore = 1 local lifeAward = 1 local lifeRemove = 1 local hitsToLife = 50 -- Duck stats local duckActive = false local duckHP = 7500 local duckBonusHPPerPlayer = 725 local duckCurrentHP -- Firing stats local delayBetweenShots = 0.375 local minShootCount = 2 local maxShootCount = 12 local minShootBreak = 1.5 local maxShootBreak = 4 local projectileTravelTime = 1

Let's create functions that will facilitate awarding or removing score, depending on the gamemode.

For score based games, you will want to call :AddScore() or :AddBossScore() on a player. For lives based games, you will want to call :AddLives() or :SubtractLives().

These functions must be called only from the host, as they are marked (Server).

When making boss stages, you have to make sure you cover every available gamemode when handling scorage. The coordinator[agiriko.digital] contains functions that help you check which gamemode is being played right now:

* IsPointmatch()
* IsScorematch()
* IsSurvival()
* IsLPS()
* IsBossRush()

Most of the time, however, you'll only need to check for Pointmatch and Survival, and all the rest can be kept as the same scorage rules as Scorematch. This is because in LPS there are no boss stages, and Boss Rush is just Scorematch.

So, here's how we'd do it for Epic Duck Fight:

--- Boss functions --- -- Function to award score for player local function AwardScore(player) -- Only the host is allowed to adjust score if not worldInfo:IsServer() then return end -- Award score according to gamemode if coordinator:IsPointmatch() then player:AddBossScore(pointsAward) elseif coordinator:IsSurvival() then player:AddLives(lifeAward) else player:AddBossScore(scoreAward) end end -- Function to award bonus score for player local function AwardBonusScore(player) -- Only the host is allowed to adjust score if not worldInfo:IsServer() then return end -- No point in adding bonus life at the game end. if coordinator:IsSurvival() then return end -- Award score according to gamemode if coordinator:IsPointmatch() then player:AddBossScore(pointsBonus) else player:AddBossScore(scoreBonus) end end -- Function to take away score from player local function RemoveScore(player) -- Only the host is allowed to adjust score if not worldInfo:IsServer() then return end -- Remove score according to gamemode if coordinator:IsPointmatch() then player:AddBossScore(pointsRemove * -1) elseif coordinator:IsSurvival() then player:SubtractLives(lifeRemove) else player:AddBossScore(scoreRemove * -1) end end
Boss Stage: Logic Pt. 2
Now we will learn how to make the health bar UI.

There's two types of UI elements available in MicroWorks' lua: UIImage[agiriko.digital] and UIText[agiriko.digital] (both can be created via worldInfo:CreateEntity(Entity.UIImage/Entity.UIText).

These UI elements contain more information in their transform than a regular object, and therefore have a special RectTransform[agiriko.digital] component. When you want to get a UI element's transform, you will call ":GetRectTransform()" instead of ":GetTransform()".

For example, with a rect transform, you can set the XY size of the UI element with SetSizeDelta. You will also have to set properties such as the anchors and the pivot.

Anchors allow you to "anchor" your UI element to a side on the rectangle it is parented to (by default, your entire screen). For example, if we anchor an image to the top right of the screen, the anchored position will be relative to that corner, and even if we play on different aspect ratios, we can be assured that this UI element will be on the top right for everyone, no matter the monitor.

They can also allow you to stretch your UI element from one side to another. You will notice there's an "AnchorMin" and an "AnchorMax". So if, for example, we set the anchor min to (0,0) (bottom left), and anchor max to (1,1) (top right), the image will stretch across the entire screen.



Pivots set the pivot point of the rectangle. Position, rotation and scaling happens around the pivot, so for example, if you vertically scale an element whose pivot is at the center, both the up and down sides will scale along their directions. But if you place the pivot at the top, the element will only scale downwards.

Let's say your anchor is set to the top right of the screen - that means the anchored position (0,0,0) will place your element at the top right of the screen, but how your element actually aligns with that corner will depend on its set pivot point:



Confusing? Great! Let's create the health bar.

We'll make a transparent background first, which will parent the actual colored health bar and the boss icon. When we destroy that background later, it will also destroy the children inside it.

-- Creates a health bar UI for the duck boss local function CreateHealthBar() -- Create the background local healthBarBackground = worldInfo:CreateEntity(Entity.UIImage) healthBarBackground:SetColor(Color(0.03, 0.03, 0.03, 0.5)) -- For UI objects, we want to get a "RectTransform". local healthBarBackgroundTransform = healthBarBackground:GetRectTransform() -- Anchors decide where to anchor the UI element to relative to the screen (helpful for managing different aspect ratios) -- 0 is left/down and 1 is right/up on each axis (X or Y) -- We want the health bar to be at the middle top of the screen, -- So our anchors will be (0.5, 1) (middle point horizontally, top point vertically) healthBarBackgroundTransform:SetAnchorMin(Vector3(0.5, 1, 0)) healthBarBackgroundTransform:SetAnchorMax(Vector3(0.5, 1, 0)) -- Now we will set the pivot point of the UI element. -- We want the pivot to also be at the middle top, -- So that anchored position (0,0,0) is perfectly aligned at the top of the screen healthBarBackgroundTransform:SetPivot(Vector3(0.5, 1, 0)) -- Now we will set the position and size -- We will offset the Y position by -100 to make some space between the screen top and the health bar healthBarBackgroundTransform:SetAnchoredPosition(Vector3(0, -100, 0)) healthBarBackgroundTransform:SetSizeDelta(Vector3(400, 30, 0))

Then, the health bar itself.

-- Now let's create the actual health bar inside the background -- Because we create the health bar after the background, it will render on top of it healthBar = worldInfo:CreateEntity(Entity.UIImage) healthBar:SetColor(goodHealthColor) -- We are going to parent the health bar into the background, -- so instead of the entire screen, the anchors will be relative to the background rectangle. -- We want to anchor and set the pivot point to the left, so that (0,0,0) is at the left of the background. -- And with the pivot point set to the left, adjusting the width will make it move in only one direction (to the left). local healthBarTransform = healthBar:GetRectTransform() healthBarTransform:SetParent(healthBarBackgroundTransform) healthBarTransform:SetAnchorMin(Vector3(0, 0.5, 0)) healthBarTransform:SetAnchorMax(Vector3(0, 0.5, 0)) healthBarTransform:SetPivot(Vector3(0, 0.5, 0)) healthBarTransform:SetSizeDelta(healthBarBackgroundTransform:GetSizeDelta()) healthBarTransform:SetAnchoredPosition(Vector3Zero())

And then, lastly, the boss icon:

-- Lastly, let's just make the boss icon next to the health bar local bossIconImage = worldInfo:CreateEntity(Entity.UIImage) bossIconImage:SetTexture(bossIcon) local bossIconImageTransform = bossIconImage:GetRectTransform() local maxBossIconSize = healthBarBackgroundTransform:GetSizeDelta().y bossIconImageTransform:SetSizeDelta(Vector3(maxBossIconSize, maxBossIconSize, 0)) -- We will parent the boss icon to the background too -- And set the anchors to the left, but the pivot to the right -- So that (0,0,0) is at the left of the health bar, but the element will be on the outer side, rather than the inner. bossIconImageTransform:SetParent(healthBarBackgroundTransform) bossIconImageTransform:SetAnchorMin(Vector3(0, 0.5, 0)) bossIconImageTransform:SetAnchorMax(Vector3(0, 0.5, 0)) bossIconImageTransform:SetPivot(Vector3(1, 0.5, 0)) bossIconImageTransform:SetAnchoredPosition(Vector3(-15, 0, 0)) end

Congrats! Calling this function on a client will now create a health bar UI. Now we have to compliment it with a function that will update the UI, depending on the state of the duck:

-- Function that updates the health bar according to the duck's HP local function UpdateHealthBar() -- First, we must get the ratio (how close the duck is to be killed) local ratio = duckCurrentHP / duckHP -- We will get the background of the health bar as a reference to its original size local parentSize = healthBar:GetRectTransform():GetParent():GetRectTransform():GetSizeDelta() local width = parentSize.x local height = parentSize.y -- We'll recalculate the width according to the ratio width = math.lerp(0, width, ratio) -- And set the new size healthBar:GetRectTransform():SetSizeDelta(Vector3(width, height, 0)) -- Now we just need to set the proper color according to the ratio: if ratio > 0.666 then healthBar:SetColor(goodHealthColor) elseif ratio > 0.333 then healthBar:SetColor(midHealthColor) else healthBar:SetColor(badHealthColor) end end
Boss Stage: Logic Pt. 3
Let's have a look at our OnBossBegin() function.

First, we're going to calculate the boss' HP. We will take the initial value we defined, and then add to it a bonus value for each player in the game.

We will also set the duck's current HP to this new max value we calculated.

-- Set duck HP based on amount of players local playerCount = worldInfo:GetActivePlayersCount() duckHP = duckHP + (duckBonusHPPerPlayer * playerCount) duckCurrentHP = duckHP

We're gonna get all the audio sources we've exposed from MicroKit, and parent them to the duck, so that when the duck moves, they will move with it.

-- Let's parent the audio sources into the duck boss -- This will ensure they will move together with the duck boss local duckBossTransform = duckBoss:GetTransform() duckAudioSource:GetTransform():SetParent(duckBossTransform) duckCannonSoundR:GetTransform():SetParent(duckBossTransform) duckCannonSoundL:GetTransform():SetParent(duckBossTransform)

Now we can make the duck rise up and play the starting sound. When the duck finishes its little intro, we will set it to active with a new function called "StartDuck()".

-- When the boss begins, we will tween the duck to its end position, -- and also play the rising sound. -- When it is done rising, we will set the duck to active. RunAsync(function() local riseTime = 5 duckBossTransform:TweenPosition(Vector3(0, 5.849, 62.732), riseTime) duckAudioSource:SetAudioClip(duckStartSound) duckAudioSource:Play() Wait(Seconds(riseTime)) StartDuck() end)

In the StartDuck() function that we are now going to define, we will do three things:

1. Set the variable "duckActive" to true
2. Create the health bar
3. Start the shooting loop
4. Make the duck listen to collisions, and attach code for when it is hit

-- Function to switch the duck to an active state local function StartDuck() duckActive = true -- Create the health bar CreateHealthBar() -- Start the firing loop DoProjectileLoop() -- Make the duck boss listen to collisions duckBoss:ListenToCollisions() -- Listen to collisions from the duck ListenFor("ObjectCollision", function(pay) if pay:HasProjectile() then local player = pay:GetProjectile():GetPlayer() local damage = pay:GetProjectile():GetDamage() OnDuckHit(player, damage) end end, duckBoss) end

Step 1 is easy enough, and we've already defined CreateHealthBar(), so that's step 2! We'll touch up on DoProjectileLoop() soon, but I'd like for now to focus on ListenToCollisions().

ListenToCollisions() is a function you can call on a GameObject[agiriko.digital], and it will make this object fire the "ObjectCollision" event when a projectile or a player touches it (if addPlayerTrigger was set to true).

We're gonna start a ListenFor for this event, and pass the duckBoss GameObject as the invoker, so that the code we supply will only execute if the duck boss was hit.

From the payload of this event, we're gonna get the projectile that hit the duck, and get even more information out of this projectile, notably the player that fired it, and the damage it dealt. Then we will pass that information to another function, OnDuckHit(player, damage).
Boss Stage: Logic Pt. 4
OnDuckHit is quite a beefy function, so let's define it now and go over it step by step. First, we will return from it if the duck is not active - we don't want to keep executing this code after the duck has been defeated, even if it was still hit.

-- If duck is not active, return if not duckActive then return end

Next, we will subtract the damage dealt by the projectile to the duck's current HP. Let's also handle the feedback, like shaking the duck's position, or playing the hit register noise.

-- First, we subtract the duck's current HP duckCurrentHP = duckCurrentHP - damage -- Briefly shake the duck duckBoss:GetTransform():ShakePosition(0.1, Vector3One(), 100) -- If the hit was from us, play the hit register sound if player:IsLocalController() then hitRegisterSound:SetVolume(0.4) hitRegisterSound:SetPitch(PRNG:RangeF(0.875, 1.125)) hitRegisterSound:PlayOnce2D() end

After that, we have to first check whether the duck's current HP is 0 or below. If it is so, then that means we won, and we can call the duck killed functions and return to prevent the rest of the code from running.

Because the duck's death should be synced, we will call it only on the host, who will sync it to clients.

Oh, yeah - also award bonus points to whoever delivered the final blow.

-- If duck's current HP is below 0, we won if duckCurrentHP <= 0 then -- The host must tell all clients to kill the duck if worldInfo:IsServer() then -- But first, award the player who delivered the final blow some extra score AwardBonusScore(player) OnDuckKilled() worldGlobals.KillDuckRPC() end return end

We'll keep a mental note to come back and create the "OnDuckKilled()" function (and the RPC), but for now, the show must go on with OnDuckHit.

We know that past this point, the duck's not killed, and we can do code that addresses it getting hit. So first, let's update the health bar:

-- Update the health bar UpdateHealthBar()

Then, let's play a random hit sound from the sounds we defined in a table. To avoid the sounds getting too spammy, we're going to make it play on a 20% chance (see? told you PRNG will come in useful!)

-- Play a random hit sound -- But only on a 20% chance. Otherwise it can get pretty spammy. if PRNG:Chance(20) then duckAudioSource:SetAudioClip(duckHitSound[PRNG:Range(1, #duckHitSound)]) duckAudioSource:Play() end

Now we need to handle the scorage. We're going to keep the amount of times a player has hit the duck in the playerData table we made.

-- Increment the player's hit count if playerData[player:GetNetworkID()] == nil then playerData[player:GetNetworkID()] = { hits = 0 } end playerData[player:GetNetworkID()].hits = playerData[player:GetNetworkID()].hits + 1

Then we're going to check whether the player is eligible to get score awarded, depending on the gamemode and the required amount of hits that we defined in the variables.

-- Now we handle scoring as the host if worldInfo:IsServer() then local requiredHitsToScore = hitsToScore if coordinator:IsPointmatch() then requiredHitsToScore = hitsToPoint elseif coordinator:IsSurvival() then requiredHitsToScore = hitsToLife end if playerData[player:GetNetworkID()].hits % requiredHitsToScore == 0 then AwardScore(player) end end

That should do it! Now let's return to that OnDuckKilled() function we wanted to make.

When the duck is killed, we're going to:

1. Set "duckActive" to false
2. Destroy the health bar
3. Play the end sound
4. Lower the duck back down to the start position and end the boss early

-- Function to run when the duck is killed local function OnDuckKilled() -- Set duckActive to false duckActive = false -- Destroy the health bar healthBar:GetRectTransform():GetParent():GetGameObject():Destroy() -- Play the duck killed sound duckAudioSource:SetAudioClip(duckEndSound) duckAudioSource:Play() -- Tween him back to the start local unriseTime = 5 duckBoss:GetTransform():TweenPosition(Vector3(0, -45.849, 62.732), unriseTime) -- We'll wait 2 seconds and end the boss if worldInfo:IsServer() then RunAsync(function() Wait(Seconds(2)) coordinator:ForceStopRound() end) end end

We'll also create the Server -> Client RPC that will inform clients to kill the duck.
-- Server -> Client RPC -- Kills the duck. RPC("KillDuckRPC", SendTo.Multicast, Delivery.Reliable, function() if worldInfo:IsServer() then return end OnDuckKilled() end)
Boss Stage: Logic Pt. 5
Our boss can now be hurt and it can be killed, but it's not attacking back. So let's fix that by creating that "DoProjectileLoop()" function we called in "StartDuck()".

The shooting loop will be handled exclusively by the host. Every couple of seconds, the duck will enter attack mode, where it will get all the players in the game, and start shooting at random targets a set amount of times within short delays. Once it finishes the attack, it will go into a break before starting again.

The host will determine the position to send projectiles towards, taken from the target's ground position. Then the host will update the clients to spawn and send a projectile to that position.

First off, we have to make sure this function does not run on clients:

-- Return if we are not the host if not worldInfo:IsServer() then return end

Then we're going to make a biiiiiiiig while loop inside of a RunAsync, that will run for as long as "duckActive" is true. When "duckActive" gets set to false at the duck's death, the loop will stop.

-- Run async RunAsync(function() -- Loop for as long as the duck is active while duckActive do -- Get a random amount of shots local shootCount = PRNG:Range(minShootCount, maxShootCount) -- Get all players local players = worldInfo:GetAllActivePlayers() -- For every shot, fire at a random player -- And wait the shot delay for i = 1, shootCount do -- Get random target local target = players[PRNG:Range(1, #players)] -- Get cannon to fire from -- If i is an even number, we will fire from the right cannon -- Otherwise, from the left cannon local fireFromRight = i % 2 == 0 -- Get target position local targetPos = target:GetTransform():GetPosition() -- We will fire a ray from the bottom of the target's position -- To determine where to send the projectile local targetGroundInfo = CastRay(targetPos, Vector3Down(), 50) -- If the hit object is nil, chances are the player is hovering above ground -- In this case, we'll just keep the player's position if ObjectExists(targetGroundInfo:GetHitObject()) then targetPos = targetGroundInfo:GetHitPosition() end -- Now that we know where to send the projectile, we will split each axis -- Because we cannot pass vectors to RPCs local posX = targetPos.x local posY = targetPos.y local posZ = targetPos.z -- Finally, run the RPC worldGlobals.ShootProjectileRPC(posX, posY, posZ, fireFromRight) -- Wait the shoot delay Wait(Seconds(delayBetweenShots)) end -- Wait random amount of shoot breaks Wait(Seconds(PRNG:RangeF(minShootBreak, maxShootBreak))) end end)

There's a lot happening here, so let's go over it step by step:

* We set local shootCount, which will determine how many times the duck will shoot during this attack.

* We get all the players, and we start a for loop that will loop for the amount of shootCount.

* We pick a target by getting a random player from the players table.

* We choose which cannon to fire from. If the current loop index is even, we will fire from the right. Otherwise, from the left.

* We get the target's position, and we send a ray down to check what is the position point of the ground below them. If there is indeed ground below them, we will override the target position with the point we got from that raycast.

* Now we split the position vector, because we cannot send vectors over RPCs.

* We call the RPC with the data we gathered, and we wait the shot delay before making another shot.

* Once the loop ends, we wait the shooting break delay, before starting a new attack all over again.

Let's now define the ShootProjectileRPC, and the ShootProjectile function:

-- Server -> Client RPC -- Shoots a projectile to position RPC("ShootProjectileRPC", SendTo.Multicast, Delivery.Reliable, function(targetX, targetY, targetZ, fireFromRight) ShootProjectile(targetX, targetY, targetZ, fireFromRight) end)

-- Function that shoots a projectile at specified location local function ShootProjectile(targetX, targetY, targetZ, fireFromRight) -- Get the cannon to fire from local cannon if fireFromRight then cannon = duckCannonSoundR else cannon = duckCannonSoundL end -- Play the firing sound cannon:SetPitch(PRNG:RangeF(0.9, 1.1)) cannon:Play() -- Construct the point local targetPoint = Vector3(targetX, targetY, targetZ) -- Spawn the projectile local projectile = duckProjectile:Spawn(cannon:GetTransform():GetPosition(), QuaternionIdentity()) -- Make the projectile look towards point projectile:GetTransform():LookAt(targetPoint) -- Make projectile listen to collisions projectile:ListenToCollisions(true, 1.75) -- Listen for object collision from this projectile ListenFor("ObjectCollision", function(pay) -- If this collision involved a player, get the player -- If the player is us, we will kill ourselves -- In either circumstance, we will destroy the projectile if pay:HasPlayer() then local player = pay:GetPlayer() if player:IsLocalController() then player:Kill() end DestroyProjectile(projectile) end end, projectile) -- Now send the projectile lunging at the target projectile:GetTransform():TweenPosition(targetPoint, projectileTravelTime) -- Wait the travel time, and explode the projectile RunAsync(function() Wait(Seconds(projectileTravelTime)) DestroyProjectile(projectile) end) end

ShootProjectile() is no small grasshopper, either. Let's analyze what it does:

* First, we get which cannon to fire from, according to the "fireFromRight" boolean that was supplied. Remember how we said we're going to use the audio sources position to determine where to fire from, when setting them up in MicroKit? Well, this is their moment to shine!

* After we determined which cannon to fire from, we play the audio source we picked, and also re-construct the vector3 with the target position axes we got supplied with.

* We spawn the duck projectile model from the position of the audio source, and rotate it to look towards the target point.

* We're make the projectile listen to collisions, and set "addPlayerTrigger" to true, because we want it to react when a player touches it. We will also set the bounds multiplier to 1.75, because we want it to be LETHAL.

* Now we set up the listener for the "ObjectCollision" event that was invoked by this projectile. If the collision involved a player, we will get the player and check whether that player was us. If it was us, we will kill ourselves.

Regardless of whether that player was us or not, we will destroy the projectile with a function we will soon define.

* Now that our little boy is all set up and ready to kill, we will tween him to the target position by the travel time we set up in the variables.

* After the travel time has passed, we will destroy the projectile with the same destroy projectile function.

-- Function that destroys a projectile local function DestroyProjectile(projectile) -- If projectile was already destroyed, return if not ObjectExists(projectile) then return end -- Play the projectile explode sound, without an audio source -- (yeah, that's possible, just gives you less options to work with!) projectileExplodeSound:SetVolume(0.5) projectileExplodeSound:SetPitch(PRNG:RangeF(0.9, 1.1)) projectileExplodeSound:PlayOnce(projectile:GetTransform():GetPosition()) -- Destroy the projectile projectile:Destroy() end

When we'll call "DestroyProjectile(projectile)", we'll first check that the projectile hasn't already been destroyed (if it was, it will be nil). If the projectile is alive, we will play the explode sound that we loaded up, straight from the asset without spawning an audio source.

...and then we'll actually destroy the projectile. Phew!
Boss Stage: Logic Pt. 6
Now, one last tiny thing we have to take care of is subtracting score from a player that died. For this, we'll add a listener to the "PlayerDied" event, and get the player that died from the payload.

-- Listen for player death -- If a player died, we will remove score ListenFor("PlayerDied", function(pay) -- Only do it on the host if not worldInfo:IsServer() then return end local player = pay:GetDiedPlayer() RemoveScore(player) -- Reset this player's hit counter if playerData[player:GetNetworkID()] == nil then playerData[player:GetNetworkID()] = { hits = 0 } end playerData[player:GetNetworkID()].hits = 0 end)

That was quite a lot! But boss stages are usually complex endeavours. All of that was just from OnBossBegin, too.

OnBossTick(), we're going to want to.. hm. Do nothing, actually. We've already covered our logic loop from OnBossBegin().

OnBossEnd(), we're just... going to want to set "duckActive" to false, in case the players didn't kill the duck before the timer ran out, and we don't want the duck to keep shooting after the boss ended.

-- Called when the boss ends local function OnBossEnd() -- We'll set duckActive to false in case the duck wasn't killed duckActive = false end

And that's it! The boss stage is now complete and ready to be tested! The same tips and guidelines that were defined in the microgame guide for Testing & Debugging, and Packaging & Uploading, still apply here. Just make sure you package everything required, i.e. the BossStages folder and the CustomMaps folder!
Recap & Endword
So to summarize, in order to make a boss stage, we:

  1. Add a boss stage descriptor (.bos) into StreamingAssets/BossStages

  2. We specify the boss stage data, including the path to the script file, a custom map name, and loading screen assets.

  3. In the script, we return a table with 3 keys: "OnBossBegin", "OnBossTick", "OnBossEnd". The value of each key is a function to run.

Congratulations on making it to the end... of Part 2! In this part, we learned how to create custom maps, and how to create custom boss stages.

In the next part, we will learn how to create custom scenes that can load into your custom map through the Extras menu, and how to make them execute your scripts, allowing for custom gamemodes.

Modding Pt. 3: Custom Scenes

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!