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:
fromlib.ssd1306importDisplayfromlib.keyesimportJoystickfromlib.engineimportGame,rectangle,textdefis_filled(x,y,inverted):fill=(x+y)%40==0ifinverted:returnnotfillelse:returnfilldefview(state):# Display data available in state['display']forxinrange(0,state['display']['width'],20):foryinrange(0,state['display']['height'],20):# rectangle is bundled view that yields pointsyieldfromrectangle(x=x,y=y,w=20,h=20,
fill=is_filled(x,y,state['inverted']))
defcontroller(state):
#Joystickdataavailableinstate['display']
ifstate['joystick']['clicked']:
returndict(state,inverted=notstate['inverted'])
else:
returnstateinitial_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:
defcell(x,y,inverted):yieldfromrectangle(x=x,y=y,w=20,h=20,
fill=is_filled(x,y,inverted))
defview(state):
forxinrange(0,state['display']['width'],20):
foryinrange(0,state['display']['height'],20):
yieldfromcell(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:
defsplash(w,h):forninrange(0,w,20):yieldfromrectangle(x=n,y=0,w=10,h=h,fill=True)
yieldfromrectangle(x=0,y=17,w=w,h=30,fill=False)
#textisbundledviewyieldfromtext(x=0,y=20,string='BREAKOUT',size=3)
defview(state):
yieldfromsplash(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=0GAME_ACTIVE=1GAME_OVER=2defcontroller(state):ifstate['status']==GAME_NOT_STARTEDandstate['joystick']['clicked']:state['status']=GAME_ACTIVEreturnstateinitial_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=8BRICK_H=4BRICK_BORDER=1BRICK_ROWS=4PADDLE_W=16PADDLE_H=4BALL_W=3BALL_H=3defbrick(data):yieldfromrectangle(x=data['x']+BRICK_BORDER,
y=data['y']+BRICK_BORDER,
w=BRICK_W-BRICK_BORDER,
h=BRICK_H-BRICK_BORDER,
fill=True)
defpaddle(data):
yieldfromrectangle(x=data['x'],y=data['y'],
w=PADDLE_W,h=PADDLE_H,
fill=True)
defball(data):
yieldfromrectangle(x=data['x'],y=data['y'],
w=BALL_W,h=BALL_H,fill=True)
defdeck(state):
forbrick_datainstate['bricks']:
yieldfrombrick(brick_data)
yieldfrompaddle(state['paddle'])
yieldfromball(state['ball'])
defview(state):
ifstate['status']==GAME_NOT_STARTED:
yieldfromsplash(state['display']['width'],
state['display']['height'])
else:
yieldfromdeck(state)
defget_initial_game_state(state):
state['status']=GAME_ACTIVEstate['bricks']=[{'x':x,'y':yn*BRICK_H}
forxinrange(0,state['display']['width'],BRICK_W)
foryninrange(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}
returnstatedefcontroller(state):
ifstate['status']==GAME_NOT_STARTEDandstate['joystick']['clicked']:
state=get_initial_game_state(state)
returnstate
And last part of views – game over screen:
defgame_over():yieldfromtext(x=0,y=20,string='GAMEOVER',size=3)
defview(state):
ifstate['status']==GAME_NOT_STARTED:
yieldfromsplash(state['display']['width'],
state['display']['height'])
else:
yieldfromdeck(state)
ifstate['status']==GAME_OVER:
yieldfromgame_over()
So we ended up with views, now we should add ability to move paddle with joystick:
defupdate_paddle(paddle,joystick,w):paddle['x']+=int(joystick['x']/10)ifpaddle['x']<0:paddle['x']=0elifpaddle['x']>(w-PADDLE_W):paddle['x']=w-PADDLE_Wreturnpaddledefcontroller(state):ifstate['status']==GAME_NOT_STARTEDandstate['joystick']['clicked']:state=get_initial_game_state(state)elifstate['status']==GAME_ACTIVE:state['paddle']=update_paddle(state['paddle'],state['joystick'],state['display']['width'])returnstate
Never mind performance, it’ll be fixed in the end of the article:
Now it’s time for teh 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=6BALL_SPEED_BORDER=0.5defget_initial_game_state(state):state['status']=GAME_ACTIVEstate['bricks']=[{'x':x,'y':yn*BRICK_H}forxinrange(0,state['display']['width'],BRICK_W)foryninrange(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}returnstatedefcalculate_velocity(ball,item_x,item_w):"""Calculates velocity for collision."""intersection=(item_x+item_w-ball['x'])/item_wvx=ball['vx']+BALL_SPEED*(0.5-intersection)ifvx>BALL_SPEED-BALL_SPEED_BORDER:vx=BALL_SPEED-BALL_SPEED_BORDERelifvx<BALL_SPEED_BORDER-BALL_SPEED:vx=BALL_SPEED_BORDER-BALL_SPEEDvy=math.sqrt(BALL_SPEED**2-vx**2)ifball['vy']>0:vy=-vyreturnvx,vydefcollide(ball,item,item_w,item_h):returnitem['x']-BALL_W<ball['x']<item['x']+item_w \
anditem['y']-BALL_H<ball['y']<item['y']+item_hdefupdate_ball(state):state['ball']['x']+=state['ball']['vx']state['ball']['y']+=state['ball']['vy']# Collide with left/right wallifstate['ball']['x']<=0orstate['ball']['x']>=state['display']['width']:state['ball']['vx']=-state['ball']['vx']# Collide with top wallifstate['ball']['y']<=0:state['ball']['vy']=-state['ball']['vy']# Collide with paddleifcollide(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 brickforn,brickinenumerate(state['bricks']):ifcollide(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)returnstatedefcontroller(state):ifstate['status']==GAME_NOT_STARTEDandstate['joystick']['clicked']:state=get_initial_game_state(state)elifstate['status']==GAME_ACTIVE:state['paddle']=update_paddle(state['paddle'],state['joystick'],state['display']['width'])state=update_ball(state)returnstate
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:
defis_game_over(state):returnnotstate['bricks']orstate['ball']['y']>state['display']['height']defcontroller(state):ifstate['status']in(GAME_NOT_STARTED,GAME_OVER)\
andstate['joystick']['clicked']:state=get_initial_game_state(state)elifstate['status']==GAME_ACTIVE:state['paddle']=update_paddle(state['paddle'],state['joystick'],state['display']['width'])state=update_ball(state)ifis_game_over(state):state['status']=GAME_OVERreturnstate
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=12BRICK_H=6BRICK_BORDER=4BRICK_ROWS=3
And now it’s smooth: