Justin Pombrio

Lights Out

I discovered a variant of the lights-out puzzle which I find strangely addicting. The board is large (12 by 12). Pressing a light toggles it as well as its neighbors. On the N‘th level, the computer “presses” 4N lights at random. Your task is to undo the presses. It is an exercise in pattern matching. The task gets more difficult at later levels as the overlapping patterns begin to look random, but at the same time you have more clicks available because the computer may choose a location more than once (and pressing a light an even number of times is the same as not pressing it at all). If you learned to beat the game, you would be able to solve the 12x12 lights-out puzzle.

Notice that the order of clicks is unimportant (the operation is commutative), and that clicking a location K times is the same as clicking it K%2 times. Thus a solution can be described by a bitmap over the board.

And the source (uses Pygame):

import pygame
import random
from pygame.locals import *

'''
A strangely addicting game, based on the Lights Out puzzle.

Justin Pombrio, 2009
'''

START_LEVEL = 1
DIFFICULTY_INCREASE = 4

BACKGROUND = pygame.Color(130, 130, 130)
CELL_SIZE = 64
GRID_SIZE = (12, 12)

TOP_HEIGHT = 60
LINE_WIDTH = 2
LIGHT_BORDER = 4
INNER_LIGHT_BORDER = 16

TOP_COLOR = pygame.Color(240, 240, 240)
LINE_COLOR = pygame.Color(80, 60, 40)
CELL_COLOR = pygame.Color(160, 130, 80)
LIGHT_COLOR = pygame.Color(150, 30, 30)
INNER_LIGHT_COLOR = pygame.Color(160, 35, 35)

TEXT_COLOR = pygame.Color(0, 0, 0)
TEXT_SIZE = 26
TEXT_POSITIONS = [(20, 20), (200, 20), (400, 20)]

class Grid:
    def __init__(self, size):
        self.width = size[0]
        self.height = size[1]
        self.lights = [[False for y in range(self.height)]
                       for x in range(self.width)]

    def clear(self):
        for x in range(self.width):
            for y in range(self.height):
                self.lights[x][y] = False

    def valid(self, light):
        x, y = light
        return x >= 0 and y >= 0 and x < self.width and y < self.height

    def toggle(self, light):
        if self.valid(light):
            x, y = light
            self.lights[x][y] = not self.lights[x][y]

    def press_random(self):
        """Press a random cell."""
        x = random.randrange(0, GRID_SIZE[0])
        y = random.randrange(0, GRID_SIZE[1])
        self.press((x, y))

    def press(self, light):
        x, y = light
        affected_lights = [(x, y), (x-1, y), (x+1, y), (x, y-1), (x, y+1)]
        for light in affected_lights:
            self.toggle(light)

    def empty(self):
        for x in range(GRID_SIZE[0]):
            for y in range(GRID_SIZE[1]):
                if self.lights[x][y]:
                    return False
        return True

    def full(self):
        for x in range(GRID_SIZE[0]):
            for y in range(GRID_SIZE[1]):
                if not self.lights[x][y]:
                    return False
        return True


class Game:
    def __init__(self):
        pygame.init()
        
        self.grid = Grid(GRID_SIZE)
        self.font = pygame.font.Font(pygame.font.get_default_font(), TEXT_SIZE)
        
        screen_size = (CELL_SIZE*GRID_SIZE[0],
                       CELL_SIZE*GRID_SIZE[1] + TOP_HEIGHT)
        self.screen = pygame.display.set_mode(screen_size)

        self.max_level = 0
        self.load_level(START_LEVEL)
        while True:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    return
                elif event.type == pygame.KEYDOWN:
                    if event.key == pygame.K_ESCAPE:
                        return
                elif event.type == pygame.MOUSEBUTTONDOWN:
                    if event.button == 1: # Left click
                        self.press(event.pos)

    def to_cell(self, pos):
        return (int(pos[0]/CELL_SIZE), int((pos[1] - TOP_HEIGHT)/CELL_SIZE))

    def to_screen(self, pos):
        return (pos[0]*CELL_SIZE, pos[1]*CELL_SIZE + TOP_HEIGHT)

    def draw(self):
        self.screen.fill(LINE_COLOR)
        top = pygame.Rect(0, 0, self.screen.get_width(), TOP_HEIGHT)
        pygame.draw.rect(self.screen, TOP_COLOR, top)
        for x in range(GRID_SIZE[0]):
            for y in range(GRID_SIZE[1]):
                self.draw_cell(x, y, self.grid.lights[x][y])
        self.draw_text("Level: " + str(self.level), TEXT_POSITIONS[0])
        self.draw_text("Clicks left: " + str(self.clicks), TEXT_POSITIONS[1])
        self.draw_text("Best level: " + str(self.max_level), TEXT_POSITIONS[2])
        pygame.display.flip()

    def _get_rect(self, pos, border):
        return pygame.Rect(pos[0] + border, pos[1] + border,
                           CELL_SIZE - 2*border, CELL_SIZE - 2*border)

    def draw_cell(self, x, y, is_on):
        pos = self.to_screen((x, y))
        rect = self._get_rect(pos, LINE_WIDTH)
        light_rect = self._get_rect(pos, LIGHT_BORDER)
        inner_light_rect = self._get_rect(pos, INNER_LIGHT_BORDER)
        pygame.draw.rect(self.screen, CELL_COLOR, rect)
        if is_on:
            pygame.draw.ellipse(self.screen, LIGHT_COLOR, light_rect)

    def draw_text(self, text, pos):
        text_image = self.font.render(text, True, TEXT_COLOR)
        self.screen.blit(text_image, pos)

    def press(self, pos):
        cell = self.to_cell(pos)
        if self.grid.valid(cell):
            self.clicks = self.clicks - 1
        self.grid.press(self.to_cell(pos))
        self.draw()
        if self.grid.empty() or self.grid.full():
            self.load_level(self.level + 1)
        elif self.clicks == 0:
            self.load_level(self.level - 1)
        
    def load_level(self, level):
        self.clicks = 0
        self.level = level
        self.max_level = max(level, self.max_level)
        difficulty = DIFFICULTY_INCREASE*level
        self.clicks = difficulty
        self.grid.clear()
        for i in range(difficulty):
            self.grid.press_random()
        self.draw()


if __name__ == '__main__':
    game = Game()
    pygame.quit()