-
MemoryError
01/25/2018 at 22:36 • 0 commentsSo I got that Boulder Dash game as far as I can, working around the ridiculously low memory of Micro:bit, and I think I won't be able to go further. This platform is simply too puny.
I have an animated player character digging tunnels, and I have boulders that block his way and fall into empty space you dig under them. And I can't add even a single line of code without getting a MemoryError.
---------- more ----------What I would need to make it a playable game at a minimum? Well, rocks need to slip sideways when on top of other rocks, and there have to be gems to collect — then I could design some logic levels, at least. If I managed to also add monsters, it could be a fun arcade game. This is the code I have so far:
from microbit import i2c, running_time, sleep, pin1, pin2 class Boy: def __init__(self, delay=100): b = bytearray self.buffer = b(1024 + 1) self.dmin = b([0] * 8) self.dmax = b([128] * 8) i2c.init(freq=400000, scl=pin2, sda=pin1) try: i2c.write(0x3c, b'') except: pass i2c.write(0x3c, b'\x00\xae\xd5\x80\xa8\x3f\xd3\x00\x40\x80\x14' b'\x20\x00\xa1\xc8\xa1\xda\x12\x81\xcf\xd9\xf1' b'\xdb\x40\xa4\xa6\xaf') self.next = running_time() + delay self.delay = delay @staticmethod def buttons(): return i2c.read(0x10, 1)[0] def tick(self): sleep(self.next - running_time()) self.next += self.delay def update(self): c = bytearray(4) b = self.buffer i = 0 for p in range(8): dmin = self.dmin[p] dmax = self.dmax[p] if dmax: c[1] = 0xb0 | p c[2] = 0x00 | ((2 + dmin) & 0x0f) c[3] = 0x10 | ((2 + dmin) >> 4) & 0x0f i2c.write(0x3c, c) l = b[i + dmin:i + dmax + 1] l[0] = 0x40 i2c.write(0x3c, l) i += 128 self.dmax[p] = 0 self.dmin[p] = 127 def blit(self, x, y, data, mask=b''): if not 0 <= x < 120: return b = self.buffer p = y // 8 s = y % 8 if 0 <= p <= 7: i = x + 128 * p + 1 for a in mask: b[i] &= ~(a << s) i += 1 i = x + 128 * p + 1 for a in data: b[i] ^= a << s i += 1 self.dmin[p] = min(self.dmin[p], x) self.dmax[p] = max(self.dmax[p], x + 8) p += 1 if 0 <= p <= 7 and s: s = 8 - s i = x + 128 * p + 1 for a in mask: b[i] &= ~(a >> s) i += 1 i = x + 128 * p + 1 for a in data: b[i] ^= a >> s i += 1 self.dmin[p] = min(self.dmin[p], x) self.dmax[p] = max(self.dmax[p], x + 8) boy = Boy() MAN_MASK = b'0\xfe\xff\xff\xff\xff\xfe0' DIRT = b'\x8a \x05P\x05 \x8a ' DARK = b'\x00\x00\x00\x00\x00\x00\x00\x00' ROCK_MASK = b'<~\xff\xff\xff\xff~<' ROCK = b'\x004Z>V*\x14\x00' dirt = bytearray(16) for y in range(8): for x in range(16): boy.blit(x * 8, y * 8, DIRT) rocks = {(0, 0), (3, 3), (3, 4), (5, 2), (10, 1)} for x, y in rocks: boy.blit(x * 8, y * 8, ROCK, ROCK_MASK) x, y = 0, 8 dx = dy = steps = 0 while True: key = boy.buttons() if steps == 0: dirt[x // 8] |= 1 << (y // 8) dx = dy = 0 if key & 4 and x < 120: dx = 1 elif key & 16 and x > 0: dx = -1 elif key & 32 and y < 56: dy = 1 elif key & 8 and y > 0: dy = -1 if (x // 8 + dx, y // 8 + dy) in rocks: dx = dy = 0 if dx or dy: steps = 7 else: steps -= 1 x += dx y += dy man = (b'\x00\x10z>:~ \x00', b'\x00 \x1a~z>\x10\x00')[(steps // 2) % 2] if dx + dy > 0: man = bytes(reversed(man)) for rx, ry in list(rocks): if dirt[rx] & (1 << (ry + 1)) and not ( (y // 8 == ry + 1) and (rx * 8 - 6 <= x <= rx * 8 + 6) ) and (rx, ry + 1) not in rocks: dirt[rx] |= 1 << ry boy.blit(rx * 8, ry * 8, DARK, ROCK_MASK) boy.blit(rx * 8, ry * 8 + 8, ROCK, ROCK_MASK) rocks.remove((rx, ry)) rocks.add((rx, ry + 1)) boy.blit(x, y, man, MAN_MASK) boy.update() boy.blit(x, y, DARK, MAN_MASK) boy.tick()
It's not the most readable code possible, you have to admit, but it works. I guess if I completely skipped animations, and made things jump by full tiles, then I could save some memory...
-
Progress on a Game
01/20/2018 at 00:41 • 0 commentsWith the hardware finalized, the remaining work is to actually write at least one game, to prove that it's possible. I decided to make a game inspired by such classics as Dig Dug, Digger and Boulder Dash — you dig tunnels, collect gems, avoid monsters and drop boulders on them. So far I have the basic graphics and the player animation:
---------- more ----------The code for the display got a bit simplified, and merged with the code for buttons. I also added a "tick" function for keeping a constant frame rate.
import microbit DIRT = b'\x8a \x05P\x05 \x8a ' DARK = b'\x00\x00\x00\x00\x00\x00\x00\x00' ROCK_MASK = b'<~\xff\xff\xff\xff~<' ROCK = b'\x004Z>V*\x14\x00' GEM = b'\x00\x18,N\x02\x04\x08\x00' MAN_MASK = b'0\xfe\xff\xff\xff\xff\xfe0' MAN = (b'\x00\x10z>:~ \x00', b'\x00 \x1a~z>\x10\x00') class Console: _command = bytearray(b'\x00\xb0\x02\x10') def __init__(self): self.next_frame = microbit.running_time() + 100 self.buffer = bytearray(1024) self.dirty_min = bytearray([0] * 8) self.dirty_max = bytearray([128] * 8) microbit.i2c.init(freq=400000, scl=microbit.pin2, sda=microbit.pin1) try: microbit.i2c.write(0x3c, b'') except OSError: pass _init = (b'\x00\xae\xd5\x80\xa8\x3f\xd3\x00\x40\x80\x14' b'\x20\x00\xa1\xc8\xa1\xda\x12\x81\xcf\xd9\xf1' b'\xdb\x40\xa4\xa6\xaf') microbit.i2c.write(0x3c, _init) @staticmethod def buttons(): return microbit.i2c.read(0x10, 1)[0] def tick(self, ms=100): delay = self.next_frame - microbit.running_time() if delay > 0: microbit.sleep(delay) self.next_frame += ms def update(self): buf = self.buffer index = 0 for page in range(8): dmin = self.dirty_min[page] dmax = self.dirty_max[page] if dmax: self._command[1] = 0xb0 | page self._command[2] = 0x00 | ((2 + dmin) & 0x0f) self._command[3] = 0x10 | ((2 + dmin) >> 4) & 0x0f microbit.i2c.write(0x3c, self._command) microbit.i2c.write(0x3c, b'\x40' + buf[index + dmin:index + dmax]) index += 128 self.dirty_max[page] = 0 self.dirty_min[page] = 127 def blit(self, x, y, data, mask=b''): if not -8 < x < 128: return if x < 0: data = data[-x:] x = 0 elif x > 120: data = data[:120 - x] page = y // 8 shift = y % 8 if 0 <= page <= 7: index = x + 128 * page for byte in mask: self.buffer[index] &= ~(byte << shift) index += 1 index = x + 128 * page for byte in data: self.buffer[index] ^= byte << shift index += 1 self.dirty_min[page] = min(self.dirty_min[page], x) self.dirty_max[page] = max(self.dirty_max[page], x + 8) page += 1 if 0 <= page <= 7 and shift: shift = 8 - shift index = x + 128 * page for byte in mask: self.buffer[index] &= ~(byte >> shift) index += 1 index = x + 128 * page for byte in data: self.buffer[index] ^= byte >> shift index += 1 self.dirty_min[page] = min(self.dirty_min[page], x) self.dirty_max[page] = max(self.dirty_max[page], x + 8) console = Console() for y in range(8): for x in range(16): if (x + 3 * y + 1) % 5: console.blit(x * 8, y * 8, DIRT) console.blit(0, 0, ROCK, ROCK_MASK) console.blit(0, 16, GEM, ROCK_MASK) frame = 0 facing = 1 x = 0 y = 8 while True: frame = (frame + 1) % 4 buttons = console.buttons() if buttons & 4: x += 1 facing = 1 elif buttons & 16: x -= 1 facing = -1 man = MAN[frame // 2] if facing > 0: man = reversed(man) console.blit(x, y, man, MAN_MASK) console.update() console.blit(x, y, DARK, MAN_MASK) console.tick(50)
You can also see how you can easily mirror the sprites by calling "reversed" on their data.
-
Why Separate Button Controller?
01/18/2018 at 10:23 • 0 commentsI have been asked why I'm using a separate chip for handling the buttons, so I thought I will just explain it in here. The question was why not just use the edge connector, and then use the additional pins available on the Micro:bit for reading the buttons. There are actually to reasons for that:
I don't like the edge connector.
Ever since I first saw it, I tried to work around it with something smaller, lighter and less expensive — you can look at my #Micro:header — but I never managed to get a reliable connection without damaging the Micro:bit itself. I do have a bunch of those connectors, both soldered on a breakout board, and loose, but they are simply too large for such a small handheld device, I don't have a Fritzing footprint for them (and don't feel like making one), and soldering all the pins is a chore. Bolts are, on the other hand, a tested and reliable alternative.
Handling buttons is not trivial.
Looking at the code for the button controller you wouldn't guess that, but handling the button presses properly is actually a non-trivial task. You have to de-bounce them, and you have to buffer them — those are some simple things we came to expect from anything with buttons, because it's so common. Doing that in the limited version of Micropython that runs on the Micro:bit, without access to timers or interrupts, while at the same time handling the logic of whatever game you are playing would be a challenge, if not outright impossible. Doing it in C as a built-in module would be a little easier (that's how I did it for #µGame), but getting that extra code merged and released would take ages — the current release is a year behind, and there is never enough memory, so I don't think they would happily accept an extra module that is only useful in one project. Having to use a custom Micropython firmware would make it impossible to code using Mu or any of the other dedicated editors.
So considering all that, I decided to use a dedicated chip for the buttons. I initially started with a simple gpio expander chip, like in #D1 Mini X-Pad Shield, but that still requires you to poll it constantly and do the de-bouncing and buffering yourself. You can also easily miss presses. Then I switched to the HT16K33 that I'm using in #PewPew FeatherWing, because it does the de-bouncing and buffering for you, but that's an expensive and bulky chip, that we are not really fully utilizing. So I looked for the cheapest microcontroller with enough pins, and ATtiny24 seems like the perfect fit, especially since I already have experience with AVR.
-
Button Controller
01/17/2018 at 18:53 • 0 commentsThe ATtiny24A chips I ordered finally arrived yesterday, and I could start working on the firmware for the button controller. I quickly assembled a setup for experimenting:
One mini-breadboard for programming, one for testing the I2C (since programming uses the same pins, and I2C needs the pullup resistors, it would be hard to have everything on one board). The chip is on a breakout board for easy plugging. An Adafruit Feather M0 Express plays the role of the I2C master. Some additional wires for the logic analyzer complete the setup.
---------- more ----------First I tried to use the TinyWireS library, and wasted whole evening yesterday trying to get it to work. The device would show up in the scan (meaning it ACKs writes), but all reads would give me 0xFF — and the logic analyzer confirms that, so something must be wrong in software.
Finally, I decided to switch to the USIWire library, and that worked without problems out of the box. Here is my code:
#include <USIWire.h> #define PINS_COUNT 6 const uint8_t pins[PINS_COUNT] = {0, 1, 2, 3, 5, 7}; volatile bool clear = false; volatile uint8_t buttons = 0; void request() { Wire.write(buttons); clear = true; } void setup() { for (uint8_t i = 0; i < PINS_COUNT; ++i) { pinMode(pins[i], INPUT_PULLUP); } Wire.begin(0x10); Wire.onRequest(request); } void loop() { static uint8_t last_buttons = 0; uint8_t current_buttons = 0; for (uint8_t i = 0; i < PINS_COUNT; ++i) { current_buttons <<= 1; current_buttons |= !digitalRead(pins[i]); } if (clear) { clear = false; buttons = 0; } buttons |= last_buttons & current_buttons; last_buttons = current_buttons; delay(16); }
Not much code, but it does de-bouncing and caching of the presses until they are read — which is why I needed a microcontroller, instead of a simple GPIO expander chip. It all works pretty well, I think I'm going to use the same approach in the next version of the #D1 Mini X-Pad Shield.
After soldering the chip to the Micro:bit, it still works very well, so I'm happy with it.
-
Advanced Spriting
01/09/2018 at 13:51 • 0 commentsAfter a nightfull of sleep, I realized that I don't need a separate method with "or" for proper non-transparent sprites, since if I'm masking the place with black anyways, "xor" works just as well. As an added bonus, I can have also parts of the sprite sticking out of the mask outline, which then will be xor-ed, which could be a nice effect. I also decided to use "nand" for the mask, not "and", since then the outline of the sprite is white and the background is black, and that works better with the shifting operators. Finally, I made the mask just an optional argument to the blit function, since you are not likely to use it alone. So I have this:
I had to add one more thing, of which I didn't think before. When not using "xor" for the sprite, I need some way to restore the previous background when the sprite moves away. Right now I'm using an inefficient method of simply keeping a copy of the background in a separate buffer, and simply copying the few bytes where the sprite used to be from it to the actual frame buffer (and updating the dirty numbers accordingly). A more efficient way could be imagined.
---------- more ----------Anyways, the new code looks like this:
def blit(self, x, y, data, mask=b''): if not -8 < x < 128: return if x < 0: data = data[-x:] x = 0 elif x > 120: data = data[:120 - x] page = y // 8 shift = y % 8 if 0 <= page <= 7: index = x + 128 * page for byte in mask: self.buffer[index] &= ~(byte << shift) index += 1 index = x + 128 * page for byte in data: self.buffer[index] ^= byte << shift index += 1 self.dirty_min[page] = min(self.dirty_min[page], x) self.dirty_max[page] = max(self.dirty_max[page], x + 8) page += 1 if 0 <= page <= 7 and shift: shift = 8 - shift index = x + 128 * page for byte in mask: self.buffer[index] &= ~(byte >> shift) index += 1 index = x + 128 * page for byte in data: self.buffer[index] ^= byte >> shift index += 1 self.dirty_min[page] = min(self.dirty_min[page], x) self.dirty_max[page] = max(self.dirty_max[page], x + 8) def clear(self, x, y, background): buf = self.buffer for page in (y // 8, y // 8 + 1): if page > 7: break index = x + 128 * page for i in range(8): buf[index] = background[index] index += 1 self.dirty_min[page] = min(self.dirty_min[page], x) self.dirty_max[page] = max(self.dirty_max[page], x + 8)
I still need to work on making the clear function more robust, so that you can use it with coordinates outside of the screen.
-
Blit
01/08/2018 at 13:21 • 0 commentsI'm still waiting for the attiny chips for the button handling, but I realized that my display driver is not really suitable for making games. Sure, it has that cool dirty pages stuff that makes it fast, but it only can draw a pixel at a time. That's not how you are going to get nice smooth animations. We need a blit operation — something that will let me draw a whole sprite in one operation.
The way blit works, it takes whole bytes, shifts them as required, and combines with the current frame buffer using one of the logic operators. For a start I have chosen xor. Why? Because that's the easiest way to have sprites that don't delete the background as they move around — you blit them once to show them, and you blit them a second time to make them disappear. Sure, they don't look pretty when they collide with something, but it's a start. Later I can do similar operations with or and and, so that I can have pretty sprites with a mask. For now I have a bouncing box:
This is done with this code:
---------- more ----------import microbit class SH1106: _command = bytearray(b'\x00\xb0\x02\x10') def __init__(self): self.buffer = bytearray(1024) self.dirty_min = bytearray([0] * 8) self.dirty_max = bytearray([128] * 8) microbit.i2c.init(freq=400000, scl=microbit.pin2, sda=microbit.pin1) for i in range(2): # First two transaction after init always fail. try: microbit.i2c.write(0x3c, b'') except OSError: pass _init = (b'\x00\xae\xd5\x80\xa8\x3f\xd3\x00\x40\x80\x14' b'\x20\x00\xa1\xc8\xa1\xda\x12\x81\xcf\xd9\xf1' b'\xdb\x40\xa4\xa6\xaf') microbit.i2c.write(0x3c, _init) microbit.i2c.write(0x3c, _init) def show(self): index = 0 buf = self.buffer for page in range(8): dmin = self.dirty_min[page] dmax = self.dirty_max[page] if dmax: self._command[1] = 0xb0 | page self._command[2] = 0x00 | ((2 + dmin) & 0x0f) self._command[3] = 0x10 | ((2 + dmin) >> 4) & 0x0f microbit.i2c.write(0x3c, self._command) microbit.i2c.write(0x3c, b'\x40' + buf[index + dmin:index + dmax]) index += 128 self.dirty_max[page] = 0 self.dirty_min[page] = 127 def pixel(self, x, y, color=None): if not 0 <= x < 128 or not 0 <= y < 64: return page = y // 8 index = x + page * 128 mask = 1 << (y % 8) if color is None: return bool(self.buffer[index] & mask) elif color: self.buffer[index] |= mask else: self.buffer[index] &= ~mask self.dirty_max[page] = max(self.dirty_max[page], x + 1) self.dirty_min[page] = min(self.dirty_min[page], x) def xor(self, x, y, data): if not -8 < x < 128: return if x < 0: data = data[-x:] x = 0 elif x > 120: data = data[:120 - x] page = y // 8 shift = y % 8 if 0 <= page <= 7: index = x + page * 128 for byte in data: self.buffer[index] ^= byte << shift index += 1 self.dirty_min[page] = min(self.dirty_min[page], x) self.dirty_max[page] = max(self.dirty_max[page], x + 8) page += 1 if 0 <= page <= 7 and shift: index = x + page * 128 shift = 8 - shift for byte in data: self.buffer[index] ^= byte >> shift index += 1 self.dirty_min[page] = min(self.dirty_min[page], x) self.dirty_max[page] = max(self.dirty_max[page], x + 8) display = SH1106() box = b'\xff\x81\x81\x81\x81\x81\x81\xff' x = 0 y = 0 dx = 1 dy = 1 while True: display.xor(x, y, box) display.show() display.xor(x, y, box) x += dx y += dy if not 0 <= x < 120: dx = -dx if not 0 <= y <= 56: dy = - dy microbit.sleep(10)
So I can easily blit 8x8 sprites (and tiles). If I want bigger ones, I can just compose them out of those 8x8 blocks — it's not going to be considerably slower.
-
Some Progress
01/07/2018 at 21:21 • 0 commentsThe PCBs with the horizontal layout and display module arrived, so I assembled the new version:
I'm happy with it mechanically. You don't touch the contacts when you hold it, and the two buttons on the micro:bit are right under your index fingers. It's wide enough to hold comfortably. The flattened d-pad is a bit less convenient than it could be if it was full-size, but I can't have everything. The coin battery fits inside the sandwich, and the whole thing the right thickness. I had to add some spacers to the bolts that hold the thing together, to make sure the spacing is right. You can both use through-hole bolts, or solder them to the pads — here I soldered them, but I think I will go with through-hole in the future.
I'm still missing one crucial part — the attiny that will be handling the buttons is still on the slow boat from China. Seems like the holiday season was really tough on the post offices around the world, because everything takes longer now. I guess I won't be able to get this to work before the contest deadline.
On an unrelated note, I think I figured out why I couldn't get that display to work reliably without a module. Turns out that after powering it, it requires to be reset by the physical reset pin. The datasheet's example schematics don't show it, but those modules have an R-C circuit on the reset pin to do that. Mine didn't, hence the problems. But I think I will stay with the modules for now anyways.
-
After All it Works!
12/17/2017 at 19:45 • 0 commentsI have re-read what I wrote in my previous log, and got that "wait a minute" moment. I'm using a circuit literally copied from the datasheet. All connections are correct. There is I2C communication. It just doesn't display anything. Perhaps it's the display that is broken? So I tried with another display, and lo and behold! It works!
To be honest, I don't think the display arrived broken — I think I broke it with my initial tries, when the components were taken from the SSD1306 datasheet, instead of the SH1106.
But if the display works, that means I can assemble the whole thing and start programming for it! I quickly added all the remaining components, programmed the micro:bit with the "fill the display with random dots" demo, and it works:
I even added the function to read the button states:
def buttons(): microbit.i2c.write(0x70, b'\x40', repeat=True) buttons = microbit.i2c.read(0x70, 2) return buttons[0] >> 7 | buttons[1] << 1
And I have all that is needed for simple games. For more complex games, I still need to implement the "blit" method — or three, for and, or, and xor.
I'm still fine with the redesign I described in the previous log, though — this layout has one large problem: the micro:bit connectors are right under your fingers, including the i2c signals — and when you touch them, you get communication errors with the display. Switching to horizontal layout not only moves the connections out of the reach, but also makes it easy to use the two buttons on the back, so think it's better, even though the direction buttons are a little bit more squashed.
-
Restart
12/15/2017 at 21:07 • 0 commentsI didn't manage to get that display to work, even though the circuit is copied directly from the datasheet. Interestingly, the modules with this display that I have use completely different values for all the components than the datasheet says — I tried with those too, to no avail. So I decided to take a step back and do it the easier way.
There is a very small, minimalist module with this display designed as a shield for one of the new ESP32 boards, that will work just fine for my purposes. It already has all the needed components and it's working, so I will just use that. In addition, because now I have the display on a separate PCB, I can stack that PCB over the bolts, so I can use regular holes for the bolts, and change the orientation of the whole device, so that the A and B buttons of the micro:bit can also be used as shoulder buttons.
The PCB looks like this:
I also decided to use an ATtiny24 for handling the buttons — this way I can write the latching code exactly the way I want it, and also do debouncing and all that internally. I briefly considering just using the analog pin and a bunch of resistors for the buttons, but I don't trust those bolt connections.
-
Special Bolts
11/30/2017 at 00:07 • 0 commentsThe special bolts arrived this weekend, and I got to try them:
The good things about them: they are exactly the right size of the micro:bit's holes, they are made specially for soldering onto the PCB, they have this small pilot nub in the center, that lets you give them an exact position. The bad things: you have to be careful to make them exactly vertical, or the micro:bit won't fit on them, the standard nuts are too big and short neighboring pins (I need to find some kind of washers for them), they take somewhat long to heat up enough to melt the solder.
In other news, I'm still struggling with getting the screen to work. I might switch to a ready module at some point.