Get Started With Lua Events Lesson 4: Examples and More Examples
Back To Get Started With Lua Events
We now know enough about how Lua works to start writing more events. We will again visit the Classic Rome scenario. You can download the event file I will start the lesson with here (In post 4). Your existing event file from Lesson 3 is probably fine, however, as long as you debugged it properly.
Naming Objects
Why Should You Name Objects?
Most of the functions that are native to TOTPP operate on 'objects' like the unittype object or the city object. In order to get this object in the first place, it is necessary to use functions like civ.getUnitType
or civ.getCity
. It is recommended that we give objects names and use those names to refer to the object, rather than using civ.getNeededObject(integer)
everywhere, or writing "wrapper" functions that can take integers as arguments.
There are several reasons for this recommendation. The first is that it is easier to understand what the code is doing. civ.createUnit(diplomat,romans,rome.location)
is much easier to understand than civ.createUnit(civ.getUnitType(50),civ.getTribe(1),civ.getTile(40,26,0))
. The second reason is that it is less error prone. Did you notice that the 50th unit in a standard game is not in fact a diplomat, but instead the explorer? Probably not. You are even less likely to realize that Rome is actually located at (40,28). This means you have to do more testing, to make sure every number is correct in your code. If you write the wrong number, the game will probably do something, just not the something you wanted. If you misspell a name, you are likely to get a nil error, and know where to fix the problem. So the second reason is that it is likely to reduce errors, and make it easier to find the errors that do occur.
A second reason to use names instead of integers is that it makes it easier to make changes to your code. Perhaps you originally had a game mechanic that used the meltdown feature of the Nuclear Plant, but then decided that the mechanic didn't work very well after all, and wish to replace it with a regular Power Plant instead. If you used the improvement number 21 in your code, you would have to search your code for every 21, determine if it refers to the Nuclear Plant, then change it to a 19 for the Power Plant instead. With a name, you would simply change NuclearPlant=civ.getImprovement(21)
to NuclearPlant=civ.getImprovement(19)
and all the events you wrote would continue to work as normal. If you chose to remove the NuclearPlant entirely from the game without replacing it, it is easier to search your code for NuclearPlant, and if you forget something, the game will often give you an error because a nil value was received instead of an improvement object. It is perfectly acceptable to give the same object multiple names. So, if you were already using the Power Plant, both PowerPlant and NuclearPlant could be assigned to the same improvement object without any trouble.
This is something that is actually likely to happen once your event file gets large, especially if you collaborate with others. (And you will have a large event file at some point. Since Lua will let you do almost anything, you will eventually try to do almost everything.) For example, in Over the Reich, unit type 109 was originally some kind of "munition" and was therefore to be deleted every turn. Eventually, we decided that munition wasn't used for very much, and we could use another munition in its place. Later, we used the 109 unit type slot for the "Historic Target" unit which was not supposed to be deleted with the munitions. However, the table governing the unit types to be deleted used integers, so I had to figure out why the unit was being deleted. This particular bug was made more difficult to sort out since sometimes the unit was supposed to be deleted.
Now that we've decided that we should give 'names' to hundreds of objects, we run into a problem. You can only have 200 local variables in a Lua function (this does not include any local variables used in a function declared inside another function). In nearly all situations, this is plenty, but a file (module) is also treated as a function and is therefore subject to the 200 local variable limit. Usually, it is good programming practice to split your function into smaller and simpler functions (or multiple files, which we'll learn how to do in a latter lesson) before you have need of 200 local variables, but this doesn't really apply to our situation where we're storing data for our scenario. The solution is to use tables to store these kinds of values. So instead of
settler = civ.getUnitType(0)
We use
unitAliases = {}
unitAliases.settler = civ.getUnitType(0)
Avoid using a table name that is likely to be a variable name in a function. For example
unit.settler = civ.getUnitType(0)
Would become a problem if we wrote the following loop:
for unit in civ.iterateUnits() do
if unit.type == unit.settler then
civ.ui.text("We found a settler.")
end
end
In this case, "unit" is a very natural variable name for this for loop, but declaring a new local "unit" prevents us from accessing the "unit" table that was declared at a higher level. In Over the Reich, objectAliases was chosen as the form for most of these kinds of tables.
How to Name a City Object
For most objects that you would want to name (unit types, technologies, tribes) the corresponding function civ.getObject(id)
is easy to use. At the time of writing this guide, it is inadvisible to try to name individual units, since the game will change their id number from time to time (to remove killed units). If it ever does become possible to name individual units, you will probably want to use someone else's code to do it anyway. But what about cities? Unlike unit types, you can't just count entries in the Rules.txt to find the id number.
One method is to use the in game console to find the id number of each city ( civ.getCurrentTile().city.id
). This is the method used in Over the Reich, and, has not, thus far, led to any known problems in that scenario.
However, there has been a report of a possible bug with this system, which at the time of writing hasn't been investigated further.
This being the case, there are at least two other options that can be used (and which might be preferable anyway). The first is
cityAliases.rome = civ.getTile(40,28,0).city
Which is accessed by simply writing cityAliases.rome
.
The second option is somewhat similar, except that it uses a function to get the city object at the time it is needed instead of storing the object in a table.
local function getCityAt(x,y,z)
local function getCity()
return civ.getTile(x,y,z).city
end
return getCity
end
Which is then used in the follwoing way:
local myCity = getCityAt(10,20,0)
-- Other Code
if city == myCity() then
-- code
end
In this method, the function getCityAt(10,20,0)
generates another function, which gets the city at (10,20,0) or returns nil if no such city exists, and we call that new function when we want to actually get the city at (10,20,0). This method should not have any problems with cities being created or destroyed, so we'll use it for this tutorial. One disadvantage of the method is that we might not be able to re-use code designed for other kinds of objects.
For example, if we have a function inTable(object,table) --> boolean
which checks if the object is also a value in the table, we cannot use inTable(city,cityTable)
and get true, since cityTable will have functions in it, not city objects. That is another reason for using the function method of getting cities in this tutorial: so we can see how code varies with how we get objects.
Exercise
In the Events.lua file for Classic Rome, take all the objects we've given names to thus far, and put them into tables. Use the tables tileAliases
, unitAliases
, tribeAliases
, and cityAliases
. Additionally, create a table parameters
for numbers such as elephantCampaignCost
, and textAliases
for strings.
It is a good idea to give parameters names for similar reasons as for why you would like to name objects. There are two big reasons that deserve to be stated explicitly. The first is that it makes tweaking the program much easier. If you use parameter names everywhere, then by changing the definition at one place, you know that everything is changed, without searching all your code. The second is that you don't have to know the final parameter value while programming. As long as you know the name of the parameter, you can program, even if you've forgot or have not decided on the final value for the parameter. This is particularly useful if you are collaborating with someone else. You can program without waiting for confirmation on a value. Similar reasons apply for giving strings names.
Additionally, for everything being re-named, find where it is used, and re-name appropriately. You might find the replace functionality (in the search menu) of Notepad++ useful. If you are not using Notepad++, text editors usually have search and replace functionality.
If you use the search and replace tool in Notepad++, make sure you check the boxes "Match Whole Word Only" and "Match Case" so you don't replace more than you expect.
These are the names that I gave the objects and parameters in question. If you used different names, that is fine, but you will have to take that into account when copying code during this lesson. For Lesson 5, I will provide the events file that I finish with during this lesson.
Note that I also created a terrainAliases table for terrain type numbers.
When changing names, perhaps you noticed that parameters.campaignCost isn't actually used anywhere (it was replaced with a campaign cost dependent on unit movement points.) You can use the search utility in your text editor to confirm this. You may delete the line if you wish.
Event Examples
Organizing an event file is important, but you're probably reading these lessons in order to have events that are worth organizing. So let us get to some examples.
Capturing An Enemy Unit
In many situations, we would like to be able to "capture" units instead of kill them. In an Age of Sail scenario, for example, we might like to capture a defeated ship instead of sink it. Perhaps we would like to "plunder" a city or unit, and have a "treasure" to transport home to be disbanded. Maybe we would like to capture "slaves".
In our Classic Rome scenario, we will allow military units to "capture" settlers instead of kill them. To do this, we will used the unit killed event trigger of civ.scen.onUnitKilled(function (defender, attacker) -> void)
. In our Events.lua file, we already have a function doWhenUnitKilled(loser,winner)
, to which we will add this event. Note that doWhenUnitKilled uses the names loser and winner instead of defender and attacker.
The first step of this unit killed event is to activate it when a settler is killed. Therefore, we wrap everything in an appropriate if statement:
if loser.type == unitAliases.settler then
end
Since unitAliases.settler is not yet defined, we put this line in the appropriate location:
unitAliases.settler = civ.getUnitType(0)
Now, we create the unit at the location of the winner:
if loser.type == unitAliases.settler then
local newSettler = civ.createUnit(unitAliases.settler, winner.owner, winner.location)
end
As it stands, this code will create a settler for the winning unit's tribe at that unit's location. (Note that unit.owner
is absent from some documentation.) Try it now. Make sure you've remembered to add unitAliases.settler to the unitAliases table.
If this is the entire function, we do not need to include the local newSettler =
in the code.
However, we might like to change the characteristics of the settler that has just been created.
The civ.createUnit function sets the home city to the 'nearest' city, the same way a city is assigned to a unit created via the cheat menu.
Perhaps we do not like this, and would like for the new settler not to have a home city. Perhaps, also, we wish for the created unit not to have any movement points for this turn, to give the other side a chance to liberate the settlers again. Additionally, we would like the new settler to have only 5 hitpoints remaining after capture.
Use what you have already learned (both in this lesson and previous lessons) and any resources at your disposal (such as TOTPP Lua function reference) to make these three changes to the newly created settler.
Scroll Down to See Answer
(Hopefully we can get collapsible text at some point)
We add the following three lines:
newSettler.homeCity = nil
newSettler.moveSpent = 255
newSettler.damage = newSettler.type.hitpoints - 5
newSettler.homeCity = nil
Not much to say about this. Nil is how to make a unit not have a home city.
newSettler.moveSpent = 255
This will guarantee that the unit can't move, since 255 is the maximum amount of movement points a unit can have. Another valid option is
newSettler.moveSpent = newSettler.type.move*totpp.movementMultipliers.aggregate
Which will set the expended movement points equal to the maximum movement points the unit has. Beware, however, that this will not work for a ship whose owner has Lighthouse, Magellan's or Nuclear Power, since that unit will have extra movement points that are not caught by unittype.move
.
newSettler.damage = newSettler.type.hitpoints - 5
We can't use
unit.hitpoints
, since that is only a 'get' command. We must set damage, and in order to do that, we must get the hitpoints for the settler type, which we do by newSettler.type.hitpoints, and then subtract 5 from that for the answer.
Bonus points if you put "5" as a parameter to make changing it easier later.
Also, using unitAliases.settler in place of newSettler.type is fine.
Finding the Nearest City
When we created a settler unit, we decided that it should never be supported, while the default behaviour is for the unit to be supported if it is created near to one of its own tribe's cities than another tribe's.
Perhaps we want the unit to always be supported, even if generated nearer to an enemy city than a friendly one. To achieve this, we need to find the nearest friendly city to a unit. This is how we do that:
local function nearestFriendlyCity(unit)
local bestCitySoFar = nil
local bestDistanceSoFar = 1000000
local function distance(tileA,tileB)
local xDist = tileA.x-tileB.x
local yDist = tileA.y-tileB.y
return math.sqrt(xDist^2+yDist^2)
end
for city in civ.iterateCities() do
if city.owner == unit.owner and distance(city.location,unit.location) < bestDistanceSoFar then
bestCitySoFar = city
bestDistanceSoFar = distance(city.location,unit.location)
end
end
return bestCitySoFar
end
In this function, we are trying to find the city with the 'best' distance to the unit, with best meaning smallest in this case. The basic idea is that we keep track of the best city that we've found so far, and the distance associated with that city. If we find a 'better' city, we update the information in bestCitySoFar and bestDistanceSoFar. At some point, we will find a 'best' city, and no other city will be better, so that city will be in the bestCitySoFar variable once we reach the end of the list. Note that if there are several cities that have the same distance from the unit, the one that will be returned is the first one checked.
Note that we can define local functions inside our function, to provide us with functionality that might not be needed by any other function. We saw this before, but it was worth pointing out explicitly.
Notice that we've defined this function locally to the Events.lua file, but we might want to try this out from the console, to see if it is working as expected. One way to do that is to make it a global function, which would mean we would have to worry about a function (or any variable) with the same name in any file loaded by our events.
Another way is to make a global table, and copy the function as an entry in the table.
console = {}
console.nearestFriendlyCity = nearestFriendlyCity
Try using this function from the console.
Distance in Civilization II
At some point in school, you probably learned that the distance between two points and is given by the formula
Generally speaking, when a Civ II game mechanic needs a distance measure of some sort, it uses this distance, or possibly some approximation of it.
However, you have probably noticed that when units move, the "distance" they move in one "step" will be different depending on the direction of the step. For example, a unit at square (10,10) can move to square (11,11), which is a distance of about 1.41. However, if the unit instead moves to (12,10), it has moved a distance of 2. This can make thinking about distance in Civ II difficult, especially if you want to think in terms of unit "steps".
It turns out that mathematicians have other notions of "distance" than , and one of them happens to be extremely convenient for Civilization II:
This is sometimes called the "Taxicab Distance" or the "Manhattan Distance," since it is the distance between two points when you have to travel on a grid road system.
Under this distance system, all unit "steps" have a distance of 2, regardless of direction. Hence, you can find the number of steps a unit must take to get from one tile to another (assuming it can cross all the tiles) by using the Taxicab Distance between the two tiles, and dividing by 2.
"Circles" (by which we mean all the points which are located at a specified distance from a "center") are "diamond" shaped by this distance measure, which is the same shape as all the tiles an air unit can reach in a turn from its own tile.
If you need a distance measure in your scenario, it is highly likely that the Taxicab distance will be as good, if not better better, than the ordinary "Euclidean" distance you are used to. I'm pretty sure that every time I needed a "distance" in Over the Reich, I used the Taxicab distance.
Extra Conditions for Production Selection
A very useful feature of Lua events is the ability to change what will appear in the production menu of a city. We use the following function from the scenario library
civ.scen.onCanBuild(function (defaultBuildFunction, city, item) -> boolean)
The function we register in civ.scen.onCanBuild
will return true if we want city
to be able to build item
, and false otherwise. defaultBuildFunction(city,item)
returns true if the default game behaviour would be to let the city build the item, and false otherwise.
We use defaultBuildFunction any time we don't want to handle a particular item and/or city, and also any time we want to make sure that all the game's normal conditions are fulfilled for making the item available, even if we have additional conditions to impose.
For a simple example, let us require that caravan units require a marketplace in the city in order to be built.
We begin by introducing an improvementAliases
table, adding the marketplace to the table, and adding the caravan unit to the unitAliases table.
improvementAliases={}
improvementAliases.marketplace = civ.getImprovement(5)
unitAliases.caravan = civ.getUnitType(48)
Next, we write our function:
local function inBuildMenu(defaultBuildFunction,city,item)
if civ.isUnitType(item) then
if item == unitAliases.caravan then
return civ.hasImprovement(city,improvementAliases.marketplace) and defaultBuildFunction(city,item)
end
end
return defaultBuildFunction(city,item)
end
civ.scen.onCanBuild(inBuildMenu)
if civ.isUnitType(item) then
exists because we will get an error if we try to compare an improvement object with a unit type object.
if item == unitAliases.caravan then
return civ.hasImprovement(city,improvementAliases.marketplace) and defaultBuildFunction(city,item)
end
If the item is a caravan, then we need to know if there is a marketplace in the city, and if the city could build a caravan anyway. If both are true, then the city can build a caravan, and the function will, indeed, return true. If either is false, the and
will be false, and inBuildMenu
will return false as desired.
If the item is not a unit, we skip the corresponding if statement, and return whatever the default function decides for that item. If the item is a unit, but not a caravan, then, again, we reach the line return defaultBuildFunction(city,item)
and return whatever the default is.
Try this code out, and see if it works. Note that the Romans don't have trade, and so can't build caravans anyway. Check the Carthaginian cities to see if it works.
Counting Things
Something we might like to do in our events is to count things. For example, suppose we want to limit the number of legions that a tribe can build. This will require us to know how many legions the tribe currently has, and also how many legions the tribe is allowed to have.
local function countUnits(unitType,tribe)
local numberSoFar = 0
for unit in civ.iterateUnits() do
if unit.owner==tribe and unit.type==unitType then
numberSoFar = numberSoFar+1
end
end
return numberSoFar
end
The idea of this function is very similar to nearestFriendlyCity
, except that we're incrementing a counter instead of updating our current best answer.
We can do more complicated things, also. Let us make the number of legions a tribe can build dependent on the population of cities that the tribe founded. (I.e. exclude the population of cities the tribe has captured.) We will use the city value city.originalOwner
to determine whether the tribe has founded the city or not.
Try writing a function findNativePopulation(tribe)
to find the population of cities that the tribe has both founded and currently owns. Then add it, along with countUnits
to the Events.lua file, and make them both accessible to the console. Finally try them out in the console.
Scroll down for the Answer:
local function findNativePopulation(tribe)
local populationSoFar = 0
for city in civ.iterateCities() do
if city.owner==tribe and city.originalOwner == tribe then
populationSoFar = populationSoFar + city.size
end
end
return populationSoFar
end
Now, let us remove legions from city production, if the tribe's native population is less than 4 times the number of legions the tribe currently owns. That is, there must be 4 "native" citizens per legion. Try implementing this in inBuildMenu
.
Scroll Down for Answer:
The relevant part of the code is
if item == unitAliases.legion then
if not defaultBuildFunction(city,item) then
return false
else
return countUnits(unitAliases.legion,city.owner) <= findNativePopulation(city.owner)/4
end
end
if not defaultBuildFunction(city,item) then
return false
If the defaultBuildFunction returns false, then we don't have to worry about counting population or legions, so we can return false immediately.
else
return countUnits(unitAliases.legion,city.owner) <= findNativePopulation(city.owner)/4
end
If we get to this part of the code, the default build function is true, so we don't have to take it into account anymore. We simply return the result of whether the tribe has fewer legions than it is allowed.
I wrote the code to check if the city satisfies the defaultBuildFunction first, since if it doesn't, there is no point in running the two counting functions. These lessons won't usually worry too much about computational efficiency in code, since computers these days are fast. However, it is worth noting that determining if a legion can be built requires checking every unit and every city in the game. It is possible (though I don't know for sure) that if we were doing this for dozens of units, it might create a lag in bringing up the build choices window. Hence, it is best not to make the check if we already know that the city can't build the unit anyway. I'll discuss efficiency a little bit more in a later lesson.
Changing A Produced Unit
Unfortunatly, at the time of writing, ToTPP doesn't provide the functionality to check or set the production order of a city. This is inconvenient for a number of reasons, one of them being that we can't check the production of other cities when deciding if a city can build a unit or improvement. It is therefore possible, in our legion example, that we run into a situation where 2 cities try to produce a legion in a turn, but only one more legion can be "supported."
One option we have is to simply allow the extra legion to be produced. Another option, however, is to do something with the on City Production event trigger civ.scen.onCityProduction(function (city, prod) -> void)
.
One thing we can do is delete the extra legion, and replace it with a different unit, say an archer. For example,
local function doOnCityProduction(city,prod)
if civ.isUnit(prod) then
if prod.type==unitAliases.legion and countUnits(unitAliases.legion,city.owner) > findNativePopulation(city.owner)/4 then
local substituteUnit = civ.createUnit(unitAliases.archers,city.owner,city.location)
substituteUnit.veteran = city:hasImprovement(improvementAliases.barracks)
civ.deleteUnit(prod)
end
end
end
civ.scen.onCityProduction(doOnCityProduction)
Try out this code in ClassicRome, by scheduling more legions to finish construction on a turn than can be supported.
The first thing to mention is the line
substituteUnit.veteran = city:hasImprovement(improvementAliases.barracks)
.
As I discovered while writing this lesson, the game does not bestow veteran status on a newly created unit until after the onCityProduction
function is run.
This means we can't use the veteran status of the legion to apply it to the Archer. This also means that removing the veteran status bestowed by a barracks will require a bit of work and some functionality we haven't covered yet.
The next thing to note is that I've used the expression findNativePopulation(city.owner)/4
, so now there are two places that use '4' for the same purpose. We should create the parameter parameters.populationPerLegion=4
and use that where we need it in place of '4'. That way, if we want to make a change, we don't have to search for every '4' in the file and determine if it corresponds to a legion cost. Do this now.
We make the check civ.isUnit(prod)
for the same reason we check if item is a unit type in our inBuildMenu function.
Another alternative we have to replacing the produced unit is to simply return the shields expended for production. Unfortunately, the remaining shields in the production box (after adding the city's production for the turn and removing the cost of the unit) are not eliminated until after the onCityProduction function is executed. Since we have not enabled production carryover in the rules.txt, giving shields to the city is ineffective. We'll revisit this in a future lesson.
Upgrading a Unit Beyond Veteran Status
Sometimes it would be desirable to have more "experience" stages for a unit than simply veteran or not veteran. Unfortunately, TOTPP and Lua do not offer a direct way to achieve this. However, we can achieve our end in an indirect way by assigning a different unit type slot to act as the "elite" version of the unit.
In this example, we will allow Archers to be promoted from veteran status to Musketeers, which will serve as the "elite archer" unit.
We will specify that the promotion has a 1/3 chance of taking place any time a veteran archer wins a battle against a unit where the sum of the attack, defense, and movement stats are at least 4. This means that elite status can't be achieved for defeating warriors and most noncombatants. We will update our doWhenUnitKilled
function. Consider this addition to the function:
parameters.enemyStatsForElitePromotion = 4
parameters.elitePromotionChance = 1/3
if winner.type == unitAliases.archers and
loser.type.attack+loser.type.defense+loser.type.move >= parameters.enemyStatsForElitePromotion
and math.random() < parameters.elitePromotionChance then
local promotionDialog = civ.ui.createDialog()
promotionDialog.title = "Defense Minister"
promotionDialog:addText("With Experience, our "..winner.type.name.." have been promoted to "..unitAliases.musketeer.name)
promotionDialog:show()
local eliteUnit = civ.createUnit(unitAliases.musketeer,winner.owner,winner.location)
eliteUnit.homeCity = winner.homeCity
eliteUnit.damage = winner.damage
eliteUnit.moveSpent = winner.moveSpent
eliteUnit.order = winner.order
eliteUnit.attributes = winner.attributes
eliteUnit.veteran = winner.veteran
if winner.gotoTile then
-- cannot set .gotoTile to nil
eliteUnit.gotoTile = winner.gotoTile
end
civ.deleteUnit(winner)
end
Let us consider the various parts of the code:
winner.type == unitAliases.archers
This is straight forward, it simply indicates that the unit winning the combat must be an archers for this event to happen.
loser.type.attack+loser.type.defense+loser.type.move >= parameters.enemyStatsForElitePromotion
This part adds the attack, defense, and movement of the loser and compares against the minimum value for the archer to be eligible for promotion. If this is false, then the promotion won't happen.
math.random() < parameters.elitePromotionChance
This is the part of the code that allows us to introduce a random element into the promotion. The function math.random()
generates a number between 0 and 1. If we want an event to have a probability of p, then we simply say math.random()<p
. So, for a 1/3 probability event, we write math.random() < 1/3
. Since math.random() generates a number with many digits, it doesn't matter if you use < or <=.
You can also use math.random(lower,upper)
which generates integers between lower and upper (inclusive). In this case, the use of < or <= is important.
local promotionDialog = civ.ui.createDialog()
promotionDialog.title = "Defense Minister"
promotionDialog:addText("With Experience, our "..winner.type.name.."
have been promoted to "..unitAliases.musketeer.name)
promotionDialog:show()
This creates a "dialog object" to display text. The difference between this and civ.ui.text(string)
in this case is that I could add a title to the message box. The other reason to use the dialog system is that it also allows options to be selected by the player, so that your code can do different things based on the player's decision.
local eliteUnit = civ.createUnit(unitAliases.musketeer,winner.owner,winner.location)
eliteUnit.homeCity = winner.homeCity
eliteUnit.damage = winner.damage
eliteUnit.moveSpent = winner.moveSpent
eliteUnit.order = winner.order
eliteUnit.attributes = winner.attributes
eliteUnit.veteran = winner.veteran
if winner.gotoTile then
-- cannot set .gotoTile to nil
eliteUnit.gotoTile = winner.gotoTile
end
Since we want the musketeer to replace the archer as closely as possible, we copy all the available unit characteristics. Location and owner are covered when the unit is created. Veteran status is strictly speaking unnecessary, since it is covered in attributes (though you might want to remove veteran status to give the unit an extra level of promotion). It turns out that you can't set the gotoTile to nil, so we have to check if the unit has a goto order, and, if so, then give it to the new unit.
At the moment, TOTPP Lua integration does not allow us to modify the unit attribute that stores aircraft range, caravan commodities and settler work accumulated, so it may be inadvisable to upgrade these kinds of units in this way.
civ.deleteUnit(winner)
Once we've replaced the unit, we have to delete it.
Temporarily set the promotion chance to 1 (to make it easier), and try out this event in various situations. Does it work as expected?
Scroll Down for Answer:
There are a couple problems with the code as written. The first is that stack kills do not work properly. For every unit killed in a stack, the promotion dialog is displayed again. This means that the entire event happens for each unit killed in the stack. If we didn't have the promotion chance set to 100%, there would be multiple chances for promotion. Only one musketeer is created. This is presumably because the deleted unit is "moved" off the map when deleted, so the game attempts to create a new musketeer on a square off the map, which it can't.
The second problem is that the promotion message is displayed even when the defender is promoted. That means the attacker sees the promotion message, which is undesirable.
The third problem is that I failed to check if the archer has veteran status.
The first problem is solved if the promotion event can only happen when a unit that was defeated in combat is killed. We can determine this because only a unit defeated in combat will have 0 or fewer hitpoints.
The second problem is solved if we only display the dialog if the unit owner is a human played tribe and is the attacker. We might want a message for a human player's archer promoted on the defensive, but this would require making a check if the game is in multiplayer or not, since it should only work in single player. (And there is already a lot of stuff do to still, without testing that. Feel free to try it yourself, however. Eventually you will have to write code without an example solution.)
The third problem is solved by a check for the winner's veteran status in the if statement.
The relevant changes to the code are as follows
if loser.hitpoints <= 0 and winner.type == unitAliases.archers and winner.veteran and
loser.type.attack+loser.type.defense+loser.type.move >= parameters.enemyStatsForElitePromotion
and math.random() < parameters.elitePromotionChance then
if winner.owner == civ.getCurrentTribe() and winner.owner.isHuman then
local promotionDialog = civ.ui.createDialog()
promotionDialog.title = "Defense Minister"
promotionDialog:addText("With Experience, our "..winner.type.name.." have been promoted to "..unitAliases.musketeer.name)
promotionDialog:show()
end
Now try this code. It works, except that if the archer gains veteran status in combat, it can immediately be promoted to musketeer as well, which we probably don't want. We need a way to find out if a unit has just been promoted to veteran status. For the attacking unit, this will be relatively easy, but it will require the elite promotion event to have code in more than one event trigger, which is something new. The problem we face is that we want to know if the archer was "recently" made a veteran unit, but we can only check the veteran status of the archer when the code is run, which is after the archer is promoted to veteran status for a successful combat. Our solution will be to store the veteran status of the unit before combat, and refer to that to see if the archer just became a veteran, or already was one. In order to do this, we will have to have code in the function registered by civ.scen.onActivateUnit
in addition to the functionality registered by civ.scen.civ.scen.onUnitKilled
.
To achieve an ability to check if the unit was just promoted, we will create a variable called, for example, activeUnitCurrentlyVeteran
, which we will not place within any function (so that anything in the Events.lua file can access it). We update this variable every time a unit is activated (since a unit must be activated before it can make an attack), and when the active unit wins combat (because it might have movement points left over to attack again). We decide to allow the chance of double promotion for defending units, since the player is in less control of the situation, and the basic Civ II game generally favours the attacker.
local activeUnitCurrentlyVeteran = activeUnitCurrentlyVeteran or false
activeUnitCurrentlyVeteran = unit.veteran
local qualifyingVeteran = winner.veteran and (activeUnitCurrentlyVeteran or winner.owner ~= civ.getCurrentTribe())
Here, we're doing some of the work of determining if the unit can be promoted outside the if statement for it. One reason is that the promotion if statement is already rather large. The other is that we might want to have elite promotion for other units, and qualifying veteran would be useful for all of them.
if loser.hitpoints <= 0 and winner.type == unitAliases.archers and qualifyingVeteran and
loser.type.attack+loser.type.defense+loser.type.move >= parameters.enemyStatsForElitePromotion
and math.random() < parameters.elitePromotionChance then
Here, I've just introduced the qualifying veteran variable into the condition for promotion.
if winner.owner == civ.getCurrentTribe() and loser.hitpoints <= 0 then
activeUnitCurrentlyVeteran = winner.veteran
end
This code is necessary in case the attacking unit has leftover movement points after making the attack. Not a concern for the archer, but it could be important if we introduce an elite horseman or something. If we're building "infrastructure," we should accommodate all likely use cases (ideally all use cases). When you get to introducing your horseman elite unit, you probably won't be thinking about how you keep track of the veteran status. And, maybe, someone else will be changing your code, and won't know that you decided to save a little thought and a few lines of code.
The loser.hitpoints <=0
condition is there because the defeated unit is not the first unit for which the unit killed function is executed. If it is not here, then after the first stack kill unit is processed, the attacker is made veteran, and can again be promoted once the killed unit is processed. (My couple of tests suggest that the killed unit is always the last to be processed, but I have not done enough tests to confirm this.)
As a final note on this section, much of what is presented here is stuff I had to figure out as I was writing this lesson. This section was presented in three parts because it really did take me that many times to revise my original code. I inserted and removed print statements a few times to figure out how things were working. This also applies to (to a greater or lesser extent) to most of the other sections I wrote. Part of programming is testing and figuring out exactly how things are working, especially when you are learning. This is particularly true for our scenario making group, since we are dealing with functionality with limited documentation. If it takes several tries to get things to work, that's normal and you shouldn't be discouraged by it.
Specifying A Region
Sometimes, we have events that we only want to take place for units (or cities) in a certain region. If our region is a rectangle, it is very easy to determine if a tile is within the region:
local function inRectangle(tile,xMin,xMax,yMin,yMax)
local x = tile.x
local y = tile.y
return xMin <= x and x <=xMax and yMin <= y and y <=yMax
end
This function will only return true if the tile is within the x and y limits specified. This function will return true if the unit is within the specified rectangle on any map, though it is straightforward to extend the function to limit the acceptable z coordinates.
If you want something more complicated than a rectangle, you can combine several rectangles together. For example:
local function inMyRegion(tile)
if inRectangle(tile,xMin1,xMax1,yMin1,yMax1) then
return true
elseif inRectangle(tile,xMin2,xMax2,yMin2,yMax2) then
return true
else
return false
end
end
You add as many inRectangle if statements as you need to specify the region, and if any of them are satisfied, the function will return true. Overlap of the rectangles is acceptable.
However, writing a full function for each region might be a little much. We might prefer to specify the rectangles that make up a region in a table, and simply pass that table to a function.
We can specify our function to take tables of rectangle specifications in 2 ways:
local region={}
region.italy = {
{xMin=36 , xMax=44, yMin=18 , yMax=38 },
{xMin=38 , xMax=50 , yMin=28 , yMax=46 },
{xMin=46 , xMax=54 , yMin=40 , yMax=54 },
}
region.sicilySardinia = {
{38,46,50,58},
{31,37,33,47},
}
local function inRegion(object, regionTable)
local tile = nil
if civ.isTile(object) then
tile = object
elseif civ.isCity(object) then
tile = object.location
elseif civ.isUnit(object) then
tile = object.location
else
error("inRegion expected a tile, city, or unit as the first argument.")
end
for __,rectangleSpec in pairs(regionTable) do
if inRectangle(tile,rectangleSpec.xMin or rectangleSpec[1],
rectangleSpec.xMax or rectangleSpec[2],
rectangleSpec.yMin or rectangleSpec[3],
rectangleSpec.yMax or rectangleSpec[4]) then
return true
end
end
return false
end
local function doOnKeyPress(keyID)
if keyID == 49 --1 then
civ.ui.text(tostring(inRegion(civ.getCurrentTile() or civ.getActiveUnit(),region.italy)))
elseif keyID == 50 --2 then
civ.ui.text(tostring(inRegion(civ.getCurrentTile() or civ.getActiveUnit(),region.sicilySardinia)))
end
end
civ.scen.onKeyPress(doOnKeyPress)
local tile = nil
if civ.isTile(object) then
tile = object
elseif civ.isCity(object) then
tile = object.location
elseif civ.isUnit(object) then
tile = object.location
else
error("inRegion expected a tile, city, or unit as the first argument.")
end
This part of the code allows us to accept multiple kinds of objects as input, so we don't have to worry about making sure the first argument is a tile. This can make some sense, since it is natural to ask if a unit or city is in a region. The error
function in lua brings up the console with an error message. We may want this in some cases, since it tells us we made a mistake somewhere. Other times, we might not want it, so that a bug doesn't break our scenario.
for __,rectangleSpec in pairs(regionTable) do
if inRectangle(tile,rectangleSpec.xMin or rectangleSpec[1],
rectangleSpec.xMax or rectangleSpec[2],
rectangleSpec.yMin or rectangleSpec[3],
rectangleSpec.yMax or rectangleSpec[4]) then
return true
end
end
return false
Here, we go through every rectangle defined in the "region table" and see if the input tile is in the rectangle. To handle the two kinds of table format, or functions are used to find the table entry actually specified.
local function doOnKeyPress(keyID)
if keyID == 49 --1 then
civ.ui.text(tostring(inRegion(civ.getCurrentTile() or civ.getActiveUnit(),region.italy)))
elseif keyID == 50 --2 then
civ.ui.text(tostring(inRegion(civ.getCurrentTile() or civ.getActiveUnit(),region.sicilySardinia)))
end
end
civ.scen.onKeyPress(doOnKeyPress)
This allows us to test the function out, by pressing keys. If you want to check for an error, replace one of the arguments to inRegion. For example, you could use civ.getImprovement(1) to make it show an error message.
Another way to specify a region is to define it to be within a certain distance of some cities. Write a function inHomesteadRome(tile,distance)
that determines if a tile is within distance squares of a city that Rome founded (based on city.originalOwner). It turns out that city.originalOwner changes whenever a city is captured, so once a city is liberated, the "originalOwner" is the former occupier. (We could define "Rome" based on a list of cities at the start of the game, but to update that list for new cities founded, we need Lua tools not yet discussed.)
Test the distance by adding keyID == 51 ('3' key) and an appropriate action to doOnKeyPress(keyID)
Scroll Down for Answer:
local function inHomesteadRome(tile,distance)
local function taxiDistance(tile1,tile2)
return math.abs(tile1.x-tile2.x)+math.abs(tile1.y-tile2.y)
end
for city in civ.iterateCities() do
if city.originalOwner == tribeAliases.romans and city.owner == tribeAliases.romans then
if taxiDistance(tile,city.location) <= distance*2 then
return true
end
end
end
-- if we get here, no city is close enough
return false
end
The "taxiDistance" function implements the "taxicab distance" mentioned earlier in the lesson.
if taxiDistance(tile,city.location) <= distance*2 then
The "distance" parameter is multiplied by 2, since each Civ II tile is a distance of 2 away from every other tile in this measuring system, so that must be accounted for. The "distance" was specified in tiles, not in map coordinates. This if statement could have been included in the above if statement without any trouble.
Conclusion
There have been a lot of examples in this lesson. In the next lesson, we will cover even more examples that can be achieved with our current level of Lua knowledge. The major "tool" we are still missing is the ability to save extra data with the saved game. While there are other things worth discussing, much of what you need to design scenario events with Lua at this point is simply experience. Now, lacking experience is not a trivial matter, but the way you gain experience is by practice. So, start trying things out. You have some working code, so try modifying it to change conditions, for example. Try writing new code, and see if it works as expected. If not, figure out why. Introduce print statements to check values. Remember that you are very fortunate to have an opportunity to learn programming where even simple programs can be fun and useful to others.