Best practice for pool spawning vs instantion load sequence?

Been writing a lot of confusing code as a result of spawning enemies and projectiles from an object pool. It is most confusing when I’m running several coroutines on an object and some values need to persist while others don’t. When I deal with things like assigning a target for my AI enemies, or anything that deals with events in awake/start/onenable, things get even more hairy because I gotta factor in who loads first, pausing certain npcs with coroutines to wait for event calls for registration with game manager. Then on disable, I gotta figure out what data needs to go, what needs to stay, and which phase I need to initialize what, whether it be an on-instantiation value that persists/fades, or an onEnable value that I don’t want to access on the first onEnable but need to access on every one thereafter. I hope you get the idea… handling dependencies the way I’ve been doing it has been chaotic.

Anyways, I came up with a temporary solution but I’m hoping some unity guru might have a better way. I came up with an abstract class that does a check to see if instantiation has already occurred, offering the appropriate override method for that condition… --the idea is that it is supposed to allow you to do all the normal things you’d do using the full gamut of awake->onenable->start when the object is first instantiated by the pool, but then you get a different override of onenable each time it is “spawned” by the pool thereafter and put into actual gameplay. It will probably make things better, but I don’t know… is there some time-tested way of doing this that is more versatile?

public abstract class Projectile : MonoBehaviour
    {
        bool hasInstance;
        void OnEnable()
        {
            if (hasInstance) OnSpawnEnable();  //override for spawn pool OnEnable();
            else OnInstanceEnable();  //override for normal instantiation OnEnable();
        }

        void Start()
        {
            hasInstance = true;
            this.gameObject.SetActive(false);
        }

        public abstract void OnSpawnEnable();
        public abstract void OnInstanceEnable();
    }

well, yes, abandon coroutines altogether. that’s the most robust way there is.

but the last time I suggested this to some guy, he was even more confused than before. I can’t tell why are people using coroutines in the first place, they are frankly hard to understand deeply, are quite hacky, and used only to quickly prototype a behavior when you’re working 9-5 and are expected to commit your code fully before leaving. yes, I’m joking, but on a more serious note, trying to build up a decent project on top of coroutine hacks is a disaster waiting to be refactored.

personally I used them exclusively to achieve simple behavior over which I had a perfect control. like simple tweening animations, and in one example I used them to make an ad-hoc confirmation dialog, and that was particularly neat because the implementation ended up really tiny and reusable. a little icon that wobbles on top of something (like ‘new’ or exclamation mark), some shiny animation moving across here and then, that kind of thing. nothing critical.

I would never consider using them for timing or state logic.

1 Like

Yeah thats primarily what I’m using them for at this point, particularly with enemy AI scripts. I use MEC coroutines though, which complicates things even further, as you have to explicitly tell them when to stop. They give you a lot of freedom (ie, using coroutines outside of monobehaviours) and allow you to assign groups and layers to control sets of coroutines. It allows me to do all kinds of weirdness, but with great freedom comes great spaghetti apparently. Really don’t want to refactor. Here is one such script:

    public class EnemyControl : IController
    {
        public event ShootHandler OnShoot;
        public event ShieldHandler OnShield;
        public event ChargeHandler OnCharge;

        Transform _target;
        Controls _controls;
        GameObject _gameObject;
        CoroutineHandle _charge;
        CoroutineHandle _think;
        float _chargeValue;

        float _shieldRate = .25f;
        float _shootRate = .50f;
        float _moveRate = .50f;

        bool _actShield;
        bool _actShoot;
        bool _actMove;

        public Vector3 moveDirection { get; set; }
        public EnemyControl(GameObject gameObject)
        {
            _gameObject = gameObject;
            _target = GameEvents.RequestTarget?.Invoke();
            _charge = Timing.RunCoroutine(Charge(), _gameObject);
            Timing.PauseCoroutines(_charge);
            _think = Timing.RunCoroutine(Think(), _gameObject);
            Timing.PauseCoroutines(_think);
            _chargeValue = 0f;
        }

        public void Activate() => Timing.ResumeCoroutines(_think);
        public void Deactivate() => Timing.PauseCoroutines(_think);

        IEnumerator<float> Think()
        {
            while (true)
            {
                yield return Timing.WaitForSeconds(2f);
                _actShield = _shieldRate > Random.Range(0f, 1f);
                _actShoot = _shootRate > Random.Range(0f, 1f);
                OnShield?.Invoke(_actShield);
                CalculateMoveDirection(.50f, 25f, 25f);
                if (_actShoot && !_actShield) Timing.RunCoroutine(Shoot(Random.Range(0f, 1f)), _gameObject);
            }
        }

        void CalculateMoveDirection(float centerFactor, float playerFactor, float randomFactor)
        {
            if (!(_moveRate > Random.Range(0f, 1f))) return;

            var centerDirection = Vector3.zero - _gameObject.transform.position;
            var playerDirection = _target.position - _gameObject.transform.position;
            if (playerDirection.magnitude < .25f) playerFactor = 0f;
            var moveToCenter = Vector3.ClampMagnitude(centerDirection, centerFactor);
            var moveToPlayer = Vector3.ClampMagnitude(playerDirection, playerFactor);
            var moveToRandom = Vector3.ClampMagnitude(new Vector3(Random.Range(-1f, 1f), Random.Range(-1f, 1f), 0f), randomFactor);
            moveDirection = moveToCenter + moveToPlayer + moveToRandom;
            moveDirection = moveDirection.normalized;
        }

        IEnumerator<float> Shoot(float waitTime)
        {
            BeginCharge();
            yield return Timing.WaitForSeconds(waitTime);
            EndCharge();
        }

        void BeginCharge()
        {
            _chargeValue = 0f;
            OnCharge?.Invoke(_chargeValue);
            Timing.ResumeCoroutines(_charge);
        }

        void EndCharge()
        {
            OnShoot?.Invoke(_chargeValue);
            Timing.PauseCoroutines(_charge);
        }

        IEnumerator<float> Charge()
        {
            while (true)
            {
                _chargeValue += Timing.DeltaTime;
                if (_chargeValue > 1f) _chargeValue = 0f;
                yield return Timing.WaitForOneFrame;
            }
        }

    }

