I've written a simple text-based (can be run in terminal) snake game in python. This is a personal project and also one of my very first projects in python. I appreciate any advice or suggestion that make my code more efficient and professional.
main.py
from game import Game
if __name__=="__main__":
game = Game()
game.new_game([15,15])
game.run()
game.py
from time import sleep
from threading import Thread
from snake import Snake
from board import Board
class Game:
def __init__(self):
pass
def new_game(self, board_dim = [10, 10], level = "EASY"):
self.snake = Snake(2, [[2,2],[2,3]], "UP", board_dim)
self.board = Board(board_dim[0], board_dim[1], self.snake.pos)
self.thrd = Thread(target=self.snake.check_arrow_keys)
self.score = 0
def finish_game(self):
print(f"Your score is {self.score}")
self.thrd.join()
exit()
def run(self):
self.thrd.start()
while True:
print('033円c')
if not self.snake.status:
break
self.board.food_process(self.snake.normalize_pos())
if self.board.eaten:
self.score += 1
self.snake.move_toward_direction(increment_size=True)
else:
self.snake.move_toward_direction()
self.board.board_init(self.snake.normalize_pos())
self.board.show_board(self.snake)
print(f"score:{self.score}")
sleep(.2)
self.finish_game()
board.py
from random import randint
class Board:
def __init__(self, columns, rows, fill):
self.board = [[0 for j in range(columns)] for i in range(rows)]
self.fill = fill
self.col = columns
self.rows = rows
self.first = fill[-1]
self.put_food(fill)
def board_init(self, fill):
self.board = [[0 for j in range(self.col)] for i in range(self.rows)]
self.fill = fill
self.first = fill[-1]
for i in self.fill:
if i == self.first:
self.board[i[0]%self.rows][i[1]%self.col] = 2
else:
self.board[i[0]%self.rows][i[1]%self.col] = 1
self.board[self.food[0]][self.food[1]] = 3
def food_process(self, fill):
if self.check_food(fill):
self.eaten = True
self.put_food(fill)
else:
self.eaten = False
def normalize_fill(self, fill):
return [[i[0]%self.rows, i[1]%self.col] for i in fill]
def check_food(self, fill):
if self.food in self.normalize_fill(fill):
return True
return False
def put_food(self, fill):
while True:
x,y = randint(0,self.col-1), randint(0, self.rows-1)
if [x,y] not in self.normalize_fill(fill):
self.board[x][y] = 3
self.food = [x,y]
return
def show_board(self, snake):
board_ = ""
for i in self.board:
for j in i:
if j==1:
board_ += "@|"
elif j==2:
if snake.dir == "UP":
board_ += "^|"
elif snake.dir == "LEFT":
board_ += "<|"
elif snake.dir == "RIGHT":
board_ += ">|"
elif snake.dir == "DOWN":
board_ += "˅|"
elif j==3:
board_ += "*|"
else:
board_ += " |"
board_ += "\n"
board_ += "".join(["_ "*self.col])
board_ += "\n"
print(board_)
snake.py
from random import choice
from threading import Thread
import sys
import select
import tty
import termios
class Snake:
def __init__(self, length, pos, direction, board_size):
if length != len(pos):
raise Exception("Length is not equal to the size of `pos`")
self.len = length
self.pos = pos
self.dir = direction
self.last = pos[-1]
self.first = pos[0]
self.columns= board_size[0]
self.rows = board_size[1]
self.init_l = length
self.status = True
def normalize_pos(self):
return [ [p[0]%self.rows, p[1]%self.columns] for p in self.pos]
def move_toward_direction(self, step = 1, increment_size=False):
temp = self.last[:]
if self.dir.upper() == "UP":
temp[0] -= 1
if self.check(temp):
self.pos.append(temp)
else:
self.__lost()
elif self.dir.upper() == "DOWN":
temp[0] += 1
if self.check(temp):
self.pos.append(temp)
else:
self.__lost()
elif self.dir.upper() == "RIGHT":
temp[1] += 1
if self.check(temp):
self.pos.append(temp)
else:
self.__lost()
elif self.dir.upper() == "LEFT":
temp[1] -= 1
if self.check(temp):
self.pos.append(temp)
else:
self.__lost()
else:
raise Exception(f"Direction not correct!: {self.dir}")
if not increment_size :
self.pos.remove(self.first)
self.first = self.pos[0]
else:
self.len += 1
self.first = self.pos[0]
self.last = self.pos[-1]
def check(self, tmp):
if tmp not in self.normalize_pos() and tmp not in self.pos:
return True
else:
return False
def rand_direction(self):
counter = 0
while True:
tmp = choice(["UP","RIGHT","LEFT","DOWN"])
#chcs = [i for i in ["UP","RIGHT","LEFT","DOWN"] if self.check(i)]
temp = self.last[:]
if tmp == "UP" :
temp[0] -= 1
elif tmp == "DOWN" :
temp[0] += 1
elif tmp == "RIGHT":
temp[1] += 1
elif tmp == "LEFT" :
temp[1] -= 1
else:
raise Exception(f"Direction not correct!: {tmp}")
if self.check(temp):
self.dir = tmp
return
counter += 1
if counter > 32:
raise Exception("No movement is possible")
def check_arrow_keys(self):
old_settings = termios.tcgetattr(sys.stdin)
try:
tty.setcbreak(sys.stdin.fileno())
while 1:
if self.__isData() or self.status:
c = sys.stdin.read(3)
if c == '\x1b[A':
if self.dir != "DOWN":
self.dir = "UP"
elif c == '\x1b[B':
if self.dir != "UP":
self.dir = "DOWN"
elif c == '\x1b[D':
if self.dir != "RIGHT":
self.dir = "LEFT"
elif c == '\x1b[C':
if self.dir != "LEFT":
self.dir = "RIGHT"
else:
pass
else:
return
finally:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
def __isData(self):
return select.select([sys.stdin], [], [], 0) == ([sys.stdin], [], [])
def __lost(self):
self.status = False
also this is link of project on github. If you enjoyed the project, please give me star on github :D
Edit: I noticed that if I run the program on the tmux, it will looks very better (It does not blink).
1 Answer 1
First off, nice game! It plays pretty smoothly. The code looks decent as-is, which is why I'm going more in depth--high level, no improvements are really needed.
I'll go through by file, since it's split up.
General comments
- Read PEP 8.
- Be consistent about using blank lines. Don't leave a blank after a function
def
. - Add type annotations
- Remove debugging code and commented-out code.
- I had trouble figuring out what functions did and didn't handle--I had to guess too much. Partly this is naming, partly it's lack of docstring or comments, and partly it's file organization. Also, it's just hard for a medium-sized game like this.
- Add docstrings to your methods to explain what each one does. Remember to explain the method in terms of "when would I want to call this?" rather than "what does it do inside?"
- In a couple places, you used
while True
loops. Try to use a more specific condition if you can, because then the reader can immediately read it and say "oh, the loop ends when..."
User Testing
I tested this out. Some notes:
- Very clean visuals (although not all the characters display in my font).
- Movement feels silky smooth on my computer.
- It seems like the heart is drawn over the snake for a while after you eat it
- Once you die, you have to press a key to exit.
- I managed to wiggle the snake around and get a score of 0 somehow at the start. There may be race conditions in the threading.
- Overall looks cool!
main.py
- Looks good.
game.py
- Pass the same parameters you would to
new_game
to__init__
instead, and removenew_game
. - Take the entire contents of the
run
loop. Split these intoget_input
,do_tick
, andupdate_display
. Right now there is no input processing ingame.py
which is a little confusing for someone trying to understand how it gets done. - Have
board.show_board
return a board, and print it here. Try to put all your print statements in one file. - Change the
run
loop towhile self.snake.alive
for clarity (change from.status
to.alive
for this reason)
board.py
- I would say this is the file that needs the most improvement, but I don't have great answers as to how.
- Generally, this could use more separation between displaying the board, vs tracking the game state.
- Add a docstring to each method. In particular:
board_init
,food_process
,normalize_fill
,check_food
,put_food
all need one to figure out what they do. Evenshow_board
is a little fuzzy in that I don't know if it will clear the screen or print the score - There are too many blank lines
- Don't use hardcoded values 1 to 3 for board contents. Instead, use an enum
- Initialization:
- I don't know what
board.fill
is based on the name, orboard.first
. - Rename
board.food
toboard.food_pos
- I don't know what
board_init
sounds like it should be called exactly once, at startup, but instead it's called every game tick. I'm not sure what it does.- I don't know what
board.normalize_fill
orsnake.normalize_pos
do based on the name. - Rename
check_food
tois_food_on_board
. Make it a one-liner. - Rename
put_food
toplace_random_food
. This is a good use ofwhile True
. - In
show_board
, use the namesx,y
orrow,column
instead ofi,j
.
snake.py
- Define an ENUM of directions
- Your initialization process is good. The variables are clear, but I think they could be trimmed or renamed in some cases.
- You don't need
self.init_l
. - Change
snake.status
tosnake.alive
. - Change
first
andlast
tohead
andtail
, which are clear for a snake. Then deletehead
andtail
, because it's bad to have two variables that can be out of sync--if you want,@property
might be a good fit instead. - Change
pos
to something likeoccupied_squares
because it's not clear it's a list from the name.
- You don't need
- Add a docstring to
normalize_pos
andcheck
, and__isData
. move_toward_direction
is too repetitive, shrink it. Remove the unusedstep
parameter as well.- Rename
self.__lost()
toself.lose()
orself.alive = False
. Prefer present tense action verbs for method names. - Remove
rand_direction
, which looks like unused testing code. - Move
check_arrow_keys
elsewhere, such as intogame.py
.
-
\$\begingroup\$ Thanks for your points. Very nice comments. \$\endgroup\$Amir reza Riahi– Amir reza Riahi2022年01月06日 17:17:26 +00:00Commented Jan 6, 2022 at 17:17