Smarter monster lua

Discuss and unveil current Marathon projects.
Post Reply
User avatar
ravenshining
Vidmaster
Posts: 892
Joined: Jun 17th '17, 22:50
Location: Hawai'i

Inspired by the current M2 commentary, I decided to look into whether I could make monsters act any "smarter" in certain circumstances using only Lua. This won't erase some of their stupid behaviors that people enjoy exploiting, but it will sometimes enable them to take corrective action.

So far, this script does one of two things:

If a monster at less than 25% health hits a friendly monster with its attack, the script will search for roughly the nearest live enemy and have the monster attack it.

Otherwise, if any monster's attack hits either a friendly monster or itself, the script will politely ask the monster to move in a preferably forward direction.

With this script, I've seen snipers jump down if they're hitting their own soldiers, berserk cyborgs respond to friendly fire but once before turning back to the player, BoBs get off their asses to move up and take the place of a fallen comrade they've mistakenly killed, and troopers reposition themselves when grenading their own feet. None of this happens reliably, but having it happen occasionally gives the monsters a little extra bit of spice and challenge!

I'd certainly welcome any input as to other things monsters might possibly be scripted to do, or point out any potential problems with this code before I release it as a plugin. Sadly, further ideas I've had rely on being able to find a monsters target, which as far as I can tell is only possible if they naturally fire a guided projectile.

Wrkncacnter gave me the two angle functions at the end on discord, he was not sure as to where he found them.
Also, at are two important points there are a trio of commented-out lines that if un-commented will print debug output to the screen. The script:

Code: Select all

Triggers = {}

function Triggers.monster_damaged(monster, aggressor_monster, damage_type, damage_amount, projectile)
  if not monster._zerkvit then
    monster._zerkvit = ( monster.vitality + damage_amount ) / 4
  end
  if aggressor_monster and not aggressor_monster.player and aggressor_monster.valid then
    if not aggressor_monster._zerkvit then
      aggressor_monster._zerkvit = ( aggressor_monster.vitality + damage_amount ) / 4
    end
    if aggressor_monster._zerkvit >= aggressor_monster.vitality and aggressor_monster ~= monster and aggressor_monster.type.friends[monster.type.class] then
      local delta = 524288
-- find nearest enemy target to attack
      for m in Monsters() do
        if m.valid and m.visible then
          if aggressor_monster.type.enemies[m.type.class] and m.life > 0 then
            local maybe = math.abs(math.abs(aggressor_monster.facing - angle_between_points(m,aggressor_monster))-180) + math.abs(m.x - aggressor_monster.x) + math.abs(m.y - aggressor_monster.y) + math.abs(m.z - aggressor_monster.z) * 7
            if maybe < delta then
              badzerktarget = m
              delta = maybe
            end
          end
        end
      end
    end
    if badzerktarget then
      aggressor_monster:attack(badzerktarget)
--      local bz = "berserking "
--      local on = " on "
--      Players.print(bz..tostring(aggressor_monster)..on..tostring(badzerktarget))
      badzerktarget = nil
    else
      if aggressor_monster == monster or aggressor_monster.type.friends[monster.type.class] then
        local dest = Players[0].monster.polygon
        local delta = 512
-- find anything to run towards, preferably but not necessarily forewards
        for m in Monsters() do
          local maybe = math.abs(math.abs(aggressor_monster.facing - angle_between_points(m,aggressor_monster))-180)
          if maybe < delta then
            dest = m.polygon
            delta = maybe
          end
        end
        aggressor_monster:move_by_path(dest)
--	local mv = "moving "
--	local to = " to "
--	Players.print(mv..tostring(aggressor_monster)..to..tostring(dest))
      end
    end
  end
end

function angleOfPoint(pt)
    local x, y = pt.x, pt.y
    local radian = math.atan2(y, x)
    local angle = radian * 180 / math.pi
    if angle < 0 then
        angle = 360 + angle
    end
    return angle
end

-- returns the degrees between two points (note: 0 degrees is 'east')
function angle_between_points(a, b)
    local x, y = b.x - a.x, b.y - a.y
    return angleOfPoint({x = x, y = y})
end
I have very, very occasionally crashed A1 with a "monster is unused" error when running this script, for example:

