Simple 8x8 Monochrome Sprite Editor in Python with Pygame
(This is a fairly casual post going over a 2 to 3 hour application I made to solve a very specific and narrow problem.)
You might wonder why anyone would want a sprite editor confined specifically to 8x8 monochrome sprites. I have two answers for you https://www.adafruit.com/product/4440, and https://www.adafruit.com/product/326. Adafruit sells two monochrome OLED displays, and they both use the SSD1306 OLED display driver, which arranges the bits of each byte of image data vertically and the bytes themselves horizontally. If you don't have any experience writing drivers for this or a similar monochrome display driver, it might not be obvious why this is relevant. The answer is that it dramatically impacts how image data needs to be arranged in memory, and that can make it a challenge when using other pixel image editors. Specifically, it's much easier and faster to work with images that are 8 pixels tall than any other size, and heights that are multiples of 8 are better than heights that are not. The width is arbitrary, but because the dimensions of Adafruit's displays are powers of 2 (and thus multiples of 8), 8x8 makes especially good sense, especially for fonts but also for sprites and tile graphics. The main reason I decided to write this program is that I couldn't find a good sprite editor with native monochrome support or C array output. I know they exist (from prior experience), but when you can write the program yourself, customized to your own needs, in around 2 hours, why bother potentially spending far more time searching than it would take to just make one?
Anyhow, the point of this tutorial isn't just to make a useful tool. Maybe you aren't ever going to use an SSD1306 driven monochrome display. Maybe you are, but you'll use a preexisting driver that handles the transforms behind the scenes. Maybe you've found one of the pixel editors I didn't. But if you are interested in learning to use Pygame, this tutorial might still be of some value to you. It's also a fun little project that can be done in a few hours and produce a practical application. It's not as good as an actual video game, but it's a potentially useful tool for making video games!
If you don't already have them, you'll need to install Python 3 and Pygame. If you are in Windows, pay close attention during installation and make sure to select the box for putting Python on the system PATH. If you miss this step, it's a bit of a pain to fix. If you've installed Python correctly, the easiest way to install Pygame is to open a terminal/command line and run pip install pygame
.
The first thing we need when using Pygame is to import the pygame
module. The Python style guide encourages separating imports for built-in modules, imports for 3rd party modules, and imports for your own submodules, by putting them in that order, separated by an empty line, to make your code more readable. So leave a few empty lines at the top of the program, in case we need to add a built-in module later. (Yes, this is foreshadowing.) Now write import pygame
.
The next step is initializing Pygame and setting up our window. It's not absolutely necessary to initialize Pygame explicitly, as the different submodules will generally initialize themselves upon first use, but I prefer to do it, so that I know everything I might need to use will be ready once we get there. We do this with pygame.init()
. Opening (or resetting) the window is done with pygame.display.set_mode()
, where the first argument is an iterable (tuple or list) containing the desired width and height of the window in pixels. I'm not going to get too deep into the UI design process here, but I decided that I want the bottom end of the window to be an 8x8 square grid where you do the actual editing, and the top to be an information/control region. So I made the window 400 pixels wide and 512 pixels tall. I also used pygame.display.set_caption()
to set the window title bar text to "Monochrome 8x8 Sprite Editor". Note that pygame.display.set_mode()
has a ton of additional options that we don't need to use here. You can learn more from Pygame's fairly high quality documentation. Here's what the start of our program looks like now.
import pygame
pygame.init()
window = pygame.display.set_mode((400, 512))
pygame.display.set_caption("Monochrome 8x8 Sprite Editor")
If you run this now, it will open a window and then immediately close and exit the program. To keep the window, we need a "game loop". (Pygame is designed for making video games, and this program is going to use common game programming patterns, even though it isn't actually a video game.) The game loop goes at the end of the file. Make some space between the above code and the game loop, because we will be putting some more initialization code, a class definition, and some functions there. The game loop should start with a run variable set to True
just outside of the loop, which we will use to control the loop. (I always name my run variable run
.) While we could just call sys.exit()
inside the loop when we want to exit the program, it generally results in a smoother exit if we exit the game loop and handle tear down outside of the loop before exiting. This also helps keep the game loop from getting cluttered. The loop itself is a simple while loop on our run variable. Before we try running this, the game loop needs a way to exit. Pygame doesn't like being forced to exit, so it's best for the program to terminate itself gracefully rather than being forced to terminate.
For this we need to learn how to do event handling. Pygame has an event module, pygame.event
that contains all of the event handling tools. If we don't handle events, the event queue will get full, and the operating system will assume the program has become unresponsive. Depending on the OS, it may eventually forcibly terminate the program, so even if we aren't using the events, we need to at least read them off of the event queue. This is done with pygame.event.get()
, which returns a list of all events currently on the queue, emptying the event queue in the process. We do want to use the events, so we will put this in a for
loop, so we can go through checking what the events are. Right now we only care about one type of event, those with the type
attribute set to pygame.QUIT
. Events of this type will be triggered by things like clicking the “X” button in the corner of the window or pressing Alt-F4, though this is highly OS dependent. (For example, older versions of Windows did not send a QUIT event for Alt+F4 (a keyboard shortcut that originated with Windows), but Linux did. In many Linux window managers, Alt+Q will issue a QUIT event as well.) The event objects in the list returned have a .type
attribute which we can test to see if the event is of type pygame.QUIT
or not. If we find such an event, we set our run variable to False
, causing the loop to terminate and ending our program. After the loop, let's call pygame.quit()
to deinitialize. Again, Pygame can handle anything critical itself, but it's still a good idea to do it explicitly. Here's what all of this looks like.
run = True
while run:
for e in pygame.event.get():
if e.type == pygame.QUIT:
run = False
pygame.quit()
Now when we run the program, our window will open, and then it will just wait for us to tell it to exit. Now, you might wonder about events of other types. What happens with them? Our code currently just ignores them. We don't have to respond to all events; we merely have to take them off of the event queue in a timely manner. OSes issue a lot of events, in case we care about them, and we only respond to the ones we care about. Events include things like key presses, key releases, mouse motion, mouse button presses and releases, moving the window, resizing the window (if it is allowed), and a ton of other things, most of which we don't care about most of the time.
What we have now is the most bare bones Pygame program. You can use this as a template for practically any Pygame program you might ever want to write. Obviously you'll want to change the size and title text to meet the specific needs of each program, but if you plan on doing more with Pygame in the future, this is the starting place nearly 100% of the time. This is what the entire program should look like now.
import pygame
pygame.init()
window = pygame.display.set_mode((400, 512))
pygame.display.set_caption("Monochrome 8x8 Sprite Editor")
run = True
while run:
for e in pygame.event.get():
if e.type == pygame.QUIT:
run = False
pygame.quit()
Now we can get to work. For the drawing area, we will need to draw a grid. The squares of that grid will need to be colored black or white, depending on current image state. They will start black, and when the user clicks on a square, it should toggle, to white if it is currently black, and to black if it is currently white. We will need to keep track of the state of the image somehow, so we know what should be rendered which color. Let's start by creating a class for this, named Sprite8x8
. When an instance is created, it will initialize a variable within the object containing a list of zeroes, one for each cell. Since it the sprite is 8x8, we need 64 values.
class Sprite8x8():
def __init__(self):
self.pixels = [0] * 8 * 8
This is the starting point for our class. To create the list, I've just taken a list containing the single value 0, and I've multiplied it by the height and width of the sprite. (In Python this creates a new list with the elements of the original list copied the number times it is multiplied by.) I would like to be able to access items in this list by x and y coordinates, so we are going to add some functions for that. In Python objects, the special functions __getitem__()
and __setitem__()
are called whenever bracket indexing notation is used. So if we create an object named sprite
with these functions, and we do a = sprite[5]
, Python will translate that into a = sprite.__getitem__(5)
. Bracket indexing notation can take any type of data, so we could also do a = sprite[5, 2]
, to specify x and y coordinates, so long as our handler functions know how to deal with tuples. Since our list of pixels is a linear array, we will need to use some math to convert the coordinates into a single array index, but this is fairly easy. The value of x is used as is, and the value of y is multiplied by the width of the 2D space we are mapping from, and they are added together. Here's what the functions look like.
def __getitem__(self, key):
if isinstance(key, int):
return self.pixels[key]
elif len(key) == 2:
return self.pixels[key[0] + key[1] * 8]
else:
raise KeyError("list incides must be integer or 2-tuple")
def __setitem__(self, key, value):
if isinstance(key, int):
self.pixels[key] = value
elif len(key) == 2:
self.pixels[key[0] + key[1] * 8] = value
else:
raise KeyError("list incides must be integer or 2-tuple")
Put these in the Sprite8x8
class, under the __init__()
function. You'll notice I've done a few additional things. First, I check the type of the key value provided. If it is an int
, then I treat it as a direct reference to the list and get/set the value at that position. If it is not an int
, we are going to assume it is a tuple or list and check the length. (Note that if the index given is say, a float, the program will crash when trying to check the length. This code is not 100% error proof.) If the length is correct for 2D coordinates, it will treat the first element as x and the second as y, doing the math we just discussed to change the 2D coordinates into a single index into the array. With these functions added to the class, we can now treat objects of the class as 1D or 2D lists.
Lastly, we are going to need some way to save or export the resulting sprite images, and it would be nice if the sprite class itself would help with this. Now, my purpose in making this program was to produce sprites for embedding in C programs, to be used with the Adafruit's monochrome OLED displays. In C, these images are just arrays of unsigned 8-bit values, and we generally just want to copy and paste them directly into our source code files. We don't really need to save them as independent image files, so I'm not going to bother writing any "save to disk" functionality (which would require a file save dialog and make things much more complicated). Instead let's make it so that the user has some way of copying the binary result into the system clipboard, from which they can paste it into their C program. To do this, we will have to convert the pixels list into a string containing the desired data. There's one more thing though that complicates this. The driver I'm writing for the SSD1306 has a few options for blitting data to the framebuffer. Two (the faster two) require images to be formatted the same way they will be sent to the display, with bits oriented vertically top to bottom and then bytes oriented left to right, in pages that are 8 pixels tall. The other one (for doing much slower, non-page aligned rendering) takes the data formatted as a series of bits arranged left-to-right, top-to-bottom. I want this program to be able to provide whichever I happen to need, so that I can design images for both rendering methods using it. So we need a function that will convert the list of pixels into binary representation, but it has to support both formats. I'm not going to go through all of the details of exactly how this works, but here's what the function looks like.
def binary(self):
tile = [0] * 8
sprite = [0] * 8
for y in range(0, 8):
for x in range(0, 8):
if pixels[x, y] == 0:
sprite[y] = sprite[y] & ~(1 << (7 - x))
tile[x] = tile[x] & ~(1 << y)
else:
sprite[y] = sprite[y] | (1 << (7 - x))
tile[x] = tile[x] | (1 << y)
return (tile, sprite)
For my SSD1306 driver, tiles are page aligned, while sprites are not. Visually, the difference is that tile data appears to be rotated 90 degrees clockwise from how it will actually be rendered, while sprites appear in the same rotation that they will be rendered. I've written the function to just do both at the same time, returning a tuple containing both formats. This is all we need for the Sprite8x8
class. It should go above the game loop somewhere. Right below it, let's create a global object named pixels
from this class. This is what we will use to handle our sprite data.
Next, let's do rendering. We have a few things we will need to render. One is our drawing grid, another is the top bar area with controls and such, and the third is a smaller rendering of the sprite, so we can get an idea of what it will look like. Now, we could render directly to the window
surface, but that's going to require a lot of positioning math. There's nothing wrong with doing it that way, but I don't want to, and there's an easier option. In Pygame, we can produce subsurfaces that are essentially references to a smaller portion of an existing surface. We can treat subsurfaces as regular surfaces, but writing to them will write to the region of the source surface that they reference. What this means is that if I want to manage a drawing region, and I want to treat the top left corner of the drawing region as (0, 0) and avoid adding in the positioning math for every single drawing operation to that region, I can just create a subsurface for the region and write to it. So, I want the drawing to be in the bottom area of the window, positioned with a bit of space between its outer edge and the sides of the window. For the size of the drawing region, I want 40x40 pixel squares, inside of grids where the grid lines are 2 pixels wide (for visibility). Since we have 8 squares in each dimension, we can multiply 40 by 8 and then add 2 multiplied by 9 grid lines, for a width and height of 338. I also worked out positioning and size for the top bar, and I decided that the sprite viewer should be in the top bar as well, so its subsurface overlaps with the top bar (meaning we will have to render it after the top bar, or the top bar will render over top of it).
drawing = window.subsurface((31, 143, 338, 338))
top_bar = window.subsurface((16, 16, 368, 96))
sprite = window.subsurface((140, 32 + 16, 32, 32))
There are our subsurfaces, one for each rendering region. Now we don't have to worry about taking the region positions into account when rendering to them. This can go directly below the code for setting up the window.
Next let's render the sprite preview. You'll notice I made the sprite subsurface 32 pixels square rather than 8. While the native size of the sprites will be 8 pixels square, that's kind of hard to see on modern monitors with very fine pixel pitch, so I've multiplied both dimensions by 4, so we can actually see it clearly. We will have to do this scaling for our rendering math as well.
When rendering, we generally start each frame by clearing our image buffer. In Pygame we do this by calling .fill()
on the surface, where the argument is the color we want to clear it to, in this case black. After that we can draw individual white pixels, looping through the x and y coordinates of our sprite.
def render_sprite():
global sprite, pixels
sprite.fill((0, 0, 0))
for x, y in [(j, i) for i in range(0, 8) for j in range(0, 8)]:
if pixels[x, y] != 0:
pygame.draw.rect(sprite, (255, 255, 255),
(x * 4, y * 4, 4, 4))
You'll notice that instead of changing individual pixels, I'm drawing 4x4 squares. We are also multiplying the x and y coordinates by 4. This is how we handle scaling. Since we cleared the background to black, we can just skip drawing the black pixels and only write the white ones. Rendering the sprite is quite simple and straightforward. The rest is a bit more complicated. This function can go right below the class and the creation of the pixels
object.
Next we will render the drawing area. We will do the top bar region last, because it will require text rendering, which is more complex. As with the last rendering function, the first step is clearing the buffer by filling it with the background color (black, once again). Next we need to loop through the pixels drawing white 40x40 rectangles for each white pixel. This time we will multiply the coordinates by 42 and then add 2 to them, to account for the 2 wide grid lines. This is just as straightforward as the previous function, though I've split the `pygame.draw.rect()` function onto multiple lines, because the extra math makes it too long to fit on one line. After we render the squares, we need to render the grid lines. Since this is a square, we can render horizontal and vertical grid lines at the same time in a single loop.
def render_drawing():
global drawing, pixels
drawing.fill((0, 0, 0))
# Render squares
for x, y in [(j, i) for i in range(0, 8) for j in range(0, 8)]:
if pixels[x, y] != 0:
pygame.draw.rect(
drawing,
(255, 255, 255),
(42 * x + 2, 42 * y + 2, 40, 40)
)
# Render grid
for i in range(0, 337, 42):
pygame.draw.line(drawing, (0, 128, 255), (0, i), (337, i),
width=2)
pygame.draw.line(drawing, (0, 128, 255), (i, 0), (i, 337),
width=2)
Again, this is quite straightforward. We are using some functions from the pygame.draw
submodule to produce our "pixels" and our grid lines. You'll notice I made the color of the lines (0, 128, 255), which is a medium bluish cyan sort of color. If you want to change the color for your own version of this, just change those values! It's in RGB ordering. This function should go below the previous one.
Lastly, we need to render the top bar. We need to render the tile and sprite data as readable text, and I would like to also include a couple of buttons, one for clearing the sprite back to black, and one for inverting the sprite. Since we need text, we will need to use Pygame's font
submodule. We will also need to render images for the buttons. Let's start with the buttons, and there's no need to render each one more than once, since they don't need to ever change. Let's do this right below the initialization for the subsurfaces, above the Sprite8x8
class. We need to start by creating a font object. Let's create both of the font objects we will need. The first is for rendering the binary image data, which we will do in the function for rendering the top bar. The second one will be for the buttons. We need two because the button labels need to be a bit bigger, and Pygame fonts are initialized for a specific size. I'm going to use Courier, because we need a fixed width font for the binary data, and I don't really care what the button font is, so I'm just going to use the same one. If you want to try a different font for the buttons, go ahead and try. Note though, that the UI layout I've designed for this assumes the width of Courier. If the width comes out smaller, you won't have any problems, but if it comes out larger, the buttons could extend into the sprite preview box, being partially rendered underneath it. (If your OS does not have Courier, you can use some other monospace font instead, but I can't promise it won't be the wrong width. This shouldn't impact functionality though.)
font = pygame.font.SysFont("Courier", 12)
button_font = pygame.font.SysFont("Courier", 16)
There are two ways to create Font
objects in Pygame. pygame.font.SysFont()
will find a system font based on the specified name. If it can't find the name specified, it will attempt to find the closest match. If it can't find that, it will use a default fallback. You can include additional terms (separated with commas) in the name string for fallback as well, for example, "Courier, monospace" will look for the default monospace font, if it can't find a good match for Courier. Terms like "serif" and "sans serif" should also work for finding a default fallback that fit a particular style. The second argument is the height of the font in pixels, and it determines the size of the text that will generated. pygame.font.SysFont()
will generally always return something usable, even if it isn't what you wanted, but there's no guarantee it will fit your UI. The other option is pygame.font.Font()
, which takes a font file and produces a font object from that. Once you have a font object, you can call .render()
on it with a string you want to render, True
or False
to determine whether it applies anti-alising, and the foreground text color. A background color can be specified optionally. See the Pygame documentation for more details. .render()
returns a surface containing the text, which you can copy to another surface. Since Surface objects have functions to get their width and height, we can render the labels and size the buttons specifically to fit the labels. Here's the full code for all of this.
font = pygame.font.SysFont("Courier", 12)
button_font = pygame.font.SysFont("Courier", 16)
sprite_box = None
tile_box = None
clear_button = None # Setup by render_top_bar()
clear_button_label = button_font.render("Clear", True, (255, 255, 255))
clear_button_image = pygame.Surface((clear_button_label.get_width() + 10,
clear_button_label.get_height() + 10))
clear_button_image.fill((64, 64, 128))
clear_button_image.blit(clear_button_label, (5, 5))
invert_button = None # Setup by render_top_bar()
invert_button_label = button_font.render("Invert", True, (255, 255, 255))
invert_button_image = pygame.Surface((invert_button_label.get_width() + 10,
invert_button_label.get_height() + 10))
invert_button_image.fill((64, 64, 128))
invert_button_image.blit(invert_button_label, (5, 5))
There are a lot of steps here. We start with creating the fonts. The _box
variables are going to be used for click detection later, but they can't be initialized until the size of the rendered binary text is known, so they will be initialized as None
until render_top_bar()
is called. We also need collision boxes for the buttons, so clear_button
and invert_button
are initialized to None
here and will be given their correct values later. For each button, we then render the label, then create the button image, give it a background fill (a mildly darker blue), and then we render the label text on them. From there the final images can just be reused by render_top_bar()
. Python's garbage collector should clean up the intermediate label surfaces.
That is the final initialization code, and as mentioned, it should go directly above Sprite8x8
. Now that we have all of that, we can write render_top_bar()
.
Yet again, we start by clearing the buffer. This time I've used a dark gray color, (20, 20, 20), to differentiate the top bar. Next we need to render the binary values, which will require both outputs from pixels.binary()
. I want to see both the tile and sprite representations. Currently these are just lists of 8 integer values, where each value contains the data of 8 pixels. I want this to be displayed in binary format. In both C and Python, integer data can be represented in binary format by starting with "0b" and then following that with 1s and 0s. Because C can understand that, and doing it that way provides a rough visual representation of the monochrome images, that's how I want the binary image data displayed. We can easily manage this with str.format()
, which can display integer values in binary. It doesn't include the leading 0b
, but we can add that ourselves. After that, we render the formatted text. I'll include the entire function later, but this is what the text formatting and rendering code looks like.
tile, sprite = pixels.binary()
tile = [font.render("0b{0:08b}".format(b), False, (255, 255, 255))
for b in tile]
sprite = [font.render("0b{0:08b}".format(b), False, (255, 255, 255))
for b in sprite]
Now we have two lists of surfaces containing lines of text. Why not single strings with newlines? Font.render()
can't do newlines. It will replace newlines with a placeholder character and continue on with the same line. If you want newlines, you have to do them manually, by rendering each line separately then stacking them together on the window surface. We've rendered them separately, and they are ready to be stacked. We will get to that in a moment though. I also want each binary representation to be labeled, so I won't forget which is the tile and which is the sprite. I know the binary text will fill up the full height of the top bar though, because I designed it that way on purpose, so the labels can't go on the top. Instead, I'll rotate the text counterclockwise 90 degrees and put them on the left side of their respective text blocks.
sprite_label = font.render("Sprite", False, (255, 255, 255))
sprite_label = pygame.transform.rotate(sprite_label, 90)
tile_label = font.render("Tile", False, (255, 255, 255))
tile_label = pygame.transform.rotate(tile_label, 90)
pygame.transform.rotate()
is used for this, and it rotates counterclockwise by default (negative rotation values rotate clockwise).
Calculating the positions for everything is a bit of a pain. First we get the width of the sprite text lines. This is part of why we needed a monospace font. The length of the text for all of these is identical, so a monospace font will produce text renderings that are exactly the same width. This means we can just get the width of the first image in each list.
sprite_w = sprite[0].get_width()
tile_w = tile[0].get_width()
sprite_x = 368 - sprite_w
sprite_l_x = sprite_x - sprite_label.get_width() - 2
sprite_l_y = (96 - sprite_label.get_height()) // 2
tile_x = sprite_l_x - tile_w - 10
tile_l_x = tile_x - tile_label.get_width() - 2
tile_l_y = (96 - tile_label.get_height()) // 2
Now we can use that width to work out the x position for the text lines (the y positions will be determined later, since each line goes at a different y position). Then we can calculate the label positions from that. Once we have that information, we can blit the labels onto the top bar surface.
top_bar.blit(tile_label, (tile_l_x, tile_l_y))
top_bar.blit(sprite_label, (sprite_l_x, sprite_l_y))
Next we need to blit the text lines, but first we should initialize the boxes defining the location of the binary text.
tile_box = pygame.Rect((tile_x + 16, 16, tile_w, 96))
sprite_box = pygame.Rect((sprite_x + 16, 16, sprite_w, 96))
These are global variables that we will use later in the event handling. You might notice that we've added 16 to the x and y positions. This is because the event handler doesn't know about our special drawing regions, so we do have to do the extra positioning math here. Now we can loop through the rendered text and blit it to the top bar area.
for y in range(0, 96, 12):
top_bar.blit(tile.pop(0), (tile_x, y))
top_bar.blit(sprite.pop(0), (sprite_x, y))
Since the two text regions are being rendered side-by-side, we can blit them in the same loop with the same y locations but different x positions. Since the loop index is the y location, instead of using the loop index for the lists we will just pop the values off of the lists from the front. We won't need these lists again after this, so this is a clean and simple way to handle it.
All that's left now is rendering the buttons. Remember, we also have to initialize their click box regions. We will start by getting the width and height of each button. You might have noticed in the button rendering code that we based the button sizes on their label sizes. We don't actually know the size of the buttons at this point, so we will have to "ask". Next we will calculate the empty vertical space left in the top bar if we arrange the buttons vertically, and we will divide that by 3 to get equal gaps between buttons and edges. And now we can calculate the button click box regions. Once we have that, we can render the buttons and the function is complete.
c_b_w = clear_button_image.get_width()
c_b_h = clear_button_image.get_height()
i_b_w = invert_button_image.get_width()
i_b_h = invert_button_image.get_height()
gap = (96 - c_b_h - i_b_h) // 3
clear_button = pygame.Rect((16 + 10, 16 + gap, c_b_w, c_b_h))
invert_button = pygame.Rect((16 + 10, 16 + c_b_h + gap * 2, i_b_w, i_b_h))
top_bar.blit(clear_button_image,
(clear_button.x - 16, clear_button.y - 16))
top_bar.blit(invert_button_image,
(invert_button.x - 16, invert_button.y - 16))
All of this math might seem intimidating. UI design can be a huge pain, because of all of the math involved, but it's not really difficult math. If you have a hard time following this, try designing some mock UI applications yourself, and play around and experiment with the math. With some practice it will get much easier and more natural. (That said, if you are planning on using what you've learned here to make video games, and you are struggling with this math, you might want to step back and either reconsider or brush up on linear algebra and some light trig, because trust me, you'll need it!) Anyhow, here's the full function.
def render_top_bar():
global font, top_bar, pixels, sprite_box, tile_box
global clear_button, invert_button
global clear_button_image, invert_button_image
top_bar.fill((20, 20, 20))
tile, sprite = pixels.binary()
tile = [font.render("0b{0:08b}".format(b), False, (255, 255, 255))
for b in tile]
sprite = [font.render("0b{0:08b}".format(b), False, (255, 255, 255))
for b in sprite]
sprite_label = font.render("Sprite", False, (255, 255, 255))
sprite_label = pygame.transform.rotate(sprite_label, 90)
tile_label = font.render("Tile", False, (255, 255, 255))
tile_label = pygame.transform.rotate(tile_label, 90)
sprite_w = sprite[0].get_width()
tile_w = tile[0].get_width()
sprite_x = 368 - sprite_w
sprite_l_x = sprite_x - sprite_label.get_width() - 2
sprite_l_y = (96 - sprite_label.get_height()) // 2
tile_x = sprite_l_x - tile_w - 10
tile_l_x = tile_x - tile_label.get_width() - 2
tile_l_y = (96 - tile_label.get_height()) // 2
top_bar.blit(tile_label, (tile_l_x, tile_l_y));
top_bar.blit(sprite_label, (sprite_l_x, sprite_l_y));
tile_box = pygame.Rect((tile_x + 16, 16, tile_w, 96))
sprite_box = pygame.Rect((sprite_x + 16, 16, sprite_w, 96))
for y in range(0, 96, 12):
top_bar.blit(tile.pop(0), (tile_x, y))
top_bar.blit(sprite.pop(0), (sprite_x, y))
c_b_w = clear_button_image.get_width()
c_b_h = clear_button_image.get_height()
i_b_w = invert_button_image.get_width()
i_b_h = invert_button_image.get_height()
gap = (96 - c_b_h - i_b_h) // 3
clear_button = pygame.Rect((16 + 10, 16 + gap, c_b_w, c_b_h))
invert_button = pygame.Rect((16 + 10, 16 + c_b_h + gap * 2,
i_b_w, i_b_h))
top_bar.blit(clear_button_image,
(clear_button.x - 16, clear_button.y - 16))
top_bar.blit(invert_button_image,
(invert_button.x - 16, invert_button.y - 16))
You might have noticed some inefficiency here. For example, the click box regions we've defined aren't going to move anywhere, so why are we recalculating them every time we render a frame? You want the honest answer? Laziness. We could do it in the initialization code, by getting the width of a zero byte rendered as binary using our formatting and the widths of the text block labels similarly and then doing all of the math. The font objects even have a .size()
function that will calculate the size of the surface they would need to render the given text on, if you had told them to render instead. Alternatively, we could wrap the initializations in if
statements checking if they were equal to None
and skipping if they aren't. Feel free to do either of those things. Maybe I'll do them myself at some point in the future. Right now though, the cost of doing it this inefficiently isn't high enough to motivate me to do it right. Sometimes you just need a program that does a thing, and it doesn't matter that much if it does it with maximal efficiency. If this was a commercial product or even an open source project with aspirations of becoming something bigger, I would totally take the time to do it right. As a rough program for personal use, for a very narrow task though, why?
Anyhow, that function should also go below the creation of the pixels
object and above the game loop. That concludes the writing of rendering functions. All that is left now is filling out the game loop itself.
There are a few things we will need for this. The very first, I think, should be responding to mouse clicks in the drawing area by toggling the pixel the pointer is currently on. We've already seen the event type pygame.QUIT
. There is also a pygame.MOUSEBUTTONDOWN
and pygame.MOUSEBUTTONUP
. After some testing, I decided that I also wanted to be able to drag across pixels and have the mouse continue to draw in the same color the first pixel became, so I've also used pygame.MOUSEMOTION
to handle dragging. For dragging, we will also need an additional variable, to keep track of the color it should be changing pixels to, so I've added drag = False
just outside of the game loop. In addition, I added render_top_bar()
in the same location, to ensure that the click boxes are initialized before the event handler has a chance to try to interact with them.
In the event handling loop in our game loop, we have an if
statement checking if the event type is pygame.QUIT
. We are going to add elif
statements to that for the other event types we want to handle, starting with pygame.MOUSEBUTTONDOWN
. This needs to check if the position of the mouse is over a square in the drawing area, and if it is, it needs to toggle that square by changing its value in our sprite object. I'm going to show you the code and then attempt to explain it.
elif e.type == pygame.MOUSEBUTTONDOWN:
if e.button == 1:
x = (e.pos[0] - 31)
y = (e.pos[1] - 143)
if not ((x % 42 in (0, 1))
or (y % 42 in (0, 1))):
x = x // 42
y = y // 42
if not ((x < 0) or (x > 7)
or (y < 0) or (y > 7)):
pixels[x, y] = pixels[x, y] ^ 1
drag = pixels[x, y]
First we check which button was pressed. The left mouse button is button 1, and that's what I want to use to draw. Next we have to normalize the position of the mouse click (contained in e.pos
) to the top right corner of the drawing area, by subtracting the position of the drawing area from it. Next we are going to check if the tip of the pointer is over one of the grid lines, because having pixels change when clicking on grid lines could be confusing. If the pointer isn't on a grid line, then we will do some division to get the coordinates of the square the pointer is over. Then we have to make sure it is on a valid square and not outside of the drawing area entirely. If it passes all of those tests, we get the current position of the pixel, and we XOR it with 1, which toggles it. Now, this event only indicates the mouse button was pushed down. It does not indicated that it was released. So we are going to set the value of drag
to new the color of the pixel we just changed. When we get to handling dragging, we will use this as the color to change pixels to.
Next we need to handle the mouse button being released. For things like buttons, this is usually where the effect of clicking actually happens. Once again, we only care about the left mouse button, so we will check if that's the button that was released. If it was, then the first thing we need to do is stop dragging, by changing drag
back to False
. Next we need to check all of the click boxes, to see if the click was inside of their area. You might have noticed that the click boxes are pygame.Rect
objects. I did this because these have built in collision detection functions. So to check if the tile binary text was clicked, we call tile_box.collidepoint(e.pos)
. This checks if the position stored in the event is within the area of the tile text data, and it returns True
if it is, or False
if it isn't. We can do the same thing for the buttons as well, triggering their effects if the click happened within their click box. Here's what the full mouse release handling code looks like.
elif e.type == pygame.MOUSEBUTTONUP:
if e.button == 1:
drag = False
if tile_box.collidepoint(e.pos):
data = pixels.binary()[0]
data = ["0b{0:08b}".format(b) for b in data]
data = "Tile\n" + "\n".join(data)
r = Tk()
r.withdraw()
r.clipboard_clear()
r.clipboard_append(data)
r.update()
r.destroy()
elif sprite_box.collidepoint(e.pos):
data = pixels.binary()[1]
data = ["0b{0:08b}".format(b) for b in data]
data = "Sprite\n" + "\n".join(data)
r = Tk()
r.withdraw()
r.clipboard_clear()
r.clipboard_append(data)
r.update()
r.destroy()
elif clear_button.collidepoint(e.pos):
pixels.pixels = [0] * 8 * 8
elif invert_button.collidepoint(e.pos):
pixels.pixels = list(map(lambda x: x ^ 1,
pixels.pixels))
Now we need to talk about a design point I alluded to earlier but didn't fully address. How do we get binary image data out of this? Ideally, we should be able to just paste it into C from here. I could try to code up some text selection mechanics for the binary representation, but that would be a huge pain, and in the end, the whole point is merely to copy the text to the clipboard. So why not just skip the hard part and copy the text to the clipboard when it is clicked? It turns out Python's built-in UI module, Tkinter, can access the clipboard. You'll need to go back up to the top of the program and add from tkinter import Tk
for this to work, and now we can easily clear the clipboard and fill it with whatever we want. And what I want is for the data I've clicked on to be formatted with newlines between elements, and to be preceded with a label indicating whether it is a tile or a sprite (in case I copy the wrong one or maybe click to copy when the window is out of focus, which won’t actually trigger the mouse event and thus won’t copy; yes, I know this because it actually happened to me!). I could (and probably should) also add commas at the end of each line, since that will be needed in C. So I can click the representation I want, then paste into C. It doesn't include any array declaration or anything, so that I can easily make it a single image array or add it to an existing multi-image array. Oh, and we've also handled the clear and invert buttons right at the very bottom!
Lastly, let's handle dragging. The mouse motion event contains a list of buttons, rather than a single button, because the user could be dragging with multiple buttons pressed. We merely need to check if the left button is one of them. We also want to check if drag
is False
, to filter out drags that didn't start on a pixel in the drawing area. Since we don't want to affect pixels when the pointer is over a grid line, we will also filter out events in those positions. Lastly we need to filter out events that happen outside of the draw area. From there we can set the pixel the mouse is over to whatever we set drag
to when the drag started.
elif e.type == pygame.MOUSEMOTION:
if (1 in e.buttons) and (drag is not False):
x = (e.pos[0] - 31)
y = (e.pos[1] - 143)
if not ((x % 42 in (0, 1))
or (y % 42 in (0, 1))):
x = x // 42
y = y // 42
if not ((x < 0) or (x > 7)
or (y < 0) or (y > 7)):
pixels[x, y] = drag
Once again, this is fairly straightforward. This concludes the event handling. There's one thing left: calling our rendering functions. Outside of the event handling loop, right after it, we need to call render_top_bar()
, render_drawing()
, and render_sprite()
. The order doesn't matter for render_drawing()
, but render_sprite()
must come after render_top_bar()
, because their spaces overlap. All of this will render everything we need to the buffer. To render the buffer to the screen, we have to call pygame.display.flip()
. And that is the end of the game loop.
That completes our program. I should note that I did not write it in the same order that we went through it here. There was debugging. I had some things rendering before others were even written. Unfortunately, programming can be messy, especially casual programming without formal design specifications, and trying to guide you through the exact order I did things in would probably be quite confusing. Sometimes you are halfway through a function when you realize you need something for it, and that takes you several levels deep into writing code, so you can write other code, so that you can finish writing the half finished function you started at. Maybe I'll try that at some point in the future, but I'm not sure it will turn out good. (It might work better as a video than a text tutorial/walkthrough...)
Anyhow, here's the full program. It's just over 200 lines of code, and so far it is working very well for me! (Shortly after I finished writing this, a couple of my kids came over, and we drew up some sprites, copied them into the C program I’m currently using to test my SSD1306 driver, and rendered the sprites onto the Adafruit monochrome OLED I wrote this program for, and it worked perfectly!)
from tkinter import Tk
import pygame
pygame.init()
window = pygame.display.set_mode((400, 512))
pygame.display.set_caption("Monochrome 8x8 Sprite Editor")
drawing = window.subsurface((31, 143, 338, 338))
top_bar = window.subsurface((16, 16, 368, 96))
sprite = window.subsurface((140, 32 + 16, 32, 32))
font = pygame.font.SysFont("Courier", 12)
button_font = pygame.font.SysFont("Courier", 16)
sprite_box = None
tile_box = None
clear_button = None # Setup by render_top_bar()
clear_button_label = button_font.render("Clear", True, (255, 255, 255))
clear_button_image = pygame.Surface((clear_button_label.get_width() + 10,
clear_button_label.get_height() + 10))
clear_button_image.fill((64, 64, 128))
clear_button_image.blit(clear_button_label, (5, 5))
invert_button = None # Setup by render_top_bar()
invert_button_label = button_font.render("Invert", True, (255, 255, 255))
invert_button_image = pygame.Surface((invert_button_label.get_width() + 10,
invert_button_label.get_height() + 10))
invert_button_image.fill((64, 64, 128))
invert_button_image.blit(invert_button_label, (5, 5))
class Sprite8x8():
def __init__(self):
self.pixels = [0] * 8 * 8
def __getitem__(self, key):
if isinstance(key, int):
return self.pixels[key]
elif len(key) == 2:
return self.pixels[key[0] + key[1] * 8]
else:
raise KeyError("list incides must be integer or 2-tuple")
def __setitem__(self, key, value):
if isinstance(key, int):
self.pixels[key] = value
elif len(key) == 2:
self.pixels[key[0] + key[1] * 8] = value
else:
raise KeyError("list incides must be integer or 2-tuple")
def binary(self):
tile = [0] * 8
sprite = [0] * 8
for y in range(0, 8):
for x in range(0, 8):
if pixels[x, y] == 0:
sprite[y] = sprite[y] & ~(1 << (7 - x))
tile[x] = tile[x] & ~(1 << y)
else:
sprite[y] = sprite[y] | (1 << (7 - x))
tile[x] = tile[x] | (1 << y)
return (tile, sprite)
pixels = Sprite8x8()
def render_sprite():
global sprite, pixels
sprite.fill((0, 0, 0))
for x, y in [(j, i) for i in range(0, 8) for j in range(0, 8)]:
if pixels[x, y] != 0:
pygame.draw.rect(sprite, (255, 255, 255),
(x * 4, y * 4, 4, 4))
def render_top_bar():
global font, top_bar, pixels, sprite_box, tile_box
global clear_button, invert_button
global clear_button_image, invert_button_image
top_bar.fill((20, 20, 20))
tile, sprite = pixels.binary()
tile = [font.render("0b{0:08b}".format(b), False, (255, 255, 255))
for b in tile]
sprite = [font.render("0b{0:08b}".format(b), False, (255, 255, 255))
for b in sprite]
sprite_label = font.render("Sprite", False, (255, 255, 255))
sprite_label = pygame.transform.rotate(sprite_label, 90)
tile_label = font.render("Tile", False, (255, 255, 255))
tile_label = pygame.transform.rotate(tile_label, 90)
sprite_w = sprite[0].get_width()
tile_w = tile[0].get_width()
sprite_x = 368 - sprite_w
sprite_l_x = sprite_x - sprite_label.get_width() - 2
sprite_l_y = (96 - sprite_label.get_height()) // 2
tile_x = sprite_l_x - tile_w - 10
tile_l_x = tile_x - tile_label.get_width() - 2
tile_l_y = (96 - tile_label.get_height()) // 2
top_bar.blit(tile_label, (tile_l_x, tile_l_y));
top_bar.blit(sprite_label, (sprite_l_x, sprite_l_y));
tile_box = pygame.Rect((tile_x + 16, 16, tile_w, 96))
sprite_box = pygame.Rect((sprite_x + 16, 16, sprite_w, 96))
for y in range(0, 96, 12):
top_bar.blit(tile.pop(0), (tile_x, y))
top_bar.blit(sprite.pop(0), (sprite_x, y))
c_b_w = clear_button_image.get_width()
c_b_h = clear_button_image.get_height()
i_b_w = invert_button_image.get_width()
i_b_h = invert_button_image.get_height()
gap = (96 - c_b_h - i_b_h) // 3
clear_button = pygame.Rect((16 + 10, 16 + gap, c_b_w, c_b_h))
invert_button = pygame.Rect((16 + 10, 16 + c_b_h + gap * 2,
i_b_w, i_b_h))
top_bar.blit(clear_button_image,
(clear_button.x - 16, clear_button.y - 16))
top_bar.blit(invert_button_image,
(invert_button.x - 16, invert_button.y - 16))
def render_drawing():
global drawing, pixels
drawing.fill((0, 0, 0))
# Render squares
for x, y in [(j, i) for i in range(0, 8) for j in range(0, 8)]:
if pixels[x, y] != 0:
pygame.draw.rect(
drawing,
(255, 255, 255),
(42 * x + 2, 42 * y + 2, 40, 40)
)
# Render grid
for i in range(0, 337, 42):
pygame.draw.line(drawing, (0, 128, 255), (0, i), (337, i),
width=2)
pygame.draw.line(drawing, (0, 128, 255), (i, 0), (i, 337),
width=2)
run = True
drag = False
render_top_bar() # Initialize tile_box and sprite_box
while run:
for e in pygame.event.get():
if e.type == pygame.QUIT:
run = False
elif e.type == pygame.MOUSEBUTTONDOWN:
if e.button == 1:
x = (e.pos[0] - 31)
y = (e.pos[1] - 143)
if not ((x % 42 in (0, 1))
or (y % 42 in (0, 1))):
x = x // 42
y = y // 42
if not ((x < 0) or (x > 7)
or (y < 0) or (y > 7)):
pixels[x, y] = pixels[x, y] ^ 1
drag = pixels[x, y]
elif e.type == pygame.MOUSEBUTTONUP:
if e.button == 1:
drag = False
if tile_box.collidepoint(e.pos):
data = pixels.binary()[0]
data = ["0b{0:08b}".format(b) for b in data]
data = "Tile\n" + "\n".join(data)
r = Tk()
r.withdraw()
r.clipboard_clear()
r.clipboard_append(data)
r.update()
r.destroy()
elif sprite_box.collidepoint(e.pos):
data = pixels.binary()[1]
data = ["0b{0:08b}".format(b) for b in data]
data = "Sprite\n" + "\n".join(data)
r = Tk()
r.withdraw()
r.clipboard_clear()
r.clipboard_append(data)
r.update()
r.destroy()
elif clear_button.collidepoint(e.pos):
pixels.pixels = [0] * 8 * 8
elif invert_button.collidepoint(e.pos):
pixels.pixels = list(map(lambda x: x ^ 1, pixels.pixels))
elif e.type == pygame.MOUSEMOTION:
if (1 in e.buttons) and (drag is not False):
x = (e.pos[0] - 31)
y = (e.pos[1] - 143)
if not ((x % 42 in (0, 1))
or (y % 42 in (0, 1))):
x = x // 42
y = y // 42
if not ((x < 0) or (x > 7)
or (y < 0) or (y > 7)):
pixels[x, y] = drag
render_top_bar()
render_drawing()
render_sprite()
pygame.display.flip()
pygame.quit()