diff --git a/.gitignore b/.gitignore index 7bbc71c..10da17d 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,10 @@ ENV/ # mypy .mypy_cache/ + +# PyCharm +.idea/ + +# 2048 save files. +2048.*.state +2048.score diff --git a/README.md b/README.md index 5df1a5d..ef9a32a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # 2048 -My version of 2048 game, with multi-instance support +My version of 2048 game, with multi-instance support, restored from +an old high school project. diff --git a/_2048/ClearSans-Bold.ttf b/_2048/ClearSans-Bold.ttf new file mode 100644 index 0000000..f5380ea Binary files /dev/null and b/_2048/ClearSans-Bold.ttf differ diff --git a/_2048/ClearSans.ttf b/_2048/ClearSans.ttf new file mode 100644 index 0000000..c2b3227 Binary files /dev/null and b/_2048/ClearSans.ttf differ diff --git a/_2048/__init__.py b/_2048/__init__.py new file mode 100644 index 0000000..81dec66 --- /dev/null +++ b/_2048/__init__.py @@ -0,0 +1,3 @@ +from .game import Game2048 +from .manager import GameManager +from .main import run_game, main diff --git a/_2048/__main__.py b/_2048/__main__.py new file mode 100644 index 0000000..ff2f885 --- /dev/null +++ b/_2048/__main__.py @@ -0,0 +1,14 @@ +"""This module makes the package executable""" + +from .main import run_game +import os + + +def main(): + """Execute the game and store state in the current directory.""" + run_game(data_dir=os.getcwd()) + + +# Run the main function if this file is executed directly. +if __name__ == '__main__': + main() diff --git a/_2048/game.py b/_2048/game.py new file mode 100644 index 0000000..09e6ca3 --- /dev/null +++ b/_2048/game.py @@ -0,0 +1,534 @@ +"""Contains the main game class, responsible for one game of 2048. + +This class handles the actual rendering of a game, and the game logic.""" + +import random + +import os +import pygame + +from .utils import load_font, center + + +class AnimatedTile(object): + """This class represents a moving tile.""" + + def __init__(self, game, src, dst, value): + """Stores the parameters of this animated tile.""" + self.game = game + self.sx, self.sy = game.get_tile_location(*src) + self.tx, self.ty = game.get_tile_location(*dst) + self.dx, self.dy = self.tx - self.sx, self.ty - self.sy + self.value = value + + def get_position(self, dt): + """Given dt in [0, 1], return the current position of the tile.""" + return self.sx + self.dx * dt, self.sy + self.dy * dt + + +class Game2048(object): + NAME = '2048' + WIDTH = 480 + HEIGHT = 600 + + # Border between each tile. + BORDER = 10 + + # Number of tiles in each direction. + COUNT_X = 4 + COUNT_Y = 4 + + # The tile to get to win the game. + WIN_TILE = 2048 + + # Length of tile moving animation. + ANIMATION_FRAMES = 10 + + BACKGROUND = (0xbb, 0xad, 0xa0) + FONT_NAME = os.path.join(os.path.dirname(__file__), 'ClearSans.ttf') + BOLD_NAME = os.path.join(os.path.dirname(__file__), 'ClearSans-Bold.ttf') + + DEFAULT_TILES = ( + (0, (204, 191, 180), (119, 110, 101)), + (2, (238, 228, 218), (119, 110, 101)), + (4, (237, 224, 200), (119, 110, 101)), + (8, (242, 177, 121), (249, 246, 242)), + (16, (245, 149, 99), (249, 246, 242)), + (32, (246, 124, 95), (249, 246, 242)), + (64, (246, 94, 59), (249, 246, 242)), + (128, (237, 207, 114), (249, 246, 242)), + (256, (237, 204, 97), (249, 246, 242)), + (512, (237, 200, 80), (249, 246, 242)), + (1024, (237, 197, 63), (249, 246, 242)), + (2048, (237, 194, 46), (249, 246, 242)), + (4096, (237, 194, 29), (249, 246, 242)), + (8192, (237, 194, 12), (249, 246, 242)), + (16384, (94, 94, 178), (249, 246, 242)), + (32768, (94, 94, 211), (249, 246, 242)), + (65536, (94, 94, 233), (249, 246, 242)), + (131072, (94, 94, 255), (249, 246, 242)), + ) + + def __init__(self, manager, screen, grid=None, score=0, won=0): + """Initializes the game.""" + # Stores the manager, screen, score, state, and winning status. + self.manager = manager + self.old_score = self.score = score + self.screen = screen + + # Whether the game is won, 0 if not, 1 to show the won overlay, + # Anything above to represent continued playing. + self.won = won + + self.lost = False + self.tiles = {} + + # A cache for scaled tiles. + self._scale_cache = {} + + # The point on the screen where the game actually takes place. + self.origin = (0, 120) + + self.game_width = self.WIDTH - self.origin[0] + self.game_height = self.HEIGHT - self.origin[1] + + self.cell_width = (self.game_width - self.BORDER) / self.COUNT_X - self.BORDER + self.cell_height = (self.game_height - self.BORDER) / self.COUNT_Y - self.BORDER + + # Use saved grid if possible. + if grid is None: + self.grid = [[0] * self.COUNT_X for _ in xrange(self.COUNT_Y)] + free = self.free_cells() + for x, y in random.sample(free, min(2, len(free))): + self.grid[y][x] = random.randint(0, 10) and 2 or 4 + else: + self.grid = grid + + # List to store past rounds, for undo. + # Finding how to undo is left as an exercise for the user. + self.old = [] + + # Keyboard event handlers. + self.key_handlers = { + pygame.K_LEFT: lambda e: self._shift_cells( + get_cells=lambda: ((r, c) for r in xrange(self.COUNT_Y) + for c in xrange(self.COUNT_X)), + get_deltas=lambda r, c: ((r, i) for i in xrange(c + 1, self.COUNT_X)), + ), + pygame.K_RIGHT: lambda e: self._shift_cells( + get_cells=lambda: ((r, c) for r in xrange(self.COUNT_Y) + for c in xrange(self.COUNT_X - 1, -1, -1)), + get_deltas=lambda r, c: ((r, i) for i in xrange(c - 1, -1, -1)), + ), + pygame.K_UP: lambda e: self._shift_cells( + get_cells=lambda: ((r, c) for c in xrange(self.COUNT_X) + for r in xrange(self.COUNT_Y)), + get_deltas=lambda r, c: ((i, c) for i in xrange(r + 1, self.COUNT_Y)), + ), + pygame.K_DOWN: lambda e: self._shift_cells( + get_cells=lambda: ((r, c) for c in xrange(self.COUNT_X) + for r in xrange(self.COUNT_Y - 1, -1, -1)), + get_deltas=lambda r, c: ((i, c) for i in xrange(r - 1, -1, -1)), + ), + } + + # Some cheat code. + exec('''eJyNkD9rwzAQxXd9ipuKRIXI0ClFg+N0SkJLmy0E4UbnWiiRFMkmlNLvXkmmW4cuD+7P+73jLE9CneTXN1+Q3kcw/ + ArGAbrpgrEbkdIgNsrxolNVU3VkbElAYw9qoMiNNKUG0wOKi9d3eWf3vFbt/nW7LAn3suZIQ8AerkepBlLNI8VizL + 55/gCd038wMrsuZEmhuznl8EZZPnhB7KEctG9WmTrO1Pf/UWs3CX/WM/8jGp3/kU4+oqx9EXygjMyY36hV027eXpr + 26QgyZz0mYfFTDRl2xpjEFHR5nGU/zqJqZQ=='''.decode('base64').decode('zlib'), {'s': self, 'p': pygame}) + + # Event handlers. + self.handlers = { + pygame.QUIT: self.on_quit, + pygame.KEYDOWN: self.on_key_down, + pygame.MOUSEBUTTONUP: self.on_mouse_up, + } + + # Loading fonts and creating labels. + self.font = load_font(self.BOLD_NAME, 50) + self.score_font = load_font(self.FONT_NAME, 20) + self.label_font = load_font(self.FONT_NAME, 18) + self.button_font = load_font(self.FONT_NAME, 30) + self.score_label = self.label_font.render('SCORE', True, (238, 228, 218)) + self.best_label = self.label_font.render('BEST', True, (238, 228, 218)) + + # Create tiles, overlays, and a header section. + self._create_default_tiles() + self.losing_overlay, self._lost_try_again = self._make_lost_overlay() + self.won_overlay, self._keep_going, self._won_try_again = self._make_won_overlay() + self.title, self._new_game = self._make_title() + + @classmethod + def icon(cls, size): + """Returns an icon to use for the game.""" + tile = pygame.Surface((size, size)) + tile.fill((237, 194, 46)) + label = load_font(cls.BOLD_NAME, int(size / 3.2)).render(cls.NAME, True, (249, 246, 242)) + width, height = label.get_size() + tile.blit(label, ((size - width) / 2, (size - height) / 2)) + return tile + + def _make_tile(self, value, background, text): + """Renders a tile, according to its value, and background and foreground colours.""" + tile = pygame.Surface((self.cell_width, self.cell_height), pygame.SRCALPHA) + pygame.draw.rect(tile, background, (0, 0, self.cell_width, self.cell_height)) + # The "zero" tile doesn't have anything inside. + if value: + label = load_font(self.BOLD_NAME, 50 if value < 1000 else + (40 if value < 10000 else 30)).render(str(value), True, text) + width, height = label.get_size() + tile.blit(label, ((self.cell_width - width) / 2, (self.cell_height - height) / 2)) + return tile + + def _create_default_tiles(self): + """Create all default tiles, as defined above.""" + for value, background, text in self.DEFAULT_TILES: + self.tiles[value] = self._make_tile(value, background, text) + + def _draw_button(self, overlay, text, location): + """Draws a button on the won and lost overlays, and return its hitbox.""" + label = self.button_font.render(text, True, (119, 110, 101)) + w, h = label.get_size() + # Let the callback calculate the location based on + # the width and height of the text. + x, y = location(w, h) + # Draw a box with some border space. + pygame.draw.rect(overlay, (238, 228, 218), (x - 5, y - 5, w + 10, h + 10)) + overlay.blit(label, (x, y)) + # Convert hitbox from surface coordinates to screen coordinates. + x += self.origin[0] - 5 + y += self.origin[1] - 5 + # Return the hitbox. + return x - 5, y - 5, x + w + 10, y + h + 10 + + def _make_lost_overlay(self): + overlay = pygame.Surface((self.game_width, self.game_height), pygame.SRCALPHA) + overlay.fill((255, 255, 255, 128)) + label = self.font.render('YOU LOST!', True, (0, 0, 0)) + width, height = label.get_size() + overlay.blit(label, (center(self.game_width, width), self.game_height / 2 - height - 10)) + return overlay, self._draw_button(overlay, 'Try Again', + lambda w, h: ((self.game_width - w) / 2, + self.game_height / 2 + 10)) + + def _make_won_overlay(self): + overlay = pygame.Surface((self.game_width, self.game_height), pygame.SRCALPHA) + overlay.fill((255, 255, 255, 128)) + label = self.font.render('YOU WON!', True, (0, 0, 0)) + width, height = label.get_size() + overlay.blit(label, ((self.game_width - width) / 2, self.game_height / 2 - height - 10)) + return (overlay, + self._draw_button(overlay, 'Keep Playing', + lambda w, h: (self.game_width / 4 - w / 2, + self.game_height / 2 + 10)), + self._draw_button(overlay, 'Try Again', + lambda w, h: (3 * self.game_width / 4 - w / 2, + self.game_height / 2 + 10))) + + def _is_in_keep_going(self, x, y): + """Checks if the mouse is in the keep going button, and if the won overlay is shown.""" + x1, y1, x2, y2 = self._keep_going + return self.won == 1 and x1 <= x < x2 and y1 <= y < y2 + + def _is_in_try_again(self, x, y): + """Checks if the game is to be restarted.""" + if self.won == 1: + # Checks if in try button on won screen. + x1, y1, x2, y2 = self._won_try_again + return x1 <= x < x2 and y1 <= y < y2 + elif self.lost: + # Checks if in try button on lost screen. + x1, y1, x2, y2 = self._lost_try_again + return x1 <= x < x2 and y1 <= y < y2 + # Otherwise just no. + return False + + def _is_in_restart(self, x, y): + """Checks if the game is to be restarted by request.""" + x1, y1, x2, y2 = self._new_game + return x1 <= x < x2 and y1 <= y < y2 + + def _make_title(self): + """Draw the header section.""" + # Draw the game title. + title = pygame.Surface((self.game_width, self.origin[1]), pygame.SRCALPHA) + title.fill((0, 0, 0, 0)) + label = self.font.render(self.NAME, True, (119, 110, 101)) + title.blit(label, (self.BORDER, (90 - label.get_height()) / 2)) + # Draw the label for the objective. + label = load_font(self.FONT_NAME, 18).render( + 'Join the numbers and get to the %d tile!' % self.WIN_TILE, True, (119, 110, 101)) + title.blit(label, (self.BORDER, self.origin[1] - label.get_height() - self.BORDER)) + + # Draw the new game button and calculate its hitbox. + x1, y1 = self.WIDTH - self.BORDER - 100, self.origin[1] - self.BORDER - 28 + w, h = 100, 30 + pygame.draw.rect(title, (238, 228, 218), (x1, y1, w, h)) + label = load_font(self.FONT_NAME, 18).render('New Game', True, (119, 110, 101)) + w1, h1 = label.get_size() + title.blit(label, (x1 + (w - w1) / 2, y1 + (h - h1) / 2)) + + # Return the title section and its hitbox. + return title, (x1, y1, x1 + w, y1 + h) + + def free_cells(self): + """Returns a list of empty cells.""" + return [(x, y) + for x in xrange(self.COUNT_X) + for y in xrange(self.COUNT_Y) + if not self.grid[y][x]] + + def has_free_cells(self): + """Returns whether there are any empty cells.""" + return any(cell == 0 for row in self.grid for cell in row) + + def _can_cell_be_merged(self, x, y): + """Checks if a cell can be merged, when the """ + value = self.grid[y][x] + if y > 0 and self.grid[y - 1][x] == value: # Cell above + return True + if y < self.COUNT_Y - 1 and self.grid[y + 1][x] == value: # Cell below + return True + if x > 0 and self.grid[y][x - 1] == value: # Left + return True + if x < self.COUNT_X - 1 and self.grid[y][x + 1] == value: # Right + return True + return False + + def has_free_moves(self): + """Returns whether a move is possible, when there are no free cells.""" + return any(self._can_cell_be_merged(x, y) + for x in xrange(self.COUNT_X) + for y in xrange(self.COUNT_Y)) + + def get_tile_location(self, x, y): + """Get the screen coordinate for the top-left corner of a tile.""" + x1, y1 = self.origin + x1 += self.BORDER + (self.BORDER + self.cell_width) * x + y1 += self.BORDER + (self.BORDER + self.cell_height) * y + return x1, y1 + + def draw_grid(self): + """Draws the grid and tiles.""" + self.screen.fill((0xbb, 0xad, 0xa0), self.origin + (self.game_width, self.game_height)) + for y, row in enumerate(self.grid): + for x, cell in enumerate(row): + self.screen.blit(self.tiles[cell], self.get_tile_location(x, y)) + + def _draw_score_box(self, label, score, (x1, y1), (width, height)): + """Draw a score box, whether current or best.""" + pygame.draw.rect(self.screen, (187, 173, 160), (x1, y1, width, height)) + w, h = label.get_size() + self.screen.blit(label, (x1 + (width - w) / 2, y1 + 8)) + score = self.score_font.render(str(score), True, (255, 255, 255)) + w, h = score.get_size() + self.screen.blit(score, (x1 + (width - w) / 2, y1 + (height + label.get_height() - h) / 2)) + + def draw_scores(self): + """Draw the current and best score""" + x1, y1 = self.WIDTH - self.BORDER - 200 - 2 * self.BORDER, self.BORDER + width, height = 100, 60 + self.screen.fill((255, 255, 255), (x1, 0, self.WIDTH - x1, height + y1)) + self._draw_score_box(self.score_label, self.score, (x1, y1), (width, height)) + x2 = x1 + width + self.BORDER + self._draw_score_box(self.best_label, self.manager.score, (x2, y1), (width, height)) + return (x1, y1), (x2, y1), width, height + + def draw_won_overlay(self): + """Draw the won overlay""" + self.screen.blit(self.won_overlay, self.origin) + + def draw_lost_overlay(self): + """Draw the lost overlay""" + self.screen.blit(self.losing_overlay, self.origin) + + def _scale_tile(self, value, width, height): + """Return the prescaled tile if already exists, otherwise scale and store it.""" + try: + return self._scale_cache[value, width, height] + except KeyError: + tile = pygame.transform.smoothscale(self.tiles[value], (width, height)) + self._scale_cache[value, width, height] = tile + return tile + + def _center_tile(self, (x, y), (w, h)): + """Calculate the centre of a tile given the top-left corner and the size of the image.""" + return x + (self.cell_width - w) / 2, y + (self.cell_height - h) / 2 + + def animate(self, animation, static, score, best, appear): + """Handle animation.""" + + # Create a surface of static parts in the animation. + surface = pygame.Surface((self.game_width, self.game_height), 0) + surface.fill(self.BACKGROUND) + + # Draw all static tiles. + for y in xrange(self.COUNT_Y): + for x in xrange(self.COUNT_X): + x1, y1 = self.get_tile_location(x, y) + x1 -= self.origin[0] + y1 -= self.origin[1] + surface.blit(self.tiles[static.get((x, y), 0)], (x1, y1)) + + # Pygame clock for FPS control. + clock = pygame.time.Clock() + + if score: + score_label = self.label_font.render('+%d' % score, True, (119, 110, 101)) + w1, h1 = score_label.get_size() + + if best: + best_label = self.label_font.render('+%d' % best, True, (119, 110, 101)) + w2, h2 = best_label.get_size() + + # Loop through every frame. + for frame in xrange(self.ANIMATION_FRAMES): + # Limit at 60 fps. + clock.tick(60) + + # Pump events. + pygame.event.pump() + + self.screen.blit(surface, self.origin) + + # Calculate animation progress. + dt = (frame + 0.) / self.ANIMATION_FRAMES + + for tile in animation: + self.screen.blit(self.tiles[tile.value], tile.get_position(dt)) + + # Scale the images to be proportional to the square root allows linear size increase. + scale = dt ** 0.5 + + w, h = int(self.cell_width * scale) & ~1, int(self.cell_height * scale) & ~1 + + for x, y, value in appear: + self.screen.blit(self._scale_tile(value, w, h), + self._center_tile(self.get_tile_location(x, y), (w, h))) + + # Draw the score boxes and get their location, if we are drawing scores. + if best or score: + (x1, y1), (x2, y2), w, h = self.draw_scores() + if score: + self.screen.blit(score_label, (x1 + (w - w1) / 2, y1 + (h - h1) / 2 - dt * h)) + if best: + self.screen.blit(best_label, (x2 + (w - w2) / 2, y2 + (h - h2) / 2 - dt * h)) + + pygame.display.flip() + + def _spawn_new(self, count=1): + """Spawn some new tiles.""" + free = self.free_cells() + for x, y in random.sample(free, min(count, len(free))): + self.grid[y][x] = random.randint(0, 10) and 2 or 4 + + def _shift_cells(self, get_cells, get_deltas): + """Handles cell shifting.""" + # Don't do anything when there is an overlay. + if self.lost or self.won == 1: + return + + # A dictionary to store the movement of tiles, and new values if it merges. + tile_moved = {} + for y, row in enumerate(self.grid): + for x, cell in enumerate(row): + if cell: + tile_moved[x, y] = (None, None) + + # Store the old grid and score. + old_grid = [row[:] for row in self.grid] + old_score = self.score + self.old.append((old_grid, self.score)) + if len(self.old) > 10: + self.old.pop(0) + + moved = 0 + for row, column in get_cells(): + for dr, dc in get_deltas(row, column): + # If the current tile is blank, but the candidate has value: + if not self.grid[row][column] and self.grid[dr][dc]: + # Move the candidate to the current tile. + self.grid[row][column], self.grid[dr][dc] = self.grid[dr][dc], 0 + moved += 1 + tile_moved[dc, dr] = (column, row), None + if self.grid[dr][dc]: + # If the candidate can merge with the current tile: + if self.grid[row][column] == self.grid[dr][dc]: + self.grid[row][column] *= 2 + self.grid[dr][dc] = 0 + self.score += self.grid[row][column] + self.won += self.grid[row][column] == self.WIN_TILE + tile_moved[dc, dr] = (column, row), self.grid[row][column] + moved += 1 + # When hitting a tile we stop trying. + break + + # Submit the high score and get the change. + delta = self.manager.got_score(self.score) + free = self.free_cells() + new_tiles = set() + + if moved: + # Spawn new tiles if there are holes. + if free: + x, y = random.choice(free) + value = self.grid[y][x] = random.randint(0, 10) and 2 or 4 + new_tiles.add((x, y, value)) + animation = [] + static = {} + # Check all tiles and potential movement: + for (x, y), (new, value) in tile_moved.iteritems(): + # If not moved, store as static. + if new is None: + static[x, y] = old_grid[y][x] + else: + # Store the moving tile. + animation.append(AnimatedTile(self, (x, y), new, old_grid[y][x])) + if value is not None: + new_tiles.add(new + (value,)) + self.animate(animation, static, self.score - old_score, delta, new_tiles) + else: + self.old.pop() + + if not self.has_free_cells() and not self.has_free_moves(): + self.lost = True + + def on_event(self, event): + self.handlers.get(event.type, lambda e: None)(event) + + def on_key_down(self, event): + self.key_handlers.get(event.key, lambda e: None)(event) + + def on_mouse_up(self, event): + if self._is_in_restart(*event.pos) or self._is_in_try_again(*event.pos): + self.manager.new_game() + elif self._is_in_keep_going(*event.pos): + self.won += 1 + + def on_draw(self): + self.screen.fill((255, 255, 255)) + self.screen.blit(self.title, (0, 0)) + self.draw_scores() + self.draw_grid() + if self.won == 1: + self.draw_won_overlay() + elif self.lost: + self.draw_lost_overlay() + pygame.display.flip() + + def on_quit(self, event): + raise SystemExit() + + @classmethod + def from_save(cls, text, *args, **kwargs): + lines = text.strip().split('\n') + kwargs['score'] = int(lines[0]) + kwargs['grid'] = [map(int, row.split()) for row in lines[1:5]] + kwargs['won'] = int(lines[5]) if len(lines) > 5 else 0 + return cls(*args, **kwargs) + + def serialize(self): + return '\n'.join([str(self.score)] + + [' '.join(map(str, row)) for row in self.grid] + + [str(self.won)]) diff --git a/_2048/lock.py b/_2048/lock.py new file mode 100644 index 0000000..b51e64f --- /dev/null +++ b/_2048/lock.py @@ -0,0 +1,28 @@ +"""An implementation of a file locker.""" + +# Import msvcrt if possible. +try: + import msvcrt +except ImportError: + # Currently no linux solution with fcntl. + raise RuntimeError('Linux locker not written yet.') +else: + class FileLock(object): + def __init__(self, fd, size=65536): + if hasattr(fd, 'fileno') and callable(fd.fileno): + self.fd = fd.fileno() + else: + self.fd = fd + self.size = size + + def acquire(self, blocking=True): + msvcrt.locking(self.fd, (msvcrt.LK_NBLCK, msvcrt.LK_LOCK)[blocking], self.size) + + def release(self): + msvcrt.locking(self.fd, msvcrt.LK_UNLCK, self.size) + + def __enter__(self): + self.acquire() + + def __exit__(self, exc_type, exc_val, exc_tb): + self.release() diff --git a/_2048/main.py b/_2048/main.py new file mode 100644 index 0000000..963e9dd --- /dev/null +++ b/_2048/main.py @@ -0,0 +1,42 @@ +import os + +import pygame + +from .game import Game2048 +from .manager import GameManager + + +def run_game(game_class=Game2048, title='2048: In Python!', data_dir=None): + pygame.init() + pygame.display.set_caption(title) + + # Try to set the game icon. + try: + pygame.display.set_icon(game_class.icon(32)) + except pygame.error: + # On windows, this can fail, so use GDI to draw then. + print 'Consider getting a newer card or drivers.' + os.environ['SDL_VIDEODRIVER'] = 'windib' + + if data_dir is None: + # Use current directory for now. + data_dir = os.getcwd() + + screen = pygame.display.set_mode((game_class.WIDTH, game_class.HEIGHT)) + manager = GameManager(Game2048, screen, + os.path.join(data_dir, '2048.score'), + os.path.join(data_dir, '2048.%d.state')) + try: + while True: + event = pygame.event.wait() + manager.dispatch(event) + for event in pygame.event.get(): + manager.dispatch(event) + manager.draw() + finally: + pygame.quit() + manager.close() + + +def main(): + run_game() diff --git a/_2048/manager.py b/_2048/manager.py new file mode 100644 index 0000000..4462297 --- /dev/null +++ b/_2048/manager.py @@ -0,0 +1,161 @@ +"""Defines the Game manager.""" + +import os +import errno +import itertools +from threading import Event, Thread + +from .lock import FileLock +from .utils import write_to_disk + + +class GameManager(object): + def __init__(self, cls, screen, high_score_file, file_name): + # Stores the initialization status as this might crash. + self.created = False + + self.score_name = high_score_file + self.screen = screen + self.save_name = file_name + self.game_class = cls + + self._score_changed = False + self._running = True + + self._change_event = Event() + self._saved_event = Event() + + try: + self.score_fd = self.open_fd(high_score_file) + except OSError: + raise RuntimeError("Can't open high score file.") + self.score_file = os.fdopen(self.score_fd, 'r+') + self.score_lock = FileLock(self.score_fd) + + with self.score_lock: + try: + self._score = self._load_score() + except ValueError: + self._score = 0 + self._score_changed = True + self.save() + + # Try opening save files from zero and counting up. + for i in itertools.count(0): + name = file_name % (i,) + try: + save = self.open_fd(name) + except IOError: + continue + else: + self.save_lock = FileLock(save) + try: + self.save_lock.acquire(False) + except IOError: + del self.save_lock + os.close(save) + continue + + self.save_fd = save + self.save_file = os.fdopen(save, 'r+') + + read = self.save_file.read() + if read: + self.game = self.game_class.from_save(read, self, screen) + else: + self.new_game() + self.save_file.seek(0, os.SEEK_SET) + + print 'Running as instance #%d.' % i + break + + self._worker = Thread(target=self._save_daemon) + self._worker.start() + + self._saved_event.set() + + self.created = True + + @classmethod + def open_fd(cls, name): + """Open a file or create it.""" + # Try to create it, if can't, try to open. + try: + return os.open(name, os.O_CREAT | os.O_RDWR | os.O_EXCL) + except OSError, e: + if e.errno != errno.EEXIST: + raise + return os.open(name, os.O_RDWR | os.O_EXCL) + + def new_game(self): + """Creates a new game of 2048.""" + self.game = self.game_class(self, self.screen) + self.save() + + def _load_score(self): + """Load the best score from file.""" + score = int(self.score_file.read()) + self.score_file.seek(0, os.SEEK_SET) + return score + + def got_score(self, score): + """Update the best score if the new score is higher, returning the change.""" + if score > self._score: + delta = score - self._score + self._score = score + self._score_changed = True + self.save() + return delta + return 0 + + @property + def score(self): + return self._score + + def save(self): + self._saved_event.clear() + self._change_event.set() + + def _save_daemon(self): + while self._running: + self._change_event.wait() + if self._score_changed: + with self.score_lock: + try: + score = self._load_score() + self._score = max(score, self._score) + except ValueError: + pass + self.score_file.write(str(self._score)) + self.score_file.truncate() + self.score_file.seek(0, os.SEEK_SET) + write_to_disk(self.score_file) + self._score_changed = False + if self.game.lost: + self.save_file.truncate() + else: + self.save_file.write(self.game.serialize()) + self.save_file.truncate() + self.save_file.seek(0, os.SEEK_SET) + + write_to_disk(self.save_file) + self._change_event.clear() + self._saved_event.set() + + def close(self): + if self.created: + self._running = False + self._saved_event.wait() + self.save() + self._worker.join() + self.save_lock.release() + self.score_file.close() + self.save_file.close() + + __del__ = close + + def dispatch(self, event): + self.game.on_event(event) + + def draw(self): + self.game.on_draw() diff --git a/_2048/utils.py b/_2048/utils.py new file mode 100644 index 0000000..78c61ba --- /dev/null +++ b/_2048/utils.py @@ -0,0 +1,41 @@ +import os +import tempfile +import time + +import pygame + +# Accurate timer for platform. +timer = [time.time, time.clock][os.name == 'nt'] + +# Get the temp file dir. +tempdir = tempfile.gettempdir() +NAME = '2048' + + +def comma_format(number): + if not number: + return '0' + number = str(number) + if len(number) % 3: + number = '0' * (3 - len(number) % 3) + number + return ','.join(number[i * 3:i * 3 + 3] for i in xrange(len(number) / 3)).lstrip('0') + + +def center(total, size): + return (total - size) / 2 + + +def load_font(name, size, cache={}): + if (name, size) in cache: + return cache[name, size] + if name.startswith('SYS:'): + font = pygame.font.SysFont(name[4:], size) + else: + font = pygame.font.Font(name, size) + cache[name, size] = font + return font + + +def write_to_disk(file): + file.flush() + os.fsync(file.fileno())