Code: Select all

vhalt: monsters.cpp:321: monster index #39 (0x22f9770) is unused (csalerts_sdl.cpp:273)
FATAL: monsters.cpp:321: monster index #39 (0x22f9770) is unused
Aborted
$lave

This is a super cool idea :0 I'll have to give it a try when I have a second.
User avatar
Wrkncacnter
Vidmaster
Posts: 1953
Joined: Jan 29th '06, 03:51
Contact:

ravenshining wrote: Wrkncacnter gave me the two angle functions at the end on discord, he was not sure as to where he found them.
Actually, I know where I found them, the link is just dead now. This comment is what I have in my PiD code:

Code: Select all

---- The following functions taken from https://gist.github.com/ignisdesign/4590736
Edit: Looks like this is the new version https://gist.github.com/kirubz/fa843750 ... 8e0ae3aed8.

Looks like he added a new function that would help you, since your logic to find angle deltas won't work on the 360/0 boundary.
User avatar
ravenshining
Vidmaster
Posts: 892
Joined: Jun 17th '17, 22:50
Location: Hawai'i

Wrkncacnter wrote:Edit: Looks like this is the new version https://gist.github.com/kirubz/fa843750 ... 8e0ae3aed8.

Looks like he added a new function that would help you, since your logic to find angle deltas won't work on the 360/0 boundary.
Thanks! I'll have to reach out to them.

I rewrote the angle delta line since our conversation because of that reason like so - note there are now two absolute value functions:

local maybe = math.abs(math.abs(aggressor_monster.facing - angle_between_points(m,aggressor_monster))-180)

The arithmetic worked in a spreadsheet, at any rate.

Another behaviour for another weekend would be to make monsters run from exploding monsters. I think I can manage that.
User avatar
ravenshining
Vidmaster
Posts: 892
Joined: Jun 17th '17, 22:50
Location: Hawai'i

I updated this script a bit since I'm now including it with M1R. Before, it would fail to set berserk monsters on the player, because the player's monster is not vaild and does not have health. Now berserk monsters will attack the player as advertised.

Also, when seeking a new place to run to, the script loops through all polygons and considers distance and elevation, rather than looping through all monsters, which should improve the repositioning behaviour.

For M1R, I set this to only activate on "normal" or higher difficulty, that bit has been commented out here.

Code: Select all

Triggers = {}

function Triggers.monster_damaged(monster, aggressor_monster, damage_type, damage_amount, projectile)
--  if Game.difficulty ~= "kindergarten" and Game.difficulty ~= "easy" then
    smartmonster(monster, aggressor_monster, damage_amount)
--  end
end

function smartmonster(monster, aggressor_monster, damage_amount)
  if not monster._zerkvit then
    monster._zerkvit = ( monster.vitality + damage_amount ) / 4
  end
  if aggressor_monster and not aggressor_monster.player and aggressor_monster.valid then
    if not aggressor_monster._zerkvit then
      aggressor_monster._zerkvit = ( aggressor_monster.vitality + damage_amount ) / 4
    end
    local badzerktarget = nil
    if aggressor_monster._zerkvit >= aggressor_monster.vitality and aggressor_monster ~= monster and aggressor_monster.type.friends[monster.type.class] then
      local delta = 524288
-- find nearest enemy target to attack
      for m in Monsters() do
        if m.visible and aggressor_monster.type.enemies[m.type.class] then
          if m.life > 0 or m.player.life > 0 then
            local maybe = math.abs(math.abs(aggressor_monster.facing - angle_between_points(m,aggressor_monster))-180) + math.abs(m.x - aggressor_monster.x) + math.abs(m.y - aggressor_monster.y) + math.abs(m.z - aggressor_monster.z) * 7
            if maybe < delta then
              badzerktarget = m
              delta = maybe
            end
          end
        end
      end
    end
    if badzerktarget ~= nil then
      aggressor_monster:attack(badzerktarget)
--      local bz = "berserking "
--      local on = " on "
--      Players.print(bz..tostring(aggressor_monster)..on..tostring(badzerktarget))
      badzerktarget = nil
    elseif aggressor_monster == monster or aggressor_monster.type.friends[monster.type.class] then
      local dest = Players[0].monster.polygon
      local delta = 512
