How to create Lua maps - Workshop - Thread

Information and discussion for custom maps and mods.
Post Reply
User avatar
Materianer
Posts: 199
Joined: Mon Jul 04, 2016 8:27 am

How to create Lua maps - Workshop - Thread

Post by Materianer »

Hi,
this thread is for Lua-mapmakers or those who want to become one.
It is for all the people who need help realizing theyr ideas or just with errors.
Exchange of knowlege and experiences with lua is welcome.

What me really helped me was reading the missions and other custom maps.
Thats why i start here with an example map i added some comment wich hopefully help understanding it.
I just added some captureable flags and buildings surrounding it, to a map.

https://resource.openra.net/maps/24022/

Code: Select all

CTFFLagtypes = {"ctflag", "ctflagra", "ctmidflag"}	 -- add here new flag types defined in the rules.yaml

CTbs = {"oilb", "gap", "spen", "syrd", "silo", "miss"} 	-- these are the building types wich get captured with a flag. Only buildings that are there from beginning will be captured that way. You can list there up to 18 building types for more add more CTbs[19] 3 lines below

function GetNearFlag (flag) -- function for flag trigger and capture
		local flagactors = Map.ActorsInCircle(flag.CenterPosition , WDist.FromCells(10), function(bld)-- with this line the game checks the area 10 cells around the flag for buildings that will be captured with flag. WDist.FromCells(10) can be changed to any other value

			return  bld.Type == CTbs[1] or  bld.Type == CTbs[2] or  bld.Type == CTbs[3] or  bld.Type == CTbs[4] or  bld.Type == CTbs[5] or  bld.Type == CTbs[6] or  bld.Type == CTbs[7] or  bld.Type == CTbs[8] or  bld.Type == CTbs[9] or  bld.Type == CTbs[10] or  bld.Type == CTbs[11]or  bld.Type == CTbs[12] or  bld.Type == CTbs[13] or  bld.Type == CTbs[14] or  bld.Type == CTbs[15]or  bld.Type == CTbs[16] or  bld.Type == CTbs[17] or  bld.Type == CTbs[18]
		end)
	Trigger.OnEnteredProximityTrigger(flag.CenterPosition, WDist.FromCells(3), function(a, id)	-- this is the flagtrigger ich changed "WDist.FromCells(3)" for this map to make the flags on the islands better captureable usally i use a way distance of 2

		local mine = flag.Owner		-- simple variables
		local yours = a.Owner

		if mine ~= yours and  mine.IsAlliedWith(yours) ~= true and yours ~= Neutral and yours ~= Player.GetPlayer("Creeps") and Actor.CruiseAltitude(a.Type) == 0 and a.Type ~= "1tnk.husk" and a.Type ~= "2tnk.husk" and a.Type ~= "3tnk.husk" and a.Type ~= "4tnk.husk" and a.Type ~= "harv.fullhusk" and a.Type ~= "harv.emptyhusk" and a.Type ~= "mcv.husk" and a.Type ~= "mgg.husk" and a.Type ~= "camera" and a.Type ~= "camera.paradrop" and a.Type ~= "camera.spyplane" and a.Type ~= "lst"  then		
-- the cases in wich the flag should not be captured.  ~= means is not 
-- i excluded "lst" for this map, transportships should not be able to capture flags in this version
-- You could also type   if aType == "e6"  for example if only engies should be able to capture flags

			flag.Owner = yours				-- with this line the flag changes its owner

			if flagactors ~= nil  then 				-- these lines are for the buildings that will be captured with the flag
				for i,v in ipairs(flagactors) do	-- a counter for the buildings
					if v ~= nil then
						v.Owner = yours  			-- buildings changes owner
					end
				end 
			end
		end
	end)	
end


