Search Unity

Best design for combining event-driven and state-machine AI scripting?

Discussion in 'Scripting' started by JoeStrout, Jan 15, 2018.

  1. JoeStrout

    JoeStrout

    Joined:
    Jan 14, 2011
    Posts:
    8,140
    Here's a scripting question that's almost a Game Design question... but it's not quite, so I'm putting it here.

    I'm working on a game where NPC behaviors can be scripted (via MiniScript). This scripting may often be done by new/amateur coders, so I want it to be as easy as possible. Currently I have the scripts structured as a nice little state machine where each state is essentially a function that gets invoked repeatedly by a main loop, until it makes a call to enter a different state. This works really well for typical idle/fleeing/pursuing type mob behavior.

    Of some importance is the fact that MiniScript is naturally threaded, so these state-handling functions will frequently include a wait call that essentially pauses the script for some number of seconds.

    Now we want to make our NPCs react to events, such as taking damage. In a completely separate project, I have a neat design for an event-driven script. The Unity C# code can toss events onto a queue, and a main loop in the script pulls these off and invokes the appropriate user-written function. This works really well for things like UI elements, responding to mouse overs and clicks etc.

    But how do I structure a script so as to neatly combine both state-machine and event-driven code?

    This is my question. MiniScript doesn't easily support "interrupts," and I don't think interrupt-driven programming is very easy to maintain anyway (when your function can get yanked out from under you at any point, it's a recipe for hard-to-reproduce bugs). So, how/where does the user put code to react to events while also doing whatever processing each state does?

    I guess what I'm really trying to avoid are big if/elseif trees that check for different states (or events). Currently, neither design has this — there's a specific function for each state, and in the event-driven code, a specific function for each event. But how to combine them?

    One idea is to insert a simple "check events" call into the main event loop, which would pull the next event (if any) off the queue, and invoke some user-written code to do something about it (such as entering a different state). This is a reasonable approach if events should usually be handled the same regardless of what state you're in, e.g., when being attacked you always switch to the aggressive state, regardless of what you were doing before.

    Another idea is to let the individual states check the event queue themselves. This works better if you frequently need to do something different with the same event in different states. And I suppose we could have it both ways — let the state-processing methods peek at the event queue and either handle the next event, or return and let the main loop do it.

    Maybe I need to just dig in and experiment, but I thought I'd throw this out here in case somebody has dealt with this issue and says "oh yeah, totally been there, here's what worked well for us."

    Thanks,
    - Joe
     
  2. lordofduct

    lordofduct

    Joined:
    Oct 3, 2011
    Posts:
    6,660
    Could we see an example of how this looks setup?
     
  3. Suddoha

    Suddoha

    Joined:
    Nov 9, 2013
    Posts:
    2,491
    Difficult to imagine that. A visualization (a flowchart or something) or even some pseudo-code would help alot.
     
  4. JoeStrout

    JoeStrout

    Joined:
    Jan 14, 2011
    Posts:
    8,140
    Hmm, well if it's helpful, here's my current stab at it — it's mostly the state machine code, with some event-handling code bolted on.

    Code (MiniScript):
    1. // Some miscellaneous utility functions
    2. randInRange = function(min, max)
    3.     return min + rnd * (max - min)
    4. end function
    5.  
    6. funcName = {}
    7.  
    8. NPC = {}
    9. // How far the NPC can see
    10. NPC.sightDistance = 10
    11. NPC.sightAngle = 60
    12. // How aggressive it is: 0=fleeing, 20=attack on sight
    13. NPC.aggressiveness = 10
    14. // Health when undamaged
    15. NPC.maxHitPoints = 20
    16. // Current health
    17. NPC.curHitPoints = NPC.maxHitPoints
    18. // Speed (doesn't actually do anything currently)
    19. NPC.speed = 1
    20.  
    21. // Data/code related to our state machine
    22. NPC.processState = null
    23. NPC.stateNames = {}
    24. NPC.stateStartTime = 0
    25. NPC.timeInState = function()
    26.     return time - self.stateStartTime
    27. end function
    28. NPC.enterState = function(funcPtr)
    29.     self.processState = @funcPtr
    30.     self.stateStartTime = time
    31.     print("entered " + funcName[@funcPtr] + " at time " + self.stateStartTime)
    32. end function
    33.  
    34. NPC.canSeePlayer = function()
    35.     return canSee(player, self.sightAngle, self.sightDistance)
    36. end function
    37.  
    38. //------------------------------------------------------------
    39. // Idle state
    40. NPC.idleState = function(event)
    41.     // Turn aimlessly, and keep an eye out for the player.
    42.     stop
    43.     turn(120*rnd - 60)
    44.     wait(0.5)
    45.     if self.canSeePlayer then
    46.         // react based on our aggressiveness
    47.         if self.aggressiveness < 6 then
    48.             self.enterFlee
    49.         else if self.aggressiveness > 15 then
    50.             self.enterAttack
    51.         end if
    52.     end if
    53. end function
    54. funcName[@NPC.idleState] = "idle"
    55. NPC.enterIdle = function();    self.enterState(@self.idleState); end function
    56.  
    57. //------------------------------------------------------------
    58. // Flee state
    59. NPC.fleeState = function(event)
    60.     // Turn away from the player and run.  Repeat until we
    61.     // can no longer see the player.
    62.     turnTo(direction(player) + 180 + randInRange(-30,30))
    63.     run
    64.     wait(0.5)
    65.     stop
    66.     if not canSee(player, 360, self.sightDistance) then
    67.         self.enterIdle
    68.     end if  
    69. end function
    70. funcName[@NPC.fleeState] = "flee"
    71. NPC.enterFlee = function();    self.enterState(@self.fleeState); end function
    72.  
    73. //------------------------------------------------------------
    74. // Attack state
    75. NPC.attackState = function(event)
    76.     // Attack the player
    77.     if not self.canSeePlayer then
    78.         self.enterSeek
    79.     else
    80.         turnTo(player)
    81.         run
    82.         wait
    83.         stop
    84.         if distance(player, me) < 2 then; attack; end if
    85.     end if
    86. end function
    87. funcName[@NPC.attackState] = "attack"
    88. NPC.enterAttack = function(); self.enterState(@self.attackState); end function
    89.  
    90. //------------------------------------------------------------
    91. // Seek state -- turn around looking for the player to attack
    92. NPC.seekState = function(event)
    93.     turn(10)
    94.     wait(0.1)
    95.     if self.canSeePlayer then; self.enterAttack; end if
    96.     if self.timeInState > 30 then
    97.         // Player's been out of sight for a long time.  Give up.
    98.         self.enterIdle
    99.     end if
    100. end function
    101. funcName[@NPC.seekState] = "seek"
    102. NPC.enterSeek = function(); self.enterState(@self.seekState); end function
    103.  
    104. //------------------------------------------------------------
    105.  
    106. // Event queue data and functions (we'll hide most of this eventually)
    107.  
    108. _events = []   // new entries poked in by the runtime; each entry includes .name and maybe other data
    109.  
    110. getNextEvent = function()
    111.     if _events.len == 0 then; return null; end if
    112.     return _events.pull
    113. end function
    114.  
    115. //------------------------------------------------------------
    116.  
    117. // Main loop function: runs our state machine and event pump, forever.
    118. NPC.mainLoop = function()
    119.     self.enterIdle   // start in the "idle" state
    120.     while 1
    121.         self.processState(getNextEvent)  // pass event to state handler
    122.     end while
    123. end function
    124.  
    125. // OK, everything is all set up.
    126. // Let's run the code!
    127. NPC.mainLoop
    In the previous, state-machine-only version of this code, the state handlers simply didn't include an "event" parameter. Now they do, and on each iteration through the main loop, I pass in whatever event is at the head of the queue (or null if no event is pending).

    The state handlers here are not yet doing anything with it. Unfortunately when they do, they're probably going to have to have code like

    Code (MiniScript):
    1. if event.name == "Damage" then
    2.     print("Ow!")
    3.     self.enterAttack
    4. else if event.name == "Nightfall" then
    5.     // ...etc...
    6. end if
    So I'm thinking about adding a global lookup of an event-handler function. The "Damage" attack would look for a function named onDamage, for example. If found, we call that first, before passing it to the current state.

    This might be the best of both worlds: events that you always handle the same way regardless of state, you make a handler function for that event (and can check the current state if you need to). Events whose handling depends greatly on what state you're in, you can handle in each state's method. Basically this leaves it up to the scripter to deal with events whichever way seems easier to them.
     
  5. laurentlavigne

    laurentlavigne

    Joined:
    Aug 16, 2012
    Posts:
    2,020
    What is self.? How do you set variables such as NPC. within unity? What is @? what does function(event) mean?
     
  6. JoeStrout

    JoeStrout

    Joined:
    Jan 14, 2011
    Posts:
    8,140
    Sorry I was unclear — all that is MiniScript code, not Unity/C# code. There's a 1-page quick reference which explains that syntax, but here's the gist of it:
    • function() begins a function declaration, which continues until end function. The result is a function object, which is a first-class type in MiniScript, so we assign that to a variable (like NPC.enterState) so we can call it later.
    • Naming a function invokes it, so when you want to refer to the function object itself without invoking it, you stick an @ in front of it. So when we do self.processState = @funcPtr, we are taking the function referred to by funcPtr, and storing it in self.processState for future use.
    • self is just a reference to the object (map) the current function was invoked by. Along with the special __isa map entry (automatically set up when you use new), this is the foundation of OOP in MiniScript.
    But since this is your first exposure to MiniScript, I fear it's giving you the wrong impression. Most MiniScript code is very simple. This is a particularly thorny problem (combining event-driven and state-machine coding at once), and the solution I came up with uses some advanced features (like function references) most users would never need.
     
  7. laurentlavigne

    laurentlavigne

    Joined:
    Aug 16, 2012
    Posts:
    2,020
    Thanks, so in c# terms
    self. => this.
    Name=function () ... end function => T Name() {...}
    and @ => implicit in c# when used with delegates
    I see in your cheat sheet there is no type, is it all duck type like lua?
    What's the performance profile of your scripting and does it precompile to bytecode?
    How do I map the unity API or some custom c# code to some miniscript call?
    for example how do I get
    Code (CSharp):
    1. coordinates = GetClosestTree()
    2. GoTo(coordinates)
    3. while (distance(self.position - coordinates) > sigma)
    4.     wait(0.1)
    5. end while
    6. WaterNearestTree()
    is the wait() function a yield return WaitForSeconds(0.1)?
     
  8. JoeStrout

    JoeStrout

    Joined:
    Jan 14, 2011
    Posts:
    8,140
    We should probably move this over to the official MiniScript thread (or go back to your scriptable modding thread). :) But your understanding is correct, and yes, it's all dynamically typed. It is indeed compiled to bytecode and performance is good, though I haven't any rigorous benchmarks to compare. You add "intrinsic functions" (C# code that can be invoked from the MiniScript side) as described in the MiniScript integration guide.

    The code you quote above is valid MiniScript code but suffers from compulsive parenthesizing, a common programmer disease. ;) By "official" style, it should be written:
    Code (MiniScript):
    1.     coordinates = GetClosestTree
    2.     GoTo(coordinates)
    3.     while distance(self.position - coordinates) > sigma
    4.         wait(0.1)
    5.     end while
    6.     WaterNearestTree
     
    laurentlavigne likes this.
  9. laurentlavigne

    laurentlavigne

    Joined:
    Aug 16, 2012
    Posts:
    2,020
    With no () behind a method name, it looks like a variable ... which can be confusing, is it optional?
     
  10. JoeStrout

    JoeStrout

    Joined:
    Jan 14, 2011
    Posts:
    8,140
    It is a variable. You're thinking it's a function identifier, but it's not; MiniScript doesn't have those. It's just a variable that happens to contain a function reference. And such variables automatically invoke their function, unless you use @ to prevent this.

    But yes, the empty parentheses are optional. :)
     
  11. laurentlavigne

    laurentlavigne

    Joined:
    Aug 16, 2012
    Posts:
    2,020
    ok I get it, this is elegant.
    so all you have are values, flow control and code that you can put in variables?
     
  12. JoeStrout

    JoeStrout

    Joined:
    Jan 14, 2011
    Posts:
    8,140
    Yep. And the only flow control is for, while, and if. The only data types are string, number, list, and map. (Objects and classes are just a special case of map.) Simple and elegant is what we were going for.
     
  13. laurentlavigne

    laurentlavigne

    Joined:
    Aug 16, 2012
    Posts:
    2,020
    Any plan to add switch case to the flow control? it might help with state machines, unless you have another approach to that.
     
  14. neoshaman

    neoshaman

    Joined:
    Feb 11, 2011
    Posts:
    4,240
    I guess when you haven't a switch case with "variable function" you can just rely on array and equivalent, which is more elegant?
     
    JoeStrout likes this.
  15. JoeStrout

    JoeStrout

    Joined:
    Jan 14, 2011
    Posts:
    8,140
    Yes, you can see above I just used function references for each state. For other things where you might use switch, you can of course use if/else if instead.