-- find anything to run towards, preferably but not necessarily forewards
      for p in Polygons() do
        local maybe = math.abs(math.abs(aggressor_monster.facing - angle_between_points(p,aggressor_monster))-180) + math.abs(p.x - aggressor_monster.x) + math.abs(p.y - aggressor_monster.y) + math.abs(p.z - aggressor_monster.z) * 7
        if maybe < delta then
          dest = p
          delta = maybe
        end
      end
      aggressor_monster:move_by_path(dest)
--      local mv = "moving "
--      local to = " to "
--      Players.print(mv..tostring(aggressor_monster)..to..tostring(dest))
    end
  end
end

function angleOfPoint(pt)
    local x, y = pt.x, pt.y
    local radian = math.atan2(y, x)
    local angle = radian * 180 / math.pi
    if angle < 0 then
        angle = 360 + angle
    end
    return angle
end

-- returns the degrees between two points (note: 0 degrees is 'east')
function angle_between_points(a, b)
    local x, y = b.x - a.x, b.y - a.y
    return angleOfPoint({x = x, y = y})
end
User avatar
ravenshining
Vidmaster
Posts: 892
Joined: Jun 17th '17, 22:50
Location: Hawai'i

I wanted to add some ballistic calculations - so now, troopers and F'lickta (Juggernaut missiles are included in the calculations but seldom require correction) will aim up, not down, so their grenades and globs fly in a neat arc towards the centre of their target instead of the floor several metres in front of the target. They can even clear some mild obstructions, pelting you from ledges unseen.

Whether and how deep a target is immersed in media is taken into account. If a target is standing in media they aim for either the midpoint between the media surface and the top of their target, or if fully immersed, at the media surface directly above the target,

Difficulty level and low-gravity is also considered, the latter a bit crudely but how often do you see F'lickta in space anyway?

If the script determines a target is too far away to hit with a falling projectile, the monster throws at a random angle between normal and 45°, and is prompted to move towards their target.

Finally, I connected a projectile's starting vertical velocity to its monster's vertical velocity, not for aim but for physics. This effect should be roughly 1:1 for monsters, but I cut it back to about 2:1 for players since 1:1 was rather disconcerting.

Unfortunately, it doesn't look like there's a way to get projectile type properties, so I had to hand-enter values. This version of the script is compatible with M2 and M∞, to use with another scenario you may have to add/delete entries but I've placed comments and whitespace where those points are.
ai.lua.zip
(2.01 KiB) Downloaded 213 times
Spoiler:

Code: Select all

Triggers = {}

--Smarter Monster Lua 3.0 by Liacrow
--This version depends on standard Infinity/Durandal physics
--For use with other scenarios, see comments in Triggers.projectile_created and function ballisticangle

gdsx = 1
gddx = 1
grav = 1024

function Triggers.init()
  Game.proper_item_accounting = true
  if Game.difficulty == "kindergarten" then
    gdsx = 0.875
    gddx = 0.5
  elseif Game.difficulty == "easy" then
    gdsx = 0.9375
    gddx = 0.75
  elseif Game.difficulty == "normal" then
    gdsx = 1
    gddx = 1
  elseif Game.difficulty == "major damage" then
    gdsx = 1.125
    gddx = 1
  elseif Game.difficulty == "total carnage" then
    gdsx = 1.25
    gddx = 1
  end
end

function Triggers.monster_damaged(monster, aggressor_monster, damage_type, damage_amount, projectile)
--  if Game.difficulty ~= "kindergarten" and Game.difficulty ~= "easy" then
    if not monster._zerkvit then
      monster._zerkvit = ( monster.vitality + damage_amount ) / 4
    end
    if aggressor_monster and not aggressor_monster.player and aggressor_monster.valid then
      smartmonster(monster, aggressor_monster, damage_amount)
    end
--  end
end

function Triggers.projectile_created(projectile)
  if projectile.owner then
    if projectile.owner.player then
      projectile.dz = projectile.owner.player.external_velocity.z / 128
    elseif projectile.owner.valid then
      projectile.dz = projectile.owner.vertical_velocity / 64

