I have pyboard, OLED display (SSD1306) and joystick (Keyes_SJoys), so I decided to try to make breakout clone. First of all I decided to create something like a little framework, that will be a bit similar to React, and all game can be formalized just in two functions:
(state) → new-state
– controller that updates state;(state) → primitives
– view that converts state to generator of primitives.
For example, code that draws chess cells and invert it on click, will be like:
from lib.ssd1306 import Display
from lib.keyes import Joystick
from lib.engine import Game, rectangle, text
def is_filled(x, y, inverted):
fill = (x + y) % 40 == 0
if inverted:
return not fill
else:
return fill
def view(state):
# Display data available in state['display']
for x in range(0, state['display']['width'], 20):
for y in range(0, state['display']['height'], 20):
# rectangle is bundled view that yields points
yield from rectangle(x=x, y=y, w=20, h=20,
fill=is_filled(x, y, state['inverted']))
def controller(state):
# Joystick data available in state['display']
if state['joystick']['clicked']:
return dict(state, inverted=not state['inverted'])
else:
return state
initial_state = {'inverted': False}
chess_deck = Game(display=Display(pinout={'sda': 'Y10',
'scl': 'Y9'},
height=64,
external_vcc=False),
joystick=Joystick('X1', 'X2', 'X3'),
initial_state=initial_state,
view=view,
controller=controller)
if __name__ == '__main__':
chess_deck.run()
In action:
From the code you can see, that views can be easily nested with yield from
. So if we want to move
cell to separate view:
def cell(x, y, inverted):
yield from rectangle(x=x, y=y, w=20, h=20,
fill=is_filled(x, y, inverted))
def view(state):
for x in range(0, state['display']['width'], 20):
for y in range(0, state['display']['height'], 20):
yield from cell(x, y, state['inverted'])
And another nice thing about this approach, is that because of generators we consume not a lot of memory,
if we’ll make it eager, we’ll fail with MemoryError: memory allocation failed
soon.
Back to breakout, let’s start with views, first of all implement splash screen:
def splash(w, h):
for n in range(0, w, 20):
yield from rectangle(x=n, y=0, w=10, h=h, fill=True)
yield from rectangle(x=0, y=17, w=w, h=30, fill=False)
# text is bundled view
yield from text(x=0, y=20, string='BREAKOUT', size=3)
def view(state):
yield from splash(state['display']['width'],
state['display']['height'])
It will draw a nice splash screen:
On splash screen game should be started when user press joystick, so we should update code a bit:
GAME_NOT_STARTED = 0
GAME_ACTIVE = 1
GAME_OVER = 2
def controller(state):
if state['status'] == GAME_NOT_STARTED and state['joystick']['clicked']:
state['status'] = GAME_ACTIVE
return state
initial_state = {'status': GAME_NOT_STARTED}
Now when joystick is pressed, game changes status
to GAME_ACTIVE
. And now it’s time to create view
for game screen:
BRICK_W = 8
BRICK_H = 4
BRICK_BORDER = 1
BRICK_ROWS = 4
PADDLE_W = 16
PADDLE_H = 4
BALL_W = 3
BALL_H = 3
def brick(data):
yield from rectangle(x=data['x'] + BRICK_BORDER,
y=data['y'] + BRICK_BORDER,
w=BRICK_W - BRICK_BORDER,
h=BRICK_H - BRICK_BORDER,
fill=True)
def paddle(data):
yield from rectangle(x=data['x'], y=data['y'],
w=PADDLE_W, h=PADDLE_H,
fill=True)
def ball(data):
yield from rectangle(x=data['x'], y=data['y'],
w=BALL_W, h=BALL_H, fill=True)
def deck(state):
for brick_data in state['bricks']:
yield from brick(brick_data)
yield from paddle(state['paddle'])
yield from ball(state['ball'])
def view(state):
if state['status'] == GAME_NOT_STARTED:
yield from splash(state['display']['width'],
state['display']['height'])
else:
yield from deck(state)
def get_initial_game_state(state):
state['status'] = GAME_ACTIVE
state['bricks'] = [{'x': x, 'y': yn * BRICK_H}
for x in range(0, state['display']['width'], BRICK_W)
for yn in range(BRICK_ROWS)]
state['paddle'] = {'x': (state['display']['width'] - PADDLE_W) / 2,
'y': state['display']['height'] - PADDLE_H}
state['ball'] = {'x': (state['display']['width'] - BALL_W) / 2,
'y': state['display']['height'] - PADDLE_H * 2 - BALL_W}
return state
def controller(state):
if state['status'] == GAME_NOT_STARTED and state['joystick']['clicked']:
state = get_initial_game_state(state)
return state
And last part of views – game over screen:
def game_over():
yield from text(x=0, y=20, string='GAMEOVER', size=3)
def view(state):
if state['status'] == GAME_NOT_STARTED:
yield from splash(state['display']['width'],
state['display']['height'])
else:
yield from deck(state)
if state['status'] == GAME_OVER:
yield from game_over()
So we ended up with views, now we should add ability to move paddle with joystick:
def update_paddle(paddle, joystick, w):
paddle['x'] += int(joystick['x'] / 10)
if paddle['x'] < 0:
paddle['x'] = 0
elif paddle['x'] > (w - PADDLE_W):
paddle['x'] = w - PADDLE_W
return paddle
def controller(state):
if state['status'] == GAME_NOT_STARTED and state['joystick']['clicked']:
state = get_initial_game_state(state)
elif state['status'] == GAME_ACTIVE:
state['paddle'] = update_paddle(state['paddle'], state['joystick'],
state['display']['width'])
return state
Never mind performance, it’ll be fixed in the end of the article:
Now it’s time for the hardest thing – moving and bouncing ball, so there’s no real physics, for simplification
ball movements will be represented as vx
and vy
, so when ball:
- initialised:
vx = rand(SPEED)
,vy = √SPEED^2 - vx^2
; - hits the top wall:
vy = -vy
; - hits the left or right wall:
vx = -vx
: - hits the brick or paddle:
vx = vx + (0.5 - intersection) * SPEED
, where intersection is between0
and1
;vy = √SPEED^2 - vx^2
.
And I implemented something like this with a few hacks:
BALL_SPEED = 6
BALL_SPEED_BORDER = 0.5
def get_initial_game_state(state):
state['status'] = GAME_ACTIVE
state['bricks'] = [{'x': x, 'y': yn * BRICK_H}
for x in range(0, state['display']['width'], BRICK_W)
for yn in range(BRICK_ROWS)]
state['paddle'] = {'x': (state['display']['width'] - PADDLE_W) / 2,
'y': state['display']['height'] - PADDLE_H}
# Initial velocity for ball:
ball_vx = BALL_SPEED_BORDER + pyb.rng() % (BALL_SPEED - BALL_SPEED_BORDER)
ball_vy = -math.sqrt(BALL_SPEED ** 2 - ball_vx ** 2)
state['ball'] = {'x': (state['display']['width'] - BALL_W) / 2,
'y': state['display']['height'] - PADDLE_H * 2 - BALL_W,
'vx': ball_vx,
'vy': ball_vy}
return state
def calculate_velocity(ball, item_x, item_w):
"""Calculates velocity for collision."""
intersection = (item_x + item_w - ball['x']) / item_w
vx = ball['vx'] + BALL_SPEED * (0.5 - intersection)
if vx > BALL_SPEED - BALL_SPEED_BORDER:
vx = BALL_SPEED - BALL_SPEED_BORDER
elif vx < BALL_SPEED_BORDER - BALL_SPEED:
vx = BALL_SPEED_BORDER - BALL_SPEED
vy = math.sqrt(BALL_SPEED ** 2 - vx ** 2)
if ball['vy'] > 0:
vy = - vy
return vx, vy
def collide(ball, item, item_w, item_h):
return item['x'] - BALL_W < ball['x'] < item['x'] + item_w \
and item['y'] - BALL_H < ball['y'] < item['y'] + item_h
def update_ball(state):
state['ball']['x'] += state['ball']['vx']
state['ball']['y'] += state['ball']['vy']
# Collide with left/right wall
if state['ball']['x'] <= 0 or state['ball']['x'] >= state['display']['width']:
state['ball']['vx'] = - state['ball']['vx']
# Collide with top wall
if state['ball']['y'] <= 0:
state['ball']['vy'] = -state['ball']['vy']
# Collide with paddle
if collide(state['ball'], state['paddle'], PADDLE_W, PADDLE_H):
vx, vy = calculate_velocity(state['ball'], state['paddle']['x'], PADDLE_W)
state['ball'].update(vx=vx, vy=vy)
# Collide with brick
for n, brick in enumerate(state['bricks']):
if collide(state['ball'], brick, BRICK_W, BRICK_H):
vx, vy = calculate_velocity(state['ball'], brick['x'], BRICK_W)
state['ball'].update(vx=vx, vy=vy)
state['bricks'].pop(n)
return state
def controller(state):
if state['status'] == GAME_NOT_STARTED and state['joystick']['clicked']:
state = get_initial_game_state(state)
elif state['status'] == GAME_ACTIVE:
state['paddle'] = update_paddle(state['paddle'], state['joystick'],
state['display']['width'])
state = update_ball(state)
return state
And it seems to be wroking:
So now the last part, we should show “Game Over” when ball hits the bottom wall or when all bricks destroyed, and then start game again if user clicks joystick:
def is_game_over(state):
return not state['bricks'] or state['ball']['y'] > state['display']['height']
def controller(state):
if state['status'] in (GAME_NOT_STARTED, GAME_OVER)\
and state['joystick']['clicked']:
state = get_initial_game_state(state)
elif state['status'] == GAME_ACTIVE:
state['paddle'] = update_paddle(state['paddle'], state['joystick'],
state['display']['width'])
state = update_ball(state)
if is_game_over(state):
state['status'] = GAME_OVER
return state
And it works too:
Performance so bad because drawing pixel on the screen is relatively time consuming operation, and we can easily fix performance by just decreasing count of bricks:
BRICK_W = 12
BRICK_H = 6
BRICK_BORDER = 4
BRICK_ROWS = 3
And now it’s smooth: