Files
OpenRA/mods/cnc/maps/the-tiberium-strain/the-tiberium-strain.lua
let5sne.win10 9cf6ebb986
Some checks failed
Continuous Integration / Linux (.NET 8.0) (push) Has been cancelled
Continuous Integration / Windows (.NET 8.0) (push) Has been cancelled
Initial commit: OpenRA game engine
Fork from OpenRA/OpenRA with one-click launch script (start-ra.cmd)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:46:54 +08:00

736 lines
20 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.
]]
--- This mission's human player.
local Nod = Player.GetPlayer("Nod")
--- Owner of the defenders.
local GDI = Player.GetPlayer("GDI")
--- Owner of the homes and villagers.
local Villagers = Player.GetPlayer("Villagers")
---@type integer Objective to destroy GDI's bio centers.
local BioObjective
---@type integer Objective to destroy all villagers.
local VillagerObjective
---@type integer Final objective to keep all non-BIO structures intact.
local IntactObjective
--- Tally of most GDI deaths. Extra units on Hard do not apply.
local GDIDeathCount = 0
--- Number of counted GDI deaths that will trigger a map-wide hunt.
local HuntThreshold = 65
--- Has Nod's starting force arrived?
local NodArrived = false
--- Are Nod's village sweepers about to reinforce?
local SweepersRequested = false
--- Have civilians spawned from the south village road?
local RunnersArrived = false
--- Return one of the entry waypoints by the east ore field.
---@return actor waypoint
local function RandomEastEntry()
return Map.NamedActor("EastEntry" .. Utils.RandomInteger(1, 5))
end
--- Is this actor a Nod attacker?
---@param actor actor
---@return boolean
local function IsMobileNod(actor)
return not actor.IsDead and actor.HasProperty("Move") and actor.Owner == Nod
end
--- Is this a resident of the main village?
local function IsWestVillager(actor)
return actor.HasTag("West Villager")
end
--- Is this actor alive and tagged to guard a wide area?
---@param actor actor
---@return boolean
local function IsLiveAreaGuard(actor)
return not actor.IsDead and actor.HasTag("Area Guard")
end
--- Is this actor either dead or idle?
---@param actor actor
---@return boolean
local function IsDeadOrIdle(actor)
return actor.IsDead or actor.IsIdle
end
--- Is Nod wiped out with no incoming sweepers?
--- @return boolean
local function IsNodDead()
return NodArrived and Nod.HasNoRequiredUnits() and not SweepersRequested
end
--- Is this actor free to come hunt base attackers?
---@param actor actor
---@return boolean
local function CanDefendBase(actor)
return not actor.IsDead and actor.IsIdle and not actor.HasTag("Area Guard")
end
--- Is GDI weak enough that Nod sweepers can be reinforced?
---@return boolean
local function AreSweepersReady()
local defenders = Utils.Where(GDI.GetGroundAttackers(), function(a)
-- Disregard the slow Mammoth Tank, which can be avoided if necessary.
return a.Type ~= "htnk"
end)
return #defenders == 0
end
--- Reveal the defenses by GDI's west gate.
local function RevealWestBaseEntrance()
local camera = Actor.Create("camera.small", true, { Owner = Nod, Location = WestGateReveal.Location })
Trigger.AfterDelay(DateTime.Seconds(6), camera.Destroy)
end
--- Mark defeat for Nod's dead task force.
local function MarkNodDead()
local objs = { BioObjective, VillagerObjective }
Utils.Do(objs, function(obj)
if Nod.IsObjectiveCompleted(obj) then
return
end
Nod.MarkFailedObjective(obj)
end)
end
--- Add the villager slaying objective if it's not already present.
--- This is done more than once as a safety check, partly because the
--- visibility cheat will bypass the usual trigger for this.
---@param announced? boolean
local function AddVillagerObjective(announced)
if not announced or IsNodDead() then
VillagerObjective = VillagerObjective or AddPrimaryObjective(Nod, "kill-every-villager-in-area")
return
end
local speaker = UserInterface.GetFluentMessage("nod-soldier")
Media.DisplayMessage(UserInterface.GetFluentMessage("careless-gdi-experiments-killed"), speaker, Nod.Color)
Trigger.AfterDelay(DateTime.Seconds(4), function()
if IsNodDead() then
return
end
Media.DisplayMessage(UserInterface.GetFluentMessage("villagers-must-not-live"), speaker, Nod.Color)
end)
Trigger.AfterDelay(DateTime.Seconds(8), AddVillagerObjective)
end
--- Check the villager objective after each has been slain.
local function CheckVillagerObjective()
-- Delay so HasNoRequiredUnits accounts for deaths during THIS tick.
Trigger.AfterDelay(1, function()
if RunnersArrived and Villagers.HasNoRequiredUnits() then
AddVillagerObjective()
Nod.MarkCompletedObjective(VillagerObjective)
end
end)
end
--- Spawn some extra Nod units for Easy difficulty.
local function ReinforceEasyNod()
if IsNodDead() then
return
end
local rallies = { ChemRally1, ChemRally2, FlameRally2 }
Media.PlaySpeechNotification(Nod, "Reinforce")
Utils.Do(rallies, function(rally)
local path = { NorthWestEntry2.Location, rally.Location }
Reinforcements.Reinforce(Nod, { "e5" }, path)
end)
end
--- Prepare to count an actor's death toward GDI's hunt threshold.
---@param actor actor
local function TallyDeath(actor)
Trigger.OnKilled(actor, function()
GDIDeathCount = GDIDeathCount + 1
if GDIDeathCount == HuntThreshold then
-- The expected start -> village -> bridges -> SE corner -> base
-- path should yield 56-63 GDI deaths once the south gate guards
-- are cleared. 20 are from the village (incl. helicopter cargo).
Utils.Do(GDI.GetGroundAttackers(), function(attacker)
-- Stop any patrolling or area guarding.
attacker.Stop()
attacker.RemoveTag("Area Guard")
attacker.Stance = "AttackAnything"
IdleHunt(attacker)
end)
end
if Difficulty == "easy" and GDIDeathCount % 15 == 0 then
ReinforceEasyNod()
end
end)
end
--- Spawn and assemble Nod's starting force.
local function ReinforceFirstNod()
Media.PlaySpeechNotification(Nod, "Reinforce")
local chemTypes = { "e5", "e5", "e5", "e5" }
local flameTypes = { "ftnk" }
for i = 1, 3 do
local path = { Map.NamedActor("NorthWestEntry" .. i).Location, Map.NamedActor("FlameRally" .. i).Location }
Reinforcements.Reinforce(Nod, flameTypes, path)
if i ~= 3 then
Trigger.AfterDelay(20, function()
local chemPath = { NorthWestEntry2.Location, Map.NamedActor("ChemRally" .. i).Location }
Reinforcements.Reinforce(Nod, chemTypes, chemPath, 10)
end)
end
end
Trigger.AfterDelay(DateTime.Seconds(3), function()
NodArrived = true
end)
end
--- Spawn another Flame Tank for Nod after the bridge tank's death.
local function ReinforceNodTank()
if IsNodDead() then
return
end
Media.PlaySpeechNotification(Nod, "Reinforce")
Reinforcements.Reinforce(Nod, { "ftnk" }, { NorthWestEntry2.Location, Waypoint1.Location })
end
--- Reinforce a Flame Tank as a reward for the intersection tank's death.
--- This originally spawns in the starting area, with no runners to chase.
local function ReinforceVillageFlameTank()
if IsNodDead() then
return
end
local runnerTypes = { "c3", "c9", "c7" }
local runners = Reinforcements.Reinforce(Villagers, runnerTypes, { SouthVillageEntry.Location }, 5, function(runner)
Trigger.OnIdle(runner, function()
if runner.Location == Waypoint3.Location then
Trigger.ClearAll(runner)
end
runner.Move(Waypoint3.Location)
end)
end)
RunnersArrived = true
Trigger.OnAllKilled(runners, CheckVillagerObjective)
local camera = Actor.Create("camera.small", true, { Owner = Nod, Location = SouthVillageEntry.Location })
Trigger.AfterDelay(DateTime.Seconds(1), function()
local path = { SouthVillageEntry.Location, SouthVillageEntry.Location + CVec.New(0, -2) }
Reinforcements.Reinforce(Nod, { "ftnk" }, path)
Media.PlaySpeechNotification(Nod, "Reinforce")
camera.Destroy()
end)
end
--- Reinforce Nod to resolve loose ends in the main village. This will speed
--- things along if Nod is close to victory but on the opposite end of the map.
local function ScheduleVillageSweepers()
if #Utils.Where(Villagers.GetActors(), IsWestVillager) == 0 then
return
end
if not AreSweepersReady() then
Trigger.AfterDelay(10, ScheduleVillageSweepers)
return
end
SweepersRequested = true
Trigger.AfterDelay(DateTime.Seconds(3), function()
Media.PlaySpeechNotification(Nod, "Reinforce")
local path = { WestVillageEntry.Location, Intersection.Location }
local types = { "ftnk", "e5", "e5", "e5", "e5", "e5" }
Reinforcements.Reinforce(Nod, types, path, 10)
end)
Trigger.AfterDelay(DateTime.Seconds(4), function()
SweepersRequested = false
end)
end
--- Airlift an infantry team to avenge GDI units near the intersection.
local function ReinforceVillageHelicopter()
local path = { RandomEastEntry().Location, Waypoint0.Location }
local passengerTypes = { "e2", "e2", "e2", "e3", "e3" }
local passengers = Reinforcements.ReinforceWithTransport(GDI, "tran", passengerTypes, path, { path[1] })[2]
Utils.Do(passengers, function(passenger)
IdleHunt(passenger)
TallyDeath(passenger)
end)
end
--- Airlift an infantry team to hunt near the south edge's river crossing.
local function ReinforceRiverHelicopter()
local path = { SouthRiverEntry.Location, Waypoint2.Location }
local exit = RandomEastEntry().Location
local passengerTypes = { "e1", "e1", "e1", "e1", "e3" }
local passengers = Reinforcements.ReinforceWithTransport(GDI, "tran", passengerTypes, path, { exit })[2]
Utils.Do(passengers, function(passenger)
TallyDeath(passenger)
Trigger.OnAddedToWorld(passenger, function()
-- IdleHunt on its own may cause some of these infantry to take a
-- long route around the map because the narrow canyon is occupied.
passenger.AttackMove(SouthRiverRally.Location)
passenger.AttackMove(SouthBridgeRally.Location)
IdleHunt(passenger)
end)
end)
end
--- Reinforce some scouts to counterattack at the south gate.
local function ReinforceEdgeHumvees()
local entry = RandomEastEntry()
local path = { entry.Location, entry.Location + CVec.New(-2, 0) }
Reinforcements.Reinforce(GDI, { "jeep", "jeep" }, path, 25, function(humvee)
humvee.AttackMove(EastHumveeRally.Location, 2)
humvee.AttackMove(SouthGate.Location, 2)
humvee.AttackMove(Waypoint3.Location, 2)
IdleHunt(humvee)
TallyDeath(humvee)
end)
end
--- Order a group to intercept (or chase away) nearby intruders when idle.
--- This is intended to feel similar to GUARD_AREA orders in TD '95.
---@param guards actor[] List of guards.
---@param center wpos Center of the group's search area.
---@param range wdist Radius of the area to guard.
local function GuardArea(guards, center, range)
local activated = false
local refreshed = false
guards = Utils.Where(guards, IsLiveAreaGuard)
if #guards == 0 then
return
end
Trigger.OnEnteredProximityTrigger(center, range, function(target, id)
if activated or not IsMobileNod(target) then
return
end
activated = true
Trigger.RemoveProximityTrigger(id)
Utils.Do(guards, function(guard)
if not IsLiveAreaGuard(guard) or not guard.IsIdle then
return
end
guard.AttackMove(target.Location, 2)
guard.AttackMove(guard.Location)
end)
end)
Utils.Do(guards, function(guard)
guard.Stance = "Defend"
Trigger.OnIdle(guard, function()
-- Does at least one guard remain after an intruder search?
-- If so, reset the guards and prepare another search area.
if refreshed or not activated or not Utils.All(guards, IsDeadOrIdle) then
return
end
refreshed = true
local survivors = Utils.Where(guards, IsLiveAreaGuard)
Utils.Do(survivors, function(survivor)
Trigger.Clear(survivor, "OnIdle")
end)
Trigger.AfterDelay(10, function()
GuardArea(survivors, center, range)
end)
end)
end)
end
--- Order a random armed villager in the west to begin hunting.
local function SendVillagerHunter()
local villagers = Utils.Where(Villagers.GetGroundAttackers(), IsWestVillager)
if #villagers == 0 then
return
end
local guard = Utils.Random(villagers)
IdleHunt(guard)
end
--- Send an available unit to defend a damaged base structure.
---@param location cpos Location of the structure attacker.
local function SendBaseGuard(location)
local guards = Utils.Where(GDI.GetGroundAttackers(), CanDefendBase)
if #guards == 0 then
return
end
local guard = Utils.Random(guards)
guard.AttackMove(location, 2)
IdleHunt(guard)
end
--- Prepare simple events for the pre-placed villagers.
local function PrepareVillagers()
Trigger.OnPlayerDiscovered(Villagers, function(_, discoverer)
if discoverer == Nod then
AddVillagerObjective(true)
end
end)
local mobiles = Utils.Where(Villagers.GetActors(), function(actor)
return actor.HasProperty("Move")
end)
Trigger.OnAllKilled(mobiles, CheckVillagerObjective)
end
--- Place all the footprint triggers needed at startup.
local function PrepareFootprints()
local footprints =
{
{
action = ReinforceRiverHelicopter,
cells =
{
CPos.New(28, 43),
CPos.New(28, 44),
CPos.New(28, 45),
CPos.New(28, 46)
}
},
{
action = ReinforceEdgeHumvees,
cells =
{
CPos.New(49, 20),
CPos.New(50, 20),
CPos.New(51, 20),
CPos.New(52, 20),
CPos.New(53, 20)
}
},
{
action = RevealWestBaseEntrance,
cells =
{
CPos.New(22, 11),
CPos.New(22, 12),
CPos.New(22, 13),
CPos.New(22, 14),
CPos.New(22, 15),
CPos.New(22, 16)
}
}
}
Utils.Do(footprints, function(footprint)
local activated = false
Trigger.OnEnteredFootprint(footprint.cells, function(actor, id)
if activated or not IsMobileNod(actor) then
return
end
activated = true
Trigger.RemoveFootprintTrigger(id)
footprint.action()
end)
end)
end
--- Set up the flares and objective for GDI's bio centers.
local function PrepareBioCenters()
local centers = { Bio1, Bio2, Bio3 }
local offset = CVec.New(1, 0)
Utils.Do(centers, function(center)
Trigger.OnKilled(center, function()
Actor.Create("flare", true, { Owner = GDI, Location = center.Location + offset })
end)
end)
Trigger.OnAllKilled(centers, function()
AddVillagerObjective()
ScheduleVillageSweepers()
Nod.MarkCompletedObjective(BioObjective)
end)
end
--- Prepare GDI structures' survival objective, repairs, and calls for help.
local function PrepareBase()
local baseStructures = Utils.Where(GDI.GetActors(), function(actor)
return actor.HasProperty("StartBuildingRepairs") and actor.Type ~= "bio"
end)
Utils.Do(baseStructures, function(structure)
Trigger.OnKilled(structure, function()
Nod.MarkFailedObjective(IntactObjective)
end)
RepairBuilding(GDI, structure, 0.75)
-- Do not guard defenses or the isolated Communications Center.
if structure.HasProperty("Attack") or structure.Type == "hq" then
return
end
Trigger.OnDamaged(structure, function(_, attacker)
if attacker.Owner == structure.Owner or attacker.IsDead then
return
end
SendBaseGuard(attacker.Location)
end)
end)
end
--- Prepare certain groups to begin area guarding. Some infantry that did area
--- guard have been skipped because their ORA range makes this feel redundant.
local function PrepareAreaGuards()
local teams =
{
{
actors = Map.ActorsWithTag("West Gate Grenadier"),
range = 5
},
{
actors = Map.ActorsWithTag("Village Entrance Guard"),
center = VillageEntranceGrenadier.CenterPosition,
range = 5
},
{
actors = Map.ActorsWithTag("Second House Guard"),
center = VillageHouse2.CenterPosition,
},
{
actors = { HilltopHumvee }
},
{
actors = { IntersectionHumvee }
},
{
actors = { CommunicationsHumvee },
},
{
actors = Map.ActorsWithTag("Communications Guard"),
center = CommunicationsGrenadier.CenterPosition,
range = 6
},
{
actors = { BioGrenadier },
range = 5
}
}
Utils.Do(teams, function(team)
local radius = WDist.FromCells(team.range or 7)
local center = team.center or team.actors[1].CenterPosition
GuardArea(team.actors, center, radius)
end)
end
--- Prepare houses' survival objective, targetable switch, and calls for help.
local function PrepareVillageHouses()
if Difficulty == "easy" then
-- Require force-fire to attack houses, as butterfingers insurance.
Actor.Create("villagetargetableswitch", true, { Owner = Villagers, Location = CPos.Zero })
end
local village = Utils.Where(Villagers.GetActors(), function(actor)
return actor.HasProperty("Health") and not actor.HasProperty("Move")
end)
Utils.Do(village, function(house)
Trigger.OnKilled(house, function()
Nod.MarkFailedObjective(IntactObjective)
end)
-- Only houses inside the main village should call for help.
if house.Location.X >= WestGateReveal.Location.X then
return
end
local hunterCalled = false
Trigger.OnDamaged(house, function()
if hunterCalled or house.IsDead then
return
end
hunterCalled = true
SendVillagerHunter()
Trigger.Clear(house, "OnDamaged")
end)
end)
end
--- Prepare the reveals and patrol for Hard's Mammoth Tank.
---@param tank actor
local function PrepareMammoth(tank)
local path = { MammothPatrolPoint1.Location, MammothPatrolPoint2.Location, MammothPatrolPoint3.Location, MammothPatrolPoint4.Location }
local encountered = false
Trigger.OnEnteredProximityTrigger(MammothEntry.CenterPosition, WDist.FromCells(9), function(actor, id)
if encountered or not IsMobileNod(actor) then
return
end
encountered = true
Trigger.RemoveProximityTrigger(id)
tank.Patrol(path, true, DateTime.Seconds(3))
local camera = Actor.Create("camera.small", true, { Owner = Nod, Location = MammothPatrolPoint1.Location })
Trigger.AfterDelay(DateTime.Seconds(6), camera.Destroy)
end)
Trigger.OnEnteredProximityTrigger(MammothGateReveal.CenterPosition, WDist.FromCells(4), function(actor, id)
if actor.Type ~= "htnk" then
return
end
Trigger.RemoveProximityTrigger(id)
local camera = Actor.Create("camera", true, { Owner = Nod, Location = MammothGateReveal.Location })
Trigger.AfterDelay(DateTime.Seconds(6), camera.Destroy)
end)
end
--- Spawn some extra units for the light "remix" of Hard difficulty.
local function SpawnHardUnits()
if Difficulty ~= "hard" then
return
end
local spawns =
{
{
type = "apc",
cell = ApcEntry.Location,
facing = Angle.SouthWest
},
{
type = "htnk",
cell = MammothEntry.Location,
facing = Angle.West,
action = PrepareMammoth
},
{
type = "e2",
cell = SouthGateGrenadierEntry.Location,
facing = Angle.SouthEast,
stance = "Defend"
},
{
type = "e3",
cell = SouthGateRocketEntry.Location,
facing = Angle.SouthWest,
stance = "Defend"
},
{
type = "mtnk",
cell = Waypoint0.Location,
facing = Angle.North,
stance = "Defend"
},
{
type = "e3",
cell = CentralFordEntry1.Location
},
{
type = "e3",
cell = CentralFordEntry2.Location
},
{
type = "e3",
cell = CentralFordEntry3.Location
},
{
type = "mtnk",
cell = SouthFordEntry.Location,
stance = "Defend"
}
}
Utils.Do(spawns, function(spawn)
local facing = spawn.facing or Angle.West
local actor = Actor.Create(spawn.type, true, { Owner = GDI, Location = spawn.cell, Facing = facing })
actor.Stance = spawn.stance or actor.Stance
if spawn.action then
spawn.action(actor)
end
end)
end
WorldLoaded = function()
InitObjectives(Nod)
BioObjective = AddPrimaryObjective(Nod, "destroy-gdi-bio-centers")
IntactObjective = AddPrimaryObjective(Nod, "keep-all-other-structures")
PrepareFootprints()
PrepareBase()
PrepareBioCenters()
PrepareVillageHouses()
Utils.Do(GDI.GetGroundAttackers(), TallyDeath)
PrepareVillagers()
PrepareAreaGuards()
SpawnHardUnits()
ReinforceFirstNod()
GDI.Cash = 5000
Camera.Position = FlameRally2.CenterPosition
Trigger.OnKilled(BridgeTank, ReinforceNodTank)
local intersectionTeam = { IntersectionHumvee, IntersectionRifle1, IntersectionRifle2, IntersectionRifle3 }
Trigger.OnAllKilled(intersectionTeam, ReinforceVillageHelicopter)
Trigger.OnKilled(IntersectionTank, function()
Trigger.AfterDelay(DateTime.Seconds(1), ReinforceVillageFlameTank)
end)
end
Tick = function()
if IsNodDead() then
MarkNodDead()
end
if Nod.IsObjectiveCompleted(BioObjective) and Nod.IsObjectiveCompleted(VillagerObjective) then
Nod.MarkCompletedObjective(IntactObjective)
end
end