A guide to writing your first ZScript project for GZDoom!
This is a big question that I think many ZScript guides don’t address - why do you need to switch to ZScript?
The truth is, if you can do everything that you want to in GZDoom already with ACS and DECORATE, you really don’t need to switch! DECORATE is still a useful feature of the engine, and it isn’t going to go away - for creating Things like scenery and monsters, it still works fine. But once you get a bit more ambitious, you’ll start running into its limitations - perhaps you need to keep track of some variables on a monster to give it a more complex behaviour than normal. You might find yourself having to assign a specific TID to the player at the start of the level so that you can refer to them from an ACS script later, or write and compile an ACS library because a pickup needs to make a call to get something that isn’t available to it in DECORATE.
It’s possible to work around all of these in the old DECORATE/ACS environments - for the first RAMP in 2021 I wrote an entire garden building minigame using them, but it was clunky and required a lot of thinking around obstacles, keeping track of TIDs and generally tricking the computer into doing it. ZScript lets you get much deeper into the details of GZDoom than the old scripting languages do - you can react to a much greater variety of things happening in the game, manipulate game elements like linedefs and sectors without having to specifically make them addressable with tags, and create classes that don’t just exist in the game world as Things but can interact with data behind the scenes as well.
ZScript isn’t easy to get into - this greater reach into the game all comes at the cost of a steep learning curve, an overwhelming number of possibilities and a much greater variety of opportunities to produce crashes. Once you get into the way it works, though, you begin to realize just how much more control you have over the game environment, and exploring its possibilities becomes as addictive as any other aspect of Doom mapping.
Like DECORATE, ZScript is written as one or more text-format lumps and is used to define objects and their behaviour. It shares much of its concept and syntax with other object-oriented programming languages such as Java and C# - going over these is a large job and I’ll try to explain the OOP concepts that you’ll need to know about as they come up, but before really diving into ZScript it might help to follow a beginner tutorial for either of those. Having a grasp of concepts like object inheritance and overridden/virtual functions will help understand what’s happening throughout ZScript, but I hope that I can give a good enough introduction to them here for them to be understandable.
ZScript suffers a bit from a lack of documentation because there’s just so much of ZScript to document - various tutorials and references for what all the components do have been written, and https://zdoom-docs.github.io/staging/ by Alison G. Watson and Fira Watson is currently my preferred reference. However, I think a lot of ZScript’s possibilities can be summed up by looking at just two concepts.
Before I got into using ZScript, the major thing putting me off was not knowing where I was supposed to start. I began with ACS and DECORATE by saying “I’d like to make this switch do something that isn’t in the standard action specials” or “I’d like to make a new kind of pickup” - I didn’t have an equivalent goal that could only be accomplished in the ZScript world. It was only after grasping the following two ideas that I realized what ZScript would allow me to do:
A Thinker is an object that can perform actions. In the DECORATE world, you’re always working with Actors, which are present as physical entities in the game - imps or teleport destinations or heads on sticks. But Thinkers exist one place above them in the object hierarchy, as objects that don’t necessarily have a presence and that can just quietly work away in the background. Instead of telling it what to do by defining a list of states as you would for a DECORATE actor, you can define a Thinker’s behaviour by writing a Tick() method for it - this will be called on every game tick automatically.
An Actor is now just one of the many types of Thinkers that you can use - actors use their Tick() method to advance through their list of states and call the functions defined in it, but other Thinkers might work very differently.
Event handlers allow you to react to things happening in the game world, without the clumsiness of having to assign tags to objects and make calls through ACS. When you add an event handler to the game, it will be notified whenever something happens that it’s set up to listen for. You could think of these in a similar way to the special ACS scripts - you could have code in an event handler for a map being loaded, or a player entering or exiting it.
However, the big difference here is that event handlers can also react to much more detailed things - a monster being damaged, a line being activated and even the player making keypresses. See https://zdoom.org/wiki/Events_and_handlers for a list of events that can be detected by these handlers - the possibilities are extensive.
A lot of ZScript's power comes from a behaviour common to both of these objects - they provide functions that you can override in your custom subclasses to insert your own logic. Anything that extends the Thinker class will have its Tick() method called 35 times per second, and an EventHandler will run functions for whatever events it's set up to listen for. Using these together, plus the access that ZScript gives you to the internals of the game world, it's possible to make some very interesting stuff.
In this tutorial we’re going to use ZScript to do something that Doom never asked for - adding a little economy of sorts so that the player can buy health and powerups. We’re also going to reward the player for their speed in killing hellspawn, and learn how to use ZScript to add a widget to the HUD to display the state of everything. To do all this, we'll be using both thinkers and event handlers to alter the level, handle input and react to events, draw to the screen and even read from elsewhere in the WAD.
One of the most difficult things about starting ZScript for me was knowing where to go to find information - therefore, throughout this tutorial I won't just talk about how to build the project, but where the functionality that I'm using is documented and how to discover more of it for yourself.
You can download the resources for this project from https://doom.teamouse.net/zstutorial/zstutorial-start.zip - it contains a couple of sprites and extras that we'll refer to in our ZScript, but you're welcome to make up your own version of any of these assets.
A lot of this tutorial will be explanations of what we're doing and how they work. Actions for you to take will be highlighted in a box with Doom's skull icon - here's your first one:
Download the resources ZIP and unzip it into a new folder. |
This will be our PK3 folder - the contents of this can be zipped into a PK3 to create a packaged mod for GZDoom. To test it, you can drag the folder to GZDoom.exe without zipping - alternatively, I prefer to use the command line, passing . as a file to indicate the current directory:
(Yes, I have my gzdoom folder in my Windows PATH - more normal people would have to enter the full path to gzdoom here instead)
From here, this tutorial will guide you through adding some ZScript elements. Good luck!
Let’s start with the most basic element of our mod and build up from there - we’re going to need an item that the player can pick up to award them points. The appearance and name of this can be up to you, but I’m going to follow the lead of Devil May Cry and make them red orbs. A basic actor pickup that extends Inventory would look like this in DECORATE:
DECORATE example of a basic DoomOrb item |
Actor DoomOrb : Inventory { Inventory.MaxAmount 999999 Inventory.Amount 1 Inventory.PickupSound "orbs/inventory/up" +BRIGHT states { Spawn: DORB A 5 DORB B 5 Loop } } |
The equivalent of this in ZScript doesn’t have anything too surprising - there is a guide to the differences between DECORATE and ZScript on the ZDoom wiki at https://zdoom.org/wiki/Converting_DECORATE_code_to_ZScript which goes into some detail, but in our case we just need to know a few things:
Create a file called ZSCRIPT in your PK3 folder and add this DoomOrb class to it. |
ZSCRIPT |
version "4.10.0" class DoomOrb : Inventory { default { Inventory.MaxAmount 999999; Inventory.Amount 1; Inventory.PickupMessage "Picked up an orb"; Inventory.PickupSound "orbs/inventory/up"; +BRIGHT; } states { Spawn: DORB A 5; DORB B 5; Loop; } } |
ZSCRIPT has to start off with a version declaration so GZDoom knows how to interpret it - at the time of writing, 4.10.0 is the latest ZSCRIPT version, and the project we're building should work in that version of GZDoom or above.
The rest of the file is very similar to the DECORATE example above - and that's all it's taken to make our first ZScript class. If you load this project up in GZDoom, enter the first level and bring down the console with the backtick (`) key, you can type the summon doomorb command to create one in front of the player. It will blink away to itself happily until you walk over it to pick it up.
If you're not familiar with GZDoom's ecosystem already, it's worth mentioning that "Inventory" here means something slightly different from in most Doom-engine games. In things like Heretic and Hexen, inventory items are specifically things that you can pick up, scroll through and then use later - for example the famous Tomes of Power or the other magic items that you can carry with you. In GZDoom, "inventory" refers to everything that the player owns - weapons, ammunition as well as usable inventory items are included here. These items won't appear in a Hexen-style inventory - they're just tokens that get put in the player's inventory behind the scenes.
So that's a simple DECORATE-style inventory object that we've just ported straight to ZScript, but now we come to the interesting part where we can use ZScript to alter its logic in ways that were off limits to us in DECORATE. We don't want these orbs to hang around forever - if they're not picked up within a certain time, we'll make them eventually vanish.
In DECORATE, you might do this by trying to construct a limited loop of states by remembering the various rules you have to adhere to with user variables and how to manipulate them in anonymous functions - or if you're like me, just give up on that and write a ton of ABABABAB over and over to make enough states to last the duration I want. ZScript allows us a much more elegant way to do this: writing a Tick() function for the object.
The Tick() function is the heart of a ZScript class's logic - every time the game updates, i.e. "on every tick", this function will be called on all objects so they can decide what they need to do on this frame. The Tick() function exists in the Thinker parent class, and for Actors, it handles all the standard stuff like moving the object through its list of states and so on. In our DoomOrb, we want to do that and then add some logic of our own.
Writing this part is exactly the same as writing a function for an object in C# or Java - we want to add a variable that each instance of this class can use to keep track of how long it's been around, and then increment that on each tick until it's time to remove it.
Add an instance variable and a Tick() function to our DoomOrb: |
ZSCRIPT |
class DoomOrb : Inventory { int age; default {...} states {...} override void Tick() { super.Tick(); age++; if (age > 350) { self.Destroy(); } } } |
The logic here is simple enough - add 1 to the 'age' instance variable on each tick, and if it's now over 350, remove the object from the game by calling its Destroy() function. There are 35 ticks per second in the GZDoom world, so this object should last ten seconds before disappearing.
The keyword "self" here is used to refer to the object that's running this code, the same way as C# and Java would use "this" - https://zdoom.org/wiki/ZScript_special_words shows a list of keywords like this. The Destroy() function exists for all ZScript objects, and marks the object for deletion - it's defined in the Object class at the very top of the hierarchy. https://zdoom-docs.github.io/staging/Api/Base/Object.html#instance-methods
Another thing that's different from some other object-oriented languages is that ZScript doesn't allow you to specify a default value for instance variables. This might cause you some concern if you're coming from C++ where this would result in just getting whatever happens to be in memory at the time, but not to worry - an int will always be initialized to 0.
Lastly, the call to super.Tick() at the top is very important - this will call the Tick() function in the Actor parent class so that the actor actually performs the logic that's in there as well as all of our added stuff. Without this call, the object will be awkwardly frozen in time - try removing it if you like, and you'll see the object isn't affected by gravity and doesn't animate because it isn't advancing through its states.
Try opening this project in GZDoom again, and this time (as long as the call to super.Tick() is intact) when you enter the command summon doomorb, you should see it vanish after ten seconds.
Try summoning a few of these objects and picking them up. If you bring down the console and type printinv afterwards, you should see them being listed in your inventory… but what's happening? After a few seconds, you might not have as many in your inventory as you thought you had picked up.
We can make what's happening clear by adding some logging to our function. You might be familiar with Log() and A_Log() - in ZScript, we can print a message to the console by using the console.printf() function.
Add a line to log when a DoomOrb has aged enough to be destroyed. |
ZSCRIPT |
… override void Tick() { super.Tick(); age++; if (age > 350) { console.printf("Destroying a %s", self.getClassName()); self.Destroy(); } } … |
console.printf is a very useful function that's available from anywhere. It's like the function of the same name in C, and it works a bit like an easier-to-parse Log function from ACS - you specify a string to log with placeholders for values, followed by the values to go into the string. In this case we're using %s in our output string to tell printf that it should expect a string to go into that slot, and we're then telling it to replace that %s with the result of self.getClassName() (which will be "DoomOrb" or whatever you called this class).
The available substitutions are listed on the ZDoom wiki at https://zdoom.org/wiki/String#Methods .
Now run the game again and do the same thing, summoning and picking up the DoomOrbs. Soon you'll notice the problem - the DoomOrb objects still exist the same way as they did in the game world when they're in the player's inventory, and so they will continue to age and be destroyed!
To prevent this, we'll add a new condition to our Tick() function that makes the object avoid aging if it's in the inventory of a player. We can detect this by checking if the object has an owner.
Add a check for the DoomOrb having been picked up, and stop it aging. We can also remove the console.printf() line. |
ZSCRIPT |
… override void Tick() { super.Tick(); if (self.owner) {return;} age++; if (age > 180) { console.printf("Destroying a %s", self.getClassName()); self.Destroy(); } } … |
Here, we're testing if this object's owner is anything - if it's non-null, then return early only having performed the superclass's Tick() function and ignoring all the aging logic. If you test this now, you should see the orbs in your inventory staying around as you'd expect.
Now that our orb object works, let's get enemies to drop some for the player to collect when they die. In the DECORATE world, we would at this point have to write a new subclass of every monster and alter their drop chances or Death states to include the commands to create new orbs. But because we're in ZScript, we can write an event handler which will be called automatically whenever a monster death occurs, and add new actions in that event instead of having to alter the object itself.
https://zdoom.org/wiki/Events_and_handlers shows us that we can use the WorldThingDied event to react when a Thing reaches zero health, so let's implement an EventHandler with that event now.
Add another class below the DoomOrb in our ZSCRIPT file - this one is our event handler. |
ZSCRIPT |
version "4.10.0" class DoomOrb : Inventory {...} class MonsterDeathEventHandler : EventHandler { override void WorldThingDied(WorldEvent e) { console.printf("Monster %s was killed", e.Thing.getClassName()); } } |
This new class extends EventHandler, and implements the WorldThingDied function. Event handler functions are given an event "e", and the the ZDoom wiki page on event handlers tells us that in this particular event, we will have access to the actor that died (referred to as Thing) and the actor that inflicted the damage that caused the death (Inflictor).
At the moment, this just does the same thing as we did above for the DoomOrb - it will log a message with the class name of the actor that died (e.Thing.getClassName()).
Before we can test this, we need to do one more thing. GZDoom has the option to make an event handler apply either globally or just in specific maps, so it needs to be told where it should put ours. We want it to be called throughout the whole game, so we'll specify that in a gameinfo section in the MAPINFO lump.
Create a MAPINFO lump in the root of the project and specify that our event handler should be loaded. |
MAPINFO |
GameInfo { AddEventHandlers = "MonsterDeathEventHandler" } |
Try the project out now and you should see a message coming up whenever a monster is killed, with the message changing depending on the monster type.
Now instead of just logging the class name, we'll get two other pieces of information out of the dying actor so that we can create a DoomOrb at the appropriate place - you can see these properties on ZDoom-docs' page about the Actor class: https://zdoom-docs.github.io/staging/Api/Base/Actor.html?highlight=pos#position
A vector3, by the way, is a three-dimensional vector. This is a collection of three coordinates, written as three numbers or variables in parentheses - for example (x, y, z) or (17, 23, 24). In ACS, coordinates often had to be expressed in unwieldy ways, using separate variables and parameters for each one like in A_SpawnItemEx below - using vectors like this lets us wrap them up in a way that's much less verbose.
bool, Actor A_SpawnItemEx (class<Actor> missile [, double xofs [, double yofs [, double zofs [, double xvel [, double yvel [, double zvel [, double angle [, int flags [, int failchance [, int tid]]]]]]]]]]) |
Having got the position and the height of the dying monster, we'll use the Actor.Spawn() function (see https://zdoom.org/wiki/Spawn_(ZScript)) to create a DoomOrb actor. This function is a lot less clunky than the large library of slightly-differently-named ACS ones, and simply takes a class name of the actor you want to spawn, and a vector3 representing the world position at which to spawn it. By specifying (pos.x, pos.y, pos.z + (height/2)) as our vector3, we'll use the original X and Y positions from the monsters' pos vector directly, and add half the height of the monster to the z-coordinate (therefore moving the spawn point up off the floor).
Change the WorldThingDied function to get the position of the dying actor and create a DoomOrb halfway up its height. |
ZSCRIPT |
class DoomOrb : Inventory {...} class MonsterDeathEventHandler : EventHandler { override void WorldThingDied(WorldEvent e) { console.printf("Monster %s was killed", e.Thing.getClassName()); vector3 pos = e.Thing.pos; double height = e.Thing.height; Actor.Spawn("DoomOrb", (pos.x, pos.y, pos.z + (height/2))); } } |
With the MonsterDeathEventHandler now active, you should be able to run the game and see monsters dropping the DoomOrbs when they're killed.
This all works nicely, but it would be good to have a count of how many orbs we've picked up visible on the screen instead of having to duck into the console every time we want to check. Let's add a basic counter to the HUD.
In the ZScript world, the process of drawing things to the screen is also handled by EventHandlers. This time, we're going to create one to listen for the RenderOverlay event, which is called on every frame when GZDoom is ready to draw HUD elements on top of the rendered game world.
(By the way, there's nothing saying that we couldn't just put the monster death events and UI drawing into one single event handler and have it handle both the cases by implementing both WorldThingDied and RenderOverlay. But I find it easier on the brain to make separate event handlers for different things.)
Add another event handler to our ZSCRIPT file, and add it to the event handlers to be called throughout the game. |
ZSCRIPT |
version "4.10.0" class DoomOrb : Inventory {...} class MonsterDeathEventHandler : EventHandler {...} class OrbUIHandler : EventHandler { override void RenderOverlay(RenderEvent e) { PlayerPawn p = PlayerPawn(players[consoleplayer].mo); int orbs = p.CountInv("DoomOrb"); Screen.DrawText(smallfont, Font.CR_RED, 4, 4, orbs); } } |
MAPINFO |
GameInfo { AddEventHandlers = "MonsterDeathEventHandler", "OrbUIHandler" } |
For this function, we need to know which player we're drawing the HUD for so we can retrieve the number of red orbs from their inventory. Players can always be retrieved from the global array called "players", as listed in the globals page at https://zdoom-docs.github.io/staging/Api/Globals.html?highlight=players#players, and the number of the current player is also available as consoleplayer. The players array stores a collection of PlayerInfo objects, which describe the state of players in the game - at the time of writing the documentation for them hasn't been finished yet, but the properties on them can be found in the zscript source code at https://github.com/ZDoom/gzdoom/blob/master/wadsrc/static/zscript/actors/player/player.zs#L2680 .
A PlayerInfo has a reference to its corresponding PlayerPawn object under the property called "mo". The difference is rather difficult to get your head round and I couldn't fully describe it myself, but the PlayerPawn is the object that moves about and represents the player's presence in the level, while PlayerInfo stores more general information about the player (how many secrets they've discovered, their FOV preferences, and so on). The PlayerPawn is the one with the inventory - so in total, the first line of this function has to:
players[consoleplayer]
players[consoleplayer].mo
PlayerPawn p = PlayerPawn(players[consoleplayer].mo)
We then call CountInv() on the PlayerPawn to count how many DoomOrbs our current player is carrying - this is one of the ZScript actor functions listed on the ZDoom wiki at https://zdoom.org/wiki/CountInv.
Having got that count, we can now write it on the screen with the Screen.DrawText function documented at https://zdoom.org/wiki/DrawText . This is more or less the ZScript equivalent of HudMessage - it's a quick way of putting text on to the HUD with minimal options. Our call here uses the parameters:
Start up GZDoom again with this in place, and you'll see… it fails to start up. Why?
It turns out ZScript is a bit picky about types of variables - our orbs variable is a number and not a string. While we as humans might think it fairly obvious how to print a number as a string, the computer won't do it unless we've specifically asked it to - so let's do that now.
There are a couple of ways to do this. One possibility is to use the String.Format function - this behaves a lot like console.printf() does, taking a string with a set of placeholders and then a list of variables to swap in. To convert our number to a string without further embellishments, you could use the %d placeholder to represent a number (to remember this I've always mentally called it "decimal" but I may be totally wrong):
String.Format("%d", orbs)
You could also use this to add some text with the count, such as:
String.Format("Orbs: %d", orbs)
Another way to convert a number to a string, which I find rather easier even if it feels less official, is to concatenate an empty string on to it - this will convert it to a string automatically. In ZScript, the concatenation operator is two dots, .. - therefore, this will force the int orbs to be treated as a string:
orbs .. ""
Choose your preferred method and alter the Screen.DrawText line to use it. |
ZSCRIPT |
… Screen.DrawText(smallfont, Font.CR_RED, 4, 4, orbs .. ""); … |
Starting up GZDoom will now give you the counter in the corner. Depending on your resolution it might be a bit hard to see… so let's add some more parameters to our call to fix that.
According to https://zdoom.org/wiki/DrawText, the DrawText function can take some more parameters after the string to display which it calls "tags", arranged in pairs with [the name of the option], [the value for the option]. Like SetHudSize() in ACS, the DTA_VirtualHeight and DTA_VirtualWidth options let us say that we want the string scaled and printed as if the screen were the size we specify - so let's reduce that from whatever ungodly resolution we have on a 2020s PC to a more reasonable 640x480.
ZSCRIPT |
… Screen.DrawText(smallfont, Font.CR_RED, 4, 4, orbs .. "", DTA_VirtualWidth, 640, DTA_VirtualHeight, 480); … |
You can play about with the parameters now to adjust the counter however you'd prefer.
So in just a few classes, we've made a good start to ZScript and done some things familiar to DECORATE/ACS in a more convenient way already - we have a powerup that times out, have got monsters to drop it, and we have an onscreen counter for them.
We've got some basic ZSCRIPT building blocks working now - let's revisit them and polish them up a bit.
It would be nice to give the player a warning before the orbs disappear, to encourage them to hurry up and get it. Let's make them blink a bit more urgently for a while before they vanish.
Add a new state to the DoomOrb class - we'll make the object jump to here when it's about to disappear. |
ZSCRIPT |
class DoomOrb : Inventory { int age;
default {...} states { Spawn: DORB A 5; DORB B 5; Loop; Decaying: DORB A 2; DORB B 2; TNT1 A 2; Loop; } override void Tick() {...} } |
This will cycle between the two orb frames more rapidly as well as the TNT1 A frame (which, for historical reasons, is the name for a frame with no sprite).
Now we need to work out a way of checking the age variable at some point during the Spawn loop. All the DECORATE functions can still be used in the state list here, so using some sort of A_JumpIf() could work… but let's write our own action function instead, because that's something we can do now in ZScript!
Add an action function called checkAge() to the DoomOrb class. |
ZSCRIPT |
class DoomOrb : Inventory { int age;
default {...} states {...} override void Tick() {...} action void checkAge() { if (invoker.age > 250) { invoker.SetState(ResolveState("Decaying")); } } } |
Action functions are specified slightly differently from other functions - they're specified with the keyword "action", which gives them special properties that allow them to distinguish between the actor that's calling them and the class that the function actually belongs to. This is important for situations like when a player is calling an action on an item that's in their inventory - in this case we don't have to worry about this possibility because the action is always called by the inventory object itself, but we have to be aware of the difference it makes to how we refer to objects. Inside action functions, the object affected by the action is now known as "invoker" instead of "self" - so we check the age property of the invoker, and potentially tell it to jump to the state label "Decaying".
Let's now call this new action function on our second state under the Spawn label, by specifying it after the state length in the same way as you would any other action function:
Make the DoomOrb call checkAge() during its Spawn loop. |
ZSCRIPT |
class DoomOrb : Inventory { int age;
default {...} states { Spawn: DORB A 5; DORB B 5 checkAge(); Loop; Decaying: DORB A 2; DORB B 2; TNT1 A 2; Loop; } override void Tick() {...} action void checkAge() {...} } |
Now, when monsters drop orbs, you should be able to see them start blinking after 250 ticks (about 7 seconds) before eventually disappearing.
Pickups mean dopamine. What's the best way to get more dopamine? More pickups! Besides, having all the monsters drop one orb doesn't sound right - it feels like bursting a giant cacodemon should be worth more than knocking a plasticine-skinned zombie over with a feather. Let's say that instead of just one each, a monster should drop an orb for every ten health points it had before you came along.
We'll need to run a loop in our MonsterDeathEventHandler this time, and this is done in much the same way as other C-styled languages - to get how many times to run that loop, let's look at the documentation for actors again.
https://zdoom-docs.github.io/staging/Api/Base/Actor.html?highlight=getmaxhealth#virtuals - This one looks good. Let's try that out.
Add a loop to MonsterDeathEventHandler so that it drops one orb for every 10 health points out of the GetMaxHealth() of the dying monster. |
ZSCRIPT |
class MonsterDeathEventHandler : EventHandler { override void WorldThingDied(WorldEvent e) { vector3 pos = e.Thing.pos; double height = e.Thing.height; int maxHealth = e.Thing.getMaxHealth(); for (int i = 0; i < maxHealth; i += 10) { Actor.Spawn("DoomOrb", (pos.x, pos.y, pos.z + (height/2))); } } } |
If you play this now, you'll notice… nothing much, really - the monsters still seem to drop one orb. But if you run over and pick them up, you'll see the counter incrementing more than it used to - the new orbs are being dropped, but they're all in the same place!
Let's distribute them a bit better. Unlike ACS, we don't have to find a function that will let us create an object in a very specific way that lets us alter its position or velocity - if we keep a reference to the object that was created, we can alter its X, Y and Z velocities directly!
Add a random element to the velocities of the DoomOrb when it's spawned. |
ZSCRIPT |
class MonsterDeathEventHandler : EventHandler { override void WorldThingDied(WorldEvent e) { vector3 pos = e.Thing.pos; double height = e.Thing.height; int maxHealth = e.Thing.getMaxHealth(); for (int i = 0; i < maxHealth; i += 10) { let spawnedActor = Actor.Spawn("DoomOrb", (pos.x, pos.y, pos.z + (height/2))); spawnedActor.vel.X = frandom(-3, 3); spawnedActor.vel.Y = frandom(-3, 3); spawnedActor.vel.Z = frandom(5, 10); } } } |
frandom() will return a random fixed-point (non-integer) value between the two given bounds - so each orb created now will be thrown at a velocity between 5 and 10 up into the air, in a random horizontal direction that can have a velocity of -3 to 3 along each of the X and Y axes. And if you're unfamiliar with the let keyword, it allows us to declare a variable that doesn't really care about its type - it'll just be whatever type was assigned to it, in this case the DoomOrb returned by the spawn function.
Velocity, like an object's pos, is a vector3 - I've broken the three dimensions of it out here and assigned them separately because I think the code looks clearer that way, but you could equally write:
spawnedActor.vel = (frandom(-3, 3), frandom(-3, 3), frandom(5, 10));
Try this out again and you'll see the effect is much more satisfying.
Let's get more ambitious. Try calling a cyberdemon into the level with summon cyberdemon, then align it in the middle of the screen and use the console command mdk to deal a million hit points of damage to it.
Hmm. Actually that's a bit too much dopamine - maybe we should add a special case for when a monster has a very high number of hit points. Let's add a bigger variety of orb that awards more points.
Add another class to our ZScript file that extends the DoomOrb class. |
ZSCRIPT |
class DoomOrbBig : DoomOrb { default { Inventory.PickupMessage "Picked up a big orb!"; Scale 2; }
override void AttachToOwner(Actor owner) { owner.GiveInventory("DoomOrb", 10); self.Destroy(); } } |
Again, there's a bit of a trick here. This class extends the DoomOrb class so it will inherit the functions and states that we've already specified - but we need to add one more thing this time and implement an AttachToOwner function. This is one of the virtual actor functions listed on https://zdoom.org/wiki/ZScript_virtual_functions#Inventory, which behave a lot like the functions that are available to event handlers - they're called when ZDoom detects something has happened to the object. In this case, AttachToOwner is called when the item is picked up by another actor.
On pickup, we want the player not to actually obtain the DoomOrbBig object, because then they would have a separate count of DoomOrbs and DoomOrbBigs in their inventory - instead, we tell this variant of the orb to add ten DoomOrbs to its owner's stash and then to destroy itself, therefore not staying in the inventory.
Having specified our new orb that's worth ten of the smaller ones, we need to rewrite our MonsterDeathEventHandler's WorldThingDied function so that it uses as many of both of these orb types as it needs to get to the monster's MaxHealth. With what you've learned about ZScript so far, try doing this on your own before looking at the code below - there are many ways to do this and this is just one example.
Rewrite our MonsterDeathEventHandler to create as many of the big orbs as it can before going to the smaller ones. |
ZSCRIPT |
class MonsterDeathEventHandler : EventHandler { override void WorldThingDied(WorldEvent e) { vector3 pos = e.Thing.pos; double height = e.Thing.height; pos.z += height/2; int maxHealth = e.Thing.getMaxHealth(); while (maxHealth >= 100) { maxHealth -= 100; self.createOrb(true, pos); } while (maxHealth >= 10) { maxHealth -= 10; self.createOrb(false, pos); } }
void createOrb(bool big, vector3 pos) { String className = "DoomOrb"; if (big) { className = "DoomOrbBig"; } let spawnedActor = Actor.Spawn(className, pos); spawnedActor.vel = (frandom(-3, 3), frandom(-3, 3), frandom(5, 10)); } } |
In this example I've streamlined a few things from the function as it was before. This one works like this:
Test the project again and make sure that your WorldThingDied function is working as expected - use console.printf() to help out if you need it.
While we're making revisions, let's improve our custom HUD piece a bit and make it more than just a number slapped on the screen. We're going to add a red orb icon beside it to make it clearer what it's for.
https://zdoom.org/wiki/DrawTexture looks like exactly what we need - it's the equivalent of DrawText but for graphics. However, if you try adding something like this to our RenderOverlay method in the OrbUIHandler, you'll discover it doesn't work:
Try this out: |
ZSCRIPT |
class OrbUIHandler : EventHandler { override void RenderOverlay(RenderEvent e) { PlayerPawn p = PlayerPawn(players[consoleplayer].mo); int orbs = p.CountInv("DoomOrb"); Screen.DrawText(smallfont, Font.CR_RED, 4, 4, orbs); Screen.DrawTexture("DORBA0", false, 4, 4, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480); } } |
Like the error message says, it can't work out how to get a texture from the string "DORBA0" directly. This is because unlike the ACS world, you can't specify graphics as just lump names any more. ZScript works with TextureIDs, which need another step to look them up - we'll have to ask the texture manager about it.
The texture manager can be called up from anywhere under the name TexMan - https://zdoom-docs.github.io/staging/Api/Drawing/TexMan.html . This is the object that handles the graphics available to the game - in yet another confusing redefinition of the word "texture", it refers here not exclusively to wall textures like most of the Doom engine does but to any kind of graphic including sprites and general graphics like TITLEPIC. We'll use its CheckForTexture method to retrieve the first DoomOrb sprite, then pass it to the DrawTexture function.
Use TexMan to look up the TextureID we want for the display, then pass that to DrawTexture. |
ZSCRIPT |
class OrbUIHandler : EventHandler { override void RenderOverlay(RenderEvent e) { PlayerPawn p = PlayerPawn(players[consoleplayer].mo); int orbs = p.CountInv("DoomOrb"); Screen.DrawText(smallfont, Font.CR_RED, 4, 4, orbs .. "", DTA_VirtualWidth, 640, DTA_VirtualHeight, 480); Screen.DrawText(smallfont, Font.CR_RED, 20, 8, orbs .. "", DTA_VirtualWidth, 640, DTA_VirtualHeight, 480); TextureID orbTex = TexMan.CheckForTexture("DORBA0", TexMan.Type_Sprite); Screen.DrawTexture(orbTex, false, 4, 4, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480, DTA_LeftOffset, 0, DTA_TopOffset, 0); } } |
Here, TexMan's CheckForTexture function takes the lump name for the graphic we want to find, and a type that it's expecting to look for - the options for these are shown in https://zdoom.org/gitview/3a1228b/wadsrc/static/zscript/base.txt . You could specify TexMan.Type_All here to just search through everything, but giving it a clue can narrow down and therefore speed up its search.
The DrawTexture function takes parameters in the same way as DrawText does - here, I'm passing the same virtual width and height values into it, as well as two more - setting the DTA_LeftOffset and DTA_TopOffset to 0 will ignore the grAb offsets that have been specified on the sprite, and just displays the sprite starting at its top left corner. (As a result the function call can look rather unwieldy, and if you have a lot of these it might be better to write your own function to wrap the call.)
Note also that I've moved the text counter over to the right and down a little to align it with the position of the orb, changing the coordinates from (4, 4) to (20, 8) - adjust it to your own preference. You could even add another graphic specifically for the UI and use that. (If you go with something like this, remember that the UI elements are drawn on the screen in the order you specify them, from back to front - so you'll need to move the line that displays the text below the line that displays the graphic.)
We've defined our new orb object and its behaviour, we've got monsters to drop them according to their general toughness, we've got a count of them on screen - let's make them do something useful. How about letting the player heal themselves with a button press when they've collected enough of them?
Another thing that ZScript can do is access the game input, so you can react to button presses (this is possible in mods based on DECORATE/ACS as well, but it can be quite clumsy to set up). Just like the other elements we've coded so far, input in ZScript is handled via listeners.
We'll put a new function into our OrbUIHandler class. https://zdoom.org/wiki/Events_and_handlers tells us the InputProcess function is called whenever there's input to be handled, and that a healthy six pieces of data are passed in the event. We want to do two things:
We're not going to look for specific keyboard keypresses directly because we don't know what the player's set the USER1 key(s) up to be (and in this context, a "key" could be a keyboard key, a mouse button or a joystick trigger) - so we're going to ask the Bindings global for it. This provides a GetKeysForCommand function, which can give us the keys (of which there can be up to two) that the player has set up for the command we want to detect. Then we're going to check if the key mentioned in the input event matches the keys we've been given - if so, we'll perform our action. For now, we'll do what we did before and just make the action to print something out so that we know it worked.
(Unfortunately, no documentation for the Bindings object is available at the moment - let's just implement an example of how it's used…)
Add an InputProcess function to our OrbUIHandler that just detects the key: |
ZSCRIPT |
class OrbUIHandler : EventHandler { override void RenderOverlay(RenderEvent e) {...} override bool InputProcess(InputEvent e) { int bind1, bind2; [bind1, bind2] = Bindings.GetKeysForCommand("+user1"); if (e.Type == InputEvent.Type_KeyDown && (e.KeyScan == bind1 || e.KeyScan == bind2)) { console.printf("You have pressed key %d for USER1", e.KeyScan); } return false; } } |
Here, we're assigning values to two ints at once - bind1 and bind2. GetKeysForCommand always returns two items because the controls screen allows for two keys to be bound to one command. They're returned as ints that uniquely identify the pressed key, and not as strings like "Q" or "7" - and these ints will correspond to the value given in e.KeyScan. We also want to check that the event type is a keydown event - the action should happen only once per keypress.
Also note that unlike every other event function that we've used so far, this one returns a boolean value! The function that calls InputProcess looks for this to decide whether to continue calling other event handlers for this event. If true is returned, then the game will consider the input event as handled and won't ask anything else to do anything about it. This is useful in situations like a modal dialogue box or inventory screen, where you want to use the player movement keys to navigate a cursor around but also to block the player from reacting to those same inputs if the dialogue box is active. For an example, look at the ConversationMenu class's MenuEvent function, which will return true if the keypress was one that the menu understood and reacted to. https://github.com/ZDoom/gzdoom/blob/master/wadsrc/static/zscript/ui/menu/conversationmenu.zs#L308
Load up the project again and make sure you have one or more keys bound to user1 (Weapons/Weapon State 1 in the controls menu) - you should then be able to get this message to appear by pressing them.
With all that working, it's simple enough to write our real action - we've used all these elements before, and we just need to retrieve the player and fiddle with their inventory a bit. While we're at it, let's add a sound effect as well - functions from DECORATE like A_StartSound still exist in ZScript, and can be used on the player like the other functions. misc/p_pkup is the sound defined in gzdoom.pk3's SNDINFO to mean the gong-like powerup sound.
When the USER1 key is pressed and the player has sufficient orbs, recharge some health. |
ZSCRIPT |
class OrbUIHandler : EventHandler { override void RenderOverlay(RenderEvent e) {...} override bool InputProcess(InputEvent e) { int bind1, bind2; [bind1, bind2] = Bindings.GetKeysForCommand("+user1"); if (e.Type == InputEvent.Type_KeyDown && (e.KeyScan == bind1 || e.KeyScan == bind2)) { console.printf("You have pressed key %d for USER1", e.KeyScan); PlayerPawn p = PlayerPawn(players[consoleplayer].mo); int orbs = p.CountInv("DoomOrb"); if (orbs >= 20) { p.GiveInventory("HealthBonus", 10); p.TakeInventory("DoomOrb", 20); p.A_StartSound("misc/p_pkup"); } } return false; } } |
We're giving the player HealthBonus items here so that it can boost their health up to 200 - if we just gave one stimpack, it would top out at 100. (You can, of course, decide on the quantities of orbs required and health or other item(s) to give back yourself.)
So let's run and test this…
What! Why not?!
You've now encountered the concept of scope in ZScript, as described in https://zdoom.org/wiki/Object_scopes_and_versions . This is something that I hadn't encountered in the coding world I live in at work which is all dull backend business software, but it's an important thing to get your head around when you're dealing with a game world.
Objects in GZDoom can be assigned one of three scopes (or "contexts", as said in the error message - I prefer this word, but the wiki is fairly insistent that it's wrong). The scopes are play, ui and data. They exist to make sure that the state of the game stays consistent, and that you're not modifying any data where you shouldn't be and making the game unstable (there are plenty of other ways to produce crashes instead).
In our case, the objects that make up the game world are said to be in the play scope, and when we're in event handler functions that deal with user interface concerns like drawing things to the screen or handling input, we are in the ui scope. From the ui scope, you can read freely from the play scope, but you cannot modify it - only other things in the play scope can do that.
The section on networking in https://zdoom.org/wiki/Events_and_handlers#Networking describes what to do - instead of trying to alter the player's inventory directly because of a UI event, we're going to send out an event of our own that tells the game that we want something to happen.
Instead of doing the check and altering the player's inventory here, use the EventHandler's SendNetworkEvent function to notify that an orb recharge has been requested. |
ZSCRIPT |
… PlayerPawn p = PlayerPawn(players[consoleplayer].mo); int orbs = p.CountInv("DoomOrb"); if (orbs >= 20) { p.GiveInventory("Health", 10); p.TakeInventory("DoomOrb", 20); } EventHandler.SendNetworkEvent("orbRecharge"); … |
Now we need to set up the other side, by writing a function that will listen for events and react to this one. NetworkProcess is another function that's available to EventHandlers - I like to put this into the UI handler if it's dealing with UI events, but you can choose whether to create a new class for it or not.
Create the NetworkProcess function to check for and react to the event. |
ZSCRIPT |
class OrbUIHandler : EventHandler { override void RenderOverlay(RenderEvent e) {...} override bool InputProcess(InputEvent e) {...} override void NetworkProcess(ConsoleEvent e) { if (e.Name == "orbRecharge") { PlayerPawn p = PlayerPawn(players[e.player].mo); int orbs = p.CountInv("DoomOrb"); if (orbs >= 20) { p.TakeInventory("DoomOrb", 20); p.GiveInventory("HealthBonus", 10); p.A_StartSound("misc/p_pkup"); } } } } |
Note that the way that we retrieve the PlayerPawn is a little different here! This time, the event has a property player that tells us the number of the player that activated this event - we only want to recharge the health of the player that requested it. In multiplayer Doom, each instance of the game is running its own playsim, and they need to apply the code that's coming up to the player matching the number in the event - if we used consoleplayer like we did before, then whenever the orbRecharge event was activated by any player, each player would see their own health being affected.
Having attempted to explain that, the rest of the code is straightforward - we count the number of DoomOrbs that the player has, and if it's greater or equal to the price of 20, we take our payment and award them some health back.
When you start this up, you should now find that you can press the USER1 key to swap orbs for health, provided you have 20 of them or more.
This is how events are passed from the UI to the game in ZScript. The distinction between the two environments isn't very clear from down here in our event handler, but they're defined this way in the base StaticEventHandler class - by default the class and its elements are in the play scope, but any function defined with the "ui" keyword is considered in the UI scope. (We'll be using this technique for ourselves later on.)
While it may seem awkward at first, the separation also makes things testable - you can also send an event that will be picked up by NetworkProcess functions by using the netevent console command, like using puke for ACS.
netevent orbRecharge
Note that unlike almost everything else in the GZDoom world, the names of the network events are case-sensitive.
Right, now that the player can take care of their health with the orbs, let's go power-mad and remove all the health items from the level. In DECORATE, we might do this by replacing the health items with a new Thing class that does nothing, because there's no way to address individual Things in a level that aren't tagged as anything. ZScript, however, gives us this power!
We're going to use a ThinkerIterator as described in https://zdoom.org/wiki/ThinkerIterator. This is an object that will search through the Thinkers that exist in the game for us - by using a loop to call its Next() function repeatedly, it will give us back each Thinker it found and we can decide what to do with it.
Let's add one more event handler to the game - this one's going to take care of setting up the level.
Add a third EventHandler to the PK3. Remember to add it to the list of event handlers in MAPINFO as well. |
ZSCRIPT |
class OrbSetupEventHandler : EventHandler { override void WorldLoaded(WorldEvent e) { ThinkerIterator it = ThinkerIterator.Create("Health", Thinker.STAT_DEFAULT); Health item; while (item = Health(it.Next())) { console.printf("Destroyed a %s", item.getClassName()); item.Destroy(); } } } |
The first argument passed to the Create function is the class to start searching from - we're interested in the GZDoom class https://zdoom.org/wiki/Classes:Health and all its descendants. In Doom, this will include Medikits, Stimpacks, Soulspheres and the HealthBonus potion bottles. (Note that Megasphere exists under the Powerup class and not Health - we'll leave this one alone for now to avoid complicating things.) The second argument is a StatNum, and these are a way GZDoom uses internally to group Thinkers together - you can see the list of them at https://zdoom.org/wiki/Thinker .
We could leave this second argument out and have the iterator just search through all the Thinkers in the game, but it's nice to be as efficient as we can - passing STAT_DEFAULT tells the iterator only to consider in-game Actors (and custom Thinkers that are defined as using that StatNum).
The thinker iterator is now ready to be used. We set up a Health variable which contains nothing to start with, then use a while loop to repeatedly put the iterator's Next() result into that variable as long as it hasn't run out. In the loop, we output a console line for debugging just to show that we're doing something, then destroy the selected item. After the last item found by the Iterator has been gone through, the Next() function will return null, and the loop will end.
Start the game up with this event handler included, and verify that you get a set of messages about destroying the health items when starting a level - after that, you can remove the console.printf line. Now we have a variant of Doom where health is a resource from monsters instead of being picked up in the level!
For the next stage of the project, we're going to set up a class that lives in the background and handles an aspect of our game - remember that unlike in DECORATE, the classes that we define don't have to have a physical presence in the level. The idea will be to reward rapid monster kills by gradually increasing a multiplier that affects how many orbs are dropped. To reward cooperation (but also to demonstrate another aspect of ZScript), this multiplier will be global across all players, with each player's kills counting towards the total.
In the ACS/DECORATE world, you could sort of implement things like this by using global variables and an infinite loop running in the background. ZScript lets us do this more neatly - this example is lifted almost directly from the global variables page of the ZDoom wiki at https://zdoom.org/wiki/ZScript_global_variables :
Add yet another new class to our ZSCRIPT file - a non-physical Thinker that will just control the score multiplier. |
ZSCRIPT |
class OrbMultiplierThinker : Thinker { double orbMultiplier; int ticksSinceKill;
OrbMultiplierThinker Init(void) { ChangeStatNum(STAT_USER); self.orbMultiplier = 1; return self; } static OrbMultiplierThinker GetInstance(void) { ThinkerIterator it = ThinkerIterator.Create("OrbMultiplierThinker", STAT_USER); let p = OrbMultiplierThinker(it.Next()); if (p) return p; return new("OrbMultiplierThinker").Init(); } } |
The two functions here work to make sure we only ever have one OrbMultiplierThinker in the game:
If you've done object-oriented programming work before, you'll have recognized this as a singleton pattern - just with a couple of extra steps, as ZScript doesn't currently support class/static variables. We get around that by using the ThinkerIterator to retrieve the singleton.
If you haven't done object-oriented programming before, notice the static keyword on the GetInstance function. This means this function is not tied to a specific instance of the OrbMultiplierThinker - it will be callable from anywhere and not just when you already know which specific OrbMultiplierThinker you'll be referring to (that's its job - to find and return the actual live OrbMultiplierThinker object). As I'm doing a terrible job explaining this, referring to https://www.codejava.net/java-core/the-java-language/what-is-static-method-in-java or any introductory OOP course will help.
Now let's do something with this class. We're going to implement a way for another class to increase the multiplier, and a Tick function to bring it down again.
Add these functions to the OrbMultiplierThinker. |
ZSCRIPT |
class OrbMultiplierThinker : Thinker { double orbMultiplier; int ticksSinceKill;
OrbMultiplierThinker Init(void) {...} static OrbMultiplierThinker GetInstance(void) {...} void RaiseMultiplier() { self.ticksSinceKill = 0; self.orbMultiplier += 0.1; console.printf("Raised multiplier to %f", self.orbMultiplier); }
override void Tick() { if (self.orbMultiplier > 1) { self.ticksSinceKill += 1; if (self.ticksSinceKill > 350) { self.ticksSinceKill = 0; self.orbMultiplier = 1; console.printf("Reset multiplier"); } } } } |
When called, RaiseMultiplier will raise the multiplier by 0.1, reset the ticksSinceKill to 0, and as usual, print a console message to tell us what's happening. The Tick function works the same way as on the Doom orb, being called on every tick and gradually increasing a counter - for this object, if the orb multiplier is above 1, we'll start counting ticks until we reach over 350. When that happens, we'll reset the multiplier back to 1 and print a message acknowledging we've done so, therefore giving the player a time limit if they want to keep their multiplier going.
Let's now hook this multiplier thinker into the game by getting our monster death event handler to update it when a monster is killed.
Go back to our MonsterDeathEventHandler and get it to interact with the OrbMultiplierThinker. |
ZSCRIPT |
class MonsterDeathEventHandler : EventHandler { override void WorldThingDied(WorldEvent e) { vector3 pos = e.Thing.pos; double height = e.Thing.height; pos.z += height/2; let o = OrbMultiplierThinker.GetInstance(); int maxHealth = e.Thing.getMaxHealth(); double maxHealth = e.Thing.getMaxHealth(); maxHealth *= o.orbMultiplier; console.printf("Points for %s after multiplying: %f", e.Thing.getClassName(), maxHealth); o.RaiseMultiplier(); while (maxHealth >= 100) { maxHealth -= 100; self.createOrb(true, pos); } while (maxHealth >= 10) { maxHealth -= 10; self.createOrb(false, pos); } }
void createOrb(bool big, vector3 pos) {...} } |
Now when a monster is killed, we use our OrbMultiplierThinker.GetInstance function to find the instance of the OrbMultiplierThinker object (or to create it, if there isn't one). Then. instead of using the monster's maxHealth property directly, we multiply it by the current value of orbMultiplier - we've turned maxHealth from an int into a double here so that the decimal from our multiplication isn't eliminated. The giving out of orbs can then be done with this altered value - but before that, we also tell the OrbMultiplierThinker to raise the multiplier and reset the time limit to 0.
Try playing with all of this in the game now - you should see the multiplier in the console logs getting bigger, and more orbs gradually being dropped by enemies as a consequence. Use some IDKFA assistance to see some dramatic increases!
Of course, the console messages are just there so we can see what's happening while testing - it would be good for the player to have an idea of their current multiplier as well. Let's add that to our UI.
We already know how to display text on the HUD, so putting information from this Thinker in the UI should be simple - it involves going back to our OrbUIHandler again and adding a reference to the OrbMultiplierThinker.
Alter the OrbUIHandler to get the information we need from our Thinker. |
ZSCRIPT |
class OrbUIHandler : EventHandler { override void RenderOverlay(RenderEvent e) { PlayerPawn p = PlayerPawn(players[consoleplayer].mo); int orbs = p.CountInv("DoomOrb"); let o = OrbMultiplierThinker.GetInstance(); String multiplierText = String.Format("x%.1f", o.orbMultiplier); Screen.DrawText(smallfont, Font.CR_RED, 20, 8, orbs .. "", DTA_VirtualWidth, 640, DTA_VirtualHeight, 480); TextureID orbTex = TexMan.CheckForTexture("DORBA0", TexMan.Type_Sprite); Screen.DrawTexture(orbTex, false, 4, 4, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480, DTA_LeftOffset, 0, DTA_TopOffset, 0); Screen.DrawText(smallfont, Font.CR_YELLOW, 60, 8, multiplierText, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480); } override bool InputProcess(InputEvent e) {...} override void NetworkProcess(ConsoleEvent e) {...} } |
Now, as part of the drawing of the UI, we get the OrbMultiplierThinker instance and put its orbMultiplier into a string by using the String.Format function. This time, we use the format %.1f to mean a decimal value with one decimal place (again, see https://zdoom.org/wiki/String#Methods for the formats available), and we add an "x" to the front. Then we add one more Screen.DrawText line to put this new string up on to the screen.
Start this up in GZDoom now. Can you see what's coming?
Scope foils us again! We can't call the GetInstance function from the UI because the OrbMultiplierThinker object, like all Thinkers, exists in the play scope - and using NetworkEvents won't help here because we need a response, we're not just sending a signal one way. Instead, we're going to do what we saw in the StaticEventHandler class a while ago, and put a function in a different scope from the rest of the class.
The keyword we're going to be using is clearscope, which makes a function callable both from ui and play scopes but puts other limits on them in exchange - just like the ui scope, clearscope classes and functions can only read from the play scope and not write to it. That means we can't just put clearscope directly on the GetInstance function that we already have, because of this line:
static OrbMultiplierThinker GetInstance(void) { ThinkerIterator it = ThinkerIterator.Create("OrbMultiplierThinker", STAT_USER); let p = OrbMultiplierThinker(it.Next()); if (p) return p; return new("OrbMultiplierThinker").Init(); } |
If we added clearscope to the start of this function, we would get a different script error because there's the possibility that the function might attempt to create a play-scoped object. So instead, we'll have to create an alternative GetInstance method that only ever tries to read.
Add a new function with the clearscope keyword to OrbMultiplierThinker, to get an instance without ever trying to create one. |
ZSCRIPT |
class OrbMultiplierThinker : Thinker { . . . static OrbMultiplierThinker GetInstance(void) {...} clearscope static OrbMultiplierThinker GetReadOnlyInstance(void) { ThinkerIterator it = ThinkerIterator.Create("OrbMultiplierThinker", STAT_USER); let p = OrbMultiplierThinker(it.Next()); if (p) return p; return null; } . . . } |
This variant of the GetInstance function won't try to create a new OrbMultiplierThinker if one isn't available - it will just return nothing. Let's get our OrbUIHandler to use that instead.
Use the new GetReadOnlyInstance() method in OrbUIHandler instead of GetInstance(). |
ZSCRIPT |
class OrbUIHandler : EventHandler { … override void RenderOverlay(RenderEvent e) { … let o = OrbMultiplierThinker.GetReadOnlyInstance(); … } … } |
That should allow us to read from the Thinker from the UI scope. Test this again - you'll find GZDoom starts up without a problem, but now try entering a map - what's it moaning about now?!
VM execution aborted: tried to read from address zero is an error that you're likely to encounter a lot when piecing ZScript together, and it sounds a lot scarier than it really is - despite it sounding like an issue with low level memory management, the problem is usually that you're attempting to call a method or alter a property on a null.
And thinking through our code again, it's easy to see why this happens - the instance of OrbMultiplierThinker is only created the first time GetInstance() is called, and that happens when the play scope needs it - currently that's only called when a monster dies. The GetReadOnlyInstance() function is called from the UI right from the start of the level, and will return null if the conditions for actually creating the thinker haven't happened yet.
There are a couple of things we can do to safeguard ourselves against this. First, it definitely pays to code defensively, and handle the condition where we just don't have an OrbMultiplierThinker yet:
Get the OrbUIHandler to handle the possibility of the OrbMultiplierThinker not existing by defining a default multiplierText. |
ZSCRIPT |
class OrbUIHandler : EventHandler { override void RenderOverlay(RenderEvent e) { PlayerPawn p = PlayerPawn(players[consoleplayer].mo); int orbs = p.CountInv("DoomOrb"); let o = OrbMultiplierThinker.GetReadOnlyInstance(); String multiplierText = "x1.0"; if (o) { multiplierText = String.Format("x%.1f", o.orbMultiplier); } TextureID orbTex = TexMan.CheckForTexture("HUDORB", TexMan.Type_Any); Screen.DrawTexture(orbTex, false, 4, 4, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480, DTA_LeftOffset, 0, DTA_TopOffset, 0); Screen.DrawText(smallfont, Font.CR_RED, 20, 8, orbs .. "", DTA_VirtualWidth, 640, DTA_VirtualHeight, 480); Screen.DrawText(smallfont, Font.CR_YELLOW, 60, 8, multiplierText, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480); } override bool InputProcess(InputEvent e) {...} override void NetworkProcess(ConsoleEvent e) {...} } |
And if we want to make sure that the OrbMultiplierThinker exists from the start of the level, we just have to make sure we tell the game to create it when the level starts.
Tell our OrbSetupEventHandler to do exactly that as part of its WorldLoaded function. |
ZSCRIPT |
class OrbSetupEventHandler : EventHandler { override void WorldLoaded(WorldEvent e) { OrbMultiplierThinker.GetInstance(); ThinkerIterator it = ThinkerIterator.Create("Health", Thinker.STAT_DEFAULT); Health item; while (item = Health(it.Next())) { console.printf("Destroyed a %s", item.getClassName()); item.Destroy(); } } } |
The game will start up correctly again now. And this is beginning to look a lot more satisfying! The display of the multiplier encourages the player to keep it topped up. Let's add one more element - a display of how much time the player has remaining to kill another demon before losing their multiplier.
We could do this just by putting a third number on the screen, but a diminishing bar would have a more urgent visual effect. We can make one by using the DrawTexture method again, using a couple more of its options and a bit of mathematics.
Let's start by rearranging our OrbMultiplierThinker to make things a little easier for ourselves:
Rewrite the OrbMultiplierThinker so that it has a property for the maximum time limit, and counts down instead of up. |
ZSCRIPT |
class OrbMultiplierThinker : Thinker { double orbMultiplier; int ticksSinceKill; int tickCountdown; int maxTicksSinceKill;
OrbMultiplierThinker Init(void) { ChangeStatNum(STAT_USER); self.orbMultiplier = 1; self.maxTicksSinceKill = 350; return self; } static OrbMultiplierThinker GetInstance(void) {...}
clearscope static OrbMultiplierThinker GetReadOnlyInstance(void) {...}
void RaiseMultiplier() { self.ticksSinceKill = 0; self.tickCountdown = self.maxTicksSinceKill; self.orbMultiplier += 0.1; console.printf("Raised multiplier to %f", self.orbMultiplier); }
override void Tick() { if (self.orbMultiplier > 1) { self.ticksSinceKill += 1; if (self.ticksSinceKill > 350) { self.ticksSinceKill = 0; self.orbMultiplier = 1; console.printf("Reset multiplier"); } } if (self.tickCountdown > 0) { self.tickCountdown -= 1; return; } self.orbMultiplier = 1; } } |
This alteration won't change anything from the player's point of view, but it will help us as the mod author a little. We've put the maximum time limit into the property maxTicksSinceKill so we can just refer to that property when we want the time limit, instead of putting the unexplained number 350 everywhere in our code (therefore if we want to adjust it later we can do it in only one place). Counting down instead of up also makes us have to do a bit less work when using this value to adjust an on-screen counter.
Let's look at the DrawTexture tags again. There are a couple of them that look useful, altering the width of the texture or how much of it is drawn on screen. We'll use DTA_WindowRightF in this example, which defines how far over to the right to stop drawing the graphic - a value that's less than the real width of the graphic will cut it off, and we can use this to make a decreasing bar. A graphic for a bar is provided in the project graphics folder under the name HUDBAR, but you can replace it with your own if you like.
So this is what we want to do:
We could skip the second step and just hard-code the number 100 (or whatever the width of our bar texture is), but working it out from the texture itself will avoid us having to update this number if we ever change the texture. TexMan's GetSize function will do this for us, given a texture ID - like Bindings.GetKeysForCommand which we used to get the player's key configuration, it returns two variables at once (the X and Y size).
To work out how much of the texture to draw, we'll get the fraction that the current tickCountdown is of the maxTicksSinceKill (if it's full, it will be 350/350 which is 1.0, if it's half full, it will be 175/350 which is 0.5) and then multiply the width of our texture by that. That will result in the number of pixels along the X axis where we want to stop - the input for DTA_WindowRightF.
Putting it all together:
Add a routine to the end of our RenderOverlay function in OrbUIHandler to get the bar texture, calculate how much of it to display by looking at the OrbMultiplierThinker and then draw it. |
ZSCRIPT |
class OrbUIHandler : EventHandler { override void RenderOverlay(RenderEvent e) { ... TextureID barTex = TexMan.CheckForTexture("HUDBAR", TexMan.TYPE_MISCPATCH); int barSizeX, barSizeY; [barSizeX, barSizeY] = TexMan.GetSize(barTex); double widthFraction = o.tickCountdown * 1.0 / o.maxTicksSinceKill; double barWidth = barSizeX * widthFraction; Screen.DrawTexture(barTex, false, 4, 20, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480, DTA_LeftOffset, 0, DTA_TopOffset, 0, DTA_WindowRightF, barWidth); } override bool InputProcess(InputEvent e) {...} override void NetworkProcess(ConsoleEvent e) {...} } |
Now select your preferred monster-packed map and watch the red orbs fly! Because all of this code runs without relying on any alterations to existing GZDoom classes, it will also work on very customized WADs with nonstandard weapons and monsters - or even other GZDoom games.
This project is really taking shape now - we have some multiplier logic and expiring pickups encouraging the player to move quickly and aggressively, accumulating that multiplier and collecting the ever-increasing collection of red orbs that burst out of the fallen monsters. Feel free to clean up the remaining debugging messages, play around with this a bit and adjust the multiplier bonuses and costs/rewards of converting orbs to health to your liking!
Our monolithic ZScript lump is getting a bit big now. Before we go on, we should take some time to organize things better and make them more efficient.
Like DECORATE, additional ZScript lumps can be loaded with the #include directive. With this arrangement, the base ZSCRIPT lump is the only one that requires a version declaration. I've separated our six classes by general subject into just four files - dealing with the in-game objects, the event handlers for things happening in the game, our UI handler, and the multiplier thinker - but some people prefer one class per file.
Arrange the project according to your preference - the tutorial will refer to classes and not files from now on. This is one possibility. |
ZSCRIPT.includes |
version "4.10.0" #include "zscript/OrbObjects.zs" #include "zscript/OrbEventHandlers.zs" #include "zscript/OrbUI.zs" #include "zscript/OrbThinkers.zs" |
zscript/OrbObjects.zs |
class DoomOrb : Inventory { ... } class DoomOrbBig : DoomOrb { ... } |
zscript/OrbEventHandlers.zs |
class OrbSetupEventHandler : EventHandler { ... } class MonsterDeathEventHandler : EventHandler { ... } |
zscript/OrbUI.zs |
class OrbUIHandler : EventHandler { ... } |
zscript/OrbThinkers.zs |
class OrbMultiplierThinker : Thinker { ... } |
As with other files that represent lumps, the file extension to ZScript lumps doesn't matter - GZDoom only cares about the portion of the filename before the dot. Using .txt or .zs seem to be the usual choices, and for the top-level one that only has #include directives, I tend to use .includes.
Computers have come a long way since id Software had to implement their ingenious pile of workarounds to trick a computer into showing a 3D world in 1993, but it still pays to think about how we could make our code more efficient - especially in an environment like GZDoom where anything we add to the game is running on several layers of VM and interpretation.
As an example, take a look at these lines in our routine for drawing the UI:
OrbUIHandler |
Screen.DrawText(smallfont, Font.CR_RED, 20, 8, String.Format("%d", orbs), DTA_VirtualWidth, 640, DTA_VirtualHeight, 480); TextureID orbTex = TexMan.CheckForTexture("DORBA0", TexMan.Type_Sprite); Screen.DrawTexture(orbTex, false, 4, 4, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480, DTA_LeftOffset, 0, DTA_TopOffset, 0); Screen.DrawText(smallfont, Font.CR_YELLOW, 60, 8, multiplierText, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480);
TextureID barTex = TexMan.CheckForTexture("HUDBAR", TexMan.TYPE_MISCPATCH); int barSizeX, barSizeY; [barSizeX, barSizeY] = TexMan.GetSize(barTex); double widthFraction = o.tickCountdown * 1.0 / o.maxTicksSinceKill; double barWidth = barSizeX * widthFraction; Screen.DrawTexture(barTex, false, 4, 20, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480, DTA_LeftOffset, 0, DTA_TopOffset, 0, DTA_WindowRightF, barWidth); |
Our three calls to ask TexMan to look something up for us are done when every single frame is drawn, and these answers aren't going to change during the runtime of the game. Even though you might not have noticed anything at this scale, the cost of calls like this can add up - it would be enough to just call these once and to remember the results in class variables.
I like to populate class variables in a function called Init(), and use the absence of one of them to indicate to the game that it should call this function to look everything up.
Move the looking up of textures and their sizes to a function that only has to be called once. |
OrbUIHandler |
class OrbUIHandler : EventHandler { ui TextureID orbTex; ui TextureID barTex; ui int barMaxWidth;
ui void Init() { self.orbTex = TexMan.CheckForTexture("DORBA0", TexMan.Type_Sprite); self.barTex = TexMan.CheckForTexture("HUDBAR", TexMan.TYPE_MISCPATCH); int barSizeX, barSizeY; [barSizeX, barSizeY] = TexMan.GetSize(barTex); self.barMaxWidth = barSizeX; }
override void RenderOverlay(RenderEvent e) {
if (!orbTex) { self.Init(); } PlayerPawn p = PlayerPawn(players[consoleplayer].mo); int orbs = p.CountInv("DoomOrb"); let o = OrbMultiplierThinker.GetReadOnlyInstance(); String multiplierText = "x1.0"; if (o) { multiplierText = String.Format("x%.1f", o.orbMultiplier); } Screen.DrawText(smallfont, Font.CR_RED, 20, 8, String.Format("%d", orbs), DTA_VirtualWidth, 640, DTA_VirtualHeight, 480); TextureID orbTex = TexMan.CheckForTexture("DORBA0", TexMan.Type_Sprite); Screen.DrawTexture(orbTex, false, 4, 4, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480, DTA_LeftOffset, 0, DTA_TopOffset, 0); Screen.DrawText(smallfont, Font.CR_YELLOW, 60, 8, multiplierText, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480); TextureID barTex = TexMan.CheckForTexture("HUDBAR", TexMan.TYPE_MISCPATCH); int barSizeX, barSizeY; [barSizeX, barSizeY] = TexMan.GetSize(barTex); double widthFraction = o.tickCountdown * 1.0 / o.maxTicksSinceKill; double barWidth = barSizeX * widthFraction; double barWidth = self.barMaxWidth * widthFraction; Screen.DrawTexture(barTex, false, 4, 20, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480, DTA_LeftOffset, 0, DTA_TopOffset, 0, DTA_WindowRightF, barWidth); |
This is better. When RenderOverlay is called the first time, it will check if orbTex is empty, and if it is, it will call the Init function to work out what our textures and sizes should be. Therefore, all those calls to TexMan only happen once, and all other times this function runs, it only does the stuff that we really have to update continuously.
Up until now, the player hasn't really had any incentive to save up the orbs they've collected - as long as they haven't reached 200 health, they might as well immediately cash them in and take the health boost. Let's reward accumulating orbs more by letting the player exchange them for better items if they hold on to them.
At the most straightforward level, we could do something like this…
OrbUIHandler |
class OrbUIHandler : EventHandler { ... override void NetworkProcess(ConsoleEvent e) { if (e.Name == "orbRecharge") { PlayerPawn p = PlayerPawn(players[e.player].mo); int orbs = p.CountInv("DoomOrb"); if (orbs >= 500) { p.GiveInventory("InvulnerabilitySphere", 1); p.TakeInventory("DoomOrb", 500); p.A_StartSound("misc/p_pkup"); return; } if (orbs >= 150) { p.GiveInventory("HealthBonus", 100); p.TakeInventory("DoomOrb", 150); p.A_StartSound("misc/p_pkup"); return; } if (orbs >= 40) { p.GiveInventory("HealthBonus", 25); p.TakeInventory("DoomOrb", 40); p.A_StartSound("misc/p_pkup"); return; } if (orbs >= 20) { p.GiveInventory("HealthBonus", 10); p.TakeInventory("DoomOrb", 20); p.A_StartSound("misc/p_pkup"); return; } } } } |
…but this is very verbose and unwieldy, and if we wanted to have some representation of the item that the player can currently buy on-screen (which we will) then we'd need another entire stack of if statements in the RenderOverlay function to handle that.
Instead, let's set up a data store that we can consult wherever we need to. First, we need a new class that can hold everything we need to represent a buyable item - we need to know the cost of the item, the quantity and type of inventory object that buying it will give to the player, and how the item will be represented visually.
Create this OrbBuyableItem class. |
OrbBuyableItem |
class OrbBuyableItem { int cost; TextureID texture; string itemClassName; int itemQuantity;
OrbBuyableItem Init(int cost, string texture, string itemClassName, int itemQuantity) { self.cost = cost; self.texture = TexMan.CheckForTexture(texture, TexMan.Type_Any); self.itemClassName = itemClassName; self.itemQuantity = itemQuantity; return self; } } |
This is a value object - unlike everything else we've implemented so far, it isn't a Thinker or an EventHandler, and the game has no special behaviour related to it by default, it's just there to hold data for us. It works exactly like a class in any other OOP language - it has four class variables, and a constructor (or in this case an approximation of one) that accepts values for each of them and assigns them. We use TexMan to look up the textureIDs for the texture names provided instead of just storing them as strings, so that we can avoid having to look them up every time when reading them.
(Despite this not being a Thinker, I put this class in my OrbThinkers file anyway because it will be used by a new Thinker we're about to implement, but you can choose your preferred location for yourself.)
Now we need to set up a Thinker which will store a collection of these objects and provide a function to tell us which one is currently available to the player. While this isn't an official piece of terminology, I tend to use the term "data library" for a Thinker that's used to look up data in this way, so this will be our OrbDataLibrary. It's going to start out looking a lot like our OrbMultiplierThinker, but with one important difference.
Create this OrbDataLibrary class, and make sure we initialize it on the WorldLoaded event in the OrbSetupEventHandler. |
OrbDataLibrary |
class OrbDataLibrary : Thinker { OrbDataLibrary Init(void) { ChangeStatNum(STAT_STATIC); return self; } static OrbDataLibrary GetInstance(void) { ThinkerIterator it = ThinkerIterator.Create("OrbDataLibrary", STAT_STATIC); let p = OrbDataLibrary(it.Next()); if (p) return p; return new("OrbDataLibrary").Init(); }
clearscope static OrbDataLibrary GetReadOnlyInstance(void) { ThinkerIterator it = ThinkerIterator.Create("OrbDataLibrary", STAT_STATIC); let p = OrbDataLibrary(it.Next()); if (p) return p; return null; } } |
OrbSetupEventHandler |
class OrbSetupEventHandler : EventHandler { override void WorldLoaded(WorldEvent e) { OrbMultiplierThinker.GetInstance(); OrbDataLibrary.GetInstance(); ThinkerIterator it = ThinkerIterator.Create("Health", Thinker.STAT_DEFAULT); Health item; while (item = Health(it.Next())) { console.printf("Destroyed a %s", item.getClassName()); item.Destroy(); } } } |
We provide the same Init, GetInstance and GetReadOnlyInstance functions as we did in the OrbMultiplierThinker, but this time, we assign it the STAT_STATIC stat number instead of STAT_USER (and we therefore use STAT_STATIC in the ThinkerIterators as well). This makes the object a static thinker, which gives it a couple of special properties:
These features make static thinkers a good choice for objects that only need to be set up once and are called when they're needed, instead of having to be updated constantly. They make a good alternative to putting things in ACS's global variables or storing things in the player's inventory - and as usual, doing it this way is a lot more flexible and less clumsy than the ACS/DECORATE implementation.
Let's now add an array of buyable items and populate it by creating several of our OrbBuyableItem objects.
Add an array to the OrbDataLibrary, and populate it in the Init method. |
OrbDataLibrary |
class OrbDataLibrary : Thinker { Array<OrbBuyableItem> buyableItems; OrbDataLibrary Init(void) { ChangeStatNum(STAT_STATIC); self.buyableItems.push(new("OrbBuyableItem").Init(20, "STIMA0", "HealthBonus", 10)); self.buyableItems.push(new("OrbBuyableItem").Init(40, "MEDIA0", "HealthBonus", 25)); self.buyableItems.push(new("OrbBuyableItem").Init(150, "SOULA0", "HealthBonus", 100)); self.buyableItems.push(new("OrbBuyableItem").Init(500, "ARM2A0", "BlueArmor", 1)); self.buyableItems.push(new("OrbBuyableItem").Init(1000, "PINVA0", "InvulnerabilitySphere", 1)); return self; } static OrbDataLibrary GetInstance(void) {...}
clearscope static OrbDataLibrary GetReadOnlyInstance(void) {...} } |
The five new lines in the Init method are doing several things at a time - creating new OrbBuyableItems, and then immediately calling their own Init methods to populate them with the orb cost, texture, and the item and quantity that they will actually give the player. The push method is then called to add the item to the buyableItems array - see the arrays page on the ZDoom wiki for more things you can do with them.
So now we have an array of possible items to buy, and the number of orbs at which they'll become available. The last thing we need to do here is provide a method that will give us the highest-value item that's available to the player.
Add this last method to our OrbDataLibrary. |
OrbDataLibrary |
class OrbDataLibrary : Thinker { Array<OrbBuyableItem> buyableItems; OrbDataLibrary Init(void) {...} static OrbDataLibrary GetInstance(void) {...}
clearscope static OrbDataLibrary GetReadOnlyInstance(void) {...} clearscope static OrbBuyableItem GetCurrentBuyableItem(int currentOrbs) { int lowestCost = -1; OrbBuyableItem selectedItem = null; foreach (item : OrbDataLibrary.GetReadOnlyInstance().buyableItems) { if (item.cost > lowestCost && item.cost <= currentOrbs) { selectedItem = item; lowestCost = item.cost; } } return selectedItem; } } |
We want this method to be accessible from both the UI and play scopes, so it's a clearscope method that uses GetReadOnlyInstance. It will loop through the buyableItems array and find the highest-priced one that has a cost equal to or lower than the value we pass in (the player's current orbs).
With that done, we just have to alter our UI classes now - on the UI, we want to display the item that the player currently has available, and when the orbRecharge event is fired, we need to look up the appropriate items to give the player. This doesn't involve any completely new concepts - we just have to consult the data library and use the data we get back from it to draw things to the screen or decide how to manipulate the player's inventory.
Consult the data library in the RenderOverlay and NetworkProcess methods to decide the item to display or give to the player. |
OrbUIHandler |
class OrbUIHandler : EventHandler { … override void RenderOverlay(RenderEvent e) { … OrbBuyableItem item = OrbDataLibrary.GetCurrentBuyableItem(orbs); if (item) { Screen.DrawText(smallfont, Font.CR_YELLOW, 112, 32, item.cost .. "", DTA_VirtualWidth, 640, DTA_VirtualHeight, 480); Screen.DrawTexture(item.texture, false, 120, 30, DTA_VirtualWidth, 640, DTA_VirtualHeight, 480, DTA_LeftOffset, 0, DTA_TopOffset, 0, DTA_CenterBottomOffset, true); } } … override void NetworkProcess(ConsoleEvent e) { if (e.Name == "orbRecharge") { PlayerPawn p = PlayerPawn(players[e.player].mo); int orbs = p.CountInv("DoomOrb"); if (orbs >= 20) { p.GiveInventory("HealthBonus", 10); p.TakeInventory("DoomOrb", 20); p.A_StartSound("misc/p_pkup"); } OrbBuyableItem item = OrbDataLibrary.GetCurrentBuyableItem(p.CountInv("DoomOrb")); if (item) { p.GiveInventory(item.itemClassName, item.itemQuantity); p.TakeInventory("DoomOrb", item.cost); p.A_StartSound("misc/p_pkup"); } } } } |
I'm just using one new tag in the DrawTexture method here, to force the sprite to start drawing from its bottom centre - this makes it a lot easier to place it on the screen, without worrying about the varying heights and widths of sprites.
Have a play about with this now, try to keep your kill streak going, and experiment with adjusting the hierarchy of items!
There's one extra thing that I want to mention, but we're basically finished with this demonstration project now. You can download the complete runnable PK3 from https://doom.teamouse.net/zstutorial/zstutorial-complete.pk3 if you want to compare it to your own!
This last part is something of a bonus because it's overkill for a project like this, but another interesting thing that ZScript lets you do is load data from your own custom lumps! I used these for larger projects like RAMP 2022 where I needed to store a list of level difficulties and award points, or for Phobian Odyssey to keep a database of possible monster parties and where they might be encountered.
Our data lump is going to be pretty small by comparison, but it will allow us to avoid hard-coding the list of available items in our OrbDataLibrary.
Create a new lump in the root of your project called ORBITEMS. |
ORBITEMS |
20,STIMA0,HealthBonus,10 40,MEDIA0,HealthBonus,25 150,SOULA0,HealthBonus,100 500,ARM2A0,BlueArmor,1 1000,PINVA0,InvulnerabilitySphere,1 |
This is just the same as the arguments that we passed to our OrbBuyableItems earlier, arranged in a comma separated list.
Now we can use the Wads global to find the ORBITEMS lump at runtime, read its contents into a string, then perform some string splitting to get our individual lines and data items and create our OrbBuyableItems by looping over them:
Replace the hard-coded items in the OrbDataLibrary's Init method with a set read from the ORBITEMS lump. |
OrbDataLibrary |
class OrbDataLibrary : Thinker { … OrbDataLibrary Init(void) { ChangeStatNum(STAT_STATIC); self.buyableItems.push(new("OrbBuyableItem").Init(20, "STIMA0", "HealthBonus", 10)); self.buyableItems.push(new("OrbBuyableItem").Init(40, "MEDIA0", "HealthBonus", 25)); self.buyableItems.push(new("OrbBuyableItem").Init(150, "SOULA0", "HealthBonus", 100)); self.buyableItems.push(new("OrbBuyableItem").Init(500, "ARM2A0", "BlueArmor", 1)); self.buyableItems.push(new("OrbBuyableItem").Init(1000, "PINVA0", "InvulnerabilitySphere", 1)); int orbItemsIndex = Wads.FindLump('ORBITEMS', 0); String orbItemsData = Wads.ReadLump(orbItemsIndex); Array<String> lines; orbItemsData.Split(lines, "\n"); foreach (line : lines) { if (line.length() < 2) { continue; } Array<String> elements; line.Split(elements, ","); int cost = elements[0].ToInt(); String texture = elements[1]; String itemClassName = elements[2]; int itemQuantity = elements[3].ToInt(); self.buyableItems.push(new("OrbBuyableItem").Init(cost, texture, itemClassName, itemQuantity)); } … } |
There are a couple of new or unusual things here that are worth mentioning:
In this case the code for reading the custom lump is actually longer than just coding it all in, but it demonstrates another interesting thing that ZScript can do for projects that need data in addition to what the base game provides. It beats abusing the LANGUAGE lump and treating it as a key-value database.
I hope that building this project has given you some idea about how ZScript works and how it can be used to expand the possibilities of GZDoom projects. I avoided using ZScript for a long time because of how impenetrable it seemed, but now that I've broken into that world, I'm finding it very difficult to stop.
Thanks to everyone on the GZDoom Discord and forums, as well as my own Discord, for their advice on ZScript and for testing my instructions out and telling me where they made no sense. Thanks also to everyone who is writing documentation for ZScript to make it more accessible to everyone!
Some ideas for expanding the project! See what else you can do with it.