Modding:Tutorial/Recreating Hell's Arena
From DoomRL Wiki
In many ways, learning through example is a solid method of figuring out how to create your own code. The archetypical example we use here is Hell's Arena, which has been the example mod of DoomRL since the inception of the sandbox. It imitates Hell's Arena as it appears in DoomRL itself, which a few additions added here and there to create a different experience.
As with all example modules in these tutorials, we will first begin with any radically-new concepts that require plenty of room, then go on to explain things by declaration. Usually this would involve looking at any cells/items/beings (or just old-fashioned tables) but this module has no new objects. Instead, we will only need to move through each engine hook and how it affects the map itself. As a measure of extra caution, generic comments will be added to this map as a means to provide understanding for those skipping all the extra details.
Contents |
Special Concept: Adding Sound and Music
The most significant departure from the Arena's vanilla counterpart is the inclusion of custom sounds and music. This is explained sufficiently in the module documentation, but the steps to doing so will be laid out explicitly here.
- First and foremost, you need to the sound and/or music files. Sounds must be a .wav file, and music must be a .mid, .mp3, or .ogg file. (Note that using .mp3 or .ogg can easily make WAD files much bigger files, so take caution when adding them to your module.) It is suggested that you name your sound and music files something that describes the audio of the file, as its filename (minus the extension) will be used verbatim in the lua script.
- Second, we need to put them into the module correctly. This is done by creating additional subdirectories in your .module folder:
- sound files go into "sound"
- music files go into "music"
- Whenever you want to access one of these sound or music files, you will use one of the following methods:
- Sounds are most commonly called using Being:play_sound("filename"), where Being is whomever you want the sound to originate from. For "surround" sounds, use player:play_sound().
- If you want the sound at a very specific point on the map, use Level.explosion() and set the radius, delay, and damage of the explosion to zero: this removes any physical part of the explosion, allowing you to play only a sound at that point.
- Music is added using core.play_music("filename"). It will override any music already playing. (Unfortunately there is no way to turn off the music: if you want no music to play, create a music file that has no audio and use it instead.)
- Do not modify sound.lua or music.lua. This is technically a way to modify (or include) sounds and music for particular objects/calls, but neither of these files are included in the source folder or WAD file. For the sake of players wanting an easy dump into their DoomRL folder, it is higly recommended that you stick to the aforementioned procedure instead.
In the Hell's Arena module, Skulltag sounds are added at the start of the map, as well as the start of each wave. Music by Simon Volpert, named "Rounds of Hell", is also included at the beginning of the map and changes for each wave. (Adding sounds to beings is a lot more complicated, and we won't get into it here. Suffice it to say that it may prove more trouble than it's worth, not to mention that default sounds cannot be overwritten.)
Engine Hooks
.run()
--always declare the module_id global core.declare( "arena" , {} ) function arena.run() Level.name = "Hell Arena" --that IS what it's called Level.name_number = 0 --remove floor # Level.fill("prwall") --immortal border tiles local translation = { ['.'] = "floor", --gray floor [','] = "blood", --red floor (blood) ['#'] = "rwall", --red wall (bloodstone) ['>'] = "stairs" --gray (normal) stairs } --create map cell string (76x18) local map = [[ #######################.............................######################## ###########.....................................................############ #####..................................................................##### ##........................................................................## #..........................................................................# ............................................................................ ..................................,,,,,,.................................... ..,,,.............................,,,,,,,................................... ..,>,............................,,,,,,,,,.................................. ..,,,............................,,,,,,,,................................... ..................................,,,,,,.................................... ............................................................................ ............................................................................ #..........................................................................# ##........................................................................## #####..................................................................##### ###########.....................................................############ #######################.............................######################## ]] --create pillar cell string (6x5) local column = [[ ,..,., ,####. .####, .####. ,..,., ]] --puts map in non-border area Level.place_tile( translation, map, 2, 2 ) --adds random pillars and blood Level.scatter_put( area.new(5,3,68,15), translation, column, "floor",9+math.random(8)) Level.scatter( area.FULL_SHRINKED,"floor","blood",100) --inserts player Level.player(38,10) end
If you recall in the tutorial Constructing a Map, we used the starting coordinate (1,1) for the Level.place_tile() method, rather than (2,2). This is because we included the map's border tiles in our test map. It is, in fact, possible to customize the border of the map (there are some issues with non-cell objects on the border, so it is suggested you don't allow for them): however, if the border of the map is going to be uniform, then when you use Level.fill(), fill the level with the border tiles you want. Then, when you write out the map string, you can ignore their existence altogether (the dimensions of such a map string should be 76x18), and place the map string at the starting coordinate (2,2) instead.
Additionally, we have another cell string on this map: a 6x5 group consisting of a pillar surrounded by floor tiles. Hell's Arena randomly scatters these tiles around using the Level.scatter_put() method. The arguments are as follows:
- Area in which the cell string can be placed
- character/map translation table (thus you should write this cell string in the same terms as your map's)
- name of the cell string
- cell_id on which this cell string can be placed (the method will be sure that the entire cell string can replace only the cell_id you input here)
- number of times cell string is placed
In the example, a fairly large area is carved out for these pillars to be placed, defining the same translation as for the normal map, using the variable name of the cell string, replacing the "floor" cell, and will be added anywhere from 10-17 times. Note that the middle of the map uses "blood" floor tiles rather than "floor", so that pillar will never appear here.
Finally, after placing the pillars, blood floor files are randomly added to the floor using the Level.scatter() method. Level.scatter() is similar to Level.scatter_put() but has no translation/map arguments. Instead, it asks for the area affected, the cell_id that will replace, the cell_id being replaced, and the number of times it should be added. In the example, "floor" is replaced with "blood" 100 times on any non-border tile (area.FULL_SHRINKED is equivalent to area.new(2,2,77,19)).
.OnEnter()
function arena.OnEnter() core.play_music("rounds_of_hell_part_1") --use some kick-ass music player:play_sound("preparetofight") --skulltag announcer! player.eq.weapon = item.new( "shotgun" ) --give the man a shotty --add 50 shotgun shells to inventory in a single "shell" item local shells = item.new( "shell" ) shells.ammo = 50 player.inv:add( shells ) ....
The .OnEnter() hook begins in a way you'd tend to expect: special sound/music is added and the player's inventory is adjusted (specifically they are given a shotgun in-hand and fifty shells in the bank).
.... Level.result(1) --sets up first wave --big announcement stuff ui.msg("A devilish voice announces:") ui.msg("\"Welcome to Hell's Arena, mortal!\"") ui.msg("\"You are either very foolish, or very brave. Either way I like it!\"") ui.msg("\"And so do the crowds!\"") player:play_sound("fight") ui.msg("Suddenly you hear screams everywhere! \"Blood! Blood! BLOOD!\"") ui.msg("The voice booms again, \"Kill all enemies and I shall reward thee!\"") player:play_sound("one") ....
The next part of .OnEnter() is a series of ui.msg() methods to display text onto the player's top part of the screen. Sounds are added are relevant points of the monologue. Note that, although ui.msg_enter isn't being used, the player will have to press ENTER once two lines of text is fully displayed. Getting the timing of this with sounds can be tricky, so I would personally recommend that you use ui.msg_enter just before a sound is to be played.
Also included here is the Level.result() method. Level.result() can be used in two ways:
- Calling without any arguments returns the current value of Level.result()
- Calling with an integer argument sets Level.result() to that integer value
This value can be used for a number of things, but it is most often associated with events occurring in the level.
.... --create the first wave Level.summon("demon",3) --3 demons Level.summon("lostsoul",2) --2 lost souls Level.summon("cacodemon",DIFFICULTY-1) --0/1/2/3/4 cacodemons end
Finally, .OnEnter() spawns us some enemies using the Level.summon() method. This is a very simple function: input the being_id you want to spawn and how many, and the game will randomly generate them anywhere on the map. To spawn enemies in a specific area, you will want to use Level.area_summon(), which includes an area argument.
The number of cacodemons in the level depends on the difficulty, which can be called through a pre-defined global variable called "DIFFICULTY". On I'm Too Young To Die, DIFFICULTY is equal to 1; on Hey, Not Too Rough, DIFFICULTY is equal to 2; and so on and so forth.
.OnKill()
function arena.OnKill() --randomized cheers from the crowd local temp = math.random(3) if temp == 1 then ui.msg("The crowds go wild! \"BLOOD! BLOOD!\"") elseif temp == 2 then ui.msg("The crowds cheer! \"Blood! Blood!\"") else ui.msg("The crowds cheer! \"Kill! Kill!\"") end end
The .OnKill() hook is used in Hell's Arena to add some crowd cheering. It is randomized between three messages using a temporary variable that changes every time another enemy dies (as the variable is re-defined each time the hook is triggered).
.OnKillAll()
function arena.OnKillAll() if Level.result() == 1 then --if first wave completes --more talk ui.msg("The voice booms, \"Not bad mortal! For a weakling that you ") ui.msg("are, you show some determination.\""); ui.msg("You hear screams everywhere! \"More Blood! More BLOOD!\"") ui.msg("The voice continues, \"I can now let you go free, or") ui.msg("you may try to complete the challenge!\""); --player can choose to continue local choice = ui.msg_confirm("\"Do you want to continue the fight?\"") if choice then --player chose to continue core.play_music("rounds_of_hell_part_2") --second verse better than first player:play_sound("two") ui.msg("The voice booms, \"I like it! Let the show go on!\"") ui.msg("You hear screams everywhere! \"More Blood! More BLOOD!\"") Level.drop("chaingun") --sweet drop Level.summon("demon",3) --3 demons Level.summon("cacodemon",DIFFICULTY) --1/2/3/4/5 cacodemons --player chose to stop else ui.msg("The voice booms, \"Coward!\" ") ui.msg("You hear screams everywhere! \"Coward! Coward! COWARD!\"") end ....
.OnKillAll() contains the bulk of code in this map, since each time a wave of enemies is killed, a number of things change. The first condition required is to check which wave we're on using Level.result(). Recall that Level.result() is equal to 1 based on the .OnEnter() hook, so the first .OnKillAll() will trigger for this first case.
After another lengthy explanation, the player is given a y/n confirmation using the ui.msg_confirm() method. This returns a true/false value depending on the input of the player: in the example we assign this return value to "choice". Thus we can use this variable to determine what happens next:
- In the event that the player chooses to accept with the "y" key (choice is set to true), then we get another music change, some displayed praise, and some items and beings added to the level.
- In the event that the player choosen to decline with the "n" key (choice is set to false), then we get a displayed insult.
Level.drop() should be self-explanatory: is it the item-equivalent to Level.summon(). Both the Level.drop() and Level.summon() methods can have the number of items/beings omitted: if so, the default value of 1 is used.
.... elseif Level.result() == 2 then --if second wave completes --even more talk ui.msg("The voice booms, \"Impressive mortal! Your determination") ui.msg("to survive makes me excited!\"") ui.msg("You hear screams everywhere! \"More Blood! More BLOOD!\"") ui.msg("\"I can let you go now, and give you a small reward, or") ui.msg("you can choose to fight the final challenge!\"") --player can choose to continue local choice = ui.msg_confirm("\"Do you want to continue the fight?\"") if choice then --player chosen to continue core.play_music("rounds_of_hell_part_3") --strongest music yet player:play_sound("three") ui.msg("The voice booms, \"Excellent! May the fight begin!!!\"") ui.msg("You hear screams everywhere! \"Kill, Kill, KILL!\"") --some more sweet loot Level.drop("shell",4) Level.drop("ammo",4) --spawns are a little more difficulty-dependent if DIFFICULTY <= 2 then --ITYTD and HNTR Level.summon("cacodemon",DIFFICULTY+1) elseif DIFFICULTY == 3 then --HMP Level.summon("knight",2) elseif DIFFICULTY >= 4 then --UV and N! Level.summon("baron",2) end else --player chose to stop ui.msg("The voice booms, \"Too bad, you won't make it far then...!\" ") ui.msg("You hear screams everywhere! \"Boooo...\"") --pointless loot Level.drop("shell",3) Level.drop("lmed") Level.drop("smed") end ....
This part of the .OnKillAll() code shows for the case of Level.result equal to 2. It is roughly the same as the first case (different music/items/summons/messages aside), but with the following nontrivial changes:
- The enemies used in the final wave are included in an if-then-elseif conditional statement, with each condition depending on DIFFICULTY. We have three settings: cacodemons (2 on ITYTD, 3 on HNTR), hell knights (on HMP), and barons of hell (on UV and N!).
- If the player chooses to decline, they will still receive some items. This is simply how it works in the vanilla map, and serves no real purpose in a single-level module such as this.
.... elseif Level.result() == 3 then --if third wave completes --finally done talking ui.msg("The voice booms, \"Congratulations mortal! A pity you came to") ui.msg("destroy us, for you would make a formidable hell warrior!\"") ui.msg("\"I grant you the title of Hell's Arena Champion!\"") ui.msg("\"And a promise is a promise... search the arena again...\"") --also pointless loot Level.drop("scglobe") Level.drop("barmor") Level.drop("lmed") end ....
Finally, .OnKillAll() checks for the case of Level.result equal to 3: this is after all three waves have been killed. The announcer finishes entirely and the player receives some superfluous rewards.
.... --increment level counter each time a wave completes Level.result(Level.result()+1) end
This final piece of code is very important: each time .OnKillAll() is triggered, after all the Level.result checks are made, Level.reuslt() is incremented by one. This means that, after the first wave of enemies is killed, the case of Level.result equal to 1 is executed, the other cases are ignored, and then Level.result() is set to 2. This repeats for the other cases, albeit with different lines being executed before Level.result() is incremented.
.OnExit()
function arena.OnExit() ui.msg_enter("The voice laughs, \"Flee mortal, flee! There's no hiding in hell!\"") --takes care of mortem text for "killed by something unknown..." if Level.result() < 4 then arena.result = "fled alive the trials at wave "..Level.result() else arena.result = "completed the trials" end end
Once the player leaves the level (and .OnExit() triggers), a final statement is given (which must be confirmed to continue, lest it be missed entirely) and a new field to "arena" is added, this time a string called "result". If Level.result() is less than 4 (which is to say, all waves were not killed) then arena.result writes out when the player left using Level.result() as the wave number. If Level.result() is 4 (or technically greater) then arena.result writes out that the player finished. But where is this string going to?
.OnMortem()
function arena.OnMortem() local kill = player.killedby --calls kill descriptions from beings if arena.result then kill = arena.result end player:mortem_print( " "..player.name..", level "..player.explevel.." " .." "..klasses[player.klass].name..", "..kill ) --e.g., "Cool Guy, level 1 Marine, fled alive the trials at wave 3" player:mortem_print(" in the Hell Arena...") end
The .OnMortem() hook is called whenever the player has exited the level, immediately after .OnExit(), and can be used to change what is displayed on the mortem screen. Here we see arena.result in action:
- If the player is killed by an enemy, that being's kill_desc (or kill_desc_melee) can be called using player.killedby, which is then set to the local variable "kill". If not, "kill" will be empty when the first line executes.
- If arena.result was created (and will thus be true), then "kill" is instead set to that string. Since arena.result is only created if the player chooses to exit the game, this will not overwrite the case where the player is actually killed. It is done mostly to avoid any instances in which there would be a "killed by something unknown..." message.
Finally, the first two lines of the mortem are replaced with the player:mortem_print() method. player.name is the name of the player (as determined in-game); player.explevel is the character level; and klasses[player.klass].name checks the class prototype and returns the player's chosen class's name (for example, Marine).
Review
Here is the entire main.lua file to view at once:
--always declare the module_id global core.declare( "arena" , {} ) function arena.OnEnter() core.play_music("rounds_of_hell_part_1") --use some kick-ass music player:play_sound("preparetofight") --skulltag announcer! player.eq.weapon = item.new( "shotgun" ) --give the man a shotty --add 50 shotgun shells to inventory in a single "shell" item local shells = item.new( "shell" ) shells.ammo = 50 player.inv:add( shells ) Level.result(1) --sets up first wave --big announcement stuff ui.msg("A devilish voice announces:") ui.msg("\"Welcome to Hell's Arena, mortal!\"") ui.msg("\"You are either very foolish, or very brave. Either way I like it!\"") ui.msg("\"And so do the crowds!\"") player:play_sound("fight") ui.msg("Suddenly you hear screams everywhere! \"Blood! Blood! BLOOD!\"") ui.msg("The voice booms again, \"Kill all enemies and I shall reward thee!\"") player:play_sound("one") --create the first wave Level.summon("demon",3) --3 demons Level.summon("lostsoul",2) --2 lost souls Level.summon("cacodemon",DIFFICULTY-1) --0/1/2/3/4 cacodemons end function arena.OnKill() --randomized cheers from the crowd local temp = math.random(3) if temp == 1 then ui.msg("The crowds go wild! \"BLOOD! BLOOD!\"") elseif temp == 2 then ui.msg("The crowds cheer! \"Blood! Blood!\"") else ui.msg("The crowds cheer! \"Kill! Kill!\"") end end function arena.OnKillAll() if Level.result() == 1 then --if first wave completes --more talk ui.msg("The voice booms, \"Not bad mortal! For a weakling that you ") ui.msg("are, you show some determination.\""); ui.msg("You hear screams everywhere! \"More Blood! More BLOOD!\"") ui.msg("The voice continues, \"I can now let you go free, or") ui.msg("you may try to complete the challenge!\""); --player can choose to continue local choice = ui.msg_confirm("\"Do you want to continue the fight?\"") if choice then --player chose to continue core.play_music("rounds_of_hell_part_2") --second verse better than first player:play_sound("two") ui.msg("The voice booms, \"I like it! Let the show go on!\"") ui.msg("You hear screams everywhere! \"More Blood! More BLOOD!\"") Level.drop("chaingun") --sweet drop Level.summon("demon",3) --3 demons Level.summon("cacodemon",DIFFICULTY) --1/2/3/4/5 cacodemons --player chose to stop else ui.msg("The voice booms, \"Coward!\" ") ui.msg("You hear screams everywhere! \"Coward! Coward! COWARD!\"") end elseif Level.result() == 2 then --if second wave completes --even more talk ui.msg("The voice booms, \"Impressive mortal! Your determination") ui.msg("to survive makes me excited!\"") ui.msg("You hear screams everywhere! \"More Blood! More BLOOD!\"") ui.msg("\"I can let you go now, and give you a small reward, or") ui.msg("you can choose to fight the final challenge!\"") --player can choose to continue local choice = ui.msg_confirm("\"Do you want to continue the fight?\"") if choice then --player chosen to continue core.play_music("rounds_of_hell_part_3") --strongest music yet player:play_sound("three") ui.msg("The voice booms, \"Excellent! May the fight begin!!!\"") ui.msg("You hear screams everywhere! \"Kill, Kill, KILL!\"") --some more sweet loot Level.drop("shell",4) Level.drop("ammo",4) --spawns are a little more difficulty-dependent if DIFFICULTY <= 2 then --ITYTD and HNTR Level.summon("cacodemon",DIFFICULTY+1) elseif DIFFICULTY == 3 then --HMP Level.summon("knight",2) elseif DIFFICULTY >= 4 then --UV and N! Level.summon("baron",2) end else --player chose to stop ui.msg("The voice booms, \"Too bad, you won't make it far then...!\" ") ui.msg("You hear screams everywhere! \"Boooo...\"") --pointless loot Level.drop("shell",3) Level.drop("lmed") Level.drop("smed") end elseif Level.result() == 3 then --if third wave completes --finally done talking ui.msg("The voice booms, \"Congratulations mortal! A pity you came to") ui.msg("destroy us, for you would make a formidable hell warrior!\"") ui.msg("\"I grant you the title of Hell's Arena Champion!\"") ui.msg("\"And a promise is a promise... search the arena again...\"") --also pointless loot Level.drop("scglobe") Level.drop("barmor") Level.drop("lmed") end --increment level counter each time a wave completes Level.result(Level.result()+1) end function arena.OnExit() ui.msg_enter("The voice laughs, \"Flee mortal, flee! There's no hiding in hell!\"") --takes care of mortem text for "killed by something unknown..." if Level.result() < 4 then arena.result = "fled alive the trials at wave "..Level.result() else arena.result = "completed the trials" end end function arena.OnMortem() local kill = player.killedby --calls kill descriptions from beings if arena.result then kill = arena.result end player:mortem_print( " "..player.name..", level "..player.explevel.." " .." "..klasses[player.klass].name..", "..kill ) --e.g., "Cool Guy, level 1 Marine, fled alive the trials at wave 3" player:mortem_print(" in the Hell Arena...") end function arena.run() Level.name = "Hell Arena" --that IS what it's called Level.name_number = 0 --remove floor # Level.fill("prwall") --immortal border tiles local translation = { ['.'] = "floor", --gray floor [','] = "blood", --red floor (blood) ['#'] = "rwall", --red wall (bloodstone) ['>'] = "stairs" --gray (normal) stairs } --create map cell string (76x18) local map = [[ #######################.............................######################## ###########.....................................................############ #####..................................................................##### ##........................................................................## #..........................................................................# ............................................................................ ..................................,,,,,,.................................... ..,,,.............................,,,,,,,................................... ..,>,............................,,,,,,,,,.................................. ..,,,............................,,,,,,,,................................... ..................................,,,,,,.................................... ............................................................................ ............................................................................ #..........................................................................# ##........................................................................## #####..................................................................##### ###########.....................................................############ #######################.............................######################## ]] --create pillar cell string (6x5) local column = [[ ,..,., ,####. .####, .####. ,..,., ]] --puts map in non-border area Level.place_tile( translation, map, 2, 2 ) --adds random pillars and blood Level.scatter_put( area.new(5,3,68,15), translation, column, "floor",9+math.random(8)) Level.scatter( area.FULL_SHRINKED,"floor","blood",100) --inserts player Level.player(38,10) end
Source data: arena.module
WAD file: arena.wad