A Boy Who Wanted To Create Worlds

Once there was a boy who fell in love with this magical device that could bring things to life inside a glaring screen. He spent endless hours exploring imaginary worlds, fighting strange creatures, shooting pixelated spaceships, racing boxy cars. The boy kept pondering. “How is this made? I want to create my own worlds…”.

Then he discovered programming. “I can finally do it!” - he thought. And he tried. And failed. Then he tried harder. He failed again and again. He was too naive to realize that those worlds he was trying to create were too sophisticated, and his knowledge was too limited. He gave up creating those worlds.

What he didn’t give up is writing code for this magical device. He realized he isn’t smart enough to create worlds, yet he found out he could create simpler things like small applications - web, desktop, server side or whatnot. Few years later he found himself getting paid to make those.

Applications got increasingly bigger, they spanned across multiple servers, integrated with each other, became pats of huge infrastructures. The boy, now a grown man, was all into it. It was fun and challenging enough to spend over 10000 hours learning and building what others wanted him to build.

Some of these things were useful, some where boring and pointless. Some were never finished. There were things he was proud of, there were others that he wouldn’t want to talk about, nonetheless everything he built made him a better builder. Yet he never found the time, courage or reason to build what he really wanted to build since he was a little boy - his own worlds.

Until one day he realized that no one can stop him from following his dream. He felt that equipped with his current knowledge and experience he will be able to learn to create worlds of his own. And he went for it.

This boy must live in many software developers, who dream about creating games, but instead sell their software craftsmanship skills to those who need something else. This boy is me, and you. And it’s time to set him free.

Welcome to the world of game development that was waiting for you all these years.

Why Ruby?

When it comes to game development, everyone will tell you that you should go with C++ or some other statically typed language that compiles down to bare metal instructions. Or that you should go with full blown game development platform like Unity. Slow, dynamic languages like Ruby seem like the last choice any sane game developer would go for.

A friend of mine said “There’s little reason to develop a desktop game with Ruby”, and he was absolutely right. Perhaps this is the reason why there are no books about it. All the casual game action happens in mobile devices, and desktop games are for seasoned gamers who demand fast and detailed 3D graphics, motion-captured animations and sophisticated game mechanics - things we know we are not going to be able to build on our own, without millions from VC pockets and Hollywood grade equipment.

Now, bear with me. Your game will not be a 3D MMORPG set in huge, photo realistic representation of Middle-earth. Let’s leave those things to Bethesda, Ubisoft and Rockstar Games. After all, everyone has to start somewhere, and you have to be smart enough to understand, that even though that little boy in you wants to create an improved version of Grand Theft Auto V, we will have to go for something that resembles lesser known Super Nintendo titles instead.

Why not go mobile then? Those devices seem perfect for simpler games. If you are a true gamer at heart, you will agree that touch screen games you find in modern phones and tablets are only good for killing 10 minutes of your time while taking a dump. You have to feel the resistance when you click a button! Screen size also does matter. Playing anything on mobile phone is a torture for those who know what playing real games should feel like.

So, your game will have to be small enough for you to be able to complete it, it will have to have simple 2D graphics, and would not require the latest GeForce with at least 512MB of RAM. This fact gives you the benefit of choice. You don’t have to worry about performance that much. You can choose a friendly and productive language that is designed for programmer happiness. And this is where Ruby starts to shine. It’s beautiful, simple and elegant. It is close to poetry.

What You Should Know Before Reading This Book

As you can read on the cover, this book is “for those who write code for living”. It’s not a requirement, and you will most likely be able to understand everything even if you are a student or hobbyist, but this book will not teach you how to be a good programmer. If you want to learn that, start with timeless classic: The Pragmatic Programmer: From Journeyman to Master.

You should understand Ruby at least to some extent. There are plenty of books and resources covering that subject. Try Why’s Poignant Guide To Ruby or Eloquent Ruby. You can also learn it while reading this book. It shouldn’t be too hard, especially if you already write code for living. After all programming language is merely a tool, and when you learn one, others are relatively easy to switch to.

You should know how to use the command line. Basic knowledge of Git can also be handy.

You don’t have to know how to draw or compose music. We will use media that is available for free. However, knowledge of graphics and audio editing software won’t hurt.

What Are We Going To Build?

This question is of paramount importance. The answer will usually determine if you will likely to succeed. If you want to overstep your boundaries, you will fail. It shouldn’t be too easy either. If you know something about programming already, I bet you can implement Tic Tac Toe, but will you feel proud about it? Will you be able to say “I’ve built a world!”. I wouldn’t.

Graphics

To begin with, we need to know what kind of graphics we are aiming for. We will instantly rule out 3D for several reasons:

We don’t want to increase the scope and complexity

Ruby may not be fast enough for 3D games

Learning proper 3D graphics programming requires reading a separate book that is several times thicker than this one.

Now, we have to swallow our pride and accept the fact that the game will have simple 2D graphics. There are three choices to go for:

Parallel Projection

Top Down

Side-Scrolling

Parallel Projection (think Fallout 1 & 2) is pretty close to 3D graphics, it requires detailed art if you want it to look decent, so we would have a rough start if we went for it.

Top Down view (old titles of Legend of Zelda) offers plenty of freedom to explore the environment in all directions and requires less graphical detail, since things look simpler from above.

Side Scrolling games (Super Mario Bros.) usually involve some physics related to jumping and require more effort to look good. Feeling of exploration is limited, since you usually move from left to right most of the time.

Going with Top Down view will give us a chance to create our game world as open for exploration as possible, while having simple graphics and movement mechanics. Sounds like the best choice for us.

If you are as bad at drawing things as I am, you could still wonder how we are going to get our graphics. Thankfully, there is this opengameart.org. It’s like GitHub of game media, we will surely find something there. It also contains audio samples and tracks.

Game Development Library

Implement it all yourself or harness the power of some game development library that offers you boilerplates and convenient access to common functions? If you’re like me, you would definitely want to implement it all yourself, but that may be the reason why I failed to make a decent game so many times.

If you will try to implement it all yourself, you will most likely end up reimplementing some existing game library, poorly. It won’t take long while you reach a point where you need to interface with underlying operating system libraries to get graphics. And guess if those bindings will work in a different operating system?

So, swallow your pride again, because we are going to use an existing game development library. Good news is that you will be able to actually finish the game, and it will be portable to Windows, Mac and Linux. We will still have to build our own game engine for ourselves on top of it, so don’t think it won’t be fun.

There are several game libraries available for Ruby, but it’s a simple choice, because Gosu is head and shoulders above others. It’s very mature, has a large and active community, and it is mainly written in C++ but has first class Ruby support, so it will be both fast and convenient to use.

Many of other Ruby game libraries are built on top of Gosu, so it’s a solid choice.

Theme And Mechanics

Choosing the right theme is undoubtedly important. It should be something that appeals to you, something you will want to play, and it should not imply difficult game mechanics. I love MMORPGs, and I always dreamed of making an open world game where you can roam around, meet other players, fight monsters and level up. Guess how many times I started building such a game? Even if I wouldn’t have lost the count, I wouldn’t be proud to say the number.

This time, equipped with logic and sanity, I’ve picked something challenging enough, yet still pretty simple to build. Are you ready?

Drumroll…

We will be building a multi directional shooter arcade game where you control a tank, roam around an island, shoot enemy tanks and try not to get destroyed by others.

If you have played Battle City or Tank Force, you should easily get the idea. I believe that implementing such a game (with several twists) would expose us to perfect level of difficulty and provide substantial amount of experience.

We will use a subset of these gorgeous graphics which are available on opengameart.org, generously provided by Csaba Felvegi.

While writing this book, I will be using Mac OS X (10.9), but it should be possible to run all the examples on other operating systems too.

Gosu Wiki has “Getting Started” pages for Mac, Linux and Windows, so I will not be going into much detail here.

Getting Gosu to run on Mac Os X

If you haven’t set up your Mac for development, first install Xcode using App Store. System Ruby should work just fine, but you may want to use Rbenv or RVM to avoid polluting system Ruby. I’ve had trouble installing Gosu with RVM, but your experience may vary.

To install the gem, simply run:

$ gem install gosu

You may need to prefix it with sudo if you are using system Ruby.

To test if gem was installed correctly, you should be able to run this to produce an empty black window:

$ irb irb(main):001:0> require 'gosu' => true irb(main):002:0> Gosu::Window.new(320, 240, false).show => nil

Most developers who use Mac every day will also recommend installing Homebrew package manager, replace Terminal app with iTerm2 and use Oh-My-Zsh to manage ZSH configuration.

Getting The Sample Code

You can find sample code at GitHub: https://github.com/spajus/ruby-gamedev-book-examples.

Clone it to a convenient location:

$ cd ~/gamedev $ git clone git@github.com:spajus/ruby-gamedev-book-examples.git

The source code of final product can be found at https://github.com/spajus/tank_island

All you need for this adventure is a good text editor, terminal and probably some graphics editor. Try GIMP if you want a free one. I’m using Pixelmator, it’s wonderful, but for Mac only. A noteworthy fact is that Pixelmator was built by fellow Lithuanians.

When it comes to editors, I don’t leave home without Vim, but as long as what you use makes you productive, it doesn’t make any difference. Vim, Emacs or Sublime are all good enough to write code, just have some good plugins that support Ruby, and you’re set. If you really feel you need an IDE, which may be the case if you are coming from a static language, you can’t go wrong with RubyMine.

Gosu Basics

By now Gosu should be installed and ready for a spin. But before we rush into building our game, we have to get acquainted with our library. We will go through several simple examples, familiarize ourselves with Gosu architecture and core principles, and take a couple of baby steps towards understanding how to put everything together.

To make this chapter easier to read and understand, I recommend watching Writing Games With Ruby talk given by Mike Moore at LA Ruby Conference 2014. In fact, this talk pushed me towards rethinking this crazy idea of using Ruby for game development, so this book wouldn’t exist without it. Thank you, Mike.

Hello World

To honor the traditions, we will start by writing “Hello World” to get a taste of what Gosu feels like. It is based on Ruby Tutorial that you can find in Gosu Wiki.

01-hello/hello_world.rb 1 require 'gosu' 2 3 class GameWindow < Gosu :: Window 4 def initialize ( width = 320 , height = 240 , fullscreen = false ) 5 super 6 self . caption = 'Hello' 7 @message = Gosu :: Image . from_text ( 8 self , 'Hello, World!' , Gosu . default_font_name , 30 ) 9 end 10 11 def draw 12 @message . draw ( 10 , 10 , 0 ) 13 end 14 end 15 16 window = GameWindow . new 17 window . show

Run the code:

$ ruby 01-hello/hello_world.rb

You should see a neat small window with your message:

See how easy that was? Now let’s try to understand what just happened here.

We have extended Gosu::Window with our own GameWindow class, initializing it as 320x240 window. super passed width , height and fullscreen initialization parameters from GameWindow to Gosu::Window .

Then we defined our window’s caption , and created @message instance variable with an image generated from text "Hello, World!" using Gosu::Image.from_text .

We have overridden Gosu::Window#draw instance method that gets called every time Gosu wants to redraw our game window. In that method we call draw on our @message variable, providing x and y screen coordinates both equal to 10 , and z (depth) value equal to 0.

Screen Coordinates And Depth

Just like most conventional computer graphics libraries, Gosu treats x as horizontal axis (left to right), y as vertical axis (top to bottom), and z as order.

x and y are measured in pixels, and value of z is a relative number that doesn’t mean anything on it’s own. The pixel in top-left corner of the screen has coordinates of 0:0 .

z order in Gosu is just like z-index in CSS. It does not define zoom level, but in case two shapes overlap, one with higher z value will be drawn on top.

Main Loop

The heart of Gosu library is the main loop that happens in Gosu::Window . It is explained fairly well in Gosu wiki, so we will not be discussing it here.

Moving Things With Keyboard

We will modify our “Hello, World!” example to learn how to move things on screen. The following code will print coordinates of the message along with number of times screen was redrawn. It also allows exiting the program by hitting Esc button.

01-hello/hello_movement.rb 1 require 'gosu' 2 3 class GameWindow < Gosu :: Window 4 def initialize ( width = 320 , height = 240 , fullscreen = false ) 5 super 6 self . caption = 'Hello Movement' 7 @x = @y = 10 8 @draws = 0 9 @buttons_down = 0 10 end 11 12 def update 13 @x -= 1 if button_down? ( Gosu :: KbLeft ) 14 @x += 1 if button_down? ( Gosu :: KbRight ) 15 @y -= 1 if button_down? ( Gosu :: KbUp ) 16 @y += 1 if button_down? ( Gosu :: KbDown ) 17 end 18 19 def button_down ( id ) 20 close if id == Gosu :: KbEscape 21 @buttons_down += 1 22 end 23 24 def button_up ( id ) 25 @buttons_down -= 1 26 end 27 28 def needs_redraw? 29 @draws == 0 || @buttons_down > 0 30 end 31 32 def draw 33 @draws += 1 34 @message = Gosu :: Image . from_text ( 35 self , info , Gosu . default_font_name , 30 ) 36 @message . draw ( @x , @y , 0 ) 37 end 38 39 private 40 41 def info 42 "[x: #{ @x } ;y: #{ @y } ;draws: #{ @draws } ]" 43 end 44 end 45 46 window = GameWindow . new 47 window . show

