Keeping a sprite within bounds of the display?

,

I have a simple game right now with a sprite that you can move with the d-pad and rotate with the crank. I'm curious if there's a better way to keep the sprite within the bounds of the display than just checking x and y coordinates when button input is captured?

My main concern is that once the sprite is rotated, height and width of the sprite should no longer be true to what they were originally (is that right?) since it'd be at an angle now.

The collision API seems incorrect since the sprite is already within the display rect.

Thoughts on a better path here?

I'm on a Mac, using Nova and Lua. Thanks!

It sounds like you need to know the bounding box of a rotated rectangle. I find that ChatGPT is pretty good at solving these types of small problems. I haven't tested this code, but here is what it gave me. It assumes the sprite is rotated from the top-left corner, but I don't know if you are rotating from the center point or some other pivot.

Prompt: Write a function in Lua that calculates the bounding box of a rotated rectangle, given an x, y, width, height, and angle in degrees.

Answer: Certainly! Here is a function that calculates the bounding box of a rotated rectangle in Lua:

function boundingBox(x, y, w, h, angle)
  local function rotate(px, py, ox, oy, angle)
    local radians = math.rad(angle)
    local s, c = math.sin(radians), math.cos(radians)
    return ox + (px - ox) * c - (py - oy) * s, oy + (px - ox) * s + (py - oy) * c
  end

  local x1, y1 = rotate(x, y, x, y, angle)
  local x2, y2 = rotate(x + w, y, x, y, angle)
  local x3, y3 = rotate(x, y + h, x, y, angle)
  local x4, y4 = rotate(x + w, y + h, x, y, angle)

  local minX = math.min(x1, x2, x3, x4)
  local maxX = math.max(x1, x2, x3, x4)
  local minY = math.min(y1, y2, y3, y4)
  local maxY = math.max(y1, y2, y3, y4)

  return minX, maxX, minY, maxY
end

This function takes the following arguments:

  • x : The x-coordinate of the rectangle's top-left corner
  • y : The y-coordinate of the rectangle's top-left corner
  • w : The width of the rectangle
  • h : The height of the rectangle
  • angle : The angle of rotation in degrees

It returns a tuple of four values: the minimum x-coordinate, the maximum x-coordinate, the minimum y-coordinate, and the maximum y-coordinate of the bounding box.

For example, you could use this function like this:

local minX, maxX, minY, maxY = boundingBox(10, 20, 30, 40, 45)

This would calculate the bounding box of a rectangle with top-left corner at (10, 20), width 30, height 40, and 45 degrees of rotation. The function would return the minimum x-coordinate, maximum x-coordinate, minimum y-coordinate, and maximum y-coordinate of the bounding box.

Here is another solution for a sprite rotated around the center instead of the top-left.

Prompt: Write a function in Lua that calculates the bounding box of a rotated rectangle, given an x, y, width, height, and angle in degrees. The x and y variables are the center point of the rectangle. The rectangle is rotated around the center point.

Solution: Here is a function that calculates the bounding box of a rotated rectangle in Lua:

function bounding_box(x, y, width, height, angle)
  local radians = math.rad(angle)
  local sin = math.sin(radians)
  local cos = math.cos(radians)

  local x1 = x + cos * width / 2 - sin * height / 2
  local y1 = y + sin * width / 2 + cos * height / 2
  local x2 = x + cos * width / 2 + sin * height / 2
  local y2 = y - sin * width / 2 + cos * height / 2
  local x3 = x - cos * width / 2 + sin * height / 2
  local y3 = y - sin * width / 2 - cos * height / 2
  local x4 = x - cos * width / 2 - sin * height / 2
  local y4 = y + sin * width / 2 - cos * height / 2

  local min_x = math.min(x1, x2, x3, x4)
  local min_y = math.min(y1, y2, y3, y4)
  local max_x = math.max(x1, x2, x3, x4)
  local max_y = math.max(y1, y2, y3, y4)

  return {
    x = min_x,
    y = min_y,
    width = max_x - min_x,
    height = max_y - min_y
  }
end

This function takes in the x and y coordinates of the center of the rectangle, the width and height of the rectangle, and the angle in degrees. It returns a table with the x , y , width , and height of the bounding box.

This is the simplest way. Even though it feels naive, checking coordinates is very quick for the CPU.

Another way might be positioning rectangles off-screen at each edge and then doing a collision check. This would be slower (but not slow) and it might make more sense in some circumstances.

You could also calculate the sprite bounding box and check that against the screen bounds. Timhei functions would help there.

2 Likes

This is great. Thank you! It amazes me what ChatGPT can do.

