Lua/Custom player ability

From SRB2 Wiki
< Lua
Jump to: navigation, search
NoteIcon.png Note
This article has not yet been updated to reflect the additions to Lua in 2.1.9. The examples provided in this article still work, but there are now more convenient ways to achieve the same effects.

One of the most useful aspects of Lua scripting in SRB2 is the ability to give characters entirely new gameplay mechanics. This tutorial will show you how to apply some of these mechanics to a character. (This tutorial assumes that you have some basic knowledge of Lua scripting.)

Identifying the character

Start a new Lua script (in any text editor of your choice) and paste the following into it:

addHook("ThinkFrame", function()
	for player in players.iterate do
	end
end)

Most of the code involved in messing with players is executed with a ThinkFrame hook, as this hook executes on every frame. for player in players.iterate allows the code block within to execute once for every player. This works because players.iterate is a hardcoded function that returns an iterable list of all of the players in the game; however, it only works in the context of the rest of the line, so just remember that for player in players.iterate allows you to run code once per player. (player can actually be any identifying word, except for reserved words. It will be used to refer to the player in the current iteration of the loop.) This can be tested by adding a line as follows:

addHook("ThinkFrame", function()
	for player in players.iterate do
		print(player.name)
	end
end)

If you try executing this script now, it will print your name into the console every frame while you're in a level. If you host a netgame and other people join, you'll see their names in the console too. (This isn't a very practical function, but it demonstrates how player iteration works. For the record, name is a read-only variable in the player_t table that holds the referenced player's name.)

As is, this code will execute for every player on every frame, regardless of which character they are. Let's limit its execution to a specific character. Since we're not creating a custom character right now, let's mess with Sonic's abilities. Modify the script so it reads as such: (New lines will be marked with comments.)

addHook("ThinkFrame", function()
	for player in players.iterate do
		if player.mo.skin ~= "sonic" then -- New line
			continue -- New line
		end -- New line
	end
end)

The player_t table has a lot of variables that might be useful to us. For now, we're concerned with the mo variable; this contains the mobj_t that represents the player in the map. The player.mo has a skin variable containing the player's current skin as a string, which we then compare to Sonic's skin name. The continue command is a function built into BLUA (not in vanilla Lua; this is an addition specifically for SRB2) from languages such as C, used inside loops to stop the current iteration of code early and advance to the next one.

Changing the character's behavior

Triggers on button press

A common type of player ability is triggered by a button press. Let's start by making one of those. Start by adding this to your script:

addHook("ThinkFrame", function()
	for player in players.iterate do
		if player.mo.skin ~= "sonic" then
			continue
		end
 
		if not (player.cmd.buttons & BT_USE) then -- Begin new lines
			player.spintapready = true
			player.spintapping = false
		elseif player.spintapready then
			player.spintapping = true
			player.spintapready = false
		else
			player.spintapping = false
		end -- End new lines
	end
end)

The bit of logic above creates a variable that can be used to determine if a button was tapped that frame. Each logic block does the following:

  • If the player is NOT holding down BT_USE (the spin button), then set their spintapready variable to true. Also set their spintapping variable to false.
  • Otherwise, if the player's spintapready variable is set to true, then set it to false and set their spintapping variable to true.
  • If neither of the above are true, then the player has been holding the spin button since the last time this block of code was run. Set their spintapping variable to false so we know they haven't just tapped the button.

At the end of it all, you can use the player's spintapping variable to read whether they've just pressed the spin button. You can change the button flag used to check if any other button has just been tapped. But how is that button press read?

The player_t table contains a cmd variable, which is a ticcmd_t table containing various information about the player's input in the current frame. The relevant variable for our use is the buttons variable, which contains all of the digital inputs that affect the player. (Buttons such as Toggle Chasecam and Pause do not affect the player, and as such are not stored in this variable. Movement, turning, and aiming are also not stored in this variable, and are instead stored in the other four variables of ticcmd_t as analog values.)

To pack all of these buttons into one value, each button is assigned a flag (such at BT_USE), which gives it its own unique spot in the binary representation of a number among the other button flags. All of the buttons being pressed have their flag value added together, and the final result is stored in ticcmd_t.buttons. To get one button's state from this value, a bitwise AND (&) operation such as (player.cmd.buttons & BT_USE) is used to determine if the value contains the button flag. Such an operation will resolve to the flag's value if it's present, and 0 otherwise. (0 resolves to false in BLUA, unlike in vanilla Lua; nonzero values resolve to true, like they do in vanilla Lua.) Thus, the operation can be used in an if statement to quickly determine if the button is being held. (Bitwise operations are an addition to BLUA that were not present in vanilla Lua. If you aren't already familiar with them, it may be a good idea to familiarize yourself with them, since they're used often in SRB2's math.)

