Overlap Resistant Speech Bubbles


Hey everyone! It’s the middle of the month, and that means it’s time for another Fireside Devlog! More specifically, this devlog will be about how we implemented our speech bubble system. This system is a force-based physics simulation and inspired by a talk Andreas Fischer (@Vengarioth) gave at GameGampMunich 2019. Follow the link to find a repository and playable demo with his code for spring-based UI! 

What we're making today!

A quick plug before you continue reading: If you´re interested in Fireside beyond this devlog, please consider joining our Discord server; we’re always looking for any feedback!

As mentioned previously, text is the main type of content in Fireside. Be it events, dialogues, or item descriptions, much of the player’s actions will involve reading. There are different ways we will be communicating this text to the player, and most are straightforward to implement. However, speech bubbles have some special requirements that prompted me to try a (perhabs slightly overengineered) implementation.

These requirements are:

  • Speech bubbles may not overlap
  • Speech bubbles must have a target they can “stick” to
  • There will be multiple (perhaps up to 10) speech bubbles on the screen at the same time. And they may have the same or different targets.

Step 1 - The Basic Setup

First, we need a possibility to interface with our speech bubbles. Since each speech bubble is a island (= relatively isolated relative to the rest of the code base) we can implement this via a factory pattern and inject all important references when creating the speech bubble.

public static SpeechBubble Show(
  string text, 
  GameObject target, 
  Action onHide,
  Action onClick = null,
  float timeoutTime = 0.0f)
{
    SpeechBubble inst = Instantiate(UIController.instance.speechBubble);
    inst.col2D = inst.GetComponent<collider2d>();
    Physics2D.SyncTransforms();
    inst.timeoutTime = 0.0f;
    inst.currentTimeoutTime = 0.0f;
    inst.onHide = onHide;
    inst.onClick = onClick;
    inst.text = text;
    inst.GetComponent<canvas>().worldCamera = UIController.instance.worldUICamera;
            
    if (target != null)
    {
         inst.target = target.transform;
         inst.transform.position = target.transform.position + (Vector3)inst.defaultOffsetToTargetObject;
    }
    return inst;
}

This assumes the UIController singleton to be active but has the benefit that we can easily swap speech bubble prefabs even at runtime (in the editor).

The other half of this architecture is a Prefab-Blueprint (prefab for making prefab variants) which provides all necessary components for our system to work.




When instantiating the prefab, we initialize all needed components (set the text etc.) and the determine a point where to place the speech bubble. For this we will be using our physics solver. 

The outer structure for spawning a speech bubble

I decided not to set the speech bubble position to the determined point instantly but to interpolate towards the point instead. Coupling this with an AnimationCurve results in a juicy spawn-effect!

Step 2 - The Physics Solver Skeleton

In principle, our physics solver is quite simple! For each speech bubble we calculate a force and apply a translation based on this force (we aren’t doing rotations). After a fixed number of iterations, we hope to have found a good solution for the configuration. Basically, there is no guarantee that this will always work. However, this is just the nature of real-time physics. As long as we can consistently avoid bad cases and the average case looks decent, we’re good to go. If you want to read more on this topic, I suggest this article by VirtualMethod.

One modification is that each speech bubble stores an alpha value which decays over time and influences the magnitude of the force. This way, older speech bubbles move less than newer speech bubbles. Along with the interpolation, this makes speech bubbles trackable by the user and prevents a shuffling effect.

No alpha value used
Same parameters with alpha = 0.99

As you can see here, the speech bubbles will still overlap when there are many speech bubbles with the same target. However, in that case the most recent speech bubble will be the most relevant so overlap is ok, as long as the recent speech bubbles stay on top. Here’s a more realistic example of the system in action:

The red dots represent the targets the speech bubbles should stick to

The final caveats for the outer loop of our physics solver are related to the fact that we are relying on Unity physics but want to simulate multiple physics steps in one frame. For this to work, we need to call Physics2D.SyncTransforms after we move each speech bubble. We must also wait for one frame before starting our simulation, so the scene graph is updated with the new collider we instantiated.

The physics solver's skeleton

Step 3 - Calculating Forces

The final piece of the puzzle is how we actually calculate the force for a single speech bubble in a single solver iteration. 

Force calculation. Executed for each speech bubble in each solver iteration

The basis for this is Newton’s law of universal gravitation, but we’re using it to push objects away from each other. The mass of each object is calculated from the extents of the object’s collider.

foreach (Collider2D col in overlaps)
{
     if (col == null)
        continue;
     if (col == speechBubble.col2D)
        continue;
     Vector3 dir = speechBubble.transform.position - col.transform.position;
     float dist = dir.magnitude;
     dist = Mathf.Max(dist, minDist); //need epsilon to avoid dividing by 0
     if (dir == Vector3.zero)
        dir = UnityEngine.Random.insideUnitCircle;
     float den = (col.bounds.extents.magnitude * bounds.extents.magnitude);
     float force = G * den / (dist * dist);
     aggForce += dir.normalized * force;
}

To minimize objects moving into each other, we raycast before moving each object. If we hit an object, we rotate the force vector until we find a free direction. This is the point where this algorithm could be optimized by a significant amount (in both runtime and accuracy), in my opinion. One easy optimization would be to alternate raycast directions sampling in both clockwise and counterclockwise directions. However, a quick test with this approach didn’t show significant improvements to the current code, although I can imagine us refactoring this in the future.

Shooting rays clockwise
Shooting rays in alternating directions

As the final step for force calculation, we simply clamp the magnitude of the rotated force vector to a set constant. Playing around with the different constants gives varying behavior. Here are the settings we’re currently using.

const int collisionSolverMaxSteps = 100;
const int rotationSteps = 18;
const float alpha = 0.99f;
const float minDist = 0.01f;
const float maxForce = 30.0f;
const float G = 10.0f;

Conclusion

All in all, this approach has been promising so far. Implementing the entire system took approximately 3 hours, and while I do think that it will take some more work in the future, we do have a working (and quite juicy!) system which suffices the requirements for our speech bubbles. You can find the source code here.

If you want to stay in touch with us, feel free to join our Discord server, it’s where we will be recruiting playtesters!


Leave a comment

Log in with itch.io to leave a comment.