[Author Prev][Author Next][Thread Prev][Thread Next][Author Index][Thread Index]

[pygame] "Driftwood" UI



Over lunch I cleaned up the latest version of my UI code, which is attached. This version just contains a generic text-displaying widget and a Button that responds to clicking, with a simple solid-color or gradient look. I hope to add more widgets to this by cleaning up and modifying older code.

Even in this incomplete version, it might serve as a low-end system for people looking to make a couple of buttons and text labels as quickly and simply as possible. It's public domain if you'd like to use it.

Kris
#!/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.22"
__license__ = "Public Domain"


##___________________________________
##      ~Imported Modules~
##___________________________________

## Standard modules.
import os

## Third-party modules.
import pygame ## Freeware SDL game toolkit from <pygame.org>
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.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.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 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),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))

    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.")
            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()