User:Tatsuru/Custom Lua ability tutorial rewrite

From SRB2 Wiki
Jump to navigation Jump to search
Note
The goal of this tutorial is to cover the most basic features of SRB2-oriented Lua scripting and assumes some programming knowledge. It will attempt to explain some programming concepts when needed, but is not a substitute for actual experience on the subject.

It is also not a tutorial on the Lua programming language itself; for that, please refer to the official Lua guide at link.

In this tutorial, we will be creating a custom ability for a character, replacing an existing one, as well as using the HUD drawer to show custom graphics on the screen. This is not the only way to make a custom character ability, nor does it mean every custom ability can be achieved with this knowledge alone, as the purpose of the guide is merely to expose how Lua interacts with the game and help you explore your possibilities.

Hooks

Hooks are the main form of communication between Lua scripts and SRB2's logic. They're made so they latch to specific events in the game when they happen, and allow custom behavior written by you to be executed before the game resumes with its intended behavior.

For example, whenever the Jump key is pressed, the JumpSpecial hook is called, allowing you, the scripter, to dictate what will happen whenever the player presses that key. You can see a full list of the hooks available in SRB2 in this page.

When dealing with custom character abilities, the hook most frequently used is PlayerThink. Instead of just a specific event, PlayerThink latches onto every frame of the game, for every player currently playing.

Using hooks is fairly simple, and is done with the use of the addHook function. It takes two arguments (sometimes three, depending on the hook type): a string of text containing the name of the hook, and a function, which is the custom behavior you want to run when the hook fires. Let's start with the following line.

-- Create a PlayerThink hook
-- Run the MyCustomAbility function when it fires
addHook("PlayerThink", MyCustomAbility)

This piece of code means that a function named MyCustomAbility will run for every player currently in the game, every frame. However, MyCustomAbility itself doesn't exist yet in our code, and the game will complain about that. We can now write our first custom player behavior. Note that the function the hook is going to call must be written before the hook itself, or the game will also throw an error.

-- Create the MyCustomAbility function
local function MyCustomAbility(player)
	print(player.speed) -- Print the player's current speed to the console
end

addHook("PlayerThink", MyCustomAbility)
Note
The -- notation is called a comment: the Lua interpreter ignores the rest of the line after this, so it's not actual code.

You can use this to write notes in your code so it's easier for others (and yourself!) to understand.

Depending on the event that the hook latches onto, there are parameters that will be passed to the called function for the scripter to use. These parameters must be listed within the parentheses after the function name so that they can be properly accessed inside the function. In PlayerThink's case, there is a single parameter: a player structure (player_t).

For this hook, that structure represents every player currently in the game. Since we are going to be dealing with player data, we're naming this parameter player, but it can be whatever name you want as long as it's the same throughout the function; for example, if you name the player parameter p, you will have to fetch the player's speed with p.speed.

If you test our piece of code right now and move around for a while, you will notice that the player's current speed is being constantly printed onto the console, as well as other players' speeds, if they're present. This is due to the print function, which will output any value passed to it to the console, and speed is a property inside player structures that contains that value.

Userdata structures

Now that you understand what a hook is, we can move onto more interesting things. There are many more properties we can mess with in a player structure than just the player's speed, and we can use them to restrict our ability to circumstances of our choice.

Player structures are a type of userdata structure, which in rough terms, means that they are data that can contain more data within themselves. For players, this means they store player-specific information like their name, ring count, score, speed, living status and much more. The most important property in a player structure is mo, which is a link to the player's in-map object. Think of it like the player's physical body, while the player_t structure is the player's "soul".

The player's object, as well as every other object in the map, are map object structures (mobj_t), another type of userdata structure. Those structures store data like an object's position in the map, its color, scale, among other things. For now, we will need the skin property, which contains the skin the object is currently using (a.k.a. the character being played), in the form of a string of text (e.g. "sonic").

Since you don't want your ability to apply to every player in the game regardless of skin, we must limit the execution of this function to your custom character. We will do this by checking the player's skin before writing what we want them to do.