Thanks, Matt! As I said, I just wanted to make sure I wasn’t missing some API or convenience function (I know the life of re-inventing wheels, lol). :slight_smile:

1 Like

Just for fun I decided to see if ChatGPT's function worked, and it does! Although I did optimize it a little bit by caching some of the repeated calculations. As long as it's rotating around the center, it works for any arbitrary sprite size. Be aware though that realtime rotation of sprites is very slow on Playdate, so it would be better to pre-render the rotations.

--main imports
import "CoreLibs/object"
import "CoreLibs/graphics"
import "CoreLibs/sprites"

--declarations
local gfx <const> = playdate.graphics
local sprite
local x = 200
local y = 120
local width = 64
local height = 128
local angle = 0

local function setup()
	
	playdate.display.setRefreshRate(50)

	image = gfx.image.new(width, height, gfx.kColorBlack)
	sprite = gfx.sprite.new(image)
	sprite:setCenter(0.5, 0.5)
	sprite:add()
	
end

setup()

function playdate.update()

	if playdate.buttonIsPressed(playdate.kButtonLeft) then
		x -= 5
	end
	if playdate.buttonIsPressed(playdate.kButtonRight) then
		x += 5
	end
	if playdate.buttonIsPressed(playdate.kButtonUp) then
		y -= 5
	end
	if playdate.buttonIsPressed(playdate.kButtonDown) then
		y += 5
	end

	angle = playdate.getCrankPosition()

	sprite:setRotation(angle)

	local bounds = bounding_box(x, y, width, height, angle)

	if bounds.x1 < 0 then x -= bounds.x1 end
	if bounds.y1 < 0 then y -= bounds.y1 end
	if bounds.x2 > 400 then x -= bounds.x2 - 400 end
	if bounds.y2 > 240 then y -= bounds.y2 - 240 end

	sprite:moveTo(x, y)

	gfx.sprite.update()
	playdate.drawFPS(0, 0)
end

function bounding_box(x, y, width, height, angle)
	local radians = math.rad(angle)
	local sin = math.sin(radians)
	local cos = math.cos(radians)

	local sinWidth = sin * width / 2
	local sinHeight = sin * height / 2
	local cosWidth = cos * width / 2
	local cosHeight = cos * height / 2
  
	local x1 = x + cosWidth - sinHeight
	local y1 = y + sinWidth + cosHeight
	local x2 = x + cosWidth + sinHeight
	local y2 = y - sinWidth + cosHeight
	local x3 = x - cosWidth + sinHeight
	local y3 = y - sinWidth - cosHeight
	local x4 = x - cosWidth - sinHeight
	local y4 = y + sinWidth - cosHeight
  
	return {
	  x1 = math.min(x1, x2, x3, x4),
	  y1 = math.min(y1, y2, y3, y4),
	  x2 = math.max(x1, x2, x3, x4),
	  y2 = math.max(y1, y2, y3, y4)
	}
end

playdate-20230105-112619

5 Likes

This is awesome!! Thanks much.

To clarify the point on pre-rendering, are you saying instead of rotating in real-time, I should swap pre-rendered sprites in correlation to the rotation angle of the crank? Or cache system created renders and use those? Just thinking of the smoothness.

There are multiple ways of pre-rendering. You could cache it on device when the game first launches, although that might increase the loading time a little bit. Or you could cache them all in a sprite sheet using an editing software like Aseprite or Photoshop.

It also depends on how often you are having to rotate and how much else you have going on in the project. For example, that rotating rectangle demo I posted will run at 50 fps on device, but when I rotate it quickly the CPU usage gets up to around 50%. When the angle doesn't change between frames, it seems like the sprite automatically caches the rotation so it won't recalculate the rotation until it changes.

As far as smoothness goes, nothing will beat rotating in real-time. But unfortunately you will usually have to compromise and use a set amount of rotated sprites instead. If your project has enough CPU overhead to allow for it, or if the images are low resolution, you might be able to get away with real-time rotations.

1 Like

Super thorough response. Thank you!!

The game I have in mind will ultimately focus around the rotation of one sprite, so I'll likely build it in real-time, and then see how perf goes as I add in the other programmatic pieces that are moving around it.

Really appreciate your in-depth answers!

Data point: I render my cars in my build process, the result is a massive image per car.

On device I could run 360 rotations and memory, load time and everything was still fine nowhere near the limits. I scaled it back to 90 after checking how many of the 360 frames were used in an average play session.

90 rotation steps balances batch rendering time and smoothness of rotation in game. Car has 1980 frames for body and the same again for the shadow.