-- set up Flags
GetandsetFlags = function()
	CTFlags = {}
	Trigger.AfterDelay(DateTime.Seconds(1), function()	-- wait 1 second if you got lines like these with ( dont forgett to close it at  end)  below

		for i=1,#CTFFLagtypes do		-- all flagtypes get counted by type
			local T1 = Neutral.GetActorsByType(CTFFLagtypes[i])		--here the game gets the flags that are spreaded on the map owned by the neutral player
			if T1 ~= nil then
				joinMyTables(CTFlags, T1)	
			end
		end
	end)
	
	Trigger.AfterDelay(DateTime.Seconds(2), function()	 -- fuse flags after a delay of 2 sec
		for k,v in ipairs(CTFlags) do		-- flags getting counted
			GetNearFlag(v)					-- here the function above getting loaded (v) stands for all the flags
		end
		return CTFlags
	end)
end

num = 0
function joinMyTables(t1, t2) -- simple join tables
	for k,v in ipairs(t2) do
		table.insert(t1, v)
		num = num + 1
	end 
	return t1
end

function WorldLoaded()		-- the worldloaded funtion getting started at the beginning of the game, call not needed ;D
	Neutral = Player.GetPlayer("Neutral")		-- needed for that lua knows who the neutral player is

	GetandsetFlags()	-- with this function call the flag setup starts

	Trigger.AfterDelay(DateTime.Seconds(3), function()		-- 3 sec delay ...
		Media.DisplayMessage("capture flags to get money")	-- player announcements
	end)

end
Thx to combine the code is written by him i just copy pasted it.

I hope the comments are right please note me if i said something wrong.

I will add some functions from time to time and update it.
hf
materia

noobmapmaker
Posts: 1086
Joined: Wed Dec 10, 2014 11:59 am

Post by noobmapmaker »

Good stuff! Such short sentences really help a lot explaining things!

Hope you will do such things more often and some new people are eager to learn it.
Playlist with ALL games of the Dark Tournament Youtube.com/CorrodeCasts
Consider supporting OpenRA by setting a bounty or by donating for a server

SethXXX
Posts: 17
Joined: Sat Aug 30, 2014 9:07 am

Post by SethXXX »

ok, so after bugging the whole forum for weeks with questions, I will give something back.

(wall of text incoming)

I am a pretty experienced mapper for the SAGE C&C games, meaning coding missions with hardcoded predefined triggers. When I learned about OpenRA and its sheer endless opportunities it presents for mappers, I was beyond excited, especially since RA was my first childhood game.
This excitement was soon to be followed by a certain disenchantment: This whole Lua mision coding might be exceptionally powerful and versatile - but is also just really hard to discern for any beginner with little prior coding knowledge.
What frustrated me the most was the simple fact that I am able to create just about any map my mind can fathom for Generals or C&C3, but in OpenRA I was not even able to do the most basic mission map possible: Give opponent a base, make him attack you and win when all his stuff is destroyed.

One problem is the documentation - there are some resources, but not really a comprehensive tutorial to get a full map done. The singleplayer mission already done are also very helpful, but here the problem is of another matter: They contain so much code with special actions and triggers that is is not easy to identify the real crucial points that are necessary for the map, and the other elements which are for advanced map makers and storytellers.

Long story short: I created my first map a while ago, focusing on just the things I mentioned above:

-get the human player an MCV and some units to be able to build a base
-build preconstructed a base for the opponent
-define simple attack scripts
-create a victory trigger if opponent is destroyed

I have uploaded the map here: https://resource.openra.net/maps/24144/

And here is the Lua Script, with a lot of comments (highly recommended to be viewed in a text editor with syntax highlighting):

Code: Select all

SovietInfantryTypes = { "e1", "e1", "e1", "e2", "e2", "e4" } --this defines which types of infantry units the Soviet player will be using for attacks. You will have to know the internal engine names, they're used in the editor.
SovietArmorTypes = { "3tnk", "3tnk"} --the same as the above for tanks. As they're chosen at random, I could have also used "3tnk" once with the same effect. However, you can make the AI prefer a certain type by including it more than the other types. (Ex. types = { "3tnk, "3tnk", "v2rl" } will return 66,6% Heavy Tanks and 33,3% V2 Rocket Launchers.
SovietAircraftType = { "yak" } --not used here. If you want a challenge, complete the script and make the Soviets use aircraft!

