Lifecycle
Growth and Death
- Make shapes grow and shrink.
- Allow behavior to kill shapes.
- Delay killing until after the game update loop.
- Replace immediate shape destruction with shrinking.
This is the eleventh tutorial in a series about Object Management. It introduces more fluid shape creation and destruction, by adding behavior for growing and dying.
This tutorial is made with Unity 2017.4.12f1.
Growing Shapes
Whenever a shape is spawned it instantly appears, at full size. Shapes pop into existence without warning, which can be a jarring experience. One way to make the introduction of a new shape smoother and more gradual is to give them an initial scale of zero and slowly grow them to their full size. Another approach is to initially make them fully transparent and gradually make them more opaque. It's also possible do both at the same time, or do something else. We don't need to limit ourselves to a single approach, so the most flexible way to go about it is to create a behavior, rather than build it into the Shape
class. In this tutorial we'll go for the first approach: growth.
Growing Behavior
To support growing shapes, add a Growing
option to the ShapeBehaviorType
enum.
public enum ShapeBehaviorType { Movement, Rotation, Oscillation, Satellite, Growing }
Add a corresponding case to the GetInstance
method that returns a GrowingShapeBehavior
.
case ShapeBehaviorType.Satellite: return ShapeBehaviorPool<SatelliteShapeBehavior>.Get(); case ShapeBehaviorType.Growing: return ShapeBehaviorPool<GrowingShapeBehavior>.Get();
Create a bare-bones implementation for the new GrowingShapeBehavior
class.
using UnityEngine; public sealed class GrowingShapeBehavior : ShapeBehavior { public override ShapeBehaviorType BehaviorType { get { return ShapeBehaviorType.Growing; } } public override bool GameUpdate (Shape shape) { return true; } public override void Save (GameDataWriter writer) {} public override void Load (GameDataReader reader) {} public override void Recycle () { ShapeBehaviorPool<GrowingShapeBehavior>.Reclaim(this); } }
Going from Zero to Full Scale
The purpose of GrowingShapeBehavior
is to grow the shape from zero to the scale that we originally gave it. So we have to keep track of the original scale in a field. Also, it takes a while to grow, so we also need a duration field. Both values must also be saved and loaded.
Vector3 originalScale; float duration; … public override void Save (GameDataWriter writer) { writer.Write(originalScale); writer.Write(duration); } public override void Load (GameDataReader reader) { originalScale = reader.ReadVector3(); duration = reader.ReadFloat(); }
The idea is that we add this behavior to a shape that already has its final scale. We'll configure the behavior with via an Initialize
method, in which we can retrieve the original scale and provide the duration via a parameter. Then we set the shape's scale to zero.
public void Initialize (Shape shape, float duration) { originalScale = shape.transform.localScale; this.duration = duration; shape.transform.localScale = Vector3.zero; }
In GameUpdate
, we have to adjust the shape's scale as long as its age is less than the growth duration. The scale factor is simply the age divided by the duration. When the shape is old enough we revert to the original scale and the behavior is no longer needed.
public override bool GameUpdate (Shape shape) { if (shape.Age < duration) { float s = shape.Age / duration; shape.transform.localScale = s * originalScale; return true; } shape.transform.localScale = originalScale; return false; }
Configuring Growth
The duration of the growing phase is something that we'll configure per spawn zone. Like for the satellite options, we'll define a nested LifecycleConfiguration
struct in SpawnZone.SpawnConfiguration
to group all options related to a shape's lifecycle. Right now that's just the growing duration, but we'll add more later. The growing duration can be randomized, but shouldn't be too long, like Somewhere between zero and two seconds.
public struct SpawnConfiguration { … [System.Serializable] public struct LifecycleConfiguration { [FloatRangeSlider(0f, 2f)] public FloatRange growingDuration; } public LifecycleConfiguration lifecycle; }
Add a method to SpawnZone
to take care of setting up the lifecycle of a shape. Besides the shape parameter, also add a parameter for the desired growing duration. If that duration is larger than zero, add a GrowingShapeBehavior
to the shape. Otherwise we don't need to bother with the behavior.
void SetupLifecycle (Shape shape, float growingDuration) { if (growingDuration > 0f) { shape.AddBehavior<GrowingShapeBehavior>().Initialize( shape, growingDuration ); } }
We made the growing duration a parameter so we can use the same duration for a shape and all its satellites. To make that work, add a duration parameter to CreateSatelliteFor
and have it invoke SetupLifecycle
for the satellite shape at the end.
void CreateSatelliteFor (Shape focalShape, float growingDuration) { … SetupLifecycle(shape, growingDuration); }
At the end of SpawnShapes
, randomly determine the growing duration and pass it to all satellites. After the satellites are created we can set up the lifecycle of the main shape. We cannot do that earlier because the scales of the satellites depend on the scale of the focal shape. Initializing the growing behavior sets the scale to zero, so it must be delayed.
public virtual void SpawnShapes () { … float growingDuration = spawnConfig.lifecycle.growingDuration.RandomValueInRange; int satelliteCount = spawnConfig.satellite.amount.RandomValueInRange; for (int i = 0; i < satelliteCount; i++) { CreateSatelliteFor(shape, growingDuration); } SetupLifecycle(shape, growingDuration); }
Smooth Growth
When the growing behavior is used, shapes no longer pop into existence immediatetly. But the growth is linear, so the player has no clue when a shape is done growing. The growing phase just stops at an arbitrary moment. We can make that a bit more smooth and organic by using `3s^2-2s^3` instead of the linear `s` scale factor. That's known as the smoothstep curve.
float s = shape.Age / duration; s = (3f - 2f * s) * s * s; shape.transform.localScale = s * originalScale;
The difference is subtle, but shapes now vary how fast they grow, starting slow, being fastest halfway, and slowing down again when they're almost done.
Dying Shapes
If we support growing shapes, it's not a big jump to also support dying shapes. Rather than increasing their scale, dying shapes shrink until their scale has been reduced to zero.
Dying Behavior
Add a Dying
option to ShapeBehaviorType
and a corresponding case to the GetInstance
method. Then create a DyingShapeBehavior
class, by duplicating and renaming GrowingShapeBehavior
and adjusting the BehaviorType
property and Recycle
method as needed. Such adjustments should be routine by now, so I'm not explicitly showing them.
The dying behavior needs the original scale and a duration, just like growing. But growing assumes that we start at age zero, while dying can start at any age. So we also need to keep track of the age at which we began dying, which is when Initialize
is invoked. Also, because we're shrinking, the original scale should not be set to zero in Initialize
.
Vector3 originalScale; float duration, dyingAge; public void Initialize (Shape shape, float duration) { originalScale = shape.transform.localScale; this.duration = duration; dyingAge = shape.Age;//shape.transform.localScale = Vector3.zero;} … public override void Save (GameDataWriter writer) { writer.Write(originalScale); writer.Write(duration); writer.Write(dyingAge); } public override void Load (GameDataReader reader) { originalScale = reader.ReadVector3(); duration = reader.ReadFloat(); dyingAge = reader.ReadFloat(); }
GameUpdate
only needs slight modification. The dying duration is found by subtracting the dying age from the shape's current age. The final scale is zero. And the scalar is reversed, which is done by using 1 minus the duration division as the initial scalar, before smoothing.
public override bool GameUpdate (Shape shape) { float dyingDuration = shape.Age - dyingAge; if (dyingDuration < duration) { float s = 1f - dyingDuration / duration; s = (3f - 2f * s) * s * s; shape.transform.localScale = s * originalScale; return true; } shape.transform.localScale = Vector3.zero; return false; }
Configuring Death
How long dying lasts is also something that we'll configure per spawn zone, so add a field for that to LifecycleConfiguration
, using the same range as for the growing duration.
[FloatRangeSlider(0f, 2f)] public FloatRange growingDuration; [FloatRangeSlider(0f, 2f)] public FloatRange dyingDuration;
Because we now have to determine two durations for the lifecycle, let's add a convenient property to LifecycleConfiguration
that returns two random durations at once, as a Vector2
with growing as its first and dying as its second component.
public Vector2 RandomDurations { get { return new Vector2( growingDuration.RandomValueInRange, dyingDuration.RandomValueInRange ); } }
Change SetupLifecycle
so it uses such a vector as its parameter, instead of a single duration. To quickly test our dying behavior in isolation, we'll have it either add a growing or a dying behavior, but not both. Only if there's no growing duration while we do have a dying duration will we add a dying behavior.
void SetupLifecycle (Shape shape, Vector2 durations) { if (durations.x > 0f) { shape.AddBehavior<GrowingShapeBehavior>().Initialize( shape, durations.x ); } else if (durations.y > 0f) { shape.AddBehavior<DyingShapeBehavior>().Initialize( shape, durations.y ); } }
Adjust CreateSatelliteFor
so it passes through the vector instead of a single duration.
void CreateSatelliteFor (Shape focalShape, Vector2 lifecycleDurations) { … SetupLifecycle(shape, lifecycleDurations); }
And update SpawnShapes
as well.
//float growingDuration =// spawnConfig.lifecycle.growingDuration.RandomValueInRange;Vector2 lifecycleDurations = spawnConfig.lifecycle.RandomDurations; int satelliteCount = spawnConfig.satellite.amount.RandomValueInRange; for (int i = 0; i < satelliteCount; i++) { CreateSatelliteFor(shape, lifecycleDurations); } SetupLifecycle(shape, lifecycleDurations);
Killing Shapes
When only the dying behavior is in use, we'll see shapes pop into existence that immediately start shrinking and disappear. But even though their scale is reduced to zero they're still alive. The amount of shapes will increase until the level maximum is reached—if set—at which point shapes will get destroyed at random.
The point of the dying behavior is that shapes should die when their scale reaches zero. To support this we have to make it possible for other classes besides Game
to terminate shapes. So add a public Kill
method with a shape parameter to Game
. Just like when destroying a shape, grab its save index, recycle the shape, move the last shape into the hole in the list, then remove the last element of the list.
public void Kill (Shape shape) { int index = shape.SaveIndex; shape.Recycle(); int lastIndex = shapes.Count - 1; shapes[lastIndex].SaveIndex = index; shapes[index] = shapes[lastIndex]; shapes.RemoveAt(lastIndex); }
Now we can kill shapes anywhere by invoking Game.Instance.Kill(shape)
, but let's add a convenient Die
method to Shape
to make this easier.
public void Die () { Game.Instance.Kill(this); }
This makes it possible to simply invoke shape.Die()
in DyingShapeBehavior.GameUpdate
when it is finished, instead of setting the scale to zero. But because the shape gets recycled—which recycles all its behavior—we must no longer indicate that the behavior should be removed. So return true
instead of false
.
public override bool GameUpdate (Shape shape) { …//shape.transform.localScale = Vector3.zero;shape.Die(); return true; }
Delayed Killing
While dead shapes indeed get removed at this point, we're killing them while Game
is working through its shape list. This causes the order of the shape list to change, moving the last shape in the list to the index that is currently getting updated. A consequence of this is that the shuffled shape gets skipped this update. While shuffling the order in which shapes get updated doesn't matter much, we must ensure that they do always get updated.
To detect the problem when it is about to happen, we must first know whether Game
is currently working through its shape list. We can do that by adding a boolean field to indicate whether we're currently in the game update loop. Set it to true
immediately before the loop and to false
immediately after it.
bool inGameUpdateLoop; … void FixedUpdate () { inGameUpdateLoop = true; for (int i = 0; i < shapes.Count; i++) { shapes[i].GameUpdate(); } inGameUpdateLoop = false; … }
If we are inside the loop, then we must not mess with the list. If a shape is killed, its removal from the list has to be postponed. We can do that by adding killed shapes to a separate kill list, which we have to keep track of besides the regular shape list.
List<Shape> shapes, killList; … void Start () { mainRandomState = Random.state; shapes = new List<Shape>(); killList = new List<Shape>(); … }
Now Kill
can check whether we're in the game update loop. If so, add the shape to the kill list. Otherwise, the shape can be killed immediately. Move the original kill code to a separate KillImmediately
method, which should be private.
public void Kill (Shape shape) { if (inGameUpdateLoop) { killList.Add(shape); } else { KillImmediately(shape); } } void KillImmediately (Shape shape) { int index = shape.SaveIndex; shape.Recycle(); int lastIndex = shapes.Count - 1; shapes[lastIndex].SaveIndex = index; shapes[index] = shapes[lastIndex]; shapes.RemoveAt(lastIndex); }
At the end of FixedUpdate
, check whether there are any shapes in the kill list. If so, kill them all immediately and then clear the list.
void FixedUpdate () { … if (killList.Count > 0) { for (int i = 0; i < killList.Count; i++) { KillImmediately(killList[i]); } killList.Clear(); } }
We can also use KillImmediately
in DestroyShape
, getting rid of duplicate code.
void DestroyShape () { if (shapes.Count > 0) {//int index = Random.Range(0, shapes.Count);//…//shapes.RemoveAt(lastIndex);Shape shape = shapes[Random.Range(0, shapes.Count)]; KillImmediately(shape); } }
Preventing Redundant Kills
The delayed killing approach guarantees that all shapes get updated as they should, but it introduces another potential problem. It is now possible that the same shape gets killed more than once. For example, it's possible for a dying behavior to kill a shape, which then immediately gets destroyed due to the shape limit. And maybe there will be other behaviors that could kill any shape at any time.
We must avoid killing a shape a second time when it's already dead, because that would cause it to get recycled when it shouldn't. Maybe it's already recycled, which would cause it to get pooled twice, leading to trouble later. Maybe it already got reused and immediately gets recycled again when it shouldn't. We can guard agains all these problems by turning the kill list into a list of shape instances and checking whether they're still valid before the kill.
List<Shape> shapes; List<ShapeInstance> killList; … void Start () { mainRandomState = Random.state; shapes = new List<Shape>(); killList = new List<ShapeInstance>(); … } … void FixedUpdate () { … if (killList.Count > 0) { for (int i = 0; i < killList.Count; i++) { if (killList[i].IsValid) { KillImmediately(killList[i].Shape€); } } killList.Clear(); } }
Complete Lifecycle
We have a behavior for growing and a behavior for dying. If we put them together, with a span of adult life in between, we get an entire lifecycle. We could do that by creating a single behavior that contains all the code for growing and dying, but it's also possible to keep using the behavior that we already have, plus an additional lifecycle behavior that adds the others when needed. That might be overkill in this case, but it's an interesting approach to try out so we'll go for it.
Lifecycle Behavior
Create a new LifecycleShapeBehavior
, linked to a Lifecycle
enum option. Start with a duplicate of DyingShapeBehavior
and make the required changes.
The lifecycle consists of three phases—growing, adult, and dying—each with its own duration. The growing phase begins immediately, so Lifecyclebehavior
can immediately add the required behavior in Initialize
, if needed. This means that it does not need to keep track of the growing duration in a field of its own, just pass the duration to the growing behavior. It also doesn't need to know the original scale. It does need to keep track of both the adult duration and the dying duration. Besides that, the dying age is equal to the growing duration plus the adult duration.
//Vector3 originalScale;float adultDuration, dyingDuration, dyingAge; public void Initialize ( Shape shape, float growingDuration, float adultDuration, float dyingDuration ) {//originalScale = shape.transform.localScale;//this.duration = duration;this.adultDuration = adultDuration; this.dyingDuration = dyingDuration; dyingAge = growingDuration + adultDuration; if (growingDuration > 0f) { shape.AddBehavior<GrowingShapeBehavior>().Initialize( shape, growingDuration ); } } … public override void Save (GameDataWriter writer) { writer.Write(adultDuration); writer.Write(dyingDuration); writer.Write(dyingAge); } public override void Load (GameDataReader reader) { adultDuration = reader.ReadFloat(); dyingDuration = reader.ReadFloat(); dyingAge = reader.ReadFloat(); }
In GameUpdate
, the lifecycle only needs to check whether the shape has reached its dying age. When that happens, it adds the dying behavior and removes itself. This will probably trigger a little too late, so reduce the final dying duration by the lost time, by adding the dying age and subtracting the current age.
public override bool GameUpdate (Shape shape) { if (shape.Age >= dyingAge) { shape.AddBehavior<DyingShapeBehavior>().Initialize( shape, dyingDuration + dyingAge - shape.Age ); return false; } return true; }
Actually, a dying behavior is only needed if there is a duration for it. If not, the shape can die immediately and the lifecycle behavior doesn't need to be removed explicitly.
if (shape.Age >= dyingAge) { if (dyingDuration <= 0f) { shape.Die(); return true; } shape.AddBehavior<DyingShapeBehavior>().Initialize( shape, dyingDuration + dyingAge - shape.Age ); return false; }
Configuring the Lifecycle
To configure the full lifecycle, add an adult duration to LifecycleConfiguration
. Its Durations
property becomes a Vector3
, with the adult duration in place of the dying duration, which moves to the third component.
[FloatRangeSlider(0f, 2f)] public FloatRange growingDuration; [FloatRangeSlider(0f, 100f)] public FloatRange adultDuration; [FloatRangeSlider(0f, 2f)] public FloatRange dyingDuration; public Vector3 RandomDurations { get { return new Vector3( growingDuration.RandomValueInRange, adultDuration.RandomValueInRange, dyingDuration.RandomValueInRange ); } }
We only have to change the vector type in SpawnZone.SpawnShapes
.
Vector3 lifecycleDurations = spawnConfig.lifecycle.RandomDurations;
And in CreateSatelliteFor
.
void CreateSatelliteFor (Shape focalShape, Vector3 lifecycleDurations) { … }
SetupLifecycle
becomes a bit more complicated. If there's a growing duration, then if we have at least one of the other durations, we need a full lifecycle. Otherwise only growth is needed. Otherwise, if we have an adult duration then we also need a lifecycle. Finally, we can make do with dying is there's only a dying duration. Make sure to change that code so it uses the third component of the vector.
void SetupLifecycle (Shape shape, Vector3 durations) { if (durations.x > 0f) { if (durations.y > 0f || durations.z > 0f) { shape.AddBehavior<LifecycleShapeBehavior>().Initialize( shape, durations.x, durations.y, durations.z ); } else { shape.AddBehavior<GrowingShapeBehavior>().Initialize( shape, durations.x ); } } else if (durations.y > 0f) { shape.AddBehavior<LifecycleShapeBehavior>().Initialize( shape, durations.x, durations.y, durations.z ); } else if (durations.z > 0f) { shape.AddBehavior<DyingShapeBehavior>().Initialize( shape, durations.z ); } }
Different Lifecyle per Satellite
Currently, a shape and all its satellites have the same lifecycle, but that is not required. Let's add a toggle option to the satellite configuration to control whether the lifecycles are uniform.
public struct SatelliteConfiguration { … public bool uniformLifecycles; }
In the case of uniform life cycles we keep using the same approach. Otherwise, we use a new set of random durations per satellite.
for (int i = 0; i < satelliteCount; i++) { CreateSatelliteFor( shape, spawnConfig.satellite.uniformLifecycles ? lifecycleDurations : spawnConfig.lifecycle.RandomDurations ); }
Now deaths due to the lifecycle can result in escaping satellites, if the focal shape ended up dying first.
Destroying Slowly
Killing shapes causes them to shrink and then die, instead of immediately disappearing. But when a shapes gets destroyed—either by the player or because there were too many shapes—they still disappear immediately. We can change such destructions so they become slow shrinking deaths too, but this requires some care.
If shape destruction is just another way to kill them, then we shouldn't bother destroying shapes that are already dying. And as dying shapes are already on their way out, it makes sense to not consider them when checking the shape limit. To be able to do that it must be possible to distinguish between shapes that are dying and those that are not.
One way to make the distinction is to put all dying shapes in a separate shape list and removing them from the regular shape list. Then we automatically ignore dying shapes when picking a random one to destroy and when checking the limit. However, that affects the save index and all places where we manipulate the shape list, because we'd have two lists instead of one.
Another way to make the distinction is via the order of the shape list. We can split the list in two sections, effectively working with two lists while all the code that works with a single list remains valid. That minimizes the changes that we have to make, so we'll use that approach.
Segregating Dying Shapes
We'll split the shapes list into a dying and non-dying section by moving all dying shapes to the front of the list. As that is a list-order manipulation, it's something that we must be careful with. Add a private MarkAsDyingImmediately
method to Game
to put a shape in the dying section. Keep track of the dying shape count in a field and use that as the new index for a shape that's marked as dying, swapping the places with the shape at that index. Afterwards, increment the dying shape count.
int dyingShapeCount; … void MarkAsDyingImmediately (Shape shape) { int index = shape.SaveIndex; shapes[dyingShapeCount].SaveIndex = index; shapes[index] = shapes[dyingShapeCount]; shape.SaveIndex = dyingShapeCount; shapes[dyingShapeCount++] = shape; }
Marking a shape as dying more than once should have no effect, so abort if the shape is already in the dying section. That's the case when its index is lower than the dying shape count.
int index = shape.SaveIndex; if (index < dyingShapeCount) { return; } shapes[dyingShapeCount].SaveIndex = index;
This change does affect KillShapeImmediately
. First, we have to decrement the dying count if a dying shape gets removed. Second, we can no longer blindly move the last shape to the remove shape's index. Doing so could put a non-dying shape in the dying section. That's only the case when a dying shape is removed, if it is not the last in the dying section. In other words, when the shape's index is less than the dying count and also less than the dying count minus one. When that is the case we have to perform a double move: the last dying shape to the removed shape, and the last shape in the list to the hole that was created.
shape.Recycle(); if (index < dyingShapeCount && index < --dyingShapeCount) { shapes[dyingShapeCount].SaveIndex = index; shapes[index] = shapes[dyingShapeCount]; index = dyingShapeCount; } int lastIndex = shapes.Count - 1;
But a double move only is only possible if there is at least a single non-dying shape. If there isn't, the hole that we created is at the end of the list, so we don't need to bother moving the last shape at all. As it's never needed to fill a hole at the end of the list, we can just skip that step in general.
int lastIndex = shapes.Count - 1; if (index < lastIndex) { shapes[lastIndex].SaveIndex = index; shapes[index] = shapes[lastIndex]; } shapes.RemoveAt(lastIndex);
Now that we know the dying shape count, subtract it from the shape count when checking whether we've exceeded the limit in FixedUpdate
. That makes it only apply to non-dying shapes. So the total amount of shapes could exceed the limit, until all dying shapes are dead.
int limit = GameLevel.Current.PopulationLimit; if (limit > 0) { while (shapes.Count - dyingShapeCount > limit) { DestroyShape(); } }
Likewise, in DestroyShape
we only go ahead if there are non-dying shapes and then only pick a random shape from the second segment of the list.
void DestroyShape () { if (shapes.Count - dyingShapeCount > 0) { Shape shape = shapes[Random.Range(dyingShapeCount, shapes.Count)]; KillImmediately(shape); } }
Finally, make sure to set the dying shape count back to zero each time we begin a new game.
void BeginNewGame () { … dyingShapeCount = 0; }
Delayed Marking
Because marking a shape as dying alters the order of the shape list, we have to make sure that this doesn't happen when we're in the game update loop. We can use the same approach that we use for the kill list, so add a second list for shapes that need to be marked.
List<ShapeInstance> killList, markAsDyingList; … void Start () { mainRandomState = Random.state; shapes = new List<Shape>(); killList = new List<ShapeInstance>(); markAsDyingList = new List<ShapeInstance>(); … }
Loop through this list at the end of FixedUpdate
, immediately marking those elements that are still valid.
void FixedUpdate () { … if (markAsDyingList.Count > 0) { for (int i = 0; i < markAsDyingList.Count; i++) { if (markAsDyingList[i].IsValid) { MarkAsDyingImmediately(markAsDyingList[i].Shape€); } } markAsDyingList.Clear(); } }
Now we can add a public MarkAsDying
method that either add a shape to the list or immediately marks it.
public void MarkAsDying (Shape shape) { if (inGameUpdateLoop) { markAsDyingList.Add(shape); } else { MarkAsDyingImmediately(shape); } }
And we can also add another convenient method to Shape
.
public void MarkAsDying () { Game.Instance.MarkAsDying(this); }
The only place where we need to invoke this method is when a DyingShapeBehavior
is initialized.
public void Initialize (Shape shape, float duration) { … shape.MarkAsDying(); }
If there were other behaviors that represented different ways to die, then those should also mark their shape as dying during initialization.
Slow Destruction
To finally support slow destruction we need to decide on a destruction duration. Make that configurable by adding a serializable field to Game
.
[SerializeField] float destroyDuration;
When the duration is positive, have DestroyShape
add a dying behavior to the shape with that duration, instead of killing it immediately.
void DestroyShape () { if (shapes.Count - dyingShapeCount > 0) { Shape shape = shapes[Random.Range(dyingShapeCount, shapes.Count)]; if (destroyDuration <= 0f) { KillImmediately(shape); } else { shape.AddBehavior<DyingShapeBehavior>().Initialize( shape, destroyDuration ); } }
Preventing Double Dying
The destruction of shapes happens independent of their lifecycle. That means that a random destruction could add the dying behavior to a shape that is still growing. That isn't a problem, because the dying behavior was added later so overrides the scale change of the growing behavior. What's more troublesome is that the lifecycle can still add a dying behavior even though one has already been added. The second behavior initiates a new shrinking effect that overrides the first, but whichever completes first decides when the shape is killed.
To prevent adding a second dying behavior to a shape it must be possible to check whether the shape is already dying, no matter why. We can add an IsMarkedAsDying
method to Game
to check this. All it has to do is check whether the shape's index is less than the dying count.
public bool IsMarkedAsDying (Shape shape) { return shape.SaveIndex < dyingShapeCount; }
Once again we make this conveniently available via Shape
, though in this case a readonly property is appropriate.
public bool IsMarkedAsDying { get { return Game.Instance.IsMarkedAsDying(this); } }
Finally, in LifecycleShapeBehavior.GameUpdate
check whether the shape is already dying when it reached its dying age. If so, don't add the dying behavior.
if (dyingDuration <= 0f) { shape.Die(); return true; } if (!shape.IsMarkedAsDying) { shape.AddBehavior<DyingShapeBehavior>().Initialize( shape, dyingDuration + dyingAge - shape.Age ); } return false;
The next tutorial is More Complex Levels.