Altering player flags

Now that we have a trigger, we can add an action. For our example, we'll flip the player's gravity. Add to your code so that it reads as such:

addHook("ThinkFrame", function()
	for player in players.iterate do
		if player.mo.skin ~= "sonic" then
			continue
		end
 
		if not (player.cmd.buttons & BT_USE) then
			player.spintapready = true
			player.spintapping = false
		elseif player.spintapready then
			player.spintapping = true
			player.spintapready = false
		else
			player.spintapping = false
		end
 
		if player.spintapping then -- New line
			player.mo.flags2 = $1 ^^ MF2_OBJECTFLIP -- New line
		end -- New line
	end
end)

This takes advantage of the spintapping flag we wrote into player to check for a tap of the spin button (if player.spintapping then), then alters the flags2 value of the player's mobj. To attain the effect desired, another bitwise operation is needed; this time a bitwise XOR (^^) is used to toggle the MF2_OBJECTFLIP flag. (An XOR operation on two values returns the different bits in the binary representation of each value. In our case, it will return flags2 with MF2_OBJECTFLIP added if it doesn't already have it, or removed if it does.)

Note the use of $1. In BLUA, $1, $2, and so on can be used to reference the original value of one of the variables being assigned in an operation.

Save this script and load it into SRB2. If you're playing as Sonic, pressing the spin key should now reverse your local gravity.

Limiting conditions

But what if we don't want Sonic to always be able to flip gravity? Maybe we should limit it to an airborne ability, so it doesn't conflict with the spindash? Well, that's simple enough. Perform the following changes to your code:

addHook("ThinkFrame", function()
	for player in players.iterate do
		if player.mo.skin ~= "sonic" then
			continue
		end
 
		if not (player.cmd.buttons & BT_USE) then
			player.spintapready = true
			player.spintapping = false
		elseif player.spintapready then
			player.spintapping = true
			player.spintapready = false
		else
			player.spintapping = false
		end
 
		if player.spintapping and not P_IsObjectOnGround(player.mo) then -- Altered line
			player.mo.flags2 = $1 ^^ MF2_OBJECTFLIP
		end
	end
end)

P_IsObjectOnGround(mobj) is a built-in function that checks if a specified mobj is on the ground. By passing the player's mobj, player.mo, to it, we can easily check if the player is airborne. But now what if we want gravity changing to only happen once per jump? And only if we jumped off in the first place? Make the following changes to your code:

addHook("ThinkFrame", function()
	for player in players.iterate do
		if player.mo.skin ~= "sonic" then
			continue
		end
 
		if not (player.cmd.buttons & BT_USE) then
			player.spintapready = true
			player.spintapping = false
		elseif player.spintapready then
			player.spintapping = true
			player.spintapready = false
		else
			player.spintapping = false
		end
 
		if player.canflip ~= 2 and player.jumping then -- Begin new lines
			player.canflip = 1
		end
		if P_IsObjectOnGround(player.mo) and (player.mo.eflags & MFE_ONGROUND) then
			player.canflip = 0
		end -- End new lines
 
		if player.spintapping and player.canflip == 1 then -- Altered line
			player.mo.flags2 = $1 ^^ MF2_OBJECTFLIP
			player.canflip = 2 -- New line
		end
	end
end)

Now instead of just checking if the player is on the ground, we use a value to see if the player can flip.

