#!/usr/bin/python """ Driftwood v3 by Kris Schnee. A simple graphical interface (ie. system of buttons, meters &c). The goal of this system is to provide an interface in a way that is very simple to use, requires no dependencies but Pygame, and doesn't take over your program's event-handling loop. It should serve as a basic UI system for those whose main concern is to get basic interface elements working quickly, as opposed to a full-featured game engine. The interface is divided into "widgets" that can respond to events. To use this module, create a list of widgets, eg "self.widgets = []". Each widget should be passed an "owner" reference. Display the widgets by calling eg.: for widget in self.widgets: widget.Draw() ## Draws to a Pygame Surface object called "screen". To make them respond to events, call in your Pygame event loop: for event in pygame.event.get(): handled = False for widget in self.widgets: handled = widget.HandleEvent(event) if handled: break if not handled: ## Put your own event-handling code here To get information back from the widgets handling events, your game should have a "Notify" function that puts this info on a stack of some kind for your game to react to it. The widgets will call Notify and pass along a dictionary object describing what happened; you can then respond to these events or not. See the demo for how all this comes together. See also my "Ringtale" module for one method of organizing a game's states. Currently available widget types: -Widget (generic) -Button I'm in the process of rebuilding this module from a past version. Module Contents (search for chapter symbols): ~Header~ ~Imported Modules~ ~Constants~ ~Classes~ ~Functions~ ~Autorun~ (the code that runs when this program is run) """ ##___________________________________ ## ~Header~ ##___________________________________ __author__ = "Kris Schnee" __version__ = "2008.1.23" __license__ = "Public Domain" ##___________________________________ ## ~Imported Modules~ ##___________________________________ ## Standard modules. import os ## Third-party modules. import pygame ## Freeware SDL game toolkit from from pygame.locals import * ## Useful constants ##___________________________________ ## ~Constants~ ##___________________________________ ## Look in these subdirectories for graphics. ## I assume a "graphics" subdir containing subdirs for "interface" and "fonts". GRAPHICS_DIRECTORY = "graphics" INTERFACE_GRAPHICS_DIRECTORY = os.path.join(GRAPHICS_DIRECTORY,"interface") FONT_DIRECTORY = os.path.join(GRAPHICS_DIRECTORY,"fonts") DEFAULT_FONT_NAME = None ##___________________________________ ## ~Classes~ ##___________________________________ class Pen: """A wrapper for pygame's Font class. It offers a simple text-writing function and is used by widgets.""" def __init__(self,filename=DEFAULT_FONT_NAME,size=30,color=(255,255,255)): if filename: filename = os.path.join(FONT_DIRECTORY,filename) self.font = pygame.font.Font(filename,size) self.color = color def Write(self,text,color=None): """Return a surface containing rendered text.""" if not color: color = self.color return self.font.render(text,1,self.color) class Widget: def __init__(self,**options): self.owner = options.get("owner") self.name = options.get("name") self.coords = options.get("coords",(0,0,100,100)) ## Pygame Rect object, or tuple. self.coords = pygame.rect.Rect(self.coords) ## Display surface. self.surface = pygame.surface.Surface(self.coords.size) self.dirty = True ## Graphics options. self.visible = options.get("visible",True) self.pen = options.get("pen",DEFAULT_PEN) self.text = options.get("text","") ## Displayed centered. self.rendered_text = None ## A display surface self.SetText(self.text) """The first color is always used. If a second is given, the window shades downward to the second color.""" self.background_color = options.get("background_color",(0,192,192)) self.background_gradient = options.get("background_gradient",(0,64,64)) self.background_image = options.get("background_image") self.border_color = options.get("border_color",(255,255,255)) ## A hidden surface where I draw my colored background and border for speed. self.background_surface = pygame.surface.Surface((self.coords.w, self.coords.h)) #.convert_alpha() self.background_surface.fill((0,0,0,0)) """Alpha: Visibility. Alpha can be handled differently for different widgets. For instance you might want a translucent window containing fully opaque text and graphics.""" self.alpha = options.get("alpha",255) ## 0 = Invisible, 255 = Opaque self.BuildBackground() def SetAlpha(self,alpha): self.alpha = alpha def SetVisible(self,visible=True): """Make me drawn or not, independently of my alpha setting.""" self.visible = visible def DrawBackground(self): """Quickly copy my blank background (w/border) to the screen. It's actually drawn using BuildBackround, which must be explicitly called to rebuild it (eg. if you want to change the border style).""" self.surface.blit(self.background_surface,(0,0)) def SetBackgroundImage(self,new_image): self.background_image = new_image self.BuildBackground() def BuildBackground(self): """Redraw the colored background and border (if any). This function is relatively time-consuming, so it's generally called only once, then DrawBackground is used each frame to blit the result.""" self.background_surface.fill((0,0,0,self.alpha)) if self.background_gradient: x1 = 0 x2 = self.background_surface.get_rect().right-1 a, b = self.background_color, self.background_gradient y1 = 0 y2 = self.background_surface.get_rect().bottom-1 h = y2-y1 rate = (float((b[0]-a[0])/h), (float(b[1]-a[1])/h), (float(b[2]-a[2])/h) ) for line in range(y1,y2): color = (min(max(a[0]+(rate[0]*line),0),255), min(max(a[1]+(rate[1]*line),0),255), min(max(a[2]+(rate[2]*line),0),255), self.alpha ) pygame.draw.line(self.background_surface,color,(x1,line),(x2,line)) else: ## Solid color background. self.background_surface.fill(self.background_color) if self.background_image: self.background_surface.blit(self.background_image,(0,0)) pygame.draw.rect(self.background_surface,self.border_color,(0,0,self.coords.w,self.coords.h),1) def DrawAlignedText(self,text,alignment="center"): """Draw text at a certain alignment.""" if alignment == "center": text_center_x = text.get_width()/2 text_center_y = text.get_height()/2 align_by = ((self.coords.w/2)-text_center_x, (self.coords.h/2)-text_center_y) self.surface.blit( text, align_by ) def SetText(self,text): self.dirty = True self.text = str(text) self.rendered_text = self.pen.Write(self.text) def Redraw(self): self.surface.fill((0,0,0,self.alpha)) if self.visible: self.DrawBackground() self.DrawAlignedText(self.rendered_text) def Draw(self): if self.dirty: self.dirty = False self.Redraw() screen.blit(self.surface,self.coords.topleft) def HandleEvent(self,event): return False ## Never handled by this class. class Button( Widget ): """A widget that sends messages to its owner when clicked.""" def __init__(self,**options): Widget.__init__(self,**options) def HandleEvent(self,event): """React to clicks within my borders. Note that the handling order is determined by the order of widgets in the widget list. Hence you should describe your interface from back-to-front if there are to be widgets "in front" of others.""" if event.type == MOUSEBUTTONDOWN: if self.coords.collidepoint(event.pos): self.owner.Notify({"text":"Clicked","sender":self.name}) return True return False class TextBox( Widget ): """A widget that holds lots of text. This widget doesn't handle scrolling text. If there's too much it will simply get cut off.""" def __init__(self,**options): self.words = [] self.word_surfaces = [] Widget.__init__(self,**options) def SetText(self,text): self.text = str(text) self.words = self.text.split(" ") for word in self.words: self.word_surfaces.append( self.pen.Write(word+" ") ) def AddText(self,text): self.SetText(self.text + str(text)) def Redraw(self): self.surface.fill((0,0,0,self.alpha)) if self.visible: self.DrawBackground() ## Place my rendered text, breaking at end of lines, by word. x,y = 4,2 right_edge = self.surface.get_width() bottom_edge = self.surface.get_height() for word in self.word_surfaces: word_width = word.get_width() word_height = word.get_height() end_of_word = x + word_width if end_of_word >= right_edge: ## Do a "carriage return" first. x = 4 y += word_height self.surface.blit(word,(x,y)) x += word_width class Scroll( Widget ): """Holds text that can be scrolled down.""" def __init__(self,**options): self.lines = [] self.cursor_home = 4 self.cursor = 10000 ## Force a new line to be created at first. self.max_lines = options.get("max_lines",20) self.lines_displayed = options.get("lines_displayed",5) self.lines_to_draw = [0,self.lines_displayed-1] Widget.__init__(self,**options) def Scroll(self,down=True,num_lines=1): self.dirty = True if down: last_line = min(len(self.lines)-1,self.lines_to_draw[1]+num_lines) first_line = last_line - self.lines_displayed + 1 else: first_line = max(0,self.lines_to_draw[0]-num_lines) last_line = first_line + self.lines_displayed - 1 self.lines_to_draw = [first_line,last_line] def SetText(self,text): self.ClearText() self.AddText(text) def ClearText(self): self.cursor = 10000 ## Again, force a new line. self.lines = [] def AddText(self,text): """Build a set of lines.""" words = str(text).split(" ") line_width = self.surface.get_width() for word in words: word_rendered = self.pen.Write(word+" ") ## Where to put it? Is there room on the current line? word_width = word_rendered.get_width() word_height = word_rendered.get_height() if self.cursor + word_width > line_width: ## There's no room. Start a new line. new_line = pygame.surface.Surface((line_width,word_height)).convert_alpha() new_line.fill((0,0,0,0)) ## If there are too many lines, delete the first. if len(self.lines) > self.max_lines: self.lines = self.lines[1:] self.lines.append(new_line) ## Begin at the left edge. self.cursor = self.cursor_home ## Place the word at the current line, at the cursor's X. self.lines[-1].blit(word_rendered,(self.cursor,0)) self.cursor += word_width def Redraw(self): self.surface.fill((0,0,0,self.alpha)) if self.visible: self.DrawBackground() ## Determine what lines to draw; some may not be visible. y = 2 for n in range(self.lines_to_draw[0],self.lines_to_draw[1]+1): line = self.lines[n] self.surface.blit(line,(4,y)) y += line.get_height() class Demo: """Demonstrates this widget system. There's a stack of events that are generated by the widgets. It would also be possible to use user-defined Pygame events and put those directly onto the Pygame event stack, but the way I'm using allows for more flexibility, because events can be passed from one widget to another, as within a complex widget containing other widgets.""" def __init__(self): self.messages = [] self.widgets = [] self.widgets.append(Widget()) ## Simplest possible widget. self.widgets.append(Button(owner=self,coords=(25,50,200,100),alpha=64,name="bMover",text="Click Me")) self.widgets.append(Widget(owner=self,coords=(50,300,500,50),name="lLabel",text="Jackdaws love my big sphinx of quartz.")) self.widgets.append(Button(owner=self,coords=(50,350,500,50),name="bChange",text="Click here to change above text.",background_gradient=None)) SAMPLE_TEXT = "The ocean was a machine designed to kill him. Garrett Fox, one little human in a raft, shivered from the cold spray of the hurricane while toothed waves stabbed up and the sky whirled. His mouth tasted of acid and his hand felt frozen on the tiller. Through the sting of salt in his eyes he squinted, trying to find the others. The storm spun the raft so that the twin lights of Castor came back into view: pathetic lamps burning holes in the mist. Castor itself was a concrete mountain floating in the storm and spearing the nothingness of the sea and sky. His place, with his people in the water." ## self.widgets.append(TextBox(owner=self,coords=(50,400,700,150),name="tbTextBox",text=SAMPLE_TEXT)) self.widgets.append(Scroll(owner=self,coords=(25,400,700,150),name="sScroll",text=SAMPLE_TEXT)) self.widgets.append(Button(owner=self,coords=(725,400,60,50),name="bTestScrollUp",text="Up",background_gradient=None)) self.widgets.append(Button(owner=self,coords=(725,500,60,50),name="bTestScrollDown",text="Down",background_gradient=None)) def Notify(self,message): self.messages.append(message) def Go(self): while True: ## Drawing screen.fill((0,0,0)) for widget in self.widgets: widget.Draw() pygame.display.update() ## Logic: Hardware events. for event in pygame.event.get(): ## First, give the widgets a chance to react. handled = False for widget in self.widgets: handled = widget.HandleEvent(event) if handled: break ## If they don't, handle the event here. if not handled: if event.type == KEYDOWN and event.key == K_ESCAPE: return ## Logic: Gameplay events. for message in self.messages: text = message.get("text") if text == "Clicked": sender = message.get("sender") if sender == "bMover": ## Move the button when it's clicked. self.widgets[1].coords[0] += 50 elif sender == "bChange": """Change text in lLabel. Note: I could've made a dict. of widgets instead of a label, so that I could refer to it by name.""" self.widgets[2].SetText("The quick brown fox jumped over the lazy dog.") elif sender == "bTestScrollDown": self.widgets[4].Scroll() elif sender == "bTestScrollUp": self.widgets[4].Scroll(False) self.messages = [] ##___________________________________ ## ~Functions~ ##___________________________________ ##__________________________________ ## ~Autorun~ ##__________________________________ pygame.init() DEFAULT_PEN = Pen() screen = pygame.display.set_mode((800,600)) ## Always create a "screen." if __name__ == "__main__": ## Run a demo. pygame.event.set_allowed((KEYDOWN,KEYUP,MOUSEBUTTONDOWN,MOUSEBUTTONUP)) demo = Demo() demo.Go()