--add or subtract entries to the following line depending on your physics model:
      if projectile.type == "grenade" and Level.low_gravity ~= true or projectile.type == "trooper grenade" and Level.low_gravity ~= true or projectile.type == "juggernaut missile" or projectile.type == "sewage yeti" then

        if not projectile.target then
          findprojtarget(projectile)
        end
        ballisticangle(projectile)
      end
    end
  end
end

function smartmonster(monster, aggressor_monster, damage_amount)
  if not aggressor_monster._zerkvit then
    aggressor_monster._zerkvit = ( aggressor_monster.vitality + damage_amount ) / 4
  end
  local badzerktarget = nil
  if aggressor_monster._zerkvit >= aggressor_monster.vitality and aggressor_monster ~= monster and aggressor_monster.type.friends[monster.type.class] then
    local delta = 524287
-- find nearest enemy target to attack
    for m in Monsters() do
      if m.visible and aggressor_monster.type.enemies[m.type.class] then
        if m.player then
          if m.player.life > 0 then
            local maybe = math.abs(math.abs(aggressor_monster.facing - angle_between_points(m,aggressor_monster))-180) + math.abs(m.x - aggressor_monster.x) + math.abs(m.y - aggressor_monster.y) + math.abs(m.z - aggressor_monster.z) * 7
            if maybe < delta then
              badzerktarget = m
              delta = maybe
            end
          end
        elseif m.life then
          if m.life > 0 then
            local maybe = math.abs(math.abs(aggressor_monster.facing - angle_between_points(m,aggressor_monster))-180) + math.abs(m.x - aggressor_monster.x) + math.abs(m.y - aggressor_monster.y) + math.abs(m.z - aggressor_monster.z) * 7
            if maybe < delta then
              badzerktarget = m
              delta = maybe
            end
          end
        end
      end
    end
  end
  if badzerktarget ~= nil then
    aggressor_monster:attack(badzerktarget)
--    Players.print("berserking "..tostring(aggressor_monster).." on "..tostring(badzerktarget))
    badzerktarget = nil
  elseif aggressor_monster == monster or aggressor_monster.type.friends[monster.type.class] then
    local dest = Players[0].monster.polygon
    local delta = 512
-- find anything to run towards, preferably but not necessarily forewards
    for p in Polygons() do
      local maybe = math.abs(math.abs(aggressor_monster.facing - angle_between_points(p,aggressor_monster))-180) + math.abs(p.x - aggressor_monster.x) + math.abs(p.y - aggressor_monster.y) + math.abs(p.z - aggressor_monster.z) * 7
      if maybe < delta then
        dest = p
        delta = maybe
      end
    end
    aggressor_monster:move_by_path(dest)
--    Players.print("moving "..tostring(aggressor_monster).." to "..tostring(dest))
  end
end

function findprojtarget(projectile)
  local delta = 524287
  for m in Monsters() do
    if m.valid or m.player then
      if m.visible or m.player then
        local maybe = math.abs(math.abs(projectile.owner.facing-angle_between_points(m,projectile.owner))-180) + 
                      math.abs(math.abs(projectile.pitch - 
                                       (math.atan2(m.z-projectile.owner.z , 
                                                  ((projectile.owner.x - m.x)^2+(projectile.owner.y-m.y)^2)^0.5)*180/math.pi)
                                       )-180)
        if maybe < delta then
          projectile.target = m
          delta = maybe
        end
      end
    end
  end
end

function ballisticangle(projectile)
  local range = ((projectile.x - projectile.target.x)^2 + (projectile.y - projectile.target.y)^2 )^0.5
  local reach = projectile.target.z + projectile.target.type.height/2 - projectile.z
  if projectile.target.polygon.media then
    if projectile.target.z + projectile.target.type.height < projectile.target.polygon.media.height then
      reach = projectile.target.polygon.media.height - projectile.z
    elseif projectile.target.z < projectile.target.polygon.media.height and projectile.target.z + projectile.target.type.height > projectile.target.polygon.media.height then
    reach = projectile.target.polygon.media.height + (projectile.target.z + projectile.target.type.height - projectile.target.polygon.media.height) / 2 - projectile.z
    end
  end
  local pv = 1024

