This is the Asteroids inspired game made with the techniques discussed in this article. Use WASD or cursor keys to move and space bar to fire.
external link to pen here.
After having previously made and published another game using this method, I wanted a working example for this article that could showcase its use in a game while still boiling it down to one sketch. For a full game I would like to add: sound, particle effects for the engine fire and maybe a leaderboard. But for now it proves the concept very well.
Summary
P5 has no way to render a SVG path because of the way it was built, it has no support for drawing an arc as part of a shape. In this article I will explain a way around that, by circuimventing P5 for some of the rendering stuff and defaulting to HTML Canvas instead.
Intro
How I adore vector graphics: it's the clean crisp smoothness of it that gets me. It scales up to any device at any resolution, always perfect crisp graphics every time.
Love it.
Back in the day when the internet was still young there was the flash browser plugin. A ton of content was made with it too, mostly games and animations, and the look and feel of the web at that time was one of vector graphics and gradients.
Steve Jobs single-handedly killed it by publishing an open letter. The mayor security issues that surfaced later did not help either. But I for one miss the look and feel of the early 00's.
So let's put vector graphics back on the web, shall we?
I dabbled with PhaserJS before but lately I am drawn to P5: it is a fun framework, its used to visualize math and physics concepts a lot, not too bloated and has a very mature ecosystem of libraries. Plus there are a lot of examples available.
The problem with rendering SVG's
Here's the conundrum with these frameworks. It's not just P5. When it comes to Vector graphics you will want to work with SVG images. Simply because that is the format that you will find and make your assets in. However, for some reason SVG (path) shapes are poorly supported. Sure you can import it as an image, but the whole point is to animate parts of it, rotate it, scale it. These are the things you will have available when you work with other basic shapes such as rectangle's or circle's. If you want to preserve the vectors you are just out of luck with static images.
But what is an SVG, really other than an XML file that describes positions, colors and shapes. It's code! One of the things that makes them so cool to begin with.
And P5 is all about drawing shapes to a canvas.
Now before anyone makes my mistake of trying to code an implementation in P5, let me save you the hours of frustation. It's not that it can't be done, someone already did it here. It's just that it is insanely complicated: first you need to parse the SVG and its paths. Then you would need to map the commands of the svg path to a single shape. Implementing that will surely make your head spin.
So why doesn't P5 support it? Doing a deepdive into P5's code and you will find that P5 seems to struggle's with the arc shape, under the hood, they are resorting to using bezier curves to emulate drawing an arc.
A very peculiar way of going about it, especially since the Canvas API supports it out of the box. Now P5 uses Canvas by default for its rendering but, similar to PhaserJS, it also supports WebGL too. So I'm assuming it's hard to unify them both for Canvas and WebGL in one framework.
The eureka moment.
Interestingly enough, P5 allows for referring to the Canvas directly.
And HTML Canvas support loading in a entire SVG path by just providing it to Path2D constructor. That is really not complicated at all, take a look at this:
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let p = new Path2D("M10 10 h 80 v 80 h -80 Z");
ctx.fill(p);
It has to be said: going down this route will prevent any chance of utilizing WebGL features later on. Fortunately, programming GL shaders is way past what I'm usually trying to do. So if settling for Canvas sounds good, let's proceed.
Getting the SVG paths.
First we need to create SVG's. I used inkscape and opted to shape everything like a circle. That's not only because I wanted to show off the ability to draw arcs in paths, it's also because collision detection with circles is easy as π.
checkCollision(gameObject) {
const dist = p5.Vector.sub(gameObject.position, this.position);
if (dist.mag() <= gameObject.radius + this.radius) {
this.handleCollision(dist, gameObject);
}
}
P5 does have a library for that very purpose but lets not bother with any of that right now.
A clever way to access the SVG is to just include it in the DOM. So simply pasting them in the HTML document will do. Because were not interested in showing them outside of the canvas, use the following CSS to hide them:
svg { display: none }
P5 comes with the 'select' utility functions to query the DOM, in the setup function:
function getPathsAndColors(svgElements) {
return svgElements.map(el => ({
path: el.getAttribute('d'),
fill: el.getAttribute('fill')
}));
}
//... in global setup function.
const svg = select('#svgElement').elt;
const svgAsteroid = select('#asteroid').elt;
const asteroidShapes = getPathsAndColors([...svgAsteroid.children]);
const craftShapes = getPathsAndColors([...svg.children]);
Note that although we could destructure the path elements from the elements children property; it is not an array but an iterable object. Using the Array.from
method or spread syntax you can still pass it to a function like getPathsAndColors that expects an array and will map the elements.
Now all we need to do is render the paths, first we have to get the canvas context. Make sure this happens in the global Draw function every sketch will have:
const ctx = drawingContext;
Then pass it to wherever the drawing takes place.
draw(ctx) {
//...rotation and scaling logic
this.shapes.forEach(shape => {
const path = new Path2D(shape.path);
const color = shape.fill;
color ? fill(color) : fill(255);
ctx.fill(path);
});
//...
}
That's actually all there is to it, if you just wanted to display an SVG path, but what about scaling, rotating and positioning it?
For this canvas has another nifty feature up it's sleeve: the transformation matrix. It basically allows for scaling and rotating all of the shapes together, without having to calculate all the individual coordinates separately.
Canvas has several methods that apply to the transformation matrix, there is rotate, translate, scale.
But you can also do all of them together in one go by using setTransform.
Seriously, how cool is that?
So first we calculate the scale that is needed. In this case we are scaling down, but unlike raster based graphics you can use any scale without losing image quality. To calculate the scale we need first to know the measurements that were used in the SVG. There usually is a viewBox property present, otherwise 1:1 is assumed. It has 4 values, the first 2 are basically offset values, but the latter indicate width and height. By taking those values in the constructor of the SVGPath class and combining them with the size that is desired, you then have the values that are use full for scaling with the transformation matrix.
const [minX, minY, height, width] = viewBox.split(' ');
this.scaleX = size / width;
this.scaleY = size / height;
Of course all the objects in the example could be heading in any direction, so for that we need to rotate the paths.
By first calculating the cos and sin values from the known angle, then multiplying those with the previously mentioned scale variables, rotation can be achieved.
Lastly, to position the object you can simply put the x and y position as the last two parameters to display the object at that location.
const cos = Math.cos(this.angle);
const sin = Math.sin(this.angle);
const v = createVector(this.width / 2, this.height / 2);
const pos = v.copy().rotate(this.angle).sub(v);
const offset = createVector(this.radius, this.radius);
const p = this.position.copy().sub(offset);
ctx.setTransform(this.scaleX * cos, this.scaleX * -sin, this.scaleY * sin, this.scaleY * cos, p.x, p.y);
ctx.translate(pos.x, pos.y);
Now you may notice there is an extra translation step in the end. Also there is a lot of Vector magic going on, this is because the rotation origin, the center around where the rotation occurs, is located in the far left corner of the object that is being drawn.
It needs to be offset after the rotation is done in order to move the rotation origin to the center.
Perhaps this could also be done in one step. Please do let me know in the comments if you figure it out.
The last thing is always to reset the transformation matrix once we are done with all the shapes that are part of the whole object.
ctx.setTransform(1, 0, 0, 1, 0, 0);
That way we prevent all the transformations getting applied to whatever needs to be rendered next.
And that is basically all there is too it. The shapes that P5 provides can still be used, but I would like to note that you have to be very careful using canvas context to set color as it can mess things up quite a bit if you plan on drawing with P5 later on. It is better to use P5's fill method at all times.
The same goes for starting a shape with P5's beginShape and then trying to use Canvas rendering context methods like arcTo to finish the path started by P5.
Lastly, before I forget: Big shout out to Redhen. His tutorial on creating a snooker simulation helped me out a lot with the physics that were used. Really interesting stuff that is definitely worth checking out.