Skip to content

Tutorial 01: Basic collisions

NightGhost edited this page Jul 30, 2021 · 2 revisions

Welcome to the Cirno tutorial series that teaches how to use the library to detect and resolve collisions between geometric primitives (aka hitboxes).

Initial setup

I will use Pixel for graphics rendering because it's convenient for me. Below is the template that will be used in this tutorial and in the further ones.

package main

import (
	"fmt"
	"time"

	"github.com/faiface/pixel"
	"github.com/faiface/pixel/pixelgl"
	colors "golang.org/x/image/colornames"
)

const (
	width  = 1280
	height = 720
)

func run() {
	// Create a new window.
	cfg := pixelgl.WindowConfig{
		Title:  "Cirno tutorial",
		Bounds: pixel.R(0, 0, width, height),
	}
	win, err := pixelgl.NewWindow(cfg)
	handleError(err)

	// Setup metrics.
	last := time.Now()
	fps := 0
	perSecond := time.Tick(time.Second)

	for !win.Closed() {
		deltaTime := time.Since(last).Seconds()
		last = time.Now()

		win.Clear(colors.White)

		win.Update()

		fps++

		select {
		case <-perSecond:
			win.SetTitle(fmt.Sprintf("%s | FPS: %d | dt: %f",
				cfg.Title, fps, deltaTime))
			fps = 0

		default:
		}
	}
}

func main() {
	pixelgl.Run(run)
}

func handleError(err error) {
	if err != nil {
		panic(err)
	}
}

This code just casually creates a 1280x720 window to draw graphics objects in it.

image not found

Creating shapes

All the geometric primitives for creating hitboxes are called shapes in Cirno. There are three types of shapes:

