diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a234c59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# macOS +.DS_Store + +# python +env/ +__pycache__/ +*.py[cod] +*$py.class \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8cd4e4c --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# tetris-terminal-game + +Tetris Clone. Runs in terminal. Implemented in Python3 using ncurses library. + +## Setup + +To run, type: + +``` +python3 tetris.py +``` + +Note that the game currently only runs on macOS with Python 3+! + +## Objective + +Clear lines by placing tetris pieces strategically. +If the pieces stack up to the top, you lose. +Try to get to level 11! + +## Controls + +"up arrow" - rotate piece +"down arrow" - make piece fall faster +"right arrow" - move piece right +"left arrow" - move piece left +"p" - pause game +"q" - quit game + +## What was the original motivation? + +A friend (@jordonwii) told @epai that animation in the terminal couldn't be done. @epai wanted to prove him wrong, and then some. So @epai started implementing a full functioning tetris in the terminal. :) + +## Special Thanks + +Special Thanks to all contributors! Pull requests are always welcome, and feel free to leave an issue if you find any bugs or have any suggestions. + +Thanks for playing! diff --git a/README.txt b/README.txt deleted file mode 100644 index 193d332..0000000 --- a/README.txt +++ /dev/null @@ -1,30 +0,0 @@ -tetris-terminal-game -==================== - -Tetris Clone. Runs in terminal. Implemented in Python using ncurses library. - -***IMPORTANT: MUST HAVE PYTHON3 INSTALLED TO RUN!*** - -to run, type: -python3 tetris.py - -Objective: -Clear lines by placing tetris pieces strategically. -If the pieces stack up to the top, you lose. -Try to get to level 11! - -Keyboard inputs: -"up arrow" - rotate piece -"down arrow" - make piece fall faster -"right arrow" - move piece right -"left arrow" - move piece left - -"p" - pause game -"q" - quit game -"m" - access menu (includes keyboard input legend) - -Thanks for playing! -Eric Pai, -Main programmer and designer (Did everything) - -p.s. What was my motivation? A friend told me that animation in the terminal couldn't be done. I wanted to prove him wrong, and then some. So I implemented a full functioning tetris in the terminal. :) diff --git a/__Main__.py b/__Main__.py deleted file mode 100644 index 15de28e..0000000 --- a/__Main__.py +++ /dev/null @@ -1,320 +0,0 @@ -# Eric Pai -# Spring 2014 - -import os -import sys -import curses -#import locale -from __game__ import * -from __welcome__ import * - -class Main: - ### FIELDS and SETUP ### - nextPieceBoarder = \ - ("┌------------------┐\n", - "| Next Piece |\n", - "|==================|\n", - "| |\n", - "| |\n", - "| |\n", - "| |\n", - "| |\n", - "| |\n", - "└------------------┘\n") - ######################## - - def __init__(self): - self.version = 0.92 - ### curses setup ### - self.stdscr = curses.initscr() - self.setupColors() - curses.noecho() - curses.cbreak() - self.stdscr.keypad(True) - self.stdscr.nodelay(True) - self.stdscr.clear() - curses.curs_set(0) - ### Field Variables ### - # -- initialized in self.doRestart() -- # - self.doRestart() - ### other ### - self.rows, self.columns = \ - [int(x) for x in os.popen('stty size', 'r').read().split()] - - def setupColors(self): - curses.start_color() - self.has_colors = curses.has_colors() - if self.has_colors: - colors = [ - curses.COLOR_RED, - curses.COLOR_YELLOW, - curses.COLOR_MAGENTA, - curses.COLOR_BLUE, - curses.COLOR_CYAN, - curses.COLOR_GREEN, - curses.COLOR_WHITE, - ] - for i, color in enumerate(colors): - curses.init_pair(i + 1, color, color) - curses.init_pair(10, curses.COLOR_WHITE, curses.COLOR_BLACK) - self.boardColor = curses.color_pair(10) - - def doWelcome(self): - self.stdscr.addstr(0, 0, welcomeMessage[0]) - start = False - blink_counter = 1 - blink = True - animate_counter = 0 - refresh_counter = 10 - while (not start): - c = self.stdscr.getch() - for i in range(10000): - c = self.stdscr.getch() - if c != -1: - start = True - break - if refresh_counter == 10: - refresh_counter = 1 - if animate_counter == len(welcomeMessage): - animate_counter = 0 - self.stdscr.addstr(0, 0, welcomeMessage[animate_counter]) - animate_counter += 1 - if blink: - self.stdscr.addstr(14, 22, "┌--------------------------------┐") - self.stdscr.addstr(15, 22, "| |") - self.stdscr.addstr(16, 22, "| |") - self.stdscr.addstr(17, 22, "| |") - self.stdscr.addstr(18, 22, "└--------------------------------┘") - else: - self.stdscr.addstr(14, 22, "┌--------------------------------┐") - self.stdscr.addstr(15, 22, "| |") - self.stdscr.addstr(16, 22, "| Press ANY key to start! |") - self.stdscr.addstr(17, 22, "| |") - self.stdscr.addstr(18, 22, "└--------------------------------┘") - blink = not blink - blink_counter = 0 - refresh_counter += 1 - #self.stdscr.addstr(23, 70, "Eric Pai") - self.stdscr.addstr(23, 0, "v{0} Eric Pai ©2014".format(self.version)) - curses.delay_output(5) - blink_counter += 1 - self.stdscr.refresh() - - def gameLoop(self): - while True: - self.doRestart() - while True: - if self.g.gameOver: - self.doGameOver() - if self.has_landed: - self.g.newPiece() - self.has_landed = False - else: - self.doMove() - if self.restart: - break - if not self.has_landed and \ - self.down_counter == self.down_constant: # do fall - self.has_landed = self.g.fallPiece() - self.down_counter = 1 - self.refreshAnimation() - - def displayBoard(self): - boardString = self.g.toString() - if not self.has_colors: - self.stdscr.addstr(0, 0, self.g.toString()) - return - for y, line in enumerate(boardString.split("\n")): - for x, ch in enumerate(line): - if ch.isdigit(): - color = int(ch) - if color == 9: - self.stdscr.addstr(y, x, '░', self.boardColor) - else: - self.stdscr.addstr(y, x, ch, curses.color_pair(color)) - else: - self.stdscr.addstr(y, x, ch, self.boardColor) - - def doMove(self): - last_move = 0 - shape_change = False - for i in range(1000): # improves reactivity - c = self.stdscr.getch() - if c == curses.KEY_DOWN: # moves piece down faster - self.down_counter = self.down_constant - curses.delay_output(self.time) - if not self.has_landed: - self.has_landed = self.g.fallPiece() - self.displayBoard() - if self.has_landed: - self.down_counter = 1 - if c == curses.KEY_LEFT: # moves blocks to the left - last_move = -1 - elif c == curses.KEY_RIGHT: # moves piece to the right - last_move = 1 - elif not shape_change and c == curses.KEY_UP: # rotates piece - self.g.rotatePiece() - shape_change = True - elif c == ord('p'): - self.doPause() - elif c == ord(' '): # if spacebar, immediately drop to bottom - self.g.dropPiece() - self.has_landed = True - break - elif c == ord('q'): - self.doQuit() - self.down_counter = 1 - self.g.movePiece(last_move) - - def doPause(self): - def printMenu(): - self.stdscr.addstr(5, 24, "┌--------------------------------┐") - self.stdscr.addstr(6, 24, "| GAME PAUSED |") - self.stdscr.addstr(7, 24, "| |") - self.stdscr.addstr(8, 24, "| Controls: |") - self.stdscr.addstr(9, 24, "| ^ Rotate Piece |") - self.stdscr.addstr(10, 24, "| | |") - self.stdscr.addstr(11, 24, "| Move left <-- --> Move right |") - self.stdscr.addstr(12, 24, "| | |") - self.stdscr.addstr(13, 24, "| V Fall faster |") - self.stdscr.addstr(14, 24, "| |") - self.stdscr.addstr(15, 24, "| Type 'p' to resume, |") - self.stdscr.addstr(16, 24, "| 'q' to quit |") - self.stdscr.addstr(17, 24, "| 'r' to restart |") - self.stdscr.addstr(18, 24, "└--------------------------------┘") - self.stdscr.refresh() - printMenu() - self.stdscr.nodelay(False) - c = self.stdscr.getch() - while c not in (ord('q'), ord('r'), ord('p')): - c = self.stdscr.getch() - if c == ord('q'): - self.doQuit() - printMenu() - if c == ord('r'): - self.restart = True - self.stdscr.nodelay(True) - - def refreshAnimation(self): - self.stdscr.clear() - curses.delay_output(self.time) # change so updates in real time - self.down_counter += 1 - self.displayBoard() - # score - self.stdscr.addstr(20, 52, "lines completed: {0}".format(self.g.clearedLines)) - self.stdscr.addstr(22, 42, "Type 'q' to quit or 'p' for pause.") - self.stdscr.addstr(15, 52, "level: {0}".format(self.g.level)) - self.stdscr.addstr(17, 48, "--------------------------") - self.stdscr.addstr(18, 48, " Score {:,} ".format(self.g.score)) - self.stdscr.addstr(19, 48, "--------------------------") - # next piece box - for i in range(len(self.nextPieceBoarder)): - self.stdscr.addstr(i + 1, 49, self.nextPieceBoarder[i]) - nextPieceLines = self.g.nextPieceToString() - for i, line in enumerate(nextPieceLines): - for j, ch in enumerate(line): - if ch.isdigit(): - color = int(ch) - self.stdscr.addstr(i + 5, 56 + j, ch, curses.color_pair(color)) - else: - self.stdscr.addstr(i + 5, 56 + j, ch, self.boardColor) - if self.g.clearedLines - self.level_constant*self.g.level >= 0: - self.down_constant -= self.level_decrement - self.g.level += 1 - if self.g.level == 11: - self.doWin() - - def doGameOver(self): - #self.stdscr.clear() - #self.stdscr.addstr(11, 34, "Game Over!") - - self.stdscr.addstr(10, 24, "┌--------------------------------┐") - self.stdscr.addstr(11, 24, "| |") - self.stdscr.addstr(12, 24, "| Game Over! |") - self.stdscr.addstr(13, 24, "| |") - self.stdscr.addstr(14, 24, "└--------------------------------┘") - self.stdscr.refresh() - curses.delay_output(1500) - self.stdscr.addstr(12, 24, "| Score: {:,}".format(self.g.score)) - self.stdscr.refresh() - curses.delay_output(1500) - self.stdscr.addstr(9, 24, "┌--------------------------------┐") - self.stdscr.addstr(10, 24, "| Play again? |") - self.stdscr.addstr(11, 24, "| |") - self.stdscr.addstr(13, 24, "| |") - self.stdscr.addstr(14, 24, "| 'y' for yes, 'n' for no |") - self.stdscr.addstr(15, 24, "└--------------------------------┘") - # self.stdscr.addstr(11, 27, " Play again? ") - # self.stdscr.addstr(13, 27, "'y' for yes, 'n' for no") - self.stdscr.refresh() - self.stdscr.nodelay(False) - c = self.stdscr.getch() - while c not in (ord('y'), ord('n')): - c = self.stdscr.getch() - if c == ord('y'): - self.restart = True - if not self.restart: - raise ZeroDivisionError - self.stdscr.nodelay(True) - - def doWin(self): - #self.stdscr.clear() - self.stdscr.addstr(10, 24, "┌--------------------------------┐") - self.stdscr.addstr(11, 24, "| |") - self.stdscr.addstr(12, 24, "| You win! |") - self.stdscr.addstr(13, 24, "| |") - self.stdscr.addstr(14, 24, "└--------------------------------┘") - self.stdscr.refresh() - curses.delay_output(1500) - self.stdscr.addstr(12, 24, "| Score: {:,}".format(self.g.score)) - self.stdscr.refresh() - curses.delay_output(1500) - self.stdscr.addstr(10, 24, "┌--------------------------------┐") - self.stdscr.addstr(11, 24, "| Play again? |") - self.stdscr.addstr(11, 24, "| |") - self.stdscr.addstr(13, 24, "| |") - self.stdscr.addstr(13, 24, "| 'y' for yes, 'n' for no |") - self.stdscr.addstr(14, 24, "└--------------------------------┘") - self.stdscr.refresh() - self.stdscr.nodelay(False) - c = self.stdscr.getch() - while c not in (ord('y'), ord('n')): - c = self.stdscr.getch() - if c == ord('y'): - self.restart = True - if not self.restart: - raise ZeroDivisionError - self.stdscr.nodelay(True) - - def doQuit(self): - self.stdscr.addstr(10, 24, "┌--------------------------------┐") - self.stdscr.addstr(11, 24, "| Are you sure you want to quit? |") - self.stdscr.addstr(12, 24, "| |") - self.stdscr.addstr(13, 24, "| 'y' for yes, 'n' for no |") - self.stdscr.addstr(14, 24, "└--------------------------------┘") - self.stdscr.refresh() - c = self.stdscr.getch() - while c not in (ord('y'), ord('n')): - c = self.stdscr.getch() - if c == ord('y'): - raise ZeroDivisionError - - def doRestart(self): - self.time = 5 - self.g = Game() - self.has_landed = True - self.down_counter = 1 - self.down_constant = 100 - self.level_constant = 5 - self.level_decrement = 10 - self.restart = False - - def doFinish(self): - curses.nocbreak() - self.stdscr.keypad(False) - curses.echo() - curses.endwin() - - - - diff --git a/__game__.py b/__game__.py deleted file mode 100644 index 64dc33b..0000000 --- a/__game__.py +++ /dev/null @@ -1,240 +0,0 @@ -# Eric Pai -# Spring 2014 - -""" Game Logic and internal representation for tetris.py """ - -### TO DO: IMPLEMENT LANDING, IMPLEMENT COLLISION DETECTION, -### IMPLEMENT LINE CLEARING - -import random -from copy import deepcopy -from __setup__ import * - -class RollDeck(): - def __init__(self, deck): - self.originLength = len(deck) - self.deck = [] - [self.deck.extend(deepcopy(deck)) for _ in range(3)] # make two copies of deck - self.originalDeck = deepcopy(self.deck) - random.shuffle(self.deck) - - def draw(self): - card = self.deck.pop() - if len(self.deck) < self.originLength: - self.deck = deepcopy(self.originalDeck) - random.shuffle(self.deck) - return card - -class Game: - TIME = 0.1 - - def __init__(self, rows=23, columns=10): - self.board = [[0 for c in range(columns)] for r in range(rows)] - self.emptyBoard = deepcopy(self.board) - self.landed = deepcopy(self.board) - self.simulateLanded = deepcopy(self.board) - self.gamePver = False - self.currPiece = None - self.clearedLines = 0 - self.pieces = makePieces() - self.deck = RollDeck(self.pieces) - self.nextPiece = self.deck.draw() - self.gameOver = False - self.clearLinesAnimation = None - self.clearLinesBoolean = False - self.score = 0 - self.level = 1 - - def newPiece(self): - self.currPiece = self.nextPiece - self.nextPiece = self.deck.draw() - if self.nextPiece == self.pieces[0]: - self.currPiece.topLeft = Position(0, 4) - else: - self.currPiece.topLeft = Position(0, 3) - p = self.currPiece - for r in range(p.getHeight()): - for c in range(p.getWidth()): - if p.shape[r][c] != 0: - row = r + p.topLeft.row - col = c + p.topLeft.col - if self.landed[row][col] != 0: - self.gameOver = True - return - - def simulateLand(self): - currTopLeft = self.currPiece.getNextFall() - rows = range(self.currPiece.getHeight()) - cols = range(self.currPiece.getWidth()) - landed = False - while not landed: - for r in rows: - for c in cols: - if self.currPiece.shape[r][c] != 0: - row = r + currTopLeft.row - col = c + currTopLeft.col - if row >= len(self.landed) or self.landed[row][col] != 0: - landed = True - break - if landed == True: - break - currTopLeft.row += 1 - shapeRows = range(self.currPiece.getHeight()) - shapeColumns = range(self.currPiece.getWidth()) - for r in shapeRows: - for c in shapeColumns: - if self.currPiece.shape[r][c] != 0: - row = r + currTopLeft.row - 1 - col = c + currTopLeft.col - self.simulateLanded[row][col] = self.currPiece.shape[r][c] - - - def fallPiece(self): - nextTopLeft = self.currPiece.getNextFall() - p = self.currPiece - tL = p.topLeft - rows = range(p.getHeight()) - cols = range(p.getWidth()) - has_landed = False - for r in rows: - for c in cols: - if p.shape[r][c] != 0: - row = r + nextTopLeft.row - col = c + nextTopLeft.col - if row >= len(self.landed) or self.landed[row][col] != 0: - self.landPiece() - return True - self.currPiece.topLeft = nextTopLeft - return False - - def movePiece(self, dir): - p = self.currPiece - if dir == -1: - nextTopLeft = p.getNextLeft() - elif dir == 1: - nextTopLeft = p.getNextRight() - else: - return - rows = range(p.getHeight()) - cols = range(p.getWidth()) - for r in rows: - for c in cols: - if p.shape[r][c] != 0: - row = r + nextTopLeft.row - col = c + nextTopLeft.col - if col < 0 or col >= len(self.landed[0]) \ - or self.landed[row][col] != 0: - return - self.currPiece.topLeft = nextTopLeft - - def dropPiece(self): - fallen = False - while not fallen: - fallen = self.fallPiece() - - def rotatePiece(self): - p = self.currPiece - topLeft = p.topLeft - nextRotation = p.getNextRotation() - rows = range(len(nextRotation)) - cols = range(len(nextRotation[0])) - try: - for r in rows: - for c in cols: - if nextRotation[r][c] != 0: - row = r + topLeft.row - col = c + topLeft.col - if col < 0: - self.movePiece(1) - #return - elif col >= len(self.landed[0]): - self.movePiece(-1) - #return - elif self.landed[row][col] != 0: - return - except IndexError as e: - return - self.currPiece.shape = nextRotation - - def landPiece(self): - p = self.currPiece - shapeRows = range(self.currPiece.getHeight()) - shapeColumns = range(self.currPiece.getWidth()) - self.score += 400 * self.level - for r in shapeRows: - for c in shapeColumns: - if self.currPiece.shape[r][c] != 0: - row = r + self.currPiece.topLeft.row - col = c + self.currPiece.topLeft.col - self.landed[row][col] = self.currPiece.shape[r][c] - self.clearLines() - - def clearLines(self): - animation = [] - rows = range(len(self.landed)) - cols = range(len(self.landed[0])) - score = 0 - combo = 0 - for row in rows: - isFilled = True - for col in cols: - if self.landed[row][col] == 0: - isFilled = False - if isFilled: - empty = [0]*len(self.landed[0]) - self.landed = [empty] + self.landed[:row] + self.landed[row+1:] - self.clearedLines += 1 - score += len(self.board[0]) * self.level * 1000 - combo += 1 - self.score += score * combo - - def updateBoard(self): - """ Updates board to include landed[] and curr tetrimino piece """ - self.board = deepcopy(self.emptyBoard) - self.simulateLanded = deepcopy(self.emptyBoard) - self.simulateLand() - rows = range(len(self.board)) - columns = range(len(self.board[0])) - shapeRows = range(self.currPiece.getHeight()) - shapeColumns = range(self.currPiece.getWidth()) - for r in rows: - for c in columns: - self.board[r][c] = self.landed[r][c] - if self.simulateLanded[r][c]: - self.board[r][c] = 9 - for r in shapeRows: - for c in shapeColumns: - if self.currPiece.shape[r][c] != 0: - row = r + self.currPiece.topLeft.row - col = c + self.currPiece.topLeft.col - self.board[row][col] = self.currPiece.shape[r][c] - - def toString(self): - rows = len(self.board) - cols = len(self.board[0]) - result = ".." * (cols + 2) + "\n" - self.updateBoard() - for r in range(1, rows): - result += "||" - for c in range(cols): - if self.board[r][c] != 0: - result += "{0}{0}".format(self.board[r][c]) - else: - result += " " - result += "||\n" - result += "^^" * (cols + 2) - return result - - def nextPieceToString(self): - result = [] - shapeRows = range(self.nextPiece.getHeight()) - shapeColumns = range(self.nextPiece.getWidth()) - for r in shapeRows: - line = "" - for c in shapeColumns: - if self.nextPiece.originShape[r][c] == 0: - line += " " - else: - line += "{0}{0}".format(self.nextPiece.originShape[r][c]) - result += [line] - return result diff --git a/__setup__.py b/__setup__.py deleted file mode 100644 index 486e01d..0000000 --- a/__setup__.py +++ /dev/null @@ -1,102 +0,0 @@ -# Eric Pai -# Spring 2014 - -""" Includes all supporting data structures used by game.py """ - -class Position: - def __init__(self, row, column): - self.row = row - self.col = column - def __str__(self): - return '({},{})'.format(self.row, self.col) - -class Tetrimino: - def __init__(self, color, shape, topLeft=Position(0, 0)): - self.color = color - self.shape = shape - self.originShape = shape - self.topLeft = topLeft - self.currRotation = 0; # index of current rotation - self.rotations = self.getRotations(shape) - - def getWidth(self): - return len(self.shape[0]) - - def getHeight(self): - return len(self.shape) - - def getNextFall(self): - return Position(self.topLeft.row + 1, self.topLeft.col) - - def getNextRight(self): - return Position(self.topLeft.row, self.topLeft.col + 1) - - def getNextLeft(self): - return Position(self.topLeft.row, self.topLeft.col - 1) - - def getRotations(self, shape): - """ takes in a set of rotations (as tuples) and sets them. """ - currRotation = shape - rotations = [] - for _ in range(len(shape)): - reversedRotation = currRotation[::-1] - currRotation = [[row[i] for row in reversedRotation] - for i in range(len(shape))] - rotations.append(currRotation) - rotations.append(shape) - return rotations - - def getNextRotation(self): - temp = self.rotations[self.currRotation] - self.currRotation = (self.currRotation + 1) % len(self.rotations) - return temp - -def makePieces(): - raw_pieces = \ - """ - .X.. - .X.. - .X.. - .X.. - - ... - XXX - ..X - - ... - XXX - X.. - - .... - .XX. - .XX. - .... - - ... - .XX - XX. - - ... - XXX - .X. - - ... - XX. - .XX - """ - pieces = [(i+1, [[i + 1 if ch == 'X' else 0 for ch in row.strip()] - for row in piece.split('\n') if row.strip()]) - for i, piece in enumerate(raw_pieces.split('\n\n'))] - pieces = [Tetrimino(color, piece) for color, piece in pieces] - pieces[0].rotations = pieces[0].rotations[:2] # L piece - pieces[3].rotations = pieces[3].rotations[:1] # O piece - pieces[4].rotations = pieces[4].rotations[:2] # s piece - pieces[6].rotations = pieces[6].rotations[:2] # z piece - return pieces - - - - - - - diff --git a/tetris.py b/tetris.py index ad92df9..8d8a424 100644 --- a/tetris.py +++ b/tetris.py @@ -1,49 +1,11 @@ -# Eric Pai -# Spring 2014 -""" -INSRT TETRIS LOGO HERE +from tetris.gui import UI - -INSERT CREDITS HERE -""" - -from __Main__ import * - -#### TO DO LIST #### -# ---Game Mechanics--- -# - wall kicking XX -# - line completion (sort of) -# - "levels" and associated falling speeds (YES!) -# - score (sort of) -# - "next piece" box (YES!) -# - "retry" option, after losing (YES!) -# - ghosting -# ---Extras/Aesthetics--- -# - home page (YES!) -# - "lose" mechanism (YES!) -# - "quit" mechanism" (YES!) -# - "clear line" animations (Maybe later...) -# - adjust layout/make more clean (YES!) -# - add "juciness" (nope) -# - make screen adjustable? (nope) -# - high score system -# ---Other--- -# - get tested and advice from other people -# - show andrew as a going-away present! -#################### - -###### Special Thanks ####### -# - carlos caballero -# - jordon wing - -m = Main() +m = UI() try: m.doWelcome() m.gameLoop() -except ZeroDivisionError as e: - pass -except KeyboardInterrupt as e: +except (ZeroDivisionError, KeyboardInterrupt) as e: pass except Exception as e: raise e diff --git a/tetris/__init__.py b/tetris/__init__.py new file mode 100644 index 0000000..11f93c0 --- /dev/null +++ b/tetris/__init__.py @@ -0,0 +1,8 @@ +import logging + +logger = logging.getLogger('tetris') +handler = logging.FileHandler('logs') +formatter = logging.Formatter('%(levelname)s - %(filename)s:%(lineno)s - %(message)s') +handler.setFormatter(formatter) +logger.addHandler(handler) +logger.setLevel(logging.DEBUG) diff --git a/tetris/constants.py b/tetris/constants.py new file mode 100644 index 0000000..8a8f487 --- /dev/null +++ b/tetris/constants.py @@ -0,0 +1,4 @@ +HEIGHT = 10 +WIDTH = 23 +TIME_INTERVAL = 0.1 +VERSION = 'v1.0.0' diff --git a/tetris/core/__init__.py b/tetris/core/__init__.py new file mode 100644 index 0000000..0c0399e --- /dev/null +++ b/tetris/core/__init__.py @@ -0,0 +1,52 @@ +from tetris.core.objects import * + +prototypes = { + 'I': ProtoPiece(Color.RED, + """ + .X.. + .X.. + .X.. + .X.. + """, 2), + 'J': ProtoPiece(Color.YELLOW, + """ + ... + XXX + ..X + """, 4), + 'L': ProtoPiece(Color.MAGENTA, + """ + ... + XXX + X.. + """, 4), + 'O': ProtoPiece(Color.BLUE, + """ + .... + .XX. + .XX. + .... + """, 1), + 'S': ProtoPiece(Color.CYAN, + """ + ... + .XX + XX. + """, 2), + 'T': ProtoPiece(Color.GREEN, + """ + ... + XXX + .X. + """, 4), + 'Z': ProtoPiece(Color.WHITE, + """ + ... + XX. + .XX + """, 2), +} + +pieces = prototypes.values() + +from tetris.core.game import Game \ No newline at end of file diff --git a/tetris/core/game.py b/tetris/core/game.py new file mode 100644 index 0000000..fda418a --- /dev/null +++ b/tetris/core/game.py @@ -0,0 +1,88 @@ +from tetris.core import prototypes, pieces +from tetris.core.objects import Board, RollDeck, Pos + +class Game: + def __init__(self, rows=23, cols=10): + self.landed = Board.empty(rows, cols) + self.deck = RollDeck(pieces) + self.next_piece = self.create_piece() + self.curr_piece = None + self.cleared_lines = 0 + self.has_ended = False + self.score = 0 + self.level = 1 + + def create_piece(self): + proto_piece = self.deck.draw() + col = 0 if proto_piece == prototypes['I'] else 3 + return proto_piece.create(origin=Pos(0, col)) + + def new_piece(self): + self.curr_piece = self.next_piece + self.next_piece = self.create_piece() + if self.landed.collides_with(self.curr_piece): + self.has_ended = True + + def fall_piece(self): + down_piece = self.curr_piece.down + if self.landed.collides_with(down_piece): + self.landed = self.landed.with_piece(self.curr_piece) + self.clear_lines() + return True + self.curr_piece = down_piece + return False + + def drop_piece(self): + fallen = False + while not fallen: + fallen = self.fall_piece() + + def move_piece(self, movedir): + piece = self.curr_piece + moved_piece = piece.left if movedir == 'left' else piece.right + if self.landed.contains(moved_piece): + self.curr_piece = moved_piece + + def rotate_piece(self): + rotated_piece = self.curr_piece.rotated + moved_piece = self.landed.move_inbounds(rotated_piece) + self.curr_piece = moved_piece + + def simulate_land(self): + landed = False + piece = self.curr_piece.down + while not self.landed.collides_with(piece): + piece = piece.down + # get piece right before it collided + return piece.up + + def clear_lines(self): + score, combo = 0, 0 + for row in range(self.landed.height): + is_filled = True + for col in range(self.landed.width): + if self.landed[row][col] == 0: + is_filled = False + break + if is_filled: + empty = (0,) * self.landed.width + self.landed = Board((empty,) + self.landed.rows[:row] + self.landed.rows[row+1:]) + self.cleared_lines += 1 + score += self.landed.width * self.level * 10 # arbitrary score calculation... + combo += 1 + self.score += score * combo + + def __str__(self): + piece = self.simulate_land() + board = self.landed.with_piece(piece, 9).with_piece(self.curr_piece) + result = ".." * (board.width + 2) + "\n" + for r in range(1, board.height): + result += "||" + for c in range(board.width): + if board[r][c] != 0: + result += "{0}{0}".format(board[r][c]) + else: + result += " " + result += "||\n" + result += "^^" * (board.width + 2) + return result diff --git a/tetris/core/objects.py b/tetris/core/objects.py new file mode 100644 index 0000000..ba3066c --- /dev/null +++ b/tetris/core/objects.py @@ -0,0 +1,199 @@ +import enum +import random +import curses +from textwrap import dedent + +class Pos: + def __init__(self, y, x): + self.row = self.y = y + self.col = self.x = x + + def __str__(self): + return '({},{})'.format(self.y, self.x) + + def __iter__(self): + return iter((self.row, self.col)) + +@enum.unique +class Color(enum.Enum): + RED = curses.COLOR_RED + YELLOW = curses.COLOR_YELLOW + MAGENTA = curses.COLOR_MAGENTA + BLUE = curses.COLOR_BLUE + CYAN = curses.COLOR_CYAN + GREEN = curses.COLOR_GREEN + WHITE = curses.COLOR_WHITE + + +class RollDeck(): + def __init__(self, deck): + self.original = [] + for _ in range(3): + self.original.extend(deck) + self.deck = RollDeck.shuffle(self.original) + + def draw(self): + card = self.deck.pop() + if len(self.deck) < len(self.original) // 3: + self.deck = RollDeck.shuffle(self.original) + return card + + @staticmethod + def shuffle(cards): + """ Return a new list of shuffled cards. """ + return random.sample(cards, len(cards)) + + +class Board: + def __init__(self, rows): + self.rows = tuple(rows) + self.height = len(self.rows) + self.width = len(self.rows[0]) + + def __getitem__(self, index): + return self.rows[index] + + @classmethod + def empty(cls, rows, cols): + rows = tuple((0,) * cols for _ in range(rows)) + return cls(rows) + + def collides_with(self, piece): + for (row, col), _ in piece: + if row >= len(self.rows) or self.rows[row][col] != 0: + return True + return False + + def contains(self, piece): + for (row, col), _ in piece: + if col < 0 or col >= self.width or self.rows[row][col] != 0: + return False + return True + + def move_inbounds(self, piece): + moved_piece = piece + for (row, col), _ in piece: + if col < 0: + moved_piece = moved_piece.right + elif col >= self.width: + moved_piece = moved_piece.left + return moved_piece + + def with_piece(self, piece, ch=None): + new_board = list(self.rows) + for (row_index, col), (r, c) in piece: + row = new_board[row_index] + char = ch or piece.shape[r][c] + new_row = row[:col] + (char,) + row[col+1:] + new_board[row_index] = tuple(new_row) + return Board(new_board) + + +class Shape: + def __init__(self, rows): + assert len(rows) == len(rows[0]), "Shapes must have the same width and height" + self.rows = rows + + @classmethod + def from_string(cls, string, color): + row_strs = (row.strip() for row in dedent(string).split('\n')) + make_row = lambda row: [color if ch == 'X' else 0 for ch in row] + rows = [make_row(row) for row in row_strs if row] + return cls(rows) + + @property + def rotated(self): + rotated_rows = list(zip(*self.rows))[::-1] + return Shape(rotated_rows) + + def __len__(self): + return len(self.rows) + + def __getitem__(self, index): + return self.rows[index] + + +class Piece: + def __init__(self, prototype, origin, rotation=0): + self.prototype = prototype + self.rotation = rotation + self.origin = origin + self.shape = prototype.rotations[rotation] + self.height = len(self.shape) + self.width = len(self.shape[0]) + + def __iter__(self): + for r in range(self.height): + for c in range(self.width): + if self.shape[r][c] != 0: + location = Pos(r + self.origin.row, c + self.origin.col) + relative = Pos(r, c) + yield (location, relative) + + def to_lines(self): + result = [] + for r in range(self.height): + line = "" + for c in range(self.width): + if self.prototype.shape[r][c] == 0: + line += " " + else: + line += "{0}{0}".format(self.prototype.shape[r][c]) + result += [line] + return result + + + def copy(self, rotation=None, origin=None): + if rotation is None: + rotation = self.rotation + return Piece(self.prototype, origin or self.origin, rotation) + + @property + def down(self): + return self.copy(origin=Pos(self.origin.row + 1, self.origin.col)) + + @property + def up(self): + return self.copy(origin=Pos(self.origin.row - 1, self.origin.col)) + + @property + def right(self): + return self.copy(origin=Pos(self.origin.row, self.origin.col + 1)) + + @property + def left(self): + return self.copy(origin=Pos(self.origin.row, self.origin.col - 1)) + + @property + def rotated(self): + import time + + next_rotation = (self.rotation + 1) % len(self.prototype.rotations) + return self.copy(rotation=next_rotation) + + + +class ProtoPiece: + def __init__(self, color, shape, rotations=None): + shape = Shape.from_string(shape, color.value) + + self.shape = shape + self.rotations = [shape] + + num_rotations = (rotations or len(shape)) - 1 + for _ in range(num_rotations): + shape = shape.rotated + self.rotations.append(shape) + + self.rotations = self.rotations[::-1] + + def create(self, origin=None): + origin = origin or Pos(0, 0) + return Piece(self, origin) + + + + + + + diff --git a/tetris/gui/__init__.py b/tetris/gui/__init__.py new file mode 100644 index 0000000..9e6c6fc --- /dev/null +++ b/tetris/gui/__init__.py @@ -0,0 +1 @@ +from tetris.gui.ui import UI \ No newline at end of file diff --git a/tetris/gui/objects.py b/tetris/gui/objects.py new file mode 100644 index 0000000..602de1f --- /dev/null +++ b/tetris/gui/objects.py @@ -0,0 +1,80 @@ +import textwrap +from contextlib import contextmanager + +from tetris.core import Pos + +def clean_and_split(string): + lines = textwrap.dedent(string.strip()).splitlines() + return [l.strip() for l in lines if l.strip()] + +class Box: + def __init__(self, *args, min_height=3, min_width=34): + self.min_height = min_height + self.min_width = min_width + self.lines = [] + + for i, text in enumerate(args): + bottom = 0 if i == len(args) - 1 else 1 + self.add_text(text, bottom=bottom) + + def add_text(self, text, top=0, bottom=0, color=None): + pad = (('', color),) + if not text: + self.lines.extend(pad) + else: + lines = clean_and_split(text) + self.lines.extend(pad * top) + self.lines.extend(zip(lines, (color,) * len(lines))) + self.lines.extend(pad * bottom) + longest_line = max(len(line) for line in lines) + self.min_width = max(self.min_width, longest_line) + return self # for fluid interface + + def render(self, ui, pos=None): + actual_height = len(self.lines) + pad_amount = max(0, self.min_height - actual_height) + pos = pos or Pos( + ui.height // 2 - max(self.min_height, actual_height) // 2, + ui.width // 2 - self.min_width // 2 - 1) + + offset = 0 + def add_line(line, color=None): + nonlocal offset + color = color or ui.board_color + ui.stdscr.addstr(pos.y + offset, pos.x, line, color) + offset += 1 + def add_lines(lines, colored=False): + for line in lines: + color = None + if colored: + line, color = line + add_line(line, color) + + center = '-'*self.min_width + pad = '|{}|'.format(' '*self.min_width) + lines = (('|{}|'.format(l.center(self.min_width)), c) for l,c in self.lines) + + add_line('┌{}┐'.format(center)) + add_lines((pad,) * (pad_amount // 2)) + add_lines(lines, colored=True) + add_lines((pad,) * (pad_amount // 2 + pad_amount % 2)) + add_line('└{}┘'.format(center)) + + +class Dialog(Box): + def __init__(self, *args, min_height=3, min_width=34, **kwargs): + super().__init__(*args, min_height=min_height, min_width=min_width) + self.add_keys(**kwargs) + + def add_keys(self, **kwargs): + self.keys = kwargs.keys() + self.add_text(None) # ne + for key, msg in kwargs.items(): + self.add_text("Press `{}` to {}".format(key, msg)) + return self + + @contextmanager + def response(self, ui): + self.render(ui) + with ui.get_key(self.keys, delay=True) as key: + yield key \ No newline at end of file diff --git a/tetris/gui/ui.py b/tetris/gui/ui.py new file mode 100644 index 0000000..aa8197c --- /dev/null +++ b/tetris/gui/ui.py @@ -0,0 +1,234 @@ +import os, sys +import curses +import textwrap +from contextlib import contextmanager + +from tetris.core import Game, Pos, Color +from tetris.gui.welcome import welcome_message +from tetris.gui.objects import * +from tetris.constants import TIME_INTERVAL, VERSION + +from tetris import logger + +class UI: + def __init__(self): + # setup curses + self.stdscr = curses.initscr() + self.setup_colors() + curses.noecho() + curses.cbreak() + self.stdscr.keypad(True) + self.stdscr.nodelay(True) + self.stdscr.clear() + curses.curs_set(0) + # initialize game params + self.doRestart() + self.height, self.width = \ + [int(x) for x in os.popen('stty size', 'r').read().split()] + + @contextmanager + def get_key(self, keys, delay=True, reactivity=10): + if delay: + self.stdscr.refresh() + self.stdscr.nodelay(False) + + special = { + 'up': curses.KEY_UP, + 'down': curses.KEY_DOWN, + 'left': curses.KEY_LEFT, + 'right': curses.KEY_RIGHT,} + keynum = lambda k: special[k] if k in special else ord(k) + key_map = {keynum(k):k for k in keys} + + i, c = 0, self.stdscr.getch() + while (delay and c not in key_map) or (not delay and i < reactivity): + getched = self.stdscr.getch() + if getched != -1: + c = getched + i += 1 + try: + yield key_map.get(c, None) + + finally: + if delay: + self.stdscr.nodelay(True) + + def setup_colors(self): + curses.start_color() + self.has_colors = curses.has_colors() + for color in Color: + curses.init_pair(color.value, color.value, color.value) + curses.init_pair(10, curses.COLOR_WHITE, curses.COLOR_BLACK) + self.board_color = curses.color_pair(10) + + def doWelcome(self): + self.stdscr.addstr(0, 0, welcome_message[0]) + blink = True + animate_counter = 0 + refresh_counter = 0 + while True: + with self.get_key(' ', delay=False) as key: + if key == ' ': + return + if refresh_counter % 10 == 0: + self.stdscr.addstr(0, 0, welcome_message[animate_counter]) + animate_counter = (animate_counter + 1) % len(welcome_message) + if blink: + Box().render(self) + else: + Box("Press SPACEBAR to start!").render(self) + blink = not blink + refresh_counter += 1 + self.stdscr.addstr(23, 0, "{0} Eric Pai ©2014-2017".format(VERSION)) + curses.delay_output(10) + self.stdscr.refresh() + + def gameLoop(self): + while True: + self.doRestart() + while True: + if self.g.has_ended: + self.doEnding("Game Over!") + if self.has_landed: + self.g.new_piece() + self.has_landed = False + else: + self.doMove() + if self.restart: + break + if not self.has_landed and self.down_counter == self.down_constant or self.fast_fall: # do fall + self.has_landed = self.g.fall_piece() + self.down_counter = 1 + self.fast_fall = False + self.refreshAnimation() + + def displayBoard(self): + board_string = str(self.g) + if not self.has_colors: + self.stdscr.addstr(0, 0, board_string) + return + for y, line in enumerate(board_string.splitlines()): + for x, ch in enumerate(line): + if ch.isdigit(): + color = int(ch) + if color == 9: + self.stdscr.addstr(y, x, '░', self.board_color) + else: + self.stdscr.addstr(y, x, ch, curses.color_pair(color)) + else: + self.stdscr.addstr(y, x, ch, self.board_color) + + def doMove(self): + keys = 'up,down,left,right,p,q, '.split(',') + with self.get_key(keys, delay=False) as key: + if key == 'down': # moves piece down faster + self.fast_fall = True + curses.delay_output(self.time) + if not self.has_landed: + self.has_landed = self.g.fall_piece() + self.displayBoard() + if self.has_landed: + self.fast_fall = False + self.down_counter = 1 + elif key == 'left': # moves blocks to the left + self.g.move_piece('left') + elif key == 'right': # moves piece to the right + self.g.move_piece('right') + elif key == 'up': # rotates piece + self.g.rotate_piece() + elif key == 'p': + self.doPause() + elif key == ' ': # if spacebar, immediately drop to bottom + self.g.drop_piece() + self.has_landed = True + elif key == 'q': + self.doQuit() + + def doPause(self): + dialog = Dialog("GAME PAUSED", + """ + Rotate piece + ^ + | + Move left <-- --> Move right + | + V + Fall faster + """, + '`spacebar` == Drop Piece', + p='resume', q='quit', r='restart') + + with dialog.response(self) as key: + if key == 'q': + self.doQuit() + if key == 'r': + self.restart = True + + def refreshAnimation(self): + self.stdscr.clear() + curses.delay_output(self.time) # change so updates in real time + self.down_counter += 1 + self.displayBoard() + left = 28 + # score + Box("level: {}".format(self.g.level), "lines: {}".format(self.g.cleared_lines), + min_width=18 + ).render(self, pos=Pos(11, left)) + Box("score", '{} pts'.format(self.g.score), + min_width=18 + ).render(self, pos=Pos(19, left)) + # next piece box + Box(min_width=18, min_height=6).render(self, pos=Pos(2, left)) + nextPieceLines = self.g.next_piece.to_lines() + for i, line in enumerate(nextPieceLines): + for j, ch in enumerate(line): + if ch.isdigit(): + self.stdscr.addstr(i + 4, left + 7 + j, ch, curses.color_pair(int(ch))) + Box("Next Piece", min_width=18, min_height=0).render(self, pos=Pos(1, left)) + # increment level + if self.g.cleared_lines - self.level_constant*self.g.level >= 0: + self.down_constant -= self.level_decrement + self.g.level += 1 + if self.g.level == 11: + self.doEnding('You Win!') + self.stdscr.refresh() + + def doEnding(self, end_text): + Box(end_text).render(self) + self.stdscr.refresh() + curses.delay_output(1500) + score = "Score: {:,}".format(self.g.score) + Box("Score: {:,}".format(self.g.score)).render(self) + self.stdscr.refresh() + curses.delay_output(1500) + dialog = Dialog("Play again?", score, y='play again', n='quit') + with dialog.response(self) as key: + if key == 'n': + raise ZeroDivisionError + + def doQuit(self): + dialog = Dialog("Are you sure you want to quit?", y='quit', n='not') + with dialog.response(self) as key: + if key == 'y': + raise ZeroDivisionError + + def doRestart(self): + self.g = Game() + self.time = 5 + self.fast_fall = False + self.has_landed = True + self.down_counter = 1 + self.down_constant = 100 + self.level_constant = 5 + self.level_decrement = 10 + self.restart = False + + def doFinish(self): + curses.nocbreak() + self.stdscr.keypad(False) + curses.echo() + curses.endwin() + + + + diff --git a/__welcome__.py b/tetris/gui/welcome.py similarity index 99% rename from __welcome__.py rename to tetris/gui/welcome.py index 7979b03..7ac9cfc 100644 --- a/__welcome__.py +++ b/tetris/gui/welcome.py @@ -1,4 +1,4 @@ -welcomeMessage = [ +welcome_message = [ """ [3][3][3] [6] [3] [6][6][6] [1][1][1][1]