Modding:Tutorial

From DoomRL Wiki

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

Jump to: navigation, search
Under Construction!
This page is still undergoing development by one or few members. It is highly recommended that you do not edit anything here until this tag is removed.

Welcome to the DoomRL modules tutorial. The objective of this guide is to explain how to develop your own modules for DoomRL as easily and as accurately as possible: therefore, its scope is fairly limited and will only cover basic techniques in customizing objects, maps, and game events. However, it is written at the level of one who has little to no background knowledge of programming languages, and so is ideal for a player who doesn't understand the fundamentals of scripting. Anyone who has the ambition to create a unique and enjoyable game out of the DoomRL engine should use this as a tool to get started.

Modifying DoomRL content is done through lua, a scripting language designed to communicate with a variety of other computer languages. (You can find out more about lua by following this link.) As such, a great deal of what can be done for the purpose of DoomRL modding is handled by the API, or application programming interface, which allows a modder to issue commands that are sent to the DoomRL engine (written in Pascal). The formatting and syntax is relatively simple and flexible, so there are a number of ways to write code in lua that will produce the same outcome. We will use a particular format, although alternatives will be explained as they come up.

Whenever example code is used in this tutorial, it will be displayed using a special formatting, as shown below:

This is an example sentence.
All spaces/symbols are shown completely          like so.
In addition, html formatting <b>cannot</b> work here.
However, certain words (for in or and, etc) as well as formats are colored;
--comments in the language are given using two dashes;
'and strings are expressed' between "single or double quotes".

Much of the tutorial is written in this format, as it is far easier to diplay lua without the default formatting of Wikimedia getting in the way.

Contents

Basic Syntax and Definitions

The following is a very quick explanation of general programming syntax in lua. If you know even a little about programming, you do not likely need to read any of this.

Writing in a computer language is very brief and exact, and never involves any connotations or implications. In fact, with the exception of particular inputs and outputs, all of the language is entirely internal, purposed to communicate with the computer in order to perform calculations. The basis of these calculations rests on binary relations: true (1) and false (0). If you have a routine that should only be performed until certain conditions, for example, you will want to use binary relations to determine whether or not the routine is run.

The most basic relations are simple comparisons with numbers.

3 == 3  --true (note that equality relations are understood with ==)
12 == 1 --false
6 >= 2  --true
6 <= 2  --false

You can also use arithmetic signs (+, -, *, /) with numbers: any arithmetic operators will be done before checking the relational operator.

Rather than directly using numbers, you will likely want to assign them to a variable. In lua, declaring a variable can be done by preceding it with the word "local", and assigning the variable a value is done using the equal sign.

local yes = 1
local no = 0

With this code, whenever you would use the variable 'yes', it will be read as a true value, and whenever you would use the variable 'no', it will be read as a false value. (Note that the parameters are not always "local": this will be covered more in detail later.) Variables can be reassigned as much as you want unless they are read-only on the engine side (this comes up in a few places for our purposes).

Variables in lua can be of a variety of different types. Typically you will use the following:

local var_void = nil          --singular value, used mostly to be a placeholder
local var_boolean = true      --double value, true/false meant for satisfying conditions
local var_number = 3.14159    --double-precision floating-point value, pretty much any number you could possibly want
local var_string = 'awesome'  --sequence of characters, must have single- or double-quotes around them
local var_function = print    --subroutine, used to reference pieces of code over an entire file
local var_table = {1 = 'a'}   --associative array of fields, holds a lot of information in a single place (explained later)

For the purpose of modding, you are often restricted by what type you can use based on what is allowed by the API. If an input was expecting a string and you use a boolean value, you will almost certainly get an error. Note that the left side could be just about anything you want: use whatever naming scheme works for you. (It is unadvised that you use "reserved words" for variables: we will see a few just below.)

Trying to determine how your code will run is primarily executed through conditional statements. These include:

if condition    --check to see if condition is true
    then result --if condition was true, do result
end             --conditional statements must always finish with this line
 
if condition1     --check to see if condition1 is true
    then result1  --if condition1 was true, do result1
elseif condition2 --if condition1 was false, check to see if condition2 is true
    then result2  --if condition2 was true, do result2
else
    result3       --if condition2 (and condition1) was false, do result3
end
 
while condition --check if condition is true
   do task      --if condition was true, task is executed; if it was false, skip
end             --if condition was true, repeat the statement; if it was false, end statement
 
