Modding:Tutorial/Recreating Hell's Arena

From DoomRL Wiki

Revision as of 05:37, 16 September 2011 by Game Hunter (Talk | contribs)

(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to: navigation, search

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.

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. Instead, 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 of the explosion to zero: this removes the visual 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.)

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 added at the beginning of the map and changes for each wave.

As with all example modules in these tutorials, we will explain things by declaration. Usually this would involve looking at any cells/items/beings (or just old-fashioned tables) but this modules has no new objects. Instead, we will move through each engine hook and how it affects the map itself.

core.declare( "arena" , {} )
 
function arena.run()
	Level.name = "Hell Arena"
	Level.fill("prwall")
	local translation = {
		['.'] = "floor",
		[','] = "blood",
		['#'] = "rwall",
		['>'] = "stairs"
	}
 
	local map = [[
#######################.............................########################
###########.....................................................############
#####..................................................................#####
##........................................................................##
#..........................................................................#
............................................................................
..................................,,,,,,....................................
..,,,.............................,,,,,,,...................................
..,>,............................,,,,,,,,,..................................
..,,,............................,,,,,,,,...................................
..................................,,,,,,....................................
............................................................................
............................................................................
#..........................................................................#
##........................................................................##
#####..................................................................#####
###########.....................................................############
#######################.............................########################
	]]
	local column = [[
,..,.,
,####.
.####,
.####.
,..,.,
	]]
 
	Level.place_tile( translation, map, 2, 2 )
	Level.scatter_put( area.new(5,3,68,15), translation, column, "floor",9+math.random(8))
	Level.scatter( area.FULL_SHRINKED,"floor","blood",100)
	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)).

function arena.OnEnter()
    core.play_music("rounds_of_hell_1")
    player:play_sound("preparetofight")
    player.eq.weapon = item.new( "shotgun" )
    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)
    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.

    ....
    Level.summon("demon",3)
    Level.summon("lostsoul",2)
    Level.summon("cacodemon",DIFFICULTY-1)
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.

function arena.OnKill()
	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).

function arena.OnKillAll()
    if Level.result() == 1 then
        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!\"");
 
        local choice = ui.msg_confirm("\"Do you want to continue the fight?\"")
        if choice then
            core.play_music("rounds_of_hell_2")
            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")  
            Level.summon("demon",3)
            Level.summon("cacodemon",DIFFICULTY)
        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
        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!\"")
 
        local choice = ui.msg_confirm("\"Do you want to continue the fight?\"")
        if choice then
            core.play_music("rounds_of_hell_3")
            player:play_sound("three")
            ui.msg("The voice booms, \"Excellent! May the fight begin!!!\"")
            ui.msg("You hear screams everywhere! \"Kill, Kill, KILL!\"")
 
            Level.drop("shell",4)
            Level.drop("ammo",4)
            if DIFFICULTY <= 2 then
                Level.summon("cacodemon",DIFFICULTY+2)
            elseif DIFFICULTY == 3 then
                Level.summon("knight",2)
            elseif DIFFICULTY >= 4 then
                Level.summon("baron",2)
            end
        else
            ui.msg("The voice booms, \"Too bad, you won't make it far then...!\" ")
            ui.msg("You hear screams everywhere! \"Boooo...\"")
 
           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
        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...\"")
 
        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.

    ....
    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.

function arena.OnExit()
    ui.msg_enter("The voice laughs, \"Flee mortal, flee! There's no hiding in hell!\"")
    local result = Level.result()
    if result < 4
        then arena.result = "fled alive the trials at wave "..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" (which is different from the local variable "result" that is defined here as well). 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 the local "result". If Level.result() is 4 (or technically greater) then arena.result writes out that the player finished. But where is this string going to?

function arena.OnMortem()
    local kill = player.killedby
    if arena.result then kill = arena.result end
        player:mortem_print( " "..player.name..", level "..player.explevel.." "
                             .." "..klasses[player.klass].name..", "..kill )
        player:mortem_print(" at 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 instead. Since arena.result is only created if the player chooses to exit the game, this takes care of 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).

Personal tools