User Tools

Site Tools


a_gentle_introduction

A Gentle Introduction

This page will walk you through creating a very simple app with Zoetrope: a sprite that can jump from one platform to another. We'll elaborate on this by adding animation and sound, too. Despite how basic this sounds, this will give us a chance to touch on all of the basics of making an app with Zoetrope.

First, A Block

We'll start by putting a single block onscreen. Zoetrope uses a display list to draw things onscreen, which is just a to-do list for drawing. On every frame, the app draws each of its sprites in sequence. You can simulate layers by manipulating the order of the display list, since sprites that are later in the list draw on top of earlier ones. What the sprite draws is completely up to each one. Zoetrope includes predefined classes for many of the kinds of sprites you'd want to use. For this example, we're going to use the Fill class, which fills in the bounds of the sprite with a solid color.

Let's get started. Take a copy of the Zoetrope source folder and open main.lua. This file is automatically run by LÖVE at startup. Replace it with the text below – if you are typing this in yourself, you can skip any line that starts with two dashes (). Anything starting with two dashes is a comment, and doesn't have any effect.

-- This turns on checks for common mistakes that can save you a
-- lot of time debugging. It takes some processor time, so it's
-- a good idea to turn this off when you're ready to release your app.
STRICT = true
 
-- This turns on the debug console, which can be brought up at
-- any point by pressing the Tab key. From the console, you can
-- watch values and run any Lua statement. The console also appears
-- automatically if your app crashes. Finally, you can press
-- Control-Alt-R (Control-Option-R on a Mac) to instantly reload your
-- code from on disk. Just like STRICT, you'd want to turn this off
-- for release.
DEBUG = true
 
-- This includes the Zoetrope library, and should be included before
-- you make any references to it.
require 'zoetrope'
 
-- the.app is a container for everything that happens in our app.
-- This statement creates a new version of Zoetrope's built-in
-- App class, with custom properties and methods defined inside
-- the curly brackets.
the.app = App:new
{
    -- Zoetrope uses event handlers for many actions. In
    -- this case, we define some new behavior for our app's
    -- run event, which happens once, when the app first starts up.
 
    onRun = function (self)
        -- The same way we created a new, customized App,
        -- we create a new, customized Fill. (0, 0) is the
        -- top left corner of the window. x increases as you
        -- move to the right, while y increases as you move
        -- downward.
        self.player = Fill:new{ x = 0, y = 0, width = 32, height = 48,
                                fill = {0, 0, 255} }
 
        -- Finally, we add our sprite to our display list.
        self:add(self.player)
    end
}

After you save your changes, run the app (i.e. by dragging the source folder onto the LÖVE application). You should see a blue square at the top left corner of the screen. Not very exciting, but it's a start!

