10.0. Tutorial: Writing Games with Lush and SDL. |
Lush provides a simple way to write 2D real-time games using the SDL library (Simple Directmedia Layer). What follows is a gentle tutorial on how to do that.
This tutorial was written to be understandable by motivated high school students with some basic familiarity with Lush but with little programing experience. Seasoned programmers will probably want to skip certain paragraphs.
The code for this tutorial is available at .
10.0.0. A Quick Reminder on Basic Lush Programming |
(setq an-instance (new a-class arg1 arg2 ...))For example, creating a new sprite (a movable object on a graphic screen) is done with:
(setq a-new-sprite (new sdl-sprite scr 0))Objects have slots and methods. Slots are variables that are attached to the object, while methods are functions that are attached to the class of the object. Calling a method on an object is like telling it to carry out a particular action. This is done with the function ==>. Here is an example:
(==> a-new-sprite move 300 400) (==> a-new-sprite draw)This can be interpreted as "tell a-new-sprite to move to position 300, 400", then "tell a-new-sprite to draw itself on the screen".
10.0.1. A Simple Lunar Lander |
The code described in this section is available in /home/leonb/lush/packages/sdl/demos/tutorial.lsh
First, make a working directory, say game and cd to it.
10.0.1.0. Creating the Ship and Background Images |
For the time being, instead of creating your own art, you can simply copy the files lem.png and moon.png from the directory /home/leonb/lush/packages/sdl/demos into your working directory.
10.0.1.1. First Implementation of Lander |
10.0.1.1.0. Getting started |
At the top of the file, we put the following line:
(libload "sdl/libsdl")which will load the SDL library.
Next we need to define a function that will run our game:
(de lander1 ()
10.0.1.1.1. initializing SDL |
(sdl-initialize)This will initialize the SDL subsystem. It's OK to call this function multiple times (it only initialize SDL once).
10.0.1.1.2. opening the SDL screen |
(setq scr (new sdl-screen 640 480 "Lander"))now the variable scr contains the screen object.
10.0.1.1.3. creating the background and ship sprites |
;; create background sprite (setq bgd (new sdl-sprite scr 0)) ;; load moon image into frame 0 of bgd sprite (==> bgd load-frame "moon.png" 0 0 0) ;; move sprite to 0 0 (==> bgd move 0 0)The move method does not actually draw anything on the screen, it merely sets the internal coordinate variables of the sprite to (0, 0).
Now let's create the ship sprite and create two variables to hold its position:
;; create ship sprite (setq ship (new sdl-sprite scr 1)) ;; load lem image into frame 0 of ship sprite (==> ship load-frame "lem.png" 0 40 35) ;; set position of ship (setq x 10) (setq y 20)The handle of the ship sprite is a coordinate 40, 35 (i.e. 40 pixel to the right, and 35 pxiels down from the upper left corner of the ship image), which is roughly at the center of the ship.
10.0.1.1.4. creating the event handler |
(setq event (new sdl-event)) (setq xyk (int-matrix 3))
10.0.1.1.5. the main loop |
- clear the screen - draw the background sprite - read the keyboard - move the ship sprite according to the keys pressed - draw the ship sprite at its new location - flip the screens (see below) - repeat the above step until the user quits
The SDL screen opened through the sdl-screen object is double buffered . What that means is that all the drawing commands do not directly happen on the visible screen, but happen in a "hidden" screen (often called a back buffer).When all the objects have been drawn, we swap the visible screen and the invisible screen: the screen we just drew into is now visible, and the previously visible screen is now available for drawing into without affecting what's shown on the screen. This technique allows us to take our time drawing all the object without the user seeing a mess of partially drawn things. In other words, the "double buffer" technique avoids the "flickering" that happens on the screen when objects are drawn one by one in sequence. Flipping the screens is performed by calling the flip method of the sdl-screen . Here the code of our main loop:
(while (not stop) (==> scr clear) ; fill image with black (==> bgd draw) ; draw moon ground (==> event get-arrows xyk) ; read keyboard (when (= (xyk 2) @@SDLK_q) (setq stop t)) ; stop when q is pressed (setq x (+ x (* 10 (xyk 0)))) ; compute new X coordinate (setq y (+ y (* 10 (xyk 1)))) ; compute new Y coordinate (==> ship move x y) ; move ship sprite to new position (==> ship draw) ; draw ship in back buffer (==> scr flip) ; flip screen buffers ) ; loopThe "get-arrow" method fills the three elements of the "xyk" vector as follows:
The expression (when (= (xyk 2) @@SDLK_q) (setq stop t)) tests if the "q" key was pressed, and sets the "stop" variable to true if it was pressed. SDLK_q is a constant. The value of a constant is accessed by prepending one or two "@" characters to the name. The while loop tests the stop variable and exits if it is true (i.e. if the "q" key has been pressed).
10.0.1.1.6. Putting it all together |
(de lander01 () ;; initialize the SDL subsystem. DONT FORGET THIS!!! (sdl-initialize) (setq scr (new sdl-screen 640 480 "Lander")) ; open screen ;; create background sprite (setq bgd (new sdl-sprite scr 0)) (==> bgd load-frame "moon.png" 0 0 0) (==> bgd move 0 0) ;; create lem sprite (setq ship (new sdl-sprite scr 1)) (==> ship load-frame "lem.png" 0 40 35) ;; set position of ship (setq x 200) (setq y 100) ;; create event object (setq event (new sdl-event)) (setq xyk (int-matrix 3)) (while (not stop) (==> scr clear) ; fill image with black (==> bgd draw) ; draw moon ground (==> event get-arrows xyk) ; read keyboard (when (= (xyk 2) @@SDLK_q) (setq stop t)) ; stop when q is pressed (setq x (+ x (* 10 (xyk 0)))) (setq y (+ y (* 10 (xyk 1)))) (==> ship move x y) ; move ship sprite to position (==> ship draw) ; draw ship (==> scr flip) ; flip screens ))
This code has three major problems:
10.0.1.2. Second Lander: Gravity and Newtonian mechanics |
The new implementation also obeys Newtonian mechanics with (gravity, inertia an such). This is done very simply with the following sequence of operations inside the main loop:
1 - read the keyboard arrows and determine the engines thrusts 2 - compute accelerations from thrust and gravity (apply accel=force/mass): - set X-acceleration = X-thrust / ship's mass - set Y-acceleration = Y-thrust / ship's mass + gravity 3 - compute new velocity from acceleration (time integration): - set new X-velocity = old X-velocity + X-acceleration * deltat - set new Y-velocity = old Y-velocity + Y-acceleration * deltat 4 - compute new position from velocity (time integration): - set X-position = X-position + X-velocity * deltat - set Y-position = Y-position + Y-velocity * deltatThe deltat variable is the expected time it takes to go around the main loop of the game (more on this below). Here is how the above equations work.
(setq ax (* mass-inv side-thrust (xyk 0))) (setq ay (+ grav (* mass-inv main-thrust (xyk 1))))Step 3 computes the velocities from the accelerations. The idea is the following, if horizontal acceleration is X-acceleration , and we maintain that acceleration for deltat seconds, our velocity will have increased by X-acceleration times deltat . If we assume that the acceleration was constant while our program went around the loop, and that it took deltat seconds to go around that loop, then we must increase the velocity by (* ax deltat) . Here is the appropriate Lush code:
(setq vx (+ vx (* ax deltat))) ; update X-velocity (setq vy (+ vy (* ay deltat))) ; update Y-velocityStep 4 computes the position from the velocities. The idea is similar: During the time deltat that it takes our program to go around its main loop, we assume that the velocity is constant. The position of the ship during that time has changed by the velocity times deltat . This is true for the horizontal velocity and position as well as the vertical velocity and position:
(setq x (+ x (* vx deltat))) ; update X-position (setq y (+ y (* vy deltat))) ; update Y-position
So, if going around the loop takes 0.05 seconds (20 frames per second), deltat should be 0.05. For example, if the X-velocity is 40 pixels per second and going around the loop takes 0.05 seconds, then the X-position should be incremented by 40*0.05 = 2 pixels each time we go around the loop, hence the formula above. How do we know how long it takes to go around the loop? Fortunately, the flip method of sdl-screen object sets the the deltat slot of the sdl-screen object to the number of seconds since the last call to flip (most likely, and hopefully a number much smaller than 1). So as long as we do one screen flip per cycle around the loop, we can simply set our deltat to the screen's deltat which we can access with :scr:deltat . The complete update code for our main loop is now:
[...get keyboard input into xyk here...] (setq ax (* mass-inv side-thrust (xyk 0))) ; update X-acceleration (setq ay (+ grav (* mass-inv main-thrust (xyk 1)))) ; update Y-accel (setq vx (+ vx (* ax deltat))) ; update X-velocity (setq vy (+ vy (* ay deltat))) ; update Y-velocity (setq x (+ x (* vx deltat))) ; update X-position (setq y (+ y (* vy deltat))) ; update Y-position [...draw all the objects here...] (==> scr flip) ; flip screens (setq deltat :scr:deltat) ; get time between screen flips
Next, we need some code to bounce the ship around or have it wrap around the screen when it goes off the boundaries. Here is the code below. We are assuming that the variable "ground" contains the value 360 or so (near the bottom of the screen):
(when (< x -40) (setq x (+ 640 (- x -40)))) ; wrap around left side (when (> x 680) (setq x (+ -40 (- x 640)))) ; wrap around right side (when (> y ground) ; bounce on ground (setq vy (* -0.5 vy)) ; divide vertical speed by 2 (setq vx (* 0.25 vx)) ; divide horiz speed by 4 (setq y ground)) ; set vert position to ground altitude
Here is the new complete code:
(de lander02 () ;; initialize the SDL subsystem. DONT FORGET THIS!!! (sdl-initialize) (let* ((scr (new sdl-screen 640 480 "Lander")) ; open screen (bgd (new sdl-sprite scr 0)) ; create background sprite (ship (new sdl-sprite scr 1)) ; create lem sprite ;; set position, velocity, acceleration of ship (x 200) (y 100) (vx 4) (vy 0) (ax 0) (ay 0) ;; set mass, inverse mass, and deltat of ship (mass 1) (mass-inv (/ 1 mass)) (deltat 0.01) (side-thrust 200) ; set side engine thrust (main-thrust 400) ; set main engine thrust (grav 200) ; set gravity coefficient in pixels/s/s (stop ()) (event (new sdl-event)) (xyk (int-matrix 3)) (ground 360)) (==> bgd load-frame "moon.png" 0 0 0) (==> bgd move 0 0) (==> ship load-frame "lem.png" 0 40 35) (while (not stop) (==> scr clear) ; fill image with black (==> bgd draw) ; draw moon ground (==> event get-arrows xyk) ; read keyboard (when (= (xyk 2) @@SDLK_q) (setq stop t)) ; stop when q is pressed (setq ax (* mass-inv side-thrust (xyk 0))) ; update acceleration (setq ay (+ grav (* mass-inv main-thrust (xyk 1)))) ; update acceleration (setq vx (+ vx (* ax deltat))) ; update velocity (setq vy (+ vy (* ay deltat))) ; update velocity (setq x (+ x (* vx deltat))) ; update position (setq y (+ y (* vy deltat))) ; update position (when (< x -40) (setq x (+ 640 (- x -40)))) ; wrap around left side (when (> x 680) (setq x (+ -40 (- x 640)))) ; wrap around right side (when (> y ground) ; bounce on ground (setq vy (* -0.5 vy)) ; divide vertical speed by 2 (setq vx (* 0.25 vx)) ; divide horiz speed by 4 (setq y ground)) ; set vert position to ground altitude (==> ship move x y) ; move ship sprite to position (==> ship draw) ; draw ship (==> scr flip) ; flip screens (setq deltat :scr:deltat) ; update deltat to time between screen flips )))
10.0.1.3. Third Lander: a flame and a shadow |
(let* ([... allocate screen and bg sprite ...] (ship (new sdl-sprite scr 1)) ; create lem sprite (flame (new sdl-sprite scr 1)) ; create flame sprite (shadow (new sdl-sprite scr 3)) ; create shadow sprite [... more initializations...] ) [... load background image ...] ;; load image and put the handle at the center of the sprite (40,35) (==> ship load-frame "lem.png" 0 40 35) ;; the flame is designed to have the handle at the same place (==> flame load-frame "lem-flame.png" 0 40 35) ;; here is the shadow (==> shadow load-frame "lem-shadow.png" 0 40 -6) (while (not stop) ; main loop [... compute all the coordinates...] (==> shadow move x 360) ; move shadow sprite to position (==> flame move x y) ; move flame sprite to position (==> ship move x y) ; move ship sprite to position (==> shadow draw) ; draw shadow (when (<> 0 (xyk 1)) (==> flame draw)) ; draw flame if engine is on (==> ship draw) ; draw ship (==> scr flip) ; flip screens (setq deltat :scr:deltat) ; update deltat to time between screen flips ))Here is the complete code (which can be found in /home/leonb/lush/packages/sdl/demos/tutorial.lsh. )
(de lander03 () ;; initialize the SDL subsystem. DONT FORGET THIS!!! (sdl-initialize) (let* ((scr (new sdl-screen 640 480 "Lander")) ; open screen (bgd (new sdl-sprite scr 0)) ; create background sprite (ship (new sdl-sprite scr 1)) ; create lem sprite (flame (new sdl-sprite scr 1)) ; create flame sprite (shadow (new sdl-sprite scr 3)) ; create shadow sprite ;; set position, velocity, acceleration of ship (x 200) (y 100) (vx 4) (vy 0) (ax 0) (ay 0) ;; set mass, inverse mass, and deltat of ship (mass 1) (mass-inv (/ 1 mass)) (deltat 0.01) (side-thrust 200) ; set side engine thrust (main-thrust 400) ; set main engine thrust (grav 200) ; set gravity coefficient in pixels/s/s (stop ()) (event (new sdl-event)) (xyk (int-matrix 3)) (ground 360)) (==> bgd load-frame "moon.png" 0 0 0) (==> bgd move 0 0) ;; put the handle at the center of the sprite (40,35) (==> ship load-frame "lem.png" 0 40 35) (==> flame load-frame "lem-flame.png" 0 40 35) (==> shadow load-frame "lem-shadow.png" 0 40 -6) (while (not stop) (==> scr clear) ; fill image with black (==> bgd draw) ; draw moon ground (==> event get-arrows xyk) ; read keyboard (when (= (xyk 2) @@SDLK_q) (setq stop t)) ; stop when q is pressed (setq ax (* mass-inv side-thrust (xyk 0))) ; update acceleration (setq ay (+ grav (* mass-inv main-thrust (xyk 1)))) ; update acceleration (setq vx (+ vx (* ax deltat))) ; update velocity (setq vy (+ vy (* ay deltat))) ; update velocity (setq x (+ x (* vx deltat))) ; update position (setq y (+ y (* vy deltat))) ; update position (when (< x -40) (setq x (+ 640 (- x -40)))) ; wrap around left side (when (> x 680) (setq x (+ -40 (- x 640)))) ; wrap around right side (when (> y ground) ; bounce on ground (setq vy (* -0.5 vy)) (setq vx (* 0.25 vx)) (setq y ground)) (==> shadow move x 360) ; move ship sprite to position (==> flame move x y) ; move ship sprite to position (==> ship move x y) ; move ship sprite to position (==> shadow draw) (when (<> 0 (xyk 1)) (==> flame draw)) ; draw flame if engine is on (==> ship draw) (==> scr flip) ; flip screens (setq deltat :scr:deltat) ; update deltat to time between screen flips )))
10.0.1.4. Fourth Lander: the ship rotates |
(==> ship rotscale-frame src-frame dst-frame angle scale)This takes the image of a source frame, identified by its number src-frame , rotates it by angle degrees (clockwise), scale it by scale , and write the resulting image in the frame with index dst-frame . The handle (or hotpoint) is left unchanged in the process, i.e., the handle in the transformed frame is at the same location within the object as in the source frame.
In the code segment below, we take the 0-th frame and create rotated version of it every 10 degrees:
(==> ship load-frame "lem.png" 0 40 35) (let ((i 1)) ;; make an image every 10 degrees from 10 to 350 (for (angle 10 350 10) ;; take frame 0, rotate by angle, scale by 1, ;; and copy into frame i (==> ship rotscale-frame 0 i angle 1) (incr i)))Within the main loop, we must read the keyboard, and use the result from the left and right arrow keys to rotate the ship. We will use a variable theta to store the current angle of the ship. This variable is an integer between 0 and 35 which, when multiplied by 10 gives the ship's angle with the vertical. theta is used as the index of the frame for the ship sprite:
(while (not stop) [... stuff deleted...] (==> event get-arrows xyk) ; read keyboard ;; update the angle from the left-right arrow keys (setq theta (+ theta (xyk 0))) ;; bring the angle back to the 0-360 interval (while (>= theta 36) (setq theta (- theta 36))) (while (< theta 0) (setq theta (+ theta 36))) ;; set frame of ship and flame to one matching the angle (==> ship set-frame (int theta)) [... stuff deleted...] )Next, we must modify the computation of the X and Y accelerations to take into account the fact that the thrust of the main engine is at an angle. When the angle of the ship with the vertical is theta times 10 degrees, the horizontal component of the thrust is main-thrust * sin( -pi/180 * 10 * theta) , which in lush is written (* main-thrust (sin (* pi/180 -10 theta))) . We must multiply the angle in degree by pi/180 because the sin functions takes angles in radians. Rather than computing pi/180 every time, we precompute the value and put the result in a global variable called pi/180 :
(defvar pi/180 (/ 3.1415927 180))The horizontal acceleration is the horizontal thrust divided by the ship's mass (or multiplied by the inverse of the mass)
(setq ax (* mass-inv main-thrust (xyk 1) (sin (* pi/180 -10 theta))))The vertical component of the acceleration is derived similarly, except the sin is now a cos , and the gravity component is added:
;; compute x acceleration (setq ax (* mass-inv main-thrust (xyk 1) (sin (* pi/180 -10 theta)))) ;; compute y acceleration (setq ay (+ grav (* mass-inv main-thrust (xyk 1) (cos (* pi/180 10 theta)))))The rest of the code is identical to the previous version.
Here is the complete code, which can be found in /home/leonb/lush/packages/sdl/demos/tutorial.lsh. ):
(setq pi/180 (/ 3.1415927 180)) (de lander04 () ;; initialize the SDL subsystem. DONT FORGET THIS!!! (sdl-initialize) (let* ((scr (new sdl-screen 640 480 "Lander")) ; open screen (bgd (new sdl-sprite scr 0)) ; create background sprite (ship (new sdl-sprite scr 1)) ; create lem sprite (flame (new sdl-sprite scr 1)) ; create flame sprite (shadow (new sdl-sprite scr 3)) ; create shadow sprite ;; set position, velocity, acceleration of ship (x 200) (y 100) (vx 4) (vy 0) (ax 0) (ay 0) ;; angle of ship (theta 0) ;; set mass, inverse mass, and deltat of ship (mass 1) (mass-inv (/ 1 mass)) (deltat 0.01) (side-thrust 200) ; set side engine thrust (main-thrust 400) ; set main engine thrust (grav 200) ; set gravity coefficient in pixels/s/s (stop ()) (event (new sdl-event)) (xyk (int-matrix 3)) (ground 360)) (==> bgd load-frame "moon.png" 0 0 0) (==> bgd move 0 0) ;; put the handle at the center of the sprite (40,35) (==> ship load-frame "lem.png" 0 40 35) (==> flame load-frame "lem-flame.png" 0 40 35) (==> shadow load-frame "lem-shadow.png" 0 40 -6) ;; fill up frames with rotated lems (let ((i 1)) ;; make an image every 10 degrees from 10 to 350 (for (angle 10 350 10) ;; take frame 0, rotate by angle, scale by 1, ;; and copy into frame i (==> ship rotscale-frame 0 i angle 1) ;; same for flame (==> flame rotscale-frame 0 i angle 1) (incr i))) (while (not stop) (==> scr clear) ; fill image with black (==> bgd draw) ; draw moon ground (==> event get-arrows xyk) ; read keyboard (when (= (xyk 2) @@SDLK_q) (setq stop t)) ; stop when q is pressed ;; update the angle from the left-right arrow keys (setq theta (+ theta (xyk 0))) ;; bring the angle back to the 0-360 interval (while (>= theta 36) (setq theta (- theta 36))) (while (< theta 0) (setq theta (+ theta 36))) ;; set frame of ship and flame to one matching the angle (==> ship set-frame (int theta)) (==> flame set-frame (int theta)) ;; compute x acceleration (setq ax (* mass-inv main-thrust (xyk 1) (sin (* pi/180 -10 theta)))) ;; compute y acceleration (setq ay (+ grav (* mass-inv main-thrust (xyk 1) (cos (* pi/180 10 theta))))) (setq vx (+ vx (* ax deltat))) ; update velocity (setq vy (+ vy (* ay deltat))) ; update velocity (setq x (+ x (* vx deltat))) ; update position (setq y (+ y (* vy deltat))) ; update position (when (< x -40) (setq x (+ 640 (- x -40)))) ; wrap around left side (when (> x 680) (setq x (+ -40 (- x 640)))) ; wrap around right side (when (> y ground) ; bounce on ground (setq vy (* -0.5 vy)) (setq vx (* 0.25 vx)) (setq theta 0) (setq y ground)) (==> shadow move x 360) ; move shadow sprite to position (==> flame move x y) ; move flame sprite to position (==> ship move x y) ; move ship sprite to position ;; now draw sprites in the right order, bottom sprite first (==> shadow draw) ; draw shadow (when (<> 0 (xyk 1)) (==> flame draw)) ; draw flame if engine is on (==> ship draw) ; draw ship (==> scr flip) ; flip screens (setq deltat :scr:deltat) ; update deltat to time between screen flips )))
10.0.2. SpaceWar: missiles and collision detection |
10.0.2.0. Two ships |
10.0.2.1. Shooting missiles |
10.0.2.2. Collision Detection |