Files
OpenRA/mods/d2k/scripts/campaign.lua
N.N 3a86c035b3 Rework D2k campaign scripts:
- Add InitialProductionDelay
- Different attacksDelays for early game and late game
- Adjust T3 unit production for selected missions
- Implement InitialAttackDelay, TimeBetweenAttacks
- Implement routines for idle units
- Emergency mode
- Airstrike rework
2026-02-22 17:04:04 +02:00

590 lines
18 KiB
Lua

--[[
Copyright (c) The OpenRA Developers and Contributors
This file is part of OpenRA, which is free software. It is made
available to you under the terms of the GNU General Public License
as published by the Free Software Foundation, either version 3 of
the License, or (at your option) any later version. For more
information, see COPYING.
]]
Difficulty = Map.LobbyOptionOrDefault("difficulty", "normal")
DifficultyModifier =
{
easy = 1,
normal = 0.8,
hard = 0.7
}
CrushChance =
{
easy = 20,
normal = 40,
hard = 60
}
CrusherTypes = { "combat_tank_a", "combat_tank_a.starport", "combat_tank_h", "combat_tank_h.starport, combat_tank_o, combat_tank_o.starport"}
ActivateCrushLogic = function ()
Trigger.OnAnyProduction( function(producer, produced)
if not producer.Owner.IsBot then return end
if Utils.Any(CrusherTypes, function(crusherType)
return crusherType == produced.Type
end)
then
AICrushLogic(produced, producer.Owner)
end
end)
end
--- Prepare basic messages for a player's win, loss, or objective updates.
---@param player player
InitObjectives = function(player)
Trigger.OnObjectiveCompleted(player, function(p, id)
Media.DisplayMessage(p.GetObjectiveDescription(id), UserInterface.GetFluentMessage("objective-completed"))
end)
Trigger.OnObjectiveFailed(player, function(p, id)
Media.DisplayMessage(p.GetObjectiveDescription(id), UserInterface.GetFluentMessage("objective-failed"))
end)
Trigger.OnPlayerLost(player, function()
Trigger.AfterDelay(DateTime.Seconds(1), function()
Media.PlaySpeechNotification(player, "Lose")
end)
end)
Trigger.OnPlayerWon(player, function()
Trigger.AfterDelay(DateTime.Seconds(1), function()
Media.PlaySpeechNotification(player, "Win")
end)
end)
end
---The Dune stand-in for the usual "Battlefield Control" announcer.
Mentat = UserInterface.GetFluentMessage("mentat")
--- Prepare waves of reinforcements to be deployed from Carryalls.
---@param player player Owner of the reinforcements and Carryalls.
---@param currentWave integer Current wave count. A typical starting value is 0.
---@param totalWaves integer Total number of waves to be reinforced.
---@param delay integer Ticks between each reinforcement.
---@param pathFunction fun():cpos[] Returns a path for each Carryall's entry flight.
---@param unitTypes table<integer, string[]> Collection of unit types that will be reinforced. Each group within this collection is keyed to a different wave number.
---@param haltCondition? fun():boolean Returns true if reinforcements should stop. If this function is absent, assume false.
---@param customHuntFunction? fun(actors: actor[]) Function called by each unit within a group upon creation. This defaults to IdleHunt. Note that reinforced units will not be in the world until unloaded.
---@param announcementFunction? fun(currentWave: integer) Function called when a new Carryall is created.
SendCarryallReinforcements = function(player, currentWave, totalWaves, delay, pathFunction, unitTypes, haltCondition, customHuntFunction, announcementFunction)
Trigger.AfterDelay(delay, function()
if haltCondition and haltCondition() then
return
end
currentWave = currentWave + 1
if currentWave > totalWaves then
return
end
if announcementFunction then
announcementFunction(currentWave)
end
local path = pathFunction()
local units = Reinforcements.ReinforceWithTransport(player, "carryall.reinforce", unitTypes[currentWave], path, { path[1] })[2]
if not customHuntFunction then
customHuntFunction = IdleHunt
end
Utils.Do(units, customHuntFunction)
SendCarryallReinforcements(player, currentWave, totalWaves, delay, pathFunction, unitTypes, haltCondition, customHuntFunction)
end)
end
--- Create a one-time area trigger that reinforces attackers with a Carryall.
---@param triggeringPlayer player
---@param reinforcingPlayer player
---@param area cpos[]
---@param unitTypes string[]
---@param path cpos[] Returns a path for the Carryall's entry flight.
---@param pauseCondition? fun():boolean Returns true if this trigger's activation should be paused. If this function is absent, assume false.
TriggerCarryallReinforcements = function(triggeringPlayer, reinforcingPlayer, area, unitTypes, path, pauseCondition)
local fired = false
Trigger.OnEnteredFootprint(area, function(a, id)
if pauseCondition and pauseCondition() then
return
end
if not fired and a.Owner == triggeringPlayer and a.Type ~= "carryall" then
fired = true
Trigger.RemoveFootprintTrigger(id)
local units = Reinforcements.ReinforceWithTransport(reinforcingPlayer, "carryall.reinforce", unitTypes, path, { path[1] })[2]
Utils.Do(units, IdleHunt)
end
end)
end
--- Destroy all non-reinforcement Carryalls owned by a player.
---@param player player
DestroyCarryalls = function(player)
Utils.Do(player.GetActorsByType("carryall"), function(actor) actor.Kill() end)
end
--- The following tables are used by the campaign AI,
--- with each value keyed to a different AI player.
--- Collection of a bot's spare units from production and reinforcement.
--- These units are used for periodic attacks or base/harvester defense.
---@type table<player, actor[]>
IdlingUnits = { }
--- Is a bot currently using idle units to attack?
---@type table<player, boolean>
Attacking = { }
--- return true if actor is already defended
---@type table<player, boolean[]>
Defending = { }
--- Collection of cells around player base
--- @type table<player, cpos[]>
DefencePerimeter = {}
--- Collection of patrol point where player should patrol
---@type table<player, cpos[]>
PatrolPoints = {}
--- Time in ticks for next attack. AI cant attack until AttackDelay < GameTime
--- Serve also as initial attack delay.
--- @type table<player, integer>
AttackDelay = {}
--- Minimum delays between attacks
--- @type table<player, integer>
TimeBetweenAttacks = {}
--- How much harvesters AI should maintain
--- @type table<player, integer>
HarvesterCount = {}
--- List of all unit combat types.
AllAttackUnitTypes = { "light_inf", "trooper", "trike", "quad", "siege_tank", "missile_tank", "sonic_tank", "devastator", "raider", "stealth_raider", "deviator", "combat_tank_a", "combat_tank_h", "combat_tank_o" }
--- When true ProductionDelays is 1 and HoldProduction is always false
---@type table<player, boolean>
EmergencyBuildRate = {}
--- Activates when EmergencyBuildRate is True.
--- Override this function for mission specific behaviour
---@param player player
---@param target cpos -- position where emergency event take place
EmergencyBehaviour = function(player, target)
HoldProduction[player] = false
if Difficulty == "hard" then
player.Cash = player.Cash + 2000
end
end
--- Is a bot's production routine on hold?
---@type table<player, boolean>
HoldProduction = { }
IgnoreDamageFromTypes = {"spicebloom", "SpiceExplosion", "Debris", "Debris2", "Debris3", "Debris4"}
--- Was a bot's last harvester eaten by a sandworm?
---@type table<player, boolean>
LastHarvesterEaten = { }
--- Gather units from a bot's idle unit pool, up to a certain group size.
---@param owner player
---@param size integer
---@return actor[]
SetupAttackGroup = function(owner, size)
local units = { }
RemoveDeadActors(IdlingUnits[owner])
for i = 0, size, 1 do
if #IdlingUnits[owner] == 0 then
return units
end
local number = Utils.RandomInteger(1, #IdlingUnits[owner] + 1)
if IdlingUnits[owner][number] then
units[i] = IdlingUnits[owner][number]
table.remove(IdlingUnits[owner], number)
end
end
return units
end
--- Order an attack from this bot if one is not already started.
---@param owner player
---@param size integer
SendAttack = function(owner, size)
if Attacking[owner] then return end
Attacking[owner] = true
HoldProduction[owner] = true
local units = SetupAttackGroup(owner, size)
Utils.Do(units, Trigger.ClearAll)
Trigger.AfterDelay(1, function()
Utils.Do(units, function(u)
u.Stop()
IdleHunt(u)
end)
end)
Trigger.OnAllRemovedFromWorld(units, function()
Attacking[owner] = false
HoldProduction[owner] = false
end)
end
--- Order patrol routine along side predefined patrol points. When Patrol routine ends, squad return into IdlingUnits pool.
--- If no patrol points set - ignore request
---@param owner player
---@param size integer
SendPatrol = function(owner, size)
if PatrolPoints[owner] == nil then return end
local units = SetupAttackGroup(owner, size)
local cells = Utils.Shuffle(PatrolPoints[owner])
Utils.Do(units, function(unit)
Trigger.ClearAll(unit)
Trigger.AfterDelay(1, function()
if unit.IsDead then return end
unit.Patrol(cells, false, 1000)
unit.CallFunc(function()
table.insert(IdlingUnits[owner], unit)
SelectRoutine(owner, unit)
end)
Trigger.OnDamaged(unit, function(self, attacker)
if Defending[owner][self] or owner.IsAlliedWith(attacker.Owner) then
return
end
if Utils.Any(IgnoreDamageFromTypes, function(a) return a == attacker.Type end) then
return
end
Defending[owner][unit] = true
Utils.Do(units, function(u)
if not u.IsDead then
u.Stop()
u.AttackMove(self.Location)
u.CallFunc(function()
FindTargetsInArea(owner, self)
end)
end
end)
end)
end)
end)
end
--- Prepare a unit to call for help if attacked.
---@param unit actor
---@param defendingPlayer player
---@param defenderCount integer
DefendActor = function(unit, defendingPlayer, defenderCount)
Trigger.OnDamaged(unit, function(self, attacker)
if unit.Owner ~= defendingPlayer or attacker.Owner == defendingPlayer then
return
end
if Defending[defendingPlayer][unit] then
return
end
-- Don't try to attack spiceblooms, debris, etc
if Utils.Any(IgnoreDamageFromTypes,
function(a) return a == attacker.Type end) then
return
end
Defending[defendingPlayer][unit] = true
Trigger.AfterDelay(1000, function()
if unit.IsDead then return end
Defending[defendingPlayer][unit] = false
end)
-- if all units are busy or dead, activate emergency mode
if #IdlingUnits[defendingPlayer] == 0 then
if EmergencyBuildRate[defendingPlayer] then return end
EmergencyBuildRate[defendingPlayer] = true
EmergencyBehaviour(defendingPlayer, unit.Location)
Trigger.AfterDelay(1500, function() EmergencyBuildRate[defendingPlayer] = false end)
end
CheckArea(defendingPlayer, unit.Location)
end)
end
--- Prepare a harvester to call for help when attacked.
---@param unit actor
---@param owner player
---@param defenderCount integer
ProtectHarvester = function(unit, owner, defenderCount)
-- Note that worm attacks will not trigger the OnDamaged event for this.
DefendActor(unit, owner, defenderCount)
-- Worms don't kill the actor, but dispose it instead.
-- If a worm kills the last harvester (hence we check for remaining ones),
-- a new harvester is delivered by the harvester insurance.
-- Otherwise, there's no need to check for new harvesters.
local killed = false
Trigger.OnKilled(unit, function()
killed = true
end)
Trigger.OnRemovedFromWorld(unit, function()
if not killed and #unit.Owner.GetActorsByType("harvester") == 0 then
LastHarvesterEaten[owner] = true
end
end)
end
--- Schedule repairs for this building once it takes enough damage.
---@param owner player Owner of the building.
---@param actor actor Building to be repaired.
---@param modifier number The repair threshold. Below this health percentage, repairs are started. 1 is full health, while 0.5 is half.
RepairBuilding = function(owner, actor, modifier)
Trigger.OnDamaged(actor, function(building)
if building.Owner == owner and building.Health < building.MaxHealth * modifier then
building.StartBuildingRepairs()
end
end)
end
--- Prepare buildings to call for help and begin repairs if attacked.
---@param owner player Owner of the base.
---@param baseBuildings actor[] Buildings to defend and repair.
---@param modifier number The repair threshold. Below this health percentage, repairs are started. 1 is full health, while 0.5 is half.
---@param defenderCount integer Maximum number of defenders to use per counterattack.
DefendAndRepairBase = function(owner, baseBuildings, modifier, defenderCount)
Utils.Do(baseBuildings, function(actor)
if actor.IsDead then
return
end
DefendActor(actor, owner, defenderCount)
RepairBuilding(owner, actor, modifier)
if Difficulty == "hard" then
Trigger.OnKilled(actor, function(killer)
if EmergencyBuildRate[owner] then return end
EmergencyBuildRate[owner] = true
EmergencyBehaviour(owner, killer.Location)
Trigger.AfterDelay(1500, function() EmergencyBuildRate[owner] = false end)
end)
end
end)
end
--- Schedule production and attacks for a factory or other unit producer.
---@param player player Owner of the factory.
---@param factory actor The factory itself.
---@param delay fun(player):integer Function that returns a delay until production repeats.
---@param toBuild fun():string[] Function that returns a list of unit types to be produced.
---@param attackSize integer Number of units that will form the next attack wave.
---@param attackThresholdSize integer Number of idle units that will trigger an attack wave.
ProduceUnits = function(player, factory, delay, toBuild, attackSize, attackThresholdSize)
if factory.IsDead or factory.Owner ~= player then return end
if HoldProduction[player] then
Trigger.AfterDelay(DateTime.Seconds(10), function() ProduceUnits(player, factory, delay, toBuild, attackSize, attackThresholdSize) end)
return
end
--- Check harvester count
if factory.Type == "heavy_factory" and #player.GetActorsByType("harvester") < HarvesterCount[player] then
player.Build({"harvester"}, function()
Trigger.AfterDelay(delay(player), function() ProduceUnits(player, factory, delay, toBuild, attackSize, attackThresholdSize) end)
end)
return
end
player.Build(toBuild(), function(unit)
IdlingUnits[player][#IdlingUnits[player] + 1] = unit[1]
SelectRoutine(player, unit[1])
if #IdlingUnits[player] >= attackThresholdSize then
if DateTime.GameTime < AttackDelay[player] then
SendPatrol(player, attackSize / 2)
HoldProduction[player] = true
Trigger.AfterDelay((AttackDelay[player] - DateTime.GameTime) / 2, function()
HoldProduction[player] = false
end)
else
AttackDelay[player] = DateTime.GameTime + TimeBetweenAttacks[player]
SendAttack(player, attackSize)
end
end
Trigger.AfterDelay(delay(player), function() ProduceUnits(player, factory, delay, toBuild, attackSize, attackThresholdSize) end)
end)
end
--- Periodically checks for nearby infantry units to crush.
---@param unit actor
---@param bot player
function AICrushLogic(unit, bot)
if unit.IsDead then return end
if Utils.RandomInteger(1,101) >= CrushChance[Difficulty] then
Trigger.AfterDelay(200, function ()
AICrushLogic(unit, bot)
end)
return
end
local targets = Map.ActorsInCircle(unit.CenterPosition, WDist.FromCells(5), function (a)
return a.Owner.IsAlliedWith(bot) == false and
a.Type == "light_inf" or
a.Type == "trooper" or
a.Type == "engineer" and
Map.TerrainType(a.Location) ~= "Rough"
end)
if targets[1] ~= nil then
unit.Stop()
unit.Move(Utils.Random(targets).Location)
Trigger.AfterDelay(55, function ()
AICrushLogic(unit, bot)
end)
unit.Hunt()
else
Trigger.AfterDelay(200, function ()
AICrushLogic(unit, bot)
end)
end
end
function IdleHuntOnBaseDestroyed(player, base)
local checkedBase = Utils.Where(base, function(building) return not building.IsDead and building.Owner == player end)
Trigger.OnAllKilledOrCaptured(checkedBase , function()
if Utils.Any(base, function(building) return not building.IsDead and building.Owner == player end) then
IdleHuntOnBaseDestroyed(player, base)
else
Utils.Do(player.GetGroundAttackers(), function(a)
a.Stop()
IdleHunt(a)
end)
end
end)
end
--- Take number of units from IdlingUnits pool and check target area for enemy activity
--- When nothing found return to base and IdlingUnits pool/
--- @param owner player
--- @param targetArea cpos
--- @param size? integer (optional). When nil random integer is selected
CheckArea = function(owner, targetArea, size)
if #IdlingUnits[owner] == 0 then return end
size = size or Utils.RandomInteger(1 , #IdlingUnits[owner])
local squad = SetupAttackGroup(owner, size)
Utils.Do(squad, function(unit)
if unit.IsDead then return end
Trigger.ClearAll(unit)
unit.Stop()
unit.AttackMove(targetArea)
unit.CallFunc(function()
Trigger.AfterDelay(1, function()
FindTargetsInArea(owner, unit)
end)
end)
end)
end
FindTargetsInArea = function(owner, unit)
if unit.IsDead then return end
Trigger.OnIdle(unit, function ()
local enemies = Map.ActorsInCircle(unit.CenterPosition, WDist.FromCells(8), function(a)
return a.IsInWorld
and not a.IsDead
and not a.Owner.IsAlliedWith(unit.Owner)
and a.Owner.InternalName ~= "Neutral"
and a.Owner.InternalName ~= "Creeps"
end)
if #enemies > 0 then
unit.Hunt()
unit.Wait(10)
else
unit.Wait(Utils.RandomInteger(200 , 500))
if DefencePerimeter[owner] ~= nil then
unit.AttackMove(Utils.Random(DefencePerimeter[owner]))
end
unit.CallFunc(function()
if not unit.IsDead then
table.insert(IdlingUnits[owner], unit)
SelectRoutine(owner, unit)
end
end)
end
end)
end
SelectRoutine = function(owner, unit)
Trigger.ClearAll(unit)
Trigger.AfterDelay(1, function()
if unit.IsDead then return end
if DefencePerimeter[owner] == nil then return end
-- ensure unit return into defence perimeter before apply new triggers
unit.Move(Utils.Random(DefencePerimeter[owner]))
unit.CallFunc(function()
PatrolPerimeter(owner, unit)
Trigger.OnKilled(unit, function(killer)
RemoveActor(IdlingUnits[owner], unit)
CheckArea(owner, killer.Location)
end)
end)
end)
end
PatrolPerimeter = function(owner, unit)
if unit.IsDead then return end
local targetCell = Utils.Random(DefencePerimeter[owner])
unit.AttackMove(targetCell, 2)
unit.CallFunc(function ()
Trigger.AfterDelay(Utils.RandomInteger(300, 500), function()
if unit.IsDead then return end
PatrolPerimeter(owner, unit)
end)
end)
end
GetCellsInRectangle = function (topLeft, bottomRight)
local cells = {}
local index = 1
for x = topLeft.X, bottomRight.X, 1 do
for y = topLeft.Y, bottomRight.Y, 1 do
local cell = CPos.New(x, y)
if Map.TerrainType(cell) ~= "Cliff" and Map.TerrainType(cell) ~= "Rough" then
cells[index] = cell
index = index + 1
end
end
end
return cells
end
RemoveDeadActors = function (actors)
for i = #actors, 1, -1 do
if actors[i].IsDead or not actors[i].IsInWorld then
table.remove(actors, i)
end
end
end
RemoveActor = function (actors, actor)
for i = #actors, 1, -1 do
if actors[i] == actor then
table.remove(actors, i)
return
end
end
end