local function MyCustomAbility(player)
	if player.mo.skin ~= "sonic" then -- If the player's current skin is not Sonic
		return -- Abort the function
	end

	print(player.speed) -- Print the player's current speed
end

addHook("PlayerThink", MyCustomAbility)
Note
Note how we're accessing two levels of userdata structures (player.mo.skin) here to access the player's skin.

That's because it's a property of the player's object and not of the player themselves.

Testing the script again will show you that now, your speed will only be printed if you're testing the script as Sonic. Change your skin to see this at play.

If the player skin is something else than "sonic"(~=), we return from the function, a.k.a we quit from it. return is a command that ends a function prematurely and ignores whatever instructions are after. We can now write the rest of the function completely sure that the code won't apply to anyone except Sonic.

We are now going to access a different structure inside the player to add a different condition for our ability.

local function MyCustomAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	if (player.cmd.buttons & BT_CUSTOM1) then -- If the player is pressing Custom 1
		print(player.speed) -- Print the player's speed
	end
end

addHook("PlayerThink", MyCustomAbility)

cmd is a command structure (ticcmd_t) that stores a player's input. Inside, we will find a buttons property, which is the one that has the non-directional buttons the player is currently pressing, such as the Custom 1 button we're looking to use.

To successfully detect this button press, we're using the BT_CUSTOM1 constant and the bitwise intersection operator & (AND), that can be used to check if a value "contains" another in binary. Constants are names that correspond to a numeric value in code. Long story short, if player.cmd.buttons "contains" BT_CUSTOM1, then we can run the code inside the block. You can now test to see that the player's speed will be printed as long as Custom 1 is held.

Object manipulation

Since printing the player's speed isn't an actual ability, we're about to make one that is. Let's change our current code a bit.

local function MyCustomAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	if (player.cmd.buttons & BT_CUSTOM1) then
		-- Use the player's object as the origin
		-- Distance from the object in the three axes: 0, 0, 0
		-- Spawn a balloon object (MT_BALLOON)
		P_SpawnMobjFromMobj(player.mo, 0, 0, 0, MT_BALLOON)
	end
end

addHook("PlayerThink", MyCustomAbility)

P_SpawnMobjFromMobj is another of SRB2's built-in functions that allows us to spawn an object using another object as an origin. It requires five arguments: the object we're going to use as the origin, the three distances from that object in 3D space, and the constant corresponding to the object type we want to spawn.

This means we're spawning an object of type MT_BALLOON, with player.mo as the origin, with a distance of 0 in each axis from it. Testing the code right now will show you that a balloon will spawn right where the player is, immediately popping and knocking them upwards.

Obviously, this isn't very interesting, so let's try changing the distances to see what happens.

local function MyCustomAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	if (player.cmd.buttons & BT_CUSTOM1) then
		-- Distance from the object in the three axes: 128 fracunits, 128 fracunits, the player's height
		P_SpawnMobjFromMobj(player.mo, 128*FRACUNIT, 128*FRACUNIT, player.mo.height, MT_BALLOON)
	end
end

addHook("PlayerThink", MyCustomAbility)

Notice that the numerical distances we pass as arguments are always multiplied by the FRACUNIT constant. Distances in SRB2 are measured in the fixed-point scale, in a unit called fracunit. One fracunit equals 65536 pixels: if we want to move X fracunits in the world, we must multiply X by 65536, which is exactly the value the FRACUNIT constant is worth.