Run the program and try pressing arrow keys:

$ ruby 01-hello/hello_movement.rb

The message will move around as long as you keep arrow keys pressed.

We could write a shorter version, but the point here is that if we wouldn’t override needs_redraw? this program would be slower by order of magnitude, because it would create @message object every time it wants to redraw the window, even though nothing would change.

Here is a screenshot of top displaying two versions of this program. Second screen has needs_redraw? method removed. See the difference?

Ruby is slow, so you have to use it wisely.

Images And Animation

It’s time to make something more exciting. Our game will have to have explosions, therefore we need to learn to animate them. We will set up a background scene and trigger explosions on top of it with our mouse.

01-hello/hello_animation.rb 1 require 'gosu' 2 3 def media_path ( file ) 4 File . join ( File . dirname ( File . dirname ( 5 __FILE__ )), 'media' , file ) 6 end 7 8 class Explosion 9 FRAME_DELAY = 10 # ms 10 SPRITE = media_path ( 'explosion.png' ) 11 12 def self . load_animation ( window ) 13 Gosu :: Image . load_tiles ( 14 window , SPRITE , 128 , 128 , false ) 15 end 16 17 def initialize ( animation , x , y ) 18 @animation = animation 19 @x , @y = x , y 20 @current_frame = 0 21 end 22 23 def update 24 @current_frame += 1 if frame_expired? 25 end 26 27 def draw 28 return if done? 29 image = current_frame 30 image . draw ( 31 @x - image . width / 2 . 0 , 32 @y - image . height / 2 . 0 , 33 0 ) 34 end 35 36 def done? 37 @done ||= @current_frame == @animation . size 38 end 39 40 private 41 42 def current_frame 43 @animation [ @current_frame % @animation . size ] 44 end 45 46 def frame_expired? 47 now = Gosu . milliseconds 48 @last_frame ||= now 49 if ( now - @last_frame ) > FRAME_DELAY 50 @last_frame = now 51 end 52 end 53 end 54 55 class GameWindow < Gosu :: Window 56 BACKGROUND = media_path ( 'country_field.png' ) 57 58 def initialize ( width = 800 , height = 600 , fullscreen = false ) 59 super 60 self . caption = 'Hello Animation' 61 @background = Gosu :: Image . new ( 62 self , BACKGROUND , false ) 63 @animation = Explosion . load_animation ( self ) 64 @explosions = [] 65 end 66 67 def update 68 @explosions . reject! ( & :done? ) 69 @explosions . map ( & :update ) 70 end 71 72 def button_down ( id ) 73 close if id == Gosu :: KbEscape 74 if id == Gosu :: MsLeft 75 @explosions . push ( 76 Explosion . new ( 77 @animation , mouse_x , mouse_y )) 78 end 79 end 80 81 def needs_cursor? 82 true 83 end 84 85 def needs_redraw? 86 ! @scene_ready || @explosions . any? 87 end 88 89 def draw 90 @scene_ready ||= true 91 @background . draw ( 0 , 0 , 0 ) 92 @explosions . map ( & :draw ) 93 end 94 end 95 96 window = GameWindow . new 97 window . show

Run it and click around to enjoy those beautiful special effects:

$ ruby 01-hello/hello_animation.rb

Now let’s figure out how it works. Our GameWindow initializes with @background Gosu::Image and @animation , that holds array of Gosu::Image instances, one for each frame of explosion. Gosu::Image.load_tiles handles it for us.

Explosion::SPRITE points to “tileset” image, which is just a regular image that contains equally sized smaller image frames arranged in ordered sequence. Rows of frames are read left to right, like you would read a book.

Given that explosion.png tileset is 1024x1024 pixels big, and it has 8 rows of 8 tiles per row, it is easy to tell that there are 64 tiles 128x128 pixels each. So, @animation[0] holds 128x128 Gosu::Image with top-left tile, and @animation[63] - the bottom-right one.

Gosu doesn’t handle animation, it’s something you have full control over. We have to draw each tile in a sequence ourselves. You can also use tiles to hold map graphics The logic behind this is pretty simple:

Explosion knows it’s @current_frame number. It begins with 0. Explosion#frame_expired? checks the last time when @current_frame was rendered, and when it is older than Explosion::FRAME_DELAY milliseconds, @current_frame is increased. When GameWindow#update is called, @current_frame is recalculated for all @explosions . Also, explosions that have finished their animation (displayed the last frame) are removed from @explosions array. GameWindow#draw draws background image and all @explosions draw their current_frame . Again, we are saving resources and not redrawing when there are no @explosions in progress. needs_redraw? handles it.

It is important to understand that update and draw order is unpredictable, these methods can be called by your system at different rate, you can’t tell which one will be called more often than the other one, so update should only be concerned with advancing object state, and draw should only draw current state on screen if it is needed. The only reliable thing here is time, consult Gosu.milliseconds to know how much time have passed.

Rule of the thumb: draw should be as lightweight as possible. Prepare all calculations in update and you will have responsive, smooth graphics.

Music And Sound

Our previous program was clearly missing a soundtrack, so we will add one. A background music will be looping, and each explosion will become audible.

01-hello/hello_sound.rb 1 require 'gosu' 2 3 def media_path ( file ) 4 File . join ( File . dirname ( File . dirname ( 5 __FILE__ )), 'media' , file ) 6 end 7 8 class Explosion 9 FRAME_DELAY = 10 # ms 10 SPRITE = media_path ( 'explosion.png' ) 11 12 def self . load_animation ( window ) 13 Gosu :: Image . load_tiles ( 14 window , SPRITE , 128 , 128 , false ) 15 end 16 17 def self . load_sound ( window ) 18 Gosu :: Sample . new ( 19 window , media_path ( 'explosion.mp3' )) 20 end 21 22 def initialize ( animation , sound , x , y ) 23 @animation = animation 24 sound . play 25 @x , @y = x , y 26 @current_frame = 0 27 end 28 29 def update 30 @current_frame += 1 if frame_expired? 31 end 32 33 def draw 34 return if done? 35 image = current_frame 36 image . draw ( 37 @x - image . width / 2 . 0 , 38 @y - image . height / 2 . 0 , 39 0 ) 40 end 41 42 def done? 43 @done ||= @current_frame == @animation . size 44 end 45 46 def sound 47 @sound . play 48 end 49 50 private 51 52 def current_frame 53 @animation [ @current_frame % @animation . size ] 54 end 55 56 def frame_expired? 57 now = Gosu . milliseconds 58 @last_frame ||= now 59 if ( now - @last_frame ) > FRAME_DELAY 60 @last_frame = now 61 end 62 end 63 end 64 65 class GameWindow < Gosu :: Window 66 BACKGROUND = media_path ( 'country_field.png' ) 67 68 def initialize ( width = 800 , height = 600 , fullscreen = false ) 69 super 70 self . caption = 'Hello Animation' 71 @background = Gosu :: Image . new ( 72 self , BACKGROUND , false ) 73 @music = Gosu :: Song . new ( 74 self , media_path ( 'menu_music.mp3' )) 75 @music . volume = 0 . 5 76 @music . play ( true ) 77 @animation = Explosion . load_animation ( self ) 78 @sound = Explosion . load_sound ( self ) 79 @explosions = [] 80 end 81 82 def update 83 @explosions . reject! ( & :done? ) 84 @explosions . map ( & :update ) 85 end 86 87 def button_down ( id ) 88 close if id == Gosu :: KbEscape 89 if id == Gosu :: MsLeft 90 @explosions . push ( 91 Explosion . new ( 92 @animation , @sound , mouse_x , mouse_y )) 93 end 94 end 95 96 def needs_cursor? 97 true 98 end 99 100 def needs_redraw? 101 ! @scene_ready || @explosions . any? 102 end 103 104 def draw 105 @scene_ready ||= true 106 @background . draw ( 0 , 0 , 0 ) 107 @explosions . map ( & :draw ) 108 end 109 end 110 111 window = GameWindow . new 112 window . show

Run it and enjoy the cinematic experience. Adding sound really makes a difference.

$ ruby 01-hello/hello_sound.rb

We only added couple of things over previous example.

72 @music = Gosu :: Song . new ( 73 self , media_path ( 'menu_music.mp3' )) 74 @music . volume = 0 . 5 75 @music . play ( true )

GameWindow creates Gosu::Song with menu_music.mp3 , adjusts the volume so it’s a little more quiet and starts playing in a loop.

16 def self . load_sound ( window ) 17 Gosu :: Sample . new ( 18 window , media_path ( 'explosion.mp3' )) 19 end

Explosion has now got load_sound method that loads explosion.mp3 sound effect Gosu::Sample . This sound effect is loaded once in GameWindow constructor, and passed into every new Explosion , where it simply starts playing.

Handling audio with Gosu is very straightforward. Use Gosu::Song to play background music, and Gosu::Sample to play effects and sounds that can overlap.

Warming Up

Before we start building our game, we want to flex our skills little more, get to know Gosu better and make sure our tools will be able to meet our expectations.

Using Tilesets

After playing around with Gosu for a while, we should be comfortable enough to implement a prototype of top-down view game map using the tileset of our choice. This ground tileset looks like a good place to start.

Integrating With Texture Packer

After downloading and extracting the tileset, it’s obvious that Gosu::Image#load_tiles will not suffice, since it only supports tiles of same size, and there is a tileset in the package that looks like this:

And there is also a JSON file that contains some metadata:

{ "frames" : { "aircraft_1d_destroyed.png" : { "frame" : { "x" : 451 , "y" : 102 , "w" : 57 , "h" : 42 }, "rotated" : false , "trimmed" : false , "spriteSourceSize" : { "x" : 0 , "y" : 0 , "w" : 57 , "h" : 42 }, "sourceSize" : { "w" : 57 , "h" : 42 } }, "aircraft_2d_destroyed.png" : { "frame" : { "x" : 2 , "y" : 680 , "w" : 63 , "h" : 47 }, "rotated" : false , "trimmed" : false , "spriteSourceSize" : { "x" : 0 , "y" : 0 , "w" : 63 , "h" : 47 }, "sourceSize" : { "w" : 63 , "h" : 47 } }, ... }}, "meta" : { "app" : "http://www.texturepacker.com" , "version" : "1.0" , "image" : "decor.png" , "format" : "RGBA8888" , "size" : { "w" : 512 , "h" : 1024 }, "scale" : "1" , "smartupdate" : "$TexturePacker:SmartUpdate:2e6b6964f24c7abfaa85a804e2dc1b05$" }

Looks like these tiles were packed with Texture Packer. After some digging I’ve discovered that Gosu doesn’t have any integration with it, so I had these choices:

Cut the original tileset image into smaller images. Parse JSON and harness the benefits of Texture Packer.

First option was too much work and would prove to be less efficient, because loading many small files is always worse than loading one bigger file. Therefore, second option was the winner, and I also thought “why not write a gem while I’m at it”. And that’s exactly what I did, and you should do the same in such a situation. The gem is available on GitHub:

https://github.com/spajus/gosu-texture-packer

You can install this gem using gem install gosu_texture_packer . If you want to examine the code, easiest way is to clone it on your computer:

$ git clone git@github.com:spajus/gosu-texture-packer.git

Let’s examine the main idea behind this gem. Here is a slightly simplified version that does handles everything in under 20 lines of code:

02-warmup/tileset.rb 1 require 'json' 2 class Tileset 3 def initialize ( window , json ) 4 @json = JSON . parse ( File . read ( json )) 5 image_file = File . join ( 6 File . dirname ( json ), @json [ 'meta' ][ 'image' ] ) 7 @main_image = Gosu :: Image . new ( 8 @window , image_file , true ) 9 end 10 11 def frame ( name ) 12 f = @json [ 'frames' ][ name ][ 'frame' ] 13 @main_image . subimage ( 14 f [ 'x' ] , f [ 'y' ] , f [ 'w' ] , f [ 'h' ] ) 15 end 16 end

If by now you are familiar with Gosu documentation, you will wonder what the hell is Gosu::Image#subimage . At the point of writing it was not documented, and I accidentally discovered it while digging through Gosu source code.

I’m lucky this function existed, because I was ready to bring out the heavy artillery and use RMagick to extract those tiles. We will probably need RMagick at some point of time later, but it’s better to avoid dependencies as long as possible.

Combining Tiles Into A Map

With tileset loading issue out of the way, we can finally get back to drawing that cool map of ours.

The following program will fill the screen with random tiles.

