Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 83 additions & 8 deletions luarules/gadgets/unit_areaattack.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,30 @@
if gadgetHandler:IsSyncedCode() then
local attackList = {}
local closeList = {}
local activeAttacks = {}

local math_random = math.random
local math_pi = math.pi
local math_sqrt = math.sqrt
local math_cos = math.cos
local math_sin = math.sin
local math_max = math.max
local math_bit_and = math.bit_and

local CMD_ATTACK = CMD.ATTACK
local CMD_OPT_INTERNAL = CMD.OPT_INTERNAL
local reissueOrder = Game.Commands.ReissueOrder

local canAreaAttack = {}
local areaAttackWeaponDefs = {}
local areaAttackWeaponDefByUnitDef = {}
for unitDefID, unitDef in pairs(UnitDefs) do
if #unitDef.weapons > 0 and unitDef.customParams.canareaattack then
canAreaAttack[unitDefID] = WeaponDefs[unitDef.weapons[1].weaponDef].range
local weaponDefID = unitDef.weapons[1].weaponDef
local weaponDef = WeaponDefs[weaponDefID]
canAreaAttack[unitDefID] = weaponDef.range

Check warning on line 43 in luarules/gadgets/unit_areaattack.lua

View workflow job for this annotation

GitHub Actions / emmylua_check

need-check-nil

weaponDef may be nil
areaAttackWeaponDefs[weaponDefID] = true
areaAttackWeaponDefByUnitDef[unitDefID] = weaponDefID
end
end
local range = canAreaAttack -- range per unitDefID, same data
Expand All @@ -52,12 +62,14 @@
local phase = math_random(200 * math_pi) / 100.0
if o.radius > 0 then
local amp = math_random(o.radius)
Spring.GiveOrderToUnit(
o.unit,
CMD.INSERT,
{ 0, CMD.ATTACK, 0, o.x + math_cos(phase) * amp, o.y, o.z + math_sin(phase) * amp },
{ "alt" }
)
Spring.GiveOrderToUnit(o.unit, CMD.INSERT, {
0,
CMD.ATTACK,
CMD_OPT_INTERNAL,
o.x + math_cos(phase) * amp,
o.y,
o.z + math_sin(phase) * amp,
}, { "alt" })
end
end
for i, o in pairs(closeList) do
Expand All @@ -66,6 +78,31 @@
end
end

function gadget:GameFramePost(frame)
for unitID, attack in pairs(activeAttacks) do
if frame >= attack.checkFrame then
local salvoLeft = Spring.GetUnitWeaponState(unitID, 1, "salvoLeft")
if not salvoLeft then
activeAttacks[unitID] = nil
elseif salvoLeft > 0 then
local nextSalvo = Spring.GetUnitWeaponState(unitID, 1, "nextSalvo")
attack.checkFrame = math_max(nextSalvo or 0, frame + 1)
else
activeAttacks[unitID] = nil
local commandID, _, commandTag = Spring.GetUnitCurrentCommand(unitID)
if commandID == CMD_ATTACK and commandTag == attack.commandTag then
-- Internal commands are not requeued by normal command completion.
Spring.UnitFinishCommand(unitID)
else
-- A command can move ahead while the burst is active. Remove only
-- the generated attack in that case.
Spring.GiveOrderToUnit(unitID, CMD.REMOVE, { attack.commandTag }, 0)
end
end
end
end
end