Two short notes about this:

  • Zoetrope uses the as an easily-accessible reference for things you use often like the.app, or the.mouse and the.keys (which correspond to the user's mouse and keyboard). You can store useful information in the, too – for example, you could use the.score to keep track of the player's score. Any object in your code will be able to access it.
  • The color of the fill sprite is set by its fill property. You'll see colors described like this all over Zoetrope. They are a table of numbers that range from 0 to 255 in RGB (red, green, blue) order. You can add a fourth number here if you want; this is interpreted as the color's alpha. A 255 alpha is completely opaque, while a 0 alpha is entirely transparent. Values in between are translucent. If you leave off the alpha, Zoetrope assumes you mean 255.

A Moving Block

If an app isn't interactive, it's not very exciting. Let's let the user move the block left and right with the arrow keys. Replace main.lua's contents with this:

STRICT = true
DEBUG = true
require 'zoetrope'
 
the.app = App:new
{
    onRun = function (self)
        self.player = Fill:new
        {
            x = 0, y = 0,
            width = 32, height = 48,
            fill = {0, 0, 255},
            onUpdate = function (self, elapsed)
                if the.keys:pressed('left') then
                    self.velocity.x = -100
                elseif the.keys:pressed('right') then
                    self.velocity.x = 100
                else
                    self.velocity.x = 0
                end
            end
        }
        self:add(self.player)
    end
}

We've added a new event handler to the player called onUpdate. Update events are sent by an app during each frame. Apps by default try to run at 60 frames per second, but this doesn't always work out in practice – there may be too many sprites onscreen, or maybe the user's computer is busy running another program in the background. The exact amount of time (in seconds) that has passed since the last frame is passed to each sprite's onUpdate handler as the argument named elapsed. We don't actually use that information here, so you could leave off that argument if you wanted to.

Nearly everything in Zoetrope can have an onUpdate handler, including each sprite and even the app itself. The order of when things receive update events is not guaranteed. If you need something to run before everything else's update event, you can use an onStartFrame handler instead. If you need to run after everyone else, use onEndFrame.

Inside the player's onUpdate method, we check the state of the.keys. This is a helper object that keeps track of the state of the keyboard. Its pressed method returns whether the user has a key pressed down during the current frame. Try changing this to justPressed and see how the block's behavior changes. You can also write things like the.keys:pressed('left', 'a') to allow multiple keys to perform the same function – it returns true if any of the keys are pressed. Keys have obvious names for the most part. You can look over the full list if you're not sure what to use.

Finally, we change the block's velocity property based on the keys being pressed. Of course, we could change the sprite's position by writing something like self.x = 50, for example, but Zoetrope sprites have built-in properties to simulate motion easily: velocity, acceleration, and drag. Velocity moves the sprite at a constant speed. Acceleration gradually moves the sprite faster and faster. Drag gradually slows the sprite down to standing still. All of these properties are defined based on either a pixels/second or pixels/second^2 rate. That way, your app will behave identically no matter how many frames per second it runs at.

One Platform

Let's add some gravity to the block, and a platform for it to land on. In order for the platform to support the player, we need to add collision detection – that is, to check whether the player and the platform sprites are overlapping. There are two parts to adding collision detection in Zoetrope. First, you have to ask for the detection to be done, and what sprites should be tested. Secondly, you need to define a onCollide handler on the sprites that are being checked. Its job is to take some action based on the collision – in our case, the platform needs to support the player. The onCollide handler is passed the sprite that it's colliding with, and two numbers: the number of pixels in the x and y dimensions that the sprite is overlapping. You can use the overlap amount to allow the user some leeway in dodging bullets, for example.

STRICT = true
DEBUG = true
require 'zoetrope'
 
the.app = App:new
{
    onRun = function (self)
        self.player = Fill:new
        {
            x = 0, y = 0,
            width = 32, height = 48,
            fill = {0, 0, 255},
            acceleration = { y = 600 },
            onUpdate = function (self, elapsed)
                if the.keys:pressed('left') then
                    self.velocity.x = -100
                elseif the.keys:pressed('right') then
                    self.velocity.x = 100
                else
                    self.velocity.x = 0
                end
            end
        }
        self:add(self.player)
 
        self.platform = Fill:new
        {
            x = 0, y = 400,
            width = 128, height = 32,
            fill = {255, 255, 255},
            onCollide = function (self, other, xOverlap, yOverlap)
                self:displace(other)
                other.velocity.y = 0
            end
        }
        self:add(self.platform)
    end,
 
    onUpdate = function (self, elapsed)
        self.platform:collide(self.player)
    end
}

Although our source code is getting long, adding this new functionality only took three steps.

  • We gave the player an accleration.y property of 400. This makes it accelerate downward. The x property of acceleration works the same way but affects horizontal movement, and rotation actually rotates a sprite onscreen. Try changing the line to accleration = { rotation = math.rad(180) } to see what it does. (Rotation in Zoetrope uses radians as a measurement.)
  • We added a platform that's a Fill like the player, only with a different position and color. It has an onCollide handler that simply displace()s any sprite that collides with it. When a Zoetrope sprite displaces another, it moves the other so that it no longer overlaps itself. A stationary sprite should always displace a movable one. Try switching self and other in both lines of the onCollide handler to see why this is important.

    We also reset the player's velocity.y to 0. Otherwise, the player eventually passes through the platform because its acceleration keeps acting on its velocity.
  • Finally, we added an onUpdate handler to the app itself to do collision checking between the player and the platform on every frame.

One important thing to remember about the display list, now that we've added multiple sprites: objects that are added later always appear on top of sprites that have been added before. Although it never happens since the platform displaces the player, if they ever overlapped, the platform would appear on top of the player. Reversing the order of the add() calls would fix this.

Making a Leap

Let's add a second platform and allow the player to jump between them with the space bars. We could just copy and paste the definition of our first one and change its x position, but our source code is getting a bit messy as it is. We're better off creating a new class called Platform that shares its appearance and player-supporting behavior, but can be customized with different positions.

Notice how definining a new class is almost identical to creating an instance – we just write extend instead of new.

STRICT = true
DEBUG = true
require 'zoetrope'
 
Player = Fill:extend
{
    width = 32,
    height = 48,
    fill = {0, 0, 255},
    acceleration = { y = 600 },
    canJump = false,
 
    onUpdate = function (self)
        if the.keys:pressed('left') then
            self.velocity.x = -100
        elseif the.keys:pressed('right') then
            self.velocity.x = 100
        else
            self.velocity.x = 0
        end
 
        if the.keys:justPressed(' ') and self.canJump then
            self.velocity.y = -500
            self.canJump = false
        end
    end,
 
    onCollide = function (self)
        if self.velocity.y > 0 then
            self.velocity.y = 0
            self.canJump = true
        end
    end
}
 
Platform = Fill:extend
{
    width = 128,
    height = 32,
    fill = {255, 255, 255},
 
    onCollide = function (self, other)
        self:displace(other)
    end
}
 
the.app = App:new
{
    onRun = function (self)
        self.player = Player:new{ x = 0, y = 0 }
        self:add(self.player)
        self.platforms = Group:new()
        self.platforms:add(Platform:new{ x = 0, y = 400 })
        self.platforms:add(Platform:new{ x = 250, y = 400 })
        self:add(self.platforms)
    end,
 
    onUpdate = function (self)
        self.platforms:collide(self.player)
    end
}

We also took another shortcut with our platforms by creating a Group. A group is a container for sprites that's very handy. First of all, it allows you to create layers in Zoetrope's display list – for example, you could have a group named background and a group named foreground that are added in the correct order. You could then later add sprites to the appropriate group whenever you wanted to, to make sure the right drawing order was happening.

Using groups is also key to fast collision detection. It's tremendously faster to detect collisions between a sprite and a group – or even better, a group and another group – than it is to check sprite-by-sprite. It doesn't matter so much in this example since there are only three sprites being checked, but it does have a real effect in an app with hundreds of sprites.

(You can check for collisions between sprites in a single group, by the way. You'd just write group:collide().)

Sound

Time for some polish. To add a sound effect for jumping, we'll need a source file. You can use a sample one or make your own. One of the easiest ways to generate sound effects for games is Bfxr – there's even a button specifically to generate jump sound effects. LÖVE supports a bunch of sound formats, including MP3, Ogg Vorbis, and WAV, so you can use whatever tool you're comfortable with.

Playing a sound effect only takes one extra line in Player's onUpdate handler:

...
if the.keys:justPressed(' ') and self.canJump then
    playSound('jump.ogg')
    self.velocity.y = -500
    self.canJump = false
end
...

You can also specify a volume to play the sound at – try playSound('jump.ogg', 0.5), for example.

A Prettier Platform

Let's give the platforms some graphics now. For the sake of simplicity, we'll won't have them animate. Instead we'll tile an image across their surface. If you draw your own image, it's important that it be 32 x 32 pixels in size. Or you can use this one:

Adding this only takes a slight change to Platform's definition, to use the Tile class instead of a Fill.

Platform = Tile:extend
{
    width = 128,
    height = 32,
    image = 'brick.png',
 
    onCollide = function (self, other)
        self:displace(other)
    end
}

Instead of a fill property, the platforms have an image. Whenever you specify pathnames to assets in Zoetrope, you need to do so relative to the top level of your source code folder. So brick.png needs to be at the top level. If we had it in a folder named assets, we'd write image = 'assets/brick.png' instead.

(You can leave out width and height for a Tile, by the way. It'll assume you want the sprite to be the same dimensions as the source image.)

Animating the Player

Finally, we'll make the player an animated sprite. Animations have an image property just like Tiles do, but it is instead a series of frames that sit side by side, with no space in between. If you've never made a sprite sheet before, you'll can use this one.

And here's the source code to add animation based on the player's movement:

Player = Animation:extend
{
    width = 32,
    height = 48,
    image = 'player.png',
    sequences = 
    {
        right = { frames = {1, 2, 3, 4, 5, 6}, fps = 10 },
        left = { frames = {7, 8, 9, 10, 11, 12}, fps = 10 } 
    },
    acceleration = { y = 600 },
    canJump = false,
 
    onUpdate = function (self)
        if the.keys:pressed('left') then
            self.velocity.x = -100
            self:play('left')
        elseif the.keys:pressed('right') then
            self.velocity.x = 100
            self:play('right')
        else
            self.velocity.x = 0
            self:freeze()
        end
 
        if the.keys:justPressed(' ') and self.canJump then
            playSound('jump.ogg')
            self.velocity.y = -500
            self.canJump = false
        end
    end,
 
    onCollide = function (self)
        if self.velocity.y > 0 then
            self.velocity.y = 0
            self.canJump = true
        end
    end
}

The sequences property is the most complicated here. It defines both a left and right walking animation. The frames property of each tells Zoetrope what order frames should come in, and fps says how quickly the animation should play (that's short for frames per second). If you don't say otherwise, sequences will loop when they reach the end. If you don't want that, add loops = false to a sequence's definition.

Inside the onUpdate handler, we call play() to play the appropriate animation. Asking to play a sequence that's already active is harmless, so we don't have to pay attention to whether the player has just changed direction. If the player isn't moving at all, we call freeze(), which stops the animation on the current frame. If you want to freeze an animation on a specific frame, you can instead write freeze(3).

Downloads for the Lazy and Exercises for the Diligent

Here's the source code for the completed version. And if you're so motivated, try these things:

  • Add a couple more platforms at different positions onscreen.
  • Move the player back to the top of the screen if he or she falls off the bottom. Hint: the height of the screen can be found in the.app.height.
  • There's actually a bug in our code in that a player can jump in mid-air if he or she runs off the edge of a platform. Fix this.
  • Make it so that the player can pass through a platform if he or she is jumping upward through it, but doesn't fall back down.

Relevant API Documentation

These pages describe all of the functionality built into the classes that this tutorial uses.

a_gentle_introduction.txt · Last modified: 2012/11/28 16:45 by Chris Klimas