First, we check if the player has already flipped by checking whether their canflip value is not equal to 2. (canflip is a variable we add to the player. Any arbitrary variable name can be added onto the player_t construct, as well as many other constructs, and used later.) If that checks out, we check their jumping variable (this returns <true> if the player has triggered a jump and hasn't yet let go of the jump button), and if true, we set the player's canflip variable to 1. (This will represent that the player is allowed to flip.)

The next check is to see if the player is on the ground. We also add a check for the player mobj's eflags variable. This is another set of flags in the mobj_t table (each mobj has a flags, flags2, and eflags variable that contain different flags for different purposes) that contains a useful flag to us: MFE_ONGROUND. Contrary to its name, this flag doesn't actually check if a mobj is on the ground; rather, it checks if the mobj's floorz variable (used for ground checks) has been affected by a pushable or solid thing, and returns false if it has. (The ceilingz variable is tested instead if the object is flipped.) This check is needed to avoid the if statement resolving to true when a player bounces off of a monitor. If this statement is true, the player's canflip variable is set to 0. (We will use this to represent that the player is on the ground, and will not be able to flip until they jump.)

Finally, when the player does flip, their canflip variable is set to 2. To us, this represents that the player has already flipped in this jump, and should not be allowed to flip again until they touch the ground.

If you test this, the action should work as expected; flipping will now only work in mid-air, if the player has jumped, once per jump.

Interacting with other Objects

Spawning and linking Objects

Spawning objects and retaining their pointers for later use is another powerful function of Lua scripting. Add the following to your script:

addHook("ThinkFrame", function()
	for player in players.iterate do
		if player.mo.skin ~= "sonic" then
			continue
		end
 
		if not (player.cmd.buttons & BT_USE) then
			player.spintapready = true
			player.spintapping = false
		elseif player.spintapready then
			player.spintapping = true
			player.spintapready = false
		else
			player.spintapping = false
		end
 
		if player.canflip ~= 2 and player.jumping then
			player.canflip = 1
		end
		if P_IsObjectOnGround(player.mo) and (player.mo.eflags & MFE_ONGROUND) then
			player.canflip = 0
		end
 
		if player.spintapping and player.canflip == 1 then
			player.mo.flags2 = $1 ^^ MF2_OBJECTFLIP
			player.canflip = 2
		end
 
		if not (player.cmd.buttons & BT_CUSTOM1) then -- Begin new lines
			player.monspawntapready = true
			player.monspawntapping = false
		elseif player.monspawntapready then
			player.monspawntapping = true
			player.monspawntapready = false
		else
			player.monspawntapping = false
		end -- End new lines
	end
end)

This is the same bit of code we used to check if the player was tapping the spin button, with a few variables changed and the flag switched to read a different button. The BT_CUSTOM1 through BT_CUSTOM3 flags represent special buttons: the Custom Action buttons are unused by any other function in SRB2, and are reserved just for Lua scripters. (Note that the Custom Action buttons may not be bound in your game yet; make sure to go set them before continuing, as we'll be using them quite a bit.)

Now modify your script as such:

addHook("ThinkFrame", function()
	for player in players.iterate do
		if player.mo.skin ~= "sonic" then
			continue
		end
 
		if not (player.cmd.buttons & BT_USE) then
			player.spintapready = true
			player.spintapping = false
		elseif player.spintapready then
			player.spintapping = true
			player.spintapready = false
		else
			player.spintapping = false
		end
 
		if player.canflip ~= 2 and player.jumping then
			player.canflip = 1
		end
		if P_IsObjectOnGround(player.mo) and (player.mo.eflags & MFE_ONGROUND) then
			player.canflip = 0
		end
 
		if player.spintapping and player.canflip == 1 then
			player.mo.flags2 = $1 ^^ MF2_OBJECTFLIP
			player.canflip = 2
		end
 
		if not (player.cmd.buttons & BT_CUSTOM1) then
			player.monspawntapready = true
			player.monspawntapping = false
		elseif player.monspawntapready then
			player.monspawntapping = true
			player.monspawntapready = false
		else
			player.monspawntapping = false
		end
 
		if player.monspawntapping then -- New line
			player.spawnedmonitor = P_SpawnMobj(player.mo.x+FixedMul(40*FRACUNIT, cos(player.mo.angle)), -- New line
					player.mo.y+FixedMul(40*FRACUNIT, sin(player.mo.angle)), player.mo.z, MT_SUPERRINGBOX)
		end -- New line
	end
end)

The primary function we use here is P_SpawnMobj(x, y, z, mobjtype). This spawns an object of the specified type (in our case, a ten-ring monitor) at the specified coordinates. We use the player's position to determine where to spawn it, but we're going to spawn it in front of the player so they don't get crushed by it. Sine and cosine are used with FixedMul() to position the monitor 40 fracunits in front of the player as determined by their angle. Once the object is spawned, we store it in the player as spawnedmonitor. (P_SpawnMobj returns the mobj spawned.)

Interacting with linked Objects

Now that we have an object linked to our player, let's let them do something cool with it. Modify the script so it reads as such:

addHook("ThinkFrame", function()
	for player in players.iterate do
		if player.mo.skin ~= "sonic" then
			continue
		end
 
		if not (player.cmd.buttons & BT_USE) then
			player.spintapready = true
			player.spintapping = false
		elseif player.spintapready then
			player.spintapping = true
			player.spintapready = false
		else
			player.spintapping = false
		end
 
		if player.canflip ~= 2 and player.jumping then
			player.canflip = 1
		end
		if P_IsObjectOnGround(player.mo) and (player.mo.eflags & MFE_ONGROUND) then
			player.canflip = 0
		end
 
		if player.spintapping and player.canflip == 1 then
			player.mo.flags2 = $1 ^^ MF2_OBJECTFLIP
			player.canflip = 2
		end
 
		if not (player.cmd.buttons & BT_CUSTOM1) then
			player.monspawntapready = true
			player.monspawntapping = false
		elseif player.monspawntapready then
			player.monspawntapping = true
			player.monspawntapready = false
		else
			player.monspawntapping = false
		end
 
		if player.monspawntapping then
			player.spawnedmonitor = P_SpawnMobj(player.mo.x+FixedMul(40*FRACUNIT, cos(player.mo.angle)),  
					player.mo.y+FixedMul(40*FRACUNIT, sin(player.mo.angle)), player.mo.z, MT_SUPERRINGBOX)
		end
 
		if player.spawnedmonitor and player.spawnedmonitor.valid then -- Begin new lines
			local xdiff = player.spawnedmonitor.x-player.mo.x
			local ydiff = player.spawnedmonitor.y-player.mo.y
			local zdiff = player.spawnedmonitor.z-player.mo.z
		end -- End new lines
	end
end)

Before we do anything with the linked object, we need to test whether it exists. The first test is whether the variable has been assigned; if it hasn't, then player.spawnedmonitor will resolve to nil, which is a special value that means nothing is there (different from 0!) and is equivalent to false for the sake of comparisons. The second test checks the object's valid variable. This variable will be false if the object no longer exists, so it can be used to make sure we're not executing code on objects that don't exist.

The variables being declared are convenience values that will be used later. Now futz with your script until it looks a bit like this:

addHook("ThinkFrame", function()
	for player in players.iterate do
		if player.mo.skin ~= "sonic" then
			continue
		end
 
		if not (player.cmd.buttons & BT_USE) then
			player.spintapready = true
			player.spintapping = false
		elseif player.spintapready then
			player.spintapping = true
			player.spintapready = false
		else
			player.spintapping = false
		end
 
		if player.canflip ~= 2 and player.jumping then
			player.canflip = 1
		end
		if P_IsObjectOnGround(player.mo) and (player.mo.eflags & MFE_ONGROUND) then
			player.canflip = 0
		end
 
		if player.spintapping and player.canflip == 1 then
			player.mo.flags2 = $1 ^^ MF2_OBJECTFLIP
			player.canflip = 2
		end
 
		if not (player.cmd.buttons & BT_CUSTOM1) then
			player.monspawntapready = true
			player.monspawntapping = false
		elseif player.monspawntapready then
			player.monspawntapping = true
			player.monspawntapready = false
		else
			player.monspawntapping = false
		end
 
		if player.monspawntapping then
			player.spawnedmonitor = P_SpawnMobj(player.mo.x+FixedMul(40*FRACUNIT, cos(player.mo.angle)),  
					player.mo.y+FixedMul(40*FRACUNIT, sin(player.mo.angle)), player.mo.z, MT_SUPERRINGBOX)
		end
 
		if player.spawnedmonitor and player.spawnedmonitor.valid then
			local xdiff = player.spawnedmonitor.x-player.mo.x
			local ydiff = player.spawnedmonitor.y-player.mo.y
			local zdiff = player.spawnedmonitor.z-player.mo.z
 
			if (player.cmd.buttons & BT_CUSTOM2) then -- Begin new lines
				player.spawnedmonitor.momx = xdiff/-16
				player.spawnedmonitor.momy = ydiff/-16
				player.spawnedmonitor.momz = zdiff/-16
			end
 
			if (player.cmd.buttons & BT_CUSTOM3) then
				player.mo.momx = xdiff/16
				player.mo.momy = ydiff/16
				player.mo.momz = zdiff/16
			end -- End new lines
		end
	end
end)

For now, we're going to do something simple, and alter some momentum values to attract the player and their ring box closer. (It's not much, but it's something.) The momx, momy and momz variables control an object's momentum along the three axes. (Note that in SRB2, Z points upward, and not Y like in most 3D games.) If you save and test this script, you now have some new abilities: press Custom Action 1 to spawn a new ring box, hold Custom Action 2 to attract the ring box toward you, and hold Custom Action 3 to attract your player toward the ring box.