#!/usr/bin/env/python # Tells Linux systems how to run this file. """ Ringtale: A game framework This is a way to organize the events of a game: a loop for storytelling. Consider a game that has several screens. For instance, you start on a title screen, then go to a setup screen, then the main game, then back and forth between that and an inventory screen. At some point you might quit and return to the title screen. How can these states of the game be organized? One way is to keep calling functions for each screen, but doing so has the potential for infinite recursion: the function for screen A calls B, which calls A again. A related problem is how to pass information back and forth between parts of the game, eg. setting the hero's current weapon from an inventory or choosing a destination from a map. Here is a system for organizing such transitions and data storage. There is a central class, Framework, which you subclass and run. Its MainLoop function uses the Pygame toolkit (free & GPL licensed; see pygame.org) to examine events such as keypresses and pass these along to any interface objects that have been created. (A previous version of this code worked without Pygame, avoiding that dependency but missing out on this version's event-handling.) The non-Pygame version is also available. The Framework runs a main loop, MainLoop(). Each cycle, it calls a Draw function and a Logic function, also running a Pygame event-handling loop that can pass unhandled events on to the Logic function. What are these Draw/Logic functions? They're set by the current state. There's a stack of states. When you call Framework.PushState("Inventory") for instance, PushState will search for functions named InventoryLogic and InventoryDraw, and begin calling those each cycle of MainLoop. When PushState is called, it also searches for an optional function named [state name]Setup, and if it's found, calls it. When you call PopState(), the top state is discarded and the previous one is put in place by changing the current Draw and Logic functions. Each state also maintains a set of interface widgets, and a dictionary of data. You can create some buttons, push a new state to shove them aside, then bring them back without having to recreate them when the new state is popped. Same for information like the current game level. If you want to pass data between states, you can specify the variable names when calling Push/PopState. Another way to keep data between states is to have a World object or custom variables in your subclass. So, you should subclass Framework and give the subclass a pair (or trio) of appropriately-named functions for every state you want it to have. Start the Framework by pushing an initial state and calling MainLoop. Within your Logic function you should have some event that will call PopState, and possibly something that will call PushState for some other state. The Framework will automatically respond to a QUIT event by ending the program. A demo is found at the bottom of this file. """ __author__ = "Kris Schnee" __date__ = "2009.9.23" __license__ = "Public Domain" import time ## For FPS counter, useful but not necessary here. import pygame.time ## For framerate regulation. from pygame.locals import * ## Event names such as QUIT ## Are you building an EXE? ## This music code may require that libogg-0.dll be copied to the dist dir. ## Find it among the Python built-in files. MUSIC_END_EVENT = USEREVENT + 1 ## Tell the loop when the song ends. pygame.mixer.music.set_endevent(MUSIC_END_EVENT) class Framework: """The main class of the game. Attempts to follow the MVC Architecture: -Model: To be created by you, eg. as a "World" class -View: *Draw functions -Controller (and game logic): *Logic and optional *Setup functions To use this framework, create a subclass of it that has various states, each consisting of a *Draw and *Logic function and optionally a *Setup function. """ def __init__(self,starting_state=None): self.logic_function = None self.draw_function = None self.setup_function = None self.states = [] self.interface_stack = [] self.notes_stack = [] """Event handlers.""" self.keyboard_handler = None self.mouse_handler = None self.music_handler = None """A dictionary for storing interface elements.""" self.interface = [] self.interface_index = {} """Used for interface events.""" self.messages = [] """A way of storing information about the game. This is an alternative to storing every bit of info in a game world object, such as the status of a multi-part command being entered. It's never automatically reset and there's no error checking other than "get" to make sure an accessed piece of data has been set. """ self.notes = {} if starting_state: self.PushState(starting_state) if self.setup_function: self.setup_function() self.popped = False ## Prevents multiple PopState calls in one loop. ## Framerate regulation. self.clock = pygame.time.Clock() self.framerate = 20 def Notify(self,event): """Add to a stack of messages (any format OK). These get cleared after each call to the Logic function, so you can use Notify to store random short-term info such as gameplay events like "the player got an item". Optional.""" self.messages.append(event) """ State Control """ def GetStateFunctions(self,state_name): """Return the draw, logic, & setup functions for this state.""" state_name = state_name.replace(" ","") ## Remove spaces. try: name = state_name + "Logic" logic_function = getattr(self,name) name = state_name + "Draw" draw_function = getattr(self,name) except: raise SystemError("Couldn't find matching logic/draw functions for state \""+state_name+"\".") name = state_name + "Setup" ## Optional if hasattr(self,name): setup_function = getattr(self,name) else: setup_function = None return logic_function, draw_function, setup_function def PushState(self,state_name,passed_notes=[]): """Start a new game state, and revert when done. The contents of the interface and notes are also pushed down, so that you can define a new interface and have the old one restored when the state is popped. If you want to transfer information to the new state, give its name as part of the passed_notes parameter. Eg. passing ["acorns","carrots"] will start the new state with its set of notes containing these keys and the values associated with them in the state "below". Note that unless you then pass the notes "down" when popping the state, any changes to those notes are discarded.""" ## Store interface elements & notes, passing some notes "up." self.interface_stack.append(self.interface) self.interface = [] self.interface_index = {} self.notes_stack.append(self.notes) self.notes = {} for key in passed_notes: self.notes[key] = self.notes_stack[-1][key] self.keyboard_handler = None self.mouse_handler = None logic_function, draw_function, setup_function = self.GetStateFunctions(state_name) self.logic_function = logic_function self.draw_function = draw_function self.setup_function = setup_function if self.setup_function: self.setup_function() self.states.append(state_name) def PopState(self,passed_notes=[]): """End the current game state & revert to that below. A past error seemed to involve several game events being generated in one run-through of a logic function, each calling PopState. Eg. this might happen if the Escape key were hit rapidly enough to generate two PyGame KEYDOWN events. The current game state would end, and then so would the next state, making the program leap to the wrong state (two screens "down") and even crashing it (if len(states)==1). So, this function will now ignore duplicate calls within one loop.""" if self.popped: print "PopState was already called this round. Ignoring it this time." return print "Popping top state from current stack: "+str(self.states) if self.states: self.states.pop() if self.states: ## Revert to past interface elements & notes. self.interface = self.interface_stack.pop() self.interface_index = {} ## Rebuild the interface index. for widget in self.interface: self.interface_index[widget.name] = widget for key in passed_notes: ## Handle the passed notes. self.notes_stack[-1][key] = self.notes[key] self.notes = self.notes_stack.pop() ## Get the appropriate functions to start running. self.logic_function, self.draw_function, self.setup_function = self.GetStateFunctions(self.states[-1]) ## Set a keyboard and/or mouse handler if appropriate. self.keyboard_handler = None self.mouse_handler = None k_handler = [w for w in self.interface if w.keyboard_handler] if k_handler: self.SetKeyboardHandler(k_handler[0]) m_handler = [w for w in self.interface if w.mouse_handler] if m_handler: self.SetMouseHandler(m_handler[0]) else: ## There are no states remaining. self.logic_function, self.draw_function, setup_function = self.DefaultLogicFunction, None, None else: raise SystemError("Tried to pop a state when none were left.") def JumpState(self,state_name): """Pop and push to end this state and switch to another. Maybe suitable for games with a "flat" structure of states.""" self.PopState() self.PushState(state_name) if self.setup_function: self.setup_function() def ClearState(self): """Pop all states.""" self.states = self.states[:1] self.PopState() """ Special event handlers (optional) These are useful if you create a specialized event-handling object, eg. something that will capture keystrokes to control a character. If you don't set such handlers, then the Pygame events (such as KEYDOWN) will get seen by MainLoop and passed along to the current Logic function, which can handle them there.""" def SetKeyboardHandler(self,widget): self.keyboard_handler = widget def SetMouseHandler(self,widget): self.mouse_handler = widget def SetMusicHandler(self,widget): """If set, this widget will be told when the music ends. This widget's "PlaySong" function will be called when a Pygame MUSIC_END_EVENT is created, indicating that a song currently being played has ended. That function should start the song again. Not using music? Just don't set a music handler, then.""" self.music_handler = widget """ Main Loop """ def MainLoop(self): """Do basic event-handling until the program ends. The game logic and drawing are handled by a pair of functions that can be replaced on the fly to represent switching to different aspects of gameplay, such as a title screen, battle screen &c. Run the current state's Logic and Draw functions. React to any demands to push a new state or pop the current one. Repeat.""" ## Framerate calculation (optional) cycles = 0 starting_time = time.time() while self.states: ## Until there's nothing to do: ## Draw. self.draw_function() ## Handle logic. unhandled_events = [] for event in pygame.event.get(): handled = False ## Check for overrides by dedicated keyboard/mouse handlers. if event.type in (KEYDOWN,KEYUP): if self.keyboard_handler: handled = self.keyboard_handler.HandleEvent(event) elif self.mouse_handler and event.type in (MOUSEBUTTONDOWN,MOUSEBUTTONUP): handled = self.keyboard_handler.HandleEvent(event) if not handled: ## Give any interface widgets a chance to react. handled = False for i in self.interface: handled = i.HandleEvent(event) if handled: break if not handled: ## Respond to certain rare additional event types. if event.type == MUSIC_END_EVENT and self.music_handler: self.music_handler.PlaySong() elif event.type == QUIT: ## Windows "X" or other forced end. exit() if not handled: ## Still unhandled? Pass this event to the logic function. unhandled_events.append(event) ## Call the logic function. self.logic_function(unhandled_events) self.messages = [] ## Clear random message stack. """Pygame refreshes the whole screen. Note: This limits the framerate. For greater efficiency you can pass a Pygame Rect object or a tuple (x1,y1,w,h) to specify what area of the screen should be refreshed. You'll have to modify this file to do so, though.""" pygame.display.update() self.clock.tick(self.framerate) ## Delay maintains desired framerate self.popped = False ## Prevents multiple PopState calls in one loop cycles += 1 ## For framerate counter ## At program's end, display the overall framerate. if cycles > 1: ending_time = time.time() fps = cycles/float(ending_time - starting_time) print "FPS: "+str(fps) """ Other """ def AddWidget(self,widget): """Adds interface elements to a list for drawing & messaging. Call this after creating a widget using whatever code you like. My own interface system "Driftwood" is one option. This function's role is to register the widget so that it can respond to events, also creating a convenient list and index that you can use for drawing the widgets, eg. "for w in self.interface: w.Draw()." Widgets are assumed to accept Pygame event objects through a HandleEvent function, so make sure your widget objects have one.""" self.interface.append(widget) self.interface_index[widget.name] = widget def ResetInterface(self): """Delete all current interface widgets. Not needed when pushing/popping states; just for your convenience.""" self.interface = [] self.interface_index = {} def SetDesiredFramerate(self,fps=20): self.framerate = fps def DefaultLogicFunction(self,events): """Readied to handle events when the program is closing. When PopState is called and no states are left, self.logic_function gets set to this rather than None. The reason is that the logic function gets called once more even after the last state is popped, which would give an error without this fix. As a result, you can override DefaultLogicFunction to have a special end-of-program function.""" pass ##### DEMOS ##### class FrameworkDemo( Framework ): """Demonstrate a very simple program using the above framework. All it does is run one state, stopping after a certain number of loops. In a game you would create a class like this and push/pop a larger set of states according to decisions made in the logic functions. Note that the trio of *Logic, *Draw and the optional *Setup conform to a naming standard, so that just invoking the state name "Test" makes them accessible. """ def __init__(self,**options): Framework.__init__(self,**options) self.PushState("Test") def TestSetup(self): """Not necessary; demonstrates setting a stackable variable.""" self.notes["count"] = 0 def TestLogic(self,events): """This sample logic function doesn't respond to any events. However, it does have a circumstance in which PopState will be run. Otherwise it'd run forever until a QUIT event overrides it.""" count = self.notes["count"] if count > 28: self.PopState() self.notes["count"] = count + 1 def TestDraw(self): """Draw a changing shape as a test.""" screen.fill((0,0,0)) count = self.notes["count"] print "Count: "+str(count) pygame.draw.circle(screen,(200-(count*4),0,count*7),(200,200),count*5) class FrameworkDemo2( Framework ): """A slightly more complex demo.""" def __init__(self,**options): Framework.__init__(self,**options) screen = pygame.display.set_mode((800,600)) pygame.font.init() ## Note: Font 'None' doesn't work in an EXE, so don't expect this demo ## to work without modification if you 'compile' it using Py2EXE. ## If you want, specify a font name in the local directory. self.font = pygame.font.Font(None,32) self.PushState("One") def OneSetup(self): self.notes["text"] = self.font.render("In state ONE. Hit P to push a new state; Escape to quit.",1,(255,255,255)) def OneDraw(self): screen.fill((0,32,64)) screen.blit(self.notes["text"],(50,200)) self.notes["lastkey_info"] = self.font.render("Key code entered from state Two: "+self.notes.get("lastkey","None"),1,(255,255,255)) screen.blit(self.notes["lastkey_info"],(50,250)) def OneLogic(self,events): for event in events: if event.type == KEYDOWN: if event.key == K_p: self.PushState("Two") elif event.key == K_ESCAPE: self.PopState() def TwoSetup(self): self.notes["text"] = self.font.render("In state TWO. Hit any key to go back.",1,(255,255,255)) def TwoDraw(self): screen.fill((64,0,0)) screen.blit(self.notes["text"],(50,200)) def TwoLogic(self,events): for event in events: if event.type == KEYDOWN: self.notes["lastkey"] = str(event.key) print self.notes.get("lastkey") self.PopState(["lastkey"]) ## Autorun (Demo) if __name__ == "__main__": print "Running demo." import time import pygame screen = pygame.display.set_mode((400,400)) d = FrameworkDemo() d.MainLoop() print "Done. Type 'd = FrameworkDemo2()' then 'd.MainLoop' to see the other demo." ## d = FrameworkDemo2() ## d.MainLoop()