Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.

Discussion Best practice discussion: blocking components

Discussion in 'Scripting' started by olejuer, Nov 15, 2022.

  1. olejuer

    olejuer

    Joined:
    Dec 1, 2014
    Posts:
    204
    Hey all,

    I frequently use a design pattern that I think might be interesting to discuss.
    In many scenarios I find myself in need to enable/disable some behaviour under certain conditions.
    For example:
    - Prevent player from moving when stunned or rooted
    - Prevent attacking when in a conversation or when in town
    - Keep the birds from singing at night or during a storm
    - ...
    you catch my drift.

    There are of course a ton of different solutions to this, depending on the exact situation.
    For example:
    - Using the input system and action sets
    - Enable/Disable MonoBehaviours
    - Setting script variables
    - ...

    Lately I have been using a blocker pattern very often. The distinct advantage is that multiple things can block something, and all blockers must be removed before the behaviour becomes active again. A player can be stunned AND rooted, in town AND in a conversation. And you don't want your birds to start singing again at the end of the storm when it's still the middle of the night...

    This is the general design I use for things that can be blocked by multiple sources:
    Code (CSharp):
    1.  
    2. public class Blockable : IBlockable
    3. {
    4.     private readonly HashSet<object> _blockers = new HashSet<object>();
    5.  
    6.     public void Trigger()
    7.     {
    8.         if (IsBlocked()) return;
    9.        
    10.         // do your thing
    11.     }
    12.  
    13.     public void AddBlocker(object blocker)
    14.     {
    15.         _blockers.Add(blocker);
    16.     }
    17.  
    18.     public void RemoveBlocker(object blocker)
    19.     {
    20.         _blockers.Remove(blocker);
    21.         if (!IsBlocked()) Trigger(); // if you want to autostart things again
    22.     }
    23.  
    24.     public bool IsBlocked()
    25.     {
    26.         return _blockers.Any(blocker => blocker != null);
    27.     }
    28. }
    29.  
    It comes with quite a bit of boilerplate and sometimes feels a little bloated. But generally it is a rather stable concept and I seldomly run into issues with it.

    So my question is:
    Do you have certain design patterns your use to activate/deactivate behaviours?
    Maybe something that isn't commonly used or interesting for some reason?
    Do you recognize a weakness in my design or have ideas for improvements?

    Cheers,
    Ole
     
  2. Owen-Reynolds

    Owen-Reynolds

    Joined:
    Feb 15, 2012
    Posts:
    1,769
    To restate, the problem is roughly this: when a character is stunned, movement is disabled; when the stunned expires, movement is turned back on. But OH NO! What if they were also rooted stuck some other way? If 2 or more things can turn something off, how do we coordinate and say "it's not back on until they ALL wear off"?

    A standard Com Sci solution is a simplification of what you have -- a mutex. Instead of a list of blockers, reduce it to just an int telling you how many blockers. Because often it doesn't matter who or why. AddBlocker is just
    mutex++;
    , removeBlocker is
    mutex--;
    . isBlocked is
    mutex>0
    .

    My more common solution is hitting it with a hammer. I write something like
    recomputeMoveStatus() { canMove = !stunned && !GM.isTalking && spells.isCasting(); }
    and have everyone call it as needed. Technically horrible design, but it's quick and works for smallish games.
     
    jvo3dc and olejuer like this.
  3. Olipool

    Olipool

    Joined:
    Feb 8, 2015
    Posts:
    200
    Interesting question. As of now I only used the standard things you mentioned. To a certain degree, I would use multiple variables for multiples blocking issues for example in Update(): if (gameIsPaused || isSleeping || isTalking) return;
    Just to see more clearly what the blockers are exactly. But that might not scale well.

    So your idea is nice. I was just asking myself why you need a HashSet if you don't do anything with the objects. Could it also work by just using a counter that gets increased with each blocker? But if some blockers might be set to null somewhere externally as you show in IsBlocked you might need them.
    If you use a HashSet anyways I would maybe make a HashMap<Blocker> with Blocker being an interface maybe with a method Reason() that will describe the Blocker, be it in game or for debugging.
     
    olejuer likes this.
  4. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    31,224
    I like hammers. My games are made almost entirely of hammers, usually.

    Why? Because when I see a hammer I know "Hey, I can hit stuff with this?", rather than "Hmm, I wonder what I might have used this tool for... and how ... and why and what was I even thinking!?"
     
    AnimalMan likes this.
  5. eisenpony

    eisenpony

    Joined:
    May 8, 2015
    Posts:
    962
    I like hammers too, but sometimes it's not clear which end of the hammer I should be using.
    One of the characteristics of the "blocking" pattern is that is inverts the responsibility of knowing when to block.

    Is it the code that is being blocked that should know when to stop, or is it the code that is blocking that should know what to stop?
     
  6. AnimalMan

    AnimalMan

    Joined:
    Apr 1, 2018
    Posts:
    1,164
    I think in the context there is a bird 3Dsound object somewhere in the hierarchy, how it got there? Could have been procedural. And the cutscene moment could have been an entity trigger. Those bird objects want to know. What is the time of day? Is there a cutscene playing? How do we tell those birds we didn’t know would exist that they are defying the global logic system?

    well, we build a global logic system that knows about their existence of course.
     
  7. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    31,224
    Or... is there a blocking manager where blockers and blockees go to meet and have coffee talk?

    This middleman (middle manager?) approach sometimes makes other things much cleaner, when the blocker can just announce that he blocks X, and the blockee can just ask if they're blocked.
     
    olejuer likes this.
  8. AnimalMan

    AnimalMan

    Joined:
    Apr 1, 2018
    Posts:
    1,164
    Perhaps all of those sound objects could be on the same sound channel.

    perhaps the cutscene mutes those channels.

    Perhaps to stun the player the player is held by the antagonist who applied the stun for the duration of the held time.

    if we render a sphere of procedural landscape around the player of that portrays such features and enemies as had been described; who are to be merely disabled on exit of the render distance set inactive or otherwise handled. Then the activities they conduct during their lifespan while render active are inconsequential if everything they emit that might disrupt other unplanned or random or suprise events that may also interfere or contradict other map systems such as night and day had been handled for what it was to begin with. Then the need for such a system is ludicrous.
     
  9. olejuer

    olejuer

    Joined:
    Dec 1, 2014
    Posts:
    204
    Hi all,

    thanks for your input!

    I agree that for simple scenarios, it's the best idea to hammer the conditions (
    if (!IsStunned) { ... }
    ) into the same script. The reason for the inversion of control here is to separate scripts cleanly for the sake of modularity. I don't want the blockee script to be aware and explicitly reference everything that could potentially block it. That would mean either a bunch of null-checks, hard dependencies, or stuffing all those conditions into the same script.

    With the blocker pattern, I can establish the connections between blocker and blockee from the outside, e.g. through a middleman script or through serialized UnityEvents. In the example, the Movable and the Stunnable script can be used independently of each other.

    As @eisenpony said, this is also a question of responsibility and needs to reflect the game logic, so the pattern is of course not always the right choice. And often it is much easier and more maintainable to just have those if-checks in the code.

    @Owen-Reynolds
    A mutex certainly has the upside of being much simpler, less boilerplate. However, I have found it difficult to work with. When debugging, it's very difficult to find out where an unexpected block comes from. It's easy to have an object count up the mutex twice, or forget counting it down.

    @Olipool
    Yeah, I was thinking of an IBlocker interface that also offers a Disabled event. The blockee could register to that and remove the blocker from the set reactively. This bloats up the pattern even more, but maybe it's worth it.

    @Kurt-Dekker
    I like the idea of a middleman approach and I use it frequently as well. In many usecases it's the better solution, although it does introduce yet another actor into the whole thing.
     
  10. eisenpony

    eisenpony

    Joined:
    May 8, 2015
    Posts:
    962
    Another useful distinction is that the "hammer" approach allows using the power of the programming language's syntax to create complex logic around the decision. i.e., your decision doesn't have to just be a series of `||` / `&& !` operators.

    In the vein of discussing interesting or uncommon patterns, here's a variation I've seen -- mostly in validation type code

    Code (CSharp):
    1. public class Blockable : IBlockable
    2. {
    3.   private readonly HashSet<IBlocker> _blockers = new HashSet<IBlocker>();
    4.   public void Trigger()
    5.   {
    6.     if (IsBlocked()) return;
    7.  
    8.     // do your thing
    9.   }
    10.   public void AddBlocker(IBlocker blocker)
    11.   {
    12.     _blockers.Add(blocker);
    13.   }
    14.   public void RemoveBlocker(IBlocker blocker)
    15.   {
    16.     _blockers.Remove(blocker);
    17.     if (!IsBlocked()) Trigger(); // if you want to autostart things again
    18.   }
    19.   public bool IsBlocked()
    20.   {
    21.     if (_blockers.Any(b => b.Type() == BlockerType.Always)) return true;
    22.     if (_blockers.Any(b => b.Type() == BlockerType.Never)) return false;
    23.     return _blockers.Any(b => b.Type() == BlockerType.Prefer);
    24.   }
    25. }
    It adds a small amount of flexibility to the chain, though still not as flexible or as clear as the "hammer" approach.
     
    olejuer likes this.
  11. Owen-Reynolds

    Owen-Reynolds

    Joined:
    Feb 15, 2012
    Posts:
    1,769
    I feel like adding and removing items from a list has the same problem. If we forget to remove an effect it means we either have a variable mysteriously stuck at 1, or a list mysteriously stuck with 1 item. The latter is just easier to debug (we can at least check it's a "stun" stuck in the list). But same problem either way -- the game is borked.

    That's partly why I sometimes go with an "active" check instead -- when the player needs to recompute "can I move now?" by checking for stun or glue or a a paused game ..., bugs are more likely to let the player move when they shouldn't be able to. That's bad but probably not game-breaking. Whereas the blocking mutex/list approach is more likely to go the other way, with a permanently stuck player.
     
  12. olejuer

    olejuer

    Joined:
    Dec 1, 2014
    Posts:
    204
    I think this is a very valuable difference, though. The object can be a reference that tells you exactly where the offending block comes from. being a HashSet, duplicates are prevented automatically. Also you can check the blockers for null, meaning destroyed game objects will not block. In the past, this helped me track down bugs quite quickly.

    When it's okay for the blockee to know about all possible blocking conditions, it's probably best to use a check like you describe.