02-warmup/random_map.rb 1 require 'gosu' 2 require 'gosu_texture_packer' 3 4 def media_path ( file ) 5 File . join ( File . dirname ( File . dirname ( 6 __FILE__ )), 'media' , file ) 7 end 8 9 class GameWindow < Gosu :: Window 10 WIDTH = 800 11 HEIGHT = 600 12 TILE_SIZE = 128 13 14 def initialize 15 super ( WIDTH , HEIGHT , false ) 16 self . caption = 'Random Map' 17 @tileset = Gosu :: TexturePacker . load_json ( 18 self , media_path ( 'ground.json' ), :precise ) 19 @redraw = true 20 end 21 22 def button_down ( id ) 23 close if id == Gosu :: KbEscape 24 @redraw = true if id == Gosu :: KbSpace 25 end 26 27 def needs_redraw? 28 @redraw 29 end 30 31 def draw 32 @redraw = false 33 ( 0 . . WIDTH / TILE_SIZE ) . each do | x | 34 ( 0 . . HEIGHT / TILE_SIZE ) . each do | y | 35 @tileset . frame ( 36 @tileset . frame_list . sample ) . draw ( 37 x * ( TILE_SIZE ), 38 y * ( TILE_SIZE ), 39 0 ) 40 end 41 end 42 end 43 end 44 45 window = GameWindow . new 46 window . show

Run it, then press spacebar to refill the screen with random tiles.

$ ruby 02-warmup/random_map.rb

The result doesn’t look seamless, so we will have to figure out what’s wrong. After playing around for a while, I’ve noticed that it’s an issue with Gosu::Image .

When you load a tile like this, it works perfectly:

Gosu :: Image . new ( self , image_path , true , 0 , 0 , 128 , 128 ) Gosu :: Image . load_tiles ( self , image_path , 128 , 128 , true )

And the following produces so called “texture bleeding”:

Gosu :: Image . new ( self , image_path , true ) Gosu :: Image . new ( self , image_path , true ) . subimage ( 0 , 0 , 128 , 128 )

Good thing we’re not building our game yet, right? Welcome to the intricacies of software development!

Now, I have reported my findings, but until it gets fixed, we need a workaround. And the workaround was to use RMagick. I knew we won’t get too far away from it. But our random map now looks gorgeous:

Using Tiled To Create Maps

While low level approach to drawing tiles in screen may be appropriate in some scenarios, like randomly generated maps, we will explore another alternatives. One of them is this great, open source, cross platform, generic tile map editor called Tiled.

It has some limitations, for instance, all tiles in tileset have to be of same proportions. On the upside, it would be easy to load Tiled tilesets with Gosu::Image#load_tiles .

Tiled uses it’s own custom, XML based tmx format for saving maps. It also allows exporting maps to JSON, which is way more convenient, since parsing XML in Ruby is usually done with Nokogiri, which is heavier and it’s native extensions usually cause more trouble than ones JSON parser uses. So, let’s see how that JSON looks like:

02-warmup/tiled_map.json 1 { "height" : 10 , 2 "layers" : [ 3 { 4 "data" : [ 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 0 , 0 , 65 , 6 \ 5 5 , 65 , 65 , 65 , 65 , 65 , 65 , 0 , 0 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 0 , 0 , 0 , 65 , 65 \ 6 , 65 , 65 , 65 , 65 , 65 , 0 , 0 , 0 , 0 , 65 , 65 , 65 , 65 , 65 , 65 , 0 , 0 , 0 , 0 , 65 , 65 , 65 \ 7 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 \ 8 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 , 65 \ 9 ], 10 "height" : 10 , 11 "name" : "Water" , 12 "opacity" : 1 , 13 "type" : "tilelayer" , 14 "visible" : true , 15 "width" : 10 , 16 "x" : 0 , 17 "y" : 0 18 }, 19 { 20 "data" : [ 0 , 0 , 7 , 5 , 57 , 43 , 0 , 0 , 0 , 0 , 0 , 0 , 28 , 1 , 1 , 42 , 0 , 0 , 0 , 0 , \ 21 0 , 0 , 44 , 1 , 1 , 42 , 0 , 0 , 0 , 0 , 0 , 0 , 28 , 1 , 1 , 27 , 43 , 0 , 0 , 0 , 0 , 0 , 28 , 1 , 1 \ 22 , 1 , 27 , 43 , 0 , 0 , 0 , 0 , 28 , 1 , 1 , 1 , 59 , 16 , 0 , 0 , 0 , 0 , 48 , 62 , 61 , 61 , 16 , 0 , \ 23 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 \ 24 , 0 , 0 , 0 , 0 , 0 ], 25 "height" : 10 , 26 "name" : "Ground" , 27 "opacity" : 1 , 28 "type" : "tilelayer" , 29 "visible" : true , 30 "width" : 10 , 31 "x" : 0 , 32 "y" : 0 33 }], 34 "orientation" : "orthogonal" , 35 "properties" : 36 { 37 38 }, 39 "tileheight" : 128 , 40 "tilesets" : [ 41 { 42 "firstgid" : 1 , 43 "image" : "media\/ground.png" , 44 "imageheight" : 1024 , 45 "imagewidth" : 1024 , 46 "margin" : 0 , 47 "name" : "ground" , 48 "properties" : 49 { 50 51 }, 52 "spacing" : 0 , 53 "tileheight" : 128 , 54 "tilewidth" : 128 55 }, 56 { 57 "firstgid" : 65 , 58 "image" : "media\/water.png" , 59 "imageheight" : 128 , 60 "imagewidth" : 128 , 61 "margin" : 0 , 62 "name" : "water" , 63 "properties" : 64 { 65 66 }, 67 "spacing" : 0 , 68 "tileheight" : 128 , 69 "tilewidth" : 128 70 }], 71 "tilewidth" : 128 , 72 "version" : 1 , 73 "width" : 10 74 }

There are following things listed here:

Two different tilesets, “ground” and “water”

Map width and height in tile count (10x10)

Layers with data array contains tile numbers

Couple of extra things that Tiled maps can have:

Object layers containing lists of objects with their coordinates

Properties hash on tiles and objects

This doesn’t look too difficult to parse, so we’re going to implement a loader for Tiled maps. And make it open source, of course.

Loading Tiled Maps With Gosu

Probably the easiest way to load Tiled map is to take each layer and render it on screen, tile by tile, like a cake. We will not care about caching at this point, and the only optimization would be not drawing things that are out of screen boundaries.

After couple of days of test driven development, I’ve ended up writing gosu_tiled gem, that allows you to load Tiled maps with just a few lines of code.

I will not go through describing the implementation, but if you want to examine the thought process, take a look at gosu_tiled gem’s git commit history.

To use the gem, do gem install gosu_tiled and examine the code that shows a map of the island that you can scroll around with arrow keys:

02-warmup/island.rb 1 require 'gosu' 2 require 'gosu_tiled' 3 4 class GameWindow < Gosu :: Window 5 MAP_FILE = File . join ( File . dirname ( 6 __FILE__ ), 'island.json' ) 7 SPEED = 5 8 9 def initialize 10 super ( 640 , 480 , false ) 11 @map = Gosu :: Tiled . load_json ( self , MAP_FILE ) 12 @x = @y = 0 13 @first_render = true 14 end 15 16 def button_down ( id ) 17 close if id == Gosu :: KbEscape 18 end 19 20 def update 21 @x -= SPEED if button_down? ( Gosu :: KbLeft ) 22 @x += SPEED if button_down? ( Gosu :: KbRight ) 23 @y -= SPEED if button_down? ( Gosu :: KbUp ) 24 @y += SPEED if button_down? ( Gosu :: KbDown ) 25 self . caption = " #{ Gosu . fps } FPS. Use arrow keys to pan" 26 end 27 28 def draw 29 @first_render = false 30 @map . draw ( @x , @y ) 31 end 32 33 def needs_redraw? 34 [ Gosu :: KbLeft , 35 Gosu :: KbRight , 36 Gosu :: KbUp , 37 Gosu :: KbDown ]. each do | b | 38 return true if button_down? ( b ) 39 end 40 @first_render 41 end 42 end 43 44 GameWindow . new . show

Run it, use arrow keys to scroll the map.

$ ruby 02-warmup/island.rb

The result is quite satisfying, and it scrolls smoothly without any optimizations:

Generating Random Map With Perlin Noise

In some cases random generated maps make all the difference. Worms and Diablo would probably be just average games if it wasn’t for those always unique, procedurally generated maps.

We will try to make a very primitive map generator ourselves. To begin with, we will be using only 3 different tiles - water, sand and grass. For implementing fully tiled edges, the generator must be aware of available tilesets and know how to combine them in valid ways. We may come back to it, but for now let’s keep things simple.

Now, generating naturally looking randomness is something worth having a book of it’s own, so instead of trying to poorly reinvent what other people have already done, we will use a well known algorithm perfectly suited for this task - Perlin noise.

If you have ever used Photoshop’s Cloud filter, you already know how Perlin noise looks like:

Now, we could implement the algorithm ourselves, but there is perlin_noise gem already available, it looks pretty solid, so we will use it.

The following program generates 100x100 map with 30% chance of water, 15% chance of sand and 55% chance of grass:

02-warmup/perlin_noise_map.rb 1 require 'gosu' 2 require 'gosu_texture_packer' 3 require 'perlin_noise' 4 5 def media_path ( file ) 6 File . join ( File . dirname ( File . dirname ( 7 __FILE__ )), 'media' , file ) 8 end 9 10 class GameWindow < Gosu :: Window 11 MAP_WIDTH = 100 12 MAP_HEIGHT = 100 13 WIDTH = 800 14 HEIGHT = 600 15 TILE_SIZE = 128 16 17 def initialize 18 super ( WIDTH , HEIGHT , false ) 19 load_tiles 20 @map = generate_map 21 @zoom = 0 . 2 22 end 23 24 def button_down ( id ) 25 close if id == Gosu :: KbEscape 26 @map = generate_map if id == Gosu :: KbSpace 27 end 28 29 def update 30 adjust_zoom ( 0 . 005 ) if button_down? ( Gosu :: KbDown ) 31 adjust_zoom ( - 0 . 005 ) if button_down? ( Gosu :: KbUp ) 32 set_caption 33 end 34 35 def draw 36 tiles_x . times do | x | 37 tiles_y . times do | y | 38 @map [ x ][ y ]. draw ( 39 x * TILE_SIZE * @zoom , 40 y * TILE_SIZE * @zoom , 41 0 , 42 @zoom , 43 @zoom ) 44 end 45 end 46 end 47 48 private 49 50 def set_caption 51 self . caption = 'Perlin Noise. ' << 52 "Zoom: #{ '%.2f' % @zoom } . " << 53 'Use Up/Down to zoom. Space to regenerate.' 54 end 55 56 def adjust_zoom ( delta ) 57 new_zoom = @zoom + delta 58 if new_zoom > 0 . 07 && new_zoom < 2 59 @zoom = new_zoom 60 end 61 end 62 63 def load_tiles 64 tiles = Gosu :: Image . load_tiles ( 65 self , media_path ( 'ground.png' ), 128 , 128 , true ) 66 @sand = tiles [ 0 ] 67 @grass = tiles [ 8 ] 68 @water = Gosu :: Image . new ( 69 self , media_path ( 'water.png' ), true ) 70 end 71 72 def tiles_x 73 count = ( WIDTH / ( TILE_SIZE * @zoom )) . ceil + 1 74 [ count , MAP_WIDTH ]. min 75 end 76 77 def tiles_y 78 count = ( HEIGHT / ( TILE_SIZE * @zoom )) . ceil + 1 79 [ count , MAP_HEIGHT ]. min 80 end 81 82 def generate_map 83 noises = Perlin :: Noise . new ( 2 ) 84 contrast = Perlin :: Curve . contrast ( 85 Perlin :: Curve :: CUBIC , 2 ) 86 map = {} 87 MAP_WIDTH . times do | x | 88 map [ x ] = {} 89 MAP_HEIGHT . times do | y | 90 n = noises [ x * 0 . 1 , y * 0 . 1 ] 91 n = contrast . call ( n ) 92 map [ x ][ y ] = choose_tile ( n ) 93 end 94 end 95 map 96 end 97 98 def choose_tile ( val ) 99 case val 100 when 0 . 0 . . 0 . 3 # 30% chance 101 @water 102 when 0 . 3 . . 0 . 45 # 15% chance, water edges 103 @sand 104 else # 55% chance 105 @grass 106 end 107 end 108 109 end 110 111 window = GameWindow . new 112 window . show

Run the program, zoom with up / down arrows and regenerate everything with spacebar.

$ ruby 02-warmup/perlin_noise_map.rb

This is a little longer than our previous examples, so we will analyze some parts to make it clear.

81 def generate_map 82 noises = Perlin :: Noise . new ( 2 ) 83 contrast = Perlin :: Curve . contrast ( 84 Perlin :: Curve :: CUBIC , 2 ) 85 map = {} 86 MAP_WIDTH . times do | x | 87 map [ x ] = {} 88 MAP_HEIGHT . times do | y | 89 n = noises [ x * 0 . 1 , y * 0 . 1 ] 90 n = contrast . call ( n ) 91 map [ x ][ y ] = choose_tile ( n ) 92 end 93 end 94 map 95 end

generate_map is the heart of this program. It creates two dimensional Perlin::Noise generator, then chooses a random tile for each location of the map, according to noise value. To make the map a little sharper, cubic contrast is applied to noise value before choosing the tile. Try commenting out contrast application - it will look like a boring golf course, since noise values will keep buzzing around the middle.

97 def choose_tile ( val ) 98 case val 99 when 0 . 0 . . 0 . 3 # 30% chance 100 @water 101 when 0 . 3 . . 0 . 45 # 15% chance, water edges 102 @sand 103 else # 55% chance 104 @grass 105 end 106 end

Here we could go crazy if we had more different tiles to use. We could add deep waters at 0.0..0.1 , mountains at 0.9..0.95 and snow caps at 0.95..1.0 . And all this would have beautiful transitions.

Player Movement With Keyboard And Mouse

We have learned to draw maps, but we need a protagonist to explore them. It will be a tank that you can move around the island with WASD keys and use your mouse to target it’s gun at things. The tank will be drawn on top of our island map, and it will be above ground, but below tree layer, so it can sneak behind palm trees. That’s as close to real deal as it gets!

02-warmup/player_movement.rb 1 require 'gosu' 2 require 'gosu_tiled' 3 require 'gosu_texture_packer' 4 5 class Tank 6 attr_accessor :x , :y , :body_angle , :gun_angle 7 8 def initialize ( window , body , shadow , gun ) 9 @x = window . width / 2 10 @y = window . height / 2 11 @window = window 12 @body = body 13 @shadow = shadow 14 @gun = gun 15 @body_angle = 0 . 0 16 @gun_angle = 0 . 0 17 end 18 19 def update 20 atan = Math . atan2 ( 320 - @window . mouse_x , 21 240 - @window . mouse_y ) 22 @gun_angle = - atan * 180 / Math :: PI 23 @body_angle = change_angle ( @body_angle , 24 Gosu :: KbW , Gosu :: KbS , Gosu :: KbA , Gosu :: KbD ) 25 end 26 27 def draw 28 @shadow . draw_rot ( @x - 1 , @y - 1 , 0 , @body_angle ) 29 @body . draw_rot ( @x , @y , 1 , @body_angle ) 30 @gun . draw_rot ( @x , @y , 2 , @gun_angle ) 31 end 32 33 private 34 35 def change_angle ( previous_angle , up , down , right , left ) 36 if @window . button_down? ( up ) 37 angle = 0 . 0 38 angle += 45 . 0 if @window . button_down? ( left ) 39 angle -= 45 . 0 if @window . button_down? ( right ) 40 elsif @window . button_down? ( down ) 41 angle = 180 . 0 42 angle -= 45 . 0 if @window . button_down? ( left ) 43 angle += 45 . 0 if @window . button_down? ( right ) 44 elsif @window . button_down? ( left ) 45 angle = 90 . 0 46 angle += 45 . 0 if @window . button_down? ( up ) 47 angle -= 45 . 0 if @window . button_down? ( down ) 48 elsif @window . button_down? ( right ) 49 angle = 270 . 0 50 angle -= 45 . 0 if @window . button_down? ( up ) 51 angle += 45 . 0 if @window . button_down? ( down ) 52 end 53 angle || previous_angle 54 end 55 end 56 57 class GameWindow < Gosu :: Window 58 MAP_FILE = File . join ( File . dirname ( 59 __FILE__ ), 'island.json' ) 60 UNIT_FILE = File . join ( File . dirname ( File . dirname ( 61 __FILE__ )), 'media' , 'ground_units.json' ) 62 SPEED = 5 63 64 def initialize 65 super ( 640 , 480 , false ) 66 @map = Gosu :: Tiled . load_json ( self , MAP_FILE ) 67 @units = Gosu :: TexturePacker . load_json ( 68 self , UNIT_FILE , :precise ) 69 @tank = Tank . new ( self , 70 @units . frame ( 'tank1_body.png' ), 71 @units . frame ( 'tank1_body_shadow.png' ), 72 @units . frame ( 'tank1_dualgun.png' )) 73 @x = @y = 0 74 @first_render = true 75 @buttons_down = 0 76 end 77 78 def needs_cursor? 79 true 80 end 81 82 def button_down ( id ) 83 close if id == Gosu :: KbEscape 84 @buttons_down += 1 85 end 86 87 def button_up ( id ) 88 @buttons_down -= 1 89 end 90 91 def update 92 @x -= SPEED if button_down? ( Gosu :: KbA ) 93 @x += SPEED if button_down? ( Gosu :: KbD ) 94 @y -= SPEED if button_down? ( Gosu :: KbW ) 95 @y += SPEED if button_down? ( Gosu :: KbS ) 96 @tank . update 97 self . caption = " #{ Gosu . fps } FPS. " << 98 'Use WASD and mouse to control tank' 99 end 100 101 def draw 102 @first_render = false 103 @map . draw ( @x , @y ) 104 @tank . draw () 105 end 106 end 107 108 GameWindow . new . show

Tank sprite is rendered in the middle of screen. It consists of three layers, body shadow, body and gun. Body and it’s shadow are always rendered in same angle, one on top of another. The angle is determined by keys that are pressed. It supports 8 directions.

Gun is a little bit different. It follows mouse cursor. To determine the angle we had to use some math. The formula to get angle in degrees is arctan(delta_x / delta_y) * 180 / PI . You can see it explained in more detail on stackoverflow.

Run it and stroll around the island. You can still move on water and into the darkness, away from the map itself, but we will handle it later.

$ ruby 02-warmup/player_movement.rb

See that tank hiding between the bushes, ready to go in 8 directions and blow things up with that precisely aimed double cannon?

Game Coordinate System

By now we may start realizing, that there is one key component missing in our designs. We have a virtual map, which is bigger than our screen space, and we should perform all calculations using that map, and only then cut out the required piece and render it in our game window.

There are three different coordinate systems that have to map with each other:

Game coordinates Viewport coordinates Screen coordinates

Game Coordinates

This is where all logic will happen. Player location, enemy locations, powerup locations - all this will have game coordinates, and it should have nothing to do with your screen position.

Viewport Coordinates

Viewport is the position of virtual camera, that is “filming” world in action. Don’t confuse it with screen coordinates, because viewport will not necessarily be mapped pixel to pixel to your game window. Imagine this: you have a huge world map, your player is standing in the middle, and game window displays the player while slowly zooming in. In this scenario, viewport is constantly shrinking, while game map stays the same, and game window also stays the same.

Screen Coordinates

This is your game display, pixel by pixel. You will draw static information, like your HUD directly on it.

How To Put It All Together

In our games we will want to separate game coordinates from viewport and screen as much as possible. Basically, we will program ourselves a “camera man” who will be busy following the action, zooming in and out, perhaps changing the view angle now and then.

Let’s implement a prototype that will allow us to navigate and zoom around a big map. We will only draw objects that are visible in viewport. Some math will be unavoidable, but in most cases it’s pretty basic - that’s the beauty of 2D games:

02-warmup/coordinate_system.rb 1 require 'gosu' 2 3 class WorldMap 4 attr_accessor :on_screen , :off_screen 5 6 def initialize ( width , height ) 7 @images = {} 8 ( 0 . .width ) . step ( 50 ) do | x | 9 @images [ x ] = {} 10 ( 0 . .height ) . step ( 50 ) do | y | 11 img = Gosu :: Image . from_text ( 12 $window , " #{ x } : #{ y } " , 13 Gosu . default_font_name , 15 ) 14 @images [ x ][ y ] = img 15 end 16 end 17 end 18 19 def draw ( camera ) 20 @on_screen = @off_screen = 0 21 @images . each do | x , row | 22 row . each do | y , val | 23 if camera . can_view? ( x , y , val ) 24 val . draw ( x , y , 0 ) 25 @on_screen += 1 26 else 27 @off_screen += 1 28 end 29 end 30 end 31 end 32 end 33 34 class Camera 35 attr_accessor :x , :y , :zoom 36 37 def initialize 38 @x = @y = 0 39 @zoom = 1 40 end 41 42 def can_view? ( x , y , obj ) 43 x0 , x1 , y0 , y1 = viewport 44 ( x0 - obj . width . .x1 ) . include? ( x ) && 45 ( y0 - obj . height . .y1 ) . include? ( y ) 46 end 47 48 def viewport 49 x0 = @x - ( $window . width / 2 ) / @zoom 50 x1 = @x + ( $window . width / 2 ) / @zoom 51 y0 = @y - ( $window . height / 2 ) / @zoom 52 y1 = @y + ( $window . height / 2 ) / @zoom 53 [ x0 , x1 , y0 , y1 ] 54 end 55 56 def to_s 57 "FPS: #{ Gosu . fps } . " << 58 " #{ @x } : #{ @y } @ #{ '%.2f' % @zoom } . " << 59 'WASD to move, arrows to zoom.' 60 end 61 62 def draw_crosshair 63 $window . draw_line ( 64 @x - 10 , @y , Gosu :: Color :: YELLOW , 65 @x + 10 , @y , Gosu :: Color :: YELLOW , 100 ) 66 $window . draw_line ( 67 @x , @y - 10 , Gosu :: Color :: YELLOW , 68 @x , @y + 10 , Gosu :: Color :: YELLOW , 100 ) 69 end 70 end 71 72 73 class GameWindow < Gosu :: Window 74 SPEED = 10 75 76 def initialize 77 super ( 800 , 600 , false ) 78 $window = self 79 @map = WorldMap . new ( 2048 , 1024 ) 80 @camera = Camera . new 81 end 82 83 def button_down ( id ) 84 close if id == Gosu :: KbEscape 85 if id == Gosu :: KbSpace 86 @camera . zoom = 1 . 0 87 @camera . x = 0 88 @camera . y = 0 89 end 90 end 91 92 def update 93 @camera . x -= SPEED if button_down? ( Gosu :: KbA ) 94 @camera . x += SPEED if button_down? ( Gosu :: KbD ) 95 @camera . y -= SPEED if button_down? ( Gosu :: KbW ) 96 @camera . y += SPEED if button_down? ( Gosu :: KbS ) 97 98 zoom_delta = @camera . zoom > 0 ? 0 . 01 : 1 . 0 99 100 if button_down? ( Gosu :: KbUp ) 101 @camera . zoom -= zoom_delta 102 end 103 if button_down? ( Gosu :: KbDown ) 104 @camera . zoom += zoom_delta 105 end 106 self . caption = @camera . to_s 107 end 108 109 def draw 110 off_x = - @camera . x + width / 2 111 off_y = - @camera . y + height / 2 112 cam_x = @camera . x 113 cam_y = @camera . y 114 translate ( off_x , off_y ) do 115 @camera . draw_crosshair 116 zoom = @camera . zoom 117 scale ( zoom , zoom , cam_x , cam_y ) do 118 @map . draw ( @camera ) 119 end 120 end 121 info = 'Objects on/off screen: ' << 122 " #{ @map . on_screen } / #{ @map . off_screen } " 123 info_img = Gosu :: Image . from_text ( 124 self , info , Gosu . default_font_name , 30 ) 125 info_img . draw ( 10 , 10 , 1 ) 126 end 127 end 128 129 GameWindow . new . show

Run it, use WASD to navigate, up / down arrows to zoom and spacebar to reset the camera.

$ ruby 02-warmup/coordinate_system.rb

It doesn’t look impressive, but understanding the concept of different coordinate systems and being able to stitch them together is paramount to the success of our final product.

Luckily for us, Gosu helps us by providing Gosu::Window#translate that handles camera offset, Gosu::Window#scale that aids zooming, and Gosu::Window#rotate that was not used yet, but will be great for shaking the view to emphasize explosions.

Prototyping The Game

Warming up was really important, but let’s combine everything we learned, add some new challenges, and build a small prototype with following features:

Camera loosely follows tank. Camera zooms automatically depending on tank speed. You can temporarily override automatic camera zoom using keyboard. Music and sound effects. Randomly generated map. Two modes: menu and gameplay. Tank movement with WADS keys. Tank aiming and shooting with mouse. Collision detection (tanks don’t swim). Explosions, visible bullet trajectories. Bullet range limiting.

Sounds fun? Hell yes! However, before we start, we should plan ahead a little and think how our game architecture will look like. We will also structure our code a little, so it will not be smashed into one ruby class, as we did in earlier examples. Books should show good manners!

Switching Between Game States

First, let’s think how to hook into Gosu::Window . Since we will have two game states, State pattern naturally comes to mind.

So, our GameWindow class could look like this:

