Friday, February 27, 2015

Simon (Python / Pygame)

This project is based on the Milton Bradley toy called Simon.  The toy adds a note/color to a sequence, which the player must play back. The original has 4 possible notes and colors. According to the Wiki, the originals are 
A link in the comments helped me associate these with the correct numbers. On that note, this StackOverflow answer by jose helped a lot to use pygame.midi.

I wanted to make a Simon that was simple but true to the original. Listed below are some features added to that end.

Levels of Difficulty

I have levels of difficulty that are considered a win when a sequence of a given length is repeated back. The levels 1-4 correspond to the Wiki's list of 8, 14, 20, and 31. For historical value (i.e., I used it to test) there is a level 0 that asks for a user to play back a note. There is also an unending level. Selection of difficulty is the first screen the user sees.


Sequences Gradually Quicken

I noticed in LuckyPennyShop.com's YouTube video Vintage Electronic Simon Game 1978 Milton Bradley Toys that the sequence not only got longer but played faster. This not only makes the game more difficult, but it also brings you to the new information faster. For this I used a variation of

where 0 < c < 1 is a non-negative scaling constant and ell is the length of the list. (c = 1 made sequences play too quickly for long sequences.) A benefit to this function is that it decreases, but levels out asymptotically to zero. We can add a minimum m not to go faster than.

Correct Tile Blinks On Miss

