Modding:Tutorial/Constructing a Map
From DoomRL Wiki
There are endless possibilities when it comes to modding DoomRL, whether it be custom enemies and items, or additional special levels, or maybe themes that depend on what day of the week it is. However, much of this can be difficult to understand, let alone create, if you are unfamiliar with programming techniques. For those of you simply seeking to create a single level, leaving the core elements of the game intact, there is a very easy method of doing this, especially with the advent of modules.
Contents |
Initial Setup
If you are creating a mod, the first thing necessary for DoomRL to recognize it is a directory that is the identifier of your mod, along with a .module at the end: this would be placed in the "modules" directory, which exists wherever your DoomRL directory is. For instance, if I were creating a mod that I would identify as "coolmap" (and assuming that my DoomRL directory were in something like C:/Games), the path to the module files would look like this:
C:/Games/DoomRL/modules/coolmap.module
Note that this identifier is not necessarily the NAME of your map, it is simply a way to distinguish it from other modules.
After we have created this directory, there are two necessary lua files that must be in it: main.lua, which acts as the file that initializes all of the module's data; and module.lua, which provides information about the module itself. main.lua will contain everything important to the actual modding part of this tutorial, but we shall look at module.lua first.
If you are unfamiliar with creating lua script files, it is as simple as creating a text file and changing the file extension to ".lua". Refer to the Tutotial main page for more information.
module.lua
module.lua is a very short script, containing only what is called a metadata table, or a table that holds information about the mod. Every module.lua should look have a base like so:
module = { id = "", --identifier of the module (same as the folder sans '.module') name = "", --name of module as it appears in the Custom Game menu author = "", --your name/gamertag/nickname/alias webpage = "", --link to your page (use 'none' if you don't have one) version = {0,0,0}, --version of the mod itself (first version is typically {0,1,0}) drlver = {0,0,0,0}, --version of DoomRL that module was made for (e.g., {0,9,9,4}) type = "single", --module type (currently only "single" works) description = "", --explains mod, though you could put whatever you want here difficulty = true, --allows player to set difficulty (false forces ITYTD) }
Feel free to copy and paste this into your module.lua, then fill in the information as necessary.
main.lua
main.lua is the core of your module, containing all of the necessary components to make the mod run as you would like it to. To start, every main.lua requires the following line:
core.declare("module_id", {} ) --replace module_id with your module's actual id
This declares a module table that you will use to store all of the module-ranging components. In the case of single levels (currently the only mod type available), this essentially replaces the Level object for the purposes of creating engine hooks that depend on events that occur within the single level. These are the following engine hooks that your module can use:
function module_id.run() end --works just like Level.Create() (initialize map/name/spawn point/etc) function module_id.OnEnter() end --triggers immediately after .run(), when player enters the map function module_id.OnTick() end --triggers every game turn function module_id.OnKill() end --triggers whenever a being other than yourself dies function module_id.OnKillAll() end --triggers whenever all beings other than yourself are dead function module_id.OnExit() end --triggers when player exits the map (for instance, decsending stairs)
If you choose not to use an engine hook, you don't need to include it in the script, but there's no harm in writing an empty hook in there, either.
We will now provide some very basic examples of each engine hook so you get an idea of how they can be used. The module's identifier will be "blank".
.run()
function blank.run() Level.name = "Blank Map" --name of map as it appears in lower-right corner of HUD Level.name_number = 0 --shows "LevX" where X is value: use 0 to remove entirely Level.fill("pwall") --necessary step, use whatever wall you will mostly use --matches all characters on map to an object_id local translation = { ["."] = "floor", ["#"] = "pwall", [">"] = "stairs", } --the map itself local map = [[ ############################################################################## #............................................................................# #............................................................................# #............................................................................# #............................................................................# #............................................................................# #............................................................................# #............................................................................# #............................................................................# #...........................................>................................# #............................................................................# #............................................................................# #............................................................................# #............................................................................# #............................................................................# #............................................................................# #............................................................................# #............................................................................# #............................................................................# ############################################################################## ]] Level.place_tile(translation,map,1,1) --this is the map-creation step Level.player(39,10) --player is placed at this coordinate end
.run() deals with the absolutely necessary pieces if you want the module to work at all. Here we define the level's in-game name, create the map, and add the player. There are two important variables we define:
- translation is a table of all object ids that will be placed on the map, along with the symbols that define them on said map. You can use any symbol you want to define an object (for instance, using ["y"] = "pwall"), just remember to place it on the map with that symbol. The symbols as they are used here will not affect how the actual objects appear in-game.
- map is a string that corresponds to what the map should look like when it is initialized. Here we use double-square brackets ("[[" and "]]"), which takes every character between them and writes into a single string. You can start new lines as much as you want, but do NOT include spaces unless the space character is one of the symbols used in translation.
You can define "translation" and "map" whatever you want so long as Level.place_tile calls them appropriately. The last two arguments define what x- and y-coordinates are used to begin the placing: since we are copying an entire map, we start from the upper-left tile (which is (1,1)).
.OnEnter()
function blank.OnEnter() ui.msg("Hello world!") --appears at the top of the screen player.inv:add("shotgun") --puts a shotgun in the player's inventory player.inv:add("shell") --puts a shotgun shell (x8) in the player's inventory end
The .OnEnter() hook has fairly limited uses, since it is only called once and doesn't do the crucial steps like making the map itself. For the most part, it is used to add messages at the start of the map, change around the player's inventory, and, depending on what else is going on in the map, initialize some timers or countdowns.
.OnTick()
local countdown = 600 --set up a timer local minute = 0 --set up a minute counter local exit_check = true --set up a position check function blank.OnTick() if countdown == 0 then countdown = 600 minute = minute + 1 ui.msg("Number of minutes passed: ".. minute) end countdown = countdown - 1 if player:get_position() == coord.new(45,10) and exit_check then ui.msg("Leaving already?") exit_check = false end end
.OnTick() is, by far, the most practical trigger of the module engine hooks, allowing you not only to set up time-specific events but also position-specific ones. In the example above, both cases are used:
- A countdown variable starts at 600: whenever it reaches zero (as 60 game seconds have passed), the countdown is reset, minute is incremented by one, and the game tells you how many minutes have passed.
- Whenever the player enters the same tile as the stairs (the stairs happen to be on (45,10)), the game sends a message, and then sets exit_check to false. Were this check not added, the message would display continuously for each turn that you stay in the tile (so it will keep displaying the message if you wait on the tile, for instance). However, this also means it will only display once, as there are no conditions in the code in which exit_check can be made true again.
Setting up too many events in the .OnTick() hook may cause some game slow-down, but it usually takes a monstrous amount of code before there is even a noticeable effect.
.OnKill()
local killCount = 0 --set up a kill counter function blank.OnKill() killCount = killCount + 1 --increment the kill counter --display messages dependent on kills if killCount == 1 then ui.msg("You have killed 1 enemy.") else ui.msg("You have killed " .. killCount .. " enemies.") end if killCount == 10 then ui.msg("Wow, killing spree!") end end
.OnKill() lets you trigger events when an enemy is killed by you specifically. There are a number of possibilities you may want to try (adding to berserk time, increasing health, spawning new enemies, etc): this example displays a simple message whenever you kill something, and gives you a metaphorical pat on the back for ten kills.
.OnKillAll()
function blank.OnKillAll() Level.summon("former",5) ui.msg("They just keep coming!!") end
.OnKillAll() is like .OnKill() but only triggers when everything on the map has died. This is how, for instance, Hell's Arena knows when to start the next wave of enemies (or when you're completely done). In this example, whenever you kill everything on the map, the game randomly spawns five former humans, essentially allowing this trigger to occur for as many times as the player wants to do so.
.OnExit()
function blank.OnExit() ui.msg_enter("Goodbye world!") end
.OnExit(), just as with .OnEnter(), is limited in its uses when it comes to a single level. You can add some messages, maybe tidy up some variables for the mortem, but once the player is leaving there isn't much left with which to interact. (In this case, ui.msg_enter() is used in order to force a confirmation on the player: otherwise the message could very well be lost in the mortem bloodslide effect.)
Review
The following is a summation of all the lines of codes used to create the module.lua and main.lua scripts, with a few changes here and there to ensure that everything will run as intended (and a couple additions to make it more playable).
--module.lua file module = { id = "blank", name = "A Blank Map", author = "DoomRL Mod Team", webpage = "http://doom.chaosforge.org/wiki/Modding:Tutorial", version = {0,1,0}, drlver = {0,9,9,4}, type = "single", description = "This is an example mod, intended to be altered by anyone wanting " .. "to test things using the DoomRL base set.", difficulty = true, }
--main.lua file core.declare("blank", {} ) function blank.OnEnter() ui.msg("Hello world!") --give the man a shotty player.inv:add("shotgun") --create shotgun shell item with a modified amount of ammo local pack = item.new("shell") pack.ammo = 50 player.inv:add(pack) end --local variables not within a loop are local to the file itself --for global variables (across all files), you want to use core.declare() --always remember to declare things before they are called somewhere else! local countdown = 600 local minute = 0 local exit_check = true function blank.OnTick() --keeps track of game minutes if countdown == 0 then countdown = 600 minute = minute + 1 ui.msg("Number of minutes passed: ".. minute) end countdown = countdown - 1 --warns player if they are on stairs if player:get_position() == coord.new(45,10) and exit_check then ui.msg("Leaving already?") exit_check = false end if player:get_position() ~= coord.new(45,10) and not exit_check then exit_check = true end end local killCount = 0 function blank.OnKill() killCount = killCount + 1 --display messages dependent on kills if killCount == 1 then ui.msg("You have killed 1 enemy.") else ui.msg("You have killed " .. killCount .. " enemies.") end if killCount == 10 then ui.msg("Wow, killing spree!") end end function blank.OnKillAll() --more enemies are added whenever all are defeated Level.summon("former",5) ui.msg("They just keep coming!!") --as a compensation, you are healed to 100% each time player.hp = player.hpmax end function blank.OnExit() ui.msg_enter("Goodbye world!") end --Personally I like to add this at the end, but these hooks can be placed in any order. function blank.run() Level.name = "Blank Map" Level.name_number = 0 Level.fill("pwall") local translation = { ["."] = "floor", ["#"] = "pwall", [">"] = "stairs", ["h"] = {"floor", being = "former"}, --places cell with being on top ["]"] = {"floor", item = "garmor"}, --places cell with item on top ["k"] = {"floor", being = "former", item = "phase"}, --places cell with both } local map = [[ ############################################################################## #............................................................................# #............................................................................# #............................................................................# #............................................................................# #...................................................h........................# #............................................................................# #......................................................h.....................# #............................................................................# #......................................]....>........h.....k.................# #............................................................................# #......................................................h.....................# #............................................................................# #...................................................h........................# #............................................................................# #............................................................................# #............................................................................# #............................................................................# #............................................................................# ############################################################################## ]] Level.place_tile(translation,map,1,1) Level.player(39,10) end
Source data: blank.module
WAD file: blank.wad