--add or subtract lines to the following section depending on your physics model:
  if projectile.type == "grenade" then
    pv = 256
  elseif projectile.type == "trooper grenade" or projectile.type == "juggernaut missile" then
    pv = 204
  elseif projectile.type == "sewage yeti" then
    pv = 128 * gdsx
  end

  if Level.low_gravity == true then
    pv = pv * 2
  end
--here's the FUN math:
  local vggxyv = pv^4-grav*(grav*range^2+2*reach*pv^2)
  local ov = projectile.pitch
  if range ~= 0 and vggxyv > 0 then
    projectile.pitch = math.max(projectile.pitch,math.atan((pv^2-vggxyv^0.5)/(grav*range)) * 180/math.pi)
--if a trajectory cannot be calculated, aim high and move closer
  else
    projectile.pitch = math.min(45,projectile.pitch+Game.global_random(45))
    projectile.owner:move_by_path(projectile.target.polygon)
  end
--  if projectile.pitch > ov then
--    Players.print("Range: "..range..", Reach: "..reach..", Speed: "..pv..", Original pitch: "..ov..", New pitch: "..projectile.pitch)
--  end
end

function angleOfPoint(pt)
    local x, y = pt.x, pt.y
    local radian = math.atan2(y, x)
    local angle = radian * 180 / math.pi
    if angle < 0 then
        angle = 360 + angle
    end
    return angle
end

-- returns the degrees between two points (note: 0 degrees is 'east')
function angle_between_points(a, b)
    local x, y = b.x - a.x, b.y - a.y
    return angleOfPoint({x = x, y = y})
end
User avatar
HelviusRufus
Cyborg
Posts: 257
Joined: Apr 15th '15, 03:37

Cool Fusion: Some time ago there was a trooper in the first room so that the scratch player could get a grenade launcher to activate the switches. I thought this was a good idea.
Reading your post about ballistics makes me think that if possible the fists only aficionado might like to maneuver in such a way as to make the trooper activate the switch before the player offs 'im.
I just play 'em; I don't know how they work.
User avatar
ravenshining
Vidmaster
Posts: 892
Joined: Jun 17th '17, 22:50
Location: Hawai'i

That scenario has two difficulties: 1, in order to clear the door, the trooper will have to aim for the top of your head, not your midsection- nevertheless, your midsection is still higher than your feet; 2, the blue trooper's grenades were guided!
User avatar
HelviusRufus
Cyborg
Posts: 257
Joined: Apr 15th '15, 03:37

Bummer.
I just play 'em; I don't know how they work.
User avatar
ravenshining
Vidmaster
Posts: 892
Joined: Jun 17th '17, 22:50
Location: Hawai'i

Decided to add in not just smarter monsters, but smarter projectiles! Unfortunately, because Lua doesn't grab much projectile data, this script will have to be reconfigured to work with scenarios other than M2 and Infinity. I've tried to place the relevant bits close to the top and to make them intuitive to adjust.

Now it would be reasonable to consider these changes to be going "too far" in changing gameplay. For this reason and due to the fact that projectile data must be configured per-scenario, in the next revision I"ll add some variables to make turning on and off different parts of the script easy.

• Guided munitions (and their relatives) have a terrain-following capability; they will attempt to avoid impact with the ground or media, unless it is an explosive and hostile monsters (or players) are detected within their blast radius. It only works at a shallow angle, is unreliable over media, and doesn't work up most stairs - all by design.

This makes Juggernaut missiles more dangerous, and makes hiding under media from Drones and Compilers much more risky. While the rules apply to the player, one can still kill themselves by pointing rockets at the ground in front of them, and still kill people by firing rockets at the ground near an opponent - it only applies in situations where one would have missed.

• Physical guided munitions (juggernaut and spnkr missiles) can lose their target if they pass them.

• Guided munitions that have lost or don't have a target will attempt to acquire a new one. For this to work a potential target must be within a certain cone in front of the projectile, be in the same polygon(!), and not be friends with the projectile's owner.

This means that player-generated missiles can lock on to monsters and other players if they get close enough and are not too far off-angle, but the same polygon limitation renders this highly unreliable in simply constructed areas to impossible in highly complex environments. I've also set this to not work for the player at all in magnetic environments.
Attachments
ai.lua.zip
(3.4 KiB) Downloaded 214 times
Post Reply