03-prototype/game_window.rb 1 class GameWindow < Gosu :: Window 2 3 attr_accessor :state 4 5 def initialize 6 super ( 800 , 600 , false ) 7 end 8 9 def update 10 @state . update 11 end 12 13 def draw 14 @state . draw 15 end 16 17 def needs_redraw? 18 @state . needs_redraw? 19 end 20 21 def button_down ( id ) 22 @state . button_down ( id ) 23 end 24 25 end

It has current @state , and all usual main loop actions are executed on that state instance. We will add base class that all game states will extend. Let’s name it GameState :

03-prototype/states/game_state.rb 1 class GameState 2 3 def self . switch ( new_state ) 4 $window . state && $window . state . leave 5 $window . state = new_state 6 new_state . enter 7 end 8 9 def enter 10 end 11 12 def leave 13 end 14 15 def draw 16 end 17 18 def update 19 end 20 21 def needs_redraw? 22 true 23 end 24 25 def button_down ( id ) 26 end 27 end

This class provides GameState.switch , that will change the state for our Gosu::Window , and all enter and leave methods when appropriate. These methods will be useful for things like switching music.

Notice that Gosu::Window is accessed using global $window variable, which will be considered an anti-pattern by most good programmers, but there is some logic behind this:

There will be only one Gosu::Window instance. It lives as long as the game runs. It is used in some way by nearly all other classes, so we would have to pass it around all the time. Accessing it using Singleton or static utility class would not give any clear benefits, just add more complexity.

Chingu, another game framework built on top of Gosu, also uses global $window , so it’s probably not the worst idea ever.

We will also need an entry point that would fire up the game and enter the first game state - the menu.

03-prototype/main.rb 1 require 'gosu' 2 require_relative 'states/game_state' 3 require_relative 'states/menu_state' 4 require_relative 'states/play_state' 5 require_relative 'game_window' 6 7 module Game 8 def self . media_path ( file ) 9 File . join ( File . dirname ( File . dirname ( 10 __FILE__ )), 'media' , file ) 11 end 12 end 13 14 $window = GameWindow . new 15 GameState . switch ( MenuState . instance ) 16 $window . show

In our entry point we also have a small helper which will help loading images and sounds using Game.media_path .

The rest is obvious: we create GameWindow instance and store it in $window variable, as discussed before. Then we use GameState.switch) to load MenuState , and show the game window.

Implementing Menu State

This is how simple MenuState implementation looks like:

03-prototype/states/menu_state.rb 1 require 'singleton' 2 class MenuState < GameState 3 include Singleton 4 attr_accessor :play_state 5 6 def initialize 7 @message = Gosu :: Image . from_text ( 8 $window , "Tanks Prototype" , 9 Gosu . default_font_name , 100 ) 10 end 11 12 def enter 13 music . play ( true ) 14 music . volume = 1 15 end 16 17 def leave 18 music . volume = 0 19 music . stop 20 end 21 22 def music 23 @@music ||= Gosu :: Song . new ( 24 $window , Game . media_path ( 'menu_music.mp3' )) 25 end 26 27 def update 28 continue_text = @play_state ? "C = Continue, " : "" 29 @info = Gosu :: Image . from_text ( 30 $window , "Q = Quit, #{ continue_text } N = New Game" , 31 Gosu . default_font_name , 30 ) 32 end 33 34 def draw 35 @message . draw ( 36 $window . width / 2 - @message . width / 2 , 37 $window . height / 2 - @message . height / 2 , 38 10 ) 39 @info . draw ( 40 $window . width / 2 - @info . width / 2 , 41 $window . height / 2 - @info . height / 2 + 200 , 42 10 ) 43 end 44 45 def button_down ( id ) 46 $window . close if id == Gosu :: KbQ 47 if id == Gosu :: KbC && @play_state 48 GameState . switch ( @play_state ) 49 end 50 if id == Gosu :: KbN 51 @play_state = PlayState . new 52 GameState . switch ( @play_state ) 53 end 54 end 55 end

It’s a Singleton, so we can always get it with MenuState.instance .

It starts playing menu_music.mp3 when you enter the menu, and stop the music when you leave it. Instance of Gosu::Song is cached in @@music class variable to save resources.

We have to know if play is already in progress, so we can add a possibility to go back to the game. That’s why MenuState has @play_state variable, and either allows creating new PlayState when N key is pressed, or switches to existing @play_state if C key is pressed.

Here comes the interesting part, implementing the play state.

Implementing Play State

Before we start implementing actual gameplay, we need to think what game entities we will be building. We will need a Map that will hold our tiles and provide world coordinate system. We will also need a Camera that will know how to float around and zoom. There will be Bullet s flying around, and each bullet will eventually cause an Explosion .

Having all that taken care of, PlayState should look pretty simple:

03-prototype/states/play_state.rb 1 require_relative '../entities/map' 2 require_relative '../entities/tank' 3 require_relative '../entities/camera' 4 require_relative '../entities/bullet' 5 require_relative '../entities/explosion' 6 class PlayState < GameState 7 8 def initialize 9 @map = Map . new 10 @tank = Tank . new ( @map ) 11 @camera = Camera . new ( @tank ) 12 @bullets = [] 13 @explosions = [] 14 end 15 16 def update 17 bullet = @tank . update ( @camera ) 18 @bullets << bullet if bullet 19 @bullets . map ( & :update ) 20 @bullets . reject! ( & :done? ) 21 @camera . update 22 $window . caption = 'Tanks Prototype. ' << 23 "[FPS: #{ Gosu . fps } . Tank @ #{ @tank . x . round } : #{ @tank . y . round } ]" 24 end 25 26 def draw 27 cam_x = @camera . x 28 cam_y = @camera . y 29 off_x = $window . width / 2 - cam_x 30 off_y = $window . height / 2 - cam_y 31 $window . translate ( off_x , off_y ) do 32 zoom = @camera . zoom 33 $window . scale ( zoom , zoom , cam_x , cam_y ) do 34 @map . draw ( @camera ) 35 @tank . draw 36 @bullets . map ( & :draw ) 37 end 38 end 39 @camera . draw_crosshair 40 end 41 42 def button_down ( id ) 43 if id == Gosu :: MsLeft 44 bullet = @tank . shoot ( * @camera . mouse_coords ) 45 @bullets << bullet if bullet 46 end 47 $window . close if id == Gosu :: KbQ 48 if id == Gosu :: KbEscape 49 GameState . switch ( MenuState . instance ) 50 end 51 end 52 53 end

Update and draw calls are passed to the underlying game entities, so they can handle them the way they want it to. Such encapsulation reduces complexity of the code and allows doing every piece of logic where it belongs, while keeping it short and simple.

There are a few interesting parts in this code. Both @tank.update and @tank.shoot may produce a new bullet, if your tank’s fire rate is not exceeded, and if left mouse button is kept down, hence the update . If bullet is produced, it is added to @bullets array, and they live their own little lifecycle, until they explode and are no longer used. @bullets.reject!(&:done?) cleans up the garbage.

PlayState#draw deserves extra explanation. @camera.x and @camera.y points to game coordinates where Camera is currently looking at. Gosu::Window#translate creates a block within which all Gosu::Image draw operations are translated by given offset. Gosu::Window#scale does the same with Camera zoom.

Crosshair is drawn without translating and scaling it, because it’s relative to screen, not to world map.

Basically, this draw method is the place that takes care drawing only what @camera can see.

If it’s hard to understand how this works, get back to “Game Coordinate System” chapter and let it sink in.

Implementing World Map

We will start analyzing game entities with Map .

03-prototype/entities/map.rb 1 require 'perlin_noise' 2 require 'gosu_texture_packer' 3 4 class Map 5 MAP_WIDTH = 100 6 MAP_HEIGHT = 100 7 TILE_SIZE = 128 8 9 def initialize 10 load_tiles 11 @map = generate_map 12 end 13 14 def find_spawn_point 15 while true 16 x = rand ( 0 . . MAP_WIDTH * TILE_SIZE ) 17 y = rand ( 0 . . MAP_HEIGHT * TILE_SIZE ) 18 if can_move_to? ( x , y ) 19 return [ x , y ] 20 else 21 puts "Invalid spawn point: #{ [ x , y ] } " 22 end 23 end 24 end 25 26 def can_move_to? ( x , y ) 27 tile = tile_at ( x , y ) 28 tile && tile != @water 29 end 30 31 def draw ( camera ) 32 @map . each do | x , row | 33 row . each do | y , val | 34 tile = @map [ x ][ y ] 35 map_x = x * TILE_SIZE 36 map_y = y * TILE_SIZE 37 if camera . can_view? ( map_x , map_y , tile ) 38 tile . draw ( map_x , map_y , 0 ) 39 end 40 end 41 end 42 end 43 44 private 45 46 def tile_at ( x , y ) 47 t_x = (( x / TILE_SIZE ) % TILE_SIZE ) . floor 48 t_y = (( y / TILE_SIZE ) % TILE_SIZE ) . floor 49 row = @map [ t_x ] 50 row [ t_y ] if row 51 end 52 53 def load_tiles 54 tiles = Gosu :: Image . load_tiles ( 55 $window , Game . media_path ( 'ground.png' ), 56 128 , 128 , true ) 57 @sand = tiles [ 0 ] 58 @grass = tiles [ 8 ] 59 @water = Gosu :: Image . new ( 60 $window , Game . media_path ( 'water.png' ), true ) 61 end 62 63 def generate_map 64 noises = Perlin :: Noise . new ( 2 ) 65 contrast = Perlin :: Curve . contrast ( 66 Perlin :: Curve :: CUBIC , 2 ) 67 map = {} 68 MAP_WIDTH . times do | x | 69 map [ x ] = {} 70 MAP_HEIGHT . times do | y | 71 n = noises [ x * 0 . 1 , y * 0 . 1 ] 72 n = contrast . call ( n ) 73 map [ x ][ y ] = choose_tile ( n ) 74 end 75 end 76 map 77 end 78 79 def choose_tile ( val ) 80 case val 81 when 0 . 0 . . 0 . 3 # 30% chance 82 @water 83 when 0 . 3 . . 0 . 45 # 15% chance, water edges 84 @sand 85 else # 55% chance 86 @grass 87 end 88 end 89 end

This implementation is very similar to the Map we had built in “Generating Random Map With Perlin Noise”, with some extra additions. can_move_to? verifies if tile under given coordinates is not water. Pretty simple, but it’s enough for our prototype.

Also, when we draw the map we have to make sure if tiles we are drawing are currently visible by our camera, otherwise we will end up drawing off screen. camera.can_view? handles it. Current implementation will probably be causing a bottleneck, since it brute forces through all the map rather than cherry-picking the visible region. We will probably have to get back and change it later.

find_spawn_point is one more addition. It keeps picking a random point on map and verifies if it’s not water using can_move_to? . When solid tile is found, it returns the coordinates, so our Tank will be able to spawn there.

Implementing Floating Camera

If you played the original Grand Theft Auto or GTA 2, you should remember how fascinating the camera was. It backed away when you were driving at high speeds, closed in when you were walking on foot, and floated around as if a smart drone was following your protagonist from above.

The following Camera implementation is far inferior to the one GTA had nearly two decades ago, but it’s a start:

03-prototype/entities/camera.rb 1 class Camera 2 attr_accessor :x , :y , :zoom 3 4 def initialize ( target ) 5 @target = target 6 @x , @y = target . x , target . y 7 @zoom = 1 8 end 9 10 def can_view? ( x , y , obj ) 11 x0 , x1 , y0 , y1 = viewport 12 ( x0 - obj . width . .x1 ) . include? ( x ) && 13 ( y0 - obj . height . .y1 ) . include? ( y ) 14 end 15 16 def mouse_coords 17 x , y = target_delta_on_screen 18 mouse_x_on_map = @target . x + 19 ( x + $window . mouse_x - ( $window . width / 2 )) / @zoom 20 mouse_y_on_map = @target . y + 21 ( y + $window . mouse_y - ( $window . height / 2 )) / @zoom 22 [ mouse_x_on_map , mouse_y_on_map ]. map ( & :round ) 23 end 24 25 def update 26 @x += @target . speed if @x < @target . x - $window . width / 4 27 @x -= @target . speed if @x > @target . x + $window . width / 4 28 @y += @target . speed if @y < @target . y - $window . height / 4 29 @y -= @target . speed if @y > @target . y + $window . height / 4 30 31 zoom_delta = @zoom > 0 ? 0 . 01 : 1 . 0 32 if $window . button_down? ( Gosu :: KbUp ) 33 @zoom -= zoom_delta unless @zoom < 0 . 7 34 elsif $window . button_down? ( Gosu :: KbDown ) 35 @zoom += zoom_delta unless @zoom > 10 36 else 37 target_zoom = @target . speed > 1 . 1 ? 0 . 85 : 1 . 0 38 if @zoom <= ( target_zoom - 0 . 01 ) 39 @zoom += zoom_delta / 3 40 elsif @zoom > ( target_zoom + 0 . 01 ) 41 @zoom -= zoom_delta / 3 42 end 43 end 44 end 45 46 def to_s 47 "FPS: #{ Gosu . fps } . " << 48 " #{ @x } : #{ @y } @ #{ '%.2f' % @zoom } . " << 49 'WASD to move, arrows to zoom.' 50 end 51 52 def target_delta_on_screen 53 [ ( @x - @target . x ) * @zoom , ( @y - @target . y ) * @zoom ] 54 end 55 56 def draw_crosshair 57 x = $window . mouse_x 58 y = $window . mouse_y 59 $window . draw_line ( 60 x - 10 , y , Gosu :: Color :: RED , 61 x + 10 , y , Gosu :: Color :: RED , 100 ) 62 $window . draw_line ( 63 x , y - 10 , Gosu :: Color :: RED , 64 x , y + 10 , Gosu :: Color :: RED , 100 ) 65 end 66 67 private 68 69 def viewport 70 x0 = @x - ( $window . width / 2 ) / @zoom 71 x1 = @x + ( $window . width / 2 ) / @zoom 72 y0 = @y - ( $window . height / 2 ) / @zoom 73 y1 = @y + ( $window . height / 2 ) / @zoom 74 [ x0 , x1 , y0 , y1 ] 75 end 76 end