function gadget:AllowCommand(
unitID,
unitDefID,
Expand Down Expand Up @@ -98,7 +135,13 @@
end
local dist = math_sqrt((x - param[1]) * (x - param[1]) + (z - param[3]) * (z - param[3]))
if dist <= range[ud] - param[4] then
attackList[#attackList + 1] = { unit = u, x = param[1], y = param[2], z = param[3], radius = param[4] }
attackList[#attackList + 1] = {
unit = u,
x = param[1],
y = param[2],
z = param[3],
radius = param[4],
}
else
closeList[#closeList + 1] =
{ unit = u, x = param[1], y = param[2], z = param[3], radius = range[ud] - param[4] }
Expand All @@ -108,15 +151,47 @@
return false
end

function gadget:ProjectileCreated(projectileID, ownerID, weaponDefID)
if not areaAttackWeaponDefs[weaponDefID] or activeAttacks[ownerID] then
return
end

local unitDefID = Spring.GetUnitDefID(ownerID)
if areaAttackWeaponDefByUnitDef[unitDefID] ~= weaponDefID then
return
end

local commandID, commandOptions, commandTag, _, _, targetZ = Spring.GetUnitCurrentCommand(ownerID)
if commandID ~= CMD_ATTACK or targetZ == nil or math_bit_and(commandOptions, CMD_OPT_INTERNAL) == 0 then

Check warning on line 165 in luarules/gadgets/unit_areaattack.lua

View workflow job for this annotation

GitHub Actions / emmylua_check

param-type-mismatch

expected `integer` but found `(integer|CommandOptionBit)?`.
return
end
if Spring.GetUnitCurrentCommand(ownerID, 2) ~= CMD_AREA_ATTACK_GROUND then
return
end

-- Poll weapon state after simulation instead of counting every projectile.
activeAttacks[ownerID] = {
commandTag = commandTag,
checkFrame = Spring.GetGameFrame(),
}
end

function gadget:UnitCreated(u, ud, team)
if canAreaAttack[ud] then
Spring.InsertUnitCmdDesc(u, aadesc)
end
end

function gadget:UnitDestroyed(unitID)
activeAttacks[unitID] = nil
end

function gadget:Initialize()
gadgetHandler:RegisterCMDID(CMD_AREA_ATTACK_GROUND)
gadgetHandler:RegisterAllowCommand(CMD_AREA_ATTACK_GROUND)
for weaponDefID in pairs(areaAttackWeaponDefs) do
Script.SetWatchProjectile(weaponDefID, true)
end
end
else -- UNSYNCED
function gadget:Initialize()
Expand Down
246 changes: 246 additions & 0 deletions spec/luarules/gadgets/unit_areaattack_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
---@diagnostic disable: undefined-field

local GADGET_PATH = "luarules/gadgets/unit_areaattack.lua"

local CMD_ATTACK = 20
local CMD_INSERT = 1
local CMD_REMOVE = 2
local CMD_OPT_INTERNAL = 8
local CMD_AREA_ATTACK_GROUND = 39999
local WEAPON_DEF_ID = 5
local UNIT_DEF_ID = 1
local UNIT_ID = 7

local function loadGadget(commands, weaponState)
local orders = {}
local finishedUnits = {}
local currentFrame = 1
local unitDefLookups = 0
local env = {
gadget = {},
gadgetHandler = {
IsSyncedCode = function()
return true
end,
RegisterCMDID = function() end,
RegisterAllowCommand = function() end,
},
GameCMD = { AREA_ATTACK_GROUND = CMD_AREA_ATTACK_GROUND },
CMD = {
ATTACK = CMD_ATTACK,
INSERT = CMD_INSERT,
REMOVE = CMD_REMOVE,
OPT_INTERNAL = CMD_OPT_INTERNAL,
},
CMDTYPE = { ICON_AREA = 5 },
Game = { Commands = { ReissueOrder = function() end } },
UnitDefs = {
[UNIT_DEF_ID] = {
weapons = { { weaponDef = WEAPON_DEF_ID } },
customParams = { canareaattack = true },
},
},
WeaponDefs = { [WEAPON_DEF_ID] = { range = 1000 } },
math = setmetatable({
bit_and = function(a, b)
return a % (b * 2) >= b and b or 0
end,
}, { __index = math }),
Script = { SetWatchProjectile = function() end },
Spring = {
GetGameFrame = function()
return currentFrame
end,
GetUnitCurrentCommand = function(_, index)
local command = commands[index or 1]
if command then
return command.id, command.options.coded, command.tag, unpack(command.params)
end
end,
GetUnitDefID = function()
unitDefLookups = unitDefLookups + 1
return UNIT_DEF_ID
end,
GetUnitWeaponState = function(_, _, stateName)
return weaponState[stateName]
end,
GetUnitPosition = function()
return 0, 0, 0
end,
GiveOrderToUnit = function(...)
orders[#orders + 1] = { ... }
end,
UnitFinishCommand = function(unitID)
finishedUnits[#finishedUnits + 1] = unitID
end,
InsertUnitCmdDesc = function() end,
SetUnitMoveGoal = function() end,
},
}
setmetatable(env, { __index = _G })

local chunk = assert(loadfile(GADGET_PATH))
setfenv(chunk, env)
chunk()

return env.gadget,
orders,
finishedUnits,
function(frame)
currentFrame = frame
end,
function()
return unitDefLookups
end
end

local function generatedAreaCommands()
return {
{
id = CMD_ATTACK,
tag = 43,
params = { 5, 0, 5 },
options = { coded = CMD_OPT_INTERNAL, internal = true },
},
{ id = CMD_AREA_ATTACK_GROUND, tag = 44, params = { 0, 0, 0, 10 }, options = { coded = 0 } },
}
end

describe("unit_areaattack", function()
it("changes area target after one complete salvo", function()
local commands = generatedAreaCommands()
local weaponState = { salvoLeft = 1, nextSalvo = 2 }
local gadget, _, finishedUnits, setFrame = loadGadget(commands, weaponState)

gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID)
gadget:GameFramePost(1)
assert.equals(0, #finishedUnits)

weaponState.salvoLeft = 0
setFrame(2)
gadget:GameFramePost(2)
assert.same({ UNIT_ID }, finishedUnits)
end)

it("does not process every projectile in a burst", function()
local commands = generatedAreaCommands()
local weaponState = { salvoLeft = 2, nextSalvo = 2 }
local gadget, _, _, _, getUnitDefLookups = loadGadget(commands, weaponState)

gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID)
gadget:ProjectileCreated(2, UNIT_ID, WEAPON_DEF_ID)
gadget:ProjectileCreated(3, UNIT_ID, WEAPON_DEF_ID)

assert.equals(1, getUnitDefLookups())
end)

it("finishes after a canceled final shot without another projectile", function()
local commands = generatedAreaCommands()
local weaponState = { salvoLeft = 1, nextSalvo = 2 }
local gadget, _, finishedUnits = loadGadget(commands, weaponState)

gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID)
gadget:GameFramePost(1)
weaponState.salvoLeft = 0
gadget:GameFramePost(2)

assert.same({ UNIT_ID }, finishedUnits)
end)

it("follows a delayed next-salvo frame", function()
local commands = generatedAreaCommands()
local weaponState = { salvoLeft = 1, nextSalvo = 2 }
local gadget, _, finishedUnits = loadGadget(commands, weaponState)

gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID)
gadget:GameFramePost(1)
weaponState.nextSalvo = 4
gadget:GameFramePost(2)
weaponState.salvoLeft = 0
gadget:GameFramePost(3)
assert.equals(0, #finishedUnits)
gadget:GameFramePost(4)

assert.same({ UNIT_ID }, finishedUnits)
end)

it("ignores unrelated weapon projectiles", function()
local commands = generatedAreaCommands()
local gadget, orders, finishedUnits = loadGadget(commands, {})

gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID + 1)
gadget:GameFramePost(1)

assert.equals(0, #finishedUnits)
assert.equals(0, #orders)
end)

it("ignores player-issued ground attacks", function()
local commands = generatedAreaCommands()
commands[1].options = { coded = 0, internal = false }
local weaponState = { salvoLeft = 0, nextSalvo = 1 }
local gadget, orders, finishedUnits = loadGadget(commands, weaponState)

gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID)
gadget:GameFramePost(1)

assert.equals(0, #finishedUnits)
assert.equals(0, #orders)
end)

it("keeps a generated attack persistent without its area command", function()
local commands = { generatedAreaCommands()[1] }
local weaponState = { salvoLeft = 0, nextSalvo = 1 }
local gadget, orders, finishedUnits = loadGadget(commands, weaponState)

gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID)
gadget:GameFramePost(1)

assert.equals(0, #finishedUnits)
assert.equals(0, #orders)
end)

for _, nextCommandID in ipairs({ CMD_ATTACK, 10 }) do
it("ignores an internal ground attack followed by command " .. nextCommandID, function()
local commands = generatedAreaCommands()
table.insert(commands, 2, {
id = nextCommandID,
tag = 45,
params = { 100, 0, 200 },
options = { coded = 0 },
})
local gadget, orders, finishedUnits = loadGadget(commands, { salvoLeft = 0, nextSalvo = 1 })

gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID)
gadget:GameFramePost(1)

assert.equals(0, #finishedUnits)
assert.equals(0, #orders)
end)
end

it("marks generated area shots internal so repeat does not retain them", function()
local commands = {}
local gadget, orders = loadGadget(commands, {})

gadget:CommandFallback(UNIT_ID, UNIT_DEF_ID, 0, CMD_AREA_ATTACK_GROUND, { 0, 0, 0, 10 }, {})
gadget:GameFrame(1)

assert.equals(CMD_INSERT, orders[1][2])
assert.equals(CMD_OPT_INTERNAL, orders[1][3][3])
end)

it("removes only the generated shot if another command moves ahead of it", function()
local commands = generatedAreaCommands()
local generatedAttack = commands[1]
local weaponState = { salvoLeft = 0, nextSalvo = 1 }
local gadget, orders, finishedUnits = loadGadget(commands, weaponState)

gadget:ProjectileCreated(1, UNIT_ID, WEAPON_DEF_ID)
commands[1] = { id = 10, tag = 99, params = { 0, 0, 0 }, options = { coded = 0 } }
gadget:GameFramePost(1)

assert.equals(0, #finishedUnits)
assert.same({ UNIT_ID, CMD_REMOVE, { generatedAttack.tag }, 0 }, orders[1])
end)
end)
Loading