Multiples of FRACUNIT must be provided when a function requires fixed values: the height property in object structures is already a fixed value (48*FRACUNIT in Sonic's case) so we can pass it as it is. This is also the case for the speed property we printed earlier, which is why it appeared so large.

This time, you can see that the balloon spawns at a moderate distance from you, though always towards the northeast of the map, regardless of where the player is facing. This is because on the map's grid, positive distances are always to the east of the map in the X axis, and to the north in the Y axis. To account for the player's direction, we will need a little bit of trigonometry.

local function MyCustomAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	if (player.cmd.buttons & BT_CUSTOM1) then
		local x = cos(player.mo.angle) -- The player's deviation from the X axis
		local y = sin(player.mo.angle) -- The player's deviation from the Y axis

		-- Use the variables we just created
		P_SpawnMobjFromMobj(player.mo, 128*x, 128*y, player.mo.height, MT_BALLOON)
	end
end

addHook("PlayerThink", MyCustomAbility)

Here we are using local variables for the first time. Local variables can be created anywhere in a script, although they'll only be valid inside the block they were created in, and need to be declared before then can be used. They are useful to temporarily store values and even userdata we need for later, and in this case, we have created two x and y locals to store values that will be used in our balloon's spawning.

But what values are those, you ask? Simply put, the cosine (function cos) and the sine (function sin) are trigonometric functions used to horizontally rotate a coordinate in space by a certain amount. Both take a single argument: an angle, to which we passed the player object's angle property. Both of them output a value ranging from 0 to FRACUNIT, which means they're already fixed values and we can multiply them by normal integers to get meaningful distances.

Testing the function right now will show you that the balloons reliably spawn in front of the player, as intended. That's one item off our checklist. Sadly, once the balloon spawns, there's nothing we can do about it: it's a loose piece of userdata we have no control over. We can change that by storing it in a variable. While we could also be using a local variable for that, we're doing something slightly different.

local function MyCustomAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	if (player.cmd.buttons & BT_CUSTOM1) then
		local x = cos(player.mo.angle)
		local y = sin(player.mo.angle)

		-- Created custom balloon field in this player structure
		-- Immediately store the spawned balloon there
		player.balloon = P_SpawnMobjFromMobj(player.mo, 128*x, 128*y, player.mo.height, MT_BALLOON)
	end
end

addHook("PlayerThink", MyCustomAbility)

As you may be wondering, balloon isn't a field that exists in unmodified SRB2. However, Lua allows us to create unlimited custom fields inside player and object structures so we can store values and userdata we may need for convenience. Values stored in those structures are also accessible from any part of the code, and even from other scripts. Luckily for us, P_SpawnMobjFromMobj not only spawns our balloon, but also returns an userdata address to it, which we can save in the field we just created and then retrieve the last spawned balloon for as long as it exists. Now we can make our script work like a real ability.

local function MyCustomAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	if (player.cmd.buttons & BT_CUSTOM1) then
		local x = cos(player.mo.angle)
		local y = sin(player.mo.angle)

		player.balloon = P_SpawnMobjFromMobj(player.mo, 128*x, 128*y, player.mo.height, MT_BALLOON)
		-- Thrust the stored balloon
		-- In the player object's angle
		-- By 12 fracunits/tic
		P_InstaThrust(player.balloon, player.mo.angle, 8*FRACUNIT)
	end
end

addHook("PlayerThink", MyCustomAbility)

P_InstaThrust is a function used to instantly boost an object towards a direction in a certain speed (thus taking three arguments: an object structure, the angle, and the distance to traverse per frame). You can observe now that every balloon you spawn will be flung in the direction you're facing at a speed of 8 fracunits/tic.

You may be wondering we probably don't want to spray balloons around, and ideally, have the function only be executed when the Custom 1 button is pressed once rather than held. Therefore, we're about to address that before continuing. Let's declare a new function and transfer the balloon spawning code to is body.

-- New function SpawnBalloon
-- Receives one player argument
local function SpawnBalloon(player)
	local x = cos(player.mo.angle)
	local y = sin(player.mo.angle)

	player.balloon = P_SpawnMobjFromMobj(player.mo, 128*x, 128*y, player.mo.height, MT_BALLOON)
	P_InstaThrust(player.balloon, player.mo.angle, 8*FRACUNIT)
end

local function MyCustomAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	if (player.cmd.buttons & BT_CUSTOM1) then
		SpawnBalloon(player) -- Call the function we just made
	end
end

addHook("PlayerThink", MyCustomAbility)

Our code works exactly the same as before - if we press Custom 1, the code will call the SpawnBalloon function and pass our player to it. There, the function will execute the exact same sequence we wrote before to the player we passed. By singling out one of our commands to a function, we made our hook code cleaner as a result -- we can also call the same function from other places in the script if we ever want to do the same thing more than once, without having to write the spawning sequence all over again.

local function SpawnBalloon(player)
	local x = cos(player.mo.angle)
	local y = sin(player.mo.angle)

	player.balloon = P_SpawnMobjFromMobj(player.mo, 128*x, 128*y, player.mo.height, MT_BALLOON)
	P_InstaThrust(player.balloon, player.mo.angle, 8*FRACUNIT)
end

local function MyCustomAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	if (player.cmd.buttons & BT_CUSTOM1) then
		if not player.pressedcustom1 -- If pressedcustom 1 is false
			SpawnBalloon(player)
		end

		player.pressedcustom1 = true -- Set new field pressedcustom1 to true
	end
end

addHook("PlayerThink", MyCustomAbility)

We are now checking for an undefined field called pressedcustom1 before calling our spawning function: if the field is not true, that is, it's false, then it should let us into the block and call the function. It turns out that undefined values default to nil, which is a special value that means the absence of any values, and when put to a logic test, it's equivalent to false. That's why if we try to spawn a balloon now, it will let us in the first time.

However, regardless whether the balloon was spawned or not, we set pressedcustom1 to true right after. That field is supposed to mean that we pressed the Custom 1 button successfully this frame, and by storing it in a player field we will make sure it's still the same value in the next frame. Therefore, in the next frame, if we are pressing the button (player.cmd.buttons & BT_CUSTOM1) and we were also pressing it in the previous (player.pressedcustom1), that means we are pressing the button for two consecutive frames, and, in turn, that means we're holding it. We don't want that: that's why now you can only spawn one balloon. Tragic, isn't it? Well, we only need one last step to fix this:

local function SpawnBalloon(player)
	local x = cos(player.mo.angle)
	local y = sin(player.mo.angle)

	player.balloon = P_SpawnMobjFromMobj(player.mo, 128*x, 128*y, player.mo.height, MT_BALLOON)
	P_InstaThrust(player.balloon, player.mo.angle, 8*FRACUNIT)
end

local function MyCustomAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	if (player.cmd.buttons & BT_CUSTOM1) then
		if not player.pressedcustom1
			SpawnBalloon(player)
		end

		player.pressedcustom1 = true
	else -- We are not pressing Custom 1 this frame
		player.pressedcustom1 = false -- Set pressedcustom1 to false
	end
end

addHook("PlayerThink", MyCustomAbility)

As you can probably tell, if we aren't pressing Custom 1 this frame, then the chain of consecutive Custom 1 presses is broken: we aren't holding the button anymore. We then set pressedcustom1 to false so we're able to spawn balloons again. You will now see that you can spawn one balloon per button press, as intended.

We can now go back to fine-tuning our ability: Let's ignore the SpawnBalloon function for now. How about we add a new command for the Custom 2 button?

-- Not displayed: function SpawnBalloon

local function MyCustomAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	if (player.cmd.buttons & BT_CUSTOM1) then
		if not player.pressedcustom1
			SpawnBalloon(player)
		end

		player.pressedcustom1 = true
	else
		player.pressedcustom1 = false
	end

	if (player.cmd.buttons & BT_CUSTOM2) then -- If the player is pressing Custom 2
		-- Damage everything around the balloon in a 128 fracunit radius
		P_RadiusAttack(player.balloon, player.mo, 128*FRACUNIT)
		-- Tell the balloon to "die"
		P_KillMobj(player.balloon)
	end
end

addHook("PlayerThink", MyCustomAbility)

This command is comprised by two functions only:

  • P_RadiusAttack will deal damage around a certain object in a specified radius. For that it takes three arguments: the inflictor object (the object that deals the damage), the source (the object that spawned the inflictor) and a fixed value as an attack radius. The inflictor object is obviously the balloon, while the source is the player object: it's important to set this to prevent the balloon from hurting the player themselves. If you check this function's description, you will see that it also supports a damage type argument, and a boolean for sight checking, but we don't need to use those for this example.
  • P_KillMobj tells an object to enter its death sequence. In the balloon's case, this means popping (displaying the popped sprite, playing a pop sound, then disappearing). It can take up to four arguments, but for our case we only need one: the object we want to kill.

If you try to use our new command normally, you will see that you can now destroy the balloons you spawned to damage enemies. However, you may be quick to notice that trying to use it without spawning any balloons or after popping your newest balloon will give us an error. And that's very much expected: we just tried to operate on an object that doesn't exist. Rather, we have two different errors depending on which you did: one because the address stored doesn't point to an existing object anymore, and another because the balloon field was never given any address in the first place. We can prevent this in a very easy way:

-- Not displayed: function SpawnBalloon

local function MyCustomAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	if (player.cmd.buttons & BT_CUSTOM1) then
		if not player.pressedcustom1
			SpawnBalloon(player)
		end

		player.pressedcustom1 = true
	else
		player.pressedcustom1 = false
	end

	-- If the player is pressing Custom 2
	-- and player.balloon is defined
	-- and the object the address in player.balloon points to still exists
	if (player.cmd.buttons & BT_CUSTOM2) and player.balloon and player.balloon.valid then
		P_RadiusAttack(player.balloon, player.mo, 128*FRACUNIT)
		P_KillMobj(player.balloon)
	end
end

addHook("PlayerThink", MyCustomAbility)

Object structures have a valid field to indicate whether the object it represents still exists in the map. If this happens to be false for the balloon we have in store, then we don't run the popping code. However, this needs to be checked in tandem with the balloon field itself since there may not be an object userdata there in first place in case we never spawned a balloon: trying to do so would raise an error. This should fix all issues with our balloon spawning...

Drat. Holding the Custom 2 button makes the balloon makes the balloon pop multiple times. That's because the balloon still exists during its dying sequence, but we're not checking for whether it's already dying, so pressing Custom 2 again before its dying sequence is over restarts it: so is the nature of P_KillMobj. We don't want that, so let's fix it very quickly.

-- Not displayed: function SpawnBalloon

local function MyCustomAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	if (player.cmd.buttons & BT_CUSTOM1) then
		if not player.pressedcustom1
			SpawnBalloon(player)
		end

		player.pressedcustom1 = true
	else
		player.pressedcustom1 = false
	end

	if (player.cmd.buttons & BT_CUSTOM2)
	and player.balloon
	and player.balloon.valid
	and player.balloon.health > 0 then -- and the balloon has at least 1 health point
		P_RadiusAttack(player.balloon, player.mo, 128*FRACUNIT)
		P_KillMobj(player.balloon)
	end
end

addHook("PlayerThink", MyCustomAbility)

Objects in SRB2 have a health field, which signifies their health points that they may or may not use depending on their in-game behavior. Regardless, on top of sending an object to its death sequence, P_KillMobj also depletes all of its health in the process. Balloons only have 1 health point, so in the moment they lose it, we can tell our script to not try to kill the balloon anymore by checking if its health is above 0 (player.balloon.health > 0).

Finally, you may have noticed that every other balloon you spawn stays in the map, forever. Since there's no way to get rid of them except manually popping them, we're modifying their behavior so they self-destruct on their own, which is why we're making use of the MobjThinker hook.

-- Not displayed:
-- functions SpawnBalloon, MyCustomAbility
-- PlayerThink hook

-- MobjThinker hook calling anonymous function
-- Restrict it to MT_BALLOON objects
addHook("MobjThinker", function(mo)
	print("I'm a balloon!")
end, MT_BALLOON)

Notice how we did not pass the name of a function to addHook this time around, but the function itself: the entire body of the function, from function(mo) to end, can be written completely inside the hook. This is another way of declaring hooks using what's called an anonymous function in Lua. This can be useful in a number of cases, but in our case it is because our function will be very short.

Additionally, MobjThinker is one of the hooks for which addHook can take three arguments instead of just two: just like PlayerThink runs for every player in every frame, MobjThinker runs for every object in the map, in every frame, and its single parameter is the object structure being dealt with. Since we don't want to affect every single map object (that would be very slow and inconvenient), we can restrict MobjThinker's execution to a single object type by giving addHook a third argument: for our case, it's MT_BALLOON. As you can probably tell, for every balloon existing in the map, a message will be printed to your console every frame.

Now let's do what we came to do. Since our balloons are supposed to be moving, it is okay to kill them once they stop.

-- Not displayed:
-- functions SpawnBalloon, MyCustomAbility
-- PlayerThink hook

addHook("MobjThinker", function(mo)
	-- If the balloon isn't moving in the X axis
	-- and it isn't moving in the Y axis
	-- and it has health points
	if mo.momx == 0
	and mo.momy == 0
	and mo.health > 0
		P_KillMobj(mo) -- Kill the balloon
	end
end, MT_BALLOON)

Here we've checked both fields momx and momy that indicate how fast an object is moving in the X and Y axes, respectively. If both of them are 0, that means they've halted horizontally and we can safely kill them: you can test so right now and see that balloons will pop on their own if they hit a wall. However, this has the drawback that it affects every balloon in every map, which means it will also kill stationary balloons in any map even if you didn't spawn them. That would be very troublesome, so we have to mark our balloons as our own, somehow. Let's go back to the spawning function.

-- Not displayed:
-- function MyCustomAbility
-- PlayerThink and MobjThinker hook

local function SpawnBalloon(player)
	local x = cos(player.mo.angle)
	local y = sin(player.mo.angle)

	player.balloon = P_SpawnMobjFromMobj(player.mo, 128*x, 128*y, player.mo.height, MT_BALLOON)
	P_InstaThrust(player.balloon, player.mo.angle, 8*FRACUNIT)

	-- Create new field customAI in the balloon
	-- Set it to true
	player.balloon.customAI = true
end

As you may remember, object structures also support custom fields. By doing this, we make our balloons have a custom field set to true that no other balloons have. That means we can adjust our MobjThinker function accordingly:

-- Not displayed:
-- functions SpawnBalloon, MyCustomAbility
-- PlayerThink hook

addHook("MobjThinker", function(mo)
	if not mo.customAI -- If the customAI field is false or undefined
		return -- Abort the function
	end

	if mo.momx == 0
	and mo.momy == 0
	and mo.health > 0
		P_KillMobj(mo)
	end
end, MT_BALLOON)

With this, our balloon ability is complete. While it's just an addition to an existing character that can completely coexist with Sonic's moveset, there are other ways of creating custom abilities by replacing existing ones, which is what we're going to do next.

Replacing existing abilities

local function MyJumpAbility(player)
	print("Jump!") -- Print "Jump!" to the console
end

-- New AbilitySpecial hook
-- Calls function MyJumpAbility
addHook("AbilitySpecial", MyJumpAbility)

The AbilitySpecial hook executes when a player attempts to use their jump ability: that is, when they press the Jump button once again after jumping. Like PlayerThink, it also provides a player parameter: if you test this function now, you will see that text will be printed to the console everytime you use Sonic's thok. Now, we only need to get rid of the thok itself.

local function MyJumpAbility(player)
	print("Jump!")

	return true -- Override default behavior
end

addHook("AbilitySpecial", MyJumpAbility)

You have witnessed that we've used the return command quite a few times, but in this case it's accompanied by a value, that value being true. A few hook types will allow you the option to skip the game's default behavior for the event they latch on if the true value is returned at any point in its function, and that's why, if you test our code now, Sonic will be unable to thok: returning true overrode his jump ability.

We're now free to make it whatever we want. Since our character has a magician motif, for his jump ability we're going to give them the power to teleport his body backwards in time. However, before we go on with that, we will need a quick function to store the player's position.

addHook("PlayerThink", function(player)
	if P_IsObjectOnGround(player.mo) -- If the player object is on the floor
		-- New field lastposition
		-- Table with elements x, y and z
		-- Store player's coordinates on each
		player.lastposition = {
			x = player.mo.x,
			y = player.mo.y,
			z = player.mo.z
		}
	end
end)

local function MyJumpAbility(player)
	return true -- Override default behavior
end

addHook("AbilitySpecial", MyJumpAbility)

We have just made a PlayerThink hook for a very simple anonymous function. P_IsObjectOnGround check is an object is on the floor (or on the ceiling if in flipped gravity) and returns true or false depending on that. If that's true


local function MySpinAbility(player)
	print("Spin!") -- Print "Spin!" to the console
end

-- New SpinSpecial hook
-- Calls function MySpinAbility
addHook("SpinSpecial", MySpinAbility)

The SpinSpecial hook latches onto every frame where the player is pressing the Spin key, and like PlayerThink, also takes a player parameter. By testing this function we just made, you'll see that text is printed to the console while the Spin key is pressed.

local function MySpinAbility(player)
	if player.mo.skin ~= "sonic" then -- If the player's skin isn't Sonic
		return -- Abort function, resume default behavior
	end

	-- Add flag PF_INVIS to the player's flags
	player.pflags = $ | PF_INVIS

	return true
end

addHook("SpinSpecial", MySpinAbility)

Here we're first making use of the union operator, bitwise OR (|), which is frequently used to add a flag constant to a flag field, such as pflags. This field is stored directly in the player structure, and contains a series of flags that signal and determine the player's behavior. Please note the $ operator: it means that we're referring to the previous mentioned variable. Thus, this line is equivalent to player.pflags = player.pflags | PF_INVIS.

If you test our ability right now, it will seemingly do nothing: Sonic did nothing and seems unchanged. That's because PF_INVIS is merely responsible for the player untargetable. As long as you haven't been already targeted by an enemy, you can walk around as if they don't see you. While this is de facto invisibility, it would be better if our character faded from view as well for visual feedback.

local function MySpinAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	player.pflags = $ | PF_INVIS

	-- Add MF2_SHADOW to the player object's secondary flags
	player.mo.flags2 = $ | MF2_SHADOW

	return true
end

addHook("SpinSpecial", MySpinAbility)

flags2 is a field in object structures that stores an object's secondary flags. We just did a similar operation as before to the player's object: adding the MF2_SHADOW to an object makes it 80% translucent. This will be the visual cue for our ability for now.

local function MySpinAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	player.pflags = $ | PF_INVIS
	player.mo.flags2 = $ | MF2_SHADOW

	-- Play Hyudoro appearance sound
	-- From the player's object
	S_StartSound(player.mo, sfx_s3k92)

	return true
end

addHook("SpinSpecial", MySpinAbility)

We're also gonna play a sound when using our ability. For that we use the S_StartSound function, that can play sound effects: it takes an object to play the sound from, and a sound constant. It can also take a player argument, in case we don't want other players to hear it, but we can leave it out for now.

If an object isn't provided (that is, nil) S_StartSound will play the sound as if it came from nowhere: since we're passing the player's object, it's from us that the sound will propagate. sfx_s3k92 is our chosen sound constant, which you can probably recognize as the Sonic 3 & Knuckles Sandopolis ghosts. If you test our code now, you'll see that the sound will play once per press.

You may be wondering that no matter what we do, we can't turn the character back to normal: they're invisible forever. This may not sound bad at first, but it's not very fun as an ability. Therefore, we're gonna add a timer to this ability, as well as a cooldown so it can't be spammed.

local function MySpinAbility(player)
	if player.mo.skin ~= "sonic" then
		return
	end

	if player.invistimer then -- If invistimer is defined and is not 0
		return true -- Abort and override
	end

	player.pflags = $ | PF_INVIS
	player.mo.flags2 = $ | MF2_SHADOW

	S_StartSound(player.mo, sfx_s3k92)

	-- Define new field invistimer as 4 seconds
	player.invistimer = 4*TICRATE

	return true
end

addHook("SpinSpecial", MySpinAbility)

We have now created a player field called invistimer that, everytime our ability is triggered, is set to a value of 4*TICRATE. Just like fracunits are SRB2's main distance measurement unit, tics are its main time measurement unit. There are 35 tics (frames) in a second, which is why the TICRATE constant equals 35. Therefore, to obtain 4 seconds, we only need to multiply 4 by TICRATE.