repeat          --repeat the following lines indefinitely
   task         --task executes (always executes at least once)
until condition --if condition is true, end statement
 
for var=val1,val2,val3 --iterate following lines based on vals
    do task            --task is executed a number of times equal to the number of vals
end
 
for var=first,step,last --iterate following lines from first to last, using step as iterator
    do task             --task is executed (last-first+1)/step times (rounded down)
end
 
for var,iter in ipairs(array) --iterate following lines for all fields in array, iter acts as index (if necessary)
    do task                   --task is executed for as many fields as there are in the array
end
 
for var,iter in pairs(table) --same as above but with a table
    do task                  --task is executed for as many keys as there are in the table
end

If a lot of these are confusing, don't worry as we'll eventually manage to find ways to include them in the tutorial as we go. Finally, we have two miscellaneous statements: break and return. Break will prematurely exit a conditional statement, and could be used to, for instance, exit a "while true" loop (although this should probably be avoided). Return is used with functions. Calling return in a function ends execution of that function immediately and can be used to return data to whatever called it.

Constructing a Map

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.

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.

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

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

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

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.

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

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
        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. Of particular note is the double-dot syntax: if you want to add variables or line-breaks in a string, end the string, include the double-dot, and add the variable or start a new line.

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.

function blank.OnExit()
    ui.msg("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.

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          = "A Blank Map",
    name        = "blank",
    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
        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
        ui.msg("Leaving already?")
        exit_check = false
    end
    if player:get_position() ~= coord.new(45,10) and not exit_check
        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
        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("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"] = {"stairs", being = "former"},                 --places cell with being on top
    ["]"] = {"stairs", item = "garmor"},                  --places cell with item on top
    ["k"] = {"stairs", 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

Try it out and mess around. The code used here should be easy enough to understand that a player can apply it as a base for quick-test modules. If you plan on using it yourself, be sure to create a "blank.module" folder in your modules directory and add these two scripts (as .lua files) into that folder.

Game Objects

A very large part of customizing the objects in DoomRL (weapons, powerups, enemies, etc) has to do with creating, manipulating, and referencing lua tables. All objects in the game are associated with a particular table: they hold a number of fields, each of which contain a key (the variable) and a value. The following is an example of a very simple table:

Dude = {
    name = 'John Romero',
    type = 'Awesome guy',
}

Adding this to a lua script would create a table called "Dude" with two fields: the key 'name', which is set to the value 'John Romero'; and the key 'type', which is set to the value 'Awesome guy'. (You can use numbers for keys as well.) If we want to reference the values in this table, we can use the following format:

Dude.name --gives the value 'John Romero'
Dude.type --gives the value 'Awesome guy'
 
Dude["name"] --same as Dude.name

While you can create tables yourself, more often than not you will be creating tables based on a specific class. (A class is like a variable type, except it can carry a vast amount of variables, tables, and functions within it. You just need to know how to work with them so there's no reason to go into detail.) These classes are based on the game's core objects and include "Beings", "Items", and "Missiles". To create a table using a class, do not use an equal sign.

Beings{
    name = "super enemy",
    ascii = "S"
    ....
}

The result is that a new table called "super enemy" will exist as a part of the Beings class.

There are four important parts to understand when working with classes in DoomRL modding:

  • The prototype is the game object's mold: the game uses it to make as many objects as necessary. Whenever you create a new object, you will do so by creating the prototype.
  • The properties are the variables in a particular object that can be modified for said object. They are distinct from prototype: changing values in the prototype affects ALL objects, while changing properties affects only the object in your scope.
  • The engine hooks are ways to execute code when particular events occur for an object. Hooks are why phase devices teleport you around when you use them (with an OnUse hook) and why shamblers regenerate their health every turn (with an OnAct hook).
  • The API is a set of functions that lets you interact with various pieces of the DoomRL engine. Often this gives you an easier way to change the prototype after initializing it, although there are a wide-ranging collection of functions for each class that allow for interesting game possibilities.

We will cover all of these in the following sections.

Prototype

Every game object starts with its prototype. It is meant to be a stable definition of an object from which various properties of an instantiated object can be used and modified. We will begin by looking at the basic keys used to designate an Item object prototype (full documentation can be found here).

Items{
    name = 'a thing',      --name of the item, as viewed in-game
    id = 'thing',          --item's in-script reference name (defaults to name)
    color = LIGHTGRAY,     --color of item, as viewed in-game
    level = 1,             --minimum in-game floor on which that item can appear
    weight = 1000,         --weighted likelihood of item spawning
    type = ITEMTYPE_NONE,  --type of item
}

You should notice that, in addition to the normal value types, there are special values used for "color" and "type". Very often there are a number of pre-defined values by the game and can be found in full in the constants and flags page, which should be used in some of the prototype fields. In particular, color can be any of sixteen colors (e.g., LIGHTGRAY) and type can be any of twelve particular item settings. Most often you will see types such as ITEMTYPE_RANGED, ITEMTYPE_PACK, and ITEMTYPE_POWER used, but you should take a look at all of them to see what best suits your need for the particular item.

ITEMTYPE_RANGED, for instance, contains the following additional prototype keys:

Items{
    name 'big gun',
    ....
    type = ITEMTYPE_RANGED,
    desc = "It's a really big gun.", --in-game description, as viewed in the inventory
    ascii = '}',                     --single character representing the weapon, as viewed in-game
    damage = '4d4',                  --weapon's damage: should be written as 'XdY' ala dice notation
    damagetype = DAMAGE_BULLET,      --weapon's damage type
    group = 'weapon-biggun',         --weapon's category: mainly for score-keeping
    ammoID = 'bigbullet'             --ammo that weapon uses: use the id of that ammo here
    fire = 10,                       --how quickly the weapon fires, in turns (1 second = 10 turns)
    acc = 3,                         --how accurate the weapon is
    ammomax = 50,                    --weapon's clip size
    radius = 0,                      --size of shot's explosion radius
    shot = 5,                        --how many shots the weapon fires
    shotcost = 1,                    --how much ammo each shot takes
    missile = big_shot,              --what the projectile of the weapon is
}

For the desc key, since the string itself contains a single quote, we wrap the string around double-quotes instead: lua handles this without error. Alternatively, we could use a backslash (such as \") to add otherwise-unusable characters in strings.

There are also some additional keys for alt-fires and alt-reloads, but these are the most important for setting up a ranged weapon. Notice that the last key requires an id from a missile object: while there are a number of pre-set missiles from which you can choose, you can also create your own missile prototype and use the id key from that prototype for the missile (the above example would require a missile object with the id of 'big_shot'). Additionally, if the missile you design is unique to this weapon, you can define the prototype directly in the weapon, like so:

Items{
    name 'big gun',
    ....
    missile = Missiles{ --assign missile prototype
        ....            --missile prototype keys go here
    }                   --missile prototype completes on this line
}                       --item prototype completes on this line

In creating the missile this way, you can omit the id of the missile, as it is automatically created for you (and should not need to be called anyway).

Referencing and changing prototype values is a more difficult than changing properties (see below), and potentially dangerous depending on what you are trying to do. It is quite possible, however, by first referencing the class that the prototype belongs to, followed by the id of the prototype, followed by the key whose value you wish to reference/change:

local new_damage = '3d3'         --create a damage variable
items.pistol.damage = new_damage --change the damage key of the pistol prototype

Executing this code will change the damage of every pistol object in the game. In general, this is not recommended for use with any code that occurs after the game has already started.

Properties

An object's properties are its own unique set of characteristics that set it apart from any other object in the game, including other copies of the same object. An object will initially receieve its property values from the object's prototype on creation, but they can be changed constantly after this without affecting other object instances.

One easy object that can have its properties manipulated without worrying about other instances is the player. As there is only ever one player in the game at any time, it is much easier to simply manipulate property values rather than digging into the prototype. The player has properties from both the player object and the being object, since the player is actually a sub-class of beings. If we wanted to change the player's HP and level, we would do it in the following way:

player.hp = player.hp + 10 --increases player's HP by 10
player.explevel = 5        --sets player's level to 5

Note that the HP property is "hp", not "HP": while this is just a case-sensitive example, sometimes the key of a property field representing a prototype key can be very different (beings have the "XP" field for how much experience they give, but an "expvalue" for their corresponding property). Be aware of these differences when coding.

Properties must always refer to a specific instance of an object. While it may seem bothersome to have to point to a specific object every time you want to change a property, these are automatically handled by engine hooks and the API, as we will see shortly.

Engine Hooks

Engine hooks are functions that allow you to truly customize object interaction. They are associated with specific events in the DoomRL engine and essentially allow for input of anything you want to occur during those events.

Like prototype keys, engine hooks are added during the initialization process of the object. They should be placed at the end of all the prototype keys, and are written much like functions:

Beings{
    name = "nasty",
    ....                            --other prototype keys are added
    OnAction = function(self)       --engine hook is called
        ....                        --code to be executed in the hook goes here
    end,                            --function ends
}                                   --prototype ends

The following shows a being called "nasty" getting initialized into DoomRL and, with it, the hook OnAction. Hooks work just like functions do, carrying input arguments and a potential output return value. OnAction has a "self" input argument, which is used as a reference to the being itself whenever OnAction is called; OnAction has a void output argument, meaning that you cannot return a value using this hook. (Note that, for hooks with output arguments, the return value affects an outcome on the engine side rather than a usable value in the code.) The OnAction function itself is called whenever a being carries out an action (usually once per second), and so will be constantly called over the course of the being's lifetime.

Hooks are applied to a particular instance of an object, not the prototype itself. This means that, if you're changing, for instance, the HP value of a being, you must call the being's HP property rather than its prototype key. What makes the input argument so useful is that it automatically refers to whatever being activated the hook, rather than you needing to specify exactly which being needs to call the function. Thus, if we want a being object that regenerates its health on every action (like the shambler), we would write the following code:

Beings{
    name = "regenerator",
    ....
    OnAction = function(self)       --initialize the hook
        if self.hp < self.hpmax     --prevent being from healing past maximum health
            self.hp = self.hp + 1   --being gains one HP
        end                         --end of conditional statement
    end,                            --end of engine hook
}

First, we check to see if the being instance's HP is less than its maximum HP: if this is true, we raise the hp property of the instance by one; if not, nothing happens. Once the if statement finishes, there is nothing left for the function to do, and so it ends as well. OnAction will make it so that the if statement is called every time the being instance acts, so it could be hard to kill something like this! (Anyone trying to kill shambler with a shotgun should understand well enough.)

Most of the engine hooks are found with the item and level object classes, since there are a number of events that the engine specifically checks for these objects over the course of their lifetimes. Common hooks are OnCreate, OnEnter, and OnDestroy/OnDie (which practically do the same thing, just for different object classes).

API

Object API is very simple in DoomRL: for each object class, there is a set of pre-defined functions that allow modders to manipulate objects of that class. Most, if not all, of the functions in an object's API are otherwise impossible to create within lua itself, which is why we are lucky to have them available. Unlike functions, however, you need not define them as such: simply writing the functional expression itself (without preceding it with "local function", for example) is all that is necessary.

In lua, the convention for API functions is to use a period between the class and the function. If the first argument requires a particular instance of an object in that class, one can use shorthand by writing out the object instance, followed by a colon, then the function name, at which point the first argument is automatically used in the function without having to explicitly write it.

being.new(id)             --this will create a new instance of the being object associated with id
being.destroy(some_being) --this will destroy a specific being object (whose id is some_being)
 
some_being:destroy()      --identical to being:destroy(some_being)

Whenever you are not working with a particular instance of an object, it usually means you are creating a new one. For some objects, this is a very important use. However, most of the API is meant to interact with specific object instances, so we will take a look at an example for such a function.

Let us suppose we want to make an enemy explode whenever it dies. One way to do this is the following:

Beings{
    name = "exploder"
    ....
    OnDie = function(self,true)                                        --engine hook upon death
       position = self:get_position()                                  --position for being is found
       Level.explosion(position, 3, 30, 5, 5, YELLOW, "", DAMAGE_FIRE) --explosion occurs on the map
    end,
}

Here we use the OnDie hook to execute code whenever an "exploder" being dies. Creating an explosion on the map is done with the API function Level.explosion, which carries quite a few input arguments: coordinates on the map where explosion should happen, radius of explosion, millisecond delay on explosion's path, damage dice, side of damage dice, color of explosion, sound id for explosion, and damage type of explosion. (Note that there are even more arguments, but these can be safely ignored if they appear after all the arguments we end up using.) The coordinates of the being instance are not immediately accessible in its properties, but can be found with the thing:get_position() API function (where the Thing in this case is the being instance, or "self", defined inside of the OnDie hook). In order to get the coordinates, we assign an output argument "position" to get_position, and use it in Level.explosion when we need to define them.

Review

As a final, rather large example, we will take a look at one of the enemies from the HereticRL mod (though this exact code might be outdated at some point), the undead warrior. The undead warrior is a fairly straight-forward enemy with the unique twist in that it will occasionally throw red axes (as opposed to green ones), which deal much more damage. To start, we will first need to initialize the two axe weapons: since the undead warrior will have more than one weapon, we cannot inline them within the being definition.

Items{
	name = "nat_undeadgreen",       --this is the green axe weapon
	id = "nat_undeadgreen",
	type = ITEMTYPE_NRANGED,        --natural ranged-weapon type
	damage = "2d6",
	damagetype = DAMAGE_BULLET,
	fire = 10,
	missile = {                     --in-line missile definition
		soundID = "undead",
		ascii = "x",
		color = LIGHTGREEN,
		delay = 80,
		miss_base = 10,
		miss_dist = 5,
	},
	weight = 0,                     --don't want these being generated!
	flags = {IF_NODROP, IF_NOAMMO}, --these are standard flags for NRANGED items
}
 
Items{
	name = "nat_undeadred",         --this is the red axe weapon
	id = "nat_undeadred",
	type = ITEMTYPE_NRANGED,
	damage = "6d2",                 --better damage than green axe (on average)
	damagetype = DAMAGE_PLASMA,     --stronger damage type
	fire = 10,
	missile = {                     --in-line missile definition
		soundID = "undead",
		ascii = "x",
		color = LIGHTRED,       --different color
		delay = 50,
		miss_base = 10,
		miss_dist = 3,          --misses less
	},
	weight = 0,
	flags = {IF_NODROP, IF_NOAMMO},
}

These must be placed in the code before we define the undead warrior itself, as they are called within the being prototype.

Beings{
	name = "undead warrior",
	name_plural = "undead warriors",     --this would work as such by default
	id = "undead",                       --small ids are nice for calls
	ascii = "u",
	color = LIGHTGRAY,
	sprite = 0,                          --necessary, always set to 0 for now
	corpse = true,                       --whether being leaves a corpse
	toDam = 6,                           --inherent damage modifier (for melee)
	toHit = 4,                           --inherent accuracy modifier
	speed = 100,                         --time of actions: 100 = 1s; <100 = >1s; >100 = <1s
	armor = 2,                           --protection value
	danger = 6,                          --weight for generation, can also determine experience
	XP = 0,                              --set experience specially
	weight = 250,
	minLev = 7,                          --lowest floor being will generate
	maxLev = 20,                         --highest floor being will generate
	HP = 50,
	attackchance = 60,                   --chance being will attack as its action
	toHitMelee = 0,                      --inherent accuracy modifier (for melee, adds to toHit)
	res_fire = 50,                       --percentage resistance to damage type DAMAGE_FIRE
	res_acid = 50,
	ai_type = "natural_melee_ranged_ai", --how the enemy moves/reacts, can define yourself
	desc = "As part of the Order's insidious plot to control your world, they've recruited the dead, " ..
               "gave them armor and armed them with deadly magic axes. Now they guard the evil cities and " ..
               "toss their infinite supply of axes at any elf who passes by.",
	kill_desc = "took an axe to the face from an undead warrior", --shows up in mortem if you died to this being
	kill_desc_melee = "chopped in two by an undead warrior",      --same as above but by melee attack
	OnCreate = function(self)
		self.eq.weapon = item.new("nat_undeadgreen")
	end,
	OnAction = function(self)
		if(math.random(6) == 1) then
			self.eq.weapon = item.new("nat_undeadred")
		else
			self.eq.weapon = item.new("nat_undeadgreen")
		end
    end,
}

Typically, for an enemy that has a ranged attack, you would use the prototype field "weapon" and in-line an item definition with type ITEMTYPE_NRANGED. However, since the undead warrior has two different attacks, we choose to assign it two separate weapons over the course of its lifetime through engine hooks.

  • Whenever an undead warrior is created, it is given a green axe in its weapon equipment slot: this is done by assigning a new item to the weapon field of the equipment property with an OnCreate hook.
  • On every action there's a chance for the undead warrior to get a red axe in its weapon equipment slot.
    • First we call the OnAction hook to make a check on every action.
    • Next, we supply a random condition using math.random(num) (which randomly chooses an integer between 1 and num). if(math.random(6) == 1) produces a 1/6 chance of the condition being true.
    • If the condition becomes true, we create a new item (this time a red axe) to the same field as before.
    • If the condition is false, we put a new green axe there instead. This is to ensure that the red axe is only in the undead warrior's equipment slot 1/6 of the time.

And that's all there is to it! You'll want to change the soundID in the missile definitions, but otherwise this code should run in any module you create.

Personal tools