I have a medium-sized project here, and I would like some comments on the code. In particular I'd like comments on how well I'm handling OO-programming, my programs logic and whether you think my code is "Pythonic."
The project is up on GitHub, currently the only major features that are missing are a proper options menu (although basic menu's like the pause and start menu work.)
Here's a basic rundown of how the program works.
We have a Game class which handles the context of pause menus and the game itself. It does this with it's call() method, which runs another Game instance which takes over the screen. When the callee' exits, control returns to the caller and everything is set to how it was before the call was made (except for music playing, that doesn't work yet, but I have a solution for it.) In the case where quitGame() is given a string argument, control will be returned to the game named. For example, in the pause menu there is an option to "exit to main menu" which is handled like such: self.quitGame("MainMenu").
The drawing of objects and handling of events are related to what objects are currently registered with the running game, self.addJob("name", Class()) adds a job/object. There is currently no Job() class being derived from, this is something I've been meaning to add. Events are handled by each separate object, they all have an eventHandler() method which is called with the events returned from pygame.event.get() every loop. This means that keys that for example move the Tetromino on the screen will only work in the context of a Tetromino job being registered for the current game. Personally I think this makes event handling very clean and easy to extend. For example when the game is over, a TimedExecution Job is added, with the anykey option set to True.
self.addJob("endtimer", TimedExecution(self.quitGame, seconds=2, anykey=True))
This means that when a key is pressed, it will immediately execute the function it was given, self.quitGame
Another important part of the program is the TextBox class, which like the name advertises is a Job that will display a text box. It renders/caches fonts so that I never have to deal with that manually (except for when I wrote the TextBox itself.) The way it's called should explain it well.
TextBox(self, "Level: {level}\nScore: {score}\nLines: {lines}\nLines left: {level up}",
border=True, y=BLOCK_HEIGHT+1, x=BLOCK_WIDTH*2+(BOARD_WIDTH)*BOARD_BLOCKWIDTH, textfit=True,
colors={"border":(0xaa,0xaa,0xaa), "font":(0xaa,0xaa,0xaa)},
font=TETRIS_STATUSBOX_FONT,
variables={"level": lambda s: s.getJob("board").level,
"score": lambda s: s.getJob("board").score,
"lines": lambda s: s.getJob("board").lines,
"level up": lambda s: s.getJob("board").level_lines,
}
)
TETRIS_STATUSBOX_FONT is a dictionary, that's how I chose to represent the fonts.
Most of the code is in Tetris.py https://github.com/UndeadMastodon/Molltris/blob/master/Tetris.py
Then there is some code for loading XML https://github.com/UndeadMastodon/Molltris/blob/master/Load.py
Here you can download the entire thing as a zipped folder https://github.com/UndeadMastodon/Molltris/archive/master.zip
I was told to post my code directly, unfortunately there is a limit to the amount of characters I can have in my post. So I'll upload it to pastebin, where no changes will be made at least. http://pastebin.com/Lp4LiSfC
Then I'll post a couple of major things here:
Tetromino class:
class Tetromino(object):
def __init__(self, board, matrix, type, color, x=0, y=None, updateinterval=FRAMERATE, queue=0):
self.matrix = matrix
self.type = type
self.board = board
self.color = color
self.updateinterval = updateinterval
self.time_until_update = self.updateinterval
self.draw_required = True
self.update_required = True
self.sped_up = False
self.x = x
self.y = y
self.queue = queue
self.level = 1
if y == None:
self.y = -(len(self.matrix))
## Hackety hack
def forBlock(self, func, boolean=False):
for y in xrange(len(self.matrix)):
for x in xrange(len(self.matrix[y])):
if self.matrix[y][x] and func(self.x + x, self.y + y, self.matrix) and boolean:
return True
def draw(self):
def drawBlock(x, y, _):
self.board.drawCube(x, y, self.color)
self.forBlock(drawBlock)
def insert(self):
def insert(x, y, _):
self.board.blocks[(x, y)] = self.color
if self.y < 0:
## XXX: GAME OVER
self.board.update_required = False
self.forBlock(insert)
self.board.checkTetris()
self.update_required = False
def update(self):
self.time_until_update -= 1
if self.time_until_update <= 0:
self.moveDiagonal(1)
self.time_until_update = self.updateinterval
def drop(self):
while self.update_required:
self.moveDiagonal(1)
def checkBlockCollision(self):
def colliding(x, y, _):
return self.board.blocks.get((x, y))
return self.forBlock(colliding, boolean=True)
def checkWallCollision(self, xp, yp):
for y in xrange(len(self.matrix)):
for x in xrange(len(self.matrix[y])):
## Some of the functions need to know which edge the collision happened on,
## otherwise the result can be treated like a boolean.
if self.matrix[y][x]:
if yp+y > self.board.height-1:
return "bottom"
if xp+x > self.board.width-1:
return "right"
if xp+x < 0:
return "left"
## Move diagonally, if possible
def moveDiagonal(self, direction):
self.y += direction
if self.checkBlockCollision():
self.y -= direction
self.insert()
if self.checkWallCollision(self.x, self.y) == "bottom":
self.y -= direction
self.insert()
## Move horizontally, if possible
def moveHorizontal(self, direction):
self.x += direction
if self.checkBlockCollision():
self.x -= direction
if self.checkWallCollision(self.x, self.y):
self.x -= direction
## Rotate if possible
def rotate(self, direction):
last_matrix = self.matrix
self.matrix = rot90(self.matrix)
if self.checkWallCollision(self.x, self.y) or self.checkBlockCollision():
self.matrix = last_matrix
## It makes the game WAAY to easy, but i kind of always wondered "what if"
def flip(self):
flip(self.matrix)
if self.checkWallCollision(self.x, self.y) or self.checkBlockCollision():
flip(self.matrix)
def eventHandler(self, events):
for event in events:
if event.type == KEYUP:
if event.key == keymap["game"]["speed_up"] and self.sped_up:
self.sped_up = False
self.updateinterval *= 10
self.time_until_update = self.updateinterval
if event.type == KEYDOWN:
if event.key == keymap["game"]["rotate_right"]:
self.rotate(1)
elif event.key == keymap["game"]["rotate_left"]:
self.rotate(-1)
elif event.key == keymap["game"]["reverse"]:
self.flip()
elif event.key == keymap["game"]["move_right"]:
self.moveHorizontal(1)
elif event.key == keymap["game"]["move_left"]:
self.moveHorizontal(-1)
elif event.key == keymap["game"]["drop_down"]:
self.drop()
elif event.key == keymap["game"]["speed_up"]:
self.sped_up = True
self.updateinterval /= 10
self.time_until_update = self.updateinterval
Game class:
class Game(object):
def __init__(self, _id, caption="", mouse_visible=True, bgcolor=(0x22,0x22,0x22), screen=None, ticktime=FRAMERATE,
width=SCREEN_WIDTH, height=SCREEN_HEIGHT, x=SCREEN_WIDTH, y=SCREEN_HEIGHT, sound_enabled=False, soundtrack=None):
self.caption = caption
self.mouse_visible = mouse_visible
self.bgcolor = bgcolor
self.screen = screen
self.ticktime = ticktime
self.batch = {}
self.drawqueue = []
self.ret = 0
self.windows = {}
self.height = y
self.width = x
self.events = None
self.id = _id
self.soundtrack = soundtrack
self.sound_enabled = sound_enabled
self.playing = ""
self.setup()
def stopMusic(self):
self.playing = ""
Pygame.mixer.music.stop()
## TODO: The call/quit model currently fails here, I'll just have to save the music's "progress."
def playMusic(self, path, loops=1):
try:
if not self.sound_enabled:
Log.warning("Attempted to play music in `{}' where sound has been disabled".format(self.id))
Pygame.mixer.music.load(path)
Pygame.mixer.music.play(loops)
Log.log("Playing sountrack `{}'".format(path))
self.playing = path
except:
Log.error("Unable to play music file: `{}'".format(path))
def getJob(self, name):
return self.batch[name]
def addJob(self, name, obj):
self.batch[name] = obj
self.drawqueue.append(name)
## Why not just call Sys.exit(), why create a separate method for this?
## Because finishing of can get more complex as this program develops.
def quit(self):
Sys.exit()
## We just "exploit" the stack to create things like pause menus or other "contexts"
## that take over the screen.
def call(self, obj, **kwargs):
game = obj(screen=self.screen, **kwargs)
ret = game.run()
self.setup()
if ret and self.id != ret:
self.quitGame(ret)
def quitGame(self, *args):
if args:
self.ret = args[0]
if self.playing:
self.stopMusic()
self.running = None
def setup(self):
Pygame.init()
Pygame.display.set_caption(self.caption)
Pygame.mouse.set_visible(int(self.mouse_visible))
if not Pygame.mixer.get_init() and self.sound_enabled:
Log.log("Initializing mixer")
Pygame.mixer.init()
if self.soundtrack and self.sound_enabled and not self.playing:
self.playMusic(self.soundtrack, loops=-1)
if not self.screen:
self.screen = Pygame.display.set_mode((self.width, self.height), DISPLAY_OPTIONS)
self.screen.fill(self.bgcolor)
Pygame.display.flip()
self.clock = Pygame.time.Clock()
def eventHandler(self, events):
pass
def run(self):
if not hasattr(self, "running") or not hasattr(self, "eventHandler"):
raise GameError("Game has not been properly initialized")
while self.running:
self.clock.tick(self.ticktime)
self.screen.fill(self.bgcolor)
self.events = Pygame.event.get()
queue = sorted(self.batch, key=lambda obj: self.batch[obj].queue)
for obj in queue:
obj = self.getJob(obj)
if obj.update_required:
obj.update()
if obj.draw_required:
obj.draw()
## Context is love, context is life.
obj.eventHandler(self.events)
Pygame.display.flip()
self.eventHandler(self.events)
if self.running:
self.running()
return self.ret