Difference between revisions of "Modding:Tutorial/Recreating Hell's Arena"
From DoomRL Wiki
Game Hunter (Talk | contribs) (work in progress) |
Game Hunter (Talk | contribs) m (→Review: new link) |
||
(10 intermediate revisions by one user not shown) | |||
Line 1: | Line 1: | ||
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. | 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. | ||
+ | |||
+ | ==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 [[Modding:Module|module documentation]], but the steps to doing so will be laid out explicitly here. | The most significant departure from the Arena's vanilla counterpart is the inclusion of custom sounds and music. This is explained sufficiently in the [[Modding:Module|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. | *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. | + | *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" | **sound files go into "sound" | ||
**music files go into "music" | **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: | *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 | + | **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 of the explosion to zero: this removes | + | **If you want the sound at a very specific point on the map, use '''[[Modding:Level#level_explosion|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( | + | **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.) |
+ | *<u>Do not modify sound.lua or music.lua.</u> 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 | + | 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()=== | ||
<source lang="lua"> | <source lang="lua"> | ||
+ | --always declare the module_id global | ||
core.declare( "arena" , {} ) | core.declare( "arena" , {} ) | ||
function arena.run() | function arena.run() | ||
− | + | Level.name = "Hell Arena" --that IS what it's called | |
− | + | Level.name_number = 0 --remove floor # | |
− | + | Level.fill("rwall") --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 = [[ | local map = [[ | ||
#######################.............................######################## | #######################.............................######################## | ||
Line 49: | Line 58: | ||
#######################.............................######################## | #######################.............................######################## | ||
]] | ]] | ||
− | + | --create pillar cell string (6x5) | |
+ | local column = [[ | ||
,..,., | ,..,., | ||
,####. | ,####. | ||
Line 56: | Line 66: | ||
,..,., | ,..,., | ||
]] | ]] | ||
− | + | ||
− | + | --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) | ||
+ | --all walls become indestructible | ||
+ | Generator.set_permanence(area.FULL) | ||
+ | --inserts player | ||
+ | Level.player(38,10) | ||
end | end | ||
</source> | </source> | ||
− | If you recall in the tutorial [[Modding:Tutorial/Constructing a Map|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. | + | If you recall in the tutorial [[Modding:Tutorial/Constructing a Map|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: | + | 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 | *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) | *character/map translation table (thus you should write this cell string in the same terms as your map's) | ||
Line 74: | Line 89: | ||
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. | 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)). | + | 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()=== | ||
<source lang="lua"> | <source lang="lua"> | ||
function arena.OnEnter() | function arena.OnEnter() | ||
− | core.play_music(" | + | core.play_music("rounds_of_hell_part_1") --use some kick-ass music |
− | player:play_sound("preparetofight") | + | player:play_sound("preparetofight") --skulltag announcer! |
− | player.eq.weapon = item.new( "shotgun" ) | + | 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" ) | local shells = item.new( "shell" ) | ||
shells.ammo = 50 | shells.ammo = 50 | ||
Line 87: | Line 105: | ||
</source> | </source> | ||
− | The | + | 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). |
<source lang="lua"> | <source lang="lua"> | ||
.... | .... | ||
− | Level.result(1) | + | Level.result(1) --sets up first wave |
+ | |||
+ | --big announcement stuff | ||
ui.msg("A devilish voice announces:") | ui.msg("A devilish voice announces:") | ||
ui.msg("\"Welcome to Hell's Arena, mortal!\"") | ui.msg("\"Welcome to Hell's Arena, mortal!\"") | ||
Line 103: | Line 123: | ||
</source> | </source> | ||
− | The next part of | + | 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: | + | 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 without any arguments returns the current value of '''Level.result()''' |
− | *Calling with an integer argument sets Level.result() to that integer value | + | *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. | This value can be used for a number of things, but it is most often associated with events occurring in the level. | ||
Line 114: | Line 134: | ||
<source lang="lua"> | <source lang="lua"> | ||
.... | .... | ||
− | Level.summon("demon",3) | + | --create the first wave |
− | Level.summon("lostsoul",2) | + | Level.summon("demon",3) --3 demons |
− | Level.summon("cacodemon",DIFFICULTY-1) | + | Level.summon("lostsoul",2) --2 lost souls |
+ | Level.summon("cacodemon",DIFFICULTY-1) --0/1/2/3/4 cacodemons | ||
end | end | ||
</source> | </source> | ||
− | Finally, | + | 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 | + | 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()=== | ||
<source lang="lua"> | <source lang="lua"> | ||
function arena.OnKill() | function arena.OnKill() | ||
+ | --randomized cheers from the crowd | ||
local temp = math.random(3) | local temp = math.random(3) | ||
if temp == 1 then ui.msg("The crowds go wild! \"BLOOD! BLOOD!\"") | if temp == 1 then ui.msg("The crowds go wild! \"BLOOD! BLOOD!\"") | ||
Line 133: | Line 156: | ||
</source> | </source> | ||
− | The | + | 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()=== | ||
<source lang="lua"> | <source lang="lua"> | ||
function arena.OnKillAll() | function arena.OnKillAll() | ||
− | if Level.result() == 1 then | + | 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("The voice booms, \"Not bad mortal! For a weakling that you ") | ||
ui.msg("are, you show some determination.\""); | ui.msg("are, you show some determination.\""); | ||
Line 143: | Line 168: | ||
ui.msg("The voice continues, \"I can now let you go free, or") | ui.msg("The voice continues, \"I can now let you go free, or") | ||
ui.msg("you may try to complete the challenge!\""); | 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?\"") | local choice = ui.msg_confirm("\"Do you want to continue the fight?\"") | ||
− | if choice then | + | if choice then --player chose to continue |
− | core.play_music(" | + | core.play_music("rounds_of_hell_part_2") --second verse better than first |
player:play_sound("two") | player:play_sound("two") | ||
ui.msg("The voice booms, \"I like it! Let the show go on!\"") | ui.msg("The voice booms, \"I like it! Let the show go on!\"") | ||
ui.msg("You hear screams everywhere! \"More Blood! More BLOOD!\"") | ui.msg("You hear screams everywhere! \"More Blood! More BLOOD!\"") | ||
− | + | ||
− | Level.drop("chaingun") | + | Level.drop("chaingun") --sweet drop |
− | Level.summon("demon",3) | + | Level.summon("demon",3) --3 demons |
− | Level.summon("cacodemon",DIFFICULTY) | + | Level.summon("cacodemon",DIFFICULTY) --1/2/3/4/5 cacodemons |
+ | --player chose to stop | ||
else | else | ||
ui.msg("The voice booms, \"Coward!\" ") | ui.msg("The voice booms, \"Coward!\" ") | ||
Line 161: | Line 188: | ||
</source> | </source> | ||
− | + | '''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 | + | 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 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. | + | *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 | + | '''Level.drop()''' should be self-explanatory: is it the item equivalent of '''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. |
<source lang="lua"> | <source lang="lua"> | ||
.... | .... | ||
− | elseif Level.result() == 2 then | + | elseif Level.result() == 2 then --if second wave completes |
+ | --even more talk | ||
ui.msg("The voice booms, \"Impressive mortal! Your determination") | ui.msg("The voice booms, \"Impressive mortal! Your determination") | ||
ui.msg("to survive makes me excited!\"") | ui.msg("to survive makes me excited!\"") | ||
Line 178: | Line 206: | ||
ui.msg("\"I can let you go now, and give you a small reward, or") | 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!\"") | 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?\"") | local choice = ui.msg_confirm("\"Do you want to continue the fight?\"") | ||
− | if choice then | + | if choice then --player chosen to continue |
− | core.play_music(" | + | core.play_music("rounds_of_hell_part_3") --strongest music yet |
player:play_sound("three") | player:play_sound("three") | ||
ui.msg("The voice booms, \"Excellent! May the fight begin!!!\"") | ui.msg("The voice booms, \"Excellent! May the fight begin!!!\"") | ||
ui.msg("You hear screams everywhere! \"Kill, Kill, KILL!\"") | ui.msg("You hear screams everywhere! \"Kill, Kill, KILL!\"") | ||
− | + | ||
+ | --some more sweet loot | ||
Level.drop("shell",4) | Level.drop("shell",4) | ||
Level.drop("ammo",4) | Level.drop("ammo",4) | ||
− | if DIFFICULTY <= 2 then | + | --spawns are a little more difficulty-dependent |
− | Level.summon("cacodemon",DIFFICULTY+ | + | if DIFFICULTY <= 2 then --ITYTD and HNTR |
− | elseif DIFFICULTY == 3 then | + | Level.summon("cacodemon",DIFFICULTY+1) |
+ | elseif DIFFICULTY == 3 then --HMP | ||
Level.summon("knight",2) | Level.summon("knight",2) | ||
− | elseif DIFFICULTY >= 4 then | + | elseif DIFFICULTY >= 4 then --UV and N! |
Level.summon("baron",2) | Level.summon("baron",2) | ||
end | end | ||
− | else | + | else --player chose to stop |
ui.msg("The voice booms, \"Too bad, you won't make it far then...!\" ") | ui.msg("The voice booms, \"Too bad, you won't make it far then...!\" ") | ||
ui.msg("You hear screams everywhere! \"Boooo...\"") | ui.msg("You hear screams everywhere! \"Boooo...\"") | ||
− | + | ||
+ | --pointless loot | ||
Level.drop("shell",3) | Level.drop("shell",3) | ||
Level.drop("lmed") | Level.drop("lmed") | ||
Line 206: | Line 238: | ||
</source> | </source> | ||
− | This part of the | + | 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!). | + | *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. | *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. | ||
<source lang="lua"> | <source lang="lua"> | ||
.... | .... | ||
− | elseif Level.result() == 3 then | + | 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("The voice booms, \"Congratulations mortal! A pity you came to") | ||
ui.msg("destroy us, for you would make a formidable hell warrior!\"") | 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("\"I grant you the title of Hell's Arena Champion!\"") | ||
ui.msg("\"And a promise is a promise... search the arena again...\"") | ui.msg("\"And a promise is a promise... search the arena again...\"") | ||
− | + | ||
+ | --also pointless loot | ||
Level.drop("scglobe") | Level.drop("scglobe") | ||
Level.drop("barmor") | Level.drop("barmor") | ||
Line 226: | Line 260: | ||
</source> | </source> | ||
− | Finally, | + | 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. |
<source lang="lua"> | <source lang="lua"> | ||
.... | .... | ||
+ | --increment level counter each time a wave completes | ||
Level.result(Level.result()+1) | Level.result(Level.result()+1) | ||
end | end | ||
</source> | </source> | ||
− | This final piece of code is very important: each time | + | This final piece of code is very important: each time '''OnKillAll()''' is triggered, after all the '''Level.result()''' checks are made, '''Level.result()''' 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()=== | ||
<source lang="lua"> | <source lang="lua"> | ||
function arena.OnExit() | function arena.OnExit() | ||
ui.msg_enter("The voice laughs, \"Flee mortal, flee! There's no hiding in hell!\"") | ui.msg_enter("The voice laughs, \"Flee mortal, flee! There's no hiding in hell!\"") | ||
− | + | ||
− | if result < 4 | + | --takes care of mortem text for "killed by something unknown..." |
− | then arena.result = "fled alive the trials at wave "..result | + | if Level.result() < 4 |
+ | then arena.result = "fled alive the trials at wave "..Level.result() | ||
else arena.result = "completed the trials" | else arena.result = "completed the trials" | ||
end | end | ||
Line 247: | Line 284: | ||
</source> | </source> | ||
− | Once the player leaves the level (and | + | 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()=== | ||
<source lang="lua"> | <source lang="lua"> | ||
function arena.OnMortem() | function arena.OnMortem() | ||
− | local kill = player.killedby | + | local kill = player.killedby --calls kill descriptions from beings |
if arena.result then kill = arena.result end | if arena.result then kill = arena.result end | ||
player:mortem_print( " "..player.name..", level "..player.explevel.." " | player:mortem_print( " "..player.name..", level "..player.explevel.." " | ||
.." "..klasses[player.klass].name..", "..kill ) | .." "..klasses[player.klass].name..", "..kill ) | ||
− | player:mortem_print(" | + | --e.g., "Cool Guy, level 1 Marine, fled alive the trials at wave 3" |
− | + | player:mortem_print(" in the Hell Arena...") | |
+ | end | ||
</source> | </source> | ||
− | The | + | 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: | ||
− | + | {{:Modding:Tutorial/Recreating Hell's Arena/Source}} | |
− | + | ||
− | + | Source data: [http://dl.dropbox.com/u/54818507/arena.module.zip arena.module] |
Latest revision as of 17:09, 8 April 2012
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("rwall") --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) --all walls become indestructible Generator.set_permanence(area.FULL) --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 of 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.result() 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("rwall") --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) --all walls become indestructible Generator.set_permanence(area.FULL) --inserts player Level.player(38,10) end
Source data: arena.module