SovietAttackPath = { AttackPoint } --this defines the waypath the Soviets will use to attack the player. Here, just the middle of the Allied base is used. If you include more than one waypoint, the units will move along them in order.

InfAttack = { } --these are necessary to define a variable for the attacking teams (see below) before they are created. You must use one of those for each team.
TankAttack = { } --same as above

BuildVehicles = true
TrainInfantry = true
Attacking = true

Tick = function() --this lets you win the mission! Tick means the function conditions are checked at every game tick - so effectively constantly
	if ussr.HasNoRequiredUnits() then --meaning all units with the "MustBeDestroyed" trait belonging to player "ussr" have been destroyed
		allies.MarkCompletedObjective(KillAll) --the primary objective with the name "KillAll" belonging to player "allies" is marked as completed. Since it is the only primary objective, the mission ends in victory immediately.
	end
end

IdleHunt = function(unit) if not unit.IsDead then Trigger.OnIdle(unit, unit.Hunt) end end --I have no clue about this tbh - copied it from a campaign mission


ProduceInfantry = function() --this is the main looping function telling the AI to produce infantry and what to do with it.

	local delay = Utils.RandomInteger(DateTime.Seconds(3), DateTime.Seconds(9)) --this is just a minor randomiser to make the unit production seem less streamlined
	local toBuild = { Utils.Random(SovietInfantryTypes) } --this defines a local variable to tell the Soviets which types of infantry to use. Here, a random unit is built from the entry "SovietInfantryTypes" above.
	ussr.Build(toBuild, function(unit) --this initiates the actual building process: player "ussr" starts to build a unit of type "toBuild" that is defined in the preceding line, the built unit is afterwards referred to as "unit"
		InfAttack[#InfAttack + 1] = unit[1] --I don't fully get this line, but I infer that if a unit is built, 1 is added to the unit counter

		if #InfAttack >= 6 then --this number is crucial: it defines the amount of infantry the Soviets are going to throw at you in one wave. In this case, it is 6. You can freely adjust this number.
			SendUnits(InfAttack, SovietAttackPath) --this tells the AI what to do with the units after building them - it is defined in the function below, but means: the team "InfAttack" is sent along the way "SovietAttackPath"
			InfAttack = { } -- this is necessary to reset the unit count to zero after the attack
			Trigger.AfterDelay(DateTime.Minutes(3), ProduceInfantry) --this tells the game when to start producing the same team again after successfully completing it and having sent it to attack. You can again use any number or change the minutes amount to seconds.
		else
			Trigger.AfterDelay(delay, ProduceInfantry) --you can see in the "if"-structure that this just means "if I have built a unit, but still have not reached the goal of having at least 6, I continue building"
		end
	end)
end

ProduceTanks = function() --this is the same as above but with tanks. You can see that it is functionally identical, but all instances of "InfAttack" have been replaced with "TankAttack" and the time variables differ a bit. You can of course use a different attack path if you want.

--------------some more general remarks about unit production:
--If you order an AI player to produce a unit he is incapable of producing due to its prerequisites (e.g. telling the Soviets to build Heavy Tanks without having a Service Depot), he will simply not do anything if this unit is called during the random selection process, stopping the loop of the attack function as well. You therefore should always make sure that the AI is actually able to build that specific unit, or alternatively remove the prerequisites in rules.yaml.
--so far I have not been able to make an AI player use more than one function in the same production queue. So you can only use infantry in one function, vehicles in another and aircraft in a third, but not two different functions for vehicles for example (may be different in TD, not tested) or mix infantry and tanks in the same attack team. (?) You can circumvent this by creating a second AI player though.
--it does not seem that the AI cheats money on its own. So if it is not building, it might simply have run out of cash.

	local delay = Utils.RandomInteger(DateTime.Seconds(12), DateTime.Seconds(17)) --a bit more delay between building tanks
	local toBuild = { Utils.Random(SovietArmorTypes) }
	ussr.Build(toBuild, function(unit)
		TankAttack[#TankAttack + 1] = unit[1]

		if #TankAttack >= 2 then --two tanks are enough here
			SendUnits(TankAttack, SovietAttackPath)
			TankAttack = { }
			Trigger.AfterDelay(DateTime.Minutes(3), ProduceTanks) --same delay
		else
			Trigger.AfterDelay(delay, ProduceTanks)
		end
	end)
end

SendUnits = function(units, waypoints) --this is the function that actually sends the units on the attack. You may note that we can use just (units, waypoints) because we have defined the units earlier in "SendUnits(InfAttack, SovietAttackPath)"
	Utils.Do(units, function(unit)
		if not unit.IsDead then
			Utils.Do(waypoints, function(waypoint)
				unit.AttackMove(waypoint.Location) --this means attack move follow the waypoint path defined at the start
			end)
			unit.Hunt() --start hunting if finished (?)
		end
	end)
end

InitProductionBuildings = function()
	if not Wafa.IsDead then
		Wafa.IsPrimaryBuilding = true --this marks the building named "Wafa" in map.yaml as the primary building - not necessary, but useful for determining the direction of the enemy if he has more than one Warfactory
		Trigger.OnKilled(Wafa, function() BuildVehicles = false end) --probably deprecated
	else
		BuildVehicles = false
	end

	if not Rax.IsDead then
		Rax.IsPrimaryBuilding = true --the same as above, but for a barracks called "Rax"
		Trigger.OnKilled(Rax, function() TrainInfantry = false end)
	else
		TrainInfantry = false
	end
end

ActivateAI = function() --this function tells the AI to start production

	InitProductionBuildings() --as below: calls the function with this name, directly above this one

	Trigger.AfterDelay(DateTime.Seconds(10), function() --this calls the two attack functions below, but with a 10 second delay. You can adjust this delay if you want the enemy attacks to start later to give the human player some time to build up.
		ProduceInfantry()
		ProduceTanks()
	end)
end

InitObjectives = function()
	
	ussrObj = ussr.AddPrimaryObjective("Deny the Allies.") --this adds an objective for the AI. One way to make the human player lose the game is to mark this as completed - which I have not done in this script.
	KillAll = allies.AddPrimaryObjective("Eliminate all Soviet units in this area.") --this adds an objective for the human player, which is much more important because it is visible in the game. The first part in front of the "=" is the internal name of the objective that can be referred to later. You can use Primary and Secondary, depending on the objective. I would advise not to use a very long description string because it needs to fit in the UI - long descriptions belong into the "Briefing" entry in map.yaml or rules.yaml.

	Trigger.OnObjectiveAdded(allies, function(p, id) --the next five basically can be copied in every mission (of course adjusted for player designations) as they just tell the game to play sounds and display texts for the objectives.
		Media.DisplayMessage(p.GetObjectiveDescription(id), "New " .. string.lower(p.GetObjectiveType(id)) .. " objective")
	end)
	
	Trigger.OnObjectiveCompleted(allies, function(p, id)
		Media.DisplayMessage(p.GetObjectiveDescription(id), "Objective completed")
	end)
	Trigger.OnObjectiveFailed(allies, function(p, id)
		Media.DisplayMessage(p.GetObjectiveDescription(id), "Objective failed")
	end)

	Trigger.OnPlayerLost(allies, function()
		Media.PlaySpeechNotification(player, "Lose")
	end)
	Trigger.OnPlayerWon(allies, function()
		Media.PlaySpeechNotification(player, "Win")
	end)
end

WorldLoaded = function() --the most important function - it gets the game started! This function is called immediately when the game starts.
	allies = Player.GetPlayer("Allies") --tell the game which player is afterwards defined as "allies". The second part after "Player.GetPlayer" needs to be filled with a player designation from map.yaml. To avoid confusion between the two, I did not use a capitalised a for the Lua definition.
	ussr = Player.GetPlayer("USSR") --the same as above for the Soviets

	InitObjectives() --this immediately calls the function with this name without any conditions
	ActivateAI() --same
end
An important caveat:

-I am still a noob compared to the real pros - this is more a case of "the one-eyed man making a tutorial for the blind". Some elements may not work as I understand them or be completely redundant.

Feel free to use any code or map data for your own work - after all, I also copied and pasted my stuff together from various other maps.

abcdefg30
Posts: 641
Joined: Mon Aug 18, 2014 6:00 pm

Post by abcdefg30 »

Code: Select all

IdleHunt = function(unit) if not unit.IsDead then Trigger.OnIdle(unit, unit.Hunt) end end --I have no clue about this tbh - copied it from a campaign mission
This is unused in your script. The IdleHunt function tells a unit to hunt enemy units as soon as it got nothing to do. I.e. you could replace the line "unit.Hunt() --start hunting if finished (?)" with "IdleHunt(unit)" to make the unit hunt until it died, not just once.

Code: Select all

ussr.Build(toBuild, function(unit) --this initiates the actual building process: player "ussr" starts to build a unit of type "toBuild" that is defined in the preceding line, the built unit is afterwards referred to as "unit"
      InfAttack[#InfAttack + 1] = unit[1] --I don't fully get this line, but I infer that if a unit is built, 1 is added to the unit counter
[...]
Actually, "unit" is a misleading name here. The build function returns a collection ('table') containing all units which were build. Yes, you can build multiple ones, e.g. using "ussr.Build({"e1", "e1"}, function(units) ... end)".

So, units[1] will return the first item in the table (collection), which is the only unit we built.

Code: Select all

Trigger.OnKilled(Wafa, function() BuildVehicles = false end) --probably deprecated 
Not sure what you mean here. This should set the "BuildVehicles" variable to false once the "Wafa" actor is killed.

Code: Select all

KillAll = allies.AddPrimaryObjective("Eliminate all Soviet units in this area.") --this adds an objective for the human player, which is much more important because it is visible in the game. The first part in front of the "=" is the internal name of the objective that can be referred to later. You can use Primary and Secondary, depending on the objective. I would advise not to use a very long description string because it needs to fit in the UI - long descriptions belong into the "Briefing" entry in map.yaml or rules.yaml.
You can use "\n" to add line breaks and consequently span the objective description over several lines ingame. You're right though that long descriptions don't belong there. :D

Code: Select all

Trigger.OnPlayerLost(allies, function()
   Media.PlaySpeechNotification(player, "Lose")
end)
Trigger.OnPlayerWon(allies, function()
   Media.PlaySpeechNotification(player, "Win")
end) 
Are you sure that this works? I'm not sure where you got that "player" from. As far as I can see, that should be "allies" instead. (You want to play the notification to that player.)


So far, looking good & promising! :)
Btw, if you didn't know: We have an IRC channel (#openra) you (or everyone else) can join and ask questions (assuming someone with Lua knowledge is around ^^).

SethXXX
Posts: 17
Joined: Sat Aug 30, 2014 9:07 am

Post by SethXXX »

thanks for alle the corrections, I will post a refined version soon, putting that into account.

Concerning the last part: That "player" is indeed a leftover from the mission I got the code from, it has been fixed.

I am aware of the IRC, but I prefer the forums - it just seems more orderly to me, and forums have the added benefit of functioning as an easily searchable documentation for others who might have the same or similar questions later.

Post Reply