Our Camera has @target that it tries to follow, @x and @y that it currently is looking at, and @zoom level.

All the magic happens in update method. It keeps track of the distance between @target and adjust itself to stay nearby. And when @target.speed shows some movement momentum, camera slowly backs away.

Camera also tels if you can_view? an object at some coordinates, so when other entities draw themselves, they can check if there is a need for that.

Another noteworthy method is mouse_coords . It translates mouse position on screen to mouse position on map, so the game will know where you are targeting your guns.

Implementing The Tank

Most of our tank code will be taken from “Player Movement With Keyboard And Mouse”:

03-prototype/entities/tank.rb 1 class Tank 2 attr_accessor :x , :y , :body_angle , :gun_angle 3 SHOOT_DELAY = 500 4 5 def initialize ( map ) 6 @map = map 7 @units = Gosu :: TexturePacker . load_json ( 8 $window , Game . media_path ( 'ground_units.json' ), :precise ) 9 @body = @units . frame ( 'tank1_body.png' ) 10 @shadow = @units . frame ( 'tank1_body_shadow.png' ) 11 @gun = @units . frame ( 'tank1_dualgun.png' ) 12 @x , @y = @map . find_spawn_point 13 @body_angle = 0 . 0 14 @gun_angle = 0 . 0 15 @last_shot = 0 16 sound . volume = 0 . 3 17 end 18 19 def sound 20 @@sound ||= Gosu :: Song . new ( 21 $window , Game . media_path ( 'tank_driving.mp3' )) 22 end 23 24 def shoot ( target_x , target_y ) 25 if Gosu . milliseconds - @last_shot > SHOOT_DELAY 26 @last_shot = Gosu . milliseconds 27 Bullet . new ( @x , @y , target_x , target_y ) . fire ( 100 ) 28 end 29 end 30 31 def update ( camera ) 32 d_x , d_y = camera . target_delta_on_screen 33 atan = Math . atan2 (( $window . width / 2 ) - d_x - $window . mouse_x , 34 ( $window . height / 2 ) - d_y - $window . mouse_y ) 35 @gun_angle = - atan * 180 / Math :: PI 36 new_x , new_y = @x , @y 37 new_x -= speed if $window . button_down? ( Gosu :: KbA ) 38 new_x += speed if $window . button_down? ( Gosu :: KbD ) 39 new_y -= speed if $window . button_down? ( Gosu :: KbW ) 40 new_y += speed if $window . button_down? ( Gosu :: KbS ) 41 if @map . can_move_to? ( new_x , new_y ) 42 @x , @y = new_x , new_y 43 else 44 @speed = 1 . 0 45 end 46 @body_angle = change_angle ( @body_angle , 47 Gosu :: KbW , Gosu :: KbS , Gosu :: KbA , Gosu :: KbD ) 48 49 if moving? 50 sound . play ( true ) 51 else 52 sound . pause 53 end 54 55 if $window . button_down? ( Gosu :: MsLeft ) 56 shoot ( * camera . mouse_coords ) 57 end 58 end 59 60 def moving? 61 any_button_down? ( Gosu :: KbA , Gosu :: KbD , Gosu :: KbW , Gosu :: KbS ) 62 end 63 64 def draw 65 @shadow . draw_rot ( @x - 1 , @y - 1 , 0 , @body_angle ) 66 @body . draw_rot ( @x , @y , 1 , @body_angle ) 67 @gun . draw_rot ( @x , @y , 2 , @gun_angle ) 68 end 69 70 def speed 71 @speed ||= 1 . 0 72 if moving? 73 @speed += 0 . 03 if @speed < 5 74 else 75 @speed = 1 . 0 76 end 77 @speed 78 end 79 80 private 81 82 def any_button_down? ( * buttons ) 83 buttons . each do | b | 84 return true if $window . button_down? ( b ) 85 end 86 false 87 end 88 89 def change_angle ( previous_angle , up , down , right , left ) 90 if $window . button_down? ( up ) 91 angle = 0 . 0 92 angle += 45 . 0 if $window . button_down? ( left ) 93 angle -= 45 . 0 if $window . button_down? ( right ) 94 elsif $window . button_down? ( down ) 95 angle = 180 . 0 96 angle -= 45 . 0 if $window . button_down? ( left ) 97 angle += 45 . 0 if $window . button_down? ( right ) 98 elsif $window . button_down? ( left ) 99 angle = 90 . 0 100 angle += 45 . 0 if $window . button_down? ( up ) 101 angle -= 45 . 0 if $window . button_down? ( down ) 102 elsif $window . button_down? ( right ) 103 angle = 270 . 0 104 angle -= 45 . 0 if $window . button_down? ( up ) 105 angle += 45 . 0 if $window . button_down? ( down ) 106 end 107 angle || previous_angle 108 end 109 end

Tank has to be aware of the Map to check where it’s moving, and it uses Camera to find out where to aim the guns. When it shoot s, it produces instances of Bullet , that are simply returned to the caller. Tank won’t keep track of them, it’s “fire and forget”.

Implementing Bullets And Explosions

Bullets will require some simple vector math. You have a point that moves along the vector with some speed. It also needs to limit the maximum vector length, so if you try to aim too far, the bullet will only go as far as it can reach.

03-prototype/entities/bullet.rb 1 class Bullet 2 COLOR = Gosu :: Color :: BLACK 3 MAX_DIST = 300 4 START_DIST = 20 5 6 def initialize ( source_x , source_y , target_x , target_y ) 7 @x , @y = source_x , source_y 8 @target_x , @target_y = target_x , target_y 9 @x , @y = point_at_distance ( START_DIST ) 10 if trajectory_length > MAX_DIST 11 @target_x , @target_y = point_at_distance ( MAX_DIST ) 12 end 13 sound . play 14 end 15 16 def draw 17 unless arrived? 18 $window . draw_quad ( @x - 2 , @y - 2 , COLOR , 19 @x + 2 , @y - 2 , COLOR , 20 @x - 2 , @y + 2 , COLOR , 21 @x + 2 , @y + 2 , COLOR , 22 1 ) 23 else 24 @explosion ||= Explosion . new ( @x , @y ) 25 @explosion . draw 26 end 27 end 28 29 def update 30 fly_distance = ( Gosu . milliseconds - @fired_at ) * 0 . 001 * @speed 31 @x , @y = point_at_distance ( fly_distance ) 32 @explosion && @explosion . update 33 end 34 35 def arrived? 36 @x == @target_x && @y == @target_y 37 end 38 39 def done? 40 exploaded? 41 end 42 43 def exploaded? 44 @explosion && @explosion . done? 45 end 46 47 def fire ( speed ) 48 @speed = speed 49 @fired_at = Gosu . milliseconds 50 self 51 end 52 53 private 54 55 def sound 56 @@sound ||= Gosu :: Sample . new ( 57 $window , Game . media_path ( 'fire.mp3' )) 58 end 59 60 def trajectory_length 61 d_x = @target_x - @x 62 d_y = @target_y - @y 63 Math . sqrt ( d_x * d_x + d_y * d_y ) 64 end 65 66 def point_at_distance ( distance ) 67 return [ @target_x , @target_y ] if distance > trajectory_length 68 distance_factor = distance . to_f / trajectory_length 69 p_x = @x + ( @target_x - @x ) * distance_factor 70 p_y = @y + ( @target_y - @y ) * distance_factor 71 [ p_x , p_y ] 72 end 73 end

Possibly the most interesting part of Bullet implementation is point_at_distance method. It returns coordinates of point that is between bullet source, which is point that bullet was fired from, and it’s target, which is the destination point. The returned point is as far away from source point as distance tells it to.

After bullet has done flying, it explodes with fanfare. In our prototype Explosion is a part of Bullet , because it’s the only thing that triggers it. Therefore Bullet has two stages of it’s lifecycle. First it flies towards the target, then it’s exploding. That brings us to Explosion :

03-prototype/entities/explosion.rb 1 class Explosion 2 FRAME_DELAY = 10 # ms 3 4 def animation 5 @@animation ||= 6 Gosu :: Image . load_tiles ( 7 $window , Game . media_path ( 'explosion.png' ), 128 , 128 , false ) 8 end 9 10 def sound 11 @@sound ||= Gosu :: Sample . new ( 12 $window , Game . media_path ( 'explosion.mp3' )) 13 end 14 15 def initialize ( x , y ) 16 sound . play 17 @x , @y = x , y 18 @current_frame = 0 19 end 20 21 def update 22 @current_frame += 1 if frame_expired? 23 end 24 25 def draw 26 return if done? 27 image = current_frame 28 image . draw ( 29 @x - image . width / 2 + 3 , 30 @y - image . height / 2 - 35 , 31 20 ) 32 end 33 34 def done? 35 @done ||= @current_frame == animation . size 36 end 37 38 private 39 40 def current_frame 41 animation [ @current_frame % animation . size ] 42 end 43 44 def frame_expired? 45 now = Gosu . milliseconds 46 @last_frame ||= now 47 if ( now - @last_frame ) > FRAME_DELAY 48 @last_frame = now 49 end 50 end 51 end

There is nothing fancy about this implementation. Most of it is taken from “Images And Animation” chapter.

Running The Prototype

We have walked through all the code. You can get it at GitHub.

Now it’s time to give it a spin. There is a video of me playing it available on YouTube, but it’s always best to experience it firsthand. Run main.rb to start the game:

$ ruby 03-prototype/main.rb

Hit N to start new game.

Time to go crazy!

One thing should be bugging you at this point. FPS shows only 30, rather than 60. That means our prototype is slow. We will put it back to 60 FPS in next chapter.

Optimizing Game Performance

To make games that are fast and don’t require a powerhouse to run, we must learn how to find and fix bottlenecks. Good news is that if you wasn’t thinking about performance to begin with, your program can usually be optimized to run twice as fast just by eliminating one or two biggest bottlenecks.

We will be using a copy of the prototype code to keep both optimized and original version, therefore if you are exploring sample code, look at 04-prototype-optimized .

Profiling Ruby Code To Find Bottlenecks

We will try to find bottlenecks in our Tanks prototype game by profiling it with ruby-prof .

It’s a ruby gem, just install it like this:

$ gem install ruby-prof

There are several ways you can use ruby-prof , so we will begin with the easiest one. Instead of running the game with ruby , we will run it with ruby-prof :

$ ruby-prof 03-prototype/main.rb

The game will run, but everything will be ten times slower as usual, because every call to every function is being recorded, and after you exit the program, profiling output will be dumped directly to your console.

Downside of this approach is that we are going to profile everything there is, including the super-slow map generation that uses Perlin Noise. We don’t want to optimize that, so in order to find bottlenecks in our play state rather than map generation, we have to keep playing at dreadful 2 FPS for at least 30 seconds.

This was the output of first “naive” profiling session:

It’s obvious, that Camera#viewport and Camera#can_view? are top CPU burners. This means either that our implementation is either very bad, or the assumption that checking if camera can view object is slower than drawing the object off screen.

Here are those slow methods:

class Camera # ... def can_view? ( x , y , obj ) x0 , x1 , y0 , y1 = viewport ( x0 - obj . width . .x1 ) . include? ( x ) && ( y0 - obj . height . .y1 ) . include? ( y ) end # ... def viewport x0 = @x - ( $window . width / 2 ) / @zoom x1 = @x + ( $window . width / 2 ) / @zoom y0 = @y - ( $window . height / 2 ) / @zoom y1 = @y + ( $window . height / 2 ) / @zoom [ x0 , x1 , y0 , y1 ] end # ... end