well it’s probably just me, but I’d refactor that with fire.

don’t get me wrong, it definitely looks clean and capable and all.
but in my practice that is highly unmaintainable, and I’m guessing this was your concern as well?

you simply don’t do these things in the real world. it’s too leet. I’m not saying that you mustn’t, this is probably a good learning experience for you, and it’s not that a horrible bald-headed coding demigod with bad spine and terrible eating habits will find you and chop your hands, but erm… compared to what you’ll get through until your refactor this, maybe baldy would be a better outcome :smile:

listen, idk, maybe someone else will come along who can tell you something more positive about this, I’m highly subjective regarding any abuse of enumerators for sideways frametickin’. I just don’t do it.

in my experience, it’s a code smell from the get go.
as a programmer you want a) to be in full control, b) to make things that are easily expandable without breaking anything, c) to not have to remember every minute detail of your past code.

coroutines – although really cool as a tool to have – when abused heavily and when entangled with critical logic, break all of these like it’s nothing.

1 Like

normally you make all of this by implementing your own clock.

you already have time.deltatime at your disposal. by knowing the internal framerate you can work out whatever you need in a truly scalable way without any self-imposed limits. you can build upon that a decent event system, or you can call methods directly, instead of doing invoke etc.

to answer your original question, well, your pool objects ought to have some sort of life cycle.
you can work out a simple state diagram for that, but in general, the two major states are alive and, well, dead.
and then you tie in your timing logic and consider your actual needs to determine if there should be states in between, like init, or reset, or what else you might need as a transitioning precondition.

for many people this sounds like a lot of work, and indeed, for just prototyping it is. but it pays off immensely. people don’t usually think about it like this, but any good code you make doesn’t just evaporate between the projects – you still get to reuse it for other, maybe quicker projects, it doesn’t matter how big it is, the only thing that matters is how nimble and useful it is on a high level, and how quickly you can add new features or remove the old ones.

and when you have clearly written clocks, state machines, decoupled logic, and decentralized entities, this is when anyone’s productivity really starts to take off. this is just my 2c, but anything short-term you might do in the meantime only delays you in the long-term. that’s how coding works regardless of how we feel about it.

1 Like

@djweaver , I don’t like your implementation of Projectile. Now you have to remember, in all child classes, that introducing an OnEnable breaks everything.

I’d go with:

public interface IPooledObject {
    void OnSpawnedFromPool();
    void OnReturnedToPool();
}

which the pool would call on the objects when they’re handed out or returned.

Objects that are pooled should in no way assume that being activated/deactivated corresponds to being pooled or not - that makes them a lot less flexible then they should be.

3 Likes