Classic Tektite Random Jumping AI

Started by Bagu, June 09, 2018, 08:21:21 PM

Previous topic - Next topic
June 09, 2018, 08:21:21 PM Last Edit: June 10, 2018, 06:21:41 PM by Bagu
I always liked the random jumping of tektites in Zelda 1 and ALTTP, and was a little disappointed by the tektites in Return of the Hylian which simply walked at the player. Thus once I figured out how to set up random jumping for some other enemies I figured I'd share this extremely basic and simple example of Nintendo tektite behavior.

This also would work for replicating Bits/Bots from Zelda 2 or Zols and Gels from Zelda 1.

example video: https://youtu.be/HGkoJe9JqEc

Code ( lua) Select
local enemy = ...

local choices = {"pause","jump","jump","jump"}
-- these are functions the enemy will choose from randomly. I added extra copies of the jump function so he would move 75% of the time instead of 50/50. However, you could create more functions and place each one in the list only once.

function enemy:on_created()
  enemy:set_life(3)
  enemy:set_damage(2)
  enemy:create_sprite("enemies/" .. enemy:get_breed())
end

function enemy:on_restarted()
    enemy[ choices[math.random( 1, #choices )] ](enemy) --this line calls a random function from the "choices" table at the top
end

function enemy:pause()
  local sprite = enemy:get_sprite()
  --sprite:set_animation("idle")
-- uncomment the line above if you want to use a custom pause animation, otherwise it'll use "walking"
  enemy:stop_movement()
  sol.timer.start(enemy, math.random(1, 2000), function() --the timer lasts randomly between 1 and 2000 ms. You can change the minimum and maximum to taste.
    enemy[ choices[math.random( 1, #choices )] ](enemy) --again picks a random function from the choices table, you could also just call enemy:restart
  end)
end

function enemy:jump()
  local sprite = enemy:get_sprite()
  enemy:stop_movement()
  --sprite:set_animation("jumping")
-- uncomment the line above if you want to use a custom jump animation, otherwise it'll keep using "walking"
  local movement = sol.movement.create("jump")
  movement:set_direction8(math.random(0, 7)) -- picks a random direction to jump in
  movement:set_distance(math.random(24, 64)) -- sets a distance between 24 and 64px
  movement:start(enemy)
  sol.timer.start(enemy, 800, function()  -- 800ms is the typical time for the enemy to land from a 64px jump.
    enemy[ choices[math.random( 1, #choices )] ](enemy)
  end)


I probably should have used movement:on_finished() instead of a timer to close the jump function, because if you use distances greater than 64 the jump begins to get cut-off early and it looked bad.

Art-wise, if you use something like Return of the Hylian's tektite sprite, it'll look a little weird because there's a shadow drawn into the animations. Thus the shadow floats through the air with the character as he jumps, instead of staying on the ground. The easiest solution is to simply erase any shadows from your animation frames, though this will make it visually ambiguous whether the character is jumping through the air or sliding across the ground. For the best results, you'd want to use a custom entity or something to place a separate shadow sprite under the character.

Special thanks to llamazing for helping me sort out the random function selection call!

Pretty cool! If you figure out how to display a shadow when something is jumping, let me know- I've tried to avoid using the jump movement because it's so hard to understand what's happening when the engine doesn't draw a shadow.

Question though- your random function thing is elegant, and I'm going to try to understand it because I don't quote yet. But my question is, would something like this work?:

Code (lua) Select

function enemy:on_restarted()
local action = math.random(1, 4)
if action >2 then enemy:pause() else enemy:jump()
end



This seems simpler to me, but perhaps it's because I'm still at a pretty basic level of understanding tables and stuff in lua. Although, I guess yours is a more adaptable system, so if you were, for example, programming a game's boss and he had ten different attacks, you wouldn't need to do an "if/then" 10 times.

I'm not very good at lua yet either!  I tried to replicate several different methods of randomization I googled and failed utterly at anything beyond printing strings until llamazing fixed my code. However, if you're interested in investigating, here's a couple examples:

https://wiki.garrysmod.com/page/table/Random
https://forums.coronalabs.com/topic/39315-how-to-iteratepick-randomly-from-associative-arrays/


The main reason I used the weighting method I did is because I lifted it from other scripts of mine with many unique functions. I like starting with this one because if I decide to add more functions I can just replace some of the duplicates in the list.

Your suggested method looks like it would work, and could be an effective basic means of weighting things. When I tried to research various methods of weighting random selections in lua, most were extremely complex (here's a good example) so I gave up and stuck to what I had working.

June 10, 2018, 03:25:22 AM #3 Last Edit: June 11, 2018, 07:25:11 AM by llamazing
I didn't understand what you were trying to do when I fixed your code. Now that I see your full code, something like this would be better:
Code (lua) Select
    local enemy = ...
     
    local choices = {
      pause = 25,
      jump = 75,
    }
    -- these are functions the enemy will choose from randomly proportional to the value associated with each one
   
    local random_max = 0 --sum of all choice values, calculate once when script is first run (in this example, radom_max = 25 + 75 = 100)
    for _,weight in pairs(choices) do random_max = random_max + weight end
   
    --this function could be moved to a different script for common utility functions
    local function random_choice()
    local choice_value = math.random(1, random_max) --choose random number between 1 and random_max
   
    --step through list of choices to find which choice correspondes to the randomly chosen value
    local cumulative = 0 --sum of current choice entry plus all previous
    for choice,weight in pairs(choices) do
    cumulative = cumulative + weight
    if choice_value <= cumulative then
    return choice --current entry corresponds to the randomly chosen value
    end
    end
   
    --should have returned a value before getting to this line, as contingency returns some choice (undefined which one)
    --you may prefer to throw an error here instead
    local choice = next(choices)
    return choice
    end
   
    function enemy:random_action()
    self[random_choice()](self) --this line calls a random function from the "choices" table at the top
    end
   
    function enemy:on_restarted()
    self:random_action()
    end

I didn't include the full script here. You'll want to replace lines 22 & 36 of your original script with self:random_action() like in the enemy:on_restarted() function.

This is basically equivalent to the sample code posted by Max (yes your code works) except it allows the user to edit the values in the choices table (or add new entries) without having to change the rest of the script. Also note that the sum of the values in the choices table don't need to be 100 either. Giving pause a value of 1 and jump a value of 3 would be equivalent.

Though the values do need to be integers. It would be easy enough to alter it to work with decimal values too if that's what you wanted.

EDIT: code fixed to add the missing parentheses mentioned below

llamazing, your version looks awesome, but when I try it, self[random_choice](self) is calling a nil value. Maybe I did something wrong, but I didn't think I could mess much up with what little I added to it:

Code ( lua) Select
local enemy = ...

function enemy:on_created()
  enemy:set_life(3)
  enemy:set_damage(2)
  enemy:create_sprite("enemies/" .. enemy:get_breed())
end

local choices = {
  pause = 25,
  jump = 75,
}

-- these are functions the enemy will choose from randomly proportional to the value associated with each one
   
local random_max = 0 --sum of all choice values, calculate once when script is first run (in this example, radom_max = 25 + 75 = 100)
for _,weight in pairs(choices) do random_max = random_max + weight end
   
--this function could be moved to a different script for common utility functions
local function random_choice()
local choice_value = math.random(1, random_max) --choose random number between 1 and random_max
       
--step through list of choices to find which choice correspondes to the randomly chosen value
local cumulative = 0 --sum of current choice entry plus all previous
for choice,weight in pairs(choices) do
  cumulative = cumulative + weight
  if choice_value <= cumulative then
    return choice --current entry corresponds to the randomly chosen value
   end
end
       
--should have returned a value before getting to this line, as contingency returns some choice (undefined which one)
--you may prefer to throw an error here instead
local choice = next(choices)
        return choice
end
   
function enemy:random_action()
  self[random_choice](self) --this line calls a random function from the "choices" table at the top
end
   
function enemy:on_restarted()
  self:random_action()
end

function enemy:pause()
  local sprite = enemy:get_sprite()
  --sprite:set_animation("idle")
  enemy:stop_movement()
  sol.timer.start(enemy, math.random(1, 2000), function()
    self:random_action()
  end)
end

function enemy:jump()
  local sprite = enemy:get_sprite()
  enemy:stop_movement()
  --sprite:set_animation("jumping")
  local movement = sol.movement.create("jump")
  movement:set_direction8(math.random(0, 7))
  movement:set_distance(math.random(24, 64))
  movement:start(enemy)
  sol.timer.start(enemy, 800, function()
    self:random_action()
  end)
end

Oops, I forgot a set of parentheses.

Change self[random_choice](self) to self[random_choice()](self) and it should work.

Yes, it works perfectly now, thanks! I also sorted out movement:on_finished for closing the jump function.

So what's the final, working version of this script look like?
Ganon: I can't afford to get cancelled on social media Link!