It doesn’t look fundamentally broken, so we will try our “checking is slower than rendering” hypothesis by short-circuiting can_view? to return true every time:

class Camera # ... def can_view? ( x , y , obj ) return true # short circuiting x0 , x1 , y0 , y1 = viewport ( x0 - obj . width . .x1 ) . include? ( x ) && ( y0 - obj . height . .y1 ) . include? ( y ) end # ... end

After saving camera.rb and running the game without profiling, you will notice a significant speedup. Hypothesis was correct, checking visibility is more expensive than simply rendering it. That means we can throw away Camera#can_view? and calls to it.

But before doing that, let’s profile once again:

We can see Camera#can_view? is still in top 3, so we will remove if camera.can_view?(map_x, map_y, tile) from Map#draw and for now keep it like this:

class Map # ... def draw ( camera ) @map . each do | x , row | row . each do | y , val | tile = @map [ x ][ y ] map_x = x * TILE_SIZE map_y = y * TILE_SIZE tile . draw ( map_x , map_y , 0 ) end end end # ... end

After completely removing Camera#can_view? , profiling session looks like dead-end - no more low hanging fruits on top:

The game still doesn’t feel fast enough, FPS occasionally keeps dropping down to ~45, so we will have to do profile our code in smarter way.

Advanced Profiling Techniques

We would get more accuracy when profiling only what we want to optimize. In our case it is everything that happens in PlayState , except for Map generation. This time we will have to use ruby-prof API to hook into places we need.

Map generation happens in PlayState initializer, so we will leverage GameState#enter and GameState#leave to start and stop profiling, since it happens after state is initialized. Here is how we hook in:

require 'ruby-prof' class PlayState < GameState # ... def enter RubyProf . start end def leave result = RubyProf . stop printer = RubyProf :: FlatPrinter . new ( result ) printer . print ( STDOUT ) end # ... end

Then we run the game as usual:

$ ruby 04-prototype-optimized/main.rb

Now, after we press N to start new game, Map generation happens relatively fast, and then profiling kicks in, FPS drops to 15. After moving around and shooting for a while we hit Esc to return to the menu, and at that point PlayState#leave spits profiling results out to the console:

We can see that Gosu::Image#draw takes up to 20% of all execution time. Then goes Gosu::Window#caption , but we need it to measure FPS, so we will leave it alone, and finally we can see Hash#each , which is guaranteed to be the one from Map#draw , and it triggers all those Gosu::Image#draw calls.

Optimizing Inefficient Code

According to profiling results, we need to optimize this method:

class Map # ... def draw ( camera ) @map . each do | x , row | row . each do | y , val | tile = @map [ x ][ y ] map_x = x * TILE_SIZE map_y = y * TILE_SIZE tile . draw ( map_x , map_y , 0 ) end end end # ... end

But we have to optimize it in more clever way than we did before. If instead of looping through all map rows and columns and blindly rendering every tile or checking if tile is visible we could calculate the exact map cells that need to be displayed, we would reduce method complexity and get major performance boost. Let’s do that.

We will use Camera#viewport to return map boundaries that are visible by camera, then divide those boundaries by Map#TILE_SIZE to get tile numbers instead of pixels, and retrieve them from the map.

class Map # ... def draw ( camera ) viewport = camera . viewport viewport . map! { | p | p / TILE_SIZE } x0 , x1 , y0 , y1 = viewport . map ( & :to_i ) ( x0 . .x1 ) . each do | x | ( y0 . .y1 ) . each do | y | row = @map [ x ] if row tile = @map [ x ][ y ] map_x = x * TILE_SIZE map_y = y * TILE_SIZE tile . draw ( map_x , map_y , 0 ) end end end end

This optimization yielded astounding results. We are now getting nearly stable 60 FPS even when profiling the code! Compare that to 2 FPS while profiling when we started.

Now we just have to do something about that Gosu::Window#caption , because it is consuming 1/3 of our CPU cycles! Even though game is already flying so fast that we will have to reduce tank and bullet speeds to make it look more realistic, we cannot let ourselves leave this low hanging fruit remain unpicked.

We will update the caption once per second, it should remove the bottleneck:

class PlayState < GameState # ... def update # ... update_caption end # ... private def update_caption now = Gosu . milliseconds if now - ( @caption_updated_at || 0 ) > 1000 $window . caption = 'Tanks Prototype. ' << "[FPS: #{ Gosu . fps } . " << "Tank @ #{ @tank . x . round } : #{ @tank . y . round } ]" @caption_updated_at = now end end end

Now it’s getting hard to get FPS to drop below 58, and profiling results show that there are no more bottlenecks:

We can now sleep well at night.

Profiling On Demand

When you develop a game, you may want to turn on profiling now and then. To avoid commenting out or adding and removing profiling every time you want to do so, use this trick:

# ... require 'ruby-prof' if ENV [ 'ENABLE_PROFILING' ] class PlayState < GameState # ... def enter RubyProf . start if ENV [ 'ENABLE_PROFILING' ] end def leave if ENV [ 'ENABLE_PROFILING' ] result = RubyProf . stop printer = RubyProf :: FlatPrinter . new ( result ) printer . print ( STDOUT ) end end def button_down ( id ) # ... if id == Gosu :: KbQ leave $window . close end end # ... end

Now, to enable profiling, simply start your game with ENABLE_PROFILING=1 environmental variable, like this:

$ ENABLE_PROFILING = 1 ruby-prof 03-prototype/main.rb

Adjusting Game Speed For Variable Performance

You should have noticed that our optimized Tanks prototype runs way too fast. Tanks and bullets should travel same distance no matter how fast or slow the code is.

One would expect Gosu::Window#update_interval to be designed exactly for that purpose, but it returns 16.6666 in both original and optimized version of the prototype, so you can guess it is the desired interval, not the actual one.

To find out actual update interval, we will use Gosu.milliseconds and calculate it ourselves. To do that, we will introduce Game#track_update_interval that will be called in GameWindow#update , and Game#update_interval which will retrieve actual update interval, so we can use it to adjust our run speed.

We will also add Game#adjust_speed method that will take arbitrary speed value and shift it so is as fast as it was when the game was running at 30 FPS. The formula is simple, if 60 FPS expects to call Gosu::Window#update every 16.66 ms, our speed adjustment will divide actual update rate from 33.33 , which roughly equals to 16.66 * 2 . So, if bullet would fly 100 pixels per update in 30 FPS, adjusted speed will change it to 50 pixels at 60 FPS.

Here is the implementation:

# 04-prototype-optimized/main.rb module Game # ... def self . track_update_interval now = Gosu . milliseconds @update_interval = ( now - ( @last_update ||= 0 )) . to_f @last_update = now end def self . update_interval @update_interval ||= $window . update_interval end def self . adjust_speed ( speed ) speed * update_interval / 33 . 33 end end # 04-prototype-optimized/game_window.rb class GameWindow < Gosu :: Window # ... def update Game . track_update_interval @state . update end # ... end

Now, to fix that speed problem, we will need to apply Game.adjust_speed to tank, bullet and camera movements.

Here are all the changes needed to make our game run at roughly same speed in different conditions:

# 04-prototype-optimized/entities/tank.rb class Tank # ... def update ( camera ) # ... shift = Game . adjust_speed ( speed ) new_x -= shift if $window . button_down? ( Gosu :: KbA ) new_x += shift if $window . button_down? ( Gosu :: KbD ) new_y -= shift if $window . button_down? ( Gosu :: KbW ) new_y += shift if $window . button_down? ( Gosu :: KbS ) # ... end # ... end # 04-prototype-optimized/entities/bullet.rb class Bullet # ... def update # ... fly_speed = Game . adjust_speed ( @speed ) fly_distance = ( Gosu . milliseconds - @fired_at ) * 0 . 001 * fly_speed @x , @y = point_at_distance ( fly_distance ) # ... end # ... end # 04-prototype-optimized/entities/camera.rb class Camera # ... def update shift = Game . adjust_speed ( @target . speed ) @x += shift if @x < @target . x - $window . width / 4 @x -= shift if @x > @target . x + $window . width / 4 @y += shift if @y < @target . y - $window . height / 4 @y -= shift if @y > @target . y + $window . height / 4 zoom_delta = @zoom > 0 ? 0 . 01 : 1 . 0 zoom_delta = Game . adjust_speed ( zoom_delta ) # ... end # ... end

There is one more trick to make the game playable even at very low FPS. You can simulate such conditions by adding sleep 0.3 to GameWindow#draw method. At that framerate game cursor is very unresponsive, so you may want to start showing native mouse cursor when things get ugly, i.e. when update interval exceeds 200 milliseconds:

# 04-prototype-optimized/game_window.rb class GameWindow < Gosu :: Window # ... def needs_cursor? Game . update_interval > 200 end # ... end

Frame Skipping

You will see strange things happening at very low framerates. For example, bullet explosions are showing up frame by frame, so explosion speed seems way too slow and unrealistic. To avoid that, we will modify our Explosion class to employ frame skipping if update rate is too slow:

# 04-prototype-optimized/explosion.rb class Explosion FRAME_DELAY = 16 . 66 # ms # ... def update advance_frame end def done? @done ||= @current_frame >= animation . size end # ... private # ... def advance_frame now = Gosu . milliseconds delta = now - ( @last_frame ||= now ) if delta > FRAME_DELAY @last_frame = now end @current_frame += ( delta / FRAME_DELAY ) . floor end end

Now our prototype is playable even at lower frame rates.

Refactoring The Prototype

At this point you may be thinking where to go next. We want to implement enemies, collision detection and AI, but design of current prototype is already limiting. Code is becoming tightly coupled, there is no clean separation between different domains.

If we were to continue building on top of our prototype, things would get ugly quickly. Thus we will untangle the spaghetti and rewrite some parts from scratch to achieve elegance.

Game Programming Patterns

I would like to tip my hat to Robert Nystrom, who wrote this amazing book called Game Programming Patterns. The book is available online for free, it is a relatively quick read - I’ve devoured it with pleasure in roughly 4 hours. If you are guessing that this chapter is inspired by that book, you are absolutely right.

Component pattern is especially noteworthy. We will be using it to do major housekeeping, and it is great time to do so, because we haven’t implemented much of the game yet.

What Is Wrong With Current Design

Until this point we have been building the code in monolithic fashion. Tank class holds the code that:

Loads all ground unit sprites. If some other class handled it, we could reuse the code to load other units. Handles sound effects. Uses Gosu::Song for moving sounds. That limits only one tank movement sound per whole game. Basically, we abused Gosu here. Handles keyboard and mouse. If we were to create AI that controls the tank, we would not be able to reuse Tank class because of this. Draws graphics on screen. Calculates physical properties, like speed, acceleration. Detects movement collisions.

Bullet is not perfect either:

It renders it’s graphics. It handles it’s movement trajectories and other physics. It treats Explosion as part of it’s own lifecycle. Draws graphics on screen. Handles sound effects.

Even the relatively small Explosion class is too monolithic:

It loads it’s graphics. It handles rendering, animation and frame skipping It loads and plays it’s sound effects.

Decoupling Using Component Pattern

Best design separates concerns in code so that everything has it’s own place, and every class handles only one thing. Let’s try splitting up Tank class into components that handle specific domains:

We will introduce GameObject class will contain shared functionality for all game objects ( Tank , Bullet , Explosion ), each of them would have it’s own set of components. Every component will have it’s parent object, so it will be able to interact with it, change it’s attributes, or possibly invoke other components if it comes to that.

All these objects will be held within ObjectPool , which would not care to know if object is a tank or a bullet. Purpose of ObjectPool is a little different in Ruby, since GC will take care of memory fragmentation for us, but we still need a single place that knows about every object in the game.

PlayState would then iterate through @object_pool.objects and invoke update and draw methods.

Now, let’s begin by implementing base class for GameObject :

05-refactor/entities/game_object.rb 1 class GameObject 2 def initialize ( object_pool ) 3 @components = [] 4 @object_pool = object_pool 5 @object_pool . objects << self 6 end 7 8 def components 9 @components 10 end 11 12 def update 13 @components . map ( & :update ) 14 end 15 16 def draw ( viewport ) 17 @components . each { | c | c . draw ( viewport ) } 18 end 19 20 def removable? 21 @removable 22 end 23 24 def mark_for_removal 25 @removable = true 26 end 27 28 protected 29 30 def object_pool 31 @object_pool 32 end 33 end

When GameObject is initialized, it registers itself with ObjectPool and prepares empty @components array. Concrete GameObject classes should initialize Components so that array would not be empty.

update and draw methods would cycle through @components and delegate those calls to each of them in a sequence. It is important to update all components first, and only then draw them. Keep in mind that @components array order has significance. First elements will always be updated and drawn before last ones.

We will also provide removable? method that would return true for objects that mark_for_removal was invoked on. This way we will be able to weed out old bullets and explosions and feed them to GC.

Next up, base Component class:

05-refactor/entities/components/component.rb 1 class Component 2 def initialize ( game_object = nil ) 3 self . object = game_object 4 end 5 6 def update 7 # override 8 end 9 10 def draw ( viewport ) 11 # override 12 end 13 14 protected 15 16 def object = ( obj ) 17 if obj 18 @object = obj 19 obj . components << self 20 end 21 end 22 23 def x 24 @object . x 25 end 26 27 def y 28 @object . y 29 end 30 31 def object 32 @object 33 end 34 end

It registers itself with GameObject#components , provides some protected methods to access parent object and it’s most often called properties - x and y .

Refactoring Explosion

Explosion was probably the smallest class, so we will extract it’s components first.

05-refactor/entities/explosion.rb 1 class Explosion < GameObject 2 attr_accessor :x , :y 3 4 def initialize ( object_pool , x , y ) 5 super ( object_pool ) 6 @x , @y = x , y 7 ExplosionGraphics . new ( self ) 8 ExplosionSounds . play 9 end 10 end

It is much cleaner than before. ExplosionGraphics will be a Component that handles animation, and ExplosionSounds will play a sound.

05-refactor/entities/components/explosion_graphics.rb 1 class ExplosionGraphics < Component 2 FRAME_DELAY = 16 . 66 # ms 3 4 def initialize ( game_object ) 5 super 6 @current_frame = 0 7 end 8 9 def draw ( viewport ) 10 image = current_frame 11 image . draw ( 12 x - image . width / 2 + 3 , 13 y - image . height / 2 - 35 , 14 20 ) 15 end 16 17 def update 18 now = Gosu . milliseconds 19 delta = now - ( @last_frame ||= now ) 20 if delta > FRAME_DELAY 21 @last_frame = now 22 end 23 @current_frame += ( delta / FRAME_DELAY ) . floor 24 object . mark_for_removal if done? 25 end 26 27 private 28 29 def current_frame 30 animation [ @current_frame % animation . size ] 31 end 32 33 def done? 34 @done ||= @current_frame >= animation . size 35 end 36 37 def animation 38 @@animation ||= 39 Gosu :: Image . load_tiles ( 40 $window , Utils . media_path ( 'explosion.png' ), 41 128 , 128 , false ) 42 end 43 end

Everything that is related to animating the explosion is now clearly separated. mark_for_removal is called on the explosion after it’s animation is done.

05-refactor/entities/components/explosion_sounds.rb 1 class ExplosionSounds 2 class << self 3 def play 4 sound . play 5 end 6 7 private 8 9 def sound 10 @@sound ||= Gosu :: Sample . new ( 11 $window , Utils . media_path ( 'explosion.mp3' )) 12 end 13 end 14 end

Since explosion sounds are triggered only once, when it starts to explode, ExplosionSounds is a static class with play method.

Refactoring Bullet

Now, let’s go up a little and reimplement our Bullet :

05-refactor/entities/bullet.rb 1 class Bullet < GameObject 2 attr_accessor :x , :y , :target_x , :target_y , :speed , :fired_at 3 4 def initialize ( object_pool , source_x , source_y , target_x , target_y ) 5 super ( object_pool ) 6 @x , @y = source_x , source_y 7 @target_x , @target_y = target_x , target_y 8 BulletPhysics . new ( self ) 9 BulletGraphics . new ( self ) 10 BulletSounds . play 11 end 12 13 def explode 14 Explosion . new ( object_pool , @x , @y ) 15 mark_for_removal 16 end 17 18 def fire ( speed ) 19 @speed = speed 20 @fired_at = Gosu . milliseconds 21 end 22 end

All physics, graphics and sounds are extracted into individual components, and instead of managing Explosion , it just registers a new Explosion with ObjectPool and marks itself for removal in explode method.

05-refactor/entities/components/bullet_physics.rb 1 class BulletPhysics < Component 2 START_DIST = 20 3 MAX_DIST = 300 4 5 def initialize ( game_object ) 6 super 7 object . x , object . y = point_at_distance ( START_DIST ) 8 if trajectory_length > MAX_DIST 9 object . target_x , object . target_y = point_at_distance ( MAX_DIST ) 10 end 11 end 12 13 def update 14 fly_speed = Utils . adjust_speed ( object . speed ) 15 fly_distance = ( Gosu . milliseconds - object . fired_at ) * 0 . 001 * fly_speed 16 object . x , object . y = point_at_distance ( fly_distance ) 17 object . explode if arrived? 18 end 19 20 def trajectory_length 21 d_x = object . target_x - x 22 d_y = object . target_y - y 23 Math . sqrt ( d_x * d_x + d_y * d_y ) 24 end 25 26 def point_at_distance ( distance ) 27 if distance > trajectory_length 28 return [ object . target_x , object . target_y ] 29 end 30 distance_factor = distance . to_f / trajectory_length 31 p_x = x + ( object . target_x - x ) * distance_factor 32 p_y = y + ( object . target_y - y ) * distance_factor 33 [ p_x , p_y ] 34 end 35 36 private 37 38 def arrived? 39 x == object . target_x && y == object . target_y 40 end 41 end

BulletPhysics is where the most of Bullet ended up at. It does all the calculations and triggers Bullet#explode when ready. When we will be implementing collision detection, the implementation will go somewhere here.

05-refactor/entities/components/bullet_graphics.rb 1 class BulletGraphics < Component 2 COLOR = Gosu :: Color :: BLACK 3 4 def draw ( viewport ) 5 $window . draw_quad ( x - 2 , y - 2 , COLOR , 6 x + 2 , y - 2 , COLOR , 7 x - 2 , y + 2 , COLOR , 8 x + 2 , y + 2 , COLOR , 9 1 ) 10 end 11 12 end

After pulling away Bullet graphics code, it looks very small and elegant. We will probably never have to edit anything here again.

05-refactor/entities/components/bullet_sounds.rb 1 class BulletSounds 2 class << self 3 def play 4 sound . play 5 end 6 7 private 8 9 def sound 10 @@sound ||= Gosu :: Sample . new ( 11 $window , Utils . media_path ( 'fire.mp3' )) 12 end 13 end 14 end

Just like ExplosionSounds , BulletSounds are stateless and static. We could make it just like a regular component, but consider it our little optimization.

Refactoring Tank

Time to take a look at freshly decoupled Tank :

05-refactor/entities/tank.rb 1 class Tank < GameObject 2 SHOOT_DELAY = 500 3 attr_accessor :x , :y , :throttle_down , :direction , :gun_angle , :sounds , :physics 4 5 def initialize ( object_pool , input ) 6 super ( object_pool ) 7 @input = input 8 @input . control ( self ) 9 @physics = TankPhysics . new ( self , object_pool ) 10 @graphics = TankGraphics . new ( self ) 11 @sounds = TankSounds . new ( self ) 12 @direction = @gun_angle = 0 . 0 13 end 14 15 def shoot ( target_x , target_y ) 16 if Gosu . milliseconds - ( @last_shot || 0 ) > SHOOT_DELAY 17 @last_shot = Gosu . milliseconds 18 Bullet . new ( object_pool , @x , @y , target_x , target_y ) . fire ( 100 ) 19 end 20 end 21 end

Tank class was reduced over 5 times. We could go further and extract Gun component, but for now it’s simple enough already. Now, the components.

05-refactor/entities/components/tank_physics.rb 1 class TankPhysics < Component 2 attr_accessor :speed 3 4 def initialize ( game_object , object_pool ) 5 super ( game_object ) 6 @object_pool = object_pool 7 @map = object_pool . map 8 game_object . x , game_object . y = @map . find_spawn_point 9 @speed = 0 . 0 10 end 11 12 def can_move_to? ( x , y ) 13 @map . can_move_to? ( x , y ) 14 end 15 16 def moving? 17 @speed > 0 18 end 19 20 def update 21 if object . throttle_down 22 accelerate 23 else 24 decelerate 25 end 26 if @speed > 0 27 new_x , new_y = x , y 28 shift = Utils . adjust_speed ( @speed ) 29 case @object . direction . to_i 30 when 0 31 new_y -= shift 32 when 45 33 new_x += shift 34 new_y -= shift 35 when 90 36 new_x += shift 37 when 135 38 new_x += shift 39 new_y += shift 40 when 180 41 new_y += shift 42 when 225 43 new_y += shift 44 new_x -= shift 45 when 270 46 new_x -= shift 47 when 315 48 new_x -= shift 49 new_y -= shift 50 end 51 if can_move_to? ( new_x , new_y ) 52 object . x , object . y = new_x , new_y 53 else 54 object . sounds . collide if @speed > 1 55 @speed = 0 . 0 56 end 57 end 58 end 59 60 private 61 62 def accelerate 63 @speed += 0 . 08 if @speed < 5 64 end 65 66 def decelerate 67 @speed -= 0 . 5 if @speed > 0 68 @speed = 0 . 0 if @speed < 0 . 01 # damp 69 end 70 end

While we had to rip player input away from it’s movement, we got ourselves a benefit - tank now both accelerates and decelerates. When directional buttons are no longer pressed, tank keeps moving in last direction, but quickly decelerates and stops. Another addition that would have been more difficult to implement on previous Tank is collision sound. When Tank abruptly stops by hitting something (for now it’s only water), collision sound is played. We will have to fix that, because metal bang is not appropriate when you stop on the edge of a river, but we now did it for the sake of science.

05-refactor/entities/components/tank_graphics.rb 1 class TankGraphics < Component 2 def initialize ( game_object ) 3 super ( game_object ) 4 @body = units . frame ( 'tank1_body.png' ) 5 @shadow = units . frame ( 'tank1_body_shadow.png' ) 6 @gun = units . frame ( 'tank1_dualgun.png' ) 7 end 8 9 def draw ( viewport ) 10 @shadow . draw_rot ( x - 1 , y - 1 , 0 , object . direction ) 11 @body . draw_rot ( x , y , 1 , object . direction ) 12 @gun . draw_rot ( x , y , 2 , object . gun_angle ) 13 end 14 15 private 16 17 def units 18 @@units = Gosu :: TexturePacker . load_json ( 19 $window , Utils . media_path ( 'ground_units.json' ), :precise ) 20 end 21 end

Again, graphics are neatly packed and separated from everything else. Eventually we should optimize draw to take viewport into consideration, but it’s good enough for now, especially when we have only one tank in the game.

05-refactor/entities/components/tank_sounds.rb 1 class TankSounds < Component 2 def update 3 if object . physics . moving? 4 if @driving && @driving . paused? 5 @driving . resume 6 elsif @driving . nil? 7 @driving = driving_sound . play ( 1 , 1 , true ) 8 end 9 else 10 if @driving && @driving . playing? 11 @driving . pause 12 end 13 end 14 end 15 16 def collide 17 crash_sound . play ( 1 , 0 . 25 , false ) 18 end 19 20 private 21 22 def driving_sound 23 @@driving_sound ||= Gosu :: Sample . new ( 24 $window , Utils . media_path ( 'tank_driving.mp3' )) 25 end 26 27 def crash_sound 28 @@crash_sound ||= Gosu :: Sample . new ( 29 $window , Utils . media_path ( 'crash.ogg' )) 30 end 31 end

Unlike Explosion and Bullet , Tank sounds are stateful. We have to keep track of tank_driving.mp3 , which is no longer Gosu::Song , but Gosu::Sample , like it should have been.

When Gosu::Sample#play is invoked, Gosu::SampleInstance is returned, and we have full control over it. Now we are ready to play sounds for more than one tank at once.

05-refactor/entities/components/player_input.rb 1 class PlayerInput < Component 2 def initialize ( camera ) 3 super ( nil ) 4 @camera = camera 5 end 6 7 def control ( obj ) 8 self . object = obj 9 end 10 11 def update 12 d_x , d_y = @camera . target_delta_on_screen 13 atan = Math . atan2 (( $window . width / 2 ) - d_x - $window . mouse_x , 14 ( $window . height / 2 ) - d_y - $window . mouse_y ) 15 object . gun_angle = - atan * 180 / Math :: PI 16 motion_buttons = [ Gosu :: KbW , Gosu :: KbS , Gosu :: KbA , Gosu :: KbD ] 17 18 if any_button_down? ( * motion_buttons ) 19 object . throttle_down = true 20 object . direction = change_angle ( object . direction , * motion_buttons ) 21 else 22 object . throttle_down = false 23 end 24 25 if Utils . button_down? ( Gosu :: MsLeft ) 26 object . shoot ( * @camera . mouse_coords ) 27 end 28 end 29 30 private 31 32 def any_button_down? ( * buttons ) 33 buttons . each do | b | 34 return true if Utils . button_down? ( b ) 35 end 36 false 37 end 38 39 def change_angle ( previous_angle , up , down , right , left ) 40 if Utils . button_down? ( up ) 41 angle = 0 . 0 42 angle += 45 . 0 if Utils . button_down? ( left ) 43 angle -= 45 . 0 if Uti