I noticed that the ending tile in the video above blinks several times when the sequence is endedcorrectly, if I followed the video right. I resort to an end screen. However, for incorrect tiles the tile that should have been pressed blinks 4 times. (I don't know if this happens in the original.)

Images of Gameplay

A board of unlit tiles looks like this:



Compare to this one, where the red button is lit:

The reason I have the number of tiles n as a constant was because I hoped to expand this to even more tiles. (I have tried with 9.) However, on top of the ideal case of having more colors and sounds for the tiles, the brightening feature (the "bright" tuple in Button.draw()) ought to be changed: Right now it naively turns any nonzeros into 255 and leaves zeros alone. If we had a tile of (132, 210, 0) for example, its bright color is the same as our yellow's, which is given by (150,150,0).

When the game is over this end screen comes up:


The "sequence" box is mostly for the edification of those who chose infinite mode. Here we see the second number is 31, which means the player was on difficulty 4. The 7 is the size of the sequence that the user didn't play back correctly.

Clicking Replay takes the user back to the difficulty screen and Quit exits. There are several places where I call the end() functions. An improvement might be adding threads, one that keeps track of when the window's "x" button is clicked and another for inside the game.

Here's the code:


'''Simple Simon'''

import math, time, pygame, pygame.midi, random

pygame.midi.init()
pygame.font.init()
audio = pygame.midi.Output(0)
audio.set_instrument(4,1)

# midi chart: http://www.electronics.dit.ie/staff/tscarff/Music_
#             technology/midi/midi_note_numbers_for_octaves.htm
BLACK = (255,255,255)
colors = [(0,150,0),(150,0,0),(150,150,0),(0,0,150)] #g,r,y ,b
sounds = [52,57,61,64]                               #e,a,c#,e_hi
hardness = {'0':1,'1':8, '2':14, '3':20, '4':31, '∞': -1}

size = 200
time_scale = .1
min_pause = .1
n = math.ceil(math.sqrt(len(colors)))
win_size = n*size
mid = round(n*size/2)
window = pygame.display.set_mode( (win_size, win_size) )
pygame.display.set_caption("Simple Simon")

class Pane:
    def __init__(self,text,pos,font_size=40,color=BLACK,fun=None):
        self.fun = fun
        self.text = text
        font = pygame.font.SysFont('Arial', font_size)
        surf = font.render(text, True, color)
        self.rect = surf.get_rect()
        self.rect.center = pos
        window.blit(surf, self.rect.topleft)        

class Button:
    def __init__(self,i):
        self.x = size * (i%n)
        self.y = size * (i//n)
        self.color = colors[i]
        self.sound = sounds[i]
        self.rect = pygame.Rect( (self.x,self.y), (size,size) )
        self.draw()
    def draw(self, lit=False, wait=0):
        bright = tuple(255 if i else 0 for i in self.color)
        color = bright if lit else self.color
        if lit:
            audio.note_on(self.sound,127,1)
        else:
            audio.note_off(self.sound,127,1)
        pygame.draw.rect(window,color,self.rect)        
        pygame.display.update()
        time.sleep(wait)

def end():
    audio.close()
    pygame.midi.quit()
    pygame.quit()
    quit()

def choose_mode(ts=60,ms=40):
    window.fill( (0,0,0) )
    while True:
        modes = enumerate(sorted(hardness.keys()))
        title = Pane('Select hardness', (mid,ts/2), ts)
        diffs = [Pane(x,(mid,ms*i+1.5*ts),ms,) for i,x in modes]
        pygame.display.update()
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                end()
            if event.type == pygame.MOUSEBUTTONDOWN:
                for diff in diffs:
                    if diff.rect.collidepoint(event.pos):
                        return diff.text
                        
def simon():
    player,wrong = False,False
    sequence = []
    correct = 0
    mode = choose_mode()    
    tiles = [Button(i) for i in range(len(colors))]
    while True:
        # player's turn
        if player:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    end()
                if event.type == pygame.MOUSEBUTTONDOWN:
                    for tile in tiles:
                        if tile.rect.collidepoint(event.pos):
                            tile.draw(lit=True)
                            if tile == sequence[correct]:
                                correct += 1
                            else:
                                wrong = True
                # letting go of mouse button redraws all tiles
                elif event.type == pygame.MOUSEBUTTONUP:
                    [tile.draw() for tile in tiles]
                    # correct tile blinks on miss
                    if wrong:
                        right = sequence[correct]
                        for i in range(4):
                            right.draw(lit=True,wait=.1)
                            right.draw(wait=.1)
                        end_screen(len(sequence),mode,'Lost')
                    # player used correct sequence 
                    elif correct == len(sequence):
                        correct = 0
                        player = False
        # computer's turn
        else:
            chain = len(sequence)
            pause = math.exp(-time_scale*chain)
            # player wins, if not in infinite mode
            if chain == hardness[mode]:
                end_screen(chain, mode)
            # pause before showing next sequence
            time.sleep(min_pause + pause)
            sequence.append(random.choice(tiles))
            for tile in sequence:
                tile.draw(lit=True,wait=pause)
                tile.draw(wait=min_pause*pause)
                # player can quit mid-sequence
                for event in pygame.event.get():
                    if event.type == pygame.QUIT:
                        end()
            player = True
        pygame.display.update()
                    
def end_screen(streak, mode, result='Won', fsize=40):    
    funs = [None, None, simon, end]
    most = str(hardness[mode]) if mode.isalnum() else '∞'
    texts = ['You %s!' % result,
             'Sequence: %d/%s' % (streak,most),
             'Replay', 'Quit']
    data = enumerate(zip(texts,funs))
    panes = [Pane(x[0],(mid,(i+1)*1.5*fsize),fsize,fun=x[1]) for i,x in data]
    pygame.display.update()
    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                end()
            if event.type == pygame.MOUSEBUTTONDOWN:
                for pane in panes:
                    if pane.rect.collidepoint(event.pos):
                        if pane.fun:
                            pane.fun()

simon()

Sunday, February 22, 2015

15 Puzzle (Python / Pygame)

In the 15 Puzzle you are given a permutation of 15 numbers in 16 slots. The puzzle is solved when the numbers are put in order with an empty spot in the 16th. Some papers I read generalized this to be "The (n**2 - 1) Puzzle." This code allows for that, though some resizing of globals may be in order to ensure that the game fits on the screen.

Not every permutation is solvable. The check in Board.permute()is found on WolframAlpha's page for the 15 puzzle.The idea is: For each tile, read left-to-right and top-to-bottom, you count the number of tiles smaller than it. Then you add the row the blank is on. If this number is even, we have a solvable permutation.

My last addition allows Multi-Tile Moves (MTM), which means that you can move more than one tile with one click if they are in a straight line.

Here is an example for n = 4. I have found a way to center the numbers in their tiles since Memory






A brief example of the WolframAlpha method:
  • 12 has no numbers before it, so we add 0
  • 1 has no numbers before it that are less than 1, so we add 0
  • 2 has 1 before it, so we add 1
  • 7 has 1 and 2 before it, so we add 2. Our tally is 3
  • ...
  • 13 has the numbers 1-12 before it, so we add 12 to our tally
  • The blank is on the fourth row, so we add 4 to our tally
  • The tally must be divisible by 2, because Python chose this as a valid permutation
As a demonstration of the MTM, note that the 13 block in the following image was clicked to move both the 9 and the 13 to the left. The `Click` tally is therefore 1 instead of 2, because only one tile was clicked even though two were moved.



I solved the above in 58 clicks using a method similar to the one shown on Wikihow. I would like to make the Game Won screen a little less anticlimactic: At the moment it looks like a regular screen. The user is expected to press Restart if they want to play again or the window exit button if they wish to quit.



Here is the code:
# 16-blocks

import pygame, random

pygame.init()
pygame.font.init()

n = 4
pad = 50
toolbar = 100
box_size = 150
side = n*box_size + (n + 1)*pad 
window = pygame.display.set_mode( (side, side+toolbar) )
pygame.display.set_caption("15-Blox")
font = pygame.font.SysFont('Verdana', 60)

WHITE = (255,255,255)
BLACK = (0,0,0)

clicks = 0

class Tile:
    def __init__(self, i, j):
        self.x = (i+1)*pad + i*box_size
        self.y = (j+1)*pad + j*box_size 
        self.id = i + j * n
        self.val = self.id
        self.color = WHITE
        self.surf = pygame.Surface( (box_size, box_size) )
        self.rect = pygame.Rect((self.x, self.y),(box_size,box_size))
        self.draw()
    def draw(self):
        if self.val != 0:            
            pygame.draw.rect(window, WHITE, self.rect)
            num_surf = font.render(str(self.val), True, BLACK)
            num_rect = num_surf.get_rect()
            num_rect.center = self.rect.center
            window.blit(num_surf, (num_rect.x, num_rect.y) )
        else:
            pygame.draw.rect(window, BLACK, self.rect)
    def __repr__(self):
        return str(self.val)

class Board:
    win_state = [i for i in range(1,n*n)] + [0]
    def __init__(self):
        # tiles in order 0-15
        self.board = [Tile(i,j) for j in range(n) for i in range(n)]
        self.vals = [i for i in range(n*n)]
        self.permute()
    def draw(self):
        window.fill(BLACK)
        for tile in self.board:
            tile.draw()
    def swap(self, clicked, index):
        global clicks
        if clicked.val != 0:
            way = 0
            if self.zero//n == index//n: # same row
                way = 1 if self.zero < index else -1
            elif self.zero%n == index%n: # same col
                way = n if self.zero < index else -n
            if way:
                for i in range(self.zero, index+way, way):
                    self.vals[self.zero] = self.vals[i]
                    self.vals[i] = 0
                    self.update()
                clicks += 1
    def update(self):
        self.zero = self.vals.index(0)
        for i,tile in enumerate(self.board):
            tile.val = self.vals[i]
    def permute(self):
        while True:
            random.shuffle(self.vals)
            self.update()
            e = self.vals.index(0) // n # row of zero
            counts = []
            for i,tile in enumerate(self.board):
                for j in range(i):
                    if 0 < self.vals[j] < tile.val:
                        counts.append(1)
            if (sum(counts) + e) % 2 == 0:
                return
    
def blox():
    global clicks
    clicks = 0
    board = Board()
    while True:
        board.draw()
        
        # stats pane
        surf = font.render('Clicks: ' + str(clicks), True, WHITE)
        rect = surf.get_rect()
        rect.bottomleft = (0, side+toolbar)
        window.blit(surf, (rect.x, rect.y) )
        
        # restart button
        restart_surf = font.render('Restart', True, WHITE)
        restart_rect = restart_surf.get_rect()
        restart_rect.bottomright = (side, side+toolbar)
        window.blit(restart_surf, (restart_rect.x, restart_rect.y) )

        # event loop
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                pygame.font.quit()
                quit()
            if event.type == pygame.MOUSEBUTTONDOWN:
                if restart_rect.collidepoint(event.pos):
                    blox()
                for index,tile in enumerate(board.board):
                    if tile.rect.collidepoint(event.pos):
                        board.swap(tile, index)
                    if board.vals == Board.win_state:
                        break

        pygame.display.update()

blox()