Each of these types implements the Shape interface so here is a list of actions you can perform on shapes:

  • some affine transformations (translation and rotation only, no scaling);
  • assigning and checking tags for collision (the tag system will be explained later in the series);
  • assigning and getting data (usually it's a game object the shape is connected to);
  • computing normals between shapes (will be explained further as well);
  • checking for collisions (overlaps) between shapes;
  • checking for contact points between shapes;
  • adding shapes to a space.

The first shape we are going to create is a rectangle:

rect, err := cirno.NewRectangle(
	cirno.NewVector(540, 460), 120, 60, 30.0)
handleError(err)

NewRectangle takes 4 arguments:

  • the coordinates of the rectangle center of type Vector that is a representation of geometric 2D vector;
  • rectangle width of type float64;
  • rectangle height of type float64;
  • rotation angle in degrees of type float64.

The distinctive feature of OBB is that it has an orientation and can be rotated. Unlike OBBs, AABBs (Axis-Aligned bounding boxes) cannot be rotated and their sides are always aligned with the xOy coordinate axes. But they have faster collision checking as an advantage so that's why AABBs are used in Cirno for quad tree quadrants. As for the API, it only provides access to OBB (which is cirno.Rectangle).

Now let's create a circle:

circle, err := cirno.NewCircle(
	cirno.NewVector(350, 250), 50)
handleError(err)

It's constructor takes only 2 arguments:

  • the coordinates of the center as a vector;
  • the radius of the circle as a float64.

There is no sense to rotate circle so rotation methods do nothing on it.

The last thing we'd want to create is a line:

line, err := cirno.NewLine(
	cirno.NewVector(800, 200),
	cirno.NewVector(1200, 400))
handleError(err)

The line constructor takes coordinates of the line segment ends as arguments.

In games shapes are connected to game objects. For example, a round bullet has a circle type hitbox so the hitbox should have a link to the object it's connected to. But in our case there are no game objects. What we really need is to draw shapes themselves. So let's just specify the color:

rect.SetData(colors.Red)
circle.SetData(colors.Red)
line.SetData(colors.Red)

You can specify literally anything as shape data but of course you'll have to do type assertion upon data extraction:

data, ok := rect.Data().(color.RGBA)

So it's better to be consistent in what you store as shape data.

Now let's add all the newly created shapes to a slice:

shapes := []cirno.Shape{rect, circle, line}

To draw them, we'll use a special utility from Pixel called IMDraw (pixel/imdraw needs to be imported).

imd := imdraw.New(nil)

Here's the code of the procedure that asserts the type of the shape and obtains all the necessary information required for the shape to be drawn on the window surface:

// drawShapes draws all the specified shapes
// on the IMDraw canvas.
func drawShapes(imd *imdraw.IMDraw, shapes []cirno.Shape) {
	for _, shape := range shapes {
		imd.Color = shape.Data().(color.RGBA)

		switch obj := shape.(type) {
		case *cirno.Line:
			// P is the one end of the line segment,
			// and Q is the other.
			imd.Push(
				pixel.V(obj.P().X, obj.P().Y),
				pixel.V(obj.Q().X, obj.Q().Y),
			)
			imd.Line(2)

		case *cirno.Circle:
			imd.Push(pixel.V(obj.Center().X,
				obj.Center().Y))
			imd.Circle(obj.Radius(), 0)

		case *cirno.Rectangle:
			// Obtain the vertices of the rectangle.
			vertices := obj.Vertices()

			imd.Push(
				pixel.V(vertices[0].X, vertices[0].Y),
				pixel.V(vertices[1].X, vertices[1].Y),
				pixel.V(vertices[2].X, vertices[2].Y),
				pixel.V(vertices[3].X, vertices[3].Y),
			)
			imd.Polygon(0)
		}
	}
}

I won't tell how IMDraw works. It's out of the tutorial's scope. If you want to know more about it, just check the corresponding article on Pixel wiki. Finally, we can draw our shapes.

win.Clear(colors.White)

imd.Clear()
drawShapes(imd, shapes)
imd.Draw(win)

win.Update()

If you did everything properly, you should see something like this on the screen:

image not found

The next step is to allow the user to choose a shape to control it. It can be implemented with command line flags. We'll need to add this code:

const (
	width     = 1280
	height    = 720
	moveSpeed = 400
	turnSpeed = 400
)

var (
	controlledShape string
)

// parseFlags parses the command line flags
// and stores their values in global variables.
func parseFlags() {
	flag.StringVar(&controlledShape, "shape", "circle",
		"The shape controlled during execution of the demo.")

	flag.Parse()
}

func main() {
	parseFlags()
	pixelgl.Run(run)
}

And after the shapes creation code we need to pick a shape depending on user's preference:

// Choose a shape to control.
var ctrlShape cirno.Shape

switch controlledShape {
case "circle":
	ctrlShape = circle

case "line":
	ctrlShape = line

case "rectangle":
	ctrlShape = rect

default:
	fmt.Println("error: unknown shape type")
	os.Exit(1)
}

Now it's time to read user inputs to move and turn the controlled shape. Place this code after win.Clear() call:

// Movement.
movement := cirno.Zero()

if win.Pressed(pixelgl.KeyUp) {
	movement = movement.Add(cirno.Up())
}

if win.Pressed(pixelgl.KeyDown) {
	movement = movement.Add(cirno.Down())
}

if win.Pressed(pixelgl.KeyLeft) {
	movement = movement.Add(cirno.Left())
}

if win.Pressed(pixelgl.KeyRight) {
	movement = movement.Add(cirno.Right())
}

// Turn.
turn := 0.0

if win.Pressed(pixelgl.KeyW) {
	turn++
}

if win.Pressed(pixelgl.KeyX) {
	turn--
}

if movement != cirno.Zero() || turn != 0.0 {
	movement = movement.MultiplyByScalar(moveSpeed * deltaTime)
	turn = turn * turnSpeed * deltaTime

	ctrlShape.Move(movement)
	ctrlShape.Rotate(turn)
}

cirno.Zero() returns the zero vector with components {0.0; 0.0}. cirno.Up() returns the vector with components {0.0; 1.0}, cirno.Left() returns the vector with components {-1.0; 0.0} and so on. Vectors can be added using Add() function. To multiply vector by some float64 number, use MultiplyByScalar function of the Vector type. Move() moves the shape in the specified direction and returns its new position. Rotate() rotates the shape at the specified angle in degrees and returns its new orientation (circle's orientation is always 0.0).

Now the user is able to move and rotate any shape he picked (circle by default). Also please pay attention to deltaTime. All the activities that last more than one frame use this value to make gameplay independent from FPS. That's why movement and turn are both multiplied by deltaTime.

If the user can move shapes, they can be overlapped by each other which means we need to detect it. Let's make all the non-overlapping shapes red and all the overlapping shapes green. Just place this code after the movement and rotation section:

// Detect collisions.
length := len(shapes)
collidingShapes := cirno.Shapes{}

for _, shape := range shapes {
	shape.SetData(colors.Red)
}

for i := 0; i < length-1; i++ {
	for j := i + 1; j < length; j++ {
		overlap, err := cirno.ResolveCollision(
			shapes[i], shapes[j], false)
		handleError(err)

		if overlap {
			collidingShapes.Insert(
				shapes[i], shapes[j])
		}
	}
}

for shape := range collidingShapes {
	shape.SetData(colors.Green)
}

First we create a hash set for colliding shapes using cirno.Shapes type. Then in the first loop we make all the shapes red assuming none of them are overlapping with each other. In the second loop we check all the possible combinations of shapes omitting duplicates (shapes[i] and shapes[j] is pretty much the same as shapes[j] and shapes[i], the order is not important in the current case). To check two shapes for collision, we call cirno.ResolveCollision() for them. It takes shapes as first 2 arguments. The third argument is not essential for the present tutorial so just leave it false for now. cirno.ResolveCollision() asserts shape types and calls the necessary underlying method to check if the shapes are overlapping. In the aforecited code both overlapping shapes are added in the collidingShapes hash set. At last in the third loop all the colliding shapes are painted green. Now you can observe something like this:

image not found

The underlying methods for collision checking used by cirno.ResolveCollision() are cirno.CollisionRectangleToCircle(), cirno.CollisionRectangleToCircle(), cirno.IntersectionLineToLine(), etc. You can call them explicitly if you need it. All of them only take shapes of corresponding types as arguments and don't have the third parameter.

As you can see, handling collisions with Cirno is not hard at all. In a real world example the shapes we performed operations on could be colliders, triggers, hitboxes, etc. Of course, there is much more Cirno can do. What described here is just basic stuff. You are welcome to look at the subsequent tutorials. The full source code is hidden under the spoiler below.

Source code
package main

import (
  "flag"
  "fmt"
  "image/color"
  "os"
  "time"

  "github.com/faiface/pixel"
  "github.com/faiface/pixel/imdraw"
  "github.com/faiface/pixel/pixelgl"
  "github.com/zergon321/cirno"
  colors "golang.org/x/image/colornames"
)

const (
  width     = 1280
  height    = 720
  moveSpeed = 400
  turnSpeed = 400
)

var (
  controlledShape string
)

// parseFlags parses the command line flags
// and stores their values in global variables.
func parseFlags() {
  flag.StringVar(&controlledShape, "shape", "circle",
  	"The shape controlled during execution of the demo.")

  flag.Parse()
}

// drawShapes draws all the specified shapes
// on the IMDraw canvas.
func drawShapes(imd *imdraw.IMDraw, shapes []cirno.Shape) {
  for _, shape := range shapes {
  	imd.Color = shape.Data().(color.RGBA)

  	switch obj := shape.(type) {
  	case *cirno.Line:
  		// P is the one end of the line segment,
  		// and Q is the other.
  		imd.Push(
  			pixel.V(obj.P().X, obj.P().Y),
  			pixel.V(obj.Q().X, obj.Q().Y),
  		)
  		imd.Line(2)

  	case *cirno.Circle:
  		imd.Push(pixel.V(obj.Center().X,
  			obj.Center().Y))
  		imd.Circle(obj.Radius(), 0)

  	case *cirno.Rectangle:
  		// Obtain the vertices of the rectangle.
  		vertices := obj.Vertices()

  		imd.Push(
  			pixel.V(vertices[0].X, vertices[0].Y),
  			pixel.V(vertices[1].X, vertices[1].Y),
  			pixel.V(vertices[2].X, vertices[2].Y),
  			pixel.V(vertices[3].X, vertices[3].Y),
  		)
  		imd.Polygon(0)
  	}
  }
}

func run() {
  // Create a new window.
  cfg := pixelgl.WindowConfig{
  	Title:  "Cirno tutorial",
  	Bounds: pixel.R(0, 0, width, height),
  }
  win, err := pixelgl.NewWindow(cfg)
  handleError(err)

  // Setup physics.
  rect, err := cirno.NewRectangle(
  	cirno.NewVector(540, 460), 120, 60, 30.0)
  handleError(err)
  circle, err := cirno.NewCircle(
  	cirno.NewVector(350, 250), 50)
  handleError(err)
  line, err := cirno.NewLine(
  	cirno.NewVector(800, 200),
  	cirno.NewVector(1200, 400))
  handleError(err)

  rect.SetData(colors.Red)
  circle.SetData(colors.Red)
  line.SetData(colors.Red)

  shapes := []cirno.Shape{rect, circle, line}

  // Choose a shape to control.
  var ctrlShape cirno.Shape

  switch controlledShape {
  case "circle":
  	ctrlShape = circle

  case "line":
  	ctrlShape = line

  case "rectangle":
  	ctrlShape = rect

  default:
  	fmt.Println("error: unknown shape type")
  	os.Exit(1)
  }

  // IMDraw to render shapes.
  imd := imdraw.New(nil)

  // Setup metrics.
  last := time.Now()
  fps := 0
  perSecond := time.Tick(time.Second)

  for !win.Closed() {
  	deltaTime := time.Since(last).Seconds()
  	last = time.Now()

  	win.Clear(colors.White)

  	// Movement.
  	movement := cirno.Zero()

  	if win.Pressed(pixelgl.KeyUp) {
  		movement = movement.Add(cirno.Up())
  	}

  	if win.Pressed(pixelgl.KeyDown) {
  		movement = movement.Add(cirno.Down())
  	}

  	if win.Pressed(pixelgl.KeyLeft) {
  		movement = movement.Add(cirno.Left())
  	}

  	if win.Pressed(pixelgl.KeyRight) {
  		movement = movement.Add(cirno.Right())
  	}

  	// Turn.
  	turn := 0.0

  	if win.Pressed(pixelgl.KeyW) {
  		turn++
  	}

  	if win.Pressed(pixelgl.KeyX) {
  		turn--
  	}

  	if movement != cirno.Zero() || turn != 0.0 {
  		movement = movement.MultiplyByScalar(moveSpeed * deltaTime)
  		turn = turn * turnSpeed * deltaTime

  		ctrlShape.Move(movement)
  		ctrlShape.Rotate(turn)
  	}

  	// Detect collisions.
  	length := len(shapes)
  	collidingShapes := cirno.Shapes{}

  	for _, shape := range shapes {
  		shape.SetData(colors.Red)
  	}

  	for i := 0; i < length-1; i++ {
  		for j := i + 1; j < length; j++ {
  			overlap, err := cirno.ResolveCollision(
  				shapes[i], shapes[j], false)
  			handleError(err)

  			if overlap {
  				collidingShapes.Insert(
  					shapes[i], shapes[j])
  			}
  		}
  	}

  	for shape := range collidingShapes {
  		shape.SetData(colors.Green)
  	}

  	// Draw shapes.
  	imd.Clear()
  	drawShapes(imd, shapes)
  	imd.Draw(win)

  	win.Update()

  	fps++

  	select {
  	case <-perSecond:
  		win.SetTitle(fmt.Sprintf("%s | FPS: %d | dt: %f",
  			cfg.Title, fps, deltaTime))
  		fps = 0

  	default:
  	}
  }
}

func main() {
  parseFlags()
  pixelgl.Run(run)
}

func handleError(err error) {
  if err != nil {
  	panic(err)
  }
}
Clone this wiki locally