Rotating all the things all the time
The camera for Wave Racer is fixed on the playable jetski. When a player interacts with the controls, it's not the jetski that moves, but every other point (aka Sprite) in the game.
Since this is a racing game with lots of turning, this means that every sprite is constantly moving and rotating, in order to give the effect of the jetski moving through the world.
As the player rotates the crank, I calculate how much rotation should be applied to the world in terms of an offset. For example, rotate the world by 3 degrees, and not, rotate the world to 177 degrees in some absolute measure. This allows me to place sprites all over the map on setup (buoys & obstacles), having sprites moving on their own accord (ie a CPU controlled jetski) and rotate them all by an offset from the input of the crank.
The steps of the algorithm for rotation, using a buoy for example and with no other movement (eg just rotating the world):
-
Calculate the current distance to the point of rotation. For the game, this is always the center of playable jetski towards the bottom center.
-
Calculate the current angle to the the point of rotation.
-
Figure out the new angle by adding the offset from the crank to the original angle.
-
Move to the new angle at the prior distance.
Here is a slightly modifed version of the function I am using:
function GameState:translate(x, y)
local a = x - self.centerX
local b = y - self.centerY
local rad = math.atan2(b, a)
rad += self.crankPositionRad
local sin = math.sin(rad)
local cos = math.cos(rad)
local dist = math.sqrt(a * a + b * b)
local translatedX = self.centerX + (dist * cos)
local translatedY = self.centerY + (dist * sin)
return translatedX, translatedY, dist
end
All sprites must use this in their update()
to be part of the world:
function sampleSprite:update()
local x, y = s:translate(sampleSprite.x, sampleSprite.y)
sampleSprite:moveTo(x, y)
end
I showed the movement of a buoy above, but this is being applied to all the background tiles, the wakes, and the CPU jetski - literally every sprite!
However, the world is not just rotating, it is also moving as the jetski propels itself.
After applying the rotation, we need to also apply the movement of the jetski to all points.
In the simplest example: moving straight up - we would subtract from the y
axis for all points, to move all points downwards, to give the impression that the jetski is moving upwards.
I have additional logic for handling the speed, momentum, reaction to collisions, and some random choppiness, etc etc.
To combine these with the rotations, I can simply add these to the new x, y coordinates, eg:
local translatedX = self.centerX + (dist * cos) + self.momentumX + self.bumpX + self.choppyX
local translatedY = self.centerY + (dist * sin) + self.momentumY + self.bumpY + self.choppyY
Now, if the jetski is moving, sliding, or got bumped in a collision, all sprites will move accordingly, and in unison, preserving the effect I am after.
Rotating images
As each sprite is being rotated through the world, it's graphical representation also needs to be rotated.
Naively/logically, this could be done via the built-in sprite:setRotation(angle)
- doc
However, I have confirmed the docs do not lie:
This function should be used with discretion, as it’s likely to be slow on the hardware. Consider pre-rendering rotated images for your sprites instead.
So... I am pre-rendering by creating a sprite sheet containing all the rotations of my image, and loading those into an image table. Once I have the angle of rotation for the sprite, I lookup the angle in the image table to get a pre-rotated version of the graphics.
I am using the spriterot utility that came from the playdate community.
Tow great write-ups on this forum that really helped me out:
Here is a snippet of one of the jetski sprite sheets at 2 degree increments:
And here is a reduced version of the code I am running, just to give a sense of how this is used end to end:
local jetskiImageTable = playdate.graphics.imagetable.new("images/image_tables/jetski")
function jetskiSprite:update()
local angle = ... -- omitted for bevity
local image = jetskiImageTable[((angle + 1) // 2) % 180 + 1] -- for 180 degreee steps
jetskiSprite:setImage(image)
end
Challenges
Summing up the approaches above, there are some inherent challenges.
Performance: Every sprite is running through the distance formula and angle calculations shown above, as well as calling :moveTo(x, y)
and :setImage(image)
. Both of these limit the max number of sprites that I can contain in my world before starting to drop frames. I am also generally re-drawing the entire screen.
Memory: My images are simple and small, however needing to create the sprite sheet means that I am ostensibly multiplying the size of the images by 180 or 360. For most game objects I am creating 180 frames, for 2 degree increments, but for the backgrounds, in order to have a smoother effect, I am using single degree increments, so 360 versions of the images.
This makes the image files larger, and also the image tables themselves grow due to needing to have 180 or 360 entries.
Combining the above, I am always mindful of headroom and tradeoffs for all approaches. Some early bottlenecks show that I have roughly 100 sprites to work with total before dropping frames. That might sound like a lot but I have an animated, tiled background, buoys for the course, the (now animated) jetskis, and the trail left behind for the wakes of jetskis.
I will go deeper into perf in another post but right now CPU is sitting around 50% and memory after loading up the image tables is about 11-12mb. I originally wanted more "frames" of animation for the background tiles, but quickly ran out of memory, so the backgrounds are the biggest tradeoff as well, and running into memory issues appears to be my looming bottleneck.
As aside: I am already reducing the sizes of my final images via pngcrush
, and summing up the sizes of all my sprite sheet PNGs, I am not at 1mb, so consuming ~11mb on startup is a little confusing. I think there's more to investigate here about memory consumption, I am hoping I have overlooked something obvious.
I am going to add in more obstacles as I get into the course design: rocks, shores, piers, etc. I still have headroom to fit some in, but I will have to keep these constraints in mind throughout.
That said, I enjoy the constraints, for me it's part of the fun of the Playdate.
Knowing I have a limited budget for memory and CPU clock time in each frame makes me really mindful about any pieces of functionality or features I add, which I hope will lead to not only a better game, but one I might "finish" some day since there isn't a world in infinite possibilities to pursue.
Finally, I see some truly impressive games being built for the playdate: physics, 3d, beautiful imagery, etc. There's likely a macro approach or rookie mistakes I am making, but I get the sense I am also not taking the "easy" path by going for a fully rotated game.