Basic Physics Example
Example Links
How to set up a simple physics simulation
This example shows how to set up and run a simple physics simulation in Indigo, and render the result.
Step 1: Define a tag for each type of object in the simulation
Indigo's physics engine uses tags to identify different types of objects in the simulation. They are custom tags that you define and can be any type, such as a simepl String, but an ADT / enum is recommended.
Tags are used to identify objects in the simulations, such as how they interact with one another, e.g. objects of tag x do not collide with objects of tag y, and also to help you when it comes to rendering and querying the simulation to find out what happened.
In this example, we define two tags: StaticCircle
and Ball
. Static circles just sit there
and balls are bouncy and subject to gravity.
enum MyTag:
case StaticCircle
case Ball
Step 2: Define a model for the simulation
This is the model for our game, and in this case mostly just holds the World
instance.
The World
is the physics simulation itself, and is where all the magic happens. The key thing
to appreciate about the World is that unlike in other game engines, the World is not mixed up
with the rest of the model or any of the view code. It is an entirely self-contained simultation
of 'colliders'.
When you want to render something in the world, you query the world for the colliders you are
interested in and then render them however you like. That isn't quite what we'll do in this
demo, as the world also has a present
method that can use to render everything in the
simulatation. Generally using the world present
isn't the intended use, but it is very useful
for debugging since it allows you to 'see' where all the colliders are in relation to your
normal view elements.
A quick note on the 'running' Boolean. This isn't just convenient, it serves a practical purpose in this demo. Normally, you wouldn't be running a simulation the instant you start a game, you'd probably at least show a title screen. Since we're going straight into it, the game will 'start' before everything is really, and it will start with an initially volatile framerate, throwing the simulation off. To get around this, we'll pause the simulation until the user presses SPACE.
final case class Model(running: Boolean, world: World[MyTag])
We'll initialise the model and the world in the companion object for the model.
The world is initialised in an empty state with a given size. We then add some forces to simulate gravity in "units per second", units in this case being pixels. The simulation does not care what scale you want to model it at, and often it helps to use a 'world space' that suits you domain model. In this case we've made the size the same as the game screen size, so the units are 1:1 with pixels, which makes rendering simpler.
We also add some resistance to the simulation, which will slow down the balls over time but causing them to globally lose energy.
Elements within the simulation are known as 'colliders'. Indigo only supports two types of collider at present: circles and boxes, and both are 'axis aligned', meaning they do not rotate. Limited, but good enough for many games, we hope to expand on this later.
Colliders have many properties, but the two we care about here are makeStatic
and restitution.
makeStatic
is a method that tells the simulation that the collider does not move, and that no
calculation is required for it. The obvious use in a video game is for walls and floors, but you
can also use it for elements that, for example, a user is dragging / moving that affect the
world.
Restitution determines how bouncy an object is. A value of 1.0 means the object will bounce back perfectly, here we've set it to 0.8 so that it's bouncy, but loses energy with each impact.
object Model:
def initial: Model =
Model(false, world)
def world: World[MyTag] =
val staticCircles =
(0 to 8).toBatch.map { i =>
Collider(
MyTag.StaticCircle,
BoundingCircle(i.toDouble * 100 + 30, 200.0, 20.0)
).makeStatic
}
val balls =
(0 to 15).toBatch.map { i =>
Collider(
MyTag.Ball,
BoundingCircle(
i.toDouble * 50 + 28,
(if i % 2 == 0 then 20 else 40) + i.toDouble * 2,
15.0
)
)
.withRestitution(Restitution(0.8))
}
World
.empty[MyTag](SimulationSettings(BoundingBox(0, 0, 800, 600)))
.addForces(Vector2(0, 600))
.withResistance(Resistance(0.01))
.withColliders(staticCircles)
.addColliders(
Collider(MyTag.StaticCircle, BoundingCircle(-100.0d, 700.0, 300.0)).makeStatic,
Collider(MyTag.StaticCircle, BoundingCircle(900.0d, 700.0, 300.0)).makeStatic,
Collider(MyTag.StaticCircle, BoundingCircle(400.0d, 800.0, 300.0)).makeStatic
)
.addColliders(balls)
Step 3: Defining the rest of the game.
We'll need to actually initialise our model.
def initialModel(startupData: Unit): Outcome[Model] =
Outcome(Model.initial)
Then during update, if the game is running, update the simulation by supplying the frame delta time, and updating the model with the new version of the world.
def updateModel(context: Context[Unit], model: Model): GlobalEvent => Outcome[Model] =
case FrameTick if model.running =>
model.world
.update(context.frame.time.delta)
.map(updated => model.copy(world = updated))
case KeyboardEvent.KeyUp(Key.SPACE) =>
Outcome(model.copy(running = !model.running))
case _ =>
Outcome(model)
Finally we'll present the simulation using the world.present
function, which will
conveniently allow us to render all the colliders in the simulation. In this case we'll
produce a Batch
of Shapes
for ease.
Below that, the flashing text is created using a Signal
that pulses every second and renders
the text, or not.
def present(context: Context[Unit], model: Model): Outcome[SceneUpdateFragment] =
Outcome(
SceneUpdateFragment(
model.world.present {
case Collider.Circle(_, bounds, _, _, _, _, _, _, _, _) =>
Shape.Circle(
bounds.position.toPoint,
bounds.radius.toInt,
Fill.Color(RGBA.White.withAlpha(0.2)),
Stroke(1, RGBA.White)
)
case Collider.Box(_, bounds, _, _, _, _, _, _, _, _) =>
Shape.Box(
bounds.toRectangle,
Fill.Color(RGBA.White.withAlpha(0.2)),
Stroke(1, RGBA.White)
)
} ++
Signal
.Pulse(1.seconds)
.map { show =>
if show then
val bounds = context.services.bounds
.measureText(message)
Batch(
message
.withSize(bounds.size)
.moveTo((800 - bounds.width) / 2, 600 - bounds.height - 10)
)
else Batch.empty
}
.at(context.frame.time.running)
)
)